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

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

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

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

600 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
微信读书数据采集脚本
从微信读书 API 拉取数据并存入本地 SQLite 数据库
"""
import json
import os
import sqlite3
import time
import urllib.request
API_URL = "https://i.weread.qq.com/api/agent/gateway"
API_KEY = os.environ.get("WEREAD_API_KEY", "")
SKILL_VERSION = "1.0.3"
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "weread.db")
PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
def api_call(api_name, **kwargs):
"""调用微信读书 API"""
body = {"api_name": api_name, "skill_version": SKILL_VERSION}
body.update(kwargs)
req = urllib.request.Request(
API_URL,
data=json.dumps(body).encode("utf-8"),
headers={
"Authorization": f"Bearer {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 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, -- 封面图 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, -- 统计维度: 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, -- 统计维度 (含 _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, -- 书籍 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, -- 分类 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, -- 作者 ID
name TEXT, -- 作者名称
count INTEGER, -- 阅读该作者的书本数
read_time TEXT -- 阅读时长 (格式化字符串)
)
""")
# 24小时阅读时段分布表 - 一天中每个小时的阅读时长
c.execute("""
CREATE TABLE IF NOT EXISTS prefer_time (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mode TEXT, -- 统计维度
period TEXT, -- 周期标识
hour_index INTEGER, -- 小时 (0-23)
duration INTEGER -- 该小时阅读时长 (秒)
)
""")
# 笔记本概览表 - 有笔记的书籍及其笔记统计
c.execute("""
CREATE TABLE IF NOT EXISTS notebooks (
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, -- 统计值文案 (如 "404本")
scheme TEXT -- App 跳转链接
)
""")
# 书单/归档表 - 用户创建的书单
c.execute("""
CREATE TABLE IF NOT EXISTS archives (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT, -- 书单名称
book_ids TEXT -- 包含的书籍 ID 列表 (JSON 数组)
)
""")
# 数据采集元信息表 - 记录采集时间等元数据
c.execute("""
CREATE TABLE IF NOT EXISTS fetch_meta (
key TEXT PRIMARY KEY, -- 元信息键名 (如 "last_fetch_time")
value TEXT -- 元信息值
)
""")
# ===== 按需数据表(用户点击时实时获取并写入) =====
# 书籍详细信息表 - 来自微信读书 /book/info 接口
c.execute("""
CREATE TABLE IF NOT EXISTS book_details (
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 接口
c.execute("""
CREATE TABLE IF NOT EXISTS book_progress (
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 接口
c.execute("""
CREATE TABLE IF NOT EXISTS book_highlights (
id INTEGER PRIMARY KEY AUTOINCREMENT,
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 字段
c.execute("""
CREATE TABLE IF NOT EXISTS book_chapters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
book_id TEXT, -- 书籍 ID
chapter_uid INTEGER, -- 章节 UID
chapter_idx INTEGER, -- 章节序号
title TEXT, -- 章节标题
UNIQUE(book_id, chapter_uid) -- 联合唯一约束
)
""")
# 个人想法/点评表 - 来自微信读书 /review/list/mine 接口
c.execute("""
CREATE TABLE IF NOT EXISTS book_reviews (
id INTEGER PRIMARY KEY AUTOINCREMENT,
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 接口
c.execute("""
CREATE TABLE IF NOT EXISTS recommended_books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
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 接口
c.execute("""
CREATE TABLE IF NOT EXISTS search_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
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 时间戳)
)
""")
conn.commit()
return conn
def fetch_shelf(conn):
"""拉取书架数据"""
print(" 拉取书架数据...")
data = api_call("/shelf/sync")
c = conn.cursor()
books = data.get("books", [])
for book in books:
c.execute(
"""INSERT OR REPLACE INTO books
(book_id, title, author, cover, category, finish_reading,
read_update_time, secret, is_top, update_time, deep_link)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
book.get("bookId"),
book.get("title", ""),
book.get("author", ""),
book.get("cover", ""),
book.get("category", ""),
book.get("finishReading", 0),
book.get("readUpdateTime", 0),
book.get("secret", 0),
book.get("isTop", 0),
book.get("updateTime", 0),
book.get("deepLink", ""),
),
)
# 保存书单
archives = data.get("archive", [])
c.execute("DELETE FROM archives")
for arch in archives:
c.execute(
"INSERT INTO archives (name, book_ids) VALUES (?, ?)",
(arch.get("name", ""), json.dumps(arch.get("bookIds", []))),
)
conn.commit()
mp_flag = "yes" if data.get("mp") else "no"
print(f" 书架: {len(books)} 本电子书, {len(data.get('albums', []))} 个专辑, MP={mp_flag}, {len(archives)} 个书单")
def fetch_readdata(conn, mode, period_label):
"""拉取阅读统计数据"""
print(f" 拉取阅读统计 [{mode}]...")
params = {"mode": mode}
if mode != "overall":
params["baseTime"] = 0
data = api_call("/readdata/detail", **params)
c = conn.cursor()
total_read_time = data.get("totalReadTime", 0)
read_days = data.get("readDays", 0)
day_avg = data.get("dayAverageReadTime", 0)
regist_time = data.get("registTime", 0)
c.execute(
"""INSERT INTO reading_stats
(mode, period, total_read_time, read_days, day_average_read_time,
regist_time, raw_json)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(
mode,
period_label,
total_read_time,
read_days,
day_avg,
regist_time,
json.dumps(data, ensure_ascii=False),
),
)
# 保存 readTimes 分桶数据
read_times = data.get("readTimes", {})
for ts, duration in read_times.items():
c.execute(
"INSERT INTO read_times (mode, period, bucket_timestamp, duration) VALUES (?, ?, ?, ?)",
(mode, period_label, int(ts), duration),
)
# 保存 dailyReadTimes(如果有)
daily_times = data.get("dailyReadTimes", {})
for ts, duration in daily_times.items():
c.execute(
"INSERT INTO read_times (mode, period, bucket_timestamp, duration) VALUES (?, ?, ?, ?)",
(mode + "_daily", period_label, int(ts), duration),
)
# 保存阅读最久的书籍
for item in data.get("readLongest", []):
book = item.get("book", {})
album = item.get("albumInfo", {})
c.execute(
"""INSERT INTO read_longest
(mode, period, book_id, title, author, cover, read_time, tags)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(
mode,
period_label,
book.get("bookId") or album.get("albumId", ""),
book.get("title") or album.get("name", ""),
book.get("author") or album.get("authorName", ""),
book.get("cover") or album.get("cover", ""),
item.get("readTime", 0),
json.dumps(item.get("tags", []), ensure_ascii=False),
),
)
# 保存偏好分类
for cat in data.get("preferCategory", []):
c.execute(
"""INSERT INTO prefer_categories
(mode, period, category_id, category_title, parent_category_title,
val, reading_time, reading_count)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(
mode,
period_label,
cat.get("categoryId", 0),
cat.get("categoryTitle", ""),
cat.get("parentCategoryTitle", ""),
cat.get("val", 0),
cat.get("readingTime", 0),
cat.get("readingCount", 0),
),
)
# 保存偏好作者
for author in data.get("preferAuthor", []):
c.execute(
"""INSERT INTO prefer_authors
(mode, period, author_id, name, count, read_time)
VALUES (?, ?, ?, ?, ?, ?)""",
(
mode,
period_label,
author.get("authorId", 0),
author.get("name", ""),
author.get("count", 0),
author.get("readTime", ""),
),
)
# 保存24小时时段分布
prefer_time = data.get("preferTime", [])
# preferTime 从6点开始到次日5点,映射到 hour_index
for i, duration in enumerate(prefer_time):
hour = (6 + i) % 24
c.execute(
"INSERT INTO prefer_time (mode, period, hour_index, duration) VALUES (?, ?, ?, ?)",
(mode, period_label, hour, duration),
)
# 保存统计摘要项
for stat in data.get("readStat", []):
c.execute(
"""INSERT INTO read_stat_items
(mode, period, stat_name, counts, scheme)
VALUES (?, ?, ?, ?, ?)""",
(
mode,
period_label,
stat.get("stat", ""),
stat.get("counts", ""),
stat.get("scheme", ""),
),
)
conn.commit()
print(f" [{mode}] 总时长: {total_read_time}秒 ({total_read_time//3600}小时), 阅读天数: {read_days}")
def fetch_notebooks(conn):
"""拉取笔记本概览(分页拉取全部)"""
print(" 拉取笔记本数据...")
c = conn.cursor()
c.execute("DELETE FROM notebooks")
count = 100
last_sort = None
total = 0
while True:
params = {"count": count}
if last_sort is not None:
params["lastSort"] = last_sort
data = api_call("/user/notebooks", **params)
books = data.get("books", [])
for item in books:
book = item.get("book", {})
c.execute(
"""INSERT OR REPLACE INTO notebooks
(book_id, title, author, cover, review_count, note_count,
bookmark_count, reading_progress, marked_status, sort)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
item.get("bookId", ""),
book.get("title", ""),
book.get("author", ""),
book.get("cover", ""),
item.get("reviewCount", 0),
item.get("noteCount", 0),
item.get("bookmarkCount", 0),
item.get("readingProgress", 0),
item.get("markedStatus", 0),
item.get("sort", 0),
),
)
total += 1
if data.get("hasMore", 0) == 1 and books:
last_sort = books[-1].get("sort")
else:
break
time.sleep(0.3)
conn.commit()
print(f" 笔记本: {total} 本有笔记的书")
def main():
if not API_KEY:
print("错误: 请先设置 WEREAD_API_KEY 环境变量")
print(" export WEREAD_API_KEY=wrk-xxxxxxxx")
return
print("=" * 50)
print("微信读书数据采集")
print("=" * 50)
# 初始化数据库
print(f"\n[1/4] 初始化数据库: {DB_PATH}")
conn = init_db(DB_PATH)
# 清空旧数据(保留表结构)
for table in [
"reading_stats", "read_times", "read_longest", "prefer_categories",
"prefer_authors", "prefer_time", "read_stat_items",
]:
conn.execute(f"DELETE FROM {table}")
conn.commit()
# 拉取书架
print("\n[2/4] 拉取书架数据")
fetch_shelf(conn)
# 拉取阅读统计(overall + 当前年)
print("\n[3/4] 拉取阅读统计数据")
fetch_readdata(conn, "overall", "overall")
current_year = time.localtime().tm_year
fetch_readdata(conn, "annually", str(current_year))
# 拉取笔记本
print("\n[4/4] 拉取笔记本数据")
fetch_notebooks(conn)
# 记录采集时间
c = conn.cursor()
c.execute(
"INSERT OR REPLACE INTO fetch_meta (key, value) VALUES (?, ?)",
("last_fetch_time", time.strftime("%Y-%m-%d %H:%M:%S")),
)
conn.commit()
# 统计
c.execute("SELECT COUNT(*) FROM books")
book_count = c.fetchone()[0]
c.execute("SELECT COUNT(*) FROM notebooks")
notebook_count = c.fetchone()[0]
c.execute("SELECT SUM(total_read_time) FROM reading_stats WHERE mode='overall'")
total_time = c.fetchone()[0] or 0
print("\n" + "=" * 50)
print("采集完成!")
print(f" 书架书籍: {book_count} 本")
print(f" 笔记本: {notebook_count} 本")
print(f" 总阅读时长: {total_time // 3600} 小时 {(total_time % 3600) // 60} 分钟")
print(f" 数据库: {DB_PATH}")
print("=" * 50)
conn.close()
if __name__ == "__main__":
main()