"""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()