708 lines
27 KiB
Python
708 lines
27 KiB
Python
"""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()
|