collect-five-topic-bulk-20261111.py 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
  1. """Bounded public-source expansion using readable publisher catalogs and originals.
  2. Existing search verification responses are not retried or bypassed. Discovery
  3. links never become content records; each selected page keeps exact HTTP bytes.
  4. """
  5. from concurrent.futures import ThreadPoolExecutor,as_completed
  6. from datetime import datetime
  7. import importlib.util
  8. import json
  9. from pathlib import Path
  10. import re
  11. import threading
  12. import time
  13. import urllib.parse
  14. from lxml import html
  15. ROOT=Path(__file__).resolve().parents[1]
  16. WORK=ROOT/'work/five-topic-bulk-20261111'
  17. for name in ['sources','search']: (WORK/name).mkdir(parents=True,exist_ok=True)
  18. spec=importlib.util.spec_from_file_location('bulkbase',ROOT/'scripts/collect-five-topic-public.py')
  19. b=importlib.util.module_from_spec(spec);spec.loader.exec_module(b)
  20. spec=importlib.util.spec_from_file_location('bulkindustry',ROOT/'scripts/collect-five-topic-industry-supplement.py')
  21. industry=importlib.util.module_from_spec(spec);spec.loader.exec_module(industry)
  22. state={'schema':'ksf.five-topic.bulk-discovery.v1','status':'running','startedAt':b.now(),'sources':[],'rejected':[],'searches':[],'hostStops':{},'productionWrites':0}
  23. guard=threading.Lock();next_start={}
  24. NOODLE=re.compile('方便面|泡面|拌面|即食面|速食面|火鸡面|汤达人|拉面说|速达面馆|满汉大餐|冻干面')
  25. def persist():b.save(WORK/'collection-state.json',{**state,'updatedAt':b.now()})
  26. def canonical(url):
  27. p=urllib.parse.urlparse(url);return urllib.parse.urlunparse((p.scheme,p.netloc,p.path.rstrip('/'),'','',''))
  28. def original_post(url):
  29. p=urllib.parse.urlparse(urllib.parse.urljoin('https://post.smzdm.com/',url))
  30. if p.hostname!='post.smzdm.com' or not re.fullmatch(r'/(?:talk/)?p/[a-z0-9]+/?',p.path):return None
  31. return 'https://post.smzdm.com'+p.path.rstrip('/')+'/'
  32. def decode(raw):
  33. try:return raw.decode('utf8'),'utf8'
  34. except UnicodeDecodeError:return raw.decode('gb18030'),'gb18030'
  35. def challenge(raw,receipt,tree):
  36. return receipt['httpStatus'] in (202,403,412,429) or bool(re.search('captcha|/verify|risk_handler|geetest',receipt['finalUrl'],re.I)) or bool(re.search('访问异常|验证码|访问验证|安全验证',tree.xpath('string(//title)')))
  37. def pace(host):
  38. with guard:
  39. at=max(time.monotonic(),next_start.get(host,0));next_start[host]=at+0.75
  40. if at>time.monotonic():time.sleep(at-time.monotonic())
  41. def catalog(job):
  42. query,page=job;url='https://www.foodaily.com/search/articles?'+urllib.parse.urlencode({'search':query,'page':page})
  43. key='foodaily-'+b.digest(url.encode())[:18];pace('www.foodaily.com')
  44. raw,r=b.fetch(url,key,WORK/'search');tree=html.fromstring(raw.decode('utf8'))
  45. if challenge(raw,r,tree):state['hostStops']['www.foodaily.com']={'url':url,'reason':'CATALOG_CHALLENGE'};raise ValueError('CATALOG_CHALLENGE')
  46. assert r['httpStatus']==200,'CATALOG_HTTP_ERROR'
  47. rows={}
  48. for a in tree.xpath('//a[@href]'):
  49. u=a.get('href');title=' '.join(a.text_content().split())
  50. if re.fullmatch(r'https://www\.foodaily\.com/articles/\d+',u or '') and title and NOODLE.search(title):
  51. rows[u]={'url':u,'title':title,'discoveryKind':'publisher_catalog','query':query,'page':page,'discoveryReceipt':str((WORK/'search'/(key+'.http.json')).relative_to(ROOT))}
  52. b.save(WORK/'search'/(key+'.results.json'),{'query':query,'page':page,'receipt':r,'results':list(rows.values())})
  53. return list(rows.values())
  54. def linked_posts(raw,receipt,origin):
  55. text,encoding=decode(raw);tree=html.fromstring(text);rows={}
  56. for a in tree.xpath('//a[@href]'):
  57. u=original_post(a.get('href'));title=' '.join(a.text_content().split())
  58. if u and len(title)>7 and NOODLE.search(title):
  59. rows[u]={'url':u,'title':title[:300],'discoveryKind':'article_related_link','discoveredAtSource':receipt['finalUrl'],'discoveryReceipt':origin}
  60. return rows
  61. def collect(candidate):
  62. u=candidate['url'];host=urllib.parse.urlparse(u).hostname
  63. if host in state['hostStops']:raise ValueError('HOST_STOPPED_AFTER_CHALLENGE')
  64. key=b.digest(u.encode())[:24];cached=WORK/'sources'/(key+'.source.json')
  65. if cached.exists():return json.loads(cached.read_bytes())
  66. pace(host);raw,r=b.fetch(u,key,WORK/'sources');text,encoding=decode(raw);tree=html.fromstring(text)
  67. if challenge(raw,r,tree):
  68. state['hostStops'][host]={'url':u,'httpStatus':r['httpStatus'],'reason':'ACCESS_CHALLENGE'};raise ValueError('ACCESS_CHALLENGE')
  69. if r['httpStatus']!=200:raise ValueError('HTTP_'+str(r['httpStatus']))
  70. if 'post.smzdm.com'==host:
  71. nodes=tree.xpath('//article');assert len(nodes)==1,'SINGLE_ORIGINAL_ARTICLE_REQUIRED'
  72. selector='//article';body=b.paragraphs(nodes[0]);title=' '.join(tree.xpath('string(//article//h1)').split()) or tree.xpath('string(//title)').strip()
  73. dates=tree.xpath('//meta[@property="article:published_time"]/@content')
  74. if not dates:
  75. found=re.search(r'\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}(?::\d{2})?',nodes[0].text_content()[:1200]);dates=[found[0]] if found else []
  76. published=dates[0] if dates else None;pid=re.search(r'/(?:talk/)?p/([a-z0-9]+)/',u)[1];platform='smzdm'
  77. else:
  78. title,body,selector,published,encoding=industry.extract(raw,r);platform='foodaily';pid=urllib.parse.urlparse(u).path.split('/')[-1]
  79. if len(body.removeprefix(title).strip())<100:raise ValueError('INSUFFICIENT_ACTUAL_BODY')
  80. if len(body)>65000:raise ValueError('BODY_REQUIRES_MANUAL_EXTRACTION')
  81. if not NOODLE.search(body):raise ValueError('NO_ACTUAL_PACKAGED_NOODLE_CONTENT')
  82. if not published:raise ValueError('ORIGINAL_DATE_REQUIRED')
  83. date=re.search(r'(\d{4})[-年/.](\d{1,2})[-月/.](\d{1,2})',published)
  84. if not date:raise ValueError('UNPARSED_DATE')
  85. day='-'.join([date[1],date[2].zfill(2),date[3].zfill(2)])
  86. if day>datetime.now().strftime('%Y-%m-%d'):raise ValueError('FUTURE_DATE')
  87. row={'entity':'social','id':platform+':'+pid,'postId':pid,'platform':platform,'url':u,'title':title,'body':body,'bodySha256':b.digest(body.encode()),
  88. 'publishedAtRaw':published,'sourceEncoding':encoding,'bodyExtractionSelector':selector,'httpReceipt':r,'collectionDirectory':'sources',
  89. 'searchDiscovery':candidate,'topics':[],'sourceType':'unreviewed_public_article','limits':[]}
  90. b.save(cached,row);return row
  91. def main():
  92. inventory=json.loads((ROOT/'work/library-cleanup-20261111/round-02/inventory-after-result.json').read_bytes())['body']['result']
  93. known={canonical(r.get('url') or '') for r in inventory['social']}
  94. latest=json.loads((ROOT/'work/database-enrichment-20261110/batches/five-topics-multisource-20261111-01/reviewed-source-index.json').read_bytes())
  95. known.update(canonical(r['url']) for r in latest if r['entity']=='social')
  96. candidates={};archives=[]
  97. for directory in [ROOT/'work/five-topic-research-20261111/sources',ROOT/'work/five-topic-collection-20261110/public-sources',ROOT/'work/five-topic-collection-20261110/supplement-01/public-sources',ROOT/'work/five-topic-collection-20261110/supplement-02/sources']:
  98. for f in directory.glob('*.http.json'):
  99. receipt=json.loads(f.read_bytes())
  100. if receipt['httpStatus']!=200 or 'post.smzdm.com/' not in receipt['finalUrl']:continue
  101. raw=(f.parent/receipt['responseFile']).read_bytes();assert b.digest(raw)==receipt['sha256']
  102. for u,c in linked_posts(raw,receipt,str(f.relative_to(ROOT))).items():
  103. if canonical(u) not in known:candidates.setdefault(u,c)
  104. state['archivedLinkCandidates']=len(candidates);persist()
  105. jobs=[('方便面',p) for p in range(1,16)]+[(q,p) for q in ['拌面','拉面','冻干','非油炸','菌菇','五谷道场','汤达人','食药同源'] for p in range(1,3)]
  106. for job in jobs:
  107. if 'www.foodaily.com' in state['hostStops']:break
  108. try:
  109. rows=catalog(job);state['searches'].append({'query':job[0],'page':job[1],'found':len(rows)})
  110. for c in rows:
  111. if canonical(c['url']) not in known:candidates.setdefault(c['url'],c)
  112. except Exception as e:state['rejected'].append({'catalog':job,'reason':str(e)})
  113. persist();print(json.dumps({'stage':'discovery','catalogs':len(state['searches']),'candidates':len(candidates)}),flush=True)
  114. b.save(WORK/'discovered-candidates.json',list(candidates.values()))
  115. # Bound this run while collecting enough distinct original pages to review.
  116. selected=list(candidates.values())[:360]
  117. with ThreadPoolExecutor(max_workers=2) as pool:
  118. tasks={pool.submit(collect,c):c for c in selected}
  119. for f in as_completed(tasks):
  120. try:state['sources'].append(f.result())
  121. except Exception as e:state['rejected'].append({'url':tasks[f]['url'],'reason':str(e)})
  122. persist()
  123. if (len(state['sources'])+len(state['rejected']))%10==0:print(json.dumps({'stage':'originals','readable':len(state['sources']),'rejected':len(state['rejected'])}),flush=True)
  124. state.update(status='collected-awaiting-semantic-review',finishedAt=b.now(),uniqueSources=len({r['id'] for r in state['sources']}));persist()
  125. print(json.dumps({'stage':'completed','sources':len(state['sources']),'hostStops':state['hostStops']}),flush=True)
  126. if __name__=='__main__':main()