feat(popup): 重做扩展云同步中心与项目文档

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Developer
2026-03-18 00:28:14 +08:00
parent 5d0611de60
commit a7ff557942
4 changed files with 1080 additions and 125 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
server/node_modules/
.env
*.log
.DS_Store

700
PROJECT_ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,700 @@
# 涩花塘磁力助手 - 项目架构与部署说明
> 更新时间2026-03-14
> 说明:本文档描述当前项目的整体架构、核心数据流、缓存策略、云同步设计、线上部署信息(**不包含任何敏感凭据**),以及当前已知问题与优化方向。
---
## 1. 项目概览
本项目由两部分组成:
1. **Chrome 扩展端**
负责在涩花塘论坛页面中:
- 识别列表页 / 帖子页
- 批量抓取帖子与磁力链接
- 本地缓存帖子、范围快照、页快照、磁力结果
- 展示搜索结果、缓存总览、收藏夹、云同步入口
2. **云端共享缓存服务**
负责:
- 共享线程缓存shared thread cache
- 共享范围缓存shared coverage cache
- 共享页缓存shared page cache
- 覆盖规划coverage planning
- 私有保险柜vault
- 账号注册 / 登录 / 鉴权
---
## 2. 目录结构
### 扩展端根目录
- `manifest.json`Chrome 扩展清单MV3
- `content.js`:内容脚本,挂载 UI、执行抓取、合并缓存、结果展示
- `background.js`Service Worker负责 IndexedDB、本地状态、云接口、跨页请求
- `popup.html` / `popup.js`:扩展独立云同步中心 + 快捷复制入口
### 服务端目录
- `server/package.json`:服务端依赖与脚本
- `server/src/index.js`Fastify 入口
- `server/src/config.js`:环境变量配置
- `server/src/db.js`MySQL 连接池
- `server/src/redis.js`Redis 连接与 JSON 缓存
- `server/src/crypto.js`AES-GCM 加密与哈希
- `server/src/auth.js`:认证辅助逻辑
- `server/src/routes/auth.js`:注册 / 登录 / me / logout
- `server/src/routes/vault.js`:私有保险柜 push / pull
- `server/src/routes/shared-cache.js`:共享缓存查找 / 写入 / coverage planning
- `server/sql/001_init.sql`:数据库初始化表结构
---
## 3. 扩展端架构
### 3.1 页面层content.js
`content.js` 负责:
- 判断当前页面是:
- 列表页forumdisplay / forum-x-y.html / normalthread tbody
- 帖子页thread-xxx / tid=xxx
- 创建浮动面板 UI
- 管理:
- 关键词搜索
- 页码范围
- 速度模式
- 结果列表
- 收藏页
- 缓存页
- 云同步页入口
- 调用后台:
- 读取 / 保存本地缓存
- 读取 / 保存进度状态
- 触发云同步相关动作
### 3.2 后台层background.js
`background.js` 是当前项目的核心调度层,负责:
- IndexedDB 缓存存取
- 云缓存接口调用
- 共享缓存 planning 调用
- 上传抑制hash 去重 + TTL 节流)
- 本地历史缓存回填到云端
- 私有保险柜加解密
- 云账号登录状态维护
### 3.3 本地存储层
当前本地存储分三类:
1. **IndexedDB结构化缓存**
- `threads`
- `coverages`
- `pageCoverages`
2. **chrome.storage.local扩展私有存储**
- 收藏夹
- 搜索历史
- 云同步状态
- 上传元数据去重表
- 进度状态
3. **运行时内存态**
- 当前抓取状态
- session backup 临时状态
---
## 4. 服务端架构
### 4.1 服务端职责
云服务现在不仅是“存数据”,还承担:
- 共享缓存存储
- 去重写入
- coverage planning
- Redis 缓存 planning 结果
- 账号认证
- 私有保险柜同步
### 4.2 路由职责
#### 认证
- `POST /api/auth/register`
- `POST /api/auth/login`
- `GET /api/auth/me`
- `POST /api/auth/logout`
#### 私有保险柜
- `POST /api/vault/push`
- `POST /api/vault/pull`
#### 共享缓存
- `POST /api/shared-cache/threads/lookup`
- `POST /api/shared-cache/threads/upsert`
- `POST /api/shared-cache/coverages/lookup`
- `POST /api/shared-cache/coverages/upsert`
- `POST /api/shared-cache/pages/lookup`
- `POST /api/shared-cache/pages/upsert`
- `POST /api/shared-cache/coverages/plan`
#### 健康检查
- `GET /health`
- `GET /ready`
---
## 5. 本地缓存模型
### 5.1 `threads`
存储唯一帖子级信息:
- `forumKey`
- `threadKey`
- `url`
- `title`
- `lastSeenAt`
- `magnets`
- `lastMagnetSyncAt`
特点:
- 同一板块 + 同一帖子只保留一份
- 线程磁链缓存优先从这里命中
### 5.2 `coverages`
存储范围级快照:
- `forumKey`
- `startPage`
- `endPage`
- `strategy`
- `frontRefreshPages`
- `threadKeys`
特点:
- 表示某个页范围对应的帖子集合
- 用于 exact coverage / shifted coverage / 历史 coverage 碎片复用
### 5.3 `pageCoverages`
存储页级快照:
- `forumKey`
- `page`
- `threadKeys`
特点:
- 用来组装连续页块
- 当前大范围命中优化的重要基础
---
## 6. 云端缓存模型
### 6.1 共享线程缓存 `shared_thread_cache`
- 唯一键:`(forum_key, thread_key)`
- 数据库存储加密AES-GCM
- 用于跨用户复用帖子级磁链结果
### 6.2 共享范围缓存 `shared_coverage_cache`
- 唯一键:`(forum_key, start_page, end_page, strategy)`
- 用于整段范围的覆盖规划
### 6.3 共享页缓存 `shared_page_cache`
- 唯一键:`(forum_key, page)`
- 用于连续页块拼装
### 6.4 私有保险柜 `vault_items`
- 用户级密文存储
- 当前用于:
- 收藏夹
- 搜索历史
- UI 设置
- 进度状态
---
## 7. 抓取流程(当前实际运行路径)
### 7.1 点击“开始获取”后的流程
1. 读取页码范围 / 关键词 / 速度配置
2. 构建 `searchContext`
3. 显示:
- `正在规划缓存:检查本地缓存 / 云端规划 / 复用块...`
4. 调用 `getCachedCoveragePlan(...)`
5. 依次判断:
- exact coverage
- assembled page coverage
- 服务端 planning
- cachedBlocks
- shiftedCoverage
6. 对缺口区间执行 live 抓取
7. 结果与缓存合并后,再写回本地与云端
### 7.2 实际搜索执行路径
主要函数:
- `fetchFromPage(...)`
- `fetchLivePageRange(...)`
- `processCachedThreadBatch(...)`
- `applyCachedMagnetHits(...)`
---
## 8. 去重与上传抑制策略
### 8.1 本地去重
- 线程去重:`normalizeCachedThreads()`
- 磁链去重:`normalizeMagnetList()`
- coverage 合并去重:`mergeCoverageThreads()`
- coverage 保存前合并:`mergeCoverageThreadLists()`
### 8.2 云端去重
数据库唯一索引 + `ON DUPLICATE KEY UPDATE`
### 8.3 上传抑制
本地维护 upload meta
- `threads`
- `coverages`
- `pages`
每类记录:
- `payloadHash`
- `lastUploadedAt`
TTL
- 线程10 分钟
-30 分钟
- 范围60 分钟
作用:
- 降低重复上传
- 降低重复加密
- 降低数据库写入压力
---
## 9. 当前“智能更新”逻辑
### 已实现
1. front refresh从第一页开始时可刷新前段
2. shiftedCoverage 复用
3. page cache block 复用
4. intersecting coverage fragments 复用
5. 服务端 coverage planning
6. Redis 缓存 planning 结果
### 当前仍不够理想的点
1. **thread cache 很多,但未完全反向沉淀成 page/coverage 索引**
这会导致“明明有很多历史线程缓存,但大范围规划不够聪明”。
2. **coverage 合并仍然偏保守**
少量前页更新后,虽然已经补了历史 coverage 合并,但仍建议继续增强“旧帖保留连续性”。
3. **前段刷新策略仍是经验型策略**
当前已从“固定刷新前 20 页”改为“前面没有缓存覆盖才刷新”,但仍属于启发式策略。
4. **启动前规划仍然是同步等待**
现在已经加了状态提示,但未来可以做:
- 本地快速规划优先
- 云端规划异步补充
---
## 10. 线上部署信息(不含敏感凭据)
### 服务器基础信息
- 公网 IP`47.238.173.98`
- 域名:`s.52oai.com`
- 系统CentOS 7当前会话已确认
- 部署方式Nginx + PM2 + Node.js + MySQL + Redis
- 面板宝塔面板BT Panel
### 当前服务端组成
- Nginx反向代理入口
- Node 服务:`magnet-cloud-cache`
- PM2进程守护
- MySQL主数据存储
- Rediscoverage planning 结果缓存
### 线上访问入口
- `https://s.52oai.com/health`
- `https://s.52oai.com/ready`
### Redis 状态
当前已验证:
- Redis 可连接
- 可写入/读取 JSON
- coverage planning key 已落库
---
## 11. 当前安全边界
### 已完成的安全改造
1. 云同步登录/注册 UI 已迁移到扩展独立页面
2. 收藏/搜索历史已迁移到扩展私有存储
3. session 进度备份已不再依赖页面 `sessionStorage`
4. shared-cache 写接口已加写入令牌鉴权
5. shared-cache 已避免旧写覆盖新写
### 仍建议后续加强
1. `vaultKeyBase64` 改为 session-only
2. auth/vault/shared-cache 更细粒度限流
3. 服务端请求体大小再收紧
4. 共享缓存写审计日志
---
## 12. 当前最值得继续优化的方向
### P1 - 正确性优先
1. **thread cache 反向沉淀成 page/coverage 索引**
这是目前提升“大范围缓存命中率”的最高价值项。
2. **coverage 合并策略继续增强**
尤其是前段少量更新后,历史旧帖不应被挤出当前范围搜索。
3. **规划器结果可观测化**
建议增加 debug summary
- 命中 exact coverage
- 命中 page blocks 数
- 命中 intersecting coverage 数
- 实际缺口页数
### P2 - 性能优先
1. 本地快速规划优先,云端 planning 异步补充
2. planning 结果本地短时缓存
3. 回填队列分批化
---
## 12.1 当前抓取合并 / 去重 / 智能更新逻辑复盘
### 一、当前逻辑里已经做对的部分
1. **线程级去重已经明确**
- 本地通过 `threadKey` 去重
- 云端通过 `(forum_key, thread_key)` 唯一约束去重
2. **磁链级去重已经明确**
- 本地通过 `normalizeMagnetList()` 去重
- 云端共享缓存目前按线程保存,磁链集合在单线程内部去重
3. **coverage 规划已经从“只看精确范围”进化为多层来源**
当前 `getCachedCoveragePlan()` 已经综合:
- exact coverage
- assembled page coverage
- 服务端 planning
- cachedBlocks页块
- intersecting coverages相交范围碎片
- shiftedCoverage
4. **上传抑制已经开始发挥作用**
- thread/page/coverage 都有 upload meta
- hash + TTL 可以减少重复上云
### 二、当前逻辑不够理想的核心点
#### 1. 线程缓存与页/范围索引仍然是“半脱节”
这是目前最关键的问题:
- `threads` 里可能已经积累了大量帖子与磁链
- 但这些历史数据没有系统性地回灌成:
- `pageCoverages`
- `coverages`
结果就是:
> 看起来缓存很多,但真正能参与“大范围规划”的缓存块不够多。
#### 2. 智能更新仍然偏启发式,而不是差异驱动
当前 front refresh / shiftedCoverage 还是基于:
- 从第 1 页开始时,倾向刷新前段
- 历史范围快照的最近性
但它缺少真正的:
- 基于“帖子是否变化”的差异判断
- 基于线程游标 / 内容指纹 / 页指纹 的更新模型
#### 3. coverage 合并虽然已经补强,但仍然不够结构化
当前已经补了:
- 保存新 coverage 前,合并历史相交 coverage 的线程
但这个策略仍是:
- 按线程集合合并
- 不是按“真实页结构”或“线程页位置偏移”合并
所以在论坛前段轻微更新时,仍可能出现:
- 范围快照连续性不足
- 旧帖可见性波动
#### 4. 服务端 planning 已加入,但仍然是“辅助规划器”
现在服务端已经能返回:
- `exactCoverage`
- `cachedBlocks`
- `shiftedCoverage`
但扩展端仍保留了大量本地规划逻辑,导致:
- 本地规划与服务端规划并存
- 逻辑复杂度上升
- 调试成本高
### 三、当前最值得优先继续优化的技术方向
#### A. `threads -> pageCoverages / coverages` 反向沉淀
这是正确性和命中率的第一优先级优化。
目标:
- 当系统手里已经有大量 thread cache 时
- 能定期 / 按需将其重建为:
- page coverage block
- coverage fragment
收益:
- “缓存明明很多却命中不上”的问题会明显缓解
#### B. 引入“线程或页面指纹”作为智能更新依据
例如:
- 页面线程列表 hash
- 范围线程集合 hash
- 线程集合版本号
这样 front refresh 就能从“经验刷新”变成:
- 先比对指纹
- 只有前段真的变化才刷新
#### C. 最终把 planning 统一到服务端主导
建议目标不是“双规划器”,而是:
- 服务端负责 coverage planning
- 扩展端只负责执行计划
- 本地逻辑只保留兜底回退
这样架构会明显更稳。
### P3 - 运维优先
1. 共享缓存保留策略 / 归档策略
2. 写入审计与异常监控
3. 密钥轮换机制
---
## 13. 当前版本的整体评价
### 已经具备的能力
- 本地线程 / 范围 / 页缓存
- 云端共享缓存
- 私有保险柜
- 上传抑制与 TTL 节流
- coverage planning + Redis planning cache
- 扩展端 UI / 云同步中心 / 收藏 / 搜索历史
### 当前最核心的短板
> 缓存数据已经很多,但“如何把这些缓存组织成最优复用计划”仍然不够强。
也就是说,当前系统最难的不是“存缓存”,而是:
> **把已有缓存规划成正确、完整、连续的搜索路径**
---
## 14. 文档使用建议
如果后续你继续迭代项目,建议把本文档当成:
1. **新人接手说明**
2. **线上部署说明**
3. **缓存/同步逻辑总览**
4. **后续重构路线图**
---
## 15. 敏感信息说明
本文档**不记录**以下信息:
- SSH 密码
- MySQL 密码
- write token
- 加密密钥
- `.env` 明文内容
这些信息应只保留在安全的部署环境与秘密管理系统中。
---
## 16. Chrome 分发与更新能力边界
结合当前平台限制和架构评估,下面是**真实可行**与**不可行**的边界。
### 16.1 对普通 Chrome 用户,不可直接做到的事
以下能力在普通非企业托管 Chrome 中,**不现实或不受支持**
1. **从网站自动把扩展装进 Chrome**
2. **通过主页密码后,静默安装 CRX**
3. **普通用户使用自托管 CRX 自动更新**
也就是说:
> `s.52oai.com` 可以做“分发引导页”,但不能真正实现“输密码后自动装到 Chrome 里”。
### 16.2 最现实的分发方式
对于普通用户,最推荐:
1. **发布到 Chrome Web Store建议 unlisted**
2. `s.52oai.com` 做密码门禁页
3. 门禁通过后展示:
- CWS 安装链接
- 安装说明
- 使用说明
这样可以获得:
- 官方安装路径
- Chrome 自动更新
- 最低用户支持成本
### 16.3 自托管更新什么时候才成立
只有在以下场景,自托管更新才是现实方案:
- **企业托管 Chrome**
- 管理员通过策略安装
- 使用企业设备 / 组织统一管理浏览器
这不是普通公网用户场景。
### 16.4 当前项目建议的分发策略
如果未来要正式分发,推荐顺序:
1. **Chrome Web Store最好 Unlisted**
2. `s.52oai.com` 做密码门禁与引导页
3. 扩展内做“最低支持版本”检查
4. 如果版本太低,则提示用户去商店更新
### 16.5 站点密码的真实作用
主页密码(例如 `123456`)只能充当:
- 引导门槛
- 简单访问控制
- 降低无关访问
但它**不能视为真正安全边界**。真正的权限控制仍然必须放在:
- 后端账号系统
- API 鉴权
- 服务端授权逻辑
---
## 16. Chrome 分发与更新能力边界(部署到主页的现实约束)
### 16.1 对普通 Chrome 用户,不能直接做到的事
对于**普通、非企业托管**的 Chrome 用户,当前不支持:
1.`s.52oai.com` 网页直接自动安装扩展
2. 从自有网站静默安装 CRX
3. 对普通用户使用自托管 CRX 做自动更新
也就是说:
> 网站可以做“入口页 / 密码门禁页 / 引导页”,但不能真正代替 Chrome 官方安装渠道完成自动安装。
### 16.2 对普通用户最现实的方案
最可行方案是:
1. 发布到 **Chrome Web Store建议 Unlisted**
2. `s.52oai.com` 作为密码门禁页
3. 输入密码后展示:
- Chrome Web Store 安装链接
- 使用说明
- 更新说明
这样可以获得:
- 标准安装体验
- Chrome 自动更新
- 最低的用户支持成本
### 16.3 自托管自动更新什么时候可行
只有在:
- 企业托管 Chrome
- 管理员策略安装
- 受管设备/受管浏览器环境
这种情况下,自托管 CRX + update manifest 才是现实路径。
### 16.4 当前项目推荐的分发模式
推荐顺序:
1. **Chrome Web StoreUnlisted** 作为主分发渠道
2. `s.52oai.com` 做密码门禁 + 安装引导页
3. 扩展内调用服务端 `/ext/config`(后续可实现)检查最低支持版本
4. 如果版本过低,提示用户跳转到商店升级
### 16.5 密码门禁的真实作用
密码门禁只能起到:
- 隐藏安装入口
- 控制谁能看到链接
但它**不能视为真正的安全边界**。真正的权限控制必须放在:
- 后端账号体系
- API token / 登录态
- 服务器侧权限判断

