Skip to main content
Liorry Herisnor
Status: ProductionInfrastructure2026 — present

AdFetch — Shared Fetch, Scrape and Search Infrastructure

A self-hosted, Firecrawl-compatible fetch, scrape, search and map service. Headless, deliberately generic, and called independently by two other products in this portfolio.

At a glance

Every product that touches the modern web needs the same three things: a browser fleet, anti-bot handling and proxy egress. Building that three times means paying for it three times — in vendor fees or in engineering hours. AdFetch is the one service that does it once: a self-hosted, Firecrawl-shaped API that AdSpy and Lead Discovery both call, independently, for every page they fetch.

  • 2 independent products

    Consumers

    AdSpy and Lead Discovery both call this service for every page fetch. Neither shares code with the other — each has its own client — and neither knows the other exists.

    Source: Consumer audit of the codebase, July 2026 — every caller of the service traced to its owning package.

  • 8 endpoints, 2 shapes

    API surface

    A native job-based API (submit a fetch, poll a job, read the result) and a Firecrawl-compatible trio (scrape, search, map) that exists specifically so callers didn't have to change any code when they switched to this service.

    Source: Router inspected July 2026.

  • 9-binary service, its own repository

    Architecture

    Its own git repository, its own Go module, 34 of its own database migrations, no shared code with the products that call it.

    Source: Repository layout inspected July 2026.

Status
Status: Production
Category
Infrastructure
Dates
2026 — present
My role
Product owner, architect and delivery lead
Target users
Any internal service that needs to fetch, render or search the public web, AdSpy, fetching publisher pages and ad landing pages, Lead Discovery, fetching pages for contact extraction
Technology
Go, PostgreSQL, Redis Streams, Playwright, Headless Chrome, Residential proxy pool, Firecrawl API

What I owned

  • Product vision
  • Discovery
  • Requirements
  • Architecture decisions
  • Delivery coordination
  • Testing
  • Production operations

Areas this project actually involved. Anything not listed, I did not own.

Who and what supported delivery

AI coding assistants
Implementation assistance under the documented delivery workflow — context packs, task cards, automated pre-commit checks and human approval on every production change.

One owner, one service, two consumers I don't control the roadmaps of. That last part is what makes the non-goals below load-bearing rather than decorative.

The infrastructure problem

Any product that works with the public web hits the same three problems, in the same order, regardless of what it's actually trying to accomplish.

A browser fleet is not optional. Modern pages render with JavaScript. Fetching raw HTML gets you an empty shell for a growing share of the sites worth fetching, so someone has to run headless browsers at scale.

Sites do not want to be fetched. Rate limits, fingerprinting and challenge pages are the default posture of the modern web, not the exception. Anti-bot handling is its own ongoing maintenance burden, unrelated to whatever the fetched page is actually going to be used for.

Egress has to come from somewhere real. Residential IP space with a metered, shared budget is what keeps a fetcher from looking like a data center — and somebody has to own that budget, the byte accounting, and the hard stop when it runs out.

None of this is specific to any one product. An ad-intelligence pipeline and a contact-discovery pipeline need identical answers to all three problems. Building the answer twice means maintaining two browser fleets, two anti-bot postures and two proxy relationships for one underlying capability.

Previous approach

Before this service existed, the answer was a vendor. A commercial scraping API was paid per request for page fetching — Lead Discovery is the pipeline with the clearest paper trail on this, because its own decision record still states the choice plainly: reimplement the paid API's exact request and response shapes in a self-hosted service, rather than keep paying for it or wrap a different vendor, specifically so that not one line of calling code had to change on the day of the switch.

The same reasoning generalizes past that one pipeline. Every additional product that wants to fetch a page either pays the same per-request vendor bill again, or quietly builds its own second browser fleet. Both are the same mistake at different price points.

Goals and non-goals

