feat(pipeline): JD 流水线 CSV 纯中文表头与模块目录重构

- 统一 csv_schema 与搜索/合并/评价/商详导出为纯中文列名,入库与视图按中文表头解析

- 竞品分析报告兼容新旧表头;新增 csv_header_rewrite 与 rewrite_pipeline_csv_headers 管理命令

- 调整 pipeline 至 jd、llm、reporting、demos 子包并更新任务与测试引用

- 新增购买者优惠摘要抽取、合并表 regen/ingest 命令、0016 迁移及相关测试

Made-with: Cursor
This commit is contained in:
hub-gif 2026-04-15 16:02:29 +08:00
parent 5cafa75ab7
commit 6280e436d8
44 changed files with 2398 additions and 395 deletions

View File

@ -32,7 +32,11 @@ from playwright.sync_api import sync_playwright
_JD_PKG_ROOT = Path(__file__).resolve().parent.parent _JD_PKG_ROOT = Path(__file__).resolve().parent.parent
if str(_JD_PKG_ROOT) not in sys.path: if str(_JD_PKG_ROOT) not in sys.path:
sys.path.insert(0, str(_JD_PKG_ROOT)) sys.path.insert(0, str(_JD_PKG_ROOT))
_BACKEND_ROOT = Path(__file__).resolve().parents[3]
if str(_BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(_BACKEND_ROOT))
from common.jd_delay_utils import parse_request_delay_range from common.jd_delay_utils import parse_request_delay_range
from pipeline.csv_schema import COMMENT_CSV_COLUMNS, COMMENT_ROW_DICT_KEYS # noqa: E402
from _low_gi_root import low_gi_project_root # noqa: E402 from _low_gi_root import low_gi_project_root # noqa: E402
_JD_COMMENT_DIR = Path(__file__).resolve().parent _JD_COMMENT_DIR = Path(__file__).resolve().parent
@ -350,30 +354,19 @@ def extract_comment_rows_from_parsed(sku: str, parsed: Any) -> list[dict[str, An
return rows return rows
def _comment_flat_fieldnames() -> list[str]:
return [
"sku",
"commentId",
"userNickName",
"tagCommentContent",
"commentDate",
"buyCountText",
"largePicURLs",
"commentScore",
]
def _write_comments_csv(path: Path, rows: list[dict[str, Any]]) -> None: def _write_comments_csv(path: Path, rows: list[dict[str, Any]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
buf = StringIO() buf = StringIO()
fn = _comment_flat_fieldnames() fn = list(COMMENT_CSV_COLUMNS)
w = csv.DictWriter(buf, fieldnames=fn, extrasaction="ignore") w = csv.DictWriter(buf, fieldnames=fn, extrasaction="ignore")
w.writeheader() w.writeheader()
for r in rows: for r in rows:
line = {k: r.get(k, "") for k in fn} line: dict[str, Any] = {}
line["largePicURLs"] = json.dumps( for h, api_k in zip(COMMENT_CSV_COLUMNS, COMMENT_ROW_DICT_KEYS):
r.get("largePicURLs") or [], ensure_ascii=False if api_k == "largePicURLs":
) line[h] = json.dumps(r.get("largePicURLs") or [], ensure_ascii=False)
else:
line[h] = r.get(api_k, "")
w.writerow(line) w.writerow(line)
path.write_text("\ufeff" + buf.getvalue(), encoding="utf-8") path.write_text("\ufeff" + buf.getvalue(), encoding="utf-8")

View File

@ -0,0 +1,606 @@
# -*- coding: utf-8 -*-
"""
商详 JSON **购买者可理解的优惠与权益摘要**用于促销策略分析而非字段堆砌
设计原则
- **先回答我买这件能怎样**到手价相对标价是否有券/补贴提示硬约束如不可用东券
- **再列可感知的物流/售后权益**价保退换送达等用短标签 + 一句说明
- **原始杂乱节点** abData埋点不进入摘要
- **优惠拆解**若存在 ``preferenceVO.preferencePopUp.expression``购买立减红包抵扣金额/促销/国补占位等与腰带价到手价**对照阅读**
输入为 ``pc_detailpage_wareBusiness`` 类接口的 **JSON 根对象** ``flatten_ware_business`` 同源
若你保存的是完整响应根级字段与之一致即可
"""
from __future__ import annotations
import json
import re
from typing import Any
from pipeline.csv_schema import strip_buyer_ranking_line_prefix
def _s(x: Any) -> str:
if x is None:
return ""
return str(x).strip()
def _strip_html(text: str, *, max_len: int = 800) -> str:
if not text:
return ""
t = re.sub(r"<[^>]+>", " ", text)
t = " ".join(t.split()).strip()
return t[:max_len] if max_len > 0 else t
def _parse_float_maybe(s: str) -> float | None:
t = (s or "").strip().replace(",", "")
if not t:
return None
m = re.search(r"(\d+(?:\.\d+)?)", t)
if not m:
return None
try:
return float(m.group(1))
except ValueError:
return None
def _price_from_gather_vo(obj: dict[str, Any]) -> dict[str, Any]:
"""warePriceGatherVO.priceItemList → 到手价 / 京东价等。"""
out: dict[str, Any] = {
"hand_price": "",
"hand_label": "",
"jd_price": "",
"jd_price_hit_line": False,
"raw_items": [],
}
wpg = obj.get("warePriceGatherVO")
if not isinstance(wpg, dict):
return out
pil = wpg.get("priceItemList")
if not isinstance(pil, list):
return out
for it in pil:
if not isinstance(it, dict):
continue
ptype = _s(it.get("priceType"))
price = _s(it.get("price"))
hit_line = bool(it.get("hitLine"))
labels: list[str] = []
for lb in it.get("priceLabelList") or []:
if isinstance(lb, dict) and _s(lb.get("labelTxt")):
labels.append(_s(lb.get("labelTxt")))
out["raw_items"].append(
{"priceType": ptype, "price": price, "hitLine": hit_line, "labels": labels}
)
if ptype == "finalPrice" and price:
out["hand_price"] = price
out["hand_label"] = labels[0] if labels else "到手价"
if ptype == "jdPrice" and price:
out["jd_price"] = price
out["jd_price_hit_line"] = hit_line
return out
def _price_from_classic_price_block(obj: dict[str, Any]) -> dict[str, Any]:
"""兼容仅有 price.finalPrice / price.p 的旧结构。"""
price = obj.get("price")
if not isinstance(price, dict):
return {}
fp = price.get("finalPrice")
fp = fp if isinstance(fp, dict) else {}
hand = _s(fp.get("price")) or _s(price.get("p"))
jd = _s(price.get("op")) or _s(price.get("p"))
return {
"hand_price": hand,
"hand_label": "到手价",
"jd_price": jd if jd != hand else "",
"jd_price_hit_line": bool(_s(price.get("op"))),
}
def _preference_bundle(obj: dict[str, Any]) -> dict[str, Any]:
"""
详情页优惠弹层同源立减红包促销国补占位以及包邮/返豆等短标签
对应前端 preferenceVO warePriceGatherVO 互补
"""
out: dict[str, Any] = {
"expression": {},
"subtrahends": [],
"shared_labels": [],
"popup_preferences": [],
}
pv = obj.get("preferenceVO")
if not isinstance(pv, dict):
return out
for lb in pv.get("againSharedLabel") or []:
if isinstance(lb, dict):
name = _s(lb.get("labelName"))
if name:
out["shared_labels"].append(name[:120])
ppop = pv.get("preferencePopUp")
if not isinstance(ppop, dict):
return out
for it in ppop.get("againSharedPreference") or []:
if not isinstance(it, dict):
continue
line = _s(it.get("text"))
val = _s(it.get("value"))
st = _s(it.get("shortText"))
if st and val:
out["popup_preferences"].append(f"{st}{val}"[:300])
elif line and val:
out["popup_preferences"].append(f"{line}{val}"[:300])
elif val:
out["popup_preferences"].append(val[:300])
ex = ppop.get("expression")
if not isinstance(ex, dict):
return out
out["expression"] = {
"base_price": _s(ex.get("basePrice"))[:32],
"discount_desc": _s(ex.get("discountDesc"))[:32],
"discount_amount": _s(ex.get("discountAmount"))[:32],
"red_amount": _s(ex.get("redAmount"))[:32],
"coupon_amount": _s(ex.get("couponAmount"))[:32],
"promotion_amount": _s(ex.get("promotionAmount"))[:32],
"gov_amount": _s(ex.get("govAmount"))[:32],
}
for sub in ex.get("subtrahends") or []:
if not isinstance(sub, dict):
continue
out["subtrahends"].append(
{
"category": _s(sub.get("topDesc"))[:32],
"description": _s(sub.get("preferenceDesc"))[:200],
"amount": _s(sub.get("preferenceAmount"))[:32],
"preference_type": _s(sub.get("preferenceType"))[:16],
}
)
return out
def _gov_support_surface(obj: dict[str, Any]) -> dict[str, Any]:
"""政府补贴/国补腰带等(页面开关与展示,非到手计算结果)。"""
g = obj.get("govSupportInfo")
if not isinstance(g, dict):
return {}
return {
"gov_subsidy_flag": bool(g.get("govSubsidy")),
"gov_support_flag": bool(g.get("govSupport")),
"subsidy_type": _s(g.get("subsidyType"))[:64],
"subsidy_scene": _s(g.get("subsidyScene"))[:32],
"right_text": _s(g.get("rightText"))[:200],
"belt_banner_url": _s(g.get("beltBanner"))[:300],
}
def _best_promotion_summary(obj: dict[str, Any]) -> dict[str, Any]:
bp = obj.get("bestPromotion")
if not isinstance(bp, dict):
return {"purchase_price": "", "can_get_coupon": []}
cgc = bp.get("canGetCoupon")
coupons: list[str] = []
if isinstance(cgc, list):
for c in cgc[:12]:
if isinstance(c, dict):
t = _s(c.get("name") or c.get("desc") or c.get("couponTitle"))
if t:
coupons.append(t[:120])
elif c:
coupons.append(_s(str(c))[:120])
return {
"purchase_price": _s(bp.get("purchasePrice"))[:32],
"can_get_coupon": coupons,
}
def _warm_tips(obj: dict[str, Any]) -> list[str]:
tips: list[str] = []
wv = obj.get("warmTipVO")
if isinstance(wv, dict):
for t in wv.get("tips") or []:
if isinstance(t, dict):
txt = _s(t.get("tipTxt"))
if txt:
tips.append(txt[:300])
prom = obj.get("promotion")
if isinstance(prom, dict):
pr = _s(prom.get("prompt"))
if pr and pr not in tips:
tips.append(pr[:300])
return tips
def _rankings(obj: dict[str, Any]) -> list[str]:
out: list[str] = []
rl = obj.get("rankInfoList")
if isinstance(rl, list):
for it in rl[:8]:
if isinstance(it, dict):
n = _s(it.get("rankName"))
if n:
out.append(n[:200])
return out
def _service_tag_labels(obj: dict[str, Any]) -> list[str]:
"""主图区服务标:短标签,去重。"""
seen: set[str] = set()
labels: list[str] = []
st = obj.get("serviceTagsVO")
if isinstance(st, dict):
for key in ("basicNewIcons", "basicIcons"):
for it in st.get(key) or []:
if isinstance(it, dict):
tx = _s(it.get("text"))
if tx and tx not in seen:
seen.add(tx)
labels.append(tx[:80])
return labels[:16]
def _service_tag_details(obj: dict[str, Any], *, limit: int = 6) -> list[dict[str, str]]:
"""每条:标题 + 一句「你能获得什么」说明(来自 tip已去 HTML"""
out: list[dict[str, str]] = []
st = obj.get("serviceTagsVO")
if not isinstance(st, dict):
return out
for key in ("basicNewIcons", "basicIcons"):
for it in st.get(key) or []:
if not isinstance(it, dict):
continue
title = _s(it.get("text"))
if not title:
continue
tip = _strip_html(_s(it.get("tip")), max_len=400)
out.append({"title": title[:80], "what_you_get": tip})
if len(out) >= limit:
return out
return out
def _delivery_one_liner(obj: dict[str, Any]) -> str:
si = obj.get("stockInfo")
if isinstance(si, dict):
pr = _s(si.get("promiseResult") or si.get("promiseInfoText"))
if pr:
return _strip_html(pr, max_len=500)
sv = obj.get("stockVO")
if isinstance(sv, dict):
pr = _s(sv.get("promiseInfoText"))
if pr:
return _strip_html(pr, max_len=500)
return ""
def _logistics_icons(obj: dict[str, Any]) -> list[str]:
texts: list[str] = []
sv = obj.get("stockVO")
if isinstance(sv, dict):
pil = sv.get("promiseIconList")
if isinstance(pil, list):
for it in pil[:10]:
if isinstance(it, dict) and _s(it.get("text")):
texts.append(_s(it.get("text"))[:40])
return texts
def _bottom_cta_hint(obj: dict[str, Any]) -> str:
bb = obj.get("bottomBtnVO")
if not isinstance(bb, dict):
return ""
items = bb.get("bottomBtnItems")
if not isinstance(items, list) or not items:
return ""
it0 = items[0]
if not isinstance(it0, dict):
return ""
bs = it0.get("buttonStyle")
if not isinstance(bs, dict):
return ""
tf = bs.get("textFormat")
if not isinstance(tf, dict):
return ""
return _strip_html(_s(tf.get("text")), max_len=200)
def _belt_surface(obj: dict[str, Any]) -> str:
bi = obj.get("beltBannerInfo")
if isinstance(bi, dict):
r = _s(bi.get("bannerRightText") or bi.get("bannerRightTextOfficialDiscount"))
if r:
return r[:120]
return ""
def _enterprise_hint(obj: dict[str, Any]) -> str:
ch = obj.get("corpHighInfo")
if isinstance(ch, dict):
return _s(ch.get("tip"))[:300]
return ""
def _user_segment_flags(obj: dict[str, Any]) -> dict[str, bool]:
ui = obj.get("userInfo")
new_people = bool(ui.get("newPeople")) if isinstance(ui, dict) else False
return {"new_people": new_people}
def _build_summary_lines(
*,
gather: dict[str, Any],
classic: dict[str, Any],
bp_sum: dict[str, Any],
pref: dict[str, Any],
gov_surf: dict[str, Any],
warm: list[str],
delivery: str,
cta: str,
flags: dict[str, bool],
) -> list[str]:
"""36 条中文短句,面向「我买能怎样」。"""
lines: list[str] = []
hand = gather.get("hand_price") or classic.get("hand_price") or bp_sum.get("purchase_price")
jd_p = gather.get("jd_price") or classic.get("jd_price")
hl = gather.get("hand_label") or classic.get("hand_label") or "到手价"
if hand:
if jd_p and _parse_float_maybe(jd_p) and _parse_float_maybe(hand):
a, b = _parse_float_maybe(jd_p), _parse_float_maybe(hand)
if a is not None and b is not None and a > b:
diff = round(a - b, 2)
lines.append(
f"当前展示「{hl}」约 {hand} 元,相对页面标价 {jd_p} 元约低 {diff} 元(以结算页为准)。"
)
else:
lines.append(
f"当前展示「{hl}」约 {hand} 元(页面标价 {jd_p} 元,以结算页为准)。"
)
else:
lines.append(f"当前展示「{hl}」约 {hand} 元(以结算页为准)。")
ex = pref.get("expression") or {}
dd = _s(ex.get("discount_desc"))
da = _s(ex.get("discount_amount"))
ra = _s(ex.get("red_amount"))
subs = pref.get("subtrahends") or []
if dd or da or ra or subs:
parts: list[str] = []
if dd and da:
parts.append(f"{dd}{da}")
elif dd:
parts.append(dd)
for s in subs:
if not isinstance(s, dict):
continue
cat = _s(s.get("category"))
desc = _s(s.get("description"))
if cat and desc:
parts.append(f"{cat}{desc}")
elif desc:
parts.append(desc)
if ra and not subs:
parts.append(f"红包类约 {ra}")
elif ra and subs and not any("红包" in _s(p) for p in parts):
parts.append(f"红包类约 {ra}")
if parts:
lines.append(
"详情页优惠拆解(与腰带/到手价对照):"
+ "".join(parts[:5])
+ "(以结算页为准)。"
)
if pref.get("shared_labels"):
lines.append(
"其他权益标签:"
+ "".join(pref["shared_labels"][:4])
+ ""
)
if pref.get("popup_preferences"):
lines.append(
"其他权益:"
+ "".join(pref["popup_preferences"][:4])
+ ""
)
if gov_surf.get("gov_subsidy_flag") or gov_surf.get("gov_support_flag"):
rt = _s(gov_surf.get("right_text"))
if rt:
lines.append(f"国补/政府补贴相关展示:{_strip_html(rt, max_len=160)}(以活动规则为准)。")
else:
lines.append("页面含政府补贴/国补相关入口(以活动规则与结算为准)。")
if flags.get("new_people") and cta:
lines.append(
f"新人相关文案:{_strip_html(cta)}(若你不是新人,价格与活动可能不同)。"
)
elif cta:
lines.append(f"主按钮文案:{_strip_html(cta)}")
if warm:
lines.append("购买限制与提示:" + "".join(warm[:4]) + "")
# 榜单仅走 visibility.rankings → buyer_ranking_line_from_profile避免 buyer_promo 重复
if delivery:
lines.append("送达:" + delivery + "")
if bp_sum.get("can_get_coupon"):
lines.append(
"可领券/活动入口(摘要):"
+ "".join(bp_sum["can_get_coupon"][:4])
+ ""
)
return lines[:8]
def extract_buyer_offer_profile(obj: Any) -> dict[str, Any]:
"""
从商详 JSON 根对象生成结构化摘要
返回 dict 可直接 ``json.dumps(..., ensure_ascii=False)``无有效输入时仍返回带 ``schema_version`` 的空壳
"""
empty: dict[str, Any] = {
"schema_version": 1,
"sku_id_hint": "",
"price_snapshot": {},
"discount_mechanism": {},
"gov_support_surface": {},
"best_promotion": {},
"purchase_constraints": {"warm_tips": [], "ware_flags": {}},
"marketing_surface": {},
"visibility": {"rankings": []},
"after_sales_short_labels": [],
"after_sales_details": [],
"delivery": {"one_liner": "", "logistics_icons": []},
"buyer_summary_lines": [],
"notes": "摘要由规则生成,结算价与活动以京东下单页为准。",
}
if not isinstance(obj, dict):
return empty
pc = obj.get("pageConfigVO")
sku_hint = ""
if isinstance(pc, dict):
sku_hint = _s(pc.get("skuid"))
gather = _price_from_gather_vo(obj)
classic = _price_from_classic_price_block(obj)
if not gather.get("hand_price") and classic.get("hand_price"):
gather["hand_price"] = classic["hand_price"]
gather["hand_label"] = classic.get("hand_label") or gather.get("hand_label")
if not gather.get("jd_price") and classic.get("jd_price"):
gather["jd_price"] = classic["jd_price"]
bp_sum = _best_promotion_summary(obj)
pref_bundle = _preference_bundle(obj)
gov_surf = _gov_support_surface(obj)
warm = _warm_tips(obj)
rankings = _rankings(obj)
short_labels = _service_tag_labels(obj)
details = _service_tag_details(obj)
delivery = _delivery_one_liner(obj)
log_icons = _logistics_icons(obj)
cta = _bottom_cta_hint(obj)
belt = _belt_surface(obj)
ent = _enterprise_hint(obj)
flags = _user_segment_flags(obj)
wim = obj.get("wareInfoReadMap")
ware_flags: dict[str, str] = {}
if isinstance(wim, dict):
for k in ("isCanUseDQ", "msbybt", "productBybt"):
if k in wim:
ware_flags[k] = _s(wim.get(k))
bybt = obj.get("bybtInfo")
if isinstance(bybt, dict) and bybt.get("productBybt"):
ware_flags["productBybt"] = "1"
lines = _build_summary_lines(
gather=gather,
classic=classic,
bp_sum=bp_sum,
pref=pref_bundle,
gov_surf=gov_surf,
warm=warm,
delivery=delivery,
cta=cta,
flags=flags,
)
if ent:
et = ent[:200].strip()
if et and et[-1] not in "。!?…":
et += ""
lines.append("企业采购提示:" + et)
out = {
"schema_version": 1,
"sku_id_hint": sku_hint,
"price_snapshot": {
"hand_price": gather.get("hand_price") or bp_sum.get("purchase_price"),
"hand_label": gather.get("hand_label") or "到手价",
"jd_list_price": gather.get("jd_price"),
"jd_list_hit_line": gather.get("jd_price_hit_line"),
"price_items": gather.get("raw_items"),
},
"discount_mechanism": pref_bundle,
"gov_support_surface": gov_surf,
"best_promotion": bp_sum,
"purchase_constraints": {
"warm_tips": warm,
"ware_flags": ware_flags,
},
"marketing_surface": {
"belt_right_text": belt,
"bottom_button_hint": _strip_html(cta, max_len=200),
},
"visibility": {"rankings": rankings},
"after_sales_short_labels": short_labels,
"after_sales_details": details,
"delivery": {"one_liner": delivery, "logistics_icons": log_icons},
"enterprise_channel": ent,
"user_segment": flags,
"buyer_summary_lines": lines,
"notes": "摘要由规则生成;价格、券、补贴以结算页为准,此处不罗列埋点/实验字段。",
}
return out
# --- 与 ``pipeline.jd.buyer_offer_export_csv`` / 流水线 detail_ware 列对齐的扁平字段 ---
_PROMO_EXCLUDE_PREFIXES_FOR_FLAT = ("榜单/曝光", "送达:", "企业采购提示:")
_DEFAULT_BUYER_PROMO_SEP = " | "
def buyer_ranking_line_from_profile(prof: dict[str, Any]) -> str:
"""榜单名单列(如 ``粗粮饼干热卖榜·第5名。``),无 ``榜单/曝光:`` 前缀;无榜单时为空串。"""
vis = prof.get("visibility")
if not isinstance(vis, dict):
return ""
rk = vis.get("rankings")
if not isinstance(rk, list) or not rk:
return ""
first = strip_buyer_ranking_line_prefix(str(rk[0]).strip())
if not first:
return ""
body = first if first.endswith("") else first + ""
return body
def buyer_promo_text_from_profile(
prof: dict[str, Any],
*,
sep: str = _DEFAULT_BUYER_PROMO_SEP,
) -> str:
"""
``buyer_summary_lines`` 取句去掉榜单/送达/企业采购句 ``sep`` 拼接
"""
raw = prof.get("buyer_summary_lines")
if not isinstance(raw, list):
return ""
parts: list[str] = []
for line in raw:
s = str(line).strip()
if not s:
continue
if any(s.startswith(p) for p in _PROMO_EXCLUDE_PREFIXES_FOR_FLAT):
continue
parts.append(s)
return sep.join(parts)
def extract_buyer_offer_profile_from_json_text(text: str) -> dict[str, Any]:
"""解析响应体字符串,失败时返回空壳摘要。"""
raw = (text or "").strip()
if not raw:
return extract_buyer_offer_profile({})
try:
obj = json.loads(raw)
except json.JSONDecodeError:
return extract_buyer_offer_profile({})
return extract_buyer_offer_profile(obj)

View File

@ -562,8 +562,21 @@ SKU_BODY_IMAGES_ONLY_FIELDNAMES: tuple[str, ...] = (
"detail_body_ingredients", "detail_body_ingredients",
) )
# 与合并表 lean 商详块一致 + skuIdkeyword_pipeline DETAIL_WARE_CSV_MODE=lean 写 detail_ware_export.csv # 与合并表 lean 商详块一致 + SKUkeyword_pipeline DETAIL_WARE_CSV_MODE=lean 写 detail_ware_export.csv(纯中文表头)
DETAIL_WARE_LEAN_CSV_FIELDNAMES: tuple[str, ...] = ( DETAIL_WARE_LEAN_CSV_FIELDNAMES: tuple[str, ...] = (
"SKU",
"品牌",
"到手价",
"店铺名称",
"类目路径",
"商品参数",
"配料表",
"榜单排名",
"促销摘要",
)
# ``ware_parsed_row`` 可提供的 lean 列(购买者摘要由独立抽取补充)
_DETAIL_WARE_LEAN_FROM_RESPONSE_KEYS: tuple[str, ...] = (
"skuId", "skuId",
"detail_brand", "detail_brand",
"detail_price_final", "detail_price_final",
@ -581,8 +594,10 @@ def detail_ware_lean_csv_row(
*, *,
detail_body_ingredients: str = "", detail_body_ingredients: str = "",
detail_body_ingredients_source_url: str = "", detail_body_ingredients_source_url: str = "",
buyer_ranking_line: str = "",
buyer_promo_text: str = "",
) -> dict[str, str]: ) -> dict[str, str]:
"""lean 详情汇总表一行(无 http_status字段来自 ``ware_parsed_row`` 子集。""" """lean 详情汇总表一行(无 http_status字段来自 ``ware_parsed_row`` 子集 + 购买者摘要列"""
full = ware_parsed_row( full = ware_parsed_row(
sku, sku,
http_status, http_status,
@ -590,7 +605,22 @@ def detail_ware_lean_csv_row(
detail_body_ingredients=detail_body_ingredients, detail_body_ingredients=detail_body_ingredients,
detail_body_ingredients_source_url=detail_body_ingredients_source_url, detail_body_ingredients_source_url=detail_body_ingredients_source_url,
) )
return {k: str(full.get(k) or "") for k in DETAIL_WARE_LEAN_CSV_FIELDNAMES} out = {k: str(full.get(k) or "") for k in _DETAIL_WARE_LEAN_FROM_RESPONSE_KEYS}
out["buyer_ranking_line"] = (buyer_ranking_line or "").strip()
out["buyer_promo_text"] = (buyer_promo_text or "").strip()
_cn = DETAIL_WARE_LEAN_CSV_FIELDNAMES
_en = (
"skuId",
"detail_brand",
"detail_price_final",
"detail_shop_name",
"detail_category_path",
"detail_product_attributes",
"detail_body_ingredients",
"buyer_ranking_line",
"buyer_promo_text",
)
return {_cn[i]: str(out.get(_en[i]) or "") for i in range(len(_cn))}
def minimal_sku_body_images_row( def minimal_sku_body_images_row(

View File

@ -24,8 +24,10 @@
from __future__ import annotations from __future__ import annotations
import csv import csv
import hashlib
import json import json
import math import math
import random
import re import re
import statistics import statistics
import sys import sys
@ -42,7 +44,65 @@ if str(_BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(_BACKEND_ROOT)) sys.path.insert(0, str(_BACKEND_ROOT))
import jd_keyword_pipeline as kpl # noqa: E402 import jd_keyword_pipeline as kpl # noqa: E402
from pipeline.csv_schema import merged_csv_effective_total_sales # 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,
)
_JD_LIST_PRICE_KEY = JD_SEARCH_CSV_HEADERS["price"]
_COUPON_SHOW_PRICE_KEY = JD_SEARCH_CSV_HEADERS["coupon_price"]
_ORIGINAL_LIST_PRICE_KEY = JD_SEARCH_CSV_HEADERS["original_price"]
_SELLING_POINT_KEY = JD_SEARCH_CSV_HEADERS["selling_point"]
_RANK_TAGLINE_KEY = JD_SEARCH_CSV_HEADERS["hot_list_rank"]
# 历史批次 CSV 表头(括号英文);新批次为纯中文,读取时新键优先
_LEGACY_JD_LIST_PRICE_KEY = "标价(jdPrice,jdPriceText,realPrice)"
_LEGACY_COUPON_SHOW_PRICE_KEY = (
"券后到手价(couponPrice,subsidyPrice,finalPrice.estimatedPrice,priceShow)"
)
_LEGACY_SHOP_NAME_KEY = "店铺名(shopName)"
_LEGACY_RANK_TAGLINE_KEY = "榜单类文案(标签/腰带/标题数组中的榜、TOP 等)"
_LEGACY_COMMENT_FUZZ_KEY = "评价量(commentFuzzy)"
_LEGACY_SELLING_POINT_KEY = "卖点(sellingPoint)"
_LIST_BRAND_TITLE_HEADER = "店铺信息标题"
_LEGACY_LIST_BRAND_TITLE_KEY = "店铺信息标题(shopInfoTitle,brandName)"
_DETAIL_PRICE_FINAL_CSV_KEYS: tuple[str, ...] = (
MERGED_FIELD_TO_CSV_HEADER["detail_price_final"],
"detail_price_final",
)
_LIST_PRICE_AND_COUPON_KEYS: tuple[str, ...] = (
*_DETAIL_PRICE_FINAL_CSV_KEYS,
_JD_LIST_PRICE_KEY,
_LEGACY_JD_LIST_PRICE_KEY,
_COUPON_SHOW_PRICE_KEY,
_LEGACY_COUPON_SHOW_PRICE_KEY,
)
# 报告摘录「标价」:列表标价优先,缺省时用商详到手价列兜底
_LIST_SHOW_PRICE_CELL_KEYS: tuple[str, ...] = (
_JD_LIST_PRICE_KEY,
_LEGACY_JD_LIST_PRICE_KEY,
MERGED_FIELD_TO_CSV_HEADER["detail_price_final"],
"detail_price_final",
)
_MERGED_SHOP_CELL_KEYS: tuple[str, ...] = (
MERGED_FIELD_TO_CSV_HEADER["detail_shop_name"],
"detail_shop_name",
JD_SEARCH_CSV_HEADERS["shop_name"],
_LEGACY_SHOP_NAME_KEY,
)
_COMMENT_FUZZ_KEYS: tuple[str, ...] = (
MERGED_FIELD_TO_CSV_HEADER["comment_fuzzy"],
_LEGACY_COMMENT_FUZZ_KEY,
)
_COMMENT_CSV_SKU = COMMENT_CSV_COLUMNS[0]
_COMMENT_CSV_BODY = COMMENT_CSV_COLUMNS[3]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 运行配置(按需改这里) # 运行配置(按需改这里)
@ -197,9 +257,9 @@ def _cell(row: dict[str, str], *keys: str) -> str:
return "" return ""
_DETAIL_CATEGORY_PATH_KEY = "detail_category_path" _DETAIL_CATEGORY_PATH_KEY = MERGED_FIELD_TO_CSV_HEADER["detail_category_path"]
_K_CAT_COL = "类目(leafCategory,cid3Name,catid)" _K_CAT_COL = JD_SEARCH_CSV_HEADERS["leaf_category"]
_K_PROP_COL = "规格属性(propertyList,color,catid,shortName)" _K_PROP_COL = JD_SEARCH_CSV_HEADERS["attributes"]
def _shortname_from_prop(prop: str) -> str: def _shortname_from_prop(prop: str) -> str:
@ -209,7 +269,7 @@ def _shortname_from_prop(prop: str) -> str:
def _detail_category_path_cell(row: dict[str, str]) -> str: def _detail_category_path_cell(row: dict[str, str]) -> str:
"""细类矩阵与按细类评价统计仅以该列为准;空则视为商详类目不完整。""" """细类矩阵与按细类评价统计仅以该列为准;空则视为商详类目不完整。"""
return str(row.get(_DETAIL_CATEGORY_PATH_KEY) or "").strip() return _cell(row, _DETAIL_CATEGORY_PATH_KEY, "detail_category_path")
def _search_export_catid_to_shortname_map(rows: list[dict[str, str]]) -> dict[str, str]: def _search_export_catid_to_shortname_map(rows: list[dict[str, str]]) -> dict[str, str]:
@ -259,13 +319,8 @@ def _float_price(s: str) -> float | None:
def _collect_prices(rows: list[dict[str, str]]) -> list[float]: def _collect_prices(rows: list[dict[str, str]]) -> list[float]:
out: list[float] = [] out: list[float] = []
price_keys = (
"detail_price_final",
"标价(jdPrice,jdPriceText,realPrice)",
"券后到手价(couponPrice,subsidyPrice,finalPrice.estimatedPrice,priceShow)",
)
for row in rows: for row in rows:
for k in price_keys: for k in _LIST_PRICE_AND_COUPON_KEYS:
p = _float_price(_cell(row, k)) p = _float_price(_cell(row, k))
if p is not None and 0 < p < 1_000_000: if p is not None and 0 < p < 1_000_000:
out.append(p) out.append(p)
@ -273,14 +328,6 @@ def _collect_prices(rows: list[dict[str, str]]) -> list[float]:
return out return out
_JD_LIST_PRICE_KEY = "标价(jdPrice,jdPriceText,realPrice)"
_COUPON_SHOW_PRICE_KEY = (
"券后到手价(couponPrice,subsidyPrice,finalPrice.estimatedPrice,priceShow)"
)
_ORIGINAL_LIST_PRICE_KEY = "原价(oriPrice,originalPrice,marketPrice)"
_SELLING_POINT_KEY = "卖点(sellingPoint)"
_RANK_TAGLINE_KEY = "榜单类文案(标签/腰带/标题数组中的榜、TOP 等)"
# 列表/合并表中「卖点、腰带」常见活动话术子串(行级命中,非严谨 NLP # 列表/合并表中「卖点、腰带」常见活动话术子串(行级命中,非严谨 NLP
_PROMO_SUBSTRINGS_IN_COPY: tuple[str, ...] = ( _PROMO_SUBSTRINGS_IN_COPY: tuple[str, ...] = (
"满减", "满减",
@ -323,8 +370,10 @@ def _analyze_price_promotions(rows: list[dict[str, str]]) -> dict[str, Any]:
pct_offs: list[float] = [] pct_offs: list[float] = []
ori_above_list = 0 ori_above_list = 0
for row in rows: for row in rows:
jd = _float_price(_cell(row, _JD_LIST_PRICE_KEY)) jd = _float_price(_cell(row, _JD_LIST_PRICE_KEY, _LEGACY_JD_LIST_PRICE_KEY))
cp = _float_price(_cell(row, _COUPON_SHOW_PRICE_KEY)) cp = _float_price(
_cell(row, _COUPON_SHOW_PRICE_KEY, _LEGACY_COUPON_SHOW_PRICE_KEY)
)
ori = _float_price(_cell(row, _ORIGINAL_LIST_PRICE_KEY)) ori = _float_price(_cell(row, _ORIGINAL_LIST_PRICE_KEY))
if jd is not None and jd > 0: if jd is not None and jd > 0:
with_jd += 1 with_jd += 1
@ -345,16 +394,24 @@ def _analyze_price_promotions(rows: list[dict[str, str]]) -> dict[str, Any]:
ori_above_list += 1 ori_above_list += 1
selling_nonempty = sum( selling_nonempty = sum(
1 for r in rows if _cell(r, _SELLING_POINT_KEY).strip() 1
for r in rows
if _cell(r, _SELLING_POINT_KEY, _LEGACY_SELLING_POINT_KEY).strip()
)
rank_nonempty = sum(
1
for r in rows
if _cell(r, _RANK_TAGLINE_KEY, _LEGACY_RANK_TAGLINE_KEY).strip()
) )
rank_nonempty = sum(1 for r in rows if _cell(r, _RANK_TAGLINE_KEY).strip())
kw_row_hits: dict[str, int] = {} kw_row_hits: dict[str, int] = {}
for kw in _PROMO_SUBSTRINGS_IN_COPY: for kw in _PROMO_SUBSTRINGS_IN_COPY:
c = 0 c = 0
for row in rows: for row in rows:
blob = ( blob = (
_cell(row, _SELLING_POINT_KEY) + " " + _cell(row, _RANK_TAGLINE_KEY) _cell(row, _SELLING_POINT_KEY, _LEGACY_SELLING_POINT_KEY)
+ " "
+ _cell(row, _RANK_TAGLINE_KEY, _LEGACY_RANK_TAGLINE_KEY)
) )
if kw in blob: if kw in blob:
c += 1 c += 1
@ -472,7 +529,7 @@ def _comment_keyword_hits(
c: Counter[str] = Counter() c: Counter[str] = Counter()
texts: list[str] = [] texts: list[str] = []
for row in rows: for row in rows:
t = _cell(row, "tagCommentContent") t = _cell(row, _COMMENT_CSV_BODY, "tagCommentContent")
if t: if t:
texts.append(t) texts.append(t)
blob = "\n".join(texts) blob = "\n".join(texts)
@ -488,7 +545,11 @@ def _comment_keyword_hits(
def _merge_comment_previews(merged_rows: list[dict[str, str]]) -> str: def _merge_comment_previews(merged_rows: list[dict[str, str]]) -> str:
parts: list[str] = [] parts: list[str] = []
for row in merged_rows: for row in merged_rows:
p = _cell(row, "comment_preview") p = _cell(
row,
MERGED_FIELD_TO_CSV_HEADER["comment_preview"],
"comment_preview",
)
if p: if p:
parts.append(p) parts.append(p)
return "\n".join(parts) return "\n".join(parts)
@ -501,13 +562,17 @@ def _iter_comment_text_units(
"""逐条评价正文;无 flat 评论时用合并表 comment_preview 按行兜底。""" """逐条评价正文;无 flat 评论时用合并表 comment_preview 按行兜底。"""
out: list[str] = [] out: list[str] = []
for row in comment_rows: for row in comment_rows:
t = _cell(row, "tagCommentContent") t = _cell(row, _COMMENT_CSV_BODY, "tagCommentContent")
if t: if t:
out.append(t) out.append(t)
if out: if out:
return out return out
for row in merged_rows: for row in merged_rows:
p = _cell(row, "comment_preview") p = _cell(
row,
MERGED_FIELD_TO_CSV_HEADER["comment_preview"],
"comment_preview",
)
if p: if p:
out.append(p) out.append(p)
return out return out
@ -540,7 +605,6 @@ _NEG_LEX = (
"", "",
"退货", "退货",
"不建议", "不建议",
"",
"糟糕", "糟糕",
"难用", "难用",
"", "",
@ -571,10 +635,14 @@ _POS_LEXEME_DETAIL = (
"代餐很方便", "代餐很方便",
"品质很稳定", "品质很稳定",
"值得信赖", "值得信赖",
"软硬适中",
) )
_NEG_LEXEME_DETAIL = ( _NEG_LEXEME_DETAIL = (
"口感偏硬", "口感偏硬",
"口感很硬", "口感很硬",
"咬不动",
"发硬",
"硬邦邦",
"口感发粘", "口感发粘",
"太甜了", "太甜了",
"甜得发腻", "甜得发腻",
@ -708,14 +776,14 @@ def _comment_lines_with_product_context(
sku_meta[sku] = ( sku_meta[sku] = (
gk, gk,
_cell(row, title_h), _cell(row, title_h),
_cell(row, "detail_shop_name") or _cell(row, "店铺名(shopName)"), _cell(row, *_MERGED_SHOP_CELL_KEYS),
) )
out: list[str] = [] out: list[str] = []
for cr in comment_rows: for cr in comment_rows:
txt = (cr.get("tagCommentContent") or "").strip() txt = _cell(cr, _COMMENT_CSV_BODY, "tagCommentContent")
if not txt: if not txt:
continue continue
sku = _cell(cr, "sku").strip() sku = _cell(cr, _COMMENT_CSV_SKU, "sku").strip()
meta = sku_meta.get(sku) meta = sku_meta.get(sku)
if meta: if meta:
gname, tit, shop = meta gname, tit, shop = meta
@ -731,8 +799,14 @@ def _comment_lines_with_product_context(
def _matrix_excerpt_line_for_llm(row: dict[str, str], title_h: str) -> str: def _matrix_excerpt_line_for_llm(row: dict[str, str], title_h: str) -> str:
title = _md_cell(_cell(row, title_h), 100) title = _md_cell(_cell(row, title_h), 100)
sp = _md_cell(_cell(row, "卖点(sellingPoint)"), 120) sp = _md_cell(_cell(row, _SELLING_POINT_KEY, _LEGACY_SELLING_POINT_KEY), 120)
ing_raw = _ingredients_from_product_attributes(_cell(row, "detail_product_attributes")) 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 "" ing = _md_cell(_ingredients_single_line(ing_raw), 100) if ing_raw else ""
chunks: list[str] = [] chunks: list[str] = []
if title: if title:
@ -746,12 +820,9 @@ def _matrix_excerpt_line_for_llm(row: dict[str, str], title_h: str) -> str:
def _listing_price_snippet_for_llm(row: dict[str, str], title_h: str) -> str: def _listing_price_snippet_for_llm(row: dict[str, str], title_h: str) -> str:
title = _md_cell(_cell(row, title_h), 72) title = _md_cell(_cell(row, title_h), 72)
lp = _cell(row, "标价(jdPrice,jdPriceText,realPrice)") lp = _cell(row, *_LIST_SHOW_PRICE_CELL_KEYS)
cp = _cell( cp = _cell(row, _COUPON_SHOW_PRICE_KEY, _LEGACY_COUPON_SHOW_PRICE_KEY)
row, dp = _cell(row, *_DETAIL_PRICE_FINAL_CSV_KEYS)
"券后到手价(couponPrice,subsidyPrice,finalPrice.estimatedPrice,priceShow)",
)
dp = _cell(row, "detail_price_final")
return f"{title}|标价:{lp}|券后:{cp}|详情价:{dp}" return f"{title}|标价:{lp}|券后:{cp}|详情价:{dp}"
@ -829,7 +900,7 @@ def build_comment_groups_llm_payload(
sku_meta[sku] = ( sku_meta[sku] = (
gk, gk,
_cell(row, title_h), _cell(row, title_h),
_cell(row, "detail_shop_name") or _cell(row, "店铺名(shopName)"), _cell(row, *_MERGED_SHOP_CELL_KEYS),
) )
out: list[dict[str, Any]] = [] out: list[dict[str, Any]] = []
for gname, cr, tu in feedback_groups: for gname, cr, tu in feedback_groups:
@ -841,10 +912,10 @@ def build_comment_groups_llm_payload(
] ]
snippets: list[str] = [] snippets: list[str] = []
for row in cr[:48]: for row in cr[:48]:
txt = (row.get("tagCommentContent") or "").strip() txt = _cell(row, _COMMENT_CSV_BODY, "tagCommentContent")
if not txt: if not txt:
continue continue
sku = _cell(row, "sku").strip() sku = _cell(row, _COMMENT_CSV_SKU, "sku").strip()
meta = sku_meta.get(sku) meta = sku_meta.get(sku)
if meta: if meta:
sg, tit, shop = meta sg, tit, shop = meta
@ -896,7 +967,7 @@ def build_scenario_groups_llm_payload(
sku_meta[sku] = ( sku_meta[sku] = (
gk, gk,
_cell(row, title_h), _cell(row, title_h),
_cell(row, "detail_shop_name") or _cell(row, "店铺名(shopName)"), _cell(row, *_MERGED_SHOP_CELL_KEYS),
) )
lexicon = [ lexicon = [
{"label": lbl, "trigger_examples": list(trigs[:12])} {"label": lbl, "trigger_examples": list(trigs[:12])}
@ -924,12 +995,12 @@ def build_scenario_groups_llm_payload(
) )
snippets: list[str] = [] snippets: list[str] = []
for row in cr: for row in cr:
txt = (row.get("tagCommentContent") or "").strip() txt = _cell(row, _COMMENT_CSV_BODY, "tagCommentContent")
if not txt: if not txt:
continue continue
if not _text_hits_scenario_triggers(txt, scenario_groups): if not _text_hits_scenario_triggers(txt, scenario_groups):
continue continue
sku = _cell(row, "sku").strip() sku = _cell(row, _COMMENT_CSV_SKU, "sku").strip()
meta = sku_meta.get(sku) meta = sku_meta.get(sku)
if meta: if meta:
sg, tit, shop = meta sg, tit, shop = meta
@ -946,10 +1017,10 @@ def build_scenario_groups_llm_payload(
break break
if len(snippets) < 5: if len(snippets) < 5:
for row in cr: for row in cr:
txt = (row.get("tagCommentContent") or "").strip() txt = _cell(row, _COMMENT_CSV_BODY, "tagCommentContent")
if not txt: if not txt:
continue continue
sku = _cell(row, "sku").strip() sku = _cell(row, _COMMENT_CSV_SKU, "sku").strip()
meta = sku_meta.get(sku) meta = sku_meta.get(sku)
if meta: if meta:
sg, tit, shop = meta sg, tit, shop = meta
@ -985,11 +1056,15 @@ def build_comment_sentiment_llm_payload(
max_samples_negative: int = 30, max_samples_negative: int = 30,
max_samples_mixed: int = 10, max_samples_mixed: int = 10,
max_chars_per_review: int = 300, max_chars_per_review: int = 300,
semantic_pool_max: int = 40,
shuffle_seed: str = "",
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """
供大模型做正/负向语义归纳附规则统计与**去重后的评价原文抽样** §8.2 词表分桶一致 供大模型做正/负向语义归纳附规则统计关键词分桶抽样以及 **sample_reviews_semantic_pool**
全量去重后的评价句确定性洗牌抽样供模型结合语境自行判断褒贬
负向样本默认多于正向便于大模型做具体问题是什么的主题归因而非只复述词频 ``sentiment_bucket_method`` 标明分桶依据为子串词表条形图与 lexicon 仍与此口径一致
但正文归纳应以模型对 ``sample_reviews_semantic_pool`` 的整句理解为准
""" """
pos_only_texts: list[str] = [] pos_only_texts: list[str] = []
neg_only_texts: list[str] = [] neg_only_texts: list[str] = []
@ -998,6 +1073,8 @@ def build_comment_sentiment_llm_payload(
attributed_texts is not None attributed_texts is not None
and len(attributed_texts) == len(texts) and len(attributed_texts) == len(texts)
) )
all_unique_disp: list[str] = []
seen_unique: set[str] = set()
for i, t in enumerate(texts): for i, t in enumerate(texts):
s = (t or "").strip() s = (t or "").strip()
if not s: if not s:
@ -1007,6 +1084,9 @@ def build_comment_sentiment_llm_payload(
if use_attr if use_attr
else s else s
) )
if disp and disp not in seen_unique:
seen_unique.add(disp)
all_unique_disp.append(disp)
hp = any(k in s for k in _POS_CLASS) hp = any(k in s for k in _POS_CLASS)
hn = any(k in s for k in _NEG_CLASS) hn = any(k in s for k in _NEG_CLASS)
if hp and hn: if hp and hn:
@ -1016,6 +1096,27 @@ def build_comment_sentiment_llm_payload(
elif hn: elif hn:
neg_only_texts.append(disp) neg_only_texts.append(disp)
def _semantic_pool(seq: list[str], cap: int) -> list[str]:
"""去重列表的洗牌子样本shuffle_seed 非空时按种子固定顺序以便同任务可复现。"""
if not seq or cap <= 0:
return []
work = list(seq)
if (shuffle_seed or "").strip():
h = hashlib.sha256(shuffle_seed.encode("utf-8")).digest()
rnd = random.Random(int.from_bytes(h[:8], "big"))
rnd.shuffle(work)
out: list[str] = []
for raw in work:
if len(raw) > max_chars_per_review:
out.append(raw[:max_chars_per_review] + "")
else:
out.append(raw)
if len(out) >= cap:
break
return out
semantic_pool = _semantic_pool(all_unique_disp, semantic_pool_max)
def _sample(seq: list[str], cap: int) -> list[str]: def _sample(seq: list[str], cap: int) -> list[str]:
out: list[str] = [] out: list[str] = []
seen: set[str] = set() seen: set[str] = set()
@ -1040,6 +1141,8 @@ def build_comment_sentiment_llm_payload(
"comment_sentiment_lexicon": lex, "comment_sentiment_lexicon": lex,
"positive_lexeme_hits_top": pos_h_top, "positive_lexeme_hits_top": pos_h_top,
"negative_lexeme_hits_top": neg_h_top, "negative_lexeme_hits_top": neg_h_top,
"sentiment_bucket_method": "keyword_substring_heuristic",
"sample_reviews_semantic_pool": semantic_pool,
"sample_reviews_positive_biased": _sample(pos_only_texts, max_samples_positive), "sample_reviews_positive_biased": _sample(pos_only_texts, max_samples_positive),
"sample_reviews_negative_biased": _sample(neg_only_texts, max_samples_negative), "sample_reviews_negative_biased": _sample(neg_only_texts, max_samples_negative),
"sample_reviews_mixed_tone": _sample(mixed_texts, max_samples_mixed), "sample_reviews_mixed_tone": _sample(mixed_texts, max_samples_mixed),
@ -1135,7 +1238,7 @@ def _comment_text_units_for_matrix_group(
"""某细类下的评价正文列表;无 flat 时用该细类合并行的 comment_preview。""" """某细类下的评价正文列表;无 flat 时用该细类合并行的 comment_preview。"""
texts: list[str] = [] texts: list[str] = []
for row in comment_rows_in_group: for row in comment_rows_in_group:
t = _cell(row, "tagCommentContent") t = _cell(row, _COMMENT_CSV_BODY, "tagCommentContent")
if t: if t:
texts.append(t) texts.append(t)
if texts: if texts:
@ -1143,7 +1246,11 @@ def _comment_text_units_for_matrix_group(
for row in merged_rows: for row in merged_rows:
if _competitor_matrix_group_key(row) != gname: if _competitor_matrix_group_key(row) != gname:
continue continue
p = _cell(row, "comment_preview") p = _cell(
row,
MERGED_FIELD_TO_CSV_HEADER["comment_preview"],
"comment_preview",
)
if p: if p:
texts.append(p) texts.append(p)
return texts return texts
@ -1201,7 +1308,7 @@ def _consumer_feedback_by_matrix_group(
merged_by_sku[s] = row merged_by_sku[s] = row
by_g: dict[str, list[dict[str, str]]] = {} by_g: dict[str, list[dict[str, str]]] = {}
for row in comment_rows: for row in comment_rows:
sku = _cell(row, "sku").strip() sku = _cell(row, _COMMENT_CSV_SKU, "sku").strip()
g = sku_map.get(sku) g = sku_map.get(sku)
if g: if g:
by_g.setdefault(g, []).append(row) by_g.setdefault(g, []).append(row)
@ -1370,10 +1477,10 @@ def _search_list_proxies(rows: list[dict[str, str]]) -> dict[str, Any]:
""" """
基于 pc_search_export 列表可见度指标**不是**全渠道零售额或 TAM 基于 pc_search_export 列表可见度指标**不是**全渠道零售额或 TAM
""" """
sku_k = "SKU(skuId)" sku_k = JD_SEARCH_CSV_HEADERS["sku_id"]
shop_k = "店铺名(shopName)" shop_k = JD_SEARCH_CSV_HEADERS["shop_name"]
page_k = "页码(page)" page_k = JD_SEARCH_CSV_HEADERS["page"]
cat_k = "类目(leafCategory,cid3Name,catid)" cat_k = JD_SEARCH_CSV_HEADERS["leaf_category"]
skus: set[str] = set() skus: set[str] = set()
shops: set[str] = set() shops: set[str] = set()
pages: set[str] = set() pages: set[str] = set()
@ -1490,32 +1597,31 @@ def _merged_rows_grouped_for_matrix(
return sorted(buckets.items(), key=sort_key) return sorted(buckets.items(), key=sort_key)
def _category_mix(rows: list[dict[str, str]]) -> list[tuple[str, int]]: def _category_mix(
"""深入合并表:仅统计具备可读细类标签的 ``detail_category_path``。""" rows: list[dict[str, str]], *, top_k: int = 12
) -> list[tuple[str, int]]:
"""
可读细类标签统计 SKU 分布 §5 ``_competitor_matrix_group_key`` 同源
仅含 ``detail_category_path`` 可解析为展示名的行
"""
labels: list[str] = [] labels: list[str] = []
for r in rows: for r in rows:
k = _matrix_group_label_from_detail_path(r) k = _matrix_group_label_from_detail_path(r)
if k: if k:
labels.append(k) labels.append(k)
return Counter(labels).most_common(8) return Counter(labels).most_common(top_k)
def _category_mix_search_export(rows: list[dict[str, str]]) -> list[tuple[str, int]]:
"""列表导出:仅当行上存在 ``detail_category_path`` 时纳入(与 §5 口径一致)。"""
labels: list[str] = []
for r in rows:
k = _matrix_group_label_from_detail_path(r)
if k:
labels.append(k)
return Counter(labels).most_common(12)
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]:
if list_export: if list_export:
return [_cell(r, "店铺名(shopName)") for r in rows if _cell(r, "店铺名(shopName)")] return [
_cell(r, JD_SEARCH_CSV_HEADERS["shop_name"], _LEGACY_SHOP_NAME_KEY)
for r in rows
if _cell(r, JD_SEARCH_CSV_HEADERS["shop_name"], _LEGACY_SHOP_NAME_KEY)
]
out: list[str] = [] out: list[str] = []
for r in rows: for r in rows:
s = _cell(r, "detail_shop_name") or _cell(r, "店铺名(shopName)") s = _cell(r, *_MERGED_SHOP_CELL_KEYS)
if s: if s:
out.append(s) out.append(s)
return out return out
@ -1523,17 +1629,16 @@ def _structure_shops(rows: list[dict[str, str]], *, list_export: bool) -> list[s
def _structure_brands(rows: list[dict[str, str]], *, list_export: bool) -> list[str]: def _structure_brands(rows: list[dict[str, str]], *, list_export: bool) -> list[str]:
if list_export: if list_export:
k = "店铺信息标题(shopInfoTitle,brandName)" return [
return [_cell(r, k) for r in rows if _cell(r, k)] _cell(r, _LIST_BRAND_TITLE_HEADER, _LEGACY_LIST_BRAND_TITLE_KEY)
return [_cell(r, "detail_brand") for r in rows if _cell(r, "detail_brand")] for r in rows
if _cell(r, _LIST_BRAND_TITLE_HEADER, _LEGACY_LIST_BRAND_TITLE_KEY)
]
def _structure_category_mix( return [
rows: list[dict[str, str]], *, list_export: bool _cell(r, MERGED_FIELD_TO_CSV_HEADER["detail_brand"], "detail_brand")
) -> list[tuple[str, int]]: for r in rows
if list_export: if _cell(r, MERGED_FIELD_TO_CSV_HEADER["detail_brand"], "detail_brand")
return _category_mix_search_export(rows) ]
return _category_mix(rows)
def _is_ingredient_url_blob(s: str) -> bool: def _is_ingredient_url_blob(s: str) -> bool:
@ -1572,11 +1677,20 @@ def _matrix_ingredients_cell(row: dict[str, str], *, max_len: int = 420) -> str:
优先 ``detail_body_ingredients``配料 OCR/文本旧合并表可能为 ``detail_body_image_urls`` 优先 ``detail_body_ingredients``配料 OCR/文本旧合并表可能为 ``detail_body_image_urls``
若为 URL 串则尝试 ``detail_product_attributes`` 中的配料/配料表片段 若为 URL 串则尝试 ``detail_product_attributes`` 中的配料/配料表片段
""" """
raw = _cell(row, "detail_body_ingredients", "detail_body_image_urls") raw = _cell(
row,
MERGED_FIELD_TO_CSV_HEADER["detail_body_ingredients"],
"detail_body_ingredients",
"detail_body_image_urls",
)
if raw and not _is_ingredient_url_blob(raw): if raw and not _is_ingredient_url_blob(raw):
return _md_cell(_ingredients_single_line(raw), max_len) return _md_cell(_ingredients_single_line(raw), max_len)
from_attr = _ingredients_from_product_attributes( from_attr = _ingredients_from_product_attributes(
_cell(row, "detail_product_attributes") _cell(
row,
MERGED_FIELD_TO_CSV_HEADER["detail_product_attributes"],
"detail_product_attributes",
)
) )
if from_attr: if from_attr:
return _md_cell(from_attr, max_len) return _md_cell(from_attr, max_len)
@ -1593,19 +1707,28 @@ def _competitor_matrix_md_line(
) -> str: ) -> str:
sku = _md_cell(_cell(row, sku_header), 14) sku = _md_cell(_cell(row, sku_header), 14)
title = _md_cell(_cell(row, title_h), 56) title = _md_cell(_cell(row, title_h), 56)
brand = _md_cell(_cell(row, "detail_brand"), 16) brand = _md_cell(
pj = _md_cell(_cell(row, "标价(jdPrice,jdPriceText,realPrice)"), 10) _cell(row, MERGED_FIELD_TO_CSV_HEADER["detail_brand"], "detail_brand"), 16
df = _md_cell(_cell(row, "detail_price_final"), 10) )
shop = _md_cell(_cell(row, "店铺名(shopName)", "detail_shop_name"), 22) pj = _md_cell(_cell(row, *_LIST_SHOW_PRICE_CELL_KEYS), 10)
sell = _md_cell(_cell(row, "卖点(sellingPoint)"), 36) df = _md_cell(_cell(row, *_DETAIL_PRICE_FINAL_CSV_KEYS), 10)
shop = _md_cell(_cell(row, *_MERGED_SHOP_CELL_KEYS), 22)
sell = _md_cell(_cell(row, _SELLING_POINT_KEY, _LEGACY_SELLING_POINT_KEY), 36)
rank = _md_cell( rank = _md_cell(
_cell(row, "榜单类文案(标签/腰带/标题数组中的榜、TOP 等)"), 28 _cell(row, _RANK_TAGLINE_KEY, _LEGACY_RANK_TAGLINE_KEY), 28
) )
cat = _md_cell(_detail_category_path_cell(row), 24) cat = _md_cell(_detail_category_path_cell(row), 24)
ing = _matrix_ingredients_cell(row) ing = _matrix_ingredients_cell(row)
ts_eff = merged_csv_effective_total_sales(row) ts_eff = merged_csv_effective_total_sales(row)
cc = _md_cell(ts_eff or _cell(row, "评价量(commentFuzzy)"), 14) cc = _md_cell(ts_eff or _cell(row, *_COMMENT_FUZZ_KEYS), 14)
prev = _md_cell(_cell(row, "comment_preview"), 72) prev = _md_cell(
_cell(
row,
MERGED_FIELD_TO_CSV_HEADER["comment_preview"],
"comment_preview",
),
72,
)
return ( return (
f"| {sku} | {title} | {brand} | {pj} | {df} | {shop} | {sell} | {rank} | " f"| {sku} | {title} | {brand} | {pj} | {df} | {shop} | {sell} | {rank} | "
f"{cat} | {ing} | {cc} | {prev} |" f"{cat} | {ing} | {cc} | {prev} |"
@ -1676,7 +1799,7 @@ def _embed_chart(run_dir: Path, filename: str, caption: str = "") -> list[str]:
def _scenario_group_asset_slug(group: str, index: int) -> str: def _scenario_group_asset_slug(group: str, index: int) -> str:
"""与 ``pipeline.report_charts`` 中场景分组图文件名规则一致(勿改格式)。""" """与 ``pipeline.reporting.charts`` 中场景分组图文件名规则一致(勿改格式)。"""
raw = (group or "").strip() raw = (group or "").strip()
core = re.sub(r"[^\w\u4e00-\u9fff-]", "", raw)[:20] core = re.sub(r"[^\w\u4e00-\u9fff-]", "", raw)[:20]
if not core: if not core:
@ -1685,13 +1808,13 @@ def _scenario_group_asset_slug(group: str, index: int) -> str:
def _focus_scenario_combo_bar_filename(group: str, index: int) -> str: def _focus_scenario_combo_bar_filename(group: str, index: int) -> str:
"""关注词 + 使用场景并排条形图(与 ``report_charts.save_combo_focus_scenario_bar`` 同源)。""" """关注词 + 使用场景并排条形图(与 ``pipeline.reporting.charts.save_combo_focus_scenario_bar`` 同源)。"""
slug = _scenario_group_asset_slug(group, index) slug = _scenario_group_asset_slug(group, index)
return f"chart_focus_and_scenarios_bar__{slug}.png" return f"chart_focus_and_scenarios_bar__{slug}.png"
def _matrix_prices_sales_chart_filename(group: str, index: int) -> str: def _matrix_prices_sales_chart_filename(group: str, index: int) -> str:
"""与 ``report_charts.generate_report_charts`` 中 ``chart_matrix_prices_sales__*`` 一致。""" """与 ``pipeline.reporting.charts.generate_report_charts`` 中 ``chart_matrix_prices_sales__*`` 一致。"""
slug = _scenario_group_asset_slug(group, index) slug = _scenario_group_asset_slug(group, index)
return f"chart_matrix_prices_sales__{slug}.png" return f"chart_matrix_prices_sales__{slug}.png"
@ -1753,28 +1876,31 @@ def _lines_4_reading_shop(
def _lines_4_reading_category( def _lines_4_reading_category(
cm_structure: list[tuple[str, int]], cm_structure: list[tuple[str, int]],
*,
n_merged_sku: int,
n_sku_matrix: int,
) -> list[str]: ) -> list[str]:
if not cm_structure: if not cm_structure or n_sku_matrix <= 0:
return []
total = sum(c for _, c in cm_structure)
if total <= 0:
return [] return []
top_lbl, top_c = cm_structure[0] top_lbl, top_c = cm_structure[0]
share = top_c / total share = top_c / float(n_sku_matrix)
lines = [ lines = [
"", "",
"**数据解读(规则摘要)**", "**数据解读(规则摘要)**",
"", "",
f"- 具备 ``detail_category_path`` 且可解析为细类标签的结构行共 **{total}** 行,对应 **{len(cm_structure)}** 种细类取值(与 §5 同源);" f"- **统计口径**:与 **§5 竞品矩阵**相同,均来自深入合并表列 ``detail_category_path`` 解析出的可读细类标签。",
f"其中「{_md_cell(top_lbl, 40)}」行数最多,约占 **{100 * share:.1f}%**。", f"- **有效总量**:合并表共 **{n_merged_sku}** 个 SKU其中 **{n_sku_matrix}** 个具备可解析细类标签(**有效细类 SKU**"
"- 若头部细类占比极高说明当前关键词下货架被少数品类定义跨品类机会需结合商详矩阵§5再核对。", f"**{max(0, n_merged_sku - n_sku_matrix)}** 个无可用路径或无法解析,与 §5 一致**不纳入**本小节分布。",
f"- 扇形图与简报包按 SKU 数取 **Top 12** 细类;下表列 **Top 5**。当前「{_md_cell(top_lbl, 40)}」款数最多,"
f"约占有效细类 SKU 的 **{100 * share:.1f}%**{top_c}/{n_sku_matrix})。",
"- 若头部细类占比极高,说明当前关键词下深入样本被少数品类定义;跨品类机会需结合 §5 矩阵再核对。",
"", "",
"| 细类标签Top 5 | 结构行数 | 占本章有效行 |", "| 细类标签Top 5 | SKU 数 | 占有效细类 SKU 数 |",
"| --- | ---: | ---: |", "| --- | ---: | ---: |",
] ]
for lbl, cnt in cm_structure[:5]: for lbl, cnt in cm_structure[:5]:
lines.append( lines.append(
f"| {_md_cell(lbl, 36)} | {cnt} | {100 * cnt / total:.1f}% |" f"| {_md_cell(lbl, 36)} | {cnt} | {100 * cnt / float(n_sku_matrix):.1f}% |"
) )
lines.append("") lines.append("")
return lines return lines
@ -1796,8 +1922,8 @@ def build_competitor_markdown(
llm_comment_groups_section_md: str | None = None, llm_comment_groups_section_md: str | None = None,
) -> str: ) -> str:
focus_words, scenario_groups, external_rows = resolve_report_tuning(report_config) focus_words, scenario_groups, external_rows = resolve_report_tuning(report_config)
sku_header = "SKU(skuId)" sku_header = MERGED_FIELD_TO_CSV_HEADER["sku_id"]
title_h = "标题(wareName)" title_h = MERGED_FIELD_TO_CSV_HEADER["title"]
batch = _run_batch_label(run_dir) batch = _run_batch_label(run_dir)
n_sku = len(merged_rows) n_sku = len(merged_rows)
n_cmt = len(comment_rows) n_cmt = len(comment_rows)
@ -1811,10 +1937,15 @@ def build_competitor_markdown(
brands_s = _structure_brands(structure_rows, list_export=list_export) brands_s = _structure_brands(structure_rows, list_export=list_export)
cr1_shop, cr3_shop, top_shop_s, _ = _brand_cr(shops_s) cr1_shop, cr3_shop, top_shop_s, _ = _brand_cr(shops_s)
cr1_list_brand, cr3_list_brand, top_list_brand, _ = _brand_cr(brands_s) cr1_list_brand, cr3_list_brand, top_list_brand, _ = _brand_cr(brands_s)
cm_structure = _structure_category_mix(structure_rows, list_export=list_export) # §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 min_brand_rows = max(5, int(0.02 * n_structure)) if n_structure else 5
brands_deep = [_cell(r, "detail_brand") for r in merged_rows if _cell(r, "detail_brand")] 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_deep, cr3_deep, top_brand_deep, _top_share_deep = _brand_cr(brands_deep)
cr1_hints = ( cr1_hints = (
cr1_shop if list_export and cr1_shop is not None else cr1_deep cr1_shop if list_export and cr1_shop is not None else cr1_deep
@ -2217,22 +2348,28 @@ def build_competitor_markdown(
lines.append("*无店铺字段。*") lines.append("*无店铺字段。*")
lines.append("") lines.append("")
lines.extend(["### 4.3 类目/叶子类目(列表字段 Top", ""]) lines.extend(["### 4.3 细分类目分布(深入合并表 · 与 §5 矩阵同口径", ""])
if cm_structure: if cm_structure and n_sku_matrix > 0:
lines.extend( lines.extend(
_embed_chart( _embed_chart(
run_dir, run_dir,
"chart_category_mix_pie.png", "chart_category_mix_pie.png",
"细类标签分布(扇形图;自 ``detail_category_path`` 解析,与 §5 口径一致)", "细类标签分布(扇形图;合并表 ``detail_category_path``,与 §5 同源)",
)
)
lines.extend(
_lines_4_reading_category(
cm_structure,
n_merged_sku=n_sku,
n_sku_matrix=n_sku_matrix,
) )
) )
lines.extend(_lines_4_reading_category(cm_structure))
lines.append( lines.append(
"*完整类目分布见界面「数据摘要」或简报包中的数据文件。*" "*完整类目分布见界面「数据摘要」或简报包中的数据文件。*"
) )
else: else:
lines.append( lines.append(
"*结构样本中无带 ``detail_category_path`` 的可解析细类行,本小节不展示扇形图;细类分布以 §5 为准。*" "*深入合并表中无具备可解析 ``detail_category_path`` 细类标签的 SKU本小节不展示扇形图请核对商详抓取与合并字段。*"
) )
lines.append("") lines.append("")
@ -2586,11 +2723,12 @@ def build_competitor_brief(
``build_competitor_markdown`` 共用统计口径输出可 JSON 序列化的结构化竞品摘要**规则驱动** LLM ``build_competitor_markdown`` 共用统计口径输出可 JSON 序列化的结构化竞品摘要**规则驱动** LLM
""" """
focus_words, scenario_groups, _ext = resolve_report_tuning(report_config) focus_words, scenario_groups, _ext = resolve_report_tuning(report_config)
sku_header = "SKU(skuId)" sku_header = MERGED_FIELD_TO_CSV_HEADER["sku_id"]
title_h = "标题(wareName)" title_h = MERGED_FIELD_TO_CSV_HEADER["title"]
batch = _run_batch_label(run_dir) batch = _run_batch_label(run_dir)
n_sku = len(merged_rows) n_sku = len(merged_rows)
n_cmt = len(comment_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 list_export = len(search_export_rows) > 0
structure_rows = search_export_rows if list_export else merged_rows structure_rows = search_export_rows if list_export else merged_rows
@ -2599,11 +2737,13 @@ def build_competitor_brief(
brands_s = _structure_brands(structure_rows, list_export=list_export) brands_s = _structure_brands(structure_rows, list_export=list_export)
cr1_shop, cr3_shop, top_shop_s, top_shop_share = _brand_cr(shops_s) cr1_shop, cr3_shop, top_shop_s, top_shop_share = _brand_cr(shops_s)
cr1_list_brand, cr3_list_brand, top_list_brand, _ = _brand_cr(brands_s) cr1_list_brand, cr3_list_brand, top_list_brand, _ = _brand_cr(brands_s)
cm_structure = _structure_category_mix(structure_rows, list_export=list_export) cm_structure = _category_mix(merged_rows, top_k=12)
min_brand_rows = max(5, int(0.02 * n_structure)) if n_structure else 5 min_brand_rows = max(5, int(0.02 * n_structure)) if n_structure else 5
brands_deep = [ brands_deep = [
_cell(r, "detail_brand") for r in merged_rows if _cell(r, "detail_brand") _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( cr1_deep, cr3_deep, top_brand_deep, top_brand_deep_share = _brand_cr(
brands_deep brands_deep
@ -2676,21 +2816,26 @@ def build_competitor_brief(
{ {
"sku_id": _cell(row, sku_header), "sku_id": _cell(row, sku_header),
"title": _cell(row, title_h), "title": _cell(row, title_h),
"brand": _cell(row, "detail_brand"), "brand": _cell(
row,
MERGED_FIELD_TO_CSV_HEADER["detail_brand"],
"detail_brand",
),
"list_price_show": _cell( "list_price_show": _cell(
row, "标价(jdPrice,jdPriceText,realPrice)" row, *_LIST_SHOW_PRICE_CELL_KEYS
), ),
"coupon_or_detail_price": _cell( "coupon_or_detail_price": _cell(
row, row,
"券后到手价(couponPrice,subsidyPrice,finalPrice.estimatedPrice,priceShow)", _COUPON_SHOW_PRICE_KEY,
), _LEGACY_COUPON_SHOW_PRICE_KEY,
"detail_price_final": _cell(row, "detail_price_final"),
"shop": _cell(
row, "店铺名(shopName)", "detail_shop_name"
), ),
"detail_price_final": _cell(row, *_DETAIL_PRICE_FINAL_CSV_KEYS),
"shop": _cell(row, *_MERGED_SHOP_CELL_KEYS),
"category": _detail_category_path_cell(row), "category": _detail_category_path_cell(row),
"selling_point": _cell(row, "卖点(sellingPoint)")[:240], "selling_point": _cell(
"comment_fuzzy": _cell(row, "评价量(commentFuzzy)"), row, _SELLING_POINT_KEY, _LEGACY_SELLING_POINT_KEY
)[:240],
"comment_fuzzy": _cell(row, *_COMMENT_FUZZ_KEYS),
"total_sales": merged_csv_effective_total_sales(row), "total_sales": merged_csv_effective_total_sales(row),
} }
) )
@ -2789,6 +2934,8 @@ def build_competitor_brief(
"comment_flat_rows": n_cmt, "comment_flat_rows": n_cmt,
"structure_source_rows": n_structure, "structure_source_rows": n_structure,
"uses_pc_search_list_export": list_export, "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, "meta": meta_slice or None,
"pc_search_raw": { "pc_search_raw": {

View File

@ -16,7 +16,8 @@
每次运行默认在 ``data/JD/pipeline_runs/<时间戳>_<关键词>/`` 下集中写入合并表 每次运行默认在 ``data/JD/pipeline_runs/<时间戳>_<关键词>/`` 下集中写入合并表
PC 搜索导出 CSV评价扁平 CSV详情汇总 CSV``detail_ware_export.csv`` PC 搜索导出 CSV评价扁平 CSV详情汇总 CSV``detail_ware_export.csv``
SKU 规整 JSON``detail/ware_{sku}_response.json``以及可选pc_search 原始包与请求记录 SKU 规整 JSON``detail/ware_{sku}_response.json``
购买者优惠摘要 JSON``buyer_offer_profiles/ware_{sku}_buyer_profile.json``以及可选pc_search 原始包与请求记录
合并表 ``keyword_pipeline_merged.csv`` 默认 ``MERGED_CSV_MODE=lean``搜索全列 + **竞品报告/入库实际用到的商详子集** ``_MERGED_LEAN_DETAIL_FIELDNAMES``+ 评论摘要全量商详扁平请设 ``MERGED_CSV_MODE="full"````WARE_BUSINESS_MERGE_FIELDNAMES`` 合并表 ``keyword_pipeline_merged.csv`` 默认 ``MERGED_CSV_MODE=lean``搜索全列 + **竞品报告/入库实际用到的商详子集** ``_MERGED_LEAN_DETAIL_FIELDNAMES``+ 评论摘要全量商详扁平请设 ``MERGED_CSV_MODE="full"````WARE_BUSINESS_MERGE_FIELDNAMES``
``detail_ware_export.csv`` 默认 ``DETAIL_WARE_CSV_MODE=lean`` ``skuId`` + 与合并表一致的商详子集品牌/到手价/店铺/类目/参数/配料全列请设 ``DETAIL_WARE_CSV_MODE="full"`` ``detail_ware_export.csv`` 默认 ``DETAIL_WARE_CSV_MODE=lean`` ``skuId`` + 与合并表一致的商详子集品牌/到手价/店铺/类目/参数/配料全列请设 ``DETAIL_WARE_CSV_MODE="full"``
@ -110,6 +111,8 @@ FILE_PC_SEARCH_CSV = "pc_search_export.csv"
FILE_COMMENTS_FLAT_CSV = "comments_flat.csv" FILE_COMMENTS_FLAT_CSV = "comments_flat.csv"
FILE_DETAIL_WARE_CSV = "detail_ware_export.csv" FILE_DETAIL_WARE_CSV = "detail_ware_export.csv"
FILE_RUN_META_JSON = "run_meta.json" FILE_RUN_META_JSON = "run_meta.json"
# 与 ``detail/`` 同级:购买者视角优惠摘要(非原始接口 JSON
DIR_BUYER_OFFER_PROFILES = "buyer_offer_profiles"
# MERGED_CSV_MODE``lean`` 时合并表为搜索全列 + 商详子集(``_MERGED_LEAN_DETAIL_FIELDNAMES``+ 评论摘要;``full`` 为搜索全列 + ``WARE_BUSINESS_MERGE_FIELDNAMES`` 全量 # MERGED_CSV_MODE``lean`` 时合并表为搜索全列 + 商详子集(``_MERGED_LEAN_DETAIL_FIELDNAMES``+ 评论摘要;``full`` 为搜索全列 + ``WARE_BUSINESS_MERGE_FIELDNAMES`` 全量
MERGED_CSV_MODE = "lean" MERGED_CSV_MODE = "lean"
# DETAIL_WARE_CSV_MODE``lean`` 时 ``detail_ware_export.csv`` 为 ``skuId`` + lean 商详子集;``full`` 为完整详情扁平列(含 http_status 与各 detail_* # DETAIL_WARE_CSV_MODE``lean`` 时 ``detail_ware_export.csv`` 为 ``skuId`` + lean 商详子集;``full`` 为完整详情扁平列(含 http_status 与各 detail_*
@ -136,12 +139,25 @@ for _p in (_SEARCH_DIR, _COMMENT_DIR, _DETAIL_DIR):
if s not in sys.path: if s not in sys.path:
sys.path.insert(0, s) sys.path.insert(0, s)
_BACKEND_ROOT = Path(__file__).resolve().parents[2]
if str(_BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(_BACKEND_ROOT))
from pipeline.csv_schema import ( # noqa: E402
MERGED_CSV_COLUMNS,
remap_merged_row_english_detail_keys_to_csv_headers,
)
from collect_pc_search_items import ( # noqa: E402 from collect_pc_search_items import ( # noqa: E402
SearchCollectionCancelled, SearchCollectionCancelled,
collect_pc_search_export_rows, collect_pc_search_export_rows,
) )
from common.jd_delay_utils import parse_request_delay_range # noqa: E402 from common.jd_delay_utils import parse_request_delay_range # noqa: E402
from scenario_filter import filter_rows_by_scenario # noqa: E402 from scenario_filter import filter_rows_by_scenario # noqa: E402
from jd_detail_buyer_extraction import ( # noqa: E402
buyer_promo_text_from_profile,
buyer_ranking_line_from_profile,
extract_buyer_offer_profile_from_json_text,
)
from jd_detail_ware_business_requests import ( # noqa: E402 from jd_detail_ware_business_requests import ( # noqa: E402
DETAIL_WARE_LEAN_CSV_FIELDNAMES, DETAIL_WARE_LEAN_CSV_FIELDNAMES,
WARE_BUSINESS_MERGE_FIELDNAMES, WARE_BUSINESS_MERGE_FIELDNAMES,
@ -177,41 +193,14 @@ _MERGED_EXTRA_FIELDS = (
+ ["comment_count", "comment_preview"] + ["comment_count", "comment_preview"]
) )
# lean 合并表·商详块jd_competitor_report + ingest + 配料);须与 pipeline/csv_schema.MERGED_LEAN_DETAIL_KEYS 一致
_MERGED_LEAN_DETAIL_FIELDNAMES: tuple[str, ...] = (
"detail_brand",
"detail_price_final",
"detail_shop_name",
"detail_category_path",
"detail_product_attributes",
"detail_body_ingredients",
)
# 合并表精简列:搜索列与 jd_h5_search_requests 一致 + 上表商详子集 + 评论摘要 def _finalize_merged_row_for_disk(merged: dict[str, str]) -> None:
_MERGED_LEAN_FIELDNAMES: tuple[str, ...] = ( """英文内部键 → 中文 CSV 列名;评论摘要列名。"""
"pipeline_keyword", remap_merged_row_english_detail_keys_to_csv_headers(merged)
"SKU(skuId)", if "comment_count" in merged:
"主商品ID(wareId)", merged["评论条数"] = str(merged.pop("comment_count") or "")
"标题(wareName)", if "comment_preview" in merged:
"标价(jdPrice,jdPriceText,realPrice)", merged["评价摘要"] = str(merged.pop("comment_preview") or "")
"券后到手价(couponPrice,subsidyPrice,finalPrice.estimatedPrice,priceShow)",
"原价(oriPrice,originalPrice,marketPrice)",
"卖点(sellingPoint)",
"榜单类文案(标签/腰带/标题数组中的榜、TOP 等)",
"评价量(commentFuzzy)",
"销量楼层(commentSalesFloor)",
"销量口径(totalSales)",
"店铺名(shopName)",
"商品链接(toUrl,clickUrl,item.m.jd.com)",
"主图(imageurl,imageUrl)",
"规格属性(propertyList,color,catid,shortName)",
"类目(leafCategory,cid3Name,catid)",
"搜索词(keyword)",
"页码(page)",
*_MERGED_LEAN_DETAIL_FIELDNAMES,
"comment_count",
"comment_preview",
)
def _merged_csv_fieldnames() -> list[str]: def _merged_csv_fieldnames() -> list[str]:
@ -219,7 +208,22 @@ def _merged_csv_fieldnames() -> list[str]:
return list(CSV_FIELDS) + [ return list(CSV_FIELDS) + [
f for f in _MERGED_EXTRA_FIELDS if f not in CSV_FIELDS f for f in _MERGED_EXTRA_FIELDS if f not in CSV_FIELDS
] ]
return list(_MERGED_LEAN_FIELDNAMES) return list(MERGED_CSV_COLUMNS)
def _normalize_merged_rows_for_export(rows: list[dict[str, str]]) -> None:
"""
整合表落盘前搜索侧榜单类文案榜单排名去掉 ``榜单/曝光`` 前缀
``strip_buyer_ranking_line_prefix`` / 入库口径一致
"""
from pipeline.csv_schema import strip_buyer_ranking_line_prefix # noqa: WPS433
hot_key = "榜单类文案"
rank_key = "榜单排名"
for merged in rows:
if merged.get(hot_key):
merged[hot_key] = strip_buyer_ranking_line_prefix(merged[hot_key])
merged[rank_key] = strip_buyer_ranking_line_prefix(merged.get(rank_key) or "")
def _detail_ware_csv_fieldnames() -> list[str]: def _detail_ware_csv_fieldnames() -> list[str]:
@ -506,6 +510,8 @@ def main(keyword: str | None = None) -> Path:
detail_dir = run_dir / "detail" detail_dir = run_dir / "detail"
detail_dir.mkdir(parents=True, exist_ok=True) detail_dir.mkdir(parents=True, exist_ok=True)
buyer_prof_dir = run_dir / DIR_BUYER_OFFER_PROFILES
buyer_prof_dir.mkdir(parents=True, exist_ok=True)
detail_ctx = browser.new_context( detail_ctx = browser.new_context(
user_agent=_JD_DETAIL_UA, user_agent=_JD_DETAIL_UA,
@ -534,7 +540,7 @@ def main(keyword: str | None = None) -> Path:
{}, {},
) )
merged: dict[str, str] = {k: str(search_row.get(k) or "") for k in CSV_FIELDS} merged: dict[str, str] = {k: str(search_row.get(k) or "") for k in CSV_FIELDS}
merged["pipeline_keyword"] = kw merged["流水线关键词"] = kw
if stop_pipeline or _pipeline_cancel_requested(): if stop_pipeline or _pipeline_cancel_requested():
stop_pipeline = True stop_pipeline = True
@ -628,6 +634,23 @@ def main(keyword: str | None = None) -> Path:
(detail_dir / f"ware_{sku}_response.json").write_text( (detail_dir / f"ware_{sku}_response.json").write_text(
response_body, encoding="utf-8" response_body, encoding="utf-8"
) )
rline, ptext = "", ""
if response_body.strip():
try:
_prof = extract_buyer_offer_profile_from_json_text(response_body)
rline = buyer_ranking_line_from_profile(_prof)
ptext = buyer_promo_text_from_profile(_prof)
(buyer_prof_dir / f"ware_{sku}_buyer_profile.json").write_text(
json.dumps(_prof, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
except Exception as e:
print(
f"[流水线] sku={sku} 购买者摘要写入失败: {e}",
file=sys.stderr,
)
merged["buyer_ranking_line"] = rline
merged["buyer_promo_text"] = ptext
_d_ing = str(merged.get("detail_body_ingredients") or "").strip() _d_ing = str(merged.get("detail_body_ingredients") or "").strip()
_d_src = str( _d_src = str(
merged.get("detail_body_ingredients_source_url") or "" merged.get("detail_body_ingredients_source_url") or ""
@ -650,6 +673,8 @@ def main(keyword: str | None = None) -> Path:
d_text or "", d_text or "",
detail_body_ingredients=_d_ing, detail_body_ingredients=_d_ing,
detail_body_ingredients_source_url=_d_src, detail_body_ingredients_source_url=_d_src,
buyer_ranking_line=rline,
buyer_promo_text=ptext,
) )
) )
@ -657,6 +682,7 @@ def main(keyword: str | None = None) -> Path:
stop_pipeline = True stop_pipeline = True
merged["comment_count"] = "0" merged["comment_count"] = "0"
merged["comment_preview"] = "" merged["comment_preview"] = ""
_finalize_merged_row_for_disk(merged)
merged_rows.append(merged) merged_rows.append(merged)
break break
@ -666,6 +692,7 @@ def main(keyword: str | None = None) -> Path:
stop_pipeline = True stop_pipeline = True
merged["comment_count"] = "0" merged["comment_count"] = "0"
merged["comment_preview"] = "" merged["comment_preview"] = ""
_finalize_merged_row_for_disk(merged)
merged_rows.append(merged) merged_rows.append(merged)
break break
@ -679,6 +706,7 @@ def main(keyword: str | None = None) -> Path:
except SystemExit: except SystemExit:
merged["comment_count"] = "0" merged["comment_count"] = "0"
merged["comment_preview"] = "" merged["comment_preview"] = ""
_finalize_merged_row_for_disk(merged)
merged_rows.append(merged) merged_rows.append(merged)
continue continue
@ -686,6 +714,7 @@ def main(keyword: str | None = None) -> Path:
stop_pipeline = True stop_pipeline = True
merged["comment_count"] = "0" merged["comment_count"] = "0"
merged["comment_preview"] = "" merged["comment_preview"] = ""
_finalize_merged_row_for_disk(merged)
merged_rows.append(merged) merged_rows.append(merged)
break break
@ -795,6 +824,7 @@ def main(keyword: str | None = None) -> Path:
merged["comment_count"] = "0" merged["comment_count"] = "0"
merged["comment_preview"] = "" merged["comment_preview"] = ""
_finalize_merged_row_for_disk(merged)
merged_rows.append(merged) merged_rows.append(merged)
print(f"[流水线] [{idx + 1}/{len(skus_ordered)}] sku={sku} OK", file=sys.stderr) print(f"[流水线] [{idx + 1}/{len(skus_ordered)}] sku={sku} OK", file=sys.stderr)
if stop_pipeline: if stop_pipeline:
@ -812,6 +842,7 @@ def main(keyword: str | None = None) -> Path:
out_path = run_dir / FILE_MERGED_CSV out_path = run_dir / FILE_MERGED_CSV
fieldnames = _merged_csv_fieldnames() fieldnames = _merged_csv_fieldnames()
_normalize_merged_rows_for_export(merged_rows)
buf = StringIO() buf = StringIO()
w = csv.DictWriter(buf, fieldnames=fieldnames, extrasaction="ignore") w = csv.DictWriter(buf, fieldnames=fieldnames, extrasaction="ignore")
w.writeheader() w.writeheader()
@ -840,6 +871,26 @@ def main(keyword: str | None = None) -> Path:
file=sys.stderr, file=sys.stderr,
) )
_backend_root = Path(__file__).resolve().parents[2]
if str(_backend_root) not in sys.path:
sys.path.insert(0, str(_backend_root))
try:
from pipeline.jd.buyer_offer_export_csv import ( # noqa: WPS433
export_buyer_offer_with_detail_csv,
)
export_buyer_offer_with_detail_csv(run_dir)
print(
f"[流水线] 已写 {DIR_BUYER_OFFER_PROFILES}/"
"buyer_offer_with_detail.csv与 detail_ware 列一致,含购买者摘要)",
file=sys.stderr,
)
except Exception as e:
print(
f"[流水线] 跳过 buyer_offer_with_detail.csv{e}",
file=sys.stderr,
)
comments_path = run_dir / FILE_COMMENTS_FLAT_CSV comments_path = run_dir / FILE_COMMENTS_FLAT_CSV
write_comments_flat_csv(comments_path, all_comment_rows) write_comments_flat_csv(comments_path, all_comment_rows)
print( print(
@ -870,6 +921,7 @@ def main(keyword: str | None = None) -> Path:
"detail_ware_csv_column_count": len(detail_fn), "detail_ware_csv_column_count": len(detail_fn),
"comment_flat_rows": len(all_comment_rows), "comment_flat_rows": len(all_comment_rows),
"detail_ware_csv_rows": len(detail_csv_rows), "detail_ware_csv_rows": len(detail_csv_rows),
"buyer_offer_profiles_dir": DIR_BUYER_OFFER_PROFILES,
"with_comment_list": bool(WITH_COMMENT_LIST), "with_comment_list": bool(WITH_COMMENT_LIST),
"list_pages": (LIST_PAGES or "").strip(), "list_pages": (LIST_PAGES or "").strip(),
} }

View File

@ -10,14 +10,21 @@
from __future__ import annotations from __future__ import annotations
import sys
from pathlib import Path
from typing import Any from typing import Any
_BACKEND_ROOT = Path(__file__).resolve().parents[2]
if str(_BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(_BACKEND_ROOT))
from pipeline.csv_schema import JD_SEARCH_CSV_HEADERS # noqa: E402
# 与 CSV 导出列名一致jd_h5_search_requests.CSV_FIELDS 子集) # 与 CSV 导出列名一致jd_h5_search_requests.CSV_FIELDS 子集)
_SCENARIO_TEXT_FIELDS: tuple[str, ...] = ( _SCENARIO_TEXT_FIELDS: tuple[str, ...] = (
"标题(wareName)", JD_SEARCH_CSV_HEADERS["title"],
"卖点(sellingPoint)", JD_SEARCH_CSV_HEADERS["selling_point"],
"类目(leafCategory,cid3Name,catid)", JD_SEARCH_CSV_HEADERS["leaf_category"],
"规格属性(propertyList,color,catid,shortName)", JD_SEARCH_CSV_HEADERS["attributes"],
) )
# 4.1 中式(米)面点及主食(含常见同义/细分) # 4.1 中式(米)面点及主食(含常见同义/细分)

View File

@ -58,7 +58,11 @@ import requests
_JD_PKG_ROOT = Path(__file__).resolve().parent.parent _JD_PKG_ROOT = Path(__file__).resolve().parent.parent
if str(_JD_PKG_ROOT) not in sys.path: if str(_JD_PKG_ROOT) not in sys.path:
sys.path.insert(0, str(_JD_PKG_ROOT)) sys.path.insert(0, str(_JD_PKG_ROOT))
_BACKEND_ROOT = Path(__file__).resolve().parents[3]
if str(_BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(_BACKEND_ROOT))
from common.jd_delay_utils import parse_request_delay_range, sleep_pc_search_request_gap from common.jd_delay_utils import parse_request_delay_range, sleep_pc_search_request_gap
from pipeline.csv_schema import JD_SEARCH_CSV_HEADERS as JD_EXPORT_COLUMN_HEADERS # noqa: E402
_JD_PC_SEARCH_DIR = Path(__file__).resolve().parent _JD_PC_SEARCH_DIR = Path(__file__).resolve().parent
@ -359,39 +363,6 @@ JD_ITEM_CSV_FIELDS = (
"page", "page",
) )
# 导出列名:中文说明(JSON 中主要原始字段名),便于对照接口
JD_EXPORT_COLUMN_HEADERS: dict[str, str] = {
"item_id": "主商品ID(wareId)",
"sku_id": "SKU(skuId)",
"title": "标题(wareName)",
# "title_plain": "标题纯文本(wareName)",
"price": "标价(jdPrice,jdPriceText,realPrice)",
"coupon_price": "券后到手价(couponPrice,subsidyPrice,finalPrice.estimatedPrice,priceShow)",
"original_price": "原价(oriPrice,originalPrice,marketPrice)",
"selling_point": "卖点(sellingPoint)",
"comment_sales_floor": "销量楼层(commentSalesFloor)",
"total_sales": "销量口径(totalSales)",
"hot_list_rank": "榜单类文案(标签/腰带/标题数组中的榜、TOP 等)",
"comment_count": "评价量(commentFuzzy)",
"shop_name": "店铺名(shopName)",
"shop_url": "店铺链接(shopUrl,shopId)",
"shop_info_url": "店铺信息链接(shopInfoUrl,brandUrl)",
"location": "地域(deliveryAddress,area,procity)",
"detail_url": "商品链接(toUrl,clickUrl,item.m.jd.com)",
"image": "主图(imageurl,imageUrl)",
# "video_cover": "视频封面(videoImage,videoPic)",
# "video_dimension": "视频比例(videoRatio)",
"seckill_info": "秒杀(seckillInfo,secKill)",
"attributes": "规格属性(propertyList,color,catid,shortName)",
"leaf_category": "类目(leafCategory,cid3Name,catid)",
# "same_count": "同款数(sameStyleCount,sameCount)",
# "relation_score": "相关度(relationScore,score)",
# "is_p4p": "广告位(isAdv,isAd,extensionId)",
"platform": "平台(platform)",
"keyword": "搜索词(keyword)",
"page": "页码(page)",
}
CSV_FIELDS = tuple(JD_EXPORT_COLUMN_HEADERS[k] for k in JD_ITEM_CSV_FIELDS) CSV_FIELDS = tuple(JD_EXPORT_COLUMN_HEADERS[k] for k in JD_ITEM_CSV_FIELDS)

View File

@ -0,0 +1,236 @@
# -*- coding: utf-8 -*-
"""
将历史 JD 流水线 CSV 表头规范为 ``csv_schema`` 中的纯中文表头仅重命名与列序不改单元格内容逻辑
用于已落盘的 ``pipeline_runs/...`` 目录新跑批次由爬虫直接写出新表头
"""
from __future__ import annotations
import csv
from pathlib import Path
from typing import Iterable
from .csv_schema import (
COMMENT_CSV_COLUMNS,
COMMENT_ROW_DICT_KEYS,
DETAIL_CSV_COLUMNS,
JD_SEARCH_CSV_HEADERS,
JD_SEARCH_INTERNAL_KEYS,
MERGED_CSV_COLUMNS,
MERGED_LEAN_DETAIL_INTERNAL_KEYS,
LEAN_DETAIL_CSV_HEADERS,
)
def _search_fieldnames_canonical() -> list[str]:
return [JD_SEARCH_CSV_HEADERS[k] for k in JD_SEARCH_INTERNAL_KEYS]
def _merged_fieldnames_canonical() -> list[str]:
return list(MERGED_CSV_COLUMNS)
def _detail_fieldnames_canonical() -> list[str]:
return list(DETAIL_CSV_COLUMNS)
def _comment_fieldnames_canonical() -> list[str]:
return list(COMMENT_CSV_COLUMNS)
def build_legacy_header_map() -> dict[str, str]:
"""
旧表头字符串 新表头纯中文或与 schema 一致
覆盖带英文括号的搜索/合并表英文 ``pipeline_keyword`` / ``detail_*``
评价 CSV 的接口字段名商详 ``skuId``
"""
m: dict[str, str] = {}
# --- 合并表 / pc_search 曾用的「括号英文」列名 ---
legacy_search_pairs: tuple[tuple[str, str], ...] = (
("主商品ID(wareId)", JD_SEARCH_CSV_HEADERS["item_id"]),
("SKU(skuId)", JD_SEARCH_CSV_HEADERS["sku_id"]),
("标题(wareName)", JD_SEARCH_CSV_HEADERS["title"]),
(
"标价(jdPrice,jdPriceText,realPrice)",
JD_SEARCH_CSV_HEADERS["price"],
),
(
"券后到手价(couponPrice,subsidyPrice,finalPrice.estimatedPrice,priceShow)",
JD_SEARCH_CSV_HEADERS["coupon_price"],
),
(
"原价(oriPrice,originalPrice,marketPrice)",
JD_SEARCH_CSV_HEADERS["original_price"],
),
("卖点(sellingPoint)", JD_SEARCH_CSV_HEADERS["selling_point"]),
("销量楼层(commentSalesFloor)", JD_SEARCH_CSV_HEADERS["comment_sales_floor"]),
("销量口径(totalSales)", JD_SEARCH_CSV_HEADERS["total_sales"]),
(
"榜单类文案(标签/腰带/标题数组中的榜、TOP 等)",
JD_SEARCH_CSV_HEADERS["hot_list_rank"],
),
("评价量(commentFuzzy)", JD_SEARCH_CSV_HEADERS["comment_count"]),
("店铺名(shopName)", JD_SEARCH_CSV_HEADERS["shop_name"]),
("店铺链接(shopUrl,shopId)", JD_SEARCH_CSV_HEADERS["shop_url"]),
(
"店铺信息链接(shopInfoUrl,brandUrl)",
JD_SEARCH_CSV_HEADERS["shop_info_url"],
),
("地域(deliveryAddress,area,procity)", JD_SEARCH_CSV_HEADERS["location"]),
(
"商品链接(toUrl,clickUrl,item.m.jd.com)",
JD_SEARCH_CSV_HEADERS["detail_url"],
),
("主图(imageurl,imageUrl)", JD_SEARCH_CSV_HEADERS["image"]),
("秒杀(seckillInfo,secKill)", JD_SEARCH_CSV_HEADERS["seckill_info"]),
(
"规格属性(propertyList,color,catid,shortName)",
JD_SEARCH_CSV_HEADERS["attributes"],
),
("类目(leafCategory,cid3Name,catid)", JD_SEARCH_CSV_HEADERS["leaf_category"]),
("平台(platform)", JD_SEARCH_CSV_HEADERS["platform"]),
("搜索词(keyword)", JD_SEARCH_CSV_HEADERS["keyword"]),
("页码(page)", JD_SEARCH_CSV_HEADERS["page"]),
)
for old, new in legacy_search_pairs:
m[old] = new
# 合并表首列
m["pipeline_keyword"] = "流水线关键词"
# 商详块:历史合并表曾直接写英文内部键
for ik, zh in zip(MERGED_LEAN_DETAIL_INTERNAL_KEYS, LEAN_DETAIL_CSV_HEADERS):
m[ik] = zh
m["comment_count"] = "评论条数"
m["comment_preview"] = "评价摘要"
# --- comments_flat接口字段 / 小写 sku ---
for api_k, zh in zip(COMMENT_ROW_DICT_KEYS, COMMENT_CSV_COLUMNS):
m[api_k] = zh
m["sku"] = "SKU"
# --- detail_ware_export ---
m["skuId"] = "SKU"
return m
LEGACY_HEADER_MAP: dict[str, str] = build_legacy_header_map()
def _normalize_field_key(k: str | None) -> str:
return (k or "").strip().lstrip("\ufeff")
def _row_with_canonical_keys(
row: dict[str, str],
legacy_map: dict[str, str],
) -> dict[str, str]:
"""将一行从任意旧表头映射为 canonical 列名;同列多旧键时取非空优先。"""
out: dict[str, str] = {}
for raw_k, v in row.items():
k = _normalize_field_key(raw_k)
if not k:
continue
nk = legacy_map.get(k, k)
sv = "" if v is None else str(v)
if nk not in out or (sv.strip() and not str(out.get(nk, "")).strip()):
out[nk] = sv
return out
def rewrite_csv_inplace(
path: Path,
fieldnames: list[str],
legacy_map: dict[str, str],
*,
dry_run: bool = False,
) -> tuple[bool, str]:
"""
UTF-8 BOM CSV ``fieldnames`` 写出缺列填空
返回 (是否执行写入, 说明)
"""
path = path.expanduser().resolve()
if not path.is_file():
return False, f"跳过(不存在): {path}"
with path.open(encoding="utf-8-sig", newline="") as f:
rdr = csv.DictReader(f)
rows_in = list(rdr)
if not rows_in:
return False, f"空文件: {path}"
rows_out: list[dict[str, str]] = []
extras: set[str] = set()
for row in rows_in:
canon = _row_with_canonical_keys(row, legacy_map)
for k in canon:
if k not in fieldnames:
extras.add(k)
rows_out.append(canon)
fieldnames_out = list(fieldnames) + sorted(extras)
if dry_run:
return True, f"[dry-run] 将写 {len(rows_out)} 行 -> {path.name}"
with path.open("w", encoding="utf-8-sig", newline="") as f:
w = csv.DictWriter(
f,
fieldnames=fieldnames_out,
extrasaction="ignore",
)
w.writeheader()
w.writerows(
[{fn: str(r.get(fn, "") or "") for fn in fieldnames_out} for r in rows_out]
)
return True, f"已写 {len(rows_out)} 行 -> {path}"
def rewrite_run_dir_csv_headers(
run_dir: Path,
*,
dry_run: bool = False,
only: Iterable[str] | None = None,
) -> list[str]:
"""
处理单个 run 目录下四种 CSV存在则处理
``only`` 为文件名子集例如 ``("keyword_pipeline_merged.csv",)``
"""
from .ingest import (
FILE_COMMENTS_FLAT_CSV,
FILE_DETAIL_WARE_CSV,
FILE_MERGED_CSV,
FILE_PC_SEARCH_CSV,
)
run_dir = run_dir.expanduser().resolve()
lm = LEGACY_HEADER_MAP
only_set = {x.strip() for x in only} if only else None
tasks: list[tuple[str, list[str]]] = [
(FILE_MERGED_CSV, _merged_fieldnames_canonical()),
(FILE_PC_SEARCH_CSV, _search_fieldnames_canonical()),
(FILE_COMMENTS_FLAT_CSV, _comment_fieldnames_canonical()),
(FILE_DETAIL_WARE_CSV, _detail_fieldnames_canonical()),
]
messages: list[str] = []
for fname, fieldnames in tasks:
if only_set is not None and fname not in only_set:
continue
ok, msg = rewrite_csv_inplace(
run_dir / fname,
fieldnames,
lm,
dry_run=dry_run,
)
if ok or "空文件" in msg or "不存在" in msg:
messages.append(msg)
return messages

View File

@ -6,7 +6,19 @@ from __future__ import annotations
import re import re
# --- 搜索导出 pc_search_export.csv列名为中文与 jd_h5_search_requests.JD_EXPORT_COLUMN_HEADERS 一致)---
def strip_buyer_ranking_line_prefix(value: str) -> str:
"""去掉榜单列上的 ``榜单/曝光:`` 历史前缀,展示与入库一致。"""
s = (value or "").strip()
prefix = "榜单/曝光:"
if s.startswith(prefix):
return s[len(prefix) :].strip()
if s.startswith("榜单/曝光"):
return s[len("榜单/曝光") :].lstrip("").strip()
return s
# --- 搜索导出 pc_search_export.csv纯中文表头与 jd_h5_search_requests.JD_EXPORT_COLUMN_HEADERS 一致)---
JD_SEARCH_INTERNAL_KEYS: tuple[str, ...] = ( JD_SEARCH_INTERNAL_KEYS: tuple[str, ...] = (
"item_id", "item_id",
"sku_id", "sku_id",
@ -34,29 +46,29 @@ JD_SEARCH_INTERNAL_KEYS: tuple[str, ...] = (
) )
JD_SEARCH_CSV_HEADERS: dict[str, str] = { JD_SEARCH_CSV_HEADERS: dict[str, str] = {
"item_id": "主商品ID(wareId)", "item_id": "主商品ID",
"sku_id": "SKU(skuId)", "sku_id": "SKU",
"title": "标题(wareName)", "title": "标题",
"price": "标价(jdPrice,jdPriceText,realPrice)", "price": "标价",
"coupon_price": "券后到手价(couponPrice,subsidyPrice,finalPrice.estimatedPrice,priceShow)", "coupon_price": "券后到手价",
"original_price": "原价(oriPrice,originalPrice,marketPrice)", "original_price": "原价",
"selling_point": "卖点(sellingPoint)", "selling_point": "卖点",
"comment_sales_floor": "销量楼层(commentSalesFloor)", "comment_sales_floor": "销量楼层",
"total_sales": "销量口径(totalSales)", "total_sales": "销量口径",
"hot_list_rank": "榜单类文案(标签/腰带/标题数组中的榜、TOP 等)", "hot_list_rank": "榜单类文案",
"comment_count": "评价量(commentFuzzy)", "comment_count": "评价量",
"shop_name": "店铺名(shopName)", "shop_name": "店铺名",
"shop_url": "店铺链接(shopUrl,shopId)", "shop_url": "店铺链接",
"shop_info_url": "店铺信息链接(shopInfoUrl,brandUrl)", "shop_info_url": "店铺信息链接",
"location": "地域(deliveryAddress,area,procity)", "location": "地域",
"detail_url": "商品链接(toUrl,clickUrl,item.m.jd.com)", "detail_url": "商品链接",
"image": "主图(imageurl,imageUrl)", "image": "主图",
"seckill_info": "秒杀(seckillInfo,secKill)", "seckill_info": "秒杀",
"attributes": "规格属性(propertyList,color,catid,shortName)", "attributes": "规格属性",
"leaf_category": "类目(leafCategory,cid3Name,catid)", "leaf_category": "类目",
"platform": "平台(platform)", "platform": "平台",
"keyword": "搜索词(keyword)", "keyword": "搜索词",
"page": "页码(page)", "page": "页码",
} }
# CSV 表头 -> 模型属性名 # CSV 表头 -> 模型属性名
@ -64,28 +76,70 @@ SEARCH_CSV_HEADER_TO_FIELD: dict[str, str] = {
h: k for k, h in JD_SEARCH_CSV_HEADERS.items() h: k for k, h in JD_SEARCH_CSV_HEADERS.items()
} }
# lean 商详子集合并宽表商详块、detail_ware_exportlean、JdJobDetailRow 共用CSV 列名与 ORM 一致 # lean 商详ORM 内部键(英文 snake_caseCSV 表头为中文(见 DETAIL_CSV_COLUMNS
LEAN_DETAIL_EXPORT_FIELDNAMES: tuple[str, ...] = ( MERGED_LEAN_DETAIL_INTERNAL_KEYS: tuple[str, ...] = (
"detail_brand", "detail_brand",
"detail_price_final", "detail_price_final",
"detail_shop_name", "detail_shop_name",
"detail_category_path", "detail_category_path",
"detail_product_attributes", "detail_product_attributes",
"detail_body_ingredients", "detail_body_ingredients",
"buyer_ranking_line",
"buyer_promo_text",
) )
# --- 商详 detail_ware_export.csvleanskuId + 上列full 模式爬虫仍可能多列,入库只认 DETAIL_CSV_COLUMNS--- LEAN_DETAIL_EXPORT_FIELDNAMES: tuple[str, ...] = MERGED_LEAN_DETAIL_INTERNAL_KEYS
JD_DETAIL_MERGE_KEYS: tuple[str, ...] = LEAN_DETAIL_EXPORT_FIELDNAMES
DETAIL_CSV_COLUMNS: tuple[str, ...] = ("skuId", *JD_DETAIL_MERGE_KEYS) LEAN_DETAIL_CSV_HEADERS: tuple[str, ...] = (
"品牌",
"到手价",
"店铺名称",
"类目路径",
"商品参数",
"配料表",
"榜单排名",
"促销摘要",
)
DETAIL_CSV_HEADER_TO_FIELD: dict[str, str] = dict(
zip(LEAN_DETAIL_CSV_HEADERS, MERGED_LEAN_DETAIL_INTERNAL_KEYS)
)
# --- 商详 detail_ware_export.csvleanSKU + 上列full 模式爬虫仍可能多列,入库只认 DETAIL_CSV_COLUMNS---
JD_DETAIL_MERGE_KEYS: tuple[str, ...] = MERGED_LEAN_DETAIL_INTERNAL_KEYS
DETAIL_CSV_COLUMNS: tuple[str, ...] = ("SKU", *LEAN_DETAIL_CSV_HEADERS)
DETAIL_CSV_TO_FIELD: dict[str, str] = { DETAIL_CSV_TO_FIELD: dict[str, str] = {
"skuId": "sku_id", "SKU": "sku_id",
**{k: k for k in JD_DETAIL_MERGE_KEYS}, **DETAIL_CSV_HEADER_TO_FIELD,
} }
# --- 评价 comments_flat.csv --- # --- 评价 comments_flat.csv(表头中文;爬虫行字典仍用英文 API 键,写出时映射)---
COMMENT_CSV_COLUMNS: tuple[str, ...] = ( COMMENT_CSV_COLUMNS: tuple[str, ...] = (
"SKU",
"评价ID",
"用户昵称",
"评价内容",
"评价时间",
"购买次数",
"晒图链接",
"评分",
)
COMMENT_CSV_TO_FIELD: dict[str, str] = {
"SKU": "sku_id",
"评价ID": "comment_id",
"用户昵称": "user_nick_name",
"评价内容": "tag_comment_content",
"评价时间": "comment_date",
"购买次数": "buy_count_text",
"晒图链接": "large_pic_urls",
"评分": "comment_score",
}
# 爬虫评价行 dict 键(与京东接口字段一致)→ CSV 中文表头
COMMENT_ROW_DICT_KEYS: tuple[str, ...] = (
"sku", "sku",
"commentId", "commentId",
"userNickName", "userNickName",
@ -96,41 +150,8 @@ COMMENT_CSV_COLUMNS: tuple[str, ...] = (
"commentScore", "commentScore",
) )
COMMENT_CSV_TO_FIELD: dict[str, str] = {
"sku": "sku_id",
"commentId": "comment_id",
"userNickName": "user_nick_name",
"tagCommentContent": "tag_comment_content",
"commentDate": "comment_date",
"buyCountText": "buy_count_text",
"largePicURLs": "large_pic_urls",
"commentScore": "comment_score",
}
# --- 合并宽表 keyword_pipeline_merged.csvlean = 搜索块 + 商详块 + 评论块;改列请改对应块,勿在尾部堆列)--- # --- 合并宽表 keyword_pipeline_merged.csvlean = 搜索块 + 商详块 + 评论块;改列请改对应块,勿在尾部堆列)---
MERGED_SEARCH_CSV_COLUMNS: tuple[str, ...] = (
"pipeline_keyword",
"SKU(skuId)",
"主商品ID(wareId)",
"标题(wareName)",
"标价(jdPrice,jdPriceText,realPrice)",
"券后到手价(couponPrice,subsidyPrice,finalPrice.estimatedPrice,priceShow)",
"原价(oriPrice,originalPrice,marketPrice)",
"卖点(sellingPoint)",
"榜单类文案(标签/腰带/标题数组中的榜、TOP 等)",
"评价量(commentFuzzy)",
"销量楼层(commentSalesFloor)",
"销量口径(totalSales)",
"店铺名(shopName)",
"商品链接(toUrl,clickUrl,item.m.jd.com)",
"主图(imageurl,imageUrl)",
"规格属性(propertyList,color,catid,shortName)",
"类目(leafCategory,cid3Name,catid)",
"搜索词(keyword)",
"页码(page)",
)
MERGED_SEARCH_INTERNAL_KEYS: tuple[str, ...] = ( MERGED_SEARCH_INTERNAL_KEYS: tuple[str, ...] = (
"pipeline_keyword", "pipeline_keyword",
"sku_id", "sku_id",
@ -153,12 +174,30 @@ MERGED_SEARCH_INTERNAL_KEYS: tuple[str, ...] = (
"page", "page",
) )
# 商详块:列名与 ORM 属性同名;与 LEAN_DETAIL_EXPORT_FIELDNAMES / 流水线 lean 一致
MERGED_LEAN_DETAIL_KEYS: tuple[str, ...] = LEAN_DETAIL_EXPORT_FIELDNAMES def _merged_search_csv_header_for_internal(k: str) -> str:
"""整合表搜索块:内部键 → 中文 CSV 表头(与 PC 搜索导出列名对齐,合并表专有列单独处理)。"""
if k == "ware_id":
return JD_SEARCH_CSV_HEADERS["item_id"]
if k == "comment_fuzzy":
return "评价量"
return JD_SEARCH_CSV_HEADERS[k]
MERGED_SEARCH_CSV_COLUMNS: tuple[str, ...] = (
"流水线关键词",
*(
_merged_search_csv_header_for_internal(k)
for k in MERGED_SEARCH_INTERNAL_KEYS[1:]
),
)
# 商详块CSV 为中文表头;内部键见 MERGED_LEAN_DETAIL_INTERNAL_KEYS
MERGED_LEAN_DETAIL_KEYS: tuple[str, ...] = LEAN_DETAIL_CSV_HEADERS
MERGED_COMMENT_CSV_COLUMNS: tuple[str, ...] = ( MERGED_COMMENT_CSV_COLUMNS: tuple[str, ...] = (
"comment_count", "评论条数",
"comment_preview", "评价摘要",
) )
MERGED_COMMENT_INTERNAL_KEYS: tuple[str, ...] = ( MERGED_COMMENT_INTERNAL_KEYS: tuple[str, ...] = (
@ -174,7 +213,7 @@ MERGED_CSV_COLUMNS: tuple[str, ...] = (
MERGED_INTERNAL_KEYS: tuple[str, ...] = ( MERGED_INTERNAL_KEYS: tuple[str, ...] = (
*MERGED_SEARCH_INTERNAL_KEYS, *MERGED_SEARCH_INTERNAL_KEYS,
*MERGED_LEAN_DETAIL_KEYS, *MERGED_LEAN_DETAIL_INTERNAL_KEYS,
*MERGED_COMMENT_INTERNAL_KEYS, *MERGED_COMMENT_INTERNAL_KEYS,
) )
@ -187,6 +226,14 @@ MERGED_FIELD_TO_CSV_HEADER: dict[str, str] = {
} }
def remap_merged_row_english_detail_keys_to_csv_headers(merged: dict[str, str]) -> None:
"""整合表写入前:将 ``ware_flat`` / 抽取逻辑产生的英文商详与购买者内部键改为中文 CSV 列名(原地修改)。"""
for ik, zh in zip(MERGED_LEAN_DETAIL_INTERNAL_KEYS, LEAN_DETAIL_CSV_HEADERS):
if ik in merged:
merged[zh] = str(merged.get(ik) or "")
del merged[ik]
def infer_total_sales_from_sales_floor(cell: str) -> str: def infer_total_sales_from_sales_floor(cell: str) -> str:
""" """
销量楼层(commentSalesFloor)列文案截取可作 ``销量口径(totalSales)`` 的片段与列表接口未单独落 totalSales 列时的兜底一致 销量楼层(commentSalesFloor)列文案截取可作 ``销量口径(totalSales)`` 的片段与列表接口未单独落 totalSales 列时的兜底一致

View File

@ -0,0 +1 @@
"""本地调试脚本(不随 Django 路由加载);见各 ``run_*`` 模块顶部用法。"""

View File

@ -2,8 +2,8 @@
细类价盘要点归纳打印 ``generate_price_group_summaries_llm`` 输出与报告 §6 后大模型段同源 细类价盘要点归纳打印 ``generate_price_group_summaries_llm`` 输出与报告 §6 后大模型段同源
cd backend cd backend
.venv\\Scripts\\python.exe pipeline/run_price_groups_llm_demo.py --job 12 --live .venv\\Scripts\\python.exe -m pipeline.demos.run_price_groups_llm_demo --job 12 --live
.venv\\Scripts\\python.exe pipeline/run_price_groups_llm_demo.py --merged "D:/path/keyword_pipeline_merged.csv" --live .venv\\Scripts\\python.exe -m pipeline.demos.run_price_groups_llm_demo --merged "D:/path/keyword_pipeline_merged.csv" --live
""" """
from __future__ import annotations from __future__ import annotations
@ -13,7 +13,7 @@ import os
import sys import sys
from pathlib import Path from pathlib import Path
BACKEND_ROOT = Path(__file__).resolve().parent.parent BACKEND_ROOT = Path(__file__).resolve().parents[2]
if str(BACKEND_ROOT) not in sys.path: if str(BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(BACKEND_ROOT)) sys.path.insert(0, str(BACKEND_ROOT))
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
@ -110,7 +110,7 @@ def main() -> None:
print("\n加 --live 调用 generate_price_group_summaries_llm", file=sys.stderr) print("\n加 --live 调用 generate_price_group_summaries_llm", file=sys.stderr)
return return
from pipeline.llm_generate import generate_price_group_summaries_llm # noqa: WPS433 from pipeline.llm.generate import generate_price_group_summaries_llm # noqa: WPS433
out = generate_price_group_summaries_llm(groups, keyword=keyword) out = generate_price_group_summaries_llm(groups, keyword=keyword)
print(out) print(out)

View File

@ -1,5 +1,5 @@
""" """
竞品报告中与大模型相关的块 ``jd_runner.write_competitor_analysis_for_run_dir`` 同源 竞品报告中与大模型相关的块 ``pipeline.jd.runner.write_competitor_analysis_for_run_dir`` 同源
- §5 ``generate_matrix_group_summaries_llm`` - §5 ``generate_matrix_group_summaries_llm``
- §6 ``generate_price_group_summaries_llm`` - §6 ``generate_price_group_summaries_llm``
@ -9,9 +9,9 @@
- §8.5 类全文补充独立长文``generate_competitor_report_markdown_llm`` - §8.5 类全文补充独立长文``generate_competitor_report_markdown_llm``
cd backend cd backend
python pipeline/run_report_llm_chapters_demo.py --run-dir "../data/JD/pipeline_runs/20260413_104252_低GI" python -m pipeline.demos.run_report_llm_chapters_demo --run-dir "../data/JD/pipeline_runs/20260413_104252_低GI"
python pipeline/run_report_llm_chapters_demo.py --run-dir "..." --live python -m pipeline.demos.run_report_llm_chapters_demo --run-dir "..." --live
python pipeline/run_report_llm_chapters_demo.py --run-dir "..." --live --only matrix,price python -m pipeline.demos.run_report_llm_chapters_demo --run-dir "..." --live --only matrix,price
""" """
from __future__ import annotations from __future__ import annotations
@ -23,7 +23,7 @@ import traceback
from pathlib import Path from pathlib import Path
from typing import Any, Callable from typing import Any, Callable
BACKEND_ROOT = Path(__file__).resolve().parent.parent BACKEND_ROOT = Path(__file__).resolve().parents[2]
if str(BACKEND_ROOT) not in sys.path: if str(BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(BACKEND_ROOT)) sys.path.insert(0, str(BACKEND_ROOT))
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
@ -39,7 +39,7 @@ if str(JCR_ROOT) not in sys.path:
import jd_competitor_report as jcr # noqa: E402 import jd_competitor_report as jcr # noqa: E402
import jd_keyword_pipeline as kpl # noqa: E402 import jd_keyword_pipeline as kpl # noqa: E402
from pipeline.jd_runner import get_default_report_config # noqa: E402 from pipeline.jd.runner import get_default_report_config # noqa: E402
def _load_run( def _load_run(
@ -160,7 +160,7 @@ def main() -> None:
flush=True, flush=True,
) )
from pipeline.llm_generate import ( # noqa: WPS433 from pipeline.llm.generate import ( # noqa: WPS433
generate_comment_group_summaries_llm, generate_comment_group_summaries_llm,
generate_comment_sentiment_analysis_llm, generate_comment_sentiment_analysis_llm,
generate_competitor_report_markdown_llm, generate_competitor_report_markdown_llm,
@ -184,6 +184,8 @@ def main() -> None:
pl = jcr.build_comment_sentiment_llm_payload( pl = jcr.build_comment_sentiment_llm_payload(
comment_units, comment_units,
attributed_texts=attr_units, attributed_texts=attr_units,
semantic_pool_max=40,
shuffle_seed=keyword,
) )
pl["keyword"] = keyword pl["keyword"] = keyword
return generate_comment_sentiment_analysis_llm(pl) return generate_comment_sentiment_analysis_llm(pl)

View File

@ -10,6 +10,7 @@ import logging
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from django.conf import settings
from django.db import transaction from django.db import transaction
from django.utils import timezone from django.utils import timezone
@ -26,6 +27,7 @@ from .csv_schema import (
SEARCH_CSV_HEADER_TO_FIELD, SEARCH_CSV_HEADER_TO_FIELD,
merged_csv_effective_total_sales, merged_csv_effective_total_sales,
search_csv_effective_total_sales, search_csv_effective_total_sales,
strip_buyer_ranking_line_prefix,
) )
from .models import ( from .models import (
JdJobCommentRow, JdJobCommentRow,
@ -44,9 +46,9 @@ FILE_PC_SEARCH_CSV = "pc_search_export.csv"
FILE_DETAIL_WARE_CSV = "detail_ware_export.csv" FILE_DETAIL_WARE_CSV = "detail_ware_export.csv"
FILE_COMMENTS_FLAT_CSV = "comments_flat.csv" FILE_COMMENTS_FLAT_CSV = "comments_flat.csv"
SKU_FIELD_MERGED = "SKU(skuId)" SKU_FIELD_MERGED = MERGED_FIELD_TO_CSV_HEADER["sku_id"]
WARE_FIELD = "主商品ID(wareId)" WARE_FIELD = MERGED_FIELD_TO_CSV_HEADER["ware_id"]
TITLE_FIELD = "标题(wareName)" TITLE_FIELD = MERGED_FIELD_TO_CSV_HEADER["title"]
BULK_CHUNK = 400 BULK_CHUNK = 400
@ -81,9 +83,12 @@ def _search_row_kwargs(row: dict[str, str]) -> dict[str, str]:
def _detail_row_kwargs(row: dict[str, str]) -> dict[str, str]: def _detail_row_kwargs(row: dict[str, str]) -> dict[str, str]:
return { kw = {
DETAIL_CSV_TO_FIELD[col]: str(row.get(col) or "").strip() for col in DETAIL_CSV_COLUMNS DETAIL_CSV_TO_FIELD[col]: str(row.get(col) or "").strip() for col in DETAIL_CSV_COLUMNS
} }
if kw.get("buyer_ranking_line"):
kw["buyer_ranking_line"] = strip_buyer_ranking_line_prefix(kw["buyer_ranking_line"])
return kw
def _comment_row_kwargs(row: dict[str, str]) -> dict[str, str]: def _comment_row_kwargs(row: dict[str, str]) -> dict[str, str]:
@ -99,9 +104,12 @@ def _normalize_merged_csv_total_sales(row: dict[str, str]) -> None:
def _merged_row_kwargs(row: dict[str, str]) -> dict[str, str]: def _merged_row_kwargs(row: dict[str, str]) -> dict[str, str]:
return { kw = {
MERGED_CSV_TO_FIELD[col]: str(row.get(col) or "").strip() for col in MERGED_CSV_COLUMNS MERGED_CSV_TO_FIELD[col]: str(row.get(col) or "").strip() for col in MERGED_CSV_COLUMNS
} }
if kw.get("buyer_ranking_line"):
kw["buyer_ranking_line"] = strip_buyer_ranking_line_prefix(kw["buyer_ranking_line"])
return kw
def _bulk_create_in_chunks(model, objects: list[Any]) -> None: def _bulk_create_in_chunks(model, objects: list[Any]) -> None:
@ -113,6 +121,32 @@ def _run_dir(job: PipelineJob) -> Path:
return Path(job.run_dir or "").expanduser().resolve() return Path(job.run_dir or "").expanduser().resolve()
def resolve_and_validate_run_dir(path_str: str) -> Path:
"""
将用户输入解析为 ``LOW_GI_PROJECT_ROOT/data/JD`` 下的绝对路径且须为已存在目录
相对路径相对 ``data/JD``与创建任务时 ``pipeline_run_dir`` 语义一致
"""
if not (path_str or "").strip():
raise ValueError("run_dir 为空")
root = (settings.LOW_GI_PROJECT_ROOT or "").strip()
if not root:
raise ValueError("LOW_GI_PROJECT_ROOT 未配置")
project_data = Path(root).resolve() / "data" / "JD"
p = Path(path_str.strip()).expanduser()
if not p.is_absolute():
p = project_data / p
p = p.resolve()
jd = project_data.resolve()
try:
p.relative_to(jd)
except ValueError as e:
raise ValueError(f"路径须位于京东数据目录下:{jd}") from e
if not p.is_dir():
raise ValueError(f"目录不存在:{p}")
return p
def ingest_job_dataset_rows(job: PipelineJob) -> dict[str, Any]: def ingest_job_dataset_rows(job: PipelineJob) -> dict[str, Any]:
""" """
删除该任务旧数据后 ``pc_search_export`` / ``detail_ware_export`` / ``comments_flat`` 全量写入数据库 删除该任务旧数据后 ``pc_search_export`` / ``detail_ware_export`` / ``comments_flat`` 全量写入数据库
@ -202,17 +236,21 @@ def ingest_job_merged_csv(job: PipelineJob) -> dict[str, Any]:
if not sku: if not sku:
continue continue
_normalize_merged_csv_total_sales(row) _normalize_merged_csv_total_sales(row)
br_h = MERGED_FIELD_TO_CSV_HEADER["buyer_ranking_line"]
br = (row.get(br_h) or "").strip()
if br:
row[br_h] = strip_buyer_ranking_line_prefix(br)
payload = _payload_as_json(row) payload = _payload_as_json(row)
title = (row.get(TITLE_FIELD) or "")[:2000] title = (row.get(TITLE_FIELD) or "")[:2000]
ware = (row.get(WARE_FIELD) or "").strip()[:64] ware = (row.get(WARE_FIELD) or "").strip()[:64]
brand = (row.get("detail_brand") or "").strip()[:512] brand = (row.get(MERGED_FIELD_TO_CSV_HEADER["detail_brand"]) or "").strip()[:512]
price = ( price = (
(row.get("detail_price_final") or "").strip() (row.get(MERGED_FIELD_TO_CSV_HEADER["detail_price_final"]) or "").strip()
or (row.get(JD_SEARCH_CSV_HEADERS["coupon_price"]) or "").strip() or (row.get(JD_SEARCH_CSV_HEADERS["coupon_price"]) or "").strip()
or (row.get(JD_SEARCH_CSV_HEADERS["price"]) or "").strip() or (row.get(JD_SEARCH_CSV_HEADERS["price"]) or "").strip()
)[:128] )[:128]
cat = ( cat = (
(row.get("detail_category_path") or "").strip() (row.get(MERGED_FIELD_TO_CSV_HEADER["detail_category_path"]) or "").strip()
or (row.get(JD_SEARCH_CSV_HEADERS["leaf_category"]) or "").strip() or (row.get(JD_SEARCH_CSV_HEADERS["leaf_category"]) or "").strip()
)[:2000] )[:2000]

View File

@ -0,0 +1 @@
"""京东采集流水线编排:运行爬虫副本、购买者 CSV 导出、详情表再生等。"""

View File

@ -0,0 +1,109 @@
# -*- coding: utf-8 -*-
"""
``detail_ware_export.csv`` ``detail/ware_*_response.json`` 合并为一张表
在原 lean 列后追加 ``buyer_ranking_line````buyer_promo_text``促销相关摘要句用稳定分隔符拼接
输出默认写入 ``<run_dir>/buyer_offer_profiles/buyer_offer_with_detail.csv``
"""
from __future__ import annotations
import csv
import sys
from pathlib import Path
# 与 pipeline.ingest / jd_keyword_pipeline 中文件名一致(避免 import ingest 触发 Django
FILE_DETAIL_WARE_CSV = "detail_ware_export.csv"
# 与 jd_keyword_pipeline.DIR_BUYER_OFFER_PROFILES 一致
DIR_BUYER_OFFER_PROFILES = "buyer_offer_profiles"
FILE_BUYER_OFFER_WITH_DETAIL_CSV = "buyer_offer_with_detail.csv"
def _ensure_crawler_detail_path() -> None:
# 本文件位于 pipeline/jd/,向上两级为 backend/
root = Path(__file__).resolve().parents[2] / "crawler_copy" / "jd_pc_search"
for sub in ("detail", ""):
p = root / sub if sub else root
s = str(p.resolve())
if s not in sys.path:
sys.path.insert(0, s)
def export_buyer_offer_with_detail_csv(
run_dir: str | Path,
*,
promo_sep: str = " | ",
) -> Path:
"""
读取 ``run_dir/detail_ware_export.csv`` ``run_dir/detail/ware_{sku}_response.json``
写出 ``run_dir/buyer_offer_profiles/buyer_offer_with_detail.csv``
"""
_ensure_crawler_detail_path()
from jd_detail_buyer_extraction import ( # noqa: WPS433
buyer_promo_text_from_profile,
buyer_ranking_line_from_profile,
extract_buyer_offer_profile_from_json_text,
)
from jd_detail_ware_business_requests import ( # noqa: WPS433
DETAIL_WARE_LEAN_CSV_FIELDNAMES,
)
run_dir = Path(run_dir).expanduser().resolve()
src = run_dir / FILE_DETAIL_WARE_CSV
if not src.is_file():
raise FileNotFoundError(f"缺少详情汇总表: {src}")
detail_dir = run_dir / "detail"
if not detail_dir.is_dir():
raise FileNotFoundError(f"缺少 detail 目录: {detail_dir}")
out_dir = run_dir / DIR_BUYER_OFFER_PROFILES
out_dir.mkdir(parents=True, exist_ok=True)
out_path = out_dir / FILE_BUYER_OFFER_WITH_DETAIL_CSV
fieldnames: list[str] = list(DETAIL_WARE_LEAN_CSV_FIELDNAMES)
with src.open(encoding="utf-8-sig", newline="") as f:
reader = csv.DictReader(f)
rows_out: list[dict[str, str]] = []
for row in reader:
sku = str(row.get("SKU") or row.get("skuId") or "").strip()
base = {
k: str(row.get(k) or "").strip() for k in DETAIL_WARE_LEAN_CSV_FIELDNAMES
}
rline = base.get("榜单排名") or base.get("buyer_ranking_line") or ""
ptext = base.get("促销摘要") or base.get("buyer_promo_text") or ""
if (not rline and not ptext) and sku:
jp = detail_dir / f"ware_{sku}_response.json"
if jp.is_file():
text = jp.read_text(encoding="utf-8").strip()
if text:
prof = extract_buyer_offer_profile_from_json_text(text)
rline = buyer_ranking_line_from_profile(prof)
ptext = buyer_promo_text_from_profile(prof, sep=promo_sep)
base["榜单排名"] = rline
base["促销摘要"] = ptext
rows_out.append(base)
with out_path.open("w", encoding="utf-8-sig", newline="") as f:
w = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
w.writeheader()
w.writerows(rows_out)
return out_path
def main(argv: list[str] | None = None) -> None:
argv = argv if argv is not None else sys.argv[1:]
if len(argv) < 1:
print(
"用法: python -m pipeline.jd.buyer_offer_export_csv <run_dir>\n"
" 例: python -m pipeline.jd.buyer_offer_export_csv "
"data/JD/pipeline_runs/20260413_104252_低GI",
file=sys.stderr,
)
sys.exit(2)
run_dir = argv[0]
path = export_buyer_offer_with_detail_csv(run_dir)
print(path)
if __name__ == "__main__":
main()

View File

@ -5,11 +5,12 @@ import csv
import sys import sys
from pathlib import Path from pathlib import Path
from .ingest import FILE_DETAIL_WARE_CSV, FILE_MERGED_CSV, SKU_FIELD_MERGED from ..csv_schema import MERGED_FIELD_TO_CSV_HEADER
from ..ingest import FILE_DETAIL_WARE_CSV, FILE_MERGED_CSV, SKU_FIELD_MERGED
def _ensure_crawler_copy_path() -> None: def _ensure_crawler_copy_path() -> None:
root = Path(__file__).resolve().parent.parent / "crawler_copy" / "jd_pc_search" root = Path(__file__).resolve().parents[2] / "crawler_copy" / "jd_pc_search"
for sub in ("detail", ""): for sub in ("detail", ""):
p = root / sub if sub else root p = root / sub if sub else root
s = str(p.resolve()) s = str(p.resolve())
@ -19,6 +20,11 @@ def _ensure_crawler_copy_path() -> None:
def regenerate_detail_ware_rows(run_dir: Path) -> list[dict[str, str]]: def regenerate_detail_ware_rows(run_dir: Path) -> list[dict[str, str]]:
_ensure_crawler_copy_path() _ensure_crawler_copy_path()
from jd_detail_buyer_extraction import ( # noqa: WPS433
buyer_promo_text_from_profile,
buyer_ranking_line_from_profile,
extract_buyer_offer_profile_from_json_text,
)
from jd_detail_ware_business_requests import ( # noqa: WPS433 from jd_detail_ware_business_requests import ( # noqa: WPS433
DETAIL_WARE_LEAN_CSV_FIELDNAMES, DETAIL_WARE_LEAN_CSV_FIELDNAMES,
detail_ware_lean_csv_row, detail_ware_lean_csv_row,
@ -43,7 +49,12 @@ def regenerate_detail_ware_rows(run_dir: Path) -> list[dict[str, str]]:
if not jp.is_file(): if not jp.is_file():
continue continue
text = jp.read_text(encoding="utf-8") text = jp.read_text(encoding="utf-8")
ing = (row.get("detail_body_ingredients") or "").strip() ing = (
row.get(MERGED_FIELD_TO_CSV_HEADER["detail_body_ingredients"])
or row.get("detail_body_ingredients")
or ""
).strip()
prof = extract_buyer_offer_profile_from_json_text(text)
rows_out.append( rows_out.append(
detail_ware_lean_csv_row( detail_ware_lean_csv_row(
sku, sku,
@ -51,6 +62,8 @@ def regenerate_detail_ware_rows(run_dir: Path) -> list[dict[str, str]]:
text, text,
detail_body_ingredients=ing, detail_body_ingredients=ing,
detail_body_ingredients_source_url="", detail_body_ingredients_source_url="",
buyer_ranking_line=buyer_ranking_line_from_profile(prof),
buyer_promo_text=buyer_promo_text_from_profile(prof),
) )
) )
return rows_out return rows_out

View File

@ -0,0 +1,106 @@
"""从 ``detail_ware_export.csv`` / ``detail/ware_*_response.json`` 补全并规范化 lean ``keyword_pipeline_merged.csv``(列序与 ``csv_schema.MERGED_CSV_COLUMNS`` 一致)。"""
from __future__ import annotations
import csv
import sys
from pathlib import Path
from ..csv_schema import (
MERGED_CSV_COLUMNS,
MERGED_FIELD_TO_CSV_HEADER,
MERGED_LEAN_DETAIL_INTERNAL_KEYS,
merged_csv_effective_total_sales,
strip_buyer_ranking_line_prefix,
)
from ..ingest import FILE_DETAIL_WARE_CSV, FILE_MERGED_CSV
HOT_KEY = "榜单类文案"
def _ensure_crawler_detail_path() -> None:
root = Path(__file__).resolve().parents[2] / "crawler_copy" / "jd_pc_search"
for sub in ("detail", ""):
p = root / sub if sub else root
s = str(p.resolve())
if s not in sys.path:
sys.path.insert(0, s)
def write_keyword_pipeline_merged_lean_csv(run_dir: Path) -> tuple[int, Path]:
"""
读取已有 ``keyword_pipeline_merged.csv``可缺列 lean 宽表列序重写
- ``销量口径`` 与入库一致``merged_csv_effective_total_sales``
- 商详块列优先与 ``detail_ware_export.csv`` 对齐缺则尝试 ``detail/ware_{sku}_response.json``
- 榜单类文案榜单排名去掉 ``榜单/曝光`` 前缀
"""
_ensure_crawler_detail_path()
from jd_detail_buyer_extraction import ( # noqa: WPS433
buyer_promo_text_from_profile,
buyer_ranking_line_from_profile,
extract_buyer_offer_profile_from_json_text,
)
run_dir = run_dir.expanduser().resolve()
merged_path = run_dir / FILE_MERGED_CSV
detail_path = run_dir / FILE_DETAIL_WARE_CSV
detail_dir = run_dir / "detail"
if not merged_path.is_file():
raise FileNotFoundError(f"缺少合并表: {merged_path}")
if not detail_dir.is_dir():
raise FileNotFoundError(f"缺少 detail 目录: {detail_dir}")
with merged_path.open(encoding="utf-8-sig", newline="") as f:
old_rows = list(csv.DictReader(f))
detail_by_sku: dict[str, dict[str, str]] = {}
if detail_path.is_file():
with detail_path.open(encoding="utf-8-sig", newline="") as f:
for r in csv.DictReader(f):
sku = (r.get("SKU") or r.get("skuId") or "").strip()
if sku:
detail_by_sku[sku] = {k: str(r.get(k) or "").strip() for k in r}
h_ts = MERGED_FIELD_TO_CSV_HEADER["total_sales"]
sku_h = MERGED_FIELD_TO_CSV_HEADER["sku_id"]
br_h = MERGED_FIELD_TO_CSV_HEADER["buyer_ranking_line"]
pr_h = MERGED_FIELD_TO_CSV_HEADER["buyer_promo_text"]
rows_out: list[dict[str, str]] = []
for row in old_rows:
out = {col: str(row.get(col) or "").strip() for col in MERGED_CSV_COLUMNS}
out[h_ts] = merged_csv_effective_total_sales(out)
if out.get(HOT_KEY):
out[HOT_KEY] = strip_buyer_ranking_line_prefix(out[HOT_KEY])
sku = (out.get(sku_h) or "").strip()
if sku and sku in detail_by_sku:
d = detail_by_sku[sku]
for ik in MERGED_LEAN_DETAIL_INTERNAL_KEYS:
ch = MERGED_FIELD_TO_CSV_HEADER[ik]
v = (d.get(ch) or d.get(ik) or "").strip()
if v:
out[ch] = v
elif sku:
jp = detail_dir / f"ware_{sku}_response.json"
if jp.is_file():
text = jp.read_text(encoding="utf-8").strip()
if text:
prof = extract_buyer_offer_profile_from_json_text(text)
out[br_h] = buyer_ranking_line_from_profile(prof)
out[pr_h] = buyer_promo_text_from_profile(prof)
out[br_h] = strip_buyer_ranking_line_prefix(out.get(br_h) or "")
rows_out.append(out)
with merged_path.open("w", encoding="utf-8-sig", newline="") as f:
w = csv.DictWriter(
f,
fieldnames=list(MERGED_CSV_COLUMNS),
extrasaction="ignore",
)
w.writeheader()
w.writerows(rows_out)
return len(rows_out), merged_path

View File

@ -12,7 +12,8 @@ from typing import Any
from django.conf import settings from django.conf import settings
from .models import PipelineJob from ..csv_schema import MERGED_FIELD_TO_CSV_HEADER
from ..models import PipelineJob
def merge_llm_supplement_with_rules_report(llm_md: str, rules_md: str) -> str: def merge_llm_supplement_with_rules_report(llm_md: str, rules_md: str) -> str:
@ -216,7 +217,7 @@ def write_competitor_analysis_for_run_dir(
) )
if not skip_kw: if not skip_kw:
try: try:
from .llm_keyword_suggest import suggest_focus_keywords_from_all_comments from ..llm.keyword_suggest import suggest_focus_keywords_from_all_comments
brief_pre = jcr.build_competitor_brief( brief_pre = jcr.build_competitor_brief(
run_dir=run_dir, run_dir=run_dir,
@ -263,7 +264,7 @@ def write_competitor_analysis_for_run_dir(
) )
if not skip_scen: if not skip_scen:
try: try:
from .llm_keyword_suggest import suggest_scenario_groups_llm from ..llm.keyword_suggest import suggest_scenario_groups_llm
raw_sg = eff_rc.get("comment_scenario_groups") raw_sg = eff_rc.get("comment_scenario_groups")
if isinstance(raw_sg, list) and raw_sg: if isinstance(raw_sg, list) and raw_sg:
@ -329,7 +330,7 @@ def write_competitor_analysis_for_run_dir(
meta=meta, meta=meta,
report_config=eff_rc, report_config=eff_rc,
) )
from .report_charts import generate_report_charts from ..reporting.charts import generate_report_charts
generate_report_charts(run_dir, brief_final) generate_report_charts(run_dir, brief_final)
@ -352,13 +353,13 @@ def write_competitor_analysis_for_run_dir(
if len(comment_units) >= 2: if len(comment_units) >= 2:
sentiment_llm_record["attempted"] = True sentiment_llm_record["attempted"] = True
try: try:
from .llm_generate import generate_comment_sentiment_analysis_llm from ..llm.generate import generate_comment_sentiment_analysis_llm
attr_units = jcr._comment_lines_with_product_context( attr_units = jcr._comment_lines_with_product_context(
comment_rows, comment_rows,
merged_rows, merged_rows,
sku_header="SKU(skuId)", sku_header=MERGED_FIELD_TO_CSV_HEADER["sku_id"],
title_h="标题(wareName)", title_h=MERGED_FIELD_TO_CSV_HEADER["title"],
) )
if len(attr_units) != len(comment_units): if len(attr_units) != len(comment_units):
attr_units = list(comment_units) attr_units = list(comment_units)
@ -369,6 +370,8 @@ def write_competitor_analysis_for_run_dir(
max_samples_negative=30, max_samples_negative=30,
max_samples_mixed=10, max_samples_mixed=10,
max_chars_per_review=360, max_chars_per_review=360,
semantic_pool_max=40,
shuffle_seed=kw,
) )
pl["keyword"] = kw pl["keyword"] = kw
llm_sentiment_md = generate_comment_sentiment_analysis_llm(pl) llm_sentiment_md = generate_comment_sentiment_analysis_llm(pl)
@ -397,8 +400,8 @@ def write_competitor_analysis_for_run_dir(
price_llm_rec: dict[str, Any] = {"schema_version": 1, "attempted": False} price_llm_rec: dict[str, Any] = {"schema_version": 1, "attempted": False}
scenario_gr_llm_rec: dict[str, Any] = {"schema_version": 1, "attempted": False} scenario_gr_llm_rec: dict[str, Any] = {"schema_version": 1, "attempted": False}
comment_gr_llm_rec: dict[str, Any] = {"schema_version": 1, "attempted": False} comment_gr_llm_rec: dict[str, Any] = {"schema_version": 1, "attempted": False}
sku_h = "SKU(skuId)" sku_h = MERGED_FIELD_TO_CSV_HEADER["sku_id"]
title_h = "标题(wareName)" title_h = MERGED_FIELD_TO_CSV_HEADER["title"]
def _env_on(name: str) -> bool: def _env_on(name: str) -> bool:
return os.environ.get(name, "").strip().lower() in ("1", "true", "yes") return os.environ.get(name, "").strip().lower() in ("1", "true", "yes")
@ -427,7 +430,7 @@ def write_competitor_analysis_for_run_dir(
if pl_mx: if pl_mx:
matrix_llm_rec["attempted"] = True matrix_llm_rec["attempted"] = True
try: try:
from .llm_generate import generate_matrix_group_summaries_llm from ..llm.generate import generate_matrix_group_summaries_llm
llm_matrix_md = generate_matrix_group_summaries_llm( llm_matrix_md = generate_matrix_group_summaries_llm(
pl_mx, keyword=kw pl_mx, keyword=kw
@ -451,7 +454,7 @@ def write_competitor_analysis_for_run_dir(
if pl_pr: if pl_pr:
price_llm_rec["attempted"] = True price_llm_rec["attempted"] = True
try: try:
from .llm_generate import generate_price_group_summaries_llm from ..llm.generate import generate_price_group_summaries_llm
llm_price_md = generate_price_group_summaries_llm(pl_pr, keyword=kw) llm_price_md = generate_price_group_summaries_llm(pl_pr, keyword=kw)
price_llm_rec["ok"] = True price_llm_rec["ok"] = True
@ -483,7 +486,7 @@ def write_competitor_analysis_for_run_dir(
if pl_sg: if pl_sg:
scenario_gr_llm_rec["attempted"] = True scenario_gr_llm_rec["attempted"] = True
try: try:
from .llm_generate import generate_scenario_group_summaries_llm from ..llm.generate import generate_scenario_group_summaries_llm
llm_scenario_gr_md = generate_scenario_group_summaries_llm( llm_scenario_gr_md = generate_scenario_group_summaries_llm(
pl_sg, keyword=kw pl_sg, keyword=kw
@ -520,7 +523,7 @@ def write_competitor_analysis_for_run_dir(
if pl_cg: if pl_cg:
comment_gr_llm_rec["attempted"] = True comment_gr_llm_rec["attempted"] = True
try: try:
from .llm_generate import generate_comment_group_summaries_llm from ..llm.generate import generate_comment_group_summaries_llm
llm_comment_gr_md = generate_comment_group_summaries_llm( llm_comment_gr_md = generate_comment_group_summaries_llm(
pl_cg, keyword=kw pl_cg, keyword=kw

View File

@ -0,0 +1 @@
"""竞品报告相关的大模型调用(关键词建议、章节生成等)。"""

View File

@ -13,8 +13,8 @@ from typing import Any
from django.conf import settings from django.conf import settings
from .brief_compact import compact_brief_for_llm from ..reporting.brief_compact import compact_brief_for_llm
from .strategy_draft import build_strategy_draft_markdown from ..reporting.strategy_draft import build_strategy_draft_markdown
def _ensure_ai_crawler_path() -> None: def _ensure_ai_crawler_path() -> None:
@ -112,37 +112,42 @@ def generate_competitor_report_markdown_llm(brief: dict[str, Any], keyword: str)
SENTIMENT_LLM_SYSTEM = """你是电商/食品类用户研究助手。输入 JSON 含: SENTIMENT_LLM_SYSTEM = """你是电商/食品类用户研究助手。输入 JSON 含:
- ``comment_sentiment_lexicon``关键词规则下的条数与短语命中粗判非深度学习
- ``positive_lexeme_hits_top`` / ``negative_lexeme_hits_top``短语级命中摘要与条形图同源 - ``comment_sentiment_lexicon``子串词表统计与报告条形图口径一致**仅作定量参考**子串命中说话人态度
- ``sample_reviews_*``按同一规则从评价中抽样的短文已截断**仅可依据这些原文与 lexicon 数字归纳** - ``positive_lexeme_hits_top`` / ``negative_lexeme_hits_top``短语级命中摘要同源
每条样本通常以 ``细类SKU品名店铺`` 开头表示该句评价对应的 **§5 矩阵细类****具体 SKU/商品标题****店铺**写归纳与引用短引文时**须让读者能回答哪家店哪条 SKU哪款品名**或保留该前缀或在同一句内用店铺名 + 品名/SKU复述一致信息**禁止**把多条样本混成用户普遍却不交代是哪一店哪一品 - ``sentiment_bucket_method``恒为 ``keyword_substring_heuristic````sample_reviews_positive_biased`` / ``negative`` / ``mixed_tone`` 是按该词表机械分桶的抽样**可能与整句真实褒贬不一致**例如软硬适中曾被误归负向
- **``sample_reviews_semantic_pool``**若有本批评价经去重后的**随机/洗牌抽样**覆盖未命中任一关键词的句子**归纳正/负向体验引用短引文时优先以此池与上述各列表中的原文为准自行结合语境理解**转折对比没那么甜软硬适中先抑后扬/先扬后抑整句态度**不得以子串是否命中负面词来断言该句为抱怨**
每条样本通常以 ``细类SKU品名店铺`` 开头表示 **§5 细类SKU品名店铺**写归纳与引文时须能还原哪家店哪条 SKU哪款品名或保留前缀**禁止**无指代地写用户普遍
**硬性要求** **硬性要求**
- **仅输出 Markdown 正文**不要用 ``` 围栏包裹全文 - **仅输出 Markdown 正文**不要用 ``` 围栏包裹全文
- **不要编造**样本中未出现的具体事实品牌价格医学功效 - **不要编造**样本中未出现的具体事实品牌价格医学功效
- 条数占比等**定量表述须与** ``comment_sentiment_lexicon`` **一致**勿与样本矛盾 - **定量数字**条数占比lexicon 各字段须与 ``comment_sentiment_lexicon`` **一致**勿编造
- 若某具体措辞口感偏硬****出现在任一 ``sample_reviews_*`` 字符串含前缀后的正文**禁止**用引号写出该句或暗示为直接引语仅可写口感相关抱怨在样本/词表中较集中等聚合表述 - **定性归纳**满意点/抱怨点引语是否算差评**整句语义**为准若某句在语义上为褒义或中性描述**不得**放入质地差口感硬等负向归因若词表分桶与句意冲突**以句意为准**并在使用注意点明关键词分桶仅作统计口径
- **不要**只复述某词出现 N 词频条形图已在报告正文你的价值是**语义层归纳**用户在说什么不满/满意的具体事由是什么 - 若某措辞****出现在任一抽样原文含前缀后正文**禁止**用引号写成直接引语
- **不要**只复述某词出现 N 条形图已展示你的价值是**语义归纳**
**建议结构**使用四级标题 ``####`` **建议结构**使用四级标题 ``####``
1. ``#### 正向体验主题``36 条;每条用一句话概括一类满意点(如口感、甜度、饱腹、性价比、物流),**尽量**在句末用简短「」引用样本中的原话片段佐证(无合适原话则省略引号,勿杜撰)。 1. ``#### 正向体验主题``36 条;概括满意点(口感、甜度、性价比等),**尽量**用「」引用 ``sample_reviews_semantic_pool`` 或其它样本中**语义确为正面**的短句(勿把对比褒义句当差评例子)。
2. ``#### 负向评价主题归因``**核心段落**。在「偏负向」与「混合」样本中归纳 **48 个具体问题维度**(示例维度,按需选用:口味/难吃/怪味、过甜或寡淡、质地口感、价格与促销、包装破损、物流时效、真伪与效期、与宣传不符、健康/功效疑虑等)。每个维度下用 12 条列表项写清「用户具体在抱怨什么」,并**尽量**附上来自 ``sample_reviews_negative_biased`` 或 ``sample_reviews_mixed_tone`` 的「」短引文(引文内**须含** ``【细类…|…店铺…】`` 前缀,或明确写出与前缀一致的**店铺 + 品名/SKU**);若某维度在样本中几乎无依据则不要硬写 2. ``#### 负向评价主题归因``**核心段落**。依据你读后判定为**确有不满**的句子,归纳 **48 个**问题维度(口味、质地、价格、物流等)。引文优先取自句意确为批评的原文(可来自任一档位键,不限于 ``sample_reviews_negative_biased``);引文须含 ``【细类…|…店铺…】`` 或同义店铺+品名/SKU
3. ``#### 混合评价中的典型张力``(可选):若 ``sample_reviews_mixed_tone`` 非空,用 24 条说明同一条评价里正负并存时在讨论什么(如「认可低糖但嫌口感」);否则写一句「本批混合样本较少,从略」 3. ``#### 混合评价中的典型张力``(可选):同一评价里褒贬并存时,说明在争什么;若无则略写
4. ``#### 使用注意``13 句说明:关键词分桶的局限、抽样与截断、与医学/功效结论无关等 4. ``#### 使用注意``关键词子串统计的局限、``sample_reviews_semantic_pool`` 与分桶的差异、抽样截断、非医学结论
总字数约 **7001600 **简体中文语气客观""" 总字数约 **7001600 **简体中文语气客观"""
def generate_comment_sentiment_analysis_llm(payload: dict[str, Any]) -> str: def generate_comment_sentiment_analysis_llm(payload: dict[str, Any]) -> str:
"""基于规则分桶抽样评价 + lexicon 统计,生成 §8.2 大模型解读段落Markdown""" """基于 lexicon 统计 + 语义池与分桶抽样,生成 §8.2 大模型解读段落Markdown"""
p = dict(payload) p = dict(payload)
raw = json.dumps(p, ensure_ascii=False) raw = json.dumps(p, ensure_ascii=False)
if len(raw) > 88_000: if len(raw) > 88_000:
# 超长时优先压缩正向与混合,保留更多负向样本以利主题归因 # 超长时优先压缩关键词分桶样本,再压缩语义池;尽量保留 semantic_pool 条数略多
for k, cap, maxlen in ( for k, cap, maxlen in (
("sample_reviews_positive_biased", 8, 200), ("sample_reviews_positive_biased", 6, 180),
("sample_reviews_mixed_tone", 6, 200), ("sample_reviews_mixed_tone", 4, 180),
("sample_reviews_negative_biased", 18, 220), ("sample_reviews_negative_biased", 14, 200),
("sample_reviews_semantic_pool", 30, 340),
): ):
lst = p.get(k) lst = p.get(k)
if isinstance(lst, list): if isinstance(lst, list):
@ -355,6 +360,7 @@ COMMENT_GROUPS_SYSTEM = """你是用户研究与品类顾问。输入为 JSON
每个 group ``group`` §5 矩阵一致的细分类目名``comment_flat_rows````effective_text_lines`` 每个 group ``group`` §5 矩阵一致的细分类目名``comment_flat_rows````effective_text_lines``
``focus_hit_lines``关注词子串命中摘要 §8.3 同源``sample_text_snippets``评价短摘录已截断 ``focus_hit_lines``关注词子串命中摘要 §8.3 同源``sample_text_snippets``评价短摘录已截断
摘录行通常以 ``细类SKU品名店铺`` 开头细类可与本 group 名对照**品名/SKU/店铺**表示该句具体出自哪条链接归纳时若引用原话**须交代是哪家店哪条 SKU哪款品名上的反馈**勿只写有用户说口感差而不指代产品 摘录行通常以 ``细类SKU品名店铺`` 开头细类可与本 group 名对照**品名/SKU/店铺**表示该句具体出自哪条链接归纳时若引用原话**须交代是哪家店哪条 SKU哪款品名上的反馈**勿只写有用户说口感差而不指代产品
关注词命中为子串统计可能与句意不一致**请以整句语义**判断褒贬软硬适中没那么甜常为满意表述不得据此写成质地问题
**为每个细类**输出一小段 Markdown全部 groups 都要写顺序与输入一致 **为每个细类**输出一小段 Markdown全部 groups 都要写顺序与输入一致
- ``#### `` + 与该 group 字段**完全一致**的细类名作为小节标题(不要使用 ``##`` 一级标题); - ``#### `` + 与该 group 字段**完全一致**的细类名作为小节标题(不要使用 ``##`` 一级标题);

View File

@ -0,0 +1,128 @@
# -*- coding: utf-8 -*-
"""
将磁盘上已存在的流水线批次目录``data/JD/pipeline_runs/...``导入数据库
- ``pc_search_export.csv`` / ``detail_ware_export.csv`` / ``comments_flat.csv``
- ``keyword_pipeline_merged.csv``合并宽表 + JdProduct / JdProductSnapshot
用法 ``backend`` 目录下::
python manage.py ingest_pipeline_dataset --run-dir pipeline_runs/20260413_104252_低GI
或绝对路径仍须在 ``data/JD`` ::
python manage.py ingest_pipeline_dataset --run-dir "D:/.../data/JD/pipeline_runs/xxx"
绑定已有 ``PipelineJob``::
python manage.py ingest_pipeline_dataset --job-id 12 --run-dir pipeline_runs/xxx
新建任务并入库关键词优先读 ``run_meta.json``::
python manage.py ingest_pipeline_dataset --create --run-dir pipeline_runs/xxx --keyword 低GI
"""
from __future__ import annotations
import json
from pathlib import Path
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from pipeline.ingest import FILE_MERGED_CSV, ingest_job_full, resolve_and_validate_run_dir
from pipeline.models import JobStatus, PipelineJob
class Command(BaseCommand):
help = "将已有 pipeline run 目录下的 CSV 导入数据库(搜索/详情/评价/合并表与商品快照)。"
def add_arguments(self, parser) -> None:
parser.add_argument(
"--run-dir",
type=str,
required=True,
help="相对 data/JD 的子路径,或位于 data/JD 下的绝对路径",
)
parser.add_argument(
"--job-id",
type=int,
default=None,
help="绑定到已有 PipelineJob未给则须配合 --create",
)
parser.add_argument(
"--create",
action="store_true",
help="新建 PipelineJobsuccess并写入 run_dir 后入库",
)
parser.add_argument(
"--keyword",
type=str,
default="",
help="与 --create 合用;默认尝试从 run_meta.json 读取 keyword",
)
def handle(self, *args, **options):
if not (settings.LOW_GI_PROJECT_ROOT or "").strip():
raise CommandError("请在 .env 中配置 LOW_GI_PROJECT_ROOT")
raw = str(options["run_dir"] or "").strip()
try:
run_path = resolve_and_validate_run_dir(raw)
except ValueError as e:
raise CommandError(str(e)) from e
merged = run_path / FILE_MERGED_CSV
if not merged.is_file():
self.stdout.write(
self.style.WARNING(
f"缺少 {FILE_MERGED_CSV},仍将尝试导入搜索/详情/评价(合并表与快照会跳过或报错)。"
)
)
job_id = options.get("job_id")
create = bool(options.get("create"))
kw_in = (options.get("keyword") or "").strip()
if job_id and create:
raise CommandError("请只使用 --job-id 或 --create 之一")
if create:
meta_kw = ""
meta_path = run_path / "run_meta.json"
if meta_path.is_file():
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
if isinstance(meta, dict):
meta_kw = str(meta.get("keyword") or "").strip()
except (json.JSONDecodeError, OSError):
pass
keyword = kw_in or meta_kw or "imported"
job = PipelineJob.objects.create(
platform="jd",
keyword=keyword[:256],
status=JobStatus.SUCCESS,
run_dir=str(run_path),
)
self.stdout.write(self.style.NOTICE(f"已创建任务 id={job.id} keyword={job.keyword!r}"))
elif job_id:
job = PipelineJob.objects.filter(pk=job_id).first()
if not job:
raise CommandError(f"找不到 PipelineJob id={job_id}")
job.run_dir = str(run_path)
job.save(update_fields=["run_dir", "updated_at"])
self.stdout.write(self.style.NOTICE(f"已更新任务 id={job.id} 的 run_dir"))
else:
raise CommandError("请指定 --job-id 绑定已有任务,或使用 --create 新建任务")
try:
stats = ingest_job_full(job)
except FileNotFoundError as e:
raise CommandError(str(e)) from e
self.stdout.write(self.style.SUCCESS(json.dumps(stats, ensure_ascii=False, indent=2)))
self.stdout.write(
self.style.NOTICE(
f"完成。前端可打开任务 {job.id},数据集接口:"
f"/api/pipeline/jobs/{job.id}/dataset/summary/ 等。"
)
)

View File

@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
"""补全并规范化已有 run 目录下的 ``keyword_pipeline_merged.csv``lean 列序,与 detail_ware 对齐)。"""
from __future__ import annotations
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from pipeline.ingest import resolve_and_validate_run_dir
from pipeline.jd.merged_regen import write_keyword_pipeline_merged_lean_csv
class Command(BaseCommand):
help = "将 keyword_pipeline_merged.csv 重写为 lean 宽表列序,并刷新榜单/购买者摘要列。"
def add_arguments(self, parser) -> None:
parser.add_argument(
"--run-dir",
type=str,
required=True,
help="相对 data/JD 的子路径,或位于 data/JD 下的绝对路径",
)
def handle(self, *args, **options):
if not (settings.LOW_GI_PROJECT_ROOT or "").strip():
raise CommandError("请在 .env 中配置 LOW_GI_PROJECT_ROOT")
raw = str(options["run_dir"] or "").strip()
try:
run_path = resolve_and_validate_run_dir(raw)
except ValueError as e:
raise CommandError(str(e)) from e
n, p = write_keyword_pipeline_merged_lean_csv(run_path)
self.stdout.write(self.style.SUCCESS(f"已写 {n} 行 -> {p}"))

View File

@ -0,0 +1,75 @@
# -*- coding: utf-8 -*-
"""
将已有 run 目录下 CSV 表头重写为 ``csv_schema`` 纯中文表头仅重命名与列序不修改业务逻辑
``backend`` 目录::
python manage.py rewrite_pipeline_csv_headers --run-dir pipeline_runs/20260413_104252_低GI
python manage.py rewrite_pipeline_csv_headers --run-dir pipeline_runs/xxx --dry-run
仅处理部分文件::
python manage.py rewrite_pipeline_csv_headers --run-dir pipeline_runs/xxx --file keyword_pipeline_merged.csv
"""
from __future__ import annotations
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from pipeline.csv_header_rewrite import rewrite_run_dir_csv_headers
from pipeline.ingest import (
FILE_COMMENTS_FLAT_CSV,
FILE_DETAIL_WARE_CSV,
FILE_MERGED_CSV,
FILE_PC_SEARCH_CSV,
resolve_and_validate_run_dir,
)
class Command(BaseCommand):
help = "将 pipeline run 目录内 CSV 表头规范为 csv_schema 中的中文表头。"
def add_arguments(self, parser) -> None:
parser.add_argument(
"--run-dir",
type=str,
required=True,
help="相对 data/JD 的子路径,或位于 data/JD 下的绝对路径",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="只打印将执行的操作,不写回文件",
)
parser.add_argument(
"--file",
action="append",
dest="files",
metavar="NAME",
help=(
"只处理指定文件名,可多次传入。"
f"可选: {FILE_MERGED_CSV}, {FILE_PC_SEARCH_CSV}, "
f"{FILE_COMMENTS_FLAT_CSV}, {FILE_DETAIL_WARE_CSV}"
),
)
def handle(self, *args, **options):
if not (settings.LOW_GI_PROJECT_ROOT or "").strip():
raise CommandError("请在 .env 中配置 LOW_GI_PROJECT_ROOT")
raw = str(options["run_dir"] or "").strip()
try:
run_path = resolve_and_validate_run_dir(raw)
except ValueError as e:
raise CommandError(str(e)) from e
dry = bool(options["dry_run"])
only = options.get("files") or None
msgs = rewrite_run_dir_csv_headers(run_path, dry_run=dry, only=only)
for msg in msgs:
self.stdout.write(msg)
if dry:
self.stdout.write(self.style.WARNING("dry-run未写入磁盘"))
else:
self.stdout.write(self.style.SUCCESS(f"完成: {run_path}"))

View File

@ -9,7 +9,7 @@ from django.conf import settings
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from pipeline.cookie_paste import normalize_browser_cookie_paste from pipeline.cookie_paste import normalize_browser_cookie_paste
from pipeline.jd_runner import run_jd_keyword_and_report from pipeline.jd.runner import run_jd_keyword_and_report
from pipeline.models import PipelineJob from pipeline.models import PipelineJob

View File

@ -0,0 +1,33 @@
# Generated by Django 5.2.1 on 2026-04-15 06:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pipeline", "0015_jdjobsearchrow_total_sales"),
]
operations = [
migrations.AddField(
model_name="jdjobdetailrow",
name="buyer_ranking_line",
field=models.TextField(blank=True, default=""),
),
migrations.AddField(
model_name="jdjobdetailrow",
name="buyer_promo_text",
field=models.TextField(blank=True, default=""),
),
migrations.AddField(
model_name="jdjobmergedrow",
name="buyer_ranking_line",
field=models.TextField(blank=True, default=""),
),
migrations.AddField(
model_name="jdjobmergedrow",
name="buyer_promo_text",
field=models.TextField(blank=True, default=""),
),
]

View File

@ -207,6 +207,8 @@ class JdJobDetailRow(models.Model):
detail_category_path = models.TextField(blank=True, default="") detail_category_path = models.TextField(blank=True, default="")
detail_product_attributes = models.TextField(blank=True, default="") detail_product_attributes = models.TextField(blank=True, default="")
detail_body_ingredients = models.TextField(blank=True, default="") detail_body_ingredients = models.TextField(blank=True, default="")
buyer_ranking_line = models.TextField(blank=True, default="")
buyer_promo_text = models.TextField(blank=True, default="")
class Meta: class Meta:
ordering = ["row_index"] ordering = ["row_index"]
@ -292,6 +294,8 @@ class JdJobMergedRow(models.Model):
detail_category_path = models.TextField(blank=True, default="") detail_category_path = models.TextField(blank=True, default="")
detail_product_attributes = models.TextField(blank=True, default="") detail_product_attributes = models.TextField(blank=True, default="")
detail_body_ingredients = models.TextField(blank=True, default="") detail_body_ingredients = models.TextField(blank=True, default="")
buyer_ranking_line = models.TextField(blank=True, default="")
buyer_promo_text = models.TextField(blank=True, default="")
pipeline_comment_count = models.TextField(blank=True, default="") pipeline_comment_count = models.TextField(blank=True, default="")
comment_preview = models.TextField(blank=True, default="") comment_preview = models.TextField(blank=True, default="")

View File

@ -0,0 +1 @@
"""规则报告与简报统计图、Markdown/Office 导出、策略稿、简报 ZIP。"""

View File

@ -7,7 +7,7 @@ import zipfile
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from pipeline.brief_concentration import ( from .brief_concentration import (
concentration_first_share, concentration_first_share,
concentration_top_three_share, concentration_top_three_share,
) )

View File

@ -149,6 +149,74 @@ def _merge_tail_as_other(
# 已不再写入报告正文的旧版图,避免 run_dir 里残留误导性 PNG # 已不再写入报告正文的旧版图,避免 run_dir 里残留误导性 PNG
# 横向条形图:统一柱厚、柱端数值字号(全文件条形图共用)
_BARH_HEIGHT = 0.6
_BAR_VALUE_FONTSIZE = 8
def _thin_barh_height(n: int) -> float:
"""
横向条形图类目条数 n 较少时降低 barh height y 轴跨度同量纲
n=1 时若仍用 0.6 ylim 跨度仅 1单条会占满大半幅视觉上极粗
"""
if n <= 0:
return _BARH_HEIGHT
if n == 1:
return 0.30
if n == 2:
return 0.44
if n <= 5:
return 0.50
if n <= 10:
return 0.54
return _BARH_HEIGHT
def _set_barh_category_ylim(ax: Any, n: int) -> None:
"""n 条类目横条时设置纵轴范围n=1 时略放宽,使柱相对更细。"""
if n <= 0:
return
if n == 1:
ax.set_ylim(-1.0, 1.0)
else:
ax.set_ylim(-0.5, float(n) - 0.5)
def _fmt_bar_value(v: float, *, as_int: bool = False) -> str:
if as_int or (math.isfinite(v) and abs(v - round(v)) < 1e-6):
return str(int(round(v)))
s = f"{v:.2f}".rstrip("0").rstrip(".")
return s if s else "0"
def _annotate_barh_numeric(
ax: Any,
bars: Any,
values: list[float],
*,
as_int: bool = False,
x_pad_ratio: float = 0.02,
) -> None:
"""在横向柱末端标注数值;调用前请已设置合适的 xlim。"""
if not bars or not values:
return
x1 = ax.get_xlim()[1]
if x1 <= 0:
return
pad = max(x1 * x_pad_ratio, 0.02 * max(values) if values else 0.1)
for bar, v in zip(bars, values):
if v is None or not math.isfinite(float(v)) or float(v) <= 0:
continue
w = bar.get_width()
ax.text(
w + pad,
bar.get_y() + bar.get_height() / 2,
_fmt_bar_value(float(v), as_int=as_int),
va="center",
fontsize=_BAR_VALUE_FONTSIZE,
)
_OBSOLETE_REPORT_ASSETS: frozenset[str] = frozenset( _OBSOLETE_REPORT_ASSETS: frozenset[str] = frozenset(
{ {
"chart_focus_keywords_bar.png", "chart_focus_keywords_bar.png",
@ -212,13 +280,21 @@ def generate_report_charts(run_dir: Path, brief: dict[str, Any]) -> list[str]:
fig_h = max(3.2, min(14.0, 0.38 * n + 1.5)) fig_h = max(3.2, min(14.0, 0.38 * n + 1.5))
fig, ax = plt.subplots(figsize=(8.2, fig_h)) fig, ax = plt.subplots(figsize=(8.2, fig_h))
y_pos = range(n) y_pos = range(n)
ax.barh(list(y_pos), values, color="#2563eb", height=0.65) bh = _thin_barh_height(n)
bars = ax.barh(
list(y_pos), values, color="#2563eb", height=bh
)
ax.set_yticks(list(y_pos)) ax.set_yticks(list(y_pos))
ax.set_yticklabels(labels, fontsize=9) ax.set_yticklabels(labels, fontsize=9)
ax.invert_yaxis() ax.invert_yaxis()
_set_barh_category_ylim(ax, n)
ax.set_title(title, fontsize=12, pad=10) ax.set_title(title, fontsize=12, pad=10)
if xlabel: if xlabel:
ax.set_xlabel(xlabel, fontsize=9) ax.set_xlabel(xlabel, fontsize=9)
ax.tick_params(axis="y", left=True, right=False, labelleft=True, labelright=False)
vmax = max(values)
ax.set_xlim(0, vmax * 1.14 + max(0.08 * vmax, 0.5))
_annotate_barh_numeric(ax, bars, list(values), as_int=True)
fig.tight_layout() fig.tight_layout()
path = out_dir / fname path = out_dir / fname
fig.savefig(path, dpi=130, bbox_inches="tight") fig.savefig(path, dpi=130, bbox_inches="tight")
@ -242,21 +318,26 @@ def generate_report_charts(run_dir: Path, brief: dict[str, Any]) -> list[str]:
fig_h = max(3.2, min(14.0, 0.38 * n_b + 1.8)) fig_h = max(3.2, min(14.0, 0.38 * n_b + 1.8))
fig, ax = plt.subplots(figsize=(8.8, fig_h)) fig, ax = plt.subplots(figsize=(8.8, fig_h))
y_pos = range(n_b) y_pos = range(n_b)
bars = ax.barh(list(y_pos), pcts, color="#2563eb", height=0.65) bh = _thin_barh_height(n_b)
bars = ax.barh(list(y_pos), pcts, color="#2563eb", height=bh)
ax.set_yticks(list(y_pos)) ax.set_yticks(list(y_pos))
ax.set_yticklabels(labels, fontsize=9) ax.set_yticklabels(labels, fontsize=9)
ax.invert_yaxis() ax.invert_yaxis()
_set_barh_category_ylim(ax, n_b)
ax.set_title(title, fontsize=12, pad=10) ax.set_title(title, fontsize=12, pad=10)
ax.set_xlabel("占有效评价文本比例(%", fontsize=9) ax.set_xlabel("占有效评价文本比例(%", fontsize=9)
ax.tick_params(axis="y", left=True, right=False, labelleft=True, labelright=False)
xmax = max(pcts) * 1.12 + 4.0 xmax = max(pcts) * 1.12 + 4.0
ax.set_xlim(0, max(xmax, max(pcts) + 10.0, 24.0)) ax.set_xlim(0, max(xmax, max(pcts) + 10.0, 24.0))
x1 = ax.get_xlim()[1]
pad = max(x1 * 0.015, 0.35)
for bar, c, p in zip(bars, counts, pcts): for bar, c, p in zip(bars, counts, pcts):
ax.text( ax.text(
min(bar.get_width() + 0.6, ax.get_xlim()[1] * 0.97), min(bar.get_width() + pad, x1 * 0.985),
bar.get_y() + bar.get_height() / 2, bar.get_y() + bar.get_height() / 2,
f"{int(c)}条 · {p:.1f}%", f"{int(c)}条 · {p:.1f}%",
va="center", va="center",
fontsize=8, fontsize=_BAR_VALUE_FONTSIZE,
) )
fig.tight_layout() fig.tight_layout()
path = out_dir / fname path = out_dir / fname
@ -286,7 +367,7 @@ def generate_report_charts(run_dir: Path, brief: dict[str, Any]) -> list[str]:
fig = plt.figure(figsize=(10.8, fig_h)) fig = plt.figure(figsize=(10.8, fig_h))
ttl = (gname or "").strip()[:22] or "细类" ttl = (gname or "").strip()[:22] or "细类"
fig.suptitle( fig.suptitle(
f"{ttl}」· 关注词与使用场景(与 §8.3 统计同源;左右 Y 轴独立)", f"{ttl}」· 关注词与使用场景",
fontsize=11, fontsize=11,
y=0.98, y=0.98,
) )
@ -304,14 +385,22 @@ def generate_report_charts(run_dir: Path, brief: dict[str, Any]) -> list[str]:
ax_r = fig.add_axes([x_r, base_bottom, ax_w, h_r]) ax_r = fig.add_axes([x_r, base_bottom, ax_w, h_r])
if has_l: if has_l:
y_pos = list(range(n_l)) y_pos = list(range(n_l))
ax_l.barh(y_pos, vl[:n_l], color="#2563eb", height=0.62) bh_l = _thin_barh_height(n_l)
bars_l = ax_l.barh(
y_pos, vl[:n_l], color="#2563eb", height=bh_l
)
ax_l.set_yticks(y_pos) ax_l.set_yticks(y_pos)
ax_l.set_yticklabels(wl[:n_l], fontsize=8) ax_l.set_yticklabels(wl[:n_l], fontsize=8)
ax_l.set_ylim(-0.5, n_l - 0.5)
ax_l.invert_yaxis() ax_l.invert_yaxis()
_set_barh_category_ylim(ax_l, n_l)
ax_l.set_xlabel("关注词子串命中次数", fontsize=9) ax_l.set_xlabel("关注词子串命中次数", fontsize=9)
ax_l.set_title("关注词(左轴:词表)", fontsize=10, pad=6) ax_l.set_title("关注词", fontsize=10, pad=6)
ax_l.tick_params(axis="y", left=True, right=False, labelleft=True, labelright=False) ax_l.tick_params(axis="y", left=True, right=False, labelleft=True, labelright=False)
vmax_l = max(vl[:n_l])
ax_l.set_xlim(0, vmax_l * 1.14 + max(0.5, 0.08 * vmax_l))
_annotate_barh_numeric(
ax_l, bars_l, list(vl[:n_l]), as_int=True
)
else: else:
ax_l.text( ax_l.text(
0.5, 0.5,
@ -328,30 +417,32 @@ def generate_report_charts(run_dir: Path, brief: dict[str, Any]) -> list[str]:
pcts = [100.0 * c / n_texts for c in gv[: len(gl)]] pcts = [100.0 * c / n_texts for c in gv[: len(gl)]]
n_b = len(gl) n_b = len(gl)
y_pos = list(range(n_b)) y_pos = list(range(n_b))
bars = ax_r.barh(y_pos, pcts, color="#059669", height=0.62) bh_r = _thin_barh_height(n_b)
bars = ax_r.barh(y_pos, pcts, color="#059669", height=bh_r)
ax_r.set_yticks(y_pos) ax_r.set_yticks(y_pos)
ax_r.set_yticklabels(gl[:n_b], fontsize=8) ax_r.set_yticklabels(gl[:n_b], fontsize=8)
ax_r.set_ylim(-0.5, n_b - 0.5)
ax_r.invert_yaxis() ax_r.invert_yaxis()
_set_barh_category_ylim(ax_r, n_b)
ax_r.set_xlabel("占有效评价文本比例(%", fontsize=9) ax_r.set_xlabel("占有效评价文本比例(%", fontsize=9)
if pcts: if pcts:
xmax = max(pcts) * 1.12 + 4.0 xmax = max(pcts) * 1.12 + 4.0
ax_r.set_xlim(0, max(xmax, max(pcts) + 10.0, 24.0)) ax_r.set_xlim(0, max(xmax, max(pcts) + 10.0, 24.0))
else: else:
ax_r.set_xlim(0, 24.0) ax_r.set_xlim(0, 24.0)
x1r = ax_r.get_xlim()[1]
pad_r = max(x1r * 0.015, 0.35)
for bar, c, p in zip(bars, gv[:n_b], pcts): for bar, c, p in zip(bars, gv[:n_b], pcts):
ax_r.text( ax_r.text(
min(bar.get_width() + 0.6, ax_r.get_xlim()[1] * 0.97), min(bar.get_width() + pad_r, x1r * 0.985),
bar.get_y() + bar.get_height() / 2, bar.get_y() + bar.get_height() / 2,
f"{int(c)}条 · {p:.1f}%", f"{int(c)}条 · {p:.1f}%",
va="center", va="center",
fontsize=8, fontsize=_BAR_VALUE_FONTSIZE,
) )
ax_r.set_title("使用场景(右轴:场景标签)", fontsize=10, pad=6) ax_r.set_title("使用场景", fontsize=10, pad=6)
# 场景类目轴画在右侧,与左侧关注词轴分离,避免中间挤两列标签 ax_r.yaxis.tick_left()
ax_r.yaxis.tick_right() ax_r.yaxis.set_label_position("left")
ax_r.yaxis.set_label_position("right") ax_r.tick_params(axis="y", left=True, right=False, labelleft=True, labelright=False)
ax_r.tick_params(axis="y", left=False, right=True, labelleft=False, labelright=True)
else: else:
ax_r.text( ax_r.text(
0.5, 0.5,
@ -412,15 +503,15 @@ def generate_report_charts(run_dir: Path, brief: dict[str, Any]) -> list[str]:
save_pie( save_pie(
labs_m, labs_m,
vals_m, vals_m,
"类目/可读名称分布(列表行占比", "细类分布(合并表 SKU",
"chart_category_mix_pie.png", "chart_category_mix_pie.png",
) )
save_bar_h( save_bar_h(
labs_m[:15], labs_m[:15],
vals_m[:15], vals_m[:15],
"类目分布(行Top", "细类分布(合并表 SKU Top",
"chart_category_mix.png", "chart_category_mix.png",
"", "SKU ",
) )
brand_mix = brief.get("list_brand_mix_top") or [] brand_mix = brief.get("list_brand_mix_top") or []
@ -614,24 +705,66 @@ def generate_report_charts(run_dir: Path, brief: dict[str, Any]) -> list[str]:
fig, (ax_l, ax_r) = plt.subplots( fig, (ax_l, ax_r) = plt.subplots(
1, 2, figsize=(10.6, fig_h), sharey=True 1, 2, figsize=(10.6, fig_h), sharey=True
) )
for yi, pr in enumerate(prices_mx): bh_mx = _thin_barh_height(n)
if pr is not None and pr > 0 and math.isfinite(pr): price_w = [
ax_l.barh(yi, pr, height=0.62, color="#2563eb") float(pr)
if pr is not None and pr > 0 and math.isfinite(pr)
else 0.0
for pr in prices_mx
]
bars_pl = ax_l.barh(
y_pos, price_w, height=bh_mx, color="#2563eb"
)
ax_l.set_yticks(y_pos) ax_l.set_yticks(y_pos)
ax_l.set_yticklabels(labels_mx, fontsize=8) ax_l.set_yticklabels(labels_mx, fontsize=8)
ax_l.invert_yaxis() ax_l.invert_yaxis()
_set_barh_category_ylim(ax_l, n)
ax_l.set_xlabel("展示价(元)", fontsize=9) ax_l.set_xlabel("展示价(元)", fontsize=9)
ax_l.set_title("展示价", fontsize=10, pad=8) ax_l.set_title("展示价", fontsize=10, pad=8)
ax_r.barh(y_pos, sales_mx, height=0.62, color="#059669") ax_l.tick_params(axis="y", left=True, right=False, labelleft=True, labelright=False)
ax_r.set_xlabel("销量(搜索列表 totalSales 口径,已解析为件数)", fontsize=9) pmax = max(price_w) if price_w else 0.0
if pmax > 0:
ax_l.set_xlim(0, pmax * 1.12 + max(0.08 * pmax, 0.5))
else:
ax_l.set_xlim(0, 1)
pad_p = max(ax_l.get_xlim()[1] * 0.012, 0.08)
for bar, pr in zip(bars_pl, prices_mx):
if pr is not None and pr > 0 and math.isfinite(pr):
ax_l.text(
bar.get_width() + pad_p,
bar.get_y() + bar.get_height() / 2,
_fmt_bar_value(float(pr), as_int=False),
va="center",
fontsize=_BAR_VALUE_FONTSIZE,
)
sales_f = [float(s) for s in sales_mx]
bars_sr = ax_r.barh(
y_pos, sales_f, height=bh_mx, color="#059669"
)
ax_r.set_xlabel("销量", fontsize=9)
ax_r.set_title("销量", fontsize=10, pad=8) ax_r.set_title("销量", fontsize=10, pad=8)
ax_r.xaxis.set_major_formatter( ax_r.xaxis.set_major_formatter(
FuncFormatter(_format_xaxis_int_cn) FuncFormatter(_format_xaxis_int_cn)
) )
ax_r.tick_params(axis="y", left=False, labelleft=False) ax_r.tick_params(axis="y", left=False, labelleft=False)
smax = max(sales_f) if sales_f else 0.0
if smax > 0:
ax_r.set_xlim(0, smax * 1.1 + max(0.04 * smax, smax * 0.02))
else:
ax_r.set_xlim(0, 1)
pad_s = max(ax_r.get_xlim()[1] * 0.008, smax * 0.01 if smax else 0.1)
for bar, sv in zip(bars_sr, sales_mx):
if sv > 0:
ax_r.text(
bar.get_width() + pad_s,
bar.get_y() + bar.get_height() / 2,
_format_xaxis_int_cn(float(sv), None),
va="center",
fontsize=_BAR_VALUE_FONTSIZE,
)
ttl = gname[:22] if gname else "细类" ttl = gname[:22] if gname else "细类"
fig.suptitle( fig.suptitle(
f"{ttl}」· 竞品矩阵:价格与销量(与 §5 表同源)", f"{ttl}」· 竞品矩阵:价格与销量",
fontsize=11, fontsize=11,
y=1.01, y=1.01,
) )

View File

@ -9,7 +9,7 @@ from __future__ import annotations
import math import math
from typing import Any from typing import Any
from pipeline.brief_concentration import ( from .brief_concentration import (
concentration_first_share, concentration_first_share,
concentration_top_three_share, concentration_top_three_share,
) )

View File

@ -12,7 +12,7 @@ from django.utils import timezone
from .cookie_paste import normalize_browser_cookie_paste from .cookie_paste import normalize_browser_cookie_paste
from .ingest import try_ingest_job_full from .ingest import try_ingest_job_full
from .jd_runner import ( from .jd.runner import (
resolve_pipeline_run_directory_for_job, resolve_pipeline_run_directory_for_job,
try_write_competitor_report_if_merged_exists, try_write_competitor_report_if_merged_exists,
) )

View File

@ -3,7 +3,10 @@ from __future__ import annotations
from django.test import SimpleTestCase from django.test import SimpleTestCase
from pipeline.brief_compact import compact_brief_for_llm, matrix_overview_for_llm from pipeline.reporting.brief_compact import (
compact_brief_for_llm,
matrix_overview_for_llm,
)
class BriefCompactTests(SimpleTestCase): class BriefCompactTests(SimpleTestCase):

View File

@ -9,7 +9,7 @@ from tempfile import TemporaryDirectory
from django.test import SimpleTestCase from django.test import SimpleTestCase
from pipeline.brief_pack import ( from pipeline.reporting.brief_pack import (
build_brief_pack_zip_bytes, build_brief_pack_zip_bytes,
markdown_summary_from_brief, markdown_summary_from_brief,
) )

View File

@ -0,0 +1,49 @@
"""buyer_offer_export_csv榜单列与促销文案列分隔符拼接"""
from __future__ import annotations
import sys
from pathlib import Path
from django.conf import settings
from django.test import SimpleTestCase
from pipeline.csv_schema import strip_buyer_ranking_line_prefix
class BuyerOfferExportCsvTests(SimpleTestCase):
def test_strip_buyer_ranking_prefix(self) -> None:
self.assertEqual(
strip_buyer_ranking_line_prefix("榜单/曝光老金磨方药食同源热卖榜·第1名。"),
"老金磨方药食同源热卖榜·第1名。",
)
self.assertEqual(
strip_buyer_ranking_line_prefix("榜单/曝光粗粮饼干热卖榜·第5名"),
"粗粮饼干热卖榜·第5名",
)
def test_ranking_and_promo_from_profile(self) -> None:
dr = Path(settings.CRAWLER_JD_ROOT).resolve() / "detail"
if str(dr) not in sys.path:
sys.path.insert(0, str(dr))
import jd_detail_buyer_extraction as be # noqa: WPS433
prof = {
"visibility": {"rankings": ["20-40元酥性饼干热卖榜·第8名"]},
"buyer_summary_lines": [
"当前展示「到手价」约 27.97 元。",
"详情页优惠拆解:购买立减。",
"榜单/曝光:应被排除。",
"送达:应被排除。",
"企业采购提示:应被排除。",
],
}
self.assertEqual(
be.buyer_ranking_line_from_profile(prof),
"20-40元酥性饼干热卖榜·第8名。",
)
t = be.buyer_promo_text_from_profile(prof)
self.assertNotIn("榜单", t)
self.assertNotIn("送达", t)
self.assertNotIn("企业采购", t)
self.assertIn(" | ", t)
self.assertIn("到手价", t)

View File

@ -9,7 +9,7 @@ from django.conf import settings
from django.test import SimpleTestCase from django.test import SimpleTestCase
from pipeline.csv_schema import infer_total_sales_from_sales_floor from pipeline.csv_schema import infer_total_sales_from_sales_floor
from pipeline.report_charts import _cn_volume_int from pipeline.reporting.charts import _cn_volume_int
class BuildCompetitorBriefTests(SimpleTestCase): class BuildCompetitorBriefTests(SimpleTestCase):
@ -42,6 +42,24 @@ class BuildCompetitorBriefTests(SimpleTestCase):
json.dumps(out) json.dumps(out)
def test_comment_sentiment_llm_payload_has_semantic_pool(self) -> None:
root = Path(settings.CRAWLER_JD_ROOT).resolve()
if str(root) not in sys.path:
sys.path.insert(0, str(root))
import jd_competitor_report as jcr # noqa: WPS433
texts = ["口感软硬适中很好吃", "太差了不建议"]
attr = [f"【细类ASKU1品名x店铺y】{t}" for t in texts]
pl = jcr.build_comment_sentiment_llm_payload(
texts,
attributed_texts=attr,
shuffle_seed="unit-test-seed",
semantic_pool_max=10,
)
self.assertIn("sample_reviews_semantic_pool", pl)
self.assertEqual(pl.get("sentiment_bucket_method"), "keyword_substring_heuristic")
self.assertGreaterEqual(len(pl["sample_reviews_semantic_pool"]), 1)
def test_custom_focus_words_in_report_config(self) -> None: def test_custom_focus_words_in_report_config(self) -> None:
root = Path(settings.CRAWLER_JD_ROOT).resolve() root = Path(settings.CRAWLER_JD_ROOT).resolve()
if str(root) not in sys.path: if str(root) not in sys.path:

View File

@ -0,0 +1,123 @@
"""商详 JSON → 购买者视角优惠摘要(规则抽取)。"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from django.conf import settings
from django.test import SimpleTestCase
class DetailBuyerExtractionTests(SimpleTestCase):
def test_extract_minimal_dict(self) -> None:
root = Path(settings.CRAWLER_JD_ROOT).resolve()
dr = root / "detail"
if str(dr) not in sys.path:
sys.path.insert(0, str(dr))
import jd_detail_buyer_extraction as be # noqa: WPS433
obj = {
"warePriceGatherVO": {
"priceItemList": [
{
"hitLine": False,
"price": "64.97",
"priceLabelList": [{"labelTxt": "到手价", "labelType": "finalPrice"}],
"priceType": "finalPrice",
},
{
"hitLine": True,
"price": "66",
"priceType": "jdPrice",
},
]
},
"bestPromotion": {"purchasePrice": "64.97", "canGetCoupon": []},
"warmTipVO": {"tips": [{"tipTxt": "此商品不可使用东券", "order": 1}]},
"promotion": {"prompt": ""},
"rankInfoList": [{"rankName": "粗粮饼干热卖榜·第5名"}],
"userInfo": {"newPeople": True},
"bottomBtnVO": {
"bottomBtnItems": [
{
"buttonStyle": {
"textFormat": {
"text": "新人到手价<span>¥64.97</span> 立即购买"
}
}
}
]
},
"stockInfo": {
"promiseResult": "12:00前付款预计今天送达",
},
"serviceTagsVO": {
"basicNewIcons": [
{"text": "7天价保", "tip": "在下单后7天内商品出现降价可享受价保服务。"},
]
},
"preferenceVO": {
"againSharedLabel": [{"labelName": "最高返6京豆"}],
"preferencePopUp": {
"expression": {
"basePrice": "66",
"discountDesc": "购买立减",
"discountAmount": "1.03",
"redAmount": "1.03",
"couponAmount": "0",
"promotionAmount": "0",
"govAmount": "",
"subtrahends": [
{
"topDesc": "红包",
"preferenceDesc": "红包抵¥1.03",
"preferenceAmount": "0",
"preferenceType": "5",
}
],
},
"againSharedPreference": [
{"shortText": "新人包邮", "value": "包邮", "text": "新人包邮"}
],
},
},
}
out = be.extract_buyer_offer_profile(obj)
self.assertEqual(out.get("schema_version"), 1)
self.assertIn("64.97", str(out.get("price_snapshot") or {}))
dm = out.get("discount_mechanism") or {}
self.assertEqual(dm.get("expression", {}).get("discount_desc"), "购买立减")
self.assertTrue(dm.get("subtrahends"))
lines = out.get("buyer_summary_lines") or []
self.assertTrue(any("到手价" in x for x in lines))
self.assertTrue(any("东券" in x for x in lines))
self.assertTrue(any("购买立减" in x or "红包" in x for x in lines))
def test_extract_from_real_file_if_present(self) -> None:
root = Path(settings.CRAWLER_JD_ROOT).resolve()
dr = root / "detail"
if str(dr) not in sys.path:
sys.path.insert(0, str(dr))
import jd_detail_buyer_extraction as be # noqa: WPS433
sample = (
Path(__file__).resolve().parents[3]
/ "data"
/ "JD"
/ "pipeline_runs"
/ "20260413_104252_低GI"
/ "detail"
/ "ware_100107873140_response.json"
)
if not sample.is_file():
self.skipTest("sample ware JSON not in workspace")
text = sample.read_text(encoding="utf-8")
out = be.extract_buyer_offer_profile_from_json_text(text)
self.assertEqual(out.get("schema_version"), 1)
self.assertTrue(out.get("buyer_summary_lines"))
# 样例中应有到手价与不可用东券提示
blob = json.dumps(out, ensure_ascii=False)
self.assertIn("64.97", blob)
self.assertIn("东券", blob)
self.assertTrue("购买立减" in blob or "红包" in blob)

View File

@ -3,7 +3,7 @@ from __future__ import annotations
import unittest import unittest
from pipeline.llm_keyword_suggest import _parse_phrases_object, _parse_scenarios_object from pipeline.llm.keyword_suggest import _parse_phrases_object, _parse_scenarios_object
class ParsePhrasesTests(unittest.TestCase): class ParsePhrasesTests(unittest.TestCase):

View File

@ -3,7 +3,10 @@ from __future__ import annotations
from django.test import SimpleTestCase from django.test import SimpleTestCase
from pipeline.md_document_export import markdown_to_docx_bytes, markdown_to_pdf_bytes from pipeline.reporting.md_document_export import (
markdown_to_docx_bytes,
markdown_to_pdf_bytes,
)
class MdDocumentExportTests(SimpleTestCase): class MdDocumentExportTests(SimpleTestCase):

View File

@ -3,7 +3,7 @@ from __future__ import annotations
from django.test import SimpleTestCase from django.test import SimpleTestCase
from pipeline.strategy_draft import build_strategy_draft_markdown from pipeline.reporting.strategy_draft import build_strategy_draft_markdown
class StrategyDraftTests(SimpleTestCase): class StrategyDraftTests(SimpleTestCase):

View File

@ -30,21 +30,21 @@ from .row_serialize import (
merged_row_to_dict, merged_row_to_dict,
search_row_to_dict, search_row_to_dict,
) )
from .brief_pack import build_brief_pack_zip_bytes from .ingest import ingest_job_full, resolve_and_validate_run_dir
from .strategy_draft import build_strategy_draft_markdown from .reporting.brief_pack import build_brief_pack_zip_bytes
from .ingest import ingest_job_full from .reporting.strategy_draft import build_strategy_draft_markdown
from .jd_runner import ( from .jd.runner import (
build_competitor_brief_for_job, build_competitor_brief_for_job,
get_default_report_config, get_default_report_config,
merge_llm_supplement_with_rules_report, merge_llm_supplement_with_rules_report,
regenerate_competitor_report, regenerate_competitor_report,
write_competitor_analysis_markdown, write_competitor_analysis_markdown,
) )
from .llm_generate import ( from .llm.generate import (
generate_competitor_report_markdown_llm, generate_competitor_report_markdown_llm,
generate_strategy_draft_markdown_llm, generate_strategy_draft_markdown_llm,
) )
from .md_document_export import markdown_to_docx_bytes, markdown_to_pdf_bytes from .reporting.md_document_export import markdown_to_docx_bytes, markdown_to_pdf_bytes
from .models import ( from .models import (
JdJobCommentRow, JdJobCommentRow,
JdJobDetailRow, JdJobDetailRow,
@ -189,10 +189,27 @@ class JobDetailView(APIView):
job = PipelineJob.objects.filter(pk=pk).first() job = PipelineJob.objects.filter(pk=pk).first()
if not job: if not job:
raise Http404() raise Http404()
ser = JobReportConfigPatchSerializer(data=request.data) body = request.data if isinstance(request.data, dict) else {}
ser.is_valid(raise_exception=True) update_fields: list[str] = []
job.report_config = ser.validated_data["report_config"] if "report_config" in body:
job.save(update_fields=["report_config", "updated_at"]) ser = JobReportConfigPatchSerializer(data={"report_config": body["report_config"]})
ser.is_valid(raise_exception=True)
job.report_config = ser.validated_data["report_config"]
update_fields.append("report_config")
if "run_dir" in body:
try:
job.run_dir = str(
resolve_and_validate_run_dir(str(body.get("run_dir") or ""))
)
except ValueError as e:
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
update_fields.append("run_dir")
if not update_fields:
return Response(
{"detail": "请提供 report_config 或 run_dir用于绑定已有批次目录"},
status=status.HTTP_400_BAD_REQUEST,
)
job.save(update_fields=update_fields + ["updated_at"])
return Response(PipelineJobSerializer(job).data) return Response(PipelineJobSerializer(job).data)
@ -876,7 +893,13 @@ class JdProductListView(APIView):
| Q(detail_brand__icontains=q) | Q(detail_brand__icontains=q)
) )
if kw: if kw:
qs = qs.filter(current_payload__pipeline_keyword=kw) from .csv_schema import MERGED_FIELD_TO_CSV_HEADER
h_kw = MERGED_FIELD_TO_CSV_HEADER["pipeline_keyword"]
qs = qs.filter(
Q(current_payload__pipeline_keyword=kw)
| Q(**{f"current_payload__{h_kw}": kw})
)
total = qs.count() total = qs.count()
page = qs.order_by("-updated_at")[offset : offset + limit] page = qs.order_by("-updated_at")[offset : offset + limit]
return Response( return Response(
@ -947,9 +970,17 @@ class JobImportMergedView(APIView):
job = PipelineJob.objects.filter(pk=pk).first() job = PipelineJob.objects.filter(pk=pk).first()
if not job: if not job:
raise Http404() raise Http404()
if job.status != JobStatus.SUCCESS or not (job.run_dir or "").strip(): if job.status == JobStatus.RUNNING:
return Response( return Response(
{"detail": "仅可对已成功且含 run_dir 的任务执行入库"}, {"detail": "执行中不可入库,请待任务结束或终止后再试"},
status=status.HTTP_400_BAD_REQUEST,
)
if not (job.run_dir or "").strip():
return Response(
{
"detail": "任务未绑定 run_dir。可 PATCH /api/pipeline/jobs/<id>/ "
"传入 run_dir或使用 python manage.py ingest_pipeline_dataset。"
},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
try: try: