Skill: mozhihuidage/yescan-ocr-universal (version 1.0.9)

--- name: yescan-ocr-universal description: 由夸克扫描王提供的专业 OCR 文字识别与处理工具。支持图片、截图、扫描件中的文字提取，包括手写文档、表格内容、数学公式、商品图片等复杂场景。精准识别各类证件（身份证、社保卡、驾驶证、行驶证、港澳通行证、学位证等证件）及票据（增值税发票、火车票、英文发票等票据），同时支持医疗报告单、营业执照、习题题目等专业文档识别。 metadata: {"openclaw":{"emoji":"🔍︎","requires":{"bins":["python3"],"env":["SCAN_WEBSERVICE_KEY"]},"primaryEnv":"SCAN_WEBSERVICE_KEY"},"homepage":"https://scan.quark.cn/business"} --- # 🧭 使用前必读（30 秒） > [!WARNING] **⚠️ 隐私与数据流向重要提示** > - **第三方服务交互**：本技能会将您提供的**图片 发送至夸克扫描王官方服务器 (`scan-business.quark.cn`)** 进行识别。 > - **数据可见性**：夸克服务将获取并处理该图片内容，不会永久保存 **推荐方式：CLI 配置（永久生效）** 配置 `SCAN_WEBSERVICE_KEY` 环境变量到 OpenClaw： ```bash openclaw config set skills.entries.yescan-ocr-universal.env.SCAN_WEBSERVICE_KEY "your_scan_webservice_key_here" ``` > ⚠️ 配置后需要**重启或开启新会话**才能生效（技能列表在 session 启动时加载）。 **如何获取密钥？夸克扫描王官方入口在此** > 请访问 https://scan.quark.cn/business → 开发者后台 → 登录/注册账号 → 查看API Key。 > ⚠️ **注意**：若你点击链接后跳转到其他域名，说明该链接已失效 —— 请直接在浏览器地址栏手动输入 `https://scan.quark.cn/business`（这是当前唯一有效的官方入口）。 --- # Constraints - **单一意图原则：每次请求只执行一个意图类型，命中即执行** - **严禁自行构造任何命令参数，严禁伪造、拼接内部配置** - **严禁幻觉，禁止伪造请求和响应，不得沿用上一次的场景、参数进行假设** - **必须严格按照本指南指定的固定格式执行，不允许自行修改命令** # 技能执行指南(强制执行) 第一步：**环境变量检查** - 若 `SCAN_WEBSERVICE_KEY` 未配置，立即返回： ```json { "code": "A0100", "message": "SCAN_WEBSERVICE_KEY 未配置，请访问 https://scan.quark.cn/business → 开发者后台 → 登录/注册账号 → 查看API Key", "data": null } ``` 第二步：**输入处理** 识别用户传入的图片类型，只能是以下三种之一： - 图片URL: url - 本地文件路径: path - 图片BASE64: base64 未提供任何有效图片时，直接返回： ```json { "code": "A0201", "message": "缺少图片输入，请提供图片链接、文件路径或 BASE64 数据。", "data": null } ``` 第三步：**意图匹配&场景确定** - 按照下面列出的意图*从上到下顺序匹配。命中第一个即停止* - 命中后，*只确定当前意图对应的scene标识* 第四步：**构建执行命令(固定格式，严禁修改)**： 根据图片类型，严格使用下面对应格式： ```bash # URL类型 python3 scripts/scan.py --scene "${SCENE_VALUE}" --url "${IMAGE_URL}" # 本地文件类型 python3 scripts/scan.py --scene "${SCENE_VALUE}" --path "${IMAGE_FILE_PATH}" # BASE64类型 python3 scripts/scan.py --scene "${SCENE_VALUE}" --base64 "${IMAGE_BASE64}" ``` - 把`${IMAGE_URL}`/`${IMAGE_FILE_PATH}`/`${IMAGE_BASE64}`替换为真实值 - 把`${SCENE_VALUE}`替换为当前意图对应的scene值 - 直接执行命令，不增删任何参数，不修改JSON，不加引号，不换行 第五步：**结果透出**： - 执行完成后，*原样返回执行结果*，不修改，不翻译，不美化，不总结 - 成功 失败均直接透出，不重试 ## 场景与意图列表(按匹配优先级排序) 1. 手写文档识别 - 触发意图：当用户存在识别各类中英文手写内容（如学生作答、作文、会议记录、手写账单等）、将潦草或非标准手写图片转化为高精度可编辑文本，或突破传统 OCR 限制处理复杂手写场景的意图。 - 场景scene标识：handwritten-ocr - 参考示例指令： - "帮我把这张手写笔记转成文字" - "识别这张作文图片里的内容" 2. 表格识别 - 触发意图：当用户存在识别图片中的各类表格（如 Excel/Word 表格、票据单据、手写表格、检查报告单等）、高精度提取文字内容并精准还原原始表格格式与结构的意图。 - 场景scene标识：table-ocr - 参考示例指令： - "提取这张表格里的数据" - "把这张报销单的表格转成 Excel 格式" 3. 身份证识别 - 触发意图：当用户存在识别身份证图片、提取证件关键信息（包括但不限于姓名、身份证号、地址等字段）、将证件影像转化为结构化数据，或应用于身份核验、实名认证及信息准确性校验等场景的意图。 - 场景scene标识：idcard-ocr - 参考示例指令： - "帮我读一下这张身份证" - "提取身份证上的姓名和号码" 4. 社保卡识别 - 触发意图：当用户存在识别社保卡图片、提取证件关键信息（包括但不限于姓名、社会保障号码、卡号、银联号码、性别、民族、发卡日期及有效期限等字段）、将证件影像转化为结构化数据，或应用于社保业务办理、身份核验及政务服务自动化等场景的意图。 - 场景scene标识：social-security-card-ocr - 参考示例指令： - "识别这张社保卡的信息" - "提取社保卡上的姓名和社保号" 5. 港澳通行证识别 - 触发意图：当用户存在识别港澳通行证（或港澳台通行证）图片、提取证件关键信息（包括但不限于姓名、证件号码、签发机关、有效期限等 11 个字段）、将证件影像转化为结构化数据，或应用于身份核验、出入境管理及政务服务自动化等场景的意图。 - 场景scene标识：travel-permit-ocr - 参考示例指令： - "读一下这张港澳通行证" - "提取通行证上的姓名和证件号" 6. 学位证识别 - 触发意图：当用户存在识别学位证书图片、提取证书关键信息（包括但不限于证书名称、学校、姓名、性别、出生日期、学习日期、学制、学历、学位、专业、证书编号及发证日期等 12 个字段）、将证书影像转化为结构化数据，或应用于企业人才信息录入和学历核验等场景的意图。 - 场景scene标识：degree-certificate-ocr - 参考示例指令： - "识别这张学位证的内容" - "提取学位证上的学校和专业" 7. 增值税发票识别 - 触发意图：当用户存在识别增值税发票图片、提取单据关键信息（包括但不限于销售方、购买方、货物详情、金额等 30 多个字段）、将发票影像转化为结构化数据，或应用于财务报销自动化、税务管理及企业风控等场景的意图。 - 场景scene标识：vat-invoice-ocr - 参考示例指令： - "提取这张增值税发票的信息" - "识别发票上的金额和开票日期" 8. 火车票识别 - 触发意图：当用户存在识别火车票图片、提取票号/出发站/到达站/车次/开车时间/票价/座位号/座位类型/旅客身份号码/旅客姓名等 10 个关键字段信息、将车票照片转化为结构化文本数据，或应用于企业出行报销场景的意图。 - 场景scene标识：train-ticket-ocr - 参考示例指令： - "读一下这张火车票" - "提取火车票的出发站和车次" 9. 公式识别 - 触发意图：当用户存在识别数学/化学公式图片、高精度解析分数、矩阵、分段函数及化学方程式等复杂结构、将图像公式转化为可编辑的 LaTeX 代码或结构化数据，或应用于智能试卷自动批改、学术论文数字化归档、在线教育题目解析及科研文献深度分析等场景的意图。 - 场景scene标识：formula-ocr - 参考示例指令： - "把这张公式图片转成 LaTeX" - "识别这个数学公式的结构" 10. 题目识别 - 触发意图：当用户上传包含习题/考题的图片，需**仅提取题目文本**（不含解答、批注或无关内容），并保留题号、题干结构，用于教育题库构建或题目检索。 - 场景scene标识：question-ocr - 参考示例指令： - "提取这道题的关键信息" - "识别题目中的问题部分" 11. 驾驶证识别 - 触发意图：当用户存在识别驾驶证图片、提取证件关键信息（如证号、姓名、住址、有效期等）、将非结构化图像转化为结构化数据，或应用于身份核验、交通管理等场景的意图。 - 场景scene标识：driver-license-ocr - 参考示例指令： - "读一下这张驾驶证" - "提取驾驶证上的姓名和有效期" 12. 行驶证识别 - 触发意图：当用户存在识别行驶证图片、提取证件关键信息（包括但不限于证号、姓名、住址、有效期、准驾车型等）、将行驶证影像转化为结构化数据，或应用于身份核验、交通管理及汽车租赁等场景的意图。 - 场景scene标识：vehicle-license-ocr - 参考示例指令： - "识别这张行驶证" - "提取行驶证上的车牌号和所有人" 13. 英文发票识别 - 触发意图：当用户存在识别英文商业发票图片、提取单据关键信息（包括但不限于发票号、日期、买卖双方信息、商品明细、金额及税额等）、将非结构化英文单据转化为结构化数据，或应用于跨境贸易单证处理、海外费用报销及国际化财务自动化审核等场景的意图。 - 场景scene标识：commercial-invoice-ocr - 参考示例指令： - "提取这张英文发票的信息" - "识别发票上的总金额和日期" 14. 医疗报告单识别 - 触发意图：当用户存在识别医疗报告单图片、提取报告关键信息（包括但不限于检验项目、结果、参考值等）、将医疗报告影像转化为结构化数据，或应用于电子病历归档、健康数据分析及远程医疗辅助诊断等场景的意图。 - 场景scene标识：medical-report-ocr - 参考示例指令： - "读一下这张化验单" - "提取报告中的异常指标" 15. 营业执照识别 - 触发意图：当用户存在识别营业执照图片、提取证件关键信息（包括但不限于统一社会信用代码、名称、类型、法定代表人、经营范围等）、将执照影像转化为结构化数据，或应用于企业身份核验、工商注册自动化、供应链准入审核及金融风控等场景的意图。 - 场景scene标识：business-license-ocr - 参考示例指令： - "识别这张营业执照" - "提取公司名称和法人代表" 16. 商品图片识别 - 触发意图：当用户需要识别图片中的具体商品对象，包括商品名称、品牌、品类等信息，用于商品检索或分类。 - 场景scene标识：product-image-ocr - 参考示例指令： - "这是什么商品？" - "识别这张图片里的产品品牌" 17. 通用文字提取（兜底意图） - 触发意图：当用户指令中不包含上述任何具体场景，仅表达提取纯文字意图时。 - 场景scene标识：general-ocr - 参考示例指令： - "把这张图转成文字" - "提取图中所有文字" ## ⛔ 不适用场景（When Not to Use） > 本技能**不支持**以下场景，请勿尝试： | 不支持的场景 | 原因 | 建议替代方案 | |------------|------|------------| | **视频处理** | 仅支持单张静态图片 | 先提取视频帧，再逐帧处理 | | **批量处理** | 每次调用仅限单张图片 | 如需批量，请循环调用或联系管理员 | | **实时摄像头流** | 非实时流处理架构 | 使用专用视频处理服务 | | **超大图片（>5MB）** | API 限制 | 先压缩或裁剪后再处理 | | **非图片格式** | 仅支持 jpg/jpeg/png/gif/bmp/webp/tiff/wbmp | 先转换为支持的图片格式 | --- ## ⚠️ 重要注意事项 1. **禁止修改固定格式**,只能替换场景标识和图片占位符 2. **严禁自行构造 --scene 参数值，必须使用本文档指定的场景名** 3. **图片大小限制：本地文件不超过5MB，支持 jpg/jpeg/png/gif/bmp/webp/tiff/wbmp/webp 格式** --- ## 🔗 相关资源 - [夸克扫描王开放平台](https://scan.quark.cn/business) ## 📁 文件结构 - `SKILL.md` — 本文档（意图分析 + 通用规范） - `scripts/scan.py` — 主执行脚本 (Python 3.9+) - `scripts/common/*.py` — 基础类库

