Skill: zhangxusong637/vision-ocr (version 1.1.2)

--- name: vision-ocr description: 用于识别图片和 PDF 文档，调用你已配置的 OCR 与多模态服务输出 Markdown 结果，并可按需发送到飞书。适合截图、扫描件、表格、票据和技术文档。 --- # vision-ocr vision-ocr 是一个用于 OpenClaw 的文档识别技能，支持图片和 PDF 输入，输出结构化 Markdown 结果，并在机器人会话中优先发送到当前飞书会话。 ## 适用场景 - 文档截图 - 扫描件 - 表格和票据 - 技术文档 - 多页 PDF - 手写便条和手写批注 ## 核心能力 - 文档识别：图片默认按文档流程处理。 - PDF 处理：支持逐页识别与基础信息查看。 - Markdown 输出：尽量保留原始文档结构，复杂表格允许混合 HTML 表格。 - 多模态整合：OCR 有效时结合原图输出最终 Markdown。 - 手写优化：手写文档、手写便条和手写批注会自动启用手写优先识别策略。 - 飞书发送：默认关闭；显式开启后可发送到当前会话或指定对象，无会话上下文时保留本地结果。 ## 触发方式 ```text 识别图片 /path/to/image.jpg 识别 PDF /path/to/file.pdf --pdf-option=ocr_full OCR 这个截图 ``` ## 🚀 飞书集成场景：自动识别 + 按需发送 **目标：** 在飞书/集成场景下实现「发送图片/PDF → 自动识别 → 按需发送结果到当前会话」 ### 正确操作（⚠️ 必须遵守） | 场景 | 调用方式 | 能否自动发送 | |------|---------|-------------| | ✅ **飞书集成** | 直接在主会话调用 `vision-ocr.run(context)` 并显式开启发送 | ✅ 可以 | | ❌ 子会话 | 通过子会话调用 `vision-ocr.run(context)` | ❌ 无法发送 | | ❌ 命令行 | `node index.js --image ...` | ❌ 无法发送 | ### 为什么子会话不行？ ``` 主会话（飞书集成） ↓ 子会话（隔离环境） ↓ vision-ocr 尝试获取会话上下文 ↓ ❌ 当前上下文未传入 session.chatId / session.openId ↓ ❌ 无法确定发送目标，跳过自动发送 ``` **解决方案：** 直接在主会话中调用 `vision-ocr.run(context)`，确保能获取到 `chatId` 和 `openId` 或 `senderId`。 ### 代码示例 ```javascript // ✅ 正确：在主会话中直接调用 const visionOcr = require('/home/node/.openclaw/workspace/skills/vision-ocr'); await visionOcr.run({ session: { chatId: 'chat_xxx', openId: 'ou_xxx', isGroup: false }, message: { text: '识别图片 /path/to/image.jpg' }, replyText: async (msg) => { // 显式开启发送后，可发送到当前飞书会话 console.log(msg); } }); // ❌ 错误：通过子会话调用会丢失当前会话上下文 ``` ### 关键检查清单 在使用 vision-ocr 实现自动发送前，确认： - [ ] **在主会话中直接调用**（不是子会话） - [ ] **通过 `run(context)` 传递会话上下文**（不是命令行） - [ ] **context 中包含 `session.chatId` 和 `session.openId` / `session.senderId`** --- ## 基本配置 推荐在技能目录 `config.json` 或当前进程环境变量中提供以下内容： ```json { "ocr": { "imageocr": { "token": "你的 ImageOCR Token", "endpoint": "你的 ImageOCR 端点" }, "multimodal": { "baseUrl": "你的多模态 API 地址", "token": "你的多模态 Token", "model": "你的模型名称" } }, "autoSendToFeishu": false } ``` 如果技能运行在 OpenClaw 会话中，并且你显式开启了飞书发送，同时运行时提供了当前群聊或私聊上下文，则发送飞书文件时会优先发到当前会话。若要发送给其他对象，可直接在对话中明确指定，例如：发给 open_id:ou_xxx 或 发给 chat:oc_xxx。 CLI 模式下，只有显式开启 `--resolve-openclaw-session` 或设置 [REDACTED_SECRET] 时，技能才会尝试从 `OPENCLAW_CHAT_ID`、`OPENCLAW_SENDER_ID`、`OPENCLAW_OPEN_ID` 等环境变量，以及 `~/.openclaw/runtime.json` 中恢复当前会话信息。机器人模式默认只信任 `context.session` 或消息中显式指定的接收者。 `node index.js --update-config` 只会把当前 shell 中的 `VISION_*` 环境变量写入技能目录 `config.json`，不会读取或改写 OpenClaw 全局配置。 如果 OpenClaw 需要发送到当前会话，必须调用导出的机器人入口 `run(context)` 并显式开启发送，而不是只执行命令行。例如： ```js const visionOcr = require('/home/node/.openclaw/workspace/skills/vision-ocr'); await visionOcr.run({ session: { chatId: 'chat_xxx', openId: 'ou_xxx', isGroup: false }, message: { text: '识别图片 /path/to/image.jpg' }, replyText: async (msg) => { console.log(msg); } }); ``` 如果仅执行 `node index.js --image ...`，识别会成功，但不会有当前会话信息，因此不会自动发送到当前飞书会话。 ## 常用命令 ```bash node index.js --image /path/to/image.jpg --confirm=true node index.js --image /path/to/file.pdf --pdf-option=ocr_full --confirm=true node index.js --image /path/to/image.jpg --confirm=true --no-send-to-feishu node index.js --image /path/to/image.jpg --confirm=true --send-to-feishu --resolve-openclaw-session ``` ## 行为说明 - 图片默认先执行 OCR。 - OCR 有效时，会将 OCR Markdown 和原图一起交给大模型生成最终 Markdown。 - OCR 无效时，会自动切换为图片描述模式。 - 显式开启飞书发送时，优先发送到当前会话；没有会话上下文时不会自动发送。 - 只有显式开启远程输入时，技能才会下载远程附件 URL 或 token 模板资源。 - 关闭飞书发送后，结果文件会保留在临时目录，便于手动处理。 ## 依赖 - Node.js 18+ - Python 3.8+ - PyMuPDF - feishu-send-files 技能 ## 隐私提示 - 默认不会发送识别结果到飞书；只有显式开启后才会发送。 - 处理敏感文档时，建议使用 --no-send-to-feishu。 - 仓库和发布包中不应包含真实凭据或本地配置文件。 ## 版本 当前版本：1.1.2 ## 修订说明 - 1.1.2：安全增强，远程附件下载 SSRF 过滤（禁止 localhost/私有段），新增下载超时与最大大小；PDF helper 调用加 `maxBuffer` 与 `PYTHON_PATH` 支持。 - 1.1.1：新增多模态大图自动预处理与 base64 清理，固定 PDF helper 文件，远程附件下载与 CLI OpenClaw 会话恢复改为显式开启，并同步更新文档与打包内容。

