Label the search intent behind a Google results page (or any set of web pages) and get a recommended content form: which intents the results serve, how strongly (by results and by traffic), in what form each is answered, how long the winning pages are, and when the query does not want an article at all.
Website and in-browser playground: https://romek-rozen.github.io/intent-labeler/ - bring your own OpenRouter key, and a DataForSEO login for live Google results, page reading, search volume and traffic in 16 markets. A full run costs about $0.03 in API fees (most of it DataForSEO Labs); the page shows the actual cost of every step, token counts and your remaining balances. The page has no server and stores no keys. Results marked "Public" (the default) are collected for the project through a small webhook - not published on the site until there is moderation.
n8n templates: the same method as a Google Sheets workflow and as a webhook API - see n8n-template/. The Google Sheets version is published in the n8n template library: Label search intent from Google SERPs with DataForSEO, OpenRouter and Sheets (more from the author: n8n creator page). No n8n yet? n8n (affiliate link).
Accounts you need: DataForSEO (affiliate link) for Google results, pages, volume and traffic, and an OpenRouter key for the model.
The method has one rule that makes it trustworthy: the language model only groups results; every number is computed by code. The model says "results r02, r06 and r08 serve the compare models intent". Shares, ranks, medians and the target length are counted from those IDs - the model never writes a percentage.
Charts from the bundled synthetic example (HTML report, Markdown).
Live examples in six markets (Google top 10, run on 2026-09-30). The main report uses
openai/gpt-6-luna with reasoning off; models/ in each folder holds the same data labeled by seven
low-cost models (see scripts/run_model_comparison.py). Each folder also has snapshot.json to re-run offline:
| Query | Market | Volume / seasonality | Dominant intent -> form | Reference length | Warnings |
|---|---|---|---|---|---|
| zagadki logiczne | PL | 8,100 / 1.66 | Kupno książek z zagadkami -> księgarniane listingi i strony produktów | 537 words | traffic_disagrees, wide_length_band |
| jak zrobić zakwas na chleb | PL | 4,400 / 2.82 | Zrobić domowy zakwas żytni -> Przepis krok po kroku z harmonogramem dokarmiania i wskazówkami | 486 words | - |
| kalkulator raty kredytu | PL | 1,900 / 1.7 | Symulacja raty kredytu hipotecznego -> kalkulator hipoteczny połączony z informacją o ofertach lub konsultacją | 2001 words | wide_length_band, serp_does_not_want_an_article |
| senuto | PL | 6,600 / 1.52 | Poznać platformę Senuto -> strona produktowa z opisem funkcji | 1525 words | - |
| standing desk | US | 135,000 / 1.23 | Shop for a standing desk -> Retailer product-category listing | 423 words | wide_length_band |
| how to make sourdough starter | US | 74,000 / 2.66 | Make a starter from scratch -> Day-by-day starter recipe with measurements and readiness cues | n/a (insufficient_sample) | - |
| best running shoes | US | 60,500 / 1.42 | Compare top running shoes -> Editorial best-of roundup with category-based recommendations | n/a (insufficient_sample) | mixed_serp, traffic_disagrees |
| neuronwriter | US | 720 / 1.73 | Review NeuronWriter's value -> hands-on review with pros, cons, and user experience | n/a (insufficient_sample) | consider_separate_pages |
| seo agency london | UK | 3,600 / 2.54 | Hire a London SEO agency -> Local agency service pages with services, process, and quote or audit calls to action | 803 words | mixed_serp, traffic_disagrees |
| Sauerteig ansetzen | DE | 74,000 / 2.6 | Sauerteigstarter selbst ansetzen -> Schritt-für-Schritt-Anleitung mit Tagesplan und kurzen Videos | n/a (insufficient_sample) | - |
| Wärmepumpe Kosten | DE | 40,500 / 1.54 | Laufende Stromkosten abschätzen -> Rechenhilfe mit Verbrauchsbeispielen, Stromtarifen und Spartipps | 861 words | - |
| kindergeld | DE | 201,000 / 1.52 | Anspruch und Bezugsdauer -> Anspruchsübersicht mit Alters- und Lebenssituationen | n/a (spread_too_wide) | consider_separate_pages, traffic_disagrees |
| come fare il lievito madre | IT | 3,600 / 4.43 | Preparare il lievito madre in casa -> Ricetta guidata con dosi, passaggi e tempi di fermentazione | 1289 words | - |
| calcolo rata mutuo | IT | 49,500 / 1.96 | Calcolare la rata del mutuo -> Calcolatore interattivo con stima della rata e piano di ammortamento | n/a (insufficient_sample) | - |
| recette pâte à crêpes | FR | 33,100 / 7.69 | Préparer une pâte à crêpes classique -> Recette illustrée avec ingrédients et étapes | 918 words | mixed_serp, consider_separate_pages |
| meilleur aspirateur robot | FR | 6,600 / 2.67 | Comparer les meilleurs modèles -> Comparatif de modèles testés avec critères et recommandations | n/a (insufficient_sample) | mixed_serp, traffic_disagrees |
| Output | Meaning |
|---|---|
| Intents | searcher goals that emerge from the results - no fixed taxonomy, no fixed count - with an optional coarse tag for filtering |
| Form per intent | how each intent is best answered, named freely ("shop category listing", "PDF set", "quiz") |
| Coverage | fraction of results that address the intent (a page can address several) |
| Answer share | how results divide between intents; a result in two intents counts half to each |
| Traffic share | the same from estimated traffic (etv); unknown traffic is left out, never zero |
| Content form per page | tables, lists, images, video, FAQ, forms, calculator inputs - counted from HTML, and their prevalence per intent |
| Page types | what each result is (category listing, buying guide, brand page with FAQ...), named freely |
| Heading themes | topics the pages cover, with how many results cover each - what a complete answer addresses |
| Search demand | monthly volume, CPC, keyword difficulty and seasonality (peak and low month, index, year over year) |
| Brands, AI Overview | brands present in the results; what the AI Overview (or its absence) says |
| Expected genre | the content genre the SERP expects, and whether an article fits at all |
| Reference length | p25/p50/p75 in words and characters of the dominant intent's pages - or null and a reason |
| Warnings | mixed SERP, several major intents, traffic disagrees, wide length band, not an article |
| Elements | presentation elements that do a job for these searchers, and forms to avoid |
| Reader questions | from People Also Ask, related searches and headings |
git clone https://github.com/romek-rozen/intent-labeler.git
cd intent-labeler
python -m venv .venv && . .venv/bin/activate
pip install -e '.[api,extract]' # core has zero dependencies; [api] = FastAPI, [extract] = trafilatura
cp .env.example .env # then fill in the LLM endpoint and modelAny OpenAI-compatible endpoint works: OpenAI, OpenRouter, LiteLLM, vLLM, Ollama (http://localhost:11434/v1).
A cheap model is enough, with reasoning switched off: openai/gpt-6-luna on OpenRouter with
INTENT_LLM_TEMPERATURE=none and INTENT_LLM_EXTRA_BODY={"reasoning":{"enabled":false}} (the defaults
in .env.example). Seven low-cost models were benchmarked on the same data; see the model comparison
on every example page and scripts/run_model_comparison.py.
# 1. Live Google top 10 + traffic + search volume (needs DataForSEO; about $0.03 in API costs). Location 2616 = Poland, 2840 = US.
intent-labeler --keyword "zagadki logiczne" --language pl --location 2616 --save-snapshot
# 2. Any set of pages - a competitor list, your own site section, 20 URLs from a client
intent-labeler --urls my-pages.txt --language en
# 3. A saved snapshot: offline and reproducible (no SERP call, cached LLM answer)
intent-labeler --snapshot examples/sample_snapshot.json --no-fetch --traffic offEach run writes out/analysis.json, out/report.html (self-contained, light and dark mode) and
out/report.md. Add --brief "a buying guide for home office workers" to give the model context
about the page you plan; it never changes which intents are reported.
uvicorn intent_labeler.api.app:app --port 8000 # or: docker build -t intent-labeler . && docker run -p 8000:8000 --env-file .env intent-labeler| Endpoint | Input |
|---|---|
POST /analyze/keyword |
{"keyword": "...", "language": "en", "location_code": 2840, "traffic": true} |
POST /analyze/urls |
{"urls": ["https://...", ...]} (max 50) |
POST /analyze/html-files |
multipart upload of saved .html files (max 50) |
POST /analyze/snapshot |
{"snapshot": {...}} |
Add ?format=html or ?format=md to get a report instead of JSON. Interactive docs at /docs.
Details: docs/API.md.
from intent_labeler import analyze
from intent_labeler.core import llm
from intent_labeler.core.config import LlmConfig
from intent_labeler.features import page_source
snapshot = page_source.snapshot_from_urls(["https://a.example/guide", "https://b.example/shop"])
result = analyze(snapshot, chat=llm.openai_chat(LlmConfig.from_env()))
print(result["form"]["dominant_intent_title"], result["form"]["length_words"])chat is any (system, user) -> str function, so plugging in another SDK takes three lines.
flowchart TD
K[keyword] --> S[serp_source<br/>Google top 10 via DataForSEO]
U[URL list / HTML files] --> P0[page_source<br/>page set without ranks]
S --> SN[(Snapshot)]
P0 --> SN
SN --> F[page_source.enrich<br/>fetch pages, trafilatura text]
F --> D[digest 400 chars<br/>8 headings + 3 paragraphs]
F --> E[element inventory<br/>tables, lists, images, video, FAQ, forms]
F --> W[words and characters<br/>thin pages flagged]
SN --> T[traffic<br/>etv per URL, unknown stays unknown]
D --> L{{intent_labeling - the only LLM call<br/>sees titles, snippets, digests - NO numbers<br/>GROUPS results into emergent intents<br/>names form, genre, page types, themes}}
L --> V[contract validation<br/>errors go back to the model<br/>unplaced results -> unassigned]
V --> M[metrics - code only<br/>coverage, answer share, traffic share<br/>length distributions, element prevalence]
E --> M
W --> M
T --> M
M --> FD[form_decision - code only<br/>dominant intent, reference length<br/>genre guard, warnings]
FD --> R[report<br/>HTML with SVG charts, Markdown, JSON]
style L fill:#4a3aa7,color:#fff
style M fill:#1baf7a,color:#fff
style FD fill:#1baf7a,color:#fff
The purple box is the only place a language model works; the green boxes are plain arithmetic.
- Source - Google top 10 (DataForSEO) or a page list becomes a
SnapshotofResults. - Fetch - pages are downloaded; text is extracted with trafilatura (if installed, else a stdlib parser);
a 400-char digest (8 headings + 3 paragraphs), words, characters and a structural inventory
(tables, lists, images, video, FAQ, forms) are recorded. Pages under 150 words are flagged
thinand fall back to title + snippet. - Traffic and demand - DataForSEO estimates
etvfor every URL (unknown stays unknown) and returns the keyword's search volume with 8 years of monthly history; code derives seasonality. - Label - one LLM call groups results into emergent intents and names each form and the genre.
Invalid JSON goes back to the model with the exact error. Unplaced results land in
unassigned. - Measure - code computes coverage, answer and traffic shares, length distributions, element prevalence, and coverage of every page type, heading theme and reader question.
- Decide - code picks the dominant intent, the reference length and the warnings.
- Report - HTML with charts, Markdown, JSON.
The full method, with the reasons behind each rule, is in docs/METHOD.md. Code layout: docs/ARCHITECTURE.md.
- Page fetching uses plain HTTP. JavaScript-rendered pages come back
thin; bot-protected sites come backerror. Both stay in the intent analysis (title and snippet are enough to label them) but not in length statistics. When most results are video or social (see the sourdough example), there is honestly no reference length. - The labeling is as good as the model; see METHOD.md on model choice.
- Traffic share needs DataForSEO (automatic with
--keyword) or your ownetvper result.
pip install -e '.[dev,api,extract]'
pytest # offline, no API keys, ~1 s
python scripts/build_examples.py # rebuild docs/example-report.* and docs/images/*Contributors and coding agents: read AGENTS.md first. Every feature has its own README - start from src/intent_labeler/features/README.md.
MIT