Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## 0.5.1

### Changed

- **`filterTitle` 无匹配时列出可用标题**:当 `--filter-title` / `ConvertOptions.filterTitle` 未在文档中找到对应标题时,错误信息附带文档中所有可用标题清单,便于快速定位与排错

## 0.5.0

### Features
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
41 changes: 36 additions & 5 deletions skills/lark-docx2md/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 比较)。

### 识别「用户是否指定了标题」的启发式规则 (重要)

按如下顺序判断,命中任一条即视为用户指定了标题,并将命中的文本作为 `<title>`:

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 文件,获得文档完整内容。
Expand Down
9 changes: 8 additions & 1 deletion src/converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
13 changes: 11 additions & 2 deletions src/title-filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export interface TitleFilterResult {
blocks: DocxBlock[];
/** 是否已找到匹配 */
matched: boolean;
/** 扫描期间遇到的所有 heading(按出现顺序,不去重;仅 scanning 阶段收集) */
availableHeadings: Array<{ level: number; text: string }>;
}

/**
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
Expand All @@ -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 };
Expand Down
51 changes: 51 additions & 0 deletions tests/title-filter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });

Expand Down
Loading