mirror of
https://github.com/KingHWJ/WeReadBoard.git
synced 2026-11-03 18:24:58 +08:00
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 张表完整字段说明、索引、表关系、数据类型规范
1245 lines
45 KiB
Python
1245 lines
45 KiB
Python
#!/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)
|