重构: 优化项目架构和代码质量

This commit is contained in:
岛风 2024-12-20 12:56:31 +08:00
parent bc15d65033
commit 89ec179890
8 changed files with 742 additions and 556 deletions

372
Pixiv.py
View File

@ -1,372 +0,0 @@
"""
P站小爬虫 爬每日排行榜
环境需求Python3.8+ / Redis
项目地址https://github.com/nyaasuki/PixivSpider
"""
import re
import os
import sys
import time
try:
import requests
import redis
from rich.console import Console
from rich.progress import Progress, BarColumn, TaskProgressColumn, TextColumn, SpinnerColumn
from rich.live import Live
from rich.layout import Layout
except:
print('检测到缺少必要包!正在尝试安装!.....')
os.system(r'pip install -r requirements.txt')
import requests
import redis
from rich.console import Console
from rich.progress import Progress, BarColumn, TaskProgressColumn, TextColumn, SpinnerColumn
from rich.live import Live
from rich.layout import Layout
requests.packages.urllib3.disable_warnings()
error_list = []
# 创建Console对象用于日志输出
console = Console()
# 创建Layout布局
layout = Layout()
layout.split(
Layout(name="PixivSpider", ratio=8),
Layout(name="progress", ratio=2)
)
# 创建日志面板并设置样式
from rich.panel import Panel
from rich.live import Live
from rich.console import Group
# 创建日志存储列表
log_messages = []
def update_log(message):
"""更新日志显示"""
log_messages.append(message)
if len(log_messages) > 18: # 保持最近的18条日志
log_messages.pop(0)
log_group = Group(*log_messages)
layout["PixivSpider"].update(
Panel(
log_group,
title="PixivSpider",
title_align="left",
border_style="cyan",
padding=(0, 1)
)
)
# 创建Console对象用于日志输出
console = Console()
def format_speed(speed):
"""格式化速度显示,保留两位小数并添加单位"""
return f"{speed:.2f}t/秒" if speed is not None else ""
# 创建进度条
progress = Progress(
TextColumn("[bold blue]{task.description}"),
BarColumn(bar_width=40),
TaskProgressColumn(),
TextColumn("{task.fields[speed]}"),
console=Console(stderr=True), # 使用stderr以避免与日志混合
expand=True,
)
class PixivSpider(object):
# 类变量用于跟踪总体进度
total_images = 500 # 每日排行榜总图片数
main_task_id = None # 主任务ID
current_subtask_id = None # 当前子任务ID
def __init__(self, db=0):
self.ajax_url = 'https://www.pixiv.net/ajax/illust/{}/pages' # id
self.top_url = 'https://www.pixiv.net/ranking.php'
self.r = redis.Redis(host='localhost', port=6379, db=db, decode_responses=True)
# 创建进度显示所需的任务
with Live(layout, console=console, refresh_per_second=10):
cls = self.__class__
if not cls.main_task_id:
layout["progress"].update(progress)
cls.main_task_id = progress.add_task("[cyan]总体进度", total=cls.total_images, speed="")
def get_list(self, pid):
"""获取作品所有页面的URL"""
try:
# 检查Redis中是否已记录该作品已完全下载
if self.r.get(f'downloaded:{pid}') == 'complete':
update_log(f'[yellow]作品ID:{pid}已在Redis中标记为完全下载跳过[/yellow]')
progress.update(self.__class__.main_task_id, advance=1)
return None
# 发送请求获取作品的所有图片信息
response = requests.get(self.ajax_url.format(pid), headers=self.headers, verify=False)
json_data = response.json()
# 检查API返回是否有错误
if json_data.get('error'):
update_log(f'[red]获取作品ID:{pid}失败:{json_data.get("message")}[/red]')
return pid
# 从返回数据中获取图片列表
images = json_data.get('body', [])
if not images:
update_log(f'[red]作品ID:{pid}没有图片[/red]')
return pid
# 获取Redis中已下载的页面记录
downloaded_redis = set()
for i in range(len(images)):
if self.r.get(f'downloaded:{pid}_p{i}') == 'true':
downloaded_redis.add(i)
# 检查本地已下载的文件并更新Redis记录
if os.path.exists('./img'):
for f in os.listdir('./img'):
if f.startswith(f'{pid}_p'):
page = int(re.search(r'_p(\d+)\.', f).group(1))
if self.r.get(f'downloaded:{pid}_p{page}') != 'true':
self.r.set(f'downloaded:{pid}_p{page}', 'true')
update_log(f'[green]发现本地文件并更新Redis记录{f}[/green]')
# 使用Redis记录作为唯一来源
downloaded = downloaded_redis
# 遍历所有图片进行下载
if len(images) > 1:
# 对于多图片组,创建子进度条
with progress:
subtask_id = progress.add_task(
f"[yellow]PID:{pid}",
total=len(images),
visible=True,
speed=""
)
for image in images:
if 'urls' not in image or 'original' not in image['urls']:
update_log(f'[red]作品ID:{pid}的图片数据格式错误[/red]')
progress.update(subtask_id, advance=1)
continue
original_url = image['urls']['original']
page_num = int(re.search(r'_p(\d+)\.', original_url).group(1))
if page_num in downloaded:
update_log(f'[yellow]作品ID:{pid}{page_num}页在Redis中已标记为下载跳过[/yellow]')
progress.update(subtask_id, advance=1)
continue
why_not_do = self.get_img(original_url)
progress.update(subtask_id, advance=1)
if why_not_do == 1:
return pid
progress.remove_task(subtask_id)
else:
# 单图片直接处理
for image in images:
if 'urls' not in image or 'original' not in image['urls']:
update_log(f'[red]作品ID:{pid}的图片数据格式错误[/red]')
continue
original_url = image['urls']['original']
page_num = int(re.search(r'_p(\d+)\.', original_url).group(1))
if page_num in downloaded:
update_log(f'[yellow]作品ID:{pid}{page_num}页在Redis中已标记为下载跳过[/yellow]')
continue
why_not_do = self.get_img(original_url)
if why_not_do == 1:
return pid
# 更新总进度
progress.update(self.__class__.main_task_id, advance=1)
except requests.exceptions.RequestException as e:
update_log(f'[red]获取作品ID:{pid}时发生网络错误:{str(e)}[/red]')
return pid
except Exception as e:
update_log(f'[red]处理作品ID:{pid}时发生错误:{str(e)}[/red]')
return pid
def get_img(self, url):
"""下载单个图片"""
# 确保下载目录存在
if not os.path.isdir('./img'):
os.makedirs('./img')
# 从URL提取作品ID、页码和文件扩展名
match = re.search(r'/(\d+)_p(\d+)\.([a-z]+)$', url)
if not match:
update_log(f'[red]无效的URL格式: {url}[/red]')
return 1
# 解析URL信息并构建文件名
illust_id, page_num, extension = match.groups()
file_name = f"{illust_id}_p{page_num}.{extension}"
# 检查Redis中是否已记录为下载
if self.r.get(f'downloaded:{illust_id}_p{page_num}') == 'true':
update_log(f'[yellow]Redis记录{file_name}已下载,跳过[/yellow]')
return 0
# 作为备份检查,验证文件是否存在
if os.path.isfile(f'./img/{file_name}'):
self.r.set(f'downloaded:{illust_id}_p{page_num}', 'true')
update_log(f'[green]文件已存在但Redis未记录已更新Redis{file_name}[/green]')
return 0
# 开始下载流程
update_log(f'[cyan]开始下载:{file_name} (第{int(page_num)+1}张)[/cyan]')
t = 0 # 重试计数器
while t < 3:
try:
img_temp = requests.get(url, headers=self.headers, timeout=15, verify=False)
if img_temp.status_code == 200:
break
update_log(f'[red]下载失败,状态码:{img_temp.status_code}[/red]')
t += 1
except requests.exceptions.RequestException as e:
update_log(f'[red]连接异常:{str(e)}[/red]')
t += 1
if t == 3:
update_log(f'[red]下载失败次数过多,跳过该图片[/red]')
return 1
# 将图片内容写入文件
with open(f'./img/{file_name}', 'wb') as fp:
fp.write(img_temp.content)
# 下载成功后在Redis中记录
self.r.set(f'downloaded:{illust_id}_p{page_num}', 'true')
page_count = self.r.get(f'total_pages:{illust_id}')
if not page_count:
self.r.set(f'total_pages:{illust_id}', str(int(page_num) + 1))
elif int(page_num) + 1 == int(page_count):
all_downloaded = all(
self.r.get(f'downloaded:{illust_id}_p{i}') == 'true'
for i in range(int(page_count))
)
if all_downloaded:
self.r.set(f'downloaded:{illust_id}', 'complete')
update_log(f'[green]作品ID:{illust_id}已完全下载[/green]')
if not self.r.exists(f'total_pages:{illust_id}') or int(page_num) == 0:
# 单图片直接显示下载完成信息
update_log(f'[green]{file_name} 已下载![/green]')
else:
# 多图片组显示详细信息
update_log(f'[green]下载完成并已记录到Redis{file_name}[/green]')
return 0
def get_top_url(self, num):
"""获取每日排行榜的特定页码数据"""
params = {
'mode': 'daily',
'content': 'illust',
'p': f'{num}',
'format': 'json'
}
response = requests.get(self.top_url, params=params, headers=self.headers, verify=False)
json_data = response.json()
self.pixiv_spider_go(json_data['contents'])
def get_top_pic(self):
"""从排行榜数据中提取作品ID和用户ID"""
for url in self.data:
illust_id = url['illust_id']
illust_user = url['user_id']
yield illust_id
self.r.set(illust_id, illust_user)
@classmethod
def pixiv_spider_go(cls, data):
"""存储排行榜数据供后续处理"""
cls.data = data
@classmethod
def pixiv_main(cls):
"""爬虫主函数"""
while True:
try:
console.print("\n[cyan]可用的Redis数据库:[/cyan]")
for i in range(6):
console.print(f"{i}.DB{i}")
db_choice = input("\n请选择Redis数据库 (0-5): ")
db_num = int(db_choice)
if 0 <= db_num <= 5:
break
console.print("[red]错误请输入0到5之间的数字[/red]")
except ValueError:
console.print("[red]错误:请输入有效的数字[/red]")
global pixiv
pixiv = PixivSpider(db_num)
console.print(f"\n[green]已选择 DB{db_num}[/green]")
cookie = pixiv.r.get('cookie')
if not cookie:
cookie = input('请输入一个cookie')
pixiv.r.set('cookie', cookie)
cls.headers = {
'accept': 'application/json',
'accept-language': 'zh-CN,zh;q=0.9,zh-TW;q=0.8,en-US;q=0.7,en;q=0.6',
'dnt': '1',
'cookie': f'{cookie}',
'referer': 'https://www.pixiv.net/',
'sec-fetch-mode': 'cors',
'sec-fetch-site': 'same-origin',
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.75 Safari/537.36'
}
console.print('[cyan]开始抓取...[/cyan]')
start_time = time.time() # 添加计时器用于计算速度
processed_count = 0 # 记录已处理的图片数量
with Live(layout, console=console, refresh_per_second=10):
layout["progress"].update(progress)
# 遍历排行榜前10页
for i in range(1, 11, 1):
pixiv.get_top_url(i)
for j in pixiv.get_top_pic():
k = pixiv.get_list(j)
if k:
error_list.append(k)
# 更新处理计数和速度
processed_count += 1
elapsed = time.time() - start_time
if elapsed > 0:
speed = processed_count / elapsed
progress.update(pixiv.__class__.main_task_id, speed=format_speed(speed))
# 清理下载失败的作品记录
for k in error_list:
pixiv.r.delete(k)
if __name__ == '__main__':
try:
console.print('[cyan]正在启动Pixiv爬虫...[/cyan]')
console.print('[yellow]确保已安装并启动Redis服务[/yellow]')
console.print('[yellow]确保已准备好有效的Pixiv Cookie[/yellow]')
PixivSpider.pixiv_main()
console.print('[green]爬虫运行完成[/green]')
except redis.exceptions.ConnectionError:
console.print('[red]错误无法连接到Redis服务请确保Redis服务正在运行[/red]')
except KeyboardInterrupt:
console.print('\n[yellow]用户中断运行[/yellow]')
except Exception as e:
console.print(f'[red]发生错误:{str(e)}[/red]')

