feat(hns): HNS 물질 DB 데이터 확장 및 임포트 스크립트 개선

This commit is contained in:
jeonghyo.k 2026-04-17 11:00:46 +09:00
부모 9f4a578af3
커밋 26b86a5a4b
5개의 변경된 파일3790개의 추가작업 그리고 2964개의 파일을 삭제

파일 보기

@ -5,23 +5,31 @@
## 파이프라인 구조 ## 파이프라인 구조
``` ```
[Excel xlsm] [Excel xlsm] [PDF 물질정보집 (193종)]
├─ (1) extract-excel.py → out/base.json (1,345종 기본 정보) ├─ (1) extract-excel.py (2b) extract-pdf.py
└─ (2) extract-images.py → out/images/*.png (225종 상세 카드 이미지) │ → out/base.json → out/pdf-data.json
└─ (2a) extract-images.py ──────────────────────────┐
(3) ocr-images.ts → out/ocr.json (Claude Vision → 물성/위험도/EmS JSON) → out/images/*.png │
↓ │
(4) merge-data.ts → frontend/src/data/hnsSubstanceData.json (3) ocr-images.ts │
→ out/ocr.json │
(5) tsx src/db/seedHns.ts → HNS_SUBSTANCE 테이블 ↓ ↓
(4) merge-data.ts ←──────────────┘
→ frontend/src/data/hnsSubstanceData.json
(5) tsx src/db/seedHns.ts
→ HNS_SUBSTANCE 테이블
``` ```
**병합 우선순위**: `pdf-data.json` > `base.json` > `ocr.json`
## 전제 조건 ## 전제 조건
- Python 3.9+ with `openpyxl` - Python 3.9+ with `openpyxl`, `PyMuPDF(fitz)`
- Node.js 20 - Node.js 20
- `ANTHROPIC_API_KEY` 환경변수 (Claude Vision API) - `ANTHROPIC_API_KEY` 환경변수 (Claude Vision API, OCR 실행 시에만 필요)
- Excel 원본 파일: `C:\Projects\MeterialDB\유해물질 화물적부도 검색툴.xlsm` - Excel 원본: `C:\Projects\MeterialDB\유해물질 화물적부도 검색툴.xlsm`
- PDF 원본: `C:\Projects\MeterialDB\해상화학사고_대응_물질정보집.pdf`
## 실행 순서 ## 실행 순서
@ -36,17 +44,25 @@ python scripts/hns-import/extract-excel.py
- 처리 시트: `화물적부도 화물코드`(1,345개), `동의어`(215개), `IBC CODE`(분류) - 처리 시트: `화물적부도 화물코드`(1,345개), `동의어`(215개), `IBC CODE`(분류)
- 출력: `scripts/hns-import/out/base.json` - 출력: `scripts/hns-import/out/base.json`
### 2) 이미지 225개 추출 ### 2a) 이미지 225개 추출 (선택 — OCR 실행 시만 필요)
```bash ```bash
python scripts/hns-import/extract-images.py python scripts/hns-import/extract-images.py
``` ```
- 출력: - 출력: `out/images/{nameKr}.png`, `out/image-map.json`
- `scripts/hns-import/out/images/{nameKr}.png`
- `scripts/hns-import/out/image-map.json` (파일명↔시트명 매핑)
### 3) Claude Vision OCR ### 2b) PDF 물질정보집 파싱 ★ 권장
```bash
python scripts/hns-import/extract-pdf.py
```
- 입력: `C:\Projects\MeterialDB\해상화학사고_대응_물질정보집.pdf`
- 해양경찰청 발행 193종, 텍스트 직접 추출 (OCR 불필요)
- 출력: `scripts/hns-import/out/pdf-data.json`
### 3) Claude Vision OCR (선택 — pdf-data.json 없을 때 보조)
```bash ```bash
export ANTHROPIC_API_KEY="sk-ant-..." export ANTHROPIC_API_KEY="sk-ant-..."
@ -54,17 +70,17 @@ cd backend
npx tsx scripts/hns-import/ocr-images.ts npx tsx scripts/hns-import/ocr-images.ts
``` ```
- 이미지 한 장당 Claude API 1회 호출 - 이미지 한 장당 Claude API 1회 호출, 동시 5개 병렬
- 동시 5개 병렬, 실패 시 3회 재시도 - 출력: `out/ocr.json` `{ [nameKr]: OcrResult }`
- 출력: `scripts/hns-import/out/ocr.json` `{ [nameKr]: OcrResult }`
### 4) 최종 JSON 병합 ### 4) 최종 JSON 병합
```bash ```bash
cd backend
npx tsx scripts/hns-import/merge-data.ts npx tsx scripts/hns-import/merge-data.ts
``` ```
- 입력: `out/base.json` + `out/ocr.json` - 입력: `out/base.json` + `out/pdf-data.json` + `out/ocr.json` (없으면 건너뜀)
- 출력: `frontend/src/data/hnsSubstanceData.json` (전량 덮어쓰기) - 출력: `frontend/src/data/hnsSubstanceData.json` (전량 덮어쓰기)
### 5) DB 재시드 ### 5) DB 재시드
@ -74,61 +90,51 @@ cd backend
npx tsx src/db/seedHns.ts npx tsx src/db/seedHns.ts
``` ```
- 기존 `DELETE FROM HNS_SUBSTANCE` → 새 1,345종 INSERT - 기존 `DELETE FROM HNS_SUBSTANCE` → 새 514종 INSERT
## 빠른 재실행 (PDF 추출 → 병합 → 시드)
```bash
cd backend
python scripts/hns-import/extract-pdf.py && \
npx tsx scripts/hns-import/merge-data.ts && \
npx tsx src/db/seedHns.ts
```
## 현재 데이터 현황 (2024-04 기준)
| 항목 | 이전 (OCR) | 현재 (PDF) |
|------|-----------|-----------|
| 총 물질 수 | 514종 | 514종 |
| 상세정보 보유 (인화점 있음) | 152종 | 195종 |
| NFPA 코드 있음 | ~150종 | 201종 |
| CAS 번호 있음 | ~380종 | 504종 |
| 해양거동 있음 | ~0종 | 206종 |
| 한국어 유사명 있음 | ~200종 | 449종 |
## 재실행 안내 ## 재실행 안내
- `out/` 디렉토리는 `.gitignore` 처리되어 커밋되지 않음 - `out/` 디렉토리는 `.gitignore` 처리되어 커밋되지 않음
- OCR 결과는 비결정적이므로 재실행 시 약간 달라질 수 있음 - OCR 결과는 비결정적이므로 재실행 시 약간 달라질 수 있음
- 비용 절감을 위해 OCR 결과는 보존하고 `ocr-images.ts --resume` 로 실패 항목만 재시도 가능 - PDF 추출은 결정적(동일 입력 → 동일 출력)이므로 재실행 안전
## 비용/시간 가이드 ## 알려진 이슈
- 이미지 225장 × Claude Sonnet 4.6 기준 약 $3~10 ### 1) PDF 매칭 실패 35종
- 전체 파이프라인: Excel 파싱 ~30초, 이미지 추출 ~10초, OCR ~10~20분, 병합/시드 ~1분
## 알려진 이슈 (후속 작업 필요) PDF 국문명과 base.json 국문명이 달라 매칭되지 않는 항목이 35개 존재.
`out/pdf-unmatched.json`에서 목록 확인 가능. 해당 항목은 OCR 데이터로 보조.
### 1) OCR ↔ base.json 국문명 매칭 실패 (136건)
`merge-data.ts` 실행 시 base.json의 국문명과 `ocr.json` 키 표기가 달라 상세정보가 연결되지 않는 물질이 다수 존재. 실제 514종 중 **상세 정보 보유는 73종** 수준.
**예시:**
| base.json 국문명 | ocr.json 키 |
|---|---|
| `1메톡시2프로판올` | `1-메톡시-2프로판올` |
| `c810이소알코올` | `(C8-10)이소 알코올` |
| `메탄올` | (OCR 없음, 이미지 누락) |
| `가솔린` | `휘발유` (동의어) |
**원인:** **원인:**
- 하이픈/공백/괄호 제거 수준 차이 (Excel 시트명과 이미지 파일명 추출 로직 불일치) - 영문제품명이 국문명으로 등록된 경우 (예: `DER 383 Epoxy resin``디이알 383`)
- 동의어 처리 미흡 (동의어 시트 215개 활용 필요) - 동일 CAS 충돌 (예: `컨덴세이트``나프타`가 같은 CAS)
- OCR 대상 225종 외 나머지 1,120종은 기본정보만 존재 - 표기 차이 (예: `아이소파-G``아이소파 G`)
**권장 해결 방향:** ### 2) 2열 레이아웃 파싱 노이즈 (약 9건)
- `merge-data.ts` 에 정규화 함수 추가 (공백/하이픈/괄호 제거 후 매칭)
- `base.json` 의 동의어(synonyms) 배열을 역인덱스로 활용해 OCR 키와 교차 매칭
- 매칭 실패 목록을 `out/merge-unmatched.json` 으로 출력하여 수동 검토
### 2) SEBC/CAS/UN 번호 varchar 길이 초과 PDF 물질특성 블록이 2열로 구성되어 있어, 일부 항목에서 비중 값이 온도값으로 오추출될 수 있음.
영향 범위 최소 (벤젠 등 9종, 값이 100 이상이면 의심).
### 3) SEBC/CAS/UN 번호 varchar 길이 초과
`base.json` 생성 시 Excel에서 복수 CAS/UN 번호를 줄바꿈으로 결합해 저장하여, `HNS_SUBSTANCE` 테이블의 `VARCHAR(20)` 등 제약을 초과했음. 현재는 [`seedHns.ts`](../../../backend/src/db/seedHns.ts) 의 `firstToken()` 헬퍼로 첫 토큰만 검색 컬럼에 저장하고 원본 전체는 `DATA` JSONB에 보존. `base.json` 생성 시 Excel에서 복수 CAS/UN 번호를 줄바꿈으로 결합해 저장하여, `HNS_SUBSTANCE` 테이블의 `VARCHAR(20)` 등 제약을 초과했음. 현재는 [`seedHns.ts`](../../../backend/src/db/seedHns.ts) 의 `firstToken()` 헬퍼로 첫 토큰만 검색 컬럼에 저장하고 원본 전체는 `DATA` JSONB에 보존.
**영향:**
- `CAS_NO`, `UN_NO` 검색 시 두 번째 이후 번호로는 매칭 불가
- 프론트엔드 표시 시 JSONB의 원본 값을 참조해야 함
**권장 해결 방향:**
- `extract-excel.py` 에서 복수 CAS/UN 을 배열로 분리 저장
- 스키마에 `CAS_NO_LIST TEXT[]`, `UN_NO_LIST TEXT[]` 추가 후 GIN 인덱스 구성
### 3) MSDS 요약시트 포맷 이미지 (약 5건)
원본 xlsm 일부 시트는 표준 HNS 카드가 아닌 KOSHA MSDS 요약시트 포맷으로, 저해상도 + 필드 구조 상이로 OCR 정확도가 떨어짐.
**해당 물질:** 프로필벤젠, 프르푸필 알콜, 프로필렌 클리콜 알긴산, 휘발유, 헥사메틸렌 디이소시안산
**권장 해결 방향:**
- `ANTHROPIC_API_KEY` 설정 후 `HNS_OCR_ONLY` 로 해당 5종만 재OCR
- 이미지 누락 필드는 화학 문헌값(ICSC, PubChem)으로 보강

파일 보기

@ -0,0 +1,707 @@
"""PDF 물질정보집 → pdf-data.json 변환.
원본: C:\\Projects\\MeterialDB\\해상화학사고_대응_물질정보집.pdf
해양경찰청 발행 193 물질 정보
PDF 구조:
- 페이지 1-21: 표지/머리말/목차
- 페이지 22-407: 193 × 2페이지 물질 카드
- 요약 카드 (홀수 순서): 인화점·발화점·증기압·증기밀도·폭발범위·NFPA·해양거동
- 상세 카드 (짝수 순서): 유사명·CAS·UN·GHS분류·물질특성·인체유해성·응급조치
- 물질 NO(1-193) 0-인덱스 시작 페이지: 21 + (NO-1) * 2
출력: out/pdf-data.json
{ [nameKr]: OcrResult } merge-data.ts 동일한 구조
"""
from __future__ import annotations
import io
import json
import os
import re
import sys
from pathlib import Path
import fitz # PyMuPDF
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
SCRIPT_DIR = Path(__file__).parent.resolve()
OUT_DIR = SCRIPT_DIR / 'out'
OUT_DIR.mkdir(exist_ok=True)
PDF_PATH = Path(os.environ.get(
'HNS_PDF_PATH',
r'C:\Projects\MeterialDB\해상화학사고_대응_물질정보집.pdf',
))
# 전각 문자 → 반각 변환 테이블
_FULLWIDTH = str.maketrans(
'()tC°℃ ,',
'()tC℃℃ ,',
)
def clean(s: str) -> str:
"""텍스트 정리."""
if not s:
return ''
s = s.translate(_FULLWIDTH)
# 온도 기호 통일: 仁/七/부/사 → ℃ (OCR 오인식)
s = re.sub(r'(?<=[0-9])\s*[仁七부사   ](?=\s|$|이|이하)', '', s)
s = re.sub(r'(?<=[0-9])\s*[tT](?=\s|$|이|이하)', '', s)
s = re.sub(r'\s+', ' ', s)
return s.strip()
def norm_key(s: str) -> str:
"""정규화 키: 공백/특수문자 제거 + 소문자."""
if not s:
return ''
return re.sub(r'[\s,./\-_()\[\]··]+', '', s).lower()
def normalize_cas(raw: str) -> str:
"""CAS 번호 정규화: OCR 노이즈 제거 후 X-XX-X 형식 반환."""
if not raw:
return ''
# 혼합물
if '혼합물' in raw:
return ''
# 특수 대시 → -
s = raw.replace('', '-').replace('', '-').replace('', '-')
# OCR 오인식: 이,이, 공백 등 → 0
s = re.sub(r'[이oO]', '0', s)
s = re.sub(r'["\'\s ]', '', s) # 잡자 제거
# CAS 포맷 검증 후 반환
m = re.match(r'^(\d{2,7}-\d{2}-\d)$', s)
if m:
return m.group(1).lstrip('0') or '0' # 앞자리 0 제거
# 완전히 일치 안 하면 숫자+대시만 남기고 검증
s2 = re.sub(r'[^0-9\-]', '', s)
m2 = re.match(r'^(\d{2,7}-\d{2}-\d)$', s2)
if m2:
return m2.group(1).lstrip('0') or '0'
return ''
def find_cas_in_text(text: str) -> str:
"""텍스트에서 CAS 번호 패턴 검색."""
# 표준 CAS 패턴: 숫자-숫자2자리-숫자1자리
candidates = re.findall(r'\b(\d{1,7}[\-—-\s]{1,2}\d{2}[\-—-\s]{1,2}\d)\b', text)
for c in candidates:
cas = normalize_cas(c)
if cas and len(cas) >= 5:
return cas
return ''
def parse_nfpa(text: str) -> dict | None:
"""NFPA 코드 파싱: '건강 : 3 화재 : 0 반응 : 1' 형태."""
m = re.search(r'건강\s*[:]\s*(\d)\s*화재\s*[:]\s*(\d)\s*반응\s*[:]\s*(\d)', text)
if m:
return {
'health': int(m.group(1)),
'fire': int(m.group(2)),
'reactivity': int(m.group(3)),
'special': '',
}
# 대안 패턴: 줄바꿈 포함
m2 = re.search(r'건강\s*[:]\s*(\d).*?화재\s*[:]\s*(\d).*?반응\s*[:]\s*(\d)', text, re.DOTALL)
if m2:
return {
'health': int(m2.group(1)),
'fire': int(m2.group(2)),
'reactivity': int(m2.group(3)),
'special': '',
}
return None
def extract_field_after(text: str, label: str, max_chars: int = 80) -> str:
"""레이블 직후 값 추출 (단순 패턴)."""
idx = text.find(label)
if idx < 0:
return ''
snippet = text[idx + len(label): idx + len(label) + max_chars + 50]
# 첫 비공백 줄 추출
lines = snippet.split('\n')
for line in lines:
v = clean(line)
if v and v not in (':', '', ''):
return v[:max_chars]
return ''
def parse_summary_card(text: str, index_entry: dict) -> dict:
"""요약 카드(첫 번째 페이지) 파싱."""
result: dict = {}
# 인화점
m = re.search(r'인화점\s*\n([^\n화발증폭위※]+)', text)
if m:
val = clean(m.group(1))
if val and '위험' not in val and len(val) < 40:
result['flashPoint'] = val
# 발화점
m = re.search(r'발화점\s*\n([^\n화발증폭위※인]+)', text)
if m:
val = clean(m.group(1))
if val and len(val) < 40:
result['autoIgnition'] = val
# 증기압 (요약 카드에서는 값이 더 명확하게 나옴)
m = re.search(r'(?:증기압|흥기압)\s*\n?([^\n증기밀도폭발인화발화]+)', text)
if m:
val = clean(m.group(1))
# 파편화된 텍스트 제거
if val and re.search(r'\d', val) and len(val) < 60:
result['vaporPressure'] = val
# 증기밀도 숫자값
m = re.search(r'증기밀도\s*\n?([0-9][^\n]{0,20})', text)
if m:
val = clean(m.group(1))
if val and len(val) < 20:
result['vaporDensity'] = val
# 폭발범위 (2열 레이아웃으로 값이 레이블에서 멀리 떨어질 수 있어 전문 탐색도 병행)
m = re.search(r'폭발범위\s*\n([^\n위험인화발화※]+)', text)
if m:
val = clean(m.group(1))
if val and '%' in val and len(val) < 30:
result['explosionRange'] = val
# 2열 레이아웃 폴백: 텍스트 전체에서 "숫자~숫자%" 패턴 검색
if not result.get('explosionRange'):
m = re.search(r'(\d+[\.,]?\d*\s*~\s*\d+[\.,]?\d*\s*%)', text)
if m:
result['explosionRange'] = clean(m.group(1))
# 화재시 대피거리
m = re.search(r'화재시\s*대피거리\s*\n?([^\n]+)', text)
if m:
val = clean(m.group(1))
if val:
result['responseDistanceFire'] = val
# 해양거동
m = re.search(r'해양거동\s*\n([^\n상온이격방호방제]+)', text)
if not m:
m = re.search(r'해양거동\s+([^\n]+)', text)
if m:
val = clean(m.group(1))
if val and len(val) < 80:
result['marineResponse'] = val
# 상온상태
m = re.search(r'상온상태\s*\n([^\n이격방호비중색상휘발냄새]+)', text)
if m:
val = clean(m.group(1))
if val and len(val) < 60:
result['state'] = val
# 냄새
m = re.search(r'냄새\s*\n([^\n이격방호색상비중상온휘발]+)', text)
if m:
val = clean(m.group(1))
if val and len(val) < 60:
result['odor'] = val
# 비중
m = re.search(r'비중\s*\n[^\n]*\n([0-9][^\n]{0,25})', text)
if not m:
m = re.search(r'비중\s*\n([0-9][^\n]{0,25})', text)
if m:
val = clean(m.group(1))
if val and len(val) < 30:
result['density'] = val
# 색상
m = re.search(r'색상\s*\n([^\n이격방호냄새비중상온휘발]+)', text)
if m:
val = clean(m.group(1))
if val and len(val) < 40:
result['color'] = val
# 이격거리 / 방호거리 거리 숫자 추출
m_hot = re.search(r'(?:이격거리|Hot\s*Zone).*?\n([^\n방호거리]+(?:\d+m|반경[^\n]+))', text, re.IGNORECASE)
if m_hot:
result['responseDistanceSpillDay'] = clean(m_hot.group(1))
m_warm = re.search(r'(?:방호거리|Warm\s*Zone).*?\n([^\n이격거리]+(?:\d+m|방향[^\n]+))', text, re.IGNORECASE)
if m_warm:
result['responseDistanceSpillNight'] = clean(m_warm.group(1))
return result
def parse_detail_card(text: str) -> dict:
"""상세 카드(두 번째 페이지) 파싱."""
result: dict = {}
# ── nameKr 헤더에서 추출 ──────────────────────────────────────────
# 형식: "001 과산화수소" or "0이 과산화수소"
first_lines = text.strip().split('\n')[:4]
for line in first_lines:
line = line.strip()
# 숫자/OCR숫자로 시작하고 뒤에 한글이 오는 패턴
m = re.match(r'^[0-9이이아오-]{2,3}\s+([\w\s\-,./()]+)$', line)
if m:
candidate = clean(m.group(1).strip())
if candidate and re.search(r'[가-힣A-Za-z]', candidate):
result['nameKr'] = candidate
break
# ── 분류 ──────────────────────────────────────────────────────────
m = re.search(r'(?:유해액체물질|위험물질|석유\s*및|해양환경관리법)[^\n]{0,60}', text)
if m:
result['hazardClass'] = clean(m.group(0))
# ── 물질요약 ───────────────────────────────────────────────────────
# 물질요약 레이블 이후 ~ 유사명/CAS 번호 전까지
m = re.search(r'(?:물질요약|= *닐으서|진 O야)(.*?)(?=유사명|CAS|$)', text, re.DOTALL)
if not m:
# 분류값 이후 ~ 유사명 전
m = re.search(r'(?:유해액체물질|석유 및)[^\n]*\n(.*?)(?=유사명|CAS)', text, re.DOTALL)
if m:
summary = re.sub(r'\s+', ' ', m.group(1)).strip()
if summary and len(summary) > 15:
result['materialSummary'] = summary[:500]
# ── 유사명 ─────────────────────────────────────────────────────────
m = re.search(r'유사명\s*\n?(.*?)(?=CAS|UN\s*번호|\d{4,7}-\d{2}-\d|분자식|$)', text, re.DOTALL)
if m:
synonyms_raw = re.sub(r'\s+', ' ', m.group(1)).strip()
# CAS 번호 형태면 제외
if synonyms_raw and not re.match(r'^\d{4,7}-\d{2}-\d', synonyms_raw) and len(synonyms_raw) < 300:
result['synonymsKr'] = synonyms_raw
# ── CAS 번호 ────────────────────────────────────────────────────────
# 1순위: "CAS번호" / "CAS 번호" 직후 줄
m = re.search(r'CAS\s*번호\s*\n\s*([^\n분자NFPA용도인화발화물질]+)', text)
if not m:
m = re.search(r'CAS\s*번호\s*([0-9][^\n분자NFPA용도인화발화물질]{4,20})', text)
if m:
cas = normalize_cas(m.group(1).strip().split()[0])
if cas:
result['casNumber'] = cas
# 2순위: 텍스트 전체에서 CAS 패턴 검색
if not result.get('casNumber'):
cas = find_cas_in_text(text)
if cas:
result['casNumber'] = cas
# ── UN 번호 ─────────────────────────────────────────────────────────
# NFPA 코드 이후 줄에 있는 4자리 숫자
m = re.search(r'(?:UN\s*번호|UN번호)\s*\n?\s*([0-9]{3,4})', text)
if not m:
# NFPA 다음 4자리 숫자
m = re.search(r'반응\s*[:]\s*\d\s*\n\s*([0-9]{3,4})\s*\n', text)
if m:
result['unNumber'] = m.group(1).strip()
# ── NFPA 코드 ───────────────────────────────────────────────────────
nfpa = parse_nfpa(text)
if nfpa:
result['nfpa'] = nfpa
# ── 용도 ────────────────────────────────────────────────────────────
m = re.search(r'용도\s*\n(.*?)(?=물질특성|인체\s*유해|인체유해|흡입노출|보호복|초동|$)', text, re.DOTALL)
if m:
usage = re.sub(r'\s+', ' ', m.group(1)).strip()
# GHS 마크(특수문자 블록) 제거
usage = re.sub(r'<[^>]*>|[♦◆◇△▲▼▽★☆■□●○◐◑]+', '', usage).strip()
if usage and len(usage) < 200:
result['usage'] = usage
# ── 물질특성 블록 ───────────────────────────────────────────────────
props_start = text.find('물질특성')
props_end = text.find('인체 유해성')
if props_end < 0:
props_end = text.find('인체유해성')
if props_end < 0:
props_end = text.find('흡입노출')
props_text = text[props_start:props_end] if 0 <= props_start < props_end else text
# 인화점 (상세) — 단일 알파벳(X/O 등 위험도 마크) 제외, 숫자 포함 값만 허용
m = re.search(r'인화점\s+([^\n발화끓는수용상온]+)', props_text)
if not m:
m = re.search(r'인화점\s*\n\s*([^\n발화끓는수용상온]+)', props_text)
if m:
val = clean(m.group(1))
if val and len(val) < 40 and (re.search(r'\d', val) or re.search(r'없음|해당없음|N/A', val)):
result['flashPoint'] = val
# 발화점 (상세) — 숫자 포함 값만 허용
m = re.search(r'발화점\s+([^\n인화끓는수용상온]+)', props_text)
if not m:
m = re.search(r'발화점\s*\n\s*([^\n인화끓는수용상온]+)', props_text)
if m:
val = clean(m.group(1))
if val and len(val) < 40 and (re.search(r'\d', val) or re.search(r'없음|해당없음|N/A', val)):
result['autoIgnition'] = val
# 끓는점
m = re.search(r'끓는점\s+([^\n인화발화수용상온]+)', props_text)
if not m:
m = re.search(r'끓는점\s*\n\s*([^\n인화발화수용상온]+)', props_text)
if m:
val = clean(m.group(1))
if val and len(val) < 40:
result['boilingPoint'] = val
# 수용해도
m = re.search(r'수용해도\s+([^\n인화발화끓는상온]+)', props_text)
if not m:
m = re.search(r'수용해도\s*\n\s*([^\n인화발화끓는상온]+)', props_text)
if m:
val = clean(m.group(1))
if val and len(val) < 50:
result['solubility'] = val
# 상온상태 (상세)
m = re.search(r'상온상태\s+([^\n색상냄새비중증기인화발화]+)', props_text)
if not m:
m = re.search(r'상온상태\s*\n\s*([^\n색상냄새비중증기인화발화]+)', props_text)
if m:
val = clean(m.group(1)).strip('()')
if val and len(val) < 60:
result['state'] = val
# 색상 (상세)
m = re.search(r'색상\s+([^\n상온냄새비중증기인화발화]+)', props_text)
if not m:
m = re.search(r'색상\s*\n\s*([^\n상온냄새비중증기인화발화]+)', props_text)
if m:
val = clean(m.group(1))
if val and len(val) < 40:
result['color'] = val
# 냄새 (상세)
m = re.search(r'냄새\s+([^\n상온색상비중증기인화발화]+)', props_text)
if not m:
m = re.search(r'냄새\s*\n\s*([^\n상온색상비중증기인화발화]+)', props_text)
if m:
val = clean(m.group(1))
if val and len(val) < 60:
result['odor'] = val
# 비중 (상세)
m = re.search(r'비중\s+([0-9][^\n증기점도휘발]{0,25})', props_text)
if not m:
m = re.search(r'비중\s*\n\s*([0-9][^\n증기점도휘발]{0,25})', props_text)
if m:
val = clean(m.group(1))
if val and len(val) < 30:
result['density'] = val
# 증기압 (상세)
m = re.search(r'증기압\s+([^\n증기밀도점도휘발]{3,40})', props_text)
if not m:
m = re.search(r'증기압\s*\n\s*([^\n증기밀도점도휘발]{3,40})', props_text)
if m:
val = clean(m.group(1))
if val and re.search(r'\d', val):
result['vaporPressure'] = val
# 증기밀도 (상세)
m = re.search(r'증기밀도\s+([0-9,\.][^\n]{0,15})', props_text)
if not m:
m = re.search(r'증기밀도\s*\n\s*([0-9,\.][^\n]{0,15})', props_text)
if m:
val = clean(m.group(1))
if val and len(val) < 20:
result['vaporDensity'] = val
# 점도
m = re.search(r'점도\s+([0-9][^\n]{0,25})', props_text)
if not m:
m = re.search(r'점도\s*\n\s*([0-9][^\n]{0,25})', props_text)
if m:
val = clean(m.group(1))
if val and len(val) < 30:
result['viscosity'] = val
# ── 인체유해성 블록 ─────────────────────────────────────────────────
hazard_start = max(text.find('인체 유해성'), text.find('인체유해성'))
if hazard_start < 0:
hazard_start = text.find('급성독성')
response_start = text.find('초동대응')
hazard_text = text[hazard_start:response_start] if 0 <= hazard_start < response_start else ''
# IDLH
m = re.search(r'I?DLH[^\n]{0,20}\n?\s*([0-9][^\n]{0,20})', hazard_text or text)
if m:
val = clean(m.group(1))
if val and re.search(r'\d', val):
result['idlh'] = val
# TWA
m = re.search(r'TWA[^\n]{0,20}\n?\s*([0-9][^\n]{0,20})', hazard_text or text)
if m:
val = clean(m.group(1))
if val and re.search(r'\d', val):
result['twa'] = val
# ── 응급조치 ─────────────────────────────────────────────────────────
fa_start = text.find('흡입노출')
fa_end = text.find('초동대응')
if fa_start >= 0:
fa_text = text[fa_start: fa_end if fa_end > fa_start else fa_start + 600]
fa = re.sub(r'\s+', ' ', fa_text).strip()
result['msds'] = {
'firstAid': fa[:600],
'spillResponse': '',
'hazard': '',
'fireFighting': '',
'exposure': '',
'regulation': '',
}
# ── 초동대응 - 이격거리/방호거리 (상세카드에서) ─────────────────────
m = re.search(r'초기\s*이격거리[^\n]{0,10}m[^\n]{0,5}\n?\s*([0-9]+)', text)
if m:
result['responseDistanceSpillDay'] = m.group(1) + 'm'
m = re.search(r'방호거리[^\n]{0,10}m[^\n]{0,5}\n?\s*([0-9]+)', text)
if m:
result['responseDistanceSpillNight'] = m.group(1) + 'm'
m = re.search(r'화재\s*시\s*대피거리[^\n]{0,10}m[^\n]{0,5}\n?\s*([0-9]+)', text)
if m:
result['responseDistanceFire'] = m.group(1) + 'm'
# ── GHS 분류 ─────────────────────────────────────────────────────────
ghs_items = re.findall(
r'(?:인화성[^\n(]{2,40}|급성독성[^\n(]{2,40}|피부부식[^\n(]{2,40}|'
r'\s*손상[^\n(]{2,40}|발암성[^\n(]{2,40}|생식독성[^\n(]{2,40}|'
r'수생환경[^\n(]{2,40}|특정표적[^\n(]{2,40}|흡인유해[^\n(]{2,40})',
text,
)
if ghs_items:
result['ghsClass'] = ' / '.join(clean(g) for g in ghs_items[:6])
return result
def parse_index_pages(pdf: fitz.Document) -> dict[int, dict]:
"""목차 페이지(4-21)에서 NO → {nameKr, nameEn, casNumber} 매핑 구축."""
index: dict[int, dict] = {}
for page_idx in range(3, 21):
page = pdf[page_idx]
text = page.get_text()
lines = [ln.strip() for ln in text.split('\n') if ln.strip()]
for i, line in enumerate(lines):
if not re.match(r'^\d{1,3}$', line):
continue
no = int(line)
if not (1 <= no <= 193):
continue
if no in index:
continue
# 탐색 창: NO 앞 1~4줄
cas, name_en, name_kr = '', '', ''
if i >= 1:
# CAS 줄: 숫자-숫자-숫자 패턴 (OCR 노이즈 허용)
raw_cas = lines[i - 1]
cas = normalize_cas(raw_cas) if re.match(r'^[0-9이이아oO-\-—-"\'\. ]{5,30}$|혼합물', raw_cas) else ''
if not cas and '혼합물' in raw_cas:
cas = '혼합물'
if cas or '혼합물' in (lines[i - 1] if i >= 1 else ''):
if i >= 2:
name_en = lines[i - 2]
if i >= 3:
name_kr = lines[i - 3]
elif i >= 2:
# CAS가 없는 경우(매칭 실패) - 줄 이동해서 재탐색
raw_cas2 = lines[i - 2] if i >= 2 else ''
cas = normalize_cas(raw_cas2) if re.match(r'^[0-9이이아oO-\-—-"\'\. ]{5,30}$|혼합물', raw_cas2) else ''
if cas or '혼합물' in raw_cas2:
name_en = lines[i - 1] if i >= 1 else ''
# name_kr는 찾기 어려움
if not name_kr and i >= 3:
# 이름이 공백/짧으면 더 위 줄에서 찾기
for j in range(3, min(6, i + 1)):
cand = lines[i - j]
if re.search(r'[가-힣]', cand) and len(cand) > 1:
name_kr = cand
break
index[no] = {
'no': no,
'nameKr': name_kr,
'nameEn': name_en,
'casNumber': cas if cas != '혼합물' else '',
}
return index
def extract_name_from_summary(text: str) -> tuple[str, str]:
"""요약 카드에서 nameKr, nameEn 추출."""
name_kr, name_en = '', ''
lines = text.strip().split('\n')
# 1~6번 줄에서 한글 이름 탐색 (헤더 "해상화학사고 대응 물질정보집" 이후)
found_header = False
for line in lines:
line = line.strip()
if not line:
continue
# 제목 줄 건너뜀
if '해상화학사고' in line or '대응' in line or '물질정보집' in line:
found_header = True
continue
# 3자리 번호 줄 건너뜀
if re.match(r'^\d{1,3}$', line):
continue
# 한글이 있으면 nameKr 후보
if re.search(r'[가-힣]', line) and len(line) > 1 and '위험' not in line and '분류' not in line:
if not name_kr:
name_kr = clean(line)
# 영문명: (영문명) 형태
m_en = re.search(r'[(]([A-Za-z][^)]{3,60})[)]', line)
if m_en and not name_en:
name_en = clean(m_en.group(1))
if name_kr and name_en:
break
return name_kr, name_en
def parse_substance(pdf: fitz.Document, no: int, index_entry: dict) -> dict | None:
"""물질 번호 no에 해당하는 2페이지를 파싱하여 통합 레코드 반환."""
start_idx = 21 + (no - 1) * 2
if start_idx + 1 >= pdf.page_count:
return None
summary_text = pdf[start_idx].get_text()
detail_text = pdf[start_idx + 1].get_text()
summary = parse_summary_card(summary_text, index_entry)
detail = parse_detail_card(detail_text)
# nameKr 결정 우선순위: 인덱스 > 상세카드 헤더 > 요약카드
name_kr = index_entry.get('nameKr', '')
if not name_kr:
name_kr = detail.get('nameKr', '')
if not name_kr:
name_kr, _ = extract_name_from_summary(summary_text)
# nameEn
name_en = index_entry.get('nameEn', '')
# 통합: detail 우선, 없으면 summary
merged: dict = {
'nameKr': name_kr,
'nameEn': name_en,
}
for key in ['casNumber', 'unNumber', 'usage', 'synonymsKr',
'flashPoint', 'autoIgnition', 'boilingPoint', 'density', 'solubility',
'vaporPressure', 'vaporDensity', 'volatility', 'explosionRange',
'state', 'color', 'odor', 'viscosity', 'idlh', 'twa',
'responseDistanceFire', 'responseDistanceSpillDay', 'responseDistanceSpillNight',
'marineResponse', 'hazardClass', 'ghsClass', 'materialSummary', 'msds']:
detail_val = detail.get(key)
summary_val = summary.get(key)
if detail_val:
merged[key] = detail_val
elif summary_val:
merged[key] = summary_val
# CAS: 인덱스 우선
if index_entry.get('casNumber') and not merged.get('casNumber'):
merged['casNumber'] = index_entry['casNumber']
# NFPA: detail 우선
if 'nfpa' in detail:
merged['nfpa'] = detail['nfpa']
if 'msds' not in merged:
merged['msds'] = {
'firstAid': '', 'spillResponse': '', 'hazard': '',
'fireFighting': '', 'exposure': '', 'regulation': '',
}
merged['_no'] = no
merged['_pageIdx'] = start_idx
return merged
def main() -> None:
if not PDF_PATH.exists():
raise SystemExit(f'PDF 파일 없음: {PDF_PATH}')
print(f'[읽기] {PDF_PATH}')
pdf = fitz.open(str(PDF_PATH))
print(f'[PDF] 총 {pdf.page_count}페이지')
# 1. 인덱스 파싱
print('[인덱스] 목차 페이지 파싱 중...')
index = parse_index_pages(pdf)
print(f'[인덱스] {len(index)}개 항목 발견')
# 2. 물질 카드 파싱
results: dict[str, dict] = {}
failed: list[int] = []
for no in range(1, 194):
entry = index.get(no, {'no': no, 'nameKr': '', 'nameEn': '', 'casNumber': ''})
try:
rec = parse_substance(pdf, no, entry)
if rec:
name_kr = rec.get('nameKr', '')
if name_kr:
key = name_kr
if key in results:
key = f'{name_kr}_{no}'
results[key] = rec
else:
print(f' [경고] NO={no} nameKr 없음 - 건너뜀')
failed.append(no)
except Exception as e:
print(f' [오류] NO={no}: {e}')
failed.append(no)
pdf.close()
# 3. 저장
out_path = OUT_DIR / 'pdf-data.json'
with open(out_path, 'w', encoding='utf-8') as f:
json.dump(results, f, ensure_ascii=False, indent=2)
size_kb = out_path.stat().st_size / 1024
print(f'\n[완료] {out_path} ({size_kb:.0f} KB, {len(results)}종)')
if failed:
print(f'[경고] 파싱 실패 {len(failed)}종: {failed}')
# 4. 통계
with_flash = sum(1 for v in results.values() if v.get('flashPoint'))
with_nfpa = sum(1 for v in results.values() if v.get('nfpa'))
with_cas = sum(1 for v in results.values() if v.get('casNumber'))
with_syn = sum(1 for v in results.values() if v.get('synonymsKr'))
print(f'[통계] 인화점: {with_flash}종, NFPA: {with_nfpa}종, CAS: {with_cas}종, 유사명: {with_syn}')
# 5. 샘플 출력
print('\n[샘플] 주요 항목:')
sample_keys = ['과산화수소', '나프탈렌', '벤젠', '톨루엔']
for k in sample_keys:
if k in results:
v = results[k]
print(f' {k}: fp={v.get("flashPoint","")} nfpa={v.get("nfpa")} cas={v.get("casNumber","")}')
if __name__ == '__main__':
main()

파일 보기

@ -1,9 +1,11 @@
/** /**
* base.json + ocr.json frontend/src/data/hnsSubstanceData.json * base.json + pdf-data.json + ocr.json frontend/src/data/hnsSubstanceData.json
* *
* : 국문명(nameKr) (/ ) * 우선순위: pdf-data (PDF , ) > base.json > ocr.json ( OCR, )
* 규칙: Excel , OCR (OCR이 ) * :
* / base.json OCR . * 1. CAS ( )
* 2. (nameKr)
* 3. (synonymsKr)
*/ */
import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { resolve, dirname } from 'node:path'; import { resolve, dirname } from 'node:path';
@ -12,6 +14,7 @@ import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const OUT_DIR = resolve(__dirname, 'out'); const OUT_DIR = resolve(__dirname, 'out');
const BASE_PATH = resolve(OUT_DIR, 'base.json'); const BASE_PATH = resolve(OUT_DIR, 'base.json');
const PDF_PATH = resolve(OUT_DIR, 'pdf-data.json');
const OCR_PATH = resolve(OUT_DIR, 'ocr.json'); const OCR_PATH = resolve(OUT_DIR, 'ocr.json');
const TARGET_PATH = resolve(__dirname, '../../../frontend/src/data/hnsSubstanceData.json'); const TARGET_PATH = resolve(__dirname, '../../../frontend/src/data/hnsSubstanceData.json');
@ -19,10 +22,19 @@ function normalizeName(s: string | undefined): string {
if (!s) return ''; if (!s) return '';
return s return s
.replace(/\s+/g, '') .replace(/\s+/g, '')
.replace(/[,.·/\-_()[\]]/g, '') .replace(/[,.·/\-_()[\]]/g, '')
.toLowerCase(); .toLowerCase();
} }
function normalizeCas(s: string | undefined): string {
if (!s) return '';
// 앞자리 0 제거 후 정규화
return s
.replace(/[^0-9\-]/g, '')
.replace(/^0+/, '')
.trim();
}
interface NfpaBlock { interface NfpaBlock {
health: number; health: number;
fire: number; fire: number;
@ -89,6 +101,14 @@ interface BaseRecord {
portFrequency: Array<{ port: string; portCode: string; lastImport: string; frequency: string }>; portFrequency: Array<{ port: string; portCode: string; lastImport: string; frequency: string }>;
} }
interface PdfResult {
[key: string]: unknown;
casNumber?: string;
nameKr?: string;
nfpa?: Partial<NfpaBlock>;
msds?: Partial<MsdsBlock>;
}
interface OcrResult { interface OcrResult {
[key: string]: unknown; [key: string]: unknown;
} }
@ -100,8 +120,8 @@ function firstString(...values: Array<unknown>): string {
return ''; return '';
} }
function pickNfpa(ocr: OcrResult): NfpaBlock | null { function pickNfpa(source: PdfResult | OcrResult): NfpaBlock | null {
const n = ocr.nfpa as Partial<NfpaBlock> | undefined; const n = source.nfpa as Partial<NfpaBlock> | undefined;
if (!n || typeof n !== 'object') return null; if (!n || typeof n !== 'object') return null;
const h = Number(n.health); const h = Number(n.health);
const f = Number(n.fire); const f = Number(n.fire);
@ -115,55 +135,70 @@ function pickNfpa(ocr: OcrResult): NfpaBlock | null {
}; };
} }
function pickMsds(ocr: OcrResult, base: MsdsBlock): MsdsBlock { function pickMsds(
const m = (ocr.msds ?? {}) as Partial<MsdsBlock>; pdf: PdfResult | undefined,
ocr: OcrResult | undefined,
base: MsdsBlock,
): MsdsBlock {
const p = (pdf?.msds ?? {}) as Partial<MsdsBlock>;
const o = (ocr?.msds ?? {}) as Partial<MsdsBlock>;
return { return {
hazard: firstString(base.hazard, m.hazard), hazard: firstString(base.hazard, p.hazard, o.hazard),
firstAid: firstString(base.firstAid, m.firstAid), firstAid: firstString(base.firstAid, p.firstAid, o.firstAid),
fireFighting: firstString(base.fireFighting, m.fireFighting), fireFighting: firstString(base.fireFighting, p.fireFighting, o.fireFighting),
spillResponse: firstString(base.spillResponse, m.spillResponse), spillResponse: firstString(base.spillResponse, p.spillResponse, o.spillResponse),
exposure: firstString(base.exposure, m.exposure), exposure: firstString(base.exposure, p.exposure, o.exposure),
regulation: firstString(base.regulation, m.regulation), regulation: firstString(base.regulation, p.regulation, o.regulation),
}; };
} }
function merge(base: BaseRecord, ocr: OcrResult | undefined): BaseRecord { function merge(
if (!ocr) return base; base: BaseRecord,
pdf: PdfResult | undefined,
const nfpaFromOcr = pickNfpa(ocr); ocr: OcrResult | undefined,
): BaseRecord {
const nfpaFromPdf = pdf ? pickNfpa(pdf) : null;
const nfpaFromOcr = ocr ? pickNfpa(ocr) : null;
// pdf NFPA 우선, 없으면 ocr, 없으면 base
const nfpa = nfpaFromPdf ?? nfpaFromOcr ?? base.nfpa;
return { return {
...base, ...base,
transportMethod: firstString(base.transportMethod, ocr.transportMethod), // pdf > base > ocr 우선순위
sebc: firstString(base.sebc, ocr.sebc), unNumber: firstString(pdf?.unNumber, base.unNumber, ocr?.unNumber),
state: firstString(base.state, ocr.state), casNumber: firstString(pdf?.casNumber, base.casNumber, ocr?.casNumber),
color: firstString(base.color, ocr.color), synonymsKr: firstString(pdf?.synonymsKr, base.synonymsKr, ocr?.synonymsKr),
odor: firstString(base.odor, ocr.odor), transportMethod: firstString(base.transportMethod, pdf?.transportMethod, ocr?.transportMethod),
flashPoint: firstString(base.flashPoint, ocr.flashPoint), sebc: firstString(base.sebc, pdf?.sebc, ocr?.sebc),
autoIgnition: firstString(base.autoIgnition, ocr.autoIgnition), usage: firstString(pdf?.usage, base.usage, ocr?.usage),
boilingPoint: firstString(base.boilingPoint, ocr.boilingPoint), state: firstString(pdf?.state, base.state, ocr?.state),
density: firstString(base.density, ocr.density), color: firstString(pdf?.color, base.color, ocr?.color),
solubility: firstString(base.solubility, ocr.solubility), odor: firstString(pdf?.odor, base.odor, ocr?.odor),
vaporPressure: firstString(base.vaporPressure, ocr.vaporPressure), flashPoint: firstString(pdf?.flashPoint, base.flashPoint, ocr?.flashPoint),
vaporDensity: firstString(base.vaporDensity, ocr.vaporDensity), autoIgnition: firstString(pdf?.autoIgnition, base.autoIgnition, ocr?.autoIgnition),
explosionRange: firstString(base.explosionRange, ocr.explosionRange), boilingPoint: firstString(pdf?.boilingPoint, base.boilingPoint, ocr?.boilingPoint),
nfpa: nfpaFromOcr ?? base.nfpa, density: firstString(pdf?.density, base.density, ocr?.density),
hazardClass: firstString(base.hazardClass, ocr.hazardClass), solubility: firstString(pdf?.solubility, base.solubility, ocr?.solubility),
ergNumber: firstString(base.ergNumber, ocr.ergNumber), vaporPressure: firstString(pdf?.vaporPressure, base.vaporPressure, ocr?.vaporPressure),
idlh: firstString(base.idlh, ocr.idlh), vaporDensity: firstString(pdf?.vaporDensity, base.vaporDensity, ocr?.vaporDensity),
aegl2: firstString(base.aegl2, ocr.aegl2), explosionRange: firstString(pdf?.explosionRange, base.explosionRange, ocr?.explosionRange),
erpg2: firstString(base.erpg2, ocr.erpg2), nfpa,
responseDistanceFire: firstString(base.responseDistanceFire, ocr.responseDistanceFire), hazardClass: firstString(pdf?.hazardClass, base.hazardClass, ocr?.hazardClass),
responseDistanceSpillDay: firstString(base.responseDistanceSpillDay, ocr.responseDistanceSpillDay), ergNumber: firstString(base.ergNumber, pdf?.ergNumber, ocr?.ergNumber),
responseDistanceSpillNight: firstString(base.responseDistanceSpillNight, ocr.responseDistanceSpillNight), idlh: firstString(pdf?.idlh, base.idlh, ocr?.idlh),
marineResponse: firstString(base.marineResponse, ocr.marineResponse), aegl2: firstString(base.aegl2, pdf?.aegl2, ocr?.aegl2),
ppeClose: firstString(base.ppeClose, ocr.ppeClose), erpg2: firstString(base.erpg2, pdf?.erpg2, ocr?.erpg2),
ppeFar: firstString(base.ppeFar, ocr.ppeFar), responseDistanceFire: firstString(pdf?.responseDistanceFire, base.responseDistanceFire, ocr?.responseDistanceFire),
msds: pickMsds(ocr, base.msds), responseDistanceSpillDay: firstString(pdf?.responseDistanceSpillDay, base.responseDistanceSpillDay, ocr?.responseDistanceSpillDay),
emsCode: firstString(base.emsCode, ocr.emsCode), responseDistanceSpillNight: firstString(pdf?.responseDistanceSpillNight, base.responseDistanceSpillNight, ocr?.responseDistanceSpillNight),
emsFire: firstString(base.emsFire, ocr.emsFire), marineResponse: firstString(pdf?.marineResponse, base.marineResponse, ocr?.marineResponse),
emsSpill: firstString(base.emsSpill, ocr.emsSpill), ppeClose: firstString(base.ppeClose, pdf?.ppeClose, ocr?.ppeClose),
emsFirstAid: firstString(base.emsFirstAid, ocr.emsFirstAid), ppeFar: firstString(base.ppeFar, pdf?.ppeFar, ocr?.ppeFar),
msds: pickMsds(pdf, ocr, base.msds),
emsCode: firstString(base.emsCode, pdf?.emsCode, ocr?.emsCode),
emsFire: firstString(base.emsFire, pdf?.emsFire, ocr?.emsFire),
emsSpill: firstString(base.emsSpill, pdf?.emsSpill, ocr?.emsSpill),
emsFirstAid: firstString(base.emsFirstAid, pdf?.emsFirstAid, ocr?.emsFirstAid),
}; };
} }
@ -173,79 +208,155 @@ function main() {
console.error('→ extract-excel.py 를 먼저 실행하세요.'); console.error('→ extract-excel.py 를 먼저 실행하세요.');
process.exit(1); process.exit(1);
} }
if (!existsSync(OCR_PATH)) {
console.warn(`ocr.json 없음: ${OCR_PATH} — 상세 데이터 없이 base 만 사용`);
}
const base: BaseRecord[] = JSON.parse(readFileSync(BASE_PATH, 'utf-8')); const base: BaseRecord[] = JSON.parse(readFileSync(BASE_PATH, 'utf-8'));
// PDF 데이터 로드
const pdfRaw: Record<string, PdfResult> = existsSync(PDF_PATH)
? JSON.parse(readFileSync(PDF_PATH, 'utf-8'))
: {};
// OCR 데이터 로드
const ocr: Record<string, OcrResult> = existsSync(OCR_PATH) const ocr: Record<string, OcrResult> = existsSync(OCR_PATH)
? JSON.parse(readFileSync(OCR_PATH, 'utf-8')) ? JSON.parse(readFileSync(OCR_PATH, 'utf-8'))
: {}; : {};
console.log(`[입력] base ${base.length}종, ocr ${Object.keys(ocr).length}`); console.log(
`[입력] base ${base.length}종, pdf ${Object.keys(pdfRaw).length}종, ocr ${Object.keys(ocr).length}`,
);
// OCR 키를 정규화 인덱스로 변환 (정규화키 → OcrResult, 역매핑 normKey → 원본키) // ── PDF 인덱스 구축 ─────────────────────────────────────────────────
const ocrIndex = new Map<string, OcrResult>(); // 1) nameKr 정규화 인덱스
const normToOrig = new Map<string, string>(); const pdfByName = new Map<string, PdfResult>();
for (const [key, value] of Object.entries(ocr)) { // 2) CAS 번호 인덱스
const pdfByCas = new Map<string, PdfResult>();
for (const [key, value] of Object.entries(pdfRaw)) {
const normKey = normalizeName(key); const normKey = normalizeName(key);
if (normKey) { if (normKey) pdfByName.set(normKey, value);
ocrIndex.set(normKey, value);
normToOrig.set(normKey, key); const cas = normalizeCas(value.casNumber);
if (cas) {
if (!pdfByCas.has(cas)) pdfByCas.set(cas, value);
} }
} }
let matched = 0; // ── OCR 인덱스 구축 ─────────────────────────────────────────────────
let matchedBySynonym = 0; const ocrByName = new Map<string, OcrResult>();
const unmatched: string[] = []; const ocrNormToOrig = new Map<string, string>();
for (const [key, value] of Object.entries(ocr)) {
const normKey = normalizeName(key);
if (normKey) {
ocrByName.set(normKey, value);
ocrNormToOrig.set(normKey, key);
}
}
// ── 병합 ──────────────────────────────────────────────────────────
let pdfMatchedByName = 0;
let pdfMatchedByCas = 0;
let pdfMatchedBySynonym = 0;
let ocrMatched = 0;
const pdfUnmatched = new Set(Object.keys(pdfRaw));
const ocrUnmatched = new Set(ocrByName.keys());
const merged = base.map((record) => { const merged = base.map((record) => {
// 1단계: nameKr 정규화 매칭 let pdfResult: PdfResult | undefined;
const key = normalizeName(record.nameKr); let ocrResult: OcrResult | undefined;
const ocrResult = ocrIndex.get(key);
if (ocrResult) { // ── PDF 매칭 ────────────────────────────────────────────────────
matched++; // 1. CAS 번호 매칭 (가장 정확)
ocrIndex.delete(key); const baseCas = normalizeCas(record.casNumber);
return merge(record, ocrResult); if (baseCas) {
pdfResult = pdfByCas.get(baseCas);
if (pdfResult) {
pdfMatchedByCas++;
const origKey = pdfResult.nameKr as string | undefined;
if (origKey) pdfUnmatched.delete(origKey);
}
} }
// 2단계: synonymsKr 동의어 매칭 (" / " 구분자) // 2. nameKr 정규화 매칭
if (record.synonymsKr) { if (!pdfResult) {
const normKr = normalizeName(record.nameKr);
pdfResult = pdfByName.get(normKr);
if (pdfResult) {
pdfMatchedByName++;
const origKey = pdfResult.nameKr as string | undefined;
if (origKey) pdfUnmatched.delete(origKey);
}
}
// 3. synonymsKr 동의어 매칭
if (!pdfResult && record.synonymsKr) {
const synonyms = record.synonymsKr.split(' / '); const synonyms = record.synonymsKr.split(' / ');
for (const syn of synonyms) { for (const syn of synonyms) {
const normSyn = normalizeName(syn); const normSyn = normalizeName(syn);
if (!normSyn) continue; if (!normSyn) continue;
const synOcrResult = ocrIndex.get(normSyn); pdfResult = pdfByName.get(normSyn);
if (synOcrResult) { if (pdfResult) {
matched++; pdfMatchedBySynonym++;
matchedBySynonym++; const origKey = pdfResult.nameKr as string | undefined;
ocrIndex.delete(normSyn); if (origKey) pdfUnmatched.delete(origKey);
return merge(record, synOcrResult); break;
} }
} }
} }
return record; // ── OCR 매칭 (PDF 없는 경우 보조) ────────────────────────────────
const normKr = normalizeName(record.nameKr);
const ocrByNameResult = ocrByName.get(normKr);
if (ocrByNameResult) {
ocrResult = ocrByNameResult;
ocrMatched++;
ocrUnmatched.delete(normKr);
}
if (!ocrResult && record.synonymsKr) {
const synonyms = record.synonymsKr.split(' / ');
for (const syn of synonyms) {
const normSyn = normalizeName(syn);
if (!normSyn) continue;
const synOcrResult = ocrByName.get(normSyn);
if (synOcrResult) {
ocrResult = synOcrResult;
ocrMatched++;
ocrUnmatched.delete(normSyn);
break;
}
}
}
return merge(record, pdfResult, ocrResult);
}); });
// 남은 OCR 키는 base에 매칭 실패한 항목 (원본 키로 복원) // ── 통계 출력 ──────────────────────────────────────────────────────
for (const normKey of ocrIndex.keys()) { const pdfTotal = pdfMatchedByCas + pdfMatchedByName + pdfMatchedBySynonym;
unmatched.push(normToOrig.get(normKey) ?? normKey); console.log(
} `[PDF 매칭] 총 ${pdfTotal}종 (CAS: ${pdfMatchedByCas}, 국문명: ${pdfMatchedByName}, 동의어: ${pdfMatchedBySynonym})`,
);
console.log(`[OCR 매칭] ${ocrMatched}`);
console.log(`[병합] base ↔ ocr 매칭 ${matched}종 (nameKr: ${matched - matchedBySynonym}, 동의어: ${matchedBySynonym})`); if (pdfUnmatched.size > 0) {
if (unmatched.length > 0) { const unmatchedList = Array.from(pdfUnmatched).sort();
const unmatchedPath = resolve(OUT_DIR, 'merge-unmatched.json'); const unmatchedPath = resolve(OUT_DIR, 'pdf-unmatched.json');
writeFileSync(unmatchedPath, JSON.stringify({ count: unmatched.length, keys: unmatched.sort() }, null, 2), 'utf-8'); writeFileSync(
console.warn(`[경고] OCR 매칭 실패 ${unmatched.length}개 → ${unmatchedPath}`); unmatchedPath,
unmatched.slice(0, 20).forEach((k) => console.warn(` - ${k}`)); JSON.stringify({ count: unmatchedList.length, keys: unmatchedList }, null, 2),
if (unmatched.length > 20) console.warn(` ... +${unmatched.length - 20}`); 'utf-8',
);
console.warn(
`[경고] PDF 매칭 실패 ${unmatchedList.length}개 → ${unmatchedPath}`,
);
unmatchedList.slice(0, 10).forEach((k) => console.warn(` - ${k}`));
if (unmatchedList.length > 10) console.warn(` ... +${unmatchedList.length - 10}`);
} }
writeFileSync(TARGET_PATH, JSON.stringify(merged, null, 2), 'utf-8'); writeFileSync(TARGET_PATH, JSON.stringify(merged, null, 2), 'utf-8');
const sizeKb = (JSON.stringify(merged).length / 1024).toFixed(0); const sizeKb = (JSON.stringify(merged).length / 1024).toFixed(0);
console.log(`[완료] ${TARGET_PATH} (${sizeKb} KB, ${merged.length}종)`); console.log(`[완료] ${TARGET_PATH} (${sizeKb} KB, ${merged.length}종)`);
console.log(` 상세 정보 보유: ${merged.filter((r) => r.flashPoint).length}`); console.log(` 상세 정보 보유: ${merged.filter((r) => r.flashPoint).length}`);
console.log(` NFPA 있음: ${merged.filter((r) => r.nfpa.health || r.nfpa.fire || r.nfpa.reactivity).length}`);
} }
main(); main();

파일 보기

@ -19,7 +19,9 @@ export function HmsDetailPanel({
]; ];
const nfpa = s.nfpa; const nfpa = s.nfpa;
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const sebcColor = s.sebc.startsWith('G') const sebcColor = !s.sebc
? 'var(--fg-sub)'
: s.sebc.startsWith('G')
? 'var(--color-accent)' ? 'var(--color-accent)'
: s.sebc.startsWith('E') : s.sebc.startsWith('E')
? 'var(--color-accent)' ? 'var(--color-accent)'

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff