← Index

Loopshift

2026express · postgres · geminiLiveRepo

One pipeline, two repositories. A Python CLI scrapes local businesses from Google Places, audits each website against about thirty-five technical defects, scores the opportunity, and pushes the result into a Node dashboard used for agency outreach.

Grid search, because Places lies about coverage

The Places API caps results per query, so a single search for "Toledo" returns a fraction of what's there and gives you no indication that it did. The scraper instead walks a configurable NxN grid of coordinates with tunable spacing and radius — three presets plus arbitrary custom grids — and dedupes across cells. Coverage becomes a function of grid density rather than of what the API felt like returning.

Scoring is a flat dictionary, one source of truth, summed at the end. The highest-value signal is the absence of a website entirely, worth 20 points — a business with no site is a better prospect than one with a slow site. Which creates a small problem, because the database is keyed on domain and these leads have no domain:

domain = normalize_domain(r.get("website", ""))
if not domain:
    slug = re.sub(r"[^a-z0-9]+", "-", (r.get("name","") or "unknown").lower()).strip("-")
    domain = f"nosite-{slug[:50]}"

A synthetic key, prefixed so it's obvious in the table what it is.

The seam runs both directions

The documented direction is the CLI POSTing audit results to /api/prospects/ingest behind a bearer key.

The undocumented direction is more interesting: the dashboard shells out to the scraper. A click builds a python -m prospector --push argv, spawn()s it, and streams stdout to the browser over SSE with a thirty-minute SIGTERM timer. So a button in the web app launches a Python process on the host, which then HTTP-POSTs back into the same server that started it.

It is not the architecture I would draw on a whiteboard. It is the architecture that let one person ship a scraper and a dashboard without building a job queue for a system with one user.

The upsert is the careful part

Re-scanning a prospect has to update the measurements without destroying what you've learned about them. ON CONFLICT (domain) DO UPDATE uses COALESCE(EXCLUDED.x, prospects.x) for identity fields — a re-scan that fails to find a phone number must never null out the one you already had — but bare EXCLUDED.x for measured fields, where the fresh audit should always win.

And prospect_outreach is left untouched entirely, so sales state survives re-scanning. That one is the difference between a tool you can run twice and a tool you run once and then never trust again.

1,457 lines of scraper against 2,128 lines of tests, 183 test cases, 2,445 cached domain audits.