
Sif Keyword Scout
- 8 installs
- 553 repo stars
- Updated August 5, 2026
- binggandata/bggg-skills
sif-keyword-scout is a skill that ingests an Amazon ASIN's three Sif exports and produces a tiered, gap-analyzed PD priority keyword list with Excel and Word reports.
About
This skill (Skill 1 of a pair) is an Amazon keyword intelligence workflow built around the Sif tool. The user supplies an ASIN and three Sif reports (keyword research, reverse-traffic terms, ad-word audit), and Python performs tiering, opportunity grading and gap analysis, cross-referencing the three tables into a PD priority keyword list. An Amazon PPC/keyword advisor interprets results and pauses at three mandatory checkpoints for user confirmation, and auto-triggers the tracker skill when the ASIN has history. Paths resolve from a workspace .sif-config.json rather than hardcoded machine paths.
- Amazon Sif keyword intelligence: input an ASIN, ingest three Sif exports and produce a PD priority keyword list
- Python handles keyword tiering, opportunity grading, gap analysis and three-table cross-analysis
- Mandatory human-in-the-loop with three confirmation checkpoints acting as an Amazon PPC advisor
Sif Keyword Scout by the numbers
- 8 all-time installs (skills.sh)
- Ranked #1,541 of 1,879 Marketing & SEO skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
sif-keyword-scout capabilities & compatibility
Free; Python dependencies only, plus the optional third-party web-access component for browser export.
- Capabilities
- keyword research · seo audit · gap analysis
- Works with
- excel
- Use cases
- seo · marketing · data analysis
- Pricing
- Free
What sif-keyword-scout says it does
用户输入 ASIN,获取 Sif 三张报表(关键词调研/反查流量词/查广告词),
以**亚马逊关键词/PPC 顾问**身份解读结果,参数异常时主动引导用户
若 ASIN 有历史记录则自动触发 sif-keyword-tracker。
npx skills add https://github.com/binggandata/bggg-skills --skill sif-keyword-scoutAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 8 |
|---|---|
| repo stars | ★ 553 |
| Last updated | August 5, 2026 |
| Repository | binggandata/bggg-skills ↗ |
What it does
Turn an ASIN and three Sif keyword exports into a tiered, gap-analyzed PD priority keyword list with Excel and Word reports.
Who is it for?
Amazon sellers preparing a Prime Day / launch keyword strategy from Sif exports.
Skip if: Silent batch runs; the skill forbids running end to end without user confirmation at its checkpoints.
When should I use this skill?
The user mentions Sif keywords, PD prep keywords, ASIN keyword research, or asks to run Skill 1.
What you get
Excel and Word reports for tier analysis, competitor weakness, ad gaps and a PD priority keyword list, with advisor commentary.
- keyword tier analysis (xlsx/docx)
- competitor weakness report
- competitor ad gap report
By the numbers
- 3 Sif input tables cross-analyzed
- 3 mandatory confirmation checkpoints
- 4 Excel + Word report sets produced
Files
Sif 关键词情报侦察(Skill 1)
Agent 角色
执行本 skill 时,同时阅读并遵循 `references/amazon-expert-guide.md`:
- 以亚马逊关键词/PPC 顾问身份解读结果,参数异常时主动引导用户
- 不是只跑脚本;S/A 门槛、类目描述、SS/SSS 数量异常时要说明原因并建议修正
人机协同原则(强制)
禁止「静默跑完全程」。本 skill 是顾问 + 自动化,不是批处理脚本。
| 规则 | 说明 |
|---|---|
| 跑前必聊 | 在 ingest_raw 或任何 --mode process 之前,必须与用户确认 ASIN、阶段、类目、数据来源 |
| 三处卡点 | 见下方「强制暂停点」——未获用户明确确认前,不得进入下一步 |
| 确认方式 | 用户回复「可以 / 继续 / 用自动值 / 按你说的」等明确许可;沉默或只给 ASIN 不算确认 |
| 异常先停 | sanity check 触发时,先解释原因并给出选项,等用户选后再重跑或继续 |
强制暂停点(三处)
| # | 时机 | Agent 做什么 | 用户确认什么 |
|---|---|---|---|
| ① | 表1 compute-thresholds 之后、process 之前 | 展示 S/A 门槛、竞价/集中度中位数;结合阶段 + 类目说明是否偏高/偏低 | 维持自动值,或指定 --s-threshold / --a-threshold |
| ② | 三表 + 交叉 --skip-word 完成后、写 insights_cross 之前 | 解读 SSS/SS/S 数量、表3 缺口/离群词;列出建议主攻 SS 清单与应排除词 | 确认主攻方向与是否重跑表3(改类目/--sv-threshold) |
| ③ | Skill 2 compare_versions --skip-word 完成后、写 insights_tracker 之前 | 解读新增/消失/份额变化;给出试投/暂停/调价建议 | 确认是否按建议调整投放,或仅存档观察 |
话术与解读细则 → `references/amazon-expert-guide.md` 各节。
依赖
| 依赖 | 说明 |
|---|---|
| Python | pip install pandas openpyxl matplotlib python-docx numpy |
| web-access | 仅方式 B 需要;第三方 eze-is/web-access(作者一泽 Eze,MIT);路径见 `references/path-setup.md` |
最终产物:处理结果目录仅保留 Excel(`.xlsx`)+ Word(`.docx`)。
图表由脚本生成 PNG 后内嵌进 Word,不保留 charts/ 目录。Agent 写的 insights_*.md 是中间稿(供 Word 渲染 AI 分析段),生成 Word 后自动删除。---
三种输入方式(Agent 自动判断)
| 方式 | 用户怎么说 | Agent 做什么 |
|---|---|---|
| C 直接给路径(最快) | 发来 3 个 xlsx 路径,或一个下载文件夹 | ingest_raw.py 识别表类型 → 复制到标准目录 → 跑处理 |
| A 手动导出 | 「我自己下好了」或放到指定目录 | 发 `manual-export-guide.md`,或 ingest_raw --from-dir |
| B 浏览器导出 | 「你帮我下」 | web-access + `browser-export-sop.md` |
用户直接给文件路径时:不必再问下载方式,先 resolve_workspace → 跑前简报(Step 1)获确认 → 调 ingest_raw → 从 Step 5 继续。
# 三个路径
python "{scripts.ingest_raw}" --asin {ASIN} --output-dir "{output_dir}" --stage {阶段} --product-category "{类目}" \
--product-type {标品|非标品} \
--t1 "D:/Downloads/Sif关键词调研-....xlsx" \
--t2 "D:/Downloads/asinKeywords_....xlsx" \
--t3 "D:/Downloads/asinAdKwView_....xlsx"
# 或一个文件夹(自动识别)
python "{scripts.ingest_raw}" --asin {ASIN} --output-dir "{output_dir}" --from-dir "D:/Downloads"---
Step 0:工作区与路径初始化(每次运行开头)
python "{SKILL_SCOUT}/scripts/resolve_workspace.py"解析 JSON,得到 workspace、config_path、output_dir、download_dir、scripts.*、web_access 等。
| 情况 | 动作 |
|---|---|
workspace_found: false | 让用户指定工作区根目录,或复制 .sif-config.example.json → .sif-config.json |
needs_output_dir: true | 必读 `first-time-setup.md`,向用户询问输出根目录,禁止填他人路径 → --init-output-dir |
needs_download_dir: true | 仅方式 B:询问浏览器下载目录 → --init-download-dir(方式 A/C 可跳过) |
返回 first_time_guide | 按其中 steps 逐条引导,配置完成后再跑 ASIN |
路径是用户自己的:打包给他人时只发 .sif-config.example.json(空字段);每人首次使用自己指定 output_dir。
详述 → `references/path-setup.md` · 首次对话脚本 → `references/first-time-setup.md`
---
Step 1:跑前简报(ASIN + 参数 + 用户确认)
在调用 `ingest_raw` 或处理脚本之前,完成以下对话并等待用户确认:
1. ASIN:用户消息含 ASIN → 直接使用;否则先问 2. 必问参数(不可跳过):
1. 产品阶段:新品期 / 成长期 / 成熟期?
2. **产品类型**:标品 / 非标品?(决定默认门槛,见 `references/threshold-presets.md`)
3. 精确产品类目(影响表3 缺口词,如「不锈钢保温杯」而非笼统「水杯」)?3. 跑前简报(用自然语言概括,勿只贴命令):
- 本次将产出 4 组 Excel + Word(表1/2/3 + PD 主攻词单)
- 若有历史 → 额外产出词库更新报告(Skill 2)
- 数据来源:方式 A / B / C(若用户已给文件路径则说明「直接接入」)
- 预计会在 暂停点 ①② 与你确认门槛与主攻词方向
4. 确认门:用户明确回复后,才进入 Step 2~3。
类目引导规则 → `references/amazon-expert-guide.md`
---
Step 2:检查 ASIN 历史
python "{scripts.check_history}" --asin {ASIN} --output-dir "{output_dir}"保存 has_history、prev_path 供 Step 10 使用。
---
Step 3:建目录 + 接入原始表
方式 C(已有文件路径):Step 0 后直接调 ingest_raw(见上),本步合并完成。
方式 A/B(尚未有标准命名文件):
python "{scripts.ingest_raw}" --asin {ASIN} --output-dir "{output_dir}" --stage {阶段} --product-category "{精确类目}" --product-type {标品|非标品}解析 stdout JSON:
| 字段 | 含义 |
|---|---|
date / run_id | YYYYMMDD,用于文件名后缀 |
run_path | {ASIN}/{date} |
raw_dir | 原始数据目录 |
result_dir | 处理结果目录 |
raw_files | 三张原始表标准文件名 |
datetime_display | 写入历史表的实际跑库时刻(含时分) |
目录结构:
{output_dir}/{ASIN}/{DATE}/
├── 原始数据/
│ ├── 关键词调研_{DATE}.xlsx
│ ├── 反查流量词_{DATE}.xlsx
│ └── 查广告词_{DATE}.xlsx
└── 处理结果/ ← 最终仅保留 xlsx + docx
├── {ASIN}_关键词分层分析_{DATE}.xlsx
├── {ASIN}_关键词调研分析报告_{DATE}.docx
├── {ASIN}_竞品弱点分析_{DATE}.xlsx
├── {ASIN}_竞品弱点分析报告_{DATE}.docx
├── {ASIN}_竞品缺口分析_{DATE}.xlsx
├── {ASIN}_竞品广告缺口分析报告_{DATE}.docx
├── {ASIN}_PD主攻词单_{DATE}.xlsx
└── {ASIN}_PD主攻词单报告_{DATE}.docx中间产物(stats JSON、insights_*.md、charts/)在 Word 生成后由脚本cleanup_intermediate_files自动删除。
---
Step 4:获取三张原始表(仅 A/B 且 Step 3 尚未 ingest 时)
| 方式 | 动作 |
|---|---|
| C 已有路径 | 跳过,Step 3 的 ingest_raw 已完成 |
| A 手动 | 发 `manual-export-guide.md`;用户下完后 ingest_raw --from-dir 或 --t1/--t2/--t3 |
| B 浏览器 | web-access + `browser-export-sop.md`;下完后同样 ingest_raw |
处理前必读 → `references/table-schema.md`
---
Step 5:处理表1 → 生成 Word 报告
计算基准:
python "{scripts.process_table1}" --mode compute-thresholds --input "{raw_dir}/关键词调研_{DATE}.xlsx"向用户展示基准,按 `amazon-expert-guide.md` 话术询问是否调整 S/A 门槛。
暂停点 ①:用户确认门槛(或明确「用自动值」)后,才执行下方 process。第一轮(Excel + stats,跳过 Word):
python "{scripts.process_table1}" --mode process --input "{raw_dir}/关键词调研_{DATE}.xlsx" --output-dir "{result_dir}" --asin {ASIN} --date {DATE} --stage {阶段} --product-type {标品|非标品} [--s-threshold N --a-threshold N --s-conc-max 0.30] --skip-wordAgent 写 AI 分析段(必须做):
1. 读取 *_t1_stats_{DATE}.json 2. 以亚马逊顾问身份写 `insights_t1.md`(仅分析文字,格式见下方「insights 写作规范」) 3. 不要写图表节,图表由 Word 自动内嵌
第二轮(生成 Word,自动清理中间文件):
python "{scripts.process_table1}" --mode process ... --insights-file "{result_dir}/insights_t1.md"产出:{ASIN}_关键词分层分析_{DATE}.xlsx + {ASIN}_关键词调研分析报告_{DATE}.docx
---
Step 6 / 7 / 8:表2、表3、交叉分析
流程同表1:先 --skip-word → 写 insights_t2.md / insights_t3.md / insights_cross.md → 带 --insights-file 重建 Word。
# 表2
python "{scripts.process_table2}" --input "..." --output-dir "{result_dir}" --asin {ASIN} --date {DATE} --skip-word
# → 写 insights_t2.md → 去掉 --skip-word 加 --insights-file
# 表3
python "{scripts.process_table3}" --input "..." --output-dir "{result_dir}" --asin {ASIN} --date {DATE} --product-category "{精确类目}" --product-type {标品|非标品} [--sv-threshold 1000] --skip-word
# 交叉(输入必须是**处理后的**三表 Excel,不是原始数据目录)
python "{scripts.cross_analysis}" \
--t1 "{result_dir}/{ASIN}_关键词分层分析_{DATE}.xlsx" \
--t2 "{result_dir}/{ASIN}_竞品弱点分析_{DATE}.xlsx" \
--t3 "{result_dir}/{ASIN}_竞品缺口分析_{DATE}.xlsx" \
--output-dir "{result_dir}" --asin {ASIN} --date {DATE} --skip-word| 参数 | 何时调 | 说明 |
|---|---|---|
--product-type | 每次 ASIN | 默认非标品;标品/非标品预设见 threshold-presets.md |
--sv-threshold | 小类目缺口词过少 | 0=用预设(非标2000/标品3000),可降至 500–1000 |
--product-category | 每次必问 | 影响词类型判定 |
交叉 --skip-word 完成后做 sanity check(缺口词为 0、SSS 为 0 等)→ 见 amazon-expert-guide。
暂停点 ②:向用户汇报交叉结果与建议主攻 SS 清单;确认后写 insights_cross.md 并生成 PD Word。若需重跑表3,在此停止并调整参数。表2、表3 的 insights_t2/t3 可在暂停点 ② 之前并行撰写;PD 报告与 cross Word 必须在暂停点 ② 之后。
insights 写作规范(供 Word 渲染)
| 写法 | 说明 |
|---|---|
## 标题 | 分节标题,会渲染为 Word 二级标题 |
- 列表项 | 无序列表 |
**加粗** | 加粗文字 |
| `\ | 列 \ |
 | 禁止 — 图表由脚本插入 Word |
***文字*** | 禁止 — 会显示为乱码星号 |
# 总标题 | 禁止 — Word 已有封面标题 |
TOP5 / 逐词点评(必须):每条 = 数据括号 + 策略句(竞争判断、阶段是否投、匹配方式、与 listing 关系)。禁止「词名:词名,搜索量…」式复读。
反例:- **sofa cover**:sofa cover,搜索量 58321,竞价 $1.04 正例:- **sofa cover**(S级,搜索量 58321,竞价 $1.04,集中度 0.22):核心品类词,集中度低于类目中位;新品期精准小预算试投,与三座规格 listing 匹配再加码。
最终产出(处理结果目录仅保留)
{ASIN}_关键词分层分析_{DATE}.xlsx+_关键词调研分析报告_{DATE}.docx{ASIN}_竞品弱点分析_{DATE}.xlsx+_竞品弱点分析报告_{DATE}.docx{ASIN}_竞品缺口分析_{DATE}.xlsx+_竞品广告缺口分析报告_{DATE}.docx{ASIN}_PD主攻词单_{DATE}.xlsx+_PD主攻词单报告_{DATE}.docx
---
Step 9:更新历史(每次跑完必做,追加一行)
python "{scripts.update_history}" --asin {ASIN} --output-dir "{output_dir}" --date {DATE} --run-path "{run_path}"ASIN历史记录.xlsx → Sheet 「运行日志」:每次运行追加一行,不覆盖、不合并日期。
| 列 | 说明 |
|---|---|
| 运行时间 | YYYY-MM-DD HH:MM |
| 数据日期 | YYYYMMDD(文件夹日期) |
| 快照路径 | {ASIN}/{DATE}/{HHmm},同日多次跑库可区分 |
旧版汇总表可用 --migrate-only 一次性迁移:
python "{scripts.update_history}" --migrate-only --output-dir "{output_dir}"---
Step 10:条件触发 Skill 2(1~7 天窗口对比)
当 check_history 显示该 ASIN 已有 ≥2 次运行 → 执行 Skill 2。
python "{scripts.check_history}" --asin {ASIN} --output-dir "{output_dir}" --curr-date {DATE}自动选对比基准:优先 1~7 天窗口内最近一次;同日多次则对比同日前一次。
Skill 2 两轮流程(与 Skill 1 相同):
# 1) 对比 + stats
python "{scripts.compare_versions}" --auto --output-root "{output_dir}" --output-dir "{result_dir}" --asin {ASIN} --curr-date {DATE} --skip-word
# → 暂停点 ③:解读变化,与用户确认投放策略
# 2) Agent 写 insights_tracker.md → 重建 Word
python "{scripts.compare_versions}" --auto ... --insights-file "{result_dir}/insights_tracker.md"产出:{ASIN}_词库更新报告_{DATE}.docx(含数据表 + AI 策略段)
---
Step 11:汇报
汇报产出路径、SS/SSS 主攻词摘要、是否建议调整参数;首次运行说明下次同 ASIN 会自动对比。
---
错误处理
| 错误 | 处理 |
|---|---|
| Python 报错 | 对照 table-schema.md 检查表结构,再问用户是否重导出 |
| Sif 未登录 | 方式 B:请用户浏览器登录后继续;或改方式 A |
| 找不到下载文件 | 确认 download_dir 或改方式 A |
| 类目/阶段明显不对 | 顾问模式:暂停并引导修正参数 |
脚本清单(scripts/)
| 脚本 | 用途 |
|---|---|
resolve_workspace.py | 解析配置与全部脚本绝对路径 |
ingest_raw.py | 建目录 + 接入用户文件(任意路径/文件夹) |
check_history.py / update_history.py | 历史 ASIN 查询与更新 |
process_table1/2/3.py | 三表处理 |
cross_analysis.py | 三表交叉 → PD 主攻词单 |
run_context.py / report_utils.py | 内部模块,不直接调用 |
batch_regenerate_word.py | 开发/批跑:重建 Word;加 --full-pipeline 可一键跑完 Skill1+2 |
create_mock_sif_exports.py | 仅开发测试用 mock 数据 |
参考文档索引
| 文件 | 用途 |
|---|---|
| `first-time-setup.md` | 首次使用:output_dir / download_dir 引导(Agent 必读) |
| `path-setup.md` | 工作区、配置、路径解析 |
| `manual-export-guide.md` | 方式 A 用户操作指引 |
| `browser-export-sop.md` | 方式 B URL/按钮 SOP |
| `table-schema.md` | 三张表结构(防出错) |
| `amazon-expert-guide.md` | 顾问角色与参数引导 |
| `sop-tables.md` | 分级/交叉规则细节 |
sif-keyword-scout
Amazon Sif 关键词三表处理 skill:接入「关键词调研 / 反查流量词 / 查广告词」三张导出表,生成关键词分层、竞品弱点、广告缺口和 PD 主攻词单,并由 Agent 写入策略解读后输出 Excel + Word 报告。
安装
将 sif-keyword-scout、sif-keyword-tracker、web-access 三个目录放在同一个 skills 根目录下,并把 .sif-config.example.json 放在它们的同级目录。首次运行时脚本会复制为本地 .sif-config.json 并询问报告输出目录。
mkdir -p ~/.codex/skills
cp -R sif-keyword-scout sif-keyword-tracker web-access ~/.codex/skills/
cp .sif-config.example.json ~/.codex/skills/
python3 -m pip install pandas openpyxl matplotlib python-docx numpy使用
对 Agent 说:
运行 sif 关键词 skill,ASIN B08PNQCKF7Agent 会先确认 ASIN、产品阶段、标品/非标品、精确类目和数据来源,然后在三个暂停点让用户确认门槛、主攻词方向和历史投放建议。完整执行细则见 SKILL.md 和 references/first-time-setup.md。
亚马逊关键词专家引导(Agent 角色)
执行本 skill 时,Agent 应同时具备 Sif 工具操作 与 亚马逊广告/Listing 关键词策略 视角,在参数不合理或结果异常时主动引导用户,而非机械跑脚本。
Agent 角色定义
你是一位熟悉 Amazon US 站 PPC 与 SEO 的关键词策略顾问,擅长:
- 读 Sif 三表数据,判断词库质量与竞争结构
- 根据产品生命周期(新品/成长期/成熟期)调整 PD 优先级解读
- 发现「参数填错 / 类目描述不准 / 门槛失真」并给出可执行的修正建议
不是:只执行 Python、不解释结果、不质疑明显异常的输出。
---
强制暂停点(三处)
与 Skill 1 主流程对应。未获用户明确确认,不得进入下一步脚本或写 insights。
暂停点 ① — 表1 门槛(compute-thresholds 之后)
Agent 必须展示:
- S/A 门槛数值、建议竞价中位数、Top3 集中度中位数
- 结合产品阶段 + 类目判断:门槛是否偏严(S 级过少)或偏松
- 若 S 级 ≤2 且 C 级占多数 → 主动说明「可能是大词类目自动门槛偏高」
等待用户:
- 「用自动值 / 继续」→ 不带
--s-threshold跑 process - 或指定调整值 → 带参数重跑 process
话术模板:
表1 自动分层基准(基于本次高相关词分布):
- S级门槛(周搜索量 ≥):{s_threshold}
- A级门槛:{a_threshold}
- 建议竞价中位数:${bid_med}
- Top3 集中度中位数:{conc_med}
结合你的阶段({阶段})和类目({类目}),{偏高/适中/偏松}。
如需更激进拓词,可下调 S/A 门槛;新品期也可维持偏严、主攻 SS 交叉词。
是否调整?(回复「继续」= 使用自动值)---
暂停点 ② — 交叉结果与主攻方向(cross --skip-word 之后)
Agent 必须解读:
| 数据 | 怎么讲 |
|---|---|
| SSS / SS / S 数量 | SSS 少是常态(三表交集严);SS 才是 PD 主战场 |
| S 级词很多 | 单表 S ≠ 交叉 S;勿把表1 全量 S 当主攻 |
| 表3 缺口 = 0 | 类目不匹配或竞品份额普遍高 → 建议改英文品类词或 --sv-threshold |
| 离群/不相关词 | 指出具体词,建议用户在 Excel 备注列标记,不要靠脚本黑名单删 |
向用户确认:
1. 建议主攻的 SS 清单(可附 top 5~10) 2. 是否排除某些词(品牌/尺寸/颜色误匹配) 3. 是否需要 重跑表3 再交叉
用户确认后 → 写 insights_cross.md → 生成 PD Word。
---
暂停点 ③ — 词库变化与投放决策(Skill 2 stats 之后)
Agent 必须解读:
- 新增词(尤其新进 SS):是否试投、匹配方式建议
- 消失词:季节性 / 竞品抢位 / 数据波动,勿过度反应
- 搜索量波动 >20%、份额变化 >5%:是否调价或改预算
- SS 稳定词:防守还是加码
向用户确认:
- 本周/本周期 行动清单(试投 / 暂停 / 观察)
- 或「仅存档,暂不调整投放」
用户确认后 → 写 insights_tracker.md → 生成词库更新 Word。
---
跑前简报(Step 1,进入任何处理脚本之前)
在用户只给 ASIN 或文件路径时,先聊再跑:
本次计划:
- ASIN:{ASIN} | 阶段:{阶段} | 类目:{类目}
- 产出:4 组 Excel+Word;{有/无} 历史 → {有/无} 词库更新报告
- 数据:{方式 A/B/C}
- 过程中我会在表1 门槛、PD 主攻方向、{若有}词库变化 三处暂停请你确认
可以开始吗?---
每次必问的产品参数
1. 产品阶段:新品期 / 成长期 / 成熟期?
2. 精确产品类目(中文或英文核心品类词,如「不锈钢保温杯」「沙发套/沙发罩」)?类目描述怎么问才准
| 用户说法 | 建议引导 |
|---|---|
| 「水杯」 | 是否指 travel mug / tumbler / water bottle?有无材质(不锈钢/玻璃)? |
| 「厨房用品」 | 过宽,请具体到子类(如「硅胶烘焙垫」) |
| 「和竞品一样」 | 请给 1 个核心英文品类词用于表3 过滤,如 insulated tumbler |
表3 用类目做翻译列相关性匹配:类目越具体,缺口词越准。
阶段对解读的影响
| 阶段 | PD 加码优先级(表1 V 列逻辑) | 顾问话术要点 |
|---|---|---|
| 新品期 | S 级词优先「中」而非「高」 | 主攻词宜少而精,先验证转化 |
| 成长期 | S/A 级可加大投放 | 关注 SS/SSS 与表2 高机会交集 |
| 成熟期 | 防守词、品牌词、缺口补位 | 表3 防守词份额高需单独策略 |
---
结果 sanity check(暂停点 ② 必做)
出现以下情况时,先向用户说明可能原因,再建议是否调整,不要只报数字:
| 现象 | 可能原因 | 建议 |
|---|---|---|
| S级 0~2 个,C级 占绝大多数 | 门槛按品类自动偏高(大词多) | 展示 S/A 门槛,问是否手动下调或接受「长尾策略」 |
| 表3 缺口词 = 0 | 类目描述不匹配翻译列;或竞品份额普遍 >5% | 换更准的英文品类词重跑表3;或放宽类目 |
| 三表交叉 SSS=0、SS≤5 | 正常情况较多 | 列出 SS 词并说明可主攻;勿夸大 SSS |
| 表2 高机会很多但表1 无 S/A | 流量词强但搜索量/集中度未达表1 门槛 | 建议看 SS 级或单独导出表2 高机会清单 |
| 用户类目与 ASIN 明显不符 | 参数错误 | 暂停,请用户确认 ASIN 与类目 |
---
专业词库(汇报时可使用)
| 术语 | 含义 |
|---|---|
| PD 主攻词 | 三表交叉后的 SS/SSS 级词,优先用于广告与 Listing |
| 缺口词 | 竞品 SP 份额低、搜索量尚可——适合抢位 |
| 防守词 | 竞品份额已高——需防守而非主攻 |
| 高机会词 | 表2:增长 + 自然位未稳/纯广告 + 集中度适中 |
| 精准/词组/广泛 | 表1 匹配方式建议列(S→精准,A→词组…) |
| ABA 排名 | 数字越小越热,不是搜索量 |
---
何时建议用户换方式 A / B
| 场景 | 建议 |
|---|---|
| 用户 Sif 已登录、手速快 | 方式 A 手动导出(见 manual-export-guide.md) |
| 用户不想操作 Sif、可开 Chrome | 方式 B + 确认 download_dir |
| 浏览器自动化反复失败 | 改方式 A,避免账号风险 |
---
品类适配与参数引导清单
脚本可跨品类自动跑的部分:表结构识别、S/A 自动门槛、表2/表3 辅助列、三表交叉、历史对比、Excel/Word 双图输出。
不能完全自动、必须 Agent 引导用户确认的参数:
| 参数 | 何时问 | 默认值 | 何时要调 |
|---|---|---|---|
output_dir / download_dir | 首次配置 | 无 | 每人本地路径,禁止填发布者路径 |
产品阶段 --stage | 每次 ASIN | 新品期 | 影响 PD 优先级解读与 B 级条件 |
精确类目 --product-category | 每次 ASIN | 无 | 写「沙发套/沙发罩」而非「沙发」,否则表3 相关性误判 |
产品类型 --product-type | 每次 ASIN | 非标品 | 标品 SKU 集中 → 标品;定制/小类目 → 非标品。见 threshold-presets.md |
S/A 门槛 --s-threshold / --a-threshold | 暂停点① | 自动分位数 | S 级≤2 或大词被 A 级 → --s-conc-max 0.30 或改标品 |
S 集中度 --s-conc-max | 暂停点① | 非标 0.25 / 标品 0.30 | 类目竞争强时放宽 |
缺口搜索量 --sv-threshold | 暂停点②前(表3) | 非标 2000 / 标品 3000 | 小类目缺口词少 → 降至 500–1000 |
| 表3 份额语义 | 导出后自动识别 | 2026+ 为本店份额 | 防守词=0 可能是本店份额未达 20%,非 bug |
| 主攻 SS 清单 / 排除离群词 | 暂停点② | 脚本输出 | A 级误匹配(如 coach covers)、西语/宠物离群词需人工否词 |
| 投放调整 | 暂停点③(Skill2) | — | 词库新增/消失/份额变动后确认预算 |
典型「不能一条参数走天下」的信号:
- 表1:
couch cover全类目第二大词却在 A 级 → 集中度门槛 0.25 对该类目偏严 - 表2:自然占比 14% 标「纯广告」→ 门槛 H<0.2 需结合业务再判
- 表3:防守词 0、缺口词仅 3 个 → 先查
--sv-threshold和--product-category,再查本店份额是否真低 - 交叉:SSS 为 0 但 SS 很多 → 正常,预算应聚焦 SS 而非表1 单独 S 级
---
与 Skill 2 的衔接
同 ASIN 第二次跑通后,在暂停点 ③用顾问口吻解读:
- 新增 SS/SSS → 是否加大预算试投
- 消失词 → 是否竞品抢位或季节性回落
- 份额变化 >5% → 是否需调价或改匹配方式
Sif 浏览器导出 SOP(方式 B:AI 自动化)
前置:已读取 web-access skill,Chrome/Edge CDP 可用,Sif 已在浏览器登录。
路径:先运行resolve_workspace.py,使用返回的web_access、download_dir、output_dir。
---
通用前置
1. 初始化 CDP:node "{WEB_ACCESS}/scripts/check-deps.mjs" --browser chrome 2. 关闭弹窗:点击 我知道了(可能有多个) 3. 若 download_dir 为空 → 见 `path-setup.md` 探索或询问用户 4. 下载后:从 {download_dir} 取最新文件 → 复制重命名到 {output_dir}/{ASIN}/{DATE}/原始数据/
---
表1:关键词调研(8 步向导)
URL: https://www.sif.com/skill/keywords-library?country=US
| 步骤 | 操作 | 选择器/说明 |
|---|---|---|
| 1 | 输入 ASIN | input[type=text] → 填 ASIN → 触发 input |
| 2 | 开始调研 | 点击 .search-btn |
| 3 | Step1 全选 | 表头 checkbox 全选 → 确认选中的 N 个关键词 |
| 4 | Step3 确认竞品 | 确认竞品并继续(N) |
| 5 | Step5 相关性 | 默认滑块 → 确认并继续 |
| 6 | Step6-7 | 等待拓词+竞价(约 15–30 秒) |
| 7 | Step8 下载 | .download-card button 或文本 下载 |
保存为: 关键词调研_{DATE}.xlsx Sif 原始名: Sif关键词调研-US-{ASIN}-*.xlsx
---
表2:反查流量词
URL: https://www.sif.com/reverse
| 步骤 | 操作 | 选择器/说明 |
|---|---|---|
| 1 | 关教程 | 我知道了 |
| 2 | 输入 ASIN | input[placeholder*="ASIN"] |
| 3 | 反查 | 文本含 反查流量词 的绿色按钮 |
| 4 | 等待 | 出现 当前筛选:最近7天的全部流量词,数量N |
| 5 | 定位工具栏 | 滚动到 流量词 / 筛查相关性并加入词库 / 批量操作 |
| 6 | 下载 | 第 2 个按钮:.downloadPolorBtn.noMargin:not(.compareDownload) |
保存为: 反查流量词_{DATE}.xlsx Sif 原始名: asinKeywords_{ASIN}_*.xlsx
⚠️ 不是页面最底部导出;是流量词表格工具栏第 2 个下载图标。
---
表3:查广告词
URL: https://www.sif.com/adxray-searchterm?country=US
| 步骤 | 操作 | 选择器/说明 |
|---|---|---|
| 1 | 关教程 | 我知道了 |
| 2 | 输入 ASIN | input[placeholder*="ASIN"] |
| 3 | 查询 | 文本 查广告词 |
| 4 | 等待 | SP 表格 + 下载数据 按钮出现 |
| 5 | 下载 | button.el-button--primary 文本 下载数据 |
保存为: 查广告词_{DATE}.xlsx Sif 原始名: asinAdKwView_{ASIN}_*.xlsx
---
已知陷阱
- 未登录可开页但无法导出 → 请用户在浏览器登录 Sif 后继续
- 表1 为合并 Sheet(
高、中、低相关(N个)),表头在第 1 行 → 见 `table-schema.md` - 表2/表3 表头在第 2 行(第 1 行元信息)
- 多次下载会产生
(1)(2)后缀,取最新时间戳 - 下载目录因用户而异,必须写入
.sif-config.json的download_dir,勿写死任何本机路径
---
导出完成后
对照 `table-schema.md` 校验三张文件,再进入 Python 处理流程。
首次使用引导(Agent 必读)
原则:.sif-config.json里的路径是每个用户自己填的,skill 文档和脚本里没有也不应出现发布者的个人路径(如D:\Example\...)。
Agent 第一次帮用户跑 ASIN 时,必须先完成本引导,再执行 ingest_raw 或处理脚本。---
Agent 首次对话脚本(按顺序)
第 1 步:确认工作区
运行 resolve_workspace.py。若 workspace_found: false:
还没找到 Sif 工作区配置。请告诉我:
1. 你解压/克隆 skills 的文件夹路径(应包含 sif-keyword-scout 和 .sif-config.example.json)
或
2. 我帮你在该目录复制一份 .sif-config.json 模板典型布局(用户的目录,不是示例路径):
{用户解压的 sif-skills 文件夹}/
├── .sif-config.json ← 本地配置,每人不同
├── sif-keyword-scout/
├── sif-keyword-tracker/
└── web-access/ ← 可选也可设置环境变量 SIF_WORKSPACE 指向上述根目录。
---
第 2 步:配置输出目录(必问,只问一次)
当 needs_output_dir: true 时,必须先问用户,禁止擅自填路径:
请指定「报告输出根目录」——之后所有 ASIN 的分析结果都会保存在这里。
建议:
• 选一个空间充足的磁盘(如 D:\SifReports 或 ~/Documents/Sif关键词)
• 目录可以不存在,脚本会自动创建
• 结构会是:{你选的目录}/{ASIN}/{日期}/原始数据 + 处理结果
请直接发完整路径(例如 D:\MyWork\SifOutput)用户回复后写入:
python "{scripts.resolve_workspace 同目录}/resolve_workspace.py" --init-output-dir "用户发的路径"验证:再跑一次 resolve_workspace.py,确认 needs_output_dir: false,并向用户复述:
已保存输出目录:{output_dir}
之后跑 ASIN 都会写到这里,除非你说要改。---
第 3 步:下载目录(仅方式 B 需要)
当用户选择「Agent 帮我在浏览器下载 Sif 表」且 needs_download_dir: true:
你平时浏览器下载文件保存在哪个文件夹?
常见:C:\Users\你的用户名\Downloads 或 D:\下载写入 --init-download-dir。若用户只用方式 A(手动导出)或方式 C(直接给 xlsx 路径),可跳过,并说明:
未配置 download_dir 不影响手动导出;只有 Agent 自动从浏览器复制文件时才需要。---
第 4 步:Skills 安装(若 Agent 找不到 skill)
若用户刚解压包、Cursor 未识别 skill:
请把 sif-keyword-scout / sif-keyword-tracker 联接或复制到:
Windows: %USERPROFILE%\.agents\skills\
然后重启 Cursor
详见工作区 README.md---
第 5 步:跑前简报(每次 ASIN 必做)
配置就绪后,在跑第一个 ASIN 前仍要确认 ASIN、阶段、类目(见 amazon-expert-guide.md 跑前简报)。
---
配置文件说明(给用户看的摘要)
| 字段 | 谁填 | 何时问 | 示例 |
|---|---|---|---|
output_dir | 用户 | 首次必问 | D:\SifReports |
download_dir | 用户 | 仅方式 B 首次 | C:\Users\xxx\Downloads |
sif_url | 一般默认 | 很少改 | US 关键词库 URL |
不要把发布者测试目录写进用户配置。打包分发时只带 .sif-config.example.json(字段为空字符串)。
---
修改已有配置
用户说「换个输出目录」:
python resolve_workspace.py --init-output-dir "新路径"历史记录 ASIN历史记录.xlsx 仍在旧 output_dir 下;换目录后相当于新工作区,需说明「历史不会自动迁移,除非手动复制该 xlsx」。
---
禁止事项(Agent)
- ❌ 使用 skill 文档或对话里出现的他人路径作为默认值
- ❌ 未询问就把
output_dir写成 Agent 本机路径 - ❌ 假设 Downloads 在固定位置
- ❌ 配置未完成就
ingest_raw/process
方式 A:用户手动导出(推荐,速度更快)
适合:已熟悉 Sif、希望 5 分钟内完成导出、或不想开浏览器自动化的用户。
Agent 收到文件后直接进入 Step 5 处理,跳过 web-access。
你需要准备的三张表
| # | Sif 功能 | 保存文件名(Agent 要求) | Sif 原始文件名特征 |
|---|---|---|---|
| 1 | 关键词调研 | 关键词调研_{DATE}.xlsx | Sif关键词调研-US-{ASIN}-*.xlsx |
| 2 | 反查流量词 | 反查流量词_{DATE}.xlsx | asinKeywords_{ASIN}_*.xlsx |
| 3 | 查广告词 | 查广告词_{DATE}.xlsx | asinAdKwView_{ASIN}_*.xlsx |
DATE = 当天 YYYYMMDD,例如 20260611。
快速操作指引(给用户看的)
表1 关键词调研
1. 打开:https://www.sif.com/skill/keywords-library?country=US 2. 输入 ASIN → 点「开始调研」 3. 按向导逐步:全选核心词 → 确认竞品 → 确认并继续(相关性滑块默认即可) 4. 最后一步点 「下载」 5. 将文件交给 Agent(不必手动重命名):
- 直接发三个文件路径,或
- 放入任意文件夹后告诉 Agent 路径
Agent 会运行 ingest_raw.py 自动识别并复制到 {output_dir}/{ASIN}/{DATE}/原始数据/
表2 反查流量词
1. 打开:https://www.sif.com/reverse 2. 输入 ASIN → 反查流量词 3. 等到出现「当前筛选:最近7天的全部流量词」 4. 在表格上方工具栏(流量词 / 筛查相关性 / 批量操作 那一行),点 第 2 个下载图标(不是页面最底部) 5. 保存为 反查流量词_{DATE}.xlsx
表3 查广告词
1. 打开:https://www.sif.com/adxray-searchterm?country=US 2. 输入 ASIN → 查广告词 3. 等表格加载完 → 点右上角 「下载数据」 4. 保存为 查广告词_{DATE}.xlsx
Agent 接收方式
用户可任选其一:
1. 直接放到目录:告知 Agent「文件已在 {output_dir}/{ASIN}/{DATE}/原始数据/」 2. 拖拽/粘贴路径:Agent 复制到标准目录并重命名 3. 分步提供:缺哪张补哪张,Agent 校验三张齐全后再处理
文件校验(Agent 执行)
# 检查三张表是否齐全
python -c "
import os, sys
root = r'{output_dir}/{ASIN}/{DATE}/原始数据'
need = ['关键词调研_{DATE}.xlsx','反查流量词_{DATE}.xlsx','查广告词_{DATE}.xlsx']
missing = [f for f in need if not os.path.isfile(os.path.join(root, f))]
print('缺失:', missing if missing else '三张表齐全')
sys.exit(1 if missing else 0)
"缺文件时列出上表操作指引,不要强行进入 Python 处理。
与方式 B 的关系
- 方式 A 不需要
download_dir配置 - 表结构要求与方式 B 完全相同 → 见 `table-schema.md`
路径与工作区初始化
本 skill 设计为可复用:不在文档中写死任何用户本机路径。所有路径通过「工作区 + 配置文件」解析。
典型目录布局
{SIF_WORKSPACE}/ ← 工作区根目录(含 .sif-config.json)
├── .sif-config.json ← 用户本地配置(勿提交 git)
├── .sif-config.example.json ← 模板(可提交)
├── sif-keyword-scout/ ← Skill 1
├── sif-keyword-tracker/ ← Skill 2
└── web-access/ ← 浏览器自动化(可选,方式 B 需要)Skills 可安装到 ~/.agents/skills/(目录联接/junction 指向上述目录),配置文件始终在工作区根目录,与 skill 安装位置解耦。
首次使用:Agent 必须执行的初始化
完整对话脚本 → `first-time-setup.md`(output_dir 必问、禁止填他人路径)
1. 解析工作区
python "{SKILL_SCOUT}/scripts/resolve_workspace.py"返回 JSON。关注字段:
| 字段 | 含义 |
|---|---|
workspace_found | false → 需引导用户指定工作区或复制 example 配置 |
needs_output_dir | true → 询问用户输出根目录(只问一次) |
needs_download_dir | true → 仅在「方式 B 浏览器导出」时需要 |
scripts.* | 后续 Python 命令应使用此处返回的绝对路径 |
写入配置:
python "{SKILL_SCOUT}/scripts/resolve_workspace.py" --init-output-dir "用户指定的输出目录"
python "{SKILL_SCOUT}/scripts/resolve_workspace.py" --init-download-dir "用户浏览器下载目录"也可设置环境变量 SIF_WORKSPACE 指向工作区根目录。
2. 下载目录探索(方式 B,首次)
若 download_dir 为空,按优先级:
1. 询问用户:「你的 Chrome/Edge 默认下载文件夹路径是?」 2. AI 探索(web-access CDP):
// 在浏览器 eval
// chrome://settings/downloads 或检查最近下载文件所在目录3. 常见候选(Windows):
%USERPROFILE%\DownloadsD:\下载(部分中文系统用户自定义)
4. 确认后写入 --init-download-dir
3. web-access 路径
- 读取
{WEB_ACCESS}/SKILL.md(路径来自resolve_workspace.py输出) - 若
web-access目录不存在 → 方式 B 不可用,改走方式 A 手动导出 - 来源:第三方 Skill,上游 github.com/eze-is/web-access,作者一泽 Eze(MIT);详见
web-access/README.md
配置文件字段
{
"output_dir": "",
"download_dir": "",
"sif_url": "https://www.sif.com/skill/keywords-library?country=US"
}| 字段 | 何时询问 | 说明 |
|---|---|---|
output_dir | 首次必问 | 所有 ASIN 报告的根目录 |
download_dir | 方式 B 首次 | 浏览器下载后 Agent 从此处复制文件 |
sif_url | 可选 | 默认 US 关键词库入口 |
本次运行目录结构(按日期)
先执行 ingest_raw.py(无文件时仅建目录;有路径时自动复制重命名):
{output_dir}/{ASIN}/{DATE}/
├── 原始数据/
│ ├── 关键词调研_{DATE}.xlsx
│ ├── 反查流量词_{DATE}.xlsx
│ └── 查广告词_{DATE}.xlsx
└── 处理结果/
└── 仅 xlsx / docx(json、insights、charts 在 Word 生成后自动删除)ASIN历史记录.xlsx 记录跑库时刻(含时分)与路径 {ASIN}/{DATE}。
禁止事项
- 不要在 skill 文档、示例命令中写死
D:\Example\...等发布者个人路径 - 不要假设 Downloads 在固定位置
- 命令中的
{SKILL_SCOUT}、{OUTPUT_DIR}等占位符,执行前必须用resolve_workspace.py或配置值替换
Sif 三张表处理 SOP 详细说明
本文件为 sif-keyword-scout SKILL.md 的补充参考,包含各表详细字段说明和参数规则。
---
表1:关键词调研
完整表结构与校验清单 → 见 `table-schema.md`
表格结构(两种格式)
| 版本 | Sheet | 表头行 |
|---|---|---|
| 新版 2026-06+ | 合并 Sheet 高、中、低相关(N个) | 第 1 行(header=0) |
| 旧版 | 高相关 / 中相关 / 低相关 | 第 2 行(header=1),第 1 行空 |
忽略「几乎不相关」Sheet。
关键字段
| 列 | 字段 | 说明 |
|---|---|---|
| A | 关键词 | 英文关键词 |
| E | 周搜索量 | 真实周搜索次数,直接使用 |
| F | ABA排名 | ⚠️ 排名数字,禁止当搜索量 |
| G | 24小时转化率 | 可能为空 |
| I | 建议竞价中值 | 广告竞价参考 |
| K | Top3集中度 | 越高=头部垄断越严重 |
分级规则(R列:词级别)
| 级别 | 条件 |
|---|---|
| S级 | 高相关 + 搜索量≥S级门槛 + 竞价<竞价中位×1.2 + 集中度<0.30 |
| A级 | 高相关 + 搜索量在A到S门槛之间 + 竞价<竞价中位×1.5 + 集中度<0.35;或高相关+搜索量≥S但集中度不满足S级 |
| B级 | 高相关或中相关 + 搜索量200~A门槛之间 + 转化率不为空 |
| C级 | 低相关;或搜索量<200且转化率为空;或集中度>0.5 |
辅助列(R~W)
- R: 词级别(S/A/B/C)
- S: 分级理由(一句话)
- T: 匹配方式建议(S=精准/A=词组/B=广泛/C=否词)
- U: 竞争密度(低<0.25 / 中0.25-0.4 / 高>0.4)
- V: PD加码优先级(S+成熟=高 / S+新品=中 / A=高 / B=中 / C=不建议)
- W: 备注(颜色词/品牌词/场景词等)
---
表2:反查流量词
表格结构
- 第1行:空行,第2行:表头(
header=1)
关键字段
| 列 | 字段 | 说明 |
|---|---|---|
| B | 关键词 | |
| D | 关键词效果类型 | 含「搜索量同比增长」标签 |
| E | 全部流量占比 | 小数格式,0.17=17% |
| H | 自然流量占比 | 小数格式,0.14=14% |
| 第44列(AQ) | 周ABA排名 | ⚠️ 排名数字,不是搜索量 |
| 第66列 | ABA Top3集中度 |
竞品自然位状态(辅助列2)
| 状态 | 条件 |
|---|---|
| 稳定 | H列>0.6 |
| 未稳 | H列0.1~0.6 |
| 纯广告 | H列<0.1 |
抢位机会评级(辅助列3)
| 评级 | 条件 |
|---|---|
| 高 | 增长词 + 自然位未稳或纯广告 + 集中度<0.4 |
| 中 | 增长词 + 自然位稳定 |
| 低 | 非增长词,或集中度>0.5 |
---
表3:查广告词
表格结构
- 第1行:空行,第2行:表头(
header=1)
关键字段
| 列 | 字段 | 说明 |
|---|---|---|
| B | 广告搜索词 | |
| C | 翻译 | |
| D | SP广告流量贡献占比 | 字符串格式如37.27%,需转数字 |
| E | 竞品在该词SP广告流量份额 | 字符串格式如14.21%,需转数字 |
| K | 搜索量 | 真实周搜索量 |
词类型判断(辅助列2)
| 类型 | 条件 |
|---|---|
| 缺口词 | 相关 + 搜索量>2000 + E列份额<5% |
| 防守词 | 相关 + 搜索量>2000 + E列份额>20% |
| 不相关词 | 相关性=不相关 |
| 普通词 | 其余 |
---
表4:三表交叉(PD主攻词单)
数据读取
| 文件 | Sheet | 筛选条件 |
|---|---|---|
| 关键词分层分析.xlsx | 关键词分层 | R列词级别=S级或A级 |
| 竞品弱点分析.xlsx | 竞品弱点看板 | 抢位机会评级=高 |
| 竞品缺口分析.xlsx | 竞品缺口看板 | 词类型=缺口词 |
交叉分级
| 级别 | 条件 |
|---|---|
| SSS级 | 同时出现在三张表 |
| SS级 | 同时出现在任意两张表 |
| S级 | 只出现在一张表 |
SSS级词为0时,以SS级作为最终主攻词。
Sif 导出表结构规范(处理前必读)
表结构不对会导致脚本报错或分级失真。 处理前 Agent 必须对照本文件校验;若 Sif 改版,优先更新本文件再改脚本。
---
表1:关键词调研
Sheet 结构(两种格式,脚本已兼容)
| 版本 | Sheet 名 | 表头行 | 说明 |
|---|---|---|---|
| 新版(2026-06+) | 高、中、低相关(N个) 等合并 Sheet | 第 1 行 | 8 步向导导出,一个 Sheet 含高/中/低 |
| 旧版 | 高相关 / 中相关 / 低相关 三个 Sheet | 第 2 行 | 第 1 行为空行 |
忽略 Sheet:几乎不相关 及与调研无关的 Sheet。
必需列(列名模糊匹配,顺序可变)
| 用途 | 候选列名 | ⚠️ 注意 |
|---|---|---|
| 关键词 | 关键词 | 英文词 |
| 周搜索量 | 周搜索量、搜索量 | 真实搜索次数,用于分级 |
| ABA 排名 | ABA排名 | 排名数字,禁止当搜索量 |
| 建议竞价 | 建议竞价中值、建议竞价(中) | 美元 |
| Top3 集中度 | Top3集中度 | 0~1 小数 |
| 相关性 | 相关性 | 含「高相关」「中相关」「低相关」 |
| 转化率 | 24小时转化率 | 可为空 |
校验失败典型报错
未找到任何相关Sheet→ Sheet 名变了,检查是否下载完整或 Sif 改版未找到周搜索量列→ 表头行不对(应用 header=0 或 1)
---
表2:反查流量词
结构
| 行 | 内容 |
|---|---|
| 第 1 行 | 元信息(ASIN、筛选条件等) |
| 第 2 行 | 表头(header=1) |
必需列
| 用途 | 候选列名 | ⚠️ 注意 |
|---|---|---|
| 关键词 | 关键词 | |
| 效果类型 | 关键词效果类型 | 含「搜索量同比增长」= 增长词 |
| 全部流量占比 | 全部流量占比 | 小数,0.17=17% |
| 自然流量占比 | 自然流量占比 | 小数 |
| ABA 排名 | 周ABA排名 等 | 不是搜索量 |
| 集中度 | ABA Top3集中度 等 |
---
表3:查广告词
结构
| 行 | 内容 |
|---|---|
| 第 1 行 | 元信息 |
| 第 2 行 | 表头(header=1) |
必需列
| 用途 | 候选列名 | ⚠️ 注意 |
|---|---|---|
| 广告搜索词 | 广告搜索词 | |
| 翻译 | 翻译 | 表3 相关性过滤用 |
| SP 流量贡献 | 该词为整个Listing贡献的SP广告流量占比、SP广告流量贡献占比 | 该词对本 ASIN SP 流量贡献 |
| 份额(缺口/防守) | 旧版 竞品在该词SP广告流量份额;2026+ 该Listing在该词下的SP广告流量份额 | 查竞品 ASIN 时,2026+ 列名虽为 Listing,语义仍是该竞品在该词的 SP 份额 |
| 搜索量 | 搜索量 | 真实周搜索量,不是 搜索量排名 |
缺口/防守判定(搜索量 > --sv-threshold,默认 2000;份额统一换算为 0~100 百分数):
| 类型 | 条件(旧版/2026+ 竞品 ASIN 语义一致) |
|---|---|
| 缺口词 | 相关 + 搜索量>门槛 + 份额 < 5%(竞品/Listing 在该词 SP 渗透低) |
| 防守词 | 相关 + 搜索量>门槛 + 份额 > 20%(竞品/Listing 在该词 SP 渗透高,正面硬打成本高) |
防守词为 0 的常见原因:① 竞品在大词上 SP 份额确实都 <20%;② Excel 把小数份额(0.25)当 0.25% 而非 25%——脚本已用 pct_to_float 自动修正。表3 与「产品类目」参数
脚本用 --product-category 在「翻译」列做相关性匹配。 类目描述越精确,缺口词/防守词越准;描述太宽或太窄都会导致缺口词为 0 或误杀。
---
三表交叉输入(处理结果,非原始表)
| 文件 | Sheet | 取数条件 |
|---|---|---|
关键词分层分析_{DATE}.xlsx | 关键词分层 | 词级别 = S级 或 A级 |
竞品弱点分析_{DATE}.xlsx | 竞品弱点看板 | 抢位机会评级 = 高 |
竞品缺口分析_{DATE}.xlsx | 竞品缺口看板 | 词类型 = 缺口词 |
交叉分级:SSS(三表同词)> SS(两表同词)> S(单表)。SSS=0 时以 SS 级 为 PD 主攻。
---
Agent 处理前检查清单
- [ ] 三张原始文件命名符合
关键词调研/反查流量词/查广告词_{DATE}.xlsx - [ ] 表1 能打开且含合并 Sheet 或 高/中/低 三个 Sheet
- [ ] 表2/表3 第 2 行是英文/中文混合表头,不是纯元信息
- [ ] ASIN 与文件名、表内元信息一致
- [ ] 已询问产品阶段 + 精确类目(表3 必需)
结构异常 → 停止处理,对照 `manual-export-guide.md` 或 `browser-export-sop.md` 让用户重新导出。
门槛参数说明(标品 / 非标品)
不要改脚本里的数字。 用 --product-type 选预设,暂停点用 CLI 覆盖单项。快速用法
# 默认非标品(沙发套、定制类、小类目)
python process_table1.py --mode compute-thresholds --input "..." --product-type 非标品
# 标品(标准 SKU、竞争集中)
python process_table1.py --mode process ... --product-type 标品 --stage 新品期
# 暂停点① 用户要求放宽 S 级集中度
python process_table1.py --mode process ... --product-type 非标品 --s-conc-max 0.30
# 暂停点② 小类目缺口词太少
python process_table3.py ... --product-type 非标品 --sv-threshold 1000Agent 跑前简报应问:标品还是非标品? 写入 ingest_raw --product-type 和后续三条 process_*。
.sif-config.json 可设 "default_product_type": "非标品",Agent 未指定时采用。
---
预设对照
| 参数 | 标品 | 非标品 | CLI 覆盖 |
|---|---|---|---|
| S 级集中度上限 | 0.30 | 0.25 | --s-conc-max |
| A 级集中度上限 | 0.35 | 0.35 | --a-conc-max |
| S/A 搜索量门槛 | 自动分位数 97/85 | 同左 | --s-threshold / --a-threshold |
| B 级要求转化率 | 新品期放宽 | 新品期放宽 | (随 stage) |
| 表2 高机会集中度 | 0.45 | 0.40 | --t2-opp-conc-max |
| 表2 纯广告自然占比 | <20% | <20% | --t2-pure-ad-nat-max |
| 表3 缺口搜索量下限 | 3000 | 2000 | --sv-threshold(0=用预设) |
| 表3 缺口份额上限 | 5% | 5% | --gap-share-max |
| 表3 防守份额下限 | 20% | 20% | --defense-share-min |
---
S≥5946、A≥1114 是什么?为什么 S 级只有 2 个?
这两个数字不是预设里的固定值,而是每次跑表1时,对「高相关词」周搜索量做 97 / 85 分位数 算出来的(B08 6.12 数据示例:S=5946,A=1114)。
S 级还要同时满足(非标品预设):
1. 相关性 = 高相关 2. 周搜索量 ≥ S 门槛(5946) 3. 建议竞价 < 中位数 × 1.2 4. Top3 集中度 < 0.25(非标品比标品 0.30 更严)
因此:大词搜索量够,但集中度 ≥0.25 会被压到 A 级或带降级理由——S 仅 2 个通常是数据信号,不是脚本算错。暂停点①可试:
--s-conc-max 0.30(放宽 S 集中度)- 或
--product-type 标品(标品默认 S 集中度 0.30) - 若仍嫌 S 门槛过高:
--s-threshold 4000(手动覆盖分位数)
---
行业做法对照(公开资料,无统一「5946」标准)
| 维度 | 常见做法 | 本 Skills |
|---|---|---|
| 标品 | 用 Sif 默认相关性;竞品池大;新品期可收紧高相关 | s_conc_max=0.30,表3 sv_threshold=3000 |
| 非标品 | 自定义相关性、小竞品池(约 35–50 ASIN)、缺口搜索量按体量 2000–5000 | s_conc_max=0.25,表3 sv_threshold=2000 |
| 搜索量分层 | 各卖家按词库分位数或绝对值自定,无行业统一 S/A 数字 | 97/85 分位数自动化,可用 CLI 覆盖 |
| 缺口/防守 | 份额阈值因类目差异大 | 缺口 <5%、防守 >20%,暂停点②可调 |
参考:Sif 官方教程强调「高/中/低相关 + 搜索量/集中度/转化」组合筛选,而非固定搜索量门槛。
---
何时改什么
| 现象 | 建议 |
|---|---|
| 大词全在 A 级、S 级≤2 | --s-conc-max 0.30 或改 --product-type 标品 |
| B 级词极少 | 已是新品期放宽;可确认 --stage 新品期 |
| 高机会词过少 | --t2-opp-conc-max 0.5 |
| 缺口词 0~3 个 | --sv-threshold 1000 或 --gap-share-max 8 |
| 防守词永远 0 | 先查份额列是否为本店;再试 --defense-share-min 15 |
完整 Agent 引导见 amazon-expert-guide.md 暂停点 ①②。
__pycache__/
*.pyc
*.pyo
"""
batch_regenerate_word.py [开发/批跑专用,Agent 主流程不直接调用]
两种模式:
1. 默认:从已有 *_stats_*.json 生成 insights_*.md 并重建 Word(脚本改版后修复旧输出)
2. --full-pipeline:跑完三表 skip-word → insights → Word → history → Skill2(本地批跑测试)
用法 1:
python batch_regenerate_word.py \\
--result-dir ".../处理结果" --asin B0XXX --date 20260611 \\
--stage 新品期 --category 沙发套 --scripts-dir "sif-keyword-scout/scripts"
用法 2:
python batch_regenerate_word.py --full-pipeline \\
--output-root ".../test" --asin B0XXX --date 20260611 \\
--stage 新品期 --category 沙发套
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
from pathlib import Path
from report_utils import render_cross_reviews, render_t1_keyword_reviews, fmt_share_pct, is_missing
SCRIPTS = Path(__file__).resolve().parent
ENV = {**os.environ, "PYTHONIOENCODING": "utf-8"}
def load(p: Path) -> dict:
with open(p, encoding="utf-8") as f:
return json.load(f)
def write_t1(d: dict, path: Path, category: str):
lc = d.get("level_counts", {})
th = d.get("thresholds", {})
stage = d.get("stage", "")
conc_med = th.get("conc_median") or 0.3
reviews = render_t1_keyword_reviews(d.get("top_sa_keywords", []), stage, conc_med, 5)
text = f"""## 执行摘要
ASIN {d.get('asin')},{stage},类目{category}。共 {d.get('total_keywords', 0)} 个调研词。
分层:S级 {lc.get('S级', 0)} / A级 {lc.get('A级', 0)} / B级 {lc.get('B级', 0)} / C级 {lc.get('C级', 0)}。
自动门槛 S≥{th.get('s_search_volume')}、A≥{th.get('a_search_volume')};竞价中位 ${th.get('bid_median')},集中度中位 {conc_med}。
## S/A 级投放策略({stage})
- **S级 {lc.get('S级', 0)} / A级 {lc.get('A级', 0)}**:可投池有限,新品期优先验证转化,再加大词预算
- **与交叉词单对齐**:表1 大词不等于 PD 主攻;最终以 SS/SSS 交叉结果分配预算
- **C 级 {lc.get('C级', 0)} 个**:默认否定,搜索词报告出现有效转化再评估
## TOP5 重点词点评
{reviews}
## PD 备战前 2 周行动清单
- **第 1 周**:TOP5 中选 3~5 个与 listing 规格一致的词,按上表匹配方式建组
- **第 2 周**:暂停 ACOS>150% 的词;转化词迁入「PD 加码组」
- **PD 前 3 天**:交叉 SS/SSS 词单独活动,预算不低于 SP 总预算 30%
- **PD 当天**:主攻组 budget +30%~50%,每小时看库存与 ACOS
"""
path.write_text(text.strip() + "\n", encoding="utf-8")
def write_t2(d: dict, path: Path):
lines = []
# 字段名与 build_t2_stats 保持一致:top_opportunity_keywords、high_opp、total
for kw in d.get("top_opportunity_keywords", [])[:10]:
keyword = kw.get("keyword", "")
diff = kw.get("difficulty", "")
nat_st = kw.get("nat_status", "")
lines.append(
f"- **{keyword}**(难度 {diff},自然位状态 {nat_st}):"
f"竞品该词{'自然位未稳' if '未稳' in str(nat_st) or '纯广告' in str(nat_st) else '排名一般'},"
f"建议 PD 前 {'精准' if diff == '低' else '词组'}匹配试投,转化后加码。"
)
opp_block = "\n".join(lines) if lines else "- (无高机会词)"
total = d.get("total", d.get("total_keywords", 0))
text = f"""## 竞品流量结构弱点
共 {total} 个流量词;增长词 {d.get('growth_count', 0)} 个;高机会 {d.get('high_opp', 0)} 个。
纯广告/自然位未稳:{d.get('pure_ad_count', 0)}/{total}——竞品依赖 SP,广告抢位窗口大。
## TOP10 高机会词抢位策略
{opp_block}
## PD 前广告活动搭建方案
- **活动 A**:高机会+低难度 → 精准匹配,每词一组
- **活动 B**:高机会+中难度 → 词组试投
- **T-3**:转化词 bid +15~20%;无效词暂停
"""
path.write_text(text.strip() + "\n", encoding="utf-8")
def write_t3(d: dict, path: Path, category: str):
lines = []
for kw in d.get("top_gap_keywords", [])[:8]:
name = kw.get("keyword", "")
sv = kw.get("search_volume", "")
sp_raw = kw.get("sp_share_pct", kw.get("sp_share", ""))
sp = "" if is_missing(sp_raw) else fmt_share_pct(sp_raw)
sp_part = f",SP份额 {sp}" if sp else ""
lines.append(
f"- **{name}**(搜索量 {sv}{sp_part}):竞品广告覆盖不足,"
f"若与「{category}」listing 语义一致,PD 前精准匹配抢位;否则标记不相关否投。"
)
gap_block = "\n".join(lines) if lines else "- (无缺口词)"
text = f"""## 缺口词战略价值
类目「{category}」共 {d.get('total', d.get('total_keywords', 0))} 个广告词;缺口词 {d.get('gap_count', 0)} 个,低难度 {d.get('gap_low_difficulty', 0)} 个,防守词 {d.get('defense_count', 0)} 个。
{d.get('defense_note', '')}
## TOP 缺口词抢位建议
{gap_block}
## PD 备战行动清单
- **第 1 周**:语义匹配的 TOP 缺口词各开 1 精准组
- **第 2 周**:合并转化词,否掉偏离类目词
"""
path.write_text(text.strip() + "\n", encoding="utf-8")
def write_cross(d: dict, path: Path):
sss, ss, s = d.get("sss_count", 0), d.get("ss_count", 0), d.get("s_count", 0)
focus = "SSS+SS" if sss else "SS"
reviews = render_cross_reviews(d.get("primary_keywords", []), 10)
text = f"""## 执行摘要
三表交叉:SSS {sss} / SS {ss} / S {s}。PD 主攻以 **{focus} 级** 为核心({d.get('primary_focus_count', ss + sss)} 个词)。
## 交叉分级说明
- S 级 {s} 个为单表观察池,勿与表1 S/A 混淆
- 预算应集中在下列 {focus} 级词
## 主攻词逐条建议
{reviews}
## PD 备战行动清单
- **第 1 周**:{focus} 级词按上表匹配方式建组
- **第 2 周**:S 级仅观察,不加主预算
- **PD 当天**:主攻组提 budget 20%~50%
"""
path.write_text(text.strip() + "\n", encoding="utf-8")
def write_tracker(d: dict, path: Path) -> None:
s = d.get("summary", {})
note = d.get("compare_note", "")
ss_new = d.get("ss_new_list", [])[:10]
ss_removed = d.get("ss_removed_list", [])[:10]
sv_lines = [
f"- **{x['关键词']}**({x['级别']}):搜索量 {x['上次搜索量']}→{x['本次搜索量']}({x['变化%']}% {x['方向']})"
for x in d.get("sv_changed", [])[:8]
]
text = f"""## 执行摘要
对比窗口:{note}。上次词池 {s.get('prev_total', 0)} 个,本次 {s.get('curr_total', 0)} 个。
新增 {s.get('new_total', 0)} / 消失 {s.get('removed_total', 0)};SS 级新增 {s.get('ss_new', 0)}、稳定 {s.get('ss_stable', 0)}、掉出 {s.get('ss_removed', 0)}。
## 新增 SS 级词(建议评估是否加预算)
{chr(10).join('- **' + w + '**' for w in ss_new) if ss_new else '- (无新增 SS 级)'}
## 掉出 SS 级词(建议暂停或降价观察)
{chr(10).join('- **' + w + '**' for w in ss_removed) if ss_removed else '- (无掉出 SS 级)'}
## 搜索量显著变动(>20%)
{chr(10).join(sv_lines) if sv_lines else '- (无显著变动)'}
## 本周投放调整行动清单
- 对新增 SS 级词:有 listing 匹配则开精准组小预算试投
- 对掉出 SS/S 级词:暂停或降 bid 20%,观察 7 天
- 搜索量上涨词:转化稳定则 bid +10~15%
- 搜索量下跌词:先控 ACOS,无效则否词
"""
path.write_text(text.strip() + "\n", encoding="utf-8")
def _run_cmd(cmd: list[str], label: str) -> None:
print(f"\n{'='*60}\n▶ {label}\n{'='*60}")
r = subprocess.run(cmd, env=ENV, text=True, encoding="utf-8", errors="replace")
if r.returncode != 0:
raise SystemExit(f"FAILED: {label} (exit {r.returncode})")
def run_full_pipeline(output_root: str, asin: str, date: str, stage: str, category: str,
product_type: str = "非标品") -> None:
root = Path(output_root)
raw = root / asin / date / "原始数据"
res = root / asin / date / "处理结果"
res.mkdir(parents=True, exist_ok=True)
py = sys.executable
s = str(SCRIPTS)
for name in (f"关键词调研_{date}.xlsx", f"反查流量词_{date}.xlsx", f"查广告词_{date}.xlsx"):
if not (raw / name).is_file():
raise FileNotFoundError(f"缺少原始表:{raw / name}")
pt_args = ["--product-type", product_type, "--stage", stage]
_run_cmd([py, f"{s}/process_table1.py", "--mode", "process",
"--input", str(raw / f"关键词调研_{date}.xlsx"), "--output-dir", str(res),
"--asin", asin, "--date", date, *pt_args, "--skip-word"], f"{asin} 表1")
_run_cmd([py, f"{s}/process_table2.py", "--input", str(raw / f"反查流量词_{date}.xlsx"),
"--output-dir", str(res), "--asin", asin, "--date", date, *pt_args, "--skip-word"], f"{asin} 表2")
_run_cmd([py, f"{s}/process_table3.py", "--input", str(raw / f"查广告词_{date}.xlsx"),
"--output-dir", str(res), "--asin", asin, "--date", date,
"--product-category", category, *pt_args, "--skip-word"], f"{asin} 表3")
_run_cmd([py, f"{s}/cross_analysis.py",
"--t1", str(res / f"{asin}_关键词分层分析_{date}.xlsx"),
"--t2", str(res / f"{asin}_竞品弱点分析_{date}.xlsx"),
"--t3", str(res / f"{asin}_竞品缺口分析_{date}.xlsx"),
"--output-dir", str(res), "--asin", asin, "--date", date, "--skip-word"], f"{asin} 交叉")
main(output_root="", result_dir=str(res), asin=asin, date=date, stage=stage,
category=category, product_type=product_type, scripts_dir=s, only=set())
_run_cmd([py, f"{s}/update_history.py", "--output-dir", str(root),
"--asin", asin, "--date", date, "--run-path", f"{asin}/{date}"], f"{asin} 历史")
hist = subprocess.run(
[py, f"{s}/check_history.py", "--asin", asin, "--output-dir", str(root), "--curr-date", date],
capture_output=True, text=True, encoding="utf-8", errors="replace", env=ENV,
)
info = json.loads(hist.stdout)
if info.get("run_count", 0) >= 2 and info.get("compare_prev_run_path"):
tracker = SCRIPTS.parent.parent / "sif-keyword-tracker" / "scripts" / "compare_versions.py"
_run_cmd([py, str(tracker), "--auto", "--output-root", str(root), "--output-dir", str(res),
"--asin", asin, "--curr-date", date, "--skip-word"], f"{asin} Skill2 stats")
sp = res / f"{asin}_tracker_stats_{date}.json"
write_tracker(json.loads(sp.read_text(encoding="utf-8")), res / "insights_tracker.md")
_run_cmd([py, str(tracker), "--auto", "--output-root", str(root), "--output-dir", str(res),
"--asin", asin, "--curr-date", date,
"--insights-file", str(res / "insights_tracker.md")], f"{asin} Skill2 Word")
def run_word(script: Path, args: list[str]):
r = subprocess.run(
[sys.executable, str(script), *args],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
env=ENV,
)
if r.returncode != 0:
print(r.stderr or r.stdout, file=sys.stderr)
raise SystemExit(r.returncode)
if r.stdout:
print(r.stdout.splitlines()[-1])
def main(output_root="", result_dir="", asin="", date="", stage="新品期",
category="", product_type="非标品", scripts_dir="", only=None):
ap = argparse.ArgumentParser()
ap.add_argument("--full-pipeline", action="store_true", help="跑完整 Skill1+2 批处理")
ap.add_argument("--output-root", default="", help="--full-pipeline 时:output_dir 根目录")
ap.add_argument("--result-dir", default="")
ap.add_argument("--asin", required=False, default="")
ap.add_argument("--date", required=False, default="")
ap.add_argument("--stage", default="新品期")
ap.add_argument("--category", default="")
ap.add_argument("--product-type", default="非标品", choices=["标品", "非标品"])
ap.add_argument("--scripts-dir", default="")
ap.add_argument("--only", default="", help="t1,t2,t3,cross 逗号分隔,默认全部")
args = ap.parse_args() if not result_dir else argparse.Namespace(
full_pipeline=False, output_root=output_root, result_dir=result_dir,
asin=asin, date=date, stage=stage, category=category, product_type=product_type,
scripts_dir=scripts_dir or str(SCRIPTS), only=",".join(sorted(only or {"t1","t2","t3","cross"})),
)
if args.full_pipeline:
if not args.output_root or not args.asin or not args.date:
print("ERROR: --full-pipeline 需要 --output-root --asin --date", file=sys.stderr)
sys.exit(1)
run_full_pipeline(args.output_root, args.asin, args.date, args.stage,
args.category or "产品", args.product_type)
print(f"\n✅ {args.asin} 全流程完成")
return
if not args.result_dir or not args.asin or not args.date or not args.scripts_dir:
print("ERROR: 需要 --result-dir --asin --date --scripts-dir", file=sys.stderr)
sys.exit(1)
res = Path(args.result_dir)
scripts = Path(args.scripts_dir)
asin, date = args.asin, args.date
cat = args.category or "产品"
only = {x.strip() for x in args.only.split(",") if x.strip()} or {"t1", "t2", "t3", "cross"}
raw = res.parent / "原始数据"
if "t1" in only:
t1_path = res / f"{asin}_t1_stats_{date}.json"
pt = ["--product-type", args.product_type, "--stage", args.stage]
if not t1_path.exists():
run_word(scripts / "process_table1.py", [
"--mode", "process", "--input", str(raw / f"关键词调研_{date}.xlsx"),
"--output-dir", str(res), "--asin", asin, "--date", date,
*pt, "--skip-word",
])
t1 = load(t1_path)
write_t1({**t1, "stage": args.stage}, res / "insights_t1.md", cat)
run_word(scripts / "process_table1.py", [
"--mode", "process", "--input", str(raw / f"关键词调研_{date}.xlsx"),
"--output-dir", str(res), "--asin", asin, "--date", date,
*pt, "--insights-file", str(res / "insights_t1.md"),
])
if "t2" in only and (res / f"{asin}_t2_stats_{date}.json").exists():
write_t2(load(res / f"{asin}_t2_stats_{date}.json"), res / "insights_t2.md")
run_word(scripts / "process_table2.py", [
"--input", str(raw / f"反查流量词_{date}.xlsx"),
"--output-dir", str(res), "--asin", asin, "--date", date,
"--product-type", args.product_type, "--stage", args.stage,
"--insights-file", str(res / "insights_t2.md"),
])
if "t3" in only and (res / f"{asin}_t3_stats_{date}.json").exists():
write_t3(load(res / f"{asin}_t3_stats_{date}.json"), res / "insights_t3.md", cat)
run_word(scripts / "process_table3.py", [
"--input", str(raw / f"查广告词_{date}.xlsx"),
"--output-dir", str(res), "--asin", asin, "--date", date,
"--product-category", cat,
"--product-type", args.product_type, "--stage", args.stage,
"--insights-file", str(res / "insights_t3.md"),
])
if "cross" in only and (res / f"{asin}_cross_stats_{date}.json").exists():
write_cross(load(res / f"{asin}_cross_stats_{date}.json"), res / "insights_cross.md")
run_word(scripts / "cross_analysis.py", [
"--t1", str(res / f"{asin}_关键词分层分析_{date}.xlsx"),
"--t2", str(res / f"{asin}_竞品弱点分析_{date}.xlsx"),
"--t3", str(res / f"{asin}_竞品缺口分析_{date}.xlsx"),
"--output-dir", str(res), "--asin", asin, "--date", date,
"--insights-file", str(res / "insights_cross.md"),
])
print(f"Done: {res} ({','.join(sorted(only))})")
if __name__ == "__main__":
main() # noqa: 供 CLI 与 run_full_pipeline 内部调用
"""
check_history.py - 读取运行日志,并按 1~7 天窗口选取对比基准
"""
import argparse
import json
import os
import sys
from datetime import datetime
try:
import openpyxl
HAS_OPENPYXL = True
except ImportError:
HAS_OPENPYXL = False
from run_context import parse_run_path, result_dir_from_run_path, datetime_display
def _split_paths(raw: str) -> list:
return [p.strip() for p in str(raw or "").replace(";", ";").split(";") if p.strip()]
def _run_datetime(info: dict) -> datetime:
date = info.get("date", "")
time = (info.get("time") or "0000").zfill(4)
if not date or len(date) != 8:
return datetime.min
try:
return datetime.strptime(f"{date}{time}", "%Y%m%d%H%M")
except ValueError:
return datetime.strptime(date, "%Y%m%d")
def _load_runs_from_log(ws, asin: str, output_dir: str) -> list:
runs = []
for row in ws.iter_rows(min_row=2, values_only=True):
if not row or row[0] != asin:
continue
run_time = str(row[1] or "")
data_date = str(row[2] or "")
path = _split_paths(str(row[3] or ""))[0] if row[3] else ""
if not path:
continue
info = parse_run_path(path)
if not info.get("date"):
info["date"] = data_date
info["path"] = path
info["run_time"] = run_time
info["datetime_display"] = run_time or info.get("datetime_display", "")
info["result_dir"] = result_dir_from_run_path(output_dir, path)
info["_dt"] = _run_datetime(info)
runs.append(info)
runs.sort(key=lambda r: r["_dt"])
return runs
def _load_runs_legacy(ws, asin: str, output_dir: str) -> list:
headers = [cell.value for cell in ws[1]]
asin_col = next((i for i, h in enumerate(headers) if h and "ASIN" in str(h).upper()), None)
if asin_col is None:
return []
for row in ws.iter_rows(min_row=2, values_only=True):
if row[asin_col] != asin:
continue
last_snap = str(row[4]) if len(row) > 4 and row[4] else ""
hist_raw = str(row[5]) if len(row) > 5 and row[5] else last_snap
path_list = _split_paths(hist_raw)
if last_snap and last_snap not in path_list:
path_list.append(last_snap)
runs = []
for p in path_list:
info = parse_run_path(p)
info["path"] = p
info["result_dir"] = result_dir_from_run_path(output_dir, p)
info["run_time"] = info.get("datetime_display", "")
info["_dt"] = _run_datetime(info)
runs.append(info)
runs.sort(key=lambda r: r["_dt"])
return runs
return []
def _parse_data_date(date_str: str):
if not date_str or len(date_str) != 8:
return None
try:
return datetime.strptime(date_str, "%Y%m%d").date()
except ValueError:
return None
def _fill_compare_out(out: dict, prev: dict, days: int, in_window: bool, note: str) -> dict:
out.update({
"compare_prev_run_path": prev["path"],
"compare_prev_date": prev.get("date", ""),
"compare_prev_datetime": prev.get("run_time") or prev.get("datetime_display", ""),
"compare_prev_result_dir": prev.get("result_dir", ""),
"compare_days_apart": days,
"compare_in_window": in_window,
"compare_note": note,
})
return out
def find_compare_baseline(runs: list, curr_date: str = "", min_days: int = 1, max_days: int = 7) -> dict:
"""
词库对比按「原数据日期」选基准,不是按同日重复跑库时间。
优先级:
1. 与本次数据日期不同、且在 1~7 天窗口内的最近一次数据日(如 0610 → 0611)
2. 仅当没有可比的异日数据时,才对比同日前一次运行(同日重跑)
3. 回退到紧邻上一次运行(可能超出窗口,报告会标注 warning)
"""
out = {
"compare_prev_run_path": "",
"compare_prev_date": "",
"compare_prev_datetime": "",
"compare_prev_result_dir": "",
"compare_days_apart": None,
"compare_in_window": False,
"compare_note": "",
}
if len(runs) < 2:
out["compare_note"] = "仅一次运行,无法对比"
return out
if curr_date:
curr_candidates = [r for r in runs if r.get("date") == curr_date]
curr = curr_candidates[-1] if curr_candidates else runs[-1]
else:
curr = runs[-1]
curr_dt = curr["_dt"]
curr_data_date = curr.get("date", "")
curr_dd = _parse_data_date(curr_data_date)
priors = [r for r in runs if r["_dt"] < curr_dt]
if not priors:
out["compare_note"] = "当前为最早一次运行"
return out
# 1) 异日数据:按原数据日期差在 1~7 天窗口内选最近一期
if curr_dd:
best_by_date: dict[str, dict] = {}
for r in priors:
prev_date = r.get("date", "")
if not prev_date or prev_date == curr_data_date:
continue
prev_dd = _parse_data_date(prev_date)
if not prev_dd:
continue
days = (curr_dd - prev_dd).days
if min_days <= days <= max_days:
slot = best_by_date.get(prev_date)
if slot is None or r["_dt"] > slot["run"]["_dt"]:
best_by_date[prev_date] = {"days": days, "run": r}
if best_by_date:
prev_date = max(best_by_date.keys())
slot = best_by_date[prev_date]
prev = slot["run"]
days = slot["days"]
return _fill_compare_out(
out, prev, days, True,
f"对比 {days} 天前数据({prev_date} → {curr_data_date},原数据日期)",
)
# 2) 同日前一次(仅异日不可比时:同日重跑同一批数据)
same_day = [r for r in priors if r.get("date") == curr_data_date]
if same_day:
prev = same_day[-1]
return _fill_compare_out(
out, prev, 0, True,
f"对比同日前一次运行(数据日期均为 {curr_data_date})",
)
# 3) 回退:紧邻上一次(按数据日期计间隔)
prev = priors[-1]
prev_dd = _parse_data_date(prev.get("date", ""))
if curr_dd and prev_dd:
days = (curr_dd - prev_dd).days
else:
days = (curr_dt.date() - prev["_dt"].date()).days
in_window = min_days <= days <= max_days if days is not None else False
note = (
f"对比 {days} 天前数据({prev.get('date', '')} → {curr_data_date},超出 {max_days} 天窗口)"
if not in_window
else f"对比 {days} 天前数据({prev.get('date', '')} → {curr_data_date})"
)
return _fill_compare_out(out, prev, days, in_window, note)
def check_history(asin: str, output_dir: str, curr_date: str = "", compare_window: int = 7) -> dict:
history_file = os.path.join(output_dir, "ASIN历史记录.xlsx")
result = {
"has_history": False,
"run_count": 0,
"last_date": "",
"last_time": "",
"last_datetime": "",
"last_run_path": "",
"last_result_dir": "",
"prev_run_path": "",
"prev_path": "",
"prev_datetime": "",
"all_runs": [],
"compare_window_days": compare_window,
}
if not os.path.exists(history_file) or not HAS_OPENPYXL:
return result
try:
wb = openpyxl.load_workbook(history_file)
if "运行日志" in wb.sheetnames:
runs = _load_runs_from_log(wb["运行日志"], asin, output_dir)
else:
runs = _load_runs_legacy(wb.active, asin, output_dir)
if not runs:
wb.close()
return result
latest = runs[-1]
prev = runs[-2] if len(runs) >= 2 else None
compare = find_compare_baseline(runs, curr_date=curr_date, max_days=compare_window)
result["has_history"] = len(runs) >= 2 or bool(prev)
result["run_count"] = len(runs)
result["last_date"] = latest.get("date", "")
result["last_time"] = latest.get("time", "")
result["last_datetime"] = latest.get("run_time") or latest.get("datetime_display", "")
result["last_run_path"] = latest.get("path", "")
result["last_result_dir"] = latest.get("result_dir", "")
result["all_runs"] = [{k: v for k, v in r.items() if k != "_dt"} for r in runs]
if prev:
result["prev_run_path"] = prev.get("path", "")
result["prev_path"] = prev.get("result_dir", "")
result["prev_datetime"] = prev.get("run_time") or prev.get("datetime_display", "")
result.update(compare)
wb.close()
except Exception as e:
result["error"] = str(e)
return result
def pd_excel_path(result_dir: str, asin: str, date: str) -> str:
return os.path.join(result_dir, f"{asin}_PD主攻词单_{date}.xlsx")
def main():
parser = argparse.ArgumentParser(description="检查 ASIN 历史记录")
parser.add_argument("--asin", required=True)
parser.add_argument("--output-dir", required=True)
parser.add_argument("--curr-date", default="", help="本次数据日期 YYYYMMDD")
parser.add_argument("--compare-window", type=int, default=7)
args = parser.parse_args()
os.makedirs(args.output_dir, exist_ok=True)
result = check_history(args.asin, args.output_dir, curr_date=args.curr_date,
compare_window=args.compare_window)
print(json.dumps(result, ensure_ascii=False, indent=2))
if result.get("all_runs"):
print(
f"\n[历史] {args.asin} 共 {result['run_count']} 次 | "
f"最近 {result['last_datetime']} | {result['last_run_path']}",
file=sys.stderr,
)
if result.get("compare_prev_run_path"):
print(
f"[对比基准] {result.get('compare_note')} | "
f"{result.get('compare_prev_datetime')} | {result.get('compare_prev_run_path')}",
file=sys.stderr,
)
if __name__ == "__main__":
main()
"""
create_mock_sif_exports.py [开发/测试专用,不在主流程中调用]
生成符合 SOP 结构的测试用三张原始表(无真实 Sif 文件时用于链路验证)
用法:
python create_mock_sif_exports.py --output-dir "其他/test/_mock_raw" --date 20260611
"""
import stdio_utf8 # noqa: F401
import argparse
import os
import pandas as pd
from openpyxl import Workbook
def write_table1(path: str):
wb = Workbook()
for sheet in ["高相关", "中相关", "低相关"]:
ws = wb.create_sheet(sheet)
ws.append([]) # 第1行空行
ws.append(["关键词", "中文翻译", "相关性", "相关性得分", "周搜索量", "ABA排名",
"24小时转化率", "", "建议竞价中值", "", "Top3集中度"])
if sheet == "高相关":
rows = [
["stanley cup", "斯坦利杯", "高", 95, 360806, 1200, 0.12, "", 1.25, "", 0.22],
["stanley tumbler", "斯坦利保温杯", "高", 90, 11914, 3500, 0.08, "", 0.95, "", 0.28],
["tumbler with handle", "带把手保温杯", "高", 85, 8500, 8000, 0.06, "", 0.88, "", 0.31],
["40 oz tumbler", "40盎司杯", "高", 82, 6200, 12000, 0.05, "", 0.75, "", 0.27],
["insulated tumbler", "保温杯", "高", 78, 4100, 18000, 0.04, "", 0.70, "", 0.33],
["coffee tumbler", "咖啡杯", "高", 75, 2800, 25000, 0.03, "", 0.65, "", 0.35],
]
elif sheet == "中相关":
rows = [
["water bottle", "水瓶", "中", 55, 1500, 45000, 0.02, "", 0.55, "", 0.38],
["travel mug", "旅行杯", "中", 50, 900, 60000, 0.01, "", 0.50, "", 0.42],
]
else:
rows = [
["coach cover", "教练罩", "低", 10, 120, 500000, "", "", 0.30, "", 0.55],
]
for r in rows:
ws.append(r)
if "Sheet" in wb.sheetnames:
del wb["Sheet"]
wb.save(path)
def write_table2(path: str):
wb = Workbook()
ws = wb.active
ws.title = "反查流量词"
ws.append([])
headers = ["", "关键词", "", "关键词效果类型", "全部流量占比", "", "", "自然流量占比",
"", "", "", "广告流量占比"]
# 补齐到66列
while len(headers) < 66:
headers.append("")
headers[43] = "周ABA排名"
headers[65] = "ABA TOP3集中度"
ws.append(headers)
rows = [
["", "stanley cup", "", "[搜索量同比增长]", 0.17, "", "", 0.08, "", "", "", 0.09, *[""]*31, 2561791, *[""]*21, 0.32],
["", "stanley tumbler", "", "[搜索量同比增长]", 0.12, "", "", 0.05, "", "", "", 0.07, *[""]*31, 3200000, *[""]*21, 0.28],
["", "tumbler with handle", "", "[搜索量同比增长]", 0.09, "", "", 0.03, "", "", "", 0.06, *[""]*31, 4100000, *[""]*21, 0.35],
["", "40 oz tumbler", "", "", 0.06, "", "", 0.15, "", "", "", 0.04, *[""]*31, 5000000, *[""]*21, 0.30],
["", "insulated tumbler", "", "", 0.04, "", "", 0.12, "", "", "", 0.03, *[""]*31, 6000000, *[""]*21, 0.38],
]
for r in rows:
ws.append(r)
wb.save(path)
def write_table3(path: str):
wb = Workbook()
ws = wb.active
ws.title = "查广告词"
ws.append([])
ws.append(["", "广告搜索词", "翻译", "SP广告流量贡献占比", "竞品在该词的SP广告流量份额",
"", "", "", "", "", "搜索量"])
rows = [
["", "stanley cup", "斯坦利杯", "37.27%", "3.21%", "", "", "", "", "", 360806],
["", "stanley tumbler", "斯坦利保温杯", "22.10%", "4.50%", "", "", "", "", "", 11914],
["", "tumbler with handle", "带把手保温杯", "8.50%", "2.80%", "", "", "", "", "", 8500],
["", "40 oz tumbler", "40盎司杯", "15.00%", "18.00%", "", "", "", "", "", 6200],
["", "water bottle", "水瓶", "5.00%", "1.20%", "", "", "", "", "", 1500],
["", "coach cover", "教练罩", "1.00%", "0.50%", "", "", "", "", "", 120],
]
for r in rows:
ws.append(r)
wb.save(path)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--output-dir", required=True)
parser.add_argument("--date", required=True)
args = parser.parse_args()
os.makedirs(args.output_dir, exist_ok=True)
t1 = os.path.join(args.output_dir, f"关键词调研_{args.date}.xlsx")
t2 = os.path.join(args.output_dir, f"反查流量词_{args.date}.xlsx")
t3 = os.path.join(args.output_dir, f"查广告词_{args.date}.xlsx")
write_table1(t1)
write_table2(t2)
write_table3(t3)
print(f"✅ 已生成测试原始表:\n {t1}\n {t2}\n {t3}")
if __name__ == "__main__":
main()
"""
cross_analysis.py - 三表交叉分析,生成PD主攻词单
Usage:
python cross_analysis.py \
--t1 "关键词分层分析_20260611.xlsx" \
--t2 "竞品弱点分析_20260611.xlsx" \
--t3 "竞品缺口分析_20260611.xlsx" \
--output-dir "处理结果/" \
--asin B0CRMP3RQT \
--date 20260611
"""
import argparse
import os
import sys
import warnings
warnings.filterwarnings("ignore")
from report_utils import (
chart_path, get_col, pct_to_float, stats_path, save_stats, load_insights, cleanup_intermediate_files,
setup_doc_styles, add_para, apply_font_to_table, set_cell_text, finalize_doc_fonts,
render_insights_section, add_doc_title, add_picture_centered, add_charts_section, insights_path,
is_missing, fmt_share_pct, fmt_cell,
)
from run_context import run_id as make_run_id
try:
import pandas as pd
import numpy as np
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.chart import BarChart, Reference, Series
except ImportError as e:
print(f"缺少依赖:{e}\n请运行:pip install pandas openpyxl numpy", file=sys.stderr)
sys.exit(1)
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
plt.rcParams["font.family"] = ["Microsoft YaHei", "SimHei", "DejaVu Sans"]
plt.rcParams["axes.unicode_minus"] = False
HAS_MPL = True
except ImportError:
HAS_MPL = False
try:
from docx import Document
HAS_DOCX = True
except ImportError:
HAS_DOCX = False
THIN = Side(style="thin")
THIN_BORDER = Border(left=THIN, right=THIN, top=THIN, bottom=THIN)
def normalize_kw(kw: str) -> str:
return str(kw).lower().strip()
# ──────── 从三张处理后的Excel读取关键词集合 ────────
def _read_sheet_with_header(path: str, sheet: str) -> pd.DataFrame:
"""自动检测表头行(兼容旧版 header=1 与新版 header=0)。
覆盖三张表的特征列:T1(词级别/关键词)、T2(是否增长词/抢位机会评级)、T3(广告搜索词/词类型)
"""
DETECT_COLS = [
"词级别", "Level", "关键词", "Keyword",
"抢位机会评级", "词库角色", "是否增长词",
"广告搜索词", "词类型", "相关性判断",
]
for header in (0, 1):
df = pd.read_excel(path, sheet_name=sheet, header=header)
if get_col(df, DETECT_COLS):
return df
return pd.read_excel(path, sheet_name=sheet, header=1)
def _read_main_sheet(path: str, skip_sheets: set) -> pd.DataFrame:
"""从处理后的 Excel 读取主数据 Sheet(跳过看板/汇总类 Sheet)。"""
xl = pd.ExcelFile(path)
for sheet in xl.sheet_names:
if sheet in skip_sheets:
continue
df = _read_sheet_with_header(path, sheet)
if len(df) == 0:
continue
return df
return _read_sheet_with_header(path, xl.sheet_names[0])
def load_t1_sa(path: str):
"""读取表1处理结果,提取S级+A级词(从含辅助列的主数据 Sheet)。
返回 (dataframe, col_kw, col_bid, col_conc, col_search)
"""
xl = pd.ExcelFile(path)
skip = {"分层汇总"}
dfs = []
for sheet in xl.sheet_names:
if sheet in skip:
continue
df = _read_sheet_with_header(path, sheet)
if get_col(df, ["词级别", "Level"]):
dfs.append(df)
if not dfs:
df = _read_main_sheet(path, skip)
dfs = [df]
df = pd.concat(dfs, ignore_index=True)
col_level = get_col(df, ["词级别", "Level"])
col_kw = get_col(df, ["关键词", "Keyword"])
col_bid = get_col(df, ["建议竞价(中)", "建议竞价中值", "竞价中值", "Bid"])
col_conc = get_col(df, ["Top3点击集中度", "Top3转化集中度", "Top3集中度", "集中度", "Concentration"])
col_search = get_col(df, ["周搜索量", "搜索量", "Search Volume"])
if not col_level or not col_kw:
raise ValueError(f"表1 {path} 找不到词级别或关键词列")
sa = df[df[col_level].isin(["S级", "A级"])].copy()
sa["__src"] = "T1"
sa["__kw_norm"] = sa[col_kw].apply(normalize_kw)
return sa, col_kw, col_bid, col_conc, col_search
def load_t2_high(path: str) -> pd.DataFrame:
"""读取表2处理结果,从主数据 Sheet 提取高机会词。"""
df = _read_main_sheet(path, {"竞品弱点看板"})
col_kw = get_col(df, ["关键词", "Keyword"])
col_rating = get_col(df, ["抢位机会评级", "评级", "Rating"])
if col_rating and col_kw:
high = df[df[col_rating].astype(str).str.strip() == "高"].copy()
elif col_kw:
high = df.copy()
else:
return pd.DataFrame(), None
high["__src"] = "T2"
high["__kw_norm"] = high[col_kw].apply(normalize_kw)
return high, col_kw
def load_t3_gap(path: str) -> pd.DataFrame:
"""读取表3处理结果,从主数据 Sheet 提取缺口词。"""
df = _read_main_sheet(path, {"竞品缺口看板"})
col_kw = get_col(df, ["广告搜索词", "关键词", "Keyword"])
col_type = get_col(df, ["词类型", "Type"])
if col_type and col_kw:
gap = df[df[col_type].astype(str).str.strip() == "缺口词"].copy()
elif col_kw:
gap = df.copy()
else:
return pd.DataFrame(), None
gap["__src"] = "T3"
gap["__kw_norm"] = gap[col_kw].apply(normalize_kw)
return gap, col_kw
# ──────── 交叉分析核心 ────────
def cross_analyze(t1_df, t1_kw, t2_df, t2_kw, t3_df, t3_kw,
t1_col_bid=None, t1_col_conc=None, t1_col_search=None):
set1 = set(t1_df["__kw_norm"].dropna()) if len(t1_df) > 0 else set()
set2 = set(t2_df["__kw_norm"].dropna()) if len(t2_df) > 0 else set()
set3 = set(t3_df["__kw_norm"].dropna()) if len(t3_df) > 0 else set()
sss_set = set1 & set2 & set3
ss_12 = (set1 & set2) - sss_set
ss_13 = (set1 & set3) - sss_set
ss_23 = (set2 & set3) - sss_set
ss_set = ss_12 | ss_13 | ss_23
s_set = (set1 | set2 | set3) - sss_set - ss_set
print(f"\n📊 三表交叉结果:")
print(f" 表1(S+A级):{len(set1)}词 表2(高机会):{len(set2)}词 表3(缺口词):{len(set3)}词")
print(f" SSS级(三表同时出现):{len(sss_set)}词")
print(f" SS级(任意两表出现):{len(ss_set)}词")
print(f" S级(仅单表出现):{len(s_set)}词")
if len(sss_set) == 0:
print(" ⚠️ SSS级词为0,以SS级作为最终主攻词")
# 反查每个词的详细数据
t1_index = t1_df.set_index("__kw_norm") if len(t1_df) > 0 else pd.DataFrame()
t2_index = t2_df.set_index("__kw_norm") if len(t2_df) > 0 else pd.DataFrame()
t3_index = t3_df.set_index("__kw_norm") if len(t3_df) > 0 else pd.DataFrame()
def get_word_row(kw, index_df, kw_col):
if len(index_df) == 0 or kw_col is None:
return {}
try:
rows = index_df.loc[[kw]] if kw in index_df.index else pd.DataFrame()
if len(rows) == 0:
return {}
return rows.iloc[0].to_dict()
except Exception:
return {}
def build_record(kw_norm, grade, sources):
r1 = get_word_row(kw_norm, t1_index, t1_kw)
r2 = get_word_row(kw_norm, t2_index, t2_kw)
r3 = get_word_row(kw_norm, t3_index, t3_kw)
# 原始关键词(取第一个有值的)
orig_kw = (r1.get(t1_kw) or r2.get(t2_kw) or r3.get(t3_kw) or kw_norm) if (t1_kw or t2_kw or t3_kw) else kw_norm
# 搜索量:优先用 t1 检测到的实际列名,再fallback
sv = None
sv_candidates = [t1_col_search] if t1_col_search else []
sv_candidates += ["周搜索量", "搜索量", "Search Volume"]
for c in sv_candidates:
if c and c in r1 and pd.notna(r1[c]):
try: sv = int(float(r1[c])); break
except: pass
if sv is None:
for c in ["搜索量", "周搜索量"]:
if c in r3 and pd.notna(r3[c]):
try: sv = int(float(r3[c])); break
except: pass
# 竞价中值:优先用 t1 检测到的实际列名
bid = None
bid_candidates = [t1_col_bid] if t1_col_bid else []
bid_candidates += ["建议竞价(中)", "建议竞价中值", "竞价中值", "Bid"]
for c in bid_candidates:
if c and c in r1 and pd.notna(r1[c]):
try: bid = round(float(r1[c]), 2); break
except: pass
# 集中度:优先用 t1 检测到的实际列名
conc = None
conc_candidates = [t1_col_conc] if t1_col_conc else []
conc_candidates += ["Top3点击集中度", "Top3转化集中度", "Top3集中度", "集中度", "Concentration"]
for c in conc_candidates:
if c and c in r1 and pd.notna(r1[c]):
try: conc = round(float(r1[c]), 3); break
except: pass
# 竞品自然位状态
nat_status = r2.get("竞品自然位状态", "") or ""
# 竞品SP份额(__sh_num 不写Excel,改为从原始列名模糊匹配)
sp_share = None
for c in list(r3.keys()):
if ("SP" in str(c) and "份额" in str(c)) or c == "__sh_num":
try:
raw = r3[c]
if not is_missing(raw):
sp_share = round(pct_to_float(raw), 2)
break
except Exception:
pass
# 词级别(来自T1)
word_level = r1.get("词级别", "") or ""
# 匹配建议
match_map = {"SSS级": "精准匹配(预算优先)", "SS级": "词组匹配+精准测试", "S级": "词组或广泛匹配"}
match_suggest = match_map.get(grade, "")
# PD加码优先级
pd_priority = {"SSS级": "极高", "SS级": "高", "S级": "中"}.get(grade, "")
# 综合操作建议
op_map = {
"SSS级": "PD前单独开精准匹配广告活动,预算优先倾斜",
"SS级": "词组匹配主打,精准匹配测试",
"S级": "词组或广泛匹配跟进,控制预算观察",
}
op = op_map.get(grade, "")
return {
"词级别": grade,
"关键词": orig_kw,
"出现来源": "+".join(sources),
"周搜索量": sv,
"建议竞价中值": bid,
"Top3集中度": conc,
"T1词级别": word_level,
"竞品自然位状态": nat_status,
"竞品SP份额%": sp_share,
"匹配建议": match_suggest,
"PD加码优先级": pd_priority,
"综合操作建议": op,
}
records = []
for kw in sss_set:
records.append(build_record(kw, "SSS级", ["表1", "表2", "表3"]))
for kw in ss_12:
records.append(build_record(kw, "SS级", ["表1", "表2"]))
for kw in ss_13:
records.append(build_record(kw, "SS级", ["表1", "表3"]))
for kw in ss_23:
records.append(build_record(kw, "SS级", ["表2", "表3"]))
for kw in s_set:
src = []
if kw in set1: src.append("表1")
if kw in set2: src.append("表2")
if kw in set3: src.append("表3")
records.append(build_record(kw, "S级", src))
result_df = pd.DataFrame(records)
# 按级别排序(SSS > SS > S),再按搜索量排序
grade_order = {"SSS级": 0, "SS级": 1, "S级": 2}
result_df["__grade_order"] = result_df["词级别"].map(grade_order)
result_df["__sv_sort"] = pd.to_numeric(result_df["周搜索量"], errors="coerce").fillna(0)
result_df = result_df.sort_values(["__grade_order", "__sv_sort"], ascending=[True, False])
result_df = result_df.drop(columns=["__grade_order", "__sv_sort"])
overlap_meta = {
"set1_count": len(set1),
"set2_count": len(set2),
"set3_count": len(set3),
"ss_12_count": len(ss_12),
"ss_13_count": len(ss_13),
"ss_23_count": len(ss_23),
"union_count": len(set1 | set2 | set3),
"sss_keywords": [build_record(k, "SSS级", ["表1", "表2", "表3"])["关键词"] for k in list(sss_set)[:5]],
"ss_12_samples": [build_record(k, "SS级", ["表1", "表2"])["关键词"] for k in list(ss_12)[:5]],
"ss_13_samples": [build_record(k, "SS级", ["表1", "表3"])["关键词"] for k in list(ss_13)[:5]],
"ss_23_samples": [build_record(k, "SS级", ["表2", "表3"])["关键词"] for k in list(ss_23)[:5]],
"s_only_t1_count": len(set1 - set2 - set3),
"s_only_t2_count": len(set2 - set1 - set3),
"s_only_t3_count": len(set3 - set1 - set2),
"naming_note": "交叉S级=仅出现在一张筛选表,与表1 S级不同;主攻预算应聚焦SSS+SS",
}
return result_df, len(sss_set), len(ss_set), len(s_set), overlap_meta
# ──────── 生成PD主攻词单Excel ────────
def build_output_excel(result_df, asin, date, output_dir):
out_path = os.path.join(output_dir, f"{asin}_PD主攻词单_{date}.xlsx")
wb = openpyxl.Workbook()
title_font = Font(bold=True, name="Microsoft YaHei", size=13)
head_fill = PatternFill(start_color="1F4E79", end_color="1F4E79", fill_type="solid")
head_font = Font(color="FFFFFF", bold=True, name="Microsoft YaHei", size=10)
grade_fills = {
"SSS级": PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid"),
"SS级": PatternFill(start_color="FFEB9C", end_color="FFEB9C", fill_type="solid"),
"S级": PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid"),
}
output_cols = ["词级别", "关键词", "出现来源", "周搜索量", "建议竞价中值",
"Top3集中度", "竞品自然位状态", "竞品SP份额%", "匹配建议", "PD加码优先级", "综合操作建议"]
col_widths = [10, 25, 14, 12, 14, 12, 16, 14, 20, 14, 36]
# Sheet 1: 按级别分开
for grade in ["SSS级", "SS级", "S级"]:
sub = result_df[result_df["词级别"] == grade]
ws = wb.create_sheet(f"{grade}词({'三表交叉' if grade=='SSS级' else '两表交叉' if grade=='SS级' else '单表出现'})")
ws.sheet_view.showGridLines = False
# 表头
for ci, (col, w) in enumerate(zip(output_cols, col_widths), 1):
c = ws.cell(1, ci, col)
c.fill = head_fill; c.font = head_font; c.border = THIN_BORDER
c.alignment = Alignment(horizontal="center")
ws.column_dimensions[openpyxl.utils.get_column_letter(ci)].width = w
# 数据
for ri, (_, row) in enumerate(sub.iterrows(), 2):
fill = grade_fills.get(grade) if ri % 2 == 0 else None
for ci, col in enumerate(output_cols, 1):
val = row.get(col, "")
if col == "竞品SP份额%":
display = fmt_share_pct(val)
elif pd.notna(val):
display = val
else:
display = ""
c = ws.cell(ri, ci, display)
c.border = THIN_BORDER
c.font = Font(name="Microsoft YaHei", size=9)
c.alignment = Alignment(wrap_text=(col == "综合操作建议"))
if fill: c.fill = fill
# Sheet 4: 词级统计(带图表)
ws_stats = wb.create_sheet("词级统计")
ws_stats.sheet_view.showGridLines = False
ws_stats.cell(1, 1, "📊 PD主攻词单统计汇总").font = title_font
stats_data = []
total_all = len(result_df)
for grade in ["SSS级", "SS级", "S级"]:
cnt = (result_df["词级别"] == grade).sum()
sub = result_df[result_df["词级别"] == grade]
avg_sv = pd.to_numeric(sub["周搜索量"], errors="coerce").mean()
stats_data.append((grade, cnt, f"{cnt/total_all*100:.1f}%" if total_all else "0%",
f"{avg_sv:.0f}" if pd.notna(avg_sv) else "N/A"))
stat_headers = ["词级别", "词数量", "占比", "平均搜索量"]
for ci, h in enumerate(stat_headers, 1):
c = ws_stats.cell(3, ci, h); c.fill = head_fill; c.font = head_font; c.border = THIN_BORDER
for ri, (grade, cnt, pct, avg) in enumerate(stats_data, 4):
ws_stats.cell(ri, 1, grade).border = THIN_BORDER
ws_stats.cell(ri, 2, cnt).border = THIN_BORDER
ws_stats.cell(ri, 3, pct).border = THIN_BORDER
ws_stats.cell(ri, 4, avg).border = THIN_BORDER
gfill = grade_fills.get(grade)
if gfill:
for ci in range(1, 5):
ws_stats.cell(ri, ci).fill = gfill
# 嵌入柱状图
chart_data_row = 10
for i, (grade, cnt, _, _) in enumerate(stats_data):
ws_stats.cell(chart_data_row + i, 8, grade)
ws_stats.cell(chart_data_row + i, 9, cnt)
bc = BarChart()
bc.type = "col"; bc.title = "SSS/SS/S级词数量分布"; bc.y_axis.title = "词数量"
bc.width = 14; bc.height = 9
dr = Reference(ws_stats, min_col=9, min_row=chart_data_row, max_row=chart_data_row + 2)
cr = Reference(ws_stats, min_col=8, min_row=chart_data_row, max_row=chart_data_row + 2)
s1 = Series(dr, title="词数量"); bc.series.append(s1); bc.set_categories(cr)
ws_stats.add_chart(bc, "A7")
# 图二:SSS+SS级词搜索量 TOP10
top_ss = result_df[result_df["词级别"].isin(["SSS级", "SS级"])].copy()
top_ss["__sv"] = pd.to_numeric(top_ss["周搜索量"], errors="coerce").fillna(0)
top_ss = top_ss.sort_values("__sv", ascending=False).head(10)
sv_row = 14
for i, (_, r) in enumerate(top_ss.iterrows()):
kw = str(r.get("关键词", ""))[:8]
ws_stats.cell(sv_row + i, 8, kw)
ws_stats.cell(sv_row + i, 9, int(r.get("__sv", 0)))
if len(top_ss) > 0:
bc2 = BarChart()
bc2.type = "col"
bc2.title = "SSS+SS级词搜索量 TOP10"
bc2.y_axis.title = "周搜索量"
bc2.width = 14
bc2.height = 9
dr2 = Reference(ws_stats, min_col=9, min_row=sv_row, max_row=sv_row + len(top_ss) - 1)
cr2 = Reference(ws_stats, min_col=8, min_row=sv_row, max_row=sv_row + len(top_ss) - 1)
bc2.series.append(Series(dr2, title="搜索量"))
bc2.set_categories(cr2)
ws_stats.add_chart(bc2, "J7")
# 删除默认空Sheet
if "Sheet" in wb.sheetnames:
del wb["Sheet"]
wb.save(out_path)
print(f"✅ PD主攻词单Excel已生成:{out_path}")
return out_path
# ──────── 生成Word报告 ────────
def build_cross_stats(result_df, sss_cnt, ss_cnt, s_cnt, asin, date, overlap_meta=None) -> dict:
top = result_df[result_df["词级别"].isin(["SSS级", "SS级"])].head(15)
records = []
for _, r in top.iterrows():
records.append({
"grade": str(r.get("词级别", "")),
"keyword": str(r.get("关键词", "")),
"sources": str(r.get("出现来源", "")),
"search_volume": r.get("周搜索量"),
"bid": r.get("建议竞价中值"),
"concentration": r.get("Top3集中度"),
"sp_share": r.get("竞品SP份额%"),
"action": str(r.get("综合操作建议", "")),
})
stats = {
"report": "cross_pd_primary",
"asin": asin, "date": date,
"sss_count": sss_cnt, "ss_count": ss_cnt, "s_count": s_cnt,
"primary_focus_count": sss_cnt + ss_cnt,
"primary_keywords": records,
}
if overlap_meta:
stats["overlap"] = overlap_meta
return stats
def build_word_report(result_df, sss_cnt, ss_cnt, s_cnt, asin, date, output_dir, insights="", overlap_meta=None):
if not HAS_DOCX:
print("⚠️ 未安装 python-docx,跳过 Word", file=sys.stderr)
return None
doc = Document()
setup_doc_styles(doc)
add_doc_title(doc, f"{asin} PD主攻词单分析报告", f"生成日期:{date}")
doc.add_heading("一、执行摘要", level=1)
main_grade = "SSS级" if sss_cnt > 0 else "SS级"
main_cnt = sss_cnt if sss_cnt > 0 else ss_cnt
top_words = result_df[result_df["词级别"].isin(["SSS级", "SS级"])].head(5)
top_kw_str = "、".join(top_words["关键词"].astype(str).tolist()) if len(top_words) else "(无)"
top_sv = top_words["周搜索量"].apply(pd.to_numeric, errors="coerce")
avg_sv_str = f"{top_sv.mean():.0f}" if len(top_sv) > 0 and top_sv.notna().any() else "N/A"
add_para(doc,
f"三表交叉:SSS级 {sss_cnt} 个,SS级 {ss_cnt} 个,S级 {s_cnt} 个(单表词,非主攻核心)。"
f"PD 主攻以{main_grade}为核心({main_cnt}个),均值搜索量 {avg_sv_str}。TOP5:{top_kw_str}。"
)
if overlap_meta:
doc.add_heading("二、交叉分级逻辑(数据校验)", level=1)
add_para(doc,
"说明:交叉「S级」= 只出现在一张筛选表中的词,与表1「S级」含义不同。"
"S 数量偏多属正常——三表并集减去双表/三表交集后的余量。"
)
logic_tbl = doc.add_table(rows=8, cols=2)
logic_tbl.style = "Table Grid"
rows_data = [
("表1 输入(S+A级)", str(overlap_meta.get("set1_count", ""))),
("表2 输入(高机会)", str(overlap_meta.get("set2_count", ""))),
("表3 输入(缺口词)", str(overlap_meta.get("set3_count", ""))),
("三表并集(去重)", str(overlap_meta.get("union_count", ""))),
("SSS(三表交集)", str(sss_cnt)),
("SS 表1+表2 / 表1+表3 / 表2+表3",
f"{overlap_meta.get('ss_12_count', 0)} / {overlap_meta.get('ss_13_count', 0)} / {overlap_meta.get('ss_23_count', 0)}"),
("S 仅单表(表1/表2/表3)",
f"{overlap_meta.get('s_only_t1_count', 0)} / {overlap_meta.get('s_only_t2_count', 0)} / {overlap_meta.get('s_only_t3_count', 0)}"),
("校验公式", f"SSS+SS+S = {sss_cnt}+{ss_cnt}+{s_cnt} = {sss_cnt + ss_cnt + s_cnt}(应等于并集)"),
]
for ri, (k, v) in enumerate(rows_data):
set_cell_text(logic_tbl.rows[ri].cells[0], k)
set_cell_text(logic_tbl.rows[ri].cells[1], v)
apply_font_to_table(logic_tbl)
doc.add_heading("三、PD 备战策略(AI 分析)", level=1)
render_insights_section(doc, insights, heading="")
sss_df = result_df[result_df["词级别"] == "SSS级"].head(10)
sec = 4
if sss_cnt > 0:
doc.add_heading("四、SSS级词逐一解读", level=1)
sec = 5
for _, r in sss_df.iterrows():
kw = r.get("关键词", "")
sv = r.get("周搜索量", "N/A")
bid = r.get("建议竞价中值", "N/A")
conc = r.get("Top3集中度", "N/A")
bid_str = f"${bid:.2f}" if isinstance(bid, float) else str(bid)
conc_str = f"{conc:.3f}" if isinstance(conc, float) else str(conc)
sp_str = fmt_share_pct(r.get("竞品SP份额%"))
sp_part = f",SP份额 {sp_str}" if sp_str else ""
add_para(doc,
f"【{kw}】搜索量 {sv},竞价 {bid_str},集中度 {conc_str},"
f"自然位 {r.get('竞品自然位状态', '未知')}{sp_part} "
f"→ {r.get('综合操作建议', '')}"
)
ss_df = result_df[result_df["词级别"] == "SS级"].head(20)
cn = ["四", "五", "六", "七"]
sec_idx = sec - 4
if len(ss_df) > 0:
doc.add_heading(f"{cn[sec_idx]}、SS级词清单(主攻候选)", level=1)
sec_idx += 1
tbl = doc.add_table(rows=len(ss_df) + 1, cols=6)
tbl.style = "Table Grid"
for ci, h in enumerate(["关键词", "来源", "周搜索量", "竞品自然位", "竞品SP份额%", "操作建议"]):
set_cell_text(tbl.rows[0].cells[ci], h)
for ri, (_, r) in enumerate(ss_df.iterrows(), 1):
set_cell_text(tbl.rows[ri].cells[0], str(r.get("关键词", "")))
set_cell_text(tbl.rows[ri].cells[1], str(r.get("出现来源", "")))
set_cell_text(tbl.rows[ri].cells[2], str(r.get("周搜索量", "")))
set_cell_text(tbl.rows[ri].cells[3], str(r.get("竞品自然位状态", "")))
set_cell_text(tbl.rows[ri].cells[4], fmt_share_pct(r.get("竞品SP份额%")))
set_cell_text(tbl.rows[ri].cells[5], str(r.get("综合操作建议", "")))
apply_font_to_table(tbl)
if s_cnt > 0:
doc.add_heading(f"{cn[sec_idx]}、S级单表词说明", level=1)
add_para(doc,
f"共 {s_cnt} 个词仅出现在一张筛选表中,不建议作为 PD 主攻预算核心。"
f"完整清单见 Excel「PD主攻词单」Sheet,可按搜索量择优观察。"
)
if HAS_MPL:
chart_paths = _gen_charts_cross(result_df, sss_cnt, ss_cnt, s_cnt, output_dir, asin, date)
if chart_paths:
items = [
("图1 · 柱形图:三表交叉分级词数量(SSS / SS / S)", chart_paths[0]),
]
if len(chart_paths) > 1:
items.append((
"图2 · 雷达图:SSS+SS 级词综合价值(搜索量/竞价友好/竞品防守弱/自然位空间/集中度)",
chart_paths[1],
))
if len(chart_paths) > 2:
items.append(("图3 · 柱形图:SS/SSS 级 TOP 词周搜索量", chart_paths[2]))
add_charts_section(
doc, items,
section_title=f"{cn[min(sec_idx + 1, 3)]}、可视化图表",
level=1,
)
finalize_doc_fonts(doc)
out = os.path.join(output_dir, f"{asin}_PD主攻词单报告_{date}.docx")
try:
doc.save(out)
except PermissionError:
alt = os.path.join(output_dir, f"{asin}_PD主攻词单报告_{date}_new.docx")
doc.save(alt)
out = alt
print(f"⚠️ 原 Word 被占用,已另存:{alt}", file=sys.stderr)
print(f"✅ Word报告已生成:{out}")
return out
def _gen_charts_cross(result_df, sss_cnt, ss_cnt, s_cnt, output_dir, asin, date):
paths = []
try:
counts = [sss_cnt, ss_cnt, s_cnt]
labels = ["SSS级", "SS级", "S级"]
fig, ax = plt.subplots(figsize=(7, 5))
colors = ["#70AD47", "#FFC000", "#FF6B6B"]
bars = ax.bar(labels, counts, color=colors, width=0.4)
for bar, cnt in zip(bars, counts):
ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.2,
str(cnt), ha="center", fontsize=14, fontweight="bold")
ax.set_title("SSS/SS/S级词数量分布", fontsize=13, fontweight="bold")
ax.set_ylabel("词数量"); ax.spines[["top", "right"]].set_visible(False)
p1 = chart_path(output_dir, f"chart1_cross_{asin}_{date}.png")
fig.savefig(p1, dpi=150, bbox_inches="tight"); plt.close(fig); paths.append(p1)
except Exception as e:
print(f"图1失败: {e}", file=sys.stderr)
try:
core = result_df[result_df["词级别"].isin(["SSS级", "SS级"])].copy()
if len(core) > 0:
sv = pd.to_numeric(core["周搜索量"], errors="coerce").fillna(0)
bid = pd.to_numeric(core["建议竞价中值"], errors="coerce").fillna(1.0)
conc = pd.to_numeric(core["Top3集中度"], errors="coerce").fillna(0.35)
sp = pd.to_numeric(core["竞品SP份额%"], errors="coerce").fillna(10.0)
def norm_high(v, ref):
return float(np.clip(v / ref * 100, 0, 100)) if ref else 50.0
def norm_low(v, ref):
return float(np.clip((1 - v / ref) * 100, 0, 100)) if ref else 50.0
sv_score = norm_high(sv.median(), max(sv.max(), 1))
bid_score = norm_low(bid.median(), max(bid.max(), 0.5))
defense_score = norm_low(sp.median(), 25.0)
conc_score = norm_low(conc.median(), max(conc.max(), 0.35))
nat_map = {"纯广告": 90, "未稳": 65, "稳定": 30}
nat_vals = core["竞品自然位状态"].astype(str).map(lambda x: nat_map.get(x.strip(), 50))
nat_score = float(nat_vals.mean()) if len(nat_vals) else 50.0
dims = ["搜索量", "竞价友好", "竞品防守弱", "自然位空间", "集中度友好"]
values = [sv_score, bid_score, defense_score, nat_score, conc_score]
angles = np.linspace(0, 2 * np.pi, len(dims), endpoint=False).tolist()
values_cycle = values + values[:1]
angles_cycle = angles + angles[:1]
fig, ax = plt.subplots(figsize=(7, 7), subplot_kw=dict(polar=True))
ax.plot(angles_cycle, values_cycle, "o-", linewidth=2, color="#2E75B6")
ax.fill(angles_cycle, values_cycle, alpha=0.25, color="#2E75B6")
ax.set_xticks(angles)
ax.set_xticklabels(dims, fontsize=10)
ax.set_ylim(0, 100)
ax.set_title("SSS+SS级词综合价值雷达图", fontsize=13, fontweight="bold", pad=20)
p2 = chart_path(output_dir, f"chart2_cross_{asin}_{date}.png")
fig.savefig(p2, dpi=150, bbox_inches="tight"); plt.close(fig); paths.append(p2)
except Exception as e:
print(f"图2雷达图失败: {e}", file=sys.stderr)
try:
top_df = result_df[result_df["词级别"].isin(["SSS级", "SS级"])].head(10)
top_df["__sv"] = pd.to_numeric(top_df["周搜索量"], errors="coerce").fillna(0)
top_df = top_df.sort_values("__sv", ascending=False)
if len(top_df) > 0:
labels_t = top_df["关键词"].astype(str).str[:12].tolist()
colors_t = ["#70AD47" if g == "SSS级" else "#FFC000" for g in top_df["词级别"]]
fig, ax = plt.subplots(figsize=(max(9, len(labels_t)), 5))
bars = ax.bar(labels_t, top_df["__sv"], color=colors_t)
for bar, v in zip(bars, top_df["__sv"]):
ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 20,
str(int(v)), ha="center", fontsize=8, fontweight="bold")
ax.set_xticklabels(labels_t, rotation=45, ha="right", fontsize=8)
ax.set_title("SSS+SS级词搜索量 TOP10", fontsize=12, fontweight="bold")
ax.set_ylabel("周搜索量"); ax.spines[["top", "right"]].set_visible(False)
from matplotlib.patches import Patch
legend_elements = [Patch(facecolor="#70AD47", label="SSS级"), Patch(facecolor="#FFC000", label="SS级")]
ax.legend(handles=legend_elements)
p3 = chart_path(output_dir, f"chart3_cross_{asin}_{date}.png")
fig.savefig(p3, dpi=150, bbox_inches="tight"); plt.close(fig); paths.append(p3)
except Exception as e:
print(f"图3失败: {e}", file=sys.stderr)
return paths
def main():
from datetime import date as _date, datetime
parser = argparse.ArgumentParser(description="三表交叉分析,生成PD主攻词单")
parser.add_argument("--t1", required=True, help="表1处理结果Excel路径")
parser.add_argument("--t2", required=True, help="表2处理结果Excel路径")
parser.add_argument("--t3", required=True, help="表3处理结果Excel路径")
parser.add_argument("--output-dir", required=True)
parser.add_argument("--asin", required=True)
parser.add_argument("--date", default=_date.today().strftime("%Y%m%d"))
parser.add_argument("--insights", default="")
parser.add_argument("--insights-file", default="")
parser.add_argument("--skip-word", action="store_true")
args = parser.parse_args()
args.insights = load_insights(args.insights, args.insights_file)
for p in [args.t1, args.t2, args.t3]:
if not os.path.exists(p):
print(f"❌ 找不到文件:{p}", file=sys.stderr); sys.exit(1)
rid = make_run_id(args.date)
os.makedirs(args.output_dir, exist_ok=True)
print(f"📂 读取表1(关键词分层):{args.t1}")
t1_df, t1_kw, t1_bid, t1_conc, t1_sv = load_t1_sa(args.t1)
print(f" S+A级词:{len(t1_df)} 个 | bid列:{t1_bid} | conc列:{t1_conc}")
print(f"📂 读取表2(高机会词):{args.t2}")
t2_df, t2_kw = load_t2_high(args.t2)
print(f" 高机会词:{len(t2_df)} 个")
print(f"📂 读取表3(缺口词):{args.t3}")
t3_df, t3_kw = load_t3_gap(args.t3)
print(f" 缺口词:{len(t3_df)} 个")
result_df, sss_cnt, ss_cnt, s_cnt, overlap_meta = cross_analyze(
t1_df, t1_kw, t2_df, t2_kw, t3_df, t3_kw,
t1_col_bid=t1_bid, t1_col_conc=t1_conc, t1_col_search=t1_sv
)
print("\n📝 生成PD主攻词单Excel...")
build_output_excel(result_df, args.asin, rid, args.output_dir)
stats = build_cross_stats(result_df, sss_cnt, ss_cnt, s_cnt, args.asin, args.date, overlap_meta)
stats["run_id"] = rid
sp = save_stats(stats, stats_path(args.output_dir, args.asin, "cross", rid))
print(f"📊 Stats JSON:{sp}")
if args.skip_word:
print("\n" + "=" * 60)
print("📝 [Agent] 写 insights_cross.md 后带 --insights-file 重建 Word")
print(f" stats:{sp}")
print(f" insights:{insights_path(args.output_dir, 'cross')}")
print("=" * 60)
else:
build_word_report(result_df, sss_cnt, ss_cnt, s_cnt,
args.asin, rid, args.output_dir, insights=args.insights,
overlap_meta=overlap_meta)
cleanup_intermediate_files(args.output_dir, args.asin, rid, "cross")
print(f"\n✅ 三表交叉分析完成!SSS:{sss_cnt} | SS:{ss_cnt} | S:{s_cnt}")
if __name__ == "__main__":
main()
"""
ingest_raw.py - 初始化目录 + 接入用户提供的原始表(任意路径/文件名)
用法 1(仅建目录):
python ingest_raw.py --asin B0CRMP3RQT --output-dir "D:/out" --stage 成长期 --product-category 水杯
用法 2(用户直接给三个文件路径):
python ingest_raw.py --asin B0CRMP3RQT --output-dir "D:/out" \
--t1 "D:/Downloads/Sif关键词调研-US-xxx.xlsx" \
--t2 "D:/Downloads/asinKeywords_xxx.xlsx" \
--t3 "D:/Downloads/asinAdKwView_xxx.xlsx"
用法 3(给一个文件夹,自动识别三张表):
python ingest_raw.py --asin B0CRMP3RQT --output-dir "D:/out" --from-dir "D:/Downloads"
stdout 输出 JSON(含 raw_dir / result_dir / 标准文件名路径),供后续 process_* 使用。
"""
from __future__ import annotations
import argparse
import json
import re
import shutil
import sys
from pathlib import Path
from run_context import init_run
try:
import pandas as pd
except ImportError:
print("请安装 pandas: pip install pandas openpyxl", file=sys.stderr)
sys.exit(1)
T1_MARKERS = ["周搜索量", "Top3集中度", "Top3点击集中度", "相关性得分", "ABA排名"]
T2_MARKERS = ["流量词占比", "自然流量占比", "广告流量占比", "是否增长词"]
T3_MARKERS = ["广告搜索词", "SP广告流量份额", "该Listing在该词下的SP广告流量份额", "贡献占比"]
def _cols_in_file(path: Path) -> set[str]:
found: set[str] = set()
try:
xl = pd.ExcelFile(path)
for sheet in xl.sheet_names[:5]:
for header in (0, 1):
try:
df = pd.read_excel(path, sheet_name=sheet, header=header, nrows=3)
found.update(str(c) for c in df.columns)
except Exception:
continue
except Exception:
pass
return found
def _score_table(path: Path) -> dict[str, int]:
name = path.name.lower()
cols = _cols_in_file(path)
col_text = " ".join(cols)
scores = {"t1": 0, "t2": 0, "t3": 0}
for m in T1_MARKERS:
if m in col_text:
scores["t1"] += 2
for m in T2_MARKERS:
if m in col_text:
scores["t2"] += 2
for m in T3_MARKERS:
if m in col_text:
scores["t3"] += 2
if "关键词调研" in name or "keywords-library" in name or "sif关键词调研" in name:
scores["t1"] += 3
if "asinkeywords" in name or "反查流量词" in name or "流量词" in name:
scores["t2"] += 3
if "asinadkwview" in name or "查广告词" in name or "adxray" in name:
scores["t3"] += 3
if "广告搜索词" in col_text:
scores["t3"] += 4
if "流量词占比" in col_text:
scores["t2"] += 4
if "周搜索量" in col_text and "相关性" in col_text:
scores["t1"] += 4
return scores
def detect_table_type(path: str) -> str:
p = Path(path)
if not p.is_file():
raise FileNotFoundError(f"找不到文件:{path}")
scores = _score_table(p)
best = max(scores, key=scores.get)
if scores[best] < 2:
raise ValueError(f"无法识别表类型:{path}(列特征不足,请用 --t1/--t2/--t3 显式指定)")
return best
def _collect_paths(t1: str, t2: str, t3: str, from_dir: str) -> list[str]:
explicit = [p for p in (t1, t2, t3) if p]
if explicit and from_dir:
raise ValueError("不要同时使用 --from-dir 与 --t1/--t2/--t3")
if from_dir:
d = Path(from_dir)
if not d.is_dir():
raise FileNotFoundError(f"找不到目录:{from_dir}")
return [str(p) for p in d.iterdir() if p.suffix.lower() in (".xlsx", ".xls")]
return explicit
def ingest_files(
ctx: dict,
sources: dict[str, str],
) -> dict:
"""sources: {t1: path, t2: path, t3: path} → 复制到 raw_dir 标准命名"""
raw_dir = Path(ctx["raw_dir"])
rid = ctx["run_id"]
targets = {
"t1": raw_dir / f"关键词调研_{rid}.xlsx",
"t2": raw_dir / f"反查流量词_{rid}.xlsx",
"t3": raw_dir / f"查广告词_{rid}.xlsx",
}
ingested = {}
for key, src in sources.items():
src_p = Path(src).resolve()
dst_p = targets[key].resolve()
if not src_p.is_file():
raise FileNotFoundError(f"找不到 {key} 文件:{src}")
if src_p != dst_p:
shutil.copy2(src_p, dst_p)
ingested[key] = {
"source": str(src_p),
"target": str(dst_p),
"skipped_copy": src_p == dst_p,
}
ctx["ingested"] = ingested
ctx["raw_paths"] = {k: str(v) for k, v in targets.items()}
return ctx
def auto_map_files(paths: list[str]) -> dict[str, str]:
"""自动识别三张表;若用户已用 --t1/--t2/--t3 则直接映射"""
mapping: dict[str, str] = {}
unknown: list[str] = []
for p in paths:
try:
kind = detect_table_type(p)
except ValueError:
unknown.append(p)
continue
if kind in mapping:
# 同类型取特征分更高的
if _score_table(Path(p))[kind] > _score_table(Path(mapping[kind]))[kind]:
unknown.append(mapping[kind])
mapping[kind] = p
else:
unknown.append(p)
else:
mapping[kind] = p
missing = [k for k in ("t1", "t2", "t3") if k not in mapping]
if missing:
msg = f"缺少表:{', '.join(missing)}"
if unknown:
msg += f";未识别文件:{unknown}"
raise ValueError(msg)
return mapping
def main():
parser = argparse.ArgumentParser(description="建目录 + 接入用户原始表(任意路径)")
parser.add_argument("--asin", required=True)
parser.add_argument("--output-dir", required=True)
parser.add_argument("--date", default="", help="YYYYMMDD,默认今天")
parser.add_argument("--stage", default="")
parser.add_argument("--product-category", default="")
parser.add_argument("--product-type", default="", choices=["", "标品", "非标品"],
help="标品/非标品门槛预设,空则默认非标品")
parser.add_argument("--t1", default="", help="表1 文件路径(关键词调研)")
parser.add_argument("--t2", default="", help="表2 文件路径(反查流量词)")
parser.add_argument("--t3", default="", help="表3 文件路径(查广告词)")
parser.add_argument("--from-dir", default="", help="含三张 xlsx 的文件夹,自动识别")
args = parser.parse_args()
extra = {}
if args.stage:
extra["stage"] = args.stage
if args.product_category:
extra["product_category"] = args.product_category
if args.product_type:
extra["product_type"] = args.product_type
ctx = init_run(args.output_dir, args.asin, date=args.date or None, extra=extra or None)
paths = _collect_paths(args.t1, args.t2, args.t3, args.from_dir)
if paths:
if args.t1 and args.t2 and args.t3:
mapping = {"t1": args.t1, "t2": args.t2, "t3": args.t3}
else:
mapping = auto_map_files(paths)
ctx = ingest_files(ctx, mapping)
print(f"✅ 已接入原始表 → {ctx['raw_dir']}", file=sys.stderr)
for k, info in ctx["ingested"].items():
print(f" {k}: {Path(info['source']).name} → {Path(info['target']).name}", file=sys.stderr)
else:
print(f"📁 已创建目录(待放入原始表):{ctx['raw_dir']}", file=sys.stderr)
print(json.dumps(ctx, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
"""
run_context.py - 单次运行的日期路径与元数据
目录结构:
{output_dir}/{ASIN}/{YYYYMMDD}/
├── 原始数据/
└── 处理结果/
└── charts/ (Word 生成后自动清理)
文件命名:关键词调研_{DATE}.xlsx(仅日期,不含时分)
历史记录表保留精确到分钟的时间戳,便于区分同日多次跑库。
"""
from __future__ import annotations
import stdio_utf8 # noqa: F401 — Windows GBK 控制台 UTF-8 兼容
import json
from datetime import datetime
from pathlib import Path
from typing import Optional
def run_id(date: str) -> str:
"""文件后缀用:20260611"""
return date
def datetime_display(date: str, time: Optional[str] = None) -> str:
"""2026-06-11 或 2026-06-11 14:30"""
base = f"{date[:4]}-{date[4:6]}-{date[6:8]}"
if time:
t = time.zfill(4)
return f"{base} {t[:2]}:{t[2:4]}"
return base
def relative_run_path(asin: str, date: str) -> str:
"""文件目录用:{ASIN}/{YYYYMMDD}"""
return f"{asin}/{date}"
def history_run_path(asin: str, date: str, time: str) -> str:
"""历史表用:{ASIN}/{YYYYMMDD}/{HHmm},区分同日多次跑库"""
return f"{asin}/{date}/{time.zfill(4)}"
def build_run_dirs(output_dir: str, asin: str, date: str) -> dict:
root = Path(output_dir) / asin / date
raw_dir = root / "原始数据"
result_dir = root / "处理结果"
charts_dir = result_dir / "charts"
for d in (raw_dir, result_dir, charts_dir):
d.mkdir(parents=True, exist_ok=True)
now = datetime.now()
return {
"output_dir": str(Path(output_dir).resolve()),
"asin": asin,
"date": date,
"time": now.strftime("%H%M"),
"run_id": run_id(date),
"datetime_display": datetime_display(date, now.strftime("%H%M")),
"run_path": relative_run_path(asin, date),
"run_root": str(root.resolve()),
"raw_dir": str(raw_dir.resolve()),
"result_dir": str(result_dir.resolve()),
"charts_dir": str(charts_dir.resolve()),
}
def write_run_meta(ctx: dict, extra: Optional[dict] = None) -> str:
meta = {
"asin": ctx["asin"],
"date": ctx["date"],
"run_id": ctx["run_id"],
"datetime_display": ctx["datetime_display"],
"run_path": ctx["run_path"],
"created_at": datetime.now().isoformat(timespec="seconds"),
}
if extra:
meta.update(extra)
path = Path(ctx["run_root"]) / "run_meta.json"
with open(path, "w", encoding="utf-8") as f:
json.dump(meta, f, ensure_ascii=False, indent=2)
return str(path)
def init_run(output_dir: str, asin: str, date: Optional[str] = None,
extra: Optional[dict] = None) -> dict:
now = datetime.now()
date = date or now.strftime("%Y%m%d")
ctx = build_run_dirs(output_dir, asin, date)
ctx["meta_path"] = write_run_meta(ctx, extra)
rid = ctx["run_id"]
ctx["raw_files"] = {
"t1": f"关键词调研_{rid}.xlsx",
"t2": f"反查流量词_{rid}.xlsx",
"t3": f"查广告词_{rid}.xlsx",
}
return ctx
def parse_run_path(path: str) -> dict:
"""解析 B0CRMP3RQT/20260611 或旧版带时分 B0CRMP3RQT/20260611/0244"""
parts = [p for p in path.replace("\\", "/").split("/") if p]
out = {"asin": "", "date": "", "time": "", "legacy": True}
if len(parts) >= 1:
out["asin"] = parts[0]
if len(parts) >= 2:
out["date"] = parts[1]
if len(parts) >= 3 and parts[2].isdigit():
out["time"] = parts[2]
out["legacy"] = False
if out["date"]:
out["run_id"] = out["date"]
out["datetime_display"] = datetime_display(out["date"], out["time"] or None)
return out
def result_dir_from_run_path(output_dir: str, run_path: str) -> str:
# 兼容旧版含时分路径:始终落到 {asin}/{date}/处理结果
info = parse_run_path(run_path)
if info["asin"] and info["date"]:
return str(Path(output_dir) / info["asin"] / info["date"] / "处理结果")
return str(Path(output_dir) / run_path.replace("\\", "/") / "处理结果")
Related skills
FAQ
What inputs does it need?
An ASIN plus three Sif exports (keyword research, reverse-traffic terms, ad-word audit), provided by path, folder or browser export.
Does it run automatically?
No, it enforces a briefing and three confirmation checkpoints; a bare ASIN or silence does not count as confirmation.