path: pdf-helper.py
content: #!/usr/bin/env python3

import argparse
import json
import sys

import fitz


def pdf_to_images(input_path, output_dir):
    doc = fitz.open(input_path)
    images = []

    try:
        page_count = len(doc)
        for page_num in range(page_count):
            page = doc[page_num]
            pix = page.get_pixmap(matrix=fitz.Matrix(2.0, 2.0))
            output_path = f"{output_dir}/page_{page_num + 1:03d}.png"
            pix.save(output_path)
            images.append(output_path)
    finally:
        doc.close()

    return {
        "images": images,
        "pageCount": len(images)
    }


def pdf_info(input_path):
    doc = fitz.open(input_path)

    try:
        mediabox = doc[0].rect
        return {
            "pages": len(doc),
            "width": mediabox.width,
            "height": mediabox.height,
        }
    finally:
        doc.close()


def optimize_image(input_path, output_path, max_pixels, max_side, jpg_quality):
    pix = fitz.Pixmap(input_path)

    try:
        if pix.colorspace is None or pix.colorspace.n != 3 or pix.alpha:
            pix = fitz.Pixmap(fitz.csRGB, pix)

        original_width = pix.width
        original_height = pix.height
        shrink_steps = 0

        while (
            (pix.width * pix.height > max_pixels or max(pix.width, pix.height) > max_side)
            and min(pix.width, pix.height) > 1
        ):
            pix.shrink(1)
            shrink_steps += 1

        pix.save(output_path, jpg_quality=jpg_quality)

        return {
            "outputPath": output_path,
            "originalWidth": original_width,
            "originalHeight": original_height,
            "width": pix.width,
            "height": pix.height,
            "shrinkSteps": shrink_steps,
        }
    finally:
        pix = None


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--mode", required=True, choices=["pdf-to-images", "pdf-info", "optimize-image"])
    parser.add_argument("--input", required=True)
    parser.add_argument("--output-dir")
    parser.add_argument("--output")
    parser.add_argument("--max-pixels", type=int, default=4000000)
    parser.add_argument("--max-side", type=int, default=1800)
    parser.add_argument("--jpg-quality", type=int, default=85)
    args = parser.parse_args()

    if args.mode == "pdf-to-images":
        if not args.output_dir:
            raise ValueError("--output-dir is required for pdf-to-images")
        result = pdf_to_images(args.input, args.output_dir)
    elif args.mode == "pdf-info":
        result = pdf_info(args.input)
    else:
        if not args.output:
            raise ValueError("--output is required for optimize-image")
        result = optimize_image(args.input, args.output, args.max_pixels, args.max_side, args.jpg_quality)

    print(json.dumps(result, ensure_ascii=False))