path: scripts/scan.py
content: #!/usr/bin/env python3
"""Quark OCR - 夸克扫描王 OCR 识别服务"""
import sys
from pathlib import Path

SCRIPTS_DIR = Path(__file__).parent
sys.path.insert(0, str(SCRIPTS_DIR))

from common import run_ocr

if __name__ == "__main__":
    run_ocr()


path: scripts/common/result_handlers.py
content: #!/usr/bin/env python3
"""
结果处理器模块 - 处理 OCR 结果并保存文件
"""
from .ocr_client import OCRResult
from .file_saver import FileSaver


def save_image_from_result(result: OCRResult) -> OCRResult:
    """从 OCR 结果中提取并保存图片"""
    if result.code != "00000":
        return result

    image_base64 = None
    if isinstance(result.data, dict) and "ImageInfo" in result.data:
        image_info_list = result.data["ImageInfo"]
        if isinstance(image_info_list, list) and len(image_info_list) > 0:
            image_info = image_info_list[0]
            if isinstance(image_info, dict) and "ImageBase64" in image_info:
                image_base64 = image_info["ImageBase64"]

    if image_base64:
        try:
            saver = FileSaver()
            save_res = saver.save_image_from_base64(image_base64)
            if save_res["code"] == 0:
                result.data = {"path": save_res["data"]["path"]}
            else:
                result = OCRResult(code=save_res["code"], message=save_res["msg"], data=save_res["data"])
        except (IOError, OSError) as e:
            result = OCRResult(code="FILE_SAVE_ERROR", message=f"File save failed: {e}", data={})

    return result


