Files
flaskProject/app/services/book_service.py
T
YanLongChangAn bd88ca76de fix(book): 修复文件名合法性检查问题
- 添加了去除文件名末尾空格和点号的功能
- 确保文件名不会以非法字符结尾
2026-06-11 11:23:07 +08:00

211 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
import requests
from pathlib import Path
from bs4 import BeautifulSoup
from app.extensions import db
from tenacity import retry, stop_after_attempt, RetryError
from app.utils import datetime_to_timestamp, timestamp_to_datetime, get_today_timestamp, find_project_root
from app.models import Setting
class BookService:
ALLOWED_CONTENT_TYPES = ['"application/octet-stream"', "application/octet-stream", "text/plain",
"application/zip"]
def __init__(self):
setting_record = Setting.query.filter_by(name="book_download").first()
if not setting_record:
raise RuntimeError("Setting 'book_download' not found in database")
self.setting = setting_record.to_dict()
# 目标日期减一天
target_ts = datetime_to_timestamp(self.setting["targetDate"]) - 24 * 60 * 60
self.setting["targetDate"] = timestamp_to_datetime(target_ts)
app_dir = find_project_root()
self.download_path = app_dir / "download" # 下载目录
print(self.download_path)
self.today_timestamp = get_today_timestamp()
self.one_date_loading = False
self.session = requests.session()
self.session.headers.update({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
"cookie": "cPNj_2132_saltkey=CeJLs8Ed; "
"cPNj_2132_auth=44bdzFfx4TYjBMhuKf4AZdi2JM%2BXZ0Okxd0JEl1Hmwbz%2Fr3WiX0sLmATmolWkLlRgorFz%2BULizz5o6G%2FJ3YGWYPF0xw; "
})
self.session.timeout = 30
# ------------------------------------------------------------
# 数据库辅助方法
# ------------------------------------------------------------
def _update_setting(self, **kwargs):
Setting.query.filter_by(name="book_download").update(kwargs)
db.session.commit()
for key, value in kwargs.items():
self.setting[key] = value
# ------------------------------------------------------------
# 文件与目录处理
# ------------------------------------------------------------
@staticmethod
def legitimate_naming(name: str) -> str:
"""将文件名中的非法字符替换为合法字符"""
name = name.rstrip(" .")
replacements = {":": "", "<": "《", ">": "》", "/": " ", "\\": " ", "?": ""}
for old, new in replacements.items():
name = name.replace(old, new)
return name
@staticmethod
def create_folder(parent_dir: Path, folder_name: str) -> Path: # 创建文件夹
"""创建文件夹(如果不存在)并返回路径"""
target = parent_dir / folder_name
target.mkdir(parents=True, exist_ok=True)
return target
def post_page(self, name, url): # 帖子页面
full_url = f"{self.setting['baseUrl']}{url}"
try:
response = self.session.get(full_url)
response.raise_for_status()
except requests.RequestException as e:
yield f"event: error\ndata: 请求帖子详情失败 {url}: {str(e)}\n\n"
return
print(f"详情页面 {str(response.status_code)} {full_url}")
soup = BeautifulSoup(response.text, 'lxml')
resource_boxs = soup.select('ignore_js_op')
for i in resource_boxs:
download_dir_path = self.download_path / self.setting["targetDate"]
self.create_folder(download_dir_path, name)
try:
for string in i.select_one(".tip.tip_4").stripped_strings:
if "阅读权限: " in repr(string):
privilege_level = int(
repr(string).strip("'").strip('阅读权限: '))
if privilege_level > self.setting["privilegeLevel"]:
print("下载失败,权限等级不够")
return
yield from self.download_file(f"{self.setting['baseUrl']}{i.select_one('a')['href']}", name, i.select_one('a').string)
# yield from self.download_file(f"{self.setting["baseUrl"]}{i.select_one(
# 'a')['href']}", name, self.legitimate_naming(i.select_one('a').string))
except RetryError as e:
print('重试三次也不成功')
@retry(stop=stop_after_attempt(3))
def download_file(self, file_url: str, dir_name: str, file_name: str): # 下载文件
try:
response = self.session.get(file_url, stream=True, allow_redirects=False)
if response.status_code in (301, 302, 307, 308): # 非200状态码,重新请求
file_url = response.headers["location"]
print(f'跳转页面了:{file_url}')
response = self.session.get(file_url, headers={"referer": file_url}, stream=True)
print(f"下载状态码:{response.status_code} {file_url}")
print(dir_name + "/" + file_name)
content_type = response.headers.get('Content-Type', '')
if content_type not in self.ALLOWED_CONTENT_TYPES:
yield f"event: mes_error\ndata: {self.setting['targetDate']} {file_name}\n\n"
print("不是可下载文件")
return
target_dir = self.download_path / self.setting["targetDate"] / dir_name / file_name
print(target_dir)
with open(target_dir, 'wb') as f:
for chunk in response.iter_content(chunk_size=1024):
if chunk:
f.write(chunk)
yield f"data: {self.setting['targetDate']} {file_name}\n\n"
except requests.RequestException as e:
yield f"event: error\ndata: {self.setting['targetDate']} {file_name} 网络错误\n\n"
raise # 触发 tenacity 重试
def section_page(self, page): # 版块页面
print(f"当前页面 {page}")
target_date_timestamp = datetime_to_timestamp(self.setting["targetDate"]) # 目标时间戳
if target_date_timestamp >= self.today_timestamp: # 判断时间(是否大于当前时间戳)
# print("不允许下载当天的")
yield f"data: 不允许下载当天的\n\n"
return None
# yield f"data: 当前页面 {page}\n\n"
url = f"{self.setting['baseUrl']}forum.php?mod={self.setting['mod']}&fid={self.setting['fid']}&page={page}" # 版块页面地址
response = self.session.get(url) # 请求
response.raise_for_status()
soup = BeautifulSoup(response.text, 'lxml') # 解析
total_page_element = soup.select_one("a.bm_h") # 获取总页数元素
if total_page_element is not None: # 总页数元素存在 则更新总页数
new_total_page = int(total_page_element.get("totalpage")) # 获取总页数
if self.setting["totalPage"] != new_total_page: # 总页数有更新
self._update_setting(totalPage=new_total_page)
yield from self.section_page(new_total_page - self.setting["targetCountdownPage"]) # 重新跳转指点版块页面
return None
posts = soup.select("table#threadlisttableid tbody[id^=normalthread]") # 获取帖子列表
posts.reverse() # 帖子列表倒序
posts_min_timestamp = datetime_to_timestamp(self.get_post_date(posts[0])) # 帖子列表最小时间戳
posts_max_timestamp = datetime_to_timestamp(self.get_post_date(posts[-1])) # 帖子列表最大时间戳
# 本页最早的帖子时间戳小于等于目标时间戳 且 倒计时页数大于 0 则跳转到上一页继续查找
if posts_min_timestamp >= target_date_timestamp and not self.one_date_loading and self.setting[
"targetCountdownPage"] > 0:
print("跳转到上一页")
# yield f"data: 跳转到上一页\n\n"
self.setting["targetCountdownPage"] = max(self.setting["targetCountdownPage"] - 1, 0)
Setting.query.filter_by(name="book_download").update(
{"targetCountdownPage": self.setting["targetCountdownPage"]})
db.session.commit()
yield from self.section_page(self.setting["totalPage"] - self.setting["targetCountdownPage"])
return None
else:
print("不需要跳转上一页")
self.one_date_loading = True
# yield f"data: 不需要跳转上一页\n\n"
for post in posts:
post_element = post.select_one(".s.xst")
post_date = self.get_post_date(post) # 获取帖子时间
post_date_timestamp = datetime_to_timestamp(post_date) # 帖子时间戳
if post_date_timestamp == target_date_timestamp: # 判断是否是目标时间
print(f"目标时间是{post_date}")
# yield f"data: 目标时间是{post_date}\n\n"
yield from self.post_page(self.legitimate_naming(post_element.string), post_element['href']) # 跳转帖子页面
if post_date_timestamp > target_date_timestamp: # 帖子时间大于目标时间并且没有下一天时间
self._update_setting(targetDate=post_date)
print(f"下一天是 {post_date}")
# yield f"data: 下一天是 {post_date}\n\n"
self.one_date_loading = False
yield from self.section_page(self.setting["totalPage"] - self.setting["targetCountdownPage"])
return None
# 判断是否需要跳转到下一页
if posts_max_timestamp <= target_date_timestamp and self.setting["targetCountdownPage"] < self.setting[
"totalPage"] - 1:
print("跳转到下一页")
# yield f"data: 跳转到下一页\n\n"
self._update_setting(
targetCountdownPage=min(self.setting["targetCountdownPage"] + 1, self.setting["totalPage"] - 1))
yield from self.section_page(self.setting["totalPage"] - self.setting["targetCountdownPage"])
return None
# ------------------------------------------------------------
# 获取帖子发布时间
# ------------------------------------------------------------
@staticmethod
def get_post_date(element):
span_span = element.select_one(".by em span span")
if span_span:
if span_span.get("title"):
return span_span.get("title")
else:
return span_span.string
else:
return element.select_one(".by em span").string
# ------------------------------------------------------------
# 下载
# ------------------------------------------------------------
def book_download(self):
print(os.getenv('aa'))
self.create_folder(self.download_path, self.setting["targetDate"]) # 创建文件夹
target_page = self.setting["totalPage"] - self.setting["targetCountdownPage"] # 目标页
yield from self.section_page(target_page) # 前往目标页
yield f"event: close\ndata: 已完成\n\n"