Files
WeReadBoard/app.py
T
admin 507b9c5704 feat: switch to MySQL, add Chinese comments, health check, and technical docs
1. 数据源切换至 MySQL:
   - app.py 从 SQLite 切换到 PyMySQL,所有 API 读取 MySQL weread_dashboard 数据库
   - 新增 db_required 装饰器统一管理数据库连接和异常处理
   - 新增 /api/health 健康检查接口,检测 MySQL 连接状态
   - MySQL 连接失败时返回 HTTP 503 和告警信息
   - 修复 fetched_at 字段 Unix 时间戳到 MySQL TIMESTAMP 的兼容问题

2. 前端数据库连接告警:
   - 页面加载时优先调用 /api/health 检查 MySQL 连接
   - 连接失败时显示固定告警横幅,阻止后续数据加载
   - CSS 告警横幅样式(黄色背景、下滑动画)

3. 代码中文注释:
   - app.py:所有函数、API 接口、工具函数添加完整中文文档注释
   - fetch_data.py:所有 SQLite 建表语句添加列级中文注释
   - migrate_to_mysql.py:保留已有中文注释

4. 技术文档与元数据文档:
   - docs/TECHNICAL_DOC.md:项目概述、系统架构、API 设计、部署指南
   - docs/METADATA_DOC.md:18 张表完整字段说明、索引、表关系、数据类型规范
2026-07-17 10:26:07 +08:00