if __name__ == "__main__":
    try:
        main()
    except Exception as error:
        print(str(error), file=sys.stderr)
        sys.exit(1)

path: README.md
content: # vision-ocr

vision-ocr 是一个面向 OpenClaw 的文档识别技能，支持图片和 PDF 的 OCR 提取、多模态整合，以及 Markdown 结果输出。

它适合处理截图、扫描件、表格、技术文档、票据和常见 PDF 文档。识别完成后默认保留在本地临时目录；如需发送到飞书，请显式开启。

当前版本：1.1.2

## 1.1.2 修订说明

- 安全强化：远程附件下载引入 SSRF 防护，禁止 localhost/私有网段 URL，新增 15s 超时与 50MB 限制。
- 安全强化：PDF helper 调用改用可配置 `PYTHON_PATH` 且上限 `maxBuffer` 20MB。
- 更新运行时读取策略：`VISION_RESOLVE_OPENCLAW_SESSION` 仍需显式开启，避免默认泄露会话信息。

## 1.1.1 修订说明

- 多模态链路新增大图自动预处理，超大图片会先缩放再发送，降低服务端处理失败概率。
- 发送图片前统一清理 base64 中的换行符，并按真实图片 MIME 类型构造 data URL，提升多模态兼容性。
- PDF 处理改为调用固定 `pdf-helper.py`，不再运行时动态生成 Python 脚本。
- 远程附件下载默认关闭，需显式开启 `--allow-remote-input` 或 `VISION_ALLOW_REMOTE_INPUT=true`。
- CLI 会话接收者恢复默认关闭，需显式开启 `--resolve-openclaw-session` 或 `VISION_RESOLVE_OPENCLAW_SESSION=true`。
- 文档与打包内容同步更新，发布包现包含固定 helper 文件并反映最新隐私/安全默认值。

## 核心能力

- 图片识别：支持常见图片格式，默认按文档识别流程处理。
- PDF 识别：支持逐页处理，适合长文档和多页材料。
- Markdown 输出：尽量保留标题、列表、表格和代码块结构。
- 多模态整合：OCR 有效时，结合原图输出更完整的最终 Markdown。
- 手写优化：检测到手写文档、手写便条或手写批注相关指令时，会自动启用手写优先识别策略。
- 飞书发送：默认关闭，可显式开启发送到当前会话或其他对象。

## 安装要求

必需环境：

- Node.js 18+
- Python 3.8+
- PyMuPDF

安装 PyMuPDF：

```bash
pip install pymupdf
```

如需发送到飞书，建议同时安装 `feishu-send-files` 技能。

## 快速开始

1. 安装技能并进入目录。
2. 准备 OCR 和多模态配置。
3. 运行命令或直接在 OpenClaw 中调用。

