import-product-images.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587
  1. """Recover exact product images from original archives, collect bytes, then publish.
  2. The frozen VOC SQLite schema is unchanged. KsfProductImage is an additive Parse
  3. collection containing actual JPEG bytes (base64), thumbnails and source evidence.
  4. No paid collection API is used. Credentials are read only by the publish command.
  5. """
  6. import argparse
  7. import base64
  8. import concurrent.futures
  9. import datetime as dt
  10. import hashlib
  11. import io
  12. import json
  13. import re
  14. import time
  15. import urllib.error
  16. import urllib.parse
  17. import urllib.request
  18. import zipfile
  19. from collections import Counter
  20. from pathlib import Path, PurePosixPath
  21. from PIL import Image, ImageOps
  22. CLASS = 'KsfProductImage'
  23. VERSION = 'ksf.product-images.v1'
  24. IMAGE_FIELDS = {'imageUrl', 'imageurl', 'itemPic', 'picUrlFull', 'pict_url', 'pic_url', 'mainPic', 'mainImage', 'picUrl', 'images'}
  25. ID_FIELDS = ('itemId', 'item_id', 'skuId', 'sku_id', 'num_iid', 'itemNumId', 'id')
  26. MAX_BYTES = 12 * 1024 * 1024
  27. Image.MAX_IMAGE_PIXELS = 40_000_000
  28. def sha(data):
  29. return hashlib.sha256(data).hexdigest()
  30. def write(path, data):
  31. path = Path(path)
  32. path.parent.mkdir(parents=True, exist_ok=True)
  33. tmp = path.with_suffix(path.suffix + '.tmp')
  34. tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
  35. tmp.replace(path)
  36. def request(url, body=None, headers=None, method=None):
  37. encoded = json.dumps(body).encode() if body is not None else None
  38. req = urllib.request.Request(url, data=encoded, headers={'Content-Type': 'application/json', **(headers or {})}, method=method)
  39. with urllib.request.urlopen(req, timeout=35) as response:
  40. return json.load(response)
  41. def config(path):
  42. values = {m[1].strip(): m[2].strip() for m in re.finditer(r'^\|\s*([^|]+)\s*\|\s*([^|]+)\s*\|', Path(path).read_text(encoding='utf-8'), re.M)}
  43. return values['Parse 端点'].rstrip('/'), {'X-Parse-Application-Id': values['appId'], 'X-Parse-Master-Key': values['masterKey']}
  44. def image_url(value, family):
  45. if not isinstance(value, str):
  46. return None
  47. value = value.strip()
  48. if value.startswith('jfs/') and family == 'jd':
  49. value = 'https://img14.360buyimg.com/n1/' + value
  50. elif value.startswith('//'):
  51. value = 'https:' + value
  52. parsed = urllib.parse.urlsplit(value)
  53. host = (parsed.hostname or '').lower()
  54. allowed = ('360buyimg.com',) if family == 'jd' else ('alicdn.com', 'tbcdn.cn')
  55. if parsed.scheme not in ('http', 'https') or parsed.username or parsed.password or parsed.port not in (None, 80, 443):
  56. return None
  57. if not any(host == domain or host.endswith('.' + domain) for domain in allowed):
  58. return None
  59. if not parsed.path or any(part in parsed.path.lower() for part in ('avatar', 'default.image', 'shaidan')):
  60. return None
  61. return urllib.parse.urlunsplit(('https', parsed.netloc, parsed.path, parsed.query, ''))
  62. def candidates(payload, known_ids):
  63. """Only product endpoints; identity comes from the same object or detail request."""
  64. endpoint = str(payload.get('id') or payload.get('endpoint') or '')
  65. family = endpoint.split('.')[0]
  66. if family not in ('jd', 'taobao') or not any(s in endpoint for s in ('search_item_list', 'get_shop_item_list', 'get_item_detail')):
  67. return []
  68. detail = 'get_item_detail' in endpoint
  69. params = payload.get('params') or {}
  70. fallback = str(params.get('itemId') or params.get('skuId') or '') if detail else ''
  71. timestamp = payload.get('sourceTime') or payload.get('collected_at') or payload.get('finishedAt') or ''
  72. found = []
  73. def walk(node, identity='', path='result'):
  74. if isinstance(node, list):
  75. for index, child in enumerate(node):
  76. walk(child, identity, path + '[' + str(index) + ']')
  77. elif isinstance(node, dict):
  78. own = next((str(node[k]) for k in ID_FIELDS if k in node and re.fullmatch(r'\d+', str(node[k]))), '')
  79. current = own or identity
  80. pid = family + ':' + current
  81. product_node = own or detail and path.endswith(('.product', '.item', '.itemInfoModel'))
  82. if pid in known_ids and product_node:
  83. for key in IMAGE_FIELDS:
  84. value = node.get(key)
  85. values = value if isinstance(value, list) else [value]
  86. for index, raw in enumerate(values[:8]):
  87. if isinstance(raw, dict):
  88. raw = raw.get('url') or raw.get('imageUrl')
  89. url = image_url(raw, family)
  90. if url:
  91. found.append({'productId': pid, 'sourceUrl': url, 'sourceField': path + '.' + key + (f'[{index}]' if isinstance(value, list) else ''),
  92. 'observedAt': timestamp, 'priority': 3 if detail and current == fallback else 2,
  93. 'endpoint': endpoint})
  94. for key, child in node.items():
  95. # Related products, SKU variants, reviews and seller logos are not the main image.
  96. if key.lower() in ('recommend', 'recommendations', 'recommendlist', 'comments', 'reviews', 'seller', 'shop', 'skulist', 'sku2info'):
  97. continue
  98. if isinstance(child, (list, dict)) and key not in IMAGE_FIELDS:
  99. walk(child, current, path + '.' + key)
  100. walk(payload.get('result', {}), fallback)
  101. return found
  102. def catalog(url, out):
  103. summary = request(url, {'op': 'summary'})
  104. if not summary.get('ok'):
  105. raise RuntimeError('CATALOG_SUMMARY_FAILED')
  106. rows, cursor = [], None
  107. while True:
  108. query = {'op': 'browse', 'entity': 'products', 'quality': 'audit', 'limit': 100, 'generation': summary['generation']}
  109. if cursor:
  110. query['cursor'] = cursor
  111. page = request(url, query)
  112. if not page.get('ok') or page.get('generation') != summary['generation']:
  113. raise RuntimeError('CATALOG_VERSION_CHANGED')
  114. rows.extend(page['records'])
  115. cursor = page.get('nextCursor')
  116. if not cursor:
  117. break
  118. if len({r['record_id'] for r in rows}) != summary['counts']['products']:
  119. raise RuntimeError('CATALOG_COUNT_MISMATCH')
  120. write(out / 'catalog.json', {'generation': summary['generation'], 'records': rows, 'url': url})
  121. return rows
  122. def plan(args):
  123. out = Path(args.out).resolve()
  124. rows = catalog(args.catalog_url, out)
  125. known = {r['record_id']: r for r in rows}
  126. choices, cached = {}, {}
  127. archive_path = Path(args.archive).resolve()
  128. with archive_path.open('rb') as stream:
  129. archive_sha = hashlib.file_digest(stream, 'sha256').hexdigest()
  130. with zipfile.ZipFile(archive_path) as archive:
  131. names = set(archive.namelist())
  132. # Complete this map before resolving candidates, including normalized JD paths.
  133. for name in sorted(names):
  134. if name.endswith('/media-manifest.json'):
  135. for item in json.loads(archive.read(name)).get('items', []):
  136. if item.get('localPath') and item.get('cacheStatus') == 'downloaded':
  137. path = str(PurePosixPath(name).parent / item['localPath'])
  138. if path in names:
  139. source = item.get('sourceUrl') or ''
  140. family = 'jd' if '360buyimg.com' in source else 'taobao'
  141. normalized = image_url(source, family)
  142. if normalized:
  143. cached[normalized] = path
  144. cached[urllib.parse.urlsplit(normalized).path.split('/jfs/')[-1]] = path
  145. for name in sorted(names):
  146. if not any(part.startswith('raw') for part in PurePosixPath(name).parts) or not name.endswith('.json'):
  147. continue
  148. try:
  149. payload = json.loads(archive.read(name))
  150. except (ValueError, UnicodeError):
  151. continue
  152. if not isinstance(payload, dict):
  153. continue
  154. for item in candidates(payload, known):
  155. item.update(sourceArchive=archive_sha, sourceMember=name)
  156. choices.setdefault(item['productId'], []).append(item)
  157. planned = []
  158. for pid, options in sorted(choices.items()):
  159. unique = {}
  160. for item in sorted(options, key=lambda i: (i['priority'], i['observedAt']), reverse=True):
  161. unique.setdefault(item['sourceUrl'], item)
  162. options = list(unique.values())[:8]
  163. for item in options:
  164. item['cachedMember'] = cached.get(item['sourceUrl']) or cached.get(urllib.parse.urlsplit(item['sourceUrl']).path.split('/jfs/')[-1])
  165. row = known[pid]
  166. planned.append({'productId': pid, 'title': row.get('title'), 'platform': row.get('platform'), 'candidates': options})
  167. result = {'schema': VERSION, 'archive': str(archive_path), 'archiveSha256': archive_sha, 'products': len(known),
  168. 'matchedProducts': len(planned), 'missingIds': sorted(set(known) - set(choices)), 'records': planned}
  169. write(out / 'plan.json', result)
  170. print(json.dumps({'products': len(known), 'matchedProducts': len(planned), 'missingProducts': len(result['missingIds'])}), flush=True)
  171. class CdnRedirect(urllib.request.HTTPRedirectHandler):
  172. def redirect_request(self, req, fp, code, msg, headers, newurl):
  173. family = 'jd' if '360buyimg.com' in (urllib.parse.urlsplit(req.full_url).hostname or '') else 'taobao'
  174. if not image_url(newurl, family):
  175. raise ValueError('UNSAFE_IMAGE_REDIRECT')
  176. return super().redirect_request(req, fp, code, msg, headers, newurl)
  177. def image_bytes(raw, edge):
  178. if not 0 < len(raw) <= MAX_BYTES:
  179. raise ValueError('INVALID_IMAGE_SIZE')
  180. with Image.open(io.BytesIO(raw)) as source:
  181. source.load()
  182. if min(source.size) < 120:
  183. raise ValueError('IMAGE_TOO_SMALL')
  184. image = ImageOps.exif_transpose(source).convert('RGB')
  185. image.thumbnail((edge, edge), Image.Resampling.LANCZOS)
  186. output = io.BytesIO()
  187. image.save(output, 'JPEG', quality=85 if edge > 300 else 80, optimize=True)
  188. return output.getvalue(), image.width, image.height
  189. def page_images(html, pid):
  190. """Parse JSON literals; never execute the merchant's JavaScript."""
  191. if not pid.startswith('jd:'):
  192. return []
  193. match = re.search(r'window\._itemOnly\s*=\s*\(?\s*', html)
  194. if not match:
  195. return []
  196. try:
  197. data, _ = json.JSONDecoder().raw_decode(html[match.end():])
  198. except ValueError:
  199. return []
  200. item = data.get('item') or {}
  201. if str(item.get('skuId')) != pid.split(':', 1)[1]:
  202. return []
  203. return [url for raw in (item.get('image') or [])[:4] if (url := image_url(raw, 'jd'))]
  204. def discover(args):
  205. out = Path(args.out).resolve()
  206. data = json.loads((out / 'plan.json').read_text(encoding='utf-8'))
  207. known = {r['productId'] for r in data['records']}
  208. records = {r['record_id']: r for r in json.loads((out / 'catalog.json').read_text(encoding='utf-8'))['records']}
  209. missing = [pid for pid in data['missingIds'] if pid not in known][:args.limit or None]
  210. errors = []
  211. def one(pid):
  212. if not pid.startswith('jd:'):
  213. return None, {'productId': pid, 'reason': 'NO_SAFE_PUBLIC_PAGE_ADAPTER'}
  214. url = 'https://item.m.jd.com/product/' + pid.split(':', 1)[1] + '.html'
  215. try:
  216. req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
  217. with urllib.request.urlopen(req, timeout=12) as response:
  218. if urllib.parse.urlsplit(response.url).hostname not in ('item.m.jd.com', 'item.jd.com'):
  219. return None, {'productId': pid, 'reason': 'PAGE_REDIRECTED'}
  220. raw = response.read(3 * 1024 * 1024)
  221. urls = page_images(raw.decode('utf-8', 'replace'), pid)
  222. if not urls:
  223. return None, {'productId': pid, 'reason': 'NO_VERIFIED_IMAGE_ON_PAGE'}
  224. page_path = 'pages/' + pid.replace(':', '-') + '.html'
  225. target = out / page_path
  226. target.parent.mkdir(exist_ok=True)
  227. target.write_bytes(raw)
  228. stamp = dt.datetime.now(dt.timezone.utc).isoformat()
  229. images = [{'productId': pid, 'sourceUrl': u, 'sourcePageUrl': url, 'sourcePageSha256': sha(raw),
  230. 'sourceMember': page_path, 'sourceField': 'window._itemOnly.item.image[' + str(i) + ']',
  231. 'observedAt': stamp, 'priority': 1, 'endpoint': 'public_product_page', 'cachedMember': None}
  232. for i, u in enumerate(urls)]
  233. return {'productId': pid, 'title': records[pid].get('title'), 'platform': records[pid].get('platform'), 'candidates': images}, None
  234. except Exception as error:
  235. return None, {'productId': pid, 'reason': type(error).__name__}
  236. finally:
  237. time.sleep(.15)
  238. with concurrent.futures.ThreadPoolExecutor(max_workers=min(args.workers, 4)) as pool:
  239. for index, (record, error) in enumerate(pool.map(one, missing), 1):
  240. if record:
  241. data['records'].append(record)
  242. known.add(record['productId'])
  243. if error:
  244. errors.append(error)
  245. if index % 100 == 0:
  246. data['missingIds'] = sorted(set(records) - known)
  247. data['matchedProducts'] = len(known)
  248. write(out / 'plan.json', data)
  249. write(out / 'discovery.json', {'errors': errors, 'discovered': index - len(errors)})
  250. print(json.dumps({'processed': index, 'discovered': index - len(errors)}), flush=True)
  251. data['missingIds'] = sorted(set(records) - known)
  252. data['matchedProducts'] = len(known)
  253. write(out / 'plan.json', data)
  254. write(out / 'discovery.json', {'errors': errors, 'discovered': len(missing) - len(errors), 'paidApiCalls': 0})
  255. print(json.dumps({'discovered': len(missing) - len(errors), 'remaining': len(data['missingIds'])}), flush=True)
  256. def supplement(args):
  257. out = Path(args.out).resolve()
  258. data = json.loads((out / 'plan.json').read_text(encoding='utf-8'))
  259. catalog_rows = {r['record_id']: r for r in json.loads((out / 'catalog.json').read_text(encoding='utf-8'))['records']}
  260. known = {r['productId'] for r in data['records']}
  261. archive_path = Path(args.archive).resolve()
  262. with archive_path.open('rb') as stream:
  263. archive_sha = hashlib.file_digest(stream, 'sha256').hexdigest()
  264. added = {}
  265. with zipfile.ZipFile(archive_path) as archive:
  266. names = archive.namelist()
  267. batch_name = next((n for n in names if n.endswith('/batch.json')), None)
  268. if not batch_name:
  269. for name in names:
  270. if not name.endswith('.json') or not any(part.startswith('raw') for part in PurePosixPath(name).parts):
  271. continue
  272. try:
  273. payload = json.loads(archive.read(name))
  274. except (ValueError, UnicodeError):
  275. continue
  276. if not isinstance(payload, dict):
  277. continue
  278. for item in candidates(payload, catalog_rows):
  279. if item['productId'] not in known:
  280. item.update(sourceArchive=archive_sha, sourceMember=name, cachedMember=None)
  281. added.setdefault(item['productId'], []).append(item)
  282. batch = {'sources': []}
  283. else:
  284. batch = json.loads(archive.read(batch_name))
  285. for source in batch['sources']:
  286. matches = [n for n in names if n.endswith('/' + source['path'])]
  287. if len(matches) != 1:
  288. continue
  289. raw = archive.read(matches[0])
  290. if sha(raw) != source['sha256']:
  291. raise RuntimeError('SUPPLEMENT_SOURCE_HASH_MISMATCH')
  292. normalized = json.loads(raw)
  293. original = normalized.get('original') or {}
  294. originals = [n for n in names if n.endswith('/' + original.get('path', '!'))]
  295. if len(originals) != 1:
  296. continue
  297. body = archive.read(originals[0])
  298. if sha(body) != original['sha256']:
  299. raise RuntimeError('SUPPLEMENT_ORIGINAL_HASH_MISMATCH')
  300. # The batch declares the platform; exact IDs are still required in each object.
  301. family = source['platform']
  302. wrapper = {'id': family + '.search_item_list', 'result': json.loads(body), 'sourceTime': source.get('observedAt', '')}
  303. for item in candidates(wrapper, catalog_rows):
  304. pid = item['productId']
  305. if pid in known:
  306. continue
  307. item.update(sourceArchive=archive_sha, sourceMember=originals[0], sourceResponseSha256=original['sha256'],
  308. endpoint='archived_product_response', cachedMember=None)
  309. added.setdefault(pid, []).append(item)
  310. for pid, options in sorted(added.items()):
  311. row = catalog_rows[pid]
  312. data['records'].append({'productId': pid, 'title': row.get('title'), 'platform': row.get('platform'), 'candidates': options[:8]})
  313. known.add(pid)
  314. data['missingIds'] = sorted(set(catalog_rows) - known)
  315. data['matchedProducts'] = len(known)
  316. write(out / 'plan.json', data)
  317. print(json.dumps({'added': len(added), 'remaining': len(data['missingIds'])}), flush=True)
  318. def collect(args):
  319. out = Path(args.out).resolve()
  320. plan_data = json.loads((out / 'plan.json').read_text(encoding='utf-8'))
  321. records = plan_data['records'][:args.limit or None]
  322. result_path = out / 'collected.json'
  323. previous = json.loads(result_path.read_text(encoding='utf-8')) if result_path.exists() else {'records': [], 'failures': []}
  324. saved = {r['productId']: r for r in previous['records']}
  325. failures = []
  326. opener = urllib.request.build_opener(CdnRedirect())
  327. def one(row):
  328. if row['productId'] in saved and (out / saved[row['productId']]['imagePath']).is_file():
  329. return saved[row['productId']], None
  330. attempts = []
  331. with zipfile.ZipFile(plan_data['archive']) as archive:
  332. for item in row['candidates']:
  333. try:
  334. if item.get('cachedMember'):
  335. raw = archive.read(item['cachedMember'])
  336. method = 'archive'
  337. else:
  338. download_url = item['sourceUrl']
  339. parsed = urllib.parse.urlsplit(download_url)
  340. # Old search subdomains present an invalid host certificate today.
  341. # The identical asset path is also served by Alibaba's image host.
  342. if re.fullmatch(r'g\.search\d*\.alicdn\.com', parsed.hostname or ''):
  343. download_url = urllib.parse.urlunsplit(('https', 'img.alicdn.com', parsed.path, parsed.query, ''))
  344. req = urllib.request.Request(download_url, headers={'User-Agent': 'Mozilla/5.0', 'Accept': 'image/*'})
  345. with opener.open(req, timeout=12) as response:
  346. if not response.headers.get('Content-Type', '').startswith('image/'):
  347. raise ValueError('NOT_AN_IMAGE')
  348. raw = response.read(MAX_BYTES + 1)
  349. method = 'cdn'
  350. image, width, height = image_bytes(raw, 800)
  351. thumbnail, tw, th = image_bytes(raw, 256)
  352. digest = sha(image)
  353. image_path = 'images/' + digest + '.jpg'
  354. thumbnail_path = 'thumbnails/' + digest + '.jpg'
  355. for path, body in ((image_path, image), (thumbnail_path, thumbnail)):
  356. target = out / path
  357. target.parent.mkdir(parents=True, exist_ok=True)
  358. target.write_bytes(body)
  359. return {**{k: v for k, v in row.items() if k != 'candidates'}, **item, 'method': method,
  360. 'downloadUrl': item['sourceUrl'] if method == 'archive' else download_url,
  361. 'imagePath': image_path, 'thumbnailPath': thumbnail_path, 'sha256': digest,
  362. 'sourceSha256': sha(raw), 'thumbnailSha256': sha(thumbnail), 'sizeBytes': len(image),
  363. 'width': width, 'height': height, 'thumbnailWidth': tw, 'thumbnailHeight': th,
  364. 'collectedAt': dt.datetime.now(dt.timezone.utc).isoformat()}, None
  365. except Exception as error:
  366. attempts.append({'sourceUrl': item['sourceUrl'], 'error': type(error).__name__})
  367. return None, {'productId': row['productId'], 'attempts': attempts}
  368. with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as pool:
  369. for index, (record, failure) in enumerate(pool.map(one, records), 1):
  370. if record:
  371. saved[record['productId']] = record
  372. if failure:
  373. failures.append(failure)
  374. if index % 100 == 0:
  375. write(result_path, {'schema': VERSION, 'records': list(saved.values()), 'failures': failures})
  376. print(json.dumps({'processed': index, 'stored': len(saved), 'failed': len(failures)}), flush=True)
  377. result = {'schema': VERSION, 'records': list(saved.values()), 'failures': failures,
  378. 'summary': {'products': plan_data['products'], 'stored': len(saved), 'failed': len(failures),
  379. 'noArchivedUrl': len(plan_data['missingIds']), 'methods': dict(Counter(r['method'] for r in saved.values())),
  380. 'paidApiCalls': 0}}
  381. write(result_path, result)
  382. print(json.dumps(result['summary']), flush=True)
  383. def publish(args):
  384. out = Path(args.out).resolve()
  385. data = json.loads((out / 'collected.json').read_text(encoding='utf-8'))
  386. base, headers = config(args.config)
  387. schema = {'className': CLASS, 'fields': {k: {'type': t} for k, t in {
  388. 'productId': 'String', 'title': 'String', 'platform': 'String', 'sourceUrl': 'String',
  389. 'imageBase64': 'String', 'thumbnailBase64': 'String', 'mimeType': 'String',
  390. 'sha256': 'String', 'thumbnailSha256': 'String', 'sourceSha256': 'String',
  391. 'width': 'Number', 'height': 'Number', 'sizeBytes': 'Number', 'provenance': 'Object', 'schemaVersion': 'String'}.items()},
  392. 'classLevelPermissions': {'find': {'*': True}, 'get': {'*': True}, 'count': {'*': True},
  393. 'create': {}, 'update': {}, 'delete': {}, 'addField': {}, 'protectedFields': {'*': ['provenance']}}}
  394. try:
  395. existing_schema = request(base + '/schemas/' + CLASS, headers=headers)
  396. if any(existing_schema.get('fields', {}).get(k, {}).get('type') != v['type'] for k, v in schema['fields'].items()):
  397. raise RuntimeError('EXISTING_IMAGE_SCHEMA_MISMATCH')
  398. permissions = existing_schema.get('classLevelPermissions', {})
  399. if any(permissions.get(action, {}).get('*') for action in ('create', 'update', 'delete', 'addField')):
  400. raise RuntimeError('EXISTING_IMAGE_WRITE_PERMISSIONS_UNSAFE')
  401. except urllib.error.HTTPError as error:
  402. if error.code not in (400, 404) or json.loads(error.read()).get('code') not in (103, 101):
  403. raise
  404. request(base + '/schemas/' + CLASS, schema, headers, 'POST')
  405. existing, skip = {}, 0
  406. while True:
  407. query = urllib.parse.urlencode({'limit': 1000, 'skip': skip, 'keys': 'productId,sha256,thumbnailSha256', 'order': 'objectId'})
  408. page = request(base + '/classes/' + CLASS + '?' + query, headers=headers)['results']
  409. for row in page:
  410. if row['productId'] in existing:
  411. raise RuntimeError('DUPLICATE_PRODUCT_IMAGE')
  412. existing[row['productId']] = row
  413. if len(page) < 1000:
  414. break
  415. skip += len(page)
  416. receipt_path = out / 'published.json'
  417. receipt = json.loads(receipt_path.read_text(encoding='utf-8')) if receipt_path.exists() else {'schema': VERSION, 'records': []}
  418. receipts = {r['productId']: r for r in receipt['records']}
  419. created = unchanged = 0
  420. pending = []
  421. selected = data['records'][:args.limit or None]
  422. for row in selected:
  423. pid = row['productId']
  424. old = existing.get(pid)
  425. if old:
  426. if old.get('sha256') != row['sha256'] or old.get('thumbnailSha256') != row['thumbnailSha256']:
  427. raise RuntimeError('EXISTING_IMAGE_DIFFERS:' + pid)
  428. unchanged += 1
  429. receipts[pid] = {'productId': pid, 'objectId': old['objectId'], 'sha256': row['sha256']}
  430. else:
  431. image, thumbnail = (out / row['imagePath']).read_bytes(), (out / row['thumbnailPath']).read_bytes()
  432. if sha(image) != row['sha256'] or sha(thumbnail) != row['thumbnailSha256']:
  433. raise RuntimeError('LOCAL_IMAGE_CHANGED:' + pid)
  434. body = {k: row[k] for k in ('productId', 'title', 'platform', 'sourceUrl', 'sha256', 'thumbnailSha256', 'sourceSha256', 'width', 'height', 'sizeBytes')}
  435. body.update(imageBase64=base64.b64encode(image).decode(), thumbnailBase64=base64.b64encode(thumbnail).decode(), mimeType='image/jpeg', schemaVersion=VERSION,
  436. provenance={k: row.get(k) for k in ('sourceArchive', 'sourceMember', 'sourceField', 'sourcePageUrl', 'sourcePageSha256', 'downloadUrl', 'endpoint', 'observedAt', 'collectedAt', 'method')}, ACL={'*': {'read': True}})
  437. pending.append((row, body))
  438. def send_chunk(chunk):
  439. prefix = urllib.parse.urlsplit(base).path.rstrip('/')
  440. response = request(base + '/batch', {'requests': [{'method': 'POST', 'path': prefix + '/classes/' + CLASS, 'body': body} for row, body in chunk]}, headers, 'POST')
  441. responses = response if isinstance(response, list) else response.get('results', [])
  442. if len(responses) != len(chunk):
  443. raise RuntimeError('BATCH_RESULT_UNCERTAIN_RESTART_TO_RECONCILE')
  444. successful, batch_receipts = [], []
  445. for (row, body), saved_row in zip(chunk, responses):
  446. if not saved_row.get('success', {}).get('objectId'):
  447. raise RuntimeError('BATCH_RESULT_PARTIAL_RESTART_TO_RECONCILE')
  448. oid = saved_row['success']['objectId']
  449. successful.append(oid)
  450. batch_receipts.append({'productId': row['productId'], 'objectId': oid, 'sha256': row['sha256']})
  451. query = urllib.parse.urlencode({'where': json.dumps({'objectId': {'$in': successful}}),
  452. 'keys': 'productId,imageBase64,thumbnailBase64', 'limit': 20})
  453. checked = {r['productId']: r for r in request(base + '/classes/' + CLASS + '?' + query, headers=headers)['results']}
  454. for row, body in chunk:
  455. check = checked.get(row['productId']) or {}
  456. if sha(base64.b64decode(check.get('imageBase64', ''))) != row['sha256'] or sha(base64.b64decode(check.get('thumbnailBase64', ''))) != row['thumbnailSha256']:
  457. raise RuntimeError('REMOTE_READBACK_MISMATCH:' + row['productId'])
  458. return batch_receipts
  459. chunks = [pending[offset:offset + 20] for offset in range(0, len(pending), 20)]
  460. with concurrent.futures.ThreadPoolExecutor(max_workers=min(args.workers, 3)) as pool:
  461. for batch_receipts in pool.map(send_chunk, chunks):
  462. for record in batch_receipts:
  463. receipts[record['productId']] = record
  464. created += len(batch_receipts)
  465. write(receipt_path, {'schema': VERSION, 'records': list(receipts.values()), 'createdThisRun': created, 'unchangedThisRun': unchanged})
  466. print(json.dumps({'processed': unchanged + created, 'created': created, 'unchanged': unchanged}), flush=True)
  467. result = {'schema': VERSION, 'className': CLASS, 'records': list(receipts.values()), 'createdThisRun': created, 'unchangedThisRun': unchanged,
  468. 'completedAt': dt.datetime.now(dt.timezone.utc).isoformat(), 'paidApiCalls': 0}
  469. write(receipt_path, result)
  470. print(json.dumps({'published': len(receipts), 'created': created, 'unchanged': unchanged}), flush=True)
  471. def verify(args):
  472. out = Path(args.out).resolve()
  473. local = json.loads((out / 'collected.json').read_text(encoding='utf-8'))
  474. catalog_data = json.loads((out / 'catalog.json').read_text(encoding='utf-8'))
  475. expected = {row['productId']: row for row in local['records']}
  476. ids = {row['record_id'] for row in catalog_data['records']}
  477. if set(expected) != ids:
  478. raise RuntimeError('IMAGE_CATALOG_COVERAGE_INCOMPLETE')
  479. base, headers = config(args.config)
  480. remote, duplicates, skip = {}, [], 0
  481. while True:
  482. query = urllib.parse.urlencode({'limit': 1000, 'skip': skip, 'keys': 'productId,sha256,thumbnailSha256,width,height,sizeBytes', 'order': 'objectId'})
  483. rows = request(base + '/classes/' + CLASS + '?' + query, headers=headers)['results']
  484. for row in rows:
  485. if row['productId'] in remote:
  486. duplicates.append(row['productId'])
  487. remote[row['productId']] = row
  488. if len(rows) < 1000:
  489. break
  490. skip += len(rows)
  491. if duplicates or set(remote) != ids:
  492. raise RuntimeError('REMOTE_IMAGE_COVERAGE_MISMATCH')
  493. for pid, row in remote.items():
  494. source = expected[pid]
  495. if any(source[k] != row.get(k) for k in ('sha256', 'thumbnailSha256', 'width', 'height', 'sizeBytes')):
  496. raise RuntimeError('REMOTE_METADATA_MISMATCH:' + pid)
  497. # Anonymous application reads match the board's public product browsing surface.
  498. public_headers = {'X-Parse-Application-Id': headers['X-Parse-Application-Id']}
  499. samples = []
  500. selected = ['jd:100000283767', 'jd:100005702440', 'jd:2848752', 'taobao:1040511334739', sorted(ids)[-1]]
  501. for pid in selected:
  502. row = request(base + '/classes/' + CLASS + '/' + remote[pid]['objectId'], headers=public_headers)
  503. if sha(base64.b64decode(row['imageBase64'])) != expected[pid]['sha256'] or sha(base64.b64decode(row['thumbnailBase64'])) != expected[pid]['thumbnailSha256']:
  504. raise RuntimeError('PUBLIC_IMAGE_BYTES_MISMATCH:' + pid)
  505. if row.get('provenance'):
  506. raise RuntimeError('PUBLIC_PROVENANCE_NOT_PROTECTED')
  507. samples.append({'productId': pid, 'objectId': row['objectId'], 'bytesVerified': True})
  508. schema = request(base + '/schemas/' + CLASS, headers=headers)
  509. permissions = schema['classLevelPermissions']
  510. if any(permissions.get(action, {}).get('*') for action in ('create', 'update', 'delete', 'addField')):
  511. raise RuntimeError('PUBLIC_WRITE_ACCESS')
  512. before = json.loads((out / 'production-summary.json').read_text(encoding='utf-8'))
  513. current = request(args.catalog_url, {'op': 'summary'})
  514. if current['generation'] != before['generation'] or current['counts'] != before['counts']:
  515. raise RuntimeError('VOC_BASELINE_CHANGED_REVIEW_REQUIRED')
  516. report = {'ok': True, 'schema': VERSION, 'className': CLASS, 'verifiedAt': dt.datetime.now(dt.timezone.utc).isoformat(),
  517. 'catalogProducts': len(ids), 'databaseImages': len(remote), 'coveragePercent': 100,
  518. 'duplicates': 0, 'metadataHashesMatched': len(remote), 'newImageBytesRereadDuringPublish': True,
  519. 'publicByteSamples': samples, 'publicWritesAllowed': False, 'publicSourcePathsHidden': True,
  520. 'vocGenerationUnchanged': True, 'vocCountsUnchanged': True, 'generation': current['generation'],
  521. 'counts': current['counts'], 'summary': local['summary'], 'totalImageBytes': sum(r['sizeBytes'] for r in local['records']),
  522. 'paidApiCalls': 0}
  523. write(out / 'verification.json', report)
  524. print(json.dumps(report, ensure_ascii=False, indent=2), flush=True)
  525. if __name__ == '__main__':
  526. parser = argparse.ArgumentParser(description=__doc__)
  527. parser.add_argument('command', choices=['plan', 'discover', 'supplement', 'collect', 'publish', 'verify'])
  528. parser.add_argument('--out', default='work/product-media')
  529. parser.add_argument('--archive')
  530. parser.add_argument('--catalog-url', default='https://kangshifu.fmode.cn/api/library')
  531. parser.add_argument('--config')
  532. parser.add_argument('--limit', type=int, default=0)
  533. parser.add_argument('--workers', type=int, default=5)
  534. args = parser.parse_args()
  535. if args.command in ('plan', 'supplement') and not args.archive:
  536. parser.error(args.command + ' requires --archive')
  537. if args.command in ('publish', 'verify') and not args.config:
  538. parser.error(args.command + ' requires --config')
  539. try:
  540. globals()[args.command](args)
  541. except Exception as error:
  542. # Never log credential headers or response bodies.
  543. print(json.dumps({'ok': False, 'error': type(error).__name__, 'reason': str(error) if isinstance(error, RuntimeError) else None}), flush=True)
  544. raise SystemExit(1)