@adgine/geo-cli 1.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +93 -0
- package/bin/adgine-geo.js +224 -0
- package/package.json +38 -0
- package/skills/.env.example +11 -0
- package/skills/README.md +247 -0
- package/skills/SKILL.md +117 -0
- package/skills/VERSION +1 -0
- package/skills/adgine-geo-aiagent/SKILL.md +200 -0
- package/skills/adgine-geo-aiagent/scripts/_client.py +341 -0
- package/skills/adgine-geo-aiagent/scripts/bot_traffic.py +278 -0
- package/skills/adgine-geo-aiagent/scripts/human_traffic.py +357 -0
- package/skills/adgine-geo-aiagent/scripts/page_analytics.py +317 -0
- package/skills/adgine-geo-aiagent/scripts/page_detail.py +273 -0
- package/skills/adgine-geo-analytics/API.md +89 -0
- package/skills/adgine-geo-analytics/SKILL.md +191 -0
- package/skills/adgine-geo-analytics/scripts/_client.py +341 -0
- package/skills/adgine-geo-analytics/scripts/get_overview.py +129 -0
- package/skills/adgine-geo-billing/SKILL.md +118 -0
- package/skills/adgine-geo-billing/scripts/_client.py +341 -0
- package/skills/adgine-geo-billing/scripts/get_credits.py +51 -0
- package/skills/adgine-geo-billing/scripts/get_credits_pricing.py +52 -0
- package/skills/adgine-geo-billing/scripts/get_subscription.py +48 -0
- package/skills/adgine-geo-billing/scripts/list_plans.py +66 -0
- package/skills/adgine-geo-brand/SKILL.md +193 -0
- package/skills/adgine-geo-brand/WORKFLOW.md +66 -0
- package/skills/adgine-geo-brand/scripts/_client.py +341 -0
- package/skills/adgine-geo-brand/scripts/generate_brand.py +95 -0
- package/skills/adgine-geo-brand/scripts/get_brand.py +67 -0
- package/skills/adgine-geo-brand/scripts/list_jobs.py +153 -0
- package/skills/adgine-geo-brand/scripts/update_brand.py +61 -0
- package/skills/adgine-geo-citation/SKILL.md +220 -0
- package/skills/adgine-geo-citation/scripts/_client.py +341 -0
- package/skills/adgine-geo-citation/scripts/create_tests.py +62 -0
- package/skills/adgine-geo-citation/scripts/get_aggregate.py +108 -0
- package/skills/adgine-geo-citation/scripts/get_results.py +179 -0
- package/skills/adgine-geo-content/SKILL.md +224 -0
- package/skills/adgine-geo-content/WORKFLOW.md +100 -0
- package/skills/adgine-geo-content/scripts/_client.py +341 -0
- package/skills/adgine-geo-content/scripts/generate_article.py +108 -0
- package/skills/adgine-geo-content/scripts/generate_outline.py +113 -0
- package/skills/adgine-geo-content/scripts/generate_titles.py +51 -0
- package/skills/adgine-geo-content/scripts/list_content.py +65 -0
- package/skills/adgine-geo-content/scripts/manage_content.py +90 -0
- package/skills/adgine-geo-content/scripts/manage_jobs.py +184 -0
- package/skills/adgine-geo-dashboard/SKILL.md +181 -0
- package/skills/adgine-geo-dashboard/scripts/_client.py +341 -0
- package/skills/adgine-geo-dashboard/scripts/check_integrations.py +139 -0
- package/skills/adgine-geo-dashboard/scripts/get_overview.py +155 -0
- package/skills/adgine-geo-dashboard/scripts/get_visibility_overview.py +93 -0
- package/skills/adgine-geo-docs/SKILL.md +60 -0
- package/skills/adgine-geo-domains/SKILL.md +223 -0
- package/skills/adgine-geo-domains/scripts/_client.py +341 -0
- package/skills/adgine-geo-domains/scripts/get_domain.py +75 -0
- package/skills/adgine-geo-domains/scripts/list_domains.py +66 -0
- package/skills/adgine-geo-domains/scripts/search_domains.py +85 -0
- package/skills/adgine-geo-integrations/SKILL.md +188 -0
- package/skills/adgine-geo-integrations/scripts/_client.py +341 -0
- package/skills/adgine-geo-integrations/scripts/cloudflare_connect.py +155 -0
- package/skills/adgine-geo-integrations/scripts/cloudflare_worker.py +212 -0
- package/skills/adgine-geo-integrations/scripts/ga4_data.py +192 -0
- package/skills/adgine-geo-integrations/scripts/ga4_setup.py +120 -0
- package/skills/adgine-geo-opportunities/SKILL.md +162 -0
- package/skills/adgine-geo-opportunities/scripts/_client.py +341 -0
- package/skills/adgine-geo-opportunities/scripts/get_opportunity.py +90 -0
- package/skills/adgine-geo-opportunities/scripts/list_opportunities.py +92 -0
- package/skills/adgine-geo-performance/SKILL.md +177 -0
- package/skills/adgine-geo-performance/scripts/_client.py +341 -0
- package/skills/adgine-geo-performance/scripts/analyze_page.py +100 -0
- package/skills/adgine-geo-projects/SKILL.md +279 -0
- package/skills/adgine-geo-projects/scripts/_client.py +341 -0
- package/skills/adgine-geo-projects/scripts/check_auth.py +45 -0
- package/skills/adgine-geo-projects/scripts/list_projects.py +47 -0
- package/skills/adgine-geo-projects/scripts/manage_competitors.py +122 -0
- package/skills/adgine-geo-projects/scripts/manage_project.py +88 -0
- package/skills/adgine-geo-saas/SKILL.md +144 -0
- package/skills/adgine-geo-saas/scripts/_client.py +341 -0
- package/skills/adgine-geo-saas/scripts/check_domain.py +43 -0
- package/skills/adgine-geo-saas/scripts/create_website.py +56 -0
- package/skills/adgine-geo-saas/scripts/get_task.py +94 -0
- package/skills/adgine-geo-site-audit/README.md +281 -0
- package/skills/adgine-geo-site-audit/SKILL.md +449 -0
- package/skills/adgine-geo-site-audit/requirements.txt +5 -0
- package/skills/adgine-geo-site-audit/scripts/_client.py +341 -0
- package/skills/adgine-geo-site-audit/scripts/_geo_notice.py +55 -0
- package/skills/adgine-geo-site-audit/scripts/export_skill_package.py +197 -0
- package/skills/adgine-geo-site-audit/scripts/geo_collect.py +2443 -0
- package/skills/adgine-geo-site-audit/scripts/geo_score.py +490 -0
- package/skills/adgine-geo-site-audit/scripts/geo_timing.py +399 -0
- package/skills/adgine-geo-site-audit/scripts/geo_visibility.py +718 -0
- package/skills/adgine-geo-site-audit/scripts/render_report_pdf.py +1502 -0
- package/skills/adgine-geo-topics/SKILL.md +232 -0
- package/skills/adgine-geo-topics/scripts/_client.py +341 -0
- package/skills/adgine-geo-topics/scripts/generate_prompts.py +140 -0
- package/skills/adgine-geo-topics/scripts/manage_prompts.py +153 -0
- package/skills/adgine-geo-topics/scripts/manage_topics.py +145 -0
- package/skills/adgine-geo-visibility/SKILL.md +168 -0
- package/skills/adgine-geo-visibility/scripts/_client.py +341 -0
- package/skills/adgine-geo-visibility/scripts/get_execution.py +151 -0
- package/skills/adgine-geo-visibility/scripts/get_matrix.py +111 -0
- package/skills/adgine-geo-visibility/scripts/get_prompt_metrics.py +104 -0
- package/skills/adgine-geo-visibility/scripts/get_topic_metrics.py +201 -0
- package/skills/adgine-geo-visibility/scripts/get_visibility.py +166 -0
- package/skills/adgine-geo-wordpress/SKILL.md +151 -0
- package/skills/adgine-geo-wordpress/scripts/_client.py +341 -0
- package/skills/adgine-geo-wordpress/scripts/list_publishable.py +62 -0
- package/skills/adgine-geo-wordpress/scripts/manage_credentials.py +139 -0
- package/skills/adgine-geo-wordpress/scripts/manage_publishes.py +108 -0
- package/skills/adgine-geo-wordpress/scripts/publish.py +79 -0
- package/skills/scripts/check_version.py +290 -0
- package/skills/setup.py +248 -0
- package/src/config.js +126 -0
- package/src/doctor.js +137 -0
- package/src/install.js +90 -0
- package/src/manifest.js +99 -0
- package/src/runner.js +159 -0
package/skills/SKILL.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: adgine/geo
|
|
3
|
+
description: >
|
|
4
|
+
Adgine GEO 平台完整技能套件,包含 18 个专项技能,覆盖生成式引擎优化(GEO)全流程:
|
|
5
|
+
网站技术审计、AI 引用测试、AI 可见性分析、内容生成、品牌画像、主题词管理、
|
|
6
|
+
WordPress 发布、GA4/Cloudflare 集成、域名管理、项目管理、账单查询、使用手册等。
|
|
7
|
+
触发词:GEO / Adgine / AI visibility / AI citation / 生成式引擎优化 / AI 可见性 /
|
|
8
|
+
引用测试 / 内容生成 / 网站审计 / 品牌画像 / WordPress 发布 / 流量分析 /
|
|
9
|
+
AI 爬虫追踪 / 优化机会 / 页面健康度 / 域名 / 账单 / 积分 / 项目管理.
|
|
10
|
+
version: "1.2.4"
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## 版本检测(路由到任何子技能之前的第一步,必须执行)
|
|
14
|
+
|
|
15
|
+
在将用户请求路由到任何子技能之前,**先单独运行**版本检测(必须是本轮第一个 bash 命令):
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
python3 <skills-root>/scripts/check_version.py --human
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`<skills-root>` 是本仓库根目录(含 `VERSION`、`setup.py` 及所有 `adgine-geo-*` 文件夹)。
|
|
22
|
+
|
|
23
|
+
**WorkBuddy 特别注意:** WorkBuddy 会改写业务脚本的终端输出,可能丢掉嵌入的 `_notice` 或页脚。
|
|
24
|
+
因此 **必须** 用上面的 `--human` 命令做独立检测;若输出不为空,**在用户回复的最后一段原文包含该句**,禁止只展示业务结果而省略版本信息。
|
|
25
|
+
|
|
26
|
+
处理规则:
|
|
27
|
+
- 脚本失败 / 超时 / 输出为空:静默忽略,继续路由和响应
|
|
28
|
+
- `update_available=false`(`--human` 无输出):不输出任何版本相关内容
|
|
29
|
+
- 有更新(`--human` 有输出)或 JSON 模式 `update_available=true`:完成用户当前请求后,在回复末尾追加更新提示(见下方模板)。同一轮对话只提示一次。
|
|
30
|
+
|
|
31
|
+
## 版本更新提示(脚本自动 + 必须处理)
|
|
32
|
+
|
|
33
|
+
本套件的**每个脚本**在运行时也会自动做版本检查(对比 GitHub 上的
|
|
34
|
+
`VERSION`)。如果有新版本,脚本会在**工具输出(stdout)第一行**打印
|
|
35
|
+
`_notice`,格式为:
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
_notice: {"update": {"current": "1.1.5", "latest": "1.2.0", "message": "..."}}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
**处理规则(每次对话都适用):**
|
|
42
|
+
- 主动版本检测 **或** 任何一次工具调用的输出里出现 `_notice`,且判定有更新
|
|
43
|
+
→ 先正常完成用户当前请求,然后在回复末尾追加一条更新提示(见下方模板)。
|
|
44
|
+
同一轮对话只提示一次。
|
|
45
|
+
- 脚本还会在输出**末尾**打印人类可读页脚(以 `---` 和 `⚠️ adgine-geo-skills` 开头)。
|
|
46
|
+
**WorkBuddy 等会改写工具输出的 Agent 也必须把这段页脚原样展示给用户**,不得省略。
|
|
47
|
+
- 两条途径均未检测到更新 → 不输出任何版本相关内容。
|
|
48
|
+
|
|
49
|
+
**更新提示模板(`message` 里含「Tell me:」= git 安装):**
|
|
50
|
+
|
|
51
|
+
> 💡 **adgine-geo-skills 有新版本 {latest}**(当前 {current})
|
|
52
|
+
> 发给我下面这句话,我来帮你一键更新:
|
|
53
|
+
> `请帮我更新 adgine-geo-skills 到最新版本`
|
|
54
|
+
|
|
55
|
+
**更新提示模板(`message` 里含「Download:」= 打包安装):**
|
|
56
|
+
|
|
57
|
+
> 💡 **adgine-geo-skills 有新版本 {latest}**(当前 {current})
|
|
58
|
+
> 请前往 {下载地址} 下载最新版本,在平台 Skill 管理界面重新安装。
|
|
59
|
+
|
|
60
|
+
## 更新处理(当用户说"请帮我更新 adgine-geo-skills"时触发)
|
|
61
|
+
|
|
62
|
+
运行 `python3 <SKILL_DIR>/scripts/check_version.py` 拿到 `update_command`,
|
|
63
|
+
然后执行它(git 安装形如 `git -C <repo_root> pull`)。
|
|
64
|
+
成功后回复:「✅ 已更新到最新版本 {latest},重新开始对话即可使用新版功能。」
|
|
65
|
+
失败则输出错误信息供用户排查。
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
# Adgine GEO Skills Suite
|
|
70
|
+
|
|
71
|
+
本套件包含 18 个专项技能,覆盖 Adgine GEO 平台全流程。根据用户意图路由到对应子技能。
|
|
72
|
+
|
|
73
|
+
## 子技能路由表
|
|
74
|
+
|
|
75
|
+
| 子技能 | 用于 |
|
|
76
|
+
|---|---|
|
|
77
|
+
| adgine/geo-projects | 创建/列出/切换项目、管理竞争对手、配置 API Key(`GEO_API_KEY`)、验证鉴权 |
|
|
78
|
+
| adgine/geo-dashboard | 项目总览快照、7 天趋势、集成连接状态(首页指标 / Dashboard 概览) |
|
|
79
|
+
| adgine/geo-analytics | GA4 流量概览、活跃用户、AI 引荐汇总(不含爬虫明细) |
|
|
80
|
+
| adgine/geo-aiagent | AI 爬虫深度追踪:GPTBot / ClaudeBot / PerplexityBot、Sankey 流图、原始日志、页面级下钻 |
|
|
81
|
+
| adgine/geo-visibility | 读取 AI 可见性得分、Share of Voice、平均排名、竞品矩阵、历史 AI 回答 |
|
|
82
|
+
| adgine/geo-citation | 向 ChatGPT / Perplexity / Google AIO / Gemini 提交真实提示,测量品牌引用率 |
|
|
83
|
+
| adgine/geo-brand | 查看/生成/编辑 AI 品牌画像(ICP / 竞品分析 / 语气风格 / 写作规范)及生成任务管理 |
|
|
84
|
+
| adgine/geo-topics | 创建主题、批量生成 AI 搜索提示词、管理主题-提示词结构 |
|
|
85
|
+
| adgine/geo-content | 生成文章大纲和完整 GEO 文章、管理内容库、查看/重试内容生成任务 |
|
|
86
|
+
| adgine/geo-performance | 单页面 AI 优化健康度:可爬取性、AI 优化评分、内容健康(移动/桌面) |
|
|
87
|
+
| adgine/geo-opportunities | 发现 AI 识别的内容缺口和优化机会,按影响力(相关性/流量/竞争度/紧迫性)排序 |
|
|
88
|
+
| adgine/geo-integrations | 连接 GA4(OAuth)和 Cloudflare、部署 AI 爬虫追踪 Worker、触发数据同步 |
|
|
89
|
+
| adgine/geo-wordpress | 发布 GEO 文章到 WordPress、管理站点凭证和分类、查看发布历史、更新已发布文章 |
|
|
90
|
+
| adgine/geo-saas | 检查子域名可用性、创建并追踪 SaaS 托管网站部署任务 |
|
|
91
|
+
| adgine/geo-billing | 查询订阅套餐、订阅状态、积分余额和充值明细(只读,不执行购买) |
|
|
92
|
+
| adgine/geo-domains | 按关键词搜索可注册域名、列出已注册域名、查看域名详情 |
|
|
93
|
+
| adgine/geo-site-audit | 对任意公开 URL 做独立 GEO 技术审计(5 大维度 / 30 项检测 / 导出 PDF),**无需 API Key** |
|
|
94
|
+
| adgine/geo-docs | Adgine 平台使用手册、帮助文档、操作指南,**无需 API Key** |
|
|
95
|
+
|
|
96
|
+
## 工作流说明
|
|
97
|
+
|
|
98
|
+
部分功能需要多个子技能协作:
|
|
99
|
+
|
|
100
|
+
- **内容发布**:`geo-content`(生成文章)→ `geo-wordpress`(发布到 WordPress)
|
|
101
|
+
- **引用测试**:`geo-topics`(创建提示词)→ `geo-citation`(运行测试)→ `geo-visibility`(查看结果)
|
|
102
|
+
- **所有平台功能**:先用 `geo-projects` 配置 API Key 并选定项目,再使用其他子技能
|
|
103
|
+
|
|
104
|
+
## 安装配置
|
|
105
|
+
|
|
106
|
+
git clone 后运行一次:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
python3 setup.py <YOUR_GEO_API_KEY>
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
或交互式:`python3 setup.py`
|
|
113
|
+
|
|
114
|
+
## 更多信息
|
|
115
|
+
|
|
116
|
+
- 平台官网:[adgine.ai](https://adgine.ai/)
|
|
117
|
+
- GitHub:https://github.com/adgine-ai/adgine-geo-skills
|
package/skills/VERSION
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
1.2.8
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: adgine/geo-aiagent
|
|
3
|
+
description: Deep drill-down tracking for AI bot crawls and AI-driven human traffic
|
|
4
|
+
— 27 endpoints covering per-bot/per-platform/per-UA breakdowns, Sankey flows,
|
|
5
|
+
raw event logs, and per-page deep dives (KPI / logs / platforms / PageSpeed).
|
|
6
|
+
Use when the user wants detail-level data: AI 爬虫追踪 / which specific AI bots
|
|
7
|
+
crawl my site / GPTBot ClaudeBot PerplexityBot / 某个页面被哪些 AI 爬虫访问过 /
|
|
8
|
+
page detail / page sankey / Core Web Vitals / AI 真人访问来源明细.
|
|
9
|
+
NOT for high-level traffic summary — use adgine-geo-analytics for that.
|
|
10
|
+
NOT for running citation tests — use adgine-geo-citation.
|
|
11
|
+
NOT for page AI-optimization health scores — use adgine-geo-performance.
|
|
12
|
+
For brand/topic visibility metrics use adgine-geo-visibility.
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
# GEO AI-Agent Tracking
|
|
16
|
+
|
|
17
|
+
The biggest analytics skill — 27 endpoints under `/api/projects/{id}/ai-agent/*`.
|
|
18
|
+
Grouped into 4 scripts by user intent:
|
|
19
|
+
|
|
20
|
+
- **bot_traffic.py** — which AI bots crawl my site, how often, what kind
|
|
21
|
+
- **human_traffic.py** — what real human traffic AI platforms send me
|
|
22
|
+
- **page_analytics.py** — site-wide page rankings, Sankey flow, logs
|
|
23
|
+
- **page_detail.py** — deep-dive analytics for a specific page path
|
|
24
|
+
|
|
25
|
+
## 触发条件
|
|
26
|
+
|
|
27
|
+
当用户说出以下意图时使用本 skill:
|
|
28
|
+
- “哪些 AI 爬虫访问了我的网站” / “GPTBot/ClaudeBot/PerplexityBot” / “AI bot tracking”
|
|
29
|
+
- “某个页面被哪些 AI 爬虫访问过” / “页面爬虫日志” / “page detail”
|
|
30
|
+
- “AI 真人访问” / “AI 引荐来源明细” / “AI referral breakdown”
|
|
31
|
+
- “Sankey 图” / “页面流量流向” / “爬虫访问日志”
|
|
32
|
+
|
|
33
|
+
**⛔ 以下意图不属于本 skill:**
|
|
34
|
+
- “流量概览” / “流量汇总”(不涉及具体 bot 明细)→ **adgine-geo-analytics**
|
|
35
|
+
- “某个页面的 AI 优化健康度” / “页面可爬取性” → **adgine-geo-performance**
|
|
36
|
+
- “运行引用测试” / “品牌被引用了吗” → **adgine-geo-citation**
|
|
37
|
+
- “可见性得分” / “声量份额” → **adgine-geo-visibility**
|
|
38
|
+
|
|
39
|
+
## Output rules — IDs (apply to every reply)
|
|
40
|
+
|
|
41
|
+
These rules apply to **every list, table, and confirmation message** in this skill. Their goal: keep user-facing output friendly while preserving the IDs the agent needs internally.
|
|
42
|
+
|
|
43
|
+
1. **Lists & tables — never show raw UUIDs in cells.** Use a 1-based `#` index column instead. Keep a private mental mapping of `#N → actual UUID` so that follow-up commands like *"delete #3"*, *"run citation test on #1 #2"*, *"show details of the 2nd one"* resolve to the right entity.
|
|
44
|
+
- Index numbers restart from 1 in each new list — they are not stable across calls.
|
|
45
|
+
- If the user references *"the topic about X"* / *"that Poki vs CrazyGames prompt"*, match by visible content (name / title / domain / prompt text), not by ID.
|
|
46
|
+
|
|
47
|
+
2. **Single-item operations — prefer a human name over an ID.**
|
|
48
|
+
- ✅ *"Project **Poki vs Competitors** deleted."*
|
|
49
|
+
- ✅ *"Topic **Brand mentions in 2024** updated — name → 'Brand mentions 2025'."*
|
|
50
|
+
- ❌ *"Project `a4305b57-1c79-4cec-a17c-16eb1d959ea6` deleted."*
|
|
51
|
+
- If the entity has **no human-readable name** (e.g. an anonymous prompt or a job), use a short 8-character prefix: *"Prompt `2a2a8f4f…` deleted."* Never paste the full UUID.
|
|
52
|
+
|
|
53
|
+
3. **Always exception: `--json` mode.** When the user passes `--json` to a script or explicitly asks for raw JSON / debug output, print the script output verbatim — do not strip IDs.
|
|
54
|
+
|
|
55
|
+
4. **Internally, the agent still uses full UUIDs** for every API call (`--project-id`, `--topic-id`, `--prompt-id`, etc.). The display rules only affect what is shown back to the user.
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Step 1: Make sure GEO_API_KEY is configured
|
|
60
|
+
|
|
61
|
+
Scripts auto-load `GEO_API_KEY` from `<skills-root>/.env` on import — **no `export` needed, no shell restart needed**. To check the configuration, run any script (it prints the exact `.env` path if the key is missing).
|
|
62
|
+
|
|
63
|
+
- ✅ Key already in `<skills-root>/.env` → proceed.
|
|
64
|
+
- ❌ Key missing, or user just gave you a new key → go to the **adgine-geo-projects** skill, **Step 0**, which runs `python3 <skills-root>/setup.py <KEY>` to write the key into the correct `.env` file. **Never** write the key to `~/.zshrc`, `~/.bashrc`, Hermes global config, or any user-secrets store.
|
|
65
|
+
|
|
66
|
+
> ⚠️ **IMPORTANT:** In all shell/exec commands, always reference the key as `$GEO_API_KEY` (the environment variable). Never hardcode the literal value.
|
|
67
|
+
|
|
68
|
+
## Project selection
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
export GEO_PROJECT_ID=<project-id>
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Scripts
|
|
75
|
+
|
|
76
|
+
### bot_traffic.py — AI bot/crawler tracking
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
python3 scripts/bot_traffic.py overview # 5 KPI: citation/training/index/agent/total
|
|
80
|
+
python3 scripts/bot_traffic.py platforms # ranking: which AI platforms crawl most
|
|
81
|
+
python3 scripts/bot_traffic.py by-platform # bots grouped by AI platform
|
|
82
|
+
python3 scripts/bot_traffic.py types # index/training/assistant/agent split
|
|
83
|
+
python3 scripts/bot_traffic.py useragents # by specific UA (GPTBot, ClaudeBot...)
|
|
84
|
+
python3 scripts/bot_traffic.py pages-by-bot # per-bot top pages
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### human_traffic.py — humans driven by AI platforms
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
python3 scripts/human_traffic.py overview # 3 KPI: UTM / referral / share
|
|
91
|
+
python3 scripts/human_traffic.py platforms # human visits by AI platform
|
|
92
|
+
python3 scripts/human_traffic.py pages # human visits per page
|
|
93
|
+
python3 scripts/human_traffic.py platform-flow # Sankey: AI platform → landing page
|
|
94
|
+
python3 scripts/human_traffic.py referral # referral-only by source
|
|
95
|
+
|
|
96
|
+
# GA4-backed (requires GA4 connected):
|
|
97
|
+
python3 scripts/human_traffic.py ga-overview
|
|
98
|
+
python3 scripts/human_traffic.py ga-platforms
|
|
99
|
+
python3 scripts/human_traffic.py ga-landing-pages
|
|
100
|
+
python3 scripts/human_traffic.py ga-landing-flow
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### page_analytics.py — site-wide
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
python3 scripts/page_analytics.py overview-kpi # citation/index/training/agent/referral
|
|
107
|
+
python3 scripts/page_analytics.py pages # top AI-referenced pages
|
|
108
|
+
python3 scripts/page_analytics.py pages-detail # 5-metric table per page
|
|
109
|
+
python3 scripts/page_analytics.py pages-export --format csv > pages.csv
|
|
110
|
+
python3 scripts/page_analytics.py platform-flow # Sankey
|
|
111
|
+
python3 scripts/page_analytics.py logs # raw AI event logs
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### page_detail.py — specific page path
|
|
115
|
+
|
|
116
|
+
All commands require `--path /your/page/path`.
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
python3 scripts/page_detail.py kpi --path /blog/article
|
|
120
|
+
python3 scripts/page_detail.py logs --path /blog/article --limit 20
|
|
121
|
+
python3 scripts/page_detail.py platforms --path /blog/article
|
|
122
|
+
python3 scripts/page_detail.py related --path /blog/article
|
|
123
|
+
python3 scripts/page_detail.py health --path /blog/article
|
|
124
|
+
python3 scripts/page_detail.py health-refresh --path /blog/article # 15–60s blocking
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Metric vocabulary
|
|
128
|
+
|
|
129
|
+
| Code | Meaning |
|
|
130
|
+
|---|---|
|
|
131
|
+
| `ai_citation` | AI quoted / cited this page in an answer |
|
|
132
|
+
| `ai_index` | AI search index crawler hit |
|
|
133
|
+
| `ai_training` | AI training data crawler hit |
|
|
134
|
+
| `ai_agent` | AI agent (assistant tool-use) hit |
|
|
135
|
+
| `ai_referral` | A real human arrived on the site via an AI platform |
|
|
136
|
+
| `total_bots` | Sum of all AI bot requests |
|
|
137
|
+
|
|
138
|
+
## Platform codes
|
|
139
|
+
|
|
140
|
+
`openai` · `google_aio` · `perplexity` · `gemini` · `anthropic` · `meta`
|
|
141
|
+
(see `/api/projects/{id}/ai-agent/platforms` for the live list).
|
|
142
|
+
|
|
143
|
+
## Output Format
|
|
144
|
+
|
|
145
|
+
ASCII tables only inside fenced code blocks. Numeric formatting: integer
|
|
146
|
+
counts with thousands separator; "%" suffix only on percentage metrics;
|
|
147
|
+
"--" for null. Change-from-previous-period shown with explicit sign.
|
|
148
|
+
|
|
149
|
+
## Related endpoints
|
|
150
|
+
|
|
151
|
+
| Method | Path |
|
|
152
|
+
|---|---|
|
|
153
|
+
| GET | `/api/projects/{id}/ai-agent/bot-platforms` |
|
|
154
|
+
| GET | `/api/projects/{id}/ai-agent/bot-traffic-overview` |
|
|
155
|
+
| GET | `/api/projects/{id}/ai-agent/bot-types` |
|
|
156
|
+
| GET | `/api/projects/{id}/ai-agent/bot-useragents` |
|
|
157
|
+
| GET | `/api/projects/{id}/ai-agent/ga-landing-pages` |
|
|
158
|
+
| GET | `/api/projects/{id}/ai-agent/ga-overview` |
|
|
159
|
+
| GET | `/api/projects/{id}/ai-agent/ga-platform-landing-flow` |
|
|
160
|
+
| GET | `/api/projects/{id}/ai-agent/ga-platforms` |
|
|
161
|
+
| GET | `/api/projects/{id}/ai-agent/human-pages` |
|
|
162
|
+
| GET | `/api/projects/{id}/ai-agent/human-platform-flow` |
|
|
163
|
+
| GET | `/api/projects/{id}/ai-agent/human-platforms` |
|
|
164
|
+
| GET | `/api/projects/{id}/ai-agent/human-traffic-overview` |
|
|
165
|
+
| GET | `/api/projects/{id}/ai-agent/logs` |
|
|
166
|
+
| GET | `/api/projects/{id}/ai-agent/overview-kpi` |
|
|
167
|
+
| GET | `/api/projects/{id}/ai-agent/pages` |
|
|
168
|
+
| GET | `/api/projects/{id}/ai-agent/pages-by-bot` |
|
|
169
|
+
| GET | `/api/projects/{id}/ai-agent/pages-detail` |
|
|
170
|
+
| GET | `/api/projects/{id}/ai-agent/pages-detail/export` |
|
|
171
|
+
| GET | `/api/projects/{id}/ai-agent/pages-platform-flow` |
|
|
172
|
+
| GET | `/api/projects/{id}/ai-agent/pages/by-path/health` |
|
|
173
|
+
| POST | `/api/projects/{id}/ai-agent/pages/by-path/health/refresh` |
|
|
174
|
+
| GET | `/api/projects/{id}/ai-agent/pages/by-path/kpi` |
|
|
175
|
+
| GET | `/api/projects/{id}/ai-agent/pages/by-path/logs` |
|
|
176
|
+
| GET | `/api/projects/{id}/ai-agent/pages/by-path/platforms` |
|
|
177
|
+
| GET | `/api/projects/{id}/ai-agent/pages/by-path/related` |
|
|
178
|
+
| GET | `/api/projects/{id}/ai-agent/platforms` |
|
|
179
|
+
| GET | `/api/projects/{id}/ai-agent/referral-traffic` |
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## Post-task recommendations
|
|
184
|
+
|
|
185
|
+
After AI traffic analysis, suggest next analytical or action steps:
|
|
186
|
+
|
|
187
|
+
| What you analyzed | → use skill (agent-internal) |
|
|
188
|
+
|---|---|
|
|
189
|
+
| Bot traffic overview | 深入查看具体 bot 详情或 page detail *(→ adgine-geo-aiagent)*|
|
|
190
|
+
| Specific page traffic | 检查该页面的 AI 优化健康度 *(→ adgine-geo-performance)*|
|
|
191
|
+
| Human referral traffic | 查看 GA4 流量总览 *(→ adgine-geo-analytics)*|
|
|
192
|
+
| Sankey / platform flow | 针对高流量入口页生成优化内容 *(→ adgine-geo-content)*|
|
|
193
|
+
| PageSpeed / Core Web Vitals | 对整个网站做全面 GEO 审计 *(→ adgine-geo-site-audit)*|
|
|
194
|
+
| Low AI bot activity | 检查 Cloudflare Worker 是否正常部署 *(→ adgine-geo-integrations)*|
|
|
195
|
+
|
|
196
|
+
**⚠️ Output rule:** Do NOT write skill names (e.g. `adgine-geo-xxx`) in user-facing suggestions. Each suggestion must be phrased as a natural-language prompt the user can copy and send directly to the agent.
|
|
197
|
+
|
|
198
|
+
> 💡 **建议下一步:**
|
|
199
|
+
> 1. **[行动标题]** — *"[可直接发送给 AI 的自然语言提示词]"*
|
|
200
|
+
> 2. **[行动标题]** — *"[可直接发送给 AI 的自然语言提示词]"*
|
|
@@ -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
|