从环境变量写入本技能本地配置：

```bash
cd ~/.openclaw/workspace/skills/vision-ocr
node index.js --update-config
```

图片识别示例：

```bash
node index.js --image /path/to/image.jpg --confirm=true
```

PDF 识别示例：

```bash
node index.js --image /path/to/file.pdf --pdf-option=ocr_full --confirm=true
```

关闭飞书发送：

```bash
node index.js --image /path/to/image.jpg --confirm=true --no-send-to-feishu
```

仅返回 OCR 原始结果：

```bash
node index.js --image /path/to/image.jpg --confirm=true --ocr-only
node index.js --image /path/to/file.pdf --pdf-option=ocr_full --confirm=true --ocr-only
```

## 配置

支持以下配置来源，优先级从高到低：

1. 环境变量
2. 技能目录配置 `./config.json`

推荐配置示例：

```json
{
  "ocr": {
    "imageocr": {
      "token": "你的 ImageOCR Token",
      "endpoint": "你的 ImageOCR 端点"
    },
    "multimodal": {
      "baseUrl": "你的多模态 API 地址",
      "token": "你的多模态 Token",
      "model": "你的模型名称"
    }
  },
  "autoSendToFeishu": false
}
```

未显式配置 `autoSendToFeishu` 时，技能默认保留本地结果文件。只有显式设置 `autoSendToFeishu: true`、传入 `--send-to-feishu`，或在上层集成中明确开启发送时，才会尝试发送到飞书。

机器人模式下，如果 OpenClaw 提供当前会话上下文并且你显式开启发送，识别结果会优先发送到当前群聊或当前私聊。若要发送给其他对象，可直接在对话里明确指定，例如：`发给 open_id:ou_xxx` 或 `发给 chat:oc_xxx`。

CLI 模式下，只有显式开启 `--resolve-openclaw-session` 或设置 `VISION_RESOLVE_OPENCLAW_SESSION=true` 时，技能才会尝试从 `OPENCLAW_CHAT_ID`、`OPENCLAW_SENDER_ID`、`OPENCLAW_OPEN_ID` 等环境变量，以及 `~/.openclaw/runtime.json` 中恢复当前会话信息。机器人模式默认只信任 `context.session` 或消息中显式指定的接收者。

OpenClaw 集成时，如需发送到当前会话，必须以机器人模式调用导出的 `run(context)` 并显式开启发送，不能只走命令行：

```js
const visionOcr = require('/home/node/.openclaw/workspace/skills/vision-ocr');

await visionOcr.run({
  session: {
    chatId: 'chat_xxx',
    openId: 'ou_xxx',
    isGroup: false
  },
  message: {
    text: '识别图片 /path/to/image.jpg'
  },
  replyText: async (msg) => {
    console.log(msg);
  }
});
```

如果 OpenClaw 只执行 `node index.js --image ...` 这类命令行调用，技能仍可完成识别，但不会拿到 `context.session`，因此无法自动发送到当前飞书会话。

`node index.js --update-config` 只会读当前 shell 中的 `VISION_*` 环境变量，并写入技能目录下的 `config.json`。它不会读取或改写任何 OpenClaw 全局配置。

常用环境变量：

```bash
export VISION_IMAGEOCR_TOKEN="你的 Token"
export VISION_IMAGEOCR_ENDPOINT="你的端点"
export VISION_BASE_URL="你的多模态 API 地址"
export VISION_MULTIMODAL_TOKEN="你的多模态 Token"
export VISION_MODEL="你的模型名称"
export VISION_AUTO_SEND_TO_FEISHU="true"
export VISION_ALLOW_REMOTE_INPUT="false"
export VISION_RESOLVE_OPENCLAW_SESSION="false"
```

## 使用方式

OpenClaw 对话示例：

```text
识别图片 /path/to/image.jpg
识别 PDF /path/to/file.pdf --pdf-option=ocr_full
OCR 这个截图
识别图片 /path/to/image.jpg 只要OCR结果
直接发送一张图片附件，不额外写路径
在显式开启远程输入后发送一个可直接下载的远程图片 URL
```

