/** * Claude Vision API 로 HNS 카드 이미지 → 구조화 JSON 변환. * * 입력: out/images/*.png (222개) * 출력: out/ocr.json { [nameKr]: Partial } * * 환경변수: ANTHROPIC_API_KEY * 모델: claude-sonnet-4-5 (Vision + 비용 효율) * 동시성: 5, 재시도 3회 * * 재실행 시 기존 ocr.json 의 결과는 유지하고 누락된 이미지만 처리한다. */ import 'dotenv/config'; import Anthropic from '@anthropic-ai/sdk'; import { readFileSync, readdirSync, writeFileSync, existsSync } from 'node:fs'; import { resolve, dirname, basename, extname } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const OUT_DIR = resolve(__dirname, 'out'); const IMG_DIR = process.env.HNS_OCR_IMG_DIR ? resolve(process.env.HNS_OCR_IMG_DIR) : resolve(OUT_DIR, 'images'); const OCR_PATH = process.env.HNS_OCR_OUT ? resolve(process.env.HNS_OCR_OUT) : resolve(OUT_DIR, 'ocr.json'); const FAIL_PATH = process.env.HNS_OCR_FAIL ? resolve(process.env.HNS_OCR_FAIL) : resolve(OUT_DIR, 'ocr-failures.json'); const OCR_LIMIT = process.env.HNS_OCR_LIMIT ? parseInt(process.env.HNS_OCR_LIMIT, 10) : undefined; const OCR_ONLY = process.env.HNS_OCR_ONLY ? process.env.HNS_OCR_ONLY.split(',').map((s) => s.trim()).filter(Boolean) : undefined; const CONCURRENCY = 5; const MAX_RETRIES = 3; const MODEL = process.env.HNS_OCR_MODEL ?? 'claude-sonnet-4-5'; const SYSTEM_PROMPT = `당신은 한국 해양 방제용 HNS 비상대응 카드 이미지를 구조화 JSON으로 추출하는 전문 파서입니다. 이미지는 표준 템플릿을 따르며 다음 섹션을 포함합니다: - 상단: 국문명, 영문명 - 물질특성: CAS번호, UN번호, 운송방법, 유사명, 특성(독성/부식성/인화성/유해성), 용도, 상태/색상/냄새, 인화점/발화점/끓는점, 용해도/증기압/증기밀도, 비중/폭발범위, NFPA 다이아몬드(건강/인화/반응), GHS 픽토그램, ERG 지침서 번호 - 대응방법: 주요 장비(PPE 근거리/원거리), 화재 대응(EmS F-x), 해상 유출(EmS S-x), 초기이격거리, 방호활동거리 - 인체유해성: TWA / STEL / AEGL-2 / IDLH, 흡입/피부/안구/경구 증상·응급조치 아래 JSON 스키마를 **엄격히** 준수하여 응답하세요. 값이 없거나 읽을 수 없는 경우 빈 문자열 "" 또는 null. 숫자는 단위 포함 원문 문자열로 유지 (예: "80℃", "2,410 mmHg (25℃)"). NFPA 건강/인화/반응은 0~4 정수. special 은 문자열(특수 표시). 응답은 **순수 JSON 객체만** 반환 (코드블록이나 설명문 없이). 스키마: { "transportMethod": "", "state": "", "color": "", "odor": "", "flashPoint": "", "autoIgnition": "", "boilingPoint": "", "density": "", "solubility": "", "vaporPressure": "", "vaporDensity": "", "explosionRange": "", "nfpa": { "health": 0, "fire": 0, "reactivity": 0, "special": "" }, "hazardClass": "", "ergNumber": "", "idlh": "", "aegl2": "", "erpg2": "", "twa": "", "stel": "", "responseDistanceFire": "", "responseDistanceSpillDay": "", "responseDistanceSpillNight": "", "marineResponse": "", "ppeClose": "", "ppeFar": "", "msds": { "hazard": "", "firstAid": "", "fireFighting": "", "spillResponse": "", "exposure": "", "regulation": "" }, "emsCode": "", "emsFire": "", "emsSpill": "", "emsFirstAid": "", "sebc": "" }`; interface OcrResult { transportMethod?: string; state?: string; color?: string; odor?: string; flashPoint?: string; autoIgnition?: string; boilingPoint?: string; density?: string; solubility?: string; vaporPressure?: string; vaporDensity?: string; explosionRange?: string; nfpa?: { health: number; fire: number; reactivity: number; special: string }; hazardClass?: string; ergNumber?: string; idlh?: string; aegl2?: string; erpg2?: string; twa?: string; stel?: string; responseDistanceFire?: string; responseDistanceSpillDay?: string; responseDistanceSpillNight?: string; marineResponse?: string; ppeClose?: string; ppeFar?: string; msds?: { hazard?: string; firstAid?: string; fireFighting?: string; spillResponse?: string; exposure?: string; regulation?: string; }; emsCode?: string; emsFire?: string; emsSpill?: string; emsFirstAid?: string; sebc?: string; } function loadExisting(path: string, fallback: T): T { if (!existsSync(path)) return fallback; try { return JSON.parse(readFileSync(path, 'utf-8')); } catch { return fallback; } } function extractJson(text: string): OcrResult | null { const cleaned = text.replace(/```json\s*/gi, '').replace(/```\s*$/g, '').trim(); const firstBrace = cleaned.indexOf('{'); const lastBrace = cleaned.lastIndexOf('}'); if (firstBrace < 0 || lastBrace < 0) return null; try { return JSON.parse(cleaned.slice(firstBrace, lastBrace + 1)); } catch { return null; } } async function callVision(client: Anthropic, imagePath: string): Promise { const imageData = readFileSync(imagePath).toString('base64'); const ext = extname(imagePath).slice(1).toLowerCase(); const mediaType = (ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : 'image/png') as | 'image/png' | 'image/jpeg'; const response = await client.messages.create({ model: MODEL, max_tokens: 4096, system: [ { type: 'text', text: SYSTEM_PROMPT, cache_control: { type: 'ephemeral' }, }, ], messages: [ { role: 'user', content: [ { type: 'image', source: { type: 'base64', media_type: mediaType, data: imageData }, }, { type: 'text', text: '이 HNS 비상대응 카드 이미지에서 모든 필드를 추출해 JSON으로 반환하세요.', }, ], }, ], }); const textBlock = response.content.find((b) => b.type === 'text'); if (!textBlock || textBlock.type !== 'text') { throw new Error('응답에 텍스트 블록 없음'); } const result = extractJson(textBlock.text); if (!result) { throw new Error(`JSON 파싱 실패: ${textBlock.text.slice(0, 200)}`); } return result; } async function processWithRetry( client: Anthropic, imagePath: string, nameKr: string, ): Promise { let lastErr: unknown; for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { try { return await callVision(client, imagePath); } catch (err) { lastErr = err; const wait = 1000 * Math.pow(2, attempt - 1); console.warn(`[${nameKr}] 시도 ${attempt} 실패, ${wait}ms 후 재시도: ${String(err).slice(0, 120)}`); await new Promise((r) => setTimeout(r, wait)); } } throw lastErr; } async function runPool(items: T[], worker: (item: T, idx: number) => Promise) { let cursor = 0; const workers = Array.from({ length: CONCURRENCY }, async () => { while (cursor < items.length) { const idx = cursor++; await worker(items[idx], idx); } }); await Promise.all(workers); } async function main() { if (!process.env.ANTHROPIC_API_KEY) { console.error('ANTHROPIC_API_KEY 환경변수가 없습니다.'); process.exit(1); } const client = new Anthropic(); if (!existsSync(IMG_DIR)) { console.error(`이미지 디렉토리 없음: ${IMG_DIR}`); process.exit(1); } const allImages = readdirSync(IMG_DIR).filter((f) => /\.(png|jpg|jpeg)$/i.test(f)); const images = OCR_ONLY ? allImages.filter((f) => OCR_ONLY.includes(basename(f, extname(f)))) : allImages; const existing: Record = loadExisting(OCR_PATH, {}); const failures: Record = loadExisting(FAIL_PATH, {}); let pending = images.filter((f) => { const nameKr = basename(f, extname(f)); return !(nameKr in existing); }); if (OCR_LIMIT && Number.isFinite(OCR_LIMIT)) { pending = pending.slice(0, OCR_LIMIT); } console.log(`[OCR] 전체 ${allImages.length}개 중 대상 ${images.length}개, 이미 처리 ${Object.keys(existing).length}개, 이번 실행 ${pending.length}개`); console.log(`[모델] ${MODEL}, 동시 ${CONCURRENCY}, 재시도 최대 ${MAX_RETRIES}`); console.log(`[출력] ${OCR_PATH}`); let done = 0; let failed = 0; await runPool(pending, async (file, idx) => { const nameKr = basename(file, extname(file)); const path = resolve(IMG_DIR, file); try { const result = await processWithRetry(client, path, nameKr); existing[nameKr] = result; delete failures[nameKr]; done++; if (done % 10 === 0 || done === pending.length) { writeFileSync(OCR_PATH, JSON.stringify(existing, null, 2), 'utf-8'); console.log(` 진행 ${done}/${pending.length} (실패 ${failed}) - 중간 저장`); } } catch (err) { failed++; failures[nameKr] = String(err).slice(0, 500); console.error(`[실패] ${nameKr}: ${String(err).slice(0, 200)}`); } }); writeFileSync(OCR_PATH, JSON.stringify(existing, null, 2), 'utf-8'); writeFileSync(FAIL_PATH, JSON.stringify(failures, null, 2), 'utf-8'); console.log(`\n[완료] 성공 ${Object.keys(existing).length} / 실패 ${Object.keys(failures).length}`); console.log(` OCR 결과: ${OCR_PATH}`); if (Object.keys(failures).length > 0) { console.log(` 실패 목록: ${FAIL_PATH} (재실행하면 실패분만 재시도)`); } } main().catch((err) => { console.error(err); process.exit(1); });