**결론**: 한국식 날짜 형식("2025. 9. 23. 오전 9:44")을 파싱하고 매 시간별로 분석하는 코드입니다.
**구현**:
```python
import re
from datetime import datetime
from collections import defaultdict, Counter
def analyze_chat_statistics(file_path):
"""
텍스트 파일에서 시간과 대화 내용을 분석하여 통계 생성
지원 형식: "2025. 9. 23. 오전 9:44"
"""
# 통계 저장용 딕셔너리
stats = {
'daily_count': defaultdict(int), # 일자별 대화 수
'hourly_count': Counter(), # 시간대별 대화 수 (0-23시)
'weekday_count': Counter(), # 요일별 대화 수
'total_messages': 0,
'date_range': {'start': None, 'end': None}
}
# 정규표현식: "2025. 9. 23. 오전 9:44" 형식
pattern = r'(\d{4})\.\s*(\d{1,2})\.\s*(\d{1,2})\.\s*(오전|오후)\s*(\d{1,2}):(\d{2})'
try:
with open(file_path, 'r', encoding='utf-8') as f:
for line_num, line in enumerate(f, 1):
line = line.strip()
if not line:
continue
match = re.search(pattern, line)
if match:
try:
year = int(match.group(1))
month = int(match.group(2))
day = int(match.group(3))
ampm = match.group(4)
hour = int(match.group(5))
minute = int(match.group(6))
# 오전/오후를 24시간 형식으로 변환
if ampm == '오후' and hour != 12:
hour += 12
elif ampm == '오전' and hour == 12:
hour = 0
# 날짜 객체 생성
date_obj = datetime(year, month, day, hour, minute)
date_key = date_obj.strftime('%Y-%m-%d')
weekday = ['월', '화', '수', '목', '금', '토', '일'][date_obj.weekday()]
# 통계 업데이트
stats['total_messages'] += 1
stats['daily_count'][date_key] += 1
stats['weekday_count'][weekday] += 1
stats['hourly_count'][hour] += 1
# 날짜 범위 업데이트
if stats['date_range']['start'] is None or date_obj < stats['date_range']['start']:
stats['date_range']['start'] = date_obj
if stats['date_range']['end'] is None or date_obj > stats['date_range']['end']:
stats['date_range']['end'] = date_obj
except (ValueError, IndexError) as e:
print(f"⚠️ {line_num}번째 줄 파싱 오류: {e}")
print(f" 내용: {line[:100]}")
continue
except FileNotFoundError:
print(f"❌ 파일을 찾을 수 없습니다: {file_path}")
return None
except Exception as e:
print(f"❌ 파일 읽기 오류: {e}")
return None
return stats
def print_statistics(stats):
"""통계 결과를 보기 좋게 출력"""
if stats is None or stats['total_messages'] == 0:
print("분석할 데이터가 없습니다.")
return
print("\n" + "="*60)
print("📊 대화 통계 분석 결과")
print("="*60)
# 전체 통계
print(f"\n📝 전체 메시지 수: {stats['total_messages']:,}개")
if stats['date_range']['start']:
print(f"📅 기간: {stats['date_range']['start'].strftime('%Y-%m-%d')} ~ "
f"{stats['date_range']['end'].strftime('%Y-%m-%d')}")
days_diff = (stats['date_range']['end'] - stats['date_range']['start']).days + 1
print(f" (총 {days_diff}일간)")
print(f"📈 일평균: {stats['total_messages'] / days_diff:.1f}개")
# 일자별 통계 (상위 10개)
if stats['daily_count']:
print("\n" + "-"*60)
print("📆 일자별 대화량 (상위 10일)")
print("-"*60)
sorted_daily = sorted(stats['daily_count'].items(), key=lambda x: x[1], reverse=True)[:10]
max_daily = max(count for _, count in sorted_daily)
for date, count in sorted_daily:
bar = '█' * (count * 40 // max_daily)
print(f"{date}: {count:4d}개 {bar}")
# 요일별 통계
if stats['weekday_count']:
print("\n" + "-"*60)
print("📅 요일별 대화량")
print("-"*60)
weekdays = ['월', '화', '수', '목', '금', '토', '일']
max_count = max(stats['weekday_count'].values()) if stats['weekday_count'] else 1
for day in weekdays:
count = stats['weekday_count'][day]
bar = '█' * (count * 40 // max_count) if count > 0 else ''
percentage = (count / stats['total_messages'] * 100) if stats['total_messages'] > 0 else 0
print(f"{day}요일: {count:4d}개 ({percentage:5.1f}%) {bar}")
# 시간대별 통계 (매 시간별)
if stats['hourly_count']:
print("\n" + "-"*60)
print("🕐 시간별 대화량 (매 시간)")
print("-"*60)
max_count = max(stats['hourly_count'].values())
for hour in range(24):
count = stats['hourly_count'][hour]
bar = '█' * (count * 40 // max_count) if count > 0 else ''
percentage = (count / stats['total_messages'] * 100) if stats['total_messages'] > 0 else 0
# 오전/오후 표시
if hour == 0:
time_str = "오전 12시"
elif hour < 12:
time_str = f"오전 {hour:2d}시"
elif hour == 12:
time_str = "오후 12시"
else:
time_str = f"오후 {hour-12:2d}시"
print(f"{time_str}: {count:4d}개 ({percentage:5.1f}%) {bar}")
# 시간대 구간별 요약
if stats['hourly_count']:
print("\n" + "-"*60)
print("⏰ 시간대 구간별 요약")
print("-"*60)
time_periods = {
'새벽 (00-06시)': sum(stats['hourly_count'][h] for h in range(0, 6)),
'오전 (06-12시)': sum(stats['hourly_count'][h] for h in range(6, 12)),
'오후 (12-18시)': sum(stats['hourly_count'][h] for h in range(12, 18)),
'저녁 (18-24시)': sum(stats['hourly_count'][h] for h in range(18, 24))
}
for period, count in time_periods.items():
percentage = (count / stats['total_messages'] * 100) if stats['total_messages'] > 0 else 0
print(f"{period}: {count:5d}개 ({percentage:5.1f}%)")
# 가장 활발한 시간대
if stats['hourly_count']:
most_active_hour = max(stats['hourly_count'].items(), key=lambda x: x[1])
if most_active_hour[0] < 12:
time_display = f"오전 {most_active_hour[0] if most_active_hour[0] != 0 else 12}시"
else:
time_display = f"오후 {most_active_hour[0]-12 if most_active_hour[0] != 12 else 12}시"
print(f"\n🔥 가장 활발한 시간: {time_display} ({most_active_hour[1]}개)")
# 가장 활발한 요일
if stats['weekday_count']:
most_active_day = max(stats['weekday_count'].items(), key=lambda x: x[1])
print(f"🔥 가장 활발한 요일: {most_active_day[0]}요일 ({most_active_day[1]}개)")
print("\n" + "="*60)
# 실행 예시
if __name__ == "__main__":
# 파일 경로 지정
file_path = "chat_log.txt" # 여기에 실제 파일명을 입력하세요
print("🔍 파일 분석 중...")
stats = analyze_chat_statistics(file_path)
print_statistics(stats)
```
**주요 변경사항**:
1. ✅ **날짜 형식**: "2025. 9. 23. 오전 9:44" 파싱
2. ✅ **오전/오후 변환**: 자동으로 24시간 형식으로 변환
3. ✅ **매 시간별 분석**: 0시~23시까지 각 시간별 통계
4. ✅ **시간 표시**: 출력 시 "오전 9시", "오후 3시" 형식으로 표시
5. ✅ **퍼센트 추가**: 각 시간대와 요일별 비율 표시
6. ✅ **최다 시간/요일**: 가장 활발한 시간대와 요일 표시
**사용 방법**:
```bash
python chat_stats.py
```
파일 경로만 수정하면 바로 실행됩니다! 📊ㄱㄱ
mcp
# 프로젝트 루트 디렉토리 생성
mkdir -p ~/projects/mcp
cd ~/projects/mcp
# 가상환경 생성
python -m venv .venv
# 활성화
source .venv/bin/activate
# pip 최신 버전으로
pip install --upgrade pip
# uv 설치 (MCP 패키지 매니저)
pip install uv
# 또는 일반 pip로
pip install "mcp[cli]"
mcp
# 현재 패키지 목록 저장
pip freeze > requirements.txt
# 확인
cat requirements.txt
# 프롬프트에서 (venv) 사라지면 성공
deactivate
# 한 줄에 모두 입력
cd ~/projects/mcp && source .venv/bin/activate
# && 의미: 앞 명령어가 성공하면 뒤 명령어 실행맥북 초기설정
# ========== Step 1: Xcode Command Line Tools ==========
xcode-select --install
# 팝업에서 "설치" 클릭, 완료 대기 (5-10분)
# ========== Step 2: Homebrew 설치 ==========
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# Enter 키, 비밀번호 입력, 완료 대기 (5-10분)
# ========== Step 3: 환경변수 설정 ==========
# M1/M2/M3 Mac
echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> ~/.zprofile
eval "$(/opt/homebrew/bin/brew shellenv)"
# Intel Mac
echo 'eval "$(/usr/local/bin/brew shellenv)"' >> ~/.zprofile
eval "$(/usr/local/bin/brew shellenv)"
# ========== Step 4: 확인 ==========
brew --version
brew doctor # 문제 있으면 알려줌
# ========== Step 5: pyenv 설치 ==========
brew install pyenv
# ========== Step 6: pyenv 환경변수 ==========
echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.zshrc
echo 'export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.zshrc
echo 'eval "$(pyenv init -)"' >> ~/.zshrc
source ~/.zshrc
# ========== Step 7: Python 설치 ==========
pyenv install 3.11.9
pyenv global 3.11.9
python --version
# ========== 완료! ==========
# Homebrew 설치 후 바로 설치하면 좋은 것들
# 1. pyenv (Python 버전 관리)
brew install pyenv
# 2. Git (버전 관리)
brew install git
# 3. iTerm2 (더 나은 터미널)
brew install --cask iterm2
# 4. VS Code (코드 에디터)
brew install --cask visual-studio-code
# 5. 기본 도구들
brew install wget curl tree
brew install font-meslo-lg-nerd-font
# Oh My Zsh 설치
sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"
# Powerlevel10k 설치
git clone --depth=1 https://github.com/romkatv/powerlevel10k.git ${ZSH_CUSTOM:-$HOME/.oh-my-zsh/custom}/themes/powerlevel10k
# ~/.zshrc 수정
nano ~/.zshrc
# 아래 줄 찾아서:
ZSH_THEME="robbyrussell"
# 이렇게 변경:
ZSH_THEME="powerlevel10k/powerlevel10k"
# 저장: Ctrl+O → Enter → Ctrl+X
# 자동완성 제안 (회색 글씨로 명령어 제안)
git clone https://github.com/zsh-users/zsh-autosuggestions ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-autosuggestions
# 문법 하이라이팅 (명령어 색상)
git clone https://github.com/zsh-users/zsh-syntax-highlighting.git ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-syntax-highlighting
# 확인
ls ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/
# zsh-autosuggestions, zsh-syntax-highlighting 보이면 성공
nano ~/.zshrc
plugins=(
git
python
pyenv
brew
zsh-autosuggestions
zsh-syntax-highlighting
)
source ~/.zshrc
# iTerm2 실행
open -a iTerm
# Dracula 테마 다운로드
cd ~/Downloads
curl -o Dracula.itermcolors https://raw.githubusercontent.com/dracula/iterm/master/Dracula.itermcolors
iTerm2 → Preferences → Profiles → Colors
Color Presets → Import
Dracula.itermcolors 선택
Dracula 선택
기본 프로필로 설정:
Profiles → Other Actions → Set as Default크롬 및 반디집
시간대 09:00 서울로 설정
서버 매니저 > 로컬 서버 > IE Enhanced Security configuration (OFF)
IE 브라우저 재시작
time.google.com
https://www.python.org/ftp/python/3.9.7/python-3.9.7-amd64.exe
편하게 C드라이브 상단에 저장할것
pip install --upgrade pip
# → 패키지 설치관리 도구(pip)를 최신버전으로 만든다. #오류난다면 아래 명령어로 실행
python -m pip install --upgrade pip
# (4) 필요한 패키지 설치
pip install -r requirements.txt
# → 현재 폴더의 requirements.txt 파일에 적힌 모든 패키지들을 한 번에 설치한다.
(venv)가 앞에 붙어있으면됨 아래는 가상환경할때
# (1) 가상환경 생성
python -m venv venv
# → "venv"라는 이름으로 파이썬 가상환경 폴더를 만든다.
# 이 폴더 안에만 독립적으로 패키지가 설치된다.
# (2) 가상환경 활성화 (실행)
venv\Scripts\activate
# → "venv" 가상환경 안으로 진입.
# (명령 프롬프트 앞에 (venv) 표시가 붙는다)
# (3) pip 업그레이드
pip install --upgrade pip
# → 패키지 설치관리 도구(pip)를 최신버전으로 만든다.
#오류난다면 아래 명령어로 실행
python -m pip install --upgrade pip
# (4) 필요한 패키지 설치
pip install -r requirements.txt
# → 현재 폴더의 requirements.txt 파일에 적힌 모든 패키지들을 한 번에 설치한다.
-----------------------------------------------------------------------
(1) 명령 프롬프트(CMD) 창을 새로 열었다면
cd 프로젝트폴더경로
# → 다시 작업할 프로젝트 폴더로 이동
venv\Scripts\activate
# → 위와 같이 가상환경을 재활성화(진입)
(2) 가상환경에서 빠져나오는 방법 (비활성화)
deactivate
# → 가상환경에서 나온다. (원래 시스템 파이썬 상태로 돌아감)44
import os
import glob
import re
from pathlib import Path
from typing import Iterator, Optional, Union
def filter_lines_memory_efficient(input_file: str, output_file: str, keyword: str,
case_sensitive: bool = True, encoding: str = 'utf-8') -> int:
"""
메모리 효율적인 줄 단위 필터링 - 대용량 파일 지원
Args:
input_file: 입력 파일 경로
output_file: 출력 파일 경로
keyword: 검색할 키워드
case_sensitive: 대소문자 구분 여부
encoding: 파일 인코딩
Returns:
필터링된 줄 수
"""
try:
line_count = 0
search_keyword = keyword if case_sensitive else keyword.lower()
# 줄 단위로 스트리밍 처리 - 메모리 효율적
with open(input_file, 'r', encoding=encoding, buffering=8192) as infile, \
open(output_file, 'w', encoding=encoding, buffering=8192) as outfile:
for line in infile: # 파이썬의 내장 이터레이터 사용
check_line = line if case_sensitive else line.lower()
if search_keyword in check_line:
outfile.write(line)
line_count += 1
print(f"✅ {Path(input_file).name}: {line_count:,}줄 필터링 완료")
return line_count
except FileNotFoundError:
print(f"❌ 파일을 찾을 수 없습니다: {input_file}")
return 0
except PermissionError:
print(f"❌ 파일 접근 권한이 없습니다: {input_file}")
return 0
except UnicodeDecodeError as e:
print(f"❌ 인코딩 오류 {input_file}: {e}")
print(f"💡 다른 인코딩 시도: cp949, euc-kr")
return 0
except Exception as e:
print(f"❌ 예상치 못한 오류 {input_file}: {e}")
return 0
def filter_with_regex_efficient(input_file: str, output_file: str, pattern: str,
flags: int = 0, encoding: str = 'utf-8') -> int:
"""
정규표현식 기반 메모리 효율적 필터링
Args:
input_file: 입력 파일 경로
output_file: 출력 파일 경로
pattern: 정규표현식 패턴
flags: re 플래그
encoding: 파일 인코딩
"""
try:
compiled_pattern = re.compile(pattern, flags)
line_count = 0
with open(input_file, 'r', encoding=encoding, buffering=8192) as infile, \
open(output_file, 'w', encoding=encoding, buffering=8192) as outfile:
for line in infile:
if compiled_pattern.search(line):
outfile.write(line)
line_count += 1
print(f"✅ 정규표현식 필터링: {line_count:,}줄 처리됨")
return line_count
except re.error as e:
print(f"❌ 정규표현식 오류: {e}")
return 0
except Exception as e:
print(f"❌ 필터링 오류: {e}")
return 0
def detect_encoding(file_path: str) -> str:
"""
파일 인코딩 자동 감지
"""
encodings_to_try = ['utf-8', 'cp949', 'euc-kr', 'utf-16', 'latin1']
for encoding in encodings_to_try:
try:
with open(file_path, 'r', encoding=encoding) as f:
f.read(1024) # 첫 1KB만 테스트
return encoding
except (UnicodeDecodeError, UnicodeError):
continue
return 'utf-8' # 기본값
def batch_filter_files_improved(directory: str, keyword: str,
pattern: str = "*.txt", case_sensitive: bool = True,
backup: bool = True) -> dict:
"""
개선된 일괄 파일 처리 - 인코딩 자동감지, 백업 옵션
Args:
directory: 처리할 디렉토리
keyword: 검색 키워드
pattern: 파일 패턴
case_sensitive: 대소문자 구분
backup: 원본 백업 여부
Returns:
처리 결과 딕셔너리
"""
files = list(Path(directory).glob(pattern))
if not files:
print(f"❌ {directory}에서 {pattern} 패턴 파일을 찾을 수 없습니다.")
return {"processed": 0, "total": 0, "lines": 0}
print(f"🔍 {len(files)}개 파일에서 '{keyword}' 검색 시작...")
processed_files = 0
total_lines = 0
failed_files = []
for file_path in files:
# 인코딩 자동 감지
encoding = detect_encoding(str(file_path))
print(f"📄 {file_path.name} (인코딩: {encoding})")
# 백업 생성
if backup:
backup_path = file_path.with_suffix('.bak' + file_path.suffix)
if not backup_path.exists():
file_path.replace(backup_path)
file_path = backup_path
# 출력 파일명
output_path = file_path.parent / f"{file_path.stem}_filtered{file_path.suffix}"
lines_found = filter_lines_memory_efficient(
str(file_path), str(output_path), keyword,
case_sensitive=case_sensitive, encoding=encoding
)
if lines_found > 0:
processed_files += 1
total_lines += lines_found
elif lines_found == -1: # 오류 발생
failed_files.append(str(file_path))
# 결과 요약
result = {
"processed": processed_files,
"total": len(files),
"lines": total_lines,
"failed": failed_files
}
print(f"\n📊 처리 완료:")
print(f" ✅ 성공: {processed_files}/{len(files)}개 파일")
print(f" 📝 총 필터링된 줄: {total_lines:,}")
if failed_files:
print(f" ❌ 실패한 파일: {len(failed_files)}개")
for failed in failed_files[:3]: # 최대 3개만 표시
print(f" - {Path(failed).name}")
return result
def get_file_info(file_path: str) -> dict:
"""
파일 정보 확인 - 크기, 줄 수 등
"""
try:
path = Path(file_path)
size = path.stat().st_size
# 줄 수 계산 (메모리 효율적)
line_count = 0
encoding = detect_encoding(file_path)
with open(file_path, 'r', encoding=encoding) as f:
for _ in f:
line_count += 1
return {
"size_mb": round(size / (1024 * 1024), 2),
"lines": line_count,
"encoding": encoding
}
except Exception as e:
return {"error": str(e)}
def interactive_mode():
"""
대화형 모드 - 사용자 친화적 인터페이스
"""
print("=" * 50)
print("🔍 텍스트 파일 필터링 도구 v2.0")
print("=" * 50)
while True:
print("\n처리 방식 선택:")
print("1. 단일 파일 처리")
print("2. 여러 파일 일괄 처리")
print("3. 정규표현식 필터링")
print("4. 파일 정보 확인")
print("5. 종료")
choice = input("\n선택 (1-5): ").strip()
if choice == "1":
# 단일 파일 처리
input_file = input("입력 파일 경로: ").strip()
if not Path(input_file).exists():
print("❌ 파일이 존재하지 않습니다.")
continue
keyword = input("검색할 키워드 (기본: 삐삐): ").strip() or "삐삐"
case_sensitive = input("대소문자 구분? (y/N): ").strip().lower() == 'y'
# 출력 파일명 자동 생성
input_path = Path(input_file)
output_file = str(input_path.parent / f"{input_path.stem}_filtered{input_path.suffix}")
# 파일 정보 표시
info = get_file_info(input_file)
if "error" not in info:
print(f"📊 파일 정보: {info['size_mb']}MB, {info['lines']:,}줄, {info['encoding']}")
filter_lines_memory_efficient(input_file, output_file, keyword, case_sensitive)
elif choice == "2":
# 여러 파일 일괄 처리
directory = input("디렉토리 경로 (기본: 현재 폴더): ").strip() or "."
pattern = input("파일 패턴 (기본: *.txt): ").strip() or "*.txt"
keyword = input("검색할 키워드 (기본: 삐삐): ").strip() or "삐삐"
case_sensitive = input("대소문자 구분? (y/N): ").strip().lower() == 'y'
backup = input("원본 백업? (Y/n): ").strip().lower() != 'n'
batch_filter_files_improved(directory, keyword, pattern, case_sensitive, backup)
elif choice == "3":
# 정규표현식 필터링
input_file = input("입력 파일 경로: ").strip()
if not Path(input_file).exists():
print("❌ 파일이 존재하지 않습니다.")
continue
pattern = input("정규표현식 패턴: ").strip()
ignore_case = input("대소문자 무시? (y/N): ").strip().lower() == 'y'
flags = re.IGNORECASE if ignore_case else 0
input_path = Path(input_file)
output_file = str(input_path.parent / f"{input_path.stem}_regex_filtered{input_path.suffix}")
filter_with_regex_efficient(input_file, output_file, pattern, flags)
elif choice == "4":
# 파일 정보 확인
file_path = input("파일 경로: ").strip()
if Path(file_path).exists():
info = get_file_info(file_path)
if "error" in info:
print(f"❌ 오류: {info['error']}")
else:
print(f"📊 파일 정보:")
print(f" 크기: {info['size_mb']}MB")
print(f" 줄 수: {info['lines']:,}")
print(f" 인코딩: {info['encoding']}")
else:
print("❌ 파일이 존재하지 않습니다.")
elif choice == "5":
print("👋 프로그램을 종료합니다.")
break
else:
print("❌ 잘못된 선택입니다.")
# 성능 테스트 함수
def performance_test(file_path: str, keyword: str):
"""
성능 테스트 - 처리 시간 및 메모리 사용량 측정
"""
import time
import psutil
import os
print(f"🚀 성능 테스트 시작: {Path(file_path).name}")
# 메모리 사용량 측정
process = psutil.Process(os.getpid())
memory_before = process.memory_info().rss / 1024 / 1024 # MB
start_time = time.time()
# 임시 출력 파일
temp_output = file_path + ".temp_filtered"
lines_found = filter_lines_memory_efficient(file_path, temp_output, keyword)
end_time = time.time()
memory_after = process.memory_info().rss / 1024 / 1024 # MB
# 임시 파일 삭제
if Path(temp_output).exists():
Path(temp_output).unlink()
print(f"📊 성능 결과:")
print(f" 처리 시간: {end_time - start_time:.2f}초")
print(f" 메모리 사용량: {memory_before:.1f} → {memory_after:.1f} MB")
print(f" 메모리 증가: {memory_after - memory_before:.1f} MB")
print(f" 처리된 줄: {lines_found:,}")
if __name__ == "__main__":
interactive_mode()ㅇㅇ
ㄷㄷ
**결론**: "타입에러 낫 어 펑션" 오류는 JavaScript 문법 문제입니다. Frida JavaScript 환경에 맞게 수정한 스크립트를 제공합니다.
## 🛠️ 수정된 안전한 스크립트
```python
import frida
import time
import sys
import os
def fixed_memory_analysis():
mail_exe_path = "mail.exe"
# 수정된 JavaScript 코드 (안전한 버전)
js_code = """
console.log("=== MAIL.EXE 메모리 분석 시작 ===");
function safeMemoryAnalysis() {
try {
// 1. 프로세스 기본 정보
console.log("\\n[1] 프로세스 정보:");
console.log("PID: " + Process.id);
console.log("아키텍처: " + Process.arch);
console.log("플랫폼: " + Process.platform);
// 2. 모듈 찾기
console.log("\\n[2] 모듈 검색:");
var modules = Process.enumerateModules();
console.log("총 모듈 개수: " + modules.length);
var targetModule = null;
// 첫 10개 모듈 출력
for (var i = 0; i < Math.min(modules.length, 10); i++) {
var mod = modules[i];
console.log("모듈[" + i + "]: " + mod.name + " -> " + mod.base);
if (i === 0) {
targetModule = mod; // 첫 번째 모듈을 타겟으로
}
}
if (!targetModule) {
console.log("ERROR: 사용할 모듈이 없습니다");
return;
}
console.log("\\n[3] 타겟 모듈: " + targetModule.name);
console.log("베이스 주소: " + targetModule.base);
console.log("모듈 크기: " + targetModule.size);
// 3. 주소 계산
var baseAddr = targetModule.base;
var offset = 0x4050cf;
var targetAddr = baseAddr.add(offset);
console.log("\\n[4] 주소 계산:");
console.log("베이스: " + baseAddr);
console.log("오프셋: 0x" + offset.toString(16));
console.log("타겟: " + targetAddr);
// 4. 메모리 접근 테스트 (안전한 방법)
console.log("\\n[5] 메모리 접근 테스트:");
try {
// 한 바이트씩 안전하게 읽기
var firstByte = Memory.readU8(targetAddr);
console.log("첫 번째 바이트: 0x" + firstByte.toString(16));
if (firstByte === 0x68) {
console.log("SUCCESS: PUSH 명령어 발견!");
// PUSH 오퍼랜드 읽기
var operand = Memory.readU32(targetAddr.add(1));
console.log("PUSH 오퍼랜드: 0x" + operand.toString(16));
// 문자열 주소로 변환
var stringAddr = ptr(operand);
console.log("문자열 주소: " + stringAddr);
// 문자열 읽기 시도
try {
var originalString = Memory.readUtf8String(stringAddr);
console.log("원본 문자열: '" + originalString + "'");
// 패치 시도
performPatch(targetAddr, stringAddr, originalString);
} catch (stringError) {
console.log("문자열 읽기 실패: " + stringError.message);
}
} else {
console.log("WARNING: PUSH 명령어가 아닙니다 (0x" + firstByte.toString(16) + ")");
// 주변 바이트들 확인
console.log("주변 바이트들:");
for (var i = 0; i < 8; i++) {
try {
var b = Memory.readU8(targetAddr.add(i));
console.log(" [+" + i + "] 0x" + b.toString(16));
} catch (e) {
console.log(" [+" + i + "] 읽기 실패");
}
}
}
} catch (accessError) {
console.log("메모리 접근 실패: " + accessError.message);
// 대안: 문자열 패턴 검색
searchStringPattern();
}
} catch (mainError) {
console.log("메인 분석 오류: " + mainError.message);
console.log("스택: " + mainError.stack);
}
}
function performPatch(pushAddr, stringAddr, originalString) {
console.log("\\n[6] 패치 수행:");
try {
// 새 문자열 할당
var newString = "test";
var newStringAddr = Memory.alloc(newString.length + 1);
Memory.writeUtf8String(newStringAddr, newString);
console.log("새 문자열 할당: " + newStringAddr);
// 메모리 보호 해제
Memory.protect(pushAddr, 8, 'rwx');
console.log("메모리 보호 해제 완료");
// PUSH 오퍼랜드 변경
Memory.writeU32(pushAddr.add(1), newStringAddr.toInt32());
console.log("PUSH 오퍼랜드 변경 완료");
// 검증
var newOperand = Memory.readU32(pushAddr.add(1));
console.log("새 오퍼랜드: 0x" + newOperand.toString(16));
var verifyString = Memory.readUtf8String(newStringAddr);
console.log("새 문자열 검증: '" + verifyString + "'");
console.log("SUCCESS: 패치 완료!");
} catch (patchError) {
console.log("패치 실패: " + patchError.message);
}
}
function searchStringPattern() {
console.log("\\n[7] 문자열 패턴 검색:");
try {
var targetString = "Not available in proxy mode.";
var ranges = Process.enumerateRanges('r--');
console.log("검색할 메모리 범위: " + ranges.length + "개");
for (var i = 0; i < Math.min(ranges.length, 50); i++) {
try {
var results = Memory.scanSync(ranges[i].base, ranges[i].size, targetString);
if (results.length > 0) {
console.log("문자열 발견! 주소: " + results[0].address);
// 직접 문자열 변경 시도
Memory.protect(results[0].address, targetString.length, 'rwx');
Memory.writeUtf8String(results[0].address, "test");
// 나머지 공간 NULL로 채우기
for (var j = 4; j < targetString.length; j++) {
Memory.writeU8(results[0].address.add(j), 0);
}
console.log("SUCCESS: 문자열 직접 변경 완료!");
return;
}
} catch (scanError) {
// 스캔 오류는 무시하고 계속
}
}
console.log("문자열 패턴을 찾을 수 없습니다");
} catch (searchError) {
console.log("검색 오류: " + searchError.message);
}
}
// 1초 후 실행
setTimeout(function() {
safeMemoryAnalysis();
}, 1000);
"""
try:
print("[*] mail.exe 실행 중...")
# 파일 확인
if not os.path.exists(mail_exe_path):
print(f"[-] {mail_exe_path} 파일이 없습니다.")
return
# 프로세스 스폰
pid = frida.spawn([mail_exe_path])
print(f"[+] 프로세스 생성 (PID: {pid})")
# 세션 연결
session = frida.attach(pid)
print("[+] Frida 연결 완료")
# 스크립트 생성
script = session.create_script(js_code)
# 메시지 처리
def on_message(message, data):
if message['type'] == 'send':
print(f"[JS] {message['payload']}")
elif message['type'] == 'error':
print(f"[ERROR] {message['description']}")
if 'stack' in message:
print(f"[STACK] {message['stack']}")
script.on('message', on_message)
script.load()
# 프로세스 재개
frida.resume(pid)
print("[+] 프로세스 재개됨")
print("\n" + "="*40)
print("분석 로그:")
print("="*40)
# 분석 대기
time.sleep(4)
print("\n분석 완료!")
input("엔터를 눌러 종료...")
except Exception as e:
print(f"[-] 오류: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
fixed_memory_analysis()
```
## 🔧 최소한의 테스트 스크립트
```python
import frida
import time
def minimal_test():
"""최소한의 기능으로 테스트"""
js_code = """
console.log("=== 최소 테스트 시작 ===");
setTimeout(function() {
try {
console.log("PID: " + Process.id);
var modules = Process.enumerateModules();
console.log("모듈 수: " + modules.length);
if (modules.length > 0) {
var main = modules[0];
console.log("메인 모듈: " + main.name);
console.log("베이스: " + main.base);
var target = main.base.add(0x4050cf);
console.log("타겟: " + target);
var byte = Memory.readU8(target);
console.log("바이트: 0x" + byte.toString(16));
if (byte === 0x68) {
console.log("PUSH 발견!");
var addr = Memory.readU32(target.add(1));
console.log("주소: 0x" + addr.toString(16));
var str = Memory.readUtf8String(ptr(addr));
console.log("문자열: " + str);
} else {
console.log("PUSH 아님");
}
}
} catch (e) {
console.log("오류: " + e.message);
}
}, 500);
"""
try:
pid = frida.spawn(["mail.exe"])
session = frida.attach(pid)
script = session.create_script(js_code)
def on_message(message, data):
print(message['payload'])
script.on('message', on_message)
script.load()
frida.resume(pid)
time.sleep(2)
except Exception as e:
print(f"오류: {e}")
if __name__ == "__main__":
minimal_test()
```
## ⚠️ 문제 해결 팁
### 1. JavaScript 문법 확인
- `padStart()` 같은 ES6 함수 사용 금지
- 문자열 연결은 `+` 연산자만 사용
- `try-catch` 블록으로 모든 메모리 접근 보호
### 2. Frida 버전 확인
```bash
frida --version
pip install --upgrade frida-tools
```
### 3. 권한 문제
```bash
# 관리자 권한으로 실행
# 또는 UAC 설정 확인
```
최소 테스트 스크립트부터 실행해서 단계별로 문제를 확인해보세요. 어떤 오류 메시지가 나오는지 알려주시면 더 구체적으로 도와드리겠습니다.Mifare Classic
1. 설치 및 설정
의존성 설치:
brew install readline qt5 pkgconfig p7zip libusb arm-none-eabi-gcc
소스코드 다운로드 및 컴파일:
git clone https://github.com/RfidResearchGroup/proxmark3.git
cd proxmark3
make clean && make all
연결 확인:
system_profiler SPUSBDataType | grep -i proxmark
ls /dev/tty.usbmodem*
2. Proxmark3 실행 및 하드웨어 확인
클라이언트 실행:
./pm3 -p /dev/tty.usbmodemiceman1
하드웨어 상태 확인:
hw status
hw version
3. 카드 분석
주파수별 스캔:
lf search # 125kHz 카드 확인 (EM4x70 감지됨)
hf search # 13.56MHz 카드 확인 (Mifare Classic 1K 발견)
Mifare 상세 정보:
hf mf info # UID: 47XXXXXX, 모든 키가 FFFFFFFFFFFF 확인
4. 키 확인 및 덤프
키 체크 및 파일 생성:
hf mf chk --1k --dump
# 결과: /Users/user/hf-mf-47XXXXXX-key.bin 생성
카드 덤프:
hf mf dump --1k
# 결과: /Users/user/hf-mf-47XXXXXX-dump.bin 생성
5. CUID 카드에 복사
복사 실행:
hf mf restore --1k -f /Users/user/hf-mf-47XXXXXX-dump.bin -k /Users/user/hf-mf-47XXXXXX-key.bin
복사 검증:
hf mf dump --ns # 복사된 카드 읽기 (파일 저장 안함)
핵심 발견사항
카드 특성:
- 듀얼 주파수: EM4x70(125kHz) + Mifare Classic 1K(13.56MHz)
- 실제 사용: Mifare Classic 부분 (UID: 47XXXXXX)
- 보안 수준: 기본 키(FFFFFFFFFFFF) 사용으로 취약
공격 가능한 카드 (PRNG 사용)
MIFARE Classic 1K (MF1 S50)
- SAK: 0x08
- 공격: darkside, nested, hardnested 모두 가능
- 성공률: 높음
MIFARE Classic 4K (MF1 S70)
- SAK: 0x18
- 공격: darkside, nested, hardnested 모두 가능
- 성공률: 높음
저주파 카드들 (125kHz)
- HID ProxCard
- EM4100/EM4102
- T5577 (재작성 가능)
- Indala
- 공격: 단순 복사/에뮬레이션
- 성공률: 매우 높음
공격 어려운/불가능한 카드 (TRNG 사용)
MIFARE Plus
- SAK: 0x08(Level 1), 0x20(Level 3)
- 공격: 표준 방법 차단됨
SmartMX
- SAK: 0x28
- 공격: 모든 표준 공격 차단
MIFARE DESFire
- SAK: 0x20
- 공격: AES 암호화로 거의 불가능
iClass, Legic Prime
- 고급 암호화
- 공격: 매우 제한적
RFID/NFC 카드 공격 가능성 비교표
고주파 카드 (13.56MHz)
카드명 SAK 메모리 RNG 암호화 가능한 공격 성공률 복사용 카드
| MIFARE Classic 1K | 0x08 | 1KB | PRNG | Crypto1 | darkside, nested, hardnested, autopwn | 높음 | CUID, UID |
| MIFARE Classic 4K | 0x18 | 4KB | PRNG | Crypto1 | darkside, nested, hardnested, autopwn | 높음 | CUID, UID |
| SmartMX + Classic | 0x28 | 다양 | TRNG | AES/3DES | 거의 불가능 | 매우 낮음 | 복사 불가 |
| MIFARE Plus Lv1 | 0x08/0x18 | 2KB/4KB | TRNG | AES | Classic 호환 모드에서만 제한적 | 낮음 | 제한적 |
| MIFARE Plus Lv2 | 0x10/0x11 | 2KB/4KB | TRNG | AES | 거의 불가능 | 매우 낮음 | 복사 불가 |
| MIFARE Plus Lv3 | 0x20 | 2KB/4KB | TRNG | AES | 거의 불가능 | 없음 | 복사 불가 |
| MIFARE DESFire | 0x20 | 2-8KB | TRNG | AES | 거의 불가능 | 없음 | 복사 불가 |
| CUID | 0x08 | 1KB | PRNG | Crypto1 | 모든 공격 | 매우 높음 | 복사 대상 |
| UID | 0x08 | 1KB | PRNG | Crypto1 | 모든 공격 | 매우 높음 | 복사 대상 |
저주파 카드 (125kHz/134kHz)
카드명 주파수 메모리 보안 가능한 공격 성공률 복사용 카드
| HID ProxCard | 125kHz | 64bit | 없음 | lf hid clone | 매우 높음 | T5577 |
| EM4100/4102 | 125kHz | 64bit | 없음 | lf em clone | 매우 높음 | T5577 |
| EM4x70 | 125kHz | 512bit | 암호화 | lf em 4x70 공격 | 낮음 | T5577 (제한적) |
| Indala | 125kHz | 64bit | 없음 | lf indala clone | 높음 | T5577 |
| T5577 | 125kHz | 330bit | 없음 | 직접 쓰기 | 매우 높음 | 복사 대상 |
공격 방법별 설명
고주파 공격 (13.56MHz)
공격 방법 필요 조건 시간 대상 카드 성공률 명령어 예시
| autopwn | 카드만 | 5-30분 | PRNG 카드 | 높음 | hf mf autopwn --1k |
| darkside | 카드만 | 5-15분 | PRNG 카드 | 높음 | hf mf darkside |
| nested | 1개 키 필요 | 1-5분 | PRNG 카드 | 매우 높음 | hf mf nested --1k --blk 0 -a -k FFFFFFFFFFFF |
| hardnested | 1개 키 필요 | 10-60분 | PRNG 카드 | 높음 | hf mf hardnested --blk 0 -a -k FFFFFFFFFFFF --tblk 4 --ta |
| 키 체크 | 카드만 | 30초-5분 | 모든 카드 | 카드별 차이 | hf mf chk --1k |
| 스니핑 공격 | 리더기 접근 | 수시간 | 모든 카드 | 중간 | hf 14a sniff + mfkey64 분석 |
저주파 공격 (125kHz)
공격 방법 명령어 시간 성공률 설명
| 카드 식별 | lf search | 1-5초 | 매우 높음 | 카드 타입 자동 감지 |
| EM410x 복사 | lf em 410x clone --id [ID] | 5-10초 | 매우 높음 | EM4100/EM4102 → T5577 |
| HID 복사 | lf hid clone [TAG_ID] | 5-10초 | 매우 높음 | HID ProxCard → T5577 |
| T5577 직접 쓰기 | lf t55xx wr -b 0 -d [data] | 10-30초 | 매우 높음 | 직접 데이터 쓰기 |
| 시뮬레이션 | lf hid sim [TAG_ID] | 즉시 | 매우 높음 | 카드 에뮬레이션 |
복사용 카드 정보
카드명 타입 가격 용도 특징
| CUID | 13.56MHz | 1-3천원 | Mifare Classic 복사 | UID 변경 가능 |
| UID | 13.56MHz | 1-2천원 | Mifare Classic 복사 | 중국산 매직 카드 |
| T5577 | 125kHz | 1-2천원 | LF 카드 복사 | 다양한 LF 카드 에뮬레이션 |
'🥕 저장소 (Dev & Tools) > 잡다구리 (ETC)' 카테고리의 다른 글
| 해킹 상황별 도구 및 활용 전략 (0) | 2026.02.21 |
|---|---|
| 기업 네트워크 계층 구조 정리 (0) | 2026.02.21 |
| 공개 와이파이, 진짜 위험한가? HTTPS면 안전하지 않나? (0) | 2026.02.18 |
| [REG] 호스트 설정 추가 (1) | 2025.08.15 |
| 사내 프록시 환경 NTP 서버 시간동기화 (0) | 2025.08.07 |
ee
# QR 이미지에서 Google OTP 정보 추출하기
# 필요한 라이브러리 설치
# pip install opencv-python pyzbar pillow
import cv2
from pyzbar import pyzbar
import urllib.parse
def extract_otp_from_image(image_path):
"""
이미지 파일에서 QR 코드를 읽어 OTP 정보 추출
"""
try:
print(f"이미지 분석 중: {image_path}")
# 파일 존재 확인
import os
if not os.path.exists(image_path):
print("❌ 파일이 존재하지 않습니다.")
return
# 여러 방법으로 이미지 읽기 시도
image = None
# 방법 1: OpenCV로 읽기
try:
image = cv2.imread(image_path)
except:
pass
# 방법 2: PIL로 읽고 OpenCV 형식으로 변환
if image is None:
try:
from PIL import Image
import numpy as np
pil_image = Image.open(image_path)
# RGB to BGR 변환 (OpenCV 형식)
image = cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR)
print("✅ PIL로 이미지 읽기 성공")
except Exception as e:
print(f"PIL 읽기 실패: {e}")
if image is None:
print("❌ 이미지를 읽을 수 없습니다.")
print("지원 형식: PNG, JPG, JPEG, BMP, GIF")
return
print(f"✅ 이미지 읽기 성공 (크기: {image.shape[1]}x{image.shape[0]})")
# QR 코드 디코딩
qr_codes = pyzbar.decode(image)
if not qr_codes:
print("❌ QR 코드를 찾을 수 없습니다.")
print("💡 팁: QR 코드가 선명하고 전체가 보이는지 확인하세요.")
return
print(f"✅ {len(qr_codes)}개의 QR 코드 발견!")
for i, qr_code in enumerate(qr_codes):
print(f"\n--- QR 코드 #{i+1} ---")
# QR 코드 데이터 추출
qr_data = qr_code.data.decode('utf-8')
# Google OTP 형식인지 확인
if qr_data.startswith('otpauth://'):
parse_otp_uri(qr_data)
else:
print("❌ Google OTP 형식이 아닙니다.")
print(f"데이터: {qr_data[:100]}...") # 처음 100자만 표시
except Exception as e:
print(f"❌ 오류 발생: {e}")
print(f"오류 타입: {type(e).__name__}")
import traceback
traceback.print_exc()
def parse_otp_uri(uri):
"""
OTP URI를 파싱하여 필요한 정보 추출
"""
print(f"\n📱 Google OTP 정보 추출 완료!")
print(f"원본 URI: {uri}")
print(f"URI 길이: {len(uri)} 문자")
# URI 파싱
parsed = urllib.parse.urlparse(uri)
params = urllib.parse.parse_qs(parsed.query)
# 계정 정보 추출
account_info = parsed.path.lstrip('/')
print(f"\n" + "="*60)
print(f"🔐 추출된 OTP 정보 상세 분석")
print(f"="*60)
print(f"📋 계정: {account_info}")
print(f"🔗 URI 스키마: {parsed.scheme}")
print(f"🔗 URI 호스트: {parsed.netloc}")
print(f"🔗 URI 경로: {parsed.path}")
print(f"🔗 URI 쿼리: {parsed.query}")
# 모든 파라미터 출력
print(f"\n📊 모든 파라미터:")
for key, value in params.items():
print(f" {key}: {value[0] if value else 'None'}")
if 'secret' in params:
secret = params['secret'][0]
print(f"\n🔑 Secret Key 상세 분석:")
print(f" 원본 키: '{secret}'")
print(f" 키 길이: {len(secret)} 문자")
print(f" 키 타입: {type(secret)}")
# 문자별 분석
print(f" 문자 구성: {list(secret)}")
# 대소문자 확인
has_upper = any(c.isupper() for c in secret)
has_lower = any(c.islower() for c in secret)
has_digit = any(c.isdigit() for c in secret)
has_special = any(not c.isalnum() for c in secret)
print(f" 대문자 포함: {has_upper}")
print(f" 소문자 포함: {has_lower}")
print(f" 숫자 포함: {has_digit}")
print(f" 특수문자 포함: {has_special}")
# Base32 유효 문자 확인
base32_chars = set('ABCDEFGHIJKLMNOPQRSTUVWXYZ234567')
secret_upper = secret.upper()
invalid_chars = [c for c in secret_upper if c not in base32_chars]
if invalid_chars:
print(f" ❌ Base32에 없는 문자: {invalid_chars}")
else:
print(f" ✅ 모든 문자가 Base32 유효")
# Base32 패딩 확인
padding_needed = 8 - (len(secret) % 8)
if padding_needed == 8:
padding_needed = 0
print(f" Base32 패딩 필요: {padding_needed}개의 '='")
# Base32 디코딩 시도
try:
import base64
padded_secret = secret_upper + '=' * padding_needed
decoded = base64.b32decode(padded_secret)
print(f" ✅ Base32 디코딩 성공")
print(f" 디코딩된 바이트 수: {len(decoded)}")
print(f" 디코딩된 비트 수: {len(decoded) * 8}")
print(f" 16진수: {decoded.hex().upper()}")
except Exception as e:
print(f" ❌ Base32 디코딩 실패: {e}")
# 키 길이 기준 분석
print(f"\n📏 키 길이 표준 분석:")
print(f" 현재 길이: {len(secret)} 문자")
print(f" RFC 4226 최소 요구: 26 문자 (128비트)")
print(f" RFC 4226 권장: 32 문자 (160비트)")
print(f" Google Auth 최소: 16 문자")
print(f" 실제 비트 수: {len(secret) * 5} 비트 (Base32)")
if len(secret) < 16:
print(f" ❌ Google Authenticator 호환 불가")
print(f" 💡 이는 QR 코드 생성 측의 문제일 수 있습니다")
print(f" 💡 일부 서비스는 짧은 키를 사용하지만 표준 위반입니다")
elif len(secret) < 26:
print(f" ⚠️ RFC 표준 미만이지만 일부 앱에서 작동할 수 있음")
else:
print(f" ✅ 표준 준수")
# 공백 제거 및 정리
clean_secret = secret.replace(' ', '').replace('-', '').upper()
if clean_secret != secret:
print(f"\n🧹 정리된 키: {clean_secret}")
secret = clean_secret
print(f"⚠️ 이 키는 절대 타인에게 노출하지 마세요!")
# QR 코드 자체 문제 진단
if len(secret) < 16:
print(f"\n🔍 QR 코드 진단:")
print(f" - 해당 QR 코드는 실제로 {len(secret)}자 키를 포함")
print(f" - 이는 이미지 해상도 문제가 아님")
print(f" - QR 코드 생성 서비스에서 짧은 키를 사용함")
print(f" - 표준을 준수하지 않는 구현일 가능성")
else:
print(f"❌ Secret 키를 찾을 수 없습니다!")
return
# 나머지 파라미터들
issuer = params.get('issuer', ['Unknown'])[0]
algorithm = params.get('algorithm', ['SHA1'])[0]
digits = params.get('digits', ['6'])[0]
period = params.get('period', ['30'])[0]
print(f"\n🏢 발급자: {issuer}")
print(f"🔧 알고리즘: {algorithm}")
print(f"🔢 자릿수: {digits}")
print(f"⏰ 갱신주기: {period}초")
# Google Authenticator 수동 입력 시도
print(f"\n" + "="*60)
print(f"📱 Google Authenticator 수동 입력 시도")
print(f"="*60)
if ':' in account_info:
service, user = account_info.split(':', 1)
print(f"서비스: {service}")
print(f"계정: {user}")
else:
print(f"계정 이름: {account_info}")
print(f"키: {secret}")
print(f"시간 기준: 예")
print(f"유형: TOTP")
if len(secret) < 16:
print(f"\n❌ 경고: 이 키는 Google Authenticator에서 거부될 가능성이 높습니다")
print(f"💡 대안:")
print(f" 1. 다른 OTP 앱 사용 (Authy, Microsoft Authenticator 등)")
print(f" 2. 서비스 제공자에게 문의하여 더 긴 키 요청")
print(f" 3. 서비스에서 다시 QR 코드 생성")
else:
print(f"\n✅ 이 키는 Google Authenticator에서 정상 작동할 것입니다")
print(f"\n✅ 분석 완료!")
# 간단 실행
if __name__ == "__main__":
print("🔍 Google OTP QR 코드 분석기")
print("-" * 30)
# 파일 선택 대화상자
try:
import tkinter as tk
from tkinter import filedialog
# tkinter 창 숨기기
root = tk.Tk()
root.withdraw()
root.attributes('-topmost', True) # 창을 맨 앞으로
print("📁 파일 선택 창이 열립니다...")
# 파일 선택
image_path = filedialog.askopenfilename(
title="QR 코드 이미지를 선택하세요",
filetypes=[
("이미지 파일", "*.png *.jpg *.jpeg *.bmp *.gif *.webp"),
("PNG 파일", "*.png"),
("JPG 파일", "*.jpg *.jpeg"),
("모든 파일", "*.*")
]
)
root.destroy()
if image_path:
print(f"선택된 파일: {image_path}")
extract_otp_from_image(image_path)
else:
print("❌ 파일이 선택되지 않았습니다.")
except ImportError:
# tkinter 없으면 기존 방식
print("파일 선택 창을 사용할 수 없습니다. 직접 입력하세요.")
image_path = input("QR 코드 이미지 파일 경로: ").strip().strip('"')
if image_path:
extract_otp_from_image(image_path)
else:
print("❌ 파일 경로가 입력되지 않았습니다.")AWS Cognito