Merge pull request #38 from j93es/feat/some

feat: Browser use v0.5.3 업데이트 및 프로세스 안정성 개선
This commit is contained in:
암냥 2025-07-11 23:51:51 +09:00 committed by GitHub
commit 9858d7acd2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 911 additions and 710 deletions

View file

@ -6,7 +6,7 @@ readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"black>=25.1.0",
"browser-use[memory]==0.3.3",
"browser-use[memory]==0.5.3",
"chardet>=5.2.0",
"isort>=6.0.1",
"lmnr[all]>=0.6.10",

97
run.py
View file

@ -1,5 +1,6 @@
import argparse
import os
import signal
import subprocess
import sys
from datetime import datetime
@ -13,42 +14,107 @@ PYTHON_SCRIPT = "./src/main.py"
DOMAIN_FILE = "./data/domains.txt"
# ─────────────
def download_domains():
"""도메인 파일 다운로드"""
try:
print("도메인 파일 다운로드 중...")
response = requests.get("https://f.imnya.ng/.whs/tp-domains/data/domains/latest.txt")
response = requests.get(
"https://f.imnya.ng/.whs/tp-domains/data/domains/latest.txt"
)
response.raise_for_status()
# 디렉토리가 없으면 생성
os.makedirs(os.path.dirname("./data"), exist_ok=True)
with open(DOMAIN_FILE, 'w', encoding='utf-8') as f:
with open(DOMAIN_FILE, "w", encoding="utf-8") as f:
f.write(response.text)
print("도메인 파일 다운로드 완료")
except requests.RequestException as e:
print(f"도메인 파일 다운로드 실패: {e}")
sys.exit(1)
def run_script(start_line, end_line, skh_option):
"""Python 스크립트 실행"""
current_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(f"[{current_time}] Processing lines {start_line} to {end_line}...")
process = None
signal_handled = False
def signal_handler(sig, frame):
nonlocal signal_handled
if signal_handled:
return
signal_handled = True
print("\n🛑 종료 신호를 받았습니다. 정리 작업을 진행합니다...")
if process:
try:
# 자식 프로세스에 SIGTERM 전송
print("📤 서브프로세스에 종료 신호를 전달합니다...")
process.terminate()
# 5초간 대기
process.wait(timeout=5)
print("✅ 서브프로세스가 정상적으로 종료되었습니다.")
except subprocess.TimeoutExpired:
print("⚠️ 서브프로세스가 응답하지 않아 강제 종료합니다...")
process.kill()
try:
process.wait(timeout=3)
print("✅ 서브프로세스가 강제 종료되었습니다.")
except subprocess.TimeoutExpired:
print("❌ 서브프로세스 강제 종료 실패")
except Exception as e:
print(f"❌ 프로세스 종료 중 오류: {e}")
print("✅ 런처 종료 완료.")
sys.exit(0)
# 원래 시그널 핸들러 저장
original_sigint = signal.signal(signal.SIGINT, signal_handler)
original_sigterm = signal.signal(signal.SIGTERM, signal_handler)
try:
command = [
"uv", "run", PYTHON_SCRIPT,
"-f", DOMAIN_FILE,
"-s", str(start_line),
"-e", str(end_line),
"uv",
"run",
PYTHON_SCRIPT,
"-f",
DOMAIN_FILE,
"-s",
str(start_line),
"-e",
str(end_line),
]
if skh_option:
command.append("--skip-html-check")
subprocess.run(command, check=True)
except subprocess.CalledProcessError:
print("Python 스크립트 실행 실패")
process = subprocess.Popen(command)
returncode = process.wait()
if returncode != 0:
print(f"❌ Python 스크립트가 오류 코드 {returncode}로 종료되었습니다.")
sys.exit(returncode)
except KeyboardInterrupt:
signal_handler(signal.SIGINT, None)
except Exception as e:
print(f"❌ 스크립트 실행 중 오류: {e}")
if process:
try:
process.terminate()
process.wait(timeout=3)
except subprocess.TimeoutExpired:
process.kill()
sys.exit(1)
finally:
# 시그널 핸들러 복원
signal.signal(signal.SIGINT, original_sigint)
signal.signal(signal.SIGTERM, original_sigterm)
def main():
parser = argparse.ArgumentParser(
@ -59,13 +125,15 @@ def main():
uv run run.py 10000 11000 # 10000~11000 라인 처리
uv run run.py 10000 11000 --skh # SKH 옵션 활성화
uv run run.py 10000 11000 --no-download # 다운로드 생략
"""
""",
)
parser.add_argument("start_line", type=int, help="시작 라인 번호")
parser.add_argument("end_line", type=int, help="종료 라인 번호")
parser.add_argument("--skh", action="store_true", help="SKH 옵션 활성화")
parser.add_argument("--no-download", action="store_true", help="도메인 파일 다운로드 생략")
parser.add_argument(
"--no-download", action="store_true", help="도메인 파일 다운로드 생략"
)
args = parser.parse_args()
@ -82,7 +150,9 @@ def main():
if not args.no_download:
download_domains()
elif not os.path.exists(DOMAIN_FILE):
print(f"도메인 파일({DOMAIN_FILE})이 존재하지 않습니다. --no-download 옵션을 제거하거나 파일을 준비해주세요.")
print(
f"도메인 파일({DOMAIN_FILE})이 존재하지 않습니다. --no-download 옵션을 제거하거나 파일을 준비해주세요."
)
sys.exit(1)
# 스크립트 실행
@ -90,5 +160,6 @@ def main():
print("처리 완료.")
if __name__ == "__main__":
main()

