yichael 1 tháng trước cách đây
mục cha
commit
d731a5c555

+ 2 - 1
.cursor/rules/project-rules.mdc

@@ -18,4 +18,5 @@ alwaysApply: true
 9. 不要做任何失败处理代码假设所有代码都会成功往下执行
 10.没有叫写注释的地方不要自动写注释
 11.我没有要求就不要自动生成新脚本,除非必要,要先得到我的许可才可以生成
-执行任务与回复结束前,自检是否符合第 1~11 条。
+12. 小红书笔记流水线落盘图片:字节只能来自 Playwright 已监听到的响应 body;**禁止**对图片 URL 做独立 HTTP(S) 拉取;标识与约定见 ``workplace/note_pipeline_forbidden_standalone_url_image_fetch.py`` 中的 ``XHS_NOTE_PIPELINE_ABSOLUTE_PROHIBITION_STANDALONE_HTTP_GET_FOR_IMAGE_BYTES``。
+执行任务与回复结束前,自检是否符合第 1~12 条。

BIN
workplace/__pycache__/ocr-pos.cpython-312.pyc


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 86 - 9
workplace/download-note/download-note.py


+ 49 - 17
workplace/download-page-sources/download-page-sources.py

@@ -21,7 +21,7 @@ import shutil
 import sys
 from pathlib import Path
 from typing import Any
-from urllib.parse import urlparse
+from urllib.parse import unquote, urlparse
 
 from playwright.sync_api import Page
 
@@ -29,6 +29,15 @@ _REPOSITORY_ROOT_DIRECTORY = Path(__file__).resolve().parent.parent.parent
 if str(_REPOSITORY_ROOT_DIRECTORY) not in sys.path:
     sys.path.insert(0, str(_REPOSITORY_ROOT_DIRECTORY))
 