Goals

  1. One HTTP surface for fetching, rendering and searching the public web, reachable by any internal service.
  2. Match the request and response shapes of the commercial API it replaced — specifically the Firecrawl shape — so a consumer's existing integration code needs no rewrite to switch.
  3. Meter proxy egress centrally, with per-consumer budgets and hard stops, so the cost and the byte accounting live in one place rather than in every caller.
  4. Stay honest about what is actually self-hosted: scrape and fetch run entirely on infrastructure this service owns; search and map proxy to the real, third-party Firecrawl API rather than reimplementing web search.

Non-goals

  • No domain knowledge of what is being fetched. This service does not know what an ad is, does not know what a lead is, does not deduplicate, classify, or resolve identity. That logic belongs entirely to the consumer that requested the fetch — never to this one. A fetch service that starts making decisions about the content it fetches is a fetch service that has started coupling itself to one caller's domain, at the expense of every other caller.
  • No consumer-specific code paths. AdSpy and Lead Discovery get the same API surface everyone else would get. There is no if adspy branch in this service, and there should never be one.
  • Not a search engine. Search and map are proxied to a real search provider rather than built from scratch — reimplementing web search was never the point.

My role

I own this service on my own: the API contract, the architecture decisions, the call to proxy search and map to real upstream Firecrawl rather than reimplement search, and production operations.

The two decisions I would point to as mine:

  • Match an existing API shape instead of inventing one. Copying the Firecrawl request and response format for the scrape/search/map trio meant every consumer's integration code already worked against this service before it existed — the switch was a URL change, not a rewrite.
  • Enforce the non-goals as a design constraint, not a policy note. The service has no schema, no table and no code path that knows what an ad or a lead is. That absence is deliberate: it's what makes a second, third or tenth consumer safe to add without ever touching this codebase's domain logic.

What was built

AdFetch, and the boundary it enforces

Everything below this line is generic. Nothing here knows what an ad, a lead, or any other domain concept is — that logic lives entirely in the consumers on the other side of the HTTP boundary.

Fetch and scrape core

Runs entirely on infrastructure this service owns.

  • Residential proxy pool

    External service

    Metered egress under a monthly bandwidth ceiling, with per-day, per-consumer byte budgets and hard stops.

  • Browser workers

    Automated

    Playwright-driven headless Chrome, including anti-bot challenge handling, shared across every consumer's requests.

  • Job queue

    Automated

    Submit a fetch, poll a job, read the result — the native API shape behind /v1/fetch, /v1/job/{id}, /v1/result/{id} and /v1/html/{id}.

  • Local scrape endpoint

    Automated

    POST /v1/scrape — Firecrawl-shaped request and response, served entirely by the queue above rather than proxied out.

Firecrawl-compatible proxy

The two endpoints this service deliberately does not reimplement.

  • Upstream Firecrawl

    External service

    Real, third-party Firecrawl API. POST /v1/search and /v1/map proxy here directly — reimplementing web search was never the goal.

Diagram is sanitized for publication: it shows responsibilities and data flow, not hostnames, addresses or credentials.

The result is an 8-endpoint surface across two shapes: a native job-based API for fetch, and a Firecrawl-compatible trio for scrape (served locally), search and map (proxied to the real Firecrawl). A consumer picks whichever shape matches its existing integration code.

Its two consumers

Both consumers are real, independent, and unaware of each other. Neither shares a client library with the other — each was integrated separately, at a different time, for a different reason.

ConsumerHow it calls this serviceWhat for
AdSpyFour separate integration points, each its own small client — one for search and map (publisher discovery), one for the fetch/job/html path (publisher crawling), and two more for the scrape endpoint (Facebook Ad Library scraping and landing-page capture with screenshots)Every page AdSpy captures — publisher pages, ad landing pages — is fetched through here
Lead DiscoveryOne client for page fetch, plus a second, legacy path for search that predates this service's current shapeEvery page fetched for contact extraction

The absence of a shared client library between the two is a real gap, not a feature — see What's next.

Where AI is used, and where it is not

There is no AI anywhere in this service's own request path. Every fetch, every job-queue claim, every proxy-budget check is deterministic code, on purpose — this is infrastructure two other products depend on being boring and predictable.

