export-industry-snapshot.py 3.8 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889
  1. """Export the existing library through its read-only API, never an upstream collector."""
  2. import argparse
  3. from concurrent.futures import ThreadPoolExecutor, as_completed
  4. from datetime import datetime, timezone
  5. import hashlib
  6. import json
  7. from pathlib import Path
  8. from urllib.request import Request, urlopen
  9. ENTITIES = ['products', 'reviews', 'prices', 'specs', 'documents', 'clauses', 'rankings', 'coverage']
  10. def main():
  11. p = argparse.ArgumentParser(description=__doc__)
  12. p.add_argument('--url', default='https://kangshifu.fmode.cn/api/library')
  13. p.add_argument('--out', type=Path, required=True)
  14. args = p.parse_args()
  15. if args.out.exists():
  16. raise ValueError('NEW_OUTPUT_REQUIRED')
  17. def read(payload):
  18. request = Request(args.url, json.dumps(payload).encode(), {'Content-Type': 'application/json'})
  19. with urlopen(request, timeout=45) as response:
  20. data = json.load(response)
  21. if not data.get('ok'):
  22. raise ValueError('READ_FAILED:' + str(data.get('error')))
  23. return data
  24. summary = read({'op': 'summary'})
  25. generation = summary['generation']
  26. def bound(payload):
  27. data = read({**payload, 'generation': generation})
  28. if data['generation'] != generation:
  29. raise ValueError('GENERATION_CHANGED')
  30. return data
  31. def export(entity):
  32. records, cursor, seen, pages = [], None, set(), 0
  33. total = None
  34. while True:
  35. request = {'op': 'browse', 'entity': entity, 'quality': 'audit', 'limit': 100}
  36. if cursor:
  37. request['cursor'] = cursor
  38. data = bound(request)
  39. if total is None:
  40. total = data['totalMatched']
  41. if total != data['totalMatched']:
  42. raise ValueError('COUNT_CHANGED')
  43. records.extend(data['records'])
  44. pages += 1
  45. cursor = data.get('nextCursor')
  46. if not cursor:
  47. break
  48. if cursor in seen or pages >= 500:
  49. raise ValueError('CURSOR_LOOP_OR_EXPORT_TOO_LARGE')
  50. seen.add(cursor)
  51. if len(records) != total or len({r['record_id'] for r in records}) != total:
  52. raise ValueError('INCOMPLETE_DATABASE_EXPORT:' + entity)
  53. print(json.dumps({'entity': entity, 'records': total, 'pages': pages}), flush=True)
  54. return entity, records, pages
  55. rows, page_counts = {}, {}
  56. with ThreadPoolExecutor(max_workers=3) as pool:
  57. for job in as_completed([pool.submit(export, entity) for entity in ENTITIES]):
  58. entity, records, pages = job.result()
  59. rows[entity], page_counts[entity] = records, pages
  60. for entity in ('documents', 'clauses', 'rankings'):
  61. def detail(row):
  62. data = bound({'op': 'detail', 'entity': entity, 'id': row['record_id']})
  63. return {**row, **data['record']}
  64. with ThreadPoolExecutor(max_workers=3) as pool:
  65. rows[entity] = list(pool.map(detail, rows[entity]))
  66. bound({'op': 'version'})
  67. snapshot = {'schema': 'ksf.industry-snapshot.v1', 'generation': generation,
  68. 'exportedAt': datetime.now(timezone.utc).isoformat(), 'summary': summary,
  69. 'entities': rows, 'pageCounts': page_counts,
  70. 'scope': 'complete existing database projection, not complete upstream platforms',
  71. 'paidCalls': 0, 'productionWrites': 0}
  72. args.out.parent.mkdir(parents=True, exist_ok=True)
  73. with args.out.open('x', encoding='utf-8') as f:
  74. json.dump(snapshot, f, ensure_ascii=False, separators=(',', ':'))
  75. print(json.dumps({'ok': True, 'generation': generation, 'file': str(args.out),
  76. 'bytes': args.out.stat().st_size,
  77. 'sha256': hashlib.sha256(args.out.read_bytes()).hexdigest()}, ensure_ascii=False))
  78. if __name__ == '__main__':
  79. main()