View File

@@ -2,7 +2,7 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<link href="https://fonts.googleapis.com/css2?family=Rajdhani:wght@500;600;700&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet">
<title>磁力助手</title>
<style>
:root {
--m-bg-deep: #0a0e14;
@@ -10,124 +10,130 @@
--m-bg-secondary: #1a1f2e;
--m-bg-card: rgba(26, 31, 46, 0.92);
--m-accent: #00d4aa;
--m-accent-glow: rgba(0, 212, 170, 0.4);
--m-accent-2: #00f5c4;
--m-danger: #ef4444;
--m-text-primary: #f0f4f8;
--m-text-secondary: #8892a4;
--m-text-muted: #5c6578;
--m-border: rgba(255, 255, 255, 0.06);
--m-border-accent: rgba(0, 212, 170, 0.3);
--m-font-display: "Rajdhani", "Microsoft YaHei", sans-serif;
--m-font-body: "Noto Sans SC", "Microsoft YaHei", sans-serif;
--m-radius-md: 12px;
--m-border: rgba(255,255,255,0.08);
--m-radius: 12px;
}
* { box-sizing: border-box; }
body {
width: 260px;
padding: 20px;
margin: 0;
min-width: 320px;
background: var(--m-bg-primary);
font-family: var(--m-font-body);
color: var(--m-text-primary);
font: 13px/1.5 "Microsoft YaHei", sans-serif;
padding: 18px;
}
.popup-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
.header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom:16px; }
.title { font-size: 18px; font-weight: 700; color: var(--m-accent); }
.subtitle { font-size: 11px; color: var(--m-text-secondary); }
.header-status { display:flex; flex-direction:column; align-items:flex-end; gap:6px; min-width:120px; }
.header-status .status { margin-top:0; }
.card {
background: var(--m-bg-card);
border: 1px solid var(--m-border);
border-radius: 16px;
padding: 14px;
margin-bottom: 12px;
}
.popup-icon {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(0, 212, 170, 0.2) 0%, rgba(0, 212, 170, 0.1) 100%);
border: 1px solid var(--m-border-accent);
border-radius: 10px;
color: var(--m-accent);
}
.popup-icon svg {
width: 20px;
height: 20px;
}
.popup-title {
font-family: var(--m-font-display);
font-size: 18px;
font-weight: 700;
letter-spacing: 1px;
background: linear-gradient(135deg, var(--m-accent) 0%, #00f5c4 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.popup-desc {
font-size: 12px;
color: var(--m-text-secondary);
margin: 0 0 16px;
line-height: 1.5;
}
button {
.card-title { font-size: 13px; font-weight: 700; margin-bottom: 8px; }
.meta { color: var(--m-text-secondary); font-size: 12px; }
.row { display:flex; gap:8px; margin-top:10px; }
.col { display:flex; flex-direction:column; gap:10px; }
input {
width: 100%;
padding: 12px 16px;
background: linear-gradient(135deg, var(--m-accent) 0%, #00f5c4 100%);
color: var(--m-bg-deep);
padding: 10px 12px;
border-radius: var(--m-radius);
border: 1px solid var(--m-border);
background: var(--m-bg-secondary);
color: var(--m-text-primary);
outline: none;
}
input:focus { border-color: var(--m-accent); }
button {
border: none;
border-radius: var(--m-radius-md);
border-radius: var(--m-radius);
padding: 10px 12px;
cursor: pointer;
font-family: var(--m-font-display);
font-size: 14px;
font-weight: 700;
letter-spacing: 0.5px;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 212, 170, 0.4);
}
button:active {
transform: translateY(0);
}
.popup-footer {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--m-border);
text-align: center;
}
.popup-footer-text {
font-size: 10px;
color: var(--m-text-muted);
letter-spacing: 0.5px;
}
.primary { background: linear-gradient(135deg, var(--m-accent), var(--m-accent-2)); color: var(--m-bg-deep); }
.secondary { background: var(--m-bg-secondary); color: var(--m-text-primary); border: 1px solid var(--m-border); }
.danger { background: rgba(239,68,68,0.16); color: #ff8585; border: 1px solid rgba(239,68,68,0.25); }
.status { display:flex; align-items:center; gap:8px; margin-top:8px; }
.dot { width: 8px; height: 8px; border-radius:50%; background: var(--m-danger); box-shadow: 0 0 8px rgba(239,68,68,.35); }
.dot.ok { background:#10b981; box-shadow:0 0 8px rgba(16,185,129,.35); }
.small { font-size: 11px; color: var(--m-text-muted); }
.hidden { display:none; }
</style>
</head>
<body>
<div class="popup-header">
<div class="popup-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" role="img" aria-label="Magnet">
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>
</svg>
<div class="header">
<div>
<div class="title">MAGNET CLOUD</div>
<div class="subtitle">扩展独立云同步中心</div>
</div>
<div class="header-status">
<div class="status"><span id="accountDot" class="dot"></span><span id="accountText" class="meta">账号状态加载中</span></div>
<div class="status"><span id="serverDot" class="dot"></span><span id="serverText" class="meta">服务器状态加载中</span></div>
<div id="syncEmail" class="small"></div>
</div>
<div class="popup-title">MAGNET</div>
</div>
<p class="popup-desc">提取当前页面的磁力链接并复制到剪贴板</p>
<button id="copyBtn" type="button">复制当前页磁力链接</button>
<div class="popup-footer">
<span class="popup-footer-text">仅支持涩花塘论坛页面</span>
<div class="card col">
<div class="card-title">云端状态看板</div>
<div id="cloudStatsSummary" class="meta">正在读取云端统计...</div>
<div id="cloudStatsLatest" class="small"></div>
<div id="cloudStatsTables" class="small"></div>
</div>
<div class="card col">
<div class="card-title">本地状态看板</div>
<div id="localStatsSummary" class="meta">正在读取本地统计...</div>
<div id="localStatsStorage" class="small"></div>
<div id="localStatsMeta" class="small"></div>
</div>
<div id="authLoggedOut" class="card col">
<div class="card-title">登录 / 注册</div>
<div class="meta">账号密码只会在扩展页面中输入,不再放在网页里。</div>
<input id="emailInput" type="text" placeholder="邮箱">
<input id="passwordInput" type="password" placeholder="密码至少6位">
<div class="row">
<button id="loginBtn" class="primary" type="button">登录</button>
<button id="registerBtn" class="secondary" type="button">注册</button>
</div>
</div>
<div id="authLoggedIn" class="card col hidden">
<div class="card-title">已登录</div>
<div id="loggedInEmail" class="meta"></div>
<div class="row">
<button id="syncNowBtn" class="primary" type="button">立即同步</button>
<button id="logoutBtn" class="danger" type="button">退出登录</button>
</div>
<div id="syncProgressText" class="small"></div>
</div>
<div class="card col">
<div class="card-title">下载器推荐</div>
<div class="meta">如果点击磁力“下载”按钮没有反应请先安装本地下载器。qBittorrent 更适合磁力资源Motrix 更适合偏轻量的使用习惯。</div>
<div class="row">
<button id="downloadNode2Btn" class="primary" type="button">qB 节点2推荐</button>
<button id="downloadOfficialBtn" class="secondary" type="button">qB 官方节点</button>
</div>
<div class="row">
<button id="downloadMotrixBtn" class="secondary" type="button">Motrix 下载</button>
</div>
<div class="small">安装完成后,请确保 qBittorrent 已关联 magnet 磁力链接协议。</div>
</div>
<div id="message" class="small"></div>
<script src="popup.js"></script>
</body>
</html>

298
popup.js
View File

@@ -1,33 +1,277 @@
document.getElementById('copyBtn').addEventListener('click', function() {
chrome.tabs.query({ active: true, currentWindow: true }, function(tabs) {
var activeTab = tabs && tabs[0];
if (!activeTab || typeof activeTab.id !== 'number') {
alert('未找到当前标签页');
return;
}
chrome.tabs.sendMessage(activeTab.id, { action: 'getMagnets' }, function(response) {
function sendMessageToBackground(message) {
return new Promise(function(resolve, reject) {
chrome.runtime.sendMessage(message, function(response) {
if (chrome.runtime.lastError) {
alert('当前页面不支持,请在涩花塘论坛页面使用');
reject(new Error(chrome.runtime.lastError.message));
return;
}
var magnets = response && Array.isArray(response.magnets) ? response.magnets : [];
magnets = Array.from(new Set(magnets));
if (magnets.length === 0) {
alert('当前页面未找到磁力链接');
return;
}
navigator.clipboard.writeText(magnets.join('\n'))
.then(function() {
alert('已复制 ' + magnets.length + ' 个磁力链接!');
})
.catch(function(err) {
var errorMsg = err && err.message ? err.message : '复制失败';
alert('复制失败:' + errorMsg);
});
resolve(response || null);
});
});
}
function setMessage(text, isError) {
var message = document.getElementById('message');
if (!message) return;
message.textContent = text || '';
message.style.color = isError ? '#ff8585' : '#8892a4';
}
var lastGoodCloudStats = null;
var lastGoodSyncStatus = null;
var syncProgressTimer = null;
var lastServerHealthy = null;
function wait(ms) {
return new Promise(function(resolve) {
setTimeout(resolve, ms);
});
}
function setSyncProgress(text) {
var el = document.getElementById('syncProgressText');
if (!el) return;
el.textContent = text || '';
}
function startSyncProgress(prefix) {
var startedAt = Date.now();
var dots = 0;
if (syncProgressTimer) {
clearInterval(syncProgressTimer);
}
setSyncProgress((prefix || '正在同步') + ' 0s');
syncProgressTimer = setInterval(function() {
dots = (dots + 1) % 4;
var suffix = new Array(dots + 1).join('.');
var elapsed = Math.max(0, Math.floor((Date.now() - startedAt) / 1000));
setSyncProgress((prefix || '正在同步') + suffix + ' ' + elapsed + 's');
}, 500);
}
function stopSyncProgress(text) {
if (syncProgressTimer) {
clearInterval(syncProgressTimer);
syncProgressTimer = null;
}
setSyncProgress(text || '');
}
function updateStatus(status) {
if (status && status.authenticated !== undefined) {
lastGoodSyncStatus = status;
}
var accountDot = document.getElementById('accountDot');
var accountText = document.getElementById('accountText');
var email = document.getElementById('syncEmail');
var loggedOut = document.getElementById('authLoggedOut');
var loggedIn = document.getElementById('authLoggedIn');
var loggedInEmail = document.getElementById('loggedInEmail');
var healthy = !!(status && status.color === 'green');
var authenticated = !!(status && status.authenticated);
if (accountDot) accountDot.classList.toggle('ok', healthy);
if (accountText) accountText.textContent = status && (status.accountText || status.text) ? (status.accountText || status.text) : '账号状态未知';
if (email) email.textContent = status && status.email ? ('账号:' + status.email) : '';
if (loggedOut) loggedOut.classList.toggle('hidden', authenticated);
if (loggedIn) loggedIn.classList.toggle('hidden', !authenticated);
if (loggedInEmail) loggedInEmail.textContent = status && status.email ? ('当前账号:' + status.email) : '当前账号:未知';
}
function updateServerStatus(ok, text) {
var serverDot = document.getElementById('serverDot');
var serverText = document.getElementById('serverText');
lastServerHealthy = !!ok;
if (serverDot) serverDot.classList.toggle('ok', !!ok);
if (serverText) serverText.textContent = text || (ok ? '服务器状态正常' : '服务器状态异常');
}
function formatDateTime(value) {
if (!value) return '暂无';
try {
var date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleString('zh-CN');
} catch (error) {
return String(value);
}
}
function renderCloudStats(stats) {
var summary = document.getElementById('cloudStatsSummary');
var latest = document.getElementById('cloudStatsLatest');
var tables = document.getElementById('cloudStatsTables');
if (!summary || !latest || !tables) return;
if (!stats || !stats.ok) {
if (lastGoodCloudStats && lastGoodCloudStats.ok) {
updateServerStatus(true, '服务器状态正常(展示最近一次成功数据)');
summary.textContent = '云端状态暂时刷新失败,显示最近一次成功数据';
latest.textContent = '最近成功更新:线程 ' + formatDateTime(lastGoodCloudStats.latest.threads) + ' 范围 ' + formatDateTime(lastGoodCloudStats.latest.coverages) + ' 页缓存 ' + formatDateTime(lastGoodCloudStats.latest.pages);
tables.textContent = '上次空间占用:' + (Array.isArray(lastGoodCloudStats.tables) ? lastGoodCloudStats.tables.map(function(table) {
return table.tableName + ' ' + table.sizeMb + 'MB';
}).join(' ') : '暂无');
return;
}
updateServerStatus(false, '服务器状态暂时不可读');
summary.textContent = '暂时无法读取云端状态';
latest.textContent = '';
tables.textContent = '';
return;
}
updateServerStatus(true, '服务器状态正常');
lastGoodCloudStats = stats;
summary.textContent = '帖子索引 ' + stats.counts.threads + ' 条(含磁力 ' + (stats.counts.magnetThreads || 0) + ' 条)/ 页缓存 ' + stats.counts.pages + ' 条 / 范围缓存 ' + stats.counts.coverages + ' 条 / 用户 ' + stats.counts.users + ' 个 / 保险柜 ' + stats.counts.vaultItems + ' 条';
latest.textContent = '最近更新:线程 ' + formatDateTime(stats.latest.threads) + ' 范围 ' + formatDateTime(stats.latest.coverages) + ' 页缓存 ' + formatDateTime(stats.latest.pages);
tables.textContent = '空间占用:' + (Array.isArray(stats.tables) ? stats.tables.map(function(table) {
return table.tableName + ' ' + table.sizeMb + 'MB';
}).join(' ') : '暂无');
}
function formatBytes(bytes) {
var value = Number(bytes || 0);
if (!Number.isFinite(value) || value <= 0) return '0 B';
if (value < 1024) return value + ' B';
if (value < 1024 * 1024) return (value / 1024).toFixed(1) + ' KB';
if (value < 1024 * 1024 * 1024) return (value / 1024 / 1024).toFixed(2) + ' MB';
return (value / 1024 / 1024 / 1024).toFixed(2) + ' GB';
}
function renderLocalStats(stats) {
var summary = document.getElementById('localStatsSummary');
var storage = document.getElementById('localStatsStorage');
var meta = document.getElementById('localStatsMeta');
if (!summary || !storage || !meta) return;
if (!stats || !stats.ok) {
summary.textContent = '暂时无法读取本地状态';
storage.textContent = '';
meta.textContent = '';
return;
}
summary.textContent = '帖子索引 ' + stats.counts.threads + ' 条(含磁力 ' + (stats.counts.magnetThreads || 0) + ' 条)/ 页缓存 ' + stats.counts.pages + ' 条 / 范围缓存 ' + stats.counts.coverages + ' 条 / 收藏 ' + stats.counts.favorites + ' 条 / 历史 ' + stats.counts.history + ' 条';
storage.textContent = '本地存储占用:' + formatBytes(stats.storage.usage) + ' / ' + formatBytes(stats.storage.quota);
meta.textContent = '上传抑制元数据:线程 ' + stats.counts.uploadMetaThreads + ' 条 范围 ' + stats.counts.uploadMetaCoverages + ' 条 页 ' + stats.counts.uploadMetaPages + ' 条';
}
async function refreshStatus() {
try {
var response = await sendMessageToBackground({ action: 'cloudGetSyncStatus' });
if (response && response.ok !== false) {
updateStatus(response || { color: 'red', text: '状态未知', authenticated: false });
return;
}
if (lastGoodSyncStatus) {
updateStatus(lastGoodSyncStatus);
setMessage('云同步状态暂时刷新失败,显示最近一次成功状态', true);
return;
}
updateStatus({ color: 'red', text: '云同步异常', authenticated: false, email: '' });
} catch (error) {
if (lastGoodSyncStatus) {
updateStatus(lastGoodSyncStatus);
setMessage('云同步状态暂时刷新失败,显示最近一次成功状态', true);
return;
}
updateStatus({ color: 'red', text: '云同步异常', authenticated: false, email: '' });
setMessage('状态获取失败:' + error.message, true);
}
}
async function refreshCloudStats() {
var attempts = 0;
var response = null;
var error = null;
for (attempts = 0; attempts < 3; attempts++) {
try {
response = await sendMessageToBackground({ action: 'cloudGetCacheStats' });
if (response && response.ok) {
renderCloudStats(response);
return;
}
} catch (err) {
error = err;
}
await wait(500 * (attempts + 1));
}
renderCloudStats(null);
if (error) {
setMessage('云端状态暂时刷新失败,稍后会自动重试', true);
}
}
async function refreshLocalStats() {
try {
var response = await sendMessageToBackground({ action: 'localGetCacheStats' });
renderLocalStats(response);
} catch (error) {
renderLocalStats(null);
}
}
async function submitAuth(action) {
var emailInput = document.getElementById('emailInput');
var passwordInput = document.getElementById('passwordInput');
var email = emailInput ? emailInput.value.trim() : '';
var password = passwordInput ? passwordInput.value : '';
if (!email || !password) {
setMessage('请输入邮箱和密码', true);
return;
}
setMessage(action === 'cloudRegister' ? '正在注册...' : '正在登录...', false);
try {
var response = await sendMessageToBackground({ action: action, email: email, password: password });
if (!response || !response.ok) {
setMessage((action === 'cloudRegister' ? '注册失败:' : '登录失败:') + (response && response.error ? response.error : '未知错误'), true);
return;
}
if (passwordInput) passwordInput.value = '';
updateStatus(response.status || { color: 'green', text: '云同步正常', authenticated: true, email: email });
setMessage(action === 'cloudRegister' ? '注册成功,云同步已开启' : '登录成功,云同步已开启', false);
} catch (error) {
setMessage((action === 'cloudRegister' ? '注册失败:' : '登录失败:') + error.message, true);
}
}
async function syncNow() {
setMessage('', false);
startSyncProgress('正在同步本地缓存到云端');
try {
await sendMessageToBackground({ action: 'cloudBackfillLocalCache', force: true });
stopSyncProgress('已提交同步任务,正在刷新状态...');
await wait(1200);
startSyncProgress('正在刷新云端状态');
await refreshStatus();
await refreshCloudStats();
await refreshLocalStats();
stopSyncProgress('同步完成');
setMessage('已触发云端回填', false);
} catch (error) {
stopSyncProgress('同步失败');
setMessage('同步失败:' + error.message, true);
}
}
async function logout() {
try {
await sendMessageToBackground({ action: 'cloudLogout' });
await refreshStatus();
setMessage('已退出登录', false);
} catch (error) {
setMessage('退出失败:' + error.message, true);
}
}
function openExternalUrl(url) {
chrome.tabs.create({ url: url });
}
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('loginBtn').addEventListener('click', function() { submitAuth('cloudLogin'); });
document.getElementById('registerBtn').addEventListener('click', function() { submitAuth('cloudRegister'); });
document.getElementById('syncNowBtn').addEventListener('click', syncNow);
document.getElementById('logoutBtn').addEventListener('click', logout);
document.getElementById('downloadNode2Btn').addEventListener('click', function() { openExternalUrl('http://7.haory.cn/x/x72/qBittorrent_5.1.4_x64_setup.exe'); });
document.getElementById('downloadOfficialBtn').addEventListener('click', function() { openExternalUrl('https://sourceforge.net/projects/qbittorrent/files/qbittorrent-win32/qbittorrent-5.1.4/qbittorrent_5.1.4_x64_setup.exe/download'); });
document.getElementById('downloadMotrixBtn').addEventListener('click', function() { openExternalUrl('http://7.haory.cn/x/x72/Motrix-Setup-1.8.19.exe'); });
refreshStatus();
refreshCloudStats();
refreshLocalStats();
});