#!/usr/bin/env python3 """ 微信读书数据展示网站 - Flask 后端 提供 API 接口供前端查询本地 SQLite 数据库中的读书数据 """ import json import os import sqlite3 import time import urllib.request from datetime import datetime from flask import Flask, jsonify, render_template, request app = Flask(__name__, static_folder="static", template_folder="templates") DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "weread.db") WEREAD_API_URL = "https://i.weread.qq.com/api/agent/gateway" WEREAD_API_KEY = os.environ.get("WEREAD_API_KEY", "") WEREAD_SKILL_VERSION = "1.0.3" def weread_api_call(api_name, **kwargs): """调用微信读书 API(按需获取数据时使用)""" body = {"api_name": api_name, "skill_version": WEREAD_SKILL_VERSION} body.update(kwargs) req = urllib.request.Request( WEREAD_API_URL, data=json.dumps(body).encode("utf-8"), headers={ "Authorization": f"Bearer {WEREAD_API_KEY}", "Content-Type": "application/json", }, method="POST", ) with urllib.request.urlopen(req, timeout=30) as resp: return json.loads(resp.read().decode("utf-8")) def ensure_tables(conn): """确保按需数据表存在""" c = conn.cursor() c.executescript(""" CREATE TABLE IF NOT EXISTS book_details ( book_id TEXT PRIMARY KEY, title TEXT, author TEXT, translator TEXT, cover TEXT, intro TEXT, category TEXT, publisher TEXT, publish_time TEXT, isbn TEXT, word_count INTEGER, new_rating REAL, new_rating_count INTEGER, fetched_at INTEGER ); CREATE TABLE IF NOT EXISTS book_progress ( book_id TEXT PRIMARY KEY, progress INTEGER, chapter_uid INTEGER, chapter_offset INTEGER, update_time INTEGER, record_reading_time INTEGER, finish_time INTEGER, is_start_reading INTEGER, fetched_at INTEGER ); CREATE TABLE IF NOT EXISTS book_highlights ( id INTEGER PRIMARY KEY AUTOINCREMENT, book_id TEXT, bookmark_id TEXT, chapter_uid INTEGER, mark_text TEXT, create_time INTEGER, type INTEGER, range_text TEXT, color_style INTEGER, fetched_at INTEGER ); CREATE TABLE IF NOT EXISTS book_chapters ( id INTEGER PRIMARY KEY AUTOINCREMENT, book_id TEXT, chapter_uid INTEGER, chapter_idx INTEGER, title TEXT, UNIQUE(book_id, chapter_uid) ); CREATE TABLE IF NOT EXISTS book_reviews ( id INTEGER PRIMARY KEY AUTOINCREMENT, book_id TEXT, review_id TEXT, content TEXT, create_time INTEGER, star INTEGER, chapter_name TEXT, is_finish INTEGER, fetched_at INTEGER ); CREATE TABLE IF NOT EXISTS recommended_books ( id INTEGER PRIMARY KEY AUTOINCREMENT, book_id TEXT, title TEXT, author TEXT, cover TEXT, intro TEXT, category TEXT, reason TEXT, reading_count INTEGER, new_rating REAL, new_rating_count INTEGER, rating_title TEXT, fetched_at INTEGER ); CREATE TABLE IF NOT EXISTS search_results ( id INTEGER PRIMARY KEY AUTOINCREMENT, keyword TEXT, book_id TEXT, title TEXT, author TEXT, cover TEXT, intro TEXT, publisher TEXT, category TEXT, reading_count INTEGER, new_rating REAL, new_rating_count INTEGER, rating_title TEXT, search_idx INTEGER, fetched_at INTEGER ); """) conn.commit() def get_db(): conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row return conn def ts_to_date(ts): """时间戳转日期字符串""" if not ts or ts == 0: return "" return datetime.fromtimestamp(ts).strftime("%Y-%m-%d") def ts_to_year(ts): """时间戳转年份""" if not ts or ts == 0: return "" return datetime.fromtimestamp(ts).strftime("%Y") def format_duration(seconds): """秒转可读时长""" if not seconds: return "0分钟" hours = seconds // 3600 minutes = (seconds % 3600) // 60 if hours > 0: return f"{hours}小时{minutes}分钟" return f"{minutes}分钟" @app.route("/") def index(): return render_template("index.html") @app.route("/api/overview") def api_overview(): """总览数据""" conn = get_db() c = conn.cursor() # 总阅读统计 c.execute("SELECT * FROM reading_stats WHERE mode='overall' LIMIT 1") overall = c.fetchone() total_read_time = overall["total_read_time"] if overall else 0 read_days = overall["read_days"] if overall else 0 regist_time = overall["regist_time"] if overall else 0 # 今年统计 current_year = str(time.localtime().tm_year) c.execute("SELECT * FROM reading_stats WHERE mode='annually' LIMIT 1") annual = c.fetchone() year_read_time = annual["total_read_time"] if annual else 0 year_read_days = annual["read_days"] if annual else 0 # 书架数量 c.execute("SELECT COUNT(*) as cnt FROM books") shelf_count = c.fetchone()["cnt"] # 读完数量 c.execute("SELECT COUNT(*) as cnt FROM books WHERE finish_reading=1") finished_count = c.fetchone()["cnt"] # 笔记统计 c.execute("SELECT COUNT(*) as cnt FROM notebooks") notebook_count = c.fetchone()["cnt"] c.execute("SELECT SUM(review_count + note_count + bookmark_count) as cnt FROM notebooks") total_notes = c.fetchone()["cnt"] or 0 # 最近阅读时间 c.execute("SELECT MAX(read_update_time) as max_ts FROM books") last_read_ts = c.fetchone()["max_ts"] or 0 # 采集时间 c.execute("SELECT value FROM fetch_meta WHERE key='last_fetch_time'") fetch_row = c.fetchone() fetch_time = fetch_row["value"] if fetch_row else "" conn.close() return jsonify({ "total_read_time": total_read_time, "total_read_time_str": format_duration(total_read_time), "read_days": read_days, "year_read_time": year_read_time, "year_read_time_str": format_duration(year_read_time), "year_read_days": year_read_days, "shelf_count": shelf_count, "finished_count": finished_count, "notebook_count": notebook_count, "total_notes": total_notes, "regist_time": ts_to_date(regist_time), "last_read_time": ts_to_date(last_read_ts), "fetch_time": fetch_time, }) @app.route("/api/yearly-trend") def api_yearly_trend(): """年度阅读时长时间序列""" conn = get_db() c = conn.cursor() c.execute( "SELECT bucket_timestamp, duration FROM read_times WHERE mode='overall' ORDER BY bucket_timestamp" ) rows = c.fetchall() conn.close() data = [] for row in rows: year = ts_to_year(row["bucket_timestamp"]) hours = round(row["duration"] / 3600, 1) data.append({"year": year, "hours": hours, "seconds": row["duration"]}) return jsonify(data) @app.route("/api/top-books") def api_top_books(): """阅读时长最久的书籍""" conn = get_db() c = conn.cursor() c.execute( """SELECT * FROM read_longest WHERE mode='overall' ORDER BY read_time DESC LIMIT 20""" ) rows = c.fetchall() conn.close() books = [] for row in rows: tags = json.loads(row["tags"]) if row["tags"] else [] books.append({ "book_id": row["book_id"], "title": row["title"], "author": row["author"], "cover": row["cover"], "read_time": row["read_time"], "read_time_str": format_duration(row["read_time"]), "tags": tags, }) return jsonify(books) @app.route("/api/categories") def api_categories(): """偏好分类""" conn = get_db() c = conn.cursor() c.execute( """SELECT * FROM prefer_categories WHERE mode='overall' AND reading_time > 0 ORDER BY reading_time DESC""" ) rows = c.fetchall() conn.close() cats = [] for row in rows: cats.append({ "category_title": row["category_title"], "parent_category_title": row["parent_category_title"], "reading_time": row["reading_time"], "reading_time_str": format_duration(row["reading_time"]), "reading_count": row["reading_count"], "val": row["val"], }) return jsonify(cats) @app.route("/api/authors") def api_authors(): """偏好作者""" conn = get_db() c = conn.cursor() c.execute( """SELECT * FROM prefer_authors WHERE mode='overall' ORDER BY count DESC LIMIT 20""" ) rows = c.fetchall() conn.close() authors = [] for row in rows: authors.append({ "name": row["name"], "count": row["count"], "read_time": row["read_time"], }) return jsonify(authors) @app.route("/api/time-distribution") def api_time_distribution(): """24小时阅读时段分布""" conn = get_db() c = conn.cursor() c.execute( """SELECT hour_index, SUM(duration) as total FROM prefer_time WHERE mode='overall' GROUP BY hour_index ORDER BY hour_index""" ) rows = c.fetchall() conn.close() dist = [] for row in rows: dist.append({ "hour": row["hour_index"], "duration": row["total"], "duration_min": round(row["total"] / 60, 1), }) return jsonify(dist) @app.route("/api/shelf") def api_shelf(): """书架列表""" page = int(request.args.get("page", 1)) per_page = int(request.args.get("per_page", 60)) category = request.args.get("category", "") finished_only = request.args.get("finished", "0") == "1" offset = (page - 1) * per_page conn = get_db() c = conn.cursor() where_clauses = [] params = [] if category: where_clauses.append("category LIKE ?") params.append(f"%{category}%") if finished_only: where_clauses.append("finish_reading=1") where_sql = " WHERE " + " AND ".join(where_clauses) if where_clauses else "" c.execute(f"SELECT COUNT(*) as cnt FROM books{where_sql}", params) total = c.fetchone()["cnt"] c.execute( f"SELECT * FROM books{where_sql} ORDER BY is_top DESC, read_update_time DESC LIMIT ? OFFSET ?", params + [per_page, offset], ) rows = c.fetchall() # 获取分类列表 c.execute("SELECT DISTINCT category FROM books WHERE category != '' ORDER BY category") categories = [r["category"] for r in c.fetchall()] conn.close() books = [] for row in rows: books.append({ "book_id": row["book_id"], "title": row["title"], "author": row["author"], "cover": row["cover"], "category": row["category"], "finish_reading": row["finish_reading"], "read_update_time": ts_to_date(row["read_update_time"]), "secret": row["secret"], "is_top": row["is_top"], "deep_link": row["deep_link"], }) return jsonify({ "books": books, "total": total, "page": page, "per_page": per_page, "total_pages": (total + per_page - 1) // per_page, "categories": categories, }) @app.route("/api/notebooks") def api_notebooks(): """笔记本概览""" conn = get_db() c = conn.cursor() c.execute( """SELECT * FROM notebooks ORDER BY (review_count + note_count + bookmark_count) DESC""" ) rows = c.fetchall() conn.close() notebooks = [] for row in rows: total = row["review_count"] + row["note_count"] + row["bookmark_count"] notebooks.append({ "book_id": row["book_id"], "title": row["title"], "author": row["author"], "cover": row["cover"], "review_count": row["review_count"], "note_count": row["note_count"], "bookmark_count": row["bookmark_count"], "total_notes": total, "reading_progress": row["reading_progress"], "marked_status": row["marked_status"], }) return jsonify(notebooks) @app.route("/api/read-stats") def api_read_stats(): """阅读统计摘要项""" conn = get_db() c = conn.cursor() c.execute("SELECT * FROM read_stat_items WHERE mode='overall'") rows = c.fetchall() conn.close() stats = [] for row in rows: stats.append({ "stat": row["stat_name"], "counts": row["counts"], "scheme": row["scheme"], }) return jsonify(stats) @app.route("/api/archives") def api_archives(): """书单""" conn = get_db() c = conn.cursor() c.execute("SELECT * FROM archives") rows = c.fetchall() conn.close() archives = [] for row in rows: book_ids = json.loads(row["book_ids"]) if row["book_ids"] else [] archives.append({ "name": row["name"], "book_count": len(book_ids), "book_ids": book_ids, }) return jsonify(archives) # ===== 按需获取:书籍详情 ===== @app.route("/api/book/detail") def api_book_detail(): """书籍详情:实时调微信读书 API 获取,入库后返回""" book_id = request.args.get("book_id", "") if not book_id: return jsonify({"error": "book_id is required"}), 400 conn = get_db() ensure_tables(conn) c = conn.cursor() now = int(time.time()) # 1. 获取书籍信息 try: info = weread_api_call("/book/info", bookId=book_id) c.execute( """INSERT OR REPLACE INTO book_details (book_id, title, author, translator, cover, intro, category, publisher, publish_time, isbn, word_count, new_rating, new_rating_count, fetched_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( book_id, info.get("title", ""), info.get("author", ""), info.get("translator", ""), info.get("cover", ""), info.get("intro", ""), info.get("category", ""), info.get("publisher", ""), info.get("publishTime", ""), info.get("isbn", ""), info.get("wordCount", 0), info.get("newRating", 0), info.get("newRatingCount", 0), now, ), ) conn.commit() except Exception as e: # API 调用失败时尝试从数据库读取缓存 c.execute("SELECT * FROM book_details WHERE book_id=?", (book_id,)) cached = c.fetchone() if not cached: return jsonify({"error": f"获取书籍信息失败: {str(e)}"}), 500 # 2. 获取阅读进度 progress_data = {} try: prog = weread_api_call("/book/getprogress", bookId=book_id) book_prog = prog.get("book", {}) c.execute( """INSERT OR REPLACE INTO book_progress (book_id, progress, chapter_uid, chapter_offset, update_time, record_reading_time, finish_time, is_start_reading, fetched_at) VALUES (?,?,?,?,?,?,?,?,?)""", ( book_id, book_prog.get("progress", 0), book_prog.get("chapterUid", 0), book_prog.get("chapterOffset", 0), book_prog.get("updateTime", 0), book_prog.get("recordReadingTime", 0), book_prog.get("finishTime", 0), book_prog.get("isStartReading", 0), now, ), ) conn.commit() progress_data = { "progress": book_prog.get("progress", 0), "record_reading_time": book_prog.get("recordReadingTime", 0), "record_reading_time_str": format_duration(book_prog.get("recordReadingTime", 0)), "update_time": ts_to_date(book_prog.get("updateTime", 0)), "finish_time": ts_to_date(book_prog.get("finishTime", 0)), "is_start_reading": book_prog.get("isStartReading", 0), } except Exception: c.execute("SELECT * FROM book_progress WHERE book_id=?", (book_id,)) cached = c.fetchone() if cached: progress_data = { "progress": cached["progress"], "record_reading_time": cached["record_reading_time"], "record_reading_time_str": format_duration(cached["record_reading_time"]), "update_time": ts_to_date(cached["update_time"]), "finish_time": ts_to_date(cached["finish_time"]), "is_start_reading": cached["is_start_reading"], } # 3. 组装返回 c.execute("SELECT * FROM book_details WHERE book_id=?", (book_id,)) detail = c.fetchone() conn.close() if not detail: return jsonify({"error": "数据获取失败"}), 500 return jsonify({ "book_id": book_id, "title": detail["title"], "author": detail["author"], "translator": detail["translator"], "cover": detail["cover"], "intro": detail["intro"], "category": detail["category"], "publisher": detail["publisher"], "publish_time": detail["publish_time"], "isbn": detail["isbn"], "word_count": detail["word_count"], "new_rating": detail["new_rating"], "new_rating_count": detail["new_rating_count"], "progress": progress_data, "deep_link": f"weread://reading?bId={book_id}", }) # ===== 按需获取:笔记详情 ===== @app.route("/api/book/notes") def api_book_notes(): """笔记详情:实时调微信读书 API 获取划线+想法,入库后返回""" book_id = request.args.get("book_id", "") if not book_id: return jsonify({"error": "book_id is required"}), 400 conn = get_db() ensure_tables(conn) c = conn.cursor() now = int(time.time()) # 1. 获取划线 highlights = [] chapters = {} try: data = weread_api_call("/book/bookmarklist", bookId=book_id) # 保存章节信息 for ch in data.get("chapters", []): c.execute( """INSERT OR REPLACE INTO book_chapters (book_id, chapter_uid, chapter_idx, title) VALUES (?,?,?,?)""", (book_id, ch.get("chapterUid", 0), ch.get("chapterIdx", 0), ch.get("title", "")), ) chapters[ch.get("chapterUid", 0)] = ch.get("title", "") # 保存划线 c.execute("DELETE FROM book_highlights WHERE book_id=?", (book_id,)) for bm in data.get("updated", []): c.execute( """INSERT INTO book_highlights (book_id, bookmark_id, chapter_uid, mark_text, create_time, type, range_text, color_style, fetched_at) VALUES (?,?,?,?,?,?,?,?,?)""", ( book_id, bm.get("bookmarkId", ""), bm.get("chapterUid", 0), bm.get("markText", ""), bm.get("createTime", 0), bm.get("type", 0), bm.get("range", ""), bm.get("colorStyle", 0), now, ), ) highlights.append({ "bookmark_id": bm.get("bookmarkId", ""), "chapter_uid": bm.get("chapterUid", 0), "chapter_title": chapters.get(bm.get("chapterUid", 0), ""), "mark_text": bm.get("markText", ""), "create_time": ts_to_date(bm.get("createTime", 0)), "range": bm.get("range", ""), }) conn.commit() except Exception as e: # 从数据库读取缓存 c.execute("SELECT * FROM book_highlights WHERE book_id=? ORDER BY create_time", (book_id,)) for row in c.fetchall(): c2 = conn.cursor() c2.execute("SELECT title FROM book_chapters WHERE book_id=? AND chapter_uid=?", (book_id, row["chapter_uid"])) ch_row = c2.fetchone() highlights.append({ "bookmark_id": row["bookmark_id"], "chapter_uid": row["chapter_uid"], "chapter_title": ch_row["title"] if ch_row else "", "mark_text": row["mark_text"], "create_time": ts_to_date(row["create_time"]), "range": row["range_text"], }) # 2. 获取想法/点评 reviews = [] try: rev_data = weread_api_call("/review/list/mine", bookid=book_id, count=100) c.execute("DELETE FROM book_reviews WHERE book_id=?", (book_id,)) for item in rev_data.get("reviews", []): rev = item.get("review", {}) c.execute( """INSERT INTO book_reviews (book_id, review_id, content, create_time, star, chapter_name, is_finish, fetched_at) VALUES (?,?,?,?,?,?,?,?)""", ( book_id, rev.get("reviewId", ""), rev.get("content", ""), rev.get("createTime", 0), rev.get("star", 0), rev.get("chapterName", ""), rev.get("isFinish", 0), now, ), ) reviews.append({ "review_id": rev.get("reviewId", ""), "content": rev.get("content", ""), "create_time": ts_to_date(rev.get("createTime", 0)), "star": rev.get("star", 0), "chapter_name": rev.get("chapterName", ""), "is_finish": rev.get("isFinish", 0), }) conn.commit() except Exception: c.execute("SELECT * FROM book_reviews WHERE book_id=? ORDER BY create_time DESC", (book_id,)) for row in c.fetchall(): reviews.append({ "review_id": row["review_id"], "content": row["content"], "create_time": ts_to_date(row["create_time"]), "star": row["star"], "chapter_name": row["chapter_name"], "is_finish": row["is_finish"], }) conn.close() # 按章节分组划线 grouped_highlights = {} for h in highlights: ch_title = h["chapter_title"] or f"章节{h['chapter_uid']}" if ch_title not in grouped_highlights: grouped_highlights[ch_title] = [] grouped_highlights[ch_title].append(h) return jsonify({ "book_id": book_id, "highlights": highlights, "highlight_count": len(highlights), "grouped_highlights": grouped_highlights, "reviews": reviews, "review_count": len(reviews), }) # ===== 推荐书籍 ===== @app.route("/api/recommend") def api_recommend(): """个性化推荐:实时调微信读书 API,入库后返回""" max_idx = int(request.args.get("maxIdx", 0)) conn = get_db() ensure_tables(conn) c = conn.cursor() now = int(time.time()) try: data = weread_api_call("/book/recommend", count=12, maxIdx=max_idx) books = data.get("books", []) for book in books: rating_detail = book.get("newRatingDetail", {}) c.execute( """INSERT OR REPLACE INTO recommended_books (book_id, title, author, cover, intro, category, reason, reading_count, new_rating, new_rating_count, rating_title, fetched_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""", ( book.get("bookId", ""), book.get("title", ""), book.get("author", ""), book.get("cover", ""), book.get("intro", ""), book.get("category", ""), book.get("reason", ""), book.get("readingCount", 0), book.get("newRating", 0), book.get("newRatingCount", 0), rating_detail.get("title", "") if isinstance(rating_detail, dict) else "", now, ), ) conn.commit() except Exception as e: return jsonify({"error": f"获取推荐失败: {str(e)}"}), 500 conn.close() result = [] for book in books: rating_detail = book.get("newRatingDetail", {}) result.append({ "book_id": book.get("bookId", ""), "title": book.get("title", ""), "author": book.get("author", ""), "cover": book.get("cover", ""), "intro": book.get("intro", ""), "category": book.get("category", ""), "reason": book.get("reason", ""), "reading_count": book.get("readingCount", 0), "new_rating": book.get("newRating", 0), "new_rating_count": book.get("newRatingCount", 0), "rating_title": rating_detail.get("title", "") if isinstance(rating_detail, dict) else "", }) return jsonify({ "books": result, "has_more": data.get("hasMore", 0), "last_search_idx": books[-1].get("searchIdx", 0) if books else 0, }) # ===== 搜索书籍 ===== @app.route("/api/search") def api_search(): """搜索书籍:实时调微信读书 API,入库后返回""" keyword = request.args.get("keyword", "").strip() # 修复 Flask URL 参数中文编码问题 try: keyword = keyword.encode("latin-1").decode("utf-8") except (UnicodeDecodeError, UnicodeEncodeError): pass if not keyword: return jsonify({"error": "keyword is required"}), 400 max_idx = int(request.args.get("maxIdx", 0)) conn = get_db() ensure_tables(conn) c = conn.cursor() now = int(time.time()) try: data = weread_api_call("/store/search", keyword=keyword, scope=10, maxIdx=max_idx) results = data.get("results", []) books = [] for group in results: for item in group.get("books", []): bi = item.get("bookInfo", {}) rating_detail = item.get("newRatingDetail", {}) c.execute( """INSERT OR REPLACE INTO search_results (keyword, book_id, title, author, cover, intro, publisher, category, reading_count, new_rating, new_rating_count, rating_title, search_idx, fetched_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( keyword, bi.get("bookId", ""), bi.get("title", ""), bi.get("author", ""), bi.get("cover", ""), bi.get("intro", ""), bi.get("publisher", ""), bi.get("category", ""), item.get("readingCount", 0), item.get("newRating", 0), item.get("newRatingCount", 0), rating_detail.get("title", "") if isinstance(rating_detail, dict) else "", item.get("searchIdx", 0), now, ), ) books.append({ "book_id": bi.get("bookId", ""), "title": bi.get("title", ""), "author": bi.get("author", ""), "cover": bi.get("cover", ""), "intro": bi.get("intro", ""), "publisher": bi.get("publisher", ""), "category": bi.get("category", ""), "reading_count": item.get("readingCount", 0), "new_rating": item.get("newRating", 0), "new_rating_count": item.get("newRatingCount", 0), "rating_title": rating_detail.get("title", "") if isinstance(rating_detail, dict) else "", }) conn.commit() except Exception as e: return jsonify({"error": f"搜索失败: {str(e)}"}), 500 conn.close() return jsonify({ "keyword": keyword, "books": books, "has_more": data.get("hasMore", 0), }) if __name__ == "__main__": app.run(host="0.0.0.0", port=8080, debug=False)