@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.
Files changed (115) hide show
  1. package/README.md +93 -0
  2. package/bin/adgine-geo.js +224 -0
  3. package/package.json +38 -0
  4. package/skills/.env.example +11 -0
  5. package/skills/README.md +247 -0
  6. package/skills/SKILL.md +117 -0
  7. package/skills/VERSION +1 -0
  8. package/skills/adgine-geo-aiagent/SKILL.md +200 -0
  9. package/skills/adgine-geo-aiagent/scripts/_client.py +341 -0
  10. package/skills/adgine-geo-aiagent/scripts/bot_traffic.py +278 -0
  11. package/skills/adgine-geo-aiagent/scripts/human_traffic.py +357 -0
  12. package/skills/adgine-geo-aiagent/scripts/page_analytics.py +317 -0
  13. package/skills/adgine-geo-aiagent/scripts/page_detail.py +273 -0
  14. package/skills/adgine-geo-analytics/API.md +89 -0
  15. package/skills/adgine-geo-analytics/SKILL.md +191 -0
  16. package/skills/adgine-geo-analytics/scripts/_client.py +341 -0
  17. package/skills/adgine-geo-analytics/scripts/get_overview.py +129 -0
  18. package/skills/adgine-geo-billing/SKILL.md +118 -0
  19. package/skills/adgine-geo-billing/scripts/_client.py +341 -0
  20. package/skills/adgine-geo-billing/scripts/get_credits.py +51 -0
  21. package/skills/adgine-geo-billing/scripts/get_credits_pricing.py +52 -0
  22. package/skills/adgine-geo-billing/scripts/get_subscription.py +48 -0
  23. package/skills/adgine-geo-billing/scripts/list_plans.py +66 -0
  24. package/skills/adgine-geo-brand/SKILL.md +193 -0
  25. package/skills/adgine-geo-brand/WORKFLOW.md +66 -0
  26. package/skills/adgine-geo-brand/scripts/_client.py +341 -0
  27. package/skills/adgine-geo-brand/scripts/generate_brand.py +95 -0
  28. package/skills/adgine-geo-brand/scripts/get_brand.py +67 -0
  29. package/skills/adgine-geo-brand/scripts/list_jobs.py +153 -0
  30. package/skills/adgine-geo-brand/scripts/update_brand.py +61 -0
  31. package/skills/adgine-geo-citation/SKILL.md +220 -0
  32. package/skills/adgine-geo-citation/scripts/_client.py +341 -0
  33. package/skills/adgine-geo-citation/scripts/create_tests.py +62 -0
  34. package/skills/adgine-geo-citation/scripts/get_aggregate.py +108 -0
  35. package/skills/adgine-geo-citation/scripts/get_results.py +179 -0
  36. package/skills/adgine-geo-content/SKILL.md +224 -0
  37. package/skills/adgine-geo-content/WORKFLOW.md +100 -0
  38. package/skills/adgine-geo-content/scripts/_client.py +341 -0
  39. package/skills/adgine-geo-content/scripts/generate_article.py +108 -0
  40. package/skills/adgine-geo-content/scripts/generate_outline.py +113 -0
  41. package/skills/adgine-geo-content/scripts/generate_titles.py +51 -0
  42. package/skills/adgine-geo-content/scripts/list_content.py +65 -0
  43. package/skills/adgine-geo-content/scripts/manage_content.py +90 -0
  44. package/skills/adgine-geo-content/scripts/manage_jobs.py +184 -0
  45. package/skills/adgine-geo-dashboard/SKILL.md +181 -0
  46. package/skills/adgine-geo-dashboard/scripts/_client.py +341 -0
  47. package/skills/adgine-geo-dashboard/scripts/check_integrations.py +139 -0
  48. package/skills/adgine-geo-dashboard/scripts/get_overview.py +155 -0
  49. package/skills/adgine-geo-dashboard/scripts/get_visibility_overview.py +93 -0
  50. package/skills/adgine-geo-docs/SKILL.md +60 -0
  51. package/skills/adgine-geo-domains/SKILL.md +223 -0
  52. package/skills/adgine-geo-domains/scripts/_client.py +341 -0
  53. package/skills/adgine-geo-domains/scripts/get_domain.py +75 -0
  54. package/skills/adgine-geo-domains/scripts/list_domains.py +66 -0
  55. package/skills/adgine-geo-domains/scripts/search_domains.py +85 -0
  56. package/skills/adgine-geo-integrations/SKILL.md +188 -0
  57. package/skills/adgine-geo-integrations/scripts/_client.py +341 -0
  58. package/skills/adgine-geo-integrations/scripts/cloudflare_connect.py +155 -0
  59. package/skills/adgine-geo-integrations/scripts/cloudflare_worker.py +212 -0
  60. package/skills/adgine-geo-integrations/scripts/ga4_data.py +192 -0
  61. package/skills/adgine-geo-integrations/scripts/ga4_setup.py +120 -0
  62. package/skills/adgine-geo-opportunities/SKILL.md +162 -0
  63. package/skills/adgine-geo-opportunities/scripts/_client.py +341 -0
  64. package/skills/adgine-geo-opportunities/scripts/get_opportunity.py +90 -0
  65. package/skills/adgine-geo-opportunities/scripts/list_opportunities.py +92 -0
  66. package/skills/adgine-geo-performance/SKILL.md +177 -0
  67. package/skills/adgine-geo-performance/scripts/_client.py +341 -0
  68. package/skills/adgine-geo-performance/scripts/analyze_page.py +100 -0
  69. package/skills/adgine-geo-projects/SKILL.md +279 -0
  70. package/skills/adgine-geo-projects/scripts/_client.py +341 -0
  71. package/skills/adgine-geo-projects/scripts/check_auth.py +45 -0
  72. package/skills/adgine-geo-projects/scripts/list_projects.py +47 -0
  73. package/skills/adgine-geo-projects/scripts/manage_competitors.py +122 -0
  74. package/skills/adgine-geo-projects/scripts/manage_project.py +88 -0
  75. package/skills/adgine-geo-saas/SKILL.md +144 -0
  76. package/skills/adgine-geo-saas/scripts/_client.py +341 -0
  77. package/skills/adgine-geo-saas/scripts/check_domain.py +43 -0
  78. package/skills/adgine-geo-saas/scripts/create_website.py +56 -0
  79. package/skills/adgine-geo-saas/scripts/get_task.py +94 -0
  80. package/skills/adgine-geo-site-audit/README.md +281 -0
  81. package/skills/adgine-geo-site-audit/SKILL.md +449 -0
  82. package/skills/adgine-geo-site-audit/requirements.txt +5 -0
  83. package/skills/adgine-geo-site-audit/scripts/_client.py +341 -0
  84. package/skills/adgine-geo-site-audit/scripts/_geo_notice.py +55 -0
  85. package/skills/adgine-geo-site-audit/scripts/export_skill_package.py +197 -0
  86. package/skills/adgine-geo-site-audit/scripts/geo_collect.py +2443 -0
  87. package/skills/adgine-geo-site-audit/scripts/geo_score.py +490 -0
  88. package/skills/adgine-geo-site-audit/scripts/geo_timing.py +399 -0
  89. package/skills/adgine-geo-site-audit/scripts/geo_visibility.py +718 -0
  90. package/skills/adgine-geo-site-audit/scripts/render_report_pdf.py +1502 -0
  91. package/skills/adgine-geo-topics/SKILL.md +232 -0
  92. package/skills/adgine-geo-topics/scripts/_client.py +341 -0
  93. package/skills/adgine-geo-topics/scripts/generate_prompts.py +140 -0
  94. package/skills/adgine-geo-topics/scripts/manage_prompts.py +153 -0
  95. package/skills/adgine-geo-topics/scripts/manage_topics.py +145 -0
  96. package/skills/adgine-geo-visibility/SKILL.md +168 -0
  97. package/skills/adgine-geo-visibility/scripts/_client.py +341 -0
  98. package/skills/adgine-geo-visibility/scripts/get_execution.py +151 -0
  99. package/skills/adgine-geo-visibility/scripts/get_matrix.py +111 -0
  100. package/skills/adgine-geo-visibility/scripts/get_prompt_metrics.py +104 -0
  101. package/skills/adgine-geo-visibility/scripts/get_topic_metrics.py +201 -0
  102. package/skills/adgine-geo-visibility/scripts/get_visibility.py +166 -0
  103. package/skills/adgine-geo-wordpress/SKILL.md +151 -0
  104. package/skills/adgine-geo-wordpress/scripts/_client.py +341 -0
  105. package/skills/adgine-geo-wordpress/scripts/list_publishable.py +62 -0
  106. package/skills/adgine-geo-wordpress/scripts/manage_credentials.py +139 -0
  107. package/skills/adgine-geo-wordpress/scripts/manage_publishes.py +108 -0
  108. package/skills/adgine-geo-wordpress/scripts/publish.py +79 -0
  109. package/skills/scripts/check_version.py +290 -0
  110. package/skills/setup.py +248 -0
  111. package/src/config.js +126 -0
  112. package/src/doctor.js +137 -0
  113. package/src/install.js +90 -0
  114. package/src/manifest.js +99 -0
  115. 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()