Files
openclaw-config/skill-workshop/proposals/calibre-cleaner-20260624-0fc374efd7/rollback.json
T
2026-07-09 18:00:04 +08:00

2 lines
9.4 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"schema":"openclaw.skill-workshop.rollback.v1","proposalId":"calibre-cleaner-20260624-0fc374efd7","writtenAt":"2026-07-09T09:56:20.398Z","targetSkillFile":"/home/yangxuan/.openclaw/workspace/storage/skills/calibre-cleaner/SKILL.md","action":"update","previousContent":"---\nname: \"calibre-cleaner\"\ndescription: \"清洗 Calibre 书库:修复损坏书名/提取作者/检测编码损坏、小文件的批量删除\"\n---\n\n# Calibre 书库清洗 Skill\n\n## 概述\n\nCalibre 书库整理三件套:\n\n1. **书名清洗** —— 修复从 txt 导入产生的 HTML 标签/长文本/乱码书名\n2. **作者提取** —— 从书名中自动提取作者名(匹配 `作者:xxx`、`-xxx`、`BY xxx` 等格式)\n3. **脏数据清理** —— 检测并删除内容损坏的 txt(中文 < 10%)以及小于 10KB 的垃圾文件\n\n## 前置条件\n\n- 书库路径:`/home/yangxuan/calibre/books`\n- 元数据文件:`/home/yangxuan/calibre/books/metadata.db`\n- 运行前须停止其他 Calibre 进程(桌面版、Docker Calibre-Web\n- 运行前 **必须备份** `metadata.db`\n\n---\n\n## 一、书名清洗 & 作者提取\n\n### `shorten(s, max_bytes=120)`\n\n将字符串截断到指定字节数,避免因 UTF-8 中文字符跨字节而损坏。\n\n```python\ndef shorten(s, max_bytes=120):\n encoded = s.encode('utf-8')\n if len(encoded) <= max_bytes:\n return s\n try:\n return encoded[:max_bytes].decode('utf-8')\n except:\n return encoded[:max_bytes-3].decode('utf-8', errors='ignore')\n```\n\n### `clean_title(title)`\n\n```python\ndef clean_title(title):\n t = title\n # 移除 HTML 标签\n t = re.sub(r'<[^>]+>', '', t)\n # 替换 _BR_ / _BR 标记\n t = re.sub(r'_BR_', ' ', t, flags=re.IGNORECASE)\n t = re.sub(r'_BR(?![a-zA-Z])', ' ', t)\n # 移除 _fontxxx_ / _imgxxx_ 标记\n t = re.sub(r'_font[^_]*_', '', t)\n t = re.sub(r'_img[^_]*_', '', t)\n # 合并多余空格\n t = re.sub(r'\\s+', ' ', t).strip()\n # 超长截短\n if len(t) > 100:\n m = re.search(r'[\\u4e00-\\u9fff]{2,}', t)\n if m:\n t = t[m.start():]\n t = t[:60].rstrip(',。;:、!?…\\ufffd\\'\"').rstrip('.,;:!?\\'').strip()\n # 移除控制字符\n t = re.sub(r'[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f]', '', t)\n return shorten(t, 100).strip() or 'Unknown'\n```\n\n### 作者提取规则(从书名中提取)\n\n格式 | 示例 | 正则\n---|---|---\n`作者:xxx`(中文冒号) | `...作者:镜欲` | `作者[:]\\s*([^\\s,,、\\)]+)`\n`作者:xxx`(英文冒号) | `...作者:镜欲` | 同上\n`-xxx`(英文 dash 结尾) | `善良的美艳妈妈-糠麸` | `-([^-()【】\\[\\]]+)$`\n`翻译:xxx` | `...翻译:kkmanlg` | `翻译[:]\\s*([^\\s,,、\\)]+)`\n`BY xxx`(英文 BY | `...BY狱厨` | `\\b[Bb][Yy]\\s+([^\\s,,、\\)]+)`\n`&xxx`and 符结尾) | `...&axenic` | `&([^&()【】\\[\\] ]+)$`\n\n提取策略:先匹配 `作者:` → `-xxx` → `翻译:` → `BY` → `&xxx`。排除项:`正文`、`内容简介`、`第\\\\d+[章节]`。\n\n```python\nEXCLUDE_AUTHORS = {'正文', '内容简介', ''}\n\ndef extract_author_from_title(title):\n m = re.search(r'作者[:]\\s*([^\\s,,、\\))《\\[]+)', title)\n if m:\n name = m.group(1).strip()\n if name not in EXCLUDE_AUTHORS and not re.match(r'^第\\d+[章节]', name):\n return name\n m = re.search(r'翻译[:]\\s*([^\\s,,、\\))《\\[]+)', title)\n if m:\n return m.group(1).strip()\n m = re.search(r'\\b[Bb][Yy]\\s+([^\\s,,、\\))《\\[]+)', title)\n if m:\n name = m.group(1).strip()\n if name not in EXCLUDE_AUTHORS:\n return name\n m = re.search(r'-([^-()【】\\[\\]{}《》\\s]{2,30})$', title)\n if m:\n name = m.group(1).strip()\n if name not in EXCLUDE_AUTHORS and not re.match(r'^\\d+', name):\n return name\n m = re.search(r'&([^&()【】\\[\\]\\s]{2,30})$', title)\n if m:\n name = m.group(1).strip()\n if name not in EXCLUDE_AUTHORS:\n return name\n return None\n```\n\n### 坏书检测条件\n\n```python\nis_bad = (\n len(title) > 100 or\n '<img' in title or '<BR' in title or '<font' in title or\n '\\ufffd' in title or\n len(title.encode('utf-8')) > 150\n)\n```\n\n---\n\n## 二、脏数据清理\n\n### 2.1 检测条件\n\n扫描书库中所有 TXT 文件,读取前 20KB 检测中文比例:\n\n- **内容损坏/二进制垃圾**:中文比例 < 10%(标记为待删除)\n- **小文件垃圾**`uncompressed_size < 10240`10KB 以下)\n\n```python\nwith open(full_path, 'rb') as f:\n raw = f.read(20480)\ntext = raw.decode('utf-8', errors='replace')\nchinese = sum(1 for c in text if '\\u4e00' <= c <= '\\u9fff')\nratio = chinese / len(text) if len(text) > 0 else 0\n\nif ratio < 0.10: # 内容损坏,删除\nif size < 10240: # 小文件垃圾,删除\n```\n\n### 2.2 批量删除(通过 SQLite 直写)\n\n停掉 calibre 进程后,通过 SQLite 删除记录并清理文件系统:\n\n```sql\nDELETE FROM data WHERE book = ?\nDELETE FROM books_authors_link WHERE book = ?\nDELETE FROM books_languages_link WHERE book = ?\nDELETE FROM books_publishers_link WHERE book = ?\nDELETE FROM books_series_link WHERE book = ?\nDELETE FROM books_tags_link WHERE book = ?\nDELETE FROM comments WHERE book = ?\nDELETE FROM identifiers WHERE book = ?\nDELETE FROM books WHERE id = ?\n```\n\n同时删除对应目录:`shutil.rmtree(os.path.join('/home/yangxuan/calibre/books', bpath))`\n\n### 2.3 完整删除脚本\n\n```python\nimport sqlite3, os, shutil\n\ndb_path = '/home/yangxuan/calibre/books/metadata.db'\nconn = sqlite3.connect(db_path)\nc = conn.cursor()\n\nc.execute('''\n SELECT b.id, b.title, b.path, d.name, d.uncompressed_size\n FROM books b \n JOIN data d ON d.book = b.id \n WHERE d.format = 'TXT'\n''')\nrows = c.fetchall()\n\nto_delete = []\n\nfor bid, title, bpath, fname, size in rows:\n full_path = os.path.join('/home/yangxuan/calibre/books', bpath, fname + '.txt')\n if not os.path.exists(full_path):\n continue\n \n # 条件1:小文件垃圾\n if size < 10240:\n to_delete.append((bid, bpath))\n continue\n \n # 条件2:内容损坏(中文 < 10%\n with open(full_path, 'rb') as f:\n raw = f.read(20480)\n text = raw.decode('utf-8', errors='replace')\n chinese = sum(1 for c in text if '\\u4e00' <= c <= '\\u9fff')\n ratio = chinese / len(text) if len(text) > 0 else 0\n if ratio < 0.10:\n to_delete.append((bid, bpath))\n\nprint(f\"待删除: {len(to_delete)}\", flush=True)\nfor bid, bpath in to_delete:\n c.execute('DELETE FROM data WHERE book=?', (bid,))\n c.execute('DELETE FROM books_authors_link WHERE book=?', (bid,))\n c.execute('DELETE FROM books_languages_link WHERE book=?', (bid,))\n c.execute('DELETE FROM books_publishers_link WHERE book=?', (bid,))\n c.execute('DELETE FROM books_series_link WHERE book=?', (bid,))\n c.execute('DELETE FROM books_tags_link WHERE book=?', (bid,))\n c.execute('DELETE FROM comments WHERE book=?', (bid,))\n c.execute('DELETE FROM identifiers WHERE book=?', (bid,))\n c.execute('DELETE FROM books WHERE id=?', (bid,))\n full_dir = os.path.join('/home/yangxuan/calibre/books', bpath)\n if os.path.exists(full_dir):\n shutil.rmtree(full_dir)\n\nconn.commit()\nconn.close()\nprint(f\"完成,删除: {len(to_delete)}\", flush=True)\n```\n\n---\n\n## 三、常用操作\n\n### 3.1 查看 XX 作者的书\n\n```bash\nsqlite3 /home/yangxuan/calibre/books/metadata.db \"\nSELECT b.id, b.title\nFROM books b JOIN books_authors_link l ON b.id = l.book\nJOIN authors a ON l.author = a.id\nWHERE a.name = 'Unknown'\nLIMIT 20;\n\"\n```\n\n### 3.2 统计作者分布\n\n```bash\nsqlite3 /home/yangxuan/calibre/books/metadata.db \"\nSELECT a.name, COUNT(*) as cnt\nFROM books b JOIN books_authors_link l ON b.id = l.book\nJOIN authors a ON l.author = a.id\nGROUP BY a.name ORDER BY cnt DESC LIMIT 20;\n\"\n```\n\n### 3.3 书库统计\n\n```bash\npython3 -c \"\nimport sqlite3\nconn = sqlite3.connect('/home/yangxuan/calibre/books/metadata.db')\nc = conn.cursor()\nc.execute('SELECT COUNT(*) FROM books')\nprint(f'总书籍: {c.fetchone()[0]}')\nc.execute('SELECT COUNT(*) FROM data WHERE format=\\\"TXT\\\"')\nprint(f'TXT 文件: {c.fetchone()[0]}')\nconn.close()\n\"\n```\n\n---\n\n## 四、操作流程(推荐顺序)\n\n```bash\ncd /home/yangxuan/calibre\n\n# 1. 停服务 + 备份\ndocker compose down\ncp books/metadata.db books/metadata.db.backup.$(date +%Y%m%d)\nkillall calibre 2>/dev/null\n\n# 2. 脏数据清理(内容损坏 + 小文件)\npython3 /path/to/clean_dirty_data.py\n\n# 3. 书名清洗 & 作者提取\ncalibre-debug /path/to/fix_titles.py\n\n# 4. 重启\ndocker compose up -d\n```\n\n---\n\n## 五、已知问题\n\n### `set_metadata` 报 `Errno 36 文件名过长`\n\n当 `author_sort` 字段超 80 字节时,Calibre 路径会触及 ext4 255 字节限制。先清理极长作者名:\n\n```bash\nsqlite3 /home/yangxuan/calibre/books/metadata.db \"\nUPDATE authors SET name='Unknown' WHERE LENGTH(name) > 30;\n\"\n```\n\n### WSL `/mnt/f` 删除 NTFS 文件权限不够\n\nWSL 的 drvfs 默认不带 metadata 选项,`sudo rm`、`os.remove`、`cmd.exe /c del` 均无法删除 NTFS 文件。不要在 WSL 下尝试跨分区删除操作,直接让 Windows 侧(文件资源管理器/PowerShell)操作。\n","previousContentHash":"929fdbb7a4de265565ca75bdf3692d33623a634f4991c21a51e5f829b0e9b2c0"}