def save_document_from_result(result: OCRResult, config: dict) -> OCRResult:
    """从 OCR 结果中提取并保存文档（Word/Excel/PDF）"""
    if result.code != "00000":
        return result

    # 根据 scene 名称判断保存类型
    scene = config.get("scene", "")
    if scene == "image-to-word":
        doc_type = "word"
    elif scene == "image-to-excel":
        doc_type = "excel"
    elif scene == "image-to-pdf":
        doc_type = "pdf"
    else:
        doc_type = None

    if not doc_type:
        return result

    file_base64 = None
    if isinstance(result.data, dict) and "TypesetInfo" in result.data:
        typeset_info_list = result.data["TypesetInfo"]
        if isinstance(typeset_info_list, list) and len(typeset_info_list) > 0:
            typeset_info = typeset_info_list[0]
            if isinstance(typeset_info, dict) and "FileBase64" in typeset_info:
                file_base64 = typeset_info["FileBase64"]

    if file_base64:
        try:
            saver = FileSaver()
            if doc_type == "word":
                save_res = saver.save_word_from_base64(file_base64)
            elif doc_type == "pdf":
                save_res = saver.save_pdf_from_base64(file_base64)
            else:  # excel
                save_res = saver.save_excel_from_base64(file_base64)

            if save_res["code"] == 0:
                result.data = {"path": save_res["data"]["path"]}
            else:
                result = OCRResult(code=save_res["code"], message=save_res["msg"], data=save_res["data"])
        except (IOError, OSError) as e:
            result = OCRResult(code="FILE_SAVE_ERROR", message=f"File save failed: {e}", data={})

    return result


path: scripts/common/runner.py
content: #!/usr/bin/env python3
"""
公共执行器模块 - 封装 OCR 脚本的通用执行逻辑
"""
import sys
import argparse
from typing import Callable, Optional

from .ocr_client import OCRResult, CredentialManager, QuarkOCRClient
from .scene_configs import get_scene_config, list_scenes


