重开仓库内容:改推五个技能(browser-harness / humanizer / humanizer-zh / product-planning / session-mechanism)
按授权清空原有内容后重新提交(原 oil-ui-pro 一并移出,可从历史恢复)。 browser-harness 剔除 .venv 等运行环境;根 .gitignore 补记 .venv/ 与 node_modules/。
This commit is contained in:
1 parent
9a152a1952
commit
777f7fe5d0
240 files changed
+52518
-4409
No files matched your search
@@ -1,413 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a portable, offline design comparison from a local manifest."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
from html.parser import HTMLParser
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
||||
TEMPLATE = SKILL_ROOT / "assets" / "style-explorer.html"
|
||||
MARKER = "/*__OIL_UI_DATA__*/ null"
|
||||
CONNECT_CSP = "connect-src 'none'"
|
||||
PREVIEW_CSP = (
|
||||
"default-src 'none'; style-src 'unsafe-inline'; img-src data:; "
|
||||
"font-src data:; media-src data:; script-src 'none'; "
|
||||
"form-action 'none'; base-uri 'none'; object-src 'none'"
|
||||
)
|
||||
|
||||
|
||||
def text_field(obj: dict, name: str, *, default: str | None = None) -> str:
|
||||
value = obj.get(name, default)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(f"{name} 必须是非空字符串")
|
||||
return value.strip()
|
||||
|
||||
|
||||
def text_list(obj: dict, name: str) -> list[str]:
|
||||
values = obj.get(name)
|
||||
if not isinstance(values, list) or not values:
|
||||
raise ValueError(f"{name} 必须是非空字符串数组")
|
||||
if any(not isinstance(v, str) or not v.strip() for v in values):
|
||||
raise ValueError(f"{name} 的每项必须是非空字符串")
|
||||
return [v.strip() for v in values]
|
||||
|
||||
|
||||
class AssetCheck(HTMLParser):
|
||||
"""Reject resource dependencies; the browser sandbox disables behavior."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.styles = []
|
||||
self.in_style = False
|
||||
self.head_position = None
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list) -> None:
|
||||
attrs = dict(attrs)
|
||||
if tag == "head" and self.head_position is None:
|
||||
self.head_position = (self.getpos(), self.get_starttag_text())
|
||||
if tag in ("iframe", "frame", "object", "embed"):
|
||||
raise ValueError("候选 HTML 不支持嵌套文档;请提供静态内容或截图")
|
||||
if tag == "style":
|
||||
self.in_style = True
|
||||
if attrs.get("style"):
|
||||
self.styles.append(attrs["style"])
|
||||
if tag == "base":
|
||||
raise ValueError("候选 HTML 不应包含 base;资源需要内嵌")
|
||||
if tag == "meta" and attrs.get("http-equiv", "").lower() == "refresh":
|
||||
raise ValueError("候选 HTML 不应自动跳转")
|
||||
for key in ("src", "poster", "data", "href", "xlink:href"):
|
||||
value = attrs.get(key, "") or ""
|
||||
if not value or value.startswith(("#", "data:")):
|
||||
continue
|
||||
raise ValueError(f"候选 HTML 含未内嵌资源 {tag}.{key}: {value}")
|
||||
if attrs.get("srcset"):
|
||||
raise ValueError("候选 HTML 请用内嵌 src 代替 srcset")
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
if tag == "style":
|
||||
self.in_style = False
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if self.in_style:
|
||||
self.styles.append(data)
|
||||
|
||||
|
||||
CSS_TOKEN = re.compile(
|
||||
r"(?P<comment>/\*.*?\*/)|(?P<string>\"(?:\\.|[^\"\\])*\"|'(?:\\.|[^'\\])*')"
|
||||
r"|(?P<ident>(?:[-_a-zA-Z]|\\(?:[0-9a-fA-F]{1,6}\s?|[^\r\n]))"
|
||||
r"(?:[-_a-zA-Z0-9]|\\(?:[0-9a-fA-F]{1,6}\s?|[^\r\n]))*)"
|
||||
r"|(?P<space>\s+)|(?P<symbol>.)", re.S,
|
||||
)
|
||||
|
||||
|
||||
def css_unescape(value: str) -> str:
|
||||
def replacement(match):
|
||||
if match.group(1):
|
||||
codepoint = int(match.group(1), 16)
|
||||
return chr(codepoint) if 0 < codepoint <= 0x10FFFF else "\ufffd"
|
||||
return match.group(2)
|
||||
return re.sub(r"\\([0-9a-fA-F]{1,6})(?:\s)?|\\([^\r\n])", replacement, value)
|
||||
|
||||
|
||||
def check_css(css: str, label: str) -> None:
|
||||
tokens = [(m.lastgroup, m.group()) for m in CSS_TOKEN.finditer(css)
|
||||
if m.lastgroup not in ("comment", "space")]
|
||||
|
||||
def check_resource(value: str):
|
||||
if not css_unescape(value).strip().lower().startswith(("data:", "#")):
|
||||
raise ValueError(f"{label}: CSS 含未内嵌资源")
|
||||
|
||||
for index, (kind, value) in enumerate(tokens):
|
||||
if value == "@" and index + 1 < len(tokens) and tokens[index + 1][0] == "ident" and css_unescape(tokens[index + 1][1]).lower() == "import":
|
||||
raise ValueError(f"{label}: 请内嵌 CSS,不使用 @import")
|
||||
name = css_unescape(value).lower() if kind == "ident" else ""
|
||||
if name not in ("url", "image-set", "-webkit-image-set", "image", "src"):
|
||||
continue
|
||||
if index + 1 >= len(tokens) or tokens[index + 1][1] != "(":
|
||||
continue
|
||||
depth, body = 1, []
|
||||
for inner_kind, inner_value in tokens[index + 2:]:
|
||||
if inner_kind == "symbol" and inner_value == "(":
|
||||
depth += 1
|
||||
elif inner_kind == "symbol" and inner_value == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
break
|
||||
if name == "url":
|
||||
body.append(inner_value[1:-1] if inner_kind == "string" else inner_value)
|
||||
elif depth == 1 and inner_kind == "string":
|
||||
check_resource(inner_value[1:-1])
|
||||
if name == "url":
|
||||
check_resource("".join(body))
|
||||
|
||||
|
||||
# Sandboxed previews have no storage; give prototypes an in-memory stand-in so their scripts keep running.
|
||||
STORAGE_SHIM = (
|
||||
"<script>(()=>{const mem=()=>{const m=new Map();return{get length(){return m.size},"
|
||||
"key:i=>[...m.keys()][i]??null,getItem:k=>m.has(String(k))?m.get(String(k)):null,"
|
||||
"setItem:(k,v)=>{m.set(String(k),String(v))},removeItem:k=>{m.delete(String(k))},clear:()=>m.clear()}};"
|
||||
"for(const n of['localStorage','sessionStorage']){try{window[n].length}catch{"
|
||||
"Object.defineProperty(window,n,{value:mem(),configurable:true})}}})();</script>"
|
||||
)
|
||||
|
||||
|
||||
def preview_csp(interactive: bool) -> str:
|
||||
# Interactive prototypes may run their own inline scripts, still with no network or external files.
|
||||
# 'unsafe-eval' lets declarative libraries such as Alpine.js evaluate their attribute expressions.
|
||||
return PREVIEW_CSP.replace("script-src 'none'", "script-src 'unsafe-inline' 'unsafe-eval'") if interactive else PREVIEW_CSP
|
||||
|
||||
|
||||
EMBEDDABLE = {
|
||||
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp",
|
||||
".gif": "image/gif", ".avif": "image/avif", ".svg": "image/svg+xml",
|
||||
".woff2": "font/woff2", ".woff": "font/woff", ".ttf": "font/ttf", ".otf": "font/otf",
|
||||
".mp4": "video/mp4", ".webm": "video/webm",
|
||||
}
|
||||
ATTR_REF = re.compile(r"""(?P<lead>\b(?:src|poster|href|xlink:href)\s*=\s*)(?P<q>["'])(?P<ref>[^"'#][^"']*)(?P=q)""")
|
||||
CSS_REF = re.compile(r"""url\(\s*(?P<q>["']?)(?P<ref>[^"')\s][^"')]*)(?P=q)\s*\)""")
|
||||
|
||||
|
||||
def embed_local_files(content: str, base: Path, root: Path, used: set[Path]) -> str:
|
||||
"""Inline images, fonts and videos referenced relative to the candidate, as long as they stay inside the manifest folder."""
|
||||
|
||||
def data_url(ref: str) -> str | None:
|
||||
if re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*:|^//", ref):
|
||||
return None
|
||||
target = (base / ref.split("?")[0].split("#")[0]).resolve()
|
||||
mime = EMBEDDABLE.get(target.suffix.lower())
|
||||
if not mime or not target.is_file() or not target.is_relative_to(root):
|
||||
return None
|
||||
used.add(target)
|
||||
return f"data:{mime};base64," + base64.b64encode(target.read_bytes()).decode("ascii")
|
||||
|
||||
def attr(match):
|
||||
url = data_url(match["ref"])
|
||||
return match.group(0) if url is None else f'{match["lead"]}{match["q"]}{url}{match["q"]}'
|
||||
|
||||
def css(match):
|
||||
url = data_url(match["ref"])
|
||||
return match.group(0) if url is None else f'url("{url}")'
|
||||
|
||||
return CSS_REF.sub(css, ATTR_REF.sub(attr, content))
|
||||
|
||||
|
||||
SCRIPT_SRC = re.compile(r"""<script\b(?P<attrs>[^>]*?)\bsrc\s*=\s*(?P<q>["'])(?P<ref>[^"']+)(?P=q)(?P<rest>[^>]*)>\s*</script>""", re.I)
|
||||
LINK_TAG = re.compile(r"<link\b[^>]*>", re.I)
|
||||
LINK_ATTR = re.compile(r"""\b(?P<name>rel|href)\s*=\s*(?P<q>["'])(?P<value>[^"']*)(?P=q)""", re.I)
|
||||
|
||||
|
||||
def local_file(ref: str, base: Path, root: Path) -> Path | None:
|
||||
if re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*:|^//", ref):
|
||||
return None
|
||||
target = (base / ref.split("?")[0].split("#")[0]).resolve()
|
||||
return target if target.is_file() and target.is_relative_to(root) else None
|
||||
|
||||
|
||||
def inline_local_code(content: str, base: Path, root: Path, used: set[Path], interactive: bool) -> str:
|
||||
"""Inline stylesheets and, for interactive candidates, scripts that live in the manifest folder."""
|
||||
|
||||
def link(match):
|
||||
attrs = {m["name"].lower(): m["value"] for m in LINK_ATTR.finditer(match.group(0))}
|
||||
target = local_file(attrs.get("href", ""), base, root) if "stylesheet" in attrs.get("rel", "").lower() else None
|
||||
if target is None:
|
||||
return match.group(0)
|
||||
used.add(target)
|
||||
css = embed_local_files(target.read_text(encoding="utf-8"), target.parent, root, used)
|
||||
return "<style>" + css.replace("</style", "<\\/style") + "</style>"
|
||||
|
||||
deferred = []
|
||||
|
||||
def script(match):
|
||||
target = local_file(match["ref"], base, root)
|
||||
if target is None:
|
||||
return match.group(0)
|
||||
used.add(target)
|
||||
if not interactive:
|
||||
return ""
|
||||
attrs = re.sub(r"\s+", " ", f'{match["attrs"]} {match["rest"]}').strip()
|
||||
code = target.read_text(encoding="utf-8").replace("</script", "<\\/script")
|
||||
is_module = re.search(r"""type\s*=\s*["']module["']""", attrs, re.I)
|
||||
tag = ('<script type="module">' if is_module else "<script>") + code + "</script>"
|
||||
# Inline scripts ignore defer, so deferred files move to the end of the body to keep their timing.
|
||||
if re.search(r"\bdefer\b", attrs, re.I) and not is_module:
|
||||
deferred.append(tag)
|
||||
return ""
|
||||
return tag
|
||||
|
||||
content = SCRIPT_SRC.sub(script, LINK_TAG.sub(link, content))
|
||||
if deferred:
|
||||
close = content.lower().rfind("</body>")
|
||||
content = content + "".join(deferred) if close < 0 else content[:close] + "".join(deferred) + content[close:]
|
||||
return content
|
||||
|
||||
|
||||
def prepare_html(path: Path, interactive: bool = False, root: Path | None = None, used: set[Path] | None = None) -> str:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
if root is not None:
|
||||
root = root.resolve()
|
||||
used = used if used is not None else set()
|
||||
content = inline_local_code(content, path.parent, root, used, interactive)
|
||||
content = embed_local_files(content, path.parent, root, used)
|
||||
parser = AssetCheck()
|
||||
parser.feed(content)
|
||||
for style in parser.styles:
|
||||
check_css(style, path.name)
|
||||
if parser.head_position is None:
|
||||
raise ValueError(f"{path.name}: 候选 HTML 需要完整的 head 元素")
|
||||
(line, column), start_tag = parser.head_position
|
||||
head_end = sum(len(part) + 1 for part in content.split('\n')[:line - 1]) + column + len(start_tag)
|
||||
meta = f'<meta http-equiv="Content-Security-Policy" content="{preview_csp(interactive)}">'
|
||||
return content[:head_end] + meta + (STORAGE_SHIM if interactive else "") + content[head_end:]
|
||||
|
||||
|
||||
def prepare_image(path: Path) -> str:
|
||||
data = path.read_bytes()
|
||||
if data.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
mime = "image/png"
|
||||
elif data.startswith(b"\xff\xd8\xff"):
|
||||
mime = "image/jpeg"
|
||||
elif data.startswith(b"RIFF") and data[8:12] == b"WEBP":
|
||||
mime = "image/webp"
|
||||
else:
|
||||
raise ValueError(f"{path.name}: 静态预览支持 PNG、JPEG 和 WebP")
|
||||
return f"data:{mime};base64," + base64.b64encode(data).decode("ascii")
|
||||
|
||||
|
||||
LOOPBACK = {"localhost", "127.0.0.1", "::1"}
|
||||
|
||||
|
||||
def local_url(value: str, identifier: str) -> str:
|
||||
"""Live candidates only point at a dev server on this machine."""
|
||||
try:
|
||||
parts = urlsplit(value)
|
||||
parts.port # Reject malformed ports before generating CSP origins.
|
||||
except ValueError:
|
||||
raise ValueError(f"{identifier}: url 必须是有效的本机 http(s) 地址") from None
|
||||
if parts.scheme not in ("http", "https") or (parts.hostname or "").lower() not in LOOPBACK or parts.username or parts.password:
|
||||
raise ValueError(f"{identifier}: url 只能是本机开发服务器地址,例如 http://localhost:5173/orders")
|
||||
return value
|
||||
|
||||
|
||||
def load_manifest(path: Path) -> tuple[dict, set[Path]]:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(raw, dict) or raw.get("schemaVersion") != 1:
|
||||
raise ValueError("manifest.schemaVersion 必须为 1")
|
||||
data = {"schemaVersion": 1, "project": text_field(raw, "project"),
|
||||
"brief": text_field(raw, "brief"), "round": text_field(raw, "round", default="01")}
|
||||
if "serve" in raw:
|
||||
serve = raw["serve"]
|
||||
if not isinstance(serve, dict):
|
||||
raise ValueError("serve 必须是对象")
|
||||
if not isinstance(serve.get("command"), str) or not serve["command"].strip():
|
||||
raise ValueError("serve.command 必须是非空字符串")
|
||||
if "cwd" in serve and (not isinstance(serve["cwd"], str) or not Path(serve["cwd"]).is_absolute()):
|
||||
raise ValueError("serve.cwd 必须是绝对路径")
|
||||
if "url" in serve:
|
||||
if not isinstance(serve["url"], str) or not serve["url"].strip():
|
||||
raise ValueError("serve.url 必须是非空的本机 http(s) 地址")
|
||||
local_url(serve["url"], "serve")
|
||||
data["serve"] = serve
|
||||
candidates = raw.get("candidates")
|
||||
if not isinstance(candidates, list) or not candidates:
|
||||
raise ValueError("candidates 至少需要一个候选")
|
||||
seen = set()
|
||||
inputs = {path, TEMPLATE.resolve()}
|
||||
output = []
|
||||
for candidate in candidates:
|
||||
if not isinstance(candidate, dict):
|
||||
raise ValueError("每个候选必须是对象")
|
||||
identifier = text_field(candidate, "id")
|
||||
if not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,63}", identifier) or identifier in seen:
|
||||
raise ValueError(f"候选 id 无效或重复: {identifier}")
|
||||
seen.add(identifier)
|
||||
kind = candidate.get("kind", "html")
|
||||
if kind not in ("html", "image", "url"):
|
||||
raise ValueError(f"未知候选 kind: {kind}")
|
||||
baseline = candidate.get("baseline", False)
|
||||
interactive = candidate.get("interactive", False)
|
||||
if not isinstance(baseline, bool) or not isinstance(interactive, bool):
|
||||
raise ValueError(f"{identifier}: baseline 和 interactive 必须是 true 或 false")
|
||||
if interactive and kind != "html":
|
||||
raise ValueError(f"{identifier}: interactive 只用于 html 候选")
|
||||
if kind == "url":
|
||||
source = None
|
||||
url = local_url(text_field(candidate, "url"), identifier)
|
||||
else:
|
||||
source = (path.parent / text_field(candidate, "source")).resolve()
|
||||
if not source.is_relative_to(path.parent) or not source.is_file():
|
||||
raise ValueError(f"候选 source 必须是 manifest 目录内可读文件: {identifier}")
|
||||
inputs.add(source)
|
||||
colors = text_list(candidate, "palette")
|
||||
if any(not re.fullmatch(r"#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})", c) for c in colors):
|
||||
raise ValueError(f"{identifier}: palette 需要十六进制颜色")
|
||||
output.append({
|
||||
"id": identifier,
|
||||
**{name: text_field(candidate, name) for name in ("name", "concept", "typography")},
|
||||
"palette": colors, "traits": text_list(candidate, "traits"), "kind": kind,
|
||||
"content": url if kind == "url" else prepare_html(source, interactive, path.parent, inputs) if kind == "html" else prepare_image(source),
|
||||
"sourceLabel": url if kind == "url" else source.name,
|
||||
"baseline": baseline,
|
||||
"interactive": interactive,
|
||||
})
|
||||
if sum(c["baseline"] for c in output) > 1:
|
||||
raise ValueError("最多只能有一个基线候选")
|
||||
# The current version always sits first so every direction is read against it.
|
||||
output.sort(key=lambda c: not c["baseline"])
|
||||
data["candidates"] = output
|
||||
canonical = json.dumps(data, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
data["fingerprint"] = hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:24]
|
||||
return data, inputs
|
||||
|
||||
|
||||
def build(manifest: Path, output: Path, *, force: bool = False) -> dict:
|
||||
manifest, output = manifest.resolve(), output.resolve()
|
||||
data, inputs = load_manifest(manifest)
|
||||
if output in inputs or output.is_relative_to(SKILL_ROOT):
|
||||
raise ValueError("输出不能覆盖输入或写进 Skill 安装目录")
|
||||
if output.exists() and not force:
|
||||
raise FileExistsError("输出已存在;使用新路径,或明确加 --force 更新")
|
||||
template = TEMPLATE.read_text(encoding="utf-8")
|
||||
if template.count(MARKER) != 1:
|
||||
raise ValueError("模板数据入口缺失或重复")
|
||||
if template.count(CONNECT_CSP) != 1:
|
||||
raise ValueError("模板连接策略入口缺失或重复")
|
||||
origins = set()
|
||||
for candidate in data["candidates"]:
|
||||
if candidate["kind"] == "url":
|
||||
parts = urlsplit(candidate["content"])
|
||||
# Browsers reject IPv6 literals in CSP source lists; probe their loopback alias.
|
||||
host = "localhost" if parts.hostname == "::1" else parts.hostname.lower()
|
||||
port = f":{parts.port}" if parts.port is not None else ""
|
||||
origins.add(f"{parts.scheme}://{host}{port}")
|
||||
connect_csp = "connect-src " + (" ".join(sorted(origins)) if origins else "'none'")
|
||||
# A closing script tag in metadata or a nested candidate cannot escape the container.
|
||||
payload = json.dumps(data, ensure_ascii=False).replace("<", "\\u003c").replace("\u2028", "\\u2028").replace("\u2029", "\\u2029")
|
||||
page = template.replace(CONNECT_CSP, connect_csp).replace(MARKER, payload)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
if force:
|
||||
temp = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=output.parent, delete=False) as handle:
|
||||
temp = Path(handle.name)
|
||||
handle.write(page)
|
||||
temp.replace(output)
|
||||
finally:
|
||||
if temp and temp.exists():
|
||||
temp.unlink()
|
||||
else:
|
||||
with output.open("x", encoding="utf-8") as handle:
|
||||
handle.write(page)
|
||||
if origins and "serve" not in data:
|
||||
print("提醒:本轮包含 url 候选,建议在 manifest 顶层补上 serve 启动方式,方便重新打开对比页。", file=sys.stderr)
|
||||
return {"output": str(output), "candidates": len(data["candidates"]), "fingerprint": data["fingerprint"], "bytes": output.stat().st_size}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if sys.version_info < (3, 10):
|
||||
print("需要 Python 3.10 或更新版本", file=sys.stderr)
|
||||
return 2
|
||||
parser = argparse.ArgumentParser(description="把本地候选与 manifest 组装成独立风格对比 HTML")
|
||||
parser.add_argument("manifest", type=Path)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--force", action="store_true", help="明确允许原子更新已有输出")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
print(json.dumps(build(args.manifest, args.output, force=args.force), ensure_ascii=False))
|
||||
return 0
|
||||
except (OSError, ValueError, TypeError) as exc:
|
||||
print(f"未生成对比页:{exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,327 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""检查本 Skill 有没有新版本,有就自动更新。
|
||||
|
||||
由使用 Skill 触发,最多每 10 分钟联网检查一次,网络失败后一小时可重试。
|
||||
只读取 ui.oiloil.org 公开的版本列表,不上传项目内容。
|
||||
发现新版本时用 npx github:oil-oil/oil-cli 原地更新本目录:
|
||||
成功打印一行“已自动更新”,不能自动更新时打印一行提示,条件不变时每天最多提示一次;
|
||||
没有新版本、网络失败或在开发目录(含 .git)里运行时什么也不输出。
|
||||
设置环境变量 OIL_NO_UPDATE_CHECK=1 关闭检查,OIL_NO_AUTO_UPDATE=1 只提示不自动更新。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
API = os.environ.get("OIL_API", "https://ui.oiloil.org").rstrip("/")
|
||||
CLI = "github:oil-oil/oil-cli"
|
||||
DAY = 24 * 3600
|
||||
# 发版后用户下一次使用就能更新;版本列表有服务端缓存,频繁检查的成本很低
|
||||
CHECK_INTERVAL = 10 * 60
|
||||
# 联网检查不能拖慢任务:超时就跳过,下次再查
|
||||
FETCH_TIMEOUT = 2
|
||||
RETRY_AFTER_FAILURE = 3600
|
||||
UPDATE_TIMEOUT = 300
|
||||
|
||||
|
||||
def read_skill() -> tuple[str, str] | None:
|
||||
text = (ROOT / "SKILL.md").read_text(encoding="utf-8")
|
||||
name = re.search(r"^name:\s*\"?([\w-]+)\"?\s*$", text, re.MULTILINE)
|
||||
version = re.search(r"^\s+version:\s*\"?(\d+\.\d+\.\d+)\"?\s*$", text, re.MULTILINE)
|
||||
if not name or not version:
|
||||
return None
|
||||
return name.group(1), version.group(1)
|
||||
|
||||
|
||||
def state_dir() -> Path:
|
||||
if sys.platform == "win32":
|
||||
base = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local")
|
||||
else:
|
||||
base = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state")
|
||||
return base / "oil"
|
||||
|
||||
|
||||
def state_path(name: str) -> Path:
|
||||
"""公开版本缓存仍按 Skill 共享,兼容原来的缓存位置。"""
|
||||
return state_dir() / f"{name}-update.json"
|
||||
|
||||
|
||||
def installation_state_path(name: str) -> Path:
|
||||
key = hashlib.sha256(os.path.normcase(str(ROOT)).encode()).hexdigest()
|
||||
return state_dir() / "installations" / f"{name}-{key}.json"
|
||||
|
||||
|
||||
def config_path() -> Path:
|
||||
if sys.platform == "win32":
|
||||
base = Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming")
|
||||
else:
|
||||
base = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
|
||||
return base / "oil" / "config.json"
|
||||
|
||||
|
||||
def logged_in() -> bool:
|
||||
if os.environ.get("OIL_TOKEN"):
|
||||
return True
|
||||
try:
|
||||
return bool(json.loads(config_path().read_text(encoding="utf-8")).get("token"))
|
||||
except (OSError, ValueError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
def load(path: Path) -> dict:
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def save(path: Path, data: dict) -> None:
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def parse(version: str) -> tuple[int, ...]:
|
||||
return tuple(int(part) for part in version.split("."))
|
||||
|
||||
|
||||
def fetch(name: str) -> dict | None:
|
||||
request = urllib.request.Request(f"{API}/api/store/versions", headers={"User-Agent": "oil-skill-update-check"})
|
||||
with urllib.request.urlopen(request, timeout=FETCH_TIMEOUT) as response:
|
||||
entry = json.load(response).get("skills", {}).get(name)
|
||||
if not isinstance(entry, dict) or not re.fullmatch(r"\d+\.\d+\.\d+", str(entry.get("latest", ""))):
|
||||
return None
|
||||
history = entry.get("history") or []
|
||||
notes = next((h.get("notes", "") for h in history if h.get("version") == entry["latest"]), "")
|
||||
# 免费版的版本信息带公开下载地址,付费版没有
|
||||
return {"latest": entry["latest"], "notes": notes, "free": bool(entry.get("download_url"))}
|
||||
|
||||
|
||||
def headline(notes: str) -> str:
|
||||
for line in notes.splitlines():
|
||||
line = re.sub(r"^[#>*\-\s]+", "", line).strip().rstrip("。.;;,,")
|
||||
if line:
|
||||
return line if len(line) <= 60 else line[:59] + "…"
|
||||
return ""
|
||||
|
||||
|
||||
def file_signature(path: str | Path | None) -> list:
|
||||
if not path:
|
||||
return []
|
||||
try:
|
||||
stat = Path(path).stat()
|
||||
return [str(path), stat.st_mtime_ns, stat.st_size]
|
||||
except OSError:
|
||||
return [str(path)]
|
||||
|
||||
|
||||
def update_context() -> tuple[str, str | None, bool]:
|
||||
"""只存指纹;登录配置变化或补齐依赖时不沿用旧冷却。"""
|
||||
npx = shutil.which("npx")
|
||||
node = shutil.which("node")
|
||||
node_version = ""
|
||||
if node:
|
||||
try:
|
||||
result = subprocess.run([node, "--version"], capture_output=True, text=True, timeout=3)
|
||||
if result.returncode == 0:
|
||||
node_version = result.stdout.strip()
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
version = re.fullmatch(r"v?(\d+)\.\d+\.\d+", node_version)
|
||||
ready = bool(npx and version and int(version.group(1)) >= 18)
|
||||
context = [API, file_signature(config_path()), os.environ.get("OIL_TOKEN", ""),
|
||||
file_signature(npx), file_signature(node), node_version,
|
||||
bool(os.environ.get("OIL_NO_AUTO_UPDATE"))]
|
||||
fingerprint = hashlib.sha256(json.dumps(context).encode()).hexdigest()
|
||||
return fingerprint, npx, ready
|
||||
|
||||
|
||||
class UpdateResult(NamedTuple):
|
||||
attempted: bool
|
||||
reason: str
|
||||
|
||||
|
||||
def cli_error(stdout: str, stderr: str) -> str:
|
||||
# npx 的日志可能混在 JSON 前面;不转述 CLI 原始 message,避免泄露信息。
|
||||
for line in reversed((stdout + "\n" + stderr).splitlines()):
|
||||
try:
|
||||
data = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if isinstance(data, dict) and isinstance(data.get("error"), str):
|
||||
return data["error"]
|
||||
return ""
|
||||
|
||||
|
||||
def auto_update(name: str, latest: str, free: bool, npx: str | None, ready: bool) -> UpdateResult:
|
||||
if os.environ.get("OIL_NO_AUTO_UPDATE"):
|
||||
return UpdateResult(False, "manual")
|
||||
if not (free or logged_in()):
|
||||
return UpdateResult(False, "unauthorized")
|
||||
if not ready:
|
||||
return UpdateResult(False, "dependencies")
|
||||
# CI=1 让命令行在没登录时直接失败,不会停下来等浏览器确认
|
||||
env = {**os.environ, "CI": "1"}
|
||||
try:
|
||||
result = subprocess.run([npx, "-y", CLI, "update", name, "--path", str(ROOT), "--json"], env=env,
|
||||
stdin=subprocess.DEVNULL, capture_output=True, text=True,
|
||||
timeout=UPDATE_TIMEOUT, check=False)
|
||||
except FileNotFoundError:
|
||||
return UpdateResult(False, "dependencies")
|
||||
except subprocess.TimeoutExpired:
|
||||
return UpdateResult(True, "network")
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return UpdateResult(True, "failed")
|
||||
error = cli_error(result.stdout, result.stderr)
|
||||
if error:
|
||||
return UpdateResult(True, error)
|
||||
# npm 在启动 CLI 前的网络或 Node 缺失错误不是 JSON。
|
||||
if re.search(r"ENOTFOUND|EAI_AGAIN|ECONN\w+|ETIMEDOUT|ERR_SOCKET_TIMEOUT|fetch failed", result.stderr, re.I):
|
||||
return UpdateResult(True, "network")
|
||||
if re.search(r"node[^\n]*(?:not found|No such file)|EBADENGINE", result.stderr, re.I):
|
||||
return UpdateResult(False, "dependencies")
|
||||
skill = read_skill()
|
||||
if result.returncode == 0 and skill and parse(skill[1]) >= parse(latest):
|
||||
return UpdateResult(True, "updated")
|
||||
return UpdateResult(True, "failed")
|
||||
|
||||
|
||||
def english() -> bool:
|
||||
return (os.environ.get("LC_ALL") or os.environ.get("LC_MESSAGES") or os.environ.get("LANG", "")).lower().startswith("en")
|
||||
|
||||
|
||||
def update_command(name: str) -> str:
|
||||
# 双引号方便直接复制,也保护 POSIX shell 中的变量与命令替换。
|
||||
path = str(ROOT)
|
||||
if os.name != "nt":
|
||||
path = re.sub(r'([\\"$`])', r'\\\1', path)
|
||||
return f'npx {CLI} update {name} --path "{path}"'
|
||||
|
||||
|
||||
def product_name(name: str) -> str:
|
||||
if name == "oil-ui-pro":
|
||||
return "Oil UI Pro"
|
||||
if name == "oil-ui":
|
||||
return "Oil UI (open source)" if english() else "Oil UI 开源版"
|
||||
return name
|
||||
|
||||
|
||||
def zh_label(label: str) -> str:
|
||||
# 中文句子里,以汉字结尾的名称后面不加空格。
|
||||
return label if re.search(r"[\u4e00-\u9fff]$", label) else f"{label} "
|
||||
|
||||
|
||||
def notice(name: str, current: str, latest: str, detail: str, reason: str) -> str:
|
||||
command = update_command(name)
|
||||
label = product_name(name)
|
||||
if english():
|
||||
intro = f"{label} {latest} is available (current: {current}){detail}. "
|
||||
if reason == "unauthorized":
|
||||
return intro + f"Authorization has expired or is missing. Run npx {CLI} login, then run {command}."
|
||||
if reason == "inactive":
|
||||
return intro + f"This Skill has no purchase record. Purchase it at https://ui.oiloil.org/pro/ to get updates, then run {command}."
|
||||
if reason == "dependencies":
|
||||
return intro + f"Automatic updates need Node.js 18 or later. Install it, then run {command}."
|
||||
return intro + f"To update, run {command}."
|
||||
intro = f"{zh_label(label)}有新版本 {latest}(当前 {current}){detail}。"
|
||||
if reason == "unauthorized":
|
||||
return intro + f"授权已失效或尚未授权。运行 npx {CLI} login 重新授权,再运行 {command}。"
|
||||
if reason == "inactive":
|
||||
return intro + f"这份 Skill 没有对应的购买记录,购买后才能获得更新。打开 https://ui.oiloil.org/pro/ 购买,再运行 {command}。"
|
||||
if reason == "dependencies":
|
||||
return intro + f"自动更新需要 Node.js 18 以上。安装后运行 {command}。"
|
||||
return intro + f"在终端运行 {command} 即可更新。"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if os.environ.get("OIL_NO_UPDATE_CHECK") or (ROOT / ".git").exists():
|
||||
return 0
|
||||
skill = read_skill()
|
||||
if not skill:
|
||||
return 0
|
||||
name, current = skill
|
||||
path = state_path(name)
|
||||
# 原来的共享文件可能包含失败与提示记录,迁移时只保留公开缓存。
|
||||
cache = {key: value for key, value in load(path).items()
|
||||
if key in {"checked_at", "latest", "notes", "free", "fetch_failed_at"}}
|
||||
install_path = installation_state_path(name)
|
||||
state = load(install_path)
|
||||
now = time.time()
|
||||
|
||||
if now - float(cache.get("checked_at", 0)) >= CHECK_INTERVAL:
|
||||
if now - float(cache.get("fetch_failed_at", 0)) < RETRY_AFTER_FAILURE:
|
||||
return 0
|
||||
try:
|
||||
found = fetch(name)
|
||||
except Exception:
|
||||
found = None
|
||||
if found:
|
||||
cache.update(found)
|
||||
cache["checked_at"] = now
|
||||
cache.pop("fetch_failed_at", None)
|
||||
else:
|
||||
cache["fetch_failed_at"] = now
|
||||
save(path, cache)
|
||||
if not found:
|
||||
return 0
|
||||
|
||||
latest = cache.get("latest")
|
||||
if not isinstance(latest, str) or not re.fullmatch(r"\d+\.\d+\.\d+", latest) or parse(latest) <= parse(current):
|
||||
return 0
|
||||
summary = headline(cache.get("notes", ""))
|
||||
detail = (f": {summary}" if english() else f":{summary}") if summary else ""
|
||||
context, npx, ready = update_context()
|
||||
|
||||
reason = state.get("auto_failed_reason", "failed")
|
||||
cooldown = RETRY_AFTER_FAILURE if reason == "network" else DAY
|
||||
if (state.get("auto_failed_version") != latest or state.get("auto_failed_context") != context
|
||||
or now - float(state.get("auto_failed_at", 0)) >= cooldown):
|
||||
result = auto_update(name, latest, bool(cache.get("free")), npx, ready)
|
||||
reason = result.reason
|
||||
if reason == "updated":
|
||||
save(install_path, {})
|
||||
label = product_name(name)
|
||||
if english():
|
||||
print(f"{label} updated automatically to {latest} (previous: {current}){detail}. Read SKILL.md again before continuing.")
|
||||
else:
|
||||
print(f"{zh_label(label)}已自动更新到 {latest}(原来是 {current}){detail}。请重新读取 SKILL.md 再继续。")
|
||||
return 0
|
||||
if result.attempted:
|
||||
state.update(auto_failed_version=latest, auto_failed_at=now,
|
||||
auto_failed_reason=reason, auto_failed_context=context)
|
||||
else:
|
||||
for key in list(state):
|
||||
if key.startswith("auto_failed_"):
|
||||
state.pop(key)
|
||||
save(install_path, state)
|
||||
|
||||
if reason == "network":
|
||||
return 0
|
||||
|
||||
language = "en" if english() else "zh"
|
||||
if (state.get("notified_version") == latest and state.get("notified_reason") == reason
|
||||
and state.get("notified_context") == context and state.get("notified_language") == language
|
||||
and now - float(state.get("notified_at", 0)) < DAY):
|
||||
return 0
|
||||
state["notified_version"], state["notified_at"] = latest, now
|
||||
state.update(notified_reason=reason, notified_context=context, notified_language=language)
|
||||
save(install_path, state)
|
||||
print(notice(name, current, latest, detail, reason))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,31 +0,0 @@
|
||||
#!/bin/sh
|
||||
# 加载时先找可用的 Python 3;缺少时只提醒一次,继续原任务。
|
||||
script_dir=${0%/*}
|
||||
if [ -n "$OIL_NO_UPDATE_CHECK" ] || [ -e "$script_dir/../.git" ]; then
|
||||
exit 0
|
||||
fi
|
||||
state_base=${XDG_STATE_HOME:-${LOCALAPPDATA:-$HOME/.local/state}}
|
||||
marker="$state_base/oil/update-python-missing"
|
||||
for interpreter in python3 python; do
|
||||
if command -v "$interpreter" >/dev/null 2>&1 && "$interpreter" -c 'import sys; sys.exit(0 if sys.version_info[0] == 3 else 1)' >/dev/null 2>&1; then
|
||||
rmdir "$marker" 2>/dev/null
|
||||
exec "$interpreter" "$script_dir/check_update.py"
|
||||
fi
|
||||
done
|
||||
# mkdir 的独占创建同时防止连续加载与并发加载重复提醒。
|
||||
missing_python_notice() {
|
||||
case ${LC_ALL:-${LC_MESSAGES:-$LANG}} in
|
||||
en*|EN*) printf '%s\n' 'Automatic updates need Python 3. This update check did not run.' ;;
|
||||
*) printf '%s\n' '自动更新需要 Python 3,本次没有检查更新。' ;;
|
||||
esac
|
||||
}
|
||||
mkdir -p "$state_base/oil" 2>/dev/null
|
||||
if mkdir "$marker" 2>/dev/null; then
|
||||
missing_python_notice
|
||||
elif [ -d "$marker" ]; then
|
||||
printf '%s\n' 'OIL_UPDATE_CHECK_SKIPPED: missing_python'
|
||||
else
|
||||
# 状态目录不可写时,由宿主记住这次提醒。
|
||||
missing_python_notice
|
||||
fi
|
||||
exit 0
|
||||
@@ -1,388 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// 给设计小样截图、录屏和做基本检查。只依赖 Node 22+ 和本机的 Chrome / Chromium / Edge。
|
||||
// 用法见 references/tools.md;`node shoot.mjs --help` 打印同样的说明。
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { createServer } from "node:http";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, dirname, extname, join, resolve } from "node:path";
|
||||
|
||||
const HELP = `用法:node shoot.mjs <页面地址或文件> [选项]
|
||||
|
||||
--out <目录> 输出目录,默认 ./shots
|
||||
--size <宽x高,...> 视口,默认 390x844;可写多个,例如 390x844,1280x900
|
||||
--states <a,b,...> 依次用 ?state=<名字> 打开并各截一张
|
||||
--param <名字> 状态参数名,默认 state
|
||||
--zoom <倍数> 设备像素比,默认 1;2 即 200% 截图
|
||||
--full 截整页,默认只截视口
|
||||
--mask 另截一份遮掉全部文字的版本
|
||||
--sheet 把所有状态拼成一张并排图(配合 --mask 再拼一张遮字版)
|
||||
--steps "<动作>" 截图前先执行的动作,用分号分隔:
|
||||
click <选择器> | hover <选择器> | drag <选择器> <dx> <dy>
|
||||
type <选择器> <文字> | key <按键> | scroll <dy> | wait <毫秒>
|
||||
--record 录下 --steps 的执行过程,输出 record.mp4 和开始、中间、结束三帧
|
||||
--hold <毫秒> 录屏时动作结束后再录多久,默认 1200
|
||||
--wait <毫秒> 页面加载后等多久再截,默认 400
|
||||
|
||||
每张图都会检查控制台错误、横向溢出和加载失败的图片,结果写进 report.json。`;
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (!args.length || args.includes("--help") || args.includes("-h")) {
|
||||
console.log(HELP);
|
||||
process.exit(args.length ? 0 : 1);
|
||||
}
|
||||
if (typeof WebSocket !== "function") fail("需要 Node 22 或更新的版本。");
|
||||
|
||||
const opt = { out: "shots", size: "390x844", param: "state", zoom: "1", hold: "1200", wait: "400" };
|
||||
const options = [...HELP.matchAll(/^ (--\S+)/gm)].map((m) => m[1]);
|
||||
const flags = new Set();
|
||||
let target = null;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a === "--force") continue;
|
||||
if (["--full", "--mask", "--sheet", "--record"].includes(a)) flags.add(a.slice(2));
|
||||
else if (a.startsWith("--")) {
|
||||
if (!options.includes(a)) fail(`不认识的选项 ${a}\n可用选项:${options.join(" ")}`);
|
||||
if (i + 1 >= args.length || args[i + 1].startsWith("--")) fail(`${a} 需要一个值`);
|
||||
opt[a.slice(2)] = args[++i];
|
||||
} else target = a;
|
||||
}
|
||||
if (!target) fail("缺少页面地址或文件。");
|
||||
|
||||
const sizes = opt.size.split(",").map((s) => {
|
||||
const m = s.trim().match(/^(\d+)x(\d+)$/);
|
||||
if (!m) fail(`尺寸写成 宽x高,例如 390x844:${s}`);
|
||||
return { w: +m[1], h: +m[2] };
|
||||
});
|
||||
const states = opt.states ? opt.states.split(",").map((s) => s.trim()).filter(Boolean) : [null];
|
||||
const zoom = Number(opt.zoom) || 1;
|
||||
const out = resolve(opt.out);
|
||||
mkdirSync(out, { recursive: true });
|
||||
|
||||
function fail(message) {
|
||||
console.error(`shoot:${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ---------- 本地文件用一个只监听本机的静态服务器打开,模块脚本和 fetch 才能正常工作 ----------
|
||||
const MIME = {
|
||||
".html": "text/html; charset=utf-8", ".js": "text/javascript", ".mjs": "text/javascript", ".css": "text/css",
|
||||
".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||
".webp": "image/webp", ".gif": "image/gif", ".avif": "image/avif", ".woff2": "font/woff2", ".woff": "font/woff",
|
||||
".ttf": "font/ttf", ".otf": "font/otf", ".mp4": "video/mp4", ".webm": "video/webm",
|
||||
};
|
||||
let server = null;
|
||||
async function resolveTarget(t) {
|
||||
if (/^https?:\/\//.test(t)) return t;
|
||||
const file = resolve(t);
|
||||
if (!existsSync(file)) fail(`找不到文件:${t}`);
|
||||
const root = statSync(file).isDirectory() ? file : dirname(file);
|
||||
const page = statSync(file).isDirectory() ? "index.html" : basename(file);
|
||||
server = createServer((req, res) => {
|
||||
const path = decodeURIComponent(new URL(req.url, "http://x").pathname);
|
||||
const local = resolve(join(root, path));
|
||||
if (!local.startsWith(root) || !existsSync(local) || statSync(local).isDirectory()) {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { "Content-Type": MIME[extname(local).toLowerCase()] || "application/octet-stream" });
|
||||
res.end(readFileSync(local));
|
||||
});
|
||||
await new Promise((ok) => server.listen(0, "127.0.0.1", ok));
|
||||
return `http://127.0.0.1:${server.address().port}/${encodeURIComponent(page)}`;
|
||||
}
|
||||
|
||||
// ---------- 启动一个独立的临时浏览器,不碰用户自己的浏览器数据 ----------
|
||||
function findChrome() {
|
||||
const env = process.env.CHROME_PATH;
|
||||
if (env && existsSync(env)) return env;
|
||||
const candidates = {
|
||||
darwin: [
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
||||
],
|
||||
win32: [
|
||||
`${process.env["PROGRAMFILES"]}\\Google\\Chrome\\Application\\chrome.exe`,
|
||||
`${process.env["PROGRAMFILES(X86)"]}\\Google\\Chrome\\Application\\chrome.exe`,
|
||||
`${process.env["PROGRAMFILES(X86)"]}\\Microsoft\\Edge\\Application\\msedge.exe`,
|
||||
],
|
||||
}[process.platform];
|
||||
for (const c of candidates || []) if (c && existsSync(c)) return c;
|
||||
for (const name of ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge"]) {
|
||||
const r = spawnSync("which", [name], { encoding: "utf8" });
|
||||
if (r.status === 0 && r.stdout.trim()) return r.stdout.trim();
|
||||
}
|
||||
fail("没找到 Chrome、Chromium 或 Edge;安装其一,或用环境变量 CHROME_PATH 指定路径。");
|
||||
}
|
||||
|
||||
const chromePath = findChrome();
|
||||
const profile = mkdtempSync(join(tmpdir(), "oil-shoot-"));
|
||||
const chrome = spawn(chromePath, [
|
||||
"--headless=new", "--remote-debugging-port=0", `--user-data-dir=${profile}`, "--no-first-run",
|
||||
"--no-default-browser-check", "--hide-scrollbars", "--mute-audio", "--disable-extensions", "about:blank",
|
||||
], { stdio: ["ignore", "ignore", "pipe"] });
|
||||
|
||||
let cleaning;
|
||||
function cleanup() {
|
||||
return cleaning ||= (async () => {
|
||||
if (chrome.exitCode === null && chrome.signalCode === null) {
|
||||
await new Promise((ok) => {
|
||||
const timer = setTimeout(() => { chrome.kill("SIGKILL"); ok(); }, 3000);
|
||||
chrome.once("close", () => { clearTimeout(timer); ok(); });
|
||||
chrome.kill();
|
||||
});
|
||||
}
|
||||
server?.close();
|
||||
rmSync(profile, { recursive: true, force: true });
|
||||
})();
|
||||
}
|
||||
// Early startup failures still use process.exit; its handlers must clean up synchronously.
|
||||
process.on("exit", () => {
|
||||
try { chrome.kill(); } catch {}
|
||||
try { server?.close(); } catch {}
|
||||
try { rmSync(profile, { recursive: true, force: true }); } catch {}
|
||||
});
|
||||
process.on("SIGINT", async () => { await cleanup(); process.exit(130); });
|
||||
process.on("SIGTERM", async () => { await cleanup(); process.exit(143); });
|
||||
chrome.on("error", (error) => fail(`浏览器启动失败:${error.message}`));
|
||||
|
||||
const wsUrl = await new Promise((ok) => {
|
||||
let buf = "";
|
||||
const timer = setTimeout(() => fail("浏览器 15 秒内没有启动。"), 15000);
|
||||
chrome.stderr.on("data", (d) => {
|
||||
buf += d;
|
||||
const m = buf.match(/DevTools listening on (ws:\/\/\S+)/);
|
||||
if (m) { clearTimeout(timer); ok(m[1]); }
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- Chrome DevTools 协议 ----------
|
||||
const ws = new WebSocket(wsUrl);
|
||||
await new Promise((ok, no) => { ws.onopen = ok; ws.onerror = () => no(new Error("连接浏览器失败")); });
|
||||
let seq = 0;
|
||||
const pending = new Map();
|
||||
const listeners = [];
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.id && pending.has(msg.id)) {
|
||||
const { ok, no } = pending.get(msg.id);
|
||||
pending.delete(msg.id);
|
||||
msg.error ? no(new Error(msg.error.message)) : ok(msg.result);
|
||||
} else if (msg.method) listeners.forEach((fn) => fn(msg));
|
||||
};
|
||||
const send = (method, params = {}, sessionId) => new Promise((ok, no) => {
|
||||
const id = ++seq;
|
||||
pending.set(id, { ok, no });
|
||||
ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
|
||||
});
|
||||
|
||||
const { targetId } = await send("Target.createTarget", { url: "about:blank" });
|
||||
const { sessionId } = await send("Target.attachToTarget", { targetId, flatten: true });
|
||||
const cdp = (method, params) => send(method, params, sessionId);
|
||||
await cdp("Page.enable");
|
||||
await cdp("Runtime.enable");
|
||||
await cdp("Log.enable");
|
||||
|
||||
let problems = [];
|
||||
listeners.push((m) => {
|
||||
if (m.sessionId !== sessionId) return;
|
||||
if (m.method === "Runtime.exceptionThrown") problems.push(`脚本错误:${m.params.exceptionDetails?.exception?.description?.split("\n")[0] || m.params.exceptionDetails?.text}`);
|
||||
if (m.method === "Runtime.consoleAPICalled" && m.params.type === "error") problems.push(`控制台错误:${m.params.args.map((a) => a.value ?? a.description ?? "").join(" ").slice(0, 200)}`);
|
||||
if (m.method === "Log.entryAdded" && m.params.entry.level === "error") problems.push(`加载错误:${m.params.entry.text.slice(0, 200)} ${m.params.entry.url || ""}`.trim());
|
||||
});
|
||||
|
||||
const evaluate = async (expression) => {
|
||||
const r = await cdp("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true });
|
||||
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
|
||||
return r.result.value;
|
||||
};
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function setViewport(w, h, scale) {
|
||||
await cdp("Emulation.setDeviceMetricsOverride", { width: w, height: h, deviceScaleFactor: scale, mobile: w < 600 });
|
||||
await cdp("Emulation.setTouchEmulationEnabled", { enabled: w < 600 });
|
||||
}
|
||||
|
||||
async function open(url) {
|
||||
const loaded = new Promise((ok) => {
|
||||
const fn = (m) => { if (m.sessionId === sessionId && m.method === "Page.loadEventFired") { listeners.splice(listeners.indexOf(fn), 1); ok(); } };
|
||||
listeners.push(fn);
|
||||
});
|
||||
const nav = await cdp("Page.navigate", { url });
|
||||
if (nav.errorText) throw new Error(`打不开 ${url}:${nav.errorText}`);
|
||||
await Promise.race([loaded, sleep(15000)]);
|
||||
await evaluate(`document.fonts ? document.fonts.ready.then(() => true) : true`);
|
||||
await sleep(Number(opt.wait));
|
||||
}
|
||||
|
||||
async function check() {
|
||||
const found = await evaluate(`(() => {
|
||||
const out = [];
|
||||
const doc = document.documentElement;
|
||||
if (doc.scrollWidth > innerWidth + 1) out.push("横向溢出:页面宽 " + doc.scrollWidth + "px,视口 " + innerWidth + "px");
|
||||
for (const img of document.images) if (img.complete && img.naturalWidth === 0) out.push("图片没加载出来:" + (img.getAttribute("src") || "").slice(0, 120));
|
||||
return out;
|
||||
})()`);
|
||||
return [...problems, ...found];
|
||||
}
|
||||
|
||||
async function screenshot(file, full) {
|
||||
let clip;
|
||||
if (full) {
|
||||
const { contentSize } = await cdp("Page.getLayoutMetrics");
|
||||
clip = { x: 0, y: 0, width: Math.ceil(contentSize.width), height: Math.ceil(contentSize.height), scale: 1 };
|
||||
}
|
||||
const { data } = await cdp("Page.captureScreenshot", { format: "png", captureBeyondViewport: !!full, ...(clip ? { clip } : {}) });
|
||||
writeFileSync(file, Buffer.from(data, "base64"));
|
||||
return file;
|
||||
}
|
||||
|
||||
// Keep color intact: SVG icons and CSS decorations may use currentColor.
|
||||
const MASK_CSS = `*,*::before,*::after{text-shadow:none!important;-webkit-text-fill-color:transparent!important;caret-color:transparent!important}
|
||||
::placeholder{color:transparent!important}svg text,svg tspan{fill:transparent!important;stroke:transparent!important}`;
|
||||
const mask = () => evaluate(`(() => { const s = document.createElement("style"); s.id = "oil-mask"; s.textContent = ${JSON.stringify(MASK_CSS)}; document.head.append(s); return true; })()`);
|
||||
|
||||
// ---------- 动作 ----------
|
||||
function tokenize(text) {
|
||||
return [...text.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)].map((m) => m[1] ?? m[2] ?? m[3]);
|
||||
}
|
||||
const KEYS = { ArrowUp: 38, ArrowDown: 40, ArrowLeft: 37, ArrowRight: 39, Enter: 13, Escape: 27, Tab: 9, " ": 32, Space: 32, Home: 36, End: 35, PageUp: 33, PageDown: 34, Backspace: 8 };
|
||||
async function center(selector) {
|
||||
const box = await evaluate(`(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return null; el.scrollIntoView({ block: "center", inline: "center" }); const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; })()`);
|
||||
if (!box) throw new Error(`找不到元素:${selector}`);
|
||||
return box;
|
||||
}
|
||||
const mouse = (type, x, y, extra = {}) => cdp("Input.dispatchMouseEvent", { type, x, y, button: "left", pointerType: "mouse", ...extra });
|
||||
async function runSteps(text) {
|
||||
for (const raw of (text || "").split(";").map((s) => s.trim()).filter(Boolean)) {
|
||||
const [verb, ...rest] = tokenize(raw);
|
||||
if (verb === "wait") await sleep(Number(rest[0]) || 0);
|
||||
else if (verb === "click") { const p = await center(rest[0]); await mouse("mouseMoved", p.x, p.y); await mouse("mousePressed", p.x, p.y, { clickCount: 1 }); await mouse("mouseReleased", p.x, p.y, { clickCount: 1 }); await sleep(120); }
|
||||
else if (verb === "hover") { const p = await center(rest[0]); await mouse("mouseMoved", p.x, p.y); await sleep(200); }
|
||||
else if (verb === "drag") {
|
||||
const p = await center(rest[0]); const dx = Number(rest[1]) || 0; const dy = Number(rest[2]) || 0;
|
||||
await mouse("mouseMoved", p.x, p.y); await mouse("mousePressed", p.x, p.y, { clickCount: 1, buttons: 1 });
|
||||
for (let i = 1; i <= 24; i++) { await mouse("mouseMoved", p.x + (dx * i) / 24, p.y + (dy * i) / 24, { buttons: 1 }); await sleep(16); }
|
||||
await mouse("mouseReleased", p.x + dx, p.y + dy, { clickCount: 1 }); await sleep(150);
|
||||
} else if (verb === "type") {
|
||||
await evaluate(`(() => { const el = document.querySelector(${JSON.stringify(rest[0])}); if (!el) throw new Error("找不到元素:${rest[0]}"); el.focus(); return true; })()`);
|
||||
await cdp("Input.insertText", { text: rest.slice(1).join(" ") }); await sleep(120);
|
||||
} else if (verb === "key") {
|
||||
const key = rest[0] === "Space" ? " " : rest[0]; const code = KEYS[rest[0]] ?? key.toUpperCase().charCodeAt(0);
|
||||
await cdp("Input.dispatchKeyEvent", { type: "keyDown", key, code: rest[0], windowsVirtualKeyCode: code });
|
||||
await cdp("Input.dispatchKeyEvent", { type: "keyUp", key, code: rest[0], windowsVirtualKeyCode: code }); await sleep(80);
|
||||
} else if (verb === "scroll") { await evaluate(`scrollBy(0, ${Number(rest[0]) || 0}), true`); await sleep(200); }
|
||||
else throw new Error(`不认识的动作:${raw}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 并排图:用同一个浏览器把截图排成一张 ----------
|
||||
async function sheet(items, file, w, h) {
|
||||
const cell = Math.min(w, 420);
|
||||
const figures = items.map(({ path, label }) =>
|
||||
`<figure><img src="data:image/png;base64,${readFileSync(path).toString("base64")}"><figcaption>${label}</figcaption></figure>`).join("");
|
||||
const html = `<!doctype html><meta charset="utf-8"><style>body{margin:0;padding:32px;background:#ececea;font:13px -apple-system,"PingFang SC",sans-serif;color:#555}
|
||||
main{display:flex;gap:24px;align-items:flex-start}figure{margin:0;width:${cell}px}img{width:100%;display:block;border-radius:12px;box-shadow:0 1px 3px #0002}
|
||||
figcaption{margin-top:10px}</style><main>${figures}</main>`;
|
||||
const tmp = join(profile, "sheet.html");
|
||||
writeFileSync(tmp, html);
|
||||
const width = items.length * cell + (items.length - 1) * 24 + 64;
|
||||
await setViewport(width, Math.round((cell * h) / w) + 120, 1);
|
||||
await open(`file://${tmp}`);
|
||||
return screenshot(file, true);
|
||||
}
|
||||
|
||||
// ---------- 录屏 ----------
|
||||
async function record(url, w, h) {
|
||||
const frames = [];
|
||||
const dir = join(out, "frames");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const onFrame = (m) => {
|
||||
if (m.sessionId !== sessionId || m.method !== "Page.screencastFrame") return;
|
||||
const name = join(dir, `f${String(frames.length).padStart(4, "0")}.jpg`);
|
||||
writeFileSync(name, Buffer.from(m.params.data, "base64"));
|
||||
frames.push({ name, t: m.params.metadata.timestamp });
|
||||
cdp("Page.screencastFrameAck", { sessionId: m.params.sessionId }).catch(() => {});
|
||||
};
|
||||
await setViewport(w, h, zoom);
|
||||
await open(url);
|
||||
listeners.push(onFrame);
|
||||
await cdp("Page.startScreencast", { format: "jpeg", quality: 88, everyNthFrame: 1 });
|
||||
await sleep(500);
|
||||
await runSteps(opt.steps);
|
||||
await sleep(Number(opt.hold));
|
||||
const finished = Date.now() / 1000;
|
||||
const issues = await check();
|
||||
await cdp("Page.stopScreencast");
|
||||
listeners.splice(listeners.indexOf(onFrame), 1);
|
||||
if (!frames.length) throw new Error("录屏没有拿到画面");
|
||||
const pick = { start: frames[0], mid: frames[Math.floor(frames.length / 2)], end: frames[frames.length - 1] };
|
||||
for (const [k, f] of Object.entries(pick)) writeFileSync(join(out, `motion-${k}.jpg`), readFileSync(f.name));
|
||||
const ffmpeg = spawnSync("ffmpeg", ["-version"]).status === 0;
|
||||
if (!ffmpeg) return { issues, message: `录屏:没装 ffmpeg,只留了 ${frames.length} 帧和 motion-start/mid/end.jpg` };
|
||||
// Generated basenames are safe for concat's quoting, even when --out contains an apostrophe.
|
||||
// Keep the final still frame through the end of --hold; screencasts only emit changed frames.
|
||||
const list = frames.map((f, i) => `file '${basename(f.name)}'\nduration ${Math.max(0.016, ((frames[i + 1]?.t ?? finished) - f.t)).toFixed(3)}`).join("\n") + `\nfile '${basename(frames.at(-1).name)}'\n`;
|
||||
writeFileSync(join(dir, "list.txt"), list);
|
||||
const r = spawnSync("ffmpeg", ["-y", "-v", "error", "-f", "concat", "-safe", "0", "-i", join(dir, "list.txt"),
|
||||
"-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2,fps=30", "-pix_fmt", "yuv420p", join(out, "record.mp4")], { encoding: "utf8" });
|
||||
if (r.status !== 0) return { issues, message: `录屏:ffmpeg 合成失败(${r.stderr.trim().split("\n").pop()}),帧留在 frames/` };
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
return { issues, message: `录屏:record.mp4(${(finished - frames[0].t).toFixed(1)} 秒)和 motion-start/mid/end.jpg` };
|
||||
}
|
||||
|
||||
// ---------- 主流程 ----------
|
||||
const base = await resolveTarget(target);
|
||||
const withState = (s) => {
|
||||
if (!s) return base;
|
||||
const u = new URL(base);
|
||||
u.searchParams.set(opt.param, s);
|
||||
return u.toString();
|
||||
};
|
||||
const report = [];
|
||||
const lines = [];
|
||||
try {
|
||||
if (flags.has("record")) {
|
||||
const result = await record(withState(states[0]), sizes[0].w, sizes[0].h);
|
||||
lines.push(result.message);
|
||||
report.push({ file: "motion-end.jpg", state: states[0], size: `${sizes[0].w}x${sizes[0].h}`, zoom, issues: result.issues });
|
||||
} else {
|
||||
for (const { w, h } of sizes) {
|
||||
const shots = [], masked = [];
|
||||
for (const s of states) {
|
||||
problems = [];
|
||||
await setViewport(w, h, zoom);
|
||||
await open(withState(s));
|
||||
if (opt.steps) await runSteps(opt.steps);
|
||||
const name = [s || "page", sizes.length > 1 ? `${w}x${h}` : "", zoom !== 1 ? `@${zoom}x` : ""].filter(Boolean).join("-");
|
||||
const file = await screenshot(join(out, `${name}.png`), flags.has("full"));
|
||||
const issues = await check();
|
||||
report.push({ file: basename(file), state: s, size: `${w}x${h}`, zoom, issues });
|
||||
shots.push({ path: file, label: s || "page" });
|
||||
if (flags.has("mask")) {
|
||||
await mask();
|
||||
await sleep(60);
|
||||
masked.push({ path: await screenshot(join(out, `${name}-masked.png`), flags.has("full")), label: s || "page" });
|
||||
}
|
||||
lines.push(`${basename(file)}${issues.length ? " ⚠ " + issues.join(";") : ""}`);
|
||||
}
|
||||
if (flags.has("sheet") && shots.length > 1) {
|
||||
const suffix = sizes.length > 1 ? `-${w}x${h}` : "";
|
||||
lines.push(basename(await sheet(shots, join(out, `sheet${suffix}.png`), w, h)));
|
||||
if (masked.length) lines.push(basename(await sheet(masked, join(out, `sheet${suffix}-masked.png`), w, h)));
|
||||
}
|
||||
}
|
||||
}
|
||||
writeFileSync(join(out, "report.json"), JSON.stringify(report, null, 2));
|
||||
} catch (error) {
|
||||
console.error(`shoot:${error.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
console.log(`输出目录:${out}`);
|
||||
for (const l of lines) console.log(`- ${l}`);
|
||||
const total = report.reduce((n, r) => n + r.issues.length, 0);
|
||||
if (report.length) console.log(total ? `发现 ${total} 个问题,详见 report.json` : "检查通过:没有控制台错误、横向溢出或加载失败的图片");
|
||||
ws.close();
|
||||
await cleanup();
|
||||
process.exit(process.exitCode || 0);
|
||||
Reference in new issue
Block a user