Files
WeReadBoard/fetch_data.py
T
admin 92a48d14cd feat: 添加配置读取工具,支持从环境变量和 .env 文件加载 WEREAD_API_KEY
fix: 修复启动脚本中的 Python 解释器路径识别
style: 更新样式以支持按钮最小宽度和灵活布局
docs: 添加项目最终文档,整合各类信息与说明
2026-07-17 22:11:27 +08:00

1108 lines
46 KiB
Python
Raw Permalink 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
"""
微信读书数据采集脚本(完整版 v2.0)
从微信读书 API 拉取数据并存入本地 SQLite 数据库。
覆盖全部 8 份官方 API 文档:
- shelf.md: 书架(电子书 + 有声书 + 书单)
- readdata.md: 阅读统计(时长、排行、偏好分类/作者/出版社/版权方等)
- notes.md: 笔记(笔记本概览、划线、个人想法)
- book.md: 书籍详情、章节目录、阅读进度
- discover.md: 推荐、相似书
- search.md: 搜索
- review.md: 公开点评
- profile.md: 用户概况(组合接口)
设计规范:
1. 所有整型时间戳字段伴随一个 _dt 文本列(ISO 格式时间)
2. 每张表维护 created_at 和 updated_at
3. 表结构与 MySQL 版本(schema_mysql.sql)完全对齐
"""
import json
import os
import sqlite3
import time
import urllib.request
from datetime import datetime, timezone, timedelta
from config_utils import get_env_value
# ===== 配置 =====
API_URL = "https://i.weread.qq.com/api/agent/gateway"
SKILL_VERSION = "1.0.4"
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "weread.db")
PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
# 中国时区 UTC+8
CST = timezone(timedelta(hours=8))
def ts_to_dt(ts):
"""将 Unix 整型时间戳转为 ISO 格式字符串 (中国时区)"""
if not ts or ts == 0:
return None
try:
return datetime.fromtimestamp(ts, tz=CST).strftime("%Y-%m-%d %H:%M:%S")
except (ValueError, OSError):
return None
def now_str():
"""当前时间字符串"""
return datetime.now(tz=CST).strftime("%Y-%m-%d %H:%M:%S")
def api_call(api_name, **kwargs):
"""调用微信读书 API"""
api_key = get_env_value("WEREAD_API_KEY", "")
if not api_key:
raise RuntimeError("WEREAD_API_KEY 未设置,请检查当前终端环境、项目 .env 或 ~/.zshrc")
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 数据库表结构(完整版 v2.0,31 张表)。
表结构与 MySQL 版本(schema_mysql.sql)完全对齐。
"""
conn = sqlite3.connect(db_path)
c = conn.cursor()
# ===== 第一组:书架数据 =====
# 1. 书架电子书表
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=读完)
read_update_time INTEGER, -- 最近阅读时间 (Unix 时间戳)
read_update_time_dt TEXT, -- 最近阅读时间 (ISO 格式)
secret INTEGER DEFAULT 0, -- 是否私密 (1=私密)
is_top INTEGER DEFAULT 0, -- 是否置顶 (1=置顶)
update_time INTEGER, -- 书籍更新时间 (Unix 时间戳)
update_time_dt TEXT, -- 书籍更新时间 (ISO 格式)
deep_link TEXT, -- 微信读书 App 深度链接
created_at TEXT DEFAULT (datetime('now', 'localtime')), -- 记录创建时间
updated_at TEXT DEFAULT (datetime('now', 'localtime')) -- 记录更新时间
)
""")
# 2. 书架有声书/专辑表
c.execute("""
CREATE TABLE IF NOT EXISTS shelf_albums (
album_id TEXT PRIMARY KEY, -- 专辑唯一标识
name TEXT, -- 专辑名称
author_name TEXT, -- 演播/作者
cover TEXT, -- 封面图 URL
track_count INTEGER DEFAULT 0, -- 音频集数
finish_status TEXT, -- 完结状态文案
finish INTEGER DEFAULT 0, -- 是否完结 (1=完结)
pay_type INTEGER, -- 付费类型
intro TEXT, -- 专辑简介
update_time INTEGER, -- 更新时间 (Unix 时间戳)
update_time_dt TEXT, -- 更新时间 (ISO 格式)
secret INTEGER DEFAULT 0, -- 是否私密
lecture_paid INTEGER DEFAULT 0, -- 是否已购买
lecture_read_update_time INTEGER, -- 最近收听时间 (Unix 时间戳)
lecture_read_update_time_dt TEXT, -- 最近收听时间 (ISO 格式)
is_top INTEGER DEFAULT 0, -- 是否置顶
created_at TEXT DEFAULT (datetime('now', 'localtime')),
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
)
""")
# 3. 书架元信息表
c.execute("""
CREATE TABLE IF NOT EXISTS shelf_meta (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mp_enabled INTEGER DEFAULT 0, -- 是否有文章收藏入口
book_count INTEGER DEFAULT 0, -- 可枚举电子书数量
album_count INTEGER DEFAULT 0, -- 有声书数量
total_count INTEGER DEFAULT 0, -- 书架总条目数
raw_json TEXT, -- 原始 API 返回 JSON
created_at TEXT DEFAULT (datetime('now', 'localtime')),
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
)
""")
# 4. 书单/归档表
c.execute("""
CREATE TABLE IF NOT EXISTS archives (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT, -- 书单名称
book_ids TEXT, -- bookId 列表 (JSON 数组)
created_at TEXT DEFAULT (datetime('now', 'localtime')),
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
)
""")
# ===== 第二组:阅读统计数据 =====
# 5. 阅读统计概要表
c.execute("""
CREATE TABLE IF NOT EXISTS reading_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mode TEXT, -- 统计维度: overall/annually/monthly/weekly
period TEXT, -- 周期标识
base_time INTEGER, -- 统计周期基准时间戳
base_time_dt TEXT, -- 基准时间 (ISO 格式)
total_read_time INTEGER DEFAULT 0, -- 总阅读时长 (秒)
read_days INTEGER DEFAULT 0, -- 有效阅读天数
day_average_read_time REAL DEFAULT 0, -- 日均阅读时长 (秒)
compare REAL, -- 与上周期对比比例
regist_time INTEGER, -- 用户注册时间 (Unix 时间戳)
regist_time_dt TEXT, -- 注册时间 (ISO 格式)
record_reading_time INTEGER DEFAULT 0, -- 朗读/记录类阅读时长 (秒)
read_rate REAL, -- 文字阅读占比百分比
wr_read_time INTEGER, -- 文字阅读时长 (秒)
wr_listen_time INTEGER, -- 听书时长 (秒)
author_count INTEGER, -- 作者总数
prefer_category_word TEXT, -- 偏好分类文案
prefer_time_word TEXT, -- 偏好时段文案
read_records_word TEXT, -- 书籍分布模块标题
read_distribution_word TEXT, -- 点评分布模块标题
read_time_gears TEXT, -- 阅读时长档位 (JSON)
style_type TEXT, -- 样式类型
raw_json TEXT, -- 原始 API 返回 JSON
created_at TEXT DEFAULT (datetime('now', 'localtime')),
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
)
""")
# 6. 阅读时长时间序列表
c.execute("""
CREATE TABLE IF NOT EXISTS read_times (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mode TEXT, -- 统计维度 (含 _daily 后缀)
period TEXT, -- 周期标识
bucket_timestamp INTEGER, -- 分桶起始时间戳
bucket_timestamp_dt TEXT, -- 分桶起始时间 (ISO 格式)
duration INTEGER DEFAULT 0, -- 阅读时长 (秒)
created_at TEXT DEFAULT (datetime('now', 'localtime')),
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
)
""")
# 7. 阅读时长排行表
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 DEFAULT 0, -- 阅读时长 (秒)
record_reading_time INTEGER, -- 朗读/记录类阅读时长 (秒)
tags TEXT, -- 标签 (JSON 数组)
created_at TEXT DEFAULT (datetime('now', 'localtime')),
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
)
""")
# 8. 阅读统计摘要项表
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, -- App 跳转链接
created_at TEXT DEFAULT (datetime('now', 'localtime')),
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
)
""")
# 9. 偏好分类表
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_id INTEGER, -- 父分类 ID
parent_category_title TEXT, -- 父分类名称
category_type INTEGER DEFAULT 0, -- 分类类型标记
val REAL DEFAULT 0, -- 偏好权重
reading_time INTEGER DEFAULT 0, -- 阅读时长 (秒)
reading_count INTEGER DEFAULT 0, -- 阅读本数
created_at TEXT DEFAULT (datetime('now', 'localtime')),
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
)
""")
# 10. 偏好作者表
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 DEFAULT 0, -- 阅读书本数
read_time TEXT, -- 阅读时长 (格式化字符串)
user_json TEXT, -- 作者关联用户信息 (JSON)
created_at TEXT DEFAULT (datetime('now', 'localtime')),
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
)
""")
# 11. 偏好出版社表
c.execute("""
CREATE TABLE IF NOT EXISTS prefer_publishers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mode TEXT, -- 统计维度
period TEXT, -- 周期标识
name TEXT, -- 出版社名
count INTEGER DEFAULT 0, -- 阅读本数
created_at TEXT DEFAULT (datetime('now', 'localtime')),
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
)
""")
# 12. 偏好版权方表
c.execute("""
CREATE TABLE IF NOT EXISTS prefer_cps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mode TEXT, -- 统计维度
period TEXT, -- 周期标识
count INTEGER DEFAULT 0, -- 阅读本数
cp_name TEXT, -- 版权方名称
cp_user_vid TEXT, -- 版权方用户 VID
cp_avatar TEXT, -- 版权方头像 URL
cp_role TEXT, -- 版权方角色
copyright_json TEXT, -- 版权方完整信息 (JSON)
created_at TEXT DEFAULT (datetime('now', 'localtime')),
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
)
""")
# 13. 偏好书籍卡片表
c.execute("""
CREATE TABLE IF NOT EXISTS prefer_books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mode TEXT, -- 统计维度
period TEXT, -- 周期标识
book_id TEXT, -- 书籍 ID
title TEXT, -- 书名
author TEXT, -- 作者
cover TEXT, -- 封面图 URL
reason TEXT, -- 推荐理由
prefer_type TEXT, -- 偏好类型
book_json TEXT, -- 书籍完整信息 (JSON)
created_at TEXT DEFAULT (datetime('now', 'localtime')),
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
)
""")
# 14. 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 DEFAULT 0, -- 阅读时长 (秒)
created_at TEXT DEFAULT (datetime('now', 'localtime')),
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
)
""")
# 15. 好友阅读排行表
c.execute("""
CREATE TABLE IF NOT EXISTS read_rank (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mode TEXT, -- 统计维度
period TEXT, -- 周期标识
rank_text TEXT, -- 排行文案
scheme TEXT, -- 排行跳转链接
created_at TEXT DEFAULT (datetime('now', 'localtime')),
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
)
""")
# 16. 勋章表
c.execute("""
CREATE TABLE IF NOT EXISTS medals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mode TEXT, -- 统计维度
period TEXT, -- 周期标识
medal_json TEXT, -- 勋章信息 (JSON)
created_at TEXT DEFAULT (datetime('now', 'localtime')),
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
)
""")
# 17. 年度报告表
c.execute("""
CREATE TABLE IF NOT EXISTS year_reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mode TEXT, -- 统计维度
period TEXT, -- 周期标识
report_year TEXT, -- 报告年份
report_json TEXT, -- 报告入口信息 (JSON)
monthly_times TEXT, -- 12个月阅读时长 (JSON 数组)
created_at TEXT DEFAULT (datetime('now', 'localtime')),
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
)
""")
# ===== 第三组:笔记数据 =====
# 18. 笔记本概览表
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, -- 书签数
total_note_count INTEGER DEFAULT 0, -- 笔记总数
reading_progress INTEGER DEFAULT 0, -- 阅读进度 (0-100)
marked_status INTEGER DEFAULT 0, -- 标记状态 (1=读完)
sort INTEGER, -- 排序值
created_at TEXT DEFAULT (datetime('now', 'localtime')),
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
)
""")
# 19. 划线内容表
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 时间戳)
create_time_dt TEXT, -- 创建时间 (ISO 格式)
type INTEGER, -- 类型 (1=划线)
range_text TEXT, -- 位置范围
color_style INTEGER, -- 划线颜色样式
created_at TEXT DEFAULT (datetime('now', 'localtime')),
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
)
""")
# 20. 章节信息表
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, -- 章节标题
word_count INTEGER, -- 章节字数
level INTEGER, -- 目录层级 (1=一级)
update_time INTEGER, -- 章节更新时间 (Unix 时间戳)
update_time_dt TEXT, -- 章节更新时间 (ISO 格式)
price INTEGER DEFAULT 0, -- 章节价格 (0=免费)
paid INTEGER DEFAULT 0, -- 是否已购买
is_mp_chapter INTEGER DEFAULT 0, -- 是否公众号章节
anchors TEXT, -- 章节内锚点 (JSON)
synckey INTEGER, -- 同步 key
chapter_update_time INTEGER, -- 章节列表最后更新时间
created_at TEXT DEFAULT (datetime('now', 'localtime')),
updated_at TEXT DEFAULT (datetime('now', 'localtime')),
UNIQUE(book_id, chapter_uid)
)
""")
# 21. 个人想法/点评表
c.execute("""
CREATE TABLE IF NOT EXISTS book_reviews (
id INTEGER PRIMARY KEY AUTOINCREMENT,
book_id TEXT, -- 书籍 ID
review_id TEXT, -- 想法唯一 ID
content TEXT, -- 想法内容文本
abstract TEXT, -- 想法对应的划线原文
range_text TEXT, -- 划线原文位置范围
chapter_uid INTEGER, -- 章节 UID
chapter_idx INTEGER, -- 章节序号
chapter_name TEXT, -- 所在章节名
create_time INTEGER, -- 创建时间 (Unix 时间戳)
create_time_dt TEXT, -- 创建时间 (ISO 格式)
star INTEGER DEFAULT -1, -- 评分 (0-5, -1=无评分)
is_finish INTEGER DEFAULT 0, -- 是否书评
created_at TEXT DEFAULT (datetime('now', 'localtime')),
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
)
""")
# 22. 热门划线表
c.execute("""
CREATE TABLE IF NOT EXISTS book_best_bookmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
book_id TEXT, -- 书籍 ID
bookmark_id TEXT, -- 划线唯一 ID
chapter_uid INTEGER, -- 所在章节 UID
range_text TEXT, -- 划线位置范围
mark_text TEXT, -- 划线原文文本
total_count INTEGER DEFAULT 0, -- 划线人数
user_vid TEXT, -- 代表用户 VID
simplified_range TEXT, -- 简体 range
traditional_range TEXT, -- 繁体 range
synckey INTEGER, -- 同步 key
created_at TEXT DEFAULT (datetime('now', 'localtime')),
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
)
""")
# 23. 划线热度统计表
c.execute("""
CREATE TABLE IF NOT EXISTS book_underlines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
book_id TEXT, -- 书籍 ID
chapter_uid INTEGER, -- 章节 UID
range_text TEXT, -- 划线位置范围
count INTEGER DEFAULT 0, -- 划线人数
score REAL DEFAULT 0, -- 热度分数
type INTEGER, -- 划线类型
synckey INTEGER, -- 同步 key
created_at TEXT DEFAULT (datetime('now', 'localtime')),
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
)
""")
# 24. 公开点评表
c.execute("""
CREATE TABLE IF NOT EXISTS public_reviews (
id INTEGER PRIMARY KEY AUTOINCREMENT,
book_id TEXT, -- 书籍 ID
review_id TEXT, -- 点评唯一 ID
content TEXT, -- 点评文本内容
html_content TEXT, -- 点评 HTML 富文本
star INTEGER DEFAULT -1, -- 评分 (20-100)
is_finish INTEGER DEFAULT 0, -- 是否读完
create_time INTEGER, -- 创建时间 (Unix 时间戳)
create_time_dt TEXT, -- 创建时间 (ISO 格式)
chapter_name TEXT, -- 所在章节名
author_vid TEXT, -- 评论者 VID
author_name TEXT, -- 评论者昵称
author_avatar TEXT, -- 评论者头像 URL
review_list_type INTEGER DEFAULT 0, -- 筛选类型
idx INTEGER, -- 序号 (用于翻页)
created_at TEXT DEFAULT (datetime('now', 'localtime')),
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
)
""")
# ===== 第四组:书籍详情 =====
# 25. 书籍详细信息表
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 DEFAULT 0, -- 总字数
new_rating REAL DEFAULT 0, -- 评分 (百分制)
new_rating_count INTEGER DEFAULT 0, -- 评分人数
new_rating_detail TEXT, -- 评分分布详情 (JSON)
deep_link TEXT, -- 跳转链接
created_at TEXT DEFAULT (datetime('now', 'localtime')),
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
)
""")
# 26. 阅读进度表
c.execute("""
CREATE TABLE IF NOT EXISTS book_progress (
book_id TEXT PRIMARY KEY, -- 书籍 ID
progress INTEGER DEFAULT 0, -- 阅读进度百分比 (0-100)
chapter_uid INTEGER, -- 当前阅读章节 UID
chapter_offset INTEGER, -- 当前章节内偏移量
update_time INTEGER, -- 最后阅读时间 (Unix 时间戳)
update_time_dt TEXT, -- 最后阅读时间 (ISO 格式)
record_reading_time INTEGER DEFAULT 0, -- 累计阅读时长 (秒)
finish_time INTEGER, -- 读完时间 (Unix 时间戳)
finish_time_dt TEXT, -- 读完时间 (ISO 格式)
is_start_reading INTEGER DEFAULT 0, -- 是否已开始阅读
server_timestamp INTEGER, -- 服务端时间戳
server_timestamp_dt TEXT, -- 服务端时间 (ISO 格式)
created_at TEXT DEFAULT (datetime('now', 'localtime')),
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
)
""")
# ===== 第五组:发现/推荐 =====
# 27. 推荐书籍表
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 DEFAULT 0, -- 在读人数
new_rating REAL DEFAULT 0, -- 评分 (百分制)
new_rating_count INTEGER DEFAULT 0, -- 评分人数
rating_title TEXT, -- 评分标签
deep_link TEXT, -- 跳转链接
search_idx INTEGER DEFAULT 0, -- 结果序号
price INTEGER, -- 价格 (分)
pay_type INTEGER, -- 付费类型
book_type INTEGER DEFAULT 0, -- 书籍类型 (0=电子书)
batch_id TEXT, -- 推荐批次 ID
created_at TEXT DEFAULT (datetime('now', 'localtime')),
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
)
""")
# 28. 相似书推荐表
c.execute("""
CREATE TABLE IF NOT EXISTS similar_books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_book_id TEXT, -- 源书籍 ID
book_id TEXT, -- 推荐书籍 ID
title TEXT, -- 书名
author TEXT, -- 作者
cover TEXT, -- 封面图 URL
deep_link TEXT, -- 跳转链接
idx INTEGER, -- 结果序号
session_id TEXT, -- 翻页会话 ID
book_json TEXT, -- 书籍完整信息 (JSON)
created_at TEXT DEFAULT (datetime('now', 'localtime')),
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
)
""")
# 29. 搜索会话表
c.execute("""
CREATE TABLE IF NOT EXISTS search_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sid TEXT, -- 搜索会话 ID
keyword TEXT, -- 搜索关键词
scope INTEGER, -- 搜索类型
has_more INTEGER DEFAULT 0, -- 是否有更多
max_idx INTEGER DEFAULT 0, -- 当前翻页偏移
created_at TEXT DEFAULT (datetime('now', 'localtime')),
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
)
""")
# 30. 搜索结果表
c.execute("""
CREATE TABLE IF NOT EXISTS search_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER, -- 关联搜索会话 ID
keyword TEXT, -- 搜索关键词
book_id TEXT, -- 书籍 ID
title TEXT, -- 书名
author TEXT, -- 作者
cover TEXT, -- 封面图 URL
intro TEXT, -- 书籍简介
publisher TEXT, -- 出版社
category TEXT, -- 分类
reading_count INTEGER DEFAULT 0, -- 在读人数
new_rating REAL DEFAULT 0, -- 评分
new_rating_count INTEGER DEFAULT 0, -- 评分人数
rating_title TEXT, -- 评分标签
deep_link TEXT, -- 跳转链接
pay_type INTEGER, -- 付费类型
price INTEGER, -- 价格 (分)
soldout INTEGER DEFAULT 0, -- 是否下架
search_idx INTEGER DEFAULT 0, -- 搜索序号
result_scope INTEGER, -- 回包分组类型
result_title TEXT, -- 回包分组标题
scope_count INTEGER, -- 分组总结果数
current_count INTEGER, -- 本次返回数量
created_at TEXT DEFAULT (datetime('now', 'localtime')),
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
)
""")
# ===== 第六组:系统表 =====
# 31. 数据采集元信息表
c.execute("""
CREATE TABLE IF NOT EXISTS fetch_meta (
key TEXT PRIMARY KEY, -- 元信息键名
value TEXT, -- 元信息值
created_at TEXT DEFAULT (datetime('now', 'localtime')),
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
)
""")
conn.commit()
return conn
# ===== 数据采集函数 =====
def fetch_shelf(conn):
"""拉取书架数据(电子书 + 有声书 + 书单)"""
print(" 拉取书架数据...")
data = api_call("/shelf/sync")
c = conn.cursor()
# 清空旧数据
for t in ["books", "shelf_albums", "shelf_meta", "archives"]:
c.execute(f"DELETE FROM {t}")
# 1. 电子书
books = data.get("books", [])
for book in books:
rut = book.get("readUpdateTime", 0)
ut = book.get("updateTime", 0)
c.execute(
"""INSERT OR REPLACE INTO books
(book_id, title, author, cover, category, finish_reading,
read_update_time, read_update_time_dt, secret, is_top,
update_time, update_time_dt, deep_link)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
book.get("bookId"),
book.get("title", ""),
book.get("author", ""),
book.get("cover", ""),
book.get("category", ""),
book.get("finishReading", 0),
rut, ts_to_dt(rut),
book.get("secret", 0),
book.get("isTop", 0),
ut, ts_to_dt(ut),
book.get("deepLink", ""),
),
)
# 2. 有声书/专辑
albums = data.get("albums", [])
for album in albums:
info = album.get("albumInfo", {})
extra = album.get("albumInfoExtra", {})
ut = info.get("updateTime", 0)
lrut = extra.get("lectureReadUpdateTime", 0)
c.execute(
"""INSERT OR REPLACE INTO shelf_albums
(album_id, name, author_name, cover, track_count, finish_status,
finish, pay_type, intro, update_time, update_time_dt,
secret, lecture_paid, lecture_read_update_time, lecture_read_update_time_dt, is_top)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
info.get("albumId"),
info.get("name", ""),
info.get("authorName", ""),
info.get("cover", ""),
info.get("trackCount", 0),
info.get("finishStatus", ""),
info.get("finish", 0),
info.get("payType"),
info.get("intro", ""),
ut, ts_to_dt(ut),
extra.get("secret", 0),
extra.get("lecturePaid", 0),
lrut, ts_to_dt(lrut),
extra.get("isTop", 0),
),
)
# 3. 书架元信息
mp_flag = 1 if data.get("mp") else 0
book_count = data.get("bookCount", len(books))
album_count = len(albums)
total = book_count + album_count + mp_flag
c.execute(
"""INSERT INTO shelf_meta (mp_enabled, book_count, album_count, total_count, raw_json)
VALUES (?, ?, ?, ?, ?)""",
(mp_flag, book_count, album_count, total,
json.dumps(data, ensure_ascii=False)),
)
# 4. 书单/归档
archives = data.get("archive", [])
for arch in archives:
c.execute(
"INSERT INTO archives (name, book_ids) VALUES (?, ?)",
(arch.get("name", ""), json.dumps(arch.get("bookIds", []))),
)
conn.commit()
print(f" 书架: {len(books)} 本电子书, {len(albums)} 个有声书, "
f"MP={'有' if mp_flag else '无'}, {len(archives)} 个书单, 总计 {total} 条目")
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()
# 5. 阅读统计概要
base_time = data.get("baseTime")
regist_time = data.get("registTime")
gears = data.get("readTimeGears")
c.execute(
"""INSERT INTO reading_stats
(mode, period, base_time, base_time_dt, total_read_time, read_days,
day_average_read_time, compare, regist_time, regist_time_dt,
record_reading_time, read_rate, wr_read_time, wr_listen_time,
author_count, prefer_category_word, prefer_time_word,
read_records_word, read_distribution_word, read_time_gears, style_type, raw_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
mode, period_label,
base_time, ts_to_dt(base_time),
data.get("totalReadTime", 0),
data.get("readDays", 0),
data.get("dayAverageReadTime", 0),
data.get("compare"),
regist_time, ts_to_dt(regist_time),
data.get("recordReadingTime", 0),
data.get("readRate"),
data.get("wrReadTime"),
data.get("wrListenTime"),
data.get("authorCount"),
data.get("preferCategoryWord"),
data.get("preferTimeWord"),
data.get("readRecordsWord"),
data.get("readDistributionWord"),
json.dumps(gears) if gears else None,
data.get("styleType"),
json.dumps(data, ensure_ascii=False),
),
)
# 6. 阅读时长时间序列
read_times = data.get("readTimes", {})
for ts, duration in read_times.items():
c.execute(
"INSERT INTO read_times (mode, period, bucket_timestamp, bucket_timestamp_dt, duration) VALUES (?, ?, ?, ?, ?)",
(mode, period_label, int(ts), ts_to_dt(int(ts)), duration),
)
daily_times = data.get("dailyReadTimes", {})
for ts, duration in daily_times.items():
c.execute(
"INSERT INTO read_times (mode, period, bucket_timestamp, bucket_timestamp_dt, duration) VALUES (?, ?, ?, ?, ?)",
(mode + "_daily", period_label, int(ts), ts_to_dt(int(ts)), duration),
)
# 7. 阅读时长排行
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, record_reading_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),
item.get("recordReadingTime"),
json.dumps(item.get("tags", []), ensure_ascii=False),
),
)
# 8. 统计摘要项
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", "")),
)
# 9. 偏好分类
for cat in data.get("preferCategory", []):
c.execute(
"""INSERT INTO prefer_categories
(mode, period, category_id, category_title, parent_category_id,
parent_category_title, category_type, val, reading_time, reading_count)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
mode, period_label,
cat.get("categoryId", 0),
cat.get("categoryTitle", ""),
cat.get("parentCategoryId"),
cat.get("parentCategoryTitle", ""),
cat.get("categoryType", 0),
cat.get("val", 0),
cat.get("readingTime", 0),
cat.get("readingCount", 0),
),
)
# 10. 偏好作者
for author in data.get("preferAuthor", []):
user = author.get("user")
c.execute(
"""INSERT INTO prefer_authors
(mode, period, author_id, name, count, read_time, user_json)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(
mode, period_label,
author.get("authorId", 0),
author.get("name", ""),
author.get("count", 0),
author.get("readTime", ""),
json.dumps(user, ensure_ascii=False) if user else None,
),
)
# 11. 偏好出版社
for pub in data.get("preferPublisher", []):
c.execute(
"INSERT INTO prefer_publishers (mode, period, name, count) VALUES (?, ?, ?, ?)",
(mode, period_label, pub.get("name", ""), pub.get("count", 0)),
)
# 12. 偏好版权方
for cp in data.get("preferCp", []):
ci = cp.get("copyrightInfo", {})
c.execute(
"""INSERT INTO prefer_cps
(mode, period, count, cp_name, cp_user_vid, cp_avatar, cp_role, copyright_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(
mode, period_label,
cp.get("count", 0),
ci.get("name", ""),
ci.get("userVid", ""),
ci.get("avatar", ""),
ci.get("role", ""),
json.dumps(ci, ensure_ascii=False) if ci else None,
),
)
# 13. 偏好书籍卡片
for pb in data.get("preferBooks", []):
book = pb.get("book", {})
c.execute(
"""INSERT INTO prefer_books
(mode, period, book_id, title, author, cover, reason, prefer_type, book_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
mode, period_label,
book.get("bookId"),
book.get("title", ""),
book.get("author", ""),
book.get("cover", ""),
pb.get("reason", ""),
pb.get("preferType", ""),
json.dumps(pb, ensure_ascii=False),
),
)
# 14. 24小时时段分布(从6点开始到次日5点)
for i, duration in enumerate(data.get("preferTime", [])):
hour = (6 + i) % 24
c.execute(
"INSERT INTO prefer_time (mode, period, hour_index, duration) VALUES (?, ?, ?, ?)",
(mode, period_label, hour, duration),
)
# 15. 好友排行
rank = data.get("rank")
if rank:
c.execute(
"INSERT INTO read_rank (mode, period, rank_text, scheme) VALUES (?, ?, ?, ?)",
(mode, period_label, rank.get("text", ""), rank.get("scheme", "")),
)
# 16. 勋章
for medal in data.get("medals", []):
c.execute(
"INSERT INTO medals (mode, period, medal_json) VALUES (?, ?, ?)",
(mode, period_label, json.dumps(medal, ensure_ascii=False)),
)
# 17. 年度报告
for yr in data.get("yearReport", []):
times = yr.get("times")
c.execute(
"""INSERT INTO year_reports
(mode, period, report_year, report_json, monthly_times)
VALUES (?, ?, ?, ?, ?)""",
(
mode, period_label,
str(yr.get("year", "")),
json.dumps(yr, ensure_ascii=False),
json.dumps(times) if times else None,
),
)
conn.commit()
total = data.get("totalReadTime", 0)
print(f" [{mode}] 总时长: {total}秒 ({total // 3600}小时), "
f"阅读天数: {data.get('readDays', 0)}, "
f"排行: {len(data.get('readLongest', []))} 本, "
f"偏好分类: {len(data.get('preferCategory', []))} 个, "
f"偏好作者: {len(data.get('preferAuthor', []))} 个, "
f"偏好出版社: {len(data.get('preferPublisher', []))} 个")
def fetch_notebooks(conn):
"""拉取笔记本概览(分页拉取全部,含 total_note_count)"""
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", {})
rc = item.get("reviewCount", 0)
nc = item.get("noteCount", 0)
bc = item.get("bookmarkCount", 0)
c.execute(
"""INSERT OR REPLACE INTO notebooks
(book_id, title, author, cover, review_count, note_count,
bookmark_count, total_note_count, reading_progress, marked_status, sort)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
item.get("bookId", ""),
book.get("title", ""),
book.get("author", ""),
book.get("cover", ""),
rc, nc, bc, rc + nc + bc,
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 get_env_value("WEREAD_API_KEY", ""):
print("错误: 请先设置 WEREAD_API_KEY 环境变量")
print(" 可配置在当前终端、项目 .env,或 ~/.zshrc")
return
print("=" * 60)
print("微信读书数据采集 (完整版 v2.0)")
print("=" * 60)
# 初始化数据库
print(f"\n[1/4] 初始化数据库: {DB_PATH}")
conn = init_db(DB_PATH)
# 清空旧的统计数据(每次采集全量覆盖)
for table in [
"reading_stats", "read_times", "read_longest", "read_stat_items",
"prefer_categories", "prefer_authors", "prefer_publishers",
"prefer_cps", "prefer_books", "prefer_time", "read_rank",
"medals", "year_reports",
]:
conn.execute(f"DELETE FROM {table}")
conn.commit()
# 拉取书架
print("\n[2/4] 拉取书架数据")
fetch_shelf(conn)
# 拉取阅读统计
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", now_str()),
)
c.execute(
"INSERT OR REPLACE INTO fetch_meta (key, value) VALUES (?, ?)",
("schema_version", "2.0"),
)
conn.commit()
# 统计
c.execute("SELECT COUNT(*) FROM books")
book_count = c.fetchone()[0]
c.execute("SELECT COUNT(*) FROM shelf_albums")
album_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
c.execute("SELECT COUNT(*) FROM prefer_publishers")
pub_count = c.fetchone()[0]
c.execute("SELECT COUNT(*) FROM prefer_cps")
cp_count = c.fetchone()[0]
print("\n" + "=" * 60)
print("采集完成!")
print(f" 书架电子书: {book_count} 本")
print(f" 有声书/专辑: {album_count} 个")
print(f" 笔记本: {notebook_count} 本")
print(f" 总阅读时长: {total_time // 3600} 小时 {(total_time % 3600) // 60} 分钟")
print(f" 偏好出版社: {pub_count} 个")
print(f" 偏好版权方: {cp_count} 个")
print(f" 数据库: {DB_PATH}")
print(f" 表结构版本: 2.0 (31 张表)")
print("=" * 60)
conn.close()
if __name__ == "__main__":
main()