| .. | ||
| __init__.py | ||
| fetch.py | ||
| parsers.py | ||
| query_builder.py | ||
| README.md | ||
| schemas.py | ||
| scraper.py | ||
Google Search Results Scraper
A platform-native Google SERP scraper intended as a drop-in clone of the
Apify "Google Search Results Scraper" actor — same input surface, same
output item shape (one item per SERP page). Built on the same layout and
progressive-implementation approach as the sibling ../youtube and
../google_maps scrapers.
Current status: organic + paid SERP scraping works end-to-end. The full
Apify input surface is accepted and validated, the output models mirror the
actor's example JSON, query composition is implemented, and _serp_page_flow
fetches and parses live SERPs: organic results (with siteLinks), text ads
(paidResults), product/shopping ads (paidProducts), related queries,
suggested results, People Also Ask questions and answers (with source
url/title), the inline AI Overview (content + cited sources), and
resultsTotal. mobileResults renders with a phone UA and parses Google's
mobile lightweight layout; includeUnfilteredResults (filter=0) is verified
live; Google AI Mode (aiModeSearch.enableAiMode) emits a conversational
answer + cited sources as its own item; includeIcons puts the base64 favicon
on organic/paid results; saveHtml attaches the raw page. The only remaining
piece is the HTTP route.
How fetching works (and why it's slow)
Google's web /search is hostile: most residential IPs get a 429 "unusual
traffic" wall, and the IPs that pass serve a JavaScript shell whose organic
results only materialize after the page's JS runs. So fetch.py needs both a
non-blocked IP and a real browser render, on the same IP:
- Reuse the last known-good sticky IP if it still passes a cheap re-precheck; otherwise race prechecks on several fresh sticky IPs at once (DataImpulse maps ports → sessions) and take the first that passes,
- render on that IP using a shared long-lived browser (launched once per process, per layout; each fetch only opens a fresh context carrying its vetted proxy) — during which the render clicks the AI Overview's "Show more" clamp and the initially-served People-Also-Ask questions open (all clicks fired first, then one shared wait) so their content lands in the DOM;
- retry on fresh IPs until one returns the results container.
A warm fetch (browser up, IP cached) runs ~8 s; the first fetch of a process
also pays the ~5 s Chromium launch and a vetting round. Requires the browser
tier (patchright Chromium via Scrapling's AsyncStealthySession) and a
residential proxy — set PROXY_PROVIDER + PROXY_URL (see
.env). Long-running callers can await fetch.close_sessions() on shutdown;
scripts that exit anyway can skip it.
Scope
Included (this actor's own features):
- Organic results, paid results (ads), product ads
- Related queries, People Also Ask, suggested results
- AI Overview extraction (
aiOverview.scrapeFullAiOverview) - Google AI Mode (
aiModeSearch.enableAiMode) — google.com's dedicated AI search interface, distinct from inline AI Overviews - Full localization: country (
gl), search language (lr), interface language (hl), exact location (uule) - Advanced search filters composed into query operators (site, related, intitle/intext/inurl, filetype, before/after/qdr date ranges, exact match)
- Inline HTML capture (
saveHtml), icons (includeIcons). The actor's key-value-store snapshot (saveHtmlToKeyValueStore→htmlSnapshotUrl) is Apify storage plumbing and is skipped (accepted but ignored).
Excluded on purpose (Apify implements these by piping into other actors /
third-party data brokers): perplexitySearch, chatGptSearch,
copilotSearch, geminiSearch, linkProspecting, and business leads
enrichment (maximumLeadsEnrichmentRecords, leadsEnrichmentDepartments,
verifyLeadsEnrichmentEmails). A verbatim Apify payload containing them still
validates (extra="allow") but they are ignored.
Quick start
from app.proprietary.platforms.google_search import (
GoogleSearchScrapeInput, scrape_serps,
)
# One output item per SERP page; queries mixes terms and Google Search URLs.
items = await scrape_serps(
GoogleSearchScrapeInput(
queries="best SEO tools\nhttps://www.google.com/search?q=apify",
maxPagesPerQuery=2,
countryCode="us",
site="example.com",
aiModeSearch={"enableAiMode": True},
)
)
iter_serps() is the streaming twin. (No HTTP route yet — module only, per
the progressive rollout.)
Module map
| File | Responsibility |
|---|---|
__init__.py |
Public exports (entry points + schemas). |
schemas.py |
Pydantic input/output models mirroring the Apify camelCase spec. extra="allow" on outputs keeps the contract open. |
scraper.py |
Orchestrator. iter_serps dispatches each queries line to _term_flow / _url_flow (+ _ai_mode_flow per term). |
query_builder.py |
Pure: classify queries lines, fold advanced filters into search operators, resolve relative dates, build the URL. |
fetch.py |
Proxy-vetted two-phase fetch: cheap precheck GET + headless render on a shared sticky IP, retrying across IPs. |
parsers.py |
Rendered SERP HTML → organic / text ads / product ads / related / People-Also-Ask / resultsTotal (degrades per-field). |
Input semantics (matching Apify)
queries(required) is a newline-separated string; each line is either a plain search term (advanced Google operators allowed) or a full Google Search URL (scraped as-is; its own URL parameters win).maxPagesPerQueryunset means 1 page (~10 results per page).forceExactMatchwraps the whole term in quotes.site:takes precedence overrelated:— when both are set,relatedToSiteis ignored.wordsInTitle/wordsInText/wordsInUrlemit oneintitle:/intext:/inurl:per word (never theallin*:forms — they conflict with other operators).fileTypesare OR-joined (filetype:pdf OR filetype:doc).beforeDate/afterDateaccept absolute (2024-05-03, UTC) or relative (3 months) dates →before:/after:operators.quickDateRange(d10/w2/m6/y1) →tbs=qdr:. Avoid combining the two.includeUnfilteredResults→filter=0.- Localization:
countryCode→gl=,searchLanguage→lr=lang_*,languageCode→hl=,locationUule→uule=. Google retired country ccTLDs (google.es et al. redirect to google.com since 2025), so the country is carried bygland the domain is alwaysgoogle.com. saveHtmlToKeyValueStoredefaults true (matching the actor);saveHtmldefaults false.
Output shape (SerpItem, one per SERP page)
searchQuery— provenance:term,url,device(DESKTOP/MOBILE),page,type,domain,countryCode,languageCode,locationUuleresultsTotalorganicResults[]—title,url,displayedUrl,description,emphasizedKeywords,siteLinks,productInfo,icon,type,positionpaidResults[],paidProducts[]relatedQueries[],peopleAlsoAsk[]suggestedResults[]— the related queries re-emitted in result shape (title, google-searchurl,type: "organic", 1-basedposition), matching how the actor synthesizes themaiOverview—{content, sources[{title, url, description, imageUrl}]}when an AI Overview appears (always fully expanded)aiModeResult—{engine, provider, text, sources[], query, kvsHtmlUrl, url}when the AI Mode add-on is enabledhtml/htmlSnapshotUrl— HTML capture add-ons
All list fields default to [], unsourced scalars to None — parity is
additive, consumers never break on missing keys.
Testing
Offline unit tests (no network — query building, schema, and SERP parsing against a synthetic fixture):
cd surfsense_backend
.venv/Scripts/python.exe -m pytest tests/unit/platforms/google_search/
Live end-to-end (needs the proxy + browser tier configured):
.venv/Scripts/python.exe scripts/e2e_google_search.py
Implementation TODO (progressive, like YouTube/Maps)
- Done:
_serp_page_floworganic / text-ad (paidResults) / product-ad (paidProducts) / related / PAA /resultsTotalparsing over a proxy-vetted browser render. - Done:
focusOnPaidAds— re-renders on fresh IPs (up to 3 tries) until ads surface, since Google serves ads non-deterministically; falls back to the richest ad-less render. - Done: People-Also-Ask answers — the render clicks the first ~4 questions
open (
fetch._expand_paa); the parser handles both answer shapes (featured-snippet.hgKElcwith a source link, and AI-generated.n6owBdparagraphs with inline source chips stripped). Expansion appends extra collapsed questions, which emit question-only. - Done:
siteLinkson organic results (the expanded sitelinks table of brand queries' top result) andsuggestedResults(related queries re-shaped withtype/position, per the actor's output). - Done: inline AI Overview (
#m-x-content) — generated prose (paragraphs- bullets, inline source chips stripped) plus cited sources (title, url,
snippet, thumbnail). The render always clicks "Show more", so the full
overview is scraped whether or not
scrapeFullAiOverviewis set (a superset of the actor's gated behavior).
- bullets, inline source chips stripped) plus cited sources (title, url,
snippet, thumbnail). The render always clicks "Show more", so the full
overview is scraped whether or not
- Done:
mobileResults— renders with a Chrome-on-Android UA + phone viewport. Google serves its lightweight mobile layout (a different DOM:Gx5Zadblocks,/url?q=redirect anchors, PAA answers and the full AI Overview pre-loaded — no clicks needed);parse_serpauto-detects the layout and dispatches to the_mobile_*extractors. Mobile pages carry noresultsTotal, marked ads, or sitelinks, so those emitNone/[]. - Done:
includeUnfilteredResults(filter=0) verified live end-to-end. - Done:
_ai_mode_flow— rendersgoogle.com/search?udm=50; the conversational answer streams into[data-subtree='aimc'], which is built from the same DOM blocks as the AI Overview, so the prose/source extractors are shared. Emits one extra item per term withaiModeResult(engine/provider/text/sources/query/url). - Done:
includeIcons— the rendered desktop SERP inlines every favicon as adata:image/...;base64,URI (img.XNo5Ab), which is exactly the actor's output shape, so it's a straight attribute read on organic + paid results. The mobile lightweight layout carries no favicons. - Skipped on purpose: key-value-store HTML snapshots
(
saveHtmlToKeyValueStore→htmlSnapshotUrl) — that's Apify storage plumbing (persist the raw page for debugging/auditing), not extraction; we have no KVS equivalent andsaveHtmlalready returns the raw HTML inline when callers want it. - HTTP route + registration once the flows are live.