"""Build portable ZIP bundles for generated HTML artifacts. Generated pages intentionally reference the Gateway's vendored browser runtime instead of public CDNs. A plain ``.html`` download therefore stops working after it is moved away from the DeerFlow deployment. This module rewrites those trusted Gateway URLs to relative paths and copies the corresponding packaged assets into a ZIP. It never downloads arbitrary third-party URLs. """ from __future__ import annotations import hashlib import json import re import zipfile from dataclasses import dataclass from importlib.resources import files from pathlib import Path, PurePosixPath from deerflow.html_runtime.public_images import resolve_html_image_path, verify_html_image_token _STATIC_ASSETS = { "alpine.min.js", "bootstrap.bundle.min.js", "bootstrap.min.css", "brand-logo-dark.png", "echarts.min.js", "g6.min.js", "leaflet.css", "leaflet.js", "topbar-dark.svg", "topbar-light.svg", } _GEO_MAP_ASSETS = { "china-provinces.geojson", "japan-prefectures.geojson", "taiwan-counties.geojson", "us-states.geojson", } _COGNITIVE_ASSET_SUFFIXES = { ".css", ".js", ".json", ".jpeg", ".jpg", ".mjs", ".png", ".svg", ".woff2", } _LICENSE_BY_ASSET = { "alpine.min.js": "LICENSE.alpine.txt", "bootstrap.bundle.min.js": "LICENSE.bootstrap.txt", "bootstrap.min.css": "LICENSE.bootstrap.txt", "echarts.min.js": "LICENSE.echarts.txt", "g6.min.js": "LICENSE.g6.txt", "leaflet.css": "LICENSE.leaflet.txt", "leaflet.js": "LICENSE.leaflet.txt", } _RUNTIME_ARCHIVE_ROOT = PurePosixPath("assets/html-runtime") _HTML_IMAGES_ARCHIVE_ROOT = PurePosixPath("assets/html-images") _STANDARD_RUNTIME_BASE_RE = ( r"(?:https?://[^/\"'\s<>()]+)?" r"(?:/[A-Za-z0-9._~!$&()*+,;=:@%+-]+)*/api/public/html-runtime" ) _HTML_IMAGE_URL_RE = re.compile( r"(?P(?:https?://[^/\"'\s<>()]+)?" r"(?:/[A-Za-z0-9._~!$&()*+,;=:@%+-]+)*/api/public/html-images/" r"(?P[^\"'\s<>()?#]+)(?:\?[^\"'\s<>()#]*)?)", re.IGNORECASE, ) _EXTERNAL_URL_RE = re.compile(r"https?://[^\s\"'<>]+", re.IGNORECASE) @dataclass(frozen=True) class OfflineBundleResult: """Summary of one completed offline bundle.""" bundled_assets: tuple[str, ...] external_urls: tuple[str, ...] def _runtime_base_pattern(runtime_base_url: str) -> str: bases = [_STANDARD_RUNTIME_BASE_RE] configured = str(runtime_base_url or "").strip().rstrip("/") if configured: bases.insert(0, re.escape(configured)) return "(?:" + "|".join(bases) + ")" def _safe_runtime_source(runtime_dir: Path, relative_path: str) -> Path | None: """Resolve only assets that the public HTML-runtime router exposes.""" normalized = relative_path.strip().lstrip("/").replace("\\", "/") pure_path = PurePosixPath(normalized) if not normalized or pure_path.is_absolute() or ".." in pure_path.parts: return None allowed = False if len(pure_path.parts) == 1 and normalized in _STATIC_ASSETS: allowed = True elif len(pure_path.parts) == 2 and pure_path.parts[0] == "maps" and pure_path.name in _GEO_MAP_ASSETS: allowed = True elif pure_path.parts and pure_path.parts[0] == "cognitive-situation-map": allowed = pure_path.suffix.lower() in _COGNITIVE_ASSET_SUFFIXES if not allowed: return None root = runtime_dir.resolve() source = (root / Path(*pure_path.parts)).resolve() try: source.relative_to(root) except ValueError: return None return source if source.is_file() else None def _add_file( archive_files: dict[str, Path], archive_path: PurePosixPath, source: Path, ) -> None: archive_files[archive_path.as_posix()] = source def _add_cognitive_runtime(archive_files: dict[str, Path], runtime_dir: Path) -> None: source_root = runtime_dir / "cognitive-situation-map" if not source_root.is_dir(): return for source in source_root.rglob("*"): if not source.is_file(): continue is_license = source.name.lower().startswith("license") if source.suffix.lower() not in _COGNITIVE_ASSET_SUFFIXES and not is_license: continue relative = source.relative_to(runtime_dir) _add_file(archive_files, _RUNTIME_ARCHIVE_ROOT / PurePosixPath(relative.as_posix()), source) def _generated_logo_destination(generated_logo_path: Path | None) -> PurePosixPath: suffix = generated_logo_path.suffix.lower() if generated_logo_path is not None else ".png" if suffix not in {".jpg", ".jpeg", ".png", ".webp"}: suffix = ".png" return _RUNTIME_ARCHIVE_ROOT / f"generated-page-logo{suffix}" def _runtime_script( runtime_dir: Path, *, map_tile_url: str, include_geo_maps: bool, ) -> str: config: dict[str, object] = {"geoMaps": {}, "mapTileUrl": map_tile_url} if include_geo_maps: config["geoMaps"] = { Path(filename).stem: (_RUNTIME_ARCHIVE_ROOT / "maps" / filename).as_posix() for filename in sorted(_GEO_MAP_ASSETS) } prefix = "window.DEERFLOW_HTML_RUNTIME_CONFIG=" + json.dumps( config, ensure_ascii=False, separators=(",", ":"), ) + ";\n" return prefix + (runtime_dir / "deerflow-runtime.js").read_text(encoding="utf-8") def _readme(original_name: str, external_urls: list[str]) -> str: external_note = ( "未打包的外部地址已记录在 manifest.json 的 external_urls 中;例如在线地图瓦片、" "用户主动引用的第三方图片或接口仍需要网络。" if external_urls else "本页没有检测到仍需联网的 http(s) 地址。" ) return ( "DeerFlow 网页离线包\n" "====================\n\n" f"原始文件:{original_name}\n" "入口文件:index.html\n" "依赖目录:assets/\n\n" "部署方式:请保持目录结构不变,将解压后的全部文件一起放到任意静态网站目录。\n" "若页面含 GeoJSON 地图等 fetch 请求,不建议直接双击 file:// 打开;可使用 Nginx、" "Apache、IIS 或其他静态 HTTP 服务访问 index.html。\n\n" f"{external_note}\n" ) def build_html_offline_bundle( html_path: Path, destination: Path, *, runtime_base_url: str, map_tile_url: str, generated_logo_path: Path | None = None, runtime_dir: Path | None = None, ) -> OfflineBundleResult: """Write a portable HTML ZIP to *destination* and return its manifest summary.""" html_path = Path(html_path) destination = Path(destination) runtime_dir = runtime_dir or Path(str(files("deerflow.html_runtime.assets"))) runtime_dir = Path(runtime_dir) html = html_path.read_text(encoding="utf-8", errors="replace") archive_files: dict[str, Path] = {} generated_files: dict[str, str] = {} bundled_assets: set[str] = set() licenses: set[str] = set() include_geo_maps = bool(re.search(r"data-df-geo\s*=", html, re.IGNORECASE)) uses_leaflet_map = bool(re.search(r"data-df-map\s*=", html, re.IGNORECASE)) base_pattern = _runtime_base_pattern(runtime_base_url) cognitive_base_re = re.compile( rf"(?P{base_pattern}/cognitive-situation-map)(?=$|[\"'\s<>()/])", re.IGNORECASE, ) def replace_cognitive_base(match: re.Match[str]) -> str: _add_cognitive_runtime(archive_files, runtime_dir) bundled_assets.add((_RUNTIME_ARCHIVE_ROOT / "cognitive-situation-map").as_posix() + "/") return (_RUNTIME_ARCHIVE_ROOT / "cognitive-situation-map").as_posix() html = cognitive_base_re.sub(replace_cognitive_base, html) runtime_url_re = re.compile( rf"(?P{base_pattern}/(?P[A-Za-z0-9._/-]+)" r"(?:\?[^\"'\s<>()#]*)?)", re.IGNORECASE, ) def replace_runtime_url(match: re.Match[str]) -> str: asset = match.group("asset").strip("/") if asset == "generated-page-logo": source = generated_logo_path if generated_logo_path and generated_logo_path.is_file() else runtime_dir / "brand-logo-dark.png" if not source.is_file(): return match.group("url") archive_path = _generated_logo_destination(source) _add_file(archive_files, archive_path, source) bundled_assets.add(archive_path.as_posix()) return archive_path.as_posix() if asset == "deerflow-runtime.js": source = runtime_dir / asset if not source.is_file(): return match.group("url") archive_path = _RUNTIME_ARCHIVE_ROOT / asset generated_files[archive_path.as_posix()] = _runtime_script( runtime_dir, map_tile_url=map_tile_url, include_geo_maps=include_geo_maps, ) bundled_assets.add(archive_path.as_posix()) if include_geo_maps: for filename in sorted(_GEO_MAP_ASSETS): map_source = _safe_runtime_source(runtime_dir, f"maps/{filename}") if map_source is not None: map_archive_path = _RUNTIME_ARCHIVE_ROOT / "maps" / filename _add_file(archive_files, map_archive_path, map_source) bundled_assets.add(map_archive_path.as_posix()) return archive_path.as_posix() source = _safe_runtime_source(runtime_dir, asset) if source is None: return match.group("url") archive_path = _RUNTIME_ARCHIVE_ROOT / PurePosixPath(asset) _add_file(archive_files, archive_path, source) bundled_assets.add(archive_path.as_posix()) license_name = _LICENSE_BY_ASSET.get(asset) if license_name and (runtime_dir / license_name).is_file(): licenses.add(license_name) return archive_path.as_posix() html = runtime_url_re.sub(replace_runtime_url, html) def replace_html_image(match: re.Match[str]) -> str: token = match.group("token") try: _thread_id, _kind, filename = verify_html_image_token(token) source = resolve_html_image_path(_thread_id, _kind, filename) except (FileNotFoundError, ValueError): return match.group("url") digest = hashlib.sha256(token.encode("utf-8")).hexdigest()[:12] safe_name = Path(filename).name archive_path = _HTML_IMAGES_ARCHIVE_ROOT / f"{digest}-{safe_name}" _add_file(archive_files, archive_path, source) bundled_assets.add(archive_path.as_posix()) return archive_path.as_posix() html = _HTML_IMAGE_URL_RE.sub(replace_html_image, html) external_url_set = { match.group(0).rstrip("),.;") for match in _EXTERNAL_URL_RE.finditer(html) } if uses_leaflet_map and str(map_tile_url).lower().startswith(("http://", "https://")): external_url_set.add(str(map_tile_url)) external_urls = sorted(external_url_set) manifest = { "format": "deerflow-html-offline-bundle-v1", "entry": "index.html", "source_name": html_path.name, "bundled_assets": sorted(bundled_assets), "external_urls": external_urls, } destination.parent.mkdir(parents=True, exist_ok=True) with zipfile.ZipFile(destination, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as archive: archive.writestr("index.html", html.encode("utf-8")) archive.writestr( "manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2).encode("utf-8"), ) archive.writestr("README.txt", _readme(html_path.name, external_urls).encode("utf-8")) for archive_path, source in sorted(archive_files.items()): archive.write(source, archive_path) for archive_path, content in sorted(generated_files.items()): archive.writestr(archive_path, content.encode("utf-8")) for license_name in sorted(licenses): archive.write(runtime_dir / license_name, f"THIRD_PARTY_LICENSES/{license_name}") return OfflineBundleResult( bundled_assets=tuple(sorted(bundled_assets)), external_urls=tuple(external_urls), )