- 도메인 조회(info): 내 도메인 아니면 자동으로 WHOIS 조회 (naver.com 등 지원) - SMS 파싱: 정규식 실패 시 AI 폴백 로직 추가 - 리팩토링: UserService, ConversationService 분리 - 문서: README.md 및 CODE_REVIEW.md 업데이트
236 lines
7.7 KiB
TypeScript
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);
|
|
} |