mirror of
https://github.com/primedigitaltech/market-assistant.git
synced 2026-07-21 23:41:39 +08:00
refactor(jd): 矩阵分组、价统与 LLM 分组载荷拆至 competitor_report
新增 matrix_group(复用 pipeline.matrix_group_label)、price_stats、ingredients、llm_group_payloads;jd_competitor_report 仅保留报告编排与 Markdown;更新 matrix_group_label 模块说明。 Made-with: Cursor
This commit is contained in:
parent
f2a2f50ded
commit
8bbb921552
@ -0,0 +1,42 @@
|
|||||||
|
"""配料列清洗:与 ``AI_crawler.normalize_ingredients_text_for_csv`` 等口径对齐。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def _is_ingredient_url_blob(s: str) -> bool:
|
||||||
|
"""详情主图 URL 串(分号分隔)或单列以 http 开头。"""
|
||||||
|
t = (s or "").strip()
|
||||||
|
if not t:
|
||||||
|
return False
|
||||||
|
if t.startswith(("http://", "https://")):
|
||||||
|
return True
|
||||||
|
head = t[:400]
|
||||||
|
if ("https://" in head or "http://" in head) and (
|
||||||
|
";" in t or len(t) > 180 or t.count("http") >= 2
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _ingredients_from_product_attributes(attrs: str) -> str:
|
||||||
|
m = re.search(r"配料(?:表)?[::]\s*([^;;]+)", attrs or "")
|
||||||
|
return m.group(1).strip() if m else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _ingredients_single_line(s: str) -> str:
|
||||||
|
"""与 ``AI_crawler.normalize_ingredients_text_for_csv`` 一致:多行配料压成一行(行间 ``;``),便于表格/CSV。"""
|
||||||
|
t = (s or "").replace("\r\n", "\n").replace("\r", "\n").strip()
|
||||||
|
if not t:
|
||||||
|
return ""
|
||||||
|
lines = [ln.strip() for ln in t.split("\n") if ln.strip()]
|
||||||
|
if len(lines) <= 1:
|
||||||
|
return lines[0] if lines else ""
|
||||||
|
return ";".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"_ingredients_from_product_attributes",
|
||||||
|
"_ingredients_single_line",
|
||||||
|
"_is_ingredient_url_blob",
|
||||||
|
]
|
||||||
@ -0,0 +1,402 @@
|
|||||||
|
"""按细类矩阵分组的 LLM 载荷(矩阵/价盘/促销/评价/场景)。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import Counter
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pipeline.csv_schema import JD_SEARCH_CSV_HEADERS, MERGED_FIELD_TO_CSV_HEADER
|
||||||
|
|
||||||
|
from .comment_sentiment import _comment_keyword_hits
|
||||||
|
from .constants import (
|
||||||
|
_COMMENT_CSV_BODY,
|
||||||
|
_COMMENT_CSV_SKU,
|
||||||
|
_COUPON_SHOW_PRICE_KEY,
|
||||||
|
_DETAIL_PRICE_FINAL_CSV_KEYS,
|
||||||
|
_LEGACY_COUPON_SHOW_PRICE_KEY,
|
||||||
|
_LEGACY_RANK_TAGLINE_KEY,
|
||||||
|
_LEGACY_SELLING_POINT_KEY,
|
||||||
|
_LIST_SHOW_PRICE_CELL_KEYS,
|
||||||
|
_MERGED_SHOP_CELL_KEYS,
|
||||||
|
_RANK_TAGLINE_KEY,
|
||||||
|
_SELLING_POINT_KEY,
|
||||||
|
)
|
||||||
|
from .csv_io import _cell, _collect_prices, _md_cell
|
||||||
|
from .ingredients import _ingredients_from_product_attributes, _ingredients_single_line
|
||||||
|
from .matrix_group import _competitor_matrix_group_key, _merged_rows_grouped_for_matrix
|
||||||
|
from .price_stats import _price_stats_extended
|
||||||
|
|
||||||
|
|
||||||
|
def _comment_scenario_counts(
|
||||||
|
texts: list[str],
|
||||||
|
scenario_groups: tuple[tuple[str, tuple[str, ...]], ...],
|
||||||
|
) -> tuple[Counter[str], int]:
|
||||||
|
"""每组统计「至少命中一个触发词」的条数。返回 (各组条数, 有效文本条数)。"""
|
||||||
|
c: Counter[str] = Counter()
|
||||||
|
n = len(texts)
|
||||||
|
for blob in texts:
|
||||||
|
for label, triggers in scenario_groups:
|
||||||
|
if any(t in blob for t in triggers):
|
||||||
|
c[label] += 1
|
||||||
|
return c, n
|
||||||
|
|
||||||
|
|
||||||
|
def _text_hits_scenario_triggers(
|
||||||
|
text: str,
|
||||||
|
scenario_groups: tuple[tuple[str, tuple[str, ...]], ...],
|
||||||
|
) -> bool:
|
||||||
|
blob = text or ""
|
||||||
|
for _lbl, triggers in scenario_groups:
|
||||||
|
if any(t in blob for t in triggers):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _group_keyword_hits(
|
||||||
|
comment_rows_in_group: list[dict[str, str]],
|
||||||
|
texts_fallback: list[str],
|
||||||
|
*,
|
||||||
|
focus_words: tuple[str, ...],
|
||||||
|
) -> Counter[str]:
|
||||||
|
h = _comment_keyword_hits(comment_rows_in_group, focus_words)
|
||||||
|
if h:
|
||||||
|
return h
|
||||||
|
if not texts_fallback:
|
||||||
|
return Counter()
|
||||||
|
blob = "\n".join(texts_fallback)
|
||||||
|
c: Counter[str] = Counter()
|
||||||
|
for w in focus_words:
|
||||||
|
if len(w) < 2:
|
||||||
|
continue
|
||||||
|
n = blob.count(w)
|
||||||
|
if n:
|
||||||
|
c[w] += n
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def _matrix_excerpt_line_for_llm(row: dict[str, str], title_h: str) -> str:
|
||||||
|
title = _md_cell(_cell(row, title_h), 100)
|
||||||
|
sp = _md_cell(_cell(row, _SELLING_POINT_KEY, _LEGACY_SELLING_POINT_KEY), 120)
|
||||||
|
ing_raw = _ingredients_from_product_attributes(
|
||||||
|
_cell(
|
||||||
|
row,
|
||||||
|
MERGED_FIELD_TO_CSV_HEADER["detail_product_attributes"],
|
||||||
|
"detail_product_attributes",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ing = _md_cell(_ingredients_single_line(ing_raw), 100) if ing_raw else ""
|
||||||
|
chunks: list[str] = []
|
||||||
|
if title:
|
||||||
|
chunks.append(title)
|
||||||
|
if sp:
|
||||||
|
chunks.append(f"卖点:{sp}")
|
||||||
|
if ing:
|
||||||
|
chunks.append(f"配料:{ing}")
|
||||||
|
return "|".join(chunks) if chunks else "(无标题摘录)"
|
||||||
|
|
||||||
|
|
||||||
|
def _listing_price_snippet_for_llm(row: dict[str, str], title_h: str) -> str:
|
||||||
|
title = _md_cell(_cell(row, title_h), 72)
|
||||||
|
lp = _cell(row, *_LIST_SHOW_PRICE_CELL_KEYS)
|
||||||
|
cp = _cell(row, _COUPON_SHOW_PRICE_KEY, _LEGACY_COUPON_SHOW_PRICE_KEY)
|
||||||
|
dp = _cell(row, *_DETAIL_PRICE_FINAL_CSV_KEYS)
|
||||||
|
return f"{title}|标价:{lp}|券后:{cp}|详情价:{dp}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_matrix_groups_llm_payload(
|
||||||
|
merged_rows: list[dict[str, str]],
|
||||||
|
*,
|
||||||
|
title_h: str,
|
||||||
|
sku_header: str = "",
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""供 ``generate_matrix_group_summaries_llm``:与 §5 细类划分一致。"""
|
||||||
|
_ = sku_header
|
||||||
|
if not merged_rows:
|
||||||
|
return []
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for gname, grows in _merged_rows_grouped_for_matrix(merged_rows):
|
||||||
|
prices = _collect_prices(grows)
|
||||||
|
pst = _price_stats_extended(prices) if prices else {"n": 0}
|
||||||
|
lines = [_matrix_excerpt_line_for_llm(r, title_h) for r in grows[:24]]
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"group": gname,
|
||||||
|
"sku_count": len(grows),
|
||||||
|
"price_stats": pst,
|
||||||
|
"lines": lines,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def build_price_groups_llm_payload(
|
||||||
|
merged_rows: list[dict[str, str]],
|
||||||
|
*,
|
||||||
|
title_h: str,
|
||||||
|
sku_header: str = "",
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""供 ``generate_price_group_summaries_llm``。"""
|
||||||
|
_ = sku_header
|
||||||
|
if not merged_rows:
|
||||||
|
return []
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for gname, grows in _merged_rows_grouped_for_matrix(merged_rows):
|
||||||
|
prices = _collect_prices(grows)
|
||||||
|
pst = _price_stats_extended(prices) if prices else {"n": 0}
|
||||||
|
snippets = [_listing_price_snippet_for_llm(r, title_h) for r in grows[:16]]
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"group": gname,
|
||||||
|
"sku_count": len(grows),
|
||||||
|
"price_stats": pst,
|
||||||
|
"listing_snippets": snippets,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _promo_snippet_for_llm(row: dict[str, str], title_h: str) -> str:
|
||||||
|
"""单条 SKU:合并表「促销摘要」「榜单排名」「榜单类文案」摘录,供促销 LLM 用(不含列表卖点/腰带列,避免固定词表匹配的粗口径)。"""
|
||||||
|
title = _md_cell(_cell(row, title_h), 56)
|
||||||
|
promo = _cell(
|
||||||
|
row,
|
||||||
|
MERGED_FIELD_TO_CSV_HEADER["buyer_promo_text"],
|
||||||
|
"buyer_promo_text",
|
||||||
|
)
|
||||||
|
br = _cell(
|
||||||
|
row,
|
||||||
|
MERGED_FIELD_TO_CSV_HEADER["buyer_ranking_line"],
|
||||||
|
"buyer_ranking_line",
|
||||||
|
)
|
||||||
|
belt = _cell(row, _RANK_TAGLINE_KEY, _LEGACY_RANK_TAGLINE_KEY)
|
||||||
|
parts: list[str] = [title]
|
||||||
|
if promo.strip():
|
||||||
|
parts.append(
|
||||||
|
f"{MERGED_FIELD_TO_CSV_HEADER['buyer_promo_text']}:{_md_cell(promo, 360)}"
|
||||||
|
)
|
||||||
|
if br.strip():
|
||||||
|
parts.append(
|
||||||
|
f"{MERGED_FIELD_TO_CSV_HEADER['buyer_ranking_line']}:{_md_cell(br, 120)}"
|
||||||
|
)
|
||||||
|
if belt.strip():
|
||||||
|
parts.append(
|
||||||
|
f"{JD_SEARCH_CSV_HEADERS['hot_list_rank']}:{_md_cell(belt, 80)}"
|
||||||
|
)
|
||||||
|
return "|".join(parts) if len(parts) > 1 else (parts[0] if parts else "")
|
||||||
|
|
||||||
|
|
||||||
|
def build_promo_groups_llm_payload(
|
||||||
|
merged_rows: list[dict[str, str]],
|
||||||
|
*,
|
||||||
|
title_h: str,
|
||||||
|
sku_header: str = "",
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""供 ``generate_promo_group_summaries_llm``:与 §5/§6 细类划分一致。"""
|
||||||
|
_ = sku_header
|
||||||
|
if not merged_rows:
|
||||||
|
return []
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for gname, grows in _merged_rows_grouped_for_matrix(merged_rows):
|
||||||
|
snippets = [_promo_snippet_for_llm(r, title_h) for r in grows[:16]]
|
||||||
|
nonempty = sum(
|
||||||
|
1
|
||||||
|
for r in grows
|
||||||
|
if _cell(
|
||||||
|
r,
|
||||||
|
MERGED_FIELD_TO_CSV_HEADER["buyer_promo_text"],
|
||||||
|
"buyer_promo_text",
|
||||||
|
).strip()
|
||||||
|
)
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"group": gname,
|
||||||
|
"sku_count": len(grows),
|
||||||
|
"rows_with_buyer_promo_text": nonempty,
|
||||||
|
"promo_snippets": snippets,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def build_comment_groups_llm_payload(
|
||||||
|
*,
|
||||||
|
feedback_groups: list[tuple[str, list[dict[str, str]], list[str]]],
|
||||||
|
focus_words: tuple[str, ...],
|
||||||
|
merged_rows: list[dict[str, str]],
|
||||||
|
sku_header: str,
|
||||||
|
title_h: str,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""供 ``generate_comment_group_summaries_llm``。"""
|
||||||
|
if not feedback_groups:
|
||||||
|
return []
|
||||||
|
sku_meta: dict[str, tuple[str, str, str]] = {}
|
||||||
|
for row in merged_rows:
|
||||||
|
sku = _cell(row, sku_header).strip()
|
||||||
|
if not sku:
|
||||||
|
continue
|
||||||
|
gk = _competitor_matrix_group_key(row)
|
||||||
|
if not gk:
|
||||||
|
continue
|
||||||
|
sku_meta[sku] = (
|
||||||
|
gk,
|
||||||
|
_cell(row, title_h),
|
||||||
|
_cell(row, *_MERGED_SHOP_CELL_KEYS),
|
||||||
|
)
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for gname, cr, tu in feedback_groups:
|
||||||
|
if not tu and not cr:
|
||||||
|
continue
|
||||||
|
gh = _group_keyword_hits(cr, tu, focus_words=focus_words)
|
||||||
|
focus_hit_lines = [
|
||||||
|
f"「{w}」{n} 次" for w, n in gh.most_common(14) if n > 0
|
||||||
|
]
|
||||||
|
snippets: list[str] = []
|
||||||
|
for row in cr[:48]:
|
||||||
|
txt = _cell(row, _COMMENT_CSV_BODY, "tagCommentContent")
|
||||||
|
if not txt:
|
||||||
|
continue
|
||||||
|
sku = _cell(row, _COMMENT_CSV_SKU, "sku").strip()
|
||||||
|
meta = sku_meta.get(sku)
|
||||||
|
if meta:
|
||||||
|
sg, tit, shop = meta
|
||||||
|
prefix = (
|
||||||
|
f"【细类:{sg}|SKU:{sku}|品名:{_md_cell(tit, 60)}|"
|
||||||
|
f"店铺:{_md_cell(shop, 28)}】"
|
||||||
|
)
|
||||||
|
snippets.append(prefix + txt[:300])
|
||||||
|
else:
|
||||||
|
snippets.append(
|
||||||
|
f"【细类:{gname}|SKU:{sku or '—'}】" + txt[:320]
|
||||||
|
)
|
||||||
|
if len(snippets) >= 16:
|
||||||
|
break
|
||||||
|
eff = tu[:28]
|
||||||
|
if len(tu) > 28:
|
||||||
|
eff = list(eff) + [f"…共 {len(tu)} 条有效文本,此处截断"]
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"group": gname,
|
||||||
|
"comment_flat_rows": f"评价行 {len(cr)};有效文本单元 {len(tu)}",
|
||||||
|
"effective_text_lines": eff,
|
||||||
|
"focus_hit_lines": focus_hit_lines,
|
||||||
|
"sample_text_snippets": snippets,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def build_scenario_groups_llm_payload(
|
||||||
|
*,
|
||||||
|
feedback_groups: list[tuple[str, list[dict[str, str]], list[str]]],
|
||||||
|
scenario_groups: tuple[tuple[str, tuple[str, ...]], ...],
|
||||||
|
merged_rows: list[dict[str, str]],
|
||||||
|
sku_header: str,
|
||||||
|
title_h: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""供 ``generate_scenario_group_summaries_llm``;计数与 §8.3 图右栏(场景)一致。"""
|
||||||
|
if not feedback_groups:
|
||||||
|
return {}
|
||||||
|
sku_meta: dict[str, tuple[str, str, str]] = {}
|
||||||
|
for row in merged_rows:
|
||||||
|
sku = _cell(row, sku_header).strip()
|
||||||
|
if not sku:
|
||||||
|
continue
|
||||||
|
gk = _competitor_matrix_group_key(row)
|
||||||
|
if not gk:
|
||||||
|
continue
|
||||||
|
sku_meta[sku] = (
|
||||||
|
gk,
|
||||||
|
_cell(row, title_h),
|
||||||
|
_cell(row, *_MERGED_SHOP_CELL_KEYS),
|
||||||
|
)
|
||||||
|
lexicon = [
|
||||||
|
{"label": lbl, "trigger_examples": list(trigs[:12])}
|
||||||
|
for lbl, trigs in scenario_groups
|
||||||
|
]
|
||||||
|
groups_out: list[dict[str, Any]] = []
|
||||||
|
for gname, cr, tu in feedback_groups:
|
||||||
|
if not tu and not cr:
|
||||||
|
continue
|
||||||
|
scen_g, scen_ng = _comment_scenario_counts(tu, scenario_groups)
|
||||||
|
dist: list[dict[str, Any]] = []
|
||||||
|
for lbl, n in scen_g.most_common():
|
||||||
|
if n <= 0:
|
||||||
|
continue
|
||||||
|
dist.append(
|
||||||
|
{
|
||||||
|
"scenario": lbl,
|
||||||
|
"mention_rows": int(n),
|
||||||
|
"share_of_effective_texts": round(
|
||||||
|
float(n) / float(scen_ng), 4
|
||||||
|
)
|
||||||
|
if scen_ng > 0
|
||||||
|
else 0.0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
snippets: list[str] = []
|
||||||
|
for row in cr:
|
||||||
|
txt = _cell(row, _COMMENT_CSV_BODY, "tagCommentContent")
|
||||||
|
if not txt:
|
||||||
|
continue
|
||||||
|
if not _text_hits_scenario_triggers(txt, scenario_groups):
|
||||||
|
continue
|
||||||
|
sku = _cell(row, _COMMENT_CSV_SKU, "sku").strip()
|
||||||
|
meta = sku_meta.get(sku)
|
||||||
|
if meta:
|
||||||
|
sg, tit, shop = meta
|
||||||
|
prefix = (
|
||||||
|
f"【细类:{sg}\uff5cSKU:{sku}\uff5c品名:{_md_cell(tit, 60)}\uff5c"
|
||||||
|
f"店铺:{_md_cell(shop, 28)}】"
|
||||||
|
)
|
||||||
|
snippets.append(prefix + txt[:300])
|
||||||
|
else:
|
||||||
|
snippets.append(
|
||||||
|
f"【细类:{gname}\uff5cSKU:{sku or '—'}】" + txt[:320]
|
||||||
|
)
|
||||||
|
if len(snippets) >= 16:
|
||||||
|
break
|
||||||
|
if len(snippets) < 5:
|
||||||
|
for row in cr:
|
||||||
|
txt = _cell(row, _COMMENT_CSV_BODY, "tagCommentContent")
|
||||||
|
if not txt:
|
||||||
|
continue
|
||||||
|
sku = _cell(row, _COMMENT_CSV_SKU, "sku").strip()
|
||||||
|
meta = sku_meta.get(sku)
|
||||||
|
if meta:
|
||||||
|
sg, tit, shop = meta
|
||||||
|
prefix = (
|
||||||
|
f"【细类:{sg}\uff5cSKU:{sku}\uff5c品名:{_md_cell(tit, 60)}\uff5c"
|
||||||
|
f"店铺:{_md_cell(shop, 28)}】"
|
||||||
|
)
|
||||||
|
snippets.append(prefix + txt[:260])
|
||||||
|
else:
|
||||||
|
snippets.append(
|
||||||
|
f"【细类:{gname}\uff5cSKU:{sku or '—'}】" + txt[:280]
|
||||||
|
)
|
||||||
|
if len(snippets) >= 10:
|
||||||
|
break
|
||||||
|
groups_out.append(
|
||||||
|
{
|
||||||
|
"group": gname,
|
||||||
|
"effective_text_count": int(scen_ng),
|
||||||
|
"scenario_distribution": dist[:18],
|
||||||
|
"sample_text_snippets": snippets,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if not groups_out:
|
||||||
|
return {}
|
||||||
|
return {"scenario_lexicon": lexicon, "groups": groups_out}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"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",
|
||||||
|
"_listing_price_snippet_for_llm",
|
||||||
|
"_matrix_excerpt_line_for_llm",
|
||||||
|
"_promo_snippet_for_llm",
|
||||||
|
"_text_hits_scenario_triggers",
|
||||||
|
]
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
"""竞品矩阵细类键:与 ``pipeline.matrix_group_label`` 及 §5 矩阵/扇图同源。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
from pipeline.matrix_group_label import (
|
||||||
|
matrix_group_label_from_detail_path as _matrix_group_label_from_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .csv_io import _detail_category_path_cell
|
||||||
|
|
||||||
|
|
||||||
|
def _matrix_group_label_from_detail_path(row: dict[str, str]) -> str:
|
||||||
|
return _matrix_group_label_from_path(_detail_category_path_cell(row))
|
||||||
|
|
||||||
|
|
||||||
|
def _competitor_matrix_group_key(row: dict[str, str]) -> str:
|
||||||
|
"""
|
||||||
|
竞品矩阵分组:§5 / §8 / 统计图共用。
|
||||||
|
**仅**依据 ``detail_category_path``;列为空或路径段均为无意义编码时不参与矩阵(返回空串)。
|
||||||
|
"""
|
||||||
|
return _matrix_group_label_from_detail_path(row)
|
||||||
|
|
||||||
|
|
||||||
|
def _merged_rows_grouped_for_matrix(
|
||||||
|
merged_rows: list[dict[str, str]],
|
||||||
|
) -> list[tuple[str, list[dict[str, str]]]]:
|
||||||
|
buckets: dict[str, list[dict[str, str]]] = {}
|
||||||
|
for row in merged_rows:
|
||||||
|
k = _competitor_matrix_group_key(row)
|
||||||
|
if not k:
|
||||||
|
continue
|
||||||
|
buckets.setdefault(k, []).append(row)
|
||||||
|
|
||||||
|
def sort_key(item: tuple[str, list[dict[str, str]]]) -> tuple[int, int, str]:
|
||||||
|
name, rows = item
|
||||||
|
miss = name.startswith("未归类")
|
||||||
|
return (1 if miss else 0, -len(rows), name)
|
||||||
|
|
||||||
|
return sorted(buckets.items(), key=sort_key)
|
||||||
|
|
||||||
|
|
||||||
|
def _category_mix(
|
||||||
|
rows: list[dict[str, str]], *, top_k: int = 12
|
||||||
|
) -> list[tuple[str, int]]:
|
||||||
|
"""
|
||||||
|
按「可读细类标签」统计 SKU 分布(与 §5 ``_competitor_matrix_group_key`` 同源);
|
||||||
|
仅含 ``detail_category_path`` 可解析为展示名的行。
|
||||||
|
|
||||||
|
返回 ``most_common(top_k)``,并将未列入 Top K 的款数合并为「(其余细类)」,
|
||||||
|
使各块 SKU 数之和等于有效矩阵 SKU 总数(与扇形图、简报 ``category_mix_top`` 一致)。
|
||||||
|
"""
|
||||||
|
labels: list[str] = []
|
||||||
|
for r in rows:
|
||||||
|
k = _matrix_group_label_from_detail_path(r)
|
||||||
|
if k:
|
||||||
|
labels.append(k)
|
||||||
|
if not labels:
|
||||||
|
return []
|
||||||
|
c = Counter(labels)
|
||||||
|
common = c.most_common(top_k)
|
||||||
|
accounted = sum(v for _, v in common)
|
||||||
|
total = sum(c.values())
|
||||||
|
rest = total - accounted
|
||||||
|
out: list[tuple[str, int]] = list(common)
|
||||||
|
if rest > 0:
|
||||||
|
out.append(("(其余细类)", rest))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"_category_mix",
|
||||||
|
"_competitor_matrix_group_key",
|
||||||
|
"_matrix_group_label_from_detail_path",
|
||||||
|
"_merged_rows_grouped_for_matrix",
|
||||||
|
]
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
"""合并表价格列的汇总统计(价盘/LLM 载荷等共用)。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import statistics
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def _price_stats_extended(prices: list[float]) -> dict[str, Any]:
|
||||||
|
if not prices:
|
||||||
|
return {}
|
||||||
|
out: dict[str, Any] = {
|
||||||
|
"min": min(prices),
|
||||||
|
"max": max(prices),
|
||||||
|
"mean": statistics.mean(prices),
|
||||||
|
"n": len(prices),
|
||||||
|
}
|
||||||
|
if len(prices) >= 2:
|
||||||
|
out["stdev"] = statistics.stdev(prices)
|
||||||
|
if len(prices) >= 2:
|
||||||
|
out["median"] = statistics.median(prices)
|
||||||
|
if len(prices) >= 4:
|
||||||
|
s = sorted(prices)
|
||||||
|
n = len(s)
|
||||||
|
mid = n // 2
|
||||||
|
lower = s[:mid] if n % 2 else s[:mid]
|
||||||
|
upper = s[mid + 1 :] if n % 2 else s[mid:]
|
||||||
|
out["q1"] = statistics.median(lower) if lower else s[0]
|
||||||
|
out["q3"] = statistics.median(upper) if upper else s[-1]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["_price_stats_extended"]
|
||||||
@ -28,7 +28,6 @@ import json
|
|||||||
import math
|
import math
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
import statistics
|
|
||||||
import sys
|
import sys
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -66,6 +65,27 @@ from competitor_report.comment_sentiment import ( # noqa: E402
|
|||||||
_merge_comment_previews,
|
_merge_comment_previews,
|
||||||
_parse_comment_score,
|
_parse_comment_score,
|
||||||
)
|
)
|
||||||
|
from competitor_report.ingredients import ( # noqa: E402
|
||||||
|
_ingredients_from_product_attributes,
|
||||||
|
_ingredients_single_line,
|
||||||
|
_is_ingredient_url_blob,
|
||||||
|
)
|
||||||
|
from 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 competitor_report.matrix_group import ( # noqa: E402
|
||||||
|
_category_mix,
|
||||||
|
_competitor_matrix_group_key,
|
||||||
|
_merged_rows_grouped_for_matrix,
|
||||||
|
)
|
||||||
|
from competitor_report.price_stats import _price_stats_extended # noqa: E402
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 运行配置(按需改这里;与 competitor_report.constants 中默认关注词等配合使用)
|
# 运行配置(按需改这里;与 competitor_report.constants 中默认关注词等配合使用)
|
||||||
@ -120,318 +140,6 @@ def _comment_lines_with_product_context(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _matrix_excerpt_line_for_llm(row: dict[str, str], title_h: str) -> str:
|
|
||||||
title = _md_cell(_cell(row, title_h), 100)
|
|
||||||
sp = _md_cell(_cell(row, _SELLING_POINT_KEY, _LEGACY_SELLING_POINT_KEY), 120)
|
|
||||||
ing_raw = _ingredients_from_product_attributes(
|
|
||||||
_cell(
|
|
||||||
row,
|
|
||||||
MERGED_FIELD_TO_CSV_HEADER["detail_product_attributes"],
|
|
||||||
"detail_product_attributes",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
ing = _md_cell(_ingredients_single_line(ing_raw), 100) if ing_raw else ""
|
|
||||||
chunks: list[str] = []
|
|
||||||
if title:
|
|
||||||
chunks.append(title)
|
|
||||||
if sp:
|
|
||||||
chunks.append(f"卖点:{sp}")
|
|
||||||
if ing:
|
|
||||||
chunks.append(f"配料:{ing}")
|
|
||||||
return "|".join(chunks) if chunks else "(无标题摘录)"
|
|
||||||
|
|
||||||
|
|
||||||
def _listing_price_snippet_for_llm(row: dict[str, str], title_h: str) -> str:
|
|
||||||
title = _md_cell(_cell(row, title_h), 72)
|
|
||||||
lp = _cell(row, *_LIST_SHOW_PRICE_CELL_KEYS)
|
|
||||||
cp = _cell(row, _COUPON_SHOW_PRICE_KEY, _LEGACY_COUPON_SHOW_PRICE_KEY)
|
|
||||||
dp = _cell(row, *_DETAIL_PRICE_FINAL_CSV_KEYS)
|
|
||||||
return f"{title}|标价:{lp}|券后:{cp}|详情价:{dp}"
|
|
||||||
|
|
||||||
|
|
||||||
def build_matrix_groups_llm_payload(
|
|
||||||
merged_rows: list[dict[str, str]],
|
|
||||||
*,
|
|
||||||
title_h: str,
|
|
||||||
sku_header: str = "",
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""供 ``generate_matrix_group_summaries_llm``:与 §5 细类划分一致。"""
|
|
||||||
_ = sku_header
|
|
||||||
if not merged_rows:
|
|
||||||
return []
|
|
||||||
out: list[dict[str, Any]] = []
|
|
||||||
for gname, grows in _merged_rows_grouped_for_matrix(merged_rows):
|
|
||||||
prices = _collect_prices(grows)
|
|
||||||
pst = _price_stats_extended(prices) if prices else {"n": 0}
|
|
||||||
lines = [_matrix_excerpt_line_for_llm(r, title_h) for r in grows[:24]]
|
|
||||||
out.append(
|
|
||||||
{
|
|
||||||
"group": gname,
|
|
||||||
"sku_count": len(grows),
|
|
||||||
"price_stats": pst,
|
|
||||||
"lines": lines,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def build_price_groups_llm_payload(
|
|
||||||
merged_rows: list[dict[str, str]],
|
|
||||||
*,
|
|
||||||
title_h: str,
|
|
||||||
sku_header: str = "",
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""供 ``generate_price_group_summaries_llm``。"""
|
|
||||||
_ = sku_header
|
|
||||||
if not merged_rows:
|
|
||||||
return []
|
|
||||||
out: list[dict[str, Any]] = []
|
|
||||||
for gname, grows in _merged_rows_grouped_for_matrix(merged_rows):
|
|
||||||
prices = _collect_prices(grows)
|
|
||||||
pst = _price_stats_extended(prices) if prices else {"n": 0}
|
|
||||||
snippets = [_listing_price_snippet_for_llm(r, title_h) for r in grows[:16]]
|
|
||||||
out.append(
|
|
||||||
{
|
|
||||||
"group": gname,
|
|
||||||
"sku_count": len(grows),
|
|
||||||
"price_stats": pst,
|
|
||||||
"listing_snippets": snippets,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _promo_snippet_for_llm(row: dict[str, str], title_h: str) -> str:
|
|
||||||
"""单条 SKU:合并表「促销摘要」「榜单排名」「榜单类文案」摘录,供促销 LLM 用(不含列表卖点/腰带列,避免固定词表匹配的粗口径)。"""
|
|
||||||
title = _md_cell(_cell(row, title_h), 56)
|
|
||||||
promo = _cell(
|
|
||||||
row,
|
|
||||||
MERGED_FIELD_TO_CSV_HEADER["buyer_promo_text"],
|
|
||||||
"buyer_promo_text",
|
|
||||||
)
|
|
||||||
br = _cell(
|
|
||||||
row,
|
|
||||||
MERGED_FIELD_TO_CSV_HEADER["buyer_ranking_line"],
|
|
||||||
"buyer_ranking_line",
|
|
||||||
)
|
|
||||||
belt = _cell(row, _RANK_TAGLINE_KEY, _LEGACY_RANK_TAGLINE_KEY)
|
|
||||||
parts: list[str] = [title]
|
|
||||||
if promo.strip():
|
|
||||||
parts.append(
|
|
||||||
f"{MERGED_FIELD_TO_CSV_HEADER['buyer_promo_text']}:{_md_cell(promo, 360)}"
|
|
||||||
)
|
|
||||||
if br.strip():
|
|
||||||
parts.append(
|
|
||||||
f"{MERGED_FIELD_TO_CSV_HEADER['buyer_ranking_line']}:{_md_cell(br, 120)}"
|
|
||||||
)
|
|
||||||
if belt.strip():
|
|
||||||
parts.append(
|
|
||||||
f"{JD_SEARCH_CSV_HEADERS['hot_list_rank']}:{_md_cell(belt, 80)}"
|
|
||||||
)
|
|
||||||
return "|".join(parts) if len(parts) > 1 else (parts[0] if parts else "")
|
|
||||||
|
|
||||||
|
|
||||||
def build_promo_groups_llm_payload(
|
|
||||||
merged_rows: list[dict[str, str]],
|
|
||||||
*,
|
|
||||||
title_h: str,
|
|
||||||
sku_header: str = "",
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""供 ``generate_promo_group_summaries_llm``:与 §5/§6 细类划分一致。"""
|
|
||||||
_ = sku_header
|
|
||||||
if not merged_rows:
|
|
||||||
return []
|
|
||||||
out: list[dict[str, Any]] = []
|
|
||||||
for gname, grows in _merged_rows_grouped_for_matrix(merged_rows):
|
|
||||||
snippets = [_promo_snippet_for_llm(r, title_h) for r in grows[:16]]
|
|
||||||
nonempty = sum(
|
|
||||||
1
|
|
||||||
for r in grows
|
|
||||||
if _cell(
|
|
||||||
r,
|
|
||||||
MERGED_FIELD_TO_CSV_HEADER["buyer_promo_text"],
|
|
||||||
"buyer_promo_text",
|
|
||||||
).strip()
|
|
||||||
)
|
|
||||||
out.append(
|
|
||||||
{
|
|
||||||
"group": gname,
|
|
||||||
"sku_count": len(grows),
|
|
||||||
"rows_with_buyer_promo_text": nonempty,
|
|
||||||
"promo_snippets": snippets,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def build_comment_groups_llm_payload(
|
|
||||||
*,
|
|
||||||
feedback_groups: list[tuple[str, list[dict[str, str]], list[str]]],
|
|
||||||
focus_words: tuple[str, ...],
|
|
||||||
merged_rows: list[dict[str, str]],
|
|
||||||
sku_header: str,
|
|
||||||
title_h: str,
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""供 ``generate_comment_group_summaries_llm``。"""
|
|
||||||
if not feedback_groups:
|
|
||||||
return []
|
|
||||||
sku_meta: dict[str, tuple[str, str, str]] = {}
|
|
||||||
for row in merged_rows:
|
|
||||||
sku = _cell(row, sku_header).strip()
|
|
||||||
if not sku:
|
|
||||||
continue
|
|
||||||
gk = _competitor_matrix_group_key(row)
|
|
||||||
if not gk:
|
|
||||||
continue
|
|
||||||
sku_meta[sku] = (
|
|
||||||
gk,
|
|
||||||
_cell(row, title_h),
|
|
||||||
_cell(row, *_MERGED_SHOP_CELL_KEYS),
|
|
||||||
)
|
|
||||||
out: list[dict[str, Any]] = []
|
|
||||||
for gname, cr, tu in feedback_groups:
|
|
||||||
if not tu and not cr:
|
|
||||||
continue
|
|
||||||
gh = _group_keyword_hits(cr, tu, focus_words=focus_words)
|
|
||||||
focus_hit_lines = [
|
|
||||||
f"「{w}」{n} 次" for w, n in gh.most_common(14) if n > 0
|
|
||||||
]
|
|
||||||
snippets: list[str] = []
|
|
||||||
for row in cr[:48]:
|
|
||||||
txt = _cell(row, _COMMENT_CSV_BODY, "tagCommentContent")
|
|
||||||
if not txt:
|
|
||||||
continue
|
|
||||||
sku = _cell(row, _COMMENT_CSV_SKU, "sku").strip()
|
|
||||||
meta = sku_meta.get(sku)
|
|
||||||
if meta:
|
|
||||||
sg, tit, shop = meta
|
|
||||||
prefix = (
|
|
||||||
f"【细类:{sg}|SKU:{sku}|品名:{_md_cell(tit, 60)}|"
|
|
||||||
f"店铺:{_md_cell(shop, 28)}】"
|
|
||||||
)
|
|
||||||
snippets.append(prefix + txt[:300])
|
|
||||||
else:
|
|
||||||
snippets.append(
|
|
||||||
f"【细类:{gname}|SKU:{sku or '—'}】" + txt[:320]
|
|
||||||
)
|
|
||||||
if len(snippets) >= 16:
|
|
||||||
break
|
|
||||||
eff = tu[:28]
|
|
||||||
if len(tu) > 28:
|
|
||||||
eff = list(eff) + [f"…共 {len(tu)} 条有效文本,此处截断"]
|
|
||||||
out.append(
|
|
||||||
{
|
|
||||||
"group": gname,
|
|
||||||
"comment_flat_rows": f"评价行 {len(cr)};有效文本单元 {len(tu)}",
|
|
||||||
"effective_text_lines": eff,
|
|
||||||
"focus_hit_lines": focus_hit_lines,
|
|
||||||
"sample_text_snippets": snippets,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def build_scenario_groups_llm_payload(
|
|
||||||
*,
|
|
||||||
feedback_groups: list[tuple[str, list[dict[str, str]], list[str]]],
|
|
||||||
scenario_groups: tuple[tuple[str, tuple[str, ...]], ...],
|
|
||||||
merged_rows: list[dict[str, str]],
|
|
||||||
sku_header: str,
|
|
||||||
title_h: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""供 ``generate_scenario_group_summaries_llm``;计数与 §8.3 图右栏(场景)一致。"""
|
|
||||||
if not feedback_groups:
|
|
||||||
return {}
|
|
||||||
sku_meta: dict[str, tuple[str, str, str]] = {}
|
|
||||||
for row in merged_rows:
|
|
||||||
sku = _cell(row, sku_header).strip()
|
|
||||||
if not sku:
|
|
||||||
continue
|
|
||||||
gk = _competitor_matrix_group_key(row)
|
|
||||||
if not gk:
|
|
||||||
continue
|
|
||||||
sku_meta[sku] = (
|
|
||||||
gk,
|
|
||||||
_cell(row, title_h),
|
|
||||||
_cell(row, *_MERGED_SHOP_CELL_KEYS),
|
|
||||||
)
|
|
||||||
lexicon = [
|
|
||||||
{"label": lbl, "trigger_examples": list(trigs[:12])}
|
|
||||||
for lbl, trigs in scenario_groups
|
|
||||||
]
|
|
||||||
groups_out: list[dict[str, Any]] = []
|
|
||||||
for gname, cr, tu in feedback_groups:
|
|
||||||
if not tu and not cr:
|
|
||||||
continue
|
|
||||||
scen_g, scen_ng = _comment_scenario_counts(tu, scenario_groups)
|
|
||||||
dist: list[dict[str, Any]] = []
|
|
||||||
for lbl, n in scen_g.most_common():
|
|
||||||
if n <= 0:
|
|
||||||
continue
|
|
||||||
dist.append(
|
|
||||||
{
|
|
||||||
"scenario": lbl,
|
|
||||||
"mention_rows": int(n),
|
|
||||||
"share_of_effective_texts": round(
|
|
||||||
float(n) / float(scen_ng), 4
|
|
||||||
)
|
|
||||||
if scen_ng > 0
|
|
||||||
else 0.0,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
snippets: list[str] = []
|
|
||||||
for row in cr:
|
|
||||||
txt = _cell(row, _COMMENT_CSV_BODY, "tagCommentContent")
|
|
||||||
if not txt:
|
|
||||||
continue
|
|
||||||
if not _text_hits_scenario_triggers(txt, scenario_groups):
|
|
||||||
continue
|
|
||||||
sku = _cell(row, _COMMENT_CSV_SKU, "sku").strip()
|
|
||||||
meta = sku_meta.get(sku)
|
|
||||||
if meta:
|
|
||||||
sg, tit, shop = meta
|
|
||||||
prefix = (
|
|
||||||
f"【细类:{sg}\uff5cSKU:{sku}\uff5c品名:{_md_cell(tit, 60)}\uff5c"
|
|
||||||
f"店铺:{_md_cell(shop, 28)}】"
|
|
||||||
)
|
|
||||||
snippets.append(prefix + txt[:300])
|
|
||||||
else:
|
|
||||||
snippets.append(
|
|
||||||
f"【细类:{gname}\uff5cSKU:{sku or '—'}】" + txt[:320]
|
|
||||||
)
|
|
||||||
if len(snippets) >= 16:
|
|
||||||
break
|
|
||||||
if len(snippets) < 5:
|
|
||||||
for row in cr:
|
|
||||||
txt = _cell(row, _COMMENT_CSV_BODY, "tagCommentContent")
|
|
||||||
if not txt:
|
|
||||||
continue
|
|
||||||
sku = _cell(row, _COMMENT_CSV_SKU, "sku").strip()
|
|
||||||
meta = sku_meta.get(sku)
|
|
||||||
if meta:
|
|
||||||
sg, tit, shop = meta
|
|
||||||
prefix = (
|
|
||||||
f"【细类:{sg}\uff5cSKU:{sku}\uff5c品名:{_md_cell(tit, 60)}\uff5c"
|
|
||||||
f"店铺:{_md_cell(shop, 28)}】"
|
|
||||||
)
|
|
||||||
snippets.append(prefix + txt[:260])
|
|
||||||
else:
|
|
||||||
snippets.append(
|
|
||||||
f"【细类:{gname}\uff5cSKU:{sku or '—'}】" + txt[:280]
|
|
||||||
)
|
|
||||||
if len(snippets) >= 10:
|
|
||||||
break
|
|
||||||
groups_out.append(
|
|
||||||
{
|
|
||||||
"group": gname,
|
|
||||||
"effective_text_count": int(scen_ng),
|
|
||||||
"scenario_distribution": dist[:18],
|
|
||||||
"sample_text_snippets": snippets,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if not groups_out:
|
|
||||||
return {}
|
|
||||||
return {"scenario_lexicon": lexicon, "groups": groups_out}
|
|
||||||
|
|
||||||
def _mermaid_pie_focus_keywords(hits: Counter[str], *, top_k: int = 8) -> str:
|
def _mermaid_pie_focus_keywords(hits: Counter[str], *, top_k: int = 8) -> str:
|
||||||
"""关注词全局 Top 的 Mermaid pie(便于渲染或导出工具识别)。"""
|
"""关注词全局 Top 的 Mermaid pie(便于渲染或导出工具识别)。"""
|
||||||
@ -455,29 +163,6 @@ def _mermaid_pie_focus_keywords(hits: Counter[str], *, top_k: int = 8) -> str:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def _comment_scenario_counts(
|
|
||||||
texts: list[str],
|
|
||||||
scenario_groups: tuple[tuple[str, tuple[str, ...]], ...],
|
|
||||||
) -> tuple[Counter[str], int]:
|
|
||||||
"""每组统计「至少命中一个触发词」的条数。返回 (各组条数, 有效文本条数)。"""
|
|
||||||
c: Counter[str] = Counter()
|
|
||||||
n = len(texts)
|
|
||||||
for blob in texts:
|
|
||||||
for label, triggers in scenario_groups:
|
|
||||||
if any(t in blob for t in triggers):
|
|
||||||
c[label] += 1
|
|
||||||
return c, n
|
|
||||||
|
|
||||||
|
|
||||||
def _text_hits_scenario_triggers(
|
|
||||||
text: str,
|
|
||||||
scenario_groups: tuple[tuple[str, tuple[str, ...]], ...],
|
|
||||||
) -> bool:
|
|
||||||
blob = text or ""
|
|
||||||
for _lbl, triggers in scenario_groups:
|
|
||||||
if any(t in blob for t in triggers):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _scenario_summary_bullets(counter: Counter[str], n_texts: int, top_k: int = 5) -> list[str]:
|
def _scenario_summary_bullets(counter: Counter[str], n_texts: int, top_k: int = 5) -> list[str]:
|
||||||
@ -540,26 +225,6 @@ def _comment_text_units_for_matrix_group(
|
|||||||
return texts
|
return texts
|
||||||
|
|
||||||
|
|
||||||
def _group_keyword_hits(
|
|
||||||
comment_rows_in_group: list[dict[str, str]],
|
|
||||||
texts_fallback: list[str],
|
|
||||||
*,
|
|
||||||
focus_words: tuple[str, ...],
|
|
||||||
) -> Counter[str]:
|
|
||||||
h = _comment_keyword_hits(comment_rows_in_group, focus_words)
|
|
||||||
if h:
|
|
||||||
return h
|
|
||||||
if not texts_fallback:
|
|
||||||
return Counter()
|
|
||||||
blob = "\n".join(texts_fallback)
|
|
||||||
c: Counter[str] = Counter()
|
|
||||||
for w in focus_words:
|
|
||||||
if len(w) < 2:
|
|
||||||
continue
|
|
||||||
n = blob.count(w)
|
|
||||||
if n:
|
|
||||||
c[w] += n
|
|
||||||
return c
|
|
||||||
|
|
||||||
|
|
||||||
def _consumer_feedback_by_matrix_group(
|
def _consumer_feedback_by_matrix_group(
|
||||||
@ -741,28 +406,6 @@ def _counter_mix_top_rows_with_remainder(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _price_stats_extended(prices: list[float]) -> dict[str, Any]:
|
|
||||||
if not prices:
|
|
||||||
return {}
|
|
||||||
out: dict[str, Any] = {
|
|
||||||
"min": min(prices),
|
|
||||||
"max": max(prices),
|
|
||||||
"mean": statistics.mean(prices),
|
|
||||||
"n": len(prices),
|
|
||||||
}
|
|
||||||
if len(prices) >= 2:
|
|
||||||
out["stdev"] = statistics.stdev(prices)
|
|
||||||
if len(prices) >= 2:
|
|
||||||
out["median"] = statistics.median(prices)
|
|
||||||
if len(prices) >= 4:
|
|
||||||
s = sorted(prices)
|
|
||||||
n = len(s)
|
|
||||||
mid = n // 2
|
|
||||||
lower = s[:mid] if n % 2 else s[:mid]
|
|
||||||
upper = s[mid + 1 :] if n % 2 else s[mid:]
|
|
||||||
out["q1"] = statistics.median(lower) if lower else s[0]
|
|
||||||
out["q3"] = statistics.median(upper) if upper else s[-1]
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _search_list_proxies(rows: list[dict[str, str]]) -> dict[str, Any]:
|
def _search_list_proxies(rows: list[dict[str, str]]) -> dict[str, Any]:
|
||||||
@ -803,118 +446,6 @@ def _search_list_proxies(rows: list[dict[str, str]]) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _category_token_meaningless(seg: str) -> bool:
|
|
||||||
"""纯数字类目 ID、空串或疑似内部编码的段,不宜直接作为矩阵分组展示名。"""
|
|
||||||
t = (seg or "").strip()
|
|
||||||
if not t:
|
|
||||||
return True
|
|
||||||
if t.isdigit():
|
|
||||||
return True
|
|
||||||
if len(t) >= 14 and re.fullmatch(r"[A-Za-z0-9_\-]+", t):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _matrix_display_segment_from_parts(parts: list[str]) -> str | None:
|
|
||||||
"""
|
|
||||||
与历史逻辑一致的主选段;若该段无意义则自右向左找第一段可读文本
|
|
||||||
(避免「仅类目码」或中间段为数字 ID 时,把路径误解析成无意义的细类展示名)。
|
|
||||||
"""
|
|
||||||
if not parts:
|
|
||||||
return None
|
|
||||||
if len(parts) >= 4:
|
|
||||||
preferred = parts[-2]
|
|
||||||
elif len(parts) >= 3:
|
|
||||||
preferred = parts[1]
|
|
||||||
elif len(parts) >= 2:
|
|
||||||
preferred = parts[1]
|
|
||||||
else:
|
|
||||||
preferred = parts[0]
|
|
||||||
order: list[str] = []
|
|
||||||
if preferred:
|
|
||||||
order.append(preferred)
|
|
||||||
if len(parts) >= 2:
|
|
||||||
order.append(parts[-2])
|
|
||||||
order.append(parts[-1])
|
|
||||||
order.extend(reversed(parts))
|
|
||||||
seen: set[str] = set()
|
|
||||||
for cand in order:
|
|
||||||
if not cand or cand in seen:
|
|
||||||
continue
|
|
||||||
seen.add(cand)
|
|
||||||
if not _category_token_meaningless(cand):
|
|
||||||
return cand.strip()
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _matrix_group_label_from_path(path: str) -> str:
|
|
||||||
"""由 ``detail_category_path`` 文本解析细类展示名;空或无可读段则返回空串。"""
|
|
||||||
t = (path or "").strip()
|
|
||||||
if not t:
|
|
||||||
return ""
|
|
||||||
parts = [p.strip() for p in t.replace(">", ">").split(">") if p.strip()]
|
|
||||||
if not parts:
|
|
||||||
return ""
|
|
||||||
key = _matrix_display_segment_from_parts(parts)
|
|
||||||
return (key[:80] if key else "")
|
|
||||||
|
|
||||||
|
|
||||||
def _matrix_group_label_from_detail_path(row: dict[str, str]) -> str:
|
|
||||||
return _matrix_group_label_from_path(_detail_category_path_cell(row))
|
|
||||||
|
|
||||||
|
|
||||||
def _competitor_matrix_group_key(row: dict[str, str]) -> str:
|
|
||||||
"""
|
|
||||||
竞品矩阵分组:§5 / §8 / 统计图共用。
|
|
||||||
**仅**依据 ``detail_category_path``;列为空或路径段均为无意义编码时不参与矩阵(返回空串)。
|
|
||||||
"""
|
|
||||||
return _matrix_group_label_from_detail_path(row)
|
|
||||||
|
|
||||||
|
|
||||||
def _merged_rows_grouped_for_matrix(
|
|
||||||
merged_rows: list[dict[str, str]],
|
|
||||||
) -> list[tuple[str, list[dict[str, str]]]]:
|
|
||||||
buckets: dict[str, list[dict[str, str]]] = {}
|
|
||||||
for row in merged_rows:
|
|
||||||
k = _competitor_matrix_group_key(row)
|
|
||||||
if not k:
|
|
||||||
continue
|
|
||||||
buckets.setdefault(k, []).append(row)
|
|
||||||
|
|
||||||
def sort_key(item: tuple[str, list[dict[str, str]]]) -> tuple[int, int, str]:
|
|
||||||
name, rows = item
|
|
||||||
miss = name.startswith("未归类")
|
|
||||||
return (1 if miss else 0, -len(rows), name)
|
|
||||||
|
|
||||||
return sorted(buckets.items(), key=sort_key)
|
|
||||||
|
|
||||||
|
|
||||||
def _category_mix(
|
|
||||||
rows: list[dict[str, str]], *, top_k: int = 12
|
|
||||||
) -> list[tuple[str, int]]:
|
|
||||||
"""
|
|
||||||
按「可读细类标签」统计 SKU 分布(与 §5 ``_competitor_matrix_group_key`` 同源);
|
|
||||||
仅含 ``detail_category_path`` 可解析为展示名的行。
|
|
||||||
|
|
||||||
返回 ``most_common(top_k)``,并将未列入 Top K 的款数合并为「(其余细类)」,
|
|
||||||
使各块 SKU 数之和等于有效矩阵 SKU 总数(与扇形图、简报 ``category_mix_top`` 一致)。
|
|
||||||
"""
|
|
||||||
labels: list[str] = []
|
|
||||||
for r in rows:
|
|
||||||
k = _matrix_group_label_from_detail_path(r)
|
|
||||||
if k:
|
|
||||||
labels.append(k)
|
|
||||||
if not labels:
|
|
||||||
return []
|
|
||||||
c = Counter(labels)
|
|
||||||
common = c.most_common(top_k)
|
|
||||||
accounted = sum(v for _, v in common)
|
|
||||||
total = sum(c.values())
|
|
||||||
rest = total - accounted
|
|
||||||
out: list[tuple[str, int]] = list(common)
|
|
||||||
if rest > 0:
|
|
||||||
out.append(("(其余细类)", rest))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _structure_shops(rows: list[dict[str, str]], *, list_export: bool) -> list[str]:
|
def _structure_shops(rows: list[dict[str, str]], *, list_export: bool) -> list[str]:
|
||||||
@ -946,35 +477,6 @@ def _structure_brands(rows: list[dict[str, str]], *, list_export: bool) -> list[
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _is_ingredient_url_blob(s: str) -> bool:
|
|
||||||
"""详情主图 URL 串(分号分隔)或单列以 http 开头。"""
|
|
||||||
t = (s or "").strip()
|
|
||||||
if not t:
|
|
||||||
return False
|
|
||||||
if t.startswith(("http://", "https://")):
|
|
||||||
return True
|
|
||||||
head = t[:400]
|
|
||||||
if ("https://" in head or "http://" in head) and (
|
|
||||||
";" in t or len(t) > 180 or t.count("http") >= 2
|
|
||||||
):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _ingredients_from_product_attributes(attrs: str) -> str:
|
|
||||||
m = re.search(r"配料(?:表)?[::]\s*([^;;]+)", attrs or "")
|
|
||||||
return m.group(1).strip() if m else ""
|
|
||||||
|
|
||||||
|
|
||||||
def _ingredients_single_line(s: str) -> str:
|
|
||||||
"""与 ``AI_crawler.normalize_ingredients_text_for_csv`` 一致:多行配料压成一行(行间 ``;``),便于表格/CSV。"""
|
|
||||||
t = (s or "").replace("\r\n", "\n").replace("\r", "\n").strip()
|
|
||||||
if not t:
|
|
||||||
return ""
|
|
||||||
lines = [ln.strip() for ln in t.split("\n") if ln.strip()]
|
|
||||||
if len(lines) <= 1:
|
|
||||||
return lines[0] if lines else ""
|
|
||||||
return ";".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def _matrix_ingredients_cell(row: dict[str, str], *, max_len: int = 420) -> str:
|
def _matrix_ingredients_cell(row: dict[str, str], *, max_len: int = 420) -> str:
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
与 ``jd_competitor_report._matrix_group_label_from_path`` 同源:
|
与 ``competitor_report.matrix_group`` / 历史 ``jd_competitor_report`` 中路径解析逻辑同源:
|
||||||
从商详 ``detail_category_path`` 解析 §5 竞品矩阵用的类目展示名(如饼干、米)。
|
从商详 ``detail_category_path`` 解析 §5 竞品矩阵用的类目展示名(如饼干、米)。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user