fix: ENOENT download error for archive-only converters (PDFMathTranslate/MinerU)

- Add existsSync checks in download.tsx before file access
- Fix normalizeFiletype.ts: md-t/md-i return original format (not tar.gz)
- Add outputMode: 'archive' support in main.ts with auto .tar suffix
- PDFMathTranslate outputs translated-<lang>.pdf + bilingual-<lang>.pdf (no original)
- MinerU outputs complete folder in .tar with logging
- Hide preview icon for .tar files in results.tsx
- Update Bun version: 1.2.2 -> 1.3.6
- Add Step 19: auto-deploy to remote server after Docker push

All 159 tests pass.
This commit is contained in:
Your Name 2026-01-22 00:47:37 +08:00
parent 1f800c3619
commit 912546aaa7
10 changed files with 190 additions and 63 deletions

View file

@ -1 +1 @@
1.2.2 1.3.6

View file

@ -691,3 +691,48 @@ jobs:
generate_release_notes: true generate_release_notes: true
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# ========================================
# Step 19: 更新遠端 ConvertX-CN 服務
# 說明:拉取最新映像並重新啟動服務(所有步驟完成後執行)
# ========================================
- name: 🔄 更新遠端 ConvertX-CN 服務
run: |
echo "========================================"
echo "🔄 更新遠端 ConvertX-CN 服務"
echo "========================================"
tailscale ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << 'REMOTE_EOF'
set -e
# 1. 進入專案資料夾
cd /home/bioailab/miniconda3/lid/app/convertx-cn
# 2. 停止服務
echo "📋 停止服務..."
docker compose down
# 3. 拉取最新映像檔
echo ""
echo "📋 拉取最新映像檔..."
docker compose pull
# 4. 重新啟動
echo ""
echo "📋 重新啟動服務..."
docker compose up -d
# 5. 顯示服務狀態與 log
echo ""
echo "📋 服務狀態:"
docker compose ps
echo ""
echo "📋 服務 log最近 100 行):"
docker logs convertx-cn --tail=100
echo ""
echo "✅ ConvertX-CN 服務已更新完成"
REMOTE_EOF
echo "========================================"

View file

@ -1,5 +1,5 @@
[tools] [tools]
bun = "1.2.2" bun = "1.3.6"
[env] [env]
JWT_SECRET = "aLongAndSecretStringUsedToSignTheJSONWebToken1234" JWT_SECRET = "aLongAndSecretStringUsedToSignTheJSONWebToken1234"

View file

