从"待整理"到"全库清单":一套可自进化的本地书籍整理脚本实践
摘要
本篇分享一套用于本地电子书管理的脚本体系:把散落在 100、待整理书籍 的文件/文件夹,自动归档到既定分类目录,并在整理后生成"最新书籍清单(Excel/CSV)+ 树形阅读目录(TXT)"。它的核心不是一次性"分好类",而是通过"AI 批量分类 + 规则兜底 + 二次深度分类 + 规则学习"形成闭环,让系统越用越准、越用越省。
相关文件位于:e:\...\书籍\脚本文档:
category_config.jsonAI智能整理待分类书籍.pyAI二次分类与规则学习.py生成书籍总目录和清单.py
背景:为什么需要"自进化"的整理方式
很多人的电子书目录会经历三个阶段:
- 早期:文件少,靠人工拖拽分类。
- 中期:来源越来越多,出现一个巨大"待整理"目录。
- 后期:即使想整理,也会因为"成本高 + 分类体系不稳定 + 难以统计与查找"而放弃。
传统做法的痛点在于:
- 只靠书名,很多书难分(尤其是短标题、系列名、资料合集)。
- 完全依赖 AI 分类会持续产生调用成本,且总有长尾难题。
- 完全依赖规则,规则维护成本极高,且覆盖不了长尾。
因此我们采用"AI 解决长尾 + 规则覆盖高频 + 二次学习把长尾转高频"的策略。
总体架构:三脚本一配置,围绕一个闭环
系统可以理解为:
- 一个配置中心 :
category_config.json(分类体系 + 关键词规则) - 一个编排入口 :
AI智能整理待分类书籍.py(一次整理,必要时触发二次整理与清单更新) - 一个学习引擎 :
AI二次分类与规则学习.py(只处理"其他类书籍",从内容学关键词并写回配置) - 一个报表器 :
生成书籍总目录和清单.py(全库扫描输出 Excel/CSV + TXT)
用一张序列图看它们的关系:
文件系统 生成书籍总目录和清单 AI二次分类与规则学习 豆包API AI智能整理待分类书籍 文件系统 生成书籍总目录和清单 AI二次分类与规则学习 豆包API AI智能整理待分类书籍 alt [仍有未能细分条目] 用户 运行脚本 1 扫描 100、待整理书籍 2 批量分类(书名) 3 分类结果(JSON) 4 移动/打包移动 5 检查 99. 其他类书籍 6 启动二次分类 7 读取内容预览(EPUB/TXT) 8 深度分类+提取关键词 9 分类+关键词(JSON) 10 移动到新分类 11 写回 category_config.json(规则学习) 12 更新全库清单与目录 13 扫描并输出报表 14 输出统计与日志 15 用户
配置文件:category_config.json 是"系统大脑"
它包含三部分:
taxonomy:一级分类 -> 子分类列表rules.keywords:关键词 -> 分类路径(一级/二级)meta:版本与更新时间
为什么要把分类体系抽成配置?
- 改目录结构不需要改代码。
- AI Prompt 能直接引用 taxonomy,强约束输出范围。
- 二次分类可以把"新领域"写回 taxonomy,让体系可演化。
关键词规则的价值是什么?
- 一次分类 AI 失败时还能分类。
- 二次分类把 AI 输出"蒸馏"为本地规则,下一次就不用再问 AI。
一次整理:AI智能整理待分类书籍.py 的关键点
这份脚本最关键的"成本控制"设计是:同名多格式只分类一次。
1)按书名分组,减少 API 调用
比如同时存在:
深入理解计算机系统.pdf深入理解计算机系统.epub
脚本会把它们归为同一组 book_name=深入理解计算机系统,只做一次分类,然后一起移动。
2)批量调用 AI,减少请求次数
脚本把书名按 BATCH_SIZE 分批发送给 AI,以"返回严格 JSON 数组"的方式让解析自动化。
3)流式输出 + 重试机制,提升稳定性与可观测性
- 流式输出让你能看到 AI 在生成什么。
- 记录首字延迟与总耗时,便于判断"网络问题还是模型慢"。
- 网络抖动时重试能显著减少失败率。
4)规则兜底,让系统永远不会"卡死"
当 AI 不可用或某些书名 AI 没返回时,脚本用 rules.keywords 做子串匹配,并采用"关键词越长越优先"的排序策略,降低歧义。
5)移动策略:单文件 vs 多格式打包文件夹
- 组内只有 1 个条目:直接移动,重名则追加时间戳。
- 组内多个条目:在目标分类下创建"书名文件夹",把多格式文件打包进去。
这让分类目录更整洁,且天然避免"多格式散落"。
二次分类:只把 AI 用在真正需要它的地方
二次分类脚本的输入不是书名,而是:
- 书名
- 内容预览(优先 EPUB/TXT)
这样 AI 能从内容中识别学科、主题与写作类型,给出更可靠的分类。更重要的是:它会输出 关键词 ,并写回 category_config.json。
规则学习策略偏保守:
- 新关键词:直接新增。
- 已存在关键词:若一级分类不同视为强冲突,保守跳过。
- 若一级相同但新路径更细(补充二级分类),允许优化更新。
一句话总结:宁可少学,也不要学错。
清单生成:把"书库"变成可检索的数据
生成书籍总目录和清单.py 会扫描 taxonomy 中的所有分类目录:
- 识别书籍文件(多后缀集合)
- 识别"书籍文件夹"(目录内包含书籍文件)
- 去重键为:
书名 + 分类 + 子分类 - 输出:
- Excel(有 pandas)或 CSV(无依赖降级)
- 树形目录 TXT(含分类统计、占用空间统计)
这一步的价值在于:
- 你终于知道自己有多少书、每类多少、总占多少硬盘空间。
- "最新"清单文件名固定,便于其他脚本或人工复用。
如何运行(最小可行方式)
1)准备 Python 与依赖
最小依赖:
bash
pip install requests
想输出 Excel:
bash
pip install pandas openpyxl
2)设置环境变量(推荐)
VOLC_API_KEY:火山引擎密钥DOUBAO_MODEL_ID:可选
3)开始整理
把书籍放入 100、待整理书籍,运行:
bash
python 脚本文档\\AI智能整理待分类书籍.py
整理后脚本会:
- 必要时执行二次分类
- 自动更新全库清单与树形目录
- 在
0. 阅读记录与清单\\书籍清单生成日志与输出文件
常见问题
- 没有设置 API Key 能用吗?
- 可以运行,但 AI 分类会直接失效,系统主要依赖规则兜底,很多书会进"其他类"。
- 为什么二次分类只支持 EPUB/TXT?
- 因为它依赖"内容预览",PDF/MOBI/AZW3 的文本抽取需要额外库与更复杂的兼容处理。
- 清单里为什么会有"书籍文件夹"?
- 因为打包移动会把多格式放入"书名文件夹",为了统计与检索,需要把它当成一本书条目。
- 规则越积越多会不会变慢?
- 规则匹配当前是线性扫描 + 子串匹配;关键词数量非常大时可引入 Trie/Aho-Corasick 做优化,但多数个人书库规模下足够。
结语:让系统越用越省
这套脚本的关键不是"AI 很强",而是:
- AI 用来解决长尾
- 规则用来覆盖高频
- 二次分类把长尾沉淀为规则
当规则库逐渐完善时,你会发现:AI 调用次数减少,分类速度更快,整理成本越来越低。
AI二次分类与规则学习.py
python
# -*- coding: utf-8 -*-
"""
书籍二次智能分类与规则学习脚本
功能:
1. 扫描 "99. 其他类书籍" 下的未分类书籍。
2. 读取书籍内容预览(前N个字符)。
3. 调用豆包大模型API,根据内容进行深度分类,并提取关键词。
4. 自动更新 category_config.json 中的规则库。
5. 将书籍移动到正确分类。
"""
import os
import shutil
import json
import time
import requests
import zipfile
import re
from datetime import datetime
# ================= 配置区域 =================
# 火山引擎/豆包 API 配置(建议使用环境变量注入密钥,避免在脚本里明文存放)
AI_CONFIG = {
# 优先读取 VOLC_API_KEY,其次兼容 ARK_API_KEY
"API_KEY": os.environ.get("VOLC_API_KEY") or os.environ.get("ARK_API_KEY") or "",
"BASE_URL": "https://ark.cn-beijing.volces.com/api/v3",
# 允许通过环境变量覆盖模型ID,便于后续切换
"MODEL_ID": os.environ.get("DOUBAO_MODEL_ID") or "doubao-seed-1-8-251228",
"TIMEOUT": 120,
"BATCH_SIZE": 30 # 针对二次分类,批量小一点更稳
}
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
ROOT_DIR = os.path.dirname(CURRENT_DIR) # e:\...\书籍
CATEGORY_CONFIG_PATH = os.path.join(CURRENT_DIR, "category_config.json")
# 待处理目录
TARGET_DIR = os.path.join(ROOT_DIR, "99. 其他类书籍")
# 预览字符数限制 (给到AI的)
PREVIEW_LIMIT = 500
# 读取文件时的缓冲限制 (为了能截取到有效的100字符,读取时稍微多读一点,比如1000)
READ_BUFFER_LIMIT = 1000
# 提取关键词数量限制
EXTRACT_KEYWORDS_LIMIT = 1
# ================= 工具类 =================
class Logger:
"""简易日志器:同时输出到控制台与日志文件。"""
def __init__(self):
# 确保日志目录存在
log_dir = os.path.join(ROOT_DIR, "0. 阅读记录与清单", "书籍清单")
if not os.path.exists(log_dir):
os.makedirs(log_dir)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
self.log_file = os.path.join(log_dir, f"AI二次分类日志_{timestamp}.txt")
print(f"[系统] 日志将保存至: {self.log_file}")
def _write(self, level, msg):
"""内部写入方法:统一格式化并写入。"""
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
formatted_msg = f"[{timestamp}] [{level}] {msg}"
print(formatted_msg)
try:
with open(self.log_file, 'a', encoding='utf-8') as f:
f.write(formatted_msg + "\n")
except Exception:
pass
def info(self, msg):
"""输出 INFO 级别日志。"""
self._write("INFO", msg)
def error(self, msg):
"""输出 ERROR 级别日志。"""
self._write("ERROR", msg)
def warning(self, msg):
"""输出 WARN 级别日志。"""
self._write("WARN", msg)
class ConfigManager:
"""配置管理器:读取和更新规则"""
def __init__(self):
"""加载 category_config.json(不存在/损坏则使用最小默认结构)。"""
self.config_path = CATEGORY_CONFIG_PATH
self.data = self._load()
def _load(self):
"""读取 JSON 配置文件。"""
if not os.path.exists(self.config_path):
return {"taxonomy": {}, "rules": {"keywords": {}}}
try:
with open(self.config_path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
print(f"配置文件读取失败: {e}")
return {"taxonomy": {}, "rules": {"keywords": {}}}
def get_taxonomy(self):
"""返回分类体系 taxonomy。"""
return self.data.get("taxonomy", {})
def add_keywords(self, new_keywords):
"""
批量添加关键词
new_keywords: { "keyword": ["Category", "Subcategory"], ... }
"""
if "rules" not in self.data:
self.data["rules"] = {}
if "keywords" not in self.data["rules"]:
self.data["rules"]["keywords"] = {}
updated = False
current_kws = self.data["rules"]["keywords"]
# 规则学习策略:
# - 新关键词:直接新增
# - 已存在关键词:若一级分类不同则视为冲突并跳过;若一级相同且新路径更细(含子类)则补全
for kw, path in new_keywords.items():
if kw not in current_kws:
current_kws[kw] = path
print(f" [规则学习] 新增关键词: '{kw}' -> {path}")
updated = True
else:
# 检查冲突
existing_path = current_kws[kw]
# 比较 path (cat, sub) 是否一致
# existing_path 可能是 ["Cat"] 或 ["Cat", "Sub"]
# path 也一样
# 简单比较:
# - 只要一级分类不同:认为强冲突(同一关键词被映射到不同大类),保守跳过
# - 一级相同但二级不同:可能是多义或子类扩展,不做强冲突处理
if existing_path[0] != path[0]:
print(f" [规则冲突警告] 关键词 '{kw}' 已存在且分类不同!")
print(f" -> 现有: {existing_path}")
print(f" -> 新增: {path}")
print(f" -> 策略: 跳过更新,保留现有规则。")
else:
# 分类相同,检查是否需要补充子分类
if len(path) > len(existing_path):
current_kws[kw] = path
print(f" [规则优化] 关键词 '{kw}' 补充子分类: {existing_path} -> {path}")
updated = True
if updated:
self._save()
def add_category(self, category, subcategories=None):
"""
新增分类
category: "一级分类名" (例如: "11. 新分类")
subcategories: ["子分类1", "子分类2"]
"""
if "taxonomy" not in self.data:
self.data["taxonomy"] = {}
taxonomy = self.data["taxonomy"]
# 分类扩展策略:
# - 一级分类不存在:直接新增
# - 一级分类存在:将新子类并入(集合合并)
if category not in taxonomy:
taxonomy[category] = subcategories if subcategories else []
print(f" [分类扩展] 新增一级分类: '{category}'")
self._save()
else:
# 如果一级分类已存在,检查是否需要合并子分类
if subcategories:
existing_subs = set(taxonomy[category])
new_subs = set(subcategories)
if not new_subs.issubset(existing_subs):
taxonomy[category] = list(existing_subs.union(new_subs))
print(f" [分类扩展] 更新分类 '{category}',新增子分类: {new_subs - existing_subs}")
self._save()
def _save(self):
"""写回 category_config.json,并更新 meta 信息。"""
self.data["meta"] = {
"version": "2.1",
"updated_at": datetime.now().strftime("%Y-%m-%d")
}
try:
with open(self.config_path, 'w', encoding='utf-8') as f:
json.dump(self.data, f, ensure_ascii=False, indent=2)
print(" [配置保存] category_config.json 已更新")
except Exception as e:
print(f" [保存失败] {e}")
class ContentScanner:
"""内容扫描器 (复用之前的逻辑)"""
def __init__(self, logger):
"""依赖注入 logger,便于统一输出。"""
self.logger = logger
def read_preview(self, path, limit=READ_BUFFER_LIMIT):
"""读取书籍内容预览。
说明:
- 入参可以是文件或文件夹。
- 若是文件夹,会优先挑选可读格式(epub/txt)的候选文件。
- 当前仅支持读取 .epub 与 .txt,其余格式返回空字符串。
"""
if os.path.isdir(path):
found_path = self._find_book_in_dir(path)
if found_path:
path = found_path
else:
return ""
self.logger.info(f"正在读取内容: {os.path.basename(path)} ...")
ext = os.path.splitext(path)[1].lower()
try:
if ext == '.epub':
return self._read_epub(path, limit)
elif ext == '.txt':
return self._read_txt(path, limit)
return "" # 其他格式暂不支持内容读取
except Exception:
return ""
def _find_book_in_dir(self, dir_path):
"""在文件夹里寻找一本最适合读取预览的文件。
策略:
- 候选格式:epub/azw3/mobi/pdf/txt
- 预览优先:epub、txt(可直接读取文本)
- 若没有优先格式,则回退到任意候选的第一个
"""
valid_exts = ['.epub', '.azw3', '.mobi', '.pdf', '.txt']
preferred_exts = ['.epub', '.txt']
candidates = []
for root, _, files in os.walk(dir_path):
for f in files:
if os.path.splitext(f)[1].lower() in valid_exts:
candidates.append(os.path.join(root, f))
for p in candidates:
if os.path.splitext(p)[1].lower() in preferred_exts: return p
return candidates[0] if candidates else None
def _read_epub(self, path, limit):
"""读取 epub 的若干章节/页面内容并抽取纯文本。
做法:
- 直接把 epub 当 zip 读取其中的 html/xhtml
- 优先读取可能包含版权页/前言/封面/标题等信息的文件
- 去掉 style/script,再去掉 HTML 标签,压缩空白
"""
text_content = []
try:
with zipfile.ZipFile(path, 'r') as z:
html_files = [n for n in z.namelist() if n.endswith(('.html', '.xhtml', '.htm'))]
# 优先读取版权页、前言等
infos = []
for name in html_files:
lower = name.lower()
p = 0
if any(x in lower for x in ['copyright', 'intro', 'preface', 'title', 'cover']): p = 2
elif not any(x in lower for x in ['toc', 'style', 'css']): p = 1
infos.append((p, name))
infos.sort(key=lambda x: x[0], reverse=True)
candidate_files = [x[1] for x in infos[:10]] # 取前10个
for html_file in candidate_files:
with z.open(html_file) as f:
raw = f.read().decode('utf-8', errors='ignore')
# 清理掉样式与脚本,减少噪声
raw = re.sub(r'<(style|script)[^>]*>.*?</\1>', '', raw, flags=re.IGNORECASE | re.DOTALL)
# 粗略去标签(足够用于分类预览,不追求排版还原)
text = re.sub(r'<[^>]+>', ' ', raw)
text = re.sub(r'\s+', ' ', text).strip()
if len(text) > 20: text_content.append(text)
if len("".join(text_content)) > limit: break
except Exception:
return ""
return "".join(text_content)[:limit]
def _read_txt(self, path, limit):
"""读取 txt 前 limit 字符,尝试多种常见编码以提高兼容性。"""
for enc in ['utf-8', 'gb18030', 'gbk', 'big5']:
try:
with open(path, 'r', encoding=enc) as f: return f.read(limit)
except: continue
return ""
class AIClassifier:
"""AI分类与规则提取器"""
def __init__(self, logger, config_manager):
"""缓存 taxonomy,避免每次请求都重新读取配置。"""
self.logger = logger
self.taxonomy = config_manager.get_taxonomy()
def classify_and_learn(self, books_info):
"""
books_info: [{"name": "书名", "content": "预览内容...", "path": "..."}]
返回: 包含分类结果和提取关键词的字典
"""
if not AI_CONFIG["API_KEY"]:
self.logger.error("未配置 API KEY")
return {}
url = f"{AI_CONFIG['BASE_URL']}/chat/completions"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {AI_CONFIG['API_KEY']}"
}
# 构造精简的 Prompt 数据:
# - 只传"书名 + 内容预览",避免把文件路径等噪声信息传给模型
# - 内容预览做截断,减少 token 与调用成本
items_to_send = []
for b in books_info:
# 截断内容以防 Token 超限
content_preview = b['content'][:PREVIEW_LIMIT] if b['content'] else "无内容预览"
items_to_send.append({
"name": b['name'],
"content_preview": content_preview
})
taxonomy_str = json.dumps(self.taxonomy, ensure_ascii=False)
items_str = json.dumps(items_to_send, ensure_ascii=False)
# 提示词尽量结构化,要求模型返回严格 JSON,便于解析与自动化更新配置
prompt = f"""
你是一个书籍分类专家。请根据书名和内容预览,对以下书籍进行分类,并提取能代表该书分类特征的关键词(用于后续规则匹配)。
【分类体系】:
{taxonomy_str}
【待处理书籍】:
{items_str}
【要求】:
1. 返回一个 JSON 数组。
2. 每个元素包含:
- "name": 书名
- "category": 一级分类 (必须完全匹配分类体系的键。如果现有分类体系都不合适,你可以根据书籍内容**创造一个新的合理分类**,格式必须是 "数字. 分类名",例如 "11. 新兴技术类书籍")
- "subcategory": 二级分类 (如果是现有分类,必须匹配;如果现有子分类不满足,你可以**在现有的一级分类下创造新的子分类**;如果是新一级分类,请创造合理的二级分类)
- "keywords": [ "关键词1", "关键词2" ] (提取**最多{EXTRACT_KEYWORDS_LIMIT}个**能准确指向该分类的关键词,词不要太通用,也不要太长)
- "reason": 分类理由
- "is_new_category": true/false (标记是否是你新创造的一级分类)
- "is_new_subcategory": true/false (标记是否是你新创造的子分类)
"""
data = {
"model": AI_CONFIG["MODEL_ID"],
"messages": [
{"role": "system", "content": "你是一个智能图书管理员。"},
{"role": "user", "content": prompt}
],
"temperature": 0.1
}
try:
self.logger.info("正在调用 AI 进行深度分析...")
response = requests.post(url, headers=headers, json=data, timeout=AI_CONFIG["TIMEOUT"])
if response.status_code == 200:
res_json = response.json()
content = res_json['choices'][0]['message']['content']
# 清理 Markdown 围栏,避免模型输出 ```json 包裹导致解析失败
content = content.replace("```json", "").replace("```", "").strip()
return json.loads(content)
else:
self.logger.error(f"AI 调用失败: {response.text}")
return []
except Exception as e:
self.logger.error(f"AI 请求异常: {e}")
return []
class FileMover:
"""文件移动器"""
def __init__(self, logger):
"""依赖注入 logger,便于统一输出。"""
self.logger = logger
def move_book(self, item_path, book_name, category, subcategory):
"""将待整理条目移动到目标分类目录。
item_path 可能是文件或文件夹:
- 文件:直接移动
- 文件夹:整体移动(保持内部结构)
"""
try:
# 1. 构建目标路径
target_dir = os.path.join(ROOT_DIR, category)
if subcategory:
target_dir = os.path.join(target_dir, subcategory)
if not os.path.exists(target_dir):
os.makedirs(target_dir)
# 2. 目标文件/文件夹路径
# 注意:item_path 可能是文件也可能是文件夹
is_dir = os.path.isdir(item_path)
basename = os.path.basename(item_path)
target_path = os.path.join(target_dir, basename)
# 3. 处理重名
if os.path.exists(target_path):
name, ext = os.path.splitext(basename)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
new_name = f"{name}_{timestamp}{ext}"
target_path = os.path.join(target_dir, new_name)
self.logger.info(f" [重名处理] 重命名为: {new_name}")
# 4. 移动
shutil.move(item_path, target_path)
self.logger.info(f"✅ 成功移动: {book_name} -> {category}/{subcategory}")
return True
except Exception as e:
self.logger.error(f"移动失败 {book_name}: {e}")
return False
# ================= 主流程 =================
def run_secondary_classification(parent_logger=None):
"""
二次分类主入口
:param parent_logger: 传入主程序的logger,实现日志统一
"""
if parent_logger:
logger = parent_logger
else:
logger = Logger()
logger.info("="*50)
logger.info("开始执行:书籍二次分类与规则学习")
logger.info(f"目标目录: {TARGET_DIR}")
logger.info("="*50)
config_mgr = ConfigManager()
scanner = ContentScanner(logger)
ai_classifier = AIClassifier(logger, config_mgr)
mover = FileMover(logger)
if not os.path.exists(TARGET_DIR):
logger.error("目标目录不存在")
return
# 1) 扫描待处理书籍:读取文件名与内容预览,组装为 AI 批量请求输入
if not os.listdir(TARGET_DIR):
logger.info("目标文件夹为空,无需执行。")
return
raw_items = os.listdir(TARGET_DIR)
books_to_process = []
for item in raw_items:
item_path = os.path.join(TARGET_DIR, item)
# 简单过滤系统文件
if item.startswith('.'): continue
book_name = os.path.splitext(item)[0]
# 读取内容(用于二次分类的"深度理解")
logger.info(f"正在读取内容: {book_name} ...")
content = scanner.read_preview(item_path, limit=READ_BUFFER_LIMIT)
# 打印内容摘要(前100字):便于人工排查"为何会被这样分类"
if content:
preview_snippet = content[:100].replace('\n', ' ').strip()
logger.info(f" -> 内容预览: {preview_snippet}...")
else:
logger.warning(f" -> 未读取到有效内容")
books_to_process.append({
"name": book_name,
"path": item_path,
"content": content
})
total_books = len(books_to_process)
logger.info(f"共找到 {total_books} 本待处理书籍(二次分类)。")
if total_books == 0:
return
# 2) 分批调用 AI:避免一次性传太多文本导致超时/超长
batch_size = AI_CONFIG["BATCH_SIZE"]
for i in range(0, total_books, batch_size):
batch = books_to_process[i : i + batch_size]
logger.info(f"处理批次 {i//batch_size + 1} (共 {len(batch)} 本)...")
# 调用 AI
ai_results = ai_classifier.classify_and_learn(batch)
# 处理结果:
# - 新分类/新子类:写回 taxonomy
# - 关键词:写回 rules.keywords
# - 文件:移动到目标目录
new_keywords_map = {} # { "kw": ["Cat", "Sub"] }
# 将结果转为字典方便查找
result_map = {res['name']: res for res in ai_results}
for book in batch:
res = result_map.get(book['name'])
if not res:
logger.warning(f"AI 未返回书籍 '{book['name']}' 的结果")
continue
cat = res.get('category')
sub = res.get('subcategory', "")
keywords = res.get('keywords', [])
reason = res.get('reason', "")
is_new_cat = res.get('is_new_category', False)
is_new_sub = res.get('is_new_subcategory', False)
# 限制关键词数量 (双重保险)
keywords = keywords[:EXTRACT_KEYWORDS_LIMIT]
# 校验分类是否有效:若仍在"其他类"或空值,则跳过移动与规则学习
if cat == "99. 其他类书籍" or not cat:
logger.warning(f"书籍 '{book['name']}' 仍无法分类。")
continue
# 如果是新一级分类,或者现有分类下的新子分类,则扩展 taxonomy
if is_new_cat or is_new_sub:
if is_new_cat:
logger.info(f" [🚀 发现新分类] AI 建议新增一级分类: {cat} / {sub}")
if is_new_sub:
logger.info(f" [🚀 发现新子类] AI 建议在 '{cat}' 下新增子类: {sub}")
config_mgr.add_category(cat, [sub] if sub else [])
else:
logger.info(f" [复用分类] 使用现有分类体系: {cat} / {sub}")
logger.info(f"书籍: 《{book['name']}》")
logger.info(f" -> 分类: {cat} / {sub}")
logger.info(f" -> 理由: {reason}")
logger.info(f" -> 提取关键词: {keywords}")
# 收集关键词:后续可用于"书名/内容关键词 -> 分类"快速匹配,减少 AI 调用
for kw in keywords:
if kw and len(kw) > 1: # 忽略单字
new_keywords_map[kw] = [cat, sub] if sub else [cat]
# 移动文件/文件夹到目标分类
mover.move_book(book['path'], book['name'], cat, sub)
# 3) 更新规则库:批次结束后统一写回,减少频繁写文件
if new_keywords_map:
config_mgr.add_keywords(new_keywords_map)
if not parent_logger:
logger.info("="*50)
logger.info("任务完成。")
def main():
"""脚本入口:直接执行二次分类流程。"""
run_secondary_classification()
if __name__ == "__main__":
main()
AI智能整理待分类书籍.py
python
# -*- coding: utf-8 -*-
"""
AI智能书籍整理脚本 (批量优化版)
功能:
1. 扫描"待整理书籍"目录(支持文件和文件夹)。
2. 批量调用火山引擎豆包大模型API进行智能分类(减少API调用次数)。
3. 如果AI分类失败,降级使用关键词规则分类。
4. 移动书籍到目标分类目录。
5. 日志统一保存在"0. 阅读记录与清单\书籍清单"下。
"""
import os
import shutil
import logging
import json
import time
import requests
from datetime import datetime
import difflib
import zipfile
import re
import runpy
try:
import pandas as pd
except ImportError:
pd = None
try:
import openpyxl
except ImportError:
pass
# ================= 配置区域 =================
# 火山引擎 API 配置
AI_CONFIG = {
"API_KEY": os.environ.get("VOLC_API_KEY") or os.environ.get("ARK_API_KEY") or "",
"BASE_URL": "https://ark.cn-beijing.volces.com/api/v3",
"MODEL_ID": os.environ.get("DOUBAO_MODEL_ID") or "doubao-seed-1-8-251228",
"TIMEOUT": 100,
"BATCH_SIZE": 50,
"MAX_RETRIES": 3,
}
# 路径配置
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
ROOT_DIR = os.path.dirname(CURRENT_DIR) # e:\...\书籍
CATEGORY_CONFIG_PATH = os.path.join(CURRENT_DIR, "category_config.json")
PATHS = {
"ROOT": ROOT_DIR,
"PENDING": os.path.join(ROOT_DIR, "100、待整理书籍"),
"LOGS": os.path.join(ROOT_DIR, "0. 阅读记录与清单", "书籍清单")
}
def load_category_config():
"""读取外部 JSON 配置文件。
约定的配置结构:
- taxonomy: {"一级分类": ["子分类1", "子分类2", ...]}
- rules.keywords: {"关键词": ["一级分类", "二级分类"?]}
设计目标:
- 配置不存在或损坏时,仍能使用默认结构继续运行(保证脚本可用)。
"""
default_config = {
"taxonomy": {
"99. 其他类书籍": []
},
"rules": {
"keywords": {}
}
}
if not os.path.exists(CATEGORY_CONFIG_PATH):
print(f"警告: 配置文件 {CATEGORY_CONFIG_PATH} 不存在,将使用默认空配置。")
return default_config
try:
with open(CATEGORY_CONFIG_PATH, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
print(f"错误: 读取配置文件失败: {e}")
return default_config
# ===========================================
class Logger:
"""日志管理类"""
def __init__(self):
"""初始化日志输出:同时写文件与控制台。"""
if not os.path.exists(PATHS["LOGS"]):
os.makedirs(PATHS["LOGS"])
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
self.log_file = os.path.join(PATHS["LOGS"], f"AI整理日志_{timestamp}.txt")
self.logger = logging.getLogger("BookOrganizer")
self.logger.setLevel(logging.INFO)
# 文件处理器:持久化日志,便于回溯
fh = logging.FileHandler(self.log_file, encoding='utf-8')
fh.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
self.logger.addHandler(fh)
# 控制台处理器:实时输出到终端
ch = logging.StreamHandler()
ch.setFormatter(logging.Formatter('%(message)s'))
self.logger.addHandler(ch)
def info(self, msg):
"""输出 INFO 级别日志。"""
self.logger.info(msg)
def error(self, msg):
"""输出 ERROR 级别日志。"""
self.logger.error(msg)
def warning(self, msg):
"""输出 WARN 级别日志。"""
self.logger.warning(msg)
def get_log_path(self):
"""返回当前日志文件路径。"""
return self.log_file
class AIBookClassifier:
"""智能分类器 (AI批量 + 规则兜底)"""
def __init__(self, logger):
"""加载分类体系与关键词规则,供后续分类复用。"""
self.logger = logger
# 加载外部配置
config = load_category_config()
self.categories = config.get("taxonomy", {})
# 关键词匹配规则
# JSON结构: "keyword": ["Category", "Subcategory"]
self.keywords = config.get("rules", {}).get("keywords", {})
def classify_batch(self, book_names):
"""批量分类入口。
返回结构:
results[书名] = ((category, subcategory, suggestion), "来源")
说明:
- 优先走 AI 批量分类,降低 API 调用次数
- AI 没返回的条目(或 AI 调用失败),走关键词规则兜底
"""
results = {}
# 1. 尝试 AI 批量分类
ai_results = self._classify_batch_with_ai(book_names)
# 2. 处理结果,缺失的走规则
for name in book_names:
if ai_results and name in ai_results:
# ai_results[name] 是 (cat, sub, suggestion)
results[name] = (ai_results[name], "AI智能")
else:
rule_res = self._classify_with_rule(name)
# 规则分类没有建议,补空字符串
# 统一格式:info = (category, subcategory, suggestion)
cat, sub = rule_res
results[name] = ((cat, sub, ""), "规则匹配")
return results
def _classify_batch_with_ai(self, book_names):
"""调用豆包 API 进行批量分类(重试机制 + 流式输出)。
返回:
- 成功:{书名: (category, subcategory, suggestion)}
- 失败:None
说明:
- 这里使用 stream=True 获取增量输出,最终仍需拼接成完整 JSON 再解析。
"""
if not AI_CONFIG["API_KEY"] or not AI_CONFIG["MODEL_ID"]:
return None
url = f"{AI_CONFIG['BASE_URL']}/chat/completions"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {AI_CONFIG['API_KEY']}"
}
# 构建 Prompt:强约束输出为 JSON,便于自动解析
category_desc = json.dumps(self.categories, ensure_ascii=False)
book_list_str = json.dumps(book_names, ensure_ascii=False)
prompt = f"""
你是一个专业的图书管理员。请根据以下书名列表,将每本书归类到分类体系中:
【分类体系】(必须严格使用以下键名作为category,注意数字后没有空格):
{category_desc}
待分类书名列表:
{book_list_str}
要求:
1. 返回且仅返回一个JSON数组。
2. 数组中每个对象包含:
- "name": 原书名 (必须完全一致)
- "category": 一级分类 (必须严格匹配上述【分类体系】中的键名,例如 "1.社科类书籍",绝不能省略数字序号,且数字后无空格)
- "subcategory": 二级分类 (必须完全匹配给定的列表项,若无则填"")
- "reason": 简要说明分类理由
- "suggestion": 如果归入"99. 其他类书籍",请在此给出你认为合理的分类建议。
3. 如果无法确定,category 填 "99. 其他类书籍"。
"""
data = {
"model": AI_CONFIG["MODEL_ID"],
"messages": [
{"role": "system", "content": "你是一个精准的书籍分类助手。"},
{"role": "user", "content": prompt}
],
"temperature": 0.1,
"stream": True, # 开启流式输出
"thinking": {"type": "disabled"}
}
# 增加重试机制:网络波动/偶发超时可以通过重试恢复
max_retries = 3
for attempt in range(max_retries):
try:
self.logger.info(f"正在调用AI API (第 {attempt + 1}/{max_retries} 次尝试)...")
start_time = time.time() # 记录开始时间
response = requests.post(url, headers=headers, json=data, timeout=AI_CONFIG["TIMEOUT"], stream=True)
if response.status_code == 200:
full_content = ""
first_token_time = None
self.logger.info("连接建立成功,等待首字返回...")
# 逐行读取流式响应:服务端使用 SSE 风格返回 data: {...}
for line in response.iter_lines():
if line:
if first_token_time is None:
first_token_time = time.time()
latency = first_token_time - start_time
self.logger.info(f"首字响应耗时: {latency:.2f}秒")
decoded_line = line.decode('utf-8')
if decoded_line.startswith('data: '):
data_str = decoded_line[6:]
if data_str == '[DONE]':
break
try:
chunk = json.loads(data_str)
if 'choices' in chunk and len(chunk['choices']) > 0:
delta = chunk['choices'][0].get('delta', {})
if 'content' in delta:
content_piece = delta['content']
full_content += content_piece
# 实时流式输出到控制台(避免日志文件被大量碎片化内容污染)
print(content_piece, end="", flush=True)
except json.JSONDecodeError:
continue
print() # 换行
total_time = time.time() - start_time
self.logger.info(f"AI生成完成,总耗时: {total_time:.2f}秒")
# 将完整的AI响应内容写入日志,方便后续排查
self.logger.info(f"AI原始响应内容:\n{full_content}")
# 解析完整 JSON:去掉模型可能包裹的 ```json 围栏
content = full_content.replace("```json", "").replace("```", "").strip()
try:
result_list = json.loads(content)
parsed_results = {}
for item in result_list:
name = item.get("name")
cat = item.get("category")
sub = item.get("subcategory")
suggestion = item.get("suggestion", "")
# 校验分类有效性
# 增加模糊匹配逻辑:如果AI漏掉了序号(例如返回"社科类书籍"),尝试自动修正为"1. 社科类书籍"
valid_cat = None
if cat in self.categories:
valid_cat = cat
else:
# 尝试修复:比如AI返回 "社科类书籍",自动匹配 "1.社科类书籍"
for defined_cat in self.categories.keys():
if cat in defined_cat: # "社科类书籍" in "1.社科类书籍"
valid_cat = defined_cat
break
if valid_cat:
cat = valid_cat # 修正为带序号的标准名称
if sub and sub not in self.categories[cat]:
sub = ""
parsed_results[name] = (cat, sub, suggestion)
else:
parsed_results[name] = ("99. 其他类书籍", "", suggestion)
return parsed_results
except json.JSONDecodeError:
self.logger.error("AI返回的JSON格式解析失败")
continue
else:
self.logger.info(f"AI API调用失败: {response.status_code} - {response.text}")
if 400 <= response.status_code < 500:
break
except requests.exceptions.Timeout:
self.logger.warning(f"AI API请求超时 (已等待 {AI_CONFIG['TIMEOUT']} 秒)")
except requests.exceptions.RequestException as e:
self.logger.warning(f"AI API请求异常: {e}")
if attempt < max_retries - 1:
time.sleep(2)
self.logger.error("AI API调用最终失败,将降级使用规则分类。")
return None
def _classify_with_rule(self, book_name):
"""基于关键词的规则分类。
规则来源:category_config.json -> rules.keywords
匹配策略:按关键词长度降序匹配,优先命中更具体的长词。
"""
name_lower = book_name.lower()
# 按关键词长度降序排序,优先匹配长词
# 兼容处理:JSON可能是列表或元组
sorted_keywords = sorted(self.keywords.items(), key=lambda x: len(x[0]), reverse=True)
for kw, info in sorted_keywords:
if kw.lower() in name_lower:
if len(info) >= 2:
cat, sub = info[0], info[1]
else:
cat, sub = info[0], ""
# 细节规避:
# 某些"X传"容易被误判到社科,且"传"字多义;这里做一个保守跳过
if '传' in name_lower and cat == "1. 社科类书籍" and ('自传' in name_lower or '传' not in name_lower[-2:]):
continue
return cat, sub
return "99. 其他类书籍", ""
def _read_epub(self, path, limit):
"""从 epub 中抽取一段可用于主题判断的文本预览。
epub 本质是 zip,内含 HTML/XHTML。这里做基础清理:
- 优先读取可能包含封面/前言/版权/简介等信息的页面
- 去 style/script、去 HTML 标签、压缩空白
"""
text_content = []
try:
with zipfile.ZipFile(path, 'r') as z:
# 过滤掉常见的非正文文件
html_files = [n for n in z.namelist() if n.endswith(('.html', '.xhtml', '.htm'))]
# 策略优化:
# 1. 优先读取可能包含版权信息、简介的文件 (文件名含 cover, title, copyright, intro)
# 2. 其次读取正文(按大小排序)
infos = []
for name in html_files:
lower_name = name.lower()
# 提高版权页和简介页的权重
is_meta = any(x in lower_name for x in ['copyright', 'intro', 'preface', 'title', 'cover'])
is_bad = any(x in lower_name for x in ['toc', 'style', 'css', 'image'])
priority = 0
if is_meta: priority = 2
elif not is_bad: priority = 1
infos.append({
"name": name,
"size": z.getinfo(name).file_size,
"priority": priority
})
# 按优先级降序,同优先级按大小降序
infos.sort(key=lambda x: (x['priority'], x['size']), reverse=True)
# 取前5个文件,这样既能覆盖版权页,也能读到正文开头
candidate_files = [x['name'] for x in infos[:5]]
for html_file in candidate_files:
with z.open(html_file) as f:
raw = f.read().decode('utf-8', errors='ignore')
# 1. 去除 <style>...</style> 和 <script>...</script>
raw = re.sub(r'<(style|script)[^>]*>.*?</\1>', '', raw, flags=re.IGNORECASE | re.DOTALL)
# 2. 去除 HTML 标签
text = re.sub(r'<[^>]+>', ' ', raw)
# 3. 去除多余空白
text = re.sub(r'\s+', ' ', text).strip()
if len(text) > 20: # 稍微放宽长度限制,以免漏掉短版权声明
text_content.append(text)
if len("".join(text_content)) > limit:
break
except Exception:
return ""
return "".join(text_content)[:limit]
class BookOrganizer:
"""整理执行器"""
def __init__(self):
"""初始化:日志、分类器、统计信息与 AI 建议容器。"""
self.logger = Logger()
self.classifier = AIBookClassifier(self.logger)
self.stats = {"moved": 0, "skipped": 0, "error": 0, "unique_books": 0}
self.ai_suggestions = [] # 收集AI的分类建议
def run(self):
"""主流程:扫描 -> 批量分类 -> 移动 -> 可选二次分类 -> 可选更新清单。"""
self.logger.info("===== 开始AI智能整理待分类书籍 (批量版) =====")
self.logger.info(f"待整理目录: {PATHS['PENDING']}")
self.logger.info(f"批处理大小: {AI_CONFIG['BATCH_SIZE']}")
if not os.path.exists(PATHS["PENDING"]):
self.logger.info(f"待整理目录不存在,正在自动创建: {PATHS['PENDING']}")
os.makedirs(PATHS["PENDING"])
# 1) 扫描所有待处理项并按"书名"分组
# 目的:同名多格式(pdf/epub/mobi...)只做一次分类,再一起移动
book_groups = {} # { "book_name": [item1, item2] }
raw_items = os.listdir(PATHS["PENDING"])
if not raw_items:
self.logger.info("待整理目录为空。")
return
# 重置建议列表,防止多次运行时残留(虽然目前是单次运行,但为了逻辑严谨)
self.ai_suggestions = []
for item in raw_items:
item_path = os.path.join(PATHS["PENDING"], item)
item_data = None
if os.path.isfile(item_path):
ext = os.path.splitext(item)[1].lower()
if ext in ['.epub', '.azw3', '.mobi', '.pdf', '.txt']:
book_name = os.path.splitext(item)[0]
item_data = {"type": "file", "name": item, "path": item_path, "book_name": book_name}
elif os.path.isdir(item_path):
# 针对特定模糊命名的文件夹(如"加餐"),提取其下一级的书籍单独处理
if item == "加餐":
self.logger.info(f" [深入扫描] 发现特殊文件夹 '{item}',正在提取内部书籍...")
inner_books = self._scan_inner_books(item_path)
for b_name, b_path in inner_books:
b_key = os.path.splitext(b_name)[0]
b_data = {"type": "file", "name": b_name, "path": b_path, "book_name": b_key}
if b_key not in book_groups:
book_groups[b_key] = []
book_groups[b_key].append(b_data)
continue # 跳过该文件夹本身的添加
book_name = item
item_data = {"type": "dir", "name": item, "path": item_path, "book_name": book_name}
if item_data:
if book_name not in book_groups:
book_groups[book_name] = []
book_groups[book_name].append(item_data)
self.logger.info(f"共扫描到 {len(book_groups)} 组待整理书籍")
# 2) 分批处理:控制一次请求的书名数量,兼顾稳定性与效率
batch_size = AI_CONFIG["BATCH_SIZE"]
# unique_book_names 已经是去重后的书名列表(因为 book_groups 的 key 就是书名)
# 扫描阶段已经做了按书名分组:book_groups[b_key] = [file1, file2...]
unique_book_names = list(book_groups.keys())
self.logger.info(f"待分类书籍(去重后): {len(unique_book_names)} 本")
has_success = False # 标记是否有书籍成功移动
for i in range(0, len(unique_book_names), batch_size):
batch_names = unique_book_names[i : i + batch_size]
if self._process_batch(batch_names, book_groups):
has_success = True
self.logger.info("===== 整理完成 =====")
self.logger.info(f"成功移动文件数: {self.stats['moved']}")
self.logger.info(f"有效书籍数(去重): {self.stats['unique_books']}")
self.logger.info(f"跳过文件数: {self.stats['skipped']}")
self.logger.info(f"错误数: {self.stats['error']}")
# 输出AI建议汇总
if self.ai_suggestions:
self.logger.info("\n" + "="*30)
self.logger.info("【AI分类建议汇总】(请根据建议优化分类体系)")
for suggestion in self.ai_suggestions:
self.logger.info(f"• {suggestion}")
self.logger.info("="*30 + "\n")
# ==========================================
# 集成:AI二次分类与规则学习
# 逻辑:若本轮整理后"99. 其他类书籍"里仍有文件,则尝试读取内容做深度分类,
# 并把关键词学习进 category_config.json,提升后续规则命中率。
other_books_dir = os.path.join(PATHS["ROOT"], "99. 其他类书籍")
# 简单检查该目录下是否有非隐藏文件
has_other_files = False
if os.path.exists(other_books_dir):
for f in os.listdir(other_books_dir):
if not f.startswith('.'):
has_other_files = True
break
if has_other_files:
self.logger.info("\n" + "="*50)
self.logger.info("检测到 '99. 其他类书籍' 中存在未分类书籍,正在启动二次智能分类...")
self.logger.info("="*50 + "\n")
try:
# 导入同目录下的模块
# 注意:模块名含中文,Python 3 通常支持直接 import;如失败则 fallback 用 importlib
try:
from AI二次分类与规则学习 import run_secondary_classification
# 传入当前的 logger,实现日志统一
run_secondary_classification(parent_logger=self.logger)
# 标记为有变动,以便更新目录
has_success = True
except ImportError as ie:
self.logger.error(f"导入二次分类模块失败: {ie}")
# 尝试 fallback 方式 (针对特殊环境)
import importlib.util
spec = importlib.util.spec_from_file_location("AI二次分类与规则学习", os.path.join(CURRENT_DIR, "AI二次分类与规则学习.py"))
if spec and spec.loader:
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
module.run_secondary_classification(parent_logger=self.logger)
has_success = True
except Exception as e:
self.logger.error(f"运行二次分类流程失败: {e}")
self.logger.info("\n" + "="*50)
self.logger.info("二次分类流程结束,继续后续处理...")
self.logger.info("="*50 + "\n")
# ==========================================
# 3) 只有当有书籍成功移动时,才自动生成最新的书籍清单
if has_success:
self.logger.info("===== 检测到文件变动,开始更新书籍总清单 =====")
try:
import io
import sys
from contextlib import redirect_stdout
# 复用"生成书籍总目录和清单.py"的 main():
# - 通过临时覆盖 sys.argv 模拟 CLI 参数
# - 用 redirect_stdout 捕获脚本的 print 输出并写入日志
argv_bak = list(sys.argv)
try:
sys.argv = ["生成书籍总目录和清单", "--root", PATHS["ROOT"]]
buf = io.StringIO()
with redirect_stdout(buf):
try:
from 生成书籍总目录和清单 import main as generate_book_catalog_main
generate_book_catalog_main()
except Exception:
import importlib.util
module_path = os.path.join(CURRENT_DIR, "生成书籍总目录和清单.py")
spec = importlib.util.spec_from_file_location("生成书籍总目录和清单", module_path)
if not spec or not spec.loader:
raise
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
module.main()
out = buf.getvalue().strip()
if out:
for line in out.splitlines():
self.logger.info(line)
finally:
sys.argv = argv_bak
except Exception as e:
self.logger.error(f"生成书籍清单失败: {e}")
else:
self.logger.info("未移动任何书籍,跳过清单更新。")
self.logger.info(f"日志已保存至: {self.logger.get_log_path()}")
def _process_batch(self, batch_names, book_groups):
"""处理一个批次,返回是否有成功移动。
步骤:
- 批量分类得到每本书的分类
- 根据同名条目数量,选择"打包移动"或"单文件移动"
- 记录 AI 建议与统计信息
"""
self.logger.info(f"正在批量分类 {len(batch_names)} 本书籍...")
batch_success = False
# 调用批量分类
results = self.classifier.classify_batch(batch_names)
# 执行移动
for book_name in batch_names:
if book_name in results:
info, method = results[book_name]
if len(info) == 3:
category, subcategory, suggestion = info
else:
category, subcategory = info
suggestion = ""
# 记录无法分类的警告
if category == "99. 其他类书籍":
msg = f"[分类警告] 书籍 '{book_name}' 被归入 '其他类书籍'。原因:{method}未能识别到具体领域特征。"
if suggestion:
msg += f" AI建议归类为:【{suggestion}】"
# 收集建议
self.ai_suggestions.append(f"书籍: 《{book_name}》 -> 建议分类: {suggestion}")
self.logger.info(msg)
# 移动策略:
# 如果该书名下有超过1个文件(即多格式同名书),则创建一个以书名命名的文件夹包裹它们
# 否则直接移动文件
items = book_groups[book_name]
item_moved = False
if len(items) > 1:
# 创建专属文件夹移动模式
if self._move_items_to_folder(items, book_name, category, subcategory, method):
item_moved = True
else:
# 单文件直接移动模式
if self._move_item(items[0], category, subcategory, method):
item_moved = True
if item_moved:
batch_success = True
self.stats["unique_books"] += 1
else:
self.logger.error(f"未找到书籍 {book_name} 的分类结果")
self.stats["error"] += 1
return batch_success
def _move_items_to_folder(self, items, book_name, category, subcategory, method):
"""将多个同名文件移动到以书名命名的文件夹中。
场景:一本书同时存在 epub/pdf/mobi 等多个格式。
策略:在目标分类目录下创建书名文件夹,把多个文件打包放入。
"""
try:
# 1. 构建目标父目录
parent_dir = os.path.join(PATHS["ROOT"], category)
if subcategory:
parent_dir = os.path.join(parent_dir, subcategory)
if not os.path.exists(parent_dir):
os.makedirs(parent_dir)
# 2. 构建专属书籍文件夹路径
book_dir_path = os.path.join(parent_dir, book_name)
# 处理文件夹重名
if os.path.exists(book_dir_path):
# 如果已存在同名文件夹,直接合并进去
self.logger.info(f" [合并] 目标文件夹已存在,将合并至: {book_name}")
else:
os.makedirs(book_dir_path)
# 3. 逐个移动文件到该文件夹内
move_count = 0
for item in items:
src_path = item["path"]
file_name = item["name"]
dst_path = os.path.join(book_dir_path, file_name)
# 处理文件重名
if os.path.exists(dst_path):
name, ext = os.path.splitext(file_name)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
new_name = f"{name}_{timestamp}{ext}"
dst_path = os.path.join(book_dir_path, new_name)
self.logger.info(f" [重名处理] 文件重名,重命名为: {new_name}")
shutil.move(src_path, dst_path)
move_count += 1
self.logger.info(f" [{method}] {len(items)} 个文件打包移动至 -> {category}/{subcategory}/{book_name}/")
self.stats["moved"] += move_count
return True
except Exception as e:
self.logger.error(f"打包移动 {book_name} 失败: {e}")
self.stats["error"] += 1
return False
def _scan_inner_books(self, dir_path):
"""递归扫描文件夹内所有书籍文件。
用途:处理像"加餐"这类不代表真实书名的容器文件夹。
返回:[(文件名, 文件路径), ...]
"""
books = []
valid_exts = ['.epub', '.azw3', '.mobi', '.pdf', '.txt']
for root, _, files in os.walk(dir_path):
for f in files:
if os.path.splitext(f)[1].lower() in valid_exts:
books.append((f, os.path.join(root, f)))
return books
def _move_item(self, item, category, subcategory, method):
"""移动单个文件/目录到目标分类,返回是否成功。
说明:
- 若目标已存在同名文件,则追加时间戳避免覆盖。
- 统计 moved/error 用于最终汇总。
"""
try:
# 构建目标路径
target_dir = os.path.join(PATHS["ROOT"], category)
if subcategory:
target_dir = os.path.join(target_dir, subcategory)
if not os.path.exists(target_dir):
os.makedirs(target_dir)
item_name = item["name"]
target_path = os.path.join(target_dir, item_name)
# 处理重名
if os.path.exists(target_path):
# 恢复重命名逻辑:添加时间戳 (格式:_20231024_120000)
name, ext = os.path.splitext(item_name)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
new_name = f"{name}_{timestamp}{ext}"
target_path = os.path.join(target_dir, new_name)
self.logger.info(f" [重名处理] 目标已存在,重命名为: {new_name}")
shutil.move(item["path"], target_path)
self.logger.info(f" [{method}] {item_name} -> {category}/{subcategory}")
self.stats["moved"] += 1
return True
except Exception as e:
self.logger.error(f"移动 {item['name']} 失败: {e}")
self.stats["error"] += 1
return False
class BookCatalogGenerator:
"""
书籍目录生成器 (集成版)
功能:扫描所有书籍,生成去重后的Excel清单和文本目录
"""
def __init__(self, root_dir, logger):
"""初始化目录生成器。
说明:该类属于"集成版清单生成",目标与独立脚本
`生成书籍总目录和清单.py` 一致,但实现更轻量。
"""
self.root_dir = root_dir
self.logger = logger
self.output_dir = os.path.join(root_dir, "0. 阅读记录与清单", "书籍清单")
# 确保输出目录存在
if not os.path.exists(self.output_dir):
os.makedirs(self.output_dir)
# 加载分类目录结构
config = load_category_config()
self.directories = config.get("taxonomy", {})
# 确保包含必要的系统级目录
if "99. 其他类书籍" not in self.directories:
self.directories["99. 其他类书籍"] = []
# 动态获取待整理目录名称
pending_dirname = os.path.basename(PATHS["PENDING"])
if pending_dirname not in self.directories:
self.directories[pending_dirname] = []
def generate_catalog(self):
"""生成目录主逻辑:扫描 -> 去重 -> 输出 Excel/TXT。"""
books_data = self._scan_all_books()
if not books_data:
self.logger.info("未扫描到任何书籍,跳过清单生成。")
return
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
# 1. 生成Excel清单
excel_path = os.path.join(self.output_dir, f"书籍总清单_{timestamp}.xlsx")
self._generate_excel(books_data, excel_path)
# 2. 生成文本目录
txt_path = os.path.join(self.output_dir, f"阅读书籍目录_{timestamp}.txt")
self._generate_txt(books_data, txt_path)
def _scan_all_books(self):
"""扫描所有分类目录。
覆盖两类条目:
- 直接书籍文件(pdf/epub/mobi/azw3/txt/doc/docx)
- 书籍文件夹(内部包含至少一个书籍文件)
"""
books_data = []
valid_exts = ['.pdf', '.epub', '.mobi', '.azw3', '.txt', '.doc', '.docx']
for category, subcategories in self.directories.items():
cat_path = os.path.join(self.root_dir, category)
if not os.path.exists(cat_path):
continue
# 遍历一级目录
for item in os.listdir(cat_path):
item_path = os.path.join(cat_path, item)
# 忽略隐藏文件
if item.startswith('.'): continue
# 情况A: 直接是书籍文件
if os.path.isfile(item_path):
ext = os.path.splitext(item)[1].lower()
if ext in valid_exts:
books_data.append(self._make_book_info(item, category, "", item_path))
# 情况B: 是子文件夹
elif os.path.isdir(item_path):
# B1: 是预定义的子分类目录
if item in subcategories:
sub_cat = item
sub_cat_path = os.path.join(cat_path, sub_cat) # 修正路径变量
# 扫描子分类下的书籍
for sub_item in os.listdir(sub_cat_path):
sub_item_path = os.path.join(sub_cat_path, sub_item)
# 情况B1-1: 子分类下直接是书籍文件
if os.path.isfile(sub_item_path):
ext = os.path.splitext(sub_item)[1].lower()
if ext in valid_exts:
books_data.append(self._make_book_info(sub_item, category, sub_cat, sub_item_path))
# 情况B1-2: 子分类下是书籍文件夹(新增逻辑,对应"打包移动")
elif os.path.isdir(sub_item_path):
# 扫描该文件夹内的所有书籍
if self._dir_contains_book(sub_item_path, valid_exts):
# 将文件夹名作为书名,或者扫描内部文件
# 这里简化处理:将文件夹本身作为一个条目,或者深入扫描
# 策略:如果文件夹内有书,将文件夹名视为书名
books_data.append(self._make_book_info(sub_item, category, sub_cat, sub_item_path))
# B2: 是书籍文件夹 (直接归类到一级)
else:
# 简单判断:如果不是子分类目录,就当作书籍文件夹处理
# 但这里需要再次确认是否包含书籍文件
if self._dir_contains_book(item_path, valid_exts):
books_data.append(self._make_book_info(item, category, "", item_path))
return books_data
def _dir_contains_book(self, dir_path, valid_exts):
"""判断文件夹内是否包含书籍文件(用于识别"书籍文件夹")。"""
for root, _, files in os.walk(dir_path):
for f in files:
if os.path.splitext(f)[1].lower() in valid_exts:
return True
return False
def _make_book_info(self, name, category, subcategory, path):
"""构建书籍信息字典。
说明:
- 文件:书名取去后缀,大小为文件大小
- 文件夹:书名取文件夹名,大小暂不递归统计(保持轻量)
"""
# 如果是文件,去掉后缀作为书名;如果是文件夹,直接用文件夹名
if os.path.isfile(path):
book_name = os.path.splitext(name)[0]
size = os.path.getsize(path)
mtime = os.path.getmtime(path)
else:
book_name = name
size = 0 # 简化处理,暂不计算文件夹总大小
mtime = os.path.getmtime(path)
return {
"书名": book_name,
"分类": category,
"子分类": subcategory,
"路径": path,
"大小(MB)": round(size / 1024 / 1024, 2),
"修改时间": datetime.fromtimestamp(mtime).strftime('%Y-%m-%d %H:%M:%S')
}
def _generate_excel(self, books_data, path):
"""生成 Excel 清单并去重。"""
try:
# 检查 pandas 是否可用
try:
import pandas as pd
except ImportError:
self.logger.error("未安装 pandas 库,无法生成 Excel 清单。请运行 'pip install pandas openpyxl' 安装。")
return
df = pd.DataFrame(books_data)
# 去重核心逻辑:书名+分类+子分类 相同则视为重复,保留第一个
df.drop_duplicates(subset=['书名', '分类', '子分类'], keep='first', inplace=True)
# 排序
df.sort_values(by=['分类', '子分类', '书名'], inplace=True)
# 导出
df.to_excel(path, index=False)
self.logger.info(f"Excel清单已生成: {path} (共 {len(df)} 本)")
except Exception as e:
self.logger.error(f"Excel生成失败: {e}")
def _generate_txt(self, books_data, path):
"""生成文本目录并去重(树形结构 + 分类统计)。"""
try:
# 内存去重
unique_books = {} # key: f"{cat}|{sub}|{name}"
for b in books_data:
key = f"{b['分类']}|{b['子分类']}|{b['书名']}"
if key not in unique_books:
unique_books[key] = b
# 构建树形结构并统计
tree = {}
category_counts = {} # { "分类名": 数量 }
total_count = 0
for b in unique_books.values():
cat = b['分类']
sub = b['子分类']
name = b['书名']
if cat not in tree: tree[cat] = {}
if sub not in tree[cat]: tree[cat][sub] = []
tree[cat][sub].append(name)
# 统计
category_counts[cat] = category_counts.get(cat, 0) + 1
total_count += 1
# 写入文件
with open(path, 'w', encoding='utf-8') as f:
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
f.write(f"===== 书籍阅读目录 =====\n")
f.write(f"生成时间: {timestamp}\n")
f.write(f"书籍总数: {total_count} 本 (已去重)\n")
f.write("-" * 30 + "\n")
f.write("【分类统计概览】\n")
# 输出统计摘要
sorted_cats = sorted(category_counts.keys())
for cat in sorted_cats:
count = category_counts[cat]
f.write(f"• {cat}: {count} 本\n")
f.write("-" * 30 + "\n\n")
# 输出详细目录
for cat in sorted_cats:
count = category_counts[cat]
f.write(f"【{cat}】(共 {count} 本)\n")
for sub in sorted(tree[cat].keys()):
books = sorted(tree[cat][sub])
prefix = f" ├─ {sub}" if sub else " ├─ [未分类]"
f.write(f"{prefix}\n")
for book in books:
f.write(f" │ └─ {book}\n")
f.write("\n")
self.logger.info(f"文本目录已生成: {path}")
except Exception as e:
self.logger.error(f"文本目录生成失败: {e}")
if __name__ == "__main__":
# 依赖自检:requests 是最小必需依赖
try:
import requests
except ImportError:
print("缺少 requests 库,正在安装...")
os.system("pip install requests")
import requests
organizer = BookOrganizer()
organizer.run()
# Windows 友好:运行结束后暂停,避免双击运行时窗口一闪而过
print("\n按任意键退出...")
os.system("pause >nul")
category_config.json
json
{
"meta": {
"version": "2.1",
"updated_at": "2026-01-20"
},
"taxonomy": {
"1. 社科类书籍": [
"心理学",
"历史",
"哲学",
"经济学",
"社会学",
"政治学",
"法学",
"教育学",
"女性主义",
"其他社科"
],
"2. 文学类书籍": [
"小说",
"散文随笔",
"诗歌",
"传记",
"戏剧",
"文学理论",
"绘本漫画",
"其他文学"
],
"3. 科技类书籍": [
"计算机与编程",
"人工智能",
"数据科学",
"科学普及",
"数学",
"物理",
"化学",
"生物",
"其他科技"
],
"4. 教辅类书籍": [
"学习方法",
"考试资料",
"语言学习",
"技能培训",
"职场进阶",
"其他教辅"
],
"5. 工具类书籍": [
"词典字典",
"百科全书",
"手册指南",
"参考资料",
"其他工具"
],
"6. 艺术类书籍": [
"摄影",
"设计",
"音乐",
"绘画",
"建筑",
"影视",
"书法",
"其他艺术"
],
"7. 健康类书籍": [
"饮食营养",
"心理健康",
"运动健身",
"医学科普",
"养生保健",
"其他健康"
],
"8. 投资理财类书籍": [
"投资技巧",
"金融知识",
"理财规划",
"经济学",
"其他理财"
],
"9. 生活时尚类书籍": [
"整理收纳",
"生活方式",
"萌宠",
"美食烹饪",
"旅游旅行",
"时尚美妆",
"其他生活"
],
"10. 期刊杂志类书籍": [
"时政新闻",
"商业财经",
"文学文摘",
"科技数码",
"综合期刊"
],
"99. 其他类书籍": []
},
"rules": {
"keywords": {
"计算机": [
"3. 科技类书籍",
"计算机与编程"
],
"编程": [
"3. 科技类书籍",
"计算机与编程"
],
"算法": [
"3. 科技类书籍",
"计算机与编程"
],
"Python": [
"3. 科技类书籍",
"计算机与编程"
],
"Java": [
"3. 科技类书籍",
"计算机与编程"
],
"小说": [
"2. 文学类书籍",
"小说"
],
"传": [
"2. 文学类书籍",
"传记"
],
"诗": [
"2. 文学类书籍",
"诗歌"
],
"历史": [
"1. 社科类书籍",
"历史"
],
"中国史": [
"1. 社科类书籍",
"历史"
],
"世界史": [
"1. 社科类书籍",
"历史"
],
"心理": [
"1. 社科类书籍",
"心理学"
],
"经济": [
"1. 社科类书籍",
"经济学"
],
"投资": [
"8. 投资理财类书籍",
"投资技巧"
],
"理财": [
"8. 投资理财类书籍",
"理财规划"
],
"股票": [
"8. 投资理财类书籍",
"投资技巧"
],
"基金": [
"8. 投资理财类书籍",
"投资技巧"
],
"健康": [
"7. 健康类书籍",
"养生保健"
],
"饮食": [
"7. 健康类书籍",
"饮食营养"
],
"菜谱": [
"9. 生活时尚类书籍",
"美食烹饪"
],
"整理": [
"9. 生活时尚类书籍",
"整理收纳"
],
"收纳": [
"9. 生活时尚类书籍",
"整理收纳"
],
"断舍离": [
"9. 生活时尚类书籍",
"整理收纳"
],
"猫": [
"9. 生活时尚类书籍",
"萌宠"
],
"狗": [
"9. 生活时尚类书籍",
"萌宠"
],
"周刊": [
"10. 期刊杂志类书籍",
"综合期刊"
],
"月刊": [
"10. 期刊杂志类书籍",
"综合期刊"
],
"杂志": [
"10. 期刊杂志类书籍",
"综合期刊"
],
"读者": [
"10. 期刊杂志类书籍",
"文学文摘"
],
"财新": [
"10. 期刊杂志类书籍",
"商业财经"
],
"漫画": [
"2. 文学类书籍",
"绘本漫画"
],
"绘本": [
"2. 文学类书籍",
"绘本漫画"
],
"潜意识": [
"1. 社科类书籍",
"心理学"
],
"心理暗示": [
"1. 社科类书籍",
"心理学"
],
"未来日记": [
"1. 社科类书籍",
"心理学"
],
"约翰·博格": [
"2. 文学类书籍",
"传记"
],
"投资传记": [
"2. 文学类书籍",
"传记"
],
"金融人物": [
"2. 文学类书籍",
"传记"
],
"黄河源": [
"2. 文学类书籍",
"散文随笔"
],
"自然纪实": [
"2. 文学类书籍",
"散文随笔"
],
"阿来": [
"2. 文学类书籍",
"传记"
],
"科幻小说": [
"2. 文学类书籍",
"小说"
],
"宇宙探索": [
"2. 文学类书籍",
"小说"
],
"罗伯特·里德": [
"2. 文学类书籍",
"小说"
],
"奥克塔维娅": [
"2. 文学类书籍",
"小说"
],
"人生叙事": [
"2. 文学类书籍",
"小说"
],
"神秘装置": [
"2. 文学类书籍",
"小说"
],
"虚构叙事": [
"2. 文学类书籍",
"小说"
],
"J.O.摩根": [
"2. 文学类书籍",
"小说"
],
"自传": [
"2. 文学类书籍",
"传记"
],
"底层人生": [
"2. 文学类书籍",
"传记"
],
"科幻": [
"2. 文学类书籍",
"小说"
],
"文学创作": [
"2. 文学类书籍",
"其他文学"
],
"文学研究": [
"2. 文学类书籍",
"其他文学"
],
"创业感悟": [
"4. 教辅类书籍",
"职场进阶"
],
"个人成长": [
"4. 教辅类书籍",
"职场进阶"
],
"投资人物传记": [
"2. 文学类书籍",
"传记"
],
"江河纪实": [
"2. 文学类书籍",
"散文随笔"
]
},
"books": {}
}
}