| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264 |
- """Collect readable public source pages, keeping search results out of evidence counts."""
- from concurrent.futures import ThreadPoolExecutor, as_completed
- from datetime import datetime, timezone
- import hashlib
- import json
- from pathlib import Path
- import re
- import threading
- import urllib.error
- import urllib.parse
- import urllib.request
- from lxml import html
- WORK = Path(__file__).resolve().parents[1] / 'work/five-topic-collection-20261110'
- PLAN = json.loads((WORK / 'collection-plan.json').read_bytes())
- PUBLIC = WORK / 'public-sources'
- PUBLIC.mkdir(exist_ok=True)
- SEARCH = WORK / 'public-search'
- QUERIES = {
- 'wellness': ['方便面 中式养生 当归', '方便面 药食同源', '菌菇汤 方便面 非油炸',
- '养生方便面 site:thepaper.cn', '方便面 菌菇 测评 site:post.smzdm.com'],
- 'student': ['大学生 宿舍 泡面 测评', '学生 夜宵 方便面', '宿舍 方便面 不用锅',
- '宿舍 泡面 site:post.smzdm.com', '方便面 夜宵 site:thepaper.cn'],
- 'premium': ['高端 方便面 真实肉块', '方便面 升级 满汉大餐', '方便面 中产 拉面说',
- '高端 方便面 site:thepaper.cn', '拉面说 满汉大餐 测评 site:post.smzdm.com'],
- 'stockpile': ['方便面 囤货 测评', '家庭 囤货 方便面 保质期', '方便面 整箱 组合装',
- '方便面 囤货 site:post.smzdm.com', '方便面 囤货 保质期 site:sohu.com'],
- 'novelty': ['方便面 联名 新品', '方便面 新口味 测评', '泡面 尝鲜 冬阴功 香菜',
- '方便面 联名 site:thepaper.cn', '方便面 新口味 测评 site:post.smzdm.com'],
- }
- NOODLES = re.compile(r'方便面|泡面|即食面|速食面|杯面|桶面|碗面|方便拌面|速食拌面|火鸡面')
- PLATFORMS = {'sohu.com': 'sohu', 'thepaper.cn': 'thepaper', 'smzdm.com': 'smzdm',
- 'zhihu.com': 'zhihu', 'weixin.qq.com': 'wechat', 'xinhuanet.com': 'xinhua',
- 'news.cn': 'xinhua', 'people.com.cn': 'people', 'chinanews.com.cn': 'chinanews',
- 'chinanews.com': 'chinanews', 'cctv.com': 'cctv', 'foodaily.com': 'foodaily',
- 'foodtalks.cn': 'foodtalks', 'cbndata.com': 'cbndata', 'jiemian.com': 'jiemian',
- 'stcn.com': 'stcn', 'yicai.com': 'yicai', 'cnr.cn': 'cnr', 'sina.com.cn': 'sina',
- '163.com': 'netease', 'news.qq.com': 'tencent'}
- STATE = {'schema': 'ksf.five-topic.public-collection.v1', 'status': 'running', 'startedAt': None,
- 'searches': [], 'sources': [], 'rejected': [], 'productionWrites': 0}
- GUARD = threading.Lock()
- def now(): return datetime.now(timezone.utc).isoformat()
- def digest(raw): return hashlib.sha256(raw).hexdigest()
- def save(path, data):
- temporary = path.with_suffix(path.suffix + '.next')
- temporary.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding='utf8')
- temporary.replace(path)
- def persist():
- STATE['updatedAt'] = now()
- save(WORK / 'public-collection-result.json', STATE)
- def platform(url):
- host = urllib.parse.urlparse(url).hostname or ''
- return next((value for suffix, value in PLATFORMS.items() if host == suffix or host.endswith('.' + suffix)), None)
- def fetch(url, name, target):
- previous = target / (name + '.http.json')
- if previous.exists():
- receipt = json.loads(previous.read_bytes())
- body = target / receipt['responseFile']
- if receipt['requestedUrl'] == url and body.exists() and digest(body.read_bytes()) == receipt['sha256']:
- return body.read_bytes(), receipt
- request = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
- try:
- with urllib.request.urlopen(request, timeout=25) as response:
- raw = response.read(6000001)
- status, final, content_type = response.status, response.url, response.headers.get('Content-Type', '')
- except urllib.error.HTTPError as error:
- raw, status, final, content_type = error.read(6000001), error.code, error.url, error.headers.get('Content-Type', '')
- if len(raw) > 6000000: raise ValueError('SOURCE_TOO_LARGE')
- body = name + '.body'
- (target / body).write_bytes(raw)
- receipt = {'requestedUrl': url, 'finalUrl': final, 'httpStatus': status, 'contentType': content_type,
- 'collectedAt': now(), 'responseFile': body, 'bytes': len(raw), 'sha256': digest(raw)}
- save(target / (name + '.http.json'), receipt)
- return raw, receipt
- def search(topic, index, query):
- key = topic + '-sogou-' + str(index + 1)
- file = SEARCH / (key + '.results.json')
- if file.exists(): return json.loads(file.read_bytes())
- url = 'https://www.sogou.com/web?' + urllib.parse.urlencode({'query': query})
- raw, receipt = fetch(url, key, SEARCH)
- rows = []
- if receipt['httpStatus'] == 200:
- root = html.fromstring(raw)
- for heading in root.xpath('//h3'):
- links = heading.xpath('.//a[@href]')
- if not links: continue
- title = re.sub(r'\s+', ' ', heading.text_content()).strip()
- # Search snippets alone never count as source bodies.
- if not title or len(title) < 6: continue
- rows.append({'title': title, 'url': urllib.parse.urljoin(receipt['finalUrl'], links[0].get('href')),
- 'topic': topic, 'query': query, 'searchReceipt': key + '.http.json'})
- result = {'topic': topic, 'query': query, 'receipt': receipt, 'results': rows}
- save(file, result)
- return result
- def resolve(raw, receipt, key):
- for step in range(2):
- host = urllib.parse.urlparse(receipt['finalUrl']).hostname or ''
- if not host.endswith('sogou.com'): return raw, receipt
- root = html.fromstring(raw)
- urls = root.xpath('//meta[translate(@http-equiv,"REFSH","refsh")="refresh"]/@content')
- target = None
- for value in urls:
- found = re.search(r'url\s*=\s*[\'\"]?(https?://[^\'\"\s>]+)', value, re.I)
- if found: target = found.group(1)
- if not target:
- text = raw.decode('utf8', errors='replace')
- found = re.search(r'(?:location\.(?:replace|assign)\s*\(|(?:window\.)?location(?:\.href)?\s*=\s*)[\'\"](https?://[^\'\"\s]+)', text)
- if found: target = found.group(1).replace('\\/', '/')
- if not target: raise ValueError('SEARCH_REDIRECT_NOT_ORIGINAL_PAGE')
- raw, receipt = fetch(target, key + '-redirect-' + str(step + 1), PUBLIC)
- return raw, receipt
- def paragraphs(node):
- clone = html.fromstring(html.tostring(node, encoding='unicode'))
- for element in clone.xpath('.//script|.//style|.//noscript|.//nav|.//footer|.//form|.//button|.//*[contains(concat(" ",normalize-space(@class)," ")," recommend-tab ")]'):
- element.drop_tree()
- nodes = clone.xpath('.//p|.//h1|.//h2|.//h3|.//blockquote|.//li')
- chunks = [re.sub(r'\s+', ' ', element.text_content()).strip() for element in nodes]
- chunks = [part for part in chunks if part]
- if not chunks: chunks = [re.sub(r'\s+', ' ', clone.text_content()).strip()]
- return '\n'.join(dict.fromkeys(chunks))
- def extract(raw, receipt):
- declared = re.search(r'charset\s*=\s*([^;\s]+)', receipt.get('contentType', ''), re.I)
- encoding = declared.group(1).strip('"\'') if declared else 'utf8'
- try: decoded = raw.decode(encoding)
- except (LookupError, UnicodeDecodeError):
- try: decoded = raw.decode('utf8'); encoding = 'utf8'
- except UnicodeDecodeError: decoded = raw.decode('gb18030'); encoding = 'gb18030'
- root = html.fromstring(decoded)
- # Never treat a verification/login response as an article.
- title = ''.join(root.xpath('//meta[@property="og:title"]/@content')[:1]) or root.xpath('string(//title)')
- title = re.sub(r'\s+', ' ', title).strip()
- if re.search(r'安全验证|访问验证|验证码|系统繁忙|访问异常|扫码登录|页面不存在|404|没有找到', title, re.I):
- raise ValueError('VERIFICATION_OR_ERROR_PAGE')
- selectors = [
- '//*[@id="js_content"]', '//*[@id="artibody"]',
- '//*[contains(concat(" ",normalize-space(@class)," ")," article-content ")]',
- '//*[contains(concat(" ",normalize-space(@class)," ")," article_content ")]',
- '//*[contains(concat(" ",normalize-space(@class)," ")," article-main ")]',
- '//*[@id="detail_content"]', '//*[@id="article-content"]',
- '//*[contains(concat(" ",normalize-space(@class)," ")," rich_media_content ")]',
- '//article', '//*[@itemprop="articleBody"]',
- '//*[contains(concat(" ",normalize-space(@class)," ")," article_content_wrapper ")]',
- '//*[contains(concat(" ",normalize-space(@class)," ")," post_text ")]',
- '//*[contains(concat(" ",normalize-space(@class)," ")," entry-content ")]',
- ]
- body, selector = None, None
- for candidate in selectors:
- texts = [paragraphs(node) for node in root.xpath(candidate)]
- texts = [value for value in texts if len(value) >= 300]
- if texts: body, selector = max(texts, key=len), candidate; break
- if not body: raise ValueError('ARTICLE_BODY_NOT_LOCATED')
- if not NOODLES.search(title + '\n' + body): raise ValueError('NOT_PACKAGED_NOODLE_CONTENT')
- date_values = root.xpath('//meta[@property="article:published_time" or @name="pubdate" or @name="publishdate" or @name="publish-date" or @name="PublishDate" or @name="weibo:article:create_at"]/@content')
- dates = [value for value in date_values if re.search(r'\d{4}[-/]\d{1,2}[-/]\d{1,2}', value)]
- if not dates:
- headers = root.xpath('//article')
- header = headers[0].text_content()[:1100] if headers else ''
- visible = re.search(r'\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}(?::\d{2})?', header)
- if visible: dates = [visible.group(0)]
- return title, body, selector, dates[0] if dates else None, encoding
- def collect(candidate):
- key = digest(candidate['url'].encode())[:24]
- record_file = PUBLIC / (key + '.source.json')
- if record_file.exists(): return json.loads(record_file.read_bytes())
- raw, receipt = fetch(candidate['url'], key, PUBLIC)
- if receipt['httpStatus'] != 200: raise ValueError('HTTP_' + str(receipt['httpStatus']))
- raw, receipt = resolve(raw, receipt, key)
- site = platform(receipt['finalUrl'])
- if not site: raise ValueError('UNSUPPORTED_OR_SEARCH_RESULT_HOST')
- title, body, selector, published, encoding = extract(raw, receipt)
- source_topics = []
- for topic in PLAN['topics']:
- hit = [term for term in topic['keywords'] if term in title + '\n' + body]
- if not hit: continue
- passages = []
- for term in hit:
- start = body.find(term)
- if start >= 0: passages.append(body[max(0, start - 65):start + 150])
- source_topics.append({'topic': topic['id'], 'matchedTerms': hit, 'passages': passages[:5]})
- if not source_topics: raise ValueError('NO_REQUESTED_TOPIC_IN_SOURCE_BODY')
- # Drop referrer/tracking fields from the identity; preserve the exact URL in the receipt.
- parsed = urllib.parse.urlparse(receipt['finalUrl'])
- query = urllib.parse.parse_qs(parsed.query)
- stable_query = urllib.parse.urlencode({k: query[k][0] for k in ['__biz', 'mid', 'idx'] if k in query})
- canonical = urllib.parse.urlunparse((parsed.scheme, parsed.netloc, parsed.path, '', stable_query, ''))
- record = {'schema': 'ksf.five-topic.public-source.v1', 'sourceType': 'public_article_not_ecommerce_review',
- 'sourceId': 'url-sha256:' + digest(canonical.encode()), 'identityBasis': 'canonical_original_url_sha256',
- 'platform': site, 'title': title, 'url': canonical, 'originalResolvedUrl': receipt['finalUrl'],
- 'publishedAtRaw': published, 'collectedAt': receipt['collectedAt'], 'body': body,
- 'bodySha256': digest(body.encode()), 'bodyExtractionSelector': selector, 'sourceEncoding': encoding, 'httpReceipt': receipt,
- 'searchDiscovery': candidate, 'topics': source_topics,
- 'verification': 'readable_original_article_with_preserved_http_bytes',
- 'publishedToDatabase': False,
- 'limits': ['原作者/媒体观点,不当作电商购买评价', '文中健康或市场数字未在此轮独立验证',
- '保留原文字段与原件,不将采集时刻写成发布时间']}
- save(record_file, record)
- return record
- def main():
- STATE['startedAt'] = now()
- candidates = {}
- for topic, queries in QUERIES.items():
- for index, query in enumerate(queries):
- try:
- result = search(topic, index, query)
- STATE['searches'].append({'topic': topic, 'query': query, 'candidateCount': len(result['results']),
- 'sha256': result['receipt']['sha256']})
- # Prefer text articles and actual experiences, excluding obvious recipe/video/category pages.
- filtered = [row for row in result['results'] if not re.search(r'做法步骤|菜谱大全|怎么做好吃|视频大全|中毒|健康知识|价格表|批发价格', row['title'])]
- for row in filtered[:10]: candidates.setdefault(row['url'], row)
- except Exception as error:
- STATE['searches'].append({'topic': topic, 'query': query, 'error': type(error).__name__})
- persist()
- print(json.dumps({'stage': 'search', 'topic': topic, 'query': query, 'candidates': len(candidates)}, ensure_ascii=False), flush=True)
- with ThreadPoolExecutor(max_workers=3) as pool:
- tasks = {pool.submit(collect, row): row for row in candidates.values()}
- for task in as_completed(tasks):
- candidate = tasks[task]
- try:
- record = task.result()
- with GUARD: STATE['sources'].append(record)
- except Exception as error:
- with GUARD: STATE['rejected'].append({'candidate': candidate, 'reason': str(error) if isinstance(error, ValueError) else type(error).__name__})
- with GUARD: persist()
- print(json.dumps({'stage': 'body', 'accepted': len(STATE['sources']), 'rejected': len(STATE['rejected']),
- 'candidates': len(candidates)}, ensure_ascii=False), flush=True)
- unique = {}
- for row in STATE['sources']:
- key = row['sourceId']
- if key in unique:
- known = {item['topic'] for item in unique[key]['topics']}
- unique[key]['topics'].extend(item for item in row['topics'] if item['topic'] not in known)
- else: unique[key] = row
- STATE['sources'] = list(unique.values())
- STATE['status'] = 'complete'
- STATE['finishedAt'] = now()
- STATE['uniqueSources'] = len(STATE['sources'])
- STATE['countsByTopic'] = {topic['id']: sum(any(item['topic'] == topic['id'] for item in row['topics']) for row in STATE['sources']) for topic in PLAN['topics']}
- STATE['databaseImported'] = 0
- persist()
- print(json.dumps({'status': STATE['status'], 'uniqueSources': STATE['uniqueSources'],
- 'countsByTopic': STATE['countsByTopic'], 'databaseImported': 0}, ensure_ascii=False), flush=True)
- if __name__ == '__main__': main()
|