Files
telegram-bot-workers/src/services/bank-sms-parser.ts
kappa 410676e322 feat(domain): enhance domain info lookup & handler refactoring
- 도메인 조회(info): 내 도메인 아니면 자동으로 WHOIS 조회 (naver.com 등 지원)
- SMS 파싱: 정규식 실패 시 AI 폴백 로직 추가
- 리팩토링: UserService, ConversationService 분리
- 문서: README.md 및 CODE_REVIEW.md 업데이트
2026-01-19 17:12:07 +09:00

236 lines
7.7 KiB
TypeScript

import { Env, BankNotification } from '../types';
import { parseQuotedPrintable } from '../utils/email-decoder';
/**
* 은행 SMS 파싱 함수
*
* 1단계: 정규표현식 기반 파싱 (빠름)
* 2단계: 실패 시 AI 모델 기반 파싱 (느리지만 유연함)
*
* @param content - 이메일 원본 내용
* @param env - 환경 변수 (AI 사용 위함)
* @returns 파싱된 은행 알림 또는 null
*/
export async function parseBankSMS(content: string, env?: Env): Promise<BankNotification | null> {
// 1. 전처리 (MIME 디코딩 등)
let text = preprocessText(content);
// 2. 정규표현식 파싱 시도
const regexResult = parseWithRegex(text);
if (regexResult) {
return regexResult;
}
// 3. AI 파싱 시도 (Fallback)
if (env) {
console.log('[BankSMS] 정규식 파싱 실패, AI 파싱 시도...');
try {
// AI 파싱은 비용/시간이 들므로 SMS 패턴이 의심될 때만 시도
// "입금", "원" 같은 키워드가 있는지 확인
if (text.includes('입금') && (text.includes('원') || text.match(/\d/))) {
return await parseWithAI(text, env);
} else {
console.log('[BankSMS] 입금 키워드 없음, AI 파싱 건너뜀');
}
} catch (error) {
console.error('[BankSMS] AI 파싱 오류:', error);
}
}
return null;
}
function preprocessText(content: string): string {
let text = content;
text = parseQuotedPrintable(text);
text = text.replace(/<br\s*\/?>/gi, '\n');
text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
// [Web발신] 또는 은행 키워드가 있는 부분만 추출
const smsStartMatch = text.match(/ \[Web발신\]| \[하나은행\]| \[KB\]| \[신한\]| \[우리\]| \[농협\]/);
if (smsStartMatch && smsStartMatch.index !== undefined) {
text = text.slice(smsStartMatch.index, smsStartMatch.index + 500);
}
return text;
}
function parseWithRegex(text: string): BankNotification | null {
// 하나은행 Web발신
const hanaWebPattern = / \[Web발신\]\s*하나[,\s]*(\d{1,2}\/\d{1,2})[,\s]*(\d{1,2}:\d{2})\s*[\d*]+\s*입금([\d,]+)원\s*(\S+)/;
const hanaWebMatch = text.match(hanaWebPattern);
if (hanaWebMatch) {
const [, date, time, amountStr, depositor] = hanaWebMatch;
return {
bankName: '하나은행',
depositorName: depositor.trim(),
amount: parseInt(amountStr.replace(/,/g, '')),
transactionTime: parseDateTime(date, time),
rawMessage: text.slice(0, 500),
};
}
// 하나은행 레거시
const hanaPattern = / \[하나은행\]\s*(\d{1,2}\/\d{1,2})\s*(\d{1,2}:\d{2})?\s*입금\s*([\d,]+)원\s*(\S+?)(?:\s+잔액\s*([\d,]+)원)?/;
const hanaMatch = text.match(hanaPattern);
if (hanaMatch) {
const [, date, time, amountStr, depositor, balanceStr] = hanaMatch;
return {
bankName: '하나은행',
depositorName: depositor,
amount: parseInt(amountStr.replace(/,/g, '')),
balanceAfter: balanceStr ? parseInt(balanceStr.replace(/,/g, '')) : undefined,
transactionTime: parseDateTime(date, time),
rawMessage: text.slice(0, 500),
};
}
// KB국민은행
const kbPattern = / \[KB\]\s*입금\s*([\d,]+)원\s*(\d{1,2}\/\d{1,2})?\s*(\d{1,2}:\d{2})?\s*(\S+)/;
const kbMatch = text.match(kbPattern);
if (kbMatch) {
const [, amountStr, date, time, depositor] = kbMatch;
return {
bankName: 'KB국민은행',
depositorName: depositor,
amount: parseInt(amountStr.replace(/,/g, '')),
transactionTime: date ? parseDateTime(date, time) : undefined,
rawMessage: text.slice(0, 500),
};
}
// 신한은행
const shinhanPattern = / \[신한\]\s*(\d{1,2}\/\d{1,2})?\s*입금\s*([\d,]+)원\s*(\S+)/;
const shinhanMatch = text.match(shinhanPattern);
if (shinhanMatch) {
const [, date, amountStr, depositor] = shinhanMatch;
return {
bankName: '신한은행',
depositorName: depositor,
amount: parseInt(amountStr.replace(/,/g, '')),
transactionTime: date ? parseDateTime(date) : undefined,
rawMessage: text.slice(0, 500),
};
}
// 일반 패턴
const genericPattern1 = /입금\s*([\d,]+)원?\s*(\S{2,10})/;
const genericPattern2 = /(\S{2,10})\s*([\d,]+)원?\s*입금/;
const genericMatch1 = text.match(genericPattern1);
if (genericMatch1) {
return {
bankName: '알수없음',
depositorName: genericMatch1[2],
amount: parseInt(genericMatch1[1].replace(/,/g, '')),
rawMessage: text.slice(0, 500),
};
}
const genericMatch2 = text.match(genericPattern2);
if (genericMatch2) {
return {
bankName: '알수없음',
depositorName: genericMatch2[1],
amount: parseInt(genericMatch2[2].replace(/,/g, '')),
rawMessage: text.slice(0, 500),
};
}
return null;
}
async function parseWithAI(text: string, env: Env): Promise<BankNotification | null> {
const prompt = `
아래 텍스트는 은행 입금 알림 문자 메시지입니다.
이 텍스트에서 [은행명, 입금자명, 입금액, 거래시간] 정보를 추출하여 JSON 형식으로 반환하세요.
텍스트:
"""
${text.slice(0, 500)}
"""
요구사항:
1. JSON 형식만 반환하세요. (마크다운 코드블록 없이)
2. 필드명: bankName, depositorName, amount (숫자), transactionTime (YYYY-MM-DDTHH:mm:ss 형식, 연도는 ${new Date().getFullYear()}년 기준)
3. 추출할 수 없는 필드는 null로 설정하세요.
4. "입금"이 아닌 "출금"이거나 광고/스팸 메시지라면 null을 반환하세요.
`;
let jsonStr = '';
// 1. OpenAI 시도
if (env.OPENAI_API_KEY) {
try {
const response = await fetch('https://gateway.ai.cloudflare.com/v1/d8e5997eb4040f8b489f09095c0f623c/telegram-bot/openai/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${env.OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: [{ role: 'user', content: prompt }],
max_tokens: 200,
temperature: 0,
}),
});
if (response.ok) {
const data = await response.json() as any;
jsonStr = data.choices[0].message.content;
}
} catch (e) {
console.error('[BankSMS] OpenAI 파싱 실패:', e);
}
}
// 2. Workers AI 시도 (OpenAI 실패 또는 미설정 시)
if (!jsonStr && env.AI) {
try {
const response = await env.AI.run('@cf/meta/llama-3.1-8b-instruct' as any, {
messages: [{ role: 'user', content: prompt }],
max_tokens: 200,
}) as any;
jsonStr = response.response;
} catch (e) {
console.error('[BankSMS] Workers AI 파싱 실패:', e);
}
}
if (!jsonStr) return null;
try {
// JSON 파싱 (마크다운 제거 등 정제)
const cleanJson = jsonStr.replace(/```json|```/g, '').trim();
if (cleanJson === 'null') return null;
const result = JSON.parse(cleanJson);
if (result.amount && result.depositorName) {
return {
bankName: result.bankName || '알수없음',
depositorName: result.depositorName,
amount: typeof result.amount === 'string' ? parseInt(result.amount.replace(/,/g, '')) : result.amount,
transactionTime: result.transactionTime ? new Date(result.transactionTime) : undefined,
rawMessage: text.slice(0, 500),
};
}
} catch (e) {
console.error('[BankSMS] AI 응답 JSON 파싱 실패:', e, jsonStr);
}
return null;
}
function parseDateTime(dateStr: string, timeStr?: string): Date {
const now = new Date();
const [month, day] = dateStr.split('/').map(Number);
const year = now.getFullYear();
let hours = 0, minutes = 0;
if (timeStr) {
[hours, minutes] = timeStr.split(':').map(Number);
}
return new Date(year, month - 1, day, hours, minutes);
}