diff --git a/.gitignore b/.gitignore index 7363bf8..2827a81 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,7 @@ Thumbs.db # Env .env .server.pid + +# Tool artifacts +.qoder/ +.workbuddy/ diff --git a/app.py b/app.py index 594216f..6d30ba4 100644 --- a/app.py +++ b/app.py @@ -20,6 +20,7 @@ import json import os import ssl import subprocess +import sys import time import urllib.request from datetime import datetime @@ -27,6 +28,8 @@ from functools import wraps import pymysql +from config_utils import get_env_value + try: import certifi _ssl_context = ssl.create_default_context(cafile=certifi.where()) @@ -56,7 +59,6 @@ MYSQL_CONFIG = { # 微信读书 API 配置 # ====================================================================== WEREAD_API_URL = "https://i.weread.qq.com/api/agent/gateway" -WEREAD_API_KEY = os.environ.get("WEREAD_API_KEY", "") WEREAD_SKILL_VERSION = "1.0.4" @@ -154,13 +156,17 @@ def weread_api_call(api_name, **kwargs): Raises: Exception: 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": WEREAD_SKILL_VERSION} body.update(kwargs) req = urllib.request.Request( WEREAD_API_URL, data=json.dumps(body).encode("utf-8"), headers={ - "Authorization": f"Bearer {WEREAD_API_KEY}", + "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", }, method="POST", @@ -1199,14 +1205,14 @@ def api_search(conn): def sync_data(): """运行 fetch_data.py + migrate_to_mysql.py,全量同步数据""" project_dir = os.path.dirname(os.path.abspath(__file__)) - python_bin = os.path.join( - os.path.expanduser("~"), - ".workbuddy/binaries/python/envs/default/bin/python", - ) + python_bin = sys.executable or "python3" env = os.environ.copy() - env["WEREAD_API_KEY"] = env.get("WEREAD_API_KEY", "") + env["WEREAD_API_KEY"] = get_env_value("WEREAD_API_KEY", "") if not env["WEREAD_API_KEY"]: - return jsonify({"ok": False, "error": "WEREAD_API_KEY 未设置"}), 500 + return jsonify({ + "ok": False, + "error": "WEREAD_API_KEY 未设置,请检查当前终端环境、项目 .env 或 ~/.zshrc", + }), 500 results = {"steps": []} try: diff --git a/config_utils.py b/config_utils.py new file mode 100644 index 0000000..9eac5fa --- /dev/null +++ b/config_utils.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +""" +项目配置读取工具。 + +读取顺序: +1. 当前进程环境变量 +2. 项目根目录下的 .env / .env.local +3. 用户常见 shell 配置文件中的 export 语句 +""" + +from pathlib import Path +import os + + +PROJECT_DIR = Path(__file__).resolve().parent +ENV_FILE_NAMES = (".env", ".env.local") +SHELL_RC_FILES = ( + Path("~/.zshrc").expanduser(), + Path("~/.zprofile").expanduser(), + Path("~/.zlogin").expanduser(), + Path("~/.bash_profile").expanduser(), + Path("~/.bashrc").expanduser(), + Path("~/.profile").expanduser(), +) + + +def _parse_assignment(line): + """解析 KEY=VALUE 或 export KEY=VALUE 形式的配置行。""" + stripped = line.strip() + if not stripped or stripped.startswith("#"): + return None + + if stripped.startswith("export "): + stripped = stripped[7:].strip() + + if "=" not in stripped: + return None + + key, value = stripped.split("=", 1) + key = key.strip() + value = value.strip() + + if not key or any(ch.isspace() for ch in key): + return None + + if value[:1] in {"'", '"'} and value[-1:] == value[:1]: + value = value[1:-1] + else: + value = value.split(" #", 1)[0].strip() + + return key, value + + +def _read_var_from_file(file_path, var_name): + """从文本配置文件里读取指定变量,后出现的值优先。""" + try: + lines = file_path.read_text(encoding="utf-8").splitlines() + except OSError: + return "" + + result = "" + for line in lines: + parsed = _parse_assignment(line) + if not parsed: + continue + key, value = parsed + if key == var_name and value: + result = value + return result + + +def _project_env_files(project_dir=None): + base_dir = Path(project_dir) if project_dir else PROJECT_DIR + return [base_dir / name for name in ENV_FILE_NAMES] + + +def load_project_env(project_dir=None): + """把项目 .env 文件里的变量加载到当前进程环境中。""" + for file_path in _project_env_files(project_dir): + if not file_path.exists(): + continue + try: + lines = file_path.read_text(encoding="utf-8").splitlines() + except OSError: + continue + for line in lines: + parsed = _parse_assignment(line) + if not parsed: + continue + key, value = parsed + if key and value and key not in os.environ: + os.environ[key] = value + + +def get_env_value(var_name, default="", project_dir=None): + """按项目约定顺序读取配置值,并回填到当前进程环境。""" + value = os.environ.get(var_name, "") + if value: + return value + + load_project_env(project_dir) + value = os.environ.get(var_name, "") + if value: + return value + + for file_path in _project_env_files(project_dir): + value = _read_var_from_file(file_path, var_name) + if value: + os.environ[var_name] = value + return value + + for file_path in SHELL_RC_FILES: + value = _read_var_from_file(file_path, var_name) + if value: + os.environ[var_name] = value + return value + + return default diff --git a/docs/PROJECT_FINAL_DOC.md b/docs/PROJECT_FINAL_DOC.md new file mode 100644 index 0000000..9f6bad7 --- /dev/null +++ b/docs/PROJECT_FINAL_DOC.md @@ -0,0 +1,286 @@ +# 微信读书数据看板最终文档 + +本文档用于替代 `.qoder/` 与 `.workbuddy/` 中分散的工具生成文档,作为当前仓库内的单一最终说明。内容以仓库现状为准,吸收了工具文档中的架构说明、接口说明、数据库分组、部署信息和迭代记录,同时剔除了已经过期或仅对工具自身有意义的中间信息。 + +## 1. 文档与目录约定 + +- 本文件是 `.qoder/repowiki` 与 `.workbuddy/memory` 的合并结果,也是这两个目录的人工可维护替代品。 +- `.qoder/`、`.workbuddy/` 属于外部工具的中间产物,不参与 Git 管理。 +- 深度技术细节仍保留在仓库原有文档中: + - [TECHNICAL_DOC.md](/Users/kinghwj/Codes/WeReadBoard/docs/TECHNICAL_DOC.md) + - [METADATA_DOC.md](/Users/kinghwj/Codes/WeReadBoard/docs/METADATA_DOC.md) + +## 2. 项目定位 + +这是一个基于微信读书 Agent Gateway API 的个人阅读数据看板。系统将微信读书中的书架、阅读统计、笔记、推荐和搜索能力整合到一个本地运行的网站中,提供可视化图表、分页书架、笔记概览和按需详情查询。 + +当前实现采用: + +- 后端:Flask + PyMySQL +- 前端:原生 HTML / CSS / JavaScript + ECharts +- 缓存与采集:SQLite +- 在线查询数据源:MySQL +- 外部能力:微信读书 Agent Gateway API + +## 3. 当前仓库结构 + +核心文件与职责如下: + +- [app.py](/Users/kinghwj/Codes/WeReadBoard/app.py):Flask 后端服务,提供页面和 `/api/*` 接口。 +- [fetch_data.py](/Users/kinghwj/Codes/WeReadBoard/fetch_data.py):全量采集脚本,将微信读书数据写入 SQLite。 +- [migrate_to_mysql.py](/Users/kinghwj/Codes/WeReadBoard/migrate_to_mysql.py):将 SQLite 数据迁移到 MySQL。 +- [schema_mysql.sql](/Users/kinghwj/Codes/WeReadBoard/schema_mysql.sql):MySQL 31 张表建表语句。 +- [config_utils.py](/Users/kinghwj/Codes/WeReadBoard/config_utils.py):运行时配置读取工具。 +- [start_server.sh](/Users/kinghwj/Codes/WeReadBoard/start_server.sh):本地服务生命周期脚本。 +- [templates/index.html](/Users/kinghwj/Codes/WeReadBoard/templates/index.html):页面骨架。 +- [static/js/app.js](/Users/kinghwj/Codes/WeReadBoard/static/js/app.js):前端交互逻辑。 +- [static/css/style.css](/Users/kinghwj/Codes/WeReadBoard/static/css/style.css):主题、布局和组件样式。 + +## 4. 系统架构 + +系统采用“采集 -> 迁移 -> 展示 -> 按需回填”的模式: + +1. `fetch_data.py` 从微信读书 API 拉取全量数据,落入本地 `weread.db`。 +2. `migrate_to_mysql.py` 将 SQLite 中的数据按表迁移到 MySQL `weread_dashboard`。 +3. `app.py` 从 MySQL 读取基础数据,向前端提供 REST API。 +4. 部分详情型接口会在用户点击时再去调用微信读书 API,并把结果缓存回 MySQL。 +5. 前端点击“刷新”时会调用 `/api/sync`,串行执行全量采集与迁移。 + +## 5. 运行时配置 + +### 5.1 微信读书 API Key + +当前代码通过 [config_utils.py](/Users/kinghwj/Codes/WeReadBoard/config_utils.py) 读取 `WEREAD_API_KEY`,优先级如下: + +1. 当前进程环境变量 +2. 项目根目录 `.env` +3. 项目根目录 `.env.local` +4. 用户 shell 配置文件,如 `~/.zshrc`、`~/.zprofile`、`~/.bash_profile` + +这层逻辑的目的,是避免“终端里已经 export,但服务进程没有继承”导致的误报。 + +### 5.2 MySQL 配置 + +当前 MySQL 连接配置仍写在代码中,`app.py` 与 `migrate_to_mysql.py` 各维护一份: + +- host: `127.0.0.1` +- port: `3306` +- user: `root` +- password: `258963` +- database: `weread_dashboard` + +这部分目前能工作,但仍属于后续值得继续抽离的技术债。 + +### 5.3 端口与启动 + +- 默认端口:`8080` +- 可选环境变量:`WEREAD_PORT` +- 启动脚本使用当前机器上可用的 `python3` 或 `python` + +常用命令: + +```bash +pip install -r requirements.txt +python3 app.py +./start_server.sh daemon +./start_server.sh stop +./start_server.sh status +``` + +## 6. 主要功能 + +### 6.1 概览与图表 + +- 总阅读时长、阅读天数、书架数量、笔记总数 +- 年度阅读趋势 +- 24 小时时段分布 +- 偏好分类 +- 偏好作者 +- 阅读时长排行 + +### 6.2 书架 + +- 关键词搜索书名 / 作者 +- 分类筛选与父子分类树 +- 仅看已读完 +- 分页浏览 +- 一键刷新同步微信读书数据 + +### 6.3 笔记与想法 + +- 笔记本概览 +- 点击书籍查看按章节整理的划线、想法和点评 +- 想法详情页按类型筛选 + +### 6.4 发现能力 + +- 为你推荐 +- 搜索书籍 +- 推荐 / 搜索结果写回本地缓存 + +## 7. API 总览 + +当前后端接口主要分为四类: + +### 7.1 系统接口 + +- `GET /api/health`:数据库健康检查 +- `POST /api/sync`:执行全量采集与迁移 + +### 7.2 基础统计接口 + +- `GET /api/overview` +- `GET /api/yearly-trend` +- `GET /api/top-books` +- `GET /api/categories` +- `GET /api/authors` +- `GET /api/time-distribution` +- `GET /api/read-stats` +- `GET /api/archives` + +### 7.3 书架与笔记接口 + +- `GET /api/shelf` +- `GET /api/notebooks` + +### 7.4 按需详情 / 发现接口 + +- `GET /api/book/detail` +- `GET /api/book/notes` +- `GET /api/recommend` +- `GET /api/search` + +接口返回格式、参数细节和字段说明,仍以 [TECHNICAL_DOC.md](/Users/kinghwj/Codes/WeReadBoard/docs/TECHNICAL_DOC.md) 为准。 + +## 8. 数据模型分组 + +当前 MySQL 使用 31 张表,按职责分为以下几组: + +### 8.1 书架 + +- `books` +- `shelf_albums` +- `shelf_meta` +- `archives` + +### 8.2 阅读统计 + +- `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` + +### 8.3 笔记与书籍内容 + +- `notebooks` +- `book_highlights` +- `book_chapters` +- `book_reviews` +- `book_best_bookmarks` +- `book_underlines` +- `public_reviews` + +### 8.4 书籍详情与进度 + +- `book_details` +- `book_progress` + +### 8.5 推荐与搜索 + +- `recommended_books` +- `similar_books` +- `search_sessions` +- `search_results` + +### 8.6 系统元信息 + +- `fetch_meta` + +设计特征: + +- MySQL 统一使用 `utf8mb4` + `InnoDB` +- 大多数时间字段采用“整型时间戳 + `_dt` 可读时间列”的双列模式 +- 每张表统一维护 `created_at` 与 `updated_at` + +完整字段说明见 [METADATA_DOC.md](/Users/kinghwj/Codes/WeReadBoard/docs/METADATA_DOC.md)。 + +## 9. 前端实现要点 + +当前前端是单页标签切换式界面,主要包括: + +- 主题切换:支持明暗主题与本地持久化 +- 图表懒加载:首次进入图表页时才初始化 ECharts +- 高清封面策略:低清 URL 自动升级为高清版本 +- 书架筛选:关键词、分类、已读完、分页 +- 想法详情页:从弹窗升级为独立页面 +- 刷新同步:按钮触发 `/api/sync` 后再刷新书架数据 + +## 10. 部署与运维要点 + +- MySQL 需要先执行 [schema_mysql.sql](/Users/kinghwj/Codes/WeReadBoard/schema_mysql.sql) 建库建表。 +- 初次准备数据时,执行: + +```bash +python3 fetch_data.py +python3 migrate_to_mysql.py +``` + +- 日常使用可直接启动 Web 服务,前端点“刷新”会自动触发同步。 +- 若样式或静态资源修改后页面无变化,优先检查: + - 服务是否使用了旧进程 + - 浏览器是否使用了缓存 + +## 11. 合并后的迭代记录 + +以下内容来自 `.workbuddy/memory/2026-07-17.md`,但已按主题重整并去掉工具内部流程描述。 + +### 11.1 数据层演进 + +- 项目由 SQLite 展示切换为 MySQL 展示。 +- 建表从 18 张表扩展到 31 张表,对齐官方接口字段。 +- 迁移脚本重写,支持全量导入与时间字段转换。 + +### 11.2 后端演进 + +- 新增 `db_required` 装饰器统一管理数据库连接。 +- 新增 `/api/health` 用于前端健康检查。 +- 新增 `/api/sync`,打通“采集 -> 迁移 -> 展示”的刷新流程。 +- 新增按需详情接口,失败时可回退本地缓存。 + +### 11.3 前端演进 + +- 新增明暗主题切换和图表配色适配。 +- 阅读排行从卡片改为列表。 +- 想法详情从弹窗改为独立页面。 +- 新增顶部搜索框、书架分类树、推荐与搜索页。 +- 书架增加刷新按钮,并在后续多次修复其加载态抖动问题。 + +### 11.4 启动与配置演进 + +- `start_server.sh` 从硬编码路径改为动态识别项目目录与解释器。 +- `WEREAD_API_KEY` 从单纯依赖当前 shell 环境,演进为支持 `.env` 和 shell 配置回退读取。 + +## 12. 当前已知技术债 + +- MySQL 连接配置仍硬编码在 `app.py` 和 `migrate_to_mysql.py`,存在重复维护。 +- `README.md` 仍保留较早期的 SQLite 描述,后续可与当前架构再对齐。 +- 当前仍以本地脚本为主,未引入更正式的进程管理和自动化部署方案。 + +## 13. 结论 + +现在仓库内推荐遵循的文档与资料体系如下: + +- 最终总说明:本文件 [PROJECT_FINAL_DOC.md](/Users/kinghwj/Codes/WeReadBoard/docs/PROJECT_FINAL_DOC.md) +- 技术展开说明:[TECHNICAL_DOC.md](/Users/kinghwj/Codes/WeReadBoard/docs/TECHNICAL_DOC.md) +- 表结构与字段说明:[METADATA_DOC.md](/Users/kinghwj/Codes/WeReadBoard/docs/METADATA_DOC.md) + +`.qoder/` 与 `.workbuddy/` 继续保留给工具使用即可,但不再视为仓库的正式文档来源。 diff --git a/fetch_data.py b/fetch_data.py index 567e8ca..4bca0ac 100644 --- a/fetch_data.py +++ b/fetch_data.py @@ -26,9 +26,10 @@ 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" -API_KEY = os.environ.get("WEREAD_API_KEY", "") 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__)) @@ -54,13 +55,17 @@ def now_str(): 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}", + "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", }, method="POST", @@ -1020,9 +1025,9 @@ def fetch_notebooks(conn): # ===== 主函数 ===== def main(): - if not API_KEY: + if not get_env_value("WEREAD_API_KEY", ""): print("错误: 请先设置 WEREAD_API_KEY 环境变量") - print(" export WEREAD_API_KEY=wrk-xxxxxxxx") + print(" 可配置在当前终端、项目 .env,或 ~/.zshrc") return print("=" * 60) diff --git a/requirements.txt b/requirements.txt index 001e7c4..1f9e10e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ flask>=3.0 +PyMySQL>=1.1 diff --git a/start_server.sh b/start_server.sh index 01c7e2e..7bc727b 100755 --- a/start_server.sh +++ b/start_server.sh @@ -7,8 +7,8 @@ # ./start_server.sh restart 重启服务 # ./start_server.sh status 查看状态 -PROJECT_DIR="/Users/hwj/WorkBuddy/2026-07-17-09-48-20" -PYTHON="/Users/hwj/.workbuddy/binaries/python/envs/default/bin/python" +PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)" +PYTHON="${PYTHON:-$(command -v python3 || command -v python)}" PID_FILE="$PROJECT_DIR/.server.pid" LOG_FILE="/tmp/weread_server.log" PORT=8080 @@ -61,6 +61,10 @@ start_foreground() { echo "服务已在运行 (PID: $(get_pid))" exit 1 fi + if [ -z "$PYTHON" ]; then + echo "未找到可用的 Python 解释器" + exit 1 + fi echo "启动服务(前台模式)..." exec "$PYTHON" app.py } @@ -70,9 +74,12 @@ start_daemon() { echo "服务已在运行 (PID: $(get_pid))" exit 1 fi + if [ -z "$PYTHON" ]; then + echo "未找到可用的 Python 解释器" + exit 1 + fi echo "启动服务(后台守护模式)..." - WEREAD_API_KEY="${WEREAD_API_KEY:-wrk-ZL_oy0woT5yKFaO1jn_15AAA}" \ - nohup "$PYTHON" app.py > "$LOG_FILE" 2>&1 & + nohup "$PYTHON" app.py > "$LOG_FILE" 2>&1 & local pid=$! echo "$pid" > "$PID_FILE" sleep 2 diff --git a/static/css/style.css b/static/css/style.css index 92e4e86..6d475a0 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -748,6 +748,7 @@ body { display: flex; align-items: center; gap: 16px; + flex-wrap: wrap; } .shelf-search { @@ -769,6 +770,7 @@ body { .shelf-refresh-btn { padding: 0 16px; height: 36px; + min-width: 112px; border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: 13px; @@ -781,7 +783,7 @@ body { align-items: center; justify-content: center; gap: 4px; - flex-shrink: 0; + flex: 0 0 112px; white-space: nowrap; } @@ -789,6 +791,15 @@ body { display: inline-block; font-size: 14px; line-height: 1; + flex: 0 0 auto; +} + +.shelf-refresh-btn .btn-text { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 4.5em; + line-height: 1; } .shelf-refresh-btn:hover:not(:disabled) { @@ -801,7 +812,7 @@ body { cursor: wait; } -.shelf-refresh-btn.loading { +.shelf-refresh-btn.is-loading { border-color: var(--accent); color: var(--accent); } @@ -2022,6 +2033,9 @@ body { .shelf-search { width: 140px; } + .shelf-filters { + gap: 10px; + } .shelf-categories { gap: 6px; } diff --git a/static/js/app.js b/static/js/app.js index daf6e68..1f563d2 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -324,7 +324,7 @@ async function refreshShelf() { const btn = document.getElementById("shelfRefreshBtn"); const textEl = btn.querySelector(".btn-text"); btn.disabled = true; - btn.classList.add("loading"); + btn.classList.add("is-loading"); textEl.textContent = "同步中..."; try { @@ -344,7 +344,7 @@ async function refreshShelf() { alert("刷新失败: " + (e.message || "未知错误")); } finally { btn.disabled = false; - btn.classList.remove("loading"); + btn.classList.remove("is-loading"); textEl.textContent = "刷新"; } }