review-five-topic-multisource-20261111.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. """Curated selection with replayable text boundaries and exact topic quotations."""
  2. import importlib.util
  3. import json
  4. from pathlib import Path
  5. import re
  6. import urllib.parse
  7. from lxml import html
  8. ROOT=Path(__file__).resolve().parents[1]
  9. WORK=ROOT/'work/five-topic-research-20261111'
  10. spec=importlib.util.spec_from_file_location('base_review',ROOT/'scripts/collect-five-topic-public.py')
  11. b=importlib.util.module_from_spec(spec);spec.loader.exec_module(b)
  12. def lines_select(row, start=None, stop=None, indexes=None):
  13. lines=row['body'].splitlines()
  14. if indexes is None:
  15. first=next((i for i,line in enumerate(lines) if start in line),None) if start else 0
  16. assert first is not None,('START_NOT_FOUND',start)
  17. last=next((i for i,line in enumerate(lines[first+1:],first+1) if stop in line),len(lines)) if stop else len(lines)
  18. indexes=list(range(first,last))
  19. row['paragraphIndexes']=indexes
  20. row['body']='\n'.join(lines[i] for i in indexes)
  21. row['bodySha256']=b.digest(row['body'].encode())
  22. return row
  23. def topic(row, key, phrase, relation):
  24. paragraph=next((line for line in row['body'].splitlines() if phrase in line),None)
  25. assert paragraph,('QUOTE_NOT_FOUND',row['url'],phrase)
  26. row['topics'].append({'topic':key,'relation':relation,'passages':[paragraph]})
  27. def main():
  28. rows=json.loads((WORK/'reextracted-sources.json').read_bytes())
  29. rows+=json.loads((WORK/'focused-collection-result.json').read_bytes())['sources']
  30. rows+=json.loads((WORK/'experience-collection-result.json').read_bytes())['sources']
  31. chosen=[]
  32. def choose(part, kind, note):
  33. original=next(x for x in rows if part in x['url']);r=json.loads(json.dumps(original))
  34. raw=(WORK/'sources'/r['httpReceipt']['responseFile']).read_bytes();assert b.digest(raw)==r['httpReceipt']['sha256']
  35. tree=html.fromstring(raw.decode(r['sourceEncoding']))
  36. if r['platform']=='smzdm':
  37. r['bodyExtractionSelector']='//article';assert len(tree.xpath('//article'))==1
  38. r['body']=b.paragraphs(tree.xpath('//article')[0])
  39. r['postId']=re.search(r'/(?:talk/)?p/([a-z0-9]+)/',r['url'])[1]
  40. if 'sohu.com' in r['url']:
  41. r['platform']='sohu';r['postId']=re.search(r'/a/([0-9_]+)',r['url'])[1]
  42. r['publishedAtRaw']=tree.xpath('//meta[@property="og:release_date"]/@content')[0]
  43. if 'sina.cn' in r['url']:
  44. r['platform']='sina';r['postId']=re.search(r'detail-([a-z0-9]+)\.d',r['url'])[1]
  45. r['url']=r['url'].split('?')[0]
  46. r.update(entity='social',id=r['platform']+':'+r['postId'],sourceType=kind,reviewNote=note,topics=[],contentScope='packaged_noodles')
  47. r['bodySha256']=b.digest(r['body'].encode());chosen.append(r);return r
  48. r=choose('/a30dq7kd/','community_experience','宿舍冲泡耗时和藤椒、麻辣口味体验;身份和配方未独立核实。')
  49. topic(r,'student','泡个十多分钟','dormitory_preparation_time')
  50. topic(r,'novelty','藤椒味','pepper_flavor_trial')
  51. r=choose('/aov0k0on/','community_experience','5连包、面饼保护和分步加料体验;非油炸和重量仅为作者表述,不转为商品规格。')
  52. topic(r,'student','先泡面饼','hot_water_preparation')
  53. topic(r,'novelty','兰州拉面','regional_flavor_case')
  54. topic(r,'stockpile','5连包','multipack_protection')
  55. r=choose('/akko2xnr/','community_experience','保留短帖真实分量和口味偏好,不扩写为调查或购买评价。')
  56. topic(r,'student','一袋半','portion_preference')
  57. r=choose('/aevdzmom/','community_experience','宿舍饱腹与泡椒酸辣偏好;不据此推断学生总体偏好。')
  58. topic(r,'student','在宿舍','dormitory_satiety')
  59. topic(r,'novelty','泡椒','sour_spicy_flavor_case')
  60. r=choose('/a20o44kn/','community_experience','晚自习后进食和宿舍整箱储备;单帖不推算人数或市场份额。')
  61. topic(r,'student','晚自习','after_class_late_night')
  62. topic(r,'stockpile','各屯了一箱','dormitory_case_stocking')
  63. r=choose('/arqpdepg/','community_experience','重点保留汤味偏好和喝汤后口渴体验;历史与营养泛论不是研究结论。')
  64. lines_select(r,start='方便面已经成为')
  65. r['contentScope']='packaged_noodles_excerpt';r['title']+='(食用体验摘录)'
  66. topic(r,'student','夜宵','late_night_soup_experience')
  67. topic(r,'wellness','觉得渴','adjacent_health_taste_sodium_hypothesis')
  68. r=choose('/736876/','community_experience','2018年冻干牛肉面体验;工艺、健康宣传和历史报价均未独立核实,非当前售价或功效证据。')
  69. topic(r,'student','等待5分钟','late_night_hot_water_preparation')
  70. topic(r,'premium','大片蔬菜','freeze_dried_texture_experience')
  71. r=choose('/85734155_119756','editorial_comparative_review','2016年30款评测的评分框架与具体体验,作历史设计参考;非当前榜单,未核实每一款商品规格。')
  72. topic(r,'student','深夜','late_night_historical_comparison')
  73. topic(r,'novelty','辣白菜','regional_flavor_historical_comparison')
  74. topic(r,'premium','工具方便度','comparative_review_dimensions')
  75. r=choose('detail-imzzcerw1177484','industry_product_process_reporting','2023年上观新闻转载;熟面急冻、高汤和一体化是报道声明,专利与参数未逐一独立验证;需要冷链和加热,不是宿舍常温开水即食方案。')
  76. topic(r,'premium','4-6个小时','frozen_soup_process_reporting')
  77. topic(r,'novelty','全程不用加水','frozen_integrated_format_case')
  78. r=choose('/articles/41519','industry_reporting_or_opinion','保留DT商业观察署名;新品、财务、份额和消费者引语均是报道口径,不新增为电商商品或独立评论。')
  79. # Exclude publisher event promotions, retaining the reported article body.
  80. promo=next((line for line in r['body'].splitlines() if 'Foodaily创新博览会' in line),None)
  81. if promo:lines_select(r,stop=promo)
  82. topic(r,'premium','隔热包装','packaging_and_real_ingredient_design')
  83. topic(r,'novelty','地域风味上','regional_flavor_portfolio')
  84. topic(r,'student','年轻人的夜宵场景','late_night_scene_reporting')
  85. topic(r,'stockpile','家庭消费场景','family_channel_positioning')
  86. r=choose('/articles/25010','industry_reporting_or_opinion','2021年深燃历史报道,用于高端溢价与价值感取舍;所列售价、销量和市场规模均非本轮实时验证。')
  87. promo=next((line for line in r['body'].splitlines() if 'Foodaily创新博览会' in line),None)
  88. if promo:lines_select(r,stop=promo)
  89. topic(r,'premium','原材料上下功夫','historical_premium_value_tradeoff')
  90. r=choose('/articles/42324','industry_product_news_excerpt','只摘录盒马冻干方便面小节;其他饮料与招商文不入库。配方、温度、冻干和热风干燥均为报道声明,并非实测工艺参数。')
  91. lines=r['body'].splitlines();indexes=[i for i,line in enumerate(lines) if '据官方介绍,新品冻干方便面' in line]
  92. assert len(indexes)==1;lines_select(r,indexes=indexes)
  93. r['title']='盒马上新冻干方便面系列(Foodaily新品周报原文摘录)';r['contentScope']='packaged_noodles_excerpt'
  94. topic(r,'premium','汤菜一体','integrated_freeze_dried_format_reporting')
  95. topic(r,'novelty','番茄炒蛋','new_flavor_lineup_reporting')
  96. r=choose('/articles/41662','industry_product_news_excerpt','只摘录方便面新品两段;原文减卡、减脂和高纤为品牌经媒体转述的营养声称,不证明减重功效,未核对实物标签。')
  97. lines=r['body'].splitlines();indexes=[i for i,line in enumerate(lines) if line.startswith('4月27日,康师傅推出') or line.startswith('历经几十年发展,方便面行业')]
  98. assert len(indexes)==2;lines_select(r,indexes=indexes)
  99. r['title']='康师傅上新红烧牛肉面减脂高纤面(Foodaily热闻原文摘录)';r['contentScope']='packaged_noodles_excerpt'
  100. topic(r,'wellness','0油炸','adjacent_health_non_fried_fiber_claim_case')
  101. r=choose('/articles/42270','industry_interview_excerpt','只选居家煮面需求段,排除餐厅打卡段;报道人物均为化名,作者中产判断不是经验证的收入分层或总体需求。')
  102. lines=r['body'].splitlines();start=lines.index('“过日子”的最优解')
  103. end=next(i for i,line in enumerate(lines) if line.startswith('参考资料:'))
  104. lines_select(r,indexes=[0,1]+list(range(start,end)))
  105. r['title']='中产“过日子人”,爱上高阶泡面(居家需求段落摘录)';r['contentScope']='packaged_noodles_excerpt'
  106. topic(r,'premium','性价比最高','home_meal_convenience_value_tradeoff')
  107. # Government publications are documents, not social posts or mandatory standards.
  108. for host,selector,docid,status,title in [
  109. ('ganxian.gov.cn',"//*[@id='zoomcon']",'gx-51706ef906154551b70187447738432f','official_explanation_republication','解读《关于党参等9种新增按照传统既是食品又是中药材的物质公告》(2023年第9号)'),
  110. ('baiyin.gov.cn',"//div[contains(concat(' ',normalize-space(@class),' '),' content ')]",'baiyin-0eece4641557434f8f67a48c85112ebc','consumer_guidance','解码食品科技——正确认识预包装食品保质期'),
  111. ('scjgj.km.gov.cn',"//*[contains(concat(' ',normalize-space(@class),' '),' articleContMain ')]",'kunming-4719685','consumer_guidance_republication','减盐食品不宜敞开吃')]:
  112. receipts=[(f,json.loads(f.read_bytes())) for f in (WORK/'sources').glob('*.http.json')]
  113. f,receipt=next((f,r) for f,r in receipts if host in r['requestedUrl'] and r['httpStatus']==200)
  114. raw=(f.parent/receipt['responseFile']).read_bytes();assert b.digest(raw)==receipt['sha256']
  115. tree=html.fromstring(raw.decode('utf8'));nodes=tree.xpath(selector);assert len(nodes)==1
  116. body=b.paragraphs(nodes[0]);date=tree.xpath('//meta[@name="PubDate"]/@content')
  117. assert date
  118. notes={'pageMetaPubDate':date[0]}
  119. if host=='baiyin.gov.cn':
  120. visible=re.search(r'发布时间:\s*(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})',tree.text_content())
  121. assert visible;date=[visible[1]];notes['visiblePublishedAt']=date[0];notes['dateDiscrepancyRetained']=True
  122. row={'entity':'documents','id':docid,'platform':'document','postId':docid,'title':title,'body':body,'bodySha256':b.digest(body.encode()),
  123. 'url':receipt['finalUrl'],'publishedAtRaw':date[0],'sourceEncoding':'utf8','bodyExtractionSelector':selector,
  124. 'collectionDirectory':'sources','httpReceipt':receipt,'declaredStatus':status,'sourceType':'official_explanation' if host=='ganxian.gov.cn' else 'official_consumer_guidance',
  125. 'reviewNote':'政府网站正文;属于解读或消费科普,不是新增国家标准或产品认证。转载发布时间与原公告年份分开保留。',
  126. 'dateEvidence':notes,'topics':[],'clauses':[],'limits':['非国家标准全文,不据科普单篇确认法规现行性;引用原标准时须结合研究库既有标准及生效日期复核。']}
  127. if host=='ganxian.gov.cn':
  128. topic(row,'wellness','上述物质作为食药物质','traditional_ingredient_application_boundary')
  129. row['clauses']=[{'name':'党参传统食用方式及同用限制','phrase':'党参。在','application':'中式汤底原料候选;需要核对植物基原、食用方式和产品标签,不视为疗效依据。'},
  130. {'name':'食药物质适量食用与特殊人群边界','phrase':'上述物质作为食药物质','application':'研发需明确适用人群;不得把食药目录等同普通食品功效许可。'}]
  131. elif host=='baiyin.gov.cn':
  132. topic(row,'stockpile','按需采购,尽快食用','storage_packaging_and_opening')
  133. row['clauses']=[{'name':'标签贮存条件与合理储存','phrase':'1.查看标签','application':'囤货规格与家庭储存方案需配套温湿度、避光等标签条件。'},
  134. {'name':'开封后的密封体系变化','phrase':'3.按需采购','application':'比较单份独立包装与大包装开封后的使用便利;保质期必须实测验证。'}]
  135. else:
  136. topic(row,'wellness','减盐食品也不一定是低钠食品','adjacent_health_sodium_claim_boundary')
  137. row['clauses']=[{'name':'减盐与低钠的差别','phrase':'阮光锋分析说','application':'是2023年媒体科普转载;相对减少与绝对低钠的阈值应回到适用标准验证,不能把减盐说成低钠。'},
  138. {'name':'钠含量的每100克与每份口径','phrase':'所以,科学减盐需要','application':'汤料研发比较须统一每份重量和营养表单位;不据主观咸淡判断钠含量。'}]
  139. for c in row['clauses']:
  140. c['excerpt']=next(line for line in body.splitlines() if c['phrase'] in line);assert c['excerpt'] in body
  141. chosen.append(row)
  142. assert len({r['id'] for r in chosen})==len(chosen)
  143. for r in chosen:
  144. assert r['topics'] and all(p in r['body'] for t in r['topics'] for p in t['passages'])
  145. result={'schema':'ksf.five-topic.reviewed-multisource.v1','selected':chosen,
  146. 'countsByTopic':{t:sum(any(v['topic']==t for v in r['topics']) for r in chosen) for t in ['wellness','student','premium','stockpile','novelty']},
  147. 'limits':['跨课题关联不重复计算总资料数;政策条款是同一文件的摘录,不额外算独立文件。',
  148. '非油炸、高纤、控钠是中式养生的相邻健康化研究,不等同中医功效或食药原料合法性。',
  149. '本轮未取得新增电商商品和购买评价;公开体验不冒称电商评价。',
  150. '所有历史资料保留日期,只作历史比较,不直接作为2026年市场趋势。']}
  151. b.save(WORK/'reviewed-selection.json',result)
  152. print(json.dumps({'selected':len(chosen),'social':sum(r['entity']=='social' for r in chosen),'documents':sum(r['entity']=='documents' for r in chosen),'clauses':sum(len(r.get('clauses',[])) for r in chosen),'topics':result['countsByTopic']},ensure_ascii=False))
  153. if __name__=='__main__':main()