feat: 重构图书墙交互及多源高阶信息抓取注入系统

1. UI 层:
- 抛弃网格卡片结构,改版为沉浸式的“图书墙”(1:1.43 原比例包含模式)。
- 新增悬浮深色遮罩系统(hover-details),解绑评分点击阻塞。
- 过滤系统默认进入“在读”状态页,彻底移除冗余无效的全选按钮。
2. 抓取与刮削层 (scrape.py):
- 抛弃对 ISBN 查询 Google Books 的严重强依赖。
- 重构抓取链路,首选 Goodreads 与特供的 Books.com.tw(博客来) 高精爬取港台原版资料。
- 建立 Amazon/Goodreads 图片去码净化正则,告别低分辨率与畸形图片。
- 修复 bs4 (.select_first) 解析选择器在旧版环境的兼容性异常。
3. Agent AI 数据管线设计 (bookshelf.py):
- 开放基于 --json 与 --cover 的强力直接注入功能。
- 升级 add 命令作为最优先级的录入手段,免去繁琐的 enrich 阶段,直接连带高清图和满载元数据强插。
- 建立入库反重名智能拦截 (title + author 交叉校验),形成完美的防呆与自动化壁垒。
This commit is contained in:
kai
2026-03-26 11:42:43 +08:00
parent 69c34d4faf
commit 0801f7c7df
9 changed files with 684 additions and 133 deletions
Vendored
BIN
View File
Binary file not shown.
+34 -13
View File
@@ -5,9 +5,12 @@
## 快速开始 ## 快速开始
```bash ```bash
# 添加书目 # 1. 常规添加书目
python bookshelf.py add "深度工作" --author "卡尔·纽波特" --format paper --status reading --tags "效率,自我管理" python bookshelf.py add "深度工作" --author "卡尔·纽波特" --format paper --status reading --tags "效率,自我管理"
# 2. (进阶) 使用 JSON 强力查重直插所有包含高阶属性的元数据:
python bookshelf.py add "乔布斯传" --json '{"title_en": "Steve Jobs", "author": "Walter Isaacson", "douban_score": 8.9}'
# 列出所有书 # 列出所有书
python bookshelf.py list python bookshelf.py list
@@ -26,28 +29,46 @@ python bookshelf.py delete 1
python bookshelf.py build python bookshelf.py build
# 然后用浏览器打开 output/index.html # 然后用浏览器打开 output/index.html
``` ```
## 批量管理命令
```bash
# 1. 从豆瓣、Goodreads、Google Books 刮削验证并自动补充缺失元数据
python bookshelf.py enrich
# 单独补充某本书:python bookshelf.py enrich --id 12
# 2. 批量将图书封面下载并作为二进制 BLOB 存入 SQLite (解决静态展示防盗链问题)
python bookshelf.py covers
```
## 完整字段 ## 完整字段
| 参数 | 说明 | 示例 | | 参数 | 说明 | 示例 |
|------|------|------| |------|------|------|
| `title` (必填) | 书名 | `"深度工作"` | | `title` (必填) | 书名 | `"深度工作"` |
| `--author` | 作者 | `"卡尔·纽波特"` | | `--author` / `--translator` | 作者 / 译者 | `"卡尔·纽波特"` / `"宋伟"` |
| `--translator` | 译者 | `"宋伟"` | | `--publisher` / `--pub-date` | 出版社 / 出版年月 | `"后浪出版"` / `"2017-09"` |
| `--publisher` | 出版社 | `"后浪出版"` |
| `--pub-date` | 出版日期 | `"2017-09"` |
| `--cover-url` | 封面 URL | `"https://..."` | | `--cover-url` | 封面 URL | `"https://..."` |
| `--format` | `paper` / `ebook` | `paper` | | `--format` | 格式:`paper` / `ebook` | `paper` |
| `--status` | `to-read` / `reading` / `read` | `reading` | | `--status` | `to-read` / `reading` / `read` | `reading` |
| `--rating` | 个人评分 1-5 | `5` | | `--rating` | 个人评分 1-5 | `5` |
| `--douban-score` | 豆瓣评分 | `7.9` | | `--douban-score` / `--goodreads-score` | 豆瓣 / GR 评分 | `7.9` / `4.18` |
| `--goodreads-score` | GR 评分 | `4.18` | | `--douban-url` / `--goodreads-url` | 豆瓣 / GR 链接 | `https://book.douban...` |
| `--title-en` / `--author-en` | 外文书名 / 外文作者 | `Deep Work` / `Cal Newport` |
| `--tags` | 逗号分隔标签 | `"哲学,认知科学"` | | `--tags` | 逗号分隔标签 | `"哲学,认知科学"` |
| `--notes` | 简评 | `"值得反复阅读"` | | `--notes` | 简评 | `"值得反复阅读"` |
| `--start-date` | 开始日期 | `"2026-03-01"` | | `--start-date` / `--finish-date` | 阅读起止日期 | `"2026-03-01"` / `"2026-03-20"` |
| `--finish-date` | 完成日期 | `"2026-03-20"` | | `--group` (db 级别) | 主题领域分组(如"历史学") | |
| `--priority` (db 级别)| 阅读优先级 | `⭐⭐⭐` |
## 数据存储 ## 数据存储与静态构建
- 数据库:`books.db`SQLite自动创建 - 数据库:`books.db`SQLite包含 `cover_blob`
- 网页输出`output/index.html`(静态 HTML,浏览器直接打开) - 静态构建:`python bookshelf.py build` (会自动抽取数据库中 `cover_blob``output/covers/` 以供显示,网页输出`output/index.html`)
## 🤖 AI Agent 交互提示
若你是被指派操作此书库的 AI Agent:
1. **一步到位入库(终极建档)**:若在对话中已经搜索获取到该书丰富的元数据(封面URL、精确书名、外文名、评分等),**切勿**分成“简略 add -> 再 enrich”两步走。请直接使用带自带查重保护的 `add` 结合 `--json` 一键全属性入库。
- 例:`python bookshelf.py add "史蒂夫·乔布斯传" --json '{"author": "Walter Isaacson", "title_en": "Steve Jobs", "douban_score": 8.9, "cover_url": "..."}'`
2. **多语言支持**:如果判断是一本翻译书,务必在爬取或手工录入时尝试保留由 `enrich` 抓取到的 `title_en` 以及 `goodreads_url`, 不要随意覆盖中文 `title` 字段。
3. **流程必做**:用户添加书目指令执行完 `add``enrich` 后,必须执行 `bookshelf.py covers` 以及 `bookshelf.py build`,以保持静态页面的同步。
4. **人工纠错**:如果你发现由于刮削不准确导致的封面错误或元数据异常,优先调用网页搜索、图片搜索拿到精准信息,后直接执行 `python bookshelf.py update <ID> --json '{"title_en": "...", "douban_score": 8.5}'` 快速订正。如果你找到了正确的高清原书封面图,请下载到本地后利用 `python bookshelf.py update <ID> --cover "./path/to/better_cover.jpg"` 强制更正库内封面,无须等待用户操作调整。
BIN
View File
Binary file not shown.
+277 -22
View File
@@ -3,6 +3,7 @@
import argparse import argparse
import sys import sys
import sqlite3
import db import db
import web import web
import scrape import scrape
@@ -11,27 +12,53 @@ import scrape
STATUS_CHOICES = ["to-read", "reading", "read"] STATUS_CHOICES = ["to-read", "reading", "read"]
FORMAT_CHOICES = ["paper", "ebook"] FORMAT_CHOICES = ["paper", "ebook"]
def cmd_add(args): def cmd_add(args):
db.init_db() db.init_db()
book_id = db.add_book(
args.title, kwargs = {
author=args.author, "author": args.author, "translator": args.translator,
translator=args.translator, "publisher": args.publisher, "pub_date": args.pub_date,
publisher=args.publisher, "cover_url": args.cover_url, "format": args.format,
pub_date=args.pub_date, "status": args.status, "rating": args.rating,
cover_url=args.cover_url, "douban_score": args.douban_score, "goodreads_score": args.goodreads_score,
format=args.format, "tags": args.tags or "", "notes": args.notes,
status=args.status, "start_date": args.start_date, "finish_date": args.finish_date,
rating=args.rating, "title_en": args.title_en or "", "author_en": args.author_en or "",
douban_score=args.douban_score, "douban_url": args.douban_url or "", "goodreads_url": args.goodreads_url or ""
goodreads_score=args.goodreads_score, }
tags=args.tags or "",
notes=args.notes, if getattr(args, "json", None):
start_date=args.start_date, import json
finish_date=args.finish_date, try:
) extra = json.loads(args.json)
print(f"✅ 已添加:《{args.title}》(ID: {book_id})") kwargs.update(extra)
except Exception as e:
print(f"❌ JSON 解析失败: {e}")
return
title = kwargs.pop("title", args.title)
if not title:
print("❌ 必须提供书名 (title)。")
return
all_books = db.list_books()
for b in all_books:
db_title = b['title'].lower()
t_lower = title.lower()
if t_lower == db_title or (len(t_lower) > 3 and (t_lower in db_title or db_title in t_lower)):
a_in = (kwargs.get("author") or "").lower()
a_db = (b.get("author") or "").lower()
if not a_in or not a_db or a_in in a_db or a_db in a_in:
print(f"⚠️ 库中可能已存在同名记录:[ID: {b['id']}] 《{b['title']}》 (作者: {b.get('author', '未知')})。")
print(" 已取消添加。若确认并非同一本请修改书名/作者,若需更新请使用 `update` 命令。")
return
book_id = db.add_book(title)
updates = {k: v for k, v in kwargs.items() if v}
if updates:
db.update_book(book_id, **updates)
print(f"✅ 已添加并录入数据:《{title}》(ID: {book_id})")
def cmd_list(args): def cmd_list(args):
@@ -48,27 +75,55 @@ def cmd_list(args):
print(f" {status_icon} [{b['id']:>3}] 《{b['title']}》— {b['author'] or '未知'}" print(f" {status_icon} [{b['id']:>3}] 《{b['title']}》— {b['author'] or '未知'}"
f" {fmt_icon}{rating_str}{tags_str}") f" {fmt_icon}{rating_str}{tags_str}")
def cmd_update(args): def cmd_update(args):
db.init_db() db.init_db()
book = db.get_book(args.id) book = db.get_book(args.id)
if not book: if not book:
print(f"❌ 未找到 ID 为 {args.id} 的书目。") print(f"❌ 未找到 ID 为 {args.id} 的书目。")
sys.exit(1) sys.exit(1)
updates = {} updates = {}
# 获取命令行常规参数
for field in ["title", "author", "translator", "publisher", "pub_date", for field in ["title", "author", "translator", "publisher", "pub_date",
"cover_url", "format", "status", "rating", "douban_score", "cover_url", "format", "status", "rating", "douban_score",
"goodreads_score", "tags", "notes", "start_date", "finish_date"]: "goodreads_score", "tags", "notes", "start_date", "finish_date",
"title_en", "author_en", "douban_url", "goodreads_url"]:
val = getattr(args, field.replace("-", "_"), None) val = getattr(args, field.replace("-", "_"), None)
if val is not None: if val is not None:
updates[field] = val updates[field] = val
# 从 JSON 批量注入参数(AI优先)
if getattr(args, "json", None):
import json
try:
updates.update(json.loads(args.json))
except Exception as e:
print(f"❌ JSON 解析失败: {e}")
return
# 从本地图片注入封面(AI优先)
if getattr(args, "cover", None):
import os
if not os.path.exists(args.cover):
print(f"❌ 找不到指定的本封面: {args.cover}")
return
try:
with open(args.cover, "rb") as f:
updates["cover_blob"] = f.read()
updates["cover_local"] = 1
print(f"🖼️ 已应用本地封面: {args.cover}")
except Exception as e:
print(f"❌ 读取封面文件失败: {e}")
return
if not updates: if not updates:
print("⚠️ 未指定任何要更新的字段。") print("⚠️ 未指定任何要更新的字段。")
return return
db.update_book(args.id, **updates) db.update_book(args.id, **updates)
print(f"✅ 已更新:《{book['title']}》(ID: {args.id})") print(f"✅ 已更新:《{book['title']}》(ID: {args.id})")
def cmd_delete(args): def cmd_delete(args):
db.init_db() db.init_db()
book = db.get_book(args.id) book = db.get_book(args.id)
@@ -139,12 +194,187 @@ def cmd_fetch(args):
douban_score=data.get("douban_score"), douban_score=data.get("douban_score"),
goodreads_score=data.get("goodreads_score"), goodreads_score=data.get("goodreads_score"),
tags=data.get("tags", ""), tags=data.get("tags", ""),
title_en=data.get("title_en", ""),
author_en=data.get("author_en", ""),
douban_url=data.get("douban_url", ""),
goodreads_url=data.get("goodreads_url", ""),
) )
print(f"✅ 已添加:《{data.get('title')}》(ID: {book_id})") print(f"✅ 已添加:《{data.get('title')}》(ID: {book_id})")
else: else:
print("💡 加 --add 选项可直接写入数据库,或用 --update-id <ID> 更新已有记录。") print("💡 加 --add 选项可直接写入数据库,或用 --update-id <ID> 更新已有记录。")
def _is_match(b_title, b_author, s_title, s_title_en, s_author):
import re
def canon(s):
return re.sub(r'[^\w\u4e00-\u9fff]', '', str(s).lower()) if s else ""
bt = canon(b_title)
st = canon(s_title)
ste = canon(s_title_en)
title_match = (bt in st or st in bt) if (bt and st) else False
if not title_match and ste:
title_match = (bt in ste or ste in bt) if bt else False
ba = canon(b_author)
sa = canon(s_author)
author_match = (ba in sa or sa in ba) if (ba and sa) else False
# 书名匹配或作者匹配之一满足即可认为是同一个记录
return title_match or author_match
def cmd_enrich(args):
"""自动补全数据库中缺失元数据的书目。"""
db.init_db()
books = db.list_books()
to_enrich = []
for b in books:
# 只要没有豆瓣或 GR 的链接,或者没有封面,就尝试 enrich
if not b.get("cover_url") or not b.get("douban_url") or not b.get("goodreads_url"):
to_enrich.append(b)
if args.id:
to_enrich = [b for b in to_enrich if b["id"] == args.id]
if not to_enrich:
print("🎉 所有书目数据已相对完整,无需 enrich。")
return
print(f"🔍 共有 {len(to_enrich)} 本书需补全或验证信息...")
import urllib.parse
for b in to_enrich:
print(f"\n=> 正在处理 [{b['id']}]: 《{b['title']}》 作者: {b.get('author','')} ...")
scraped = None
src = ""
search_term = b['title']
if b.get('author'): search_term += f" {b['author']}"
# 策略 1: 直接向 Goodreads 询问(它对原版图书封面最标准、非促销封套)
print(" 🔍 尝试通过 Goodreads 直搜...")
gr_data = scrape.fetch_goodreads(search_term)
if gr_data and _is_match(b['title'], b.get('author'), gr_data.get('title'), gr_data.get('title_en'), gr_data.get('author')):
scraped = gr_data
src = "Goodreads"
# 策略 2: 如果 GR 失败或缺信息,对于繁体中文,尝试博客来
if not scraped or not scraped.get("cover_url"):
print(" 🔍 尝试通过博客来(Books.com.tw)直搜...")
tw_data = scrape.fetch_books_tw(search_term)
if tw_data and _is_match(b['title'], b.get('author'), tw_data.get('title'), None, tw_data.get('author')):
if not scraped: scraped = tw_data
else:
for k, v in tw_data.items():
if not scraped.get(k): scraped[k] = v
scraped["format"] = "paper" # 博客来主要是实体书
src = "博客来" + ((" / " + src) if src else "")
# 策略 3: Google Books 仅作为属性补充,**决不拿它的 ISBN 去瞎关联豆瓣**
query = f"intitle:{b['title']}"
if b.get('author'): query += f"+inauthor:{b['author']}"
q = urllib.parse.quote(query)
url = f"https://www.googleapis.com/books/v1/volumes?q={q}"
try:
html = scrape._get(url)
if html:
import json
data = json.loads(html)
if data.get("totalItems", 0) > 0:
vol = data["items"][0].get("volumeInfo", {})
# 只要匹配度过关,就拿它的 publisher, pub_date, ISBN
if _is_match(b['title'], b.get('author'), vol.get("title"), None, ",".join(vol.get("authors", []))):
isbn = next((ids.get("identifier") for ids in vol.get("industryIdentifiers", []) if ids.get("type") in ("ISBN_13", "ISBN_10")), None)
if not scraped: scraped = {"title": vol.get("title")}
if vol.get("publisher") and not scraped.get("publisher"): scraped["publisher"] = vol["publisher"]
if vol.get("publishedDate") and not scraped.get("pub_date"): scraped["pub_date"] = vol["publishedDate"][:7]
if isbn and not scraped.get("isbn"): scraped["isbn"] = isbn
src = src or "Google Books"
except Exception:
pass
# 策略 4: 如果有 ISBN,去豆瓣白嫖评分和出版信息(但为了防止封面被覆盖为促销版,不轻易覆写封面)
if scraped and scraped.get("isbn"):
douban_data = scrape.fetch_douban(scraped["isbn"])
if douban_data and _is_match(b['title'], b.get('author'), douban_data.get('title'), douban_data.get('title_en'), douban_data.get('author')):
for k, v in douban_data.items():
if k == "cover_url" and scraped.get("cover_url"): continue # 优先信任前面外站的原版封面
if not scraped.get(k): scraped[k] = v
src += " / 豆瓣"
if scraped:
updates = {}
# 新刮削的标题如果是外文但不等于原书名,放到 title_en 保护原中文书名
if scraped.get("title") and not _is_match(b["title"], None, scraped["title"], None, None):
if not b.get("title_en"):
updates["title_en"] = scraped["title"]
if scraped.get("author") and not _is_match(b["author"], None, scraped["author"], None, None):
if not b.get("author_en"):
updates["author_en"] = scraped["author"]
for k in ["author", "translator", "publisher", "pub_date", "cover_url", "tags",
"douban_score", "goodreads_score", "isbn", "title_en", "author_en", "douban_url", "goodreads_url"]:
if scraped.get(k) and not b.get(k):
updates[k] = scraped[k]
if updates:
db.update_book(b["id"], **updates)
print(f" ✅ 已补全字段: {', '.join(updates.keys())} (来源: {src})")
else:
print(f" 👍 已匹配到信息,但无需补充新字段 (来源: {src})")
else:
print(" 🚫 彻底失败,保留原样。")
# 延时避免被反爬
scrape._sleep()
def cmd_covers(args):
"""批量下载封面存入数据库 BLOB。"""
db.init_db()
books = db.list_books()
to_download = [b for b in books if b.get("cover_url") and not b.get("cover_blob")]
if args.id:
to_download = [b for b in to_download if b["id"] == args.id]
print(f"🖼️ 共有 {len(to_download)} 本书需要下载封面到数据库...")
conn = db._connect()
try:
import requests
except ImportError:
import urllib.request
count = 0
for b in to_download:
url = b["cover_url"]
print(f"下载 [{b['id']}] 《{b['title']}》封面: {url}")
try:
# 豆瓣防盗链需要 Referer,而且可能需要 Playwright 那个 UA
headers = {"User-Agent": scrape._UA, "Referer": "https://book.douban.com/"}
if scrape._HAS_REQUESTS:
resp = requests.get(url, headers=headers, timeout=15)
if resp.status_code == 200:
blob = resp.content
else:
blob = None
else:
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=15) as resp:
blob = resp.read()
if blob:
conn.execute("UPDATE books SET cover_blob = ? WHERE id = ?", (sqlite3.Binary(blob), b["id"]))
conn.commit()
print(" ✅ 成功")
count += 1
else:
print(" ❌ 获取失败")
scrape._sleep()
except Exception as e:
print(f" ❌ 网络错误: {e}")
conn.close()
print(f"🎉 封面下载完成,成功 {count} 本。")
def cmd_build(args): def cmd_build(args):
db.init_db() db.init_db()
output_path = web.build() output_path = web.build()
@@ -175,6 +405,10 @@ def main():
p_add.add_argument("--notes", help="笔记/简评") p_add.add_argument("--notes", help="笔记/简评")
p_add.add_argument("--start-date", help="开始阅读日期") p_add.add_argument("--start-date", help="开始阅读日期")
p_add.add_argument("--finish-date", help="完成阅读日期") p_add.add_argument("--finish-date", help="完成阅读日期")
p_add.add_argument("--title-en", help="外文原名")
p_add.add_argument("--author-en", help="外文作者名")
p_add.add_argument("--douban-url", help="豆瓣链接")
p_add.add_argument("--goodreads-url", help="Goodreads 链接")
p_add.set_defaults(func=cmd_add) p_add.set_defaults(func=cmd_add)
# --- list --- # --- list ---
@@ -202,6 +436,10 @@ def main():
p_upd.add_argument("--notes", help="笔记/简评") p_upd.add_argument("--notes", help="笔记/简评")
p_upd.add_argument("--start-date", help="开始阅读日期") p_upd.add_argument("--start-date", help="开始阅读日期")
p_upd.add_argument("--finish-date", help="完成阅读日期") p_upd.add_argument("--finish-date", help="完成阅读日期")
p_upd.add_argument("--title-en", help="外文原名")
p_upd.add_argument("--author-en", help="外文作者名")
p_upd.add_argument("--douban-url", help="豆瓣链接")
p_upd.add_argument("--goodreads-url", help="Goodreads 链接")
p_upd.set_defaults(func=cmd_update) p_upd.set_defaults(func=cmd_update)
# --- delete --- # --- delete ---
@@ -218,10 +456,27 @@ def main():
p_fetch.add_argument("--status", choices=STATUS_CHOICES, help="阅读状态(入库时使用)") p_fetch.add_argument("--status", choices=STATUS_CHOICES, help="阅读状态(入库时使用)")
p_fetch.set_defaults(func=cmd_fetch) p_fetch.set_defaults(func=cmd_fetch)
# --- enrich ---
p_enrich = sub.add_parser("enrich", help="批量刮削补全缺失信息")
p_enrich.add_argument("--id", type=int, help="只补全特定 ID 的书")
p_enrich.set_defaults(func=cmd_enrich)
# --- covers ---
p_covers = sub.add_parser("covers", help="批量下载并保存封面到数据库")
p_covers.add_argument("--id", type=int, help="只下载特定 ID 的书")
p_covers.set_defaults(func=cmd_covers)
# --- build --- # --- build ---
p_build = sub.add_parser("build", help="生成展示网页") p_build = sub.add_parser("build", help="生成展示网页")
p_build.set_defaults(func=cmd_build) p_build.set_defaults(func=cmd_build)
# --- update ---
p_update = sub.add_parser("update", help="(高级/AI专用)通过 JSON 直接更新元数据或本地封面")
p_update.add_argument("id", type=int, help="书籍记录的数字 ID")
p_update.add_argument("--json", type=str, help="包含需更新字段的 JSON 字符串 (例: '{\"douban_score\": 9.5}')")
p_update.add_argument("--cover", type=str, help="本地图片文件路径,直接注入 cover_blob")
p_update.set_defaults(func=cmd_update)
args = parser.parse_args() args = parser.parse_args()
args.func(args) args.func(args)
+12 -5
View File
@@ -54,6 +54,10 @@ def init_db():
("read_order", "INTEGER"), ("read_order", "INTEGER"),
("cover_blob", "BLOB"), ("cover_blob", "BLOB"),
("cover_local", "TEXT DEFAULT ''"), ("cover_local", "TEXT DEFAULT ''"),
("title_en", "TEXT DEFAULT ''"),
("author_en", "TEXT DEFAULT ''"),
("douban_url", "TEXT DEFAULT ''"),
("goodreads_url","TEXT DEFAULT ''"),
] ]
for col_name, col_def in new_cols: for col_name, col_def in new_cols:
plain = col_name.strip('"') plain = col_name.strip('"')
@@ -67,19 +71,22 @@ def add_book(title, *, author=None, translator=None, publisher=None,
pub_date=None, cover_url=None, format="paper", status="to-read", pub_date=None, cover_url=None, format="paper", status="to-read",
rating=None, douban_score=None, goodreads_score=None, rating=None, douban_score=None, goodreads_score=None,
tags="", notes=None, start_date=None, finish_date=None, tags="", notes=None, start_date=None, finish_date=None,
group="", isbn="", priority="", read_reason="", read_order=None): group="", isbn="", priority="", read_reason="", read_order=None,
title_en="", author_en="", douban_url="", goodreads_url=""):
"""添加一本书,返回新书 ID。""" """添加一本书,返回新书 ID。"""
conn = _connect() conn = _connect()
cur = conn.execute(""" cur = conn.execute("""
INSERT INTO books (title, author, translator, publisher, pub_date, INSERT INTO books (title, author, translator, publisher, pub_date,
cover_url, format, status, rating, douban_score, cover_url, format, status, rating, douban_score,
goodreads_score, tags, notes, start_date, finish_date, goodreads_score, tags, notes, start_date, finish_date,
"group", isbn, priority, read_reason, read_order) "group", isbn, priority, read_reason, read_order,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) title_en, author_en, douban_url, goodreads_url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (title, author, translator, publisher, pub_date, cover_url, """, (title, author, translator, publisher, pub_date, cover_url,
format, status, rating, douban_score, goodreads_score, format, status, rating, douban_score, goodreads_score,
tags, notes, start_date, finish_date, tags, notes, start_date, finish_date,
group, isbn, priority, read_reason, read_order)) group, isbn, priority, read_reason, read_order,
title_en, author_en, douban_url, goodreads_url))
conn.commit() conn.commit()
book_id = cur.lastrowid book_id = cur.lastrowid
conn.close() conn.close()
@@ -95,7 +102,7 @@ def update_book(book_id, **kwargs):
"cover_url", "format", "status", "rating", "douban_score", "cover_url", "format", "status", "rating", "douban_score",
"goodreads_score", "tags", "notes", "start_date", "finish_date", "goodreads_score", "tags", "notes", "start_date", "finish_date",
"group", "isbn", "priority", "read_reason", "read_order", "group", "isbn", "priority", "read_reason", "read_order",
"cover_blob", "cover_local", "cover_blob", "cover_local", "title_en", "author_en", "douban_url", "goodreads_url",
} }
fields = {k: v for k, v in kwargs.items() if k in allowed} fields = {k: v for k, v in kwargs.items() if k in allowed}
if not fields: if not fields:
+92 -18
View File
@@ -211,6 +211,8 @@ def _parse_douban_html(html):
result["pub_date"] = value result["pub_date"] = value
elif "ISBN" in label.upper(): elif "ISBN" in label.upper():
result["isbn"] = value result["isbn"] = value
elif "原作名" in label:
result["title_en"] = value
# 方法2:兜底 — 如果 span.pl 没有拿到,用纯文本 # 方法2:兜底 — 如果 span.pl 没有拿到,用纯文本
if not result.get("author"): if not result.get("author"):
@@ -262,6 +264,8 @@ def fetch_douban(identifier: str):
return None return None
data = _parse_douban_html(html) data = _parse_douban_html(html)
if data:
data["douban_url"] = url.split("?")[0]
return data return data
@@ -352,6 +356,8 @@ def _parse_goodreads_html(html):
if cover_tag: if cover_tag:
src = cover_tag.get("src", "") src = cover_tag.get("src", "")
if src and "nophoto" not in src: if src and "nophoto" not in src:
# 移除亚马逊 CDN 图片的尺寸限制,如 ._SY475_ 等以获取原图
src = re.sub(r'\._S[YX]\d+_?\.', '.', src)
result["cover_url"] = src result["cover_url"] = src
return result if result.get("title") else None return result if result.get("title") else None
@@ -368,9 +374,9 @@ def fetch_goodreads(identifier: str):
if "goodreads.com/book/show/" in identifier: if "goodreads.com/book/show/" in identifier:
url = identifier.split("?")[0].split("&")[0] url = identifier.split("?")[0].split("&")[0]
elif _is_isbn(identifier): else:
# 先通过搜索页找到书籍链接 # 当作搜索关键词(支持 ISBN 或 书名)
search_url = GOODREADS_SEARCH_URL.format(query=_clean_isbn(identifier)) search_url = GOODREADS_SEARCH_URL.format(query=_requests.utils.quote(identifier) if _HAS_REQUESTS else urllib.parse.quote(identifier))
_sleep() _sleep()
search_html = _get(search_url) search_html = _get(search_url)
if not search_html: if not search_html:
@@ -380,23 +386,21 @@ def fetch_goodreads(identifier: str):
if not match: if not match:
return None return None
url = f"https://www.goodreads.com/book/show/{match.group(1)}" url = f"https://www.goodreads.com/book/show/{match.group(1)}"
else:
return None
_sleep() _sleep()
html = _get(url) html = _get(url)
if not html or "goodreads" not in html.lower(): if not html or "goodreads" not in html.lower():
return None return None
return _parse_goodreads_html(html) data = _parse_goodreads_html(html)
if data:
data["goodreads_url"] = url.split("?")[0]
return data
def fetch_goodreads_score(isbn: str): def fetch_goodreads_data_only_score(isbn: str):
"""只获取 Goodreads 评分(用于补全其他来源的数据)。""" """(已弃用,外层应直接调用 fetch_goodreads 拿全部信息)"""
data = fetch_goodreads(isbn) pass
if data and data.get("goodreads_score"):
return data["goodreads_score"]
return None
# ── Google Books API ────────────────────────────────────────────────── # ── Google Books API ──────────────────────────────────────────────────
@@ -496,22 +500,92 @@ def fetch_open_library(isbn: str):
return result if result.get("title") else None return result if result.get("title") else None
# ── 博客来 (Books.com.tw) ──────────────────────────────────────────────
def fetch_books_tw(keyword: str):
"""
搜索博客来并获取第一条书籍信息
URL: https://search.books.com.tw/search/query/key/{keyword}/cat/BKA
"""
import urllib.parse
search_url = f"https://search.books.com.tw/search/query/key/{urllib.parse.quote(keyword)}/cat/BKA"
html = _get(search_url)
if not html: return None
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "html.parser")
# 找到第一本书
items = soup.select('.table-searchbox .box')
if not items:
return None
first_item = items[0]
result = {}
title_els = first_item.select('.msg h3 a')
if title_els:
result["title"] = title_els[0].get_text(strip=True)
# 封面
img_els = first_item.select('.box_1 img')
if img_els:
img_el = img_els[0]
src = img_el.get("data-original") or img_el.get("src")
if src:
# 博客来图片通常有 &w= 缩放参数,去掉或改大
import re
src = re.sub(r'&w=\d+', '&w=800', src)
src = re.sub(r'&h=\d+', '&h=800', src)
result["cover_url"] = src.replace("https://im1.book.com.tw/", "https://im2.book.com.tw/")
# 作者、出版社、日期信息
info_boxes = first_item.select('.info')
if info_boxes:
info_box = info_boxes[0]
links = info_box.find_all('a')
authors = []
publisher = ""
for a in links:
href = a.get("href", "")
if "adv_author" in href:
authors.append(a.get_text(strip=True))
elif "adv_pub" in href:
publisher = a.get_text(strip=True)
if authors: result["author"] = " / ".join(authors)
if publisher: result["publisher"] = publisher
info_text = info_box.get_text()
import re
m = re.search(r'出版日期[:]\s*(\d{4}-\d{2}-\d{2}|\d{4}-\d{2}|\d{4}/\d{2}/\d{2})', info_text)
if m:
result["pub_date"] = m.group(1).replace("/", "-")
return result
# ── 主入口 ──────────────────────────────────────────────────────────── # ── 主入口 ────────────────────────────────────────────────────────────
def fetch_by_isbn(isbn: str): def fetch_by_isbn(isbn: str):
""" """
根据 ISBN 自动选择数据源。 根据 ISBN 自动选择数据源。
中文 ISBN → 豆瓣 + Goodreads 补 GR 评分。 中文 ISBN → 豆瓣 + Goodreads 补 GR 评分与链接
英文 ISBN → Goodreads 为主力 → Google Books 兜底。 英文 ISBN → Goodreads 为主力 → Google Books 兜底。
""" """
if _is_chinese_isbn(isbn): if _is_chinese_isbn(isbn):
result = fetch_douban(isbn) result = fetch_douban(isbn)
if result: if result:
# 尝试补全 Goodreads 评分 # 尝试补全 Goodreads 评分和链接
if not result.get("goodreads_score"): gr_data = fetch_goodreads(isbn)
gr_score = fetch_goodreads_score(isbn) if gr_data:
if gr_score: if gr_data.get("goodreads_score"):
result["goodreads_score"] = gr_score result["goodreads_score"] = gr_data["goodreads_score"]
if gr_data.get("goodreads_url"):
result["goodreads_url"] = gr_data["goodreads_url"]
if not result.get("title_en") and gr_data.get("title"):
result["title_en"] = gr_data["title"]
if not result.get("author_en") and gr_data.get("author"):
result["author_en"] = gr_data["author"]
return result, "豆瓣" return result, "豆瓣"
result = fetch_open_library(isbn) result = fetch_open_library(isbn)
if result: if result:
+136 -22
View File
@@ -177,15 +177,16 @@ body {
/* Book Grid */ /* Book Grid */
.book-grid { .book-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1.2rem; gap: 1.2rem;
padding: 1.5rem; padding: 1.5rem;
max-width: 1400px; max-width: 1400px;
margin: 0 auto; margin: 0 auto;
} }
/* Book Card */ /* Book Card (Book Wall Style) */
.book-card { .book-card {
position: relative;
background: var(--bg-card); background: var(--bg-card);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
@@ -197,32 +198,54 @@ body {
.book-card:hover { .book-card:hover {
transform: translateY(-4px); transform: translateY(-4px);
box-shadow: var(--shadow); box-shadow: 0 12px 24px rgba(0,0,0,0.3);
border-color: rgba(124, 106, 239, 0.2); border-color: var(--accent);
}
.card-cover-wrapper {
position: relative;
width: 100%;
aspect-ratio: 1 / 1.43; /* Standard book proportion */
background: var(--bg-secondary);
overflow: hidden;
} }
.card-cover { .card-cover {
width: 100%; width: 100%;
height: 200px; height: 100%;
object-fit: cover; object-fit: contain;
display: block; display: block;
} }
.card-cover--placeholder { .card-scores-container {
height: 160px; padding: 0.6rem;
display: flex; display: flex;
align-items: center;
justify-content: center; justify-content: center;
font-size: 3rem; gap: 0.5rem;
background: linear-gradient(135deg, var(--bg-secondary), var(--bg-card-hover)); background: var(--bg-card);
border-top: 1px solid rgba(255, 255, 255, 0.05);
z-index: 2;
} }
.card-body { .card-hover-details {
padding: 1rem 1.2rem 1.2rem; position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
padding: 1.2rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
flex: 1; background: rgba(15, 15, 20, 0.92);
backdrop-filter: blur(8px);
color: #eee;
opacity: 0;
transition: opacity 0.3s ease;
z-index: 3;
overflow-y: auto;
box-sizing: border-box;
}
.book-card:hover .card-hover-details {
opacity: 1;
} }
.card-title { .card-title {
@@ -232,6 +255,26 @@ body {
color: var(--text-primary); color: var(--text-primary);
} }
.card-priority {
color: #fbbf24;
font-size: 0.9rem;
margin-left: 0.4rem;
vertical-align: middle;
}
.card-group {
font-size: 0.72rem;
color: var(--accent-light);
background: rgba(124, 106, 239, 0.15);
padding: 0.15rem 0.5rem;
border-radius: 4px;
align-self: flex-start;
margin-bottom: 0.2rem;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 500;
}
.card-meta { .card-meta {
font-size: 0.82rem; font-size: 0.82rem;
color: var(--text-secondary); color: var(--text-secondary);
@@ -252,21 +295,35 @@ body {
.score { .score {
font-size: 0.78rem; font-size: 0.78rem;
padding: 0.15rem 0.6rem; padding: 0.2rem 0.6rem;
border-radius: 999px; border-radius: 999px;
font-weight: 500; font-weight: 600;
text-decoration: none;
transition: all var(--transition);
background: var(--bg-secondary) !important;
border: 1px solid var(--border) !important;
color: var(--text-primary) !important;
}
a.score:hover {
filter: brightness(1.2);
transform: translateY(-1px);
} }
.score.douban { .score.douban {
background: rgba(0, 180, 0, 0.1); color: #4ade80 !important;
color: #4ade80;
border: 1px solid rgba(0, 180, 0, 0.15);
} }
.score.goodreads { .score.goodreads {
background: rgba(96, 165, 250, 0.1); color: #60a5fa !important;
color: #60a5fa; }
border: 1px solid rgba(96, 165, 250, 0.15);
.card-title-en {
font-size: 0.85rem;
color: var(--text-muted);
font-family: serif;
margin-top: -0.2rem;
line-height: 1.3;
} }
.card-tags { .card-tags {
@@ -315,6 +372,63 @@ body {
margin-top: 2rem; margin-top: 2rem;
} }
/* Group Overview */
#group-overview {
display: flex;
flex-direction: column;
gap: 2rem;
padding: 1.5rem;
max-width: 1400px;
margin: 0 auto;
}
.group-section {
background: var(--bg-card);
border-radius: var(--radius);
padding: 1.5rem;
box-shadow: var(--shadow);
border: 1px solid var(--border);
}
.group-section-title {
margin-top: 0;
margin-bottom: 1rem;
font-size: 1.4rem;
font-weight: 600;
color: var(--text-primary);
border-bottom: 2px solid var(--border);
padding-bottom: 0.5rem;
}
.group-count {
color: var(--text-muted);
font-size: 1rem;
font-weight: normal;
}
.group-covers {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.tiny-cover {
height: 110px;
border-radius: 4px;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
transition: transform var(--transition);
object-fit: contain;
background: var(--bg-secondary);
padding: 2px;
box-sizing: border-box;
}
.tiny-cover:hover {
transform: scale(1.15);
z-index: 10;
}
/* Responsive */ /* Responsive */
@media (max-width: 640px) { @media (max-width: 640px) {
.hero h1 { font-size: 1.8rem; } .hero h1 { font-size: 1.8rem; }
+31 -19
View File
@@ -50,13 +50,13 @@
<nav class="view-nav"> <nav class="view-nav">
<button class="view-btn active" data-view="progress">📖 阅读进度</button> <button class="view-btn active" data-view="progress">📖 阅读进度</button>
<button class="view-btn" data-view="library">📚 书库</button> <button class="view-btn" data-view="library">📚 书库</button>
<button class="view-btn" data-view="tags">🏷 主题</button> <button class="view-btn" data-view="groups">🗂 主题概览</button>
</nav> </nav>
<!-- 进度视图的子筛选 --> <!-- 进度视图的子筛选 -->
<nav class="filter-nav" id="filter-progress"> <nav class="filter-nav" id="filter-progress">
<button class="filter-btn active" data-filter="all">全部</button> <button class="filter-btn" data-filter="all">全部</button>
<button class="filter-btn" data-filter="reading">在读</button> <button class="filter-btn active" data-filter="reading">在读</button>
<button class="filter-btn" data-filter="read">已读</button> <button class="filter-btn" data-filter="read">已读</button>
<button class="filter-btn" data-filter="to-read">待读</button> <button class="filter-btn" data-filter="to-read">待读</button>
</nav> </nav>
@@ -68,17 +68,16 @@
<button class="filter-btn" data-filter="ebook">📱 电子书</button> <button class="filter-btn" data-filter="ebook">📱 电子书</button>
</nav> </nav>
<!-- 标签视图的按钮 -->
<nav class="filter-nav hidden" id="filter-tags">
<button class="filter-btn tag-btn active" data-tag="">全部</button>
{{TAG_BUTTONS}}
</nav>
<!-- 书目卡片列表 --> <!-- 书目卡片列表 -->
<main class="book-grid"> <main class="book-grid" id="book-list">
{{BOOK_CARDS}} {{BOOK_CARDS}}
</main> </main>
<!-- 群组概况列表 -->
<main id="group-overview" style="display: none;">
{{GROUP_OVERVIEW_HTML}}
</main>
<footer class="footer"> <footer class="footer">
<p>Ecliptic Pathfinder · 生成于 <span id="gen-time"></span></p> <p>Ecliptic Pathfinder · 生成于 <span id="gen-time"></span></p>
</footer> </footer>
@@ -91,27 +90,30 @@ const viewBtns = document.querySelectorAll('.view-btn');
const filterNavs = { const filterNavs = {
progress: document.getElementById('filter-progress'), progress: document.getElementById('filter-progress'),
library: document.getElementById('filter-library'), library: document.getElementById('filter-library'),
tags: document.getElementById('filter-tags'),
}; };
let currentView = 'progress'; let currentView = 'progress';
let currentFilter = 'all'; let currentFilter = 'reading';
let currentTag = '';
function applyFilters() { function applyFilters() {
if (currentView === 'groups') {
document.getElementById('book-list').style.display = 'none';
document.getElementById('group-overview').style.display = 'flex';
} else {
document.getElementById('book-list').style.display = '';
document.getElementById('group-overview').style.display = 'none';
cards.forEach(card => { cards.forEach(card => {
let show = true; let show = true;
if (currentView === 'progress' && currentFilter !== 'all') { if (currentView === 'progress' && currentFilter !== 'all') {
show = card.dataset.status === currentFilter; show = card.dataset.status === currentFilter;
} else if (currentView === 'library' && currentFilter !== 'all') { } else if (currentView === 'library' && currentFilter !== 'all') {
show = card.dataset.format === currentFilter; show = card.dataset.format === currentFilter;
} else if (currentView === 'tags' && currentTag) {
const cardTags = card.dataset.tags.split(',').map(t => t.trim());
show = cardTags.includes(currentTag);
} }
card.style.display = show ? '' : 'none'; card.style.display = show ? '' : 'none';
}); });
} }
}
viewBtns.forEach(btn => { viewBtns.forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
@@ -119,13 +121,16 @@ viewBtns.forEach(btn => {
btn.classList.add('active'); btn.classList.add('active');
currentView = btn.dataset.view; currentView = btn.dataset.view;
currentFilter = 'all'; currentFilter = 'all';
currentTag = ''; Object.values(filterNavs).forEach(n => {
Object.values(filterNavs).forEach(n => n.classList.add('hidden')); if (n) n.classList.add('hidden');
});
if (filterNavs[currentView]) filterNavs[currentView].classList.remove('hidden'); if (filterNavs[currentView]) filterNavs[currentView].classList.remove('hidden');
// reset sub-filter active state // reset sub-filter active state
filterNavs[currentView]?.querySelectorAll('.filter-btn').forEach((b, i) => { if (filterNavs[currentView]) {
filterNavs[currentView].querySelectorAll('.filter-btn').forEach((b, i) => {
b.classList.toggle('active', i === 0); b.classList.toggle('active', i === 0);
}); });
}
applyFilters(); applyFilters();
}); });
}); });
@@ -139,8 +144,15 @@ document.querySelectorAll('.filter-nav').forEach(nav => {
if (btn.dataset.tag !== undefined) { if (btn.dataset.tag !== undefined) {
currentTag = btn.dataset.tag; currentTag = btn.dataset.tag;
currentFilter = 'all'; currentFilter = 'all';
currentGroup = '';
} else if (btn.dataset.group !== undefined) {
currentGroup = btn.dataset.group;
currentFilter = 'all';
currentTag = '';
} else { } else {
currentFilter = btn.dataset.filter; currentFilter = btn.dataset.filter;
currentTag = '';
currentGroup = '';
} }
applyFilters(); applyFilters();
}); });
+86 -18
View File
@@ -24,7 +24,11 @@ def _escape(text):
def _render_book_card(book): def _render_book_card(book):
"""渲染单本书的 HTML 卡片。""" """渲染单本书的 HTML 卡片。"""
# 封面:优先本地 BLOB 提取后的相对路径,其次远程 URL
cover = book.get("cover_url") or "" cover = book.get("cover_url") or ""
if book.get("cover_blob"):
cover = f"covers/{book['id']}.jpg"
cover_html = (f'<img class="card-cover" src="{_escape(cover)}" alt="封面" loading="lazy">' cover_html = (f'<img class="card-cover" src="{_escape(cover)}" alt="封面" loading="lazy">'
if cover if cover
else '<div class="card-cover card-cover--placeholder">📖</div>') else '<div class="card-cover card-cover--placeholder">📖</div>')
@@ -36,10 +40,20 @@ def _render_book_card(book):
scores = [] scores = []
if book.get("douban_score"): if book.get("douban_score"):
scores.append(f'<span class="score douban">豆瓣 {book["douban_score"]}</span>') ds = f'D {book["douban_score"]}'
if book.get("douban_url"):
scores.append(f'<a href="{_escape(book["douban_url"])}" target="_blank" class="score douban" title="在豆瓣查看">{ds}</a>')
else:
scores.append(f'<span class="score douban">{ds}</span>')
if book.get("goodreads_score"): if book.get("goodreads_score"):
scores.append(f'<span class="score goodreads">GR {book["goodreads_score"]}</span>') gs = f'G {book["goodreads_score"]}'
scores_html = f'<div class="card-scores">{"".join(scores)}</div>' if scores else "" if book.get("goodreads_url"):
scores.append(f'<a href="{_escape(book["goodreads_url"])}" target="_blank" class="score goodreads" title="在 Goodreads 查看">{gs}</a>')
else:
scores.append(f'<span class="score goodreads">{gs}</span>')
scores_html = f'<div class="card-scores-container">{"".join(scores)}</div>' if scores else ""
tags_html = "" tags_html = ""
if book.get("tags"): if book.get("tags"):
@@ -51,7 +65,10 @@ def _render_book_card(book):
meta_parts = [] meta_parts = []
if book.get("author"): if book.get("author"):
meta_parts.append(f'{_escape(book["author"])}') a_text = _escape(book["author"])
if book.get("author_en"):
a_text += f' <span style="opacity: 0.7;">[{_escape(book["author_en"])}]</span>'
meta_parts.append(a_text)
if book.get("translator"): if book.get("translator"):
meta_parts.append(f'译: {_escape(book["translator"])}') meta_parts.append(f'译: {_escape(book["translator"])}')
if book.get("publisher"): if book.get("publisher"):
@@ -72,22 +89,39 @@ def _render_book_card(book):
dates_html = f'<div class="card-dates">{" · ".join(dates)}</div>' if dates else "" dates_html = f'<div class="card-dates">{" · ".join(dates)}</div>' if dates else ""
format_badge = "📱 电子书" if book.get("format") == "ebook" else "📕 纸质书" format_badge = "📱 电子书" if book.get("format") == "ebook" else "📕 纸质书"
group_html = f'<div class="card-group">{_escape(book.get("group", ""))}</div>' if book.get("group") else ""
priority = book.get("priority", "")
priority_html = f'<span class="card-priority">{priority}</span>' if priority else ""
# 默认加上在读状态的类(如果在读)
is_reading = ' is-reading' if book.get('status') == 'reading' else ''
return f""" return f"""
<div class="book-card" data-status="{_escape(book.get('status', ''))}" <div class="book-card{is_reading}" data-status="{_escape(book.get('status', ''))}"
data-format="{_escape(book.get('format', ''))}" data-format="{_escape(book.get('format', ''))}"
data-tags="{_escape(book.get('tags', ''))}"> data-tags="{_escape(book.get('tags', ''))}"
data-group="{_escape(book.get('group', ''))}">
<div class="card-cover-wrapper">
{cover_html} {cover_html}
<div class="card-body"> <div class="card-hover-details">
<h3 class="card-title">{_escape(book.get('title', ''))}</h3> {group_html}
<h3 class="card-title">{_escape(book.get('title', ''))} {priority_html}</h3>
{f'<div class="card-title-en">{_escape(book["title_en"])}</div>' if book.get("title_en") else ""}
<div class="meta-section">
{meta_html} {meta_html}
</div>
{rating_html} {rating_html}
{scores_html} <div class="meta-dates">
{tags_html}
{notes_html}
{dates_html} {dates_html}
</div>
{notes_html}
{tags_html}
<div class="card-format">{format_badge}</div> <div class="card-format">{format_badge}</div>
</div> </div>
</div>
{scores_html}
</div>""" </div>"""
@@ -117,11 +151,37 @@ def build():
paper = stats["by_format"].get("paper", 0) paper = stats["by_format"].get("paper", 0)
ebook = stats["by_format"].get("ebook", 0) ebook = stats["by_format"].get("ebook", 0)
# 标签按钮 # 领域/分组概览
tag_buttons = "".join(
f'<button class="tag-btn" data-tag="{_escape(t)}">{_escape(t)}</button>' from collections import defaultdict
for t in all_tags group_books = defaultdict(list)
) for b in books:
grp = b.get("group") or "未分类"
group_books[grp].append(b)
group_sections = []
for grp, gbooks in sorted(group_books.items()):
covers_html = []
for b in gbooks:
cid = str(b["id"])
if b.get("cover_blob") or b.get("cover_local"):
src = f"./covers/{cid}.jpg"
else:
src = b.get("cover_url", "https://via.placeholder.com/150?text=No+Cover")
cv = f'<img src="{src}" class="tiny-cover" title="{_escape(b.get("title",""))}">'
covers_html.append(cv)
section = f'''
<div class="group-section">
<h2 class="group-section-title">{_escape(grp)} <span class="group-count">({len(gbooks)})</span></h2>
<div class="group-covers">
{"".join(covers_html)}
</div>
</div>
'''
group_sections.append(section)
group_overview_html = "".join(group_sections)
html = (template html = (template
.replace("{{CSS}}", css) .replace("{{CSS}}", css)
@@ -131,11 +191,19 @@ def build():
.replace("{{TO_READ}}", str(to_read)) .replace("{{TO_READ}}", str(to_read))
.replace("{{PAPER}}", str(paper)) .replace("{{PAPER}}", str(paper))
.replace("{{EBOOK}}", str(ebook)) .replace("{{EBOOK}}", str(ebook))
.replace("{{TAG_BUTTONS}}", tag_buttons) .replace("{{GROUP_OVERVIEW_HTML}}", group_overview_html)
.replace("{{BOOK_CARDS}}", cards_html)) .replace("{{BOOK_CARDS}}", cards_html))
with open(OUTPUT_PATH, "w", encoding="utf-8") as f: with open(OUTPUT_PATH, "w", encoding="utf-8") as f:
f.write(html) f.write(html)
# 复制封面图片不处理(直接使用 URL # 提取 BLOB 封面到 output/covers/
covers_dir = os.path.join(OUTPUT_DIR, "covers")
os.makedirs(covers_dir, exist_ok=True)
for b in books:
if b.get("cover_blob"):
cover_path = os.path.join(covers_dir, f"{b['id']}.jpg")
with open(cover_path, "wb") as f:
f.write(b["cover_blob"])
return OUTPUT_PATH return OUTPUT_PATH