enrich-research-library.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. """Build traceable enrichment batches from byte-preserved VOC responses."""
  2. import argparse
  3. from collections import Counter
  4. import hashlib
  5. import json
  6. from pathlib import Path
  7. import re
  8. import sys
  9. ROOT = Path(__file__).resolve().parents[1]
  10. sys.path.insert(0, str(ROOT / 'server/voc-library'))
  11. WORK = ROOT / 'work/database-enrichment-20261110'
  12. def read(p): return json.loads(Path(p).read_bytes())
  13. def sha(b): return hashlib.sha256(b).hexdigest()
  14. def save(p, value):
  15. p = Path(p); p.parent.mkdir(parents=True, exist_ok=True)
  16. p.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding='utf-8')
  17. def filename(s): return re.sub(r'[^a-zA-Z0-9_-]', '_', s)
  18. def payload(doc):
  19. outer = doc.get('data', {})
  20. if not isinstance(outer, dict) or outer.get('code', 0) not in (0, 200):
  21. raise ValueError('UPSTREAM_BUSINESS_ERROR')
  22. return outer.get('data', outer), outer.get('recordTime')
  23. def reviews(doc, family, item):
  24. d, observed = payload(doc)
  25. if not isinstance(d, dict): raise ValueError('REVIEW_PAYLOAD_NOT_OBJECT')
  26. rows = d.get('data' if family == 'jd' else 'comments', [])
  27. if not isinstance(rows, list): raise ValueError('REVIEW_ARRAY_REQUIRED')
  28. accepted, rejected, seen = [], Counter(), set()
  29. for i, r in enumerate(rows):
  30. actual = r.get('productId') if family == 'jd' else r.get('auction', {}).get('aucNumId')
  31. rid = r.get('commentId' if family == 'jd' else 'rateId')
  32. if str(actual) != item:
  33. rejected['REVIEW_PRODUCT_ID_MISMATCH'] += 1; continue
  34. if type(rid) not in (str, int) or not str(rid):
  35. rejected['REVIEW_ID_MISSING'] += 1; continue
  36. if str(rid) in seen: rejected['REPEATED_REVIEW_ID_IN_PAGE'] += 1; continue
  37. seen.add(str(rid))
  38. content = r.get('commentData' if family == 'jd' else 'content')
  39. if not isinstance(content, str) or not content.strip():
  40. rejected['EMPTY_REVIEW'] += 1; continue
  41. template = bool(re.fullmatch(r'(该用户觉得商品非常好,给出5星好评|此用户未填写评价内容|此用户没有填写评价|此用户未填写评价|此用户未及时填写评价内容,系统默认好评[!!。]?)', content.strip()))
  42. accepted.append({'itemId': item, 'reviewId': str(rid), 'content': content,
  43. 'appendContent': r.get('afterComment') if family == 'jd' else r.get('appendList') or r.get('append'),
  44. 'reviewDate': r.get('commentDate' if family == 'jd' else 'date'),
  45. 'rating': r.get('commentScore' if family == 'jd' else 'rate'),
  46. 'ratingScale': '1-5' if family == 'jd' else None,
  47. 'purchasedSku': r.get('productColor') if family == 'jd' else r.get('auction', {}).get('sku'),
  48. 'isDefault': True if template else None, 'sourceTime': observed,
  49. 'originPointer': '/data/data/' + ('data/' if family == 'jd' else 'comments/') + str(i)})
  50. return accepted, rejected
  51. def detail(doc, family, item):
  52. d, observed = payload(doc); specs = []; price = None
  53. if not isinstance(d, dict): raise ValueError('DETAIL_PAYLOAD_NOT_OBJECT')
  54. p = d.get('product' if family == 'jd' else 'item', {})
  55. if not isinstance(p, dict): raise ValueError('DETAIL_PRODUCT_NOT_OBJECT')
  56. if str(p.get('skuId' if family == 'jd' else 'itemId')) != item:
  57. raise ValueError('DETAIL_PRODUCT_ID_MISMATCH')
  58. if family == 'jd':
  59. parts = [(k, p[k]) for k in ('color', 'size', 'spec') if isinstance(p.get(k), str) and p[k].strip()]
  60. if parts:
  61. specs.append({'itemId': item, 'skuId': item, 'specText': ';'.join(v for k, v in parts),
  62. 'verification': 'source_attribute',
  63. 'basis': '当前SKU销售属性原文;字段=' + ','.join(k for k, v in parts) + ';不将运输重量或标题推断为净含量。',
  64. 'originPointers': ['/data/data/product/' + k for k, v in parts]})
  65. amount = d.get('priceFloor', {}).get('price')
  66. basis = 'VOC jd.get_item_detail_v1 priceFloor.price 页面报价;活动、地区与登录条件见原始响应'
  67. else:
  68. base = d.get('skuBase') or {}; props = {}
  69. for prop in base.get('props', []):
  70. for v in prop.get('values', []): props[str(prop.get('pid')) + ':' + str(v.get('vid'))] = (prop.get('name'), v.get('name'))
  71. for i, sku in enumerate(base.get('skus', [])):
  72. tokens = str(sku.get('propPath', '')).split(';')
  73. if not sku.get('skuId') or not tokens or any(t not in props for t in tokens): continue
  74. text = ';'.join(str(props[t][0]) + ':' + str(props[t][1]) for t in tokens)
  75. specs.append({'itemId': item, 'skuId': str(sku['skuId']), 'specText': text,
  76. 'verification': 'source_attribute', 'basis': 'skuBase.skus.propPath逐项连接props原始属性;不同SKU分别存储,不将备选口味合并为一个SKU。',
  77. 'originPointers': ['/data/data/skuBase/skus/' + str(i), '/data/data/skuBase/props']})
  78. amount = p.get('price'); basis = 'VOC taobao.get_item_detail_v6 item.price 商品级展示价;非特定SKU成交价'
  79. if any(t in str(p.get('title', '')) for t in ('积分', '定金', '补差', '运费')): amount = None
  80. if type(amount) in (str, float, int) and re.fullmatch(r'\d+(\.\d{1,2})?', str(amount)) and float(amount) > 0:
  81. price = {'itemId': item, 'amount': str(amount), 'currency': 'CNY', 'priceBasis': basis, 'sourceTime': observed}
  82. return specs, price
  83. def social_body(doc, platform, iid):
  84. d, _ = payload(doc)
  85. if platform == 'bilibili':
  86. # Only the primary video object is eligible. Recommendations, playlists
  87. # and comments can repeat the requested ID and are not author body text.
  88. if isinstance(d, dict) and d.get('message') and not d.get('bvid') and not d.get('videoData'):
  89. raise ValueError('UPSTREAM_DETAIL_UNAVAILABLE')
  90. note = d.get('videoData', d) if isinstance(d, dict) else None
  91. if not isinstance(note, dict) or note.get('bvid') != iid:
  92. raise ValueError('SOCIAL_ID_MISMATCH')
  93. text = note.get('desc') or ''.join(x.get('raw_text', '') for x in note.get('desc_v2') or [] if isinstance(x, dict))
  94. title = note.get('title', '')
  95. elif platform == 'xiaohongshu':
  96. notes = []
  97. if isinstance(d, dict):
  98. notes = d.get('note_list', [d])
  99. elif isinstance(d, list):
  100. for entry in d:
  101. if isinstance(entry, dict): notes.extend(entry.get('note_list', [entry]))
  102. found = [n for n in notes if isinstance(n, dict) and str(n.get('id') or n.get('note_id')) == iid]
  103. if not found: raise ValueError('SOCIAL_ID_MISMATCH')
  104. # Two conflicting primary objects require review, not a longest-text guess.
  105. if len({(n.get('desc') or '', n.get('title') or '') for n in found}) > 1:
  106. raise ValueError('SOCIAL_PRIMARY_OBJECT_CONFLICT')
  107. note = found[0]
  108. text, title = note.get('desc') or '', note.get('title') or ''
  109. else: raise ValueError('SOCIAL_PLATFORM_UNSUPPORTED')
  110. if not isinstance(text, str) or not isinstance(title, str): raise ValueError('SOCIAL_TEXT_TYPE_INVALID')
  111. if not text.strip(): raise ValueError('UPSTREAM_BODY_EMPTY')
  112. norm = lambda s: re.sub(r'\W', '', s)
  113. if not norm(text) or text.strip().lower() in ('null', 'undefined', 'none', '暂无简介', '暂无描述', '无简介'):
  114. raise ValueError('UPSTREAM_BODY_PLACEHOLDER')
  115. if norm(text) == norm(title): raise ValueError('BODY_ONLY_REPEATS_TITLE')
  116. return text
  117. def build(pilot=False, selected=None, name='pilot'):
  118. import library_channel
  119. import structured_batch
  120. snapshot = read(ROOT / 'work/industry-monitoring-20261107-r1/snapshot.json')
  121. products = {r['record_id']: r for r in snapshot['entities']['products']}
  122. queue = read(WORK / 'product-queue.json')
  123. if pilot:
  124. ids = [p['productId'] for p in queue if p['platform'] == 'jd'][:3] + [p['productId'] for p in queue if p['platform'] != 'jd'][:3]
  125. else: ids = selected or [p['productId'] for p in queue]
  126. directory = WORK / 'batches' / name
  127. if (directory / 'transport.zip').exists(): raise ValueError('BATCH_ALREADY_SEALED')
  128. batch = {'schema': 'ksf.voc-agent-batch.v2', 'batchId': 'ksf-enrichment-20261110-' + name,
  129. 'producer': 'Codex: VOC 0.3.44 exact-ID enrichment; preserved upstream bytes and explicit mapping adapters',
  130. 'sources': [], 'attachments': [], 'products': [], 'reviews': [], 'specs': [], 'prices': [], 'social': []}
  131. report = {'baselineGeneration': snapshot['generation'], 'selectedProducts': len(ids), 'accepted': Counter(), 'excluded': Counter(), 'products': []}
  132. seen_reviews = set(); adapters = {}; originals = {}
  133. for pid in ids:
  134. p = products[pid]; family, iid = pid.split(':', 1)
  135. marketplace = 'jd' if family == 'jd' else 'tmall' if p['platform'] == 'tmall' else 'taobao_unverified'
  136. group = marketplace
  137. adapter = adapters.setdefault(group, {'kind': 'explicit_mapping_adapter_not_upstream_response',
  138. 'context': {'kind': 'existing_library_product_context_not_new_observation', 'generation': snapshot['generation'], 'exportedAt': snapshot['exportedAt']},
  139. 'products': [], 'reviews': [], 'specs': [], 'prices': []})
  140. accepted = Counter()
  141. for endpoint in (('jd.get_item_detail_v1' if family == 'jd' else 'taobao.get_item_detail_v6'), ('jd.get_item_comments_v1' if family == 'jd' else 'taobao.get_item_comment_v3')):
  142. prefix = WORK / 'collected' / (filename(pid) + '--' + filename(endpoint))
  143. mp, rp = Path(str(prefix) + '.meta.json'), Path(str(prefix) + '.response.json')
  144. if not mp.exists() or not rp.exists(): continue
  145. meta = read(mp); raw = rp.read_bytes()
  146. if not meta.get('ok'): report['excluded']['REQUEST_FAILED'] += 1; continue
  147. if meta['sha256'] != sha(raw): raise ValueError('ORIGINAL_RESPONSE_HASH_MISMATCH')
  148. doc = json.loads(raw)
  149. origin = {'path': 'raw/original/' + rp.name, 'sha256': sha(raw), 'endpoint': endpoint,
  150. 'requestParams': meta['params'], 'collectedAt': meta['finishedAt']}
  151. try:
  152. if 'comment' in endpoint:
  153. rows, rejects = reviews(doc, family, iid); report['excluded'].update(rejects)
  154. for r in rows:
  155. key = pid + ':' + r['reviewId']
  156. if key in seen_reviews: continue
  157. seen_reviews.add(key); adapter['reviews'].append({**r, 'origin': origin}); accepted['reviews'] += 1
  158. else:
  159. specs, price = detail(doc, family, iid)
  160. for r in specs: adapter['specs'].append({**r, 'origin': origin}); accepted['specs'] += 1
  161. if price: adapter['prices'].append({**price, 'origin': origin}); accepted['prices'] += 1
  162. originals[origin['path']] = raw
  163. except ValueError as e: report['excluded'][str(e)] += 1
  164. if accepted:
  165. adapter['products'].append({'itemId': iid, 'title': p['title'], 'shopId': p.get('shop_id'), 'shopName': p.get('shop_name'),
  166. 'sourceTime': p.get('source_time_raw'), 'originRecordId': pid})
  167. report['products'].append({'id': pid, **accepted}); report['accepted'].update(accepted)
  168. from intake_contract import ENTITIES
  169. for group, adapter in adapters.items():
  170. if not adapter['products']: continue
  171. rel = 'raw/adapter-' + group + '.json'; save(directory / rel, adapter)
  172. sid = 'adapter-' + group
  173. batch['sources'].append({'id': sid, 'path': rel, 'sha256': sha((directory / rel).read_bytes()),
  174. 'platform': 'jd' if group == 'jd' else 'taobao', 'marketplace': group})
  175. for entity in ('products', 'reviews', 'specs', 'prices'):
  176. for i, row in enumerate(adapter[entity]):
  177. batch[entity].append({'sourceId': sid, 'pointer': '/' + entity + '/' + str(i),
  178. 'fields': {k: '/' + k for k in row if k in ENTITIES[entity][2]}})
  179. for rel, raw in originals.items():
  180. p = directory / rel; p.parent.mkdir(parents=True, exist_ok=True); p.write_bytes(raw)
  181. batch['attachments'].append({'path': rel, 'sha256': sha(raw)})
  182. save(directory / 'batch.json', batch)
  183. if not batch['products']:
  184. save(directory / 'receipt.json', {**report, 'batchId': batch['batchId'], 'empty': True})
  185. print(json.dumps({'empty': True, 'selectedProducts': len(ids)})); return
  186. check = structured_batch.build(directory / 'batch.json', directory / 'staging.sqlite', False)
  187. packed, digest = library_channel.pack(directory / 'batch.json')
  188. (directory / 'transport.zip').write_bytes(packed)
  189. receipt = {**report, 'validation': check, 'archiveSha256': digest, 'archiveBytes': len(packed), 'batchId': batch['batchId'],
  190. 'originalResponses': len(originals), 'originalBytes': sum(map(len, originals.values()))}
  191. save(directory / 'receipt.json', receipt)
  192. print(json.dumps(receipt, ensure_ascii=False))
  193. if __name__ == '__main__':
  194. p = argparse.ArgumentParser(); p.add_argument('--pilot', action='store_true'); p.add_argument('--selection'); p.add_argument('--name', default='pilot')
  195. a = p.parse_args(); build(a.pilot, read(a.selection) if a.selection else None, a.name)