mirror of
https://github.com/gbessoni/seobuild-onpage.git
synced 2026-06-23 11:58:37 +02:00
Research pipeline (scripts/research.py): - extract_meta_entities() mines bolded query-matched phrases from the `highlighted` field of competitor SERP results, with inline <b>/<strong>/** fallback parsing. These are the entities Google's snippet generator already validated as relevant. - extract_target_ngrams() tokenizes top 3 competitors' headings + titles, filters via inlined English stopwords, returns top 5 bigrams/trigrams. - detect_secondary_intent() maps the funnel-next intent (Orcas 1 dual intent) with overrides for transactional title signals and brand- domain dominance. - All four signals (primary_intent, secondary_intent, meta_entities, target_ngrams) now surface at top level of research output for direct brief consumption. Compact + brief output formats updated. - DataForSEOClient._extract_serp now passes through the `highlighted` field per organic result (was being dropped silently). Framework (SKILL.md): - 41-point checklist -> 45-point checklist with Meta Entity Isolation, N-Gram AI Alignment, Dual-Intent, and Status Code Governance checks. Passing threshold raised to 36/45. - New "Technical Codebase Execution Rules" section: when run inside a project repo, detect framework (Next.js, Astro, Hugo, Jekyll, etc.), inject semantic HTML into source files, emit Apache/.htaccess + Nginx + next.config.js + Vercel snippets for 301/410 redirects. - HARD RULES rewritten as positive naming guidance. Docs: - README.md "What It Actually Does" block expanded 13 -> 14 steps reflecting dual-intent mapping, n-gram seeding, 301/410 governance. - references/quality-checklist.md adds the 4 new pass/fail checks with field references back to research.py output. - CLAUDE.md framework features list updated. Tests: - New tests/test_research_v171.py with 13 tests covering meta-entity extraction (highlighted + inline-tag + dedup), n-gram extraction (stopword filter, top-N limit, empty input), tokenizer, and secondary-intent funnel + overrides. - Live smoke-tested against airport parking JFK: meta_entities returns 8 real bolded SERP phrases; target_ngrams returns "jfk airport", "airport parking", "uncovered valet" etc. as expected. All existing test files still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
231 lines
8.5 KiB
Python
231 lines
8.5 KiB
Python
"""Tests for v1.7.1 research-pipeline additions:
|
|
extract_meta_entities, extract_target_ngrams, detect_secondary_intent.
|
|
"""
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
|
|
|
|
from research import (
|
|
extract_meta_entities,
|
|
extract_target_ngrams,
|
|
detect_secondary_intent,
|
|
_tokenize,
|
|
_STOPWORDS,
|
|
)
|
|
|
|
|
|
# --- extract_meta_entities ----------------------------------------------------
|
|
|
|
def test_meta_entities_from_highlighted_field():
|
|
"""DataForSEO returns bolded snippet phrases in `highlighted` arrays.
|
|
These are the primary source for the Meta Entity Isolation check."""
|
|
serp = {
|
|
"organic": [
|
|
{
|
|
"title": "JFK Parking",
|
|
"description": "plain description text",
|
|
"highlighted": ["JFK long-term parking", "from $12.95/day"],
|
|
},
|
|
{
|
|
"title": "SpotHero",
|
|
"description": "another result",
|
|
"highlighted": ["JFK long-term parking", "covered garages"],
|
|
},
|
|
]
|
|
}
|
|
out = extract_meta_entities(serp)
|
|
# "jfk long-term parking" appears in 2 results -> top of list
|
|
assert out[0] == "jfk long-term parking"
|
|
assert "covered garages" in out
|
|
assert "from $12.95/day" in out
|
|
|
|
|
|
def test_meta_entities_from_inline_tags_fallback():
|
|
"""Some sources embed <b>/<strong>/** in description text. Must be
|
|
parsed as a fallback so we don't lose the signal."""
|
|
serp = {
|
|
"organic": [
|
|
{
|
|
"description": "Try <b>JFK parking</b> from $20/day with <strong>free shuttle</strong>.",
|
|
"highlighted": [],
|
|
},
|
|
{
|
|
"description": "Markdown variant: **JFK parking** is competitive.",
|
|
"highlighted": [],
|
|
},
|
|
]
|
|
}
|
|
out = extract_meta_entities(serp)
|
|
assert "jfk parking" in out # appears 2x across both formats
|
|
assert "free shuttle" in out
|
|
|
|
|
|
def test_meta_entities_empty_when_no_signal():
|
|
serp = {"organic": [{"description": "no markup here", "highlighted": []}]}
|
|
assert extract_meta_entities(serp) == []
|
|
|
|
|
|
def test_meta_entities_handles_missing_organic():
|
|
assert extract_meta_entities({}) == []
|
|
assert extract_meta_entities({"organic": None}) == []
|
|
|
|
|
|
def test_meta_entities_dedupes_and_orders_by_frequency():
|
|
serp = {
|
|
"organic": [
|
|
{"highlighted": ["alpha", "beta"], "description": ""},
|
|
{"highlighted": ["alpha", "ALPHA"], "description": ""}, # case fold
|
|
{"highlighted": ["alpha", "gamma"], "description": ""},
|
|
]
|
|
}
|
|
out = extract_meta_entities(serp)
|
|
assert out[0] == "alpha" # 4 occurrences, lowercased
|
|
assert "beta" in out
|
|
assert "gamma" in out
|
|
|
|
|
|
# --- extract_target_ngrams ----------------------------------------------------
|
|
|
|
def test_ngrams_extracts_bigrams_and_trigrams():
|
|
"""Top 3 competitors' headings -> top 5 bigrams + trigrams.
|
|
Stopwords ('and', 'for') must be filtered. Numeric junk filtered."""
|
|
content_data = [
|
|
{
|
|
"title": "JFK Airport Parking Guide",
|
|
"headings": [
|
|
"H2: JFK Airport Parking Rates",
|
|
"H2: JFK Airport Parking Lots",
|
|
"H3: Long Term JFK Airport Parking",
|
|
],
|
|
},
|
|
{
|
|
"title": "Airport Parking JFK Comparison",
|
|
"headings": [
|
|
"H2: Airport Parking Costs",
|
|
"H2: Airport Parking Reviews",
|
|
],
|
|
},
|
|
{
|
|
"title": "JFK Long Term Parking",
|
|
"headings": ["H2: JFK Long Term", "H2: Long Term Parking 2026"],
|
|
},
|
|
]
|
|
out = extract_target_ngrams(content_data, top_n_competitors=3, top_k=5)
|
|
bigrams = [b["phrase"] for b in out["bigrams"]]
|
|
trigrams = [t["phrase"] for t in out["trigrams"]]
|
|
|
|
# Top bigram should be airport parking or jfk airport (both very frequent)
|
|
assert any("airport parking" in b or "jfk airport" in b for b in bigrams[:3])
|
|
# Trigrams should include jfk airport parking variants
|
|
assert any("airport parking" in t for t in trigrams)
|
|
# No bigrams should contain pure stopwords
|
|
for b in bigrams:
|
|
for tok in b.split():
|
|
assert tok not in _STOPWORDS, f"stopword in bigram: {b}"
|
|
|
|
|
|
def test_ngrams_handles_only_top_n():
|
|
"""Should respect top_n_competitors -- if we pass 5 competitors but ask
|
|
for top 3, only the first 3 are scanned."""
|
|
content_data = [
|
|
{"title": "alpha alpha alpha", "headings": []},
|
|
{"title": "alpha alpha alpha", "headings": []},
|
|
{"title": "alpha alpha alpha", "headings": []},
|
|
{"title": "ZZZ never appears", "headings": ["H2: ZZZ never appears"]},
|
|
{"title": "ZZZ never appears", "headings": ["H2: ZZZ never appears"]},
|
|
]
|
|
out = extract_target_ngrams(content_data, top_n_competitors=3, top_k=5)
|
|
bigrams = [b["phrase"] for b in out["bigrams"]]
|
|
# 'zzz' content from competitors 4-5 should not appear
|
|
for b in bigrams:
|
|
assert "zzz" not in b.lower()
|
|
|
|
|
|
def test_ngrams_empty_input():
|
|
assert extract_target_ngrams([], 3, 5) == {"bigrams": [], "trigrams": []}
|
|
assert extract_target_ngrams(None, 3, 5) == {"bigrams": [], "trigrams": []}
|
|
assert extract_target_ngrams([None, None], 3, 5) == {"bigrams": [], "trigrams": []}
|
|
|
|
|
|
def test_tokenize_filters_stopwords_and_short():
|
|
tokens = _tokenize("The JFK airport is in New York and is very busy.")
|
|
assert "the" not in tokens
|
|
assert "is" not in tokens
|
|
assert "and" not in tokens
|
|
assert "in" not in tokens
|
|
assert "jfk" in tokens
|
|
assert "airport" in tokens
|
|
assert "york" in tokens
|
|
assert "busy" in tokens
|
|
|
|
|
|
def test_tokenize_drops_pure_digits():
|
|
tokens = _tokenize("Park here for $20 in 2026 today")
|
|
assert "20" not in tokens
|
|
assert "2026" not in tokens
|
|
assert "park" in tokens
|
|
assert "today" in tokens
|
|
|
|
|
|
# --- detect_secondary_intent --------------------------------------------------
|
|
|
|
def test_secondary_intent_funnel_default():
|
|
"""Without strong SERP signals, secondary intent follows the funnel:
|
|
informational -> commercial, commercial -> transactional, etc."""
|
|
empty_serp = {"organic": []}
|
|
assert detect_secondary_intent("kw", "informational", empty_serp) == "commercial"
|
|
assert detect_secondary_intent("kw", "commercial", empty_serp) == "transactional"
|
|
assert detect_secondary_intent("kw", "transactional", empty_serp) == "navigational"
|
|
assert detect_secondary_intent("kw", "navigational", empty_serp) == "transactional"
|
|
|
|
|
|
def test_secondary_intent_transactional_override():
|
|
"""Strong transactional words in top-5 titles should pull secondary
|
|
to transactional regardless of funnel default."""
|
|
serp = {
|
|
"organic": [
|
|
{"title": "Book JFK parking now", "domain": "a.com"},
|
|
{"title": "Reserve your spot", "domain": "b.com"},
|
|
{"title": "JFK parking guide", "domain": "c.com"},
|
|
{"title": "Parking info", "domain": "d.com"},
|
|
{"title": "Long term parking", "domain": "e.com"},
|
|
]
|
|
}
|
|
# primary informational normally maps to commercial, but 'book/reserve'
|
|
# in titles forces transactional
|
|
assert detect_secondary_intent("kw", "informational", serp) == "transactional"
|
|
|
|
|
|
def test_secondary_intent_navigational_override():
|
|
"""If <=2 distinct domains hold the top 5, the intent is brand-driven."""
|
|
serp = {
|
|
"organic": [
|
|
{"title": "JFK parking", "domain": "spothero.com"},
|
|
{"title": "Book now", "domain": "spothero.com"},
|
|
{"title": "Reviews", "domain": "spothero.com"},
|
|
{"title": "Locations", "domain": "spothero.com"},
|
|
{"title": "Rates", "domain": "spothero.com"},
|
|
]
|
|
}
|
|
# With "Book"/"Reserve" not present here, navigational override kicks in
|
|
assert detect_secondary_intent("kw", "informational", serp) == "navigational"
|
|
|
|
|
|
if __name__ == "__main__":
|
|
test_meta_entities_from_highlighted_field()
|
|
test_meta_entities_from_inline_tags_fallback()
|
|
test_meta_entities_empty_when_no_signal()
|
|
test_meta_entities_handles_missing_organic()
|
|
test_meta_entities_dedupes_and_orders_by_frequency()
|
|
test_ngrams_extracts_bigrams_and_trigrams()
|
|
test_ngrams_handles_only_top_n()
|
|
test_ngrams_empty_input()
|
|
test_tokenize_filters_stopwords_and_short()
|
|
test_tokenize_drops_pure_digits()
|
|
test_secondary_intent_funnel_default()
|
|
test_secondary_intent_transactional_override()
|
|
test_secondary_intent_navigational_override()
|
|
print("All tests passed.")
|