@adgine/geo-cli 1.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +93 -0
- package/bin/adgine-geo.js +224 -0
- package/package.json +38 -0
- package/skills/.env.example +11 -0
- package/skills/README.md +247 -0
- package/skills/SKILL.md +117 -0
- package/skills/VERSION +1 -0
- package/skills/adgine-geo-aiagent/SKILL.md +200 -0
- package/skills/adgine-geo-aiagent/scripts/_client.py +341 -0
- package/skills/adgine-geo-aiagent/scripts/bot_traffic.py +278 -0
- package/skills/adgine-geo-aiagent/scripts/human_traffic.py +357 -0
- package/skills/adgine-geo-aiagent/scripts/page_analytics.py +317 -0
- package/skills/adgine-geo-aiagent/scripts/page_detail.py +273 -0
- package/skills/adgine-geo-analytics/API.md +89 -0
- package/skills/adgine-geo-analytics/SKILL.md +191 -0
- package/skills/adgine-geo-analytics/scripts/_client.py +341 -0
- package/skills/adgine-geo-analytics/scripts/get_overview.py +129 -0
- package/skills/adgine-geo-billing/SKILL.md +118 -0
- package/skills/adgine-geo-billing/scripts/_client.py +341 -0
- package/skills/adgine-geo-billing/scripts/get_credits.py +51 -0
- package/skills/adgine-geo-billing/scripts/get_credits_pricing.py +52 -0
- package/skills/adgine-geo-billing/scripts/get_subscription.py +48 -0
- package/skills/adgine-geo-billing/scripts/list_plans.py +66 -0
- package/skills/adgine-geo-brand/SKILL.md +193 -0
- package/skills/adgine-geo-brand/WORKFLOW.md +66 -0
- package/skills/adgine-geo-brand/scripts/_client.py +341 -0
- package/skills/adgine-geo-brand/scripts/generate_brand.py +95 -0
- package/skills/adgine-geo-brand/scripts/get_brand.py +67 -0
- package/skills/adgine-geo-brand/scripts/list_jobs.py +153 -0
- package/skills/adgine-geo-brand/scripts/update_brand.py +61 -0
- package/skills/adgine-geo-citation/SKILL.md +220 -0
- package/skills/adgine-geo-citation/scripts/_client.py +341 -0
- package/skills/adgine-geo-citation/scripts/create_tests.py +62 -0
- package/skills/adgine-geo-citation/scripts/get_aggregate.py +108 -0
- package/skills/adgine-geo-citation/scripts/get_results.py +179 -0
- package/skills/adgine-geo-content/SKILL.md +224 -0
- package/skills/adgine-geo-content/WORKFLOW.md +100 -0
- package/skills/adgine-geo-content/scripts/_client.py +341 -0
- package/skills/adgine-geo-content/scripts/generate_article.py +108 -0
- package/skills/adgine-geo-content/scripts/generate_outline.py +113 -0
- package/skills/adgine-geo-content/scripts/generate_titles.py +51 -0
- package/skills/adgine-geo-content/scripts/list_content.py +65 -0
- package/skills/adgine-geo-content/scripts/manage_content.py +90 -0
- package/skills/adgine-geo-content/scripts/manage_jobs.py +184 -0
- package/skills/adgine-geo-dashboard/SKILL.md +181 -0
- package/skills/adgine-geo-dashboard/scripts/_client.py +341 -0
- package/skills/adgine-geo-dashboard/scripts/check_integrations.py +139 -0
- package/skills/adgine-geo-dashboard/scripts/get_overview.py +155 -0
- package/skills/adgine-geo-dashboard/scripts/get_visibility_overview.py +93 -0
- package/skills/adgine-geo-docs/SKILL.md +60 -0
- package/skills/adgine-geo-domains/SKILL.md +223 -0
- package/skills/adgine-geo-domains/scripts/_client.py +341 -0
- package/skills/adgine-geo-domains/scripts/get_domain.py +75 -0
- package/skills/adgine-geo-domains/scripts/list_domains.py +66 -0
- package/skills/adgine-geo-domains/scripts/search_domains.py +85 -0
- package/skills/adgine-geo-integrations/SKILL.md +188 -0
- package/skills/adgine-geo-integrations/scripts/_client.py +341 -0
- package/skills/adgine-geo-integrations/scripts/cloudflare_connect.py +155 -0
- package/skills/adgine-geo-integrations/scripts/cloudflare_worker.py +212 -0
- package/skills/adgine-geo-integrations/scripts/ga4_data.py +192 -0
- package/skills/adgine-geo-integrations/scripts/ga4_setup.py +120 -0
- package/skills/adgine-geo-opportunities/SKILL.md +162 -0
- package/skills/adgine-geo-opportunities/scripts/_client.py +341 -0
- package/skills/adgine-geo-opportunities/scripts/get_opportunity.py +90 -0
- package/skills/adgine-geo-opportunities/scripts/list_opportunities.py +92 -0
- package/skills/adgine-geo-performance/SKILL.md +177 -0
- package/skills/adgine-geo-performance/scripts/_client.py +341 -0
- package/skills/adgine-geo-performance/scripts/analyze_page.py +100 -0
- package/skills/adgine-geo-projects/SKILL.md +279 -0
- package/skills/adgine-geo-projects/scripts/_client.py +341 -0
- package/skills/adgine-geo-projects/scripts/check_auth.py +45 -0
- package/skills/adgine-geo-projects/scripts/list_projects.py +47 -0
- package/skills/adgine-geo-projects/scripts/manage_competitors.py +122 -0
- package/skills/adgine-geo-projects/scripts/manage_project.py +88 -0
- package/skills/adgine-geo-saas/SKILL.md +144 -0
- package/skills/adgine-geo-saas/scripts/_client.py +341 -0
- package/skills/adgine-geo-saas/scripts/check_domain.py +43 -0
- package/skills/adgine-geo-saas/scripts/create_website.py +56 -0
- package/skills/adgine-geo-saas/scripts/get_task.py +94 -0
- package/skills/adgine-geo-site-audit/README.md +281 -0
- package/skills/adgine-geo-site-audit/SKILL.md +449 -0
- package/skills/adgine-geo-site-audit/requirements.txt +5 -0
- package/skills/adgine-geo-site-audit/scripts/_client.py +341 -0
- package/skills/adgine-geo-site-audit/scripts/_geo_notice.py +55 -0
- package/skills/adgine-geo-site-audit/scripts/export_skill_package.py +197 -0
- package/skills/adgine-geo-site-audit/scripts/geo_collect.py +2443 -0
- package/skills/adgine-geo-site-audit/scripts/geo_score.py +490 -0
- package/skills/adgine-geo-site-audit/scripts/geo_timing.py +399 -0
- package/skills/adgine-geo-site-audit/scripts/geo_visibility.py +718 -0
- package/skills/adgine-geo-site-audit/scripts/render_report_pdf.py +1502 -0
- package/skills/adgine-geo-topics/SKILL.md +232 -0
- package/skills/adgine-geo-topics/scripts/_client.py +341 -0
- package/skills/adgine-geo-topics/scripts/generate_prompts.py +140 -0
- package/skills/adgine-geo-topics/scripts/manage_prompts.py +153 -0
- package/skills/adgine-geo-topics/scripts/manage_topics.py +145 -0
- package/skills/adgine-geo-visibility/SKILL.md +168 -0
- package/skills/adgine-geo-visibility/scripts/_client.py +341 -0
- package/skills/adgine-geo-visibility/scripts/get_execution.py +151 -0
- package/skills/adgine-geo-visibility/scripts/get_matrix.py +111 -0
- package/skills/adgine-geo-visibility/scripts/get_prompt_metrics.py +104 -0
- package/skills/adgine-geo-visibility/scripts/get_topic_metrics.py +201 -0
- package/skills/adgine-geo-visibility/scripts/get_visibility.py +166 -0
- package/skills/adgine-geo-wordpress/SKILL.md +151 -0
- package/skills/adgine-geo-wordpress/scripts/_client.py +341 -0
- package/skills/adgine-geo-wordpress/scripts/list_publishable.py +62 -0
- package/skills/adgine-geo-wordpress/scripts/manage_credentials.py +139 -0
- package/skills/adgine-geo-wordpress/scripts/manage_publishes.py +108 -0
- package/skills/adgine-geo-wordpress/scripts/publish.py +79 -0
- package/skills/scripts/check_version.py +290 -0
- package/skills/setup.py +248 -0
- package/src/config.js +126 -0
- package/src/doctor.js +137 -0
- package/src/install.js +90 -0
- package/src/manifest.js +99 -0
- package/src/runner.js +159 -0
|
@@ -0,0 +1,2443 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""GEO Audit Signal Collector — collects structured signals for agent judgment.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
python scripts/geo_collect.py <url> [--output <path>] [--render auto|always|never]
|
|
6
|
+
[--max-subpages 20] [--concurrency 6]
|
|
7
|
+
|
|
8
|
+
Outputs a JSON object with:
|
|
9
|
+
- meta: fetch metadata (url, domain, render_method, fetched_at, timings)
|
|
10
|
+
- signals: per-check programmatic signals (numbers, booleans, lists)
|
|
11
|
+
- snippets: text excerpts for agent semantic judgment (max ~500 chars each)
|
|
12
|
+
- errors: any collection failures
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import glob
|
|
18
|
+
import gzip
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import shutil
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
import time
|
|
27
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
28
|
+
from dataclasses import dataclass, field, asdict
|
|
29
|
+
from datetime import datetime, timezone
|
|
30
|
+
from typing import Any, Optional
|
|
31
|
+
from urllib.parse import quote as _url_quote, urljoin, urlparse
|
|
32
|
+
from xml.etree import ElementTree
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
from curl_cffi import requests
|
|
36
|
+
from bs4 import BeautifulSoup
|
|
37
|
+
except ImportError as _exc:
|
|
38
|
+
print(f"ERROR: Missing dependency — {_exc.name}")
|
|
39
|
+
print(" Fix: pip install -r adgine-geo-site-audit/requirements.txt")
|
|
40
|
+
print(" Or: pip install curl_cffi beautifulsoup4 lxml")
|
|
41
|
+
sys.exit(1)
|
|
42
|
+
|
|
43
|
+
# Default browser impersonation profile for curl_cffi
|
|
44
|
+
_IMPERSONATE = "chrome"
|
|
45
|
+
|
|
46
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
|
47
|
+
logger = logging.getLogger(__name__)
|
|
48
|
+
|
|
49
|
+
# ============================================================================
|
|
50
|
+
# Constants
|
|
51
|
+
# ============================================================================
|
|
52
|
+
|
|
53
|
+
DEFAULT_TIMEOUT = 15
|
|
54
|
+
MAX_SUBPAGES = 20
|
|
55
|
+
DEFAULT_CONCURRENCY = 6
|
|
56
|
+
MAX_SITEMAP_FETCHES = 25
|
|
57
|
+
MAX_SITEMAP_PAGE_URLS = 5000
|
|
58
|
+
SNIPPET_MAX_LEN = 500
|
|
59
|
+
|
|
60
|
+
CRAWLER_UAS: dict[str, str] = {
|
|
61
|
+
"GPTBot": "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; GPTBot/1.0; +https://openai.com/gptbot",
|
|
62
|
+
"ChatGPT-User": "Mozilla/5.0 (compatible; ChatGPT-User/1.0; +https://openai.com/bot)",
|
|
63
|
+
"ClaudeBot": "Mozilla/5.0 (compatible; ClaudeBot/1.0; +claudebot@anthropic.com)",
|
|
64
|
+
"PerplexityBot": "Mozilla/5.0 (compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)",
|
|
65
|
+
"Googlebot": "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
|
|
66
|
+
"Bingbot": "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)",
|
|
67
|
+
"Google-Extended": "Mozilla/5.0 (compatible; Google-Extended)",
|
|
68
|
+
"Bytespider": "Mozilla/5.0 (compatible; Bytespider; spider-feedback@bytedance.com)",
|
|
69
|
+
"CCBot": "CCBot/2.0 (https://commoncrawl.org/faq/)",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
DEFAULT_UA = "Mozilla/5.0 (compatible; AdgineGeoAudit/1.0)"
|
|
73
|
+
|
|
74
|
+
TRUST_PATHS_EN = ["contact", "privacy", "terms", "security", "support", "about"]
|
|
75
|
+
TRUST_PATHS_ZH = ["联系我们", "隐私政策", "服务条款", "安全", "帮助中心", "关于我们"]
|
|
76
|
+
|
|
77
|
+
EXPERIENCE_KEYWORDS = [
|
|
78
|
+
"screenshot", "截图", "实测", "case study", "hands-on", "demo",
|
|
79
|
+
"backtest", "教程", "客户反馈", "testimonial", "before and after",
|
|
80
|
+
"roi", "tutorial", "实操", "评测",
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
AUTHORITY_SOURCES = [".gov", ".edu", "reuters.com", "bloomberg.com", "gartner.com",
|
|
84
|
+
"mckinsey.com", "who.int", "worldbank.org", "statista.com"]
|
|
85
|
+
|
|
86
|
+
ASSET_KEYWORDS = {
|
|
87
|
+
"guide": ["guide", "指南"],
|
|
88
|
+
"comparison": ["comparison", "对比", " vs ", "versus"],
|
|
89
|
+
"case_study": ["case study", "案例", "customer story"],
|
|
90
|
+
"methodology": ["methodology", "方法论", "framework", "框架"],
|
|
91
|
+
"report": ["report", "报告", "whitepaper", "白皮书"],
|
|
92
|
+
"glossary": ["glossary", "术语", "词汇表"],
|
|
93
|
+
"tutorial": ["tutorial", "教程", "how-to", "quickstart"],
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
SITEMAP_CANDIDATE_PATHS = [
|
|
97
|
+
"/sitemap.xml",
|
|
98
|
+
"/sitemap.xml.gz",
|
|
99
|
+
"/sitemap_index.xml",
|
|
100
|
+
"/sitemap_index.xml.gz",
|
|
101
|
+
"/sitemap-index.xml",
|
|
102
|
+
"/sitemap-index.xml.gz",
|
|
103
|
+
"/sitemap/sitemap.xml",
|
|
104
|
+
"/sitemap/sitemap_index.xml",
|
|
105
|
+
"/sitemap/sitemap-index.xml",
|
|
106
|
+
"/sitemaps.xml",
|
|
107
|
+
"/sitemaps/sitemap.xml",
|
|
108
|
+
"/wp-sitemap.xml",
|
|
109
|
+
"/page-sitemap.xml",
|
|
110
|
+
"/post-sitemap.xml",
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
SITEMAP_PRODUCT_SEGMENTS = {
|
|
114
|
+
"products", "product", "payments", "billing", "checkout", "connect",
|
|
115
|
+
"tax", "radar", "terminal", "issuing", "treasury", "atlas", "identity",
|
|
116
|
+
"invoicing", "subscriptions", "sigma", "climate", "capital",
|
|
117
|
+
"financial-connections", "payment-links",
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
SITEMAP_CONVERSION_SEGMENTS = SITEMAP_PRODUCT_SEGMENTS | {
|
|
121
|
+
"service", "services", "course", "courses", "solution", "solutions",
|
|
122
|
+
"feature", "features", "pricing", "prices", "fees", "plans",
|
|
123
|
+
"app-download", "download", "downloads", "app", "apps", "signup",
|
|
124
|
+
"sign-up", "register", "join", "start", "trial", "demo", "book-demo",
|
|
125
|
+
"campaign", "campaigns", "activity", "activities", "event", "events",
|
|
126
|
+
"promo", "promotions", "offer", "offers",
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
SITEMAP_TRUST_SEGMENTS = {
|
|
130
|
+
"about", "contact", "company", "team", "support", "privacy", "terms",
|
|
131
|
+
"legal", "risk", "risks", "security", "trust", "regulatory",
|
|
132
|
+
"regulation", "compliance", "reviews", "review", "licenses", "license",
|
|
133
|
+
"responsible", "safety",
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
SITEMAP_CITATION_SEGMENTS = {
|
|
137
|
+
"guides", "guide", "docs", "documentation", "developers", "developer",
|
|
138
|
+
"help", "faq", "faqs", "questions", "compare", "comparison",
|
|
139
|
+
"alternatives", "vs", "blog", "blogs", "glossary", "resources",
|
|
140
|
+
"resource", "learn", "tutorial", "tutorials", "article", "articles",
|
|
141
|
+
"news", "reports", "report", "research", "whitepapers", "whitepaper",
|
|
142
|
+
"ebooks", "ebook", "academy", "case-studies", "case-study", "stories",
|
|
143
|
+
"customers", "customer-stories",
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
SITEMAP_TECHNICAL_SEGMENTS = {
|
|
147
|
+
"robots.txt", "sitemap.xml", "sitemap_index.xml", "sitemap-index.xml",
|
|
148
|
+
"sitemaps.xml", "wp-sitemap.xml", "page-sitemap.xml", "post-sitemap.xml",
|
|
149
|
+
"llms.txt", "llms-full.txt",
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
SITEMAP_RISK_SEGMENTS = {
|
|
153
|
+
"404", "not-found", "notfound", "error", "errors", "fallback",
|
|
154
|
+
"component", "components", "search", "login", "signin", "sign-in",
|
|
155
|
+
"dashboard", "auth", "account", "wp-json", "xmlrpc.php", "synthetic",
|
|
156
|
+
"synthetic-404", "blocked", "bot-blocked", "bot", "challenge",
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
SITEMAP_CATEGORY_SCORES = {
|
|
160
|
+
"conversion": 170,
|
|
161
|
+
"trust": 150,
|
|
162
|
+
"citation": 135,
|
|
163
|
+
"template": 90,
|
|
164
|
+
"technical_entry": 20,
|
|
165
|
+
"risk_validation": -60,
|
|
166
|
+
"default": 40,
|
|
167
|
+
"asset": -120,
|
|
168
|
+
"homepage": -200,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
SITEMAP_SAMPLE_CATEGORY_ORDER = [
|
|
172
|
+
"conversion",
|
|
173
|
+
"trust",
|
|
174
|
+
"citation",
|
|
175
|
+
"template",
|
|
176
|
+
"technical_entry",
|
|
177
|
+
"default",
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
HOMEPAGE_LINK_SKIP_EXTENSIONS = {
|
|
181
|
+
".7z", ".avi", ".css", ".csv", ".doc", ".docx", ".eot", ".gif", ".ico",
|
|
182
|
+
".jpeg", ".jpg", ".js", ".json", ".m4a", ".mov", ".mp3", ".mp4", ".otf",
|
|
183
|
+
".pdf", ".png", ".rar", ".rss", ".svg", ".ttf", ".txt", ".wav", ".webm",
|
|
184
|
+
".webmanifest", ".webp", ".woff", ".woff2", ".xls", ".xlsx", ".xml",
|
|
185
|
+
".zip",
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
# ============================================================================
|
|
189
|
+
# HTTP Layer
|
|
190
|
+
# ============================================================================
|
|
191
|
+
|
|
192
|
+
_CHROME_PATHS: list[str] = [
|
|
193
|
+
"headless_shell",
|
|
194
|
+
"headless_shell.exe",
|
|
195
|
+
"chromium",
|
|
196
|
+
"chromium.exe",
|
|
197
|
+
"chromium-browser",
|
|
198
|
+
"google-chrome",
|
|
199
|
+
"google-chrome-stable",
|
|
200
|
+
"chrome",
|
|
201
|
+
"chrome.exe",
|
|
202
|
+
"msedge",
|
|
203
|
+
"msedge.exe",
|
|
204
|
+
"/Applications/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
|
|
205
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
206
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
207
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
208
|
+
]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _chrome_candidate_paths() -> list[str]:
|
|
212
|
+
candidates: list[str] = []
|
|
213
|
+
env_path = os.environ.get("GEO_AUDIT_CHROME_PATH")
|
|
214
|
+
if env_path:
|
|
215
|
+
candidates.append(env_path)
|
|
216
|
+
|
|
217
|
+
home = os.path.expanduser("~")
|
|
218
|
+
local_app_data = os.environ.get("LOCALAPPDATA")
|
|
219
|
+
program_files = [os.environ.get("PROGRAMFILES"), os.environ.get("PROGRAMFILES(X86)")]
|
|
220
|
+
|
|
221
|
+
patterns = [
|
|
222
|
+
os.path.join(home, "Library/Caches/ms-playwright/chromium_headless_shell-*/chrome-*/headless_shell"),
|
|
223
|
+
os.path.join(home, "Library/Caches/ms-playwright/chromium-*/chrome-*/headless_shell"),
|
|
224
|
+
os.path.join(home, "Library/Caches/ms-playwright/chromium-*/chrome-*/Chromium.app/Contents/MacOS/Chromium"),
|
|
225
|
+
os.path.join(home, "Library/Caches/ms-playwright/chromium-*/chrome-*/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"),
|
|
226
|
+
os.path.join(home, ".cache/ms-playwright/chromium_headless_shell-*/chrome-*/headless_shell"),
|
|
227
|
+
os.path.join(home, ".cache/ms-playwright/chromium-*/chrome-*/headless_shell"),
|
|
228
|
+
]
|
|
229
|
+
if local_app_data:
|
|
230
|
+
patterns.extend([
|
|
231
|
+
os.path.join(local_app_data, "ms-playwright", "chromium_headless_shell-*", "chrome-win", "headless_shell.exe"),
|
|
232
|
+
os.path.join(local_app_data, "ms-playwright", "chromium-*", "chrome-win", "headless_shell.exe"),
|
|
233
|
+
os.path.join(local_app_data, "ms-playwright", "chromium-*", "chrome-win", "chrome.exe"),
|
|
234
|
+
os.path.join(local_app_data, "ms-playwright", "chromium-*", "chrome-win", "Chromium.exe"),
|
|
235
|
+
])
|
|
236
|
+
for base in program_files:
|
|
237
|
+
if not base:
|
|
238
|
+
continue
|
|
239
|
+
candidates.extend([
|
|
240
|
+
os.path.join(base, "Google", "Chrome for Testing", "Application", "chrome.exe"),
|
|
241
|
+
os.path.join(base, "Google", "Chrome", "Application", "chrome.exe"),
|
|
242
|
+
os.path.join(base, "Microsoft", "Edge", "Application", "msedge.exe"),
|
|
243
|
+
os.path.join(base, "Chromium", "Application", "chrome.exe"),
|
|
244
|
+
])
|
|
245
|
+
for pattern in patterns:
|
|
246
|
+
candidates.extend(sorted(glob.glob(pattern), reverse=True))
|
|
247
|
+
candidates.extend(_CHROME_PATHS)
|
|
248
|
+
|
|
249
|
+
deduped: list[str] = []
|
|
250
|
+
seen: set[str] = set()
|
|
251
|
+
for candidate in candidates:
|
|
252
|
+
if candidate and candidate not in seen:
|
|
253
|
+
deduped.append(candidate)
|
|
254
|
+
seen.add(candidate)
|
|
255
|
+
return deduped
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _get(session: requests.Session, url: str, *, ua: str = DEFAULT_UA,
|
|
259
|
+
timeout: int = DEFAULT_TIMEOUT) -> dict:
|
|
260
|
+
"""Fetch a URL and return a dict with status, headers, text, final_url, error."""
|
|
261
|
+
try:
|
|
262
|
+
# When using a custom (bot) UA, skip impersonate to avoid TLS/UA mismatch.
|
|
263
|
+
# When using the default audit UA, rely on impersonate for full browser mimicry.
|
|
264
|
+
use_impersonate = (ua == DEFAULT_UA)
|
|
265
|
+
kwargs: dict[str, Any] = {
|
|
266
|
+
"timeout": timeout,
|
|
267
|
+
"allow_redirects": True,
|
|
268
|
+
}
|
|
269
|
+
if use_impersonate:
|
|
270
|
+
kwargs["impersonate"] = _IMPERSONATE
|
|
271
|
+
else:
|
|
272
|
+
kwargs["headers"] = {"User-Agent": ua, "Accept": "*/*"}
|
|
273
|
+
r = session.get(url, **kwargs)
|
|
274
|
+
text = ""
|
|
275
|
+
try:
|
|
276
|
+
content_type = r.headers.get("Content-Type", "").lower()
|
|
277
|
+
is_gzip_sitemap = (
|
|
278
|
+
urlparse(r.url).path.endswith(".gz")
|
|
279
|
+
or "application/x-gzip" in content_type
|
|
280
|
+
)
|
|
281
|
+
if is_gzip_sitemap:
|
|
282
|
+
text = gzip.decompress(r.content).decode(
|
|
283
|
+
r.encoding or "utf-8", errors="replace"
|
|
284
|
+
)
|
|
285
|
+
else:
|
|
286
|
+
text = r.text
|
|
287
|
+
except Exception:
|
|
288
|
+
try:
|
|
289
|
+
text = r.text
|
|
290
|
+
except Exception:
|
|
291
|
+
pass
|
|
292
|
+
return {
|
|
293
|
+
"url": url, "status": r.status_code, "ok": r.ok,
|
|
294
|
+
"headers": dict(r.headers), "text": text,
|
|
295
|
+
"final_url": r.url, "error": "",
|
|
296
|
+
}
|
|
297
|
+
except (requests.RequestsError, OSError) as e:
|
|
298
|
+
return {"url": url, "status": 0, "ok": False, "headers": {},
|
|
299
|
+
"text": "", "final_url": "", "error": str(e)}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _get_with_new_session(url: str, *, ua: str = DEFAULT_UA,
|
|
303
|
+
timeout: int = DEFAULT_TIMEOUT) -> dict:
|
|
304
|
+
"""Fetch a URL in a worker thread with an isolated curl_cffi session."""
|
|
305
|
+
with requests.Session() as session:
|
|
306
|
+
return _get(session, url, ua=ua, timeout=timeout)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _bounded_workers(concurrency: int) -> int:
|
|
310
|
+
return max(1, min(int(concurrency or 1), 12))
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _timed_call(func, *args, **kwargs) -> tuple[Any, float]:
|
|
314
|
+
started = time.perf_counter()
|
|
315
|
+
result = func(*args, **kwargs)
|
|
316
|
+
return result, round(time.perf_counter() - started, 3)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _parallel_fetch(
|
|
320
|
+
jobs: list[tuple[str, str, str]],
|
|
321
|
+
*,
|
|
322
|
+
concurrency: int = DEFAULT_CONCURRENCY,
|
|
323
|
+
timeout: int = DEFAULT_TIMEOUT,
|
|
324
|
+
) -> dict[str, dict]:
|
|
325
|
+
"""Fetch jobs concurrently and return results by caller-provided key."""
|
|
326
|
+
if not jobs:
|
|
327
|
+
return {}
|
|
328
|
+
workers = min(_bounded_workers(concurrency), len(jobs))
|
|
329
|
+
job_by_key = {key: (url, ua) for key, url, ua in jobs}
|
|
330
|
+
results: dict[str, dict] = {}
|
|
331
|
+
with ThreadPoolExecutor(max_workers=workers) as executor:
|
|
332
|
+
future_to_key = {
|
|
333
|
+
executor.submit(_get_with_new_session, url, ua=ua, timeout=timeout): key
|
|
334
|
+
for key, url, ua in jobs
|
|
335
|
+
}
|
|
336
|
+
for future in as_completed(future_to_key):
|
|
337
|
+
key = future_to_key[future]
|
|
338
|
+
try:
|
|
339
|
+
results[key] = future.result()
|
|
340
|
+
except Exception as exc:
|
|
341
|
+
url, _ua = job_by_key[key]
|
|
342
|
+
results[key] = {
|
|
343
|
+
"url": url,
|
|
344
|
+
"status": 0,
|
|
345
|
+
"ok": False,
|
|
346
|
+
"headers": {},
|
|
347
|
+
"text": "",
|
|
348
|
+
"final_url": url,
|
|
349
|
+
"error": str(exc),
|
|
350
|
+
}
|
|
351
|
+
return results
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _detect_access_blocker(*results: dict) -> dict:
|
|
355
|
+
"""Detect edge/security challenges that hide the real page from crawlers."""
|
|
356
|
+
evidence: list[str] = []
|
|
357
|
+
affected_urls: list[dict[str, Any]] = []
|
|
358
|
+
providers: set[str] = set()
|
|
359
|
+
|
|
360
|
+
for result in results:
|
|
361
|
+
if not result:
|
|
362
|
+
continue
|
|
363
|
+
|
|
364
|
+
status = result.get("status", 0)
|
|
365
|
+
headers = {k.lower(): str(v) for k, v in result.get("headers", {}).items()}
|
|
366
|
+
text = (result.get("text") or "")[:50000].lower()
|
|
367
|
+
title_match = re.search(r"<title[^>]*>([^<]+)</title>", text, re.IGNORECASE)
|
|
368
|
+
title = title_match.group(1).strip() if title_match else ""
|
|
369
|
+
|
|
370
|
+
result_evidence: list[str] = []
|
|
371
|
+
if status in {401, 403, 429}:
|
|
372
|
+
result_evidence.append(f"HTTP {status}")
|
|
373
|
+
if headers.get("x-vercel-mitigated") == "challenge":
|
|
374
|
+
providers.add("Vercel")
|
|
375
|
+
result_evidence.append("X-Vercel-Mitigated: challenge")
|
|
376
|
+
if "x-vercel-challenge-token" in headers:
|
|
377
|
+
providers.add("Vercel")
|
|
378
|
+
result_evidence.append("X-Vercel-Challenge-Token present")
|
|
379
|
+
if headers.get("server", "").lower() == "vercel":
|
|
380
|
+
providers.add("Vercel")
|
|
381
|
+
if "cf-mitigated" in headers or "cf-chl" in text:
|
|
382
|
+
providers.add("Cloudflare")
|
|
383
|
+
result_evidence.append("Cloudflare challenge signal")
|
|
384
|
+
if "vercel security checkpoint" in text or "vercel security checkpoint" in title:
|
|
385
|
+
providers.add("Vercel")
|
|
386
|
+
result_evidence.append("Vercel Security Checkpoint page")
|
|
387
|
+
if "we're verifying your browser" in text or "enable javascript to continue" in text:
|
|
388
|
+
result_evidence.append("browser verification copy")
|
|
389
|
+
if "just a moment" in title and "cloudflare" in text:
|
|
390
|
+
providers.add("Cloudflare")
|
|
391
|
+
result_evidence.append("Cloudflare Just a moment page")
|
|
392
|
+
|
|
393
|
+
if result_evidence:
|
|
394
|
+
affected_urls.append({
|
|
395
|
+
"url": result.get("url", ""),
|
|
396
|
+
"status": status,
|
|
397
|
+
"final_url": result.get("final_url", ""),
|
|
398
|
+
"evidence": result_evidence,
|
|
399
|
+
})
|
|
400
|
+
evidence.extend(result_evidence)
|
|
401
|
+
|
|
402
|
+
return {
|
|
403
|
+
"detected": bool(affected_urls),
|
|
404
|
+
"provider": ", ".join(sorted(providers)) if providers else "",
|
|
405
|
+
"evidence": _dedupe_preserve_order(evidence),
|
|
406
|
+
"affected_urls": affected_urls[:10],
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _find_chrome() -> Optional[str]:
|
|
411
|
+
for path in _chrome_candidate_paths():
|
|
412
|
+
if shutil.which(path):
|
|
413
|
+
return path
|
|
414
|
+
if os.path.isfile(path) and os.access(path, os.X_OK):
|
|
415
|
+
return path
|
|
416
|
+
return None
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _render_chrome(url: str, timeout: int = 20) -> Optional[str]:
|
|
420
|
+
chrome = _find_chrome()
|
|
421
|
+
if not chrome:
|
|
422
|
+
return None
|
|
423
|
+
try:
|
|
424
|
+
result = subprocess.run(
|
|
425
|
+
[
|
|
426
|
+
chrome,
|
|
427
|
+
"--headless",
|
|
428
|
+
"--disable-gpu",
|
|
429
|
+
"--disable-breakpad",
|
|
430
|
+
"--disable-crash-reporter",
|
|
431
|
+
"--noerrdialogs",
|
|
432
|
+
"--no-sandbox",
|
|
433
|
+
"--dump-dom",
|
|
434
|
+
url,
|
|
435
|
+
],
|
|
436
|
+
capture_output=True, text=True, timeout=timeout,
|
|
437
|
+
)
|
|
438
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
439
|
+
return result.stdout
|
|
440
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, Exception):
|
|
441
|
+
pass
|
|
442
|
+
return None
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
_SPA_MARKERS = ("__NEXT_DATA__", "window.__NUXT__", "window.__APP__",
|
|
446
|
+
'id="app"', 'id="root"', 'id="__next"', "ng-app", "data-reactroot")
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _is_spa_shell(html: str) -> bool:
|
|
450
|
+
if not html:
|
|
451
|
+
return True
|
|
452
|
+
text = re.sub(r"<script[^>]*>[\s\S]*?</script>", "", html, flags=re.IGNORECASE)
|
|
453
|
+
text = re.sub(r"<style[^>]*>[\s\S]*?</style>", "", text, flags=re.IGNORECASE)
|
|
454
|
+
text = re.sub(r"<[^>]+>", " ", text)
|
|
455
|
+
words = text.split()
|
|
456
|
+
if len(words) < 50:
|
|
457
|
+
return True
|
|
458
|
+
if len(words) < 150:
|
|
459
|
+
lower = html.lower()
|
|
460
|
+
if any(m.lower() in lower for m in _SPA_MARKERS):
|
|
461
|
+
return True
|
|
462
|
+
return False
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
# ============================================================================
|
|
466
|
+
# HTML Parsing
|
|
467
|
+
# ============================================================================
|
|
468
|
+
|
|
469
|
+
def _parse_html(html: str, base_url: str = "") -> dict:
|
|
470
|
+
"""Parse HTML into structured page info dict."""
|
|
471
|
+
info: dict[str, Any] = {
|
|
472
|
+
"title": "", "description": "", "canonical": "", "meta_robots": "",
|
|
473
|
+
"h1": [], "h2": [], "h3": [], "h4": [], "h5": [], "h6": [],
|
|
474
|
+
"schema_types": [], "schema_blocks": [],
|
|
475
|
+
"internal_links": [], "external_links": [], "word_count": 0,
|
|
476
|
+
"paragraphs": [], "ol_count": 0, "ul_count": 0, "li_count": 0,
|
|
477
|
+
"time_elements": [], "visible_dates": [], "body_text": "",
|
|
478
|
+
"has_faq_visible": False, "hreflang_count": 0,
|
|
479
|
+
"html_lang": "",
|
|
480
|
+
"og_tags": {}, "twitter_tags": {}, "image_count": 0,
|
|
481
|
+
"images_missing_alt": 0, "images_weak_alt": 0, "table_count": 0,
|
|
482
|
+
}
|
|
483
|
+
if not html:
|
|
484
|
+
return info
|
|
485
|
+
|
|
486
|
+
try:
|
|
487
|
+
soup = BeautifulSoup(html, "lxml")
|
|
488
|
+
except Exception:
|
|
489
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
490
|
+
|
|
491
|
+
# Language
|
|
492
|
+
html_el = soup.find("html")
|
|
493
|
+
if html_el and html_el.get("lang"):
|
|
494
|
+
info["html_lang"] = html_el["lang"].strip()
|
|
495
|
+
|
|
496
|
+
# Title
|
|
497
|
+
if soup.title and soup.title.string:
|
|
498
|
+
info["title"] = soup.title.string.strip()
|
|
499
|
+
|
|
500
|
+
# Meta
|
|
501
|
+
desc = soup.find("meta", attrs={"name": "description"})
|
|
502
|
+
if desc and desc.get("content"):
|
|
503
|
+
info["description"] = desc["content"].strip()
|
|
504
|
+
|
|
505
|
+
canonical = soup.find("link", attrs={"rel": "canonical"})
|
|
506
|
+
if canonical and canonical.get("href"):
|
|
507
|
+
info["canonical"] = canonical["href"].strip()
|
|
508
|
+
|
|
509
|
+
meta_robots = soup.find("meta", attrs={"name": "robots"})
|
|
510
|
+
if meta_robots and meta_robots.get("content"):
|
|
511
|
+
info["meta_robots"] = meta_robots["content"].strip()
|
|
512
|
+
|
|
513
|
+
info["hreflang_count"] = len(soup.find_all("link", attrs={"rel": "alternate", "hreflang": True}))
|
|
514
|
+
|
|
515
|
+
# Headings
|
|
516
|
+
info["h1"] = [t.get_text(strip=True) for t in soup.find_all("h1")]
|
|
517
|
+
info["h2"] = [t.get_text(strip=True) for t in soup.find_all("h2")]
|
|
518
|
+
info["h3"] = [t.get_text(strip=True) for t in soup.find_all("h3")]
|
|
519
|
+
info["h4"] = [t.get_text(strip=True) for t in soup.find_all("h4")]
|
|
520
|
+
info["h5"] = [t.get_text(strip=True) for t in soup.find_all("h5")]
|
|
521
|
+
info["h6"] = [t.get_text(strip=True) for t in soup.find_all("h6")]
|
|
522
|
+
|
|
523
|
+
# Lists
|
|
524
|
+
info["ol_count"] = len(soup.find_all("ol"))
|
|
525
|
+
info["ul_count"] = len(soup.find_all("ul"))
|
|
526
|
+
info["li_count"] = len(soup.find_all("li"))
|
|
527
|
+
info["table_count"] = len(soup.find_all("table"))
|
|
528
|
+
|
|
529
|
+
# Images and social metadata
|
|
530
|
+
images = soup.find_all("img")
|
|
531
|
+
info["image_count"] = len(images)
|
|
532
|
+
weak_alt_values = {"image", "photo", "picture", "logo", "icon", "banner", "图片", "照片", "图像"}
|
|
533
|
+
for image in images:
|
|
534
|
+
alt = (image.get("alt") or "").strip()
|
|
535
|
+
if not alt:
|
|
536
|
+
info["images_missing_alt"] += 1
|
|
537
|
+
elif alt.lower() in weak_alt_values or len(alt) <= 3:
|
|
538
|
+
info["images_weak_alt"] += 1
|
|
539
|
+
|
|
540
|
+
for meta in soup.find_all("meta"):
|
|
541
|
+
prop = meta.get("property") or meta.get("name") or ""
|
|
542
|
+
content = meta.get("content") or ""
|
|
543
|
+
if not prop or not content:
|
|
544
|
+
continue
|
|
545
|
+
prop_lower = prop.lower()
|
|
546
|
+
if prop_lower.startswith("og:"):
|
|
547
|
+
info["og_tags"][prop_lower] = content.strip()
|
|
548
|
+
elif prop_lower.startswith("twitter:"):
|
|
549
|
+
info["twitter_tags"][prop_lower] = content.strip()
|
|
550
|
+
|
|
551
|
+
# Paragraphs
|
|
552
|
+
info["paragraphs"] = [
|
|
553
|
+
p.get_text(" ", strip=True) for p in soup.find_all("p") if p.get_text(strip=True)
|
|
554
|
+
]
|
|
555
|
+
|
|
556
|
+
# Time elements
|
|
557
|
+
for t in soup.find_all("time"):
|
|
558
|
+
dt = t.get("datetime", "")
|
|
559
|
+
if dt:
|
|
560
|
+
info["time_elements"].append(dt)
|
|
561
|
+
|
|
562
|
+
# Visible dates
|
|
563
|
+
body_text_raw = soup.get_text(" ", strip=True)
|
|
564
|
+
info["visible_dates"] = re.findall(
|
|
565
|
+
r"\b\d{4}[-/]\d{1,2}[-/]\d{1,2}\b", body_text_raw
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
# Schema.org JSON-LD (must be extracted BEFORE scripts are decomposed)
|
|
569
|
+
for script in soup.find_all("script", attrs={"type": "application/ld+json"}):
|
|
570
|
+
try:
|
|
571
|
+
data = json.loads(script.string)
|
|
572
|
+
# Handle @graph wrapper (common in Yoast SEO / RankMath / Next.js)
|
|
573
|
+
if isinstance(data, dict) and "@graph" in data and isinstance(data["@graph"], list):
|
|
574
|
+
blocks = data["@graph"]
|
|
575
|
+
else:
|
|
576
|
+
blocks = data if isinstance(data, list) else [data]
|
|
577
|
+
for block in blocks:
|
|
578
|
+
if isinstance(block, dict):
|
|
579
|
+
info["schema_blocks"].append(block)
|
|
580
|
+
# Recurse into nested @graph if present (rare but valid)
|
|
581
|
+
nested_graph = block.get("@graph", [])
|
|
582
|
+
if isinstance(nested_graph, list):
|
|
583
|
+
for sub in nested_graph:
|
|
584
|
+
if isinstance(sub, dict):
|
|
585
|
+
info["schema_blocks"].append(sub)
|
|
586
|
+
t = sub.get("@type", "")
|
|
587
|
+
if isinstance(t, list):
|
|
588
|
+
info["schema_types"].extend(t)
|
|
589
|
+
elif t:
|
|
590
|
+
info["schema_types"].append(t)
|
|
591
|
+
t = block.get("@type", "")
|
|
592
|
+
if isinstance(t, list):
|
|
593
|
+
info["schema_types"].extend(t)
|
|
594
|
+
elif t:
|
|
595
|
+
info["schema_types"].append(t)
|
|
596
|
+
except (json.JSONDecodeError, TypeError):
|
|
597
|
+
pass
|
|
598
|
+
|
|
599
|
+
# Links (extracted before decompose to preserve <a> in <noscript> if any)
|
|
600
|
+
if base_url:
|
|
601
|
+
parsed_base = urlparse(base_url)
|
|
602
|
+
for a in soup.find_all("a", href=True):
|
|
603
|
+
href = a["href"].strip()
|
|
604
|
+
if not href or href.startswith(("#", "javascript:", "mailto:", "tel:", "sms:")):
|
|
605
|
+
continue
|
|
606
|
+
absolute = urljoin(base_url, href)
|
|
607
|
+
link_parsed = urlparse(absolute)
|
|
608
|
+
if link_parsed.scheme not in ("http", "https"):
|
|
609
|
+
continue
|
|
610
|
+
if link_parsed.netloc.lower() == parsed_base.netloc.lower():
|
|
611
|
+
info["internal_links"].append(absolute)
|
|
612
|
+
else:
|
|
613
|
+
info["external_links"].append(absolute)
|
|
614
|
+
|
|
615
|
+
# Body text (stripped of scripts/styles)
|
|
616
|
+
for tag in soup(["script", "style", "noscript"]):
|
|
617
|
+
tag.decompose()
|
|
618
|
+
info["body_text"] = soup.get_text(" ", strip=True)
|
|
619
|
+
info["word_count"] = len(info["body_text"].split())
|
|
620
|
+
|
|
621
|
+
# FAQ detection
|
|
622
|
+
faq_kws = ["faq", "常见问题", "frequently asked", "q&a"]
|
|
623
|
+
text_lower = info["body_text"].lower()
|
|
624
|
+
headings_lower = " ".join(info["h2"] + info["h3"]).lower()
|
|
625
|
+
if any(kw in text_lower or kw in headings_lower for kw in faq_kws):
|
|
626
|
+
info["has_faq_visible"] = True
|
|
627
|
+
if "FAQPage" in info["schema_types"]:
|
|
628
|
+
info["has_faq_visible"] = True
|
|
629
|
+
|
|
630
|
+
return info
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
# ============================================================================
|
|
634
|
+
# Sitemap / Robots Parsing
|
|
635
|
+
# ============================================================================
|
|
636
|
+
|
|
637
|
+
def _dedupe_preserve_order(values: list[str]) -> list[str]:
|
|
638
|
+
seen: set[str] = set()
|
|
639
|
+
deduped: list[str] = []
|
|
640
|
+
for value in values:
|
|
641
|
+
if value and value not in seen:
|
|
642
|
+
seen.add(value)
|
|
643
|
+
deduped.append(value)
|
|
644
|
+
return deduped
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _xml_tag_name(tag: str) -> str:
|
|
648
|
+
return tag.rsplit("}", 1)[-1].lower()
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _parse_sitemap_xml(sitemap_text: str, sitemap_url: str) -> dict:
|
|
652
|
+
"""Parse a sitemap or sitemap index and return URLs, lastmods, and metadata."""
|
|
653
|
+
parsed: dict[str, Any] = {
|
|
654
|
+
"page_urls": [],
|
|
655
|
+
"child_sitemaps": [],
|
|
656
|
+
"lastmods": [],
|
|
657
|
+
"has_hreflang": False,
|
|
658
|
+
"root_tag": "",
|
|
659
|
+
}
|
|
660
|
+
if not sitemap_text:
|
|
661
|
+
return parsed
|
|
662
|
+
|
|
663
|
+
parsed["has_hreflang"] = "xhtml:link" in sitemap_text or "hreflang" in sitemap_text
|
|
664
|
+
|
|
665
|
+
try:
|
|
666
|
+
root = ElementTree.fromstring(sitemap_text.encode("utf-8"))
|
|
667
|
+
root_tag = _xml_tag_name(root.tag)
|
|
668
|
+
parsed["root_tag"] = root_tag
|
|
669
|
+
parsed["lastmods"] = [
|
|
670
|
+
(node.text or "").strip()
|
|
671
|
+
for node in root.iter()
|
|
672
|
+
if _xml_tag_name(node.tag) == "lastmod" and (node.text or "").strip()
|
|
673
|
+
]
|
|
674
|
+
if root_tag == "sitemapindex":
|
|
675
|
+
locs = [
|
|
676
|
+
(node.text or "").strip()
|
|
677
|
+
for sitemap in root
|
|
678
|
+
for node in sitemap
|
|
679
|
+
if _xml_tag_name(sitemap.tag) == "sitemap"
|
|
680
|
+
and _xml_tag_name(node.tag) == "loc"
|
|
681
|
+
and (node.text or "").strip()
|
|
682
|
+
]
|
|
683
|
+
parsed["child_sitemaps"] = [urljoin(sitemap_url, loc) for loc in locs]
|
|
684
|
+
else:
|
|
685
|
+
locs = [
|
|
686
|
+
(node.text or "").strip()
|
|
687
|
+
for url_node in root
|
|
688
|
+
for node in url_node
|
|
689
|
+
if _xml_tag_name(url_node.tag) == "url"
|
|
690
|
+
and _xml_tag_name(node.tag) == "loc"
|
|
691
|
+
and (node.text or "").strip()
|
|
692
|
+
]
|
|
693
|
+
parsed["page_urls"] = locs
|
|
694
|
+
return parsed
|
|
695
|
+
except ElementTree.ParseError:
|
|
696
|
+
pass
|
|
697
|
+
|
|
698
|
+
# Fallback for malformed XML that still contains useful sitemap tags.
|
|
699
|
+
locs = [u.strip() for u in re.findall(r"<loc>\s*([^<]+?)\s*</loc>", sitemap_text)]
|
|
700
|
+
parsed["lastmods"] = [
|
|
701
|
+
u.strip() for u in re.findall(r"<lastmod>\s*([^<]+?)\s*</lastmod>", sitemap_text)
|
|
702
|
+
]
|
|
703
|
+
is_index = "<sitemapindex" in sitemap_text.lower()
|
|
704
|
+
if is_index:
|
|
705
|
+
parsed["root_tag"] = "sitemapindex"
|
|
706
|
+
parsed["child_sitemaps"] = [urljoin(sitemap_url, loc) for loc in locs]
|
|
707
|
+
else:
|
|
708
|
+
parsed["root_tag"] = "urlset"
|
|
709
|
+
parsed["page_urls"] = locs
|
|
710
|
+
return parsed
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _sitemap_candidate_urls(base_url: str, robots_sitemap_urls: list[str]) -> list[str]:
|
|
714
|
+
candidates = [urljoin(base_url, value) for value in robots_sitemap_urls]
|
|
715
|
+
candidates.extend(urljoin(base_url, path) for path in SITEMAP_CANDIDATE_PATHS)
|
|
716
|
+
return _dedupe_preserve_order(candidates)
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _sitemap_path_segments(page_url: str) -> list[str]:
|
|
720
|
+
path = urlparse(page_url).path.lower().strip("/")
|
|
721
|
+
segments = [segment for segment in path.split("/") if segment]
|
|
722
|
+
while segments and (
|
|
723
|
+
segments[0] in {"en", "zh", "ja", "jp", "ko", "fr", "de", "es", "pt"}
|
|
724
|
+
or re.fullmatch(r"[a-z]{2}[-_][a-z]{2}", segments[0])
|
|
725
|
+
):
|
|
726
|
+
segments.pop(0)
|
|
727
|
+
return segments
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _classify_sitemap_page_url(page_url: str) -> str:
|
|
731
|
+
segments = _sitemap_path_segments(page_url)
|
|
732
|
+
if not segments:
|
|
733
|
+
return "homepage"
|
|
734
|
+
|
|
735
|
+
first = segments[0]
|
|
736
|
+
path = "/" + "/".join(segments)
|
|
737
|
+
joined = " ".join(segments)
|
|
738
|
+
|
|
739
|
+
if re.search(r"\.(pdf|png|jpg|jpeg|webp|gif|svg|zip)$", path):
|
|
740
|
+
return "asset"
|
|
741
|
+
if first in SITEMAP_TECHNICAL_SEGMENTS or any(segment in SITEMAP_TECHNICAL_SEGMENTS for segment in segments):
|
|
742
|
+
return "technical_entry"
|
|
743
|
+
if (
|
|
744
|
+
first in SITEMAP_RISK_SEGMENTS
|
|
745
|
+
or any(segment in SITEMAP_RISK_SEGMENTS for segment in segments)
|
|
746
|
+
or urlparse(page_url).query
|
|
747
|
+
):
|
|
748
|
+
return "risk_validation"
|
|
749
|
+
if (
|
|
750
|
+
first in SITEMAP_CONVERSION_SEGMENTS
|
|
751
|
+
or any(segment in SITEMAP_CONVERSION_SEGMENTS for segment in segments[:2])
|
|
752
|
+
):
|
|
753
|
+
return "conversion"
|
|
754
|
+
if first in SITEMAP_TRUST_SEGMENTS or any(segment in SITEMAP_TRUST_SEGMENTS for segment in segments[:2]):
|
|
755
|
+
return "trust"
|
|
756
|
+
if (
|
|
757
|
+
first in SITEMAP_CITATION_SEGMENTS
|
|
758
|
+
or any(segment in SITEMAP_CITATION_SEGMENTS for segment in segments[:2])
|
|
759
|
+
or any(token in joined for token in ("alternatives", "comparison", "compare", " vs ", "versus"))
|
|
760
|
+
):
|
|
761
|
+
return "citation"
|
|
762
|
+
return "template"
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def _score_sitemap_page_url(page_url: str) -> tuple[int, str]:
|
|
766
|
+
category = _classify_sitemap_page_url(page_url)
|
|
767
|
+
segments = _sitemap_path_segments(page_url)
|
|
768
|
+
score = SITEMAP_CATEGORY_SCORES.get(category, SITEMAP_CATEGORY_SCORES["default"])
|
|
769
|
+
|
|
770
|
+
if category == "conversion" and len(segments) == 1:
|
|
771
|
+
score += 30
|
|
772
|
+
elif category in {"trust", "citation"} and len(segments) <= 2:
|
|
773
|
+
score += 15
|
|
774
|
+
elif category == "template" and len(segments) <= 2:
|
|
775
|
+
score += 8
|
|
776
|
+
|
|
777
|
+
if len(segments) > 4:
|
|
778
|
+
score -= (len(segments) - 4) * 4
|
|
779
|
+
if urlparse(page_url).query:
|
|
780
|
+
score -= 25
|
|
781
|
+
|
|
782
|
+
return score, category
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _sitemap_template_key(page_url: str, category: str) -> str:
|
|
786
|
+
segments = _sitemap_path_segments(page_url)
|
|
787
|
+
if category != "template":
|
|
788
|
+
return category
|
|
789
|
+
if not segments:
|
|
790
|
+
return "homepage"
|
|
791
|
+
return segments[0]
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def _canonical_sitemap_sample_key(page_url: str) -> str:
|
|
795
|
+
parsed = urlparse(page_url)
|
|
796
|
+
segments = _sitemap_path_segments(page_url)
|
|
797
|
+
path = "/" + "/".join(segments)
|
|
798
|
+
return f"{parsed.netloc.lower()}{path.rstrip('/')}"
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def _normalize_homepage_link_url(href: str, base_url: str) -> Optional[str]:
|
|
802
|
+
if not href:
|
|
803
|
+
return None
|
|
804
|
+
href = href.strip()
|
|
805
|
+
if not href or href.startswith(("#", "javascript:", "mailto:", "tel:", "sms:")):
|
|
806
|
+
return None
|
|
807
|
+
|
|
808
|
+
absolute = urljoin(base_url, href)
|
|
809
|
+
parsed = urlparse(absolute)
|
|
810
|
+
parsed_base = urlparse(base_url)
|
|
811
|
+
if parsed.scheme not in {"http", "https"}:
|
|
812
|
+
return None
|
|
813
|
+
if parsed.netloc.lower() != parsed_base.netloc.lower():
|
|
814
|
+
return None
|
|
815
|
+
|
|
816
|
+
extension = os.path.splitext(parsed.path.lower())[1]
|
|
817
|
+
if extension in HOMEPAGE_LINK_SKIP_EXTENSIONS:
|
|
818
|
+
return None
|
|
819
|
+
|
|
820
|
+
normalized = parsed._replace(fragment="", query="")
|
|
821
|
+
return normalized.geturl().rstrip("/") or normalized.geturl()
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def _extract_homepage_candidate_urls(homepage_html: str, base_url: str) -> list[str]:
|
|
825
|
+
"""Extract same-origin page candidates from the homepage when sitemap URLs are unavailable."""
|
|
826
|
+
if not homepage_html or not base_url:
|
|
827
|
+
return []
|
|
828
|
+
try:
|
|
829
|
+
soup = BeautifulSoup(homepage_html, "lxml")
|
|
830
|
+
except Exception:
|
|
831
|
+
soup = BeautifulSoup(homepage_html, "html.parser")
|
|
832
|
+
|
|
833
|
+
urls: list[str] = []
|
|
834
|
+
for anchor in soup.find_all("a", href=True):
|
|
835
|
+
normalized = _normalize_homepage_link_url(anchor.get("href", ""), base_url)
|
|
836
|
+
if normalized:
|
|
837
|
+
urls.append(normalized)
|
|
838
|
+
return _dedupe_preserve_order(urls)[:MAX_SITEMAP_PAGE_URLS]
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def _select_subpage_sample_entries(
|
|
842
|
+
sitemap_info: dict[str, Any],
|
|
843
|
+
homepage_html: str,
|
|
844
|
+
base_url: str,
|
|
845
|
+
limit: int = MAX_SUBPAGES,
|
|
846
|
+
) -> tuple[list[dict[str, Any]], str, list[str]]:
|
|
847
|
+
sitemap_page_urls = sitemap_info.get("page_urls", []) or []
|
|
848
|
+
if sitemap_page_urls:
|
|
849
|
+
return _prioritize_sitemap_page_urls(sitemap_page_urls, limit), "sitemap", []
|
|
850
|
+
|
|
851
|
+
homepage_candidate_urls = _extract_homepage_candidate_urls(homepage_html, base_url)
|
|
852
|
+
if not homepage_candidate_urls:
|
|
853
|
+
return [], "none", []
|
|
854
|
+
return (
|
|
855
|
+
_prioritize_sitemap_page_urls(homepage_candidate_urls, limit),
|
|
856
|
+
"homepage_links",
|
|
857
|
+
homepage_candidate_urls,
|
|
858
|
+
)
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def _prioritize_sitemap_page_urls(page_urls: list[str], limit: int = MAX_SUBPAGES) -> list[dict[str, Any]]:
|
|
862
|
+
"""Pick representative pages using GEO sampling priority, not sitemap order.
|
|
863
|
+
|
|
864
|
+
The homepage is collected separately by the caller, so homepage URLs inside a
|
|
865
|
+
sitemap are not duplicated here. Within the remaining URLs, priority is:
|
|
866
|
+
conversion pages, trust pages, citation pages, one page per important
|
|
867
|
+
template, technical entry pages, and risk-validation URLs last.
|
|
868
|
+
"""
|
|
869
|
+
entries: list[dict[str, Any]] = []
|
|
870
|
+
seen_keys: set[str] = set()
|
|
871
|
+
|
|
872
|
+
for index, page_url in enumerate(_dedupe_preserve_order(page_urls)):
|
|
873
|
+
key = _canonical_sitemap_sample_key(page_url)
|
|
874
|
+
if key in seen_keys:
|
|
875
|
+
continue
|
|
876
|
+
seen_keys.add(key)
|
|
877
|
+
|
|
878
|
+
score, category = _score_sitemap_page_url(page_url)
|
|
879
|
+
if category == "homepage":
|
|
880
|
+
continue
|
|
881
|
+
entries.append({
|
|
882
|
+
"url": page_url,
|
|
883
|
+
"category": category,
|
|
884
|
+
"score": score,
|
|
885
|
+
"index": index,
|
|
886
|
+
"template_key": _sitemap_template_key(page_url, category),
|
|
887
|
+
})
|
|
888
|
+
|
|
889
|
+
entries.sort(key=lambda item: (-item["score"], item["index"]))
|
|
890
|
+
|
|
891
|
+
selected: list[dict[str, Any]] = []
|
|
892
|
+
selected_urls: set[str] = set()
|
|
893
|
+
|
|
894
|
+
def select_entry(entry: dict[str, Any]) -> None:
|
|
895
|
+
selected.append(entry)
|
|
896
|
+
selected_urls.add(entry["url"])
|
|
897
|
+
|
|
898
|
+
for category in SITEMAP_SAMPLE_CATEGORY_ORDER:
|
|
899
|
+
if len(selected) >= limit:
|
|
900
|
+
break
|
|
901
|
+
if category == "template":
|
|
902
|
+
seen_templates: set[str] = set()
|
|
903
|
+
for entry in entries:
|
|
904
|
+
if len(selected) >= limit:
|
|
905
|
+
break
|
|
906
|
+
template_key = entry["template_key"]
|
|
907
|
+
if (
|
|
908
|
+
entry["category"] == category
|
|
909
|
+
and entry["url"] not in selected_urls
|
|
910
|
+
and template_key not in seen_templates
|
|
911
|
+
):
|
|
912
|
+
select_entry(entry)
|
|
913
|
+
seen_templates.add(template_key)
|
|
914
|
+
continue
|
|
915
|
+
for entry in entries:
|
|
916
|
+
if entry["category"] == category and entry["url"] not in selected_urls:
|
|
917
|
+
select_entry(entry)
|
|
918
|
+
break
|
|
919
|
+
|
|
920
|
+
for entry in entries:
|
|
921
|
+
if len(selected) >= limit:
|
|
922
|
+
break
|
|
923
|
+
if entry["url"] not in selected_urls:
|
|
924
|
+
select_entry(entry)
|
|
925
|
+
|
|
926
|
+
return [
|
|
927
|
+
{
|
|
928
|
+
"url": item["url"],
|
|
929
|
+
"category": item["category"],
|
|
930
|
+
"score": item["score"],
|
|
931
|
+
"template_key": item["template_key"],
|
|
932
|
+
}
|
|
933
|
+
for item in selected[:limit]
|
|
934
|
+
]
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def _discover_sitemaps(
|
|
938
|
+
session: requests.Session,
|
|
939
|
+
base_url: str,
|
|
940
|
+
robots_result: dict,
|
|
941
|
+
*,
|
|
942
|
+
concurrency: int = DEFAULT_CONCURRENCY,
|
|
943
|
+
) -> dict:
|
|
944
|
+
"""Find sitemap URLs from robots.txt and common paths, then follow sitemap indexes."""
|
|
945
|
+
robots_rules = _parse_robots_rules(robots_result.get("text", ""))
|
|
946
|
+
declared_candidates = _dedupe_preserve_order(
|
|
947
|
+
[urljoin(base_url, value) for value in robots_rules["sitemap_urls"]]
|
|
948
|
+
)
|
|
949
|
+
fallback_candidates = _dedupe_preserve_order(
|
|
950
|
+
[urljoin(base_url, path) for path in SITEMAP_CANDIDATE_PATHS]
|
|
951
|
+
)
|
|
952
|
+
queue = list(declared_candidates or fallback_candidates)
|
|
953
|
+
queued = set(queue)
|
|
954
|
+
fallback_pending = [url for url in fallback_candidates if url not in queued]
|
|
955
|
+
fetched: list[dict[str, Any]] = []
|
|
956
|
+
page_urls: list[str] = []
|
|
957
|
+
lastmods: list[str] = []
|
|
958
|
+
has_hreflang = False
|
|
959
|
+
first_status = 0
|
|
960
|
+
first_ok_status = 0
|
|
961
|
+
use_provided_session = not isinstance(session, requests.Session)
|
|
962
|
+
has_successful_sitemap = False
|
|
963
|
+
|
|
964
|
+
while queue and len(fetched) < MAX_SITEMAP_FETCHES:
|
|
965
|
+
remaining = MAX_SITEMAP_FETCHES - len(fetched)
|
|
966
|
+
batch_size = min(_bounded_workers(concurrency), remaining, len(queue))
|
|
967
|
+
batch_urls = [queue.pop(0) for _ in range(batch_size)]
|
|
968
|
+
if use_provided_session:
|
|
969
|
+
batch_results = {
|
|
970
|
+
sitemap_url: _get(session, sitemap_url)
|
|
971
|
+
for sitemap_url in batch_urls
|
|
972
|
+
}
|
|
973
|
+
else:
|
|
974
|
+
batch_results = _parallel_fetch(
|
|
975
|
+
[(sitemap_url, sitemap_url, DEFAULT_UA) for sitemap_url in batch_urls],
|
|
976
|
+
concurrency=concurrency,
|
|
977
|
+
)
|
|
978
|
+
|
|
979
|
+
for sitemap_url in batch_urls:
|
|
980
|
+
result = batch_results.get(sitemap_url, {})
|
|
981
|
+
if not first_status:
|
|
982
|
+
first_status = result.get("status", 0)
|
|
983
|
+
|
|
984
|
+
entry: dict[str, Any] = {
|
|
985
|
+
"url": sitemap_url,
|
|
986
|
+
"status": result.get("status", 0),
|
|
987
|
+
"ok": result.get("ok", False),
|
|
988
|
+
"root_tag": "",
|
|
989
|
+
"page_url_count": 0,
|
|
990
|
+
"child_sitemap_count": 0,
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
if result.get("ok"):
|
|
994
|
+
parsed = _parse_sitemap_xml(result.get("text", ""), sitemap_url)
|
|
995
|
+
if parsed["page_urls"] or parsed["child_sitemaps"]:
|
|
996
|
+
has_successful_sitemap = True
|
|
997
|
+
if not first_ok_status:
|
|
998
|
+
first_ok_status = result.get("status", 0)
|
|
999
|
+
entry["root_tag"] = parsed["root_tag"]
|
|
1000
|
+
entry["page_url_count"] = len(parsed["page_urls"])
|
|
1001
|
+
entry["child_sitemap_count"] = len(parsed["child_sitemaps"])
|
|
1002
|
+
page_urls.extend(parsed["page_urls"])
|
|
1003
|
+
lastmods.extend(parsed["lastmods"])
|
|
1004
|
+
has_hreflang = has_hreflang or parsed["has_hreflang"]
|
|
1005
|
+
|
|
1006
|
+
for child_url in parsed["child_sitemaps"]:
|
|
1007
|
+
if child_url not in queued:
|
|
1008
|
+
queued.add(child_url)
|
|
1009
|
+
queue.append(child_url)
|
|
1010
|
+
|
|
1011
|
+
fetched.append(entry)
|
|
1012
|
+
|
|
1013
|
+
if not queue and fallback_pending and not has_successful_sitemap:
|
|
1014
|
+
queue = list(fallback_pending)
|
|
1015
|
+
queued.update(queue)
|
|
1016
|
+
fallback_pending = []
|
|
1017
|
+
|
|
1018
|
+
page_urls = _dedupe_preserve_order(page_urls)[:MAX_SITEMAP_PAGE_URLS]
|
|
1019
|
+
successful = [
|
|
1020
|
+
item for item in fetched
|
|
1021
|
+
if item["ok"] and (item["page_url_count"] or item["child_sitemap_count"])
|
|
1022
|
+
]
|
|
1023
|
+
|
|
1024
|
+
return {
|
|
1025
|
+
"exists": bool(successful),
|
|
1026
|
+
"status_code": first_ok_status or first_status,
|
|
1027
|
+
"url_count": len(page_urls),
|
|
1028
|
+
"latest_lastmod": max(lastmods) if lastmods else "",
|
|
1029
|
+
"has_hreflang": has_hreflang,
|
|
1030
|
+
"robots_declares_sitemap": len(robots_rules["sitemap_urls"]) > 0,
|
|
1031
|
+
"sitemap_urls": [item["url"] for item in successful],
|
|
1032
|
+
"candidate_urls": fetched,
|
|
1033
|
+
"child_sitemap_count": sum(item["child_sitemap_count"] for item in fetched),
|
|
1034
|
+
"page_urls": page_urls,
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
def _extract_sitemap_urls(sitemap_text: str, limit: int = MAX_SUBPAGES) -> list[str]:
|
|
1039
|
+
if not sitemap_text:
|
|
1040
|
+
return []
|
|
1041
|
+
return _parse_sitemap_xml(sitemap_text, "")["page_urls"][:limit]
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def _parse_robots_rules(robots_text: str) -> dict:
|
|
1045
|
+
"""Parse robots.txt into structured rules."""
|
|
1046
|
+
rules: dict[str, list[str]] = {} # ua -> disallow paths
|
|
1047
|
+
sitemap_urls: list[str] = []
|
|
1048
|
+
current_ua = "*"
|
|
1049
|
+
|
|
1050
|
+
for line in robots_text.splitlines():
|
|
1051
|
+
line = line.strip()
|
|
1052
|
+
if line.startswith("#") or not line:
|
|
1053
|
+
continue
|
|
1054
|
+
if ":" not in line:
|
|
1055
|
+
continue
|
|
1056
|
+
key, _, value = line.partition(":")
|
|
1057
|
+
key = key.strip().lower()
|
|
1058
|
+
value = value.strip()
|
|
1059
|
+
|
|
1060
|
+
if key == "user-agent":
|
|
1061
|
+
current_ua = value
|
|
1062
|
+
if current_ua not in rules:
|
|
1063
|
+
rules[current_ua] = []
|
|
1064
|
+
elif key == "disallow" and value:
|
|
1065
|
+
rules.setdefault(current_ua, []).append(value)
|
|
1066
|
+
elif key == "sitemap":
|
|
1067
|
+
sitemap_urls.append(value)
|
|
1068
|
+
|
|
1069
|
+
return {"rules": rules, "sitemap_urls": sitemap_urls}
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
def _robots_rule_blocks_root(rule: str) -> bool:
|
|
1073
|
+
"""Return true only when a Disallow rule blocks the homepage/root URL."""
|
|
1074
|
+
rule = (rule or "").split("#", 1)[0].strip()
|
|
1075
|
+
if not rule:
|
|
1076
|
+
return False
|
|
1077
|
+
return rule in {"/", "/*", "/$", "/*$"}
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
def _robots_effective_disallows(robots_rules: dict, user_agent: str) -> list[str]:
|
|
1081
|
+
"""Return UA-specific disallows, falling back to wildcard rules."""
|
|
1082
|
+
rules = robots_rules.get("rules", {})
|
|
1083
|
+
return rules.get(user_agent) or rules.get(user_agent.lower()) or rules.get("*", [])
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
# ============================================================================
|
|
1087
|
+
# Signal Collection — D1: Technical Accessibility
|
|
1088
|
+
# ============================================================================
|
|
1089
|
+
|
|
1090
|
+
def _collect_d1(session: requests.Session, url: str, homepage_result: dict,
|
|
1091
|
+
homepage_info: dict, robots_result: dict, sitemap_info: dict,
|
|
1092
|
+
llms_result: dict, llms_full_result: dict,
|
|
1093
|
+
notfound_result: dict, ua_probes: dict, sub_pages: list[dict],
|
|
1094
|
+
render_method: str) -> tuple[dict, dict]:
|
|
1095
|
+
"""Collect D1 Technical Accessibility signals and snippets."""
|
|
1096
|
+
signals: dict[str, Any] = {}
|
|
1097
|
+
snippets: dict[str, str] = {}
|
|
1098
|
+
|
|
1099
|
+
signals["d1_access_blocker"] = _detect_access_blocker(
|
|
1100
|
+
homepage_result,
|
|
1101
|
+
robots_result,
|
|
1102
|
+
llms_result,
|
|
1103
|
+
notfound_result,
|
|
1104
|
+
*ua_probes.values(),
|
|
1105
|
+
)
|
|
1106
|
+
|
|
1107
|
+
parsed_requested = urlparse(url)
|
|
1108
|
+
parsed_final = urlparse(homepage_result.get("final_url") or url)
|
|
1109
|
+
homepage_status = homepage_result.get("status", 0)
|
|
1110
|
+
homepage_text = homepage_result.get("text", "")
|
|
1111
|
+
homepage_headers = {k.lower(): str(v) for k, v in homepage_result.get("headers", {}).items()}
|
|
1112
|
+
internal_links = homepage_info.get("internal_links", [])
|
|
1113
|
+
sitemap_page_urls = sitemap_info.get("page_urls", [])
|
|
1114
|
+
|
|
1115
|
+
signals["d1_entry_normalization"] = {
|
|
1116
|
+
"requested_url": url,
|
|
1117
|
+
"final_url": homepage_result.get("final_url", ""),
|
|
1118
|
+
"requested_scheme": parsed_requested.scheme,
|
|
1119
|
+
"final_scheme": parsed_final.scheme,
|
|
1120
|
+
"requested_host": parsed_requested.netloc,
|
|
1121
|
+
"final_host": parsed_final.netloc,
|
|
1122
|
+
"uses_https": parsed_final.scheme == "https",
|
|
1123
|
+
"host_changed": parsed_requested.netloc.lower() != parsed_final.netloc.lower(),
|
|
1124
|
+
"canonical": homepage_info.get("canonical", ""),
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
signals["d1_homepage_access"] = {
|
|
1128
|
+
"status_code": homepage_status,
|
|
1129
|
+
"ok": homepage_result.get("ok", False),
|
|
1130
|
+
"word_count": homepage_info.get("word_count", 0),
|
|
1131
|
+
"content_type": homepage_headers.get("content-type", ""),
|
|
1132
|
+
"final_url": homepage_result.get("final_url", ""),
|
|
1133
|
+
"access_blocker_detected": signals["d1_access_blocker"].get("detected", False),
|
|
1134
|
+
"access_blocker_evidence": signals["d1_access_blocker"].get("evidence", []),
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
# D1.1 robots.txt
|
|
1138
|
+
robots_rules = _parse_robots_rules(robots_result.get("text", ""))
|
|
1139
|
+
wildcard_disallows = robots_rules["rules"].get("*", [])
|
|
1140
|
+
signals["d1_robots"] = {
|
|
1141
|
+
"status_code": robots_result.get("status", 0),
|
|
1142
|
+
"disallow_paths": wildcard_disallows[:20],
|
|
1143
|
+
"blocks_root": any(_robots_rule_blocks_root(rule) for rule in wildcard_disallows),
|
|
1144
|
+
"total_rules": sum(len(v) for v in robots_rules["rules"].values()),
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
# D1.2 AI Crawler Access
|
|
1148
|
+
crawler_results = {}
|
|
1149
|
+
blocker_provider = signals["d1_access_blocker"].get("provider", "")
|
|
1150
|
+
waf_is_blocking_bots = signals["d1_access_blocker"].get("detected", False) and bool(blocker_provider)
|
|
1151
|
+
# Also check robots.txt for per-UA blocks
|
|
1152
|
+
for name in CRAWLER_UAS:
|
|
1153
|
+
status = ua_probes.get(name, {}).get("status", 0)
|
|
1154
|
+
# Check if robots.txt explicitly blocks this UA
|
|
1155
|
+
ua_rules = _robots_effective_disallows(robots_rules, name)
|
|
1156
|
+
robots_blocked = any(_robots_rule_blocks_root(rule) for rule in ua_rules)
|
|
1157
|
+
# 区分 WAF 安全挑战拦截与 robots.txt 策略拦截
|
|
1158
|
+
waf_blocked = (
|
|
1159
|
+
not robots_blocked
|
|
1160
|
+
and status not in range(200, 400)
|
|
1161
|
+
and waf_is_blocking_bots
|
|
1162
|
+
)
|
|
1163
|
+
crawler_results[name] = {
|
|
1164
|
+
"status": status,
|
|
1165
|
+
"robots_blocked": robots_blocked,
|
|
1166
|
+
"waf_blocked": waf_blocked,
|
|
1167
|
+
"accessible": status in range(200, 400) and not robots_blocked,
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
accessible_count = sum(1 for v in crawler_results.values() if v["accessible"])
|
|
1171
|
+
blocked_count = sum(1 for v in crawler_results.values() if not v["accessible"])
|
|
1172
|
+
waf_blocked_count = sum(1 for v in crawler_results.values() if v["waf_blocked"])
|
|
1173
|
+
# 当所有探测结果均被 WAF 拦截时,AI 爬虫可达性实质上不可检测
|
|
1174
|
+
all_waf_blocked = waf_blocked_count > 0 and accessible_count == 0
|
|
1175
|
+
|
|
1176
|
+
signals["d1_ai_crawlers"] = {
|
|
1177
|
+
"results": crawler_results,
|
|
1178
|
+
"accessible_count": accessible_count,
|
|
1179
|
+
"blocked_count": blocked_count,
|
|
1180
|
+
"waf_blocked_count": waf_blocked_count,
|
|
1181
|
+
"waf_provider": blocker_provider if waf_blocked_count > 0 else "",
|
|
1182
|
+
"all_waf_blocked": all_waf_blocked,
|
|
1183
|
+
"note": (
|
|
1184
|
+
f"该项无法检测:站点启用了 {blocker_provider} 安全防护,"
|
|
1185
|
+
f"Bot UA 探测请求被拦截,无法判断 AI 爬虫的真实可达性。"
|
|
1186
|
+
) if all_waf_blocked else "",
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
# D1.3 Sitemap
|
|
1190
|
+
signals["d1_sitemap"] = {
|
|
1191
|
+
"exists": sitemap_info.get("exists", False),
|
|
1192
|
+
"status_code": sitemap_info.get("status_code", 0),
|
|
1193
|
+
"url_count": sitemap_info.get("url_count", 0),
|
|
1194
|
+
"latest_lastmod": sitemap_info.get("latest_lastmod", ""),
|
|
1195
|
+
"has_hreflang": sitemap_info.get("has_hreflang", False),
|
|
1196
|
+
"robots_declares_sitemap": sitemap_info.get("robots_declares_sitemap", False),
|
|
1197
|
+
"sitemap_urls": sitemap_info.get("sitemap_urls", [])[:10],
|
|
1198
|
+
"child_sitemap_count": sitemap_info.get("child_sitemap_count", 0),
|
|
1199
|
+
"sample_source": sitemap_info.get("sample_source", "none"),
|
|
1200
|
+
"homepage_candidate_url_count": sitemap_info.get("homepage_candidate_url_count", 0),
|
|
1201
|
+
"sampled_page_urls": sitemap_info.get("sampled_page_urls", []),
|
|
1202
|
+
"sampled_page_categories": sitemap_info.get("sampled_page_categories", []),
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
# D1.4 Status Codes & Error Handling
|
|
1206
|
+
sub_statuses = [p.get("status", 0) for p in sub_pages]
|
|
1207
|
+
notfound_status = notfound_result.get("status", 0)
|
|
1208
|
+
notfound_text = notfound_result.get("text", "")
|
|
1209
|
+
notfound_has_noindex = "noindex" in notfound_text.lower() if notfound_text else False
|
|
1210
|
+
|
|
1211
|
+
# Check for soft-404 (404 probe returning 200)
|
|
1212
|
+
soft_404 = notfound_status == 200
|
|
1213
|
+
|
|
1214
|
+
signals["d1_status_codes"] = {
|
|
1215
|
+
"homepage_status": homepage_result.get("status", 0),
|
|
1216
|
+
"homepage_final_url": homepage_result.get("final_url", ""),
|
|
1217
|
+
"sub_page_statuses": sub_statuses,
|
|
1218
|
+
"sub_page_errors": sum(1 for s in sub_statuses if s >= 400 or s == 0),
|
|
1219
|
+
"notfound_status": notfound_status,
|
|
1220
|
+
"notfound_has_noindex": notfound_has_noindex,
|
|
1221
|
+
"soft_404": soft_404,
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
# D1.5 Rendering Readability
|
|
1225
|
+
signals["d1_rendering"] = {
|
|
1226
|
+
"word_count": homepage_info.get("word_count", 0),
|
|
1227
|
+
"render_method": render_method,
|
|
1228
|
+
"is_spa_shell": render_method != "static",
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
# D1.6 llms.txt
|
|
1232
|
+
llms_text = llms_result.get("text", "") if llms_result.get("ok") else ""
|
|
1233
|
+
llms_full_text = llms_full_result.get("text", "") if llms_full_result.get("ok") else ""
|
|
1234
|
+
llms_links = re.findall(r"\[([^\]]+)\]\(([^)]+)\)", llms_text)
|
|
1235
|
+
llms_has_title = llms_text.startswith("#") or "\n#" in llms_text
|
|
1236
|
+
llms_has_intro = ">" in llms_text[:500]
|
|
1237
|
+
llms_sections = len(re.findall(r"^##\s", llms_text, re.MULTILINE))
|
|
1238
|
+
|
|
1239
|
+
signals["d1_llms_txt"] = {
|
|
1240
|
+
"exists": llms_result.get("ok", False),
|
|
1241
|
+
"full_exists": llms_full_result.get("ok", False),
|
|
1242
|
+
"status_code": llms_result.get("status", 0),
|
|
1243
|
+
"has_title": llms_has_title,
|
|
1244
|
+
"has_intro": llms_has_intro,
|
|
1245
|
+
"link_count": len(llms_links),
|
|
1246
|
+
"section_count": llms_sections,
|
|
1247
|
+
"char_count": len(llms_text),
|
|
1248
|
+
}
|
|
1249
|
+
if llms_text:
|
|
1250
|
+
snippets["d1_llms_txt_content"] = llms_text[:SNIPPET_MAX_LEN]
|
|
1251
|
+
|
|
1252
|
+
# D1.7 Analytics & CDN
|
|
1253
|
+
html_lower = homepage_result.get("text", "").lower()
|
|
1254
|
+
has_ga4 = bool(re.search(r"g-[a-z0-9]{6,}", html_lower))
|
|
1255
|
+
has_gtm = "gtm-" in html_lower or "googletagmanager" in html_lower
|
|
1256
|
+
has_gtag = "gtag(" in html_lower or "gtag.js" in html_lower
|
|
1257
|
+
|
|
1258
|
+
cdn_signals = {}
|
|
1259
|
+
for key in ["server", "cf-ray", "x-served-by", "via", "x-cdn"]:
|
|
1260
|
+
if key in homepage_headers:
|
|
1261
|
+
cdn_signals[key] = homepage_headers[key]
|
|
1262
|
+
is_cloudflare = "cloudflare" in homepage_headers.get("server", "").lower() or "cf-ray" in homepage_headers
|
|
1263
|
+
|
|
1264
|
+
signals["d1_analytics"] = {
|
|
1265
|
+
"has_ga4": has_ga4,
|
|
1266
|
+
"has_gtm": has_gtm,
|
|
1267
|
+
"has_gtag": has_gtag,
|
|
1268
|
+
"cdn_signals": cdn_signals,
|
|
1269
|
+
"is_cloudflare": is_cloudflare,
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
coverage_targets = {
|
|
1273
|
+
"pricing": ("pricing", "price", "plans", "费用", "价格"),
|
|
1274
|
+
"product_service": ("product", "service", "feature", "solution", "course", "产品", "服务", "课程"),
|
|
1275
|
+
"blog_resources": ("blog", "resource", "learn", "guide", "academy", "博客", "资源", "指南"),
|
|
1276
|
+
"docs_help": ("docs", "documentation", "help", "support", "faq", "帮助", "文档"),
|
|
1277
|
+
"about_contact": ("about", "contact", "company", "team", "关于", "联系"),
|
|
1278
|
+
"legal": ("privacy", "terms", "legal", "policy", "compliance", "隐私", "条款", "合规"),
|
|
1279
|
+
}
|
|
1280
|
+
searchable_links = " ".join(internal_links + sitemap_page_urls).lower()
|
|
1281
|
+
coverage = {
|
|
1282
|
+
name: any(token in searchable_links for token in tokens)
|
|
1283
|
+
for name, tokens in coverage_targets.items()
|
|
1284
|
+
}
|
|
1285
|
+
high_value_categories = {
|
|
1286
|
+
entry.get("category")
|
|
1287
|
+
for entry in sitemap_info.get("sampled_page_categories", [])
|
|
1288
|
+
if entry.get("category")
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
signals["d1_core_entry_coverage"] = {
|
|
1292
|
+
"coverage": coverage,
|
|
1293
|
+
"covered_count": sum(1 for value in coverage.values() if value),
|
|
1294
|
+
"total_targets": len(coverage),
|
|
1295
|
+
"internal_link_count": len(internal_links),
|
|
1296
|
+
"sitemap_page_count": len(sitemap_page_urls),
|
|
1297
|
+
}
|
|
1298
|
+
signals["d1_crawl_set_coverage"] = {
|
|
1299
|
+
"sampled_page_count": len(sub_pages),
|
|
1300
|
+
"sampled_categories": sorted(high_value_categories),
|
|
1301
|
+
"has_homepage": True,
|
|
1302
|
+
"has_high_value_page": any(
|
|
1303
|
+
category in high_value_categories
|
|
1304
|
+
for category in ("conversion", "trust", "citation", "template")
|
|
1305
|
+
),
|
|
1306
|
+
}
|
|
1307
|
+
signals["d1_waf_cdn_blocking"] = {
|
|
1308
|
+
"detected": signals["d1_access_blocker"].get("detected", False),
|
|
1309
|
+
"evidence": signals["d1_access_blocker"].get("evidence", []),
|
|
1310
|
+
"cdn_signals": cdn_signals,
|
|
1311
|
+
"status_code": homepage_status,
|
|
1312
|
+
}
|
|
1313
|
+
meta_robots = homepage_info.get("meta_robots", "")
|
|
1314
|
+
x_robots_tag = homepage_headers.get("x-robots-tag", "")
|
|
1315
|
+
canonical = homepage_info.get("canonical", "")
|
|
1316
|
+
canonical_host = urlparse(urljoin(url, canonical)).netloc if canonical else ""
|
|
1317
|
+
signals["d1_indexability"] = {
|
|
1318
|
+
"meta_robots": meta_robots,
|
|
1319
|
+
"x_robots_tag": x_robots_tag,
|
|
1320
|
+
"has_noindex": "noindex" in f"{meta_robots} {x_robots_tag}".lower(),
|
|
1321
|
+
"has_nofollow": "nofollow" in f"{meta_robots} {x_robots_tag}".lower(),
|
|
1322
|
+
"canonical": canonical,
|
|
1323
|
+
"canonical_host": canonical_host,
|
|
1324
|
+
"canonical_conflict": bool(canonical_host and canonical_host != parsed_final.netloc),
|
|
1325
|
+
}
|
|
1326
|
+
lower_notfound = notfound_text.lower()
|
|
1327
|
+
signals["d1_soft_404"] = {
|
|
1328
|
+
"probe_status": notfound_status,
|
|
1329
|
+
"soft_404": soft_404,
|
|
1330
|
+
"looks_like_error_page": any(token in lower_notfound for token in ("not found", "404", "page not found", "不存在")),
|
|
1331
|
+
"has_noindex": notfound_has_noindex,
|
|
1332
|
+
}
|
|
1333
|
+
homepage_signature = (
|
|
1334
|
+
homepage_info.get("title", ""),
|
|
1335
|
+
"|".join(homepage_info.get("h1", [])),
|
|
1336
|
+
homepage_info.get("canonical", ""),
|
|
1337
|
+
)
|
|
1338
|
+
fallback_examples = []
|
|
1339
|
+
for page in sub_pages:
|
|
1340
|
+
if page.get("status") == 200:
|
|
1341
|
+
info = _parse_html(page.get("text", ""), page.get("final_url", ""))
|
|
1342
|
+
signature = (
|
|
1343
|
+
info.get("title", ""),
|
|
1344
|
+
"|".join(info.get("h1", [])),
|
|
1345
|
+
info.get("canonical", ""),
|
|
1346
|
+
)
|
|
1347
|
+
if signature == homepage_signature:
|
|
1348
|
+
fallback_examples.append(page.get("final_url") or page.get("url"))
|
|
1349
|
+
signals["d1_homepage_fallback"] = {
|
|
1350
|
+
"fallback_count": len(fallback_examples),
|
|
1351
|
+
"fallback_examples": fallback_examples[:5],
|
|
1352
|
+
}
|
|
1353
|
+
signals["d1_404_index_conflict"] = {
|
|
1354
|
+
"notfound_status": notfound_status,
|
|
1355
|
+
"notfound_has_noindex": notfound_has_noindex,
|
|
1356
|
+
"conflict": notfound_status in (404, 410) and not notfound_has_noindex,
|
|
1357
|
+
}
|
|
1358
|
+
signals["d1_sitemap_pollution"] = {
|
|
1359
|
+
"sampled_errors": sum(1 for status in sub_statuses if status >= 400 or status == 0),
|
|
1360
|
+
"sampled_total": len(sub_statuses),
|
|
1361
|
+
"sitemap_url_count": sitemap_info.get("url_count", 0),
|
|
1362
|
+
}
|
|
1363
|
+
signals["d1_internal_discovery"] = {
|
|
1364
|
+
"internal_link_count": len(internal_links),
|
|
1365
|
+
"footer_nav_coverage_proxy": coverage,
|
|
1366
|
+
"covered_target_count": sum(1 for value in coverage.values() if value),
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
return signals, snippets
|
|
1370
|
+
|
|
1371
|
+
|
|
1372
|
+
# ============================================================================
|
|
1373
|
+
# Signal Collection — D2: Content Quality
|
|
1374
|
+
# ============================================================================
|
|
1375
|
+
|
|
1376
|
+
def _collect_d2(homepage_info: dict, sub_page_infos: list[dict],
|
|
1377
|
+
homepage_result: dict) -> tuple[dict, dict]:
|
|
1378
|
+
"""Collect D2 Content Quality signals and snippets."""
|
|
1379
|
+
signals: dict[str, Any] = {}
|
|
1380
|
+
snippets: dict[str, str] = {}
|
|
1381
|
+
|
|
1382
|
+
all_infos = [homepage_info] + sub_page_infos
|
|
1383
|
+
word_counts = [p.get("word_count", 0) for p in all_infos]
|
|
1384
|
+
|
|
1385
|
+
# D2.1 Professional Depth
|
|
1386
|
+
# Deep pages: >=800 words (English) or large CJK content
|
|
1387
|
+
deep_threshold = 800
|
|
1388
|
+
deep_pages = sum(1 for wc in word_counts if wc >= deep_threshold)
|
|
1389
|
+
deep_ratio = deep_pages / max(len(word_counts), 1)
|
|
1390
|
+
|
|
1391
|
+
signals["d2_depth"] = {
|
|
1392
|
+
"pages_sampled": len(all_infos),
|
|
1393
|
+
"word_counts": word_counts,
|
|
1394
|
+
"deep_pages": deep_pages,
|
|
1395
|
+
"deep_ratio": round(deep_ratio, 2),
|
|
1396
|
+
}
|
|
1397
|
+
# Snippet: first 500 chars of the deepest page body for agent to judge quality
|
|
1398
|
+
deepest_idx = word_counts.index(max(word_counts)) if word_counts else 0
|
|
1399
|
+
if all_infos:
|
|
1400
|
+
snippets["d2_depth_sample"] = all_infos[deepest_idx].get("body_text", "")[:SNIPPET_MAX_LEN]
|
|
1401
|
+
|
|
1402
|
+
# D2.2 Content Freshness
|
|
1403
|
+
all_dates = []
|
|
1404
|
+
for info in all_infos:
|
|
1405
|
+
all_dates.extend(info.get("time_elements", []))
|
|
1406
|
+
all_dates.extend(info.get("visible_dates", []))
|
|
1407
|
+
# Also check schema datePublished/dateModified
|
|
1408
|
+
for info in all_infos:
|
|
1409
|
+
for block in info.get("schema_blocks", []):
|
|
1410
|
+
for key in ("datePublished", "dateModified"):
|
|
1411
|
+
if key in block:
|
|
1412
|
+
all_dates.append(str(block[key])[:10])
|
|
1413
|
+
|
|
1414
|
+
# Parse and find most recent
|
|
1415
|
+
parsed_dates = []
|
|
1416
|
+
for d in all_dates:
|
|
1417
|
+
try:
|
|
1418
|
+
# Try ISO format
|
|
1419
|
+
parsed_dates.append(datetime.fromisoformat(d[:10]).date())
|
|
1420
|
+
except (ValueError, TypeError):
|
|
1421
|
+
pass
|
|
1422
|
+
|
|
1423
|
+
latest_date = max(parsed_dates) if parsed_dates else None
|
|
1424
|
+
days_since_latest = (datetime.now(timezone.utc).date() - latest_date).days if latest_date else None
|
|
1425
|
+
pages_with_dates = sum(1 for info in all_infos
|
|
1426
|
+
if info.get("time_elements") or info.get("visible_dates"))
|
|
1427
|
+
|
|
1428
|
+
signals["d2_freshness"] = {
|
|
1429
|
+
"total_dates_found": len(all_dates),
|
|
1430
|
+
"pages_with_dates": pages_with_dates,
|
|
1431
|
+
"pages_sampled": len(all_infos),
|
|
1432
|
+
"latest_date": str(latest_date) if latest_date else None,
|
|
1433
|
+
"days_since_latest": days_since_latest,
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
# D2.3 Author/Team Attribution
|
|
1437
|
+
author_signals = []
|
|
1438
|
+
has_author_schema = False
|
|
1439
|
+
has_about_link = False
|
|
1440
|
+
|
|
1441
|
+
for info in all_infos:
|
|
1442
|
+
for block in info.get("schema_blocks", []):
|
|
1443
|
+
if "author" in block:
|
|
1444
|
+
has_author_schema = True
|
|
1445
|
+
author_val = block["author"]
|
|
1446
|
+
if isinstance(author_val, dict):
|
|
1447
|
+
author_signals.append(author_val.get("name", ""))
|
|
1448
|
+
elif isinstance(author_val, str):
|
|
1449
|
+
author_signals.append(author_val)
|
|
1450
|
+
|
|
1451
|
+
# Check for author/about page links
|
|
1452
|
+
all_links = []
|
|
1453
|
+
for info in all_infos:
|
|
1454
|
+
all_links.extend(info.get("internal_links", []))
|
|
1455
|
+
about_patterns = ["/about", "/team", "/author", "/我们", "/关于"]
|
|
1456
|
+
has_about_link = any(any(p in link.lower() for p in about_patterns) for link in all_links)
|
|
1457
|
+
|
|
1458
|
+
# Text patterns for bylines
|
|
1459
|
+
body_combined = " ".join(info.get("body_text", "")[:200] for info in all_infos)
|
|
1460
|
+
byline_patterns = re.findall(
|
|
1461
|
+
r"(?:by|作者|编辑|reviewed by|written by)\s*[::]?\s*([A-Z\u4e00-\u9fff][\w\s\u4e00-\u9fff]{1,30})",
|
|
1462
|
+
body_combined, re.IGNORECASE
|
|
1463
|
+
)
|
|
1464
|
+
|
|
1465
|
+
signals["d2_author"] = {
|
|
1466
|
+
"has_author_schema": has_author_schema,
|
|
1467
|
+
"schema_authors": list(set(author_signals))[:5],
|
|
1468
|
+
"has_about_link": has_about_link,
|
|
1469
|
+
"byline_matches": byline_patterns[:5],
|
|
1470
|
+
}
|
|
1471
|
+
if author_signals or byline_patterns:
|
|
1472
|
+
snippets["d2_author_context"] = "; ".join(
|
|
1473
|
+
(author_signals + byline_patterns)[:5]
|
|
1474
|
+
)[:SNIPPET_MAX_LEN]
|
|
1475
|
+
|
|
1476
|
+
# D2.4 Sources & Citations
|
|
1477
|
+
all_external = []
|
|
1478
|
+
for info in all_infos:
|
|
1479
|
+
all_external.extend(info.get("external_links", []))
|
|
1480
|
+
ext_domains = list(set(urlparse(u).netloc for u in all_external if urlparse(u).netloc))
|
|
1481
|
+
authority_hits = [d for d in ext_domains
|
|
1482
|
+
if any(a in d for a in AUTHORITY_SOURCES)]
|
|
1483
|
+
|
|
1484
|
+
signals["d2_citations"] = {
|
|
1485
|
+
"total_external_links": len(all_external),
|
|
1486
|
+
"unique_domains": len(ext_domains),
|
|
1487
|
+
"authority_domains": authority_hits[:10],
|
|
1488
|
+
"avg_per_page": round(len(all_external) / max(len(all_infos), 1), 1),
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
# D2.5 Real Experience Signals
|
|
1492
|
+
exp_hits: dict[str, int] = {}
|
|
1493
|
+
exp_context_snippets: list[str] = []
|
|
1494
|
+
combined_text = " ".join(info.get("body_text", "") for info in all_infos).lower()
|
|
1495
|
+
|
|
1496
|
+
for kw in EXPERIENCE_KEYWORDS:
|
|
1497
|
+
count = combined_text.count(kw.lower())
|
|
1498
|
+
if count > 0:
|
|
1499
|
+
exp_hits[kw] = count
|
|
1500
|
+
# Grab context around first occurrence
|
|
1501
|
+
idx = combined_text.find(kw.lower())
|
|
1502
|
+
start = max(0, idx - 50)
|
|
1503
|
+
end = min(len(combined_text), idx + len(kw) + 100)
|
|
1504
|
+
exp_context_snippets.append(combined_text[start:end])
|
|
1505
|
+
|
|
1506
|
+
# Video embeds
|
|
1507
|
+
has_video = any("youtube" in info.get("body_text", "").lower() or
|
|
1508
|
+
"vimeo" in info.get("body_text", "").lower() or
|
|
1509
|
+
"bilibili" in info.get("body_text", "").lower()
|
|
1510
|
+
for info in all_infos)
|
|
1511
|
+
|
|
1512
|
+
signals["d2_experience"] = {
|
|
1513
|
+
"keyword_hits": exp_hits,
|
|
1514
|
+
"hit_types": len(exp_hits),
|
|
1515
|
+
"has_video_embed": has_video,
|
|
1516
|
+
}
|
|
1517
|
+
if exp_context_snippets:
|
|
1518
|
+
snippets["d2_experience_context"] = "\n---\n".join(
|
|
1519
|
+
exp_context_snippets[:3]
|
|
1520
|
+
)[:SNIPPET_MAX_LEN]
|
|
1521
|
+
|
|
1522
|
+
# D2.6 Facts & Data
|
|
1523
|
+
# Count numeric tokens, percentages, currency
|
|
1524
|
+
num_tokens = len(re.findall(r"\b\d+[.,]?\d*%?\b", combined_text))
|
|
1525
|
+
year_refs = len(re.findall(r"\b20[12]\d\b", combined_text))
|
|
1526
|
+
|
|
1527
|
+
signals["d2_data"] = {
|
|
1528
|
+
"numeric_tokens": min(num_tokens, 999),
|
|
1529
|
+
"year_references": year_refs,
|
|
1530
|
+
"authority_source_mentions": len(authority_hits),
|
|
1531
|
+
}
|
|
1532
|
+
if num_tokens > 0:
|
|
1533
|
+
# Sample a snippet with data context
|
|
1534
|
+
data_match = re.search(r"[^.]*\d+[.,]?\d*%[^.]*\.", combined_text)
|
|
1535
|
+
if data_match:
|
|
1536
|
+
snippets["d2_data_sample"] = data_match.group(0)[:SNIPPET_MAX_LEN]
|
|
1537
|
+
|
|
1538
|
+
# D2.7 Content Completeness
|
|
1539
|
+
short_threshold = 200
|
|
1540
|
+
short_pages = sum(1 for wc in word_counts if wc < short_threshold)
|
|
1541
|
+
short_ratio = short_pages / max(len(word_counts), 1)
|
|
1542
|
+
|
|
1543
|
+
signals["d2_completeness"] = {
|
|
1544
|
+
"pages_sampled": len(word_counts),
|
|
1545
|
+
"short_pages": short_pages,
|
|
1546
|
+
"short_ratio": round(short_ratio, 2),
|
|
1547
|
+
"word_counts": word_counts,
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
# D2.8 Summaries & Key Takeaways
|
|
1551
|
+
summary_kws = ["tl;dr", "key takeaways", "summary", "摘要", "要点",
|
|
1552
|
+
"核心结论", "highlights", "in brief", "总结"]
|
|
1553
|
+
summary_hits = 0
|
|
1554
|
+
summary_context = []
|
|
1555
|
+
for info in all_infos:
|
|
1556
|
+
page_text = info.get("body_text", "").lower()
|
|
1557
|
+
headings = " ".join(info.get("h2", []) + info.get("h3", [])).lower()
|
|
1558
|
+
if any(kw in page_text or kw in headings for kw in summary_kws):
|
|
1559
|
+
summary_hits += 1
|
|
1560
|
+
# Find the actual summary section
|
|
1561
|
+
for kw in summary_kws:
|
|
1562
|
+
idx = page_text.find(kw)
|
|
1563
|
+
if idx >= 0:
|
|
1564
|
+
summary_context.append(page_text[idx:idx + 200])
|
|
1565
|
+
break
|
|
1566
|
+
|
|
1567
|
+
signals["d2_summaries"] = {
|
|
1568
|
+
"pages_with_summary": summary_hits,
|
|
1569
|
+
"pages_sampled": len(all_infos),
|
|
1570
|
+
"coverage_ratio": round(summary_hits / max(len(all_infos), 1), 2),
|
|
1571
|
+
}
|
|
1572
|
+
if summary_context:
|
|
1573
|
+
snippets["d2_summary_sample"] = "\n---\n".join(summary_context[:3])[:SNIPPET_MAX_LEN]
|
|
1574
|
+
|
|
1575
|
+
# New 5-dimension score-table support signals.
|
|
1576
|
+
all_h1 = [h for info in all_infos for h in info.get("h1", [])]
|
|
1577
|
+
all_h2_h6 = [
|
|
1578
|
+
h
|
|
1579
|
+
for info in all_infos
|
|
1580
|
+
for key in ("h2", "h3", "h4", "h5", "h6")
|
|
1581
|
+
for h in info.get(key, [])
|
|
1582
|
+
]
|
|
1583
|
+
schema_types_all = [schema_type for info in all_infos for schema_type in info.get("schema_types", [])]
|
|
1584
|
+
og_complete = sum(1 for info in all_infos if {"og:title", "og:description"}.issubset(set(info.get("og_tags", {}).keys())))
|
|
1585
|
+
twitter_complete = sum(1 for info in all_infos if info.get("twitter_tags"))
|
|
1586
|
+
image_count = sum(info.get("image_count", 0) for info in all_infos)
|
|
1587
|
+
images_missing_alt = sum(info.get("images_missing_alt", 0) for info in all_infos)
|
|
1588
|
+
images_weak_alt = sum(info.get("images_weak_alt", 0) for info in all_infos)
|
|
1589
|
+
combined_links = " ".join(link.lower() for info in all_infos for link in info.get("internal_links", []))
|
|
1590
|
+
information_architecture_targets = {
|
|
1591
|
+
"product": ("product", "feature", "solution", "course", "service", "产品", "服务", "课程"),
|
|
1592
|
+
"pricing": ("pricing", "price", "plans", "费用", "价格"),
|
|
1593
|
+
"blog_resources": ("blog", "resource", "learn", "guide", "academy", "博客", "资源", "指南"),
|
|
1594
|
+
"docs_help": ("docs", "documentation", "help", "support", "faq", "帮助", "文档"),
|
|
1595
|
+
"about_contact": ("about", "contact", "company", "team", "关于", "联系"),
|
|
1596
|
+
"legal": ("privacy", "terms", "legal", "policy", "隐私", "条款"),
|
|
1597
|
+
}
|
|
1598
|
+
architecture_coverage = {
|
|
1599
|
+
key: any(token in combined_links for token in tokens)
|
|
1600
|
+
for key, tokens in information_architecture_targets.items()
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
signals["d2_heading_structure"] = {
|
|
1604
|
+
"h1_count_total": len(all_h1),
|
|
1605
|
+
"pages_with_one_h1": sum(1 for info in all_infos if len(info.get("h1", [])) == 1),
|
|
1606
|
+
"h2_h6_count_total": len(all_h2_h6),
|
|
1607
|
+
"heading_samples": all_h2_h6[:20],
|
|
1608
|
+
}
|
|
1609
|
+
signals["d2_information_architecture"] = {
|
|
1610
|
+
"coverage": architecture_coverage,
|
|
1611
|
+
"covered_count": sum(1 for value in architecture_coverage.values() if value),
|
|
1612
|
+
"total_targets": len(architecture_coverage),
|
|
1613
|
+
}
|
|
1614
|
+
signals["d2_schema_coverage"] = {
|
|
1615
|
+
"schema_types": sorted(set(schema_types_all)),
|
|
1616
|
+
"schema_type_count": len(set(schema_types_all)),
|
|
1617
|
+
"has_organization": "Organization" in schema_types_all,
|
|
1618
|
+
"has_website": "WebSite" in schema_types_all,
|
|
1619
|
+
"has_breadcrumb": "BreadcrumbList" in schema_types_all,
|
|
1620
|
+
"has_article_like": any(t in schema_types_all for t in ("Article", "BlogPosting", "NewsArticle", "TechArticle")),
|
|
1621
|
+
"has_faq": "FAQPage" in schema_types_all,
|
|
1622
|
+
}
|
|
1623
|
+
signals["d2_social_metadata"] = {
|
|
1624
|
+
"pages_with_og_title_description": og_complete,
|
|
1625
|
+
"pages_with_twitter_tags": twitter_complete,
|
|
1626
|
+
"pages_sampled": len(all_infos),
|
|
1627
|
+
"image_count": image_count,
|
|
1628
|
+
"images_missing_alt": images_missing_alt,
|
|
1629
|
+
"images_weak_alt": images_weak_alt,
|
|
1630
|
+
}
|
|
1631
|
+
signals["d2_lang"] = {
|
|
1632
|
+
"html_lang": homepage_info.get("html_lang", ""),
|
|
1633
|
+
"hreflang_count": homepage_info.get("hreflang_count", 0),
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
return signals, snippets
|
|
1637
|
+
|
|
1638
|
+
|
|
1639
|
+
# ============================================================================
|
|
1640
|
+
# Signal Collection — D3: Brand Entity
|
|
1641
|
+
# ============================================================================
|
|
1642
|
+
|
|
1643
|
+
def _collect_d3(homepage_info: dict, homepage_result: dict,
|
|
1644
|
+
llms_result: dict, sub_page_infos: list[dict],
|
|
1645
|
+
wikipedia_result: dict, wikidata_result: dict,
|
|
1646
|
+
domain: str) -> tuple[dict, dict]:
|
|
1647
|
+
"""Collect D3 Brand Entity signals and snippets."""
|
|
1648
|
+
signals: dict[str, Any] = {}
|
|
1649
|
+
snippets: dict[str, str] = {}
|
|
1650
|
+
|
|
1651
|
+
schema_blocks = homepage_info.get("schema_blocks", [])
|
|
1652
|
+
schema_types = homepage_info.get("schema_types", [])
|
|
1653
|
+
|
|
1654
|
+
# D3.1 Brand Name Consistency
|
|
1655
|
+
# Sources: Organization schema name, <title> brand segment, llms.txt title
|
|
1656
|
+
org_name = ""
|
|
1657
|
+
for block in schema_blocks:
|
|
1658
|
+
if block.get("@type") in ("Organization", "Corporation", "LocalBusiness"):
|
|
1659
|
+
org_name = block.get("name", "")
|
|
1660
|
+
break
|
|
1661
|
+
|
|
1662
|
+
title = homepage_info.get("title", "")
|
|
1663
|
+
title_brand = title.split("|")[0].split(" - ")[0].split(" — ")[0].strip() if title else ""
|
|
1664
|
+
|
|
1665
|
+
llms_text = llms_result.get("text", "") if llms_result.get("ok") else ""
|
|
1666
|
+
llms_title = ""
|
|
1667
|
+
if llms_text:
|
|
1668
|
+
first_line = llms_text.strip().split("\n")[0]
|
|
1669
|
+
if first_line.startswith("#"):
|
|
1670
|
+
llms_title = first_line.lstrip("#").strip()
|
|
1671
|
+
|
|
1672
|
+
signals["d3_brand_name"] = {
|
|
1673
|
+
"schema_org_name": org_name,
|
|
1674
|
+
"title_brand": title_brand,
|
|
1675
|
+
"llms_title": llms_title,
|
|
1676
|
+
"domain_root": domain.replace("www.", "").split(".")[0],
|
|
1677
|
+
}
|
|
1678
|
+
snippets["d3_brand_name_sources"] = (
|
|
1679
|
+
f"Schema Organization name: {org_name}\n"
|
|
1680
|
+
f"Title brand segment: {title_brand}\n"
|
|
1681
|
+
f"llms.txt title: {llms_title}\n"
|
|
1682
|
+
f"Domain: {domain}"
|
|
1683
|
+
)
|
|
1684
|
+
|
|
1685
|
+
# D3.2 Brand Description
|
|
1686
|
+
description = homepage_info.get("description", "")
|
|
1687
|
+
h1_text = " | ".join(homepage_info.get("h1", []))
|
|
1688
|
+
llms_intro = ""
|
|
1689
|
+
if llms_text:
|
|
1690
|
+
# Extract blockquote (>) intro
|
|
1691
|
+
intro_lines = [l[1:].strip() for l in llms_text.split("\n") if l.startswith(">")]
|
|
1692
|
+
llms_intro = " ".join(intro_lines)
|
|
1693
|
+
|
|
1694
|
+
signals["d3_brand_description"] = {
|
|
1695
|
+
"meta_description_length": len(description),
|
|
1696
|
+
"has_h1": bool(h1_text),
|
|
1697
|
+
"llms_intro_length": len(llms_intro),
|
|
1698
|
+
}
|
|
1699
|
+
snippets["d3_brand_description_content"] = (
|
|
1700
|
+
f"Meta description: {description}\n"
|
|
1701
|
+
f"H1: {h1_text}\n"
|
|
1702
|
+
f"llms.txt intro: {llms_intro[:200]}"
|
|
1703
|
+
)[:SNIPPET_MAX_LEN]
|
|
1704
|
+
|
|
1705
|
+
# D3.3 Trust Entry Points
|
|
1706
|
+
all_links = homepage_info.get("internal_links", [])
|
|
1707
|
+
links_lower = [l.lower() for l in all_links]
|
|
1708
|
+
body_lower = homepage_info.get("body_text", "").lower()
|
|
1709
|
+
|
|
1710
|
+
trust_found = []
|
|
1711
|
+
for path in TRUST_PATHS_EN:
|
|
1712
|
+
if any(path in l for l in links_lower):
|
|
1713
|
+
trust_found.append(path)
|
|
1714
|
+
for path in TRUST_PATHS_ZH:
|
|
1715
|
+
if path in body_lower:
|
|
1716
|
+
trust_found.append(path)
|
|
1717
|
+
trust_found = list(set(trust_found))
|
|
1718
|
+
|
|
1719
|
+
signals["d3_trust_entries"] = {
|
|
1720
|
+
"found": trust_found,
|
|
1721
|
+
"count": len(trust_found),
|
|
1722
|
+
"missing": [p for p in TRUST_PATHS_EN if p not in trust_found],
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
# D3.4 Third-Party Platforms
|
|
1726
|
+
platform_patterns = {
|
|
1727
|
+
"App Store": ["apps.apple.com", "itunes.apple.com"],
|
|
1728
|
+
"Google Play": ["play.google.com"],
|
|
1729
|
+
"LinkedIn": ["linkedin.com"],
|
|
1730
|
+
"YouTube": ["youtube.com", "youtu.be"],
|
|
1731
|
+
"Twitter/X": ["twitter.com", "x.com"],
|
|
1732
|
+
"Facebook": ["facebook.com"],
|
|
1733
|
+
"Instagram": ["instagram.com"],
|
|
1734
|
+
"Reddit": ["reddit.com"],
|
|
1735
|
+
"GitHub": ["github.com"],
|
|
1736
|
+
"Trustpilot": ["trustpilot.com"],
|
|
1737
|
+
"G2": ["g2.com"],
|
|
1738
|
+
}
|
|
1739
|
+
ext_links = homepage_info.get("external_links", [])
|
|
1740
|
+
found_platforms = []
|
|
1741
|
+
for name, patterns in platform_patterns.items():
|
|
1742
|
+
if any(any(p in link for p in patterns) for link in ext_links):
|
|
1743
|
+
found_platforms.append(name)
|
|
1744
|
+
|
|
1745
|
+
# Also check schema sameAs
|
|
1746
|
+
same_as_links = []
|
|
1747
|
+
for block in schema_blocks:
|
|
1748
|
+
sa = block.get("sameAs", [])
|
|
1749
|
+
if isinstance(sa, str):
|
|
1750
|
+
sa = [sa]
|
|
1751
|
+
same_as_links.extend(sa)
|
|
1752
|
+
|
|
1753
|
+
signals["d3_third_party"] = {
|
|
1754
|
+
"platforms_found": found_platforms,
|
|
1755
|
+
"platform_count": len(found_platforms),
|
|
1756
|
+
"same_as_count": len(same_as_links),
|
|
1757
|
+
"same_as_links": same_as_links[:10],
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
# D3.5 Knowledge Graph Presence
|
|
1761
|
+
wiki_data = {}
|
|
1762
|
+
if wikipedia_result.get("ok") and wikipedia_result.get("text"):
|
|
1763
|
+
try:
|
|
1764
|
+
wiki_json = json.loads(wikipedia_result["text"])
|
|
1765
|
+
search_results = wiki_json.get("query", {}).get("search", [])
|
|
1766
|
+
wiki_data = {
|
|
1767
|
+
"total_hits": wiki_json.get("query", {}).get("searchinfo", {}).get("totalhits", 0),
|
|
1768
|
+
"results": [{"title": r.get("title", ""), "snippet": r.get("snippet", "")}
|
|
1769
|
+
for r in search_results[:3]],
|
|
1770
|
+
}
|
|
1771
|
+
except (json.JSONDecodeError, KeyError):
|
|
1772
|
+
pass
|
|
1773
|
+
|
|
1774
|
+
wikidata_data = {}
|
|
1775
|
+
if wikidata_result.get("ok") and wikidata_result.get("text"):
|
|
1776
|
+
try:
|
|
1777
|
+
wd_json = json.loads(wikidata_result["text"])
|
|
1778
|
+
entities = wd_json.get("search", [])
|
|
1779
|
+
wikidata_data = {
|
|
1780
|
+
"entity_count": len(entities),
|
|
1781
|
+
"entities": [{"id": e.get("id", ""), "label": e.get("label", ""),
|
|
1782
|
+
"description": e.get("description", "")}
|
|
1783
|
+
for e in entities[:3]],
|
|
1784
|
+
}
|
|
1785
|
+
except (json.JSONDecodeError, KeyError):
|
|
1786
|
+
pass
|
|
1787
|
+
|
|
1788
|
+
# Check if sameAs includes wikipedia/wikidata
|
|
1789
|
+
has_wiki_sameas = any("wikipedia" in l or "wikidata" in l for l in same_as_links)
|
|
1790
|
+
|
|
1791
|
+
signals["d3_knowledge_graph"] = {
|
|
1792
|
+
"wikipedia": wiki_data,
|
|
1793
|
+
"wikidata": wikidata_data,
|
|
1794
|
+
"has_wiki_sameas": has_wiki_sameas,
|
|
1795
|
+
}
|
|
1796
|
+
if wiki_data.get("results"):
|
|
1797
|
+
snippets["d3_knowledge_graph_results"] = json.dumps(
|
|
1798
|
+
wiki_data["results"][:2], ensure_ascii=False
|
|
1799
|
+
)[:SNIPPET_MAX_LEN]
|
|
1800
|
+
|
|
1801
|
+
# D3.6 Technical Trust Assets
|
|
1802
|
+
tech_patterns = ["github.com", "developer.", "api docs", "openapi", "swagger",
|
|
1803
|
+
"sdk", "npm", "pypi", "/developers", "/api", "/docs"]
|
|
1804
|
+
tech_found = []
|
|
1805
|
+
all_page_links = ext_links + homepage_info.get("internal_links", [])
|
|
1806
|
+
for pattern in tech_patterns:
|
|
1807
|
+
if any(pattern in l.lower() for l in all_page_links):
|
|
1808
|
+
tech_found.append(pattern)
|
|
1809
|
+
# Also check body text
|
|
1810
|
+
body = homepage_info.get("body_text", "").lower()
|
|
1811
|
+
for pattern in tech_patterns:
|
|
1812
|
+
if pattern in body and pattern not in tech_found:
|
|
1813
|
+
tech_found.append(pattern)
|
|
1814
|
+
|
|
1815
|
+
signals["d3_tech_assets"] = {
|
|
1816
|
+
"found": list(set(tech_found)),
|
|
1817
|
+
"count": len(set(tech_found)),
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
# D3.7 Organization + WebSite Schema
|
|
1821
|
+
has_org = any(t in ("Organization", "Corporation", "LocalBusiness") for t in schema_types)
|
|
1822
|
+
has_website = "WebSite" in schema_types
|
|
1823
|
+
|
|
1824
|
+
org_fields = {}
|
|
1825
|
+
for block in schema_blocks:
|
|
1826
|
+
if block.get("@type") in ("Organization", "Corporation", "LocalBusiness"):
|
|
1827
|
+
for f in ("url", "name", "logo", "sameAs", "description", "contactPoint"):
|
|
1828
|
+
if f in block:
|
|
1829
|
+
org_fields[f] = True
|
|
1830
|
+
break
|
|
1831
|
+
|
|
1832
|
+
signals["d3_schema_org"] = {
|
|
1833
|
+
"has_organization": has_org,
|
|
1834
|
+
"has_website": has_website,
|
|
1835
|
+
"org_fields": list(org_fields.keys()),
|
|
1836
|
+
"org_field_count": len(org_fields),
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
# D3.8 Brand Search Presence
|
|
1840
|
+
signals["d3_brand_search"] = {
|
|
1841
|
+
"wikipedia_hits": wiki_data.get("total_hits", 0),
|
|
1842
|
+
"wikidata_entities": wikidata_data.get("entity_count", 0),
|
|
1843
|
+
"same_as_count": len(same_as_links),
|
|
1844
|
+
"same_as_domains": list(set(urlparse(l).netloc for l in same_as_links if urlparse(l).netloc))[:10],
|
|
1845
|
+
}
|
|
1846
|
+
if same_as_links:
|
|
1847
|
+
snippets["d3_brand_search_sameas"] = "\n".join(same_as_links[:10])[:SNIPPET_MAX_LEN]
|
|
1848
|
+
|
|
1849
|
+
return signals, snippets
|
|
1850
|
+
|
|
1851
|
+
|
|
1852
|
+
# ============================================================================
|
|
1853
|
+
# Signal Collection — D4: AI Citability
|
|
1854
|
+
# ============================================================================
|
|
1855
|
+
|
|
1856
|
+
def _collect_d4(homepage_info: dict, sub_page_infos: list[dict],
|
|
1857
|
+
llms_result: dict, homepage_result: dict) -> tuple[dict, dict]:
|
|
1858
|
+
"""Collect D4 AI Citability signals and snippets."""
|
|
1859
|
+
signals: dict[str, Any] = {}
|
|
1860
|
+
snippets: dict[str, str] = {}
|
|
1861
|
+
|
|
1862
|
+
all_infos = [homepage_info] + sub_page_infos
|
|
1863
|
+
schema_types_all = []
|
|
1864
|
+
for info in all_infos:
|
|
1865
|
+
schema_types_all.extend(info.get("schema_types", []))
|
|
1866
|
+
|
|
1867
|
+
# D4.1 Definition Content
|
|
1868
|
+
description = homepage_info.get("description", "")
|
|
1869
|
+
h1 = " | ".join(homepage_info.get("h1", []))
|
|
1870
|
+
llms_text = llms_result.get("text", "") if llms_result.get("ok") else ""
|
|
1871
|
+
llms_intro = ""
|
|
1872
|
+
if llms_text:
|
|
1873
|
+
intro_lines = [l[1:].strip() for l in llms_text.split("\n") if l.startswith(">")]
|
|
1874
|
+
llms_intro = " ".join(intro_lines)
|
|
1875
|
+
|
|
1876
|
+
# Count definition patterns
|
|
1877
|
+
body_combined = " ".join(info.get("body_text", "") for info in all_infos)
|
|
1878
|
+
def_patterns = re.findall(
|
|
1879
|
+
r"(?:is a|是|refers to|即|指的是|定义为|means|represents)",
|
|
1880
|
+
body_combined, re.IGNORECASE
|
|
1881
|
+
)
|
|
1882
|
+
|
|
1883
|
+
signals["d4_definition"] = {
|
|
1884
|
+
"description_length": len(description),
|
|
1885
|
+
"has_h1": bool(h1),
|
|
1886
|
+
"has_llms_intro": bool(llms_intro),
|
|
1887
|
+
"definition_pattern_count": len(def_patterns),
|
|
1888
|
+
}
|
|
1889
|
+
snippets["d4_definition_content"] = (
|
|
1890
|
+
f"Description: {description}\n"
|
|
1891
|
+
f"H1: {h1}\n"
|
|
1892
|
+
f"llms.txt intro: {llms_intro[:200]}"
|
|
1893
|
+
)[:SNIPPET_MAX_LEN]
|
|
1894
|
+
|
|
1895
|
+
# D4.2 Direct Answer Paragraphs
|
|
1896
|
+
ideal_paragraphs = []
|
|
1897
|
+
for info in all_infos:
|
|
1898
|
+
for p in info.get("paragraphs", []):
|
|
1899
|
+
word_count = len(p.split())
|
|
1900
|
+
# Ideal range: 80-200 words for EN, or 160-400 chars for CJK-heavy
|
|
1901
|
+
if 60 <= word_count <= 250:
|
|
1902
|
+
ideal_paragraphs.append(p)
|
|
1903
|
+
|
|
1904
|
+
signals["d4_direct_answers"] = {
|
|
1905
|
+
"ideal_paragraph_count": len(ideal_paragraphs),
|
|
1906
|
+
"total_paragraphs": sum(len(info.get("paragraphs", [])) for info in all_infos),
|
|
1907
|
+
}
|
|
1908
|
+
if ideal_paragraphs:
|
|
1909
|
+
# Sample up to 3 ideal paragraphs for agent to judge
|
|
1910
|
+
snippets["d4_answer_paragraphs"] = "\n---\n".join(
|
|
1911
|
+
ideal_paragraphs[:3]
|
|
1912
|
+
)[:SNIPPET_MAX_LEN]
|
|
1913
|
+
|
|
1914
|
+
# D4.3 FAQ Coverage
|
|
1915
|
+
faq_pages = sum(1 for info in all_infos if info.get("has_faq_visible"))
|
|
1916
|
+
has_faq_schema = "FAQPage" in schema_types_all
|
|
1917
|
+
|
|
1918
|
+
# Count question patterns
|
|
1919
|
+
question_patterns = re.findall(
|
|
1920
|
+
r"(?:什么是|如何|怎么|为什么|why|how to|what is|how do|can i|does)",
|
|
1921
|
+
body_combined.lower()
|
|
1922
|
+
)
|
|
1923
|
+
# Count actual Q&A pairs in FAQ sections
|
|
1924
|
+
faq_questions = re.findall(
|
|
1925
|
+
r"(?:<h[2-4][^>]*>|^\s*[-•*]\s*|^\s*\d+\.\s*)(.*?\?)",
|
|
1926
|
+
body_combined, re.MULTILINE
|
|
1927
|
+
)
|
|
1928
|
+
|
|
1929
|
+
signals["d4_faq"] = {
|
|
1930
|
+
"has_visible_faq": faq_pages > 0,
|
|
1931
|
+
"has_faq_schema": has_faq_schema,
|
|
1932
|
+
"question_pattern_count": len(question_patterns),
|
|
1933
|
+
"faq_question_count": len(faq_questions),
|
|
1934
|
+
}
|
|
1935
|
+
# Snippet: FAQ content for agent to judge quality
|
|
1936
|
+
for info in all_infos:
|
|
1937
|
+
if info.get("has_faq_visible"):
|
|
1938
|
+
body = info.get("body_text", "")
|
|
1939
|
+
# Find FAQ section
|
|
1940
|
+
faq_idx = -1
|
|
1941
|
+
for kw in ["faq", "常见问题", "frequently asked"]:
|
|
1942
|
+
idx = body.lower().find(kw)
|
|
1943
|
+
if idx >= 0:
|
|
1944
|
+
faq_idx = idx
|
|
1945
|
+
break
|
|
1946
|
+
if faq_idx >= 0:
|
|
1947
|
+
snippets["d4_faq_content"] = body[faq_idx:faq_idx + SNIPPET_MAX_LEN]
|
|
1948
|
+
break
|
|
1949
|
+
|
|
1950
|
+
# D4.4 How-It-Works / Process
|
|
1951
|
+
process_kws = ["how it works", "工作原理", "如何使用", "step 1", "step 2",
|
|
1952
|
+
"快速开始", "操作步骤", "getting started", "how to use"]
|
|
1953
|
+
process_hits = []
|
|
1954
|
+
headings_combined = " ".join(
|
|
1955
|
+
" ".join(info.get("h2", []) + info.get("h3", [])) for info in all_infos
|
|
1956
|
+
).lower()
|
|
1957
|
+
|
|
1958
|
+
for kw in process_kws:
|
|
1959
|
+
if kw in body_combined.lower() or kw in headings_combined:
|
|
1960
|
+
process_hits.append(kw)
|
|
1961
|
+
|
|
1962
|
+
# Check for ordered lists as process indicators
|
|
1963
|
+
total_ol = sum(info.get("ol_count", 0) for info in all_infos)
|
|
1964
|
+
|
|
1965
|
+
signals["d4_process"] = {
|
|
1966
|
+
"keyword_hits": process_hits,
|
|
1967
|
+
"has_ordered_lists": total_ol > 0,
|
|
1968
|
+
"ol_count": total_ol,
|
|
1969
|
+
}
|
|
1970
|
+
if process_hits:
|
|
1971
|
+
# Find context around first process keyword
|
|
1972
|
+
for kw in process_hits:
|
|
1973
|
+
idx = body_combined.lower().find(kw)
|
|
1974
|
+
if idx >= 0:
|
|
1975
|
+
snippets["d4_process_content"] = body_combined[idx:idx + SNIPPET_MAX_LEN]
|
|
1976
|
+
break
|
|
1977
|
+
|
|
1978
|
+
# D4.5 Comparison Content
|
|
1979
|
+
compare_kws = [" vs ", "对比", "compare", "alternatives", "区别",
|
|
1980
|
+
"difference", "better than", "选择", "versus"]
|
|
1981
|
+
compare_hits = []
|
|
1982
|
+
for kw in compare_kws:
|
|
1983
|
+
if kw in body_combined.lower():
|
|
1984
|
+
compare_hits.append(kw)
|
|
1985
|
+
|
|
1986
|
+
# Check for comparison tables
|
|
1987
|
+
# (approximation: check headings for comparison keywords)
|
|
1988
|
+
compare_in_headings = any(
|
|
1989
|
+
any(kw in h.lower() for kw in compare_kws)
|
|
1990
|
+
for info in all_infos
|
|
1991
|
+
for h in info.get("h2", []) + info.get("h3", [])
|
|
1992
|
+
)
|
|
1993
|
+
|
|
1994
|
+
signals["d4_comparison"] = {
|
|
1995
|
+
"keyword_hits": compare_hits,
|
|
1996
|
+
"in_headings": compare_in_headings,
|
|
1997
|
+
}
|
|
1998
|
+
if compare_hits:
|
|
1999
|
+
for kw in compare_hits:
|
|
2000
|
+
idx = body_combined.lower().find(kw)
|
|
2001
|
+
if idx >= 0:
|
|
2002
|
+
start = max(0, idx - 50)
|
|
2003
|
+
snippets["d4_comparison_content"] = body_combined[start:start + SNIPPET_MAX_LEN]
|
|
2004
|
+
break
|
|
2005
|
+
|
|
2006
|
+
# D4.6 Lists & Step-by-Step Guides
|
|
2007
|
+
total_ol = sum(info.get("ol_count", 0) for info in all_infos)
|
|
2008
|
+
total_ul = sum(info.get("ul_count", 0) for info in all_infos)
|
|
2009
|
+
total_li = sum(info.get("li_count", 0) for info in all_infos)
|
|
2010
|
+
avg_li = total_li / max(len(all_infos), 1)
|
|
2011
|
+
|
|
2012
|
+
signals["d4_lists"] = {
|
|
2013
|
+
"ol_count": total_ol,
|
|
2014
|
+
"ul_count": total_ul,
|
|
2015
|
+
"li_count": total_li,
|
|
2016
|
+
"avg_li_per_page": round(avg_li, 1),
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
# D4.7 AI Answer Page Assets
|
|
2020
|
+
all_links_text = " ".join(
|
|
2021
|
+
" ".join(info.get("internal_links", [])) for info in all_infos
|
|
2022
|
+
).lower()
|
|
2023
|
+
all_headings_text = " ".join(
|
|
2024
|
+
" ".join(info.get("h1", []) + info.get("h2", []) + info.get("h3", []))
|
|
2025
|
+
for info in all_infos
|
|
2026
|
+
).lower()
|
|
2027
|
+
search_text = all_links_text + " " + all_headings_text + " " + llms_text.lower()
|
|
2028
|
+
|
|
2029
|
+
found_assets = []
|
|
2030
|
+
for asset_type, keywords in ASSET_KEYWORDS.items():
|
|
2031
|
+
if any(kw in search_text for kw in keywords):
|
|
2032
|
+
found_assets.append(asset_type)
|
|
2033
|
+
|
|
2034
|
+
signals["d4_page_assets"] = {
|
|
2035
|
+
"found": found_assets,
|
|
2036
|
+
"count": len(found_assets),
|
|
2037
|
+
"total_types": len(ASSET_KEYWORDS),
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
# D4.8 Article Structured Data
|
|
2041
|
+
article_types = ["Article", "BlogPosting", "TechArticle", "NewsArticle"]
|
|
2042
|
+
has_article = any(t in schema_types_all for t in article_types)
|
|
2043
|
+
has_breadcrumb = "BreadcrumbList" in schema_types_all
|
|
2044
|
+
|
|
2045
|
+
article_fields = []
|
|
2046
|
+
for info in all_infos:
|
|
2047
|
+
for block in info.get("schema_blocks", []):
|
|
2048
|
+
if block.get("@type") in article_types:
|
|
2049
|
+
for f in ("author", "datePublished", "headline", "image"):
|
|
2050
|
+
if f in block:
|
|
2051
|
+
article_fields.append(f)
|
|
2052
|
+
break
|
|
2053
|
+
|
|
2054
|
+
signals["d4_article_schema"] = {
|
|
2055
|
+
"has_article_schema": has_article,
|
|
2056
|
+
"has_breadcrumb": has_breadcrumb,
|
|
2057
|
+
"article_fields": list(set(article_fields)),
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
return signals, snippets
|
|
2061
|
+
|
|
2062
|
+
|
|
2063
|
+
# ============================================================================
|
|
2064
|
+
# Signal Collection — D5: Recommendation
|
|
2065
|
+
# ============================================================================
|
|
2066
|
+
|
|
2067
|
+
# Common URL path patterns for content asset detection
|
|
2068
|
+
_BLOG_PATH_PATTERNS = ("/blog", "/articles", "/news", "/posts", "/insights", "/stories")
|
|
2069
|
+
_RESOURCE_PATH_PATTERNS = ("/resources", "/guides", "/docs", "/help", "/learn", "/library", "/hub")
|
|
2070
|
+
_PRICING_PATH_PATTERNS = ("/pricing", "/plans", "/subscribe", "/prices", "/buy")
|
|
2071
|
+
_PRODUCT_PATH_PATTERNS = ("/product", "/features", "/solutions", "/services")
|
|
2072
|
+
_CONVERSION_PATH_PATTERNS = ("/signup", "/register", "/demo", "/trial", "/download", "/booking", "/appointment", "/contact")
|
|
2073
|
+
_EXTERNAL_PLATFORM_DOMAINS = {
|
|
2074
|
+
"youtube.com": "YouTube", "youtu.be": "YouTube",
|
|
2075
|
+
"linkedin.com": "LinkedIn",
|
|
2076
|
+
"github.com": "GitHub",
|
|
2077
|
+
"reddit.com": "Reddit",
|
|
2078
|
+
"twitter.com": "Twitter", "x.com": "Twitter",
|
|
2079
|
+
"facebook.com": "Facebook",
|
|
2080
|
+
"instagram.com": "Instagram",
|
|
2081
|
+
"producthunt.com": "ProductHunt",
|
|
2082
|
+
"trustpilot.com": "Trustpilot",
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
|
|
2086
|
+
def _collect_d5(
|
|
2087
|
+
homepage_info: dict, sub_page_infos: list[dict],
|
|
2088
|
+
homepage_result: dict, sub_page_results: list[dict],
|
|
2089
|
+
) -> tuple[dict, dict]:
|
|
2090
|
+
"""Collect D5 Recommendation signals."""
|
|
2091
|
+
signals: dict[str, Any] = {}
|
|
2092
|
+
snippets: dict[str, str] = {}
|
|
2093
|
+
all_infos = [homepage_info] + sub_page_infos
|
|
2094
|
+
|
|
2095
|
+
# Gather all URLs (from internal links + page URLs)
|
|
2096
|
+
all_urls: list[str] = []
|
|
2097
|
+
for info in all_infos:
|
|
2098
|
+
all_urls.extend(info.get("internal_links", []))
|
|
2099
|
+
for result in sub_page_results:
|
|
2100
|
+
if result.get("ok"):
|
|
2101
|
+
all_urls.append(result.get("final_url", result.get("url", "")))
|
|
2102
|
+
all_urls.append(homepage_result.get("final_url", ""))
|
|
2103
|
+
|
|
2104
|
+
url_text = " ".join(all_urls).lower()
|
|
2105
|
+
|
|
2106
|
+
# Content assets detection
|
|
2107
|
+
blog_detected = any(p in url_text for p in _BLOG_PATH_PATTERNS)
|
|
2108
|
+
resource_hub_detected = any(p in url_text for p in _RESOURCE_PATH_PATTERNS)
|
|
2109
|
+
pricing_detected = any(p in url_text for p in _PRICING_PATH_PATTERNS)
|
|
2110
|
+
product_detected = any(p in url_text for p in _PRODUCT_PATH_PATTERNS)
|
|
2111
|
+
|
|
2112
|
+
# Conversion pages
|
|
2113
|
+
conversion_found: list[str] = []
|
|
2114
|
+
for pattern in _CONVERSION_PATH_PATTERNS:
|
|
2115
|
+
if pattern.lstrip("/") in url_text:
|
|
2116
|
+
conversion_found.append(pattern.lstrip("/"))
|
|
2117
|
+
|
|
2118
|
+
# External platform links
|
|
2119
|
+
external_platforms: dict[str, bool] = {}
|
|
2120
|
+
for info in all_infos:
|
|
2121
|
+
for link in info.get("external_links", []):
|
|
2122
|
+
for domain, name in _EXTERNAL_PLATFORM_DOMAINS.items():
|
|
2123
|
+
if domain in link.lower():
|
|
2124
|
+
external_platforms[name] = True
|
|
2125
|
+
|
|
2126
|
+
signals["d5_content_assets"] = {
|
|
2127
|
+
"blog_detected": blog_detected,
|
|
2128
|
+
"resource_hub_detected": resource_hub_detected,
|
|
2129
|
+
"pricing_detected": pricing_detected,
|
|
2130
|
+
"product_detected": product_detected,
|
|
2131
|
+
"conversion_pages": sorted(set(conversion_found)),
|
|
2132
|
+
"conversion_count": len(set(conversion_found)),
|
|
2133
|
+
"external_platforms": sorted(external_platforms.keys()),
|
|
2134
|
+
"external_platform_count": len(external_platforms),
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
# Homepage CTA detection
|
|
2138
|
+
homepage_text = homepage_info.get("body_text", "").lower()
|
|
2139
|
+
cta_keywords = ["sign up", "get started", "try free", "free trial", "book a demo",
|
|
2140
|
+
"contact us", "subscribe", "download", "start free"]
|
|
2141
|
+
cta_found = [kw for kw in cta_keywords if kw in homepage_text]
|
|
2142
|
+
signals["d5_cta"] = {
|
|
2143
|
+
"homepage_cta_keywords": cta_found,
|
|
2144
|
+
"has_clear_cta": len(cta_found) > 0,
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
# Snippet: list conversion URLs and external platforms
|
|
2148
|
+
if conversion_found:
|
|
2149
|
+
snippets["d5_conversion_pages"] = ", ".join(sorted(set(conversion_found)))
|
|
2150
|
+
if external_platforms:
|
|
2151
|
+
snippets["d5_external_platforms"] = ", ".join(sorted(external_platforms.keys()))
|
|
2152
|
+
|
|
2153
|
+
return signals, snippets
|
|
2154
|
+
|
|
2155
|
+
|
|
2156
|
+
# ============================================================================
|
|
2157
|
+
# Main Orchestrator
|
|
2158
|
+
# ============================================================================
|
|
2159
|
+
|
|
2160
|
+
def collect_signals(
|
|
2161
|
+
url: str,
|
|
2162
|
+
*,
|
|
2163
|
+
render: str = "auto",
|
|
2164
|
+
max_subpages: int = MAX_SUBPAGES,
|
|
2165
|
+
concurrency: int = DEFAULT_CONCURRENCY,
|
|
2166
|
+
) -> dict:
|
|
2167
|
+
"""Main entry point: fetch all data and collect signals for all dimensions."""
|
|
2168
|
+
started = time.perf_counter()
|
|
2169
|
+
timings: dict[str, float] = {}
|
|
2170
|
+
|
|
2171
|
+
def record_timing(name: str, stage_started: float) -> None:
|
|
2172
|
+
timings[name] = round(time.perf_counter() - stage_started, 3)
|
|
2173
|
+
|
|
2174
|
+
if not url.startswith(("http://", "https://")):
|
|
2175
|
+
url = "https://" + url
|
|
2176
|
+
|
|
2177
|
+
parsed = urlparse(url)
|
|
2178
|
+
domain = parsed.netloc
|
|
2179
|
+
errors: list[str] = []
|
|
2180
|
+
render_method = "static"
|
|
2181
|
+
concurrency = _bounded_workers(concurrency)
|
|
2182
|
+
max_subpages = max(0, int(max_subpages))
|
|
2183
|
+
|
|
2184
|
+
session = requests.Session()
|
|
2185
|
+
|
|
2186
|
+
# --- Fetch Phase ---
|
|
2187
|
+
logger.info("Fetching homepage: %s", url)
|
|
2188
|
+
stage_started = time.perf_counter()
|
|
2189
|
+
homepage_result = _get(session, url)
|
|
2190
|
+
record_timing("homepage_fetch_seconds", stage_started)
|
|
2191
|
+
if not homepage_result["ok"]:
|
|
2192
|
+
errors.append(f"Homepage fetch failed: {homepage_result['error']}")
|
|
2193
|
+
|
|
2194
|
+
# SPA detection & rendering
|
|
2195
|
+
stage_started = time.perf_counter()
|
|
2196
|
+
if homepage_result["ok"] and render != "never":
|
|
2197
|
+
if _is_spa_shell(homepage_result["text"]) or render == "always":
|
|
2198
|
+
rendered = _render_chrome(url)
|
|
2199
|
+
if rendered:
|
|
2200
|
+
homepage_result["text"] = rendered
|
|
2201
|
+
render_method = "chrome"
|
|
2202
|
+
logger.info("SPA detected, rendered with Chrome")
|
|
2203
|
+
else:
|
|
2204
|
+
render_method = "spa_unrendered"
|
|
2205
|
+
logger.warning("SPA detected but Chrome unavailable")
|
|
2206
|
+
record_timing("homepage_render_seconds", stage_started)
|
|
2207
|
+
|
|
2208
|
+
logger.info("Fetching robots.txt, llms.txt, sitemaps...")
|
|
2209
|
+
stage_started = time.perf_counter()
|
|
2210
|
+
robots_result = _get(session, urljoin(url, "/robots.txt"))
|
|
2211
|
+
record_timing("robots_fetch_seconds", stage_started)
|
|
2212
|
+
|
|
2213
|
+
stage_started = time.perf_counter()
|
|
2214
|
+
with ThreadPoolExecutor(max_workers=min(concurrency, 4)) as executor:
|
|
2215
|
+
future_llms = executor.submit(_timed_call, _get_with_new_session, urljoin(url, "/llms.txt"))
|
|
2216
|
+
future_llms_full = executor.submit(_timed_call, _get_with_new_session, urljoin(url, "/llms-full.txt"))
|
|
2217
|
+
future_notfound = executor.submit(
|
|
2218
|
+
_timed_call,
|
|
2219
|
+
_get_with_new_session,
|
|
2220
|
+
urljoin(url, "/__geo_audit_nonexistent_path_probe__"),
|
|
2221
|
+
)
|
|
2222
|
+
future_sitemap = executor.submit(
|
|
2223
|
+
_timed_call,
|
|
2224
|
+
_discover_sitemaps,
|
|
2225
|
+
session,
|
|
2226
|
+
url,
|
|
2227
|
+
robots_result,
|
|
2228
|
+
concurrency=concurrency,
|
|
2229
|
+
)
|
|
2230
|
+
llms_result, timings["llms_fetch_seconds"] = future_llms.result()
|
|
2231
|
+
llms_full_result, timings["llms_full_fetch_seconds"] = future_llms_full.result()
|
|
2232
|
+
notfound_result, timings["notfound_probe_seconds"] = future_notfound.result()
|
|
2233
|
+
sitemap_info, timings["sitemap_discovery_seconds"] = future_sitemap.result()
|
|
2234
|
+
record_timing("resource_discovery_seconds", stage_started)
|
|
2235
|
+
|
|
2236
|
+
# Multi-UA probes
|
|
2237
|
+
logger.info("Probing AI crawler access...")
|
|
2238
|
+
stage_started = time.perf_counter()
|
|
2239
|
+
ua_probes = _parallel_fetch(
|
|
2240
|
+
[(name, url, ua) for name, ua in CRAWLER_UAS.items()],
|
|
2241
|
+
concurrency=concurrency,
|
|
2242
|
+
)
|
|
2243
|
+
record_timing("crawler_probe_seconds", stage_started)
|
|
2244
|
+
|
|
2245
|
+
# Sub-pages from sitemap
|
|
2246
|
+
logger.info("Fetching sampled sub-pages...")
|
|
2247
|
+
stage_started = time.perf_counter()
|
|
2248
|
+
sub_page_results: list[dict] = []
|
|
2249
|
+
selection_started = time.perf_counter()
|
|
2250
|
+
sampling_base_url = homepage_result.get("final_url") or url
|
|
2251
|
+
sampled_page_entries, sample_source, homepage_candidate_urls = _select_subpage_sample_entries(
|
|
2252
|
+
sitemap_info,
|
|
2253
|
+
homepage_result.get("text", ""),
|
|
2254
|
+
sampling_base_url,
|
|
2255
|
+
max_subpages,
|
|
2256
|
+
)
|
|
2257
|
+
record_timing("subpage_selection_seconds", selection_started)
|
|
2258
|
+
sitemap_info["sample_source"] = sample_source
|
|
2259
|
+
sitemap_info["homepage_candidate_url_count"] = len(homepage_candidate_urls)
|
|
2260
|
+
sitemap_info["homepage_candidate_urls"] = homepage_candidate_urls[:100]
|
|
2261
|
+
sitemap_info["sampled_page_urls"] = [entry["url"] for entry in sampled_page_entries]
|
|
2262
|
+
sitemap_info["sampled_page_categories"] = [
|
|
2263
|
+
{
|
|
2264
|
+
"url": entry["url"],
|
|
2265
|
+
"category": entry["category"],
|
|
2266
|
+
"template_key": entry.get("template_key", entry["category"]),
|
|
2267
|
+
}
|
|
2268
|
+
for entry in sampled_page_entries
|
|
2269
|
+
]
|
|
2270
|
+
static_fetch_started = time.perf_counter()
|
|
2271
|
+
subpage_results_by_url = _parallel_fetch(
|
|
2272
|
+
[(entry["url"], entry["url"], DEFAULT_UA) for entry in sampled_page_entries],
|
|
2273
|
+
concurrency=concurrency,
|
|
2274
|
+
)
|
|
2275
|
+
record_timing("subpage_static_fetch_seconds", static_fetch_started)
|
|
2276
|
+
render_started = time.perf_counter()
|
|
2277
|
+
for sub_url in sitemap_info["sampled_page_urls"]:
|
|
2278
|
+
sub = subpage_results_by_url.get(sub_url, {
|
|
2279
|
+
"url": sub_url,
|
|
2280
|
+
"status": 0,
|
|
2281
|
+
"ok": False,
|
|
2282
|
+
"headers": {},
|
|
2283
|
+
"text": "",
|
|
2284
|
+
"final_url": sub_url,
|
|
2285
|
+
"error": "missing parallel fetch result",
|
|
2286
|
+
})
|
|
2287
|
+
if sub["ok"] and render != "never" and _is_spa_shell(sub["text"]):
|
|
2288
|
+
rendered = _render_chrome(sub_url)
|
|
2289
|
+
if rendered:
|
|
2290
|
+
sub["text"] = rendered
|
|
2291
|
+
sub_page_results.append(sub)
|
|
2292
|
+
record_timing("subpage_render_seconds", render_started)
|
|
2293
|
+
record_timing("subpage_fetch_seconds", stage_started)
|
|
2294
|
+
|
|
2295
|
+
# Knowledge graph probes
|
|
2296
|
+
logger.info("Probing Wikipedia/Wikidata...")
|
|
2297
|
+
stage_started = time.perf_counter()
|
|
2298
|
+
brand_query = _derive_brand_query(homepage_result["text"], domain)
|
|
2299
|
+
knowledge_results = _parallel_fetch(
|
|
2300
|
+
[
|
|
2301
|
+
(
|
|
2302
|
+
"wikipedia",
|
|
2303
|
+
f"https://en.wikipedia.org/w/api.php?action=query&list=search"
|
|
2304
|
+
f"&srsearch={_url_quote(brand_query)}&format=json&srlimit=3",
|
|
2305
|
+
DEFAULT_UA,
|
|
2306
|
+
),
|
|
2307
|
+
(
|
|
2308
|
+
"wikidata",
|
|
2309
|
+
f"https://www.wikidata.org/w/api.php?action=wbsearchentities"
|
|
2310
|
+
f"&search={_url_quote(brand_query)}&language=en&format=json&limit=3",
|
|
2311
|
+
DEFAULT_UA,
|
|
2312
|
+
),
|
|
2313
|
+
],
|
|
2314
|
+
concurrency=min(concurrency, 2),
|
|
2315
|
+
timeout=10,
|
|
2316
|
+
)
|
|
2317
|
+
wikipedia_result = knowledge_results["wikipedia"]
|
|
2318
|
+
wikidata_result = knowledge_results["wikidata"]
|
|
2319
|
+
record_timing("knowledge_graph_seconds", stage_started)
|
|
2320
|
+
|
|
2321
|
+
# --- Parse Phase ---
|
|
2322
|
+
logger.info("Parsing HTML...")
|
|
2323
|
+
stage_started = time.perf_counter()
|
|
2324
|
+
homepage_info = _parse_html(homepage_result["text"], url)
|
|
2325
|
+
sub_page_infos = [_parse_html(p["text"], p.get("url", "")) for p in sub_page_results if p["ok"]]
|
|
2326
|
+
record_timing("html_parse_seconds", stage_started)
|
|
2327
|
+
|
|
2328
|
+
# --- Signal Collection Phase ---
|
|
2329
|
+
logger.info("Collecting D1 signals...")
|
|
2330
|
+
stage_started = time.perf_counter()
|
|
2331
|
+
d1_signals, d1_snippets = _collect_d1(
|
|
2332
|
+
session, url, homepage_result, homepage_info,
|
|
2333
|
+
robots_result, sitemap_info,
|
|
2334
|
+
llms_result, llms_full_result, notfound_result,
|
|
2335
|
+
ua_probes, sub_page_results, render_method,
|
|
2336
|
+
)
|
|
2337
|
+
record_timing("d1_signal_collection_seconds", stage_started)
|
|
2338
|
+
|
|
2339
|
+
logger.info("Collecting D2 signals...")
|
|
2340
|
+
stage_started = time.perf_counter()
|
|
2341
|
+
d2_signals, d2_snippets = _collect_d2(homepage_info, sub_page_infos, homepage_result)
|
|
2342
|
+
record_timing("d2_signal_collection_seconds", stage_started)
|
|
2343
|
+
|
|
2344
|
+
logger.info("Collecting D3 signals...")
|
|
2345
|
+
stage_started = time.perf_counter()
|
|
2346
|
+
d3_signals, d3_snippets = _collect_d3(
|
|
2347
|
+
homepage_info, homepage_result, llms_result,
|
|
2348
|
+
sub_page_infos, wikipedia_result, wikidata_result, domain,
|
|
2349
|
+
)
|
|
2350
|
+
record_timing("d3_signal_collection_seconds", stage_started)
|
|
2351
|
+
|
|
2352
|
+
logger.info("Collecting D4 signals...")
|
|
2353
|
+
stage_started = time.perf_counter()
|
|
2354
|
+
d4_signals, d4_snippets = _collect_d4(homepage_info, sub_page_infos, llms_result, homepage_result)
|
|
2355
|
+
record_timing("d4_signal_collection_seconds", stage_started)
|
|
2356
|
+
|
|
2357
|
+
logger.info("Collecting D5 signals...")
|
|
2358
|
+
stage_started = time.perf_counter()
|
|
2359
|
+
d5_signals, d5_snippets = _collect_d5(
|
|
2360
|
+
homepage_info, sub_page_infos, homepage_result, sub_page_results,
|
|
2361
|
+
)
|
|
2362
|
+
record_timing("d5_signal_collection_seconds", stage_started)
|
|
2363
|
+
timings["total_collection_seconds"] = round(time.perf_counter() - started, 3)
|
|
2364
|
+
|
|
2365
|
+
# --- Assemble Output ---
|
|
2366
|
+
output = {
|
|
2367
|
+
"meta": {
|
|
2368
|
+
"url": url,
|
|
2369
|
+
"domain": domain,
|
|
2370
|
+
"brand_query": brand_query,
|
|
2371
|
+
"render_method": render_method,
|
|
2372
|
+
"fetched_at": datetime.now(timezone.utc).isoformat(),
|
|
2373
|
+
"sub_pages_fetched": len(sub_page_infos),
|
|
2374
|
+
"sub_pages_requested": max_subpages,
|
|
2375
|
+
"concurrency": concurrency,
|
|
2376
|
+
"report_generation_elapsed_seconds": timings["total_collection_seconds"],
|
|
2377
|
+
"timings": timings,
|
|
2378
|
+
"version": "1.6.0",
|
|
2379
|
+
},
|
|
2380
|
+
"signals": {**d1_signals, **d2_signals, **d3_signals, **d4_signals, **d5_signals},
|
|
2381
|
+
"snippets": {**d1_snippets, **d2_snippets, **d3_snippets, **d4_snippets, **d5_snippets},
|
|
2382
|
+
"errors": errors,
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
return output
|
|
2386
|
+
|
|
2387
|
+
|
|
2388
|
+
def _derive_brand_query(html: str, domain: str) -> str:
|
|
2389
|
+
"""Extract best-effort brand name from <title> or domain."""
|
|
2390
|
+
if html:
|
|
2391
|
+
m = re.search(r"<title[^>]*>([^<]+)</title>", html, re.IGNORECASE)
|
|
2392
|
+
if m:
|
|
2393
|
+
title = m.group(1).strip()
|
|
2394
|
+
for sep in ["|", " - ", " \u2013 ", "\u2014", " :: "]:
|
|
2395
|
+
if sep in title:
|
|
2396
|
+
title = title.split(sep)[0].strip()
|
|
2397
|
+
if 2 <= len(title) <= 60:
|
|
2398
|
+
return title
|
|
2399
|
+
root = domain.split(":")[0]
|
|
2400
|
+
root = root[4:] if root.startswith("www.") else root
|
|
2401
|
+
return root.split(".")[0]
|
|
2402
|
+
|
|
2403
|
+
|
|
2404
|
+
# ============================================================================
|
|
2405
|
+
# CLI
|
|
2406
|
+
# ============================================================================
|
|
2407
|
+
|
|
2408
|
+
def main():
|
|
2409
|
+
parser = argparse.ArgumentParser(description="GEO Audit Signal Collector")
|
|
2410
|
+
parser.add_argument("url", help="Target URL to audit")
|
|
2411
|
+
parser.add_argument("--output", "-o", help="Output file path (default: stdout)")
|
|
2412
|
+
parser.add_argument("--render", choices=["auto", "always", "never"],
|
|
2413
|
+
default="auto", help="SPA rendering mode")
|
|
2414
|
+
parser.add_argument("--max-subpages", type=int, default=MAX_SUBPAGES,
|
|
2415
|
+
help=f"Maximum sitemap sub-pages to sample (default: {MAX_SUBPAGES})")
|
|
2416
|
+
parser.add_argument("--concurrency", type=int, default=DEFAULT_CONCURRENCY,
|
|
2417
|
+
help=f"Concurrent fetch workers, capped at 12 (default: {DEFAULT_CONCURRENCY})")
|
|
2418
|
+
args = parser.parse_args()
|
|
2419
|
+
|
|
2420
|
+
result = collect_signals(
|
|
2421
|
+
args.url,
|
|
2422
|
+
render=args.render,
|
|
2423
|
+
max_subpages=args.max_subpages,
|
|
2424
|
+
concurrency=args.concurrency,
|
|
2425
|
+
)
|
|
2426
|
+
|
|
2427
|
+
output_json = json.dumps(result, ensure_ascii=False, indent=2)
|
|
2428
|
+
|
|
2429
|
+
if args.output:
|
|
2430
|
+
with open(args.output, "w", encoding="utf-8") as f:
|
|
2431
|
+
f.write(output_json)
|
|
2432
|
+
logger.info("Output written to %s", args.output)
|
|
2433
|
+
else:
|
|
2434
|
+
print(output_json)
|
|
2435
|
+
|
|
2436
|
+
|
|
2437
|
+
if __name__ == "__main__":
|
|
2438
|
+
try:
|
|
2439
|
+
import _geo_notice
|
|
2440
|
+
_geo_notice.emit()
|
|
2441
|
+
except Exception:
|
|
2442
|
+
pass
|
|
2443
|
+
main()
|