diff --git a/tools/epub_review_editor/MAINTENANCE.md b/tools/epub_review_editor/MAINTENANCE.md index 40dddf2..eb83538 100644 --- a/tools/epub_review_editor/MAINTENANCE.md +++ b/tools/epub_review_editor/MAINTENANCE.md @@ -41,8 +41,9 @@ epub_review_sessions/ - text chapter: `kind: "text"`、`parts`、`row_count`、`touched_count`、`marked_count` - image chapter: `kind: "image"`、`images`、`parts: []`、`row_count: 0` +- 统计字段同时保留总数和细分:`chapter_count`、`text_chapter_count`、`image_chapter_count`、`image_count`、`asset_count` - part 标题默认使用 `part0000` 形式,真实标题可保留在 `source_title` -- 插图通过 `/api/asset/` 读取,必须继续使用 `extracted_path()` 防止路径穿越 +- 插图通过 `/api/asset/` 读取,必须继续使用 `extracted_path()` 防止路径穿越;只把 raster 图片纳入 `/api/structure` 与 `/api/asset` 白名单,不能任意读取解包目录文件 章节来源优先级: @@ -89,6 +90,7 @@ GPT 能力是可选增强,不应影响离线审校。 - API Key 优先读取 `gpt_config.json`,也支持环境变量 `OPENAI_API_KEY`。 - `/api/gpt/config` 不得返回 API Key。 - 未配置 Key 时,重翻接口应返回可读错误,不影响编辑保存。 +- 重翻请求不得临时覆盖 `base_url` 或 `model` 后继续复用已保存密钥;要更换端点或模型必须先保存配置,避免把密钥发往未确认端点。 单段重翻流程: @@ -134,6 +136,7 @@ Prompt 硬规则: - `/api/structure` - `/api/gpt/config` - 首个插图 `/api/asset/...` 返回 200 且浏览器可显示 +- 非白名单或非 raster asset 不能被 `/api/asset/...` 读取 - 目录按钮、检索按钮、隐藏侧栏、点击段落打开右侧面板 - 阅读段落下方没有编号和“快速编辑/快速标记” - 未配置 API Key 时重翻返回清晰错误 diff --git a/tools/epub_review_editor/README.md b/tools/epub_review_editor/README.md index 3985389..4d503f5 100644 --- a/tools/epub_review_editor/README.md +++ b/tools/epub_review_editor/README.md @@ -1,6 +1,6 @@ # 双语 EPUB 审校编辑器 -当前版本:`0.4.0` +当前版本:`0.4.1` 这个工具用于把生成后的中日双语 EPUB 打开成浏览器审校界面。用户可以直接修改中文译文,也可以标记“哪里翻得不好”,并把所有记录沉淀为可交给 Codex 的反馈文件。 @@ -22,6 +22,8 @@ `0.4.0` 新增阅读型交互与 GPT 重翻:左侧目录/检索改为按需打开的独立侧栏;阅读模式去掉每段下方操作按钮,点击段落直接打开右侧精修与重翻面板,并在面板挤开正文后恢复原阅读位置;本地可配置 OpenAI 兼容 GPT API,对单段生成重翻候选,确认后再应用到编辑框。 +`0.4.1` 修复后端/API 安全与结构细节:章节内插图与文字按 spine 顺序 flush,结构统计补充 text/image 细分字段;内部目录识别增加目录页/链接密度判定;结构和 `/api/asset` 仅允许已识别的 raster 图片并加 `nosniff`;重翻接口不再允许单次请求临时覆盖 Base URL 或模型后复用已保存密钥。 + ## 独立安装 这个审校器现在可以脱离 Codex 使用,只需要 Python 和浏览器。把 `tools/epub_review_editor` 目录复制到其他电脑或服务器后,在该目录里安装依赖: @@ -129,5 +131,5 @@ epub_review_sessions\_\ - 当前解析规则针对本项目的“日文灰字 + 中文紧随其后”的双语 EPUB。 - 目录用于审校导航;插图可以阅读和跳转,但当前版本不提供图片内容编辑。 -- GPT API Key 仅保存在 `--review-root` 下的本地 `gpt_config.json`,接口不会回传密钥;服务器部署时请保护好审校目录,不要把没有鉴权的服务暴露到公网。 +- GPT API Key 仅保存在 `--review-root` 下的本地 `gpt_config.json`,接口不会回传密钥;重翻时只能使用已保存的 Base URL 和模型,避免把密钥发往未确认端点。服务器部署时请保护好审校目录,不要把没有鉴权的服务暴露到公网。 - 浏览器内保存的是审校工作副本;要生成 EPUB 文件,需要点击“导出审校 EPUB”。 diff --git a/tools/epub_review_editor/server.py b/tools/epub_review_editor/server.py index 4669bf8..fd68049 100644 --- a/tools/epub_review_editor/server.py +++ b/tools/epub_review_editor/server.py @@ -26,7 +26,7 @@ from pathlib import Path from typing import Any import xml.etree.ElementTree as ET -from flask import Flask, jsonify, request, send_from_directory +from flask import Flask, jsonify, make_response, request, send_from_directory try: from version import VERSION_RULES, version @@ -36,7 +36,7 @@ except ImportError: "MINOR": "新增向后兼容能力、API 字段或可选配置", "MAJOR": "破坏兼容的 API、配置或行为变更", } - version = "0.4.0" + version = "0.4.1" P_RE = re.compile(r"(]*>)(.*?)(

)", re.S | re.I) @@ -51,6 +51,7 @@ GRAY_RE = re.compile( ) EXTERNAL_URI_RE = re.compile(r"^(?:[a-z][a-z0-9+.-]*:|//)", re.I) IMAGE_EXTENSIONS = {".apng", ".avif", ".bmp", ".gif", ".jpeg", ".jpg", ".png", ".svg", ".webp"} +RASTER_IMAGE_EXTENSIONS = {".apng", ".avif", ".bmp", ".gif", ".jpeg", ".jpg", ".png", ".webp"} DEFAULT_REVIEW_ROOT = "epub_review_sessions" STATE_VERSION = 1 @@ -754,6 +755,11 @@ def is_image_asset(entry_name: str) -> bool: return suffix in IMAGE_EXTENSIONS +def is_raster_image_asset(entry_name: str) -> bool: + suffix = posixpath.splitext(entry_name.split("?", 1)[0])[1].lower() + return suffix in RASTER_IMAGE_EXTENSIONS + + def asset_url(entry_name: str) -> str: return f"/api/asset/{urllib.parse.quote(entry_name, safe='/')}" @@ -770,7 +776,7 @@ def extract_images_from_html(session_root: Path, entry_name: str) -> list[dict[s if not href or EXTERNAL_URI_RE.match(href): continue image_entry = normalize_href(entry_dir(entry_name), href) - if not image_entry or not is_image_asset(image_entry): + if not image_entry or not is_raster_image_asset(image_entry): continue images.append( { @@ -786,22 +792,38 @@ def extract_images_from_html(session_root: Path, entry_name: str) -> list[dict[s return images +def collect_asset_paths_from_structure(structure: dict[str, Any]) -> set[str]: + paths: set[str] = set() + for chapter in structure.get("chapters", []): + for image in chapter.get("images", []): + asset_path = image.get("asset_path", "") + if asset_path: + paths.add(asset_path) + return paths + + def nav_target_key(base_dir: str, href: str) -> tuple[str, str]: path, fragment = normalize_href_with_fragment(base_dir, href) return path, fragment def parse_internal_toc(session_root: Path) -> list[dict[str, Any]]: - items: list[dict[str, Any]] = [] - for entry_name in html_entries_in_reading_order(session_root): + ordered_files = html_entries_in_reading_order(session_root) + ordered_set = set(ordered_files) + for entry_name in ordered_files: path = extracted_path(session_root, entry_name) if not path.exists(): continue text = path.read_text(encoding="utf-8", errors="replace") if not re.search(r"]*href=", text, flags=re.I): continue + basename = posixpath.basename(entry_name).lower() + heading = extract_heading_from_html(text) + is_toc_like_name = basename in {"nav.xhtml", "toc.xhtml", "toc.html", "toc.htm"} or "toc" in basename + is_toc_like_title = bool(re.search(r"(目录|目次|contents?)", heading, flags=re.I)) matches = list(P_RE.finditer(text)) base_dir = entry_dir(entry_name) + candidate_items: list[dict[str, Any]] = [] i = 0 while i < len(matches): source_inner = matches[i].group(2) @@ -813,6 +835,9 @@ def parse_internal_toc(session_root: Path) -> list[dict[str, Any]]: if not target_file: i += 1 continue + if target_file not in ordered_set: + i += 1 + continue source_title = compact_text(strip_tags(link_match.group(2))) or compact_text(strip_tags(source_inner)) title = source_title if i + 1 < len(matches): @@ -823,7 +848,7 @@ def parse_internal_toc(session_root: Path) -> list[dict[str, Any]]: title = next_title i += 1 if title: - items.append( + candidate_items.append( { "title": title, "href": target_file, @@ -833,8 +858,11 @@ def parse_internal_toc(session_root: Path) -> list[dict[str, Any]]: } ) i += 1 - if items: - return items + if candidate_items: + internal_targets = sum(1 for item in candidate_items if item["href"] in ordered_set and item["href"] != entry_name) + link_density = internal_targets / max(1, len(matches)) + if is_toc_like_name or is_toc_like_title or (len(candidate_items) >= 4 and link_density >= 0.35): + return candidate_items return [] @@ -974,7 +1002,7 @@ def build_structure(session_root: Path) -> dict[str, Any]: for entry_name in ordered_files: heading = read_body_heading(session_root, entry_name) - if heading and entry_name not in start_titles: + if heading and entry_name not in start_titles and len(strip_tags(heading)) <= 80: start_titles[entry_name] = heading if start_titles: @@ -1119,17 +1147,26 @@ def build_structure(session_root: Path) -> dict[str, Any]: nonlocal chapter_serial text_parts: list[dict[str, Any]] = [] text_title = title + + def flush_text_parts() -> None: + nonlocal chapter_serial, text_parts, text_title + if not text_parts: + return + chapter_serial += 1 + chapters.append(chapter_from_parts(f"C{chapter_serial:04d}", text_title or text_parts[0]["source_title"] or text_parts[0]["title"], text_parts)) + text_parts = [] + text_title = "" + for file_index, entry_name in enumerate(files): if entry_name in image_by_file: + flush_text_parts() images = image_by_file.get(entry_name, []) for image_index, image in enumerate(images, start=1): image_title = title if file_index == 0 and not text_parts else "" chapters.append(make_image_chapter(entry_name, image, image_title, image_index, len(images))) elif entry_name in rows_by_file: text_parts.append(make_part(entry_name)) - if text_parts: - chapter_serial += 1 - chapters.append(chapter_from_parts(f"C{chapter_serial:04d}", text_title or text_parts[0]["source_title"] or text_parts[0]["title"], text_parts)) + flush_text_parts() for spec in chapter_specs: node_files = [entry_name for entry_name in spec.get("files", []) if entry_name not in assigned_files] @@ -1177,8 +1214,11 @@ def build_structure(session_root: Path) -> dict[str, Any]: return { "chapters": chapters, "chapter_count": len(chapters), + "text_chapter_count": sum(1 for chapter in chapters if chapter.get("kind") != "image"), + "image_chapter_count": sum(1 for chapter in chapters if chapter.get("kind") == "image"), "part_count": sum(len(chapter["parts"]) for chapter in chapters), "image_count": sum(len(chapter.get("images", [])) for chapter in chapters), + "asset_count": len(collect_asset_paths_from_structure({"chapters": chapters})), "row_count": len(rows), } @@ -1563,13 +1603,21 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset: if session_root is None: return no_active_response() entry_name = urllib.parse.unquote(entry_name) + structure = build_structure(session_root) + if entry_name not in collect_asset_paths_from_structure(structure): + return jsonify({"error": "asset not allowed"}), 404 + if not is_raster_image_asset(entry_name): + return jsonify({"error": "asset type not allowed"}), 415 try: path = extracted_path(session_root, entry_name) except ValueError as exc: return jsonify({"error": str(exc)}), 400 if not path.exists() or not path.is_file(): return jsonify({"error": "asset not found"}), 404 - return send_from_directory(path.parent, path.name) + response = make_response(send_from_directory(path.parent, path.name)) + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Content-Security-Policy"] = "default-src 'none'; img-src 'self' data:; style-src 'none'; script-src 'none'; object-src 'none'" + return response @app.route("/api/gpt/config", methods=["GET", "POST"]) def api_gpt_config(): @@ -1593,10 +1641,12 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset: data = request.get_json(silent=True) or {} try: config = read_gpt_config(review_root) - if data.get("base_url"): - config["base_url"] = normalize_gpt_base_url(str(data.get("base_url"))) - if data.get("model"): - config["model"] = str(data.get("model")).strip() + requested_base_url = str(data.get("base_url") or "").strip() + requested_model = str(data.get("model") or "").strip() + if requested_base_url and normalize_gpt_base_url(requested_base_url) != config["base_url"]: + return jsonify({"error": "重翻请求不能临时覆盖 Base URL。请先保存 API 设置,避免把已保存密钥发往未确认端点。"}), 400 + if requested_model and requested_model != config["model"]: + return jsonify({"error": "重翻请求不能临时覆盖模型。请先保存 API 设置后再重翻。"}), 400 instruction = str(data.get("instruction") or "") raw = call_openai_compatible_chat(config, build_retranslate_messages(row, instruction)) candidate_html = sanitize_cn_html(raw) diff --git a/tools/epub_review_editor/static/app.js b/tools/epub_review_editor/static/app.js index 7480341..e77694f 100644 --- a/tools/epub_review_editor/static/app.js +++ b/tools/epub_review_editor/static/app.js @@ -1199,8 +1199,6 @@ async function retranslateCurrentRow() { method: "POST", body: JSON.stringify({ instruction: dom.gptInstructionInput.value, - base_url: dom.gptBaseUrlInput.value, - model: dom.gptModelInput.value, }), }); gptCandidateHtml = data.candidate_html || ""; diff --git a/tools/epub_review_editor/static/index.html b/tools/epub_review_editor/static/index.html index 8779eb8..8e757eb 100644 --- a/tools/epub_review_editor/static/index.html +++ b/tools/epub_review_editor/static/index.html @@ -9,7 +9,7 @@
-

双语 EPUB 审校编辑器 v0.4.0

+

双语 EPUB 审校编辑器 v0.4.1

正在载入……

diff --git a/tools/epub_review_editor/version.py b/tools/epub_review_editor/version.py index 4e9618c..add31b9 100644 --- a/tools/epub_review_editor/version.py +++ b/tools/epub_review_editor/version.py @@ -1,4 +1,4 @@ -version = "0.4.0" +version = "0.4.1" VERSION_RULES = { "PATCH": "修复 bug 或兼容性小修",