release: 2026-04-17 (320건 커밋) #190
3
.gitignore
vendored
3
.gitignore
vendored
@ -79,6 +79,9 @@ prediction/image/**/*.pth
|
||||
frontend/public/hns-manual/pages/
|
||||
frontend/public/hns-manual/images/
|
||||
|
||||
# HNS import pipeline outputs (local, 1회성 생성물)
|
||||
backend/scripts/hns-import/out/
|
||||
|
||||
# Claude Code (team workflow tracked, override global gitignore)
|
||||
!.claude/
|
||||
.claude/settings.local.json
|
||||
|
||||
53
backend/package-lock.json
generated
53
backend/package-lock.json
generated
@ -22,6 +22,7 @@
|
||||
"pg": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "^0.89.0",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/cors": "^2.8.17",
|
||||
@ -34,6 +35,37 @@
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.89.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.89.0.tgz",
|
||||
"integrity": "sha512-nyGau0zex62EpU91hsHa0zod973YEoiMgzWZ9hC55WdiOLrE4AGpcg4wXI7lFqtvMLqMcLfewQU9sHgQB6psow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json-schema-to-ts": "^3.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"anthropic-ai-sdk": "bin/cli"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"zod": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
||||
@ -1689,6 +1721,20 @@
|
||||
"bignumber.js": "^9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-to-ts": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
|
||||
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"ts-algebra": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||
@ -2618,6 +2664,13 @@
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-algebra": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
|
||||
@ -23,6 +23,7 @@
|
||||
"pg": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "^0.89.0",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/cors": "^2.8.17",
|
||||
|
||||
134
backend/scripts/hns-import/README.md
Normal file
134
backend/scripts/hns-import/README.md
Normal file
@ -0,0 +1,134 @@
|
||||
# HNS 물질 Import 파이프라인
|
||||
|
||||
`C:\Projects\MeterialDB\유해물질 화물적부도 검색툴.xlsm` 외부 자료를 `HNS_SUBSTANCE` DB로 변환하는 1회성 파이프라인.
|
||||
|
||||
## 파이프라인 구조
|
||||
|
||||
```
|
||||
[Excel xlsm]
|
||||
├─ (1) extract-excel.py → out/base.json (1,345종 기본 정보)
|
||||
└─ (2) extract-images.py → out/images/*.png (225종 상세 카드 이미지)
|
||||
↓
|
||||
(3) ocr-images.ts → out/ocr.json (Claude Vision → 물성/위험도/EmS JSON)
|
||||
↓
|
||||
(4) merge-data.ts → frontend/src/data/hnsSubstanceData.json
|
||||
↓
|
||||
(5) tsx src/db/seedHns.ts → HNS_SUBSTANCE 테이블
|
||||
```
|
||||
|
||||
## 전제 조건
|
||||
|
||||
- Python 3.9+ with `openpyxl`
|
||||
- Node.js 20
|
||||
- `ANTHROPIC_API_KEY` 환경변수 (Claude Vision API)
|
||||
- Excel 원본 파일: `C:\Projects\MeterialDB\유해물질 화물적부도 검색툴.xlsm`
|
||||
|
||||
## 실행 순서
|
||||
|
||||
### 1) Excel 메타 시트 파싱
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python scripts/hns-import/extract-excel.py
|
||||
```
|
||||
|
||||
- 입력: `C:\Projects\MeterialDB\유해물질 화물적부도 검색툴.xlsm`
|
||||
- 처리 시트: `화물적부도 화물코드`(1,345개), `동의어`(215개), `IBC CODE`(분류)
|
||||
- 출력: `scripts/hns-import/out/base.json`
|
||||
|
||||
### 2) 이미지 225개 추출
|
||||
|
||||
```bash
|
||||
python scripts/hns-import/extract-images.py
|
||||
```
|
||||
|
||||
- 출력:
|
||||
- `scripts/hns-import/out/images/{nameKr}.png`
|
||||
- `scripts/hns-import/out/image-map.json` (파일명↔시트명 매핑)
|
||||
|
||||
### 3) Claude Vision OCR
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_API_KEY="sk-ant-..."
|
||||
cd backend
|
||||
npx tsx scripts/hns-import/ocr-images.ts
|
||||
```
|
||||
|
||||
- 이미지 한 장당 Claude API 1회 호출
|
||||
- 동시 5개 병렬, 실패 시 3회 재시도
|
||||
- 출력: `scripts/hns-import/out/ocr.json` `{ [nameKr]: OcrResult }`
|
||||
|
||||
### 4) 최종 JSON 병합
|
||||
|
||||
```bash
|
||||
npx tsx scripts/hns-import/merge-data.ts
|
||||
```
|
||||
|
||||
- 입력: `out/base.json` + `out/ocr.json`
|
||||
- 출력: `frontend/src/data/hnsSubstanceData.json` (전량 덮어쓰기)
|
||||
|
||||
### 5) DB 재시드
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npx tsx src/db/seedHns.ts
|
||||
```
|
||||
|
||||
- 기존 `DELETE FROM HNS_SUBSTANCE` → 새 1,345종 INSERT
|
||||
|
||||
## 재실행 안내
|
||||
|
||||
- `out/` 디렉토리는 `.gitignore` 처리되어 커밋되지 않음
|
||||
- OCR 결과는 비결정적이므로 재실행 시 약간 달라질 수 있음
|
||||
- 비용 절감을 위해 OCR 결과는 보존하고 `ocr-images.ts --resume` 로 실패 항목만 재시도 가능
|
||||
|
||||
## 비용/시간 가이드
|
||||
|
||||
- 이미지 225장 × Claude Sonnet 4.6 기준 약 $3~10
|
||||
- 전체 파이프라인: Excel 파싱 ~30초, 이미지 추출 ~10초, OCR ~10~20분, 병합/시드 ~1분
|
||||
|
||||
## 알려진 이슈 (후속 작업 필요)
|
||||
|
||||
### 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 시트명과 이미지 파일명 추출 로직 불일치)
|
||||
- 동의어 처리 미흡 (동의어 시트 215개 활용 필요)
|
||||
- OCR 대상 225종 외 나머지 1,120종은 기본정보만 존재
|
||||
|
||||
**권장 해결 방향:**
|
||||
- `merge-data.ts` 에 정규화 함수 추가 (공백/하이픈/괄호 제거 후 매칭)
|
||||
- `base.json` 의 동의어(synonyms) 배열을 역인덱스로 활용해 OCR 키와 교차 매칭
|
||||
- 매칭 실패 목록을 `out/merge-unmatched.json` 으로 출력하여 수동 검토
|
||||
|
||||
### 2) SEBC/CAS/UN 번호 varchar 길이 초과
|
||||
|
||||
`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)으로 보강
|
||||
236
backend/scripts/hns-import/extract-excel.py
Normal file
236
backend/scripts/hns-import/extract-excel.py
Normal file
@ -0,0 +1,236 @@
|
||||
"""Excel 메타 시트 → base.json 변환.
|
||||
|
||||
처리 시트:
|
||||
- 화물적부도 화물코드: 1,345개 기본 레코드
|
||||
- 동의어: 215개 한/영 유사명
|
||||
- IBC CODE: IMO IBC 분류
|
||||
|
||||
출력: HNSSearchSubstance 스키마(frontend/src/common/types/hns.ts)에 맞춘 JSON 배열.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
import openpyxl
|
||||
|
||||
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)
|
||||
|
||||
SOURCE_XLSX = Path(os.environ.get(
|
||||
'HNS_SOURCE_XLSX',
|
||||
r'C:\Projects\MeterialDB\유해물질 화물적부도 검색툴.xlsm',
|
||||
))
|
||||
|
||||
|
||||
def norm_key(s: str | None) -> str:
|
||||
if not s:
|
||||
return ''
|
||||
return re.sub(r'\s+', '', str(s)).strip().lower()
|
||||
|
||||
|
||||
def split_synonyms(raw: str | None) -> str:
|
||||
if not raw:
|
||||
return ''
|
||||
# 원본은 "·" 또는 "/" 구분, 개행 포함
|
||||
parts = re.split(r'[·/\n]+', str(raw))
|
||||
cleaned = [p.strip() for p in parts if p and p.strip()]
|
||||
return ' / '.join(cleaned)
|
||||
|
||||
|
||||
def clean_text(v) -> str:
|
||||
if v is None:
|
||||
return ''
|
||||
return str(v).strip()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print(f'[읽기] {SOURCE_XLSX}')
|
||||
if not SOURCE_XLSX.exists():
|
||||
raise SystemExit(f'소스 파일 없음: {SOURCE_XLSX}')
|
||||
|
||||
wb = openpyxl.load_workbook(SOURCE_XLSX, read_only=True, data_only=True, keep_vba=False)
|
||||
|
||||
# ────────── 화물적부도 화물코드 ──────────
|
||||
ws = wb['화물적부도 화물코드']
|
||||
rows = list(ws.iter_rows(values_only=True))
|
||||
# 헤더 row6: 연번, 약자/제품명, 영어명, 영문명 동의어, 국문명, 국문명 동의어, 주요 사용처, UN번호, CAS번호
|
||||
cargo_rows = [r for r in rows[6:] if r[0] is not None and isinstance(r[0], (int, float))]
|
||||
print(f'[화물적부도] 데이터 행 {len(cargo_rows)}개')
|
||||
|
||||
# ────────── 동의어 시트 ──────────
|
||||
ws_syn = wb['동의어']
|
||||
syn_rows = list(ws_syn.iter_rows(values_only=True))
|
||||
# 헤더 row2: 연번, 국문명, 영문명, cas, un, 한글 유사명, 영문 유사명
|
||||
syn_map: dict[str, dict] = {}
|
||||
for r in syn_rows[2:]:
|
||||
if not r or r[0] is None:
|
||||
continue
|
||||
name_kr = clean_text(r[1])
|
||||
cas = clean_text(r[3])
|
||||
if not name_kr and not cas:
|
||||
continue
|
||||
key = norm_key(name_kr) or norm_key(cas)
|
||||
syn_map[key] = {
|
||||
'synonymsKr': split_synonyms(r[5]) if len(r) > 5 else '',
|
||||
'synonymsEn': split_synonyms(r[6]) if len(r) > 6 else '',
|
||||
}
|
||||
print(f'[동의어] {len(syn_map)}개')
|
||||
|
||||
# ────────── IBC CODE 시트 ──────────
|
||||
ws_ibc = wb['IBC CODE']
|
||||
ibc_map: dict[str, dict] = {}
|
||||
for i, r in enumerate(ws_ibc.iter_rows(values_only=True)):
|
||||
if i < 2:
|
||||
continue # header 2 rows
|
||||
if not r or not r[0]:
|
||||
continue
|
||||
name_en = clean_text(r[0])
|
||||
key = norm_key(name_en)
|
||||
if not key:
|
||||
continue
|
||||
ibc_map[key] = {
|
||||
'ibcHazard': clean_text(r[2]), # 위험성 S/P
|
||||
'ibcShipType': clean_text(r[3]), # 선박형식
|
||||
'ibcTankType': clean_text(r[4]), # 탱크형식
|
||||
'ibcDetection': clean_text(r[10]) if len(r) > 10 else '', # 탐지장치
|
||||
'ibcFireFighting': clean_text(r[12]) if len(r) > 12 else '', # 화재대응
|
||||
'ibcMinRequirement': clean_text(r[14]) if len(r) > 14 else '', # 구체적운영상 요건
|
||||
}
|
||||
print(f'[IBC CODE] {len(ibc_map)}개')
|
||||
|
||||
wb.close()
|
||||
|
||||
# ────────── 통합 레코드 생성 ──────────
|
||||
# 동일 CAS/국문명 기준으로 cargoCodes 그룹화
|
||||
groups: dict[str, list] = defaultdict(list)
|
||||
for r in cargo_rows:
|
||||
_, abbr, name_en, syn_en, name_kr, syn_kr, usage, un, cas = r[:9]
|
||||
# 그룹 키: CAS 우선, 없으면 국문명
|
||||
cas_s = clean_text(cas)
|
||||
group_key = cas_s if cas_s else norm_key(name_kr)
|
||||
groups[group_key].append({
|
||||
'abbreviation': clean_text(abbr),
|
||||
'nameKr': clean_text(name_kr),
|
||||
'nameEn': clean_text(name_en),
|
||||
'synonymsKr': split_synonyms(syn_kr),
|
||||
'synonymsEn': split_synonyms(syn_en),
|
||||
'usage': clean_text(usage),
|
||||
'unNumber': clean_text(un),
|
||||
'casNumber': cas_s,
|
||||
})
|
||||
|
||||
records: list[dict] = []
|
||||
next_id = 1
|
||||
for group_key, entries in groups.items():
|
||||
# 대표 레코드: 가장 먼저 등장 (동의어 필드가 있는 걸 우선)
|
||||
primary = max(entries, key=lambda e: (bool(e['synonymsKr']), bool(e['synonymsEn']), len(e['nameKr'])))
|
||||
|
||||
name_kr_key = norm_key(primary['nameKr'])
|
||||
name_en_key = norm_key(primary['nameEn'])
|
||||
|
||||
# 동의어 병합
|
||||
syn_extra = syn_map.get(name_kr_key, {})
|
||||
synonyms_kr = ' / '.join(filter(None, [primary['synonymsKr'], syn_extra.get('synonymsKr', '')]))
|
||||
synonyms_en = ' / '.join(filter(None, [primary['synonymsEn'], syn_extra.get('synonymsEn', '')]))
|
||||
|
||||
# IBC 병합 (영문명 기준)
|
||||
ibc = ibc_map.get(name_en_key, {})
|
||||
|
||||
# cargoCodes 집계
|
||||
cargo_codes = [
|
||||
{
|
||||
'code': e['abbreviation'],
|
||||
'name': e['nameEn'] or e['nameKr'],
|
||||
'company': '국제공통',
|
||||
'source': '적부도',
|
||||
}
|
||||
for e in entries
|
||||
if e['abbreviation']
|
||||
]
|
||||
|
||||
record = {
|
||||
'id': next_id,
|
||||
'abbreviation': primary['abbreviation'],
|
||||
'nameKr': primary['nameKr'],
|
||||
'nameEn': primary['nameEn'],
|
||||
'synonymsKr': synonyms_kr,
|
||||
'synonymsEn': synonyms_en,
|
||||
'unNumber': primary['unNumber'],
|
||||
'casNumber': primary['casNumber'],
|
||||
'transportMethod': '',
|
||||
'sebc': '',
|
||||
# 물리·화학 (OCR 단계에서 채움)
|
||||
'usage': primary['usage'],
|
||||
'state': '',
|
||||
'color': '',
|
||||
'odor': '',
|
||||
'flashPoint': '',
|
||||
'autoIgnition': '',
|
||||
'boilingPoint': '',
|
||||
'density': '',
|
||||
'solubility': '',
|
||||
'vaporPressure': '',
|
||||
'vaporDensity': '',
|
||||
'explosionRange': '',
|
||||
# 위험도
|
||||
'nfpa': {'health': 0, 'fire': 0, 'reactivity': 0, 'special': ''},
|
||||
'hazardClass': '',
|
||||
'ergNumber': '',
|
||||
'idlh': '',
|
||||
'aegl2': '',
|
||||
'erpg2': '',
|
||||
# 방제
|
||||
'responseDistanceFire': '',
|
||||
'responseDistanceSpillDay': '',
|
||||
'responseDistanceSpillNight': '',
|
||||
'marineResponse': '',
|
||||
'ppeClose': '',
|
||||
'ppeFar': '',
|
||||
# MSDS
|
||||
'msds': {
|
||||
'hazard': '',
|
||||
'firstAid': '',
|
||||
'fireFighting': '',
|
||||
'spillResponse': '',
|
||||
'exposure': '',
|
||||
'regulation': '',
|
||||
},
|
||||
# IBC
|
||||
'ibcHazard': ibc.get('ibcHazard', ''),
|
||||
'ibcShipType': ibc.get('ibcShipType', ''),
|
||||
'ibcTankType': ibc.get('ibcTankType', ''),
|
||||
'ibcDetection': ibc.get('ibcDetection', ''),
|
||||
'ibcFireFighting': ibc.get('ibcFireFighting', ''),
|
||||
'ibcMinRequirement': ibc.get('ibcMinRequirement', ''),
|
||||
# EmS (OCR에서 채움)
|
||||
'emsCode': '',
|
||||
'emsFire': '',
|
||||
'emsSpill': '',
|
||||
'emsFirstAid': '',
|
||||
# cargoCodes / portFrequency
|
||||
'cargoCodes': cargo_codes,
|
||||
'portFrequency': [],
|
||||
}
|
||||
records.append(record)
|
||||
next_id += 1
|
||||
|
||||
print(f'[통합] 그룹화 결과 {len(records)}종 (화물적부도 {len(cargo_rows)}행 기준)')
|
||||
|
||||
# 저장
|
||||
out_path = OUT_DIR / 'base.json'
|
||||
with open(out_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(records, f, ensure_ascii=False, indent=2)
|
||||
print(f'[완료] {out_path} ({out_path.stat().st_size / 1024:.0f} KB)')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
170
backend/scripts/hns-import/extract-images.py
Normal file
170
backend/scripts/hns-import/extract-images.py
Normal file
@ -0,0 +1,170 @@
|
||||
"""물질별 시트에서 메인 카드 이미지(100KB+) 추출.
|
||||
|
||||
엑셀 워크시트 → drawing → image 관계 체인을 추적해
|
||||
각 물질 시트의 핵심 이미지만 out/images/{nameKr}.png 로 저장.
|
||||
|
||||
동시에 out/image-map.json 생성 (파일명 ↔ 시트명/국문명 매핑).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||
OUT_DIR = SCRIPT_DIR / 'out'
|
||||
IMG_DIR = OUT_DIR / 'images'
|
||||
IMG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
SOURCE_XLSX = Path(os.environ.get(
|
||||
'HNS_SOURCE_XLSX',
|
||||
r'C:\Projects\MeterialDB\유해물질 화물적부도 검색툴.xlsm',
|
||||
))
|
||||
|
||||
NS = {
|
||||
'm': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
|
||||
'r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
|
||||
'pr': 'http://schemas.openxmlformats.org/package/2006/relationships',
|
||||
}
|
||||
|
||||
# 메타 시트(데이터 시트)는 스킵
|
||||
SKIP_SHEETS = {
|
||||
'화물적부도 화물코드',
|
||||
'항구별 코드',
|
||||
'동의어',
|
||||
'IBC CODE',
|
||||
'경계선',
|
||||
}
|
||||
# 지침서 번호 시트(115~171) 패턴: 순수 숫자
|
||||
SKIP_PATTERN = re.compile(r'^\d{3}$')
|
||||
|
||||
# 최소 이미지 크기 (주요 카드만 대상, 작은 아이콘 제외)
|
||||
MIN_IMAGE_SIZE = 50_000 # 50 KB
|
||||
|
||||
|
||||
def safe_filename(name: str) -> str:
|
||||
name = name.strip().rstrip(',').strip()
|
||||
name = re.sub(r'[<>:"/\\|?*]', '_', name)
|
||||
return name
|
||||
|
||||
|
||||
def norm_path(p: str) -> str:
|
||||
return os.path.normpath(p).replace(os.sep, '/')
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print(f'[읽기] {SOURCE_XLSX}')
|
||||
if not SOURCE_XLSX.exists():
|
||||
raise SystemExit(f'소스 파일 없음: {SOURCE_XLSX}')
|
||||
|
||||
image_map: dict[str, dict] = {}
|
||||
saved = 0
|
||||
skipped = 0
|
||||
missing = 0
|
||||
|
||||
with zipfile.ZipFile(SOURCE_XLSX) as z:
|
||||
# 1) workbook.xml → sheet 목록
|
||||
with z.open('xl/workbook.xml') as f:
|
||||
wb_root = ET.parse(f).getroot()
|
||||
sheets = []
|
||||
for s in wb_root.findall('m:sheets/m:sheet', NS):
|
||||
sheets.append({
|
||||
'name': s.get('name'),
|
||||
'rid': s.get('{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id'),
|
||||
})
|
||||
with z.open('xl/_rels/workbook.xml.rels') as f:
|
||||
rels_root = ET.parse(f).getroot()
|
||||
rid_target = {r.get('Id'): r.get('Target') for r in rels_root.findall('pr:Relationship', NS)}
|
||||
for s in sheets:
|
||||
s['target'] = rid_target.get(s['rid'])
|
||||
|
||||
print(f'[시트] 총 {len(sheets)}개')
|
||||
|
||||
for s in sheets:
|
||||
name = s['name']
|
||||
if name in SKIP_SHEETS or SKIP_PATTERN.match(name or ''):
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
sheet_file = 'xl/' + s['target']
|
||||
rels_file = os.path.dirname(sheet_file) + '/_rels/' + os.path.basename(sheet_file) + '.rels'
|
||||
try:
|
||||
with z.open(rels_file) as f:
|
||||
srels = ET.parse(f).getroot()
|
||||
except KeyError:
|
||||
missing += 1
|
||||
continue
|
||||
|
||||
# 시트 → drawing
|
||||
drawing_rel = None
|
||||
for r in srels.findall('pr:Relationship', NS):
|
||||
t = r.get('Target') or ''
|
||||
if 'drawing' in (r.get('Type') or '').lower() and 'drawings/' in t:
|
||||
drawing_rel = t
|
||||
break
|
||||
if not drawing_rel:
|
||||
missing += 1
|
||||
continue
|
||||
|
||||
drawing_path = norm_path(os.path.join(os.path.dirname(sheet_file), drawing_rel))
|
||||
drawing_rels_path = os.path.dirname(drawing_path) + '/_rels/' + os.path.basename(drawing_path) + '.rels'
|
||||
try:
|
||||
with z.open(drawing_rels_path) as f:
|
||||
drels = ET.parse(f).getroot()
|
||||
except KeyError:
|
||||
missing += 1
|
||||
continue
|
||||
|
||||
# drawing → images
|
||||
image_paths: list[str] = []
|
||||
for r in drels.findall('pr:Relationship', NS):
|
||||
t = r.get('Target') or ''
|
||||
if 'image' in t.lower():
|
||||
img_path = norm_path(os.path.join(os.path.dirname(drawing_path), t))
|
||||
image_paths.append(img_path)
|
||||
if not image_paths:
|
||||
missing += 1
|
||||
continue
|
||||
|
||||
# 가장 큰 이미지 선택 (실제 카드 이미지는 100KB+, 아이콘은 수 KB)
|
||||
sized = [(z.getinfo(p).file_size, p) for p in image_paths]
|
||||
sized.sort(reverse=True)
|
||||
largest_size, largest_path = sized[0]
|
||||
if largest_size < MIN_IMAGE_SIZE:
|
||||
missing += 1
|
||||
continue
|
||||
|
||||
# 저장
|
||||
safe = safe_filename(name)
|
||||
ext = os.path.splitext(largest_path)[1].lower() or '.png'
|
||||
out_name = f'{safe}{ext}'
|
||||
out_path = IMG_DIR / out_name
|
||||
with z.open(largest_path) as fin, open(out_path, 'wb') as fout:
|
||||
fout.write(fin.read())
|
||||
image_map[out_name] = {
|
||||
'sheetName': name,
|
||||
'nameKr': safe,
|
||||
'source': largest_path,
|
||||
'sizeBytes': largest_size,
|
||||
}
|
||||
saved += 1
|
||||
if saved % 25 == 0:
|
||||
print(f' {saved}개 저장 완료')
|
||||
|
||||
print(f'\n[결과] 저장 {saved} / 스킵(메타) {skipped} / 이미지없음 {missing}')
|
||||
|
||||
map_path = OUT_DIR / 'image-map.json'
|
||||
with open(map_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(image_map, f, ensure_ascii=False, indent=2)
|
||||
print(f'[완료] 매핑 파일: {map_path}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
23
backend/scripts/hns-import/merge-batch.py
Normal file
23
backend/scripts/hns-import/merge-batch.py
Normal file
@ -0,0 +1,23 @@
|
||||
"""배치 JSON을 ocr.json에 병합."""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||
OUT_DIR = SCRIPT_DIR / 'out'
|
||||
OCR_PATH = OUT_DIR / 'ocr.json'
|
||||
BATCH_PATH = OUT_DIR / 'batch.json'
|
||||
|
||||
with open(OCR_PATH, encoding='utf-8') as f:
|
||||
ocr = json.load(f)
|
||||
with open(BATCH_PATH, encoding='utf-8') as f:
|
||||
batch = json.load(f)
|
||||
|
||||
added = [k for k in batch if k not in ocr]
|
||||
updated = [k for k in batch if k in ocr]
|
||||
ocr.update(batch)
|
||||
|
||||
with open(OCR_PATH, 'w', encoding='utf-8') as f:
|
||||
json.dump(ocr, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f'merged: +{len(added)} added, ~{len(updated)} updated, total {len(ocr)}')
|
||||
251
backend/scripts/hns-import/merge-data.ts
Normal file
251
backend/scripts/hns-import/merge-data.ts
Normal file
@ -0,0 +1,251 @@
|
||||
/**
|
||||
* base.json + ocr.json → frontend/src/data/hnsSubstanceData.json
|
||||
*
|
||||
* 매칭 키: 국문명(nameKr) 정규화 비교 (공백/특수문자 제거 후 소문자 비교)
|
||||
* 병합 규칙: Excel 기본 필드 유지, OCR 결과는 빈 필드만 채움 (OCR이 우선이지 않음)
|
||||
* 실제로 물성/위험도 필드는 base.json 에서 대부분 비어있으므로 OCR 값으로 채워짐.
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const OUT_DIR = resolve(__dirname, 'out');
|
||||
const BASE_PATH = resolve(OUT_DIR, 'base.json');
|
||||
const OCR_PATH = resolve(OUT_DIR, 'ocr.json');
|
||||
const TARGET_PATH = resolve(__dirname, '../../../frontend/src/data/hnsSubstanceData.json');
|
||||
|
||||
function normalizeName(s: string | undefined): string {
|
||||
if (!s) return '';
|
||||
return s
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/[,.·/\-_()[\]]/g, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
interface NfpaBlock {
|
||||
health: number;
|
||||
fire: number;
|
||||
reactivity: number;
|
||||
special: string;
|
||||
}
|
||||
|
||||
interface MsdsBlock {
|
||||
hazard: string;
|
||||
firstAid: string;
|
||||
fireFighting: string;
|
||||
spillResponse: string;
|
||||
exposure: string;
|
||||
regulation: string;
|
||||
}
|
||||
|
||||
interface BaseRecord {
|
||||
id: number;
|
||||
abbreviation: string;
|
||||
nameKr: string;
|
||||
nameEn: string;
|
||||
synonymsEn: string;
|
||||
synonymsKr: string;
|
||||
unNumber: string;
|
||||
casNumber: string;
|
||||
transportMethod: string;
|
||||
sebc: string;
|
||||
usage: string;
|
||||
state: string;
|
||||
color: string;
|
||||
odor: string;
|
||||
flashPoint: string;
|
||||
autoIgnition: string;
|
||||
boilingPoint: string;
|
||||
density: string;
|
||||
solubility: string;
|
||||
vaporPressure: string;
|
||||
vaporDensity: string;
|
||||
explosionRange: string;
|
||||
nfpa: NfpaBlock;
|
||||
hazardClass: string;
|
||||
ergNumber: string;
|
||||
idlh: string;
|
||||
aegl2: string;
|
||||
erpg2: string;
|
||||
responseDistanceFire: string;
|
||||
responseDistanceSpillDay: string;
|
||||
responseDistanceSpillNight: string;
|
||||
marineResponse: string;
|
||||
ppeClose: string;
|
||||
ppeFar: string;
|
||||
msds: MsdsBlock;
|
||||
ibcHazard: string;
|
||||
ibcShipType: string;
|
||||
ibcTankType: string;
|
||||
ibcDetection: string;
|
||||
ibcFireFighting: string;
|
||||
ibcMinRequirement: string;
|
||||
emsCode: string;
|
||||
emsFire: string;
|
||||
emsSpill: string;
|
||||
emsFirstAid: string;
|
||||
cargoCodes: Array<{ code: string; name: string; company: string; source: string }>;
|
||||
portFrequency: Array<{ port: string; portCode: string; lastImport: string; frequency: string }>;
|
||||
}
|
||||
|
||||
interface OcrResult {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function firstString(...values: Array<unknown>): string {
|
||||
for (const v of values) {
|
||||
if (typeof v === 'string' && v.trim().length > 0) return v.trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function pickNfpa(ocr: OcrResult): NfpaBlock | null {
|
||||
const n = ocr.nfpa as Partial<NfpaBlock> | undefined;
|
||||
if (!n || typeof n !== 'object') return null;
|
||||
const h = Number(n.health);
|
||||
const f = Number(n.fire);
|
||||
const r = Number(n.reactivity);
|
||||
if ([h, f, r].some((x) => !Number.isFinite(x))) return null;
|
||||
return {
|
||||
health: h,
|
||||
fire: f,
|
||||
reactivity: r,
|
||||
special: typeof n.special === 'string' ? n.special : '',
|
||||
};
|
||||
}
|
||||
|
||||
function pickMsds(ocr: OcrResult, base: MsdsBlock): MsdsBlock {
|
||||
const m = (ocr.msds ?? {}) as Partial<MsdsBlock>;
|
||||
return {
|
||||
hazard: firstString(base.hazard, m.hazard),
|
||||
firstAid: firstString(base.firstAid, m.firstAid),
|
||||
fireFighting: firstString(base.fireFighting, m.fireFighting),
|
||||
spillResponse: firstString(base.spillResponse, m.spillResponse),
|
||||
exposure: firstString(base.exposure, m.exposure),
|
||||
regulation: firstString(base.regulation, m.regulation),
|
||||
};
|
||||
}
|
||||
|
||||
function merge(base: BaseRecord, ocr: OcrResult | undefined): BaseRecord {
|
||||
if (!ocr) return base;
|
||||
|
||||
const nfpaFromOcr = pickNfpa(ocr);
|
||||
|
||||
return {
|
||||
...base,
|
||||
transportMethod: firstString(base.transportMethod, ocr.transportMethod),
|
||||
sebc: firstString(base.sebc, ocr.sebc),
|
||||
state: firstString(base.state, ocr.state),
|
||||
color: firstString(base.color, ocr.color),
|
||||
odor: firstString(base.odor, ocr.odor),
|
||||
flashPoint: firstString(base.flashPoint, ocr.flashPoint),
|
||||
autoIgnition: firstString(base.autoIgnition, ocr.autoIgnition),
|
||||
boilingPoint: firstString(base.boilingPoint, ocr.boilingPoint),
|
||||
density: firstString(base.density, ocr.density),
|
||||
solubility: firstString(base.solubility, ocr.solubility),
|
||||
vaporPressure: firstString(base.vaporPressure, ocr.vaporPressure),
|
||||
vaporDensity: firstString(base.vaporDensity, ocr.vaporDensity),
|
||||
explosionRange: firstString(base.explosionRange, ocr.explosionRange),
|
||||
nfpa: nfpaFromOcr ?? base.nfpa,
|
||||
hazardClass: firstString(base.hazardClass, ocr.hazardClass),
|
||||
ergNumber: firstString(base.ergNumber, ocr.ergNumber),
|
||||
idlh: firstString(base.idlh, ocr.idlh),
|
||||
aegl2: firstString(base.aegl2, ocr.aegl2),
|
||||
erpg2: firstString(base.erpg2, ocr.erpg2),
|
||||
responseDistanceFire: firstString(base.responseDistanceFire, ocr.responseDistanceFire),
|
||||
responseDistanceSpillDay: firstString(base.responseDistanceSpillDay, ocr.responseDistanceSpillDay),
|
||||
responseDistanceSpillNight: firstString(base.responseDistanceSpillNight, ocr.responseDistanceSpillNight),
|
||||
marineResponse: firstString(base.marineResponse, ocr.marineResponse),
|
||||
ppeClose: firstString(base.ppeClose, ocr.ppeClose),
|
||||
ppeFar: firstString(base.ppeFar, ocr.ppeFar),
|
||||
msds: pickMsds(ocr, base.msds),
|
||||
emsCode: firstString(base.emsCode, ocr.emsCode),
|
||||
emsFire: firstString(base.emsFire, ocr.emsFire),
|
||||
emsSpill: firstString(base.emsSpill, ocr.emsSpill),
|
||||
emsFirstAid: firstString(base.emsFirstAid, ocr.emsFirstAid),
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (!existsSync(BASE_PATH)) {
|
||||
console.error(`base.json 없음: ${BASE_PATH}`);
|
||||
console.error('→ extract-excel.py 를 먼저 실행하세요.');
|
||||
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 ocr: Record<string, OcrResult> = existsSync(OCR_PATH)
|
||||
? JSON.parse(readFileSync(OCR_PATH, 'utf-8'))
|
||||
: {};
|
||||
|
||||
console.log(`[입력] base ${base.length}종, ocr ${Object.keys(ocr).length}종`);
|
||||
|
||||
// OCR 키를 정규화 인덱스로 변환 (정규화키 → OcrResult, 역매핑 normKey → 원본키)
|
||||
const ocrIndex = new Map<string, OcrResult>();
|
||||
const normToOrig = new Map<string, string>();
|
||||
for (const [key, value] of Object.entries(ocr)) {
|
||||
const normKey = normalizeName(key);
|
||||
if (normKey) {
|
||||
ocrIndex.set(normKey, value);
|
||||
normToOrig.set(normKey, key);
|
||||
}
|
||||
}
|
||||
|
||||
let matched = 0;
|
||||
let matchedBySynonym = 0;
|
||||
const unmatched: string[] = [];
|
||||
|
||||
const merged = base.map((record) => {
|
||||
// 1단계: nameKr 정규화 매칭
|
||||
const key = normalizeName(record.nameKr);
|
||||
const ocrResult = ocrIndex.get(key);
|
||||
if (ocrResult) {
|
||||
matched++;
|
||||
ocrIndex.delete(key);
|
||||
return merge(record, ocrResult);
|
||||
}
|
||||
|
||||
// 2단계: synonymsKr 동의어 매칭 (" / " 구분자)
|
||||
if (record.synonymsKr) {
|
||||
const synonyms = record.synonymsKr.split(' / ');
|
||||
for (const syn of synonyms) {
|
||||
const normSyn = normalizeName(syn);
|
||||
if (!normSyn) continue;
|
||||
const synOcrResult = ocrIndex.get(normSyn);
|
||||
if (synOcrResult) {
|
||||
matched++;
|
||||
matchedBySynonym++;
|
||||
ocrIndex.delete(normSyn);
|
||||
return merge(record, synOcrResult);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return record;
|
||||
});
|
||||
|
||||
// 남은 OCR 키는 base에 매칭 실패한 항목 (원본 키로 복원)
|
||||
for (const normKey of ocrIndex.keys()) {
|
||||
unmatched.push(normToOrig.get(normKey) ?? normKey);
|
||||
}
|
||||
|
||||
console.log(`[병합] base ↔ ocr 매칭 ${matched}종 (nameKr: ${matched - matchedBySynonym}, 동의어: ${matchedBySynonym})`);
|
||||
if (unmatched.length > 0) {
|
||||
const unmatchedPath = resolve(OUT_DIR, 'merge-unmatched.json');
|
||||
writeFileSync(unmatchedPath, JSON.stringify({ count: unmatched.length, keys: unmatched.sort() }, null, 2), 'utf-8');
|
||||
console.warn(`[경고] OCR 매칭 실패 ${unmatched.length}개 → ${unmatchedPath}`);
|
||||
unmatched.slice(0, 20).forEach((k) => console.warn(` - ${k}`));
|
||||
if (unmatched.length > 20) console.warn(` ... +${unmatched.length - 20}`);
|
||||
}
|
||||
|
||||
writeFileSync(TARGET_PATH, JSON.stringify(merged, null, 2), 'utf-8');
|
||||
const sizeKb = (JSON.stringify(merged).length / 1024).toFixed(0);
|
||||
console.log(`[완료] ${TARGET_PATH} (${sizeKb} KB, ${merged.length}종)`);
|
||||
console.log(` 상세 정보 보유: ${merged.filter((r) => r.flashPoint).length}종`);
|
||||
}
|
||||
|
||||
main();
|
||||
300
backend/scripts/hns-import/ocr-claude-vision.ts
Normal file
300
backend/scripts/hns-import/ocr-claude-vision.ts
Normal file
@ -0,0 +1,300 @@
|
||||
/**
|
||||
* Claude Vision API 로 HNS 카드 이미지 → 구조화 JSON 변환.
|
||||
*
|
||||
* 입력: out/images/*.png (222개)
|
||||
* 출력: out/ocr.json { [nameKr]: Partial<HNSSearchSubstance> }
|
||||
*
|
||||
* 환경변수: ANTHROPIC_API_KEY
|
||||
* 모델: claude-sonnet-4-5 (Vision + 비용 효율)
|
||||
* 동시성: 5, 재시도 3회
|
||||
*
|
||||
* 재실행 시 기존 ocr.json 의 결과는 유지하고 누락된 이미지만 처리한다.
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
import { readFileSync, readdirSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { resolve, dirname, basename, extname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const OUT_DIR = resolve(__dirname, 'out');
|
||||
const IMG_DIR = process.env.HNS_OCR_IMG_DIR
|
||||
? resolve(process.env.HNS_OCR_IMG_DIR)
|
||||
: resolve(OUT_DIR, 'images');
|
||||
const OCR_PATH = process.env.HNS_OCR_OUT
|
||||
? resolve(process.env.HNS_OCR_OUT)
|
||||
: resolve(OUT_DIR, 'ocr.json');
|
||||
const FAIL_PATH = process.env.HNS_OCR_FAIL
|
||||
? resolve(process.env.HNS_OCR_FAIL)
|
||||
: resolve(OUT_DIR, 'ocr-failures.json');
|
||||
const OCR_LIMIT = process.env.HNS_OCR_LIMIT ? parseInt(process.env.HNS_OCR_LIMIT, 10) : undefined;
|
||||
const OCR_ONLY = process.env.HNS_OCR_ONLY
|
||||
? process.env.HNS_OCR_ONLY.split(',').map((s) => s.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
const CONCURRENCY = 5;
|
||||
const MAX_RETRIES = 3;
|
||||
const MODEL = process.env.HNS_OCR_MODEL ?? 'claude-sonnet-4-5';
|
||||
|
||||
const SYSTEM_PROMPT = `당신은 한국 해양 방제용 HNS 비상대응 카드 이미지를 구조화 JSON으로 추출하는 전문 파서입니다.
|
||||
|
||||
이미지는 표준 템플릿을 따르며 다음 섹션을 포함합니다:
|
||||
- 상단: 국문명, 영문명
|
||||
- 물질특성: CAS번호, UN번호, 운송방법, 유사명, 특성(독성/부식성/인화성/유해성), 용도, 상태/색상/냄새, 인화점/발화점/끓는점, 용해도/증기압/증기밀도, 비중/폭발범위, NFPA 다이아몬드(건강/인화/반응), GHS 픽토그램, ERG 지침서 번호
|
||||
- 대응방법: 주요 장비(PPE 근거리/원거리), 화재 대응(EmS F-x), 해상 유출(EmS S-x), 초기이격거리, 방호활동거리
|
||||
- 인체유해성: TWA / STEL / AEGL-2 / IDLH, 흡입/피부/안구/경구 증상·응급조치
|
||||
|
||||
아래 JSON 스키마를 **엄격히** 준수하여 응답하세요. 값이 없거나 읽을 수 없는 경우 빈 문자열 "" 또는 null.
|
||||
숫자는 단위 포함 원문 문자열로 유지 (예: "80℃", "2,410 mmHg (25℃)").
|
||||
NFPA 건강/인화/반응은 0~4 정수. special 은 문자열(특수 표시).
|
||||
|
||||
응답은 **순수 JSON 객체만** 반환 (코드블록이나 설명문 없이).
|
||||
|
||||
스키마:
|
||||
{
|
||||
"transportMethod": "",
|
||||
"state": "",
|
||||
"color": "",
|
||||
"odor": "",
|
||||
"flashPoint": "",
|
||||
"autoIgnition": "",
|
||||
"boilingPoint": "",
|
||||
"density": "",
|
||||
"solubility": "",
|
||||
"vaporPressure": "",
|
||||
"vaporDensity": "",
|
||||
"explosionRange": "",
|
||||
"nfpa": { "health": 0, "fire": 0, "reactivity": 0, "special": "" },
|
||||
"hazardClass": "",
|
||||
"ergNumber": "",
|
||||
"idlh": "",
|
||||
"aegl2": "",
|
||||
"erpg2": "",
|
||||
"twa": "",
|
||||
"stel": "",
|
||||
"responseDistanceFire": "",
|
||||
"responseDistanceSpillDay": "",
|
||||
"responseDistanceSpillNight": "",
|
||||
"marineResponse": "",
|
||||
"ppeClose": "",
|
||||
"ppeFar": "",
|
||||
"msds": {
|
||||
"hazard": "",
|
||||
"firstAid": "",
|
||||
"fireFighting": "",
|
||||
"spillResponse": "",
|
||||
"exposure": "",
|
||||
"regulation": ""
|
||||
},
|
||||
"emsCode": "",
|
||||
"emsFire": "",
|
||||
"emsSpill": "",
|
||||
"emsFirstAid": "",
|
||||
"sebc": ""
|
||||
}`;
|
||||
|
||||
interface OcrResult {
|
||||
transportMethod?: string;
|
||||
state?: string;
|
||||
color?: string;
|
||||
odor?: string;
|
||||
flashPoint?: string;
|
||||
autoIgnition?: string;
|
||||
boilingPoint?: string;
|
||||
density?: string;
|
||||
solubility?: string;
|
||||
vaporPressure?: string;
|
||||
vaporDensity?: string;
|
||||
explosionRange?: string;
|
||||
nfpa?: { health: number; fire: number; reactivity: number; special: string };
|
||||
hazardClass?: string;
|
||||
ergNumber?: string;
|
||||
idlh?: string;
|
||||
aegl2?: string;
|
||||
erpg2?: string;
|
||||
twa?: string;
|
||||
stel?: string;
|
||||
responseDistanceFire?: string;
|
||||
responseDistanceSpillDay?: string;
|
||||
responseDistanceSpillNight?: string;
|
||||
marineResponse?: string;
|
||||
ppeClose?: string;
|
||||
ppeFar?: string;
|
||||
msds?: {
|
||||
hazard?: string;
|
||||
firstAid?: string;
|
||||
fireFighting?: string;
|
||||
spillResponse?: string;
|
||||
exposure?: string;
|
||||
regulation?: string;
|
||||
};
|
||||
emsCode?: string;
|
||||
emsFire?: string;
|
||||
emsSpill?: string;
|
||||
emsFirstAid?: string;
|
||||
sebc?: string;
|
||||
}
|
||||
|
||||
function loadExisting<T>(path: string, fallback: T): T {
|
||||
if (!existsSync(path)) return fallback;
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, 'utf-8'));
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function extractJson(text: string): OcrResult | null {
|
||||
const cleaned = text.replace(/```json\s*/gi, '').replace(/```\s*$/g, '').trim();
|
||||
const firstBrace = cleaned.indexOf('{');
|
||||
const lastBrace = cleaned.lastIndexOf('}');
|
||||
if (firstBrace < 0 || lastBrace < 0) return null;
|
||||
try {
|
||||
return JSON.parse(cleaned.slice(firstBrace, lastBrace + 1));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function callVision(client: Anthropic, imagePath: string): Promise<OcrResult> {
|
||||
const imageData = readFileSync(imagePath).toString('base64');
|
||||
const ext = extname(imagePath).slice(1).toLowerCase();
|
||||
const mediaType = (ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : 'image/png') as
|
||||
| 'image/png'
|
||||
| 'image/jpeg';
|
||||
|
||||
const response = await client.messages.create({
|
||||
model: MODEL,
|
||||
max_tokens: 4096,
|
||||
system: [
|
||||
{
|
||||
type: 'text',
|
||||
text: SYSTEM_PROMPT,
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'image',
|
||||
source: { type: 'base64', media_type: mediaType, data: imageData },
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: '이 HNS 비상대응 카드 이미지에서 모든 필드를 추출해 JSON으로 반환하세요.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const textBlock = response.content.find((b) => b.type === 'text');
|
||||
if (!textBlock || textBlock.type !== 'text') {
|
||||
throw new Error('응답에 텍스트 블록 없음');
|
||||
}
|
||||
const result = extractJson(textBlock.text);
|
||||
if (!result) {
|
||||
throw new Error(`JSON 파싱 실패: ${textBlock.text.slice(0, 200)}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function processWithRetry(
|
||||
client: Anthropic,
|
||||
imagePath: string,
|
||||
nameKr: string,
|
||||
): Promise<OcrResult> {
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
return await callVision(client, imagePath);
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
const wait = 1000 * Math.pow(2, attempt - 1);
|
||||
console.warn(`[${nameKr}] 시도 ${attempt} 실패, ${wait}ms 후 재시도: ${String(err).slice(0, 120)}`);
|
||||
await new Promise((r) => setTimeout(r, wait));
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
async function runPool<T>(items: T[], worker: (item: T, idx: number) => Promise<void>) {
|
||||
let cursor = 0;
|
||||
const workers = Array.from({ length: CONCURRENCY }, async () => {
|
||||
while (cursor < items.length) {
|
||||
const idx = cursor++;
|
||||
await worker(items[idx], idx);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!process.env.ANTHROPIC_API_KEY) {
|
||||
console.error('ANTHROPIC_API_KEY 환경변수가 없습니다.');
|
||||
process.exit(1);
|
||||
}
|
||||
const client = new Anthropic();
|
||||
|
||||
if (!existsSync(IMG_DIR)) {
|
||||
console.error(`이미지 디렉토리 없음: ${IMG_DIR}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const allImages = readdirSync(IMG_DIR).filter((f) => /\.(png|jpg|jpeg)$/i.test(f));
|
||||
const images = OCR_ONLY
|
||||
? allImages.filter((f) => OCR_ONLY.includes(basename(f, extname(f))))
|
||||
: allImages;
|
||||
const existing: Record<string, OcrResult> = loadExisting(OCR_PATH, {});
|
||||
const failures: Record<string, string> = loadExisting(FAIL_PATH, {});
|
||||
|
||||
let pending = images.filter((f) => {
|
||||
const nameKr = basename(f, extname(f));
|
||||
return !(nameKr in existing);
|
||||
});
|
||||
if (OCR_LIMIT && Number.isFinite(OCR_LIMIT)) {
|
||||
pending = pending.slice(0, OCR_LIMIT);
|
||||
}
|
||||
|
||||
console.log(`[OCR] 전체 ${allImages.length}개 중 대상 ${images.length}개, 이미 처리 ${Object.keys(existing).length}개, 이번 실행 ${pending.length}개`);
|
||||
console.log(`[모델] ${MODEL}, 동시 ${CONCURRENCY}, 재시도 최대 ${MAX_RETRIES}`);
|
||||
console.log(`[출력] ${OCR_PATH}`);
|
||||
|
||||
let done = 0;
|
||||
let failed = 0;
|
||||
|
||||
await runPool(pending, async (file, idx) => {
|
||||
const nameKr = basename(file, extname(file));
|
||||
const path = resolve(IMG_DIR, file);
|
||||
try {
|
||||
const result = await processWithRetry(client, path, nameKr);
|
||||
existing[nameKr] = result;
|
||||
delete failures[nameKr];
|
||||
done++;
|
||||
if (done % 10 === 0 || done === pending.length) {
|
||||
writeFileSync(OCR_PATH, JSON.stringify(existing, null, 2), 'utf-8');
|
||||
console.log(` 진행 ${done}/${pending.length} (실패 ${failed}) - 중간 저장`);
|
||||
}
|
||||
} catch (err) {
|
||||
failed++;
|
||||
failures[nameKr] = String(err).slice(0, 500);
|
||||
console.error(`[실패] ${nameKr}: ${String(err).slice(0, 200)}`);
|
||||
}
|
||||
});
|
||||
|
||||
writeFileSync(OCR_PATH, JSON.stringify(existing, null, 2), 'utf-8');
|
||||
writeFileSync(FAIL_PATH, JSON.stringify(failures, null, 2), 'utf-8');
|
||||
|
||||
console.log(`\n[완료] 성공 ${Object.keys(existing).length} / 실패 ${Object.keys(failures).length}`);
|
||||
console.log(` OCR 결과: ${OCR_PATH}`);
|
||||
if (Object.keys(failures).length > 0) {
|
||||
console.log(` 실패 목록: ${FAIL_PATH} (재실행하면 실패분만 재시도)`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
324
backend/scripts/hns-import/ocr-local.py
Normal file
324
backend/scripts/hns-import/ocr-local.py
Normal file
@ -0,0 +1,324 @@
|
||||
"""로컬 EasyOCR 기반 HNS 카드 이미지 파싱.
|
||||
|
||||
전용 venv(.venv)에 설치된 easyocr을 사용한다.
|
||||
|
||||
1. 이미지 → EasyOCR → (bbox, text, conf) 리스트
|
||||
2. y좌표로 행 그룹화 후 각 행 내 x좌표 정렬
|
||||
3. 레이블 키워드 기반 필드 매핑 (정규식)
|
||||
4. 결과를 out/ocr.json 에 누적 저장 (재실행 가능)
|
||||
|
||||
실행:
|
||||
cd backend/scripts/hns-import
|
||||
source .venv/Scripts/activate # Windows Git Bash
|
||||
python ocr-local.py [--limit N] [--only 벤젠,톨루엔,...]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||
OUT_DIR = SCRIPT_DIR / 'out'
|
||||
IMG_DIR = OUT_DIR / 'images'
|
||||
OCR_PATH_DEFAULT = OUT_DIR / 'ocr.json'
|
||||
FAIL_PATH_DEFAULT = OUT_DIR / 'ocr-failures.json'
|
||||
|
||||
|
||||
# ────────── 필드 레이블 패턴 (EasyOCR 오인식 변형 포함) ──────────
|
||||
# 각 필드의 후보 레이블 문자열(공백 제거 후 비교). 한글 OCR이 종종 비슷한 글자로 오인식되므로
|
||||
# 대표적인 변형도 함께 등록 (예: "인화점" ↔ "인회점", "끓는점" ↔ "꿈는점" ↔ "끝는점").
|
||||
LABEL_CANDIDATES: dict[str, list[str]] = {
|
||||
'casNumber': ['CAS번호', 'CASNO', 'CAS'],
|
||||
'unNumber': ['UN번호', 'UNNO', 'UN'],
|
||||
'transportMethod': ['운송방법', '운승방벌', '운송방벌', '운송방립', '운송'],
|
||||
'usage': ['용도'],
|
||||
'state': ['성상', '상태', '형태'],
|
||||
'color': ['색상', '색'],
|
||||
'odor': ['냄새'],
|
||||
'flashPoint': ['인화점', '인회점', '인하점', '인호점'],
|
||||
'autoIgnition': ['발화점', '발회점', '발하점'],
|
||||
'boilingPoint': ['끓는점', '꿈는점', '끝는점', '끊는점'],
|
||||
'density': ['비중'],
|
||||
'solubility': ['용해도', '용해'],
|
||||
'vaporPressure': ['증기압', '증기압력'],
|
||||
'vaporDensity': ['증기밀도'],
|
||||
'explosionRange': ['폭발범위', '곡발범위', '폭범위', '폭발한계'],
|
||||
'idlh': ['IDLH'],
|
||||
'aegl2': ['AEGL-2', 'AEGL2'],
|
||||
'erpg2': ['ERPG-2', 'ERPG2'],
|
||||
'twa': ['TWA'],
|
||||
'stel': ['STEL'],
|
||||
'ergNumber': ['ERG번호', 'ERG'],
|
||||
'hazardClass': ['위험분류', '위험', '분류'],
|
||||
'synonymsKr': ['유사명'],
|
||||
'responseDistanceFire': ['대피거리', '머피거리'],
|
||||
'ppeClose': ['근거리(레벨A)', '근거리레벨A', '근거리', '레벨A'],
|
||||
'ppeFar': ['원거리(레벨C)', '원거리레벨C', '원거리', '레벨C'],
|
||||
'emsFire': ['화재(F-E)', '화재(F-C)', '화재(F-D)', '화재대응'],
|
||||
'emsSpill': ['유출(S-U)', '유출(S-O)', '유출(S-D)', '해상유출'],
|
||||
'marineResponse': ['해상대응', '해상'],
|
||||
}
|
||||
|
||||
|
||||
def _norm_label(s: str) -> str:
|
||||
"""공백/특수문자 제거 후 비교용 정규화."""
|
||||
return re.sub(r'[\s,.·()\[\]:;\'"-]+', '', s).strip()
|
||||
|
||||
|
||||
LABEL_INDEX: dict[str, str] = {}
|
||||
for _field, _candidates in LABEL_CANDIDATES.items():
|
||||
for _cand in _candidates:
|
||||
LABEL_INDEX[_norm_label(_cand)] = _field
|
||||
|
||||
# NFPA 셀 값(한 자릿수 0~4) 추출용
|
||||
NFPA_VALUE_RE = re.compile(r'^[0-4]$')
|
||||
|
||||
|
||||
def group_rows(items: list[dict], y_tolerance_ratio: float = 0.6) -> list[list[dict]]:
|
||||
"""텍스트 조각들을 y 좌표 기준으로 행 단위로 그룹화 (글자 높이 비례 허용치)."""
|
||||
if not items:
|
||||
return []
|
||||
heights = [it['y1'] - it['y0'] for it in items]
|
||||
median_h = sorted(heights)[len(heights) // 2]
|
||||
y_tol = max(8, median_h * y_tolerance_ratio)
|
||||
|
||||
sorted_items = sorted(items, key=lambda it: it['cy'])
|
||||
rows: list[list[dict]] = []
|
||||
for it in sorted_items:
|
||||
if rows and abs(it['cy'] - rows[-1][-1]['cy']) <= y_tol:
|
||||
rows[-1].append(it)
|
||||
else:
|
||||
rows.append([it])
|
||||
for row in rows:
|
||||
row.sort(key=lambda it: it['cx'])
|
||||
return rows
|
||||
|
||||
|
||||
def _match_label(text: str) -> str | None:
|
||||
key = _norm_label(text)
|
||||
if not key:
|
||||
return None
|
||||
# 정확 일치 우선
|
||||
if key in LABEL_INDEX:
|
||||
return LABEL_INDEX[key]
|
||||
# 접두 일치 (OCR이 뒤에 잡티를 붙이는 경우)
|
||||
for cand_key, field in LABEL_INDEX.items():
|
||||
if len(cand_key) >= 2 and key.startswith(cand_key):
|
||||
return field
|
||||
return None
|
||||
|
||||
|
||||
def parse_card(items: list[dict]) -> dict[str, Any]:
|
||||
"""OCR 결과 목록을 필드 dict로 변환."""
|
||||
rows = group_rows(items)
|
||||
result: dict[str, Any] = {}
|
||||
|
||||
# 1) 행 내 "레이블 → 값" 쌍 추출
|
||||
# 같은 행에서 레이블 바로 뒤의 첫 non-label 텍스트를 값으로 사용.
|
||||
for row in rows:
|
||||
# 여러 레이블이 같은 행에 있을 수 있음 (2컬럼 표 구조)
|
||||
idx = 0
|
||||
while idx < len(row):
|
||||
field = _match_label(row[idx]['text'])
|
||||
if field:
|
||||
# 다음 non-label 조각을 값으로 취함
|
||||
value_parts: list[str] = []
|
||||
j = idx + 1
|
||||
while j < len(row):
|
||||
nxt = row[j]
|
||||
if _match_label(nxt['text']):
|
||||
break
|
||||
value_parts.append(nxt['text'])
|
||||
j += 1
|
||||
if value_parts and field not in result:
|
||||
value = ' '.join(value_parts).strip()
|
||||
if value and value not in ('-', '–', 'N/A'):
|
||||
result[field] = value
|
||||
idx = j
|
||||
else:
|
||||
idx += 1
|
||||
|
||||
# 2) NFPA 추출: "NFPA" 단어 주변의 0~4 숫자 3개
|
||||
nfpa_idx_row: int | None = None
|
||||
for ri, row in enumerate(rows):
|
||||
for cell in row:
|
||||
if re.search(r'NFPA', cell['text']):
|
||||
nfpa_idx_row = ri
|
||||
break
|
||||
if nfpa_idx_row is not None:
|
||||
break
|
||||
if nfpa_idx_row is not None:
|
||||
# 해당 행 + 다음 2개 행에서 0~4 숫자 수집
|
||||
candidates: list[int] = []
|
||||
for ri in range(nfpa_idx_row, min(nfpa_idx_row + 3, len(rows))):
|
||||
for cell in rows[ri]:
|
||||
m = NFPA_VALUE_RE.match(cell['text'].strip())
|
||||
if m:
|
||||
candidates.append(int(cell['text'].strip()))
|
||||
if len(candidates) >= 3:
|
||||
break
|
||||
if len(candidates) >= 3:
|
||||
break
|
||||
if len(candidates) >= 3:
|
||||
result['nfpa'] = {
|
||||
'health': candidates[0],
|
||||
'fire': candidates[1],
|
||||
'reactivity': candidates[2],
|
||||
'special': '',
|
||||
}
|
||||
|
||||
# 3) EmS 코드 (F-x / S-x 패턴)
|
||||
all_text = ' '.join(cell['text'] for row in rows for cell in row)
|
||||
f_match = re.search(r'F\s*-\s*([A-Z])', all_text)
|
||||
s_match = re.search(r'S\s*-\s*([A-Z])', all_text)
|
||||
if f_match or s_match:
|
||||
parts = []
|
||||
if f_match:
|
||||
parts.append(f'F-{f_match.group(1)}')
|
||||
if s_match:
|
||||
parts.append(f'S-{s_match.group(1)}')
|
||||
if parts:
|
||||
result['emsCode'] = ', '.join(parts)
|
||||
|
||||
# 4) ERG 번호 (3자리 숫자, P 접미사 가능, "ERG" 키워드 근처)
|
||||
erg_match = re.search(r'ERG[^\d]{0,10}(\d{3}P?)', all_text)
|
||||
if erg_match:
|
||||
result['ergNumber'] = erg_match.group(1)
|
||||
|
||||
# 5) EmS F-x / S-x 코드 뒤의 본문 (생략 - 이미지 내 텍스트 밀도가 낮아 행 단위로 이미 잡힘)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _preprocess_image(pil_img, upscale: float = 2.5):
|
||||
"""한글 OCR 정확도 향상을 위한 업스케일 + 샤프닝 + 대비 향상."""
|
||||
from PIL import Image, ImageEnhance, ImageFilter
|
||||
import numpy as np
|
||||
|
||||
if pil_img.mode != 'RGB':
|
||||
pil_img = pil_img.convert('RGB')
|
||||
|
||||
# 1) 업스케일 (LANCZOS)
|
||||
w, h = pil_img.size
|
||||
pil_img = pil_img.resize((int(w * upscale), int(h * upscale)), Image.LANCZOS)
|
||||
|
||||
# 2) 대비 향상
|
||||
pil_img = ImageEnhance.Contrast(pil_img).enhance(1.3)
|
||||
|
||||
# 3) 샤프닝
|
||||
pil_img = pil_img.filter(ImageFilter.UnsharpMask(radius=1.5, percent=150, threshold=2))
|
||||
|
||||
return np.array(pil_img)
|
||||
|
||||
|
||||
def run_ocr(image_path: Path, reader, upscale: float = 2.5) -> list[dict]:
|
||||
# OpenCV가 Windows에서 한글 경로를 못 읽으므로 PIL로 로드 후 전처리
|
||||
from PIL import Image
|
||||
with Image.open(image_path) as pil:
|
||||
img = _preprocess_image(pil, upscale=upscale)
|
||||
raw = reader.readtext(img, detail=1, paragraph=False)
|
||||
items: list[dict] = []
|
||||
for bbox, text, conf in raw:
|
||||
if not text or not str(text).strip():
|
||||
continue
|
||||
xs = [p[0] for p in bbox]
|
||||
ys = [p[1] for p in bbox]
|
||||
items.append({
|
||||
'text': str(text).strip(),
|
||||
'cx': sum(xs) / 4.0,
|
||||
'cy': sum(ys) / 4.0,
|
||||
'x0': min(xs),
|
||||
'x1': max(xs),
|
||||
'y0': min(ys),
|
||||
'y1': max(ys),
|
||||
'conf': float(conf),
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
def load_json(path: Path, fallback):
|
||||
if not path.exists():
|
||||
return fallback
|
||||
try:
|
||||
return json.loads(path.read_text(encoding='utf-8'))
|
||||
except Exception:
|
||||
return fallback
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--limit', type=int, default=None)
|
||||
parser.add_argument('--only', type=str, default=None,
|
||||
help='파이프(|)로 구분된 물질명 리스트')
|
||||
parser.add_argument('--img-dir', type=Path, default=IMG_DIR)
|
||||
parser.add_argument('--out', type=Path, default=OCR_PATH_DEFAULT)
|
||||
parser.add_argument('--fail', type=Path, default=FAIL_PATH_DEFAULT)
|
||||
parser.add_argument('--debug', action='store_true',
|
||||
help='파싱 중간 결과(row 단위) 함께 출력')
|
||||
args = parser.parse_args()
|
||||
|
||||
import easyocr # noqa: WPS433
|
||||
|
||||
print('[로딩] EasyOCR 모델 (ko + en)... (최초 실행 시 수 분 소요)')
|
||||
reader = easyocr.Reader(['ko', 'en'], gpu=False, verbose=False)
|
||||
print('[로딩] 완료')
|
||||
|
||||
images = sorted([p for p in args.img_dir.iterdir() if p.suffix.lower() in {'.png', '.jpg', '.jpeg'}])
|
||||
if args.only:
|
||||
only_set = {s.strip() for s in args.only.split('|') if s.strip()}
|
||||
images = [p for p in images if p.stem in only_set]
|
||||
|
||||
existing: dict[str, Any] = load_json(args.out, {})
|
||||
failures: dict[str, str] = load_json(args.fail, {})
|
||||
|
||||
pending = [p for p in images if p.stem not in existing]
|
||||
if args.limit:
|
||||
pending = pending[: args.limit]
|
||||
|
||||
print(f'[대상] {len(images)}개 중 대기 {len(pending)}개, 이미 처리 {len(existing)}개')
|
||||
|
||||
ok = 0
|
||||
fail = 0
|
||||
for i, path in enumerate(pending, start=1):
|
||||
name = path.stem
|
||||
try:
|
||||
items = run_ocr(path, reader)
|
||||
parsed = parse_card(items)
|
||||
if args.debug:
|
||||
print(f'\n--- {name} (텍스트 {len(items)}개) ---')
|
||||
for row in group_rows(items):
|
||||
print(' |', ' │ '.join(f'{c["text"]}' for c in row))
|
||||
print(f' → parsed: {parsed}')
|
||||
existing[name] = parsed
|
||||
if name in failures:
|
||||
del failures[name]
|
||||
ok += 1
|
||||
except Exception as e: # noqa: BLE001
|
||||
failures[name] = f'{type(e).__name__}: {e}'[:500]
|
||||
fail += 1
|
||||
print(f'[실패] {name}: {e}')
|
||||
|
||||
if i % 10 == 0 or i == len(pending):
|
||||
args.out.write_text(json.dumps(existing, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
args.fail.write_text(json.dumps(failures, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
print(f' 진행 {i}/{len(pending)} (성공 {ok}, 실패 {fail}) - 중간 저장')
|
||||
|
||||
args.out.write_text(json.dumps(existing, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
args.fail.write_text(json.dumps(failures, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
|
||||
print(f'\n[완료] 성공 {ok} / 실패 {fail}')
|
||||
print(f' 결과: {args.out}')
|
||||
if failures:
|
||||
print(f' 실패 목록: {args.fail}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
29
backend/scripts/hns-import/requirements.txt
Normal file
29
backend/scripts/hns-import/requirements.txt
Normal file
@ -0,0 +1,29 @@
|
||||
# extract-excel.py 용
|
||||
openpyxl>=3.1.0
|
||||
|
||||
# ocr-local.py 용 (EasyOCR 기반 로컬 OCR, 대안 파이프라인)
|
||||
easyocr==1.7.2
|
||||
filelock==3.19.1
|
||||
fsspec==2025.10.0
|
||||
ImageIO==2.37.2
|
||||
Jinja2==3.1.6
|
||||
lazy-loader==0.5
|
||||
MarkupSafe==3.0.3
|
||||
mpmath==1.3.0
|
||||
networkx==3.2.1
|
||||
ninja==1.13.0
|
||||
numpy==2.0.2
|
||||
opencv-python-headless==4.13.0.92
|
||||
packaging==26.1
|
||||
pillow==11.3.0
|
||||
pyclipper==1.3.0.post6
|
||||
python-bidi==0.6.7
|
||||
PyYAML==6.0.3
|
||||
scikit-image==0.24.0
|
||||
scipy==1.13.1
|
||||
shapely==2.0.7
|
||||
sympy==1.14.0
|
||||
tifffile==2024.8.30
|
||||
torch==2.8.0
|
||||
torchvision==0.23.0
|
||||
typing_extensions==4.15.0
|
||||
@ -24,8 +24,14 @@ async function seedHnsSubstances() {
|
||||
|
||||
let inserted = 0
|
||||
|
||||
// varchar 길이 제한에 맞춰 첫 번째 토큰만 검색 컬럼에 저장 (원본은 DATA JSONB에 보존)
|
||||
const firstToken = (v: unknown, max: number): string | null => {
|
||||
if (v == null) return null
|
||||
const s = String(v).split(/[\n,;/]/)[0].trim()
|
||||
return s ? s.slice(0, max) : null
|
||||
}
|
||||
|
||||
for (const s of HNS_SEARCH_DB) {
|
||||
// 검색용 컬럼 추출, 나머지는 DATA JSONB로 저장
|
||||
const { abbreviation, nameKr, nameEn, unNumber, casNumber, sebc, ...detailData } = s
|
||||
|
||||
await client.query(
|
||||
@ -39,7 +45,16 @@ async function seedHnsSubstances() {
|
||||
CAS_NO = EXCLUDED.CAS_NO,
|
||||
SEBC = EXCLUDED.SEBC,
|
||||
DATA = EXCLUDED.DATA`,
|
||||
[s.id, abbreviation, nameKr, nameEn, unNumber, casNumber, sebc, JSON.stringify(detailData)]
|
||||
[
|
||||
s.id,
|
||||
firstToken(abbreviation, 50),
|
||||
firstToken(nameKr, 200) ?? '',
|
||||
firstToken(nameEn, 200),
|
||||
firstToken(unNumber, 10),
|
||||
firstToken(casNumber, 20),
|
||||
firstToken(sebc, 50),
|
||||
JSON.stringify(detailData),
|
||||
]
|
||||
)
|
||||
|
||||
inserted++
|
||||
|
||||
@ -31,6 +31,8 @@ export interface HNSSearchSubstance {
|
||||
idlh: string;
|
||||
aegl2: string;
|
||||
erpg2: string;
|
||||
twa: string;
|
||||
stel: string;
|
||||
/* 방제거리 */
|
||||
responseDistanceFire: string;
|
||||
responseDistanceSpillDay: string;
|
||||
|
||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -1843,17 +1843,19 @@ function HmsDetailPanel({
|
||||
];
|
||||
const nfpa = s.nfpa;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const sebcColor = s.sebc.startsWith('G')
|
||||
? 'var(--color-accent)'
|
||||
: s.sebc.startsWith('E')
|
||||
const sebcColor = !s.sebc
|
||||
? 'var(--fg-sub)'
|
||||
: s.sebc.startsWith('G')
|
||||
? 'var(--color-accent)'
|
||||
: s.sebc.startsWith('F')
|
||||
? 'var(--color-caution)'
|
||||
: s.sebc.startsWith('D')
|
||||
? 'var(--color-accent)'
|
||||
: s.sebc.startsWith('S')
|
||||
: s.sebc.startsWith('E')
|
||||
? 'var(--color-accent)'
|
||||
: s.sebc.startsWith('F')
|
||||
? 'var(--color-caution)'
|
||||
: s.sebc.startsWith('D')
|
||||
? 'var(--color-accent)'
|
||||
: 'var(--fg-sub)';
|
||||
: s.sebc.startsWith('S')
|
||||
? 'var(--color-accent)'
|
||||
: 'var(--fg-sub)';
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user