44
config.py Normal file
View File

@ -0,0 +1,44 @@
"""配置管理"""
from typing import Dict, Any
from dataclasses import dataclass
@dataclass
class RedisConfig:
"""Redis配置"""
host: str = 'localhost'
port: int = 6379
max_connections: int = 10
db_range: tuple = (0, 5) # 支持的数据库范围(包含)
@dataclass
class PixivConfig:
"""Pixiv API配置"""
ajax_url: str = 'https://www.pixiv.net/ajax/illust/{}/pages'
top_url: str = 'https://www.pixiv.net/ranking.php'
user_agent: str = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.75 Safari/537.36'
headers: Dict[str, str] = None
def __post_init__(self):
"""初始化默认请求头"""
self.headers = {
'accept': 'application/json',
'accept-language': 'zh-CN,zh;q=0.9,zh-TW;q=0.8,en-US;q=0.7,en;q=0.6',
'dnt': '1',
'referer': 'https://www.pixiv.net/',
'sec-fetch-mode': 'cors',
'sec-fetch-site': 'same-origin',
'user-agent': self.user_agent
}
# 全局配置实例
REDIS_CONFIG = RedisConfig()
PIXIV_CONFIG = PixivConfig()
# Redis键模式
class RedisKeys:
"""Redis键定义"""
COOKIE = 'cookie'
DOWNLOADED_IMAGE = 'downloaded:{pid}_p{page}' # 已下载的图片页
DOWNLOADED_WORK = 'downloaded:{pid}' # 已完成的作品
TOTAL_PAGES = 'total_pages:{pid}' # 作品总页数
USER_ID = '{illust_id}' # 作品作者ID

