v1.0: 重写核心代码,清理空壳脚本

- 重写 ticket_script.py: 状态机、完整的选票/下单流程、反检测
- 重写 scripts/: 验证码处理、定时调度、NTP校时、多账户管理
- 删除空壳 appium_simulator.py
- 清理配置文件中的硬编码密码
- 重写 README,去除虚假宣传
This commit is contained in:
zhaojie
2026-04-01 15:24:34 +08:00
commit b711b98dc8
11 changed files with 1371 additions and 0 deletions

11
scripts/__init__.py Normal file
View 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
View 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
View 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)

View 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
View 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"

View 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