feat: track yitang scraper tool and project logos
|
After Width: | Height: | Size: 229 KiB |
@@ -0,0 +1,37 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_5110_14771)">
|
||||
<rect width="40" height="40" rx="20" fill="url(#paint0_linear_5110_14771)"/>
|
||||
<g filter="url(#filter0_f_5110_14771)">
|
||||
<circle cx="12.6071" cy="34.9794" r="14.081" fill="#32E6B9" fill-opacity="0.4"/>
|
||||
</g>
|
||||
<g filter="url(#filter1_f_5110_14771)">
|
||||
<circle cx="33.3404" cy="43.6185" r="12.8704" fill="#32E6B9"/>
|
||||
</g>
|
||||
<path d="M27.6897 3.3426C28.057 3.01322 28.0795 2.99983 28.3485 2.98366C28.7851 2.95177 29.1858 3.16107 29.8664 3.78072C31.4566 5.22574 33.6703 8.19724 35.047 10.7343L35.5796 11.7191L36.3309 12.0927C37.0562 12.4593 38.2459 13.2111 38.7428 13.614C38.9673 13.7997 38.9992 13.8032 39.2326 13.7124C40.2872 13.3017 41.7982 13.846 43.1307 15.1244C44.3302 16.274 45.479 18.2384 45.9191 19.8778C45.9833 20.1417 46.0683 20.709 46.0994 21.1314C46.1997 22.6144 45.7243 23.7986 44.8085 24.3349C44.6213 24.443 44.6085 24.4725 44.6137 24.939C44.6559 27.1604 44.0571 29.3777 42.8543 31.54C41.4965 33.9678 39.0794 36.4798 35.8073 38.846C34.0502 40.1247 29.8918 42.5469 28.0122 43.3973C23.5096 45.4244 19.8999 46.2022 16.7645 45.8181C14.8943 45.5915 12.7771 44.8611 11.5246 44.0137C11.1951 43.786 11.1428 43.7719 10.8909 43.8439C9.55017 44.2289 7.79442 43.4374 6.30272 41.7817C5.70771 41.1197 4.74709 39.4939 4.43575 38.6241C3.71569 36.5887 3.85859 34.752 4.81783 33.6551C5.0657 33.3725 5.07387 33.3607 5.01972 32.8856C4.93033 32.1079 4.88982 30.9566 4.93066 30.2138L4.96343 29.5205L3.92091 27.6777C2.3078 24.8074 1.28326 22.3968 0.887982 20.5554C0.679402 19.546 0.692781 19.0981 0.949061 18.7667C1.10529 18.5666 1.61622 18.3594 2.23252 18.2455C3.78518 17.9729 7.17203 18.2198 10.9394 18.885L11.3303 18.9525L12.1903 18.1921C13.6179 16.9275 14.5665 16.2176 16.315 15.1274C18.1373 13.9872 20.1943 13.0498 22.5103 12.3071L23.2533 12.0687L23.6615 10.9964C25.1239 7.13587 26.6218 4.28969 27.6897 3.3426ZM15.4393 23.1261C13.7864 24.0804 12.9602 24.5582 12.3529 25.093C9.89364 27.2585 8.9738 30.6888 10.0208 33.7938C10.2794 34.5606 10.7568 35.387 11.7111 37.0398C12.6653 38.6926 13.1424 39.5194 13.6771 40.1267C15.8426 42.5859 19.2732 43.5045 22.3783 42.4574C23.145 42.1989 23.972 41.7223 25.6248 40.768L35.1333 35.2783C36.7862 34.324 37.6124 33.8462 38.2196 33.3114C40.6789 31.1459 41.5988 27.7156 40.5518 24.6105C40.2932 23.8438 39.8157 23.0174 38.8615 21.3646C37.9072 19.7117 37.4302 18.885 36.8955 18.2777C34.7299 15.8185 31.2993 14.8999 28.1943 15.947C27.4275 16.2055 26.6006 16.6821 24.9478 17.6364L15.4393 23.1261Z" fill="url(#paint1_linear_5110_14771)"/>
|
||||
<rect x="15.8816" y="30.0273" width="4.00904" height="8.32646" rx="2.00452" transform="rotate(-30 15.8816 30.0273)" fill="white"/>
|
||||
<rect x="26.6978" y="23.7812" width="4.00904" height="8.32646" rx="2.00452" transform="rotate(-30 26.6978 23.7812)" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_f_5110_14771" x="-14.9163" y="7.45599" width="55.0468" height="55.047" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="6.72123" result="effect1_foregroundBlur_5110_14771"/>
|
||||
</filter>
|
||||
<filter id="filter1_f_5110_14771" x="9.46959" y="19.7477" width="47.7417" height="47.741" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="5.50019" result="effect1_foregroundBlur_5110_14771"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_5110_14771" x1="20" y1="0" x2="20" y2="40" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#6C4DFF"/>
|
||||
<stop offset="1" stop-color="#583ED3"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_5110_14771" x1="14.639" y1="10.7917" x2="32.1712" y2="41.1585" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white" stop-opacity="0.8"/>
|
||||
<stop offset="0.437689" stop-color="white"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_5110_14771">
|
||||
<rect width="40" height="40" rx="20" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 354 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 256 KiB |
@@ -0,0 +1,731 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 一堂创业课文档采集工具
|
||||
*
|
||||
* 用法:
|
||||
* node auto-scrape.mjs <URL> [输出文件名]
|
||||
*
|
||||
* 示例:
|
||||
* node auto-scrape.mjs https://yitang.top/fs-doc/abc123/docId
|
||||
* node auto-scrape.mjs https://your-tenant.feishu.cn/wiki/WikiNodeTokenExample...
|
||||
*
|
||||
* Cookie 持久化:首次登录后自动保存,之后无需重复扫码。
|
||||
*/
|
||||
import { chromium } from 'playwright';
|
||||
import CryptoJS from 'crypto-js';
|
||||
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { execSync } from 'child_process';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { createInterface } from 'readline/promises';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// ── 打包模式:自动检测 Playwright 浏览器路径 ────────────────────────────────────
|
||||
// 当通过 pkg 打包后,process.execPath 指向可执行文件本身
|
||||
// 浏览器目录应位于可执行文件旁边的 browsers/ 目录中
|
||||
const execDir = dirname(process.execPath);
|
||||
const bundledBrowsers = resolve(execDir, 'browsers');
|
||||
if (existsSync(bundledBrowsers)) {
|
||||
process.env.PLAYWRIGHT_BROWSERS_PATH = bundledBrowsers;
|
||||
}
|
||||
|
||||
// ── CLI 参数解析 ──────────────────────────────────────────────────────────────
|
||||
let args = process.argv.slice(2);
|
||||
if (!args[0]) {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
args = [(await rl.question('请输入一堂文档链接:')).trim()];
|
||||
rl.close();
|
||||
}
|
||||
if (!args[0] || args[0] === '--help' || args[0] === '-h') {
|
||||
console.log(`
|
||||
用法:node auto-scrape.mjs <URL> [输出文件名]
|
||||
|
||||
URL yitang.top/fs-doc/... 或 *.feishu.cn/wiki/... 文档链接(必填)
|
||||
输出文件名 保存路径,默认为文档标题.md
|
||||
|
||||
示例:
|
||||
node auto-scrape.mjs https://yitang.top/fs-doc/abc/docId
|
||||
node auto-scrape.mjs https://your-tenant.feishu.cn/wiki/WikiNodeTokenExample
|
||||
node auto-scrape.mjs https://yitang.top/fs-doc/abc/docId 课程笔记.md
|
||||
node auto-scrape.mjs --clean 已有文档.md
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// ── --clean:对已生成的 Markdown 文件执行清洗(不启动浏览器)──────────────────
|
||||
if (args[0] === '--clean') {
|
||||
const file = resolve(args[1] || '');
|
||||
if (!args[1] || !existsSync(file)) {
|
||||
console.error('用法:node auto-scrape.mjs --clean <markdown文件>');
|
||||
process.exit(1);
|
||||
}
|
||||
const md = readFileSync(file, 'utf-8');
|
||||
writeFileSync(file, cleanMarkdown(md), 'utf-8');
|
||||
console.log(`✅ 已清洗:${file}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const TARGET_URL = args[0].trim();
|
||||
const customOutput = args[1];
|
||||
const COOKIE_FILE = resolve('cookies.json');
|
||||
|
||||
// ── 判断 URL 类型 ──────────────────────────────────────────────────────────────
|
||||
const isFeishuWiki = /feishu\.cn\/wiki\//.test(TARGET_URL);
|
||||
const isFeishuDocx = /feishu\.cn\/docx\//.test(TARGET_URL);
|
||||
const isYitang = TARGET_URL.includes('yitang.top/fs-doc/');
|
||||
const isFeishu = isFeishuWiki || isFeishuDocx;
|
||||
|
||||
if (!isFeishuWiki && !isFeishuDocx && !isYitang) {
|
||||
console.error('❌ URL 格式不正确,支持 yitang.top/fs-doc/<acl>/<docId> 或 *.feishu.cn/wiki/<token>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Feishu Wiki:直接调用 fetch-feishu-wiki.mjs ────────────────────────────────
|
||||
if (isFeishuWiki) {
|
||||
console.log('🔄 检测到飞书 Wiki 文档,使用 lark-cli API 获取...\n');
|
||||
try {
|
||||
const fetchScript = resolve(__dirname, 'fetch-feishu-wiki.mjs');
|
||||
execSync(`node "${fetchScript}" "${TARGET_URL}"`, {
|
||||
stdio: 'inherit',
|
||||
encoding: 'utf-8'
|
||||
});
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('❌ 调用 fetch-feishu-wiki.mjs 失败');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// yitang 专用字段
|
||||
let ACL, DOC_ID, LOGIN_URL;
|
||||
if (isYitang) {
|
||||
const match = TARGET_URL.match(/\/fs-doc\/([^/]+)\/([^/?#]+)/);
|
||||
if (!match) {
|
||||
console.error('❌ yitang URL 格式不正确,应为 https://yitang.top/fs-doc/<acl>/<docId>');
|
||||
process.exit(1);
|
||||
}
|
||||
ACL = match[1];
|
||||
DOC_ID = match[2];
|
||||
LOGIN_URL = `https://yitang.top/login?after_login=%2Ffs-doc%2F${ACL}%2F${DOC_ID}`;
|
||||
}
|
||||
|
||||
// ── Markdown 清洗(CommonMark 定界符合规)────────────────────────────────────
|
||||
// 1) 相邻同样式文本块合并:**甲****乙** → **甲乙**
|
||||
// 2) 加粗/斜体/删除线以中文标点结束、后接汉字/字母/数字时,闭合标记后补空格:**标签:** 研发
|
||||
// 3) 标题行去除 **、*、~~ 等格式标记残留
|
||||
function stripHeadingMarkers(line) {
|
||||
const m = line.match(/^(#{1,6}\s+)(.*)$/);
|
||||
if (!m) return line;
|
||||
let inner = m[2], prev;
|
||||
do {
|
||||
prev = inner;
|
||||
inner = inner
|
||||
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
||||
.replace(/__([^_]+)__/g, '$1')
|
||||
.replace(/~~([^~]+)~~/g, '$1')
|
||||
.replace(/(^|[^*])\*([^*]+)\*/g, '$1$2');
|
||||
} while (inner !== prev);
|
||||
return m[1] + inner.trimEnd();
|
||||
}
|
||||
|
||||
function mergeAdjacentSpans(line) {
|
||||
let prev;
|
||||
do {
|
||||
prev = line;
|
||||
line = line
|
||||
.replace(/\*\*([^*\n]+)\*\*\*\*([^*\n]+)\*\*/g, '**$1$2**')
|
||||
.replace(/\*\*\*([^*\n]+?)\*\*\*\*\*\*([^*\n]+?)\*\*\*/g, '***$1$2***')
|
||||
.replace(/~~([^~\n]+)~~~~([^~\n]+)~~/g, '~~$1$2~~');
|
||||
} while (line !== prev);
|
||||
return line;
|
||||
}
|
||||
|
||||
function fixClosingDelimiters(line) {
|
||||
// CommonMark 侧翼规则:闭合定界符前为标点、后紧跟字母/数字(含汉字)时无法闭合,需补空格
|
||||
const addSpace = (whole, marker, inner) =>
|
||||
/[\p{P}\p{S}]/u.test(inner.slice(-1)) ? `${marker}${inner}${marker} ` : whole;
|
||||
line = line.replace(/(?<!\*)\*\*\*([^*\n]+?)\*\*\*(?!\*)(?=[\p{L}\p{N}])/gu, (m, inner) => addSpace(m, '***', inner));
|
||||
line = line.replace(/\*\*([^*\n]+)\*\*(?=[\p{L}\p{N}])/gu, (m, inner) => addSpace(m, '**', inner));
|
||||
line = line.replace(/~~([^~\n]+)~~(?=[\p{L}\p{N}])/gu, (m, inner) => addSpace(m, '~~', inner));
|
||||
line = line.replace(/(?<!\*)\*([^*\n]+)\*(?!\*)(?=[\p{L}\p{N}])/gu, (m, inner) => addSpace(m, '*', inner));
|
||||
return line;
|
||||
}
|
||||
|
||||
function cleanMarkdown(md) {
|
||||
// 迭代至不动点,确保单遍扫描因定界符交叠漏掉的模式也被处理
|
||||
let prev;
|
||||
do {
|
||||
prev = md;
|
||||
md = md.split('\n')
|
||||
.map(l => fixClosingDelimiters(mergeAdjacentSpans(stripHeadingMarkers(l))))
|
||||
.join('\n');
|
||||
} while (md !== prev);
|
||||
return md;
|
||||
}
|
||||
|
||||
// ── Feishu block → Markdown ──────────────────────────────────────────────────
|
||||
const T = { PAGE:1, TEXT:2, H1:3, H2:4, H3:5, H4:6, H5:7, H6:8,
|
||||
ORDERED:13, BULLET:12, TODO:17, CODE:14, QUOTE:15, DIVIDER:22,
|
||||
IMAGE:27, TABLE:31, TABLE_CELL:32, QUOTE_CONTAINER:34,
|
||||
GRID:24, GRID_COL:25 };
|
||||
|
||||
function textRuns(elements) {
|
||||
if (!Array.isArray(elements)) return '';
|
||||
// 相同样式的相邻 run 先合并,避免生成 **甲****乙** 这类相邻定界符
|
||||
const runs = [];
|
||||
for (const el of elements) {
|
||||
const run = el.text_run;
|
||||
if (!run) continue;
|
||||
const s = run.text_element_style || {};
|
||||
const key = `${!!s.bold}|${!!s.italic}|${!!s.strikethrough}|${!!s.inline_code}|${s.link?.url || ''}`;
|
||||
const last = runs[runs.length - 1];
|
||||
if (last && last.key === key) last.content += run.content || '';
|
||||
else runs.push({ key, content: run.content || '', style: s });
|
||||
}
|
||||
return runs.map(({ content: t, style: s }) => {
|
||||
if (s.bold) t = `**${t}**`;
|
||||
if (s.italic) t = `*${t}*`;
|
||||
if (s.strikethrough) t = `~~${t}~~`;
|
||||
if (s.inline_code) t = `\`${t}\``;
|
||||
if (s.link?.url) t = `[${t}](${s.link.url})`;
|
||||
return t;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function blockToMd(block, depth = 0) {
|
||||
if (!block) return '';
|
||||
const attr = block.blockAttr || {};
|
||||
const indent = ' '.repeat(Math.max(0, depth - 1));
|
||||
switch (block.type) {
|
||||
case T.PAGE: return `# ${textRuns(attr.page?.elements)}\n\n`;
|
||||
case T.H1: return `# ${textRuns(attr.heading1?.elements)}\n\n`;
|
||||
case T.H2: return `## ${textRuns(attr.heading2?.elements)}\n\n`;
|
||||
case T.H3: return `### ${textRuns(attr.heading3?.elements)}\n\n`;
|
||||
case T.H4: return `#### ${textRuns(attr.heading4?.elements)}\n\n`;
|
||||
case T.H5: return `##### ${textRuns(attr.heading5?.elements)}\n\n`;
|
||||
case T.H6: return `###### ${textRuns(attr.heading6?.elements)}\n\n`;
|
||||
case T.TEXT: {
|
||||
const txt = textRuns(attr.text?.elements);
|
||||
return txt ? `${txt}\n\n` : '\n';
|
||||
}
|
||||
case T.BULLET: return `${indent}- ${textRuns(attr.bullet?.elements)}\n`;
|
||||
case T.ORDERED: return `${indent}1. ${textRuns(attr.ordered?.elements)}\n`;
|
||||
case T.TODO: {
|
||||
const done = attr.todo?.style?.done ? '[x]' : '[ ]';
|
||||
return `${indent}- ${done} ${textRuns(attr.todo?.elements)}\n`;
|
||||
}
|
||||
case T.QUOTE: return `> ${textRuns(attr.quote?.elements)}\n\n`;
|
||||
case T.CODE: {
|
||||
const language = attr.code?.style?.language;
|
||||
const lang = typeof language === 'string' ? language.toLowerCase().replace('plain text','') : '';
|
||||
const code = (attr.code?.elements || []).map(e => e.text_run?.content || '').join('');
|
||||
return `\`\`\`${lang}\n${code}\n\`\`\``;
|
||||
}
|
||||
case T.DIVIDER: return `---\n\n`;
|
||||
case T.IMAGE: {
|
||||
const caption = (attr.textArea || []).map(a => a.text).filter(Boolean).join(' ');
|
||||
const url = attr.cdnUrl || attr.image?.token || '';
|
||||
return `\n\n`;
|
||||
}
|
||||
case T.QUOTE_CONTAINER: return '';
|
||||
case T.GRID: return '';
|
||||
case T.GRID_COL: return '';
|
||||
case T.TABLE: return '\n';
|
||||
case T.TABLE_CELL: {
|
||||
const text = (block.childrens || []).map(c => {
|
||||
const a = c.blockAttr || {};
|
||||
return textRuns(a.text?.elements || a.heading1?.elements || a.heading2?.elements || []);
|
||||
}).filter(Boolean).join(' | ');
|
||||
return text ? `| ${text} ` : '';
|
||||
}
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
function blocksToMarkdown(rootBlock) {
|
||||
const lines = [];
|
||||
function walk(block, depth) {
|
||||
if (!block) return;
|
||||
lines.push(blockToMd(block, depth));
|
||||
if (block.type !== T.TABLE_CELL && block.childrens) {
|
||||
block.childrens.forEach(c => walk(c, depth + 1));
|
||||
}
|
||||
if (block.type === T.TABLE) lines.push('\n');
|
||||
}
|
||||
walk(rootBlock, 0);
|
||||
return lines.join('').replace(/\n{3,}/g, '\n\n').trim();
|
||||
}
|
||||
|
||||
// ── Feishu 原生 docx block (block_type schema) → Markdown ────────────────────
|
||||
// 飞书 Open Platform block_type: 1=page 2=text 3-8=h1-h6 12=bullet 13=ordered
|
||||
// 14=code 15=quote 16=todo 21=divider 26=image 30=table 31=table_cell
|
||||
|
||||
const FT_HEADING = { 3: 1, 4: 2, 5: 3, 6: 4, 7: 5, 8: 6 };
|
||||
const FT_CODE_LANG = {
|
||||
1: '', 7: 'bash', 9: 'cpp', 10: 'c', 12: 'css', 22: 'go',
|
||||
28: 'json', 29: 'java', 30: 'javascript', 49: 'python',
|
||||
52: 'ruby', 53: 'rust', 56: 'sql', 60: 'shell', 63: 'typescript',
|
||||
};
|
||||
|
||||
function ftTextElements(elements = []) {
|
||||
// 相同样式的相邻 text_run 先合并,避免生成 **甲****乙** 这类相邻定界符
|
||||
const segs = [];
|
||||
for (const el of elements) {
|
||||
if (el.text_run) {
|
||||
const s = el.text_run.text_element_style || {};
|
||||
const key = `${!!s.inline_code}|${!!s.bold}|${!!s.italic}|${!!s.strikethrough}|${s.link?.url || ''}`;
|
||||
const last = segs[segs.length - 1];
|
||||
if (last && last.key === key) last.content += el.text_run.content || '';
|
||||
else segs.push({ key, content: el.text_run.content || '', style: s });
|
||||
} else if (el.mention_doc) {
|
||||
const { url = '', title = '' } = el.mention_doc;
|
||||
segs.push({ key: null, text: url ? `[${title || url}](${url})` : title });
|
||||
} else if (el.mention_user) {
|
||||
segs.push({ key: null, text: `@${el.mention_user.user_id || 'user'}` });
|
||||
} else {
|
||||
segs.push({ key: null, text: '' });
|
||||
}
|
||||
}
|
||||
return segs.map(seg => {
|
||||
if (seg.key === null) return seg.text;
|
||||
let t = seg.content;
|
||||
const s = seg.style;
|
||||
if (s.inline_code) return `\`${t}\``;
|
||||
if (s.bold) t = `**${t}**`;
|
||||
if (s.italic) t = `*${t}*`;
|
||||
if (s.strikethrough) t = `~~${t}~~`;
|
||||
if (s.link?.url) t = `[${t}](${decodeURIComponent(s.link.url)})`;
|
||||
return t;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function ftBlockToMd(block, index, depth, orderedNum = 1) {
|
||||
const t = block.block_type;
|
||||
const indent = ' '.repeat(depth);
|
||||
if (t === 1) return ftRenderChildren(block.children || [], index, depth);
|
||||
if (t === 2) {
|
||||
const text = ftTextElements(block.text?.elements);
|
||||
return text ? text + '\n' : '\n';
|
||||
}
|
||||
if (FT_HEADING[t] !== undefined) {
|
||||
return `${'#'.repeat(FT_HEADING[t])} ${ftTextElements(block.text?.elements)}\n\n`;
|
||||
}
|
||||
if (t === 12) {
|
||||
const nested = block.children?.length ? '\n' + ftRenderChildren(block.children, index, depth + 1) : '';
|
||||
return `${indent}- ${ftTextElements(block.text?.elements)}${nested}\n`;
|
||||
}
|
||||
if (t === 13) {
|
||||
const nested = block.children?.length ? '\n' + ftRenderChildren(block.children, index, depth + 1) : '';
|
||||
return `${indent}${orderedNum}. ${ftTextElements(block.text?.elements)}${nested}\n`;
|
||||
}
|
||||
if (t === 14) {
|
||||
const langId = block.code?.style?.language ?? 1;
|
||||
const code = ftTextElements(block.code?.elements || []);
|
||||
return `\`\`\`${FT_CODE_LANG[langId] ?? ''}\n${code}\n\`\`\`\n`;
|
||||
}
|
||||
if (t === 15) return `> ${ftTextElements(block.text?.elements)}\n\n`;
|
||||
if (t === 16) {
|
||||
const done = block.text?.style?.done ? '[x]' : '[ ]';
|
||||
return `- ${done} ${ftTextElements(block.text?.elements)}\n`;
|
||||
}
|
||||
if (t === 21) return '\n---\n\n';
|
||||
if (t === 26) {
|
||||
const token = block.image?.token || '';
|
||||
const src = token
|
||||
? `https://internal-api-drive-stream.feishu.cn/space/api/box/stream/download/all/${token}`
|
||||
: '';
|
||||
return `\n\n`;
|
||||
}
|
||||
if (t === 30) return ftRenderTable(block, index) + '\n';
|
||||
if (t === 31) return null;
|
||||
const fallback = ftTextElements(block.text?.elements || []);
|
||||
return fallback ? fallback + '\n' : null;
|
||||
}
|
||||
|
||||
function ftRenderChildren(childIds, index, depth) {
|
||||
const lines = [];
|
||||
let n = 1;
|
||||
for (const id of childIds) {
|
||||
const block = index.get(id);
|
||||
if (!block) continue;
|
||||
if (block.block_type === 13) {
|
||||
const line = ftBlockToMd(block, index, depth, n++);
|
||||
if (line != null) lines.push(line);
|
||||
} else {
|
||||
n = 1;
|
||||
const line = ftBlockToMd(block, index, depth);
|
||||
if (line != null) lines.push(line);
|
||||
}
|
||||
}
|
||||
return lines.join('');
|
||||
}
|
||||
|
||||
function ftRenderTable(block, index) {
|
||||
const { row_size = 0, column_size = 0 } = block.table?.property || {};
|
||||
const cellIds = block.table?.cells || [];
|
||||
if (!row_size || !column_size) return '';
|
||||
const rows = Array.from({ length: row_size }, () => Array(column_size).fill(''));
|
||||
for (let r = 0; r < row_size; r++) {
|
||||
for (let c = 0; c < column_size; c++) {
|
||||
const cell = index.get(cellIds[r * column_size + c]);
|
||||
if (!cell) continue;
|
||||
rows[r][c] = ftRenderChildren(cell.children || [], index, 0).replace(/\n+/g, ' ').trim();
|
||||
}
|
||||
}
|
||||
const out = ['| ' + rows[0].join(' | ') + ' |', '| ' + Array(column_size).fill('---').join(' | ') + ' |'];
|
||||
for (let r = 1; r < row_size; r++) out.push('| ' + rows[r].join(' | ') + ' |');
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
function feishuBlocksToMd(blocks) {
|
||||
const index = new Map();
|
||||
for (const b of blocks) if (b.block_id) index.set(b.block_id, b);
|
||||
const root = blocks.find(b => b.block_type === 1) || blocks[0];
|
||||
if (!root) return blocks.map(b => ftBlockToMd(b, index, 0)).filter(Boolean).join('\n');
|
||||
return ftRenderChildren(root.children || [], index, 0).replace(/\n{3,}/g, '\n\n').trim();
|
||||
}
|
||||
|
||||
// 从 API 响应 JSON 中递归查找 block 数组(block_type schema)
|
||||
function findFeishuBlocks(obj, depth = 0) {
|
||||
if (depth > 5 || !obj || typeof obj !== 'object') return [];
|
||||
if (obj.block_id && typeof obj.block_type === 'number') return [obj];
|
||||
if (Array.isArray(obj)) {
|
||||
const flat = [];
|
||||
for (const item of obj) flat.push(...findFeishuBlocks(item, depth + 1));
|
||||
return flat;
|
||||
}
|
||||
for (const c of [obj.items, obj.blocks, obj.data?.items, obj.data?.blocks,
|
||||
obj.data?.document?.blocks, obj.result?.data?.items]) {
|
||||
if (c) { const found = findFeishuBlocks(c, depth + 1); if (found.length) return found; }
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// 从 wiki node API 响应中找出真实 docx token 和标题
|
||||
function findWikiObjToken(obj, wikiToken, depth = 0) {
|
||||
if (depth > 6 || !obj || typeof obj !== 'object') return null;
|
||||
if (obj.obj_token && (obj.node_token === wikiToken || obj.wiki_token === wikiToken || depth > 0)) return obj;
|
||||
if (obj.obj_token && obj.title) return obj;
|
||||
if (Array.isArray(obj)) {
|
||||
for (const item of obj) { const f = findWikiObjToken(item, wikiToken, depth + 1); if (f) return f; }
|
||||
return null;
|
||||
}
|
||||
for (const key of Object.keys(obj)) {
|
||||
const f = findWikiObjToken(obj[key], wikiToken, depth + 1);
|
||||
if (f) return f;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Main ─────────────────────────────────────────────────────────────────────
|
||||
console.log('╔════════════════════════════════════════════╗');
|
||||
console.log('║ 一堂文档采集工具 ║');
|
||||
console.log('╚════════════════════════════════════════════╝');
|
||||
console.log(`📎 URL: ${TARGET_URL}`);
|
||||
if (isYitang) {
|
||||
console.log(`📄 ACL: ${ACL}`);
|
||||
console.log(`🔖 DocID: ${DOC_ID}`);
|
||||
} else {
|
||||
const ftToken = TARGET_URL.match(/\/(wiki|docx)\/([A-Za-z0-9_-]+)/);
|
||||
console.log(`🔖 Type: feishu ${ftToken?.[1] || ''} Token: ${ftToken?.[2] || ''}`);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
const hasSavedCookies = existsSync(COOKIE_FILE);
|
||||
const browser = await chromium.launch({ headless: false, args: ['--window-size=820,720'] });
|
||||
const context = await browser.newContext();
|
||||
|
||||
if (hasSavedCookies) {
|
||||
const saved = JSON.parse(readFileSync(COOKIE_FILE, 'utf-8'));
|
||||
await context.addCookies(saved);
|
||||
console.log(`✅ 已加载保存的 Cookie(${saved.length} 条),跳过扫码登录`);
|
||||
} else {
|
||||
console.log('首次运行,请在弹出的浏览器中完成登录...');
|
||||
}
|
||||
|
||||
const page = await context.newPage();
|
||||
|
||||
let apiResponseText = null;
|
||||
const feishuApiResponses = [];
|
||||
|
||||
// 飞书原生 docx/wiki API 匹配模式
|
||||
const FEISHU_DOC_RE = [
|
||||
/\/docx\/v\d+\/documents\/[A-Za-z0-9]+\/blocks/,
|
||||
/\/space\/api\/docs\/v\d+\/raw_api/,
|
||||
/apiName=DocxV\d+Document/i,
|
||||
];
|
||||
const FEISHU_WIKI_RE = [
|
||||
/\/wiki\/v\d+\/spaces?\/[^/]+\/nodes?/,
|
||||
/\/wiki\/api\/.*\/(get_?node|node_meta|node_info)/,
|
||||
/\/space\/api\/wiki\/v\d+\/.*node/,
|
||||
/apiName=WikiV\d+/i,
|
||||
];
|
||||
|
||||
page.on('response', async (resp) => {
|
||||
const u = resp.url();
|
||||
|
||||
if (isFeishu) {
|
||||
const isDoc = FEISHU_DOC_RE.some(p => p.test(u));
|
||||
const isWiki = FEISHU_WIKI_RE.some(p => p.test(u));
|
||||
// 跳过非 JSON、静态资源、已知无用的请求
|
||||
const ct = resp.headers()['content-type'] || '';
|
||||
if (!ct.includes('json') && !ct.includes('text/plain')) return;
|
||||
if (resp.status() < 200 || resp.status() >= 300) return;
|
||||
|
||||
try {
|
||||
const text = await resp.text();
|
||||
let json; try { json = JSON.parse(text); } catch { return; }
|
||||
|
||||
if (isDoc || isWiki) {
|
||||
console.log(`\n🎯 捕获飞书 API (${isWiki ? 'wiki' : 'doc'}):${u.split('?')[0]} (${text.length} 字节)`);
|
||||
feishuApiResponses.push({ url: u, data: json, kind: isWiki ? 'wiki' : 'doc' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 兜底:扫描任意 JSON 响应中是否含有 block 数据
|
||||
const blocks = findFeishuBlocks(json);
|
||||
if (blocks.length > 0) {
|
||||
console.log(`\n📦 兜底捕获 block 数据:${u.split('?')[0]} (${blocks.length} 个 block)`);
|
||||
feishuApiResponses.push({ url: u, data: json, kind: 'doc' });
|
||||
}
|
||||
} catch {}
|
||||
return;
|
||||
}
|
||||
|
||||
// yitang 路径
|
||||
if (u.includes('get-doc-blocks') || u.includes('get-blocks')) {
|
||||
try {
|
||||
const text = await resp.text();
|
||||
// 忽略"未登录"错误响应(code 10)
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (parsed.code === 10) {
|
||||
console.log(`\n⚠️ API 返回未登录,等待真实响应...`);
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
apiResponseText = text;
|
||||
console.log(`\n🎯 捕获 API 响应:${u.split('?')[0]} (${text.length} 字节)`);
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
|
||||
console.log('🌐 正在打开文档页面...');
|
||||
await page.goto(TARGET_URL, { waitUntil: 'load', timeout: 30000 });
|
||||
|
||||
// 检测是否需要登录
|
||||
const needLogin = async () => {
|
||||
const u = page.url();
|
||||
if (isFeishu) {
|
||||
// 飞书登录页:passport.feishu.cn / accounts.feishu.cn / /login
|
||||
return u.includes('passport.feishu') || u.includes('accounts.feishu') || u.includes('/login');
|
||||
}
|
||||
// yitang 路径
|
||||
if (u.includes('/login') || u.includes('sso.')) return true;
|
||||
const tok = await readToken();
|
||||
if (!tok && hasSavedCookies) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
if (await needLogin()) {
|
||||
console.log('⚠️ 需要登录,正在跳转...');
|
||||
if (isFeishu) {
|
||||
if (!page.url().includes('passport') && !page.url().includes('accounts') && !page.url().includes('/login')) {
|
||||
await page.goto(TARGET_URL, { waitUntil: 'load', timeout: 30000 });
|
||||
}
|
||||
console.log('请在浏览器中完成飞书登录...');
|
||||
await page.waitForFunction(
|
||||
() => !location.href.includes('passport') && !location.href.includes('accounts') && !location.href.includes('/login'),
|
||||
{ timeout: 300000 }
|
||||
);
|
||||
} else {
|
||||
if (!page.url().includes('/login') && !page.url().includes('sso.')) {
|
||||
await page.goto(LOGIN_URL, { waitUntil: 'load', timeout: 30000 });
|
||||
}
|
||||
console.log('请在浏览器中完成登录(微信扫码或手机验证码)...');
|
||||
await page.waitForURL('**/fs-doc/**', { timeout: 300000 });
|
||||
}
|
||||
console.log('\n✅ 登录成功');
|
||||
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
|
||||
}
|
||||
|
||||
// 保存最新 Cookie
|
||||
const cookies = await context.cookies();
|
||||
writeFileSync(COOKIE_FILE, JSON.stringify(cookies, null, 2));
|
||||
console.log(`💾 Cookie 已保存`);
|
||||
|
||||
// ── 飞书原生路径 ───────────────────────────────────────────────────────────────
|
||||
if (isFeishu) {
|
||||
if (feishuApiResponses.length === 0) {
|
||||
console.log('⏳ 等待飞书 API 数据加载...');
|
||||
await page.waitForTimeout(8000);
|
||||
}
|
||||
if (feishuApiResponses.length === 0) {
|
||||
console.log('🔄 重新加载页面...');
|
||||
await page.goto(TARGET_URL, { waitUntil: 'networkidle', timeout: 30000 });
|
||||
await page.waitForTimeout(5000);
|
||||
}
|
||||
if (feishuApiResponses.length === 0) {
|
||||
console.error('❌ 未捕获到飞书 API 响应,请检查登录状态或文档权限');
|
||||
await browser.close();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 提取 wiki node 元信息(wiki 页面需先拿 obj_token)
|
||||
let wikiMeta = null;
|
||||
const ftToken = TARGET_URL.match(/\/(wiki|docx)\/([A-Za-z0-9_-]+)/)?.[2] || '';
|
||||
if (TARGET_URL.includes('/wiki/')) {
|
||||
for (const item of feishuApiResponses) {
|
||||
if (item.kind === 'wiki') {
|
||||
wikiMeta = findWikiObjToken(item.data, ftToken);
|
||||
if (wikiMeta) {
|
||||
console.log(`📎 wiki → docx token: ${wikiMeta.obj_token} 标题: ${wikiMeta.title || '(未知)'}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是 wiki 页面且找到了 docx token,需要导航到 docx URL 来获取实际内容
|
||||
if (wikiMeta?.obj_token) {
|
||||
const docxUrl = TARGET_URL.replace(/\/wiki\/[^/]+/, `/docx/${wikiMeta.obj_token}`);
|
||||
console.log(`🔄 正在加载文档内容: ${docxUrl}`);
|
||||
feishuApiResponses.length = 0; // 清空之前的 wiki API 响应
|
||||
await page.goto(docxUrl, { waitUntil: 'load', timeout: 30000 });
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
if (feishuApiResponses.length === 0) {
|
||||
console.log('⏳ 等待文档 API 数据加载...');
|
||||
await page.waitForTimeout(5000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 提取 blocks(去重)
|
||||
let docxBlocks = [];
|
||||
for (const item of feishuApiResponses) {
|
||||
if (item.kind !== 'doc') continue;
|
||||
const found = findFeishuBlocks(item.data);
|
||||
if (found.length) docxBlocks.push(...found);
|
||||
}
|
||||
const seenIds = new Set();
|
||||
docxBlocks = docxBlocks.filter(b => {
|
||||
if (!b.block_id || seenIds.has(b.block_id)) return false;
|
||||
seenIds.add(b.block_id);
|
||||
return true;
|
||||
});
|
||||
console.log(`📦 共提取 ${docxBlocks.length} 个 block`);
|
||||
|
||||
if (docxBlocks.length === 0) {
|
||||
console.error('❌ 未获取到有效 block 数据');
|
||||
await browser.close();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pageTitle = wikiMeta?.title || await page.title();
|
||||
const markdown = cleanMarkdown(feishuBlocksToMd(docxBlocks));
|
||||
const output = `# ${pageTitle}\n\n> Source: ${TARGET_URL}\n\n${markdown}`;
|
||||
const safeTitle = pageTitle.replace(/[/\\:*?"<>|]/g, '_').trim();
|
||||
const outDir = resolve('output');
|
||||
if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
|
||||
const outputPath = resolve(customOutput || `output/${safeTitle || 'output'}.md`);
|
||||
writeFileSync(outputPath, output, 'utf-8');
|
||||
|
||||
console.log('');
|
||||
console.log('╔════════════════════════════════════════════╗');
|
||||
console.log(`║ ✅ 完成!共 ${output.length} 字符`);
|
||||
console.log(`║ 📄 ${outputPath}`);
|
||||
console.log('╚════════════════════════════════════════════╝');
|
||||
await browser.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 读取 TOKEN(函数,方便重登后复用)
|
||||
async function readToken() {
|
||||
return page.evaluate(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem('#|g.TOKEN')
|
||||
|| Object.entries(localStorage).find(([k]) => k.includes('TOKEN'))?.[1]
|
||||
|| null;
|
||||
if (!raw) return null;
|
||||
return raw.startsWith('{') ? JSON.parse(raw).$v || null : raw;
|
||||
} catch { return null; }
|
||||
}).catch(() => null);
|
||||
}
|
||||
|
||||
// 读取 TOKEN
|
||||
let capturedToken = await page.evaluate(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem('#|g.TOKEN')
|
||||
|| Object.entries(localStorage).find(([k]) => k.includes('TOKEN'))?.[1]
|
||||
|| null;
|
||||
if (!raw) return null;
|
||||
return raw.startsWith('{') ? JSON.parse(raw).$v || null : raw;
|
||||
} catch { return null; }
|
||||
}).catch(() => null);
|
||||
console.log('🔑 TOKEN:', capturedToken ? capturedToken.substring(0, 8) + '...' : '未找到');
|
||||
|
||||
if (!apiResponseText) {
|
||||
console.log('⏳ 等待 API 数据加载...');
|
||||
await page.waitForTimeout(10000);
|
||||
}
|
||||
if (!apiResponseText) {
|
||||
console.log('🔄 重新加载页面...');
|
||||
await page.goto(TARGET_URL, { waitUntil: 'networkidle', timeout: 30000 });
|
||||
await page.waitForTimeout(5000);
|
||||
}
|
||||
if (!apiResponseText) {
|
||||
console.error('❌ 未捕获到 API 响应,可能未登录或文档受限');
|
||||
await browser.close();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!apiResponseText) {
|
||||
console.error('❌ 仍未获取到有效数据,请检查是否已完成登录或文档是否有访问权限');
|
||||
await browser.close();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── 解密 ──────────────────────────────────────────────────────────────────────
|
||||
let docData;
|
||||
const envelope = JSON.parse(apiResponseText);
|
||||
const payload = envelope.data;
|
||||
|
||||
if (capturedToken && typeof payload === 'string') {
|
||||
try {
|
||||
const key = CryptoJS.enc.Utf8.parse(CryptoJS.MD5(capturedToken).toString());
|
||||
const iv = CryptoJS.enc.Utf8.parse(capturedToken);
|
||||
const dec = CryptoJS.AES.decrypt(payload.trim(), key, {
|
||||
iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7,
|
||||
});
|
||||
docData = JSON.parse(dec.toString(CryptoJS.enc.Utf8));
|
||||
console.log('🔓 AES 解密成功');
|
||||
} catch (e) {
|
||||
console.log('⚠️ AES 解密失败:', e.message);
|
||||
docData = typeof payload === 'object' ? payload : envelope;
|
||||
}
|
||||
} else {
|
||||
docData = typeof payload === 'object' ? payload : envelope;
|
||||
console.log('📄 直接使用响应数据');
|
||||
}
|
||||
|
||||
// ── 转换并保存 ────────────────────────────────────────────────────────────────
|
||||
const pageTitle = await page.title();
|
||||
const markdown = cleanMarkdown(blocksToMarkdown(docData.blocks));
|
||||
const output = `# ${pageTitle}\n\n> Source: ${TARGET_URL}\n\n${markdown}`;
|
||||
|
||||
// 输出文件名:优先用参数,其次用标题,最后回退 output.md
|
||||
const safeTitle = pageTitle.replace(/[/\\:*?"<>|]/g, '_').trim();
|
||||
const outDir2 = resolve('output');
|
||||
if (!existsSync(outDir2)) mkdirSync(outDir2, { recursive: true });
|
||||
const outputPath = resolve(customOutput || `output/${safeTitle || 'output'}.md`);
|
||||
writeFileSync(outputPath, output, 'utf-8');
|
||||
|
||||
console.log('');
|
||||
console.log('╔════════════════════════════════════════════╗');
|
||||
console.log(`║ ✅ 完成!共 ${output.length} 字符`);
|
||||
console.log(`║ 📄 ${outputPath}`);
|
||||
console.log('╚════════════════════════════════════════════╝');
|
||||
|
||||
await browser.close();
|
||||