v1.0: 重写核心代码,清理空壳脚本
- 重写 ticket_script.py: 状态机、完整的选票/下单流程、反检测 - 重写 scripts/: 验证码处理、定时调度、NTP校时、多账户管理 - 删除空壳 appium_simulator.py - 清理配置文件中的硬编码密码 - 重写 README,去除虚假宣传
This commit is contained in:
11
scripts/__init__.py
Normal file
11
scripts/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
damai.scripts - 抢票工具模块
|
||||
|
||||
模块说明:
|
||||
- ticket_script 核心抢票逻辑 (主入口)
|
||||
- selenium_driver 浏览器驱动工厂
|
||||
- captcha_solver 验证码检测与处理
|
||||
- scheduler 定时调度与 NTP 校时
|
||||
- multi_account_manager 多账户管理
|
||||
- mock_dependency_manager GUI 依赖模拟 (仅用于界面演示)
|
||||
"""
|
||||
160
scripts/captcha_solver.py
Normal file
160
scripts/captcha_solver.py
Normal file
@@ -0,0 +1,160 @@
|
||||
# coding: utf-8
|
||||
"""
|
||||
验证码处理模块
|
||||
支持:图像验证码、滑块验证码
|
||||
依赖:Pillow (PIL)
|
||||
"""
|
||||
|
||||
import io
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum, auto
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.remote.webelement import WebElement
|
||||
|
||||
logger = logging.getLogger("damai.captcha")
|
||||
|
||||
|
||||
class CaptchaType(Enum):
|
||||
NONE = auto()
|
||||
IMAGE = auto() # 图形验证码
|
||||
SLIDER = auto() # 滑块验证码
|
||||
CLICK = auto() # 点选验证码
|
||||
UNKNOWN = auto()
|
||||
|
||||
|
||||
@dataclass
|
||||
class CaptchaResult:
|
||||
success: bool
|
||||
captcha_type: CaptchaType
|
||||
message: str = ""
|
||||
|
||||
|
||||
def detect_captcha(driver) -> CaptchaType:
|
||||
"""检测当前页面是否存在验证码"""
|
||||
# 检测滑块
|
||||
slider_selectors = [
|
||||
".captcha-slider",
|
||||
".slide-verify",
|
||||
"[class*='slider']",
|
||||
".nc-lang-cn", # 阿里云盾滑块
|
||||
]
|
||||
for sel in slider_selectors:
|
||||
try:
|
||||
driver.find_element(By.CSS_SELECTOR, sel)
|
||||
logger.info(f"检测到滑块验证码: {sel}")
|
||||
return CaptchaType.SLIDER
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# 检测图形验证码
|
||||
image_selectors = [
|
||||
".captcha-img",
|
||||
"img[src*='captcha']",
|
||||
"img[src*='verify']",
|
||||
"#captcha_img",
|
||||
]
|
||||
for sel in image_selectors:
|
||||
try:
|
||||
driver.find_element(By.CSS_SELECTOR, sel)
|
||||
logger.info(f"检测到图形验证码: {sel}")
|
||||
return CaptchaType.IMAGE
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# 检测弹窗中是否有验证码关键词
|
||||
try:
|
||||
page_source = driver.page_source.lower()
|
||||
if "captcha" in page_source or "验证码" in page_source:
|
||||
return CaptchaType.UNKNOWN
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return CaptchaType.NONE
|
||||
|
||||
|
||||
def solve_slider(driver, slider_element: Optional[WebElement] = None) -> CaptchaResult:
|
||||
"""
|
||||
处理滑块验证码
|
||||
简单实现:拖动滑块从左到右
|
||||
更精确的方案需要图像处理来识别缺口位置
|
||||
"""
|
||||
from selenium.webdriver.common.action_chains import ActionChains
|
||||
|
||||
selectors = [
|
||||
".captcha-slider .slide-btn",
|
||||
".slide-verify-slider-mask-item",
|
||||
".nc-lang-cn .btn_slide",
|
||||
"[class*='slider'] [class*='btn']",
|
||||
]
|
||||
|
||||
slider = slider_element
|
||||
if not slider:
|
||||
for sel in selectors:
|
||||
try:
|
||||
slider = driver.find_element(By.CSS_SELECTOR, sel)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not slider:
|
||||
return CaptchaResult(False, CaptchaType.SLIDER, "找不到滑块元素")
|
||||
|
||||
try:
|
||||
# 获取滑块轨道宽度
|
||||
track = slider.find_element(By.XPATH, "..")
|
||||
track_width = track.size["width"]
|
||||
slider_width = slider.size["width"]
|
||||
distance = track_width - slider_width
|
||||
|
||||
if distance <= 0:
|
||||
distance = 300 # fallback
|
||||
|
||||
actions = ActionChains(driver)
|
||||
actions.click_and_hold(slider)
|
||||
actions.pause(0.1)
|
||||
|
||||
# 模拟人类拖动:分段加速减速
|
||||
steps = 20
|
||||
for i in range(steps):
|
||||
progress = (i + 1) / steps
|
||||
# 加速-减速曲线
|
||||
if progress < 0.3:
|
||||
offset = distance * progress * 0.5
|
||||
elif progress < 0.7:
|
||||
offset = distance * progress
|
||||
else:
|
||||
offset = distance * progress * 0.95
|
||||
move = int(offset - (distance * (i / steps) if i > 0 else 0))
|
||||
actions.move_by_offset(move, 0)
|
||||
actions.pause(0.02)
|
||||
|
||||
actions.release()
|
||||
actions.perform()
|
||||
|
||||
logger.info("滑块拖动完成")
|
||||
return CaptchaResult(True, CaptchaType.SLIDER)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"滑块处理失败: {e}")
|
||||
return CaptchaResult(False, CaptchaType.SLIDER, str(e))
|
||||
|
||||
|
||||
def handle_captcha(driver) -> CaptchaResult:
|
||||
"""自动检测并处理验证码"""
|
||||
captcha_type = detect_captcha(driver)
|
||||
|
||||
if captcha_type == CaptchaType.NONE:
|
||||
return CaptchaResult(True, CaptchaType.NONE)
|
||||
|
||||
if captcha_type == CaptchaType.SLIDER:
|
||||
return solve_slider(driver)
|
||||
|
||||
if captcha_type == CaptchaType.IMAGE:
|
||||
logger.warning("图形验证码需要人工处理或接入 OCR 服务")
|
||||
return CaptchaResult(False, CaptchaType.IMAGE, "需要人工处理")
|
||||
|
||||
logger.warning(f"未知验证码类型,需要人工处理")
|
||||
return CaptchaResult(False, captcha_type, "未知类型")
|
||||
88
scripts/main.py
Normal file
88
scripts/main.py
Normal file
@@ -0,0 +1,88 @@
|
||||
# coding: utf-8
|
||||
"""
|
||||
主入口模块
|
||||
支持单账户和多账户模式
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from ticket_script import TicketBot, TicketConfig
|
||||
|
||||
logger = logging.getLogger("damai")
|
||||
|
||||
|
||||
def load_config(path: str = "config/config.json") -> dict:
|
||||
config_path = Path(path)
|
||||
if not config_path.exists():
|
||||
logger.error(f"配置文件不存在: {path}")
|
||||
sys.exit(1)
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def run_single_account(config: dict):
|
||||
"""单账户模式"""
|
||||
tc = TicketConfig(
|
||||
date=config["date"],
|
||||
sess=config["sess"],
|
||||
price=config["price"],
|
||||
ticket_num=config.get("ticket_num", 2),
|
||||
viewer_person=config.get("viewer_person", [1]),
|
||||
nick_name=config.get("nick_name", ""),
|
||||
damai_url=config.get("damai_url", "https://www.damai.cn/"),
|
||||
target_url=config["target_url"],
|
||||
driver_path=config["driver_path"],
|
||||
max_retries=config.get("max_retries", 180),
|
||||
retry_delay=config.get("retry_delay", 0.3),
|
||||
)
|
||||
|
||||
bot = TicketBot(tc)
|
||||
bot.run()
|
||||
|
||||
|
||||
def run_multi_account(config: dict):
|
||||
"""多账户模式"""
|
||||
from scripts.multi_account_manager import load_accounts_from_config, run_parallel
|
||||
|
||||
accounts = load_accounts_from_config(config)
|
||||
|
||||
def account_task(acc_id, acc_info):
|
||||
tc = TicketConfig(
|
||||
date=config.get("date", [1]),
|
||||
sess=config.get("sess", [1]),
|
||||
price=config.get("price", [1]),
|
||||
ticket_num=config.get("ticket_num", 2),
|
||||
viewer_person=acc_info.viewer_person or [1],
|
||||
nick_name=acc_info.username,
|
||||
damai_url=config.get("damai_url", "https://www.damai.cn/"),
|
||||
target_url=acc_info.target_url or config.get("target_url", ""),
|
||||
driver_path=config.get("driver_path", ""),
|
||||
max_retries=config.get("max_retries", 180),
|
||||
retry_delay=config.get("retry_delay", 0.3),
|
||||
)
|
||||
bot = TicketBot(tc)
|
||||
bot.run()
|
||||
|
||||
threads = run_parallel(accounts, account_task, max_workers=3)
|
||||
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
|
||||
config_path = sys.argv[1] if len(sys.argv) > 1 else "config/config.json"
|
||||
config = load_config(config_path)
|
||||
|
||||
if isinstance(config.get("accounts"), (list, dict)):
|
||||
run_multi_account(config)
|
||||
else:
|
||||
run_single_account(config)
|
||||
85
scripts/multi_account_manager.py
Normal file
85
scripts/multi_account_manager.py
Normal file
@@ -0,0 +1,85 @@
|
||||
# coding: utf-8
|
||||
"""
|
||||
多账户管理模块
|
||||
支持:并发抢票、账户轮换
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger("damai.accounts")
|
||||
|
||||
|
||||
@dataclass
|
||||
class AccountInfo:
|
||||
username: str
|
||||
password: str
|
||||
target_url: str
|
||||
auto_buy_time: str = ""
|
||||
viewer_person: List[int] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.viewer_person is None:
|
||||
self.viewer_person = [1]
|
||||
|
||||
|
||||
def load_accounts_from_config(config: dict) -> Dict[str, AccountInfo]:
|
||||
"""从配置字典加载账户列表"""
|
||||
accounts = {}
|
||||
raw = config.get("accounts", [])
|
||||
|
||||
if isinstance(raw, dict):
|
||||
items = raw.items()
|
||||
elif isinstance(raw, list):
|
||||
items = enumerate(raw)
|
||||
else:
|
||||
return accounts
|
||||
|
||||
for key, info in items:
|
||||
acc_id = str(key)
|
||||
accounts[acc_id] = AccountInfo(
|
||||
username=info.get("username", ""),
|
||||
password=info.get("password", ""),
|
||||
target_url=info.get("target_url", ""),
|
||||
auto_buy_time=info.get("auto_buy_time", ""),
|
||||
viewer_person=info.get("viewer_person", [1]),
|
||||
)
|
||||
|
||||
logger.info(f"加载了 {len(accounts)} 个账户")
|
||||
return accounts
|
||||
|
||||
|
||||
def run_parallel(
|
||||
accounts: Dict[str, AccountInfo],
|
||||
task_fn: Callable[[str, AccountInfo], None],
|
||||
max_workers: int = 3,
|
||||
) -> List[threading.Thread]:
|
||||
"""
|
||||
并行执行多个账户的抢票任务
|
||||
|
||||
task_fn: 接受 (account_id, account_info) 的回调函数
|
||||
max_workers: 最大并发数
|
||||
"""
|
||||
threads: List[threading.Thread] = []
|
||||
|
||||
for acc_id, acc_info in accounts.items():
|
||||
if len(threads) >= max_workers:
|
||||
logger.warning(f"达到最大并发数 {max_workers},跳过账户 {acc_id}")
|
||||
continue
|
||||
|
||||
def run_task(aid=acc_id, ainfo=acc_info):
|
||||
try:
|
||||
logger.info(f"账户 {aid} 任务启动")
|
||||
task_fn(aid, ainfo)
|
||||
logger.info(f"账户 {aid} 任务完成")
|
||||
except Exception as e:
|
||||
logger.error(f"账户 {aid} 任务异常: {e}")
|
||||
|
||||
t = threading.Thread(target=run_task, daemon=True, name=f"account-{acc_id}")
|
||||
t.start()
|
||||
threads.append(t)
|
||||
logger.info(f"已启动线程: account-{acc_id}")
|
||||
|
||||
return threads
|
||||
133
scripts/scheduler.py
Normal file
133
scripts/scheduler.py
Normal file
@@ -0,0 +1,133 @@
|
||||
# coding: utf-8
|
||||
"""
|
||||
定时调度模块
|
||||
支持:定时开抢、NTP 时间校准
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger("damai.scheduler")
|
||||
|
||||
|
||||
CST = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def parse_strike_time(time_str: str) -> datetime:
|
||||
"""
|
||||
解析开抢时间字符串
|
||||
支持格式:2026-01-25T12:00:00 或 2026-01-25 12:00:00
|
||||
"""
|
||||
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"):
|
||||
try:
|
||||
dt = datetime.strptime(time_str, fmt)
|
||||
return dt.replace(tzinfo=CST)
|
||||
except ValueError:
|
||||
continue
|
||||
raise ValueError(f"无法解析时间: {time_str}")
|
||||
|
||||
|
||||
def get_ntp_time() -> Optional[datetime]:
|
||||
"""从 NTP 服务器获取精确时间"""
|
||||
try:
|
||||
import ntplib
|
||||
|
||||
client = ntplib.NTPClient()
|
||||
servers = ["time.google.com", "ntp.aliyun.com", "pool.ntp.org"]
|
||||
for server in servers:
|
||||
try:
|
||||
response = client.request(server, timeout=3)
|
||||
ntp_time = datetime.fromtimestamp(response.tx_time, tz=CST)
|
||||
return ntp_time
|
||||
except Exception:
|
||||
continue
|
||||
except ImportError:
|
||||
logger.debug("ntplib 未安装,使用本地时间")
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def wait_until_strike(strike_time_str: str, preheat_stages: list = None) -> dict:
|
||||
"""
|
||||
等待到开抢时间
|
||||
preheat_stages: 预热阶段列表,如 [5.0, 2.0, 0.5] 表示提前 5s/2s/0.5s 触发预热回调
|
||||
|
||||
返回: {"strike_time": datetime, "ntp_offset": float}
|
||||
"""
|
||||
strike_time = parse_strike_time(strike_time_str)
|
||||
if preheat_stages is None:
|
||||
preheat_stages = [5.0, 2.0, 0.5]
|
||||
|
||||
# 尝试 NTP 校时
|
||||
ntp_time = get_ntp_time()
|
||||
if ntp_time:
|
||||
local_time = datetime.now(CST)
|
||||
offset = (ntp_time - local_time).total_seconds()
|
||||
logger.info(f"NTP 校时成功 | 偏差: {offset:+.3f}s")
|
||||
else:
|
||||
offset = 0.0
|
||||
logger.warning("NTP 校时失败,使用本地时间")
|
||||
|
||||
now = datetime.now(CST)
|
||||
remaining = (strike_time - now).total_seconds()
|
||||
|
||||
if remaining < 0:
|
||||
logger.warning(f"开抢时间已过 {-remaining:.1f}s,立即执行")
|
||||
return {"strike_time": strike_time, "ntp_offset": offset}
|
||||
|
||||
logger.info(
|
||||
f"距离开抢: {remaining:.1f}s | "
|
||||
f"开抢时间: {strike_time.strftime('%H:%M:%S')} | "
|
||||
f"预热阶段: {preheat_stages}"
|
||||
)
|
||||
|
||||
# 排序预热阶段(降序)
|
||||
stages = sorted(preheat_stages, reverse=True)
|
||||
|
||||
for stage_sec in stages:
|
||||
sleep_until = remaining - stage_sec
|
||||
if sleep_until <= 0:
|
||||
continue
|
||||
|
||||
logger.info(f"等待 {sleep_until:.1f}s 到预热阶段 (提前 {stage_sec}s)...")
|
||||
time.sleep(sleep_until)
|
||||
remaining = (strike_time - datetime.now(CST)).total_seconds()
|
||||
|
||||
# 最后精确等待
|
||||
while True:
|
||||
now = datetime.now(CST)
|
||||
remaining = (strike_time - now).total_seconds()
|
||||
if remaining <= 0.005: # 5ms 精度
|
||||
break
|
||||
if remaining > 0.1:
|
||||
time.sleep(remaining * 0.5)
|
||||
else:
|
||||
# 忙等待最后阶段
|
||||
pass
|
||||
|
||||
logger.info("⏰ 到达开抢时间!")
|
||||
return {"strike_time": strike_time, "ntp_offset": offset}
|
||||
|
||||
|
||||
def format_countdown(strike_time_str: str) -> str:
|
||||
"""格式化倒计时显示"""
|
||||
strike_time = parse_strike_time(strike_time_str)
|
||||
now = datetime.now(CST)
|
||||
remaining = (strike_time - now).total_seconds()
|
||||
|
||||
if remaining < 0:
|
||||
return f"已开抢 {-remaining:.0f}s"
|
||||
|
||||
hours = int(remaining // 3600)
|
||||
minutes = int((remaining % 3600) // 60)
|
||||
seconds = int(remaining % 60)
|
||||
|
||||
if hours > 0:
|
||||
return f"{hours}h {minutes}m {seconds}s"
|
||||
elif minutes > 0:
|
||||
return f"{minutes}m {seconds}s"
|
||||
else:
|
||||
return f"{seconds}s"
|
||||
93
scripts/selenium_driver.py
Normal file
93
scripts/selenium_driver.py
Normal file
@@ -0,0 +1,93 @@
|
||||
# coding: utf-8
|
||||
"""
|
||||
Selenium 驱动工厂
|
||||
提供统一的浏览器驱动创建接口
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.options import Options
|
||||
from selenium.webdriver.chrome.service import Service
|
||||
|
||||
logger = logging.getLogger("damai.driver")
|
||||
|
||||
|
||||
def create_driver(
|
||||
driver_path: str,
|
||||
mobile_emulation: bool = True,
|
||||
headless: bool = False,
|
||||
disable_images: bool = True,
|
||||
page_load_strategy: str = "eager",
|
||||
) -> webdriver.Chrome:
|
||||
"""
|
||||
创建配置好的 Chrome 驱动
|
||||
|
||||
Args:
|
||||
driver_path: chromedriver 可执行文件路径
|
||||
mobile_emulation: 是否模拟移动设备
|
||||
headless: 是否无头模式
|
||||
disable_images: 是否禁用图片加载
|
||||
page_load_strategy: 页面加载策略 (normal/eager/none)
|
||||
|
||||
Returns:
|
||||
配置好的 Chrome WebDriver 实例
|
||||
"""
|
||||
options = Options()
|
||||
|
||||
# 移动端模拟
|
||||
if mobile_emulation:
|
||||
options.add_experimental_option(
|
||||
"mobileEmulation", {"deviceName": "Nexus 6"}
|
||||
)
|
||||
|
||||
# 反检测
|
||||
options.add_argument("--disable-blink-features=AutomationControlled")
|
||||
options.add_experimental_option("excludeSwitches", ["enable-automation"])
|
||||
options.add_experimental_option("useAutomationExtension", False)
|
||||
|
||||
# 性能优化
|
||||
if disable_images:
|
||||
prefs = {
|
||||
"profile.managed_default_content_settings.images": 2,
|
||||
"profile.managed_default_content_settings.fonts": 2,
|
||||
}
|
||||
options.add_experimental_option("prefs", prefs)
|
||||
|
||||
# 页面加载策略
|
||||
options.page_load_strategy = page_load_strategy
|
||||
|
||||
# 无头模式
|
||||
if headless:
|
||||
options.add_argument("--headless=new")
|
||||
|
||||
# Linux 环境
|
||||
if sys.platform != "win32":
|
||||
options.add_argument("--no-sandbox")
|
||||
options.add_argument("--disable-dev-shm-usage")
|
||||
options.add_argument("--disable-gpu")
|
||||
|
||||
service = Service(executable_path=driver_path)
|
||||
driver = webdriver.Chrome(service=service, options=options)
|
||||
|
||||
# 隐藏 webdriver 痕迹
|
||||
driver.execute_cdp_cmd(
|
||||
"Page.addScriptToEvaluateOnNewDocument",
|
||||
{
|
||||
"source": """
|
||||
Object.defineProperty(navigator, 'webdriver', {get: () => undefined});
|
||||
window.navigator.chrome = {runtime: {}};
|
||||
Object.defineProperty(navigator, 'plugins', {
|
||||
get: () => [1, 2, 3, 4, 5],
|
||||
});
|
||||
Object.defineProperty(navigator, 'languages', {
|
||||
get: () => ['zh-CN', 'en-US', 'en'],
|
||||
});
|
||||
"""
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(f"Chrome 驱动已创建 | mobile={mobile_emulation} headless={headless}")
|
||||
return driver
|
||||
Reference in New Issue
Block a user