123
main.py
View File

@ -1,61 +1,112 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- """
Pixiv爬虫 - 主程序入口
from Pixiv import PixivSpider 环境需求Python3.8+ / Redis
import redis_monitor """
import sys import sys
from typing import NoReturn
import requests.packages.urllib3
from rich.console import Console
import redis.exceptions
def show_main_menu(): from pixiv_spider import PixivSpider
import redis_monitor
from config import REDIS_CONFIG
# 禁用SSL警告
requests.packages.urllib3.disable_warnings()
console = Console()
def show_main_menu() -> NoReturn:
"""显示主菜单并处理用户选择""" """显示主菜单并处理用户选择"""
while True: while True:
print("\n=== PixivSpider ===")
print("1. 爬取每日排行榜")
print("2. Redis数据库操作")
print("3. 退出程序")
try: try:
choice = input("\n请选择操作 (1-3): ") console.print("\n=== PixivSpider ===")
console.print("1. 爬取每日排行榜")
console.print("2. Redis数据库操作")
console.print("3. 退出程序")
choice = console.input("\n请选择操作 (1-3): ")
if choice == "1": if choice == "1":
print("\n=== 启动PixivSpider ===") run_spider()
print("确保已安装并启动Redis服务")
print("确保已准备好有效的Pixiv Cookie")
try:
PixivSpider.pixiv_main()
except redis.exceptions.ConnectionError:
print('错误无法连接到Redis服务请确保Redis服务正在运行')
except KeyboardInterrupt:
print('\n用户中断运行')
except Exception as e:
print(f'发生错误:{str(e)}')
elif choice == "2": elif choice == "2":
print("\n=== 启动Redis管理工具 ===") run_redis_monitor()
redis_monitor.show_menu()
elif choice == "3": elif choice == "3":
print("\nbye!") console.print("\n[green]再见![/green]")
sys.exit(0) sys.exit(0)
else: else:
print("\n无效的选择,请重试") console.print("\n[red]无效的选择,请重试[/red]")
except KeyboardInterrupt: except KeyboardInterrupt:
print("\n\n检测到Ctrl+C正在安全退出...") console.print("\n\n[yellow]检测到Ctrl+C正在安全退出...[/yellow]")
sys.exit(0) sys.exit(0)
except Exception as e: except Exception as e:
print(f"\n发生错误:{str(e)}") console.print(f"\n[red]发生错误:{str(e)}[/red]")
if __name__ == "__main__": def run_spider() -> None:
"""运行Pixiv爬虫"""
console.print("\n=== 启动PixivSpider ===")
console.print("[yellow]确保已安装并启动Redis服务[/yellow]")
console.print("[yellow]确保已准备好有效的Pixiv Cookie[/yellow]")
while True:
try:
console.print("\n[cyan]可用的Redis数据库:[/cyan]")
min_db, max_db = REDIS_CONFIG.db_range
for i in range(min_db, max_db + 1):
console.print(f"{i}.DB{i}")
db_choice = console.input("\n请选择Redis数据库: ")
db_num = int(db_choice)
if min_db <= db_num <= max_db:
spider = PixivSpider(db_num)
spider.run()
break
else:
console.print(f"[red]错误:请输入{min_db}{max_db}之间的数字[/red]")
except redis.exceptions.ConnectionError:
console.print('[red]错误无法连接到Redis服务请确保Redis服务正在运行[/red]')
break
except ValueError:
console.print("[red]错误:请输入有效的数字[/red]")
except KeyboardInterrupt:
console.print('\n[yellow]用户中断运行[/yellow]')
break
except Exception as e:
console.print(f'[red]发生错误:{str(e)}[/red]')
break
def run_redis_monitor() -> None:
"""运行Redis管理工具"""
console.print("\n=== 启动Redis管理工具 ===")
redis_monitor.show_menu()
def check_dependencies() -> None:
"""检查并安装依赖包"""
try: try:
import redis import redis
import requests import requests
from rich import console, progress, layout, panel
except ImportError: except ImportError:
print('检测到缺少必要包!正在尝试安装!.....') console.print('[yellow]检测到缺少必要包!正在尝试安装!.....[/yellow]')
import os import os
os.system(r'pip install -r requirements.txt') os.system('pip install -r requirements.txt')
# 重新导入以验证安装
import redis import redis
import requests import requests
from rich import console, progress, layout, panel
console.print('[green]依赖安装完成[/green]')
show_main_menu()
if __name__ == "__main__":
try:
check_dependencies()
show_main_menu()
except Exception as e:
console.print(f"[red]程序启动失败:{str(e)}[/red]")
sys.exit(1)

