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,304 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs/promises";
3
+ import { Type } from "@sinclair/typebox";
4
+ import { saveTopic } from "../storage/local-store.js";
5
+ import { browserCdpAdapter } from "../adapters/browser/browser-cdp.js";
6
+ import { researchWithTikHub } from "../adapters/research/tikhub.js";
7
+ import { runFreeResearch, type SearchResult } from "../modules/research/free-engine.js";
8
+ import type { BrowserPlatform, ResearchItem } from "../adapters/browser/types.js";
9
+
10
+ type ResearchMode = "auto" | "browser_first" | "api_fallback" | "free" | "manual";
11
+
12
+ export const researchSchema = Type.Object({
13
+ action: Type.Unsafe<"discover" | "session_status">({
14
+ type: "string",
15
+ enum: ["discover", "session_status"],
16
+ description: "Research action: discover new topics or inspect browser session status.",
17
+ }),
18
+ industry: Type.Optional(Type.String({ description: "Industry or niche. Falls back to MEMORY.md if omitted." })),
19
+ keyword: Type.Optional(Type.String({ description: "Research keyword or angle." })),
20
+ platform: Type.Optional(
21
+ Type.Unsafe<BrowserPlatform>({
22
+ type: "string",
23
+ enum: ["xiaohongshu", "douyin", "wechat_mp", "wechat_video", "bilibili"],
24
+ description: "Target platform.",
25
+ }),
26
+ ),
27
+ topic_count: Type.Optional(Type.Number({ description: "How many topics to create. Default: 3." })),
28
+ save_topics: Type.Optional(Type.Boolean({ description: "Save generated topics into ~/.autocrew/topics. Default: true." })),
29
+ mode: Type.Optional(
30
+ Type.Unsafe<ResearchMode>({
31
+ type: "string",
32
+ enum: ["auto", "browser_first", "api_fallback", "free", "manual"],
33
+ description: "Execution mode. 'free' uses web search + viral scoring (no browser/API needed). Default: auto.",
34
+ }),
35
+ ),
36
+ search_results: Type.Optional(
37
+ Type.Array(
38
+ Type.Object({
39
+ title: Type.String(),
40
+ snippet: Type.String(),
41
+ url: Type.String(),
42
+ }),
43
+ { description: "Pre-fetched search results for 'free' mode. Caller provides these from web_search." },
44
+ ),
45
+ ),
46
+ });
47
+
48
+ interface MemoryContext {
49
+ industry?: string;
50
+ competitors: string[];
51
+ }
52
+
53
+ function resolveDataDir(customDir?: string): string {
54
+ if (customDir) return customDir;
55
+ const home = process.env.HOME || process.env.USERPROFILE || "~";
56
+ return path.join(home, ".autocrew");
57
+ }
58
+
59
+ function normalizePlatform(platform?: string): BrowserPlatform {
60
+ return (platform as BrowserPlatform) || "xiaohongshu";
61
+ }
62
+
63
+ async function readMemoryContext(dataDir?: string): Promise<MemoryContext> {
64
+ const target = path.join(resolveDataDir(dataDir), "MEMORY.md");
65
+ try {
66
+ const raw = await fs.readFile(target, "utf-8");
67
+ const competitors = raw
68
+ .split("\n")
69
+ .map((line) => line.trim())
70
+ .filter((line) => line.startsWith("- "))
71
+ .map((line) => line.replace(/^- /, ""))
72
+ .filter((line) => /xhs|小红书|douyin|抖音|bilibili|b站|wechat/i.test(line));
73
+
74
+ const industryMatch =
75
+ raw.match(/industry[::]\s*(.+)/i) ||
76
+ raw.match(/定位[::]\s*(.+)/) ||
77
+ raw.match(/行业[::]\s*(.+)/);
78
+
79
+ return {
80
+ industry: industryMatch?.[1]?.trim(),
81
+ competitors,
82
+ };
83
+ } catch {
84
+ return { competitors: [] };
85
+ }
86
+ }
87
+
88
+ function truncateTitle(text: string, max = 20): string {
89
+ const compact = text.replace(/\s+/g, "").trim();
90
+ if (compact.length <= max) return compact;
91
+ return `${compact.slice(0, max - 1)}…`;
92
+ }
93
+
94
+ function buildTopicTitle(item: ResearchItem, platform: BrowserPlatform): string {
95
+ const base = item.title || `${platform}选题`;
96
+ return truncateTitle(base);
97
+ }
98
+
99
+ function buildTopicDescription(
100
+ item: ResearchItem,
101
+ industry: string,
102
+ platform: BrowserPlatform,
103
+ ): string {
104
+ const summary = item.summary?.trim() || "来自浏览器登录态下的近期内容观察";
105
+ const sourceLabel =
106
+ item.source === "browser_cdp" ? "浏览器登录态" : item.source === "api_provider" ? "API fallback" : "人工降级";
107
+ return `${summary}。适合 ${industry || "当前账号"} 在 ${platform} 上继续延展,来源:${sourceLabel}${item.author ? `,参考账号:${item.author}` : ""}。`;
108
+ }
109
+
110
+ function buildTopicTags(industry: string, platform: BrowserPlatform): string[] {
111
+ const tags = [platform];
112
+ if (industry) {
113
+ tags.push(industry);
114
+ }
115
+ if (platform === "xiaohongshu") {
116
+ tags.push("选题");
117
+ }
118
+ if (platform === "douyin") {
119
+ tags.push("短视频");
120
+ }
121
+ return Array.from(new Set(tags));
122
+ }
123
+
124
+ async function runDiscovery(params: Record<string, unknown>) {
125
+ const dataDir = (params._dataDir as string) || undefined;
126
+ const memory = await readMemoryContext(dataDir);
127
+ const industry = ((params.industry as string) || memory.industry || "").trim();
128
+ const platform = normalizePlatform(params.platform as string | undefined);
129
+ const keyword = ((params.keyword as string) || industry || "内容选题").trim();
130
+ const topicCount = Number(params.topic_count || 3);
131
+ const saveTopics = params.save_topics !== false;
132
+ const mode = (params.mode as ResearchMode) || "auto";
133
+
134
+ let items: ResearchItem[] = [];
135
+ const sourcesUsed: string[] = [];
136
+
137
+ // --- Free mode: web search + viral scoring engine ---
138
+ if (mode === "free") {
139
+ const searchResults = (params.search_results as SearchResult[] | undefined) || [];
140
+ const freeResult = await runFreeResearch({
141
+ keyword,
142
+ searchResults,
143
+ topicCount,
144
+ dataDir,
145
+ });
146
+
147
+ const topics = freeResult.candidates.map((c) => ({
148
+ title: c.title,
149
+ description: c.description,
150
+ tags: c.tags,
151
+ source: c.source,
152
+ }));
153
+
154
+ const saved = [];
155
+ if (saveTopics) {
156
+ for (const topic of topics) {
157
+ saved.push(await saveTopic(topic, dataDir));
158
+ }
159
+ }
160
+
161
+ return {
162
+ ok: true,
163
+ mode: "free",
164
+ platform,
165
+ keyword,
166
+ industry: freeResult.industry,
167
+ competitors: memory.competitors,
168
+ sourcesUsed: ["free_engine"],
169
+ topics: saveTopics ? saved : topics,
170
+ savedCount: saveTopics ? saved.length : 0,
171
+ searchQueries: freeResult.searchQueries,
172
+ filtersApplied: freeResult.filtersApplied,
173
+ summary: freeResult.summary,
174
+ candidates: freeResult.candidates,
175
+ };
176
+ }
177
+
178
+ // --- Browser-first mode (Pro path) ---
179
+ if (mode === "auto" || mode === "browser_first") {
180
+ items = await browserCdpAdapter.research({ platform, keyword, limit: topicCount });
181
+ if (items.length > 0) {
182
+ sourcesUsed.push(browserCdpAdapter.id);
183
+ }
184
+ }
185
+
186
+ if (items.length === 0 && (mode === "auto" || mode === "api_fallback")) {
187
+ items = await researchWithTikHub({ platform, keyword, limit: topicCount });
188
+ if (items.length > 0) {
189
+ sourcesUsed.push("tikhub_fallback");
190
+ }
191
+ }
192
+
193
+ // --- Auto fallback to free engine when browser + API both empty ---
194
+ if (items.length === 0 && mode === "auto") {
195
+ const searchResults = (params.search_results as SearchResult[] | undefined) || [];
196
+ if (searchResults.length > 0) {
197
+ const freeResult = await runFreeResearch({
198
+ keyword,
199
+ searchResults,
200
+ topicCount,
201
+ dataDir,
202
+ });
203
+ if (freeResult.candidates.length > 0) {
204
+ const topics = freeResult.candidates.map((c) => ({
205
+ title: c.title,
206
+ description: c.description,
207
+ tags: c.tags,
208
+ source: c.source,
209
+ }));
210
+
211
+ const saved = [];
212
+ if (saveTopics) {
213
+ for (const topic of topics) {
214
+ saved.push(await saveTopic(topic, dataDir));
215
+ }
216
+ }
217
+
218
+ return {
219
+ ok: true,
220
+ mode: "free",
221
+ platform,
222
+ keyword,
223
+ industry: freeResult.industry,
224
+ competitors: memory.competitors,
225
+ sourcesUsed: ["free_engine_auto_fallback"],
226
+ topics: saveTopics ? saved : topics,
227
+ savedCount: saveTopics ? saved.length : 0,
228
+ searchQueries: freeResult.searchQueries,
229
+ filtersApplied: freeResult.filtersApplied,
230
+ summary: freeResult.summary,
231
+ candidates: freeResult.candidates,
232
+ };
233
+ }
234
+ }
235
+ }
236
+
237
+ if (items.length === 0) {
238
+ items = Array.from({ length: topicCount }).map((_, index) => ({
239
+ title: `${keyword} 选题方向 ${index + 1}`,
240
+ summary: "手动降级模式生成,请后续结合真实平台反馈再筛一次。",
241
+ platform,
242
+ source: "manual",
243
+ }));
244
+ sourcesUsed.push("manual");
245
+ }
246
+
247
+ const topics = items.slice(0, topicCount).map((item) => ({
248
+ title: buildTopicTitle(item, platform),
249
+ description: buildTopicDescription(item, industry, platform),
250
+ tags: buildTopicTags(industry, platform),
251
+ source: `${item.source}:${platform}`,
252
+ }));
253
+
254
+ const saved = [];
255
+ if (saveTopics) {
256
+ for (const topic of topics) {
257
+ saved.push(await saveTopic(topic, dataDir));
258
+ }
259
+ }
260
+
261
+ return {
262
+ ok: true,
263
+ mode: mode === "auto" ? "browser_first" : mode,
264
+ platform,
265
+ keyword,
266
+ industry: industry || null,
267
+ competitors: memory.competitors,
268
+ sourcesUsed,
269
+ topics: saveTopics ? saved : topics,
270
+ savedCount: saveTopics ? saved.length : 0,
271
+ note:
272
+ "Current browser-first adapter is a structural runtime entry. Replace its placeholder implementation with host-specific CDP/web-access execution next.",
273
+ };
274
+ }
275
+
276
+ async function getSessionStatuses(params: Record<string, unknown>) {
277
+ const requestedPlatform = params.platform as BrowserPlatform | undefined;
278
+ const platforms: BrowserPlatform[] = requestedPlatform
279
+ ? [requestedPlatform]
280
+ : ["xiaohongshu", "douyin", "wechat_mp", "wechat_video", "bilibili"];
281
+
282
+ const items = [];
283
+ for (const platform of platforms) {
284
+ if (browserCdpAdapter.getSessionStatus) {
285
+ items.push(await browserCdpAdapter.getSessionStatus(platform));
286
+ }
287
+ }
288
+
289
+ return {
290
+ ok: true,
291
+ sessions: items,
292
+ };
293
+ }
294
+
295
+ export async function executeResearch(params: Record<string, unknown>) {
296
+ const action = (params.action as string) || "discover";
297
+ if (action === "discover") {
298
+ return runDiscovery(params);
299
+ }
300
+ if (action === "session_status") {
301
+ return getSessionStatuses(params);
302
+ }
303
+ return { ok: false, error: `Unknown action: ${action}` };
304
+ }
@@ -0,0 +1,305 @@
1
+ /**
2
+ * autocrew_review tool — content review integrating:
3
+ * 1. Sensitive word scanning
4
+ * 2. De-AI check (humanizer-zh dry-run)
5
+ * 3. Quality scoring (info density, hook strength, CTA clarity, readability)
6
+ * 4. Auto-fix (apply suggestions)
7
+ *
8
+ * PRD §6: "autocrew review 命令整合敏感词检测"
9
+ */
10
+ import { Type } from "@sinclair/typebox";
11
+ import { scanText, type ScanResult } from "../modules/filter/sensitive-words.js";
12
+ import { humanizeZh, type HumanizeZhResult } from "../modules/humanizer/zh.js";
13
+ import { getContent, updateContent, transitionStatus, normalizeLegacyStatus } from "../storage/local-store.js";
14
+
15
+ // --- Types ---
16
+
17
+ export interface QualityScore {
18
+ /** 0-100 overall */
19
+ total: number;
20
+ /** Sub-scores, each 0-25 */
21
+ infoDensity: number;
22
+ hookStrength: number;
23
+ ctaClarity: number;
24
+ readability: number;
25
+ /** Per-dimension notes */
26
+ notes: string[];
27
+ }
28
+
29
+ export interface ReviewReport {
30
+ ok: boolean;
31
+ /** Did the content pass all checks? */
32
+ passed: boolean;
33
+ sensitiveWords: ScanResult;
34
+ aiCheck: { hasAiTraces: boolean; changeCount: number; changes: string[] };
35
+ qualityScore: QualityScore;
36
+ /** Combined summary */
37
+ summary: string;
38
+ /** Suggested fixes */
39
+ fixes: string[];
40
+ /** Auto-fixed text (if available) */
41
+ autoFixedText?: string;
42
+ }
43
+
44
+ // --- Schema ---
45
+
46
+ export const reviewSchema = Type.Object({
47
+ action: Type.Unsafe<"full_review" | "scan_only" | "quality_score" | "auto_fix">({
48
+ type: "string",
49
+ enum: ["full_review", "scan_only", "quality_score", "auto_fix"],
50
+ description:
51
+ "Action: 'full_review' runs all checks, 'scan_only' runs sensitive word scan only, " +
52
+ "'quality_score' returns quality score only, 'auto_fix' applies all auto-fixable suggestions and saves.",
53
+ }),
54
+ content_id: Type.Optional(Type.String({ description: "AutoCrew content id to review." })),
55
+ text: Type.Optional(Type.String({ description: "Raw text to review directly (if no content_id)." })),
56
+ platform: Type.Optional(Type.String({ description: "Target platform for platform-specific checks." })),
57
+ });
58
+
59
+ // --- Quality Scoring ---
60
+
61
+ function scoreQuality(text: string, platform?: string): QualityScore {
62
+ const notes: string[] = [];
63
+
64
+ // --- Info Density (0-25) ---
65
+ // Penalize filler, reward concrete data points
66
+ const charCount = text.length;
67
+ const sentences = text.split(/[。!?\n]/).filter((s) => s.trim().length > 0);
68
+ const avgSentenceLen = charCount / Math.max(sentences.length, 1);
69
+ const dataPoints = (text.match(/\d+[%%万亿个条次天月年]/g) || []).length;
70
+ let infoDensity = 15; // baseline
71
+ if (dataPoints >= 3) infoDensity += 5;
72
+ else if (dataPoints >= 1) infoDensity += 2;
73
+ if (avgSentenceLen > 80) {
74
+ infoDensity -= 5;
75
+ notes.push("句子偏长,建议拆分");
76
+ }
77
+ if (avgSentenceLen < 15 && sentences.length > 3) {
78
+ infoDensity += 3;
79
+ }
80
+ // Penalize filler phrases
81
+ const fillerCount = (text.match(/值得一提|需要注意|综上所述|总而言之|可以说/g) || []).length;
82
+ if (fillerCount > 0) {
83
+ infoDensity -= Math.min(5, fillerCount * 2);
84
+ notes.push(`发现 ${fillerCount} 处套话,建议删除`);
85
+ }
86
+ infoDensity = clamp(infoDensity, 0, 25);
87
+
88
+ // --- Hook Strength (0-25) ---
89
+ const firstLine = sentences[0]?.trim() || "";
90
+ let hookStrength = 10;
91
+ // Question hook
92
+ if (/[??]/.test(firstLine)) {
93
+ hookStrength += 5;
94
+ notes.push("开头用了提问式 hook ✓");
95
+ }
96
+ // Number hook
97
+ if (/\d/.test(firstLine)) {
98
+ hookStrength += 4;
99
+ }
100
+ // Emotional trigger
101
+ if (/别再|千万|后悔|真相|没想到|居然|竟然|震惊|绝了/.test(firstLine)) {
102
+ hookStrength += 5;
103
+ }
104
+ // Short punchy opening
105
+ if (firstLine.length <= 20 && firstLine.length > 0) {
106
+ hookStrength += 3;
107
+ }
108
+ // Penalty: generic opening
109
+ if (/大家好|今天我们|在当今社会|随着.*的发展/.test(firstLine)) {
110
+ hookStrength -= 8;
111
+ notes.push("开头太泛,建议用具体场景或数据切入");
112
+ }
113
+ hookStrength = clamp(hookStrength, 0, 25);
114
+
115
+ // --- CTA Clarity (0-25) ---
116
+ const lastThird = text.slice(Math.floor(text.length * 0.7));
117
+ let ctaClarity = 10;
118
+ // Explicit CTA
119
+ if (/关注|收藏|点赞|转发|评论|私信|留言|试试|赶紧|快去/.test(lastThird)) {
120
+ ctaClarity += 8;
121
+ notes.push("结尾有明确 CTA ✓");
122
+ }
123
+ // Question CTA
124
+ if (/[??]/.test(lastThird)) {
125
+ ctaClarity += 4;
126
+ }
127
+ // No CTA at all
128
+ if (ctaClarity === 10) {
129
+ notes.push("结尾缺少 CTA,建议加引导互动的句子");
130
+ }
131
+ // Platform-specific CTA
132
+ if (platform === "xhs" || platform === "xiaohongshu") {
133
+ if (/收藏|关注/.test(lastThird)) ctaClarity += 3;
134
+ }
135
+ if (platform === "douyin") {
136
+ if (/关注|点赞/.test(lastThird)) ctaClarity += 3;
137
+ }
138
+ ctaClarity = clamp(ctaClarity, 0, 25);
139
+
140
+ // --- Readability (0-25) ---
141
+ let readability = 15;
142
+ // Paragraph count
143
+ const paragraphs = text.split(/\n{2,}/).filter((p) => p.trim());
144
+ if (paragraphs.length >= 3 && paragraphs.length <= 8) {
145
+ readability += 3;
146
+ }
147
+ // Emoji usage
148
+ const emojiCount = (text.match(/[\u{1F300}-\u{1F9FF}]/gu) || []).length;
149
+ if (emojiCount >= 1 && emojiCount <= 10) {
150
+ readability += 3;
151
+ } else if (emojiCount > 15) {
152
+ readability -= 3;
153
+ notes.push("emoji 过多,建议精简");
154
+ }
155
+ // Line breaks
156
+ if (paragraphs.some((p) => p.length > 300)) {
157
+ readability -= 5;
158
+ notes.push("有段落超过 300 字,建议拆分");
159
+ }
160
+ // Short paragraphs bonus
161
+ const shortParas = paragraphs.filter((p) => p.length <= 100).length;
162
+ if (shortParas / Math.max(paragraphs.length, 1) > 0.5) {
163
+ readability += 4;
164
+ }
165
+ readability = clamp(readability, 0, 25);
166
+
167
+ const total = infoDensity + hookStrength + ctaClarity + readability;
168
+
169
+ return { total, infoDensity, hookStrength, ctaClarity, readability, notes };
170
+ }
171
+
172
+ function clamp(v: number, min: number, max: number): number {
173
+ return Math.max(min, Math.min(max, v));
174
+ }
175
+
176
+ // --- Execute ---
177
+
178
+ export async function executeReview(params: Record<string, unknown>) {
179
+ const action = (params.action as string) || "full_review";
180
+ const dataDir = (params._dataDir as string) || undefined;
181
+ const platform = (params.platform as string) || undefined;
182
+ const contentId = params.content_id as string | undefined;
183
+
184
+ // Resolve text
185
+ let text = (params.text as string) || "";
186
+ let title = "";
187
+ if (!text && contentId) {
188
+ const content = await getContent(contentId, dataDir);
189
+ if (!content) return { ok: false, error: `Content ${contentId} not found` };
190
+ text = content.body;
191
+ title = content.title;
192
+ }
193
+ if (!text) return { ok: false, error: "text or content_id is required" };
194
+
195
+ const fullText = title ? `${title}\n\n${text}` : text;
196
+
197
+ // --- scan_only ---
198
+ if (action === "scan_only") {
199
+ const scanResult = await scanText(fullText, platform, dataDir);
200
+ return { ok: true, action, sensitiveWords: scanResult };
201
+ }
202
+
203
+ // --- quality_score ---
204
+ if (action === "quality_score") {
205
+ const score = scoreQuality(fullText, platform);
206
+ return { ok: true, action, qualityScore: score };
207
+ }
208
+
209
+ // --- auto_fix ---
210
+ if (action === "auto_fix") {
211
+ // 1. Sensitive word auto-fix
212
+ const scanResult = await scanText(fullText, platform, dataDir);
213
+ let fixedText = scanResult.autoFixedText || fullText;
214
+
215
+ // 2. Humanizer pass
216
+ const humanResult = humanizeZh({ text: fixedText });
217
+ fixedText = humanResult.humanizedText;
218
+
219
+ // Save back if content_id provided
220
+ if (contentId) {
221
+ await updateContent(contentId, { body: fixedText }, dataDir);
222
+ }
223
+
224
+ return {
225
+ ok: true,
226
+ action,
227
+ autoFixedText: fixedText,
228
+ sensitiveWordsFixed: scanResult.hits.filter((h) => h.suggestion).length,
229
+ aiFixesApplied: humanResult.changeCount,
230
+ saved: !!contentId,
231
+ };
232
+ }
233
+
234
+ // --- full_review ---
235
+ // 1. Sensitive words
236
+ const sensitiveWords = await scanText(fullText, platform, dataDir);
237
+
238
+ // 2. AI check (dry-run humanizer)
239
+ const humanResult = humanizeZh({ text: fullText });
240
+ const aiCheck = {
241
+ hasAiTraces: humanResult.changeCount > 0,
242
+ changeCount: humanResult.changeCount,
243
+ changes: humanResult.changes,
244
+ };
245
+
246
+ // 3. Quality score
247
+ const qualityScore = scoreQuality(fullText, platform);
248
+
249
+ // 4. Build fixes list
250
+ const fixes: string[] = [];
251
+ if (sensitiveWords.hitCount > 0) {
252
+ fixes.push(`修复 ${sensitiveWords.hitCount} 个敏感词`);
253
+ for (const hit of sensitiveWords.hits.slice(0, 5)) {
254
+ const fix = hit.suggestion ? `"${hit.word}" → "${hit.suggestion}"` : `删除"${hit.word}"`;
255
+ fixes.push(` - ${fix} (${hit.category})`);
256
+ }
257
+ }
258
+ if (aiCheck.hasAiTraces) {
259
+ fixes.push(`去 AI 味:${aiCheck.changeCount} 处需要修改`);
260
+ }
261
+ for (const note of qualityScore.notes) {
262
+ fixes.push(note);
263
+ }
264
+
265
+ // 5. Determine pass/fail
266
+ const passed =
267
+ sensitiveWords.hitCount === 0 &&
268
+ !aiCheck.hasAiTraces &&
269
+ qualityScore.total >= 60;
270
+
271
+ // 6. Build summary
272
+ const parts: string[] = [];
273
+ parts.push(passed ? "✅ 审核通过" : "⚠️ 审核未通过");
274
+ parts.push(`敏感词: ${sensitiveWords.hitCount === 0 ? "✓ 无" : `✗ ${sensitiveWords.hitCount} 个`}`);
275
+ parts.push(`AI 痕迹: ${aiCheck.hasAiTraces ? `✗ ${aiCheck.changeCount} 处` : "✓ 无"}`);
276
+ parts.push(`质量评分: ${qualityScore.total}/100 (信息密度${qualityScore.infoDensity} Hook${qualityScore.hookStrength} CTA${qualityScore.ctaClarity} 可读性${qualityScore.readability})`);
277
+ const summary = parts.join("\n");
278
+
279
+ // 7. Auto-transition if content_id provided
280
+ if (contentId) {
281
+ const targetStatus = passed ? "approved" : "revision";
282
+ const diffNote = passed ? undefined : fixes.join("; ");
283
+ await transitionStatus(
284
+ contentId,
285
+ normalizeLegacyStatus(targetStatus),
286
+ { diffNote },
287
+ dataDir,
288
+ ).catch(() => {
289
+ /* transition may fail if not in reviewing state — that's ok */
290
+ });
291
+ }
292
+
293
+ const report: ReviewReport = {
294
+ ok: true,
295
+ passed,
296
+ sensitiveWords,
297
+ aiCheck,
298
+ qualityScore,
299
+ summary,
300
+ fixes,
301
+ autoFixedText: sensitiveWords.autoFixedText,
302
+ };
303
+
304
+ return report;
305
+ }