mirror of
https://github.com/KingHWJ/WeReadBoard.git
synced 2026-11-03 18:24:58 +08:00
1975 lines
74 KiB
Python
1975 lines
74 KiB
Python
#!/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 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
|
||
|
||
v2_bp = Blueprint("v2", __name__)
|
||
|
||
# ======================================================================
|
||
# 配置
|
||
# ======================================================================
|
||
|
||
WEREAD_API_URL = os.environ.get("WEREAD_API_URL", "https://i.weread.qq.com/api/agent/gateway")
|
||
WEREAD_API_KEY = os.environ.get("WEREAD_API_KEY", "")
|
||
WEREAD_SKILL_VERSION = os.environ.get("WEREAD_SKILL_VERSION", "1.0.4")
|
||
|
||
MYSQL_CONFIG = {
|
||
"host": os.environ.get("WEREAD_DB_HOST", "124.222.38.85"),
|
||
"port": int(os.environ.get("WEREAD_DB_PORT", 13306)),
|
||
"user": os.environ.get("WEREAD_DB_USER", "root"),
|
||
"password": os.environ.get("WEREAD_DB_PASSWORD", "258963"),
|
||
"database": os.environ.get("WEREAD_DB_NAME", "weread_dashboard"),
|
||
"charset": "utf8mb4",
|
||
"cursorclass": pymysql.cursors.DictCursor,
|
||
"connect_timeout": 5,
|
||
}
|
||
|
||
CST = timezone(timedelta(hours=8))
|
||
|
||
# 进程内缓存 {key: (expire_ts, value)}
|
||
_CACHE = {}
|
||
_CACHE_LOCK = None
|
||
|
||
|
||
def cached(key, ttl, producer):
|
||
"""带 TTL 的进程内缓存;producer 抛异常时不写缓存。"""
|
||
now = time.time()
|
||
hit = _CACHE.get(key)
|
||
if hit and hit[0] > now:
|
||
return hit[1]
|
||
value = producer()
|
||
_CACHE[key] = (now + ttl, value)
|
||
return value
|
||
|
||
|
||
# ======================================================================
|
||
# 微信读书接口调用
|
||
# ======================================================================
|
||
|
||
|
||
class WeReadError(RuntimeError):
|
||
"""微信读书接口调用异常。"""
|
||
|
||
|
||
def weapi(api_name, ttl=0, **kwargs):
|
||
"""
|
||
调用微信读书 Agent Gateway。
|
||
|
||
Args:
|
||
api_name: 接口名,如 "/readdata/detail"
|
||
ttl: 进程内缓存秒数,0 表示不缓存
|
||
**kwargs: 业务参数(平铺在 body 顶层)
|
||
"""
|
||
if not WEREAD_API_KEY:
|
||
raise WeReadError("WEREAD_API_KEY 未设置")
|
||
|
||
cache_key = f"api:{api_name}:{json.dumps(kwargs, sort_keys=True, ensure_ascii=False)}"
|
||
|
||
def _produce():
|
||
body = {"api_name": api_name, "skill_version": WEREAD_SKILL_VERSION}
|
||
body.update(kwargs)
|
||
req = urllib.request.Request(
|
||
WEREAD_API_URL,
|
||
data=json.dumps(body).encode("utf-8"),
|
||
headers={
|
||
"Authorization": f"Bearer {WEREAD_API_KEY}",
|
||
"Content-Type": "application/json",
|
||
},
|
||
method="POST",
|
||
)
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=25) as resp:
|
||
data = json.loads(resp.read().decode("utf-8"))
|
||
except urllib.error.HTTPError as e:
|
||
raise WeReadError(f"{api_name} 调用失败: HTTP {e.code}")
|
||
except Exception as e: # noqa: BLE001
|
||
raise WeReadError(f"{api_name} 调用失败: {e}")
|
||
|
||
errcode = data.get("errcode")
|
||
if errcode:
|
||
raise WeReadError(f"{api_name} 返回错误 {errcode}: {data.get('errmsg', '')}")
|
||
return data
|
||
|
||
if ttl:
|
||
return cached(cache_key, ttl, _produce)
|
||
return _produce()
|
||
|
||
|
||
def weapi_safe(api_name, ttl=0, default=None, **kwargs):
|
||
"""调用失败时返回 default,不抛异常(用于可降级的数据项)。"""
|
||
try:
|
||
return weapi(api_name, ttl=ttl, **kwargs)
|
||
except Exception: # noqa: BLE001
|
||
return default
|
||
|
||
|
||
# ======================================================================
|
||
# 数据库(可降级)
|
||
# ======================================================================
|
||
|
||
|
||
def db_or_none():
|
||
"""返回 MySQL 连接;不可用时返回 None(接口降级运行)。"""
|
||
try:
|
||
return pymysql.connect(**MYSQL_CONFIG)
|
||
except Exception: # noqa: BLE001
|
||
return None
|
||
|
||
|
||
def with_db(f):
|
||
"""
|
||
装饰器:注入关键字参数 conn(可能为 None)与 degraded 标记。
|
||
请求结束后关闭连接。
|
||
"""
|
||
|
||
@wraps(f)
|
||
def wrapper(*args, **kwargs):
|
||
conn = db_or_none()
|
||
try:
|
||
result = f(conn=conn, *args, **kwargs)
|
||
return result
|
||
finally:
|
||
if conn:
|
||
conn.close()
|
||
|
||
return wrapper
|
||
|
||
|
||
def query_all(conn, sql, params=None):
|
||
if not conn:
|
||
return []
|
||
with conn.cursor() as cur:
|
||
cur.execute(sql, params or ())
|
||
return list(cur.fetchall())
|
||
|
||
|
||
def query_one(conn, sql, params=None):
|
||
rows = query_all(conn, sql, params)
|
||
return rows[0] if rows else None
|
||
|
||
|
||
def 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,
|
||
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
|
||
|
||
|
||
# ======================================================================
|
||
# 接口:阅读目标(必须持久化,不使用可降级数据库入口)
|
||
# ======================================================================
|
||
|
||
|
||
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
|
||
|
||
|
||
@v2_bp.route("/api/v2/goals", methods=["GET"])
|
||
@with_goals_db
|
||
def api_v2_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())})
|
||
|
||
|
||
@v2_bp.route("/api/v2/goals", methods=["POST"])
|
||
@with_goals_db
|
||
def api_v2_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
|
||
|
||
|
||
@v2_bp.route("/api/v2/goals/<goal_id>", methods=["PUT"])
|
||
@with_goals_db
|
||
def api_v2_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})
|
||
|
||
|
||
@v2_bp.route("/api/v2/goals/<goal_id>", methods=["DELETE"])
|
||
@with_goals_db
|
||
def api_v2_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})
|
||
|
||
|
||
@v2_bp.route("/api/v2/goals/import", methods=["POST"])
|
||
@with_goals_db
|
||
def api_v2_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})
|
||
|
||
|
||
# ======================================================================
|
||
# 接口:连接状态
|
||
# ======================================================================
|
||
|
||
|
||
@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}<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")
|
||
|
||
|
||
@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/<book_id>")
|
||
@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)
|
||
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,
|
||
}
|
||
|
||
|
||
@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 = [], [], [], []
|
||
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,
|
||
}
|
||
)
|
||
|
||
|
||
# ======================================================================
|
||
# 接口:单条想法详情(想法 / 点赞 / 评论)
|
||
# ======================================================================
|
||
|
||
|
||
@v2_bp.route("/api/v2/thought/<review_id>")
|
||
@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/<book_id>/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/<book_id>/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/<book_id>/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]})
|