Skip to content

Scraping

The user-facing version of this feature is documented at URL scraping. This page is the operator’s reference: how the pipeline works, what the admin can configure, and how to add a new provider.

The scraping admin lives at Admin > Scraping. Everything except the env-var first-boot seeds is database-backed and takes effect without a restart.

add-item dialog /admin/scraping
| |
| paste URL | tune knobs
v v
+-------------------------------------------------+
| GET /api/scrape/stream (SSE) |
| - auth + URL validate |
| - build provider chain |
+-------------------------------------------------+
|
v
+-------------------------------------------------+
| orchestrator |
| - tiers (fall through until price + photo) |
| - one running merge across all tiers |
| - cache lookup / dedup |
| - post-passes (clean-title) |
+-------------------------------------------------+
^
|
+-------------------+---------------------+
| fetch-provider scrapeProviders[] |
| (always on, free) (admin-configurable)|
+-----------------------------------------+

The always-on fetch-provider runs first. Everything else is opt-in. For Amazon it fetches the page as a regular browser first (Amazon hides prices from link-preview crawlers) and reads the price and full-size product photos from Amazon’s own markup.

Each configured provider has a tier (1-5). The orchestrator runs all tier-1 entries in parallel, folds their results into one running merge, and only advances to tier 2 if that merge is still below the quality threshold. Same for tier 2 to tier 3, etc.

The implicit fetch-provider is tier 0; it runs first, alone.

Why tiers: cost control. Put cheap providers in tier 1 and paid hosted services in tier 2 and up. Pages where an early tier gets a complete result (title, price, and a real product photo) never spend money on Browserbase, ScrapFly, or AI extraction.

Default tier on new entries:

Provider typeDefault tier
browserless1
flaresolverr1
custom-http1
giftwrapt-scraper1
browserbase-fetch2
scrapfly2
browserbase-stagehand3
ai3

Adjust the tier from the Tier dropdown on each card in /admin/scraping.

All configured at Admin > Scraping. Secret fields are encrypted at rest in app_settings (AES-256-GCM, master key derived from BETTER_AUTH_SECRET).

TypeReturnsSecretBest for
browserlessHTMLtokenSelf-hosted JS rendering
flaresolverrHTML-Self-hosted; resolves interstitials before returning HTML
browserbase-fetchHTMLapiKeyHosted JS rendering, no LLM
browserbase-stagehandstructuredapiKeyHosted structured extraction with LLM
scrapflyHTMLapiKeyHosted rendering service
giftwrapt-scraperHTMLtokenA self-hosted GiftWrapt Scraper gateway (headless rendering)
custom-httpHTML or JSONcustomHeadersBring-your-own scraper service
aistructured-LLM extraction from raw HTML, using your AI provider

HTML responses go through the built-in extractor; structured ones are used as returned. You can add multiple entries of the same type (e.g. two Browserless instances) and drag to reorder within a tier.

A custom-http entry is called as GET <endpoint>?url=<encoded URL> with your custom headers. In JSON mode it must answer within the per-provider timeout with this shape (unknown fields are ignored, every field is optional):

{
"title": "string",
"description": "string",
"price": "string", // human-readable, e.g. "29.99"
"currency": "string", // e.g. "USD"
"imageUrls": ["string"], // absolute URLs preferred
"siteName": "string",
"finalUrl": "string" // the requested URL is used otherwise
}

Every successful result, from every tier, feeds one running merge. Falling through to a later tier never throws away what an earlier tier found: if the built-in fetch got the photos and a tier-2 provider got the price, the final result has both.

  • The highest-scoring result is the “base.”
  • Any field the base left empty (title, description, price, currency, siteName, ratings) is filled from the others, best score first.
  • imageUrls are pooled from everyone, then de-duplicated (different sizes of the same photo count as one, keeping the largest), ranked (real product photos first, social-share cards next, thumbnails last), and capped at the best 12.
  • Before a result joins, a same-product check compares it with the base. Different Amazon product IDs, or titles that share too few words, keep it out, so a later tier that hit a captcha page or a different product can’t graft its data onto a good result.

The merged result is persisted as its own row with scraperId: 'merged:a,b,c', marked as the run’s result. The Scrape History card in /admin/scraping shows it with a Result badge directly above that run’s individual attempts, and its detail lists which providers contributed and which were kept out (and why).

Each result is scored by what it found. Price and a real product photo matter most:

SignalPoints
Product title (not the site name or an error page)+2
Price+3
Real product photo+3
Social-share card as the only photo+1
Description of 30+ characters+1
Page looks like a bot or login wall-3
Title looks like an error page (“404”, “Access Denied”)-3

scrapeQualityThreshold (default 8) is the score the merge has to reach to stop trying further tiers. 8 is exactly title + price + real photo, so a result missing either keeps going while paid tiers are configured. Lower it to spend less on paid providers and accept results without a price; raise it to also require a description.

