Files
WeReadBoard/reading_api.py
T

1989 lines
75 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
阅间 · 微信读书看板数据接口层
==================================
为当前阅间看板提供数据聚合接口。
设计原则:
1. 当前接口挂在 /api/reading/* 下,并保留旧客户端兼容路径
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 contextlib import suppress
from datetime import date, datetime, timedelta, timezone
from functools import wraps
import pymysql
from flask import Blueprint, jsonify, make_response, request
from werkzeug.exceptions import BadRequest
reading_bp = Blueprint("reading", __name__)
READING_API_PREFIX = "/api/reading"
LEGACY_API_PREFIX = "/api/" + "v" + "2"
def route(path, **options):
"""注册当前接口路径,并保留旧客户端兼容路径。"""
def decorator(func):
reading_bp.route(f"{LEGACY_API_PREFIX}{path}", **options)(func)
reading_bp.route(f"{READING_API_PREFIX}{path}", **options)(func)
return func
return decorator
# ======================================================================
# 配置
# ======================================================================
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 upsert_recommended_book(conn, values):
"""按 book_id 更新推荐缓存,避免同一本书每次推荐都新增一行。"""
if not conn:
return
columns = (
"book_id, title, author, cover, intro, category, reason, reading_count, "
"new_rating, new_rating_count, rating_title, deep_link, search_idx, price, "
"pay_type, book_type, batch_id"
)
try:
with conn.cursor() as cur:
cur.execute("SELECT id FROM recommended_books WHERE book_id=%s ORDER BY id LIMIT 1", (values[0],))
row = cur.fetchone()
if row:
cur.execute(
"UPDATE recommended_books SET title=%s, author=%s, cover=%s, intro=%s, category=%s, "
"reason=%s, reading_count=%s, new_rating=%s, new_rating_count=%s, rating_title=%s, "
"deep_link=%s, search_idx=%s, price=%s, pay_type=%s, book_type=%s, batch_id=%s, "
"updated_at=NOW() WHERE id=%s",
(*values[1:], row[0]),
)
else:
cur.execute(
f"INSERT INTO recommended_books ({columns}) VALUES ({','.join(['%s'] * 17)})",
values,
)
cur.execute(
"INSERT INTO content_logs "
"(event_type, batch_id, book_id, search_idx) VALUES ('recommendation',%s,%s,%s)",
(values[16], values[0], values[12] or 0),
)
conn.commit()
except Exception:
pass
def upsert_search_result(conn, session_id, values):
"""按 keyword + book_id 更新搜索缓存,避免重复搜索产生重复行。"""
if not conn or not session_id:
return
keyword, book_id = values[0], values[1]
try:
with conn.cursor() as cur:
cur.execute(
"SELECT id FROM search_results WHERE keyword=%s AND book_id=%s ORDER BY id LIMIT 1",
(keyword, book_id),
)
row = cur.fetchone()
if row:
cur.execute(
"UPDATE search_results SET session_id=%s, title=%s, author=%s, cover=%s, intro=%s, "
"publisher=%s, category=%s, reading_count=%s, new_rating=%s, new_rating_count=%s, "
"rating_title=%s, deep_link=%s, pay_type=%s, price=%s, soldout=%s, search_idx=%s, "
"result_scope=%s, result_title=%s, scope_count=%s, current_count=%s, updated_at=NOW() "
"WHERE id=%s",
(session_id, *values[2:], row[0]),
)
else:
cur.execute(
"INSERT INTO search_results "
"(session_id, keyword, book_id, title, author, cover, intro, publisher, category, "
"reading_count, new_rating, new_rating_count, rating_title, deep_link, pay_type, price, "
"soldout, search_idx, result_scope, result_title, scope_count, current_count) "
f"VALUES ({','.join(['%s'] * 22)})",
(session_id, *values),
)
cur.execute(
"INSERT INTO content_logs "
"(event_type, session_id, keyword, book_id, search_idx) VALUES ('search',%s,%s,%s,%s)",
(session_id, keyword, book_id, values[16] or 0),
)
conn.commit()
except Exception:
pass
# ======================================================================
# 格式化与展示辅助
# ======================================================================
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<progress<100),或最近 N 天内有阅读记录
wish —— 在架但从未积累进度、也超过 N 天没有阅读记录
"""
if int(finish or 0):
return "done"
progress = norm_progress(progress)
if 0 < progress < 100:
return "reading"
try:
read_update_time = int(read_update_time or 0)
except (TypeError, ValueError):
read_update_time = 0
if read_update_time:
cutoff = time.time() - recent_days * 86400
if read_update_time >= cutoff:
return "reading"
return "wish"
def make_book(
book_id,
title,
author=None,
category=None,
finish=0,
is_top=0,
read_update_time=0,
cover_url=None,
deep_link=None,
progress=None,
rating=None,
read_time=None,
intro=None,
):
"""
组装成当前前端使用的书籍结构(字段名与设计稿一致)。
"""
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,
"is_top": int(is_top or 0),
"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"),
is_top=row.get("isTop"),
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
# ======================================================================
# 接口:阅读目标(必须持久化,不使用可降级数据库入口)
# ======================================================================
def with_goals_db(f):
"""目标及成员共用事务;数据库失败必须让前端保留原表单。"""
@wraps(f)
def wrapper(*args, **kwargs):
conn = None
try:
conn = pymysql.connect(**MYSQL_CONFIG)
response = make_response(f(conn, *args, **kwargs))
if request.method != "GET":
if response.status_code < 400:
conn.commit()
else:
conn.rollback()
return response
except Exception as error:
if conn is not None:
# 断线时回滚也可能失败;仍返回原操作错误,由关闭连接释放事务。
with suppress(pymysql.MySQLError):
conn.rollback()
if isinstance(error, BadRequest):
return jsonify({"error": error.description}), 400
if isinstance(error, pymysql.MySQLError):
return jsonify({"error": "阅读目标数据库暂不可用,请稍后重试"}), 503
raise
finally:
if conn is not None:
conn.close()
return wrapper
def validate_goal(data):
if not isinstance(data, dict):
raise BadRequest("阅读目标必须是 JSON 对象")
goal_id = data.get("id")
if not isinstance(goal_id, str) or not 1 <= len(goal_id) <= 64:
raise BadRequest("目标 ID 必须是 1 至 64 个字符的字符串")
name = data.get("name")
if not isinstance(name, str) or not 1 <= len(name.strip()) <= 60:
raise BadRequest("目标名称去除首尾空格后须为 1 至 60 个字符")
dates = []
for field, label in (("start", "开始"), ("end", "结束")):
value = data.get(field)
try:
parsed = date.fromisoformat(value) if isinstance(value, str) else None
except ValueError:
parsed = None
if parsed is None or parsed.isoformat() != value or parsed.year < 1000:
raise BadRequest(f"{label}日期必须是有效的 YYYY-MM-DD 日期,年份须为 1000 至 9999")
dates.append(value)
if dates[0] > dates[1]:
raise BadRequest("结束日期不能早于开始日期")
ids = data.get("ids")
if not isinstance(ids, list) or not ids:
raise BadRequest("目标至少需要一本书,ids 必须是非空数组")
if any(not isinstance(book_id, str) or not 1 <= len(book_id) <= 64 for book_id in ids):
raise BadRequest("书籍 ID 必须是 1 至 64 个字符的字符串")
sources = data.get("sources", [])
if not isinstance(sources, list) or any(not isinstance(source, str) for source in sources):
raise BadRequest("书单来源 sources 必须是字符串数组")
return {"id": goal_id, "name": name.strip(), "start": dates[0], "end": dates[1],
"ids": list(dict.fromkeys(ids)), "sources": list(dict.fromkeys(sources))}
def insert_goal(cur, goal, skip_existing=False):
sql = "INSERT INTO reading_goals (id, name, start_date, end_date, sources) VALUES (%s,%s,%s,%s,%s)"
if skip_existing:
# 仅同 ID 已存在时跳过,不覆盖原目标;不用 INSERT IGNORE 掩盖其他写入错误。
sql += " ON DUPLICATE KEY UPDATE id=id"
cur.execute(sql, (goal["id"], goal["name"], goal["start"], goal["end"],
json.dumps(goal["sources"], ensure_ascii=False)))
if skip_existing and cur.rowcount == 0:
return False
cur.executemany("INSERT INTO reading_goal_books (goal_id, book_id) VALUES (%s,%s)",
[(goal["id"], book_id) for book_id in goal["ids"]])
return True
@route("/goals", methods=["GET"])
@with_goals_db
def api_goals(conn):
rows = query_all(conn,
"SELECT g.id, g.name, g.start_date, g.end_date, g.sources, b.book_id "
"FROM reading_goals g LEFT JOIN reading_goal_books b ON b.goal_id=g.id "
"ORDER BY g.created_at, g.id, b.book_id")
goals = {}
for row in rows:
goal_id = row["id"]
if goal_id not in goals:
goals[goal_id] = {"id": goal_id, "name": row["name"],
"start": row["start_date"].isoformat(), "end": row["end_date"].isoformat(),
"ids": [], "sources": json.loads(row["sources"])}
# 不关联书架缓存,已移出书架或尚未同步的书籍仍保留在目标分母中。
if row["book_id"] is not None:
goals[goal_id]["ids"].append(row["book_id"])
return jsonify({"goals": list(goals.values())})
@route("/goals", methods=["POST"])
@with_goals_db
def api_create_goal(conn):
goal = validate_goal(request.get_json(silent=True))
try:
with conn.cursor() as cur:
insert_goal(cur, goal)
except pymysql.err.IntegrityError as error:
if error.args[0] == 1062:
return jsonify({"error": "该目标 ID 已存在,请刷新后重试"}), 409
raise
return jsonify({"goal": goal}), 201
@route("/goals/<goal_id>", methods=["PUT"])
@with_goals_db
def api_update_goal(conn, goal_id):
goal = validate_goal(request.get_json(silent=True))
if goal["id"] != goal_id:
raise BadRequest("目标 ID 与请求路径不一致")
with conn.cursor() as cur:
# 锁定父记录,避免并发编辑把两次提交的书籍成员混在一起。
cur.execute("SELECT id FROM reading_goals WHERE id=%s FOR UPDATE", (goal_id,))
if cur.fetchone() is None:
return jsonify({"error": "阅读目标不存在"}), 404
cur.execute("UPDATE reading_goals SET name=%s, start_date=%s, end_date=%s, sources=%s WHERE id=%s",
(goal["name"], goal["start"], goal["end"],
json.dumps(goal["sources"], ensure_ascii=False), goal_id))
cur.execute("DELETE FROM reading_goal_books WHERE goal_id=%s", (goal_id,))
cur.executemany("INSERT INTO reading_goal_books (goal_id, book_id) VALUES (%s,%s)",
[(goal_id, book_id) for book_id in goal["ids"]])
return jsonify({"goal": goal})
@route("/goals/<goal_id>", methods=["DELETE"])
@with_goals_db
def api_delete_goal(conn, goal_id):
if not 1 <= len(goal_id) <= 64:
raise BadRequest("目标 ID 必须是 1 至 64 个字符的字符串")
with conn.cursor() as cur:
cur.execute("DELETE FROM reading_goals WHERE id=%s", (goal_id,))
if cur.rowcount == 0:
return jsonify({"error": "阅读目标不存在"}), 404
return jsonify({"ok": True})
@route("/goals/import", methods=["POST"])
@with_goals_db
def api_import_goals(conn):
data = request.get_json(silent=True)
if not isinstance(data, dict) or not isinstance(data.get("goals"), list):
raise BadRequest("导入内容必须包含 goals 数组")
# 先校验整批,重复 ID 只保留第一条;任何写入失败均回滚整个批次。
goals = [validate_goal(goal) for goal in data["goals"]]
seen = set()
imported = 0
with conn.cursor() as cur:
for goal in goals:
if goal["id"] in seen:
continue
seen.add(goal["id"])
imported += insert_goal(cur, goal, skip_existing=True)
return jsonify({"imported": imported})
# ======================================================================
# 接口:连接状态
# ======================================================================
@route("/status")
@with_db
def api_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
@route("/dashboard")
@with_db
def api_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}<small>天</small>",
"hint": "单日满 1 分钟计一天",
"icon": "flag",
},
{
"label": "自然日均阅读",
"value": f"{round(total_minutes / days)}<small>分钟</small>" if days else "0<small>分钟</small>",
"hint": f"按 {days} 个自然日折算",
"icon": "chart",
},
{
"label": "笔记总数",
"value": f"{note_total}<small>条</small>",
"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")
@route("/shelf")
@with_db
def api_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,
}
)
# ======================================================================
# 接口:笔记本与笔记
# ======================================================================
@route("/notebooks")
@with_db
def api_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})
@route("/notes")
@with_db
def api_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}"
# ======================================================================
# 接口:书籍详情(简介 / 目录 / 我的笔记 / 热门划线 / 公开点评 / 相似推荐)
# ======================================================================
@route("/book/<book_id>")
@with_db
def api_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,
}
)
# ======================================================================
# 接口:书单 / 推荐 / 搜索
# ======================================================================
@route("/archives")
@with_db
def api_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})
@route("/recommend")
@with_db
def api_recommend(conn):
"""为你推荐(实时)。Query: maxIdx"""
max_idx = request.args.get("maxIdx", type=int, default=0)
refresh = request.args.get("refresh", "0") == "1"
try:
data = weapi("/book/recommend", ttl=0 if refresh else 600, count=12, maxIdx=max_idx)
except WeReadError as e:
return jsonify({"error": str(e)}), 502
books = []
batch_id = datetime.now().strftime("%Y%m%d%H%M%S")
raw_books = data.get("books") or []
def enrich_recommendation(book):
info = weapi_safe("/book/info", ttl=1800, bookId=str(book.get("bookId") or "")) or {}
merged = dict(book)
for key in ("cover", "newRating", "newRatingCount", "newRatingDetail", "intro", "category", "deepLink"):
if not merged.get(key) and info.get(key):
merged[key] = info[key]
return merged
with ThreadPoolExecutor(max_workers=6) as pool:
enriched_books = list(pool.map(enrich_recommendation, raw_books))
for book in enriched_books:
rating_detail = book.get("newRatingDetail") or {}
if conn:
upsert_recommended_book(
conn,
(
book.get("bookId", ""), book.get("title", ""), book.get("author", ""),
book.get("cover", ""), book.get("intro", ""), book.get("category", ""),
book.get("reason", ""), book.get("readingCount", 0), book.get("newRating", 0),
book.get("newRatingCount", 0), (rating_detail or {}).get("title", ""),
book.get("deepLink", ""), book.get("searchIdx", 0), book.get("price"),
book.get("payType"), book.get("type", 0), batch_id,
),
)
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,
}
@route("/search")
@with_db
def api_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 = [], [], [], []
session_id = None
if conn:
try:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO search_sessions (sid, keyword, scope, has_more, max_idx) VALUES (%s,%s,%s,%s,%s)",
(data.get("sid", ""), keyword, scope, data.get("hasMore", 0), 0),
)
session_id = cur.lastrowid
except Exception:
session_id = None
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 not in seen_groups:
seen_groups.add(gkey)
if title:
groups.append({"title": title, "scope": group.get("scope"), "count": group.get("scopeCount") or 0})
items = group.get("books") or []
if title == "作者" and not items and group.get("scopeCount"):
authors.append({"name": keyword, "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(bi.get("newRating") or 0) / 100, 1) if bi.get("newRating") else None,
"reading_count": item.get("readingCount") or 0,
"deep_link": bi.get("deepLink") or "",
"group": title,
"status": "wish",
"progress": 0,
}
if session_id:
upsert_search_result(
conn,
session_id,
(
keyword, bid, entry["title"], entry["author"], bi.get("cover", ""),
bi.get("intro", ""), bi.get("publisher", ""), entry["category"],
item.get("readingCount", 0), bi.get("newRating", 0), bi.get("newRatingCount", 0),
(bi.get("newRatingDetail") or {}).get("title", ""), entry["deep_link"],
bi.get("payType"), bi.get("price"), bi.get("soldout", 0), item.get("searchIdx", 0),
group.get("scope"), title, group.get("scopeCount") or 0, group.get("currentCount") or 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,
}
)
# ======================================================================
# 接口:单条想法详情(想法 / 点赞 / 评论)
# ======================================================================
@route("/thought/<review_id>")
@with_db
def api_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),
}
)
# ======================================================================
# 接口:公开点评(可按类型筛选)
# ======================================================================
@route("/book/<book_id>/reviews")
@with_db
def api_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,
}
)
# ======================================================================
# 接口:章节划线下想法(供热门划线弹层使用)
# ======================================================================
@route("/book/<book_id>/highlights")
@with_db
def api_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,
}
)
# ======================================================================
# 接口:划线下想法列表
# ======================================================================
@route("/book/<book_id>/underline-thoughts")
@with_db
def api_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]})