expand-product-review-coverage.py 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
  1. """Plan a bounded second round of missing-product review collection.
  2. Uses existing production counts and original historical volume evidence, never
  3. joins different links by a similar title. Does not collect or publish itself.
  4. """
  5. import argparse
  6. from collections import Counter, defaultdict
  7. import hashlib
  8. import json
  9. from pathlib import Path
  10. import re
  11. import sys
  12. import zipfile
  13. ROOT = Path(__file__).resolve().parents[1]
  14. WORK = ROOT / 'work/product-reviews/round2'
  15. sys.path.insert(0, str(ROOT / 'server/voc-library'))
  16. import agent_batch
  17. def write(path, value):
  18. path.parent.mkdir(parents=True, exist_ok=True)
  19. with path.open('x', encoding='utf-8', newline='\n') as f:
  20. json.dump(value, f, ensure_ascii=False, indent=2)
  21. f.write('\n')
  22. def displayed_count(value):
  23. match = re.search(r'\d+(?:\.\d+)?', str(value))
  24. return float(match[0]) * (10000 if '万' in str(value) else 1) if match else 0
  25. def plan():
  26. catalog = json.loads((WORK / 'catalog.json').read_text(encoding='utf-8'))
  27. volumes = json.loads((ROOT / 'work/product-reviews/volume-evidence-r1.json').read_text(encoding='utf-8'))
  28. prior = []
  29. for p in (ROOT / 'work/product-reviews').glob('live-*/*/reservation.json'):
  30. r = json.loads(p.read_text(encoding='utf-8'))
  31. prior.append((r['endpoint'].split('.')[0], r['params']['itemId']))
  32. skip = set(prior)
  33. pools = defaultdict(list)
  34. for r in catalog['records']:
  35. pid = r['record_id']
  36. family, iid = pid.split(':')
  37. evidence = volumes.get(pid)
  38. if r.get('relatedCounts', {}).get('reviews') or (family, iid) in skip or not evidence:
  39. continue
  40. if displayed_count(evidence['countText']) < 10000 or r.get('category') not in (
  41. 'banmian', 'soup_noodle', 'instant_unspecified', 'mianpi', 'reganmian', 'fried_noodle'):
  42. continue
  43. pools[r.get('brand')].append({'productId': pid, 'title': r['title'], 'brand': r.get('brand'),
  44. 'category': r.get('category'), 'shopId': r.get('shop_id'), 'marketplace': r['platform'],
  45. 'volumeEvidence': evidence, 'storedReviews': 0, 'page': '1' if family == 'jd' else 1,
  46. 'endpoint': 'jd.get_item_comments_v1' if family == 'jd' else 'taobao.get_item_comment_v3'})
  47. quotas = {'ksf': 26, 'tongyi': 6, 'baixiang': 6, 'jinmailang': 6,
  48. 'riqing': 5, 'nongxin': 4, 'akuan': 4, 'wugudaochang': 3}
  49. selected_by_brand = {}
  50. for brand, quota in quotas.items():
  51. candidates = sorted(pools[brand], key=lambda r: displayed_count(r['volumeEvidence']['countText']), reverse=True)
  52. chosen = []
  53. # Spread shop/category buckets while preserving volume priority within
  54. # each bucket; exact identity is retained for every candidate.
  55. buckets = defaultdict(list)
  56. for r in candidates:
  57. buckets[(r['shopId'], r['category'])].append(r)
  58. while buckets and len(chosen) < quota:
  59. for key in list(buckets):
  60. if len(chosen) >= quota:
  61. break
  62. chosen.append(buckets[key].pop(0))
  63. if not buckets[key]:
  64. del buckets[key]
  65. selected_by_brand[brand] = chosen
  66. selected = []
  67. # Interleave brands to learn actual access before expanding the full plan.
  68. while any(selected_by_brand.values()):
  69. for brand in quotas:
  70. if selected_by_brand[brand]:
  71. selected.append(selected_by_brand[brand].pop(0))
  72. if len(selected) > 60:
  73. raise ValueError('SIXTY_PRODUCT_LIMIT')
  74. original = Path('F:/文档/康师傅VOC_全部已采集数据_20261105_191821.zip')
  75. archive_sha = agent_batch.digest(original)
  76. evidence_dir = WORK / 'volume-sources'
  77. with zipfile.ZipFile(original) as archive:
  78. for r in selected:
  79. member = r['volumeEvidence']['sourceMember']
  80. raw = archive.read(member)
  81. name = hashlib.sha256(member.encode()).hexdigest()[:20] + '.json'
  82. target = evidence_dir / name
  83. target.parent.mkdir(parents=True, exist_ok=True)
  84. if not target.exists():
  85. target.write_bytes(raw)
  86. payload = json.loads(raw)
  87. iid = r['productId'].split(':')[1]
  88. matches = []
  89. def walk(node, pointer=''):
  90. if isinstance(node, dict):
  91. if any(str(node.get(k)) == iid for k in ('itemId', 'id', 'skuId')) and str(node.get('cc')) == r['volumeEvidence']['countText']:
  92. matches.append(pointer)
  93. for k, v in node.items():
  94. if isinstance(v, (dict, list)):
  95. walk(v, pointer + '/' + k.replace('~', '~0').replace('/', '~1'))
  96. elif isinstance(node, list):
  97. for i, v in enumerate(node):
  98. walk(v, pointer + '/' + str(i))
  99. walk(payload)
  100. if not matches:
  101. raise ValueError('VOLUME_EVIDENCE_POINTER_NOT_VERIFIED:' + r['productId'])
  102. r['volumeEvidence'].update({'originalArchiveSha256': archive_sha, 'file': 'volume-sources/' + name,
  103. 'sha256': hashlib.sha256(raw).hexdigest(), 'pointer': matches[0] + '/cc',
  104. 'scope': 'historical_platform_display_not_current_exact_count'})
  105. result = {'schema': 'ksf.review-coverage-plan.v1', 'round': '20261106-r2', 'generation': catalog['generation'],
  106. 'products': len(catalog['records']), 'productsWithReviews': sum(bool(r.get('relatedCounts', {}).get('reviews')) for r in catalog['records']),
  107. 'candidates': selected, 'candidateCount': len(selected), 'maxNewGatewayRequests': len(selected),
  108. 'maxNewCostCny': round(len(selected) * .30, 2), 'unitPriceCny': .30, 'retries': 0,
  109. 'priorTaskConservativeReservationCny': 9.90, 'firstReviewRoundConfirmedAndReservedCny': 2.10,
  110. 'sharedTaskBudgetCny': 50, 'worstCaseOccupiedCny': round(9.90 + 2.10 + len(selected) * .30, 2),
  111. 'brandCounts': dict(Counter(r['brand'] for r in selected)), 'maxReviewsPerProduct': 20,
  112. 'selection': 'missing reviews; historical display >=10000; own brand priority; shop/category spread',
  113. 'noGlobalLikesSortClaim': True, 'productionImported': False}
  114. assert result['worstCaseOccupiedCny'] <= 50
  115. write(WORK / 'plan-expanded.json', result)
  116. print(json.dumps({k: v for k, v in result.items() if k != 'candidates'}, ensure_ascii=False, indent=2))
  117. if __name__ == '__main__':
  118. parser = argparse.ArgumentParser(description=__doc__)
  119. parser.add_argument('command', choices=['plan'])
  120. args = parser.parse_args()
  121. if args.command == 'plan':
  122. plan()