Each attempt’s score breakdown is stored with it and shown in its detail in Scrape History. The weights live in src/lib/scrapers/score.ts.

When a product URL redirects to the store’s homepage (or a locale homepage like /en-us, or a “store closed” password page), the product is gone. The scrape stops there instead of trying paid tiers, the add-item form shows This link is no longer valid., and a bulk-import job for it fails immediately instead of retrying. A URL that was already a homepage scrapes normally.

A successful scrape is reused for the same URL for scrapeCacheTtlHours. The cache serves the best recent run’s merged result, as long as it scored at least 5 (for example a title and a real photo, or a title and a price). That floor is separate from the quality threshold on purpose: a page that never shows a price can’t reach 8, but is still worth reusing rather than re-running every paid tier.

SettingDefaultWhat it does
scrapeProviderTimeoutMs20000Per-provider HTTP budget
scrapeOverallTimeoutMs45000Overall scrape budget incl. parallel racers
scrapeQualityThreshold8Score needed to stop trying further tiers
scrapeCacheTtlHours24URL-based dedup cache TTL (0 = disable)
scrapeAiCleanTitlesEnabledfalsePost-pass: AI normalises the winning title

AI extraction itself is a regular provider entry (type ai) in a tier of your choosing. The legacy scrapeAiProviderEnabled toggle is only read at boot, to create that entry when none exists.

The AI features here depend on a configured AI provider. The toggles disable themselves in the admin UI if no AI provider exists.

Items can be imported in bulk from an external source (CSV, JSON, etc.). The scraper doesn’t run inline for those - imported rows get queued in item_scrape_jobs and drained by the /api/cron/item-scrape-queue cron tick. Three knobs control the drain:

SettingDefaultWhat
scrapeQueueUsersPerInvocation25Distinct users handled per cron tick
scrapeQueueConcurrency3Max parallel jobs per user inside one tick
scrapeQueueMaxAttempts3Max attempts before a job is marked failed
importEnabledtrueMaster kill switch for the import flow

Failed jobs are kept for diagnostics. The parent item retains whatever fields were already set at create time. A dead link fails on its first attempt.

Authenticated users supply the URL, so the scraper has to refuse to fetch addresses inside your own infrastructure. The always-on fetch-provider and the AI provider both go through safeFetch, which:

  • Rejects non-http(s) schemes.
  • DNS-resolves the hostname and rejects any address in a private, loopback, link-local, CGNAT, multicast, or reserved range.
  • Walks redirects manually (redirect: 'manual') and re-runs the same check on every hop.
  • Caps the redirect chain (default 5).
  • Sets credentials: 'omit'.

Hosted providers (Browserbase, ScrapFly, Browserless, etc.) make outbound calls from outside your infra, so they don’t need this hostname check on the user-supplied URL - their upstreams apply their own rules.

Secret fields on configured providers (token, apiKey, customHeaders containing auth) are AES-256-GCM-encrypted in app_settings JSONB. The master key is derived via scrypt from BETTER_AUTH_SECRET. New writes encrypt; reads accept either an envelope or legacy plaintext.

ScenarioWorking chainNotes
Vercel, no extrasfetch-provider onlyBest-effort; manual entry fallback.
Vercel + Browserbase Fetchfetch -> browserbase-fetchCheapest hosted upgrade.
Vercel + ScrapFlyfetch -> scrapflyHosted rendering via asp=true. Pay per credit.
Vercel + Browserbase + Stagehandfetch -> browserbase-fetch -> stagehandStagehand only runs when the earlier tiers lack a price or photo.
Vercel + custom HTTPfetch -> custom-httpBring-your-own scraper, no vendor cost.
Self-host containersFull chain incl. browserless + flaresolverrRun them as sidecars.
AI on top of any of the aboveadds an ai entry, usually the last tierOff by default. Costs money.
  1. Add a discriminated variant to scrapeProviderEntrySchema in src/lib/settings.ts. Mark secret fields with appSecretField().
  2. If a new secret field type, include it in encryptScrapeProviderSecrets.
  3. Create src/lib/scrapers/providers/<id>.ts exporting a create<Id>Provider(entry) factory.
  4. Pick a sensible default tier in makeDefaultEntry() (cheap and self-hosted low, paid high).
  5. Wire the factory into loadConfiguredProviders().
  6. Add a card component in scraper-providers-form-view.tsx plus a default entry shape in makeDefaultEntry().
  7. Add a fixture test covering the success path and each error code.

Errors should classify into the small enum (bot_block, http_4xx, http_5xx, network_error, timeout, invalid_response, config_missing, dead_link, unknown) via ScrapeProviderError so the orchestrator surfaces the right wire code.