organize-workshop-report-site.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. """Organize the existing report site without regenerating research or Skill releases."""
  2. import hashlib
  3. import html
  4. import json
  5. import re
  6. import shutil
  7. from pathlib import Path
  8. from urllib.parse import unquote, urlsplit
  9. ROOT = Path(__file__).resolve().parents[1]
  10. OUT = ROOT / 'docs/workshop-quality-r13-20261110'
  11. BACKUP = ROOT / 'work/workshop-navigation-20261110/before'
  12. MANUAL = '康师傅工作坊操作手册.html'
  13. OLD_MANUAL = '康师傅工作坊操作手册-r13本地修订.html'
  14. EVIDENCE = '证据底表.html'
  15. ORDER = ['category', 'rules', 'competitors', 'tasks', 'research', 'accept', 'voc',
  16. 'matrix', 'opportunities', 'priority', 'feasibility', 'value', 'proposal']
  17. GROUPS = [('market', '第一个半天'), ('needs', '第二个半天'), ('concept', '第三个半天')]
  18. e = lambda value: html.escape(str(value), quote=True)
  19. sha = lambda path: hashlib.sha256(path.read_bytes()).hexdigest()
  20. def dump(path, value):
  21. path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + '\n', encoding='utf8')
  22. def data_block(text, marker):
  23. start = text.index(marker) + len(marker)
  24. value, length = json.JSONDecoder().raw_decode(text[start:])
  25. return value, start, length
  26. def navigation(steps, base, active):
  27. result = []
  28. for group, label in GROUPS:
  29. result.append(f'<div class="site-nav-label">{label}</div>')
  30. for step in steps:
  31. if step['id'] in ORDER and step['group'] == group:
  32. current = ' aria-current="page"' if step['id'] == active else ''
  33. result.append(f'<a href="{base}reports/{step["id"]}.html"{current}>'
  34. f'<span>{step["number"]}</span><span>{e(step["title"])}</span></a>')
  35. result.append('<div class="secondary-links"><div class="site-nav-label">研究参考</div>')
  36. for name, label in [('参考学习与类型对应.html', '参考报告与方法'), ('选材与分析规则.html', '选材与分析规则')]:
  37. current = ' aria-current="page"' if active == name else ''
  38. result.append(f'<a href="{base}{name}"{current}>{label}</a>')
  39. return ''.join(result) + '</div>'
  40. def shell(title, body, steps, active='', base='', intro='', actions='', pager=''):
  41. section = '报告案例' if active in ORDER else title
  42. links = []
  43. for rel, label, selected in [('index.html', '首页', active == 'home'),
  44. (MANUAL, '操作手册', False),
  45. ('案例报告合集.html', '报告案例', active in ORDER or active == 'reports')]:
  46. links.append(f'<a href="{base}{rel}"' + (' aria-current="page"' if selected else '') + f'>{label}</a>')
  47. links.append('<a href="https://kangshifu.fmode.cn/" target="_blank" rel="noopener">成果看板 ↗</a>')
  48. breadcrumb = f'<a href="{base}index.html">工作坊首页</a>'
  49. if active in ORDER:
  50. breadcrumb += f'<span>/</span><a href="{base}案例报告合集.html">报告案例</a>'
  51. breadcrumb += f'<span>/</span>{e(title)}' if active != 'home' else ''
  52. return f'''<!doctype html>
  53. <html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
  54. <title>{e(title)} · 康师傅工作坊</title><link rel="stylesheet" href="{base}assets/workshop-reports.css">
  55. <script src="{base}assets/workshop-reports.js" defer></script></head><body>
  56. <a class="skip-link" href="#content">跳转到正文</a><div class="site-layout">
  57. <aside class="site-sidebar"><a class="site-brand" href="{base}index.html"><span class="brand-mark">康</span><span>康师傅<small>产品创新工作坊</small></span></a>
  58. <details id="site-menu" class="site-menu" open><summary>报告目录 · {e(section)}</summary><nav class="site-navigation" aria-label="报告目录">{navigation(steps, base, active)}</nav></details>
  59. <div class="sidebar-note">按研究顺序查看案例<br>每篇报告可返回对应任务提示词</div></aside>
  60. <div class="site-content"><header class="site-topbar"><span>产品创新工作坊</span><nav class="site-links" aria-label="网站导航">{''.join(links)}</nav></header>
  61. <main id="content" class="reading"><div class="breadcrumb">{breadcrumb}</div><div class="page-heading"><h1>{e(title)}</h1>{f'<p>{intro}</p>' if intro else ''}{f'<div class="page-actions">{actions}</div>' if actions else ''}</div>
  62. {body}{pager}<footer class="site-footer">康师傅产品创新工作坊 · 案例用于理解研究方法与交付形式;实际结论以各组证据和验证为准。</footer></main></div></div></body></html>'''
  63. def card(step):
  64. return f'<a class="card" href="reports/{step["id"]}.html"><small>步骤 {step["number"]}</small><strong>{e(step["title"])}</strong><span>{e(step["question"])}</span></a>'
  65. def source_item(record, base):
  66. key = record['key']
  67. title = record.get('title') or record.get('purchasedSku') or record.get('productId') or record['recordId']
  68. text = f'<blockquote>{e(record["quote"])}</blockquote>' if record.get('quote') else ''
  69. if record.get('appendText'):
  70. text += f'<p>追评:{e(record["appendText"])}</p>'
  71. date = record.get('dateRaw') or record.get('sourceTime') or '时间未标明'
  72. note = record.get('reason') or record.get('note') or record.get('qualification') or '商品信息用于本报告对照,具体规格及价格以相应原件为准。'
  73. text += f'<p class="source-meta">对象:{e(record.get("productId") or record["recordId"])} · 时间:{e(date)}</p><p>{e(note)}</p>'
  74. source = record.get('source') or {}
  75. if source.get('asset_path'):
  76. text += f'<p class="source-meta">原件:{e(source["asset_path"])} · 第 {e(source.get("line_number", "未知"))} 行</p>'
  77. if record.get('url'):
  78. text += f'<p><a href="{e(record["url"])}" target="_blank" rel="noopener">原始页面 ↗</a></p>'
  79. text += f'<p class="source-meta"><a href="{base}data/citations/{key}.json">查看留存记录</a></p>'
  80. return f'<details class="source-item" id="source-{key}"><summary>{key} · {e(title)}</summary><div class="source-body">{text}</div></details>'
  81. def inline_citations(body, records, base):
  82. keys = []
  83. def replace(match):
  84. href = html.unescape(match.group(1))
  85. parsed = urlsplit(href)
  86. key = parsed.fragment
  87. if unquote(parsed.path).split('/')[-1] in [EVIDENCE, '证据索引.html']:
  88. if not key:
  89. return ''
  90. assert key in records, key
  91. if key not in keys:
  92. keys.append(key)
  93. return f'<a href="#source-{key}">{match.group(2)}</a>'
  94. return match.group(0)
  95. body = re.sub(r'<a\s+href="([^"]+)"[^>]*>(.*?)</a>', replace, body, flags=re.S)
  96. if keys:
  97. body += '<section class="report-sources"><h2>本篇引用</h2><p class="muted">点击正文编号可在本页展开对应原文。以下材料的使用范围与限制保持原记录。</p>'
  98. body += ''.join(source_item(records[k], base) for k in keys) + '</section>'
  99. return body, keys
  100. def main():
  101. BACKUP.mkdir(parents=True, exist_ok=True)
  102. for path in list(OUT.glob('*.html')) + list((OUT / 'reports').glob('*.html')) + list((OUT / 'manual').glob('*')):
  103. if path.is_file():
  104. saved = BACKUP / path.relative_to(OUT)
  105. saved.parent.mkdir(parents=True, exist_ok=True)
  106. if not saved.exists():
  107. shutil.copy2(path, saved)
  108. text = (OUT / MANUAL).read_text('utf8')
  109. data, _, _ = data_block(text, 'window.WORKSHOP_MANUAL_DATA = ')
  110. mapping, _, _ = data_block(text, ' const promptMap = ')
  111. assert data['steps'][0]['id'] == 'topic' and len(data['steps']) == 14
  112. steps = data['steps']
  113. ledger_path = OUT / '证据与编码-r13.json'
  114. ledger_sha = sha(ledger_path)
  115. ledger = json.loads(ledger_path.read_bytes())
  116. records = {r['key']: r for group in ['oldReviews', 'addedReviews', 'oldProducts', 'addedProducts', 'social'] for r in ledger[group]}
  117. citations = OUT / 'data/citations'
  118. citations.mkdir(exist_ok=True)
  119. used = set()
  120. by_id = {s['id']: s for s in steps}
  121. for index, identifier in enumerate(ORDER):
  122. step = by_id[identifier]
  123. raw = (BACKUP / f'reports/{identifier}.html').read_text('utf8')
  124. body = re.search(r'<div class="reading"><article>(.*)</article></div></main>', raw, re.S).group(1)
  125. body = re.sub(r'^<div class="kicker">.*?</div>', '', body, count=1, flags=re.S)
  126. heading = re.search(r'<h2>(.*?)</h2>', body, re.S)
  127. title = html.unescape(re.sub('<[^>]+>', '', heading.group(1)))
  128. body = body[:heading.start()] + body[heading.end():]
  129. body = re.sub(r'<p class="muted"><a href="../证据底表.html">.*?</p>', '', body, flags=re.S)
  130. body = body.replace(OLD_MANUAL, MANUAL)
  131. body, keys = inline_citations(body, records, '../')
  132. used.update(keys)
  133. actions = f'<a class="action-link primary" href="../{MANUAL}#{identifier}">返回 {step["number"]} 任务提示词</a><a class="action-link" href="../案例报告合集.html">全部报告</a>'
  134. previous = f'<a href="{ORDER[index-1]}.html">← {e(by_id[ORDER[index-1]]["title"])}</a>' if index else '<a href="../案例报告合集.html">← 报告目录</a>'
  135. following = f'<a href="{ORDER[index+1]}.html">{e(by_id[ORDER[index+1]]["title"])} →</a>' if index < len(ORDER)-1 else f'<a href="../{MANUAL}">返回操作手册 →</a>'
  136. pager = f'<nav class="report-pager" aria-label="前后报告">{previous}{following}</nav>'
  137. page = shell(title, '<article id="report-content">' + body + '</article>', steps, identifier, '../',
  138. intro=f'步骤 {step["number"]} · 宿舍常备拌面研究案例', actions=actions, pager=pager)
  139. (OUT / f'reports/{identifier}.html').write_text(page, encoding='utf8')
  140. for key in sorted(used):
  141. dump(citations / f'{key}.json', records[key])
  142. groups = []
  143. for group, label in GROUPS:
  144. cards = ''.join(card(s) for s in steps if s['id'] in ORDER and s['group'] == group)
  145. groups.append(f'<section class="panel"><h2>{label}</h2><div class="grid">{cards}</div></section>')
  146. intro = '按三个半天的研究顺序查看 13 篇报告案例;从每篇报告可以返回对应提示词。'
  147. (OUT / '案例报告合集.html').write_text(shell('报告案例', ''.join(groups), steps, 'reports', intro=intro), encoding='utf8')
  148. home = f'''<section class="panel"><h2>开始工作坊</h2><div class="grid"><a class="card" href="{MANUAL}"><small>01—14 · 三个半天</small><strong>操作手册 →</strong><span>沿本组课题逐节研究,检查报告并接续下一步。</span></a><a class="card" href="案例报告合集.html"><small>13 篇研究报告</small><strong>报告案例 →</strong><span>查看图谱、竞品、需求、机会、可行性和最终提案。</span></a></div></section>
  149. <section class="panel"><h2>案例研究主线</h2><p>宿舍常备拌面:从风味选择与准备体验出发,比较已有供给、用户任务和产品改良方向。</p><p class="muted">案例使用留存商品与用户表达展开探索分析;产品原型设定、研究推断及尚未完成的实测,在各篇报告中分别说明。</p><a class="action-link" href="reports/category.html">从品类全景开始</a> <a class="action-link" href="reports/proposal.html">查看最终概念提案</a></section>'''
  150. (OUT / 'index.html').write_text(shell('产品创新工作坊', home, steps, 'home', intro='操作手册与研究报告案例'), encoding='utf8')
  151. support = [('参考学习与类型对应.html', '参考报告与方法'), ('选材与分析规则.html', '选材与分析规则'),
  152. ('商业回推演算器.html', '商业回推演算器'), ('旧稿逐项复核.html', '案例复核记录')]
  153. for name, title in support:
  154. raw = (BACKUP / name).read_text('utf8')
  155. body = re.search(r'<div class="reading">(.*)</div></main>', raw, re.S).group(1)
  156. body = body.replace(OLD_MANUAL, MANUAL)
  157. (OUT / name).write_text(shell(title, body, steps, name), encoding='utf8')
  158. # Keep the latest 14 prompts as the sole manual source; report links return here.
  159. dump(OUT / 'manual-source.json', data)
  160. dump(OUT / 'prompts-and-outputs.json', {'version': data['release']['contentVersion'],
  161. 'steps': [{**s, 'fullPrompt': mapping[s['id']]} for s in steps]})
  162. manual_dir = OUT / 'manual'
  163. index_text = (manual_dir / 'index.html').read_text('utf8')
  164. index_text = index_text.replace('href="#install"', 'href="../index.html"').replace('href="#topic"', 'href="../index.html"')
  165. index_text = index_text.replace('0 / 15 项已核对', '0 / 14 项已核对')
  166. index_text = index_text.replace('<span>产品创新 / <strong>工作坊操作手册</strong></span>', '<span><strong>工作坊操作手册</strong></span>')
  167. if '>报告案例</a>' not in index_text:
  168. index_text = index_text.replace(' <a href="https://kangshifu.fmode.cn/"', ' <a href="../index.html">首页</a><a href="../案例报告合集.html">报告案例</a>\n <a href="https://kangshifu.fmode.cn/"')
  169. (manual_dir / 'index.html').write_text(index_text, encoding='utf8')
  170. from update_workshop_manual_install_r13 import render_manual
  171. render_manual(data, json.loads((OUT / 'prompts-and-outputs.json').read_bytes()))
  172. # Remove the former page after every incoming citation has a local replacement.
  173. if (OUT / EVIDENCE).exists():
  174. (OUT / EVIDENCE).unlink()
  175. assert sha(ledger_path) == ledger_sha
  176. routes = {'home': 'index.html', 'manual': MANUAL, 'reports': '案例报告合集.html',
  177. 'reportPages': [dict(id=s['id'], number=s['number'], path=f'reports/{s["id"]}.html', manual=f'{MANUAL}#{s["id"]}') for s in steps if s['id'] in ORDER]}
  178. dump(OUT / 'site-routes.json', routes)
  179. receipt = dict(ok=True, reportPages=13, manualSteps=14, removedPage=EVIDENCE,
  180. citationsInReportPages=True, preservedSourceRecords=len(used), originalLedgerSha256=ledger_sha,
  181. manualPromptsUnchanged=True, backup=str(BACKUP), manualPublished=False, productionWrites=0)
  182. dump(OUT / '页面导航整理核验.json', receipt)
  183. manifest_path = OUT / '本地发行清单.json'
  184. manifest = json.loads(manifest_path.read_bytes())
  185. manifest.update(manualSha256=sha(OUT / MANUAL), steps=14, siteRoutes='site-routes.json')
  186. dump(manifest_path, manifest)
  187. print(json.dumps(receipt, ensure_ascii=False))
  188. if __name__ == '__main__':
  189. main()