+from workplace.note_pipeline_forbidden_standalone_url_image_fetch import (
+    XHS_NOTE_PIPELINE_ABSOLUTE_PROHIBITION_STANDALONE_HTTP_GET_FOR_IMAGE_BYTES,
+)
+
+_xhs_note_pipeline_image_bytes_must_not_use_standalone_http_get_at_module_scope = (
+    XHS_NOTE_PIPELINE_ABSOLUTE_PROHIBITION_STANDALONE_HTTP_GET_FOR_IMAGE_BYTES
+)
+_ = _xhs_note_pipeline_image_bytes_must_not_use_standalone_http_get_at_module_scope
+
 _EXTRACT_NOTE_FEED_CARD_ROWS_FOR_PAGE_CACHE_JS = r"""
 () => {
   const normalizePossibleImageUrl = (raw) => {
@@ -102,7 +111,9 @@ _EXTRACT_NOTE_FEED_CARD_ROWS_FOR_PAGE_CACHE_JS = r"""
     };
   }).filter((x) => {
     const u = (x.cover_url || "").trim();
-    return u.startsWith("http://") || u.startsWith("https://");
+    const hasHttpCover = u.startsWith("http://") || u.startsWith("https://");
+    const noteIdTrimmed = (x.note_id || "").trim();
+    return hasHttpCover || noteIdTrimmed.length > 0;
   });
 }
 """
@@ -152,20 +163,36 @@ def _image_file_suffix_from_cdn_url(image_url: str) -> str:
     return ".jpg"
 
 
+def _lookup_cached_image_body_bytes_for_request_url(
+    cached_image_body_by_request_url: dict[str, bytes],
+    resource_url: str,
+) -> bytes | None:
+    if resource_url in cached_image_body_by_request_url:
+        return cached_image_body_by_request_url[resource_url]
+    target_parsed = urlparse(resource_url)
+    target_path_normalized = unquote(target_parsed.path)
+    for cached_request_url, image_body_bytes in cached_image_body_by_request_url.items():
+        cached_parsed = urlparse(cached_request_url)
+        if cached_parsed.netloc != target_parsed.netloc:
+            continue
+        if unquote(cached_parsed.path) == target_path_normalized:
+            return image_body_bytes
+    return None
+
+
 def _write_image_payload_to_disk(
-    page: Page,
     resource_url: str,
     destination_file_path: Path,
-    cached_image_body_by_request_url: dict[str, bytes] | None,
+    cached_image_body_by_request_url: dict[str, bytes],
 ) -> None:
+    cached_image_body_bytes = _lookup_cached_image_body_bytes_for_request_url(
+        cached_image_body_by_request_url,
+        resource_url,
+    )
+    if cached_image_body_bytes is None:
+        return
     destination_file_path.parent.mkdir(parents=True, exist_ok=True)
-    if cached_image_body_by_request_url is not None:
-        cached_image_body_bytes = cached_image_body_by_request_url.get(resource_url)
-        if cached_image_body_bytes is not None:
-            destination_file_path.write_bytes(cached_image_body_bytes)
-            return
-    api_response = page.context.request.get(resource_url)
-    destination_file_path.write_bytes(api_response.body())
+    destination_file_path.write_bytes(cached_image_body_bytes)
 
 
 def start(
@@ -187,12 +214,19 @@ def start(
         shutil.rmtree(note_output_root_directory)
     note_output_root_directory.mkdir(parents=True, exist_ok=True)
 
+    image_response_body_by_request_url_for_disk_write: dict[str, bytes] = (
+        cached_image_body_by_request_url
+        if cached_image_body_by_request_url is not None
+        else {}
+    )
+
     note_feed_playwright_page = _playwright_page_whose_location_is_search_result_feed_or_fallback(
         page,
     )
     note_feed_playwright_page.wait_for_timeout(800)
-    note_feed_playwright_page.evaluate(_SCROLL_FEEDS_CONTAINER_FOR_LAZY_NOTE_CARD_IMAGES_JS)
-    note_feed_playwright_page.wait_for_timeout(1200)
+    for _scroll_feed_container_for_lazy_cover_image_round_index in range(4):
+        note_feed_playwright_page.evaluate(_SCROLL_FEEDS_CONTAINER_FOR_LAZY_NOTE_CARD_IMAGES_JS)
+        note_feed_playwright_page.wait_for_timeout(700)
     card_row_dicts: list[dict[str, Any]] = note_feed_playwright_page.evaluate(
         _EXTRACT_NOTE_FEED_CARD_ROWS_FOR_PAGE_CACHE_JS,
     )
@@ -206,10 +240,9 @@ def start(
         cover_suffix = _image_file_suffix_from_cdn_url(cover_url)
         cover_file_path = single_note_directory_path / f"cover_image{cover_suffix}"
         _write_image_payload_to_disk(
-            note_feed_playwright_page,
             cover_url,
             cover_file_path,
-            cached_image_body_by_request_url,
+            image_response_body_by_request_url_for_disk_write,
         )
 
         avatar_url = str(row.get("avatar_url") or "").strip()
@@ -217,10 +250,9 @@ def start(
             avatar_suffix = _image_file_suffix_from_cdn_url(avatar_url)
             avatar_file_path = single_note_directory_path / f"author_avatar_image{avatar_suffix}"
             _write_image_payload_to_disk(
-                note_feed_playwright_page,
                 avatar_url,
                 avatar_file_path,
-                cached_image_body_by_request_url,
+                image_response_body_by_request_url_for_disk_write,
             )
 
         note_title_text = str(row.get("title") or "")

+ 28 - 3
workplace/main.py

@@ -38,6 +38,9 @@ PYAUTOGUI_SCREEN_CALIBRATION_FROM_REPOSITORY_CONFIG_INI = PYAUTOGUI_SCREEN_CALIB
 # 本入口统一使用仓库封装的 Playwright 与 PyAutoGUI 单例。
 from workplace import playwright as workplace_playwright  # noqa: E402
 from workplace import pyautogui as workplace_pyautogui  # noqa: E402
+from workplace.note_pipeline_forbidden_standalone_url_image_fetch import (
+    XHS_NOTE_PIPELINE_ABSOLUTE_PROHIBITION_STANDALONE_HTTP_GET_FOR_IMAGE_BYTES,
+)
 
 
 def _load_python_module_from_file(
@@ -102,8 +105,19 @@ def _load_download_note_module():
     )
 
 
+def _load_preview_note_module():
+    return _load_python_module_from_file(
+        REPO_ROOT / "workplace" / "preview-note" / "preview-note.py",
+        "workplace_preview_note",
+    )
+
+
 def start(command_line_argument_strings: list[str] | None = None) -> int:
     """跑通一次:附着首页 → 顶栏搜索 → 筛选排序;进程返回码与最内层 ``then`` 一致。"""
+    _xhs_note_pipeline_image_bytes_must_not_use_standalone_http_get = (
+        XHS_NOTE_PIPELINE_ABSOLUTE_PROHIBITION_STANDALONE_HTTP_GET_FOR_IMAGE_BYTES
+    )
+    _ = _xhs_note_pipeline_image_bytes_must_not_use_standalone_http_get
     # 初始化全局键鼠节奏与 Playwright 单例,后续子脚本共用同一套行为。
     workplace_pyautogui.init_singleton()
     workplace_playwright.init_singleton()
@@ -209,12 +223,23 @@ def _execute_third_fourth_fifth_step_linear_after_top_bar_keyword_search(
         playwright_page,
         cached_image_body_by_request_url=cached_image_body_by_request_url,
     )
-    stop_listening_page_image_responses_cell[0]()
-    # 第六步: 下载笔记0
-    return download_note_script_module.start(
+    # 第六步: 缓存笔记详情(图片仅从 ``cached_image_body_by_request_url`` 落盘,不发起独立 HTTP)。
+    download_note_exit_code = download_note_script_module.start(
         [note_list_zero_based_index_for_detail_download],
         existing_playwright_page=playwright_page,
+        cached_image_body_by_request_url=cached_image_body_by_request_url,
+    )
+    if stop_listening_page_image_responses_cell[0] is not None:
+        stop_listening_page_image_responses_cell[0]()
+
+    # 第七步: 预览笔记详情。
+    preview_note_script_module = _load_preview_note_module()
+    preview_note_exit_code = preview_note_script_module.start(
+        [note_list_zero_based_index_for_detail_download],
     )
+    if preview_note_exit_code != 0:
+        return preview_note_exit_code
+    return download_note_exit_code
 
 
 def main() -> int:

+ 21 - 1
workplace/memory-cache-image-name.py

@@ -13,9 +13,23 @@ _REPOSITORY_ROOT_DIRECTORY = Path(__file__).resolve().parent.parent
 if str(_REPOSITORY_ROOT_DIRECTORY) not in sys.path:
     sys.path.insert(0, str(_REPOSITORY_ROOT_DIRECTORY))
 
+from workplace.note_pipeline_forbidden_standalone_url_image_fetch import (
+    XHS_NOTE_PIPELINE_ABSOLUTE_PROHIBITION_STANDALONE_HTTP_GET_FOR_IMAGE_BYTES,
+)
+
 _CONTENT_TYPE_IMAGE_PREFIX = "image/"
 
 
+def should_cache_image_request_url_for_note_pipeline(image_request_url: str) -> bool:
+    parsed_image_request_url = urlparse(image_request_url)
+    hostname_only = (parsed_image_request_url.hostname or "").lower()
+    if not hostname_only.endswith(".xhscdn.com"):
+        return False
+    return hostname_only.startswith("sns-webpic") or hostname_only.startswith(
+        "sns-avatar",
+    )
+
+
 def _http_response_represents_image_payload(response: Response) -> bool:
     content_type_header_value = response.headers.get("content-type", "")
     return _CONTENT_TYPE_IMAGE_PREFIX in content_type_header_value.lower()
@@ -37,6 +51,10 @@ def start(
     image_name_log_output_file_path: Path | None = None,
     cached_image_body_by_request_url: dict[str, bytes] | None = None,
 ) -> Callable[[], None]:
+    _xhs_note_pipeline_image_bytes_must_not_use_standalone_http_get = (
+        XHS_NOTE_PIPELINE_ABSOLUTE_PROHIBITION_STANDALONE_HTTP_GET_FOR_IMAGE_BYTES
+    )
+    _ = _xhs_note_pipeline_image_bytes_must_not_use_standalone_http_get
     recorded_image_request_url_set: set[str] = set()
 
     def on_http_response_finished(response: Response) -> None:
@@ -46,6 +64,8 @@ def start(
         ):
             return
         image_request_url = response.url
+        if not should_cache_image_request_url_for_note_pipeline(image_request_url):
+            return
         if image_request_url in recorded_image_request_url_set:
             return
         recorded_image_request_url_set.add(image_request_url)
@@ -71,4 +91,4 @@ def start(
     return stop_listening_page_image_responses
 
 
-__all__ = ["start"]
+__all__ = ["should_cache_image_request_url_for_note_pipeline", "start"]

+ 5 - 0
workplace/note_pipeline_forbidden_standalone_url_image_fetch.py

@@ -0,0 +1,5 @@
+"""小红书笔记流水线:持久化用的图片字节只能来自 Playwright 已监听到的响应 body;严禁对图片 URL 发起独立 HTTP(S) 下载。"""
+
+XHS_NOTE_PIPELINE_ABSOLUTE_PROHIBITION_STANDALONE_HTTP_GET_FOR_IMAGE_BYTES = True
+
+__all__ = ["XHS_NOTE_PIPELINE_ABSOLUTE_PROHIBITION_STANDALONE_HTTP_GET_FOR_IMAGE_BYTES"]

+ 137 - 4
workplace/ocr-pos.py

@@ -1,7 +1,9 @@
 #!/usr/bin/env python3
 """
