Skip to content

Commit f4c6900

Browse files
author
shangwenmo
committed
Add pdflux-saas-markdown skill.
0 parents  commit f4c6900

3 files changed

Lines changed: 296 additions & 0 deletions

File tree

pdflux-saas-markdown/SKILL.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
name: pdflux-saas-markdown
3+
description: 解析文档并获取文档内容,尤其适用于提取 PDF、DOCX、DOC、PPT、PPTX、PNG、JPG、JPEG 等文件中的正文与表格内容。用于将文件转换为 markdown、响应“转 markdown”这类需求、读取文档具体内容、基于文档内容执行后续分析或脚本处理;当需要编写脚本解析文档时,优先使用这个 skill。
4+
---
5+
6+
# pdflux-saas-markdown
7+
8+
执行一个 JavaScript 工作流,先把单个本地文件解析为 markdown,再基于 markdown 获取正文、表格和结构化内容。支持 PDF、Word、PPT 和图片等常见格式,适合文档解析、表格提取、内容核对,以及把文档内容交给后续脚本继续处理。
9+
10+
## 运行方式
11+
12+
```bash
13+
node .claude/skills/pdflux-saas-markdown/scripts/upload_to_markdown.js <local-file-path>
14+
```
15+
16+
## 执行约束
17+
18+
- 必须直接调用 `scripts/upload_to_markdown.js` 执行,不要根据下方行为约定自行重写上传、轮询、下载 markdown 的流程。
19+
- 行为约定仅用于说明脚本做什么、输出什么、何时适合使用,不是给模型手工照着执行的步骤。
20+
- 即使任务只是提取表格、获取字段、读取正文或为后续脚本准备输入,也必须先运行该脚本,再基于脚本产出的 markdown 继续处理。
21+
- 只有在脚本本身不可用、报错、或需要修复脚本时,才允许检查或修改脚本实现;在正常使用场景下不要绕过脚本。
22+
23+
## 适用场景
24+
25+
- 当用户要解析文档、获取文档具体内容或抽取文档表格时,使用这个 skill。
26+
- 当用户输入类似“转 markdown”“输出 markdown”“导出 markdown”“提取 markdown”时,使用这个 skill,并直接输出 markdown 内容。
27+
- 当后续任务依赖文档内容继续处理,例如生成摘要、抽取字段、编写脚本处理文档、对比表格或做规则校验时,优先先用这个 skill 解析文档。
28+
- 当只是需要文档内容供后续操作使用时,不默认向用户输出原始 markdown 全文;优先将 markdown 保存到临时文件,再读取、筛选、提取需要的内容。
29+
- 当用户明确要求“输出 markdown 原文”或表达的是“转 markdown”类直接转换需求时,直接展示完整 markdown。
30+
31+
## 环境变量
32+
33+
- `PAODINGAI_API_KEY`: 必填访问令牌。若未设置,提示 `Enter PAODINGAI_API_KEY:` 并接收手动输入。
34+
- `PAODINGAI_API_BASE_URL`: 可选 API 域名。默认值:`https://saas.pdflux.com`
35+
36+
## 脚本行为说明
37+
38+
1. 优先从 `PAODINGAI_API_KEY` 读取令牌,缺失时回退到交互式输入。
39+
2. 使用 `Authorization: Bearer <token>` 调用 `/api/v1/saas/upload` 上传文件。
40+
3. 持续轮询 `/api/v1/saas/document/{uuid}`,直到 `parsed === 2`
41+
4. 当解析状态为负值时立即失败。
42+
5.`/api/v1/saas/document/{uuid}/markdown` 下载 markdown。
43+
6. 脚本默认将 markdown 内容写入 stdout,因此在实际使用中,若后续还要继续消费文档内容,优先将 stdout 重定向到临时文件或工作文件,再读取文件内容进行后续处理。
44+
7. 当任务目标是获取具体内容、字段或表格时,读取解析结果并只输出必要信息,不向用户直接回显原始 markdown 全文。
45+
8. 当用户明确表达“转 markdown”“输出 markdown”或等价意图时,直接返回 markdown 内容,而不是只返回提取后的摘要或字段。
46+
9. 将进度与错误写入 stderr,错误时返回非零退出码。
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
interface:
2+
display_name: "PDFlux SaaS Markdown"
3+
short_description: "Parse docs and tables from PDF, DOCX, and images"
4+
default_prompt: "Always execute the bundled scripts/upload_to_markdown.js script to parse one local PDF, DOCX, PPT, image, or similar file. If the user explicitly asks to convert to markdown or output markdown, return the markdown content directly; otherwise extract only the document content or tables needed for the task."
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
#!/usr/bin/env node
2+
3+
const fs = require('node:fs/promises');
4+
const path = require('node:path');
5+
const readline = require('node:readline/promises');
6+
const { stdin, stderr, stdout } = require('node:process');
7+
8+
const DEFAULT_BASE_URL = 'https://saas.pdflux.com';
9+
const DEFAULT_POLL_INTERVAL_MS = 2000;
10+
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
11+
12+
function sleep(ms) {
13+
return new Promise(resolve => setTimeout(resolve, ms));
14+
}
15+
16+
function normalizeBaseUrl(url) {
17+
return (url || DEFAULT_BASE_URL).trim().replace(/\/+$/, '');
18+
}
19+
20+
async function readAccessToken() {
21+
const fromEnv = (process.env.PAODINGAI_API_KEY || '').trim();
22+
if (fromEnv) {
23+
return fromEnv;
24+
}
25+
26+
const rl = readline.createInterface({
27+
input: stdin,
28+
output: stderr,
29+
});
30+
31+
try {
32+
const input = await rl.question('Enter PAODINGAI_API_KEY: ');
33+
const token = (input || '').trim();
34+
if (!token) {
35+
throw new Error('PAODINGAI_API_KEY is required but not provided.');
36+
}
37+
return token;
38+
} finally {
39+
rl.close();
40+
}
41+
}
42+
43+
async function parseResponse(response) {
44+
const contentType = response.headers.get('content-type') || '';
45+
const bodyText = await response.text();
46+
if (contentType.includes('application/json')) {
47+
try {
48+
return JSON.parse(bodyText);
49+
} catch {
50+
return { status: false, msg: bodyText || 'Invalid JSON response' };
51+
}
52+
}
53+
return bodyText;
54+
}
55+
56+
function extractApiError(payload, fallback) {
57+
if (!payload) {
58+
return fallback;
59+
}
60+
if (typeof payload === 'string') {
61+
return payload || fallback;
62+
}
63+
if (typeof payload === 'object') {
64+
return payload.msg || payload.message || JSON.stringify(payload);
65+
}
66+
return fallback;
67+
}
68+
69+
async function uploadFile({ baseUrl, token, filePath }) {
70+
const formData = new FormData();
71+
const filename = path.basename(filePath);
72+
const bytes = await fs.readFile(filePath);
73+
const fileBlob = new Blob([bytes], { type: 'application/octet-stream' });
74+
formData.append('file', fileBlob, filename);
75+
76+
const response = await fetch(`${baseUrl}/api/v1/saas/upload`, {
77+
method: 'POST',
78+
headers: {
79+
Authorization: `Bearer ${token}`,
80+
},
81+
body: formData,
82+
});
83+
84+
const payload = await parseResponse(response);
85+
if (!response.ok) {
86+
throw new Error(
87+
`Upload failed (${response.status}): ${extractApiError(payload, 'Request failed')}`,
88+
);
89+
}
90+
91+
if (typeof payload !== 'object' || payload.status === false) {
92+
throw new Error(
93+
`Upload failed: ${extractApiError(payload, 'Invalid upload response')}`,
94+
);
95+
}
96+
97+
const uuid = payload?.data?.uuid;
98+
if (!uuid) {
99+
throw new Error(
100+
`Upload succeeded but uuid is missing: ${JSON.stringify(payload)}`,
101+
);
102+
}
103+
104+
return uuid;
105+
}
106+
107+
async function pollParsed({ baseUrl, token, uuid, pollIntervalMs, timeoutMs }) {
108+
const startTime = Date.now();
109+
110+
while (Date.now() - startTime < timeoutMs) {
111+
const response = await fetch(`${baseUrl}/api/v1/saas/document/${uuid}`, {
112+
method: 'GET',
113+
headers: {
114+
Authorization: `Bearer ${token}`,
115+
},
116+
});
117+
118+
const payload = await parseResponse(response);
119+
if (!response.ok) {
120+
throw new Error(
121+
`Polling failed (${response.status}): ${extractApiError(payload, 'Request failed')}`,
122+
);
123+
}
124+
if (typeof payload !== 'object' || payload.status === false) {
125+
throw new Error(
126+
`Polling failed: ${extractApiError(payload, 'Invalid status response')}`,
127+
);
128+
}
129+
130+
const parsed = payload?.data?.parsed;
131+
if (parsed === 2) {
132+
return;
133+
}
134+
if (typeof parsed === 'number' && parsed < 0) {
135+
throw new Error(
136+
`Parsing failed with status ${parsed}: ${extractApiError(payload, 'Parse failed')}`,
137+
);
138+
}
139+
140+
await sleep(pollIntervalMs);
141+
}
142+
143+
throw new Error(
144+
`Polling timed out after ${Math.floor(timeoutMs / 1000)} seconds.`,
145+
);
146+
}
147+
148+
async function downloadMarkdown({ baseUrl, token, uuid }) {
149+
const response = await fetch(
150+
`${baseUrl}/api/v1/saas/document/${uuid}/markdown`,
151+
{
152+
method: 'GET',
153+
headers: {
154+
Authorization: `Bearer ${token}`,
155+
},
156+
},
157+
);
158+
159+
const contentType = response.headers.get('content-type') || '';
160+
const bodyText = await response.text();
161+
162+
if (!response.ok) {
163+
let errorMessage = bodyText;
164+
if (contentType.includes('application/json')) {
165+
try {
166+
const payload = JSON.parse(bodyText);
167+
errorMessage = extractApiError(payload, bodyText);
168+
} catch {
169+
// Keep bodyText
170+
}
171+
}
172+
throw new Error(
173+
`Markdown download failed (${response.status}): ${errorMessage || 'Request failed'}`,
174+
);
175+
}
176+
177+
if (contentType.includes('application/json')) {
178+
try {
179+
const payload = JSON.parse(bodyText);
180+
if (payload?.status === false) {
181+
throw new Error(
182+
`Markdown download failed: ${extractApiError(payload, 'API returned error')}`,
183+
);
184+
}
185+
} catch (error) {
186+
if (error instanceof Error) {
187+
throw error;
188+
}
189+
}
190+
}
191+
192+
return bodyText;
193+
}
194+
195+
async function ensureInputFile(filePathArg) {
196+
if (!filePathArg) {
197+
throw new Error('Usage: node upload_to_markdown.js <local-file-path>');
198+
}
199+
200+
const resolvedPath = path.resolve(filePathArg);
201+
let stat;
202+
try {
203+
stat = await fs.stat(resolvedPath);
204+
} catch {
205+
throw new Error(`Input file does not exist: ${resolvedPath}`);
206+
}
207+
208+
if (!stat.isFile()) {
209+
throw new Error(`Input path is not a file: ${resolvedPath}`);
210+
}
211+
212+
return resolvedPath;
213+
}
214+
215+
async function main() {
216+
const filePath = await ensureInputFile(process.argv[2]);
217+
const token = await readAccessToken();
218+
const baseUrl = normalizeBaseUrl(process.env.PAODINGAI_API_BASE_URL);
219+
220+
const pollIntervalMs = DEFAULT_POLL_INTERVAL_MS;
221+
const timeoutMs = DEFAULT_TIMEOUT_MS;
222+
223+
stderr.write(`[pdflux-saas-markdown] Uploading ${path.basename(filePath)}\n`);
224+
const uuid = await uploadFile({ baseUrl, token, filePath });
225+
226+
stderr.write(`[pdflux-saas-markdown] Uploaded uuid=${uuid}\n`);
227+
stderr.write('[pdflux-saas-markdown] Polling parse status\n');
228+
await pollParsed({ baseUrl, token, uuid, pollIntervalMs, timeoutMs });
229+
230+
stderr.write(
231+
'[pdflux-saas-markdown] Parse completed, downloading markdown\n',
232+
);
233+
const markdown = await downloadMarkdown({ baseUrl, token, uuid });
234+
235+
stdout.write(markdown);
236+
if (!markdown.endsWith('\n')) {
237+
stdout.write('\n');
238+
}
239+
}
240+
241+
main().catch(error => {
242+
stderr.write(
243+
`[pdflux-saas-markdown] ${error instanceof Error ? error.message : String(error)}\n`,
244+
);
245+
process.exitCode = 1;
246+
});

0 commit comments

Comments
 (0)