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.
- package/HAMLETDEER.md +562 -0
- package/LICENSE +21 -0
- package/README.md +190 -0
- package/README_CN.md +190 -0
- package/adapters/openclaw/index.ts +68 -0
- package/bin/autocrew.mjs +23 -0
- package/bin/autocrew.ts +13 -0
- package/openclaw.plugin.json +36 -0
- package/package.json +74 -0
- package/skills/_writing-style/SKILL.md +68 -0
- package/skills/audience-profiler/SKILL.md +241 -0
- package/skills/content-attribution/SKILL.md +128 -0
- package/skills/content-review/SKILL.md +257 -0
- package/skills/cover-generator/SKILL.md +93 -0
- package/skills/humanizer-zh/SKILL.md +75 -0
- package/skills/intel-digest/SKILL.md +57 -0
- package/skills/intel-pull/SKILL.md +74 -0
- package/skills/manage-pipeline/SKILL.md +63 -0
- package/skills/memory-distill/SKILL.md +89 -0
- package/skills/onboarding/SKILL.md +117 -0
- package/skills/pipeline-status/SKILL.md +51 -0
- package/skills/platform-rewrite/SKILL.md +125 -0
- package/skills/pre-publish/SKILL.md +142 -0
- package/skills/publish-content/SKILL.md +500 -0
- package/skills/remix-content/SKILL.md +77 -0
- package/skills/research/SKILL.md +127 -0
- package/skills/setup/SKILL.md +353 -0
- package/skills/spawn-batch-writer/SKILL.md +66 -0
- package/skills/spawn-planner/SKILL.md +72 -0
- package/skills/spawn-writer/SKILL.md +60 -0
- package/skills/teardown/SKILL.md +144 -0
- package/skills/title-craft/SKILL.md +234 -0
- package/skills/topic-ideas/SKILL.md +105 -0
- package/skills/video-timeline/SKILL.md +117 -0
- package/skills/write-script/SKILL.md +232 -0
- package/skills/xhs-cover-review/SKILL.md +48 -0
- package/src/adapters/browser/browser-cdp.ts +260 -0
- package/src/adapters/browser/browser-relay.ts +236 -0
- package/src/adapters/browser/gateway-client.ts +148 -0
- package/src/adapters/browser/types.ts +36 -0
- package/src/adapters/image/gemini.ts +219 -0
- package/src/adapters/research/tikhub.ts +19 -0
- package/src/cli/banner.ts +18 -0
- package/src/cli/bootstrap.ts +33 -0
- package/src/cli/commands/adapt.ts +28 -0
- package/src/cli/commands/advance.ts +28 -0
- package/src/cli/commands/assets.ts +24 -0
- package/src/cli/commands/audit.ts +18 -0
- package/src/cli/commands/contents.ts +18 -0
- package/src/cli/commands/cover.ts +58 -0
- package/src/cli/commands/events.ts +17 -0
- package/src/cli/commands/humanize.ts +27 -0
- package/src/cli/commands/index.ts +80 -0
- package/src/cli/commands/init.ts +28 -0
- package/src/cli/commands/intel.ts +55 -0
- package/src/cli/commands/learn.ts +34 -0
- package/src/cli/commands/memory.ts +18 -0
- package/src/cli/commands/migrate.ts +24 -0
- package/src/cli/commands/open.ts +21 -0
- package/src/cli/commands/pipelines.ts +18 -0
- package/src/cli/commands/pre-publish.ts +27 -0
- package/src/cli/commands/profile.ts +31 -0
- package/src/cli/commands/research.ts +36 -0
- package/src/cli/commands/restore.ts +28 -0
- package/src/cli/commands/review.ts +61 -0
- package/src/cli/commands/start.ts +28 -0
- package/src/cli/commands/status.ts +14 -0
- package/src/cli/commands/templates.ts +15 -0
- package/src/cli/commands/topics.ts +18 -0
- package/src/cli/commands/trash.ts +28 -0
- package/src/cli/commands/upgrade.ts +48 -0
- package/src/cli/commands/versions.ts +24 -0
- package/src/cli/index.ts +40 -0
- package/src/data/sensitive-words-builtin.json +114 -0
- package/src/data/source-presets.yaml +54 -0
- package/src/e2e.test.ts +596 -0
- package/src/modules/auth/cookie-manager.ts +113 -0
- package/src/modules/cards/template-engine.ts +74 -0
- package/src/modules/cards/templates/comparison-table.ts +71 -0
- package/src/modules/cards/templates/data-chart.ts +76 -0
- package/src/modules/cards/templates/flow-chart.ts +49 -0
- package/src/modules/cards/templates/key-points.ts +59 -0
- package/src/modules/cover/prompt-builder.test.ts +157 -0
- package/src/modules/cover/prompt-builder.ts +212 -0
- package/src/modules/cover/ratio-adapter.test.ts +122 -0
- package/src/modules/cover/ratio-adapter.ts +104 -0
- package/src/modules/filter/sensitive-words.test.ts +72 -0
- package/src/modules/filter/sensitive-words.ts +212 -0
- package/src/modules/humanizer/zh.test.ts +75 -0
- package/src/modules/humanizer/zh.ts +175 -0
- package/src/modules/intel/collector.ts +19 -0
- package/src/modules/intel/collectors/competitor.test.ts +71 -0
- package/src/modules/intel/collectors/competitor.ts +65 -0
- package/src/modules/intel/collectors/rss.test.ts +56 -0
- package/src/modules/intel/collectors/rss.ts +70 -0
- package/src/modules/intel/collectors/trends.test.ts +80 -0
- package/src/modules/intel/collectors/trends.ts +107 -0
- package/src/modules/intel/collectors/web-search.test.ts +85 -0
- package/src/modules/intel/collectors/web-search.ts +81 -0
- package/src/modules/intel/integration.test.ts +203 -0
- package/src/modules/intel/intel-engine.test.ts +103 -0
- package/src/modules/intel/intel-engine.ts +96 -0
- package/src/modules/intel/source-config.test.ts +113 -0
- package/src/modules/intel/source-config.ts +131 -0
- package/src/modules/learnings/diff-tracker.test.ts +144 -0
- package/src/modules/learnings/diff-tracker.ts +189 -0
- package/src/modules/learnings/rule-distiller.ts +141 -0
- package/src/modules/memory/distill.ts +208 -0
- package/src/modules/migrate/legacy-migrate.test.ts +169 -0
- package/src/modules/migrate/legacy-migrate.ts +229 -0
- package/src/modules/pro/api-client.ts +192 -0
- package/src/modules/pro/gate.test.ts +110 -0
- package/src/modules/pro/gate.ts +104 -0
- package/src/modules/profile/creator-profile.test.ts +178 -0
- package/src/modules/profile/creator-profile.ts +248 -0
- package/src/modules/publish/douyin-api.ts +34 -0
- package/src/modules/publish/wechat-mp.ts +320 -0
- package/src/modules/publish/xiaohongshu-api.ts +127 -0
- package/src/modules/research/free-engine.ts +360 -0
- package/src/modules/timeline/markup-generator.ts +63 -0
- package/src/modules/timeline/parser.ts +275 -0
- package/src/modules/workflow/templates.ts +124 -0
- package/src/modules/writing/platform-rewrite.ts +190 -0
- package/src/modules/writing/title-hashtag.ts +385 -0
- package/src/runtime/context.test.ts +97 -0
- package/src/runtime/context.ts +129 -0
- package/src/runtime/events.test.ts +83 -0
- package/src/runtime/events.ts +104 -0
- package/src/runtime/hooks.ts +174 -0
- package/src/runtime/tool-runner.test.ts +204 -0
- package/src/runtime/tool-runner.ts +282 -0
- package/src/runtime/workflow-engine.test.ts +455 -0
- package/src/runtime/workflow-engine.ts +391 -0
- package/src/server/index.ts +409 -0
- package/src/server/start.ts +39 -0
- package/src/storage/local-store.test.ts +304 -0
- package/src/storage/local-store.ts +704 -0
- package/src/storage/pipeline-store.test.ts +363 -0
- package/src/storage/pipeline-store.ts +698 -0
- package/src/tools/asset.ts +96 -0
- package/src/tools/content-save.ts +276 -0
- package/src/tools/cover-review.ts +221 -0
- package/src/tools/humanize.ts +54 -0
- package/src/tools/init.ts +133 -0
- package/src/tools/intel.ts +92 -0
- package/src/tools/memory.ts +76 -0
- package/src/tools/pipeline-ops.ts +109 -0
- package/src/tools/pipeline.ts +168 -0
- package/src/tools/pre-publish.ts +232 -0
- package/src/tools/publish.ts +183 -0
- package/src/tools/registry.ts +198 -0
- package/src/tools/research.ts +304 -0
- package/src/tools/review.ts +305 -0
- package/src/tools/rewrite.ts +165 -0
- package/src/tools/status.ts +30 -0
- package/src/tools/timeline.ts +234 -0
- package/src/tools/topic-create.ts +50 -0
- package/src/types/providers.ts +69 -0
- package/src/types/timeline.test.ts +147 -0
- package/src/types/timeline.ts +83 -0
- package/src/utils/retry.test.ts +97 -0
- package/src/utils/retry.ts +85 -0
- package/templates/AGENTS.md +99 -0
- package/templates/SOUL.md +31 -0
- package/templates/TOOLS.md +76 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Free Research Engine — topic discovery using only public web search
|
|
3
|
+
*
|
|
4
|
+
* No crawlers, no third-party APIs. Pure web_search + style calibration + viral scoring.
|
|
5
|
+
*
|
|
6
|
+
* PRD §5: "Free 版选题(纯公开搜索)"
|
|
7
|
+
*/
|
|
8
|
+
import { loadProfile, type CreatorProfile } from "../profile/creator-profile.js";
|
|
9
|
+
|
|
10
|
+
// --- Types ---
|
|
11
|
+
|
|
12
|
+
export interface TopicCandidate {
|
|
13
|
+
title: string;
|
|
14
|
+
description: string;
|
|
15
|
+
tags: string[];
|
|
16
|
+
source: string;
|
|
17
|
+
/** 0-100 viral potential score */
|
|
18
|
+
viralScore: number;
|
|
19
|
+
/** Score breakdown */
|
|
20
|
+
scoreBreakdown: {
|
|
21
|
+
titleAppeal: number;
|
|
22
|
+
topicHeat: number;
|
|
23
|
+
profileFit: number;
|
|
24
|
+
};
|
|
25
|
+
/** Why this topic was suggested */
|
|
26
|
+
reasoning: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface FreeResearchResult {
|
|
30
|
+
ok: boolean;
|
|
31
|
+
keyword: string;
|
|
32
|
+
industry: string;
|
|
33
|
+
candidates: TopicCandidate[];
|
|
34
|
+
/** Search queries that were used */
|
|
35
|
+
searchQueries: string[];
|
|
36
|
+
/** Style filters applied */
|
|
37
|
+
filtersApplied: string[];
|
|
38
|
+
summary: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SearchResult {
|
|
42
|
+
title: string;
|
|
43
|
+
snippet: string;
|
|
44
|
+
url: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build search queries for topic discovery.
|
|
49
|
+
* Returns 3-5 queries tailored to the user's industry and keyword.
|
|
50
|
+
*/
|
|
51
|
+
export function buildSearchQueries(
|
|
52
|
+
keyword: string,
|
|
53
|
+
industry: string,
|
|
54
|
+
platforms: string[],
|
|
55
|
+
): string[] {
|
|
56
|
+
const queries: string[] = [];
|
|
57
|
+
const currentMonth = new Date().toLocaleDateString("zh-CN", { year: "numeric", month: "long" });
|
|
58
|
+
|
|
59
|
+
// Core queries
|
|
60
|
+
queries.push(`${industry} ${keyword} 内容选题`);
|
|
61
|
+
queries.push(`${industry} 热门话题 ${currentMonth}`);
|
|
62
|
+
queries.push(`${keyword} 爆款内容 分析`);
|
|
63
|
+
|
|
64
|
+
// Platform-specific
|
|
65
|
+
const platformNames: Record<string, string> = {
|
|
66
|
+
xhs: "小红书",
|
|
67
|
+
xiaohongshu: "小红书",
|
|
68
|
+
douyin: "抖音",
|
|
69
|
+
wechat_mp: "公众号",
|
|
70
|
+
bilibili: "B站",
|
|
71
|
+
};
|
|
72
|
+
const primaryPlatform = platforms[0];
|
|
73
|
+
if (primaryPlatform && platformNames[primaryPlatform]) {
|
|
74
|
+
queries.push(`${keyword} ${platformNames[primaryPlatform]} 爆款`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Trend query
|
|
78
|
+
queries.push(`${keyword} 最新趋势 ${new Date().getFullYear()}`);
|
|
79
|
+
|
|
80
|
+
return queries;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Score a topic candidate for viral potential.
|
|
85
|
+
*
|
|
86
|
+
* Dimensions (each 0-33, total normalized to 0-100):
|
|
87
|
+
* - Title appeal: length, specificity, emotional triggers, numbers
|
|
88
|
+
* - Topic heat: keyword relevance, timeliness signals
|
|
89
|
+
* - Profile fit: alignment with user's industry, audience, writing rules
|
|
90
|
+
*/
|
|
91
|
+
export function scoreCandidate(
|
|
92
|
+
candidate: { title: string; description: string; tags: string[] },
|
|
93
|
+
profile: CreatorProfile | null,
|
|
94
|
+
keyword: string,
|
|
95
|
+
): { viralScore: number; breakdown: TopicCandidate["scoreBreakdown"]; reasoning: string } {
|
|
96
|
+
let titleAppeal = 15;
|
|
97
|
+
let topicHeat = 15;
|
|
98
|
+
let profileFit = 15;
|
|
99
|
+
const reasons: string[] = [];
|
|
100
|
+
|
|
101
|
+
// --- Title Appeal ---
|
|
102
|
+
const titleLen = candidate.title.length;
|
|
103
|
+
// Optimal title length: 10-20 chars
|
|
104
|
+
if (titleLen >= 10 && titleLen <= 20) {
|
|
105
|
+
titleAppeal += 5;
|
|
106
|
+
} else if (titleLen > 25) {
|
|
107
|
+
titleAppeal -= 3;
|
|
108
|
+
}
|
|
109
|
+
// Numbers in title
|
|
110
|
+
if (/\d/.test(candidate.title)) {
|
|
111
|
+
titleAppeal += 5;
|
|
112
|
+
reasons.push("标题含数字,吸引力+");
|
|
113
|
+
}
|
|
114
|
+
// Emotional triggers
|
|
115
|
+
if (/别再|千万|后悔|真相|没想到|居然|竟然|绝了|必看|干货|避坑/.test(candidate.title)) {
|
|
116
|
+
titleAppeal += 5;
|
|
117
|
+
reasons.push("标题有情绪触发词");
|
|
118
|
+
}
|
|
119
|
+
// Specificity (not generic)
|
|
120
|
+
if (/推荐|分享|介绍|总结/.test(candidate.title) && !/\d/.test(candidate.title)) {
|
|
121
|
+
titleAppeal -= 5;
|
|
122
|
+
reasons.push("标题偏泛,建议加具体角度");
|
|
123
|
+
}
|
|
124
|
+
// Question format
|
|
125
|
+
if (/[??]/.test(candidate.title)) {
|
|
126
|
+
titleAppeal += 3;
|
|
127
|
+
}
|
|
128
|
+
titleAppeal = clamp(titleAppeal, 0, 33);
|
|
129
|
+
|
|
130
|
+
// --- Topic Heat ---
|
|
131
|
+
// Keyword match
|
|
132
|
+
if (candidate.title.includes(keyword) || candidate.description.includes(keyword)) {
|
|
133
|
+
topicHeat += 5;
|
|
134
|
+
}
|
|
135
|
+
// Timeliness signals
|
|
136
|
+
const year = String(new Date().getFullYear());
|
|
137
|
+
if (candidate.description.includes(year) || candidate.description.includes("最新")) {
|
|
138
|
+
topicHeat += 5;
|
|
139
|
+
reasons.push("话题有时效性");
|
|
140
|
+
}
|
|
141
|
+
// Tags relevance
|
|
142
|
+
if (candidate.tags.length >= 3) {
|
|
143
|
+
topicHeat += 3;
|
|
144
|
+
}
|
|
145
|
+
// Description quality (has data/evidence)
|
|
146
|
+
if (/\d+[%%万亿]/.test(candidate.description)) {
|
|
147
|
+
topicHeat += 5;
|
|
148
|
+
reasons.push("描述引用了数据");
|
|
149
|
+
}
|
|
150
|
+
topicHeat = clamp(topicHeat, 0, 33);
|
|
151
|
+
|
|
152
|
+
// --- Profile Fit ---
|
|
153
|
+
if (profile) {
|
|
154
|
+
// Industry match
|
|
155
|
+
if (profile.industry && (candidate.title.includes(profile.industry) || candidate.tags.some((t) => t.includes(profile.industry)))) {
|
|
156
|
+
profileFit += 5;
|
|
157
|
+
reasons.push("与用户行业匹配");
|
|
158
|
+
}
|
|
159
|
+
// Audience match
|
|
160
|
+
if (profile.audiencePersona) {
|
|
161
|
+
const painPoints = profile.audiencePersona.painPoints || [];
|
|
162
|
+
for (const pain of painPoints) {
|
|
163
|
+
if (candidate.title.includes(pain) || candidate.description.includes(pain)) {
|
|
164
|
+
profileFit += 5;
|
|
165
|
+
reasons.push(`命中受众痛点: ${pain}`);
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// Style boundary check (never list)
|
|
171
|
+
const neverTopics = profile.styleBoundaries?.never || [];
|
|
172
|
+
for (const never of neverTopics) {
|
|
173
|
+
if (candidate.title.includes(never) || candidate.description.includes(never)) {
|
|
174
|
+
profileFit -= 10;
|
|
175
|
+
reasons.push(`触碰风格禁区: ${never}`);
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
profileFit = clamp(profileFit, 0, 34);
|
|
181
|
+
|
|
182
|
+
const viralScore = clamp(titleAppeal + topicHeat + profileFit, 0, 100);
|
|
183
|
+
const reasoning = reasons.length > 0 ? reasons.join(";") : "综合评估";
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
viralScore,
|
|
187
|
+
breakdown: { titleAppeal, topicHeat, profileFit },
|
|
188
|
+
reasoning,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Filter candidates against the user's style profile.
|
|
194
|
+
* Removes topics that conflict with writing rules or style boundaries.
|
|
195
|
+
*/
|
|
196
|
+
export function filterByStyle(
|
|
197
|
+
candidates: TopicCandidate[],
|
|
198
|
+
profile: CreatorProfile | null,
|
|
199
|
+
): { filtered: TopicCandidate[]; filtersApplied: string[] } {
|
|
200
|
+
if (!profile) return { filtered: candidates, filtersApplied: [] };
|
|
201
|
+
|
|
202
|
+
const filtersApplied: string[] = [];
|
|
203
|
+
let filtered = [...candidates];
|
|
204
|
+
|
|
205
|
+
// Filter by style boundaries (never list)
|
|
206
|
+
const neverTopics = profile.styleBoundaries?.never || [];
|
|
207
|
+
if (neverTopics.length > 0) {
|
|
208
|
+
const before = filtered.length;
|
|
209
|
+
filtered = filtered.filter((c) => {
|
|
210
|
+
return !neverTopics.some(
|
|
211
|
+
(n) => c.title.includes(n) || c.description.includes(n),
|
|
212
|
+
);
|
|
213
|
+
});
|
|
214
|
+
if (filtered.length < before) {
|
|
215
|
+
filtersApplied.push(`排除了 ${before - filtered.length} 个触碰风格禁区的选题`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Filter low profile-fit scores
|
|
220
|
+
const lowFitBefore = filtered.length;
|
|
221
|
+
filtered = filtered.filter((c) => c.scoreBreakdown.profileFit >= 5);
|
|
222
|
+
if (filtered.length < lowFitBefore) {
|
|
223
|
+
filtersApplied.push(`排除了 ${lowFitBefore - filtered.length} 个与用户定位不匹配的选题`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return { filtered, filtersApplied };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Process raw search results into scored topic candidates.
|
|
231
|
+
*
|
|
232
|
+
* This is the core function that the research skill/tool calls after
|
|
233
|
+
* performing web searches. It takes raw search results and produces
|
|
234
|
+
* scored, filtered topic candidates.
|
|
235
|
+
*/
|
|
236
|
+
export async function processSearchResults(
|
|
237
|
+
searchResults: SearchResult[],
|
|
238
|
+
keyword: string,
|
|
239
|
+
profile: CreatorProfile | null,
|
|
240
|
+
topicCount: number = 5,
|
|
241
|
+
): Promise<{ candidates: TopicCandidate[]; filtersApplied: string[] }> {
|
|
242
|
+
// Deduplicate by title similarity
|
|
243
|
+
const seen = new Set<string>();
|
|
244
|
+
const unique = searchResults.filter((r) => {
|
|
245
|
+
const key = r.title.slice(0, 15);
|
|
246
|
+
if (seen.has(key)) return false;
|
|
247
|
+
seen.add(key);
|
|
248
|
+
return true;
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// Convert to candidates and score
|
|
252
|
+
let candidates: TopicCandidate[] = unique.map((r) => {
|
|
253
|
+
// Extract a concise title (≤20 chars)
|
|
254
|
+
let title = r.title.replace(/[-_|—–].*$/, "").trim();
|
|
255
|
+
if (title.length > 20) title = title.slice(0, 20);
|
|
256
|
+
|
|
257
|
+
// Extract tags from snippet
|
|
258
|
+
const tags = extractTags(r.snippet, keyword);
|
|
259
|
+
|
|
260
|
+
const { viralScore, breakdown, reasoning } = scoreCandidate(
|
|
261
|
+
{ title, description: r.snippet, tags },
|
|
262
|
+
profile,
|
|
263
|
+
keyword,
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
title,
|
|
268
|
+
description: r.snippet.slice(0, 200),
|
|
269
|
+
tags,
|
|
270
|
+
source: `web_search: ${r.url}`,
|
|
271
|
+
viralScore,
|
|
272
|
+
scoreBreakdown: breakdown,
|
|
273
|
+
reasoning,
|
|
274
|
+
};
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// Style filter
|
|
278
|
+
const { filtered, filtersApplied } = filterByStyle(candidates, profile);
|
|
279
|
+
candidates = filtered;
|
|
280
|
+
|
|
281
|
+
// Sort by viral score descending
|
|
282
|
+
candidates.sort((a, b) => b.viralScore - a.viralScore);
|
|
283
|
+
|
|
284
|
+
// Return top N
|
|
285
|
+
return {
|
|
286
|
+
candidates: candidates.slice(0, topicCount),
|
|
287
|
+
filtersApplied,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Main entry: run the free research engine.
|
|
293
|
+
*
|
|
294
|
+
* Note: This function does NOT perform web searches itself — it expects
|
|
295
|
+
* the caller (skill or tool) to provide search results. This keeps the
|
|
296
|
+
* module pure and testable.
|
|
297
|
+
*/
|
|
298
|
+
export async function runFreeResearch(opts: {
|
|
299
|
+
keyword: string;
|
|
300
|
+
searchResults: SearchResult[];
|
|
301
|
+
topicCount?: number;
|
|
302
|
+
dataDir?: string;
|
|
303
|
+
}): Promise<FreeResearchResult> {
|
|
304
|
+
const { keyword, searchResults, topicCount = 5, dataDir } = opts;
|
|
305
|
+
const profile = await loadProfile(dataDir);
|
|
306
|
+
const industry = profile?.industry || "通用";
|
|
307
|
+
|
|
308
|
+
const queries = buildSearchQueries(keyword, industry, profile?.platforms || []);
|
|
309
|
+
const { candidates, filtersApplied } = await processSearchResults(
|
|
310
|
+
searchResults,
|
|
311
|
+
keyword,
|
|
312
|
+
profile,
|
|
313
|
+
topicCount,
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
const summary =
|
|
317
|
+
candidates.length > 0
|
|
318
|
+
? `找到 ${candidates.length} 个选题候选(最高分 ${candidates[0].viralScore})`
|
|
319
|
+
: "未找到合适的选题候选,建议换个关键词或方向";
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
ok: true,
|
|
323
|
+
keyword,
|
|
324
|
+
industry,
|
|
325
|
+
candidates,
|
|
326
|
+
searchQueries: queries,
|
|
327
|
+
filtersApplied,
|
|
328
|
+
summary,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// --- Helpers ---
|
|
333
|
+
|
|
334
|
+
function clamp(v: number, min: number, max: number): number {
|
|
335
|
+
return Math.max(min, Math.min(max, v));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function extractTags(text: string, keyword: string): string[] {
|
|
339
|
+
const tags = new Set<string>();
|
|
340
|
+
tags.add(keyword);
|
|
341
|
+
|
|
342
|
+
// Extract hashtag-like patterns
|
|
343
|
+
const hashMatches = text.match(/#[\u4e00-\u9fffA-Za-z0-9]+/g);
|
|
344
|
+
if (hashMatches) {
|
|
345
|
+
for (const m of hashMatches.slice(0, 4)) {
|
|
346
|
+
tags.add(m.replace("#", ""));
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Extract quoted terms
|
|
351
|
+
const quoteMatches = text.match(/[「」""]/g) ? text.match(/[「「]([^」」]+)[」」]/g) : null;
|
|
352
|
+
if (quoteMatches) {
|
|
353
|
+
for (const m of quoteMatches.slice(0, 2)) {
|
|
354
|
+
const clean = m.replace(/[「」""]/g, "");
|
|
355
|
+
if (clean.length <= 8) tags.add(clean);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return Array.from(tags).slice(0, 5);
|
|
360
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { VideoPreset } from "../../types/timeline.js";
|
|
2
|
+
|
|
3
|
+
const CARD_TEMPLATES = `
|
|
4
|
+
Available card templates:
|
|
5
|
+
- comparison-table: attrs: title, rows (format: name:pros:cons,name:pros:cons)
|
|
6
|
+
- key-points: attrs: items (comma-separated)
|
|
7
|
+
- flow-chart: attrs: steps (comma-separated)
|
|
8
|
+
- data-chart: attrs: title, items (format: label:value,label:value)
|
|
9
|
+
`.trim();
|
|
10
|
+
|
|
11
|
+
const MARKUP_SYNTAX = `
|
|
12
|
+
Markup syntax:
|
|
13
|
+
- [card:TEMPLATE_NAME attr1="value1" attr2="value2"] — insert a visual card
|
|
14
|
+
- [broll:PROMPT_DESCRIPTION] — insert a B-roll visual segment
|
|
15
|
+
Optional: add span=N to link the broll to N preceding narration segments
|
|
16
|
+
`.trim();
|
|
17
|
+
|
|
18
|
+
const PRESET_GUIDANCE: Record<VideoPreset, string> = {
|
|
19
|
+
"knowledge-explainer": `
|
|
20
|
+
Preset guidance (knowledge-explainer):
|
|
21
|
+
- Target ~60% card visuals and ~40% broll transitions
|
|
22
|
+
- Use cards when comparing, listing, or highlighting key points
|
|
23
|
+
- Use broll for topic transitions and visual variety
|
|
24
|
+
- broll prompts should be specific: describe the content, style, and atmosphere
|
|
25
|
+
- Alternate between cards and broll to maintain viewer engagement
|
|
26
|
+
`.trim(),
|
|
27
|
+
|
|
28
|
+
tutorial: `
|
|
29
|
+
Preset guidance (tutorial):
|
|
30
|
+
- Use numbered step cards (flow-chart, key-points) to structure the tutorial
|
|
31
|
+
- Use broll between steps for transitions and breathing room
|
|
32
|
+
- Mark screen recording placeholders when describing software operations
|
|
33
|
+
(e.g. [broll:screen recording — opening settings panel])
|
|
34
|
+
- Each major step should have a corresponding card summarizing the action
|
|
35
|
+
`.trim(),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const BASE_INSTRUCTIONS = `
|
|
39
|
+
You are a video script markup assistant. Your job is to read the script below
|
|
40
|
+
and insert [card:...] and [broll:...] tags at appropriate positions.
|
|
41
|
+
|
|
42
|
+
Rules:
|
|
43
|
+
1. Do NOT modify the script text itself — only insert markup tags on new lines
|
|
44
|
+
2. Place markup tags AFTER the narration line they relate to
|
|
45
|
+
3. Every card must use one of the available templates with proper attributes
|
|
46
|
+
4. broll prompts must be vivid and specific enough for AI image generation
|
|
47
|
+
5. Return the complete script with markup tags inserted
|
|
48
|
+
`.trim();
|
|
49
|
+
|
|
50
|
+
export function buildMarkupPrompt(
|
|
51
|
+
script: string,
|
|
52
|
+
preset: VideoPreset
|
|
53
|
+
): string {
|
|
54
|
+
const sections = [
|
|
55
|
+
BASE_INSTRUCTIONS,
|
|
56
|
+
MARKUP_SYNTAX,
|
|
57
|
+
CARD_TEMPLATES,
|
|
58
|
+
PRESET_GUIDANCE[preset],
|
|
59
|
+
`---\nScript:\n${script}`,
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
return sections.join("\n\n");
|
|
63
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Timeline,
|
|
3
|
+
TTSSegment,
|
|
4
|
+
VisualSegment,
|
|
5
|
+
VisualType,
|
|
6
|
+
CardTemplate,
|
|
7
|
+
VideoPreset,
|
|
8
|
+
AspectRatio,
|
|
9
|
+
SegmentStatus,
|
|
10
|
+
} from "../../types/timeline.js";
|
|
11
|
+
|
|
12
|
+
export interface ParseOptions {
|
|
13
|
+
contentId: string;
|
|
14
|
+
preset: VideoPreset;
|
|
15
|
+
aspectRatio: AspectRatio;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface ParsedCard {
|
|
19
|
+
type: "card";
|
|
20
|
+
template: string;
|
|
21
|
+
attrs: Record<string, string>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface ParsedBroll {
|
|
25
|
+
type: "broll";
|
|
26
|
+
prompt: string;
|
|
27
|
+
span: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type ParsedMarkup = ParsedCard | ParsedBroll;
|
|
31
|
+
|
|
32
|
+
const CARD_RE = /^\[card:([^\]]+)\]$/;
|
|
33
|
+
const BROLL_RE = /^\[broll:([^\]]+)\]$/;
|
|
34
|
+
const ATTR_RE = /(\w+)="([^"]*)"/g;
|
|
35
|
+
const BARE_ATTR_RE = /(\w+)=(\S+)/g;
|
|
36
|
+
|
|
37
|
+
function estimateDuration(text: string): number {
|
|
38
|
+
let chineseCount = 0;
|
|
39
|
+
let nonChineseCount = 0;
|
|
40
|
+
|
|
41
|
+
for (const char of text) {
|
|
42
|
+
if (/[\u4e00-\u9fff\u3400-\u4dbf]/.test(char)) {
|
|
43
|
+
chineseCount++;
|
|
44
|
+
} else if (/[a-zA-Z0-9]/.test(char)) {
|
|
45
|
+
nonChineseCount++;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const chineseSec = chineseCount / 4;
|
|
50
|
+
const nonChineseSec = nonChineseCount / 15;
|
|
51
|
+
return Math.max(chineseSec + nonChineseSec, 0.5);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function padId(prefix: string, n: number): string {
|
|
55
|
+
return `${prefix}-${String(n).padStart(3, "0")}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseCardContent(raw: string): ParsedCard {
|
|
59
|
+
const parts = raw.trim();
|
|
60
|
+
const firstSpace = parts.indexOf(" ");
|
|
61
|
+
|
|
62
|
+
let template: string;
|
|
63
|
+
let rest: string;
|
|
64
|
+
|
|
65
|
+
if (firstSpace === -1) {
|
|
66
|
+
template = parts;
|
|
67
|
+
rest = "";
|
|
68
|
+
} else {
|
|
69
|
+
template = parts.slice(0, firstSpace);
|
|
70
|
+
rest = parts.slice(firstSpace + 1);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const attrs: Record<string, string> = {};
|
|
74
|
+
let match: RegExpExecArray | null;
|
|
75
|
+
|
|
76
|
+
// quoted attrs first
|
|
77
|
+
ATTR_RE.lastIndex = 0;
|
|
78
|
+
while ((match = ATTR_RE.exec(rest)) !== null) {
|
|
79
|
+
attrs[match[1]] = match[2];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { type: "card", template, attrs };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parseBrollContent(raw: string): ParsedBroll {
|
|
86
|
+
const trimmed = raw.trim();
|
|
87
|
+
let span = 1;
|
|
88
|
+
|
|
89
|
+
// extract bare attrs like span=2
|
|
90
|
+
let prompt = trimmed;
|
|
91
|
+
const spanMatch = /\bspan=(\d+)\b/.exec(trimmed);
|
|
92
|
+
if (spanMatch) {
|
|
93
|
+
span = parseInt(spanMatch[1], 10);
|
|
94
|
+
prompt = trimmed.slice(0, spanMatch.index).trim();
|
|
95
|
+
const after = trimmed.slice(spanMatch.index + spanMatch[0].length).trim();
|
|
96
|
+
if (after) prompt = prompt ? `${prompt} ${after}` : after;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { type: "broll", prompt, span };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parseCardData(attrs: Record<string, string>): Record<string, unknown> {
|
|
103
|
+
const data: Record<string, unknown> = {};
|
|
104
|
+
|
|
105
|
+
if (attrs.title) {
|
|
106
|
+
data.title = attrs.title;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (attrs.rows) {
|
|
110
|
+
data.rows = attrs.rows.split(",").map((row) => {
|
|
111
|
+
const parts = row.split(":");
|
|
112
|
+
return { name: parts[0], pros: parts[1] ?? "", cons: parts[2] ?? "" };
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (attrs.items) {
|
|
117
|
+
data.items = attrs.items.split(",");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (attrs.steps) {
|
|
121
|
+
data.steps = attrs.steps.split(",");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return data;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function parseMarkedScript(
|
|
128
|
+
script: string,
|
|
129
|
+
options: ParseOptions
|
|
130
|
+
): Timeline {
|
|
131
|
+
const lines = script.split("\n");
|
|
132
|
+
const ttsSegments: TTSSegment[] = [];
|
|
133
|
+
const visualSegments: VisualSegment[] = [];
|
|
134
|
+
|
|
135
|
+
// Accumulate text lines and pair them with following markup
|
|
136
|
+
let textBuffer: string[] = [];
|
|
137
|
+
let ttsCounter = 0;
|
|
138
|
+
let visCounter = 0;
|
|
139
|
+
let currentStart = 0;
|
|
140
|
+
|
|
141
|
+
// Track which tts+layer combos are taken
|
|
142
|
+
const layerMap = new Map<string, Set<number>>();
|
|
143
|
+
|
|
144
|
+
function flushText(): string | null {
|
|
145
|
+
if (textBuffer.length === 0) return null;
|
|
146
|
+
const text = textBuffer.join("\n");
|
|
147
|
+
textBuffer = [];
|
|
148
|
+
return text;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function createTts(text: string): TTSSegment {
|
|
152
|
+
ttsCounter++;
|
|
153
|
+
const duration = estimateDuration(text);
|
|
154
|
+
const seg: TTSSegment = {
|
|
155
|
+
id: padId("tts", ttsCounter),
|
|
156
|
+
text,
|
|
157
|
+
estimatedDuration: parseFloat(duration.toFixed(2)),
|
|
158
|
+
start: parseFloat(currentStart.toFixed(2)),
|
|
159
|
+
asset: null,
|
|
160
|
+
status: "pending" as SegmentStatus,
|
|
161
|
+
};
|
|
162
|
+
currentStart += duration;
|
|
163
|
+
return seg;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function pickLayer(ttsIds: string[], preferredLayer: number): number {
|
|
167
|
+
for (const ttsId of ttsIds) {
|
|
168
|
+
const taken = layerMap.get(ttsId);
|
|
169
|
+
if (taken?.has(preferredLayer)) {
|
|
170
|
+
return preferredLayer + 1;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return preferredLayer;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function markLayer(ttsIds: string[], layer: number): void {
|
|
177
|
+
for (const ttsId of ttsIds) {
|
|
178
|
+
if (!layerMap.has(ttsId)) layerMap.set(ttsId, new Set());
|
|
179
|
+
layerMap.get(ttsId)!.add(layer);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
for (const line of lines) {
|
|
184
|
+
const trimmed = line.trim();
|
|
185
|
+
|
|
186
|
+
if (trimmed === "") continue;
|
|
187
|
+
|
|
188
|
+
const cardMatch = CARD_RE.exec(trimmed);
|
|
189
|
+
const brollMatch = BROLL_RE.exec(trimmed);
|
|
190
|
+
|
|
191
|
+
if (cardMatch) {
|
|
192
|
+
// Flush preceding text as TTS
|
|
193
|
+
const text = flushText();
|
|
194
|
+
if (text) {
|
|
195
|
+
ttsSegments.push(createTts(text));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const parsed = parseCardContent(cardMatch[1]);
|
|
199
|
+
const lastTtsId =
|
|
200
|
+
ttsSegments.length > 0
|
|
201
|
+
? ttsSegments[ttsSegments.length - 1].id
|
|
202
|
+
: null;
|
|
203
|
+
const linkedTts = lastTtsId ? [lastTtsId] : [];
|
|
204
|
+
|
|
205
|
+
const layer = pickLayer(linkedTts, 0);
|
|
206
|
+
markLayer(linkedTts, layer);
|
|
207
|
+
|
|
208
|
+
visCounter++;
|
|
209
|
+
visualSegments.push({
|
|
210
|
+
id: padId("vis", visCounter),
|
|
211
|
+
layer,
|
|
212
|
+
type: "card" as VisualType,
|
|
213
|
+
template: parsed.template as CardTemplate,
|
|
214
|
+
data: parseCardData(parsed.attrs),
|
|
215
|
+
linkedTts,
|
|
216
|
+
opacity: 0.85,
|
|
217
|
+
asset: null,
|
|
218
|
+
status: "pending" as SegmentStatus,
|
|
219
|
+
});
|
|
220
|
+
} else if (brollMatch) {
|
|
221
|
+
// Flush preceding text as TTS
|
|
222
|
+
const text = flushText();
|
|
223
|
+
if (text) {
|
|
224
|
+
ttsSegments.push(createTts(text));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const parsed = parseBrollContent(brollMatch[1]);
|
|
228
|
+
const span = Math.min(parsed.span, ttsSegments.length);
|
|
229
|
+
const linkedTts = ttsSegments
|
|
230
|
+
.slice(-span)
|
|
231
|
+
.map((s) => s.id);
|
|
232
|
+
|
|
233
|
+
const layer = pickLayer(linkedTts, 0);
|
|
234
|
+
markLayer(linkedTts, layer);
|
|
235
|
+
|
|
236
|
+
visCounter++;
|
|
237
|
+
visualSegments.push({
|
|
238
|
+
id: padId("vis", visCounter),
|
|
239
|
+
layer,
|
|
240
|
+
type: "broll" as VisualType,
|
|
241
|
+
prompt: parsed.prompt,
|
|
242
|
+
linkedTts,
|
|
243
|
+
asset: null,
|
|
244
|
+
status: "pending" as SegmentStatus,
|
|
245
|
+
});
|
|
246
|
+
} else {
|
|
247
|
+
textBuffer.push(trimmed);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Flush remaining text
|
|
252
|
+
const remaining = flushText();
|
|
253
|
+
if (remaining) {
|
|
254
|
+
ttsSegments.push(createTts(remaining));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
version: "2.0",
|
|
259
|
+
contentId: options.contentId,
|
|
260
|
+
preset: options.preset,
|
|
261
|
+
aspectRatio: options.aspectRatio,
|
|
262
|
+
subtitle: {
|
|
263
|
+
template: "modern-outline",
|
|
264
|
+
position: "bottom",
|
|
265
|
+
},
|
|
266
|
+
tracks: {
|
|
267
|
+
tts: ttsSegments,
|
|
268
|
+
visual: visualSegments,
|
|
269
|
+
subtitle: {
|
|
270
|
+
asset: null,
|
|
271
|
+
status: "pending",
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
}
|