1245 lines
45 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
"""
微信读书数据展示网站 - Flask 后端服务
=====================================
功能说明:
1. 提供读书数据的 RESTful API 接口,数据源为本地 MySQL 数据库
2. 按需调用微信读书 API 获取书籍详情、笔记详情、推荐书籍、搜索结果,并入库缓存
3. 提供 /api/health 健康检查接口,前端据此判断数据库连接状态
技术栈:
- Flask:轻量级 Web 框架
- PyMySQL:MySQL 数据库驱动
- 微信读书 Agent Gateway API:按需数据获取
作者:WorkBuddy
创建时间:2026-07-17
"""
import json
import os
import time
import urllib.request
from datetime import datetime
from functools import wraps
import pymysql
from flask import Flask, jsonify, render_template, request
# ======================================================================
# Flask 应用实例
# ======================================================================
app = Flask(__name__, static_folder="static", template_folder="templates")
# ======================================================================
# 数据库连接配置
# ======================================================================
MYSQL_CONFIG = {
"host": "127.0.0.1", # MySQL 服务器地址
"port": 3306, # MySQL 端口
"user": "root", # 数据库用户名
"password": "258963", # 数据库密码
"database": "weread_dashboard", # 数据库名称
"charset": "utf8mb4", # 字符集(支持完整 Unicode 含 emoji)
"cursorclass": pymysql.cursors.DictCursor, # 使用字典游标,方便按字段名取值
}
# ======================================================================
# 微信读书 API 配置
# ======================================================================
WEREAD_API_URL = "https://i.weread.qq.com/api/agent/gateway"
WEREAD_API_KEY = os.environ.get("WEREAD_API_KEY", "")
WEREAD_SKILL_VERSION = "1.0.3"
# ======================================================================
# 数据库连接与健康检查
# ======================================================================
def get_db():
"""
获取 MySQL 数据库连接。
每次请求创建独立连接,请求结束后由调用方关闭。
使用 DictCursor,查询结果以字典形式返回。
Returns:
pymysql.Connection: MySQL 数据库连接对象
Raises:
pymysql.MySQLError: 当 MySQL 无法连接时抛出异常
"""
conn = pymysql.connect(**MYSQL_CONFIG)
return conn
def check_db_health():
"""
检查 MySQL 数据库连接是否正常。
执行一条简单的 SELECT 1 查询,成功则表示连接正常。
Returns:
tuple: (is_healthy: bool, error_message: str)
"""
try:
conn = pymysql.connect(**MYSQL_CONFIG)
cursor = conn.cursor()
cursor.execute("SELECT 1")
cursor.close()
conn.close()
return True, "MySQL 连接正常"
except pymysql.MySQLError as e:
return False, f"MySQL 连接失败: {e}"
except Exception as e:
return False, f"数据库连接异常: {e}"
def db_required(f):
"""
装饰器:自动处理数据库连接。
- 为被装饰的函数注入 conn(数据库连接)参数
- 如果 MySQL 连接失败,返回 503 错误和告警信息
- 请求结束后自动关闭连接
"""
@wraps(f)
def wrapper(*args, **kwargs):
try:
conn = get_db()
except pymysql.MySQLError as e:
# MySQL 连接失败,返回 503 Service Unavailable
return jsonify({
"error": "数据库连接失败",
"detail": str(e),
"alert": "MySQL 数据库无法连接,请检查 MySQL 服务是否已启动。"
}), 503
except Exception as e:
return jsonify({
"error": "数据库连接异常",
"detail": str(e),
"alert": "数据库连接异常,请联系管理员。"
}), 503
try:
result = f(conn=conn, *args, **kwargs)
return result
finally:
conn.close()
return wrapper
# ======================================================================
# 微信读书 API 调用
# ======================================================================
def weread_api_call(api_name, **kwargs):
"""
调用微信读书 Agent Gateway API。
用于按需获取书籍详情、笔记详情、推荐书籍、搜索结果等数据。
Args:
api_name (str): API 接口名称,如 "/book/info"
**kwargs: API 参数,将作为 JSON body 发送
Returns:
dict: API 返回的 JSON 数据
Raises:
Exception: API 调用失败时抛出异常
"""
body = {"api_name": api_name, "skill_version": WEREAD_SKILL_VERSION}
body.update(kwargs)
req = urllib.request.Request(
WEREAD_API_URL,
data=json.dumps(body).encode("utf-8"),
headers={
"Authorization": f"Bearer {WEREAD_API_KEY}",
"Content-Type": "application/json",
},
method="POST",
)
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode("utf-8"))
# ======================================================================
# 工具函数
# ======================================================================
def ts_to_date(ts):
"""
Unix 时间戳转日期字符串。
Args:
ts (int): Unix 时间戳(秒)
Returns:
str: 格式化日期 "YYYY-MM-DD",空值返回空字符串
"""
if not ts or ts == 0:
return ""
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
def ts_to_year(ts):
"""
Unix 时间戳转年份字符串。
Args:
ts (int): Unix 时间戳(秒)
Returns:
str: 年份 "YYYY",空值返回空字符串
"""
if not ts or ts == 0:
return ""
return datetime.fromtimestamp(ts).strftime("%Y")
def format_duration(seconds):
"""
秒数转可读时长字符串。
Args:
seconds (int): 时长(秒)
Returns:
str: 如 "5小时30分钟"、"20分钟"
"""
if not seconds:
return "0分钟"
hours = seconds // 3600
minutes = (seconds % 3600) // 60
if hours > 0:
return f"{hours}小时{minutes}分钟"
return f"{minutes}分钟"
def ensure_tables(cursor):
"""
确保按需数据表存在(MySQL 版本)。
这些表存储用户点击书籍详情/笔记时实时获取的数据,
不在初始化采集脚本中创建,而是首次访问时按需创建。
Args:
cursor: MySQL 游标对象
"""
cursor.executescript = None # MySQL 无 executescript,逐条执行
tables_sql = [
# 书籍详细信息表(来自微信读书 /book/info 接口)
"""CREATE TABLE IF NOT EXISTS `book_details` (
`book_id` VARCHAR(64) NOT NULL,
`title` VARCHAR(255) DEFAULT NULL COMMENT '书名',
`author` VARCHAR(255) DEFAULT NULL COMMENT '作者',
`translator` VARCHAR(255) DEFAULT NULL COMMENT '译者',
`cover` VARCHAR(512) DEFAULT NULL COMMENT '封面图URL',
`intro` LONGTEXT DEFAULT NULL COMMENT '书籍简介',
`category` VARCHAR(128) DEFAULT NULL COMMENT '分类',
`publisher` VARCHAR(255) DEFAULT NULL COMMENT '出版社',
`publish_time` VARCHAR(32) DEFAULT NULL COMMENT '出版时间',
`isbn` VARCHAR(32) DEFAULT NULL COMMENT 'ISBN号',
`word_count` INT UNSIGNED DEFAULT 0 COMMENT '总字数',
`new_rating` DECIMAL(5,1) DEFAULT 0 COMMENT '评分(百分制)',
`new_rating_count` INT UNSIGNED DEFAULT 0 COMMENT '评分人数',
`fetched_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '获取时间',
PRIMARY KEY (`book_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='书籍详细信息'""",
# 阅读进度表(来自微信读书 /book/getprogress 接口)
"""CREATE TABLE IF NOT EXISTS `book_progress` (
`book_id` VARCHAR(64) NOT NULL,
`progress` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '阅读进度百分比(0-100)',
`chapter_uid` INT UNSIGNED DEFAULT NULL COMMENT '当前阅读章节UID',
`chapter_offset` INT UNSIGNED DEFAULT NULL COMMENT '当前章节内偏移量',
`update_time` INT UNSIGNED DEFAULT NULL COMMENT '最后阅读时间(Unix时间戳)',
`record_reading_time` INT UNSIGNED DEFAULT 0 COMMENT '累计阅读时长(秒)',
`finish_time` INT UNSIGNED DEFAULT NULL COMMENT '读完时间(Unix时间戳)',
`is_start_reading` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已开始阅读(1=是)',
`fetched_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '获取时间',
PRIMARY KEY (`book_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='阅读进度'""",
# 划线内容表(来自微信读书 /book/bookmarklist 接口)
"""CREATE TABLE IF NOT EXISTS `book_highlights` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`book_id` VARCHAR(64) NOT NULL COMMENT '书籍ID',
`bookmark_id` VARCHAR(64) DEFAULT NULL COMMENT '划线唯一ID',
`chapter_uid` INT UNSIGNED DEFAULT NULL COMMENT '所在章节UID',
`mark_text` LONGTEXT DEFAULT NULL COMMENT '划线原文内容',
`create_time` INT UNSIGNED DEFAULT NULL COMMENT '创建时间(Unix时间戳)',
`type` TINYINT DEFAULT NULL COMMENT '划线类型',
`range_text` VARCHAR(64) DEFAULT NULL COMMENT '位置范围',
`color_style` TINYINT DEFAULT NULL COMMENT '划线颜色样式',
`fetched_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '获取时间',
PRIMARY KEY (`id`),
INDEX `idx_highlights_book_id` (`book_id`),
INDEX `idx_highlights_chapter` (`book_id`, `chapter_uid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='划线内容'""",
# 章节信息表(来自微信读书 /book/bookmarklist 接口的 chapters 字段)
"""CREATE TABLE IF NOT EXISTS `book_chapters` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`book_id` VARCHAR(64) NOT NULL COMMENT '书籍ID',
`chapter_uid` INT UNSIGNED NOT NULL COMMENT '章节UID',
`chapter_idx` INT UNSIGNED DEFAULT NULL COMMENT '章节序号',
`title` VARCHAR(255) DEFAULT NULL COMMENT '章节标题',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_book_chapter` (`book_id`, `chapter_uid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='章节信息'""",
# 个人想法/点评表(来自微信读书 /review/list/mine 接口)
"""CREATE TABLE IF NOT EXISTS `book_reviews` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`book_id` VARCHAR(64) NOT NULL COMMENT '书籍ID',
`review_id` VARCHAR(64) DEFAULT NULL COMMENT '想法唯一ID',
`content` LONGTEXT DEFAULT NULL COMMENT '想法内容文本',
`create_time` INT UNSIGNED DEFAULT NULL COMMENT '创建时间(Unix时间戳)',
`star` TINYINT DEFAULT -1 COMMENT '评分(0-5, -1=无评分)',
`chapter_name` VARCHAR(255) DEFAULT NULL COMMENT '所在章节名称',
`is_finish` TINYINT(1) DEFAULT 0 COMMENT '是否书评(1=书评)',
`fetched_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '获取时间',
PRIMARY KEY (`id`),
INDEX `idx_reviews_book_id` (`book_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='个人想法/点评'""",
# 推荐书籍表(来自微信读书 /book/recommend 接口)
"""CREATE TABLE IF NOT EXISTS `recommended_books` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`book_id` VARCHAR(64) NOT NULL COMMENT '书籍ID',
`title` VARCHAR(255) DEFAULT NULL COMMENT '书名',
`author` VARCHAR(255) DEFAULT NULL COMMENT '作者',
`cover` VARCHAR(512) DEFAULT NULL COMMENT '封面图URL',
`intro` TEXT DEFAULT NULL COMMENT '书籍简介',
`category` VARCHAR(128) DEFAULT NULL COMMENT '分类',
`reason` VARCHAR(512) DEFAULT NULL COMMENT '推荐理由',
`reading_count` INT UNSIGNED DEFAULT 0 COMMENT '在读人数',
`new_rating` DECIMAL(5,1) DEFAULT 0 COMMENT '评分(百分制)',
`new_rating_count` INT UNSIGNED DEFAULT 0 COMMENT '评分人数',
`rating_title` VARCHAR(32) DEFAULT NULL COMMENT '评分标签(神作/力荐等)',
`fetched_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '获取时间',
PRIMARY KEY (`id`),
INDEX `idx_recommended_book_id` (`book_id`),
INDEX `idx_recommended_fetched` (`fetched_at` DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='推荐书籍'""",
# 搜索结果表(来自微信读书 /store/search 接口)
"""CREATE TABLE IF NOT EXISTS `search_results` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`keyword` VARCHAR(255) NOT NULL COMMENT '搜索关键词',
`book_id` VARCHAR(64) NOT NULL COMMENT '书籍ID',
`title` VARCHAR(255) DEFAULT NULL COMMENT '书名',
`author` VARCHAR(255) DEFAULT NULL COMMENT '作者',
`cover` VARCHAR(512) DEFAULT NULL COMMENT '封面图URL',
`intro` TEXT DEFAULT NULL COMMENT '书籍简介',
`publisher` VARCHAR(255) DEFAULT NULL COMMENT '出版社',
`category` VARCHAR(128) DEFAULT NULL COMMENT '分类',
`reading_count` INT UNSIGNED DEFAULT 0 COMMENT '在读人数',
`new_rating` DECIMAL(5,1) DEFAULT 0 COMMENT '评分(百分制)',
`new_rating_count` INT UNSIGNED DEFAULT 0 COMMENT '评分人数',
`rating_title` VARCHAR(32) DEFAULT NULL COMMENT '评分标签',
`search_idx` INT UNSIGNED DEFAULT 0 COMMENT '搜索序号',
`fetched_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '获取时间',
PRIMARY KEY (`id`),
INDEX `idx_search_keyword` (`keyword`),
INDEX `idx_search_book_id` (`book_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='搜索结果'""",
]
for sql in tables_sql:
cursor.execute(sql)
# ======================================================================
# 健康检查接口
# ======================================================================
@app.route("/api/health")
def api_health():
"""
健康检查接口。
前端页面加载时调用此接口,判断 MySQL 数据库是否正常连接。
如果连接失败,前端会显示告警横幅。
Returns:
JSON: {"status": "ok"/"error", "database": "connected"/"disconnected", "message": "..."}
"""
is_healthy, message = check_db_health()
if is_healthy:
return jsonify({
"status": "ok",
"database": "connected",
"message": message
})
else:
return jsonify({
"status": "error",
"database": "disconnected",
"message": message
}), 503
# ======================================================================
# 页面路由
# ======================================================================
@app.route("/")
def index():
"""首页:渲染读书数据仪表盘页面"""
return render_template("index.html")
# ======================================================================
# API 接口 - 基础数据查询
# ======================================================================
@app.route("/api/overview")
@db_required
def api_overview(conn):
"""
总览数据接口。
返回用户的核心阅读指标:累计阅读时长、阅读天数、藏书量、笔记数等。
Returns:
JSON: 包含以下字段
- total_read_time: 总阅读时长(秒)
- total_read_time_str: 总阅读时长(格式化字符串)
- read_days: 阅读天数
- year_read_time: 今年阅读时长(秒)
- year_read_time_str: 今年阅读时长(格式化字符串)
- year_read_days: 今年阅读天数
- shelf_count: 书架书籍数
- finished_count: 读完书籍数
- notebook_count: 有笔记的书籍数
- total_notes: 笔记总数(划线+想法+书签)
- regist_time: 注册时间
- last_read_time: 最近阅读时间
- fetch_time: 数据采集时间
"""
cursor = conn.cursor()
# 查询总体阅读统计
cursor.execute("SELECT * FROM reading_stats WHERE mode='overall' LIMIT 1")
overall = cursor.fetchone()
total_read_time = overall["total_read_time"] if overall else 0
read_days = overall["read_days"] if overall else 0
regist_time = overall["regist_time"] if overall else 0
# 查询今年阅读统计
cursor.execute("SELECT * FROM reading_stats WHERE mode='annually' LIMIT 1")
annual = cursor.fetchone()
year_read_time = annual["total_read_time"] if annual else 0
year_read_days = annual["read_days"] if annual else 0
# 查询书架书籍总数
cursor.execute("SELECT COUNT(*) AS cnt FROM books")
shelf_count = cursor.fetchone()["cnt"]
# 查询已读完书籍数
cursor.execute("SELECT COUNT(*) AS cnt FROM books WHERE finish_reading=1")
finished_count = cursor.fetchone()["cnt"]
# 查询笔记本统计
cursor.execute("SELECT COUNT(*) AS cnt FROM notebooks")
notebook_count = cursor.fetchone()["cnt"]
cursor.execute("SELECT SUM(review_count + note_count + bookmark_count) AS cnt FROM notebooks")
total_notes = cursor.fetchone()["cnt"] or 0
# 查询最近阅读时间
cursor.execute("SELECT MAX(read_update_time) AS max_ts FROM books")
last_read_ts = cursor.fetchone()["max_ts"] or 0
# 查询数据采集时间
cursor.execute("SELECT value FROM fetch_meta WHERE `key`='last_fetch_time'")
fetch_row = cursor.fetchone()
fetch_time = fetch_row["value"] if fetch_row else ""
return jsonify({
"total_read_time": total_read_time,
"total_read_time_str": format_duration(total_read_time),
"read_days": read_days,
"year_read_time": year_read_time,
"year_read_time_str": format_duration(year_read_time),
"year_read_days": year_read_days,
"shelf_count": shelf_count,
"finished_count": finished_count,
"notebook_count": notebook_count,
"total_notes": total_notes,
"regist_time": ts_to_date(regist_time),
"last_read_time": ts_to_date(last_read_ts),
"fetch_time": fetch_time,
})
@app.route("/api/yearly-trend")
@db_required
def api_yearly_trend(conn):
"""
年度阅读时长时间序列接口。
返回各年度的阅读时长数据,用于前端柱状图展示。
Returns:
JSON数组: [{year: "2024", hours: 45.2, seconds: 162720}, ...]
"""
cursor = conn.cursor()
cursor.execute(
"SELECT bucket_timestamp, duration FROM read_times "
"WHERE mode='overall' ORDER BY bucket_timestamp"
)
rows = cursor.fetchall()
data = []
for row in rows:
year = ts_to_year(row["bucket_timestamp"])
hours = round(row["duration"] / 3600, 1)
data.append({"year": year, "hours": hours, "seconds": row["duration"]})
return jsonify(data)
@app.route("/api/top-books")
@db_required
def api_top_books(conn):
"""
阅读时长最久的书籍排行接口。
返回阅读时长排名前 20 的书籍信息,用于前端卡片展示。
Returns:
JSON数组: [{book_id, title, author, cover, read_time, read_time_str, tags}, ...]
"""
cursor = conn.cursor()
cursor.execute(
"SELECT * FROM read_longest WHERE mode='overall' "
"ORDER BY read_time DESC LIMIT 20"
)
rows = cursor.fetchall()
books = []
for row in rows:
tags = json.loads(row["tags"]) if row["tags"] else []
books.append({
"book_id": row["book_id"],
"title": row["title"],
"author": row["author"],
"cover": row["cover"],
"read_time": row["read_time"],
"read_time_str": format_duration(row["read_time"]),
"tags": tags,
})
return jsonify(books)
@app.route("/api/categories")
@db_required
def api_categories(conn):
"""
偏好分类接口。
返回用户阅读偏好分类数据,按阅读时长降序排列,用于前端饼图展示。
Returns:
JSON数组: [{category_title, parent_category_title, reading_time, reading_time_str, reading_count, val}, ...]
"""
cursor = conn.cursor()
cursor.execute(
"SELECT * FROM prefer_categories WHERE mode='overall' "
"AND reading_time > 0 ORDER BY reading_time DESC"
)
rows = cursor.fetchall()
cats = []
for row in rows:
cats.append({
"category_title": row["category_title"],
"parent_category_title": row["parent_category_title"],
"reading_time": row["reading_time"],
"reading_time_str": format_duration(row["reading_time"]),
"reading_count": row["reading_count"],
"val": row["val"],
})
return jsonify(cats)
@app.route("/api/authors")
@db_required
def api_authors(conn):
"""
偏好作者接口。
返回用户阅读最多的作者排行(前 20),用于前端横向条形图展示。
Returns:
JSON数组: [{name, count, read_time}, ...]
"""
cursor = conn.cursor()
cursor.execute(
"SELECT * FROM prefer_authors WHERE mode='overall' "
"ORDER BY count DESC LIMIT 20"
)
rows = cursor.fetchall()
authors = []
for row in rows:
authors.append({
"name": row["name"],
"count": row["count"],
"read_time": row["read_time"],
})
return jsonify(authors)
@app.route("/api/time-distribution")
@db_required
def api_time_distribution(conn):
"""
24 小时阅读时段分布接口。
返回一天中每个小时的阅读时长,用于前端折线图展示。
Returns:
JSON数组: [{hour: 0, duration: 3600, duration_min: 60.0}, ...]
"""
cursor = conn.cursor()
cursor.execute(
"SELECT hour_index, SUM(duration) AS total "
"FROM prefer_time WHERE mode='overall' "
"GROUP BY hour_index ORDER BY hour_index"
)
rows = cursor.fetchall()
dist = []
for row in rows:
dist.append({
"hour": row["hour_index"],
"duration": row["total"],
"duration_min": round(row["total"] / 60, 1),
})
return jsonify(dist)
@app.route("/api/shelf")
@db_required
def api_shelf(conn):
"""
书架列表接口(分页)。
返回书架中的书籍列表,支持按分类筛选和只看已读完。
Query Parameters:
page (int): 页码,默认 1
per_page (int): 每页数量,默认 60
category (str): 分类筛选(模糊匹配)
finished (str): "1" 表示只看已读完
Returns:
JSON: {books: [...], total, page, per_page, total_pages, categories: [...]}
"""
page = int(request.args.get("page", 1))
per_page = int(request.args.get("per_page", 60))
category = request.args.get("category", "")
finished_only = request.args.get("finished", "0") == "1"
offset = (page - 1) * per_page
cursor = conn.cursor()
# 构建查询条件
where_clauses = []
params = []
if category:
where_clauses.append("category LIKE %s")
params.append(f"%{category}%")
if finished_only:
where_clauses.append("finish_reading=1")
where_sql = " WHERE " + " AND ".join(where_clauses) if where_clauses else ""
# 查询总数
cursor.execute(f"SELECT COUNT(*) AS cnt FROM books{where_sql}", params)
total = cursor.fetchone()["cnt"]
# 查询当前页数据(置顶优先,最近阅读时间倒序)
cursor.execute(
f"SELECT * FROM books{where_sql} ORDER BY is_top DESC, read_update_time DESC LIMIT %s OFFSET %s",
params + [per_page, offset],
)
rows = cursor.fetchall()
# 获取所有分类列表(用于前端筛选下拉框)
cursor.execute("SELECT DISTINCT category FROM books WHERE category != '' ORDER BY category")
categories = [r["category"] for r in cursor.fetchall()]
books = []
for row in rows:
books.append({
"book_id": row["book_id"],
"title": row["title"],
"author": row["author"],
"cover": row["cover"],
"category": row["category"],
"finish_reading": row["finish_reading"],
"read_update_time": ts_to_date(row["read_update_time"]),
"secret": row["secret"],
"is_top": row["is_top"],
"deep_link": row["deep_link"],
})
return jsonify({
"books": books,
"total": total,
"page": page,
"per_page": per_page,
"total_pages": (total + per_page - 1) // per_page,
"categories": categories,
})
@app.route("/api/notebooks")
@db_required
def api_notebooks(conn):
"""
笔记本概览接口。
返回所有有笔记的书籍列表,按笔记总数降序排列。
Returns:
JSON数组: [{book_id, title, author, cover, review_count, note_count, bookmark_count, total_notes, reading_progress, marked_status}, ...]
"""
cursor = conn.cursor()
cursor.execute(
"SELECT * FROM notebooks "
"ORDER BY (review_count + note_count + bookmark_count) DESC"
)
rows = cursor.fetchall()
notebooks = []
for row in rows:
total = row["review_count"] + row["note_count"] + row["bookmark_count"]
notebooks.append({
"book_id": row["book_id"],
"title": row["title"],
"author": row["author"],
"cover": row["cover"],
"review_count": row["review_count"],
"note_count": row["note_count"],
"bookmark_count": row["bookmark_count"],
"total_notes": total,
"reading_progress": row["reading_progress"],
"marked_status": row["marked_status"],
})
return jsonify(notebooks)
@app.route("/api/read-stats")
@db_required
def api_read_stats(conn):
"""
阅读统计摘要项接口。
返回微信读书 App 中展示的阅读统计摘要(读过/读完/阅读/笔记)。
Returns:
JSON数组: [{stat, counts, scheme}, ...]
"""
cursor = conn.cursor()
cursor.execute("SELECT * FROM read_stat_items WHERE mode='overall'")
rows = cursor.fetchall()
stats = []
for row in rows:
stats.append({
"stat": row["stat_name"],
"counts": row["counts"],
"scheme": row["scheme"],
})
return jsonify(stats)
@app.route("/api/archives")
@db_required
def api_archives(conn):
"""
书单/归档接口。
返回用户创建的书单列表。
Returns:
JSON数组: [{name, book_count, book_ids: [...]}, ...]
"""
cursor = conn.cursor()
cursor.execute("SELECT * FROM archives")
rows = cursor.fetchall()
archives = []
for row in rows:
book_ids = json.loads(row["book_ids"]) if row["book_ids"] else []
archives.append({
"name": row["name"],
"book_count": len(book_ids),
"book_ids": book_ids,
})
return jsonify(archives)
# ======================================================================
# API 接口 - 按需获取:书籍详情
# ======================================================================
@app.route("/api/book/detail")
@db_required
def api_book_detail(conn):
"""
书籍详情接口。
实时调用微信读书 /book/info 和 /book/getprogress API 获取书籍详情和阅读进度,
入库缓存后返回给前端。API 调用失败时回退到数据库缓存。
Query Parameters:
book_id (str): 书籍 ID(必填)
Returns:
JSON: 书籍详细信息 + 阅读进度
"""
book_id = request.args.get("book_id", "")
if not book_id:
return jsonify({"error": "book_id is required"}), 400
cursor = conn.cursor()
ensure_tables(cursor)
conn.commit()
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# 1. 获取书籍基本信息
try:
info = weread_api_call("/book/info", bookId=book_id)
cursor.execute(
"REPLACE INTO book_details "
"(book_id, title, author, translator, cover, intro, category, "
"publisher, publish_time, isbn, word_count, new_rating, new_rating_count, fetched_at) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)",
(
book_id,
info.get("title", ""),
info.get("author", ""),
info.get("translator", ""),
info.get("cover", ""),
info.get("intro", ""),
info.get("category", ""),
info.get("publisher", ""),
info.get("publishTime", ""),
info.get("isbn", ""),
info.get("wordCount", 0),
info.get("newRating", 0),
info.get("newRatingCount", 0),
now,
),
)
conn.commit()
except Exception as e:
# API 调用失败,尝试从数据库读取缓存
cursor.execute("SELECT * FROM book_details WHERE book_id=%s", (book_id,))
cached = cursor.fetchone()
if not cached:
return jsonify({"error": f"获取书籍信息失败: {str(e)}"}), 500
# 2. 获取阅读进度
progress_data = {}
try:
prog = weread_api_call("/book/getprogress", bookId=book_id)
book_prog = prog.get("book", {})
cursor.execute(
"REPLACE INTO book_progress "
"(book_id, progress, chapter_uid, chapter_offset, update_time, "
"record_reading_time, finish_time, is_start_reading, fetched_at) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)",
(
book_id,
book_prog.get("progress", 0),
book_prog.get("chapterUid", 0),
book_prog.get("chapterOffset", 0),
book_prog.get("updateTime", 0),
book_prog.get("recordReadingTime", 0),
book_prog.get("finishTime", 0),
book_prog.get("isStartReading", 0),
now,
),
)
conn.commit()
progress_data = {
"progress": book_prog.get("progress", 0),
"record_reading_time": book_prog.get("recordReadingTime", 0),
"record_reading_time_str": format_duration(book_prog.get("recordReadingTime", 0)),
"update_time": ts_to_date(book_prog.get("updateTime", 0)),
"finish_time": ts_to_date(book_prog.get("finishTime", 0)),
"is_start_reading": book_prog.get("isStartReading", 0),
}
except Exception:
# API 调用失败,从数据库读取缓存的进度数据
cursor.execute("SELECT * FROM book_progress WHERE book_id=%s", (book_id,))
cached = cursor.fetchone()
if cached:
progress_data = {
"progress": cached["progress"],
"record_reading_time": cached["record_reading_time"],
"record_reading_time_str": format_duration(cached["record_reading_time"]),
"update_time": ts_to_date(cached["update_time"]),
"finish_time": ts_to_date(cached["finish_time"]),
"is_start_reading": cached["is_start_reading"],
}
# 3. 从数据库读取最终书籍详情并组装返回
cursor.execute("SELECT * FROM book_details WHERE book_id=%s", (book_id,))
detail = cursor.fetchone()
if not detail:
return jsonify({"error": "数据获取失败"}), 500
return jsonify({
"book_id": book_id,
"title": detail["title"],
"author": detail["author"],
"translator": detail["translator"],
"cover": detail["cover"],
"intro": detail["intro"],
"category": detail["category"],
"publisher": detail["publisher"],
"publish_time": detail["publish_time"],
"isbn": detail["isbn"],
"word_count": detail["word_count"],
"new_rating": float(detail["new_rating"]) if detail["new_rating"] else 0,
"new_rating_count": detail["new_rating_count"],
"progress": progress_data,
"deep_link": f"weread://reading?bId={book_id}",
})
# ======================================================================
# API 接口 - 按需获取:笔记详情
# ======================================================================
@app.route("/api/book/notes")
@db_required
def api_book_notes(conn):
"""
笔记详情接口。
实时调用微信读书 /book/bookmarklist 和 /review/list/mine API,
获取划线和想法/点评数据,入库缓存后返回。
划线按章节分组展示。API 调用失败时回退到数据库缓存。
Query Parameters:
book_id (str): 书籍 ID(必填)
Returns:
JSON: {book_id, highlights: [...], highlight_count, grouped_highlights: {...}, reviews: [...], review_count}
"""
book_id = request.args.get("book_id", "")
if not book_id:
return jsonify({"error": "book_id is required"}), 400
cursor = conn.cursor()
ensure_tables(cursor)
conn.commit()
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# 1. 获取划线数据
highlights = []
chapters = {}
try:
data = weread_api_call("/book/bookmarklist", bookId=book_id)
# 保存章节信息
for ch in data.get("chapters", []):
cursor.execute(
"REPLACE INTO book_chapters (book_id, chapter_uid, chapter_idx, title) "
"VALUES (%s,%s,%s,%s)",
(book_id, ch.get("chapterUid", 0), ch.get("chapterIdx", 0), ch.get("title", "")),
)
chapters[ch.get("chapterUid", 0)] = ch.get("title", "")
# 保存划线数据(先删除旧数据再插入)
cursor.execute("DELETE FROM book_highlights WHERE book_id=%s", (book_id,))
for bm in data.get("updated", []):
cursor.execute(
"INSERT INTO book_highlights "
"(book_id, bookmark_id, chapter_uid, mark_text, create_time, "
"type, range_text, color_style, fetched_at) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)",
(
book_id,
bm.get("bookmarkId", ""),
bm.get("chapterUid", 0),
bm.get("markText", ""),
bm.get("createTime", 0),
bm.get("type", 0),
bm.get("range", ""),
bm.get("colorStyle", 0),
now,
),
)
highlights.append({
"bookmark_id": bm.get("bookmarkId", ""),
"chapter_uid": bm.get("chapterUid", 0),
"chapter_title": chapters.get(bm.get("chapterUid", 0), ""),
"mark_text": bm.get("markText", ""),
"create_time": ts_to_date(bm.get("createTime", 0)),
"range": bm.get("range", ""),
})
conn.commit()
except Exception:
# API 调用失败,从数据库读取缓存的划线数据
cursor.execute(
"SELECT * FROM book_highlights WHERE book_id=%s ORDER BY create_time", (book_id,)
)
for row in cursor.fetchall():
cursor.execute(
"SELECT title FROM book_chapters WHERE book_id=%s AND chapter_uid=%s",
(book_id, row["chapter_uid"])
)
ch_row = cursor.fetchone()
highlights.append({
"bookmark_id": row["bookmark_id"],
"chapter_uid": row["chapter_uid"],
"chapter_title": ch_row["title"] if ch_row else "",
"mark_text": row["mark_text"],
"create_time": ts_to_date(row["create_time"]),
"range": row["range_text"],
})
# 2. 获取想法/点评数据
reviews = []
try:
rev_data = weread_api_call("/review/list/mine", bookid=book_id, count=100)
cursor.execute("DELETE FROM book_reviews WHERE book_id=%s", (book_id,))
for item in rev_data.get("reviews", []):
rev = item.get("review", {})
cursor.execute(
"INSERT INTO book_reviews "
"(book_id, review_id, content, create_time, star, chapter_name, is_finish, fetched_at) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s)",
(
book_id,
rev.get("reviewId", ""),
rev.get("content", ""),
rev.get("createTime", 0),
rev.get("star", 0),
rev.get("chapterName", ""),
rev.get("isFinish", 0),
now,
),
)
reviews.append({
"review_id": rev.get("reviewId", ""),
"content": rev.get("content", ""),
"create_time": ts_to_date(rev.get("createTime", 0)),
"star": rev.get("star", 0),
"chapter_name": rev.get("chapterName", ""),
"is_finish": rev.get("isFinish", 0),
})
conn.commit()
except Exception:
# API 调用失败,从数据库读取缓存的想法数据
cursor.execute(
"SELECT * FROM book_reviews WHERE book_id=%s ORDER BY create_time DESC", (book_id,)
)
for row in cursor.fetchall():
reviews.append({
"review_id": row["review_id"],
"content": row["content"],
"create_time": ts_to_date(row["create_time"]),
"star": row["star"],
"chapter_name": row["chapter_name"],
"is_finish": row["is_finish"],
})
# 3. 按章节分组划线
grouped_highlights = {}
for h in highlights:
ch_title = h["chapter_title"] or f"章节{h['chapter_uid']}"
if ch_title not in grouped_highlights:
grouped_highlights[ch_title] = []
grouped_highlights[ch_title].append(h)
return jsonify({
"book_id": book_id,
"highlights": highlights,
"highlight_count": len(highlights),
"grouped_highlights": grouped_highlights,
"reviews": reviews,
"review_count": len(reviews),
})
# ======================================================================
# API 接口 - 推荐书籍
# ======================================================================
@app.route("/api/recommend")
@db_required
def api_recommend(conn):
"""
个性化推荐接口。
调用微信读书 /book/recommend API 获取推荐书籍列表,入库缓存后返回。
支持翻页(通过 maxIdx 参数)。
Query Parameters:
maxIdx (int): 翻页偏移量,默认 0
Returns:
JSON: {books: [...], has_more, last_search_idx}
"""
max_idx = int(request.args.get("maxIdx", 0))
cursor = conn.cursor()
ensure_tables(cursor)
conn.commit()
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
try:
data = weread_api_call("/book/recommend", count=12, maxIdx=max_idx)
books = data.get("books", [])
# 将推荐书籍入库
for book in books:
rating_detail = book.get("newRatingDetail", {})
cursor.execute(
"REPLACE INTO recommended_books "
"(book_id, title, author, cover, intro, category, reason, "
"reading_count, new_rating, new_rating_count, rating_title, fetched_at) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)",
(
book.get("bookId", ""),
book.get("title", ""),
book.get("author", ""),
book.get("cover", ""),
book.get("intro", ""),
book.get("category", ""),
book.get("reason", ""),
book.get("readingCount", 0),
book.get("newRating", 0),
book.get("newRatingCount", 0),
rating_detail.get("title", "") if isinstance(rating_detail, dict) else "",
now,
),
)
conn.commit()
except Exception as e:
return jsonify({"error": f"获取推荐失败: {str(e)}"}), 500
# 组装返回数据
result = []
for book in books:
rating_detail = book.get("newRatingDetail", {})
result.append({
"book_id": book.get("bookId", ""),
"title": book.get("title", ""),
"author": book.get("author", ""),
"cover": book.get("cover", ""),
"intro": book.get("intro", ""),
"category": book.get("category", ""),
"reason": book.get("reason", ""),
"reading_count": book.get("readingCount", 0),
"new_rating": book.get("newRating", 0),
"new_rating_count": book.get("newRatingCount", 0),
"rating_title": rating_detail.get("title", "") if isinstance(rating_detail, dict) else "",
})
return jsonify({
"books": result,
"has_more": data.get("hasMore", 0),
"last_search_idx": books[-1].get("searchIdx", 0) if books else 0,
})
# ======================================================================
# API 接口 - 搜索书籍
# ======================================================================
@app.route("/api/search")
@db_required
def api_search(conn):
"""
搜索书籍接口。
调用微信读书 /store/search API 按关键词搜索书籍,入库缓存后返回。
注意:Flask URL 参数中文编码需特殊处理(latin-1 -> utf-8 转码)。
Query Parameters:
keyword (str): 搜索关键词(必填)
maxIdx (int): 翻页偏移量,默认 0
Returns:
JSON: {keyword, books: [...], has_more}
"""
keyword = request.args.get("keyword", "").strip()
# 修复 Flask URL 参数中文编码问题
try:
keyword = keyword.encode("latin-1").decode("utf-8")
except (UnicodeDecodeError, UnicodeEncodeError):
pass
if not keyword:
return jsonify({"error": "keyword is required"}), 400
max_idx = int(request.args.get("maxIdx", 0))
cursor = conn.cursor()
ensure_tables(cursor)
conn.commit()
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
try:
data = weread_api_call("/store/search", keyword=keyword, scope=10, maxIdx=max_idx)
results = data.get("results", [])
books = []
for group in results:
for item in group.get("books", []):
bi = item.get("bookInfo", {})
rating_detail = item.get("newRatingDetail", {})
cursor.execute(
"REPLACE INTO search_results "
"(keyword, book_id, title, author, cover, intro, publisher, "
"category, reading_count, new_rating, new_rating_count, "
"rating_title, search_idx, fetched_at) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)",
(
keyword,
bi.get("bookId", ""),
bi.get("title", ""),
bi.get("author", ""),
bi.get("cover", ""),
bi.get("intro", ""),
bi.get("publisher", ""),
bi.get("category", ""),
item.get("readingCount", 0),
item.get("newRating", 0),
item.get("newRatingCount", 0),
rating_detail.get("title", "") if isinstance(rating_detail, dict) else "",
item.get("searchIdx", 0),
now,
),
)
books.append({
"book_id": bi.get("bookId", ""),
"title": bi.get("title", ""),
"author": bi.get("author", ""),
"cover": bi.get("cover", ""),
"intro": bi.get("intro", ""),
"publisher": bi.get("publisher", ""),
"category": bi.get("category", ""),
"reading_count": item.get("readingCount", 0),
"new_rating": item.get("newRating", 0),
"new_rating_count": item.get("newRatingCount", 0),
"rating_title": rating_detail.get("title", "") if isinstance(rating_detail, dict) else "",
})
conn.commit()
except Exception as e:
return jsonify({"error": f"搜索失败: {str(e)}"}), 500
return jsonify({
"keyword": keyword,
"books": books,
"has_more": data.get("hasMore", 0),
})
# ======================================================================
# 启动入口
# ======================================================================
if __name__ == "__main__":
# 启动前检查 MySQL 连接
is_healthy, msg = check_db_health()
if not is_healthy:
print(f"[警告] {msg}")
print("[提示] 服务仍会启动,但前端将显示数据库连接告警")
else:
print(f"[信息] {msg}")
# 启动 Flask 服务,监听 8080 端口
app.run(host="0.0.0.0", port=8080, debug=False)