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,103 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import { runIntelPull } from "./intel-engine.js";
6
+ import type { SearchResult } from "./collectors/web-search.js";
7
+
8
+ describe("runIntelPull", () => {
9
+ let tmpDir: string;
10
+
11
+ beforeEach(async () => {
12
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "intel-engine-test-"));
13
+ });
14
+
15
+ afterEach(async () => {
16
+ await fs.rm(tmpDir, { recursive: true, force: true });
17
+ });
18
+
19
+ it("collects and saves intel from web_search source", async () => {
20
+ const mockResults: SearchResult[] = [
21
+ { title: "AI Breakthrough", url: "https://example.com/1", snippet: "Big news in AI" },
22
+ { title: "LLM Update", url: "https://example.com/2", snippet: "New model released" },
23
+ ];
24
+ const searchFn = vi.fn().mockResolvedValue(mockResults);
25
+
26
+ const result = await runIntelPull({
27
+ keywords: ["AI"],
28
+ industry: "科技",
29
+ platforms: ["小红书"],
30
+ dataDir: tmpDir,
31
+ searchFn,
32
+ skipBrowser: true,
33
+ sources: ["web_search"],
34
+ });
35
+
36
+ expect(result.totalCollected).toBeGreaterThan(0);
37
+ expect(result.totalSaved).toBeGreaterThan(0);
38
+ expect(result.bySource.web_search).toBeGreaterThan(0);
39
+ expect(result.errors).toEqual([]);
40
+
41
+ // Verify files were saved
42
+ const intelDir = path.join(tmpDir, "pipeline", "intel", "AI");
43
+ const files = await fs.readdir(intelDir);
44
+ expect(files.length).toBeGreaterThan(0);
45
+ });
46
+
47
+ it("handles collector errors gracefully", async () => {
48
+ const searchFn = vi.fn().mockRejectedValue(new Error("Network down"));
49
+
50
+ const result = await runIntelPull({
51
+ keywords: ["fail"],
52
+ industry: "科技",
53
+ platforms: [],
54
+ dataDir: tmpDir,
55
+ searchFn,
56
+ skipBrowser: true,
57
+ sources: ["web_search"],
58
+ });
59
+
60
+ expect(result.totalCollected).toBe(0);
61
+ expect(result.totalSaved).toBe(0);
62
+ expect(result.errors.length).toBeGreaterThan(0);
63
+ });
64
+
65
+ it("filters to specific sources", async () => {
66
+ const searchFn = vi.fn().mockResolvedValue([
67
+ { title: "Test", url: "https://example.com/x", snippet: "test" },
68
+ ]);
69
+
70
+ const result = await runIntelPull({
71
+ keywords: ["test"],
72
+ industry: "科技",
73
+ platforms: [],
74
+ dataDir: tmpDir,
75
+ searchFn,
76
+ skipBrowser: true,
77
+ sources: ["web_search"],
78
+ });
79
+
80
+ // Only web_search source should be present
81
+ expect(Object.keys(result.bySource)).toEqual(["web_search"]);
82
+ });
83
+
84
+ it("runs multiple collectors in parallel", async () => {
85
+ const searchFn = vi.fn().mockResolvedValue([
86
+ { title: "Multi", url: "https://example.com/m", snippet: "multi" },
87
+ ]);
88
+
89
+ const result = await runIntelPull({
90
+ keywords: ["test"],
91
+ industry: "科技",
92
+ platforms: [],
93
+ dataDir: tmpDir,
94
+ searchFn,
95
+ skipBrowser: true,
96
+ // No sources filter — runs web_search, rss, trend (skips competitor due to skipBrowser)
97
+ });
98
+
99
+ // web_search and trend both use searchFn, rss may return 0 (no config)
100
+ expect(result.totalCollected).toBeGreaterThan(0);
101
+ expect(result.totalSaved).toBeGreaterThan(0);
102
+ });
103
+ });
@@ -0,0 +1,96 @@
1
+ import type { IntelItem } from "../../storage/pipeline-store.js";
2
+ import { saveIntel } from "../../storage/pipeline-store.js";
3
+ import type { Collector, CollectorOptions } from "./collector.js";
4
+ import { createWebSearchCollector } from "./collectors/web-search.js";
5
+ import type { SearchFn } from "./collectors/web-search.js";
6
+ import { createRssCollector } from "./collectors/rss.js";
7
+ import { createTrendCollector } from "./collectors/trends.js";
8
+ import { createCompetitorCollector } from "./collectors/competitor.js";
9
+
10
+ // ─── Types ──────────────────────────────────────────────────────────────────
11
+
12
+ export interface IntelPullOptions {
13
+ keywords: string[];
14
+ industry: string;
15
+ platforms: string[];
16
+ dataDir?: string;
17
+ searchFn: SearchFn;
18
+ skipBrowser?: boolean;
19
+ sources?: string[]; // filter to specific sources
20
+ }
21
+
22
+ export interface IntelPullResult {
23
+ totalCollected: number;
24
+ totalSaved: number;
25
+ bySource: Record<string, number>;
26
+ errors: string[];
27
+ }
28
+
29
+ // ─── Orchestrator ───────────────────────────────────────────────────────────
30
+
31
+ function buildCollectors(opts: IntelPullOptions): Collector[] {
32
+ const all: Collector[] = [
33
+ createWebSearchCollector(opts.searchFn),
34
+ createRssCollector(),
35
+ createTrendCollector(opts.searchFn),
36
+ ];
37
+
38
+ if (!opts.skipBrowser) {
39
+ all.push(createCompetitorCollector());
40
+ }
41
+
42
+ if (opts.sources?.length) {
43
+ const allowed = new Set(opts.sources);
44
+ return all.filter((c) => allowed.has(c.id));
45
+ }
46
+
47
+ return all;
48
+ }
49
+
50
+ export async function runIntelPull(opts: IntelPullOptions): Promise<IntelPullResult> {
51
+ const collectors = buildCollectors(opts);
52
+
53
+ const collectorOpts: CollectorOptions = {
54
+ keywords: opts.keywords,
55
+ industry: opts.industry,
56
+ platforms: opts.platforms,
57
+ dataDir: opts.dataDir,
58
+ };
59
+
60
+ const settled = await Promise.allSettled(
61
+ collectors.map((c) => c.collect(collectorOpts)),
62
+ );
63
+
64
+ const allItems: IntelItem[] = [];
65
+ const errors: string[] = [];
66
+ const bySource: Record<string, number> = {};
67
+
68
+ for (const result of settled) {
69
+ if (result.status === "fulfilled") {
70
+ const { items, source, errors: collectorErrors } = result.value;
71
+ allItems.push(...items);
72
+ bySource[source] = (bySource[source] ?? 0) + items.length;
73
+ errors.push(...collectorErrors);
74
+ } else {
75
+ errors.push(`Collector failed: ${result.reason}`);
76
+ }
77
+ }
78
+
79
+ let totalSaved = 0;
80
+ for (const item of allItems) {
81
+ try {
82
+ await saveIntel(item, opts.dataDir);
83
+ totalSaved++;
84
+ } catch (err: unknown) {
85
+ const msg = err instanceof Error ? err.message : String(err);
86
+ errors.push(`Failed to save "${item.title}": ${msg}`);
87
+ }
88
+ }
89
+
90
+ return {
91
+ totalCollected: allItems.length,
92
+ totalSaved,
93
+ bySource,
94
+ errors,
95
+ };
96
+ }
@@ -0,0 +1,113 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import yaml from "js-yaml";
6
+ import { loadSourceConfig, getRecommendedSources } from "./source-config.js";
7
+
8
+ let testDir: string;
9
+
10
+ beforeEach(async () => {
11
+ testDir = await fs.mkdtemp(
12
+ path.join(os.tmpdir(), "autocrew-source-config-test-"),
13
+ );
14
+ // Create pipeline/intel/_sources directory
15
+ await fs.mkdir(
16
+ path.join(testDir, "pipeline", "intel", "_sources"),
17
+ { recursive: true },
18
+ );
19
+ });
20
+
21
+ afterEach(async () => {
22
+ await fs.rm(testDir, { recursive: true, force: true });
23
+ });
24
+
25
+ describe("loadSourceConfig", () => {
26
+ it("loads RSS config from _sources/*.yaml", async () => {
27
+ const configData = {
28
+ rss: [
29
+ { url: "https://example.com/feed", domain: "科技" },
30
+ { url: "https://other.com/rss", domain: "AI", tags: ["ml"] },
31
+ ],
32
+ keywords: ["AI", "LLM"],
33
+ };
34
+ await fs.writeFile(
35
+ path.join(testDir, "pipeline", "intel", "_sources", "feeds.yaml"),
36
+ yaml.dump(configData),
37
+ );
38
+
39
+ const config = await loadSourceConfig(testDir);
40
+ expect(config.rss).toHaveLength(2);
41
+ expect(config.rss[0].url).toBe("https://example.com/feed");
42
+ expect(config.rss[1].tags).toEqual(["ml"]);
43
+ expect(config.keywords).toEqual(["AI", "LLM"]);
44
+ });
45
+
46
+ it("returns empty arrays when no config files exist", async () => {
47
+ const config = await loadSourceConfig(testDir);
48
+ expect(config.rss).toEqual([]);
49
+ expect(config.trends).toEqual([]);
50
+ expect(config.accounts).toEqual([]);
51
+ expect(config.keywords).toEqual([]);
52
+ });
53
+
54
+ it("returns empty arrays when _sources dir does not exist", async () => {
55
+ const emptyDir = await fs.mkdtemp(
56
+ path.join(os.tmpdir(), "autocrew-empty-test-"),
57
+ );
58
+ const config = await loadSourceConfig(emptyDir);
59
+ expect(config.rss).toEqual([]);
60
+ expect(config.trends).toEqual([]);
61
+ await fs.rm(emptyDir, { recursive: true, force: true });
62
+ });
63
+
64
+ it("merges multiple yaml files", async () => {
65
+ const sourcesDir = path.join(testDir, "pipeline", "intel", "_sources");
66
+ await fs.writeFile(
67
+ path.join(sourcesDir, "rss.yaml"),
68
+ yaml.dump({ rss: [{ url: "https://a.com/feed", domain: "A" }] }),
69
+ );
70
+ await fs.writeFile(
71
+ path.join(sourcesDir, "trends.yaml"),
72
+ yaml.dump({ trends: [{ source: "hackernews", min_score: 100 }] }),
73
+ );
74
+
75
+ const config = await loadSourceConfig(testDir);
76
+ expect(config.rss).toHaveLength(1);
77
+ expect(config.trends).toHaveLength(1);
78
+ expect(config.trends[0].source).toBe("hackernews");
79
+ });
80
+ });
81
+
82
+ describe("getRecommendedSources", () => {
83
+ it("recommends sources by exact industry match", async () => {
84
+ const result = await getRecommendedSources("科技");
85
+ expect(result.trends.length).toBeGreaterThan(0);
86
+ expect(result.trends.some((t) => t.source === "hackernews")).toBe(true);
87
+ expect(result.rssSuggestions.length).toBeGreaterThan(0);
88
+ });
89
+
90
+ it("different industries get different sources", async () => {
91
+ const tech = await getRecommendedSources("科技");
92
+ const beauty = await getRecommendedSources("美妆");
93
+
94
+ // 科技 has hackernews, 美妆 does not
95
+ expect(tech.trends.some((t) => t.source === "hackernews")).toBe(true);
96
+ expect(beauty.trends.some((t) => t.source === "hackernews")).toBe(false);
97
+
98
+ // 美妆 has douyin_hot, 科技 does not
99
+ expect(beauty.trends.some((t) => t.source === "douyin_hot")).toBe(true);
100
+ });
101
+
102
+ it("falls back to _default for unknown industry", async () => {
103
+ const result = await getRecommendedSources("未知行业");
104
+ expect(result.trends.length).toBeGreaterThan(0);
105
+ expect(result.trends.some((t) => t.source === "weibo_hot")).toBe(true);
106
+ });
107
+
108
+ it("AI industry includes arxiv and reddit sources", async () => {
109
+ const result = await getRecommendedSources("AI");
110
+ expect(result.trends.some((t) => t.source === "arxiv")).toBe(true);
111
+ expect(result.trends.some((t) => t.source === "reddit")).toBe(true);
112
+ });
113
+ });
@@ -0,0 +1,131 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs/promises";
3
+ import { fileURLToPath } from "node:url";
4
+ import yaml from "js-yaml";
5
+ import { pipelinePath } from "../../storage/pipeline-store.js";
6
+
7
+ // ─── Types ──────────────────────────────────────────────────────────────────
8
+
9
+ export interface RssFeed {
10
+ url: string;
11
+ domain: string;
12
+ tags?: string[];
13
+ }
14
+
15
+ export interface TrendSource {
16
+ source: string;
17
+ enabled?: boolean;
18
+ region?: string;
19
+ subreddits?: string[];
20
+ min_score?: number;
21
+ keywords?: string[];
22
+ categories?: string[];
23
+ }
24
+
25
+ export interface CompetitorAccount {
26
+ platform: string;
27
+ name: string;
28
+ id: string;
29
+ domain: string;
30
+ }
31
+
32
+ export interface SourceConfig {
33
+ rss: RssFeed[];
34
+ trends: TrendSource[];
35
+ accounts: CompetitorAccount[];
36
+ keywords: string[];
37
+ }
38
+
39
+ export interface RecommendedSources {
40
+ trends: TrendSource[];
41
+ rssSuggestions: RssFeed[];
42
+ }
43
+
44
+ // ─── Source Config Loader ───────────────────────────────────────────────────
45
+
46
+ export async function loadSourceConfig(dataDir?: string): Promise<SourceConfig> {
47
+ const sourcesDir = path.join(pipelinePath(dataDir), "intel", "_sources");
48
+ const config: SourceConfig = {
49
+ rss: [],
50
+ trends: [],
51
+ accounts: [],
52
+ keywords: [],
53
+ };
54
+
55
+ let files: string[];
56
+ try {
57
+ files = (await fs.readdir(sourcesDir)).filter((f) => f.endsWith(".yaml") || f.endsWith(".yml"));
58
+ } catch {
59
+ return config;
60
+ }
61
+
62
+ for (const file of files) {
63
+ const content = await fs.readFile(path.join(sourcesDir, file), "utf-8");
64
+ const data = yaml.load(content) as Record<string, unknown> | null;
65
+ if (!data) continue;
66
+
67
+ if (Array.isArray(data.rss)) {
68
+ config.rss.push(...(data.rss as RssFeed[]));
69
+ }
70
+ if (Array.isArray(data.trends)) {
71
+ config.trends.push(...(data.trends as TrendSource[]));
72
+ }
73
+ if (Array.isArray(data.accounts)) {
74
+ config.accounts.push(...(data.accounts as CompetitorAccount[]));
75
+ }
76
+ if (Array.isArray(data.keywords)) {
77
+ config.keywords.push(...(data.keywords as string[]));
78
+ }
79
+ }
80
+
81
+ return config;
82
+ }
83
+
84
+ // ─── Recommended Sources ────────────────────────────────────────────────────
85
+
86
+ interface PresetEntry {
87
+ trends: TrendSource[];
88
+ rss_suggestions: RssFeed[];
89
+ }
90
+
91
+ interface PresetsFile {
92
+ presets: Record<string, PresetEntry>;
93
+ }
94
+
95
+ const __filename = fileURLToPath(import.meta.url);
96
+ const __dirname = path.dirname(__filename);
97
+ const PRESETS_PATH = path.resolve(__dirname, "../../data/source-presets.yaml");
98
+
99
+ export async function getRecommendedSources(industry: string): Promise<RecommendedSources> {
100
+ const content = await fs.readFile(PRESETS_PATH, "utf-8");
101
+ const file = yaml.load(content) as PresetsFile;
102
+ const presets = file.presets;
103
+
104
+ // Exact match
105
+ if (presets[industry]) {
106
+ const preset = presets[industry];
107
+ return {
108
+ trends: preset.trends ?? [],
109
+ rssSuggestions: preset.rss_suggestions ?? [],
110
+ };
111
+ }
112
+
113
+ // Partial match — find first key that contains the industry string or vice versa
114
+ for (const key of Object.keys(presets)) {
115
+ if (key === "_default") continue;
116
+ if (key.includes(industry) || industry.includes(key)) {
117
+ const preset = presets[key];
118
+ return {
119
+ trends: preset.trends ?? [],
120
+ rssSuggestions: preset.rss_suggestions ?? [],
121
+ };
122
+ }
123
+ }
124
+
125
+ // Default fallback
126
+ const fallback = presets._default;
127
+ return {
128
+ trends: fallback?.trends ?? [],
129
+ rssSuggestions: fallback?.rss_suggestions ?? [],
130
+ };
131
+ }
@@ -0,0 +1,144 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import {
6
+ detectPatterns,
7
+ recordDiff,
8
+ listDiffs,
9
+ getPatternFrequency,
10
+ } from "../learnings/diff-tracker.js";
11
+
12
+ let testDir: string;
13
+
14
+ beforeEach(async () => {
15
+ testDir = await fs.mkdtemp(path.join(os.tmpdir(), "autocrew-diff-test-"));
16
+ });
17
+
18
+ afterEach(async () => {
19
+ await fs.rm(testDir, { recursive: true, force: true });
20
+ });
21
+
22
+ describe("detectPatterns", () => {
23
+ it("detects remove_progression_words when 首先/其次/最后 are removed", () => {
24
+ const patterns = detectPatterns(
25
+ "首先我们来看。其次分析。最后总结。",
26
+ "我们来看,分析,总结。",
27
+ );
28
+ expect(patterns).toContain("remove_progression_words");
29
+ });
30
+
31
+ it("detects shorten_content when text gets significantly shorter", () => {
32
+ const long = "这是一个非常非常非常长的句子,包含了很多很多很多的内容,需要被大幅度缩短。这里还有更多内容。";
33
+ const short = "短。";
34
+ const patterns = detectPatterns(long, short);
35
+ expect(patterns).toContain("shorten_content");
36
+ });
37
+
38
+ it("detects add_emoji when 3+ emoji added", () => {
39
+ const patterns = detectPatterns("这是一段文字", "这是一段文字 🎉🔥✨💡");
40
+ expect(patterns).toContain("add_emoji");
41
+ });
42
+
43
+ it("detects reduce_we_pronoun when 2+ 我们 are removed", () => {
44
+ const patterns = detectPatterns(
45
+ "我们来看看这个问题,我们分析原因,我们给出结论。",
46
+ "来看看这个问题,分析原因,给出结论。",
47
+ );
48
+ expect(patterns).toContain("reduce_we_pronoun");
49
+ });
50
+
51
+ it("returns empty array when no patterns detected", () => {
52
+ const patterns = detectPatterns("今天天气不错", "今天天气不错,出去走走");
53
+ // Minor addition — no strong pattern
54
+ expect(Array.isArray(patterns)).toBe(true);
55
+ });
56
+
57
+ it("can detect multiple patterns at once", () => {
58
+ const patterns = detectPatterns(
59
+ "首先我们来看看这个非常非常长的问题,其次我们分析原因,最后我们给出结论。",
60
+ "来看这问题 🎉,分析原因,给结论。",
61
+ );
62
+ expect(patterns.length).toBeGreaterThanOrEqual(2);
63
+ });
64
+ });
65
+
66
+ describe("recordDiff", () => {
67
+ it("saves a diff file and returns the diff object", async () => {
68
+ const diff = await recordDiff("content-001", "body", "原始文本", "修改后文本", testDir);
69
+ expect(diff.id).toMatch(/^diff-/);
70
+ expect(diff.contentId).toBe("content-001");
71
+ expect(diff.before).toBe("原始文本");
72
+ expect(diff.after).toBe("修改后文本");
73
+ expect(diff.createdAt).toBeTruthy();
74
+ });
75
+
76
+ it("truncates very long before/after to 2000 chars", async () => {
77
+ const longText = "x".repeat(5000);
78
+ const diff = await recordDiff("content-002", "body", longText, "short", testDir);
79
+ expect(diff.before.length).toBeLessThanOrEqual(2000);
80
+ });
81
+
82
+ it("persists to disk", async () => {
83
+ await recordDiff("content-003", "title", "旧标题", "新标题", testDir);
84
+ const diffs = await listDiffs(undefined, testDir);
85
+ expect(diffs.length).toBeGreaterThanOrEqual(1);
86
+ });
87
+ });
88
+
89
+ describe("listDiffs", () => {
90
+ it("returns empty array when no diffs exist", async () => {
91
+ const diffs = await listDiffs(undefined, testDir);
92
+ expect(diffs).toEqual([]);
93
+ });
94
+
95
+ it("returns all recorded diffs", async () => {
96
+ await recordDiff("c1", "body", "a", "b", testDir);
97
+ await recordDiff("c2", "body", "c", "d", testDir);
98
+ const diffs = await listDiffs(undefined, testDir);
99
+ expect(diffs.length).toBe(2);
100
+ });
101
+
102
+ it("filters by contentId", async () => {
103
+ await recordDiff("c1", "body", "a", "b", testDir);
104
+ await recordDiff("c2", "body", "c", "d", testDir);
105
+ const diffs = await listDiffs({ contentId: "c1" }, testDir);
106
+ expect(diffs.length).toBe(1);
107
+ expect(diffs[0].contentId).toBe("c1");
108
+ });
109
+ });
110
+
111
+ describe("getPatternFrequency", () => {
112
+ it("returns empty array when no diffs", async () => {
113
+ const freq = await getPatternFrequency(testDir);
114
+ expect(freq).toEqual([]);
115
+ });
116
+
117
+ it("counts pattern occurrences across diffs", async () => {
118
+ // Record 3 diffs that all trigger remove_progression_words
119
+ for (let i = 0; i < 3; i++) {
120
+ await recordDiff(
121
+ `c${i}`,
122
+ "body",
123
+ "首先看,其次分析,最后总结。",
124
+ "看,分析,总结。",
125
+ testDir,
126
+ );
127
+ }
128
+ const freq = await getPatternFrequency(testDir);
129
+ const entry = freq.find((f) => f.pattern === "remove_progression_words");
130
+ expect(entry).toBeDefined();
131
+ expect(entry!.count).toBe(3);
132
+ });
133
+
134
+ it("sorts by frequency descending", async () => {
135
+ // 3x pattern A, 1x pattern B
136
+ for (let i = 0; i < 3; i++) {
137
+ await recordDiff(`ca${i}`, "body", "首先看,其次分析,最后总结。", "看,分析,总结。", testDir);
138
+ }
139
+ await recordDiff("cb1", "body", "我们来看", "来看", testDir);
140
+
141
+ const freq = await getPatternFrequency(testDir);
142
+ expect(freq[0].count).toBeGreaterThanOrEqual(freq[1]?.count ?? 0);
143
+ });
144
+ });