aitable-workflow-core 0.1.13-beta.1
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/dist/chunk-J5QGFP2Q.js +1 -0
- package/dist/index.d.ts +6163 -0
- package/dist/index.js +703 -0
- package/dist/shared/run-state.d.ts +71 -0
- package/dist/shared/run-state.js +1 -0
- package/package.json +43 -0
- package/wolai-crawler/config.default.json +16 -0
- package/wolai-crawler/debug.js +11 -0
- package/wolai-crawler/lib/browser.js +118 -0
- package/wolai-crawler/lib/crawler.js +699 -0
- package/wolai-crawler/lib/debug-page.js +71 -0
- package/wolai-crawler/lib/md-processor.js +223 -0
- package/wolai-crawler/lib/runtime.js +167 -0
- package/wolai-crawler/package.json +20 -0
- package/wolai-crawler/run.js +140 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
|
|
4
|
+
const { launchBrowserContext } = require('./browser');
|
|
5
|
+
const { ensureDir, sleep, resolveRepoPath } = require('./runtime');
|
|
6
|
+
|
|
7
|
+
async function debugPage(url, options = {}) {
|
|
8
|
+
const { headed = false, timeout = 120000 } = options;
|
|
9
|
+
const { browser, context, page } = await launchBrowserContext({ headed });
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
console.log(`→ 打开: ${url}`);
|
|
13
|
+
await page.goto(url, { waitUntil: 'networkidle', timeout });
|
|
14
|
+
await sleep(3000);
|
|
15
|
+
|
|
16
|
+
const title = await page.title();
|
|
17
|
+
console.log(` 标题: ${title}`);
|
|
18
|
+
console.log(` URL: ${page.url()}`);
|
|
19
|
+
|
|
20
|
+
// 截图
|
|
21
|
+
const screenshotDir = ensureDir('.sandbox/kb-validate/.wolai-raw/debug');
|
|
22
|
+
const screenshotPath = path.join(screenshotDir, `debug-${Date.now()}.png`);
|
|
23
|
+
await page.screenshot({ path: screenshotPath, fullPage: true });
|
|
24
|
+
console.log(` 截图: ${screenshotPath}`);
|
|
25
|
+
|
|
26
|
+
// 提取所有按钮/菜单相关元素
|
|
27
|
+
const elements = await page.evaluate(() => {
|
|
28
|
+
const items = [];
|
|
29
|
+
for (const el of document.querySelectorAll('button, [role="button"], a')) {
|
|
30
|
+
const text = el.textContent?.trim();
|
|
31
|
+
const ariaLabel = el.getAttribute('aria-label');
|
|
32
|
+
const title = el.getAttribute('title');
|
|
33
|
+
const className = el.className;
|
|
34
|
+
if ((text && text.length < 50) || ariaLabel || title) {
|
|
35
|
+
items.push({
|
|
36
|
+
tag: el.tagName,
|
|
37
|
+
text: text?.slice(0, 50),
|
|
38
|
+
ariaLabel,
|
|
39
|
+
title,
|
|
40
|
+
className: typeof className === 'string' ? className.slice(0, 100) : '',
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return items.slice(0, 200);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const reportPath = path.join(screenshotDir, `debug-elements-${Date.now()}.json`);
|
|
48
|
+
fs.writeFileSync(reportPath, JSON.stringify(elements, null, 2), 'utf-8');
|
|
49
|
+
console.log(` 元素快照: ${reportPath}`);
|
|
50
|
+
|
|
51
|
+
// 提取所有链接
|
|
52
|
+
const links = await page.evaluate(() => {
|
|
53
|
+
return Array.from(document.querySelectorAll('a[href]'))
|
|
54
|
+
.map((a) => ({
|
|
55
|
+
href: a.getAttribute('href'),
|
|
56
|
+
text: a.textContent?.trim().slice(0, 50),
|
|
57
|
+
}))
|
|
58
|
+
.filter((item) => item.href && (item.href.includes('wolai') || item.href.startsWith('/')))
|
|
59
|
+
.slice(0, 100);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const linksPath = path.join(screenshotDir, `debug-links-${Date.now()}.json`);
|
|
63
|
+
fs.writeFileSync(linksPath, JSON.stringify(links, null, 2), 'utf-8');
|
|
64
|
+
console.log(` 链接快照: ${linksPath}`);
|
|
65
|
+
} finally {
|
|
66
|
+
await context.close();
|
|
67
|
+
await browser.close();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { debugPage };
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
|
|
4
|
+
const { ensureDir, resolveRepoPath, sanitizeFilename, writeJson } = require('./runtime');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 从 wolai 导出包中整理出标准知识库源文件
|
|
8
|
+
*
|
|
9
|
+
* 输入:rawDir/downloads/<session>/*.md + image/
|
|
10
|
+
* 输出:outputDir/<category>/<title>.md,图片放在同级 image/ 下
|
|
11
|
+
*/
|
|
12
|
+
async function processRawDownloads(config) {
|
|
13
|
+
const rawDir = resolveRepoPath(config.rawDir);
|
|
14
|
+
const outputDir = resolveRepoPath(config.outputDir);
|
|
15
|
+
const urlMapping = loadUrlMapping(config.urlMappingFile);
|
|
16
|
+
|
|
17
|
+
ensureDir(outputDir);
|
|
18
|
+
|
|
19
|
+
const downloadsDir = path.join(rawDir, 'downloads');
|
|
20
|
+
if (!fs.existsSync(downloadsDir)) {
|
|
21
|
+
console.log(`⚠ 下载目录不存在,跳过后处理: ${downloadsDir}`);
|
|
22
|
+
return { processed: 0, skipped: 0, images: 0, errors: [] };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const stats = {
|
|
26
|
+
processed: 0,
|
|
27
|
+
skipped: 0,
|
|
28
|
+
images: 0,
|
|
29
|
+
errors: [],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// 遍历所有下载会话
|
|
33
|
+
const sessions = fs.readdirSync(downloadsDir).filter((name) => {
|
|
34
|
+
return fs.statSync(path.join(downloadsDir, name)).isDirectory();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
for (const session of sessions) {
|
|
38
|
+
const sessionPath = path.join(downloadsDir, session);
|
|
39
|
+
|
|
40
|
+
// wolai 导出的是 zip,已解压到 extracted/ 目录
|
|
41
|
+
const extractedPath = path.join(sessionPath, 'extracted');
|
|
42
|
+
const sourcePath = fs.existsSync(extractedPath) && fs.statSync(extractedPath).isDirectory() ? extractedPath : sessionPath;
|
|
43
|
+
|
|
44
|
+
const mdFiles = findMarkdownFiles(sourcePath);
|
|
45
|
+
for (const mdPath of mdFiles) {
|
|
46
|
+
try {
|
|
47
|
+
const result = await processSingleMarkdown(mdPath, {
|
|
48
|
+
outputDir,
|
|
49
|
+
urlMapping,
|
|
50
|
+
rawImageDir: findImageDir(sourcePath),
|
|
51
|
+
});
|
|
52
|
+
if (result.updated) stats.processed++;
|
|
53
|
+
else stats.skipped++;
|
|
54
|
+
stats.images += result.imageCount || 0;
|
|
55
|
+
} catch (err) {
|
|
56
|
+
stats.errors.push({ file: mdPath, error: err.message });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const reportPath = path.join(rawDir, `process-report-${Date.now()}.json`);
|
|
62
|
+
writeJson(reportPath, { ...stats, finishedAt: new Date().toISOString() });
|
|
63
|
+
console.log(`\n📄 整理报告: ${reportPath}`);
|
|
64
|
+
console.log(` 处理: ${stats.processed}`);
|
|
65
|
+
console.log(` 跳过: ${stats.skipped}`);
|
|
66
|
+
console.log(` 图片: ${stats.images}`);
|
|
67
|
+
console.log(` 错误: ${stats.errors.length}`);
|
|
68
|
+
|
|
69
|
+
return stats;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function processSingleMarkdown(mdPath, options) {
|
|
73
|
+
const { outputDir, urlMapping, rawImageDir } = options;
|
|
74
|
+
let content = fs.readFileSync(mdPath, 'utf-8');
|
|
75
|
+
|
|
76
|
+
// 1. 提取标题
|
|
77
|
+
const titleMatch = content.match(/^#\s*(.+)$/m);
|
|
78
|
+
const title = titleMatch ? titleMatch[1].trim() : path.basename(mdPath, '.md');
|
|
79
|
+
|
|
80
|
+
// 2. 找到对应 URL
|
|
81
|
+
const url = findUrlForTitle(title, urlMapping) || findUrlForFile(mdPath, urlMapping);
|
|
82
|
+
|
|
83
|
+
// 3. 确定输出分类和路径
|
|
84
|
+
const category = inferCategory(title, content);
|
|
85
|
+
const categoryDir = ensureDir(path.join(outputDir, category));
|
|
86
|
+
const safeTitle = sanitizeFilename(title);
|
|
87
|
+
const outMdPath = path.join(categoryDir, `${safeTitle}.md`);
|
|
88
|
+
const outImageDir = ensureDir(path.join(categoryDir, 'image'));
|
|
89
|
+
|
|
90
|
+
// 4. 检查是否已存在且内容相同
|
|
91
|
+
if (fs.existsSync(outMdPath)) {
|
|
92
|
+
const existing = fs.readFileSync(outMdPath, 'utf-8');
|
|
93
|
+
// 简单比较:去除来源行后的正文
|
|
94
|
+
if (normalizeForCompare(existing) === normalizeForCompare(content)) {
|
|
95
|
+
return { updated: false, imageCount: 0, outMdPath };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 5. 迁移图片
|
|
100
|
+
let imageCount = 0;
|
|
101
|
+
if (fs.existsSync(rawImageDir)) {
|
|
102
|
+
const images = fs.readdirSync(rawImageDir);
|
|
103
|
+
for (const img of images) {
|
|
104
|
+
const srcPath = path.join(rawImageDir, img);
|
|
105
|
+
const destPath = path.join(outImageDir, img);
|
|
106
|
+
if (!fs.existsSync(destPath) || fs.statSync(srcPath).size !== fs.statSync(destPath).size) {
|
|
107
|
+
fs.copyFileSync(srcPath, destPath);
|
|
108
|
+
}
|
|
109
|
+
imageCount++;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 修正 markdown 中图片路径
|
|
113
|
+
content = content.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
|
|
114
|
+
if (src.startsWith('http')) return match;
|
|
115
|
+
const basename = path.basename(src);
|
|
116
|
+
// 使用相对于 md 文件的路径
|
|
117
|
+
return ``;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 6. 添加/更新来源标注
|
|
122
|
+
if (url && !content.includes('来源:')) {
|
|
123
|
+
content = content.replace(/^#\s*.+\n/m, (match) => `${match}\n> 来源:${url}\n`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 7. 写入文件
|
|
127
|
+
fs.writeFileSync(outMdPath, content, 'utf-8');
|
|
128
|
+
return { updated: true, imageCount, outMdPath };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function findMarkdownFiles(dir) {
|
|
132
|
+
const results = [];
|
|
133
|
+
if (!fs.existsSync(dir)) return results;
|
|
134
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
135
|
+
for (const entry of entries) {
|
|
136
|
+
const fullPath = path.join(dir, entry.name);
|
|
137
|
+
if (entry.isDirectory()) {
|
|
138
|
+
results.push(...findMarkdownFiles(fullPath));
|
|
139
|
+
} else if (entry.name.endsWith('.md')) {
|
|
140
|
+
results.push(fullPath);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return results;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function findImageDir(dir) {
|
|
147
|
+
if (!fs.existsSync(dir)) return null;
|
|
148
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
149
|
+
for (const entry of entries) {
|
|
150
|
+
const fullPath = path.join(dir, entry.name);
|
|
151
|
+
if (entry.isDirectory()) {
|
|
152
|
+
if (entry.name === 'image') return fullPath;
|
|
153
|
+
const nested = findImageDir(fullPath);
|
|
154
|
+
if (nested) return nested;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function loadUrlMapping(urlMappingFile) {
|
|
161
|
+
const absolutePath = resolveRepoPath(urlMappingFile);
|
|
162
|
+
if (!fs.existsSync(absolutePath)) return [];
|
|
163
|
+
try {
|
|
164
|
+
return JSON.parse(fs.readFileSync(absolutePath, 'utf-8'));
|
|
165
|
+
} catch {
|
|
166
|
+
return [];
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function findUrlForTitle(title, urlMapping) {
|
|
171
|
+
if (!urlMapping || urlMapping.length === 0) return null;
|
|
172
|
+
const exact = urlMapping.find((item) => item.title === title);
|
|
173
|
+
if (exact) return exact.url;
|
|
174
|
+
// 模糊匹配
|
|
175
|
+
const fuzzy = urlMapping.find((item) => title.includes(item.title) || item.title.includes(title));
|
|
176
|
+
return fuzzy ? fuzzy.url : null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function findUrlForFile(mdPath, urlMapping) {
|
|
180
|
+
const basename = path.basename(mdPath, '.md');
|
|
181
|
+
return findUrlForTitle(basename, urlMapping);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function inferCategory(title, content) {
|
|
185
|
+
// 根据标题关键词推断分类,匹配现有目录结构
|
|
186
|
+
const rules = [
|
|
187
|
+
{ pattern: /付费|权益|搭建服务|席位/, category: '16-付费权益' },
|
|
188
|
+
{ pattern: /插件|开发者|API|脚本|字段装饰器/, category: '18-开发者指南' },
|
|
189
|
+
{ pattern: /高级权限|行列权限/, category: '12-高级权限' },
|
|
190
|
+
{ pattern: /应用模式|组件|列表|按钮|标签页|过滤器|视图组件|图片|透视表组件/, category: '09-应用模式' },
|
|
191
|
+
{ pattern: /仪表盘|图表|TOP_N|智能总结/, category: '08-使用仪表盘' },
|
|
192
|
+
{ pattern: /视图|看板|甘特|日历|画册|表格视图|查询页面|数据表/, category: '07-使用视图' },
|
|
193
|
+
{ pattern: /数据连接|同步|数据源|听记|OA|待办|日程|考勤|通讯录|表格数据/, category: '06-数据连接中心' },
|
|
194
|
+
{ pattern: /公式|函数/, category: '05-公式函数' },
|
|
195
|
+
{ pattern: /表单/, category: '04-使用表单' },
|
|
196
|
+
{ pattern: /字段|AI字段|字段类型/, category: '03-使用字段' },
|
|
197
|
+
{ pattern: /自动化|工作流|webhook|Webhook|机器人|Agent|群发|HTTP|邮件|待办|日程|审批|外呼/, category: '10-自动化工作流' },
|
|
198
|
+
{ pattern: /快捷键|导入|导出|模板|常见问题|快速上手|入门|行数|上限/, category: '01-快速入门' },
|
|
199
|
+
{ pattern: /分组|排序|筛选|历史记录/, category: '02-基础操作' },
|
|
200
|
+
];
|
|
201
|
+
|
|
202
|
+
for (const rule of rules) {
|
|
203
|
+
if (rule.pattern.test(title) || rule.pattern.test(content.slice(0, 500))) {
|
|
204
|
+
return rule.category;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return '99-未分类';
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function normalizeForCompare(content) {
|
|
212
|
+
return content
|
|
213
|
+
.replace(/^---[\s\S]*?---\n?/m, '')
|
|
214
|
+
.replace(/^>\s*来源:.*\n?/m, '')
|
|
215
|
+
.replace(/\s+/g, '')
|
|
216
|
+
.trim();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
module.exports = {
|
|
220
|
+
processRawDownloads,
|
|
221
|
+
processSingleMarkdown,
|
|
222
|
+
inferCategory,
|
|
223
|
+
};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
|
|
4
|
+
// 允许调用方通过 --repo-root 指定项目根目录(发布包场景下无法从 __dirname 推断)
|
|
5
|
+
const explicitRepoRoot = (() => {
|
|
6
|
+
const idx = process.argv.indexOf('--repo-root');
|
|
7
|
+
if (idx !== -1 && process.argv[idx + 1]) {
|
|
8
|
+
return path.resolve(process.argv[idx + 1]);
|
|
9
|
+
}
|
|
10
|
+
const env = process.env.WOLAI_REPO_ROOT;
|
|
11
|
+
if (env) return path.resolve(env);
|
|
12
|
+
return null;
|
|
13
|
+
})();
|
|
14
|
+
|
|
15
|
+
function findRepoRoot() {
|
|
16
|
+
// 从 wolai-crawler 所在目录向上查找,直到遇到 monorepo 根目录的 package.json
|
|
17
|
+
let dir = __dirname;
|
|
18
|
+
for (let i = 0; i < 12; i++) {
|
|
19
|
+
const pkgPath = path.join(dir, 'package.json');
|
|
20
|
+
if (fs.existsSync(pkgPath)) {
|
|
21
|
+
try {
|
|
22
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
23
|
+
if (pkg.name === 'aitable-workflow-monorepo' || pkg.workspaces) {
|
|
24
|
+
return dir;
|
|
25
|
+
}
|
|
26
|
+
} catch { /* ignore */ }
|
|
27
|
+
}
|
|
28
|
+
const parent = path.dirname(dir);
|
|
29
|
+
if (parent === dir) break;
|
|
30
|
+
dir = parent;
|
|
31
|
+
}
|
|
32
|
+
// 兜底:兼容旧位置 scripts/wolai-crawler/
|
|
33
|
+
return path.resolve(__dirname, '../../..');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const repoRoot = explicitRepoRoot || findRepoRoot();
|
|
37
|
+
|
|
38
|
+
function resolveRepoPath(filePath) {
|
|
39
|
+
if (!filePath) return filePath;
|
|
40
|
+
if (path.isAbsolute(filePath)) return filePath;
|
|
41
|
+
const normalized = path.normalize(filePath);
|
|
42
|
+
if (normalized.startsWith('..')) {
|
|
43
|
+
throw new Error(`配置路径不能跳出仓库根目录: ${filePath}。请使用相对于项目根目录 ${repoRoot} 的路径。`);
|
|
44
|
+
}
|
|
45
|
+
return path.resolve(repoRoot, normalized);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function ensureDir(dirPath) {
|
|
49
|
+
const absolutePath = resolveRepoPath(dirPath);
|
|
50
|
+
if (!fs.existsSync(absolutePath)) {
|
|
51
|
+
fs.mkdirSync(absolutePath, { recursive: true });
|
|
52
|
+
}
|
|
53
|
+
return absolutePath;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readJson(filePath) {
|
|
57
|
+
const absolutePath = resolveRepoPath(filePath);
|
|
58
|
+
return JSON.parse(fs.readFileSync(absolutePath, 'utf-8'));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function writeJson(filePath, data) {
|
|
62
|
+
const absolutePath = resolveRepoPath(filePath);
|
|
63
|
+
ensureDir(path.dirname(absolutePath));
|
|
64
|
+
fs.writeFileSync(absolutePath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
|
65
|
+
return absolutePath;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function sleep(ms) {
|
|
69
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function sanitizeFilename(name) {
|
|
73
|
+
return name
|
|
74
|
+
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_')
|
|
75
|
+
.replace(/\s+/g, ' ')
|
|
76
|
+
.trim()
|
|
77
|
+
.slice(0, 120);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parseArgs(argv = process.argv.slice(2)) {
|
|
81
|
+
const args = {
|
|
82
|
+
config: null,
|
|
83
|
+
login: false,
|
|
84
|
+
headed: false,
|
|
85
|
+
processOnly: false,
|
|
86
|
+
force: false,
|
|
87
|
+
loginWaitMs: null,
|
|
88
|
+
url: null,
|
|
89
|
+
output: null,
|
|
90
|
+
repoRoot: null,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
for (let i = 0; i < argv.length; i++) {
|
|
94
|
+
const arg = argv[i];
|
|
95
|
+
switch (arg) {
|
|
96
|
+
case '--config':
|
|
97
|
+
case '-c':
|
|
98
|
+
args.config = argv[++i];
|
|
99
|
+
break;
|
|
100
|
+
case '--login':
|
|
101
|
+
case '-l':
|
|
102
|
+
args.login = true;
|
|
103
|
+
break;
|
|
104
|
+
case '--login-wait-ms':
|
|
105
|
+
args.loginWaitMs = Number(argv[++i]) || null;
|
|
106
|
+
break;
|
|
107
|
+
case '--url':
|
|
108
|
+
case '-u':
|
|
109
|
+
args.url = argv[++i];
|
|
110
|
+
break;
|
|
111
|
+
case '--output':
|
|
112
|
+
case '-o':
|
|
113
|
+
args.output = argv[++i];
|
|
114
|
+
break;
|
|
115
|
+
case '--repo-root':
|
|
116
|
+
args.repoRoot = argv[++i];
|
|
117
|
+
break;
|
|
118
|
+
case '--headed':
|
|
119
|
+
case '-H':
|
|
120
|
+
args.headed = true;
|
|
121
|
+
break;
|
|
122
|
+
case '--process-only':
|
|
123
|
+
case '-p':
|
|
124
|
+
args.processOnly = true;
|
|
125
|
+
break;
|
|
126
|
+
case '--force':
|
|
127
|
+
case '-f':
|
|
128
|
+
args.force = true;
|
|
129
|
+
break;
|
|
130
|
+
default:
|
|
131
|
+
if (arg.startsWith('--config=')) {
|
|
132
|
+
args.config = arg.slice('--config='.length);
|
|
133
|
+
} else if (arg.startsWith('--login-wait-ms=')) {
|
|
134
|
+
args.loginWaitMs = Number(arg.slice('--login-wait-ms='.length)) || null;
|
|
135
|
+
} else if (arg.startsWith('--url=')) {
|
|
136
|
+
args.url = arg.slice('--url='.length);
|
|
137
|
+
} else if (arg.startsWith('--output=')) {
|
|
138
|
+
args.output = arg.slice('--output='.length);
|
|
139
|
+
} else if (arg.startsWith('--repo-root=')) {
|
|
140
|
+
args.repoRoot = arg.slice('--repo-root='.length);
|
|
141
|
+
}
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return args;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function requireDependency(name) {
|
|
150
|
+
try {
|
|
151
|
+
return require(require.resolve(name, { paths: [path.resolve(__dirname, '..'), repoRoot] }));
|
|
152
|
+
} catch (err) {
|
|
153
|
+
throw new Error(`依赖 ${name} 未找到。请先运行: pnpm --filter wolai-crawler install 或 npm install`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
module.exports = {
|
|
158
|
+
repoRoot,
|
|
159
|
+
resolveRepoPath,
|
|
160
|
+
ensureDir,
|
|
161
|
+
readJson,
|
|
162
|
+
writeJson,
|
|
163
|
+
sleep,
|
|
164
|
+
sanitizeFilename,
|
|
165
|
+
parseArgs,
|
|
166
|
+
requireDependency,
|
|
167
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "wolai-crawler",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "从 wolai 帮助中心自动导出 markdown 并整理为知识库源文件",
|
|
6
|
+
"type": "commonjs",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"login:wolai": "node run.js --config .sandbox/kb-validate/wolai-crawler-config.json --login",
|
|
9
|
+
"login:wolai:auto": "node run.js --config .sandbox/kb-validate/wolai-crawler-config.json --login --login-wait-ms 60000",
|
|
10
|
+
"crawl": "node run.js --config .sandbox/kb-validate/wolai-crawler-config.json",
|
|
11
|
+
"crawl:headed": "node run.js --config .sandbox/kb-validate/wolai-crawler-config.json --headed",
|
|
12
|
+
"export:page": "node run.js --config .sandbox/kb-validate/wolai-crawler-config.json --url",
|
|
13
|
+
"process": "node run.js --config .sandbox/kb-validate/wolai-crawler-config.json --process-only",
|
|
14
|
+
"debug": "node debug.js",
|
|
15
|
+
"debug:headed": "node debug.js --headed"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"playwright": "^1.52.0"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* wolai 帮助中心爬虫
|
|
5
|
+
*
|
|
6
|
+
* 用法:
|
|
7
|
+
* 1. 登录并保存登录态:
|
|
8
|
+
* node packages/core/src/kb/wolai-crawler/run.js --config .sandbox/kb-validate/wolai-crawler-config.json --login
|
|
9
|
+
* 2. 全量爬取(无头):
|
|
10
|
+
* node packages/core/src/kb/wolai-crawler/run.js --config .sandbox/kb-validate/wolai-crawler-config.json
|
|
11
|
+
* 3. 有头模式调试验证:
|
|
12
|
+
* node packages/core/src/kb/wolai-crawler/run.js --config .sandbox/kb-validate/wolai-crawler-config.json --headed
|
|
13
|
+
* 4. 仅处理后处理:
|
|
14
|
+
* node packages/core/src/kb/wolai-crawler/run.js --config .sandbox/kb-validate/wolai-crawler-config.json --process-only
|
|
15
|
+
* 5. 强制重新爬取(不跳过已存在):
|
|
16
|
+
* node packages/core/src/kb/wolai-crawler/run.js --config .sandbox/kb-validate/wolai-crawler-config.json --force
|
|
17
|
+
* 6. 单页导出测试:
|
|
18
|
+
* node packages/core/src/kb/wolai-crawler/run.js --config .sandbox/kb-validate/wolai-crawler-config.json --url https://wolai.dingtalk.com/xxx
|
|
19
|
+
* 7. 指定输出目录(覆盖配置):
|
|
20
|
+
* node packages/core/src/kb/wolai-crawler/run.js --config .sandbox/kb-validate/wolai-crawler-config.json --url https://wolai.dingtalk.com/xxx --output ./wiki
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const { parseArgs, readJson, resolveRepoPath, ensureDir } = require('./lib/runtime');
|
|
24
|
+
const { launchBrowserContext } = require('./lib/browser');
|
|
25
|
+
const { loginAndSaveStorageState, runCrawl } = require('./lib/crawler');
|
|
26
|
+
const { processRawDownloads } = require('./lib/md-processor');
|
|
27
|
+
|
|
28
|
+
function loadConfig(configPath) {
|
|
29
|
+
if (!configPath) {
|
|
30
|
+
console.error('Usage: node packages/core/src/kb/wolai-crawler/run.js --config <path> [--login] [--headed] [--process-only] [--force] [--output <dir>]');
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
return readJson(configPath);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function copyDirectoryContents(srcDir, destDir, { flatten = false } = {}) {
|
|
37
|
+
const fs = require('node:fs');
|
|
38
|
+
const path = require('node:path');
|
|
39
|
+
ensureDir(destDir);
|
|
40
|
+
let sourceDir = srcDir;
|
|
41
|
+
// 如果只有一个子目录且没有文件,将其视为容器目录并铺平
|
|
42
|
+
if (flatten) {
|
|
43
|
+
const entries = fs.readdirSync(sourceDir, { withFileTypes: true });
|
|
44
|
+
const dirs = entries.filter((e) => e.isDirectory());
|
|
45
|
+
const files = entries.filter((e) => e.isFile());
|
|
46
|
+
if (dirs.length === 1 && files.length === 0) {
|
|
47
|
+
sourceDir = path.join(sourceDir, dirs[0].name);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const entries = fs.readdirSync(sourceDir, { withFileTypes: true });
|
|
51
|
+
for (const entry of entries) {
|
|
52
|
+
const srcPath = path.join(sourceDir, entry.name);
|
|
53
|
+
const destPath = path.join(destDir, entry.name);
|
|
54
|
+
if (entry.isDirectory()) {
|
|
55
|
+
copyDirectoryContents(srcPath, destPath);
|
|
56
|
+
} else {
|
|
57
|
+
fs.copyFileSync(srcPath, destPath);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function main() {
|
|
63
|
+
const args = parseArgs();
|
|
64
|
+
const config = loadConfig(args.config);
|
|
65
|
+
|
|
66
|
+
// CLI --output 覆盖配置里的 outputDir
|
|
67
|
+
if (args.output) {
|
|
68
|
+
config.outputDir = args.output;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (args.login) {
|
|
72
|
+
await loginAndSaveStorageState(config, { loginWaitMs: args.loginWaitMs });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (args.processOnly) {
|
|
77
|
+
await processRawDownloads(config);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 单页测试模式
|
|
82
|
+
if (args.url) {
|
|
83
|
+
const { exportPage } = require('./lib/crawler');
|
|
84
|
+
const { browser, context, page } = await launchBrowserContext({
|
|
85
|
+
storageState: config.storageState,
|
|
86
|
+
headed: true,
|
|
87
|
+
});
|
|
88
|
+
try {
|
|
89
|
+
const result = await exportPage(page, { url: args.url, title: 'single-test' }, ensureDir(config.rawDir), { ...config, headed: true });
|
|
90
|
+
console.log(`\n✓ 单页测试成功: ${result.filePath}`);
|
|
91
|
+
|
|
92
|
+
// 如果指定了 --output,把最终内容(md 或解压目录)复制到输出目录
|
|
93
|
+
if (args.output) {
|
|
94
|
+
const fs = require('node:fs');
|
|
95
|
+
const path = require('node:path');
|
|
96
|
+
const outputDir = ensureDir(config.outputDir);
|
|
97
|
+
if (result.isZip) {
|
|
98
|
+
copyDirectoryContents(result.filePath, outputDir, { flatten: true });
|
|
99
|
+
} else {
|
|
100
|
+
const destMd = path.join(outputDir, path.basename(result.filePath));
|
|
101
|
+
fs.copyFileSync(result.filePath, destMd);
|
|
102
|
+
// 同时复制同目录下的 image/ 文件夹(如果存在)
|
|
103
|
+
const sourceImageDir = path.join(path.dirname(result.filePath), 'image');
|
|
104
|
+
if (fs.existsSync(sourceImageDir)) {
|
|
105
|
+
copyDirectoryContents(sourceImageDir, path.join(outputDir, 'image'));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
console.log(`✓ 已复制到输出目录: ${outputDir}`);
|
|
110
|
+
}
|
|
111
|
+
} finally {
|
|
112
|
+
await context.close();
|
|
113
|
+
await browser.close();
|
|
114
|
+
}
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const results = await runCrawl(config, {
|
|
119
|
+
force: args.force,
|
|
120
|
+
headed: args.headed,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// 默认爬取成功后自动后处理
|
|
124
|
+
if (results.pages.some((p) => p.filePath)) {
|
|
125
|
+
console.log('\n→ 开始整理下载文件...');
|
|
126
|
+
await processRawDownloads(config);
|
|
127
|
+
} else {
|
|
128
|
+
console.log('\n⚠ 没有成功下载的页面,跳过后处理');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
main()
|
|
133
|
+
.then(() => {
|
|
134
|
+
// 显式退出,避免 Playwright 残留 timer/worker 导致子进程挂起
|
|
135
|
+
process.exit(0);
|
|
136
|
+
})
|
|
137
|
+
.catch((err) => {
|
|
138
|
+
console.error('Fatal error:', err);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
});
|