The one boundary worth naming honestly: what happens inside the real upstream Firecrawl API, on search and map requests, is outside this service's control and outside its visibility. This service proxies those two calls faithfully; it does not audit or constrain whatever Firecrawl itself does with them.

Evidence and its limits

There is no screenshot of this service, and there will not be one. AdFetch is a headless API with no user interface — nothing renders that a browser could capture. The closest thing to a screenshot is the architecture diagram above; the card illustration on the case-studies index is the same kind of diagram, labeled on its face as an illustration rather than a capture, for the same reason.

What can be verified instead is the API surface itself:

MethodPathShapeServed
POST/v1/fetchNativeLocally
POST/v1/batch/fetchNativeLocally
GET/v1/job/{id}NativeLocally
GET/v1/result/{id}NativeLocally
GET/v1/html/{id}NativeLocally
POST/v1/scrapeFirecrawl-compatibleLocally
POST/v1/searchFirecrawl-compatibleProxied to upstream Firecrawl
POST/v1/mapFirecrawl-compatibleProxied to upstream Firecrawl

What this service produces is visible secondhand, in its consumers' own evidence: the ad and landing-page captures in AdSpy's evidence gallery, and the captured pages in Lead Discovery's own case study. Every image in both of those galleries passed through this service first — this one just fetched what's on screen there.

What runs, and what does not

ComponentStateThe evidence
Native fetch/job/result pathRunningBacks every AdSpy crawl and every Lead Discovery page fetch
Local scrape endpointRunningBacks AdSpy's Facebook and landing-page capture
Firecrawl-compatible search and map proxyRunningBacks AdSpy's publisher discovery; Lead Discovery's legacy search path
Proxy budget enforcementRunningPer-day, per-consumer byte budgets with hard stops
A user interfaceNeverHeadless by design — not a gap to close, a permanent property of the service

There is no systemd unit for this service; it runs under Docker Compose. That is a real operational difference from every other flagship system in this portfolio, and it's named honestly in What's next rather than smoothed over.

Results

  • One fetch capability, two independent products. Neither AdSpy nor Lead Discovery had to build or maintain its own browser fleet, anti-bot posture, or proxy relationship. Both get it from one place.
  • Zero rewrite on adoption. Matching the Firecrawl request and response shape meant a consumer's existing integration code worked against this service before the switch, not after a rewrite.
  • The proxy bill lives in one place. Metered, budgeted egress with hard stops replaced what would otherwise be an unmanaged cost spread across every consumer that happened to need a browser.
  • The boundary held. After two independent consumers and roughly a year of use, this service still has no code path that knows what an ad or a lead is. That absence is the actual product.

Lessons

  • Naming can silently smuggle ownership across a boundary. This portfolio's own case study on this exact system briefly attributed ad-specific ingestion logic to this service, because a package comment written from inside one consumer's codebase described this service in that consumer's own vocabulary. This service's own repository contains a binary literally named ad-ingestor as one of its nine components — a real, separate naming collision, unrelated to any consumer's domain logic, and exactly the kind of trap that makes "just read one file" an unsafe way to establish what a shared service actually does.
  • A generic service earns trust by refusing scope, not by adding it. The hardest feature requests to say no to were the ones from a single consumer that would have made this service slightly better for them and permanently coupled for everyone else. Every one of those requests belonged in the consumer instead.
  • Match the contract, not the implementation. Copying Firecrawl's request and response shapes — rather than designing a cleaner API from scratch — is what made the swap invisible to every caller. A better API nobody has to adopt is worth less than a compatible one everyone already has.
  • Shared infrastructure needs its own case study. Describing this service only from inside a consumer's documentation is how its identity gets lost in the first place — which is the entire reason this page exists.

What's next

  • Replace the four-plus-one bespoke per-consumer clients with one shared client library, so the next consumer doesn't write its own from scratch.
  • Give this service a proper systemd unit instead of relying solely on Docker Compose for process supervision — the one operational gap that would matter most if this service went down.
  • Onboard a third consumer, if one appears, as the real test of whether the non-goals above actually hold under pressure.

Want the detail behind this?

I am happy to walk through the architecture, the decisions and what I would change.