analyze_centrum_douyin_live.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575
  1. import json
  2. import math
  3. import re
  4. from collections import Counter, defaultdict
  5. from datetime import datetime, timezone
  6. from pathlib import Path
  7. OUTPUT_DIR = Path("outputs/centrum_douyin_audit_2026-06-08/live-full")
  8. INPUT_FILES = [
  9. OUTPUT_DIR / "live-raw-dataset.json",
  10. OUTPUT_DIR / "live-raw-dataset-batch2.json",
  11. ]
  12. RULE_TEXT = Path("outputs/centrum_douyin_audit_2026-06-08/rule_pack_extracted.txt")
  13. def clean(value):
  14. return re.sub(r"\s+", " ", str(value or "")).strip()
  15. def as_int(value):
  16. try:
  17. number = int(float(value or 0))
  18. except Exception:
  19. number = 0
  20. return number
  21. def load_json(path):
  22. return json.loads(path.read_text(encoding="utf-8"))
  23. def video_score(video):
  24. return (
  25. as_int(video.get("likeCount"))
  26. + as_int(video.get("commentCount")) * 3
  27. + as_int(video.get("shareCount")) * 2
  28. + as_int(video.get("playCount")) * 0.02
  29. )
  30. def video_key(video):
  31. return clean(video.get("id") or video.get("videoId") or video.get("url"))
  32. def comment_key(comment):
  33. return clean(comment.get("id") or comment.get("cid") or f"{comment.get('videoId')}:{comment.get('text')}")
  34. def merge_datasets():
  35. videos = {}
  36. comments = {}
  37. warnings = []
  38. errors = []
  39. effective_keywords = []
  40. requested_keywords = []
  41. for path in INPUT_FILES:
  42. if not path.exists():
  43. continue
  44. data = load_json(path)
  45. requested_keywords.extend(data.get("profile", {}).get("keywords", []))
  46. effective_keywords.extend(data.get("effectiveKeywords", []))
  47. warnings.extend(data.get("warnings", []))
  48. errors.extend(data.get("errors", []))
  49. for video in data.get("videos", []):
  50. key = video_key(video)
  51. if not key:
  52. continue
  53. if key not in videos or video_score(video) > video_score(videos[key]):
  54. videos[key] = video
  55. for comment in data.get("comments", []):
  56. key = comment_key(comment)
  57. if key and key not in comments:
  58. comments[key] = comment
  59. return {
  60. "videos": list(videos.values()),
  61. "comments": list(comments.values()),
  62. "warnings": warnings,
  63. "errors": errors,
  64. "requestedKeywords": sorted(set(map(clean, requested_keywords)) - {""}),
  65. "effectiveKeywords": sorted(set(map(clean, effective_keywords)) - {""}),
  66. }
  67. SKU_RULES = [
  68. ("善存PRO/每日营养包", r"PRO营养包|每日营养包|营养包|代谢包|状态全开|气血满格|新生焕活"),
  69. ("银善存/脑力瓶", r"银善存|脑力瓶|大脑维生素|脑力|大脑"),
  70. ("善存复合维生素/多维", r"复合维生素|多维|维生素|多维元素|26种营养"),
  71. ("善存男士/女士/50+", r"男士|女士|女性|男性|50岁|50\+|中老年|老人|Costco|美国"),
  72. ("善存小佳维/儿童", r"小佳维|儿童|孩子|宝宝|长高"),
  73. ("善存孕妇复合维生素", r"孕妇|孕期|备孕|叶酸|胎儿"),
  74. ("善存海外/跨境版本", r"海外|跨境|澳洲|美国版|澳版|天猫国际|京东国际|包邮"),
  75. ]
  76. RULE_PATTERNS = {
  77. "RP01": ("绝对化/保证性/最高级", r"全网第一|第一|唯一|100%|保证|最[好强]|立刻|马上|闭眼入|爆火"),
  78. "RP02": ("疾病治疗/治愈/诊断暗示", r"甲流|感冒|肿瘤|医生|治疗|治好|预防|缓解|疼|病|症|免疫"),
  79. "RP03": ("未经确认功效/健康利益宣称", r"精力|脑力|大脑|气血|代谢|瘦|吃瘦|状态|焕活|免疫力|长高|睡眠|抗疲劳"),
  80. "RP04": ("数字/实验/前后对比造成明确功效承诺", r"\d+\s*(天|周|月|%|倍|种|粒|岁)|提升|改善|对比|实测|检测"),
  81. "RP05": ("产品法规属性误用/三品一械边界错误", r"海外|跨境|保健|蓝帽|普通食品|美国|澳洲|Costco|天猫国际|京东国际"),
  82. "RP06": ("包装/标签/备案/说明书不一致", r"正品|真假|喷码|批号|效期|保质期|版本|包装|标签"),
  83. "RP07": ("用量/用法/禁忌/适用人群误导", r"每天|一粒|几粒|怎么吃|吃法|空腹|饭后|孕妇|儿童|孩子|老人|全家|适合"),
  84. "RP11": ("竞品贬损/虚假比较/不公平竞争", r"红黑榜|黑榜|智商税|别买|不如|对比|哪家|排名|踩坑"),
  85. "RP13": ("表达强度过高但可弱化", r"满格|全开|必看|不行|不能少|值得拥有|还不快|妙招"),
  86. "RP14": ("证据引用不足或上下文不清", r"营养师|专家|医生|高管|实测|研究|标准|干货|教你"),
  87. "RP16": ("平台敏感词/卡审词疑似命中", r"甲流|吃瘦|瘦|气血|脑力|大脑|病|医生|减肥"),
  88. "RP18": ("促销/价格/库存/券后信息不清", r"活动|包邮|直播|京东|天猫|券|价格|优惠|买|下单"),
  89. "RP19": ("达人/KOL/KOC/专家身份表达边界", r"营养师|专家|医生|高管|主持人|明星|教你"),
  90. "RP21": ("CTA/购买引导/场景导向不清", r"买|下单|链接|购物车|看看|拥有|囤|包邮|直播"),
  91. "RP22": ("灰区创意/趋势模仿/情绪钩子", r"必看|改命|焦虑|竞争力|长续婚姻|身材管理|小妙招|闭眼入"),
  92. }
  93. COMMENT_THEMES = [
  94. ("低质互动/刷金币", r"金币|坚持互动|多互动|串门|涨粉|许愿|抖音大大|满减券|6000|7000|5000|3000|稳定得|互关|看广告|下图回复"),
  95. ("购买路径/价格", r"怎么买|哪里买|链接|购物车|多少钱|价格|贵|便宜|活动|券|京东|天猫|直播|下单|店铺"),
  96. ("正品/渠道/版本信任", r"正品|真假|喷码|批号|效期|保质期|过期|版本|美国版|澳洲|海外|进口|天猫国际|京东国际"),
  97. ("用法用量/适用人群", r"怎么吃|几粒|一天吃|每天吃|饭前|饭后|空腹|孕妇|儿童|孩子|老人|哺乳|妈妈能吃|适合.*(孕妇|儿童|老人|孩子|中老年)"),
  98. ("功效/健康诉求", r"有用|效果|精力|气血|代谢|免疫|甲流|睡眠|脑力|瘦|长高|补什么|缺"),
  99. ("体验/副作用/剂型", r"反胃|恶心|吞|吞咽|颗粒|大粒|便秘|拉肚子|不舒服|味道|腥"),
  100. ("质疑/比较/性价比", r"智商税|真的|靠谱吗|有必要|不如|对比|区别|性价比|划算|坑|贵"),
  101. ("内容互动/达人明星", r"喜欢|好看|姐姐|明星|秋瓷炫|颖儿|维嘉|百克力|老师|收藏|学到了"),
  102. ]
  103. def classify_sku(text):
  104. hits = [label for label, pattern in SKU_RULES if re.search(pattern, text, flags=re.I)]
  105. return hits or ["善存泛品牌/其他"]
  106. def classify_rules(text):
  107. hits = []
  108. for rule_id, (name, pattern) in RULE_PATTERNS.items():
  109. if re.search(pattern, text, flags=re.I):
  110. hits.append({"ruleId": rule_id, "ruleName": name})
  111. return hits
  112. def classify_comment(text):
  113. if re.search(COMMENT_THEMES[0][1], text, flags=re.I):
  114. return [COMMENT_THEMES[0][0]]
  115. hits = [label for label, pattern in COMMENT_THEMES if re.search(pattern, text, flags=re.I)]
  116. return hits or ["泛互动/情绪反馈"]
  117. def risk_level(rule_ids):
  118. red = {"RP01", "RP02", "RP03", "RP04", "RP05", "RP06", "RP07", "RP11"}
  119. if len(red.intersection(rule_ids)) >= 3:
  120. return "高"
  121. if red.intersection(rule_ids):
  122. return "中高"
  123. if rule_ids:
  124. return "中"
  125. return "低"
  126. def truncate(text, length=120):
  127. text = clean(text)
  128. return text if len(text) <= length else text[: length - 1] + "…"
  129. def comment_rows(comments, videos_by_id):
  130. rows = []
  131. for comment in comments:
  132. text = clean(comment.get("text"))
  133. if not text:
  134. continue
  135. video = videos_by_id.get(clean(comment.get("videoId")), {})
  136. themes = classify_comment(text)
  137. rows.append({
  138. "commentId": comment.get("id") or comment.get("cid") or "",
  139. "videoId": comment.get("videoId") or "",
  140. "videoTitle": video.get("title") or "",
  141. "videoAuthor": video.get("author") or "",
  142. "keyword": comment.get("keyword") or video.get("keyword") or "",
  143. "themes": themes,
  144. "themeText": "、".join(themes),
  145. "text": text,
  146. "likeCount": as_int(comment.get("likeCount")),
  147. "replyCount": as_int(comment.get("replyCommentTotal")),
  148. "ipLocation": comment.get("ipLocation") or "",
  149. })
  150. rows.sort(key=lambda row: (row["likeCount"], len(row["text"])), reverse=True)
  151. return rows
  152. def analyze_videos(videos, comments_by_video):
  153. rows = []
  154. for video in videos:
  155. text = clean(" ".join([
  156. video.get("title"),
  157. video.get("desc"),
  158. video.get("content"),
  159. " ".join(video.get("tags") or []),
  160. ]))
  161. sku_hits = classify_sku(text)
  162. rule_hits = classify_rules(text)
  163. rule_ids = [item["ruleId"] for item in rule_hits]
  164. comments = comments_by_video.get(clean(video.get("id")), [])
  165. top_comment = sorted(comments, key=lambda c: as_int(c.get("likeCount")), reverse=True)[:1]
  166. rows.append({
  167. "videoId": video.get("id") or "",
  168. "keyword": video.get("keyword") or "",
  169. "skuGroup": "、".join(sku_hits),
  170. "author": video.get("author") or "",
  171. "title": clean(video.get("title") or video.get("desc")),
  172. "publishTime": video.get("timestamp") or "",
  173. "likeCount": as_int(video.get("likeCount")),
  174. "commentCount": as_int(video.get("commentCount")),
  175. "shareCount": as_int(video.get("shareCount")),
  176. "collectCount": as_int(video.get("collectCount")),
  177. "playCount": as_int(video.get("playCount")),
  178. "score": round(video_score(video), 2),
  179. "url": video.get("url") or video.get("sourceUrl") or video.get("fallbackUrl") or "",
  180. "coverUrl": video.get("coverUrl") or "",
  181. "tags": "、".join(video.get("tags") or []),
  182. "ruleHits": "、".join(rule_ids),
  183. "ruleNames": "、".join([item["ruleName"] for item in rule_hits]),
  184. "riskLevel": risk_level(set(rule_ids)),
  185. "commentCaptured": len(comments),
  186. "topComment": top_comment[0].get("text") if top_comment else "",
  187. "topCommentLike": as_int(top_comment[0].get("likeCount")) if top_comment else 0,
  188. })
  189. rows.sort(key=lambda row: row["score"], reverse=True)
  190. return rows
  191. def build_theme_summary(comment_data):
  192. buckets = defaultdict(list)
  193. for row in comment_data:
  194. for theme in row["themes"]:
  195. buckets[theme].append(row)
  196. summaries = []
  197. for theme, rows in buckets.items():
  198. sorted_rows = sorted(rows, key=lambda r: r["likeCount"], reverse=True)
  199. examples = [r["text"] for r in sorted_rows[:5]]
  200. summaries.append({
  201. "theme": theme,
  202. "commentCount": len(rows),
  203. "likedCommentCount": sum(1 for row in rows if row["likeCount"] > 0),
  204. "totalLikes": sum(row["likeCount"] for row in rows),
  205. "sampleComments": examples,
  206. "opportunity": comment_opportunity(theme),
  207. })
  208. summaries.sort(key=lambda row: (row["commentCount"], row["totalLikes"]), reverse=True)
  209. return summaries
  210. def comment_opportunity(theme):
  211. mapping = {
  212. "购买路径/价格": "置顶评论和商品卡要明确官方购买路径、活动口径和客服入口,避免用户在评论区反复问链接/价格。",
  213. "正品/渠道/版本信任": "补充正品辨别、版本差异、喷码/批号/效期说明,跨境 SKU 尤其要把包装版本说清楚。",
  214. "用法用量/适用人群": "所有用法、用量、特殊人群问题都回到标签/说明书,并引导咨询专业人士。",
  215. "功效/健康诉求": "用户会把内容理解成功效承诺,后续稿件应减少结果承诺,改为日常营养支持和生活方式组合。",
  216. "体验/副作用/剂型": "建立颗粒大小、吞咽、服用时间、胃部不适等体验 FAQ,但不要自行给医疗建议。",
  217. "质疑/比较/性价比": "用成分、剂型、预算、版本和适用场景做中性比较,避免红黑榜和竞品贬损。",
  218. "内容互动/达人明星": "高互动来自达人/情绪内容时,要用评论区和商品卡补足 SKU 承接,否则曝光不等于转化。",
  219. "泛互动/情绪反馈": "保留情绪互动价值,同时在高赞评论下补充产品边界与购买答疑。",
  220. }
  221. return mapping.get(theme, "需要人工复核评论上下文后再定动作。")
  222. def build_goldens():
  223. return [
  224. {
  225. "id": "GD-LIVE-01",
  226. "riskPrototype": "左右脑互博、40+精力满格、脑力/精力状态承诺",
  227. "ruleHits": "RP02、RP03、RP13、RP16",
  228. "beforeExample": "梗王蓄力局,40+精力满格 #善存PRO营养包",
  229. "afterExample": "40+忙碌阶段更要关注饮食、作息和日常营养补充;产品请按标签建议选择。",
  230. "why": "保留年龄场景和生活状态,但不承诺精力/脑力改善。",
  231. "sourceEvidence": "李维嘉/善存PRO营养包 live 样本",
  232. },
  233. {
  234. "id": "GD-LIVE-02",
  235. "riskPrototype": "吃瘦不饿瘦、身材管理小妙招、代谢包",
  236. "ruleHits": "RP03、RP04、RP13、RP16",
  237. "beforeExample": "开工前我的身材管理小妙招来啦 #善存代谢包 #状态全开",
  238. "afterExample": "开工阶段也别忽略均衡饮食和规律运动;如需补充维生素矿物质,请按产品标签建议。",
  239. "why": "删除减重/代谢结果暗示,把内容改为生活方式+日常补充。",
  240. "sourceEvidence": "颖儿/善存代谢包 live 样本",
  241. },
  242. {
  243. "id": "GD-LIVE-03",
  244. "riskPrototype": "气血满格、新生焕活,就这一包",
  245. "ruleHits": "RP03、RP13、RP16",
  246. "beforeExample": "气血满格,就这一包;新生焕活,就这一包。",
  247. "afterExample": "忙碌和年龄阶段可以关注日常营养管理,按标签建议补充相关营养成分。",
  248. "why": "弱化强感受和单包解决感,减少平台卡审和功效风险。",
  249. "sourceEvidence": "善存海外旗舰店/PRO营养包 live 样本",
  250. },
  251. {
  252. "id": "GD-LIVE-04",
  253. "riskPrototype": "甲流有哪些维生素可以吃",
  254. "ruleHits": "RP02、RP03、RP16",
  255. "beforeExample": "甲流有哪些维生素可以吃?一粒补充26种营养。",
  256. "afterExample": "特殊健康时期更要注意均衡饮食和休息;本品为日常营养补充,不替代药物或治疗。",
  257. "why": "删除疾病场景和营养品解决病症的关联。",
  258. "sourceEvidence": "善存复合维生素 live 样本",
  259. },
  260. {
  261. "id": "GD-LIVE-05",
  262. "riskPrototype": "一粒就可以补充26人体所需营养/一粒补全",
  263. "ruleHits": "RP01、RP03、RP04、RP07",
  264. "beforeExample": "一粒就可以补充26人体所需营养。",
  265. "afterExample": "含多种维生素和矿物质,适合按产品标签建议作为日常营养补充。",
  266. "why": "保留成分数量,但不推导全部所需或一粒补全。",
  267. "sourceEvidence": "善存复合维生素 live 样本",
  268. },
  269. {
  270. "id": "GD-LIVE-06",
  271. "riskPrototype": "国外国内爆火、海外好物闭眼入",
  272. "ruleHits": "RP01、RP05、RP13、RP18",
  273. "beforeExample": "国外国内爆火的善存女性维生素,海外好物闭眼入。",
  274. "afterExample": "不同渠道/版本的包装和说明可能不同,请以当前购买页面、包装标签和官方客服说明为准。",
  275. "why": "把热销背书和跨境信任改为版本与渠道说明。",
  276. "sourceEvidence": "善存海外/女士维生素 live 样本",
  277. },
  278. {
  279. "id": "GD-LIVE-07",
  280. "riskPrototype": "成人儿童营养好物、不花冤枉钱的泛比较",
  281. "ruleHits": "RP07、RP11、RP14",
  282. "beforeExample": "成人&儿童营养好物,看了不花冤枉钱。",
  283. "afterExample": "成人和儿童适用产品不同,建议按年龄、标签说明和自身需求选择。",
  284. "why": "避免成人儿童泛化和价值判断过强。",
  285. "sourceEvidence": "营养师/山姆营养品区 live 样本",
  286. },
  287. {
  288. "id": "GD-LIVE-08",
  289. "riskPrototype": "正品/喷码/效期/版本问题散落在评论区",
  290. "ruleHits": "RP05、RP06、RP18、RP21",
  291. "beforeExample": "评论区反复追问真假、效期、喷码、美国版/澳洲版区别。",
  292. "afterExample": "置顶评论:购买请认准官方授权渠道;不同版本包装/成分/适用说明请以购买页和实物标签为准,效期/喷码可咨询客服。",
  293. "why": "将售前信任问题前置,降低投诉和客服压力。",
  294. "sourceEvidence": "多条善存海外/男士/女士/50+评论",
  295. },
  296. {
  297. "id": "GD-LIVE-09",
  298. "riskPrototype": "明星都在吃/达人高管背书",
  299. "ruleHits": "RP14、RP19、RP22",
  300. "beforeExample": "秋瓷炫、王艳、百克力都在吃的营养包。",
  301. "afterExample": "达人分享仅代表个人体验;产品信息以包装、标签和官方说明为准。",
  302. "why": "保留达人种草,但不把身份/使用行为变成效果背书。",
  303. "sourceEvidence": "善存海外旗舰店/营养包 live 样本",
  304. },
  305. {
  306. "id": "GD-LIVE-10",
  307. "riskPrototype": "50岁以上善存怎么选、男士/女士/海外版混讲",
  308. "ruleHits": "RP05、RP06、RP07、RP14",
  309. "beforeExample": "美国 Costco 三款 50 岁以上善存如何选择。",
  310. "afterExample": "不同年龄和版本适用说明不同,请按具体 SKU、包装标签和购买渠道说明选择。",
  311. "why": "SKU/版本边界清楚,避免跨版本套用。",
  312. "sourceEvidence": "50+版本选择 live 样本",
  313. },
  314. ]
  315. def build_script_breakdowns(top_videos):
  316. rows = []
  317. for video in top_videos[:30]:
  318. title = video["title"]
  319. rules = set(filter(None, video["ruleHits"].split("、")))
  320. if re.search(r"参鸡汤|餐厅|身材管理|开工|婚姻|阅读|高管|梗王", title):
  321. hook = "生活方式/达人故事钩子"
  322. structure = "先用生活或人物情境吸引停留,再用标签、话题或轻口播承接善存 SKU。"
  323. fix = "保留故事,但结尾补 SKU 边界:日常营养补充、按标签建议、非治疗。"
  324. elif re.search(r"甲流|维生素可以吃|医生|营养师", title):
  325. hook = "专家/健康问题钩子"
  326. structure = "用健康问题或专业身份制造可信度。"
  327. fix = "删疾病解决路径,改成均衡饮食与标签范围;专家身份不替代证据。"
  328. elif re.search(r"正品|版本|Costco|怎么选|选择", title):
  329. hook = "选购/信任答疑钩子"
  330. structure = "围绕版本、渠道、真假、效期等购买前疑问展开。"
  331. fix = "适合做置顶 FAQ 和客服承接,减少评论区重复追问。"
  332. else:
  333. hook = "商品卖点/活动钩子"
  334. structure = "用成分数量、活动、包邮或热门标签推动转化。"
  335. fix = "保留成分和促销信息,但避免一粒补全、保证效果、闭眼入。"
  336. rows.append({
  337. "videoId": video["videoId"],
  338. "title": title,
  339. "author": video["author"],
  340. "skuGroup": video["skuGroup"],
  341. "hookType": hook,
  342. "currentStructure": structure,
  343. "riskRules": "、".join(sorted(rules)),
  344. "reusableFix": fix,
  345. })
  346. return rows
  347. def build_summary(video_rows, comment_data, theme_summary, merged):
  348. sku_counts = Counter()
  349. for row in video_rows:
  350. for sku in row["skuGroup"].split("、"):
  351. sku_counts[sku] += 1
  352. risk_counts = Counter(row["riskLevel"] for row in video_rows)
  353. author_counts = Counter(row["author"] for row in video_rows if row["author"])
  354. keyword_counts = Counter(row["keyword"] for row in video_rows if row["keyword"])
  355. return {
  356. "generatedAt": datetime.now(timezone.utc).astimezone().isoformat(),
  357. "requestedKeywordCount": len(merged["requestedKeywords"]),
  358. "effectiveKeywordCount": len(merged["effectiveKeywords"]),
  359. "videoCount": len(video_rows),
  360. "commentCount": len(comment_data),
  361. "actionableCommentCount": sum(1 for row in comment_data if "低质互动/刷金币" not in row["themes"]),
  362. "lowQualityCommentCount": sum(1 for row in comment_data if "低质互动/刷金币" in row["themes"]),
  363. "warningCount": len(merged["warnings"]),
  364. "errorCount": len(merged["errors"]),
  365. "skuCounts": [{"skuGroup": k, "videoCount": v} for k, v in sku_counts.most_common()],
  366. "riskCounts": [{"riskLevel": k, "videoCount": v} for k, v in risk_counts.most_common()],
  367. "topAuthors": [{"author": k, "videoCount": v} for k, v in author_counts.most_common(15)],
  368. "topKeywords": [{"keyword": k, "videoCount": v} for k, v in keyword_counts.most_common(20)],
  369. "topCommentThemes": theme_summary[:10],
  370. }
  371. def build_markdown(data):
  372. summary = data["summary"]
  373. top_videos = data["videoRows"][:18]
  374. top_themes = data["themeSummary"][:8]
  375. goldens = data["goldenRows"]
  376. script_rows = data["scriptBreakdowns"][:12]
  377. lines = []
  378. lines.append("# 善存抖音稿件审核润色与评论 VOC Live 版报告")
  379. lines.append("")
  380. lines.append(f"生成时间:{summary['generatedAt']}")
  381. lines.append("")
  382. lines.append("## 样本覆盖")
  383. lines.append("")
  384. lines.append(f"- live 去重视频样本:{summary['videoCount']} 条")
  385. lines.append(f"- live 去重评论样本:{summary['commentCount']} 条")
  386. lines.append(f"- 可行动 VOC 评论:{summary['actionableCommentCount']} 条;低质互动/刷金币类评论:{summary['lowQualityCommentCount']} 条")
  387. lines.append(f"- 请求关键词:{summary['requestedKeywordCount']} 个;有效返回关键词:{summary['effectiveKeywordCount']} 个")
  388. lines.append(f"- 接口警告/错误:{summary['warningCount']} / {summary['errorCount']},主要为部分关键词或单条评论页 fetch failed;已保留成功样本。")
  389. lines.append("- 规则资料:QW1 Rule Pack Examples V0.1,已提取 28 个 Rule Pack,用于规则映射和黄金改写。")
  390. lines.append("")
  391. lines.append("## 方法说明与限制")
  392. lines.append("")
  393. lines.append("- 采集口径:使用 `claude-code-voc-intelligence` 的抖音采集能力,围绕善存、善存营养包、复合维生素、30+/50+、男士/女士、海外版/跨境版等关键词抓取视频与评论,并对跨批次视频、评论做去重。")
  394. lines.append("- 脚本拆解口径:使用 `douyin-speaking-daily` 的稿件拆解能力,对标题、标签、描述、评论和可获取文本形成 transcript proxy 与脚本结构分析;未做完整视频 ASR 的样本,结论以可见文案和评论为准。")
  395. lines.append("- 审核差异限制:本轮未取得品牌审核前原稿、驳回原因、修改版本流水和最终通过稿的一一对应关系,因此“做了哪些润色和修改”是基于已发布稿件、Rule Pack 命中风险和可复用通过话术的反推,不等同于逐条原稿差异审计。")
  396. lines.append("- 证据强度:78 条视频和 471 条评论足以支撑本轮方向性判断;涉及合规结论时仍建议由品牌法务/平台审核团队结合原始投放链路复核。")
  397. lines.append("")
  398. lines.append("## 一句话结论")
  399. lines.append("")
  400. lines.append("善存抖音已投内容的主流过审路径不是直接讲“治疗/功效”,而是把产品包进明星生活方式、职场状态、选购答疑、跨境版本说明和日常营养补充场景中;但 live 样本里仍大量出现“精力满格、气血满格、代谢/吃瘦、甲流、脑力/大脑、闭眼入、一粒补充所需”等高风险表达。评论区最值得改进的是:正品与版本信任、用法用量/适用人群、购买路径/价格、功效追问和剂型体验。")
  401. lines.append("")
  402. lines.append("## 已发布稿件的润色/修改路径")
  403. lines.append("")
  404. lines.append("1. 明星生活方式化:秋瓷炫参鸡汤面、颖儿开工身材管理、李维嘉 40+梗王等内容,把产品从硬功效改为生活情境和标签承接。")
  405. lines.append("2. 年龄/状态场景化:PRO 营养包常用 30+/40+、精力、气血、状态等表达,过审上比治疗承诺温和,但仍需进一步弱化为日常营养管理。")
  406. lines.append("3. 店铺商品话术成分化:官方/旗舰店内容常用“26种营养、一粒、男女复合维生素、海外版”等成分和版本话术,需避免推导“一粒补全/疾病时期该吃”。")
  407. lines.append("4. 跨境 SKU 信任化:海外/美国/澳洲/Costco/天猫国际相关内容更适合做版本、包装、渠道和效期说明,而不是用海外身份做安全背书。")
  408. lines.append("5. 评论承接客服化:正品、喷码、效期、怎么吃、适合谁、哪里买等评论应前置成置顶 FAQ 和客服关键词库。")
  409. lines.append("")
  410. lines.append("## 黄金改写数据集")
  411. lines.append("")
  412. lines.append("| ID | 风险原型 | 命中规则 | 风险表达 | 建议通过版 | 依据 |")
  413. lines.append("|---|---|---|---|---|---|")
  414. for row in goldens:
  415. lines.append(f"| {row['id']} | {row['riskPrototype']} | {row['ruleHits']} | {row['beforeExample']} | {row['afterExample']} | {row['why']} |")
  416. lines.append("")
  417. lines.append("## 评论 VOC 主题与改进空间")
  418. lines.append("")
  419. lines.append("| 主题 | 评论数 | 点赞评论数 | 高赞/典型评论摘录 | 改进动作 |")
  420. lines.append("|---|---:|---:|---|---|")
  421. for row in top_themes:
  422. examples = "<br>".join(truncate(text, 60) for text in row["sampleComments"][:3])
  423. lines.append(f"| {row['theme']} | {row['commentCount']} | {row['likedCommentCount']} | {examples} | {row['opportunity']} |")
  424. lines.append("")
  425. lines.append("## 样本案例 Top")
  426. lines.append("")
  427. for index, row in enumerate(top_videos, start=1):
  428. lines.append(f"### {index}. {row['title'] or row['videoId']}")
  429. lines.append(f"- 作者:{row['author']}|关键词:{row['keyword']}|SKU:{row['skuGroup']}")
  430. lines.append(f"- 互动:{row['likeCount']}赞 / {row['commentCount']}评 / {row['shareCount']}转 / {row['collectCount']}藏")
  431. lines.append(f"- 规则命中:{row['ruleHits'] or '未命中主要规则'}|风险等级:{row['riskLevel']}")
  432. lines.append(f"- 链接:{row['url']}")
  433. if row["topComment"]:
  434. lines.append(f"- 高赞评论:{truncate(row['topComment'], 90)}({row['topCommentLike']}赞)")
  435. lines.append("")
  436. lines.append("## 逐字稿/脚本结构拆解")
  437. lines.append("")
  438. lines.append("本轮未批量下载视频做 ASR,逐字稿拆解使用 live 采集到的视频标题、描述、标签和评论作为脚本代理文本,并用本地 douyin-viral-script-analyzer 跑通结构拆解。正式复盘审核前后稿时,可把原始口播 ASR 替换进同一输入结构。")
  439. lines.append("")
  440. lines.append("| 样本 | 钩子类型 | 当前结构 | 风险规则 | 可复用改法 |")
  441. lines.append("|---|---|---|---|---|")
  442. for row in script_rows:
  443. lines.append(f"| {truncate(row['title'], 36)} | {row['hookType']} | {row['currentStructure']} | {row['riskRules']} | {row['reusableFix']} |")
  444. lines.append("")
  445. lines.append("## 下一步建议")
  446. lines.append("")
  447. lines.append("1. 把品牌审核前原稿、驳回原因和通过稿补入 `content_id/version_id/audit_id`,即可把本报告从“已发布稿件推断”升级为真正的审核前后差异复盘。")
  448. lines.append("2. 对评论数最高的官方/达人稿件追加 ASR,尤其是秋瓷炫、颖儿、李维嘉、善存海外旗舰店和 50+版本选择内容。")
  449. lines.append("3. 建立置顶评论模板:正品/版本/效期、用法用量、特殊人群、非治疗声明、购买路径。")
  450. return "\n".join(lines)
  451. def main():
  452. OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
  453. merged = merge_datasets()
  454. comments_by_video = defaultdict(list)
  455. for comment in merged["comments"]:
  456. comments_by_video[clean(comment.get("videoId"))].append(comment)
  457. videos_by_id = {clean(video.get("id")): video for video in merged["videos"]}
  458. video_rows = analyze_videos(merged["videos"], comments_by_video)
  459. comment_data = comment_rows(merged["comments"], videos_by_id)
  460. theme_summary = build_theme_summary(comment_data)
  461. summary = build_summary(video_rows, comment_data, theme_summary, merged)
  462. script_breakdowns = build_script_breakdowns(video_rows)
  463. data = {
  464. "summary": summary,
  465. "requestedKeywords": merged["requestedKeywords"],
  466. "effectiveKeywords": merged["effectiveKeywords"],
  467. "videoRows": video_rows,
  468. "commentRows": comment_data,
  469. "themeSummary": theme_summary,
  470. "goldenRows": build_goldens(),
  471. "scriptBreakdowns": script_breakdowns,
  472. "warnings": merged["warnings"],
  473. "errors": merged["errors"],
  474. "rulePackSource": str(RULE_TEXT),
  475. }
  476. (OUTPUT_DIR / "live-analysis-data.json").write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
  477. (OUTPUT_DIR / "善存抖音稿件审核润色与评论VOC-live.md").write_text(build_markdown(data), encoding="utf-8")
  478. analyzer_input = {
  479. "metadata": {"project": "善存抖音稿件审核润色与评论VOC-live", "generatedAt": summary["generatedAt"]},
  480. "videos": [
  481. {
  482. "aweme_id": row["videoId"],
  483. "keyword": row["keyword"],
  484. "title": row["title"],
  485. "author": {"nickname": row["author"]},
  486. "likeCount": row["likeCount"],
  487. "commentCount": row["commentCount"],
  488. "shareCount": row["shareCount"],
  489. "playCount": row["playCount"],
  490. "url": row["url"],
  491. }
  492. for row in video_rows
  493. ],
  494. "comments": [
  495. {
  496. "id": row["commentId"],
  497. "aweme_id": row["videoId"],
  498. "text": row["text"],
  499. "likeCount": row["likeCount"],
  500. }
  501. for row in comment_data
  502. ],
  503. "transcripts": [
  504. {
  505. "awemeId": row["videoId"],
  506. "provider": "live-title-desc-proxy",
  507. "text": "。".join(filter(None, [row["title"], row["topComment"]])),
  508. "segments": [],
  509. }
  510. for row in video_rows
  511. ],
  512. }
  513. (OUTPUT_DIR / "douyin_analyzer_input_live.json").write_text(json.dumps(analyzer_input, ensure_ascii=False, indent=2), encoding="utf-8")
  514. print(json.dumps({
  515. "status": "ok",
  516. "videoCount": summary["videoCount"],
  517. "commentCount": summary["commentCount"],
  518. "effectiveKeywordCount": summary["effectiveKeywordCount"],
  519. "output": str(OUTPUT_DIR / "live-analysis-data.json"),
  520. "markdown": str(OUTPUT_DIR / "善存抖音稿件审核润色与评论VOC-live.md"),
  521. }, ensure_ascii=False, indent=2))
  522. if __name__ == "__main__":
  523. main()