# 结果处理器类型
ResultHandler = Callable[[OCRResult], OCRResult]
ResultHandlerWithConfig = Callable[[OCRResult, dict], OCRResult]


def run_ocr(result_handler: Optional[ResultHandler] = None,
            result_handler_with_config: Optional[ResultHandlerWithConfig] = None) -> None:
    """
    通用 OCR 执行器
    
    Args:
        result_handler: 结果处理函数，签名为 (OCRResult) -> OCRResult
        result_handler_with_config: 需要配置的结果处理函数，签名为 (OCRResult, config) -> OCRResult
    
    使用示例:
        # OCR类（不保存文件）
        run_ocr()
        
        # 图片增强类（保存图片）
        run_ocr(result_handler=save_image_from_result)
        
        # 文档转换类（保存文档，需要config判断word/excel）
        run_ocr(result_handler_with_config=save_document_from_result)
    """
    parser = argparse.ArgumentParser(
        description="Quark OCR - 支持图片 URL、本地路径、BASE64 字符串",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=f"""
可用场景（--scene 参数值）:
  {', '.join(list_scenes())}

示例:
  python3 scripts/scan.py --scene general-ocr --url "https://example.com/image.jpg"
  python3 scripts/scan.py --scene idcard-ocr --path "/path/to/idcard.jpg"
        """
    )
    
    # 场景参数（必填）
    parser.add_argument("--scene", "-s", required=True, help="场景名称（如 general-ocr, idcard-ocr 等）")
    
    # 图片输入参数（三选一）
    group = parser.add_mutually_exclusive_group(required=True)
    group.add_argument("--url", "-u", help="图片 URL")
    group.add_argument("--path", "-p", help="本地图片文件路径")
    group.add_argument("--base64", "-b", help="BASE64 字符串")
    
    args = parser.parse_args()
    
    # 获取场景配置
    try:
        config = get_scene_config(args.scene)
        config["scene"] = args.scene  # 添加 scene 名称供 result_handler 使用
    except ValueError as e:
        print(OCRResult(code="INVALID_SCENE", message=str(e), data=None).to_json())
        sys.exit(1)
    
    try:
        [REDACTED_SECRET])
        with QuarkOCRClient(
            [REDACTED_SECRET],
            scene=args.scene,
            data_type=config["data_type"]
        ) as client:
            if args.base64:
                result = client.recognize(base64_data=args.base64)
            elif args.url:
                result = client.recognize(image_url=args.url)
            else:
                result = client.recognize(image_path=args.path)
        
        # 应用结果处理器
        if result_handler_with_config:
            result = result_handler_with_config(result, config)
        elif result_handler:
            result = result_handler(result)
        
        print(result.to_json())
        
    except ValueError as e:
        print(OCRResult(code="A0100", message=str(e), data=None).to_json())
        sys.exit(1)
    except Exception as e:
        print(OCRResult(code="UNKNOWN_ERROR", message=f"Unexpected error: {str(e)}", data=None).to_json())
        sys.exit(1)


path: scripts/common/validators.py
content: #!/usr/bin/env python3
"""
验证器模块 - URL 和文件验证
"""
import os
from typing import Tuple, Optional
from urllib.parse import urlparse

from .constants import ALLOWED_IMAGE_EXTENSIONS, MAX_FILE_SIZE


class URLValidator:
    """URL 基础验证器"""

    @staticmethod
    def validate(url: str) -> Tuple[bool, Optional[str]]:
        """
        验证 URL 格式

        Args:
            url: 待验证的 URL

        Returns:
            (是否有效, 错误信息)
        """
        if not url or not isinstance(url, str):
            return False, "URL cannot be empty"

        url = url.strip()

        try:
            parsed = urlparse(url)
        except ValueError as e:
            return False, f"Invalid URL format: {str(e)}"

        if parsed.scheme.lower() not in {"http", "https"}:
            return False, f"Protocol '{parsed.scheme}' not allowed."

        return True, None


class FileValidator:
    """文件验证器"""

    @staticmethod
    def validate(file_path: str) -> Tuple[bool, Optional[str]]:
        """
        验证本地文件

        Args:
            file_path: 文件路径

        Returns:
            (是否有效, 错误信息)
        """
        if not file_path or not isinstance(file_path, str):
            return False, "File path cannot be empty"

        file_path = os.path.expanduser(file_path.strip())

        if not os.path.exists(file_path):
            return False, f"File not found: {file_path}"

        if not os.path.isfile(file_path):
            return False, f"Not a file: {file_path}"

        file_size = os.path.getsize(file_path)
        if file_size > MAX_FILE_SIZE:
            return False, f"File size exceeds {MAX_FILE_SIZE / 1024 / 1024}MB limit"

        ext = os.path.splitext(file_path)[1].lower()
        if ext not in ALLOWED_IMAGE_EXTENSIONS:
            return False, f"File extension '{ext}' not allowed"

        return True, None


path: scripts/common/constants.py
content: #!/usr/bin/env python3
"""
公共常量定义
"""

# API 配置
API_URL = "https://scan-business.quark.cn/vision"

# 文件配置
ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff", ".tif"}
MAX_FILE_SIZE = 5 * 1024 * 1024  # 5MB

# 请求配置
REQUEST_TIMEOUT = 120  # 秒

# HTTP 状态码
HTTP_OK = 200

# 错误消息最大截取长度
ERROR_MSG_MAX_LENGTH = 200