@ -39,6 +39,7 @@ const properties: Record<
properties: { properties: {
from: Record<string, string[]>; from: Record<string, string[]>;
to: Record<string, string[]>; to: Record<string, string[]>;
outputMode?: "archive";
options?: Record< options?: Record<
string, string,
Record< Record<
@ -142,7 +143,7 @@ const properties: Record<
properties: propertiesMarkitdown, properties: propertiesMarkitdown,
converter: convertMarkitdown, converter: convertMarkitdown,
}, },
mineru: { MinerU: {
properties: propertiesMineru, properties: propertiesMineru,
converter: convertMineru, converter: convertMineru,
}, },
@ -173,6 +174,10 @@ export async function handleConvert(
"INSERT INTO file_names (job_id, file_name, output_file_name, status) VALUES (?1, ?2, ?3, ?4)", "INSERT INTO file_names (job_id, file_name, output_file_name, status) VALUES (?1, ?2, ?3, ?4)",
); );
// Check if the converter outputs an archive (.tar)
const converterProps = properties[converterName]?.properties;
const isArchiveOutput = converterProps?.outputMode === "archive";
for (const chunk of chunks(fileNames, MAX_CONVERT_PROCESS)) { for (const chunk of chunks(fileNames, MAX_CONVERT_PROCESS)) {
const toProcess: Promise<string>[] = []; const toProcess: Promise<string>[] = [];
for (const fileName of chunk) { for (const fileName of chunk) {
@ -180,11 +185,17 @@ export async function handleConvert(
const fileTypeOrig = fileName.split(".").pop() ?? ""; const fileTypeOrig = fileName.split(".").pop() ?? "";
const fileType = normalizeFiletype(fileTypeOrig); const fileType = normalizeFiletype(fileTypeOrig);
const newFileExt = normalizeOutputFiletype(convertTo); const newFileExt = normalizeOutputFiletype(convertTo);
const newFileName = fileName.replace( let newFileName = fileName.replace(
new RegExp(`${fileTypeOrig}(?!.*${fileTypeOrig})`), new RegExp(`${fileTypeOrig}(?!.*${fileTypeOrig})`),
newFileExt, newFileExt,
); );
const targetPath = `${userOutputDir}${newFileName}`;
// For archive output converters, the actual file will have .tar extension
if (isArchiveOutput) {
newFileName = `${newFileName}.tar`;
}
const targetPath = `${userOutputDir}${newFileName.replace(/\.tar$/, "")}`;
toProcess.push( toProcess.push(
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
mainConverter(filePath, fileType, convertTo, targetPath, {}, converterName) mainConverter(filePath, fileType, convertTo, targetPath, {}, converterName)

View file

@ -113,6 +113,7 @@ export async function convert(
// Create .tar archive from the output directory (不使用壓縮) // Create .tar archive from the output directory (不使用壓縮)
// 強制使用 .tar 格式,禁止 .tar.gz // 強制使用 .tar 格式,禁止 .tar.gz
const tarPath = getArchiveFileName(targetPath); const tarPath = getArchiveFileName(targetPath);
console.log(`[MinerU] Target tar path: ${tarPath}`);
// Ensure the parent directory exists // Ensure the parent directory exists
const tarDir = dirname(tarPath); const tarDir = dirname(tarPath);
@ -121,11 +122,21 @@ export async function convert(
} }
// Use the actual MinerU output directory for archiving // Use the actual MinerU output directory for archiving
// MinerU 產生完整資料夾結構,全部封裝進 .tar
const outputToArchive = existsSync(mineruActualOutput) const outputToArchive = existsSync(mineruActualOutput)
? mineruActualOutput ? mineruActualOutput
: mineruOutputDir; : mineruOutputDir;
console.log(`[MinerU] Archiving directory: ${outputToArchive}`);
// 列出要封裝的內容
if (existsSync(outputToArchive)) {
const contents = readdirSync(outputToArchive);
console.log(`[MinerU] Archive contents: ${contents.join(", ")}`);
}
await createTarArchive(outputToArchive, tarPath, execFile); await createTarArchive(outputToArchive, tarPath, execFile);
console.log(`[MinerU] Created archive: ${tarPath}`);
// Clean up the temporary directory // Clean up the temporary directory
removeDir(mineruOutputDir); removeDir(mineruOutputDir);

View file

@ -261,49 +261,75 @@ export async function convert(
// 5. 執行 pdf2zh 翻譯 // 5. 執行 pdf2zh 翻譯
const { monoPath, dualPath } = await runPdf2zh(filePath, tempDir, targetLang, execFile); const { monoPath, dualPath } = await runPdf2zh(filePath, tempDir, targetLang, execFile);
// 6. 複製原始檔案到封裝目錄 // 6. 複製翻譯後的檔案到封裝目錄
const originalDest = join(archiveDir, "original.pdf"); // PDFMathTranslate 輸出:
copyFileSync(filePath, originalDest); // - mono: 純翻譯版本translated-<lang>.pdf
// - dual: 雙語對照版本bilingual-<lang>.pdf
// 7. 複製翻譯後的檔案(優先使用 mono因為它是純翻譯版本 // ⚠️ 不包含原始 PDF只包含翻譯結果
const translatedDest = join(archiveDir, `translated-${targetLang}.pdf`); const translatedDest = join(archiveDir, `translated-${targetLang}.pdf`);
const bilingualDest = join(archiveDir, `bilingual-${targetLang}.pdf`);
// 檢查各種可能的輸出檔案名稱 // 檢查各種可能的 mono 輸出檔案名稱
const possibleOutputs = [ const possibleMonoOutputs = [
monoPath, monoPath,
dualPath,
join(tempDir, `${inputFileName}-mono.pdf`), join(tempDir, `${inputFileName}-mono.pdf`),
];
// 檢查各種可能的 dual 輸出檔案名稱
const possibleDualOutputs = [
dualPath,
join(tempDir, `${inputFileName}-dual.pdf`), join(tempDir, `${inputFileName}-dual.pdf`),
]; ];
let foundOutput = false; let foundMono = false;
for (const outputPath of possibleOutputs) { let foundDual = false;
// 複製 mono翻譯版
for (const outputPath of possibleMonoOutputs) {
if (existsSync(outputPath)) { if (existsSync(outputPath)) {
copyFileSync(outputPath, translatedDest); copyFileSync(outputPath, translatedDest);
foundOutput = true; foundMono = true;
console.log(`[PDFMathTranslate] Using output: ${outputPath}`); console.log(`[PDFMathTranslate] Copied mono (translated): ${outputPath}`);
break;
}
}
// 複製 dual對照版
for (const outputPath of possibleDualOutputs) {
if (existsSync(outputPath)) {
copyFileSync(outputPath, bilingualDest);
foundDual = true;
console.log(`[PDFMathTranslate] Copied dual (bilingual): ${outputPath}`);
break; break;
} }
} }
// 如果找不到預期的輸出檔案,嘗試找任何 PDF // 如果找不到預期的輸出檔案,嘗試找任何 PDF
if (!foundOutput) { if (!foundMono && !foundDual) {
const allFiles = readdirSync(tempDir); const allFiles = readdirSync(tempDir);
const pdfFiles = allFiles.filter((f) => f.endsWith(".pdf") && f !== basename(filePath)); const pdfFiles = allFiles.filter((f) => f.endsWith(".pdf") && f !== basename(filePath));
const firstPdfName = pdfFiles[0];
for (const pdfName of pdfFiles) {
if (firstPdfName) { const pdfPath = join(tempDir, pdfName);
const firstPdf = join(tempDir, firstPdfName); if (pdfName.includes("mono") || pdfName.includes("translated")) {
copyFileSync(firstPdf, translatedDest); copyFileSync(pdfPath, translatedDest);
foundOutput = true; foundMono = true;
console.log(`[PDFMathTranslate] Using fallback output: ${firstPdf}`); console.log(`[PDFMathTranslate] Using fallback mono: ${pdfPath}`);
} else if (pdfName.includes("dual") || pdfName.includes("bilingual")) {
copyFileSync(pdfPath, bilingualDest);
foundDual = true;
console.log(`[PDFMathTranslate] Using fallback dual: ${pdfPath}`);
}
} }
} }
if (!foundOutput) { if (!foundMono && !foundDual) {
throw new Error("No translated PDF output found"); throw new Error("No translated PDF output found (neither mono nor dual)");
} }
console.log(`[PDFMathTranslate] Archive contents: mono=${foundMono}, dual=${foundDual}`);
// 8. 建立 .tar 封裝 // 8. 建立 .tar 封裝
const tarPath = getArchiveFileName(targetPath); const tarPath = getArchiveFileName(targetPath);
const tarDir = dirname(tarPath); const tarDir = dirname(tarPath);

View file

@ -31,10 +31,11 @@ export const normalizeOutputFiletype = (filetype: string): string => {
case "markdown_mmd": case "markdown_mmd":
case "markdown": case "markdown":
return "md"; return "md";
// MinerU output formats - output as tar.gz // MinerU output formats - 保持原始格式名稱(.tar 由 main.ts 自動添加)
// 因為 MinerU 是 archive-only 引擎outputMode: "archive"
case "md-t": case "md-t":
case "md-i": case "md-i":
return "tar.gz"; return lowercaseFiletype;
default: default:
return lowercaseFiletype; return lowercaseFiletype;
} }

View file

@ -1,3 +1,4 @@
import { existsSync } from "node:fs";
import { Elysia } from "elysia"; import { Elysia } from "elysia";
import sanitize from "sanitize-filename"; import sanitize from "sanitize-filename";
import { outputDir } from ".."; import { outputDir } from "..";
@ -10,7 +11,7 @@ export const download = new Elysia()
.use(userService) .use(userService)
.get( .get(
"/download/:userId/:jobId/:fileName", "/download/:userId/:jobId/:fileName",
async ({ params, redirect, user }) => { async ({ params, redirect, set, user }) => {
const userId = user.id; const userId = user.id;
const job = await db const job = await db
.query("SELECT * FROM jobs WHERE user_id = ? AND id = ?") .query("SELECT * FROM jobs WHERE user_id = ? AND id = ?")
@ -24,6 +25,14 @@ export const download = new Elysia()
const fileName = sanitize(decodeURIComponent(params.fileName)); const fileName = sanitize(decodeURIComponent(params.fileName));
const filePath = `${outputDir}${userId}/${jobId}/${fileName}`; const filePath = `${outputDir}${userId}/${jobId}/${fileName}`;
// 檢查檔案是否存在
if (!existsSync(filePath)) {
console.error(`[Download] File not found: ${filePath}`);
set.status = 404;
return { error: "File not found", path: filePath };
}
return Bun.file(filePath); return Bun.file(filePath);
}, },
{ {
@ -32,7 +41,7 @@ export const download = new Elysia()
) )
.get( .get(
"/archive/:jobId", "/archive/:jobId",
async ({ params, redirect, user }) => { async ({ params, redirect, set, user }) => {
const userId = user.id; const userId = user.id;
const job = await db const job = await db
.query("SELECT * FROM jobs WHERE user_id = ? AND id = ?") .query("SELECT * FROM jobs WHERE user_id = ? AND id = ?")
@ -45,9 +54,23 @@ export const download = new Elysia()
const jobId = decodeURIComponent(params.jobId); const jobId = decodeURIComponent(params.jobId);
const outputPath = `${outputDir}${userId}/${jobId}`; const outputPath = `${outputDir}${userId}/${jobId}`;
// 檢查輸出目錄是否存在
if (!existsSync(outputPath)) {
console.error(`[Archive] Output directory not found: ${outputPath}`);
set.status = 404;
return { error: "Output directory not found", path: outputPath };
}
// 使用統一的封裝管理器建立 .tar不壓縮 // 使用統一的封裝管理器建立 .tar不壓縮
const outputTar = await createJobArchive(outputPath, jobId); const outputTar = await createJobArchive(outputPath, jobId);
// 檢查 archive 是否成功建立
if (!existsSync(outputTar)) {
console.error(`[Archive] Failed to create archive: ${outputTar}`);
set.status = 500;
return { error: "Failed to create archive", path: outputTar };
}
return Bun.file(outputTar); return Bun.file(outputTar);
}, },
{ {

View file

@ -97,35 +97,41 @@ function ResultsArticle({
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{files.map((file) => ( {files.map((file) => {
<tr> const isTarFile = file.output_file_name.endsWith(".tar");
<td safe class="max-w-[20vw] truncate"> return (
{file.output_file_name} <tr>
</td> <td safe class="max-w-[20vw] truncate">
<td safe>{file.status}</td> {file.output_file_name}
<td class="flex flex-row gap-4"> </td>
<a <td safe>{file.status}</td>
class={` <td class="flex flex-row gap-4">
text-accent-500 underline {/* Hide preview icon for .tar files */}
hover:text-accent-400 {!isTarFile && (
`} <a
href={`${WEBROOT}/download/${outputPath}${file.output_file_name}`} class={`
> text-accent-500 underline
<EyeIcon /> hover:text-accent-400
</a> `}
<a href={`${WEBROOT}/download/${outputPath}${file.output_file_name}`}
class={` >
text-accent-500 underline <EyeIcon />
hover:text-accent-400 </a>
`} )}
href={`${WEBROOT}/download/${outputPath}${file.output_file_name}`} <a
download={file.output_file_name} class={`
> text-accent-500 underline
<DownloadIcon /> hover:text-accent-400
</a> `}
</td> href={`${WEBROOT}/download/${outputPath}${file.output_file_name}`}
</tr> download={file.output_file_name}
))} >
<DownloadIcon />
</a>
</td>
</tr>
);
})}
</tbody> </tbody>
</table> </table>
</article> </article>

View file

@ -255,7 +255,7 @@ describe("PDFMathTranslate converter - Output structure", () => {
} }
}); });
test("should create archive with original.pdf and translated-<lang>.pdf", async () => { test("should create archive with translated-<lang>.pdf and bilingual-<lang>.pdf (no original)", async () => {
let tarSourceDir = ""; let tarSourceDir = "";
let archiveContents: string[] = []; let archiveContents: string[] = [];
@ -271,7 +271,9 @@ describe("PDFMathTranslate converter - Output structure", () => {
if (!existsSync(outputDir)) { if (!existsSync(outputDir)) {
mkdirSync(outputDir, { recursive: true }); mkdirSync(outputDir, { recursive: true });
} }
// 模擬 pdf2zh 產生 mono翻譯版和 dual對照版
writeFileSync(join(outputDir, "input-mono.pdf"), "%PDF-1.4\n%Translated"); writeFileSync(join(outputDir, "input-mono.pdf"), "%PDF-1.4\n%Translated");
writeFileSync(join(outputDir, "input-dual.pdf"), "%PDF-1.4\n%Bilingual");
} }
callback(null, "Translation complete", ""); callback(null, "Translation complete", "");
} else if (cmd === "tar") { } else if (cmd === "tar") {
@ -291,9 +293,11 @@ describe("PDFMathTranslate converter - Output structure", () => {
const targetPath = join(testDir, "output.tar"); const targetPath = join(testDir, "output.tar");
await convert(testInputFile, "pdf", "pdf-ko", targetPath, undefined, mockExecFile); await convert(testInputFile, "pdf", "pdf-ko", targetPath, undefined, mockExecFile);
// Verify archive contains expected files // Verify archive contains expected files (翻譯版 + 對照版,不包含原始檔)
expect(archiveContents).toContain("original.pdf");
expect(archiveContents).toContain("translated-ko.pdf"); expect(archiveContents).toContain("translated-ko.pdf");
expect(archiveContents).toContain("bilingual-ko.pdf");
// 不應包含原始檔案
expect(archiveContents).not.toContain("original.pdf");
}); });
test("should only use .tar format, not .tar.gz", async () => { test("should only use .tar format, not .tar.gz", async () => {