#!/usr/bin/env python3 """ 阅间 · 微信读书看板 v2 数据接口层 ================================== 为 v2 前端(阅间设计版)提供数据聚合接口。 设计原则: 1. 不修改 v1 的任何接口,v2 全部挂在 /api/v2/* 下 2. 以微信读书 Agent Gateway 实时数据为主、本地 MySQL 为缓存与聚合补充 3. MySQL 不可用时降级运行(实时接口仍可用),只在响应里标注 degraded 4. 热点接口带进程内 TTL 缓存,避免频繁打微信读书接口 作者:WorkBuddy 创建时间:2026-09-11 """ import hashlib import json import os import time import urllib.error import urllib.request from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timedelta, timezone from functools import wraps import pymysql from flask import Blueprint, jsonify, request v2_bp = Blueprint("v2", __name__) # ====================================================================== # 配置 # ====================================================================== WEREAD_API_URL = os.environ.get("WEREAD_API_URL", "https://i.weread.qq.com/api/agent/gateway") WEREAD_API_KEY = os.environ.get("WEREAD_API_KEY", "") WEREAD_SKILL_VERSION = os.environ.get("WEREAD_SKILL_VERSION", "1.0.4") MYSQL_CONFIG = { "host": os.environ.get("WEREAD_DB_HOST", "124.222.38.85"), "port": int(os.environ.get("WEREAD_DB_PORT", 13306)), "user": os.environ.get("WEREAD_DB_USER", "root"), "password": os.environ.get("WEREAD_DB_PASSWORD", "258963"), "database": os.environ.get("WEREAD_DB_NAME", "weread_dashboard"), "charset": "utf8mb4", "cursorclass": pymysql.cursors.DictCursor, "connect_timeout": 5, } CST = timezone(timedelta(hours=8)) # 进程内缓存 {key: (expire_ts, value)} _CACHE = {} _CACHE_LOCK = None def cached(key, ttl, producer): """带 TTL 的进程内缓存;producer 抛异常时不写缓存。""" now = time.time() hit = _CACHE.get(key) if hit and hit[0] > now: return hit[1] value = producer() _CACHE[key] = (now + ttl, value) return value # ====================================================================== # 微信读书接口调用 # ====================================================================== class WeReadError(RuntimeError): """微信读书接口调用异常。""" def weapi(api_name, ttl=0, **kwargs): """ 调用微信读书 Agent Gateway。 Args: api_name: 接口名,如 "/readdata/detail" ttl: 进程内缓存秒数,0 表示不缓存 **kwargs: 业务参数(平铺在 body 顶层) """ if not WEREAD_API_KEY: raise WeReadError("WEREAD_API_KEY 未设置") cache_key = f"api:{api_name}:{json.dumps(kwargs, sort_keys=True, ensure_ascii=False)}" def _produce(): 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", ) try: with urllib.request.urlopen(req, timeout=25) as resp: data = json.loads(resp.read().decode("utf-8")) except urllib.error.HTTPError as e: raise WeReadError(f"{api_name} 调用失败: HTTP {e.code}") except Exception as e: # noqa: BLE001 raise WeReadError(f"{api_name} 调用失败: {e}") errcode = data.get("errcode") if errcode: raise WeReadError(f"{api_name} 返回错误 {errcode}: {data.get('errmsg', '')}") return data if ttl: return cached(cache_key, ttl, _produce) return _produce() def weapi_safe(api_name, ttl=0, default=None, **kwargs): """调用失败时返回 default,不抛异常(用于可降级的数据项)。""" try: return weapi(api_name, ttl=ttl, **kwargs) except Exception: # noqa: BLE001 return default # ====================================================================== # 数据库(可降级) # ====================================================================== def db_or_none(): """返回 MySQL 连接;不可用时返回 None(接口降级运行)。""" try: return pymysql.connect(**MYSQL_CONFIG) except Exception: # noqa: BLE001 return None def with_db(f): """ 装饰器:注入关键字参数 conn(可能为 None)与 degraded 标记。 请求结束后关闭连接。 """ @wraps(f) def wrapper(*args, **kwargs): conn = db_or_none() try: result = f(conn=conn, *args, **kwargs) return result finally: if conn: conn.close() return wrapper def query_all(conn, sql, params=None): if not conn: return [] with conn.cursor() as cur: cur.execute(sql, params or ()) return list(cur.fetchall()) def query_one(conn, sql, params=None): rows = query_all(conn, sql, params) return rows[0] if rows else None # ====================================================================== # 格式化与展示辅助 # ====================================================================== def ts_to_date(ts): """时间戳 → YYYY-MM-DD。""" try: ts = int(ts) except (TypeError, ValueError): return "" if ts <= 0: return "" return datetime.fromtimestamp(ts, tz=CST).strftime("%Y-%m-%d") def duration_str(seconds): """秒 → X小时Y分钟 / Y分钟。""" seconds = int(seconds or 0) hours, minutes = seconds // 3600, (seconds % 3600) // 60 if hours > 0: return f"{hours}小时{minutes}分钟" return f"{minutes}分钟" def cover_class(book_id): """按 book_id 稳定映射到设计稿的 4 种封面底色(c1~c4)。""" digest = hashlib.md5(str(book_id).encode("utf-8")).hexdigest() return f"c{int(digest[:8], 16) % 4 + 1}" def short_title(title): """把书名切成封面上的短行(最多 3 行)。""" text = (title or "").strip() for ch in "《》": text = text.replace(ch, "") # 去掉括号里的副标题,封面只留主书名 for left, right in (("(", ")"), ("(", ")"), ("【", "】"), ("[", "]")): while left in text and right in text: start = text.index(left) end = text.index(right, start) text = text[:start] + text[end + 1 :] text = text.split("·")[0].strip() if not text: text = (title or "未命名").strip() if len(text) <= 5: return text # 按标点优先断行,其次按长度均分 for sep in (":", ":", ",", ",", " "): if sep in text: head = text.split(sep)[0] if 2 <= len(head) <= 8: return head line_count = 3 if len(text) > 10 else 2 size = (len(text) + line_count - 1) // line_count return "\n".join(text[i : i + size] for i in range(0, len(text), size)) def norm_progress(value): """进度统一到 0~100 的整数。""" try: value = float(value or 0) except (TypeError, ValueError): return 0 if value <= 0: return 0 if value > 100: # 有的字段是万分比 value = value / 100 return max(0, min(100, int(round(value)))) RECENT_DAYS = 30 # 「在读」判定窗口:有进度,或最近 30 天内有阅读记录 def book_status(finish, progress, read_update_time, recent_days=RECENT_DAYS): """ 书籍状态口径: done —— 已读完(接口 finish_reading=1) reading—— 有阅读进度(0= cutoff: return "reading" return "wish" def make_book( book_id, title, author=None, category=None, finish=0, read_update_time=0, cover_url=None, deep_link=None, progress=None, rating=None, read_time=None, intro=None, ): """ 组装成 v2 前端使用的书籍结构(字段名与设计稿一致)。 """ finish = int(finish or 0) read_update_time = int(read_update_time or 0) if progress is None: progress = 100 if finish else 0 if rating: score = round(float(rating) / 100, 1) # 771 → 7.7 → 前端展示 77/100 else: score = None return { "id": str(book_id), "bookId": str(book_id), "title": title or "未命名", "short": short_title(title), "author": author or "佚名", "en": "", "cover": cover_class(book_id), "cover_url": cover_url or "", "progress": norm_progress(progress), "status": book_status(finish, progress, read_update_time), "score": score, "desc": intro or (f"书架中「{category}」分类的藏书。" if category else "书架中的藏书。"), "category": category or "", "read_time": read_time or 0, "read_time_str": duration_str(read_time) if read_time else "", "read_update_time": ts_to_date(read_update_time), "finish_reading": finish, "deep_link": deep_link or "", } # ====================================================================== # 书架:实时数据 + 本地进度/评分缓存合并 # ====================================================================== def shelf_counts(payload): """按接口规范统计书架条目:电子书 + 专辑 + 文章收藏入口。""" books = payload.get("books") or [] albums = payload.get("albums") or [] has_mp = 1 if payload.get("mp") else 0 return { "ebooks": len(books), "albums": len(albums), "mp": has_mp, "total": len(books) + len(albums) + has_mp, "archives": len(payload.get("archive") or []), } def shelf_payload(): """书架原始数据(缓存 10 分钟)。""" return weapi("/shelf/sync", ttl=600) def progress_map(conn): """从本地缓存表收集阅读进度:notebooks.reading_progress 优先,其次 book_progress。""" result = {} for row in query_all( conn, "SELECT book_id, reading_progress AS p FROM notebooks WHERE reading_progress > 0", ): result[str(row["book_id"])] = row["p"] for row in query_all( conn, "SELECT book_id, progress AS p FROM book_progress WHERE progress > 0" ): result.setdefault(str(row["book_id"]), row["p"]) return result def rating_map(conn): """从本地缓存表收集评分(0~1000)。""" rows = query_all(conn, "SELECT book_id, new_rating AS r FROM book_details WHERE new_rating > 0") return {str(row["book_id"]): row["r"] for row in rows} def intro_map(conn): rows = query_all(conn, "SELECT book_id, intro, word_count FROM book_details") return {str(row["book_id"]): row for row in rows} def persist_detail(conn, bid, info): """把书籍基本信息写入缓存表(供后续快速读取评分/简介)。""" if not conn or not info: return rating_detail = info.get("newRatingDetail") try: with conn.cursor() as cur: cur.execute( "REPLACE INTO book_details " "(book_id, title, author, translator, cover, intro, category, publisher, " "publish_time, isbn, word_count, new_rating, new_rating_count, new_rating_detail, deep_link) " "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", ( str(bid), 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") or 0, info.get("newRating") or 0, info.get("newRatingCount") or 0, json.dumps(rating_detail, ensure_ascii=False) if isinstance(rating_detail, dict) else None, info.get("deepLink", ""), ), ) conn.commit() except Exception: # noqa: BLE001 pass def persist_progress(conn, bid, info): """把阅读进度写入缓存表。""" if not conn or not info: return try: with conn.cursor() as cur: cur.execute( "REPLACE INTO book_progress " "(book_id, progress, chapter_uid, chapter_offset, update_time, update_time_dt, " "record_reading_time, finish_time, finish_time_dt, is_start_reading) " "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", ( str(bid), int(info.get("progress") or 0), info.get("chapter_uid") or 0, info.get("chapter_offset") or 0, info.get("update_time") or 0, ts_to_datetime(info.get("update_time") or 0), int(info.get("record_reading_time") or 0), info.get("finish_time") or 0, ts_to_datetime(info.get("finish_time") or 0), info.get("is_start_reading") or 0, ), ) conn.commit() except Exception: # noqa: BLE001 pass def ts_to_datetime(ts): try: ts = int(ts) except (TypeError, ValueError): return None if ts <= 0: return None return datetime.fromtimestamp(ts, tz=CST).strftime("%Y-%m-%d %H:%M:%S") def enrich_books(conn, book_ids, with_rating=True, workers=6): """ 首屏富化:并发补齐阅读进度(并可同时补评分),结果写入本地缓存表, 下次访问直接命中缓存。返回 {book_id: {progress, rating, ...}}。 """ book_ids = [str(b) for b in book_ids if b] if not book_ids: return {} def one(bid): result = {"progress": None, "rating": None} prog = weapi_safe("/book/getprogress", ttl=600, bookId=bid) if prog: book = prog.get("book") or {} result["progress"] = { "progress": norm_progress(book.get("progress")), "record_reading_time": book.get("recordReadingTime") or 0, "finish_time": book.get("finishTime") or 0, "update_time": book.get("updateTime") or 0, "is_start_reading": book.get("isStartReading") or 0, "chapter_uid": book.get("chapterUid") or 0, "chapter_offset": book.get("chapterOffset") or 0, } persist_progress(conn, bid, result["progress"]) if with_rating: info = weapi_safe("/book/info", ttl=1800, bookId=bid) if info and info.get("newRating"): result["rating"] = info.get("newRating") persist_detail(conn, bid, info) return bid, result with ThreadPoolExecutor(max_workers=workers) as pool: return {bid: info for bid, info in pool.map(one, book_ids)} def shelf_books(conn, with_progress=False, progress_limit=30): """ 书架书籍列表(设计稿字段结构)。 Args: with_progress: 是否富化首屏书籍(实时补拉进度与评分,并写入本地缓存) progress_limit: 单次富化的最大本数 """ payload = shelf_payload() known_progress = progress_map(conn) known_rating = rating_map(conn) rows = payload.get("books") or [] books = [] for row in rows: bid = str(row.get("bookId") or "") if not bid: continue books.append( make_book( bid, row.get("title"), row.get("author"), row.get("category"), finish=row.get("finishReading"), read_update_time=row.get("readUpdateTime"), cover_url=row.get("cover"), deep_link=row.get("deepLink"), progress=known_progress.get(bid), rating=known_rating.get(bid), ) ) # 首屏富化:进度与评分缺失的书并发补齐(结果落库,下次直接命中缓存) if with_progress: missing = [b["id"] for b in books if b["id"] not in known_progress or b["id"] not in known_rating] enriched = enrich_books(conn, missing[:progress_limit]) for book in books: info = enriched.get(book["id"]) if not info: continue prog = info.get("progress") if prog: if prog["progress"]: book["progress"] = prog["progress"] if prog["record_reading_time"]: book["read_time"] = prog["record_reading_time"] book["read_time_str"] = duration_str(prog["record_reading_time"]) if prog["finish_time"]: book["status"] = "done" elif not book["read_update_time"] and prog["update_time"]: book["read_update_time"] = ts_to_date(prog["update_time"]) if info.get("rating"): book["score"] = round(float(info["rating"]) / 100, 1) if book["status"] != "done": book["status"] = book_status(book["finish_reading"], book["progress"], int(_ts(book["read_update_time"])), 30) return books, payload def _ts(date_text): """YYYY-MM-DD → 时间戳(无法解析返回 0)。""" if not date_text: return 0 try: return int(datetime.strptime(date_text, "%Y-%m-%d").replace(tzinfo=CST).timestamp()) except ValueError: return 0 # ====================================================================== # 接口:连接状态 # ====================================================================== @v2_bp.route("/api/v2/status") @with_db def api_v2_status(conn): """前端顶栏/横幅使用:数据源连接状态与书架规模。""" db_ok = conn is not None counts, err = None, None try: payload = shelf_payload() counts = shelf_counts(payload) except Exception as e: # noqa: BLE001 err = str(e) fetch_time = "" row = query_one(conn, "SELECT value FROM fetch_meta WHERE `key`='last_fetch_time'") if row: fetch_time = row["value"] return jsonify( { "ok": bool(counts), "api_key_set": bool(WEREAD_API_KEY), "database": "connected" if db_ok else "disconnected", "fetch_time": fetch_time, "counts": counts, "error": err, "degraded": not db_ok, } ) # ====================================================================== # 接口:阅读概览(趋势 + 统计 + 洞察,一次请求返回整页数据) # ====================================================================== PERIOD_MODE = {"week": "weekly", "month": "monthly", "year": "annually", "overall": "overall"} PERIOD_HINT = {"week": "本周", "month": "本月", "year": "本年", "overall": "总计"} def natural_days(period, regist_time=0): """周期自然天数(未满周期按已过天数计)。""" now = datetime.now(tz=CST) if period == "week": return 7 if period == "month": return now.day if period == "year": return now.timetuple().tm_yday if regist_time: try: start = datetime.fromtimestamp(int(regist_time), tz=CST) return max(1, (now - start).days + 1) except (ValueError, OSError): return 365 return 365 def build_trend(period, read_times): """把 readTimes 字典转成设计稿柱状图数据。""" items = sorted(((int(k), int(v or 0)) for k, v in (read_times or {}).items()), key=lambda x: x[0]) is_hour_unit = period in ("year", "overall") def shown(duration): return round(duration / 3600, 1) if is_hour_unit else int(round(duration / 60)) # 去掉首尾「折算后为 0」的空桶(未到的月份 / 注册初期的年份) while len(items) > 1 and shown(items[-1][1]) == 0: items.pop() while len(items) > 1 and shown(items[0][1]) == 0: items.pop(0) labels, values, seconds = [], [], [] for ts, duration in items: dt = datetime.fromtimestamp(ts, tz=CST) if period == "week": labels.append("一二三四五六日"[dt.weekday()]) elif period == "month": labels.append(f"{dt.day}日") elif period == "year": labels.append(f"{dt.month}月") else: labels.append(str(dt.year)) values.append(shown(duration)) seconds.append(duration) total_seconds = sum(seconds) return { "labels": labels, "values": values, "unit": "时" if is_hour_unit else "分", "minutes": round(total_seconds / 60, 1), "total_seconds": total_seconds, } def dedupe_by_book(items, key="bookId", limit=None): """按书籍去重(接口返回里同一本书可能出现多条)。""" seen, out = set(), [] for item in items or []: k = item.get(key) or item.get("book_id") or item.get("title") if k in seen: continue seen.add(k) out.append(item) return out[:limit] if limit else out @v2_bp.route("/api/v2/dashboard") @with_db def api_v2_dashboard(conn): """ 阅读概览整页数据:周期趋势、统计卡、读得最多、偏好方向、时段分布、阅读画像。 Query: period=week|month|year|overall(默认 week) """ period = request.args.get("period", "week") if period not in PERIOD_MODE: period = "week" mode = PERIOD_MODE[period] data = weapi_safe("/readdata/detail", ttl=300, mode=mode, **({"baseTime": 0} if mode != "overall" else {})) if not data: return jsonify({"error": "阅读统计获取失败,请稍后重试"}), 502 # 周/月维度接口不回偏好画像字段,缺失项用「总计」口径补齐并标注来源 pref_source = "period" pref_data = data if mode != "overall" and not (data.get("preferCategory") and data.get("preferTime")): fallback = weapi_safe("/readdata/detail", ttl=600, mode="overall") or {} if fallback: pref_source = "overall" merged = dict(fallback) for k in ("preferCategory", "preferTime", "preferAuthor", "preferCp", "preferPublisher", "medals", "preferCategoryWord", "preferTimeWord", "preferBooks"): if data.get(k): merged[k] = data[k] pref_data = merged trend = build_trend(period, data.get("readTimes") or {}) total_seconds = int(data.get("totalReadTime") or trend["total_seconds"] or 0) total_minutes = total_seconds / 60 read_days = int(data.get("readDays") or 0) days = natural_days(period, data.get("registTime")) # 笔记口径:本地笔记本合计(全量);周期口径见 readStat note_row = query_one( conn, "SELECT COALESCE(SUM(review_count),0) AS review, COALESCE(SUM(note_count),0) AS note, " "COALESCE(SUM(bookmark_count),0) AS bookmark, COUNT(*) AS books FROM notebooks", ) or {} note_review = int(note_row.get("review") or 0) note_note = int(note_row.get("note") or 0) note_bookmark = int(note_row.get("bookmark") or 0) note_total = note_review + note_note + note_bookmark note_books = int(note_row.get("books") or 0) stats = [ { "label": "阅读 / 收听时长", "value": duration_str(total_seconds), "hint": f"{PERIOD_HINT[period]}合计", "icon": "clock", }, { "label": "有效阅读天数", "value": f"{read_days}天", "hint": "单日满 1 分钟计一天", "icon": "flag", }, { "label": "自然日均阅读", "value": f"{round(total_minutes / days)}分钟" if days else "0分钟", "hint": f"按 {days} 个自然日折算", "icon": "chart", }, { "label": "笔记总数", "value": f"{note_total}条", "hint": f"划线 {note_note} · 想法 {note_review} · 书签 {note_bookmark}", "icon": "note", }, ] # 读得最多(周期内) top_books = [] for item in dedupe_by_book(data.get("readLongest") or [], limit=3): book = item.get("book") or {} album = item.get("albumInfo") or {} top_books.append( { "id": str(book.get("bookId") or album.get("albumId") or ""), "title": book.get("title") or album.get("name") or "", "author": book.get("author") or album.get("authorName") or "", "read_time": int(item.get("readTime") or 0), "read_time_str": duration_str(item.get("readTime") or 0), } ) # 偏爱的阅读方向(按分类合并同年份重复条目,权重归一化到 100) cat_agg = {} for cat in pref_data.get("preferCategory") or []: title = cat.get("categoryTitle") or "" if not title: continue entry = cat_agg.setdefault( title, {"title": title, "parent": cat.get("parentCategoryTitle") or "", "seconds": 0, "count": 0}, ) entry["seconds"] += int(cat.get("readingTime") or 0) entry["count"] += int(cat.get("readingCount") or 0) ranked = sorted(cat_agg.values(), key=lambda c: c["seconds"], reverse=True)[:3] max_seconds = max([c["seconds"] for c in ranked] or [0]) or 1 cats = [ { "title": c["title"], "parent": c["parent"], "val": max(1, round(c["seconds"] / max_seconds * 100)), "reading_time_str": duration_str(c["seconds"]), "reading_count": c["count"], } for c in ranked ] max_val = max([c["val"] for c in cats] or [0]) or 1 # 留给自己的时段(接口按 06:00 起 24 个桶返回) prefer_time = list(pref_data.get("preferTime") or []) hours = [(6 + i) % 24 for i in range(len(prefer_time))] max_hour = max(prefer_time) if prefer_time else 0 peak_hour = hours[prefer_time.index(max_hour)] if max_hour else None # 阅读画像 authors = [] for a in dedupe_by_book(pref_data.get("preferAuthor") or [], key="name", limit=3): authors.append({"name": a.get("name") or "", "count": a.get("count") or 0, "read_time": a.get("readTime") or ""}) publishers = [ {"name": p.get("name") or "", "count": p.get("count") or 0} for p in dedupe_by_book(pref_data.get("preferPublisher") or [], key="name", limit=3) ] cps = [ {"name": (cp.get("copyrightInfo") or {}).get("name") or "", "count": cp.get("count") or 0} for cp in dedupe_by_book(pref_data.get("preferCp") or [], key="name", limit=3) ] medals = [] for m in dedupe_by_book(pref_data.get("medals") or [], key="name", limit=8): medals.append( { "name": m.get("name") or m.get("title") or "", "detail": m.get("rankText") or m.get("hint") or "", "date": ts_to_date(m.get("ctime") or 0), } ) rank = data.get("rank") or pref_data.get("rank") or {} read_stat = {s.get("stat"): s.get("counts") for s in (pref_data.get("readStat") or [])} regists = ts_to_date(pref_data.get("registTime") or data.get("registTime") or 0) # 年度小结(真实数据,用于“年度回顾”弹窗) year_data = data if mode == "annually" else weapi_safe("/readdata/detail", ttl=300, mode="annually", baseTime=0) year_summary = {} if year_data: year_stat = {s.get("stat"): s.get("counts") for s in (year_data.get("readStat") or [])} year_top = dedupe_by_book(year_data.get("readLongest") or [], limit=3) year_summary = { "year": datetime.now(tz=CST).year, "total_seconds": int(year_data.get("totalReadTime") or 0), "duration_str": duration_str(year_data.get("totalReadTime") or 0), "read_days": int(year_data.get("readDays") or 0), "prefer_category": year_data.get("preferCategoryWord") or "", "prefer_time": year_data.get("preferTimeWord") or "", "read_count": year_stat.get("读过") or "", "finish_count": year_stat.get("读完") or "", "note_count": year_stat.get("笔记") or "", "top_books": [ { "title": (t.get("book") or {}).get("title") or (t.get("albumInfo") or {}).get("name") or "", "read_time_str": duration_str(t.get("readTime") or 0), } for t in year_top ], } return jsonify( { "period": period, "mode": mode, "trend": trend, "stats": stats, "top_books": top_books, "categories": { "items": cats, "max_val": max_val, "word": pref_data.get("preferCategoryWord") or "", }, "time_distribution": { "values": prefer_time, "hours": hours, "max": max_hour, "peak_hour": peak_hour, "word": pref_data.get("preferTimeWord") or "", }, "authors": authors, "publishers": publishers, "cps": cps, "medals": medals, "rank": rank, "read_stat": read_stat, "prefer_words": { "category": pref_data.get("preferCategoryWord") or "", "time": pref_data.get("preferTimeWord") or "", "records": pref_data.get("readRecordsWord") or "", "distribution": pref_data.get("readDistributionWord") or "", }, "preference_source": pref_source, "notes": { "total": note_total, "review": note_review, "note": note_note, "bookmark": note_bookmark, "books": note_books, }, "regist_time": regists, "year_summary": year_summary, "degraded": conn is None, } ) # ====================================================================== # 接口:书架 # ====================================================================== SHELF_TABS = ("all", "reading", "done", "wish", "lists", "audio", "articles") @v2_bp.route("/api/v2/shelf") @with_db def api_v2_shelf(conn): """ 书架数据。 Query: tab: all|reading|done|wish|lists|audio|articles keyword: 书名/作者筛选 limit/offset: 分页(默认全部返回,前端做本地筛选时传 tab=all) progress: 1 时为首屏书籍实时补拉进度 """ tab = request.args.get("tab", "all") keyword = (request.args.get("keyword") or "").strip().lower() limit = request.args.get("limit", type=int) offset = request.args.get("offset", type=int, default=0) with_progress = request.args.get("progress", "0") == "1" try: books, payload = shelf_books(conn, with_progress=with_progress) except WeReadError as e: return jsonify({"error": str(e)}), 502 counts = shelf_counts(payload) counts.update( { "reading": sum(1 for b in books if b["status"] == "reading"), "done": sum(1 for b in books if b["status"] == "done"), "wish": sum(1 for b in books if b["status"] == "wish"), } ) if keyword: books = [b for b in books if keyword in (b["title"] + b["author"] + b["category"]).lower()] if tab in ("reading", "done", "wish"): books = [b for b in books if b["status"] == tab] elif tab == "all": pass total = len(books) page_books = books[offset : offset + limit] if limit else books[offset:] archives = [] for arc in payload.get("archive") or []: ids = [str(i) for i in (arc.get("bookIds") or [])] archives.append( { "id": arc.get("name") or "", "name": arc.get("name") or "", "source": "我的书单", "description": f"书架书单 · 收录 {len(ids)} 本", "ids": ids, "book_count": len(ids), "album_ids": arc.get("albumIds") or [], } ) albums = [ { "id": str(a.get("albumId") or ""), "title": a.get("name") or "", "author": a.get("authorName") or "", "cover": a.get("cover") or "", "count": a.get("trackCount") or 0, "finish": a.get("finish") or 0, "intro": a.get("intro") or "", } for a in (payload.get("albums") or []) ] mp = payload.get("mp") or {} return jsonify( { "tab": tab, "counts": counts, "total": total, "books": page_books, "lists": archives, "albums": albums, "mp": {"title": (mp.get("book") or {}).get("title") or "", "show": mp.get("show") or 0}, "degraded": conn is None, } ) # ====================================================================== # 接口:笔记本与笔记 # ====================================================================== @v2_bp.route("/api/v2/notebooks") @with_db def api_v2_notebooks(conn): """笔记本概览(有笔记的书 + 各自数量),本地库数据。""" rows = query_all( conn, "SELECT book_id, title, author, cover, review_count, note_count, bookmark_count, " "reading_progress, marked_status FROM notebooks " "ORDER BY (review_count + note_count + bookmark_count) DESC", ) items = [] for row in rows: total = int(row["review_count"] or 0) + int(row["note_count"] or 0) + int(row["bookmark_count"] or 0) items.append( { "id": str(row["book_id"]), "bookId": str(row["book_id"]), "title": row["title"], "author": row["author"], "cover": cover_class(row["book_id"]), "cover_url": row["cover"], "review_count": int(row["review_count"] or 0), "note_count": int(row["note_count"] or 0), "bookmark_count": int(row["bookmark_count"] or 0), "total_notes": total, "progress": norm_progress(row["reading_progress"]), } ) totals = { "books": len(items), "notes": sum(i["total_notes"] for i in items), "highlights": sum(i["note_count"] for i in items), "thoughts": sum(i["review_count"] for i in items), "bookmarks": sum(i["bookmark_count"] for i in items), } return jsonify({"items": items, "totals": totals, "degraded": conn is None}) @v2_bp.route("/api/v2/notes") @with_db def api_v2_notes(conn): """ 某本书的划线与想法(实时接口)。 Query: book_id(必填);book_id=all 时聚合笔记最多的前 N 本(limit 默认 4) """ book_id = (request.args.get("book_id") or "").strip() if not book_id: return jsonify({"error": "book_id is required"}), 400 if book_id == "all": limit = request.args.get("limit", type=int, default=4) rows = query_all( conn, "SELECT book_id FROM notebooks ORDER BY (review_count + note_count + bookmark_count) DESC LIMIT %s", (limit,), ) collected, counts = [], {"highlights": 0, "thoughts": 0, "bookmarks": 0} books_meta = [] for row in rows: bid = str(row["book_id"]) detail = collect_notes(bid) if not detail: continue meta = notes_book_meta(conn, bid) books_meta.append(meta) for h in detail["highlights"]: collected.append({**h, "bookId": bid, "book_title": meta["title"], "type": "highlight"}) for t in detail["thoughts"]: collected.append({**t, "bookId": bid, "book_title": meta["title"], "type": "thought"}) counts["highlights"] += len(detail["highlights"]) counts["thoughts"] += len(detail["thoughts"]) counts["bookmarks"] += detail["bookmark_count"] collected.sort(key=lambda n: n.get("date") or "", reverse=True) return jsonify( { "scope": "all", "books": books_meta, "notes": collected, "counts": counts, "aggregated_books": len(books_meta), "degraded": conn is None, } ) detail = collect_notes(book_id) if detail is None: return jsonify({"error": "获取笔记失败,请稍后重试"}), 502 meta = notes_book_meta(conn, book_id) notes = [ {**h, "bookId": book_id, "book_title": meta["title"], "type": "highlight"} for h in detail["highlights"] ] + [ {**t, "bookId": book_id, "book_title": meta["title"], "type": "thought"} for t in detail["thoughts"] ] notes.sort(key=lambda n: n.get("date") or "", reverse=True) return jsonify( { "scope": "book", "book": meta, "notes": notes, "counts": { "highlights": len(detail["highlights"]), "thoughts": len(detail["thoughts"]), "bookmarks": detail["bookmark_count"], }, "degraded": conn is None, } ) def notes_book_meta(conn, book_id): """笔记页需要的书籍信息(本地库优先,回落实时书架)。""" row = query_one(conn, "SELECT title, author, cover, reading_progress FROM notebooks WHERE book_id=%s", (book_id,)) if row: return { "id": str(book_id), "bookId": str(book_id), "title": row["title"], "author": row["author"], "cover": cover_class(book_id), "cover_url": row["cover"], "progress": norm_progress(row["reading_progress"]), } data = weapi_safe("/book/info", ttl=1800, bookId=book_id) or {} return { "id": str(book_id), "bookId": str(book_id), "title": data.get("title") or "未知书籍", "author": data.get("author") or "", "cover": cover_class(book_id), "cover_url": data.get("cover") or "", "progress": 0, } def collect_notes(book_id): """ 拉取一本书的划线 + 想法,归一成前端使用的结构。 返回 None 表示接口不可用;返回空结构表示确实没有笔记。 """ marks = weapi_safe("/book/bookmarklist", ttl=300, bookId=book_id) thoughts = weapi_safe("/review/list/mine", ttl=300, bookid=book_id, count=100) if marks is None and thoughts is None: return None chapters = {} for ch in (marks or {}).get("chapters") or []: chapters[ch.get("chapterUid")] = ch.get("title") or "" highlights = [] for bm in (marks or {}).get("updated") or []: text = (bm.get("markText") or "").strip() if not text: continue highlights.append( { "id": str(bm.get("bookmarkId") or ""), "text": text, "chapter": chapters.get(bm.get("chapterUid"), ""), "range": bm.get("range") or "", "date": ts_to_date(bm.get("createTime")), "url": bookmark_url(book_id, bm), } ) items, bookmark_count = [], 0 for item in (thoughts or {}).get("reviews") or []: rev = item.get("review") or {} content = rev.get("content") or "" # 纯书签记录没有正文,只计入数量 if rev.get("type") == 4 or (not content.strip()): bookmark_count += 1 continue items.append( { "id": str(rev.get("reviewId") or ""), "text": content.strip(), "abstract": (rev.get("abstract") or "").strip(), "chapter": rev.get("chapterName") or (chapters.get(rev.get("chapterUid")) or ""), "range": rev.get("range") or "", "star": rev.get("star"), "date": ts_to_date(rev.get("createTime")), "url": bookmark_url(book_id, rev), } ) return {"highlights": highlights, "thoughts": items, "bookmark_count": bookmark_count} def bookmark_url(book_id, mark): """拼微信读书深度链接(划线定位)。""" rng = (mark.get("range") or "").split("-") chapter_uid = mark.get("chapterUid") or 0 if len(rng) == 2 and rng[0].isdigit() and rng[1].isdigit(): return ( f"weread://bestbookmark?bookId={book_id}&chapterUid={chapter_uid}" f"&rangeStart={rng[0]}&rangeEnd={rng[1]}" ) return f"weread://reading?bId={book_id}" # ====================================================================== # 接口:书籍详情(简介 / 目录 / 我的笔记 / 热门划线 / 公开点评 / 相似推荐) # ====================================================================== @v2_bp.route("/api/v2/book/") @with_db def api_v2_book(conn, book_id): """书籍详情聚合接口(全部走实时接口,逐项降级)。""" info = weapi_safe("/book/info", ttl=1800, bookId=book_id) if not info: return jsonify({"error": "获取书籍信息失败,请稍后重试"}), 502 progress = weapi_safe("/book/getprogress", ttl=300, bookId=book_id) or {} prog_book = progress.get("book") or {} persist_detail(conn, book_id, info) persist_progress( conn, book_id, { "progress": norm_progress(prog_book.get("progress")), "record_reading_time": prog_book.get("recordReadingTime") or 0, "finish_time": prog_book.get("finishTime") or 0, "update_time": prog_book.get("updateTime") or 0, "is_start_reading": prog_book.get("isStartReading") or 0, "chapter_uid": prog_book.get("chapterUid") or 0, "chapter_offset": prog_book.get("chapterOffset") or 0, }, ) chapters_raw = weapi_safe("/book/chapterinfo", ttl=1800, bookId=book_id) or {} best = weapi_safe("/book/bestbookmarks", ttl=600, bookId=book_id) or {} reviews_raw = weapi_safe("/review/list", ttl=600, bookId=book_id, count=10) or {} similar_raw = weapi_safe("/book/similar", ttl=1800, bookId=book_id, count=8, maxIdx=0) or {} notes = collect_notes(book_id) or {"highlights": [], "thoughts": [], "bookmark_count": 0} rating = float(info.get("newRating") or 0) chapters = [] for ch in (chapters_raw.get("chapters") or []): if (ch.get("level") or 1) > 1: # 只保留一级章节,避免目录过长 continue chapters.append( { "uid": ch.get("chapterUid"), "idx": ch.get("chapterIdx"), "title": ch.get("title") or "", "words": ch.get("wordCount") or 0, "paid": ch.get("paid") or 0, } ) chapters_map = {c["uid"]: c["title"] for c in chapters} for c in (chapters_raw.get("chapters") or []): chapters_map.setdefault(c.get("chapterUid"), c.get("title") or "") best_marks = [] for item in (best.get("items") or [])[:10]: best_marks.append( { "text": (item.get("markText") or "").strip(), "count": int(item.get("totalCount") or 0), "chapter": chapters_map.get(item.get("chapterUid"), ""), "range": item.get("range") or "", "url": bookmark_url(book_id, item), } ) reviews = [] for item in ((reviews_raw.get("reviews") or []))[:10]: rev = item.get("review") or {} author = rev.get("author") or {} star = rev.get("star") or 0 reviews.append( { "id": str(rev.get("reviewId") or ""), "name": author.get("name") or "书友", "avatar": author.get("avatar") or "", "rating": max(1, min(5, int(round(star / 20)))) if star else 0, "star": star, "text": (rev.get("content") or "").strip(), "date": ts_to_date(rev.get("createTime")), "chapter": rev.get("chapterName") or "", "is_finish": rev.get("isFinish") or 0, } ) similar = [] for item in ((similar_raw.get("booksimilar") or {}).get("books") or [])[:8]: bi = (item.get("book") or {}).get("bookInfo") or {} if not bi.get("bookId"): continue similar.append( make_book( bi.get("bookId"), bi.get("title"), bi.get("author"), bi.get("category"), cover_url=bi.get("cover"), deep_link=bi.get("deepLink"), rating=bi.get("newRating"), intro=bi.get("intro"), ) ) return jsonify( { "book": { "id": str(book_id), "bookId": str(book_id), "title": info.get("title") or "", "author": info.get("author") or "", "translator": info.get("translator") or "", "cover": cover_class(book_id), "cover_url": info.get("cover") or "", "short": short_title(info.get("title")), "intro": info.get("intro") or "", "category": info.get("category") or "", "publisher": info.get("publisher") or "", "publish_time": info.get("publishTime") or "", "isbn": info.get("isbn") or "", "word_count": info.get("wordCount") or 0, "rating": round(rating / 10, 1) if rating else None, "rating_count": info.get("newRatingCount") or 0, "deep_link": info.get("deepLink") or f"weread://reading?bId={book_id}", }, "progress": { "progress": norm_progress(prog_book.get("progress")), "record_reading_time": prog_book.get("recordReadingTime") or 0, "record_reading_time_str": duration_str(prog_book.get("recordReadingTime") or 0), "update_time": ts_to_date(prog_book.get("updateTime")), "finish_time": ts_to_date(prog_book.get("finishTime")), "is_start_reading": prog_book.get("isStartReading") or 0, }, "chapters": chapters, "chapter_total": len(chapters_raw.get("chapters") or []), "best_bookmarks": {"items": best_marks, "total": int(best.get("totalCount") or 0)}, "reviews": { "items": reviews, "count": int(reviews_raw.get("reviewsCnt") or len(reviews)), "deep_v": reviews_raw.get("deepVRecommendInfo") or {}, }, "notes": { "items": [ {**h, "bookId": str(book_id), "type": "highlight"} for h in notes["highlights"] ] + [{**t, "bookId": str(book_id), "type": "thought"} for t in notes["thoughts"]], "counts": { "highlights": len(notes["highlights"]), "thoughts": len(notes["thoughts"]), "bookmarks": notes["bookmark_count"], }, }, "similar": similar, } ) # ====================================================================== # 接口:书单 / 推荐 / 搜索 # ====================================================================== @v2_bp.route("/api/v2/archives") @with_db def api_v2_archives(conn): """书单(书架 archive)+ 成员书籍。""" try: payload = shelf_payload() except WeReadError as e: return jsonify({"error": str(e)}), 502 books, _ = shelf_books(conn) by_id = {b["id"]: b for b in books} lists = [] for arc in payload.get("archive") or []: ids = [str(i) for i in (arc.get("bookIds") or [])] members = [by_id[i] for i in ids if i in by_id] lists.append( { "id": arc.get("name") or "", "name": arc.get("name") or "", "source": "我的书单", "description": f"书架书单 · 收录 {len(ids)} 本", "ids": ids, "members": members, "book_count": len(ids), } ) return jsonify({"lists": lists, "count": len(lists), "degraded": conn is None}) @v2_bp.route("/api/v2/recommend") @with_db def api_v2_recommend(conn): """为你推荐(实时)。Query: maxIdx""" max_idx = request.args.get("maxIdx", type=int, default=0) try: data = weapi("/book/recommend", ttl=600, count=12, maxIdx=max_idx) except WeReadError as e: return jsonify({"error": str(e)}), 502 books = [] for book in data.get("books") or []: rating_detail = book.get("newRatingDetail") or {} books.append( { "id": str(book.get("bookId") or ""), "bookId": str(book.get("bookId") or ""), "title": book.get("title") or "", "author": book.get("author") or "", "cover": cover_class(book.get("bookId") or ""), "cover_url": book.get("cover") or "", "short": short_title(book.get("title")), "desc": book.get("intro") or "", "reason": book.get("reason") or "", "category": book.get("category") or "", "score": round(float(book.get("newRating") or 0) / 100, 1) if book.get("newRating") else None, "rating_title": (rating_detail or {}).get("title") or "", "reading_count": book.get("readingCount") or 0, "deep_link": book.get("deepLink") or "", "status": "wish", "progress": 0, } ) return jsonify({"books": books, "has_more": data.get("hasMore") or 0}) SEARCH_SCOPES = { "all": 0, "books": 10, "author": 6, "lists": 13, "audio": 14, "fulltext": 12, "novel": 11, "account": 2, "article": 4, } @v2_bp.route("/api/v2/search") @with_db def api_v2_search(conn): """ 搜索(实时)。Query: keyword(必填), scope=all|books|author|lists|audio|fulltext|novel|account|article 返回按分组归一后的书籍 / 作者 / 书单结果。 """ keyword = (request.args.get("keyword") or "").strip() scope_name = request.args.get("scope", "books") # Flask 对中文查询参数的编码兜底 try: keyword = keyword.encode("latin-1").decode("utf-8") except (UnicodeDecodeError, UnicodeEncodeError): pass if not keyword: return jsonify({"error": "keyword is required"}), 400 scope = SEARCH_SCOPES.get(scope_name, 10) try: data = weapi("/store/search", ttl=300, keyword=keyword, scope=scope, maxIdx=0, count=15) except WeReadError as e: return jsonify({"error": str(e), "keyword": keyword, "scope": scope_name}), 502 books, authors, lists, groups = [], [], [], [] seen_books, seen_authors, seen_groups = set(), set(), set() for group in data.get("results") or []: title = group.get("title") or "" gkey = (title, group.get("scope"), group.get("currentCount")) if gkey in seen_groups: continue seen_groups.add(gkey) items = group.get("books") or [] if title: groups.append({"title": title, "scope": group.get("scope"), "count": group.get("scopeCount") or 0}) for item in items: bi = item.get("bookInfo") or item.get("book") or {} bid = str(bi.get("bookId") or "") if bid and title == "作者": name = bi.get("author") or bi.get("title") or "" if name and name not in seen_authors: seen_authors.add(name) authors.append({"name": name, "count": bi.get("bookCount") or 0}) continue if not bid or bid in seen_books: continue seen_books.add(bid) entry = { "id": bid, "bookId": bid, "title": bi.get("title") or "", "author": bi.get("author") or (bi.get("authorInfo") or {}).get("name") or "", "cover": cover_class(bid), "cover_url": bi.get("cover") or "", "short": short_title(bi.get("title")), "desc": bi.get("intro") or "", "category": bi.get("category") or "", "score": round(float(item.get("newRating") or 0) / 100, 1) if item.get("newRating") else None, "reading_count": item.get("readingCount") or 0, "deep_link": bi.get("deepLink") or "", "group": title, "status": "wish", "progress": 0, } if title in ("书单", "书单合集"): lists.append({**entry, "name": entry["title"], "source": title, "ids": []}) else: books.append(entry) return jsonify( { "keyword": keyword, "scope": scope_name, "scope_id": scope, "groups": groups, "books": books, "authors": authors, "lists": lists, "has_more": data.get("hasMore") or 0, } ) # ====================================================================== # 接口:单条想法详情(想法 / 点赞 / 评论) # ====================================================================== @v2_bp.route("/api/v2/thought/") @with_db def api_v2_thought(conn, review_id): """单条想法详情:正文 + 点赞数 + 评论列表。""" data = weapi_safe("/review/single", ttl=300, reviewId=review_id, commentsCount=10, likesCount=10) if not data: return jsonify({"error": "获取想法详情失败"}), 502 review = data.get("review") or {} author = review.get("author") or {} comments = [] for c in (data.get("comments") or []): inner = c.get("comment") or c c_author = inner.get("author") or {} comments.append( { "name": c_author.get("name") or "书友", "text": (inner.get("content") or "").strip(), "date": ts_to_date(inner.get("createTime")), "likes": inner.get("likesCount") or 0, } ) likes = data.get("likes") or [] return jsonify( { "id": review_id, "text": (review.get("content") or "").strip(), "abstract": (review.get("abstract") or "").strip(), "author": author.get("name") or "我", "date": ts_to_date(review.get("createTime")), "chapter": review.get("chapterName") or "", "star": review.get("star"), "book_review_count": data.get("bookReviewCount") or 0, "like_count": len(likes), "likes": [ {"name": ((l.get("like") or {}).get("author") or {}).get("name") or "书友"} for l in likes[:10] ], "comments": comments, "comment_count": len(comments), } ) # ====================================================================== # 接口:公开点评(可按类型筛选) # ====================================================================== @v2_bp.route("/api/v2/book//reviews") @with_db def api_v2_book_reviews(conn, book_id): """ 书籍公开点评。Query: type=0|1|2|3|4(全部/推荐/不行/最新/一般), count """ review_type = request.args.get("type", type=int, default=0) count = request.args.get("count", type=int, default=10) data = weapi_safe("/review/list", ttl=600, bookId=book_id, reviewListType=review_type, count=count) if data is None: return jsonify({"error": "获取点评失败"}), 502 items = [] for item in (data.get("reviews") or [])[:count]: rev = item.get("review") or {} author = rev.get("author") or {} star = rev.get("star") or 0 items.append( { "id": str(rev.get("reviewId") or ""), "name": author.get("name") or "书友", "avatar": author.get("avatar") or "", "rating": max(1, min(5, int(round(star / 20)))) if star else 0, "star": star, "text": (rev.get("content") or "").strip(), "date": ts_to_date(rev.get("createTime")), "chapter": rev.get("chapterName") or "", "is_finish": rev.get("isFinish") or 0, } ) return jsonify( { "book_id": str(book_id), "type": review_type, "items": items, "count": int(data.get("reviewsCnt") or len(items)), "has_more": data.get("reviewsHasMore") or 0, "friend_count": data.get("friendCommentCount") or 0, "deep_v": data.get("deepVRecommendInfo") or {}, "value": data.get("deepVRecommendValue") or 0, } ) # ====================================================================== # 接口:章节划线下想法(供热门划线弹层使用) # ====================================================================== @v2_bp.route("/api/v2/book//highlights") @with_db def api_v2_book_highlights(conn, book_id): """ 热门划线 / 章节划线热度。 Query: chapter_uid(可选,0=全书) """ chapter_uid = request.args.get("chapter_uid", type=int, default=0) best = weapi_safe("/book/bestbookmarks", ttl=600, bookId=book_id, chapterUid=chapter_uid) if best is None: return jsonify({"error": "获取热门划线失败"}), 502 chapters_raw = weapi_safe("/book/chapterinfo", ttl=1800, bookId=book_id) or {} chapters_map = {c.get("chapterUid"): c.get("title") or "" for c in (chapters_raw.get("chapters") or [])} items = [] for item in (best.get("items") or [])[:20]: rng = (item.get("range") or "").split("-") items.append( { "text": (item.get("markText") or "").strip(), "count": int(item.get("totalCount") or 0), "chapter": chapters_map.get(item.get("chapterUid"), ""), "chapter_uid": item.get("chapterUid"), "range": item.get("range") or "", "range_start": int(rng[0]) if len(rng) == 2 and rng[0].isdigit() else None, "range_end": int(rng[1]) if len(rng) == 2 and rng[1].isdigit() else None, "url": bookmark_url(book_id, item), } ) return jsonify( { "book_id": str(book_id), "chapter_uid": chapter_uid, "total": int(best.get("totalCount") or 0), "items": items, } ) # ====================================================================== # 接口:划线下想法列表 # ====================================================================== @v2_bp.route("/api/v2/book//underline-thoughts") @with_db def api_v2_underline_thoughts(conn, book_id): """某条划线下他人的想法(每屏最多 20 条)。Query: chapter_uid, range""" chapter_uid = request.args.get("chapter_uid", type=int, default=0) rng = (request.args.get("range") or "").strip() if not chapter_uid or not rng: return jsonify({"error": "chapter_uid 与 range 必填"}), 400 payload = {"bookId": book_id, "chapterUid": chapter_uid, "reviews": [{"range": rng, "maxIdx": 0, "count": 10}]} data = weapi_safe("/book/readreviews", ttl=300, **payload) if data is None: return jsonify({"error": "获取划线下想法失败"}), 502 items = [] for group in data.get("reviews") or []: for item in group.get("reviews") or []: rev = item.get("review") or item inner = rev.get("review") or rev author = inner.get("author") or {} text = (inner.get("content") or "").strip() if not text: continue items.append( { "id": str(inner.get("reviewId") or ""), "name": author.get("name") or "书友", "text": text, "date": ts_to_date(inner.get("createTime")), "likes": inner.get("likesCount") or 0, } ) return jsonify({"book_id": str(book_id), "chapter_uid": chapter_uid, "range": rng, "items": items[:20]})