-使用仓库内 ``python/RapidOCR`` 对**图片**做 OCR:识别行须 **完整包含** 目标关键词(连续子串),
-返回该行文字框**中心点**(图像像素坐标)。
+使用仓库内 ``python/RapidOCR`` 对**图片**做 OCR:默认识别行须 **完整包含** 目标关键词(连续子串),
+返回该行文字框**中心点**(图像像素坐标)。笔记列表里标题常被 UI **折成两行**,RapidOCR 会得到两个框、
+任一行都不含整段标题;此时可用 ``ocr_find_text_center_allowing_note_title_wrapped_across_two_ocr_lines``
+将上下两行框拼成一块再取中心。
 
 与 Playwright / 浏览器 DOM 无关;仅输入图片路径或字节。
 
@@ -103,8 +105,17 @@ def _quad_center_xy(box) -> tuple[float, float]:
 
 
 def _normalize_ocr_match_string(s: str) -> str:
-    """去掉首尾空白并去掉内部空白,便于「搜索 小红书」与「搜索小红书」对齐。"""
-    return "".join((s or "").strip().split())
+    """去掉首尾空白并去掉内部空白;再将全角标点与半角对齐,便于 metadata 与 RapidOCR 输出对齐。"""
+    collapsed_whitespace_string = "".join((s or "").strip().split())
+    return (
+        collapsed_whitespace_string.replace(",", ",")
+        .replace("?", "?")
+        .replace("(", "(")
+        .replace(")", ")")
+        .replace(":", ":")
+        .replace(";", ";")
+        .replace("!", "!")
+    )
 
 
 def _ocr_line_contains_full_target(ocr_line: str, target_text: str) -> bool:
