ProjectsRelist
Back to projects
Relist

Relist

Every car on the lot, on Marketplace, never stale.

Next.jsTypeScriptFirebaseChrome ExtensionAnthropicChrome MV3Vercel CronFirestoreVitest

Context

Facebook Marketplace is where used cars actually move in this market, and it has no vehicle-listing API. There is no endpoint a dealership can post a car to, so the honest shape of this product is not an integration. It is a dashboard that owns the inventory and the posting record, a Chrome extension that fills Marketplace's own create-vehicle form from the salesperson's own Facebook account, and a person who always clicks Publish. Relist never publishes anything by itself.

That one constraint decides everything downstream. Because a human publishes from a personal account, the job is not throughput, it is not looking like a dealer bot. And because there is no API to break, the thing that breaks is Facebook's form, so selling this means owning that maintenance for every customer.

I built it first for 604 Motors, a dealership whose site I also built.

One loop, end to end

Inventory
Dealer source
a website, a Google Sheet, a Meta feed
Sync every 6 hours
Firestore vehicles
Studio photos and a written description
Readiness gate
nothing Marketplace needs is missing
Posting
Extension polls /api/ext/next
Autofill on Marketplace
filled, never submitted
A human clicks Publish
/api/ext/posted
Pacing gate
cooldown drawn and stored

Reading a dealer's site with no API to read

Most dealers cannot hand you a feed. They can hand you a URL. So the connector takes the page that lists the cars and works backwards.

It looks for pages the polite way first: robots.txt for a Sitemap: line, then the sitemap index, up to 5 sitemap documents and 200 same-host URLs. Only when that finds nothing does it crawl links from the page it was given, 2 requests in flight, 800 ms apart. Every fetch is https only, capped at 1.5 MB, and re-checked against private address ranges on each redirect hop, so a pasted URL cannot be aimed back at our own network. Forty detail pages is the ceiling, and a sync runs as 60-second steps the client re-posts until the run reports done.

Per page, schema.org JSON-LD comes first: exact, free, and already published by most dealer platforms. Claude Opus 5 at low effort is the floor under that, not the plan. At most 6 pages per sync, 60,000 characters each, a 30-second timeout, never on the cron, roughly 15 to 20 cents on a sync that needs it. Running it on every page of every crawl was priced and rejected: thousands a month, and nothing gained on a site that already emits JSON-LD.

What comes back is filtered, not trusted. A row missing year, make, model, price or mileage is dropped and counted back to the owner, because a half-filled car cannot be published anyway. Zero kilometres with no VIN reads as a brochure, not a car. And the sync will not sell the lot on a bad read: an empty source skips the sold-diff entirely, and a run that would mark more than half the available cars sold is refused and logged.

When a site cannot be read at all, nobody is stranded: the cars step offers "Have Relist connect it", which files a connect request I pick up by hand and unlocks Continue.

Pacing is a safety property, not a preference

A dealer bot is not caught by what it posts. It is caught by rhythm. So pacing is the thing protecting the account, and it runs on the server where every device gets the same answer.

Two numbers do the work: posts per hour, between 5 and 10, and a daily cap between 3 and 7. The cooldown is derived from the hourly rate, then jittered plus or minus 20 percent so posts never land on a metronome. It is drawn once when a posting is recorded and stored on that posting as nextAllowedAt, so the gate is deterministic afterwards. Redrawing it on every check was the rejected alternative: one person, two machines, two different answers.

The daily cap is the part that actually matters. An hourly rate on its own still lets somebody put forty cars up in a day.

The gate keys on the pair of member and Facebook account, not the member alone. A salesperson may post from more than one account, the content script reads which account is signed in at fill time rather than scraping a profile, and each account gets its own hour and its own day.

5 to 10
posts per hour, per Facebook account
3 to 7
posts per day, the cap that is the real brake
every 6 h
inventory sync, on a Vercel cron
59
test files behind the loop
Chrome 114
the floor the side panel needs

Connecting without pasting a token

The dashboard hands the extension its token directly through externally_connectable. No URL, no copy and paste, no options page. The page checks the extension is reachable before minting, because minting revokes the token already out there, and races the handshake against three seconds; if it loses, it says the token was issued but not delivered instead of showing a green light.

"Connected" means the extension called home. The badge is stamped by that member's first authenticated GET /api/ext/next, never by the page that minted the token.

externally_connectable restricts who may message the extension, not what they send, so the service worker also requires sender.origin to sit in an allow-list and the apiBase it is handed to equal it. Pending posts are tab-bound and expire, so browsing a stranger's listing can never be attributed to an abandoned fill.

Packaging for the Chrome Web Store adds two facts. The store rejects any manifest carrying a key, so the pack script strips it and the store assigns its own ID, different from the pinned unpacked one. And at cutover both the manifest's externally_connectable.matches and the background script's origin allow-list need the production origin. Update only one and you ship a connect button that silently does nothing.

Under the hood

Next.js App Router, TypeScript and Tailwind, with Firebase Auth and Firestore behind the Admin SDK on the server, on Vercel. Lists keep their server render and hand it to a client island that subscribes with onSnapshot under the rules that already existed, so live updates opened nothing new. The extension is plain-JavaScript Manifest V3 with no build step, and it opens as a Chrome side panel, with a floating-window fallback for browsers that expose chrome.sidePanel and never render it.