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 ""
+ ```
+
+ - **指定了标题**:在上述命令基础上追加 `--filter-title ""`:
-```bash
-npx -y lark-docx2md@latest dl --agent local ""
-```
+ ```bash
+ npx -y lark-docx2md@latest dl --agent local --filter-title "" ""
+ ```
-> **注意**:缺少 `--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 {
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' });