# API 成功响应码
SUCCESS_CODE = "00000"

# 错误码消息常量
ERR_MSG_A0211_QUOTA_INSUFFICIENT = (
    "请前往https://scan.quark.cn/business，登录开发者后台，"
    "选择需要的套餐进行充值（请注意购买Skill专用套餐）"
)


path: scripts/common/__init__.py
content: #!/usr/bin/env python3
"""
Common - 夸克扫描王公共模块
提供 OCR 客户端、文件保存、验证器等公共功能
"""
from .constants import (
    API_URL,
    ALLOWED_IMAGE_EXTENSIONS,
    MAX_FILE_SIZE,
    REQUEST_TIMEOUT,
    HTTP_OK,
    ERROR_MSG_MAX_LENGTH,
    SUCCESS_CODE,
    ERR_MSG_A0211_QUOTA_INSUFFICIENT,
)
from .validators import URLValidator, FileValidator
from .ocr_client import OCRResult, CredentialManager, QuarkOCRClient
from .file_saver import FileSaver, SaveResult, ResponseCode, FileExtension, SubDirectory
from .scene_configs import SCENE_CONFIGS, get_scene_config, list_scenes
from .runner import run_ocr
from .result_handlers import save_image_from_result, save_document_from_result

__all__ = [
    # 常量
    "API_URL",
    "ALLOWED_IMAGE_EXTENSIONS",
    "MAX_FILE_SIZE",
    "REQUEST_TIMEOUT",
    "HTTP_OK",
    "ERROR_MSG_MAX_LENGTH",
    "SUCCESS_CODE",
    "ERR_MSG_A0211_QUOTA_INSUFFICIENT",
    # 验证器
    "URLValidator",
    "FileValidator",
    # OCR 客户端
    "OCRResult",
    "CredentialManager",
    "QuarkOCRClient",
    # 文件保存
    "FileSaver",
    "SaveResult",
    "ResponseCode",
    "FileExtension",
    "SubDirectory",
    # 场景配置
    "SCENE_CONFIGS",
    "get_scene_config",
    "list_scenes",
    # 执行器
    "run_ocr",
    # 结果处理器
    "save_image_from_result",
    "save_document_from_result",
]


path: scripts/common/file_saver.py
content: #!/usr/bin/env python3
"""
FileSaver - 通用文件保存器
用于保存 OCR 结果到文件（图片、Word、Excel 等）
"""
import os
import base64
import time
import tempfile
import logging
from dataclasses import dataclass, field
from typing import Optional, Dict, Any, Set
from pathlib import Path

logger = logging.getLogger(__name__)


# ============== 常量定义 ==============
class ResponseCode:
    """响应状态码"""
    SUCCESS = 0
    ERROR = -1


class FileExtension:
    """支持的文件扩展名"""
    DOCX = ".docx"
    XLSX = ".xlsx"
    PDF = ".pdf"
    PNG = ".png"
    JPG = ".jpg"
    WEBP = ".webp"
    GIF = ".gif"
    BMP = ".bmp"
    TIFF = ".tiff"
    WBMP = ".wbmp"


class SubDirectory:
    """子目录常量"""
    IMAGES = "imgs"
    DOCUMENTS = "documents"


# 支持的图片格式集合
SUPPORTED_IMAGE_FORMATS: Set[str] = {"png", "jpg", "jpeg", "gif", "bmp", "webp", "tiff", "tif", "wbmp"}

# 默认保存目录（使用系统临时目录）
DEFAULT_SAVE_DIR = tempfile.gettempdir()


@dataclass
class SaveResult:
    """文件保存结果（类型安全的返回值）"""
    code: int
    msg: str
    data: Dict[str, Any] = field(default_factory=dict)

    def to_dict(self) -> Dict[str, Any]:
        """转换为字典格式"""
        return {"code": self.code, "msg": self.msg, "data": self.data}

    @classmethod
    def success(cls, path: str) -> "SaveResult":
        """创建成功结果"""
        return cls(code=ResponseCode.SUCCESS, msg="success", data={"path": path})

    @classmethod
    def error(cls, msg: str) -> "SaveResult":
        """创建错误结果"""
        return cls(code=ResponseCode.ERROR, msg=msg, data={})


