164 lines
5.8 KiB
Python
164 lines
5.8 KiB
Python
"""站点发现、能力探测与状态读取(纯函数,无副作用)。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import mimetypes
|
|
import re
|
|
import shutil
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from deerflow.config import get_app_config
|
|
from deerflow.config.runtime_paths import resolve_path
|
|
|
|
SITE_MANIFEST_NAME = "site.json"
|
|
BUILD_RESULT_NAME = ".build-result.json"
|
|
|
|
# 站点目录名约束(与 build_site.py 的 SITE_NAME_RE 一致)
|
|
SITE_NAME_RE_OK = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
|
|
# dist/ 静态托管的 MIME 修正表
|
|
_EXTRA_MIME_TYPES = {
|
|
".js": "text/javascript; charset=utf-8",
|
|
".mjs": "text/javascript; charset=utf-8",
|
|
".css": "text/css; charset=utf-8",
|
|
".json": "application/json; charset=utf-8",
|
|
".geojson": "application/json; charset=utf-8",
|
|
".wasm": "application/wasm",
|
|
".html": "text/html; charset=utf-8",
|
|
".htm": "text/html; charset=utf-8",
|
|
".svg": "image/svg+xml",
|
|
".woff": "font/woff",
|
|
".woff2": "font/woff2",
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class WebAppCapability:
|
|
"""能力探测结果:任何一项缺失都只是让功能优雅不可用。"""
|
|
|
|
available: bool = False
|
|
enabled: bool = False
|
|
checks: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
template_dir: str = ""
|
|
node_bin: str = ""
|
|
|
|
|
|
def resolve_template_dir() -> Path:
|
|
"""解析站点模板目录(相对 project root 或绝对路径)。"""
|
|
config = get_app_config()
|
|
return resolve_path(config.web_app.template_dir)
|
|
|
|
|
|
def capability_status() -> WebAppCapability:
|
|
"""检查配置开关、模板、预置依赖与 node 二进制。永不抛异常。"""
|
|
checks: dict[str, dict[str, Any]] = {}
|
|
try:
|
|
config = get_app_config()
|
|
enabled = bool(config.web_app.enabled)
|
|
checks["enabled"] = {"ok": enabled, "detail": "config.yaml web_app.enabled"}
|
|
|
|
template_dir = resolve_template_dir()
|
|
template_ok = template_dir.is_dir() and (template_dir / SITE_MANIFEST_NAME).is_file()
|
|
checks["template"] = {"ok": template_ok, "detail": str(template_dir)}
|
|
|
|
vite_bin = template_dir / "node_modules" / "vite" / "bin" / "vite.js"
|
|
deps_ok = vite_bin.is_file()
|
|
checks["node_modules"] = {"ok": deps_ok, "detail": str(vite_bin)}
|
|
|
|
node_path = config.web_app.node_path.strip()
|
|
node_bin = node_path if node_path and Path(node_path).exists() else (shutil.which("node") or "")
|
|
checks["node"] = {"ok": bool(node_bin), "detail": node_bin}
|
|
|
|
available = all(item["ok"] for item in checks.values())
|
|
return WebAppCapability(
|
|
available=available,
|
|
enabled=enabled,
|
|
checks=checks,
|
|
template_dir=str(template_dir),
|
|
node_bin=node_bin,
|
|
)
|
|
except Exception as exc: # noqa: BLE001 - 能力探测永不抛出
|
|
checks["internal"] = {"ok": False, "detail": str(exc)}
|
|
return WebAppCapability(available=False, enabled=False, checks=checks)
|
|
|
|
|
|
def read_site_manifest(site_dir: Path) -> dict[str, Any]:
|
|
"""读取 site.json(损坏时返回空 dict,由调用方决定如何呈现)。"""
|
|
manifest_path = site_dir / SITE_MANIFEST_NAME
|
|
try:
|
|
data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
return data if isinstance(data, dict) else {}
|
|
except (OSError, ValueError):
|
|
return {}
|
|
|
|
|
|
def _dist_info(site_dir: Path) -> dict[str, Any]:
|
|
dist = site_dir / "dist"
|
|
if not dist.is_dir():
|
|
return {"exists": False}
|
|
try:
|
|
entries = [p for p in dist.rglob("*") if p.is_file()]
|
|
latest = max((p.stat().st_mtime for p in entries), default=None)
|
|
return {
|
|
"exists": True,
|
|
"file_count": len(entries),
|
|
"total_bytes": sum(p.stat().st_size for p in entries),
|
|
"mtime": latest,
|
|
}
|
|
except OSError:
|
|
return {"exists": True, "file_count": 0, "total_bytes": 0, "mtime": None}
|
|
|
|
|
|
def _last_build(site_dir: Path) -> dict[str, Any] | None:
|
|
result_path = site_dir / BUILD_RESULT_NAME
|
|
try:
|
|
data = json.loads(result_path.read_text(encoding="utf-8"))
|
|
return data if isinstance(data, dict) else None
|
|
except (OSError, ValueError):
|
|
return None
|
|
|
|
|
|
def find_sites(outputs_dir: Path) -> list[dict[str, Any]]:
|
|
"""扫描 outputs/ 一级目录,凡带 site.json 的即站点。永不抛异常。"""
|
|
sites: list[dict[str, Any]] = []
|
|
try:
|
|
candidates = sorted(outputs_dir.iterdir(), key=lambda p: (p.name.lower()))
|
|
except OSError:
|
|
return sites
|
|
for entry in candidates:
|
|
if not entry.is_dir() or not SITE_NAME_RE_OK.match(entry.name):
|
|
continue
|
|
if not (entry / SITE_MANIFEST_NAME).is_file():
|
|
continue
|
|
manifest = read_site_manifest(entry)
|
|
try:
|
|
mtime = (entry / SITE_MANIFEST_NAME).stat().st_mtime
|
|
except OSError:
|
|
mtime = None
|
|
sites.append(
|
|
{
|
|
"name": entry.name,
|
|
"title": str(manifest.get("title") or entry.name),
|
|
"description": str(manifest.get("description") or ""),
|
|
"nav": manifest.get("nav") if isinstance(manifest.get("nav"), list) else [],
|
|
"map": manifest.get("map") if isinstance(manifest.get("map"), dict) else {},
|
|
"manifest_mtime": mtime,
|
|
"dist": _dist_info(entry),
|
|
"last_build": _last_build(entry),
|
|
}
|
|
)
|
|
sites.sort(key=lambda s: (-(s["manifest_mtime"] or 0), str(s["name"])))
|
|
return sites
|
|
|
|
|
|
def guess_site_mime(path: Path) -> str:
|
|
"""dist/ 静态文件 MIME(带编码修正;未知扩展名回退 octet-stream)。"""
|
|
suffix = path.suffix.lower()
|
|
if suffix in _EXTRA_MIME_TYPES:
|
|
return _EXTRA_MIME_TYPES[suffix]
|
|
guessed, _ = mimetypes.guess_type(path.name)
|
|
return guessed or "application/octet-stream"
|