audit-library-cleanup.py 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122
  1. """Conservative, evidence-bearing triage of the current research inventory."""
  2. from collections import Counter
  3. from datetime import datetime, timezone
  4. import hashlib
  5. import json
  6. from pathlib import Path
  7. import re
  8. import unicodedata
  9. import os
  10. ROOT = Path(__file__).resolve().parents[1]
  11. WORK = ROOT / os.environ.get('KSF_CLEANUP_WORK','work/library-cleanup-20261111')
  12. def canonical(value): return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(',', ':'))
  13. def sha(value): return hashlib.sha256(value.encode()).hexdigest()
  14. def clean(value): return re.sub(r'\s+', '', unicodedata.normalize('NFKC', str(value or ''))).strip()
  15. def meaningless_body(value, title):
  16. body = clean(value)
  17. stripped = re.sub(r'[^\w\u3400-\u9fff]', '', body)
  18. if not stripped or body.lower() in {'null','none','undefined','n/a','无','暂无','暂无简介','暂无描述','暂无内容','无简介','未填写','简介','视频简介','不详','网络','转载','自制','转自网络','。','-', '--', '...','待补充'}:
  19. return 'EMPTY_OR_PLACEHOLDER_BODY'
  20. if body == clean(title): return 'BODY_REPEATS_TITLE'
  21. if re.fullmatch(r'https?://\S+', str(value or '').strip()): return 'LINK_ONLY_BODY'
  22. if re.fullmatch(r'(?:#[^#\s]+\s*)+', str(value or '').strip()): return 'HASHTAGS_ONLY_BODY'
  23. return None
  24. def main():
  25. inventory = json.loads((WORK / 'inventory-result.json').read_bytes())['body']['result']
  26. # pg-promise serializes bigint as text. Bind plans to the actual typed rows,
  27. # and never mistake the string "0" for an enabled fulltext flag.
  28. definitions = {t['name']:t['columns'] for t in inventory['manifest']['tables']}
  29. for entity,table in [('products','products'),('social','social_posts')]:
  30. for row in inventory[entity]:
  31. for field in definitions[table]:
  32. value=row.get(field['name'])
  33. if value is not None and 'INT' in field['type'].upper():
  34. row[field['name']]=int(value)
  35. protected = set()
  36. for b in ['five-topics-public-01','five-topics-public-02','five-topics-public-03']:
  37. protected.update(r['id'] for r in json.loads((ROOT/'work/database-enrichment-20261110/batches'/b/'expected-records.json').read_bytes()))
  38. removals, repairs, review = [], [], []
  39. core_food = re.compile(r'方便面|泡面|拌面|汤面|拉面|速食面|即食面|面饼|干脆面|挂面|面条|面皮|热干面|乌冬|乌龙面|河粉|米粉|米线|螺蛳粉|酸辣粉|酱|调料|调味|配料|面粉')
  40. beverages = re.compile(r'豆奶|豆乳|奶茶|果汁|饮料|饮品|绿茶|冰红茶|柠檬水|咖啡|凉茶|海之言|鲜橙多|橙汁|苹果汁|奶绿|冰糖雪梨|金桔柠檬|葡萄汁|无糖茶|乌龙|茶里王|双萃|蜜桃多|果漾|矿泉水|椰子水|果蔬汁|番茄汁|酸梅汤|春拂|爱夸')
  41. hard_offtopic = re.compile(r'街霸|街头霸王|最强杰米|无期迷途|[Pp]higros|猫咪大战争|喵星人大战|红莲的弓矢|鬼灭之刃|空耳翻唱|中文进食曲|原创编舞|花衬衫和油泼面|新歌|翻唱|伴奏|速食面刷新|方便面滑板|泡面滑板|502胶水|修复万物|泡面能复制|郭德纲|于谦|家有儿女|风犬少年的天空|波兰球|AI全民制作人|AI剧情|泡面半生的代价|赛博人物志')
  42. protection = re.compile(r'配料|口感|口味|汤底|面饼|面条|料包|份量|分量|包装|制作|烹饪|工艺|营养|钠|蛋白|保质期|储存|囤货|非油炸|干拌|酸甜|麻辣|咸|辣度')
  43. generic = re.compile(r'^(?:方便面|泡面|速食面|拌面|汤面|拉面|炸酱面|担担面|热干面|葱油拌面|油泼面|白象方便面|康师傅方便面|统一方便面|今天吃面|今天吃泡面|吃面|吃泡面|嗦面|一碗面|面面面)+$')
  44. # Individually reviewed against the original title/tags, never tags alone.
  45. reviewed_irrelevant = {
  46. 'bilibili:BV1BSJyzxELa','bilibili:BV1HXHm6ZEG2','bilibili:BV1JL41187mi',
  47. 'bilibili:BV1Jnno6mEwC','bilibili:BV1PC4y1F7Qi','bilibili:BV1QaoMBQE22',
  48. 'bilibili:BV1W2xS6LEur','bilibili:BV1bV4367E6g','bilibili:BV1f741177xs',
  49. 'bilibili:BV1k24y1c7TA','bilibili:BV1kr9mBMEMz','bilibili:BV1rK4y1V7EU',
  50. 'bilibili:BV1v8x96kEwy','bilibili:BV1yKNG6sETM',
  51. 'bilibili:BV111421B75m','bilibili:BV1AnQLB1ECA','bilibili:BV1CU99BoECj',
  52. 'bilibili:BV1CX4y1y7ED','bilibili:BV1DG4y1B7qu','bilibili:BV1GTeP69ES4',
  53. 'bilibili:BV1Kse466ELq','bilibili:BV1LKMn6uETD','bilibili:BV1Si4y1E72L',
  54. 'bilibili:BV1UTXnYVEsj','bilibili:BV1WEBMBoE4G','bilibili:BV1Ye6KYLEi9',
  55. 'bilibili:BV1gv2MBsES6','bilibili:BV1hT4y1A7C2','bilibili:BV1yoVo6qEY1',
  56. }
  57. def entry(row, entity, reason, evidence):
  58. # Hash only stable entity columns, excluding the audit-only source join.
  59. before = {k:v for k,v in row.items() if k not in ('payload_sha256','source_payload','_ksf_source_rowid')}
  60. return {'entity':entity,'id':row['id'],'title':row.get('title'),'reason':reason,'evidence':evidence,
  61. 'sourceRecordId':row['source_record_id'],'sourcePayloadSha256':row['payload_sha256'],
  62. 'recordSha256':sha(canonical(before)), 'before':before}
  63. for row in inventory['products']:
  64. title = row['title'] or ''
  65. if not clean(title) or clean(title) in {'商品','测试商品','暂无','未知','null','None'}:
  66. removals.append(entry(row,'products','INVALID_PRODUCT_TITLE',{'title':title}))
  67. elif beverages.search(title) and not core_food.search(title):
  68. removals.append(entry(row,'products','UNRELATED_STANDALONE_BEVERAGE',{'title':title,'matched':beverages.search(title).group(0)}))
  69. elif re.search(r'补邮专用|邮费链接',title) and not core_food.search(title):
  70. removals.append(entry(row,'products','NON_PRODUCT_POSTAGE_LINK',{'title':title}))
  71. elif row['category_code'] is None and not core_food.search(title):
  72. review.append({'entity':'products','id':row['id'],'title':title,'reason':'UNCATEGORIZED_REQUIRES_SEMANTIC_REVIEW'})
  73. for row in inventory['social']:
  74. if row['id'] in protected: continue
  75. title, body = row['title'] or '', row['content'] or ''
  76. source = json.loads(row['source_payload'])
  77. tags = str(source.get('tags') or '')
  78. missing = meaningless_body(body,title)
  79. title_norm = re.sub(r'[\W_]+','',title)
  80. off = hard_offtopic.search(title)
  81. reason = None
  82. if row['id']=='bilibili:BV11ENX6BE6p': reason='USER_IDENTIFIED_EMPTY_ENTERTAINMENT'
  83. elif row['id'] in reviewed_irrelevant: reason='REVIEWED_FICTION_GAME_OR_CRAFT'
  84. elif off: reason='UNRELATED_GAME_MUSIC_DRAMA_OR_DIY'
  85. elif generic.fullmatch(title_norm) and (missing or len(clean(body))<12): reason='GENERIC_TITLE_WITHOUT_RESEARCH_EVIDENCE'
  86. elif re.fullmatch(r'吃遍全球所有泡面,成为世界泡面先生【第\d+天】',title) and missing:
  87. reason='GENERIC_TITLE_WITHOUT_RESEARCH_EVIDENCE'
  88. elif not clean(title) and missing: reason='EMPTY_RESEARCH_RECORD'
  89. if reason:
  90. removals.append(entry(row,'social',reason,{'title':title,'content':body,'tags':tags,'matched':off.group(0) if off else None}))
  91. elif missing:
  92. if row['fulltext_available'] != 0:
  93. repairs.append({**entry(row,'social',missing,{'title':title,'content':body,'tags':tags}),
  94. 'changes':{'content':None,'fulltext_available':0}})
  95. review.append({'entity':'social','id':row['id'],'title':title,'content':body,'tags':tags,
  96. 'reason':missing,'informativeTitle':bool(protection.search(title))})
  97. elif len(clean(body))<15 and not protection.search(title+' '+body):
  98. review.append({'entity':'social','id':row['id'],'title':title,'content':body,'tags':tags,'reason':'SHORT_NON_SUBSTANTIVE_BODY'})
  99. result = {'schema':'ksf.library-cleanup.audit.v1','generation':inventory['generation'],
  100. 'checkedAt':datetime.now(timezone.utc).isoformat(),'scope':{'products':len(inventory['products']),'social':len(inventory['social'])},
  101. 'protectedDedicatedArticles':len(protected),'proposedRemovals':removals,'proposedRepairs':repairs,'requiresReview':review,
  102. 'counts':{'removeByEntity':dict(Counter(r['entity'] for r in removals)),
  103. 'removeByReason':dict(Counter(r['reason'] for r in removals)),
  104. 'repairFulltextFlags':len(repairs),'review':len(review)},
  105. 'limits':['缺少价格、评论、图片或品牌分类不自动删除有效商品。','访问受限不等于原内容删除。',
  106. '保留面体原料、调味工艺和包装研发线索;不把原料当即食竞品。','纯标题的视频线索不当作已获取正文或消费者证言。']}
  107. (WORK/'audit-proposals.json').write_text(json.dumps(result,ensure_ascii=False,indent=2),encoding='utf8')
  108. for name,rows in [('proposed-removals',removals),('requires-review',review)]:
  109. (WORK/(name+'.jsonl')).write_text('\n'.join(json.dumps({k:v for k,v in r.items() if k not in ('before','recordSha256','sourcePayloadSha256')},ensure_ascii=False) for r in rows),encoding='utf8')
  110. print(json.dumps(result['counts'],ensure_ascii=False))
  111. if __name__=='__main__': main()