@remixmate/cli 0.9.42 → 0.9.44
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/manifest.json +4 -4
- package/package.json +2 -1
- package/skills/gen-script/scripts/gen_script.py +21 -2
- package/skills/gen-script/version.json +1 -1
- package/skills/render-video/scripts/render_video.py +38 -4
- package/skills/render-video/version.json +1 -1
- package/skills/template-registry/scripts/registry_loader.py +52 -0
- package/skills/template-registry/version.json +1 -1
- package/skills/template-registry/video_dsl/runtime/dsl_validator.py +41 -0
- package/skills/web-read/SKILL.md +6 -0
- package/skills/web-read/skill.json +2 -2
- package/skills/web-screenshot/scripts/_media_screenshot/js/extract_article.js +44 -7
- package/skills/web-screenshot/scripts/_media_screenshot/reader.py +6 -0
- package/skills/web-screenshot/scripts/read_page.py +5 -2
- package/skills/web-screenshot/version.json +1 -1
package/dist/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"version": "0.9.
|
|
4
|
-
"generatedAt": "2026-09-
|
|
3
|
+
"version": "0.9.44",
|
|
4
|
+
"generatedAt": "2026-09-22T14:28:26.210Z",
|
|
5
5
|
"skills": [
|
|
6
6
|
{
|
|
7
7
|
"id": "doc-parse",
|
|
@@ -1043,7 +1043,7 @@
|
|
|
1043
1043
|
"tier": "tool",
|
|
1044
1044
|
"category": "consuming",
|
|
1045
1045
|
"title": "Web Page Reader",
|
|
1046
|
-
"summary": "Open any URL in a headless browser (Playwright Python) and return the page's MAIN TEXT — title, headings, paragraphs, lists, code blocks and tables — as Markdown, plain text, or structured JSON. Boilerplate (nav / sidebar / comments / ads / footer) is stripped by a Readability-style pass, and JS-rendered pages work because a real browser runs the page. This is the tool to use whenever you need to KNOW WHAT A PAGE SAYS: summarizing an article, pulling source material for a script, reading a README or docs page, checking what a link contains. It returns text, not pictures — for a screenshot (png/jpg) use web_screenshot, for a recording (mp4/webm) use web_record. Output is capped by max_chars (default 20000) and truncated on a block boundary; pass an `output` path to keep the full text on disk.",
|
|
1046
|
+
"summary": "Open any URL in a headless browser (Playwright Python) and return the page's MAIN TEXT — title, headings, paragraphs, lists, code blocks and tables — as Markdown, plain text, or structured JSON. Boilerplate (nav / sidebar / comments / ads / footer) is stripped by a Readability-style pass, and JS-rendered pages work because a real browser runs the page. This is the tool to use whenever you need to KNOW WHAT A PAGE SAYS: summarizing an article, pulling source material for a script, reading a README or docs page, checking what a link contains. It returns text, not pictures — for a screenshot (png/jpg) use web_screenshot, for a recording (mp4/webm) use web_record. GitHub repository pages also return observed Stars/Forks/Watchers before sidebar cleanup (missing counters stay unknown). Reuse successful reads across script and template fields; do not repeat the same URL just for counters already returned. Output is capped by max_chars (default 20000) and truncated on a block boundary; pass an `output` path to keep the full text on disk.",
|
|
1047
1047
|
"triggers": [
|
|
1048
1048
|
"Read this link / what does this page say / summarize this article",
|
|
1049
1049
|
"Fetch page content, extract article text, get the text of a URL",
|
|
@@ -1083,7 +1083,7 @@
|
|
|
1083
1083
|
},
|
|
1084
1084
|
"selector": {
|
|
1085
1085
|
"type": "string",
|
|
1086
|
-
"description": "Read only inside this CSS selector. Leave empty to auto-detect the article container
|
|
1086
|
+
"description": "Read only inside this CSS selector, preserving the selected region's sidebar/button text instead of pruning boilerplate. Scripts/styles are still removed. Leave empty to auto-detect the article container; use a targeted selector only for a concrete missing required fact."
|
|
1087
1087
|
},
|
|
1088
1088
|
"include_links": {
|
|
1089
1089
|
"type": "boolean",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remixmate/cli",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.44",
|
|
4
4
|
"description": "AI media generation skills for Claude Code / Codex — 12 skills covering image, video, voice, digital human, web screenshot, web recording, script, template registry, rendering, Jianying export, and video deconstruction.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"test:cli": "npm run build && node --test test/*.test.mjs",
|
|
23
23
|
"test:validators": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-validators.py",
|
|
24
24
|
"test:web-read": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-web-read.py",
|
|
25
|
+
"test:web-read:browser": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-web-read-browser.py",
|
|
25
26
|
"test:template-pipeline": "PYTHONDONTWRITEBYTECODE=1 node dist/cli.js exec -- python3 scripts/test-template-pipeline.py",
|
|
26
27
|
"test:props-contract": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-props-contract.py",
|
|
27
28
|
"test:card-array-keys": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-card-array-keys.py",
|
|
@@ -68,12 +68,16 @@ except ImportError:
|
|
|
68
68
|
try:
|
|
69
69
|
from registry_loader import (
|
|
70
70
|
load_registry_data as _load_registry_data,
|
|
71
|
-
|
|
71
|
+
require_visible_template as _get_template,
|
|
72
|
+
TemplateStatusGatedError,
|
|
72
73
|
)
|
|
73
74
|
_HAS_REGISTRY_LOADER = True
|
|
74
75
|
except ImportError:
|
|
75
76
|
_HAS_REGISTRY_LOADER = False
|
|
76
77
|
|
|
78
|
+
class TemplateStatusGatedError(RuntimeError): # type: ignore[no-redef]
|
|
79
|
+
"""占位:共享模块不可导入时(单测 / 裁剪安装)永远不会被抛出。"""
|
|
80
|
+
|
|
77
81
|
VALID_RATIOS = ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"]
|
|
78
82
|
VALID_PURPOSES = ["opening", "point", "example", "explanation", "highlight", "cta", "ending"]
|
|
79
83
|
|
|
@@ -378,11 +382,19 @@ def _load_template_config(template_id: str) -> dict | None:
|
|
|
378
382
|
(仅在仓库还残留旧目录时使用)。
|
|
379
383
|
返回完整 template dict(含 supportedAspectRatios / assetRequirements / slotMapping 等),
|
|
380
384
|
找不到时返回 None。
|
|
385
|
+
|
|
386
|
+
⚠️ 一个例外**不走**兜底:模板存在、但被状态门控挡在可见集合外(``beta`` 而本进程
|
|
387
|
+
没开 ENABLE_BETA_TEMPLATES)时 ``TemplateStatusGatedError`` 原样上抛。下面那些
|
|
388
|
+
``_template_*`` / ``_resolve_contract`` 读到 None 会一路退回通用默认值 —— arc 三段式、
|
|
389
|
+
默认 layout、空 customPayload,而且 llmHint 根本不会打给模型。对「id 不存在」这是
|
|
390
|
+
合理容错,对「模板在线但本进程看不见」则是把配置问题伪装成一条能跑通的废片。
|
|
381
391
|
"""
|
|
382
392
|
# 优先使用 registry_loader(这是模板元数据的唯一权威来源)
|
|
383
393
|
if _HAS_REGISTRY_LOADER:
|
|
384
394
|
try:
|
|
385
395
|
return _get_template(template_id)
|
|
396
|
+
except TemplateStatusGatedError:
|
|
397
|
+
raise
|
|
386
398
|
except Exception as exc:
|
|
387
399
|
print(
|
|
388
400
|
f"⚠️ registry_loader failed, falling back to local template.json: {exc}",
|
|
@@ -1883,4 +1895,11 @@ Examples:
|
|
|
1883
1895
|
|
|
1884
1896
|
|
|
1885
1897
|
if __name__ == "__main__":
|
|
1886
|
-
|
|
1898
|
+
try:
|
|
1899
|
+
main()
|
|
1900
|
+
except TemplateStatusGatedError as exc:
|
|
1901
|
+
# 状态门控挡住了一个确实在线的模板。这是配置问题,不是调用方参数错误,
|
|
1902
|
+
# 所以给一条可执行的提示而不是 traceback —— 并且**必须**非零退出:静默
|
|
1903
|
+
# 退回通用骨架正是这次要消灭的失败形态。
|
|
1904
|
+
print(f"❌ {exc}", file=sys.stderr)
|
|
1905
|
+
sys.exit(1)
|
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
"skillName": "gen-script",
|
|
3
3
|
"repoName": "agent-skill-media-maker",
|
|
4
4
|
"skillId": "474",
|
|
5
|
-
"version": "
|
|
5
|
+
"version": "V15",
|
|
6
6
|
"skillDescription": "Video-script generation skill. Turns a user-supplied topic into a structured Video DSL (JSON) that describes the full video — scene structure, asset requirements, and narrative flow.\n\nUse this skill as soon as the user mentions any of these intents:\n- Write a video script, generate a video script, plan a video, write storyboards\n- Create a short video, plan video content, generate a Video DSL\n- Turn a topic into a video structure / video content plan\n\nEven when the user does not say \"generate the DSL\", use this skill whenever they want to turn a topic into a structured video plan.\n\n⚠️ Stop-and-confirm gate: after this skill returns a DSL, show the full script and wait for the user's explicit confirmation. Never call `prepare_video_assets` in the same turn."
|
|
7
7
|
}
|
|
@@ -331,11 +331,18 @@ def resolve_remotion_entry(template_id: str, aspect_ratio: str) -> str:
|
|
|
331
331
|
|
|
332
332
|
Falls back to ``MainVideo16x9`` / ``MainVideo`` when the template isn't
|
|
333
333
|
found, the registry can't be loaded, or the aspect ratio isn't keyed.
|
|
334
|
+
|
|
335
|
+
``include_all_statuses=True`` —— 状态门控是**选型策略**,不是渲染策略。
|
|
336
|
+
它要回答的是"列表里该不该出现这个模板、agent 该不该挑它",一旦模板已经被选定并
|
|
337
|
+
通过校验,渲染就必须读到它真实的定义。把门控带到这里的后果是静默换成另一个
|
|
338
|
+
composition / 丢掉封面,成片与模板声明不符,而日志里只字未提。
|
|
339
|
+
「能不能被选中」由 template-registry 的 ``--include-beta`` 与
|
|
340
|
+
``dsl_validator._check_template_status_gate`` 负责把关。
|
|
334
341
|
"""
|
|
335
342
|
if template_id:
|
|
336
343
|
try:
|
|
337
344
|
from registry_loader import get_template # type: ignore
|
|
338
|
-
tpl = get_template(template_id)
|
|
345
|
+
tpl = get_template(template_id, include_all_statuses=True)
|
|
339
346
|
if tpl:
|
|
340
347
|
entry_map = tpl.get("remotionEntry") or {}
|
|
341
348
|
hit = entry_map.get(aspect_ratio)
|
|
@@ -356,6 +363,9 @@ def resolve_cover_composition_id(template_id: str) -> Optional[str]:
|
|
|
356
363
|
Reading from the registry rather than the template.json file directly so
|
|
357
364
|
behaviour matches what ab-render's manifest exposes (the registry is the
|
|
358
365
|
aggregated truth used by both ab-render and ab-skill).
|
|
366
|
+
|
|
367
|
+
``include_all_statuses=True`` 的理由同 ``resolve_remotion_entry``:门控管选型,
|
|
368
|
+
不管渲染。带上门控的话 beta 模板的封面会无声无息地不渲。
|
|
359
369
|
"""
|
|
360
370
|
if not template_id:
|
|
361
371
|
return None
|
|
@@ -363,7 +373,7 @@ def resolve_cover_composition_id(template_id: str) -> Optional[str]:
|
|
|
363
373
|
# sys.path setup happens once at module top — see header.
|
|
364
374
|
from registry_loader import get_template # type: ignore
|
|
365
375
|
|
|
366
|
-
tpl = get_template(template_id)
|
|
376
|
+
tpl = get_template(template_id, include_all_statuses=True)
|
|
367
377
|
if not tpl:
|
|
368
378
|
return None
|
|
369
379
|
for comp in tpl.get("compositions", []) or []:
|
|
@@ -554,7 +564,8 @@ def validate_and_fix_render_plan(render_plan: dict) -> list:
|
|
|
554
564
|
if template_id:
|
|
555
565
|
try:
|
|
556
566
|
from registry_loader import get_template # type: ignore
|
|
557
|
-
|
|
567
|
+
# include_all_statuses:门控管选型不管渲染(理由见 resolve_remotion_entry)。
|
|
568
|
+
_tpl = get_template(template_id, include_all_statuses=True)
|
|
558
569
|
needs_video_duration = bool(
|
|
559
570
|
((_tpl or {}).get("capabilities") or {}).get("needsVideoDuration")
|
|
560
571
|
)
|
|
@@ -1790,7 +1801,10 @@ def render_with_remote_api(
|
|
|
1790
1801
|
if template_id:
|
|
1791
1802
|
try:
|
|
1792
1803
|
from registry_loader import get_template # type: ignore
|
|
1793
|
-
|
|
1804
|
+
# include_all_statuses:门控管选型不管渲染(理由见 resolve_remotion_entry)。
|
|
1805
|
+
# 漏掉它的话私有 beta 模板会丢 sourceOssKey,转而按内置模板渲——渲出来的
|
|
1806
|
+
# 是另一个模板的画面。
|
|
1807
|
+
_meta = get_template(template_id, include_all_statuses=True)
|
|
1794
1808
|
if _meta and not _meta.get("isBuiltin"):
|
|
1795
1809
|
source_oss_key = _meta.get("sourceOssKey")
|
|
1796
1810
|
except Exception:
|
|
@@ -2086,6 +2100,26 @@ def auto_bind_template(dsl: dict, template_id: str) -> dict:
|
|
|
2086
2100
|
LogPrint(f"❌ template not found: {template_id} (available: {available})", file=sys.stderr)
|
|
2087
2101
|
sys.exit(1)
|
|
2088
2102
|
|
|
2103
|
+
# 选型门控在这里落地,而不是在上面的查表上:查表刻意用未门控的列表,因为绑定
|
|
2104
|
+
# 必须读到模板真实的定义(slotMapping / compositions)。门控管的是"允许不允许
|
|
2105
|
+
# 选中它",而 auto_bind 正是"一个 id 变成本次成片所用模板"的那一刻。
|
|
2106
|
+
#
|
|
2107
|
+
# dsl_validator 已经在更早的位置报同一件事,但它只看 meta.templateId;手写的
|
|
2108
|
+
# DSL 可能没有那个字段,只靠 --template-id 传进来 —— 这里补上那条路。
|
|
2109
|
+
try:
|
|
2110
|
+
from registry_loader import gated_status # type: ignore
|
|
2111
|
+
_gated = gated_status(template_id)
|
|
2112
|
+
except Exception:
|
|
2113
|
+
_gated = None
|
|
2114
|
+
if _gated:
|
|
2115
|
+
LogPrint(
|
|
2116
|
+
f"❌ template '{template_id}' exists but its status is '{_gated}', so this "
|
|
2117
|
+
f"process is not allowed to select it. Set ENABLE_BETA_TEMPLATES=1 to admit "
|
|
2118
|
+
f"beta templates, or promote the template to stable.",
|
|
2119
|
+
file=sys.stderr,
|
|
2120
|
+
)
|
|
2121
|
+
sys.exit(1)
|
|
2122
|
+
|
|
2089
2123
|
selected = match_template.load_full_template(selected)
|
|
2090
2124
|
LogPrint(f"📋 auto-bound template: {selected.get('name', '')} ({template_id})", file=sys.stderr)
|
|
2091
2125
|
binding = match_template.build_binding(selected, dsl)
|
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
"skillName": "render-video",
|
|
3
3
|
"repoName": "agent-skill-media-maker",
|
|
4
4
|
"skillId": "473",
|
|
5
|
-
"version": "
|
|
5
|
+
"version": "V25",
|
|
6
6
|
"skillDescription": "Final-render skill: loads a persisted RenderPlan by `job_id` and drives the Remotion engine to produce the final video.\n\nUse this skill as soon as the user mentions any of these intents (after assets are already prepared):\n- Render the video, composite the video, export the video\n- Turn the prepared assets into the final clip\n- Render with Remotion\n\nPrerequisite: assets must already be generated via `prepare_video_assets`. This skill never resolves or regenerates assets — pass it a `job_id` from a previous `prepare_video_assets` call.\n\n⚠️ Stop-and-confirm gate: never call this skill until the user has explicitly confirmed the assets prepared by `prepare_video_assets`. If those assets were prepared in the current turn and the user has not replied since, stop and ask instead of rendering."
|
|
7
7
|
}
|
|
@@ -53,10 +53,13 @@ __all__ = [
|
|
|
53
53
|
"load_registry_data",
|
|
54
54
|
"load_registry_indexed",
|
|
55
55
|
"get_template",
|
|
56
|
+
"require_visible_template",
|
|
57
|
+
"gated_status",
|
|
56
58
|
"list_templates",
|
|
57
59
|
"invalidate_cache",
|
|
58
60
|
"RegistryAuthError",
|
|
59
61
|
"RegistryUnreachableError",
|
|
62
|
+
"TemplateStatusGatedError",
|
|
60
63
|
"EXIT_NOT_AUTHENTICATED",
|
|
61
64
|
"EXIT_BACKEND_UNREACHABLE",
|
|
62
65
|
]
|
|
@@ -78,6 +81,15 @@ class RegistryAuthError(RuntimeError):
|
|
|
78
81
|
class RegistryUnreachableError(RuntimeError):
|
|
79
82
|
"""网络不通 / 超时 / 5xx —— 用户无需重新登录,重试或修配置即可。"""
|
|
80
83
|
|
|
84
|
+
|
|
85
|
+
class TemplateStatusGatedError(RuntimeError):
|
|
86
|
+
"""模板存在,但它的 ``status`` 不在当前进程的可见集合里(见 ``_allowed_statuses``)。
|
|
87
|
+
|
|
88
|
+
与「id 不存在」分开的独立失败模式:前者要改 id,后者要开门控开关
|
|
89
|
+
(``ENABLE_BETA_TEMPLATES=1``)或把模板转正。压成同一个 ``None`` 返回值的后果见
|
|
90
|
+
``require_visible_template`` 的文档。
|
|
91
|
+
"""
|
|
92
|
+
|
|
81
93
|
# 默认缓存 TTL(秒)。HTTP 拉取后该时间内不再重新请求。
|
|
82
94
|
_DEFAULT_TTL_SECONDS = 300
|
|
83
95
|
|
|
@@ -386,6 +398,46 @@ def get_template(template_id: str, *, include_all_statuses: bool = False) -> Opt
|
|
|
386
398
|
return load_registry_indexed(include_all_statuses=include_all_statuses).get(template_id)
|
|
387
399
|
|
|
388
400
|
|
|
401
|
+
def gated_status(template_id: str) -> Optional[str]:
|
|
402
|
+
"""模板存在、但被状态门控挡在可见集合外时返回它的真实 status;否则 None。
|
|
403
|
+
|
|
404
|
+
``get_template`` 用 ``None`` 同时表达「registry 里没有这个 id」和「有,但被
|
|
405
|
+
门控挡了」两件完全不同的事。前者是调用方拼错了 id,后者是运行时配置问题
|
|
406
|
+
(模板确实在线,只是当前进程没开 beta)—— 两者的处置方式不同,却在一个返回
|
|
407
|
+
值里混成一样。这个函数把后者单独识别出来。
|
|
408
|
+
"""
|
|
409
|
+
if not template_id:
|
|
410
|
+
return None
|
|
411
|
+
if get_template(template_id) is not None:
|
|
412
|
+
return None
|
|
413
|
+
tpl = get_template(template_id, include_all_statuses=True)
|
|
414
|
+
return _resolve_status(tpl) if tpl is not None else None
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def require_visible_template(template_id: str) -> Optional[dict]:
|
|
418
|
+
"""与 ``get_template`` 相同,但模板**被状态门控挡住**时抛异常而不是返回 None。
|
|
419
|
+
|
|
420
|
+
为什么需要它:``get_template`` 返回 None 之后,调用方普遍的写法是「拿不到就用
|
|
421
|
+
默认值继续」—— 对"这个 id 不存在"是合理的容错,对"模板在线但本进程看不到"却是
|
|
422
|
+
灾难性的静默降级:模板的 llmHint / customPayloadSchema / capabilities 全部蒸发,
|
|
423
|
+
脚本按通用骨架生成、payload 契约整段跳过,最后烧掉渲染积分换一条废片,而日志里
|
|
424
|
+
一个字都没有。
|
|
425
|
+
|
|
426
|
+
返回 None 只剩一个含义:registry 里没有这个 templateId。
|
|
427
|
+
"""
|
|
428
|
+
tpl = get_template(template_id)
|
|
429
|
+
if tpl is not None:
|
|
430
|
+
return tpl
|
|
431
|
+
status = gated_status(template_id)
|
|
432
|
+
if status is None:
|
|
433
|
+
return None
|
|
434
|
+
raise TemplateStatusGatedError(
|
|
435
|
+
f"模板 '{template_id}' 存在,但状态是 '{status}',当前进程的可见集合只有 "
|
|
436
|
+
f"{sorted(_allowed_statuses())}。设置 {_BETA_GATE_ENV}=1 放行 beta,"
|
|
437
|
+
f"或把该模板转为 stable —— 继续往下走只会拿不到它的契约并产出通用结果。"
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
|
|
389
441
|
def list_templates(*, include_all_statuses: bool = False) -> list[dict]:
|
|
390
442
|
"""Return the visible templates as a list (status-filtered)."""
|
|
391
443
|
return list(load_registry_indexed(include_all_statuses=include_all_statuses).values())
|
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
"skillName": "template-registry",
|
|
3
3
|
"repoName": "agent-skill-media-maker",
|
|
4
4
|
"skillId": "475",
|
|
5
|
-
"version": "
|
|
5
|
+
"version": "V18",
|
|
6
6
|
"skillDescription": "Video-template registry skill. Stores every video-template definition and lists the available templates (templateId / name / aspect ratio / style tags).\n\nUse this skill as soon as the user mentions any of these intents:\n- View available templates / list every template\n\nNote: DSL→TemplateBinding is no longer a separate exposed step — once prepare_video_assets receives a template_id it builds the binding internally."
|
|
7
7
|
}
|
|
@@ -131,6 +131,10 @@ def _get_template_cfg(template_id: Optional[str]) -> Optional[dict]:
|
|
|
131
131
|
shared-lib sys.path)
|
|
132
132
|
- the registry can't be loaded (network/file/etc.)
|
|
133
133
|
- the template isn't in the visible set (e.g. beta gate hides it)
|
|
134
|
+
|
|
135
|
+
最后那一条是**静默跳过整套模板契约校验**的入口 —— payload 必填字段、版式字段名、
|
|
136
|
+
逐句对齐全部形同不存在。它由 ``_check_template_status_gate`` 单独报一条错来兜底,
|
|
137
|
+
所以这里保持返回 None 不变(否则每个 ``_check_*`` 都会为同一件事各报一遍)。
|
|
134
138
|
"""
|
|
135
139
|
if not template_id:
|
|
136
140
|
return None
|
|
@@ -144,6 +148,39 @@ def _get_template_cfg(template_id: Optional[str]) -> Optional[dict]:
|
|
|
144
148
|
return None
|
|
145
149
|
|
|
146
150
|
|
|
151
|
+
def _check_template_status_gate(dsl: dict) -> list[ValidationError]:
|
|
152
|
+
"""模板存在、却被状态门控挡在可见集合外 → 一条 error。
|
|
153
|
+
|
|
154
|
+
为什么必须报错而不是继续:下面所有读 ``_get_template_cfg`` 的检查(payload 必填、
|
|
155
|
+
slideSchemas 字段名、lineSyncSlides)拿到 None 之后都是 ``return []`` —— 于是一个
|
|
156
|
+
beta 模板的 DSL 能"零错误"通过校验,再一路烧掉 TTS 与渲染积分产出空壳画面。
|
|
157
|
+
contracts 测试之所以没发现这一点,是因为它们 patch 掉了 ``_get_template_cfg``,
|
|
158
|
+
生产里的门控在测试里从不生效。
|
|
159
|
+
"""
|
|
160
|
+
template_id = _pick_template_id(dsl)
|
|
161
|
+
if not template_id:
|
|
162
|
+
return []
|
|
163
|
+
try:
|
|
164
|
+
from registry_loader import gated_status # type: ignore
|
|
165
|
+
except ImportError:
|
|
166
|
+
return []
|
|
167
|
+
try:
|
|
168
|
+
status = gated_status(template_id)
|
|
169
|
+
except Exception:
|
|
170
|
+
# registry 不可达等问题由各自的加载路径报,这里不重复成第二条噪声。
|
|
171
|
+
return []
|
|
172
|
+
if not status:
|
|
173
|
+
return []
|
|
174
|
+
return [ValidationError(
|
|
175
|
+
"meta.templateId",
|
|
176
|
+
f"template '{template_id}' exists but its status is '{status}', so it is hidden "
|
|
177
|
+
f"from this process — every template-specific contract check (payload required "
|
|
178
|
+
f"fields, slide schemas, line-sync) would be skipped instead of enforced. "
|
|
179
|
+
f"Set ENABLE_BETA_TEMPLATES=1 to admit beta templates, or promote the template "
|
|
180
|
+
f"to stable.",
|
|
181
|
+
)]
|
|
182
|
+
|
|
183
|
+
|
|
147
184
|
def _contains_cjk(text: str) -> bool:
|
|
148
185
|
return bool(text) and bool(_CJK_PATTERN.search(text))
|
|
149
186
|
|
|
@@ -1010,6 +1047,9 @@ def validate_integrity(dsl: dict) -> list[ValidationError]:
|
|
|
1010
1047
|
# render-video 与 prepare_video_assets 都走这条 —— 而"没选版式"正是要在烧掉渲染
|
|
1011
1048
|
# 积分之前拦住的东西。单版式模板不受影响,见 _check_slide_id_chosen。
|
|
1012
1049
|
errors += _check_slide_id_chosen(dsl)
|
|
1050
|
+
# 必须排在下面那批模板契约检查**之前**:模板被状态门控挡住时它们会集体空转,
|
|
1051
|
+
# 于是"零错误通过"其实是"一条都没查"。见 _check_template_status_gate。
|
|
1052
|
+
errors += _check_template_status_gate(dsl)
|
|
1013
1053
|
# 同理:字段名写错同样是「渲染成功但画面是空壳」,必须在烧掉 TTS / 渲染积分
|
|
1014
1054
|
# 之前拦住。只对声明了 slideSchemas 的模板生效,见 _check_slide_payload_fields。
|
|
1015
1055
|
errors += _check_slide_payload_fields(dsl)
|
|
@@ -1044,6 +1084,7 @@ def validate_dsl(dsl: dict) -> list[ValidationError]:
|
|
|
1044
1084
|
errors += _check_narration_items_text_coexist(dsl)
|
|
1045
1085
|
errors += _check_narration_language_strict(dsl)
|
|
1046
1086
|
errors += _check_slide_id_chosen(dsl)
|
|
1087
|
+
errors += _check_template_status_gate(dsl)
|
|
1047
1088
|
errors += _check_slide_payload_fields(dsl)
|
|
1048
1089
|
errors += _check_custom_payload_fields(dsl)
|
|
1049
1090
|
errors += _check_line_sync_narration(dsl)
|
package/skills/web-read/SKILL.md
CHANGED
|
@@ -50,6 +50,10 @@ python3 <ReadScript> --url "https://example.com/article/123"
|
|
|
50
50
|
|
|
51
51
|
Prints a Markdown document: an `# title` line, a `site · author · date · url` line, then the body. A one-line extraction diagnostic (`blocks / chars / container`) goes to **stderr**, so piping stdout gives you clean text.
|
|
52
52
|
|
|
53
|
+
GitHub repository pages also include Stars / Forks / Watchers found on that page, captured before sidebar cleanup. JSON exposes them as `metadata.repository`; Markdown and plain text include a statistics line. Counts retain the page's precision (e.g. `47.2k`); missing counters are unknown, not zero.
|
|
54
|
+
|
|
55
|
+
Reuse a successful read for both the script and template fields. Re-read only for a specific missing required fact, an extraction failure/truncation, or an explicit request for fresh data. Change the relevant selector/limit/wait setting for corrective reads. Do not repeatedly fetch the same page for optional popularity text: reuse returned counters or use factual non-numeric wording.
|
|
56
|
+
|
|
53
57
|
## Output formats
|
|
54
58
|
|
|
55
59
|
```bash
|
|
@@ -84,6 +88,8 @@ python3 <ReadScript> --url "https://example.com/post" --max-chars 0
|
|
|
84
88
|
|
|
85
89
|
The container is picked by paragraph-density scoring, which is right on ordinary article/docs/blog pages and can miss on unusual layouts. In order of what to try:
|
|
86
90
|
|
|
91
|
+
An explicit `--selector` opts out of boilerplate pruning inside that scope, so a sidebar, button, or counter can be read intentionally. Non-content elements such as scripts and styles are still removed.
|
|
92
|
+
|
|
87
93
|
```bash
|
|
88
94
|
# 1. Content renders late (SPA): wait for the real element
|
|
89
95
|
python3 <ReadScript> --url "https://app.example.com/doc/1" --wait-for-selector "article.body"
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"tier": "tool",
|
|
5
5
|
"category": "consuming",
|
|
6
6
|
"title": "Web Page Reader",
|
|
7
|
-
"description": "Open any URL in a headless browser (Playwright Python) and return the page's MAIN TEXT — title, headings, paragraphs, lists, code blocks and tables — as Markdown, plain text, or structured JSON. Boilerplate (nav / sidebar / comments / ads / footer) is stripped by a Readability-style pass, and JS-rendered pages work because a real browser runs the page. This is the tool to use whenever you need to KNOW WHAT A PAGE SAYS: summarizing an article, pulling source material for a script, reading a README or docs page, checking what a link contains. It returns text, not pictures — for a screenshot (png/jpg) use web_screenshot, for a recording (mp4/webm) use web_record. Output is capped by max_chars (default 20000) and truncated on a block boundary; pass an `output` path to keep the full text on disk.",
|
|
7
|
+
"description": "Open any URL in a headless browser (Playwright Python) and return the page's MAIN TEXT — title, headings, paragraphs, lists, code blocks and tables — as Markdown, plain text, or structured JSON. Boilerplate (nav / sidebar / comments / ads / footer) is stripped by a Readability-style pass, and JS-rendered pages work because a real browser runs the page. This is the tool to use whenever you need to KNOW WHAT A PAGE SAYS: summarizing an article, pulling source material for a script, reading a README or docs page, checking what a link contains. It returns text, not pictures — for a screenshot (png/jpg) use web_screenshot, for a recording (mp4/webm) use web_record. GitHub repository pages also return observed Stars/Forks/Watchers before sidebar cleanup (missing counters stay unknown). Reuse successful reads across script and template fields; do not repeat the same URL just for counters already returned. Output is capped by max_chars (default 20000) and truncated on a block boundary; pass an `output` path to keep the full text on disk.",
|
|
8
8
|
"auth": "none",
|
|
9
9
|
"envVars": [
|
|
10
10
|
"WEB_CAPTURE_BROWSER",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
},
|
|
34
34
|
"selector": {
|
|
35
35
|
"type": "string",
|
|
36
|
-
"description": "Read only inside this CSS selector. Leave empty to auto-detect the article container
|
|
36
|
+
"description": "Read only inside this CSS selector, preserving the selected region's sidebar/button text instead of pruning boilerplate. Scripts/styles are still removed. Leave empty to auto-detect the article container; use a targeted selector only for a concrete missing required fact."
|
|
37
37
|
},
|
|
38
38
|
"include_links": {
|
|
39
39
|
"type": "boolean",
|
|
@@ -48,6 +48,41 @@
|
|
|
48
48
|
|
|
49
49
|
const bodyCharCount = rawLen(document.body);
|
|
50
50
|
|
|
51
|
+
// Repository counters live outside the README. Capture them before the
|
|
52
|
+
// readability pass removes forms / sidebars, without another page request.
|
|
53
|
+
// Keep GitHub's displayed precision (e.g. 47.2k); never invent missing counts.
|
|
54
|
+
const repoPath = location.pathname.replace(/\/+$/, '');
|
|
55
|
+
if (location.hostname === 'github.com' && /^\/[^/]+\/[^/]+$/.test(repoPath)) {
|
|
56
|
+
const repository = { name: repoPath.slice(1), url: `${location.origin}${repoPath}` };
|
|
57
|
+
const countPattern = /^\d[\d,.]*(?:[kmb])?$/i;
|
|
58
|
+
const links = Array.from(document.querySelectorAll('a[href]'));
|
|
59
|
+
for (const [key, suffix] of [['stars', 'stargazers'], ['forks', 'forks'], ['watchers', 'watchers']]) {
|
|
60
|
+
const candidates = links.filter((a) => {
|
|
61
|
+
try {
|
|
62
|
+
const url = new URL(a.href, location.href);
|
|
63
|
+
return url.origin === location.origin && url.pathname.replace(/\/+$/, '') === `${repoPath}/${suffix}`;
|
|
64
|
+
} catch { return false; }
|
|
65
|
+
});
|
|
66
|
+
for (const link of candidates) {
|
|
67
|
+
const exact = [link, ...link.querySelectorAll('[title]')]
|
|
68
|
+
.map((el) => squash(el.getAttribute('title'))).find((value) => countPattern.test(value));
|
|
69
|
+
const displayed = squash(link.textContent).match(/(?:^|\s)(\d[\d,.]*(?:[kmb])?)(?=\s|$)/i);
|
|
70
|
+
if (exact || displayed) {
|
|
71
|
+
repository[key] = exact || displayed[1];
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (Object.keys(repository).length > 2) metadata.repository = repository;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Resolve explicit scopes on the original DOM. A caller may intentionally
|
|
80
|
+
// request a sidebar, a button, or a node nested inside one.
|
|
81
|
+
const selected = opts.selector ? document.querySelector(opts.selector) : null;
|
|
82
|
+
if (opts.selector && !selected) {
|
|
83
|
+
return { error: 'selector-not-found', selector: opts.selector, metadata };
|
|
84
|
+
}
|
|
85
|
+
|
|
51
86
|
// ── 1. strip boilerplate (structural only, see below) ────────────────────
|
|
52
87
|
const JUNK_TAGS = [
|
|
53
88
|
'script', 'style', 'noscript', 'template', 'svg', 'canvas', 'iframe',
|
|
@@ -65,7 +100,10 @@
|
|
|
65
100
|
return typeof tokens === 'string' && JUNK_WORDS.test(tokens);
|
|
66
101
|
};
|
|
67
102
|
|
|
68
|
-
|
|
103
|
+
const cleanupSelector = selected
|
|
104
|
+
? 'script,style,noscript,template,svg,canvas,iframe'
|
|
105
|
+
: JUNK_TAGS;
|
|
106
|
+
document.querySelectorAll(cleanupSelector).forEach((el) => {
|
|
69
107
|
try { el.remove(); } catch (e) { /* already detached */ }
|
|
70
108
|
});
|
|
71
109
|
|
|
@@ -102,10 +140,7 @@
|
|
|
102
140
|
let containerReason = '';
|
|
103
141
|
|
|
104
142
|
if (opts.selector) {
|
|
105
|
-
container =
|
|
106
|
-
if (!container) {
|
|
107
|
-
return { error: 'selector-not-found', selector: opts.selector, metadata };
|
|
108
|
-
}
|
|
143
|
+
container = selected;
|
|
109
144
|
containerReason = `selector:${opts.selector}`;
|
|
110
145
|
} else {
|
|
111
146
|
// Readability's scoring:每个够长的段落给自己父/祖父/曾祖父记分(1/2、1/3 衰减),
|
|
@@ -165,6 +200,7 @@
|
|
|
165
200
|
Array.prototype.slice.call(container.querySelectorAll('div,section,ul,ol,span,header,p,figure'))
|
|
166
201
|
.forEach((el) => {
|
|
167
202
|
if (!container.contains(el) || el === container) return;
|
|
203
|
+
if (selected) return; // Explicit scopes opt out of boilerplate pruning.
|
|
168
204
|
if (!isJunkClass(el)) return;
|
|
169
205
|
if (rawLen(el) / containerChars > 0.4) return;
|
|
170
206
|
try { el.remove(); } catch (e) { /* already detached */ }
|
|
@@ -328,7 +364,7 @@
|
|
|
328
364
|
const flush = () => {
|
|
329
365
|
const t = squash(buffer);
|
|
330
366
|
buffer = '';
|
|
331
|
-
if (t.length >= 2) push({ type: 'paragraph', text: t });
|
|
367
|
+
if (t.length >= (selected ? 1 : 2)) push({ type: 'paragraph', text: t });
|
|
332
368
|
};
|
|
333
369
|
el.childNodes.forEach((child) => {
|
|
334
370
|
if (child.nodeType === 3) { buffer += child.nodeValue; return; }
|
|
@@ -354,7 +390,8 @@
|
|
|
354
390
|
flush();
|
|
355
391
|
};
|
|
356
392
|
|
|
357
|
-
|
|
393
|
+
if (EMIT.has(container.tagName)) emit(container);
|
|
394
|
+
else walk(container, 0);
|
|
358
395
|
|
|
359
396
|
const charCount = blocks.reduce((n, b) => {
|
|
360
397
|
if (b.type === 'list') return n + b.items.reduce((m, i) => m + i.text.length, 0);
|
|
@@ -150,6 +150,12 @@ def _header(data: dict, fmt: str) -> str:
|
|
|
150
150
|
trailer.append(meta["publishedTime"])
|
|
151
151
|
trailer.append(data.get("finalUrl") or meta.get("url") or "")
|
|
152
152
|
lines.append(" · ".join(t for t in trailer if t))
|
|
153
|
+
repository = meta.get("repository") or {}
|
|
154
|
+
stats = [f"{label}: {repository[key]}" for key, label in
|
|
155
|
+
(("stars", "Stars"), ("forks", "Forks"), ("watchers", "Watchers"))
|
|
156
|
+
if repository.get(key) is not None]
|
|
157
|
+
if stats:
|
|
158
|
+
lines.append("GitHub repository statistics (observed on page): " + " · ".join(stats))
|
|
153
159
|
return "\n".join(lines)
|
|
154
160
|
|
|
155
161
|
|
|
@@ -98,6 +98,9 @@ def main() -> None:
|
|
|
98
98
|
chars = result["charCount"]
|
|
99
99
|
body_chars = result["bodyCharCount"]
|
|
100
100
|
status = result.get("status")
|
|
101
|
+
# A targeted read of a counter/button can legitimately be one character.
|
|
102
|
+
# Empty scopes still deserve a warning; HTTP warnings remain independent.
|
|
103
|
+
unexpectedly_short = chars < 100 and (not args.selector or chars == 0)
|
|
101
104
|
if isinstance(status, int) and status >= 400:
|
|
102
105
|
# 错误页也有正文("404 Not Found"),照读不误——但不说一声的话,调用方
|
|
103
106
|
# 会把一张错误页当成文章内容去写脚本。
|
|
@@ -105,14 +108,14 @@ def main() -> None:
|
|
|
105
108
|
f"⚠️ 服务器返回 HTTP {status},下面读到的很可能是错误页而不是目标内容。",
|
|
106
109
|
file=sys.stderr,
|
|
107
110
|
)
|
|
108
|
-
if
|
|
111
|
+
if unexpectedly_short and body_chars >= 2000:
|
|
109
112
|
print(
|
|
110
113
|
f"⚠️ 整页有 {body_chars} 字符,却只抽到 {chars} 字符"
|
|
111
114
|
f"(容器 {result.get('container')})——正文容器多半没认对。"
|
|
112
115
|
"可用 --selector <正文容器> 指定,或 --format json 看抽到了哪些块。",
|
|
113
116
|
file=sys.stderr,
|
|
114
117
|
)
|
|
115
|
-
elif
|
|
118
|
+
elif unexpectedly_short:
|
|
116
119
|
print(
|
|
117
120
|
f"⚠️ 这个页面几乎没有文本(整页 {body_chars} 字符,HTTP {result.get('status')})。"
|
|
118
121
|
"多半是登录墙、反爬拦截,或正文由 JS 延迟渲染还没出来。"
|
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
"skillName": "web-screenshot",
|
|
3
3
|
"repoName": "agent-skill-media-maker",
|
|
4
4
|
"skillId": "555",
|
|
5
|
-
"version": "
|
|
5
|
+
"version": "V6",
|
|
6
6
|
"skillDescription": "Web-page screenshot skill (still images only, png/jpg), built on Playwright Python — captures any URL to a local image file.\nSupports: full-page / viewport / element / region capture, device emulation, waiting for dynamic content, hiding or masking distracting elements, and static annotations (highlight box / arrow / caption / numbered sequence / redact / code-line highlight).\n\nImages only (`.png` / `.jpg`). For video (`.webm` / screen recording / storyboard clips) use web-record / `web_record`.\n\nUse this skill immediately whenever the user asks for any of:\n- Web page screenshot, screen capture, full-page screenshot, long screenshot\n- Capture a specific element / region, partial screenshot, focus on an area\n- Hide or mask elements before capturing (cookie banner, sensitive information)\n- Annotate a still screenshot (highlight box / arrow / label / caption bar / numbering)\n- Highlight lines L5-L20 of a file and screenshot it\n- Wait for an element to appear, then capture\n- Mobile / device-emulated screenshot (iPhone / Pixel, etc.)\n- Capture with cookies / a logged-in session / storageState\n\nEven when the user does not say \"playwright\", any request to screenshot a URL and get a local image file should route here."
|
|
7
7
|
}
|