Initial commit: Lead Scraper - Google Maps
This commit is contained in:
2
.env.example
Normal file
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
APIFY_API_TOKEN=your_apify_token_here
|
||||
PORT=8086
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
.env
|
||||
data/
|
||||
.DS_Store
|
||||
52
BRIEF.md
Normal file
52
BRIEF.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Lead Gen Scraper — Google Maps
|
||||
|
||||
## Concepto
|
||||
Aplicación web que scrapea datos de Google Maps usando la API de Apify. Permite buscar negocios por keyword, ciudad y estado/provincia, mostrando resultados en tabla con panel de detalle.
|
||||
|
||||
## Stack (adaptado a nuestro entorno)
|
||||
- **Backend:** Node.js + Express
|
||||
- **Templates:** EJS
|
||||
- **Estilos:** Tailwind CSS via CDN (no build step)
|
||||
- **Scraping:** `apify-client` NPM con actor `compass/crawler-google-places`
|
||||
- **Database:** JSON file-backed store (portable, sin dependencia Replit)
|
||||
- **Deploy:** VPS (systemd, como el resto de proyectos)
|
||||
|
||||
## Funcionalidades
|
||||
|
||||
### Core
|
||||
1. **Búsqueda** — Formulario con keyword, ciudad, estado, max resultados
|
||||
2. **Resultados** — Tabla con nombre, teléfono, email, web, reviews + panel detalle slide-in
|
||||
3. **Historial** — Búsquedas guardadas, ordenadas por fecha, con acciones View/Delete
|
||||
4. **Leads** — Bookmark de negocios, página dedicada con gestión completa
|
||||
|
||||
### UI
|
||||
- Sidebar fija oscura (dark olive #1a1f1e + green accent #4a7c59)
|
||||
- Filter bar horizontal superior
|
||||
- Click en fila → panel detalle deslizante derecha
|
||||
- Loading states (Apify tarda 30-120s)
|
||||
- Empty states para todas las páginas
|
||||
- Error handling con mensajes claros
|
||||
|
||||
### Gestión de Leads
|
||||
- Bookmark toggle (save/unsave) desde tabla y panel
|
||||
- Sync bidireccional tabla↔panel
|
||||
- Remove from Leads con actualización en vivo
|
||||
- Persistencia en JSON local
|
||||
|
||||
### Detalles técnicos
|
||||
- Server timeout 5 min (búsquedas largas)
|
||||
- Formato búsqueda: "{keyword} in {city}, {state}"
|
||||
- Keys: `search:{timestamp}`, `lead:{timestamp}`
|
||||
- dotenv para API token
|
||||
- .gitignore: node_modules, .env, data/, .DS_Store
|
||||
|
||||
## Adaptaciones vs briefing original
|
||||
- ❌ Replit Database → ✅ JSON file store (siempre)
|
||||
- ❌ Replit deploy → ✅ VPS systemd
|
||||
- ❌ Replit Secrets → ✅ .env file
|
||||
- Se mantiene opción de empaquetado zip para portabilidad
|
||||
|
||||
## Notas
|
||||
- Apify Actor ID: nwua9Gu5YrADL7ZDj
|
||||
- El usuario necesitará su propio API token de Apify
|
||||
- Puerto: 8086 (siguiente disponible en VPS)
|
||||
1630
package-lock.json
generated
Normal file
1630
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
package.json
Normal file
16
package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "lead-scraper",
|
||||
"version": "1.0.0",
|
||||
"description": "Google Maps Lead Gen Scraper using Apify",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "node --watch server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"apify-client": "^2.9.3",
|
||||
"dotenv": "^16.4.1",
|
||||
"express": "^4.18.2",
|
||||
"ejs": "^3.1.9"
|
||||
}
|
||||
}
|
||||
192
public/js/app.js
Normal file
192
public/js/app.js
Normal file
@@ -0,0 +1,192 @@
|
||||
// State
|
||||
let currentResults = [];
|
||||
|
||||
// DOM
|
||||
const searchForm = document.getElementById('searchForm');
|
||||
const searchBtn = document.getElementById('searchBtn');
|
||||
const loadingState = document.getElementById('loadingState');
|
||||
const errorState = document.getElementById('errorState');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
const resultsArea = document.getElementById('resultsArea');
|
||||
const resultsBody = document.getElementById('resultsBody');
|
||||
const resultsQuery = document.getElementById('resultsQuery');
|
||||
const resultsCount = document.getElementById('resultsCount');
|
||||
const detailPanel = document.getElementById('detailPanel');
|
||||
const recentSearches = document.getElementById('recentSearches');
|
||||
|
||||
function showState(state) {
|
||||
[loadingState, errorState, emptyState, resultsArea].forEach(el => el?.classList.add('hidden'));
|
||||
if (recentSearches) recentSearches.classList.toggle('hidden', state !== 'empty');
|
||||
state === 'loading' && loadingState?.classList.remove('hidden');
|
||||
state === 'error' && errorState?.classList.remove('hidden');
|
||||
state === 'empty' && emptyState?.classList.remove('hidden');
|
||||
state === 'results' && resultsArea?.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Search
|
||||
searchForm?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(searchForm);
|
||||
const data = Object.fromEntries(fd);
|
||||
|
||||
showState('loading');
|
||||
searchBtn.disabled = true;
|
||||
searchBtn.innerHTML = '<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg> Searching...';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error || 'Search failed');
|
||||
|
||||
currentResults = json.results;
|
||||
renderResults(json.results, json.query);
|
||||
} catch (err) {
|
||||
document.getElementById('errorMsg').textContent = err.message;
|
||||
showState('error');
|
||||
} finally {
|
||||
searchBtn.disabled = false;
|
||||
searchBtn.innerHTML = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> Search';
|
||||
}
|
||||
});
|
||||
|
||||
function renderResults(results, query) {
|
||||
resultsQuery.textContent = query;
|
||||
resultsCount.textContent = results.length;
|
||||
resultsBody.innerHTML = '';
|
||||
|
||||
results.forEach((r, i) => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = 'hover:bg-card-hover transition-colors cursor-pointer';
|
||||
tr.onclick = () => showDetail(r);
|
||||
tr.innerHTML = `
|
||||
<td class="px-4 py-3">
|
||||
<button onclick="event.stopPropagation(); toggleBookmark('${r.placeId}', ${i})"
|
||||
class="bookmark-btn text-gray-500 hover:text-accent transition-colors" data-place="${r.placeId}">
|
||||
${r.isBookmarked
|
||||
? '<svg class="w-4 h-4 text-accent fill-accent" viewBox="0 0 24 24"><path d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"/></svg>'
|
||||
: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"/></svg>'}
|
||||
</button>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<p class="font-medium text-white">${esc(r.title)}</p>
|
||||
<p class="text-xs text-gray-500">${esc(r.categoryName || r.address || '')}</p>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-300">${esc(r.phone) || '—'}</td>
|
||||
<td class="px-4 py-3 text-gray-300">${esc(r.email) || '—'}</td>
|
||||
<td class="px-4 py-3">${r.website
|
||||
? `<a href="${esc(r.website)}" target="_blank" onclick="event.stopPropagation()" class="text-accent hover:underline truncate block max-w-[180px]">${esc(r.website.replace(/^https?:\/\//, '').replace(/\/$/, ''))}</a>`
|
||||
: '—'}</td>
|
||||
<td class="px-4 py-3">${r.totalScore ? `<span class="text-yellow-400">★</span> ${r.totalScore.toFixed(1)}` : '—'}</td>
|
||||
<td class="px-4 py-3 text-gray-400">${r.reviewsCount || '—'}</td>
|
||||
`;
|
||||
resultsBody.appendChild(tr);
|
||||
});
|
||||
|
||||
showState('results');
|
||||
}
|
||||
|
||||
function showDetail(place) {
|
||||
detailPanel.innerHTML = `
|
||||
<div class="slide-in">
|
||||
<div class="p-4 border-b border-white/10 flex items-center justify-between sticky top-0 bg-sidebar z-10">
|
||||
<h3 class="font-bold text-white truncate">${esc(place.title)}</h3>
|
||||
<button onclick="detailPanel.classList.add('hidden')" class="text-gray-400 hover:text-white">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
${place.imageUrl ? `<img src="${esc(place.imageUrl)}" class="w-full h-48 object-cover" onerror="this.remove()">` : ''}
|
||||
<div class="p-4 space-y-4">
|
||||
<button onclick="toggleBookmarkFromPanel('${place.placeId}')"
|
||||
class="w-full py-2 rounded-lg text-sm font-medium transition-colors panel-bookmark-btn
|
||||
${place.isBookmarked ? 'bg-accent text-white' : 'bg-accent/20 text-accent hover:bg-accent/30'}" data-place="${place.placeId}">
|
||||
${place.isBookmarked ? '★ Saved as Lead' : '☆ Save as Lead'}
|
||||
</button>
|
||||
|
||||
<div class="space-y-3 text-sm">
|
||||
${place.categoryName ? `<div><span class="text-gray-500">Category</span><p class="text-white">${esc(place.categoryName)}</p></div>` : ''}
|
||||
${place.address ? `<div><span class="text-gray-500">Address</span><p class="text-white">${esc(place.address)}</p></div>` : ''}
|
||||
${place.phone ? `<div><span class="text-gray-500">Phone</span><p class="text-white"><a href="tel:${esc(place.phone)}" class="text-accent hover:underline">${esc(place.phone)}</a></p></div>` : ''}
|
||||
${place.email ? `<div><span class="text-gray-500">Email</span><p class="text-white"><a href="mailto:${esc(place.email)}" class="text-accent hover:underline">${esc(place.email)}</a></p></div>` : ''}
|
||||
${place.website ? `<div><span class="text-gray-500">Website</span><p><a href="${esc(place.website)}" target="_blank" class="text-accent hover:underline break-all">${esc(place.website)}</a></p></div>` : ''}
|
||||
${place.totalScore ? `<div><span class="text-gray-500">Rating</span><p class="text-white"><span class="text-yellow-400">★</span> ${place.totalScore.toFixed(1)} <span class="text-gray-500">(${place.reviewsCount} reviews)</span></p></div>` : ''}
|
||||
${place.url ? `<div><a href="${esc(place.url)}" target="_blank" class="text-accent hover:underline text-xs">View on Google Maps →</a></div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
detailPanel.classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function toggleBookmark(placeId, index) {
|
||||
const place = currentResults[index];
|
||||
if (!place) return;
|
||||
|
||||
if (place.isBookmarked) {
|
||||
await fetch('/api/leads/' + encodeURIComponent(placeId), { method: 'DELETE' });
|
||||
place.isBookmarked = false;
|
||||
} else {
|
||||
await fetch('/api/leads', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ place }),
|
||||
});
|
||||
place.isBookmarked = true;
|
||||
}
|
||||
updateBookmarkUI(placeId, place.isBookmarked);
|
||||
}
|
||||
|
||||
async function toggleBookmarkFromPanel(placeId) {
|
||||
const place = currentResults.find(r => r.placeId === placeId);
|
||||
if (!place) return;
|
||||
const index = currentResults.indexOf(place);
|
||||
await toggleBookmark(placeId, index);
|
||||
}
|
||||
|
||||
function updateBookmarkUI(placeId, isBookmarked) {
|
||||
// Update table button
|
||||
document.querySelectorAll(`.bookmark-btn[data-place="${placeId}"]`).forEach(btn => {
|
||||
btn.innerHTML = isBookmarked
|
||||
? '<svg class="w-4 h-4 text-accent fill-accent" viewBox="0 0 24 24"><path d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"/></svg>'
|
||||
: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"/></svg>';
|
||||
});
|
||||
// Update panel button
|
||||
document.querySelectorAll(`.panel-bookmark-btn[data-place="${placeId}"]`).forEach(btn => {
|
||||
btn.className = `w-full py-2 rounded-lg text-sm font-medium transition-colors panel-bookmark-btn ${isBookmarked ? 'bg-accent text-white' : 'bg-accent/20 text-accent hover:bg-accent/30'}`;
|
||||
btn.textContent = isBookmarked ? '★ Saved as Lead' : '☆ Save as Lead';
|
||||
});
|
||||
}
|
||||
|
||||
// Load saved search
|
||||
async function loadSearch(key) {
|
||||
showState('loading');
|
||||
try {
|
||||
const res = await fetch('/api/search/' + encodeURIComponent(key));
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
currentResults = data.results || [];
|
||||
renderResults(currentResults, data.query);
|
||||
} catch (err) {
|
||||
document.getElementById('errorMsg').textContent = err.message;
|
||||
showState('error');
|
||||
}
|
||||
}
|
||||
|
||||
// Check URL for view param
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('view')) loadSearch(params.get('view'));
|
||||
|
||||
// Close detail panel on Escape
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') detailPanel?.classList.add('hidden');
|
||||
});
|
||||
|
||||
function esc(str) {
|
||||
if (!str) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
151
server.js
Normal file
151
server.js
Normal file
@@ -0,0 +1,151 @@
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const { ApifyClient } = require('apify-client');
|
||||
const store = require('./store');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 8086;
|
||||
|
||||
app.set('view engine', 'ejs');
|
||||
app.set('views', path.join(__dirname, 'views'));
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Increase timeout for long Apify runs
|
||||
app.use((req, res, next) => {
|
||||
res.setTimeout(300000); // 5 min
|
||||
next();
|
||||
});
|
||||
|
||||
// --- Pages ---
|
||||
app.get('/', (req, res) => {
|
||||
const searches = store.getSearches();
|
||||
const searchList = Object.entries(searches)
|
||||
.map(([key, val]) => ({ key, ...val }))
|
||||
.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0));
|
||||
res.render('index', { searches: searchList, results: null, query: null, error: null });
|
||||
});
|
||||
|
||||
app.get('/leads', (req, res) => {
|
||||
const leads = store.getLeads();
|
||||
const leadList = Object.entries(leads)
|
||||
.map(([key, val]) => ({ key, ...val }))
|
||||
.sort((a, b) => (b.savedAt || 0) - (a.savedAt || 0));
|
||||
res.render('leads', { leads: leadList });
|
||||
});
|
||||
|
||||
app.get('/history', (req, res) => {
|
||||
const searches = store.getSearches();
|
||||
const searchList = Object.entries(searches)
|
||||
.map(([key, val]) => ({ key, ...val }))
|
||||
.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0));
|
||||
res.render('history', { searches: searchList });
|
||||
});
|
||||
|
||||
// --- API ---
|
||||
app.post('/api/search', async (req, res) => {
|
||||
const { keyword, city, state, maxResults } = req.body;
|
||||
if (!keyword || !city) return res.status(400).json({ error: 'Keyword and city are required' });
|
||||
|
||||
const token = process.env.APIFY_API_TOKEN;
|
||||
if (!token) return res.status(500).json({ error: 'APIFY_API_TOKEN not configured. Add it to .env file.' });
|
||||
|
||||
const searchQuery = `${keyword} in ${city}${state ? ', ' + state : ''}`;
|
||||
const max = parseInt(maxResults) || 20;
|
||||
|
||||
try {
|
||||
const client = new ApifyClient({ token });
|
||||
const run = await client.actor('nwua9Gu5YrADL7ZDj').call({
|
||||
searchStringsArray: [searchQuery],
|
||||
maxCrawledPlacesPerSearch: max,
|
||||
language: 'en',
|
||||
deeperCityScrape: false,
|
||||
});
|
||||
|
||||
const { items } = await client.dataset(run.defaultDatasetId).listItems();
|
||||
|
||||
const results = items.map(item => ({
|
||||
placeId: item.placeId || item.cid || `place_${Date.now()}_${Math.random().toString(36).slice(2,8)}`,
|
||||
title: item.title || '',
|
||||
phone: item.phone || '',
|
||||
website: item.website || '',
|
||||
url: item.url || '',
|
||||
address: item.address || item.street || '',
|
||||
city: item.city || city,
|
||||
state: item.state || state || '',
|
||||
totalScore: item.totalScore || 0,
|
||||
reviewsCount: item.reviewsCount || 0,
|
||||
categoryName: item.categoryName || '',
|
||||
email: extractEmail(item),
|
||||
imageUrl: item.imageUrl || '',
|
||||
openingHours: item.openingHours || [],
|
||||
additionalInfo: item.additionalInfo || {},
|
||||
isBookmarked: store.isLead(item.placeId || item.cid || ''),
|
||||
}));
|
||||
|
||||
const key = `search:${Date.now()}`;
|
||||
store.saveSearch(key, {
|
||||
query: searchQuery,
|
||||
keyword, city, state,
|
||||
maxResults: max,
|
||||
resultsCount: results.length,
|
||||
timestamp: Date.now(),
|
||||
results,
|
||||
});
|
||||
|
||||
res.json({ success: true, key, results, query: searchQuery });
|
||||
} catch (err) {
|
||||
console.error('Apify error:', err);
|
||||
res.status(500).json({ error: `Scraping failed: ${err.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/search/:key', (req, res) => {
|
||||
const search = store.getSearch(req.params.key);
|
||||
if (!search) return res.status(404).json({ error: 'Search not found' });
|
||||
// Refresh bookmark status
|
||||
if (search.results) {
|
||||
search.results = search.results.map(r => ({
|
||||
...r,
|
||||
isBookmarked: store.isLead(r.placeId),
|
||||
}));
|
||||
}
|
||||
res.json(search);
|
||||
});
|
||||
|
||||
app.delete('/api/search/:key', (req, res) => {
|
||||
store.deleteSearch(req.params.key);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.post('/api/leads', (req, res) => {
|
||||
const { place } = req.body;
|
||||
if (!place || !place.placeId) return res.status(400).json({ error: 'Invalid place data' });
|
||||
const key = `lead:${Date.now()}`;
|
||||
store.saveLead(key, { ...place, savedAt: Date.now() });
|
||||
res.json({ success: true, key });
|
||||
});
|
||||
|
||||
app.delete('/api/leads/:placeId', (req, res) => {
|
||||
const leads = store.getLeads();
|
||||
const entry = Object.entries(leads).find(([, v]) => v.placeId === req.params.placeId);
|
||||
if (entry) store.deleteLead(entry[0]);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.get('/api/leads/check/:placeId', (req, res) => {
|
||||
res.json({ isLead: store.isLead(req.params.placeId) });
|
||||
});
|
||||
|
||||
function extractEmail(item) {
|
||||
// Try to find email in various Apify output fields
|
||||
if (item.email) return item.email;
|
||||
if (item.emails && item.emails.length) return item.emails[0];
|
||||
const text = JSON.stringify(item.additionalInfo || {});
|
||||
const match = text.match(/[\w.-]+@[\w.-]+\.\w+/);
|
||||
return match ? match[0] : '';
|
||||
}
|
||||
|
||||
app.listen(PORT, () => console.log(`Lead Scraper running on http://localhost:${PORT}`));
|
||||
55
store.js
Normal file
55
store.js
Normal file
@@ -0,0 +1,55 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const DATA_DIR = path.join(__dirname, 'data');
|
||||
const SEARCHES_FILE = path.join(DATA_DIR, 'searches.json');
|
||||
const LEADS_FILE = path.join(DATA_DIR, 'leads.json');
|
||||
|
||||
function ensureDataDir() {
|
||||
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function readJSON(file) {
|
||||
ensureDataDir();
|
||||
if (!fs.existsSync(file)) return {};
|
||||
try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
|
||||
catch { return {}; }
|
||||
}
|
||||
|
||||
function writeJSON(file, data) {
|
||||
ensureDataDir();
|
||||
fs.writeFileSync(file, JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
// Searches
|
||||
function getSearches() { return readJSON(SEARCHES_FILE); }
|
||||
function saveSearch(key, data) {
|
||||
const all = getSearches();
|
||||
all[key] = data;
|
||||
writeJSON(SEARCHES_FILE, all);
|
||||
}
|
||||
function deleteSearch(key) {
|
||||
const all = getSearches();
|
||||
delete all[key];
|
||||
writeJSON(SEARCHES_FILE, all);
|
||||
}
|
||||
function getSearch(key) { return getSearches()[key] || null; }
|
||||
|
||||
// Leads
|
||||
function getLeads() { return readJSON(LEADS_FILE); }
|
||||
function saveLead(key, data) {
|
||||
const all = getLeads();
|
||||
all[key] = data;
|
||||
writeJSON(LEADS_FILE, all);
|
||||
}
|
||||
function deleteLead(key) {
|
||||
const all = getLeads();
|
||||
delete all[key];
|
||||
writeJSON(LEADS_FILE, all);
|
||||
}
|
||||
function isLead(placeId) {
|
||||
const all = getLeads();
|
||||
return Object.values(all).some(l => l.placeId === placeId);
|
||||
}
|
||||
|
||||
module.exports = { getSearches, saveSearch, deleteSearch, getSearch, getLeads, saveLead, deleteLead, isLead };
|
||||
46
views/history.ejs
Normal file
46
views/history.ejs
Normal file
@@ -0,0 +1,46 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="bg-dark">
|
||||
<head><%- include('partials/head') %></head>
|
||||
<body class="bg-dark text-gray-200 flex min-h-screen">
|
||||
<% const activePage = 'history'; %>
|
||||
<%- include('partials/sidebar') %>
|
||||
|
||||
<main class="flex-1 ml-56 p-6">
|
||||
<h2 class="text-lg font-bold mb-4">Search History</h2>
|
||||
|
||||
<% if (!searches || searches.length === 0) { %>
|
||||
<div class="flex flex-col items-center justify-center py-24 text-gray-500">
|
||||
<svg class="w-16 h-16 mb-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
<p class="text-sm font-medium">No searches yet</p>
|
||||
<p class="text-xs mt-1">Your search history will appear here</p>
|
||||
<a href="/" class="mt-4 bg-accent hover:bg-accent-hover text-white px-4 py-2 rounded-lg text-sm transition-colors">Start Searching</a>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<div class="space-y-2">
|
||||
<% searches.forEach(s => { %>
|
||||
<div class="bg-card border border-white/5 rounded-lg p-4 flex items-center justify-between hover:bg-card-hover transition-colors">
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-white"><%= s.query %></p>
|
||||
<p class="text-xs text-gray-500 mt-1"><%= s.resultsCount %> results · <%= new Date(s.timestamp).toLocaleString() %></p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="/?view=<%= encodeURIComponent(s.key) %>"
|
||||
class="bg-accent/20 text-accent hover:bg-accent/30 px-3 py-1.5 rounded text-xs font-medium transition-colors">View</a>
|
||||
<button onclick="deleteSearch('<%= s.key %>', this)"
|
||||
class="bg-red-500/10 text-red-400 hover:bg-red-500/20 px-3 py-1.5 rounded text-xs font-medium transition-colors">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<% }) %>
|
||||
</div>
|
||||
<% } %>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
async function deleteSearch(key, btn) {
|
||||
if (!confirm('Delete this search?')) return;
|
||||
await fetch('/api/search/' + encodeURIComponent(key), { method: 'DELETE' });
|
||||
btn.closest('.bg-card').remove();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
121
views/index.ejs
Normal file
121
views/index.ejs
Normal file
@@ -0,0 +1,121 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="bg-dark">
|
||||
<head><%- include('partials/head') %></head>
|
||||
<body class="bg-dark text-gray-200 flex min-h-screen">
|
||||
<% const activePage = 'search'; %>
|
||||
<%- include('partials/sidebar') %>
|
||||
|
||||
<main class="flex-1 ml-56">
|
||||
<!-- Search Bar -->
|
||||
<div class="bg-sidebar/50 border-b border-white/5 p-4">
|
||||
<form id="searchForm" class="flex items-end gap-3 max-w-5xl">
|
||||
<div class="flex-1">
|
||||
<label class="block text-xs text-gray-500 mb-1">Keyword</label>
|
||||
<input type="text" name="keyword" placeholder="e.g. Restaurants, Plumbers..."
|
||||
class="w-full bg-card border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:border-accent" required>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="block text-xs text-gray-500 mb-1">City</label>
|
||||
<input type="text" name="city" placeholder="e.g. Austin"
|
||||
class="w-full bg-card border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:border-accent" required>
|
||||
</div>
|
||||
<div class="w-40">
|
||||
<label class="block text-xs text-gray-500 mb-1">State</label>
|
||||
<input type="text" name="state" placeholder="e.g. Texas"
|
||||
class="w-full bg-card border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:border-accent">
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<label class="block text-xs text-gray-500 mb-1">Max</label>
|
||||
<input type="number" name="maxResults" value="20" min="1" max="100"
|
||||
class="w-full bg-card border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-accent">
|
||||
</div>
|
||||
<button type="submit" id="searchBtn"
|
||||
class="bg-accent hover:bg-accent-hover text-white px-5 py-2 rounded-lg text-sm font-medium transition-colors flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="p-4 relative" id="contentArea">
|
||||
<!-- Loading State -->
|
||||
<div id="loadingState" class="hidden">
|
||||
<div class="flex flex-col items-center justify-center py-24 text-gray-500">
|
||||
<div class="flex gap-1 mb-4">
|
||||
<div class="w-3 h-3 bg-accent rounded-full pulse-dot"></div>
|
||||
<div class="w-3 h-3 bg-accent rounded-full pulse-dot"></div>
|
||||
<div class="w-3 h-3 bg-accent rounded-full pulse-dot"></div>
|
||||
</div>
|
||||
<p class="text-sm font-medium">Scraping Google Maps...</p>
|
||||
<p class="text-xs mt-1">This usually takes 30-120 seconds</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div id="errorState" class="hidden">
|
||||
<div class="flex flex-col items-center justify-center py-24">
|
||||
<div class="bg-red-500/10 border border-red-500/20 rounded-lg p-6 max-w-md text-center">
|
||||
<svg class="w-8 h-8 text-red-400 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"/></svg>
|
||||
<p class="text-red-400 text-sm font-medium" id="errorMsg"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div id="emptyState">
|
||||
<div class="flex flex-col items-center justify-center py-24 text-gray-500">
|
||||
<svg class="w-16 h-16 mb-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
<p class="text-sm font-medium">Search Google Maps for leads</p>
|
||||
<p class="text-xs mt-1">Enter a keyword and city to get started</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Table -->
|
||||
<div id="resultsArea" class="hidden">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<p class="text-sm text-gray-400"><span id="resultsQuery"></span> — <span id="resultsCount"></span> results</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto rounded-lg border border-white/5">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="bg-card text-gray-400 text-left text-xs uppercase tracking-wider">
|
||||
<th class="px-4 py-3 w-8"></th>
|
||||
<th class="px-4 py-3">Business</th>
|
||||
<th class="px-4 py-3">Phone</th>
|
||||
<th class="px-4 py-3">Email</th>
|
||||
<th class="px-4 py-3">Website</th>
|
||||
<th class="px-4 py-3">Rating</th>
|
||||
<th class="px-4 py-3">Reviews</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="resultsBody" class="divide-y divide-white/5"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail Panel -->
|
||||
<div id="detailPanel" class="fixed top-0 right-0 w-96 h-full bg-sidebar border-l border-white/10 z-50 hidden overflow-y-auto scrollbar-thin">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Searches (shown on empty state) -->
|
||||
<% if (searches && searches.length > 0) { %>
|
||||
<div id="recentSearches" class="px-4 pb-4">
|
||||
<h3 class="text-xs text-gray-500 uppercase tracking-wider mb-2">Recent Searches</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
<% searches.slice(0, 6).forEach(s => { %>
|
||||
<button onclick="loadSearch('<%= s.key %>')"
|
||||
class="bg-card hover:bg-card-hover border border-white/5 rounded-lg p-3 text-left transition-colors">
|
||||
<p class="text-sm text-white font-medium truncate"><%= s.query %></p>
|
||||
<p class="text-xs text-gray-500 mt-1"><%= s.resultsCount %> results · <%= new Date(s.timestamp).toLocaleDateString() %></p>
|
||||
</button>
|
||||
<% }) %>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
</main>
|
||||
|
||||
<script src="/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
77
views/leads.ejs
Normal file
77
views/leads.ejs
Normal file
@@ -0,0 +1,77 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="bg-dark">
|
||||
<head><%- include('partials/head') %></head>
|
||||
<body class="bg-dark text-gray-200 flex min-h-screen">
|
||||
<% const activePage = 'leads'; %>
|
||||
<%- include('partials/sidebar') %>
|
||||
|
||||
<main class="flex-1 ml-56 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-bold">Saved Leads</h2>
|
||||
<span class="text-xs text-gray-500"><%= leads.length %> leads</span>
|
||||
</div>
|
||||
|
||||
<% if (!leads || leads.length === 0) { %>
|
||||
<div class="flex flex-col items-center justify-center py-24 text-gray-500">
|
||||
<svg class="w-16 h-16 mb-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"/></svg>
|
||||
<p class="text-sm font-medium">No saved leads</p>
|
||||
<p class="text-xs mt-1">Bookmark businesses from search results to save them here</p>
|
||||
<a href="/" class="mt-4 bg-accent hover:bg-accent-hover text-white px-4 py-2 rounded-lg text-sm transition-colors">Search Leads</a>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<div class="overflow-x-auto rounded-lg border border-white/5">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="bg-card text-gray-400 text-left text-xs uppercase tracking-wider">
|
||||
<th class="px-4 py-3">Business</th>
|
||||
<th class="px-4 py-3">Phone</th>
|
||||
<th class="px-4 py-3">Email</th>
|
||||
<th class="px-4 py-3">Website</th>
|
||||
<th class="px-4 py-3">Rating</th>
|
||||
<th class="px-4 py-3">Saved</th>
|
||||
<th class="px-4 py-3 w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-white/5">
|
||||
<% leads.forEach(l => { %>
|
||||
<tr class="hover:bg-card-hover transition-colors" id="lead-<%= l.placeId %>">
|
||||
<td class="px-4 py-3">
|
||||
<p class="font-medium text-white"><%= l.title %></p>
|
||||
<p class="text-xs text-gray-500"><%= l.categoryName %></p>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-300"><%= l.phone || '—' %></td>
|
||||
<td class="px-4 py-3 text-gray-300"><%= l.email || '—' %></td>
|
||||
<td class="px-4 py-3">
|
||||
<% if (l.website) { %>
|
||||
<a href="<%= l.website %>" target="_blank" class="text-accent hover:underline truncate block max-w-[200px]"><%= l.website.replace(/^https?:\/\//, '').replace(/\/$/, '') %></a>
|
||||
<% } else { %>—<% } %>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<% if (l.totalScore) { %>
|
||||
<span class="text-yellow-400">★</span> <%= l.totalScore.toFixed(1) %>
|
||||
<span class="text-gray-500">(<%= l.reviewsCount %>)</span>
|
||||
<% } else { %>—<% } %>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-gray-500"><%= new Date(l.savedAt).toLocaleDateString() %></td>
|
||||
<td class="px-4 py-3">
|
||||
<button onclick="removeLead('<%= l.placeId %>')" class="text-red-400 hover:text-red-300 transition-colors" title="Remove">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% } %>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
async function removeLead(placeId) {
|
||||
await fetch('/api/leads/' + encodeURIComponent(placeId), { method: 'DELETE' });
|
||||
const row = document.getElementById('lead-' + placeId);
|
||||
if (row) row.remove();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
34
views/partials/head.ejs
Normal file
34
views/partials/head.ejs
Normal file
@@ -0,0 +1,34 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Lead Scraper — Google Maps</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
sidebar: '#1a1f1e',
|
||||
accent: '#4a7c59',
|
||||
'accent-hover': '#5a9469',
|
||||
dark: '#111413',
|
||||
card: '#1e2423',
|
||||
'card-hover': '#252b2a',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body { font-family: 'Inter', system-ui, sans-serif; }
|
||||
.scrollbar-thin::-webkit-scrollbar { width: 6px; }
|
||||
.scrollbar-thin::-webkit-scrollbar-track { background: transparent; }
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; }
|
||||
@keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
.slide-in { animation: slideIn 0.25s ease-out; }
|
||||
.fade-in { animation: fadeIn 0.3s ease-out; }
|
||||
@keyframes pulse-dot { 0%, 80%, 100% { transform: scale(0); } 40% { transform: scale(1); } }
|
||||
.pulse-dot { animation: pulse-dot 1.4s infinite ease-in-out both; }
|
||||
.pulse-dot:nth-child(1) { animation-delay: -0.32s; }
|
||||
.pulse-dot:nth-child(2) { animation-delay: -0.16s; }
|
||||
</style>
|
||||
26
views/partials/sidebar.ejs
Normal file
26
views/partials/sidebar.ejs
Normal file
@@ -0,0 +1,26 @@
|
||||
<aside class="w-56 bg-sidebar h-screen flex flex-col fixed left-0 top-0 z-40">
|
||||
<div class="p-5 border-b border-white/10">
|
||||
<h1 class="text-lg font-bold text-white flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
Lead Scraper
|
||||
</h1>
|
||||
<p class="text-xs text-gray-500 mt-1">Google Maps Leads</p>
|
||||
</div>
|
||||
<nav class="flex-1 p-3 space-y-1">
|
||||
<a href="/" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors <%= typeof activePage !== 'undefined' && activePage === 'search' ? 'bg-accent/20 text-accent' : 'text-gray-400 hover:bg-white/5 hover:text-white' %>">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
||||
Search
|
||||
</a>
|
||||
<a href="/history" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors <%= typeof activePage !== 'undefined' && activePage === 'history' ? 'bg-accent/20 text-accent' : 'text-gray-400 hover:bg-white/5 hover:text-white' %>">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
History
|
||||
</a>
|
||||
<a href="/leads" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors <%= typeof activePage !== 'undefined' && activePage === 'leads' ? 'bg-accent/20 text-accent' : 'text-gray-400 hover:bg-white/5 hover:text-white' %>">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"/></svg>
|
||||
Leads
|
||||
</a>
|
||||
</nav>
|
||||
<div class="p-4 border-t border-white/10">
|
||||
<p class="text-[10px] text-gray-600">Powered by Apify</p>
|
||||
</div>
|
||||
</aside>
|
||||
Reference in New Issue
Block a user