- URL 하드코딩 → .env APP_URL 기반 동적 URL로 변경 - DB 연결 하드코딩 → .env 기반으로 변경 - MySQL strict mode DATE 오류 수정
32 KiB
질문 : deepseek-OCR 이 공개 프로그램을 어떻게 사용할 수 있나?
답변 : 딥시크(DeepSeek)의 공개 OCR 모델 DeepSeek-OCR를 로컬/서버에서 바로 돌릴 수 있어요. 핵심은 두 가지 실행 경로(vLLM 또는 Hugging Face Transformers) 중 하나를 고르는 것.
1) 이게 뭐고, 뭘 잘하나
- 문서/표/차트까지 포함한 레이아웃 보존형 OCR → Markdown 변환이 강점. 프롬프트로 “Convert the document to markdown” 같은 지시를 주면 구조화해서 뽑아줘요. (GitHub)
- 오픈소스(MIT) + 공개 가중치(Hugging Face). 최근 vLLM 업스트림 공식 지원도 들어갔습니다. (GitHub)
빠른 설치 (CUDA 11.8, PyTorch 2.6 기준)
# 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)
실행 ①: vLLM 경로 (성능/동시성 유리)
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)
실행 ②: Transformers 경로 (간단 통합/커스터마이즈 용이)
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)
주요 프롬프트 예시
- 문서 → 마크다운:
<image>\n<|grounding|>Convert the document to markdown. - 단순 문자만:
<image>\nFree OCR. - 피겨/차트 파싱:
<image>\nParse the figure.리드미에 여러 프롬프트가 예시로 포함되어 있어요. (GitHub)
PDF 배치 처리·API로 감싸기
- 공식 스크립트:
run_dpsk_ocr_pdf.py로 폴더 단위 처리. (GitHub) - 빠른 웹/API 템플릿(비공식): FastAPI+React로 감싼 샘플들이 여럿 있어요. 예: “PDF→Markdown 변환” REST API, 또는 데모 웹앱. (GitHub)
- 러스트 경량 서버(CPU/Metal/초기 CUDA): OpenAI 호환 HTTP 서버/CLI 포함. 로컬 파이프라인 구성에 유용. (GitHub)
리소스/성능 메모
- HF 카드 기준 모델은 약 3B 파라미터, BF16, GPU 구동 전제 예시가 제공됩니다(Transformers 섹션). CPU도 이론상 가능하나 실사용 속도는 크게 떨어질 수 있어요. vLLM 경로는 대용량 처리에 유리. (Hugging Face)
- 리드미에는 A100-40G에서 PDF 처리 체감치(토큰/s) 예시가 실려 있습니다. (GitHub)
보안/정책 유의(특히 기업 환경)
일부 공공기관·기업은 DeepSeek 계열 모델 사용을 제한하기도 합니다. 예컨대 미 상무부 일부 국·실국은 2025-03-17 공지로 DeepSeek 사용 금지를 안내했습니다. 회사 정책과 고객사 보안요건을 먼저 확인하세요. (Reuters)
공식 문서/레퍼런스
- GitHub(설치·스크립트·프롬프트·PDF 처리): (GitHub)
- Hugging Face 모델 카드(버전 고정·vLLM 예시): (Hugging Face)
- vLLM 가이드(오프라인 배치 레시피): (VLLM Documentation)
- 연구/아티클(컨텍스트 압축 아이디어): (arXiv)
다음 선택지
- 운영 관점: 배치 파이프라인(PDF 폴더→MD/JSON) 또는 REST API 중 하나로 고정.
- 프롬프트/후처리: 표/수식/도형은 “Convert to markdown” + 후처리(표 파서, 수식 라텍스 보정) 조합이 안정적.
- 우리 스택(Nginx/Apache + PHP/Laravel + Jenkins)에서라면: 별도 GPU 호스트 + FastAPI 서비스로 세워 두고, Admin/API에서 비동기 잡 큐로 호출하는 구조가 깔끔합니다.
원하시면 PDF 폴더 일괄 변환 스크립트(입력/출력 디렉토리, 동시성, 실패 리트라이 포함)나 FastAPI 라우터 샘플까지 바로 짜드릴게요.
질문 : 사업자등록증 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)
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
// /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→자동채움→저장)
<!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
// /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
// /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)
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
// /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→자동채움→저장)
<!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
// /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
// /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 리버스 프록시 샘플까지 이어서 드릴게요.