- scrape.py: Douban scraping with Playwright (multi-strategy browser launch) - scrape.py: Goodreads scraping with requests + BeautifulSoup - scrape.py: Google Books API + Open Library as fallback - bookshelf.py: add 'fetch' subcommand (ISBN / Douban URL / GR URL) - Fix Douban #info parsing using span.pl tags - Add requirements.txt (requests, beautifulsoup4, playwright)
231 lines
8.8 KiB
Python
231 lines
8.8 KiB
Python
#!/usr/bin/env python3
|
||
"""bookshelf — 图书阅读管理 CLI。"""
|
||
|
||
import argparse
|
||
import sys
|
||
import db
|
||
import web
|
||
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})")
|
||
|
||
|
||
def cmd_list(args):
|
||
db.init_db()
|
||
books = db.list_books(status=args.status, format=args.format, tag=args.tag)
|
||
if not books:
|
||
print("📚 暂无书目。")
|
||
return
|
||
for b in books:
|
||
status_icon = {"to-read": "📖", "reading": "📗", "read": "✅"}.get(b["status"], "📕")
|
||
fmt_icon = "📱" if b["format"] == "ebook" else "📕"
|
||
rating_str = f" ⭐{b['rating']}" if b["rating"] else ""
|
||
tags_str = f" [{b['tags']}]" if b["tags"] else ""
|
||
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"]:
|
||
val = getattr(args, field.replace("-", "_"), None)
|
||
if val is not None:
|
||
updates[field] = val
|
||
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)
|
||
if not book:
|
||
print(f"❌ 未找到 ID 为 {args.id} 的书目。")
|
||
sys.exit(1)
|
||
db.delete_book(args.id)
|
||
print(f"🗑️ 已删除:《{book['title']}》(ID: {args.id})")
|
||
|
||
|
||
def cmd_fetch(args):
|
||
"""刮削图书元数据,可选择直接写入数据库。"""
|
||
identifier = args.identifier
|
||
|
||
# 选择刮削策略
|
||
if "douban.com" in identifier:
|
||
print(f"🔍 正在从豆瓣刮削:{identifier}")
|
||
data = scrape.fetch_douban(identifier)
|
||
source = "豆瓣"
|
||
elif "goodreads.com" in identifier:
|
||
print(f"🔍 正在从 Goodreads 刮削:{identifier}")
|
||
data = scrape.fetch_goodreads(identifier)
|
||
source = "Goodreads"
|
||
elif scrape._is_isbn(identifier):
|
||
print(f"🔍 正在刮削 ISBN:{identifier}")
|
||
data, source = scrape.fetch_by_isbn(identifier)
|
||
else:
|
||
print("❌ 请提供有效的 ISBN(10/13位)、豆瓣链接或 Goodreads 链接。")
|
||
sys.exit(1)
|
||
|
||
if not data:
|
||
print("❌ 刮削失败,未能获取到图书信息。")
|
||
print(" 提示:豆瓣偶尔会拒绝请求(418/403),请稍后重试。")
|
||
sys.exit(1)
|
||
|
||
# 打印预览
|
||
print(scrape.preview(data, source))
|
||
print()
|
||
|
||
if args.update_id:
|
||
# 更新已有记录
|
||
db.init_db()
|
||
book = db.get_book(args.update_id)
|
||
if not book:
|
||
print(f"❌ 未找到 ID 为 {args.update_id} 的书目。")
|
||
sys.exit(1)
|
||
# 只覆盖刮削到的字段,保留用户已设置的字段
|
||
updates = {k: v for k, v in data.items() if v}
|
||
if args.format:
|
||
updates["format"] = args.format
|
||
if args.status:
|
||
updates["status"] = args.status
|
||
db.update_book(args.update_id, **updates)
|
||
print(f"✅ 已更新:《{book['title']}》→《{data.get('title', book['title'])}》(ID: {args.update_id})")
|
||
|
||
elif args.add:
|
||
# 写入新记录
|
||
db.init_db()
|
||
book_id = db.add_book(
|
||
data.get("title", ""),
|
||
author=data.get("author"),
|
||
translator=data.get("translator"),
|
||
publisher=data.get("publisher"),
|
||
pub_date=data.get("pub_date"),
|
||
cover_url=data.get("cover_url"),
|
||
format=args.format or "paper",
|
||
status=args.status or "to-read",
|
||
douban_score=data.get("douban_score"),
|
||
goodreads_score=data.get("goodreads_score"),
|
||
tags=data.get("tags", ""),
|
||
)
|
||
print(f"✅ 已添加:《{data.get('title')}》(ID: {book_id})")
|
||
else:
|
||
print("💡 加 --add 选项可直接写入数据库,或用 --update-id <ID> 更新已有记录。")
|
||
|
||
|
||
def cmd_build(args):
|
||
db.init_db()
|
||
output_path = web.build()
|
||
print(f"🌐 网页已生成:{output_path}")
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(
|
||
prog="bookshelf",
|
||
description="📚 个人图书阅读管理工具",
|
||
)
|
||
sub = parser.add_subparsers(dest="command", required=True)
|
||
|
||
# --- add ---
|
||
p_add = sub.add_parser("add", help="添加书目")
|
||
p_add.add_argument("title", help="书名")
|
||
p_add.add_argument("--author", help="作者")
|
||
p_add.add_argument("--translator", help="译者")
|
||
p_add.add_argument("--publisher", help="出版社")
|
||
p_add.add_argument("--pub-date", help="出版日期")
|
||
p_add.add_argument("--cover-url", help="封面图片 URL")
|
||
p_add.add_argument("--format", choices=FORMAT_CHOICES, default="paper", help="格式")
|
||
p_add.add_argument("--status", choices=STATUS_CHOICES, default="to-read", help="阅读状态")
|
||
p_add.add_argument("--rating", type=int, choices=range(1, 6), help="个人评分 1-5")
|
||
p_add.add_argument("--douban-score", type=float, help="豆瓣评分")
|
||
p_add.add_argument("--goodreads-score", type=float, help="Goodreads 评分")
|
||
p_add.add_argument("--tags", help="阅读主题标签,逗号分隔")
|
||
p_add.add_argument("--notes", help="笔记/简评")
|
||
p_add.add_argument("--start-date", help="开始阅读日期")
|
||
p_add.add_argument("--finish-date", help="完成阅读日期")
|
||
p_add.set_defaults(func=cmd_add)
|
||
|
||
# --- list ---
|
||
p_list = sub.add_parser("list", help="列出书目")
|
||
p_list.add_argument("--status", choices=STATUS_CHOICES, help="按状态筛选")
|
||
p_list.add_argument("--format", choices=FORMAT_CHOICES, help="按格式筛选")
|
||
p_list.add_argument("--tag", help="按标签筛选")
|
||
p_list.set_defaults(func=cmd_list)
|
||
|
||
# --- update ---
|
||
p_upd = sub.add_parser("update", help="更新书目")
|
||
p_upd.add_argument("id", type=int, help="书目 ID")
|
||
p_upd.add_argument("--title", help="书名")
|
||
p_upd.add_argument("--author", help="作者")
|
||
p_upd.add_argument("--translator", help="译者")
|
||
p_upd.add_argument("--publisher", help="出版社")
|
||
p_upd.add_argument("--pub-date", help="出版日期")
|
||
p_upd.add_argument("--cover-url", help="封面图片 URL")
|
||
p_upd.add_argument("--format", choices=FORMAT_CHOICES, help="格式")
|
||
p_upd.add_argument("--status", choices=STATUS_CHOICES, help="阅读状态")
|
||
p_upd.add_argument("--rating", type=int, choices=range(1, 6), help="个人评分 1-5")
|
||
p_upd.add_argument("--douban-score", type=float, help="豆瓣评分")
|
||
p_upd.add_argument("--goodreads-score", type=float, help="Goodreads 评分")
|
||
p_upd.add_argument("--tags", help="阅读主题标签,逗号分隔")
|
||
p_upd.add_argument("--notes", help="笔记/简评")
|
||
p_upd.add_argument("--start-date", help="开始阅读日期")
|
||
p_upd.add_argument("--finish-date", help="完成阅读日期")
|
||
p_upd.set_defaults(func=cmd_update)
|
||
|
||
# --- delete ---
|
||
p_del = sub.add_parser("delete", help="删除书目")
|
||
p_del.add_argument("id", type=int, help="书目 ID")
|
||
p_del.set_defaults(func=cmd_delete)
|
||
|
||
# --- fetch ---
|
||
p_fetch = sub.add_parser("fetch", help="从豆瓣/Google Books 刮削书目信息")
|
||
p_fetch.add_argument("identifier", help="ISBN(10/13位)或豆瓣图书链接")
|
||
p_fetch.add_argument("--add", action="store_true", help="刮削后直接写入数据库")
|
||
p_fetch.add_argument("--update-id", type=int, metavar="ID", help="刮削后更新指定 ID 的书目")
|
||
p_fetch.add_argument("--format", choices=FORMAT_CHOICES, help="书籍格式(入库时使用)")
|
||
p_fetch.add_argument("--status", choices=STATUS_CHOICES, help="阅读状态(入库时使用)")
|
||
p_fetch.set_defaults(func=cmd_fetch)
|
||
|
||
# --- build ---
|
||
p_build = sub.add_parser("build", help="生成展示网页")
|
||
p_build.set_defaults(func=cmd_build)
|
||
|
||
args = parser.parse_args()
|
||
args.func(args)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|