常用参数：

| 参数 | 说明 |
| ---- | ---- |
| `--image` | 输入图片或 PDF 路径 |
| `--pdf-option` | PDF 处理方式 |
| `--prompt` | 自定义补充指令 |
| `--prompt-type` | 预设模板类型 |
| `--confirm` | 跳过交互确认 |
| `--progress` | 显示处理进度 |
| `--ocr-only` | 仅返回 OCR 原始结果，不做多模态整合 |
| `--send-to-feishu` | 显式开启发送到飞书 |
| `--no-send-to-feishu` | 显式关闭发送到飞书 |
| `--allow-remote-input` | 显式允许下载远程附件 URL 或 token 模板资源 |
| `--resolve-openclaw-session` | 显式允许从 `OPENCLAW_*` 或 `runtime.json` 恢复接收者 |
| `--to` | 显式指定接收者，例如 `open_id:ou_xxx` |

PDF 处理选项：

| 选项 | 说明 |
| ---- | ---- |
| `ocr_full` | 逐页识别全部内容 |
| `ocr_table` | 逐页提取表格内容 |
| `ocr_plain` | 逐页提取纯文字 |
| `save_images` | 将 PDF 保存为图片 |
| `info` | 查看 PDF 基本信息 |

## 结果与隐私

- 默认不会发送识别结果到飞书；只有显式开启后才会发送。
- 机器人模式下，如果上下文里带有附件本地路径，技能可以直接识别，不必强制把路径写进消息文本。
- 只有显式开启远程输入后，如果上下文里提供了可直接下载的远程附件 URL，或提供了 `file token + download URL 模板/前缀`，技能才会先下载到本地临时目录再识别。
- 当前已显式适配的本地字段包括：`path`、`filePath`、`file_path`、`localPath`、`local_path`、`tempFilePath`、`temp_file_path`、`downloadPath`、`savedPath`。
- 当前已显式适配的远程字段包括：`url`、`downloadUrl`、`download_url`、`previewUrl`、`fileUrl`、`imageUrl`、`resourceUrl`，以及 `file_token` / `fileToken` / `file_key` / `image_key` 配合 `attachmentDownloadUrlTemplate`、`downloadUrlTemplate`、`attachmentDownloadUrlPrefix` 等模板或前缀字段。
- 当前会优先扫描这些常见容器：`context.message`、`context.message.attachments`、`context.message.files`、`context.message.images`、`context.attachments`、`context.files`、`context.images`、`context.event.message`、`context.payload.message`、`context.extra.attachments`，以及 JSON 字符串形式的 `message.content`。
- 显式指定 `--ocr-only` 或在对话中说明“只要 OCR 结果”时，会直接返回 OCR 原始结果，便于排查识别质量。
- 对 PDF 启用 `--ocr-only` 时，会按页返回每页 OCR 原文，不再做多模态整合。
- 默认会先判断图片更像文档、代码截图、界面截图，还是自然照片/动物照片/人物照片，再自动决定走 OCR+多模态，还是直接多模态描述。
- 机器人回复会附带简短判定说明，便于查看是按文档链路、照片链路还是回退链路完成处理。
- 如果上游只给裸 `file token`，但没有下载模板、前缀或可用 URL，技能仍然无法自行下载该附件，需要 OpenClaw 集成层补充可下载地址或先落地到本地路径。
- 在 OpenClaw 机器人会话中，若显式开启发送，会优先发送到当前会话；没有当前会话上下文时不会自动发送。
- 如处理敏感文档，继续保持默认本地输出，或显式使用 `--no-send-to-feishu`。
- 发送关闭或发送失败时，结果会保留在临时目录 `/tmp/vision-ocr-<PID>/result.md`。
- 仓库和发布包中不应包含真实 `config.json`、Token 或 API Key。
- 技能会读取输入文件、技能目录 `config.json`、显式 `VISION_*` 环境变量；只有显式开启 OpenClaw 会话恢复后，才会在 CLI 发送场景下读取 `OPENCLAW_*` 环境变量或 `~/.openclaw/runtime.json` 以恢复接收者。
- 技能默认只会向你配置的 OCR、多模态服务发起网络请求；开启飞书发送时才会调用 `feishu-send-files`。