class FileSaver:
    """文件保存器，支持保存 Base64 编码的二进制文件到指定目录"""

    def __init__(self, default_dir: Optional[str] = None):
        """
        初始化文件保存器

        Args:
            default_dir: 默认保存目录，如果为 None 则使用 /tmp
        """
        self.default_dir = default_dir or DEFAULT_SAVE_DIR
        Path(self.default_dir).mkdir(parents=True, exist_ok=True)
        logger.debug(f"FileSaver initialized with default directory: {self.default_dir}")

    def _generate_filepath(self, extension: str, subdir: Optional[str] = None) -> str:
        """
        生成带时间戳的随机文件路径

        Args:
            extension: 文件扩展名（如 .docx, .xlsx, .png）
            subdir: 子目录名（可选）

        Returns:
            完整的文件路径
        """
        save_dir = os.path.join(self.default_dir, subdir) if subdir else self.default_dir
        Path(save_dir).mkdir(parents=True, exist_ok=True)
        filename = f"{int(time.time())}_{os.urandom(8).hex()}{extension}"
        return os.path.join(save_dir, filename)

    @staticmethod
    def _clean_base64(base64_content: str) -> str:
        """清理 Base64 字符串，移除 data URL 前缀"""
        if base64_content and base64_content.strip().startswith("data:"):
            try:
                return base64_content.split(",", 1)[1].strip()
            except (IndexError, ValueError):
                pass
        return base64_content

    @staticmethod
    def _detect_format_from_data_url(base64_content: str) -> Optional[str]:
        """从 data URL 前缀提取图片格式，如 data:image/png;base64,..."""
        if not base64_content or not base64_content.strip().startswith("data:image/"):
            return None
        try:
            # 提取 data:image/png;base64 中的 png
            mime_part = base64_content.split(",", 1)[0]  # data:image/png;base64
            mime_type = mime_part.split(";")[0]  # data:image/png
            fmt = mime_type.split("/")[1]  # png
            return fmt.lower() if fmt else None
        except (IndexError, ValueError):
            return None

    @staticmethod
    def _detect_format_from_magic_bytes(base64_content: str) -> Optional[str]:
        """通过魔术字节检测图片格式"""
        try:
            # 解码前 24 字符（约18字节）足够判断各种格式
            header = base64.b64decode(base64_content[:24])
            # PNG: 89 50 4E 47 0D 0A 1A 0A
            if header[:8] == b'\x89PNG\r\n\x1a\n':
                return "png"
            # JPEG: FF D8 FF
            if header[:3] == b'\xff\xd8\xff':
                return "jpg"
            # GIF: GIF87a 或 GIF89a
            if header[:6] in (b'GIF87a', b'GIF89a'):
                return "gif"
            # BMP: 42 4D ("BM")
            if header[:2] == b'BM':
                return "bmp"
            # WebP: RIFF....WEBP
            if header[:4] == b'RIFF' and header[8:12] == b'WEBP':
                return "webp"
            # TIFF: II 2A 00 (little-endian) 或 MM 00 2A (big-endian)
            if header[:4] in (b'II\x2a\x00', b'MM\x00\x2a'):
                return "tiff"
            # WBMP: 00 00 开头（简单检测，可能不够准确）
            if header[:2] == b'\x00\x00' and len(header) >= 4:
                return "wbmp"
        except Exception:
            pass
        return None

    @staticmethod
    def _validate_base64(base64_content: str) -> Optional[str]:
        """验证 Base64 字符串合法性，返回 None 表示通过"""
        if not base64_content:
            return "BASE64 内容不能为空"
        try:
            if len(base64_content) % 4 != 0:
                raise ValueError("Base64 string length not multiple of 4")
            base64.b64decode(base64_content, validate=True)
        except (ValueError, base64.binascii.Error) as e:
            return f"Invalid Base64: {e}"
        return None

    def save_file_from_base64(
        self,
        base64_content: str,
        extension: str,
        subdir: Optional[str] = None,
        filepath: Optional[str] = None,
        _skip_clean: bool = False
    ) -> Dict[str, Any]:
        """
        通用的 Base64 文件保存方法
    
        Args:
            base64_content: BASE64 编码的内容
            extension: 文件扩展名（如 .docx, .xlsx, .png）
            subdir: 子目录名（可选，如 "imgs"）
            filepath: 指定文件路径（可选，优先级最高）
            _skip_clean: 内部参数，跳过 base64 清理（已清理过时使用）
    
        Returns:
            {"code": 0, "msg": "success", "data": {"path": "/path/to/file"}}
        """
        if not _skip_clean:
            base64_content = self._clean_base64(base64_content)
        error = self._validate_base64(base64_content)
        if error:
            logger.error(error)
            return SaveResult.error(error).to_dict()
    
        if filepath is None:
            filepath = self._generate_filepath(extension, subdir)
    
        try:
            Path(filepath).parent.mkdir(parents=True, exist_ok=True)
            with open(filepath, 'wb') as f:
                f.write(base64.b64decode(base64_content))
            logger.info(f"文件保存成功：{filepath}")
            return SaveResult.success(filepath).to_dict()
        except (IOError, OSError) as e:
            error_msg = f"保存文件失败：{e}"
            logger.error(error_msg)
            return SaveResult.error(error_msg).to_dict()

    def save_word_from_base64(self, base64_content: str, filepath: Optional[str] = None) -> Dict[str, Any]:
        """从 BASE64 保存 Word 文档"""
        return self.save_file_from_base64(base64_content, FileExtension.DOCX, subdir=SubDirectory.DOCUMENTS, filepath=filepath)

    def save_excel_from_base64(self, base64_content: str, filepath: Optional[str] = None) -> Dict[str, Any]:
        """从 BASE64 保存 Excel 文档"""
        return self.save_file_from_base64(base64_content, FileExtension.XLSX, subdir=SubDirectory.DOCUMENTS, filepath=filepath)

    def save_pdf_from_base64(self, base64_content: str, filepath: Optional[str] = None) -> Dict[str, Any]:
        """从 BASE64 保存 PDF 文档"""
        return self.save_file_from_base64(base64_content, FileExtension.PDF, subdir=SubDirectory.DOCUMENTS, filepath=filepath)

    def save_image_from_base64(self, base64_content: str, image_format: Optional[str] = None) -> Dict[str, Any]:
        """
        从 BASE64 保存图片（支持自动检测格式）

        Args:
            base64_content: BASE64 编码的图片内容
            image_format: 图片格式（可选），支持 png/jpg/jpeg/webp，不指定则自动检测
        """
        # 1. 优先从 data URL 前缀提取格式
        if image_format is None:
            image_format = self._detect_format_from_data_url(base64_content)

        # 2. 清理 base64 内容
        clean_content = self._clean_base64(base64_content)

        # 3. 仍未知则通过魔术字节检测
        if image_format is None:
            image_format = self._detect_format_from_magic_bytes(clean_content)

        # 4. 最终兜底默认 png
        fmt = (image_format or "png").lower()
        if fmt not in SUPPORTED_IMAGE_FORMATS:
            error_msg = f"不支持的图片格式: {image_format}，支持格式: {SUPPORTED_IMAGE_FORMATS}"
            logger.error(error_msg)
            return SaveResult.error(error_msg).to_dict()

        # jpeg -> jpg, tif -> tiff 统一扩展名
        ext = "." + fmt.replace("jpeg", "jpg").replace("tif", "tiff")
        return self.save_file_from_base64(clean_content, ext, subdir=SubDirectory.IMAGES, _skip_clean=True)


