@remixmate/cli 0.9.69 → 0.9.71

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/README.md CHANGED
@@ -187,6 +187,7 @@ This project contains 13 AI media generation skills covering the full pipeline f
187
187
  │ web-screenshot Web page screenshot (png / jpg) │
188
188
  │ web-record Web page recording / storyboard video │
189
189
  │ web-read Web page text extraction (markdown/json) │
190
+ │ gen-post Post copy: Douyin / XHS / TikTok │
190
191
  └─────────────────────────────────────────────────────────────┘
191
192
  ```
192
193
 
@@ -207,6 +208,7 @@ This project contains 13 AI media generation skills covering the full pipeline f
207
208
  │ ├── render-video/ # orchestration: Phase 3 Remotion render (canonical render_video.py)
208
209
  │ ├── export-jianying/ # orchestration: export Jianying draft ZIP
209
210
  │ ├── video-parser/ # tool: video deconstruction & analysis
211
+ │ ├── gen-post/ # tool: social post copy per platform (http handler)
210
212
  │ ├── web-screenshot/ # tool: headless-browser screenshot (also hosts record.py)
211
213
  │ ├── web-record/ # tool: headless-browser recording (entry -> web-screenshot/scripts/record.py)
212
214
  │ └── web-read/ # tool: headless-browser text extraction (entry -> web-screenshot/scripts/read_page.py)
@@ -227,6 +229,7 @@ This project contains 13 AI media generation skills covering the full pipeline f
227
229
  | render-video | orchestration | job_id → Remotion render → upload | python `scripts/render_video.py` |
228
230
  | export-jianying | orchestration | Asset URLs → Jianying draft ZIP (auto-converts from RenderPlan) | python `scripts/gen_jianying_draft.py` |
229
231
  | video-parser | tool | Video → audio + ASR + keyframes + scene segmentation | python `scripts/parse_via_render.py` |
232
+ | gen-post | tool | Post text for a finished video — title / description / hashtags for Douyin, Xiaohongshu, TikTok | http handler |
230
233
  | web-screenshot | tool | Web page screenshot (png / jpg) | python `scripts/screenshot.py` |
231
234
  | web-record | tool | Web page recording / scroll-through / storyboard video (webm -> mp4 -> VOD) | python `../web-screenshot/scripts/record.py` |
232
235
  | web-read | tool | Web page → main text (markdown / plain text / structured JSON) | python `../web-screenshot/scripts/read_page.py` |
package/README.zh-CN.md CHANGED
@@ -141,6 +141,7 @@ Python 技能(13 个中的 9 个)需要 `python3 >= 3.10`。`web-screenshot`
141
141
  │ web-screenshot 网页截图(png / jpg) │
142
142
  │ web-record 网页录屏 / 滚动录屏 / 分镜视频 │
143
143
  │ web-read 网页正文抽取(markdown / 纯文本 / JSON) │
144
+ │ gen-post 发布文案(抖音 / 小红书 / TikTok) │
144
145
  └─────────────────────────────────────────────────────────────┘
145
146
  ```
146
147
 
@@ -161,6 +162,7 @@ Python 技能(13 个中的 9 个)需要 `python3 >= 3.10`。`web-screenshot`
161
162
  │ ├── render-video/ # 编排: Phase 3 Remotion 渲染(含 render_video.py 实现)
162
163
  │ ├── export-jianying/ # 编排: 导出剪映草稿 ZIP
163
164
  │ ├── video-parser/ # 工具: 视频解构与分析
165
+ │ ├── gen-post/ # 工具: 按平台生成发布文案(http handler)
164
166
  │ ├── web-screenshot/ # 工具: 无头浏览器截图(record.py 也放在这里)
165
167
  │ ├── web-record/ # 工具: 无头浏览器录屏(入口指向 web-screenshot/scripts/record.py)
166
168
  │ └── web-read/ # 工具: 无头浏览器正文抽取(入口指向 web-screenshot/scripts/read_page.py)
@@ -181,6 +183,7 @@ Python 技能(13 个中的 9 个)需要 `python3 >= 3.10`。`web-screenshot`
181
183
  | render-video | 编排 | job_id → Remotion 渲染 → 上传 | python `scripts/render_video.py` |
182
184
  | export-jianying | 编排 | 素材 URL → 剪映草稿 ZIP(支持从 RenderPlan 自动转换) | python `scripts/gen_jianying_draft.py` |
183
185
  | video-parser | 工具 | 视频 → 音频 + ASR + 关键帧 + 场景分段 | python `scripts/parse_via_render.py` |
186
+ | gen-post | 工具 | 成片的发布文案:抖音 / 小红书 / TikTok 的标题、描述、话题标签 | http handler |
184
187
  | web-screenshot | 工具 | 网页截图(png / jpg) | python `scripts/screenshot.py` |
185
188
  | web-record | 工具 | 网页录屏 / 滚动录屏 / 分镜视频(webm → mp4 → VOD) | python `../web-screenshot/scripts/record.py` |
186
189
  | web-read | 工具 | 网页正文抽取(markdown / 纯文本 / 结构化 JSON) | python `../web-screenshot/scripts/read_page.py` |
