From 507b9c57047b9350f887701088e7154dc709a4cd Mon Sep 17 00:00:00 2001 From: kinghwj Date: Fri, 17 Jul 2026 10:26:07 +0800 Subject: [PATCH] feat: switch to MySQL, add Chinese comments, health check, and technical docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 张表完整字段说明、索引、表关系、数据类型规范 --- .workbuddy/memory/2026-07-17.md | 18 + app.py | 948 ++++++++++++++++++++++---------- docs/METADATA_DOC.md | 524 ++++++++++++++++++ docs/TECHNICAL_DOC.md | 331 +++++++++++ fetch_data.py | 323 +++++------ static/css/style.css | 44 ++ static/js/app.js | 46 +- templates/index.html | 7 + 8 files changed, 1805 insertions(+), 436 deletions(-) create mode 100644 docs/METADATA_DOC.md create mode 100644 docs/TECHNICAL_DOC.md diff --git a/.workbuddy/memory/2026-07-17.md b/.workbuddy/memory/2026-07-17.md index 4d7697c..6557d0a 100644 --- a/.workbuddy/memory/2026-07-17.md +++ b/.workbuddy/memory/2026-07-17.md @@ -7,3 +7,21 @@ - 前端页面:ECharts 图表(年度趋势柱状图、24h时段折线图、分类饼图、作者排行条形图)、书架网格(分页+筛选)、笔记概览 - 数据规模:累计阅读 339 小时 57 分钟、304 天阅读、404 本书架藏书、1709 条笔记 - 技术栈:Python Flask + SQLite + ECharts + 原生 HTML/CSS/JS + +## 功能迭代(第二批) +- Git 仓库:dev 分支初版提交 + 功能迭代提交,master 分支 --no-ff 合并,遵循 conventional commits 规范 +- 书籍详情弹窗:点击书架书籍实时调 /book/info + /book/getprogress API,展示详细信息+阅读进度,数据入库 book_details/book_progress 表 +- 笔记详情弹窗:点击笔记书籍实时调 /book/bookmarklist + /review/list/mine API,划线按章节分组+想法/点评,数据入库 book_highlights/book_reviews/book_chapters 表 +- 推荐页面:个性化推荐(/book/recommend)+ 关键词搜索(/store/search),数据入库 recommended_books/search_results 表 +- 修复 Flask 中文 URL 参数编码问题(latin-1 -> utf-8 转码) +- MySQL 版本建表语句 schema_mysql.sql:18 张表完整 DDL,utf8mb4 字符集,含索引和字段注释 +- 新增 7 张按需数据表:book_details, book_progress, book_highlights, book_chapters, book_reviews, recommended_books, search_results + +## MySQL 迁移 +- 启动本地 MySQL 5.7.31(通过 launchctl load com.oracle.oss.mysql.mysqld.plist) +- MySQL root 密码:258963 +- 创建数据库 `weread_dashboard`(utf8mb4),执行 schema_mysql.sql 创建 18 张表 +- 迁移脚本 `migrate_to_mysql.py`:SQLite -> MySQL 全量迁移,共 862 行数据 +- 时间戳列自动转换(SQLite int -> MySQL TIMESTAMP) +- 中文内容 utf8mb4 完整保存,数据校验通过 +- Flask 服务暂未切换至 MySQL(用户要求服务不重启) diff --git a/app.py b/app.py index 57d7d56..d45213d 100644 --- a/app.py +++ b/app.py @@ -1,29 +1,151 @@ #!/usr/bin/env python3 """ -微信读书数据展示网站 - Flask 后端 -提供 API 接口供前端查询本地 SQLite 数据库中的读书数据 +微信读书数据展示网站 - 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 sqlite3 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") -DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "weread.db") +# ====================================================================== +# 数据库连接配置 +# ====================================================================== +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): - """调用微信读书 API(按需获取数据时使用)""" + """ + 调用微信读书 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( @@ -39,81 +161,44 @@ def weread_api_call(api_name, **kwargs): return json.loads(resp.read().decode("utf-8")) -def ensure_tables(conn): - """确保按需数据表存在""" - c = conn.cursor() - c.executescript(""" - CREATE TABLE IF NOT EXISTS book_details ( - book_id TEXT PRIMARY KEY, - title TEXT, author TEXT, translator TEXT, cover TEXT, - intro TEXT, category TEXT, publisher TEXT, publish_time TEXT, - isbn TEXT, word_count INTEGER, new_rating REAL, - new_rating_count INTEGER, fetched_at INTEGER - ); - CREATE TABLE IF NOT EXISTS book_progress ( - book_id TEXT PRIMARY KEY, - progress INTEGER, chapter_uid INTEGER, chapter_offset INTEGER, - update_time INTEGER, record_reading_time INTEGER, - finish_time INTEGER, is_start_reading INTEGER, fetched_at INTEGER - ); - CREATE TABLE IF NOT EXISTS book_highlights ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - book_id TEXT, bookmark_id TEXT, chapter_uid INTEGER, - mark_text TEXT, create_time INTEGER, type INTEGER, - range_text TEXT, color_style INTEGER, fetched_at INTEGER - ); - CREATE TABLE IF NOT EXISTS book_chapters ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - book_id TEXT, chapter_uid INTEGER, chapter_idx INTEGER, - title TEXT, UNIQUE(book_id, chapter_uid) - ); - CREATE TABLE IF NOT EXISTS book_reviews ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - book_id TEXT, review_id TEXT, content TEXT, - create_time INTEGER, star INTEGER, chapter_name TEXT, - is_finish INTEGER, fetched_at INTEGER - ); - CREATE TABLE IF NOT EXISTS recommended_books ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - book_id TEXT, title TEXT, author TEXT, cover TEXT, - intro TEXT, category TEXT, reason TEXT, - reading_count INTEGER, new_rating REAL, - new_rating_count INTEGER, rating_title TEXT, fetched_at INTEGER - ); - CREATE TABLE IF NOT EXISTS search_results ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - keyword TEXT, book_id TEXT, title TEXT, author TEXT, - cover TEXT, intro TEXT, publisher TEXT, category TEXT, - reading_count INTEGER, new_rating REAL, - new_rating_count INTEGER, rating_title TEXT, - search_idx INTEGER, fetched_at INTEGER - ); - """) - conn.commit() - - -def get_db(): - conn = sqlite3.connect(DB_PATH) - conn.row_factory = sqlite3.Row - return conn - +# ====================================================================== +# 工具函数 +# ====================================================================== 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 @@ -123,56 +208,243 @@ def format_duration(seconds): 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") -@app.route("/api/overview") -def api_overview(): - """总览数据""" - conn = get_db() - c = conn.cursor() +# ====================================================================== +# API 接口 - 基础数据查询 +# ====================================================================== - # 总阅读统计 - c.execute("SELECT * FROM reading_stats WHERE mode='overall' LIMIT 1") - overall = c.fetchone() +@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 - # 今年统计 - current_year = str(time.localtime().tm_year) - c.execute("SELECT * FROM reading_stats WHERE mode='annually' LIMIT 1") - annual = c.fetchone() + # 查询今年阅读统计 + 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 - # 书架数量 - c.execute("SELECT COUNT(*) as cnt FROM books") - shelf_count = c.fetchone()["cnt"] + # 查询书架书籍总数 + cursor.execute("SELECT COUNT(*) AS cnt FROM books") + shelf_count = cursor.fetchone()["cnt"] - # 读完数量 - c.execute("SELECT COUNT(*) as cnt FROM books WHERE finish_reading=1") - finished_count = c.fetchone()["cnt"] + # 查询已读完书籍数 + cursor.execute("SELECT COUNT(*) AS cnt FROM books WHERE finish_reading=1") + finished_count = cursor.fetchone()["cnt"] - # 笔记统计 - c.execute("SELECT COUNT(*) as cnt FROM notebooks") - notebook_count = c.fetchone()["cnt"] - c.execute("SELECT SUM(review_count + note_count + bookmark_count) as cnt FROM notebooks") - total_notes = c.fetchone()["cnt"] or 0 + # 查询笔记本统计 + 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 - # 最近阅读时间 - c.execute("SELECT MAX(read_update_time) as max_ts FROM books") - last_read_ts = c.fetchone()["max_ts"] or 0 + # 查询最近阅读时间 + cursor.execute("SELECT MAX(read_update_time) AS max_ts FROM books") + last_read_ts = cursor.fetchone()["max_ts"] or 0 - # 采集时间 - c.execute("SELECT value FROM fetch_meta WHERE key='last_fetch_time'") - fetch_row = c.fetchone() + # 查询数据采集时间 + 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 "" - conn.close() - return jsonify({ "total_read_time": total_read_time, "total_read_time_str": format_duration(total_read_time), @@ -191,15 +463,21 @@ def api_overview(): @app.route("/api/yearly-trend") -def api_yearly_trend(): - """年度阅读时长时间序列""" - conn = get_db() - c = conn.cursor() - c.execute( - "SELECT bucket_timestamp, duration FROM read_times WHERE mode='overall' ORDER BY bucket_timestamp" +@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 = c.fetchall() - conn.close() + rows = cursor.fetchall() data = [] for row in rows: @@ -211,16 +489,21 @@ def api_yearly_trend(): @app.route("/api/top-books") -def api_top_books(): - """阅读时长最久的书籍""" - conn = get_db() - c = conn.cursor() - c.execute( - """SELECT * FROM read_longest WHERE mode='overall' - ORDER BY read_time DESC LIMIT 20""" +@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 = c.fetchall() - conn.close() + rows = cursor.fetchall() books = [] for row in rows: @@ -239,17 +522,21 @@ def api_top_books(): @app.route("/api/categories") -def api_categories(): - """偏好分类""" - conn = get_db() - c = conn.cursor() - c.execute( - """SELECT * FROM prefer_categories WHERE mode='overall' - AND reading_time > 0 - ORDER BY reading_time DESC""" +@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 = c.fetchall() - conn.close() + rows = cursor.fetchall() cats = [] for row in rows: @@ -266,16 +553,21 @@ def api_categories(): @app.route("/api/authors") -def api_authors(): - """偏好作者""" - conn = get_db() - c = conn.cursor() - c.execute( - """SELECT * FROM prefer_authors WHERE mode='overall' - ORDER BY count DESC LIMIT 20""" +@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 = c.fetchall() - conn.close() + rows = cursor.fetchall() authors = [] for row in rows: @@ -289,17 +581,22 @@ def api_authors(): @app.route("/api/time-distribution") -def api_time_distribution(): - """24小时阅读时段分布""" - conn = get_db() - c = conn.cursor() - c.execute( - """SELECT hour_index, SUM(duration) as total - FROM prefer_time WHERE mode='overall' - GROUP BY hour_index ORDER BY hour_index""" +@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 = c.fetchall() - conn.close() + rows = cursor.fetchall() dist = [] for row in rows: @@ -313,41 +610,54 @@ def api_time_distribution(): @app.route("/api/shelf") -def 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 - conn = get_db() - c = conn.cursor() + cursor = conn.cursor() + # 构建查询条件 where_clauses = [] params = [] if category: - where_clauses.append("category LIKE ?") + 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 "" - c.execute(f"SELECT COUNT(*) as cnt FROM books{where_sql}", params) - total = c.fetchone()["cnt"] + # 查询总数 + cursor.execute(f"SELECT COUNT(*) AS cnt FROM books{where_sql}", params) + total = cursor.fetchone()["cnt"] - c.execute( - f"SELECT * FROM books{where_sql} ORDER BY is_top DESC, read_update_time DESC LIMIT ? OFFSET ?", + # 查询当前页数据(置顶优先,最近阅读时间倒序) + 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 = c.fetchall() + rows = cursor.fetchall() - # 获取分类列表 - c.execute("SELECT DISTINCT category FROM books WHERE category != '' ORDER BY category") - categories = [r["category"] for r in c.fetchall()] - - conn.close() + # 获取所有分类列表(用于前端筛选下拉框) + 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: @@ -375,16 +685,21 @@ def api_shelf(): @app.route("/api/notebooks") -def api_notebooks(): - """笔记本概览""" - conn = get_db() - c = conn.cursor() - c.execute( - """SELECT * FROM notebooks - ORDER BY (review_count + note_count + bookmark_count) DESC""" +@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 = c.fetchall() - conn.close() + rows = cursor.fetchall() notebooks = [] for row in rows: @@ -406,13 +721,18 @@ def api_notebooks(): @app.route("/api/read-stats") -def api_read_stats(): - """阅读统计摘要项""" - conn = get_db() - c = conn.cursor() - c.execute("SELECT * FROM read_stat_items WHERE mode='overall'") - rows = c.fetchall() - conn.close() +@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: @@ -426,13 +746,18 @@ def api_read_stats(): @app.route("/api/archives") -def api_archives(): - """书单""" - conn = get_db() - c = conn.cursor() - c.execute("SELECT * FROM archives") - rows = c.fetchall() - conn.close() +@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: @@ -446,29 +771,41 @@ def api_archives(): return jsonify(archives) -# ===== 按需获取:书籍详情 ===== +# ====================================================================== +# API 接口 - 按需获取:书籍详情 +# ====================================================================== @app.route("/api/book/detail") -def api_book_detail(): - """书籍详情:实时调微信读书 API 获取,入库后返回""" +@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 - conn = get_db() - ensure_tables(conn) - c = conn.cursor() - now = int(time.time()) + cursor = conn.cursor() + ensure_tables(cursor) + conn.commit() + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - # 1. 获取书籍信息 + # 1. 获取书籍基本信息 try: info = weread_api_call("/book/info", bookId=book_id) - c.execute( - """INSERT OR 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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + 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", ""), @@ -488,9 +825,9 @@ def api_book_detail(): ) conn.commit() except Exception as e: - # API 调用失败时尝试从数据库读取缓存 - c.execute("SELECT * FROM book_details WHERE book_id=?", (book_id,)) - cached = c.fetchone() + # 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 @@ -499,11 +836,11 @@ def api_book_detail(): try: prog = weread_api_call("/book/getprogress", bookId=book_id) book_prog = prog.get("book", {}) - c.execute( - """INSERT OR REPLACE INTO book_progress - (book_id, progress, chapter_uid, chapter_offset, update_time, - record_reading_time, finish_time, is_start_reading, fetched_at) - VALUES (?,?,?,?,?,?,?,?,?)""", + 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), @@ -526,8 +863,9 @@ def api_book_detail(): "is_start_reading": book_prog.get("isStartReading", 0), } except Exception: - c.execute("SELECT * FROM book_progress WHERE book_id=?", (book_id,)) - cached = c.fetchone() + # API 调用失败,从数据库读取缓存的进度数据 + cursor.execute("SELECT * FROM book_progress WHERE book_id=%s", (book_id,)) + cached = cursor.fetchone() if cached: progress_data = { "progress": cached["progress"], @@ -538,10 +876,9 @@ def api_book_detail(): "is_start_reading": cached["is_start_reading"], } - # 3. 组装返回 - c.execute("SELECT * FROM book_details WHERE book_id=?", (book_id,)) - detail = c.fetchone() - conn.close() + # 3. 从数据库读取最终书籍详情并组装返回 + cursor.execute("SELECT * FROM book_details WHERE book_id=%s", (book_id,)) + detail = cursor.fetchone() if not detail: return jsonify({"error": "数据获取失败"}), 500 @@ -558,50 +895,64 @@ def api_book_detail(): "publish_time": detail["publish_time"], "isbn": detail["isbn"], "word_count": detail["word_count"], - "new_rating": detail["new_rating"], + "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") -def api_book_notes(): - """笔记详情:实时调微信读书 API 获取划线+想法,入库后返回""" +@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 - conn = get_db() - ensure_tables(conn) - c = conn.cursor() - now = int(time.time()) + cursor = conn.cursor() + ensure_tables(cursor) + conn.commit() + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - # 1. 获取划线 + # 1. 获取划线数据 highlights = [] chapters = {} try: data = weread_api_call("/book/bookmarklist", bookId=book_id) + # 保存章节信息 for ch in data.get("chapters", []): - c.execute( - """INSERT OR REPLACE INTO book_chapters - (book_id, chapter_uid, chapter_idx, title) - VALUES (?,?,?,?)""", + 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", "") - # 保存划线 - c.execute("DELETE FROM book_highlights WHERE book_id=?", (book_id,)) + # 保存划线数据(先删除旧数据再插入) + cursor.execute("DELETE FROM book_highlights WHERE book_id=%s", (book_id,)) for bm in data.get("updated", []): - c.execute( - """INSERT INTO book_highlights - (book_id, bookmark_id, chapter_uid, mark_text, create_time, - type, range_text, color_style, fetched_at) - VALUES (?,?,?,?,?,?,?,?,?)""", + 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", ""), @@ -623,13 +974,17 @@ def api_book_notes(): "range": bm.get("range", ""), }) conn.commit() - except Exception as e: - # 从数据库读取缓存 - c.execute("SELECT * FROM book_highlights WHERE book_id=? ORDER BY create_time", (book_id,)) - for row in c.fetchall(): - c2 = conn.cursor() - c2.execute("SELECT title FROM book_chapters WHERE book_id=? AND chapter_uid=?", (book_id, row["chapter_uid"])) - ch_row = c2.fetchone() + 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"], @@ -639,18 +994,17 @@ def api_book_notes(): "range": row["range_text"], }) - # 2. 获取想法/点评 + # 2. 获取想法/点评数据 reviews = [] try: rev_data = weread_api_call("/review/list/mine", bookid=book_id, count=100) - c.execute("DELETE FROM book_reviews WHERE book_id=?", (book_id,)) + cursor.execute("DELETE FROM book_reviews WHERE book_id=%s", (book_id,)) for item in rev_data.get("reviews", []): rev = item.get("review", {}) - c.execute( - """INSERT INTO book_reviews - (book_id, review_id, content, create_time, star, - chapter_name, is_finish, fetched_at) - VALUES (?,?,?,?,?,?,?,?)""", + 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", ""), @@ -672,8 +1026,11 @@ def api_book_notes(): }) conn.commit() except Exception: - c.execute("SELECT * FROM book_reviews WHERE book_id=? ORDER BY create_time DESC", (book_id,)) - for row in c.fetchall(): + # 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"], @@ -683,9 +1040,7 @@ def api_book_notes(): "is_finish": row["is_finish"], }) - conn.close() - - # 按章节分组划线 + # 3. 按章节分组划线 grouped_highlights = {} for h in highlights: ch_title = h["chapter_title"] or f"章节{h['chapter_uid']}" @@ -703,27 +1058,42 @@ def api_book_notes(): }) -# ===== 推荐书籍 ===== +# ====================================================================== +# API 接口 - 推荐书籍 +# ====================================================================== @app.route("/api/recommend") -def api_recommend(): - """个性化推荐:实时调微信读书 API,入库后返回""" +@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)) - conn = get_db() - ensure_tables(conn) - c = conn.cursor() - now = int(time.time()) + 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", {}) - c.execute( - """INSERT OR REPLACE INTO recommended_books - (book_id, title, author, cover, intro, category, reason, - reading_count, new_rating, new_rating_count, rating_title, fetched_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""", + 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", ""), @@ -743,8 +1113,7 @@ def api_recommend(): except Exception as e: return jsonify({"error": f"获取推荐失败: {str(e)}"}), 500 - conn.close() - + # 组装返回数据 result = [] for book in books: rating_detail = book.get("newRatingDetail", {}) @@ -769,11 +1138,25 @@ def api_recommend(): }) -# ===== 搜索书籍 ===== +# ====================================================================== +# API 接口 - 搜索书籍 +# ====================================================================== @app.route("/api/search") -def api_search(): - """搜索书籍:实时调微信读书 API,入库后返回""" +@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: @@ -784,10 +1167,10 @@ def api_search(): return jsonify({"error": "keyword is required"}), 400 max_idx = int(request.args.get("maxIdx", 0)) - conn = get_db() - ensure_tables(conn) - c = conn.cursor() - now = int(time.time()) + 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) @@ -797,12 +1180,12 @@ def api_search(): for item in group.get("books", []): bi = item.get("bookInfo", {}) rating_detail = item.get("newRatingDetail", {}) - c.execute( - """INSERT OR 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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + 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", ""), @@ -837,8 +1220,6 @@ def api_search(): except Exception as e: return jsonify({"error": f"搜索失败: {str(e)}"}), 500 - conn.close() - return jsonify({ "keyword": keyword, "books": books, @@ -846,5 +1227,18 @@ def api_search(): }) +# ====================================================================== +# 启动入口 +# ====================================================================== + 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) diff --git a/docs/METADATA_DOC.md b/docs/METADATA_DOC.md new file mode 100644 index 0000000..8906487 --- /dev/null +++ b/docs/METADATA_DOC.md @@ -0,0 +1,524 @@ +# 微信读书数据展示网站 - 元数据文档 + +> 版本:1.2.0 | 更新时间:2026-07-17 | 数据库:MySQL weread_dashboard + +--- + +## 1. 数据库概览 + +| 属性 | 值 | +|------|-----| +| 数据库名称 | weread_dashboard | +| 字符集 | utf8mb4 | +| 排序规则 | utf8mb4_unicode_ci | +| 存储引擎 | InnoDB | +| 表数量 | 18 | +| 数据总量 | 862 行 | + +--- + +## 2. 表分类 + +| 分类 | 表名 | 说明 | 数据来源 | +|------|------|------|----------| +| **基础数据** | books | 书架书籍 | 采集脚本 | +| **基础数据** | reading_stats | 阅读统计概要 | 采集脚本 | +| **基础数据** | read_times | 阅读时长时间序列 | 采集脚本 | +| **基础数据** | read_longest | 阅读时长排行 | 采集脚本 | +| **基础数据** | prefer_categories | 偏好分类 | 采集脚本 | +| **基础数据** | prefer_authors | 偏好作者 | 采集脚本 | +| **基础数据** | prefer_time | 24h 时段分布 | 采集脚本 | +| **基础数据** | notebooks | 笔记本概览 | 采集脚本 | +| **基础数据** | read_stat_items | 统计摘要项 | 采集脚本 | +| **基础数据** | archives | 书单归档 | 采集脚本 | +| **基础数据** | fetch_meta | 采集元信息 | 采集脚本 | +| **按需数据** | book_details | 书籍详情 | 用户点击时获取 | +| **按需数据** | book_progress | 阅读进度 | 用户点击时获取 | +| **按需数据** | book_highlights | 划线内容 | 用户点击时获取 | +| **按需数据** | book_chapters | 章节信息 | 用户点击时获取 | +| **按需数据** | book_reviews | 想法/点评 | 用户点击时获取 | +| **按需数据** | recommended_books | 推荐书籍 | 用户浏览推荐时获取 | +| **按需数据** | search_results | 搜索结果 | 用户搜索时获取 | + +--- + +## 3. 表结构详情 + +### 3.1 books - 书架书籍表 + +> 存储用户书架中的所有书籍基本信息 + +| 字段名 | 数据类型 | 是否必填 | 默认值 | 说明 | +|--------|----------|----------|--------|------| +| book_id | VARCHAR(64) | 是 (PK) | - | 书籍唯一标识 | +| title | VARCHAR(255) | 是 | '' | 书名 | +| author | VARCHAR(255) | 否 | NULL | 作者 | +| cover | VARCHAR(512) | 否 | NULL | 封面图 URL | +| category | VARCHAR(128) | 否 | NULL | 分类(如"文学-古代诗词") | +| finish_reading | TINYINT(1) | 是 | 0 | 是否读完 (1=读完) | +| read_update_time | INT UNSIGNED | 否 | NULL | 最近阅读时间 (Unix 时间戳) | +| secret | TINYINT(1) | 是 | 0 | 是否私密 (1=私密) | +| is_top | TINYINT(1) | 是 | 0 | 是否置顶 (1=置顶) | +| update_time | INT UNSIGNED | 否 | NULL | 书籍更新时间 (Unix 时间戳) | +| deep_link | VARCHAR(512) | 否 | NULL | 微信读书深度链接 | + +**索引**: +- PRIMARY KEY (book_id) +- idx_books_category (category) +- idx_books_finish_reading (finish_reading) +- idx_books_read_update_time (read_update_time DESC) + +**记录数**:404 + +--- + +### 3.2 reading_stats - 阅读统计概要表 + +> 存储总体和年度的阅读统计数据 + +| 字段名 | 数据类型 | 是否必填 | 默认值 | 说明 | +|--------|----------|----------|--------|------| +| id | BIGINT | 是 (PK) | AUTO | 自增主键 | +| mode | VARCHAR(20) | 是 | - | 统计维度: overall/annually | +| period | VARCHAR(20) | 是 | '' | 周期标识: "overall" 或 "2026" | +| total_read_time | INT UNSIGNED | 是 | 0 | 总阅读时长 (秒) | +| read_days | INT UNSIGNED | 是 | 0 | 有效阅读天数 | +| day_average_read_time | DOUBLE | 否 | 0 | 日均阅读时长 (秒) | +| regist_time | INT UNSIGNED | 否 | NULL | 用户注册时间 (Unix 时间戳) | +| raw_json | LONGTEXT | 否 | NULL | 原始 API 返回 JSON | +| created_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 记录创建时间 | + +**索引**: +- PRIMARY KEY (id) +- idx_reading_stats_mode (mode, period) + +**记录数**:2 + +--- + +### 3.3 read_times - 阅读时长时间序列表 + +> 按年/月/日分桶的阅读时长数据 + +| 字段名 | 数据类型 | 是否必填 | 默认值 | 说明 | +|--------|----------|----------|--------|------| +| id | BIGINT | 是 (PK) | AUTO | 自增主键 | +| mode | VARCHAR(30) | 是 | - | 统计维度 (含 _daily 后缀) | +| period | VARCHAR(20) | 是 | '' | 周期标识 | +| bucket_timestamp | INT UNSIGNED | 是 | - | 分桶起始时间戳 | +| duration | INT UNSIGNED | 是 | 0 | 阅读时长 (秒) | + +**索引**: +- PRIMARY KEY (id) +- idx_read_times_mode_period (mode, period) +- idx_read_times_bucket (bucket_timestamp) + +**记录数**:21 + +--- + +### 3.4 read_longest - 阅读时长排行表 + +> 阅读时间最久的书籍排名 + +| 字段名 | 数据类型 | 是否必填 | 默认值 | 说明 | +|--------|----------|----------|--------|------| +| id | BIGINT | 是 (PK) | AUTO | 自增主键 | +| mode | VARCHAR(20) | 是 | - | 统计维度 | +| period | VARCHAR(20) | 是 | '' | 周期标识 | +| book_id | VARCHAR(64) | 是 | - | 书籍 ID | +| title | VARCHAR(255) | 否 | NULL | 书名 | +| author | VARCHAR(255) | 否 | NULL | 作者 | +| cover | VARCHAR(512) | 否 | NULL | 封面图 URL | +| read_time | INT UNSIGNED | 是 | 0 | 阅读时长 (秒) | +| tags | VARCHAR(512) | 否 | NULL | 标签 (JSON 数组) | + +**索引**: +- PRIMARY KEY (id) +- idx_read_longest_mode (mode, period) + +**记录数**:20 + +--- + +### 3.5 prefer_categories - 偏好分类表 + +> 用户阅读的书籍分类统计 + +| 字段名 | 数据类型 | 是否必填 | 默认值 | 说明 | +|--------|----------|----------|--------|------| +| id | BIGINT | 是 (PK) | AUTO | 自增主键 | +| mode | VARCHAR(20) | 是 | - | 统计维度 | +| period | VARCHAR(20) | 是 | '' | 周期标识 | +| category_id | INT UNSIGNED | 否 | NULL | 分类 ID | +| category_title | VARCHAR(128) | 否 | NULL | 分类名称 | +| parent_category_title | VARCHAR(128) | 否 | NULL | 父分类名称 | +| val | DOUBLE | 否 | 0 | 偏好权重 | +| reading_time | INT UNSIGNED | 否 | 0 | 该分类阅读时长 (秒) | +| reading_count | INT UNSIGNED | 否 | 0 | 该分类阅读本数 | + +**索引**: +- PRIMARY KEY (id) +- idx_prefer_cat_mode (mode, period) + +**记录数**:16 + +--- + +### 3.6 prefer_authors - 偏好作者表 + +> 用户阅读最多的作者统计 + +| 字段名 | 数据类型 | 是否必填 | 默认值 | 说明 | +|--------|----------|----------|--------|------| +| id | BIGINT | 是 (PK) | AUTO | 自增主键 | +| mode | VARCHAR(20) | 是 | - | 统计维度 | +| period | VARCHAR(20) | 是 | '' | 周期标识 | +| author_id | INT UNSIGNED | 否 | NULL | 作者 ID | +| name | VARCHAR(255) | 否 | NULL | 作者名称 | +| count | INT UNSIGNED | 否 | 0 | 阅读书本数 | +| read_time | VARCHAR(64) | 否 | NULL | 阅读时长 (格式化字符串) | + +**索引**: +- PRIMARY KEY (id) +- idx_prefer_author_mode (mode, period) + +**记录数**:20 + +--- + +### 3.7 prefer_time - 24小时时段分布表 + +> 一天中每个小时的阅读时长分布 + +| 字段名 | 数据类型 | 是否必填 | 默认值 | 说明 | +|--------|----------|----------|--------|------| +| id | BIGINT | 是 (PK) | AUTO | 自增主键 | +| mode | VARCHAR(20) | 是 | - | 统计维度 | +| period | VARCHAR(20) | 是 | '' | 周期标识 | +| hour_index | TINYINT UNSIGNED | 是 | - | 小时 (0-23) | +| duration | INT UNSIGNED | 是 | 0 | 阅读时长 (秒) | + +**索引**: +- PRIMARY KEY (id) +- idx_prefer_time_mode (mode, period) + +**记录数**:24 + +--- + +### 3.8 notebooks - 笔记本概览表 + +> 有笔记的书籍及其笔记统计 + +| 字段名 | 数据类型 | 是否必填 | 默认值 | 说明 | +|--------|----------|----------|--------|------| +| book_id | VARCHAR(64) | 是 (PK) | - | 书籍 ID | +| title | VARCHAR(255) | 否 | NULL | 书名 | +| author | VARCHAR(255) | 否 | NULL | 作者 | +| cover | VARCHAR(512) | 否 | NULL | 封面图 URL | +| review_count | INT UNSIGNED | 是 | 0 | 想法/点评数 | +| note_count | INT UNSIGNED | 是 | 0 | 划线数 | +| bookmark_count | INT UNSIGNED | 是 | 0 | 书签数 | +| reading_progress | INT UNSIGNED | 是 | 0 | 阅读进度 (0-100) | +| marked_status | TINYINT(1) | 是 | 0 | 标记状态 (1=读完) | +| sort | BIGINT | 否 | NULL | 排序值 | + +**索引**: +- PRIMARY KEY (book_id) + +**记录数**:32 + +--- + +### 3.9 read_stat_items - 统计摘要项表 + +> App 首页展示的阅读统计摘要 + +| 字段名 | 数据类型 | 是否必填 | 默认值 | 说明 | +|--------|----------|----------|--------|------| +| id | BIGINT | 是 (PK) | AUTO | 自增主键 | +| mode | VARCHAR(20) | 是 | - | 统计维度 | +| period | VARCHAR(20) | 是 | '' | 周期标识 | +| stat_name | VARCHAR(64) | 否 | NULL | 统计项名称 (读过/读完/阅读/笔记) | +| counts | VARCHAR(64) | 否 | NULL | 统计值文案 (如"404本") | +| scheme | VARCHAR(512) | 否 | NULL | App 跳转链接 | + +**索引**: +- PRIMARY KEY (id) +- idx_read_stat_items_mode (mode, period) + +**记录数**:8 + +--- + +### 3.10 archives - 书单归档表 + +> 用户创建的书单 + +| 字段名 | 数据类型 | 是否必填 | 默认值 | 说明 | +|--------|----------|----------|--------|------| +| id | BIGINT | 是 (PK) | AUTO | 自增主键 | +| name | VARCHAR(255) | 否 | NULL | 书单名称 | +| book_ids | LONGTEXT | 否 | NULL | 书籍 ID 列表 (JSON 数组) | + +**索引**: +- PRIMARY KEY (id) + +**记录数**:6 + +--- + +### 3.11 fetch_meta - 采集元信息表 + +> 记录数据采集的元信息 + +| 字段名 | 数据类型 | 是否必填 | 默认值 | 说明 | +|--------|----------|----------|--------|------| +| key | VARCHAR(64) | 是 (PK) | - | 元信息键名 | +| value | TEXT | 否 | NULL | 元信息值 | + +**索引**: +- PRIMARY KEY (key) + +**记录数**:1 + +--- + +### 3.12 book_details - 书籍详细信息表 + +> 来自微信读书 /book/info 接口,用户点击书籍时按需获取 + +| 字段名 | 数据类型 | 是否必填 | 默认值 | 说明 | +|--------|----------|----------|--------|------| +| book_id | VARCHAR(64) | 是 (PK) | - | 书籍 ID | +| title | VARCHAR(255) | 否 | NULL | 书名 | +| author | VARCHAR(255) | 否 | NULL | 作者 | +| translator | VARCHAR(255) | 否 | NULL | 译者 | +| cover | VARCHAR(512) | 否 | NULL | 封面图 URL | +| intro | LONGTEXT | 否 | NULL | 书籍简介 | +| category | VARCHAR(128) | 否 | NULL | 分类 | +| publisher | VARCHAR(255) | 否 | NULL | 出版社 | +| publish_time | VARCHAR(32) | 否 | NULL | 出版时间 | +| isbn | VARCHAR(32) | 否 | NULL | ISBN 号 | +| word_count | INT UNSIGNED | 否 | 0 | 总字数 | +| new_rating | DECIMAL(5,1) | 否 | 0 | 评分 (百分制) | +| new_rating_count | INT UNSIGNED | 否 | 0 | 评分人数 | +| fetched_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 获取时间 | + +**索引**: +- PRIMARY KEY (book_id) + +**记录数**:1 + +--- + +### 3.13 book_progress - 阅读进度表 + +> 来自微信读书 /book/getprogress 接口 + +| 字段名 | 数据类型 | 是否必填 | 默认值 | 说明 | +|--------|----------|----------|--------|------| +| book_id | VARCHAR(64) | 是 (PK) | - | 书籍 ID | +| progress | TINYINT UNSIGNED | 是 | 0 | 阅读进度百分比 (0-100) | +| chapter_uid | INT UNSIGNED | 否 | NULL | 当前阅读章节 UID | +| chapter_offset | INT UNSIGNED | 否 | NULL | 当前章节内偏移量 | +| update_time | INT UNSIGNED | 否 | NULL | 最后阅读时间 (Unix 时间戳) | +| record_reading_time | INT UNSIGNED | 否 | 0 | 累计阅读时长 (秒) | +| finish_time | INT UNSIGNED | 否 | NULL | 读完时间 (Unix 时间戳) | +| is_start_reading | TINYINT(1) | 是 | 0 | 是否已开始阅读 | +| fetched_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 获取时间 | + +**索引**: +- PRIMARY KEY (book_id) + +**记录数**:1 + +--- + +### 3.14 book_highlights - 划线内容表 + +> 来自微信读书 /book/bookmarklist 接口 + +| 字段名 | 数据类型 | 是否必填 | 默认值 | 说明 | +|--------|----------|----------|--------|------| +| id | BIGINT | 是 (PK) | AUTO | 自增主键 | +| book_id | VARCHAR(64) | 是 | - | 书籍 ID | +| bookmark_id | VARCHAR(64) | 否 | NULL | 划线唯一 ID | +| chapter_uid | INT UNSIGNED | 否 | NULL | 所在章节 UID | +| mark_text | LONGTEXT | 否 | NULL | 划线原文内容 | +| create_time | INT UNSIGNED | 否 | NULL | 创建时间 (Unix 时间戳) | +| type | TINYINT | 否 | NULL | 划线类型 | +| range_text | VARCHAR(64) | 否 | NULL | 位置范围 | +| color_style | TINYINT | 否 | NULL | 划线颜色样式 | +| fetched_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 获取时间 | + +**索引**: +- PRIMARY KEY (id) +- idx_highlights_book_id (book_id) +- idx_highlights_chapter (book_id, chapter_uid) + +**记录数**:142 + +--- + +### 3.15 book_chapters - 章节信息表 + +> 来自微信读书 /book/bookmarklist 接口的 chapters 字段 + +| 字段名 | 数据类型 | 是否必填 | 默认值 | 说明 | +|--------|----------|----------|--------|------| +| id | BIGINT | 是 (PK) | AUTO | 自增主键 | +| book_id | VARCHAR(64) | 是 | - | 书籍 ID | +| chapter_uid | INT UNSIGNED | 是 | - | 章节 UID | +| chapter_idx | INT UNSIGNED | 否 | NULL | 章节序号 | +| title | VARCHAR(255) | 否 | NULL | 章节标题 | + +**索引**: +- PRIMARY KEY (id) +- UNIQUE KEY uk_book_chapter (book_id, chapter_uid) + +**记录数**:77 + +--- + +### 3.16 book_reviews - 个人想法/点评表 + +> 来自微信读书 /review/list/mine 接口 + +| 字段名 | 数据类型 | 是否必填 | 默认值 | 说明 | +|--------|----------|----------|--------|------| +| id | BIGINT | 是 (PK) | AUTO | 自增主键 | +| book_id | VARCHAR(64) | 是 | - | 书籍 ID | +| review_id | VARCHAR(64) | 否 | NULL | 想法唯一 ID | +| content | LONGTEXT | 否 | NULL | 想法内容文本 | +| create_time | INT UNSIGNED | 否 | NULL | 创建时间 (Unix 时间戳) | +| star | TINYINT | 否 | -1 | 评分 (0-5, -1=无评分) | +| chapter_name | VARCHAR(255) | 否 | NULL | 所在章节名称 | +| is_finish | TINYINT(1) | 否 | 0 | 是否书评 (1=书评) | +| fetched_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 获取时间 | + +**索引**: +- PRIMARY KEY (id) +- idx_reviews_book_id (book_id) + +**记录数**:19 + +--- + +### 3.17 recommended_books - 推荐书籍表 + +> 来自微信读书 /book/recommend 接口 + +| 字段名 | 数据类型 | 是否必填 | 默认值 | 说明 | +|--------|----------|----------|--------|------| +| id | BIGINT | 是 (PK) | AUTO | 自增主键 | +| book_id | VARCHAR(64) | 是 | - | 书籍 ID | +| title | VARCHAR(255) | 否 | NULL | 书名 | +| author | VARCHAR(255) | 否 | NULL | 作者 | +| cover | VARCHAR(512) | 否 | NULL | 封面图 URL | +| intro | TEXT | 否 | NULL | 书籍简介 | +| category | VARCHAR(128) | 否 | NULL | 分类 | +| reason | VARCHAR(512) | 否 | NULL | 推荐理由 | +| reading_count | INT UNSIGNED | 否 | 0 | 在读人数 | +| new_rating | DECIMAL(5,1) | 否 | 0 | 评分 (百分制) | +| new_rating_count | INT UNSIGNED | 否 | 0 | 评分人数 | +| rating_title | VARCHAR(32) | 否 | NULL | 评分标签 (神作/力荐等) | +| fetched_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 获取时间 | + +**索引**: +- PRIMARY KEY (id) +- idx_recommended_book_id (book_id) +- idx_recommended_fetched (fetched_at DESC) + +**记录数**:48 + +--- + +### 3.18 search_results - 搜索结果表 + +> 来自微信读书 /store/search 接口 + +| 字段名 | 数据类型 | 是否必填 | 默认值 | 说明 | +|--------|----------|----------|--------|------| +| id | BIGINT | 是 (PK) | AUTO | 自增主键 | +| keyword | VARCHAR(255) | 是 | - | 搜索关键词 | +| book_id | VARCHAR(64) | 是 | - | 书籍 ID | +| title | VARCHAR(255) | 否 | NULL | 书名 | +| author | VARCHAR(255) | 否 | NULL | 作者 | +| cover | VARCHAR(512) | 否 | NULL | 封面图 URL | +| intro | TEXT | 否 | NULL | 书籍简介 | +| publisher | VARCHAR(255) | 否 | NULL | 出版社 | +| category | VARCHAR(128) | 否 | NULL | 分类 | +| reading_count | INT UNSIGNED | 否 | 0 | 在读人数 | +| new_rating | DECIMAL(5,1) | 否 | 0 | 评分 (百分制) | +| new_rating_count | INT UNSIGNED | 否 | 0 | 评分人数 | +| rating_title | VARCHAR(32) | 否 | NULL | 评分标签 | +| search_idx | INT UNSIGNED | 否 | 0 | 搜索序号 | +| fetched_at | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 获取时间 | + +**索引**: +- PRIMARY KEY (id) +- idx_search_keyword (keyword) +- idx_search_book_id (book_id) + +**记录数**:20 + +--- + +## 4. 表关系说明 + +### 4.1 实体关系 + +``` +books (书架) + ├── book_id ──── book_details (书籍详情) [1:1 按需] + ├── book_id ──── book_progress (阅读进度) [1:1 按需] + ├── book_id ──── book_highlights (划线) [1:N 按需] + ├── book_id ──── book_chapters (章节) [1:N 按需] + ├── book_id ──── book_reviews (想法) [1:N 按需] + └── book_id ──── notebooks (笔记本概览) [1:1 有笔记的书] + +read_longest (阅读排行) + └── book_id ──── books (书架) [N:1 关联] + +recommended_books (推荐) + └── book_id (独立,不关联 books) + +search_results (搜索) + └── book_id (独立,不关联 books) + +archives (书单) + └── book_ids (JSON 数组,逻辑关联 books) +``` + +### 4.2 逻辑分组 + +- **静态数据组**(采集时全量写入):books, reading_stats, read_times, read_longest, prefer_categories, prefer_authors, prefer_time, notebooks, read_stat_items, archives, fetch_meta +- **动态数据组**(用户操作时按需写入):book_details, book_progress, book_highlights, book_chapters, book_reviews, recommended_books, search_results + +--- + +## 5. 数据类型规范 + +| MySQL 类型 | 用途 | 对应 SQLite 类型 | +|------------|------|------------------| +| VARCHAR(n) | 变长字符串(书名、作者等) | TEXT | +| LONGTEXT | 长文本(简介、划线内容) | TEXT | +| TEXT | 中等文本 | TEXT | +| INT UNSIGNED | 无符号整数(时间戳、计数) | INTEGER | +| TINYINT(1) | 布尔值(0/1) | INTEGER | +| TINYINT UNSIGNED | 小范围整数(小时 0-23、进度 0-100) | INTEGER | +| BIGINT | 自增主键 | INTEGER | +| DECIMAL(5,1) | 精度数值(评分) | REAL | +| DOUBLE | 浮点数(权重、日均时长) | REAL | +| TIMESTAMP | 时间戳(自动维护) | INTEGER (Unix 时间戳) | + +--- + +## 6. 字符集与编码 + +- 数据库字符集:`utf8mb4`(支持完整 Unicode,包括 Emoji) +- 排序规则:`utf8mb4_unicode_ci`(不区分大小写,支持多语言排序) +- 连接字符集:`utf8mb4`(PyMySQL 连接配置中指定) +- 前端编码:`UTF-8`(HTML meta 标签声明) diff --git a/docs/TECHNICAL_DOC.md b/docs/TECHNICAL_DOC.md new file mode 100644 index 0000000..4b02e65 --- /dev/null +++ b/docs/TECHNICAL_DOC.md @@ -0,0 +1,331 @@ +# 微信读书数据展示网站 - 技术文档 + +> 版本:1.2.0 | 更新时间:2026-07-17 | 作者:WorkBuddy + +--- + +## 1. 项目概述 + +### 1.1 项目简介 + +本项目是一个个人读书数据展示网站,数据来源于微信读书。通过微信读书 API 拉取用户的阅读数据(书架、阅读统计、笔记等),存入本地数据库,并通过 Web 仪表盘进行可视化展示。 + +### 1.2 核心功能 + +| 模块 | 功能说明 | +|------|----------| +| 数据采集 | 从微信读书 API 拉取书架、阅读统计、笔记本数据,存入本地数据库 | +| 总览仪表盘 | 累计阅读时长、阅读天数、藏书量、笔记数等核心指标 | +| 数据图表 | 年度阅读趋势(柱状图)、24h 时段分布(折线图)、偏好分类(饼图)、偏好作者(条形图) | +| 读书排行 | 阅读时长最久的 20 本书卡片展示 | +| 书架 | 404 本书的网格展示,支持分类筛选和"仅看已读完" | +| 笔记概览 | 有笔记的书籍列表,点击查看划线和想法详情 | +| 书籍详情 | 点击书架书籍弹出详情(书名、作者、出版社、ISBN、评分、阅读进度) | +| 笔记详情 | 点击笔记书籍查看划线(按章节分组)和想法/点评 | +| 推荐页面 | 个性化推荐书籍 + 关键词搜索,数据入库 | + +### 1.3 技术栈 + +| 层级 | 技术 | 说明 | +|------|------|------| +| 前端 | HTML5 + CSS3 + 原生 JavaScript | 无框架依赖,轻量高效 | +| 图表 | ECharts 5.5.0 | 年度趋势、时段分布、分类饼图、作者排行 | +| 后端 | Python Flask | 轻量级 Web 框架,提供 RESTful API | +| 数据库 | MySQL 5.7+(主)/ SQLite(采集) | MySQL 为生产数据库,SQLite 用于数据采集中间存储 | +| 数据采集 | 微信读书 Agent Gateway API | 通过 weread-skills 技能提供的 API 接口 | +| 版本控制 | Git | dev 分支开发,master 分支发布,遵循 Conventional Commits | + +--- + +## 2. 系统架构 + +### 2.1 整体架构图 + +``` +┌─────────────────────────────────────────────────────────┐ +│ 用户浏览器(前端) │ +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ +│ │ 总览仪表盘│ │ 数据图表 │ │ 书架列表 │ │ 推荐搜索 │ │ +│ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │ +│ │ │ │ │ │ +│ └───────────┴─────┬─────┴───────────┘ │ +│ │ │ +│ ┌────────▼────────┐ │ +│ │ 健康检查模块 │ │ +│ │ (MySQL 连接检测) │ │ +│ └────────┬────────┘ │ +└─────────────────────────┼───────────────────────────────┘ + │ HTTP / RESTful API +┌─────────────────────────┼───────────────────────────────┐ +│ Flask 后端服务 │ +│ │ │ +│ ┌──────────────────────▼──────────────────────┐ │ +│ │ API 路由层 │ │ +│ │ /api/overview /api/shelf /api/book/detail │ │ +│ │ /api/recommend /api/search /api/health ... │ │ +│ └──────────────────────┬──────────────────────┘ │ +│ │ │ +│ ┌──────────────────────▼──────────────────────┐ │ +│ │ db_required 装饰器 │ │ +│ │ (连接管理 + 错误处理 + 503 告警) │ │ +│ └──────────────────────┬──────────────────────┘ │ +│ │ │ +│ ┌──────────────────────▼──────────────────────┐ │ +│ │ PyMySQL 数据访问层 │ │ +│ └───────────┬──────────────────┬──────────────┘ │ +│ │ │ │ +│ ┌───────────▼─────┐ ┌────────▼─────────┐ │ +│ │ MySQL 数据库 │ │ 微信读书 API 代理 │ │ +│ │ weread_dashboard│ │ (按需获取+入库) │ │ +│ └─────────────────┘ └──────────────────┘ │ +└─────────────────────────────────────────────────────────┘ + │ +┌─────────────────────────┼───────────────────────────────┐ +│ 数据采集层(独立运行) │ +│ ┌──────────────────────▼──────────────────────┐ │ +│ │ fetch_data.py 采集脚本 │ │ +│ │ 拉取书架/统计/笔记 → SQLite → migrate_to_mysql│ │ +│ └──────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +### 2.2 数据流设计 + +``` +微信读书 API + │ + ▼ +fetch_data.py(采集脚本) + │ + ▼ +weread.db(SQLite 中间存储) + │ + ▼ +migrate_to_mysql.py(迁移脚本) + │ + ▼ +MySQL weread_dashboard(生产数据库) + │ + ▼ +Flask app.py(后端服务,端口 8080) + │ + ▼ +前端页面(ECharts 可视化) +``` + +**按需数据流**(用户点击书籍/笔记时): + +``` +用户点击 → Flask API → 微信读书 API(实时获取)→ 入库 MySQL → 返回前端 + ↓(API 失败时) + 从 MySQL 读取缓存数据 → 返回前端 +``` + +### 2.3 目录结构 + +``` +weread-dashboard/ +├── app.py # Flask 后端服务(MySQL 数据源) +├── fetch_data.py # 数据采集脚本(SQLite) +├── migrate_to_mysql.py # SQLite → MySQL 数据迁移脚本 +├── schema_mysql.sql # MySQL 建表语句(含中文注释) +├── requirements.txt # Python 依赖 +├── weread.db # SQLite 数据库文件(采集中间存储) +├── README.md # 项目说明 +├── docs/ # 文档目录 +│ ├── TECHNICAL_DOC.md # 技术文档(本文件) +│ └── METADATA_DOC.md # 元数据文档 +├── templates/ +│ └── index.html # 前端页面 +└── static/ + ├── css/ + │ └── style.css # 样式文件 + └── js/ + └── app.js # 前端逻辑 +``` + +--- + +## 3. 后端 API 设计 + +### 3.1 API 接口列表 + +| 接口 | 方法 | 说明 | 数据来源 | +|------|------|------|----------| +| `/api/health` | GET | 健康检查(MySQL 连接状态) | MySQL | +| `/api/overview` | GET | 总览数据(核心指标) | MySQL | +| `/api/yearly-trend` | GET | 年度阅读时长时间序列 | MySQL | +| `/api/top-books` | GET | 阅读时长排行 TOP 20 | MySQL | +| `/api/categories` | GET | 偏好分类 | MySQL | +| `/api/authors` | GET | 偏好作者 TOP 20 | MySQL | +| `/api/time-distribution` | GET | 24 小时时段分布 | MySQL | +| `/api/shelf` | GET | 书架列表(分页) | MySQL | +| `/api/notebooks` | GET | 笔记本概览 | MySQL | +| `/api/read-stats` | GET | 阅读统计摘要 | MySQL | +| `/api/archives` | GET | 书单列表 | MySQL | +| `/api/book/detail` | GET | 书籍详情(按需) | 微信读书 API + MySQL | +| `/api/book/notes` | GET | 笔记详情(按需) | 微信读书 API + MySQL | +| `/api/recommend` | GET | 个性化推荐(按需) | 微信读书 API + MySQL | +| `/api/search` | GET | 搜索书籍(按需) | 微信读书 API + MySQL | + +### 3.2 接口详情 + +#### 3.2.1 健康检查 + +``` +GET /api/health +``` + +**响应(正常)**: +```json +{ + "status": "ok", + "database": "connected", + "message": "MySQL 连接正常" +} +``` + +**响应(异常,HTTP 503)**: +```json +{ + "status": "error", + "database": "disconnected", + "message": "MySQL 连接失败: ..." +} +``` + +#### 3.2.2 总览数据 + +``` +GET /api/overview +``` + +**响应**: +```json +{ + "total_read_time": 1223850, + "total_read_time_str": "339小时57分钟", + "read_days": 304, + "year_read_time": 849029, + "year_read_time_str": "235小时50分钟", + "year_read_days": 198, + "shelf_count": 404, + "finished_count": 12, + "notebook_count": 32, + "total_notes": 1709, + "regist_time": "2024-03-15", + "last_read_time": "2026-07-17", + "fetch_time": "2026-07-17 09:50:00" +} +``` + +#### 3.2.3 书架列表(分页) + +``` +GET /api/shelf?page=1&per_page=60&category=文学&finished=1 +``` + +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| page | int | 1 | 页码 | +| per_page | int | 60 | 每页数量 | +| category | string | "" | 分类筛选(模糊匹配) | +| finished | string | "0" | "1"=只看已读完 | + +#### 3.2.4 书籍详情(按需) + +``` +GET /api/book/detail?book_id=34615967 +``` + +实时调用微信读书 `/book/info` + `/book/getprogress` API,入库后返回。API 失败时回退到数据库缓存。 + +#### 3.2.5 错误处理 + +所有 API 接口在 MySQL 连接失败时统一返回: + +``` +HTTP 503 Service Unavailable +{ + "error": "数据库连接失败", + "detail": "具体错误信息", + "alert": "MySQL 数据库无法连接,请检查 MySQL 服务是否已启动。" +} +``` + +--- + +## 4. 关键设计决策 + +### 4.1 数据库选择 + +- **生产环境使用 MySQL**:支持并发访问、事务管理、完善的权限体系 +- **采集层使用 SQLite**:轻量级,无需额外服务,适合单机数据采集 +- **迁移脚本桥接**:`migrate_to_mysql.py` 负责 SQLite → MySQL 的数据同步 + +### 4.2 按需获取策略 + +书籍详情、笔记详情、推荐、搜索等数据采用**按需获取**策略: +- 用户点击时实时调用微信读书 API +- 获取成功后入库 MySQL 缓存 +- API 调用失败时自动回退到数据库缓存 +- 避免初始化时大量 API 调用,提升用户体验 + +### 4.3 健康检查与告警机制 + +- 后端提供 `/api/health` 接口检测 MySQL 连接状态 +- 前端页面加载时首先调用健康检查 +- 连接失败时显示固定告警横幅,阻止后续数据加载 +- 使用 `db_required` 装饰器统一处理数据库连接异常 + +### 4.4 Git 分支策略 + +- **dev 分支**:日常开发,功能迭代 +- **master 分支**:稳定发布版本,通过 `--no-ff` 合并 dev +- 提交信息遵循 **Conventional Commits** 规范(`feat:` / `fix:` / `merge:`) + +--- + +## 5. 部署与运行 + +### 5.1 环境要求 + +- Python 3.13+ +- MySQL 5.7+ +- 微信读书 API Key(通过 `WEREAD_API_KEY` 环境变量配置) + +### 5.2 启动步骤 + +```bash +# 1. 设置微信读书 API Key +export WEREAD_API_KEY=wrk-xxxxxxxx + +# 2. 安装 Python 依赖 +pip install flask pymysql + +# 3. 启动 MySQL 服务 +sudo /usr/local/mysql/support-files/mysql.server start + +# 4. 创建数据库并建表 +mysql -u root -p < schema_mysql.sql + +# 5. 采集数据(首次) +python fetch_data.py + +# 6. 迁移数据到 MySQL +python migrate_to_mysql.py + +# 7. 启动 Web 服务 +python app.py +# 访问 http://127.0.0.1:8080 +``` + +### 5.3 数据更新 + +```bash +# 重新采集最新数据 +python fetch_data.py + +# 同步到 MySQL +python migrate_to_mysql.py +``` diff --git a/fetch_data.py b/fetch_data.py index 626b14b..4866939 100644 --- a/fetch_data.py +++ b/fetch_data.py @@ -35,268 +35,275 @@ def api_call(api_name, **kwargs): def init_db(db_path): - """初始化数据库表结构""" + """ + 初始化 SQLite 数据库表结构。 + 创建所有数据表(如果不存在),包括: + - 基础数据表:书架、阅读统计、笔记本等(采集时写入) + - 按需数据表:书籍详情、划线、想法等(用户点击时写入) + """ conn = sqlite3.connect(db_path) c = conn.cursor() - # 书架书籍表 + # ===== 基础数据表(采集脚本写入) ===== + + # 书架书籍表 - 存储用户书架中的所有书籍信息 c.execute(""" CREATE TABLE IF NOT EXISTS books ( - book_id TEXT PRIMARY KEY, - title TEXT, - author TEXT, - cover TEXT, - category TEXT, - finish_reading INTEGER DEFAULT 0, - read_update_time INTEGER, - secret INTEGER DEFAULT 0, - is_top INTEGER DEFAULT 0, - update_time INTEGER, - deep_link TEXT + book_id TEXT PRIMARY KEY, -- 书籍唯一标识 + title TEXT, -- 书名 + author TEXT, -- 作者 + cover TEXT, -- 封面图 URL + category TEXT, -- 分类(如 "文学-古代诗词") + finish_reading INTEGER DEFAULT 0, -- 是否读完 (1=读完, 0=未读完) + read_update_time INTEGER, -- 最近阅读时间 (Unix 时间戳) + secret INTEGER DEFAULT 0, -- 是否私密书籍 (1=私密) + is_top INTEGER DEFAULT 0, -- 是否置顶 (1=置顶) + update_time INTEGER, -- 书籍信息更新时间 (Unix 时间戳) + deep_link TEXT -- 微信读书 App 深度链接 ) """) - # 阅读统计概要表 + # 阅读统计概要表 - 存储总体/年度阅读统计数据 c.execute(""" CREATE TABLE IF NOT EXISTS reading_stats ( id INTEGER PRIMARY KEY AUTOINCREMENT, - mode TEXT, - period TEXT, - total_read_time INTEGER, - read_days INTEGER, - day_average_read_time REAL, - regist_time INTEGER, - raw_json TEXT + mode TEXT, -- 统计维度: overall(总体)/annually(年度) + period TEXT, -- 周期标识: "overall" 或 "2026" + total_read_time INTEGER, -- 总阅读时长 (秒) + read_days INTEGER, -- 有效阅读天数 + day_average_read_time REAL, -- 日均阅读时长 (秒) + regist_time INTEGER, -- 用户注册时间 (Unix 时间戳) + raw_json TEXT -- 原始 API 返回 JSON (完整备份) ) """) - # 阅读时长时间序列(按年/月/日分桶) + # 阅读时长时间序列表 - 按年/月/日分桶的阅读时长数据 c.execute(""" CREATE TABLE IF NOT EXISTS read_times ( id INTEGER PRIMARY KEY AUTOINCREMENT, - mode TEXT, - period TEXT, - bucket_timestamp INTEGER, - duration INTEGER + mode TEXT, -- 统计维度 (含 _daily 后缀表示每日数据) + period TEXT, -- 周期标识 + bucket_timestamp INTEGER, -- 分桶起始时间戳 (Unix 时间戳) + duration INTEGER -- 该时段阅读时长 (秒) ) """) - # 阅读时长最长的书籍排行 + # 阅读时长排行表 - 阅读时间最久的书籍排名 c.execute(""" CREATE TABLE IF NOT EXISTS read_longest ( id INTEGER PRIMARY KEY AUTOINCREMENT, - mode TEXT, - period TEXT, - book_id TEXT, - title TEXT, - author TEXT, - cover TEXT, - read_time INTEGER, - tags TEXT + mode TEXT, -- 统计维度 + period TEXT, -- 周期标识 + book_id TEXT, -- 书籍 ID + title TEXT, -- 书名 + author TEXT, -- 作者 + cover TEXT, -- 封面图 URL + read_time INTEGER, -- 阅读时长 (秒) + tags TEXT -- 标签 (JSON 数组格式) ) """) - # 偏好分类 + # 偏好分类表 - 用户阅读的书籍分类统计 c.execute(""" CREATE TABLE IF NOT EXISTS prefer_categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, - mode TEXT, - period TEXT, - category_id INTEGER, - category_title TEXT, - parent_category_title TEXT, - val REAL, - reading_time INTEGER, - reading_count INTEGER + mode TEXT, -- 统计维度 + period TEXT, -- 周期标识 + category_id INTEGER, -- 分类 ID + category_title TEXT, -- 分类名称 (如 "文学") + parent_category_title TEXT, -- 父分类名称 (如 "精品小说") + val REAL, -- 偏好权重值 + reading_time INTEGER, -- 该分类阅读时长 (秒) + reading_count INTEGER -- 该分类阅读本数 ) """) - # 偏好作者 + # 偏好作者表 - 用户阅读最多的作者统计 c.execute(""" CREATE TABLE IF NOT EXISTS prefer_authors ( id INTEGER PRIMARY KEY AUTOINCREMENT, - mode TEXT, - period TEXT, - author_id INTEGER, - name TEXT, - count INTEGER, - read_time TEXT + mode TEXT, -- 统计维度 + period TEXT, -- 周期标识 + author_id INTEGER, -- 作者 ID + name TEXT, -- 作者名称 + count INTEGER, -- 阅读该作者的书本数 + read_time TEXT -- 阅读时长 (格式化字符串) ) """) - # 24小时阅读时段分布 + # 24小时阅读时段分布表 - 一天中每个小时的阅读时长 c.execute(""" CREATE TABLE IF NOT EXISTS prefer_time ( id INTEGER PRIMARY KEY AUTOINCREMENT, - mode TEXT, - period TEXT, - hour_index INTEGER, - duration INTEGER + mode TEXT, -- 统计维度 + period TEXT, -- 周期标识 + hour_index INTEGER, -- 小时 (0-23) + duration INTEGER -- 该小时阅读时长 (秒) ) """) - # 笔记本概览 + # 笔记本概览表 - 有笔记的书籍及其笔记统计 c.execute(""" CREATE TABLE IF NOT EXISTS notebooks ( - book_id TEXT PRIMARY KEY, - title TEXT, - author TEXT, - cover TEXT, - review_count INTEGER DEFAULT 0, - note_count INTEGER DEFAULT 0, - bookmark_count INTEGER DEFAULT 0, - reading_progress INTEGER DEFAULT 0, - marked_status INTEGER DEFAULT 0, - sort INTEGER + book_id TEXT PRIMARY KEY, -- 书籍 ID + title TEXT, -- 书名 + author TEXT, -- 作者 + cover TEXT, -- 封面图 URL + review_count INTEGER DEFAULT 0, -- 想法/点评数 + note_count INTEGER DEFAULT 0, -- 划线数 + bookmark_count INTEGER DEFAULT 0, -- 书签数 + reading_progress INTEGER DEFAULT 0, -- 阅读进度 (0-100) + marked_status INTEGER DEFAULT 0, -- 标记状态 (1=读完) + sort INTEGER -- 排序值 (微信读书返回的排序字段) ) """) - # 阅读统计摘要项 + # 阅读统计摘要项表 - App 首页展示的统计摘要 c.execute(""" CREATE TABLE IF NOT EXISTS read_stat_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, - mode TEXT, - period TEXT, - stat_name TEXT, - counts TEXT, - scheme TEXT + mode TEXT, -- 统计维度 + period TEXT, -- 周期标识 + stat_name TEXT, -- 统计项名称 (读过/读完/阅读/笔记) + counts TEXT, -- 统计值文案 (如 "404本") + scheme TEXT -- App 跳转链接 ) """) - # 书单/归档 + # 书单/归档表 - 用户创建的书单 c.execute(""" CREATE TABLE IF NOT EXISTS archives ( id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT, - book_ids TEXT + name TEXT, -- 书单名称 + book_ids TEXT -- 包含的书籍 ID 列表 (JSON 数组) ) """) - # 数据采集元信息 + # 数据采集元信息表 - 记录采集时间等元数据 c.execute(""" CREATE TABLE IF NOT EXISTS fetch_meta ( - key TEXT PRIMARY KEY, - value TEXT + key TEXT PRIMARY KEY, -- 元信息键名 (如 "last_fetch_time") + value TEXT -- 元信息值 ) """) - # ===== 按需获取的详细数据表 ===== + # ===== 按需数据表(用户点击时实时获取并写入) ===== - # 书籍详细信息(来自 /book/info) + # 书籍详细信息表 - 来自微信读书 /book/info 接口 c.execute(""" CREATE TABLE IF NOT EXISTS book_details ( - book_id TEXT PRIMARY KEY, - title TEXT, - author TEXT, - translator TEXT, - cover TEXT, - intro TEXT, - category TEXT, - publisher TEXT, - publish_time TEXT, - isbn TEXT, - word_count INTEGER, - new_rating REAL, - new_rating_count INTEGER, - fetched_at INTEGER + book_id TEXT PRIMARY KEY, -- 书籍 ID + title TEXT, -- 书名 + author TEXT, -- 作者 + translator TEXT, -- 译者 + cover TEXT, -- 封面图 URL + intro TEXT, -- 书籍简介 + category TEXT, -- 分类 + publisher TEXT, -- 出版社 + publish_time TEXT, -- 出版时间 + isbn TEXT, -- ISBN 号 + word_count INTEGER, -- 总字数 + new_rating REAL, -- 评分 (百分制) + new_rating_count INTEGER, -- 评分人数 + fetched_at INTEGER -- 数据获取时间 (Unix 时间戳) ) """) - # 阅读进度(来自 /book/getprogress) + # 阅读进度表 - 来自微信读书 /book/getprogress 接口 c.execute(""" CREATE TABLE IF NOT EXISTS book_progress ( - book_id TEXT PRIMARY KEY, - progress INTEGER, - chapter_uid INTEGER, - chapter_offset INTEGER, - update_time INTEGER, - record_reading_time INTEGER, - finish_time INTEGER, - is_start_reading INTEGER, - fetched_at INTEGER + book_id TEXT PRIMARY KEY, -- 书籍 ID + progress INTEGER, -- 阅读进度百分比 (0-100) + chapter_uid INTEGER, -- 当前阅读章节 UID + chapter_offset INTEGER, -- 当前章节内偏移量 + update_time INTEGER, -- 最后阅读时间 (Unix 时间戳) + record_reading_time INTEGER, -- 累计阅读时长 (秒) + finish_time INTEGER, -- 读完时间 (Unix 时间戳) + is_start_reading INTEGER, -- 是否已开始阅读 (1=是) + fetched_at INTEGER -- 数据获取时间 (Unix 时间戳) ) """) - # 划线内容(来自 /book/bookmarklist) + # 划线内容表 - 来自微信读书 /book/bookmarklist 接口 c.execute(""" CREATE TABLE IF NOT EXISTS book_highlights ( id INTEGER PRIMARY KEY AUTOINCREMENT, - book_id TEXT, - bookmark_id TEXT, - chapter_uid INTEGER, - mark_text TEXT, - create_time INTEGER, - type INTEGER, - range_text TEXT, - color_style INTEGER, - fetched_at INTEGER + book_id TEXT, -- 书籍 ID + bookmark_id TEXT, -- 划线唯一 ID + chapter_uid INTEGER, -- 所在章节 UID + mark_text TEXT, -- 划线原文内容 + create_time INTEGER, -- 创建时间 (Unix 时间戳) + type INTEGER, -- 划线类型 + range_text TEXT, -- 位置范围 + color_style INTEGER, -- 划线颜色样式 + fetched_at INTEGER -- 数据获取时间 (Unix 时间戳) ) """) - # 章节信息(来自 /book/bookmarklist 的 chapters) + # 章节信息表 - 来自微信读书 /book/bookmarklist 接口的 chapters 字段 c.execute(""" CREATE TABLE IF NOT EXISTS book_chapters ( id INTEGER PRIMARY KEY AUTOINCREMENT, - book_id TEXT, - chapter_uid INTEGER, - chapter_idx INTEGER, - title TEXT, - UNIQUE(book_id, chapter_uid) + book_id TEXT, -- 书籍 ID + chapter_uid INTEGER, -- 章节 UID + chapter_idx INTEGER, -- 章节序号 + title TEXT, -- 章节标题 + UNIQUE(book_id, chapter_uid) -- 联合唯一约束 ) """) - # 个人想法/点评(来自 /review/list/mine) + # 个人想法/点评表 - 来自微信读书 /review/list/mine 接口 c.execute(""" CREATE TABLE IF NOT EXISTS book_reviews ( id INTEGER PRIMARY KEY AUTOINCREMENT, - book_id TEXT, - review_id TEXT, - content TEXT, - create_time INTEGER, - star INTEGER, - chapter_name TEXT, - is_finish INTEGER, - fetched_at INTEGER + book_id TEXT, -- 书籍 ID + review_id TEXT, -- 想法唯一 ID + content TEXT, -- 想法内容文本 + create_time INTEGER, -- 创建时间 (Unix 时间戳) + star INTEGER, -- 评分 (0-5, -1=无评分) + chapter_name TEXT, -- 所在章节名称 + is_finish INTEGER, -- 是否书评 (1=书评) + fetched_at INTEGER -- 数据获取时间 (Unix 时间戳) ) """) - # 推荐书籍(来自 /book/recommend) + # 推荐书籍表 - 来自微信读书 /book/recommend 接口 c.execute(""" CREATE TABLE IF NOT EXISTS recommended_books ( id INTEGER PRIMARY KEY AUTOINCREMENT, - book_id TEXT, - title TEXT, - author TEXT, - cover TEXT, - intro TEXT, - category TEXT, - reason TEXT, - reading_count INTEGER, - new_rating REAL, - new_rating_count INTEGER, - rating_title TEXT, - fetched_at INTEGER + book_id TEXT, -- 书籍 ID + title TEXT, -- 书名 + author TEXT, -- 作者 + cover TEXT, -- 封面图 URL + intro TEXT, -- 书籍简介 + category TEXT, -- 分类 + reason TEXT, -- 推荐理由 + reading_count INTEGER, -- 在读人数 + new_rating REAL, -- 评分 (百分制) + new_rating_count INTEGER, -- 评分人数 + rating_title TEXT, -- 评分标签 (神作/力荐等) + fetched_at INTEGER -- 数据获取时间 (Unix 时间戳) ) """) - # 搜索结果(来自 /store/search) + # 搜索结果表 - 来自微信读书 /store/search 接口 c.execute(""" CREATE TABLE IF NOT EXISTS search_results ( id INTEGER PRIMARY KEY AUTOINCREMENT, - keyword TEXT, - book_id TEXT, - title TEXT, - author TEXT, - cover TEXT, - intro TEXT, - publisher TEXT, - category TEXT, - reading_count INTEGER, - new_rating REAL, - new_rating_count INTEGER, - rating_title TEXT, - search_idx INTEGER, - fetched_at INTEGER + keyword TEXT, -- 搜索关键词 + book_id TEXT, -- 书籍 ID + title TEXT, -- 书名 + author TEXT, -- 作者 + cover TEXT, -- 封面图 URL + intro TEXT, -- 书籍简介 + publisher TEXT, -- 出版社 + category TEXT, -- 分类 + reading_count INTEGER, -- 在读人数 + new_rating REAL, -- 评分 (百分制) + new_rating_count INTEGER, -- 评分人数 + rating_title TEXT, -- 评分标签 + search_idx INTEGER, -- 搜索序号 + fetched_at INTEGER -- 数据获取时间 (Unix 时间戳) ) """) diff --git a/static/css/style.css b/static/css/style.css index 5ed160a..7065980 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -8,6 +8,9 @@ --text-light: #9b8e80; --accent: #c17b4a; --accent-light: #e8a87c; + --alert-bg: #fff3cd; + --alert-border: #ffc107; + --alert-text: #856404; --accent-dark: #a05c2c; --border: #e8e0d4; --shadow: 0 2px 12px rgba(44, 36, 22, 0.08); @@ -657,6 +660,47 @@ body { opacity: 0.7; } +/* ===== 数据库连接告警横幅 ===== */ +.db-alert-banner { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 9999; + display: flex; + align-items: center; + gap: 10px; + padding: 12px 24px; + background: var(--alert-bg); + border-bottom: 2px solid var(--alert-border); + color: var(--alert-text); + font-size: 14px; + font-weight: 500; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + animation: slideDown 0.3s ease-out; +} + +.db-alert-icon { + font-size: 20px; +} + +.db-alert-text { + flex-shrink: 0; +} + +.db-alert-detail { + font-size: 12px; + opacity: 0.8; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@keyframes slideDown { + from { transform: translateY(-100%); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + /* ===== 加载动画 ===== */ .loading { display: flex; diff --git a/static/js/app.js b/static/js/app.js index 39b7592..059fefe 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -14,8 +14,52 @@ function formatHours(seconds) { return (seconds / 3600).toFixed(1); } +// ===== 数据库健康检查 ===== +/** + * 页面加载时检查 MySQL 数据库连接状态。 + * 如果连接失败,显示告警横幅并阻止后续数据加载。 + */ +async function checkDBHealth() { + try { + const resp = await fetch(API_BASE + "/api/health"); + const data = await resp.json(); + if (resp.ok && data.database === "connected") { + // 数据库连接正常,隐藏告警横幅 + document.getElementById("dbAlertBanner").style.display = "none"; + return true; + } else { + // 数据库连接异常,显示告警横幅 + showDBAlert(data.message || "数据库连接失败"); + return false; + } + } catch (error) { + // 健康检查接口本身无法访问 + showDBAlert("无法连接到服务器,请检查后端服务是否运行。"); + return false; + } +} + +/** + * 显示数据库连接告警横幅。 + * @param {string} message - 告警详情信息 + */ +function showDBAlert(message) { + const banner = document.getElementById("dbAlertBanner"); + const detail = document.getElementById("dbAlertDetail"); + if (banner) banner.style.display = "flex"; + if (detail) detail.textContent = message; +} + // ===== 初始化 ===== -document.addEventListener("DOMContentLoaded", () => { +document.addEventListener("DOMContentLoaded", async () => { + // 首先检查数据库连接状态 + const isHealthy = await checkDBHealth(); + if (!isHealthy) { + // 数据库连接失败,不加载后续数据 + return; + } + + // 数据库连接正常,加载所有数据模块 loadOverview(); loadYearlyTrend(); loadTimeDistribution(); diff --git a/templates/index.html b/templates/index.html index 2817d4d..802823f 100644 --- a/templates/index.html +++ b/templates/index.html @@ -8,6 +8,13 @@ + + +