preview-note.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733
  1. #!/usr/bin/env python3
  2. from __future__ import annotations
  3. import json
  4. import re
  5. import sys
  6. import webbrowser
  7. from html import escape
  8. from pathlib import Path
  9. _REPOSITORY_ROOT_DIRECTORY = Path(__file__).resolve().parent.parent.parent
  10. _NOTE_DETAIL_OUTPUT_ROOT_DIRECTORY = _REPOSITORY_ROOT_DIRECTORY / "output" / "note-detail"
  11. _NOTE_OUTPUT_ROOT_DIRECTORY = _REPOSITORY_ROOT_DIRECTORY / "output" / "note"
  12. _PREVIEW_HTML_OUTPUT_FILE_PATH = _NOTE_DETAIL_OUTPUT_ROOT_DIRECTORY / "preview-note-detail.html"
  13. _PREVIEW_HTML_DOCUMENT_PAGE_TITLE_VISIBLE_TEXT = "小红书笔记预览"
  14. def _natural_sort_detail_image_file_path_list(
  15. detail_note_images_directory_path: Path,
  16. ) -> list[Path]:
  17. if not detail_note_images_directory_path.is_dir():
  18. return []
  19. pattern = re.compile(r"detail_note_image_(\d+)", re.IGNORECASE)
  20. candidates: list[tuple[tuple[int, ...], Path]] = []
  21. for image_file_path in detail_note_images_directory_path.iterdir():
  22. if not image_file_path.is_file():
  23. continue
  24. match = pattern.search(image_file_path.name)
  25. key = (int(match.group(1)),) if match else (0, image_file_path.name)
  26. candidates.append((key, image_file_path))
  27. candidates.sort(key=lambda item: item[0])
  28. return [path for _key, path in candidates]
  29. def _read_note_detail_title_text(note_detail_sequence_directory_path: Path) -> str:
  30. note_detail_title_file_path = note_detail_sequence_directory_path / "note_detail_title.txt"
  31. if not note_detail_title_file_path.is_file():
  32. return ""
  33. return note_detail_title_file_path.read_text(encoding="utf-8").strip()
  34. def _read_note_detail_body_metadata_dictionary(
  35. note_detail_sequence_directory_path: Path,
  36. ) -> dict[str, object]:
  37. note_detail_body_json_file_path = note_detail_sequence_directory_path / "note_detail_body.json"
  38. if not note_detail_body_json_file_path.is_file():
  39. return {}
  40. return json.loads(note_detail_body_json_file_path.read_text(encoding="utf-8"))
  41. def _read_note_card_metadata_dictionary(
  42. note_output_sequence_directory_path: Path,
  43. ) -> dict[str, object]:
  44. note_card_metadata_json_file_path = note_output_sequence_directory_path / "note_card_metadata.json"
  45. if not note_card_metadata_json_file_path.is_file():
  46. return {}
  47. return json.loads(note_card_metadata_json_file_path.read_text(encoding="utf-8"))
  48. def _build_html_document_fragment_for_one_note_detail_sequence(
  49. note_detail_sequence_directory_path: Path,
  50. note_detail_sequence_directory_name: str,
  51. note_card_metadata_dictionary: dict[str, object],
  52. ) -> str:
  53. title_text = _read_note_detail_title_text(note_detail_sequence_directory_path)
  54. body_metadata_dictionary = _read_note_detail_body_metadata_dictionary(
  55. note_detail_sequence_directory_path,
  56. )
  57. detail_body_plain_text = str(
  58. body_metadata_dictionary.get("detail_body_plain_text") or "",
  59. )
  60. detail_body_hashtag_object_list: list[object] = list(
  61. body_metadata_dictionary.get("detail_body_hashtag_list") or [],
  62. )
  63. detail_body_emoji_image_url_list: list[object] = list(
  64. body_metadata_dictionary.get("detail_body_emoji_image_url_list") or [],
  65. )
  66. card_note_title_text = str(note_card_metadata_dictionary.get("note_title") or "").strip()
  67. author_name_text = str(note_card_metadata_dictionary.get("author_name") or "").strip()
  68. publish_time_text = str(note_card_metadata_dictionary.get("publish_time") or "").strip()
  69. likes_count_text = str(note_card_metadata_dictionary.get("likes_count") or "").strip()
  70. display_title_text = title_text if title_text else card_note_title_text
  71. detail_note_images_directory_path = (
  72. note_detail_sequence_directory_path / "detail_note_images"
  73. )
  74. image_file_path_list = _natural_sort_detail_image_file_path_list(
  75. detail_note_images_directory_path,
  76. )
  77. image_relative_path_string_list: list[str] = []
  78. for image_file_path in image_file_path_list:
  79. relative_path = Path(note_detail_sequence_directory_name) / "detail_note_images" / image_file_path.name
  80. image_relative_path_string_list.append(
  81. relative_path.as_posix(),
  82. )
  83. display_title_html = escape(display_title_text) if display_title_text else ""
  84. author_name_html = escape(author_name_text) if author_name_text else ""
  85. publish_time_html = escape(publish_time_text) if publish_time_text else ""
  86. likes_count_html = escape(likes_count_text) if likes_count_text else ""
  87. hashtag_link_html_fragment_list: list[str] = []
  88. for hashtag_object in detail_body_hashtag_object_list:
  89. if not isinstance(hashtag_object, dict):
  90. continue
  91. display_text = str(hashtag_object.get("display_text") or "").strip()
  92. relative_href = str(hashtag_object.get("relative_href") or "").strip()
  93. if not display_text:
  94. continue
  95. if relative_href.startswith("/"):
  96. full_href = f"https://www.xiaohongshu.com{relative_href}"
  97. elif relative_href.startswith("http"):
  98. full_href = relative_href
  99. else:
  100. full_href = relative_href
  101. hashtag_link_html_fragment_list.append(
  102. f'<a class="hashtag" href="{escape(full_href, quote=True)}">{escape(display_text)}</a>',
  103. )
  104. body_html = ""
  105. if detail_body_plain_text.strip():
  106. body_html = (
  107. f'<div class="note-detail-plain-body">{escape(detail_body_plain_text)}</div>'
  108. )
  109. hashtag_section_html = ""
  110. if not detail_body_plain_text.strip() and hashtag_link_html_fragment_list:
  111. hashtag_section_html = (
  112. '<div class="hashtag-row">'
  113. + " ".join(hashtag_link_html_fragment_list)
  114. + "</div>"
  115. )
  116. emoji_row_html_fragment_list: list[str] = []
  117. for emoji_image_url_object in detail_body_emoji_image_url_list:
  118. emoji_url = str(emoji_image_url_object or "").strip()
  119. if not emoji_url.startswith("http"):
  120. continue
  121. emoji_row_html_fragment_list.append(
  122. f'<img class="inline-emoji" src="{escape(emoji_url, quote=True)}" alt="" />',
  123. )
  124. emoji_section_html = ""
  125. if emoji_row_html_fragment_list:
  126. emoji_section_html = (
  127. '<div class="emoji-row">' + "".join(emoji_row_html_fragment_list) + "</div>"
  128. )
  129. carousel_slide_html_fragment_list: list[str] = []
  130. for image_relative_path_string in image_relative_path_string_list:
  131. carousel_slide_html_fragment_list.append(
  132. '<div class="carousel-slide">'
  133. f'<img src="{escape(image_relative_path_string, quote=True)}" alt="" />'
  134. "</div>",
  135. )
  136. has_local_detail_image_file_for_carousel = bool(carousel_slide_html_fragment_list)
  137. slides_inner_html = "".join(carousel_slide_html_fragment_list)
  138. slide_count_integer = len(carousel_slide_html_fragment_list)
  139. article_id_attribute_value = escape(note_detail_sequence_directory_name, quote=True)
  140. meta_heading_block_html = ""
  141. if author_name_html:
  142. meta_heading_block_html += (
  143. f'<div class="meta-author-row"><span class="meta-author-name">{author_name_html}</span></div>'
  144. )
  145. if display_title_html:
  146. meta_heading_block_html += f'<h1 class="meta-detail-title">{display_title_html}</h1>'
  147. stats_row_html_fragment_list: list[str] = []
  148. if likes_count_html:
  149. stats_row_html_fragment_list.append(
  150. f'<span class="meta-stat-item"><span class="meta-stat-label">赞</span>'
  151. f'<span class="meta-stat-value">{likes_count_html}</span></span>',
  152. )
  153. if publish_time_html:
  154. stats_row_html_fragment_list.append(
  155. f'<span class="meta-stat-item meta-publish-time">{publish_time_html}</span>',
  156. )
  157. stats_row_html = ""
  158. if stats_row_html_fragment_list:
  159. stats_row_html = (
  160. '<div class="meta-stats-row">' + "".join(stats_row_html_fragment_list) + "</div>"
  161. )
  162. meta_body_inner_html_fragment_list: list[str] = []
  163. if body_html:
  164. meta_body_inner_html_fragment_list.append(body_html)
  165. if emoji_section_html:
  166. meta_body_inner_html_fragment_list.append(emoji_section_html)
  167. if hashtag_section_html:
  168. meta_body_inner_html_fragment_list.append(hashtag_section_html)
  169. meta_body_block_html = ""
  170. if meta_body_inner_html_fragment_list:
  171. meta_body_block_html = (
  172. '<div class="meta-body-block">'
  173. + "".join(meta_body_inner_html_fragment_list)
  174. + "</div>"
  175. )
  176. note_detail_carousel_column_html = ""
  177. if has_local_detail_image_file_for_carousel:
  178. carousel_dot_button_html_fragment_list: list[str] = []
  179. for carousel_dot_zero_based_index in range(slide_count_integer):
  180. carousel_dot_active_css_class_suffix = (
  181. " is-active" if carousel_dot_zero_based_index == 0 else ""
  182. )
  183. carousel_dot_button_html_fragment_list.append(
  184. '<button type="button" class="carousel-dot'
  185. + carousel_dot_active_css_class_suffix
  186. + '" data-carousel-dot-index="'
  187. + str(carousel_dot_zero_based_index)
  188. + '" aria-label="第 '
  189. + str(carousel_dot_zero_based_index + 1)
  190. + ' 张"></button>',
  191. )
  192. carousel_dots_inner_html = "".join(carousel_dot_button_html_fragment_list)
  193. carousel_counter_text_initial = f"1/{slide_count_integer}"
  194. note_detail_carousel_column_html = f"""<div class="note-detail-carousel-column">
  195. <div class="carousel-stage">
  196. <div class="carousel-scroll-viewport" data-carousel-viewport tabindex="0" aria-label="笔记图片,可横向滑动或滚轮翻页">
  197. <div class="carousel-track">
  198. {slides_inner_html}
  199. </div>
  200. </div>
  201. <div class="carousel-counter-pill" data-carousel-counter>{carousel_counter_text_initial}</div>
  202. <div class="carousel-dots" data-carousel-dots>
  203. {carousel_dots_inner_html}
  204. </div>
  205. </div>
  206. </div>"""
  207. meta_card_inner_html = (
  208. meta_heading_block_html + stats_row_html + meta_body_block_html
  209. )
  210. shell_modifier_class_list: list[str] = []
  211. if not has_local_detail_image_file_for_carousel:
  212. shell_modifier_class_list.append("note-detail-shell--no-carousel")
  213. if has_local_detail_image_file_for_carousel and not meta_card_inner_html.strip():
  214. shell_modifier_class_list.append("note-detail-shell--carousel-only")
  215. if not has_local_detail_image_file_for_carousel and meta_card_inner_html.strip():
  216. shell_modifier_class_list.append("note-detail-shell--meta-only")
  217. shell_modifier_class_html = (
  218. " " + " ".join(shell_modifier_class_list) if shell_modifier_class_list else ""
  219. )
  220. note_detail_meta_column_html = ""
  221. if meta_card_inner_html.strip():
  222. note_detail_meta_column_html = f"""<div class="note-detail-meta-column">
  223. <div class="meta-card">
  224. {meta_card_inner_html}
  225. </div>
  226. </div>"""
  227. if not note_detail_carousel_column_html and not note_detail_meta_column_html:
  228. return f"""<article class="note-detail-shell note-detail-shell--empty{shell_modifier_class_html}" id="note-detail-{article_id_attribute_value}"></article>"""
  229. return f"""<article class="note-detail-shell{shell_modifier_class_html}" id="note-detail-{article_id_attribute_value}">
  230. {note_detail_carousel_column_html}
  231. {note_detail_meta_column_html}
  232. </article>"""
  233. def _build_full_html_document_string(
  234. note_detail_sequence_directory_path_list: list[Path],
  235. note_card_metadata_by_sequence_name_dictionary: dict[str, dict[str, object]],
  236. ) -> str:
  237. fragment_list: list[str] = []
  238. for note_detail_sequence_directory_path in note_detail_sequence_directory_path_list:
  239. sequence_name = note_detail_sequence_directory_path.name
  240. note_card_metadata_dictionary = note_card_metadata_by_sequence_name_dictionary.get(
  241. sequence_name,
  242. {},
  243. )
  244. fragment_list.append(
  245. _build_html_document_fragment_for_one_note_detail_sequence(
  246. note_detail_sequence_directory_path,
  247. sequence_name,
  248. note_card_metadata_dictionary,
  249. ),
  250. )
  251. inner = "\n".join(fragment_list)
  252. page_title_escaped = escape(_PREVIEW_HTML_DOCUMENT_PAGE_TITLE_VISIBLE_TEXT)
  253. stylesheet = """
  254. :root {
  255. color-scheme: light dark;
  256. --xhs-red: #ff2442;
  257. --xhs-red-soft: rgba(255, 36, 66, 0.12);
  258. --panel-bg: #fff;
  259. --page-bg-top: #f8f6f7;
  260. --page-bg-bottom: #eef0f5;
  261. --text: #1a1a1a;
  262. --muted: rgba(0,0,0,0.48);
  263. --radius-lg: 18px;
  264. --shadow-card: 0 4px 24px rgba(15, 23, 42, 0.08), 0 1px 3px rgba(15, 23, 42, 0.04);
  265. --shadow-card-hover: 0 8px 32px rgba(15, 23, 42, 0.1);
  266. }
  267. * { box-sizing: border-box; }
  268. html {
  269. width: 100vw;
  270. height: 100vh;
  271. max-width: 100vw;
  272. max-height: 100vh;
  273. overflow: hidden;
  274. }
  275. body {
  276. margin: 0;
  277. width: 100vw;
  278. height: 100vh;
  279. max-width: 100vw;
  280. max-height: 100vh;
  281. overflow: hidden;
  282. display: flex;
  283. flex-direction: column;
  284. font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif;
  285. color: var(--text);
  286. background: linear-gradient(168deg, var(--page-bg-top) 0%, var(--page-bg-bottom) 55%, #e6e9f0 100%);
  287. background-attachment: fixed;
  288. letter-spacing: 0.01em;
  289. }
  290. .preview-page-header {
  291. flex: 0 0 auto;
  292. width: min(1080px, 100vw);
  293. max-width: 100%;
  294. margin: 0 auto;
  295. padding: clamp(12px, 2.8vh, 28px) clamp(16px, 2.5vw, 20px) clamp(6px, 1vh, 8px);
  296. }
  297. .preview-page-header-inner {
  298. display: flex;
  299. flex-direction: column;
  300. align-items: flex-start;
  301. gap: 10px;
  302. }
  303. .preview-page-title {
  304. margin: 0;
  305. font-size: clamp(1.35rem, 3.5vw, 1.65rem);
  306. font-weight: 700;
  307. letter-spacing: 0.04em;
  308. line-height: 1.25;
  309. display: flex;
  310. align-items: center;
  311. gap: 12px;
  312. }
  313. .preview-page-title-mark {
  314. display: inline-block;
  315. width: 4px;
  316. height: 1.15em;
  317. border-radius: 2px;
  318. background: linear-gradient(180deg, var(--xhs-red) 0%, #ff6b6b 100%);
  319. flex-shrink: 0;
  320. }
  321. .preview-page-title-text {
  322. background: linear-gradient(90deg, #1a1a1a 0%, #3d3d3d 100%);
  323. -webkit-background-clip: text;
  324. background-clip: text;
  325. color: transparent;
  326. }
  327. @media (prefers-color-scheme: dark) {
  328. .preview-page-title-text {
  329. background: linear-gradient(90deg, #f5f5f7 0%, #d1d1d6 100%);
  330. -webkit-background-clip: text;
  331. background-clip: text;
  332. color: transparent;
  333. }
  334. }
  335. .preview-main {
  336. flex: 1 1 auto;
  337. min-height: 0;
  338. width: min(1080px, 100vw);
  339. max-width: 100%;
  340. margin: 0 auto;
  341. padding: clamp(8px, 1.2vh, 12px) clamp(12px, 2vw, 16px) clamp(10px, 1.5vh, 16px);
  342. display: flex;
  343. flex-direction: column;
  344. gap: clamp(8px, 1.2vh, 12px);
  345. overflow: hidden;
  346. }
  347. .note-detail-shell {
  348. display: flex; flex-wrap: wrap; align-items: stretch; gap: 0;
  349. flex: 1 1 auto;
  350. min-height: 0;
  351. background: var(--panel-bg);
  352. border-radius: var(--radius-lg);
  353. overflow: hidden;
  354. box-shadow: var(--shadow-card);
  355. border: 1px solid rgba(0,0,0,0.04);
  356. margin-bottom: 0;
  357. transition: box-shadow 0.25s ease;
  358. }
  359. .note-detail-shell:hover {
  360. box-shadow: var(--shadow-card-hover);
  361. }
  362. .note-detail-shell--no-carousel { min-height: unset; }
  363. .note-detail-shell--meta-only .note-detail-meta-column {
  364. flex: 1 1 100%; max-width: 100%;
  365. }
  366. .note-detail-shell--carousel-only .note-detail-carousel-column {
  367. flex: 1 1 100%; max-width: 100%;
  368. }
  369. .note-detail-shell--empty {
  370. min-height: 0; margin-bottom: 12px; box-shadow: none; background: transparent;
  371. }
  372. .note-detail-carousel-column {
  373. flex: 1 1 340px; min-width: min(100%, 340px);
  374. min-height: 0;
  375. background: linear-gradient(180deg, #121212 0%, #0a0a0a 100%);
  376. display: flex; flex-direction: column;
  377. }
  378. .carousel-stage {
  379. position: relative;
  380. flex: 1;
  381. display: flex;
  382. flex-direction: column;
  383. min-height: 0;
  384. }
  385. .carousel-scroll-viewport {
  386. flex: 1; overflow-x: auto; overflow-y: hidden;
  387. scroll-snap-type: x mandatory;
  388. scrollbar-width: none;
  389. -ms-overflow-style: none;
  390. outline: none;
  391. touch-action: pan-x;
  392. }
  393. .carousel-scroll-viewport::-webkit-scrollbar { display: none; width: 0; height: 0; }
  394. .carousel-scroll-viewport:focus-visible { box-shadow: inset 0 0 0 2px var(--xhs-red); }
  395. .carousel-track { display: flex; flex-direction: row; height: 100%; min-height: 0; }
  396. .carousel-counter-pill {
  397. position: absolute;
  398. top: 12px;
  399. right: 12px;
  400. z-index: 2;
  401. padding: 4px 10px;
  402. border-radius: 999px;
  403. font-size: 12px;
  404. font-variant-numeric: tabular-nums;
  405. color: #fff;
  406. background: rgba(0, 0, 0, 0.45);
  407. backdrop-filter: blur(6px);
  408. pointer-events: none;
  409. line-height: 1.35;
  410. }
  411. .carousel-dots {
  412. position: absolute;
  413. bottom: 14px;
  414. left: 50%;
  415. transform: translateX(-50%);
  416. z-index: 2;
  417. display: flex;
  418. flex-wrap: wrap;
  419. justify-content: center;
  420. align-items: center;
  421. gap: 7px;
  422. max-width: calc(100% - 24px);
  423. pointer-events: auto;
  424. }
  425. .carousel-dot {
  426. width: 6px;
  427. height: 6px;
  428. padding: 0;
  429. border: none;
  430. border-radius: 50%;
  431. background: rgba(255, 255, 255, 0.38);
  432. cursor: pointer;
  433. flex-shrink: 0;
  434. transition: transform 0.2s ease, background 0.2s ease;
  435. }
  436. .carousel-dot:hover { background: rgba(255, 255, 255, 0.65); }
  437. .carousel-dot.is-active {
  438. background: #fff;
  439. transform: scale(1.2);
  440. box-shadow: 0 0 0 1px rgba(0,0,0,0.15);
  441. }
  442. .carousel-slide {
  443. flex: 0 0 100%; scroll-snap-align: start; scroll-snap-stop: always;
  444. display: flex; align-items: center; justify-content: center;
  445. box-sizing: border-box; padding: 8px;
  446. min-height: 100%;
  447. align-self: stretch;
  448. }
  449. .carousel-slide img {
  450. max-width: 100%; max-height: 100%; width: auto; height: auto;
  451. object-fit: contain; vertical-align: middle; border-radius: 4px;
  452. }
  453. .note-detail-meta-column {
  454. flex: 1 1 300px; min-width: min(100%, 280px); max-width: 100%;
  455. min-height: 0;
  456. box-sizing: border-box;
  457. overflow-y: auto;
  458. scrollbar-gutter: stable;
  459. }
  460. .meta-card {
  461. padding: clamp(16px, 2.2vh, 24px) clamp(16px, 2.5vw, 24px) clamp(18px, 2.5vh, 28px);
  462. }
  463. .meta-author-row { margin-bottom: 12px; }
  464. .meta-author-name {
  465. font-size: 15px;
  466. font-weight: 600;
  467. color: var(--text);
  468. }
  469. .meta-detail-title {
  470. font-size: clamp(17px, 2.5vw, 20px);
  471. font-weight: 600;
  472. margin: 0 0 16px;
  473. line-height: 1.45;
  474. letter-spacing: 0.02em;
  475. }
  476. .meta-stats-row {
  477. display: flex; flex-wrap: wrap; align-items: center; gap: 14px 22px;
  478. margin-bottom: 18px; padding-bottom: 18px;
  479. border-bottom: 1px solid rgba(0,0,0,0.07);
  480. font-size: 13px; color: var(--muted);
  481. }
  482. .meta-stat-item { display: inline-flex; align-items: baseline; gap: 5px; }
  483. .meta-stat-label { opacity: 0.72; font-size: 12px; }
  484. .meta-stat-value {
  485. color: var(--text);
  486. font-weight: 600;
  487. font-variant-numeric: tabular-nums;
  488. }
  489. .note-detail-plain-body {
  490. white-space: pre-wrap; word-break: break-word;
  491. line-height: 1.75;
  492. font-size: 15px;
  493. color: rgba(0,0,0,0.88);
  494. }
  495. .hashtag-row { margin-top: 14px; line-height: 1.9; }
  496. a.hashtag { color: #13386c; text-decoration: none; margin-right: 8px; }
  497. a.hashtag:hover { text-decoration: underline; }
  498. .emoji-row { margin-top: 8px; }
  499. img.inline-emoji { height: 1.15em; vertical-align: -0.2em; }
  500. @media (prefers-color-scheme: dark) {
  501. :root {
  502. --panel-bg: #2c2c2e;
  503. --page-bg-top: #1c1c1f;
  504. --page-bg-bottom: #121214;
  505. --text: #f2f2f7;
  506. --muted: rgba(255,255,255,0.48);
  507. --shadow-card: 0 4px 28px rgba(0,0,0,0.35);
  508. --shadow-card-hover: 0 8px 36px rgba(0,0,0,0.45);
  509. }
  510. body {
  511. background: linear-gradient(168deg, var(--page-bg-top) 0%, var(--page-bg-bottom) 100%);
  512. }
  513. a.hashtag { color: #8ab4ff; }
  514. .meta-stats-row { border-bottom-color: rgba(255,255,255,0.1); }
  515. .note-detail-plain-body { color: rgba(255,255,255,0.88); }
  516. .note-detail-shell { border-color: rgba(255,255,255,0.06); }
  517. }
  518. @media (max-width: 720px) {
  519. .preview-page-header { padding: clamp(10px, 2.5vh, 20px) clamp(12px, 4vw, 16px) 4px; }
  520. .note-detail-shell { flex-direction: column; }
  521. .note-detail-carousel-column { min-height: min(42vh, 40%); }
  522. .carousel-counter-pill { top: 8px; right: 8px; font-size: 11px; padding: 3px 8px; }
  523. .carousel-dots { bottom: 10px; gap: 6px; }
  524. }
  525. """
  526. carousel_script = """
  527. (function () {
  528. var carouselAutoAdvanceMilliseconds = 4000;
  529. function carouselViewportCurrentSlideZeroBasedIndex(carouselViewportElement, slideCount) {
  530. var w = carouselViewportElement.clientWidth;
  531. if (w <= 0) return 0;
  532. var i = Math.round(carouselViewportElement.scrollLeft / w);
  533. if (i < 0) i = 0;
  534. if (i >= slideCount) i = slideCount - 1;
  535. return i;
  536. }
  537. function carouselViewportScrollToSlideZeroBasedIndex(carouselViewportElement, slideZeroBasedIndex, slideCount) {
  538. var w = carouselViewportElement.clientWidth;
  539. if (w <= 0) return;
  540. if (slideZeroBasedIndex < 0) slideZeroBasedIndex = 0;
  541. if (slideZeroBasedIndex >= slideCount) slideZeroBasedIndex = slideCount - 1;
  542. carouselViewportElement.scrollTo({ left: slideZeroBasedIndex * w, behavior: "smooth" });
  543. }
  544. function syncCarouselDotsAndCounterFromScroll(carouselViewportElement) {
  545. var stage = carouselViewportElement.closest(".carousel-stage");
  546. var track = carouselViewportElement.querySelector(".carousel-track");
  547. var slides = track ? track.querySelectorAll(".carousel-slide") : [];
  548. var slideCount = slides.length;
  549. if (!stage || !slideCount) return;
  550. var counter = stage.querySelector("[data-carousel-counter]");
  551. var dotNodeList = stage.querySelectorAll("[data-carousel-dot-index]");
  552. var activeIndex = carouselViewportCurrentSlideZeroBasedIndex(carouselViewportElement, slideCount);
  553. if (counter) {
  554. counter.textContent = (activeIndex + 1) + "/" + slideCount;
  555. }
  556. dotNodeList.forEach(function (dotElement) {
  557. var dotIndex = parseInt(dotElement.getAttribute("data-carousel-dot-index"), 10);
  558. if (dotIndex === activeIndex) {
  559. dotElement.classList.add("is-active");
  560. } else {
  561. dotElement.classList.remove("is-active");
  562. }
  563. });
  564. }
  565. function clearCarouselAutoAdvanceTimer(carouselViewportElement) {
  566. var timerId = carouselViewportElement.__carouselAutoAdvanceIntervalId;
  567. if (timerId) {
  568. clearInterval(timerId);
  569. carouselViewportElement.__carouselAutoAdvanceIntervalId = null;
  570. }
  571. }
  572. function startCarouselAutoAdvanceIfMultipleSlides(carouselViewportElement) {
  573. clearCarouselAutoAdvanceTimer(carouselViewportElement);
  574. var track = carouselViewportElement.querySelector(".carousel-track");
  575. var slides = track ? track.querySelectorAll(".carousel-slide") : [];
  576. if (slides.length <= 1) return;
  577. carouselViewportElement.__carouselAutoAdvanceIntervalId = setInterval(function () {
  578. var slideCount = slides.length;
  579. var w = carouselViewportElement.clientWidth;
  580. if (w <= 0) return;
  581. var currentIndex = carouselViewportCurrentSlideZeroBasedIndex(carouselViewportElement, slideCount);
  582. var nextIndex = (currentIndex + 1) % slideCount;
  583. carouselViewportElement.scrollTo({ left: nextIndex * w, behavior: "smooth" });
  584. }, carouselAutoAdvanceMilliseconds);
  585. }
  586. function bindCarouselViewport(carouselViewportElement) {
  587. var stage = carouselViewportElement.closest(".carousel-stage");
  588. var track = carouselViewportElement.querySelector(".carousel-track");
  589. var slides = track ? track.querySelectorAll(".carousel-slide") : [];
  590. var slideCount = slides.length;
  591. carouselViewportElement.addEventListener("scroll", function () {
  592. syncCarouselDotsAndCounterFromScroll(carouselViewportElement);
  593. });
  594. carouselViewportElement.addEventListener("wheel", function (wheelEvent) {
  595. if (Math.abs(wheelEvent.deltaY) <= Math.abs(wheelEvent.deltaX)) return;
  596. wheelEvent.preventDefault();
  597. carouselViewportElement.scrollLeft += wheelEvent.deltaY;
  598. }, { passive: false });
  599. if (stage) {
  600. stage.addEventListener("mouseenter", function () {
  601. clearCarouselAutoAdvanceTimer(carouselViewportElement);
  602. });
  603. stage.addEventListener("mouseleave", function () {
  604. startCarouselAutoAdvanceIfMultipleSlides(carouselViewportElement);
  605. });
  606. }
  607. if (stage) {
  608. stage.querySelectorAll("[data-carousel-dot-index]").forEach(function (dotElement) {
  609. dotElement.addEventListener("click", function () {
  610. var targetIndex = parseInt(dotElement.getAttribute("data-carousel-dot-index"), 10);
  611. carouselViewportScrollToSlideZeroBasedIndex(carouselViewportElement, targetIndex, slideCount);
  612. clearCarouselAutoAdvanceTimer(carouselViewportElement);
  613. startCarouselAutoAdvanceIfMultipleSlides(carouselViewportElement);
  614. });
  615. });
  616. }
  617. syncCarouselDotsAndCounterFromScroll(carouselViewportElement);
  618. startCarouselAutoAdvanceIfMultipleSlides(carouselViewportElement);
  619. }
  620. document.querySelectorAll("[data-carousel-viewport]").forEach(bindCarouselViewport);
  621. window.addEventListener("resize", function () {
  622. document.querySelectorAll("[data-carousel-viewport]").forEach(function (carouselViewportElement) {
  623. syncCarouselDotsAndCounterFromScroll(carouselViewportElement);
  624. });
  625. });
  626. })();
  627. """
  628. return f"""<!DOCTYPE html>
  629. <html lang="zh-CN">
  630. <head>
  631. <meta charset="utf-8" />
  632. <meta name="viewport" content="width=device-width, initial-scale=1" />
  633. <title>{page_title_escaped}</title>
  634. <style>{stylesheet}</style>
  635. </head>
  636. <body>
  637. <header class="preview-page-header">
  638. <div class="preview-page-header-inner">
  639. <h1 class="preview-page-title"><span class="preview-page-title-mark" aria-hidden="true"></span><span class="preview-page-title-text">{page_title_escaped}</span></h1>
  640. </div>
  641. </header>
  642. <main class="preview-main">
  643. {inner}
  644. </main>
  645. <script>{carousel_script}</script>
  646. </body>
  647. </html>
  648. """
  649. def _collect_note_detail_sequence_directory_path_list(
  650. only_sequence_name: str | None,
  651. ) -> list[Path]:
  652. if not _NOTE_DETAIL_OUTPUT_ROOT_DIRECTORY.is_dir():
  653. return []
  654. result: list[Path] = []
  655. for child_path in sorted(
  656. _NOTE_DETAIL_OUTPUT_ROOT_DIRECTORY.iterdir(),
  657. key=lambda p: p.name,
  658. ):
  659. if not child_path.is_dir():
  660. continue
  661. if only_sequence_name is not None and child_path.name != only_sequence_name:
  662. continue
  663. result.append(child_path)
  664. return result
  665. def _build_note_card_metadata_by_sequence_name_dictionary(
  666. sequence_name_list: list[str],
  667. ) -> dict[str, dict[str, object]]:
  668. result_dictionary: dict[str, dict[str, object]] = {}
  669. for sequence_name in sequence_name_list:
  670. note_output_sequence_directory_path = _NOTE_OUTPUT_ROOT_DIRECTORY / sequence_name
  671. result_dictionary[sequence_name] = _read_note_card_metadata_dictionary(
  672. note_output_sequence_directory_path,
  673. )
  674. return result_dictionary
  675. def start(command_line_argument_strings: list[str] | None = None) -> int:
  676. arguments = (
  677. command_line_argument_strings
  678. if command_line_argument_strings is not None
  679. else sys.argv[1:]
  680. )
  681. only_sequence_name: str | None = arguments[0].strip() if arguments else None
  682. if only_sequence_name == "":
  683. only_sequence_name = None
  684. note_detail_sequence_directory_path_list = _collect_note_detail_sequence_directory_path_list(
  685. only_sequence_name,
  686. )
  687. sequence_name_list = [p.name for p in note_detail_sequence_directory_path_list]
  688. note_card_metadata_by_sequence_name_dictionary = (
  689. _build_note_card_metadata_by_sequence_name_dictionary(sequence_name_list)
  690. )
  691. html_document_string = _build_full_html_document_string(
  692. note_detail_sequence_directory_path_list,
  693. note_card_metadata_by_sequence_name_dictionary,
  694. )
  695. _PREVIEW_HTML_OUTPUT_FILE_PATH.parent.mkdir(parents=True, exist_ok=True)
  696. _PREVIEW_HTML_OUTPUT_FILE_PATH.write_text(
  697. html_document_string,
  698. encoding="utf-8",
  699. )
  700. preview_html_file_uri_string = _PREVIEW_HTML_OUTPUT_FILE_PATH.resolve().as_uri()
  701. webbrowser.open(preview_html_file_uri_string)
  702. return 0
  703. def main() -> int:
  704. return start()
  705. __all__ = ["start", "main", "_NOTE_DETAIL_OUTPUT_ROOT_DIRECTORY"]
  706. if __name__ == "__main__":
  707. raise SystemExit(main())