diff --git a/CHANGELOG.md b/CHANGELOG.md index bb5b98d..1fd11f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.5.1 + +### Changed + +- **`filterTitle` 无匹配时列出可用标题**:当 `--filter-title` / `ConvertOptions.filterTitle` 未在文档中找到对应标题时,错误信息附带文档中所有可用标题清单,便于快速定位与排错 + ## 0.5.0 ### Features diff --git a/package.json b/package.json index 1c40d73..198f8a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lark-docx2md", - "version": "0.5.0", + "version": "0.5.1", "description": "Convert Lark/Feishu documents to Markdown", "type": "module", "main": "./dist/converter.js", diff --git a/skills/lark-docx2md/SKILL.md b/skills/lark-docx2md/SKILL.md index 6a846c7..81f66f2 100644 --- a/skills/lark-docx2md/SKILL.md +++ b/skills/lark-docx2md/SKILL.md @@ -11,16 +11,47 @@ description: 读取飞书 Wiki 文档或电子表格(`https://*.feishu.cn/wiki - 飞书文档 URL,格式为 `https://*.feishu.cn/wiki/...` 或 `https://*.feishu.cn/sheets/...`(可带 `?sheet=xxx` 参数) +## 可选输入:标题(用于 `--filter-title`) + +当用户只想要文档中的某一节内容时,可通过 `--filter-title "<标题>"` 仅转换匹配该标题及其子级的部分(标题文本需与文档中的标题**完全一致**,仅做 trim 比较)。 + +### 识别「用户是否指定了标题」的启发式规则 (重要) + +按如下顺序判断,命中任一条即视为用户指定了标题,并将命中的文本作为 ``: + +1. **显式指令**:用户消息中出现「标题」「章节」「小节」「部分」「节选」「只要/只读/仅要/仅读」「其中的」等关键词,并紧跟一个可识别的名词短语或引号文本。 + - 例:`只要「接入指南」这一节` → title = `接入指南` + - 例:`把其中“常见问题”部分转成 md` → title = `常见问题` +2. **引号包裹**:用户消息中出现成对的引号(`""` / `''` / `“”` / `‘’` / `「」` / `《》`)包裹的短文本,且语义上指代文档中的某一节。 + - 例:`读一下文档的「部署流程」` → title = `部署流程` +3. **介词结构**:形如 `URL 中的 X`、`文档里的 X`、`X 那一段`、`关于 X 的内容` 等结构,X 为短名词短语(一般 ≤ 30 字、不含句号/换行)。 +4. **未命中即视为未指定**:若用户仅给出 URL、或要求「完整」「全部」「整篇」内容,或无法稳定抽取标题文本,则**不要**添加 `--filter-title`。 + +### 标题文本的规范化 + +- 去除首尾空白与包裹用的引号。 +- **保留**原文大小写、标点、空格与中英文字符(过滤器按精确匹配比较)。 +- 不要自行翻译、改写或补全标题。 +- 命令行中用双引号包裹;若标题内含英文双引号,用 `\"` 转义。 + ## 工作流程 1. 验证 URL 包含 `/wiki/` 或 `/sheets/`。 -2. **必须**严格按以下命令调用,`--agent local` 参数不可省略: +2. 按上文规则判断用户是否指定了标题,并据此构造命令: + + - **未指定标题**(默认): + + ```bash + npx -y lark-docx2md@latest dl --agent local "<url>" + ``` + + - **指定了标题**:在上述命令基础上追加 `--filter-title "<title>"`: -```bash -npx -y lark-docx2md@latest dl --agent local "<wiki_url>" -``` + ```bash + npx -y lark-docx2md@latest dl --agent local --filter-title "<title>" "<url>" + ``` -> **注意**:缺少 `--agent local` 会导致输出格式不适合 AI 处理,必须保留该参数。 + > **注意**:`--agent local` 参数在任何情况下都不可省略,缺少它会导致输出格式不适合 AI 处理。 3. 捕获标准输出,从提示词中解析出 Markdown 文件的绝对路径。 4. 读取该绝对路径对应的 Markdown 文件,获得文档完整内容。 diff --git a/src/converter.ts b/src/converter.ts index c16f5b9..5d8e5f5 100644 --- a/src/converter.ts +++ b/src/converter.ts @@ -64,7 +64,14 @@ export async function convert (opts: ConvertOptions): Promise<ConvertResult> { await client.getDocxBlocks(docToken, filter.pageHandler); const result = filter.getResult(); if (!result.matched) { - throw new Error(`未找到匹配的标题「${opts.filterTitle}」,请检查标题文本是否正确`); + let msg = `No heading matched "${opts.filterTitle}". Please verify the heading text.`; + if (result.availableHeadings.length > 0) { + const list = result.availableHeadings + .map(h => `${'#'.repeat(h.level)} ${h.text}`) + .join('\n'); + msg += `\n\nAvailable headings in the document:\n\n${list}`; + } + throw new Error(msg); } blocks = result.blocks; } else { diff --git a/src/title-filter.ts b/src/title-filter.ts index 0d407cb..0fdd785 100644 --- a/src/title-filter.ts +++ b/src/title-filter.ts @@ -15,6 +15,8 @@ export interface TitleFilterResult { blocks: DocxBlock[]; /** 是否已找到匹配 */ matched: boolean; + /** 扫描期间遇到的所有 heading(按出现顺序,不去重;仅 scanning 阶段收集) */ + availableHeadings: Array<{ level: number; text: string }>; } /** @@ -58,6 +60,7 @@ export function createTitleFilter (options: TitleFilterOptions): { let state: FilterState = 'scanning'; let matchedLevel = 0; const collected: DocxBlock[] = []; + const seenHeadings: Array<{ level: number; text: string }> = []; function pageHandler (blocks: DocxBlock[]): boolean { for (const block of blocks) { @@ -70,7 +73,9 @@ export function createTitleFilter (options: TitleFilterOptions): { case 'scanning': { const level = getHeadingLevel(block); if (level !== null) { - const text = extractHeadingText(block); + const text = extractHeadingText(block) ?? ''; + // 记录扫描到的所有标题(不去重、按出现顺序),用于未匹配时给出可用标题列表 + seenHeadings.push({ level, text }); if (text === targetTitle) { state = 'collecting'; matchedLevel = level; @@ -97,7 +102,11 @@ export function createTitleFilter (options: TitleFilterOptions): { } function getResult (): TitleFilterResult { - return { blocks: [...collected], matched: state === 'collecting' || state === 'done' }; + return { + blocks: [...collected], + matched: state === 'collecting' || state === 'done', + availableHeadings: [...seenHeadings], + }; } return { pageHandler, getResult }; diff --git a/tests/title-filter.test.ts b/tests/title-filter.test.ts index d8e3c82..9fae007 100644 --- a/tests/title-filter.test.ts +++ b/tests/title-filter.test.ts @@ -152,6 +152,57 @@ describe('createTitleFilter', () => { expect(getResult().matched).toBe(false); }); + it('collects all scanned headings into availableHeadings when not matched (no dedupe, in order)', () => { + const { pageHandler, getResult } = createTitleFilter({ title: 'Missing' }); + + pageHandler([ + makeHeadingBlock(1, 'Intro'), + makeTextBlock('t1'), + makeHeadingBlock(2, 'Section A'), + makeHeadingBlock(3, 'Section A'), // 重复文本但 level 不同 + makeHeadingBlock(2, 'Section A'), // 完全重复 + makeHeadingBlock(1, 'Outro'), + ]); + + const result = getResult(); + expect(result.matched).toBe(false); + expect(result.availableHeadings).toEqual([ + { level: 1, text: 'Intro' }, + { level: 2, text: 'Section A' }, + { level: 3, text: 'Section A' }, + { level: 2, text: 'Section A' }, + { level: 1, text: 'Outro' }, + ]); + }); + + it('availableHeadings only records headings scanned before match', () => { + const { pageHandler, getResult } = createTitleFilter({ title: 'Target' }); + + pageHandler([ + makeHeadingBlock(1, 'Before'), + makeHeadingBlock(2, 'Target'), + makeHeadingBlock(3, 'Child'), // collecting 阶段,不进 availableHeadings + makeHeadingBlock(1, 'After'), // 触发结束,不进 availableHeadings + ]); + + const result = getResult(); + expect(result.matched).toBe(true); + expect(result.availableHeadings).toEqual([ + { level: 1, text: 'Before' }, + { level: 2, text: 'Target' }, + ]); + }); + + it('availableHeadings is empty when document has no headings', () => { + const { pageHandler, getResult } = createTitleFilter({ title: 'Whatever' }); + + pageHandler([makePageBlock(), makeTextBlock('t1'), makeTextBlock('t2')]); + + const result = getResult(); + expect(result.matched).toBe(false); + expect(result.availableHeadings).toEqual([]); + }); + it('subsequent pageHandler calls after done still return false (covers done state)', () => { const { pageHandler } = createTitleFilter({ title: 'A' });