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
|
```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"` 强制更正库内封面,无须等待用户操作调整。
|
||||||
|
|||||||
+277
-22
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user