|
|
|
@@ -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"(<p\b[^>]*>)(.*?)(</p>)", 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"<a\b[^>]*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)
|
|
|
|
|