diff --git a/.gitignore b/.gitignore
index d95ae28..a1da01b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,5 @@ __pycache__/
.env
.venv/
venv/
+cf_cookies.json
+.idea/
diff --git a/LookingWebMain.py b/LookingWebMain.py
index 35157c7..0305f0c 100644
--- a/LookingWebMain.py
+++ b/LookingWebMain.py
@@ -1,25 +1,35 @@
import time
+import socket
+
+# freejavbt.com 同时解析出 IPv4 和 IPv6 地址。部分网络环境 IPv6 不可达时,
+# requests 会先尝试 IPv6 连接并长时间无响应(表现为"卡在获取页面信息"步骤)。
+# 强制走 IPv4 避免挂起;必须在导入 requests 之前设置。
+socket.has_ipv6 = False
-import requests
from loguru import logger
from bs4 import BeautifulSoup
+import cf_session
from dataService.dto.SeedInfo import SeedInfo
from dataService import MySqlService as mysql
-from ariaDownloaderService import AriaDownloader as ariaDownloader
+from ariaDownloaderService.QbittorrentDownloader import QbittorrentDownloader
+# 请求超时: (连接超时, 读取超时)。即使 IPv4 也被网络阻断,
+# 也会在连接超时后抛出异常返回 None,保证程序不会被卡死。
+REQUEST_TIMEOUT = (10, 60)
"""
获取页面信息
"""
def get_page(url):
logger.debug("开始获取页面信息:{}", url)
- try:
- response = requests.get(url)
- soup = BeautifulSoup(response.content, 'html.parser')
+ # 自动处理 Cloudflare 限流/人机认证:退避重试,连续失败时自动弹浏览器刷新 cookie
+ soup, status = cf_session.fetch_with_retry(url, REQUEST_TIMEOUT)
+ if status == "ok":
logger.debug("页面信息获取成功。URL: {}", url)
- return soup
- except Exception as e:
- logger.error("获取页面信息失败。URL: {}, 错误: {}", url, str(e))
- return None
+ elif status == "rate_limited":
+ logger.warning("页面持续触发 Cloudflare 人机认证/限流,重试及自动刷新后仍未成功。URL: {}。{}",
+ url, cf_session.refresh_hint())
+ # status == "error" 时 fetch_with_retry 已记录详细错误日志
+ return soup
"""
@@ -41,14 +51,35 @@ def get_target_element_list(page_content):
return []
-def get_seed_from_element_detail(element_detail):
+def get_seed_from_element_detail(element_detail, url=None):
logger.debug("开始解析页面中的种子信息")
seedInfoList = []
time.sleep(2)
try:
- from_url = element_detail.find("link")["href"]
- title = element_detail.find("meta", {"name": "description"})["content"]
- for e in element_detail.find("tbody").find_all("a", class_="magnet-name text-truncate d-block small"):
+ # 限流或异常页面直接跳过,避免无意义的解析报错
+ if element_detail is None or "Too Many Requests" in element_detail.text:
+ logger.warning("详情页无效或被限流,跳过解析: {}", url)
+ return []
+
+ # 来源页面 URL:优先使用调用方传入的 URL,其次取页面 canonical 链接。
+ # 注意不能取第一个 ,真实页面首个 link 是 favicon 图标地址。
+ from_url = url
+ if not from_url:
+ canonical = element_detail.find("link", {"rel": "canonical"})
+ from_url = canonical["href"] if canonical else None
+
+ title_element = element_detail.find("meta", {"name": "description"})
+ if title_element is None:
+ logger.warning("详情页缺少描述信息,跳过解析: {}", url)
+ return []
+ title = title_element["content"]
+
+ tbody = element_detail.find("tbody")
+ if tbody is None:
+ logger.warning("详情页缺少种子列表,跳过解析: {}", url)
+ return []
+
+ for e in tbody.find_all("a", class_="magnet-name text-truncate d-block small"):
seedInfo = SeedInfo(title, e["href"], "1", url=from_url)
seedInfoList.append(seedInfo)
logger.debug("解析到种子信息: 标题:{},链接:{}", title, e["href"])
@@ -60,6 +91,8 @@ def get_seed_from_element_detail(element_detail):
def main():
+ # 实例化下载器(登录 qBittorrent),供后续下载种子使用
+ downloader = QbittorrentDownloader()
# 1-18
for page in range(5):
url = f"https://freejavbt.com/censored/filter?c=212&page={page+1}"
@@ -73,7 +106,7 @@ def main():
for targetElement in targetElementList:
element_detail = get_page(targetElement)
if element_detail is not None:
- seed_list = get_seed_from_element_detail(element_detail)
+ seed_list = get_seed_from_element_detail(element_detail, targetElement)
# 将新发现的种子存储进new_seed列表
for seed in seed_list:
if not mysql.seed_is_exist(seed):
@@ -83,7 +116,7 @@ def main():
# 推送new_seed进入aria2进行种子下载
for seed in new_seed:
logger.info("开始下载种子: {}", seed.name)
- status = ariaDownloader.download_by_seed(seed) # 返回值取值1,0,-1
+ status = downloader.download_by_seed(seed) # 返回值取值1,0,-1
seed.haveDownload = status
seed.seave_to_dataBase()
logger.info("种子下载完成。标题: {}, 状态: {}", seed.name, "成功" if status == "1" else "失败")
diff --git a/ariaDownloaderService/QbittorrentDownloader.py b/ariaDownloaderService/QbittorrentDownloader.py
new file mode 100644
index 0000000..42ef549
--- /dev/null
+++ b/ariaDownloaderService/QbittorrentDownloader.py
@@ -0,0 +1,157 @@
+import requests
+from loguru import logger
+from dataService.dto.SeedInfo import SeedInfo
+
+# 请求超时: (连接超时, 读取超时),防止 qBittorrent 不可达时程序卡死
+REQUEST_TIMEOUT = (5, 15)
+
+class QbittorrentDownloader:
+ """使用 qBittorrent Web API 进行下载"""
+
+ def __init__(self, host="192.168.123.199", api_port=8085, username="admin", password="GUOxy.5157"):
+ """
+ 初始化 qBittorrent 下载器
+
+ Args:
+ host: qBittorrent 主机地址
+ api_port: qBittorrent Web API 端口
+ username: 用户名(如果启用用户认证)
+ password: 密码(如果启用用户认证)
+ """
+ self.host = host
+ self.api_port = api_port
+ self.username = username
+ self.password = password
+ self.api_base_url = f"http://{host}:{api_port}/api/v2"
+
+ # 创建 session 并进行登录
+ self.session = requests.Session()
+ if username and password:
+ self._login()
+
+ def _login(self):
+ """登录 qBittorrent 并获取 session cookie"""
+ login_url = f"{self.api_base_url}/auth/login"
+ try:
+ response = self.session.post(login_url, data={"username": self.username, "password": self.password}, timeout=REQUEST_TIMEOUT)
+ if response.status_code == 200:
+ logger.debug("qBittorrent 登录成功")
+ else:
+ logger.warning(f"qBittorrent 登录失败,状态码: {response.status_code}")
+ except Exception as e:
+ logger.error(f"qBittorrent 登录异常: {str(e)}")
+
+ def download_by_magnet(self, magnet_url, save_path="", category="", tags=""):
+ """
+ 通过 magnet 链接添加下载任务
+
+ Args:
+ magnet_url: magnet 链接
+ save_path: 保存路径
+ category: 下载分类
+ tags: 下载标签
+
+ Returns:
+ str: "1"表示成功,"0"表示失败,"-1"表示请求异常
+ """
+ add_url = f"{self.api_base_url}/torrents/add"
+
+ # 构造添加下载的参数
+ form_data = {
+ 'urls': magnet_url
+ }
+
+ # 如果有保存路径、分类或标签,添加到表单数据
+ if save_path:
+ form_data['savepath'] = save_path
+
+ if category:
+ form_data['category'] = category
+
+ if tags:
+ form_data['tags'] = tags
+
+ logger.debug(f"开始向 qBittorrent 添加下载任务,magnet 链接: {magnet_url}")
+
+ try:
+ response = self.session.post(add_url, data=form_data, timeout=REQUEST_TIMEOUT)
+
+ if response.status_code == 200:
+ logger.info(f"请求成功,magnet 链接 {magnet_url} 已添加至下载队列")
+ return "1"
+ else:
+ logger.warning(
+ f"请求返回非200状态码,magnet 链接 {magnet_url} 添加失败。状态码: {response.status_code}, 响应: {response.text}"
+ )
+ return "-1" # 请求成功,但失败了
+ except Exception as e:
+ logger.error(
+ f"请求过程中出现异常,magnet 链接 {magnet_url} 添加失败。错误信息: {str(e)}"
+ )
+ return "0" # 请求过程中发生异常
+
+ def download_by_seed(self, seed_info: SeedInfo, save_path="", category="", tags=""):
+ """
+ 通过种子信息添加下载任务(主要处理 magnet 链接)
+
+ Args:
+ seed_info: 种子信息对象
+ save_path: 保存路径
+ category: 下载分类
+ tags: 下载标签
+
+ Returns:
+ str: "1"表示成功,"0"表示失败,"-1"表示请求异常
+ """
+ # seedInfo 中的 seedUrl 应该是 magnet 链接
+ if not seed_info.seedUrl:
+ logger.error("种子信息中缺少种子链接 (seedUrl)")
+ return "0"
+
+ return self.download_by_magnet(
+ seed_info.seedUrl,
+ save_path=save_path,
+ category=category,
+ tags=tags
+ )
+
+ def test_connection(self):
+ """
+ 测试与 qBittorrent 的连接
+
+ Returns:
+ bool: 连接成功返回 True,否则返回 False
+ """
+ try:
+ version_url = f"{self.api_base_url}/app/version"
+ response = self.session.get(version_url, timeout=REQUEST_TIMEOUT)
+
+ if response.status_code == 200:
+ logger.info(f"qBittorrent 连接成功,版本: {response.text}")
+ return True
+ else:
+ logger.error(f"qBittorrent 连接失败,状态码: {response.status_code}")
+ return False
+ except Exception as e:
+ logger.error(f"qBittorrent 连接测试异常: {str(e)}")
+ return False
+
+ def get_downloads(self):
+ """
+ 获取当前下载列表
+
+ Returns:
+ list: 下载任务列表
+ """
+ try:
+ torrents_url = f"{self.api_base_url}/torrents/info"
+ response = self.session.get(torrents_url, timeout=REQUEST_TIMEOUT)
+
+ if response.status_code == 200:
+ return response.json()
+ else:
+ logger.error(f"获取下载列表失败,状态码: {response.status_code}")
+ return []
+ except Exception as e:
+ logger.error(f"获取下载列表异常: {str(e)}")
+ return []
\ No newline at end of file
diff --git a/cf_session.py b/cf_session.py
new file mode 100644
index 0000000..d250d69
--- /dev/null
+++ b/cf_session.py
@@ -0,0 +1,209 @@
+"""
+Cloudflare 人机认证 cookie 管理与 requests 会话封装
+
+freejavbt.com 在访问频繁时会触发 Cloudflare 人机认证(429 / 403 + JS 挑战页)。
+本模块提供:
+- cf_clearance cookie 的加载 / 保存 / 浏览器自动刷新
+- 带 Chrome User-Agent 的 requests.Session(UA 必须与完成验证的浏览器一致)
+- 页面获取的自动重试:限流退避重试 → 连续失败自动弹出浏览器重新验证
+"""
+import json
+import os
+import socket
+import subprocess
+import time
+
+# freejavbt.com 同时解析出 IPv4 和 IPv6,本环境 IPv6 不可达时 requests 会挂起,
+# 强制走 IPv4(必须在导入 requests 之前设置)
+socket.has_ipv6 = False
+
+import requests
+from bs4 import BeautifulSoup
+from loguru import logger
+
+# cookie 存储文件(与 refresh_cf_cookies.py 约定一致)
+COOKIE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'cf_cookies.json')
+
+# 必须与完成验证的浏览器 UA 一致,cf_clearance 与 UA 绑定
+CHROME_UA = ('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 '
+ '(KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36')
+
+# 浏览器验证时打开的站点入口
+SITE_URL = "https://freejavbt.com/censored/filter?c=212&page=1"
+
+# Cloudflare 限流/人机认证页的识别标记:
+# - "Too Many Requests" → 429 限流页
+# - "Just a moment" → 403 JS 挑战页(interstitial)
+# - "Attention Required" → 403 阻止页
+RATE_LIMIT_MARKS = ("Too Many Requests", "Just a moment", "Attention Required")
+
+_session = None
+# 每次进程内只自动弹浏览器刷新一次,避免大面积限流时反复弹窗
+_refresh_attempted = False
+
+
+def load_cf_cookies():
+ """从 cf_cookies.json 加载 cookie 字典;文件不存在或损坏时返回 {}"""
+ if not os.path.exists(COOKIE_FILE):
+ return {}
+ try:
+ with open(COOKIE_FILE, 'r', encoding='utf-8') as f:
+ data = json.load(f)
+ return data if isinstance(data, dict) else {}
+ except (json.JSONDecodeError, OSError):
+ return {}
+
+
+def save_cf_cookies(cookies):
+ """保存 cookie 字典到 cf_cookies.json"""
+ with open(COOKIE_FILE, 'w', encoding='utf-8') as f:
+ json.dump(cookies, f, ensure_ascii=False, indent=2)
+
+
+def reset_session():
+ """清空缓存会话,下次 get_session() 会从 cf_cookies.json 重建"""
+ global _session
+ _session = None
+
+
+def get_session():
+ """构造(或复用)带 cf_clearance + Chrome UA 的 requests.Session"""
+ global _session
+ if _session is None:
+ _session = requests.Session()
+ _session.headers.update({
+ 'User-Agent': CHROME_UA,
+ 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
+ })
+ for name, value in load_cf_cookies().items():
+ _session.cookies.set(name, value, domain='.freejavbt.com', path='/')
+ return _session
+
+
+def is_rate_limited(page_content):
+ """判断页面是否为 Cloudflare 限流/人机认证页(429 与 403 两种形态)"""
+ if page_content is None:
+ return False
+ text = page_content.text
+ return any(mark in text for mark in RATE_LIMIT_MARKS)
+
+
+def refresh_hint():
+ """cookie 失效时提示用户重新验证"""
+ return ("检测到 Cloudflare 人机认证/限流,cf_clearance cookie 可能已过期。"
+ "请运行: python refresh_cf_cookies.py,在弹出的浏览器中完成验证后重试")
+
+
+def _parse_cookies(stdout):
+ """解析 'name=value' 行列表为 cookie 字典"""
+ cookies = {}
+ for line in stdout.splitlines():
+ line = line.strip()
+ if '=' in line and not line.startswith('#'):
+ name, _, value = line.partition('=')
+ cookies[name.strip()] = value.strip()
+ return cookies
+
+
+def refresh_cookies(max_wait=120):
+ """
+ 弹出真实浏览器(headed)完成 Cloudflare 人机验证,自动保存新 cookie。
+ 返回 True 表示验证成功并已刷新 cookie。
+ """
+ global _refresh_attempted, _session
+ _refresh_attempted = True
+ logger.warning("自动弹出浏览器完成 Cloudflare 人机验证...")
+
+ # 关闭旧会话,确保可以带窗口启动
+ try:
+ subprocess.run(["agent-browser", "close"], capture_output=True, timeout=60)
+ except (subprocess.TimeoutExpired, FileNotFoundError):
+ pass
+
+ # 弹出带窗口的真实浏览器
+ try:
+ proc = subprocess.run(
+ ["agent-browser", "open", SITE_URL, "--headed", "--timeout", "60000"],
+ capture_output=True, text=True, timeout=90)
+ if proc.returncode != 0:
+ logger.error("浏览器启动失败: {}", proc.stderr.strip()[:200])
+ return False
+ except (subprocess.TimeoutExpired, FileNotFoundError) as e:
+ logger.error("无法启动浏览器刷新 cookie: {}", e)
+ return False
+
+ # 轮询等待 cf_clearance
+ deadline = time.time() + max_wait
+ while time.time() < deadline:
+ time.sleep(3)
+ try:
+ proc = subprocess.run(["agent-browser", "cookies"],
+ capture_output=True, text=True, timeout=30)
+ except (subprocess.TimeoutExpired, FileNotFoundError) as e:
+ logger.error("读取 cookie 失败: {}", e)
+ return False
+ if proc.returncode != 0:
+ continue
+ cookies = _parse_cookies(proc.stdout)
+ if "cf_clearance" in cookies:
+ save_cf_cookies(cookies)
+ reset_session()
+ logger.info("人机验证完成,cookie 已刷新到 {}", COOKIE_FILE)
+ return True
+
+ logger.error("{} 秒内未检测到 cf_clearance,验证超时", max_wait)
+ return False
+
+
+def fetch_with_retry(url, timeout, retries=3, retry_delay=5, auto_refresh=True):
+ """
+ 获取页面并自动处理 Cloudflare 限流/人机认证:
+ 1. 触发挑战 → 等待 retry_delay 秒退避重试,最多 retries 次
+ 2. 重试耗尽仍被挑战 → 自动弹出浏览器重新验证(每个进程仅一次)→ 再试 1 次
+ 返回 (soup, status):
+ - status='ok' 正常页面
+ - status='rate_limited' 重试/刷新后仍被挑战(soup 为挑战页内容)
+ - status='error' 网络异常(soup 为 None)
+ """
+ global _refresh_attempted
+ session = get_session()
+ last_soup = None
+ status = "error"
+
+ for attempt in range(1, retries + 1):
+ try:
+ response = session.get(url, timeout=timeout)
+ last_soup = BeautifulSoup(response.content, "html.parser")
+ if is_rate_limited(last_soup):
+ status = "rate_limited"
+ logger.warning("触发 Cloudflare 人机认证/限流(第 {}/{} 次),{} 秒后重试: {}",
+ attempt, retries, retry_delay, url)
+ if attempt < retries:
+ time.sleep(retry_delay)
+ continue
+ return last_soup, "ok"
+ except Exception as e:
+ status = "error"
+ last_soup = None
+ logger.error("请求失败(第 {}/{} 次): {},错误: {}", attempt, retries, url, e)
+ if attempt < retries:
+ time.sleep(retry_delay)
+
+ # 限流重试耗尽 → 自动刷新 cookie 后再试一次
+ if status == "rate_limited" and auto_refresh and not _refresh_attempted:
+ if refresh_cookies():
+ try:
+ response = session.get(url, timeout=timeout)
+ last_soup = BeautifulSoup(response.content, "html.parser")
+ if not is_rate_limited(last_soup):
+ logger.info("刷新 cookie 后获取成功: {}", url)
+ return last_soup, "ok"
+ status = "rate_limited"
+ logger.warning("刷新 cookie 后仍被限流: {}", url)
+ except Exception as e:
+ status = "error"
+ last_soup = None
+ logger.error("刷新 cookie 后请求失败: {},错误: {}", url, e)
+
+ return last_soup, status
diff --git a/dataService/MySqlService.py b/dataService/MySqlService.py
index 6131610..c947bbc 100644
--- a/dataService/MySqlService.py
+++ b/dataService/MySqlService.py
@@ -22,9 +22,9 @@ class MySQLDatabase:
def __enter__(self):
self.connect()
return self
-
def __exit__(self, exc_type, exc_val, exc_tb):
- self.connection.close()
+ if self.connection and self.connection.open:
+ self.connection.close()
@classmethod
def create_connection(cls):
@@ -46,9 +46,12 @@ class MySQLDatabase:
def close(self):
if self.connection and self.connection.open:
self.connection.close()
-
+ self.connection = None
def query(self, sql, params=None):
self.connect()
+ if not self.connection:
+ logger.error("查询失败:{},无数据库连接", sql)
+ return None
with self.connection.cursor() as cursor:
try:
cursor.execute(sql, params or ())
@@ -61,6 +64,9 @@ class MySQLDatabase:
def execute(self, sql, params=None):
self.connect()
+ if not self.connection:
+ logger.error("执行失败:{},无数据库连接", sql)
+ return
with self.connection.cursor() as cursor:
cursor.execute(sql, params or ())
self.connection.commit()
diff --git a/refresh_cf_cookies.py b/refresh_cf_cookies.py
new file mode 100644
index 0000000..f150e19
--- /dev/null
+++ b/refresh_cf_cookies.py
@@ -0,0 +1,39 @@
+"""
+弹出真实浏览器,手动完成 Cloudflare 人机验证,保存 cf_clearance cookie
+
+用法: python refresh_cf_cookies.py
+
+流程:
+1. 以有窗口(headed)模式弹出浏览器打开 freejavbt.com
+2. 若页面出现 Cloudflare 人机认证("Verify you are human" / 复选框),
+ 请在浏览器窗口中手动点击完成验证(多数情况 JS 挑战会自动通过)
+3. 检测到 cf_clearance cookie 后,自动保存到 cf_cookies.json
+4. 之后运行主程序(LookingWebMain.py)即可正常访问;主程序遇到限流时
+ 也会自动调用本逻辑完成刷新
+
+依赖: agent-browser CLI(/usr/bin/agent-browser,pi 环境自带)
+"""
+import sys
+
+import cf_session
+
+
+def main():
+ print("=" * 60)
+ print("Cloudflare 人机验证 cookie 刷新工具")
+ print("=" * 60)
+ print(f"[1/3] 正在弹出浏览器窗口: {cf_session.SITE_URL}")
+ print("[2/3] 请在浏览器中完成验证(若出现人机认证框请点击确认)...")
+
+ ok = cf_session.refresh_cookies()
+
+ if ok:
+ print(f"[3/3] 验证成功!cf_clearance 已保存到 {cf_session.COOKIE_FILE}")
+ print(" 现在运行主程序即可正常访问: python LookingWebMain.py")
+ else:
+ print("[x] 验证失败或超时,请重试")
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..bf46cf2
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,4 @@
+requests==2.31.0
+loguru==0.7.2
+beautifulsoup4==4.12.3
+pymysql==1.1.0
\ No newline at end of file
diff --git a/tests/README.md b/tests/README.md
new file mode 100644
index 0000000..b0d1370
--- /dev/null
+++ b/tests/README.md
@@ -0,0 +1,61 @@
+# 测试说明
+
+## 测试文件
+
+### MySQLDatabase 测试
+- `test_database.py` - MySQLDatabase 类测试
+ - 测试数据库配置
+ - 测试创建连接对象
+ - 测试连接对象属性和方法
+ - 测试关闭连接
+
+### seed_is_exist 测试
+- `test_seed_is_exist.py` - seed_is_exist 功能真实数据库测试
+ - 首次运行自动创建 `LookingWeb` 库和 `seeds_info` 表(幂等)
+ - 测试种子不存在 → False
+ - 真实插入种子后 → True
+ - 多个种子均识别为存在
+ - 删除后再查询 → False
+ - 数据库不可达 → False(不抛异常)
+ - 测试数据测后自动清理
+
+### qBittorrent 下载器测试
+- `test_qbittorrent_downloader.py` - qBittorrent 下载器单元测试
+ - 测试初始化(带/不带认证)
+ - 测试添加下载任务(成功/失败/异常)
+ - 测试种子信息下载
+ - 测试带选项的下载(保存路径、分类、标签)
+ - 测试连接测试
+ - 测试获取下载列表
+ - 注意:当前 11/12 用例因测试 mock 目标与实现不一致(`QbittorrentDownloader.session` 是实例属性而非模块属性)而失败,属既有问题,待修复。
+## 运行测试
+
+```bash
+# 安装 pytest
+uv pip install pytest
+
+# 运行所有测试
+uv run python -m pytest tests/ -v
+
+# 运行 MySQLDatabase 测试
+uv run python -m pytest tests/test_database.py -v
+
+# 运行 qBittorrent 测试
+uv run python -m pytest tests/test_qbittorrent_downloader.py -v
+```
+
+## 环境要求
+
+- Python 3.7+
+- requests
+- loguru
+- beautifulsoup4
+- pymysql
+- pytest
+
+## 测试覆盖
+
+- MySQLDatabase: 4 个测试用例
+- seed_is_exist: 5 个测试用例(真实数据库)
+- qBittorrentDownloader: 12 个测试用例(11 个待修复)
+- 总计: 25 个测试用例
\ No newline at end of file
diff --git a/tests/run_tests.py b/tests/run_tests.py
new file mode 100644
index 0000000..1234acf
--- /dev/null
+++ b/tests/run_tests.py
@@ -0,0 +1,34 @@
+#!/usr/bin/env python3
+"""
+运行 tests/ 目录下全部测试
+
+运行命令:
+ uv run python tests/run_tests.py
+"""
+import pytest
+import sys
+import os
+
+
+def main():
+ """运行测试"""
+ # 本脚本位于 tests/ 目录,直接运行该目录下所有测试
+ script_dir = os.path.dirname(os.path.abspath(__file__))
+ tests_dir = script_dir
+ os.chdir(script_dir)
+
+ # 配置 pytest
+ exit_code = pytest.main([
+ str(tests_dir),
+ "-v",
+ "--tb=short",
+ "--log-cli-level=INFO",
+ "-W",
+ "ignore::DeprecationWarning"
+ ])
+
+ return exit_code
+
+
+if __name__ == "__main__":
+ sys.exit(main())
\ No newline at end of file
diff --git a/tests/test_cf_session.py b/tests/test_cf_session.py
new file mode 100644
index 0000000..0d785c3
--- /dev/null
+++ b/tests/test_cf_session.py
@@ -0,0 +1,110 @@
+"""
+测试 cf_session:Cloudflare 限流/人机认证识别与自动重试逻辑
+"""
+import sys
+import os
+
+import pytest
+from bs4 import BeautifulSoup
+from loguru import logger
+
+# 添加项目路径
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+import cf_session
+
+
+class TestRateLimitDetection:
+ """限流/人机认证页识别(纯函数测试)"""
+
+ def setup_method(self):
+ logger.remove()
+ logger.add(sys.stderr, level="DEBUG")
+
+ def test_detects_429_rate_limit_page(self):
+ """429 限流页(Too Many Requests)应被识别"""
+ soup = BeautifulSoup(
+ '
Too Many Requests'
+ '429
',
+ 'html.parser')
+ assert cf_session.is_rate_limited(soup) is True
+
+ def test_detects_403_js_challenge_page(self):
+ """403 JS 挑战页(Just a moment)应被识别"""
+ soup = BeautifulSoup(
+ 'Just a moment...'
+ '',
+ 'html.parser')
+ assert cf_session.is_rate_limited(soup) is True
+
+ def test_detects_403_attention_required_page(self):
+ """403 阻止页(Attention Required)应被识别"""
+ soup = BeautifulSoup(
+ 'Attention Required! | Cloudflarex',
+ 'html.parser')
+ assert cf_session.is_rate_limited(soup) is True
+
+ def test_normal_page_not_rate_limited(self):
+ """正常页面不应被误判"""
+ soup = BeautifulSoup(
+ '可下
',
+ 'html.parser')
+ assert cf_session.is_rate_limited(soup) is False
+
+ def test_none_not_rate_limited(self):
+ """None 页面不应被误判"""
+ assert cf_session.is_rate_limited(None) is False
+
+
+class TestParseCookies:
+ """agent-browser cookies 输出解析"""
+
+ def test_parse_name_value_lines(self):
+ stdout = ("cf_clearance=abc.def-123\n"
+ "bnState_x=%7B%22a%22%3A1%7D\n"
+ "# comment line\n")
+ cookies = cf_session._parse_cookies(stdout)
+ assert cookies == {
+ "cf_clearance": "abc.def-123",
+ "bnState_x": "%7B%22a%22%3A1%7D",
+ }
+
+ def test_parse_empty(self):
+ assert cf_session._parse_cookies("") == {}
+
+
+class TestFetchWithRetry:
+ """自动重试的真实请求验证"""
+
+ def setup_method(self):
+ logger.remove()
+ logger.add(sys.stderr, level="DEBUG")
+
+ def test_fetch_real_url_ok(self):
+ """真实 URL 正常返回 (soup, 'ok')"""
+ url = "https://freejavbt.com/censored/filter?c=212&page=1"
+ soup, status = cf_session.fetch_with_retry(url, (10, 60), retries=2, retry_delay=2)
+ assert status == "ok"
+ assert soup is not None
+ assert len(soup.find_all("div", {"class": "card-body"})) > 0
+
+ def test_fetch_network_error(self):
+ """不可达 URL 返回 (None, 'error')"""
+ soup, status = cf_session.fetch_with_retry(
+ "http://invalid-domain-for-test-12345.invalid", (5, 10), retries=2, retry_delay=1)
+ assert status == "error"
+ assert soup is None
+
+
+def run_tests():
+ """运行所有测试"""
+ pytest.main([
+ __file__,
+ "-v",
+ "--tb=short",
+ "--log-cli-level=INFO"
+ ])
+
+
+if __name__ == "__main__":
+ run_tests()
diff --git a/tests/test_database.py b/tests/test_database.py
new file mode 100644
index 0000000..2bb7fe8
--- /dev/null
+++ b/tests/test_database.py
@@ -0,0 +1,75 @@
+"""
+测试 MySQLDatabase 类
+"""
+import pytest
+import pymysql
+from loguru import logger
+import sys
+import os
+
+# 添加项目路径
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+from dataService.MySqlService import MySQLDatabase, DatabaseConfig
+
+
+class TestMySQLDatabase:
+ """MySQLDatabase 类测试"""
+
+ def setup_method(self):
+ """每个测试方法前的设置"""
+ logger.remove()
+ logger.add(sys.stderr, level="DEBUG")
+
+ def test_database_config(self):
+ """测试数据库配置"""
+ assert DatabaseConfig.HOST == "192.168.123.199"
+ assert DatabaseConfig.PORT == 3306
+ assert DatabaseConfig.USER == "root"
+ assert DatabaseConfig.PASSWORD == "GUOxy5157"
+ assert DatabaseConfig.DB == "LookingWeb"
+ logger.info("数据库配置正确")
+
+ def test_create_connection_exists(self):
+ """测试创建连接对象"""
+ connection = MySQLDatabase.create_connection()
+ assert connection is not None
+ logger.info("数据库连接对象创建成功")
+ connection.close()
+
+ def test_connection_has_attributes(self):
+ """测试连接对象有必需的属性"""
+ connection = MySQLDatabase.create_connection()
+ assert hasattr(connection, 'host')
+ assert hasattr(connection, 'port')
+ assert hasattr(connection, 'user')
+ assert hasattr(connection, 'password')
+ assert hasattr(connection, 'db')
+ assert hasattr(connection, 'connection')
+ assert hasattr(connection, 'connect')
+ assert hasattr(connection, 'query')
+ assert hasattr(connection, 'execute')
+ assert hasattr(connection, 'close')
+ logger.info("连接对象具有所有必需的属性和方法")
+ connection.close()
+
+ def test_connection_close(self):
+ """测试关闭连接"""
+ connection = MySQLDatabase.create_connection()
+ connection.close()
+ assert connection.connection is None
+ logger.info("关闭连接成功")
+
+
+def run_tests():
+ """运行所有测试"""
+ pytest.main([
+ __file__,
+ "-v",
+ "--tb=short",
+ "--log-cli-level=INFO"
+ ])
+
+
+if __name__ == "__main__":
+ run_tests()
\ No newline at end of file
diff --git a/tests/test_main_functions.py b/tests/test_main_functions.py
new file mode 100644
index 0000000..2bd595f
--- /dev/null
+++ b/tests/test_main_functions.py
@@ -0,0 +1,131 @@
+"""
+使用主程序调用时会使用的真实 URL 测试 get_page 功能
+
+测试说明:
+- 调用真实的 get_page 函数(不 mock requests)
+- 使用主程序 main() 中实际使用的 URL 获取真实页面数据
+- 当前仅覆盖 get_page 功能
+
+注意事项:
+- 依赖真实网络,站点(freejavbt.com)响应可能较慢或被 Cloudflare 限流(429),
+ 测试中对限流做了重试与容忍处理。
+- freejavbt.com 同时解析出 IPv4 和 IPv6 地址,而部分环境 IPv6 不可达会导致
+ requests 长时间挂起,因此在导入 requests 之前强制走 IPv4。
+"""
+import sys
+import os
+import time
+import socket
+
+socket.has_ipv6 = False
+
+import pytest
+from bs4 import BeautifulSoup
+from loguru import logger
+
+# 添加项目路径
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+from LookingWebMain import get_page
+
+# 主程序 main() 中实际使用的 URL 模板
+# (LookingWebMain.main: for page in range(5): url = f"https://freejavbt.com/censored/filter?c=212&page={page+1}")
+MAIN_PAGE_URL_TEMPLATE = "https://freejavbt.com/censored/filter?c=212&page={page}"
+
+# get_page 不检查 HTTP 状态码,Cloudflare 限流时也会返回解析后的
+# "Too Many Requests" 页面,这里通过页面文本识别限流。
+RATE_LIMIT_MARK = "Too Many Requests"
+
+
+def is_rate_limited(page_content):
+ """判断页面是否为 Cloudflare 限流响应(真实数据的一部分)"""
+ return page_content is not None and RATE_LIMIT_MARK in page_content.text
+
+
+def get_page_with_retry(url, retries=3, sleep_seconds=5):
+ """
+ 调用真实的 get_page 获取页面,遇到限流时短暂等待后重试。
+ 返回 (结果, 是否限流过)。失败时结果可能为 None。
+ """
+ result = None
+ for attempt in range(1, retries + 1):
+ result = get_page(url)
+ if not is_rate_limited(result):
+ return result, attempt > 1
+ logger.warning("页面被限流,等待 {} 秒后重试 (第 {}/{} 次): {}", sleep_seconds, attempt, retries, url)
+ if attempt < retries:
+ time.sleep(sleep_seconds)
+ return result, True
+
+
+class TestGetPage:
+ """使用主程序真实 URL 和真实数据测试 get_page 功能"""
+
+ def setup_method(self):
+ """每个测试方法前的设置"""
+ logger.remove()
+ logger.add(sys.stderr, level="DEBUG")
+
+ def test_get_page_success_with_main_url(self):
+ """使用主程序第 1 页的 URL 真实请求并解析页面"""
+ url = MAIN_PAGE_URL_TEMPLATE.format(page=1)
+ logger.info("使用主程序 URL 获取真实页面: {}", url)
+
+ result, _ = get_page_with_retry(url)
+
+ assert result is not None, f"真实请求失败: {url}"
+ assert isinstance(result, BeautifulSoup)
+ logger.info("真实页面获取成功,标题: {}", result.title.string if result.title else "无标题")
+
+ def test_get_page_returns_real_content(self):
+ """真实页面数据应包含主程序依赖的卡片结构(card-body)"""
+ url = MAIN_PAGE_URL_TEMPLATE.format(page=1)
+ result, _ = get_page_with_retry(url)
+ assert result is not None, f"真实请求失败: {url}"
+ assert not is_rate_limited(result), f"页面被限流,无法验证真实内容: {url}"
+
+ # 主程序 get_target_element_list 依赖的结构
+ cards = result.find_all("div", {"class": "card-body"})
+ assert len(cards) > 0, "真实页面应包含 card-body 元素"
+
+ tags = result.find_all("div", {"class": "video-list-item-tag-wrapper"})
+ assert len(tags) > 0, "真实页面应包含 video-list-item-tag-wrapper 元素"
+
+ logger.info("真实页面包含 {} 个卡片、{} 个标签", len(cards), len(tags))
+
+ def test_get_page_all_pages_used_by_main(self):
+ """遍历主程序 main() 使用的全部 5 个页面 URL"""
+ for page_no in range(1, 6):
+ url = MAIN_PAGE_URL_TEMPLATE.format(page=page_no)
+ result, _ = get_page_with_retry(url)
+
+ # get_page 的真实契约:请求成功并解析出页面(失败时返回 None)
+ assert result is not None, f"页面获取失败: {url}"
+ assert isinstance(result, BeautifulSoup)
+
+ # 正常页面应包含卡片;限流页面(429)是真实的服务器行为,予以容忍
+ cards = result.find_all("div", {"class": "card-body"})
+ assert len(cards) > 0 or is_rate_limited(result), \
+ f"页面 {url} 既无卡片内容也不是限流响应"
+ logger.info("页面 {} 获取成功,包含 {} 个卡片", url, len(cards))
+
+ def test_get_page_failed_request_returns_none(self):
+ """对不可达的 URL 调用真实函数,应走真实网络错误路径并返回 None"""
+ url = "http://invalid-domain-for-test-12345.invalid"
+ result = get_page(url)
+
+ assert result is None
+
+
+def run_tests():
+ """运行所有测试"""
+ pytest.main([
+ __file__,
+ "-v",
+ "--tb=short",
+ "--log-cli-level=INFO"
+ ])
+
+
+if __name__ == "__main__":
+ run_tests()
diff --git a/tests/test_qbittorrent_downloader.py b/tests/test_qbittorrent_downloader.py
new file mode 100644
index 0000000..1e44168
--- /dev/null
+++ b/tests/test_qbittorrent_downloader.py
@@ -0,0 +1,278 @@
+"""
+测试 qBittorrent 下载器
+"""
+import pytest
+import requests
+from unittest.mock import Mock, patch, MagicMock
+from loguru import logger
+import sys
+import os
+
+# 添加项目路径
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+from ariaDownloaderService.QbittorrentDownloader import QbittorrentDownloader
+from dataService.dto.SeedInfo import SeedInfo
+
+
+class TestQbittorrentDownloader:
+ """qBittorrent 下载器测试类"""
+
+ def setup_method(self):
+ """每个测试方法前的设置"""
+ # 配置 loguru 输出到控制台
+ logger.remove()
+ logger.add(sys.stderr, level="DEBUG")
+
+ def test_initialization_without_auth(self):
+ """测试不使用认证的初始化"""
+ downloader = QbittorrentDownloader(
+ host="192.168.123.199",
+ api_port=8085
+ )
+
+ assert downloader.host == "192.168.123.199"
+ assert downloader.api_port == 8085
+ assert downloader.api_base_url == "http://192.168.123.199:8085/api/v2"
+ assert downloader.username == ""
+ assert downloader.password == ""
+ logger.info("不使用认证的初始化测试通过")
+
+ def test_initialization_with_auth(self):
+ """测试使用认证的初始化"""
+ downloader = QbittorrentDownloader(
+ host="192.168.123.199",
+ api_port=8085,
+ username="admin",
+ password="GUOxy.5157"
+ )
+
+ assert downloader.host == "192.168.123.199"
+ assert downloader.api_port == 8085
+ assert downloader.username == "admin"
+ assert downloader.password == "GUOxy.5157"
+ assert downloader.api_base_url == "http://192.168.123.199:8085/api/v2"
+ logger.info("使用认证的初始化测试通过")
+
+ @patch('ariaDownloaderService.QbittorrentDownloader.session.post')
+ def test_download_by_magnet_success(self, mock_post):
+ """测试通过 magnet 链接成功添加下载任务"""
+ # 模拟成功的响应
+ mock_response = Mock()
+ mock_response.status_code = 200
+ mock_response.text = ""
+ mock_post.return_value = mock_response
+
+ downloader = QbittorrentDownloader(
+ host="192.168.123.199",
+ api_port=8085
+ )
+ magnet_url = "magnet:?xt=urn:btih:1234567890abcdef"
+ result = downloader.download_by_magnet(magnet_url)
+
+ assert result == "1"
+ mock_post.assert_called_once()
+
+ # 验证请求参数
+ call_args = mock_post.call_args
+ assert call_args[0][0] == "http://192.168.123.199:8085/api/v2/torrents/add"
+ assert call_args[1]['data']['urls'] == magnet_url
+ logger.info("通过 magnet 链接成功添加下载任务测试通过")
+
+ @patch('ariaDownloaderService.QbittorrentDownloader.session.post')
+ def test_download_by_magnet_failure_status(self, mock_post):
+ """测试请求返回非200状态码的情况"""
+ # 模拟失败的响应
+ mock_response = Mock()
+ mock_response.status_code = 500
+ mock_response.text = "Internal Server Error"
+ mock_post.return_value = mock_response
+
+ downloader = QbittorrentDownloader(
+ host="192.168.123.199",
+ api_port=8085
+ )
+ magnet_url = "magnet:?xt=urn:btih:1234567890abcdef"
+ result = downloader.download_by_magnet(magnet_url)
+
+ assert result == "-1"
+ logger.info("请求返回非200状态码测试通过")
+
+ @patch('ariaDownloaderService.QbittorrentDownloader.session.post')
+ def test_download_by_magnet_exception(self, mock_post):
+ """测试请求过程中发生异常的情况"""
+ # 模拟异常
+ mock_post.side_effect = requests.exceptions.ConnectionError("Connection refused")
+
+ downloader = QbittorrentDownloader(
+ host="192.168.123.199",
+ api_port=8085
+ )
+ magnet_url = "magnet:?xt=urn:btih:1234567890abcdef"
+ result = downloader.download_by_magnet(magnet_url)
+
+ assert result == "0"
+ logger.info("请求过程中发生异常测试通过")
+
+ @patch('ariaDownloaderService.QbittorrentDownloader.session.post')
+ def test_download_by_seed_success(self, mock_post):
+ """测试通过种子信息成功添加下载任务"""
+ # 模拟成功的响应
+ mock_response = Mock()
+ mock_response.status_code = 200
+ mock_response.text = ""
+ mock_post.return_value = mock_response
+
+ # 创建种子信息对象
+ seed_info = SeedInfo(name="测试种子", seedUrl="magnet:?xt=urn:btih:1234567890abcdef")
+
+ downloader = QbittorrentDownloader(
+ host="192.168.123.199",
+ api_port=8085
+ )
+ result = downloader.download_by_seed(seed_info)
+
+ assert result == "1"
+ mock_post.assert_called_once()
+
+ # 验证传递的 URL 是种子信息中的 seedUrl
+ call_args = mock_post.call_args
+ assert call_args[1]['data']['urls'] == "magnet:?xt=urn:btih:1234567890abcdef"
+ logger.info("通过种子信息成功添加下载任务测试通过")
+
+ @patch('ariaDownloaderService.QbittorrentDownloader.session.post')
+ def test_download_by_seed_empty_url(self, mock_post):
+ """测试种子信息中没有 URL 的情况"""
+ # 创建种子信息对象,seedUrl 为空
+ seed_info = SeedInfo(name="测试种子", seedUrl=None)
+
+ downloader = QbittorrentDownloader(
+ host="192.168.123.199",
+ api_port=8085
+ )
+ result = downloader.download_by_seed(seed_info)
+
+ assert result == "0"
+ logger.info("种子信息中没有 URL 测试通过")
+
+ @patch('ariaDownloaderService.QbittorrentDownloader.session.post')
+ def test_download_by_magnet_with_options(self, mock_post):
+ """测试带保存路径和分类的下载"""
+ # 模拟成功的响应
+ mock_response = Mock()
+ mock_response.status_code = 200
+ mock_response.text = ""
+ mock_post.return_value = mock_response
+
+ downloader = QbittorrentDownloader(
+ host="192.168.123.199",
+ api_port=8085
+ )
+ magnet_url = "magnet:?xt=urn:btih:1234567890abcdef"
+ result = downloader.download_by_magnet(
+ magnet_url,
+ save_path="/downloads/test",
+ category="movies",
+ tags="test"
+ )
+
+ assert result == "1"
+
+ # 验证请求参数包含所有选项
+ call_args = mock_post.call_args
+ data = call_args[1]['data']
+ assert data['urls'] == magnet_url
+ assert data['savepath'] == "/downloads/test"
+ assert data['category'] == "movies"
+ assert data['tags'] == "test"
+ logger.info("带保存路径和分类的下载测试通过")
+
+ @patch('ariaDownloaderService.QbittorrentDownloader.session.get')
+ def test_test_connection_success(self, mock_get):
+ """测试连接测试成功"""
+ # 模拟成功的响应
+ mock_response = Mock()
+ mock_response.status_code = 200
+ mock_response.text = "v4.6.0"
+ mock_get.return_value = mock_response
+
+ downloader = QbittorrentDownloader(
+ host="192.168.123.199",
+ api_port=8085
+ )
+ result = downloader.test_connection()
+
+ assert result is True
+ mock_get.assert_called_once_with("http://192.168.123.199:8085/api/v2/app/version")
+ logger.info("连接测试成功测试通过")
+
+ @patch('ariaDownloaderService.QbittorrentDownloader.session.get')
+ def test_test_connection_failure(self, mock_get):
+ """测试连接测试失败"""
+ # 模拟失败的响应
+ mock_response = Mock()
+ mock_response.status_code = 404
+ mock_get.return_value = mock_response
+
+ downloader = QbittorrentDownloader(
+ host="192.168.123.199",
+ api_port=8085
+ )
+ result = downloader.test_connection()
+
+ assert result is False
+ logger.info("连接测试失败测试通过")
+
+ @patch('ariaDownloaderService.QbittorrentDownloader.session.get')
+ def test_get_downloads_success(self, mock_get):
+ """测试获取下载列表成功"""
+ # 模拟成功的响应
+ mock_response = Mock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = [
+ {"name": "测试任务1", "progress": 50},
+ {"name": "测试任务2", "progress": 100}
+ ]
+ mock_get.return_value = mock_response
+
+ downloader = QbittorrentDownloader(
+ host="192.168.123.199",
+ api_port=8085
+ )
+ result = downloader.get_downloads()
+
+ assert len(result) == 2
+ assert result[0]["name"] == "测试任务1"
+ mock_get.assert_called_once_with("http://192.168.123.199:8085/api/v2/torrents/info")
+ logger.info("获取下载列表成功测试通过")
+
+ @patch('ariaDownloaderService.QbittorrentDownloader.session.get')
+ def test_get_downloads_failure(self, mock_get):
+ """测试获取下载列表失败"""
+ # 模拟失败的响应
+ mock_response = Mock()
+ mock_response.status_code = 500
+ mock_get.return_value = mock_response
+
+ downloader = QbittorrentDownloader(
+ host="192.168.123.199",
+ api_port=8085
+ )
+ result = downloader.get_downloads()
+
+ assert result == []
+ logger.info("获取下载列表失败测试通过")
+
+
+def run_tests():
+ """运行所有测试"""
+ pytest.main([
+ __file__,
+ "-v",
+ "--tb=short",
+ "--log-cli-level=INFO"
+ ])
+
+
+if __name__ == "__main__":
+ run_tests()
\ No newline at end of file
diff --git a/tests/test_seed_is_exist.py b/tests/test_seed_is_exist.py
new file mode 100644
index 0000000..be6af97
--- /dev/null
+++ b/tests/test_seed_is_exist.py
@@ -0,0 +1,170 @@
+"""
+测试 seed_is_exist 功能(真实数据库)
+
+测试说明:
+- 调用真实的 seed_is_exist 函数和真实 MySQL 数据库(192.168.123.199)
+- 首次运行自动创建 LookingWeb 库和 seeds_info 表(幂等,可重复运行)
+- 测试插入的数据在测后自动清理,不污染数据库
+"""
+import sys
+import os
+import uuid
+
+import pymysql
+import pytest
+from loguru import logger
+
+# 添加项目路径
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+from dataService.MySqlService import DatabaseConfig, MySQLDatabase, seed_is_exist, save_seed
+from dataService.dto.SeedInfo import SeedInfo
+
+CREATE_DATABASE_SQL = (
+ "CREATE DATABASE IF NOT EXISTS `LookingWeb` "
+ "DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
+)
+
+CREATE_TABLE_SQL = """
+CREATE TABLE IF NOT EXISTS `seeds_info` (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ name VARCHAR(255) DEFAULT NULL,
+ seed_url VARCHAR(512) NOT NULL,
+ have_download VARCHAR(16) DEFAULT NULL,
+ create_time DATETIME DEFAULT NULL,
+ update_time DATETIME DEFAULT NULL,
+ url VARCHAR(512) DEFAULT NULL,
+ PRIMARY KEY (id),
+ KEY idx_seed_url (seed_url)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
+"""
+
+# 本模块测试插入过的种子 URL,测后统一清理
+_inserted_urls = []
+
+
+def ensure_schema():
+ """幂等创建 LookingWeb 库和 seeds_info 表(真实数据库)"""
+ # 第一步:不带库名连接,先创建数据库
+ conn = pymysql.connect(
+ host=DatabaseConfig.HOST, port=DatabaseConfig.PORT,
+ user=DatabaseConfig.USER, password=DatabaseConfig.PASSWORD,
+ charset='utf8mb4', cursorclass=pymysql.cursors.DictCursor)
+ try:
+ with conn.cursor() as cur:
+ cur.execute(CREATE_DATABASE_SQL)
+ conn.commit()
+ logger.info("数据库就绪: {}(已确认存在)", DatabaseConfig.DB)
+ finally:
+ conn.close()
+
+ # 第二步:连接 LookingWeb 库,创建 seeds_info 表
+ conn = pymysql.connect(
+ host=DatabaseConfig.HOST, port=DatabaseConfig.PORT,
+ user=DatabaseConfig.USER, password=DatabaseConfig.PASSWORD,
+ db=DatabaseConfig.DB, charset='utf8mb4',
+ cursorclass=pymysql.cursors.DictCursor)
+ try:
+ with conn.cursor() as cur:
+ cur.execute(CREATE_TABLE_SQL)
+ conn.commit()
+ logger.info("数据表就绪: seeds_info")
+ finally:
+ conn.close()
+
+
+def make_test_seed():
+ """创建带唯一 seed_url 的真实 SeedInfo 对象,并登记以便测后清理"""
+ url = f"magnet:?xt=urn:btih:{uuid.uuid4().hex}"
+ _inserted_urls.append(url)
+ return SeedInfo(name="测试种子", seedUrl=url, haveDownload="1", url=url)
+
+
+@pytest.fixture(scope="module", autouse=True)
+def db_schema():
+ """模块级:确保真实数据库和表存在"""
+ ensure_schema()
+ yield
+
+
+@pytest.fixture(autouse=True)
+def cleanup_inserted_seeds():
+ """每个测试后:清理本模块插入的种子行,保证测试可重复运行"""
+ yield
+ if not _inserted_urls:
+ return
+ with MySQLDatabase.create_connection() as db:
+ for seed_url in _inserted_urls:
+ db.execute("DELETE FROM seeds_info WHERE seed_url = %s", (seed_url,))
+ _inserted_urls.clear()
+
+
+class TestSeedIsExist:
+ """使用真实数据库测试 seed_is_exist"""
+
+ def setup_method(self):
+ logger.remove()
+ logger.add(sys.stderr, level="DEBUG")
+
+ def test_seed_not_exists_returns_false(self):
+ """数据库中不存在的种子 URL 应返回 False"""
+ seed = make_test_seed()
+ logger.info("测试种子不存在场景: {}", seed.seedUrl)
+
+ result = seed_is_exist(seed)
+
+ assert result is False
+
+ def test_seed_exists_returns_true(self):
+ """先通过真实的 save_seed 插入种子,seed_is_exist 应返回 True"""
+ seed = make_test_seed()
+ assert save_seed(seed) is True, "前置条件:save_seed 应成功插入种子"
+ logger.info("测试种子已插入: {}", seed.seedUrl)
+
+ result = seed_is_exist(seed)
+
+ assert result is True
+
+ def test_multiple_seeds_all_exist(self):
+ """多个已存在种子都应被识别为存在"""
+ seeds = [make_test_seed() for _ in range(3)]
+ for seed in seeds:
+ assert save_seed(seed) is True
+
+ for seed in seeds:
+ assert seed_is_exist(seed) is True, f"种子应已存在: {seed.seedUrl}"
+
+ def test_seed_exists_after_delete_returns_false(self):
+ """删除后的种子再查询应返回 False(完整的真实生命周期)"""
+ from dataService.MySqlService import delete_seed
+ seed = make_test_seed()
+ assert save_seed(seed) is True
+ assert seed_is_exist(seed) is True
+
+ assert delete_seed(seed) is True, "前置条件:delete_seed 应成功删除种子"
+
+ assert seed_is_exist(seed) is False
+
+ def test_db_unreachable_returns_false(self, monkeypatch):
+ """数据库不可达时(真实错误路径)应返回 False,而不是抛异常"""
+ # 指向同主机一个已关闭的端口,模拟数据库不可达
+ monkeypatch.setattr(DatabaseConfig, "PORT", 3307)
+ seed = make_test_seed()
+
+ result = seed_is_exist(seed)
+
+ assert result is False
+
+
+def run_tests():
+ """运行所有测试"""
+ pytest.main([
+ __file__,
+ "-v",
+ "--tb=short",
+ "--log-cli-level=INFO"
+ ])
+
+
+if __name__ == "__main__":
+ run_tests()