@@ -0,0 +1,21 @@
1
+ /**
2
+ * gen-post handler — POST ab-api /publish/caption/generate.
3
+ *
4
+ * Generates the text of a social post — per-platform title / description / tags —
5
+ * for a finished video (the video itself is not touched or uploaded): the same endpoint and prompts the web studio's 「发布文案」
6
+ * panel uses. Prompt wording, compliance rules and the platform roster all live
7
+ * in ab-api (publish_prompts.go + the user's saved prompt overrides); this
8
+ * handler only collects context and forwards it.
9
+ *
10
+ * Behavior:
11
+ * - --platform is repeatable and/or comma-separated; `all` (the default) expands
12
+ * to the three platforms the web panel shows. Each platform is one backend
13
+ * call (one LLM run, billed separately), issued concurrently.
14
+ * - --render-id links the result to a studio take so it shows up in the web
15
+ * panel; without it the backend generates without persisting anything.
16
+ * - --instruction rewrites an existing caption (--title / --description / --tag
17
+ * are the draft) instead of generating from scratch; single platform only,
18
+ * since the draft belongs to one platform.
19
+ */
20
+ import type { HandlerContext, HandlerInput } from './index.js';
21
+ export declare function genPost(input: HandlerInput, ctxIn: HandlerContext): Promise<void>;
@@ -0,0 +1,188 @@
1
+ /**
2
+ * gen-post handler — POST ab-api /publish/caption/generate.
3
+ *
4
+ * Generates the text of a social post — per-platform title / description / tags —
5
+ * for a finished video (the video itself is not touched or uploaded): the same endpoint and prompts the web studio's 「发布文案」
6
+ * panel uses. Prompt wording, compliance rules and the platform roster all live
7
+ * in ab-api (publish_prompts.go + the user's saved prompt overrides); this
8
+ * handler only collects context and forwards it.
9
+ *
10
+ * Behavior:
11
+ * - --platform is repeatable and/or comma-separated; `all` (the default) expands
12
+ * to the three platforms the web panel shows. Each platform is one backend
13
+ * call (one LLM run, billed separately), issued concurrently.
14
+ * - --render-id links the result to a studio take so it shows up in the web
15
+ * panel; without it the backend generates without persisting anything.
16
+ * - --instruction rewrites an existing caption (--title / --description / --tag
17
+ * are the draft) instead of generating from scratch; single platform only,
18
+ * since the draft belongs to one platform.
19
+ */
20
+ import { EXIT, mmPost, resolveHttpContext, SkillError } from '../http.js';
21
+ import { emitProgress } from '../progress.js';
22
+ import { isTrue, toNumber, toStringArray } from './shared.js';
23
+ /** Platforms the web panel enables by default; `all` expands to these. */
24
+ const DEFAULT_PLATFORMS = ['douyin', 'xiaohongshu', 'tiktok'];
25
+ /** Friendly aliases → backend platform codes. Unknown values pass through for ab-api to validate. */
26
+ const PLATFORM_ALIASES = {
27
+ dy: 'douyin',
28
+ 抖音: 'douyin',
29
+ xhs: 'xiaohongshu',
30
+ rednote: 'xiaohongshu',
31
+ 小红书: 'xiaohongshu',
32
+ tt: 'tiktok',
33
+ };
34
+ /** Display names for human-readable output only. */
35
+ const PLATFORM_LABELS = {
36
+ douyin: '抖音 Douyin',
37
+ xiaohongshu: '小红书 Xiaohongshu',
38
+ tiktok: 'TikTok',
39
+ };
40
+ /** Backend waits on a non-streaming LLM call (80s server-side cap); leave headroom. */
41
+ const GENERATE_TIMEOUT_MS = 90_000;
42
+ function resolvePlatforms(value) {
43
+ const raw = toStringArray(value)
44
+ .flatMap((v) => v.split(/[,,\s]+/))
45
+ .map((v) => v.trim())
46
+ .filter(Boolean);
47
+ if (raw.length === 0)
48
+ return [...DEFAULT_PLATFORMS];
49
+ const out = [];
50
+ for (const item of raw) {
51
+ const key = item.toLowerCase();
52
+ const codes = key === 'all' ? [...DEFAULT_PLATFORMS] : [PLATFORM_ALIASES[item] ?? PLATFORM_ALIASES[key] ?? key];
53
+ for (const code of codes)
54
+ if (!out.includes(code))
55
+ out.push(code);
56
+ }
57
+ return out;
58
+ }
59
+ function optString(value) {
60
+ if (typeof value !== 'string')
61
+ return undefined;
62
+ const trimmed = value.trim();
63
+ return trimmed || undefined;
64
+ }
65
+ /** Tags may arrive as `--tag a --tag b`, `--tag "a,b"`, or with a leading `#`. */
66
+ function normalizeTags(value) {
67
+ return toStringArray(value)
68
+ .flatMap((v) => v.split(/[,,]+/))
69
+ .map((v) => v.trim().replace(/^#+/, ''))
70
+ .filter(Boolean);
71
+ }
72
+ async function generateOne(ctx, platform, base) {
73
+ emitProgress({ phase: 'gen-post:request', platform });
74
+ try {
75
+ const caption = await mmPost(ctx, '/publish/caption/generate', { ...base, platform }, { timeoutMs: GENERATE_TIMEOUT_MS });
76
+ if (!caption || (!caption.title && !caption.description)) {
77
+ return { platform, error: 'backend returned an empty caption' };
78
+ }
79
+ emitProgress({ phase: 'gen-post:done', platform });
80
+ return { platform, caption: { ...caption, platform, tags: caption.tags ?? [] } };
81
+ }
82
+ catch (err) {
83
+ // Auth failures abort the whole run: every other platform would fail the same way.
84
+ if (err instanceof SkillError && err.exitCode === EXIT.NOT_AUTHENTICATED)
85
+ throw err;
86
+ return { platform, error: err instanceof Error ? err.message : String(err) };
87
+ }
88
+ }
89
+ function printHuman(results) {
90
+ for (const r of results) {
91
+ const label = PLATFORM_LABELS[r.platform] ?? r.platform;
92
+ process.stdout.write(`\n━━━━━━━━ ${label} ━━━━━━━━\n`);
93
+ if (!r.caption) {
94
+ process.stdout.write(`❌ ${r.error ?? 'generation failed'}\n`);
95
+ continue;
96
+ }
97
+ const { title, description, tags } = r.caption;
98
+ process.stdout.write(`标题 / Title:\n${title}\n\n`);
99
+ process.stdout.write(`描述 / Description:\n${description}\n\n`);
100
+ process.stdout.write(`标签 / Tags:\n${tags.map((t) => `#${t}`).join(' ')}\n`);
101
+ }
102
+ const ok = results.filter((r) => r.caption).length;
103
+ process.stdout.write(`\n🎉 Generated publish copy for ${ok}/${results.length} platform(s)\n`);
104
+ }
105
+ export async function genPost(input, ctxIn) {
106
+ const topic = optString(input.topic);
107
+ const hook = optString(input.hook);
108
+ const instruction = optString(input.instruction);
109
+ const renderId = optString(input.render_id);
110
+ const platforms = resolvePlatforms(input.platform);
111
+ const current = {
112
+ title: optString(input.title) ?? '',
113
+ description: optString(input.description) ?? '',
114
+ tags: normalizeTags(input.tag),
115
+ };
116
+ const hasDraft = !!(current.title || current.description || current.tags.length);
117
+ if (instruction) {
118
+ if (platforms.length !== 1) {
119
+ throw new SkillError('❌ --instruction rewrites one platform\'s caption; pass exactly one --platform');
120
+ }
121
+ if (!hasDraft) {
122
+ throw new SkillError('❌ --instruction needs the caption to rewrite: pass --title / --description / --tag');
123
+ }
124
+ if (instruction.length > 500) {
125
+ throw new SkillError('❌ --instruction must be at most 500 characters');
126
+ }
127
+ }
128
+ else if (!topic && !hook) {
129
+ throw new SkillError('❌ Please provide --topic (what the video is about); --hook is optional extra context');
130
+ }
131
+ const durationSec = toNumber(input.duration, 'duration');
132
+ if (durationSec !== undefined && durationSec < 0) {
133
+ throw new SkillError('❌ --duration must be a non-negative number of seconds');
134
+ }
135
+ const base = {};
136
+ if (renderId)
137
+ base.renderId = renderId;
138
+ const videoProjectId = optString(input.video_project_id);
139
+ if (videoProjectId)
140
+ base.videoProjectId = videoProjectId;
141
+ if (topic)
142
+ base.topic = topic;
143
+ if (hook)
144
+ base.hook = hook;
145
+ const sourceUrl = optString(input.source_url);
146
+ if (sourceUrl)
147
+ base.sourceUrl = sourceUrl;
148
+ if (durationSec !== undefined)
149
+ base.durationSec = durationSec;
150
+ const aspectRatio = optString(input.aspect_ratio);
151
+ if (aspectRatio)
152
+ base.aspectRatio = aspectRatio;
153
+ const language = optString(input.language);
154
+ if (language)
155
+ base.language = language;
156
+ const promptOverride = optString(input.prompt);
157
+ if (promptOverride)
158
+ base.promptOverride = promptOverride;
159
+ if (instruction) {
160
+ base.instruction = instruction;
161
+ base.current = current;
162
+ }
163
+ const ctx = await resolveHttpContext(ctxIn.skillName, {
164
+ apiBaseUrl: input.api_base_url,
165
+ privateToken: input.priv_token,
166
+ preflightToken: ctxIn.auth?.token,
167
+ });
168
+ const results = await Promise.all(platforms.map((p) => generateOne(ctx, p, base)));
169
+ const succeeded = results.filter((r) => r.caption);
170
+ if (isTrue(input.json_output)) {
171
+ process.stdout.write(JSON.stringify({
172
+ captions: succeeded.map((r) => ({
173
+ platform: r.platform,
174
+ title: r.caption.title,
175
+ description: r.caption.description,
176
+ tags: r.caption.tags,
177
+ })),
178
+ errors: results.filter((r) => !r.caption).map((r) => ({ platform: r.platform, error: r.error })),
179
+ persisted: !!renderId,
180
+ }) + '\n');
181
+ }
182
+ else {
183
+ printHuman(results);
184
+ }
185
+ if (succeeded.length === 0) {
186
+ throw new SkillError(`❌ publish copy generation failed for every platform: ${results.map((r) => `${r.platform}: ${r.error}`).join('; ')}`);
187
+ }
188
+ }
@@ -9,10 +9,12 @@ import { genVoice } from './gen-voice.js';
9
9
  import { genVideo } from './gen-video.js';
10
10
  import { genDigitalHuman } from './gen-digital-human.js';
11
11
  import { templateInit } from './template-init.js';
12
+ import { genPost } from './gen-post.js';
12
13
  export const HANDLERS = {
13
14
  'gen-image': genImage,
14
15
  'gen-voice': genVoice,
15
16
  'gen-video': genVideo,
16
17
  'gen-digital-human': genDigitalHuman,
17
18
  'template-init': templateInit,
19
+ 'gen-post': genPost,
18
20
  };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.9.69",
4
- "generatedAt": "2026-10-05T15:20:28.516Z",
3
+ "version": "0.9.71",
4
+ "generatedAt": "2026-10-08T03:44:04.976Z",
5
5
  "skills": [
6
6
  {
7
7
  "id": "doc-parse",
@@ -411,6 +411,124 @@
411
411
  ]
412
412
  }
413
413
  },
414
+ {
415
+ "id": "gen-post",
416
+ "toolName": "gen_post",
417
+ "tier": "tool",
418
+ "category": "consuming",
419
+ "title": "Social Post Copy",
420
+ "summary": "Write the text of a social post for a finished video — title, description and hashtags — per platform (text only; it does not upload or publish the video): Douyin (抖音), Xiaohongshu (小红书) and TikTok. Same prompts as the web studio's publish panel, including the platform's length limits and ad-law / community-guideline compliance rules, and the user's saved prompt overrides. Generates all three platforms by default; pass `platform` to pick. Give it the video's topic plus any hook, source URL, duration and aspect ratio you know — it only writes from what it is given. With `instruction` plus the current title/description/tags it rewrites an existing caption instead. Each platform is one LLM call and costs credits.",
421
+ "triggers": [
422
+ "Publish copy / caption / title + description + hashtags for a video",
423
+ "发布文案、视频标题、话题标签、抖音文案、小红书笔记文案、TikTok caption",
424
+ "Rewrite or polish an existing video caption for a platform"
425
+ ],
426
+ "entry": {
427
+ "type": "http",
428
+ "handler": "gen-post"
429
+ },
430
+ "runtime": "node",
431
+ "envVars": [
432
+ "PRIV_TOKEN",
433
+ "MM_API_BASE_URL",
434
+ "AGENT_NAME"
435
+ ],
436
+ "parameters": {
437
+ "type": "object",
438
+ "properties": {
439
+ "topic": {
440
+ "type": "string",
441
+ "description": "What the video is about — the main input for the copy (required unless rewriting with `instruction`)"
442
+ },
443
+ "platform": {
444
+ "type": "array",
445
+ "items": {
446
+ "type": "string"
447
+ },
448
+ "description": "Target platforms: douyin / xiaohongshu / tiktok, or all (default: all three). Repeatable or comma-separated."
449
+ },
450
+ "hook": {
451
+ "type": "string",
452
+ "description": "The video's opening hook line, if known"
453
+ },
454
+ "source_url": {
455
+ "type": "string",
456
+ "description": "Project / source URL the video is about, if any (used as context; never written into the copy as a link)"
457
+ },
458
+ "duration": {
459
+ "type": "number",
460
+ "description": "Video duration in seconds"
461
+ },
462
+ "aspect_ratio": {
463
+ "type": "string",
464
+ "description": "Video aspect ratio, e.g. 9:16 (default 9:16)"
465
+ },
466
+ "language": {
467
+ "type": "string",
468
+ "description": "Output language. Default: English for tiktok, 简体中文 for the others."
469
+ },
470
+ "instruction": {
471
+ "type": "string",
472
+ "description": "Rewrite request for an existing caption, e.g. 'shorter title, drop the emoji'. Requires exactly one platform plus the current title / description / tag. Max 500 characters."
473
+ },
474
+ "title": {
475
+ "type": "string",
476
+ "description": "Current title to rewrite (only with `instruction`)"
477
+ },
478
+ "description": {
479
+ "type": "string",
480
+ "description": "Current description to rewrite (only with `instruction`)"
481
+ },
482
+ "tag": {
483
+ "type": "array",
484
+ "items": {
485
+ "type": "string"
486
+ },
487
+ "description": "Current tags to rewrite (only with `instruction`). Repeatable or comma-separated; a leading # is stripped."
488
+ },
489
+ "prompt": {
490
+ "type": "string",
491
+ "description": "Replace the platform prompt for this run only. Placeholders: {{topic}} {{hook}} {{sourceUrl}} {{durationSec}} {{aspectRatio}} {{language}}. The output must still be a JSON object with title / description / tags."
492
+ },
493
+ "render_id": {
494
+ "type": "string",
495
+ "description": "Studio render (take) id. When given, the result is saved to that take and shows up in the web publish panel; otherwise nothing is saved."
496
+ },
497
+ "video_project_id": {
498
+ "type": "string",
499
+ "description": "Studio video project id of the take (only with `render_id`)"
500
+ },
501
+ "json_output": {
502
+ "type": "boolean",
503
+ "description": "Emit a JSON result ({ captions: [{ platform, title, description, tags }], errors, persisted }) instead of human-readable output"
504
+ }
505
+ },
506
+ "required": []
507
+ },
508
+ "ui": {
509
+ "primary": [
510
+ "topic",
511
+ "platform",
512
+ "hook"
513
+ ],
514
+ "advanced": [
515
+ "source_url",
516
+ "duration",
517
+ "aspect_ratio",
518
+ "language",
519
+ "instruction",
520
+ "title",
521
+ "description",
522
+ "tag",
523
+ "prompt"
524
+ ],
525
+ "hidden": [
526
+ "render_id",
527
+ "video_project_id",
528
+ "json_output"
529
+ ]
530
+ }
531
+ },
414
532
  {
415
533
  "id": "gen-script",
416
534
  "toolName": "gen_script",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remixmate/cli",
3
- "version": "0.9.69",
3
+ "version": "0.9.71",
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",
@@ -0,0 +1,104 @@
1
+ ---
2
+ name: gen-post
3
+ description: |
4
+ Social-post skill: write the post text — title, description and hashtags — for a finished video, per platform: Douyin (抖音), Xiaohongshu (小红书) and TikTok. Text only; it does not upload or publish the video. Backed by ab-api's `/publish/caption/generate`, the same prompts the web studio's publish panel uses.
5
+
6
+ Use this skill immediately whenever the user asks for any of:
7
+ - Publish copy / caption / title + description + hashtags for a video
8
+ - 发布文案、视频标题、话题标签、抖音文案、小红书笔记文案、TikTok caption
9
+ - Rewrite or polish an existing video caption for a platform
10
+ triggers:
11
+ - Publish copy / caption / title + description + hashtags for a video
12
+ - 发布文案、视频标题、话题标签、抖音文案、小红书笔记文案、TikTok caption
13
+ - Rewrite or polish an existing video caption for a platform
14
+ ---
15
+
16
+ # Social Post Skill
17
+
18
+ Wraps ab-api's `POST /publish/caption/generate`. Platform prompts (length limits, tone, and
19
+ the ad-law / community-guideline compliance rules) live in the backend, and a user's saved
20
+ prompt overrides from the web panel apply here too.
21
+
22
+ | `--platform` | Default language | Shape |
23
+ |--------------|------------------|-------|
24
+ | `douyin` (`dy`) | 简体中文 | short hook title, 60-150 字 description, 3-5 tags |
25
+ | `xiaohongshu` (`xhs`) | 简体中文 | note-style title ≤ 20 字, 200-400 字 body, 5-8 tags |
26
+ | `tiktok` (`tt`) | English | first-line title, short caption, 3-5 hashtags |
27
+
28
+ Default is all three. Each platform is one LLM call, run concurrently, charged separately.
29
+
30
+ ## Generate
31
+
32
+ The copy is written only from what you pass — anything missing is treated as "not provided",
33
+ never invented. So pass what you know about the video: `--topic` is the main input, and
34
+ `--hook` / `--source-url` / `--duration` / `--aspect-ratio` sharpen it.
35
+
36
+ ```bash
37
+ remixmate gen-post \
38
+ --topic "<what the video is about>" \
39
+ --hook "<opening line>" \
40
+ --duration 45 --aspect-ratio 9:16
41
+ ```
42
+
43
+ ```bash
44
+ # One platform, machine-readable
45
+ remixmate gen-post --topic "<topic>" --platform xhs --json-output
46
+ ```
47
+
48
+ After running `render-video` in the same session, you already know the topic, hook, duration
49
+ and aspect ratio from the script — reuse them rather than asking the user again.
50
+
51
+ ## Rewrite
52
+
53
+ `--instruction` rewrites an existing caption instead of starting over. One platform only;
54
+ pass the current caption as the draft. Fields the instruction doesn't mention are kept.
55
+ Compliance rules still win: asking for superlatives, guarantees or off-platform links gets a
56
+ compliant rewording, not a literal one.
57
+
58
+ ```bash
59
+ remixmate gen-post --platform douyin \
60
+ --instruction "标题去掉 emoji,再短一点" \
61
+ --title "<current title>" --description "<current description>" \
62
+ --tag tagA --tag tagB
63
+ ```
64
+
65
+ ## Flags
66
+
67
+ | Flag | Description | Default |
68
+ |------|-------------|---------|
69
+ | `--topic` | What the video is about (required unless `--instruction`) | — |
70
+ | `--platform` | `douyin` / `xiaohongshu` / `tiktok` / `all`; repeatable or comma-separated | `all` |
71
+ | `--hook` | Opening hook line | none |
72
+ | `--source-url` | Project/source URL, context only | none |
73
+ | `--duration` | Seconds | none |
74
+ | `--aspect-ratio` | e.g. `9:16` | `9:16` |
75
+ | `--language` | Output language | per platform (see table) |
76
+ | `--instruction` | Rewrite request, ≤ 500 chars | none |
77
+ | `--title` / `--description` / `--tag` | Current caption to rewrite | none |
78
+ | `--prompt` | Replace the platform prompt for this run; placeholders `{{topic}}` `{{hook}}` `{{sourceUrl}}` `{{durationSec}}` `{{aspectRatio}}` `{{language}}` | saved/default prompt |
79
+ | `--render-id` / `--video-project-id` | Save the result to a studio take (shows in the web publish panel) | not saved |
80
+ | `--json-output` | `{ captions: [{ platform, title, description, tags }], errors, persisted }` | off |
81
+ | `--api-base-url` / `--priv-token` | Override API root / token | env |
82
+
83
+ Tags come back without `#`; the human-readable output adds it.
84
+
85
+ ## Output
86
+
87
+ Show the user each platform's title, description and tags as-is so they can copy them. If
88
+ some platforms failed, the rest still print; the run exits non-zero only when every platform
89
+ failed.
90
+
91
+ ## Credits
92
+
93
+ Each platform charges credits. Relay the footer when it appears:
94
+
95
+ ```
96
+ 💳 Charged <credits> credits · balance <balance>
97
+ ```
98
+
99
+ ## Error handling
100
+
101
+ - **Token missing / 401**: set `PRIV_TOKEN` or run `remixmate login`.
102
+ - **`不支持的平台`**: the platform code is not one the backend knows.
103
+ - **`模型响应超时` / timeout**: retry; generating fewer platforms at once also helps.
104
+ - **`--instruction` errors**: pass exactly one `--platform` and the current caption.
@@ -0,0 +1,113 @@
1
+ {
2
+ "name": "gen-post",
3
+ "toolName": "gen_post",
4
+ "tier": "tool",
5
+ "category": "consuming",
6
+ "title": "Social Post Copy",
7
+ "description": "Write the text of a social post for a finished video — title, description and hashtags — per platform (text only; it does not upload or publish the video): Douyin (抖音), Xiaohongshu (小红书) and TikTok. Same prompts as the web studio's publish panel, including the platform's length limits and ad-law / community-guideline compliance rules, and the user's saved prompt overrides. Generates all three platforms by default; pass `platform` to pick. Give it the video's topic plus any hook, source URL, duration and aspect ratio you know — it only writes from what it is given. With `instruction` plus the current title/description/tags it rewrites an existing caption instead. Each platform is one LLM call and costs credits.",
8
+ "auth": "required",
9
+ "envVars": [
10
+ "PRIV_TOKEN",
11
+ "MM_API_BASE_URL",
12
+ "AGENT_NAME"
13
+ ],
14
+ "entry": {
15
+ "type": "http",
16
+ "handler": "gen-post"
17
+ },
18
+ "parameters": {
19
+ "type": "object",
20
+ "properties": {
21
+ "topic": {
22
+ "type": "string",
23
+ "description": "What the video is about — the main input for the copy (required unless rewriting with `instruction`)"
24
+ },
25
+ "platform": {
26
+ "type": "array",
27
+ "items": {
28
+ "type": "string"
29
+ },
30
+ "description": "Target platforms: douyin / xiaohongshu / tiktok, or all (default: all three). Repeatable or comma-separated."
31
+ },
32
+ "hook": {
33
+ "type": "string",
34
+ "description": "The video's opening hook line, if known"
35
+ },
36
+ "source_url": {
37
+ "type": "string",
38
+ "description": "Project / source URL the video is about, if any (used as context; never written into the copy as a link)"
39
+ },
40
+ "duration": {
41
+ "type": "number",
42
+ "description": "Video duration in seconds"
43
+ },
44
+ "aspect_ratio": {
45
+ "type": "string",
46
+ "description": "Video aspect ratio, e.g. 9:16 (default 9:16)"
47
+ },
48
+ "language": {
49
+ "type": "string",
50
+ "description": "Output language. Default: English for tiktok, 简体中文 for the others."
51
+ },
52
+ "instruction": {
53
+ "type": "string",
54
+ "description": "Rewrite request for an existing caption, e.g. 'shorter title, drop the emoji'. Requires exactly one platform plus the current title / description / tag. Max 500 characters."
55
+ },
56
+ "title": {
57
+ "type": "string",
58
+ "description": "Current title to rewrite (only with `instruction`)"
59
+ },
60
+ "description": {
61
+ "type": "string",
62
+ "description": "Current description to rewrite (only with `instruction`)"
63
+ },
64
+ "tag": {
65
+ "type": "array",
66
+ "items": {
67
+ "type": "string"
68
+ },
69
+ "description": "Current tags to rewrite (only with `instruction`). Repeatable or comma-separated; a leading # is stripped."
70
+ },
71
+ "prompt": {
72
+ "type": "string",
73
+ "description": "Replace the platform prompt for this run only. Placeholders: {{topic}} {{hook}} {{sourceUrl}} {{durationSec}} {{aspectRatio}} {{language}}. The output must still be a JSON object with title / description / tags."
74
+ },
75
+ "render_id": {
76
+ "type": "string",
77
+ "description": "Studio render (take) id. When given, the result is saved to that take and shows up in the web publish panel; otherwise nothing is saved."
78
+ },
79
+ "video_project_id": {
80
+ "type": "string",
81
+ "description": "Studio video project id of the take (only with `render_id`)"
82
+ },
83
+ "json_output": {
84
+ "type": "boolean",
85
+ "description": "Emit a JSON result ({ captions: [{ platform, title, description, tags }], errors, persisted }) instead of human-readable output"
86
+ }
87
+ },
88
+ "required": []
89
+ },
90
+ "ui": {
91
+ "primary": [
92
+ "topic",
93
+ "platform",
94
+ "hook"
95
+ ],
96
+ "advanced": [
97
+ "source_url",
98
+ "duration",
99
+ "aspect_ratio",
100
+ "language",
101
+ "instruction",
102
+ "title",
103
+ "description",
104
+ "tag",
105
+ "prompt"
106
+ ],
107
+ "hidden": [
108
+ "render_id",
109
+ "video_project_id",
110
+ "json_output"
111
+ ]
112
+ }
113
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "skillName": "gen-post",
3
+ "repoName": "agent-skill-media-maker",
4
+ "skillId": "pending-gen-post",
5
+ "version": "V1",
6
+ "skillDescription": "Social-post skill: write the post text — title, description and hashtags — for a finished video, per platform: Douyin (抖音), Xiaohongshu (小红书) and TikTok. Text only; it does not upload or publish the video. Backed by ab-api's `/publish/caption/generate` (the same prompts as the web studio's publish panel).\n\nUse this skill immediately whenever the user asks for any of:\n- Publish copy / caption / title + description + hashtags for a video\n- 发布文案、视频标题、话题标签、抖音文案、小红书笔记文案、TikTok caption\n- Rewrite or polish an existing video caption for a platform"
7
+ }
@@ -110,6 +110,8 @@ python3 <ReadScript> --url "https://example.com/x" --user-agent "Mozilla/5.0 (Ma
110
110
 
111
111
  The stderr diagnostics tell you which case you are in: `⚠️ 整页有 N 字符,却只抽到 M` means the wrong container was chosen (→ `--selector`), while `⚠️ 这个页面几乎没有文本` means the page itself never rendered text (→ waiting, cookies, or user-agent).
112
112
 
113
+ Slow or unreachable sub-resources do not fail the read: images / fonts / media / stylesheets are never fetched, every other sub-resource is cut off after 8s, and extraction runs on whatever DOM has loaded (stderr says `[web_read] N 个子资源…已跳过`). A non-zero `打开页面失败` therefore means the server never returned the page itself — retrying the same URL with a bigger timeout, another user-agent, device or browser will not help; ask for the text or a different URL.
114
+
113
115
  ## Links and images
114
116
 
115
117
  Both are dropped by default, because they are noise for a summarize/rewrite task and they inflate the character budget.
@@ -21,11 +21,15 @@ def _ensure_playwright_pkg() -> None:
21
21
  _pip_install("playwright")
22
22
 
23
23
 
24
- def ensure_chromium() -> None:
25
- """Install the chromium browser via `playwright install`."""
26
- print("[bootstrap] playwright install chromium ...", file=sys.stderr)
24
+ def ensure_browser(name: str = "chromium") -> None:
25
+ """Install the given browser engine via `playwright install <name>`.
26
+
27
+ Must install the engine that is about to be launched: installing chromium
28
+ and then relaunching firefox fails the same way a second time.
29
+ """
30
+ print(f"[bootstrap] playwright install {name} ...", file=sys.stderr)
27
31
  subprocess.check_call(
28
- [sys.executable, "-m", "playwright", "install", "chromium"]
32
+ [sys.executable, "-m", "playwright", "install", name]
29
33
  )
30
34
 
31
35
 
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
  from typing import Any
5
5
 
6
6
  from . import js_loader
7
- from .bootstrap import ensure_chromium
7
+ from .bootstrap import ensure_browser
8
8
 
9
9
 
10
10
  def pick_browser_type(p, name: str | None):
@@ -42,15 +42,35 @@ def build_context_options(p, cfg: dict, record_video_dir: str | None) -> dict:
42
42
  return opts
43
43
 
44
44
 
45
+ def _is_missing_executable(e: Exception) -> bool:
46
+ msg = str(e)
47
+ return "Executable doesn't exist" in msg or "playwright install" in msg.lower()
48
+
49
+
45
50
  def launch_with_browser_install(p, browser_name: str | None, launch_kwargs: dict | None = None):
46
51
  btype = pick_browser_type(p, browser_name)
52
+ engine = btype.name # "chromium" | "firefox" | "webkit"
53
+ try:
54
+ return btype.launch(**(launch_kwargs or {}))
55
+ except Exception as e:
56
+ if not _is_missing_executable(e):
57
+ raise
58
+ # 引擎没装:装「这次要启动的那个」引擎再试一次。装不上 / 装了仍起不来时给一句
59
+ # 能据以行动的话,而不是 30 行 Playwright traceback。
60
+ hint = (
61
+ f"浏览器引擎 {engine} 在当前环境不可用。"
62
+ + ("去掉 browser 参数改用默认的 chromium 即可。" if engine != "chromium"
63
+ else "请运行 `python3 -m playwright install chromium`。")
64
+ )
65
+ try:
66
+ ensure_browser(engine)
67
+ except Exception as e:
68
+ raise SystemExit(f"{hint}\n(自动安装失败:{type(e).__name__}: {str(e).splitlines()[0] if str(e) else ''})")
47
69
  try:
48
70
  return btype.launch(**(launch_kwargs or {}))
49
71
  except Exception as e:
50
- msg = str(e)
51
- if "Executable doesn't exist" in msg or "playwright install" in msg.lower():
52
- ensure_chromium()
53
- return btype.launch(**(launch_kwargs or {}))
72
+ if _is_missing_executable(e):
73
+ raise SystemExit(hint)
54
74
  raise
55
75
 
56
76
 
@@ -12,12 +12,74 @@ disk so nothing is actually lost.
12
12
  from __future__ import annotations
13
13
 
14
14
  import json
15
+ import sys
15
16
  from pathlib import Path
17
+ from urllib.parse import urlsplit
16
18
 
17
19
  from . import js_loader
18
20
  from .bootstrap import ensure_runtime
19
21
  from .browser import apply_pre_action_waits, build_context_options, launch_with_browser_install
20
22
 
23
+ # 读正文只需要 HTML 和(SPA 场景下)脚本 / 接口;图片、字体、媒体、样式表一律不拉。
24
+ # 抽取逻辑只读 DOM 文本与属性(img 的 src 照样在),不依赖布局。
25
+ _SKIPPED_RESOURCE_TYPES = {"image", "media", "font", "stylesheet"}
26
+ # 单个子资源的拉取上限。浏览器要等 defer 脚本 / 阻塞样式表下载完才触发
27
+ # DOMContentLoaded,一个从本机出口挂死的 CDN 就能让整页永远「打不开」
28
+ # (2026-10-06:新加坡出口读腾讯云社区文章,_next chunk 与广州 COS 资源全部挂起)。
29
+ # 超过上限的请求被 abort,浏览器跳过它继续解析。
30
+ _SUBRESOURCE_TIMEOUT_MS = 8000
31
+ # 拿到主文档响应头之后,最多再等这么久的 DOMContentLoaded;到点不报错,按当前 DOM 抽取。
32
+ _DOM_READY_TIMEOUT_MS = 15000
33
+
34
+
35
+ class _SubresourceGate:
36
+ """page.route 处理器:丢弃不需要的资源,给其余子资源加单请求超时。"""
37
+
38
+ def __init__(self) -> None:
39
+ self.stalled: list[str] = []
40
+
41
+ def __call__(self, route) -> None:
42
+ req = route.request
43
+ if req.is_navigation_request():
44
+ route.continue_()
45
+ return
46
+ if req.resource_type in _SKIPPED_RESOURCE_TYPES:
47
+ route.abort()
48
+ return
49
+ try:
50
+ resp = route.fetch(timeout=_SUBRESOURCE_TIMEOUT_MS)
51
+ except Exception:
52
+ self.stalled.append(urlsplit(req.url).hostname or "?")
53
+ try:
54
+ route.abort()
55
+ except Exception:
56
+ pass # 页面已关闭 / 已导航走
57
+ return
58
+ try:
59
+ route.fulfill(response=resp)
60
+ except Exception:
61
+ pass
62
+
63
+ def summary(self) -> str:
64
+ hosts = sorted(set(self.stalled))
65
+ shown = ", ".join(hosts[:5]) + (" …" if len(hosts) > 5 else "")
66
+ return f"{len(self.stalled)} 个子资源超过 {_SUBRESOURCE_TIMEOUT_MS // 1000}s 未返回已跳过({shown})"
67
+
68
+
69
+ def _navigation_error(url: str, e: Exception) -> SystemExit:
70
+ # 一条模型能据以行动的错误,胜过 30 行 Playwright traceback。
71
+ # 走到这里说明连主文档的响应头都没拿到(子资源挂死已由 _SubresourceGate 兜住),
72
+ # 问题在网络层或对方服务器;换参数重试几乎不可能有不同结果,要明确告诉调用方别再试。
73
+ first = str(e).splitlines()[0] if str(e) else ""
74
+ kind = "超时未响应" if type(e).__name__ == "TimeoutError" else "连接失败"
75
+ return SystemExit(
76
+ f"打开页面失败({kind}):{url}\n"
77
+ f"{type(e).__name__}: {first}\n"
78
+ "服务器没有返回页面:多半是从当前网络出口无法访问该站点,或站点拒绝了本机 IP。"
79
+ "加大 timeout、换 user_agent / device / browser 通常不会有不同结果,不要对同一 URL 反复重试;"
80
+ "请让用户直接粘贴正文,或换一个可访问的链接。"
81
+ )
82
+
21
83
 
22
84
  def extract(cfg: dict) -> dict:
23
85
  """Load cfg['url'] and return the raw extraction dict from the page."""
@@ -38,19 +100,24 @@ def extract(cfg: dict) -> dict:
38
100
  if cfg.get("timeout"):
39
101
  context.set_default_timeout(cfg["timeout"])
40
102
  page = context.new_page()
103
+ gate = _SubresourceGate()
104
+ page.route("**/*", gate)
41
105
  try:
42
106
  try:
43
- response = page.goto(cfg["url"], wait_until="domcontentloaded")
107
+ # commit = 拿到主文档响应头即返回,不让任何子资源决定「页面打不打得开」。
108
+ response = page.goto(cfg["url"], wait_until="commit")
44
109
  except Exception as e:
45
- # 一条模型能据以行动的错误,胜过 30 行 Playwright traceback。
46
- # 导航失败是这个 skill 最常见的失败形态(打不开 / 超时 / DNS),
47
- # 让它读起来像"这个网址打不开",而不是像程序崩了。
48
- raise SystemExit(
49
- f"打开页面失败:{cfg['url']}\n"
50
- f"{type(e).__name__}: {str(e).splitlines()[0]}\n"
51
- "可能是网络不可达、站点屏蔽了无头浏览器,或加载超过了超时时间。"
52
- "可尝试加大 --timeout、换 --user-agent,或确认该 URL 在本机能打开。"
53
- )
110
+ raise _navigation_error(cfg["url"], e)
111
+ dom_ready = True
112
+ try:
113
+ page.wait_for_load_state("domcontentloaded", timeout=_DOM_READY_TIMEOUT_MS)
114
+ except Exception:
115
+ dom_ready = False
116
+ if gate.stalled or not dom_ready:
117
+ note = gate.summary() if gate.stalled else "部分子资源未返回"
118
+ if not dom_ready:
119
+ note += f";页面 {_DOM_READY_TIMEOUT_MS // 1000}s 内未完成解析,按当前已加载的 DOM 抽取"
120
+ print(f"[web_read] {note}", file=sys.stderr)
54
121
  status = response.status if response else None
55
122
  if status is not None and status >= 400:
56
123
  # Keep going: many sites serve real content under a 403/404
@@ -2,6 +2,6 @@
2
2
  "skillName": "web-screenshot",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "555",
5
- "version": "V7",
5
+ "version": "V8",
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
  }