## 常见问题

### 未找到 imageocr 配置

请检查以下任一位置是否已配置：

1. `./config.json`
2. 环境变量 `VISION_IMAGEOCR_TOKEN` 与 `VISION_IMAGEOCR_ENDPOINT`

### 未自动发送到飞书

如果未显式开启飞书发送，或当前运行环境没有提供会话上下文，技能会保留结果文件而不自动发送。OpenClaw 集成时，如需发送到当前会话，请改为调用导出的 `run(context)` 并开启发送，或在对话中显式指定接收者，或手动发送生成的 Markdown 文件。

### PDF 转图片失败

通常是未安装 PyMuPDF：

```bash
pip install pymupdf
```

### 需要查看详细日志

```bash
DEBUG=1 node index.js --image /path/to/file.jpg --confirm=true
```

## 发布页

Clawhub 页面：<https://clawhub.ai/zhangxusong637/vision-ocr>


path: config.example.json
content: {
  "ocr": {
    "imageocr": {
      "token": "你的 ImageOCR Token",
      "endpoint": "你的 ImageOCR 端点"
    },
    "multimodal": {
      "baseUrl": "你的多模态 API 地址（必需）",
      "token": "你的多模态 Token（必需）",
      "model": "你的模型名称（必需）"
    }
  },
  "autoSendToFeishu": false
}


path: package.json
content: {
  "name": "vision-ocr",
  "version": "1.1.2",
  "description": "混合多模态 OCR 技能，支持图片/PDF 识别、Markdown 输出和可选飞书发送。",
  "main": "index.js",
  "scripts": {
    "start": "node index.js",
    "check": "node --check index.js && node --check update-config.js && node --check test-config.js && node --check test-timeout.js && node --check test-cli-parsers.js",
    "test:unit": "node test-cli-parsers.js",
    "test": "npm run check && npm run test:unit",
    "package:release": "tar -czf vision-ocr.tar.gz index.js update-config.js pdf-helper.py skill.json SKILL.md README.md config.example.json package.json LICENSE"
  },
  "keywords": [
    "ocr",
    "vision",
    "多模态",
    "图片识别"
  ],
  "author": "张旭松",
  "license": "MIT",
  "engines": {
    "node": ">=18"
  }
}


path: update-config.js
content: #!/usr/bin/env node

/**
 * vision-ocr local config helper
 *
 * This script only reads VISION_* environment variables and writes the local
 * config.json in the skill directory. It does not read or modify any global
 * OpenClaw configuration files.
 */

const fs = require('fs');
const path = require('path');

const args = process.argv.slice(2);
const LOCAL_CONFIG = path.join(__dirname, 'config.json');
const EXAMPLE_CONFIG = path.join(__dirname, 'config.example.json');
const REQUIRED_FIELDS = [
  'ocr.imageocr.token',
  'ocr.imageocr.endpoint',
  'ocr.multimodal.baseUrl',
  'ocr.multimodal.token',
  'ocr.multimodal.model'
];

function log(message, level = 'INFO') {
  const timestamp = new Date().toISOString();
  console.log(`[${timestamp}] [${level}] ${message}`);
}

function readJsonFile(filePath) {
  try {
    if (!fs.existsSync(filePath)) {
      return null;
    }

    return JSON.parse(fs.readFileSync(filePath, 'utf8'));
  } catch (error) {
    log(`读取文件失败：${filePath} - ${error.message}`, 'ERROR');
    return null;
  }
}

function writeJsonFile(filePath, data) {
  try {
    fs.mkdirSync(path.dirname(filePath), { recursive: true });
    fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
    log(`文件已更新：${filePath}`, 'SUCCESS');
    return true;
  } catch (error) {
    log(`写入文件失败：${filePath} - ${error.message}`, 'ERROR');
    return false;
  }
}

