{
"name": "扫描PDF二维码提取发票号码",
"description": "批量扫描PDF文件中的二维码,提取发票号码并以Excel格式输出。支持在Work Buddy新建任务对话框中点击左下角+号添加多个PDF文件。",
"triggers": [
"扫描PDF二维码提取发票号码",
"扫描PDF二维码",
"提取发票号码",
"批量提取发票号码",
"PDF二维码识别",
"发票号码提取",
"扫码提取发票",
"PDF扫码"
],
"window": {
"title": "扫描PDF二维码提取发票号码",
"width": 700,
"height": 480,
"resizable": true,
"modal": true
},
"ui": {
"type": "custom",
"html": "<div style='display:flex;flex-direction:column;height:100%;padding:20px 24px;font-family:'Segoe UI',system-ui,sans-serif;background:#f7f9fc;box-sizing:border-box;'>\n <div style='margin-bottom:16px;'>\n <h3 style='margin:0 0 6px 0;color:#1a1a2e;font-weight:600;font-size:18px;'>📄 扫描PDF二维码提取发票号码</h3>\n <p style='margin:0;color:#666;font-size:13px;'>点击下方「+ 添加文件」选择PDF文件(支持多选),系统将自动识别二维码中的发票号码</p>\n </div>\n\n <!-- 文件上传区域 -->\n <div style='display:flex;align-items:center;gap:12px;margin-bottom:16px;flex-wrap:wrap;'>\n <button id='addFilesBtn' style='padding:10px 24px;border:2px dashed #0078d4;border-radius:8px;background:#fff;color:#0078d4;cursor:pointer;font-size:14px;font-weight:600;transition:all 0.2s;'>+ 添加文件</button>\n <span style='font-size:13px;color:#888;'>已添加 <span id='fileCount'>0</span> 个PDF文件</span>\n <button id='clearFilesBtn' style='padding:6px 16px;border:1px solid #ddd;border-radius:4px;background:#fff;cursor:pointer;font-size:12px;color:#999;'>清空列表</button>\n </div>\n\n <!-- 文件列表 -->\n <div style='flex:1;border:1px solid #e0e0e0;border-radius:8px;background:#fff;overflow:hidden;display:flex;flex-direction:column;min-height:0;'>\n <div style='display:flex;justify-content:space-between;padding:10px 16px;background:#f0f2f5;border-bottom:1px solid #e0e0e0;font-weight:600;font-size:13px;color:#444;'>\n <span style='flex:1;'>文件名</span>\n <span style='width:120px;text-align:center;'>状态</span>\n <span style='width:100px;text-align:center;'>发票号码</span>\n </div>\n <div id='fileListContainer' style='flex:1;overflow-y:auto;padding:4px 0;'></div>\n </div>\n\n <!-- 底部操作栏 -->\n <div style='display:flex;justify-content:space-between;align-items:center;margin-top:16px;'>\n <div style='display:flex;gap:12px;'>\n <button id='scanBtn' style='padding:10px 36px;border:none;border-radius:6px;background:#0078d4;color:#fff;cursor:pointer;font-size:14px;font-weight:600;'>开始扫描</button>\n <button id='exportBtn' style='padding:10px 28px;border:1px solid #0078d4;border-radius:6px;background:#fff;color:#0078d4;cursor:pointer;font-size:14px;font-weight:600;'>导出Excel</button>\n </div>\n <button id='closeBtn' style='padding:8px 24px;border:1px solid #ddd;border-radius:6px;background:#fff;cursor:pointer;font-size:13px;color:#888;'>关闭</button>\n </div>\n</div>"
},
"actions": {
"addFiles": {
"description": "打开文件选择对话框,添加PDF文件",
"code": "async (context) => {\n const result = await context.showOpenFilePicker({\n multiple: true,\n filters: [{ name: 'PDF文件', extensions: ['pdf'] }]\n });\n if (result && result.length > 0) {\n const currentFiles = context.state.fileList || [];\n const newFiles = result.map(file => ({\n name: file.name,\n path: file.path,\n status: '待扫描',\n invoiceNumber: ''\n }));\n const updated = [...currentFiles, ...newFiles];\n context.setState({ fileList: updated });\n context.renderFileList(updated);\n context.updateUI('fileCount', updated.length);\n }\n}"
},
"clearFiles": {
"description": "清空文件列表",
"code": "async (context) => {\n context.setState({ fileList: [] });\n context.renderFileList([]);\n context.updateUI('fileCount', 0);\n}"
},
"scanFiles": {
"description": "扫描所有PDF文件中的二维码,提取发票号码",
"code": "async (context) => {\n const fileList = context.state.fileList || [];\n if (fileList.length === 0) {\n await context.showMessage('请先添加PDF文件!', 'warning');\n return;\n }\n // 更新所有文件状态为“扫描中”\n const scanningList = fileList.map(f => ({ ...f, status: '扫描中...' }));\n context.setState({ fileList: scanningList });\n context.renderFileList(scanningList);\n let processed = 0;\n for (const item of scanningList) {\n try {\n // 调用PDF二维码识别功能(Work Buddy内置API)\n const result = await context.scanPDFQRCode(item.path);\n // 从二维码内容中提取发票号码(支持多种格式)\n const invoiceNumber = context.extractInvoiceNumber(result);\n item.status = '已完成';\n item.invoiceNumber = invoiceNumber || '未识别';\n } catch (e) {\n item.status = '失败';\n item.invoiceNumber = '识别失败';\n }\n processed++;\n context.renderFileList(scanningList);\n context.updateUI('fileCount', scanningList.length);\n // 更新进度(可选)\n await context.updateProgress(processed, scanningList.length);\n }\n const completed = scanningList.filter(f => f.status === '已完成' && f.invoiceNumber && f.invoiceNumber !== '未识别');\n await context.showMessage(扫描完成!共 ${scanningList.length} 个文件,成功识别 ${completed.length} 个发票号码。, 'info');\n}"
},
"exportExcel": {
"description": "将识别结果导出为Excel文件",
"code": "async (context) => {\n const fileList = context.state.fileList || [];\n const validResults = fileList.filter(f => f.invoiceNumber && f.invoiceNumber !== '未识别' && f.invoiceNumber !== '识别失败');\n if (validResults.length === 0) {\n await context.showMessage('没有可导出的发票号码数据,请先执行扫描!', 'warning');\n return;\n }\n // 构建Excel数据\n const excelData = [\n ['序号', '文件名', '发票号码', '状态']\n ];\n validResults.forEach((item, index) => {\n excelData.push([\n index + 1,\n item.name,\n item.invoiceNumber,\n item.status\n ]);\n });\n try {\n const filePath = await context.exportExcel(excelData, '发票号码提取结果.xlsx');\n await context.showMessage(Excel导出成功!文件保存至:${filePath}, 'info');\n } catch (e) {\n await context.showMessage(导出失败:${e.message}, 'error');\n }\n}"
},
"close": {
"description": "关闭弹窗",
"code": "async (context) => { context.close(); }"
}
},
"state": {
"fileList": []
},
"permissions": [
"readFiles",
"writeFiles",
"showDialog",
"showOpenFilePicker",
"scanQRCode",
"exportExcel"
],
"lifecycle": {
"onOpen": {
"code": "async (context) => {\n // 初始化状态\n context.setState({ fileList: [] });\n\n // 定义提取发票号码的函数\n context.extractInvoiceNumber = (qrContent) => {\n if (!qrContent) return null;\n const content = String(qrContent);\n // 常见发票号码格式:\n // 1. 纯数字发票号码(如:1234567890)\n // 2. 发票号码+发票代码组合(如:发票号码:1234567890)\n // 3. JSON格式中的发票号码字段\n let match = content.match(/发票号码[:::\s]([0-9]{8,12})/);\n if (match) return match[1];\n match = content.match(/invoice[:::\s]([0-9]{8,12})/i);\n if (match) return match[1];\n match = content.match(/number[:::\s]([0-9]{8,12})/i);\n if (match) return match[1];\n match = content.match(/\"发票号码\"\s[::]\s\"([0-9]{8,12})\"/);\n if (match) return match[1];\n match = content.match(/\"invoiceNumber\"\s[::]\s\"([0-9]{8,12})\"/i);\n if (match) return match[1];\n // 如果内容中只有一串数字且长度在8-12位之间,视为发票号码\n const numbers = content.match(/[0-9]{8,12}/g);\n if (numbers && numbers.length === 1) {\n return numbers[0];\n }\n // 尝试提取JSON对象中的发票号码\n try {\n const json = JSON.parse(content);\n if (json.invoiceNumber) return String(json.invoiceNumber);\n if (json.invoice_no) return String(json.invoice_no);\n if (json.number) return String(json.number);\n if (json.发票号码) return String(json.发票号码);\n } catch (e) { / 忽略非JSON内容 */ }\n return null;\n };\n\n // 渲染文件列表\n context.renderFileList = (fileList) => {\n const container = context.getUIElement('fileListContainer');\n if (!container) return;\n if (!fileList || fileList.length === 0) {\n container.innerHTML = '<div style=\"padding:40px 20px;text-align:center;color:#bbb;font-size:14px;\">暂无文件,请点击「+ 添加文件」添加PDF</div>';\n return;\n }\n let html = '';\n fileList.forEach((item, index) => {\n const statusColor = item.status === '已完成' ? '#28a745' :\n item.status === '失败' ? '#dc3545' :\n item.status === '扫描中...' ? '#ffc107' : '#6c757d';\n const invoiceDisplay = item.invoiceNumber || '-';\n html += \n <div style='display:flex;justify-content:space-between;padding:8px 16px;border-bottom:1px solid #f0f0f0;font-size:13px;align-items:center;'>\n <span style='flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#333;'>${item.name}</span>\n <span style='width:120px;text-align:center;color:${statusColor};font-weight:500;'>${item.status}</span>\n <span style='width:100px;text-align:center;color:#1a1a2e;font-weight:600;'>${invoiceDisplay}</span>\n </div>\n;\n });\n container.innerHTML = html;\n };\n\n // 绑定UI事件\n context.bindUI('addFilesBtn', 'addFiles');\n context.bindUI('clearFilesBtn', 'clearFiles');\n context.bindUI('scanBtn', 'scanFiles');\n context.bindUI('exportBtn', 'exportExcel');\n context.bindUI('closeBtn', 'close');\n\n // 初始化空列表\n context.renderFileList([]);\n context.updateUI('fileCount', 0);\n }\n}"
}
}
支付宝付费改造契约(PAYSKILL)
本技能为付费技能(PAYSKILL)。调用遵循支付宝 AI 生意画像契约,由平台调度层执行 probe → pay → complete → ack 流程:
- probe(探测):平台调用前先探测是否已持有本技能的支付宝有效订阅 / 付费凭证。已付费则直接放行。
- 402 Payment-Needed:未付费时,脚本向标准输出返回 HTTP 状态
402与Payment-Needed标记,提示用户通过支付宝完成付费(pay),平台据此唤起付费页。 - complete(扣费确认):用户在支付宝完成扣费后,平台下发
Payment-Proof(付费凭证)。 - ack(验签放行):平台携带
Payment-Proof重试本次调用;scripts/payment_gate.py验签该凭证,确认有效后放行主流程。
订阅持久化与幂等:
- 付费状态缓存在本地
.pay_cache/state.json,避免每次调用重复付费(订阅持久化)。 - 同一
Payment-Proof在 24h 幂等窗口内仅生效一次,避免重复扣费。
现金流改造后的调用套路:
用户调用 → 平台 probe →
未付费:下发 402 Payment-Needed → 支付宝 pay → complete → 下发 Payment-Proof
已付费/携带 Proof:脚本 ack 验签 → 放行主流程
> 本契约由 SkillHUB 平台调度层解析,脚本本地仅处理 402 / 验签 / 持久化 三环节;付费本体交给支付宝。
支付宝 AI 按量付费(Machine Pay / A2M)改造说明
本 Skill 已按支付宝官方 A2M 协议改造为 Pay Skill,付费闸口位于 scripts/payment_gate.py,统一调用云端计费服务 https://pay.pembroli.cn/api/resource。
付费流程
- 未付费调用时,服务端返回 HTTP 402 +
Payment-Needed头,携带金额、商户、订单号及 RSA2 签名。 - 买家侧
payment_gate.py收到 402 后引导用户完成支付,平台回传Payment-Proof。 - 客户端携带
Payment-Proof重试资源端点。
验付调用
服务端收到 Payment-Proof 后,调用支付宝验付接口(alipay.aipay.agent.payment.verify)核验凭证有效性(trade_no、out_trade_no、active 状态),验证失败返回 400,验证通过才进入资源发放。
履约确认
资源发放成功后,服务端异步调用履约确认接口(alipay.aipay.agent.fulfillment.confirm,trade_no)回执,标记本笔订单已履约;并按 out_trade_no 做幂等,重复请求直接返回已缓存结果,杜绝重复收费。
幂等与缓存
买家侧按 slug 本地缓存 Payment-Proof(24h TTL),有效期内免重复支付;服务端按订单号幂等,保证同一笔订单只履约一次。