/** * 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; const PARSE_TIMEOUT_MS = 30000; 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)) { const buffer = fs.readFileSync(filePath); // 타임아웃 처리 const result = await Promise.race([ parse(buffer), new Promise((_, reject) => setTimeout(() => reject(new Error('파싱 타임아웃 (30초)')), PARSE_TIMEOUT_MS) ), ]); 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: Date.now() - startTime, }, format: ext.slice(1), requires_ocr: false, }); } // 미지원 포맷 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}`); });