/** * kordoc 마이크로서비스 — HWP/HWPX/PDF/텍스트 → Markdown 변환 API */ import express from 'express'; import fs from 'fs'; import path from 'path'; import { parse, detectFormat } from 'kordoc'; const app = express(); const PORT = 3100; // 동시 파싱 제한 — kordoc parse()는 취소 불가(AbortSignal 미지원)이므로 // Promise.race 타임아웃 후 유령 작업이 남을 수 있음. 슬롯 제한으로 누적 방지. const MAX_CONCURRENT_PARSES = 2; const parseJobs = new Map(); // parseId → { state: "running"|"timed_out" } function getParseTimeoutMs(fileSize) { // 10MB당 60초, 최소 60초, 최대 300초 (extract_worker와 동일 공식) return Math.min(300000, Math.max(60000, Math.floor(fileSize / (10 * 1024 * 1024)) * 60000 + 60000)); } function releaseParseSlot(parseId) { if (parseJobs.has(parseId)) { parseJobs.delete(parseId); } } app.use(express.json({ limit: '500mb' })); // 헬스체크 app.get('/health', (req, res) => { res.json({ status: 'ok', service: 'kordoc' }); }); // 지원 포맷 목록 const TEXT_FORMATS = new Set(['.md', '.txt', '.csv', '.json', '.xml', '.html']); const PARSEABLE_FORMATS = new Set(['.hwp', '.hwpx', '.pdf']); const IMAGE_FORMATS = new Set(['.jpg', '.jpeg', '.png', '.tiff', '.tif', '.bmp', '.gif']); /** * 문서 파싱 — 파일 경로를 받아 Markdown으로 변환 */ app.post('/parse', async (req, res) => { const startTime = Date.now(); try { const { filePath } = req.body; if (!filePath) { return res.status(400).json({ error: 'filePath is required' }); } // 파일 존재 확인 if (!fs.existsSync(filePath)) { return res.status(404).json({ error: `파일을 찾을 수 없습니다: ${filePath}` }); } const ext = path.extname(filePath).toLowerCase(); const stat = fs.statSync(filePath); // 100MB 초과 파일 거부 if (stat.size > 100 * 1024 * 1024) { return res.status(413).json({ error: '파일 크기 100MB 초과' }); } // 텍스트 파일 — 직접 읽기 if (TEXT_FORMATS.has(ext)) { const text = fs.readFileSync(filePath, 'utf-8'); return res.json({ success: true, markdown: text, metadata: { format: ext.slice(1), fileSize: stat.size }, format: ext.slice(1), requires_ocr: false, }); } // 이미지 파일 — OCR 필요 플래그 if (IMAGE_FORMATS.has(ext)) { return res.json({ success: true, markdown: '', metadata: { format: ext.slice(1), fileSize: stat.size }, format: ext.slice(1), requires_ocr: true, }); } // HWP/HWPX/PDF — kordoc 파싱 if (PARSEABLE_FORMATS.has(ext)) { if (parseJobs.size >= MAX_CONCURRENT_PARSES) { console.warn(`[PARSE] REJECTED ${filePath} — concurrent limit (${parseJobs.size}/${MAX_CONCURRENT_PARSES})`); return res.status(503).json({ error: `동시 파싱 한도 초과 (${MAX_CONCURRENT_PARSES}건), 재시도 필요` }); } const buffer = fs.readFileSync(filePath); const parseTimeout = getParseTimeoutMs(stat.size); const parseId = `${path.basename(filePath)}-${Date.now()}`; parseJobs.set(parseId, { state: 'running' }); console.log(`[PARSE] START ${parseId} ext=${ext} size=${(stat.size / (1024 * 1024)).toFixed(1)}MB timeout=${parseTimeout / 1000}s active=${parseJobs.size}`); let timeoutHandle; try { const result = await Promise.race([ parse(buffer).finally(() => { // timed_out 상태에서만 여기서 슬롯 해제 — 정상 경로는 아래에서 처리 const job = parseJobs.get(parseId); if (job && job.state === 'timed_out') { releaseParseSlot(parseId); console.warn(`[PARSE] ZOMBIE_DONE ${parseId} active=${parseJobs.size}`); } }), new Promise((_, reject) => { timeoutHandle = setTimeout(() => { const job = parseJobs.get(parseId); if (job) job.state = 'timed_out'; // 슬롯은 유령 완료 시점(finally)에서 해제 — 여기서 빼지 않음 reject(new Error(`파싱 타임아웃 (${parseTimeout / 1000}초, ${(stat.size / (1024 * 1024)).toFixed(1)}MB, ext=${ext.slice(1)})`)); }, parseTimeout); }), ]); // 정상 완료: 타이머 정리 + 슬롯 해제 clearTimeout(timeoutHandle); releaseParseSlot(parseId); const elapsed = Date.now() - startTime; console.log(`[PARSE] DONE ${parseId} elapsed=${elapsed}ms chars=${(result.markdown || '').length} active=${parseJobs.size}`); if (!result.success) { return res.status(422).json({ error: '문서 파싱 실패', warnings: result.warnings || [], }); } return res.json({ success: true, markdown: result.markdown || '', metadata: { ...(result.metadata || {}), format: ext.slice(1), fileSize: stat.size, parseTime: elapsed, }, format: ext.slice(1), requires_ocr: false, }); } catch (parseErr) { // 비-timeout 에러: 타이머 정리 + 슬롯 해제 const job = parseJobs.get(parseId); if (job && job.state !== 'timed_out') { clearTimeout(timeoutHandle); releaseParseSlot(parseId); } // timeout 에러: 타이머는 이미 발화됨, 슬롯은 parse().finally()에서 해제 예정 throw parseErr; } } // 미지원 포맷 return res.json({ success: true, markdown: '', metadata: { format: ext.slice(1), fileSize: stat.size }, format: ext.slice(1), requires_ocr: false, unsupported: true, }); } catch (err) { console.error(`[ERROR] /parse: ${err.message}`); res.status(500).json({ error: err.message }); } }); // 문서 비교 app.post('/compare', async (req, res) => { try { const { filePathA, filePathB } = req.body; if (!filePathA || !filePathB) { return res.status(400).json({ error: 'filePathA and filePathB are required' }); } // TODO: Phase 2에서 kordoc compare 구현 return res.json({ diffs: [], message: 'compare는 Phase 2에서 구현 예정' }); } catch (err) { res.status(500).json({ error: err.message }); } }); app.listen(PORT, () => { console.log(`kordoc-service listening on port ${PORT}`); });