858 lines
32 KiB
Markdown
858 lines
32 KiB
Markdown
|
|
질문 : deepseek-OCR 이 공개 프로그램을 어떻게 사용할 수 있나?
|
|||
|
|
|
|||
|
|
답변 :
|
|||
|
|
딥시크(DeepSeek)의 공개 OCR 모델 **DeepSeek-OCR**를 로컬/서버에서 바로 돌릴 수 있어요. 핵심은 두 가지 실행 경로(vLLM 또는 Hugging Face Transformers) 중 하나를 고르는 것.
|
|||
|
|
|
|||
|
|
### 1) 이게 뭐고, 뭘 잘하나
|
|||
|
|
|
|||
|
|
* 문서/표/차트까지 포함한 **레이아웃 보존형 OCR → Markdown 변환**이 강점. 프롬프트로 “Convert the document to markdown” 같은 지시를 주면 구조화해서 뽑아줘요. ([GitHub][1])
|
|||
|
|
* 오픈소스(MIT) + 공개 가중치(Hugging Face). 최근 **vLLM 업스트림 공식 지원**도 들어갔습니다. ([GitHub][1])
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 빠른 설치 (CUDA 11.8, PyTorch 2.6 기준)
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
# 0) 필수: NVIDIA 드라이버 + CUDA 11.8 런타임 환경
|
|||
|
|
# 1) 클론
|
|||
|
|
git clone https://github.com/deepseek-ai/DeepSeek-OCR.git
|
|||
|
|
cd DeepSeek-OCR
|
|||
|
|
|
|||
|
|
# 2) 가상환경 (conda 예시)
|
|||
|
|
conda create -n deepseek-ocr python=3.12.9 -y
|
|||
|
|
conda activate deepseek-ocr
|
|||
|
|
|
|||
|
|
# 3) 패키지 (vLLM 경로)
|
|||
|
|
pip install torch==2.6.0 torchvision==0.21.0 torchaudio==2.6.0 --index-url https://download.pytorch.org/whl/cu118
|
|||
|
|
# vLLM 0.8.5 wheel 설치 (리드미의 .whl 사용) + requirements
|
|||
|
|
pip install vllm-0.8.5+cu118-cp38-abi3-manylinux1_x86_64.whl
|
|||
|
|
pip install -r requirements.txt
|
|||
|
|
pip install flash-attn==2.7.3 --no-build-isolation
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> 위 버전 고정과 설치 순서는 프로젝트 리드미의 권장 스펙입니다. ([GitHub][1])
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 실행 ①: vLLM 경로 (성능/동시성 유리)
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cd DeepSeek-OCR-master/DeepSeek-OCR-vllm
|
|||
|
|
# config.py 에 INPUT_PATH / OUTPUT_PATH 등 경로 설정
|
|||
|
|
# 1) 단일/다중 이미지 OCR (스트리밍 출력)
|
|||
|
|
python run_dpsk_ocr_image.py
|
|||
|
|
# 2) PDF 대량 처리 (예: A100-40G 기준 ~2500 tokens/s 표기)
|
|||
|
|
python run_dpsk_ocr_pdf.py
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
* vLLM 최신 야간 빌드로도 구동 가능 (업스트림 공식 지원). 예시 스니펫은 HF 모델카드 참고. ([GitHub][1])
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 실행 ②: Transformers 경로 (간단 통합/커스터마이즈 용이)
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
from transformers import AutoModel, AutoTokenizer
|
|||
|
|
import torch, os
|
|||
|
|
os.environ["CUDA_VISIBLE_DEVICES"] = '0'
|
|||
|
|
m = 'deepseek-ai/DeepSeek-OCR'
|
|||
|
|
tok = AutoTokenizer.from_pretrained(m, trust_remote_code=True)
|
|||
|
|
model = AutoModel.from_pretrained(
|
|||
|
|
m, _attn_implementation='flash_attention_2',
|
|||
|
|
trust_remote_code=True, use_safetensors=True
|
|||
|
|
).eval().cuda().to(torch.bfloat16)
|
|||
|
|
|
|||
|
|
prompt = "<image>\n<|grounding|>Convert the document to markdown. "
|
|||
|
|
image_file = "sample.jpg"
|
|||
|
|
output_dir = "./out"
|
|||
|
|
|
|||
|
|
res = model.infer(tok, prompt=prompt, image_file=image_file,
|
|||
|
|
output_path=output_dir, base_size=1024,
|
|||
|
|
image_size=640, crop_mode=True,
|
|||
|
|
save_results=True, test_compress=True)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
* 모델/토크나이저는 HF에서 받아옵니다. **Tiny/Small/Base/Large** 입력 해상도 프리셋도 제공. ([Hugging Face][2])
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 주요 프롬프트 예시
|
|||
|
|
|
|||
|
|
* 문서 → 마크다운:
|
|||
|
|
`<image>\n<|grounding|>Convert the document to markdown.`
|
|||
|
|
* 단순 문자만:
|
|||
|
|
`<image>\nFree OCR.`
|
|||
|
|
* 피겨/차트 파싱:
|
|||
|
|
`<image>\nParse the figure.`
|
|||
|
|
리드미에 여러 프롬프트가 예시로 포함되어 있어요. ([GitHub][1])
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## PDF 배치 처리·API로 감싸기
|
|||
|
|
|
|||
|
|
* **공식 스크립트**: `run_dpsk_ocr_pdf.py` 로 폴더 단위 처리. ([GitHub][1])
|
|||
|
|
* **빠른 웹/API 템플릿**(비공식): FastAPI+React로 감싼 샘플들이 여럿 있어요.
|
|||
|
|
예: “PDF→Markdown 변환” REST API, 또는 데모 웹앱. ([GitHub][3])
|
|||
|
|
* **러스트 경량 서버**(CPU/Metal/초기 CUDA): OpenAI 호환 HTTP 서버/CLI 포함. 로컬 파이프라인 구성에 유용. ([GitHub][4])
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 리소스/성능 메모
|
|||
|
|
|
|||
|
|
* HF 카드 기준 모델은 약 **3B 파라미터, BF16**, GPU 구동 전제 예시가 제공됩니다(Transformers 섹션). CPU도 이론상 가능하나 실사용 속도는 크게 떨어질 수 있어요. vLLM 경로는 대용량 처리에 유리. ([Hugging Face][2])
|
|||
|
|
* 리드미에는 **A100-40G에서 PDF 처리 체감치(토큰/s)** 예시가 실려 있습니다. ([GitHub][1])
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 보안/정책 유의(특히 기업 환경)
|
|||
|
|
|
|||
|
|
일부 공공기관·기업은 DeepSeek 계열 모델 사용을 제한하기도 합니다. 예컨대 **미 상무부 일부 국·실국은 2025-03-17 공지로 DeepSeek 사용 금지**를 안내했습니다. 회사 정책과 고객사 보안요건을 먼저 확인하세요. ([Reuters][5])
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 공식 문서/레퍼런스
|
|||
|
|
|
|||
|
|
* GitHub(설치·스크립트·프롬프트·PDF 처리): ([GitHub][1])
|
|||
|
|
* Hugging Face 모델 카드(버전 고정·vLLM 예시): ([Hugging Face][2])
|
|||
|
|
* vLLM 가이드(오프라인 배치 레시피): ([VLLM Documentation][6])
|
|||
|
|
* 연구/아티클(컨텍스트 압축 아이디어): ([arXiv][7])
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### 다음 선택지
|
|||
|
|
|
|||
|
|
* 운영 관점: **배치 파이프라인(PDF 폴더→MD/JSON)** 또는 **REST API** 중 하나로 고정.
|
|||
|
|
* 프롬프트/후처리: 표/수식/도형은 “Convert to markdown” + 후처리(표 파서, 수식 라텍스 보정) 조합이 안정적.
|
|||
|
|
* 우리 스택(Nginx/Apache + PHP/Laravel + Jenkins)에서라면: **별도 GPU 호스트 + FastAPI 서비스**로 세워 두고, Admin/API에서 비동기 잡 큐로 호출하는 구조가 깔끔합니다.
|
|||
|
|
|
|||
|
|
원하시면 **PDF 폴더 일괄 변환 스크립트**(입력/출력 디렉토리, 동시성, 실패 리트라이 포함)나 **FastAPI 라우터 샘플**까지 바로 짜드릴게요.
|
|||
|
|
|
|||
|
|
[1]: https://github.com/deepseek-ai/DeepSeek-OCR "GitHub - deepseek-ai/DeepSeek-OCR: Contexts Optical Compression"
|
|||
|
|
[2]: https://huggingface.co/deepseek-ai/DeepSeek-OCR "deepseek-ai/DeepSeek-OCR · Hugging Face"
|
|||
|
|
[3]: https://github.com/Bogdanovich77/DeekSeek-OCR---Dockerized-API?utm_source=chatgpt.com "Bogdanovich77/DeekSeek-OCR---Dockerized-API"
|
|||
|
|
[4]: https://github.com/TimmyOVO/deepseek-ocr.rs?utm_source=chatgpt.com "TimmyOVO/deepseek-ocr.rs"
|
|||
|
|
[5]: https://www.reuters.com/technology/artificial-intelligence/us-commerce-department-bureaus-ban-chinas-deepseek-government-devices-sources-2025-03-17/?utm_source=chatgpt.com "US Commerce department bureaus ban China's DeepSeek on government devices, sources say"
|
|||
|
|
[6]: https://docs.vllm.ai/projects/recipes/en/latest/DeepSeek/DeepSeek-OCR.html?utm_source=chatgpt.com "DeepSeek-OCR Usage Guide - vLLM Recipes"
|
|||
|
|
[7]: https://arxiv.org/html/2510.18234v1?utm_source=chatgpt.com "DeepSeek-OCR: Contexts Optical Compression"
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
질문 :
|
|||
|
|
사업자등록증 pdf를 업로드 해서 AI를 활용한 OCR 기술로 그 사업자 등록증의 형태에 맞게 웹이나 앱에서 이 입력부분을 자동으로 처리하는 것을 만들려고 한다. 만약 php와 자바스크립트를 이용해서 이 로직구현이 가능할까? 추후 python등의 서버를 구비해서 API형태로 만들고 싶다.
|
|||
|
|
|
|||
|
|
|
|||
|
|
AI 답변 :
|
|||
|
|
충분히 됩니다. 카페24 **웹호스팅(PHP 7.3 / MySQL 8.0)** 기준으로, 서버에 바이너리를 깔 수 없으니 **브라우저에서 Tesseract.js + pdf.js**로 OCR을 돌리고, **추출 결과만** PHP로 저장하는 구조가 가장 현실적이에요. 그대로 가져다 쓸 수 있게 “파일 단위”로 정리했습니다.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# 0) DB 테이블 (MySQL 8.0)
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
CREATE TABLE biz_cert (
|
|||
|
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|||
|
|
biz_no VARCHAR(20) NOT NULL, -- 000-00-00000
|
|||
|
|
company_name VARCHAR(255) NOT NULL,
|
|||
|
|
representative VARCHAR(100) NOT NULL,
|
|||
|
|
open_date DATE NULL,
|
|||
|
|
address TEXT NULL,
|
|||
|
|
type VARCHAR(255) NULL,
|
|||
|
|
item VARCHAR(255) NULL,
|
|||
|
|
issue_date DATE NULL,
|
|||
|
|
raw_text LONGTEXT NULL, -- 원문 텍스트(디버그용)
|
|||
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|||
|
|
PRIMARY KEY (id),
|
|||
|
|
KEY idx_biz_no (biz_no)
|
|||
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# 1) `/config/db.php` (PDO 연결 공통)
|
|||
|
|
|
|||
|
|
```php
|
|||
|
|
<?php
|
|||
|
|
// /config/db.php
|
|||
|
|
define('DB_HOST', 'localhost');
|
|||
|
|
define('DB_NAME', 'YOUR_DB_NAME');
|
|||
|
|
define('DB_USER', 'YOUR_DB_USER');
|
|||
|
|
define('DB_PASS', 'YOUR_DB_PASS');
|
|||
|
|
define('DB_CHARSET', 'utf8mb4');
|
|||
|
|
|
|||
|
|
function db_connect(): PDO {
|
|||
|
|
$dsn = 'mysql:host='.DB_HOST.';dbname='.DB_NAME.';charset='.DB_CHARSET;
|
|||
|
|
$opt = [
|
|||
|
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
|||
|
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
|||
|
|
];
|
|||
|
|
return new PDO($dsn, DB_USER, DB_PASS, $opt);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# 2) `/public/index.html` (업로드→브라우저 OCR→자동채움→저장)
|
|||
|
|
|
|||
|
|
```html
|
|||
|
|
<!doctype html>
|
|||
|
|
<html lang="ko">
|
|||
|
|
<head>
|
|||
|
|
<meta charset="utf-8" />
|
|||
|
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|||
|
|
<title>사업자등록증 OCR (MVP, PHP7.3/MySQL8)</title>
|
|||
|
|
<style>
|
|||
|
|
body{font:14px/1.4 system-ui,Segoe UI,Apple SD Gothic Neo,Malgun Gothic}
|
|||
|
|
.row{display:flex;gap:24px}
|
|||
|
|
.col{flex:1}
|
|||
|
|
#preview{max-width:100%;border:1px solid #ddd}
|
|||
|
|
.hl{background:#fff7cc}
|
|||
|
|
.mono{font-family:ui-monospace,Consolas,monospace}
|
|||
|
|
</style>
|
|||
|
|
</head>
|
|||
|
|
<body>
|
|||
|
|
<h2>사업자등록증 OCR (AI 없이, 브라우저 Tesseract.js)</h2>
|
|||
|
|
|
|||
|
|
<input id="file" type="file" accept="application/pdf,image/*" />
|
|||
|
|
<span id="status">대기중</span>
|
|||
|
|
<div class="row" style="margin-top:16px">
|
|||
|
|
<div class="col">
|
|||
|
|
<canvas id="pdf-canvas" style="display:none"></canvas>
|
|||
|
|
<img id="preview" alt="미리보기" />
|
|||
|
|
<pre id="raw" class="mono" style="white-space:pre-wrap;background:#f7f7f7;padding:8px"></pre>
|
|||
|
|
</div>
|
|||
|
|
<div class="col">
|
|||
|
|
<form id="biz-form">
|
|||
|
|
<p><input class="hl" name="biz_no" placeholder="사업자등록번호 (000-00-00000)" style="width:100%"></p>
|
|||
|
|
<p><input class="hl" name="company_name" placeholder="상호" style="width:100%"></p>
|
|||
|
|
<p><input class="hl" name="representative" placeholder="대표자" style="width:100%"></p>
|
|||
|
|
<p><input class="hl" name="open_date" placeholder="개업연월일 (YYYY-MM-DD)" style="width:100%"></p>
|
|||
|
|
<p><input class="hl" name="address" placeholder="사업장 소재지" style="width:100%"></p>
|
|||
|
|
<p><input class="hl" name="type" placeholder="업태" style="width:100%"></p>
|
|||
|
|
<p><input class="hl" name="item" placeholder="종목" style="width:100%"></p>
|
|||
|
|
<p><input name="issue_date" placeholder="발급일자 (선택)" style="width:100%"></p>
|
|||
|
|
<button type="button" id="save">저장</button>
|
|||
|
|
</form>
|
|||
|
|
<div id="save-status"></div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- pdf.js (브라우저에서 PDF → canvas 렌더링) -->
|
|||
|
|
<script src="https://cdn.jsdelivr.net/npm/pdfjs-dist@4.6.82/build/pdf.min.js"></script>
|
|||
|
|
<script>pdfjsLib.GlobalWorkerOptions.workerSrc="https://cdn.jsdelivr.net/npm/pdfjs-dist@4.6.82/build/pdf.worker.min.js";</script>
|
|||
|
|
|
|||
|
|
<!-- Tesseract.js (브라우저 OCR) -->
|
|||
|
|
<script src="https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/tesseract.min.js"></script>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
const $ = (q)=>document.querySelector(q);
|
|||
|
|
const statusEl = $('#status');
|
|||
|
|
const rawEl = $('#raw');
|
|||
|
|
const previewEl = $('#preview');
|
|||
|
|
const pdfCanvas = $('#pdf-canvas');
|
|||
|
|
|
|||
|
|
function normalizeBiz(v){
|
|||
|
|
const d=(v||'').replace(/\D/g,'');
|
|||
|
|
if(d.length===10) return d.slice(0,3)+'-'+d.slice(3,5)+'-'+d.slice(5);
|
|||
|
|
return v||'';
|
|||
|
|
}
|
|||
|
|
function isValidBizNo(v){
|
|||
|
|
const d=(v||'').replace(/\D/g,''); if(d.length!==10) return false;
|
|||
|
|
const w=[1,3,7,1,3,7,1,3,5]; let sum=0;
|
|||
|
|
for(let i=0;i<9;i++) sum += Number(d[i])*w[i];
|
|||
|
|
sum += Math.floor((Number(d[8])*5)/10);
|
|||
|
|
const check=(10-(sum%10))%10; return check===Number(d[9]);
|
|||
|
|
}
|
|||
|
|
function toDateISO(s){
|
|||
|
|
if(!s) return '';
|
|||
|
|
const d=s.replace(/[년월.\-\/\s]/g,'-').replace(/-+/g,'-').replace(/-$/, '');
|
|||
|
|
const m=d.match(/(\d{4})-(\d{1,2})-(\d{1,2})/);
|
|||
|
|
return m ? `${m[1]}-${m[2].padStart(2,'0')}-${m[3].padStart(2,'0')}` : '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 간단 파서 (OCR 텍스트 → 필드)
|
|||
|
|
function parseBiz(text){
|
|||
|
|
const T=text||'';
|
|||
|
|
const pick=(re)=>{const m=T.match(re); return m?m[1].trim():''};
|
|||
|
|
|
|||
|
|
// 앵커 기반
|
|||
|
|
let biz = pick(/사업자등록번호[^\d]*(\d{3}[\s\-]?\d{2}[\s\-]?\d{5})/u);
|
|||
|
|
let company = pick(/상\s*호[^\S\r\n]*[::]?\s*([^\r\n]+)/u);
|
|||
|
|
let rep = pick(/대표자[^\S\r\n]*[::]?\s*([^\r\n]+)/u);
|
|||
|
|
let open = pick(/개업[^\r\n]*(\d{4}[.\-년]\s*\d{1,2}[.\-월]?\s*\d{1,2})/u);
|
|||
|
|
let addr = pick(/사업장\s*소재지[^\S\r\n]*[::]?\s*([^\r\n]+(?:\r?\n[^\r\n]+)?)/u);
|
|||
|
|
let type = pick(/업태[^\S\r\n]*[::]?\s*([^\r\n]+)/u);
|
|||
|
|
let item = pick(/종목[^\S\r\n]*[::]?\s*([^\r\n]+)/u);
|
|||
|
|
let issue = pick(/발급일자[^\d]*(\d{4}[.\-\/]\s*\d{1,2}[.\-\/]?\s*\d{1,2})/u);
|
|||
|
|
|
|||
|
|
biz = normalizeBiz(biz);
|
|||
|
|
open = toDateISO(open);
|
|||
|
|
issue = toDateISO(issue);
|
|||
|
|
|
|||
|
|
return { biz_no: biz, company_name: company, representative: rep,
|
|||
|
|
open_date: open, address: addr, type, item, issue_date: issue };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// PDF → canvas → dataURL
|
|||
|
|
async function pdfFirstPageToDataURL(file){
|
|||
|
|
const buf = await file.arrayBuffer();
|
|||
|
|
const pdf = await pdfjsLib.getDocument({data:buf}).promise;
|
|||
|
|
const page = await pdf.getPage(1);
|
|||
|
|
const viewport = page.getViewport({scale: 2.0});
|
|||
|
|
const canvas = pdfCanvas; const ctx = canvas.getContext('2d');
|
|||
|
|
canvas.width = viewport.width; canvas.height = viewport.height;
|
|||
|
|
await page.render({canvasContext:ctx, viewport}).promise;
|
|||
|
|
return canvas.toDataURL('image/png');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function runOCR(imageDataURL){
|
|||
|
|
statusEl.textContent = 'OCR 시작...';
|
|||
|
|
const { data } = await Tesseract.recognize(
|
|||
|
|
imageDataURL,
|
|||
|
|
'kor+eng',
|
|||
|
|
{
|
|||
|
|
logger: m => { if(m.status==='recognizing text'){ statusEl.textContent = `인식중... ${Math.round(m.progress*100)}%`; } },
|
|||
|
|
// 학습 데이터 경로 (공개 CDN)
|
|||
|
|
langPath: 'https://tessdata.projectnaptha.com/4.0.0'
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
statusEl.textContent = 'OCR 완료';
|
|||
|
|
return data.text || '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$('#file').addEventListener('change', async (e)=>{
|
|||
|
|
const f = e.target.files[0]; if(!f) return;
|
|||
|
|
rawEl.textContent = ''; statusEl.textContent='준비중...';
|
|||
|
|
|
|||
|
|
let imageDataURL=null;
|
|||
|
|
|
|||
|
|
if(f.type==='application/pdf'){
|
|||
|
|
imageDataURL = await pdfFirstPageToDataURL(f); // 1페이지만 예시
|
|||
|
|
previewEl.src = imageDataURL;
|
|||
|
|
} else if(f.type.startsWith('image/')){
|
|||
|
|
imageDataURL = URL.createObjectURL(f);
|
|||
|
|
previewEl.src = imageDataURL;
|
|||
|
|
} else {
|
|||
|
|
statusEl.textContent = '지원하지 않는 형식';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const text = await runOCR(imageDataURL);
|
|||
|
|
rawEl.textContent = text;
|
|||
|
|
|
|||
|
|
const fields = parseBiz(text);
|
|||
|
|
for(const k in fields){
|
|||
|
|
const el=document.querySelector(`[name="${k}"]`);
|
|||
|
|
if(el) el.value = fields[k] || '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 즉시 유효성 힌트
|
|||
|
|
const biz = document.querySelector('[name="biz_no"]').value;
|
|||
|
|
if(biz && !isValidBizNo(biz)) statusEl.textContent = '참고: 사업자번호 체크섬 불일치(수정 검토)';
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
$('#save').addEventListener('click', async ()=>{
|
|||
|
|
const form = $('#biz-form'); const obj = {};
|
|||
|
|
new FormData(form).forEach((v,k)=>obj[k]=v);
|
|||
|
|
obj['raw_text'] = rawEl.textContent;
|
|||
|
|
|
|||
|
|
const res = await fetch('/save_biz.php', {
|
|||
|
|
method:'POST',
|
|||
|
|
headers:{'Content-Type':'application/json'},
|
|||
|
|
body: JSON.stringify(obj)
|
|||
|
|
});
|
|||
|
|
const data = await res.json();
|
|||
|
|
$('#save-status').textContent = data.ok ? `저장 완료 (id=${data.id})` : `실패: ${data.error||'unknown'}`;
|
|||
|
|
});
|
|||
|
|
</script>
|
|||
|
|
</body>
|
|||
|
|
</html>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> 포인트
|
|||
|
|
>
|
|||
|
|
> * **PDF도 브라우저에서 1페이지만 렌더** → 이미지처럼 OCR. (여러 페이지면 반복 처리)
|
|||
|
|
> * **언어팩**: `kor+eng`, `langPath`는 공개 CDN 사용.
|
|||
|
|
> * **정규식 파싱**은 앵커(“사업자등록번호”, “상호”… ) 기반이라 양식이 조금 달라도 견딥니다.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# 3) `/save_biz.php` (추출 결과 저장 API)
|
|||
|
|
|
|||
|
|
```php
|
|||
|
|
<?php
|
|||
|
|
// /save_biz.php
|
|||
|
|
header('Content-Type: application/json; charset=utf-8');
|
|||
|
|
require_once __DIR__.'/config/db.php';
|
|||
|
|
|
|||
|
|
function normalize_biz($v){
|
|||
|
|
$d = preg_replace('/\D/','',$v??'');
|
|||
|
|
return (strlen($d)===10) ? substr($d,0,3).'-'.substr($d,3,2).'-'.substr($d,5) : ($v??'');
|
|||
|
|
}
|
|||
|
|
function valid_biz_no($s){
|
|||
|
|
$d = preg_replace('/\D/','',$s??'');
|
|||
|
|
if(strlen($d)!=10) return false;
|
|||
|
|
$w=[1,3,7,1,3,7,1,3,5]; $sum=0;
|
|||
|
|
for($i=0;$i<9;$i++){ $sum += intval($d[$i])*$w[$i]; }
|
|||
|
|
$sum += intdiv(intval($d[8])*5,10);
|
|||
|
|
$check = (10 - ($sum % 10)) % 10;
|
|||
|
|
return $check == intval($d[9]);
|
|||
|
|
}
|
|||
|
|
function to_date($s){
|
|||
|
|
if(!$s) return null;
|
|||
|
|
$d = preg_replace('/[년월.\-\/\s]+/','-',$s);
|
|||
|
|
if(preg_match('/^(\d{4})-(\d{1,2})-(\d{1,2})$/',$d,$m)){
|
|||
|
|
return sprintf('%04d-%02d-%02d',$m[1],$m[2],$m[3]);
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
|||
|
|
|
|||
|
|
$biz_no = normalize_biz($input['biz_no'] ?? '');
|
|||
|
|
$company_name = trim($input['company_name'] ?? '');
|
|||
|
|
$representative = trim($input['representative'] ?? '');
|
|||
|
|
$open_date = to_date($input['open_date'] ?? '');
|
|||
|
|
$address = trim($input['address'] ?? '');
|
|||
|
|
$type = trim($input['type'] ?? '');
|
|||
|
|
$item = trim($input['item'] ?? '');
|
|||
|
|
$issue_date = to_date($input['issue_date'] ?? '');
|
|||
|
|
$raw_text = $input['raw_text'] ?? '';
|
|||
|
|
|
|||
|
|
if(!$biz_no || !$company_name){
|
|||
|
|
echo json_encode(['ok'=>false,'error'=>'필수값 누락(biz_no/company_name)']); exit;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try{
|
|||
|
|
$pdo = db_connect();
|
|||
|
|
$stmt = $pdo->prepare("
|
|||
|
|
INSERT INTO biz_cert
|
|||
|
|
(biz_no, company_name, representative, open_date, address, type, item, issue_date, raw_text)
|
|||
|
|
VALUES (:biz_no, :company_name, :representative, :open_date, :address, :type, :item, :issue_date, :raw_text)
|
|||
|
|
");
|
|||
|
|
$stmt->execute([
|
|||
|
|
':biz_no'=>$biz_no,
|
|||
|
|
':company_name'=>$company_name,
|
|||
|
|
':representative'=>$representative,
|
|||
|
|
':open_date'=>$open_date,
|
|||
|
|
':address'=>$address,
|
|||
|
|
':type'=>$type,
|
|||
|
|
':item'=>$item,
|
|||
|
|
':issue_date'=>$issue_date,
|
|||
|
|
':raw_text'=>$raw_text,
|
|||
|
|
]);
|
|||
|
|
echo json_encode(['ok'=>true,'id'=>$pdo->lastInsertId(),'valid_biz_no'=>valid_biz_no($biz_no)]);
|
|||
|
|
} catch(Throwable $e){
|
|||
|
|
http_response_code(500);
|
|||
|
|
echo json_encode(['ok'=>false,'error'=>$e->getMessage()]);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# 4) `/list.php` (최근 20건 확인용)
|
|||
|
|
|
|||
|
|
```php
|
|||
|
|
<?php
|
|||
|
|
// /list.php
|
|||
|
|
header('Content-Type: text/html; charset=utf-8');
|
|||
|
|
require_once __DIR__.'/config/db.php';
|
|||
|
|
$pdo = db_connect();
|
|||
|
|
$rows = $pdo->query("SELECT id,biz_no,company_name,representative,open_date,created_at FROM biz_cert ORDER BY id DESC LIMIT 20")->fetchAll();
|
|||
|
|
?>
|
|||
|
|
<!doctype html><meta charset="utf-8">
|
|||
|
|
<h3>최근 20건</h3>
|
|||
|
|
<table border="1" cellpadding="6" cellspacing="0">
|
|||
|
|
<tr><th>ID</th><th>사업자번호</th><th>상호</th><th>대표자</th><th>개업일</th><th>저장일</th></tr>
|
|||
|
|
<?php foreach($rows as $r): ?>
|
|||
|
|
<tr>
|
|||
|
|
<td><?=htmlspecialchars($r['id'])?></td>
|
|||
|
|
<td><?=htmlspecialchars($r['biz_no'])?></td>
|
|||
|
|
<td><?=htmlspecialchars($r['company_name'])?></td>
|
|||
|
|
<td><?=htmlspecialchars($r['representative'])?></td>
|
|||
|
|
<td><?=htmlspecialchars($r['open_date'])?></td>
|
|||
|
|
<td><?=htmlspecialchars($r['created_at'])?></td>
|
|||
|
|
</tr>
|
|||
|
|
<?php endforeach; ?>
|
|||
|
|
</table>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 운영 팁 (카페24 웹호스팅 한정)
|
|||
|
|
|
|||
|
|
* **서버 exec 미사용**: Tesseract, Poppler 같은 바이너리 설치 불가하므로 브라우저 OCR이 현실적.
|
|||
|
|
* **용량/속도**: PDF가 크면 1페이지만 먼저 처리 → “추가 페이지 인식” 버튼으로 확장.
|
|||
|
|
* **언어팩 캐시**: Tesseract.js가 언어 데이터(약 수~수십 MB)를 내려받습니다. CDN 캐시 이점이 큼.
|
|||
|
|
* **정확도 올리기**: PDF 렌더 스케일을 `2.0→2.5~3.0`으로, 이미지면 `<img>`를 `<canvas>`로 리샘플 후 OCR.
|
|||
|
|
* **보정 UX**: 자동 채움 후 노란 배경(샘플처럼 `.hl`) + 탭 이동 → 저장.
|
|||
|
|
* **보안**: 저장 전 **숫자 마스킹**(예: `대표자 주민번호 일부`)이 필요하면 프런트/백 모두에서 처리.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 다음 단계(나중에 고도화)
|
|||
|
|
|
|||
|
|
* Python API(DeepSeek-OCR/PaddleOCR) 별도 서버 → 같은 방식으로 **/save_biz.php** 앞단에서 교체 호출.
|
|||
|
|
* 앵커 크롭 후 2차 OCR(속도/정확도↑), 주소 정규화, 체크섬 실패 시 재인식 루틴 등.
|
|||
|
|
|
|||
|
|
필요하면 **여러 페이지 PDF 지원**, **라라벨 컨트롤러 버전**, **Docker-Compose 모식도**, **Nginx 리버스 프록시 샘플**까지 이어서 드릴게요.
|
|||
|
|
|
|||
|
|
|
|||
|
|
질문 :
|
|||
|
|
카페24에서 웹호스팅을 해서 사용하는 사이트가 있는데, 위의 라이브러리를 사용해서 직접 구현이 가능하게 단계별 코드를 만들어줘. php 7.3 mysql 8.0 기준으로 보면됩니다.
|
|||
|
|
|
|||
|
|
답변 :
|
|||
|
|
충분히 됩니다. 카페24 **웹호스팅(PHP 7.3 / MySQL 8.0)** 기준으로, 서버에 바이너리를 깔 수 없으니 **브라우저에서 Tesseract.js + pdf.js**로 OCR을 돌리고, **추출 결과만** PHP로 저장하는 구조가 가장 현실적이에요. 그대로 가져다 쓸 수 있게 “파일 단위”로 정리했습니다.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# 0) DB 테이블 (MySQL 8.0)
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
CREATE TABLE biz_cert (
|
|||
|
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|||
|
|
biz_no VARCHAR(20) NOT NULL, -- 000-00-00000
|
|||
|
|
company_name VARCHAR(255) NOT NULL,
|
|||
|
|
representative VARCHAR(100) NOT NULL,
|
|||
|
|
open_date DATE NULL,
|
|||
|
|
address TEXT NULL,
|
|||
|
|
type VARCHAR(255) NULL,
|
|||
|
|
item VARCHAR(255) NULL,
|
|||
|
|
issue_date DATE NULL,
|
|||
|
|
raw_text LONGTEXT NULL, -- 원문 텍스트(디버그용)
|
|||
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|||
|
|
PRIMARY KEY (id),
|
|||
|
|
KEY idx_biz_no (biz_no)
|
|||
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# 1) `/config/db.php` (PDO 연결 공통)
|
|||
|
|
|
|||
|
|
```php
|
|||
|
|
<?php
|
|||
|
|
// /config/db.php
|
|||
|
|
define('DB_HOST', 'localhost');
|
|||
|
|
define('DB_NAME', 'YOUR_DB_NAME');
|
|||
|
|
define('DB_USER', 'YOUR_DB_USER');
|
|||
|
|
define('DB_PASS', 'YOUR_DB_PASS');
|
|||
|
|
define('DB_CHARSET', 'utf8mb4');
|
|||
|
|
|
|||
|
|
function db_connect(): PDO {
|
|||
|
|
$dsn = 'mysql:host='.DB_HOST.';dbname='.DB_NAME.';charset='.DB_CHARSET;
|
|||
|
|
$opt = [
|
|||
|
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
|||
|
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
|||
|
|
];
|
|||
|
|
return new PDO($dsn, DB_USER, DB_PASS, $opt);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# 2) `/public/index.html` (업로드→브라우저 OCR→자동채움→저장)
|
|||
|
|
|
|||
|
|
```html
|
|||
|
|
<!doctype html>
|
|||
|
|
<html lang="ko">
|
|||
|
|
<head>
|
|||
|
|
<meta charset="utf-8" />
|
|||
|
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|||
|
|
<title>사업자등록증 OCR (MVP, PHP7.3/MySQL8)</title>
|
|||
|
|
<style>
|
|||
|
|
body{font:14px/1.4 system-ui,Segoe UI,Apple SD Gothic Neo,Malgun Gothic}
|
|||
|
|
.row{display:flex;gap:24px}
|
|||
|
|
.col{flex:1}
|
|||
|
|
#preview{max-width:100%;border:1px solid #ddd}
|
|||
|
|
.hl{background:#fff7cc}
|
|||
|
|
.mono{font-family:ui-monospace,Consolas,monospace}
|
|||
|
|
</style>
|
|||
|
|
</head>
|
|||
|
|
<body>
|
|||
|
|
<h2>사업자등록증 OCR (AI 없이, 브라우저 Tesseract.js)</h2>
|
|||
|
|
|
|||
|
|
<input id="file" type="file" accept="application/pdf,image/*" />
|
|||
|
|
<span id="status">대기중</span>
|
|||
|
|
<div class="row" style="margin-top:16px">
|
|||
|
|
<div class="col">
|
|||
|
|
<canvas id="pdf-canvas" style="display:none"></canvas>
|
|||
|
|
<img id="preview" alt="미리보기" />
|
|||
|
|
<pre id="raw" class="mono" style="white-space:pre-wrap;background:#f7f7f7;padding:8px"></pre>
|
|||
|
|
</div>
|
|||
|
|
<div class="col">
|
|||
|
|
<form id="biz-form">
|
|||
|
|
<p><input class="hl" name="biz_no" placeholder="사업자등록번호 (000-00-00000)" style="width:100%"></p>
|
|||
|
|
<p><input class="hl" name="company_name" placeholder="상호" style="width:100%"></p>
|
|||
|
|
<p><input class="hl" name="representative" placeholder="대표자" style="width:100%"></p>
|
|||
|
|
<p><input class="hl" name="open_date" placeholder="개업연월일 (YYYY-MM-DD)" style="width:100%"></p>
|
|||
|
|
<p><input class="hl" name="address" placeholder="사업장 소재지" style="width:100%"></p>
|
|||
|
|
<p><input class="hl" name="type" placeholder="업태" style="width:100%"></p>
|
|||
|
|
<p><input class="hl" name="item" placeholder="종목" style="width:100%"></p>
|
|||
|
|
<p><input name="issue_date" placeholder="발급일자 (선택)" style="width:100%"></p>
|
|||
|
|
<button type="button" id="save">저장</button>
|
|||
|
|
</form>
|
|||
|
|
<div id="save-status"></div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- pdf.js (브라우저에서 PDF → canvas 렌더링) -->
|
|||
|
|
<script src="https://cdn.jsdelivr.net/npm/pdfjs-dist@4.6.82/build/pdf.min.js"></script>
|
|||
|
|
<script>pdfjsLib.GlobalWorkerOptions.workerSrc="https://cdn.jsdelivr.net/npm/pdfjs-dist@4.6.82/build/pdf.worker.min.js";</script>
|
|||
|
|
|
|||
|
|
<!-- Tesseract.js (브라우저 OCR) -->
|
|||
|
|
<script src="https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/tesseract.min.js"></script>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
const $ = (q)=>document.querySelector(q);
|
|||
|
|
const statusEl = $('#status');
|
|||
|
|
const rawEl = $('#raw');
|
|||
|
|
const previewEl = $('#preview');
|
|||
|
|
const pdfCanvas = $('#pdf-canvas');
|
|||
|
|
|
|||
|
|
function normalizeBiz(v){
|
|||
|
|
const d=(v||'').replace(/\D/g,'');
|
|||
|
|
if(d.length===10) return d.slice(0,3)+'-'+d.slice(3,5)+'-'+d.slice(5);
|
|||
|
|
return v||'';
|
|||
|
|
}
|
|||
|
|
function isValidBizNo(v){
|
|||
|
|
const d=(v||'').replace(/\D/g,''); if(d.length!==10) return false;
|
|||
|
|
const w=[1,3,7,1,3,7,1,3,5]; let sum=0;
|
|||
|
|
for(let i=0;i<9;i++) sum += Number(d[i])*w[i];
|
|||
|
|
sum += Math.floor((Number(d[8])*5)/10);
|
|||
|
|
const check=(10-(sum%10))%10; return check===Number(d[9]);
|
|||
|
|
}
|
|||
|
|
function toDateISO(s){
|
|||
|
|
if(!s) return '';
|
|||
|
|
const d=s.replace(/[년월.\-\/\s]/g,'-').replace(/-+/g,'-').replace(/-$/, '');
|
|||
|
|
const m=d.match(/(\d{4})-(\d{1,2})-(\d{1,2})/);
|
|||
|
|
return m ? `${m[1]}-${m[2].padStart(2,'0')}-${m[3].padStart(2,'0')}` : '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 간단 파서 (OCR 텍스트 → 필드)
|
|||
|
|
function parseBiz(text){
|
|||
|
|
const T=text||'';
|
|||
|
|
const pick=(re)=>{const m=T.match(re); return m?m[1].trim():''};
|
|||
|
|
|
|||
|
|
// 앵커 기반
|
|||
|
|
let biz = pick(/사업자등록번호[^\d]*(\d{3}[\s\-]?\d{2}[\s\-]?\d{5})/u);
|
|||
|
|
let company = pick(/상\s*호[^\S\r\n]*[::]?\s*([^\r\n]+)/u);
|
|||
|
|
let rep = pick(/대표자[^\S\r\n]*[::]?\s*([^\r\n]+)/u);
|
|||
|
|
let open = pick(/개업[^\r\n]*(\d{4}[.\-년]\s*\d{1,2}[.\-월]?\s*\d{1,2})/u);
|
|||
|
|
let addr = pick(/사업장\s*소재지[^\S\r\n]*[::]?\s*([^\r\n]+(?:\r?\n[^\r\n]+)?)/u);
|
|||
|
|
let type = pick(/업태[^\S\r\n]*[::]?\s*([^\r\n]+)/u);
|
|||
|
|
let item = pick(/종목[^\S\r\n]*[::]?\s*([^\r\n]+)/u);
|
|||
|
|
let issue = pick(/발급일자[^\d]*(\d{4}[.\-\/]\s*\d{1,2}[.\-\/]?\s*\d{1,2})/u);
|
|||
|
|
|
|||
|
|
biz = normalizeBiz(biz);
|
|||
|
|
open = toDateISO(open);
|
|||
|
|
issue = toDateISO(issue);
|
|||
|
|
|
|||
|
|
return { biz_no: biz, company_name: company, representative: rep,
|
|||
|
|
open_date: open, address: addr, type, item, issue_date: issue };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// PDF → canvas → dataURL
|
|||
|
|
async function pdfFirstPageToDataURL(file){
|
|||
|
|
const buf = await file.arrayBuffer();
|
|||
|
|
const pdf = await pdfjsLib.getDocument({data:buf}).promise;
|
|||
|
|
const page = await pdf.getPage(1);
|
|||
|
|
const viewport = page.getViewport({scale: 2.0});
|
|||
|
|
const canvas = pdfCanvas; const ctx = canvas.getContext('2d');
|
|||
|
|
canvas.width = viewport.width; canvas.height = viewport.height;
|
|||
|
|
await page.render({canvasContext:ctx, viewport}).promise;
|
|||
|
|
return canvas.toDataURL('image/png');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function runOCR(imageDataURL){
|
|||
|
|
statusEl.textContent = 'OCR 시작...';
|
|||
|
|
const { data } = await Tesseract.recognize(
|
|||
|
|
imageDataURL,
|
|||
|
|
'kor+eng',
|
|||
|
|
{
|
|||
|
|
logger: m => { if(m.status==='recognizing text'){ statusEl.textContent = `인식중... ${Math.round(m.progress*100)}%`; } },
|
|||
|
|
// 학습 데이터 경로 (공개 CDN)
|
|||
|
|
langPath: 'https://tessdata.projectnaptha.com/4.0.0'
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
statusEl.textContent = 'OCR 완료';
|
|||
|
|
return data.text || '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$('#file').addEventListener('change', async (e)=>{
|
|||
|
|
const f = e.target.files[0]; if(!f) return;
|
|||
|
|
rawEl.textContent = ''; statusEl.textContent='준비중...';
|
|||
|
|
|
|||
|
|
let imageDataURL=null;
|
|||
|
|
|
|||
|
|
if(f.type==='application/pdf'){
|
|||
|
|
imageDataURL = await pdfFirstPageToDataURL(f); // 1페이지만 예시
|
|||
|
|
previewEl.src = imageDataURL;
|
|||
|
|
} else if(f.type.startsWith('image/')){
|
|||
|
|
imageDataURL = URL.createObjectURL(f);
|
|||
|
|
previewEl.src = imageDataURL;
|
|||
|
|
} else {
|
|||
|
|
statusEl.textContent = '지원하지 않는 형식';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const text = await runOCR(imageDataURL);
|
|||
|
|
rawEl.textContent = text;
|
|||
|
|
|
|||
|
|
const fields = parseBiz(text);
|
|||
|
|
for(const k in fields){
|
|||
|
|
const el=document.querySelector(`[name="${k}"]`);
|
|||
|
|
if(el) el.value = fields[k] || '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 즉시 유효성 힌트
|
|||
|
|
const biz = document.querySelector('[name="biz_no"]').value;
|
|||
|
|
if(biz && !isValidBizNo(biz)) statusEl.textContent = '참고: 사업자번호 체크섬 불일치(수정 검토)';
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
$('#save').addEventListener('click', async ()=>{
|
|||
|
|
const form = $('#biz-form'); const obj = {};
|
|||
|
|
new FormData(form).forEach((v,k)=>obj[k]=v);
|
|||
|
|
obj['raw_text'] = rawEl.textContent;
|
|||
|
|
|
|||
|
|
const res = await fetch('/save_biz.php', {
|
|||
|
|
method:'POST',
|
|||
|
|
headers:{'Content-Type':'application/json'},
|
|||
|
|
body: JSON.stringify(obj)
|
|||
|
|
});
|
|||
|
|
const data = await res.json();
|
|||
|
|
$('#save-status').textContent = data.ok ? `저장 완료 (id=${data.id})` : `실패: ${data.error||'unknown'}`;
|
|||
|
|
});
|
|||
|
|
</script>
|
|||
|
|
</body>
|
|||
|
|
</html>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> 포인트
|
|||
|
|
>
|
|||
|
|
> * **PDF도 브라우저에서 1페이지만 렌더** → 이미지처럼 OCR. (여러 페이지면 반복 처리)
|
|||
|
|
> * **언어팩**: `kor+eng`, `langPath`는 공개 CDN 사용.
|
|||
|
|
> * **정규식 파싱**은 앵커(“사업자등록번호”, “상호”… ) 기반이라 양식이 조금 달라도 견딥니다.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# 3) `/save_biz.php` (추출 결과 저장 API)
|
|||
|
|
|
|||
|
|
```php
|
|||
|
|
<?php
|
|||
|
|
// /save_biz.php
|
|||
|
|
header('Content-Type: application/json; charset=utf-8');
|
|||
|
|
require_once __DIR__.'/config/db.php';
|
|||
|
|
|
|||
|
|
function normalize_biz($v){
|
|||
|
|
$d = preg_replace('/\D/','',$v??'');
|
|||
|
|
return (strlen($d)===10) ? substr($d,0,3).'-'.substr($d,3,2).'-'.substr($d,5) : ($v??'');
|
|||
|
|
}
|
|||
|
|
function valid_biz_no($s){
|
|||
|
|
$d = preg_replace('/\D/','',$s??'');
|
|||
|
|
if(strlen($d)!=10) return false;
|
|||
|
|
$w=[1,3,7,1,3,7,1,3,5]; $sum=0;
|
|||
|
|
for($i=0;$i<9;$i++){ $sum += intval($d[$i])*$w[$i]; }
|
|||
|
|
$sum += intdiv(intval($d[8])*5,10);
|
|||
|
|
$check = (10 - ($sum % 10)) % 10;
|
|||
|
|
return $check == intval($d[9]);
|
|||
|
|
}
|
|||
|
|
function to_date($s){
|
|||
|
|
if(!$s) return null;
|
|||
|
|
$d = preg_replace('/[년월.\-\/\s]+/','-',$s);
|
|||
|
|
if(preg_match('/^(\d{4})-(\d{1,2})-(\d{1,2})$/',$d,$m)){
|
|||
|
|
return sprintf('%04d-%02d-%02d',$m[1],$m[2],$m[3]);
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
|||
|
|
|
|||
|
|
$biz_no = normalize_biz($input['biz_no'] ?? '');
|
|||
|
|
$company_name = trim($input['company_name'] ?? '');
|
|||
|
|
$representative = trim($input['representative'] ?? '');
|
|||
|
|
$open_date = to_date($input['open_date'] ?? '');
|
|||
|
|
$address = trim($input['address'] ?? '');
|
|||
|
|
$type = trim($input['type'] ?? '');
|
|||
|
|
$item = trim($input['item'] ?? '');
|
|||
|
|
$issue_date = to_date($input['issue_date'] ?? '');
|
|||
|
|
$raw_text = $input['raw_text'] ?? '';
|
|||
|
|
|
|||
|
|
if(!$biz_no || !$company_name){
|
|||
|
|
echo json_encode(['ok'=>false,'error'=>'필수값 누락(biz_no/company_name)']); exit;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try{
|
|||
|
|
$pdo = db_connect();
|
|||
|
|
$stmt = $pdo->prepare("
|
|||
|
|
INSERT INTO biz_cert
|
|||
|
|
(biz_no, company_name, representative, open_date, address, type, item, issue_date, raw_text)
|
|||
|
|
VALUES (:biz_no, :company_name, :representative, :open_date, :address, :type, :item, :issue_date, :raw_text)
|
|||
|
|
");
|
|||
|
|
$stmt->execute([
|
|||
|
|
':biz_no'=>$biz_no,
|
|||
|
|
':company_name'=>$company_name,
|
|||
|
|
':representative'=>$representative,
|
|||
|
|
':open_date'=>$open_date,
|
|||
|
|
':address'=>$address,
|
|||
|
|
':type'=>$type,
|
|||
|
|
':item'=>$item,
|
|||
|
|
':issue_date'=>$issue_date,
|
|||
|
|
':raw_text'=>$raw_text,
|
|||
|
|
]);
|
|||
|
|
echo json_encode(['ok'=>true,'id'=>$pdo->lastInsertId(),'valid_biz_no'=>valid_biz_no($biz_no)]);
|
|||
|
|
} catch(Throwable $e){
|
|||
|
|
http_response_code(500);
|
|||
|
|
echo json_encode(['ok'=>false,'error'=>$e->getMessage()]);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# 4) `/list.php` (최근 20건 확인용)
|
|||
|
|
|
|||
|
|
```php
|
|||
|
|
<?php
|
|||
|
|
// /list.php
|
|||
|
|
header('Content-Type: text/html; charset=utf-8');
|
|||
|
|
require_once __DIR__.'/config/db.php';
|
|||
|
|
$pdo = db_connect();
|
|||
|
|
$rows = $pdo->query("SELECT id,biz_no,company_name,representative,open_date,created_at FROM biz_cert ORDER BY id DESC LIMIT 20")->fetchAll();
|
|||
|
|
?>
|
|||
|
|
<!doctype html><meta charset="utf-8">
|
|||
|
|
<h3>최근 20건</h3>
|
|||
|
|
<table border="1" cellpadding="6" cellspacing="0">
|
|||
|
|
<tr><th>ID</th><th>사업자번호</th><th>상호</th><th>대표자</th><th>개업일</th><th>저장일</th></tr>
|
|||
|
|
<?php foreach($rows as $r): ?>
|
|||
|
|
<tr>
|
|||
|
|
<td><?=htmlspecialchars($r['id'])?></td>
|
|||
|
|
<td><?=htmlspecialchars($r['biz_no'])?></td>
|
|||
|
|
<td><?=htmlspecialchars($r['company_name'])?></td>
|
|||
|
|
<td><?=htmlspecialchars($r['representative'])?></td>
|
|||
|
|
<td><?=htmlspecialchars($r['open_date'])?></td>
|
|||
|
|
<td><?=htmlspecialchars($r['created_at'])?></td>
|
|||
|
|
</tr>
|
|||
|
|
<?php endforeach; ?>
|
|||
|
|
</table>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 운영 팁 (카페24 웹호스팅 한정)
|
|||
|
|
|
|||
|
|
* **서버 exec 미사용**: Tesseract, Poppler 같은 바이너리 설치 불가하므로 브라우저 OCR이 현실적.
|
|||
|
|
* **용량/속도**: PDF가 크면 1페이지만 먼저 처리 → “추가 페이지 인식” 버튼으로 확장.
|
|||
|
|
* **언어팩 캐시**: Tesseract.js가 언어 데이터(약 수~수십 MB)를 내려받습니다. CDN 캐시 이점이 큼.
|
|||
|
|
* **정확도 올리기**: PDF 렌더 스케일을 `2.0→2.5~3.0`으로, 이미지면 `<img>`를 `<canvas>`로 리샘플 후 OCR.
|
|||
|
|
* **보정 UX**: 자동 채움 후 노란 배경(샘플처럼 `.hl`) + 탭 이동 → 저장.
|
|||
|
|
* **보안**: 저장 전 **숫자 마스킹**(예: `대표자 주민번호 일부`)이 필요하면 프런트/백 모두에서 처리.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 다음 단계(나중에 고도화)
|
|||
|
|
|
|||
|
|
* Python API(DeepSeek-OCR/PaddleOCR) 별도 서버 → 같은 방식으로 **/save_biz.php** 앞단에서 교체 호출.
|
|||
|
|
* 앵커 크롭 후 2차 OCR(속도/정확도↑), 주소 정규화, 체크섬 실패 시 재인식 루틴 등.
|
|||
|
|
|
|||
|
|
필요하면 **여러 페이지 PDF 지원**, **라라벨 컨트롤러 버전**, **Docker-Compose 모식도**, **Nginx 리버스 프록시 샘플**까지 이어서 드릴게요.
|