package-industry-existing-data.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. """Freeze the existing-data research release; never deploy or collect upstream data."""
  2. import argparse
  3. from concurrent.futures import ThreadPoolExecutor
  4. from datetime import datetime, timezone
  5. import difflib
  6. import hashlib
  7. import json
  8. from pathlib import Path
  9. import re
  10. import shutil
  11. from urllib.error import HTTPError
  12. from urllib.request import Request, urlopen
  13. import zipfile
  14. ROOT = Path(__file__).resolve().parents[1]
  15. WORK = ROOT / 'work/industry-existing-data-release-20261108-r1'
  16. BASELINE = WORK / 'public-baseline'
  17. PRIOR = ROOT / 'artifacts/ksf-industry-monitor-20261107-r11/package'
  18. RELEASE = 'ksf-industry-monitor-20261108-r12'
  19. PACKAGE = ROOT / 'artifacts' / RELEASE / 'package'
  20. POLICY = ROOT / 'artifacts/ksf-industry-policy-usage-20261108-r1/package'
  21. def sha(raw):
  22. return hashlib.sha256(raw).hexdigest()
  23. def normalized(raw):
  24. return raw.replace(b'\r\n', b'\n')
  25. def save_json(path, data):
  26. path.parent.mkdir(parents=True, exist_ok=True)
  27. path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
  28. def replace_one(text, before, after):
  29. assert text.count(before) == 1, 'PUBLIC_ANCHOR_CHANGED: ' + before[:80]
  30. return text.replace(before, after, 1)
  31. def shared_payloads():
  32. """Use observed public code, adding only industry navigation integration."""
  33. app = (BASELINE / 'src/layout/kf-app.js').read_text(encoding='utf-8')
  34. changes = [
  35. ("import { CONFIG } from '../app.config.js';", "import { CONFIG } from '../app.config.js';\nimport { industrySection } from '../core/industry-navigation.js';"),
  36. (' const previous = this._route;', ' const previous = this._route;\n const previousQuery = this._query;'),
  37. (" const samePool = ['pool', 'mode'].includes(previous.name) && ['pool', 'mode'].includes(name);", " const samePool = ['pool', 'mode'].includes(previous.name) && ['pool', 'mode'].includes(name);\n const industrySectionChanged = previous.name === 'industry' && name === 'industry' && (previousQuery.tab || 'insights') !== (query.tab || 'insights');"),
  38. ("navigation !== 'initial' && previous.path !== path && !samePool && name !== 'mode'", "navigation !== 'initial' && ((previous.path !== path && !samePool) || industrySectionChanged) && name !== 'mode'"),
  39. (" const [title, sub] = reportType ? [reportType.name, '报告中心'] : TITLES[this._route.name] || TITLES.notfound;", " const researchPage = this._route.name === 'industry' ? industrySection(this._query.tab || 'insights') : null;\n const [title, sub] = researchPage ? [researchPage.label, '行业监测'] : reportType ? [reportType.name, '报告中心'] : TITLES[this._route.name] || TITLES.notfound;"),
  40. ('<kf-nav class="nav" .current=${this._route.path} .connection=', '<kf-nav class="nav" .current=${this._route.path} .query=${this._query} .connection='),
  41. ]
  42. for before, after in changes:
  43. app = replace_one(app, before, after)
  44. nav = (BASELINE / 'src/layout/kf-nav.js').read_text(encoding='utf-8')
  45. nav = replace_one(nav, "import { href } from '../utils/router.js';", "import { href } from '../utils/router.js';\nimport { INDUSTRY_SECTIONS, industrySectionPath } from '../core/industry-navigation.js';")
  46. nav = replace_one(nav, ' current: { type: String },', ' current: { type: String },\n query: { type: Object },\n _industryOpen: { state: true },')
  47. nav = replace_one(nav, " this.current = '/';", " this.current = '/';\n this.query = {};\n this._industryOpen = false;")
  48. local_nav = (ROOT / 'src/layout/kf-nav.js').read_text(encoding='utf-8')
  49. start = local_nav.index(' #industryLinks() {')
  50. end = local_nav.index('\n static styles', start)
  51. method = local_nav[start:end]
  52. update = " updated(changed) {\n if (this.#cur() === '/industry' && (changed.has('current') || changed.has('query'))) this._industryOpen = true;\n }\n\n"
  53. nav = replace_one(nav, ' static styles = css`', update + method + '\n static styles = css`')
  54. css = '''
  55. .side>nav{flex:1;min-height:0;overflow-y:auto;align-content:start;scrollbar-width:thin}
  56. .brand,.foot{flex-shrink:0}
  57. .industry-group{min-width:0}.industry-group [hidden]{display:none!important}
  58. .industry-group button{font:inherit;cursor:pointer;touch-action:manipulation}
  59. .industry-group .nav-parent{display:flex;align-items:center;gap:12px;width:100%;min-height:44px;padding:5px 12px;border:0;border-radius:var(--r-md);background:transparent;color:var(--kf-ink-2);text-align:left;font-size:14px;font-weight:500}
  60. .industry-group.active>.nav-parent{color:var(--kf-accent-ink)}
  61. .industry-group .en{white-space:nowrap;font-size:12px}
  62. .industry-group .chevron{display:flex;flex:none}.industry-children{display:grid;gap:3px;margin:5px 0 7px 21px;padding-left:12px;border-left:1px solid var(--kf-line)}
  63. .industry-child{gap:8px;min-width:0;height:37px;padding:0 9px;font-size:12px;font-weight:400;line-height:1.6}
  64. .industry-child>svg{flex:none;width:15px;height:15px}.industry-child>span:not(.library-current-mark){flex:1}
  65. .industry-child[aria-current=page]{color:var(--kf-accent-ink);background:var(--kf-accent-soft);box-shadow:none;font-weight:600}.industry-child[aria-current=page]::before{display:none}.library-current-mark{display:flex;flex:none}
  66. .industry-group button:focus-visible,.industry-child:focus-visible{outline:2px solid var(--kf-accent);outline-offset:-2px}
  67. '''
  68. nav = replace_one(nav, ' :host { display: block; height: 100%; }', ' :host { display: block; height: 100%; }' + css)
  69. branch = '''it.path === '/industry' ? html`<div class=${`nav-group industry-group ${active(it) ? 'active' : ''}`}><button class="nav-parent" data-industry-toggle aria-expanded=${String(this._industryOpen)} aria-controls="industry-subnav" @click=${() => this._industryOpen = !this._industryOpen}>${icon(it.icon, 18)}<span class="lbl">${it.label}<span class="en">${it.en}</span></span><span class="chevron">${icon(this._industryOpen ? 'down' : 'right', 13)}</span></button><div id="industry-subnav" class="industry-children" role="group" aria-label="行业监测分类" ?hidden=${!this._industryOpen}>${this.#industryLinks()}</div></div>` : '''
  70. nav = replace_one(nav, '${ITEMS.map((it) => html`<a', '${ITEMS.map((it) => ' + branch + 'html`<a')
  71. assert 'LIBRARY_GROUPS' not in nav and 'data-library-toggle' not in nav
  72. assert 'scrollRestore=${' not in app
  73. return {'src/layout/kf-app.js': app.encode(), 'src/layout/kf-nav.js': nav.encode()}
  74. def industry_dependencies():
  75. found, dependencies = set(), set()
  76. queue = ['src/pages/view-industry.js', 'src/core/industry-navigation.js']
  77. while queue:
  78. name = queue.pop()
  79. if name in found:
  80. continue
  81. found.add(name)
  82. for rel in re.findall(r'(?:from\s*|import\s*)[\'\"]([.][^\'\"]+)[\'\"]', (ROOT / name).read_text(encoding='utf-8')):
  83. dest = (ROOT / name).parent.joinpath(rel).resolve().relative_to(ROOT).as_posix()
  84. if '/industry-' in dest or '/policy-reviewed-' in dest:
  85. queue.append(dest)
  86. else:
  87. dependencies.add(dest)
  88. return found, dependencies
  89. def capture(name):
  90. target = BASELINE / name
  91. if target.is_file():
  92. raw = target.read_bytes()
  93. return dict(path=name, exists=True, sha256=sha(raw), normalizedSha256=sha(normalized(raw)))
  94. try:
  95. with urlopen(Request('https://kangshifu.fmode.cn/' + name, headers={'Cache-Control': 'no-cache'}), timeout=25) as response:
  96. raw, content_type = response.read(3_000_000), response.headers.get('Content-Type', '')
  97. if 'javascript' not in content_type:
  98. assert sha(raw) == sha((BASELINE / 'index.html').read_bytes()), 'UNEXPECTED_PUBLIC_RESPONSE:' + name
  99. return dict(path=name, exists=False, sha256=None, normalizedSha256=None)
  100. target.parent.mkdir(parents=True, exist_ok=True)
  101. target.write_bytes(raw)
  102. return dict(path=name, exists=True, sha256=sha(raw), normalizedSha256=sha(normalized(raw)))
  103. except HTTPError as error:
  104. assert error.code == 404, 'PUBLIC_READ_FAILED:' + name
  105. return dict(path=name, exists=False, sha256=None, normalizedSha256=None)
  106. def build():
  107. assert not PACKAGE.exists(), 'RELEASE_DIRECTORY_MUST_BE_NEW'
  108. static, dependencies = industry_dependencies()
  109. shared = shared_payloads()
  110. with ThreadPoolExecutor(max_workers=8) as pool:
  111. captures = list(pool.map(capture, sorted(static | dependencies | set(shared))))
  112. by_path = {r['path']: r for r in captures}
  113. # Missing dependencies may be new pure modules. Existing shared modules stay public.
  114. for name in dependencies:
  115. if not by_path[name]['exists']:
  116. assert name.startswith('src/core/library-selection'), 'MISSING_PUBLIC_DEPENDENCY:' + name
  117. static.add(name)
  118. PACKAGE.mkdir(parents=True)
  119. save_json(PACKAGE / 'package.json', dict(name='ksf-industry-existing-data-r12', private=True, type='module'))
  120. frozen = {}
  121. def copy(name, destination=None):
  122. raw = (ROOT / name).read_bytes()
  123. frozen[name] = sha(raw)
  124. dest = PACKAGE / (destination or name)
  125. dest.parent.mkdir(parents=True, exist_ok=True)
  126. dest.write_bytes(raw)
  127. manifest, patch = [], []
  128. for name in sorted(static | set(shared)):
  129. if name in shared:
  130. raw = normalized(shared[name])
  131. dest = PACKAGE / 'payload' / name
  132. dest.parent.mkdir(parents=True, exist_ok=True)
  133. dest.write_bytes(raw)
  134. else:
  135. copy(name, 'payload/' + name)
  136. dest = PACKAGE / 'payload' / name
  137. raw = normalized(dest.read_bytes())
  138. dest.write_bytes(raw)
  139. prior = by_path[name]
  140. manifest.append(dict(path=name, sha256=sha(raw), allowMissing=not prior['exists'],
  141. priorNormalizedSha256=[prior['normalizedSha256']] if prior['exists'] else [],
  142. observedProductionSha256=prior['sha256']))
  143. old = (BASELINE / name).read_text(encoding='utf-8') if prior['exists'] else ''
  144. patch.extend(difflib.unified_diff(old.splitlines(True), raw.decode().splitlines(True),
  145. fromfile='public/' + name, tofile='release/' + name))
  146. for name in shared:
  147. assert by_path[name]['exists']
  148. backend = json.loads((PRIOR / 'RELEASE.json').read_bytes())['backendFiles']
  149. backend += ['server/voc-library/industry_workshop.py', 'server/voc-library/industry_decisions.py',
  150. 'server/voc-library/policy_usage_review.py']
  151. for name in backend:
  152. copy(name)
  153. for p in (ROOT / 'server/voc-library').glob('test_industry*.py'):
  154. copy(p.relative_to(ROOT).as_posix())
  155. copy('server/voc-library/test_policy_usage_review.py')
  156. for p in (ROOT / 'test').glob('industry-*.test.js'):
  157. if p.name != 'industry-collection-guard.test.js':
  158. copy(p.relative_to(ROOT).as_posix())
  159. target = PACKAGE / 'test' / p.name
  160. target.write_text(target.read_text(encoding='utf-8').replace('../src/', '../payload/src/'), encoding='utf-8')
  161. copy('scripts/verify-industry-existing-data.py')
  162. copy('scripts/verify-industry-existing-data-public.py')
  163. copy('scripts/query-industry-policy-usage.py')
  164. for name in ('agent-queries.md', '19-已核政策原文与用法查询.md'):
  165. copy('docs/industry-monitoring-20261107/' + name, 'docs/' + name)
  166. shutil.copytree(POLICY / 'sources', PACKAGE / 'data/policy-review/sources')
  167. shutil.copyfile(POLICY / 'policy-usage.json', PACKAGE / 'data/policy-review/policy-usage.json')
  168. assert sha((PACKAGE / 'data/policy-review/policy-usage.json').read_bytes()) == 'd22574f1ce28e1487e0be2abb78c47b32d22de801dc6e54ae9a8e3b3d31e777f'
  169. publisher = (PRIOR / 'scripts/publish-industry-ui.mjs').read_text(encoding='utf-8')
  170. before = next(line for line in publisher.splitlines() if line.startswith('export const FILES='))
  171. static_files = sorted(static | set(shared))
  172. publisher = replace_one(publisher, before, 'export const FILES=Object.freeze(' + json.dumps(static_files) + ');')
  173. publisher = publisher.replace('ksf-industry-monitor-20261107-r11', RELEASE)
  174. (PACKAGE / 'scripts/publish-industry-ui.mjs').write_text(publisher, encoding='utf-8')
  175. save_json(PACKAGE / 'deploy/industry-ui-files.json', dict(version='ksf.industry-ui.publish.v1', release=RELEASE,
  176. homepageSha256=sha((BASELINE / 'index.html').read_bytes()), files=manifest))
  177. save_json(PACKAGE / 'evidence/production-baseline.json', dict(observedAt=datetime.now(timezone.utc).isoformat(), files=captures))
  178. (PACKAGE / 'evidence/ui.patch').write_text(''.join(patch), encoding='utf-8')
  179. save_json(PACKAGE / 'RELEASE.json', dict(release=RELEASE, createdAt=datetime.now(timezone.utc).isoformat(),
  180. scope='Existing archived data research release; collection expansion deferred',
  181. staticFiles=static_files, backendFiles=backend, sourceFilesSha256=frozen,
  182. curatedSharedFiles=list(shared), existingVocCoreModulesReplaced=0,
  183. activeResearchDatabaseIncluded=False, auxiliaryDatabaseIncluded=False, credentialsIncluded=False,
  184. primaryShop='1000084104', generation='82b104fc024b49a75d2f5376bf9778ced6f63a9433b5fc5a6daa3c458d05d5ea',
  185. policyReviewSha256='d22574f1ce28e1487e0be2abb78c47b32d22de801dc6e54ae9a8e3b3d31e777f',
  186. verifiedPolicyClauses=12, pendingPolicyClauses=19, policyOriginals=13,
  187. productionDeployed=False, nativeProductionRuntimeVerified=False, overallMainlineComplete=False,
  188. paidCalls=0, productionChanges=0, researchDatabaseWrites=0))
  189. print(json.dumps(dict(ok=True, package=str(PACKAGE), staticFiles=len(static_files), backendFiles=len(backend), sealed=False)))
  190. def seal():
  191. receipt = json.loads((PACKAGE / 'evidence/acceptance.json').read_bytes())
  192. assert receipt['ok'] and receipt['browser']['ok'] and receipt['publisher']['ok']
  193. for name, digest in receipt['testedFilesSha256'].items():
  194. assert sha((PACKAGE / name).read_bytes()) == digest, 'TESTED_FILE_CHANGED:' + name
  195. assert not list(PACKAGE.rglob('*.sqlite')) and not list(PACKAGE.rglob('*.db'))
  196. files = sorted(p for p in PACKAGE.rglob('*') if p.is_file() and p.name != 'FILES.sha256')
  197. (PACKAGE / 'FILES.sha256').write_text(''.join(sha(p.read_bytes()) + ' ' + p.relative_to(PACKAGE).as_posix() + '\n' for p in files), encoding='utf-8')
  198. archive = PACKAGE.parent / (RELEASE + '.zip')
  199. assert not archive.exists(), 'SEALED_ARCHIVE_ALREADY_EXISTS'
  200. with zipfile.ZipFile(archive, 'x', zipfile.ZIP_DEFLATED) as z:
  201. for p in sorted(PACKAGE.rglob('*')):
  202. if p.is_file():
  203. z.write(p, p.relative_to(PACKAGE).as_posix())
  204. save_json(PACKAGE.parent / 'delivery.json', dict(ok=True, archive=str(archive), bytes=archive.stat().st_size,
  205. sha256=sha(archive.read_bytes()), manifestEntries=len(files), paidCalls=0, productionDeployed=False))
  206. print((PACKAGE.parent / 'delivery.json').read_text(encoding='utf-8'))
  207. if __name__ == '__main__':
  208. parser = argparse.ArgumentParser(description=__doc__)
  209. parser.add_argument('--seal', action='store_true')
  210. args = parser.parse_args()
  211. (seal if args.seal else build)()