feat(project): 初始化项目结构和配置

- 添加.gitignore和.flaskenv环境配置文件
- 创建Flask应用基础架构,包括models、services、utils模块
- 配置数据库模型User和Setting,集成SQLAlchemy和Alembic迁移
- 添加前端Vue项目结构,包含Element Plus组件库
- 配置前后端API路由和蓝prints模块
- 实现书籍下载服务BookService功能模块
- 添加代码规范配置.editorconfig、.oxfmtrc.json、.oxlintrc.json
- 配置VSCode推荐插件和前端构建工具链
- 实现文件服务FileService和相关业务逻辑
This commit is contained in:
2026-04-19 15:43:45 +08:00
parent b9fad6344f
commit ade1b6c9b0
63 changed files with 9610 additions and 1 deletions
+26
View File
@@ -0,0 +1,26 @@
from flask import Flask
from flask_cors import CORS
from .extensions import db, migrate
from .config import DevelopmentConfig, ProductionConfig, TestingConfig
def create_app(config_name="development"):
app = Flask(__name__)
CORS(app)
app.config.from_object(DevelopmentConfig)
match config_name:
case 'production':
app.config.from_object(ProductionConfig)
case 'testing':
app.config.from_object(TestingConfig)
case _:
app.config.from_object(DevelopmentConfig)
db.init_app(app)
migrate.init_app(app, db)
from .blueprints.api import bp as api_bp
app.register_blueprint(api_bp, url_prefix='/api')
return app
View File
+91
View File
@@ -0,0 +1,91 @@
from typing import List
from flask import Blueprint, jsonify, Response, current_app
from app.services import FileService, VideoService, TorrentService, BookService, ImageService, MusicService
bp = Blueprint("api", __name__)
@bp.route("/book/download")
def book_download():
book_object = BookService()
app = current_app._get_current_object()
def generate():
with app.app_context():
yield from book_object.book_download()
return Response(generate(), mimetype='text/event-stream')
@bp.route("/video/findDeduplication")
def video_find_deduplication():
video_object = VideoService(paths=[
"C:\\迅雷下载\\0_done",
"D:\\",
"E:\\",
"F:\\"
])
return jsonify({"code": 200, "message": video_object.find_deduplication()})
@bp.route('/video/deduplication')
def video_deduplication():
video_object = VideoService(paths=[
"C:\\迅雷下载\\0_done",
"D:\\",
"E:\\",
"F:\\"
])
return jsonify({"code": 200, "message": video_object.deduplication()})
@bp.route("/video/hasTorrent")
def video_has_torrent():
torrent_obj = TorrentService(["E:\\"])
return jsonify({"code": 200, "message": torrent_obj.has_torrent()})
@bp.route('/files/rename')
def files_rename():
paths: List[str] = [
"C:\\迅雷下载\\0_done",
"C:\\Users\\Localhost\\Desktop\\BT",
]
file_object = FileService(paths)
return jsonify({"code": 200, "message": file_object.rename()})
@bp.route('/torrent/deduplication')
def torrent_deduplication():
torrent_object = TorrentService(["C:\\Users\\Localhost\\Desktop\\BT"])
return jsonify({"code": 200, "message": torrent_object.deduplication()})
@bp.route("/image/export")
def image_export():
image_object = ImageService()
def generate():
yield from image_object.export()
return Response(generate(), mimetype='text/event-stream')
@bp.route("/image/hasTorrent")
def image_has_torrent():
torrent_obj = TorrentService(["C:\\Users\\Localhost\\Desktop\\Images"])
return jsonify({"code": 200, "message": torrent_obj.has_torrent()})
@bp.route("/music/rename")
def music_rename():
music_object = MusicService()
return jsonify({"code": 200, "message": music_object.rename()})
@bp.route("/torrent/statistics")
def torrent_statistics():
torrent_object = TorrentService(["C:\\Users\\Localhost\\Desktop\\BT"])
return jsonify({"code": 200, "message": torrent_object.statistics()})
+16
View File
@@ -0,0 +1,16 @@
class Config:
SECRET_KEY = 'mysecretkey'
SQLALCHEMY_DATABASE_URI = 'sqlite:///flask.db'
SQLALCHEMY_TRACK_MODIFICATIONS = False
class DevelopmentConfig(Config):
DEBUG = True
class ProductionConfig(Config):
DEBUG = False
class TestingConfig(Config):
TESTING = True
+17
View File
@@ -0,0 +1,17 @@
import os
IGNORE_DIRS = {
"System Volume Information",
"$RECYCLE.BIN",
"Prevent Disk Sleep.txt",
"PREVENT DISK SLEEP.txt",
"images",
"done_images",
"女同"
}
SPECIAL_PREFIXES = {"T28", "FC2"}
SEPARATORS = {"♀  ", "", "+"}
SPECIAL_DIRS = {"川村まや", "上原亜衣", "上原志織"}
IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp', '.svg'}
DISLIKE_DIRS = {"篠真有", "二羽紗愛", "綾瀬天"}
USERPROFILE = os.environ.get("USERPROFILE")
TORRENT_ROOT_FOLDER: str = os.path.join(USERPROFILE, "Desktop\\BT")
+5
View File
@@ -0,0 +1,5 @@
from flask_migrate import Migrate
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
migrate = Migrate()
+2
View File
@@ -0,0 +1,2 @@
from .user import User
from .setting import Setting
+29
View File
@@ -0,0 +1,29 @@
from ..extensions import db
class Setting(db.Model):
name = db.Column(db.String(64), nullable=False)
mod = db.Column(db.String(64), index=True, unique=True)
fid = db.Column(db.Integer, primary_key=True)
orderby = db.Column(db.String(64))
baseUrl = db.Column(db.String(64))
targetDate = db.Column(db.String(64))
targetCountdownPage = db.Column(db.Integer)
totalPage = db.Column(db.Integer)
privilegeLevel = db.Column(db.Integer)
def __repr__(self):
return '<Setting %r>' % self.name
def to_dict(self):
return {
'name': self.name,
'mod': self.mod,
'fid': self.fid,
'orderby': self.orderby,
'baseUrl': self.baseUrl,
'targetDate': self.targetDate,
'targetCountdownPage': self.targetCountdownPage,
'totalPage': self.totalPage,
'privilegeLevel': self.privilegeLevel
}
+11
View File
@@ -0,0 +1,11 @@
from ..extensions import db
class Torrent(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(64), index=True, unique=True)
captions_exists = db.Column(db.Boolean, server_default=db.false())
captions_effective = db.Column(db.Boolean, server_default=db.false())
def __repr__(self):
return '<Torrent %r>' % self.id
+17
View File
@@ -0,0 +1,17 @@
from ..extensions import db
# 定义模型类(数据表)
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
def __repr__(self):
return f'<User {self.username}>'
# 将对象转换为字典,方便返回 JSON
def to_dict(self):
return {
'id': self.id,
'username': self.username,
'email': self.email
}
+7
View File
@@ -0,0 +1,7 @@
from .folder_service import FolderService
from .file_service import FileService
from .torrent_service import TorrentService
from .book_service import BookService
from .image_service import ImageService
from .music_service import MusicService
from .video_service import VideoService
+163
View File
@@ -0,0 +1,163 @@
import os
import requests
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
from app.models import Setting
class BookService:
def __init__(self):
self.setting = Setting.query.filter_by(name="book_download").first().to_dict()
self.setting["targetDate"] = timestamp_to_datetime(datetime_to_timestamp(self.setting["targetDate"]) - 24 * 60 * 60)
app_dir = os.path.dirname(os.path.dirname(__file__))
self.download_path = os.path.join(os.path.dirname(app_dir), "download") # 下载目录
self.today_timestamp = get_today_timestamp()
self.one_date_loading = False
self.headers = {
"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; "
}
def legitimate_naming(self, name): # 合法命名
name = name.replace(":", "")
name = name.replace("<", "")
name = name.replace(">", "")
name = name.replace("/", " ")
name = name.replace("\\", " ")
name = name.replace("?", "")
return name
def create_folder(self, target_dir, name): # 创建文件夹
real_dir = os.path.join(target_dir, name)
folder = os.path.exists(real_dir)
if not folder:
os.makedirs(real_dir)
else:
print(f"文件夹:{name} 已存在")
def isDownloadTarget(self, soup): # 是否是可以下载的文件
return soup.headers.get('Content-Type') in ['"application/octet-stream"', "application/octet-stream"]
def post_page(self, name, url): # 帖子页面
response = requests.get(f"{self.setting["baseUrl"]}{url}", headers=self.headers)
print(f"详情页面状态码:{str(response.status_code)}")
soup = BeautifulSoup(response.text, 'lxml')
resource_boxs = soup.select('ignore_js_op')
for i in resource_boxs:
download_dir_path = os.path.join(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)
except RetryError as e:
print('重试三次也不成功')
@retry(stop=stop_after_attempt(3))
def download_file(self, file_url, dir_name, file_name): # 下载文件
file_res = requests.get(file_url, headers=self.headers, stream=True, allow_redirects=False)
if file_res.status_code in (301, 302, 307, 308): # 非200状态码,重新请求
location = file_res.headers["location"]
print(f'跳转页面了:{location}')
file_res = requests.get(location, headers={"referer": location}, stream=True)
print(f"下载状态码:{file_res.status_code} {file_url}")
print(dir_name + "/" + file_name)
yield f"data: {self.setting["targetDate"]} {file_name}\n\n"
if self.isDownloadTarget(file_res):
with open(f"download/{self.setting["targetDate"]}/{dir_name}/{file_name}", 'wb') as file2:
for chunk in file_res.iter_content(chunk_size=1024):
if chunk:
file2.write(chunk)
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 = requests.get(url, headers=self.headers) # 请求
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.setting["totalPage"] = new_total_page # 更新总页数
Setting.query.filter_by(name="book_download").update({"totalPage": self.setting["totalPage"]})
db.session.commit()
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.setting["targetDate"] = post_date
Setting.query.filter_by(name="book_download").update({"targetDate": self.setting["targetDate"]})
db.session.commit()
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.setting["targetCountdownPage"] = min(self.setting["targetCountdownPage"] + 1,
self.setting["totalPage"] - 1)
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
def get_post_date(self, element):
if element.select_one(".by em span span"):
if element.select_one(".by em span span").get("title"):
return element.select_one(".by em span span").get("title")
else:
return element.select_one(".by em span span").string
else:
return element.select_one(".by em span").string
def book_download(self):
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"
+75
View File
@@ -0,0 +1,75 @@
import os
from typing import List, Optional
from app.services import FolderService
from app.utils import windows_sort_unicode
from app.constants import IGNORE_DIRS, SEPARATORS, SPECIAL_PREFIXES, SPECIAL_DIRS
class FileService:
def __init__(self, paths: List[str]):
self.paths = paths
self.result_list: list[str] = []
def perform_rename(self, entry: os.DirEntry[str], new_name: str) -> Optional[str]:
if new_name == entry.name:
return None
new_path = os.path.join(os.path.dirname(entry.path), new_name)
os.rename(entry.path, new_path)
self.result_list.insert(0, new_path)
# return new_path
def process_folder_name(self, entry: os.DirEntry[str]) -> Optional[str]:
if not any(sep in entry.name for sep in SEPARATORS):
return None
# 替换所有分隔符为+
new_name = entry.name
for sep in SEPARATORS:
new_name = new_name.replace(sep, "+")
# 分割并排序各部分
parts = new_name.split("+")
sorted_parts = windows_sort_unicode(parts)
new_name = "+".join(sorted_parts)
if new_name == entry.name:
return None
return self.perform_rename(entry, new_name)
def process_file_name(self, entry: os.DirEntry[str]) -> Optional[str]:
parent_dir = os.path.basename(os.path.dirname(entry.path))
name, ext = os.path.splitext(entry.name)
ext = ext.lower()
name = name.upper()
# 排除特殊文件夹,仅处理大小写问题
if parent_dir in SPECIAL_DIRS:
return self.perform_rename(entry, f"{name}{ext}")
name = name.replace("-", "")
# 处理特殊前缀
for prefix in SPECIAL_PREFIXES:
if name.startswith(prefix):
return self.perform_rename(entry, f"{prefix}-{name[len(prefix):]}{ext}")
# 在字母和数字之间添加连字符
new_name = []
for i, char in enumerate(name):
if i > 0 and not name[i - 1].isnumeric() and char.isnumeric():
new_name.append(f"-{char}")
else:
new_name.append(char)
if ''.join(new_name[-2:]) == "4K":
new_name.insert(-2, "-")
return self.perform_rename(entry, f"{''.join(new_name)}{ext}")
def rename(self) -> List[str]:
FolderService(paths=self.paths, ignore_dirs=IGNORE_DIRS, folder_callback=self.process_folder_name,
file_callback=self.process_file_name).process_folder()
return self.result_list
+46
View File
@@ -0,0 +1,46 @@
import os
from typing import Optional, Callable, List
class FolderService:
def __init__(
self,
paths: List[str] | str,
ignore_dirs: Optional[set] = None,
folder_callback: Optional[Callable] = None,
file_callback: Optional[Callable] = None,
empty_folder_callback: Optional[Callable] = None,
max_depth: Optional[int] = None
):
self.paths: List[str] = paths if type(paths) is not str else [paths]
self.ignore_dirs = ignore_dirs or set()
self.folder_callback = folder_callback
self.file_callback = file_callback
self.empty_folder_callback = empty_folder_callback
self.max_depth = max_depth
def _traverse_folder(self, path: Optional[str] = None, depth: Optional[int] = 0) -> None:
"""处理目录"""
for entry in os.scandir(path):
if entry.name in self.ignore_dirs:
continue
if entry.is_dir():
# 判断要加载的文件夹深度
if self.max_depth is not None and depth >= self.max_depth:
return
# 循环加载
self._traverse_folder(entry.path, depth + 1)
# 判断是空文件夹
if not os.listdir(entry.path) and self.empty_folder_callback:
self.empty_folder_callback(entry)
# 文件夹处理的回调
if self.folder_callback:
self.folder_callback(entry)
elif entry.is_file():
if self.file_callback:
self.file_callback(entry)
def process_folder(self) -> None:
for path in self.paths:
if os.path.exists(path):
self._traverse_folder(path)
+40
View File
@@ -0,0 +1,40 @@
import os
import re
import shutil
from app.services import FolderService
from app.constants import IMAGE_EXTENSIONS, IGNORE_DIRS
class ImageService:
def __init__(self):
self.prefix_pattern = re.compile(r'(?i)[A-Z]+\.')
self.paths = ["C:\\Users\\Localhost\\Desktop\\images"]
self.image_root_folder = "C:\\Users\\Localhost\\Desktop\\images"
self.done_image = "C:\\Users\\Localhost\\Desktop\\BT\\done_images"
def export_callback(self, entry: os.DirEntry[str]) -> None:
name, ext = os.path.splitext(entry.name)
ext = ext.lower()
if ext in IMAGE_EXTENSIONS:
file_parent_dir = os.path.basename(os.path.dirname(entry.path))
dest_dir = os.path.join(self.image_root_folder, file_parent_dir)
os.makedirs(dest_dir, exist_ok=True)
dest_file = os.path.join(dest_dir, entry.name)
if os.path.exists(dest_file):
yield f"data: {entry.name} 已存在\n\n"
return
try:
shutil.move(entry.path, dest_file)
except shutil.Error as e:
yield f"data: 移动失败: {e}\n\n"
def export(self):
FolderService(self.paths, ignore_dirs=IGNORE_DIRS, file_callback=self.export_callback).process_folder()
# 去结尾字母,已使用过了
def remove_last_letter(self, entry: os.DirEntry[str]):
without_modifier_file_name = self.prefix_pattern.sub('.', entry.name)
os.rename(entry.path, os.path.join(os.path.dirname(entry.path), without_modifier_file_name))
+22
View File
@@ -0,0 +1,22 @@
import os
from typing import List
from app.services import FolderService
from app.utils import windows_sort_unicode
class MusicService:
def __init__(self):
self.paths: List[str] = ["C:\\Users\\Localhost\\Desktop\\Music"]
self.result_list: List[str] = []
def perform_rename(self, entry: os.DirEntry[str]):
name, ext = os.path.splitext(entry.name)
music_artist, music_name = name.split(" - ")
music_artist_list = music_artist.split("")
sorted_music_artist_list = windows_sort_unicode(music_artist_list)
new_music_artist = "".join(sorted_music_artist_list)
if music_artist != new_music_artist:
self.result_list.insert(0, f"{new_music_artist} - {music_name}")
def rename(self):
FolderService(paths=self.paths, ignore_dirs={"old", "专辑"}, file_callback=self.perform_rename).process_folder()
return self.result_list
+81
View File
@@ -0,0 +1,81 @@
import os
import re
from collections import defaultdict
from typing import List
from app.constants import IGNORE_DIRS
from app.services import FolderService
from app.utils import to_json_serializable
class TorrentService:
def __init__(self, paths: List[str]):
self.paths: List[str] = paths
self.torrent_root_folder = ["C:\\Users\\Localhost\\Desktop\\BT"]
self.prefix_pattern = re.compile(r'(?i)[A-Z]+\.')
self.torrent_list = defaultdict(set)
self.list_of_duplicate_torrent: List[str] = []
self.result_list = []
self.torrent_name = defaultdict(lambda: defaultdict(set))
def process_torrent_file(self, entry: os.DirEntry[str]) -> None:
temp_name = entry.name.replace("-4K", "")
without_modifier_file_name = self.prefix_pattern.sub('.', temp_name)
name, ext = os.path.splitext(without_modifier_file_name)
if name not in self.torrent_list:
self.torrent_list[name].add(os.path.basename(os.path.dirname(entry.path)))
else:
for torrent_path in self.torrent_list[name]:
if not os.path.exists(os.path.join(os.path.dirname(entry.path), f"{name}A{ext}")):
self.list_of_duplicate_torrent.insert(0, name)
print(f"data: {entry.name} 已有种子: {torrent_path}")
def generate_torrent_list(self):
folder_obj = FolderService(paths=self.paths, ignore_dirs=IGNORE_DIRS, file_callback=self.process_torrent_file)
folder_obj.process_folder()
return self.torrent_list
def deduplication(self):
self.generate_torrent_list()
return self.list_of_duplicate_torrent
def has_torrent_callback(self, entry: os.DirEntry):
temp_name = entry.name
temp_name = temp_name.replace("-4K", "")
without_modifier_file_name = self.prefix_pattern.sub('.', temp_name)
name, ext = os.path.splitext(without_modifier_file_name)
if name not in self.torrent_list:
self.result_list.insert(0, f"{os.path.basename(os.path.dirname(entry.path))} {entry.name} 该文件无种子")
def has_torrent(self):
FolderService(paths=self.torrent_root_folder, ignore_dirs=IGNORE_DIRS,
file_callback=self.process_torrent_file).process_folder()
FolderService(paths=self.paths, ignore_dirs=IGNORE_DIRS,
file_callback=self.has_torrent_callback).process_folder()
return self.result_list
def statistics_file_callback(self, entry: os.DirEntry):
path_arr = entry.path.split(os.sep)
torrent_sub_type = {"0_多人", "FWAY", "0_女同", "_temp"}
torrent_type = {"4k2", "4K原版", "456k", "1024", "2048", "done", "FC2", "hhd800", "other", "高清中文字幕",
"三级写真", "无码流出", "亚洲有码原创"}
if path_arr[-2] not in torrent_type and path_arr[-2] not in torrent_sub_type:
if path_arr[-3] in torrent_sub_type:
# print(path_arr[-3])
if path_arr[-4] in torrent_type:
# print(path_arr[-4])
pass
else:
print(f"有奇怪的东西混入{path_arr}")
pass
elif path_arr[-3] in torrent_type:
self.torrent_name[path_arr[-2]][path_arr[-3]].add(entry.name)
pass
else:
print(f"有奇怪的东西混入2{path_arr}")
def statistics(self):
FolderService(paths=self.paths,
ignore_dirs=IGNORE_DIRS,
file_callback=self.statistics_file_callback).process_folder()
return to_json_serializable(self.torrent_name)
+94
View File
@@ -0,0 +1,94 @@
import os
import re
import shutil
from collections import defaultdict
from app.constants import IGNORE_DIRS, TORRENT_ROOT_FOLDER
from app.services import FolderService
class VideoService:
def __init__(self, paths: list[str]):
self.paths: list[str] = paths
self.prefix_pattern = re.compile(r'(?i)[A-Z]+\.')
self.torrent_root_folder = "C:\\Users\\Localhost\\Desktop\\BT"
self.torrent_folder_depth = len(TORRENT_ROOT_FOLDER.split(os.sep))
self.new_paths: dict[str, dict[str, list[str]]] = defaultdict(dict)
self.new_paths_2: dict[str, list[str]] = defaultdict(dict)
self.current_folder = ""
self.result_list: list[str] = []
self.func_type = None
def process_torrent_file(self, entry: os.DirEntry):
name, ext = os.path.splitext(self.prefix_pattern.sub('.', entry.name))
if name not in self.new_paths[self.current_folder]:
self.new_paths[self.current_folder][name] = []
self.new_paths[self.current_folder][name].append(entry.path)
if name not in self.new_paths_2:
self.new_paths_2[name] = []
self.new_paths_2[name].append(entry.path)
# if self.func_type != "remove_deduplication":
# if ext != ".mp4":
# self.result_list.insert(0, f"重复文件:{name} {ext}")
# if len(self.new_paths[self.current_folder][name]) > 1:
# print(name, ext)
def get_init_folder(self, entry: os.DirEntry):
temp_path: list[str] = []
for path in self.paths:
if os.path.exists(path) and os.path.exists(os.path.join(path, entry.name)):
temp_path.append(os.path.join(path, entry.name))
self.current_folder = entry.name
FolderService(paths=temp_path, ignore_dirs=IGNORE_DIRS,
file_callback=self.process_torrent_file,
empty_folder_callback=self.remove_folder).process_folder()
if self.func_type != "remove_deduplication":
return
FolderService(paths=entry.path, ignore_dirs=IGNORE_DIRS,
file_callback=self.torrent_deduplication,
empty_folder_callback=self.remove_folder).process_folder()
def torrent_deduplication(self, entry: os.DirEntry):
without_modifier_file_name = self.prefix_pattern.sub('.', entry.name)
name, ext = os.path.splitext(without_modifier_file_name)
if name in self.new_paths[entry.path.split(os.sep)[self.torrent_folder_depth]]:
folder_arr = entry.path.split(os.sep)[self.torrent_folder_depth:-1]
folder_arr.insert(0, "done")
new_path = os.path.join(TORRENT_ROOT_FOLDER, *folder_arr)
print(os.path.join(new_path, entry.name))
os.makedirs(new_path, exist_ok=True)
try:
shutil.move(entry.path, os.path.join(new_path, entry.name))
self.result_list.insert(0, f"移动到文件夹:{os.path.join(new_path, entry.name)}")
except shutil.Error:
pass
def remove_folder(self, entry: os.DirEntry):
if entry.name == self.current_folder:
return
os.rmdir(entry.path)
self.result_list.insert(0, f"删除文件夹:{entry.path}")
def deduplication(self):
self.func_type = "remove_deduplication"
FolderService(paths=TORRENT_ROOT_FOLDER, ignore_dirs=IGNORE_DIRS | {"done"}, max_depth=1,
folder_callback=self.get_init_folder).process_folder()
return self.result_list
def find_deduplication(self):
self.func_type = "find_deduplication"
FolderService(paths=TORRENT_ROOT_FOLDER, ignore_dirs=IGNORE_DIRS | {"done"}, max_depth=1,
folder_callback=self.get_init_folder).process_folder()
for name in self.new_paths_2:
if len(self.new_paths_2[name]) > 1:
temp_name = []
for path_2 in self.new_paths_2[name]:
name_2 = os.path.splitext(os.path.basename(path_2))[0]
if name_2 not in temp_name:
temp_name.append(name_2)
else:
self.result_list.insert(0, name_2)
print(name_2)
if len(temp_name)>1 and name in temp_name:
self.result_list.insert(0, name)
return self.result_list
+14
View File
@@ -0,0 +1,14 @@
{
"mod": "forumdisplay",
"fid": "139",
"orderby": "dateline",
"baseUrl": "http://127.0.0.1:20000/",
"targetDate": "2026-03-23",
"targetCountdownPage": 998,
"totalPage": 999,
"privilegeLevel": 20,
"headers": {
"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_lastfp=079737cd852f396e59ea74a50e4ff9f1; cPNj_2132_saltkey=CeJLs8Ed; cPNj_2132_lastvisit=1726724743; _safe=h4uHX8t74U1uC89h; cPNj_2132_nofavfid=1; cPNj_2132_smile=1D1; cPNj_2132_secqaaqSARdM0=1286544.1fe30bcf776352790f; cPNj_2132_atarget=1; cPNj_2132__refer=%252Fhome.php%253Fmod%253Dspacecp%2526ac%253Dprofile%2526op%253Dpassword; cPNj_2132_auth=44bdzFfx4TYjBMhuKf4AZdi2JM%2BXZ0Okxd0JEl1Hmwbz%2Fr3WiX0sLmATmolWkLlRgorFz%2BULizz5o6G%2FJ3YGWYPF0xw; cPNj_2132_lastcheckfeed=508551%7C1726728824; cPNj_2132_lip=8.219.243.67%2C1726728824; cPNj_2132_secqaaqSAp9u0=1287401.84f80cf53671988988; cPNj_2132_home_diymode=1; cPNj_2132_sid=0; cPNj_2132_st_t=508551%7C1729318359%7C645e73a57f2807c1a85c994685fbe7b7; cPNj_2132_forum_lastvisit=D_151_1726729986D_36_1726732739D_95_1726751606D_103_1729304183D_139_1729318359; cPNj_2132_visitedfid=139D103D159D95D141D143D166D36D170D151; cPNj_2132_ulastactivity=1729318359%7C0; cf_clearance=wXzU4znYwA5CfAohAd3fPolFurbPcgWGacwTjhmMmQs-1729318361-1.2.1.1-YhnHHOA0XWfeDsF2TdvRrtWHzFTfFlZCf5l3u5AFdto2u16rpdDDZGgi6OkrKmJ6gw4bFgh1P1PcvBtfxw4wA3ME2BcVkNhYscsgDJ_V0Dgh91hgopC85KB4LlV2rcz_K0UL7NMyAgbhID4AC.eeLsjlPmxd_Xq8SJCDctTvfRimKb22bGEuyXA_bKL_k2X5lDLDJSXfjx3285g0X2WUQroJKymoOBHn0MBUYrnP1h_dyD7KzZYkj38J3_aiM22H_rtzKO3O2sD_fitnDRuM.BAJhdr2bYdrM_UXdJAj4IF3MY_BqlCyYHSX.AaVqOz3MyK7j7Dj_jv9PdThWwcA_kjRuK4hVmxoofcu._2tw5_EY28D2.0CDIgAbjidrIUW; cPNj_2132_st_p=508551%7C1729318366%7Cf7f4e3654f2de21700cdd0472289b3e2; cPNj_2132_viewid=tid_2380153; cPNj_2132_lastact=1729318373%09forum.php%09attachment"
}
}
+106
View File
@@ -0,0 +1,106 @@
import json
import time
import ctypes
from collections import defaultdict
from typing import List
from datetime import datetime
from functools import cmp_to_key
def windows_sort_unicode(items: List[str]) -> List[str]:
"""
使用Windows API实现字符串列表的排序。
该函数通过调用Windows的Shlwapi.dll库中的StrCmpLogicalW函数进行字符串的比较,以实现自然排序。
自然排序是指在排序数字和字母组合的字符串时,数字按照数值来比较,而不是按照字符的ASCII值来比较。
:param items: List[str] - 需要排序的字符串列表。
:return: List[str] - 排序后的字符串列表。
"""
shlwapi = ctypes.windll.LoadLibrary('Shlwapi.dll')
StrCmpLogicalW = shlwapi.StrCmpLogicalW
StrCmpLogicalW.argtypes = [ctypes.c_wchar_p, ctypes.c_wchar_p]
StrCmpLogicalW.restype = ctypes.c_int
def compare(a: str, b: str) -> int:
"""
比较两个字符串的函数,用于在排序时调用。
:param a: str - 第一个字符串。
:param b: str - 第二个字符串。
:return: int - StrCmpLogicalW函数的返回值,表示两个字符串的比较结果。
"""
return StrCmpLogicalW(str(a), str(b))
return sorted(items, key=cmp_to_key(compare))
def read_json_file(json_path): # 读取json文件
with open(json_path, 'r', encoding='utf-8') as file:
data = json.load(file)
return data
def write_json_file(json_path, data): # 写入json文件
with open(json_path, 'w') as file:
json.dump(data, file, sort_keys=False, indent=4)
def datetime_to_timestamp(date) -> int:
"""
日期转时间戳
:param date: 需要转换的日期
:return:
"""
return int(datetime.strptime(date, "%Y-%m-%d").timestamp())
def timestamp_to_datetime(timestamp):
"""
时间戳转日期
:param timestamp: 需要转换的时间戳
:return:
"""
return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d")
def get_today_timestamp():
"""
获取今日时间戳
:return:
"""
return datetime_to_timestamp(datetime.now().strftime("%Y-%m-%d"))
def show_delay_progress(delay_time: float) -> None:
"""在控制台显示延迟进度条"""
total_blocks = 30 # 进度条总长度
start_time = time.time()
end_time = start_time + delay_time
print(f"\n等待 {delay_time:.1f} 秒... [{' ' * total_blocks}]", end='', flush=True)
while time.time() < end_time:
elapsed = time.time() - start_time
progress = min(1.0, elapsed / delay_time)
filled = int(progress * total_blocks)
remaining = end_time - time.time()
print(f"\r等待 {delay_time:.1f} 秒... [{'=' * filled}>{' ' * (total_blocks - filled - 1)}]"
f" 剩余: {remaining:.1f}s", end='', flush=True)
time.sleep(0.1)
print("\r" + " " * 80 + "\r", end='', flush=True) # 清空进度行
def to_json_serializable(obj):
"""递归将 defaultdict 和 set 转换为 JSON 可序列化的类型"""
if isinstance(obj, defaultdict):
# 转为普通字典,并递归处理每个值
obj = dict(obj)
if isinstance(obj, dict):
return {k: to_json_serializable(v) for k, v in obj.items()}
if isinstance(obj, set):
return list(obj) # 集合转列表
# 其他基本类型(str, int, float, list, tuple, None)直接返回
return obj