import json import math import re from collections import Counter, defaultdict from datetime import datetime, timezone from pathlib import Path OUTPUT_DIR = Path("outputs/centrum_douyin_audit_2026-06-08/live-full") INPUT_FILES = [ OUTPUT_DIR / "live-raw-dataset.json", OUTPUT_DIR / "live-raw-dataset-batch2.json", ] RULE_TEXT = Path("outputs/centrum_douyin_audit_2026-06-08/rule_pack_extracted.txt") def clean(value): return re.sub(r"\s+", " ", str(value or "")).strip() def as_int(value): try: number = int(float(value or 0)) except Exception: number = 0 return number def load_json(path): return json.loads(path.read_text(encoding="utf-8")) def video_score(video): return ( as_int(video.get("likeCount")) + as_int(video.get("commentCount")) * 3 + as_int(video.get("shareCount")) * 2 + as_int(video.get("playCount")) * 0.02 ) def video_key(video): return clean(video.get("id") or video.get("videoId") or video.get("url")) def comment_key(comment): return clean(comment.get("id") or comment.get("cid") or f"{comment.get('videoId')}:{comment.get('text')}") def merge_datasets(): videos = {} comments = {} warnings = [] errors = [] effective_keywords = [] requested_keywords = [] for path in INPUT_FILES: if not path.exists(): continue data = load_json(path) requested_keywords.extend(data.get("profile", {}).get("keywords", [])) effective_keywords.extend(data.get("effectiveKeywords", [])) warnings.extend(data.get("warnings", [])) errors.extend(data.get("errors", [])) for video in data.get("videos", []): key = video_key(video) if not key: continue if key not in videos or video_score(video) > video_score(videos[key]): videos[key] = video for comment in data.get("comments", []): key = comment_key(comment) if key and key not in comments: comments[key] = comment return { "videos": list(videos.values()), "comments": list(comments.values()), "warnings": warnings, "errors": errors, "requestedKeywords": sorted(set(map(clean, requested_keywords)) - {""}), "effectiveKeywords": sorted(set(map(clean, effective_keywords)) - {""}), } SKU_RULES = [ ("善存PRO/每日营养包", r"PRO营养包|每日营养包|营养包|代谢包|状态全开|气血满格|新生焕活"), ("银善存/脑力瓶", r"银善存|脑力瓶|大脑维生素|脑力|大脑"), ("善存复合维生素/多维", r"复合维生素|多维|维生素|多维元素|26种营养"), ("善存男士/女士/50+", r"男士|女士|女性|男性|50岁|50\+|中老年|老人|Costco|美国"), ("善存小佳维/儿童", r"小佳维|儿童|孩子|宝宝|长高"), ("善存孕妇复合维生素", r"孕妇|孕期|备孕|叶酸|胎儿"), ("善存海外/跨境版本", r"海外|跨境|澳洲|美国版|澳版|天猫国际|京东国际|包邮"), ] RULE_PATTERNS = { "RP01": ("绝对化/保证性/最高级", r"全网第一|第一|唯一|100%|保证|最[好强]|立刻|马上|闭眼入|爆火"), "RP02": ("疾病治疗/治愈/诊断暗示", r"甲流|感冒|肿瘤|医生|治疗|治好|预防|缓解|疼|病|症|免疫"), "RP03": ("未经确认功效/健康利益宣称", r"精力|脑力|大脑|气血|代谢|瘦|吃瘦|状态|焕活|免疫力|长高|睡眠|抗疲劳"), "RP04": ("数字/实验/前后对比造成明确功效承诺", r"\d+\s*(天|周|月|%|倍|种|粒|岁)|提升|改善|对比|实测|检测"), "RP05": ("产品法规属性误用/三品一械边界错误", r"海外|跨境|保健|蓝帽|普通食品|美国|澳洲|Costco|天猫国际|京东国际"), "RP06": ("包装/标签/备案/说明书不一致", r"正品|真假|喷码|批号|效期|保质期|版本|包装|标签"), "RP07": ("用量/用法/禁忌/适用人群误导", r"每天|一粒|几粒|怎么吃|吃法|空腹|饭后|孕妇|儿童|孩子|老人|全家|适合"), "RP11": ("竞品贬损/虚假比较/不公平竞争", r"红黑榜|黑榜|智商税|别买|不如|对比|哪家|排名|踩坑"), "RP13": ("表达强度过高但可弱化", r"满格|全开|必看|不行|不能少|值得拥有|还不快|妙招"), "RP14": ("证据引用不足或上下文不清", r"营养师|专家|医生|高管|实测|研究|标准|干货|教你"), "RP16": ("平台敏感词/卡审词疑似命中", r"甲流|吃瘦|瘦|气血|脑力|大脑|病|医生|减肥"), "RP18": ("促销/价格/库存/券后信息不清", r"活动|包邮|直播|京东|天猫|券|价格|优惠|买|下单"), "RP19": ("达人/KOL/KOC/专家身份表达边界", r"营养师|专家|医生|高管|主持人|明星|教你"), "RP21": ("CTA/购买引导/场景导向不清", r"买|下单|链接|购物车|看看|拥有|囤|包邮|直播"), "RP22": ("灰区创意/趋势模仿/情绪钩子", r"必看|改命|焦虑|竞争力|长续婚姻|身材管理|小妙招|闭眼入"), } COMMENT_THEMES = [ ("低质互动/刷金币", r"金币|坚持互动|多互动|串门|涨粉|许愿|抖音大大|满减券|6000|7000|5000|3000|稳定得|互关|看广告|下图回复"), ("购买路径/价格", r"怎么买|哪里买|链接|购物车|多少钱|价格|贵|便宜|活动|券|京东|天猫|直播|下单|店铺"), ("正品/渠道/版本信任", r"正品|真假|喷码|批号|效期|保质期|过期|版本|美国版|澳洲|海外|进口|天猫国际|京东国际"), ("用法用量/适用人群", r"怎么吃|几粒|一天吃|每天吃|饭前|饭后|空腹|孕妇|儿童|孩子|老人|哺乳|妈妈能吃|适合.*(孕妇|儿童|老人|孩子|中老年)"), ("功效/健康诉求", r"有用|效果|精力|气血|代谢|免疫|甲流|睡眠|脑力|瘦|长高|补什么|缺"), ("体验/副作用/剂型", r"反胃|恶心|吞|吞咽|颗粒|大粒|便秘|拉肚子|不舒服|味道|腥"), ("质疑/比较/性价比", r"智商税|真的|靠谱吗|有必要|不如|对比|区别|性价比|划算|坑|贵"), ("内容互动/达人明星", r"喜欢|好看|姐姐|明星|秋瓷炫|颖儿|维嘉|百克力|老师|收藏|学到了"), ] def classify_sku(text): hits = [label for label, pattern in SKU_RULES if re.search(pattern, text, flags=re.I)] return hits or ["善存泛品牌/其他"] def classify_rules(text): hits = [] for rule_id, (name, pattern) in RULE_PATTERNS.items(): if re.search(pattern, text, flags=re.I): hits.append({"ruleId": rule_id, "ruleName": name}) return hits def classify_comment(text): if re.search(COMMENT_THEMES[0][1], text, flags=re.I): return [COMMENT_THEMES[0][0]] hits = [label for label, pattern in COMMENT_THEMES if re.search(pattern, text, flags=re.I)] return hits or ["泛互动/情绪反馈"] def risk_level(rule_ids): red = {"RP01", "RP02", "RP03", "RP04", "RP05", "RP06", "RP07", "RP11"} if len(red.intersection(rule_ids)) >= 3: return "高" if red.intersection(rule_ids): return "中高" if rule_ids: return "中" return "低" def truncate(text, length=120): text = clean(text) return text if len(text) <= length else text[: length - 1] + "…" def comment_rows(comments, videos_by_id): rows = [] for comment in comments: text = clean(comment.get("text")) if not text: continue video = videos_by_id.get(clean(comment.get("videoId")), {}) themes = classify_comment(text) rows.append({ "commentId": comment.get("id") or comment.get("cid") or "", "videoId": comment.get("videoId") or "", "videoTitle": video.get("title") or "", "videoAuthor": video.get("author") or "", "keyword": comment.get("keyword") or video.get("keyword") or "", "themes": themes, "themeText": "、".join(themes), "text": text, "likeCount": as_int(comment.get("likeCount")), "replyCount": as_int(comment.get("replyCommentTotal")), "ipLocation": comment.get("ipLocation") or "", }) rows.sort(key=lambda row: (row["likeCount"], len(row["text"])), reverse=True) return rows def analyze_videos(videos, comments_by_video): rows = [] for video in videos: text = clean(" ".join([ video.get("title"), video.get("desc"), video.get("content"), " ".join(video.get("tags") or []), ])) sku_hits = classify_sku(text) rule_hits = classify_rules(text) rule_ids = [item["ruleId"] for item in rule_hits] comments = comments_by_video.get(clean(video.get("id")), []) top_comment = sorted(comments, key=lambda c: as_int(c.get("likeCount")), reverse=True)[:1] rows.append({ "videoId": video.get("id") or "", "keyword": video.get("keyword") or "", "skuGroup": "、".join(sku_hits), "author": video.get("author") or "", "title": clean(video.get("title") or video.get("desc")), "publishTime": video.get("timestamp") or "", "likeCount": as_int(video.get("likeCount")), "commentCount": as_int(video.get("commentCount")), "shareCount": as_int(video.get("shareCount")), "collectCount": as_int(video.get("collectCount")), "playCount": as_int(video.get("playCount")), "score": round(video_score(video), 2), "url": video.get("url") or video.get("sourceUrl") or video.get("fallbackUrl") or "", "coverUrl": video.get("coverUrl") or "", "tags": "、".join(video.get("tags") or []), "ruleHits": "、".join(rule_ids), "ruleNames": "、".join([item["ruleName"] for item in rule_hits]), "riskLevel": risk_level(set(rule_ids)), "commentCaptured": len(comments), "topComment": top_comment[0].get("text") if top_comment else "", "topCommentLike": as_int(top_comment[0].get("likeCount")) if top_comment else 0, }) rows.sort(key=lambda row: row["score"], reverse=True) return rows def build_theme_summary(comment_data): buckets = defaultdict(list) for row in comment_data: for theme in row["themes"]: buckets[theme].append(row) summaries = [] for theme, rows in buckets.items(): sorted_rows = sorted(rows, key=lambda r: r["likeCount"], reverse=True) examples = [r["text"] for r in sorted_rows[:5]] summaries.append({ "theme": theme, "commentCount": len(rows), "likedCommentCount": sum(1 for row in rows if row["likeCount"] > 0), "totalLikes": sum(row["likeCount"] for row in rows), "sampleComments": examples, "opportunity": comment_opportunity(theme), }) summaries.sort(key=lambda row: (row["commentCount"], row["totalLikes"]), reverse=True) return summaries def comment_opportunity(theme): mapping = { "购买路径/价格": "置顶评论和商品卡要明确官方购买路径、活动口径和客服入口,避免用户在评论区反复问链接/价格。", "正品/渠道/版本信任": "补充正品辨别、版本差异、喷码/批号/效期说明,跨境 SKU 尤其要把包装版本说清楚。", "用法用量/适用人群": "所有用法、用量、特殊人群问题都回到标签/说明书,并引导咨询专业人士。", "功效/健康诉求": "用户会把内容理解成功效承诺,后续稿件应减少结果承诺,改为日常营养支持和生活方式组合。", "体验/副作用/剂型": "建立颗粒大小、吞咽、服用时间、胃部不适等体验 FAQ,但不要自行给医疗建议。", "质疑/比较/性价比": "用成分、剂型、预算、版本和适用场景做中性比较,避免红黑榜和竞品贬损。", "内容互动/达人明星": "高互动来自达人/情绪内容时,要用评论区和商品卡补足 SKU 承接,否则曝光不等于转化。", "泛互动/情绪反馈": "保留情绪互动价值,同时在高赞评论下补充产品边界与购买答疑。", } return mapping.get(theme, "需要人工复核评论上下文后再定动作。") def build_goldens(): return [ { "id": "GD-LIVE-01", "riskPrototype": "左右脑互博、40+精力满格、脑力/精力状态承诺", "ruleHits": "RP02、RP03、RP13、RP16", "beforeExample": "梗王蓄力局,40+精力满格 #善存PRO营养包", "afterExample": "40+忙碌阶段更要关注饮食、作息和日常营养补充;产品请按标签建议选择。", "why": "保留年龄场景和生活状态,但不承诺精力/脑力改善。", "sourceEvidence": "李维嘉/善存PRO营养包 live 样本", }, { "id": "GD-LIVE-02", "riskPrototype": "吃瘦不饿瘦、身材管理小妙招、代谢包", "ruleHits": "RP03、RP04、RP13、RP16", "beforeExample": "开工前我的身材管理小妙招来啦 #善存代谢包 #状态全开", "afterExample": "开工阶段也别忽略均衡饮食和规律运动;如需补充维生素矿物质,请按产品标签建议。", "why": "删除减重/代谢结果暗示,把内容改为生活方式+日常补充。", "sourceEvidence": "颖儿/善存代谢包 live 样本", }, { "id": "GD-LIVE-03", "riskPrototype": "气血满格、新生焕活,就这一包", "ruleHits": "RP03、RP13、RP16", "beforeExample": "气血满格,就这一包;新生焕活,就这一包。", "afterExample": "忙碌和年龄阶段可以关注日常营养管理,按标签建议补充相关营养成分。", "why": "弱化强感受和单包解决感,减少平台卡审和功效风险。", "sourceEvidence": "善存海外旗舰店/PRO营养包 live 样本", }, { "id": "GD-LIVE-04", "riskPrototype": "甲流有哪些维生素可以吃", "ruleHits": "RP02、RP03、RP16", "beforeExample": "甲流有哪些维生素可以吃?一粒补充26种营养。", "afterExample": "特殊健康时期更要注意均衡饮食和休息;本品为日常营养补充,不替代药物或治疗。", "why": "删除疾病场景和营养品解决病症的关联。", "sourceEvidence": "善存复合维生素 live 样本", }, { "id": "GD-LIVE-05", "riskPrototype": "一粒就可以补充26人体所需营养/一粒补全", "ruleHits": "RP01、RP03、RP04、RP07", "beforeExample": "一粒就可以补充26人体所需营养。", "afterExample": "含多种维生素和矿物质,适合按产品标签建议作为日常营养补充。", "why": "保留成分数量,但不推导全部所需或一粒补全。", "sourceEvidence": "善存复合维生素 live 样本", }, { "id": "GD-LIVE-06", "riskPrototype": "国外国内爆火、海外好物闭眼入", "ruleHits": "RP01、RP05、RP13、RP18", "beforeExample": "国外国内爆火的善存女性维生素,海外好物闭眼入。", "afterExample": "不同渠道/版本的包装和说明可能不同,请以当前购买页面、包装标签和官方客服说明为准。", "why": "把热销背书和跨境信任改为版本与渠道说明。", "sourceEvidence": "善存海外/女士维生素 live 样本", }, { "id": "GD-LIVE-07", "riskPrototype": "成人儿童营养好物、不花冤枉钱的泛比较", "ruleHits": "RP07、RP11、RP14", "beforeExample": "成人&儿童营养好物,看了不花冤枉钱。", "afterExample": "成人和儿童适用产品不同,建议按年龄、标签说明和自身需求选择。", "why": "避免成人儿童泛化和价值判断过强。", "sourceEvidence": "营养师/山姆营养品区 live 样本", }, { "id": "GD-LIVE-08", "riskPrototype": "正品/喷码/效期/版本问题散落在评论区", "ruleHits": "RP05、RP06、RP18、RP21", "beforeExample": "评论区反复追问真假、效期、喷码、美国版/澳洲版区别。", "afterExample": "置顶评论:购买请认准官方授权渠道;不同版本包装/成分/适用说明请以购买页和实物标签为准,效期/喷码可咨询客服。", "why": "将售前信任问题前置,降低投诉和客服压力。", "sourceEvidence": "多条善存海外/男士/女士/50+评论", }, { "id": "GD-LIVE-09", "riskPrototype": "明星都在吃/达人高管背书", "ruleHits": "RP14、RP19、RP22", "beforeExample": "秋瓷炫、王艳、百克力都在吃的营养包。", "afterExample": "达人分享仅代表个人体验;产品信息以包装、标签和官方说明为准。", "why": "保留达人种草,但不把身份/使用行为变成效果背书。", "sourceEvidence": "善存海外旗舰店/营养包 live 样本", }, { "id": "GD-LIVE-10", "riskPrototype": "50岁以上善存怎么选、男士/女士/海外版混讲", "ruleHits": "RP05、RP06、RP07、RP14", "beforeExample": "美国 Costco 三款 50 岁以上善存如何选择。", "afterExample": "不同年龄和版本适用说明不同,请按具体 SKU、包装标签和购买渠道说明选择。", "why": "SKU/版本边界清楚,避免跨版本套用。", "sourceEvidence": "50+版本选择 live 样本", }, ] def build_script_breakdowns(top_videos): rows = [] for video in top_videos[:30]: title = video["title"] rules = set(filter(None, video["ruleHits"].split("、"))) if re.search(r"参鸡汤|餐厅|身材管理|开工|婚姻|阅读|高管|梗王", title): hook = "生活方式/达人故事钩子" structure = "先用生活或人物情境吸引停留,再用标签、话题或轻口播承接善存 SKU。" fix = "保留故事,但结尾补 SKU 边界:日常营养补充、按标签建议、非治疗。" elif re.search(r"甲流|维生素可以吃|医生|营养师", title): hook = "专家/健康问题钩子" structure = "用健康问题或专业身份制造可信度。" fix = "删疾病解决路径,改成均衡饮食与标签范围;专家身份不替代证据。" elif re.search(r"正品|版本|Costco|怎么选|选择", title): hook = "选购/信任答疑钩子" structure = "围绕版本、渠道、真假、效期等购买前疑问展开。" fix = "适合做置顶 FAQ 和客服承接,减少评论区重复追问。" else: hook = "商品卖点/活动钩子" structure = "用成分数量、活动、包邮或热门标签推动转化。" fix = "保留成分和促销信息,但避免一粒补全、保证效果、闭眼入。" rows.append({ "videoId": video["videoId"], "title": title, "author": video["author"], "skuGroup": video["skuGroup"], "hookType": hook, "currentStructure": structure, "riskRules": "、".join(sorted(rules)), "reusableFix": fix, }) return rows def build_summary(video_rows, comment_data, theme_summary, merged): sku_counts = Counter() for row in video_rows: for sku in row["skuGroup"].split("、"): sku_counts[sku] += 1 risk_counts = Counter(row["riskLevel"] for row in video_rows) author_counts = Counter(row["author"] for row in video_rows if row["author"]) keyword_counts = Counter(row["keyword"] for row in video_rows if row["keyword"]) return { "generatedAt": datetime.now(timezone.utc).astimezone().isoformat(), "requestedKeywordCount": len(merged["requestedKeywords"]), "effectiveKeywordCount": len(merged["effectiveKeywords"]), "videoCount": len(video_rows), "commentCount": len(comment_data), "actionableCommentCount": sum(1 for row in comment_data if "低质互动/刷金币" not in row["themes"]), "lowQualityCommentCount": sum(1 for row in comment_data if "低质互动/刷金币" in row["themes"]), "warningCount": len(merged["warnings"]), "errorCount": len(merged["errors"]), "skuCounts": [{"skuGroup": k, "videoCount": v} for k, v in sku_counts.most_common()], "riskCounts": [{"riskLevel": k, "videoCount": v} for k, v in risk_counts.most_common()], "topAuthors": [{"author": k, "videoCount": v} for k, v in author_counts.most_common(15)], "topKeywords": [{"keyword": k, "videoCount": v} for k, v in keyword_counts.most_common(20)], "topCommentThemes": theme_summary[:10], } def build_markdown(data): summary = data["summary"] top_videos = data["videoRows"][:18] top_themes = data["themeSummary"][:8] goldens = data["goldenRows"] script_rows = data["scriptBreakdowns"][:12] lines = [] lines.append("# 善存抖音稿件审核润色与评论 VOC Live 版报告") lines.append("") lines.append(f"生成时间:{summary['generatedAt']}") lines.append("") lines.append("## 样本覆盖") lines.append("") lines.append(f"- live 去重视频样本:{summary['videoCount']} 条") lines.append(f"- live 去重评论样本:{summary['commentCount']} 条") lines.append(f"- 可行动 VOC 评论:{summary['actionableCommentCount']} 条;低质互动/刷金币类评论:{summary['lowQualityCommentCount']} 条") lines.append(f"- 请求关键词:{summary['requestedKeywordCount']} 个;有效返回关键词:{summary['effectiveKeywordCount']} 个") lines.append(f"- 接口警告/错误:{summary['warningCount']} / {summary['errorCount']},主要为部分关键词或单条评论页 fetch failed;已保留成功样本。") lines.append("- 规则资料:QW1 Rule Pack Examples V0.1,已提取 28 个 Rule Pack,用于规则映射和黄金改写。") lines.append("") lines.append("## 方法说明与限制") lines.append("") lines.append("- 采集口径:使用 `claude-code-voc-intelligence` 的抖音采集能力,围绕善存、善存营养包、复合维生素、30+/50+、男士/女士、海外版/跨境版等关键词抓取视频与评论,并对跨批次视频、评论做去重。") lines.append("- 脚本拆解口径:使用 `douyin-speaking-daily` 的稿件拆解能力,对标题、标签、描述、评论和可获取文本形成 transcript proxy 与脚本结构分析;未做完整视频 ASR 的样本,结论以可见文案和评论为准。") lines.append("- 审核差异限制:本轮未取得品牌审核前原稿、驳回原因、修改版本流水和最终通过稿的一一对应关系,因此“做了哪些润色和修改”是基于已发布稿件、Rule Pack 命中风险和可复用通过话术的反推,不等同于逐条原稿差异审计。") lines.append("- 证据强度:78 条视频和 471 条评论足以支撑本轮方向性判断;涉及合规结论时仍建议由品牌法务/平台审核团队结合原始投放链路复核。") lines.append("") lines.append("## 一句话结论") lines.append("") lines.append("善存抖音已投内容的主流过审路径不是直接讲“治疗/功效”,而是把产品包进明星生活方式、职场状态、选购答疑、跨境版本说明和日常营养补充场景中;但 live 样本里仍大量出现“精力满格、气血满格、代谢/吃瘦、甲流、脑力/大脑、闭眼入、一粒补充所需”等高风险表达。评论区最值得改进的是:正品与版本信任、用法用量/适用人群、购买路径/价格、功效追问和剂型体验。") lines.append("") lines.append("## 已发布稿件的润色/修改路径") lines.append("") lines.append("1. 明星生活方式化:秋瓷炫参鸡汤面、颖儿开工身材管理、李维嘉 40+梗王等内容,把产品从硬功效改为生活情境和标签承接。") lines.append("2. 年龄/状态场景化:PRO 营养包常用 30+/40+、精力、气血、状态等表达,过审上比治疗承诺温和,但仍需进一步弱化为日常营养管理。") lines.append("3. 店铺商品话术成分化:官方/旗舰店内容常用“26种营养、一粒、男女复合维生素、海外版”等成分和版本话术,需避免推导“一粒补全/疾病时期该吃”。") lines.append("4. 跨境 SKU 信任化:海外/美国/澳洲/Costco/天猫国际相关内容更适合做版本、包装、渠道和效期说明,而不是用海外身份做安全背书。") lines.append("5. 评论承接客服化:正品、喷码、效期、怎么吃、适合谁、哪里买等评论应前置成置顶 FAQ 和客服关键词库。") lines.append("") lines.append("## 黄金改写数据集") lines.append("") lines.append("| ID | 风险原型 | 命中规则 | 风险表达 | 建议通过版 | 依据 |") lines.append("|---|---|---|---|---|---|") for row in goldens: lines.append(f"| {row['id']} | {row['riskPrototype']} | {row['ruleHits']} | {row['beforeExample']} | {row['afterExample']} | {row['why']} |") lines.append("") lines.append("## 评论 VOC 主题与改进空间") lines.append("") lines.append("| 主题 | 评论数 | 点赞评论数 | 高赞/典型评论摘录 | 改进动作 |") lines.append("|---|---:|---:|---|---|") for row in top_themes: examples = "
".join(truncate(text, 60) for text in row["sampleComments"][:3]) lines.append(f"| {row['theme']} | {row['commentCount']} | {row['likedCommentCount']} | {examples} | {row['opportunity']} |") lines.append("") lines.append("## 样本案例 Top") lines.append("") for index, row in enumerate(top_videos, start=1): lines.append(f"### {index}. {row['title'] or row['videoId']}") lines.append(f"- 作者:{row['author']}|关键词:{row['keyword']}|SKU:{row['skuGroup']}") lines.append(f"- 互动:{row['likeCount']}赞 / {row['commentCount']}评 / {row['shareCount']}转 / {row['collectCount']}藏") lines.append(f"- 规则命中:{row['ruleHits'] or '未命中主要规则'}|风险等级:{row['riskLevel']}") lines.append(f"- 链接:{row['url']}") if row["topComment"]: lines.append(f"- 高赞评论:{truncate(row['topComment'], 90)}({row['topCommentLike']}赞)") lines.append("") lines.append("## 逐字稿/脚本结构拆解") lines.append("") lines.append("本轮未批量下载视频做 ASR,逐字稿拆解使用 live 采集到的视频标题、描述、标签和评论作为脚本代理文本,并用本地 douyin-viral-script-analyzer 跑通结构拆解。正式复盘审核前后稿时,可把原始口播 ASR 替换进同一输入结构。") lines.append("") lines.append("| 样本 | 钩子类型 | 当前结构 | 风险规则 | 可复用改法 |") lines.append("|---|---|---|---|---|") for row in script_rows: lines.append(f"| {truncate(row['title'], 36)} | {row['hookType']} | {row['currentStructure']} | {row['riskRules']} | {row['reusableFix']} |") lines.append("") lines.append("## 下一步建议") lines.append("") lines.append("1. 把品牌审核前原稿、驳回原因和通过稿补入 `content_id/version_id/audit_id`,即可把本报告从“已发布稿件推断”升级为真正的审核前后差异复盘。") lines.append("2. 对评论数最高的官方/达人稿件追加 ASR,尤其是秋瓷炫、颖儿、李维嘉、善存海外旗舰店和 50+版本选择内容。") lines.append("3. 建立置顶评论模板:正品/版本/效期、用法用量、特殊人群、非治疗声明、购买路径。") return "\n".join(lines) def main(): OUTPUT_DIR.mkdir(parents=True, exist_ok=True) merged = merge_datasets() comments_by_video = defaultdict(list) for comment in merged["comments"]: comments_by_video[clean(comment.get("videoId"))].append(comment) videos_by_id = {clean(video.get("id")): video for video in merged["videos"]} video_rows = analyze_videos(merged["videos"], comments_by_video) comment_data = comment_rows(merged["comments"], videos_by_id) theme_summary = build_theme_summary(comment_data) summary = build_summary(video_rows, comment_data, theme_summary, merged) script_breakdowns = build_script_breakdowns(video_rows) data = { "summary": summary, "requestedKeywords": merged["requestedKeywords"], "effectiveKeywords": merged["effectiveKeywords"], "videoRows": video_rows, "commentRows": comment_data, "themeSummary": theme_summary, "goldenRows": build_goldens(), "scriptBreakdowns": script_breakdowns, "warnings": merged["warnings"], "errors": merged["errors"], "rulePackSource": str(RULE_TEXT), } (OUTPUT_DIR / "live-analysis-data.json").write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") (OUTPUT_DIR / "善存抖音稿件审核润色与评论VOC-live.md").write_text(build_markdown(data), encoding="utf-8") analyzer_input = { "metadata": {"project": "善存抖音稿件审核润色与评论VOC-live", "generatedAt": summary["generatedAt"]}, "videos": [ { "aweme_id": row["videoId"], "keyword": row["keyword"], "title": row["title"], "author": {"nickname": row["author"]}, "likeCount": row["likeCount"], "commentCount": row["commentCount"], "shareCount": row["shareCount"], "playCount": row["playCount"], "url": row["url"], } for row in video_rows ], "comments": [ { "id": row["commentId"], "aweme_id": row["videoId"], "text": row["text"], "likeCount": row["likeCount"], } for row in comment_data ], "transcripts": [ { "awemeId": row["videoId"], "provider": "live-title-desc-proxy", "text": "。".join(filter(None, [row["title"], row["topComment"]])), "segments": [], } for row in video_rows ], } (OUTPUT_DIR / "douyin_analyzer_input_live.json").write_text(json.dumps(analyzer_input, ensure_ascii=False, indent=2), encoding="utf-8") print(json.dumps({ "status": "ok", "videoCount": summary["videoCount"], "commentCount": summary["commentCount"], "effectiveKeywordCount": summary["effectiveKeywordCount"], "output": str(OUTPUT_DIR / "live-analysis-data.json"), "markdown": str(OUTPUT_DIR / "善存抖音稿件审核润色与评论VOC-live.md"), }, ensure_ascii=False, indent=2)) if __name__ == "__main__": main()