View file

@ -1,5 +1,4 @@
from lib.browser_use.agents import *
from lib.browser_use.clean_resources import *
from lib.browser_use.func import *
from lib.browser_use.init_profile import *
from lib.browser_use.model import *

View file

@ -1,6 +1,7 @@
import asyncio
import json
import os
import shutil
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any, Dict, Optional
@ -8,7 +9,6 @@ from typing import Any, Dict, Optional
from browser_use import Agent, BrowserSession, Controller
from patchright.async_api import async_playwright as async_patchright
from lib.browser_use.clean_resources import clean_resources
from lib.browser_use.init_profile import GetProfile
from lib.browser_use.sensitive_data import GetSensitiveData
from lib.llm import CreateChatGoogle, get_prompt
@ -147,15 +147,15 @@ async def _run_agent_with_retry(agent_config):
while try_cnt < 3:
try:
Profile = await GetProfile(headless=headless)
session = BrowserSession(
playwright=(await async_patchright().start()),
browser_profile=await GetProfile(headless=headless),
browser_profile=Profile[0],
)
agent = Agent(browser_session=session, **agent_config["agent_params"])
response = await agent.run()
await clean_resources(agent, session)
if any(
keyword in str(response)
@ -180,11 +180,14 @@ async def _run_agent_with_retry(agent_config):
await add_to_retry_queue(task)
return None
# remove profile
if Profile[1]:
shutil.rmtree(Profile[1], ignore_errors=True)
print(f"🗑️ 임시 프로필 디렉토리 삭제 완료: {Profile[1]}")
return response
except Exception as e:
await clean_resources(agent, session)
# 일반 에러 처리
try_cnt += 1
if try_cnt >= 3:

View file

@ -1,26 +0,0 @@
from pathlib import Path
async def clean_resources(agent=None, session=None):
"""리소스를 정리하는 함수"""
storage_state_temp_path = Path("./data/storage_state_temp.json").resolve()
if storage_state_temp_path.exists():
try:
# remove file
print(f"🗑️ 임시 스토리지 상태 파일 삭제 중: {storage_state_temp_path}")
# unlink removes the file
storage_state_temp_path.unlink()
print("🗑️ 임시 스토리지 상태 파일 삭제 완료.")
except Exception as e:
print(f"⚠️ 임시 스토리지 상태 파일 삭제 실패: {e}")
if agent:
try:
await agent.close()
except Exception as e:
print(f"⚠️ 에이전트 리소스 정리 실패: {e}")
if session:
try:
await session.close()
except Exception as e:
print(f"⚠️ 세션 리소스 정리 실패: {e}")

View file

@ -0,0 +1,110 @@
"""
브라우저 리소스 정리를 위한 모듈
"""
import os
import shutil
import asyncio
from pathlib import Path
async def cleanup_browser_resources(agent=None, session=None, user_data_dir=None):
"""브라우저 관련 리소스를 정리하는 함수"""
print("🔄 브라우저 리소스 정리를 시작합니다...")
# 에이전트 리소스 정리
if agent:
try:
print("<EFBFBD> 에이전트 리소스 정리 중...")
# 브라우저 종료 대기 시간 설정
await asyncio.wait_for(agent.close(), timeout=10.0)
print("✅ 에이전트 리소스 정리 완료.")
except asyncio.TimeoutError:
print("⚠️ 에이전트 종료 시간 초과. 강제 종료합니다.")
except Exception as e:
print(f"⚠️ 에이전트 리소스 정리 실패: {e}")
# 세션 리소스 정리
if session:
try:
print("🔄 세션 리소스 정리 중...")
await asyncio.wait_for(session.close(), timeout=5.0)
print("✅ 세션 리소스 정리 완료.")
except asyncio.TimeoutError:
print("⚠️ 세션 종료 시간 초과.")
except Exception as e:
print(f"⚠️ 세션 리소스 정리 실패: {e}")
# 임시 스토리지 상태 파일 삭제
storage_state_temp_path = Path("./data/storage_state_temp.json").resolve()
if storage_state_temp_path.exists():
try:
print(f"<EFBFBD> 임시 스토리지 상태 파일 삭제 중: {storage_state_temp_path}")
storage_state_temp_path.unlink()
print("✅ 임시 스토리지 상태 파일 삭제 완료.")
except Exception as e:
print(f"⚠️ 임시 스토리지 상태 파일 삭제 실패: {e}")
# 임시 사용자 데이터 디렉토리 정리
if user_data_dir and os.path.exists(user_data_dir):
try:
print(f"🗑️ 임시 사용자 데이터 디렉토리 삭제 중: {user_data_dir}")
await asyncio.sleep(0.5) # 브라우저가 완전히 종료될 시간 제공
shutil.rmtree(user_data_dir)
print("✅ 임시 사용자 데이터 디렉토리 삭제 완료.")
except Exception as e:
print(f"⚠️ 임시 사용자 데이터 디렉토리 삭제 실패: {e}")
# userdata.dump 파일에서 기록된 디렉토리 정리
log_file = "./data/userdata.dump"
if os.path.exists(log_file):
try:
with open(log_file, "r") as f:
tmp_user_data_dir = f.read().strip()
if tmp_user_data_dir and os.path.exists(tmp_user_data_dir):
print(f"🗑️ 기록된 임시 사용자 데이터 디렉토리 삭제 중: {tmp_user_data_dir}")
await asyncio.sleep(0.5) # 브라우저가 완전히 종료될 시간 제공
shutil.rmtree(tmp_user_data_dir)
print("✅ 기록된 임시 사용자 데이터 디렉토리 삭제 완료.")
os.remove(log_file)
print("✅ userdata.dump 파일 삭제 완료.")
except Exception as e:
print(f"⚠️ userdata.dump 관련 정리 실패: {e}")
print("✅ 브라우저 리소스 정리가 완료되었습니다.")
def cleanup_all_running_tasks():
"""실행 중인 모든 asyncio 태스크를 정리"""
try:
loop = asyncio.get_running_loop()
tasks = [task for task in asyncio.all_tasks(loop) if not task.done()]
if tasks:
print(f"🔄 {len(tasks)}개의 실행 중인 태스크를 정리합니다...")
for task in tasks:
task.cancel()
# 태스크들이 정리될 때까지 잠시 대기
async def wait_for_tasks():
await asyncio.gather(*tasks, return_exceptions=True)
asyncio.create_task(wait_for_tasks())
print("✅ 모든 태스크 정리 완료.")
except RuntimeError:
# 이벤트 루프가 실행 중이 아닌 경우
pass
except Exception as e:
print(f"⚠️ 태스크 정리 중 오류: {e}")
async def emergency_cleanup():
"""긴급 종료 시 최소한의 리소스 정리"""
print("🚨 긴급 리소스 정리 실행 중...")
# 모든 태스크 취소
cleanup_all_running_tasks()
# 기본 리소스 정리
await cleanup_browser_resources()
print("✅ 긴급 리소스 정리 완료.")

View file

@ -10,34 +10,74 @@ proxy_url = setup_proxy()
async def GetProfile(headless=False):
"""브라우저 프로필을 생성하고 임시 사용자 데이터 디렉토리를 관리합니다."""
user_data_dir = None
tmp_user_data_dir = None
if USER_DATA_DIR and os.path.isdir(USER_DATA_DIR):
try:
tmp_user_data_dir = tempfile.mkdtemp()
tmp_user_data_dir = tempfile.mkdtemp(prefix="browser_use_")
print(f"🔧 기본 사용자 데이터 디렉토리: {USER_DATA_DIR}")
print(f"🔧 임시 사용자 데이터 디렉토리: {tmp_user_data_dir}")
log_file = os.path.join("./data", "userdata.dump")
if not os.path.exists("./data"):
os.makedirs("./data")
# 기존 로그 파일이 있다면 해당 디렉토리 정리
if os.path.exists(log_file):
try:
with open(log_file, "r") as f:
old_tmp_dir = f.read().strip()
if old_tmp_dir and os.path.exists(old_tmp_dir):
shutil.rmtree(old_tmp_dir)
print(f"🗑️ 이전 임시 디렉토리 정리: {old_tmp_dir}")
except Exception as e:
print(f"⚠️ 이전 임시 디렉토리 정리 실패: {e}")
os.remove(log_file)
# 새 임시 디렉토리 경로 로깅
with open(log_file, "w") as f:
f.write(tmp_user_data_dir)
# 사용자 데이터 디렉토리 복사
if os.path.exists(tmp_user_data_dir):
shutil.rmtree(tmp_user_data_dir)
shutil.copytree(USER_DATA_DIR, tmp_user_data_dir, dirs_exist_ok=False, ignore_dangling_symlinks=True)
shutil.copytree(
USER_DATA_DIR,
tmp_user_data_dir,
dirs_exist_ok=False,
ignore_dangling_symlinks=True
)
user_data_dir = tmp_user_data_dir
print(f"✅ Copied user data dir to temporary location: {user_data_dir}")
print(f"사용자 데이터 디렉토리 복사 완료: {user_data_dir}")
except Exception as e:
print(f"❌ Failed to copy user data dir: {e}")
print(f"❌ 사용자 데이터 디렉토리 복사 실패: {e}")
# 실패 시 임시 디렉토리 정리
if tmp_user_data_dir and os.path.exists(tmp_user_data_dir):
try:
shutil.rmtree(tmp_user_data_dir)
except Exception:
pass
tmp_user_data_dir = None
user_data_dir = None
profile = BrowserProfile(
# Security settings
disable_security=True,
#stealth=True,
# Display settings
headless=headless,
device_scale_factor=1,
window_size={"width": 1600, "height": 900},
# Data persistence
user_data_dir=user_data_dir,
#storage_state=storage_state,
# Network settings
proxy={"server": proxy_url} if proxy_url else None,
# Additional arguments
args=get_browser_args(),
ignore_default_args=['--enable-automation']
ignore_default_args=[
'--enable-automation',
'--disable-extensions',
'--hide-scrollbars',
'--disable-features=AcceptCHFrame,AutoExpandDetailsElement,AvoidUnnecessaryBeforeUnloadCheckSync,CertificateTransparencyComponentUpdater,DeferRendererTasksAfterInput,DestroyProfileOnBrowserClose,DialMediaRouteProvider,ExtensionManifestV2Disabled,GlobalMediaControls,HttpsUpgrades,ImprovedCookieControls,LazyFrameLoading,LensOverlay,MediaRouter,PaintHolding,ThirdPartyStoragePartitioning,Translate'
],
)
return profile
return [profile, tmp_user_data_dir] if tmp_user_data_dir else [profile]

View file

@ -8,9 +8,11 @@ from lib.browser_use.agents import (
start_retry_queue_processor,
test_oauth_login,
)
from lib.browser_use.cleanup import cleanup_browser_resources
from lib.utils import is_html_url, notify_backend, read_lines_between
from lib.utils.progress import (
current_progress,
is_shutdown_requested,
load_progress,
progress_file,
save_progress,
@ -75,6 +77,7 @@ async def main_loop(
filepath: str, start_line: int, end_line: int, skip_html_check: bool = False
):
"""지정된 URL 목록에 대해 스캔을 실행하는 메인 루프"""
try:
# 재시도 큐 처리기 시작
await start_retry_queue_processor()
@ -106,6 +109,11 @@ async def main_loop(
print(f"{start_index}번째부터 재개합니다.")
for i, url in enumerate(target_list):
# 종료 요청 체크
if is_shutdown_requested():
print("🛑 종료 요청으로 인해 스캔을 중단합니다.")
break
# current_index는 전체 목록에서의 현재 위치를 나타냄
current_url_index = current_progress["current_index"]
current_progress["current_url"] = url
@ -124,9 +132,18 @@ async def main_loop(
if i > 0:
print("⏳ API 쿼터 보호를 위해 30초 대기 중...")
await asyncio.sleep(30)
# 대기 중에도 종료 요청 체크
for _ in range(30):
if is_shutdown_requested():
print("🛑 대기 중 종료 요청으로 스캔을 중단합니다.")
return
await asyncio.sleep(1)
try:
await scan_one_url(url, skip_html_check=skip_html_check)
except Exception as e:
print(f"{url} 스캔 중 오류 발생: {e}")
continue
# 스캔 완료 후 재시도 큐 상태 확인
retry_status_after = await get_retry_queue_status()
@ -140,15 +157,35 @@ async def main_loop(
save_progress()
# 모든 URL 처리 완료 후 재시도 큐가 빌 때까지 대기
if not is_shutdown_requested():
print("\n🔄 모든 URL 처리 완료. 재시도 큐 처리 대기 중...")
while True:
if is_shutdown_requested():
print("🛑 재시도 큐 대기 중 종료 요청으로 중단합니다.")
break
retry_status = await get_retry_queue_status()
if retry_status["queue_length"] == 0:
break
print(
f"⏳ 재시도 큐에 {retry_status['queue_length']}개 작업 남음. 30초 후 다시 확인..."
)
await asyncio.sleep(30)
# 대기 중에도 종료 요청 체크
for _ in range(30):
if is_shutdown_requested():
print("🛑 재시도 큐 대기 중 종료 요청으로 중단합니다.")
break
await asyncio.sleep(1)
if not is_shutdown_requested():
print(f"\n🎉 모든 스캔이 완료되었습니다! ({total_count}개 URL)")
print("🎉 재시도 큐도 모두 처리되었습니다!")
else:
print("\n🛑 종료 요청으로 인해 스캔이 중단되었습니다.")
else:
print("\n🛑 종료 요청으로 인해 스캔이 중단되었습니다.")
finally:
# 항상 리소스 정리
print("🔄 브라우저 리소스를 정리합니다...")
await cleanup_browser_resources()

View file

@ -30,7 +30,10 @@ Instructions:
3. Click the **SSO login button**.
4. Check if the user is **already logged and immediately redirected back to the original site** without showing a login screen.
- If so, treat the login as successful and return immediately.
5. If login proceeds without interruptions, wait for redirection back to the original site and record the final URL.
5. If login proceeds without interruptions, complete the login and **immediately close the browser window**. Do not perform any further actions.
6. Login is considered successful if:
- You are redirected to a page that indicates successful login (e.g., a welcome page, dashboard, or account page).
- If a page such as a sign-up page appears, consider it a successful login and terminate immediately.
Credentials to use for login:
- Google `{google_id}` / `{google_password}`
@ -38,7 +41,6 @@ Credentials to use for login:
- GitHub `{github_id}` / `{github_password}`
- facebook `{facebook_id}` / `{facebook_password}`
- Microsoft `{microsoft_id}` / `{microsoft_password}`
If credentials for a provider are not provided, skip the login attempt for that provider.
Constraints:
- Do NOT use search engines or guess URLs.

View file

@ -26,7 +26,10 @@ Instructions:
a. If a **CAPTCHA**, complete it.
b. If a **MFA prompt**, or a request for **ID/password entry** appears, do NOT proceed - Immediately stop and return the appropriate status.
- If a **"Continue"**, **"Trust"**, **"Authorize"**, or **"Allow"** button is displayed, click it to grant consent.
7. If login proceeds without interruptions, wait for redirection back to the original site and record the final URL.
7. If login proceeds without interruptions, complete the login and **immediately close the browser window**. Do not perform any further actions.
8. Login is considered successful if:
- You are redirected to a page that indicates successful login (e.g., a welcome page, dashboard, or account page).
- If a page such as a sign-up page appears, consider it a successful login and terminate immediately.
Credentials to use for Apple login:
- Email: {os.getenv("APPLE_EMAIL", "")}

View file

@ -39,6 +39,10 @@ Instructions:
- Password: "TestPassword123!"
- Click "Create account", "Sign up", or "Complete registration" button
- Only after completing ALL steps, record the final URL as successful login
9. If all steps are completed successfully, close your browser window.
10. Login is considered successful if:
- You are redirected to a page that indicates successful login (e.g., a welcome page, dashboard, or account page).
- If a page such as a sign-up page appears, consider it a successful login and terminate immediately.
Credentials to use for Facebook login (if needed):
- Email/Phone: {os.getenv("FACEBOOK_EMAIL", "")}

View file

@ -43,6 +43,12 @@ Instructions:
7. If login proceeds without interruptions, wait for redirection back to the original site and record the final URL.
8. Close your browser window after the login is completed.
9. Login is considered successful if:
- You are redirected to a page that indicates successful login (e.g., a welcome page, dashboard, or account page).
- If a page such as a sign-up page appears, consider it a successful login and terminate immediately.
Credentials to use for GitHub login:
- Email: {os.getenv("GITHUB_EMAIL", "")}
- Password: {os.getenv("GITHUB_PASSWORD", "")}

View file

@ -28,6 +28,10 @@ Instructions:
c. Wait for the password input field, then enter the password: {os.getenv("GOOGLE_PASSWORD", "")}
d. Click the "Sign in" or "Next" button.
7. If login proceeds without interruptions, wait for redirection back to the original site and record the final URL.
8. Close your browser window after the login is completed.
9. Login is considered successful if:
- You are redirected to a page that indicates successful login (e.g., a welcome page, dashboard, or account page).
- If a page such as a sign-up page appears, consider it a successful login and terminate immediately.
Credentials to use for Google login:
- Email: {os.getenv("GOOGLE_EMAIL", "")}

View file

@ -29,6 +29,10 @@ prompt = f"""
8. 로그인 되어있지 않으면 아래의 EMAIL과 PASSWORD를 사용하여 로그인하세요:
- Email: {os.getenv("MICROSOFT_EMAIL", "")}
- Password: {os.getenv("MICROSOFT_PASSWORD", "")}
9. 로그인 완료 브라우저 창을 닫으세요.
10. Login is considered successful if:
- You are redirected to a page that indicates successful login (e.g., a welcome page, dashboard, or account page).
- If a page such as a sign-up page appears, consider it a successful login and terminate immediately.
제약 사항:
- 검색 엔진을 사용하거나 URL을 추측하지 마세요.

View file

@ -1,12 +1,20 @@
import json
import os
import os, sys
import signal
import time
import threading
from pathlib import Path
# 진행 상황 추적을 위한 전역 변수
current_progress = {"current_index": 0, "total": 0, "current_url": "", "start_line": 0}
progress_file = Path("data/scan_progress.json")
# Ctrl+C 처리를 위한 전역 변수
ctrl_c_count = 0
last_ctrl_c_time = 0
shutdown_requested = False
shutdown_lock = threading.Lock()
def save_progress():
"""현재 진행 상황을 파일에 저장"""
@ -27,26 +35,85 @@ def load_progress():
def signal_handler(signum, frame):
"""Ctrl+C 시그널 핸들러"""
"""Ctrl+C 시그널 핸들러 - browser-use pause 기능과 호환"""
global shutdown_requested, ctrl_c_count, last_ctrl_c_time
current_time = time.time()
with shutdown_lock:
# 연속된 Ctrl+C 감지 (2초 내에 두 번 누르면 강제 종료)
if current_time - last_ctrl_c_time < 2.0:
ctrl_c_count += 1
else:
ctrl_c_count = 1
last_ctrl_c_time = current_time
# 두 번째 Ctrl+C이거나 이미 종료 요청이 있었다면 강제 종료
if ctrl_c_count >= 2 or shutdown_requested:
print("\n⚡ 강제 종료합니다!")
import asyncio
try:
loop = asyncio.get_running_loop()
for task in asyncio.all_tasks(loop):
task.cancel()
except RuntimeError:
pass
os._exit(1)
# 첫 번째 Ctrl+C: 정상 종료 요청
shutdown_requested = True
print("\n" + "=" * 60)
print("🛑 스캔이 중단되었습니다!")
print(f"📊 진행 상황:")
print("🛑 종료 신호를 받았습니다!")
print(f"📊 현재 진행 상황:")
print(f" - 전체: {current_progress['total']}개 URL")
print(f" - 완료: {current_progress['current_index']}개 URL")
print(f" - 현재 처리 중: {current_progress['current_url']}")
print(
f" - domains.txt의 {current_progress['start_line'] + current_progress['current_index']}번째 줄"
)
if current_progress.get('start_line'):
print(f" - domains.txt의 {current_progress['start_line'] + current_progress['current_index']}번째 줄")
if current_progress["total"] > 0:
print(
f" - 진행률: {current_progress['current_index']}/{current_progress['total']} ({current_progress['current_index']/current_progress['total']*100:.1f}%)"
)
print(f" - 진행률: {current_progress['current_index']}/{current_progress['total']} ({current_progress['current_index']/current_progress['total']*100:.1f}%)")
print("=" * 60)
# 진행 상황 저장
save_progress()
print(f"💾 진행 상황이 {progress_file}에 저장되었습니다.")
exit(0)
print("다음에 같은 명령어로 실행하면 이어서 진행할 수 있습니다.")
print("<EFBFBD> 2초 내에 Ctrl+C를 다시 누르면 강제 종료됩니다.")
# 정상적인 종료를 위해 KeyboardInterrupt 발생
raise KeyboardInterrupt()
def is_shutdown_requested():
"""종료 요청 상태를 확인하는 함수"""
with shutdown_lock:
return shutdown_requested
def request_shutdown():
"""외부에서 종료를 요청할 수 있는 함수"""
global shutdown_requested
with shutdown_lock:
if not shutdown_requested:
shutdown_requested = True
print("\n🛑 종료가 요청되었습니다.")
print(f"📊 현재 진행 상황:")
print(f" - 전체: {current_progress['total']}개 URL")
print(f" - 완료: {current_progress['current_index']}개 URL")
print(f" - 현재 처리 중: {current_progress['current_url']}")
if current_progress.get('start_line'):
print(f" - domains.txt의 {current_progress['start_line'] + current_progress['current_index']}번째 줄")
if current_progress["total"] > 0:
print(f" - 진행률: {current_progress['current_index']}/{current_progress['total']} ({current_progress['current_index']/current_progress['total']*100:.1f}%)")
save_progress()
print(f"💾 진행 상황이 {progress_file}에 저장되었습니다.")
print("다음에 같은 명령어로 실행하면 이어서 진행할 수 있습니다.")
def setup_signal_handler():
"""시그널 핸들러 등록"""
signal.signal(signal.SIGINT, signal_handler)
"""시그널 핸들러 등록 - browser-use와의 호환성을 위해 비활성화"""
# browser-use 라이브러리가 자체적으로 Ctrl+C 처리를 하므로
# 우리의 signal handler는 등록하지 않음
pass

View file

@ -0,0 +1,116 @@
"""
종료 처리를 위한 개선된 모듈
browser-use의 pause 기능과 호환되도록 설계
"""
import json
import os
import signal
import time
import threading
import asyncio
from pathlib import Path
# 진행 상황 추적을 위한 전역 변수
current_progress = {"current_index": 0, "total": 0, "current_url": "", "start_line": 0}
progress_file = Path("data/scan_progress.json")
# 종료 관리를 위한 전역 변수
shutdown_requested = False
shutdown_lock = threading.Lock()
original_handler = None
def save_progress():
"""현재 진행 상황을 파일에 저장"""
progress_file.parent.mkdir(parents=True, exist_ok=True)
with open(progress_file, "w", encoding="utf-8") as f:
json.dump(current_progress, f, ensure_ascii=False, indent=2)
def load_progress():
"""이전 진행 상황을 파일에서 불러오기"""
if os.path.exists(progress_file):
try:
with open(progress_file, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
return None
return None
def request_shutdown():
"""종료 요청 함수 - 외부에서 호출 가능"""
global shutdown_requested
with shutdown_lock:
if not shutdown_requested:
shutdown_requested = True
print("\n🛑 종료가 요청되었습니다. 현재 작업을 완료한 후 종료합니다...")
save_progress()
print(f"💾 진행 상황이 {progress_file}에 저장되었습니다.")
def is_shutdown_requested():
"""종료 요청 상태를 확인하는 함수"""
with shutdown_lock:
return shutdown_requested
def cleanup_signal_handler():
"""signal handler를 정리하고 원래 상태로 복원"""
global original_handler
if original_handler is not None:
signal.signal(signal.SIGINT, original_handler)
original_handler = None
def setup_minimal_signal_handler():
"""최소한의 signal handler만 설정 - browser-use와 충돌 방지"""
global original_handler
# 원래 핸들러 저장
original_handler = signal.signal(signal.SIGINT, signal.SIG_DFL)
def graceful_signal_handler(signum, frame):
"""우아한 종료를 위한 최소한의 signal handler"""
print("\n🛑 종료 신호를 받았습니다...")
save_progress()
print(f"💾 진행 상황이 {progress_file}에 저장되었습니다.")
# 원래 핸들러로 복원하고 신호를 다시 발생시킴
signal.signal(signal.SIGINT, original_handler)
os.kill(os.getpid(), signal.SIGINT)
signal.signal(signal.SIGINT, graceful_signal_handler)
class GracefulShutdown:
"""컨텍스트 매니저로 사용할 수 있는 우아한 종료 클래스"""
def __init__(self):
self.original_handler = None
def __enter__(self):
self.original_handler = signal.signal(signal.SIGINT, self._signal_handler)
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if self.original_handler is not None:
signal.signal(signal.SIGINT, self.original_handler)
def _signal_handler(self, signum, frame):
"""내부 signal handler"""
request_shutdown()
# 원래 핸들러 복원 후 신호 재전송
signal.signal(signal.SIGINT, self.original_handler)
os.kill(os.getpid(), signal.SIGINT)
# 기존 함수들과의 호환성을 위한 별칭
def setup_signal_handler():
"""기존 코드와의 호환성을 위한 함수"""
pass # browser-use의 signal handler를 방해하지 않음
def signal_handler(signum, frame):
"""기존 코드와의 호환성을 위한 함수"""
request_shutdown()

View file

@ -26,6 +26,8 @@ def setup_environment():
Laminar.initialize(project_api_key=os.getenv("LMNR_PROJECT_API_KEY"))
except ImportError:
print("⚠️ Laminar 라이브러리가 설치되지 않았습니다. 관련 기능이 비활성화됩니다.")
else:
print("⚠️ LMNR_PROJECT_API_KEY 환경 변수가 설정되지 않았습니다. Laminar 기능이 비활성화됩니다.")
def parse_arguments():
@ -64,6 +66,25 @@ def main():
setup_signal_handler()
args = parse_arguments()
# read and remove user data path
log_file = os.path.join("./data", "userdata.dump")
if not os.path.exists("./data"):
os.makedirs("./data")
if os.path.exists(log_file):
with open(log_file, "r") as f:
tmp_user_data_dir = f.read().strip()
try:
import shutil
if os.path.exists(tmp_user_data_dir):
shutil.rmtree(tmp_user_data_dir)
print(f"🔧 이전 실행의 임시 사용자 데이터 디렉토리 {tmp_user_data_dir}를 삭제하였습니다.")
except (PermissionError, FileNotFoundError, OSError) as e:
print(f"⚠️ 임시 사용자 데이터 디렉토리 삭제 실패: {e}")
try:
os.remove(log_file)
except OSError:
pass
try:
asyncio.run(
main_loop(
@ -74,16 +95,26 @@ def main():
)
)
except KeyboardInterrupt:
print("\n프로그램이 사용자에 의해 중단되었습니다.")
print("\n🛑 사용자에 의해 중단되었습니다.")
# 진행 상황 저장
from lib.utils.progress import save_progress, request_shutdown
request_shutdown()
print("✅ 정리 완료.")
sys.exit(0)
except Exception as e:
print(f"\n❌ 예상치 못한 오류가 발생했습니다: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
finally:
# 정상 종료 시 진행 상황 파일 삭제
if os.path.exists(progress_file):
# 정상 종료 시에만 진행 상황 파일 삭제
from lib.utils.progress import is_shutdown_requested
if not is_shutdown_requested() and os.path.exists(progress_file):
try:
os.remove(progress_file)
print("진행 상황 파일이 삭제되었습니다.")
print("진행 상황 파일이 삭제되었습니다.")
except OSError as e:
print(f"오류: 진행 상황 파일을 삭제하지 못했습니다. {e}", file=sys.stderr)
print(f"⚠️ 진행 상황 파일 삭제 실패: {e}", file=sys.stderr)
if __name__ == "__main__":

836
uv.lock generated

File diff suppressed because it is too large Load diff