From 1e9c537b863ed27e14ab7a93623212ca87c48e57 Mon Sep 17 00:00:00 2001 From: kinghwj Date: Fri, 17 Jul 2026 09:59:37 +0800 Subject: [PATCH] feat: init weread data dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 数据采集脚本 fetch_data.py:从微信读书 API 拉取书架、阅读统计、笔记数据存入 SQLite - Flask 后端 app.py:提供 8 个 API 接口(总览、年度趋势、读书排行、偏好分类、偏好作者、时段分布、书架、笔记) - 前端页面:ECharts 图表(年度趋势、时段分布、分类饼图、作者排行)、书架网格、笔记概览 - 数据规模:404 本书架、339 小时阅读、304 天阅读、1709 条笔记 --- .gitignore | 30 ++ .workbuddy/memory/2026-07-17.md | 9 + README.md | 80 ++++ app.py | 375 +++++++++++++++++ fetch_data.py | 472 +++++++++++++++++++++ requirements.txt | 1 + static/css/style.css | 708 ++++++++++++++++++++++++++++++++ static/js/app.js | 556 +++++++++++++++++++++++++ templates/index.html | 130 ++++++ 9 files changed, 2361 insertions(+) create mode 100644 .gitignore create mode 100644 .workbuddy/memory/2026-07-17.md create mode 100644 README.md create mode 100644 app.py create mode 100644 fetch_data.py create mode 100644 requirements.txt create mode 100644 static/css/style.css create mode 100644 static/js/app.js create mode 100644 templates/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..58b633a --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +env/ +.venv + +# Database +*.db +*.db-journal + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Env +.env diff --git a/.workbuddy/memory/2026-07-17.md b/.workbuddy/memory/2026-07-17.md new file mode 100644 index 0000000..4d7697c --- /dev/null +++ b/.workbuddy/memory/2026-07-17.md @@ -0,0 +1,9 @@ +# 2026-07-17 工作日志 + +## 微信读书数据展示网站 +- 使用微信读书 API(weread-skills)拉取用户的阅读数据 +- 数据采集脚本 `fetch_data.py`:拉取书架(404本)、阅读统计(overall+annually)、笔记本(32本1709条笔记)数据,存入本地 SQLite 数据库 `weread.db` +- Flask 后端 `app.py`(端口 8080),提供 8 个 API 接口:overview, yearly-trend, top-books, categories, authors, time-distribution, shelf, notebooks +- 前端页面:ECharts 图表(年度趋势柱状图、24h时段折线图、分类饼图、作者排行条形图)、书架网格(分页+筛选)、笔记概览 +- 数据规模:累计阅读 339 小时 57 分钟、304 天阅读、404 本书架藏书、1709 条笔记 +- 技术栈:Python Flask + SQLite + ECharts + 原生 HTML/CSS/JS diff --git a/README.md b/README.md new file mode 100644 index 0000000..ce09b81 --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +# 微信读书数据展示网站 + +基于微信读书 API 的个人读书数据展示网站,数据存储在本地 SQLite 数据库中。 + +## 项目结构 + +``` +. +├── fetch_data.py # 数据采集脚本(从微信读书 API 拉取数据到 SQLite) +├── app.py # Flask 后端服务 +├── weread.db # SQLite 数据库(运行 fetch_data.py 后生成) +├── requirements.txt # Python 依赖 +├── templates/ +│ └── index.html # 前端页面 +└── static/ + ├── css/style.css # 样式文件 + └── js/app.js # 前端逻辑 +``` + +## 快速开始 + +### 1. 设置环境变量 + +```bash +export WEREAD_API_KEY=wrk-xxxxxxxx # 你的微信读书 API Key +``` + +### 2. 安装依赖 + +```bash +pip install -r requirements.txt +``` + +### 3. 采集数据 + +```bash +python fetch_data.py +``` + +将拉取书架、阅读统计、笔记等数据并存入 `weread.db`。 + +### 4. 启动网站 + +```bash +python app.py +``` + +访问 http://127.0.0.1:8080 即可查看读书数据展示。 + +## 功能模块 + +| 模块 | 说明 | +|------|------| +| 总览仪表盘 | 累计阅读时长、阅读天数、书架藏书数、笔记总数 | +| 年度趋势图 | 各年度阅读时长柱状图 | +| 时段分布 | 24 小时阅读时段分布折线图 | +| 偏好分类 | 阅读分类占比饼图 | +| 偏好作者 | 阅读最多的作者排行 | +| 读书排行 | 阅读时长最久的书籍 TOP 20 | +| 书架 | 全部书架书籍展示,支持分类筛选和已读完筛选 | +| 笔记概览 | 有笔记的书籍及划线/想法/书签统计 | + +## 数据库表结构 + +| 表名 | 说明 | +|------|------| +| books | 书架书籍信息 | +| reading_stats | 阅读统计概要(overall/annually) | +| read_times | 阅读时长时间序列 | +| read_longest | 阅读时长最久的书籍 | +| prefer_categories | 偏好分类 | +| prefer_authors | 偏好作者 | +| prefer_time | 24 小时阅读时段分布 | +| notebooks | 笔记本概览 | +| read_stat_items | 阅读统计摘要项 | +| archives | 书单/归档 | + +## 更新数据 + +随时重新运行 `python fetch_data.py` 即可刷新本地数据。 diff --git a/app.py b/app.py new file mode 100644 index 0000000..d994501 --- /dev/null +++ b/app.py @@ -0,0 +1,375 @@ +#!/usr/bin/env python3 +""" +微信读书数据展示网站 - Flask 后端 +提供 API 接口供前端查询本地 SQLite 数据库中的读书数据 +""" + +import json +import os +import sqlite3 +import time +from datetime import datetime + +from flask import Flask, jsonify, render_template, request + +app = Flask(__name__, static_folder="static", template_folder="templates") + +DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "weread.db") + + +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def ts_to_date(ts): + """时间戳转日期字符串""" + if not ts or ts == 0: + return "" + return datetime.fromtimestamp(ts).strftime("%Y-%m-%d") + + +def ts_to_year(ts): + """时间戳转年份""" + if not ts or ts == 0: + return "" + return datetime.fromtimestamp(ts).strftime("%Y") + + +def format_duration(seconds): + """秒转可读时长""" + if not seconds: + return "0分钟" + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + if hours > 0: + return f"{hours}小时{minutes}分钟" + return f"{minutes}分钟" + + +@app.route("/") +def index(): + return render_template("index.html") + + +@app.route("/api/overview") +def api_overview(): + """总览数据""" + conn = get_db() + c = conn.cursor() + + # 总阅读统计 + c.execute("SELECT * FROM reading_stats WHERE mode='overall' LIMIT 1") + overall = c.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() + 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"] + + # 读完数量 + c.execute("SELECT COUNT(*) as cnt FROM books WHERE finish_reading=1") + finished_count = c.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 + + # 最近阅读时间 + c.execute("SELECT MAX(read_update_time) as max_ts FROM books") + last_read_ts = c.fetchone()["max_ts"] or 0 + + # 采集时间 + c.execute("SELECT value FROM fetch_meta WHERE key='last_fetch_time'") + fetch_row = c.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), + "read_days": read_days, + "year_read_time": year_read_time, + "year_read_time_str": format_duration(year_read_time), + "year_read_days": year_read_days, + "shelf_count": shelf_count, + "finished_count": finished_count, + "notebook_count": notebook_count, + "total_notes": total_notes, + "regist_time": ts_to_date(regist_time), + "last_read_time": ts_to_date(last_read_ts), + "fetch_time": fetch_time, + }) + + +@app.route("/api/yearly-trend") +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" + ) + rows = c.fetchall() + conn.close() + + data = [] + for row in rows: + year = ts_to_year(row["bucket_timestamp"]) + hours = round(row["duration"] / 3600, 1) + data.append({"year": year, "hours": hours, "seconds": row["duration"]}) + + return jsonify(data) + + +@app.route("/api/top-books") +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""" + ) + rows = c.fetchall() + conn.close() + + books = [] + for row in rows: + tags = json.loads(row["tags"]) if row["tags"] else [] + books.append({ + "book_id": row["book_id"], + "title": row["title"], + "author": row["author"], + "cover": row["cover"], + "read_time": row["read_time"], + "read_time_str": format_duration(row["read_time"]), + "tags": tags, + }) + + return jsonify(books) + + +@app.route("/api/categories") +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""" + ) + rows = c.fetchall() + conn.close() + + cats = [] + for row in rows: + cats.append({ + "category_title": row["category_title"], + "parent_category_title": row["parent_category_title"], + "reading_time": row["reading_time"], + "reading_time_str": format_duration(row["reading_time"]), + "reading_count": row["reading_count"], + "val": row["val"], + }) + + return jsonify(cats) + + +@app.route("/api/authors") +def api_authors(): + """偏好作者""" + conn = get_db() + c = conn.cursor() + c.execute( + """SELECT * FROM prefer_authors WHERE mode='overall' + ORDER BY count DESC LIMIT 20""" + ) + rows = c.fetchall() + conn.close() + + authors = [] + for row in rows: + authors.append({ + "name": row["name"], + "count": row["count"], + "read_time": row["read_time"], + }) + + return jsonify(authors) + + +@app.route("/api/time-distribution") +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""" + ) + rows = c.fetchall() + conn.close() + + dist = [] + for row in rows: + dist.append({ + "hour": row["hour_index"], + "duration": row["total"], + "duration_min": round(row["total"] / 60, 1), + }) + + return jsonify(dist) + + +@app.route("/api/shelf") +def api_shelf(): + """书架列表""" + 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() + + where_clauses = [] + params = [] + if category: + where_clauses.append("category LIKE ?") + 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"] + + c.execute( + f"SELECT * FROM books{where_sql} ORDER BY is_top DESC, read_update_time DESC LIMIT ? OFFSET ?", + params + [per_page, offset], + ) + rows = c.fetchall() + + # 获取分类列表 + c.execute("SELECT DISTINCT category FROM books WHERE category != '' ORDER BY category") + categories = [r["category"] for r in c.fetchall()] + + conn.close() + + books = [] + for row in rows: + books.append({ + "book_id": row["book_id"], + "title": row["title"], + "author": row["author"], + "cover": row["cover"], + "category": row["category"], + "finish_reading": row["finish_reading"], + "read_update_time": ts_to_date(row["read_update_time"]), + "secret": row["secret"], + "is_top": row["is_top"], + "deep_link": row["deep_link"], + }) + + return jsonify({ + "books": books, + "total": total, + "page": page, + "per_page": per_page, + "total_pages": (total + per_page - 1) // per_page, + "categories": categories, + }) + + +@app.route("/api/notebooks") +def api_notebooks(): + """笔记本概览""" + conn = get_db() + c = conn.cursor() + c.execute( + """SELECT * FROM notebooks + ORDER BY (review_count + note_count + bookmark_count) DESC""" + ) + rows = c.fetchall() + conn.close() + + notebooks = [] + for row in rows: + total = row["review_count"] + row["note_count"] + row["bookmark_count"] + notebooks.append({ + "book_id": row["book_id"], + "title": row["title"], + "author": row["author"], + "cover": row["cover"], + "review_count": row["review_count"], + "note_count": row["note_count"], + "bookmark_count": row["bookmark_count"], + "total_notes": total, + "reading_progress": row["reading_progress"], + "marked_status": row["marked_status"], + }) + + return jsonify(notebooks) + + +@app.route("/api/read-stats") +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() + + stats = [] + for row in rows: + stats.append({ + "stat": row["stat_name"], + "counts": row["counts"], + "scheme": row["scheme"], + }) + + return jsonify(stats) + + +@app.route("/api/archives") +def api_archives(): + """书单""" + conn = get_db() + c = conn.cursor() + c.execute("SELECT * FROM archives") + rows = c.fetchall() + conn.close() + + archives = [] + for row in rows: + book_ids = json.loads(row["book_ids"]) if row["book_ids"] else [] + archives.append({ + "name": row["name"], + "book_count": len(book_ids), + "book_ids": book_ids, + }) + + return jsonify(archives) + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=8080, debug=False) diff --git a/fetch_data.py b/fetch_data.py new file mode 100644 index 0000000..41601ef --- /dev/null +++ b/fetch_data.py @@ -0,0 +1,472 @@ +#!/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): + """初始化数据库表结构""" + 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 + ) + """) + + # 阅读统计概要表 + 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 + ) + """) + + # 阅读时长时间序列(按年/月/日分桶) + c.execute(""" + CREATE TABLE IF NOT EXISTS read_times ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mode TEXT, + period TEXT, + bucket_timestamp INTEGER, + 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 + ) + """) + + # 偏好分类 + 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 + ) + """) + + # 偏好作者 + 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 + ) + """) + + # 24小时阅读时段分布 + c.execute(""" + CREATE TABLE IF NOT EXISTS prefer_time ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mode TEXT, + period TEXT, + hour_index INTEGER, + 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 + ) + """) + + # 阅读统计摘要项 + 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 + ) + """) + + # 书单/归档 + c.execute(""" + CREATE TABLE IF NOT EXISTS archives ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT, + book_ids TEXT + ) + """) + + # 数据采集元信息 + c.execute(""" + CREATE TABLE IF NOT EXISTS fetch_meta ( + key TEXT PRIMARY KEY, + value TEXT + ) + """) + + 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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..001e7c4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +flask>=3.0 diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..f7c1ead --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,708 @@ +/* ===== 全局变量 ===== */ +:root { + --bg-primary: #f8f5f0; + --bg-card: #ffffff; + --bg-hero: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + --text-primary: #2c2416; + --text-secondary: #6b5d4f; + --text-light: #9b8e80; + --accent: #c17b4a; + --accent-light: #e8a87c; + --accent-dark: #a05c2c; + --border: #e8e0d4; + --shadow: 0 2px 12px rgba(44, 36, 22, 0.08); + --shadow-hover: 0 8px 24px rgba(44, 36, 22, 0.15); + --radius: 12px; + --radius-sm: 8px; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", + "Hiragino Sans GB", "Microsoft YaHei", sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + line-height: 1.6; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 0 24px; +} + +/* ===== 导航栏 ===== */ +.navbar { + position: sticky; + top: 0; + z-index: 100; + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(12px); + border-bottom: 1px solid var(--border); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04); +} + +.nav-container { + max-width: 1200px; + margin: 0 auto; + padding: 0 24px; + height: 60px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.nav-logo { + display: flex; + align-items: center; + gap: 8px; + font-size: 18px; + font-weight: 700; + color: var(--text-primary); +} + +.logo-icon { + font-size: 24px; +} + +.nav-links { + display: flex; + gap: 24px; +} + +.nav-link { + text-decoration: none; + color: var(--text-secondary); + font-size: 14px; + font-weight: 500; + padding: 6px 12px; + border-radius: var(--radius-sm); + transition: all 0.2s; +} + +.nav-link:hover { + color: var(--accent); + background: rgba(193, 123, 74, 0.08); +} + +.nav-link.active { + color: var(--accent); + background: rgba(193, 123, 74, 0.12); +} + +.nav-update { + font-size: 12px; + color: var(--text-light); +} + +/* ===== Hero 区 ===== */ +.hero-section { + position: relative; + padding: 80px 0 60px; + overflow: hidden; + background: var(--bg-hero); +} + +.hero-bg { + position: absolute; + inset: 0; + background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3Cg fill='%23ffffff' fill-opacity='0.05'%3E%3Cpath d='M50 25 L55 40 L70 40 L58 50 L63 65 L50 55 L37 65 L42 50 L30 40 L45 40 Z'/%3E%3C/g%3E%3C/svg%3E"); + opacity: 0.6; +} + +.hero-content { + position: relative; + text-align: center; + color: #fff; + max-width: 800px; + margin: 0 auto; + padding: 0 24px; +} + +.hero-title { + font-size: 36px; + font-weight: 700; + margin-bottom: 12px; + letter-spacing: 2px; + text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +.hero-subtitle { + font-size: 16px; + opacity: 0.9; + margin-bottom: 40px; +} + +.hero-stats { + display: flex; + justify-content: center; + gap: 48px; + flex-wrap: wrap; +} + +.hero-stat { + text-align: center; +} + +.hero-stat-value { + font-size: 42px; + font-weight: 800; + line-height: 1.2; + text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +.hero-stat-label { + font-size: 14px; + opacity: 0.85; + margin-top: 4px; +} + +/* ===== 统计卡片 ===== */ +.stats-cards-section { + padding: 40px 0 20px; + margin-top: -40px; + position: relative; + z-index: 10; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 20px; +} + +.stat-card { + background: var(--bg-card); + border-radius: var(--radius); + padding: 24px; + box-shadow: var(--shadow); + transition: transform 0.2s, box-shadow 0.2s; + text-align: center; + border: 1px solid var(--border); +} + +.stat-card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-hover); +} + +.stat-card-icon { + font-size: 32px; + margin-bottom: 12px; +} + +.stat-card-value { + font-size: 28px; + font-weight: 800; + color: var(--accent-dark); + margin-bottom: 4px; +} + +.stat-card-label { + font-size: 13px; + color: var(--text-secondary); +} + +.stat-card-sub { + font-size: 12px; + color: var(--text-light); + margin-top: 4px; +} + +/* ===== Section 标题 ===== */ +.section-title { + font-size: 24px; + font-weight: 700; + margin-bottom: 32px; + color: var(--text-primary); + text-align: center; +} + +/* ===== 图表区 ===== */ +.charts-section { + padding: 60px 0; +} + +.charts-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; +} + +.chart-card { + background: var(--bg-card); + border-radius: var(--radius); + padding: 24px; + box-shadow: var(--shadow); + border: 1px solid var(--border); +} + +.chart-card.large { + grid-column: 1 / -1; +} + +.chart-title { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 16px; +} + +.chart-container { + width: 100%; + height: 320px; +} + +/* ===== 读书排行 ===== */ +.top-books-section { + padding: 60px 0; + background: rgba(193, 123, 74, 0.04); +} + +.top-books-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 20px; +} + +.top-book-card { + background: var(--bg-card); + border-radius: var(--radius); + overflow: hidden; + box-shadow: var(--shadow); + transition: transform 0.2s, box-shadow 0.2s; + border: 1px solid var(--border); + cursor: pointer; +} + +.top-book-card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-hover); +} + +.top-book-rank { + position: absolute; + top: 8px; + left: 8px; + background: rgba(0, 0, 0, 0.7); + color: #ffd700; + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: 700; + z-index: 5; +} + +.top-book-cover { + position: relative; + width: 100%; + height: 200px; + object-fit: cover; + background: var(--bg-primary); +} + +.top-book-info { + padding: 14px 16px; +} + +.top-book-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.top-book-author { + font-size: 12px; + color: var(--text-light); + margin-bottom: 8px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.top-book-time { + font-size: 13px; + color: var(--accent); + font-weight: 600; +} + +.top-book-tags { + margin-top: 6px; + display: flex; + gap: 4px; + flex-wrap: wrap; +} + +.top-book-tag { + font-size: 11px; + padding: 2px 8px; + border-radius: 10px; + background: rgba(193, 123, 74, 0.12); + color: var(--accent-dark); +} + +/* ===== 书架 ===== */ +.shelf-section { + padding: 60px 0; +} + +.shelf-controls { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + flex-wrap: wrap; + gap: 12px; +} + +.shelf-filters { + display: flex; + align-items: center; + gap: 16px; +} + +.shelf-search { + padding: 8px 16px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: 14px; + width: 200px; + background: var(--bg-card); + color: var(--text-primary); +} + +.shelf-search:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(193, 123, 74, 0.15); +} + +.filter-checkbox { + display: flex; + align-items: center; + gap: 6px; + font-size: 14px; + color: var(--text-secondary); + cursor: pointer; +} + +.shelf-info { + font-size: 13px; + color: var(--text-light); +} + +.book-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 20px; + margin-bottom: 32px; +} + +.book-card { + cursor: pointer; + transition: transform 0.2s; +} + +.book-card:hover { + transform: translateY(-4px); +} + +.book-cover-wrapper { + position: relative; + width: 100%; + aspect-ratio: 3 / 4; + border-radius: var(--radius-sm); + overflow: hidden; + box-shadow: var(--shadow); + background: var(--bg-primary); +} + +.book-cover-img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.3s; +} + +.book-card:hover .book-cover-img { + transform: scale(1.05); +} + +.book-finished-badge { + position: absolute; + top: 6px; + right: 6px; + background: #4caf50; + color: white; + font-size: 10px; + padding: 2px 6px; + border-radius: 4px; + font-weight: 600; +} + +.book-top-badge { + position: absolute; + top: 6px; + left: 6px; + background: rgba(255, 215, 0, 0.9); + color: #5d4037; + font-size: 10px; + padding: 2px 6px; + border-radius: 4px; + font-weight: 600; +} + +.book-title { + font-size: 13px; + font-weight: 500; + margin-top: 8px; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.book-author { + font-size: 11px; + color: var(--text-light); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ===== 分页 ===== */ +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + margin-top: 32px; +} + +.page-btn { + padding: 8px 14px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-card); + color: var(--text-secondary); + cursor: pointer; + font-size: 14px; + transition: all 0.2s; +} + +.page-btn:hover:not(:disabled) { + border-color: var(--accent); + color: var(--accent); +} + +.page-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.page-btn.active { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} + +.page-info { + font-size: 13px; + color: var(--text-light); + margin: 0 8px; +} + +/* ===== 笔记区 ===== */ +.notes-section { + padding: 60px 0; + background: rgba(193, 123, 74, 0.04); +} + +.notes-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 16px; + margin-bottom: 32px; +} + +.note-stat { + background: var(--bg-card); + border-radius: var(--radius); + padding: 20px; + text-align: center; + box-shadow: var(--shadow); + border: 1px solid var(--border); +} + +.note-stat-value { + font-size: 28px; + font-weight: 800; + color: var(--accent-dark); +} + +.note-stat-label { + font-size: 13px; + color: var(--text-secondary); + margin-top: 4px; +} + +.notebooks-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 20px; +} + +.notebook-card { + background: var(--bg-card); + border-radius: var(--radius); + padding: 16px; + display: flex; + gap: 14px; + box-shadow: var(--shadow); + border: 1px solid var(--border); + transition: transform 0.2s, box-shadow 0.2s; +} + +.notebook-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-hover); +} + +.notebook-cover { + width: 60px; + height: 80px; + border-radius: 6px; + object-fit: cover; + flex-shrink: 0; + box-shadow: var(--shadow); +} + +.notebook-info { + flex: 1; + min-width: 0; +} + +.notebook-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.notebook-author { + font-size: 12px; + color: var(--text-light); + margin-bottom: 8px; +} + +.notebook-stats { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.notebook-stat-item { + font-size: 12px; + color: var(--text-secondary); +} + +.notebook-stat-item strong { + color: var(--accent-dark); +} + +.notebook-progress { + margin-top: 8px; + height: 4px; + background: var(--border); + border-radius: 2px; + overflow: hidden; +} + +.notebook-progress-bar { + height: 100%; + background: var(--accent); + border-radius: 2px; + transition: width 0.3s; +} + +/* ===== 页脚 ===== */ +.footer { + padding: 40px 0; + text-align: center; + color: var(--text-light); + font-size: 13px; + border-top: 1px solid var(--border); + background: var(--bg-card); +} + +.footer-hint { + margin-top: 8px; + font-size: 12px; + opacity: 0.7; +} + +/* ===== 加载动画 ===== */ +.loading { + display: flex; + justify-content: center; + align-items: center; + padding: 60px 0; +} + +.loading-spinner { + width: 40px; + height: 40px; + border: 3px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ===== 响应式 ===== */ +@media (max-width: 768px) { + .nav-links { + display: none; + } + .hero-title { + font-size: 24px; + } + .hero-stats { + gap: 24px; + } + .hero-stat-value { + font-size: 32px; + } + .charts-grid { + grid-template-columns: 1fr; + } + .chart-card.large { + grid-column: auto; + } + .book-grid { + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 12px; + } + .top-books-grid { + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + } +} diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..1862f6c --- /dev/null +++ b/static/js/app.js @@ -0,0 +1,556 @@ +/* ===== 微信读书数据展示 - 前端逻辑 ===== */ + +const API_BASE = ""; + +// ===== 工具函数 ===== +async function fetchAPI(path) { + const resp = await fetch(API_BASE + path); + if (!resp.ok) throw new Error(`API error: ${resp.status}`); + return resp.json(); +} + +function formatHours(seconds) { + if (!seconds) return "0"; + return (seconds / 3600).toFixed(1); +} + +// ===== 初始化 ===== +document.addEventListener("DOMContentLoaded", () => { + loadOverview(); + loadYearlyTrend(); + loadTimeDistribution(); + loadCategories(); + loadAuthors(); + loadTopBooks(); + loadShelf(1); + loadNotebooks(); + initNavScroll(); +}); + +// ===== 导航栏滚动高亮 ===== +function initNavScroll() { + const links = document.querySelectorAll(".nav-link"); + const sections = document.querySelectorAll("section[id]"); + + window.addEventListener("scroll", () => { + let current = ""; + sections.forEach((sec) => { + const top = sec.offsetTop - 100; + if (window.scrollY >= top) current = sec.id; + }); + links.forEach((link) => { + link.classList.toggle("active", link.getAttribute("href") === `#${current}`); + }); + }); + + links.forEach((link) => { + link.addEventListener("click", (e) => { + e.preventDefault(); + const target = document.querySelector(link.getAttribute("href")); + if (target) { + window.scrollTo({ top: target.offsetTop - 60, behavior: "smooth" }); + } + }); + }); +} + +// ===== 总览数据 ===== +async function loadOverview() { + try { + const data = await fetchAPI("/api/overview"); + + // Hero 统计 + const heroStats = document.getElementById("heroStats"); + heroStats.innerHTML = ` +
+
${formatHours(data.total_read_time)}
+
累计阅读 (小时)
+
+
+
${data.read_days}
+
阅读天数
+
+
+
${data.shelf_count}
+
书架藏书
+
+
+
${data.total_notes}
+
读书笔记
+
+ `; + + document.getElementById("heroSubtitle").textContent = + `自 ${data.regist_time} 加入微信读书,已陪伴你 ${data.read_days} 个阅读的日子`; + + // 统计卡片 + const statsGrid = document.getElementById("statsGrid"); + statsGrid.innerHTML = ` +
+
⏱️
+
${data.total_read_time_str}
+
累计阅读时长
+
今年: ${data.year_read_time_str}
+
+
+
📅
+
${data.read_days}
+
累计阅读天数
+
今年: ${data.year_read_days} 天
+
+
+
📚
+
${data.shelf_count}
+
书架书籍
+
已读完: ${data.finished_count} 本
+
+
+
✍️
+
${data.total_notes}
+
读书笔记
+
来自 ${data.notebook_count} 本书
+
+ `; + + // 更新时间 + const timeStr = `数据更新: ${data.fetch_time}`; + document.getElementById("fetchTime").textContent = timeStr; + document.getElementById("footerTime").textContent = data.fetch_time; + } catch (e) { + console.error("加载总览失败:", e); + } +} + +// ===== 年度趋势图 ===== +async function loadYearlyTrend() { + try { + const data = await fetchAPI("/api/yearly-trend"); + const chart = echarts.init(document.getElementById("chart-yearly")); + + const years = data.map((d) => d.year); + const hours = data.map((d) => d.hours); + + chart.setOption({ + tooltip: { + trigger: "axis", + formatter: (params) => { + const p = params[0]; + return `${p.name}年
阅读: ${p.value} 小时`; + }, + }, + grid: { left: 50, right: 30, top: 30, bottom: 40 }, + xAxis: { + type: "category", + data: years, + axisLabel: { color: "#6b5d4f" }, + axisLine: { lineStyle: { color: "#e8e0d4" } }, + }, + yAxis: { + type: "value", + name: "小时", + axisLabel: { color: "#6b5d4f" }, + splitLine: { lineStyle: { color: "#f0ebe3" } }, + }, + series: [ + { + data: hours, + type: "bar", + barWidth: "50%", + itemStyle: { + borderRadius: [8, 8, 0, 0], + color: { + type: "linear", + x: 0, y: 0, x2: 0, y2: 1, + colorStops: [ + { offset: 0, color: "#e8a87c" }, + { offset: 1, color: "#c17b4a" }, + ], + }, + }, + label: { + show: true, + position: "top", + color: "#6b5d4f", + fontSize: 12, + formatter: "{c}h", + }, + }, + ], + }); + window.addEventListener("resize", () => chart.resize()); + } catch (e) { + console.error("加载年度趋势失败:", e); + } +} + +// ===== 24小时时段分布 ===== +async function loadTimeDistribution() { + try { + const data = await fetchAPI("/api/time-distribution"); + const chart = echarts.init(document.getElementById("chart-hours")); + + const hours = data.map((d) => `${d.hour}时`); + const minutes = data.map((d) => d.duration_min); + + chart.setOption({ + tooltip: { + trigger: "axis", + formatter: (params) => { + const p = params[0]; + return `${p.name}
阅读: ${p.value} 分钟`; + }, + }, + grid: { left: 50, right: 20, top: 20, bottom: 40 }, + xAxis: { + type: "category", + data: hours, + axisLabel: { + color: "#6b5d4f", + fontSize: 10, + interval: 2, + }, + axisLine: { lineStyle: { color: "#e8e0d4" } }, + }, + yAxis: { + type: "value", + name: "分钟", + axisLabel: { color: "#6b5d4f" }, + splitLine: { lineStyle: { color: "#f0ebe3" } }, + }, + series: [ + { + data: minutes, + type: "line", + smooth: true, + symbol: "circle", + symbolSize: 4, + lineStyle: { width: 2, color: "#c17b4a" }, + itemStyle: { color: "#c17b4a" }, + areaStyle: { + color: { + type: "linear", + x: 0, y: 0, x2: 0, y2: 1, + colorStops: [ + { offset: 0, color: "rgba(193,123,74,0.3)" }, + { offset: 1, color: "rgba(193,123,74,0.02)" }, + ], + }, + }, + }, + ], + }); + window.addEventListener("resize", () => chart.resize()); + } catch (e) { + console.error("加载时段分布失败:", e); + } +} + +// ===== 偏好分类 ===== +async function loadCategories() { + try { + const data = await fetchAPI("/api/categories"); + const chart = echarts.init(document.getElementById("chart-categories")); + + const items = data.slice(0, 8).map((d) => ({ + name: d.category_title, + value: d.reading_time, + })); + + chart.setOption({ + tooltip: { + trigger: "item", + formatter: (p) => { + const hours = (p.value / 3600).toFixed(1); + return `${p.name}
阅读: ${hours} 小时 (${p.percent}%)`; + }, + }, + legend: { + type: "scroll", + orient: "vertical", + right: 10, + top: 20, + bottom: 20, + textStyle: { color: "#6b5d4f", fontSize: 11 }, + }, + series: [ + { + type: "pie", + radius: ["40%", "70%"], + center: ["35%", "50%"], + avoidLabelOverlap: false, + itemStyle: { + borderRadius: 6, + borderColor: "#fff", + borderWidth: 2, + }, + label: { show: false }, + emphasis: { + label: { + show: true, + fontSize: 14, + fontWeight: "bold", + }, + }, + data: items, + color: [ + "#c17b4a", "#e8a87c", "#d4a574", "#b8860b", + "#cd853f", "#daa520", "#bc8f8f", "#bdb76b", + ], + }, + ], + }); + window.addEventListener("resize", () => chart.resize()); + } catch (e) { + console.error("加载分类失败:", e); + } +} + +// ===== 偏好作者 ===== +async function loadAuthors() { + try { + const data = await fetchAPI("/api/authors"); + const chart = echarts.init(document.getElementById("chart-authors")); + + const top10 = data.slice(0, 10); + const names = top10.map((d) => d.name); + const counts = top10.map((d) => d.count); + + chart.setOption({ + tooltip: { + trigger: "axis", + axisPointer: { type: "shadow" }, + formatter: (params) => { + const p = params[0]; + return `${p.name}
阅读: ${p.value} 本`; + }, + }, + grid: { left: 100, right: 40, top: 20, bottom: 20 }, + xAxis: { + type: "value", + axisLabel: { color: "#6b5d4f" }, + splitLine: { lineStyle: { color: "#f0ebe3" } }, + }, + yAxis: { + type: "category", + data: names.reverse(), + axisLabel: { color: "#6b5d4f", fontSize: 12 }, + axisLine: { lineStyle: { color: "#e8e0d4" } }, + }, + series: [ + { + type: "bar", + data: counts.reverse(), + barWidth: "50%", + itemStyle: { + borderRadius: [0, 6, 6, 0], + color: { + type: "linear", + x: 0, y: 0, x2: 1, y2: 0, + colorStops: [ + { offset: 0, color: "#e8a87c" }, + { offset: 1, color: "#c17b4a" }, + ], + }, + }, + label: { + show: true, + position: "right", + color: "#6b5d4f", + fontSize: 12, + formatter: "{c}本", + }, + }, + ], + }); + window.addEventListener("resize", () => chart.resize()); + } catch (e) { + console.error("加载作者失败:", e); + } +} + +// ===== 读书排行 ===== +async function loadTopBooks() { + try { + const data = await fetchAPI("/api/top-books"); + const grid = document.getElementById("topBooksGrid"); + + grid.innerHTML = data + .map((book, i) => { + const tagsHtml = book.tags + .map((t) => `${t}`) + .join(""); + return ` +
+
+ ${i + 1} + ${book.title} +
+
+
${book.title}
+
${book.author}
+
⏱ ${book.read_time_str}
+ ${tagsHtml ? `
${tagsHtml}
` : ""} +
+
`; + }) + .join(""); + } catch (e) { + console.error("加载排行失败:", e); + } +} + +// ===== 书架 ===== +let shelfCurrentPage = 1; + +async function loadShelf(page) { + shelfCurrentPage = page; + const search = document.getElementById("shelfSearch").value; + const finished = document.getElementById("finishedOnly").checked ? "1" : "0"; + const params = new URLSearchParams({ + page: page, + per_page: 60, + category: search, + finished: finished, + }); + + try { + const data = await fetchAPI(`/api/shelf?${params}`); + const grid = document.getElementById("bookGrid"); + + document.getElementById("shelfInfo").textContent = + `共 ${data.total} 本 · 第 ${page}/${data.total_pages} 页`; + + grid.innerHTML = data.books + .map((book) => { + const badges = []; + if (book.is_top) badges.push(`置顶`); + if (book.finish_reading) badges.push(`已读完`); + return ` +
+
+ ${book.title} + ${badges.join("")} +
+
${book.title}
+
${book.author}
+
`; + }) + .join(""); + + // 分页 + renderPagination(page, data.total_pages); + } catch (e) { + console.error("加载书架失败:", e); + } +} + +function renderPagination(current, total) { + const pag = document.getElementById("pagination"); + if (total <= 1) { + pag.innerHTML = ""; + return; + } + + let html = ``; + + const maxButtons = 7; + let start = Math.max(1, current - 3); + let end = Math.min(total, start + maxButtons - 1); + start = Math.max(1, end - maxButtons + 1); + + if (start > 1) { + html += ``; + if (start > 2) html += `...`; + } + + for (let i = start; i <= end; i++) { + html += ``; + } + + if (end < total) { + if (end < total - 1) html += `...`; + html += ``; + } + + html += ``; + + pag.innerHTML = html; +} + +// 搜索和筛选事件 +document.getElementById("shelfSearch").addEventListener("input", debounce(() => loadShelf(1), 300)); +document.getElementById("finishedOnly").addEventListener("change", () => loadShelf(1)); + +function debounce(fn, delay) { + let timer; + return (...args) => { + clearTimeout(timer); + timer = setTimeout(() => fn(...args), delay); + }; +} + +// ===== 笔记概览 ===== +async function loadNotebooks() { + try { + const data = await fetchAPI("/api/notebooks"); + const summary = document.getElementById("notesSummary"); + + const totalHighlights = data.reduce((s, d) => s + d.note_count, 0); + const totalReviews = data.reduce((s, d) => s + d.review_count, 0); + const totalBookmarks = data.reduce((s, d) => s + d.bookmark_count, 0); + + summary.innerHTML = ` +
+
${data.length}
+
有笔记的书
+
+
+
${totalHighlights}
+
划线
+
+
+
${totalReviews}
+
想法/点评
+
+
+
${totalBookmarks}
+
书签
+
+ `; + + const grid = document.getElementById("notebooksGrid"); + grid.innerHTML = data + .map((nb) => { + const progress = nb.reading_progress || 0; + return ` +
+ ${nb.title} +
+
${nb.title}
+
${nb.author}
+
+ 划线 ${nb.note_count} + 想法 ${nb.review_count} + 书签 ${nb.bookmark_count} +
+
+
+
+
+
`; + }) + .join(""); + } catch (e) { + console.error("加载笔记失败:", e); + } +} + +// ===== 打开书籍 ===== +function openBook(bookId) { + if (bookId) { + window.open(`weread://reading?bId=${bookId}`, "_blank"); + } +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..4faf5eb --- /dev/null +++ b/templates/index.html @@ -0,0 +1,130 @@ + + + + + + 我的读书数据 + + + + + + + + +
+
+
+

阅读,是一场漫长的旅行

+

正在加载数据...

+
+ +
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+

📊 阅读数据可视化

+
+
+

年度阅读时长趋势

+
+
+
+

24小时阅读时段分布

+
+
+
+

偏好阅读分类

+
+
+
+

偏好作者 TOP 10

+
+
+
+
+
+ + +
+
+

🏆 阅读时长排行

+
+ +
+
+
+ + +
+
+

📚 我的书架

+
+
+ + +
+
+
+
+ +
+ +
+
+ + +
+
+

✍️ 笔记概览

+
+ +
+
+ +
+
+
+ + + + + + +