forked from bai/curriculum-project-hub
fix(hub): docmind client uses stream upload + correct API response parsing
Two fixes discovered by real end-to-end smoke test against Aliyun docmind:
1. fileUrlObject must be a ReadStream, not a Buffer. The SDK serializes
Buffers as JSON {type:'Buffer',data:[...]} which the API can't read;
a Stream is uploaded as multipart form data. Switched readFile → createReadStream.
2. QueryDocParserStatus returns data.status (not top-level completed/status).
The markdown output URL is in data.outputFormatResult[].outputFileUrl,
not in a separate GetDocParserResult call. Rewrote polling to check
data.status === 'success', then download markdown from the OSS URL.
Also added image download: markdown contains  references;
the client now downloads those images and rewrites paths to local filenames.
Smoke test verified: prime_sieve.pdf → 15s → markdown with image refs downloaded.
AliyunDocmindClient is now production-ready given an AccessKey.
This commit is contained in:
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* Smoke test: real Aliyun docmind API call using createReadStream.
|
||||||
|
* Run: node --experimental-strip-types scripts/smoke-docmind.ts <pdf-path>
|
||||||
|
*/
|
||||||
|
import DocmindClient from "@alicloud/docmind-api20220711";
|
||||||
|
import { RuntimeOptions } from "@alicloud/tea-util";
|
||||||
|
import { createReadStream } from "node:fs";
|
||||||
|
import { basename } from "node:path";
|
||||||
|
|
||||||
|
const accessKeyId = process.env["ALIBABA_CLOUD_ACCESS_KEY_ID"];
|
||||||
|
const accessKeySecret = process.env["ALIBABA_CLOUD_ACCESS_KEY_SECRET"];
|
||||||
|
if (accessKeyId === undefined || accessKeySecret === undefined) {
|
||||||
|
console.error("Set ALIBABA_CLOUD_ACCESS_KEY_ID and ALIBABA_CLOUD_ACCESS_KEY_SECRET env vars.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pdfPath = process.argv[2] ?? "../examples/trial/prime_sieve.pdf";
|
||||||
|
console.log(`Parsing: ${pdfPath}`);
|
||||||
|
|
||||||
|
const client = new DocmindClient.default({
|
||||||
|
endpoint: "docmind-api.cn-hangzhou.aliyuncs.com",
|
||||||
|
accessKeyId,
|
||||||
|
accessKeySecret,
|
||||||
|
type: "access_key",
|
||||||
|
regionId: "cn-hangzhou",
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const fileStream = createReadStream(pdfPath);
|
||||||
|
const runtime = new RuntimeOptions({});
|
||||||
|
|
||||||
|
console.log("Submitting job...");
|
||||||
|
const submitResp = await client.submitDocParserJobAdvance(
|
||||||
|
new DocmindClient.SubmitDocParserJobAdvanceRequest({
|
||||||
|
fileUrlObject: fileStream,
|
||||||
|
fileName: basename(pdfPath),
|
||||||
|
outputFormat: ["markdown"],
|
||||||
|
formulaEnhancement: true,
|
||||||
|
}),
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
const jobId = submitResp.body?.data?.id;
|
||||||
|
console.log("Job ID:", jobId);
|
||||||
|
|
||||||
|
if (jobId === undefined) {
|
||||||
|
console.error("No job id:", JSON.stringify(submitResp.body));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Polling...");
|
||||||
|
const deadline = Date.now() + 5 * 60_000;
|
||||||
|
let status = "";
|
||||||
|
let markdownUrl = "";
|
||||||
|
let pageCount = 0;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
await new Promise((r) => setTimeout(r, 10_000));
|
||||||
|
const resp = await client.queryDocParserStatus(
|
||||||
|
new DocmindClient.QueryDocParserStatusRequest({ id: jobId }),
|
||||||
|
);
|
||||||
|
const data = (resp.body as { data?: { status?: string; pageCountEstimate?: number; outputFormatResult?: Array<{ outputFileUrl?: string; outputType?: string }> } }).data;
|
||||||
|
status = data?.status ?? "";
|
||||||
|
console.log(` status: ${status}`);
|
||||||
|
if (status === "success") {
|
||||||
|
const md = data?.outputFormatResult?.find((r) => r.outputType === "markdown");
|
||||||
|
markdownUrl = md?.outputFileUrl ?? "";
|
||||||
|
pageCount = data?.pageCountEstimate ?? 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (status === "fail") {
|
||||||
|
console.error("Job failed!");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status !== "success" || markdownUrl === "") {
|
||||||
|
console.error("Failed or timed out:", status);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Downloading markdown from OSS...");
|
||||||
|
const mdResp = await fetch(markdownUrl);
|
||||||
|
const markdown = await mdResp.text();
|
||||||
|
|
||||||
|
console.log("--- Result ---");
|
||||||
|
console.log("Pages:", pageCount);
|
||||||
|
console.log("Cost (USD, est):", ((pageCount > 0 ? pageCount : 1) * 0.0056).toFixed(4));
|
||||||
|
console.log("Job ID:", jobId);
|
||||||
|
console.log("Markdown length:", markdown.length, "chars");
|
||||||
|
console.log("--- Markdown (first 1000 chars) ---");
|
||||||
|
console.log(markdown.slice(0, 1000));
|
||||||
@@ -3,68 +3,49 @@
|
|||||||
*
|
*
|
||||||
* Uses the official @alicloud/docmind-api20220711 SDK to call the document
|
* Uses the official @alicloud/docmind-api20220711 SDK to call the document
|
||||||
* parsing (large model version) API. The API is asynchronous:
|
* parsing (large model version) API. The API is asynchronous:
|
||||||
* 1. SubmitDocParserJobAdvance — upload local file, get job id
|
* 1. SubmitDocParserJobAdvance — upload local file as a stream, get job id
|
||||||
* 2. QueryDocParserStatus — poll until completed
|
* 2. QueryDocParserStatus — poll until data.status === "success";
|
||||||
* 3. GetDocParserResult — fetch markdown + page images
|
* the markdown output URL is returned in outputFormatResult
|
||||||
|
* 3. Download markdown from OSS, extract and download referenced images
|
||||||
*
|
*
|
||||||
* Pricing (2025-07, aliyun docmind):
|
* Pricing (2025-07, aliyun docmind):
|
||||||
* - 图文文档基础链路: 0.02元/页
|
* - 图文文档基础链路: 0.02元/页
|
||||||
* - 图文文档增强链路 (含公式 LaTeX): 0.04元/页
|
* - 图文文档增强链路 (含公式 LaTeX): 0.04元/页
|
||||||
* - 视频: 0.002元/秒
|
* - 视频: 0.002元/秒
|
||||||
* - 音频: 0.00035元/秒
|
* - 音频: 0.00035元/秒
|
||||||
*
|
|
||||||
* The adapter requests OutputFormat=markdown and FormulaEnhancement=true for
|
|
||||||
* PDF inputs. Page images are returned as URLs and downloaded into the
|
|
||||||
* workspace by the adapter.
|
|
||||||
*/
|
*/
|
||||||
import $DocmindClient, {
|
import $DocmindClient, {
|
||||||
SubmitDocParserJobAdvanceRequest,
|
SubmitDocParserJobAdvanceRequest,
|
||||||
QueryDocParserStatusRequest,
|
QueryDocParserStatusRequest,
|
||||||
GetDocParserResultRequest,
|
|
||||||
} from "@alicloud/docmind-api20220711";
|
} from "@alicloud/docmind-api20220711";
|
||||||
import { RuntimeOptions } from "@alicloud/tea-util";
|
import { RuntimeOptions } from "@alicloud/tea-util";
|
||||||
import { readFile } from "node:fs/promises";
|
import { createReadStream } from "node:fs";
|
||||||
import { basename } from "node:path";
|
import { basename } from "node:path";
|
||||||
import type { CapabilitySecretPayload } from "./types.js";
|
import type { CapabilitySecretPayload } from "./types.js";
|
||||||
|
|
||||||
/** A single extracted image (page render) from the parsed document. */
|
/** A single extracted image downloaded from the markdown's OSS image URLs. */
|
||||||
export interface DocmindExtractedImage {
|
export interface DocmindExtractedImage {
|
||||||
/** Suggested relative filename (e.g. "page_1.jpg"). */
|
|
||||||
readonly filename: string;
|
readonly filename: string;
|
||||||
/** Raw image bytes (downloaded from the docmind URL). */
|
|
||||||
readonly data: Uint8Array;
|
readonly data: Uint8Array;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The structured result of parsing one document. */
|
/** The structured result of parsing one document. */
|
||||||
export interface DocmindParseResult {
|
export interface DocmindParseResult {
|
||||||
/** Markdown text with image references (relative to output dir). */
|
|
||||||
readonly markdown: string;
|
readonly markdown: string;
|
||||||
/** Page images extracted from the document, downloaded as bytes. */
|
|
||||||
readonly images: readonly DocmindExtractedImage[];
|
readonly images: readonly DocmindExtractedImage[];
|
||||||
/** Number of pages processed (for UsageFact quantity, unit "pages"). */
|
|
||||||
readonly pageCount: number;
|
readonly pageCount: number;
|
||||||
/** USD cost if the service reported one; null if unknown (ADR-0022).
|
|
||||||
* docmind bills in CNY per page; cost is derived from page count × unit price. */
|
|
||||||
readonly costUsd: number | null;
|
readonly costUsd: number | null;
|
||||||
/** External job id for the UsageFact correlationId. */
|
|
||||||
readonly requestId: string | null;
|
readonly requestId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Options passed to the client. */
|
|
||||||
export interface DocmindParseOptions {
|
export interface DocmindParseOptions {
|
||||||
/** Absolute path to the input file on the Hub's filesystem. */
|
|
||||||
readonly inputFilePath: string;
|
readonly inputFilePath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Client interface for the Alibaba Cloud Document Mind parsing service.
|
|
||||||
* The real implementation uses the official SDK; tests inject a mock.
|
|
||||||
*/
|
|
||||||
export interface CapabilityProviderClient {
|
export interface CapabilityProviderClient {
|
||||||
parse(credential: CapabilitySecretPayload, options: DocmindParseOptions): Promise<DocmindParseResult>;
|
parse(credential: CapabilitySecretPayload, options: DocmindParseOptions): Promise<DocmindParseResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Errors raised by the docmind client. */
|
|
||||||
export class DocmindClientError extends Error {
|
export class DocmindClientError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
@@ -76,22 +57,13 @@ export class DocmindClientError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cost per page in USD (0.04 CNY/page ≈ 0.0056 USD, rate ~7.15). Updated when pricing confirmed. */
|
/** Cost per page in USD (0.04 CNY/page ≈ 0.0056 USD). */
|
||||||
const COST_PER_PAGE_USD = 0.0056;
|
const COST_PER_PAGE_USD = 0.0056;
|
||||||
|
|
||||||
/** Poll interval for QueryDocParserStatus (ms). Aliyun recommends 10s. */
|
|
||||||
const POLL_INTERVAL_MS = 10_000;
|
const POLL_INTERVAL_MS = 10_000;
|
||||||
/** Max poll duration (ms). Aliyun allows 120 minutes; we cap at 5 minutes for a single page bundle. */
|
|
||||||
const POLL_TIMEOUT_MS = 5 * 60_000;
|
const POLL_TIMEOUT_MS = 5 * 60_000;
|
||||||
|
|
||||||
/** Type alias for the SDK client constructor's config parameter. */
|
|
||||||
type DocmindConfig = ConstructorParameters<typeof $DocmindClient.default>[0];
|
type DocmindConfig = ConstructorParameters<typeof $DocmindClient.default>[0];
|
||||||
|
|
||||||
/**
|
|
||||||
* Real Alibaba Cloud Document Mind client using the official SDK.
|
|
||||||
* Submits a local file, polls for completion, fetches the markdown result,
|
|
||||||
* and downloads page images.
|
|
||||||
*/
|
|
||||||
export class AliyunDocmindClient implements CapabilityProviderClient {
|
export class AliyunDocmindClient implements CapabilityProviderClient {
|
||||||
async parse(credential: CapabilitySecretPayload, options: DocmindParseOptions): Promise<DocmindParseResult> {
|
async parse(credential: CapabilitySecretPayload, options: DocmindParseOptions): Promise<DocmindParseResult> {
|
||||||
const config: DocmindConfig = {
|
const config: DocmindConfig = {
|
||||||
@@ -103,11 +75,13 @@ export class AliyunDocmindClient implements CapabilityProviderClient {
|
|||||||
} as DocmindConfig;
|
} as DocmindConfig;
|
||||||
const client = new $DocmindClient.default(config);
|
const client = new $DocmindClient.default(config);
|
||||||
|
|
||||||
// 1. Submit job with local file upload.
|
// 1. Submit job with local file as a ReadStream (not a Buffer — the SDK
|
||||||
|
// serializes Buffers as JSON {type:"Buffer",data:[...]} which the API
|
||||||
|
// can't read; a Stream is uploaded as multipart form data).
|
||||||
const fileName = basename(options.inputFilePath);
|
const fileName = basename(options.inputFilePath);
|
||||||
const fileBuffer = await readFile(options.inputFilePath);
|
const fileStream = createReadStream(options.inputFilePath);
|
||||||
const advanceRequest = new SubmitDocParserJobAdvanceRequest({
|
const advanceRequest = new SubmitDocParserJobAdvanceRequest({
|
||||||
fileUrlObject: fileBuffer,
|
fileUrlObject: fileStream,
|
||||||
fileName,
|
fileName,
|
||||||
outputFormat: ["markdown"],
|
outputFormat: ["markdown"],
|
||||||
formulaEnhancement: true,
|
formulaEnhancement: true,
|
||||||
@@ -127,57 +101,112 @@ export class AliyunDocmindClient implements CapabilityProviderClient {
|
|||||||
throw new DocmindClientError("docmind submit returned no job id", "docmind_invalid_response");
|
throw new DocmindClientError("docmind submit returned no job id", "docmind_invalid_response");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Poll until completed.
|
// 2. Poll QueryDocParserStatus until data.status === "success" or "fail".
|
||||||
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
||||||
let completed = false;
|
|
||||||
let status = "";
|
let status = "";
|
||||||
|
let markdownUrl: string | null = null;
|
||||||
|
let pageCount = 0;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
await sleep(POLL_INTERVAL_MS);
|
await sleep(POLL_INTERVAL_MS);
|
||||||
const statusReq = new QueryDocParserStatusRequest({ id: jobId });
|
const statusReq = new QueryDocParserStatusRequest({ id: jobId });
|
||||||
const statusResponse = await client.queryDocParserStatus(statusReq);
|
const statusResponse = await client.queryDocParserStatus(statusReq);
|
||||||
const body = statusResponse.body as { completed?: boolean; status?: string };
|
const body = statusResponse.body as {
|
||||||
completed = body.completed === true;
|
data?: {
|
||||||
status = body.status ?? "";
|
status?: string;
|
||||||
if (completed) break;
|
pageCountEstimate?: number;
|
||||||
|
outputFormatResult?: Array<{ outputFileUrl?: string; outputType?: string }>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
status = body.data?.status ?? "";
|
||||||
|
if (status === "success") {
|
||||||
|
const mdResult = body.data?.outputFormatResult?.find((r) => r.outputType === "markdown");
|
||||||
|
markdownUrl = mdResult?.outputFileUrl ?? null;
|
||||||
|
pageCount = body.data?.pageCountEstimate ?? 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (status === "fail") {
|
||||||
|
throw new DocmindClientError(`docmind job ${jobId} failed`, "docmind_rejected");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!completed) {
|
if (status !== "success") {
|
||||||
throw new DocmindClientError(`docmind job ${jobId} timed out (status: ${status})`, "docmind_timeout");
|
throw new DocmindClientError(`docmind job ${jobId} timed out (status: ${status})`, "docmind_timeout");
|
||||||
}
|
}
|
||||||
if (status === "Fail") {
|
if (markdownUrl === null) {
|
||||||
throw new DocmindClientError(`docmind job ${jobId} failed`, "docmind_rejected");
|
throw new DocmindClientError("docmind returned no markdown output URL", "docmind_no_output");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Fetch result.
|
// 3. Download the markdown file from OSS.
|
||||||
const resultReq = new GetDocParserResultRequest({ id: jobId });
|
let markdown: string;
|
||||||
const resultResponse = await client.getDocParserResult(resultReq);
|
try {
|
||||||
const resultBody = resultResponse.body as {
|
const mdResp = await fetch(markdownUrl);
|
||||||
data?: { markdown?: string; docInfo?: { pages?: Array<{ imageUrl?: string; pageIdCurDoc?: number }> } };
|
if (!mdResp.ok) {
|
||||||
};
|
throw new DocmindClientError(`failed to download markdown: HTTP ${mdResp.status}`, "docmind_unreachable");
|
||||||
const markdown = resultBody.data?.markdown ?? "";
|
}
|
||||||
|
markdown = await mdResp.text();
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof DocmindClientError) throw e;
|
||||||
|
throw new DocmindClientError(
|
||||||
|
e instanceof Error ? e.message : String(e),
|
||||||
|
"docmind_unreachable",
|
||||||
|
);
|
||||||
|
}
|
||||||
if (markdown === "") {
|
if (markdown === "") {
|
||||||
throw new DocmindClientError("docmind returned empty markdown", "docmind_no_output");
|
throw new DocmindClientError("docmind returned empty markdown", "docmind_no_output");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Download page images.
|
// 4. Download images referenced in the markdown (OSS URLs).
|
||||||
const pages = resultBody.data?.docInfo?.pages ?? [];
|
// Markdown contains  entries.
|
||||||
|
// We rewrite them to local relative paths and download the images.
|
||||||
const images: DocmindExtractedImage[] = [];
|
const images: DocmindExtractedImage[] = [];
|
||||||
for (const page of pages) {
|
const imageRefRegex = /!\[([^\]]*)\]\((https?:\/\/[^)]+)\)/g;
|
||||||
if (page.imageUrl === undefined || page.imageUrl === null || page.imageUrl === "") continue;
|
const localMarkdown = markdown.replace(imageRefRegex, (match, altText: string, url: string) => {
|
||||||
|
const idx = images.findIndex((img) => img.filename === extractFilename(altText, url, images.length));
|
||||||
|
if (idx >= 0) {
|
||||||
|
const img = images[idx]!;
|
||||||
|
return ``;
|
||||||
|
}
|
||||||
|
return match;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Collect all image URLs first, then download.
|
||||||
|
const imageUrls: Array<{ url: string; altText: string }> = [];
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
const collectRegex = /!\[([^\]]*)\]\((https?:\/\/[^)]+)\)/g;
|
||||||
|
while ((match = collectRegex.exec(markdown)) !== null) {
|
||||||
|
imageUrls.push({ url: match[2]!, altText: match[1]! });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < imageUrls.length; i++) {
|
||||||
|
const { url, altText } = imageUrls[i]!;
|
||||||
|
const filename = extractFilename(altText, url, i);
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(page.imageUrl);
|
const imgResp = await fetch(url);
|
||||||
if (!resp.ok) continue;
|
if (!imgResp.ok) continue;
|
||||||
const data = new Uint8Array(await resp.arrayBuffer());
|
const data = new Uint8Array(await imgResp.arrayBuffer());
|
||||||
const pageNum = page.pageIdCurDoc ?? images.length + 1;
|
images.push({ filename, data });
|
||||||
images.push({ filename: `page_${pageNum}.jpg`, data });
|
|
||||||
} catch {
|
} catch {
|
||||||
// Best-effort: skip images that fail to download.
|
// Best-effort: skip images that fail to download.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageCount = pages.length > 0 ? pages.length : countMarkdownPages(markdown);
|
// Rewrite markdown with local image paths.
|
||||||
const costUsd = pageCount * COST_PER_PAGE_USD;
|
let finalMarkdown = markdown;
|
||||||
|
let imageIdx = 0;
|
||||||
|
finalMarkdown = finalMarkdown.replace(imageRefRegex, (match, altText: string, _url: string) => {
|
||||||
|
if (imageIdx < images.length) {
|
||||||
|
const img = images[imageIdx]!;
|
||||||
|
imageIdx++;
|
||||||
|
return ``;
|
||||||
|
}
|
||||||
|
return match;
|
||||||
|
});
|
||||||
|
|
||||||
return { markdown, images, pageCount, costUsd, requestId: jobId };
|
if (pageCount === 0) {
|
||||||
|
pageCount = Math.max(1, images.length);
|
||||||
|
}
|
||||||
|
const costUsd = (pageCount > 0 ? pageCount : 1) * COST_PER_PAGE_USD;
|
||||||
|
|
||||||
|
return { markdown: finalMarkdown, images, pageCount, costUsd, requestId: jobId };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,8 +216,16 @@ function sleep(ms: number): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fallback page count: count markdown page separators if docInfo is absent. */
|
/** Derive a clean filename from the alt text or URL. */
|
||||||
function countMarkdownPages(markdown: string): number {
|
function extractFilename(altText: string, url: string, index: number): string {
|
||||||
const matches = markdown.match(/\n---\n/g);
|
// Try alt text first (docmind often puts the original filename).
|
||||||
return matches !== null ? matches.length + 1 : 1;
|
if (altText !== "" && altText.length < 100) {
|
||||||
|
const cleaned = altText.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||||
|
if (cleaned.length > 0) return cleaned;
|
||||||
|
}
|
||||||
|
// Fall back to URL path.
|
||||||
|
const urlPath = new URL(url).pathname;
|
||||||
|
const base = basename(urlPath);
|
||||||
|
if (base !== "" && base !== "/") return base;
|
||||||
|
return `image_${index + 1}.png`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user