150
pixiv_download.py Normal file
View File

@ -0,0 +1,150 @@
"""Pixiv下载组件"""
import os
import re
from typing import Optional, Union
import requests
from rich.progress import Progress
from config import PIXIV_CONFIG
from redis_client import RedisClient
class PixivDownloader:
"""处理Pixiv图片下载"""
def __init__(self, headers: dict, progress: Progress):
"""
初始化下载器
参数:
headers: 带cookie的请求头
progress: Rich进度条实例
"""
self.headers = headers
self.progress = progress
self.redis = RedisClient()
def download_image(self, url: str) -> bool:
"""
下载单张图片
参数:
url: 图片URL
返回:
bool: 成功返回True失败返回False
"""
# 从URL提取图片信息
match = re.search(r'/(\d+)_p(\d+)\.([a-z]+)$', url)
if not match:
return False
illust_id, page_num, extension = match.groups()
file_name = f"{illust_id}_p{page_num}.{extension}"
# 检查是否已下载
if self.redis.is_image_downloaded(illust_id, page_num):
return True
# 确保下载目录存在
if not os.path.isdir('./img'):
os.makedirs('./img')
# 下载重试机制
for attempt in range(3):
try:
response = requests.get(
url,
headers=self.headers,
timeout=15,
verify=False
)
if response.status_code == 200:
# 保存图片
with open(f'./img/{file_name}', 'wb') as fp:
fp.write(response.content)
# 更新Redis记录
self.redis.mark_image_downloaded(illust_id, page_num)
# 更新总页数
total_pages = self.redis.get_total_pages(illust_id)
if not total_pages:
self.redis.set_total_pages(illust_id, int(page_num) + 1)
elif int(page_num) + 1 == total_pages:
# 检查作品是否完成
all_downloaded = all(
self.redis.is_image_downloaded(illust_id, i)
for i in range(total_pages)
)
if all_downloaded:
self.redis.mark_work_complete(illust_id)
return True
except requests.RequestException:
if attempt == 2: # 最后一次尝试失败
return False
continue
return False
def download_work(self, work_id: str) -> bool:
"""
下载作品的所有图片
参数:
work_id: Pixiv作品ID
返回:
bool: 全部成功返回True否则False
"""
# 跳过已完成的作品
if self.redis.is_work_complete(work_id):
return True
try:
# 获取图片URL列表
response = requests.get(
PIXIV_CONFIG.ajax_url.format(work_id),
headers=self.headers,
verify=False
)
data = response.json()
if data.get('error'):
return False
images = data.get('body', [])
if not images:
return False
# 下载每张图片
if len(images) > 1:
# 多图作品
subtask_id = self.progress.add_task(
f"[yellow]PID:{work_id}",
total=len(images)
)
success = True
for image in images:
if 'urls' not in image or 'original' not in image['urls']:
success = False
continue
if not self.download_image(image['urls']['original']):
success = False
self.progress.update(subtask_id, advance=1)
self.progress.remove_task(subtask_id)
return success
else:
# 单图作品
if 'urls' not in images[0] or 'original' not in images[0]['urls']:
return False
return self.download_image(images[0]['urls']['original'])
except (requests.RequestException, KeyError, ValueError):
return False

