Skip to content

Website Crawl — States, Depth & Request Fingerprint

What our own crawler fetches, to what depth, how it identifies itself, and which surface each crawl feeds. Code: worker/src/handlers/crawl-website.js (crawlWebsite()), driven by worker/src/handlers/prospect-crawl-enrich.js, compose-deal-brief.js, and the advisor crawl_website tool.

Last updated 2026-06-01 (added scheduling / backoff / dead-domain detection).

Request fingerprint (identical across all states)

Section titled “Request fingerprint (identical across all states)”
User-AgentMozilla/5.0 (compatible; GoldenPagesBot/1.0; +https://www.goldenpages.ie/bot)
robots.txt tokengoldenpagesbot (longest-prefix group match, else *; Allow beats Disallow on a tie, */$ wildcards honoured)
Accepttext/html,application/xml,text/plain
Redirectsfollowed
Timeoutshomepage 12s · each asset/sitemap file 5s · whole sitemap-index walk 6s
Caps≤25 sitemap files · ≤5,000 URLs counted · ≤12 inner pages sampled (deep)

We identify honestly as a known Irish directory crawler — no impersonation. We report which AI crawlers a site blocks (gptbot, google-extended, ccbot, claudebot, anthropic-ai, perplexitybot, bytespider) as an “AI-search aware” signal; we never pretend to be them.

Always fetched first, in parallel with the homepage (≈0 added wall-time): robots.txt, llms.txt, sitemap.xml, sitemap_index.xml, wp-sitemap.xml → 6 requests baseline.

StateWhat it fetchesPage countInner pages~Requests
1. Shallowhomepage + 5 well-known assets. Sitemap is probed only — if it’s a <sitemapindex> we record how many child sitemaps exist but don’t fetch them.null on index sites (flat <urlset> still counts)none~6
2. Full-sitemapShallow + BFS-walk the sitemap index (child sitemaps in parallel batches, within the 25-file / 6s / 5,000-URL caps).exact page_count + content_page_count (taxonomy archives stripped) + by_typenone6 + up to 25
3. DeepFull-sitemap + sample up to 12 inner pages (prioritised: service → location → about → contact → pricing → product → blog), batches of 4, paced by crawl-delay when respecting robots.same as Full-sitemapup to 12: per-type counts, cross-page NAP/social/schema/tracking6 + ≤25 + ≤12

Depth is set by two params: deep (→ inner-page sampling, forces full sitemap) and sitemap_depth (shallow vs full; ignored when deep:true).

  • Homepage NAP (tel: phones + emails via mailto: AND plain-text, through the shared isJunkEmail filter) is captured on every state — it’s a function of the homepage HTML we already have, no extra fetch.
  • Inner-page emails/phones are added only in Deep (sampled contact/about/etc. pages).
  • The promoted crawl_email (BQ, prospect states only) is chosen by pickCrawlEmail(crawl_root): same-domain-role > same-domain > freemail, dropping off-domain non-free addresses and pages with >5 distinct emails (aggregator/directory guard). The full list is kept in crawl_raw.

robots posture (orthogonal to depth — set per caller)

Section titled “robots posture (orthogonal to depth — set per caller)”
  • respect_robots:true (the scaled prospect crawl): read robots.txt first and bail before touching the site if our bot is disallowed (returns robots_blocked:true — itself a recorded signal); deep sampling filters candidate URLs by robots and paces by crawl-delay.
  • respect_robots:false (advisor + deal-brief): a rep explicitly asked to see one specific site, so we fetch the homepage regardless. robots/ai_bots_blocked are still reported.

Who runs which state — and where the result goes

Section titled “Who runs which state — and where the result goes”
Callerdeepsitemap_depthrespect_robotsStateResult written to
prospect-crawl-enrich shallow (every-minute cron)falseshallowtrue1 ShallowPROSPECT_LISTINGS MERGE on crawl_root (fans to all sibling listings): crawl_* + fit_* + crawl_email/crawl_phone + crawl_raw
prospect-crawl-enrich deep (PAUSED 2026-05-24)truefulltrue3 Deepsame MERGE + crawl_deep_at + service/location page counts
compose-deal-brieffalseshallowfalse1 Shallowdeal-brief KV (deal-brief:<id>), surfaced in the brief’s website block
advisor crawl_website (default)false(full)false2 Full-sitemapchat response; optionally vectorized into fcr-site-portfolio
advisor crawl_website deep:truetruefullfalse3 Deepchat response; optionally vectorized

Only the two prospect-crawl-enrich states write to PROSPECT_LISTINGS. The crawl is free and runs on every row with a real website (it is NOT gated by enrich_enabled — that gate is for paid SerpAPI/Pleper/Ahrefs enrichment only).

Extracted parameters (full field inventory)

Section titled “Extracted parameters (full field inventory)”

What crawlWebsite() returns, which depth state populates it, and whether the prospect crawl persists it to PROSPECT_LISTINGS. Homepage-derived fields are present in all three states (S/F/D); page counts need Full or Deep; a handful are Deep-only.

FieldWhatStatePersisted to PROSPECT_LISTINGS
title<title>S/F/D— (returned only; not a column, not in crawl_raw)
metaDescription<meta name=description>S/F/D
metaKeywords<meta name=keywords>S/F/D
textPreviewfirst 1,500 chars of body textS/F/D
wordCounthomepage word countS/F/Dcrawl_word_count
technical.is_httpsSSLS/F/Dcrawl_has_ssl
technical.has_viewportmobile-readyS/F/Dcrawl_mobile_ready
technical.has_faviconfavicon presentS/F/D
technical.has_og / og_titleOpen Graph tagsS/F/D
technical.has_canonicalcanonical linkS/F/D
technical.h1first H1 textS/F/D
features.detected_cmsWordPress/Wix/Shopify/…S/F/Dcrawl_cms (+ derived crawl_is_spa, crawl_is_diy_builder)
features.has_ecommerceproduct schema / Woo / Shopify / cart markupS/F/Dcrawl_has_ecommerce
features.has_blogblog presentS/F/Dcrawl_has_blog
features.has_bookingonline booking (Calendly/Setmore/…)S/F/Dcrawl_has_booking
features.has_chatlive chat widgetS/F/Dcrawl_has_chat
features.has_formscontact/enquiry formS/F/Dcrawl_has_form
features.has_gallerygallery/portfolioS/F/D
features.has_phonephone on pageS/F/D— (see crawl_phone)
technologies[]Wappalyzer-style stackS/F/Dcrawl_raw (CMS/ads/GA/pixel promoted to flags below)
technologies → analytics/ads/pixelGA / Google Ads / Meta Pixel tagsS/F/Dcrawl_has_analytics, crawl_has_google_ads_tag, crawl_has_fb_pixel
structuredData.has_json_ld / types[]JSON-LD schemaS/F/Dcrawl_has_schema (+ types in crawl_raw)
paymentProcessors[]Stripe/PayPal/Klarna/…S/F/Dcrawl_raw
aiReadinessllms.txt / robots.txt / sitemap / structured-data / ai_bots_blocked[]S/F/Dcrawl_ai_ready (derived) + ai_bots_blocked in crawl_raw
robotsfound / homepage_allowed / crawl_delay / ai_bots_blockedS/F/Dvia crawl_error='robots_blocked' when disallowed
social{}Facebook/Insta/LinkedIn/… profile linksS/F/Dcrawl_raw
contact.phones[] / contact.emails[]homepage NAP (mailto: + plain-text)S/F/Dcrawl_phone / crawl_email (ranked picker)
pageCounttotal sitemap URLsF/D (S: null on index sites)(raw count in crawl_raw)
contentPageCountreal pages, taxonomy archives strippedF/Dcrawl_page_count
sitemap.by_typeper-type breakdown (post/page/product/tag/…)F/Dcrawl_raw
deep.service_page_count# service pagesD onlycrawl_service_page_count
deep.location_page_count# location pagesD onlycrawl_location_page_count
deep.has_about/has_contact/has_pricingtrust-page presenceD onlycrawl_raw
deep.emails/phonesNAP across sampled pagesD onlyfeeds crawl_email/crawl_phone
deep.trackingGA / Google Ads / FB pixel across pagesD only(promoted to the flags above)
deep.sample[]per-page {url, type, wordCount}D onlycrawl_raw (trimmed)
fit_sitepro/seo/estore/ads/ads_crosssell/socialderived solution-fit flagsS/F/Dfit_* columns

Note: title / metaDescription / metaKeywords are extracted and returned to the advisor + deal-brief, but are not stored on PROSPECT_LISTINGS (no column, not in crawl_raw). The richer presence-fingerprint fields (trust-page composite, lead-capture vendor, freshness, performance) are proposed, not built — see docs/crawl-presence-fingerprint-brief.md.

Which crawler runs in which flow (important — these scripts aren’t the only one)

Section titled “Which crawler runs in which flow (important — these scripts aren’t the only one)”

GoldenPagesBot + robots-respect applies to the background book sweep only. The interactive flows use different paths:

FlowCrawlerUser-Agentrobots.txt
Background book sweep (prospect-crawl-enrich, cron)crawl-website.jsGoldenPagesBotrespected (bails if disallowed)
Advisor crawl_website (Roam / Discovery drill-in)crawl-website.jsGoldenPagesBotnot respected (rep asked to see one site)
Deal-brief composecrawl-website.jsGoldenPagesBotnot respected
/prospect skill quick crawl (Step 1c)WebFetch (Claude Code)WebFetch UA — not GoldenPagesBotskipped (single homepage fetch for brand assets)
InSites Digital Footprint (submit_insites_df)InSites/Yext 3rd-partytheir crawlertheir policy
LRC / run_serp_gridSerpAPI (no site crawl)n/an/a

So: a quick /prospect run is not GoldenPagesBot and does not read robots; the Discovery/advisor crawl is GoldenPagesBot but does not respect robots; only the scaled background crawl is both.

Keyword identification is a separate pipeline (not these scripts)

Section titled “Keyword identification is a separate pipeline (not these scripts)”

The “3 relevant keywords” in /prospect and LRC come from the keyword stack, not the website crawl:

  • prospect_intel (proven KEYWORD_INTELLIGENCE terms, commercial-intent first) + suggest_insites_params,
  • the GP listing’s keywords field,
  • CATEGORY_BENCHMARKS + Ahrefs volumes,
  • LRC submission = manual_keywords × locations cross-product.

The crawl only contributes on-page seeds (meta title/keywords, H1/nav labels, service-page names) that can inform those suggestions — it is not the keyword engine and does not rank or select keywords.

Crawl scheduling, backoff & dead-domain detection

Section titled “Crawl scheduling, backoff & dead-domain detection”

The background sweep is self-scheduling off crawl_next_at — the every-minute cron selects due hosts, and each result reschedules the host with a per-status backoff so live sites are re-checked often and dead/blocked ones are left alone. After a result is written, crawl_next_at is pushed by roughly: success ~90d · timeout ~7d · robots_blocked ~180d · other errors ~30d (host-grouped selection, with a per-host abort cap so one slow host can’t starve a tick).

Crawl-state and reachability are also materialised host-grain in dim_website (one row per crawl_root) so a shared site is judged once and every listing on it inherits the verdict — see Prospect Enrichment & Opportunity Index §1.

Dead-domain detection is DNS-based, not HTTP-based. A 5xx/530 is often a live Cloudflare-fronted origin or a broken cert, not a dead site — so a DNS-over-HTTPS pass sets dim_website.dns_status (has_A / nxdomain / registered_noA / doh_error). Confirmed nxdomain domains are routed into website_action_queue as a flag (remove/replace the dead URL on the GP listing, or flag the live GMB profile) — never a delete — and conceded +180d on the crawl schedule. Full detail + the action-type routing: Prospect Enrichment & Opportunity Index §2.

  • Deep is paused (its multi-page burst tripped a site’s bot-detection — see CRAWL_ENRICH_HANDOFF_2026-05-24.md item #1). Live crawls are Shallow only, so crawl_email currently comes from the homepage alone until deep is re-enabled with a throttled plan.
  • Email extraction is on-page only. Off-page (backlinks, organic rank, GBP, reviews, social reach, ad spend) stays in the InSites Digital Footprint audit (submit_insites_df).
Ask the docsRAG over this site
Ask anything about the FCR Dashboard platform — architecture, BigQuery, the worker routes, billing rules, the LRC stack, scoring… Answers are grounded in this documentation, with source links.
How does the deal-brief refresh work? Which routes are Worker vs n8n? How is account health scored?