function buildEnvConfig() {
  return {
    ocr: {
      imageocr: {
        [REDACTED_SECRET] || undefined,
        endpoint: process.env.VISION_IMAGEOCR_ENDPOINT || undefined
      },
      multimodal: {
        baseUrl: process.env.VISION_BASE_URL || undefined,
        [REDACTED_SECRET] || undefined,
        model: process.env.VISION_MODEL || undefined
      }
    },
    autoSendToFeishu: process.env.VISION_AUTO_SEND_TO_FEISHU
      ? /^(1|true|yes)$/i.test(process.env.VISION_AUTO_SEND_TO_FEISHU)
      : undefined
  };
}

function mergeConfig(baseConfig, overrideConfig) {
  return {
    ...baseConfig,
    ...overrideConfig,
    ocr: {
      ...baseConfig?.ocr,
      ...overrideConfig?.ocr,
      imageocr: {
        ...baseConfig?.ocr?.imageocr,
        ...overrideConfig?.ocr?.imageocr
      },
      multimodal: {
        ...baseConfig?.ocr?.multimodal,
        ...overrideConfig?.ocr?.multimodal
      }
    }
  };
}

function getByPath(target, dottedPath) {
  return dottedPath.split('.').reduce((acc, key) => acc?.[key], target);
}

function getMissingFields(config) {
  return REQUIRED_FIELDS.filter(field => !getByPath(config, field));
}

function printSummary(config, missingFields) {
  console.log('\nLocal config status');
  console.log('-------------------');
  console.log('config.json:', fs.existsSync(LOCAL_CONFIG) ? LOCAL_CONFIG : 'not created');
  console.log('imageocr.token:', config?.ocr?.imageocr?.token ? 'configured' : 'missing');
  console.log('imageocr.endpoint:', config?.ocr?.imageocr?.endpoint || 'missing');
  console.log('multimodal.baseUrl:', config?.ocr?.multimodal?.baseUrl || 'missing');
  console.log('multimodal.token:', config?.ocr?.multimodal?.token ? 'configured' : 'missing');
  console.log('multimodal.model:', config?.ocr?.multimodal?.model || 'missing');
  console.log(
    'autoSendToFeishu:',
    typeof config?.autoSendToFeishu === 'boolean' ? String(config.autoSendToFeishu) : 'unset'
  );

  if (missingFields.length > 0) {
    console.log('\nMissing fields:');
    for (const field of missingFields) {
      console.log(`  - ${field}`);
    }
  } else {
    console.log('\nRequired fields are complete.');
  }
}

function loadBaseConfig() {
  return readJsonFile(LOCAL_CONFIG) || readJsonFile(EXAMPLE_CONFIG) || {};
}

async function main() {
  console.log('\nvision-ocr local config helper\n');

  const checkOnly = args.includes('--check');
  const forceUpdate = args.includes('--force');
  const envConfig = buildEnvConfig();
  const baseConfig = loadBaseConfig();
  const mergedConfig = mergeConfig(baseConfig, envConfig);
  const missingFields = getMissingFields(mergedConfig);

  printSummary(mergedConfig, missingFields);

  if (checkOnly) {
    process.exit(missingFields.length === 0 ? 0 : 1);
  }

  const hasVisionEnv = Object.keys(process.env).some(name => name.startsWith('VISION_'));
  if (!forceUpdate && !hasVisionEnv) {
    log('未检测到任何 VISION_* 环境变量，未改写本地配置。', 'WARN');
    console.log('\n如需写入本地 config.json，请先设置对应的 VISION_* 环境变量，再运行该命令。');
    process.exit(missingFields.length === 0 ? 0 : 1);
  }

  if (!writeJsonFile(LOCAL_CONFIG, mergedConfig)) {
    process.exit(1);
  }

  log(`仅更新技能目录本地配置：${LOCAL_CONFIG}`, 'SUCCESS');
}

main().catch(error => {
  log(`执行失败：${error.message}`, 'ERROR');
  process.exit(1);
});