167
pixiv_spider.py Normal file
View File

@ -0,0 +1,167 @@
"""
Pixiv爬虫 - 每日排行榜下载
环境需求Python3.8+ / Redis
"""
from typing import Generator, List, Dict, Any
import requests
from rich.console import Console
from rich.progress import (
Progress,
BarColumn,
TaskProgressColumn,
TextColumn,
SpinnerColumn
)
from rich.live import Live
from rich.layout import Layout
from rich.panel import Panel
from rich.console import Group
from config import PIXIV_CONFIG
from redis_client import RedisClient
from pixiv_download import PixivDownloader
requests.packages.urllib3.disable_warnings()
class PixivSpider:
"""Pixiv每日排行榜爬虫"""
TOTAL_IMAGES = 500 # 每日排行榜总图片数
def __init__(self, db: int = 0):
"""
初始化爬虫
参数:
db: Redis数据库编号(0-5)
"""
# 设置Redis
self.redis = RedisClient()
if not self.redis.select_db(db):
raise ValueError(f"无效的Redis数据库编号: {db}")
# 设置界面组件
self.console = Console()
self._setup_ui()
# 初始化状态
self.headers = None
self.current_ranking_data = []
self.failed_works = []
def _setup_ui(self) -> None:
"""设置Rich界面组件"""
# 创建布局
self.layout = Layout()
self.layout.split(
Layout(name="PixivSpider", ratio=8),
Layout(name="progress", ratio=2)
)
# 创建进度条
self.progress = Progress(
TextColumn("[bold blue]{task.description}"),
BarColumn(bar_width=40),
TaskProgressColumn(),
TextColumn("{task.fields[speed]}"),
console=Console(stderr=True),
expand=True
)
# 设置日志面板
self.log_messages = []
self.main_task_id = self.progress.add_task(
"[cyan]总体进度",
total=self.TOTAL_IMAGES,
speed=""
)
def _update_log(self, message: str) -> None:
"""更新日志显示"""
self.log_messages.append(message)
if len(self.log_messages) > 18:
self.log_messages.pop(0)
log_group = Group(*self.log_messages)
self.layout["PixivSpider"].update(
Panel(
log_group,
title="PixivSpider",
title_align="left",
border_style="cyan",
padding=(0, 1)
)
)
def _setup_session(self) -> None:
"""设置请求会话"""
cookie = self.redis.get_cookie()
if not cookie:
cookie = input('请输入一个cookie')
self.redis.set_cookie(cookie)
self.headers = PIXIV_CONFIG.headers.copy()
self.headers['cookie'] = cookie
def get_ranking_page(self, page: int) -> None:
"""
获取排行榜单页数据
参数:
page: 页码(1-10)
"""
params = {
'mode': 'daily',
'content': 'illust',
'p': str(page),
'format': 'json'
}
response = requests.get(
PIXIV_CONFIG.top_url,
params=params,
headers=self.headers,
verify=False
)
data = response.json()
self.current_ranking_data = data['contents']
def process_ranking_data(self) -> Generator[str, None, None]:
"""
处理当前排行榜数据
生成:
str: 作品ID
"""
for item in self.current_ranking_data:
work_id = str(item['illust_id'])
user_id = str(item['user_id'])
self.redis.store_user_id(work_id, user_id)
yield work_id
def run(self) -> None:
"""运行爬虫"""
self._setup_session()
downloader = PixivDownloader(self.headers, self.progress)
with Live(self.layout, self.console, refresh_per_second=10):
self.layout["progress"].update(self.progress)
self._update_log('[cyan]开始抓取...[/cyan]')
# 处理排行榜页面
for page in range(1, 11):
try:
self.get_ranking_page(page)
for work_id in self.process_ranking_data():
if not downloader.download_work(work_id):
self.failed_works.append(work_id)
self.progress.update(self.main_task_id, advance=1)
except requests.RequestException as e:
self._update_log(f'[red]获取排行榜第{page}页时发生错误:{str(e)}[/red]')
continue
# 清理失败作品的记录
for work_id in self.failed_works:
self.redis.client.delete(work_id)
self._update_log('[green]爬虫运行完成[/green]')