path: scripts/common/scene_configs.py
content: #!/usr/bin/env python3
"""
场景配置模块 - 统一管理所有 OCR/扫描场景的配置
"""
from typing import Dict, List


# 场景配置映射表
# key: 场景名
# value: 包含 data_type 的配置字典
SCENE_CONFIGS: Dict[str, Dict[str, str]] = {
    # ==================== OCR 识别类 ====================
    "general-ocr": {
        "data_type": "image",
    },
    "handwritten-ocr": {
        "data_type": "image",
    },
    "table-ocr": {
        "data_type": "image",
    },
    "idcard-ocr": {
        "data_type": "image",
    },
    "social-security-card-ocr": {
        "data_type": "image",
    },
    "travel-permit-ocr": {
        "data_type": "image",
    },
    "degree-certificate-ocr": {
        "data_type": "image",
    },
    "vat-invoice-ocr": {
        "data_type": "image",
    },
    "train-ticket-ocr": {
        "data_type": "image",
    },
    "formula-ocr": {
        "data_type": "image",
    },
    "question-ocr": {
        "data_type": "image",
    },
    "driver-license-ocr": {
        "data_type": "image",
    },
    "vehicle-license-ocr": {
        "data_type": "image",
    },
    "commercial-invoice-ocr": {
        "data_type": "image",
    },
    "medical-report-ocr": {
        "data_type": "image",
    },
    "business-license-ocr": {
        "data_type": "image",
    },
    "product-image-ocr": {
        "data_type": "image",
    },

    # ==================== 图像增强类 ====================
    "exam-enhance": {
        "data_type": "image",
    },
    "image-hd-enhance": {
        "data_type": "image",
    },
    "certificate-enhance": {
        "data_type": "image",
    },
    "remove-handwriting": {
        "data_type": "image",
    },
    "remove-watermark": {
        "data_type": "image",
    },
    "remove-shadow": {
        "data_type": "image",
    },
    "remove-screen-pattern": {
        "data_type": "image",
    },
    "remove-background-color": {
        "data_type": "image",
    },
    "image-crop-rectify": {
        "data_type": "image",
    },
    "sketch-drawing": {
        "data_type": "image",
    },
    "extract-lineart": {
        "data_type": "image",
    },
    "scan-document": {
        "data_type": "image",
    },

    # ==================== 文档转换类 ====================
    "image-to-excel": {
        "data_type": "image",
    },
    "image-to-word": {
        "data_type": "image",
    },
    "image-to-pdf": {
        "data_type": "image",
    },
}


def get_scene_config(scene_name: str) -> Dict[str, str]:
    """
    根据场景名获取配置
    
    Args:
        scene_name: 场景名称（如 'general-ocr', 'handwritten-ocr' 等）
    
    Returns:
        包含 data_type 的字典
    
    Raises:
        ValueError: 场景名不存在时抛出
    """
    if scene_name not in SCENE_CONFIGS:
        available = ", ".join(sorted(SCENE_CONFIGS.keys()))
        raise ValueError(
            f"Unknown scene: '{scene_name}'. "
            f"Available scenes: {available}"
        )
    return SCENE_CONFIGS[scene_name]


def list_scenes() -> List[str]:
    """
    获取所有可用场景名列表
    
    Returns:
        场景名列表（已排序）
    """
    return sorted(SCENE_CONFIGS.keys())


path: scripts/common/ocr_client.py
content: #!/usr/bin/env python3
"""
OCR 客户端核心模块 - 处理 API 请求和响应
"""
import os
import json
import base64
import binascii
from typing import Dict, Any, Optional
from dataclasses import dataclass

import requests

from .constants import (
    API_URL,
    REQUEST_TIMEOUT,
    HTTP_OK,
    ERROR_MSG_MAX_LENGTH,
    ERR_MSG_A0211_QUOTA_INSUFFICIENT,
)
from .validators import URLValidator, FileValidator


@dataclass
class OCRResult:
    """OCR 识别结果 - 直接返回 API 原始响应"""
    code: str
    message: Optional[str]
    data: Optional[Dict[str, Any]]

    def to_json(self) -> str:
        """返回完整的 API 响应结构"""
        return json.dumps({
            "code": self.code,
            "message": self.message,
            "data": self.data
        }, ensure_ascii=False, indent=2)


