collect-first-live-sources.py 7.1 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283
  1. """Capture two official noodle pages and build a bounded, source-backed import batch."""
  2. from pathlib import Path
  3. from html.parser import HTMLParser
  4. from datetime import datetime, timezone
  5. from urllib.request import Request, urlopen
  6. import hashlib
  7. import json
  8. import re
  9. ROOT = Path(__file__).resolve().parents[1]
  10. OUT = ROOT / 'work/first-live-sources-20261102'
  11. TASK_KEY = 'KSF-真实资料-20261102-01'
  12. class Text(HTMLParser):
  13. def __init__(self):
  14. super().__init__(convert_charrefs=True)
  15. self.skip = 0
  16. self.parts = []
  17. def handle_starttag(self, tag, attrs):
  18. if tag in ('script', 'style'):
  19. self.skip += 1
  20. def handle_endtag(self, tag):
  21. if tag in ('script', 'style'):
  22. self.skip = max(0, self.skip - 1)
  23. def handle_data(self, data):
  24. if not self.skip:
  25. self.parts.append(data)
  26. def dump(file, value):
  27. file.write_text(json.dumps(value, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
  28. def main():
  29. OUT.mkdir(parents=True, exist_ok=False)
  30. category = {'dataMode': 'live', 'code': 'KSF-RESEARCH-INSTANT-MIXED-NOODLES-V1', 'name': '速食拌面(研究工作分类)', 'taxonomyVersion': 'ksf-research-food-form-v1', 'axis': '食用方式', 'scopeNote': '研究者按官网明确标为拌面/酱拌面的产品设定工作分类,不是官方行业分类;本批不含汤面、传统现制面或未知食用方式产品。'}
  31. specs = [
  32. {'key': 'uni-jiangban', 'url': 'https://www.uni-president.com.cn/products-jiangbanmian.asp', 'title': '统一官网:统一酱拌面', 'family': '统一酱拌面', 'marker': '统一酱拌面 酱浓才够味', 'flavors': ['老干妈豆豉辣酱风味', '卤香牛肉风味', '老坛酸菜牛肉风味', 'XO酱海鲜风味'], 'packaging': '碗、五合一(袋*5)'},
  33. {'key': 'uni-qiaomianguan', 'url': 'https://www.uni-president.com.cn/products-qiaomianguan.asp', 'title': '统一官网:巧面馆(仅拌面部分)', 'family': '统一巧面馆拌面', 'marker': '统一巧面馆(拌面)', 'flavors': ['老坛酸菜酱拌面', '老成都担担面', '重庆豌豆杂酱风味面', '串串香干碟拌面', '贵州豆豉辣酱风味面'], 'packaging': '碗、袋、五合一(袋*5)'}
  34. ]
  35. batches = []
  36. facts = []
  37. for spec in specs:
  38. with urlopen(Request(spec['url'], headers={'User-Agent': 'KSF-Workshop-Research/1.0'}), timeout=25) as response:
  39. assert response.status == 200
  40. assert response.url == spec['url'], 'unexpected redirect'
  41. raw = response.read(2_000_001)
  42. assert len(raw) <= 2_000_000
  43. content_type = response.headers.get('Content-Type', '')
  44. collected = datetime.now(timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z')
  45. html = raw.decode('gb18030', errors='strict')
  46. parser = Text()
  47. parser.feed(html)
  48. text = re.sub(r'\s+', ' ', ' '.join(parser.parts)).strip()
  49. assert spec['marker'] in text
  50. section = text[text.index(spec['marker']):]
  51. quote_match = re.search(r'产品口味:.*?包装规格:[^ ]+', section)
  52. assert quote_match, 'flavor/packaging block missing'
  53. quote = quote_match.group(0)
  54. assert all(flavor in quote for flavor in spec['flavors']), 'official flavor list changed'
  55. assert 'sample' not in category.values()
  56. (OUT / (spec['key'] + '.response.bin')).write_bytes(raw)
  57. (OUT / (spec['key'] + '.html')).write_text(html, encoding='utf-8')
  58. (OUT / (spec['key'] + '.txt')).write_text(text, encoding='utf-8')
  59. source = {'title': spec['title'], 'sourceKind': 'web', 'sourceUrl': spec['url'], 'platform': 'official_website', 'dataMode': 'live', 'rawText': text, 'collectedAt': collected}
  60. records = [{'itemKey': 'category', 'className': 'KfCategory', 'externalKey': 'KSF-RESEARCH-INSTANT-MIXED-NOODLES-V1', 'data': category}]
  61. for index, flavor in enumerate(spec['flavors'], 1):
  62. item = 'product-' + str(index)
  63. name = spec['family'] + '·' + flavor
  64. product = {'dataMode': 'live', 'name': name, 'brandName': '统一', 'categoryRef': {'$item': 'category'}, 'flavor': flavor, 'packaging': spec['packaging'], 'isOwnBrand': False, 'productLevel': 'product', 'attributes': {'sourceStatement': '官网品牌自述;不等同于在售或第三方验证', 'productGranularity': '口味条目,不展开为SKU,不假定各口味拥有全部包装组合', 'pagePublicationDate': None}}
  65. records.append({'itemKey': item, 'className': 'KfProduct', 'externalKey': TASK_KEY + ':' + spec['key'] + ':' + item, 'dependsOn': ['category'], 'data': product})
  66. records.append({'itemKey': 'evidence-' + str(index), 'className': 'KfEvidence', 'externalKey': TASK_KEY + ':' + spec['key'] + ':evidence-' + str(index), 'dependsOn': [item], 'data': {'dataMode': 'live', 'productRef': {'$item': item}, 'quote': quote, 'locator': {'url': spec['url'], 'section': spec['marker'], 'startCharacter': text.index(quote), 'endCharacter': text.index(quote) + len(quote), 'unit': 'Unicode code points; captured text UTF-8'}, 'evidenceKind': 'manufacturer_product_statement', 'collectedAt': collected, 'verificationState': 'page_fetched_claim_not_independently_verified', 'platform': 'official_website', 'quoteForm': 'verbatim_excerpt'}})
  67. facts.append({'name': name, 'flavor': flavor, 'packagingStatement': spec['packaging'], 'sourceUrl': spec['url']})
  68. task = {'source': source, 'records': records}
  69. dump(OUT / (spec['key'] + '.import.json'), task)
  70. capture = {'url': spec['url'], 'status': 200, 'contentType': content_type, 'encoding': 'gb18030', 'collectedAt': collected, 'responseSha256': hashlib.sha256(raw).hexdigest(), 'rawTextSha256': hashlib.sha256(text.encode()).hexdigest(), 'collector': 'Codex local urllib HTTPS GET; archived snapshot imported by workshop Agent', 'publishedAt': None}
  71. dump(OUT / (spec['key'] + '.capture.json'), capture)
  72. batches.append({'key': spec['key'], 'task': task, 'capture': capture})
  73. gaps = ['康师傅自有拌面同口径产品资料尚待补齐,暂不能作完整自有/竞品比较', '官网页面无明确发布日期,不能据此判断新品上市时间或当前在售状态', '缺单个SKU净含量、配料表、营养信息及每个口味对应包装组合', '缺渠道/店铺、实际成交或观察价格、优惠口径与价格观察时间', '缺真实消费者逐字评价、使用场景与需求强度,不从品牌文案推断用户痛点', '缺供应商报价、配方比例、工艺参数、成本与法规验证,不能推出量产结论']
  74. bundle = {'version': 'ksf.first-live-source-batch.v1', 'taskKey': TASK_KEY, 'batches': batches, 'facts': facts, 'pendingQuestions': gaps, 'collectionPerformedBy': 'Codex', 'platformScope': 'two publicly accessible official website pages; no ecommerce/social collection'}
  75. dump(OUT / 'batch.json', bundle)
  76. print(json.dumps({'ok': True, 'output': str(OUT), 'sourcePages': len(batches), 'flavorEntries': len(facts), 'evidenceEntries': len(facts), 'databaseWrites': 0}, ensure_ascii=False))
  77. if __name__ == '__main__':
  78. main()