diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..6e86a48 Binary files /dev/null and b/.DS_Store differ diff --git a/README.md b/README.md index 0aa6f40..8188fd6 100644 --- a/README.md +++ b/README.md @@ -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 --json '{"title_en": "...", "douban_score": 8.5}'` 快速订正。如果你找到了正确的高清原书封面图,请下载到本地后利用 `python bookshelf.py update --cover "./path/to/better_cover.jpg"` 强制更正库内封面,无须等待用户操作调整。 diff --git a/books.db b/books.db deleted file mode 100644 index 098b977..0000000 Binary files a/books.db and /dev/null differ diff --git a/bookshelf.py b/bookshelf.py index 35092e5..331764f 100644 --- a/bookshelf.py +++ b/bookshelf.py @@ -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 更新已有记录。") +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) diff --git a/db.py b/db.py index dbdb9f7..f8e544b 100644 --- a/db.py +++ b/db.py @@ -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: diff --git a/scrape.py b/scrape.py index 06e568b..3f89768 100644 --- a/scrape.py +++ b/scrape.py @@ -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: diff --git a/static/style.css b/static/style.css index 13392b6..7f85821 100644 --- a/static/style.css +++ b/static/style.css @@ -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; } diff --git a/templates/index.html b/templates/index.html index 023934e..c900c62 100644 --- a/templates/index.html +++ b/templates/index.html @@ -50,13 +50,13 @@ @@ -68,17 +68,16 @@ - - - -
+
{{BOOK_CARDS}}
+ +
+ {{GROUP_OVERVIEW_HTML}} +
+

Ecliptic Pathfinder · 生成于

@@ -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() { - 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'; - }); + 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; + } + 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) => { - b.classList.toggle('active', i === 0); - }); + 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(); }); diff --git a/web.py b/web.py index 10ded19..e0dd099 100644 --- a/web.py +++ b/web.py @@ -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'封面' if cover else '
📖
') @@ -36,10 +40,20 @@ def _render_book_card(book): scores = [] if book.get("douban_score"): - scores.append(f'豆瓣 {book["douban_score"]}') + ds = f'D {book["douban_score"]}' + if book.get("douban_url"): + scores.append(f'{ds}') + else: + scores.append(f'{ds}') + if book.get("goodreads_score"): - scores.append(f'GR {book["goodreads_score"]}') - scores_html = f'
{"".join(scores)}
' if scores else "" + gs = f'G {book["goodreads_score"]}' + if book.get("goodreads_url"): + scores.append(f'{gs}') + else: + scores.append(f'{gs}') + + scores_html = f'
{"".join(scores)}
' 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' [{_escape(book["author_en"])}]' + 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'
{" · ".join(dates)}
' if dates else "" format_badge = "📱 电子书" if book.get("format") == "ebook" else "📕 纸质书" + group_html = f'
{_escape(book.get("group", ""))}
' if book.get("group") else "" + priority = book.get("priority", "") + priority_html = f'{priority}' if priority else "" + # 默认加上在读状态的类(如果在读) + is_reading = ' is-reading' if book.get('status') == 'reading' else '' + return f""" -
- {cover_html} -
-

{_escape(book.get('title', ''))}

- {meta_html} - {rating_html} - {scores_html} - {tags_html} - {notes_html} - {dates_html} -
{format_badge}
+ data-tags="{_escape(book.get('tags', ''))}" + data-group="{_escape(book.get('group', ''))}"> + +
+ {cover_html} +
+ {group_html} +

{_escape(book.get('title', ''))} {priority_html}

+ {f'
{_escape(book["title_en"])}
' if book.get("title_en") else ""} +
+ {meta_html} +
+ {rating_html} +
+ {dates_html} +
+ {notes_html} + {tags_html} +
{format_badge}
+
+ + {scores_html}
""" @@ -117,11 +151,37 @@ def build(): paper = stats["by_format"].get("paper", 0) ebook = stats["by_format"].get("ebook", 0) - # 标签按钮 - tag_buttons = "".join( - f'' - 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'' + covers_html.append(cv) + + section = f''' +
+

{_escape(grp)} ({len(gbooks)})

+
+ {"".join(covers_html)} +
+
+ ''' + 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