132
redis_client.py Normal file
View File

@ -0,0 +1,132 @@
"""Redis客户端管理"""
from typing import Optional
import redis
from redis.connection import ConnectionPool
from config import REDIS_CONFIG, RedisKeys
class RedisClient:
"""Redis客户端管理器使用连接池"""
_pools: dict[int, ConnectionPool] = {}
_instance: Optional['RedisClient'] = None
def __new__(cls) -> 'RedisClient':
"""确保单例"""
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
"""初始化客户端管理器"""
if not hasattr(self, '_initialized'):
self._initialized = True
self._current_db = 0
self._redis: Optional[redis.Redis] = None
self._init_connection()
def _get_pool(self, db: int) -> ConnectionPool:
"""获取指定数据库的连接池"""
if db not in self._pools:
self._pools[db] = redis.ConnectionPool(
host=REDIS_CONFIG.host,
port=REDIS_CONFIG.port,
db=db,
max_connections=REDIS_CONFIG.max_connections,
decode_responses=True
)
return self._pools[db]
def _init_connection(self) -> None:
"""初始化当前数据库的连接"""
self._redis = redis.Redis(
connection_pool=self._get_pool(self._current_db)
)
def select_db(self, db: int) -> bool:
"""
切换到指定数据库
参数:
db: 数据库编号
返回:
bool: 成功返回True失败返回False
"""
min_db, max_db = REDIS_CONFIG.db_range
if not min_db <= db <= max_db:
return False
if db != self._current_db:
self._current_db = db
self._init_connection()
return True
@property
def client(self) -> redis.Redis:
"""获取当前Redis客户端"""
return self._redis
def get_cookie(self) -> Optional[str]:
"""获取存储的Pixiv cookie"""
return self._redis.get(RedisKeys.COOKIE)
def set_cookie(self, cookie: str) -> None:
"""存储Pixiv cookie"""
self._redis.set(RedisKeys.COOKIE, cookie)
def is_image_downloaded(self, pid: str, page: int) -> bool:
"""检查特定图片页是否已下载"""
key = RedisKeys.DOWNLOADED_IMAGE.format(pid=pid, page=page)
return self._redis.get(key) == 'true'
def mark_image_downloaded(self, pid: str, page: int) -> None:
"""标记特定图片页为已下载"""
key = RedisKeys.DOWNLOADED_IMAGE.format(pid=pid, page=page)
self._redis.set(key, 'true')
def is_work_complete(self, pid: str) -> bool:
"""检查作品是否已完全下载"""
key = RedisKeys.DOWNLOADED_WORK.format(pid=pid)
return self._redis.get(key) == 'complete'
def mark_work_complete(self, pid: str) -> None:
"""标记作品为已完全下载"""
key = RedisKeys.DOWNLOADED_WORK.format(pid=pid)
self._redis.set(key, 'complete')
def get_total_pages(self, pid: str) -> Optional[int]:
"""获取作品总页数"""
key = RedisKeys.TOTAL_PAGES.format(pid=pid)
value = self._redis.get(key)
return int(value) if value else None
def set_total_pages(self, pid: str, total: int) -> None:
"""设置作品总页数"""
key = RedisKeys.TOTAL_PAGES.format(pid=pid)
self._redis.set(key, str(total))
def store_user_id(self, illust_id: str, user_id: str) -> None:
"""存储作品作者ID"""
key = RedisKeys.USER_ID.format(illust_id=illust_id)
self._redis.set(key, user_id)
def get_db_stats(self) -> tuple[int, list[str]]:
"""
获取当前数据库统计信息
返回:
tuple: (作品数量, 作品ID列表)
"""
pattern = RedisKeys.DOWNLOADED_IMAGE.format(pid='*', page='0')
work_keys = self._redis.keys(pattern)
work_ids = [key.split(':')[1].split('_')[0] for key in work_keys]
return len(work_ids), work_ids
def clear_db(self) -> None:
"""清空当前数据库"""
self._redis.flushdb()
def close(self) -> None:
"""关闭所有连接池"""
for pool in self._pools.values():
pool.disconnect()
self._pools.clear()