@@ -167,6 +178,127 @@ def ocr_smallest_line_center_for_dom_text(
     return int(round(cx)), int(round(cy))
 
 
+def _ocr_quadrilateral_box_axis_aligned_bounding_rectangle_union_center_xy(
+    quadrilateral_box_a: Any,
+    quadrilateral_box_b: Any,
+) -> tuple[float, float]:
+    xs = [float(p[0]) for p in quadrilateral_box_a] + [float(p[0]) for p in quadrilateral_box_b]
+    ys = [float(p[1]) for p in quadrilateral_box_a] + [float(p[1]) for p in quadrilateral_box_b]
+    return (min(xs) + max(xs)) / 2.0, (min(ys) + max(ys)) / 2.0
+
+
+def _ocr_smallest_area_line_index_where_normalized_line_contains_substring(
+    ocr_result: Any,
+    substring_normalized: str,
+) -> int | None:
+    if not substring_normalized:
+        return None
+    best_line_index: int | None = None
+    best_area_pixels = float("inf")
+    for line_index, line_text in enumerate(ocr_result.txts):
+        line_normalized = _normalize_ocr_match_string(line_text)
+        if substring_normalized not in line_normalized:
+            continue
+        box = ocr_result.boxes[line_index]
+        xs = [float(p[0]) for p in box]
+        ys = [float(p[1]) for p in box]
+        area_pixels = (max(xs) - min(xs)) * (max(ys) - min(ys))
+        if area_pixels < best_area_pixels:
+            best_area_pixels = area_pixels
+            best_line_index = line_index
+    return best_line_index
+
+
+def _ocr_find_stitched_center_when_note_title_wraps_across_two_ocr_lines(
+    image: str | Path | bytes | np.ndarray,
+    target_text: str,
+) -> tuple[int, int] | None:
+    """
+    当整段 ``target_text`` 被折成两行、分两框识别时:在归一化后的标题上尝试若干切分,
+    用上半段匹配**较上**的一行、下半段匹配**较下**的一行,取两框轴对齐外包矩形的中心。
+    """
+    target_normalized = _normalize_ocr_match_string(target_text)
+    target_character_count = len(target_normalized)
+    if target_character_count < 8:
+        return None
+    ocr = _get_rapid_ocr()
+    result = ocr(_rapidocr_input_from_path_bytes_or_bgr_numpy(image))
+    if result is None or not result.txts or result.boxes is None:
+        return None
+    image_height_pixels = 1080
+    if isinstance(image, np.ndarray) and image.ndim >= 2:
+        image_height_pixels = int(image.shape[0])
+    max_vertical_gap_between_wrapped_title_lines_pixels = min(
+        96,
+        max(40, int(image_height_pixels * 0.045)),
+    )
+    half_split_position = target_character_count // 2
+    for split_position_delta in range(-12, 13):
+        split_position = half_split_position + split_position_delta
+        if split_position < 4 or split_position > target_character_count - 4:
+            continue
+        first_line_fragment_normalized = target_normalized[:split_position]
+        second_line_fragment_normalized = target_normalized[split_position:]
+        if len(second_line_fragment_normalized) < 3:
+            continue
+        first_line_ocr_index = _ocr_smallest_area_line_index_where_normalized_line_contains_substring(
+            result,
+            first_line_fragment_normalized,
+        )
+        second_line_ocr_index = _ocr_smallest_area_line_index_where_normalized_line_contains_substring(
+            result,
+            second_line_fragment_normalized,
+        )
+        if first_line_ocr_index is None or second_line_ocr_index is None:
+            continue
+        if first_line_ocr_index == second_line_ocr_index:
+            continue
+        first_line_center_x, first_line_center_y = _quad_center_xy(
+            result.boxes[first_line_ocr_index],
+        )
+        second_line_center_x, second_line_center_y = _quad_center_xy(
+            result.boxes[second_line_ocr_index],
+        )
+        if first_line_center_y >= second_line_center_y:
+            continue
+        vertical_gap_pixels = abs(second_line_center_y - first_line_center_y)
+        if vertical_gap_pixels > max_vertical_gap_between_wrapped_title_lines_pixels:
+            continue
+        horizontal_center_distance_pixels = abs(first_line_center_x - second_line_center_x)
+        if horizontal_center_distance_pixels > min(
+            420,
+            int(image_height_pixels * 0.36),
+        ):
+            continue
+        union_center_x, union_center_y = (
+            _ocr_quadrilateral_box_axis_aligned_bounding_rectangle_union_center_xy(
+                result.boxes[first_line_ocr_index],
+                result.boxes[second_line_ocr_index],
+            )
+        )
+        return int(round(union_center_x)), int(round(union_center_y))
+    return None
+
+
+def ocr_find_text_center_allowing_note_title_wrapped_across_two_ocr_lines(
+    image: str | Path | bytes | np.ndarray,
+    target_text: str,
+    *,
+    prefer_highest_score: bool = True,
+) -> tuple[int, int] | None:
+    single_line_center_xy = ocr_find_text_center(
+        image,
+        target_text,
+        prefer_highest_score=prefer_highest_score,
+    )
+    if single_line_center_xy is not None:
+        return single_line_center_xy
+    return _ocr_find_stitched_center_when_note_title_wraps_across_two_ocr_lines(
+        image,
+        target_text,
+    )
+
+
 def ocr_find_text_center(
     image: str | Path | bytes | np.ndarray,
     target_text: str,
@@ -251,6 +383,7 @@ __all__ = [
     "OCR_RAPIDOCR_INFERENCE_ENGINE_NAME",
     "OCR_RAPIDOCR_ONNX_RUNTIME_USE_CUDA",
     "ocr_find_text_center",
+    "ocr_find_text_center_allowing_note_title_wrapped_across_two_ocr_lines",
     "ocr_smallest_line_center_for_dom_text",
     "start",
     "main",

+ 704 - 0
workplace/preview-note/preview-note.py

@@ -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())

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác