feat: 添加配置读取工具,支持从环境变量和 .env 文件加载 WEREAD_API_KEY

fix: 修复启动脚本中的 Python 解释器路径识别
style: 更新样式以支持按钮最小宽度和灵活布局
docs: 添加项目最终文档,整合各类信息与说明
This commit is contained in:
admin committed 2026-07-17 22:11:27 +08:00
1 parent 3d5a402117
commit 92a48d14cd
9 files changed
+461 -20

No files matched your search

+4
View File
@@ -29,3 +29,7 @@ Thumbs.db
# Env # Env
.env .env
.server.pid .server.pid
# Tool artifacts
.qoder/
.workbuddy/
+14 -8
View File
@@ -20,6 +20,7 @@ import json
import os import os
import ssl import ssl
import subprocess import subprocess
import sys
import time import time
import urllib.request import urllib.request
from datetime import datetime from datetime import datetime
@@ -27,6 +28,8 @@ from functools import wraps
import pymysql import pymysql
from config_utils import get_env_value
try: try:
import certifi import certifi
_ssl_context = ssl.create_default_context(cafile=certifi.where()) _ssl_context = ssl.create_default_context(cafile=certifi.where())
@@ -56,7 +59,6 @@ MYSQL_CONFIG = {
# 微信读书 API 配置 # 微信读书 API 配置
# ====================================================================== # ======================================================================
WEREAD_API_URL = "https://i.weread.qq.com/api/agent/gateway" 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" WEREAD_SKILL_VERSION = "1.0.4"
@@ -154,13 +156,17 @@ def weread_api_call(api_name, **kwargs):
Raises: Raises:
Exception: API 调用失败时抛出异常 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 = {"api_name": api_name, "skill_version": WEREAD_SKILL_VERSION}
body.update(kwargs) body.update(kwargs)
req = urllib.request.Request( req = urllib.request.Request(
WEREAD_API_URL, WEREAD_API_URL,
data=json.dumps(body).encode("utf-8"), data=json.dumps(body).encode("utf-8"),
headers={ headers={
"Authorization": f"Bearer {WEREAD_API_KEY}", "Authorization": f"Bearer {api_key}",
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
method="POST", method="POST",
@@ -1199,14 +1205,14 @@ def api_search(conn):
def sync_data(): def sync_data():
"""运行 fetch_data.py + migrate_to_mysql.py,全量同步数据""" """运行 fetch_data.py + migrate_to_mysql.py,全量同步数据"""
project_dir = os.path.dirname(os.path.abspath(__file__)) project_dir = os.path.dirname(os.path.abspath(__file__))
python_bin = os.path.join( python_bin = sys.executable or "python3"
os.path.expanduser("~"),
".workbuddy/binaries/python/envs/default/bin/python",
)
env = os.environ.copy() 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"]: 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": []} results = {"steps": []}
try: try:
+118
View File
@@ -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
+286
View File
@@ -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/` 继续保留给工具使用即可,但不再视为仓库的正式文档来源。
+9 -4
View File
@@ -26,9 +26,10 @@ import time
import urllib.request import urllib.request
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from config_utils import get_env_value
# ===== 配置 ===== # ===== 配置 =====
API_URL = "https://i.weread.qq.com/api/agent/gateway" API_URL = "https://i.weread.qq.com/api/agent/gateway"
API_KEY = os.environ.get("WEREAD_API_KEY", "")
SKILL_VERSION = "1.0.4" SKILL_VERSION = "1.0.4"
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "weread.db") DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "weread.db")
PROJECT_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -54,13 +55,17 @@ def now_str():
def api_call(api_name, **kwargs): def api_call(api_name, **kwargs):
"""调用微信读书 API""" """调用微信读书 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 = {"api_name": api_name, "skill_version": SKILL_VERSION}
body.update(kwargs) body.update(kwargs)
req = urllib.request.Request( req = urllib.request.Request(
API_URL, API_URL,
data=json.dumps(body).encode("utf-8"), data=json.dumps(body).encode("utf-8"),
headers={ headers={
"Authorization": f"Bearer {API_KEY}", "Authorization": f"Bearer {api_key}",
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
method="POST", method="POST",
@@ -1020,9 +1025,9 @@ def fetch_notebooks(conn):
# ===== 主函数 ===== # ===== 主函数 =====
def main(): def main():
if not API_KEY: if not get_env_value("WEREAD_API_KEY", ""):
print("错误: 请先设置 WEREAD_API_KEY 环境变量") print("错误: 请先设置 WEREAD_API_KEY 环境变量")
print(" export WEREAD_API_KEY=wrk-xxxxxxxx") print(" 可配置在当前终端、项目 .env,或 ~/.zshrc")
return return
print("=" * 60) print("=" * 60)
+1
View File
@@ -1 +1,2 @@
flask>=3.0 flask>=3.0
PyMySQL>=1.1
+11 -4
View File
@@ -7,8 +7,8 @@
# ./start_server.sh restart 重启服务 # ./start_server.sh restart 重启服务
# ./start_server.sh status 查看状态 # ./start_server.sh status 查看状态
PROJECT_DIR="/Users/hwj/WorkBuddy/2026-07-17-09-48-20" PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)"
PYTHON="/Users/hwj/.workbuddy/binaries/python/envs/default/bin/python" PYTHON="${PYTHON:-$(command -v python3 || command -v python)}"
PID_FILE="$PROJECT_DIR/.server.pid" PID_FILE="$PROJECT_DIR/.server.pid"
LOG_FILE="/tmp/weread_server.log" LOG_FILE="/tmp/weread_server.log"
PORT=8080 PORT=8080
@@ -61,6 +61,10 @@ start_foreground() {
echo "服务已在运行 (PID: $(get_pid))" echo "服务已在运行 (PID: $(get_pid))"
exit 1 exit 1
fi fi
if [ -z "$PYTHON" ]; then
echo "未找到可用的 Python 解释器"
exit 1
fi
echo "启动服务(前台模式)..." echo "启动服务(前台模式)..."
exec "$PYTHON" app.py exec "$PYTHON" app.py
} }
@@ -70,9 +74,12 @@ start_daemon() {
echo "服务已在运行 (PID: $(get_pid))" echo "服务已在运行 (PID: $(get_pid))"
exit 1 exit 1
fi fi
if [ -z "$PYTHON" ]; then
echo "未找到可用的 Python 解释器"
exit 1
fi
echo "启动服务(后台守护模式)..." 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=$! local pid=$!
echo "$pid" > "$PID_FILE" echo "$pid" > "$PID_FILE"
sleep 2 sleep 2
+16 -2
View File
@@ -748,6 +748,7 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 16px;
flex-wrap: wrap;
} }
.shelf-search { .shelf-search {
@@ -769,6 +770,7 @@ body {
.shelf-refresh-btn { .shelf-refresh-btn {
padding: 0 16px; padding: 0 16px;
height: 36px; height: 36px;
min-width: 112px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-size: 13px; font-size: 13px;
@@ -781,7 +783,7 @@ body {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 4px; gap: 4px;
flex-shrink: 0; flex: 0 0 112px;
white-space: nowrap; white-space: nowrap;
} }
@@ -789,6 +791,15 @@ body {
display: inline-block; display: inline-block;
font-size: 14px; font-size: 14px;
line-height: 1; 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) { .shelf-refresh-btn:hover:not(:disabled) {
@@ -801,7 +812,7 @@ body {
cursor: wait; cursor: wait;
} }
.shelf-refresh-btn.loading { .shelf-refresh-btn.is-loading {
border-color: var(--accent); border-color: var(--accent);
color: var(--accent); color: var(--accent);
} }
@@ -2022,6 +2033,9 @@ body {
.shelf-search { .shelf-search {
width: 140px; width: 140px;
} }
.shelf-filters {
gap: 10px;
}
.shelf-categories { .shelf-categories {
gap: 6px; gap: 6px;
} }
+2 -2
View File
@@ -324,7 +324,7 @@ async function refreshShelf() {
const btn = document.getElementById("shelfRefreshBtn"); const btn = document.getElementById("shelfRefreshBtn");
const textEl = btn.querySelector(".btn-text"); const textEl = btn.querySelector(".btn-text");
btn.disabled = true; btn.disabled = true;
btn.classList.add("loading"); btn.classList.add("is-loading");
textEl.textContent = "同步中..."; textEl.textContent = "同步中...";
try { try {
@@ -344,7 +344,7 @@ async function refreshShelf() {
alert("刷新失败: " + (e.message || "未知错误")); alert("刷新失败: " + (e.message || "未知错误"));
} finally { } finally {
btn.disabled = false; btn.disabled = false;
btn.classList.remove("loading"); btn.classList.remove("is-loading");
textEl.textContent = "刷新"; textEl.textContent = "刷新";
} }
} }