View File

@ -1,160 +1,173 @@
import redis """Redis监控和管理工具"""
from typing import Optional, Dict
import sys
from rich.console import Console
from rich.table import Table
from rich.prompt import Prompt, Confirm
def show_db_status(r, db_index): from redis_client import RedisClient
"""显示指定数据库的状态信息""" from config import REDIS_CONFIG
try:
# 切换到指定数据库
r.select(db_index)
# 检查并显示Cookie状态
cookie = r.get('cookie')
if cookie:
print(f"Cookie值: {cookie}")
else:
print("Cookie状态: 匿名")
# 获取所有键
all_keys = r.keys('*')
# 统计图片ID数量
pid_count = len([key for key in all_keys if key.startswith('downloaded:') and '_p0' in key])
print(f"当前存储的图片作品数量: {pid_count}\n")
except redis.RedisError as e:
print(f"错误:{str(e)}")
def check_redis_status(): console = Console()
"""检查Redis状态并显示详细信息"""
try: class RedisMonitor:
# 连接到Redis """Redis监控和管理界面"""
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
def __init__(self):
"""初始化监控器"""
self.redis = RedisClient()
# 检查连接 def _show_db_info(self, db_index: int) -> None:
r.ping() """
显示数据库详细信息
# 获取活跃数据库信息仅0-5 参数:
info = r.info() db_index: 数据库编号
keyspace_info = {k: v for k, v in info.items() if k.startswith('db')} """
try:
# 过滤0-5范围内的数据库 self.redis.select_db(db_index)
valid_indices = set(range(6)) # 0-5
db_indices = [int(k.replace('db', '')) for k in keyspace_info.keys() if int(k.replace('db', '')) in valid_indices] table = Table(title=f"数据库 db{db_index} 信息")
db_indices.sort() table.add_column("项目", style="cyan")
table.add_column("", style="green")
if not db_indices:
print("\n当前没有活跃的数据库") # Cookie状态
return cookie = self.redis.get_cookie()
db_list = ', '.join([f"db{i}" for i in db_indices]) table.add_row(
print(f"\n活跃的Redis数据库: {db_list}") "Cookie状态",
cookie[:30] + "..." if cookie else "未设置"
if len(db_indices) == 1: )
# 只有一个数据库,直接显示其信息
print(f"\n数据库 db{db_indices[0]} 的信息:") # 作品统计
show_db_status(r, db_indices[0]) work_count, work_ids = self.redis.get_db_stats()
else: table.add_row("已下载作品数", str(work_count))
# 多个数据库,让用户选择
while True: console.print(table)
choice = input("\n请选择要查看的数据库编号 (例如: 0 表示db0): ")
try: except Exception as e:
db_index = int(choice) console.print(f"[red]获取数据库信息时出错:{str(e)}[/red]")
if db_index in db_indices:
print(f"\n数据库 db{db_index} 的信息:") def show_status(self) -> None:
show_db_status(r, db_index) """显示Redis状态和数据库信息"""
break try:
else: # 获取活跃数据库
print("无效的数据库编号,请重试") active_dbs = []
except ValueError: min_db, max_db = REDIS_CONFIG.db_range
print("请输入有效的数字") for db in range(min_db, max_db + 1):
if self.redis.select_db(db):
work_count, _ = self.redis.get_db_stats()
if work_count > 0:
active_dbs.append(db)
if not active_dbs:
console.print("\n[yellow]当前没有活跃的数据库[/yellow]")
return
except redis.ConnectionError: # 显示数据库列表
print("错误无法连接到Redis服务器请确保Redis服务正在运行") db_list = ", ".join(f"db{db}" for db in active_dbs)
except Exception as e: console.print(f"\n[cyan]活跃的数据库: {db_list}[/cyan]")
print(f"错误:{str(e)}")
# 显示详细信息
def clear_redis_db(): if len(active_dbs) == 1:
"""清空Redis数据库""" self._show_db_info(active_dbs[0])
try:
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
# 获取当前数据库信息仅0-5
info = r.info()
keyspace_info = {k: v for k, v in info.items() if k.startswith('db')}
# 过滤0-5范围内的数据库
valid_indices = set(range(6)) # 0-5
db_indices = [int(k.replace('db', '')) for k in keyspace_info.keys() if int(k.replace('db', '')) in valid_indices]
db_indices.sort()
if not db_indices:
print("\n当前没有活跃的数据库")
return
db_list = ', '.join([f"db{i}" for i in db_indices])
print(f"\n活跃的Redis数据库: {db_list}")
print("\n清空选项:")
print("1. 清空指定数据库")
print("2. 清空所有数据库")
print("3. 取消操作")
choice = input("请选择操作 (1-3): ")
if choice == '1':
if len(db_indices) == 1:
db_index = db_indices[0]
confirm = input(f"确定要清空数据库 db{db_index} 吗?(y/n): ")
if confirm.lower() == 'y':
r.select(db_index)
r.flushdb()
print(f"数据库 db{db_index} 已清空\n")
else: else:
while True: while True:
choice = input("\n请选择要清空的数据库编号 (例如: 0 表示db0): ") db = Prompt.ask(
try: "请选择要查看的数据库编号",
db_index = int(choice) choices=[str(db) for db in active_dbs]
if 0 <= db_index <= 5 and db_index in db_indices: )
confirm = input(f"确定要清空数据库 db{db_index} 吗?(y/n): ") self._show_db_info(int(db))
if confirm.lower() == 'y': break
r.select(db_index)
r.flushdb() except Exception as e:
print(f"数据库 db{db_index} 已清空\n") console.print(f"[red]获取Redis状态时出错{str(e)}[/red]")
break
else:
print("无效的数据库编号,请重试")
except ValueError:
print("请输入有效的数字")
elif choice == '2':
confirm = input("确定要清空所有数据库吗?(y/n): ")
if confirm.lower() == 'y':
for db_index in range(6): # 0-5
r.select(db_index)
r.flushdb()
print("所有的数据库已清空\n")
elif choice == '3':
print("已取消操作\n")
else:
print("无效的选择\n")
except redis.ConnectionError: def clear_database(self) -> None:
print("错误无法连接到Redis服务器请确保Redis服务正在运行") """清空Redis数据库"""
try:
# 获取活跃数据库
active_dbs = []
min_db, max_db = REDIS_CONFIG.db_range
for db in range(min_db, max_db + 1):
if self.redis.select_db(db):
work_count, _ = self.redis.get_db_stats()
if work_count > 0:
active_dbs.append(db)
if not active_dbs:
console.print("\n[yellow]当前没有活跃的数据库[/yellow]")
return
# 显示数据库列表
db_list = ", ".join(f"db{db}" for db in active_dbs)
console.print(f"\n[cyan]活跃的数据库: {db_list}[/cyan]")
# 显示选项
console.print("\n清空选项:")
console.print("1. 清空指定数据库")
console.print("2. 清空所有数据库")
console.print("3. 取消操作")
choice = Prompt.ask("请选择操作", choices=["1", "2", "3"])
if choice == "1":
if len(active_dbs) == 1:
db = active_dbs[0]
if Confirm.ask(f"确定要清空数据库 db{db} 吗?"):
self.redis.select_db(db)
self.redis.clear_db()
console.print(f"[green]数据库 db{db} 已清空[/green]")
else:
db = int(Prompt.ask(
"请选择要清空的数据库编号",
choices=[str(db) for db in active_dbs]
))
if Confirm.ask(f"确定要清空数据库 db{db} 吗?"):
self.redis.select_db(db)
self.redis.clear_db()
console.print(f"[green]数据库 db{db} 已清空[/green]")
elif choice == "2":
if Confirm.ask("确定要清空所有数据库吗?"):
for db in range(min_db, max_db + 1):
self.redis.select_db(db)
self.redis.clear_db()
console.print("[green]所有数据库已清空[/green]")
except Exception as e:
console.print(f"[red]清空数据库时出错:{str(e)}[/red]")
def run(self) -> None:
"""运行监控界面"""
while True:
console.print("\n=== Redis管理工具 ===")
console.print("1. 显示状态")
console.print("2. 清空数据库")
console.print("3. 退出")
try:
choice = Prompt.ask("请选择操作", choices=["1", "2", "3"])
if choice == "1":
self.show_status()
elif choice == "2":
self.clear_database()
else:
break
except KeyboardInterrupt:
console.print("\n[yellow]用户中断操作[/yellow]")
break
except Exception as e:
console.print(f"[red]发生错误:{str(e)}[/red]")
def show_menu() -> None:
"""Redis监控入口"""
try:
monitor = RedisMonitor()
monitor.run()
except Exception as e: except Exception as e:
print(f"错误:{str(e)}") console.print(f"[red]启动Redis管理工具时出错{str(e)}[/red]")
def show_menu():
"""显示交互菜单"""
while True:
print("=== Redis管理工具 ===")
print("1. 显示状态")
print("2. 清空数据库")
print("3. 退出")
choice = input("请选择操作 (1-3): ")
if choice == '1':
check_redis_status()
elif choice == '2':
clear_redis_db()
elif choice == '3':
print("退出程序")
break
else:
print("无效的选择,请重试\n")
if __name__ == '__main__': if __name__ == '__main__':
show_menu() show_menu()

View File

@ -1,3 +1,4 @@
redis==5.2.1 redis==5.2.1
requests==2.32.3 requests==2.32.3
rich==13.7.1 rich==13.7.1
urllib3<2.0.0 # 确保与requests兼容