mirror of
https://github.com/primedigitaltech/market-assistant.git
synced 2026-07-22 08:01:34 +08:00
competitor_report 子包与 jd_competitor_report 主模块移至 backend/pipeline;runner 与测试改为 from pipeline import jd_competitor_report;crawler_copy 下 jd_competitor_report 为转发 shim;jd_keyword_pipeline 文档更新。 Made-with: Cursor
1389 lines
62 KiB
Python
1389 lines
62 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
关键词 → 调用 ``jd_keyword_pipeline`` 全链路采集 → 生成 **标准化竞品分析报告**(Markdown)。
|
||
|
||
报告结构对齐常见竞品分析框架:研究范围与方法、执行摘要、**整体市场观察(列表可见度参考)**、
|
||
市场与竞争结构、**按细分类目分组的竞品对比矩阵**、价格分析(含规则化价差/活动信号与可选 **细类价盘·促销** 大模型归纳)、**按细分类目的消费者反馈与用户画像**、**策略与机会提示**(以大模型归纳为主,可选)与附录;并明确数据边界。
|
||
若运行配置中提供了外部市场规模摘录(``EXTERNAL_MARKET_TABLE_ROWS``),则追加对应表格小节;否则不输出占位行。
|
||
|
||
依赖:全量抓取时与 ``crawler_copy/jd_pc_search/jd_keyword_pipeline.py`` 相同(Node、h5st、Playwright、``common/jd_cookie.txt``)。
|
||
**仅复用已有目录生成报告时**不需要跑浏览器,只需该目录下已有 CSV / ``run_meta.json``。
|
||
|
||
本模块位于 ``pipeline``(解析与报告);爬虫实现仅在 ``crawler_copy/jd_pc_search``。
|
||
|
||
用法:
|
||
|
||
- **重新抓取并出报告**:``EXISTING_RUN_DIR = None``,配置 ``KEYWORD``(及可选 ``OVERRIDE_*``),在 ``backend`` 目录下执行
|
||
``python -m pipeline.jd_competitor_report``;或沿用爬虫目录下的兼容入口 ``python jd_competitor_report.py``(见该文件说明)。
|
||
- **只分析已有批次**:将 ``EXISTING_RUN_DIR`` 设为 ``pipeline_runs/<时间戳>_<关键词>/`` 的绝对或相对路径(相对当前工作目录),
|
||
再执行同一命令;**不重新抓取**。关键词优先用本文件 ``KEYWORD``,否则读 ``run_meta.json`` 的 ``keyword``,再否则从目录名
|
||
``YYYYMMDD_HHMMSS_<词>`` 推断。
|
||
|
||
流水线其余参数(评论分页、延迟等)仍在 ``jd_keyword_pipeline.py`` 顶部配置。
|
||
|
||
输出:在对应运行目录下覆盖写入 ``competitor_analysis.md``。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
import math
|
||
import random
|
||
import re
|
||
import sys
|
||
from collections import Counter
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
# 竞品报告依赖爬虫副本中的 ``jd_keyword_pipeline``(采集);本文件归属 pipeline(解析与成稿)。
|
||
_BACKEND = Path(__file__).resolve().parent.parent
|
||
_CRAWLER_JD = _BACKEND / "crawler_copy" / "jd_pc_search"
|
||
if str(_CRAWLER_JD) not in sys.path:
|
||
sys.path.insert(0, str(_CRAWLER_JD))
|
||
|
||
import jd_keyword_pipeline as kpl # noqa: E402
|
||
from pipeline.csv_schema import ( # noqa: E402
|
||
COMMENT_CSV_COLUMNS,
|
||
JD_SEARCH_CSV_HEADERS,
|
||
MERGED_FIELD_TO_CSV_HEADER,
|
||
merged_csv_effective_total_sales,
|
||
)
|
||
|
||
from pipeline.competitor_report.config import * # noqa: F403
|
||
from pipeline.competitor_report.constants import * # noqa: F403
|
||
from pipeline.competitor_report.csv_io import * # noqa: F403
|
||
from pipeline.competitor_report.price_promo import ( # noqa: E402
|
||
_analyze_price_promotions,
|
||
_markdown_price_promotion_section,
|
||
)
|
||
from pipeline.competitor_report.comment_sentiment import ( # noqa: E402
|
||
build_comment_sentiment_llm_payload,
|
||
_comment_keyword_hits,
|
||
_comment_sentiment_lexicon,
|
||
_iter_comment_text_units,
|
||
_iter_comment_text_units_and_scores,
|
||
_merge_comment_previews,
|
||
_parse_comment_score,
|
||
)
|
||
from pipeline.competitor_report.llm_group_payloads import ( # noqa: E402
|
||
build_comment_groups_llm_payload,
|
||
build_matrix_groups_llm_payload,
|
||
build_price_groups_llm_payload,
|
||
build_promo_groups_llm_payload,
|
||
build_scenario_groups_llm_payload,
|
||
_comment_scenario_counts,
|
||
_group_keyword_hits,
|
||
_text_hits_scenario_triggers,
|
||
)
|
||
from pipeline.competitor_report.matrix_group import ( # noqa: E402
|
||
_category_mix,
|
||
_competitor_matrix_group_key,
|
||
_merged_rows_grouped_for_matrix,
|
||
)
|
||
from pipeline.competitor_report.price_stats import _price_stats_extended # noqa: E402
|
||
from pipeline.competitor_report.consumer_feedback import ( # noqa: E402
|
||
_comment_lines_with_product_context,
|
||
_consumer_feedback_by_matrix_group,
|
||
_sku_to_matrix_group_map,
|
||
)
|
||
from pipeline.competitor_report.list_mix import ( # noqa: E402
|
||
_brand_cr,
|
||
_counter_mix_top_rows_with_remainder,
|
||
_search_list_proxies,
|
||
_structure_brands,
|
||
_structure_names_for_pie_counter,
|
||
_structure_shops,
|
||
)
|
||
from pipeline.competitor_report.matrix_md import ( # noqa: E402
|
||
_competitor_matrix_md_line,
|
||
_matrix_ingredients_cell,
|
||
)
|
||
from pipeline.competitor_report.report_md_helpers import ( # noqa: E402
|
||
_embed_chart,
|
||
_focus_scenario_combo_bar_filename,
|
||
_lines_4_reading_brand,
|
||
_lines_4_reading_shop,
|
||
_matrix_prices_sales_chart_filename,
|
||
_mermaid_pie_focus_keywords,
|
||
_scenario_group_asset_slug,
|
||
_scenario_summary_bullets,
|
||
_strategy_hints,
|
||
)
|
||
from pipeline.competitor_report.run_context import ( # noqa: E402
|
||
_infer_keyword,
|
||
_pc_search_result_count_from_raw,
|
||
_resolve_existing_run_dir,
|
||
_run_batch_label,
|
||
)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 运行配置(按需改这里;与 pipeline.competitor_report.constants 中默认关注词等配合使用)
|
||
# ---------------------------------------------------------------------------
|
||
# KEYWORD:京东 PC 搜索词;全量抓取时必填。「仅已有目录」模式下可留空,改从 run_meta / 目录名推断。
|
||
KEYWORD = "低GI"
|
||
# 已有流水线目录(含 keyword_pipeline_merged.csv 等)时设为路径则**不重新抓取**,只生成 competitor_analysis.md。
|
||
EXISTING_RUN_DIR = None
|
||
# EXISTING_RUN_DIR = r"data\JD\pipeline_runs\20260408_144606_低GI" # 相对数据根或绝对路径
|
||
# 以下非 None 时仅本次运行临时覆盖 jd_keyword_pipeline 中同名变量(不改 pipeline 文件)
|
||
OVERRIDE_MAX_SKUS: int | None = None
|
||
OVERRIDE_PAGE_START: int | None = None
|
||
OVERRIDE_PAGE_TO: int | None = None
|
||
|
||
|
||
def build_competitor_markdown(
|
||
*,
|
||
run_dir: Path,
|
||
keyword: str,
|
||
merged_rows: list[dict[str, str]],
|
||
search_export_rows: list[dict[str, str]],
|
||
comment_rows: list[dict[str, str]],
|
||
meta: dict[str, Any] | None,
|
||
report_config: dict[str, Any] | None = None,
|
||
llm_sentiment_section_md: str | None = None,
|
||
llm_matrix_section_md: str | None = None,
|
||
llm_price_groups_section_md: str | None = None,
|
||
llm_promo_groups_section_md: str | None = None,
|
||
llm_scenario_groups_section_md: str | None = None,
|
||
llm_comment_groups_section_md: str | None = None,
|
||
llm_strategy_opportunities_section_md: str | None = None,
|
||
chapter8_text_mining_probe_section_md: str | None = None,
|
||
) -> str:
|
||
focus_words, scenario_groups, external_rows = resolve_report_tuning(report_config)
|
||
_ch8_probe_sec = (chapter8_text_mining_probe_section_md or "").strip()
|
||
sku_header = MERGED_FIELD_TO_CSV_HEADER["sku_id"]
|
||
title_h = MERGED_FIELD_TO_CSV_HEADER["title"]
|
||
batch = _run_batch_label(run_dir)
|
||
n_sku = len(merged_rows)
|
||
n_cmt = len(comment_rows)
|
||
n_sku_pathed = sum(1 for r in merged_rows if _detail_category_path_cell(r))
|
||
n_sku_matrix = sum(1 for r in merged_rows if _competitor_matrix_group_key(r))
|
||
|
||
list_export = len(search_export_rows) > 0
|
||
structure_rows = search_export_rows if list_export else merged_rows
|
||
n_structure = len(structure_rows)
|
||
shops_s = _structure_shops(structure_rows, list_export=list_export)
|
||
brands_s = _structure_brands(structure_rows, list_export=list_export)
|
||
shops_for_cr = _structure_names_for_pie_counter(shops_s)
|
||
brands_for_cr = _structure_names_for_pie_counter(brands_s)
|
||
cr1_shop, cr3_shop, top_shop_s, _ = _brand_cr(shops_for_cr)
|
||
cr1_list_brand, cr3_list_brand, top_list_brand, _ = _brand_cr(brands_for_cr)
|
||
# §4.3 类目分布:深入合并表(与 §5 竞品矩阵同一细类划分,非搜索列表行)
|
||
cm_structure = _category_mix(merged_rows, top_k=12)
|
||
min_brand_rows = max(5, int(0.02 * n_structure)) if n_structure else 5
|
||
|
||
brands_deep = [
|
||
_cell(r, MERGED_FIELD_TO_CSV_HEADER["detail_brand"], "detail_brand")
|
||
for r in merged_rows
|
||
if _cell(r, MERGED_FIELD_TO_CSV_HEADER["detail_brand"], "detail_brand")
|
||
]
|
||
cr1_deep, cr3_deep, top_brand_deep, _top_share_deep = _brand_cr(brands_deep)
|
||
cr1_hints = (
|
||
cr1_shop if list_export and cr1_shop is not None else cr1_deep
|
||
)
|
||
|
||
pst_merged = _price_stats_extended(_collect_prices(merged_rows))
|
||
pst_list = (
|
||
_price_stats_extended(_collect_prices(search_export_rows))
|
||
if list_export
|
||
else {}
|
||
)
|
||
# 价格分析(§2 要点、§6、策略提示):优先「列表全量」;无列表或无解析价时再用合并表深入样本
|
||
pst = (
|
||
pst_list
|
||
if list_export and pst_list.get("n", 0) > 0
|
||
else pst_merged
|
||
)
|
||
price_analysis_basis_cn = (
|
||
f"PC 搜索列表导出共 **{len(search_export_rows)}** 行中的展示价(标价/券后等)"
|
||
if list_export and pst_list.get("n", 0) > 0
|
||
else f"已深入抓取的 **{n_sku}** 个 SKU 合并数据中的展示价"
|
||
)
|
||
promo_rows = (
|
||
search_export_rows
|
||
if list_export and pst_list.get("n", 0) > 0
|
||
else merged_rows
|
||
)
|
||
promo_sig = _analyze_price_promotions(promo_rows)
|
||
|
||
hits = _comment_keyword_hits(comment_rows, focus_words)
|
||
if not hits:
|
||
blob = _merge_comment_previews(merged_rows)
|
||
for w in focus_words:
|
||
if len(w) < 2:
|
||
continue
|
||
n = blob.count(w)
|
||
if n:
|
||
hits[w] += n
|
||
|
||
comment_texts, comment_scores = _iter_comment_text_units_and_scores(
|
||
comment_rows, merged_rows
|
||
)
|
||
sentiment_lex = _comment_sentiment_lexicon(comment_texts, comment_scores)
|
||
scen_counts, scen_n_texts = _comment_scenario_counts(
|
||
comment_texts, scenario_groups
|
||
)
|
||
|
||
feedback_groups = _consumer_feedback_by_matrix_group(
|
||
merged_rows=merged_rows,
|
||
comment_rows=comment_rows,
|
||
sku_header=sku_header,
|
||
)
|
||
matrix_groups_for_exec = _merged_rows_grouped_for_matrix(merged_rows)
|
||
multi_feedback_cat = len(matrix_groups_for_exec) >= 2
|
||
|
||
(
|
||
api_rc,
|
||
api_list_kw,
|
||
api_rc_uniques,
|
||
api_raw_json_n,
|
||
api_rc_n_values,
|
||
) = _pc_search_result_count_from_raw(run_dir)
|
||
|
||
has_external_market = bool(external_rows)
|
||
|
||
lines: list[str] = [
|
||
f"# 竞品分析报告(京东 PC 渠道)",
|
||
"",
|
||
f"> **监测主题**:{keyword} ",
|
||
f"> **数据批次**:{batch} ",
|
||
f"> **报告生成**:自动化草稿,**仅供内部研讨**,不构成市场承诺或投资建议。",
|
||
"",
|
||
"---",
|
||
"",
|
||
"## 一、研究范围、数据来源与局限",
|
||
"",
|
||
"### 1.1 研究范围",
|
||
"",
|
||
f"- **搜索关键词**:「{keyword}」",
|
||
f"- **分析对象**:本次采集流程选取的 **{n_sku}** 个 SKU(搜索排序靠前子样本,非全站普查)。",
|
||
]
|
||
if n_sku:
|
||
n_sku_nop = n_sku - n_sku_pathed
|
||
n_sku_unparsed = n_sku_pathed - n_sku_matrix
|
||
lines.append(
|
||
f"- **细类分析范围**:**{n_sku_matrix}** 个 SKU 具备参与**第五至第八章**分析所需的**商品详情页类目路径**"
|
||
f"(且能读出常见细类名称,如饼干、挂面等);另有 **{n_sku_nop}** 个商品缺少该信息、"
|
||
f"**{n_sku_unparsed}** 个虽有路径但读不出细类名称,**未纳入**细类矩阵与按细类的评价统计。"
|
||
)
|
||
if meta:
|
||
lines.append(
|
||
f"- **搜索列表页**:逻辑第 **{meta.get('page_start')}** 页至第 **{meta.get('page_to')}** 页;"
|
||
f"搜索导出共 **{meta.get('pc_search_export_rows', '—')}** 行(含未深入拉详情的商品)。"
|
||
)
|
||
lines.extend(
|
||
[
|
||
"",
|
||
"### 1.2 数据来源",
|
||
"",
|
||
"- **渠道**:京东 PC 端公开商品列表、商详与评价等可访问数据。",
|
||
"- **可追溯**:原始表格与接口响应保存在本批次任务输出目录,供内部复核;对外分享请脱敏。",
|
||
"",
|
||
"### 1.3 方法说明(指标含义)",
|
||
"",
|
||
"- **价格**:自页面「标价 / 券后价 / 详情价」等抽取的**展示价**,含促销与规格差异,**不等于**出厂价或成本。**第六章** 在具备可用的搜索列表导出时,优先以**列表全量**统计;否则使用**已深入 SKU** 的合并数据;**第六章第一节** 归纳标价与券后价差等**列表侧展示价差信号**(不对卖点/腰带字段做预设关键词扫描)。",
|
||
"- **品牌/店铺集中度(第四章)**:有列表全量时按列表行计店铺与品牌占比;无列表导出时按深入 SKU 合并表估算。",
|
||
"- **评价主题词**:对评价正文做**预设词表子串计数**,非分词主题模型,适合扫方向,**需抽样人工验证**。",
|
||
"- **用途/场景**:对每条评价独立判断是否命中预设场景词;一条可计入多个场景,统计的是「提及该场景的评价条数」而非用户数。",
|
||
(
|
||
"- **用户画像(第八章)**:正负面粗判含**口语短语**级摘录;**第八章第三节**另用分词、词频与主题模型等对评论做**补充分析**(与**第八章第二节**条形图口径不同、互为补充),可选词云并由大模型归纳要点。"
|
||
if _ch8_probe_sec
|
||
else "- **用户画像(第八章)**:正负面粗判含**口语短语**级摘录;关注词与场景**仅按细类**以**同图左右并列**展示(左为关注词命中次数,右为场景占有效文本 **%**);见**第八章第三节**。"
|
||
),
|
||
"- **细类划分(第五至第八章)**:**仅**依据合并表中的**商品详情页类目路径**;该信息缺失或无法读出细类名称的 SKU **不参与**竞品矩阵与按细类评价统计(相关评价条亦**不进入**按细类图表)。",
|
||
"- **检索结果规模**:来自京东 PC 搜索返回的「结果条数」类指标,表示平台侧申报的匹配数量级,**不等于**动销、库存或独立 SKU 数。",
|
||
"",
|
||
"### 1.4 主要局限",
|
||
"",
|
||
"- 仅覆盖 **京东 PC**,不含天猫、抖音、线下、B2B 原料端。",
|
||
"- 样本量由本次抓取上限与搜索页数决定,**结论外推需谨慎**。",
|
||
"- 详情配料与宣称以页面展示为准,**与真实配方可能不一致**(合规与实测另议)。",
|
||
(
|
||
"- **行业零售额、TAM、CAGR 等**:无法从本批次数据推导;本报告已纳入任务中配置的第三方摘录,见 **第三章第五节**。"
|
||
if has_external_market
|
||
else "- **行业零售额、TAM、CAGR 等**:无法从本批次数据推导;本报告未纳入外部摘录(可在任务报告调参中维护市场信息表)。"
|
||
),
|
||
"",
|
||
"---",
|
||
"",
|
||
"## 二、执行摘要(要点)",
|
||
"",
|
||
]
|
||
)
|
||
|
||
exec_bullets: list[str] = []
|
||
exec_bullets.append(
|
||
f"在关键词「{keyword}」下,本次深入分析 **{n_sku}** 个 SKU,关联评价文本 **{n_cmt}** 条。"
|
||
)
|
||
if list_export and cr1_shop is not None and top_shop_s:
|
||
src = f"列表全量 **{n_structure}** 行"
|
||
if cr3_shop is not None:
|
||
exec_bullets.append(
|
||
f"竞争结构({src},第四章):**店铺** 第一大店铺份额 ≈ **{100 * cr1_shop:.1f}%**(「{top_shop_s}」),"
|
||
f"前三店铺合计份额 ≈ **{100 * cr3_shop:.1f}%**(按列表行计,同一 SKU 多行会重复计)。"
|
||
)
|
||
else:
|
||
exec_bullets.append(
|
||
f"竞争结构({src},第四章):**店铺** 第一大店铺份额 ≈ **{100 * cr1_shop:.1f}%**(「{top_shop_s}」)。"
|
||
)
|
||
elif not list_export and cr1_deep is not None and top_brand_deep:
|
||
if cr3_deep is not None:
|
||
exec_bullets.append(
|
||
f"竞争结构(无列表导出,第四章用深入合并表):**品牌** 第一大品牌份额 ≈ **{100 * cr1_deep:.1f}%**(「{top_brand_deep}」),"
|
||
f"前三品牌合计份额 ≈ **{100 * cr3_deep:.1f}%**。"
|
||
)
|
||
else:
|
||
exec_bullets.append(
|
||
f"竞争结构(无列表导出,第四章用深入合并表):**品牌** 第一大品牌份额 ≈ **{100 * cr1_deep:.1f}%**(「{top_brand_deep}」)。"
|
||
)
|
||
if (
|
||
list_export
|
||
and len(brands_for_cr) >= min_brand_rows
|
||
and cr1_list_brand is not None
|
||
and top_list_brand
|
||
):
|
||
if cr3_list_brand is not None:
|
||
exec_bullets.append(
|
||
f"同批列表中**品牌信息有效** **{len(brands_for_cr)}** 条:**品牌** 第一大品牌份额 ≈ **{100 * cr1_list_brand:.1f}%**(「{top_list_brand}」),"
|
||
f"前三品牌合计份额 ≈ **{100 * cr3_list_brand:.1f}%**。"
|
||
)
|
||
else:
|
||
exec_bullets.append(
|
||
f"同批列表中**品牌信息有效** **{len(brands_for_cr)}** 条:**品牌** 第一大品牌份额 ≈ **{100 * cr1_list_brand:.1f}%**(「{top_list_brand}」)。"
|
||
)
|
||
elif list_export and cr1_deep is not None and top_brand_deep and not brands_for_cr:
|
||
exec_bullets.append(
|
||
f"列表导出缺少品牌标题字段,**深入 {n_sku} SKU** 商详品牌第一大品牌份额 ≈ **{100 * cr1_deep:.1f}%**(「{top_brand_deep}」),供与第五章矩阵对照。"
|
||
)
|
||
if pst:
|
||
price_src_short = (
|
||
"(列表全量)"
|
||
if list_export and pst_list.get("n", 0) > 0
|
||
else "(深入样本)"
|
||
)
|
||
exec_bullets.append(
|
||
f"展示价格{price_src_short}:可解析价格 **{pst['n']}** 个观测,区间约 **{pst['min']:.2f}~{pst['max']:.2f}** 元,"
|
||
f"中位数 **{pst.get('median', pst['mean']):.2f}** 元。"
|
||
)
|
||
wb = int(promo_sig.get("rows_with_both_list_and_coupon") or 0)
|
||
sh = promo_sig.get("share_coupon_below_list_when_both")
|
||
med = promo_sig.get("median_discount_pct_when_coupon_below")
|
||
if wb >= 3 and isinstance(sh, (int, float)) and sh >= 0.08 and med is not None:
|
||
exec_bullets.append(
|
||
f"列表侧约 **{100.0 * float(sh):.0f}%** 可对齐行呈现「券后/到手」**低于**「标价」,展示价差中位数约 **{float(med):.1f}%**(**第六章第一节** 活动与话术摘录)。"
|
||
)
|
||
if multi_feedback_cat and (hits or scen_n_texts > 0):
|
||
exec_bullets.append(
|
||
"评价侧写(关注词、用途/场景)已按**第五章同一细类划分**分节,见**第八章第三节**(同图并列)。"
|
||
)
|
||
elif hits:
|
||
top3 = "、".join(f"「{w}」({n})" for w, n in hits.most_common(3))
|
||
exec_bullets.append(f"评价侧写(词频):{top3}。")
|
||
if scen_n_texts > 0 and scen_counts and not multi_feedback_cat:
|
||
top_s = scen_counts.most_common(4)
|
||
frag = ";".join(f"{lbl} **{n}** 条" for lbl, n in top_s)
|
||
exec_bullets.append(f"用途/场景(评价自述,可多选):{frag}(有效文本 **{scen_n_texts}** 条)。")
|
||
if api_rc is not None:
|
||
exec_bullets.append(
|
||
f"PC 搜索返回的检索结果规模约 **{api_rc:,}**(站内匹配条数量级,见第三章第二节;**不是**零售额或动销统计)。"
|
||
)
|
||
for b in exec_bullets:
|
||
lines.append(f"- {b}")
|
||
if not exec_bullets:
|
||
lines.append("- 当前批次可汇总要点较少(以正文各节实际输出为准)。")
|
||
|
||
proxy = _search_list_proxies(search_export_rows) if search_export_rows else {}
|
||
lines.extend(["", "---", "", "## 三、整体市场观察(渠道可见度参考,非官方市场规模)", ""])
|
||
lines.extend(
|
||
[
|
||
"### 3.1 与「市场规模」的区别",
|
||
"",
|
||
"- **官方/行业市场规模**(如全国零售额、品类增速、渗透率)通常来自 **Euromonitor、行业协会、上市公司年报、券商研报** 等;**不能**用京东搜索返回条数或 SKU 数直接等同。",
|
||
"- **第三章第二节** 使用搜索接口返回的**结果条数**;**第三章第三、四节** 描述本次导出的列表行、去重 SKU/店铺及列表价,仅作**参照**,外推全市场需谨慎。",
|
||
"",
|
||
"### 3.2 接口返回的检索规模",
|
||
"",
|
||
]
|
||
)
|
||
if api_rc is not None:
|
||
lines.append(
|
||
f"- 根据本批次保存的搜索原始响应解析:监测词「**{keyword}**」下,平台申报的检索匹配规模约 **{api_rc:,}**。"
|
||
)
|
||
if api_list_kw:
|
||
lines.append(
|
||
f"- 同批响应中的列表关键词:**{api_list_kw}**(可与监测词对照是否一致)。"
|
||
)
|
||
if len(api_rc_uniques) > 1:
|
||
nums = "、".join(f"{u:,}" for u in api_rc_uniques)
|
||
lines.append(
|
||
f"- 注:多份原始响应中该规模字段曾出现不同取值({nums}),正文取**众数** **{api_rc:,}**(共 {api_rc_n_values} 次有效读取)。"
|
||
)
|
||
elif api_raw_json_n > 0:
|
||
lines.append(
|
||
f"- 已扫描 **{api_raw_json_n}** 份原始响应并完成读取。"
|
||
)
|
||
lines.extend(
|
||
[
|
||
"- **含义**:平台对该关键词给出的**检索匹配条数量级**,用于感受站内商品池「宽度」;可能含不同类目/规格条目,**不等于**独立 SKU 数、动销或 GMV,且会随索引与运营策略变化。",
|
||
"",
|
||
]
|
||
)
|
||
else:
|
||
lines.append(
|
||
"*未能从本批次搜索原始响应中解析到有效的检索规模字段(目录缺失、无可用响应或字段为空)。*"
|
||
)
|
||
lines.append("")
|
||
|
||
lines.extend(["### 3.3 搜索列表规模(本次抓取范围内的可见 SKU / 店铺)", ""])
|
||
if proxy.get("total_rows", 0) > 0:
|
||
pmin, pmax = proxy.get("page_span") or (None, None)
|
||
span_txt = (
|
||
f"页码(去重)约 **{pmin}~{pmax}** 页"
|
||
if pmin is not None and pmax is not None
|
||
else "页码字段缺失或无法解析"
|
||
)
|
||
lines.extend(
|
||
[
|
||
f"- **列表导出行数**:**{proxy['total_rows']}** 行。",
|
||
f"- **去重 SKU 数**:**{proxy['unique_skus']}**;**去重店铺数**:**{proxy['unique_shops']}**;{span_txt}。",
|
||
f"- **列表中去重叶子类目代码/片段数**(粗略):**{proxy['unique_leaf_cats']}**(同一关键词下品类宽度的参考)。",
|
||
"",
|
||
]
|
||
)
|
||
lpst = proxy.get("list_price_stats") or {}
|
||
lines.extend(["### 3.4 列表端展示价(全导出,非仅深入样本)", ""])
|
||
if lpst:
|
||
lines.extend(
|
||
[
|
||
f"- 自列表「标价 / 券后价」解析到 **{lpst['n']}** 个数值价;"
|
||
f"区间约 **{lpst['min']:.2f}~{lpst['max']:.2f}** 元,"
|
||
f"中位数 **{float(lpst.get('median', lpst['mean'])):.2f}** 元。",
|
||
"- **说明**:第六章价格统计表已与上表同源(均为列表全量,条件满足时);若正文第六章标注为合并表样本,则因无可用列表价而退化。深入 SKU 的详情价可与列表价对照。",
|
||
"",
|
||
]
|
||
)
|
||
else:
|
||
lines.append("*列表导出中未能解析出数值价格。*")
|
||
lines.append("")
|
||
else:
|
||
lines.append(
|
||
"*未读到可用的搜索列表导出或文件为空;第三章第三、四节无列表侧数据。*"
|
||
)
|
||
lines.append("")
|
||
lines.extend(["### 3.4 列表端展示价(全导出)", "", "*无列表数据。*", ""])
|
||
|
||
if external_rows:
|
||
lines.extend(
|
||
[
|
||
"### 3.5 外部市场规模与行业信息(运行配置摘录)",
|
||
"",
|
||
"以下为本次任务报告调参中维护的**第三方市场摘录**,可与第三章第二节检索规模及第三章第三、四节列表参照对照使用;**指标含义与真实性以原出处为准**。",
|
||
"",
|
||
"| 指标 | 数值与说明 | 来源 | 年份 |",
|
||
"| --- | --- | --- | --- |",
|
||
]
|
||
)
|
||
for a, b, c, d in external_rows:
|
||
lines.append(
|
||
f"| {_md_cell(a, 40)} | {_md_cell(b, 48)} | {_md_cell(c, 36)} | {_md_cell(d, 12)} |"
|
||
)
|
||
lines.append("")
|
||
|
||
ch4_heading = (
|
||
"## 四、市场与竞争结构(PC 搜索列表全量)"
|
||
if list_export
|
||
else "## 四、市场与竞争结构(深入合并表 · 无列表导出)"
|
||
)
|
||
lines.extend(["", "---", "", ch4_heading, ""])
|
||
if list_export:
|
||
lines.append(
|
||
f"基于**搜索列表导出**共 **{n_structure}** 行,与第三章第三节一致;"
|
||
f"集中度按**列表行**计数(同一 SKU 多次曝光则重复计)。"
|
||
)
|
||
else:
|
||
lines.append(
|
||
f"*未读到可用列表全量行,以下退化为**深入 SKU 合并样本** **{n_structure}** 行。*"
|
||
)
|
||
lines.append("")
|
||
|
||
lines.extend(["### 4.1 品牌分布与集中度", ""])
|
||
brand_rows_n = len(brands_for_cr)
|
||
show_list_brand_cr = list_export and brand_rows_n >= min_brand_rows
|
||
show_merged_brand_cr = not list_export and brand_rows_n > 0
|
||
if (show_list_brand_cr or show_merged_brand_cr) and cr1_list_brand is not None:
|
||
lines.extend(
|
||
_embed_chart(
|
||
run_dir,
|
||
"chart_brand_rows_pie.png",
|
||
"品牌列表曝光占比(扇形图;按整理后的品牌名计数,与结构化摘要中的品牌占比统计一致;"
|
||
"长尾并入「(其余品牌)」;扇形内再合并为「其他」)",
|
||
)
|
||
)
|
||
lines.extend(
|
||
_lines_4_reading_brand(
|
||
cr1=cr1_list_brand,
|
||
cr3=cr3_list_brand,
|
||
top=top_list_brand or "",
|
||
brand_rows_n=brand_rows_n,
|
||
n_structure=n_structure,
|
||
)
|
||
)
|
||
lines.append(
|
||
"*更细的品牌行数分布见本任务「结构化摘要」数据包。*"
|
||
)
|
||
elif list_export:
|
||
lines.append(
|
||
f"*列表导出中店铺/品牌标题有效 **{brand_rows_n}** 条,"
|
||
f"低于建议阈值(≥{min_brand_rows}),品牌集中度未展开。**店铺结构见第四章第二节**;"
|
||
f"商详品牌在**第五章**。*"
|
||
)
|
||
else:
|
||
lines.append("*深入子样本无可用品牌字段。*")
|
||
lines.append("")
|
||
|
||
lines.extend(["### 4.2 店铺分布与集中度", ""])
|
||
shop_rows_n = len(shops_for_cr)
|
||
if shop_rows_n:
|
||
lines.extend(
|
||
_embed_chart(
|
||
run_dir,
|
||
"chart_shop_rows_pie.png",
|
||
"店铺列表曝光占比(扇形图;按整理后的店铺名计数,与结构化摘要中的店铺占比统计一致;"
|
||
"长尾并入「(其余店铺)」;扇形内再合并为「其他」)",
|
||
)
|
||
)
|
||
lines.extend(
|
||
_lines_4_reading_shop(
|
||
cr1=cr1_shop,
|
||
cr3=cr3_shop,
|
||
top=top_shop_s or "",
|
||
shop_rows_n=shop_rows_n,
|
||
n_structure=n_structure,
|
||
)
|
||
)
|
||
lines.append(
|
||
"*更细的店铺行数分布见本任务「结构化摘要」数据包。*"
|
||
)
|
||
else:
|
||
lines.append("*无店铺字段。*")
|
||
lines.append("")
|
||
|
||
lines.extend(["### 4.3 细分类目分布(深入合并表 · 与第五章矩阵同一细类划分)", ""])
|
||
if cm_structure and n_sku_matrix > 0:
|
||
lines.extend(
|
||
_embed_chart(
|
||
run_dir,
|
||
"chart_category_mix_pie.png",
|
||
"细类标签分布(扇形图;依据合并表中的商品详情页类目路径,与第五章一致;"
|
||
"Top 12 以外的细类在统计时并入「(其余细类)」;扇形图内再合并为「其他」)",
|
||
)
|
||
)
|
||
lines.append(
|
||
"*完整类目分布见界面「数据摘要」或简报包中的数据文件。*"
|
||
)
|
||
else:
|
||
lines.append(
|
||
"*深入合并表中无具备可解析商品详情页类目路径的 SKU,本小节不展示扇形图;请核对商详抓取与合并字段。*"
|
||
)
|
||
lines.append("")
|
||
|
||
lines.extend(
|
||
[
|
||
"---",
|
||
"",
|
||
"## 五、竞品对比矩阵(按细分类目分组)",
|
||
"",
|
||
"分组**仅**依据合并表中的**商品详情页类目路径**(京东商详中的类目层级):**三级路径**取中间一段(如 … > **饼干** > 粗粮饼干),"
|
||
"**四级及以上**取倒数第二段(如 … > **面条** > 挂面)。**路径缺失**或各段均为内部编码、**读不出常见细类名称**的 SKU **不进入**本矩阵,亦**不参与**第八章按细类的评价统计。",
|
||
"",
|
||
"**读图方式**:每个细类下为**并列横向条形图**(左:**展示价**(元);右:**销量**(来自搜索列表页「已售」等销量文案,如「已售50万+」计为 **50 万**)),"
|
||
"纵轴为**产品标题**(与本节各附图一致)。**SKU、店铺、配料与评价摘要等明细不列入正文**,详见本批次导出的合并数据表。",
|
||
"",
|
||
]
|
||
)
|
||
grouped_matrix = _merged_rows_grouped_for_matrix(merged_rows)
|
||
if not grouped_matrix:
|
||
if merged_rows:
|
||
lines.append(
|
||
"*深入合并表有条目,但均无可用商品详情页类目路径(或路径无法解析为可读细类),故无法生成细类矩阵;"
|
||
"第五至第八章中依赖矩阵的按细类统计相应为空。请核对商详抓取与合并字段。*"
|
||
)
|
||
else:
|
||
lines.append("*无合并表 SKU。*")
|
||
lines.append("")
|
||
for gi, (gname, grows) in enumerate(grouped_matrix):
|
||
lines.append(f"### {gname}(**{len(grows)}** 款)")
|
||
lines.append("")
|
||
mx_chart = _matrix_prices_sales_chart_filename(gname, gi)
|
||
lines.extend(
|
||
_embed_chart(
|
||
run_dir,
|
||
mx_chart,
|
||
f"「{_md_cell(gname, 20)}」· 展示价与销量(页面「已售」销量文案);纵轴为产品标题。",
|
||
)
|
||
)
|
||
if not (run_dir / "report_assets" / mx_chart).is_file():
|
||
lines.append(
|
||
f"*(尚未生成 ``report_assets/{mx_chart}``:请确认已执行报告出图流程,或重新生成报告。)*"
|
||
)
|
||
lines.append("")
|
||
lines.append("")
|
||
|
||
_llm_mx = (llm_matrix_section_md or "").strip()
|
||
if _llm_mx:
|
||
lines.extend(
|
||
[
|
||
"",
|
||
"#### 细类要点归纳(大模型,与上文条形图互补)",
|
||
"",
|
||
"> **说明**:与第五章相同的细类划分下归纳卖点与配料共性;**具体 SKU、价格与条形图以正文为准**,SKU 级明细见合并表 CSV。",
|
||
"",
|
||
_llm_mx,
|
||
"",
|
||
]
|
||
)
|
||
|
||
ch6_price_title = (
|
||
"## 六、价格分析(PC 搜索列表全量)"
|
||
if list_export and pst_list.get("n", 0) > 0
|
||
else "## 六、价格分析(深入 SKU 合并表 · 无可用列表价或未导出列表)"
|
||
)
|
||
lines.extend(["---", "", ch6_price_title, ""])
|
||
lines.append(f"- **统计基础**:{price_analysis_basis_cn}。")
|
||
if (
|
||
list_export
|
||
and pst_list.get("n", 0) > 0
|
||
and pst_merged.get("n", 0) > 0
|
||
):
|
||
lines.append(
|
||
f"- **对照**:合并表深入样本可解析价 **{pst_merged['n']}** 个观测,中位数约 **{float(pst_merged.get('median', pst_merged['mean'])):.2f}** 元(与上表样本范围不同,仅作对照)。"
|
||
)
|
||
lines.append("")
|
||
if pst:
|
||
price_tbl = [
|
||
"| 统计量 | 数值(元) | 说明 |",
|
||
"| --- | --- | --- |",
|
||
f"| 样本量 | {pst['n']} | 与统计基础一致 |",
|
||
f"| 最小值 | {pst['min']:.2f} | |",
|
||
]
|
||
if "q1" in pst:
|
||
price_tbl.append(f"| 下四分位 Q1 | {float(pst['q1']):.2f} | |")
|
||
else:
|
||
price_tbl.append("| 下四分位 Q1 | — | 样本不足 4 个 |")
|
||
price_tbl.append(
|
||
f"| 中位数 | {float(pst.get('median', pst['mean'])):.2f} | |"
|
||
)
|
||
if "q3" in pst:
|
||
price_tbl.append(f"| 上四分位 Q3 | {float(pst['q3']):.2f} | |")
|
||
else:
|
||
price_tbl.append("| 上四分位 Q3 | — | 样本不足 4 个 |")
|
||
price_tbl.extend(
|
||
[
|
||
f"| 最大值 | {pst['max']:.2f} | |",
|
||
f"| 均值 | {pst['mean']:.2f} | |",
|
||
]
|
||
)
|
||
if "stdev" in pst:
|
||
price_tbl.append(f"| 标准差 | {pst['stdev']:.2f} | 离散程度 |")
|
||
lines.extend(price_tbl)
|
||
lines.append("")
|
||
lines.append(
|
||
"**解读提示**:价差大通常反映规格、组合装、品牌溢价或促销差异;B 端定价策略需结合成本与渠道单独建模。"
|
||
)
|
||
lines.append("")
|
||
lines.extend(_markdown_price_promotion_section(promo_sig))
|
||
else:
|
||
lines.append("*当前样本无可用数值价格,本节不展开统计表。*")
|
||
lines.append("")
|
||
lines.extend(_markdown_price_promotion_section(promo_sig))
|
||
lines.append("")
|
||
|
||
_llm_pr = (llm_price_groups_section_md or "").strip()
|
||
if _llm_pr:
|
||
lines.extend(
|
||
[
|
||
"",
|
||
"#### 细类价盘要点归纳(大模型,与第六章量化表互补)",
|
||
"",
|
||
"> **说明**:侧重价带与标价/券后关系的可读叙述;**数值以正文分位数表为准**。",
|
||
"",
|
||
_llm_pr,
|
||
"",
|
||
]
|
||
)
|
||
|
||
_llm_po = (llm_promo_groups_section_md or "").strip()
|
||
if _llm_po:
|
||
lines.extend(
|
||
[
|
||
"",
|
||
"#### 细类促销与活动要点归纳(大模型,与第六章第一节及价盘互补)",
|
||
"",
|
||
"> **说明**:依据合并表「促销摘要」及榜单相关字段(如「榜单排名」「榜单类文案」)等**页面展示摘录**;"
|
||
"不采用列表「卖点/腰带」类字段作归纳依据(多为固定词表匹配,口径偏粗)。归纳券/补贴/新人/榜单曝光等活动形态,**不**替代第五章的配料/宣称归纳。**具体以页面与 CSV 为准**。",
|
||
"",
|
||
_llm_po,
|
||
"",
|
||
]
|
||
)
|
||
|
||
_sm_score = sentiment_lex.get("method") == "score_then_lexeme"
|
||
_sec82_title = (
|
||
"### 8.2 评价正负面粗判(评分优先 + 关键词回退)"
|
||
if _sm_score
|
||
else "### 8.2 评价正负面粗判(关键词规则)"
|
||
)
|
||
_sec82_block: list[str] = [
|
||
"---",
|
||
"",
|
||
"## 八、消费者反馈与用户画像(按细分类目)",
|
||
"",
|
||
"### 8.1 方法",
|
||
"",
|
||
"- **细类划分**:与**第五章「竞品矩阵」**相同,**仅**依据合并表中的**商品详情页类目路径**解析为「饼干 / 西式糕点 / …」等(规则见第五章开头说明)。",
|
||
"- **归因**:每条评价按其 SKU 对应到深入样本,再映射到该 SKU 所属细类;SKU 不在合并表中的评价单独归入说明性分组;**在合并表中但该 SKU 缺少类目路径或读不出细类名称的,该评价不进入按细类统计**(与第五章**同一条排除规则**)。",
|
||
"- **正负面粗判(第八章第二节)**:若评价含有效「评分」列则**先按星级**粗分正负与中评,再在对应子集内统计口语短语;无评分时仍按关键词子串;若任务开启**大模型评价情感分析**,可附**大模型对抽样原文的主题归因**,与条形图互补。",
|
||
(
|
||
"- **文本补充分析(第八章第三节)**:本任务已用中文分词与统计工具做了开放词表分析(词频、关键词突出度、词对共现、主题归纳等,可选词云),与**第八章第二节**规则词表条形图**不同**、**互补**;**不再**输出原「关注词次数 + 场景占比」左右并列条图。"
|
||
if _ch8_probe_sec
|
||
else "- **关注词与使用场景(第八章第三节)**:对组内评价正文做关注词子串计数(左栏条形图);对每条有效文本独立扫描**本次任务生效的场景词组**(来自报告调参或系统默认),一条可属多场景,右栏为**占该细类有效文本比例 %**(多标签下可相加 **>** 100%)。二者在 **同一张图左右并列**,与第五章矩阵细类一一对应。"
|
||
),
|
||
"",
|
||
_sec82_title,
|
||
"",
|
||
f"- **有效文本条数**:{sentiment_lex.get('text_units', 0)}(与第八章第一节**归因规则**一致)。",
|
||
]
|
||
if _sm_score:
|
||
_sec82_block.append(
|
||
"- **正负面粗判规模**:本批存在有效「评分」时——**1~2 星**计为偏负向,**4~5 星**计为偏正向,**3 星**计为中评,**空文本**计为中性;"
|
||
"无评分的条仍按关键词子串划分;「混合」仅在**无评分**且同条兼含正/负关键词时出现。"
|
||
)
|
||
_sec82_block.extend(
|
||
[
|
||
f"- **偏正向**:{sentiment_lex.get('positive_only', 0)} 条"
|
||
+ ("(主要为 4~5 星)" if _sm_score else "(仅命中正向词表)")
|
||
+ ";"
|
||
f"**偏负向**:{sentiment_lex.get('negative_only', 0)} 条"
|
||
+ ("(主要为 1~2 星)" if _sm_score else "(仅命中负向词表)")
|
||
+ ";"
|
||
f"**混合**:{sentiment_lex.get('mixed_positive_and_negative', 0)} 条"
|
||
+ ("(无评分且同条兼含正/负关键词)" if _sm_score else "(同条兼含正/负词)")
|
||
+ ";"
|
||
f"**中性或空文本**:{sentiment_lex.get('neutral_or_empty', 0)} 条"
|
||
+ ("(含 3 星中评及无关键词命中)" if _sm_score else "")
|
||
+ "。",
|
||
"- **说明**:"
|
||
+ (
|
||
"星级与正文可能不一致(如五星长文吐槽);口语短语条形图仅在对应星级子集内统计;正式结论请**人工抽样**阅读原文。"
|
||
if _sm_score
|
||
else "词表为方向性粗判,讽刺、省略与错别字会导致误判;正式结论请**人工抽样**阅读原文。"
|
||
),
|
||
]
|
||
)
|
||
lines.extend(_sec82_block)
|
||
_scope = (sentiment_lex.get("lexeme_scope_note") or "").strip()
|
||
if _scope:
|
||
lines.append(f"- **词根统计说明**:{_scope}")
|
||
lines.extend(["", ""])
|
||
lines.extend(
|
||
_embed_chart(
|
||
run_dir,
|
||
"chart_sentiment_overview_pie.png",
|
||
"评价正负面粗判规模(扇形图;与上表条数一致)",
|
||
)
|
||
)
|
||
lines.extend(
|
||
_embed_chart(
|
||
run_dir,
|
||
"chart_positive_lexemes_bar.png",
|
||
(
|
||
"正向评价里**最常出现的口语短语**(在 **4~5 星** 评价条内统计;条形图)"
|
||
if _sm_score
|
||
else "正向评价里**最常出现的口语短语**(在偏正向或混合评价条内统计;条形图)"
|
||
),
|
||
)
|
||
)
|
||
lines.extend(
|
||
_embed_chart(
|
||
run_dir,
|
||
"chart_negative_lexemes_bar.png",
|
||
(
|
||
"负向评价里**最常出现的口语短语**(在 **1~2 星** 评价条内统计;条形图)"
|
||
if _sm_score
|
||
else "负向评价里**最常出现的口语短语**(在偏负向或混合评价条内统计;条形图)"
|
||
),
|
||
)
|
||
)
|
||
pos_h = sentiment_lex.get("positive_tone_lexeme_hits") or []
|
||
neg_h = sentiment_lex.get("negative_tone_lexeme_hits") or []
|
||
if pos_h:
|
||
frag = ";".join(
|
||
f"「{x.get('word', '')}」{x.get('texts_matched', 0)} 条"
|
||
for x in pos_h[:6]
|
||
if isinstance(x, dict)
|
||
)
|
||
lines.append(f"- **正向语境高频短语(摘要)**:{frag}。")
|
||
if neg_h:
|
||
frag_n = ";".join(
|
||
f"「{x.get('word', '')}」{x.get('texts_matched', 0)} 条"
|
||
for x in neg_h[:6]
|
||
if isinstance(x, dict)
|
||
)
|
||
lines.append(f"- **负向语境高频短语(摘要)**:{frag_n}。")
|
||
_llm_s = (llm_sentiment_section_md or "").strip()
|
||
if _llm_s:
|
||
lines.extend(
|
||
[
|
||
"",
|
||
"#### 大模型深入解读(主题归因,与词频统计互补)",
|
||
"",
|
||
"> **说明**:基于与上节**同一套评分优先或关键词归类规则**抽样的评价原文,由大模型归纳**用户在说什么**(尤其是负向的具体事由),与上列条数、条形图**互补**;引文以原评论为准。",
|
||
"",
|
||
_llm_s,
|
||
]
|
||
)
|
||
lines.append("")
|
||
if _ch8_probe_sec:
|
||
lines.extend(
|
||
[
|
||
"### 8.3 评论文本补充分析(词频、关键词与共现、主题归纳)",
|
||
"",
|
||
"> **说明**:与**第八章第二节**口语短语条形图(规则词表)**口径不同**、**互补**;**不再**输出本章原「关注词 + 场景」左右并列条图;插图位于本批次报告附图文件夹中。",
|
||
"",
|
||
_ch8_probe_sec,
|
||
"",
|
||
]
|
||
)
|
||
else:
|
||
# 仅当未嵌入第八章第三节补充分析(_ch8_probe_sec 为空)时:原「关注词 + 场景」条图与逐细类段落
|
||
lines.extend(
|
||
[
|
||
"### 8.3 关注词与使用场景(按细类)",
|
||
"",
|
||
"每细类一张**左右并列图**(与报告附图文件夹中的 ``chart_focus_and_scenarios_bar__*.png`` 同源):"
|
||
"**左**为配置关注词子串命中次数(同一评价可出现多次,为次数而非去重条数);"
|
||
"**右**为预设场景词组命中占该细类有效文本比例 %(一条可属多场景;多柱比例可相加 **>** 100%)。"
|
||
"统计均基于评价正文(或兜底预览)子串规则,**不等于**购买动机调研结论。",
|
||
"",
|
||
]
|
||
)
|
||
if not feedback_groups:
|
||
lines.append("*无评价数据可归组。*")
|
||
lines.append("")
|
||
else:
|
||
for gi, (gname, cr_g, texts_g) in enumerate(feedback_groups):
|
||
n_flat = len(cr_g)
|
||
lines.append(f"#### {gname}")
|
||
lines.append("")
|
||
lines.append(
|
||
f"- **本细类逐条评价**:{n_flat} 条;**用于统计的有效文本条数**:{len(texts_g)}。"
|
||
)
|
||
lines.append("")
|
||
hits_g = _group_keyword_hits(cr_g, texts_g, focus_words=focus_words)
|
||
scen_g, scen_ng = _comment_scenario_counts(texts_g, scenario_groups)
|
||
has_focus = any(n > 0 for n in hits_g.values()) if hits_g else False
|
||
has_scen = scen_ng > 0 and any(n > 0 for n in scen_g.values())
|
||
if scen_ng <= 0:
|
||
lines.append("*该细类下无可用评价正文。*")
|
||
lines.append("")
|
||
continue
|
||
if has_focus or has_scen:
|
||
cap = (
|
||
f"「{_md_cell(gname, 24)}」细类 · 关注词与使用场景(左:关注词命中次数;右:场景占有效文本 %;"
|
||
f"有效文本 **{scen_ng}** 条)"
|
||
)
|
||
lines.extend(
|
||
_embed_chart(
|
||
run_dir,
|
||
_focus_scenario_combo_bar_filename(gname, gi),
|
||
cap,
|
||
)
|
||
)
|
||
else:
|
||
lines.append("*该细类无关注词命中且未命中预设场景词组。*")
|
||
lines.append("")
|
||
if has_scen:
|
||
for para in _scenario_summary_bullets(scen_g, scen_ng):
|
||
lines.append(para)
|
||
lines.append("")
|
||
elif scen_ng > 0:
|
||
lines.append("*未命中预设场景词组。*")
|
||
lines.append("")
|
||
|
||
_llm_sg = (llm_scenario_groups_section_md or "").strip()
|
||
if _llm_sg:
|
||
lines.extend(
|
||
[
|
||
"",
|
||
"#### 使用场景要点归纳(大模型,与第八章第三节右栏图表互补)",
|
||
"",
|
||
"> **说明**:与第八章第三节**相同**的预设场景词组与子串命中规则;**各场景条数与占比以正文图右栏为准**。",
|
||
"",
|
||
_llm_sg,
|
||
"",
|
||
]
|
||
)
|
||
|
||
_llm_cg = (llm_comment_groups_section_md or "").strip()
|
||
if _llm_cg:
|
||
lines.extend(
|
||
[
|
||
"",
|
||
"#### 细类评价与关注词要点归纳(大模型,与第八章第三节左栏图表互补)",
|
||
"",
|
||
"> **说明**:归纳各细类反馈主题与配置关注词命中;**次数与第八章第三节图左栏以正文为准**。",
|
||
"",
|
||
_llm_cg,
|
||
"",
|
||
]
|
||
)
|
||
|
||
lines.extend(["---", "", "## 九、策略与机会提示(假设清单,待验证)", ""])
|
||
_llm_st = (llm_strategy_opportunities_section_md or "").strip()
|
||
if _llm_st:
|
||
lines.extend(
|
||
[
|
||
"基于本任务结构化摘要(价盘、集中度、评价与场景、促销信号等)的**假设性策略归纳**;数字与明细以前文及 CSV 为准,定稿前请结合贵司成本、渠道与合规复核。",
|
||
"",
|
||
"#### 策略与机会建议(大模型)",
|
||
"",
|
||
_llm_st,
|
||
"",
|
||
]
|
||
)
|
||
else:
|
||
lines.extend(
|
||
[
|
||
"未生成本节大模型正文:请在任务 `report_config` 中开启 `llm_strategy_opportunities` 并重跑产物,或检查 run 目录下 `strategy_opportunities_llm.json` 是否报错。",
|
||
"",
|
||
]
|
||
)
|
||
|
||
lines.extend(
|
||
[
|
||
"---",
|
||
"",
|
||
"## 附录 A:数据留存说明",
|
||
"",
|
||
"- 本批次**任务输出目录**内保存:搜索列表导出、深入 SKU 合并表、商详与评价相关表格,以及搜索/商详原始响应与运行参数快照,供内部复核与复算。",
|
||
"- 对外演示或转发前请按公司规范做**脱敏**处理。",
|
||
"",
|
||
"---",
|
||
"",
|
||
"*本报告由系统自动汇总生成;定稿前请业务交叉核对数据与结论。*",
|
||
"",
|
||
]
|
||
)
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _sanitize_json_numbers(obj: Any) -> Any:
|
||
"""浮点 NaN/Inf 无法 JSON 序列化,统一转 None 或圆角。"""
|
||
if isinstance(obj, float):
|
||
if math.isnan(obj) or math.isinf(obj):
|
||
return None
|
||
return round(obj, 6)
|
||
if isinstance(obj, dict):
|
||
return {k: _sanitize_json_numbers(v) for k, v in obj.items()}
|
||
if isinstance(obj, list):
|
||
return [_sanitize_json_numbers(x) for x in obj]
|
||
return obj
|
||
|
||
|
||
def build_competitor_brief(
|
||
*,
|
||
run_dir: Path,
|
||
keyword: str,
|
||
merged_rows: list[dict[str, str]],
|
||
search_export_rows: list[dict[str, str]],
|
||
comment_rows: list[dict[str, str]],
|
||
meta: dict[str, Any] | None,
|
||
report_config: dict[str, Any] | None = None,
|
||
) -> dict[str, Any]:
|
||
"""
|
||
与 ``build_competitor_markdown`` 共用**同一套统计规则**,输出可 JSON 序列化的结构化竞品摘要(**规则驱动**,无 LLM)。
|
||
"""
|
||
focus_words, scenario_groups, _ext = resolve_report_tuning(report_config)
|
||
sku_header = MERGED_FIELD_TO_CSV_HEADER["sku_id"]
|
||
title_h = MERGED_FIELD_TO_CSV_HEADER["title"]
|
||
batch = _run_batch_label(run_dir)
|
||
n_sku = len(merged_rows)
|
||
n_cmt = len(comment_rows)
|
||
n_sku_matrix = sum(1 for r in merged_rows if _competitor_matrix_group_key(r))
|
||
|
||
list_export = len(search_export_rows) > 0
|
||
structure_rows = search_export_rows if list_export else merged_rows
|
||
n_structure = len(structure_rows)
|
||
shops_s = _structure_shops(structure_rows, list_export=list_export)
|
||
brands_s = _structure_brands(structure_rows, list_export=list_export)
|
||
shops_for_cr = _structure_names_for_pie_counter(shops_s)
|
||
brands_for_cr = _structure_names_for_pie_counter(brands_s)
|
||
cr1_shop, cr3_shop, top_shop_s, top_shop_share = _brand_cr(shops_for_cr)
|
||
cr1_list_brand, cr3_list_brand, top_list_brand, _ = _brand_cr(brands_for_cr)
|
||
cm_structure = _category_mix(merged_rows, top_k=12)
|
||
min_brand_rows = max(5, int(0.02 * n_structure)) if n_structure else 5
|
||
|
||
brands_deep = [
|
||
_cell(r, MERGED_FIELD_TO_CSV_HEADER["detail_brand"], "detail_brand")
|
||
for r in merged_rows
|
||
if _cell(r, MERGED_FIELD_TO_CSV_HEADER["detail_brand"], "detail_brand")
|
||
]
|
||
cr1_deep, cr3_deep, top_brand_deep, top_brand_deep_share = _brand_cr(
|
||
brands_deep
|
||
)
|
||
cr1_hints = cr1_shop if list_export and cr1_shop is not None else cr1_deep
|
||
|
||
pst_merged = _price_stats_extended(_collect_prices(merged_rows))
|
||
pst_list = (
|
||
_price_stats_extended(_collect_prices(search_export_rows))
|
||
if list_export
|
||
else {}
|
||
)
|
||
pst = (
|
||
pst_list
|
||
if list_export and pst_list.get("n", 0) > 0
|
||
else pst_merged
|
||
)
|
||
price_stats_source = (
|
||
"pc_search_export_all_rows"
|
||
if list_export and pst_list.get("n", 0) > 0
|
||
else "keyword_pipeline_merged"
|
||
)
|
||
promo_rows_brief = (
|
||
search_export_rows
|
||
if list_export and pst_list.get("n", 0) > 0
|
||
else merged_rows
|
||
)
|
||
price_promotion_signals = _analyze_price_promotions(promo_rows_brief)
|
||
|
||
hits = _comment_keyword_hits(comment_rows, focus_words)
|
||
if not hits:
|
||
blob = _merge_comment_previews(merged_rows)
|
||
for w in focus_words:
|
||
if len(w) < 2:
|
||
continue
|
||
n = blob.count(w)
|
||
if n:
|
||
hits[w] += n
|
||
|
||
comment_texts, comment_scores = _iter_comment_text_units_and_scores(
|
||
comment_rows, merged_rows
|
||
)
|
||
comment_sentiment_lexicon = _comment_sentiment_lexicon(
|
||
comment_texts, comment_scores
|
||
)
|
||
scen_counts, scen_n_texts = _comment_scenario_counts(
|
||
comment_texts, scenario_groups
|
||
)
|
||
|
||
(
|
||
api_rc,
|
||
api_list_kw,
|
||
api_rc_uniques,
|
||
api_raw_json_n,
|
||
_api_rc_n_values,
|
||
) = _pc_search_result_count_from_raw(run_dir)
|
||
|
||
proxy = _search_list_proxies(search_export_rows) if search_export_rows else {}
|
||
|
||
hints = _strategy_hints(
|
||
cr1=cr1_hints,
|
||
pst=pst,
|
||
hits=hits,
|
||
n_comments=n_cmt,
|
||
scen_counts=scen_counts,
|
||
scen_n_texts=scen_n_texts,
|
||
)
|
||
|
||
matrix_groups: list[dict[str, Any]] = []
|
||
for gname, mrows in _merged_rows_grouped_for_matrix(merged_rows):
|
||
items: list[dict[str, str]] = []
|
||
for row in mrows:
|
||
items.append(
|
||
{
|
||
"sku_id": _cell(row, sku_header),
|
||
"title": _cell(row, title_h),
|
||
"brand": _cell(
|
||
row,
|
||
MERGED_FIELD_TO_CSV_HEADER["detail_brand"],
|
||
"detail_brand",
|
||
),
|
||
"list_price_show": _cell(
|
||
row, *_LIST_SHOW_PRICE_CELL_KEYS
|
||
),
|
||
"coupon_or_detail_price": _cell(
|
||
row,
|
||
_COUPON_SHOW_PRICE_KEY,
|
||
_LEGACY_COUPON_SHOW_PRICE_KEY,
|
||
),
|
||
"detail_price_final": _cell(row, *_DETAIL_PRICE_FINAL_CSV_KEYS),
|
||
"shop": _cell(row, *_MERGED_SHOP_CELL_KEYS),
|
||
"category": _detail_category_path_cell(row),
|
||
"selling_point": _cell(
|
||
row, _SELLING_POINT_KEY, _LEGACY_SELLING_POINT_KEY
|
||
)[:240],
|
||
"comment_fuzzy": _cell(row, *_COMMENT_FUZZ_KEYS),
|
||
"total_sales": merged_csv_effective_total_sales(row),
|
||
}
|
||
)
|
||
matrix_groups.append(
|
||
{"group": gname, "sku_count": len(items), "skus": items}
|
||
)
|
||
|
||
feedback_by_group: list[dict[str, Any]] = []
|
||
usage_scenarios_by_matrix_group: list[dict[str, Any]] = []
|
||
for gi, (gname, cr, tu) in enumerate(
|
||
_consumer_feedback_by_matrix_group(
|
||
merged_rows=merged_rows,
|
||
comment_rows=comment_rows,
|
||
sku_header=sku_header,
|
||
)
|
||
):
|
||
gh = _group_keyword_hits(cr, tu, focus_words=focus_words)
|
||
scen_g, scen_n_g = _comment_scenario_counts(tu, scenario_groups)
|
||
slug_fb = _scenario_group_asset_slug(gname, gi)
|
||
feedback_by_group.append(
|
||
{
|
||
"group": gname,
|
||
"matrix_group_index": gi,
|
||
"chart_slug": slug_fb,
|
||
"comment_rows": len(cr),
|
||
"effective_comment_text_units": len(tu),
|
||
"focus_keyword_hits": [
|
||
{"word": w, "count": n} for w, n in gh.most_common(24)
|
||
],
|
||
"scenarios_top": [
|
||
{
|
||
"scenario": s,
|
||
"count": n,
|
||
"share_of_text_units": (
|
||
n / scen_n_g if scen_n_g else 0.0
|
||
),
|
||
}
|
||
for s, n in scen_g.most_common(6)
|
||
]
|
||
if scen_n_g
|
||
else [],
|
||
}
|
||
)
|
||
if scen_n_g > 0 and scen_g:
|
||
usage_scenarios_by_matrix_group.append(
|
||
{
|
||
"group": gname,
|
||
"matrix_group_index": gi,
|
||
"chart_slug": slug_fb,
|
||
"effective_text_units": scen_n_g,
|
||
"scenarios": [
|
||
{
|
||
"scenario": s,
|
||
"count": int(n),
|
||
"share_of_text_units": (
|
||
float(n) / scen_n_g if scen_n_g else 0.0
|
||
),
|
||
}
|
||
for s, n in scen_g.most_common()
|
||
if n > 0
|
||
],
|
||
}
|
||
)
|
||
|
||
meta_slice: dict[str, Any] = {}
|
||
if meta:
|
||
for k in (
|
||
"page_start",
|
||
"page_to",
|
||
"max_skus_config",
|
||
"pc_search_export_rows",
|
||
"merged_rows",
|
||
"scenario_filter_enabled",
|
||
"merged_csv_mode",
|
||
):
|
||
if k in meta:
|
||
meta_slice[k] = meta[k]
|
||
|
||
list_brand_block: dict[str, Any] | None
|
||
if len(brands_for_cr) >= min_brand_rows:
|
||
list_brand_block = {
|
||
"first_share": cr1_list_brand,
|
||
"top_three_combined_share": cr3_list_brand,
|
||
"top_label": top_list_brand,
|
||
}
|
||
else:
|
||
list_brand_block = None
|
||
|
||
out: dict[str, Any] = {
|
||
"schema_version": 1,
|
||
"keyword": keyword,
|
||
"batch_label": batch,
|
||
"run_dir": str(run_dir.resolve()),
|
||
"scope": {
|
||
"merged_sku_count": n_sku,
|
||
"comment_flat_rows": n_cmt,
|
||
"structure_source_rows": n_structure,
|
||
"uses_pc_search_list_export": list_export,
|
||
"category_mix_source": "keyword_pipeline_merged",
|
||
"category_mix_valid_matrix_sku_count": n_sku_matrix,
|
||
},
|
||
"meta": meta_slice or None,
|
||
"pc_search_raw": {
|
||
"result_count_consensus": api_rc,
|
||
"list_keyword": api_list_kw or None,
|
||
"result_count_uniques": api_rc_uniques,
|
||
"raw_json_files_scanned": api_raw_json_n,
|
||
},
|
||
"list_visibility_proxy": proxy,
|
||
"concentration": {
|
||
"shops_from_list": {
|
||
"first_share": cr1_shop,
|
||
"top_three_combined_share": cr3_shop,
|
||
"top_label": top_shop_s,
|
||
"top_share_pct": top_shop_share,
|
||
},
|
||
"list_brand_field": list_brand_block,
|
||
"detail_brand_among_merged": {
|
||
"first_share": cr1_deep,
|
||
"top_three_combined_share": cr3_deep,
|
||
"top_label": top_brand_deep,
|
||
"top_share_pct": top_brand_deep_share,
|
||
},
|
||
},
|
||
"category_mix_top": [
|
||
{"label": lbl, "count": cnt} for lbl, cnt in cm_structure
|
||
],
|
||
"list_brand_mix_top": [
|
||
{"label": k, "count": v}
|
||
for k, v in _counter_mix_top_rows_with_remainder(
|
||
brands_s,
|
||
top_n=24,
|
||
remainder_label="(其余品牌)",
|
||
)
|
||
],
|
||
"list_shop_mix_top": [
|
||
{"label": k, "count": v}
|
||
for k, v in _counter_mix_top_rows_with_remainder(
|
||
shops_s,
|
||
top_n=24,
|
||
remainder_label="(其余店铺)",
|
||
)
|
||
],
|
||
"price_stats": pst,
|
||
"price_stats_source": price_stats_source,
|
||
"price_stats_merged_sample": pst_merged,
|
||
"price_stats_list_export": pst_list if list_export else {},
|
||
"price_promotion_signals": price_promotion_signals,
|
||
"comment_focus_keywords": [
|
||
{"word": w, "count": n} for w, n in hits.most_common(24)
|
||
],
|
||
"usage_scenarios": [
|
||
{
|
||
"scenario": lbl,
|
||
"count": n,
|
||
"share_of_text_units": (
|
||
n / scen_n_texts if scen_n_texts else 0.0
|
||
),
|
||
}
|
||
for lbl, n in scen_counts.most_common(16)
|
||
],
|
||
"usage_scenarios_denominator": scen_n_texts,
|
||
"usage_scenarios_by_matrix_group": usage_scenarios_by_matrix_group,
|
||
"strategy_hints": hints,
|
||
"matrix_by_group": matrix_groups,
|
||
"consumer_feedback_by_matrix_group": feedback_by_group,
|
||
"comment_sentiment_lexicon": comment_sentiment_lexicon,
|
||
"notes": [
|
||
"与在线分析报告各章**计数规则**一致;关注词与场景以任务中的分析规则为准(子串命中统计,非深度主题模型)。",
|
||
"价格来自页面展示字段抽取,含促销与规格差异;促销与标价对齐等为启发式摘录,仅供对照。",
|
||
"评价语气为关键词粗判,非深度学习情感模型。",
|
||
"「集中度」中:最大一家占比、前三名合计占比为小数(如 0.12 表示约 12%),对应列表或深入样本中的相关行。",
|
||
],
|
||
}
|
||
return _sanitize_json_numbers(out)
|
||
|
||
|
||
def main() -> None:
|
||
try:
|
||
if hasattr(sys.stdout, "reconfigure"):
|
||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||
if hasattr(sys.stderr, "reconfigure"):
|
||
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
||
except Exception:
|
||
pass
|
||
|
||
existing = _resolve_existing_run_dir(EXISTING_RUN_DIR)
|
||
meta_path_early = (existing / kpl.FILE_RUN_META_JSON) if existing else None
|
||
meta_early: dict[str, Any] | None = None
|
||
if meta_path_early and meta_path_early.is_file():
|
||
try:
|
||
meta_early = json.loads(meta_path_early.read_text(encoding="utf-8"))
|
||
except json.JSONDecodeError:
|
||
meta_early = None
|
||
|
||
if existing:
|
||
if not existing.is_dir():
|
||
print(f"[竞品报告] EXISTING_RUN_DIR 不是目录: {existing}", file=sys.stderr)
|
||
sys.exit(2)
|
||
kw = (KEYWORD or "").strip() or _infer_keyword(existing, meta_early)
|
||
if not kw:
|
||
print(
|
||
"[竞品报告] 仅分析已有目录时,请配置 KEYWORD,或保留 run_meta.json 的 keyword,"
|
||
"或使目录名为 YYYYMMDD_HHMMSS_关键词",
|
||
file=sys.stderr,
|
||
)
|
||
sys.exit(2)
|
||
run_dir = existing
|
||
print(f"[竞品报告] 使用已有目录(不抓取): {run_dir}", file=sys.stderr)
|
||
else:
|
||
kw = (KEYWORD or "").strip()
|
||
if not kw:
|
||
print("[竞品报告] 全量抓取时请在本文件顶部配置 KEYWORD", file=sys.stderr)
|
||
sys.exit(2)
|
||
|
||
backup: dict[str, Any] = {}
|
||
try:
|
||
if OVERRIDE_MAX_SKUS is not None:
|
||
backup["MAX_SKUS"] = kpl.MAX_SKUS
|
||
kpl.MAX_SKUS = max(1, int(OVERRIDE_MAX_SKUS))
|
||
if OVERRIDE_PAGE_START is not None:
|
||
backup["PAGE_START"] = kpl.PAGE_START
|
||
kpl.PAGE_START = max(1, int(OVERRIDE_PAGE_START))
|
||
if OVERRIDE_PAGE_TO is not None:
|
||
backup["PAGE_TO"] = kpl.PAGE_TO
|
||
kpl.PAGE_TO = max(1, int(OVERRIDE_PAGE_TO))
|
||
|
||
print(f"[竞品报告] 关键词={kw!r},开始流水线…", file=sys.stderr)
|
||
run_dir = kpl.main(keyword=kw)
|
||
finally:
|
||
for name, val in backup.items():
|
||
setattr(kpl, name, val)
|
||
|
||
merged_path = run_dir / kpl.FILE_MERGED_CSV
|
||
comments_path = run_dir / kpl.FILE_COMMENTS_FLAT_CSV
|
||
meta_path = run_dir / kpl.FILE_RUN_META_JSON
|
||
|
||
_, merged_rows = _read_csv_rows(merged_path)
|
||
_, search_export_rows = _read_csv_rows(run_dir / kpl.FILE_PC_SEARCH_CSV)
|
||
_, comment_rows = _read_csv_rows(comments_path)
|
||
meta: dict[str, Any] | None = meta_early if existing else None
|
||
if meta is None and meta_path.is_file():
|
||
try:
|
||
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
||
except json.JSONDecodeError:
|
||
meta = None
|
||
|
||
md = build_competitor_markdown(
|
||
run_dir=run_dir,
|
||
keyword=kw,
|
||
merged_rows=merged_rows,
|
||
search_export_rows=search_export_rows,
|
||
comment_rows=comment_rows,
|
||
meta=meta,
|
||
)
|
||
out_md = run_dir / "competitor_analysis.md"
|
||
out_md.write_text(md, encoding="utf-8")
|
||
print(f"[竞品报告] 运行目录: {run_dir}", file=sys.stderr)
|
||
print(f"[竞品报告] 已写: {out_md}", file=sys.stderr)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|