@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,718 @@
1
+ #!/usr/bin/env python3
2
+ """AI visibility sampling prompt generation and result scoring."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import re
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Any, Optional
12
+ from urllib.parse import urlparse
13
+
14
+
15
+ UNMENTIONED_RANK = 11
16
+ SUBAGENT_CONCURRENCY_LIMIT = 5
17
+
18
+ CATEGORY_RULES = [
19
+ ("online brokerage", ["brokerage", "stocks", "etf", "reits", "options", "trading", "market access"]),
20
+ ("payment infrastructure", ["payments", "checkout", "billing", "invoicing", "subscriptions"]),
21
+ ("school or education provider", ["school", "course", "tuition", "student", "learning", "education"]),
22
+ ("software platform", ["software", "platform", "api", "dashboard", "workflow", "automation"]),
23
+ ("financial service", ["finance", "financial", "investment", "wealth", "banking"]),
24
+ ]
25
+
26
+ REGION_KEYWORDS = [
27
+ "Singapore",
28
+ "Hong Kong",
29
+ "United States",
30
+ "United Kingdom",
31
+ "China",
32
+ "Australia",
33
+ "Europe",
34
+ "Global",
35
+ ]
36
+
37
+ PRODUCT_TERM_STOPWORDS = {
38
+ "about", "account", "accounts", "all", "and", "an", "app", "apps", "are", "as",
39
+ "at", "article", "articles", "blog", "blogs", "brand", "business", "businesses",
40
+ "by", "case", "category", "center", "commi", "commission", "company", "contact", "content", "customer",
41
+ "customers", "data", "description", "docs", "documentation", "english",
42
+ "discover", "enterprise", "faq", "features", "fee", "fees", "financial", "for", "form", "from",
43
+ "global", "globally", "guide", "guides", "headquartered", "help", "hits", "holding", "home", "homepage",
44
+ "h1", "http", "https", "index", "info", "infrastructure", "insights", "into", "intro", "is", "its",
45
+ "learn", "learning", "licenses", "lifetime", "links", "login", "low", "main",
46
+ "market", "markets", "meta", "news", "of", "offer", "offers", "on", "online", "open", "or", "our", "overview",
47
+ "explained", "page", "pages", "platform", "pricing", "privacy", "product", "products", "proxy",
48
+ "regulatory", "resources", "results", "same", "sec", "service", "services", "site", "solution",
49
+ "solutions", "story", "student", "students", "support", "team", "terms", "that", "the", "this",
50
+ "first", "llms.txt", "title", "to", "today", "tool", "tools", "total", "use", "user", "users", "usd", "via", "vote",
51
+ "website", "with", "www", "your", "我们", "产品", "服务", "平台", "官网",
52
+ "首页", "帮助", "关于",
53
+ }
54
+
55
+ URL_SEGMENT_STOPWORDS = PRODUCT_TERM_STOPWORDS | {
56
+ "api", "assets", "auth", "cdn", "css", "developer", "developers", "download",
57
+ "en", "es", "fr", "html", "images", "img", "js", "login", "media", "page",
58
+ "post", "sg", "sitemap", "static", "zh",
59
+ }
60
+
61
+
62
+ def _dedupe(items: list[str]) -> list[str]:
63
+ seen: set[str] = set()
64
+ result: list[str] = []
65
+ for item in items:
66
+ normalized = item.strip()
67
+ key = normalized.lower()
68
+ if normalized and key not in seen:
69
+ result.append(normalized)
70
+ seen.add(key)
71
+ return result
72
+
73
+
74
+ def _elapsed_since(started: float) -> float:
75
+ return round(time.perf_counter() - started, 3)
76
+
77
+
78
+ def _domain_root(domain: str) -> str:
79
+ parts = [p for p in domain.lower().split(".") if p]
80
+ if len(parts) >= 2 and parts[-2] not in {"com", "co", "org", "net"}:
81
+ return parts[-2]
82
+ return parts[0] if parts else domain
83
+
84
+
85
+ def _normalize_domain(value: str) -> str:
86
+ domain = (value or "").strip().lower()
87
+ if "://" in domain:
88
+ domain = urlparse(domain).netloc
89
+ domain = domain.split("@")[-1].split(":")[0]
90
+ if domain.startswith("www."):
91
+ domain = domain[4:]
92
+ return domain
93
+
94
+
95
+ def _clean_brand_name(value: str) -> str:
96
+ value = re.sub(r"\s+", " ", value or "").strip()
97
+ if not value:
98
+ return ""
99
+ value = re.split(r"\s+[|—–-]\s+|:", value, maxsplit=1)[0].strip()
100
+ value = re.sub(r"\$0.*$", "", value).strip()
101
+ return value
102
+
103
+
104
+ def _first_nonempty(values: list[Any]) -> str:
105
+ for value in values:
106
+ if value is None:
107
+ continue
108
+ text = str(value).strip()
109
+ if text:
110
+ return text
111
+ return ""
112
+
113
+
114
+ def _infer_category(text: str) -> str:
115
+ lower = text.lower()
116
+ best_category = "website or digital service"
117
+ best_hits = 0
118
+ for category, keywords in CATEGORY_RULES:
119
+ hits = sum(1 for keyword in keywords if keyword in lower)
120
+ if hits > best_hits:
121
+ best_category = category
122
+ best_hits = hits
123
+ return best_category
124
+
125
+
126
+ def _infer_regions(text: str) -> list[str]:
127
+ lower = text.lower()
128
+ regions = [region for region in REGION_KEYWORDS if region.lower() in lower]
129
+ return _dedupe(regions) or ["target market"]
130
+
131
+
132
+ def _display_term(term: str, original_forms: dict[str, str]) -> str:
133
+ original = original_forms.get(term, term)
134
+ if original.isupper() and 2 <= len(original) <= 8:
135
+ return original
136
+ return term
137
+
138
+
139
+ def _is_candidate_term(term: str, excluded_terms: set[str], stopwords: set[str]) -> bool:
140
+ normalized = term.strip().lower()
141
+ if (
142
+ not normalized
143
+ or normalized in stopwords
144
+ or normalized in excluded_terms
145
+ or len(normalized) < 3
146
+ or normalized.isdigit()
147
+ ):
148
+ return False
149
+ return bool(re.search(r"[a-zA-Z\u4e00-\u9fff]", normalized))
150
+
151
+
152
+ def _term_words(text: str) -> list[tuple[str, str]]:
153
+ words = re.findall(r"[A-Za-z][A-Za-z0-9+.-]*|[\u4e00-\u9fff]{2,}", text)
154
+ return [(word.lower().strip(".-+"), word.strip(".-+")) for word in words if word.strip(".-+")]
155
+
156
+
157
+ def _add_term(
158
+ scores: dict[str, int],
159
+ original_forms: dict[str, str],
160
+ term: str,
161
+ score: int,
162
+ excluded_terms: set[str],
163
+ stopwords: set[str],
164
+ display_term: Optional[str] = None,
165
+ ) -> None:
166
+ normalized = re.sub(r"\s+", " ", term.strip().lower())
167
+ if not _is_candidate_term(normalized, excluded_terms, stopwords):
168
+ return
169
+ scores[normalized] = scores.get(normalized, 0) + score
170
+ original_forms.setdefault(normalized, (display_term or term).strip())
171
+
172
+
173
+ def _rank_product_terms(scores: dict[str, int], original_forms: dict[str, str]) -> list[str]:
174
+ ranked = sorted(scores.items(), key=lambda item: (item[0].count(" "), -item[1], len(item[0]), item[0]))
175
+ selected: list[str] = []
176
+ for term, _score in ranked:
177
+ if any(term in existing.lower() or existing.lower() in term for existing in selected):
178
+ continue
179
+ selected.append(_display_term(term, original_forms))
180
+ if len(selected) >= 8:
181
+ break
182
+ return selected
183
+
184
+
185
+ def _infer_product_terms(
186
+ text: str,
187
+ source_urls: Optional[list[str]] = None,
188
+ excluded_terms: Optional[list[str]] = None,
189
+ ) -> list[str]:
190
+ excluded = {
191
+ value.lower()
192
+ for value in (excluded_terms or [])
193
+ if value and len(value.lower()) >= 2
194
+ }
195
+ for value in list(excluded):
196
+ excluded.update(part for part in re.split(r"[\s.-]+", value) if len(part) >= 3)
197
+ scores: dict[str, int] = {}
198
+ original_forms: dict[str, str] = {}
199
+
200
+ for normalized, original in _term_words(text):
201
+ score = 5 if original.isupper() and 2 <= len(original) <= 8 else 2
202
+ _add_term(scores, original_forms, normalized, score, excluded, PRODUCT_TERM_STOPWORDS, original)
203
+
204
+ clean_words = [
205
+ (normalized, original)
206
+ for normalized, original in _term_words(text)
207
+ if _is_candidate_term(normalized, excluded, PRODUCT_TERM_STOPWORDS)
208
+ ]
209
+ for index in range(len(clean_words) - 1):
210
+ first, first_original = clean_words[index]
211
+ second, second_original = clean_words[index + 1]
212
+ phrase = f"{first} {second}"
213
+ original_phrase = f"{first_original} {second_original}"
214
+ _add_term(scores, original_forms, phrase, 3, excluded, PRODUCT_TERM_STOPWORDS, original_phrase)
215
+
216
+ if len(_rank_product_terms(scores, original_forms)) < 3:
217
+ for url in source_urls or []:
218
+ path = urlparse(str(url)).path
219
+ for segment in re.split(r"[/?#]", path):
220
+ segment = segment.strip("-_ ")
221
+ if not segment:
222
+ continue
223
+ parts = [part for part in re.split(r"[-_]+", segment) if part]
224
+ normalized_parts = [part.lower() for part in parts]
225
+ meaningful_parts = [
226
+ part for part in normalized_parts
227
+ if _is_candidate_term(part, excluded, URL_SEGMENT_STOPWORDS)
228
+ ]
229
+ for part in meaningful_parts:
230
+ _add_term(scores, original_forms, part, 4, excluded, URL_SEGMENT_STOPWORDS)
231
+ if 2 <= len(meaningful_parts) <= 4:
232
+ phrase = " ".join(meaningful_parts)
233
+ _add_term(scores, original_forms, phrase, 8, excluded, URL_SEGMENT_STOPWORDS)
234
+
235
+ return _rank_product_terms(scores, original_forms)
236
+
237
+
238
+ def _source_urls(audit_data: dict[str, Any]) -> list[str]:
239
+ meta = audit_data.get("meta", {})
240
+ signals = audit_data.get("signals", {})
241
+ sitemap = signals.get("d1_sitemap", {})
242
+ third_party = signals.get("d3_third_party", {})
243
+ urls = [meta.get("url", "")]
244
+ urls.extend(sitemap.get("sampled_page_urls", [])[:6])
245
+ urls.extend(third_party.get("same_as_links", [])[:6])
246
+ return _dedupe([str(url) for url in urls if url])
247
+
248
+
249
+ def build_brand_profile(audit_data: dict[str, Any]) -> dict[str, Any]:
250
+ meta = audit_data.get("meta", {})
251
+ signals = audit_data.get("signals", {})
252
+ snippets = audit_data.get("snippets", {})
253
+ domain = _normalize_domain(str(meta.get("domain") or urlparse(str(meta.get("url", ""))).netloc))
254
+
255
+ brand_name_signals = signals.get("d3_brand_name", {})
256
+ third_party = signals.get("d3_third_party", {})
257
+ knowledge_graph = signals.get("d3_knowledge_graph", {})
258
+ brand_candidates = [
259
+ brand_name_signals.get("schema_org_name"),
260
+ brand_name_signals.get("llms_title"),
261
+ brand_name_signals.get("title_brand"),
262
+ meta.get("brand_query"),
263
+ _domain_root(domain),
264
+ ]
265
+ brand_name = _clean_brand_name(_first_nonempty(brand_candidates)) or _domain_root(domain).title()
266
+
267
+ description = _first_nonempty([
268
+ snippets.get("d3_brand_description_content"),
269
+ snippets.get("d4_definition_content"),
270
+ snippets.get("d1_llms_txt_content"),
271
+ meta.get("brand_query"),
272
+ ])
273
+ product_text = " ".join([
274
+ description,
275
+ snippets.get("d1_llms_txt_content", ""),
276
+ snippets.get("d2_depth_sample", ""),
277
+ snippets.get("d4_definition_content", ""),
278
+ ])
279
+ combined = " ".join([
280
+ product_text,
281
+ json.dumps(third_party, ensure_ascii=False),
282
+ json.dumps(knowledge_graph, ensure_ascii=False),
283
+ ])
284
+ source_urls = _source_urls(audit_data)
285
+ sitemap = signals.get("d1_sitemap", {})
286
+ site_urls = _dedupe([str(meta.get("url", ""))] + [str(url) for url in sitemap.get("sampled_page_urls", [])[:10]])
287
+ domain_root = _domain_root(domain)
288
+ regions = _infer_regions(combined)
289
+ brand_variants = _dedupe([brand_name, domain_root, brand_name.replace(" ", "")])
290
+ category = _infer_category(combined)
291
+ excluded_terms = brand_variants + regions + [domain, category]
292
+
293
+ return {
294
+ "brand_name": brand_name,
295
+ "brand_variants": brand_variants,
296
+ "domain": domain,
297
+ "category": category,
298
+ "regions": regions,
299
+ "product_terms": _infer_product_terms(product_text, site_urls, excluded_terms),
300
+ "description": description[:800],
301
+ "source_urls": source_urls,
302
+ "third_party_sources": {
303
+ "platforms_found": third_party.get("platforms_found", []),
304
+ "same_as_links": third_party.get("same_as_links", []),
305
+ "knowledge_graph": knowledge_graph,
306
+ },
307
+ }
308
+
309
+
310
+ def generate_visibility_prompts(brand_profile: dict[str, Any]) -> list[dict[str, Any]]:
311
+ brand = brand_profile["brand_name"]
312
+ category = brand_profile.get("category") or "website or digital service"
313
+ region = (brand_profile.get("regions") or ["target market"])[0]
314
+ product_terms = brand_profile.get("product_terms") or ["service"]
315
+ product_phrase = ", ".join(product_terms[:3])
316
+
317
+ prompt_specs = [
318
+ ("brand_identification", f"{brand} 是什么?请简要说明它的主要产品、服务对象和适用地区。", True),
319
+ ("brand_identification", f"What does {brand} do, and what kind of customer should consider it?", True),
320
+ ("category_discovery", f"在 {region} 选择 {category} 时,有哪些品牌或平台值得考虑?请按推荐顺序列出。", False),
321
+ ("category_discovery", f"Compare leading {category} options for users in {region}. Which brands would you shortlist?", False),
322
+ ("problem_solution", f"我想解决和 {product_phrase} 相关的问题,应该考虑哪些方案或平台?", False),
323
+ ("problem_solution", f"What tools or providers can help with {product_phrase} for users in {region}?", False),
324
+ ("competitor_alternative", f"What are the main alternatives or competitors to {brand}?", True),
325
+ ("competitor_alternative", f"如果不使用 {brand},还有哪些类似服务可以选择?请说明差异。", True),
326
+ ("source_reference", f"Where can I find reliable official information about {brand}? Include source URLs if you know them.", True),
327
+ ("source_reference", f"请列出了解 {category} 的可信来源,并说明是否有官方站点可参考。", False),
328
+ ]
329
+
330
+ return [
331
+ {
332
+ "id": f"visibility-{index:02d}",
333
+ "type": prompt_type,
334
+ "prompt": prompt,
335
+ "mentions_brand_in_prompt": mentions_brand,
336
+ }
337
+ for index, (prompt_type, prompt, mentions_brand) in enumerate(prompt_specs, start=1)
338
+ ]
339
+
340
+
341
+ def build_subagent_task(prompt_item: dict[str, Any]) -> str:
342
+ return f"""Answer the prompt below using only your own model knowledge.
343
+
344
+ Do not use hidden parent-agent context, local files, prior audit JSON, or any supplied brand profile. Return a concise answer and then the structured block exactly as requested.
345
+
346
+ Prompt:
347
+ {prompt_item["prompt"]}
348
+
349
+ Return format:
350
+ prompt: <repeat the prompt>
351
+ answer: <your answer>
352
+ mentioned_brand: true|false
353
+ brand_position: <number|null>
354
+ mentioned_urls: [<url>, ...]
355
+ competitors_mentioned: [<name>, ...]
356
+ """
357
+
358
+
359
+ def batch_subagent_tasks(
360
+ prompts: list[dict[str, Any]],
361
+ max_concurrency: int = SUBAGENT_CONCURRENCY_LIMIT,
362
+ ) -> list[list[str]]:
363
+ if max_concurrency < 1:
364
+ raise ValueError("max_concurrency must be at least 1")
365
+ prompt_ids = [prompt["id"] for prompt in prompts]
366
+ return [
367
+ prompt_ids[index:index + max_concurrency]
368
+ for index in range(0, len(prompt_ids), max_concurrency)
369
+ ]
370
+
371
+
372
+ def visibility_reference_metadata(public_geo_score: Optional[float] = None) -> dict[str, Any]:
373
+ metadata: dict[str, Any] = {
374
+ "agent_visibility_included": False,
375
+ "score_formula": "visibility_reference_only",
376
+ }
377
+ if public_geo_score is not None:
378
+ metadata["geo_score_reference"] = round(float(public_geo_score), 1)
379
+ return metadata
380
+
381
+
382
+ def _answer_by_prompt_id(answers: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
383
+ result = {}
384
+ for answer in answers:
385
+ prompt_id = answer.get("prompt_id") or answer.get("id")
386
+ if prompt_id:
387
+ result[str(prompt_id)] = answer
388
+ return result
389
+
390
+
391
+ def _contains_any(text: str, values: list[str]) -> bool:
392
+ lower = text.lower()
393
+ return any(value and value.lower() in lower for value in values)
394
+
395
+
396
+ def _as_bool(value: Any) -> Optional[bool]:
397
+ if isinstance(value, bool):
398
+ return value
399
+ if isinstance(value, str):
400
+ normalized = value.strip().lower()
401
+ if normalized in {"true", "yes", "1"}:
402
+ return True
403
+ if normalized in {"false", "no", "0", "null", "none", ""}:
404
+ return False
405
+ return None
406
+
407
+
408
+ def _mentioned_brand(answer: dict[str, Any], text: str, brand_profile: dict[str, Any]) -> bool:
409
+ # Parent scoring is the source of truth. Sub-agent self labels are useful
410
+ # hints, but they can be wrong and must not override answer-text evidence.
411
+ return _contains_any(text, brand_profile.get("brand_variants", []))
412
+
413
+
414
+ def _brand_position(answer: dict[str, Any], mentioned: bool) -> int:
415
+ raw = answer.get("brand_position")
416
+ if isinstance(raw, int) and raw > 0:
417
+ return min(raw, UNMENTIONED_RANK)
418
+ if isinstance(raw, str) and raw.isdigit():
419
+ return min(int(raw), UNMENTIONED_RANK)
420
+ return UNMENTIONED_RANK if not mentioned else 5
421
+
422
+
423
+ def _mentioned_urls(answer: dict[str, Any], text: str) -> list[str]:
424
+ urls = answer.get("mentioned_urls")
425
+ if isinstance(urls, list):
426
+ return [str(url) for url in urls]
427
+ return re.findall(r"https?://[^\s)\]]+", text)
428
+
429
+
430
+ def _correct_entity(answer: dict[str, Any], text: str, brand_profile: dict[str, Any], mentioned: bool) -> bool:
431
+ if "correct_entity" in answer:
432
+ return bool(_as_bool(answer["correct_entity"]))
433
+ if not mentioned:
434
+ return False
435
+ expected_terms = [brand_profile.get("category", "")]
436
+ expected_terms.extend(brand_profile.get("regions", []))
437
+ expected_terms.extend(brand_profile.get("product_terms", []))
438
+ return _contains_any(text, [term for term in expected_terms if term])
439
+
440
+
441
+ def _hallucinated(answer: dict[str, Any], mentioned: bool) -> bool:
442
+ if not mentioned:
443
+ return False
444
+ if "hallucinated" in answer:
445
+ return bool(_as_bool(answer["hallucinated"]))
446
+ if "hallucination" in answer:
447
+ return bool(_as_bool(answer["hallucination"]))
448
+ incorrect_claims = answer.get("incorrect_claims")
449
+ return bool(incorrect_claims)
450
+
451
+
452
+ def score_visibility_results(
453
+ brand_profile: dict[str, Any],
454
+ prompts: list[dict[str, Any]],
455
+ answers: list[dict[str, Any]],
456
+ public_geo_score: Optional[float] = None,
457
+ ) -> dict[str, Any]:
458
+ answers_by_id = _answer_by_prompt_id(answers)
459
+ rows: list[dict[str, Any]] = []
460
+
461
+ for prompt in prompts:
462
+ answer = answers_by_id.get(prompt["id"], {})
463
+ answer_text = str(answer.get("answer") or answer.get("text") or "")
464
+ urls = _mentioned_urls(answer, answer_text)
465
+ evidence_text = " ".join([
466
+ answer_text,
467
+ " ".join(urls),
468
+ json.dumps(answer.get("competitors_mentioned", []), ensure_ascii=False),
469
+ ])
470
+ mentioned = _mentioned_brand(answer, evidence_text, brand_profile)
471
+ position = _brand_position(answer, mentioned)
472
+ source_visible = _contains_any(" ".join(urls + [answer_text]), [brand_profile.get("domain", "")])
473
+ correct_entity = _correct_entity(answer, evidence_text, brand_profile, mentioned)
474
+ hallucinated = _hallucinated(answer, mentioned)
475
+
476
+ rows.append({
477
+ "prompt_id": prompt["id"],
478
+ "type": prompt["type"],
479
+ "prompt": prompt["prompt"],
480
+ "mentioned_brand": mentioned,
481
+ "brand_position": position if mentioned else None,
482
+ "source_visible": source_visible,
483
+ "correct_entity": correct_entity,
484
+ "hallucinated": hallucinated,
485
+ "mentioned_urls": urls,
486
+ "competitors_mentioned": answer.get("competitors_mentioned", []),
487
+ })
488
+
489
+ total = len(rows) or 1
490
+ mentioned_rows = [row for row in rows if row["mentioned_brand"]]
491
+ mention_count = len(mentioned_rows)
492
+ category_rows = [row for row in rows if row["type"] == "category_discovery"]
493
+ source_rows = [row for row in rows if row["type"] == "source_reference"]
494
+ rank_values = [
495
+ row["brand_position"] if row["brand_position"] is not None else UNMENTIONED_RANK
496
+ for row in rows
497
+ ]
498
+ average_rank = sum(rank_values) / total
499
+ rank_score = max(0.0, 1.0 - ((average_rank - 1) / (UNMENTIONED_RANK - 1)))
500
+
501
+ mention_rate = mention_count / total
502
+ correct_entity_rate = (
503
+ sum(1 for row in mentioned_rows if row["correct_entity"]) / mention_count
504
+ if mention_count else 0.0
505
+ )
506
+ category_visibility_rate = (
507
+ sum(1 for row in category_rows if row["mentioned_brand"]) / len(category_rows)
508
+ if category_rows else 0.0
509
+ )
510
+ source_visibility_rate = (
511
+ sum(1 for row in source_rows if row["source_visible"]) / len(source_rows)
512
+ if source_rows else 0.0
513
+ )
514
+ hallucination_rate = (
515
+ sum(1 for row in mentioned_rows if row["hallucinated"]) / mention_count
516
+ if mention_count else 0.0
517
+ )
518
+ visibility_score = round(100 * (
519
+ 0.30 * mention_rate
520
+ + 0.20 * correct_entity_rate
521
+ + 0.20 * category_visibility_rate
522
+ + 0.15 * source_visibility_rate
523
+ + 0.10 * rank_score
524
+ + 0.05 * (1 - hallucination_rate)
525
+ ), 1)
526
+
527
+ metrics = {
528
+ "mention_rate": round(mention_rate, 4),
529
+ "correct_entity_rate": round(correct_entity_rate, 4),
530
+ "category_visibility_rate": round(category_visibility_rate, 4),
531
+ "source_visibility_rate": round(source_visibility_rate, 4),
532
+ "average_rank": round(average_rank, 2),
533
+ "hallucination_rate": round(hallucination_rate, 4),
534
+ "visibility_score": visibility_score,
535
+ }
536
+ metrics.update(visibility_reference_metadata(public_geo_score))
537
+
538
+ return {
539
+ "brand_profile": brand_profile,
540
+ "metrics": metrics,
541
+ "rows": rows,
542
+ }
543
+
544
+
545
+ def _md_cell(value: Any, max_len: int = 240) -> str:
546
+ text = str(value or "").replace("\n", " ").replace("|", "\\|").strip()
547
+ if len(text) > max_len:
548
+ return text[: max_len - 1].rstrip() + "…"
549
+ return text
550
+
551
+
552
+ def _visibility_recommendations(metrics: dict[str, Any]) -> list[str]:
553
+ recommendations: list[str] = []
554
+ if metrics["category_visibility_rate"] < 0.5:
555
+ recommendations.append("补充围绕品类、场景、对比和选择标准的页面,让不含品牌名的问题也能命中品牌实体。")
556
+ if metrics["source_visibility_rate"] < 0.7:
557
+ recommendations.append("强化官网来源可引用入口,例如 llms.txt、About、FAQ、Docs、对比页和结构化数据中的官方 URL。")
558
+ if metrics["correct_entity_rate"] < 0.8:
559
+ recommendations.append("统一品牌主体说明、地区、产品线和目标用户表述,降低 AI 把品牌归错类或描述不完整的概率。")
560
+ if metrics["hallucination_rate"] > 0.1:
561
+ recommendations.append("在官网与第三方资料中补齐可核验事实,优先修正易被误述的业务范围、地区、价格和资质信息。")
562
+ if not recommendations:
563
+ recommendations.append("保持当前实体资料、可引用页面和第三方资料一致性,并持续用隔离 prompt 做回归采样。")
564
+ return recommendations
565
+
566
+
567
+ def format_visibility_report(results: dict[str, Any]) -> str:
568
+ metrics = results["metrics"]
569
+ rows = results["rows"]
570
+ mentioned_count = sum(1 for row in rows if row["mentioned_brand"])
571
+ category_rows = [row for row in rows if row["type"] == "category_discovery"]
572
+ source_rows = [row for row in rows if row["type"] == "source_reference"]
573
+ lines = [
574
+ "## AI 可见性采样参考",
575
+ "",
576
+ "> 本章节基于当前运行模型采样,仅用于粗略观察品牌在 AI 回答中的可见性;该采样不参与 GEO 总分。如需更精确、持续的 AI 可见性数据,可注册 [adgine.ai](https://adgine.ai/) 进行持续监测。",
577
+ "",
578
+ "| 指标 | 结果 |",
579
+ "|------|------|",
580
+ f"| AI 可见性采样分 | {metrics['visibility_score']}/100 |",
581
+ f"| 品牌命中率 | {metrics['mention_rate'] * 100:.1f}% |",
582
+ f"| 实体描述正确率 | {metrics['correct_entity_rate'] * 100:.1f}% |",
583
+ f"| 品类主动可见率 | {metrics['category_visibility_rate'] * 100:.1f}% |",
584
+ f"| 来源可见率 | {metrics['source_visibility_rate'] * 100:.1f}% |",
585
+ f"| 平均排名 | {metrics['average_rank']} |",
586
+ f"| 事实错误率 | {metrics['hallucination_rate'] * 100:.1f}% |",
587
+ ]
588
+ lines.extend([
589
+ "",
590
+ "### 统计摘要",
591
+ "",
592
+ f"- 共执行 {len(rows)} 个隔离 prompt,目标品牌出现在 {mentioned_count} 个回答中。",
593
+ f"- 不含品牌名的品类发现 prompt 命中 {sum(1 for row in category_rows if row['mentioned_brand'])}/{len(category_rows) or 0}。",
594
+ f"- 来源型 prompt 命中官网或站内 URL {sum(1 for row in source_rows if row['source_visible'])}/{len(source_rows) or 0}。",
595
+ "",
596
+ "### Prompt 明细",
597
+ "",
598
+ "| ID | 类型 | Prompt | 是否可见 | 排名 | 官网/站内来源 |",
599
+ "|----|------|--------|------|---:|------|",
600
+ ])
601
+ for row in rows:
602
+ lines.append(
603
+ "| {prompt_id} | {type} | {prompt} | {mentioned} | {rank} | {source} |".format(
604
+ prompt_id=row["prompt_id"],
605
+ type=row["type"],
606
+ prompt=_md_cell(row["prompt"]),
607
+ mentioned="✅" if row["mentioned_brand"] else "❌",
608
+ rank=row["brand_position"] if row["brand_position"] is not None else "—",
609
+ source="✅" if row["source_visible"] else "❌",
610
+ )
611
+ )
612
+ lines.extend([
613
+ "",
614
+ "### 改进建议",
615
+ "",
616
+ ])
617
+ lines.extend(f"- {item}" for item in _visibility_recommendations(metrics))
618
+ return "\n".join(lines) + "\n"
619
+
620
+
621
+ def _load_json(path: str) -> Any:
622
+ return json.loads(Path(path).read_text(encoding="utf-8"))
623
+
624
+
625
+ def _write_json(path: Optional[str], data: Any) -> None:
626
+ text = json.dumps(data, ensure_ascii=False, indent=2)
627
+ if path:
628
+ Path(path).write_text(text + "\n", encoding="utf-8")
629
+ else:
630
+ print(text)
631
+
632
+
633
+ def main(argv: Optional[list[str]] = None) -> int:
634
+ parser = argparse.ArgumentParser(description="Prepare and score agent-simulated GEO visibility tests.")
635
+ subparsers = parser.add_subparsers(dest="command", required=True)
636
+
637
+ prepare = subparsers.add_parser("prepare", help="Build brand profile and prompt set from audit JSON")
638
+ prepare.add_argument("audit_json")
639
+ prepare.add_argument("--output", "-o")
640
+
641
+ score = subparsers.add_parser("score", help="Score sub-agent answers")
642
+ score.add_argument("visibility_plan_json")
643
+ score.add_argument("answers_json")
644
+ score.add_argument("--output", "-o")
645
+ score.add_argument("--report", help="Optional Markdown report output path")
646
+ score.add_argument("--public-geo-score", type=float, help="Optional GEO score reference; visibility sampling does not change it")
647
+
648
+ args = parser.parse_args(argv)
649
+ command_started = time.perf_counter()
650
+ timings: dict[str, float] = {}
651
+
652
+ if args.command == "prepare":
653
+ stage_started = time.perf_counter()
654
+ audit_data = _load_json(args.audit_json)
655
+ timings["audit_json_read_seconds"] = _elapsed_since(stage_started)
656
+ stage_started = time.perf_counter()
657
+ brand_profile = build_brand_profile(audit_data)
658
+ timings["brand_profile_build_seconds"] = _elapsed_since(stage_started)
659
+ stage_started = time.perf_counter()
660
+ prompts = generate_visibility_prompts(brand_profile)
661
+ timings["prompt_generation_seconds"] = _elapsed_since(stage_started)
662
+ stage_started = time.perf_counter()
663
+ subagent_batches = batch_subagent_tasks(prompts)
664
+ subagent_tasks = {
665
+ prompt["id"]: build_subagent_task(prompt)
666
+ for prompt in prompts
667
+ }
668
+ timings["subagent_task_build_seconds"] = _elapsed_since(stage_started)
669
+ timings["total_visibility_prepare_seconds"] = _elapsed_since(command_started)
670
+ plan = {
671
+ "brand_profile": brand_profile,
672
+ "prompts": prompts,
673
+ "subagent_concurrency_limit": SUBAGENT_CONCURRENCY_LIMIT,
674
+ "subagent_batches": subagent_batches,
675
+ "subagent_tasks": subagent_tasks,
676
+ "timings": timings,
677
+ }
678
+ _write_json(args.output, plan)
679
+ return 0
680
+
681
+ if args.command == "score":
682
+ stage_started = time.perf_counter()
683
+ plan = _load_json(args.visibility_plan_json)
684
+ timings["visibility_plan_read_seconds"] = _elapsed_since(stage_started)
685
+ stage_started = time.perf_counter()
686
+ raw_answers = _load_json(args.answers_json)
687
+ timings["answers_read_seconds"] = _elapsed_since(stage_started)
688
+ answers = raw_answers.get("answers", raw_answers) if isinstance(raw_answers, dict) else raw_answers
689
+ stage_started = time.perf_counter()
690
+ results = score_visibility_results(
691
+ plan["brand_profile"],
692
+ plan["prompts"],
693
+ answers,
694
+ public_geo_score=args.public_geo_score,
695
+ )
696
+ timings["visibility_score_calculation_seconds"] = _elapsed_since(stage_started)
697
+ report_text = ""
698
+ if args.report:
699
+ stage_started = time.perf_counter()
700
+ report_text = format_visibility_report(results)
701
+ timings["visibility_report_render_seconds"] = _elapsed_since(stage_started)
702
+ timings["total_visibility_score_seconds"] = _elapsed_since(command_started)
703
+ results["timings"] = timings
704
+ _write_json(args.output, results)
705
+ if args.report:
706
+ Path(args.report).write_text(report_text, encoding="utf-8")
707
+ return 0
708
+
709
+ return 2
710
+
711
+
712
+ if __name__ == "__main__":
713
+ try:
714
+ import _geo_notice
715
+ _geo_notice.emit()
716
+ except Exception:
717
+ pass
718
+ raise SystemExit(main())