brand-geo-analysis-plus
中国互联网品牌感知监测统一工具。聚合全网热榜追踪(hub)、小红书/抖音/公众号近30天深度讨论研究(cn30)、豆包/Kimi/DeepSeek AI 搜索品牌可见度分析(geo)三个互补模块,帮助品牌方/市场运营/内容创作者从「话题热度—真实口碑—AI 回答」三层视角全面感知品牌。当用户需要研究中国社交媒体热点、分析品牌舆情、对比竞品口碑、分析品牌在 AI 搜索中的表现、做 GEO 优化、追踪全网热点时使用。触发词:品牌感知、舆情监测、社媒研究、热榜聚合、AI 搜索可见度、GEO 分析、跨平台舆情、热点追踪、品牌口碑、品牌竞品对比、社媒热榜。
Install
npx skills add https://github.com/redfox-data/redfox-community/tree/main/skills/brand-geo-analysis-plus
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install redfox-data-redfox-community@llmmart
git clone https://github.com/redfox-data/redfox-community.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole redfox-data/redfox-community collection as a plugin from our marketplace. Git is the plain clone.
README
品牌GEO分析Plus版 / brand-geo-analysis-plus
简介
一个 Skill、三层视角:实时全网热榜聚合 + 近 30 天社媒深度研究 + AI 搜索品牌可见度分析,帮你从「话题热度 → 真实口碑 → AI 回答」完整看清一个品牌或话题在中国互联网上的处境。
核心价值
- 三层视角互相补位:热榜看当下什么在火,社媒看用户真实怎么说,AI 搜索看大模型怎么介绍品牌。三层结合,避免只看单一数据源得出片面结论。
- 直接给结论,不给数据堆:热榜榜单、口碑洞察、GEO 得分、竞品对比、可视化报告,拿来即可用。
- 中文互联网原生化:平台与指标按国内社媒习惯设计,例如小红书以收藏/点赞比看「种草」信号、抖音以分享数看传播力、公众号以阅读量看关注度。
适用对象
- 🏢 品牌 / 市场运营 — 日常舆情监测、竞品口碑对比、品牌 AI 可见度诊断。
- 📈 增长 / GEO 负责人 — 看品牌在豆包、Kimi、DeepSeek 里被如何介绍,找优化方向。
- ✍️ 内容创作者 / 编导 — 追全网热点、判断哪些话题值得借势、找选题方向。
- 🔍 咨询 / 投放代理商 — 为客户产出一份有数据支撑的品牌感知报告。
功能特性
核心功能
- 全网热榜聚合:覆盖百度、知乎、微博、抖音、B站、快手、头条七大平台,按平台输出 TOP10,并附全网热点小结。
- 灵活的时间与范围取数:支持最新(小时级)、今日、昨日、本周热榜;可只看单个平台完整榜单,也可按关键词搜索热点,笼统的大类词会自动扩展为多个相关词。
- 热点趋势预测:对榜上热点给出后续走向判断,帮助判断是否值得跟进。
- 近 30 天社媒深度研究:在小红书、抖音、公众号三大平台检索真实用户讨论,输出数据速览、综合洞察与可视化报告。
- AI 搜索品牌可见度分析:向豆包、Kimi、DeepSeek 批量提问,给出 GEO 综合得分、品牌提及率、情绪倾向、竞品对比、信源引用等结论。
- 自动生成可视化报告:社媒研究与 AI 搜索分析完成后会自动产出交互式报告,无需另行要求。
- 完整品牌感知诊断:把热度、口碑、AI 回答三层发现合并为一份诊断报告,并给出分层的行动建议。
特色亮点
- 三层视角一体:热度、口碑、AI 回答三个层面由同一个工具串起来,一次诊断即可覆盖品牌感知的关键工序。
- 按需取用:只想做一件事时,用对应模块即可,不必跑完整流程。
- 互不影响:某个数据源临时不可用时,其余模块的产出照常交付。
密钥获取与安全说明
- 本技能需要使用环境变量:
REDFOX_API_KEY。 REDFOX_API_KEY由 红狐 hub (https://redfox.hk)提供。- 请前往 红狐 hub 注册账号,获取
REDFOX_API_KEY。 - 配置设备环境变量
REDFOX_API_KEY后使用本技能。 - 在提供密钥前,请先确认密钥来源、可用范围、有效期及是否支持重置/撤销。
- 禁止在代码、提示词、日志或输出文件中硬编码/明文暴露密钥。
使用指南
直接用自然语言说出你想了解的品牌、话题或热点即可,无需记忆任何命令。
常用说法速查
| 意图 | 示例话术 | 效果 |
|---|---|---|
| 看当下热点 | 「今天全网都在聊什么?」 | 七大平台热榜 TOP10 + 全网热点小结 |
| 按时间回看 | 「昨天热榜」「本周热点」 | 对应时间窗的跨平台榜单 |
| 搜特定方向 | 「搜一下体育热点」「苏超最近有什么热点」 | 大类词自动扩展为多个相关词;精确词直接用原词检索 |
| 只看某个平台 | 「微博热榜」「查看B站完整榜单」 | 该平台的完整榜单 |
| 盯住某个品牌 | 「最近一个月小红书上关于某品牌的讨论怎么样」 | 三大社媒平台近 30 天真实讨论 + 洞察 + 可视化报告 |
| 看 AI 怎么评 | 「看看豆包、Kimi 里是怎么介绍我们品牌的」 | GEO 得分、提及率、情绪、竞品对比、信源 + 交互式报告 |
| 做完整诊断 | 「帮我做一份某品牌的完整感知报告」 | 热度 → 口碑 → AI 回答三层合并报告 + 分层行动建议 |
| 持续监测 | 「订阅每日推送」 | 定时推送热榜精简版与热点小结 |
输出示例
想一次性看清一个品牌时,你会拿到一份分层的诊断结论,大致如下(示意):
一句话结论:该品牌在社媒端讨论活跃、口碑偏正面,但在 AI 搜索推荐类问题中的提及率明显偏低,存在可见度缺口。
1. 热度层:是否出现在近期热点中、覆盖哪些平台、趋势方向。 2. 口碑层:真实用户反馈、正负面信号、关键讨论主题。 3. AI 层:GEO 得分、提及率(含分平台拆分)、情绪分布、竞品对比、被引用的主要信源。 4. 行动建议:热度层是否需要借势、口碑层具体需改进的负面反馈、AI 层的 GEO 优化方向。
使用场景
| 场景 | 角色 | 示例问法 | 收益 |
|---|---|---|---|
| 品牌舆情监测 | 品牌 / 市场运营 | 「最近一个月关于我们品牌的讨论怎么样?」 | 快速掌握社媒真实口碑与正负面信号 |
| 竞品口碑对比 | 市场 / 增长 | 「对比一下 A 和 B 在小红书、抖音上的口碑」 | 看清双方在用户讨论中的差异与各自短板 |
| AI 可见度诊断 | 品牌 / GEO 负责人 | 「豆包、Kimi、DeepSeek 会怎么推荐我们这类产品?」 | 量化品牌在 AI 搜索中的可见度,定位优化方向 |
| 热点借势选题 | 内容创作者 / 编导 | 「今天全网在聊什么,有没有能蹭的热点?」 | 判断热点走向与借势时机,减少选题空转 |
| 完整品牌诊断 | 品牌负责人 / 咨询 | 「帮我做一份某品牌的完整感知报告」 | 一份覆盖热度、口碑、AI 回答三层的报告与行动建议 |
重要数据说明
- 热榜为小时级更新,默认统计「前一个完整小时」;今日、昨日、本周榜按对应时间窗统计。
- 社媒讨论研究的检索窗口为最近 30 天;AI 搜索分析为即时提问所得,结果会随时间和模型版本变化。
- AI 搜索分析单次提问量有限(通常 8 ~ 12 个问题),属小样本观察,建议与其他数据交叉判断。
- 品牌提及检测基于品牌名与别名的关键词匹配,同名品牌可能出现误匹配;部分 AI 平台不返回可引用的外部链接,此时信源为空属正常现象,并不代表品牌官网未被引用。
Skill manifest
品牌GEO分析Plus版
一句话定位
一个 Skill、三层视角:实时热榜聚合(hub)+ 近30天社媒深度研究(cn30)+ AI 搜索品牌可见度(geo),覆盖中国互联网品牌感知的全部关键场景。
何时使用
任意以下场景触发,立即使用本 skill:
- 想知道今天全网在聊什么、哪个热点值得追 → hub 模块
- 想研究某话题在过去30天的真实用户讨论、做品牌口碑/竞品对比 → cn30 模块
- 想看品牌在豆包/Kimi/DeepSeek AI 搜索里被如何介绍、品牌 AI 可见度如何 → geo 模块
- 想做完整的品牌感知诊断(热榜热度 → 真实口碑 → AI 回答) → 组合工作流
三个模块速览
| 模块 | 脚本入口 | 平台 | 时间维度 | 输出 |
|---|---|---|---|---|
| hub(热榜) | scripts/hub_fetch.py |
百度/知乎/微博/抖音/B站/快手/头条(7平台) | 实时(小时级)/ 昨日 / 本周 / 历史30天 | TOP10 表格 + 跨平台小结 + 趋势预测 |
| cn30(社媒研究) | scripts/cn30_search.py |
小红书/抖音/公众号(3平台) | 近30天任意天数 | 数据速览(TOP5×3平台)+ 综合洞察 + HTML 报告 |
| geo(AI 搜索可见度) | scripts/geo_search.py + scripts/geo_analyze.py + scripts/geo_report.py |
豆包/Kimi/DeepSeek(3 平台) | 即时提问 | GEO 得分 + 提及率 + 情绪 + 竞品 + 信源 + 交互式 HTML 报告 |
鉴权(共享)
三个模块共享同一个 REDFOX_API_KEY。从 红狐 hub 获取,配置方式:
export REDFOX_API_KEY=ak_你的密钥
或写入 ~/.openclaw/openclaw.json 的 env.REDFOX_API_KEY。优先级:命令行 --api-key > 环境变量 > 配置文件。
依赖安装(按模块):
| 模块 | 依赖 |
|---|---|
| hub | 标准库(urllib.request,无三方依赖) |
| cn30 | 标准库(urllib.request,无三方依赖) |
| geo | pip install requests |
路由决策树
根据用户意图选择模块。如果意图模糊,先用 AskUserQuestion 确认。
用户想做什么?
│
├─ 想知道今天/最近哪个话题/事件在全网火
│ └─→ hub 模块(实时热榜)
│
├─ 想研究某话题在用户真实讨论里的口碑/反馈
│ └─→ cn30 模块(小红书+抖音+公众号)
│
├─ 想看品牌/产品在 AI 搜索里被如何介绍
│ └─→ geo 模块
│
├─ 想做品牌诊断:热度 + 口碑 + AI 可见度 全维度
│ └─→ 组合工作流(hub → cn30 → geo)
│
└─ 给了具体平台/时间/品牌名
└─→ 根据字段路由到对应模块
模块一:hub(热榜聚合)
能力
实时抓取 7 大平台热搜数据,跨平台事件识别,TOP10 榜单,趋势预测,订阅推送。
平台代码
| 代码 | 平台 | 代码 | 平台 |
|---|---|---|---|
bd |
百度 | bz |
B站 |
zh |
知乎 | ks |
快手 |
wb |
微博 | tt |
头条 |
dy |
抖音 |
快速调用
# 最新热榜(前一个完整小时)
python3 ~/.workbuddy/skills/brand-geo-analysis-plus/scripts/hub_fetch.py \
--source "全平台热点事件-GitHub"
# 今日热榜(今日0:00 到当前整点)
python3 ~/.workbuddy/skills/brand-geo-analysis-plus/scripts/hub_fetch.py \
--source "全平台热点事件-GitHub" \
--start-date "T 00:00:00" --end-date "T HH:00:00"
# 昨日热榜 / 本周热榜 / 指定平台 / 关键词泛化
# 详见 references/hub-instructions.md
输出规范
按平台分类输出 TOP10,平台展示顺序固定为:百度 → 知乎 → 微博 → 抖音 → B站 → 快手 → 头条。
- 智能跳过空平台
- 数据≤10 条展示实际数量;>10 条显示「查看{平台名}完整榜单」引导
- 热度值已由脚本格式化完毕,直接使用
hotCount字段原值即可,不要做任何换算- 脚本内
format_hot_count()已处理:纯数字会折算为「数字+万」(5980000→598万);返回值已含单位("598万"、"345 万热度")的原样透传 - 禁止对
hotCount做整除/乘法运算(它是字符串,会TypeError),也禁止再拼接「万」(会得到「598万万」)
- 脚本内
- 完整模板见
references/hub-output-templates.md - 趋势预测逻辑见
references/hub-prediction-logic.md - 全部指令清单见
references/hub-instructions.md
关键规则
- 小时级更新:默认查询是「前一个完整小时」而非当前小时
- 关键词泛化:大词(体育/娱乐/科技等)自动扩展为10个相关词;精确词直接用原词
- 默认 compact 模式:stdout 输出极简(每平台仅预览 TOP3,表头会标注"(下表为 TOP3 预览)")+ 末尾附带
dataFile: {path},完整榜单必须从dataFile读 JSON,不要因为只看到 3 条就认为数据缺失 - 平台顺序已由脚本固定为 百度 → 知乎 → 微博 → 抖音 → B站 → 快手 → 头条,无需自行重排
模块二:cn30(社媒深度研究)
能力
从小红书、抖音、公众号三大平台搜索近30天真实用户讨论数据,跨平台综合分析舆情趋势,输出研究报告和可视化 HTML 报告。
平台代码
| 代码 | 平台 | 关键指标 |
|---|---|---|
xhs |
小红书 | 点赞/收藏/评论(收藏/点赞比=种草信号) |
dy |
抖音 | 点赞/评论/分享(分享数=传播力) |
gzh |
公众号 | 阅读/点赞/转发(阅读量=关注度) |
快速调用
python3 ~/.workbuddy/skills/brand-geo-analysis-plus/scripts/cn30_search.py \
"AI视频工具,大模型" \
--platforms xhs,dy,gzh \
--count 50 \
--days 30 \
--output-format both \
--output-dir ~/Documents/CnLast30Days
--output-format 支持 json / html / both。
工作流
- 环境检查:确认
REDFOX_API_KEY已配置 - 关键词质量检查:话题太模糊("工具"、"方法")时让用户具体化
- 预研究:并行 2-3 个 WebSearch 提取热词(小红书/抖音/通用)
- 查询计划:合并相关词为一次调用,每平台最多5个词,默认只调用1次引擎
- 运行引擎:前台运行(5分钟超时),读完整输出
- WebSearch 补充:1-2 次 WebSearch 覆盖知乎/B站/36氪(排除 xhs/dy/gzh 域名)
- 综合输出:按 输出规则 生成报告
输出规范(强制)
🇨🇳 cn-last30days v2.0.0 · {YYYY-MM-DD}
## 数据速览
(公众号 TOP5 → 小红书 TOP5 → 抖音 TOP5,标题作可点击 Markdown 链接)
我的发现:
**{话题}** - 1-2句描述,来源 [平台@作者](链接)
核心发现:
1. ...
2. ...
{引擎页脚逐字粘贴}
---
我是 {TOPIC} 的专家,我可以帮你:
- ...
HTML 报告已生成:[查看报告](HTML文件路径)
LAW 规则(任何一条违反都视为输出错误):
- LAW 1:结尾不要
Sources:/References:块 - LAW 2:通用查询以「我的发现:」开头,禁止
##/###章节标题;对比查询例外 - LAW 3:不用破折号(
—/–),用-(空格连字符空格) - LAW 4:引擎页脚逐字包含,位于核心发现之后、邀请之前
- LAW 5:每个引用用内联 Markdown 链接,不用裸 URL
- LAW 6:不要输出原始排名列表,转化为散文洞察
- LAW 7:徽章之后、「我的发现」之前必须有「数据速览」模块
完整模板见 references/cn30-output-rules.md。
HTML 报告(自动生成)
数据查询完成后,无需询问直接生成 HTML:
python3 ~/.workbuddy/skills/brand-geo-analysis-plus/scripts/cn30_search.py \
--from-json "JSON文件路径" \
--output-dir ~/Documents/CnLast30Days
open "HTML文件路径"
模块三:geo(AI 搜索品牌可见度)
能力
向豆包、Kimi、DeepSeek 三个 AI 搜索引擎批量提问,分析品牌出现率、情绪倾向、信源引用、竞品对比,生成交互式 HTML 报告。
平台代码
doubao / kimi / deepseek(默认全选,逗号分隔)
工作流
Step 0: 输入收集
必填:
- 品牌名(如「元气森林」、「大疆」、「蔚来」)
- 品类/行业(如「无糖饮料」、「无人机」、「新能源汽车」)
可选:
- 品牌别名(提高匹配精度)
- 竞品列表
- 自定义问题列表(有则跳过 Step 1)
如果只有品牌名没有品类,必须追问品类。
Step 1: 问题生成(用户未提供问题时)
通过 websearch 搜索品类相关问题,结合品类知识生成 8 个问题。
问题类型必须覆盖四类(每类至少 2 个,共 8 个,最多 12 个):
- 推荐类:「{品类}哪个品牌好?」(不直接含品牌名)
- 对比类:「{品牌A}和{品牌B}哪个好?」
- 评价类:「{品牌}怎么样?」(可含品牌名)
- 场景类:「{场景}用什么{品类}好?」(不直接含品牌名)
生成后必须用 AskUserQuestion 确认,等用户明确同意才能进入 Step 2。
Step 2: 批量搜索
python3 ~/.workbuddy/skills/brand-geo-analysis-plus/scripts/geo_search.py \
--queries '["问题1","问题2","问题3","问题4","问题5","问题6","问题7","问题8"]' \
--platforms doubao,kimi,deepseek
输出 output/search_results.json。脚本自动完成全部任务(3 平台 × N 问题)的并行提交与轮询,最长 15 分钟。
- 8 个问题 → 24 个任务,等待提示:「约需 5-8 分钟」
- 平台返回限流/故障话术(如豆包「高峰期算力紧张…请稍后再试」)时,脚本会自动重试 1 次;仍无效则记为
status: "invalid"并排除出统计(不计入提及率分母),不再当作「品牌未被提及」
Step 3: 确定性分析
python3 ~/.workbuddy/skills/brand-geo-analysis-plus/scripts/geo_analyze.py \
--brand "品牌名" \
--aliases '["别名1","别名2"]' \
--competitors '["竞品A","竞品B"]' \
--search-results output/search_results.json
输出 output/deterministic.json + output/ai_analysis_template.json。
Step 4: AI 分析(Agent 执行)
读取 output/search_results.json,只对 status == "completed" 的回答分析以下字段(invalid / failed / timeout 一律跳过,它们已被排除出统计):
brand_rank(int|null):推荐列表中的排名位置brand_context(str):上下文摘要sentiment(str):positive/neutral/negativesentiment_reason(str):判断依据competitors_mentioned(list[str]):所有提及的竞品competitor_details(list[dict]):每竞品的 rank + sentimentkey_claims(list[str]):2-3 条关键描述
写入 output/ai_analysis.json。
注意:正文中的内联引用标记(Kimi <REF>…</REF>、DeepSeek [citation:N])已由脚本在 Step 2 剥离,无需自行处理。
信源可用性:sources 为空是正常现象,常见于 Kimi(该平台只返回正文内联引用标记,不含可解析外链)。此时结果里会带 sources_note 字段,报告「信源分析」章节会自动展示数据完整性提示,不要把空信源当作「品牌官网未被引用」的结论。
情绪判断规则:
- 回答明确推荐/强调优势 →
positive - 客观描述/仅列举 →
neutral - 指出缺点/不推荐 →
negative
Step 5: 合并 + 报告生成
# 合并分析(Step 4 完成后)
python3 ~/.workbuddy/skills/brand-geo-analysis-plus/scripts/geo_analyze.py \
--brand "品牌名" --aliases '["别名"]' --competitors '["竞品"]' \
--search-results output/search_results.json \
--ai-analysis output/ai_analysis.json
# 生成 HTML
python3 ~/.workbuddy/skills/brand-geo-analysis-plus/scripts/geo_report.py \
--analysis output/analysis_result.json \
--search-results output/search_results.json
Step 6: 交付
- 交付
output/geo_report.html - 输出关键发现摘要(5 条核心结论):
- 跨平台提及率 + 分平台拆分
- GEO 综合得分 + 最佳/最差平台
- 情绪分布(正面率 = 正面数/有效回答数,中性不计入正面)
- 竞品对比 + 品牌排名
- TOP3 信源域名 + 官网是否被引用(若某平台无信源,须说明而非当作"未被引用")
核心指标
详细定义见 references/geo-metrics.md。核心公式:
GEO 得分 = 提及率得分 × 40% + 排名得分 × 30% + 情绪得分 × 30%
= 提及率×100 × 40% + max(0, 100 - (均排名-1)×10) × 30%
+ (正面占比×100 - 负面占比×50) × 30%
解读:70+优秀 / 50-69良好 / 30-49一般 / <30不足。
组合工作流:完整品牌感知诊断
当用户需要做全维度品牌诊断(典型问法:「帮我做一份品牌的完整感知报告」),依次执行:
阶段 1:hub 摸热度(5分钟)
python3 scripts/hub_fetch.py --source "全平台热点事件-GitHub" --keywords "{品牌}" --expand-keywords
产出:品牌是否在近期热点中、覆盖哪些平台、平均热度、上榜时长。
阶段 2:cn30 挖口碑(5-10分钟)
python3 scripts/cn30_search.py "{品牌},{品类}" --days 30 --output-format both
产出:真实用户讨论、情感倾向、TOP 反馈、HTML 报告。
阶段 3:geo 看 AI 回答(10-15分钟)
python3 scripts/geo_search.py --queries '[5 个问题]' --platforms doubao,kimi,deepseek
python3 scripts/geo_analyze.py --brand "{品牌}" --competitors '["{竞品}"]' --search-results output/search_results.json
# 步骤 4-5 见 geo 模块工作流
产出:品牌在 AI 搜索里的提及率、情绪、排名、信源、HTML 报告。
阶段 4:综合报告
把 hub/cn30/geo 三阶段发现合并成一份叙事文档,结构:
# {品牌} 品牌感知诊断报告
## 一句话结论
(综合三阶段结果的一句话判断)
## 1. 热度层(全网热榜)
- 是否在热点中 / 平台覆盖 / 趋势方向
## 2. 口碑层(社媒讨论)
- 真实用户反馈 / 正负面信号 / 关键讨论主题
## 3. AI 层(生成式搜索)
- GEO 得分 / 提及率 / 情绪分布 / 竞品对比 / 信源
## 4. 行动建议
- 热度层:{是否需要借势}
- 口碑层:{具体需改进的负面反馈}
- AI 层:{GEO 优化方向}
错误处理(通用)
| 情况 | 处理方式 |
|---|---|
未配置 REDFOX_API_KEY |
提示用户前往 红狐 hub 获取 |
| 关键词模糊("工具"、"方法") | 用 AskUserQuestion 让用户具体化 |
| cn30 引擎部分失败 | 继续分析已完成的数据,HTML 报告中标注失败项 |
| geo 任务全部超时 | 提示用户稍后重试,可能是 API 负载过高 |
| geo AI 分析结果缺失 | 仅生成确定性分析(提及率、域名),情绪模块标注"待分析" |
| 品牌名太短(≤2字) | 提示用户提供品牌别名以提高匹配精度 |
资源索引
脚本
| 文件 | 用途 |
|---|---|
scripts/hub_fetch.py |
7平台热榜聚合 |
scripts/cn30_search.py |
小红书/抖音/公众号近30天讨论搜索 + HTML 报告生成 |
scripts/geo_search.py |
AI 搜索批量调度器 |
scripts/geo_analyze.py |
GEO 确定性分析 + 合并分析 |
scripts/geo_report.py |
GEO HTML 报告生成 |
scripts/lib/platforms.py |
AI 平台适配器 |
scripts/lib/analyzer.py |
GEO 分析逻辑库 |
scripts/lib/report_template.py |
GEO HTML 模板库 |
参考资料
| 文件 | 何时加载 |
|---|---|
references/cn30-output-rules.md |
生成 cn30 报告时 |
references/hub-instructions.md |
处理 hub 用户指令时 |
references/hub-output-templates.md |
输出 hub 报告时 |
references/hub-prediction-logic.md |
做趋势预测时 |
references/geo-metrics.md |
计算 GEO 指标时 |
常见问答
Q: 三个模块必须都用吗? A: 不必。根据路由决策树按需调用。简单场景用一个模块即可,复杂诊断才用组合工作流。
Q: 为什么不把三个模块的脚本合并成一个 CLI? A: 三个数据源、调用逻辑、输出格式差异较大。脚本独立性更高,便于单独维护和升级。当一个数据源失败时不影响其他模块。
Q: 升级某个模块怎么办?
A: 单模块独立升级只需替换 scripts/ 下对应文件 + 更新对应 references/ 文档。统一 SKILL.md 不需要改动。
Q: 这三个模块能直接拿到本工作区使用吗?
A: 是的。skill 安装在 ~/.workbuddy/skills/brand-geo-analysis-plus/,所有 WorkBuddy 会话和 workspace 都可使用。
Files (redfox-community)
-
references
-
cn30-output-rules.md 4.3 KB
# 输出规则与模板 ## 输出规则(LAW) **徽章(输出第一行,强制):** ``` 🇨🇳 cn-last30days v{VERSION} · {YYYY-MM-DD} ``` **LAW 1** - 结尾不要 `Sources:` / `References:` 块,引擎页脚的 `🇨🇳 数据源:` 行是唯一引用。 **LAW 2** - 通用查询的正文以 `我的发现:` 开头,正文中禁止使用章节标题(`##`/`###`)。**例外**:徽章之后的「数据速览」是 LAW 7 强制要求的唯一 `##` 标题,不受此条限制(即正文只允许 `## 数据速览` 一个 H2,其余一律禁止)。对比查询例外,必须用 `# {A} vs {B}: 跨平台舆情对比`。 **LAW 3** - 不用破折号(`—`/`–`),用 ` - `(空格连字符空格)。 **LAW 4** - 引擎页脚逐字包含在输出中,位于核心发现之后、邀请之前。页脚内容取自脚本 stdout JSON 的 `engine_footer` 字段,**不得自行编造**。 **LAW 5** - 每个引用用内联 Markdown 链接 `[名称](url)`,不用裸 URL。 **LAW 6** - 不要输出原始排名列表,将引擎数据转化为散文洞察。 **LAW 7** - 徽章之后、「我的发现」之前,必须输出「数据速览」模块:按公众号→小红书→抖音顺序,每平台展示 TOP5 作品表格(标题超链接、作者、平台专属互动指标),模块末尾展示数据总量和 HTML 报告提示。公众号展示阅读/点赞/转发,小红书展示点赞/收藏/评论,抖音展示点赞/评论/分享。 --- ## 通用查询输出格式 ``` 🇨🇳 cn-last30days v{VERSION} · {YYYY-MM-DD} ## 数据速览 **公众号 TOP5** | 标题 | 作者 | 📖阅读 | 👍点赞 | 🔄转发 | |------|------|--------|--------|--------| | [{title}]({url}) | {author} | {reads} | {likes} | {shares} | | ... | ... | ... | ... | ... | **小红书 TOP5** | 标题 | 作者 | 👍点赞 | 💬评论 | ⭐收藏 | |------|------|--------|--------|--------| | [{title}]({url}) | {author} | {likes} | {comments} | {collects} | | ... | ... | ... | ... | ... | **抖音 TOP5** | 标题 | 作者 | 👍点赞 | 💬评论 | 🔄分享 | |------|------|--------|--------|--------| | [{title}]({url}) | {author} | {likes} | {comments} | {shares} | | ... | ... | ... | ... | ... | 共拉取 {total} 条数据(公众号 {gzh} 条 / 小红书 {xhs} 条 / 抖音 {dy} 条),更多数据可查看 HTML 报告。 --- 我的发现: **{话题总结1}** - [1-2句话描述] 来源 [小红书@{作者}](作者链接) **{话题总结2}** - [1-2句话] 来源 [抖音@{作者}](作者链接) 核心发现: 1. [发现] - 来源 [公众号{作者名}](文章链接) 2. [发现] - 来源 [小红书@{作者}](笔记链接) 3. [发现] - 来源 [抖音@{作者}](视频链接) {引擎页脚逐字粘贴(取自脚本 stdout JSON 的 engine_footer 字段,禁止编造)} --- 我是 {TOPIC} 的专家,我可以帮你: - [基于研究的具体问题] - [深入分析某个模式或辩论] 随时可以深入。 HTML 报告已生成:[查看报告](HTML文件路径) 💡 支持自由调整查询时间范围,比如近7天。是否需要每天9点将「当前研究话题」的最新研究和数据推送给你? ``` **引用格式规范:** - 小红书:`来源 [小红书@{作者}](作者链接)` 或 `来源 [小红书笔记](笔记链接)` - 抖音:`来源 [抖音@{作者}](作者链接)` 或 `来源 [抖音视频](视频链接)` - 公众号:`来源 [公众号{作者名}](文章链接)` - 链接取自引擎返回的 `url` 字段(作品链接)或 `author_link` 字段(作者链接),优先用作品链接 --- ## 对比查询输出格式 ``` 🇨🇳 cn-last30days v{VERSION} · {YYYY-MM-DD} # {A} vs {B}: 跨平台舆情对比 ## 一句话结论 [哪个性价比/口碑/热度更优] ## {实体1} - **优势**:[来源 [小红书@作者](链接)] - **劣势**:[来源 [公众号作者](链接)] ## {实体2} - **优势**:[来源 [抖音@作者](链接)] - **劣势**:[来源 [小红书@作者](链接)] ## 正面对比 | 维度 | {实体1} | {实体2} | |------|---------|---------| | 小红书讨论 | ... | ... | | 抖音热度 | ... | ... | | 公众号评价 | ... | ... | | 适合谁 | ... | ... | ## 结论 **选 {实体1} 如果** [场景] / **选 {实体2} 如果** [场景] {引擎页脚逐字粘贴(取自脚本 stdout JSON 的 engine_footer 字段,禁止编造)} ``` -
geo-metrics.md 5.2 KB
# GEO 指标定义说明 ## 概述 GEO(Generative Engine Optimization,生成式引擎优化)是衡量品牌在 AI 搜索引擎中可见度和表现的分析框架。本文档定义了 GEO Analyzer Skill 中使用的所有核心指标。 ## 核心指标 ### 1. 品牌提及率 (Brand Mention Rate) **定义**: 在所有问题中,品牌被 AI 搜索引擎提及的比例。 **计算公式**: ``` 品牌提及率 = 品牌被提及的回答数 / 已完成的回答总数 × 100% ``` **维度**: - 跨平台提及率: 所有平台的综合提及率 - 分平台提及率: 豆包、Kimi、DeepSeek 各自的提及率 **解读**: - 80%-100%: 品牌在 AI 搜索中高度可见 - 50%-79%: 品牌有一定可见度,但有提升空间 - 20%-49%: 品牌可见度较低,需要 GEO 优化 - 0%-19%: 品牌几乎不可见,存在严重的 AI 搜索盲区 **检测方法**: 脚本通过品牌名 + 别名进行大小写不敏感的关键词匹配。 ### 2. 品牌平均排名 (Brand Average Rank) **定义**: 在 AI 回答有明确品牌排序的情况下,品牌排名位置的均值。 **计算公式**: ``` 品牌平均排名 = 所有有排名数据的回答中品牌排名位置之和 / 有排名数据的回答数 ``` **解读**: - 1.0-2.0: 品牌处于推荐列表顶部 - 2.1-4.0: 品牌处于推荐列表中上部 - 4.1-6.0: 品牌处于推荐列表中下部 - 6.1+: 品牌处于推荐列表底部 **注意**: 并非所有回答都有明确的品牌排序。此指标仅基于有排序数据的回答计算。 ### 3. 情绪分布 (Sentiment Distribution) **定义**: AI 回答对品牌的整体情绪倾向的比例分布。 **分类标准**: - **正面 (positive)**: 回答明确推荐该品牌、强调其优势、给予积极评价 - **中性 (neutral)**: 回答客观描述品牌、仅列举名称无评价、或品牌未被提及 - **负面 (negative)**: 回答指出品牌缺点、不推荐、强调劣势 **维度**: - 整体情绪分布: 所有平台的综合分布 - 分平台情绪分布: 各平台的独立分布 **正负面占比口径**(报告摘要、品牌指纹、竞品表统一使用此口径): - 分母 = **全部有效回答数**(`status == "completed"`,平台限流/故障话术已标记为 `invalid` 并剔除) - **正面率 = positive / 有效回答数 × 100%** - **负面率 = negative / 有效回答数 × 100%** - 注意:`正面率 ≠ 100% - 负面率`,中性评价单独计数,不可并入正面 **解读**: - 正面占比 > 60%: 品牌在 AI 搜索中口碑良好 - 正面占比 30%-60%: 品牌口碑中等,有正有负 - 负面占比 > 30%: 品牌存在口碑风险,需要关注负面原因 ### 4. GEO 综合得分 (GEO Score) **定义**: 综合衡量品牌在 AI 搜索引擎中表现的单一分数(0-100)。 **计算公式**: ``` GEO 得分 = 提及率得分 × 40% + 排名得分 × 30% + 情绪得分 × 30% ``` **子得分计算**: - **提及率得分** = 品牌提及率 × 100 - **排名得分** = max(0, 100 - (平均排名 - 1) × 10)(排名越靠前分越高,无排名数据则为 0) - **情绪得分** = 正面占比 × 100 - 负面占比 × 50(范围 0-100) **解读**: - 70-100: 优秀 — 品牌在 AI 搜索中表现强劲 - 50-69: 良好 — 品牌有一定存在感,但有提升空间 - 30-49: 一般 — 品牌在 AI 搜索中存在感较弱 - 0-29: 不足 — 品牌在 AI 搜索中几乎不可见或口碑较差 ### 5. 信源引用分析 (Source Citation Analysis) **定义**: AI 搜索引擎在回答中引用的信息来源域名的统计。 **指标**: - **域名频次**: 每个域名在所有回答中被引用的次数 - **域名排行 TOP10**: 被引用最多的 10 个域名 - **分平台域名**: 各平台各自的引用域名分布 **价值**: - 了解 AI 搜索引擎依赖哪些信息源 - 判断品牌官网/官方渠道是否被引用 - 发现影响品牌 AI 可见度的关键信息源 ### 6. 竞品对比矩阵 (Competitor Comparison Matrix) **定义**: 用户品牌与竞品在相同维度上的对比分析。 **竞品来源**: 1. 用户指定的已知竞品 2. AI 从回答中自动发现的新竞品 3. 两者合并去重 **对比维度**: - 跨平台提及率对比 - 分平台提及率对比 - (如有 AI 分析数据)情绪分布对比 - (如有 AI 分析数据)平均排名对比 ## 辅助指标 ### 品牌提及矩阵 (Brand Mention Matrix) 以热力图形式展示品牌在每个问题 × 每个平台中的提及情况。 - 行: 10 个问题 - 列: 豆包 / Kimi / DeepSeek - 单元格: 提及次数(绿色)/ 未提及(灰色) **价值**: 精确定位品牌在哪些类型的问题中表现好或差。 ### 竞品提及率 (Competitor Mention Rate) 与品牌提及率计算方式相同,但针对每个竞品单独计算。 ## 指标局限性 1. **样本量限制**: 10 个问题 × 3 个平台 = 30 个数据点,统计意义有限 2. **时效性**: AI 搜索引擎的回答可能随时间变化,结果仅反映分析时点的情况 3. **关键词匹配局限**: 品牌提及检测基于关键词匹配,可能存在误匹配(如同名不同品牌) 4. **AI 分析主观性**: 情绪和排名分析依赖 AI 理解,不同模型可能给出不同判断 5. **平台差异**: 三个 AI 平台的搜索算法和回答风格不同,直接对比需注意这一因素 -
hub-instructions.md 4.5 KB
# 指令列表 本文档列出全平台聚合热点榜支持的所有用户指令。 ## 一、查询类指令 ### 1.1 查询最新热点 | 指令 | 说明 | |------|------| | 热点榜 | 查询当前最新热点榜(前一个完整小时) | | 今日热点 | 查询今日0:00到当前时间整点的热点榜 | | 全网热点榜 | 查询当前最新热点榜(前一个完整小时) | | 最新热榜 | 查询当前最新热点榜(前一个完整小时) | **输出内容**: - **📅 统计时间:** 小时级更新,热点上榜时间为{start_time}至{end_time} - **📊 覆盖平台:** {实际有数据的平台列表} - 各平台TOP10热点表格(百度、知乎、微博、抖音、B站、快手、头条) - 全网热点小总结 - 查看更多引导 - 订阅推送引导 --- ### 1.2 查询昨日热榜 | 指令 | 说明 | |------|------| | 昨日热榜 | 查询昨日0:00到24:00的热点榜 | | 昨天的热点 | 查询昨日热点榜 | | 昨天热榜 | 查询昨日热点榜 | **输出内容**: - **📅 统计时间:** 小时级更新,热点上榜时间为昨日00:00至24:00 - **📊 覆盖平台:** {实际有数据的平台列表} - 各平台TOP10热点表格 - 全网热点小总结 - 查看更多引导 - 订阅推送引导 --- ### 1.3 查询本周热点 | 指令 | 说明 | |------|------| | 本周热榜 | 查询本周一0:00到当前时间整点的热点榜 | | 本周热点 | 查询本周热点榜 | | 这周热榜 | 查询本周热点榜 | **输出内容**: - **📅 统计时间:** 小时级更新,热点上榜时间为本周一00:00至{当前时间} - **📊 覆盖平台:** {实际有数据的平台列表} - 各平台TOP10热点表格 - 全网热点小总结 - 查看更多引导 - 订阅推送引导 **本周时间计算规则**: - 本周一 = 今天日期 - (今天星期几 - 1)天 - 示例:今天是2026-05-09(周六,星期六),本周一 = 2026-05-09 - 5天 = 2026-05-04 - 错误示例:今天是2026-05-09(周六),本周一 = 2026-05-03(往前多算了一天) --- ### 1.4 查询特定平台热榜 | 指令 | 说明 | |------|------| | 百度热榜 | 查询百度平台的热点榜 | | 微博热榜 | 查询微博平台的热点榜 | | 抖音热榜 | 查询抖音平台的热点榜 | | B站热榜 | 查询B站平台的热点榜 | | 快手热榜 | 查询快手平台的热点榜 | | 知乎热榜 | 查询知乎平台的热点榜 | | 头条热榜 | 查询头条平台的热点榜 | **输出内容**: - **📅 统计时间:** 小时级更新,热点上榜时间为{start_time}至{end_time} - **📊 覆盖平台:** {平台名称} - 该平台完整50条热点数据 - 订阅推送引导 --- ### 1.4 关键词搜索热点 | 指令 | 说明 | |------|------| | 搜体育热点 | 搜索"体育"相关热点(自动泛化为体育、足球、篮球等10个关键词) | | 搜明星热点 | 搜索"明星"相关热点(自动泛化为明星、演员、歌手等) | | 搜苏超 | 搜索"苏超"相关热点(精确词,不泛化) | **关键词泛化策略**: - 大词(如"体育"、"娱乐"、"科技"):自动扩展为10个泛化词 - 精确词(如"苏超"、"某明星名字"):直接使用原词搜索 - 支持泛化的大类:体育、娱乐、科技、财经、社会、游戏、汽车、美食、旅游、时尚等 **输出内容**: - **📅 统计时间:** 小时级更新,热点上榜时间为{start_time}至{end_time} - **📊 覆盖平台:** {实际有数据的平台列表} - 匹配关键词的热点数据 - 订阅推送引导 --- ## 二、查看更多指令 | 指令 | 说明 | |------|------| | 查看百度完整榜单 | 查看百度平台完整50条热点 | | 查看知乎完整榜单 | 查看知乎平台完整50条热点 | | 查看微博完整榜单 | 查看微博平台完整50条热点 | | 查看抖音完整榜单 | 查看抖音平台完整50条热点 | | 查看B站完整榜单 | 查看B站平台完整50条热点 | | 查看快手完整榜单 | 查看快手平台完整50条热点 | | 查看头条完整榜单 | 查看头条平台完整50条热点 | **输出内容**: - **📅 统计时间:** 小时级更新,热点上榜时间为{start_time}至{end_time} - **📊 覆盖平台:** {平台名称} - 该平台完整50条热点表格 - 订阅推送引导 --- ## 三、订阅推送指令 | 指令 | 说明 | |------|------| | 订阅每日推送 | 每天定时推送最新热榜 | | 订阅每周推送 | 每周定时推送热门汇总 | **推送内容**: - 精简版TOP热点表格 - 全网热点小总结 - 查看详情引导 -
hub-output-templates.md 6.7 KB
# 输出模板参考 本文档包含全平台聚合热点榜的所有输出模板,供执行时参考。 ## 一、主输出模板(按平台分类) **【重要说明】** - 必须按平台分类输出,每个平台最多展示TOP10热点 - 平台顺序:百度 → 知乎 → 微博 → 抖音 → B站 → 快手 → 头条 - 每个平台的热点按热度值降序排列 - 平台展示格式:二级标题为"{平台名}热点"(如"百度热点"、"知乎热点") **【智能输出规则】** 1. 如果某平台没有数据(hotspots为空或不存在),则完全跳过该平台,不展示标题和空表格 2. 如果某平台数据少于10条,则展示实际数量(如只有5条就展示5条) 3. 查看更多引导根据实际剩余数据动态生成: - 数据≤10条:不显示查看更多引导 - 数据>10条:显示"💡 该平台还有{N}条数据未展示,回复「查看{平台名}完整榜单」可查看全部数据。" ```markdown # 🔥 全网热点榜(按平台分类) > **📅 统计时间:** 小时级更新,热点上榜时间为{start_time}至{end_time} > **📊 覆盖平台:** {实际有数据的平台列表} --- ## 百度热点 | 排名 | 热点 | 热度 | |:---:|------|:---:| | 1 | [{热点标题}]({url}) | {热度} | | 2 | [{热点标题}]({url}) | {热度} | | 3 | [{热点标题}]({url}) | {热度} | | 4 | [{热点标题}]({url}) | {热度} | | 5 | [{热点标题}]({url}) | {热度} | | 6 | [{热点标题}]({url}) | {热度} | | 7 | [{热点标题}]({url}) | {热度} | | 8 | [{热点标题}]({url}) | {热度} | | 9 | [{热点标题}]({url}) | {热度} | | 10 | [{热点标题}]({url}) | {热度} | 💡 该平台还有40条数据未展示,回复「查看百度完整榜单」可查看全部数据。 --- ## 知乎热点 | 排名 | 热点 | 热度 | |:---:|------|:---:| | 1 | [{热点标题}]({url}) | {热度} | | 2 | [{热点标题}]({url}) | {热度} | | 3 | [{热点标题}]({url}) | {热度} | | 4 | [{热点标题}]({url}) | {热度} | | 5 | [{热点标题}]({url}) | {热度} | {该平台只有5条数据,不显示查看更多引导} --- ## 微博热点 | 排名 | 热点 | 热度 | |:---:|------|:---:| | 1 | [{热点标题}]({url}) | {热度} | | 2 | [{热点标题}]({url}) | {热度} | | ... | ... | ... | | 10 | [{热点标题}]({url}) | {热度} | 💡 该平台还有35条数据未展示,回复「查看微博完整榜单」可查看全部数据。 --- {如果某平台无数据,则完全跳过,不展示任何内容} --- ## 📊 全网热点小总结 **今日最热**:{热度最高的热点标题}({热度},来自{平台}) **跨平台热度王**:{出现在最多平台的热点标题}(覆盖{N}个平台) **上升最快**:{热度增长最快的热点标题} **值得关注**:{智能体根据热度、平台覆盖、趋势综合判断的热点} --- 📬 **订阅推送**: - 回复「订阅每日推送」,每天定时推送最新热榜 - 回复「订阅每周推送」,每周定时推送热门汇总 ``` ## 二、平台完整榜单模板 当用户请求查看某平台完整榜单时使用: ```markdown # 百度热点完整榜单 > **📅 统计时间:** 小时级更新,热点上榜时间为{start_time}至{end_time} > **📊 覆盖平台:** {平台名称} | 排名 | 热点 | 热度 | |:---:|------|:---:| | 1 | [{热点标题}]({url}) | {热度} | | 2 | [{热点标题}]({url}) | {热度} | | 3 | [{热点标题}]({url}) | {热度} | | ... | ... | ... | | {实际总数} | [{热点标题}]({url}) | {热度} | --- 📬 **订阅推送**:回复「订阅每日推送」或「订阅每周推送」,定期获取热榜更新 ``` ## 三、订阅推送模板 ### 每日推送格式 ```markdown # 📊 今日热点榜 > **📅 统计时间:** 小时级更新,热点上榜时间为{start_time}至{end_time} > **📊 覆盖平台:** {实际有数据的平台列表} ## 🔥 全网TOP3 | 排名 | 热点 | 热度 | 来源平台 | |:---:|------|:---:|:---:| | 1 | {热点标题} | {热度} | {平台} | | 2 | {热点标题} | {热度} | {平台} | | 3 | {热点标题} | {热度} | {平台} | ## 📈 平台热点速览 **百度TOP3**:{热点1} | {热点2} | {热点3} **知乎TOP3**:{热点1} | {热点2} | {热点3} **微博TOP3**:{热点1} | {热点2} | {热点3} ## 💡 今日看点 {智能体总结的今日热点亮点,100字以内} --- 查看完整榜单请回复「热点榜」 ``` ### 每周推送格式 ```markdown # 📊 本周热点汇总 > **📅 统计时间:** 小时级更新,热点上榜时间为{开始日期}至{结束日期} > **📊 覆盖平台:** {实际有数据的平台列表} ## 🔥 本周最热TOP5 | 排名 | 热点 | 平均热度 | 覆盖平台 | |:---:|------|:---:|:---:| | 1 | {热点标题} | {热度} | {N}个平台 | | 2 | {热点标题} | {热度} | {N}个平台 | | ... | ... | ... | ... | ## 📈 本周趋势 **持续在榜**:{连续多天在榜的热点} **本周新秀**:{本周新上榜的热门话题} **本周焦点**:{本周最受关注的事件} --- 查看更多热点请回复「热点榜」 ``` ## 四、输出要求 ### 1. 智能输出逻辑 - **跳过空平台**:如果某平台没有数据,则完全不展示该平台 - **动态展示数量**:如果某平台数据少于10条,则展示实际数量 - **动态查看更多**: - 数据≤10条:不显示查看更多引导 - 数据>10条:计算剩余条数并显示对应提示 ### 2. 平台展示格式 - 二级标题:平台名(如"百度"、"知乎") - 三级标题:统一为"热点" - 平台顺序:百度 → 知乎 → 微博 → 抖音 → B站 → 快手 → 头条 ### 3. 统计时间格式 - 格式:**📅 统计时间:** 小时级更新,热点上榜时间为{start_time}至{end_time} - 示例:**📅 统计时间:** 小时级更新,热点上榜时间为2026-04-16 16:00至2026-04-16 17:00 - 使用查询的实际开始和结束时间 - "统计时间"标题必须保留并加粗 - 必须包含"小时级更新"字样 ### 4. 热度值格式 - **直接使用 `hotCount` 字段的原值,不要做任何换算**(脚本已格式化完毕) - 脚本内 `format_hot_count()` 的行为: - 纯数字会折算为"数字+万"(`5980000` → `598万`) - 返回值已含单位时原样透传(`"598万"`、`"345 万热度"` 保持原样) - **禁止**对 `hotCount` 做整除/乘法运算(该字段是字符串,会 `TypeError`) - **禁止**在 `{热度}` 占位符后追加"万"(会拼出"598万万") ### 5. 热点标题格式 - 有URL:显示为超链接 `[{标题}]({url})` - 无URL:仅显示文本 ### 5. 交互引导 - 每个平台根据实际数据量决定是否显示查看更多引导 - 如果有多个平台有剩余数据,在最后一个平台后统一显示订阅推送引导 - 订阅选项:每日推送、每周推送 -
hub-prediction-logic.md 3.6 KB
# 热度趋势预测逻辑 本文档详细说明热度趋势预测的判断规则和计算方法。 ## 预测维度 | 维度 | 权重 | 判断依据 | 数据字段 | |------|------|----------|---------| | 热度值 | 35% | maxHotScore值越高热度越高 | maxHotScore | | 平台覆盖 | 35% | platName分布,平台数越多越稳定 | platName计数 | | 排名表现 | 30% | maxPosition值越小排名越好 | maxPosition | ## 话题类型预测规则 ### 突发事件(地震、事故等) **热度曲线**:快速上升→急速下降 **生命周期**:12-24小时 **建议**:第一时间跟进,延后价值低 **预测特征**: - 热词包含:地震、事故、灾难、突发、爆炸、火灾等 - 初期热度极高,但衰减速度快 - 覆盖平台广但持续时间短 --- ### 娱乐八卦(明星、恋情等) **热度曲线**:快速上升→缓慢下降 **生命周期**:24-48小时 **建议**:12小时内跟进效果最佳 **预测特征**: - 热词包含:明星、恋情、绯闻、八卦、曝光、官宣等 - 热度上升快,下降相对缓慢 - 舆论讨论度高,容易产生二次发酵 --- ### 社会民生(政策、假期等) **热度曲线**:缓慢上升→稳定→缓慢下降 **生命周期**:48-72小时 **建议**:有较长跟进窗口 **预测特征**: - 热词包含:政策、假期、法规、民生、教育、医疗等 - 热度增长平稳,持续时间较长 - 影响面广,容易引发深度讨论 --- ### 行业动态(产品、价格等) **热度曲线**:中等速度上升→中等速度下降 **生命周期**:36-72小时 **建议**:可做深度分析内容 **预测特征**: - 热词包含:发布、产品、价格、行业、技术、创新等 - 热度变化相对温和 - 适合专业深度内容创作 --- ## 热度值分析(新增) | 热度区间 | 热度等级 | 趋势预测 | 行动建议 | |---------|---------|---------|---------| | <100万 | 低热度 | 可能即将退出榜单 | 观望为主 | | 100-500万 | 中等热度 | 热度相对稳定 | 可适度跟进 | | 500-1000万 | 高热度 | 热度持续强劲 | 值得重点跟进 | | >1000万 | 爆款热度 | 热度爆发状态 | 必须优先跟进 | **热度计算说明**: - maxHotScore字段直接反映热度值 - 热度值需换算为万单位展示(如8500000 → 850万) - 多平台热度值累加可评估全网总热度 ## 平台覆盖分析 | 平台数 | 热度稳定性 | 预测置信度 | 行动建议 | |--------|-----------|-----------|----------| | 1-2个 | 低 | 低 | 仅限特定平台 | | 3-4个 | 中 | 中 | 可适度跟进 | | 5-6个 | 高 | 高 | 值得重点跟进 | | 7-8个 | 极高 | 极高 | 必须优先跟进 | ## 排名表现分析(基于maxPosition) | 排名区间 | 表现 | 趋势预测 | 行动建议 | |---------|------|---------|---------| | TOP 1-3 | 榜首级 | 热度巅峰 | 全力跟进 | | TOP 4-10 | 头部 | 热度强劲 | 重点跟进 | | TOP 11-30 | 中部 | 热度稳定 | 适度跟进 | | TOP 31-50 | 尾部 | 热度较弱 | 观望评估 | **排名说明**: - maxPosition越小排名越好 - 多平台排名加权可评估全网表现 ## 综合预测输出格式 ### 首页热词快览 首页热词快览分析中,热度趋势预测输出格式: ```markdown 📈 **热度趋势预测** **综合预测**:{整体趋势描述},建议{内容建议} ``` ## 预测准确性说明 - 基于历史数据规律的统计预测 - 实际情况可能受突发事件影响 - 建议结合实时数据动态调整 - 话题类型判断基于关键词匹配,可能存在误判
-
-
scripts
-
lib
-
analyzer.py 22.2 KB
#!/usr/bin/env python3 """ GEO 分析逻辑库 — 确定性分析(脚本完成)+ AI 分析模板生成 确定性分析(无需 AI): - detect_mention(text, keywords) -> bool, count - extract_domains(sources) -> list[str] - aggregate_domains(results) -> dict[str, int] - compute_mention_rate(results, keywords, platform) -> float - compute_mention_matrix(results, queries, platforms, keywords) -> list[list[dict]] AI 分析模板: - build_ai_analysis_template(results, brand, competitors) -> list[dict] - build_analysis_prompt(answer, brand, competitors) -> str """ from urllib.parse import urlparse # ── 确定性分析 ───────────────────────────────────────────── def detect_mention(text, keywords): """检测关键词是否在文本中出现 Args: text: 待检测文本 keywords: 关键词列表(品牌名 + 别名) Returns: (mentioned: bool, count: int) """ if not text: return False, 0 text_lower = text.lower() count = 0 for kw in keywords: if not kw: continue # 大小写不敏感匹配 count += text_lower.count(kw.lower()) return count > 0, count def detect_all_mentions(text, brand_keywords, competitor_map): """检测品牌和所有竞品的提及情况 Args: text: 待检测文本 brand_keywords: 品牌关键词列表 competitor_map: {竞品名: [关键词列表]} Returns: { "brand_mentioned": bool, "brand_mention_count": int, "competitor_mentions": {竞品名: int} } """ brand_mentioned, brand_count = detect_mention(text, brand_keywords) competitor_mentions = {} for comp_name, comp_keywords in competitor_map.items(): _, count = detect_mention(text, comp_keywords) competitor_mentions[comp_name] = count return { "brand_mentioned": brand_mentioned, "brand_mention_count": brand_count, "competitor_mentions": competitor_mentions, } def extract_domains_from_sources(sources): """从引用来源列表中提取域名列表 Args: sources: list[dict],每项含 {"title", "url", "domain"} Returns: list[str]: 域名列表 """ domains = [] for s in sources: if not isinstance(s, dict): continue domain = s.get("domain", "") # 兜底: 上游未填 domain 时从 url 解析,避免静默全空 if not domain and s.get("url"): domain = extract_domain(s["url"]) if domain: domains.append(domain) return domains # 常见多段后缀,用于判断主域名边界 _MULTI_PART_TLDS = { "com.cn", "net.cn", "org.cn", "gov.cn", "edu.cn", "ac.cn", "com.hk", "com.tw", "com.mo", "co.jp", "co.kr", "co.uk", "com.au", "com.sg", "com.my", "com.br", } def extract_domain(url): """从 URL 中提取域名(去除 www. 前缀)""" if not url: return "" try: parsed = urlparse(url) domain = parsed.netloc or parsed.path.split("/")[0] return domain.replace("www.", "").lower() except Exception: return url.lower() def root_domain(domain): """取站点主域名,用于合并同一站点的子域 enterprise.dji.com -> dji.com m.it168.com / digital.it168.com -> it168.com www.gov.cn 类多段后缀按 com.cn 等规则保留三段 Args: domain: 完整域名 Returns: str: 主域名 """ d = (domain or "").lower().strip().lstrip(".") if not d or "." not in d: return d parts = d.split(".") if len(parts) <= 2: return d if ".".join(parts[-2:]) in _MULTI_PART_TLDS: return ".".join(parts[-3:]) if len(parts) >= 3 else d return ".".join(parts[-2:]) def merge_subdomains(domain_count): """把同一站点的子域合并计数,展示名取该站点出现次数最多的完整域名 Args: domain_count: {完整域名: 次数} Returns: dict[str, int]: {主域名: 次数},按次数降序 """ groups = {} for domain, count in domain_count.items(): root = root_domain(domain) groups.setdefault(root, {}) groups[root][domain] = groups[root].get(domain, 0) + count merged = {} for root, sub in groups.items(): merged[root] = sum(sub.values()) return dict(sorted(merged.items(), key=lambda x: -x[1])) def aggregate_domains(results, platform=None): """聚合统计引用域名频次 Args: results: 搜索结果列表 platform: 可选,限定平台 Returns: dict[str, int]: {域名: 出现次数},按频次降序 """ domain_count = {} for r in results: if platform and r.get("platform") != platform: continue if r.get("status") != "completed": continue for domain in extract_domains_from_sources(r.get("sources", [])): domain_count[domain] = domain_count.get(domain, 0) + 1 # 合并同一站点的子域,避免 dji.com / enterprise.dji.com 被当成两个信源 return merge_subdomains(domain_count) def compute_mention_rate(results, keywords, platform=None): """计算提及率 Args: results: 搜索结果列表 keywords: 关键词列表 platform: 可选,限定平台 Returns: float: 0.0 ~ 1.0 """ total = 0 mentioned = 0 for r in results: if platform and r.get("platform") != platform: continue if r.get("status") != "completed": continue total += 1 is_mentioned, _ = detect_mention(r.get("content", ""), keywords) if is_mentioned: mentioned += 1 return mentioned / total if total > 0 else 0.0 def build_mention_matrix(results, queries, platforms, brand_keywords): """构建品牌提及矩阵 Returns: list[list[dict]]: 矩阵 [问题数 x 平台数] 每个单元格: {"mentioned": bool, "mention_count": int, "platform": str, "question": str} """ matrix = [] for qi, query in enumerate(queries): row = [] for platform in platforms: # 找到对应的结果 cell = {"mentioned": False, "mention_count": 0, "platform": platform, "question": query} for r in results: if ( r.get("query_index") == qi and r.get("platform") == platform and r.get("status") == "completed" ): is_mentioned, count = detect_mention(r.get("content", ""), brand_keywords) cell["mentioned"] = is_mentioned cell["mention_count"] = count break row.append(cell) matrix.append(row) return matrix def compute_per_answer_analysis(results, brand_keywords, competitor_map): """对每份回答执行确定性分析 Returns: list[dict]: 每份回答的分析结果 """ per_answer = [] for r in results: if r.get("status") != "completed": per_answer.append({ "question": r.get("question", ""), "query_index": r.get("query_index", 0), "platform": r.get("platform", ""), "status": r.get("status", "unknown"), "brand_mentioned": False, "brand_mention_count": 0, "competitor_mentions": {}, "source_domains": [], }) continue mentions = detect_all_mentions( r.get("content", ""), brand_keywords, competitor_map ) domains = extract_domains_from_sources(r.get("sources", [])) per_answer.append({ "question": r.get("question", ""), "query_index": r.get("query_index", 0), "platform": r.get("platform", ""), "status": "completed", "brand_mentioned": mentions["brand_mentioned"], "brand_mention_count": mentions["brand_mention_count"], "competitor_mentions": mentions["competitor_mentions"], "source_domains": domains, }) return per_answer def compute_aggregate_metrics(per_answer, queries, platforms, brand, competitors): """计算聚合指标 Returns: dict: 聚合指标 """ # 品牌提及率(分平台 + 跨平台) brand_mention_rate = {"overall": 0.0} for p in platforms: brand_mention_rate[p] = 0.0 total_completed = 0 total_mentioned = 0 platform_stats = {p: {"total": 0, "mentioned": 0} for p in platforms} for ans in per_answer: if ans.get("status") != "completed": continue p = ans.get("platform", "") total_completed += 1 platform_stats.setdefault(p, {"total": 0, "mentioned": 0}) platform_stats[p]["total"] += 1 if ans.get("brand_mentioned"): total_mentioned += 1 platform_stats[p]["mentioned"] += 1 brand_mention_rate["overall"] = total_mentioned / total_completed if total_completed > 0 else 0.0 for p in platforms: stats = platform_stats.get(p, {"total": 0, "mentioned": 0}) brand_mention_rate[p] = stats["mentioned"] / stats["total"] if stats["total"] > 0 else 0.0 # 竞品提及率 competitor_mention_rates = {} for comp in competitors: rates = {"overall": 0.0} for p in platforms: rates[p] = 0.0 comp_total = 0 comp_mentioned = 0 comp_platform_stats = {p: {"total": 0, "mentioned": 0} for p in platforms} for ans in per_answer: if ans.get("status") != "completed": continue p = ans.get("platform", "") comp_total += 1 comp_platform_stats.setdefault(p, {"total": 0, "mentioned": 0}) comp_platform_stats[p]["total"] += 1 comp_count = ans.get("competitor_mentions", {}).get(comp, 0) if comp_count > 0: comp_mentioned += 1 comp_platform_stats[p]["mentioned"] += 1 rates["overall"] = comp_mentioned / comp_total if comp_total > 0 else 0.0 for p in platforms: stats = comp_platform_stats.get(p, {"total": 0, "mentioned": 0}) rates[p] = stats["mentioned"] / stats["total"] if stats["total"] > 0 else 0.0 competitor_mention_rates[comp] = rates # 域名统计 domain_stats = {} domain_stats_by_platform = {p: {} for p in platforms} for ans in per_answer: if ans.get("status") != "completed": continue p = ans.get("platform", "") for domain in ans.get("source_domains", []): domain_stats[domain] = domain_stats.get(domain, 0) + 1 domain_stats_by_platform.setdefault(p, {}) domain_stats_by_platform[p][domain] = domain_stats_by_platform[p].get(domain, 0) + 1 # 排序域名统计(合并同一站点的子域) domain_stats = merge_subdomains(domain_stats) for p in platforms: domain_stats_by_platform[p] = merge_subdomains(domain_stats_by_platform.get(p, {})) return { "brand_mention_rate": brand_mention_rate, "competitor_mention_rates": competitor_mention_rates, "domain_stats": domain_stats, "domain_stats_by_platform": domain_stats_by_platform, "total_completed": total_completed, "total_mentioned": total_mentioned, } # ── AI 分析模板 ───────────────────────────────────────────── AI_ANALYSIS_SCHEMA = { "brand_rank": None, # 品牌在推荐列表中的排名(未提及则为 null) "brand_context": "", # 品牌被提及时的上下文摘要 "sentiment": "", # positive / neutral / negative "sentiment_reason": "", # 情绪判断依据 "competitors_mentioned": [], # AI 回答中提及的竞品列表 "competitor_details": [], # 竞品详情: [{"name": str, "rank": int|null, "sentiment": str}] "key_claims": [], # 关于品牌的关键描述列表 } def build_ai_analysis_template(results, brand, competitors): """生成 AI 分析模板 JSON Agent 读取此模板后,对每份回答执行 AI 分析并填充字段 Returns: list[dict]: 每份回答的 AI 分析模板 """ template = [] for r in results: if r.get("status") != "completed": continue entry = { "question": r.get("question", ""), "query_index": r.get("query_index", 0), "platform": r.get("platform", ""), "brand": brand, "competitors": competitors, **AI_ANALYSIS_SCHEMA, } template.append(entry) return template def build_analysis_prompt(answer_content, brand, competitors): """构造单份回答的 AI 分析 prompt Agent 读取此 prompt 后,对 AI 回答内容执行分析并输出结构化 JSON """ competitors_str = "、".join(competitors) if competitors else "无" prompt = f"""请分析以下 AI 搜索引擎的回答内容,提取关于品牌「{brand}」的 GEO 分析信息。 ## 待分析品牌 - 品牌名: {brand} - 已知竞品: {competitors_str} ## AI 搜索引擎回答内容 --- {answer_content} --- ## 分析要求 请输出一个 JSON 对象,包含以下字段: 1. **brand_rank**: 品牌在回答的推荐列表或排名中的位置(数字)。如果回答列举了多个品牌/产品,品牌排第几位?未提及则为 null。 2. **brand_context**: 品牌被提及时的一句话上下文摘要。未提及则为空字符串。 3. **sentiment**: 回答对该品牌的整体情绪倾向。可选值: "positive"(正面/推荐/优势)、"neutral"(中性/客观描述)、"negative"(负面/劣势/不推荐)。未提及则为 "neutral"。 4. **sentiment_reason**: 情绪判断的依据,引用回答中的原文或概括原因。 5. **competitors_mentioned**: 回答中提及的所有竞品品牌名称列表(不限于已知竞品,发现新竞品也列出)。 6. **competitor_details**: 对每个被提及的竞品,提供其排名和情绪信息。格式为列表,每项含: name(竞品名)、rank(在推荐列表中的排名,无排名则为 null)、sentiment(positive/neutral/negative)。 7. **key_claims**: 关于该品牌的关键描述或评价(2-3条简短摘要)。 ## 输出格式 ```json {{ "brand_rank": null, "brand_context": "", "sentiment": "neutral", "sentiment_reason": "", "competitors_mentioned": [], "competitor_details": [], "key_claims": [] }} ``` 请确保输出是合法 JSON,不要包含注释或额外文本。""" return prompt def merge_analysis(deterministic, ai_analysis): """合并确定性分析和 AI 分析结果 Args: deterministic: 确定性分析结果列表 (per_answer) ai_analysis: AI 分析结果列表 Returns: list[dict]: 合并后的完整分析结果 """ # 创建 AI 分析的查找索引 ai_map = {} for ai in ai_analysis: key = (ai.get("query_index", 0), ai.get("platform", "")) ai_map[key] = ai merged = [] for det in deterministic: key = (det.get("query_index", 0), det.get("platform", "")) ai = ai_map.get(key, {}) merged.append({ # 确定性字段 "question": det.get("question", ""), "query_index": det.get("query_index", 0), "platform": det.get("platform", ""), "status": det.get("status", "unknown"), "brand_mentioned": det.get("brand_mentioned", False), "brand_mention_count": det.get("brand_mention_count", 0), "competitor_mentions": det.get("competitor_mentions", {}), "source_domains": det.get("source_domains", []), # AI 字段 "brand_rank": ai.get("brand_rank"), "brand_context": ai.get("brand_context", ""), "sentiment": ai.get("sentiment", "neutral"), "sentiment_reason": ai.get("sentiment_reason", ""), "ai_competitors_mentioned": ai.get("competitors_mentioned", []), "competitor_details": ai.get("competitor_details", []), "key_claims": ai.get("key_claims", []), }) return merged def compute_sentiment_distribution(merged_results, platforms): """计算情绪分布(分平台 + 跨平台) Returns: dict: { "overall": {"positive": int, "neutral": int, "negative": int}, "doubao": {...}, ... } """ distribution = {"overall": {"positive": 0, "neutral": 0, "negative": 0}} for p in platforms: distribution[p] = {"positive": 0, "neutral": 0, "negative": 0} for r in merged_results: if r.get("status") != "completed": continue sentiment = r.get("sentiment", "neutral") if sentiment not in ("positive", "neutral", "negative"): sentiment = "neutral" distribution["overall"][sentiment] += 1 p = r.get("platform", "") if p in distribution: distribution[p][sentiment] += 1 return distribution def compute_brand_avg_rank(merged_results, platforms): """计算品牌平均排名(在被提及的回答中) Returns: dict: {"overall": float|null, "doubao": float|null, ...} """ ranks = {"overall": []} for p in platforms: ranks[p] = [] for r in merged_results: if r.get("status") != "completed": continue rank = r.get("brand_rank") if rank is not None and isinstance(rank, (int, float)) and rank > 0: ranks["overall"].append(rank) p = r.get("platform", "") if p in ranks: ranks[p].append(rank) result = {} for key, values in ranks.items(): result[key] = sum(values) / len(values) if values else None return result def compute_competitor_metrics(merged_results, platforms, all_competitors): """计算每个竞品的平均排名和情绪分布 从 AI 分析的 competitor_details 字段中提取数据 对于在 ai_competitors_mentioned 中被提及但无显式排名的竞品, 根据其在列表中的位置推断隐式排名 Returns: dict: { competitor_name: { "avg_rank": {"overall": float|null, "doubao": ..., ...}, "sentiment": {"overall": {"positive": int, "neutral": int, "negative": int}, ...} } } """ result = {} for comp in all_competitors: ranks = {"overall": []} sentiment = {"overall": {"positive": 0, "neutral": 0, "negative": 0}} for p in platforms: ranks[p] = [] sentiment[p] = {"positive": 0, "neutral": 0, "negative": 0} for r in merged_results: if r.get("status") != "completed": continue p = r.get("platform", "") details = r.get("competitor_details", []) ai_mentioned = r.get("ai_competitors_mentioned", []) # 先处理有显式排名的竞品 matched_in_details = False got_rank = False for d in details: if not isinstance(d, dict): continue d_name = d.get("name", "") if comp.lower() not in d_name.lower() and d_name.lower() not in comp.lower(): continue matched_in_details = True d_rank = d.get("rank") if d_rank is not None and isinstance(d_rank, (int, float)) and d_rank > 0: ranks["overall"].append(d_rank) if p in ranks: ranks[p].append(d_rank) got_rank = True d_sent = d.get("sentiment", "neutral") if d_sent not in ("positive", "neutral", "negative"): d_sent = "neutral" sentiment["overall"][d_sent] += 1 if p in sentiment: sentiment[p][d_sent] += 1 # 如果在 competitor_details 中未找到或未获取排名,从 ai_competitors_mentioned 推断 if not got_rank: comp_lower = comp.lower() for idx, ai_name in enumerate(ai_mentioned): if not isinstance(ai_name, str): continue if comp_lower == ai_name.lower() or comp_lower in ai_name.lower() or ai_name.lower() in comp_lower: # 隐式排名: 在列表中的位置 + 1 implicit_rank = idx + 1 ranks["overall"].append(implicit_rank) if p in ranks: ranks[p].append(implicit_rank) # 如果在details中未匹配到情感,默认中性 if not matched_in_details: sentiment["overall"]["neutral"] += 1 if p in sentiment: sentiment[p]["neutral"] += 1 break avg_rank = {} for key, values in ranks.items(): avg_rank[key] = sum(values) / len(values) if values else None result[comp] = { "avg_rank": avg_rank, "sentiment": sentiment, } return result def compute_geo_score(mention_rate, avg_rank, sentiment_dist, max_rank=10): """计算 GEO 综合得分 (0-100) 加权: 提及率 40% + 排名 30% + 情绪 30% """ # 提及率得分 (0-100) mention_score = mention_rate * 100 # 排名得分 (0-100),排名越靠前分越高 if avg_rank is not None and avg_rank > 0: rank_score = max(0, 100 - (avg_rank - 1) * 100 / max_rank) else: rank_score = 0 # 情绪得分 (0-100) total = sum(sentiment_dist.values()) if total > 0: positive_ratio = sentiment_dist.get("positive", 0) / total negative_ratio = sentiment_dist.get("negative", 0) / total sentiment_score = positive_ratio * 100 - negative_ratio * 50 sentiment_score = max(0, min(100, sentiment_score)) else: sentiment_score = 50 # 加权综合 geo_score = mention_score * 0.4 + rank_score * 0.3 + sentiment_score * 0.3 return round(geo_score, 1) -
platforms.py 14.3 KB
#!/usr/bin/env python3 """ 3平台适配器 — 豆包 / Kimi / DeepSeek 统一接口 统一接口: submit(platform, query) -> task_id poll(platform, task_id) -> result_dict | None (None = still pending) extract_result(platform, raw_data) -> (content, sources) extract_result_full(platform, raw_data) -> dict (含信源可用性诊断) is_valid_answer(content) -> (valid, reason) """ import os import json import re import time from urllib.parse import urlparse import requests API_BASE = "https://redfox.hk" API_KEY = os.environ.get("REDFOX_API_KEY") PLATFORMS = { "doubao": { "submit_path": "/story/api/doubaoSearch/submit", "result_path": "/story/api/doubaoSearch/result", "label": "豆包", }, "kimi": { "submit_path": "/story/api/kimi/submit", "result_path": "/story/api/kimi/result", "label": "Kimi", }, "deepseek": { "submit_path": "/story/api/deepSearch/dsSubmit", "result_path": "/story/api/deepSearch/dsResult", "label": "DeepSeek", }, } SOURCE_TAG = "品牌GEO分析-GitHub" def _get_headers(): """返回统一请求头,若 API_KEY 缺失则抛异常""" if not API_KEY: raise ValueError( "未配置 REDFOX_API_KEY 环境变量," "请前往 https://redfox.hk/settings/api-keys?source=github 获取 API Key" ) return { "X-API-Key": API_KEY, "Content-Type": "application/json", } def submit(platform, query, source=None, max_retries=3): """提交搜索请求,返回 task_id Args: platform: 平台 key (doubao / kimi / deepseek) query: 搜索关键词 source: 来源标识,默认 SOURCE_TAG max_retries: 限流重试次数 Returns: task_id 字符串 Raises: ValueError: 平台未知或响应中无 taskId requests.RequestException: 网络错误 """ if platform not in PLATFORMS: raise ValueError(f"未知平台: {platform},可选: {list(PLATFORMS.keys())}") config = PLATFORMS[platform] url = f"{API_BASE}{config['submit_path']}" for attempt in range(max_retries + 1): resp = requests.post( url, json={"inquiry_text": query, "source": source or SOURCE_TAG}, headers=_get_headers(), timeout=30, ) resp.raise_for_status() data = resp.json() task_id = (data.get("data") or {}).get("taskId") if task_id: return task_id # 限流错误,等待后重试 code = data.get("code", 0) if code == 3108 and attempt < max_retries: wait = (attempt + 1) * 2 # 2s, 4s, 6s time.sleep(wait) continue raise ValueError(f"提交失败,未获取到 taskId: {json.dumps(data, ensure_ascii=False)}") raise ValueError(f"提交失败,重试 {max_retries} 次后仍未获取到 taskId") def poll(platform, task_id): """轮询单个任务状态 Returns: - dict: 任务完成时返回完整响应 JSON - None: 任务仍在处理中 - raises ValueError: 任务失败 """ if platform not in PLATFORMS: raise ValueError(f"未知平台: {platform}") config = PLATFORMS[platform] url = f"{API_BASE}{config['result_path']}" resp = requests.post( url, json={"taskId": task_id}, headers=_get_headers(), timeout=30, ) resp.raise_for_status() data = resp.json() # 状态字段可能在 data.data.status 或 data.status inner = data.get("data") or {} status = inner.get("status", "") if isinstance(inner, dict) else "" if not status: status = data.get("status", "") if status in ("completed", "success", "done"): return data if status in ("failed", "error"): raise ValueError(f"搜索任务失败: {json.dumps(data, ensure_ascii=False)}") return None # still pending # ── 回答有效性校验 ──────────────────────────────────────────── # 平台在限流/故障时会返回一段「话术」而非真实回答(实测豆包: # 「当前高峰期算力紧张,优先通道暂时繁忙。我们正在优先为你调度资源,请稍后再试。」)。 # 这类文本若被记为 completed,会让该平台凭空少一次品牌提及, # 直接压低提及率与 GEO 得分 —— 必须识别出来并从统计中剔除。 # 强错误标记:命中即判无效(与长度无关) HARD_ERROR_MARKERS = ( "算力紧张", "算力不足", "算力资源", "优先通道", "服务繁忙", "系统繁忙", "服务器繁忙", "当前繁忙", "请求过于频繁", "操作过于频繁", "访问过于频繁", "提问过于频繁", "当前高峰期", "调度资源", "服务暂时不可用", "系统开小差", "内容审核未通过", "涉嫌违规", "不符合相关规范", "无法回答该问题", "抱歉,我无法", "内容由AI生成,仅供参考", ) # 软错误标记:仅在文本明显偏短时才判无效(避免误伤正常回答里的同词) SOFT_ERROR_MARKERS = ( "请稍后再试", "请稍后重试", "请稍候再试", "稍后再试", "网络异常", "网络错误", "服务异常", "系统异常", "服务不可用", "请刷新页面", "请重新提问", "请换个问题", ) MAX_SOFT_LEN = 200 # 软标记判定的长度上限 MIN_VALID_LEN = 40 # 低于此长度一律视为异常响应 def is_valid_answer(content): """判断平台返回的是否为真实回答(而非限流/故障话术) Args: content: 平台返回的正文 Returns: (valid: bool, reason: str) reason 仅在不通过时有值 """ text = strip_inline_citations(content or "").strip() if not text: return False, "空回答" n = len(text) for marker in HARD_ERROR_MARKERS: if marker in text: return False, f"平台错误话术(命中「{marker}」)" if n < MIN_VALID_LEN: return False, f"内容过短({n} 字),疑似异常响应" if n < MAX_SOFT_LEN: for marker in SOFT_ERROR_MARKERS: if marker in text: return False, f"平台错误话术(命中「{marker}」)" return True, "" # ── 内联引用标记清洗 ────────────────────────────────────────── # 各平台的引用标记都指向引擎内部片段,不含真实 URL,必须从正文剥离, # 否则会原样出现在报告与原始存档里: # Kimi : <REF>cite✦tools://web_search:1#1:~:text=Mini 5 Pro...249g</REF> # DeepSeek: ⋯1999 元起[citation:1](成对出现,标记本身无跳转意义) _CITATION_RES = ( re.compile(r"<REF>.*?(?:</REF>|\Z)", re.S | re.I), re.compile(r"<ref>.*?(?:</ref>|\Z)", re.S | re.I), re.compile(r"cite\u2726tools://[^\s<\u3000]*"), re.compile(r"[\[\u3010]\s*citation\s*:\s*\d+\s*[\]\u3011]", re.I), re.compile(r"[\u2726\u2727]"), ) def strip_inline_citations(text): """剥离正文中的内联引用标记(Kimi <REF>…</REF> / DeepSeek [citation:N])""" if not text: return "" out = text for pattern in _CITATION_RES: out = pattern.sub("", out) # 清理残留的孤立标签与多余空格 out = re.sub(r"</?REF>", "", out, flags=re.I) out = re.sub(r"[ \t]{2,}", " ", out) # 标记被移除后留下的空行/行尾空格 out = re.sub(r"[ \t]+(\n)", r"\1", out) out = re.sub(r"\n{3,}", "\n\n", out) return out.strip() _URL_RE = re.compile(r"https?://[^\s<>\"'\uff0c\u3002\uff09\uff08\)\]\u3001]+") def extract_urls_from_text(text, limit=50): """从正文中提取裸 URL,用于平台未提供结构化信源时的兜底""" if not text: return [] seen, urls = set(), [] for m in _URL_RE.finditer(text): u = m.group(0).rstrip(".,;:") if u not in seen: seen.add(u) urls.append(u) if len(urls) >= limit: break return urls def extract_result(platform, raw_data): """从 API 原始响应中提取统一格式的内容和引用来源 三平台实际响应格式: 豆包: data.result.content + data.result.searchGuid[].text_card{title,url,sitename} Kimi: data.result.content(webPages 实测恒为空数组,引用只以正文内联 <REF>cite✦tools://…标记存在,不含真实 URL,故多数情况无信源) DeepSeek: data.result.content + data.result(待确认) 信源提取顺序: 结构化字段 → 通用字段 → 正文裸 URL 兜底 返回前会调用 strip_inline_citations() 清理正文中的内联引用标记。 Args: platform: 平台 key raw_data: poll() 返回的完整 JSON dict Returns: (content: str, sources: list[dict]) sources 每项: {"title": str, "url": str, "domain": str} """ data = raw_data.get("data") or raw_data # result 字段可能是 dict(豆包/Kimi/DeepSeek)或 list result_obj = data.get("result") # ── 提取 content ────────────────────────────────────── content = "" if isinstance(result_obj, dict): # 豆包/Kimi/DeepSeek: content 在 result.content content = ( result_obj.get("content") or result_obj.get("answer") or result_obj.get("text") or result_obj.get("response") or "" ) elif isinstance(result_obj, str): content = result_obj # 兜底: 直接在 data 层找 content if not content: content = ( data.get("content") or data.get("answer") or data.get("text") or data.get("response") or "" ) # ── 提取 sources ────────────────────────────────────── sources = [] if isinstance(result_obj, dict): # 豆包: searchGuid[].text_card{title, url, sitename} search_guid = result_obj.get("searchGuid") or [] for item in search_guid: if not isinstance(item, dict): continue card = item.get("text_card") or item url = card.get("url") or card.get("link") or "" title = card.get("title") or card.get("name") or card.get("sitename") or "" if url or title: sources.append({ "title": title, "url": url, "domain": extract_domain(url), }) # Kimi: webPages[] web_pages = result_obj.get("webPages") or [] for item in web_pages: if not isinstance(item, dict): continue url = item.get("url") or item.get("link") or item.get("href") or "" title = item.get("title") or item.get("name") or "" if url or title: sources.append({ "title": title, "url": url, "domain": extract_domain(url), }) # 通用: result.sources / result.references / result.citations if not sources: for key in ("sources", "references", "citations"): raw_sources = result_obj.get(key) if isinstance(raw_sources, list) and raw_sources: for s in raw_sources: if not isinstance(s, dict): continue url = s.get("url") or s.get("link") or s.get("href") or "" title = s.get("title") or s.get("name") or s.get("text") or "" sources.append({ "title": title, "url": url, "domain": extract_domain(url), }) break # 兜底: data 层的 sources if not sources: for key in ("sources", "references", "citations"): raw_sources = data.get(key) if isinstance(raw_sources, list) and raw_sources: for s in raw_sources: if not isinstance(s, dict): continue url = s.get("url") or s.get("link") or s.get("href") or "" title = s.get("title") or s.get("name") or s.get("text") or "" sources.append({ "title": title, "url": url, "domain": extract_domain(url), }) break # 最后兜底: 正文中的裸 URL(Kimi 的 webPages 恒为空,只能这样兜) # 注意必须从「剥离引用标记之前」的原文里取 if not sources: for url in extract_urls_from_text(content): domain = extract_domain(url) sources.append({"title": domain, "url": url, "domain": domain}) # 剥离内联引用标记(Kimi <REF>…</REF>),避免污染报告与存档 content = strip_inline_citations(content) return content, sources def extract_result_full(platform, raw_data): """在 extract_result 基础上附带信源可用性诊断 Returns: { "content": str, "sources": list[dict], "sources_available": bool, # 是否拿到了可解析的外链信源 "sources_note": str, # 不可用时的原因说明(可直接进报告) } """ content, sources = extract_result(platform, raw_data) available = bool(sources) note = "" if not available: if platform == "kimi": note = "Kimi 仅返回正文内联引用标记(cite✦tools://…),不含可解析外链,故无信源数据" else: note = f"{PLATFORMS.get(platform, {}).get('label', platform)} 本次响应未返回可解析的信源" return { "content": content, "sources": sources, "sources_available": available, "sources_note": note, } def extract_domain(url): """从 URL 中提取域名(去除 www. 前缀)""" if not url: return "" try: parsed = urlparse(url) domain = parsed.netloc or parsed.path.split("/")[0] return domain.replace("www.", "").lower() except Exception: return url.lower() -
report_template.py 55.4 KB
#!/usr/bin/env python3 """ GEO 报告 HTML 模板 (v6 - taste-skill 设计优化版) 设计: VARIANCE=4 / MOTION=2 / DENSITY=6 (taste-skill anti-slop) 主色: #0f766e (teal-700) | 字体: Outfit + Noto Sans SC + JetBrains Mono 签名: 数据驱动摘要 + 品牌指纹 + 信源双栏 + 竞品对比 v6 更新 (taste-skill优化): - 更紧凑的间距系统 (tighter spacing scale) - 更好的视觉层次 (section divider 替代 card border) - 统一的圆角系统 (all 8px radius) - 精简的阴影 (tinted shadow, 非纯黑) - Summary 区域更突出的视觉处理 - 导航胶囊化 (pill nav) """ import html as _html_mod import math import re from datetime import datetime PLATFORM_LABELS = {"doubao": "豆包", "kimi": "Kimi", "deepseek": "DeepSeek"} PLATFORM_COLORS = {"doubao": "#2563eb", "kimi": "#7c3aed", "deepseek": "#0891b2"} SENTIMENT_LABELS = {"positive": "正面", "neutral": "中性", "negative": "负面"} # 位置等级标签 POSITION_LABELS = { "leading": "领先", "upper": "上游", "mid": "中游", "trailing": "落后" } # 分数等级 GRADE_LABELS = { "Excellent": "优秀", "Good": "良好", "Fair": "一般", "Weak": "较弱", "N/A": "无数据" } # 域名分类规则 DOMAIN_CATEGORIES = { "nio.cn": "品牌官网", "nio.com": "品牌官网", "nio.com.cn": "品牌官网", "weibo.com": "社媒资讯", "weibo.cn": "社媒资讯", "zhihu.com": "社媒资讯", "zhuanlan.zhihu.com": "社媒资讯", "bilibili.com": "社媒资讯", "b23.tv": "社媒资讯", "douyin.com": "社媒资讯", "iesdouyin.com": "社媒资讯", "xiaohongshu.com": "社媒资讯", "xhslink.com": "社媒资讯", "toutiao.com": "社媒资讯", "m.toutiao.com": "社媒资讯", "36kr.com": "社媒资讯", "36氪": "社媒资讯", "sina.cn": "社媒资讯", "sina.com.cn": "社媒资讯", "auto.sina.cn": "社媒资讯", "k.sina.cn": "社媒资讯", "cj.sina.cn": "社媒资讯", "sohu.com": "社媒资讯", "qq.com": "社媒资讯", "163.com": "社媒资讯", "ifeng.com": "社媒资讯", "tech.ifeng.com": "社媒资讯", "autohome.com.cn": "汽车垂直", "chejiahao.m.autohome.com.cn": "汽车垂直", "pcauto.com.cn": "汽车垂直", "m.pcauto.com.cn": "汽车垂直", "dongchedi.com": "汽车垂直", "smzdm.com": "社媒资讯", "post.m.smzdm.com": "社媒资讯", "youjia-pc.bdstatic.com": "社媒资讯", } def _classify_domain(domain): """将域名分类为: 品牌官网/汽车垂直/社媒资讯/其他网站""" d = domain.lower().strip() if d in DOMAIN_CATEGORIES: return DOMAIN_CATEGORIES[d] for key, cat in DOMAIN_CATEGORIES.items(): if key in d or d.endswith("." + key): return cat return "其他网站" POS_PATTERNS = [ "换电", "服务", "豪华", "高端", "NPS", "可靠", "智能", "安全", "舒适", "静谧", "品质", "标杆", "体验", "补能", "质保", "上门服务", "省心", "稳定", "满足", "无焦虑", "口碑", "用户体验", "设计精致", "续航", "激光雷达", "性能", "内饰", "底盘", "保值", ] NEG_PATTERNS = [ "成本高", "不均", "不足", "少", "弱", "缺", "价格高", "贵", "维修", "等待", "局限", "不够", "漏洞", "发热", "噪音", "缺点", "不便", "保值率低", "小众", ] def _esc(text): return _html_mod.escape(_clean(str(text))) def _clean(text): return text.replace("\u2014", "-").replace("\u2013", "-") def _pct(num, den): return int(num / den * 100) if den else 0 def _score_grade(score): if not isinstance(score, (int, float)): return "无数据", "var(--muted)", "var(--surface-2)" if score >= 80: return "优秀", "var(--hit)", "var(--hit-light)" if score >= 60: return "良好", "var(--accent)", "var(--accent-light)" if score >= 40: return "一般", "var(--warn)", "var(--warn-light)" return "较弱", "var(--miss)", "var(--miss-light)" def _generate_summary(analysis, platforms, total_completed, total_mentioned, position): """生成GEO文字总结: 综合评估 + 各平台表现 + 发力方向""" brand = analysis.get("brand", "") geo_score = analysis.get("geo_score", {}) mention_rate = analysis.get("metrics", {}).get("brand_mention_rate", {}) sentiment_dist = analysis.get("sentiment_distribution", {}).get("overall", {}) brand_avg_rank = analysis.get("brand_avg_rank", {}) comparison = analysis.get("competitor_comparison", []) geo_o = geo_score.get("overall", 0) mr_overall = mention_rate.get("overall", 0) mr_pct = int(mr_overall * 100) total_sent = sum(sentiment_dist.values()) neg_count = sentiment_dist.get("negative", 0) pos_count = sentiment_dist.get("positive", 0) neu_count = sentiment_dist.get("neutral", 0) avg_rank_o = brand_avg_rank.get("overall") # --- 综合评估 --- if geo_o >= 80: grade_text = "处于领先水平" elif geo_o >= 60: grade_text = "处于中上游水平,仍有提升空间" elif geo_o >= 40: grade_text = "处于中游水平,需要重点关注" else: grade_text = "处于较弱水平,亟待改善" # --- 各平台表现 --- platform_lines = [] for p in platforms: label = PLATFORM_LABELS.get(p, p) s = geo_score.get(p, 0) mr_p = int(mention_rate.get(p, 0) * 100) p_rank = brand_avg_rank.get(p) rank_str = f"{p_rank:.1f}" if p_rank else "未排名" p_pos = sentiment_dist.get(p, {}) p_neg = p_pos.get("negative", 0) if isinstance(p_pos, dict) else 0 # 诊断 if mr_p >= 75: mr_diag = "高提及" elif mr_p >= 50: mr_diag = "中等提及" elif mr_p >= 25: mr_diag = "较低提及" else: mr_diag = "极低提及" gt, _, _ = _score_grade(s if isinstance(s, (int, float)) else None) platform_lines.append(f"<strong>{label}</strong>: GEO得分{s}({gt}),提及率{mr_p}%({mr_diag}),平均排名{rank_str}") # --- 竞品格局 --- comp_with_rank = [c for c in comparison if c.get("mention_rate_overall", 0) > 0.2 and c.get("name") != brand] comp_names = [c["name"] for c in comp_with_rank[:5]] comp_text = "、".join(comp_names) if comp_names else "暂无强竞品" # --- 发力方向 --- directions = [] # 找最弱平台 weakest_p = min(platforms, key=lambda p: geo_score.get(p, 0)) weakest_label = PLATFORM_LABELS.get(weakest_p, weakest_p) weakest_mr = int(mention_rate.get(weakest_p, 0) * 100) if weakest_mr < 50: directions.append(f"重点提升{weakest_label}平台的提及率(当前仅{weakest_mr}%),可通过优化该平台的搜索内容匹配度来增加品牌曝光") # 排名优化 if avg_rank_o and avg_rank_o > 2: directions.append(f"提升品牌在推荐列表中的排名位置(当前平均#{avg_rank_o:.0f}),强化产品核心卖点的差异化表达") elif avg_rank_o and avg_rank_o <= 2: directions.append(f"巩固排名优势(平均#{avg_rank_o:.0f}),持续输出高质量内容维持领先地位") # 情感 if neg_count > 0: directions.append("关注负面反馈,针对性解决用户痛点") elif neu_count > pos_count: directions.append("当前中性评价居多,建议通过更多主动推荐场景和对比评测内容,将中性认知转化为正面推荐") # 竞品 if len(comp_with_rank) >= 3: directions.append(f"关注{comp_text}等竞品的动态,在回答中强化自身差异化优势") directions_text = ";".join(directions) # --- 情绪口径(与报告「情感分析」章节保持一致:分母为全部有效回答)--- if total_sent > 0: pos_pct = _pct(pos_count, total_sent) neu_pct = _pct(neu_count, total_sent) neg_pct = _pct(neg_count, total_sent) sentiment_text = ( f"正面率 {pos_pct}%({pos_count}正 / {neu_count}中 / {neg_count}负)" + (",存在负面评价" if neg_count > 0 else ",无负面评价") ) else: sentiment_text = "暂无情绪数据(无有效 AI 回答)" # --- 竞争强度(按高频竞品数量动态判定,不再写死)--- comp_n = len(comp_with_rank) if comp_n >= 5: competition_text = "竞争环境非常拥挤" elif comp_n >= 3: competition_text = "竞争环境较为拥挤" elif comp_n >= 1: competition_text = "竞争环境存在一定压力" else: competition_text = "暂未观察到明显竞争" # --- 数据完整性提示(平台故障不应被读作品牌弱势)--- failed_n = sum( 1 for a in analysis.get("per_answer", []) if a.get("status") and a.get("status") != "completed" ) coverage_note = "" if failed_n: coverage_note = ( f" 注:另有 {failed_n} 条回答因平台超时或返回异常已从统计中剔除," "不计入提及率与得分。" ) # 拼装 HTML platform_html = "<br>".join(platform_lines) return f""" <div class="summary"> <div class="summary-hd">报告摘要 · {brand}</div> <div class="summary-bd"> <p><strong>综合评估:</strong> {brand} 跨平台GEO综合得分 <strong>{geo_o}</strong> 分,{grade_text}。品牌提及率 {mr_pct}%({total_mentioned}/{total_completed} 条有效回答提及),{sentiment_text}。整体品牌认知度{'较高' if mr_pct >= 50 else '有待提升'}。{coverage_note}</p> <p><strong>各平台表现:</strong><br>{platform_html}</p> <p><strong>竞品格局:</strong> AI回答中高频出现的竞品{('包括' + comp_text + '等,') if comp_names else '暂不明显,'}{competition_text}。</p> <p><strong>发力方向:</strong> {directions_text}。</p> </div> </div>""" def _extract_brand_voice(analysis): """从所有提及品牌的AI回答中提取正面/负面关键词""" pos_items = [] neg_items = [] seen_pos = set() seen_neg = set() for ans in analysis.get("per_answer", []): if ans.get("status") != "completed" or not ans.get("brand_mentioned"): continue platform = ans.get("platform", "") sentiment = ans.get("sentiment", "neutral") context = ans.get("brand_context", "") reason = ans.get("sentiment_reason", "") claims = ans.get("key_claims", []) all_text = f"{context} {reason} {' '.join(claims)}" if sentiment in ("positive", "neutral"): for claim in claims: is_neg = any(p in claim for p in NEG_PATTERNS) if is_neg: for p in NEG_PATTERNS: idx = claim.find(p) if idx > 0 and '无' in claim[max(0, idx - 4):idx]: is_neg = False break is_pos = any(p in claim for p in POS_PATTERNS) and not is_neg if is_pos and claim not in seen_pos: seen_pos.add(claim) pos_items.append({"text": claim, "platform": platform}) if is_neg and claim not in seen_neg: seen_neg.add(claim) neg_items.append({"text": claim, "platform": platform}) for pat in POS_PATTERNS: if pat in all_text: keyword = pat if keyword not in seen_pos: seen_pos.add(keyword) pos_items.append({"text": keyword, "platform": platform, "tag": True}) if sentiment == "negative": for claim in claims: if claim not in seen_neg: seen_neg.add(claim) neg_items.append({"text": claim, "platform": platform}) pos_tags = [] seen = set() for item in pos_items: key = item["text"] if item.get("tag") and key in seen: continue seen.add(key) pos_tags.append(item) return pos_tags[:20], neg_items[:10] def _compute_position(analysis): """计算品牌在AI回答中的位次排名""" per_answer = analysis.get("per_answer", []) ranks = [] rank_totals = [] for ans in per_answer: if ans.get("status") != "completed" or not ans.get("brand_mentioned"): continue r = ans.get("brand_rank") if r and isinstance(r, (int, float)): ranks.append(r) details = ans.get("competitor_details", []) total = len(details) + 1 if details else len(ans.get("ai_competitors_mentioned", [])) + 1 rank_totals.append(max(total, r)) if not ranks: return None avg_rank = sum(ranks) / len(ranks) avg_total = sum(rank_totals) / len(rank_totals) if rank_totals else max(ranks) + 2 pct = avg_rank / avg_total if avg_total > 0 else 0.5 if avg_rank <= 2: label, css_class = "领先", "pos-leading" elif pct <= 0.4: label, css_class = "上游", "pos-upper" elif pct <= 0.65: label, css_class = "中游", "pos-mid" else: label, css_class = "落后", "pos-trailing" return { "avg_rank": avg_rank, "total_brands": int(avg_total), "label": label, "css_class": css_class, "rank_count": len(ranks), } def _root_domain(domain): """取站点主域名,用于合并子域(enterprise.dji.com -> dji.com)""" d = (domain or "").lower().strip().lstrip(".") if not d or "." not in d: return d parts = d.split(".") if len(parts) <= 2: return d _multi = {"com.cn", "net.cn", "org.cn", "gov.cn", "edu.cn", "ac.cn", "com.hk", "com.tw", "com.mo", "co.jp", "co.kr", "co.uk", "com.au", "com.sg", "com.my", "com.br"} if ".".join(parts[-2:]) in _multi: return ".".join(parts[-3:]) return ".".join(parts[-2:]) def _domain_of(url, fallback=""): """从 URL 解析域名(剥离 www.)""" if not url: return fallback try: from urllib.parse import urlparse netloc = urlparse(url).netloc or "" except Exception: netloc = "" netloc = netloc.replace("www.", "").lower() return netloc or fallback def _collect_sources(analysis, search_results): """收集信源数据: 域名聚合、文章频次、分类统计""" if not search_results: return None results = search_results.get("results", []) # 文章频次统计 article_freq = {} article_meta = {} domain_freq = {} platform_source_count = {} # {平台: 信源条数} platform_answered = {} # {平台: 有效回答数} platform_no_source = {} # {平台: 无信源回答数} platform_domain_freq = {} # {平台: {主域名: 次数}} for r in results: if r.get("status") != "completed": continue p = r.get("platform", "") platform_answered[p] = platform_answered.get(p, 0) + 1 srcs = r.get("sources", []) if not srcs: platform_no_source[p] = platform_no_source.get(p, 0) + 1 for src in srcs: url = src.get("url", "") title = src.get("title", "") # 兜底: 上游未填 domain 时从 url 解析,避免信源统计静默全空 domain = src.get("domain", "") or _domain_of(url) if not url: continue if not title: title = domain article_freq[url] = article_freq.get(url, 0) + 1 article_meta[url] = {"title": title, "domain": domain, "url": url} key = _root_domain(domain) domain_freq[key] = domain_freq.get(key, 0) + 1 platform_source_count[p] = platform_source_count.get(p, 0) + 1 pf = platform_domain_freq.setdefault(p, {}) pf[key] = pf.get(key, 0) + 1 if not article_freq: return { "top_domains": [], "top_articles": [], "category_freq": {}, "total_citations": 0, "domain_freq": {}, "platform_source_count": platform_source_count, "platform_answered": platform_answered, "platform_no_source": platform_no_source, "platform_domain_freq": platform_domain_freq, "monopolies": [], } # 分类统计 category_freq = {} for domain, count in domain_freq.items(): cat = _classify_domain(domain) category_freq[cat] = category_freq.get(cat, 0) + count total_citations = sum(domain_freq.values()) # 排序 top_domains = sorted(domain_freq.items(), key=lambda x: -x[1])[:10] top_articles = sorted(article_freq.items(), key=lambda x: -x[1])[:10] # 单一信源垄断检测(**分平台**:实测豆包 33 条信源中 30 条来自同一域名,占 91%) monopolies = [] for p, pf in platform_domain_freq.items(): p_total = sum(pf.values()) if p_total < 5: continue top_d, top_c = max(pf.items(), key=lambda x: x[1]) if top_c / p_total >= 0.7: monopolies.append({ "platform": PLATFORM_LABELS.get(p, p), "domain": top_d, "count": top_c, "total": p_total, "share": round(top_c / p_total * 100), }) return { "top_domains": top_domains, "top_articles": [(article_meta[url], freq) for url, freq in top_articles], "category_freq": category_freq, "total_citations": total_citations, "domain_freq": domain_freq, "platform_source_count": platform_source_count, "platform_answered": platform_answered, "platform_no_source": platform_no_source, "platform_domain_freq": platform_domain_freq, "monopolies": monopolies, } def generate_html(analysis, search_results=None): brand = analysis.get("brand", "") platforms = analysis.get("platforms", ["doubao", "kimi", "deepseek"]) queries = analysis.get("queries", []) has_ai = "sentiment_distribution" in analysis total_completed = analysis.get("metrics", {}).get("total_completed", 0) total_mentioned = analysis.get("metrics", {}).get("total_mentioned", 0) geo_overall = analysis.get("geo_score", {}).get("overall", 0) pos_tags, neg_tags = _extract_brand_voice(analysis) position = _compute_position(analysis) sources_data = _collect_sources(analysis, search_results) summary_html = _generate_summary(analysis, platforms, total_completed, total_mentioned, position) sections = [ ("品牌指纹", _render_fingerprint(analysis, platforms, total_completed, total_mentioned, position)), ("提及矩阵", _render_mention_matrix(analysis, platforms, queries, total_completed)), ("情感分析", _render_sentiment_merged(analysis, platforms, has_ai, total_completed, pos_tags, neg_tags, brand)), ("信源分析", _render_sources_analysis(analysis, search_results, platforms, total_completed, sources_data)), ("竞品对比", _render_competitors(analysis, platforms, total_completed)), ("原始存档", _render_archive(analysis, search_results, platforms, queries)), ] nav_html = "".join( f'<a href="#s{i}" class="nl">{label}</a>' for i, (label, _) in enumerate(sections) ) sections_html = "".join( f'<section id="s{i}" class="report-section"><h2 class="sec-title">{label}</h2>{content}</section>' for i, (label, content) in enumerate(sections) ) platform_labels = " / ".join(PLATFORM_LABELS.get(p, p) for p in platforms) geo_str = f"{geo_overall}" if isinstance(geo_overall, (int, float)) else "0" mr_pct = _pct(total_mentioned, total_completed) now = datetime.now().strftime("%Y-%m-%d %H:%M") return _build_html(brand, platform_labels, now, geo_str, mr_pct, total_completed, nav_html, sections_html, platform_labels, summary_html) def _build_html(brand, platform_labels, now, geo_str, mr_pct, total_completed, nav_html, sections_html, platform_labels2, summary_html=""): return f"""<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="description" content="GEO 品牌感知报告 - {_esc(brand)}"> <title>"{_esc(brand)}" GEO 品牌感知报告</title> <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;600;800&family=Noto+Sans+SC:wght@400;500;700&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet"> <style> :root {{ --bg: #f8fafb; --surface: #ffffff; --surface-2: #f1f5f7; --text: #1a1d21; --muted: #64748b; --line: #e2e8f0; --accent: #0f766e; --accent-light: #f0fdfa; --hit: #059669; --hit-light: #ecfdf5; --miss: #dc2626; --miss-light: #fef2f2; --warn: #b45309; --warn-light: #fffbeb; --font-d: 'Outfit','Noto Sans SC',system-ui,sans-serif; --font-b: 'Noto Sans SC',system-ui,'PingFang SC','Microsoft YaHei',sans-serif; --font-m: 'JetBrains Mono','Menlo','Consolas',monospace; --xs: .7rem; --sm: .8125rem; --s0: .9375rem; --s1: 1.0625rem; --s2: 1.1875rem; --s3: 1.375rem; --s4: 1.75rem; --s5: 2.25rem; --sp1: .1875rem; --sp2: .4375rem; --sp3: .6875rem; --sp4: .875rem; --sp5: 1.125rem; --sp6: 1.375rem; --sp8: 1.75rem; --sp10: 2.25rem; --wide: 62rem; --pad: clamp(.875rem,3.5vw,2.5rem); --r: 8px; --rs: 5px; --sh: 0 1px 2px rgba(15,23,42,.04),0 1px 1px rgba(15,23,42,.02); --dur: 180ms; --ease: cubic-bezier(.33,1,.68,1); }} @media(prefers-reduced-motion:reduce){{:root{{--dur:0ms}}}} *,*::before,*::after{{margin:0;padding:0;box-sizing:border-box}} body{{font-family:var(--font-b);font-size:var(--s0);line-height:1.65;color:var(--text);background:var(--bg);-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}} .wrap{{max-width:var(--wide);margin:0 auto;padding:0 var(--pad)}} /* Navigation - pill style */ nav.top{{position:sticky;top:0;z-index:100;background:rgba(248,250,251,.94);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border-bottom:1px solid var(--line);padding:0 var(--pad)}} nav.top .inner{{max-width:var(--wide);margin:0 auto;display:flex;gap:var(--sp1);overflow-x:auto;scrollbar-width:none;padding:var(--sp2) 0}} nav.top .inner::-webkit-scrollbar{{display:none}} .nl{{font-family:var(--font-d);font-size:var(--xs);font-weight:500;color:var(--muted);text-decoration:none;white-space:nowrap;padding:4px 10px;border-radius:var(--r);transition:all var(--dur) var(--ease)}} .nl:hover{{color:var(--text);background:var(--surface-2)}} .nl.active{{color:var(--accent);background:var(--accent-light)}} /* Hero - compact, no center */ header.hero{{padding:var(--sp8) 0 var(--sp6)}} header.hero h1{{font-family:var(--font-d);font-weight:700;font-size:clamp(1.5rem,3.5vw,var(--s4));letter-spacing:-.02em;color:var(--text);margin-bottom:var(--sp1);line-height:1.2}} header.hero .sub{{font-size:var(--sm);color:var(--muted)}} /* Sections */ section.report-section{{padding:var(--sp6) 0;border-top:1px solid var(--line)}} .sec-title{{font-family:var(--font-d);font-weight:600;font-size:var(--s2);letter-spacing:-.01em;margin-bottom:var(--sp5);color:var(--text);display:flex;align-items:center;gap:var(--sp3)}} .sec-title::before{{content:'';width:3px;height:1.1em;background:var(--accent);border-radius:2px;flex-shrink:0}} /* Cards */ .card{{background:var(--surface);border:1px solid var(--line);border-radius:var(--r);padding:var(--sp5);box-shadow:var(--sh)}} .card+.card{{margin-top:var(--sp3)}} .cl{{font-family:var(--font-d);font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:var(--sp3)}} .src{{font-size:10px;color:var(--muted);margin-top:var(--sp4);line-height:1.6;font-family:var(--font-m);opacity:.8}} .src::before{{content:'说明: ';font-weight:600}} /* Grids */ .g2{{display:grid;grid-template-columns:1fr 1fr;gap:var(--sp3)}} .g3{{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--sp3)}} @media(max-width:640px){{.g2,.g3{{grid-template-columns:1fr}}}} /* Numeric */ .num{{font-family:var(--font-m);font-weight:700;font-variant-numeric:tabular-nums}} .ptag{{display:inline-flex;align-items:center;gap:var(--sp2);font-size:var(--sm);font-weight:500}} .pd{{width:7px;height:7px;border-radius:50%;flex-shrink:0}} /* Heatmap table */ .heat{{width:100%;border-collapse:separate;border-spacing:0}} .heat th{{padding:var(--sp2) var(--sp3);font-size:var(--xs);font-weight:600;color:var(--muted);text-align:center;border-bottom:1px solid var(--line);font-family:var(--font-d)}} .heat td{{padding:6px var(--sp3);font-size:var(--sm);text-align:center;border-bottom:1px solid var(--surface-2)}} .heat-y{{background:var(--accent-light);color:var(--accent);font-family:var(--font-m);font-weight:700;border-radius:var(--rs)}} .heat-n{{color:var(--muted);opacity:.4;background:var(--surface-2);font-weight:500}} .heat-f{{color:var(--miss);font-size:var(--xs);background:var(--miss-light);border-radius:var(--rs)}} /* Data table */ .tbl{{width:100%;border-collapse:separate;border-spacing:0}} .tbl th{{padding:var(--sp2) var(--sp3);text-align:left;font-size:11px;font-weight:600;color:var(--muted);letter-spacing:.03em;border-bottom:1px solid var(--line);font-family:var(--font-d);text-transform:uppercase}} .tbl td{{padding:var(--sp2) var(--sp3);font-size:var(--sm);border-bottom:1px solid var(--surface-2);vertical-align:middle}} .tbl tr:hover td{{background:var(--surface-2)}} .tbl .hl td{{background:var(--accent-light);font-weight:600}} .tbl .hl:hover td{{background:#ccfbf1}} /* Badges */ .badge{{display:inline-block;padding:2px 8px;border-radius:var(--rs);font-size:10px;font-weight:600;line-height:1.6}} .b-h{{background:var(--hit-light);color:var(--hit)}} .b-m{{background:var(--miss-light);color:var(--miss)}} .b-w{{background:var(--warn-light);color:var(--warn)}} .b-a{{background:var(--accent-light);color:var(--accent)}} .b-g{{background:var(--surface-2);color:var(--muted)}} /* Insights */ .insight{{padding:var(--sp3) var(--sp4);border-radius:var(--r);margin-bottom:var(--sp2);font-size:var(--sm);line-height:1.7;border-left:3px solid}} .ins-h{{background:var(--hit-light);border-color:var(--hit)}} .ins-m{{background:var(--miss-light);border-color:var(--miss)}} .ins-n{{background:var(--surface-2);border-color:var(--muted)}} /* Bar charts */ .bar-row{{display:flex;align-items:center;gap:var(--sp3);margin-bottom:var(--sp2)}} .bar-name{{width:160px;font-size:var(--sm);color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex-shrink:0}} .bar-track{{flex:1;background:var(--surface-2);border-radius:var(--rs);height:18px;overflow:hidden}} .bar-fill{{height:100%;border-radius:var(--rs);display:flex;align-items:center;padding:0 var(--sp2);font-family:var(--font-m);font-size:var(--xs);font-weight:700;color:#fff;min-width:22px;font-variant-numeric:tabular-nums}} /* Folds */ .fold{{cursor:pointer;user-select:none}} .fold::before{{content:'';display:inline-block;width:0;height:0;border-left:5px solid var(--muted);border-top:4px solid transparent;border-bottom:4px solid transparent;margin-right:var(--sp2);vertical-align:middle;transition:transform var(--dur) var(--ease)}} .fold.open::before{{transform:rotate(90deg)}} .fold-body{{display:none;margin-top:var(--sp3);padding:var(--sp4);background:var(--surface-2);border-radius:var(--r);font-size:var(--sm);line-height:1.7;border:1px solid var(--line)}} .fold.open+.fold-body{{display:block}} /* Legend */ .legend{{display:flex;flex-direction:column;gap:var(--sp2)}} .legend-item{{display:flex;align-items:center;gap:var(--sp3);font-size:var(--sm)}} .legend-dot{{width:10px;height:10px;border-radius:3px;flex-shrink:0}} .legend-val{{font-family:var(--font-m);font-weight:700;margin-left:auto;font-variant-numeric:tabular-nums}} /* Footer */ footer.rf{{padding:var(--sp6) 0;border-top:1px solid var(--line);text-align:center;font-size:var(--xs);color:var(--muted)}} /* Voice tags */ .vtag{{display:inline-flex;align-items:center;gap:var(--sp1);padding:2px 8px;border-radius:var(--rs);font-size:var(--xs);font-weight:500;margin:0 var(--sp1) var(--sp2) 0;line-height:1.5}} .vtag-pos{{background:var(--hit-light);color:var(--hit);border:1px solid rgba(5,150,105,.12)}} .vtag-neg{{background:var(--miss-light);color:var(--miss);border:1px solid rgba(220,38,38,.12)}} .vtag .vp{{font-size:10px;opacity:.5;margin-left:2px}} /* Position indicator */ .pos-bar{{display:flex;align-items:center;gap:var(--sp3);padding:var(--sp3) var(--sp4);border-radius:var(--r);border:1px solid var(--line);background:var(--surface)}} .pos-rank{{font-family:var(--font-m);font-weight:800;font-size:var(--s4);line-height:1}} .pos-label{{font-family:var(--font-d);font-weight:600;font-size:var(--sm);color:var(--text)}} .pos-desc{{font-size:var(--xs);color:var(--muted)}} .pos-leading{{color:var(--hit)}} .pos-upper{{color:var(--accent)}} .pos-mid{{color:var(--warn)}} .pos-trailing{{color:var(--miss)}} /* Source analysis */ .sa-grid{{display:grid;grid-template-columns:1fr 1fr;gap:var(--sp3);align-items:start}} @media(max-width:900px){{.sa-grid{{grid-template-columns:1fr;}}}} .sa-list{{border:1px solid var(--line);border-radius:var(--r);background:var(--surface);overflow:hidden}} .sa-list-hd{{padding:var(--sp2) var(--sp3);font-family:var(--font-d);font-size:11px;font-weight:600;color:var(--muted);border-bottom:1px solid var(--line);letter-spacing:.02em;text-transform:uppercase}} .sa-row{{display:flex;align-items:center;gap:var(--sp2);padding:5px var(--sp3);border-bottom:1px solid var(--surface-2);font-size:var(--sm);transition:background var(--dur) var(--ease)}} .sa-row:hover{{background:var(--surface-2)}} .sa-row:last-child{{border-bottom:none}} .sa-rank{{font-family:var(--font-m);font-weight:700;font-size:var(--xs);color:var(--muted);width:18px;text-align:center;flex-shrink:0}} .sa-name{{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}} .sa-cnt{{font-family:var(--font-m);font-weight:700;font-size:var(--xs);color:var(--accent);width:24px;text-align:right;flex-shrink:0}} .sa-cat{{font-size:10px;padding:1px 5px;border-radius:var(--rs);font-weight:500;flex-shrink:0}} .sa-cat-brand{{background:var(--hit-light);color:var(--hit)}} .sa-cat-auto{{background:#eff6ff;color:#2563eb}} .sa-cat-media{{background:#f0fdf4;color:#059669}} .sa-cat-other{{background:var(--surface-2);color:var(--muted)}} .sa-article-link{{text-decoration:none;color:var(--text);display:block}} .sa-article-link:hover .sa-name{{color:var(--accent)}} .sa-cat-bar{{display:flex;gap:var(--sp5);justify-content:center;flex-wrap:wrap;margin-top:var(--sp3);padding:var(--sp3)}} .sa-cat-item{{display:flex;align-items:center;gap:var(--sp2);font-size:var(--sm)}} .sa-cat-dot{{width:8px;height:8px;border-radius:2px;flex-shrink:0}} .sa-cat-pct{{font-family:var(--font-m);font-weight:700;font-variant-numeric:tabular-nums}} /* Summary block */ .summary{{background:var(--surface);border:1px solid var(--line);border-radius:var(--r);overflow:hidden;margin-bottom:var(--sp5)}} .summary-hd{{padding:var(--sp4) var(--sp5);background:var(--accent);color:#fff;font-family:var(--font-d);font-weight:600;font-size:var(--sm);letter-spacing:.02em}} .summary-bd{{padding:var(--sp5);font-size:var(--sm);line-height:1.85;color:var(--text)}} .summary-bd p{{margin-bottom:var(--sp3)}} .summary-bd p:last-child{{margin-bottom:0}} .summary-bd strong{{color:var(--text);font-weight:600}} </style> </head> <body> <nav class="top" aria-label="报告导航"><div class="inner">{nav_html}</div></nav> <main class="wrap"> <header class="hero"> <h1>"{_esc(brand)}" GEO 品牌感知报告</h1> <div class="sub">{platform_labels} · {now}</div> </header> {summary_html} {sections_html} <footer class="rf"> <p>GEO 品牌感知分析 · {platform_labels2} · 共 {total_completed} 条有效回答</p> <p style="margin-top:var(--sp1);opacity:.7">提及率=品牌被提及次数/有效回答数 · 排名与情感=AI逐条分析 · 综合得分=提及*40%+排名*30%+情感*30%</p> </footer> </main> <script> document.querySelectorAll('.fold').forEach(el=>{{el.addEventListener('click',()=>el.classList.toggle('open'))}}); document.querySelectorAll('.nl').forEach(a=>{{a.addEventListener('click',e=>{{e.preventDefault();const t=document.querySelector(a.getAttribute('href'));if(t)t.scrollIntoView({{behavior:'smooth',block:'start'}})}})}}); </script> </body> </html>""" def _render_fingerprint(analysis, platforms, total_completed, total_mentioned, position): geo_score = analysis.get("geo_score", {}) mention_rate = analysis.get("metrics", {}).get("brand_mention_rate", {}) sentiment_dist = analysis.get("sentiment_distribution", {}).get("overall", {}) brand = analysis.get("brand", "") avg_rank = analysis.get("brand_avg_rank", {}).get("overall") geo_o = geo_score.get("overall", 0) mr_pct = _pct(total_mentioned, total_completed) total_sent = sum(sentiment_dist.values()) neg_pct = _pct(sentiment_dist.get("negative", 0), total_sent) if total_sent else 0 pos_pct = _pct(sentiment_dist.get("positive", 0), total_sent) if total_sent else 0 neu_pct = _pct(sentiment_dist.get("neutral", 0), total_sent) if total_sent else 0 rank_str = f"{avg_rank:.1f}" if avg_rank else "N/A" # 平台卡片: 紧凑布局 score_cards = "" for p in platforms: s = geo_score.get(p, 0) label = PLATFORM_LABELS.get(p, p) color = PLATFORM_COLORS.get(p, "var(--accent)") rate = mention_rate.get(p, 0) pct = int(rate * 100) gt, gc, gbg = _score_grade(s if isinstance(s, (int, float)) else None) p_done = sum(1 for a in analysis.get("per_answer", []) if a.get("platform") == p and a.get("status") == "completed") p_ment = sum(1 for a in analysis.get("per_answer", []) if a.get("platform") == p and a.get("status") == "completed" and a.get("brand_mentioned")) score_cards += f""" <div style="padding:var(--sp3) var(--sp4);border:1px solid var(--line);border-radius:var(--r);background:var(--surface)"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--sp2)"> <span class="ptag"><span class="pd" style="background:{color}"></span>{label}</span> <span class="badge" style="background:{gbg};color:{gc}">{gt}</span> </div> <div class="num" style="font-size:var(--s4);color:{gc};margin-bottom:var(--sp2)">{s if isinstance(s,(int,float)) else 'N/A'}</div> <div style="display:flex;gap:var(--sp4);font-size:var(--xs);color:var(--muted)"> <span>提及 {pct}%</span> <span>{p_ment}/{p_done} 条</span> </div> </div>""" pos_html = "" if position: pr = position["avg_rank"] pt = position["total_brands"] pc = position["css_class"] pl = position["label"] pos_html = f""" <div class="pos-bar" style="margin-top:0;margin-bottom:var(--sp3)"> <div class="pos-rank {pc}">#{round(pr)}</div> <div> <div class="pos-label {pc}">{pl}</div> <div class="pos-desc">平均排名 {pr:.1f} · 约 {pt} 个品牌参与</div> </div> </div>""" return f""" <div class="g2"> <div class="card"> {pos_html} <div style="display:grid;grid-template-columns:repeat(2,1fr);gap:var(--sp4) var(--sp5);margin-bottom:var(--sp3)"> <div><div style="font-size:11px;color:var(--muted);margin-bottom:2px;text-transform:uppercase;letter-spacing:.03em">GEO 综合</div><div class="num" style="font-size:var(--s4);color:var(--accent)">{geo_o}</div></div> <div><div style="font-size:11px;color:var(--muted);margin-bottom:2px;text-transform:uppercase;letter-spacing:.03em">提及率</div><div class="num" style="font-size:var(--s4);color:var(--hit)">{mr_pct}%</div></div> <div><div style="font-size:11px;color:var(--muted);margin-bottom:2px;text-transform:uppercase;letter-spacing:.03em">平均排名</div><div class="num" style="font-size:var(--s4);color:var(--warn)">{rank_str}</div></div> <div><div style="font-size:11px;color:var(--muted);margin-bottom:2px;text-transform:uppercase;letter-spacing:.03em">正面率</div><div class="num" style="font-size:var(--s4);color:var(--hit)">{pos_pct}%</div></div> </div> <div class="src">综合得分 = 提及率*40% + 排名得分*30% + 情感得分*30%</div> </div> <div style="display:grid;grid-template-columns:1fr;gap:var(--sp2)">{score_cards}</div> </div>""" def _render_sentiment_merged(analysis, platforms, has_ai, total_completed, pos_tags, neg_tags, brand): """情感分析: 合并原品牌声音+情感分析, 包含分布/平台对比/正面负面信号/非中性详情""" if not has_ai: return '<div class="card" style="color:var(--muted)">暂无 AI 情感分析数据</div>' dist = analysis.get("sentiment_distribution", {}) overall = dist.get("overall", {"positive": 0, "neutral": 0, "negative": 0}) total = sum(overall.values()) neg_count = overall.get("negative", 0) neg_pct = _pct(neg_count, total) if total else 0 pos_pct = _pct(overall.get("positive", 0), total) if total else 0 neu_pct = _pct(overall.get("neutral", 0), total) if total else 0 # 各平台情感对比 platform_rows = "" for p in platforms: d = dist.get(p, {"positive": 0, "neutral": 0, "negative": 0}) t = sum(d.values()) or 1 pp = int(d["positive"]/t*100); np_ = int(d["neutral"]/t*100); ng = 100-pp-np_ label = PLATFORM_LABELS.get(p, p); color = PLATFORM_COLORS.get(p, "var(--accent)") platform_rows += f""" <div style="display:flex;align-items:center;gap:var(--sp3);padding:var(--sp2) 0;border-bottom:1px solid var(--surface-2)"> <span class="ptag" style="width:70px"><span class="pd" style="background:{color}"></span>{label}</span> <div style="flex:1;display:flex;height:14px;border-radius:var(--rs);overflow:hidden;gap:1px"> <div style="width:{pp}%;background:var(--hit)"></div> <div style="width:{np_}%;background:#d4d4d8"></div> <div style="width:{ng}%;background:var(--miss)"></div> </div> <span class="num" style="font-size:var(--xs);color:var(--muted);width:80px;text-align:right">{d['positive']}正 {d['neutral']}中 {d['negative']}负</span> </div>""" # 正面信号 pos_html = "" for item in pos_tags: p = PLATFORM_LABELS.get(item["platform"], item["platform"]) is_tag = item.get("tag", False) text = _esc(item["text"]) if is_tag: pos_html += f'<span class="vtag vtag-pos">{text}</span>' else: pos_html += f'<span class="vtag vtag-pos">{text} <span class="vp">{p}</span></span>' if not pos_html: pos_html = '<span style="color:var(--muted);font-size:var(--sm)">未提取到正面信号</span>' # 负面信号 neg_html = "" for item in neg_tags: p = PLATFORM_LABELS.get(item["platform"], item["platform"]) text = _esc(item["text"]) neg_html += f'<span class="vtag vtag-neg">{text} <span class="vp">{p}</span></span>' if not neg_html: neg_html = '<span style="color:var(--muted);font-size:var(--sm)">未发现负面信号</span>' # 非中性回答详情 per_answer = analysis.get("per_answer", []) pos_insights = "" neg_insights = "" for ans in per_answer: if ans.get("status") != "completed": continue s = ans.get("sentiment", "neutral") if s == "neutral": continue q = _esc(ans.get("question", "")) pl = PLATFORM_LABELS.get(ans.get("platform", ""), ans.get("platform", "")) reason = _esc(ans.get("sentiment_reason", "")) bc = {"positive": "b-h", "negative": "b-m"}.get(s, "b-g") sl = SENTIMENT_LABELS.get(s, s) card = f'<div class="insight {"ins-h" if s=="positive" else "ins-m"}"><span class="badge {bc}">{sl}</span> <strong>{pl}</strong> / {q}<br><span style="font-size:var(--xs);color:var(--muted)">{reason}</span></div>' if s == "positive": pos_insights += card else: neg_insights += card if not pos_insights: pos_insights = '<div style="color:var(--muted);font-size:var(--sm);padding:var(--sp4)">无正面回答详情</div>' if not neg_insights: neg_insights = '<div style="color:var(--muted);font-size:var(--sm);padding:var(--sp4)">无负面回答详情</div>' return f""" <div class="g2"> <div class="card"> <div class="cl">整体情感分布</div> <div class="legend" style="margin-bottom:var(--sp3)"> <div class="legend-item"><div class="legend-dot" style="background:var(--hit)"></div>正面<span class="legend-val">{overall.get('positive',0)}</span></div> <div class="legend-item"><div class="legend-dot" style="background:#d4d4d8"></div>中性<span class="legend-val">{overall.get('neutral',0)}</span></div> <div class="legend-item"><div class="legend-dot" style="background:var(--miss)"></div>负面<span class="legend-val">{neg_count}</span></div> </div> <div class="src">AI 对 {total} 条回答逐条情感分析(正面=推荐倾向 / 中性=客观描述 / 负面=批评态度)</div> </div> <div class="card"> <div class="cl">各平台情感对比</div> {platform_rows} <div class="src">各平台独立统计, 正面=主动推荐 / 中性=客观提及 / 负面=指出不足</div> </div> </div> <div class="g2" style="margin-top:var(--sp3)"> <div class="card"> <div class="cl">正面信号 ({len(pos_tags)} 项)</div> <div style="display:flex;flex-wrap:wrap;margin-bottom:var(--sp3)">{pos_html}</div> <div class="src">从 AI 回答中提取, 匹配关键词库({len(POS_PATTERNS)}个正面词)</div> {pos_insights} </div> <div class="card"> <div class="cl">负面信号 ({len(neg_tags)} 项)</div> <div style="display:flex;flex-wrap:wrap;margin-bottom:var(--sp3)">{neg_html}</div> <div class="src">已排除“无XX”类假阳性(如“无充电等待”不算负面)</div> {neg_insights} </div> </div>""" def _render_mention_matrix(analysis, platforms, queries, total_completed): matrix = analysis.get("mention_matrix", []) per_answer = analysis.get("per_answer", []) header_cells = "".join( f'<th><span class="ptag"><span class="pd" style="background:{PLATFORM_COLORS.get(p,"var(--accent)")}"></span>{PLATFORM_LABELS.get(p,p)}</span></th>' for p in platforms ) rows = "" for i, row in enumerate(matrix): q = queries[i] if i < len(queries) else f"Q{i+1}" q_short = q if len(q) <= 24 else q[:24] + "..." cells = "" for j, cell in enumerate(row): mentioned = cell.get("mentioned", False) count = cell.get("mention_count", 0) if mentioned: cells += f'<td class="heat-y" title="出现{count}次">{count}</td>' else: p = platforms[j] if j < len(platforms) else "" has_failed = any(a.get("query_index") == i and a.get("platform") == p and a.get("status") != "completed" for a in per_answer) cells += f'<td class="{"heat-f" if has_failed else "heat-n"}" title="{"查询失败" if has_failed else "未提及"}">{"✕" if has_failed else "—"}</td>' rows += f'<tr><td style="text-align:left;font-size:var(--sm);max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="{_esc(q)}">{_esc(q_short)}</td>{cells}</tr>' return f""" <div class="card"> <table class="heat"> <thead><tr><th style="text-align:left;width:50%">问题</th>{header_cells}</tr></thead> <tbody>{rows}</tbody> </table> <div style="display:flex;gap:var(--sp4);margin-top:var(--sp3);flex-wrap:wrap;font-size:var(--xs);color:var(--muted)"> <span><span style="display:inline-block;width:8px;height:8px;background:var(--accent-light);border-radius:2px;vertical-align:middle;margin-right:var(--sp1)"></span>已提及</span> <span><span style="display:inline-block;width:8px;height:8px;background:var(--surface-2);border-radius:2px;vertical-align:middle;margin-right:var(--sp1)"></span>未提及 (—)</span> <span><span style="display:inline-block;width:8px;height:8px;background:var(--miss-light);border-radius:2px;vertical-align:middle;margin-right:var(--sp1)"></span>查询失败 (✕)</span> </div> <div class="src">共 {total_completed} 条回答, 通过关键词匹配(品牌名+别名)检测是否提及</div> </div>""" def _render_sentiment(analysis, platforms, has_ai, total_completed): pass # merged into _render_sentiment_merged CAT_COLORS = { "品牌官网": "#059669", "汽车垂直": "#2563eb", "社媒资讯": "#0f766e", "其他网站": "#a1a1aa", } CAT_CSS = { "品牌官网": "sa-cat-brand", "汽车垂直": "sa-cat-auto", "社媒资讯": "sa-cat-media", "其他网站": "sa-cat-other", } def _render_sources_analysis(analysis, search_results, platforms, total_completed, sources_data): """信源分析: 域名表(左) + 环形图(中) + 文章排名(右) + 分类占比(底)""" if not sources_data: return '<div class="card" style="color:var(--muted)">暂无信源数据</div>' top_domains = sources_data["top_domains"] top_articles = sources_data["top_articles"] category_freq = sources_data["category_freq"] total_citations = sources_data["total_citations"] brand = analysis.get("brand", "") aliases = analysis.get("aliases", []) brand_keywords = [k.lower() for k in [brand] + aliases if k] # 左侧: 域名聚合排序 TOP10 domain_rows = "" for i, (domain, count) in enumerate(top_domains): cat = _classify_domain(domain) css_cat = CAT_CSS.get(cat, "sa-cat-other") is_brand = any(kw in domain.lower() for kw in brand_keywords) name_style = "color:var(--hit);font-weight:600" if is_brand else "" mark = " *" if is_brand else "" domain_url = f"https://{domain}" domain_rows += f'<div class="sa-row"><span class="sa-rank">{i+1}</span><a class="sa-name sa-article-link" href="{domain_url}" target="_blank" rel="noopener" style="{name_style}" title="{_esc(domain)}">{_esc(domain)}{mark}</a><span class="sa-cnt">{count}</span><span class="sa-cat {css_cat}">{cat}</span></div>' # 右侧: 文章频次排名 TOP10 article_rows = "" for i, (meta, count) in enumerate(top_articles): title = _esc(meta["title"])[:30] url = _esc(meta["url"]) article_rows += f'<a class="sa-row sa-article-link" href="{url}" target="_blank" rel="noopener"><span class="sa-rank">{i+1}</span><span class="sa-name" title="{_esc(meta["title"])}">{title}</span><span class="sa-cnt">{count}</span></a>' # 底部: 分类占比 cat_bar = "" sorted_cats = sorted(category_freq.items(), key=lambda x: -x[1]) for cat, count in sorted_cats: pct = count / total_citations * 100 if total_citations else 0 col = CAT_COLORS.get(cat, "#a1a1aa") cat_bar += f'<div class="sa-cat-item"><span class="sa-cat-dot" style="background:{col}"></span><span class="sa-cat-pct">{pct:.1f}%</span> {cat}</div>' brand_note = "" if any(any(kw in d.lower() for kw in brand_keywords) for d, _ in top_domains): brand_note = f'<span style="color:var(--hit)">* {brand} 品牌官网</span>' elif brand: brand_note = f'<span style="color:var(--miss)">"{_esc(brand)}" 品牌官网未进入 TOP10</span>' # ── 信源覆盖度说明(不再让「空信源」静默呈现为 0)── coverage_notes = [] p_src = sources_data.get("platform_source_count", {}) p_ans = sources_data.get("platform_answered", {}) for p in platforms: answered = p_ans.get(p, 0) if answered and not p_src.get(p, 0): label = PLATFORM_LABELS.get(p, p) coverage_notes.append( f"{label} 的 {answered} 条回答均未返回可解析外链信源" + ("(该平台仅提供正文内联引用标记)" if p == "kimi" else "") ) for r in (search_results or {}).get("results", []): note = r.get("sources_note") if note and note not in coverage_notes: coverage_notes.append(note) break monopoly = sources_data.get("monopolies") or [] for m in monopoly: coverage_notes.append( f"{m['platform']}的信源高度集中于 {m['domain']}" f"({m['count']}/{m['total']} 次,占 {m['share']}%)," "该平台域名多样性视角参考价值有限" ) note_html = "" if coverage_notes: note_html = ( '<div style="margin-top:var(--sp3);font-size:var(--xs);color:var(--warn);' 'background:var(--warn-light);border:1px solid #fde68a;border-radius:var(--rs);' 'padding:var(--sp2) var(--sp3);line-height:1.7">' "<strong>数据完整性提示:</strong> " + ";".join(_esc(n) for n in coverage_notes) + "</div>" ) if not top_domains: return f""" <div class="card" style="color:var(--muted)">暂无信源数据(所有平台本次均未返回可解析外链信源)</div> {note_html}""" return f""" <div class="sa-grid"> <div class="sa-list"> <div class="sa-list-hd">信源引用域名聚合排序 (TOP10)</div> {domain_rows} </div> <div class="sa-list"> <div class="sa-list-hd">信源引用文章频次排名 (TOP10)</div> {article_rows} </div> </div> <div class="sa-cat-bar"> {cat_bar} </div> <div style="display:flex;gap:var(--sp4);margin-top:var(--sp1);font-size:var(--xs);color:var(--muted);justify-content:center"> {brand_note} </div> <div class="src">总引用 {total_citations} 次 · 来自 {total_completed} 条有效回答 · 域名按聚合频次降序(已合并同站子域)· 文章按被引用次数降序</div> {note_html}""" def _render_competitors(analysis, platforms, total_completed): comparison = analysis.get("competitor_comparison", []) brand = analysis.get("brand", "") mention_rate = analysis.get("metrics", {}).get("brand_mention_rate", {}) brand_avg_rank = analysis.get("brand_avg_rank", {}) brand_sentiment = analysis.get("sentiment_distribution", {}).get("overall", {}) if not comparison and not brand: return '<div class="card" style="color:var(--muted)">暂无竞品数据</div>' brand_total_s = sum(brand_sentiment.values()) if brand_sentiment else 0 brand_neg_pct = _pct(brand_sentiment.get("negative", 0), brand_total_s) if brand_total_s else 0 brand_pos_pct = _pct(brand_sentiment.get("positive", 0), brand_total_s) if brand_total_s else 0 brand_rank_str = f"{brand_avg_rank.get('overall',0):.1f}" if brand_avg_rank.get("overall") else "N/A" brand_row = f""" <tr class="hl"> <td><strong>{_esc(brand)}</strong> <span class="badge b-a">本品牌</span></td> <td class="num">{_pct(int(mention_rate.get('overall',0)*100),100)}%</td> <td class="num">{brand_rank_str}</td> <td class="num" style="color:var(--hit)">{brand_pos_pct}%</td> </tr>""" sorted_comps = sorted(comparison, key=lambda c: c.get("mention_rate_overall",0), reverse=True) comp_rows = "" comp_count = 0 skipped_count = 0 for comp in sorted_comps: name = comp.get("name", "") if name == brand: continue mr = comp.get("mention_rate_overall",0) # 只展示在回答中实际被提及的竞品 if mr <= 0: continue if comp_count >= 10: skipped_count += 1 continue comp_count += 1 rank = comp.get("avg_rank") rs = f"{rank:.1f}" if rank else "N/A" sd = comp.get("sentiment_dist", {}) sd_total = sum(sd.values()) if sd else 0 comp_neg_pct = _pct(sd.get("negative", 0), sd_total) if sd_total else 0 pp = _pct(sd.get("positive", 0), sd_total) if sd_total else 0 detail = "" if sd: detail = f'<div class="fold-body" style="font-size:var(--xs)"><strong>各平台提及:</strong> 豆包 {_pct(int(comp.get("mention_rate_doubao",0)*100),100)}% · Kimi {_pct(int(comp.get("mention_rate_kimi",0)*100),100)}% · DeepSeek {_pct(int(comp.get("mention_rate_deepseek",0)*100),100)}%<br><strong>情感分布:</strong> 正面{sd.get("positive",0)} / 中性{sd.get("neutral",0)} / 负面{sd.get("negative",0)}</div>' comp_rows += f'<tr><td><span class="fold" style="font-size:var(--sm)">{_esc(name)}</span>{detail}</td><td class="num">{_pct(int(mr*100),100)}%</td><td class="num">{rs}</td><td class="num" style="color:var(--hit)">{pp}%</td></tr>' more_text = f'<div style="margin-top:var(--sp2);font-size:var(--xs);color:var(--muted);text-align:center">还有 {skipped_count} 个竞品未展示(提及率较低)</div>' if skipped_count > 0 else '' return f""" <div class="card"> <table class="tbl"> <thead><tr><th style="width:35%">品牌 (点击展开详情)</th><th>提及率</th><th>平均排名</th><th>正面率</th></tr></thead> <tbody>{brand_row}{comp_rows}</tbody> </table> {more_text} <div class="src">提及率=关键词匹配+AI提及列表推断排名 · 正面率=1-负面率 · 排名=在推荐列表中的位置</div> </div>""" def _render_archive(analysis, search_results, platforms, queries): brand = analysis.get("brand", "") if not search_results: return '<div class="card" style="color:var(--muted)">暂无原始数据</div>' results = search_results.get("results", []) if not results: return '<div class="card" style="color:var(--muted)">暂无原始数据</div>' by_question = {} for r in results: qi = r.get("query_index", 0) by_question.setdefault(qi, []).append(r) per_answer_map = {} for a in analysis.get("per_answer", []): key = (a.get("query_index"), a.get("platform")) per_answer_map[key] = a panels = "" for qi in sorted(by_question.keys()): question = queries[qi] if qi < len(queries) else f"Q{qi+1}" answers = by_question[qi] platform_items = "" for ans in answers: p = ans.get("platform", "") label = PLATFORM_LABELS.get(p, p) color = PLATFORM_COLORS.get(p, "var(--accent)") status = ans.get("status", "unknown") content = ans.get("content", "") pa = per_answer_map.get((qi, p), {}) s = pa.get("sentiment", "") sb = "" if s and status == "completed": sl = SENTIMENT_LABELS.get(s, s) bc = {"positive": "b-h", "neutral": "b-g", "negative": "b-m"}.get(s, "b-g") sb = f' <span class="badge {bc}">{sl}</span>' cd = f"[{status}]" if status != "completed" else content escaped = _esc(cd) # 品牌词高亮 brand_kw = [brand] + [a for a in analysis.get("aliases", []) if a] if brand else [] for kw in brand_kw: ekw = _esc(kw) if ekw and ekw in escaped: escaped = escaped.replace(ekw, f'<mark style="background:#fef08a;color:inherit;padding:0 2px;border-radius:2px">{ekw}</mark>') platform_items += f'<div style="margin-bottom:var(--sp3)"><div class="fold" style="font-size:var(--sm);font-weight:500"><span class="ptag"><span class="pd" style="background:{color}"></span>{label}</span>{sb}</div><div class="fold-body">{escaped}</div></div>' nd = '<div style="color:var(--muted)">暂无数据</div>' panels += f'<div class="card" style="margin-bottom:var(--sp3)"><div class="fold" style="font-size:var(--s1);font-weight:600">Q{qi+1}. {_esc(_clean(question))}</div><div class="fold-body">{platform_items if platform_items else nd}</div></div>' return panels -
__init__.py 0 B
-
-
cn30_search.py 49.2 KB
#!/usr/bin/env python3 """ cn-last30days: 中国社媒平台话题研究工具 ========================================== 从小红书、抖音、公众号三大平台搜索过去30天内人们关于某话题的真实讨论。 Usage: python cn_last30days.py "AI视频工具" python cn_last30days.py "大模型" --output-format html python cn_last30days.py "小红书运营" --platforms xhs,gzh """ from __future__ import annotations import argparse import json import os import sys import time from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any from urllib.parse import quote # Windows stdout UTF-8 if os.name == "nt": for stream in (sys.stdout, sys.stderr): if hasattr(stream, "reconfigure"): stream.reconfigure(encoding="utf-8", errors="replace") # ─── 常量 ────────────────────────────────────────────────────────────────────────── VERSION = "v2.0.0" # 与 SKILL.md 徽章、references/cn30-output-rules.md 保持一致 API_BASE = "https://redfox.hk/story/api/multiPlatform/workSearch" PLATFORMS = { "xhs": { "label": "小红书", "result_key": "xhsResult", }, "dy": { "label": "抖音", "result_key": "dyResult", }, "gzh": { "label": "公众号", "result_key": "gzhResult", }, } DEFAULT_COUNT = 50 SOURCE_LABEL = "多平台话题研究-GitHub" # ─── API Key ──────────────────────────────────────────────────────────────────────── class InsufficientCreditsError(Exception): """API 积分不足错误""" pass def get_api_key(cli_key: str | None = None) -> str: """按优先级获取 API Key: 命令行 > 环境变量 > 配置文件""" if cli_key: return cli_key # 环境变量 for env_name in ("REDFOX_API_KEY", "X_API_KEY"): val = os.environ.get(env_name, "").strip() if val: return val # 配置文件 config_path = os.path.expanduser("~/.qoder/apis/redfox.json") if os.path.isfile(config_path): try: with open(config_path) as f: cfg = json.load(f) val = (cfg.get("api_key") or "").strip() if val: return val except Exception: pass return "" # ─── 数量解析 ─────────────────────────────────────────────────────────────────────── def parse_count(value: Any) -> int: """解析数量字段,支持 '1.2w'、'5000+' 等中文格式""" if value is None: return 0 if isinstance(value, (int, float)): return int(value) text = str(value).replace("+", "").replace(",", "").strip() if not text: return 0 try: if "w" in text.lower(): return int(float(text.lower().replace("w", "")) * 10000) if text.endswith("万"): return int(float(text[:-1]) * 10000) if text.endswith("亿"): return int(float(text[:-1]) * 100000000) return int(float(text)) except (TypeError, ValueError): return 0 def fuzzy_count(value: Any) -> str: """模糊化互动数,5000以下保留原始值""" num = parse_count(value) if num <= 0: return "--" if num < 5000: return str(num) if num < 10000: return "5000+" wan = num // 10000 return f"{wan}w+" # ─── HTTP 请求 ────────────────────────────────────────────────────────────────────── def _http_post(url: str, payload: dict, api_key: str, max_retries: int = 3) -> dict: """带重试的 HTTP POST 请求""" import urllib.request import urllib.error headers = { "Content-Type": "application/json", "X-API-KEY": api_key, "User-Agent": "cn-last30days/1.0", } body = json.dumps(payload, ensure_ascii=False).encode("utf-8") last_error = None for attempt in range(max_retries): try: req = urllib.request.Request(url, data=body, headers=headers, method="POST") with urllib.request.urlopen(req, timeout=30) as resp: raw = resp.read().decode("utf-8") result = json.loads(raw) code = result.get("code") if code == 3108: # 限频,等待重试 time.sleep(5 * (attempt + 1)) continue if code == 3201: # 积分不足,不可重试 raise InsufficientCreditsError(result.get("msg", "积分不足")) if code not in (200, 2000): raise Exception(f"API 错误 code={code}: {result.get('msg', '未知')}") return result except urllib.error.HTTPError as e: last_error = f"HTTP {e.code}" if attempt < max_retries - 1: time.sleep(2 ** attempt) except urllib.error.URLError as e: last_error = f"网络错误: {e.reason}" if attempt < max_retries - 1: time.sleep(2 ** attempt) except Exception as e: last_error = str(e) if attempt < max_retries - 1: time.sleep(2 ** attempt) raise Exception(f"请求失败: {last_error}(已尝试 {max_retries} 次)") def _first_of(art: dict, *keys: str, default: Any = None) -> Any: """从文章字典中按优先级取第一个非空值""" for k in keys: v = art.get(k) if v is not None and v != "" and v != 0: return v return default def _normalize_article(art: dict, platform: str, idx: int) -> dict: """将不同平台的数据归一化为统一格式""" if platform == "xhs": return _normalize_xhs(art, idx) elif platform == "dy": return _normalize_dy(art, idx) elif platform == "gzh": return _normalize_gzh(art, idx) return art def _normalize_xhs(art: dict, idx: int) -> dict: """归一化小红书数据 - 兼容 xhsUser/searchArticle (work*前缀) 和 xhs/search/search 两种格式""" note_id = str(_first_of(art, "workId", "id", "noteId", "workUuid", "uuid", default="")) author_id = str(_first_of(art, "accountUserid", "authorId", "accountId", default="")) title_raw = _first_of(art, "workTitle", "title", "displayTitle", default="") desc_raw = _first_of(art, "workDesc", "desc", "displayDesc", "summary", default="") title = (title_raw or desc_raw or "无标题")[:200] desc = (desc_raw or "")[:500] # 链接 note_link = _first_of(art, "workUrl", "shareInfoLink", "url", default="") if not note_link and note_id: xsec_token = art.get("xsecToken", "") if xsec_token: note_link = f"https://www.xiaohongshu.com/explore/{note_id}?xsec_token={xsec_token}" else: note_link = f"https://www.xiaohongshu.com/explore/{note_id}" author_link = f"https://www.xiaohongshu.com/user/profile/{author_id}" if author_id else "" # 作者 author_name = _first_of(art, "accountNickname", "authorNickname", "author", "accountName", "nickname", default="未知") # 时间 pub_time = _first_of(art, "workPublishTime", "createTime", "publishTime", "time", default="") if isinstance(pub_time, (int, float)) and pub_time > 1000000000000: from datetime import datetime as _dt try: pub_time = _dt.fromtimestamp(pub_time / 1000.0).strftime("%Y-%m-%d %H:%M:%S") except (OSError, ValueError): pub_time = str(pub_time) # 封面 cover = _first_of(art, "coverUrl", "cover", default="") # 账号类型 account_type = _first_of(art, "accountType", default="") # 笔记类型 work_type = _first_of(art, "workType", "noteType", default="") return { "id": f"XHS{idx}", "platform": "小红书", "platform_key": "xhs", "title": title, "desc": desc, "url": note_link, "author": author_name, "author_id": author_id, "author_link": author_link, "author_fans": fuzzy_count(_first_of(art, "authorFans", "followerCount", default=0)), "published_at": str(pub_time), "engagement": { "likes": parse_count(_first_of(art, "workLikedCount", "likedCount", "likeCount", default=0)), "comments": parse_count(_first_of(art, "workCommentsCount", "commentsCount", "commentCount", default=0)), "collects": parse_count(_first_of(art, "workCollectedCount", "collectedCount", "collectCount", default=0)), "shares": parse_count(_first_of(art, "workSharedCount", "sharedCount", "shareCount", default=0)), "interactions": parse_count(_first_of(art, "interactiveCount", default=0)), }, "engagement_display": _engagement_display(art, "xhs"), "cover": cover, "scores": _extract_scores(art), "account_type": account_type, "work_type": work_type, } def _normalize_dy(art: dict, idx: int) -> dict: """归一化抖音数据 - 兼容 dyData/searchArticle 和 dy/search/search 两种格式""" work_url = _first_of(art, "workUrl", "url", default="") title_raw = _first_of(art, "title", "desc", default="") desc_raw = _first_of(art, "desc", "summary", default="") title = (title_raw or "无标题")[:200] desc = (desc_raw or "")[:500] author_name = _first_of(art, "accountName", "author", "authorNickname", default="未知") author_id = str(_first_of(art, "accountId", "authorId", default="")) pub_time = _first_of(art, "publishTime", "createTime", default="") cover = _first_of(art, "cover", "coverUrl", default="") return { "id": f"DY{idx}", "platform": "抖音", "platform_key": "dy", "title": title, "desc": desc, "url": work_url, "author": author_name, "author_id": author_id, "author_link": f"https://www.douyin.com/user/{author_id}" if author_id else "", "author_fans": fuzzy_count(_first_of(art, "followerCount", "authorFans", default=0)), "published_at": str(pub_time), "engagement": { "likes": parse_count(_first_of(art, "likeCount", "likedCount", default=0)), "comments": parse_count(_first_of(art, "commentCount", "commentsCount", default=0)), "collects": parse_count(_first_of(art, "collectCount", "collectedCount", default=0)), "shares": parse_count(_first_of(art, "shareCount", "sharedCount", default=0)), }, "engagement_display": _engagement_display(art, "dy"), "cover": cover, "scores": _extract_scores(art), } def _normalize_gzh(art: dict, idx: int) -> dict: """归一化公众号数据 - 适配 gzh/search/hotArticle 格式""" url = _first_of(art, "url", "workUrl", default="") title = (art.get("title") or "无标题")[:200] summary = _first_of(art, "summary", "desc", default="") author_name = _first_of(art, "author", "accountName", default="-") author_id = str(_first_of(art, "accountId", "authorId", default="")) pub_time = _first_of(art, "publicTime", "publishTime", "createTime", default="") cover = _first_of(art, "imageUrl", "coverUrl", "cover", default="") return { "id": f"GZH{idx}", "platform": "公众号", "platform_key": "gzh", "title": title, "desc": (summary or "")[:500], "url": url, "author": author_name, "author_id": author_id, "author_link": "", "author_fans": fuzzy_count(_first_of(art, "followerCount", "authorFans", default=0)), "published_at": str(pub_time), "engagement": { "reads": parse_count(_first_of(art, "clicksCount", "readCount", default=0)), "likes": parse_count(_first_of(art, "likeCount", "likedCount", default=0)), "watches": parse_count(_first_of(art, "watchCount", default=0)), "collects": parse_count(_first_of(art, "collectCount", "collectedCount", default=0)), "shares": parse_count(_first_of(art, "shareCount", "sharedCount", default=0)), "comments": parse_count(_first_of(art, "commentsCount", "commentCount", default=0)), }, "engagement_display": _engagement_display(art, "gzh"), "cover": cover, "scores": _extract_scores(art), } def _engagement_display(art: dict, platform: str) -> str: """生成可读的互动数据字符串""" if platform == "xhs": likes = fuzzy_count(_first_of(art, "workLikedCount", "likedCount", "likeCount", default=0)) comments = fuzzy_count(_first_of(art, "workCommentsCount", "commentsCount", "commentCount", default=0)) collects = fuzzy_count(_first_of(art, "workCollectedCount", "collectedCount", "collectCount", default=0)) interactions = fuzzy_count(_first_of(art, "interactiveCount", default=0)) return f"🔥{interactions}互动 👍{likes} ⭐{collects} 💬{comments}" elif platform == "dy": likes = fuzzy_count(_first_of(art, "workLikedCount", "likeCount", "likedCount", default=0)) comments = fuzzy_count(_first_of(art, "workCommentsCount", "commentCount", "commentsCount", default=0)) shares = fuzzy_count(_first_of(art, "workSharedCount", "shareCount", "sharedCount", default=0)) collects = fuzzy_count(_first_of(art, "workCollectedCount", "collectCount", "collectedCount", default=0)) return f"👍{likes} 💬{comments} ⭐{collects} 🔄{shares}" elif platform == "gzh": reads = fuzzy_count(_first_of(art, "clicksCount", "readCount", default=0)) likes = fuzzy_count(_first_of(art, "likeCount", "likedCount", default=0)) watches = fuzzy_count(_first_of(art, "watchCount", default=0)) comments = fuzzy_count(_first_of(art, "commentsCount", "commentCount", default=0)) shares = fuzzy_count(_first_of(art, "shareCount", "sharedCount", default=0)) return f"📖{reads} 👍{likes} 👁{watches} 💬{comments} 🔄{shares}" return "" def _extract_scores(art: dict) -> dict: """提取评分字段(如有关键词搜索评分)""" return { "total": art.get("totalScore", 0), "relevance": art.get("relevanceScore", 0), "popularity": art.get("popularityScore", 0), "recency": art.get("recencyScore", 0), } # ─── 主搜索函数 ───────────────────────────────────────────────────────────────────── def search( keyword: str, platforms: list[str] | None = None, count: int = DEFAULT_COUNT, api_key: str | None = None, days: int = 30, ) -> dict: """通过统一接口搜索多平台话题数据""" if not platforms: platforms = list(PLATFORMS.keys()) key = get_api_key(api_key) if not key: sys.stderr.write("\u274c 未找到 API Key,请先配置:\n") sys.stderr.write(" export REDFOX_API_KEY=ak_你的密钥\n") sys.stderr.write(" 或使用 --api-key 参数传入\n") sys.stderr.write(" 注册地址: https://www.redfox.hk/login\n") sys.stderr.flush() sys.exit(1) # 构建统一请求参数 today = datetime.now() start_date = (today - timedelta(days=days)).strftime("%Y-%m-%d") end_date = today.strftime("%Y-%m-%d") payload = { "keyword": keyword, "source": SOURCE_LABEL, "startDate": start_date, "endDate": end_date, } sys.stderr.write(f"[\u2699\ufe0f] 搜索中: {keyword} ...\n") sys.stderr.flush() results = {} credit_error = False try: result = _http_post(API_BASE, payload, key) data = result.get("data") or {} for p in platforms: if p not in PLATFORMS: results[p] = {"platform": p, "label": p, "items": [], "total": 0, "error": "未知平台"} continue plat = PLATFORMS[p] label = plat["label"] result_key = plat["result_key"] articles = data.get(result_key, []) if isinstance(articles, dict): articles = articles.get("articles", []) if not isinstance(articles, list): articles = [] # 去重并归一化 all_articles = [] seen_ids = set() for art in articles: uid = ( art.get("workUuid") or art.get("uuid") or art.get("id") or art.get("noteId") or "" ) if uid and uid in seen_ids: continue if uid: seen_ids.add(uid) item = _normalize_article(art, p, len(all_articles) + 1) all_articles.append(item) if len(all_articles) >= count: break sys.stderr.write(f"[{label}] 获取 {len(all_articles)} 条\n") sys.stderr.flush() results[p] = { "platform": p, "label": label, "items": all_articles[:count], "total": len(all_articles[:count]), } except InsufficientCreditsError as e: sys.stderr.write(f"⚠️ {e}\n") sys.stderr.write(f"请配置个人 API Key: export REDFOX_API_KEY=你的密钥\n") sys.stderr.write(f"注册地址: https://www.redfox.hk/login\n") sys.stderr.flush() credit_error = True except Exception as e: sys.stderr.write(f"请求失败: {e}\n") sys.stderr.flush() # 为未处理的平台填充空结果 for p in platforms: if p not in results: results[p] = { "platform": p, "label": PLATFORMS[p]["label"], "items": [], "total": 0, } if credit_error: results[p]["error"] = "积分不足,请配置个人 API Key" # 汇总统计 total_items = sum(r["total"] for r in results.values()) today_utc = datetime.now(timezone.utc) return { "keyword": keyword, "searched_at": today_utc.isoformat(), "date_range": { "from": (today_utc - timedelta(days=days)).strftime("%Y-%m-%d"), "to": today_utc.strftime("%Y-%m-%d"), }, "platforms": results, "total_items": total_items, } # ─── 引擎页脚 ─────────────────────────────────────────────────────────────────────── def build_engine_footer(platforms: dict) -> str: """构造引擎页脚,供报告逐字粘贴 references/cn30-output-rules.md 的 LAW 1 / LAW 4 要求输出中包含 「🇨🇳 数据源: …」页脚并逐字粘贴。该页脚由本函数生成, 通过 summary.engine_footer 字段(stdout JSON)暴露给调用方。 Args: platforms: data["platforms"] 结构 Returns: str: 单行页脚,如 "🇨🇳 数据源: 小红书 · 抖音 · 公众号 · 近30天 · cn-last30days v2.0.0" """ labels = [v.get("label", k) for k, v in platforms.items() if not v.get("error")] if not labels: labels = [v.get("label", k) for k, v in platforms.items()] total = sum(v.get("total", 0) for v in platforms.values()) return ( f"🇨🇳 数据源: {' · '.join(labels)} · 近30天 · " f"共 {total} 条 · cn-last30days {VERSION}" ) # ─── JSON 输出 ────────────────────────────────────────────────────────────────────── def format_as_json(data: dict, max_items: int = 50) -> dict: """精简 JSON 格式(供 AI 智能体分析使用)""" output = { "keyword": data["keyword"], "searched_at": data["searched_at"], "date_range": data["date_range"], "total_items": data["total_items"], "platforms": {}, } for pkey, pdata in data["platforms"].items(): items = [] for item in pdata.get("items", [])[:max_items]: items.append({ "id": item["id"], "platform": item["platform"], "title": item["title"], "author": item["author"], "author_fans": item["author_fans"], "published_at": item["published_at"], "engagement_display": item["engagement_display"], "engagement": item["engagement"], "url": item["url"], "desc": item["desc"][:200], "scores": item.get("scores", {}), }) output["platforms"][pkey] = { "label": pdata["label"], "total": pdata["total"], "items": items, } if pdata.get("error"): output["platforms"][pkey]["error"] = pdata["error"] return output # ─── HTML 报告 ────────────────────────────────────────────────────────────────────── def _md_to_html(text: str) -> str: """简易 Markdown → HTML 转换(无第三方依赖)""" import re lines = text.split("\n") out = [] in_list = False for line in lines: stripped = line.strip() # 标题 if stripped.startswith("### "): if in_list: out.append("</ul>"); in_list = False out.append(f'<h4>{stripped[4:]}</h4>') elif stripped.startswith("## "): if in_list: out.append("</ul>"); in_list = False out.append(f'<h3>{stripped[2:]}</h3>') elif stripped.startswith("# "): if in_list: out.append("</ul>"); in_list = False out.append(f'<h2>{stripped[2:]}</h2>') # 分隔线 elif stripped == "---": if in_list: out.append("</ul>"); in_list = False out.append('<hr>') # 无序列表 elif stripped.startswith("- "): if not in_list: out.append('<ul>'); in_list = True content = _md_inline(stripped[2:]) out.append(f'<li>{content}</li>') # 有序列表 elif re.match(r'^\d+\.\s', stripped): if not in_list: out.append('<ol>'); in_list = True content = _md_inline(re.sub(r'^\d+\.\s', '', stripped)) out.append(f'<li>{content}</li>') # 空行 elif not stripped: if in_list: out.append("</ul>"); in_list = False out.append('') # 普通段落 else: if in_list: out.append("</ul>"); in_list = False out.append(f'<p>{_md_inline(stripped)}</p>') if in_list: out.append("</ul>") return "\n".join(out) def _md_inline(text: str) -> str: """行内 Markdown 转换:粗体、链接""" import re text = text.replace("&", "&").replace("<", "<").replace(">", ">") # [text](url) text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<a href="\2" target="_blank">\1</a>', text) # **bold** text = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', text) return text def format_as_html(data: dict, max_items: int = 50, report_html: str = "") -> str: """生成网站风格 HTML 报告""" keyword = data["keyword"] total = data["total_items"] date_range = data["date_range"] # 平台配色和图标 platform_meta = { "xhs": {"primary": "#ff2442", "bg": "#fff1f0", "icon": "📕", "name": "小红书"}, "dy": {"primary": "#161823", "bg": "#f5f5f5", "icon": "🎵", "name": "抖音"}, "gzh": {"primary": "#07c160", "bg": "#f0fff4", "icon": "📖", "name": "公众号"}, } # 统计卡片 stats_html = "" for pkey, pdata in data["platforms"].items(): meta = platform_meta.get(pkey, platform_meta["xhs"]) ptotal = pdata["total"] # 统计总互动 total_likes = sum(it.get("engagement", {}).get("likes", 0) for it in pdata.get("items", [])[:max_items]) total_reads = sum( it.get("engagement", {}).get("reads", 0) + it.get("engagement", {}).get("likes", 0) + it.get("engagement", {}).get("collects", 0) + it.get("engagement", {}).get("shares", 0) + it.get("engagement", {}).get("comments", 0) for it in pdata.get("items", [])[:max_items] ) m_primary = meta["primary"] m_bg = meta["bg"] m_icon = meta["icon"] m_name = meta["name"] stats_html += f''' <div class="stat-card" style="--p-color: {m_primary}; --p-bg: {m_bg}"> <div class="stat-icon">{m_icon}</div> <div class="stat-body"> <div class="stat-label">{m_name}</div> <div class="stat-num">{ptotal} <small>条</small></div> </div> </div>''' # 构建 Tab 和内容 tabs_html = "" panels_html = "" for pkey, pdata in data["platforms"].items(): meta = platform_meta.get(pkey, platform_meta["xhs"]) label = pdata["label"] ptotal = pdata["total"] is_first = pkey == list(data["platforms"].keys())[0] active = " active" if is_first else "" m_primary = meta["primary"] m_icon = meta["icon"] tabs_html += ( '<button class="tab-btn' + active + '" data-platform="' + pkey + '" ' 'style="--tab-color: ' + m_primary + '">' + m_icon + ' ' + label + ' <span class="tab-count">' + str(ptotal) + '</span></button>\n' ) display = "block" if is_first else "none" items = pdata.get("items", [])[:max_items] error_html = "" if pdata.get("error"): error_html = '<div class="error-banner">⚠️ ' + pdata["error"] + '</div>' cards = "" for idx, item in enumerate(items): title_escaped = item["title"].replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """) desc_escaped = item["desc"][:200].replace("&", "&").replace("<", "<").replace(">", ">") if item.get("desc") else "" author_escaped = item["author"].replace("&", "&").replace("<", "<").replace(">", ">") item_url = item.get("url", "") url_attr = 'href="' + item_url + '"' if item_url else 'href="#"' author_link = item.get("author_link", "") author_html = ('<a href="' + author_link + '" class="author-link" target="_blank">' + author_escaped + '</a>') if author_link else ('<span class="author-name">' + author_escaped + '</span>') # 互动数据标签 — 按平台展示对应字段,始终显示(包括0值),使用fuzzy_count格式化 eng = item.get("engagement", {}) eng_tags = "" if pkey == "gzh": reads = fuzzy_count(eng.get("reads", 0)) likes = fuzzy_count(eng.get("likes", 0)) shares = fuzzy_count(eng.get("shares", 0)) eng_tags = ('<span class="eng-tag reads">📖 ' + reads + '</span>' + '<span class="eng-tag">👍 ' + likes + '</span>' + '<span class="eng-tag">🔄 ' + shares + '</span>') elif pkey == "xhs": likes = fuzzy_count(eng.get("likes", 0)) collects = fuzzy_count(eng.get("collects", 0)) comments = fuzzy_count(eng.get("comments", 0)) eng_tags = ('<span class="eng-tag">👍 ' + likes + '</span>' + '<span class="eng-tag">⭐ ' + collects + '</span>' + '<span class="eng-tag">💬 ' + comments + '</span>') elif pkey == "dy": likes = fuzzy_count(eng.get("likes", 0)) comments = fuzzy_count(eng.get("comments", 0)) shares = fuzzy_count(eng.get("shares", 0)) eng_tags = ('<span class="eng-tag">👍 ' + likes + '</span>' + '<span class="eng-tag">💬 ' + comments + '</span>' + '<span class="eng-tag">🔄 ' + shares + '</span>') author_fans = item.get("author_fans", "--") pub_date = item.get("published_at", "")[:10] if item.get("published_at") else "--" desc_html = ('<p class="card-desc">' + desc_escaped + '</p>') if desc_escaped else '' rank_num = idx + 1 # 公众号没有粉丝数,不展示粉丝字段 if pkey == "gzh": fans_html = '' else: fans_html = ( ' <span class="dot">·</span>\n' ' <span class="fans">' + str(author_fans) + '粉</span>\n' ) cards += ( '\n <div class="card" style="--card-accent: ' + m_primary + '">\n' ' <div class="card-rank">' + str(rank_num) + '</div>\n' ' <div class="card-body">\n' ' <a ' + url_attr + ' class="card-title" target="_blank">' + title_escaped + '</a>\n' ' <div class="card-meta">\n' ' ' + author_html + '\n' + fans_html + ' <span class="dot">·</span>\n' ' <span class="time">' + pub_date + '</span>\n' ' </div>\n' ' ' + desc_html + '\n' ' <div class="card-footer">\n' ' <div class="engagement">' + eng_tags + '</div>\n' ' <a ' + url_attr + ' class="view-btn" target="_blank">查看原文 ↗</a>\n' ' </div>\n' ' </div>\n' ' </div>' ) xhs_notice = ( '<div style="background:#fff3cd;border:1px solid #ffc107;border-radius:8px;padding:10px 14px;margin-bottom:14px;color:#664d03;font-size:13px;line-height:1.6">' '⚠️ 受小红书风控规则限制,部分作品链接可能无法正常跳转,您可复制对应作品标题前往小红书搜索查看,感谢理解🙇♀️🙇♀️' '</div>' ) if pkey == "xhs" else "" no_data_html = "<div class='no-data-hint'><p>未查询到相关内容,建议更换关键词重试。</p></div>" if not items else "" panels_html += ( '\n <div class="tab-panel" id="panel-' + pkey + '" style="display: ' + display + '">\n' ' ' + error_html + '\n' ' ' + xhs_notice + '\n' ' <div class="card-list">\n' ' ' + cards + '\n' ' </div>\n' ' ' + no_data_html + '\n' ' </div>' ) # ── 研究报告区域 ── report_section = "" if report_html: report_section = f''' <div class="report-section"> <h2 class="section-title">📝 研究报告</h2> <div class="report-content">{report_html}</div> </div>''' html = f'''<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>cn-last30days · {keyword}</title> <style> * {{ margin: 0; padding: 0; box-sizing: border-box; }} :root {{ --primary: #4f46e5; --primary-light: #e0e7ff; --text: #1f2937; --text-secondary: #6b7280; --border: #e5e7eb; --bg: #f9fafb; --card-bg: #ffffff; --radius: 12px; }} body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Noto Sans SC', Roboto, sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; }} /* ── 导航栏 ── */ .navbar {{ background: white; border-bottom: 1px solid var(--border); padding: 12px 24px; position: sticky; top: 0; z-index: 100; display: flex; align-items: center; gap: 12px; }} .navbar .logo {{ font-size: 18px; font-weight: 800; color: var(--primary); letter-spacing: -0.5px; }} .navbar .logo span {{ color: #ef4444; }} .navbar .badge {{ background: var(--primary-light); color: var(--primary); font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 20px; }} /* ── Hero ── */ .hero {{ background: linear-gradient(135deg, #1e1b4b 0%, #312e81 50%, #4338ca 100%); color: white; padding: 48px 24px; text-align: center; }} .hero h1 {{ font-size: 28px; font-weight: 800; margin-bottom: 8px; }} .hero .keyword {{ display: inline-block; background: rgba(255,255,255,0.15); border-radius: 8px; padding: 4px 16px; font-size: 16px; margin: 8px 0; }} .hero .date-range {{ font-size: 14px; opacity: 0.8; }} /* ── 统计卡片 ── */ .stats-row {{ display: flex; gap: 16px; padding: 24px; max-width: 960px; margin: -28px auto 0; position: relative; z-index: 10; flex-wrap: wrap; justify-content: center; }} .stat-card {{ background: white; border-radius: var(--radius); padding: 20px 24px; box-shadow: 0 4px 12px rgba(0,0,0,0.08); flex: 1; min-width: 180px; display: flex; align-items: center; gap: 16px; border-left: 4px solid var(--p-color); }} .stat-icon {{ font-size: 32px; }} .stat-label {{ font-size: 13px; color: var(--text-secondary); }} .stat-num {{ font-size: 24px; font-weight: 800; color: var(--text); }} .stat-num small {{ font-size: 13px; font-weight: 400; color: var(--text-secondary); }} /* ── 主内容区 ── */ .main {{ max-width: 960px; margin: 24px auto; padding: 0 24px; }} /* ── Tab ── */ .tab-bar {{ display: flex; gap: 4px; background: white; border-radius: var(--radius) var(--radius) 0 0; padding: 8px 8px 0; border: 1px solid var(--border); border-bottom: none; }} .tab-btn {{ padding: 12px 24px; border: none; background: transparent; font-size: 15px; font-weight: 600; cursor: pointer; color: var(--text-secondary); border-radius: 8px 8px 0 0; transition: all 0.2s; position: relative; }} .tab-btn:hover {{ background: var(--bg); color: var(--tab-color); }} .tab-btn.active {{ background: var(--bg); color: var(--tab-color); }} .tab-btn.active::after {{ content: ''; position: absolute; bottom: 0; left: 20%; right: 20%; height: 3px; background: var(--tab-color); border-radius: 3px 3px 0 0; }} .tab-count {{ font-size: 12px; background: var(--bg); padding: 2px 8px; border-radius: 10px; font-weight: 500; margin-left: 4px; }} .tab-btn.active .tab-count {{ background: var(--primary-light); color: var(--tab-color); }} /* ── 卡片列表 ── */ .card-list {{ background: white; border: 1px solid var(--border); border-radius: 0 0 var(--radius) var(--radius); }} .card {{ display: flex; gap: 16px; padding: 20px 24px; border-bottom: 1px solid #f3f4f6; transition: background 0.15s; }} .card:last-child {{ border-bottom: none; }} .card:hover {{ background: #fafbfc; }} .card-rank {{ font-size: 20px; font-weight: 800; color: var(--card-accent); min-width: 32px; text-align: center; padding-top: 2px; opacity: 0.8; }} .card-body {{ flex: 1; min-width: 0; }} .card-title {{ font-size: 16px; font-weight: 600; color: var(--text); text-decoration: none; line-height: 1.5; display: block; transition: color 0.15s; }} .card-title:hover {{ color: var(--card-accent); }} .card-meta {{ font-size: 13px; color: var(--text-secondary); margin-top: 6px; display: flex; align-items: center; gap: 0; flex-wrap: wrap; }} .author-link {{ color: var(--primary); text-decoration: none; font-weight: 500; transition: color 0.15s; }} .author-link:hover {{ color: var(--card-accent); text-decoration: underline; }} .author-name {{ color: var(--primary); font-weight: 500; }} .dot {{ margin: 0 6px; }} .card-desc {{ font-size: 14px; color: var(--text-secondary); line-height: 1.6; margin-top: 8px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }} .card-footer {{ display: flex; justify-content: space-between; align-items: center; margin-top: 10px; }} .engagement {{ display: flex; gap: 8px; flex-wrap: wrap; }} .eng-tag {{ font-size: 12px; color: var(--text-secondary); background: #f3f4f6; padding: 2px 8px; border-radius: 4px; }} .eng-tag.reads {{ background: #ecfdf5; color: #065f46; }} .view-btn {{ font-size: 13px; color: var(--card-accent); text-decoration: none; font-weight: 500; white-space: nowrap; transition: opacity 0.15s; }} .view-btn:hover {{ opacity: 0.7; }} /* ── 其他 ── */ .error-banner {{ background: #fef3c7; color: #92400e; padding: 12px 16px; border-radius: 8px; margin: 16px 24px; font-size: 14px; }} .no-data-hint {{ text-align: center; color: var(--text-secondary); padding: 48px; }} /* ── 研究报告 ── */ .report-section {{ max-width: 960px; margin: 0 auto; padding: 0 24px; }} .section-title {{ font-size: 20px; font-weight: 800; color: var(--text); margin-bottom: 16px; padding-bottom: 8px; border-bottom: 2px solid var(--primary); }} .report-content {{ background: white; border: 1px solid var(--border); border-radius: var(--radius); padding: 32px; line-height: 1.8; color: var(--text); font-size: 15px; }} .report-content h2 {{ font-size: 20px; font-weight: 800; color: var(--primary); margin: 24px 0 12px; }} .report-content h3 {{ font-size: 18px; font-weight: 700; color: var(--text); margin: 20px 0 10px; }} .report-content h4 {{ font-size: 16px; font-weight: 700; color: var(--text); margin: 16px 0 8px; }} .report-content p {{ margin-bottom: 12px; }} .report-content strong {{ color: #1e1b4b; }} .report-content a {{ color: var(--primary); text-decoration: none; }} .report-content a:hover {{ text-decoration: underline; }} .report-content ul, .report-content ol {{ margin: 8px 0 12px 20px; }} .report-content li {{ margin-bottom: 4px; }} .report-content hr {{ border: none; border-top: 1px solid var(--border); margin: 20px 0; }} .footer {{ text-align: center; color: var(--text-secondary); font-size: 12px; padding: 32px 24px; margin-top: 16px; }} .footer a {{ color: var(--primary); text-decoration: none; }} /* ── 响应式 ── */ @media (max-width: 640px) {{ .hero {{ padding: 32px 16px; }} .hero h1 {{ font-size: 20px; }} .stats-row {{ padding: 16px; margin-top: -20px; }} .stat-card {{ min-width: 140px; padding: 14px 16px; }} .stat-num {{ font-size: 20px; }} .main {{ padding: 0 12px; }} .card {{ padding: 14px 16px; gap: 10px; }} .card-rank {{ font-size: 16px; min-width: 24px; }} .card-title {{ font-size: 15px; }} }} </style> </head> <body> <nav class="navbar"> <div class="logo">🇨🇳 cn<span>-last30days</span></div> <div class="badge">{VERSION}</div> </nav> <div class="hero"> <h1>中国社媒话题研究</h1> <div class="keyword">{keyword}</div> <div class="date-range">{date_range["from"]} ~ {date_range["to"]}</div> </div> <div class="stats-row"> {stats_html} <div class="stat-card" style="--p-color: var(--primary); --p-bg: var(--primary-light)"> <div class="stat-icon">📊</div> <div class="stat-body"> <div class="stat-label">合计</div> <div class="stat-num">{total} <small>条</small></div> </div> </div> </div> {report_section} <div class="main"> <div class="tab-bar"> {tabs_html} </div> {panels_html} </div> <div class="footer"> 数据来源:<a href="https://redfox.hk" target="_blank">redfox.hk</a> API · 小红书 / 抖音 / 公众号 · 近30天热门内容<br> 互动数据为入库快照,实时数据可能持续增长 </div> <script> document.querySelectorAll('.tab-btn').forEach(btn => {{ btn.addEventListener('click', () => {{ document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); document.querySelectorAll('.tab-panel').forEach(p => p.style.display = 'none'); btn.classList.add('active'); document.getElementById('panel-' + btn.dataset.platform).style.display = 'block'; }}); }}); </script> </body> </html>''' return html # ─── CLI ───────────────────────────────────────────────────────────────────────────── def main(): parser = argparse.ArgumentParser( description="cn-last30days: 中国社媒平台话题研究工具" ) parser.add_argument("keyword", nargs="?", default="dummy", help="搜索关键词(--from-json 模式下可省略)") parser.add_argument( "--platforms", "-p", default="xhs,dy,gzh", help="平台列表,逗号分隔(默认: xhs,dy,gzh)" ) parser.add_argument( "--count", "-n", type=int, default=DEFAULT_COUNT, help=f"每个平台获取条数(默认: {DEFAULT_COUNT})" ) parser.add_argument( "--days", "-d", type=int, default=30, help="搜索时间范围,最近多少天(默认: 30,最大: 30)" ) parser.add_argument( "--output-format", "-f", choices=["json", "html", "both"], default="json", help="输出格式(默认: json,综合报告后再按需生成HTML)" ) parser.add_argument( "--output-dir", default=str(Path.home() / "Downloads" / "CnLast30Days"), help="HTML 输出目录" ) parser.add_argument( "--api-key", default=None, help="API Key(覆盖环境变量和配置文件)" ) parser.add_argument( "--max-items", type=int, default=50, help="输出中最多展示条数(默认: 50)" ) parser.add_argument( "--from-json", default=None, help="从已有 JSON 文件生成 HTML,不调用 API(值: JSON 文件路径)" ) parser.add_argument( "--report-file", default=None, help="研究报告 Markdown 文件路径(嵌入到 HTML 报告顶部)" ) parser.add_argument( "--debug", action="store_true", help="调试模式,打印原始 API 响应" ) args = parser.parse_args() # ── 从 JSON 生成 HTML 模式 ── if args.from_json: json_path = Path(args.from_json) if not json_path.exists(): sys.stderr.write(f"错误: JSON 文件不存在: {json_path}\n") sys.exit(1) raw = json.loads(json_path.read_text(encoding="utf-8")) # 从精简 JSON 反向构造 data 结构 data = { "keyword": raw.get("keyword", ""), "total_items": raw.get("total_items", 0), "date_range": raw.get("date_range", {}), "platforms": {}, } for pkey, pdata in raw.get("platforms", {}).items(): items = [] for it in pdata.get("items", []): item = dict(it) item["source"] = pkey items.append(item) data["platforms"][pkey] = { "label": pdata.get("label", pkey), "total": pdata.get("total", len(items)), "items": items, } # 读取研究报告 report_html = "" if args.report_file: report_path = Path(args.report_file) if report_path.exists(): report_md = report_path.read_text(encoding="utf-8") report_html = _md_to_html(report_md) else: sys.stderr.write(f"⚠️ 报告文件不存在: {report_path},跳过\n") html_content = format_as_html(data, max_items=args.max_items, report_html=report_html) output_dir = Path(args.output_dir) output_dir.mkdir(parents=True, exist_ok=True) base_name = json_path.stem # 用原 JSON 文件名 html_file = output_dir / f"{base_name}.html" html_file.write_text(html_content, encoding="utf-8") sys.stderr.write(f"✅ HTML 已保存: {html_file}\n") print(json.dumps({"files": {"html": str(html_file)}}, ensure_ascii=False)) return # ── 正常搜索模式 ── # 解析平台列表 platforms = [] for p in args.platforms.split(","): p = p.strip().lower() if p in PLATFORMS: platforms.append(p) else: sys.stderr.write(f"未知平台: {p},可用: {', '.join(PLATFORMS.keys())}\n") if not platforms: sys.stderr.write("错误: 未指定有效平台\n") sys.exit(1) # 执行搜索 keyword = args.keyword.strip() if not keyword: sys.stderr.write("错误: 关键词不能为空\n") sys.exit(1) sys.stderr.write(f"\n{'='*60}\n") sys.stderr.write(f"cn-last30days · 搜索: {keyword}\n") sys.stderr.write(f"平台: {', '.join(PLATFORMS[p]['label'] for p in platforms)}\n") sys.stderr.write(f"每平台: {args.count} 条 | 时间: 近{args.days}天\n") sys.stderr.write(f"{'='*60}\n\n") sys.stderr.flush() try: data = search( keyword=keyword, platforms=platforms, count=args.count, api_key=args.api_key, days=args.days, ) except Exception as e: sys.stderr.write(f"\n❌ 搜索失败: {e}\n") sys.exit(1) # 准备输出目录和文件名 output_dir = Path(args.output_dir) output_dir.mkdir(parents=True, exist_ok=True) keyword_safe = keyword.replace('"', '').replace(' ', '_')[:30] timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") base_name = f"cn30days_{keyword_safe}_{timestamp}" # 保存 JSON json_file = None if args.output_format in ("json", "both"): json_data = format_as_json(data, max_items=args.max_items) json_file = output_dir / f"{base_name}.json" json_file.write_text( json.dumps(json_data, ensure_ascii=False, indent=2), encoding="utf-8", ) sys.stderr.write(f"\n✅ JSON 已保存: {json_file}\n") # 保存 HTML html_file = None if args.output_format in ("html", "both"): html_content = format_as_html(data, max_items=args.max_items) html_file = output_dir / f"{base_name}.html" html_file.write_text(html_content, encoding="utf-8") sys.stderr.write(f"✅ HTML 已保存: {html_file}\n") # 统计(stderr,不影响 stdout) sys.stderr.write(f"\n{'='*60}\n") sys.stderr.write(f"搜索完成!共 {data['total_items']} 条结果\n") for pkey, pdata in data["platforms"].items(): status = f"✅ {pdata['total']} 条" if not pdata.get("error") else f"❌ {pdata['error']}" sys.stderr.write(f" {pdata['label']}: {status}\n") sys.stderr.write(f"{'='*60}\n") sys.stderr.write(f"{build_engine_footer(data['platforms'])}\n") sys.stderr.write(f"{'='*60}\n") sys.stderr.flush() # stdout 输出简洁摘要(供 AI 智能体解析,单行 JSON) summary = { "keyword": keyword, "date_range": data["date_range"], "total_items": data["total_items"], "platforms": {p: v["total"] for p, v in data["platforms"].items()}, # 引擎页脚:供报告逐字粘贴(references/cn30-output-rules.md LAW 4) "engine_footer": build_engine_footer(data["platforms"]), "files": {}, } if json_file: summary["files"]["json"] = str(json_file) if html_file: summary["files"]["html"] = str(html_file) print(json.dumps(summary, ensure_ascii=False)) if __name__ == "__main__": main() -
geo_analyze.py 12.1 KB
#!/usr/bin/env python3 """ GEO 分析编排器 — 读取搜索结果,执行确定性分析,输出分析数据 用法: # Step 1: 仅确定性分析(Agent 执行 AI 分析前) python3 geo_analyze.py --brand "品牌名" --search-results output/search_results.json # 可选参数 --aliases '["别名1","别名2"]' 品牌别名 --competitors '["竞品A","竞品B"]' 已知竞品 --ai-analysis output/ai_analysis.json AI 分析结果(有则合并输出完整报告数据) 输出: output/deterministic.json 确定性分析结果 output/ai_analysis_template.json AI 分析模板(供 Agent 填充) output/analysis_result.json 合并后的完整分析(当提供 --ai-analysis 时) """ import sys import os import json import argparse sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "lib")) from analyzer import ( detect_all_mentions, extract_domains_from_sources, aggregate_domains, compute_mention_rate, build_mention_matrix, compute_per_answer_analysis, compute_aggregate_metrics, build_ai_analysis_template, build_analysis_prompt, merge_analysis, compute_sentiment_distribution, compute_brand_avg_rank, compute_competitor_metrics, compute_geo_score, ) from platforms import PLATFORMS def main(): parser = argparse.ArgumentParser(description="GEO 分析编排器") parser.add_argument("--brand", required=True, help="品牌名称") parser.add_argument("--aliases", default="[]", help="品牌别名 JSON 列表") parser.add_argument("--competitors", default="[]", help="已知竞品 JSON 列表") parser.add_argument( "--search-results", default=os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "output", "search_results.json"), help="搜索结果 JSON 路径", ) parser.add_argument( "--ai-analysis", default=None, help="AI 分析结果 JSON 路径(有则合并输出完整分析)", ) parser.add_argument( "--output-dir", default=None, help="输出目录(默认 output/)", ) args = parser.parse_args() # ── 解析参数 ────────────────────────────────────────── brand = args.brand aliases = json.loads(args.aliases) if args.aliases else [] competitors = json.loads(args.competitors) if args.competitors else [] # 品牌关键词 = 品牌名 + 别名 brand_keywords = [brand] + aliases # 竞品关键词映射(默认竞品名就是关键词) competitor_map = {} for comp in competitors: competitor_map[comp] = [comp] output_dir = args.output_dir or os.path.join( os.path.dirname(os.path.abspath(__file__)), "..", "output" ) os.makedirs(output_dir, exist_ok=True) # ── 读取搜索结果 ────────────────────────────────────── search_path = args.search_results if not os.path.exists(search_path): print(json.dumps({"error": f"搜索结果文件不存在: {search_path}"}, ensure_ascii=False)) sys.exit(1) with open(search_path, "r", encoding="utf-8") as f: search_data = json.load(f) results = search_data.get("results", []) queries = search_data.get("queries", []) platforms = search_data.get("platforms", ["doubao", "kimi", "deepseek"]) print(f"品牌: {brand}", file=sys.stderr) print(f"别名: {aliases}", file=sys.stderr) print(f"竞品: {competitors}", file=sys.stderr) print(f"搜索结果: {len(results)} 条,平台: {platforms}", file=sys.stderr) # ── Phase 1: 确定性分析 ──────────────────────────────── print("\n执行确定性分析...", file=sys.stderr) per_answer = compute_per_answer_analysis(results, brand_keywords, competitor_map) metrics = compute_aggregate_metrics(per_answer, queries, platforms, brand, competitors) mention_matrix = build_mention_matrix(results, queries, platforms, brand_keywords) deterministic = { "brand": brand, "aliases": aliases, "competitors": competitors, "platforms": platforms, "queries": queries, "per_answer": per_answer, "metrics": metrics, "mention_matrix": mention_matrix, } det_path = os.path.join(output_dir, "deterministic.json") with open(det_path, "w", encoding="utf-8") as f: json.dump(deterministic, f, ensure_ascii=False, indent=2) print(f"确定性分析已保存: {det_path}", file=sys.stderr) # ── Phase 2: 生成 AI 分析模板 ────────────────────────── ai_template = build_ai_analysis_template(results, brand, competitors) template_path = os.path.join(output_dir, "ai_analysis_template.json") with open(template_path, "w", encoding="utf-8") as f: json.dump(ai_template, f, ensure_ascii=False, indent=2) print(f"AI 分析模板已保存: {template_path}", file=sys.stderr) # 输出 AI 分析提示 print(f"\n{'='*60}", file=sys.stderr) print(f"AI 分析任务: 请对 {len(ai_template)} 份回答执行 AI 分析", file=sys.stderr) print(f"模板文件: {template_path}", file=sys.stderr) print(f"每份回答需填充字段: brand_rank, brand_context, sentiment,", file=sys.stderr) print(f" sentiment_reason, competitors_mentioned, key_claims", file=sys.stderr) print(f"完成后保存为: {os.path.join(output_dir, 'ai_analysis.json')}", file=sys.stderr) print(f"然后重新运行本脚本添加 --ai-analysis 参数生成完整报告数据", file=sys.stderr) print(f"{'='*60}\n", file=sys.stderr) # ── Phase 3: 合并 AI 分析(如果提供)────────────────── if args.ai_analysis and os.path.exists(args.ai_analysis): print("检测到 AI 分析结果,开始合并...", file=sys.stderr) with open(args.ai_analysis, "r", encoding="utf-8") as f: ai_data = json.load(f) # 合并确定性 + AI 分析 merged = merge_analysis(per_answer, ai_data) # 计算需要 AI 数据的指标 sentiment_dist = compute_sentiment_distribution(merged, platforms) brand_avg_rank = compute_brand_avg_rank(merged, platforms) # 收集所有竞品(用户指定 + AI 发现) all_competitors = set(competitors) for ai in ai_data: for comp in ai.get("competitors_mentioned", []): all_competitors.add(comp) # 计算竞品指标(平均排名 + 情绪分布) competitor_metrics = compute_competitor_metrics(merged, platforms, all_competitors) # 计算 GEO 综合得分 geo_score = {} geo_score["overall"] = compute_geo_score( metrics["brand_mention_rate"]["overall"], brand_avg_rank["overall"], sentiment_dist["overall"], ) for p in platforms: geo_score[p] = compute_geo_score( metrics["brand_mention_rate"].get(p, 0), brand_avg_rank.get(p), sentiment_dist.get(p, {"positive": 0, "neutral": 0, "negative": 0}), ) # 计算竞品提及率(覆盖所有竞品,含 AI 发现的) comp_mention_rates = {} for comp in all_competitors: rates = {"overall": 0.0} for p in platforms: rates[p] = 0.0 total_done = 0 total_hit = 0 p_stats = {p: {"t": 0, "h": 0} for p in platforms} comp_lower = comp.lower() for ans in merged: if ans.get("status") != "completed": continue p = ans.get("platform", "") total_done += 1 p_stats.setdefault(p, {"t": 0, "h": 0}) p_stats[p]["t"] += 1 # 确定性匹配 det_count = ans.get("competitor_mentions", {}).get(comp, 0) # AI 发现匹配(模糊匹配) ai_mentioned = False if det_count == 0: for ai_comp in ans.get("ai_competitors_mentioned", []): if comp_lower == ai_comp.lower() or comp_lower in ai_comp.lower() or ai_comp.lower() in comp_lower: ai_mentioned = True break if det_count > 0 or ai_mentioned: total_hit += 1 p_stats[p]["h"] += 1 rates["overall"] = total_hit / total_done if total_done > 0 else 0.0 for p in platforms: ps = p_stats.get(p, {"t": 0, "h": 0}) rates[p] = ps["h"] / ps["t"] if ps["t"] > 0 else 0.0 comp_mention_rates[comp] = rates # 竞品对比矩阵 competitor_comparison = [] for comp in sorted(all_competitors): comp_rates = comp_mention_rates.get(comp, {"overall": 0}) comp_m = competitor_metrics.get(comp, {}) comp_avg_rank = comp_m.get("avg_rank", {}) comp_sent = comp_m.get("sentiment", {}) comp_sent_overall = comp_sent.get("overall", {"positive": 0, "neutral": 0, "negative": 0}) comp_sent_total = sum(comp_sent_overall.values()) competitor_comparison.append({ "name": comp, "mention_rate_overall": comp_rates.get("overall", 0), "mention_rate_doubao": comp_rates.get("doubao", 0), "mention_rate_kimi": comp_rates.get("kimi", 0), "mention_rate_deepseek": comp_rates.get("deepseek", 0), "avg_rank": comp_avg_rank.get("overall"), "positive_pct": comp_sent_overall["positive"] / comp_sent_total if comp_sent_total > 0 else 0, "negative_pct": comp_sent_overall["negative"] / comp_sent_total if comp_sent_total > 0 else 0, "sentiment_dist": comp_sent_overall, }) analysis_result = { "brand": brand, "aliases": aliases, "competitors": competitors, "discovered_competitors": list(all_competitors - set(competitors)), "all_competitors": sorted(all_competitors), "platforms": platforms, "queries": queries, "per_answer": merged, "metrics": metrics, "mention_matrix": mention_matrix, "sentiment_distribution": sentiment_dist, "brand_avg_rank": brand_avg_rank, "geo_score": geo_score, "competitor_comparison": competitor_comparison, "competitor_metrics": competitor_metrics, } result_path = os.path.join(output_dir, "analysis_result.json") with open(result_path, "w", encoding="utf-8") as f: json.dump(analysis_result, f, ensure_ascii=False, indent=2) print(f"完整分析结果已保存: {result_path}", file=sys.stderr) # stdout 输出摘要 summary = { "brand": brand, "geo_score": geo_score, "mention_rate": metrics["brand_mention_rate"], "sentiment": sentiment_dist, "avg_rank": brand_avg_rank, "total_competitors": len(all_competitors), "output": result_path, } print(json.dumps(summary, ensure_ascii=False)) else: # 无 AI 分析,仅输出确定性分析摘要 summary = { "brand": brand, "deterministic_only": True, "mention_rate": metrics["brand_mention_rate"], "total_completed": metrics["total_completed"], "total_mentioned": metrics["total_mentioned"], "top_domains": list(metrics["domain_stats"].items())[:10], "deterministic_output": det_path, "ai_template_output": template_path, "next_step": "请根据 ai_analysis_template.json 对每份回答执行 AI 分析,保存为 ai_analysis.json 后重新运行", } print(json.dumps(summary, ensure_ascii=False)) if __name__ == "__main__": main() -
geo_report.py 3.1 KB
#!/usr/bin/env python3 """ GEO 报告生成器 — 读取分析结果,生成交互式 HTML 报告 用法: python3 geo_report.py --analysis output/analysis_result.json # 可选: 附带原始搜索结果(用于原始回答存档模块) --search-results output/search_results.json 输出: output/geo_report.html """ import sys import os import json import argparse from datetime import datetime sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "lib")) from report_template import generate_html def main(): parser = argparse.ArgumentParser(description="GEO 报告生成器") parser.add_argument( "--analysis", default=os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "output", "analysis_result.json"), help="分析结果 JSON 路径", ) parser.add_argument( "--search-results", default=os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "output", "search_results.json"), help="搜索结果 JSON 路径(可选,用于原始回答存档)", ) parser.add_argument( "--output", default=None, help="输出 HTML 路径(默认 output/geo_report.html)", ) args = parser.parse_args() # ── 读取分析结果 ────────────────────────────────────── if not os.path.exists(args.analysis): print(json.dumps({"error": f"分析结果文件不存在: {args.analysis}"}, ensure_ascii=False)) sys.exit(1) with open(args.analysis, "r", encoding="utf-8") as f: analysis = json.load(f) # ── 读取搜索结果(可选)──────────────────────────────── search_results = None if os.path.exists(args.search_results): with open(args.search_results, "r", encoding="utf-8") as f: search_results = json.load(f) # ── 生成 HTML ──────────────────────────────────────── print("正在生成 HTML 报告...", file=sys.stderr) html_content = generate_html(analysis, search_results) # ── 写入文件 ───────────────────────────────────────── output_path = args.output or os.path.join( os.path.dirname(os.path.abspath(__file__)), "..", "output", "geo_report.html" ) os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True) with open(output_path, "w", encoding="utf-8") as f: f.write(html_content) print(f"报告已生成: {output_path}", file=sys.stderr) # stdout 输出摘要 brand = analysis.get("brand", "") geo_score = analysis.get("geo_score", {}) summary = { "brand": brand, "output": output_path, "geo_score": geo_score, "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), } print(json.dumps(summary, ensure_ascii=False)) if __name__ == "__main__": main() -
geo_search.py 11.1 KB
#!/usr/bin/env python3 """ GEO 搜索调度器 — 3平台 x N问题 批量提交 + 并行轮询 用法: python3 geo_search.py --queries '["问题1","问题2",...]' --platforms doubao,kimi,deepseek 输出: output/search_results.json 环境变量: REDFOX_API_KEY — 红狐 API Key(必填) """ import sys import os import json import time import argparse from concurrent.futures import ThreadPoolExecutor, as_completed # 将 lib 目录加入搜索路径 sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "lib")) from platforms import PLATFORMS, submit, poll, extract_result_full, is_valid_answer, API_KEY POLL_INTERVAL = 5 # 轮询间隔(秒) MAX_POLL_TIME = 900 # 最长等待 15 分钟(DeepSeek 深度搜索较慢) MAX_WORKERS = 6 # 并发线程数(降低避免 Kimi 限流) MAX_ANSWER_RETRY = 1 # 平台返回错误话术(限流/故障)时的重试次数 def main(): parser = argparse.ArgumentParser(description="GEO 批量搜索调度器") parser.add_argument( "--queries", required=True, help='问题列表 JSON,如 \'["问题1","问题2"]\'', ) parser.add_argument( "--platforms", default="doubao,kimi,deepseek", help="平台列表,逗号分隔(默认 doubao,kimi,deepseek)", ) parser.add_argument( "--output", default=None, help="输出文件路径(默认 output/search_results.json)", ) args = parser.parse_args() # ── 前置检查 ────────────────────────────────────────── if not API_KEY: result = { "error": ( "未配置 REDFOX_API_KEY 环境变量," "请前往 https://redfox.hk/settings/api-keys?source=github 获取 API Key" ) } print(json.dumps(result, ensure_ascii=False)) sys.exit(1) queries = json.loads(args.queries) platforms = [p.strip() for p in args.platforms.split(",") if p.strip()] for p in platforms: if p not in PLATFORMS: print(json.dumps({"error": f"未知平台: {p},可选: {list(PLATFORMS.keys())}"}, ensure_ascii=False)) sys.exit(1) total = len(queries) * len(platforms) output_path = args.output or os.path.join( os.path.dirname(os.path.abspath(__file__)), "..", "output", "search_results.json" ) os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True) print(f"开始搜索: {len(queries)} 个问题 x {len(platforms)} 个平台 = {total} 个任务", file=sys.stderr) # ── Phase 1: 批量提交所有任务 ────────────────────────── tasks = [] # [{query, platform, query_index, task_id | error}] with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor: future_map = {} for qi, query in enumerate(queries): for platform in platforms: future = executor.submit(submit, platform, query) future_map[future] = { "query": query, "platform": platform, "query_index": qi, } for future in as_completed(future_map): info = future_map[future] try: task_id = future.result() info["task_id"] = task_id print(f" [提交] {info['platform']} Q{info['query_index']+1} -> {task_id}", file=sys.stderr) except Exception as e: info["task_id"] = None info["error"] = str(e) print(f" [提交失败] {info['platform']} Q{info['query_index']+1} -> {e}", file=sys.stderr) tasks.append(info) submitted_ok = [t for t in tasks if t.get("task_id")] print(f"\n提交完成: {len(submitted_ok)}/{total} 成功,开始轮询...\n", file=sys.stderr) # ── Phase 2: 并行轮询所有任务 ────────────────────────── results = [] pending = list(submitted_ok) max_attempts = MAX_POLL_TIME // POLL_INTERVAL for attempt in range(1, max_attempts + 1): if not pending: break time.sleep(POLL_INTERVAL) still_pending = [] completed_this_round = 0 with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor: future_map = {} for task in pending: future = executor.submit(poll, task["platform"], task["task_id"]) future_map[future] = task for future in as_completed(future_map): task = future_map[future] try: raw = future.result() if raw is not None: # 任务完成 —— 先校验是否为真实回答 ex = extract_result_full(task["platform"], raw) valid, reason = is_valid_answer(ex["content"]) if not valid: retries = task.get("answer_retry", 0) if retries < MAX_ANSWER_RETRY: # 平台限流/故障话术,重新提交一次 try: task["task_id"] = submit(task["platform"], task["query"]) task["answer_retry"] = retries + 1 still_pending.append(task) print( f" [重试] {task['platform']} Q{task['query_index']+1}" f" -> {reason},已重新提交", file=sys.stderr, ) except Exception as e: results.append({ "question": task["query"], "query_index": task["query_index"], "platform": task["platform"], "content": "", "sources": [], "status": "invalid", "error": f"{reason};重试提交失败: {e}", }) print( f" [无效] {task['platform']} Q{task['query_index']+1}" f" -> {reason}", file=sys.stderr, ) continue # 重试后仍无效:标记 invalid,排除出统计(不计入分母) results.append({ "question": task["query"], "query_index": task["query_index"], "platform": task["platform"], "content": "", "sources": [], "status": "invalid", "error": reason, }) completed_this_round += 1 print( f" [无效] {task['platform']} Q{task['query_index']+1}" f" -> {reason}(已排除出统计)", file=sys.stderr, ) continue entry = { "question": task["query"], "query_index": task["query_index"], "platform": task["platform"], "content": ex["content"], "sources": ex["sources"], "sources_available": ex["sources_available"], "status": "completed", } if ex["sources_note"]: entry["sources_note"] = ex["sources_note"] results.append(entry) completed_this_round += 1 print(f" [完成] {task['platform']} Q{task['query_index']+1}", file=sys.stderr) else: still_pending.append(task) except Exception as e: results.append({ "question": task["query"], "query_index": task["query_index"], "platform": task["platform"], "content": "", "sources": [], "status": "failed", "error": str(e), }) print(f" [失败] {task['platform']} Q{task['query_index']+1} -> {e}", file=sys.stderr) pending = still_pending if pending: print(f" 轮询 {attempt}: 本轮完成 {completed_this_round},剩余 {len(pending)} 等待中...", file=sys.stderr) # 处理超时任务 for task in pending: results.append({ "question": task["query"], "query_index": task["query_index"], "platform": task["platform"], "content": "", "sources": [], "status": "timeout", }) # 处理提交失败的任务 for task in tasks: if task.get("task_id") is None: results.append({ "question": task["query"], "query_index": task["query_index"], "platform": task["platform"], "content": "", "sources": [], "status": "submit_failed", "error": task.get("error", "Unknown"), }) # 按 query_index + platform 排序 platform_order = {p: i for i, p in enumerate(platforms)} results.sort(key=lambda r: (r.get("query_index", 0), platform_order.get(r.get("platform", ""), 99))) output = { "queries": queries, "platforms": platforms, "total_tasks": total, "completed": len([r for r in results if r["status"] == "completed"]), "invalid": len([r for r in results if r["status"] == "invalid"]), "failed": len([r for r in results if r["status"] not in ("completed", "invalid")]), "results": results, } with open(output_path, "w", encoding="utf-8") as f: json.dump(output, f, ensure_ascii=False, indent=2) summary = f"\n搜索完成: {output['completed']}/{total} 条有效" if output["invalid"]: summary += f",{output['invalid']} 条平台异常已排除" if output["failed"]: summary += f",{output['failed']} 条失败" print(summary, file=sys.stderr) print(f"结果已保存: {output_path}", file=sys.stderr) # stdout 输出 JSON 供 Agent 解析 print(json.dumps(output, ensure_ascii=False)) if __name__ == "__main__": main() -
hub_fetch.py 14.2 KB
#!/usr/bin/env python # -*- coding: utf-8 -*- """ 热点数据获取脚本 从热点接口获取各平台热点数据,支持时间范围查询和关键词筛选 """ import argparse import json import os import ssl import sys import urllib.request from datetime import datetime, timedelta from typing import Dict, List, Optional import tempfile # 平台代码映射:平台代码 -> 平台名称 PLATFORM_MAP = { "wb": "微博", "dy": "抖音", "bz": "B站", "ks": "快手", "zh": "知乎", "tt": "头条", "bd": "百度" } # 平台代码到接口枚举值的映射 PLATFORM_CODE_MAP = { "ks": 1, # 快手 "dy": 2, # 抖音 "wb": 5, # 微博 "bd": 7, # 百度 "bz": 8, # B站 "zh": 9, # 知乎 "tt": 10 # 头条 } # 平台展示固定顺序(与 SKILL.md 输出规范一致) # 百度 → 知乎 → 微博 → 抖音 → B站 → 快手 → 头条 DISPLAY_ORDER = ["bd", "zh", "wb", "dy", "bz", "ks", "tt"] # 关键词泛化映射:大词 -> 泛化词列表 KEYWORD_EXPANSION_MAP = { "体育": ["体育", "足球", "篮球", "运动", "健身", "奥运", "世界杯", "NBA", "CBA", "乒乓球"], "娱乐": ["娱乐", "明星", "电影", "电视剧", "综艺", "音乐", "八卦"], "科技": ["科技", "互联网", "手机", "电脑", "AI", "人工智能", "数码"], "财经": ["财经", "股市", "基金", "理财", "经济", "金融"], "社会": ["社会", "民生", "新闻", "热点", "事件"], "游戏": ["游戏", "电竞", "网游", "手游", "王者荣耀", "英雄联盟"], "汽车": ["汽车", "车", "新能源", "电动车", "特斯拉", "比亚迪"], "美食": ["美食", "吃", "餐厅", "菜谱", "做饭"], "旅游": ["旅游", "旅行", "景点", "攻略", "酒店"], "时尚": ["时尚", "穿搭", "美妆", "护肤", "衣服"] } def expand_keywords(keyword: str) -> List[str]: """ 对关键词进行泛化扩展 Args: keyword: 用户输入的关键词 Returns: 扩展后的关键词列表 """ # 首先检查是否为大词(优先匹配) for big_word, expanded_words in KEYWORD_EXPANSION_MAP.items(): if keyword == big_word or big_word in keyword: return expanded_words # 精确词不扩展(长度<=2或包含特定字符) if len(keyword) <= 2 or any(char in keyword for char in ["超", "联赛", "杯"]): return [keyword] # 默认不扩展 return [keyword] def format_hot_count(hot_count: str) -> str: """ 智能格式化热度值 Args: hot_count: 原始热度值 Returns: 格式化后的热度值 """ if not hot_count: return "0" # 如果已经包含单位(万、亿),直接返回 if "万" in hot_count or "亿" in hot_count: return hot_count # 尝试转换为数字 try: num = int(hot_count) # 转换为"数字+万"格式 wan = num // 10000 if wan > 0: return f"{wan}万" else: return str(num) except ValueError: # 无法转换为数字,直接返回原值 return hot_count def _get_api_key() -> str: """从当前环境变量获取 REDFOX_API_KEY""" key = os.environ.get("REDFOX_API_KEY") if not key: raise SystemExit("❌ 未找到 REDFOX_API_KEY,请配置环境变量:export REDFOX_API_KEY=<你的apikey>") return key def fetch_hotspot_data( source: str, platforms: Optional[List[str]] = None, keywords: Optional[List[str]] = None, start_date: Optional[str] = None, end_date: Optional[str] = None ) -> Dict: """ 从接口获取热点数据 Args: source: 数据来源 platforms: 平台代码列表(可选) keywords: 关键词列表(可选) start_date: 开始时间(可选) end_date: 结束时间(可选) Returns: API返回的数据字典 """ # API配置 api_url = "https://redfox.hk/story/api/hotSpot/getListByPlatformWithKeyword" # 构建请求参数 params = { "source": source } # 添加平台参数(数组)- 始终传入,即使为空数组 platform_enums = [] if platforms: for p in platforms: if p in PLATFORM_CODE_MAP: platform_enums.append(PLATFORM_CODE_MAP[p]) else: print(f"警告: 不支持的平台代码 {p}", file=sys.stderr) params["platforms"] = platform_enums # 添加关键词参数(数组)- 始终传入,即使为空数组 params["keywords"] = keywords if keywords else [] # 添加时间参数 if start_date: params["startDate"] = start_date if end_date: params["endDate"] = end_date # 获取 API Key api_key = _get_api_key() try: # 发送请求(使用 urllib,跳过 SSL 验证) body = json.dumps(params).encode("utf-8") req = urllib.request.Request( api_url, data=body, headers={ "Content-Type": "application/json", "X-API-KEY": api_key }, method="POST" ) ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE with urllib.request.urlopen(req, timeout=30, context=ctx) as resp: if resp.status != 200: return { "status": "error", "message": f"HTTP请求失败: {resp.status}", "data": None } raw = resp.read() # 解析响应 result = json.loads(raw.decode("utf-8")) # 接口返回code=2000表示成功 if result.get("code") == 2000: return { "status": "success", "message": "数据获取成功", "data": result.get("data", {}) } else: # 接口返回错误码 error_msg = result.get("msg") or result.get("message") or "接口返回错误" return { "status": "error", "message": f"接口错误: {error_msg} (code: {result.get('code')})", "data": None } except Exception as e: err_msg = str(e) if "timed out" in err_msg.lower(): return {"status": "error", "message": "请求超时", "data": None} if "urlopen" in err_msg.lower() or "connection" in err_msg.lower(): return {"status": "error", "message": f"连接失败: {err_msg}", "data": None} return {"status": "error", "message": f"请求异常: {err_msg}", "data": None} def process_hotspot_data(data: Dict, platform_filter: Optional[List[str]] = None) -> Dict: """ 处理热点数据 Args: data: API返回的原始数据 platform_filter: 平台筛选列表 Returns: 处理后的数据 """ result = { "total": 0, "platforms": {} } # 平台列表映射 platform_list_map = { "bdList": "bd", "bzList": "bz", "dyList": "dy", "ksList": "ks", "ttList": "tt", "wbList": "wb", "zhList": "zh" } for list_key, platform_code in platform_list_map.items(): # 如果指定了平台筛选,跳过不在筛选列表中的平台 if platform_filter and platform_code not in platform_filter: continue hotspots = data.get(list_key, []) if hotspots: processed_hotspots = [] for hotspot in hotspots: processed = { "title": hotspot.get("title", ""), "url": hotspot.get("url", ""), "hotCount": format_hot_count(hotspot.get("hotCount", "0")), "index": hotspot.get("index", 0), "gmtCreate": hotspot.get("gmtCreate", ""), "platCode": platform_code, "platName": PLATFORM_MAP.get(platform_code, platform_code) } processed_hotspots.append(processed) result["platforms"][platform_code] = { "name": PLATFORM_MAP.get(platform_code, platform_code), "count": len(processed_hotspots), "hotspots": processed_hotspots } result["total"] += len(processed_hotspots) return result def ordered_platforms(result: Dict): """按固定展示顺序返回 (platform_code, platform_data) 列表 API 返回顺序不稳定(实测为 百度/B站/抖音/快手/头条/微博/知乎), 这里统一重排为 SKILL.md 规定的顺序;未知平台码追加在末尾。 """ platforms = result.get("platforms", {}) ordered = [(c, platforms[c]) for c in DISPLAY_ORDER if c in platforms] ordered += [(c, d) for c, d in platforms.items() if c not in DISPLAY_ORDER] return ordered def output_compact(result: Dict) -> str: """ 输出极简格式(避免数据被截断) 末尾附带完整数据文件路径,避免重复调用接口 """ output_lines = [] # 元信息 output_lines.append(f"status: {result['status']}") output_lines.append(f"total: {result['total']}") output_lines.append(f"platforms: {len(result['platforms'])}") output_lines.append("") # 各平台概览(每平台仅预览 TOP3,完整数据见 dataFile) for platform_code, platform_data in ordered_platforms(result): total_n = platform_data["count"] preview_n = min(3, total_n) output_lines.append(f"[{platform_data['name']}] {total_n}条(下表为 TOP{preview_n} 预览)") for hotspot in platform_data["hotspots"][:3]: output_lines.append(f" {hotspot['index']}. {hotspot['title']} - {hotspot['hotCount']}") output_lines.append("") # 附带完整数据文件路径(一次调用搞定,禁止二次调用浪费token) if "dataFile" in result: output_lines.append(f"dataFile: {result['dataFile']}") return "\n".join(output_lines) def output_json(result: Dict) -> str: """ 输出JSON格式 """ return json.dumps(result, ensure_ascii=False, indent=2) def output_markdown(result: Dict) -> str: """ 输出Markdown表格格式(只输出TOP10) """ output_lines = [] for platform_code, platform_data in ordered_platforms(result): output_lines.append(f"## {platform_data['name']}") output_lines.append("") output_lines.append("|排名|标题|热度|") output_lines.append("|---|---|---|") for hotspot in platform_data["hotspots"][:10]: title = hotspot["title"] url = hotspot["url"] hot_count = hotspot["hotCount"] index = hotspot["index"] # Markdown超链接格式 title_link = f"[{title}]({url})" if url else title output_lines.append(f"|{index}|{title_link}|{hot_count}|") output_lines.append("") return "\n".join(output_lines) def main(): parser = argparse.ArgumentParser(description="获取各平台热点数据") parser.add_argument( "--source", default="全平台热点事件-GitHub", help="数据来源(默认:全平台热点事件)" ) parser.add_argument( "--platforms", help="平台代码列表,逗号分隔(如:wb,dy,bz)" ) parser.add_argument( "--keywords", help="关键词列表,逗号分隔(如:体育,足球)" ) parser.add_argument( "--expand-keywords", action="store_true", help="启用关键词泛化扩展" ) parser.add_argument( "--start-date", help="开始时间(格式:YYYY-MM-DD HH:MM:SS)" ) parser.add_argument( "--end-date", help="结束时间(格式:YYYY-MM-DD HH:MM:SS)" ) parser.add_argument( "--output", choices=["compact", "json", "markdown"], default="compact", help="输出格式(默认:compact)" ) args = parser.parse_args() # 解析平台参数 platforms = None if args.platforms: platforms = [p.strip() for p in args.platforms.split(",")] # 解析关键词参数 keywords = None if args.keywords: raw_keywords = [k.strip() for k in args.keywords.split(",")] if args.expand_keywords: # 对每个关键词进行扩展 expanded = [] for k in raw_keywords: expanded.extend(expand_keywords(k)) keywords = list(dict.fromkeys(expanded)) # 去重保持顺序 else: keywords = raw_keywords # 计算默认时间范围(前一个完整小时) if not args.start_date or not args.end_date: now = datetime.now() current_hour = now.replace(minute=0, second=0, microsecond=0) start_time = current_hour - timedelta(hours=1) end_time = current_hour if not args.start_date: args.start_date = start_time.strftime("%Y-%m-%d %H:%M:%S") if not args.end_date: args.end_date = end_time.strftime("%Y-%m-%d %H:%M:%S") # 获取数据 api_result = fetch_hotspot_data( source=args.source, platforms=platforms, keywords=keywords, start_date=args.start_date, end_date=args.end_date ) if api_result["status"] == "error": print(json.dumps(api_result, ensure_ascii=False)) sys.exit(1) # 处理数据 result = process_hotspot_data(api_result["data"], platforms) # 添加元信息 result["status"] = "success" result["source"] = args.source result["startDate"] = args.start_date result["endDate"] = args.end_date if keywords: result["keywords"] = keywords # 保存完整数据到临时文件 temp_file = tempfile.mktemp(prefix="platforms-hotspot-data-", suffix=".json") with open(temp_file, "w", encoding="utf-8") as f: json.dump(result, f, ensure_ascii=False, indent=2) result["dataFile"] = temp_file # 输出结果 if args.output == "compact": print(output_compact(result)) elif args.output == "json": print(output_json(result)) elif args.output == "markdown": print(output_markdown(result)) if __name__ == "__main__": main()
-
-
README.en.md 8.7 KB
# Brand Geo Analysis Plus / brand-geo-analysis-plus --- ## Introduction One skill, three perspectives: real-time aggregated trending boards + in-depth social media research over the last 30 days + brand visibility analysis in AI search. Together they show you the full picture of how a brand or topic is perceived across the Chinese internet, from "what's trending" to "what users really say" to "what AI answers". **Core Value** - **Three complementary perspectives**: trending boards show what is hot right now, social media shows what real users say, and AI search shows how large models describe the brand. Combining all three prevents one-sided conclusions drawn from a single data source. - **Conclusions, not data dumps**: trending rankings, reputation insights, GEO scores, competitor comparisons and visual reports that are ready to use. - **Built for the Chinese internet**: platforms and metrics follow local social media conventions, for example the save-to-like ratio on Xiaohongshu as a "seeding" signal, shares on Douyin as reach, and reads on WeChat Official Accounts as attention. **Who It's For** - 🏢 **Brand / marketing operations** — daily reputation monitoring, competitor reputation comparison, brand AI visibility diagnosis. - 📈 **Growth / GEO owners** — see how the brand is described by Doubao, Kimi and DeepSeek, and where to optimise. - ✍️ **Content creators / editors** — track trending topics across platforms, decide which ones are worth riding, and find angles. - 🔍 **Consultancies / media agencies** — produce a data-backed brand perception report for clients. --- ## Features ### Core Capabilities - **Cross-platform trending aggregation**: covers Baidu, Zhihu, Weibo, Douyin, Bilibili, Kuaishou and Toutiao, with a per-platform TOP10 plus an overall summary. - **Flexible time and scope**: latest (hourly), today, yesterday and this week rankings; full rankings for a single platform; keyword search where broad category words are automatically expanded into related terms. - **Trend forecasting**: a forward-looking read on trending items to help you judge whether follow-up is worthwhile. - **In-depth social media research over the last 30 days**: real user discussions from Xiaohongshu, Douyin and WeChat Official Accounts, delivered as a data snapshot, consolidated insights and a visual report. - **Brand visibility in AI search**: batch queries to Doubao, Kimi and DeepSeek, producing an overall GEO score, brand mention rate, sentiment, competitor comparison and cited sources. - **Automatic visual reports**: interactive reports for social media research and AI search analysis are generated automatically, with no extra request needed. - **Full brand perception diagnosis**: combines the trending, reputation and AI-answer layers into one report with layer-by-layer action recommendations. ### Highlights - **Three perspectives in one**: trending, reputation and AI answers are connected by a single tool, so one diagnosis covers the key steps of brand perception. - **Use only what you need**: when you only want one thing, use the matching module instead of the full workflow. - **Independent modules**: if one data source is temporarily unavailable, the other modules still deliver. --- ## API Key Acquisition & Security - This skill requires the environment variable: `REDFOX_API_KEY`. - `REDFOX_API_KEY` is provided by [RedFoxHub](https://redfox.hk/settings/api-keys?source=github) (`https://redfox.hk`). - Please register an account at [RedFoxHub](https://redfox.hk?source=github) to obtain your `REDFOX_API_KEY`. - Configure the `REDFOX_API_KEY` environment variable on your device before using this skill. - Before providing a key, confirm its source, allowed scope, validity period, and whether it supports reset or revocation. - Never hardcode or expose the key in plain text in code, prompts, logs or output files. --- ## Usage Guide Just describe the brand, topic or trending item you care about in natural language — there are no commands to memorise. ### Common Requests | Intent | Example Request | What You Get | | --------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------- | | Check what's trending | "What is everyone talking about today?" | TOP10 trending boards across seven platforms plus an overall summary | | Look back by time | "Yesterday's rankings", "This week's trending topics" | Cross-platform rankings for the matching time window | | Search a topic area | "Search sports trends", "What's trending about the Super League?" | Broad category words expand into related terms; precise terms are used as-is | | Focus on one platform | "Weibo rankings", "Show the full Bilibili list" | The complete ranking for that platform | | Track a brand | "How has my brand been discussed on Xiaohongshu this month?" | 30 days of real discussion across three social platforms + insights + report | | See how AI rates it | "How do Doubao and Kimi describe our brand?" | GEO score, mention rate, sentiment, competitor comparison, sources + report | | Run a full diagnosis | "Put together a full perception report for my brand" | One combined trending → reputation → AI report with action recommendations | | Keep monitoring | "Subscribe to daily updates" | Scheduled digest of trending boards and highlights | ### Sample Output When you want the full picture of a brand, you receive a layered diagnosis along these lines (illustrative): > **One-line conclusion**: The brand is actively discussed on social media with largely positive sentiment, yet its mention rate in AI search recommendation questions remains clearly low — a visibility gap. > > **1. Trending layer**: whether it appears in recent trending items, which platforms it covers, and its direction. > **2. Reputation layer**: real user feedback, positive and negative signals, key discussion themes. > **3. AI layer**: GEO score, mention rate (with a per-platform breakdown), sentiment distribution, competitor comparison, main cited sources. > **4. Action recommendations**: whether to ride the trend, which negative feedback to address, and where to optimise GEO. --- ## Use Cases | Scenario | Role | Example Question | Benefit | | -------------------------------- | ------------------------ | ------------------------------------------------------------------- | ---------------------------------------------------------------------- | | Brand reputation monitoring | Brand / marketing ops | "How has our brand been discussed over the past month?" | Quickly grasp real social sentiment and positive/negative signals | | Competitor reputation comparison | Marketing / growth | "Compare how A and B are received on Xiaohongshu and Douyin" | See differences in user discussion and each side's weaknesses | | AI visibility diagnosis | Brand / GEO owner | "How would Doubao, Kimi and DeepSeek recommend products like ours?" | Quantify brand visibility in AI search and locate optimisation areas | | Riding trends for content | Creator / editor | "What's trending today that we could ride?" | Judge trend direction and timing, reducing wasted topic exploration | | Full brand diagnosis | Brand lead / consultancy | "Put together a full perception report for my brand" | One report and action plan covering trending, reputation and AI layers | --- ## Important Data Notes - Trending boards update hourly and by default cover "the previous full hour"; today, yesterday and weekly boards use their respective windows. - Social media research covers a rolling 30-day window; AI search analysis is based on live queries whose answers change over time and with model versions. - A single AI search analysis uses a limited number of questions (typically 8–12), so it is a small-sample observation that is best cross-checked against other data. - Brand mention detection relies on keyword matching of the brand name and aliases, so same-name brands may cause false matches; some AI platforms do not return external links, in which case an empty source list is normal and does not mean the brand website was not cited. --- -
README.md 7.5 KB
# 品牌GEO分析Plus版 / brand-geo-analysis-plus --- ## 简介 一个 Skill、三层视角:实时全网热榜聚合 + 近 30 天社媒深度研究 + AI 搜索品牌可见度分析,帮你从「话题热度 → 真实口碑 → AI 回答」完整看清一个品牌或话题在中国互联网上的处境。 **核心价值** - **三层视角互相补位**:热榜看当下什么在火,社媒看用户真实怎么说,AI 搜索看大模型怎么介绍品牌。三层结合,避免只看单一数据源得出片面结论。 - **直接给结论,不给数据堆**:热榜榜单、口碑洞察、GEO 得分、竞品对比、可视化报告,拿来即可用。 - **中文互联网原生化**:平台与指标按国内社媒习惯设计,例如小红书以收藏/点赞比看「种草」信号、抖音以分享数看传播力、公众号以阅读量看关注度。 **适用对象** - 🏢 **品牌 / 市场运营** — 日常舆情监测、竞品口碑对比、品牌 AI 可见度诊断。 - 📈 **增长 / GEO 负责人** — 看品牌在豆包、Kimi、DeepSeek 里被如何介绍,找优化方向。 - ✍️ **内容创作者 / 编导** — 追全网热点、判断哪些话题值得借势、找选题方向。 - 🔍 **咨询 / 投放代理商** — 为客户产出一份有数据支撑的品牌感知报告。 --- ## 功能特性 ### 核心功能 - **全网热榜聚合**:覆盖百度、知乎、微博、抖音、B站、快手、头条七大平台,按平台输出 TOP10,并附全网热点小结。 - **灵活的时间与范围取数**:支持最新(小时级)、今日、昨日、本周热榜;可只看单个平台完整榜单,也可按关键词搜索热点,笼统的大类词会自动扩展为多个相关词。 - **热点趋势预测**:对榜上热点给出后续走向判断,帮助判断是否值得跟进。 - **近 30 天社媒深度研究**:在小红书、抖音、公众号三大平台检索真实用户讨论,输出数据速览、综合洞察与可视化报告。 - **AI 搜索品牌可见度分析**:向豆包、Kimi、DeepSeek 批量提问,给出 GEO 综合得分、品牌提及率、情绪倾向、竞品对比、信源引用等结论。 - **自动生成可视化报告**:社媒研究与 AI 搜索分析完成后会自动产出交互式报告,无需另行要求。 - **完整品牌感知诊断**:把热度、口碑、AI 回答三层发现合并为一份诊断报告,并给出分层的行动建议。 ### 特色亮点 - **三层视角一体**:热度、口碑、AI 回答三个层面由同一个工具串起来,一次诊断即可覆盖品牌感知的关键工序。 - **按需取用**:只想做一件事时,用对应模块即可,不必跑完整流程。 - **互不影响**:某个数据源临时不可用时,其余模块的产出照常交付。 --- ## 密钥获取与安全说明 - 本技能需要使用环境变量:`REDFOX_API_KEY`。 - `REDFOX_API_KEY` 由 [红狐 hub](https://redfox.hk/settings/api-keys?source=github) (`https://redfox.hk`)提供。 - 请前往 [红狐 hub](https://redfox.hk?source=github) 注册账号,获取 `REDFOX_API_KEY`。 - 配置设备环境变量 `REDFOX_API_KEY` 后使用本技能。 - 在提供密钥前,请先确认密钥来源、可用范围、有效期及是否支持重置/撤销。 - 禁止在代码、提示词、日志或输出文件中硬编码/明文暴露密钥。 --- ## 使用指南 直接用自然语言说出你想了解的品牌、话题或热点即可,无需记忆任何命令。 ### 常用说法速查 | 意图 | 示例话术 | 效果 | | -------------- | -------------------------------------------- | -------------------------------------------------------- | | 看当下热点 | 「今天全网都在聊什么?」 | 七大平台热榜 TOP10 + 全网热点小结 | | 按时间回看 | 「昨天热榜」「本周热点」 | 对应时间窗的跨平台榜单 | | 搜特定方向 | 「搜一下体育热点」「苏超最近有什么热点」 | 大类词自动扩展为多个相关词;精确词直接用原词检索 | | 只看某个平台 | 「微博热榜」「查看B站完整榜单」 | 该平台的完整榜单 | | 盯住某个品牌 | 「最近一个月小红书上关于某品牌的讨论怎么样」 | 三大社媒平台近 30 天真实讨论 + 洞察 + 可视化报告 | | 看 AI 怎么评 | 「看看豆包、Kimi 里是怎么介绍我们品牌的」 | GEO 得分、提及率、情绪、竞品对比、信源 + 交互式报告 | | 做完整诊断 | 「帮我做一份某品牌的完整感知报告」 | 热度 → 口碑 → AI 回答三层合并报告 + 分层行动建议 | | 持续监测 | 「订阅每日推送」 | 定时推送热榜精简版与热点小结 | ### 输出示例 想一次性看清一个品牌时,你会拿到一份分层的诊断结论,大致如下(示意): > **一句话结论**:该品牌在社媒端讨论活跃、口碑偏正面,但在 AI 搜索推荐类问题中的提及率明显偏低,存在可见度缺口。 > > **1. 热度层**:是否出现在近期热点中、覆盖哪些平台、趋势方向。 > **2. 口碑层**:真实用户反馈、正负面信号、关键讨论主题。 > **3. AI 层**:GEO 得分、提及率(含分平台拆分)、情绪分布、竞品对比、被引用的主要信源。 > **4. 行动建议**:热度层是否需要借势、口碑层具体需改进的负面反馈、AI 层的 GEO 优化方向。 --- ## 使用场景 | 场景 | 角色 | 示例问法 | 收益 | | -------------- | --------------- | -------------------------------------------- | ------------------------------------------------ | | 品牌舆情监测 | 品牌 / 市场运营 | 「最近一个月关于我们品牌的讨论怎么样?」 | 快速掌握社媒真实口碑与正负面信号 | | 竞品口碑对比 | 市场 / 增长 | 「对比一下 A 和 B 在小红书、抖音上的口碑」 | 看清双方在用户讨论中的差异与各自短板 | | AI 可见度诊断 | 品牌 / GEO 负责人 | 「豆包、Kimi、DeepSeek 会怎么推荐我们这类产品?」 | 量化品牌在 AI 搜索中的可见度,定位优化方向 | | 热点借势选题 | 内容创作者 / 编导 | 「今天全网在聊什么,有没有能蹭的热点?」 | 判断热点走向与借势时机,减少选题空转 | | 完整品牌诊断 | 品牌负责人 / 咨询 | 「帮我做一份某品牌的完整感知报告」 | 一份覆盖热度、口碑、AI 回答三层的报告与行动建议 | --- ## 重要数据说明 - 热榜为小时级更新,默认统计「前一个完整小时」;今日、昨日、本周榜按对应时间窗统计。 - 社媒讨论研究的检索窗口为最近 30 天;AI 搜索分析为即时提问所得,结果会随时间和模型版本变化。 - AI 搜索分析单次提问量有限(通常 8 ~ 12 个问题),属小样本观察,建议与其他数据交叉判断。 - 品牌提及检测基于品牌名与别名的关键词匹配,同名品牌可能出现误匹配;部分 AI 平台不返回可引用的外部链接,此时信源为空属正常现象,并不代表品牌官网未被引用。 --- -
SKILL.md 19.6 KB
--- name: brand-geo-analysis-plus description: 中国互联网品牌感知监测统一工具。聚合全网热榜追踪(hub)、小红书/抖音/公众号近30天深度讨论研究(cn30)、豆包/Kimi/DeepSeek AI 搜索品牌可见度分析(geo)三个互补模块,帮助品牌方/市场运营/内容创作者从「话题热度—真实口碑—AI 回答」三层视角全面感知品牌。当用户需要研究中国社交媒体热点、分析品牌舆情、对比竞品口碑、分析品牌在 AI 搜索中的表现、做 GEO 优化、追踪全网热点时使用。触发词:品牌感知、舆情监测、社媒研究、热榜聚合、AI 搜索可见度、GEO 分析、跨平台舆情、热点追踪、品牌口碑、品牌竞品对比、社媒热榜。 agent_created: true allowed-tools: Bash, Read, Write, AskUserQuestion, WebSearch metadata: emoji: "👁️" requires: env: - REDFOX_API_KEY bins: - python3 primaryEnv: REDFOX_API_KEY tags: - brand - social-media - trends - ai-search - geo - xiaohongshu - douyin - wechat - weibo - bilibili - kuaishou - toutiao - baidu - zhihu - doubao - kimi - deepseek --- # 品牌GEO分析Plus版 ## 一句话定位 一个 Skill、三层视角:实时热榜聚合(hub)+ 近30天社媒深度研究(cn30)+ AI 搜索品牌可见度(geo),覆盖中国互联网品牌感知的全部关键场景。 ## 何时使用 任意以下场景触发,立即使用本 skill: - 想知道今天全网在聊什么、哪个热点值得追 → **hub 模块** - 想研究某话题在过去30天的真实用户讨论、做品牌口碑/竞品对比 → **cn30 模块** - 想看品牌在豆包/Kimi/DeepSeek AI 搜索里被如何介绍、品牌 AI 可见度如何 → **geo 模块** - 想做完整的品牌感知诊断(热榜热度 → 真实口碑 → AI 回答) → **组合工作流** ## 三个模块速览 | 模块 | 脚本入口 | 平台 | 时间维度 | 输出 | | ------------------------ | ---------------------------------------------------------------------------- | ------------------------------------------ | -------------------------------------- | --------------------------------------------------------- | | **hub**(热榜) | `scripts/hub_fetch.py` | 百度/知乎/微博/抖音/B站/快手/头条(7平台) | 实时(小时级)/ 昨日 / 本周 / 历史30天 | TOP10 表格 + 跨平台小结 + 趋势预测 | | **cn30**(社媒研究) | `scripts/cn30_search.py` | 小红书/抖音/公众号(3平台) | 近30天任意天数 | 数据速览(TOP5×3平台)+ 综合洞察 + HTML 报告 | | **geo**(AI 搜索可见度) | `scripts/geo_search.py` + `scripts/geo_analyze.py` + `scripts/geo_report.py` | 豆包/Kimi/DeepSeek(3 平台) | 即时提问 | GEO 得分 + 提及率 + 情绪 + 竞品 + 信源 + 交互式 HTML 报告 | ## 鉴权(共享) 三个模块共享同一个 `REDFOX_API_KEY`。从 [红狐 hub](https://redfox.hk/settings/api-keys?source=github) 获取,配置方式: ```bash export REDFOX_API_KEY=ak_你的密钥 ``` 或写入 `~/.openclaw/openclaw.json` 的 `env.REDFOX_API_KEY`。优先级:命令行 `--api-key` > 环境变量 > 配置文件。 依赖安装(按模块): | 模块 | 依赖 | | ---- | -------------------------------------- | | hub | 标准库(`urllib.request`,无三方依赖) | | cn30 | 标准库(`urllib.request`,无三方依赖) | | geo | `pip install requests` | --- ## 路由决策树 根据用户意图选择模块。如果意图模糊,先用 AskUserQuestion 确认。 ``` 用户想做什么? │ ├─ 想知道今天/最近哪个话题/事件在全网火 │ └─→ hub 模块(实时热榜) │ ├─ 想研究某话题在用户真实讨论里的口碑/反馈 │ └─→ cn30 模块(小红书+抖音+公众号) │ ├─ 想看品牌/产品在 AI 搜索里被如何介绍 │ └─→ geo 模块 │ ├─ 想做品牌诊断:热度 + 口碑 + AI 可见度 全维度 │ └─→ 组合工作流(hub → cn30 → geo) │ └─ 给了具体平台/时间/品牌名 └─→ 根据字段路由到对应模块 ``` --- ## 模块一:hub(热榜聚合) ### 能力 实时抓取 7 大平台热搜数据,跨平台事件识别,TOP10 榜单,趋势预测,订阅推送。 ### 平台代码 | 代码 | 平台 | 代码 | 平台 | | ---- | ---- | ---- | ---- | | `bd` | 百度 | `bz` | B站 | | `zh` | 知乎 | `ks` | 快手 | | `wb` | 微博 | `tt` | 头条 | | `dy` | 抖音 | | | ### 快速调用 ```bash # 最新热榜(前一个完整小时) python3 ~/.workbuddy/skills/brand-geo-analysis-plus/scripts/hub_fetch.py \ --source "全平台热点事件-GitHub" # 今日热榜(今日0:00 到当前整点) python3 ~/.workbuddy/skills/brand-geo-analysis-plus/scripts/hub_fetch.py \ --source "全平台热点事件-GitHub" \ --start-date "T 00:00:00" --end-date "T HH:00:00" # 昨日热榜 / 本周热榜 / 指定平台 / 关键词泛化 # 详见 references/hub-instructions.md ``` ### 输出规范 按平台分类输出 TOP10,**平台展示顺序固定**为:百度 → 知乎 → 微博 → 抖音 → B站 → 快手 → 头条。 - 智能跳过空平台 - 数据≤10 条展示实际数量;>10 条显示「查看{平台名}完整榜单」引导 - 热度值**已由脚本格式化完毕**,直接使用 `hotCount` 字段原值即可,**不要做任何换算** - 脚本内 `format_hot_count()` 已处理:纯数字会折算为「数字+万」(`5980000` → `598万`);返回值已含单位(`"598万"`、`"345 万热度"`)的原样透传 - 禁止对 `hotCount` 做整除/乘法运算(它是字符串,会 `TypeError`),也禁止再拼接「万」(会得到「598万万」) - 完整模板见 `references/hub-output-templates.md` - 趋势预测逻辑见 `references/hub-prediction-logic.md` - 全部指令清单见 `references/hub-instructions.md` ### 关键规则 - **小时级更新**:默认查询是「前一个完整小时」而非当前小时 - **关键词泛化**:大词(体育/娱乐/科技等)自动扩展为10个相关词;精确词直接用原词 - **默认 compact 模式**:stdout 输出极简(每平台仅预览 TOP3,表头会标注"(下表为 TOP3 预览)")+ 末尾附带 `dataFile: {path}`,完整榜单必须从 `dataFile` 读 JSON,不要因为只看到 3 条就认为数据缺失 - **平台顺序已由脚本固定**为 百度 → 知乎 → 微博 → 抖音 → B站 → 快手 → 头条,无需自行重排 --- ## 模块二:cn30(社媒深度研究) ### 能力 从小红书、抖音、公众号三大平台搜索近30天真实用户讨论数据,跨平台综合分析舆情趋势,输出研究报告和可视化 HTML 报告。 ### 平台代码 | 代码 | 平台 | 关键指标 | | ----- | ------ | -------------------------------------- | | `xhs` | 小红书 | 点赞/收藏/评论(收藏/点赞比=种草信号) | | `dy` | 抖音 | 点赞/评论/分享(分享数=传播力) | | `gzh` | 公众号 | 阅读/点赞/转发(阅读量=关注度) | ### 快速调用 ```bash python3 ~/.workbuddy/skills/brand-geo-analysis-plus/scripts/cn30_search.py \ "AI视频工具,大模型" \ --platforms xhs,dy,gzh \ --count 50 \ --days 30 \ --output-format both \ --output-dir ~/Documents/CnLast30Days ``` `--output-format` 支持 `json` / `html` / `both`。 ### 工作流 1. **环境检查**:确认 `REDFOX_API_KEY` 已配置 2. **关键词质量检查**:话题太模糊("工具"、"方法")时让用户具体化 3. **预研究**:并行 2-3 个 WebSearch 提取热词(小红书/抖音/通用) 4. **查询计划**:合并相关词为一次调用,每平台最多5个词,默认只调用1次引擎 5. **运行引擎**:前台运行(5分钟超时),读完整输出 6. **WebSearch 补充**:1-2 次 WebSearch 覆盖知乎/B站/36氪(排除 xhs/dy/gzh 域名) 7. **综合输出**:按 [输出规则](references/cn30-output-rules.md) 生成报告 ### 输出规范(强制) ``` 🇨🇳 cn-last30days v2.0.0 · {YYYY-MM-DD} ## 数据速览 (公众号 TOP5 → 小红书 TOP5 → 抖音 TOP5,标题作可点击 Markdown 链接) 我的发现: **{话题}** - 1-2句描述,来源 [平台@作者](链接) 核心发现: 1. ... 2. ... {引擎页脚逐字粘贴} --- 我是 {TOPIC} 的专家,我可以帮你: - ... HTML 报告已生成:[查看报告](HTML文件路径) ``` **LAW 规则**(任何一条违反都视为输出错误): - LAW 1:结尾不要 `Sources:` / `References:` 块 - LAW 2:通用查询以「我的发现:」开头,禁止 `##`/`###` 章节标题;对比查询例外 - LAW 3:不用破折号(`—`/`–`),用 `-`(空格连字符空格) - LAW 4:引擎页脚逐字包含,位于核心发现之后、邀请之前 - LAW 5:每个引用用内联 Markdown 链接,不用裸 URL - LAW 6:不要输出原始排名列表,转化为散文洞察 - LAW 7:徽章之后、「我的发现」之前必须有「数据速览」模块 完整模板见 `references/cn30-output-rules.md`。 ### HTML 报告(自动生成) 数据查询完成后,**无需询问**直接生成 HTML: ```bash python3 ~/.workbuddy/skills/brand-geo-analysis-plus/scripts/cn30_search.py \ --from-json "JSON文件路径" \ --output-dir ~/Documents/CnLast30Days open "HTML文件路径" ``` --- ## 模块三:geo(AI 搜索品牌可见度) ### 能力 向豆包、Kimi、DeepSeek 三个 AI 搜索引擎批量提问,分析品牌出现率、情绪倾向、信源引用、竞品对比,生成交互式 HTML 报告。 ### 平台代码 `doubao` / `kimi` / `deepseek`(默认全选,逗号分隔) ### 工作流 #### Step 0: 输入收集 必填: - 品牌名(如「元气森林」、「大疆」、「蔚来」) - 品类/行业(如「无糖饮料」、「无人机」、「新能源汽车」) 可选: - 品牌别名(提高匹配精度) - 竞品列表 - 自定义问题列表(有则跳过 Step 1) **如果只有品牌名没有品类,必须追问品类。** #### Step 1: 问题生成(用户未提供问题时) 通过 websearch 搜索品类相关问题,结合品类知识生成 **8 个**问题。 问题类型必须覆盖四类(**每类至少 2 个**,共 8 个,最多 12 个): - **推荐类**:「{品类}哪个品牌好?」(不直接含品牌名) - **对比类**:「{品牌A}和{品牌B}哪个好?」 - **评价类**:「{品牌}怎么样?」(可含品牌名) - **场景类**:「{场景}用什么{品类}好?」(不直接含品牌名) 生成后**必须用 AskUserQuestion 确认**,等用户明确同意才能进入 Step 2。 #### Step 2: 批量搜索 ```bash python3 ~/.workbuddy/skills/brand-geo-analysis-plus/scripts/geo_search.py \ --queries '["问题1","问题2","问题3","问题4","问题5","问题6","问题7","问题8"]' \ --platforms doubao,kimi,deepseek ``` 输出 `output/search_results.json`。脚本自动完成全部任务(3 平台 × N 问题)的并行提交与轮询,最长 15 分钟。 - 8 个问题 → 24 个任务,等待提示:「约需 5-8 分钟」 - 平台返回限流/故障话术(如豆包「高峰期算力紧张…请稍后再试」)时,脚本会自动重试 1 次;仍无效则记为 `status: "invalid"` 并**排除出统计**(不计入提及率分母),不再当作「品牌未被提及」 #### Step 3: 确定性分析 ```bash python3 ~/.workbuddy/skills/brand-geo-analysis-plus/scripts/geo_analyze.py \ --brand "品牌名" \ --aliases '["别名1","别名2"]' \ --competitors '["竞品A","竞品B"]' \ --search-results output/search_results.json ``` 输出 `output/deterministic.json` + `output/ai_analysis_template.json`。 #### Step 4: AI 分析(Agent 执行) 读取 `output/search_results.json`,**只对 `status == "completed"` 的回答**分析以下字段(`invalid` / `failed` / `timeout` 一律跳过,它们已被排除出统计): - `brand_rank` (int|null):推荐列表中的排名位置 - `brand_context` (str):上下文摘要 - `sentiment` (str):`positive` / `neutral` / `negative` - `sentiment_reason` (str):判断依据 - `competitors_mentioned` (list[str]):所有提及的竞品 - `competitor_details` (list[dict]):每竞品的 rank + sentiment - `key_claims` (list[str]):2-3 条关键描述 写入 `output/ai_analysis.json`。 **注意**:正文中的内联引用标记(Kimi `<REF>…</REF>`、DeepSeek `[citation:N]`)已由脚本在 Step 2 剥离,无需自行处理。 **信源可用性**:`sources` 为空是**正常现象**,常见于 Kimi(该平台只返回正文内联引用标记,不含可解析外链)。此时结果里会带 `sources_note` 字段,报告「信源分析」章节会自动展示数据完整性提示,**不要把空信源当作「品牌官网未被引用」的结论**。 **情绪判断规则:** - 回答明确推荐/强调优势 → `positive` - 客观描述/仅列举 → `neutral` - 指出缺点/不推荐 → `negative` #### Step 5: 合并 + 报告生成 ```bash # 合并分析(Step 4 完成后) python3 ~/.workbuddy/skills/brand-geo-analysis-plus/scripts/geo_analyze.py \ --brand "品牌名" --aliases '["别名"]' --competitors '["竞品"]' \ --search-results output/search_results.json \ --ai-analysis output/ai_analysis.json # 生成 HTML python3 ~/.workbuddy/skills/brand-geo-analysis-plus/scripts/geo_report.py \ --analysis output/analysis_result.json \ --search-results output/search_results.json ``` #### Step 6: 交付 1. 交付 `output/geo_report.html` 2. 输出关键发现摘要(5 条核心结论): - 跨平台提及率 + 分平台拆分 - GEO 综合得分 + 最佳/最差平台 - 情绪分布(正面率 = 正面数/有效回答数,中性不计入正面) - 竞品对比 + 品牌排名 - TOP3 信源域名 + 官网是否被引用(若某平台无信源,须说明而非当作"未被引用") ### 核心指标 详细定义见 `references/geo-metrics.md`。核心公式: ``` GEO 得分 = 提及率得分 × 40% + 排名得分 × 30% + 情绪得分 × 30% = 提及率×100 × 40% + max(0, 100 - (均排名-1)×10) × 30% + (正面占比×100 - 负面占比×50) × 30% ``` 解读:70+优秀 / 50-69良好 / 30-49一般 / <30不足。 --- ## 组合工作流:完整品牌感知诊断 当用户需要做全维度品牌诊断(典型问法:「帮我做一份{X}品牌的完整感知报告」),依次执行: ### 阶段 1:hub 摸热度(5分钟) ```bash python3 scripts/hub_fetch.py --source "全平台热点事件-GitHub" --keywords "{品牌}" --expand-keywords ``` 产出:品牌是否在近期热点中、覆盖哪些平台、平均热度、上榜时长。 ### 阶段 2:cn30 挖口碑(5-10分钟) ```bash python3 scripts/cn30_search.py "{品牌},{品类}" --days 30 --output-format both ``` 产出:真实用户讨论、情感倾向、TOP 反馈、HTML 报告。 ### 阶段 3:geo 看 AI 回答(10-15分钟) ```bash python3 scripts/geo_search.py --queries '[5 个问题]' --platforms doubao,kimi,deepseek python3 scripts/geo_analyze.py --brand "{品牌}" --competitors '["{竞品}"]' --search-results output/search_results.json # 步骤 4-5 见 geo 模块工作流 ``` 产出:品牌在 AI 搜索里的提及率、情绪、排名、信源、HTML 报告。 ### 阶段 4:综合报告 把 hub/cn30/geo 三阶段发现合并成一份叙事文档,结构: ``` # {品牌} 品牌感知诊断报告 ## 一句话结论 (综合三阶段结果的一句话判断) ## 1. 热度层(全网热榜) - 是否在热点中 / 平台覆盖 / 趋势方向 ## 2. 口碑层(社媒讨论) - 真实用户反馈 / 正负面信号 / 关键讨论主题 ## 3. AI 层(生成式搜索) - GEO 得分 / 提及率 / 情绪分布 / 竞品对比 / 信源 ## 4. 行动建议 - 热度层:{是否需要借势} - 口碑层:{具体需改进的负面反馈} - AI 层:{GEO 优化方向} ``` --- ## 错误处理(通用) | 情况 | 处理方式 | | ---------------------------- | ------------------------------------------------------------------------------- | | 未配置 `REDFOX_API_KEY` | 提示用户前往 [红狐 hub](https://redfox.hk/settings/api-keys?source=github) 获取 | | 关键词模糊("工具"、"方法") | 用 AskUserQuestion 让用户具体化 | | cn30 引擎部分失败 | 继续分析已完成的数据,HTML 报告中标注失败项 | | geo 任务全部超时 | 提示用户稍后重试,可能是 API 负载过高 | | geo AI 分析结果缺失 | 仅生成确定性分析(提及率、域名),情绪模块标注"待分析" | | 品牌名太短(≤2字) | 提示用户提供品牌别名以提高匹配精度 | --- ## 资源索引 ### 脚本 | 文件 | 用途 | | -------------------------------- | ------------------------------------------------ | | `scripts/hub_fetch.py` | 7平台热榜聚合 | | `scripts/cn30_search.py` | 小红书/抖音/公众号近30天讨论搜索 + HTML 报告生成 | | `scripts/geo_search.py` | AI 搜索批量调度器 | | `scripts/geo_analyze.py` | GEO 确定性分析 + 合并分析 | | `scripts/geo_report.py` | GEO HTML 报告生成 | | `scripts/lib/platforms.py` | AI 平台适配器 | | `scripts/lib/analyzer.py` | GEO 分析逻辑库 | | `scripts/lib/report_template.py` | GEO HTML 模板库 | ### 参考资料 | 文件 | 何时加载 | | ------------------------------------ | ------------------- | | `references/cn30-output-rules.md` | 生成 cn30 报告时 | | `references/hub-instructions.md` | 处理 hub 用户指令时 | | `references/hub-output-templates.md` | 输出 hub 报告时 | | `references/hub-prediction-logic.md` | 做趋势预测时 | | `references/geo-metrics.md` | 计算 GEO 指标时 | --- ## 常见问答 **Q: 三个模块必须都用吗?** A: 不必。根据路由决策树按需调用。简单场景用一个模块即可,复杂诊断才用组合工作流。 **Q: 为什么不把三个模块的脚本合并成一个 CLI?** A: 三个数据源、调用逻辑、输出格式差异较大。脚本独立性更高,便于单独维护和升级。当一个数据源失败时不影响其他模块。 **Q: 升级某个模块怎么办?** A: 单模块独立升级只需替换 `scripts/` 下对应文件 + 更新对应 `references/` 文档。统一 SKILL.md 不需要改动。 **Q: 这三个模块能直接拿到本工作区使用吗?** A: 是的。skill 安装在 `~/.workbuddy/skills/brand-geo-analysis-plus/`,所有 WorkBuddy 会话和 workspace 都可使用。
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.