@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,341 @@
1
+ #!/usr/bin/env python3
2
+ """Shared HTTP client utilities for geo-skills scripts.
3
+
4
+ Used by all scripts in this skill. Uses Python stdlib only — no external dependencies.
5
+
6
+ This file is the canonical source. It must be copied verbatim into every
7
+ adgine-geo-* skill folder's scripts/ directory. The sync linter at
8
+ scripts/sync-skills/sync-skills.sh (in the GEOAgent repo) flags any drift.
9
+ """
10
+ import os
11
+ import sys
12
+ import json
13
+ import time
14
+ import atexit
15
+ import unicodedata
16
+ import urllib.request as _req
17
+ import urllib.error as _uerr
18
+ import urllib.parse as _up
19
+
20
+ def _load_dot_env():
21
+ """Load .env from the repo root (adgine-geo-skills/) into os.environ.
22
+
23
+ Resolved relative to this file's location, so it works regardless of the
24
+ agent's working directory (e.g. Hermes, OpenClaw, or any other runtime).
25
+ Existing environment variables are never overwritten.
26
+ """
27
+ if os.environ.get("GEO_API_KEY"):
28
+ return
29
+ # _client.py is at <repo_root>/adgine-geo-<skill>/scripts/_client.py
30
+ repo_root = os.path.dirname(
31
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
32
+ )
33
+ env_path = os.path.join(repo_root, ".env")
34
+ if not os.path.isfile(env_path):
35
+ return
36
+ with open(env_path) as _f:
37
+ for _line in _f:
38
+ _line = _line.strip()
39
+ if not _line or _line.startswith("#") or "=" not in _line:
40
+ continue
41
+ _k, _, _v = _line.partition("=")
42
+ _k = _k.strip()
43
+ _v = _v.strip()
44
+ if _k and _k not in os.environ:
45
+ os.environ[_k] = _v
46
+
47
+
48
+ _load_dot_env()
49
+
50
+ _VERSION_UNCHECKED = object()
51
+ _version_state = _VERSION_UNCHECKED
52
+
53
+
54
+ def _repo_root():
55
+ return os.path.dirname(
56
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
57
+ )
58
+
59
+
60
+ def _load_check_version_module():
61
+ import importlib.util
62
+ path = os.path.join(_repo_root(), "scripts", "check_version.py")
63
+ if not os.path.isfile(path):
64
+ return None
65
+ spec = importlib.util.spec_from_file_location("geo_check_version", path)
66
+ mod = importlib.util.module_from_spec(spec)
67
+ spec.loader.exec_module(mod)
68
+ return mod
69
+
70
+
71
+ def _load_version_state():
72
+ global _version_state
73
+ if _version_state is not _VERSION_UNCHECKED:
74
+ return _version_state
75
+ _version_state = None
76
+ try:
77
+ mod = _load_check_version_module()
78
+ if mod is not None:
79
+ _version_state = mod.get_state()
80
+ except Exception:
81
+ pass
82
+ return _version_state
83
+
84
+
85
+ def _print_version_notice():
86
+ """Emit machine + human hints at import if a newer version exists."""
87
+ state = _load_version_state()
88
+ if not state or not state.get("update_available"):
89
+ return
90
+ cur, lat = state["current"], state["latest"]
91
+ try:
92
+ mod = _load_check_version_module()
93
+ if mod is not None:
94
+ inline = mod.format_user_inline(state)
95
+ if inline:
96
+ sys.stdout.write(inline + "\n\n")
97
+ sys.stdout.flush()
98
+ except Exception:
99
+ pass
100
+ if state.get("install_type") == "git":
101
+ msg = (f"adgine-geo-skills {lat} available (current {cur}). "
102
+ "Tell me: 请帮我更新 adgine-geo-skills 到最新版本")
103
+ else:
104
+ msg = (f"adgine-geo-skills {lat} available (current {cur}). "
105
+ f"Download: {state.get('release_url', '')}")
106
+ notice = {"update": {"current": cur, "latest": lat, "message": msg}}
107
+ try:
108
+ line = f"_notice: {json.dumps(notice, ensure_ascii=False)}\n\n"
109
+ sys.stdout.write(line)
110
+ sys.stdout.flush()
111
+ except Exception:
112
+ pass
113
+
114
+
115
+ def _print_version_footer():
116
+ """Emit human-readable footer at process exit (WorkBuddy-compatible)."""
117
+ state = _load_version_state()
118
+ if not state or not state.get("update_available"):
119
+ return
120
+ try:
121
+ mod = _load_check_version_module()
122
+ if mod is None:
123
+ return
124
+ footer = mod.format_user_footer(state)
125
+ if footer:
126
+ sys.stdout.write("\n" + footer + "\n")
127
+ sys.stdout.flush()
128
+ except Exception:
129
+ pass
130
+
131
+
132
+ _print_version_notice()
133
+ atexit.register(_print_version_footer)
134
+
135
+
136
+ def get_api_config():
137
+ """Read GEO_API_KEY and GEO_API_BASE_URL from environment.
138
+
139
+ Exits with a helpful message if GEO_API_KEY is not set.
140
+ Returns (key, base_url).
141
+ """
142
+ key = os.environ.get("GEO_API_KEY", "")
143
+ base = os.environ.get("GEO_API_BASE_URL", "https://platform.adgine.ai").rstrip("/")
144
+ if not key:
145
+ repo_root = os.path.dirname(
146
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
147
+ )
148
+ env_path = os.path.join(repo_root, ".env")
149
+ example_path = os.path.join(repo_root, ".env.example")
150
+ setup_path = os.path.join(repo_root, "setup.py")
151
+ print("ERROR: GEO_API_KEY is not set.")
152
+ print(f" Expected location: {env_path}")
153
+ print()
154
+ print(" One-shot fix (recommended):")
155
+ print(f" python3 {setup_path} <YOUR_KEY>")
156
+ print()
157
+ print(" Or set it manually:")
158
+ if not os.path.isfile(env_path):
159
+ print(f" cp {example_path} {env_path}")
160
+ print(f" edit {env_path} and set GEO_API_KEY=geo_sk_live_xxx")
161
+ print()
162
+ print(" Get your key at: https://platform.adgine.ai")
163
+ print(" The .env file is gitignored — your key stays local and private.")
164
+ print()
165
+ print(" NOTE: adgine-geo-site-audit does NOT require an API key.")
166
+ print(" Run directly: python3 adgine-geo-site-audit/scripts/geo_collect.py <url>")
167
+ sys.exit(1)
168
+ return key, base
169
+
170
+
171
+ def get_project_id(arg_value=None):
172
+ """Resolve project ID from argument or GEO_PROJECT_ID env var.
173
+
174
+ Exits with instructions if neither is set.
175
+ """
176
+ pid = arg_value or os.environ.get("GEO_PROJECT_ID", "")
177
+ if not pid:
178
+ print("ERROR: Project ID is required but not set.")
179
+ print(" Option 1: export GEO_PROJECT_ID=<project-id>")
180
+ print(" Option 2: pass --project-id <id> to this script")
181
+ print(" Run list_projects.py from the adgine-geo-projects skill to see your available projects.")
182
+ sys.exit(1)
183
+ return pid
184
+
185
+
186
+ def _do_request(method, url, key, body=None, timeout=30):
187
+ """Execute an HTTP request and return parsed JSON.
188
+
189
+ Exits with an error message on HTTP errors or network failures.
190
+ """
191
+ data = json.dumps(body).encode("utf-8") if body is not None else None
192
+ headers = {
193
+ "Authorization": f"Bearer {key}",
194
+ "Content-Type": "application/json",
195
+ "Accept": "application/json",
196
+ "User-Agent": "geo-skills/1.0",
197
+ }
198
+ request = _req.Request(url, data=data, headers=headers, method=method)
199
+ try:
200
+ with _req.urlopen(request, timeout=timeout) as resp:
201
+ raw = resp.read().decode("utf-8")
202
+ return json.loads(raw) if raw else {}
203
+ except _uerr.HTTPError as e:
204
+ raw = e.read().decode("utf-8")[:400]
205
+ try:
206
+ err_data = json.loads(raw)
207
+ msg = err_data.get("message") or err_data.get("detail") or raw
208
+ except Exception:
209
+ msg = raw
210
+ if e.code == 401:
211
+ print("ERROR: Unauthorized — API key is invalid or revoked.")
212
+ print(" Generate a new key: https://platform.adgine.ai")
213
+ elif e.code == 403:
214
+ print("ERROR: Forbidden — insufficient permissions for this operation.")
215
+ elif e.code == 404:
216
+ print(f"ERROR: Not found — {msg}")
217
+ else:
218
+ print(f"ERROR: HTTP {e.code} — {msg}")
219
+ sys.exit(1)
220
+ except Exception as e:
221
+ print(f"ERROR: Request failed — {e}")
222
+ sys.exit(1)
223
+
224
+
225
+ def api_get(path, key, base, params=None, timeout=30):
226
+ url = f"{base}{path}"
227
+ if params:
228
+ clean = {k: str(v) for k, v in params.items() if v is not None}
229
+ if clean:
230
+ url += "?" + _up.urlencode(clean)
231
+ return _do_request("GET", url, key, timeout=timeout)
232
+
233
+
234
+ def api_post(path, key, base, body=None, timeout=30):
235
+ return _do_request("POST", f"{base}{path}", key, body, timeout=timeout)
236
+
237
+
238
+ def api_patch(path, key, base, body=None, timeout=30):
239
+ return _do_request("PATCH", f"{base}{path}", key, body, timeout=timeout)
240
+
241
+
242
+ def api_put(path, key, base, body=None, timeout=30):
243
+ return _do_request("PUT", f"{base}{path}", key, body, timeout=timeout)
244
+
245
+
246
+ def api_delete(path, key, base, timeout=30):
247
+ return _do_request("DELETE", f"{base}{path}", key, timeout=timeout)
248
+
249
+
250
+ def extract_data(result):
251
+ """Extract the .data field from a standard geo-api envelope response.
252
+
253
+ On success the API returns {"code": 0, "data": <payload>, "message": "ok"}.
254
+ On non-zero code, this prints the message and exits(1).
255
+ """
256
+ if not isinstance(result, dict):
257
+ return result
258
+ if "code" in result and result.get("code") not in (0, None):
259
+ msg = result.get("message") or result.get("detail") or "request failed"
260
+ print(f"ERROR: API returned code={result.get('code')} — {msg}")
261
+ sys.exit(1)
262
+ return result.get("data", result)
263
+
264
+
265
+ def poll_job(status_path, key, base, interval=3, max_wait=300,
266
+ terminal=("completed", "failed", "done", "success", "error")):
267
+ """Poll a job/task endpoint until it reaches a terminal state.
268
+
269
+ Prints an inline spinner with the current phase. Returns the final job dict.
270
+ """
271
+ elapsed = 0
272
+ frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
273
+ idx = 0
274
+ last_job = {}
275
+ while elapsed < max_wait:
276
+ result = api_get(status_path, key, base)
277
+ last_job = extract_data(result) or {}
278
+ status = (last_job.get("status") or "").lower()
279
+ phase = last_job.get("current_phase") or last_job.get("phase") or status or "pending"
280
+ print(f"\r {frames[idx % len(frames)]} {phase}... ({elapsed}s)", end="", flush=True)
281
+ idx += 1
282
+ if status in terminal:
283
+ print() # newline after spinner
284
+ return last_job
285
+ time.sleep(interval)
286
+ elapsed += interval
287
+ print()
288
+ print(f"WARNING: Job is still running after {max_wait}s. Check status manually.")
289
+ return last_job
290
+
291
+
292
+ def print_json(data):
293
+ """Print data as formatted JSON (UTF-8 safe)."""
294
+ print(json.dumps(data, ensure_ascii=False, indent=2))
295
+
296
+
297
+ def fmt_change(value):
298
+ """Format a numeric change indicator per CONVENTIONS.md §4.
299
+
300
+ Returns: '+N' / '-N' / '0' / '--' (None becomes '--').
301
+ """
302
+ if value is None:
303
+ return "--"
304
+ try:
305
+ n = float(value)
306
+ except (TypeError, ValueError):
307
+ return "--"
308
+ if n == 0:
309
+ return "0"
310
+ sign = "+" if n > 0 else ""
311
+ if n == int(n):
312
+ return f"{sign}{int(n):,}"
313
+ return f"{sign}{n:,.1f}"
314
+
315
+
316
+ def truncate(text, n=60, ellipsis="…"):
317
+ """Truncate text to n characters, adding ellipsis if cut. ASCII-safe for tables."""
318
+ if text is None:
319
+ return "--"
320
+ s = str(text)
321
+ if len(s) <= n:
322
+ return s
323
+ return s[: max(0, n - len(ellipsis))] + ellipsis
324
+
325
+ def display_width(s):
326
+ """Return the display width of a string (CJK chars count as 2 columns)."""
327
+ w = 0
328
+ for c in str(s):
329
+ eaw = unicodedata.east_asian_width(c)
330
+ w += 2 if eaw in ('W', 'F') else 1
331
+ return w
332
+
333
+
334
+ def pad(s, width, align='left'):
335
+ """Pad string to display width for CJK-aware table alignment.
336
+ Replaces f'{s:<N}' (align='left') and f'{s:>N}' (align='right').
337
+ """
338
+ s = str(s)
339
+ dw = display_width(s)
340
+ spaces = max(0, width - dw) * ' '
341
+ return spaces + s if align == 'right' else s + spaces
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env python3
2
+ """Submit citation tests for one or more GEO prompts.
3
+
4
+ Sends each prompt to multiple AI platforms (ChatGPT, Perplexity, Google AI
5
+ Overviews, etc.) to check whether your brand is cited in the responses.
6
+ Results are processed asynchronously — check with get_results.py.
7
+
8
+ Usage:
9
+ python3 scripts/create_tests.py --prompt-ids <id1,id2,...> [--project-id <id>]
10
+ """
11
+ import sys
12
+ import os
13
+ import argparse
14
+
15
+ sys.path.insert(0, os.path.dirname(__file__))
16
+ from _client import get_api_config, get_project_id, api_post, extract_data, print_json
17
+
18
+ parser = argparse.ArgumentParser(description="Submit GEO citation tests")
19
+ parser.add_argument("--prompt-ids", required=True,
20
+ help="Comma-separated prompt IDs to test")
21
+ parser.add_argument("--project-id", help="Project ID (or set GEO_PROJECT_ID env var)")
22
+ parser.add_argument("--json", action="store_true", help="Output raw JSON response")
23
+ args = parser.parse_args()
24
+
25
+ key, base = get_api_config()
26
+ pid = get_project_id(args.project_id)
27
+
28
+ prompt_ids = [p.strip() for p in args.prompt_ids.split(",") if p.strip()]
29
+ if not prompt_ids:
30
+ print("ERROR: --prompt-ids must contain at least one ID")
31
+ sys.exit(1)
32
+
33
+ print(f"Submitting citation tests for {len(prompt_ids)} prompt(s)...")
34
+
35
+ body = {"prompt_ids": prompt_ids}
36
+ result = api_post(f"/api/projects/{pid}/citation-tests", key, base, body)
37
+ data = extract_data(result)
38
+
39
+ if args.json:
40
+ print_json(data)
41
+ sys.exit(0)
42
+
43
+ tests = data if isinstance(data, list) else data.get("tests") or data.get("items") or []
44
+ print(f"\nCreated {len(tests)} citation test(s)")
45
+ print()
46
+
47
+ # Group by prompt
48
+ by_prompt = {}
49
+ for t in tests:
50
+ prompt_id = t.get("prompt_id", "unknown")
51
+ by_prompt.setdefault(prompt_id, []).append(t)
52
+
53
+ for prompt_id, prompt_tests in by_prompt.items():
54
+ platforms = [t.get("platform", "?") for t in prompt_tests]
55
+ print(f" Prompt [{prompt_id[:36]}]")
56
+ print(f" Platforms : {', '.join(platforms)}")
57
+ print()
58
+
59
+ print("Results are processed asynchronously (5–15 minutes per platform).")
60
+ print("Check results with:")
61
+ for pid_str in prompt_ids:
62
+ print(f" python3 scripts/get_results.py --prompt-id {pid_str}")
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env python3
2
+ """Retrieve project-level citation aggregate metrics.
3
+
4
+ Returns citation count, citation share, and global citation ranking
5
+ for a given time window, with automatic previous-period comparison.
6
+
7
+ Usage:
8
+ python3 scripts/get_aggregate.py [--start-date 2025-02-22] [--end-date 2025-03-14]
9
+ python3 scripts/get_aggregate.py --platform chatgpt,perplexity
10
+ python3 scripts/get_aggregate.py --json
11
+ """
12
+ import sys
13
+ import os
14
+ import argparse
15
+
16
+ sys.path.insert(0, os.path.dirname(__file__))
17
+ from _client import get_api_config, get_project_id, api_get, extract_data, print_json
18
+
19
+ parser = argparse.ArgumentParser(description="View project-level citation aggregate metrics")
20
+ parser.add_argument("--project-id", help="Project ID (or set GEO_PROJECT_ID env var)")
21
+ parser.add_argument("--start-date", help="Start date (YYYY-MM-DD), default: 7 days ago")
22
+ parser.add_argument("--end-date", help="End date (YYYY-MM-DD), default: today")
23
+ parser.add_argument("--platform", help="Comma-separated platform filter (e.g. chatgpt,perplexity)")
24
+ parser.add_argument("--json", action="store_true", help="Output raw JSON")
25
+ args = parser.parse_args()
26
+
27
+ key, base = get_api_config()
28
+ pid = get_project_id(args.project_id)
29
+
30
+ # Build query params
31
+ params = {}
32
+ if args.start_date:
33
+ params["date_from"] = args.start_date
34
+ if args.end_date:
35
+ params["date_to"] = args.end_date
36
+ if args.platform:
37
+ params["platform"] = args.platform
38
+
39
+ result = api_get(
40
+ f"/api/projects/{pid}/analytics/citation/aggregate",
41
+ key, base, params=params,
42
+ )
43
+ data = extract_data(result)
44
+
45
+ if args.json:
46
+ print_json(data)
47
+ sys.exit(0)
48
+
49
+ # Formatted output
50
+ date_range = data.get("date_range") or {}
51
+ print(f"Citation Aggregate Metrics")
52
+ print(f" Period: {date_range.get('from', '?')} → {date_range.get('to', '?')}")
53
+ if date_range.get("platform"):
54
+ print(f" Platforms: {', '.join(date_range['platform'])}")
55
+ print()
56
+
57
+ # Main metrics
58
+ citation_count = data.get("citation_count") or {}
59
+ citation_share = data.get("citation_share") or {}
60
+ citation_rank = data.get("citation_rank") or {}
61
+ total_citations = data.get("total_citations") or {}
62
+
63
+
64
+ def fmt_change(val):
65
+ if val is None:
66
+ return ""
67
+ if isinstance(val, float):
68
+ sign = "+" if val > 0 else ""
69
+ return f" ({sign}{val:.1f})"
70
+ sign = "+" if val > 0 else ""
71
+ return f" ({sign}{val})"
72
+
73
+
74
+ def fmt_pct(val):
75
+ if val is None:
76
+ return "--"
77
+ return f"{val:.2f}%"
78
+
79
+
80
+ print(f" Citation Count : {citation_count.get('current', 0)}{fmt_change(citation_count.get('change'))}")
81
+ print(f" Citation Share : {fmt_pct(citation_share.get('current'))}{fmt_change(citation_share.get('change'))}")
82
+ print(f" Citation Rank : #{citation_rank.get('current', '--')}{fmt_change(citation_rank.get('change'))}")
83
+ print(f" Total (all brands): {total_citations.get('current', 0)}{fmt_change(total_citations.get('change'))}")
84
+ print()
85
+
86
+ # Platform breakdown
87
+ by_platform = data.get("by_platform") or []
88
+ if by_platform:
89
+ print(" By Platform:")
90
+ for p in by_platform:
91
+ plat = p.get("platform", "?")
92
+ count = p.get("citation_count", 0)
93
+ share = p.get("citation_share")
94
+ share_str = f"{share:.1f}%" if share is not None else "--"
95
+ print(f" {plat:<16} {count:>5} citations ({share_str} share)")
96
+ print()
97
+
98
+ # Competitor ranking
99
+ competitors = data.get("competitors") or []
100
+ if competitors:
101
+ print(" Brand Citation Ranking:")
102
+ for i, c in enumerate(competitors[:10], 1):
103
+ name = c.get("name", "?")
104
+ count = c.get("citation_count", 0)
105
+ share = c.get("citation_share")
106
+ share_str = f"{share:.1f}%" if share is not None else "--"
107
+ marker = " ← you" if c.get("is_our_brand") else ""
108
+ print(f" #{i:<2} {name:<20} {count:>5} ({share_str}){marker}")
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env python3
2
+ """Retrieve citation test results for a GEO prompt.
3
+
4
+ Usage:
5
+ # Results for a single prompt (all platforms):
6
+ python3 scripts/get_results.py --prompt-id <id> [--project-id <id>] [--json]
7
+
8
+ # Aggregated cited URLs across multiple prompts:
9
+ python3 scripts/get_results.py --aggregate --prompt-ids <id1,id2,...> [--project-id <id>]
10
+ """
11
+ import sys
12
+ import os
13
+ import argparse
14
+
15
+ sys.path.insert(0, os.path.dirname(__file__))
16
+ from _client import get_api_config, get_project_id, api_get, api_post, extract_data, print_json
17
+
18
+ parser = argparse.ArgumentParser(description="View GEO citation test results")
19
+ parser.add_argument("--project-id", help="Project ID (or set GEO_PROJECT_ID env var)")
20
+ parser.add_argument("--prompt-id", help="Prompt ID to retrieve results for")
21
+ parser.add_argument("--prompt-ids", help="Comma-separated prompt IDs (used with --aggregate)")
22
+ parser.add_argument("--test-id", help="Citation test ID for a single-test detail lookup")
23
+ parser.add_argument("--aggregate", action="store_true",
24
+ help="Aggregate cited URLs across multiple prompts")
25
+ parser.add_argument("--start-date", help="Filter by start date (YYYY-MM-DD), uses analytics endpoint")
26
+ parser.add_argument("--end-date", help="Filter by end date (YYYY-MM-DD), uses analytics endpoint")
27
+ parser.add_argument("--show-response", action="store_true",
28
+ help="Print the full AI response text for each test")
29
+ parser.add_argument("--json", action="store_true", help="Output raw JSON")
30
+ args = parser.parse_args()
31
+
32
+ key, base = get_api_config()
33
+ pid = get_project_id(args.project_id)
34
+
35
+ STATUS_ICONS = {
36
+ "completed": "✓",
37
+ "running": "⏳",
38
+ "pending": "◌",
39
+ "failed": "✗",
40
+ }
41
+
42
+ # ── AGGREGATE MODE ────────────────────────────────────────────────────────────
43
+ if args.aggregate:
44
+ if not args.prompt_ids:
45
+ print("ERROR: --prompt-ids is required with --aggregate")
46
+ sys.exit(1)
47
+ prompt_ids = [p.strip() for p in args.prompt_ids.split(",") if p.strip()]
48
+ body = {"prompt_ids": prompt_ids}
49
+ result = api_post(f"/api/projects/{pid}/citation-tests/urls", key, base, body)
50
+ data = extract_data(result)
51
+
52
+ if args.json:
53
+ print_json(data)
54
+ sys.exit(0)
55
+
56
+ urls = data.get("urls") or []
57
+ total = data.get("total", len(urls))
58
+ print(f"Cited URLs ({total} found across {len(prompt_ids)} prompts):")
59
+ print()
60
+ for url in urls:
61
+ print(f" {url}")
62
+ sys.exit(0)
63
+
64
+ # ── SINGLE TEST DETAIL MODE ──────────────────────────────────────────────────
65
+ if args.test_id:
66
+ result = api_get(
67
+ f"/api/projects/{pid}/citation-tests/{args.test_id}",
68
+ key, base,
69
+ )
70
+ test = extract_data(result) or {}
71
+ if args.json:
72
+ print_json(test)
73
+ sys.exit(0)
74
+ platform = test.get("platform", "Unknown")
75
+ status = test.get("status", "pending")
76
+ cited = test.get("is_cited") or test.get("cited", False)
77
+ print(f"Citation test {args.test_id}")
78
+ print(f" Platform : {platform}")
79
+ print(f" Status : {STATUS_ICONS.get(status, '?')} {status}")
80
+ print(f" Brand cited : {'Yes' if cited else 'No'}")
81
+ citations = test.get("citations") or []
82
+ if citations:
83
+ print(f" Cited URLs : {len(citations)}")
84
+ for url in citations[:10]:
85
+ print(f" - {url}")
86
+ sys.exit(0)
87
+
88
+ # ── SINGLE PROMPT MODE ────────────────────────────────────────────────────────
89
+ if not args.prompt_id:
90
+ print("ERROR: One of --prompt-id, --test-id or (--aggregate + --prompt-ids) is required")
91
+ sys.exit(1)
92
+
93
+ # If date filters provided, use the analytics execution endpoint (supports date range)
94
+ if args.start_date or args.end_date:
95
+ params = {}
96
+ if args.start_date:
97
+ params["date_from"] = args.start_date
98
+ if args.end_date:
99
+ params["date_to"] = args.end_date
100
+ result = api_get(
101
+ f"/api/projects/{pid}/analytics/prompts/{args.prompt_id}/executions",
102
+ key, base, params=params,
103
+ )
104
+ data = extract_data(result)
105
+
106
+ if args.json:
107
+ print_json(data)
108
+ sys.exit(0)
109
+
110
+ items = data if isinstance(data, list) else (data or {}).get("items", [])
111
+ print(f"Citation executions for prompt {args.prompt_id}")
112
+ if args.start_date or args.end_date:
113
+ print(f" Date range: {args.start_date or '...'} → {args.end_date or '...'}")
114
+ print(f" Found: {len(items)} execution(s)")
115
+ print()
116
+ for e in items:
117
+ plat = e.get("platform", "?")
118
+ dt = (e.get("analyzed_at") or e.get("date") or "--")[:10]
119
+ mentioned = e.get("our_brand_mentioned", False)
120
+ rank = e.get("our_brand_rank")
121
+ print(f" [{plat}] {dt} Brand: {'Yes' if mentioned else 'No'}"
122
+ f"{f' Rank: #{rank}' if rank else ''}")
123
+ sys.exit(0)
124
+
125
+ result = api_get(
126
+ f"/api/projects/{pid}/citation-tests/prompts/{args.prompt_id}",
127
+ key, base,
128
+ )
129
+ data = extract_data(result)
130
+
131
+ if args.json:
132
+ print_json(data)
133
+ sys.exit(0)
134
+
135
+ tests = data if isinstance(data, list) else data.get("tests") or data.get("items") or [data]
136
+
137
+ # Handle case where a single test object is returned
138
+ if isinstance(data, dict) and "platform" in data:
139
+ tests = [data]
140
+
141
+ print(f"Citation results for prompt: {args.prompt_id}")
142
+ print()
143
+
144
+ for test in tests:
145
+ platform = test.get("platform", "Unknown")
146
+ status = test.get("status", "pending")
147
+ icon = STATUS_ICONS.get(status, "?")
148
+ cited = test.get("is_cited") or test.get("cited", False)
149
+ response = test.get("response_text") or ""
150
+ citations = test.get("citations") or []
151
+
152
+ print(f" [{platform}] {icon} {status}")
153
+ print(f" Brand cited : {'Yes ✓' if cited else 'No'}")
154
+
155
+ if citations:
156
+ print(f" Cited URLs : {len(citations)}")
157
+ for url in citations[:5]:
158
+ print(f" - {url}")
159
+ if len(citations) > 5:
160
+ print(f" ... and {len(citations) - 5} more")
161
+
162
+ if args.show_response and response:
163
+ print(f" AI Response :")
164
+ # Indent response for readability
165
+ for line in response[:800].splitlines():
166
+ print(f" {line}")
167
+ if len(response) > 800:
168
+ print(f" [... truncated, {len(response)} chars total ...]")
169
+
170
+ print()
171
+
172
+ # Summary
173
+ completed = [t for t in tests if t.get("status") == "completed"]
174
+ cited_tests = [t for t in completed if t.get("is_cited") or t.get("cited")]
175
+ print(f"Summary: {len(cited_tests)}/{len(completed)} completed tests cited your brand", end="")
176
+ pending_count = len([t for t in tests if t.get("status") in ("pending", "running")])
177
+ if pending_count:
178
+ print(f" ({pending_count} still running)", end="")
179
+ print()