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:
@@ -5,9 +5,12 @@
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 添加书目
|
||||
# 1. 常规添加书目
|
||||
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
|
||||
|
||||
@@ -26,28 +29,46 @@ python bookshelf.py delete 1
|
||||
python bookshelf.py build
|
||||
# 然后用浏览器打开 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` (必填) | 书名 | `"深度工作"` |
|
||||
| `--author` | 作者 | `"卡尔·纽波特"` |
|
||||
| `--translator` | 译者 | `"宋伟"` |
|
||||
| `--publisher` | 出版社 | `"后浪出版"` |
|
||||
| `--pub-date` | 出版日期 | `"2017-09"` |
|
||||
| `--author` / `--translator` | 作者 / 译者 | `"卡尔·纽波特"` / `"宋伟"` |
|
||||
| `--publisher` / `--pub-date` | 出版社 / 出版年月 | `"后浪出版"` / `"2017-09"` |
|
||||
| `--cover-url` | 封面 URL | `"https://..."` |
|
||||
| `--format` | `paper` / `ebook` | `paper` |
|
||||
| `--format` | 格式:`paper` / `ebook` | `paper` |
|
||||
| `--status` | `to-read` / `reading` / `read` | `reading` |
|
||||
| `--rating` | 个人评分 1-5 | `5` |
|
||||
| `--douban-score` | 豆瓣评分 | `7.9` |
|
||||
| `--goodreads-score` | GR 评分 | `4.18` |
|
||||
| `--douban-score` / `--goodreads-score` | 豆瓣 / GR 评分 | `7.9` / `4.18` |
|
||||
| `--douban-url` / `--goodreads-url` | 豆瓣 / GR 链接 | `https://book.douban...` |
|
||||
| `--title-en` / `--author-en` | 外文书名 / 外文作者 | `Deep Work` / `Cal Newport` |
|
||||
| `--tags` | 逗号分隔标签 | `"哲学,认知科学"` |
|
||||
| `--notes` | 简评 | `"值得反复阅读"` |
|
||||
| `--start-date` | 开始日期 | `"2026-03-01"` |
|
||||
| `--finish-date` | 完成日期 | `"2026-03-20"` |
|
||||
| `--start-date` / `--finish-date` | 阅读起止日期 | `"2026-03-01"` / `"2026-03-20"` |
|
||||
| `--group` (db 级别) | 主题领域分组(如"历史学") | |
|
||||
| `--priority` (db 级别)| 阅读优先级 | `⭐⭐⭐` |
|
||||
|
||||
## 数据存储
|
||||
## 数据存储与静态构建
|
||||
|
||||
- 数据库:`books.db`(SQLite,自动创建)
|
||||
- 网页输出:`output/index.html`(静态 HTML,浏览器直接打开)
|
||||
- 数据库:`books.db`(SQLite,包含 `cover_blob`)
|
||||
- 静态构建:`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"` 强制更正库内封面,无须等待用户操作调整。
|
||||
|
||||
+277
-22
@@ -3,6 +3,7 @@
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import sqlite3
|
||||
import db
|
||||
import web
|
||||
import scrape
|
||||
@@ -11,27 +12,53 @@ import scrape
|
||||
STATUS_CHOICES = ["to-read", "reading", "read"]
|
||||
FORMAT_CHOICES = ["paper", "ebook"]
|
||||
|
||||
|
||||
def cmd_add(args):
|
||||
db.init_db()
|
||||
book_id = db.add_book(
|
||||
args.title,
|
||||
author=args.author,
|
||||
translator=args.translator,
|
||||
publisher=args.publisher,
|
||||
pub_date=args.pub_date,
|
||||
cover_url=args.cover_url,
|
||||
format=args.format,
|
||||
status=args.status,
|
||||
rating=args.rating,
|
||||
douban_score=args.douban_score,
|
||||
goodreads_score=args.goodreads_score,
|
||||
tags=args.tags or "",
|
||||
notes=args.notes,
|
||||
start_date=args.start_date,
|
||||
finish_date=args.finish_date,
|
||||
)
|
||||
print(f"✅ 已添加:《{args.title}》(ID: {book_id})")
|
||||
|
||||
kwargs = {
|
||||
"author": args.author, "translator": args.translator,
|
||||
"publisher": args.publisher, "pub_date": args.pub_date,
|
||||
"cover_url": args.cover_url, "format": args.format,
|
||||
"status": args.status, "rating": args.rating,
|
||||
"douban_score": args.douban_score, "goodreads_score": args.goodreads_score,
|
||||
"tags": args.tags or "", "notes": args.notes,
|
||||
"start_date": args.start_date, "finish_date": args.finish_date,
|
||||
"title_en": args.title_en or "", "author_en": args.author_en or "",
|
||||
"douban_url": args.douban_url or "", "goodreads_url": args.goodreads_url or ""
|
||||
}
|
||||
|
||||
if getattr(args, "json", None):
|
||||
import json
|
||||
try:
|
||||
extra = json.loads(args.json)
|
||||
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):
|
||||
@@ -48,27 +75,55 @@ def cmd_list(args):
|
||||
print(f" {status_icon} [{b['id']:>3}] 《{b['title']}》— {b['author'] or '未知'}"
|
||||
f" {fmt_icon}{rating_str}{tags_str}")
|
||||
|
||||
|
||||
def cmd_update(args):
|
||||
db.init_db()
|
||||
book = db.get_book(args.id)
|
||||
if not book:
|
||||
print(f"❌ 未找到 ID 为 {args.id} 的书目。")
|
||||
sys.exit(1)
|
||||
|
||||
updates = {}
|
||||
|
||||
# 获取命令行常规参数
|
||||
for field in ["title", "author", "translator", "publisher", "pub_date",
|
||||
"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)
|
||||
if val is not None:
|
||||
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:
|
||||
print("⚠️ 未指定任何要更新的字段。")
|
||||
return
|
||||
|
||||
db.update_book(args.id, **updates)
|
||||
print(f"✅ 已更新:《{book['title']}》(ID: {args.id})")
|
||||
|
||||
|
||||
def cmd_delete(args):
|
||||
db.init_db()
|
||||
book = db.get_book(args.id)
|
||||
@@ -139,12 +194,187 @@ def cmd_fetch(args):
|
||||
douban_score=data.get("douban_score"),
|
||||
goodreads_score=data.get("goodreads_score"),
|
||||
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})")
|
||||
else:
|
||||
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):
|
||||
db.init_db()
|
||||
output_path = web.build()
|
||||
@@ -175,6 +405,10 @@ def main():
|
||||
p_add.add_argument("--notes", help="笔记/简评")
|
||||
p_add.add_argument("--start-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)
|
||||
|
||||
# --- list ---
|
||||
@@ -202,6 +436,10 @@ def main():
|
||||
p_upd.add_argument("--notes", help="笔记/简评")
|
||||
p_upd.add_argument("--start-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)
|
||||
|
||||
# --- delete ---
|
||||
@@ -218,10 +456,27 @@ def main():
|
||||
p_fetch.add_argument("--status", choices=STATUS_CHOICES, help="阅读状态(入库时使用)")
|
||||
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 ---
|
||||
p_build = sub.add_parser("build", help="生成展示网页")
|
||||
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.func(args)
|
||||
|
||||
|
||||
@@ -54,6 +54,10 @@ def init_db():
|
||||
("read_order", "INTEGER"),
|
||||
("cover_blob", "BLOB"),
|
||||
("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:
|
||||
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",
|
||||
rating=None, douban_score=None, goodreads_score=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。"""
|
||||
conn = _connect()
|
||||
cur = conn.execute("""
|
||||
INSERT INTO books (title, author, translator, publisher, pub_date,
|
||||
cover_url, format, status, rating, douban_score,
|
||||
goodreads_score, tags, notes, start_date, finish_date,
|
||||
"group", isbn, priority, read_reason, read_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"group", isbn, priority, read_reason, read_order,
|
||||
title_en, author_en, douban_url, goodreads_url)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (title, author, translator, publisher, pub_date, cover_url,
|
||||
format, status, rating, douban_score, goodreads_score,
|
||||
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()
|
||||
book_id = cur.lastrowid
|
||||
conn.close()
|
||||
@@ -95,7 +102,7 @@ def update_book(book_id, **kwargs):
|
||||
"cover_url", "format", "status", "rating", "douban_score",
|
||||
"goodreads_score", "tags", "notes", "start_date", "finish_date",
|
||||
"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}
|
||||
if not fields:
|
||||
|
||||
@@ -211,6 +211,8 @@ def _parse_douban_html(html):
|
||||
result["pub_date"] = value
|
||||
elif "ISBN" in label.upper():
|
||||
result["isbn"] = value
|
||||
elif "原作名" in label:
|
||||
result["title_en"] = value
|
||||
|
||||
# 方法2:兜底 — 如果 span.pl 没有拿到,用纯文本
|
||||
if not result.get("author"):
|
||||
@@ -262,6 +264,8 @@ def fetch_douban(identifier: str):
|
||||
return None
|
||||
|
||||
data = _parse_douban_html(html)
|
||||
if data:
|
||||
data["douban_url"] = url.split("?")[0]
|
||||
return data
|
||||
|
||||
|
||||
@@ -352,6 +356,8 @@ def _parse_goodreads_html(html):
|
||||
if cover_tag:
|
||||
src = cover_tag.get("src", "")
|
||||
if src and "nophoto" not in src:
|
||||
# 移除亚马逊 CDN 图片的尺寸限制,如 ._SY475_ 等以获取原图
|
||||
src = re.sub(r'\._S[YX]\d+_?\.', '.', src)
|
||||
result["cover_url"] = src
|
||||
|
||||
return result if result.get("title") else None
|
||||
@@ -368,9 +374,9 @@ def fetch_goodreads(identifier: str):
|
||||
|
||||
if "goodreads.com/book/show/" in identifier:
|
||||
url = identifier.split("?")[0].split("&")[0]
|
||||
elif _is_isbn(identifier):
|
||||
# 先通过搜索页找到书籍链接
|
||||
search_url = GOODREADS_SEARCH_URL.format(query=_clean_isbn(identifier))
|
||||
else:
|
||||
# 当作搜索关键词(支持 ISBN 或 书名)
|
||||
search_url = GOODREADS_SEARCH_URL.format(query=_requests.utils.quote(identifier) if _HAS_REQUESTS else urllib.parse.quote(identifier))
|
||||
_sleep()
|
||||
search_html = _get(search_url)
|
||||
if not search_html:
|
||||
@@ -380,23 +386,21 @@ def fetch_goodreads(identifier: str):
|
||||
if not match:
|
||||
return None
|
||||
url = f"https://www.goodreads.com/book/show/{match.group(1)}"
|
||||
else:
|
||||
return None
|
||||
|
||||
_sleep()
|
||||
html = _get(url)
|
||||
if not html or "goodreads" not in html.lower():
|
||||
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):
|
||||
"""只获取 Goodreads 评分(用于补全其他来源的数据)。"""
|
||||
data = fetch_goodreads(isbn)
|
||||
if data and data.get("goodreads_score"):
|
||||
return data["goodreads_score"]
|
||||
return None
|
||||
def fetch_goodreads_data_only_score(isbn: str):
|
||||
"""(已弃用,外层应直接调用 fetch_goodreads 拿全部信息)"""
|
||||
pass
|
||||
|
||||
|
||||
# ── Google Books API ──────────────────────────────────────────────────
|
||||
@@ -496,22 +500,92 @@ def fetch_open_library(isbn: str):
|
||||
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):
|
||||
"""
|
||||
根据 ISBN 自动选择数据源。
|
||||
中文 ISBN → 豆瓣 + Goodreads 补 GR 评分。
|
||||
中文 ISBN → 豆瓣 + Goodreads 补 GR 评分与链接。
|
||||
英文 ISBN → Goodreads 为主力 → Google Books 兜底。
|
||||
"""
|
||||
if _is_chinese_isbn(isbn):
|
||||
result = fetch_douban(isbn)
|
||||
if result:
|
||||
# 尝试补全 Goodreads 评分
|
||||
if not result.get("goodreads_score"):
|
||||
gr_score = fetch_goodreads_score(isbn)
|
||||
if gr_score:
|
||||
result["goodreads_score"] = gr_score
|
||||
# 尝试补全 Goodreads 评分和链接
|
||||
gr_data = fetch_goodreads(isbn)
|
||||
if gr_data:
|
||||
if gr_data.get("goodreads_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, "豆瓣"
|
||||
result = fetch_open_library(isbn)
|
||||
if result:
|
||||
|
||||
+136
-22
@@ -177,15 +177,16 @@ body {
|
||||
/* Book Grid */
|
||||
.book-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 1.2rem;
|
||||
padding: 1.5rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Book Card */
|
||||
/* Book Card (Book Wall Style) */
|
||||
.book-card {
|
||||
position: relative;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
@@ -197,32 +198,54 @@ body {
|
||||
|
||||
.book-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow);
|
||||
border-color: rgba(124, 106, 239, 0.2);
|
||||
box-shadow: 0 12px 24px rgba(0,0,0,0.3);
|
||||
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 {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.card-cover--placeholder {
|
||||
height: 160px;
|
||||
.card-scores-container {
|
||||
padding: 0.6rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 3rem;
|
||||
background: linear-gradient(135deg, var(--bg-secondary), var(--bg-card-hover));
|
||||
gap: 0.5rem;
|
||||
background: var(--bg-card);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 1rem 1.2rem 1.2rem;
|
||||
.card-hover-details {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
padding: 1.2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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 {
|
||||
@@ -232,6 +255,26 @@ body {
|
||||
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 {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
@@ -252,21 +295,35 @@ body {
|
||||
|
||||
.score {
|
||||
font-size: 0.78rem;
|
||||
padding: 0.15rem 0.6rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
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 {
|
||||
background: rgba(0, 180, 0, 0.1);
|
||||
color: #4ade80;
|
||||
border: 1px solid rgba(0, 180, 0, 0.15);
|
||||
color: #4ade80 !important;
|
||||
}
|
||||
|
||||
.score.goodreads {
|
||||
background: rgba(96, 165, 250, 0.1);
|
||||
color: #60a5fa;
|
||||
border: 1px solid rgba(96, 165, 250, 0.15);
|
||||
color: #60a5fa !important;
|
||||
}
|
||||
|
||||
.card-title-en {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
font-family: serif;
|
||||
margin-top: -0.2rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.card-tags {
|
||||
@@ -315,6 +372,63 @@ body {
|
||||
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 */
|
||||
@media (max-width: 640px) {
|
||||
.hero h1 { font-size: 1.8rem; }
|
||||
|
||||
+31
-19
@@ -50,13 +50,13 @@
|
||||
<nav class="view-nav">
|
||||
<button class="view-btn active" data-view="progress">📖 阅读进度</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 class="filter-nav" id="filter-progress">
|
||||
<button class="filter-btn active" data-filter="all">全部</button>
|
||||
<button class="filter-btn" data-filter="reading">在读</button>
|
||||
<button class="filter-btn" data-filter="all">全部</button>
|
||||
<button class="filter-btn active" data-filter="reading">在读</button>
|
||||
<button class="filter-btn" data-filter="read">已读</button>
|
||||
<button class="filter-btn" data-filter="to-read">待读</button>
|
||||
</nav>
|
||||
@@ -68,17 +68,16 @@
|
||||
<button class="filter-btn" data-filter="ebook">📱 电子书</button>
|
||||
</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}}
|
||||
</main>
|
||||
|
||||
<!-- 群组概况列表 -->
|
||||
<main id="group-overview" style="display: none;">
|
||||
{{GROUP_OVERVIEW_HTML}}
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<p>Ecliptic Pathfinder · 生成于 <span id="gen-time"></span></p>
|
||||
</footer>
|
||||
@@ -91,26 +90,29 @@ const viewBtns = document.querySelectorAll('.view-btn');
|
||||
const filterNavs = {
|
||||
progress: document.getElementById('filter-progress'),
|
||||
library: document.getElementById('filter-library'),
|
||||
tags: document.getElementById('filter-tags'),
|
||||
};
|
||||
|
||||
let currentView = 'progress';
|
||||
let currentFilter = 'all';
|
||||
let currentTag = '';
|
||||
let currentFilter = 'reading';
|
||||
|
||||
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 => {
|
||||
let show = true;
|
||||
if (currentView === 'progress' && currentFilter !== 'all') {
|
||||
show = card.dataset.status === currentFilter;
|
||||
} else if (currentView === 'library' && currentFilter !== 'all') {
|
||||
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';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
viewBtns.forEach(btn => {
|
||||
@@ -119,13 +121,16 @@ viewBtns.forEach(btn => {
|
||||
btn.classList.add('active');
|
||||
currentView = btn.dataset.view;
|
||||
currentFilter = 'all';
|
||||
currentTag = '';
|
||||
Object.values(filterNavs).forEach(n => n.classList.add('hidden'));
|
||||
Object.values(filterNavs).forEach(n => {
|
||||
if (n) n.classList.add('hidden');
|
||||
});
|
||||
if (filterNavs[currentView]) filterNavs[currentView].classList.remove('hidden');
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
applyFilters();
|
||||
});
|
||||
});
|
||||
@@ -139,8 +144,15 @@ document.querySelectorAll('.filter-nav').forEach(nav => {
|
||||
if (btn.dataset.tag !== undefined) {
|
||||
currentTag = btn.dataset.tag;
|
||||
currentFilter = 'all';
|
||||
currentGroup = '';
|
||||
} else if (btn.dataset.group !== undefined) {
|
||||
currentGroup = btn.dataset.group;
|
||||
currentFilter = 'all';
|
||||
currentTag = '';
|
||||
} else {
|
||||
currentFilter = btn.dataset.filter;
|
||||
currentTag = '';
|
||||
currentGroup = '';
|
||||
}
|
||||
applyFilters();
|
||||
});
|
||||
|
||||
@@ -24,7 +24,11 @@ def _escape(text):
|
||||
|
||||
def _render_book_card(book):
|
||||
"""渲染单本书的 HTML 卡片。"""
|
||||
# 封面:优先本地 BLOB 提取后的相对路径,其次远程 URL
|
||||
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">'
|
||||
if cover
|
||||
else '<div class="card-cover card-cover--placeholder">📖</div>')
|
||||
@@ -36,10 +40,20 @@ def _render_book_card(book):
|
||||
|
||||
scores = []
|
||||
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"):
|
||||
scores.append(f'<span class="score goodreads">GR {book["goodreads_score"]}</span>')
|
||||
scores_html = f'<div class="card-scores">{"".join(scores)}</div>' if scores else ""
|
||||
gs = f'G {book["goodreads_score"]}'
|
||||
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 = ""
|
||||
if book.get("tags"):
|
||||
@@ -51,7 +65,10 @@ def _render_book_card(book):
|
||||
|
||||
meta_parts = []
|
||||
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"):
|
||||
meta_parts.append(f'译: {_escape(book["translator"])}')
|
||||
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 ""
|
||||
|
||||
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"""
|
||||
<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-tags="{_escape(book.get('tags', ''))}">
|
||||
data-tags="{_escape(book.get('tags', ''))}"
|
||||
data-group="{_escape(book.get('group', ''))}">
|
||||
|
||||
<div class="card-cover-wrapper">
|
||||
{cover_html}
|
||||
<div class="card-body">
|
||||
<h3 class="card-title">{_escape(book.get('title', ''))}</h3>
|
||||
<div class="card-hover-details">
|
||||
{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}
|
||||
</div>
|
||||
{rating_html}
|
||||
{scores_html}
|
||||
{tags_html}
|
||||
{notes_html}
|
||||
<div class="meta-dates">
|
||||
{dates_html}
|
||||
</div>
|
||||
{notes_html}
|
||||
{tags_html}
|
||||
<div class="card-format">{format_badge}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{scores_html}
|
||||
</div>"""
|
||||
|
||||
|
||||
@@ -117,11 +151,37 @@ def build():
|
||||
paper = stats["by_format"].get("paper", 0)
|
||||
ebook = stats["by_format"].get("ebook", 0)
|
||||
|
||||
# 标签按钮
|
||||
tag_buttons = "".join(
|
||||
f'<button class="tag-btn" data-tag="{_escape(t)}">{_escape(t)}</button>'
|
||||
for t in all_tags
|
||||
)
|
||||
# 领域/分组概览
|
||||
|
||||
from collections import defaultdict
|
||||
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
|
||||
.replace("{{CSS}}", css)
|
||||
@@ -131,11 +191,19 @@ def build():
|
||||
.replace("{{TO_READ}}", str(to_read))
|
||||
.replace("{{PAPER}}", str(paper))
|
||||
.replace("{{EBOOK}}", str(ebook))
|
||||
.replace("{{TAG_BUTTONS}}", tag_buttons)
|
||||
.replace("{{GROUP_OVERVIEW_HTML}}", group_overview_html)
|
||||
.replace("{{BOOK_CARDS}}", cards_html))
|
||||
|
||||
with open(OUTPUT_PATH, "w", encoding="utf-8") as f:
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user