path: skill.json
content: {
  "name": "vision-ocr",
  "displayName": "Vision OCR - 图片/PDF OCR 转 Markdown",
  "version": "1.1.2",
  "description": "识别图片、截图、扫描件和 PDF，调用你配置的 OCR/多模态服务输出 Markdown，可选发送到飞书",
  "keywords": [
    "ocr",
    "vision",
    "pdf",
    "image",
    "document",
    "markdown",
    "image ocr",
    "pdf ocr",
    "document ocr",
    "multimodal",
    "document processing",
    "screenshot ocr",
    "scan ocr",
    "table ocr",
    "invoice ocr",
    "receipt ocr",
    "图片ocr",
    "pdf识别",
    "图片识别",
    "文档识别",
    "扫描件识别",
    "截图识别",
    "表格识别",
    "票据识别",
    "多模态"
  ],
  "author": "张旭松",
  "license": "MIT",
  "repository": "https://clawhub.ai/zhangxusong637/vision-ocr",
  "homepage": "https://clawhub.ai/zhangxusong637/vision-ocr",
  "entry": "index.js",
  "category": "document-processing",
  "tags": [
    "ocr",
    "document-processing",
    "document-ocr",
    "pdf-ocr",
    "image-ocr",
    "markdown-output",
    "multimodal-ocr",
    "table-extraction",
    "invoice-recognition",
    "screenshot-ocr",
    "scan-recognition",
    "feishu",
    "pdf",
    "image-recognition"
  ],
  "requirements": {
    "bins": ["node", "python3"],
    "packages": ["pymupdf"]
  },
  "config": {
    "example": "config.example.json",
    "env": [
      "VISION_IMAGEOCR_TOKEN",
      "VISION_IMAGEOCR_ENDPOINT",
      "VISION_BASE_URL",
      "VISION_MULTIMODAL_TOKEN",
      "VISION_MODEL",
      "VISION_AUTO_SEND_TO_FEISHU",
      "VISION_ALLOW_REMOTE_INPUT",
      "VISION_RESOLVE_OPENCLAW_SESSION",
      "VISION_SESSION_RECIPIENT_ID",
      "VISION_SESSION_RECIPIENT_TYPE",
      "VISION_SESSION_CHAT_ID",
      "VISION_SESSION_OPEN_ID"
    ]
  },
  "triggers": [
    {
      "type": "prefix_match",
      "value": "识别图片"
    },
    {
      "type": "prefix_match",
      "value": "识别 PDF"
    },
    {
      "type": "prefix_match",
      "value": "OCR"
    },
    {
      "type": "prefix_match",
      "value": "识别文档"
    },
    {
      "type": "prefix_match",
      "value": "提取文字"
    },
    {
      "type": "contains",
      "value": "识别"
    },
    {
      "type": "contains",
      "value": "OCR"
    },
    {
      "type": "contains",
      "value": "截图"
    },
    {
      "type": "contains",
      "value": "扫描件"
    },
    {
      "type": "contains",
      "value": "PDF"
    },
    {
      "type": "contains",
      "value": "表格"
    },
    {
      "type": "contains",
      "value": "转 Markdown"
    }
  ],
  "installation": {
    "steps": [
      {
        "type": "download",
        "label": "一键安装到 OpenClaw",
        "description": "在 Clawhub 搜索 vision-ocr，安装后即可识别图片、截图、扫描件和 PDF"
      },
      {
        "type": "manual",
        "label": "手动导入技能",
        "description": "复制到 ~/.openclaw/workspace/skills/vision-ocr，并在技能目录 config.json 或 VISION_* 环境变量中完成配置"
      }
    ],
    "dependencies": [
      {
        "name": "PyMuPDF",
        "command": "pip install pymupdf",
        "optional": false,
        "description": "PDF 转图片所需"
      },
      {
        "name": "feishu-send-files",
        "command": "clawhub install feishu-send-files",
        "optional": true,
        "description": "用于自动发送识别结果到当前飞书会话或指定对象"
      }
    ]
  },
  "configuration": {
    "priority": [
      "环境变量（最高）",
      "技能目录配置（./config.json）"
    ],
    "required": [
      "ocr.imageocr.token",
      "ocr.imageocr.endpoint",
      "ocr.multimodal.baseUrl",
      "ocr.multimodal.token",
      "ocr.multimodal.model"
    ],
    "optional": [
      "autoSendToFeishu"
    ],
    "defaults": {
      "autoSendToFeishu": false
    }
  }
}


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)