efbe298a1f
CI / build (push) Failing after 13m56s
- 홈: 로그인 시 요약(이번달 수입/지출, 순자산, 예산대비지출 바)+바로가기, 비로그인 랜딩 - 영수증 OCR(온디바이스 Tesseract.js): 금액·날짜·상호 추출해 내역 폼 자동입력 - 내역 카드 할부(2~24개월) 입력/표시 - 투자 소수점 수량 입력(step=any) - 계좌 관리 등록버튼 탭 라인 우측 이동, 정기거래 3단 레이아웃 - 목록 좌측 여백 제거(분류/예산/태그/계좌), 대시보드 일/주 버튼 제거, 도넛/파이 차트 잘림 수정 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
96 lines
3.5 KiB
JavaScript
96 lines
3.5 KiB
JavaScript
// 영수증 OCR (온디바이스, Tesseract.js). 서버·API키 불필요.
|
|
// 이미지 → 텍스트 인식 → 금액·날짜·상호 추출.
|
|
import Tesseract from 'tesseract.js'
|
|
|
|
// 합계 금액을 가리키는 키워드 (우선순위 높은 순)
|
|
const TOTAL_KEYWORDS = [
|
|
'받을금액', '받을 금액', '결제금액', '결제 금액', '합계금액', '합 계', '합계',
|
|
'총액', '총 액', '총구매액', '판매금액', '결제대상금액', '청구금액', '승인금액',
|
|
'total', 'TOTAL',
|
|
]
|
|
// 합계로 오인하기 쉬운(제외할) 키워드
|
|
const EXCLUDE_KEYWORDS = ['부가세', '면세', '과세', '봉사료', '거스름', '받은금액', '현금', '잔액', '포인트']
|
|
|
|
// 문자열에서 숫자(천단위 콤마 포함)들을 추출 → 정수 배열
|
|
function numbersIn(line) {
|
|
const out = []
|
|
const re = /\d{1,3}(?:,\d{3})+|\d{3,}/g
|
|
let m
|
|
while ((m = re.exec(line)) !== null) {
|
|
const n = parseInt(m[0].replace(/,/g, ''), 10)
|
|
if (!Number.isNaN(n)) out.push(n)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// 합계 금액 추정
|
|
function extractAmount(lines) {
|
|
// 1) 합계 키워드가 있는 줄에서 금액 찾기 (제외 키워드 줄은 건너뜀)
|
|
for (const kw of TOTAL_KEYWORDS) {
|
|
for (const line of lines) {
|
|
if (!line.includes(kw)) continue
|
|
if (EXCLUDE_KEYWORDS.some((ex) => line.includes(ex))) continue
|
|
const nums = numbersIn(line)
|
|
if (nums.length) return Math.max(...nums)
|
|
}
|
|
}
|
|
// 2) 폴백: 전체에서 가장 큰 금액(100원 이상)
|
|
const all = lines.flatMap(numbersIn).filter((n) => n >= 100 && n < 100_000_000)
|
|
return all.length ? Math.max(...all) : null
|
|
}
|
|
|
|
// 거래일 추정 (YYYY-MM-DD 반환)
|
|
function extractDate(text) {
|
|
// 4자리 연도: 2024-01-05 / 2024.01.05 / 2024년 1월 5일 / 2024/1/5
|
|
let m = text.match(/(20\d{2})\s*[.\-/년]\s*(\d{1,2})\s*[.\-/월]\s*(\d{1,2})/)
|
|
if (m) return fmt(m[1], m[2], m[3])
|
|
// 2자리 연도: 24.01.05
|
|
m = text.match(/(\d{2})\s*[.\-/]\s*(\d{1,2})\s*[.\-/]\s*(\d{1,2})/)
|
|
if (m) return fmt('20' + m[1], m[2], m[3])
|
|
return null
|
|
}
|
|
function fmt(y, mo, d) {
|
|
const mm = String(Number(mo)).padStart(2, '0')
|
|
const dd = String(Number(d)).padStart(2, '0')
|
|
if (Number(mo) < 1 || Number(mo) > 12 || Number(d) < 1 || Number(d) > 31) return null
|
|
return `${y}-${mm}-${dd}`
|
|
}
|
|
|
|
// 상호(가게 이름) 추정 — 상단의 한글 포함, 숫자/키워드 없는 첫 줄
|
|
function extractStore(lines) {
|
|
for (const line of lines.slice(0, 6)) {
|
|
const t = line.trim()
|
|
if (t.length < 2 || t.length > 30) continue
|
|
if (/\d/.test(t)) continue
|
|
if (!/[가-힣]/.test(t)) continue
|
|
if (TOTAL_KEYWORDS.some((k) => t.includes(k))) continue
|
|
if (['영수증', '주소', '사업자', '대표', 'TEL', '전화'].some((k) => t.includes(k))) continue
|
|
return t
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* 영수증 이미지에서 금액·날짜·상호 추출.
|
|
* @param {File|Blob|string} image 파일 또는 dataURL
|
|
* @param {(p:number)=>void} onProgress 0~100 진행률 콜백
|
|
* @returns {Promise<{amount:number|null, date:string|null, store:string|null, text:string}>}
|
|
*/
|
|
export async function scanReceipt(image, onProgress) {
|
|
const { data } = await Tesseract.recognize(image, 'kor+eng', {
|
|
logger: (msg) => {
|
|
if (msg.status === 'recognizing text' && typeof msg.progress === 'number') {
|
|
onProgress?.(Math.round(msg.progress * 100))
|
|
}
|
|
},
|
|
})
|
|
const text = data.text || ''
|
|
const lines = text.split('\n').map((l) => l.trim()).filter(Boolean)
|
|
return {
|
|
amount: extractAmount(lines),
|
|
date: extractDate(text),
|
|
store: extractStore(lines),
|
|
text,
|
|
}
|
|
}
|