prepare-jd-historical-baseline.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  1. """Recover the seven existing JD store pages and compare with the public library.
  2. No collector calls, credentials, database writes, ingestion or publication.
  3. Python 3.9+ standard library. The output directory must not already exist.
  4. """
  5. import argparse
  6. from collections import Counter
  7. import datetime as dt
  8. import hashlib
  9. import json
  10. from pathlib import Path
  11. import re
  12. import urllib.request
  13. import zipfile
  14. ARCHIVE_SHA = 'ed0e9d60015a28b74b22fe89e5008bd918e8091b0399b5c91a952cae6a320587'
  15. SHOP = '1000084104'
  16. BOARD = 'https://kangshifu.fmode.cn/api/library'
  17. ARRAY_POINTER = '/result/data/data/ItemList'
  18. def require(value, message):
  19. if not value:
  20. raise ValueError(message)
  21. def digest(data):
  22. return hashlib.sha256(data).hexdigest()
  23. def file_digest(path):
  24. h = hashlib.sha256()
  25. with Path(path).open('rb') as stream:
  26. for block in iter(lambda: stream.read(1024 * 1024), b''):
  27. h.update(block)
  28. return h.hexdigest()
  29. def encoded(value):
  30. return (json.dumps(value, ensure_ascii=False, indent=2) + '\n').encode('utf-8')
  31. def extract_pages(archive):
  32. require(file_digest(archive) == ARCHIVE_SHA, 'ORIGINAL_ARCHIVE_SHA_MISMATCH')
  33. files, pages, inventory = {}, [], {}
  34. with zipfile.ZipFile(archive) as source:
  35. for page in range(1, 8):
  36. suffix = '/20261104-complete/raw/ksf_pass3_p%d_a1.json' % page
  37. names = [name for name in source.namelist() if name.endswith(suffix)]
  38. require(len(names) == 1, 'HISTORICAL_PAGE_MISSING_OR_AMBIGUOUS')
  39. raw = source.read(names[0])
  40. doc = json.loads(raw)
  41. result = doc['result']
  42. body = result['data']
  43. data = body['data']
  44. require(doc['httpStatus'] == 200 and doc['status'] == 'ok' and
  45. result['code'] == 200 and body['code'] == 0, 'PAGE_NOT_SUCCESSFUL')
  46. query = body['requestParams']['query']
  47. require(str(doc['params']['shopId']) == SHOP and str(query['shopId']) == SHOP and
  48. str(data['shopId']) == SHOP, 'STORE_ID_MISMATCH')
  49. require(int(doc['params']['page']) == page and int(query['page']) == page,
  50. 'REQUEST_PAGE_MISMATCH')
  51. require(data['totalResults'] == 165 and data['TotalPage'] == 7,
  52. 'REPORTED_COVERAGE_CHANGED')
  53. rows = data['ItemList']
  54. require(len(rows) == (21 if page == 7 else 24), 'PAGE_ROW_COUNT_MISMATCH')
  55. name = 'originals/ksf_pass3_p%d_a1.json' % page
  56. files[name] = raw
  57. ids = []
  58. for index, row in enumerate(rows):
  59. item_id = row['itemId']
  60. require(isinstance(item_id, str) and item_id.isdigit(), 'INVALID_EXACT_ITEM_ID')
  61. pid = 'jd:' + item_id
  62. require(pid not in inventory, 'DUPLICATE_LISTING_ACROSS_PAGES')
  63. inventory[pid] = {
  64. 'id': pid, 'itemId': item_id, 'platformFamily': 'jd',
  65. 'historical': {
  66. 'shopId': SHOP, 'shopName': data['shopName'],
  67. 'title': row['title'], 'url': row.get('link'),
  68. 'sourceTimeRaw': body.get('recordTime'),
  69. 'sourceTimezone': None,
  70. 'startedAt': doc.get('startedAt'), 'finishedAt': doc.get('finishedAt'),
  71. 'archiveMember': names[0], 'archiveSha256': ARCHIVE_SHA,
  72. 'responsePath': name, 'responseSha256': digest(raw),
  73. 'jsonPointer': ARRAY_POINTER + '/' + str(index), 'page': page,
  74. },
  75. }
  76. ids.append(pid)
  77. pages.append({
  78. 'page': page, 'requestPageRaw': doc['params']['page'], 'shopId': SHOP,
  79. 'shopName': data['shopName'], 'rowCount': len(rows),
  80. 'uniqueCount': len(set(ids)), 'reportedTotal': data['totalResults'],
  81. 'reportedPages': data['TotalPage'], 'sourceTimeRaw': body.get('recordTime'),
  82. 'startedAt': doc.get('startedAt'), 'finishedAt': doc.get('finishedAt'),
  83. 'httpStatus': doc['httpStatus'], 'outerCode': result['code'],
  84. 'businessCode': body['code'], 'archiveMember': names[0],
  85. 'responsePath': name, 'sha256': digest(raw), 'productIds': ids,
  86. })
  87. require(len(inventory) == 165, 'HISTORICAL_UNIQUE_TOTAL_MISMATCH')
  88. return files, pages, inventory
  89. def extract_competitors(archive):
  90. """Choose a complete single pass per store, never the union of retries."""
  91. groups, files = {}, {}
  92. with zipfile.ZipFile(archive) as source:
  93. for name in source.namelist():
  94. match = re.search(r'/20261104-complete/raw/([^/]+)_pass(\d+)_p(\d+)_a1\.json$', name)
  95. if not match or match[1] == 'ksf':
  96. continue
  97. raw = source.read(name)
  98. doc = json.loads(raw)
  99. if doc.get('id') != 'jd.get_shop_item_list_v1':
  100. continue
  101. result = doc.get('result', {})
  102. body = result.get('data') or {}
  103. data = body.get('data') or {}
  104. require(doc.get('httpStatus') == 200 and result.get('code') == 200 and
  105. body.get('code') == 0, 'COMPETITOR_PAGE_FAILURE')
  106. shop = str(doc['params']['shopId'])
  107. require(shop == str(data['shopId']) == str(body['requestParams']['query']['shopId']),
  108. 'COMPETITOR_STORE_MISMATCH')
  109. page = int(doc['params']['page'])
  110. require(page == int(match[3]) == int(body['requestParams']['query']['page']),
  111. 'COMPETITOR_PAGE_MISMATCH')
  112. groups.setdefault((shop, int(match[2])), []).append({
  113. 'archiveMember': name, 'sha256': digest(raw), 'raw': raw,
  114. 'page': page, 'reportedPages': data['TotalPage'],
  115. 'reportedTotal': data['totalResults'], 'rows': data['ItemList'],
  116. 'sourceTimeRaw': body.get('recordTime'), 'shopName': data['shopName'],
  117. })
  118. audits, candidates = [], {}
  119. for (shop, pass_number), pages in sorted(groups.items()):
  120. ids = [str(row['itemId']) for page in pages for row in page['rows']]
  121. totals = {p['reportedTotal'] for p in pages}
  122. page_counts = {p['reportedPages'] for p in pages}
  123. got = {p['page'] for p in pages}
  124. complete = (len(totals) == 1 and len(page_counts) == 1 and
  125. len(got) == len(pages) and
  126. got == set(range(1, next(iter(page_counts)) + 1)) and
  127. len(ids) == len(set(ids)) == next(iter(totals)))
  128. audit = {
  129. 'shopId': shop, 'shopName': pages[0]['shopName'], 'pass': pass_number,
  130. 'pages': len(pages), 'pageNumbers': sorted(got), 'rows': len(ids),
  131. 'uniqueProducts': len(set(ids)), 'duplicateRows': len(ids) - len(set(ids)),
  132. 'reportedTotals': sorted(totals), 'reportedPageCounts': sorted(page_counts),
  133. 'accessibleHistoricalCatalogComplete': complete,
  134. 'sourceTimeMinRaw': min(p['sourceTimeRaw'] for p in pages),
  135. 'sourceTimeMaxRaw': max(p['sourceTimeRaw'] for p in pages),
  136. 'responses': [{k: v for k, v in p.items() if k not in ('raw', 'rows')} for p in pages],
  137. }
  138. audits.append(audit)
  139. if complete and (shop not in candidates or candidates[shop][0]['pass'] < pass_number):
  140. candidates[shop] = (audit, pages)
  141. inventories, selected = [], []
  142. for shop, (audit, pages) in sorted(candidates.items()):
  143. selected.append(audit)
  144. for page in sorted(pages, key=lambda p: p['page']):
  145. path = 'competitors/originals/' + page['archiveMember'].rsplit('/', 1)[-1]
  146. files[path] = page['raw']
  147. for index, row in enumerate(page['rows']):
  148. item_id = row['itemId']
  149. require(isinstance(item_id, str) and item_id.isdigit(), 'INVALID_COMPETITOR_ITEM_ID')
  150. inventories.append({
  151. 'id': 'jd:' + item_id, 'historicalShopId': shop,
  152. 'historicalShopName': page['shopName'], 'historicalTitle': row['title'],
  153. 'historicalUrl': row.get('link'), 'sourceTimeRaw': page['sourceTimeRaw'],
  154. 'sourceTimezone': None, 'archiveSha256': ARCHIVE_SHA,
  155. 'archiveMember': page['archiveMember'], 'responsePath': path,
  156. 'responseSha256': page['sha256'], 'page': page['page'],
  157. 'jsonPointer': ARRAY_POINTER + '/' + str(index),
  158. })
  159. require(len(selected) == 7, 'EXPECTED_SEVEN_COMPETITOR_STORES')
  160. files['competitors/pass-audit.json'] = encoded(audits)
  161. return files, selected, inventories
  162. class PublicBoard:
  163. def __init__(self):
  164. self.files = {}
  165. self.calls = 0
  166. def read(self, name, request):
  167. self.calls += 1
  168. req = urllib.request.Request(BOARD, data=encoded(request),
  169. headers={'Content-Type': 'application/json',
  170. 'Accept': 'application/json'}, method='POST')
  171. with urllib.request.urlopen(req, timeout=30) as response:
  172. raw = response.read(8 * 1024 * 1024 + 1)
  173. require(len(raw) <= 8 * 1024 * 1024, 'BOARD_RESPONSE_TOO_LARGE')
  174. result = json.loads(raw)
  175. require(result.get('ok') is True, 'BOARD_READ_FAILED')
  176. self.files['board/' + name + '-request.json'] = encoded(request)
  177. self.files['board/' + name + '.json'] = raw
  178. return result
  179. def browse_all(self, entity, generation, shop=SHOP):
  180. rows, cursors, total = {}, set(), None
  181. cursor = None
  182. for page in range(1, 101):
  183. query = {'op': 'browse', 'entity': entity,
  184. 'filters': {'platform': 'jd', 'shopId': shop},
  185. 'quality': 'audit', 'limit': 100, 'generation': generation}
  186. if cursor:
  187. query['cursor'] = cursor
  188. result = self.read('%s-%s-p%d' % (shop, entity, page), query)
  189. require(result['generation'] == generation, 'BOARD_GENERATION_CHANGED')
  190. total = result['totalMatched'] if total is None else total
  191. require(total == result['totalMatched'], 'BOARD_MATCH_COUNT_CHANGED')
  192. for row in result['records']:
  193. require(row['record_id'] not in rows, 'DUPLICATE_BOARD_RECORD')
  194. require(row['platform'] == 'jd' and row['shop_id'] == shop,
  195. 'BOARD_RECORD_OUTSIDE_STORE_SCOPE')
  196. rows[row['record_id']] = row
  197. cursor = result.get('nextCursor')
  198. if not cursor:
  199. require(len(rows) == total, 'INCOMPLETE_BOARD_PAGINATION')
  200. return rows
  201. require(cursor not in cursors, 'BOARD_CURSOR_LOOP')
  202. cursors.add(cursor)
  203. raise ValueError('BOARD_PAGINATION_LIMIT')
  204. def prepare(archive, out):
  205. require(not out.exists(), 'OUTPUT_ALREADY_EXISTS')
  206. files, pages, historical = extract_pages(archive)
  207. competitor_files, competitor_pages, competitor_inventory = extract_competitors(archive)
  208. files.update(competitor_files)
  209. board = PublicBoard()
  210. before = board.read('summary-before', {'op': 'summary'})
  211. generation = before['generation']
  212. current = board.browse_all('products', generation)
  213. prices = board.browse_all('prices', generation)
  214. competitor_summary = []
  215. for store in competitor_pages:
  216. shop = store['shopId']
  217. live = board.browse_all('products', generation, shop=shop)
  218. hist = [r for r in competitor_inventory if r['historicalShopId'] == shop]
  219. hist_ids, live_ids = {r['id'] for r in hist}, set(live)
  220. for row in hist:
  221. context = live.get(row['id'])
  222. row['current'] = ({'generation': generation, **{key: context.get(key) for key in (
  223. 'title', 'shop_id', 'shop_name', 'brand', 'category', 'source_day', 'relatedCounts')
  224. }} if context else None)
  225. competitor_summary.append({
  226. 'shopId': shop, 'shopName': store['shopName'], 'selectedHistoricalPass': store['pass'],
  227. 'historicalPages': store['pages'], 'historicalUniqueProducts': len(hist_ids),
  228. 'historicalSourceTimeMinRaw': store['sourceTimeMinRaw'],
  229. 'historicalSourceTimeMaxRaw': store['sourceTimeMaxRaw'],
  230. 'accessibleHistoricalCatalogComplete': True, 'currentStoreProducts': len(live),
  231. 'sameIds': len(hist_ids & live_ids),
  232. 'historicalIdsAbsentFromCurrentStore': sorted(hist_ids - live_ids),
  233. 'currentIdsAbsentFromHistoricalPages': sorted(live_ids - hist_ids),
  234. 'currentProductsWithReviews': sum(r.get('relatedCounts', {}).get('reviews', 0) > 0
  235. for r in live.values()),
  236. 'currentStoredReviewCount': sum(r.get('relatedCounts', {}).get('reviews', 0)
  237. for r in live.values()),
  238. 'currentCategoryCounts': dict(Counter(r.get('category') or '(unknown)' for r in live.values())),
  239. 'brandOwnershipInferredFromStoreName': False,
  240. })
  241. files['competitors/coverage.json'] = encoded(competitor_summary)
  242. files['competitors/inventory.json'] = encoded(competitor_inventory)
  243. after = board.read('summary-after', {'op': 'summary'})
  244. require(after['generation'] == generation, 'BOARD_CHANGED_DURING_READBACK')
  245. files.update(board.files)
  246. hist_ids, current_ids = set(historical), set(current)
  247. # Save product context separately from historical observations; never overwrite it.
  248. rows = []
  249. price_by_product = Counter(row['product_id'] for row in prices.values())
  250. for pid in sorted(hist_ids | current_ids):
  251. item = dict(historical.get(pid, {'id': pid}))
  252. live = current.get(pid)
  253. item['current'] = ({
  254. 'generation': generation,
  255. **{key: live.get(key) for key in (
  256. 'title', 'shop_id', 'shop_name', 'brand', 'category', 'source_day',
  257. 'classification_state', 'source_record_id', 'relatedCounts')},
  258. } if live else None)
  259. item['historicalOnly'] = pid not in current_ids
  260. item['currentOnly'] = pid not in hist_ids
  261. item['currentReviewCount'] = (live or {}).get('relatedCounts', {}).get('reviews', 0)
  262. item['currentPriceCount'] = price_by_product.get(pid, 0)
  263. rows.append(item)
  264. category_groups = []
  265. for category in sorted({row.get('category') or '(unknown)' for row in current.values()}):
  266. selected = [r for r in rows if r['current'] and
  267. (r['current'].get('category') or '(unknown)') == category]
  268. category_groups.append({
  269. 'category': None if category == '(unknown)' else category,
  270. 'products': len(selected),
  271. 'productsWithReviews': sum(r['currentReviewCount'] > 0 for r in selected),
  272. 'storedReviewCount': sum(r['currentReviewCount'] for r in selected),
  273. 'productsWithPrices': sum(r['currentPriceCount'] > 0 for r in selected),
  274. 'storedPriceCount': sum(r['currentPriceCount'] for r in selected),
  275. })
  276. price_groups = []
  277. # Different price bases and dates remain distinct; no per-unit normalization.
  278. keys = {(p.get('price_basis'), p.get('source_day')) for p in prices.values()}
  279. for basis, day in sorted(keys, key=lambda k: (str(k[0]), str(k[1]))):
  280. selected = [p for p in prices.values()
  281. if p.get('price_basis') == basis and p.get('source_day') == day]
  282. known = [p['amount_minor'] for p in selected if p.get('amount_minor') is not None]
  283. price_groups.append({
  284. 'priceBasis': basis, 'sourceCalendarDate': day, 'observations': len(selected),
  285. 'products': len({p['product_id'] for p in selected}),
  286. 'knownAmountObservations': len(known),
  287. 'minAmountMinor': min(known) if known else None,
  288. 'maxAmountMinor': max(known) if known else None,
  289. 'interpretation': '历史整件展示价格观察;非当前成交价或同规格单位价',
  290. })
  291. with_reviews = [r for r in rows if r['current'] and r['currentReviewCount'] > 0]
  292. report = {
  293. 'schema': 'ksf.jd-historical-store-baseline.v1', 'ok': True,
  294. 'generatedAt': dt.datetime.now(dt.timezone.utc).isoformat(),
  295. 'collectorCalls': 0, 'newPaidCalls': 0, 'newCollectionCostCny': '0.00',
  296. 'productionWrites': 0, 'databaseWrites': 0, 'publicBoardReads': board.calls,
  297. 'shop': {'platformFamily': 'jd', 'shopId': SHOP,
  298. 'shopName': '康师傅方便面京东自营旗舰店'},
  299. 'historical': {
  300. 'archiveSha256': ARCHIVE_SHA, 'archiveBytes': archive.stat().st_size,
  301. 'pages': 7, 'rows': 165, 'uniqueProducts': 165, 'duplicateRows': 0,
  302. 'reportedTotal': 165, 'reportedPages': 7,
  303. 'accessibleHistoricalCatalogComplete': True,
  304. 'completenessBasis': '请求与响应店铺/页码一致,1至7页齐全,165唯一ID等于各页声明总数',
  305. 'sourceTimeMinRaw': min(p['sourceTimeRaw'] for p in pages),
  306. 'sourceTimeMaxRaw': max(p['sourceTimeRaw'] for p in pages),
  307. 'sourceTimezone': None,
  308. 'currentLiveCatalogComplete': False,
  309. 'transactionalSnapshotProven': False,
  310. },
  311. 'production': {
  312. 'url': BOARD, 'generation': generation, 'generationUnchanged': True,
  313. 'libraryCounts': before['counts'], 'storeProducts': len(current),
  314. 'sameIds': len(hist_ids & current_ids),
  315. 'historicalIdsAbsentFromCurrentStore': sorted(hist_ids - current_ids),
  316. 'currentStoreIdsAbsentFromHistoricalPages': sorted(current_ids - hist_ids),
  317. 'productsWithStoredReviews': len(with_reviews),
  318. 'productsWithoutStoredReviews': len(current) - len(with_reviews),
  319. 'storedReviewCount': sum(r['currentReviewCount'] for r in with_reviews),
  320. 'productsWithStoredPrices': len(price_by_product),
  321. 'storedPriceCount': len(prices),
  322. 'productsWithNumericPrice': len({r['product_id'] for r in prices.values()
  323. if r.get('amount_minor') is not None}),
  324. 'numericPriceObservations': sum(r.get('amount_minor') is not None for r in prices.values()),
  325. 'noReviewsDoesNotMeanPlatformZero': True,
  326. 'reviewCountBasis': '公开商品检索relatedCounts;含待确认评价,不是analysis数量',
  327. },
  328. 'categories': category_groups, 'historicalPriceGroups': price_groups,
  329. 'competitors': {
  330. 'stores': len(competitor_summary),
  331. 'historicalListingsAcrossStores': len(competitor_inventory),
  332. 'historicalPages': sum(r['historicalPages'] for r in competitor_summary),
  333. 'storeCoverage': competitor_summary,
  334. 'completeIndustryUniverseClaimed': False,
  335. 'currentLiveCatalogComplete': False,
  336. },
  337. 'gatesStillOpen': [
  338. '不同观察轮次的采集稳定性与主店最终选择',
  339. '未知/待细分品类、SKU验证、商品宣称与卖点证据',
  340. '逐商品评价接口可访问范围与结束条件',
  341. '项目全部窗口费用原账本去重;不能拿ksf前缀子集推总余额',
  342. ],
  343. }
  344. files['coverage.json'] = encoded(report)
  345. files['pages.json'] = encoded(pages)
  346. files['inventory.json'] = encoded(rows)
  347. files['price-observations.json'] = encoded(list(prices.values()))
  348. files['review-gaps.json'] = encoded([
  349. {'id': r['id'], 'category': r['current']['category'],
  350. 'storedReviews': r['currentReviewCount'], 'storedPrices': r['currentPriceCount'],
  351. 'title': r['current']['title']}
  352. for r in rows if r['current'] and not r['currentReviewCount']
  353. ])
  354. lines = [
  355. '# 京东康师傅候选店历史目录基线', '',
  356. '用途:复用已采原件建立历史目录与评价缺口,不再为这7页历史资料付费重采。', '',
  357. '- 店铺:1000084104(康师傅方便面京东自营旗舰店)。',
  358. '- 历史:2026-11-04,7页,24×6+21=165条,165唯一商品,无重叠;与接口总数一致。',
  359. '- 现库:%s代,店铺%d商品;历史/现库交集%d。' %
  360. (generation, len(current), len(hist_ids & current_ids)),
  361. '- 店内%d商品有收录评价、%d商品未收录,评价%d条(含待确认)。' %
  362. (len(with_reviews), len(current) - len(with_reviews), report['production']['storedReviewCount']),
  363. '- %d商品有%d条价格观察;按价格口径和日期分组,不能混作当前售价。' %
  364. (len(price_by_product), len(prices)),
  365. '- 本次新增付费0元、生产写入0;%d次公开看板只读请求。' % board.calls,
  366. '- 另核7家京东竞品店历史目录,57页/1279个店内商品观察;按独立pass验证,未拼接重试轮次。',
  367. '', '## 文件', '',
  368. '`originals/`保留7页原字节;`pages.json`含页码/来源/SHA/Pointer;',
  369. '`inventory.json`分别保留历史与现库字段;`review-gaps.json`列评价空白商品;',
  370. '`coverage.json`给出分类覆盖与口径;`price-observations.json`保留已有价格;',
  371. '`board/`为代际固定的只读核对原响应。', '',
  372. '`competitors/`含7店历史目录原件、逐轮分页审计、现库核对与商品清单。', '',
  373. '## 范围', '',
  374. '已验收的是该接口当时可访问的历史店铺目录,不是今天在售全集、原子快照、完整SKU、',
  375. '全历史评价或已选定长期主店。未知分类不按标题补齐;不能用店铺归属代替品牌证据。',
  376. '本包是覆盖证据与执行输入,不是待发布batch,不要将旧目录再次覆盖现库。',
  377. '本包不包含项目总费用核实;账本去重后再决定新增付费采集范围。', '',
  378. ]
  379. files['README.md'] = '\n'.join(lines).encode('utf-8')
  380. out.mkdir(parents=True, exist_ok=False)
  381. for name, raw in files.items():
  382. target = out / name
  383. target.parent.mkdir(parents=True, exist_ok=True)
  384. target.write_bytes(raw)
  385. manifest = ''.join('%s %s\n' % (digest(raw), name) for name, raw in sorted(files.items()))
  386. (out / 'FILES.sha256').write_bytes(manifest.encode('utf-8'))
  387. return report
  388. def main():
  389. parser = argparse.ArgumentParser(description=__doc__)
  390. parser.add_argument('--archive', type=Path, required=True)
  391. parser.add_argument('--out', type=Path, required=True)
  392. args = parser.parse_args()
  393. result = prepare(args.archive.resolve(), args.out.resolve())
  394. print(json.dumps(result, ensure_ascii=False, indent=2))
  395. if __name__ == '__main__':
  396. main()