package-product-images-layout.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. """Freeze the integrated product UI against the current observed public baseline."""
  2. import argparse
  3. import concurrent.futures
  4. import difflib
  5. import hashlib
  6. import json
  7. from pathlib import Path
  8. import re
  9. import subprocess
  10. import urllib.error
  11. import urllib.request
  12. import zipfile
  13. ROOT = Path(__file__).resolve().parents[1]
  14. WORK = ROOT / 'work/product-images-layout-20261106-r2'
  15. PACKAGE = WORK / 'package'
  16. DOCS = ROOT / 'docs/industry-product-images-20261106/release-r2'
  17. OUT = ROOT / 'artifacts/商品图片与详情排版-20261106-r2'
  18. RELEASE = 'ksf-product-images-layout-20261106-r2'
  19. FILES = [
  20. 'src/core/library-comparison.js', 'src/services/library-images.js', 'src/pages/library-product-image.js',
  21. 'src/core/library.js', 'src/services/library-service.js', 'src/services/library-comparison.js',
  22. 'src/pages/library-product-compare.js', 'src/pages/library-styles.js',
  23. 'src/pages/library-product-detail.js', 'src/pages/view-library.js',
  24. ]
  25. def sha(raw):
  26. return hashlib.sha256(raw).hexdigest()
  27. def norm(raw):
  28. return sha(raw.replace(b'\r\n', b'\n'))
  29. def save(name, data):
  30. p = PACKAGE / name
  31. p.parent.mkdir(parents=True, exist_ok=True)
  32. p.write_bytes(data.encode() if isinstance(data, str) else data)
  33. def js(name, data):
  34. save(name, json.dumps(data, ensure_ascii=False, indent=2) + '\n')
  35. def prepare(refresh=False):
  36. assert not PACKAGE.exists() or refresh, 'PACKAGE_ALREADY_EXISTS'
  37. assert not (OUT / (RELEASE + '.zip')).exists(), 'IMMUTABLE_ARCHIVE_ALREADY_EXISTS'
  38. baselines = {r['path']: r for r in json.loads((WORK / 'production-baseline.json').read_text(encoding='utf-8'))}
  39. assert baselines['src/pages/library-product-detail.js']['sha256'] == 'f77135b2dff426a4ac30d668271831d7e20816e5d0ff1b7ead41219e4e2051ba'
  40. old_manifest = json.loads((ROOT / 'docs/industry-product-images-20261106/frontend-release.json').read_text(encoding='utf-8'))
  41. for row in old_manifest['files']:
  42. assert baselines[row['path']]['sha256'] == row['observedProductionSha256'], 'PUBLIC_BASELINE_CHANGED:' + row['path']
  43. entries = []
  44. diffs = []
  45. for name in FILES:
  46. before = (WORK / 'production-before' / name).read_bytes()
  47. observed = baselines[name]
  48. assert sha(before) == observed['sha256']
  49. raw = (ROOT / name).read_bytes()
  50. save('payload/' + name, raw)
  51. subprocess.run(['node', '--check', str(PACKAGE / 'payload' / name)], check=True, capture_output=True)
  52. entries.append({'path': name, 'sha256': sha(raw), 'normalizedSha256': norm(raw),
  53. 'priorNormalizedSha256': [] if observed['absent'] else [norm(before)],
  54. 'observedProductionSha256': sha(before), 'allowMissing': observed['absent']})
  55. diffs.extend(difflib.unified_diff([] if observed['absent'] else before.decode('utf-8').splitlines(True),
  56. raw.decode('utf-8').splitlines(True), fromfile='production/' + name, tofile='payload/' + name))
  57. detail = (PACKAGE / 'payload/src/pages/library-product-detail.js').read_text(encoding='utf-8')
  58. public_detail = (WORK / 'production-before/src/pages/library-product-detail.js').read_text(encoding='utf-8')
  59. getter = lambda text: text[text.index(' get reviewQuality() {'):text.index(' _count(entity) {')]
  60. assert getter(detail) == getter(public_detail)
  61. for part in ["if (request?.entity === 'reviews') request.quality = this.reviewQuality;", '.value=${this.reviewQuality}',
  62. '已收录评价仍有信息待确认,先展示原文;这些记录尚未计入分析概况。']:
  63. assert part in detail and part in public_detail
  64. assert '<h3>商品信息</h3>' not in detail
  65. manifest = {'version': 'ksf.product-images-layout.publish.v1', 'release': RELEASE,
  66. 'origin': 'https://kangshifu.fmode.cn', 'homepageSha256': baselines['index.html']['sha256'],
  67. 'files': entries, 'reviewDefaultDisplayHotfixPreserved': True,
  68. 'databaseImagesAlreadyStored': 4674, 'productionInstalled': False}
  69. js('deploy/product-ui-files.json', manifest)
  70. publisher = (ROOT / 'scripts/publish-opportunity-ui.mjs').read_text(encoding='utf-8')
  71. start = publisher.index('export const FILES=Object.freeze([')
  72. end = publisher.index(']);', start) + 3
  73. publisher = publisher[:start] + 'export const FILES=Object.freeze(' + json.dumps(FILES) + ');' + publisher[end:]
  74. publisher = publisher.replace('ksf.opportunity-ui.publish.v1', manifest['version']).replace('ksf.opportunity-ui.20261103.v1', RELEASE)
  75. publisher = publisher.replace('filesChecked:6', 'filesChecked:FILES.length').replace('ksf-opportunity-ui', 'ksf-product-ui')
  76. publisher = publisher.replace("sourceDir:root,manifestPath:path.join(root,'deploy/opportunity-ui-files-20261103.json')",
  77. "sourceDir:path.join(root,'payload'),manifestPath:path.join(root,'deploy/product-ui-files.json')")
  78. save('scripts/publish-product-ui.mjs', publisher)
  79. subprocess.run(['node', '--check', str(PACKAGE / 'scripts/publish-product-ui.mjs')], check=True, capture_output=True)
  80. save('evidence/product-ui.patch', ''.join(diffs))
  81. save('evidence/database-before.json', (WORK / 'database-before.json').read_bytes())
  82. deps = []
  83. for name in FILES:
  84. text = (PACKAGE / 'payload' / name).read_text(encoding='utf-8')
  85. for match in re.finditer(r'''(?:from|import)\s*\(?\s*['"](\.[^'"]+)['"]''', text):
  86. resolved = (Path(name).parent / match[1]).as_posix()
  87. absolute = (ROOT / resolved).resolve()
  88. assert absolute.is_relative_to(ROOT) and absolute.is_file()
  89. deps.append(absolute.relative_to(ROOT).as_posix())
  90. js('RELEASE.json', {'release': RELEASE, 'staticFiles': len(FILES),
  91. 'updatedExistingFiles': sum(not e['allowMissing'] for e in entries),
  92. 'newFiles': sum(e['allowMissing'] for e in entries),
  93. 'reviewDefaultDisplayHotfixPreserved': True, 'relativeImports': sorted(set(deps)),
  94. 'databaseIncluded': False, 'credentialsIncluded': False, 'productionInstalled': False})
  95. DOCS.mkdir(parents=True, exist_ok=True)
  96. (DOCS / 'frontend-release.json').write_bytes((PACKAGE / 'deploy/product-ui-files.json').read_bytes())
  97. print(json.dumps({'ok': True, 'staticFiles': len(FILES), 'newFiles': sum(e['allowMissing'] for e in entries),
  98. 'reviewDefaultDisplayHotfixPreserved': True, 'productionWrites': 0}, ensure_ascii=False))
  99. def check_baselines():
  100. baselines = json.loads((WORK / 'production-baseline.json').read_text(encoding='utf-8'))
  101. def check(row):
  102. req = urllib.request.Request('https://kangshifu.fmode.cn/' + row['path'], headers={'Cache-Control': 'no-cache'})
  103. try:
  104. with urllib.request.urlopen(req, timeout=30) as r:
  105. actual, mime = r.read(), r.headers.get('Content-Type', '')
  106. except urllib.error.HTTPError as error:
  107. assert row['status'] == error.code
  108. actual, mime = error.read(), error.headers.get('Content-Type', '')
  109. assert sha(actual) == row['sha256'] and mime == row['contentType'], 'PUBLIC_BASELINE_CHANGED:' + row['path']
  110. return {'path': row['path'], 'baselineMatches': True}
  111. rows = list(concurrent.futures.ThreadPoolExecutor(max_workers=4).map(check, baselines))
  112. return {'ok': True, 'files': rows, 'productionWrites': 0}
  113. def package():
  114. for name in ['browser-verification.json', 'publisher-verification.json', 'routine-verification.json']:
  115. raw = (WORK / name).read_bytes()
  116. assert json.loads(raw)['ok'], 'VERIFICATION_FAILED:' + name
  117. save('evidence/' + name, raw)
  118. (DOCS / name).write_bytes(raw)
  119. save('evidence/module-graph-local.json', (WORK / 'module-graph-local.json').read_bytes())
  120. assert json.loads((WORK / 'browser-verification.json').read_text(encoding='utf-8'))['productionInstalled'] is False
  121. js('evidence/public-baselines-final.json', check_baselines())
  122. save('交接说明.md', (DOCS / 'README.md').read_bytes())
  123. for p in sorted((WORK / 'screenshots').glob('*.png')):
  124. save('evidence/screenshots/' + p.name, p.read_bytes())
  125. dest = DOCS / 'screenshots' / p.name
  126. dest.parent.mkdir(parents=True, exist_ok=True)
  127. dest.write_bytes(p.read_bytes())
  128. for name in ['package-product-images-layout.py', 'verify-product-ui-release.py']:
  129. save('verification/' + name, (ROOT / 'scripts' / name).read_bytes())
  130. members = sorted(p for p in PACKAGE.rglob('*') if p.is_file() and p.name != 'FILES.sha256')
  131. assert not any(p.suffix.lower() in {'.sqlite', '.db', '.key', '.pem', '.pyc'} for p in members)
  132. save('FILES.sha256', ''.join(sha(p.read_bytes()) + ' ' + p.relative_to(PACKAGE).as_posix() + '\n' for p in members))
  133. OUT.mkdir(parents=True, exist_ok=True)
  134. archive = OUT / (RELEASE + '.zip')
  135. with zipfile.ZipFile(archive, 'x', zipfile.ZIP_DEFLATED, compresslevel=9) as z:
  136. for p in [*members, PACKAGE / 'FILES.sha256']:
  137. z.write(p, p.relative_to(PACKAGE).as_posix())
  138. with zipfile.ZipFile(archive) as z:
  139. assert z.testzip() is None
  140. for line in z.read('FILES.sha256').decode().splitlines():
  141. expected, name = line.split(' ', 1)
  142. assert sha(z.read(name)) == expected
  143. receipt = {'ok': True, 'path': str(archive), 'sha256': sha(archive.read_bytes()), 'bytes': archive.stat().st_size,
  144. 'filesVerified': len(members), 'staticFiles': len(FILES), 'productionInstalled': False,
  145. 'productionWrites': 0, 'supersedes': 'ksf-product-images-20261106-r1.zip'}
  146. (DOCS / 'delivery-receipt.json').write_text(json.dumps(receipt, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
  147. print(json.dumps(receipt, ensure_ascii=False, indent=2))
  148. if __name__ == '__main__':
  149. parser = argparse.ArgumentParser(description=__doc__)
  150. parser.add_argument('--package', action='store_true')
  151. parser.add_argument('--refresh', action='store_true', help='Refresh unarchived local staging after a source correction')
  152. args = parser.parse_args()
  153. assert not (args.package and args.refresh)
  154. package() if args.package else prepare(args.refresh)