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.
@@ -0,0 +1,699 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+ const { execFileSync } = require('node:child_process');
4
+
5
+ const {
6
+ ensureDir,
7
+ resolveRepoPath,
8
+ sleep,
9
+ sanitizeFilename,
10
+ writeJson,
11
+ } = require('./runtime');
12
+ const { launchBrowserContext, saveStorageState } = require('./browser');
13
+
14
+ async function navigateWithRetry(page, url, { timeout = 120000, retries = 2 } = {}) {
15
+ let lastError;
16
+ for (let i = 0; i <= retries; i++) {
17
+ try {
18
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout });
19
+ return;
20
+ } catch (err) {
21
+ lastError = err;
22
+ if (i < retries) {
23
+ console.log(` ⚠ 导航失败,重试 ${i + 1}/${retries}: ${url}`);
24
+ await sleep(2000);
25
+ }
26
+ }
27
+ }
28
+ throw lastError;
29
+ }
30
+
31
+ /**
32
+ * 从当前页面提取所有 wolai 内部页面链接
33
+ * 多策略:先尝试 DOM 目录树,再尝试所有内部链接
34
+ */
35
+ async function collectPageUrls(page, startUrl, options = {}) {
36
+ const { pageWaitMs = 3000, timeout = 120000 } = options;
37
+
38
+ console.log(`→ 打开目录页: ${startUrl}`);
39
+ await navigateWithRetry(page, startUrl, { timeout });
40
+ await sleep(pageWaitMs);
41
+
42
+ const result = await page.evaluate(() => {
43
+ const results = [];
44
+ const seen = new Set();
45
+
46
+ function add(url, title = '') {
47
+ if (!url) return;
48
+ let fullUrl = url;
49
+ if (url.startsWith('/')) {
50
+ fullUrl = `https://wolai.dingtalk.com${url}`;
51
+ }
52
+ if (!fullUrl.startsWith('https://wolai.dingtalk.com/')) return;
53
+ if (fullUrl === window.location.href) return;
54
+ if (seen.has(fullUrl)) return;
55
+ seen.add(fullUrl);
56
+ results.push({ url: fullUrl, title: title.trim().slice(0, 200) });
57
+ }
58
+
59
+ // 策略0:尝试从全局状态提取页面树
60
+ try {
61
+ const globals = [
62
+ window.__INITIAL_STATE__,
63
+ window.__DATA__,
64
+ window.__PRELOADED_STATE__,
65
+ window.__APP_STATE__,
66
+ ];
67
+ for (const state of globals) {
68
+ if (!state) continue;
69
+ const text = JSON.stringify(state);
70
+ const matches = text.match(/"[a-zA-Z0-9]{10,30}":/g) || [];
71
+ // 这里只记录可能包含页面 ID 的全局变量名,实际解析需要看到结构
72
+ }
73
+ } catch { /* ignore */ }
74
+
75
+ // 策略1:常见目录树选择器
76
+ const treeSelectors = [
77
+ '.sidebar a',
78
+ '.page-tree a',
79
+ '.catalog a',
80
+ '.outline a',
81
+ 'nav a',
82
+ '[class*="tree"] a',
83
+ '[class*="catalog"] a',
84
+ '[class*="sidebar"] a',
85
+ '[class*="outline"] a',
86
+ '[class*="menu"] a',
87
+ '[class*="toc"] a',
88
+ '[class*="index"] a',
89
+ '[class*="directory"] a',
90
+ ];
91
+ for (const selector of treeSelectors) {
92
+ for (const el of document.querySelectorAll(selector)) {
93
+ const href = el.getAttribute('href');
94
+ const title = el.textContent || el.getAttribute('title') || '';
95
+ add(href, title);
96
+ }
97
+ }
98
+
99
+ // 策略2:页面中所有内部 a 标签(兜底)
100
+ if (results.length === 0) {
101
+ for (const el of document.querySelectorAll('a[href]')) {
102
+ const href = el.getAttribute('href');
103
+ const title = el.textContent || el.getAttribute('title') || '';
104
+ add(href, title);
105
+ }
106
+ }
107
+
108
+ return {
109
+ urls: results,
110
+ globalKeys: Object.keys(window).filter((k) => /STATE|DATA|APP|wolai/i.test(k)).slice(0, 20),
111
+ };
112
+ });
113
+
114
+ const { urls, globalKeys } = result;
115
+
116
+ if (urls.length === 0) {
117
+ console.log(' ⚠ 未采集到子页面链接,保存目录页快照...');
118
+ if (globalKeys.length > 0) {
119
+ console.log(` 检测到可疑全局变量: ${globalKeys.join(', ')}`);
120
+ }
121
+ await savePageSnapshot(page, startUrl, options);
122
+ }
123
+
124
+ console.log(` ✓ 采集到 ${urls.length} 个子页面链接`);
125
+ return urls;
126
+ }
127
+
128
+ async function savePageSnapshot(page, url, options = {}) {
129
+ const fs = require('node:fs');
130
+ const { ensureDir } = require('./runtime');
131
+ const snapshotDir = ensureDir('.sandbox/kb-validate/.wolai-raw/debug');
132
+ const timestamp = Date.now();
133
+
134
+ // 截图
135
+ const screenshotPath = `${snapshotDir}/snapshot-${timestamp}.png`;
136
+ try {
137
+ await page.screenshot({ path: screenshotPath, fullPage: true });
138
+ console.log(` 截图: ${screenshotPath}`);
139
+ } catch (err) {
140
+ console.log(` 截图失败: ${err.message}`);
141
+ }
142
+
143
+ // 页面 HTML
144
+ const htmlPath = `${snapshotDir}/snapshot-${timestamp}.html`;
145
+ try {
146
+ const html = await page.content();
147
+ fs.writeFileSync(htmlPath, html, 'utf-8');
148
+ console.log(` HTML: ${htmlPath}`);
149
+ } catch (err) {
150
+ console.log(` HTML 保存失败: ${err.message}`);
151
+ }
152
+
153
+ // 元素快照
154
+ const elementsPath = `${snapshotDir}/snapshot-${timestamp}-elements.json`;
155
+ try {
156
+ const elements = await page.evaluate(() => {
157
+ return Array.from(document.querySelectorAll('a[href], button, [role="button"]'))
158
+ .map((el) => ({
159
+ tag: el.tagName,
160
+ href: el.getAttribute('href'),
161
+ text: el.textContent?.trim().slice(0, 80),
162
+ ariaLabel: el.getAttribute('aria-label'),
163
+ title: el.getAttribute('title'),
164
+ className: typeof el.className === 'string' ? el.className.slice(0, 150) : '',
165
+ }))
166
+ .filter((item) => item.href || item.text || item.ariaLabel || item.title)
167
+ .slice(0, 300);
168
+ });
169
+ fs.writeFileSync(elementsPath, JSON.stringify(elements, null, 2), 'utf-8');
170
+ console.log(` 元素: ${elementsPath}`);
171
+ } catch (err) {
172
+ console.log(` 元素快照失败: ${err.message}`);
173
+ }
174
+ }
175
+
176
+ /**
177
+ * 点击右上角「⋯ → 导出页面...」导出当前页面
178
+ * 由于 wolai DOM 可能变化,这里使用多 selector 尝试,
179
+ * 失败时可在 headed 模式下由用户接管。
180
+ */
181
+ async function exportPage(page, { url, title }, rawDir, options = {}) {
182
+ const {
183
+ pageWaitMs = 10000,
184
+ downloadWaitMs = 30000,
185
+ timeout = 120000,
186
+ headed = false,
187
+ } = options;
188
+
189
+ // 有头模式下给用户足够时间手动干预
190
+ const effectiveDownloadWaitMs = headed ? Math.max(downloadWaitMs, 1800000) : downloadWaitMs;
191
+
192
+ console.log(`→ 导出: ${title || url}`);
193
+ await navigateWithRetry(page, url, { timeout });
194
+
195
+ // 校验登录态:若仍处于演示模式,说明 storageState 未生效
196
+ const loginCheck = await detectLoginState(page);
197
+ if (!loginCheck.isLoggedIn) {
198
+ throw new Error(
199
+ `当前页面仍为未登录/演示模式(演示模式=${loginCheck.presentationMode}, 登录按钮=${loginCheck.loginButton})。\n` +
200
+ '请确认:\n' +
201
+ '1. 登录态文件存在且未过期;\n' +
202
+ '2. 你已完成 wolai 登录/扫码;\n' +
203
+ '3. 你对目标页面具有导出权限。\n' +
204
+ '如需重新登录,请删除登录态文件后重试。',
205
+ );
206
+ }
207
+
208
+ // 等待网络空闲和页面稳定
209
+ console.log(' 等待页面渲染...');
210
+ try {
211
+ await page.waitForLoadState('networkidle', { timeout: 20000 });
212
+ } catch { /* ignore */ }
213
+ await sleep(pageWaitMs);
214
+
215
+ // 等待页面主要内容渲染
216
+ try {
217
+ await page.waitForFunction(() => document.readyState === 'complete', { timeout: 10000 });
218
+ } catch { /* ignore */ }
219
+
220
+ // 检测并关闭演示模式新手引导弹窗
221
+ const hasPreviewGuide = await page.evaluate(() => {
222
+ const bodyText = document.body.innerText;
223
+ return bodyText.includes('了解如何使用演示模式') ||
224
+ bodyText.includes('将文档轻松转换成幻灯片') ||
225
+ (bodyText.includes('立即使用') && bodyText.includes('演示模式'));
226
+ }).catch(() => false);
227
+
228
+ if (hasPreviewGuide) {
229
+ console.log(' 检测到演示模式新手引导,尝试关闭...');
230
+ try {
231
+ // 点击「立即使用」关闭引导
232
+ const guideCloseSelectors = [
233
+ 'text=立即使用',
234
+ 'button:has-text("立即使用")',
235
+ '.wolaiModal button:has-text("立即使用")',
236
+ '[class*="wolaiModal"] button:has-text("立即使用")',
237
+ ];
238
+ for (const selector of guideCloseSelectors) {
239
+ const btn = page.locator(selector).first();
240
+ if (await btn.isVisible({ timeout: 3000 }).catch(() => false)) {
241
+ await btn.click({ timeout: 5000 });
242
+ console.log(` ✓ 关闭演示模式引导: ${selector}`);
243
+ await sleep(3000);
244
+ break;
245
+ }
246
+ }
247
+
248
+ // 有头模式下如果还没关掉,暂停让用户手动处理
249
+ const stillHasGuide = await page.evaluate(() => {
250
+ const bodyText = document.body.innerText;
251
+ return bodyText.includes('了解如何使用演示模式') || bodyText.includes('将文档轻松转换成幻灯片');
252
+ }).catch(() => false);
253
+
254
+ if (stillHasGuide && headed) {
255
+ console.log(' 自动关闭引导失败,请在浏览器中手动关闭演示模式引导弹窗');
256
+ console.log(' 关闭后按回车键继续...');
257
+ await new Promise((resolve) => {
258
+ process.stdin.once('data', resolve);
259
+ });
260
+ }
261
+ } catch (err) {
262
+ console.log(` 关闭引导失败: ${err.message}`);
263
+ }
264
+ }
265
+
266
+ // 每个页面独立的下载目录
267
+ const sessionDir = path.join(rawDir, 'downloads', `${Date.now()}_${sanitizeFilename(title || 'untitled')}`);
268
+ ensureDir(sessionDir);
269
+
270
+ // 监听下载(context 级别,兼容 Wolai 在新标签页/新窗口触发下载的情况)
271
+ const context = page.context();
272
+
273
+ const downloadedFiles = [];
274
+ const collectDownload = async (download) => {
275
+ const filePath = path.join(sessionDir, download.suggestedFilename());
276
+ try {
277
+ await download.saveAs(filePath);
278
+ downloadedFiles.push(filePath);
279
+ console.log(` [download] 收到下载: ${path.basename(filePath)}`);
280
+ } catch (err) {
281
+ console.log(` [download] 保存失败: ${err.message}`);
282
+ }
283
+ };
284
+ context.on('page', (newPage) => {
285
+ newPage.on('download', collectDownload);
286
+ });
287
+ page.on('download', collectDownload);
288
+
289
+ const downloadPromise = new Promise((resolve, reject) => {
290
+ let resolved = false;
291
+ const timer = setTimeout(() => {
292
+ if (!resolved) reject(new Error(`下载超时 (${effectiveDownloadWaitMs}ms),未收到 download 事件`));
293
+ }, effectiveDownloadWaitMs);
294
+ const done = (filePath) => {
295
+ if (resolved) return;
296
+ resolved = true;
297
+ clearTimeout(timer);
298
+ clearInterval(interval);
299
+ resolve({ filePath });
300
+ };
301
+ const checkDownloaded = () => {
302
+ if (downloadedFiles.length > 0) done(downloadedFiles[0]);
303
+ };
304
+ const interval = setInterval(() => {
305
+ checkDownloaded();
306
+ // 兜底:扫描下载目录(即使未触发 download 事件,Playwright 也会因 setDefaultDownloadsPath 自动保存)
307
+ try {
308
+ const files = fs.readdirSync(sessionDir).filter((f) => !f.startsWith('.'));
309
+ if (files.length > 0 && downloadedFiles.length === 0) {
310
+ done(path.join(sessionDir, files[0]));
311
+ }
312
+ } catch { /* ignore */ }
313
+ }, 500);
314
+ checkDownloaded();
315
+ });
316
+
317
+ // 尝试点击右上角「更多」菜单
318
+ const moreButtonSelectors = [
319
+ '#tour-tool-bar > div:last-child',
320
+ '#tour-tool-bar svg path[d*="M8 6.8a1.2 1.2"]',
321
+ '[class*="liqjz"]',
322
+ 'button[aria-label="更多"]',
323
+ 'button[title="更多"]',
324
+ '.page-header .anticon-more',
325
+ '.page-header [class*="more"]',
326
+ 'header [class*="more"] button',
327
+ '[class*="page-option"]',
328
+ '[class*="header-right"] button:last-child',
329
+ ];
330
+
331
+ // 调试:打印页面中 svg 数量和候选菜单按钮信息
332
+ try {
333
+ const debugInfo = await page.evaluate(() => {
334
+ const svgs = Array.from(document.querySelectorAll('svg'));
335
+ const candidates = svgs
336
+ .map((svg) => {
337
+ const path = svg.querySelector('path');
338
+ return {
339
+ d: path?.getAttribute('d')?.slice(0, 80) || '',
340
+ className: svg.closest('[class]')?.className?.toString().slice(0, 120) || '',
341
+ };
342
+ })
343
+ .filter((item) => item.d.includes('1.2') || item.d.includes('M8') || item.d.includes('8 6'));
344
+ return { svgCount: svgs.length, candidates };
345
+ });
346
+ console.log(` [debug] 页面 svg 数量: ${debugInfo.svgCount}, 候选菜单图标: ${debugInfo.candidates.length}`);
347
+ debugInfo.candidates.slice(0, 10).forEach((c, i) => console.log(` ${i}: d="${c.d}..." class="${c.className}"`));
348
+ } catch (err) {
349
+ console.log(` [debug] 获取 svg 信息失败: ${err.message}`);
350
+ }
351
+
352
+ // 重试查找并点击菜单按钮,点击后验证是否出现导出菜单
353
+ let moreClicked = false;
354
+ const maxAttempts = headed ? 6 : 3;
355
+ for (let attempt = 0; attempt < maxAttempts && !moreClicked; attempt++) {
356
+ if (attempt > 0) {
357
+ console.log(` 第 ${attempt + 1} 次尝试定位菜单按钮...`);
358
+ await sleep(2000);
359
+ }
360
+
361
+ for (const selector of moreButtonSelectors) {
362
+ try {
363
+ const loc = page.locator(selector).first();
364
+ const visible = await loc.isVisible({ timeout: 3000 }).catch(() => false);
365
+ if (visible) {
366
+ await loc.click({ timeout: 5000 });
367
+ await sleep(1000);
368
+ // 验证是否出现导出菜单
369
+ const hasExportMenu = await page.locator('text=导出页面').first().isVisible({ timeout: 2000 }).catch(() => false);
370
+ if (hasExportMenu) {
371
+ moreClicked = true;
372
+ console.log(` ✓ 点击菜单按钮: ${selector}`);
373
+ break;
374
+ }
375
+ console.log(` ✗ ${selector} 点错,未出现导出菜单`);
376
+ }
377
+ } catch { /* ignore */ }
378
+ }
379
+
380
+ // 兜底:在 tour-tool-bar 内通过 SVG path 找三个点的更多按钮
381
+ if (!moreClicked) {
382
+ try {
383
+ const clicked = await page.evaluate(() => {
384
+ const toolbar = document.querySelector('#tour-tool-bar');
385
+ const svgs = toolbar ? toolbar.querySelectorAll('svg') : document.querySelectorAll('svg');
386
+ for (const svg of svgs) {
387
+ const path = svg.querySelector('path');
388
+ const d = path?.getAttribute('d') || '';
389
+ if (d.includes('M8 6.8a1.2 1.2') || d.includes('a1.2 1.2 0 1 1 0 2.4') || /M\d+\.?\d* \d+\.?\d*a1\.2 1\.2/.test(d)) {
390
+ // 向上找可点击父元素,优先找 d-flex 容器
391
+ let el = svg.closest('[class*="d-flex"]') || svg.parentElement;
392
+ while (el && el.tagName !== 'BUTTON' && !el.getAttribute('role') && el.tagName !== 'A') {
393
+ el = el.parentElement;
394
+ }
395
+ (el || svg).click();
396
+ return { ok: true, d: d.slice(0, 40), tag: (el || svg).tagName, className: (el || svg).className?.toString().slice(0, 80) };
397
+ }
398
+ }
399
+ return { ok: false };
400
+ });
401
+ if (clicked.ok) {
402
+ await sleep(1000);
403
+ const hasExportMenu = await page.locator('text=导出页面').first().isVisible({ timeout: 2000 }).catch(() => false);
404
+ if (hasExportMenu) {
405
+ moreClicked = true;
406
+ console.log(` ✓ 点击菜单按钮(SVG兜底): d="${clicked.d}..." tag=${clicked.tag} class=${clicked.className}`);
407
+ }
408
+ }
409
+ } catch { /* ignore */ }
410
+ }
411
+ }
412
+
413
+ if (!moreClicked) {
414
+ await savePageSnapshot(page, url, options);
415
+ if (headed) {
416
+ console.log(' ⚠ 无法自动定位菜单按钮,请在浏览器中手动点击右上角「⋯」→「导出页面...」,脚本会继续等待下载');
417
+ } else {
418
+ throw new Error('无法定位右上角「更多」菜单按钮,建议用 --headed 查看页面结构');
419
+ }
420
+ }
421
+
422
+ // 尝试点击「导出页面...」
423
+ const exportMenuSelectors = [
424
+ 'text=导出页面...',
425
+ 'text=导出页面',
426
+ 'text=导出当前页面',
427
+ '.ant-dropdown-menu-item:has-text("导出")',
428
+ '[class*="dropdown"] >> text=导出',
429
+ '[role="menuitem"] >> text=导出',
430
+ '[class*="_21m0P"]',
431
+ '[class*="_3gFMB"]',
432
+ ];
433
+
434
+ let exportClicked = false;
435
+ for (let attempt = 0; attempt < (headed ? 6 : 3) && !exportClicked; attempt++) {
436
+ if (attempt > 0) {
437
+ console.log(` 第 ${attempt + 1} 次尝试定位导出菜单...`);
438
+ await sleep(1500);
439
+ }
440
+
441
+ for (const selector of exportMenuSelectors) {
442
+ try {
443
+ const loc = page.locator(selector).first();
444
+ if (await loc.isVisible({ timeout: 2000 }).catch(() => false)) {
445
+ await loc.click({ timeout: 5000 });
446
+ exportClicked = true;
447
+ console.log(` ✓ 点击导出菜单: ${selector}`);
448
+ break;
449
+ }
450
+ } catch { /* ignore */ }
451
+ }
452
+
453
+ // 兜底:文本匹配「导出页面...」
454
+ if (!exportClicked) {
455
+ try {
456
+ const clicked = await page.evaluate(() => {
457
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
458
+ let node;
459
+ while ((node = walker.nextNode())) {
460
+ if (node.textContent.includes('导出页面')) {
461
+ let el = node.parentElement;
462
+ while (el && el.tagName !== 'BUTTON' && el.tagName !== 'A' && el.getAttribute('role') !== 'button' && el.getAttribute('role') !== 'menuitem') {
463
+ el = el.parentElement;
464
+ }
465
+ if (el) {
466
+ el.click();
467
+ return true;
468
+ }
469
+ }
470
+ }
471
+ return false;
472
+ });
473
+ if (clicked) {
474
+ exportClicked = true;
475
+ console.log(' ✓ 点击导出菜单(文本兜底)');
476
+ }
477
+ } catch { /* ignore */ }
478
+ }
479
+ }
480
+
481
+ if (!exportClicked) {
482
+ if (headed) {
483
+ console.log(' ⚠ 无法自动定位导出菜单,请在浏览器中手动点击「导出页面...」,脚本会继续等待下载');
484
+ } else {
485
+ throw new Error('无法定位「导出页面...」菜单项,建议用 --headed 查看页面结构');
486
+ }
487
+ }
488
+
489
+ // 等待导出面板,选择 Markdown 格式,然后点击「导出」
490
+ try {
491
+ await page.locator('.wolaiModal, [class*="wolaiModal"]').waitFor({ state: 'visible', timeout: 8000 });
492
+
493
+ // 检查当前格式,如果不是 Markdown 则切换
494
+ const currentFormat = await page.locator('.wolaiModal [class*="_2TgYE"]').first().textContent().catch(() => '');
495
+ console.log(` 当前导出格式: ${currentFormat}`);
496
+
497
+ if (!currentFormat.includes('Markdown')) {
498
+ // 点击格式下拉
499
+ const formatDropdown = page.locator('.wolaiModal button:has([class*="_2TgYE"]), .wolaiModal [class*="gsQNQ"] button').first();
500
+ await formatDropdown.click({ timeout: 5000 });
501
+ await sleep(800);
502
+
503
+ // 选择 Markdown
504
+ const markdownOption = page.locator('.wolaiModal [class*="MuiMenuItem-root"], [role="listbox"] [role="option"]').filter({ hasText: /Markdown/i }).first();
505
+ await markdownOption.click({ timeout: 5000 });
506
+ console.log(' ✓ 切换导出格式为 Markdown');
507
+ await sleep(800);
508
+ }
509
+
510
+ const exportBtn = page.locator('.wolaiModal button:has-text("导出"), .wolaiModal [class*="_3sMqb"]').first();
511
+ await exportBtn.click({ timeout: 5000 });
512
+ console.log(' ✓ 点击导出面板确认按钮');
513
+ } catch (err) {
514
+ if (!headed) {
515
+ throw new Error(`导出面板确认失败: ${err.message}`);
516
+ }
517
+ console.log(' ⚠ 未自动点击导出面板确认按钮,请在浏览器中手动完成');
518
+ }
519
+
520
+ // 等待下载完成
521
+ const { filePath } = await downloadPromise;
522
+ console.log(` ✓ 下载完成: ${path.basename(filePath)}`);
523
+
524
+ // 如果是 zip,解压到 sessionDir 并删除 zip
525
+ if (filePath.toLowerCase().endsWith('.zip')) {
526
+ const extractDir = path.join(sessionDir, 'extracted');
527
+ ensureDir(extractDir);
528
+ try {
529
+ // 使用 execFileSync 数组传参,避免文件名(服务端可控)被 shell 解析导致命令注入
530
+ execFileSync('unzip', ['-o', filePath, '-d', extractDir], { stdio: 'ignore' });
531
+ console.log(` ✓ 解压完成: ${extractDir}`);
532
+ fs.unlinkSync(filePath);
533
+ console.log(` ✓ 删除 zip: ${path.basename(filePath)}`);
534
+ return { filePath: extractDir, sessionDir, isZip: true };
535
+ } catch (err) {
536
+ console.log(` ✗ 解压失败: ${err.message}`);
537
+ return { filePath, sessionDir, isZip: false };
538
+ }
539
+ }
540
+
541
+ return { filePath, sessionDir, isZip: false };
542
+ }
543
+
544
+ async function detectLoginState(page) {
545
+ // 演示模式按钮是未登录/访客态的标志
546
+ const presentationMode = await page.locator('text=演示模式').count().catch(() => 0);
547
+ const loginButton = await page.locator('text=登录').count().catch(() => 0);
548
+ return { isLoggedIn: presentationMode === 0 && loginButton === 0, presentationMode, loginButton };
549
+ }
550
+
551
+ async function loginAndSaveStorageState(config, { loginWaitMs = null } = {}) {
552
+ const { storageState, timeoutMs = 120000, loginUrl = 'https://wolai.dingtalk.com/' } = config;
553
+ // 登录必须开有头模式,否则用户看不到浏览器窗口
554
+ const { browser, context, page } = await launchBrowserContext({ storageState: null, headed: true });
555
+
556
+ try {
557
+ console.log('→ 打开 wolai 登录页...');
558
+ await navigateWithRetry(page, loginUrl, { timeout: timeoutMs });
559
+ console.log('请在本机弹出的浏览器窗口中完成登录/扫码。');
560
+
561
+ if (process.stdin.isTTY) {
562
+ console.log('登录成功后,请回到本终端按回车键保存登录态。');
563
+ if (loginWaitMs) {
564
+ console.log(`(若 ${loginWaitMs}ms 内未按回车,将自动保存。)`);
565
+ }
566
+ const waiters = [
567
+ new Promise((resolve) => {
568
+ process.stdin.once('data', resolve);
569
+ }),
570
+ ];
571
+ if (loginWaitMs) waiters.push(sleep(loginWaitMs));
572
+ await Promise.race(waiters);
573
+ } else {
574
+ const waitTime = loginWaitMs ?? 30000;
575
+ console.log(`将在 ${waitTime}ms 后自动保存登录态(可用 --login-wait-ms 调整)...`);
576
+ await sleep(waitTime);
577
+ }
578
+
579
+ // 验证是否已真正登录(而非演示模式)
580
+ let loginCheck = await detectLoginState(page);
581
+ if (!loginCheck.isLoggedIn) {
582
+ console.log(`⚠ 当前页面仍处于未登录状态(演示模式=${loginCheck.presentationMode}, 登录按钮=${loginCheck.loginButton})。`);
583
+ if (process.stdin.isTTY) {
584
+ console.log('请继续完成登录,直到页面右上角不再显示"演示模式",然后按回车保存登录态。');
585
+ await new Promise((resolve) => {
586
+ process.stdin.once('data', resolve);
587
+ });
588
+ loginCheck = await detectLoginState(page);
589
+ }
590
+ if (!loginCheck.isLoggedIn) {
591
+ throw new Error('登录态验证失败:页面仍处于演示模式。请确认已完成登录/扫码,且对目标页面具有导出权限。');
592
+ }
593
+ }
594
+
595
+ const savedPath = await saveStorageState(context, storageState);
596
+ console.log(`✓ 登录态已保存: ${savedPath}`);
597
+ } finally {
598
+ await context.close();
599
+ await browser.close();
600
+ }
601
+ }
602
+
603
+ async function runCrawl(config, { force = false, headed = false } = {}) {
604
+ const rawDir = ensureDir(config.rawDir);
605
+ const storageState = config.storageState;
606
+ const skipExisting = config.skipExisting && !force;
607
+
608
+ const { browser, context, page } = await launchBrowserContext({
609
+ storageState,
610
+ headed: headed || config.headed,
611
+ });
612
+
613
+ const results = {
614
+ startedAt: new Date().toISOString(),
615
+ pages: [],
616
+ errors: [],
617
+ };
618
+
619
+ try {
620
+ // 1. 采集目录
621
+ const pageUrls = await collectPageUrls(page, config.startUrl, config);
622
+ writeJson(config.urlMappingFile, pageUrls);
623
+
624
+ // 2. 逐个导出
625
+ for (let i = 0; i < pageUrls.length; i++) {
626
+ const item = pageUrls[i];
627
+ const existing = findExistingRaw(item, rawDir);
628
+ if (skipExisting && existing) {
629
+ console.log(`⏭ 跳过(已存在): ${item.title || item.url}`);
630
+ results.pages.push({ ...item, skipped: true, rawPath: existing });
631
+ continue;
632
+ }
633
+
634
+ try {
635
+ const { filePath } = await exportPage(page, item, rawDir, {
636
+ ...config,
637
+ headed: headed || config.headed,
638
+ });
639
+ results.pages.push({ ...item, filePath });
640
+ } catch (err) {
641
+ console.error(` ✗ 导出失败: ${item.url} - ${err.message}`);
642
+ results.errors.push({ url: item.url, error: err.message });
643
+ }
644
+
645
+ if (config.cooldownMs) {
646
+ await sleep(config.cooldownMs);
647
+ }
648
+ }
649
+ } finally {
650
+ await context.close();
651
+ await browser.close();
652
+ }
653
+
654
+ results.finishedAt = new Date().toISOString();
655
+ const reportPath = path.join(rawDir, `crawl-report-${Date.now()}.json`);
656
+ writeJson(reportPath, results);
657
+ console.log(`\n📄 爬取报告: ${reportPath}`);
658
+ console.log(` 成功: ${results.pages.filter((p) => p.filePath).length}`);
659
+ console.log(` 跳过: ${results.pages.filter((p) => p.skipped).length}`);
660
+ console.log(` 失败: ${results.errors.length}`);
661
+
662
+ return results;
663
+ }
664
+
665
+ function findExistingRaw(item, rawDir) {
666
+ const downloadsDir = path.join(rawDir, 'downloads');
667
+ if (!fs.existsSync(downloadsDir)) return null;
668
+
669
+ const candidates = [];
670
+ for (const session of fs.readdirSync(downloadsDir)) {
671
+ const sessionPath = path.join(downloadsDir, session);
672
+ if (!fs.statSync(sessionPath).isDirectory()) continue;
673
+
674
+ // 优先找解压后的目录
675
+ const extractedPath = path.join(sessionPath, 'extracted');
676
+ if (fs.existsSync(extractedPath) && fs.statSync(extractedPath).isDirectory()) {
677
+ candidates.push(extractedPath);
678
+ continue;
679
+ }
680
+
681
+ for (const file of fs.readdirSync(sessionPath)) {
682
+ if (file.endsWith('.md') || file.endsWith('.zip')) {
683
+ candidates.push(path.join(sessionPath, file));
684
+ }
685
+ }
686
+ }
687
+
688
+ // 按修改时间取最新
689
+ if (candidates.length === 0) return null;
690
+ candidates.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
691
+ return candidates[0];
692
+ }
693
+
694
+ module.exports = {
695
+ collectPageUrls,
696
+ exportPage,
697
+ loginAndSaveStorageState,
698
+ runCrawl,
699
+ };