collect-five-topic-multisource.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. """Discover original pages across publishers, community and official guidance.
  2. Search pages never become evidence. Keep every response byte and precise body
  3. locator, reject challenge pages, and stop the affected host rather than bypass it.
  4. """
  5. from concurrent.futures import ThreadPoolExecutor,as_completed
  6. from datetime import datetime,timezone
  7. import importlib.util
  8. import json
  9. from pathlib import Path
  10. import re
  11. import time
  12. import urllib.parse
  13. from lxml import html
  14. ROOT=Path(__file__).resolve().parents[1]
  15. WORK=ROOT/'work/five-topic-research-20261111'
  16. SEARCH=WORK/'search';PUBLIC=WORK/'sources'
  17. for d in [SEARCH,PUBLIC]:d.mkdir(exist_ok=True)
  18. spec=importlib.util.spec_from_file_location('public_base_multi',ROOT/'scripts/collect-five-topic-public.py')
  19. base=importlib.util.module_from_spec(spec);spec.loader.exec_module(base)
  20. spec=importlib.util.spec_from_file_location('industry_base_multi',ROOT/'scripts/collect-five-topic-industry-supplement.py')
  21. industry=importlib.util.module_from_spec(spec);spec.loader.exec_module(industry)
  22. QUERIES={
  23. 'wellness':['药食同源 方便面 配料 标准','菌菇 非油炸 方便面 测评','方便面 减盐 钠 工艺','当归 枸杞 方便面 汤料'],
  24. 'student':['宿舍 方便面 开水 不用煮 测评','夜宵 方便面 小份 测评','学生 拌面 沥水 烫手','大学生 方便面 消费 调查'],
  25. 'premium':['高端 方便面 真肉 冻干 工艺','拉面说 牛肉 方便 测评','方便面 高汤 杀菌 工艺','方便面 高端化 价格 复购'],
  26. 'stockpile':['方便面 保质期 储存 油脂 氧化','方便面 囤货 组合装 测评','方便面 包装 阻氧 防潮','方便面 家庭 囤货 口味'],
  27. 'novelty':['方便面 香菜 冬阴功 新品','拌面 联名 新口味 产品','方便面 地域风味 调味 研发','方便面 酸辣 藤椒 测评'],
  28. 'policy':['食药物质 人参 西洋参 黄芪 公告 site:gov.cn','食药物质 管理规定 普通食品 功效 site:gov.cn',
  29. '预包装食品 营养标签 减少钠 问答 site:gov.cn','食品接触材料 塑料 微波 加热 方便面 site:gov.cn',
  30. '方便面 食品安全 消费提示 储存 site:gov.cn','预包装食品 保质期 储存条件 标签 问答 site:gov.cn',
  31. '食品添加剂 调味面制品 使用标准 问答 site:gov.cn','食品 营养声称 低钠 减钠 site:gov.cn'],
  32. }
  33. PUBLISHER_QUERIES=['学生 方便面','夜宵','宿舍','方便面 冻干','方便面 减盐','方便面 工艺','方便面 包装','方便面 新品','药食同源 方便面']
  34. STATE={'schema':'ksf.five-topic.multisource-collection.v1','startedAt':base.now(),'status':'running',
  35. 'searches':[],'sources':[],'rejected':[],'hostStops':{},'productionWrites':0}
  36. def persist():STATE['updatedAt']=base.now();base.save(WORK/'public-collection-result.json',STATE)
  37. def text(n):return re.sub(r'\s+',' ',n.text_content()).strip()
  38. def search(job):
  39. index,topic,query=job;key='web-'+str(index).zfill(2)
  40. url='https://www.so.com/s?'+urllib.parse.urlencode({'q':query})
  41. raw,receipt=base.fetch(url,key,SEARCH)
  42. root=html.fromstring(raw.decode('utf8',errors='replace'))
  43. if receipt['httpStatus']!=200 or re.search('验证|验证码',root.xpath('string(//title)')):raise ValueError('SEARCH_UNAVAILABLE')
  44. found={}
  45. for a in root.xpath('//a[@data-mdurl]'):
  46. u=a.get('data-mdurl');title=text(a)
  47. if not u or len(title)<7 or not u.startswith(('http://','https://')):continue
  48. host=urllib.parse.urlparse(u).hostname or ''
  49. if topic=='policy' and not host.endswith('.gov.cn'):continue
  50. if not re.search('方便面|泡面|拌面|即食面|拉面|食药|食物|食品|原料|食用|药食|黄芪|西洋参',title):continue
  51. found[u]={'url':u,'title':title,'topic':topic,'query':query,'searchReceipt':key+'.http.json'}
  52. result={'topic':topic,'query':query,'httpReceipt':receipt,'results':list(found.values())}
  53. base.save(SEARCH/(key+'.results.json'),result)
  54. return result
  55. def publisher_search(job):
  56. i,query=job;key='publisher-'+str(i).zfill(2)
  57. url='https://www.foodaily.com/search/articles?'+urllib.parse.urlencode({'search':query})
  58. raw,receipt=base.fetch(url,key,SEARCH);root=html.fromstring(raw.decode('utf8',errors='replace'))
  59. rows={}
  60. for a in root.xpath('//a[@href]'):
  61. u=a.get('href');title=text(a)
  62. if re.fullmatch(r'https://www\.foodaily\.com/articles/\d+',u or '') and re.search('方便面|泡面|拌面|汤面|面饼|拉面|速食面',title):
  63. rows[u]={'url':u,'title':title,'query':query,'searchReceipt':key+'.http.json'}
  64. result={'query':query,'httpReceipt':receipt,'results':list(rows.values())}
  65. base.save(SEARCH/(key+'.results.json'),result);return result
  66. def canonical(url):
  67. p=urllib.parse.urlparse(url)
  68. return urllib.parse.urlunparse((p.scheme,p.netloc,p.path.rstrip('/'),'','',''))
  69. def collect(candidate):
  70. u=candidate['url'];host=urllib.parse.urlparse(u).hostname or ''
  71. if host in STATE['hostStops']:raise ValueError('HOST_STOPPED_AFTER_CHALLENGE')
  72. key=base.digest(u.encode())[:24];cached=PUBLIC/(key+'.source.json')
  73. if cached.exists():return json.loads(cached.read_bytes())
  74. raw,receipt=base.fetch(u,key,PUBLIC)
  75. if receipt['httpStatus'] in (202,403,412,429) or re.search('captcha|geetest|/verify|risk_handler',receipt['finalUrl'],re.I):
  76. STATE['hostStops'][host]={'reason':'ACCESS_CHALLENGE','url':u,'httpStatus':receipt['httpStatus']}
  77. raise ValueError('ACCESS_CHALLENGE')
  78. if receipt['httpStatus']!=200:raise ValueError('HTTP_'+str(receipt['httpStatus']))
  79. if 'pdf' in receipt['contentType'].lower() or raw[:4]==b'%PDF':raise ValueError('PDF_REQUIRES_SEPARATE_EXTRACTION')
  80. try:decoded=raw.decode('utf8');encoding='utf8'
  81. except UnicodeDecodeError:decoded=raw.decode('gb18030');encoding='gb18030'
  82. root=html.fromstring(decoded);title=re.sub(r'\s+',' ',root.xpath('string(//title)')).strip()
  83. if re.search('安全验证|访问验证|验证码|访问异常|验证中心|页面不存在|404|登录',title):raise ValueError('VERIFICATION_OR_ERROR_PAGE')
  84. final_host=urllib.parse.urlparse(receipt['finalUrl']).hostname or ''
  85. is_gov=final_host.endswith('.gov.cn')
  86. if final_host.endswith('so.com') or final_host.endswith('sogou.com'):raise ValueError('SEARCH_REDIRECT_NOT_ORIGINAL')
  87. selectors=["//*[@id='UCAP-CONTENT']","//*[@id='zoom']","//*[@id='zoomcon']","//*[@id='content']",
  88. "//*[contains(concat(' ',normalize-space(@class),' '),' TRS_Editor ')]",
  89. "//*[contains(concat(' ',normalize-space(@class),' '),' article-content ')]",
  90. "//*[contains(concat(' ',normalize-space(@class),' '),' article_content ')]",
  91. "//*[contains(concat(' ',normalize-space(@class),' '),' pages_content ')]",
  92. "//*[@id='js_content']","//*[@id='artibody']","//*[contains(concat(' ',normalize-space(@class),' '),' post_text ')]",
  93. "//*[contains(concat(' ',normalize-space(@class),' '),' article-body ')]",'//article']
  94. if final_host=='www.foodaily.com':
  95. title,body,selector,published,encoding=industry.extract(raw,receipt)
  96. else:
  97. body=selector=None
  98. for s in selectors:
  99. nodes=root.xpath(s)
  100. if len(nodes)==1:
  101. value=base.paragraphs(nodes[0])
  102. if len(value)>=120:body=value;selector=s;break
  103. if not body:raise ValueError('UNIQUE_BODY_NOT_LOCATED')
  104. h1=root.xpath('//article//h1|//h1')
  105. if len(h1)==1:title=text(h1[0]) or title
  106. dates=root.xpath('//meta[@property="article:published_time" or @name="PubDate" or @name="pubdate" or @name="publishdate" or @name="publish-date" or @name="PublishDate"]/@content')
  107. if not dates:
  108. visible=re.search(r'\d{4}[-年/.]\d{1,2}[-月/.]\d{1,2}(?:日)?(?:\s+\d{2}:\d{2}(?::\d{2})?)?',root.text_content()[:5000])
  109. dates=[visible.group(0)] if visible else []
  110. published=dates[0] if dates else None
  111. if not re.search('方便面|泡面|即食面|速食面|拌面|拉面说|满汉|速达面馆|火鸡面',title+'\n'+body) and not is_gov:raise ValueError('NOT_NOODLE_RESEARCH')
  112. if len(body)>95000:raise ValueError('BODY_TOO_LARGE_REQUIRES_REVIEW')
  113. if not published:raise ValueError('PUBLICATION_DATE_NOT_LOCATED')
  114. parsed=re.search(r'(\d{4})[-年/.](\d{1,2})[-月/.](\d{1,2})',published)
  115. if parsed:
  116. day='-'.join([parsed[1],parsed[2].zfill(2),parsed[3].zfill(2)])
  117. if day>datetime.now().strftime('%Y-%m-%d'):raise ValueError('FUTURE_PUBLICATION_DATE')
  118. if final_host=='post.smzdm.com':
  119. m=re.fullmatch(r'/(?:talk/)?p/([a-z0-9]+)/?',urllib.parse.urlparse(receipt['finalUrl']).path)
  120. if not m:raise ValueError('EXACT_POST_URL_REQUIRED')
  121. platform,pid='smzdm',m[1]
  122. elif final_host=='www.foodaily.com':platform,pid='foodaily',urllib.parse.urlparse(receipt['finalUrl']).path.split('/')[-1]
  123. else:platform,pid=('govcn' if is_gov else final_host.replace('www.','').replace('.','_')),base.digest(canonical(receipt['finalUrl']).encode())[:32]
  124. row={'schema':'ksf.five-topic.public-source.v1','url':receipt['finalUrl'],'title':title,'body':body,'bodySha256':base.digest(body.encode()),
  125. 'platform':platform,'postId':pid,'sourceId':platform+':'+pid,'sourceType':'official_policy_or_guidance' if is_gov else 'unreviewed_original_article',
  126. 'publishedAtRaw':published,'collectedAt':receipt['collectedAt'],'httpReceipt':receipt,'sourceEncoding':encoding,
  127. 'bodyExtractionSelector':selector,'collectionDirectory':'sources','searchDiscovery':candidate,'topics':[],'publishedToDatabase':False,
  128. 'limits':['保留原作者、日期及原文,不等同独立消费者或电商购买评价','原文工艺、价格、营养及功效声明须区分来源和适用边界,不能自动作为已验证结论']}
  129. base.save(cached,row);return row
  130. def main():
  131. inv=json.loads((ROOT/'work/library-cleanup-20261111/round-02/inventory-after-result.json').read_bytes())['body']['result']
  132. existing={canonical(r.get('url') or '') for r in inv['social']}
  133. candidates={};jobs=[(i,t,q) for i,(t,q) in enumerate([(t,q) for t,qs in QUERIES.items() for q in qs],1)]
  134. with ThreadPoolExecutor(max_workers=3) as pool:
  135. tasks={pool.submit(search,j):j for j in jobs}
  136. tasks.update({pool.submit(publisher_search,(i,q)):('publisher',q) for i,q in enumerate(PUBLISHER_QUERIES,1)})
  137. for future in as_completed(tasks):
  138. try:
  139. result=future.result();STATE['searches'].append({'query':result['query'],'count':len(result['results'])})
  140. for row in result['results']:
  141. if canonical(row['url']) not in existing:candidates.setdefault(row['url'],row)
  142. except Exception as e:STATE['rejected'].append({'job':tasks[future],'reason':str(e)})
  143. persist()
  144. STATE['candidateCount']=len(candidates);persist();base.save(WORK/'discovered-candidates.json',list(candidates.values()))
  145. print(json.dumps({'stage':'search-complete','candidates':len(candidates)}),flush=True)
  146. with ThreadPoolExecutor(max_workers=3) as pool:
  147. tasks={pool.submit(collect,row):row for row in candidates.values()}
  148. for future in as_completed(tasks):
  149. try:STATE['sources'].append(future.result())
  150. except Exception as e:STATE['rejected'].append({'url':tasks[future]['url'],'reason':str(e)})
  151. persist()
  152. if (len(STATE['sources'])+len(STATE['rejected']))%10==0:print(json.dumps({'stage':'originals','readable':len(STATE['sources']),'rejected':len(STATE['rejected'])}),flush=True)
  153. STATE.update(status='collected-awaiting-content-review',finishedAt=base.now(),uniqueSources=len(STATE['sources']));persist()
  154. print(json.dumps({'stage':'completed','readableOriginals':len(STATE['sources']),'candidates':len(candidates)}),flush=True)
  155. if __name__=='__main__':main()