diff --git a/api_v2.py b/api_v2.py new file mode 100644 index 0000000..3d938e3 --- /dev/null +++ b/api_v2.py @@ -0,0 +1,1677 @@ +#!/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", "127.0.0.1"), + "port": int(os.environ.get("WEREAD_DB_PORT", 3306)), + "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]}) diff --git a/app.py b/app.py index d780e04..474c0bf 100644 --- a/app.py +++ b/app.py @@ -27,11 +27,16 @@ from functools import wraps import pymysql from flask import Flask, jsonify, render_template, request +from api_v2 import v2_bp + # ====================================================================== # Flask 应用实例 # ====================================================================== app = Flask(__name__, static_folder="static", template_folder="templates") +# 注册 v2 数据接口(/api/v2/*,为「阅间」新版前端提供聚合数据) +app.register_blueprint(v2_bp) + # ====================================================================== # 数据库连接配置 # ====================================================================== @@ -1270,5 +1275,7 @@ if __name__ == "__main__": else: print(f"[信息] {msg}") - # 启动 Flask 服务,监听 8080 端口 - app.run(host="0.0.0.0", port=8080, debug=False) + # 监听端口:默认 8080,可用环境变量 PORT 覆盖(v2 前端与 v1 并存时可换端口) + port = int(os.environ.get("PORT", 8080)) + print(f"[信息] 打开 http://127.0.0.1:{port} 查看「阅间」看板") + app.run(host="0.0.0.0", port=port, debug=False)