@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,490 @@
1
+ #!/usr/bin/env python3
2
+ """Score GEO audit assessments using the compressed v3 evaluation table.
3
+
4
+ The collector gathers evidence. The auditor or agent assigns item statuses and
5
+ evidence notes. This module performs deterministic score math against the
6
+ 30-item "five dimension compressed strict v3" table.
7
+
8
+ Formula:
9
+ dimension_score = floor(sum(item_points * status_coefficient) / effective_points * 100)
10
+ final_score = floor(sum(capped_dimension_score * dimension_weight))
11
+
12
+ Statuses:
13
+ PASS = 1.0, WARN = item-specific coefficient, FAIL = 0.0,
14
+ ERROR/N/A are excluded from the effective denominator.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import math
21
+ import time
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+ from typing import Any, Iterable
25
+
26
+
27
+ SCORE_TABLE_VERSION = "five-dimension-compressed-strict-v3"
28
+ EXPECTED_ITEM_COUNT = 30
29
+
30
+ STATUS_BADGES: dict[str, str] = {
31
+ "PASS": "✅ PASS",
32
+ "WARN": "⚠️ WARN",
33
+ "FAIL": "❌ FAIL",
34
+ "ERROR": "🔴 ERROR",
35
+ "N/A": "➖ N/A",
36
+ }
37
+
38
+ STATUS_COEFFICIENTS: dict[str, float | None] = {
39
+ "PASS": 1.0,
40
+ "FAIL": 0.0,
41
+ "ERROR": None,
42
+ "N/A": None,
43
+ }
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class ScoreItem:
48
+ id: str
49
+ name: str
50
+ points: int
51
+ warn_coefficient: float
52
+ score_rule: str
53
+ focus: str
54
+ detail: str
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class ScoreDimension:
59
+ id: str
60
+ label: str
61
+ short_label: str
62
+ weight: float
63
+ items: tuple[ScoreItem, ...]
64
+
65
+
66
+ def item(
67
+ item_id: str,
68
+ name: str,
69
+ points: int,
70
+ warn_percent: int,
71
+ score_rule: str,
72
+ focus: str,
73
+ detail: str,
74
+ ) -> ScoreItem:
75
+ return ScoreItem(
76
+ id=item_id,
77
+ name=name,
78
+ points=points,
79
+ warn_coefficient=warn_percent / 100,
80
+ score_rule=score_rule,
81
+ focus=focus,
82
+ detail=detail,
83
+ )
84
+
85
+
86
+ DIMENSIONS: tuple[ScoreDimension, ...] = (
87
+ ScoreDimension(
88
+ "discoverability",
89
+ "维度一:AI 可发现",
90
+ "AI 可发现",
91
+ 0.25,
92
+ (
93
+ item("1.1", "入口规范与可达性", 19, 50, "通过=100%;待优化=50%;不通过=0。若首页不可达、跳转链异常、最终 URL 错误或返回非真实 HTML,通常不高于待优化。", "域名规范化与入口统一、首页可达性、状态码与最终 URL 正确性。", "检查是否优先使用 HTTPS,是否统一 www / 非 www、尾斜杠、参数与 fragment 等 URL 变体;同时确认首页可正常返回真实页面内容,各核心入口页面的 status code、redirect、final URL、content-type 均正常。"),
94
+ item("1.2", "抓取入口文件健康", 19, 50, "通过=100%;待优化=50%;不通过=0。robots / sitemap / llms 任一关键入口缺失或异常时通常记待优化;多项异常或关键入口长期缺失记不通过。", "robots.txt、sitemap、llms.txt / llms-full.txt。", "检查 /robots.txt、/sitemap.xml、/sitemap_index.xml、/llms.txt、/llms-full.txt 是否存在、可访问、格式正确且引用关系正常,并确认这些入口文件没有错误配置或异常限制。"),
95
+ item("1.3", "核心页面发现覆盖", 12, 50, "通过=100%;待优化=50%;不通过=0。高价值页面覆盖不足、发现链路断裂或 sitemap/nav 仅覆盖少量品牌页时,通常记待优化。", "核心页面收录入口覆盖、Crawl set 覆盖质量、站内发现链路。", "检查 sitemap、首页导航、页脚和正文内链中,是否覆盖 product、service、pricing、blog、docs、resources、about、contact、legal 等核心页面,并确认 crawler 能顺利发现高价值页面。"),
96
+ item("1.4", "AI crawler 实际可访问性", 24, 20, "通过=100%;待优化=20%;不通过=0。若 AI crawler 被拦截、返回 challenge page、429/403、仅能拿到非正文模板或主内容不可抓取,通常直接记不通过;并触发跨维度与总分封顶。", "AI 爬虫 UA 可访问性、WAF / CDN / 安全验证拦截、渲染可抓取性。", "检查 Googlebot、Bingbot、GPTBot、ClaudeBot、PerplexityBot 等访问首页、robots、sitemap 及核心页面时,是否能拿到真实 HTML 与正文内容;确认不存在 403/429/challenge page、安全验证、纯前端渲染或 hydration 异常。"),
97
+ item("1.5", "索引控制与可收录状态", 8, 20, "通过=100%;待优化=20%;不通过=0。核心页出现 noindex、canonical 冲突、x-robots-tag 异常或误去收录时,通常不高于待优化;严重时记不通过并触发封顶。", "Indexability 基础状态。", "检查 robots meta、x-robots-tag、noindex、nofollow、canonical 等索引控制信号是否正确,确保核心页面没有被误排除出抓取或索引,也不存在互相冲突的收录指令。"),
98
+ item("1.6", "错误页与模板回退风险", 15, 20, "通过=100%;待优化=20%;不通过=0。存在软404、homepage fallback、错误模板 200 返回或 404 模板索引冲突时,通常不高于待优化;成片出现时记不通过并触发跨维度封顶。", "软 404 与错误页识别、Homepage fallback / 模板回退、404 模板索引冲突。", "检查是否存在返回 200 但实际为错误页、title/H1/canonical 回退到首页或频道页、真实 404/410 页面错误输出 index,follow 等情况,避免 crawler 抓到错误模板内容。"),
99
+ item("1.7", "Sitemap 质量与污染", 3, 50, "通过=100%;待优化=50%;不通过=0。没有可用 sitemap 时直接记不通过;存在失效 URL、错误语言版本、组件页或批量污染时记待优化;污染严重时记不通过。", "Sitemap 污染与失效 URL。", "检查 sitemap 是否存在且可用,并检查其中是否混入失效 URL、测试页、组件页、错误页或不存在的语言版本,避免浪费抓取预算、误导 crawler,并降低 AI 对站点内容结构的判断准确性。"),
100
+ ),
101
+ ),
102
+ ScoreDimension(
103
+ "understandability",
104
+ "维度二:AI 可理解",
105
+ "AI 可理解",
106
+ 0.20,
107
+ (
108
+ item("2.1", "页面标题与结构语义", 21, 50, "通过=100%;待优化=50%;不通过=0。标题/H1/层级不清但仍能基本表达主题时记待优化;大面积缺失或严重错位记不通过。", "页面标题语义清晰度、Meta description 语义清晰度、H1 唯一性与相关性、H2-H6 层级结构。", "检查 title、meta description、H1 与 H2-H6 层级是否准确表达页面主题、对象和核心价值,是否具备清晰的结构化语义。"),
109
+ item("2.2", "业务定位与价值主张清晰度", 12, 50, "通过=100%;待优化=50%;不通过=0。业务描述模糊、类型难识别、价值主张与页面对象错位时记待优化。", "业务描述与价值主张、业务类型识别。", "检查首页与核心页面是否明确说明品牌做什么、为谁服务、提供什么价值,以及站点是否能被清晰识别为 SaaS、本地商家、电商、Publisher、Agency 或 Web App 等业务类型。"),
110
+ item("2.3", "站点架构与内容组织表达", 16, 50, "通过=100%;待优化=50%;不通过=0。栏目存在但组织弱、主题页只是自动归档、结构无法承担导览职能时记待优化。", "站点信息架构完整性、主题页与分类页表达、内容聚类结构可辨识性。", "检查导航、页脚和页面集合是否清楚呈现 product、service、pricing、blog、docs、resources 等结构,并确认 category、resource hub、docs hub 等页面是真正承担主题组织与导览作用。"),
111
+ item("2.4", "正文可总结性与页面差异化", 10, 50, "通过=100%;待优化=50%;不通过=0。可读但摘要性弱、同类页差异不足时记待优化;大量模板化时可记不通过。", "页面主内容摘要能力、页面差异化表达。", "检查页面正文是否能形成清晰的 visible text summary;同时确认同类页面在标题、首段、H2 结构、内容骨架与主题重点上有足够差异。"),
112
+ item("2.5", "多语言表达一致性", 7, 50, "通过=100%;待优化=50%;不通过=0。语言版本结构、hreflang 或 schema 轻中度不一致时记待优化;严重错配记不通过。", "多语言关系表达、多语言 Schema 一致性。", "检查 hreflang、语言版本路径、语言切换关系以及不同语言页面的 schema 输出是否一致,避免 AI 无法理解不同语言页面的对应关系。"),
113
+ item("2.6", "Schema 覆盖与实现质量", 30, 40, "通过=100%;待优化=40%;不通过=0。Schema 部分缺失/不完整/与可见内容不一致时通常不高于待优化;关键类型缺失、明显错误或误标类型时记不通过。", "Schema 类型覆盖、Schema 与可见内容一致性、Schema 完整性与字段质量、BlogPosting / Article 选择合理性、富结果错误风险。", "检查站点是否为不同页面使用适配的 schema 类型,JSON-LD / microdata 字段是否完整且与页面可见内容一致,文章类页面是否正确选择 BlogPosting 或 Article,并避免误标类型。"),
114
+ item("2.7", "图片与社交元数据可理解性", 4, 60, "通过=100%;待优化=60%;不通过=0。属于增强项,alt/OG/Twitter 信息不完整通常记待优化。", "图片与社交元数据可读性。", "检查图片 alt、Open Graph、Twitter/X card 等是否完整且语义清晰,帮助 AI 和平台更准确理解页面主题、核心内容和分享预览信息。"),
115
+ ),
116
+ ),
117
+ ScoreDimension(
118
+ "citability",
119
+ "维度三:AI 可引用",
120
+ "AI 可引用",
121
+ 0.20,
122
+ (
123
+ item("3.1", "可直接摘录与回答能力", 27, 40, "通过=100%;待优化=40%;不通过=0。若缺少直接可摘录表达、关键问题回答或 FAQ/总结结构,通常不高于待优化;几乎无法直接提炼答案时记不通过。", "直接可摘录表达、关键问题回答能力、FAQ 与总结内容。", "检查页面是否存在直接、明确、可独立引用的句子和段落,是否能用简洁内容回答用户核心问题,并具备 FAQ、总结、key takeaways、结论段等结构。"),
124
+ item("3.2", "引用型内容形态覆盖", 22, 50, "通过=100%;待优化=50%;不通过=0。definition/comparison/guide/how-to/case 等类型不全时记待优化。", "定义型内容、比较型内容、内容资产类型覆盖。", "检查站点是否具备 definition、comparison、guide、how-to、glossary、benchmark、report、case study、docs 等适合 AI 在解释、比较、推荐和操作型回答中调用的内容形态。"),
125
+ item("3.3", "证据与事实支撑强度", 28, 40, "通过=100%;待优化=40%;不通过=0。缺少数据、案例、方法论、作者/时间信号时通常不高于待优化;事实支撑极弱时记不通过。", "统计/数据/事实信息、案例与第一手经验、方法论与出处、作者与时间信号。", "检查页面是否提供可验证的数据、量化结论、案例、实操经验、benchmark、来源说明、methodology、作者信息以及发布时间/更新时间等信号。"),
126
+ item("3.4", "正文信息密度与可用性", 7, 50, "通过=100%;待优化=50%;不通过=0。内容偏薄但仍有基本信息时记待优化;大面积 thin content 时记不通过。", "正文信息密度。", "检查正文内容的字数、信息量、段落质量与主题聚焦度,避免页面过薄、描述空泛、信息密度不足或虽有内容但难以摘录成有效答案。"),
127
+ item("3.5", "模板化、重复与语言深度风险", 16, 50, "通过=100%;待优化=50%;不通过=0。存在模板化、重复或语言深度不对称但未严重影响引用判断时记待优化。", "薄弱/模板化内容风险、同主题页重复风险、多语言内容深度差异。", "检查是否存在 vague claims、模板改写、duplicate content、thin variant 或不同语言版本深度明显不对称等问题,避免 AI 在判断引用对象时出现混淆。"),
128
+ ),
129
+ ),
130
+ ScoreDimension(
131
+ "trustworthiness",
132
+ "维度四:AI 可信任",
133
+ "AI 可信任",
134
+ 0.20,
135
+ (
136
+ item("4.1", "品牌主体与联系可验证性", 25, 30, "通过=100%;待优化=30%;不通过=0。品牌实体不清、About/Contact 缺失或不可验证时通常不高于待优化;严重时触发 AI可信任更严封顶。", "品牌实体清晰度、About 页面完整性、Contact 与支持信息。", "检查品牌名称、产品名称、组织身份与业务定位是否清楚一致,About 页面是否完整介绍品牌背景、团队和业务,是否存在可验证联系方式、支持入口和客服方式。"),
137
+ item("4.2", "作者、团队与资质信号", 24, 50, "通过=100%;待优化=50%;不通过=0。团队/作者/资质信号偏弱时记待优化。", "Team / Author / Credential 信号、作者性与出处可信度、监管/资质/牌照信号。", "检查是否展示团队成员、作者简介、专业资历、出处说明、责任边界,以及金融、教育、医疗等行业是否具备资质牌照、监管记录或官方登记等高可信信号。"),
138
+ item("4.3", "合规与安全透明度", 17, 30, "通过=100%;待优化=30%;不通过=0。缺少 legal/policy/trust/security 关键页面时通常不高于待优化;严重时触发 AI可信任更严封顶。", "Legal / Policy / Compliance 页面、Trust 页面与安全信息。", "检查是否具备 privacy、terms、compliance、risk disclosure、security、trust center、responsible use、support 等页面,帮助 AI 判断品牌在合规、安全、责任边界与服务支持上是否透明。"),
139
+ item("4.4", "社会证明与外部验证", 23, 50, "通过=100%;待优化=50%;不通过=0。第三方平台、案例、媒体提及不足时记待优化。", "案例与客户证明、第三方平台实体信号、外部评价与媒体提及。", "检查是否存在 measurable case study、客户证明、外部媒体报道、行业引用,以及 LinkedIn、GitHub、YouTube、Wikipedia/Wikidata、应用商店、目录站等第三方平台上的官方存在。"),
140
+ item("4.5", "信任可见性风险", 11, 60, "通过=100%;待优化=60%;不通过=0。登录墙/付费墙或弱信任页存在但仍有部分可验证内容时记待优化。", "弱信任页面风险、内容需登录后才可见。", "检查 About、Team、Support、Policy 等信任页是否过薄、缺失或内容空泛,并确认高价值内容是否被登录墙或付费墙完全阻断且没有可抓取预览。"),
141
+ ),
142
+ ),
143
+ ScoreDimension(
144
+ "recommendability",
145
+ "维度五:AI 可推荐",
146
+ "AI 可推荐",
147
+ 0.15,
148
+ (
149
+ item("5.1", "平台适配与搜索意图覆盖", 18, 30, "通过=100%;待优化=30%;不通过=0。AI 平台适配内容和搜索意图覆盖明显不足时通常不高于待优化;并触发 AI可推荐更严封顶。", "AI 平台适配内容覆盖、Search intent 覆盖。", "检查站点是否具备适配 Google AI Overviews、ChatGPT、Perplexity、Gemini、Claude、Copilot 等平台的内容资产与页面类型,并确认内容覆盖定义、比较、决策、操作、问题解决等常见意图。"),
150
+ item("5.2", "主题权威与内容集群建设", 17, 30, "通过=100%;待优化=30%;不通过=0。主题集群弱、hub 不成体系、核心主题缺少可持续内容集群时通常不高于待优化;并触发 AI可推荐更严封顶。", "Topical authority 建设、资源中心 / Hub 建设。", "检查站点是否围绕核心主题形成 guides、comparisons、glossary、reports、case studies、docs 等内容集群,以及是否存在真正承担聚合、筛选、导览作用的 resource hub / docs hub。"),
151
+ item("5.3", "首页导流与站内继续访问路径", 15, 50, "通过=100%;待优化=50%;不通过=0。首页导流弱或被推荐页后续路径不清时记待优化。", "首页到高价值内容的导流、站内继续访问路径。", "检查首页与导航是否能把用户和 AI 明确导向高价值内容资产,并确认被推荐页面内部是否存在相关推荐、内链、导航和 CTA。"),
152
+ item("5.4", "推荐支撑内容资产", 22, 50, "通过=100%;待优化=50%;不通过=0。compare/FAQ/how-to/case/report/docs 等资产不够完整时记待优化。", "比较与决策页能力、案例、报告、方法论资产、Docs / Help / 操作型内容。", "检查站点是否具备 compare pages、best-of 页、替代方案页、case study、benchmark、report、methodology、docs、FAQ、how-to、教程与帮助中心等资产。"),
153
+ item("5.5", "转化承接页完备度", 15, 30, "通过=100%;待优化=30%;不通过=0。高价值转化页缺失、承接链路弱或推荐落点不足时通常不高于待优化;并触发 AI可推荐更严封顶。", "产品/服务转化页完备度、应用下载与行动入口。", "检查是否具备 pricing、product、service、feature、integration、signup、demo、trial、下载入口、App Store / Google Play 等页面,确保 AI 推荐后用户能顺利进入下一步。"),
154
+ item("5.6", "外部推荐辅助信号与阻碍因素", 13, 50, "通过=100%;待优化=50%;不通过=0。外部平台存在不足或阻碍因素明显时记待优化;若平台侧核心阻碍很强,可记不通过。", "外部平台推荐辅助信号、推荐意愿削弱因素。", "检查 YouTube、Reddit、LinkedIn、GitHub、Product Hunt、review sites、目录站等外部平台是否能为推荐提供辅助信号,同时识别首页与内容资产断裂、问题解决型页面不足、平台存在感弱、模板内容过重等问题。"),
155
+ ),
156
+ ),
157
+ )
158
+
159
+
160
+ def _floor_score(value: float) -> int:
161
+ return int(math.floor(value + 1e-9))
162
+
163
+
164
+ def _elapsed_since(started: float) -> float:
165
+ return round(time.perf_counter() - started, 3)
166
+
167
+
168
+ def normalize_status(value: str | None) -> str:
169
+ if value is None:
170
+ return "ERROR"
171
+ normalized = str(value).strip().upper()
172
+ aliases = {
173
+ "NA": "N/A",
174
+ "NOT_APPLICABLE": "N/A",
175
+ "NOT APPLICABLE": "N/A",
176
+ "不适用": "N/A",
177
+ "PARTIAL": "WARN",
178
+ "NEEDS_OPTIMIZATION": "WARN",
179
+ "NEEDS OPTIMIZATION": "WARN",
180
+ "待优化": "WARN",
181
+ "通过": "PASS",
182
+ "不通过": "FAIL",
183
+ "失败": "FAIL",
184
+ "检测失败": "ERROR",
185
+ }
186
+ normalized = aliases.get(normalized, normalized)
187
+ if normalized in {"PASS", "WARN", "FAIL", "ERROR", "N/A"}:
188
+ return normalized
189
+ return "ERROR"
190
+
191
+
192
+ def status_coefficient(status: str, score_item: ScoreItem) -> float | None:
193
+ if status == "WARN":
194
+ return score_item.warn_coefficient
195
+ return STATUS_COEFFICIENTS.get(status)
196
+
197
+
198
+ def iter_items() -> Iterable[ScoreItem]:
199
+ for dimension in DIMENSIONS:
200
+ yield from dimension.items
201
+
202
+
203
+ def item_map() -> dict[str, ScoreItem]:
204
+ return {score_item.id: score_item for score_item in iter_items()}
205
+
206
+
207
+ def validate_config() -> list[str]:
208
+ errors: list[str] = []
209
+ ids: set[str] = set()
210
+ for dimension in DIMENSIONS:
211
+ total = sum(score_item.points for score_item in dimension.items)
212
+ if total != 100:
213
+ errors.append(f"{dimension.label} 检测项分值合计为 {total},应为 100")
214
+ for score_item in dimension.items:
215
+ if score_item.id in ids:
216
+ errors.append(f"重复检测项 ID: {score_item.id}")
217
+ if not 0 <= score_item.warn_coefficient <= 1:
218
+ errors.append(f"{score_item.id} 待优化系数非法: {score_item.warn_coefficient}")
219
+ ids.add(score_item.id)
220
+ weight_total = sum(dimension.weight for dimension in DIMENSIONS)
221
+ if abs(weight_total - 1.0) > 0.0001:
222
+ errors.append(f"大项权重合计为 {weight_total},应为 1.0")
223
+ if len(ids) != EXPECTED_ITEM_COUNT:
224
+ errors.append(f"检测项数量为 {len(ids)},应为 {EXPECTED_ITEM_COUNT}")
225
+ return errors
226
+
227
+
228
+ def _coerce_assessments(payload: dict[str, Any]) -> dict[str, dict[str, Any]]:
229
+ raw = payload.get("items") or payload.get("assessments") or payload
230
+ if isinstance(raw, list):
231
+ result: dict[str, dict[str, Any]] = {}
232
+ for entry in raw:
233
+ if isinstance(entry, dict) and entry.get("id"):
234
+ result[str(entry["id"])] = entry
235
+ return result
236
+ if isinstance(raw, dict):
237
+ result = {}
238
+ for key, value in raw.items():
239
+ if isinstance(value, str):
240
+ result[str(key)] = {"status": value}
241
+ elif isinstance(value, dict):
242
+ result[str(key)] = value
243
+ return result
244
+ return {}
245
+
246
+
247
+ def _cap_payload(scope: str, dimension_id: str | None, cap: int, failed: list[str], reason: str) -> dict[str, Any]:
248
+ return {
249
+ "scope": scope,
250
+ "dimension_id": dimension_id,
251
+ "cap": cap,
252
+ "failed_item_ids": failed,
253
+ "reason": reason,
254
+ }
255
+
256
+
257
+ def _apply_cap_if_needed(score: int, cap: int, payload: dict[str, Any], caps: list[dict[str, Any]]) -> int:
258
+ if score > cap:
259
+ caps.append(payload)
260
+ return cap
261
+ return score
262
+
263
+
264
+ def _apply_dimension_caps(dimension_id: str, statuses: dict[str, str], score: int) -> tuple[int, list[dict[str, Any]]]:
265
+ caps: list[dict[str, Any]] = []
266
+
267
+ if dimension_id == "discoverability":
268
+ failed = [item_id for item_id in ("1.4", "1.5", "1.6") if statuses.get(item_id) == "FAIL"]
269
+ if len(failed) >= 2:
270
+ score = _apply_cap_if_needed(score, 35, _cap_payload("dimension", dimension_id, 35, failed, "AI crawler、索引控制或错误页模板回退中两项及以上不通过,AI 可发现最高 35 分。"), caps)
271
+ elif failed:
272
+ score = _apply_cap_if_needed(score, 55, _cap_payload("dimension", dimension_id, 55, failed, "AI crawler、索引控制或错误页模板回退中任一项不通过,AI 可发现最高 55 分。"), caps)
273
+
274
+ if dimension_id == "citability":
275
+ failed_core = [item_id for item_id in ("3.1", "3.3") if statuses.get(item_id) == "FAIL"]
276
+ failed_density = statuses.get("3.4") == "FAIL"
277
+ if len(failed_core) == 2 and failed_density:
278
+ score = _apply_cap_if_needed(score, 35, _cap_payload("dimension", dimension_id, 35, [*failed_core, "3.4"], "可摘录/回答能力与证据支撑均不通过,并叠加正文信息密度不通过,AI 可引用最高 35 分。"), caps)
279
+ elif len(failed_core) == 2:
280
+ score = _apply_cap_if_needed(score, 45, _cap_payload("dimension", dimension_id, 45, failed_core, "可摘录/回答能力与证据支撑同时不通过,AI 可引用最高 45 分。"), caps)
281
+ elif failed_core:
282
+ score = _apply_cap_if_needed(score, 65, _cap_payload("dimension", dimension_id, 65, failed_core, "可摘录/回答能力或证据支撑任一项不通过,AI 可引用最高 65 分。"), caps)
283
+
284
+ if dimension_id == "trustworthiness":
285
+ failed = [item_id for item_id in ("4.1", "4.3") if statuses.get(item_id) == "FAIL"]
286
+ if len(failed) >= 2:
287
+ score = _apply_cap_if_needed(score, 35, _cap_payload("dimension", dimension_id, 35, failed, "品牌主体可验证性与合规安全透明度同时不通过,AI 可信任最高 35 分。"), caps)
288
+ elif failed:
289
+ score = _apply_cap_if_needed(score, 55, _cap_payload("dimension", dimension_id, 55, failed, "品牌主体可验证性或合规安全透明度不通过,AI 可信任最高 55 分。"), caps)
290
+
291
+ if dimension_id == "recommendability":
292
+ failed = [item_id for item_id in ("5.1", "5.2", "5.5") if statuses.get(item_id) == "FAIL"]
293
+ if len(failed) >= 2:
294
+ score = _apply_cap_if_needed(score, 45, _cap_payload("dimension", dimension_id, 45, failed, "平台适配、主题权威或转化承接中两项及以上不通过,AI 可推荐最高 45 分。"), caps)
295
+ elif failed:
296
+ score = _apply_cap_if_needed(score, 60, _cap_payload("dimension", dimension_id, 60, failed, "平台适配、主题权威或转化承接中任一项不通过,AI 可推荐最高 60 分。"), caps)
297
+
298
+ if dimension_id == "understandability" and statuses.get("1.6") == "FAIL":
299
+ score = _apply_cap_if_needed(score, 70, _cap_payload("cross_dimension", dimension_id, 70, ["1.6"], "错误页与模板回退风险不通过,AI 可理解最高 70 分。"), caps)
300
+ if dimension_id == "citability":
301
+ if statuses.get("1.4") == "FAIL":
302
+ score = _apply_cap_if_needed(score, 70, _cap_payload("cross_dimension", dimension_id, 70, ["1.4"], "AI crawler 实际可访问性不通过,AI 可引用最高 70 分。"), caps)
303
+ if statuses.get("1.6") == "FAIL":
304
+ score = _apply_cap_if_needed(score, 65, _cap_payload("cross_dimension", dimension_id, 65, ["1.6"], "错误页与模板回退风险不通过,AI 可引用最高 65 分。"), caps)
305
+ if dimension_id == "recommendability" and statuses.get("1.4") == "FAIL":
306
+ score = _apply_cap_if_needed(score, 65, _cap_payload("cross_dimension", dimension_id, 65, ["1.4"], "AI crawler 实际可访问性不通过,AI 可推荐最高 65 分。"), caps)
307
+
308
+ return score, caps
309
+
310
+
311
+ def _apply_final_cap(statuses: dict[str, str], score: int) -> tuple[int, dict[str, Any] | None]:
312
+ # 新增:sitemap 无法访问时总分最高 10 分
313
+ if statuses.get("1.7") == "FAIL" and score > 10:
314
+ return 10, _cap_payload("overall", None, 10, ["1.7"], "Sitemap 无法访问,总分最高 10 分。")
315
+ failed = [item_id for item_id in ("1.4", "1.5", "1.6") if statuses.get(item_id) == "FAIL"]
316
+ if len(failed) >= 2 and score > 62:
317
+ return 62, _cap_payload("overall", None, 62, failed, "存在 2 个及以上 P0 技术阻塞,总分最高 62 分。")
318
+ if len(failed) == 1 and score > 70:
319
+ return 70, _cap_payload("overall", None, 70, failed, "存在 1 个 P0 技术阻塞,总分最高 70 分。")
320
+ return score, None
321
+
322
+
323
+ def score_assessment(payload: dict[str, Any]) -> dict[str, Any]:
324
+ assessments = _coerce_assessments(payload)
325
+ all_statuses: dict[str, str] = {}
326
+ for score_item in iter_items():
327
+ assessment = assessments.get(score_item.id, {})
328
+ all_statuses[score_item.id] = normalize_status(assessment.get("status") if isinstance(assessment, dict) else None)
329
+
330
+ dimensions_result: list[dict[str, Any]] = []
331
+ caps: list[dict[str, Any]] = []
332
+
333
+ for dimension in DIMENSIONS:
334
+ weighted = 0.0
335
+ effective_points = 0
336
+ item_results: list[dict[str, Any]] = []
337
+ for score_item in dimension.items:
338
+ assessment = assessments.get(score_item.id, {})
339
+ status = all_statuses[score_item.id]
340
+ coeff = status_coefficient(status, score_item)
341
+ if coeff is not None:
342
+ weighted += score_item.points * coeff
343
+ effective_points += score_item.points
344
+ item_results.append({
345
+ "id": score_item.id,
346
+ "name": score_item.name,
347
+ "points": score_item.points,
348
+ "status": status,
349
+ "badge": STATUS_BADGES[status],
350
+ "coefficient": coeff,
351
+ "warn_coefficient": score_item.warn_coefficient,
352
+ "note": str(assessment.get("note", "")).strip() if isinstance(assessment, dict) else "",
353
+ "detail": score_item.detail,
354
+ "focus": score_item.focus,
355
+ "score_rule": score_item.score_rule,
356
+ })
357
+
358
+ raw_score = 0 if effective_points == 0 else _floor_score((weighted / effective_points) * 100)
359
+ capped_score, dimension_caps = _apply_dimension_caps(dimension.id, all_statuses, raw_score)
360
+ caps.extend(dimension_caps)
361
+ dimensions_result.append({
362
+ "id": dimension.id,
363
+ "label": dimension.label,
364
+ "short_label": dimension.short_label,
365
+ "weight": dimension.weight,
366
+ "score": capped_score,
367
+ "raw_score": raw_score,
368
+ "effective_points": effective_points,
369
+ "caps": dimension_caps,
370
+ "items": item_results,
371
+ })
372
+
373
+ raw_final_score = _floor_score(
374
+ sum(dimension["score"] * dimension["weight"] for dimension in dimensions_result)
375
+ )
376
+ final_score, final_cap = _apply_final_cap(all_statuses, raw_final_score)
377
+ if final_cap:
378
+ caps.append(final_cap)
379
+
380
+ # When overall cap triggers, scale dimension display scores proportionally
381
+ # so the report doesn't show high dimension scores contradicting a low total.
382
+ if final_score < raw_final_score and raw_final_score > 0:
383
+ ratio = final_score / raw_final_score
384
+ for dimension in dimensions_result:
385
+ dimension["display_score"] = _floor_score(dimension["score"] * ratio)
386
+ else:
387
+ for dimension in dimensions_result:
388
+ dimension["display_score"] = dimension["score"]
389
+
390
+ return {
391
+ "score_table_version": SCORE_TABLE_VERSION,
392
+ "final_score": final_score,
393
+ "raw_final_score": raw_final_score,
394
+ "dimensions": dimensions_result,
395
+ "caps": caps,
396
+ }
397
+
398
+
399
+ def render_markdown_report(results: dict[str, Any], *, title: str = "GEO 总分") -> str:
400
+ lines = [
401
+ f"## {title}: {results['final_score']}/100",
402
+ "",
403
+ "| 评估维度 | 得分 | 权重 |",
404
+ "|---|---:|---:|",
405
+ ]
406
+ for dimension in results["dimensions"]:
407
+ ds = dimension.get("display_score", dimension["score"])
408
+ lines.append(
409
+ f"| {dimension['label']} | {ds}/100 | {dimension['weight'] * 100:g}% |"
410
+ )
411
+
412
+ for dimension in results["dimensions"]:
413
+ ds = dimension.get("display_score", dimension["score"])
414
+ lines.extend([
415
+ "",
416
+ f"## {dimension['label']} ({ds}/100)",
417
+ "",
418
+ "| # | 检测项 | 状态 | 说明 |",
419
+ "|---|---|---|---|",
420
+ ])
421
+ for item_result in dimension["items"]:
422
+ note = (item_result["note"] or item_result["detail"]).replace("|", "\\|")
423
+ lines.append(
424
+ f"| {item_result['id']} | {item_result['name']} | {item_result['badge']} | {note} |"
425
+ )
426
+ return "\n".join(lines) + "\n"
427
+
428
+
429
+ def main(argv: list[str] | None = None) -> int:
430
+ parser = argparse.ArgumentParser(description="Score GEO audit assessments with the compressed v3 table.")
431
+ subparsers = parser.add_subparsers(dest="command", required=True)
432
+
433
+ validate = subparsers.add_parser("validate", help="Validate the built-in score table")
434
+ validate.add_argument("--json", action="store_true", help="Print validation details as JSON")
435
+
436
+ score = subparsers.add_parser("score", help="Score an assessment JSON file")
437
+ score.add_argument("assessment_json")
438
+ score.add_argument("--output", "-o")
439
+ score.add_argument("--report")
440
+ score.add_argument("--title", default="GEO 总分")
441
+
442
+ args = parser.parse_args(argv)
443
+ command_started = time.perf_counter()
444
+ timings: dict[str, float] = {}
445
+
446
+ if args.command == "validate":
447
+ stage_started = time.perf_counter()
448
+ errors = validate_config()
449
+ timings["config_validation_seconds"] = _elapsed_since(stage_started)
450
+ timings["total_score_module_seconds"] = _elapsed_since(command_started)
451
+ if args.json:
452
+ print(json.dumps({"ok": not errors, "errors": errors, "timings": timings}, ensure_ascii=False, indent=2))
453
+ elif errors:
454
+ print("\n".join(errors))
455
+ else:
456
+ print(f"Score table is valid: 5 dimensions, {EXPECTED_ITEM_COUNT} items, compressed strict v3.")
457
+ return 1 if errors else 0
458
+
459
+ if args.command == "score":
460
+ stage_started = time.perf_counter()
461
+ payload = json.loads(Path(args.assessment_json).read_text(encoding="utf-8"))
462
+ timings["assessment_read_seconds"] = _elapsed_since(stage_started)
463
+ stage_started = time.perf_counter()
464
+ results = score_assessment(payload)
465
+ timings["score_calculation_seconds"] = _elapsed_since(stage_started)
466
+ report_text = ""
467
+ if args.report:
468
+ stage_started = time.perf_counter()
469
+ report_text = render_markdown_report(results, title=args.title)
470
+ timings["score_report_render_seconds"] = _elapsed_since(stage_started)
471
+ timings["total_score_module_seconds"] = _elapsed_since(command_started)
472
+ results["timings"] = timings
473
+ if args.output:
474
+ Path(args.output).write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8")
475
+ else:
476
+ print(json.dumps(results, ensure_ascii=False, indent=2))
477
+ if args.report:
478
+ Path(args.report).write_text(report_text, encoding="utf-8")
479
+ return 0
480
+
481
+ return 1
482
+
483
+
484
+ if __name__ == "__main__":
485
+ try:
486
+ import _geo_notice
487
+ _geo_notice.emit()
488
+ except Exception:
489
+ pass
490
+ raise SystemExit(main())