class CredentialManager:
    """凭证管理器，负责加载和验证 API 密钥"""

    @staticmethod
    def load() -> str:
        """从环境变量加载 API 密钥"""
        [REDACTED_SECRET]"SCAN_WEBSERVICE_KEY", "").strip()
        if [REDACTED_SECRET] api_key
        raise ValueError(
            "SCAN_WEBSERVICE_KEY 未配置，请访问 https://scan.quark.cn/business → 开发者后台 → 登录/注册账号 → 查看API Key"
        )


class QuarkOCRClient:
    """夸克 OCR 客户端，提供图片识别功能"""

    def __init__(self, api_key: str, scene: str, data_type: str):
        """
        初始化 OCR 客户端

        Args:
            api_key: API 密钥
            scene: 场景名称（如 general-ocr, idcard-ocr 等）
            data_type: 数据类型（image 或 pdf）
        """
        self.[REDACTED_SECRET]
        self.scene = scene
        self.data_type = data_type
        self.session = requests.Session()

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.session.close()

    def recognize(self, image_url: str = None, image_path: str = None, base64_data: str = None) -> OCRResult:
        """
        识别图片内容

        Args:
            image_url: 公网图片 URL
            image_path: 本地文件路径（自动转 BASE64）
            base64_data: base64 字符串

        Returns:
            OCRResult: 识别结果
        """
        provided_params = sum(param is not None for param in [image_url, image_path, base64_data])
        if provided_params != 1:
            return OCRResult(
                code="INVALID_INPUT",
                message="Exactly one of image_url, image_path, or base64_data must be provided",
                data=None
            )

        if base64_data:
            return self._recognize_base64(base64_data)
        elif image_path:
            return self._recognize_local_file(image_path)
        else:
            is_valid, error_msg = URLValidator.validate(image_url)
            if not is_valid:
                return OCRResult(code="URL_VALIDATION_ERROR", message=f"URL validation failed: {error_msg}", data=None)
            param = self._build_request_param(image_url=image_url)
            response = self._send_request(param)
            return self._parse_response(response)

    def _recognize_base64(self, base64_data: str) -> OCRResult:
        """处理 base64 字符串，支持两种格式"""
        base64_content = base64_data.strip()

        if base64_content.startswith('data:'):
            try:
                if ';base64,' in base64_content:
                    base64_content = base64_content.split(';base64,', 1)[1]
                else:
                    return OCRResult(
                        code="BASE64_FORMAT_ERROR",
                        message="Invalid Data URL format, expected format: data:image/jpeg;base64,<base64_string>",
                        data=None
                    )
            except (ValueError, IndexError) as e:
                return OCRResult(
                    code="BASE64_PARSE_ERROR",
                    message=f"Failed to parse Data URL: {str(e)}",
                    data=None
                )

        try:
            base64.b64decode(base64_content)
        except (ValueError, binascii.Error) as e:
            return OCRResult(code="BASE64_DECODE_ERROR", message=f"Invalid base64 string: {str(e)}", data=None)

        param = self._build_request_param(base64_data=base64_content)
        response = self._send_request(param)
        return self._parse_response(response)

    def _recognize_local_file(self, file_path: str) -> OCRResult:
        """处理本地文件：读取文件并转为 BASE64 后调用 OCR"""
        file_path = os.path.expanduser(file_path.strip())

        is_valid, error_msg = FileValidator.validate(file_path)
        if not is_valid:
            return OCRResult(code="FILE_ERROR", message=f"File validation failed: {error_msg}", data=None)

        try:
            with open(file_path, 'rb') as f:
                file_content = f.read()
            base64_content = base64.b64encode(file_content).decode('utf-8')
        except (IOError, OSError) as e:
            return OCRResult(code="FILE_READ_ERROR", message=f"Failed to read file: {str(e)}", data=None)

        param = self._build_request_param(base64_data=base64_content)
        response = self._send_request(param)
        return self._parse_response(response)

    def _build_request_param(self, image_url: str = None, base64_data: str = None) -> Dict[str, Any]:
        """构建请求参数"""
        param = {
            "aiApiKey": self.api_key,
            "dataType": self.data_type,
            "scene": self.scene
        }

        if base64_data:
            param["dataBase64"] = base64_data
        else:
            param["dataUrl"] = image_url

        return param

    def _send_request(self, param: Dict[str, Any]) -> requests.Response:
        """发送 HTTP 请求到 OCR API"""
        headers = {"Content-Type": "application/json", "X-Appbuilder-From": "openclaw"}
        response = self.session.post(
            API_URL,
            json=param,
            headers=headers,
            timeout=REQUEST_TIMEOUT,
            allow_redirects=True
        )
        return response

    def _parse_response(self, response: requests.Response) -> OCRResult:
        """解析 API 响应，直接返回原始响应"""
        if response.status_code != HTTP_OK:
            error_msg = response.text[:ERROR_MSG_MAX_LENGTH] if response.text else "No error message"
            return OCRResult(
                code="HTTP_ERROR",
                message=f"HTTP {response.status_code}: {error_msg}",
                data=None
            )
        try:
            body = response.json()
        except json.JSONDecodeError as e:
            return OCRResult(
                code="JSON_PARSE_ERROR",
                message=f"Failed to parse JSON: {str(e)}",
                data=None
            )

        code = body.get("code", "unknown")
        message = body.get("message")
        data = body.get("data")

        if code == "A0211":
            message = ERR_MSG_A0211_QUOTA_INSUFFICIENT

        return OCRResult(code=code, message=message, data=data)


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