从“待整理”到“全库清单”:一套可自进化的本地书籍整理脚本实践

从"待整理"到"全库清单":一套可自进化的本地书籍整理脚本实践

摘要

本篇分享一套用于本地电子书管理的脚本体系:把散落在 100、待整理书籍 的文件/文件夹,自动归档到既定分类目录,并在整理后生成"最新书籍清单(Excel/CSV)+ 树形阅读目录(TXT)"。它的核心不是一次性"分好类",而是通过"AI 批量分类 + 规则兜底 + 二次深度分类 + 规则学习"形成闭环,让系统越用越准、越用越省。

相关文件位于:e:\...\书籍\脚本文档

  • category_config.json
  • AI智能整理待分类书籍.py
  • AI二次分类与规则学习.py
  • 生成书籍总目录和清单.py

背景:为什么需要"自进化"的整理方式

很多人的电子书目录会经历三个阶段:

  1. 早期:文件少,靠人工拖拽分类。
  2. 中期:来源越来越多,出现一个巨大"待整理"目录。
  3. 后期:即使想整理,也会因为"成本高 + 分类体系不稳定 + 难以统计与查找"而放弃。

传统做法的痛点在于:

  • 只靠书名,很多书难分(尤其是短标题、系列名、资料合集)。
  • 完全依赖 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 是"系统大脑"

它包含三部分:

  1. taxonomy:一级分类 -> 子分类列表
  2. rules.keywords:关键词 -> 分类路径(一级/二级)
  3. 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. 阅读记录与清单\\书籍清单 生成日志与输出文件

常见问题

  1. 没有设置 API Key 能用吗?
    • 可以运行,但 AI 分类会直接失效,系统主要依赖规则兜底,很多书会进"其他类"。
  2. 为什么二次分类只支持 EPUB/TXT?
    • 因为它依赖"内容预览",PDF/MOBI/AZW3 的文本抽取需要额外库与更复杂的兼容处理。
  3. 清单里为什么会有"书籍文件夹"?
    • 因为打包移动会把多格式放入"书名文件夹",为了统计与检索,需要把它当成一本书条目。
  4. 规则越积越多会不会变慢?
    • 规则匹配当前是线性扫描 + 子串匹配;关键词数量非常大时可引入 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": {}
  }
}
相关推荐
人工智能培训2 小时前
如何持续、安全地向大模型注入新知识?
人工智能·python·算法·大模型·大模型学习·大模型应用工程师·大模型工程师证书
密瓜智能2 小时前
面向算力虚拟化的开源探索:如何看待 Flex:ai,以及为什么工程交付如此重要
人工智能·开源
产业家2 小时前
AI手机的终极猜想:超级Agent入口|产业深度
人工智能·智能手机
幻云20102 小时前
Python深度学习:筑基与实践
前端·javascript·vue.js·人工智能·python
沛沛老爹2 小时前
从Web到AI:多模态Agent Skills生态系统实战(Java+Vue构建跨模态智能体)
java·前端·vue.js·人工智能·rag·企业转型
晓晓不觉早2 小时前
OPE.Platform,轻量化AI能力拉满
人工智能·科技
瑞华丽PLM2 小时前
制造企业研发管理体系化升级与数字化转型落地建议书
人工智能·制造·plm·国产plm·瑞华丽plm·瑞华丽
许泽宇的技术分享2 小时前
当 AI Agent 遇上 .NET:一场关于智能体架构的技术探险
人工智能·架构·.net
2401_832298102 小时前
边缘AI协同架构,云服务器赋能端侧智能全域覆盖
人工智能