|
@@ -0,0 +1,704 @@
|
|
|
|
|
+#!/usr/bin/env python3
|
|
|
|
|
+
|
|
|
|
|
+from __future__ import annotations
|
|
|
|
|
+
|
|
|
|
|
+import json
|
|
|
|
|
+import re
|
|
|
|
|
+import sys
|
|
|
|
|
+import webbrowser
|
|
|
|
|
+from html import escape
|
|
|
|
|
+from pathlib import Path
|
|
|
|
|
+
|
|
|
|
|
+_REPOSITORY_ROOT_DIRECTORY = Path(__file__).resolve().parent.parent.parent
|
|
|
|
|
+_NOTE_DETAIL_OUTPUT_ROOT_DIRECTORY = _REPOSITORY_ROOT_DIRECTORY / "output" / "note-detail"
|
|
|
|
|
+_NOTE_OUTPUT_ROOT_DIRECTORY = _REPOSITORY_ROOT_DIRECTORY / "output" / "note"
|
|
|
|
|
+_PREVIEW_HTML_OUTPUT_FILE_PATH = _NOTE_DETAIL_OUTPUT_ROOT_DIRECTORY / "preview-note-detail.html"
|
|
|
|
|
+_PREVIEW_HTML_DOCUMENT_PAGE_TITLE_VISIBLE_TEXT = "小红书笔记预览"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _natural_sort_detail_image_file_path_list(
|
|
|
|
|
+ detail_note_images_directory_path: Path,
|
|
|
|
|
+) -> list[Path]:
|
|
|
|
|
+ if not detail_note_images_directory_path.is_dir():
|
|
|
|
|
+ return []
|
|
|
|
|
+ pattern = re.compile(r"detail_note_image_(\d+)", re.IGNORECASE)
|
|
|
|
|
+ candidates: list[tuple[tuple[int, ...], Path]] = []
|
|
|
|
|
+ for image_file_path in detail_note_images_directory_path.iterdir():
|
|
|
|
|
+ if not image_file_path.is_file():
|
|
|
|
|
+ continue
|
|
|
|
|
+ match = pattern.search(image_file_path.name)
|
|
|
|
|
+ key = (int(match.group(1)),) if match else (0, image_file_path.name)
|
|
|
|
|
+ candidates.append((key, image_file_path))
|
|
|
|
|
+ candidates.sort(key=lambda item: item[0])
|
|
|
|
|
+ return [path for _key, path in candidates]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _read_note_detail_title_text(note_detail_sequence_directory_path: Path) -> str:
|
|
|
|
|
+ note_detail_title_file_path = note_detail_sequence_directory_path / "note_detail_title.txt"
|
|
|
|
|
+ if not note_detail_title_file_path.is_file():
|
|
|
|
|
+ return ""
|
|
|
|
|
+ return note_detail_title_file_path.read_text(encoding="utf-8").strip()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _read_note_detail_body_metadata_dictionary(
|
|
|
|
|
+ note_detail_sequence_directory_path: Path,
|
|
|
|
|
+) -> dict[str, object]:
|
|
|
|
|
+ note_detail_body_json_file_path = note_detail_sequence_directory_path / "note_detail_body.json"
|
|
|
|
|
+ if not note_detail_body_json_file_path.is_file():
|
|
|
|
|
+ return {}
|
|
|
|
|
+ return json.loads(note_detail_body_json_file_path.read_text(encoding="utf-8"))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _read_note_card_metadata_dictionary(
|
|
|
|
|
+ note_output_sequence_directory_path: Path,
|
|
|
|
|
+) -> dict[str, object]:
|
|
|
|
|
+ note_card_metadata_json_file_path = note_output_sequence_directory_path / "note_card_metadata.json"
|
|
|
|
|
+ if not note_card_metadata_json_file_path.is_file():
|
|
|
|
|
+ return {}
|
|
|
|
|
+ return json.loads(note_card_metadata_json_file_path.read_text(encoding="utf-8"))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _build_html_document_fragment_for_one_note_detail_sequence(
|
|
|
|
|
+ note_detail_sequence_directory_path: Path,
|
|
|
|
|
+ note_detail_sequence_directory_name: str,
|
|
|
|
|
+ note_card_metadata_dictionary: dict[str, object],
|
|
|
|
|
+) -> str:
|
|
|
|
|
+ title_text = _read_note_detail_title_text(note_detail_sequence_directory_path)
|
|
|
|
|
+ body_metadata_dictionary = _read_note_detail_body_metadata_dictionary(
|
|
|
|
|
+ note_detail_sequence_directory_path,
|
|
|
|
|
+ )
|
|
|
|
|
+ detail_body_plain_text = str(
|
|
|
|
|
+ body_metadata_dictionary.get("detail_body_plain_text") or "",
|
|
|
|
|
+ )
|
|
|
|
|
+ detail_body_hashtag_object_list: list[object] = list(
|
|
|
|
|
+ body_metadata_dictionary.get("detail_body_hashtag_list") or [],
|
|
|
|
|
+ )
|
|
|
|
|
+ detail_body_emoji_image_url_list: list[object] = list(
|
|
|
|
|
+ body_metadata_dictionary.get("detail_body_emoji_image_url_list") or [],
|
|
|
|
|
+ )
|
|
|
|
|
+ card_note_title_text = str(note_card_metadata_dictionary.get("note_title") or "").strip()
|
|
|
|
|
+ author_name_text = str(note_card_metadata_dictionary.get("author_name") or "").strip()
|
|
|
|
|
+ publish_time_text = str(note_card_metadata_dictionary.get("publish_time") or "").strip()
|
|
|
|
|
+ likes_count_text = str(note_card_metadata_dictionary.get("likes_count") or "").strip()
|
|
|
|
|
+ display_title_text = title_text if title_text else card_note_title_text
|
|
|
|
|
+ detail_note_images_directory_path = (
|
|
|
|
|
+ note_detail_sequence_directory_path / "detail_note_images"
|
|
|
|
|
+ )
|
|
|
|
|
+ image_file_path_list = _natural_sort_detail_image_file_path_list(
|
|
|
|
|
+ detail_note_images_directory_path,
|
|
|
|
|
+ )
|
|
|
|
|
+ image_relative_path_string_list: list[str] = []
|
|
|
|
|
+ for image_file_path in image_file_path_list:
|
|
|
|
|
+ relative_path = Path(note_detail_sequence_directory_name) / "detail_note_images" / image_file_path.name
|
|
|
|
|
+ image_relative_path_string_list.append(
|
|
|
|
|
+ relative_path.as_posix(),
|
|
|
|
|
+ )
|
|
|
|
|
+ display_title_html = escape(display_title_text) if display_title_text else ""
|
|
|
|
|
+ author_name_html = escape(author_name_text) if author_name_text else ""
|
|
|
|
|
+ publish_time_html = escape(publish_time_text) if publish_time_text else ""
|
|
|
|
|
+ likes_count_html = escape(likes_count_text) if likes_count_text else ""
|
|
|
|
|
+ hashtag_link_html_fragment_list: list[str] = []
|
|
|
|
|
+ for hashtag_object in detail_body_hashtag_object_list:
|
|
|
|
|
+ if not isinstance(hashtag_object, dict):
|
|
|
|
|
+ continue
|
|
|
|
|
+ display_text = str(hashtag_object.get("display_text") or "").strip()
|
|
|
|
|
+ relative_href = str(hashtag_object.get("relative_href") or "").strip()
|
|
|
|
|
+ if not display_text:
|
|
|
|
|
+ continue
|
|
|
|
|
+ if relative_href.startswith("/"):
|
|
|
|
|
+ full_href = f"https://www.xiaohongshu.com{relative_href}"
|
|
|
|
|
+ elif relative_href.startswith("http"):
|
|
|
|
|
+ full_href = relative_href
|
|
|
|
|
+ else:
|
|
|
|
|
+ full_href = relative_href
|
|
|
|
|
+ hashtag_link_html_fragment_list.append(
|
|
|
|
|
+ f'<a class="hashtag" href="{escape(full_href, quote=True)}">{escape(display_text)}</a>',
|
|
|
|
|
+ )
|
|
|
|
|
+ body_html = ""
|
|
|
|
|
+ if detail_body_plain_text.strip():
|
|
|
|
|
+ body_html = (
|
|
|
|
|
+ f'<div class="note-detail-plain-body">{escape(detail_body_plain_text)}</div>'
|
|
|
|
|
+ )
|
|
|
|
|
+ hashtag_section_html = ""
|
|
|
|
|
+ if not detail_body_plain_text.strip() and hashtag_link_html_fragment_list:
|
|
|
|
|
+ hashtag_section_html = (
|
|
|
|
|
+ '<div class="hashtag-row">'
|
|
|
|
|
+ + " ".join(hashtag_link_html_fragment_list)
|
|
|
|
|
+ + "</div>"
|
|
|
|
|
+ )
|
|
|
|
|
+ emoji_row_html_fragment_list: list[str] = []
|
|
|
|
|
+ for emoji_image_url_object in detail_body_emoji_image_url_list:
|
|
|
|
|
+ emoji_url = str(emoji_image_url_object or "").strip()
|
|
|
|
|
+ if not emoji_url.startswith("http"):
|
|
|
|
|
+ continue
|
|
|
|
|
+ emoji_row_html_fragment_list.append(
|
|
|
|
|
+ f'<img class="inline-emoji" src="{escape(emoji_url, quote=True)}" alt="" />',
|
|
|
|
|
+ )
|
|
|
|
|
+ emoji_section_html = ""
|
|
|
|
|
+ if emoji_row_html_fragment_list:
|
|
|
|
|
+ emoji_section_html = (
|
|
|
|
|
+ '<div class="emoji-row">' + "".join(emoji_row_html_fragment_list) + "</div>"
|
|
|
|
|
+ )
|
|
|
|
|
+ carousel_slide_html_fragment_list: list[str] = []
|
|
|
|
|
+ for image_relative_path_string in image_relative_path_string_list:
|
|
|
|
|
+ carousel_slide_html_fragment_list.append(
|
|
|
|
|
+ '<div class="carousel-slide">'
|
|
|
|
|
+ f'<img src="{escape(image_relative_path_string, quote=True)}" alt="" />'
|
|
|
|
|
+ "</div>",
|
|
|
|
|
+ )
|
|
|
|
|
+ has_local_detail_image_file_for_carousel = bool(carousel_slide_html_fragment_list)
|
|
|
|
|
+ slides_inner_html = "".join(carousel_slide_html_fragment_list)
|
|
|
|
|
+ slide_count_integer = len(carousel_slide_html_fragment_list)
|
|
|
|
|
+ article_id_attribute_value = escape(note_detail_sequence_directory_name, quote=True)
|
|
|
|
|
+ meta_heading_block_html = ""
|
|
|
|
|
+ if author_name_html:
|
|
|
|
|
+ meta_heading_block_html += (
|
|
|
|
|
+ f'<div class="meta-author-row"><span class="meta-author-name">{author_name_html}</span></div>'
|
|
|
|
|
+ )
|
|
|
|
|
+ if display_title_html:
|
|
|
|
|
+ meta_heading_block_html += f'<h1 class="meta-detail-title">{display_title_html}</h1>'
|
|
|
|
|
+ stats_row_html_fragment_list: list[str] = []
|
|
|
|
|
+ if likes_count_html:
|
|
|
|
|
+ stats_row_html_fragment_list.append(
|
|
|
|
|
+ f'<span class="meta-stat-item"><span class="meta-stat-label">赞</span>'
|
|
|
|
|
+ f'<span class="meta-stat-value">{likes_count_html}</span></span>',
|
|
|
|
|
+ )
|
|
|
|
|
+ if publish_time_html:
|
|
|
|
|
+ stats_row_html_fragment_list.append(
|
|
|
|
|
+ f'<span class="meta-stat-item meta-publish-time">{publish_time_html}</span>',
|
|
|
|
|
+ )
|
|
|
|
|
+ stats_row_html = ""
|
|
|
|
|
+ if stats_row_html_fragment_list:
|
|
|
|
|
+ stats_row_html = (
|
|
|
|
|
+ '<div class="meta-stats-row">' + "".join(stats_row_html_fragment_list) + "</div>"
|
|
|
|
|
+ )
|
|
|
|
|
+ meta_body_inner_html_fragment_list: list[str] = []
|
|
|
|
|
+ if body_html:
|
|
|
|
|
+ meta_body_inner_html_fragment_list.append(body_html)
|
|
|
|
|
+ if emoji_section_html:
|
|
|
|
|
+ meta_body_inner_html_fragment_list.append(emoji_section_html)
|
|
|
|
|
+ if hashtag_section_html:
|
|
|
|
|
+ meta_body_inner_html_fragment_list.append(hashtag_section_html)
|
|
|
|
|
+ meta_body_block_html = ""
|
|
|
|
|
+ if meta_body_inner_html_fragment_list:
|
|
|
|
|
+ meta_body_block_html = (
|
|
|
|
|
+ '<div class="meta-body-block">'
|
|
|
|
|
+ + "".join(meta_body_inner_html_fragment_list)
|
|
|
|
|
+ + "</div>"
|
|
|
|
|
+ )
|
|
|
|
|
+ note_detail_carousel_column_html = ""
|
|
|
|
|
+ if has_local_detail_image_file_for_carousel:
|
|
|
|
|
+ carousel_dot_button_html_fragment_list: list[str] = []
|
|
|
|
|
+ for carousel_dot_zero_based_index in range(slide_count_integer):
|
|
|
|
|
+ carousel_dot_active_css_class_suffix = (
|
|
|
|
|
+ " is-active" if carousel_dot_zero_based_index == 0 else ""
|
|
|
|
|
+ )
|
|
|
|
|
+ carousel_dot_button_html_fragment_list.append(
|
|
|
|
|
+ '<button type="button" class="carousel-dot'
|
|
|
|
|
+ + carousel_dot_active_css_class_suffix
|
|
|
|
|
+ + '" data-carousel-dot-index="'
|
|
|
|
|
+ + str(carousel_dot_zero_based_index)
|
|
|
|
|
+ + '" aria-label="第 '
|
|
|
|
|
+ + str(carousel_dot_zero_based_index + 1)
|
|
|
|
|
+ + ' 张"></button>',
|
|
|
|
|
+ )
|
|
|
|
|
+ carousel_dots_inner_html = "".join(carousel_dot_button_html_fragment_list)
|
|
|
|
|
+ carousel_counter_text_initial = f"1/{slide_count_integer}"
|
|
|
|
|
+ note_detail_carousel_column_html = f"""<div class="note-detail-carousel-column">
|
|
|
|
|
+<div class="carousel-stage">
|
|
|
|
|
+<div class="carousel-scroll-viewport" data-carousel-viewport tabindex="0" aria-label="笔记图片,可横向滑动或滚轮翻页">
|
|
|
|
|
+<div class="carousel-track">
|
|
|
|
|
+{slides_inner_html}
|
|
|
|
|
+</div>
|
|
|
|
|
+</div>
|
|
|
|
|
+<div class="carousel-counter-pill" data-carousel-counter>{carousel_counter_text_initial}</div>
|
|
|
|
|
+<div class="carousel-dots" data-carousel-dots>
|
|
|
|
|
+{carousel_dots_inner_html}
|
|
|
|
|
+</div>
|
|
|
|
|
+</div>
|
|
|
|
|
+</div>"""
|
|
|
|
|
+ meta_card_inner_html = (
|
|
|
|
|
+ meta_heading_block_html + stats_row_html + meta_body_block_html
|
|
|
|
|
+ )
|
|
|
|
|
+ shell_modifier_class_list: list[str] = []
|
|
|
|
|
+ if not has_local_detail_image_file_for_carousel:
|
|
|
|
|
+ shell_modifier_class_list.append("note-detail-shell--no-carousel")
|
|
|
|
|
+ if has_local_detail_image_file_for_carousel and not meta_card_inner_html.strip():
|
|
|
|
|
+ shell_modifier_class_list.append("note-detail-shell--carousel-only")
|
|
|
|
|
+ if not has_local_detail_image_file_for_carousel and meta_card_inner_html.strip():
|
|
|
|
|
+ shell_modifier_class_list.append("note-detail-shell--meta-only")
|
|
|
|
|
+ shell_modifier_class_html = (
|
|
|
|
|
+ " " + " ".join(shell_modifier_class_list) if shell_modifier_class_list else ""
|
|
|
|
|
+ )
|
|
|
|
|
+ note_detail_meta_column_html = ""
|
|
|
|
|
+ if meta_card_inner_html.strip():
|
|
|
|
|
+ note_detail_meta_column_html = f"""<div class="note-detail-meta-column">
|
|
|
|
|
+<div class="meta-card">
|
|
|
|
|
+{meta_card_inner_html}
|
|
|
|
|
+</div>
|
|
|
|
|
+</div>"""
|
|
|
|
|
+ if not note_detail_carousel_column_html and not note_detail_meta_column_html:
|
|
|
|
|
+ return f"""<article class="note-detail-shell note-detail-shell--empty{shell_modifier_class_html}" id="note-detail-{article_id_attribute_value}"></article>"""
|
|
|
|
|
+ return f"""<article class="note-detail-shell{shell_modifier_class_html}" id="note-detail-{article_id_attribute_value}">
|
|
|
|
|
+{note_detail_carousel_column_html}
|
|
|
|
|
+{note_detail_meta_column_html}
|
|
|
|
|
+</article>"""
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _build_full_html_document_string(
|
|
|
|
|
+ note_detail_sequence_directory_path_list: list[Path],
|
|
|
|
|
+ note_card_metadata_by_sequence_name_dictionary: dict[str, dict[str, object]],
|
|
|
|
|
+) -> str:
|
|
|
|
|
+ fragment_list: list[str] = []
|
|
|
|
|
+ for note_detail_sequence_directory_path in note_detail_sequence_directory_path_list:
|
|
|
|
|
+ sequence_name = note_detail_sequence_directory_path.name
|
|
|
|
|
+ note_card_metadata_dictionary = note_card_metadata_by_sequence_name_dictionary.get(
|
|
|
|
|
+ sequence_name,
|
|
|
|
|
+ {},
|
|
|
|
|
+ )
|
|
|
|
|
+ fragment_list.append(
|
|
|
|
|
+ _build_html_document_fragment_for_one_note_detail_sequence(
|
|
|
|
|
+ note_detail_sequence_directory_path,
|
|
|
|
|
+ sequence_name,
|
|
|
|
|
+ note_card_metadata_dictionary,
|
|
|
|
|
+ ),
|
|
|
|
|
+ )
|
|
|
|
|
+ inner = "\n".join(fragment_list)
|
|
|
|
|
+ page_title_escaped = escape(_PREVIEW_HTML_DOCUMENT_PAGE_TITLE_VISIBLE_TEXT)
|
|
|
|
|
+ stylesheet = """
|
|
|
|
|
+:root {
|
|
|
|
|
+ color-scheme: light dark;
|
|
|
|
|
+ --xhs-red: #ff2442;
|
|
|
|
|
+ --xhs-red-soft: rgba(255, 36, 66, 0.12);
|
|
|
|
|
+ --panel-bg: #fff;
|
|
|
|
|
+ --page-bg-top: #f8f6f7;
|
|
|
|
|
+ --page-bg-bottom: #eef0f5;
|
|
|
|
|
+ --text: #1a1a1a;
|
|
|
|
|
+ --muted: rgba(0,0,0,0.48);
|
|
|
|
|
+ --radius-lg: 18px;
|
|
|
|
|
+ --shadow-card: 0 4px 24px rgba(15, 23, 42, 0.08), 0 1px 3px rgba(15, 23, 42, 0.04);
|
|
|
|
|
+ --shadow-card-hover: 0 8px 32px rgba(15, 23, 42, 0.1);
|
|
|
|
|
+}
|
|
|
|
|
+* { box-sizing: border-box; }
|
|
|
|
|
+body {
|
|
|
|
|
+ margin: 0;
|
|
|
|
|
+ min-height: 100vh;
|
|
|
|
|
+ font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif;
|
|
|
|
|
+ color: var(--text);
|
|
|
|
|
+ background: linear-gradient(168deg, var(--page-bg-top) 0%, var(--page-bg-bottom) 55%, #e6e9f0 100%);
|
|
|
|
|
+ background-attachment: fixed;
|
|
|
|
|
+ letter-spacing: 0.01em;
|
|
|
|
|
+}
|
|
|
|
|
+.preview-page-header {
|
|
|
|
|
+ max-width: 1080px;
|
|
|
|
|
+ margin: 0 auto;
|
|
|
|
|
+ padding: 28px 20px 8px;
|
|
|
|
|
+}
|
|
|
|
|
+.preview-page-header-inner {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ align-items: flex-start;
|
|
|
|
|
+ gap: 10px;
|
|
|
|
|
+}
|
|
|
|
|
+.preview-page-title {
|
|
|
|
|
+ margin: 0;
|
|
|
|
|
+ font-size: clamp(1.35rem, 3.5vw, 1.65rem);
|
|
|
|
|
+ font-weight: 700;
|
|
|
|
|
+ letter-spacing: 0.04em;
|
|
|
|
|
+ line-height: 1.25;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 12px;
|
|
|
|
|
+}
|
|
|
|
|
+.preview-page-title-mark {
|
|
|
|
|
+ display: inline-block;
|
|
|
|
|
+ width: 4px;
|
|
|
|
|
+ height: 1.15em;
|
|
|
|
|
+ border-radius: 2px;
|
|
|
|
|
+ background: linear-gradient(180deg, var(--xhs-red) 0%, #ff6b6b 100%);
|
|
|
|
|
+ flex-shrink: 0;
|
|
|
|
|
+}
|
|
|
|
|
+.preview-page-title-text {
|
|
|
|
|
+ background: linear-gradient(90deg, #1a1a1a 0%, #3d3d3d 100%);
|
|
|
|
|
+ -webkit-background-clip: text;
|
|
|
|
|
+ background-clip: text;
|
|
|
|
|
+ color: transparent;
|
|
|
|
|
+}
|
|
|
|
|
+@media (prefers-color-scheme: dark) {
|
|
|
|
|
+ .preview-page-title-text {
|
|
|
|
|
+ background: linear-gradient(90deg, #f5f5f7 0%, #d1d1d6 100%);
|
|
|
|
|
+ -webkit-background-clip: text;
|
|
|
|
|
+ background-clip: text;
|
|
|
|
|
+ color: transparent;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+.preview-main {
|
|
|
|
|
+ max-width: 1080px;
|
|
|
|
|
+ margin: 0 auto;
|
|
|
|
|
+ padding: 12px 16px 56px;
|
|
|
|
|
+}
|
|
|
|
|
+.note-detail-shell {
|
|
|
|
|
+ display: flex; flex-wrap: wrap; align-items: stretch; gap: 0;
|
|
|
|
|
+ background: var(--panel-bg);
|
|
|
|
|
+ border-radius: var(--radius-lg);
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ box-shadow: var(--shadow-card);
|
|
|
|
|
+ border: 1px solid rgba(0,0,0,0.04);
|
|
|
|
|
+ margin-bottom: 28px;
|
|
|
|
|
+ min-height: min(72vh, 640px);
|
|
|
|
|
+ transition: box-shadow 0.25s ease;
|
|
|
|
|
+}
|
|
|
|
|
+.note-detail-shell:hover {
|
|
|
|
|
+ box-shadow: var(--shadow-card-hover);
|
|
|
|
|
+}
|
|
|
|
|
+.note-detail-shell--no-carousel { min-height: unset; }
|
|
|
|
|
+.note-detail-shell--meta-only .note-detail-meta-column {
|
|
|
|
|
+ flex: 1 1 100%; max-width: 100%;
|
|
|
|
|
+}
|
|
|
|
|
+.note-detail-shell--carousel-only .note-detail-carousel-column {
|
|
|
|
|
+ flex: 1 1 100%; max-width: 100%;
|
|
|
|
|
+}
|
|
|
|
|
+.note-detail-shell--empty {
|
|
|
|
|
+ min-height: 0; margin-bottom: 12px; box-shadow: none; background: transparent;
|
|
|
|
|
+}
|
|
|
|
|
+.note-detail-carousel-column {
|
|
|
|
|
+ flex: 1 1 340px; min-width: min(100%, 340px);
|
|
|
|
|
+ background: linear-gradient(180deg, #121212 0%, #0a0a0a 100%);
|
|
|
|
|
+ display: flex; flex-direction: column;
|
|
|
|
|
+}
|
|
|
|
|
+.carousel-stage {
|
|
|
|
|
+ position: relative;
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ min-height: 280px;
|
|
|
|
|
+}
|
|
|
|
|
+.carousel-scroll-viewport {
|
|
|
|
|
+ flex: 1; overflow-x: auto; overflow-y: hidden;
|
|
|
|
|
+ scroll-snap-type: x mandatory;
|
|
|
|
|
+ scrollbar-width: none;
|
|
|
|
|
+ -ms-overflow-style: none;
|
|
|
|
|
+ outline: none;
|
|
|
|
|
+ touch-action: pan-x;
|
|
|
|
|
+}
|
|
|
|
|
+.carousel-scroll-viewport::-webkit-scrollbar { display: none; width: 0; height: 0; }
|
|
|
|
|
+.carousel-scroll-viewport:focus-visible { box-shadow: inset 0 0 0 2px var(--xhs-red); }
|
|
|
|
|
+.carousel-track { display: flex; flex-direction: row; height: 100%; min-height: 280px; }
|
|
|
|
|
+.carousel-counter-pill {
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ top: 12px;
|
|
|
|
|
+ right: 12px;
|
|
|
|
|
+ z-index: 2;
|
|
|
|
|
+ padding: 4px 10px;
|
|
|
|
|
+ border-radius: 999px;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ font-variant-numeric: tabular-nums;
|
|
|
|
|
+ color: #fff;
|
|
|
|
|
+ background: rgba(0, 0, 0, 0.45);
|
|
|
|
|
+ backdrop-filter: blur(6px);
|
|
|
|
|
+ pointer-events: none;
|
|
|
|
|
+ line-height: 1.35;
|
|
|
|
|
+}
|
|
|
|
|
+.carousel-dots {
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ bottom: 14px;
|
|
|
|
|
+ left: 50%;
|
|
|
|
|
+ transform: translateX(-50%);
|
|
|
|
|
+ z-index: 2;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-wrap: wrap;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 7px;
|
|
|
|
|
+ max-width: calc(100% - 24px);
|
|
|
|
|
+ pointer-events: auto;
|
|
|
|
|
+}
|
|
|
|
|
+.carousel-dot {
|
|
|
|
|
+ width: 6px;
|
|
|
|
|
+ height: 6px;
|
|
|
|
|
+ padding: 0;
|
|
|
|
|
+ border: none;
|
|
|
|
|
+ border-radius: 50%;
|
|
|
|
|
+ background: rgba(255, 255, 255, 0.38);
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ flex-shrink: 0;
|
|
|
|
|
+ transition: transform 0.2s ease, background 0.2s ease;
|
|
|
|
|
+}
|
|
|
|
|
+.carousel-dot:hover { background: rgba(255, 255, 255, 0.65); }
|
|
|
|
|
+.carousel-dot.is-active {
|
|
|
|
|
+ background: #fff;
|
|
|
|
|
+ transform: scale(1.2);
|
|
|
|
|
+ box-shadow: 0 0 0 1px rgba(0,0,0,0.15);
|
|
|
|
|
+}
|
|
|
|
|
+.carousel-slide {
|
|
|
|
|
+ flex: 0 0 100%; scroll-snap-align: start; scroll-snap-stop: always;
|
|
|
|
|
+ display: flex; align-items: center; justify-content: center;
|
|
|
|
|
+ box-sizing: border-box; padding: 8px;
|
|
|
|
|
+}
|
|
|
|
|
+.carousel-slide img {
|
|
|
|
|
+ max-width: 100%; max-height: min(72vh, 620px); width: auto; height: auto;
|
|
|
|
|
+ object-fit: contain; vertical-align: middle; border-radius: 4px;
|
|
|
|
|
+}
|
|
|
|
|
+.note-detail-meta-column {
|
|
|
|
|
+ flex: 1 1 300px; min-width: min(100%, 280px); max-width: 100%;
|
|
|
|
|
+ box-sizing: border-box;
|
|
|
|
|
+}
|
|
|
|
|
+.meta-card {
|
|
|
|
|
+ padding: 24px 24px 28px;
|
|
|
|
|
+}
|
|
|
|
|
+.meta-author-row { margin-bottom: 12px; }
|
|
|
|
|
+.meta-author-name {
|
|
|
|
|
+ font-size: 15px;
|
|
|
|
|
+ font-weight: 600;
|
|
|
|
|
+ color: var(--text);
|
|
|
|
|
+}
|
|
|
|
|
+.meta-detail-title {
|
|
|
|
|
+ font-size: clamp(17px, 2.5vw, 20px);
|
|
|
|
|
+ font-weight: 600;
|
|
|
|
|
+ margin: 0 0 16px;
|
|
|
|
|
+ line-height: 1.45;
|
|
|
|
|
+ letter-spacing: 0.02em;
|
|
|
|
|
+}
|
|
|
|
|
+.meta-stats-row {
|
|
|
|
|
+ display: flex; flex-wrap: wrap; align-items: center; gap: 14px 22px;
|
|
|
|
|
+ margin-bottom: 18px; padding-bottom: 18px;
|
|
|
|
|
+ border-bottom: 1px solid rgba(0,0,0,0.07);
|
|
|
|
|
+ font-size: 13px; color: var(--muted);
|
|
|
|
|
+}
|
|
|
|
|
+.meta-stat-item { display: inline-flex; align-items: baseline; gap: 5px; }
|
|
|
|
|
+.meta-stat-label { opacity: 0.72; font-size: 12px; }
|
|
|
|
|
+.meta-stat-value {
|
|
|
|
|
+ color: var(--text);
|
|
|
|
|
+ font-weight: 600;
|
|
|
|
|
+ font-variant-numeric: tabular-nums;
|
|
|
|
|
+}
|
|
|
|
|
+.note-detail-plain-body {
|
|
|
|
|
+ white-space: pre-wrap; word-break: break-word;
|
|
|
|
|
+ line-height: 1.75;
|
|
|
|
|
+ font-size: 15px;
|
|
|
|
|
+ color: rgba(0,0,0,0.88);
|
|
|
|
|
+}
|
|
|
|
|
+.hashtag-row { margin-top: 14px; line-height: 1.9; }
|
|
|
|
|
+a.hashtag { color: #13386c; text-decoration: none; margin-right: 8px; }
|
|
|
|
|
+a.hashtag:hover { text-decoration: underline; }
|
|
|
|
|
+.emoji-row { margin-top: 8px; }
|
|
|
|
|
+img.inline-emoji { height: 1.15em; vertical-align: -0.2em; }
|
|
|
|
|
+@media (prefers-color-scheme: dark) {
|
|
|
|
|
+ :root {
|
|
|
|
|
+ --panel-bg: #2c2c2e;
|
|
|
|
|
+ --page-bg-top: #1c1c1f;
|
|
|
|
|
+ --page-bg-bottom: #121214;
|
|
|
|
|
+ --text: #f2f2f7;
|
|
|
|
|
+ --muted: rgba(255,255,255,0.48);
|
|
|
|
|
+ --shadow-card: 0 4px 28px rgba(0,0,0,0.35);
|
|
|
|
|
+ --shadow-card-hover: 0 8px 36px rgba(0,0,0,0.45);
|
|
|
|
|
+ }
|
|
|
|
|
+ body {
|
|
|
|
|
+ background: linear-gradient(168deg, var(--page-bg-top) 0%, var(--page-bg-bottom) 100%);
|
|
|
|
|
+ }
|
|
|
|
|
+ a.hashtag { color: #8ab4ff; }
|
|
|
|
|
+ .meta-stats-row { border-bottom-color: rgba(255,255,255,0.1); }
|
|
|
|
|
+ .note-detail-plain-body { color: rgba(255,255,255,0.88); }
|
|
|
|
|
+ .note-detail-shell { border-color: rgba(255,255,255,0.06); }
|
|
|
|
|
+}
|
|
|
|
|
+@media (max-width: 720px) {
|
|
|
|
|
+ .preview-page-header { padding: 20px 16px 4px; }
|
|
|
|
|
+ .note-detail-shell { flex-direction: column; min-height: unset; }
|
|
|
|
|
+ .note-detail-carousel-column { min-height: 42vh; }
|
|
|
|
|
+ .carousel-counter-pill { top: 8px; right: 8px; font-size: 11px; padding: 3px 8px; }
|
|
|
|
|
+ .carousel-dots { bottom: 10px; gap: 6px; }
|
|
|
|
|
+}
|
|
|
|
|
+"""
|
|
|
|
|
+ carousel_script = """
|
|
|
|
|
+(function () {
|
|
|
|
|
+ var carouselAutoAdvanceMilliseconds = 4000;
|
|
|
|
|
+ function carouselViewportCurrentSlideZeroBasedIndex(carouselViewportElement, slideCount) {
|
|
|
|
|
+ var w = carouselViewportElement.clientWidth;
|
|
|
|
|
+ if (w <= 0) return 0;
|
|
|
|
|
+ var i = Math.round(carouselViewportElement.scrollLeft / w);
|
|
|
|
|
+ if (i < 0) i = 0;
|
|
|
|
|
+ if (i >= slideCount) i = slideCount - 1;
|
|
|
|
|
+ return i;
|
|
|
|
|
+ }
|
|
|
|
|
+ function carouselViewportScrollToSlideZeroBasedIndex(carouselViewportElement, slideZeroBasedIndex, slideCount) {
|
|
|
|
|
+ var w = carouselViewportElement.clientWidth;
|
|
|
|
|
+ if (w <= 0) return;
|
|
|
|
|
+ if (slideZeroBasedIndex < 0) slideZeroBasedIndex = 0;
|
|
|
|
|
+ if (slideZeroBasedIndex >= slideCount) slideZeroBasedIndex = slideCount - 1;
|
|
|
|
|
+ carouselViewportElement.scrollTo({ left: slideZeroBasedIndex * w, behavior: "smooth" });
|
|
|
|
|
+ }
|
|
|
|
|
+ function syncCarouselDotsAndCounterFromScroll(carouselViewportElement) {
|
|
|
|
|
+ var stage = carouselViewportElement.closest(".carousel-stage");
|
|
|
|
|
+ var track = carouselViewportElement.querySelector(".carousel-track");
|
|
|
|
|
+ var slides = track ? track.querySelectorAll(".carousel-slide") : [];
|
|
|
|
|
+ var slideCount = slides.length;
|
|
|
|
|
+ if (!stage || !slideCount) return;
|
|
|
|
|
+ var counter = stage.querySelector("[data-carousel-counter]");
|
|
|
|
|
+ var dotNodeList = stage.querySelectorAll("[data-carousel-dot-index]");
|
|
|
|
|
+ var activeIndex = carouselViewportCurrentSlideZeroBasedIndex(carouselViewportElement, slideCount);
|
|
|
|
|
+ if (counter) {
|
|
|
|
|
+ counter.textContent = (activeIndex + 1) + "/" + slideCount;
|
|
|
|
|
+ }
|
|
|
|
|
+ dotNodeList.forEach(function (dotElement) {
|
|
|
|
|
+ var dotIndex = parseInt(dotElement.getAttribute("data-carousel-dot-index"), 10);
|
|
|
|
|
+ if (dotIndex === activeIndex) {
|
|
|
|
|
+ dotElement.classList.add("is-active");
|
|
|
|
|
+ } else {
|
|
|
|
|
+ dotElement.classList.remove("is-active");
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ function clearCarouselAutoAdvanceTimer(carouselViewportElement) {
|
|
|
|
|
+ var timerId = carouselViewportElement.__carouselAutoAdvanceIntervalId;
|
|
|
|
|
+ if (timerId) {
|
|
|
|
|
+ clearInterval(timerId);
|
|
|
|
|
+ carouselViewportElement.__carouselAutoAdvanceIntervalId = null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ function startCarouselAutoAdvanceIfMultipleSlides(carouselViewportElement) {
|
|
|
|
|
+ clearCarouselAutoAdvanceTimer(carouselViewportElement);
|
|
|
|
|
+ var track = carouselViewportElement.querySelector(".carousel-track");
|
|
|
|
|
+ var slides = track ? track.querySelectorAll(".carousel-slide") : [];
|
|
|
|
|
+ if (slides.length <= 1) return;
|
|
|
|
|
+ carouselViewportElement.__carouselAutoAdvanceIntervalId = setInterval(function () {
|
|
|
|
|
+ var slideCount = slides.length;
|
|
|
|
|
+ var w = carouselViewportElement.clientWidth;
|
|
|
|
|
+ if (w <= 0) return;
|
|
|
|
|
+ var currentIndex = carouselViewportCurrentSlideZeroBasedIndex(carouselViewportElement, slideCount);
|
|
|
|
|
+ var nextIndex = (currentIndex + 1) % slideCount;
|
|
|
|
|
+ carouselViewportElement.scrollTo({ left: nextIndex * w, behavior: "smooth" });
|
|
|
|
|
+ }, carouselAutoAdvanceMilliseconds);
|
|
|
|
|
+ }
|
|
|
|
|
+ function bindCarouselViewport(carouselViewportElement) {
|
|
|
|
|
+ var stage = carouselViewportElement.closest(".carousel-stage");
|
|
|
|
|
+ var track = carouselViewportElement.querySelector(".carousel-track");
|
|
|
|
|
+ var slides = track ? track.querySelectorAll(".carousel-slide") : [];
|
|
|
|
|
+ var slideCount = slides.length;
|
|
|
|
|
+ carouselViewportElement.addEventListener("scroll", function () {
|
|
|
|
|
+ syncCarouselDotsAndCounterFromScroll(carouselViewportElement);
|
|
|
|
|
+ });
|
|
|
|
|
+ carouselViewportElement.addEventListener("wheel", function (wheelEvent) {
|
|
|
|
|
+ if (Math.abs(wheelEvent.deltaY) <= Math.abs(wheelEvent.deltaX)) return;
|
|
|
|
|
+ wheelEvent.preventDefault();
|
|
|
|
|
+ carouselViewportElement.scrollLeft += wheelEvent.deltaY;
|
|
|
|
|
+ }, { passive: false });
|
|
|
|
|
+ if (stage) {
|
|
|
|
|
+ stage.addEventListener("mouseenter", function () {
|
|
|
|
|
+ clearCarouselAutoAdvanceTimer(carouselViewportElement);
|
|
|
|
|
+ });
|
|
|
|
|
+ stage.addEventListener("mouseleave", function () {
|
|
|
|
|
+ startCarouselAutoAdvanceIfMultipleSlides(carouselViewportElement);
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ if (stage) {
|
|
|
|
|
+ stage.querySelectorAll("[data-carousel-dot-index]").forEach(function (dotElement) {
|
|
|
|
|
+ dotElement.addEventListener("click", function () {
|
|
|
|
|
+ var targetIndex = parseInt(dotElement.getAttribute("data-carousel-dot-index"), 10);
|
|
|
|
|
+ carouselViewportScrollToSlideZeroBasedIndex(carouselViewportElement, targetIndex, slideCount);
|
|
|
|
|
+ clearCarouselAutoAdvanceTimer(carouselViewportElement);
|
|
|
|
|
+ startCarouselAutoAdvanceIfMultipleSlides(carouselViewportElement);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ syncCarouselDotsAndCounterFromScroll(carouselViewportElement);
|
|
|
|
|
+ startCarouselAutoAdvanceIfMultipleSlides(carouselViewportElement);
|
|
|
|
|
+ }
|
|
|
|
|
+ document.querySelectorAll("[data-carousel-viewport]").forEach(bindCarouselViewport);
|
|
|
|
|
+ window.addEventListener("resize", function () {
|
|
|
|
|
+ document.querySelectorAll("[data-carousel-viewport]").forEach(function (carouselViewportElement) {
|
|
|
|
|
+ syncCarouselDotsAndCounterFromScroll(carouselViewportElement);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+})();
|
|
|
|
|
+"""
|
|
|
|
|
+ return f"""<!DOCTYPE html>
|
|
|
|
|
+<html lang="zh-CN">
|
|
|
|
|
+<head>
|
|
|
|
|
+<meta charset="utf-8" />
|
|
|
|
|
+<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
|
|
|
+<title>{page_title_escaped}</title>
|
|
|
|
|
+<style>{stylesheet}</style>
|
|
|
|
|
+</head>
|
|
|
|
|
+<body>
|
|
|
|
|
+<header class="preview-page-header">
|
|
|
|
|
+<div class="preview-page-header-inner">
|
|
|
|
|
+<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>
|
|
|
|
|
+</div>
|
|
|
|
|
+</header>
|
|
|
|
|
+<main class="preview-main">
|
|
|
|
|
+{inner}
|
|
|
|
|
+</main>
|
|
|
|
|
+<script>{carousel_script}</script>
|
|
|
|
|
+</body>
|
|
|
|
|
+</html>
|
|
|
|
|
+"""
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _collect_note_detail_sequence_directory_path_list(
|
|
|
|
|
+ only_sequence_name: str | None,
|
|
|
|
|
+) -> list[Path]:
|
|
|
|
|
+ if not _NOTE_DETAIL_OUTPUT_ROOT_DIRECTORY.is_dir():
|
|
|
|
|
+ return []
|
|
|
|
|
+ result: list[Path] = []
|
|
|
|
|
+ for child_path in sorted(
|
|
|
|
|
+ _NOTE_DETAIL_OUTPUT_ROOT_DIRECTORY.iterdir(),
|
|
|
|
|
+ key=lambda p: p.name,
|
|
|
|
|
+ ):
|
|
|
|
|
+ if not child_path.is_dir():
|
|
|
|
|
+ continue
|
|
|
|
|
+ if only_sequence_name is not None and child_path.name != only_sequence_name:
|
|
|
|
|
+ continue
|
|
|
|
|
+ result.append(child_path)
|
|
|
|
|
+ return result
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _build_note_card_metadata_by_sequence_name_dictionary(
|
|
|
|
|
+ sequence_name_list: list[str],
|
|
|
|
|
+) -> dict[str, dict[str, object]]:
|
|
|
|
|
+ result_dictionary: dict[str, dict[str, object]] = {}
|
|
|
|
|
+ for sequence_name in sequence_name_list:
|
|
|
|
|
+ note_output_sequence_directory_path = _NOTE_OUTPUT_ROOT_DIRECTORY / sequence_name
|
|
|
|
|
+ result_dictionary[sequence_name] = _read_note_card_metadata_dictionary(
|
|
|
|
|
+ note_output_sequence_directory_path,
|
|
|
|
|
+ )
|
|
|
|
|
+ return result_dictionary
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def start(command_line_argument_strings: list[str] | None = None) -> int:
|
|
|
|
|
+ arguments = (
|
|
|
|
|
+ command_line_argument_strings
|
|
|
|
|
+ if command_line_argument_strings is not None
|
|
|
|
|
+ else sys.argv[1:]
|
|
|
|
|
+ )
|
|
|
|
|
+ only_sequence_name: str | None = arguments[0].strip() if arguments else None
|
|
|
|
|
+ if only_sequence_name == "":
|
|
|
|
|
+ only_sequence_name = None
|
|
|
|
|
+ note_detail_sequence_directory_path_list = _collect_note_detail_sequence_directory_path_list(
|
|
|
|
|
+ only_sequence_name,
|
|
|
|
|
+ )
|
|
|
|
|
+ sequence_name_list = [p.name for p in note_detail_sequence_directory_path_list]
|
|
|
|
|
+ note_card_metadata_by_sequence_name_dictionary = (
|
|
|
|
|
+ _build_note_card_metadata_by_sequence_name_dictionary(sequence_name_list)
|
|
|
|
|
+ )
|
|
|
|
|
+ html_document_string = _build_full_html_document_string(
|
|
|
|
|
+ note_detail_sequence_directory_path_list,
|
|
|
|
|
+ note_card_metadata_by_sequence_name_dictionary,
|
|
|
|
|
+ )
|
|
|
|
|
+ _PREVIEW_HTML_OUTPUT_FILE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
+ _PREVIEW_HTML_OUTPUT_FILE_PATH.write_text(
|
|
|
|
|
+ html_document_string,
|
|
|
|
|
+ encoding="utf-8",
|
|
|
|
|
+ )
|
|
|
|
|
+ preview_html_file_uri_string = _PREVIEW_HTML_OUTPUT_FILE_PATH.resolve().as_uri()
|
|
|
|
|
+ webbrowser.open(preview_html_file_uri_string)
|
|
|
|
|
+ return 0
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def main() -> int:
|
|
|
|
|
+ return start()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+__all__ = ["start", "main", "_NOTE_DETAIL_OUTPUT_ROOT_DIRECTORY"]
|
|
|
|
|
+
|
|
|
|
|
+if __name__ == "__main__":
|
|
|
|
|
+ raise SystemExit(main())
|