| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889 |
- """Export the existing library through its read-only API, never an upstream collector."""
- import argparse
- from concurrent.futures import ThreadPoolExecutor, as_completed
- from datetime import datetime, timezone
- import hashlib
- import json
- from pathlib import Path
- from urllib.request import Request, urlopen
- ENTITIES = ['products', 'reviews', 'prices', 'specs', 'documents', 'clauses', 'rankings', 'coverage']
- def main():
- p = argparse.ArgumentParser(description=__doc__)
- p.add_argument('--url', default='https://kangshifu.fmode.cn/api/library')
- p.add_argument('--out', type=Path, required=True)
- args = p.parse_args()
- if args.out.exists():
- raise ValueError('NEW_OUTPUT_REQUIRED')
- def read(payload):
- request = Request(args.url, json.dumps(payload).encode(), {'Content-Type': 'application/json'})
- with urlopen(request, timeout=45) as response:
- data = json.load(response)
- if not data.get('ok'):
- raise ValueError('READ_FAILED:' + str(data.get('error')))
- return data
- summary = read({'op': 'summary'})
- generation = summary['generation']
- def bound(payload):
- data = read({**payload, 'generation': generation})
- if data['generation'] != generation:
- raise ValueError('GENERATION_CHANGED')
- return data
- def export(entity):
- records, cursor, seen, pages = [], None, set(), 0
- total = None
- while True:
- request = {'op': 'browse', 'entity': entity, 'quality': 'audit', 'limit': 100}
- if cursor:
- request['cursor'] = cursor
- data = bound(request)
- if total is None:
- total = data['totalMatched']
- if total != data['totalMatched']:
- raise ValueError('COUNT_CHANGED')
- records.extend(data['records'])
- pages += 1
- cursor = data.get('nextCursor')
- if not cursor:
- break
- if cursor in seen or pages >= 500:
- raise ValueError('CURSOR_LOOP_OR_EXPORT_TOO_LARGE')
- seen.add(cursor)
- if len(records) != total or len({r['record_id'] for r in records}) != total:
- raise ValueError('INCOMPLETE_DATABASE_EXPORT:' + entity)
- print(json.dumps({'entity': entity, 'records': total, 'pages': pages}), flush=True)
- return entity, records, pages
- rows, page_counts = {}, {}
- with ThreadPoolExecutor(max_workers=3) as pool:
- for job in as_completed([pool.submit(export, entity) for entity in ENTITIES]):
- entity, records, pages = job.result()
- rows[entity], page_counts[entity] = records, pages
- for entity in ('documents', 'clauses', 'rankings'):
- def detail(row):
- data = bound({'op': 'detail', 'entity': entity, 'id': row['record_id']})
- return {**row, **data['record']}
- with ThreadPoolExecutor(max_workers=3) as pool:
- rows[entity] = list(pool.map(detail, rows[entity]))
- bound({'op': 'version'})
- snapshot = {'schema': 'ksf.industry-snapshot.v1', 'generation': generation,
- 'exportedAt': datetime.now(timezone.utc).isoformat(), 'summary': summary,
- 'entities': rows, 'pageCounts': page_counts,
- 'scope': 'complete existing database projection, not complete upstream platforms',
- 'paidCalls': 0, 'productionWrites': 0}
- args.out.parent.mkdir(parents=True, exist_ok=True)
- with args.out.open('x', encoding='utf-8') as f:
- json.dump(snapshot, f, ensure_ascii=False, separators=(',', ':'))
- print(json.dumps({'ok': True, 'generation': generation, 'file': str(args.out),
- 'bytes': args.out.stat().st_size,
- 'sha256': hashlib.sha256(args.out.read_bytes()).hexdigest()}, ensure_ascii=False))
- if __name__ == '__main__':
- main()
|