autocrew 0.1.0

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.
Files changed (165) hide show
  1. package/HAMLETDEER.md +562 -0
  2. package/LICENSE +21 -0
  3. package/README.md +190 -0
  4. package/README_CN.md +190 -0
  5. package/adapters/openclaw/index.ts +68 -0
  6. package/bin/autocrew.mjs +23 -0
  7. package/bin/autocrew.ts +13 -0
  8. package/openclaw.plugin.json +36 -0
  9. package/package.json +74 -0
  10. package/skills/_writing-style/SKILL.md +68 -0
  11. package/skills/audience-profiler/SKILL.md +241 -0
  12. package/skills/content-attribution/SKILL.md +128 -0
  13. package/skills/content-review/SKILL.md +257 -0
  14. package/skills/cover-generator/SKILL.md +93 -0
  15. package/skills/humanizer-zh/SKILL.md +75 -0
  16. package/skills/intel-digest/SKILL.md +57 -0
  17. package/skills/intel-pull/SKILL.md +74 -0
  18. package/skills/manage-pipeline/SKILL.md +63 -0
  19. package/skills/memory-distill/SKILL.md +89 -0
  20. package/skills/onboarding/SKILL.md +117 -0
  21. package/skills/pipeline-status/SKILL.md +51 -0
  22. package/skills/platform-rewrite/SKILL.md +125 -0
  23. package/skills/pre-publish/SKILL.md +142 -0
  24. package/skills/publish-content/SKILL.md +500 -0
  25. package/skills/remix-content/SKILL.md +77 -0
  26. package/skills/research/SKILL.md +127 -0
  27. package/skills/setup/SKILL.md +353 -0
  28. package/skills/spawn-batch-writer/SKILL.md +66 -0
  29. package/skills/spawn-planner/SKILL.md +72 -0
  30. package/skills/spawn-writer/SKILL.md +60 -0
  31. package/skills/teardown/SKILL.md +144 -0
  32. package/skills/title-craft/SKILL.md +234 -0
  33. package/skills/topic-ideas/SKILL.md +105 -0
  34. package/skills/video-timeline/SKILL.md +117 -0
  35. package/skills/write-script/SKILL.md +232 -0
  36. package/skills/xhs-cover-review/SKILL.md +48 -0
  37. package/src/adapters/browser/browser-cdp.ts +260 -0
  38. package/src/adapters/browser/browser-relay.ts +236 -0
  39. package/src/adapters/browser/gateway-client.ts +148 -0
  40. package/src/adapters/browser/types.ts +36 -0
  41. package/src/adapters/image/gemini.ts +219 -0
  42. package/src/adapters/research/tikhub.ts +19 -0
  43. package/src/cli/banner.ts +18 -0
  44. package/src/cli/bootstrap.ts +33 -0
  45. package/src/cli/commands/adapt.ts +28 -0
  46. package/src/cli/commands/advance.ts +28 -0
  47. package/src/cli/commands/assets.ts +24 -0
  48. package/src/cli/commands/audit.ts +18 -0
  49. package/src/cli/commands/contents.ts +18 -0
  50. package/src/cli/commands/cover.ts +58 -0
  51. package/src/cli/commands/events.ts +17 -0
  52. package/src/cli/commands/humanize.ts +27 -0
  53. package/src/cli/commands/index.ts +80 -0
  54. package/src/cli/commands/init.ts +28 -0
  55. package/src/cli/commands/intel.ts +55 -0
  56. package/src/cli/commands/learn.ts +34 -0
  57. package/src/cli/commands/memory.ts +18 -0
  58. package/src/cli/commands/migrate.ts +24 -0
  59. package/src/cli/commands/open.ts +21 -0
  60. package/src/cli/commands/pipelines.ts +18 -0
  61. package/src/cli/commands/pre-publish.ts +27 -0
  62. package/src/cli/commands/profile.ts +31 -0
  63. package/src/cli/commands/research.ts +36 -0
  64. package/src/cli/commands/restore.ts +28 -0
  65. package/src/cli/commands/review.ts +61 -0
  66. package/src/cli/commands/start.ts +28 -0
  67. package/src/cli/commands/status.ts +14 -0
  68. package/src/cli/commands/templates.ts +15 -0
  69. package/src/cli/commands/topics.ts +18 -0
  70. package/src/cli/commands/trash.ts +28 -0
  71. package/src/cli/commands/upgrade.ts +48 -0
  72. package/src/cli/commands/versions.ts +24 -0
  73. package/src/cli/index.ts +40 -0
  74. package/src/data/sensitive-words-builtin.json +114 -0
  75. package/src/data/source-presets.yaml +54 -0
  76. package/src/e2e.test.ts +596 -0
  77. package/src/modules/auth/cookie-manager.ts +113 -0
  78. package/src/modules/cards/template-engine.ts +74 -0
  79. package/src/modules/cards/templates/comparison-table.ts +71 -0
  80. package/src/modules/cards/templates/data-chart.ts +76 -0
  81. package/src/modules/cards/templates/flow-chart.ts +49 -0
  82. package/src/modules/cards/templates/key-points.ts +59 -0
  83. package/src/modules/cover/prompt-builder.test.ts +157 -0
  84. package/src/modules/cover/prompt-builder.ts +212 -0
  85. package/src/modules/cover/ratio-adapter.test.ts +122 -0
  86. package/src/modules/cover/ratio-adapter.ts +104 -0
  87. package/src/modules/filter/sensitive-words.test.ts +72 -0
  88. package/src/modules/filter/sensitive-words.ts +212 -0
  89. package/src/modules/humanizer/zh.test.ts +75 -0
  90. package/src/modules/humanizer/zh.ts +175 -0
  91. package/src/modules/intel/collector.ts +19 -0
  92. package/src/modules/intel/collectors/competitor.test.ts +71 -0
  93. package/src/modules/intel/collectors/competitor.ts +65 -0
  94. package/src/modules/intel/collectors/rss.test.ts +56 -0
  95. package/src/modules/intel/collectors/rss.ts +70 -0
  96. package/src/modules/intel/collectors/trends.test.ts +80 -0
  97. package/src/modules/intel/collectors/trends.ts +107 -0
  98. package/src/modules/intel/collectors/web-search.test.ts +85 -0
  99. package/src/modules/intel/collectors/web-search.ts +81 -0
  100. package/src/modules/intel/integration.test.ts +203 -0
  101. package/src/modules/intel/intel-engine.test.ts +103 -0
  102. package/src/modules/intel/intel-engine.ts +96 -0
  103. package/src/modules/intel/source-config.test.ts +113 -0
  104. package/src/modules/intel/source-config.ts +131 -0
  105. package/src/modules/learnings/diff-tracker.test.ts +144 -0
  106. package/src/modules/learnings/diff-tracker.ts +189 -0
  107. package/src/modules/learnings/rule-distiller.ts +141 -0
  108. package/src/modules/memory/distill.ts +208 -0
  109. package/src/modules/migrate/legacy-migrate.test.ts +169 -0
  110. package/src/modules/migrate/legacy-migrate.ts +229 -0
  111. package/src/modules/pro/api-client.ts +192 -0
  112. package/src/modules/pro/gate.test.ts +110 -0
  113. package/src/modules/pro/gate.ts +104 -0
  114. package/src/modules/profile/creator-profile.test.ts +178 -0
  115. package/src/modules/profile/creator-profile.ts +248 -0
  116. package/src/modules/publish/douyin-api.ts +34 -0
  117. package/src/modules/publish/wechat-mp.ts +320 -0
  118. package/src/modules/publish/xiaohongshu-api.ts +127 -0
  119. package/src/modules/research/free-engine.ts +360 -0
  120. package/src/modules/timeline/markup-generator.ts +63 -0
  121. package/src/modules/timeline/parser.ts +275 -0
  122. package/src/modules/workflow/templates.ts +124 -0
  123. package/src/modules/writing/platform-rewrite.ts +190 -0
  124. package/src/modules/writing/title-hashtag.ts +385 -0
  125. package/src/runtime/context.test.ts +97 -0
  126. package/src/runtime/context.ts +129 -0
  127. package/src/runtime/events.test.ts +83 -0
  128. package/src/runtime/events.ts +104 -0
  129. package/src/runtime/hooks.ts +174 -0
  130. package/src/runtime/tool-runner.test.ts +204 -0
  131. package/src/runtime/tool-runner.ts +282 -0
  132. package/src/runtime/workflow-engine.test.ts +455 -0
  133. package/src/runtime/workflow-engine.ts +391 -0
  134. package/src/server/index.ts +409 -0
  135. package/src/server/start.ts +39 -0
  136. package/src/storage/local-store.test.ts +304 -0
  137. package/src/storage/local-store.ts +704 -0
  138. package/src/storage/pipeline-store.test.ts +363 -0
  139. package/src/storage/pipeline-store.ts +698 -0
  140. package/src/tools/asset.ts +96 -0
  141. package/src/tools/content-save.ts +276 -0
  142. package/src/tools/cover-review.ts +221 -0
  143. package/src/tools/humanize.ts +54 -0
  144. package/src/tools/init.ts +133 -0
  145. package/src/tools/intel.ts +92 -0
  146. package/src/tools/memory.ts +76 -0
  147. package/src/tools/pipeline-ops.ts +109 -0
  148. package/src/tools/pipeline.ts +168 -0
  149. package/src/tools/pre-publish.ts +232 -0
  150. package/src/tools/publish.ts +183 -0
  151. package/src/tools/registry.ts +198 -0
  152. package/src/tools/research.ts +304 -0
  153. package/src/tools/review.ts +305 -0
  154. package/src/tools/rewrite.ts +165 -0
  155. package/src/tools/status.ts +30 -0
  156. package/src/tools/timeline.ts +234 -0
  157. package/src/tools/topic-create.ts +50 -0
  158. package/src/types/providers.ts +69 -0
  159. package/src/types/timeline.test.ts +147 -0
  160. package/src/types/timeline.ts +83 -0
  161. package/src/utils/retry.test.ts +97 -0
  162. package/src/utils/retry.ts +85 -0
  163. package/templates/AGENTS.md +99 -0
  164. package/templates/SOUL.md +31 -0
  165. package/templates/TOOLS.md +76 -0
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Cookie manager — stores and retrieves platform cookies for AutoCrew.
3
+ *
4
+ * Cookies are stored at ~/.autocrew/auth/{platform}.json, encrypted with
5
+ * a key derived from the machine ID (hostname + username).
6
+ */
7
+ import path from "node:path";
8
+ import os from "node:os";
9
+ import fs from "node:fs/promises";
10
+ import crypto from "node:crypto";
11
+
12
+ const AUTH_DIR = path.join(os.homedir(), ".autocrew", "auth");
13
+ const ALGORITHM = "aes-256-gcm";
14
+
15
+ interface StoredCookie {
16
+ ciphertext: string;
17
+ iv: string;
18
+ tag: string;
19
+ savedAt: string;
20
+ }
21
+
22
+ function deriveKey(): Buffer {
23
+ const machineId = `${os.hostname()}:${os.userInfo().username}:autocrew-cookie-v1`;
24
+ return crypto.createHash("sha256").update(machineId).digest();
25
+ }
26
+
27
+ function encrypt(plaintext: string): { ciphertext: string; iv: string; tag: string } {
28
+ const key = deriveKey();
29
+ const iv = crypto.randomBytes(16);
30
+ const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
31
+ let encrypted = cipher.update(plaintext, "utf8", "hex");
32
+ encrypted += cipher.final("hex");
33
+ const tag = cipher.getAuthTag();
34
+ return {
35
+ ciphertext: encrypted,
36
+ iv: iv.toString("hex"),
37
+ tag: tag.toString("hex"),
38
+ };
39
+ }
40
+
41
+ function decrypt(data: { ciphertext: string; iv: string; tag: string }): string {
42
+ const key = deriveKey();
43
+ const iv = Buffer.from(data.iv, "hex");
44
+ const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
45
+ decipher.setAuthTag(Buffer.from(data.tag, "hex"));
46
+ let decrypted = decipher.update(data.ciphertext, "hex", "utf8");
47
+ decrypted += decipher.final("utf8");
48
+ return decrypted;
49
+ }
50
+
51
+ function cookieFilePath(platform: string): string {
52
+ return path.join(AUTH_DIR, `${platform}.json`);
53
+ }
54
+
55
+ export async function saveCookie(platform: string, cookie: string): Promise<void> {
56
+ await fs.mkdir(AUTH_DIR, { recursive: true });
57
+ const encrypted = encrypt(cookie);
58
+ const stored: StoredCookie = {
59
+ ...encrypted,
60
+ savedAt: new Date().toISOString(),
61
+ };
62
+ await fs.writeFile(cookieFilePath(platform), JSON.stringify(stored, null, 2), "utf-8");
63
+ }
64
+
65
+ export async function loadCookie(platform: string): Promise<string | null> {
66
+ try {
67
+ const raw = await fs.readFile(cookieFilePath(platform), "utf-8");
68
+ const stored: StoredCookie = JSON.parse(raw);
69
+ return decrypt(stored);
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ export type CookieHealthStatus = "valid" | "missing" | "decrypt_failed" | "incomplete";
76
+
77
+ export interface CookieHealthResult {
78
+ status: CookieHealthStatus;
79
+ missingFields?: string[];
80
+ }
81
+
82
+ const REQUIRED_FIELDS: Record<string, string[]> = {
83
+ xiaohongshu: ["a1", "web_session"],
84
+ };
85
+
86
+ export async function checkCookieHealth(platform: string): Promise<CookieHealthResult> {
87
+ const cookie = await loadCookie(platform);
88
+ if (cookie === null) {
89
+ // Distinguish between missing file and decryption failure
90
+ try {
91
+ await fs.access(cookieFilePath(platform));
92
+ return { status: "decrypt_failed" };
93
+ } catch {
94
+ return { status: "missing" };
95
+ }
96
+ }
97
+
98
+ const requiredFields = REQUIRED_FIELDS[platform];
99
+ if (!requiredFields) {
100
+ return { status: "valid" };
101
+ }
102
+
103
+ const cookieFields = new Set(
104
+ cookie.split(";").map((p) => p.trim().split("=")[0]?.trim()).filter(Boolean),
105
+ );
106
+
107
+ const missing = requiredFields.filter((f) => !cookieFields.has(f));
108
+ if (missing.length > 0) {
109
+ return { status: "incomplete", missingFields: missing };
110
+ }
111
+
112
+ return { status: "valid" };
113
+ }
@@ -0,0 +1,74 @@
1
+ // Card template engine — renders HTML knowledge cards for video overlay
2
+
3
+ import type { AspectRatio, CardTemplate } from "../../types/timeline.js";
4
+ import { render as comparisonTable } from "./templates/comparison-table.js";
5
+ import { render as keyPoints } from "./templates/key-points.js";
6
+ import { render as flowChart } from "./templates/flow-chart.js";
7
+ import { render as dataChart } from "./templates/data-chart.js";
8
+
9
+ const DIMENSIONS: Record<AspectRatio, { width: number; height: number }> = {
10
+ "9:16": { width: 1080, height: 1920 },
11
+ "16:9": { width: 1920, height: 1080 },
12
+ "3:4": { width: 1080, height: 1440 },
13
+ "1:1": { width: 1080, height: 1080 },
14
+ "4:3": { width: 1440, height: 1080 },
15
+ };
16
+
17
+ const renderers: Record<CardTemplate, (data: Record<string, unknown>) => string> = {
18
+ "comparison-table": comparisonTable,
19
+ "key-points": keyPoints,
20
+ "flow-chart": flowChart,
21
+ "data-chart": dataChart,
22
+ };
23
+
24
+ export interface RenderCardOptions {
25
+ aspectRatio?: AspectRatio;
26
+ }
27
+
28
+ /**
29
+ * Render a knowledge card as a complete HTML document.
30
+ * The output is designed to be screenshotted by Puppeteer at the exact
31
+ * pixel dimensions matching the chosen aspect ratio.
32
+ */
33
+ export function renderCard(
34
+ template: CardTemplate,
35
+ data: Record<string, unknown>,
36
+ options?: RenderCardOptions
37
+ ): string {
38
+ const renderer = renderers[template];
39
+ if (!renderer) {
40
+ throw new Error(`Unknown card template: ${template}`);
41
+ }
42
+
43
+ const ratio = options?.aspectRatio ?? "9:16";
44
+ const { width, height } = DIMENSIONS[ratio];
45
+ const body = renderer(data);
46
+
47
+ return `<!DOCTYPE html>
48
+ <html lang="zh-CN">
49
+ <head>
50
+ <meta charset="UTF-8">
51
+ <meta name="viewport" content="width=${width}">
52
+ <style>
53
+ * { margin: 0; padding: 0; box-sizing: border-box; }
54
+ html, body {
55
+ width: ${width}px;
56
+ height: ${height}px;
57
+ overflow: hidden;
58
+ }
59
+ body {
60
+ background: #ffffff;
61
+ font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
62
+ display: flex;
63
+ align-items: center;
64
+ justify-content: center;
65
+ padding: 80px;
66
+ color: #1e293b;
67
+ }
68
+ </style>
69
+ </head>
70
+ <body>
71
+ ${body}
72
+ </body>
73
+ </html>`;
74
+ }
@@ -0,0 +1,71 @@
1
+ // Comparison table template — renders a table with pros/cons columns
2
+
3
+ export function render(data: Record<string, unknown>): string {
4
+ const title = (data.title as string) ?? "";
5
+ const rows = (data.rows as Array<{ name: string; pros: string; cons: string }>) ?? [];
6
+
7
+ const rowsHtml = rows
8
+ .map(
9
+ (r) => `
10
+ <tr>
11
+ <td class="name">${esc(r.name)}</td>
12
+ <td class="pros">${esc(r.pros)}</td>
13
+ <td class="cons">${esc(r.cons)}</td>
14
+ </tr>`
15
+ )
16
+ .join("\n");
17
+
18
+ return `
19
+ <div class="comparison-table">
20
+ <h1>${esc(title)}</h1>
21
+ <table>
22
+ <thead>
23
+ <tr>
24
+ <th>Item</th>
25
+ <th class="pros">Pros</th>
26
+ <th class="cons">Cons</th>
27
+ </tr>
28
+ </thead>
29
+ <tbody>
30
+ ${rowsHtml}
31
+ </tbody>
32
+ </table>
33
+ </div>
34
+ <style>
35
+ .comparison-table h1 {
36
+ font-size: 48px;
37
+ margin-bottom: 40px;
38
+ color: #1a1a2e;
39
+ }
40
+ table {
41
+ width: 100%;
42
+ border-collapse: collapse;
43
+ font-size: 28px;
44
+ }
45
+ th, td {
46
+ padding: 20px 24px;
47
+ text-align: left;
48
+ border-bottom: 1px solid #e5e7eb;
49
+ }
50
+ th {
51
+ background: #f8fafc;
52
+ font-weight: 700;
53
+ color: #475569;
54
+ font-size: 24px;
55
+ text-transform: uppercase;
56
+ letter-spacing: 1px;
57
+ }
58
+ th.pros, td.pros { color: #16a34a; }
59
+ th.cons, td.cons { color: #dc2626; }
60
+ td.name { font-weight: 600; color: #1e293b; }
61
+ </style>
62
+ `;
63
+ }
64
+
65
+ function esc(s: string): string {
66
+ return s
67
+ .replace(/&/g, "&amp;")
68
+ .replace(/</g, "&lt;")
69
+ .replace(/>/g, "&gt;")
70
+ .replace(/"/g, "&quot;");
71
+ }
@@ -0,0 +1,76 @@
1
+ // Data chart template — horizontal bar chart
2
+
3
+ export function render(data: Record<string, unknown>): string {
4
+ const title = (data.title as string) ?? "";
5
+ const items = (data.items as Array<{ label: string; value: number }>) ?? [];
6
+ const maxValue = Math.max(...items.map((i) => i.value), 1);
7
+
8
+ const barsHtml = items
9
+ .map((item) => {
10
+ const pct = Math.round((item.value / maxValue) * 100);
11
+ return `
12
+ <div class="bar-row">
13
+ <span class="label">${esc(item.label)}</span>
14
+ <div class="bar-track">
15
+ <div class="bar-fill" style="width: ${pct}%"></div>
16
+ </div>
17
+ <span class="value">${item.value}</span>
18
+ </div>`;
19
+ })
20
+ .join("\n");
21
+
22
+ return `
23
+ <div class="data-chart">
24
+ <h1>${esc(title)}</h1>
25
+ ${barsHtml}
26
+ </div>
27
+ <style>
28
+ .data-chart h1 {
29
+ font-size: 48px;
30
+ margin-bottom: 40px;
31
+ color: #1a1a2e;
32
+ }
33
+ .bar-row {
34
+ display: flex;
35
+ align-items: center;
36
+ gap: 20px;
37
+ margin-bottom: 24px;
38
+ }
39
+ .label {
40
+ width: 180px;
41
+ flex-shrink: 0;
42
+ font-size: 26px;
43
+ font-weight: 600;
44
+ color: #334155;
45
+ text-align: right;
46
+ }
47
+ .bar-track {
48
+ flex: 1;
49
+ height: 40px;
50
+ background: #f1f5f9;
51
+ border-radius: 8px;
52
+ overflow: hidden;
53
+ }
54
+ .bar-fill {
55
+ height: 100%;
56
+ background: #3b82f6;
57
+ border-radius: 8px;
58
+ transition: width 0.3s;
59
+ }
60
+ .value {
61
+ width: 80px;
62
+ font-size: 26px;
63
+ font-weight: 700;
64
+ color: #3b82f6;
65
+ }
66
+ </style>
67
+ `;
68
+ }
69
+
70
+ function esc(s: string): string {
71
+ return s
72
+ .replace(/&/g, "&amp;")
73
+ .replace(/</g, "&lt;")
74
+ .replace(/>/g, "&gt;")
75
+ .replace(/"/g, "&quot;");
76
+ }
@@ -0,0 +1,49 @@
1
+ // Flow chart template — horizontal steps with arrows
2
+
3
+ export function render(data: Record<string, unknown>): string {
4
+ const steps = (data.steps as string[]) ?? [];
5
+
6
+ const stepsHtml = steps
7
+ .map((step, i) => {
8
+ const arrow = i < steps.length - 1 ? '<span class="arrow">\u2192</span>' : "";
9
+ return `<span class="step">${esc(step)}</span>${arrow}`;
10
+ })
11
+ .join("\n");
12
+
13
+ return `
14
+ <div class="flow-chart">
15
+ ${stepsHtml}
16
+ </div>
17
+ <style>
18
+ .flow-chart {
19
+ display: flex;
20
+ align-items: center;
21
+ justify-content: center;
22
+ flex-wrap: wrap;
23
+ gap: 24px;
24
+ }
25
+ .step {
26
+ background: #eff6ff;
27
+ border: 2px solid #3b82f6;
28
+ border-radius: 16px;
29
+ padding: 24px 36px;
30
+ font-size: 28px;
31
+ font-weight: 600;
32
+ color: #1e40af;
33
+ }
34
+ .arrow {
35
+ font-size: 40px;
36
+ color: #3b82f6;
37
+ font-weight: 700;
38
+ }
39
+ </style>
40
+ `;
41
+ }
42
+
43
+ function esc(s: string): string {
44
+ return s
45
+ .replace(/&/g, "&amp;")
46
+ .replace(/</g, "&lt;")
47
+ .replace(/>/g, "&gt;")
48
+ .replace(/"/g, "&quot;");
49
+ }
@@ -0,0 +1,59 @@
1
+ // Key points template — numbered list with colored circle numbers
2
+
3
+ export function render(data: Record<string, unknown>): string {
4
+ const items = (data.items as string[]) ?? [];
5
+
6
+ const itemsHtml = items
7
+ .map(
8
+ (item, i) => `
9
+ <div class="point">
10
+ <span class="num">${i + 1}</span>
11
+ <span class="text">${esc(item)}</span>
12
+ </div>`
13
+ )
14
+ .join("\n");
15
+
16
+ return `
17
+ <div class="key-points">
18
+ ${itemsHtml}
19
+ </div>
20
+ <style>
21
+ .key-points {
22
+ display: flex;
23
+ flex-direction: column;
24
+ gap: 32px;
25
+ }
26
+ .point {
27
+ display: flex;
28
+ align-items: center;
29
+ gap: 24px;
30
+ }
31
+ .num {
32
+ flex-shrink: 0;
33
+ width: 56px;
34
+ height: 56px;
35
+ border-radius: 50%;
36
+ background: #3b82f6;
37
+ color: #fff;
38
+ font-size: 28px;
39
+ font-weight: 700;
40
+ display: flex;
41
+ align-items: center;
42
+ justify-content: center;
43
+ }
44
+ .text {
45
+ font-size: 32px;
46
+ color: #1e293b;
47
+ line-height: 1.5;
48
+ }
49
+ </style>
50
+ `;
51
+ }
52
+
53
+ function esc(s: string): string {
54
+ return s
55
+ .replace(/&/g, "&amp;")
56
+ .replace(/</g, "&lt;")
57
+ .replace(/>/g, "&gt;")
58
+ .replace(/"/g, "&quot;");
59
+ }
@@ -0,0 +1,157 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ buildCoverPrompts,
4
+ extractCoverTitle,
5
+ type PromptBuilderInput,
6
+ } from "../cover/prompt-builder.js";
7
+
8
+ describe("extractCoverTitle", () => {
9
+ it("returns custom title when valid length", () => {
10
+ expect(extractCoverTitle("很长的原始标题", "短标题")).toBe("短标题");
11
+ });
12
+
13
+ it("ignores custom title that is too short", () => {
14
+ expect(extractCoverTitle("原始标题", "x")).not.toBe("x");
15
+ });
16
+
17
+ it("ignores custom title that is too long", () => {
18
+ expect(extractCoverTitle("原始标题", "这个标题超过了八个字符的限制")).not.toBe("这个标题超过了八个字符的限制");
19
+ });
20
+
21
+ it("returns short titles as-is", () => {
22
+ expect(extractCoverTitle("AI赚钱")).toBe("AI赚钱");
23
+ });
24
+
25
+ it("strips brackets and punctuation", () => {
26
+ const result = extractCoverTitle("【重磅】发布!");
27
+ expect(result).not.toContain("【");
28
+ expect(result).not.toContain("】");
29
+ });
30
+
31
+ it("picks a segment from long titles", () => {
32
+ const result = extractCoverTitle("如何用AI工具,三天做出一个产品,月入过万");
33
+ expect(result.length).toBeLessThanOrEqual(8);
34
+ expect(result.length).toBeGreaterThanOrEqual(2);
35
+ });
36
+
37
+ it("falls back to first 6 chars for unsplittable long titles", () => {
38
+ const result = extractCoverTitle("这是一个完全没有分隔符的超级长标题内容");
39
+ expect(result.length).toBeLessThanOrEqual(8);
40
+ });
41
+ });
42
+
43
+ describe("buildCoverPrompts", () => {
44
+ const baseInput: PromptBuilderInput = {
45
+ title: "AI时代如何用工具赚钱",
46
+ body: "在这个AI快速发展的时代,普通人也可以利用各种AI工具来创造收入。本文将分享三个实战案例。",
47
+ platform: "xhs",
48
+ hasReferencePhotos: false,
49
+ };
50
+
51
+ it("returns exactly 3 prompt sets", () => {
52
+ const prompts = buildCoverPrompts(baseInput);
53
+ expect(prompts).toHaveLength(3);
54
+ });
55
+
56
+ it("labels are A, B, C", () => {
57
+ const prompts = buildCoverPrompts(baseInput);
58
+ expect(prompts.map((p) => p.label)).toEqual(["A", "B", "C"]);
59
+ });
60
+
61
+ it("styles are cinematic, minimalist, bold-impact", () => {
62
+ const prompts = buildCoverPrompts(baseInput);
63
+ expect(prompts.map((p) => p.style)).toEqual(["cinematic", "minimalist", "bold-impact"]);
64
+ });
65
+
66
+ it("all prompts contain the title text", () => {
67
+ const prompts = buildCoverPrompts(baseInput);
68
+ for (const p of prompts) {
69
+ expect(p.imagePrompt).toContain(p.titleText);
70
+ }
71
+ });
72
+
73
+ it("all prompts specify 3:4 vertical orientation", () => {
74
+ const prompts = buildCoverPrompts(baseInput);
75
+ for (const p of prompts) {
76
+ expect(p.imagePrompt).toContain("3:4");
77
+ expect(p.imagePrompt).toContain("Vertical");
78
+ }
79
+ });
80
+
81
+ it("all prompts include prohibition rules", () => {
82
+ const prompts = buildCoverPrompts(baseInput);
83
+ for (const p of prompts) {
84
+ expect(p.imagePrompt).toContain("No watermarks");
85
+ expect(p.imagePrompt).toContain("photorealistic");
86
+ }
87
+ });
88
+
89
+ it("titleText is 2-8 chars", () => {
90
+ const prompts = buildCoverPrompts(baseInput);
91
+ for (const p of prompts) {
92
+ expect(p.titleText.length).toBeGreaterThanOrEqual(2);
93
+ expect(p.titleText.length).toBeLessThanOrEqual(8);
94
+ }
95
+ });
96
+
97
+ it("includes reference photo instruction when hasReferencePhotos is true", () => {
98
+ const input: PromptBuilderInput = {
99
+ ...baseInput,
100
+ title: "我的创业故事",
101
+ body: "我从零开始创业的经历",
102
+ hasReferencePhotos: true,
103
+ };
104
+ const prompts = buildCoverPrompts(input);
105
+ // Person-type subject with reference photos should mention reference
106
+ const hasRef = prompts.some((p) => p.imagePrompt.includes("reference photo"));
107
+ expect(hasRef).toBe(true);
108
+ });
109
+
110
+ it("does NOT include reference photo instruction when hasReferencePhotos is false", () => {
111
+ const prompts = buildCoverPrompts(baseInput);
112
+ for (const p of prompts) {
113
+ expect(p.imagePrompt).not.toContain("reference photo");
114
+ }
115
+ });
116
+
117
+ it("detects person subject type", () => {
118
+ const input: PromptBuilderInput = {
119
+ ...baseInput,
120
+ title: "我如何从零到一",
121
+ body: "我的个人经历分享",
122
+ hasReferencePhotos: false,
123
+ };
124
+ const prompts = buildCoverPrompts(input);
125
+ // Person subject should use person layout
126
+ expect(prompts[0].layoutHint).toContain("person");
127
+ });
128
+
129
+ it("detects event subject type", () => {
130
+ const input: PromptBuilderInput = {
131
+ ...baseInput,
132
+ title: "突发!新政策发布",
133
+ body: "今天刚刚发布的最新政策",
134
+ hasReferencePhotos: false,
135
+ };
136
+ const prompts = buildCoverPrompts(input);
137
+ expect(prompts[0].layoutHint).toContain("moment");
138
+ });
139
+
140
+ it("uses custom title when provided", () => {
141
+ const input: PromptBuilderInput = {
142
+ ...baseInput,
143
+ customTitle: "AI赚钱",
144
+ };
145
+ const prompts = buildCoverPrompts(input);
146
+ for (const p of prompts) {
147
+ expect(p.titleText).toBe("AI赚钱");
148
+ }
149
+ });
150
+
151
+ it("each style has different prompt content", () => {
152
+ const prompts = buildCoverPrompts(baseInput);
153
+ const promptTexts = prompts.map((p) => p.imagePrompt);
154
+ // All 3 should be different
155
+ expect(new Set(promptTexts).size).toBe(3);
156
+ });
157
+ });