feat(hns): HNS 물질 DB 데이터 확장 및 임포트 스크립트 개선
This commit is contained in:
부모
9f4a578af3
커밋
26b86a5a4b
@ -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)으로 보강
|
|
||||||
|
|||||||
707
backend/scripts/hns-import/extract-pdf.py
Normal file
707
backend/scripts/hns-import/extract-pdf.py
Normal file
@ -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'[이oO0]', '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이이아오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이이아oO0-9\-—-"\'\. ]{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이이아oO0-9\-—-"\'\. ]{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
불러오는 중...
Reference in New Issue
Block a user