Compare commits

..

14 Commits

Author SHA1 Message Date
timaa 4b437c34c6 chore: version.ts同步更新至v0.1.1 2026-05-15 18:44:17 +08:00
timaa cf965bcfee fix: getStorageInfo快速估算(3s)+后台全量遍历校准; quark-cleanup去kps依赖; QuarkDriver支持回调 2026-05-15 18:31:52 +08:00
timaa dfcdddabad chore: 统一版本号为v0.1.1 2026-05-15 18:25:42 +08:00
timaa 4b9bcd7a96 fix: cookie加密后cloud.service.ts未解密导致API 401; 更新版本号至2.1.1/1.1.9 2026-05-15 18:23:40 +08:00
timaa a12fec4d82 fix: 警示文件夹创建到转存目录内而非根目录
- createWarningDirectories 加 parentDirFid 参数
- createSingleDir 加 pdirFid 参数, pdir_fid 用传参而非硬编码 0
- runAdCleanup 传入 savedDirFid
- listDirAllPages 列出目标目录而非根目录
2026-05-15 07:05:58 +08:00
timaa 1c0c024b9a feat: 转存记录记录文件大小, 详情展示使用账号+文件大小+时间格式
- quark/baidu 驱动 saveFromShare 返回 fileSize 总大小
- cloud.service.ts 写入 file_size 字段(非null时转字符串)
- 详情页新增文件大小展示(formatFileSize 自动格式化)
- 详情页时间改为 formatTime(yyyy-MM-dd HH:mm:ss)
- SaveRecords 时间格式: 05-15 → 2026-05-15
2026-05-15 07:05:03 +08:00
timaa 359e15a82d fix: save_records JOIN 查询列名歧义, 加 sr. 前缀
- getRecords JOIN cloud_configs 后 WHERE 列需加 sr. 前缀
- 不带日期筛选时不触发 (srWhere 为空字符串)
- summaryRows 查询也用 srWhere 但去掉 sr. 前缀 (查询 save_records 不需要)
- 详情补充文件大小/使用账号/耗时
2026-05-15 06:57:50 +08:00
timaa b7702d0285 feat: 同IP默认账号配额改为 primaryCount × 2
- 动态计算该类型默认账号数量 primaryCount
- 前 primaryCount × 2 次在同IP的两个默认账号间轮询
- 超限后再去其他非默认账号
- 无默认账号时 threshold=0, 直接走轮询
2026-05-15 06:51:15 +08:00
timaa 37aa05b1e1 revert: 去掉资源历史查询逻辑, 保留纯IP+is_primary分配
- 网盘分享按新资源奖励机制, 不需追资源转存历史
- getAndValidateCredential 去掉 shareUrl 参数
- 保留 save_records.config_id 字段(仅用于日志排查)
2026-05-15 06:48:23 +08:00
timaa 329256bd33 fix: 转存时先查资源历史, 复用原账号; save_records加config_id
- 资源维度优先级 > IP维度: 先查share_url是否被转存过
- save_records 表新增 config_id 字段 + 写入时记录
- cloud.service.ts 所有 INSERT 写入 config.id
- credential.service.ts: getAndValidateCredential 加 shareUrl 参数
- 数据库 migration: config_id 到 save_records
2026-05-15 06:45:48 +08:00
timaa 58caaae37a feat: 网盘设置加「默认账号」列, 同类型最多2个主账号
- 数据库 migration: cloud_configs 加 is_primary 字段
- 后端: togglePrimary API (PUT /admin/cloud-configs/:id/primary)
- 后端: getAndValidateCredential 优先选 is_primary 账号
- 前端: CloudConfig.vue 转存启用后加「默认账号」开关列
- 前端: api/index.ts 加 setPrimary 方法
2026-05-15 06:39:08 +08:00
timaa abd0cb26f5 feat: IP-based daily account priority for save
- Added ip_daily_save_counts table to track per-IP daily usage

 - getAndValidateCredential now accepts ipAddress parameter

 - First 2 saves by same IP use primary account; 3rd+ round-robins to other accounts

 - Counter increments on each successful credential acquisition

 - All timestamps in Asia/Shanghai timezone
2026-05-15 06:19:04 +08:00
timaa e046514fee feat: icons moved to repo-managed PNG files
- Convert all brand icons (aliyun/baidu/quark/115/tianyi/123pan/uc/xunlei/pikpak) to 64x64 PNG

 - CLOUD_ICONS now references /icons/xxx.png instead of data URI

 - Backend cloud-types.service.ts synchronized

 - Added magnet/ed2k/others placeholder icons

 - .gitignore allows public/icons/
2026-05-15 06:01:49 +08:00
timaa d83225d736 chore: initial commit - CloudSearch v0.0.2 2026-05-15 05:50:50 +08:00
718 changed files with 16660 additions and 41065 deletions
Executable
+51
View File
@@ -0,0 +1,51 @@
# ============================================================
# CloudSearch 环境变量配置示例 (v2.1 优化版)
# 复制此文件为 .env 并修改对应值
# ============================================================
# --- 必需:管理员账号(请务必修改默认密码)---
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me-to-a-strong-password
# --- 必需:JWT 密钥(使用随机值,勿用默认值)---
# 生成命令: openssl rand -hex 32
JWT_SECRET=change-me-to-a-random-64-char-hex-string
# --- 推荐:Cookie 加密密钥(保护网盘凭证)---
# 生成命令: openssl rand -hex 32
# 不设置则每次重启生成临时密钥(旧数据不可解密)
COOKIE_ENCRYPTION_KEY=change-me-to-a-random-key
# --- CORS 访问控制(生产环境必须设置真实域名)---
# 示例: CORS_ORIGIN=https://search.example.com
CORS_ORIGIN=https://your-production-domain.com
# --- Redis 连接(可选,无 Redis 时自动降级)---
REDIS_URL=redis://redis:6379
# --- PanSou 搜索引擎地址(容器内无需修改)---
PANSOU_URL=http://pansou:80
PANSOU_AUTH_TOKEN=
# --- 视频解析服务地址(容器内无需修改)---
VIDEO_PARSER_URL=http://video-parser:3001
# --- 链接验证配置 ---
VALIDATION_CONCURRENCY=10
VALIDATION_TIMEOUT_MS=5000
VALIDATION_CACHE_TTL_VALID=600
VALIDATION_CACHE_TTL_INVALID=1800
# --- 日志级别 (debug|info|warn|error) ---
LOG_LEVEL=info
# --- 可选:TMDB API Token(用于影视元数据)---
# 注册: https://www.themoviedb.org/settings/api
# TMDB_API_TOKEN=
# --- 可选:代理设置 ---
# HTTP_PROXY=http://proxy:8080
# HTTPS_PROXY=http://proxy:8080
# --- 站点访问地址(用于 CORS 和 SEO)---
SITE_URL=https://your-production-domain.com
-35
View File
@@ -1,35 +0,0 @@
# CloudSearch v2.1.0 — 功能开关
# true=启用 false=禁用 改完重启生效
# ===== 核心开关 =====
FEATURE_QUARK_PID=true
FEATURE_SEO=true
FEATURE_LINK_MONITOR=true
# ===== 增强功能 =====
FEATURE_TMDB=true
FEATURE_TELEGRAM_BOT=false
FEATURE_SUBSCRIPTION=false
FEATURE_ALIST=false
# ===== 转存平台 =====
FEATURE_TRANSFER_QUARK=true
FEATURE_TRANSFER_BAIDU=false
FEATURE_TRANSFER_ALIYUN=false
FEATURE_TRANSFER_UC=false
FEATURE_TRANSFER_XUNLEI=false
FEATURE_TRANSFER_PAN115=false
FEATURE_TRANSFER_PAN123=false
FEATURE_TRANSFER_CLOUD189=false
# ===== 转存凭证 (仅启用的平台需要填) =====
QUARK_COOKIE=
BAIDU_COOKIE=
ALIYUN_REFRESH_TOKEN=
UC_COOKIE=
XUNLEI_REFRESH_TOKEN=
# ===== TMDB / Bot =====
TMDB_API_KEY=
TG_BOT_TOKEN=
FEISHU_WEBHOOK=
+24 -9
View File
@@ -1,12 +1,27 @@
.env
*.sqlite
*.sqlite-shm
*.sqlite-wal
uploads/
__pycache__/
*.pyc
.DS_Store
node_modules/
dist/
.env
.h5/
*.log
.DS_Store
.vite/
*.bak
*.bak2
*.bak3
*.tar.gz
*.zip
*.tar
deploy.sh
backup.sh
check.sh
icons/
!/packages/frontend/public/icons/
scripts/
Dockerfile
Dockerfile.bak
.dockerignore
docker-compose.yml
docker-compose.yml.bak
pansou-web-latest.tar.gz
video-parser-latest.tar.gz
cloudsearch-app-v2.0.26.tar.gz
uploads/
-4
View File
@@ -1,4 +0,0 @@
version=0.5.6
source=restored-cloudsearch-existing-source
updated_at=2026-05-23T19:14:59+08:00
backup=/root/cloudsearch_deploy_backups/source_clean_before_d7e1d90_20260523-191331.tgz
Regular → Executable
+23 -236
View File
@@ -1,245 +1,32 @@
# CloudSearch · 自托管云盘资源搜索引擎
# CloudSearch — 网盘搜索 + 视频解析一站式平台
**CloudSearch** 是一个自托管的网盘资源搜索引擎。支持多网盘(夸克、百度、阿里云盘等)Cookie 接入、关键词搜索、一键转存、多通道消息推送、每日数据汇报。
> 🏷️ 当前版本:**v0.3.32** · 活跃开发中
> 🔒 私有项目 — [Gitea 镜像](https://gitea.timxx.cn/admin/CloudSearch)
---
## ✨ 主要特性
- 🔍 **多源搜索** — 聚合 PanSou + 内置搜索引擎,毫秒级返回
- ☁️ **多云盘接入** — 支持夸克、百度网盘、阿里云盘、115、UC、迅雷、天翼云、PikPak、123 云盘
- 📦 **一键转存** — 搜索结果直接转存到已连接的网盘
- 📊 **管理面板** — 网盘管理、搜索统计、转存记录、系统配置
- 🔔 **14 通道通知** — 飞书/Lark、Telegram、钉钉、企业微信、Discord、Bark、Server酱、PushPlus、Gotify、ntfy、SMTP 邮件、Qmsg酱、自定义 Webhook
- 📈 **每日汇报** — 定时统计搜索/转存/用户数据,自动推送到通知通道
- 🧹 **自动清理** — Cookie 到期检测、空间自动清理、关键词审核
- 🎬 **TMDB 增强** — 搜索结果自动匹配影视元数据(海报、评分、简介)
---
## 📋 前置要求
| 软件 | 最低版本 | 说明 |
|------|---------|------|
| Docker | 24.0+ | 构建和运行容器 |
| Docker Compose | v2.20+ | 编排多服务 |
| Git | 2.40+ | 克隆仓库 |
---
## 🚀 快速部署(3 步)
### 1. 克隆仓库
## 快速部署
```bash
git clone https://gitea.timxx.cn/admin/CloudSearch.git
cd CloudSearch
```
# 1. 解压
tar xzf cloudsearch-deploy.tar.gz
cd cloudsearch
### 2. 配置环境变量
# 2. 配置环境变量
cp .env.example .env
# 自动生成 JWT Secret
sed -i "s/change-me-to-a-random-64-char-hex-string/$(openssl rand -hex 32)/" .env
# 自动生成 Cookie 加密密钥
sed -i "s/change-me-to-a-random-key/$(openssl rand -hex 32)/" .env
# ⚠️ 务必手动修改 ADMIN_PASSWORD 和 CORS_ORIGIN
echo "⚠️ 请编辑 .env 文件,修改 ADMIN_PASSWORD 和 CORS_ORIGIN"
```bash
cp docker-compose.env .env
# 编辑 .env,修改以下必填项:
# JWT_SECRET — JWT 签名密钥(生成:openssl rand -hex 32
# ADMIN_PASSWORD — 管理后台登录密码
# CORS_ORIGIN — 前端访问域名(如 http://your-domain.com
# REDIS_URL — Redis 连接地址
# PANSOU_AUTH_TOKEN — PanSou 搜索 Token(可选)
```
<details>
<summary>📋 完整环境变量参考</summary>
| 变量 | 默认值 | 必填 | 说明 |
|------|--------|------|------|
| `PORT` | `9527` | | 服务端口 |
| `NODE_ENV` | `production` | | 运行环境 |
| `TZ` | `Asia/Shanghai` | | 时区 |
| `JWT_SECRET` | — | ✅ | JWT 签名密钥 |
| `ADMIN_USERNAME` | `admin` | | 管理员用户名 |
| `ADMIN_PASSWORD` | — | ✅ | 管理员密码 |
| `CORS_ORIGIN` | — | ✅ | CORS 允许的域名 |
| `COOKIE_ENCRYPTION_KEY` | — | | Cookie 加密密钥(生产环境建议设置) |
| `DB_PATH` | `/data/database.sqlite` | | SQLite 数据库路径 |
| `REDIS_URL` | — | ✅ | Redis 连接(格式 `redis://[:password@]host:port` |
| `PANSOU_URL` | `http://pansou:80` | | PanSou 搜索服务地址 |
| `PANSOU_AUTH_TOKEN` | — | | PanSou 鉴权 Token |
| `VALIDATION_CONCURRENCY` | `10` | | 网盘并发校验数 |
| `VALIDATION_TIMEOUT` | `5000` | | 单次校验超时(ms) |
| `CACHE_TTL_VALID` | `14400` | | 有效结果缓存(秒) |
| `CACHE_TTL_INVALID` | `3600` | | 无效结果缓存(秒) |
| `LOG_LEVEL` | `info` | | 日志级别(debug/info/warn/error |
| `UPLOAD_DIR` | `/app/uploads` | | 上传文件目录 |
| `APP_VERSION_FILE` | `/app/VERSION` | | 版本文件路径 |
</details>
### 3. 启动服务
```bash
# 3. 一键启动
docker compose up -d
# 4. 访问
# 首页: http://服务器IP
# 管理后台: http://服务器IP/admin/login
```
访问 `http://localhost:9527` — 管理面板:`http://localhost:9527/admin`
## 首次使用
---
## 📦 服务架构
```
┌─────────────────────────────────────────────────────┐
│ Nginx / OpenResty │
│ (反向代理 + HTTPS) │
├─────────────────────────────────────────────────────┤
│ CloudSearch_App (:9527) CloudSearch_PanSou (:80) │
│ Node.js + Express + TS 第三方搜索聚合服务 │
│ ┌─────────────────────┐ │
│ │ 前端 (Vue 3 + Vite) │ │
│ │ 后端 (Express API) │ │
│ │ SQLite + Redis │ │
│ │ Chromium (Playwright)│ │
│ └─────────────────────┘ │
├─────────────────────────────────────────────────────┤
│ Redis (:6379) │
│ 缓存 + 会话 │
└─────────────────────────────────────────────────────┘
```
| 容器 | 镜像 | 端口 | 说明 |
|------|------|------|------|
| `CloudSearch_App` | `cloudsearch-app:latest` (自构建) | `9527` | 主服务 |
| `CloudSearch_PanSou` | `ghcr.io/fish2018/pansou-web:latest` | `80` (内部) | 搜索聚合 |
| Redis | `redis:7-alpine` | `6379` (内部) | 缓存 |
---
## 🔄 升级指南
### 方案 A:拉取新镜像(推荐生产环境)
```bash
# 拉取最新版本
docker pull gitea.timxx.cn/admin/cloudsearch:v0.3.32
# 更新 .env 中的镜像标签或使用 latest
docker compose up -d app
```
### 方案 B:从源码重新构建
```bash
git pull origin master
./build.sh # 读取 VERSION 构建新镜像
docker compose up -d app
```
> 升级后验证:`curl -s http://localhost:9527/health | jq .version`
---
## 🛠️ 常用命令
```bash
# 查看服务状态
docker compose ps
# 查看日志
docker compose logs -f app # 主服务实时日志
docker compose logs --tail=100 app # 最近100行
# 进入容器
docker compose exec app sh
# 重启服务
docker compose restart app
# 健康检查
curl http://localhost:9527/health
# 查看版本
curl -s http://localhost:9527/health | jq .version
```
---
## 📁 目录结构
```
CloudSearch/
├── VERSION # 🏷️ 当前版本号(唯一真相来源)
├── build.sh # 🔨 构建 Docker 镜像
├── docker-compose.yml # 🐳 服务编排
├── docker-compose.env # 📋 环境变量模板
├── .env # 🔐 本地环境变量(Git 忽略)
├── source_clean/ # 🧩 主应用
│ ├── Dockerfile # 多阶段构建(node:20-alpine
│ ├── package.json # Node.js 依赖
│ ├── tsconfig.json
│ ├── src/ # 后端 TypeScript 源码
│ │ ├── main.ts # 入口
│ │ ├── config/ # 配置管理
│ │ ├── routes/ # API 路由
│ │ ├── cloud/ # 云盘驱动(quark/baidu/aliyun...
│ │ ├── search/ # 搜索服务
│ │ ├── database/ # SQLite 数据库
│ │ ├── services/ # 业务逻辑
│ │ └── middleware/ # 中间件
│ ├── frontend-src/ # 🎨 Vue 3 前端源码
│ │ ├── src/pages/ # 页面组件
│ │ ├── src/api/ # API 封装
│ │ ├── vite.config.ts
│ │ └── package.json
│ └── frontend/ # 📦 编译后的静态资源
├── icons/ # 🖼️ 云盘品牌图标
└── uploads/ # 📤 上传文件
```
---
## 🏗️ 开发指南
### 后端开发
```bash
cd source_clean
npm install
npm run dev # tsx watch,热重载
```
### 前端开发
```bash
cd source_clean/frontend-src
npm install
npm run dev # Vite 开发服务器
```
### 构建部署
```bash
# 完整构建流程
./build.sh # 构建 Docker 镜像
docker compose up -d app # 部署
# 仅前端修改(跳过 Docker 重建)
cd source_clean/frontend-src && npm run build
# 将 dist/ 复制到 source_clean/frontend/,重启容器
```
---
## 🔗 相关链接
- 📦 Gitea 镜像:[gitea.timxx.cn/admin/CloudSearch](https://gitea.timxx.cn/admin/CloudSearch)
- 🐳 容器注册表:`gitea.timxx.cn/admin/cloudsearch`
- 📝 版本规则:`X.Y.Z` — patch 每次任务 +0.0.1minor 每轮测试稳定 +0.1.0
---
## 📄 License
私有项目 — 保留所有权利。
1. 登录管理后台 `/admin/login`(账号: admin,密码: 你在 .env 中设置的)
2. 在「网盘配置」中添加夸克网盘 Cookie
3. 在「推广管理」中添加首页推广内容(可选)
4. 返回首页即可开始搜索
-83
View File
@@ -1,83 +0,0 @@
# 🏷️ vX.Y.Z — YYYY-MM-DD
> **镜像**: `gitea.timxx.cn/admin/cloudsearch:vX.Y.Z`
> **Commit**: `abc1234`
> **构建**: `./build.sh` → Docker 多阶段构建 (node:20-alpine)
---
## ✨ 新增功能
-
---
## 🐛 问题修复
-
---
## 💥 破坏性变更
>
---
## ⚠️ 升级注意事项
### 环境变量变更
> 本次无需修改 `.env`
```diff
# 如需新增/修改环境变量:
# + NEW_VAR=value
# - OLD_VAR=value → NEW_VAR=value
```
### 数据库迁移
> 本次无需手动迁移
```sql
-- 如需执行 SQL(请先在 staging 验证):
-- ALTER TABLE xxx ADD COLUMN yyy TEXT DEFAULT '';
```
### 升级步骤
```bash
# 1. 拉取新镜像
docker pull gitea.timxx.cn/admin/cloudsearch:vX.Y.Z
# 2. 更新 docker-compose.yml 中的镜像标签(或使用 latest)
# image: gitea.timxx.cn/admin/cloudsearch:vX.Y.Z
# 3. 重新部署
docker compose up -d app
# 4. 验证
curl -s http://localhost:9527/health | jq .version
# → "X.Y.Z"
```
---
## 📋 完整 Changelog
<!-- 用 git log 生成:
git log v0.3.31..v0.3.32 --oneline --no-merges
-->
```
abc1234 feat: xxx
def5678 fix: yyy
```
---
## 🔗 相关
- Gitea: https://gitea.timxx.cn/admin/CloudSearch
- 版本规则: Patch 每次任务 +0.0.1 · Minor 每轮稳定 +0.1.0 · Major 需确认
-157
View File
@@ -1,157 +0,0 @@
# CloudSearch · 版本发布操作流程
## 🗺️ 概述
CloudSearch 使用 **VERSION 文件** 作为版本号的唯一真相来源。每次发布包含三个步骤:构建镜像 → 推送仓库 → 创建 Release。
---
## 🔢 版本规则
| 类型 | 增量 | 触发条件 |
|------|------|---------|
| **Patch** | `+0.0.1` | 每次完成任务组后 |
| **Minor** | `+0.1.0` | 用户说"测试没问题可以上线了" |
| **Major** | `1.0.0` | 完整流程验证接近完美(需先确认) |
示例:`0.3.31 → 0.3.32` (patch) · `0.3.x → 0.4.0` (minor)
---
## 📋 手动发布流程
### 方式 A:一键脚本(推荐)
```bash
# 1. 登录私有仓库
docker login gitea.timxx.cn
# 2. 执行发布(自动读取 VERSION 或手动指定)
chmod +x scripts/build-and-push.sh
./scripts/build-and-push.sh v0.3.32
```
### 方式 B:分步执行
```bash
# 1. 更新版本号
echo "0.3.32" > VERSION
# 2. 构建镜像
cd source_clean
docker build -t cloudsearch-app:v0.3.32 -t cloudsearch-app:latest .
# 3. 标记并推送
docker tag cloudsearch-app:latest gitea.timxx.cn/admin/cloudsearch:v0.3.32
docker tag cloudsearch-app:latest gitea.timxx.cn/admin/cloudsearch:latest
docker push gitea.timxx.cn/admin/cloudsearch:v0.3.32
docker push gitea.timxx.cn/admin/cloudsearch:latest
# 4. Git 标签
git add VERSION && git commit -m "release: v0.3.32"
git tag -a v0.3.32 -m "Release v0.3.32"
git push origin master --tags
```
---
## 📝 Release 说明编写
每次发布在 Gitea 上创建 Release,使用 `RELEASE_TEMPLATE.md` 模板,填写以下段落:
1. **版本号与发布日期**
2. **新增功能** — 列出新加的特性
3. **问题修复** — 列出修复的 bug
4. **破坏性变更** — 标注不兼容的 API/配置变更
5. **升级注意事项**`.env` 变更、数据库迁移 SQL
6. **Docker 镜像地址** — 本次发布的完整 tag
### 快速生成 Changelog
```bash
# 从上次发布到现在的提交记录
git log v0.3.31..v0.3.32 --oneline --no-merges
```
---
## 🤖 CI 自动化思路(可选参考)
### Gitea Actions 示例
```yaml
# .gitea/workflows/release.yml
name: Build and Release
on:
push:
tags:
- 'v*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 获取版本号
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
- name: 构建镜像
run: |
docker build -t cloudsearch-app:${{ steps.version.outputs.VERSION }} \
-f source_clean/Dockerfile source_clean/
- name: 登录私有仓库
run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login gitea.timxx.cn -u admin --password-stdin
- name: 推送镜像
run: |
docker tag cloudsearch-app:${{ steps.version.outputs.VERSION }} \
gitea.timxx.cn/admin/cloudsearch:v${{ steps.version.outputs.VERSION }}
docker tag cloudsearch-app:${{ steps.version.outputs.VERSION }} \
gitea.timxx.cn/admin/cloudsearch:latest
docker push gitea.timxx.cn/admin/cloudsearch:v${{ steps.version.outputs.VERSION }}
docker push gitea.timxx.cn/admin/cloudsearch:latest
- name: 创建 Release
uses: actions/gitea-release@v1
with:
tag: v${{ steps.version.outputs.VERSION }}
title: v${{ steps.version.outputs.VERSION }}
body: |
查看 RELEASE_NOTES.md 了解完整变更。
token: ${{ secrets.GITEA_TOKEN }}
```
---
## 🔧 生产部署触发
镜像推送成功后,生产环境执行:
```bash
# 方式1:拉取新镜像并重建
docker pull gitea.timxx.cn/admin/cloudsearch:latest
docker compose up -d app
# 方式2:指定版本
docker pull gitea.timxx.cn/admin/cloudsearch:v0.3.32
# 修改 docker-compose.yml 中的 image 标签为 :v0.3.32
docker compose up -d app
# 验证
curl -s http://localhost:9527/health | jq .version
# → "0.3.32"
```
---
## ⚠️ 注意事项
- **VERSION 文件是唯一真相来源** — 所有工具(build.sh、health check、前端)都从它读取
- **不要手动修改 `/app/dist/version.js`** — 它是编译产物,重启后丢失
- **`docker restart` 不会拉取新镜像** — 必须 `docker compose up -d` 重建容器
- **推送前先 pull --rebase** — 避免 force push 覆盖历史
- **每 5 次 patch 迭代同步到 Gitea** — 保持镜像仓库与源码一致
-1
View File
@@ -1 +0,0 @@
0.5.6
-11
View File
@@ -1,11 +0,0 @@
#!/bin/bash
set -e
cd "$(dirname "$0")/source_clean"
VERSION=$(cat ../VERSION)
echo "🔨 Building CloudSearch v${VERSION}..."
cp ../VERSION ./VERSION
docker build -t cloudsearch-app:v${VERSION} -t cloudsearch-app:latest .
echo "✅ Built: cloudsearch-app:v${VERSION} + cloudsearch-app:latest"
echo " Run: docker-compose up -d app"
-18
View File
@@ -1,18 +0,0 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY server.py .
COPY templates/ templates/
RUN mkdir -p /data
EXPOSE 9531
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:9531/health')"
CMD ["python", "server.py"]
-196
View File
@@ -1,196 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>功能开关 - CloudSearch</title>
<style>
:root {
--bg: #f5f7fa; --card-bg: #fff; --text: #303133; --muted: #909399;
--border: #e4e7ed; --primary: #409eff; --success: #67c23a; --danger: #f56c6c;
--warning: #e6a23c;
}
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: -apple-system, 'Helvetica Neue', sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
.nav { background: var(--card-bg); border-bottom: 1px solid var(--border); padding: 0 24px; height: 56px; display: flex; align-items: center; justify-content: space-between; }
.nav h2 { font-size: 18px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
.nav .badge { font-size: 11px; background: var(--primary); color: #fff; padding: 2px 8px; border-radius: 10px; }
.nav a { color: var(--primary); text-decoration: none; font-size: 13px; }
.container { max-width: 800px; margin: 0 auto; padding: 24px 16px; }
.group { margin-bottom: 24px; }
.group-title { font-size: 13px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 1px; padding: 8px 0; border-bottom: 1px solid var(--border); margin-bottom: 12px; }
.card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 16px 20px; margin-bottom: 8px; display: flex; align-items: center; justify-content: space-between; transition: box-shadow .15s; }
.card:hover { box-shadow: 0 2px 8px rgba(0,0,0,.06); }
.card-info h3 { font-size: 14px; font-weight: 500; }
.card-info .key { font-size: 11px; color: var(--muted); font-family: 'SF Mono', Monaco, monospace; margin-top: 2px; }
.toggle-wrap { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
.toggle { position: relative; width: 44px; height: 24px; border-radius: 24px; border: none; cursor: pointer; transition: background .2s; outline: none; }
.toggle:focus-visible { box-shadow: 0 0 0 2px rgba(64,158,255,.4); }
.toggle.off { background: #c0c4cc; }
.toggle.on { background: var(--success); }
.toggle::after { content: ''; position: absolute; top: 2px; left: 2px; width: 20px; height: 20px; border-radius: 50%; background: #fff; transition: transform .2s ease; box-shadow: 0 1px 2px rgba(0,0,0,.15); }
.toggle.on::after { transform: translateX(20px); }
.toggle:disabled { opacity: .4; cursor: not-allowed; }
.status { font-size: 12px; min-width: 36px; text-align: center; }
.status.on { color: var(--success); }
.status.off { color: var(--muted); }
.actions { display: flex; gap: 12px; justify-content: flex-end; margin-top: 24px; }
.btn { padding: 8px 20px; border: 1px solid var(--border); border-radius: 6px; background: var(--card-bg); color: var(--text); cursor: pointer; font-size: 13px; transition: .15s; }
.btn:hover { border-color: var(--primary); color: var(--primary); }
.btn.primary { background: var(--primary); border-color: var(--primary); color: #fff; }
.btn.primary:hover { opacity: .85; }
.toast { position: fixed; top: 20px; left: 50%; transform: translateX(-50%); background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 10px 24px; font-size: 14px; box-shadow: 0 4px 12px rgba(0,0,0,.1); z-index: 999; opacity: 0; transition: opacity .25s; }
.toast.show { opacity: 1; }
.toast.ok { border-color: var(--success); }
.toast.err { border-color: var(--danger); }
.loading-overlay { text-align: center; padding: 60px; color: var(--muted); font-size: 14px; }
</style>
</head>
<body>
<div class="nav">
<h2>🔧 功能开关 <span class="badge">v2.2</span></h2>
<a href="/admin">← 返回管理后台</a>
</div>
<div class="container" id="app">
<div class="loading-overlay">加载中…</div>
</div>
<div class="toast" id="toast"></div>
<script>
// ========== Feature Definitions ==========
const FEATURES = [
// 核心功能
{ key: 'feature_quark_pid', name: '夸克推广PID', group: '核心功能', default: true },
{ key: 'feature_seo', name: 'SEO / Sitemap', group: '核心功能', default: true },
{ key: 'feature_link_monitor', name: '失效链接监控', group: '核心功能', default: true },
// 增强功能
{ key: 'feature_tmdb', name: 'TMDB影视刮削', group: '增强功能', default: true },
{ key: 'feature_telegram_bot', name: 'Telegram Bot', group: '增强功能', default: false },
{ key: 'feature_subscription', name: '关键词订阅通知', group: '增强功能', default: false },
{ key: 'feature_alist', name: 'AList打通', group: '增强功能', default: false },
// 网盘转存
{ key: 'feature_transfer_quark', name: '夸克转存', group: '网盘转存', default: true },
{ key: 'feature_transfer_baidu', name: '百度转存', group: '网盘转存', default: false },
{ key: 'feature_transfer_aliyun', name: '阿里转存', group: '网盘转存', default: false },
{ key: 'feature_transfer_uc', name: 'UC转存', group: '网盘转存', default: false },
{ key: 'feature_transfer_xunlei', name: '迅雷转存', group: '网盘转存', default: false },
{ key: 'feature_transfer_115', name: '115转存', group: '网盘转存', default: false },
{ key: 'feature_transfer_123', name: '123转存', group: '网盘转存', default: false },
{ key: 'feature_transfer_cloud189',name: '天翼转存', group: '网盘转存', default: false },
];
// ========== API helpers ==========
const BASE = '';
function token() { return localStorage.getItem('admin_token') || ''; }
function headers() { const h = {'Content-Type':'application/json'}; const t=token(); if(t) h['Authorization']='Bearer '+t; return h; }
async function apiGet(url) {
const res = await fetch(BASE + url, { headers: headers() });
if (res.status === 401) { alert('登录已过期,请重新登录'); location.href = '/admin'; return null; }
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
}
async function apiPut(url, body) {
const res = await fetch(BASE + url, { method:'PUT', headers:headers(), body:JSON.stringify(body) });
if (res.status === 401) { alert('登录已过期,请重新登录'); location.href = '/admin'; return null; }
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
}
// ========== State ==========
let configs = {}; // key -> value from system_configs
// ========== Toast ==========
function toast(msg, type) {
const el = document.getElementById('toast');
el.textContent = msg; el.className = 'toast ' + (type||'ok') + ' show';
clearTimeout(el._t); el._t = setTimeout(() => el.classList.remove('show'), 2500);
}
// ========== Toggle ==========
async function toggleFeature(key, currentVal) {
const newVal = !currentVal;
// Optimistic UI update
configs[key] = String(newVal);
render();
try {
await apiPut('/api/admin/system-configs', {
entries: [{ key, value: newVal ? 'true' : 'false' }]
});
toast(newVal ? `${key.replace('feature_','')} 已开启` : `${key.replace('feature_','')} 已关闭`, 'ok');
} catch(e) {
// Revert
configs[key] = String(currentVal);
render();
toast('切换失败: ' + e.message, 'err');
}
}
async function toggleAll(groupKeys, enable) {
const entries = groupKeys.map(k => ({ key: k, value: enable ? 'true' : 'false' }));
for (const e of entries) configs[e.key] = e.value;
render();
try {
await apiPut('/api/admin/system-configs', { entries });
toast(`${enable ? '✅' : '❌'}${enable ? '开启' : '关闭'} ${entries.length} 个开关`, 'ok');
} catch(e) {
await load(); // revert
toast('批量操作失败: ' + e.message, 'err');
}
}
// ========== Load ==========
async function load() {
try {
const data = await apiGet('/api/admin/system-configs');
if (!data) return;
configs = {};
for (const row of data) { configs[row.key] = row.value; }
render();
} catch(e) {
document.getElementById('app').innerHTML =
'<div class="loading-overlay" style="color:var(--danger)">加载失败: ' + e.message + '</div>';
}
}
// ========== Render ==========
function render() {
const groups = {};
for (const f of FEATURES) {
(groups[f.group] || (groups[f.group] = [])).push(f);
}
let html = '';
for (const [group, items] of Object.entries(groups)) {
const keys = items.map(f => f.key);
const allOn = keys.every(k => configs[k] === 'true');
const allOff = keys.every(k => configs[k] !== 'true');
html += '<div class="group">';
html += '<div class="group-title" style="display:flex;justify-content:space-between;align-items:center">';
html += '<span>' + group + '</span>';
html += '<span style="font-weight:400;font-size:12px">';
html += '<a href="#" onclick="toggleAll(' + JSON.stringify(keys) + ',true);return false" style="margin-right:8px">全部开启</a>';
html += '<a href="#" onclick="toggleAll(' + JSON.stringify(keys) + ',false);return false">全部关闭</a>';
html += '</span></div>';
for (const f of items) {
const val = configs[f.key] === 'true';
html += '<div class="card">';
html += '<div class="card-info"><h3>' + f.name + '</h3><div class="key">' + f.key + '</div></div>';
html += '<div class="toggle-wrap">';
html += '<span class="status ' + (val ? 'on' : 'off') + '">' + (val ? '开' : '关') + '</span>';
html += '<button class="toggle ' + (val ? 'on' : 'off') + '" onclick="toggleFeature(\'' + f.key + '\',' + val + ')" title="点击切换"></button>';
html += '</div></div>';
}
html += '</div>';
}
html += '<div class="actions"><button class="btn" onclick="load()">🔄 刷新状态</button></div>';
document.getElementById('app').innerHTML = html;
}
// ========== Init ==========
load();
</script>
</body>
</html>
-3
View File
@@ -1,3 +0,0 @@
flask>=3.0
waitress>=2.1
pymysql>=1.1
-206
View File
@@ -1,206 +0,0 @@
#!/usr/bin/env python3
"""
CloudSearch 管理后台 v2.2.0
功能开关一键管理 — 支持本地 SQLite + MySQL 双向同步
"""
import os
import json
import sqlite3
import logging
from datetime import datetime
from typing import Dict, Optional
from flask import Flask, render_template, request, jsonify
# ── 日志 ──────────────────────────────────────────────────
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
log = logging.getLogger("admin")
app = Flask(__name__)
# ── 配置 ──────────────────────────────────────────────────
ADMIN_PORT = int(os.getenv("ADMIN_PORT", "9531"))
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin123")
DB_PATH = os.getenv("ADMIN_DB_PATH", "/data/admin_flags.sqlite")
# MySQL(主应用 system_configs 表,可选)
MYSQL_HOST = os.getenv("MYSQL_HOST", "")
MYSQL_PORT = int(os.getenv("MYSQL_PORT", "3306"))
MYSQL_USER = os.getenv("MYSQL_USER", "")
MYSQL_PASS = os.getenv("MYSQL_PASS", "")
MYSQL_DB = os.getenv("MYSQL_DB", "cloudsearch")
# ── 功能开关定义 ──────────────────────────────────────────
FEATURES: Dict[str, dict] = {
"feature_quark_pid": {"name": "夸克推广PID", "group": "核心", "default": True},
"feature_seo": {"name": "SEO / Sitemap", "group": "核心", "default": True},
"feature_link_monitor": {"name": "失效链接监控", "group": "核心", "default": True},
"feature_tmdb": {"name": "TMDB影视刮削", "group": "增强", "default": True},
"feature_telegram_bot": {"name": "Telegram Bot", "group": "增强", "default": False},
"feature_subscription": {"name": "关键词订阅通知", "group": "增强", "default": False},
"feature_alist": {"name": "AList打通", "group": "增强", "default": False},
"feature_transfer_quark": {"name": "夸克转存", "group": "转存", "default": True},
"feature_transfer_baidu": {"name": "百度转存", "group": "转存", "default": False},
"feature_transfer_aliyun": {"name": "阿里转存", "group": "转存", "default": False},
"feature_transfer_uc": {"name": "UC转存", "group": "转存", "default": False},
"feature_transfer_xunlei": {"name": "迅雷转存", "group": "转存", "default": False},
"feature_transfer_115": {"name": "115转存", "group": "转存", "default": False},
"feature_transfer_123": {"name": "123转存", "group": "转存", "default": False},
"feature_transfer_cloud189":{"name": "天翼转存", "group": "转存", "default": False},
}
# ── SQLite ────────────────────────────────────────────────
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("""
CREATE TABLE IF NOT EXISTS flags (
key TEXT PRIMARY KEY,
value INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
# 初始化默认值
for key in FEATURES:
conn.execute(
"INSERT OR IGNORE INTO flags(key, value) VALUES(?, ?)",
(key, int(FEATURES[key]["default"]))
)
conn.commit()
return conn
def read_flags_sqlite() -> Dict[str, bool]:
conn = get_db()
rows = conn.execute("SELECT key, value, updated_at FROM flags ORDER BY key").fetchall()
conn.close()
return {r["key"]: bool(r["value"]) for r in rows}, {r["key"]: r["updated_at"] for r in rows}
def write_flag_sqlite(key: str, value: bool):
conn = get_db()
conn.execute(
"INSERT INTO flags(key, value, updated_at) VALUES(?, ?, datetime('now')) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=datetime('now')",
(key, int(value))
)
conn.commit()
conn.close()
# ── MySQL 同步 ────────────────────────────────────────────
def sync_to_mysql(key: str, value: bool):
"""将开关状态同步到主应用的 system_configs 表"""
if not MYSQL_HOST:
return # MySQL 未配置,跳过
try:
import pymysql
conn = pymysql.connect(
host=MYSQL_HOST, port=MYSQL_PORT,
user=MYSQL_USER, password=MYSQL_PASS,
database=MYSQL_DB, charset="utf8mb4",
connect_timeout=5
)
with conn.cursor() as cur:
cur.execute("""
INSERT INTO system_configs (config_key, config_value, updated_at)
VALUES (%s, %s, NOW())
ON DUPLICATE KEY UPDATE config_value=VALUES(config_value), updated_at=NOW()
""", (key, "true" if value else "false"))
conn.commit()
conn.close()
log.info(f"MySQL 同步成功: {key} = {value}")
except Exception as e:
log.warning(f"MySQL 同步失败 ({key}): {e}")
def read_flags_mysql() -> Optional[Dict[str, bool]]:
if not MYSQL_HOST:
return None
try:
import pymysql
conn = pymysql.connect(
host=MYSQL_HOST, port=MYSQL_PORT,
user=MYSQL_USER, password=MYSQL_PASS,
database=MYSQL_DB, charset="utf8mb4",
connect_timeout=5
)
with conn.cursor(pymysql.cursors.DictCursor) as cur:
cur.execute("SELECT config_key, config_value FROM system_configs WHERE config_key LIKE 'feature_%'")
rows = cur.fetchall()
conn.close()
return {r["config_key"]: r["config_value"].lower() == "true" for r in rows}
except Exception as e:
log.warning(f"MySQL 读取失败: {e}")
return None
# ── 路由 ──────────────────────────────────────────────────
@app.route("/")
def index():
"""管理后台首页"""
return render_template("admin.html", features=FEATURES)
@app.route("/health")
def health():
return jsonify({"status": "ok", "service": "cloudsearch-admin"})
@app.route("/api/flags", methods=["GET"])
def api_list_flags():
"""列出所有开关"""
flags_sqlite, updated = read_flags_sqlite()
flags_mysql = read_flags_mysql()
result = {}
for key, meta in FEATURES.items():
result[key] = {
"name": meta["name"],
"group": meta["group"],
"value": flags_sqlite.get(key, meta["default"]),
"mysql_value": flags_mysql.get(key) if flags_mysql else None,
"synced": (flags_mysql is None) or (flags_sqlite.get(key) == flags_mysql.get(key)),
"updated_at": updated.get(key, ""),
}
return jsonify(result)
@app.route("/api/flags/<key>", methods=["PUT"])
def api_set_flag(key):
"""设置单个开关"""
if key not in FEATURES:
return jsonify({"error": f"未知开关: {key}"}), 404
data = request.get_json(force=True)
value = bool(data.get("value", False))
# 写本地 SQLite
write_flag_sqlite(key, value)
# 同步到 MySQL
sync_to_mysql(key, value)
log.info(f"开关切换: {key} = {value}")
return jsonify({"ok": True, "key": key, "value": value})
@app.route("/api/flags/batch", methods=["PUT"])
def api_batch_set_flags():
"""批量设置开关"""
data = request.get_json(force=True)
if not isinstance(data, dict):
return jsonify({"error": "请求体需为 {key: value} 字典"}), 400
results = {}
for key, value in data.items():
if key not in FEATURES:
results[key] = {"error": "未知"}
continue
val = bool(value)
write_flag_sqlite(key, val)
sync_to_mysql(key, val)
results[key] = val
log.info(f"批量切换: {key} = {val}")
return jsonify({"ok": True, "results": results})
if __name__ == "__main__":
from waitress import serve
log.info(f"管理后台启动: http://0.0.0.0:{ADMIN_PORT}")
log.info(f"MySQL 同步: {'已配置' if MYSQL_HOST else '未配置 (仅使用本地 SQLite)'}")
serve(app, host="0.0.0.0", port=ADMIN_PORT, threads=4)
-104
View File
@@ -1,104 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CloudSearch 管理后台</title>
<style>
:root {
--bg: #0a0e17; --bg-card: rgba(16,22,36,0.8); --bg-card-hover: rgba(22,30,48,0.9);
--border: rgba(255,255,255,0.06); --border-active: rgba(99,102,241,0.3);
--text: #e2e8f0; --text-secondary: #94a3b8; --text-muted: #64748b;
--primary: #6366f1; --primary-glow: rgba(99,102,241,0.2);
--success: #22c55e; --success-glow: rgba(34,197,94,0.2);
--warning: #f59e0b; --danger: #ef4444;
--radius: 16px; --radius-sm: 10px;
}
*{margin:0;padding:0;box-sizing:border-box}
body{
font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Segoe UI',system-ui,sans-serif;
background:var(--bg);color:var(--text);min-height:100vh;line-height:1.5;-webkit-font-smoothing:antialiased
}
body::before{
content:'';position:fixed;inset:0;
background:radial-gradient(ellipse 80% 50% at 20% 0%,rgba(99,102,241,0.08) 0%,transparent 60%),
radial-gradient(ellipse 60% 40% at 80% 100%,rgba(34,197,94,0.06) 0%,transparent 60%);
pointer-events:none;z-index:0
}
.header{
position:sticky;top:0;z-index:100;
background:rgba(10,14,23,0.75);backdrop-filter:blur(20px) saturate(180%);
-webkit-backdrop-filter:blur(20px) saturate(180%);border-bottom:1px solid var(--border);padding:0 24px
}
.header-inner{max-width:1100px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;height:64px}
.logo{display:flex;align-items:center;gap:12px}
.logo-icon{width:36px;height:36px;border-radius:10px;background:linear-gradient(135deg,var(--primary),#8b5cf6);display:flex;align-items:center;justify-content:center;font-size:18px;box-shadow:0 0 20px var(--primary-glow)}
.logo-text h1{font-size:17px;font-weight:700;letter-spacing:-0.3px}
.logo-text span{font-size:11px;color:var(--text-muted);font-weight:500}
.header-actions{display:flex;gap:8px;align-items:center}
.btn{padding:8px 16px;border-radius:8px;font-size:13px;font-weight:500;cursor:pointer;border:1px solid var(--border);background:rgba(255,255,255,0.04);color:var(--text-secondary);transition:all 0.2s;display:flex;align-items:center;gap:6px;font-family:inherit}
.btn:hover{background:rgba(255,255,255,0.08);color:var(--text)}
.btn-primary{background:var(--primary);border-color:var(--primary);color:white}
.btn-primary:hover{background:#4f46e5;box-shadow:0 0 20px var(--primary-glow)}
.stats-strip{max-width:1100px;margin:24px auto 0;padding:0 24px;display:grid;grid-template-columns:repeat(4,1fr);gap:12px}
.stat-mini{background:var(--bg-card);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border:1px solid var(--border);border-radius:var(--radius-sm);padding:16px;text-align:center;transition:all 0.2s}
.stat-mini:hover{border-color:var(--border-active);background:var(--bg-card-hover);transform:translateY(-2px)}
.stat-mini .stat-num{font-size:28px;font-weight:800;letter-spacing:-1px;background:linear-gradient(135deg,var(--primary),#8b5cf6);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
.stat-mini .stat-label{font-size:12px;color:var(--text-muted);margin-top:2px;font-weight:500}
.main{max-width:1100px;margin:20px auto 40px;padding:0 24px;position:relative;z-index:1}
.section-title{font-size:11px;text-transform:uppercase;letter-spacing:2px;color:var(--text-muted);font-weight:700;margin:28px 0 14px;display:flex;align-items:center;gap:10px}
.section-title::after{content:'';flex:1;height:1px;background:var(--border)}
.feature-grid{display:grid;gap:8px}
.feature-card{background:var(--bg-card);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border:1px solid var(--border);border-radius:var(--radius-sm);padding:16px 20px;display:flex;align-items:center;justify-content:space-between;gap:16px;transition:all 0.25s;position:relative;overflow:hidden}
.feature-card::before{content:'';position:absolute;left:0;top:0;bottom:0;width:3px;border-radius:3px 0 0 3px;transition:all 0.3s}
.feature-card.on::before{background:var(--success)}
.feature-card.off::before{background:var(--border)}
.feature-card:hover{background:var(--bg-card-hover);border-color:var(--border-active);transform:translateX(2px)}
.feature-card.loading{opacity:0.5;pointer-events:none}
.feature-info h3{font-size:14px;font-weight:600;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.feature-info .code{font-size:11px;color:var(--text-muted);font-family:monospace;background:rgba(255,255,255,0.03);padding:2px 8px;border-radius:5px;margin-top:4px;display:inline-block}
.feature-info .updated{font-size:11px;color:var(--text-muted);margin-left:8px}
.badge{font-size:10px;padding:3px 8px;border-radius:999px;font-weight:600}
.badge-synced{background:rgba(34,197,94,0.12);color:var(--success)}
.badge-mismatch{background:rgba(239,68,68,0.12);color:var(--danger)}
.toggle-wrap{flex-shrink:0}
.toggle{width:48px;height:26px;border-radius:26px;border:none;cursor:pointer;position:relative;transition:all 0.3s cubic-bezier(0.4,0,0.2,1);background:rgba(255,255,255,0.1)}
.toggle.on{background:var(--success);box-shadow:0 0 16px var(--success-glow)}
.toggle::after{content:'';position:absolute;top:3px;left:3px;width:20px;height:20px;border-radius:50%;background:white;transition:transform 0.3s cubic-bezier(0.4,0,0.2,1);box-shadow:0 2px 4px rgba(0,0,0,0.2)}
.toggle.on::after{transform:translateX(22px)}
.toast-container{position:fixed;bottom:24px;right:24px;z-index:999;display:flex;flex-direction:column;gap:8px}
.toast{background:var(--bg-card);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);border:1px solid var(--border);border-radius:var(--radius-sm);padding:14px 20px;font-size:13px;font-weight:500;display:flex;align-items:center;gap:8px;opacity:0;transform:translateX(40px);transition:all 0.3s cubic-bezier(0.4,0,0.2,1);min-width:240px;box-shadow:0 4px 24px rgba(0,0,0,0.3)}
.toast.show{opacity:1;transform:translateX(0)}
.toast.success{border-color:var(--success)}
.toast.error{border-color:var(--danger)}
.toast.info{border-color:var(--primary)}
.toast-icon{font-size:18px;flex-shrink:0}
.spinner{width:24px;height:24px;border:2px solid var(--border);border-top-color:var(--primary);border-radius:50%;animation:spin 0.6s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.5}}
.saving{animation:pulse 0.8s ease-in-out infinite}
.loader{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:80px 0;gap:16px;color:var(--text-muted)}
@media(max-width:768px){.stats-strip{grid-template-columns:repeat(2,1fr)}.feature-card{flex-direction:column;align-items:flex-start}.feature-card .toggle-wrap{align-self:flex-end;margin-top:-32px}.header-inner{height:56px}}
</style>
</head>
<body>
<header class="header"><div class="header-inner"><div class="logo"><div class="logo-icon"></div><div class="logo-text"><h1>CloudSearch 管理后台</h1><span>功能开关 &amp; 系统配置</span></div></div><div class="header-actions"><button class="btn" onclick="refreshAll()">🔄 刷新</button><button class="btn btn-primary" onclick="saveAll()" id="btn-save-all">💾 全部保存</button></div></div></header>
<div class="stats-strip" id="stats"></div>
<main class="main" id="main"><div class="loader"><div class="spinner"></div><span>加载功能开关...</span></div></main>
<div class="toast-container" id="toast-container"></div>
<script>
var FEATURES={{ features | tojson }};
var currentFlags={},statsData=null;
function refreshAll(){Promise.all([loadFlags(),loadStats()]).then(render)}
function loadFlags(){fetch('/api/flags').then(function(r){return r.json()}).then(function(d){currentFlags=d}).catch(function(){showToast('无法连接','error')})}
function loadStats(){var t=sessionStorage.getItem('admin_token');if(!t)return;fetch('http://127.0.0.1:9527/api/admin/stats',{headers:{'Authorization':'Bearer '+t}}).then(function(r){return r.ok?r.json():null}).then(function(d){statsData=d}).catch(function(){})}
function render(){renderStats();renderFeatures()}
function renderStats(){var e=document.getElementById('stats');if(!statsData){e.innerHTML='';return}e.innerHTML='<div class="stat-mini"><div class="stat-num">'+(statsData.todaySearches||0)+'</div><div class="stat-label">今日搜索</div></div><div class="stat-mini"><div class="stat-num">'+(statsData.todaySaves||0)+'</div><div class="stat-label">今日转存</div></div><div class="stat-mini"><div class="stat-num">'+(statsData.totalSearches||0)+'</div><div class="stat-label">总搜索</div></div><div class="stat-mini"><div class="stat-num">'+(statsData.totalSaves||0)+'</div><div class="stat-label">总转存</div></div>'}
function renderFeatures(){var entries=Object.entries(FEATURES).map(function(e){e[1]=Object.assign({},e[1],{flag:currentFlags[e[0]]});return e});var groups={};entries.forEach(function(e){var g=e[1].group;(groups[g]=groups[g]||[]).push(e)});var order=['核心','增强','转存'],icons={'核心':'🔵','增强':'🟣','转存':'🟢'},html='';order.forEach(function(g){if(!groups[g])return;html+='<div class="section-title">'+(icons[g]||'📌')+' '+g+'功能</div><div class="feature-grid">';groups[g].forEach(function(p){var key=p[0],item=p[1],flag=item.flag||{},on=flag.value,synced=flag.synced!==false,updated=flag.updated_at?flag.updated_at.replace('T',' ').slice(0,16):'';html+='<div class="feature-card '+(on?'on':'off')+'" id="card-'+key+'"><div class="feature-info"><h3>'+item.name+(synced?'':' <span class="badge badge-mismatch">⚠ 未同步</span>')+(synced&&on?' <span class="badge badge-synced">● 已启用</span>':'')+'</h3><span class="code">'+key+'</span>'+(updated?'<span class="updated">'+updated+'</span>':'')+'</div><div class="toggle-wrap"><button class="toggle '+(on?'on':'off')+'" onclick="toggleFlag(\''+key+'\','+(!on)+')"><span style="display:none">开关</span></button></div></div>'});html+='</div>'});document.getElementById('main').innerHTML=html}
function toggleFlag(key,value){var card=document.getElementById('card-'+key);if(!card)return;card.classList.add('loading');fetch('/api/flags/'+key,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({value:value})}).then(function(r){return r.json()}).then(function(d){if(d.ok){showToast(FEATURES[key].name+' → '+(value?'✅ 已开启':'❌ 已关闭'),'success');loadFlags().then(render)}else showToast(d.error||'失败','error')}).catch(function(e){showToast('网络错误: '+e.message,'error');card.classList.remove('loading')}).finally(function(){card.classList.remove('loading')})}
function saveAll(){var btn=document.getElementById('btn-save-all');btn.classList.add('saving');btn.innerHTML='⏳ 保存中...';fetch('/api/flags').then(function(r){return r.json()}).then(function(d){showToast('已同步 '+Object.keys(d).length+' 个开关 ✓','success')}).catch(function(){showToast('同步失败','error')}).finally(function(){setTimeout(function(){btn.classList.remove('saving');btn.innerHTML='💾 全部保存'},600)})}
function showToast(msg,type){var c=document.getElementById('toast-container'),icons={success:'✓',error:'✕',info:''},el=document.createElement('div');el.className='toast '+(type||'info');el.innerHTML='<span class="toast-icon">'+(icons[type]||'')+'</span>'+msg;c.appendChild(el);requestAnimationFrame(function(){el.classList.add('show')});setTimeout(function(){el.classList.remove('show');setTimeout(function(){el.remove()},300)},2800)}
(function init(){try{fetch('http://127.0.0.1:9527/api/admin/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:'admin',password:'0nL5kLhMIJ1121PYmQb25A'})}).then(function(r){return r.ok?r.json():null}).then(function(d){if(d&&d.token)sessionStorage.setItem('admin_token',d.token)}).catch(function(){})}catch(e){}refreshAll()})();
</script>
</body>
</html>
-8
View File
@@ -1,8 +0,0 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY *.py .
EXPOSE 9530 9532
HEALTHCHECK --interval=30s --timeout=5s --retries=3 CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:9530/health')"
CMD ["sh", "-c", "python search_enricher.py & python feishu_bot.py & python subscription_monitor.py & wait"]
-319
View File
@@ -1,319 +0,0 @@
"""
CloudSearch 飞书 Bot v1.0.0
替代 Telegram Bot,支持 /search /subscribe 命令 + Webhook 推送
通过飞书开放平台事件订阅接收消息
"""
import os
import json
import time
import hmac
import hashlib
import logging
import sqlite3
from typing import Optional
from flask import Flask, request, jsonify
import requests
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("feishubot")
# ── 飞书配置 ──────────────────────────────────
APP_ID = os.environ.get("FEISHU_APP_ID", "")
APP_SECRET = os.environ.get("FEISHU_APP_SECRET", "")
VERIFY_TOKEN = os.environ.get("FEISHU_VERIFY_TOKEN", "")
WEBHOOK_URL = os.environ.get("FEISHU_WEBHOOK_URL", "")
CLOUDSEARCH_API = os.environ.get("CLOUDSEARCH_API", "http://app:9527")
DB_PATH = os.environ.get("BOT_DB_PATH", "/data/bot.db")
# ── 飞书API ───────────────────────────────────
FEISHU_TOKEN_URL = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
FEISHU_SEND_URL = "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=open_id"
_tenant_token = None
_token_expire = 0
def get_tenant_token() -> str:
"""获取飞书 tenant_access_token(缓存2h"""
global _tenant_token, _token_expire
if _tenant_token and time.time() < _token_expire:
return _tenant_token
resp = requests.post(FEISHU_TOKEN_URL, json={
"app_id": APP_ID, "app_secret": APP_SECRET
}, timeout=10)
data = resp.json()
if data.get("code") != 0:
raise Exception(f"获取飞书Token失败: {data}")
_tenant_token = data["tenant_access_token"]
_token_expire = time.time() + data.get("expire", 7200) - 300
logger.info("飞书 tenant_token 已刷新")
return _tenant_token
def send_feishu_msg(open_id: str, content: str, msg_type: str = "text"):
"""发送飞书消息"""
body = {
"receive_id": open_id,
"msg_type": msg_type,
"content": json.dumps({"text": content}) if msg_type == "text" else content
}
resp = requests.post(
FEISHU_SEND_URL,
headers={"Authorization": f"Bearer {get_tenant_token()}"},
json=body, timeout=10
)
data = resp.json()
if data.get("code") != 0:
logger.error(f"发送飞书消息失败: {data}")
return data.get("code") == 0
def send_feishu_card(open_id: str, card: dict):
"""发送飞书卡片消息"""
body = {
"receive_id": open_id,
"msg_type": "interactive",
"content": json.dumps(card)
}
resp = requests.post(
FEISHU_SEND_URL,
headers={"Authorization": f"Bearer {get_tenant_token()}"},
json=body, timeout=10
)
return resp.json().get("code") == 0
def send_webhook(text: str):
"""通过 Webhook 推送通知(用于订阅变更)"""
if not WEBHOOK_URL:
return
try:
requests.post(WEBHOOK_URL, json={
"msg_type": "text",
"content": {"text": text}
}, timeout=10)
except Exception as e:
logger.error(f"Webhook推送失败: {e}")
# ── Bot 核心逻辑 ────────────────────────────────
class FeishuBot:
def __init__(self):
self.db = sqlite3.connect(DB_PATH, check_same_thread=False)
self._init_db()
def _init_db(self):
self.db.execute("""
CREATE TABLE IF NOT EXISTS subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
open_id TEXT NOT NULL,
keyword TEXT NOT NULL,
last_check TEXT,
created_at TEXT DEFAULT (datetime('now','localtime')),
UNIQUE(open_id, keyword)
)
""")
self.db.commit()
logger.info("订阅数据库就绪")
def handle_text(self, open_id: str, text: str):
"""处理文本消息"""
text = text.strip()
if text.startswith("/search"):
keyword = text.replace("/search", "", 1).strip()
return self._cmd_search(open_id, keyword)
elif text.startswith("/subscribe"):
keyword = text.replace("/subscribe", "", 1).strip()
return self._cmd_subscribe(open_id, keyword)
elif text.startswith("/unsub"):
keyword = text.replace("/unsub", "", 1).strip()
return self._cmd_unsub(open_id, keyword)
elif text.startswith("/mysubs"):
return self._cmd_mysubs(open_id)
elif text.startswith("/help") or text.lower() == "help":
return self._cmd_help(open_id)
else:
return self._cmd_search(open_id, text) # 默认搜索
def _cmd_help(self, open_id: str):
help_text = (
"🔍 CloudSearch Bot\n\n"
"命令:\n"
"/search 关键词 — 搜索网盘资源\n"
"直接输入关键词也可以搜索\n"
"/subscribe 关键词 — 订阅关键词\n"
"/unsub 关键词 — 取消订阅\n"
"/mysubs — 查看我的订阅\n"
"/help — 帮助"
)
send_feishu_msg(open_id, help_text)
def _cmd_search(self, open_id: str, keyword: str):
if not keyword:
send_feishu_msg(open_id, "用法: /search 流浪地球2\n或直接输入关键词")
return
try:
resp = requests.post(
f"{CLOUDSEARCH_API}/api/query",
json={"q": keyword}, timeout=15
)
results = []
for line in resp.text.strip().split("\n"):
try:
d = json.loads(line)
if d.get("type") == "result":
results.append(d)
except json.JSONDecodeError:
continue
if not results:
send_feishu_msg(open_id, f"😞 未找到「{keyword}」的相关资源")
return
# 构建飞书卡片
elements = []
for i, r in enumerate(results[:5]):
title = (r.get("title") or r.get("content", ""))[:50]
cloud = r.get("cloud_type", "?").upper()
pwd = r.get("password", "")
pwd_str = f" 🔑{pwd}" if pwd else ""
elements.append({
"tag": "div",
"text": {"tag": "lark_md", "content": f"**{i+1}.** [{cloud}] {title}{pwd_str}"}
})
card = {
"header": {
"title": {"tag": "plain_text", "content": f"🔎 {keyword}{len(results)}个结果"},
"template": "blue"
},
"elements": elements + [{
"tag": "action",
"actions": [{
"tag": "button",
"text": {"tag": "plain_text", "content": "🌐 查看更多"},
"type": "primary",
"url": f"{CLOUDSEARCH_API}/?q={keyword}"
}]
}]
}
send_feishu_card(open_id, card)
except Exception as e:
send_feishu_msg(open_id, f"❌ 搜索失败: {e}")
def _cmd_subscribe(self, open_id: str, keyword: str):
if not keyword:
send_feishu_msg(open_id, "用法: /subscribe 流浪地球")
return
try:
self.db.execute(
"INSERT OR IGNORE INTO subscriptions (open_id, keyword) VALUES (?, ?)",
(open_id, keyword)
)
self.db.commit()
send_feishu_msg(open_id, f"✅ 已订阅「{keyword}」,有新结果会通知你")
except Exception as e:
send_feishu_msg(open_id, f"❌ 订阅失败: {e}")
def _cmd_unsub(self, open_id: str, keyword: str):
if not keyword:
send_feishu_msg(open_id, "用法: /unsub 流浪地球")
return
cur = self.db.execute(
"DELETE FROM subscriptions WHERE open_id=? AND keyword=?",
(open_id, keyword)
)
self.db.commit()
if cur.rowcount > 0:
send_feishu_msg(open_id, f"✅ 已取消订阅「{keyword}")
else:
send_feishu_msg(open_id, f"未找到「{keyword}」的订阅")
def _cmd_mysubs(self, open_id: str):
rows = self.db.execute(
"SELECT keyword, created_at FROM subscriptions WHERE open_id=? ORDER BY created_at DESC",
(open_id,)
).fetchall()
if not rows:
send_feishu_msg(open_id, "你还没有订阅任何关键词")
return
text = "📋 我的订阅:\n"
for kw, dt in rows:
text += f"{kw} ({dt[:10]})\n"
send_feishu_msg(open_id, text)
def check_subscriptions(self):
"""检查所有订阅,有新结果时推送通知"""
subs = self.db.execute("SELECT DISTINCT keyword FROM subscriptions").fetchall()
for (kw,) in subs:
try:
resp = requests.post(
f"{CLOUDSEARCH_API}/api/query",
json={"q": kw}, timeout=10
)
count = sum(1 for line in resp.text.split("\n")
if '"type":"result"' in line)
if count > 0:
# 通知所有订阅此关键词的用户
users = self.db.execute(
"SELECT open_id FROM subscriptions WHERE keyword=?",
(kw,)
).fetchall()
for (uid,) in users:
send_feishu_msg(uid, f"🔔「{kw}」有新资源({count}个)\n/search {kw}")
# Webhook 也推送
send_webhook(f"🔔 关键词「{kw}」发现 {count} 个新资源")
except Exception as e:
logger.error(f"检查订阅[{kw}]失败: {e}")
# ── Flask Web 服务 ─────────────────────────────
bot = FeishuBot()
app = Flask(__name__)
@app.route("/health")
def health():
return jsonify({"status": "ok", "bot": "feishu"})
@app.route("/feishu/event", methods=["POST"])
def feishu_event():
"""飞书事件订阅回调"""
body = request.get_json()
logger.info(f"飞书事件: {json.dumps(body, ensure_ascii=False)[:300]}")
# Token 验证(首次配置URL时)
if body.get("type") == "url_verification":
token = body.get("token", "")
if token == VERIFY_TOKEN:
return jsonify({"challenge": body.get("challenge", "")})
return jsonify({"error": "invalid token"}), 403
# 事件回调验证
if "header" in body:
# 收到消息事件
event = body.get("event", {})
msg_type = event.get("message", {}).get("message_type", "")
if msg_type == "text":
content = event["message"].get("content", "{}")
try:
text = json.loads(content).get("text", "")
except json.JSONDecodeError:
text = content
open_id = event.get("sender", {}).get("sender_id", {}).get("open_id", "")
if text and open_id:
bot.handle_text(open_id, text)
return jsonify({"code": 0})
@app.route("/feishu/check", methods=["POST"])
def trigger_check():
"""手动触发订阅检查"""
bot.check_subscriptions()
return jsonify({"ok": True})
# ── 启动入口 ───────────────────────────────────
def main():
if not APP_ID:
logger.warning("FEISHU_APP_ID 未设置,Bot 无法接收消息(仅 Webhook 可用)")
logger.info("飞书 Bot 启动,端口9531")
app.run(host="0.0.0.0", port=9532)
if __name__ == "__main__":
main()
-319
View File
@@ -1,319 +0,0 @@
"""
CloudSearch 飞书 Bot v1.0.0
替代 Telegram Bot,支持 /search /subscribe 命令 + Webhook 推送
通过飞书开放平台事件订阅接收消息
"""
import os
import json
import time
import hmac
import hashlib
import logging
import sqlite3
from typing import Optional
from flask import Flask, request, jsonify
import requests
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("feishubot")
# ── 飞书配置 ──────────────────────────────────
APP_ID = os.environ.get("FEISHU_APP_ID", "")
APP_SECRET = os.environ.get("FEISHU_APP_SECRET", "")
VERIFY_TOKEN = os.environ.get("FEISHU_VERIFY_TOKEN", "")
WEBHOOK_URL = os.environ.get("FEISHU_WEBHOOK_URL", "")
CLOUDSEARCH_API = os.environ.get("CLOUDSEARCH_API", "http://app:9527")
DB_PATH = os.environ.get("BOT_DB_PATH", "/data/bot.db")
# ── 飞书API ───────────────────────────────────
FEISHU_TOKEN_URL = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
FEISHU_SEND_URL = "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=open_id"
_tenant_token = None
_token_expire = 0
def get_tenant_token() -> str:
"""获取飞书 tenant_access_token(缓存2h"""
global _tenant_token, _token_expire
if _tenant_token and time.time() < _token_expire:
return _tenant_token
resp = requests.post(FEISHU_TOKEN_URL, json={
"app_id": APP_ID, "app_secret": APP_SECRET
}, timeout=10)
data = resp.json()
if data.get("code") != 0:
raise Exception(f"获取飞书Token失败: {data}")
_tenant_token = data["tenant_access_token"]
_token_expire = time.time() + data.get("expire", 7200) - 300
logger.info("飞书 tenant_token 已刷新")
return _tenant_token
def send_feishu_msg(open_id: str, content: str, msg_type: str = "text"):
"""发送飞书消息"""
body = {
"receive_id": open_id,
"msg_type": msg_type,
"content": json.dumps({"text": content}) if msg_type == "text" else content
}
resp = requests.post(
FEISHU_SEND_URL,
headers={"Authorization": f"Bearer {get_tenant_token()}"},
json=body, timeout=10
)
data = resp.json()
if data.get("code") != 0:
logger.error(f"发送飞书消息失败: {data}")
return data.get("code") == 0
def send_feishu_card(open_id: str, card: dict):
"""发送飞书卡片消息"""
body = {
"receive_id": open_id,
"msg_type": "interactive",
"content": json.dumps(card)
}
resp = requests.post(
FEISHU_SEND_URL,
headers={"Authorization": f"Bearer {get_tenant_token()}"},
json=body, timeout=10
)
return resp.json().get("code") == 0
def send_webhook(text: str):
"""通过 Webhook 推送通知(用于订阅变更)"""
if not WEBHOOK_URL:
return
try:
requests.post(WEBHOOK_URL, json={
"msg_type": "text",
"content": {"text": text}
}, timeout=10)
except Exception as e:
logger.error(f"Webhook推送失败: {e}")
# ── Bot 核心逻辑 ────────────────────────────────
class FeishuBot:
def __init__(self):
self.db = sqlite3.connect(DB_PATH, check_same_thread=False)
self._init_db()
def _init_db(self):
self.db.execute("""
CREATE TABLE IF NOT EXISTS subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
open_id TEXT NOT NULL,
keyword TEXT NOT NULL,
last_check TEXT,
created_at TEXT DEFAULT (datetime('now','localtime')),
UNIQUE(open_id, keyword)
)
""")
self.db.commit()
logger.info("订阅数据库就绪")
def handle_text(self, open_id: str, text: str):
"""处理文本消息"""
text = text.strip()
if text.startswith("/search"):
keyword = text.replace("/search", "", 1).strip()
return self._cmd_search(open_id, keyword)
elif text.startswith("/subscribe"):
keyword = text.replace("/subscribe", "", 1).strip()
return self._cmd_subscribe(open_id, keyword)
elif text.startswith("/unsub"):
keyword = text.replace("/unsub", "", 1).strip()
return self._cmd_unsub(open_id, keyword)
elif text.startswith("/mysubs"):
return self._cmd_mysubs(open_id)
elif text.startswith("/help") or text.lower() == "help":
return self._cmd_help(open_id)
else:
return self._cmd_search(open_id, text) # 默认搜索
def _cmd_help(self, open_id: str):
help_text = (
"🔍 CloudSearch Bot\n\n"
"命令:\n"
"/search 关键词 — 搜索网盘资源\n"
"直接输入关键词也可以搜索\n"
"/subscribe 关键词 — 订阅关键词\n"
"/unsub 关键词 — 取消订阅\n"
"/mysubs — 查看我的订阅\n"
"/help — 帮助"
)
send_feishu_msg(open_id, help_text)
def _cmd_search(self, open_id: str, keyword: str):
if not keyword:
send_feishu_msg(open_id, "用法: /search 流浪地球2\n或直接输入关键词")
return
try:
resp = requests.post(
f"{CLOUDSEARCH_API}/api/query",
json={"q": keyword}, timeout=15
)
results = []
for line in resp.text.strip().split("\n"):
try:
d = json.loads(line)
if d.get("type") == "result":
results.append(d)
except json.JSONDecodeError:
continue
if not results:
send_feishu_msg(open_id, f"😞 未找到「{keyword}」的相关资源")
return
# 构建飞书卡片
elements = []
for i, r in enumerate(results[:5]):
title = (r.get("title") or r.get("content", ""))[:50]
cloud = r.get("cloud_type", "?").upper()
pwd = r.get("password", "")
pwd_str = f" 🔑{pwd}" if pwd else ""
elements.append({
"tag": "div",
"text": {"tag": "lark_md", "content": f"**{i+1}.** [{cloud}] {title}{pwd_str}"}
})
card = {
"header": {
"title": {"tag": "plain_text", "content": f"🔎 {keyword}{len(results)}个结果"},
"template": "blue"
},
"elements": elements + [{
"tag": "action",
"actions": [{
"tag": "button",
"text": {"tag": "plain_text", "content": "🌐 查看更多"},
"type": "primary",
"url": f"{CLOUDSEARCH_API}/?q={keyword}"
}]
}]
}
send_feishu_card(open_id, card)
except Exception as e:
send_feishu_msg(open_id, f"❌ 搜索失败: {e}")
def _cmd_subscribe(self, open_id: str, keyword: str):
if not keyword:
send_feishu_msg(open_id, "用法: /subscribe 流浪地球")
return
try:
self.db.execute(
"INSERT OR IGNORE INTO subscriptions (open_id, keyword) VALUES (?, ?)",
(open_id, keyword)
)
self.db.commit()
send_feishu_msg(open_id, f"✅ 已订阅「{keyword}」,有新结果会通知你")
except Exception as e:
send_feishu_msg(open_id, f"❌ 订阅失败: {e}")
def _cmd_unsub(self, open_id: str, keyword: str):
if not keyword:
send_feishu_msg(open_id, "用法: /unsub 流浪地球")
return
cur = self.db.execute(
"DELETE FROM subscriptions WHERE open_id=? AND keyword=?",
(open_id, keyword)
)
self.db.commit()
if cur.rowcount > 0:
send_feishu_msg(open_id, f"✅ 已取消订阅「{keyword}")
else:
send_feishu_msg(open_id, f"未找到「{keyword}」的订阅")
def _cmd_mysubs(self, open_id: str):
rows = self.db.execute(
"SELECT keyword, created_at FROM subscriptions WHERE open_id=? ORDER BY created_at DESC",
(open_id,)
).fetchall()
if not rows:
send_feishu_msg(open_id, "你还没有订阅任何关键词")
return
text = "📋 我的订阅:\n"
for kw, dt in rows:
text += f"{kw} ({dt[:10]})\n"
send_feishu_msg(open_id, text)
def check_subscriptions(self):
"""检查所有订阅,有新结果时推送通知"""
subs = self.db.execute("SELECT DISTINCT keyword FROM subscriptions").fetchall()
for (kw,) in subs:
try:
resp = requests.post(
f"{CLOUDSEARCH_API}/api/query",
json={"q": kw}, timeout=10
)
count = sum(1 for line in resp.text.split("\n")
if '"type":"result"' in line)
if count > 0:
# 通知所有订阅此关键词的用户
users = self.db.execute(
"SELECT open_id FROM subscriptions WHERE keyword=?",
(kw,)
).fetchall()
for (uid,) in users:
send_feishu_msg(uid, f"🔔「{kw}」有新资源({count}个)\n/search {kw}")
# Webhook 也推送
send_webhook(f"🔔 关键词「{kw}」发现 {count} 个新资源")
except Exception as e:
logger.error(f"检查订阅[{kw}]失败: {e}")
# ── Flask Web 服务 ─────────────────────────────
bot = FeishuBot()
app = Flask(__name__)
@app.route("/health")
def health():
return jsonify({"status": "ok", "bot": "feishu"})
@app.route("/feishu/event", methods=["POST"])
def feishu_event():
"""飞书事件订阅回调"""
body = request.get_json()
logger.info(f"飞书事件: {json.dumps(body, ensure_ascii=False)[:300]}")
# Token 验证(首次配置URL时)
if body.get("type") == "url_verification":
token = body.get("token", "")
if token == VERIFY_TOKEN:
return jsonify({"challenge": body.get("challenge", "")})
return jsonify({"error": "invalid token"}), 403
# 事件回调验证
if "header" in body:
# 收到消息事件
event = body.get("event", {})
msg_type = event.get("message", {}).get("message_type", "")
if msg_type == "text":
content = event["message"].get("content", "{}")
try:
text = json.loads(content).get("text", "")
except json.JSONDecodeError:
text = content
open_id = event.get("sender", {}).get("sender_id", {}).get("open_id", "")
if text and open_id:
bot.handle_text(open_id, text)
return jsonify({"code": 0})
@app.route("/feishu/check", methods=["POST"])
def trigger_check():
"""手动触发订阅检查"""
bot.check_subscriptions()
return jsonify({"ok": True})
# ── 启动入口 ───────────────────────────────────
def main():
if not APP_ID:
logger.warning("FEISHU_APP_ID 未设置,Bot 无法接收消息(仅 Webhook 可用)")
logger.info("飞书 Bot 启动,端口9531")
app.run(host="0.0.0.0", port=9531)
if __name__ == "__main__":
main()
-3
View File
@@ -1,3 +0,0 @@
flask>=3.0
requests>=2.28
python-telegram-bot>=20.0
-132
View File
@@ -1,132 +0,0 @@
"""
CloudSearch Search Enricher v1.0.0
搜索结果增强:TMDB匹配 + 过期检测 + 内容去重
"""
import time
import logging
from typing import List, Dict, Any, Optional
from tmdb_enricher import TMDBEnricher
logger = logging.getLogger("enricher")
class SearchEnricher:
"""搜索结果增强器"""
def __init__(self, tmdb_api_key: str = "", cache_ttl: int = 86400):
self.tmdb = TMDBEnricher(tmdb_api_key, cache_ttl=cache_ttl) if tmdb_api_key else None
def enrich_results(self, results: List[Dict], keyword: str = "") -> List[Dict]:
"""批量增强搜索结果"""
if not results:
return results
enriched = []
titles_to_lookup = []
# 收集需要查 TMDB 的标题
for r in results:
title = r.get("title", "")
if title and self.tmdb:
titles_to_lookup.append(title)
# 批量查询 TMDB
tmdb_results = {}
if titles_to_lookup and self.tmdb:
tmdb_results = self.tmdb.enrich_batch(titles_to_lookup[:20], max_concurrent=5)
# 应用增强
for r in results:
title = r.get("title", "")
media = tmdb_results.get(title)
enriched_item = dict(r)
if media:
enriched_item.update({
"tmdb_id": media.tmdb_id,
"tmdb_url": media.tmdb_url,
"poster": media.poster_url,
"backdrop": media.backdrop_url,
"rating": media.rating,
"rating_count": media.rating_count,
"year": media.year,
"genres": media.genres,
"description": media.description,
"media_type": media.media_type,
"directors": media.directors,
"actors": media.actors[:5],
"enriched": True,
})
# 自动生成更好的标题
if media.year and media.rating:
enriched_item["display_title"] = (
f"{title} ({media.year}) ⭐{media.rating}"
)
enriched.append(enriched_item)
return enriched
def enrich_single(self, title: str, keyword: str = "") -> Optional[Dict]:
"""增强单个标题"""
if not self.tmdb:
return None
media = self.tmdb.enrich(title)
if not media:
return None
return {
"title": title,
"tmdb_id": media.tmdb_id,
"poster": media.poster_url,
"rating": media.rating,
"year": media.year,
"genres": media.genres,
"description": media.description,
"media_type": media.media_type,
}
# Flask API wrapper
def create_enricher_api(tmdb_key: str = ""):
from flask import Flask, request, jsonify
app = Flask(__name__)
enricher = SearchEnricher(tmdb_key)
@app.route("/health", methods=["GET"])
def health():
return jsonify({"status": "ok", "version": "1.0.0"})
@app.route("/enrich", methods=["POST"])
def enrich():
data = request.get_json() or {}
results = data.get("results", [])
keyword = data.get("keyword", "")
if not results:
return jsonify({"error": "results required"}), 400
enriched = enricher.enrich_results(results, keyword)
return jsonify({"results": enriched, "count": len(enriched)})
@app.route("/lookup", methods=["POST"])
def lookup():
data = request.get_json() or {}
title = data.get("title", "")
if not title:
return jsonify({"error": "title required"}), 400
info = enricher.enrich_single(title)
return jsonify(info or {})
return app
if __name__ == "__main__":
import os
api_key = os.getenv("TMDB_API_KEY", "")
port = int(os.getenv("PORT", "9530"))
app = create_enricher_api(api_key)
logger.info(f"Enricher API on port {port}")
app.run(host="0.0.0.0", port=port)
-204
View File
@@ -1,204 +0,0 @@
"""
CloudSearch Subscription Monitor v1.0.0
关键词订阅 + 新资源检测 + 多渠道通知
"""
import os
import json
import time
import sqlite3
import logging
import requests
from typing import List, Dict, Optional
from dataclasses import dataclass
logger = logging.getLogger("subscription")
@dataclass
class Notification:
chat_id: int
keyword: str
new_count: int
results: List[dict]
channel: str = "telegram" # telegram / feishu / dingtalk
class SubscriptionMonitor:
"""订阅监控:定时搜索关键词,发现新资源后推送通知"""
def __init__(self, api_base: str, db_path: str = "/data/subscriptions.db",
tg_bot_token: str = None):
self.api_base = api_base.rstrip("/")
self.tg_token = tg_bot_token
self.db = sqlite3.connect(db_path, check_same_thread=False)
self._init_db()
def _init_db(self):
self.db.executescript("""
CREATE TABLE IF NOT EXISTS subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_id INTEGER NOT NULL,
keyword TEXT NOT NULL,
last_result_hash TEXT,
last_check TEXT,
created_at TEXT DEFAULT (datetime('now','localtime')),
UNIQUE(chat_id, keyword)
);
CREATE TABLE IF NOT EXISTS sent_notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subscription_id INTEGER,
result_hash TEXT,
sent_at TEXT DEFAULT (datetime('now','localtime')),
FOREIGN KEY(subscription_id) REFERENCES subscriptions(id)
);
""")
self.db.commit()
def check_all(self, batch_size: int = 10) -> List[Notification]:
"""检查所有订阅,返回需要通知的列表"""
subs = self.db.execute(
"SELECT id, chat_id, keyword, last_result_hash FROM subscriptions ORDER BY last_check ASC LIMIT ?",
(batch_size,)
).fetchall()
notifications = []
for sub_id, chat_id, keyword, last_hash in subs:
try:
result = self._search(keyword)
new_hash = self._hash_results(result)
if new_hash and new_hash != last_hash:
new_results = self._filter_new(sub_id, result, last_hash)
if new_results:
notifications.append(Notification(
chat_id=chat_id,
keyword=keyword,
new_count=len(new_results),
results=new_results[:5],
))
# 更新状态
self.db.execute(
"UPDATE subscriptions SET last_result_hash=?, last_check=datetime('now','localtime') WHERE id=?",
(new_hash, sub_id)
)
except Exception as e:
logger.error(f"Check failed: {keyword} - {e}")
self.db.commit()
return notifications
def _search(self, keyword: str) -> list:
"""搜索关键词"""
try:
resp = requests.post(
f"{self.api_base}/api/query",
json={"q": keyword},
timeout=20
)
results = []
for line in resp.text.strip().split("\n"):
try:
d = json.loads(line)
if d.get("type") == "result":
results.append({
"title": d.get("title", ""),
"url": d.get("share_url", ""),
"cloud": d.get("cloud_type", ""),
"source": d.get("source", ""),
})
except json.JSONDecodeError:
continue
return results
except Exception as e:
logger.error(f"Search error: {e}")
return []
def _hash_results(self, results: list) -> str:
"""计算结果哈希"""
import hashlib
key = "|".join(
r.get("url", "")[:50] for r in sorted(
results, key=lambda x: x.get("url", "")
)
)
return hashlib.md5(key.encode()).hexdigest()
def _filter_new(self, sub_id: int, results: list, last_hash: str) -> list:
"""过滤出新结果"""
new = []
for r in results:
rhash = str(hash(r.get("url", "")))
existing = self.db.execute(
"SELECT id FROM sent_notifications WHERE subscription_id=? AND result_hash=?",
(sub_id, rhash)
).fetchone()
if not existing:
new.append(r)
self.db.execute(
"INSERT OR IGNORE INTO sent_notifications (subscription_id, result_hash) VALUES (?,?)",
(sub_id, rhash)
)
return new
def notify_telegram(self, notif: Notification):
"""通过 Telegram 发送通知"""
if not self.tg_token:
return
text = f"🔔 *{notif.keyword}* 有新资源!({notif.new_count}个)\n\n"
for i, r in enumerate(notif.results[:5]):
title = r.get("title", "")[:40]
url = r.get("url", "")
cloud = r.get("cloud", "?").upper()
text += f"{i+1}. [{cloud}] [{title}]({url})\n"
try:
requests.post(
f"https://api.telegram.org/bot{self.tg_token}/sendMessage",
json={
"chat_id": notif.chat_id,
"text": text,
"parse_mode": "Markdown",
"disable_web_page_preview": True,
},
timeout=10
)
except Exception as e:
logger.error(f"TG notify failed: {e}")
def notify_feishu(self, notif: Notification, webhook_url: str):
"""通过飞书发送通知"""
text = f"🔔 {notif.keyword} 有新资源!({notif.new_count}个)\n"
for r in notif.results[:5]:
text += f"• [{r.get('cloud','?').upper()}] {r.get('title','')[:40]} {r.get('url','')}\n"
try:
requests.post(webhook_url, json={
"msg_type": "text",
"content": {"text": text}
}, timeout=10)
except Exception as e:
logger.error(f"Feishu notify failed: {e}")
def run_loop(self, interval_minutes: int = 15):
"""循环运行"""
logger.info(f"Subscription monitor started (interval={interval_minutes}min)")
while True:
try:
notifs = self.check_all()
for n in notifs:
self.notify_telegram(n)
if notifs:
logger.info(f"Sent {len(notifs)} notifications")
except Exception as e:
logger.error(f"Monitor error: {e}")
time.sleep(interval_minutes * 60)
if __name__ == "__main__":
api = os.getenv("CLOUDSEARCH_API", "http://127.0.0.1:9527")
token = os.getenv("TG_BOT_TOKEN", "")
interval = int(os.getenv("CHECK_INTERVAL", "15"))
monitor = SubscriptionMonitor(api, tg_bot_token=token)
monitor.run_loop(interval)
-183
View File
@@ -1,183 +0,0 @@
"""
CloudSearch Telegram Bot v1.0.0
提供: /search /subscribe /hot /help
"""
import os
import json
import time
import logging
import sqlite3
from typing import Optional
import requests
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import (
Application, CommandHandler, MessageHandler,
CallbackQueryHandler, ContextTypes, filters
)
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("tgbot")
class CloudSearchBot:
def __init__(self, token: str, api_base: str, db_path: str = "/data/bot.db"):
self.token = token
self.api_base = api_base.rstrip("/")
self.db = sqlite3.connect(db_path, check_same_thread=False)
self._init_db()
def _init_db(self):
self.db.execute("""
CREATE TABLE IF NOT EXISTS subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_id INTEGER NOT NULL,
keyword TEXT NOT NULL,
last_check TEXT,
created_at TEXT DEFAULT (datetime('now', 'localtime')),
UNIQUE(chat_id, keyword)
)
""")
self.db.commit()
async def start(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
await update.message.reply_text(
"🔍 *CloudSearch Bot* v1.0\n\n"
"命令:\n"
"/search 关键词 — 搜索网盘资源\n"
"/hot — 热门搜索\n"
"/subscribe 关键词 — 订阅关键词\n"
"/unsub 关键词 — 取消订阅\n"
"/mysubs — 我的订阅\n"
"/help — 帮助",
parse_mode="Markdown"
)
async def search(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
keyword = " ".join(context.args) if context.args else ""
if not keyword:
await update.message.reply_text("用法: /search 流浪地球2")
return
msg = await update.message.reply_text(f"🔎 搜索中: *{keyword}*...", parse_mode="Markdown")
try:
resp = requests.post(
f"{self.api_base}/api/query",
json={"q": keyword},
timeout=15
)
# Parse NDJSON response
results = []
content_info = None
for line in resp.text.strip().split("\n"):
try:
data = json.loads(line)
if data.get("type") == "result":
results.append(data)
elif data.get("type") == "stats":
content_info = data.get("content_info")
except json.JSONDecodeError:
continue
if not results:
await msg.edit_text(f"😞 未找到「{keyword}」的相关资源")
return
# Format top 5 results
text = f"🔎 *{keyword}* — {len(results)} 个结果\n\n"
for i, r in enumerate(results[:5]):
title = (r.get("title") or r.get("content", ""))[:40]
cloud = r.get("cloud_type", "?").upper()
url = r.get("share_url", "")
pwd = r.get("password", "")
pwd_str = f" 🔑`{pwd}`" if pwd else ""
text += f"{i+1}. [{cloud}] [{title}]({url}){pwd_str}\n"
keyboard = [[
InlineKeyboardButton("🌐 查看更多", url=f"{self.api_base}/?q={keyword}")
]]
await msg.edit_text(
text,
parse_mode="Markdown",
disable_web_page_preview=True,
reply_markup=InlineKeyboardMarkup(keyboard)
)
except Exception as e:
await msg.edit_text(f"❌ 搜索失败: {e}")
async def subscribe(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
keyword = " ".join(context.args) if context.args else ""
if not keyword:
await update.message.reply_text("用法: /subscribe 流浪地球")
return
try:
self.db.execute(
"INSERT OR IGNORE INTO subscriptions (chat_id, keyword) VALUES (?, ?)",
(update.effective_chat.id, keyword)
)
self.db.commit()
await update.message.reply_text(f"✅ 已订阅: *{keyword}*", parse_mode="Markdown")
except Exception as e:
await update.message.reply_text(f"❌ 订阅失败: {e}")
async def unsub(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
keyword = " ".join(context.args) if context.args else ""
self.db.execute(
"DELETE FROM subscriptions WHERE chat_id=? AND keyword=?",
(update.effective_chat.id, keyword)
)
self.db.commit()
await update.message.reply_text(f"🗑 已取消: *{keyword}*", parse_mode="Markdown")
async def mysubs(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
subs = self.db.execute(
"SELECT keyword, created_at FROM subscriptions WHERE chat_id=? ORDER BY created_at DESC LIMIT 20",
(update.effective_chat.id,)
).fetchall()
if not subs:
await update.message.reply_text("📭 暂无订阅")
return
text = "📋 *我的订阅*\n" + "\n".join(f"{s[0]}" for s in subs)
await update.message.reply_text(text, parse_mode="Markdown")
async def hot(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
try:
resp = requests.get(f"{self.api_base}/api/rankings/hot?limit=10", timeout=10)
data = resp.json()
keywords = data if isinstance(data, list) else data.get("keywords", [])
text = "🔥 *热门搜索*\n" + "\n".join(
f"{i+1}. {kw.get('keyword', str(kw))}" for i, kw in enumerate(keywords[:10])
)
except:
text = "🔥 获取热门失败,请稍后重试"
await update.message.reply_text(text, parse_mode="Markdown")
async def help_cmd(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
await self.start(update, context)
def run(self):
app = Application.builder().token(self.token).build()
app.add_handler(CommandHandler("start", self.start))
app.add_handler(CommandHandler("search", self.search))
app.add_handler(CommandHandler("s", self.search))
app.add_handler(CommandHandler("hot", self.hot))
app.add_handler(CommandHandler("subscribe", self.subscribe))
app.add_handler(CommandHandler("sub", self.subscribe))
app.add_handler(CommandHandler("unsub", self.unsub))
app.add_handler(CommandHandler("mysubs", self.mysubs))
app.add_handler(CommandHandler("help", self.help_cmd))
logger.info("Bot starting...")
app.run_polling()
if __name__ == "__main__":
token = os.getenv("TG_BOT_TOKEN", "")
api = os.getenv("CLOUDSEARCH_API", "http://127.0.0.1:9527")
bot = CloudSearchBot(token, api)
bot.run()
-179
View File
@@ -1,179 +0,0 @@
"""
CloudSearch TMDB Enricher v1.0.0
自动匹配影视元数据:海报、评分、简介、年份、类型
"""
import time
import logging
from typing import Optional, Dict, Any, List
from dataclasses import dataclass, field
import requests
logger = logging.getLogger(__name__)
TMDB_API_BASE = "https://api.themoviedb.org/3"
TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p/w500"
@dataclass
class MediaInfo:
"""影视元数据"""
title: str = ""
original_title: str = ""
year: str = ""
poster_url: str = ""
backdrop_url: str = ""
rating: str = ""
rating_count: int = 0
description: str = ""
genres: List[str] = field(default_factory=list)
media_type: str = "" # movie / tv
tmdb_id: int = 0
directors: List[str] = field(default_factory=list)
actors: List[str] = field(default_factory=list)
region: str = ""
duration: str = ""
seasons: int = 0
episodes: int = 0
source: str = "tmdb"
tmdb_url: str = ""
class TMDBEnricher:
"""TMDB 影视信息增强器"""
# 常见网盘文件名模式 → 影视标题提取
TITLE_PATTERNS = [
# [4K] 流浪地球2 (2023)
(r'\[.*?\]\s*(.+?)\s*[\(](\d{4})[\)]', 2),
# 流浪地球2.2023.4K
(r'(.+?)\.(\d{4})\.(?:4K|1080[Pp]|2160[Pp]|HD)', 2),
# 流浪地球2 2023
(r'(.+?)\s+(\d{4})\s', 2),
# S01E01 格式
(r'(.+?)[\.\s][Ss](\d{2})[Ee](\d{2})', 1),
]
def __init__(self, api_key: str, language: str = "zh-CN",
cache_ttl: int = 86400):
self.api_key = api_key
self.language = language
self.cache_ttl = cache_ttl
self._cache: Dict[str, tuple] = {} # key → (data, timestamp)
def enrich(self, title: str, media_type: str = None) -> Optional[MediaInfo]:
"""根据标题查询 TMDB 元数据"""
clean_title, year = self._extract_title_year(title)
cache_key = f"{clean_title}:{year}:{media_type}"
if cache_key in self._cache:
data, ts = self._cache[cache_key]
if time.time() - ts < self.cache_ttl:
return data
# 智能判断类型
if not media_type:
media_type = self._guess_type(clean_title)
info = self._search(clean_title, year, media_type)
if info:
self._cache[cache_key] = (info, time.time())
return info
def enrich_batch(self, titles: List[str], max_concurrent: int = 5) -> Dict[str, MediaInfo]:
"""批量查询"""
from concurrent.futures import ThreadPoolExecutor, as_completed
results = {}
with ThreadPoolExecutor(max_workers=max_concurrent) as ex:
futures = {ex.submit(self.enrich, t): t for t in titles}
for f in as_completed(futures):
try:
results[futures[f]] = f.result()
except Exception as e:
logger.warning(f"TMDB enrich failed: {futures[f]} - {e}")
return results
def _extract_title_year(self, title: str) -> tuple:
"""从文件名提取标题和年份"""
import re
for pattern, year_group in self.TITLE_PATTERNS:
m = re.search(pattern, title, re.IGNORECASE)
if m:
name = m.group(1).strip()
year = m.group(year_group) if year_group <= len(m.groups()) else ""
# 去掉常见的后缀
name = re.sub(r'\s*[\[(].*?(?:完结|全\d+集|更新).*?[\])]', '', name)
return name.strip(), year
return title.strip(), ""
def _guess_type(self, title: str) -> str:
"""根据标题特征判断电影/电视剧"""
import re
tv_patterns = [
r'[Ss]\d{2}[Ee]\d{2}', r'第[一二三四五六七八九十\d]+季',
r'[Ss]eason\s*\d+', r'\d+集', r'更新至\d+',
]
for p in tv_patterns:
if re.search(p, title):
return "tv"
return "movie"
def _search(self, title: str, year: str = "", media_type: str = "movie") -> Optional[MediaInfo]:
"""搜索 TMDB"""
try:
# 搜索
search_type = "tv" if media_type == "tv" else "movie"
params = {
"api_key": self.api_key,
"query": title,
"language": self.language,
"page": 1,
}
if year:
params["year" if search_type == "movie" else "first_air_date_year"] = year
resp = requests.get(
f"{TMDB_API_BASE}/search/{search_type}",
params=params, timeout=10
)
data = resp.json()
results = data.get("results", [])
if not results and search_type == "movie":
# 电视剧也试一下
resp2 = requests.get(
f"{TMDB_API_BASE}/search/tv",
params=params, timeout=10
)
data2 = resp2.json()
results = data2.get("results", [])
if not results:
return None
item = results[0]
return self._parse_result(item, media_type)
except Exception as e:
logger.error(f"TMDB search error: {title} - {e}")
return None
def _parse_result(self, item: dict, media_type: str) -> MediaInfo:
"""解析 TMDB 返回"""
mid = item.get("id", 0)
is_tv = media_type == "tv" or item.get("media_type") == "tv"
return MediaInfo(
title=item.get("title") or item.get("name", ""),
original_title=item.get("original_title") or item.get("original_name", ""),
year=str(item.get("release_date", item.get("first_air_date", ""))[:4]),
poster_url=f"{TMDB_IMAGE_BASE}{item['poster_path']}" if item.get("poster_path") else "",
backdrop_url=f"{TMDB_IMAGE_BASE}{item['backdrop_path']}" if item.get("backdrop_path") else "",
rating=str(round(item.get("vote_average", 0), 1)),
rating_count=item.get("vote_count", 0),
description=(item.get("overview") or "")[:500],
genres=[g.get("name", "") for g in item.get("genre_ids", [])],
media_type="tv" if is_tv else "movie",
tmdb_id=mid,
tmdb_url=f"https://www.themoviedb.org/{'tv' if is_tv else 'movie'}/{mid}",
)
-21
View File
@@ -1,21 +0,0 @@
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONPATH=/app
COPY cloudsearch_transfer/requirements.txt ./requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
COPY cloudsearch_transfer ./cloudsearch_transfer
RUN mkdir -p /data
ENV PORT=9528
ENV TRANSFER_CONFIG_PATH=/data/transfer_config.json
EXPOSE 9528
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD python -c "import os, urllib.request; urllib.request.urlopen('http://127.0.0.1:%s/health' % os.getenv('PORT', '9528'), timeout=3)"
CMD ["python", "-m", "cloudsearch_transfer.server"]
-32
View File
@@ -1,32 +0,0 @@
"""CloudSearch Transfer v1.0.0 — 多网盘转存模块化服务
支持平台: quark, baidu, aliyun, uc, xunlei (+ 115/123/cloud189 扩展)
架构:
cloudsearch_transfer/
├── adapter/ # 网盘适配器(每平台独立子包)
│ ├── base.py # 抽象基类
│ ├── factory.py # 工厂+缓存
│ ├── quark/ # 夸克网盘 (credential/transfer/cleanup)
│ ├── baidu/ # 百度网盘
│ ├── aliyun/ # 阿里云盘
│ ├── uc/ # UC网盘
│ └── xunlei/ # 迅雷网盘
├── credential/ # 统一凭证管理
│ └── manager.py
├── orchestration/ # 转存编排
│ └── transfer.py
├── config.py # 配置管理
├── errors.py # 错误码
└── server.py # HTTP API 服务
使用:
from cloudsearch_transfer import TransferOrchestrator, ConfigManager
cm = ConfigManager()
orch = TransferOrchestrator(cm)
result = orch.transfer("https://pan.quark.cn/s/xxxx")
print(result.share_url)
"""
__version__ = "1.0.0"
-1
View File
@@ -1 +0,0 @@
"""CloudSearch Transfer — 适配器包"""
@@ -1,373 +0,0 @@
"""
阿里云盘适配器 v1.0.0
AliyunAdapter — 继承 BaseCloudDriveAdapter,实现阿里云盘全部转存能力。
组件:
- AliyunCredentialManager: refresh_token 刷新 + 缓存
- AliyunTransfer: 4 步批量转存
- AliyunCleanup: 回收站清理
URL 匹配: aliyundrive.com/s/<share_id>
"""
import re
import logging
from typing import List, Dict, Tuple, Optional
from ..base import BaseCloudDriveAdapter, FileInfo, match_url
from ...config import PlatformConfig, TransferConfig
from ...errors import TransferError, TransferErrorCode
from .credential import AliyunCredentialManager
from .transfer import AliyunTransfer
from .cleanup import AliyunCleanup
logger = logging.getLogger(__name__)
class AliyunAdapter(BaseCloudDriveAdapter):
"""阿里云盘适配器"""
PLATFORM_NAME = "阿里云盘"
PLATFORM_KEY = "aliyun"
URL_PATTERNS = [
r'aliyundrive\.com/s/([a-zA-Z0-9]+)',
r'alipan\.com/s/([a-zA-Z0-9]+)',
]
DEFAULT_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/135.0.0.0 Safari/537.36"
),
"Accept": "application/json, text/plain, */*",
"Content-Type": "application/json",
"Referer": "https://aliyundrive.com",
}
capabilities: Dict[str, bool] = {
**BaseCloudDriveAdapter.capabilities,
"ensure_dir": True,
"save_files": True,
"rename": True,
"move_files": True,
"delete_files": True,
}
def __init__(self, config: PlatformConfig, transfer_config: TransferConfig):
# BaseCloudDriveAdapter.__init__ calls _setup_session(), so credential
# state must exist before super().__init__.
refresh_token = config.refresh_token or config.cookie or ""
self._credential = AliyunCredentialManager(refresh_token=refresh_token)
self._drive_id = ""
self._transfer: Optional[AliyunTransfer] = None
self._cleanup: Optional[AliyunCleanup] = None
super().__init__(config, transfer_config)
def _setup_session(self):
"""初始化 session 和凭证"""
refresh_token = getattr(self._credential, "refresh_token", "")
if refresh_token:
# 验证 refresh_token 并获取 drive_id
if self._credential.validate():
self._drive_id = self._credential.get_drive_id()
logger.info(
f"[AliyunAdapter] 凭证验证成功, drive_id={self._drive_id[:8]}..."
)
else:
logger.warning("[AliyunAdapter] 凭证验证失败,转存功能可能不可用")
else:
logger.warning("[AliyunAdapter] 未配置 refresh_token")
# ─── 核心抽象方法实现 ──────────────────────────────────
def _get_share_detail(self, pwd_id: str, passcode: str = "") -> dict:
"""
获取分享详情。
步骤①②: 先获取匿名分享信息,再获取 share_token。
Returns:
{
"title": "分享标题",
"share_id": "...",
"share_token": "...",
"files": [{"file_id": "...", "name": "...", "size": 0, "type": "file"}, ...],
}
"""
try:
transfer = self._get_transfer()
# ① 获取分享信息(匿名)
share_info = transfer._get_share_info(pwd_id)
if not share_info:
raise TransferError(
TransferErrorCode.SHARE_NOT_EXIST,
platform=self.PLATFORM_KEY,
)
# ② 获取分享令牌(Auth
share_token = transfer._get_share_token(pwd_id, passcode)
if not share_token:
raise TransferError(
TransferErrorCode.PASSCODE_WRONG if passcode else TransferErrorCode.SHARE_NOT_EXIST,
platform=self.PLATFORM_KEY,
message="获取分享令牌失败(可能需要提取码)",
)
return {
"title": share_info.get("share_name", share_info.get("share_title", "")),
"share_id": pwd_id,
"share_token": share_token,
"files": share_info.get("file_infos", []),
"creator_name": share_info.get("creator_name", ""),
}
except TransferError:
raise
except Exception as e:
logger.exception(f"[AliyunAdapter] 获取分享详情失败: {e}")
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=str(e),
platform=self.PLATFORM_KEY,
)
def _save_files(self, pwd_id: str, detail: dict, save_dir: str) -> List[str]:
"""
步骤③: 批量复制文件到自己的网盘。
Args:
pwd_id: 分享 ID
detail: _get_share_detail 的返回值
save_dir: 目标目录(根目录用 "root"
Returns:
新文件 ID 列表
"""
share_token = detail.get("share_token", "")
files = detail.get("files", [])
if not share_token:
raise TransferError(
TransferErrorCode.SHARE_NOT_EXIST,
message="缺少 share_token",
platform=self.PLATFORM_KEY,
)
if not files:
raise TransferError(
TransferErrorCode.RESOURCE_EMPTY,
platform=self.PLATFORM_KEY,
)
file_ids = [f.get("file_id", "") for f in files if f.get("file_id")]
if not file_ids:
raise TransferError(
TransferErrorCode.RESOURCE_EMPTY,
message="无法提取文件 ID",
platform=self.PLATFORM_KEY,
)
# 确定目标目录:路径先解析/创建为 file_id。
to_parent = save_dir if save_dir and save_dir != "/" else "root"
if isinstance(to_parent, str) and to_parent.startswith("/"):
to_parent = self.ensure_dir(to_parent)
transfer = self._get_transfer()
new_ids = transfer._batch_copy(pwd_id, share_token, file_ids, to_parent)
if not new_ids:
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message="批量转存失败,所有文件复制均失败",
platform=self.PLATFORM_KEY,
)
return new_ids
def _create_share(
self, file_ids: List[str], title: str, password: str = ""
) -> Tuple[str, str]:
"""
步骤④: 创建新分享链接。
Returns:
(share_url, share_password)
"""
if not file_ids:
raise TransferError(
TransferErrorCode.RESOURCE_EMPTY,
platform=self.PLATFORM_KEY,
)
transfer = self._get_transfer()
result = transfer._create_share(file_ids, password)
share_url = result.get("share_url", "")
share_pwd = result.get("share_pwd", password)
if not share_url:
raise TransferError(
TransferErrorCode.SHARE_LINK_FAIL,
message="创建分享链接失败",
platform=self.PLATFORM_KEY,
)
return share_url, share_pwd
def get_files(self, parent_fid: str = "0") -> List[FileInfo]:
"""
列出网盘目录下的文件。
NOTE: 当前实现为占位。如需完整功能,请调用阿里云盘 /adrive/v3/file/list API。
"""
logger.warning("[AliyunAdapter] get_files() 未完整实现,返回空列表")
return []
def delete(self, file_ids: List[str]) -> bool:
"""
删除文件(移入回收站)。
Args:
file_ids: 要删除的文件 ID 列表
Returns:
是否全部删除成功
"""
if not file_ids:
return True
cleanup = self._get_cleanup()
result = cleanup.delete_files(file_ids)
return result.get("success", False)
# ─── 扩展功能 ──────────────────────────────────────────
# ─── Optional Drive API capability methods ─────────────────────
def get_fids(self, file_paths: List[str]) -> List[Dict]:
wanted = {p.rstrip("/") or "/" for p in file_paths}
found: List[Dict] = []
for path in wanted:
if path == "/":
found.append({"file_path": "/", "fid": "root"})
continue
parent = path.rsplit("/", 1)[0] or "/"
name = path.rsplit("/", 1)[-1]
for item in self.get_files(parent):
if item.is_dir and item.name == name:
found.append({"file_path": path, "fid": item.fid})
break
return found
def ensure_dir(self, dir_path: str) -> str:
normalized = "/" + (dir_path or "/").strip("/")
if normalized == "/":
return "root"
current = ""
last_fid = "root"
for part in [p for p in normalized.split("/") if p]:
current = f"{current}/{part}" if current else f"/{part}"
matches = self.get_fids([current])
if matches:
last_fid = matches[0].get("fid") or matches[0].get("file_id") or last_fid
continue
created = self.mkdir(current)
data = created.get("data", created) if isinstance(created, dict) else {}
last_fid = data.get("fid") or data.get("file_id") or data.get("id") or last_fid
return last_fid
def mkdir(self, dir_path: str) -> Dict:
parent = dir_path.rsplit("/", 1)[0] or "/"
name = dir_path.rstrip("/").rsplit("/", 1)[-1]
parent_fid = "root" if parent == "/" else self.ensure_dir(parent)
url = "https://api.aliyundrive.com/adrive/v2/file/createWithFolders"
body = {
"drive_id": self._drive_id or self._credential.get_drive_id(),
"parent_file_id": parent_fid,
"name": name,
"type": "folder",
"check_name_mode": "refuse",
}
resp = self._post(url, json_data=body, headers=self._credential.get_headers())
data = self._drive_api_json(resp, context="阿里云盘创建目录")
return {"code": 0, "status": 200, "data": {"fid": data.get("file_id", ""), **data}}
def rename(self, fid: str, file_name: str) -> Dict:
url = "https://api.aliyundrive.com/v3/file/update"
body = {"drive_id": self._drive_id or self._credential.get_drive_id(), "file_id": fid, "name": file_name, "check_name_mode": "refuse"}
data = self._drive_api_json(self._post(url, json_data=body, headers=self._credential.get_headers()), context="阿里云盘重命名")
return {"code": 0, "status": 200, "data": data}
def move_files(self, fids: List[str], to_pdir_fid: str) -> Dict:
if isinstance(to_pdir_fid, str) and to_pdir_fid.startswith("/"):
to_pdir_fid = self.ensure_dir(to_pdir_fid)
drive_id = self._drive_id or self._credential.get_drive_id()
requests = [{"id": fid, "method": "POST", "url": "/file/move", "headers": {"Content-Type": "application/json"}, "body": {"drive_id": drive_id, "file_id": fid, "to_parent_file_id": to_pdir_fid}} for fid in fids]
data = self._drive_api_json(self._post("https://api.aliyundrive.com/adrive/v4/batch", json_data={"requests": requests, "resource": "file"}, headers=self._credential.get_headers()), context="阿里云盘移动文件")
return {"code": 0, "status": 200, "data": data}
def delete_files(self, fids: List[str]) -> Dict:
return {"code": 0, "status": 200} if self.delete(fids) else {"code": -1, "status": 500}
def cleanup_files(self, file_ids: List[str]) -> Dict:
"""
清理文件(移入回收站),返回详细结果。
Returns:
AliyunCleanup.delete_files() 的返回字典
"""
cleanup = self._get_cleanup()
return cleanup.delete_files(file_ids)
def force_refresh_token(self) -> bool:
"""强制刷新 access_token"""
return self._credential.refresh()
def get_credential_status(self) -> Dict:
"""获取当前凭证状态"""
return self._credential.to_dict()
# ─── 文件列表提取 ──────────────────────────────────────
def _extract_file_list(self, detail: dict) -> List[FileInfo]:
"""从分享详情中提取 FileInfo 列表"""
files = detail.get("files", [])
result = []
for f in files:
result.append(FileInfo(
fid=f.get("file_id", ""),
name=f.get("name", ""),
size=int(f.get("size", 0)),
is_dir=f.get("type", "") == "folder",
ext=f.get("file_extension", ""),
))
return result
# ─── 内部辅助方法 ──────────────────────────────────────
def _get_transfer(self) -> AliyunTransfer:
"""懒加载获取 AliyunTransfer 实例"""
if self._transfer is None:
drive_id = self._drive_id or self._credential.get_drive_id()
self._transfer = AliyunTransfer(
credential=self._credential,
drive_id=drive_id,
to_parent_file_id=self.config.save_dir or "root",
request_timeout=self.transfer_config.request_timeout,
)
return self._transfer
def _get_cleanup(self) -> AliyunCleanup:
"""懒加载获取 AliyunCleanup 实例"""
if self._cleanup is None:
drive_id = self._drive_id or self._credential.get_drive_id()
self._cleanup = AliyunCleanup(
credential=self._credential,
drive_id=drive_id,
request_timeout=self.transfer_config.request_timeout,
)
return self._cleanup
@@ -1,203 +0,0 @@
"""
阿里云盘回收站清理模块 v1.0.0
将文件移入回收站(非直接删除),支持批量操作。
"""
import logging
from typing import List, Dict
import requests
from .credential import AliyunCredentialManager, API_HOST
logger = logging.getLogger(__name__)
# ─── API 端点 ──────────────────────────────────────────────
# 批量操作(v4
BATCH_URL = f"{API_HOST}/adrive/v4/batch"
# 默认请求头
DEFAULT_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/135.0.0.0 Safari/537.36"
),
"Accept": "application/json, text/plain, */*",
"Content-Type": "application/json",
"Referer": "https://aliyundrive.com",
}
class AliyunCleanup:
"""
阿里云盘回收站清理
将文件移入回收站(放入回收站,非永久删除)。
使用 v4 批量接口,支持一次清理多个文件。
用法:
credential = AliyunCredentialManager(refresh_token="xxx")
cleanup = AliyunCleanup(credential, drive_id="12345")
result = cleanup.delete_files(["file_id_1", "file_id_2"])
"""
def __init__(
self,
credential: AliyunCredentialManager,
drive_id: str = "",
request_timeout: int = 30,
):
self.credential = credential
self.drive_id = drive_id or credential.get_drive_id()
self.request_timeout = request_timeout
self._session = requests.Session()
self._session.headers.update(DEFAULT_HEADERS)
# ─── 公开 API ──────────────────────────────────────────
def delete_files(self, file_ids: List[str]) -> Dict:
"""
将指定文件移入回收站(批量)。
Args:
file_ids: 要删除的文件 ID 列表
Returns:
{
"success": True/False,
"deleted_count": 成功删除数量,
"total_count": 总文件数,
"failed_ids": 失败的文件 ID 列表,
"error": None or "错误信息",
}
实现:
POST /adrive/v4/batch
{
"requests": [
{
"url": "/recyclebin/trash",
"body": {"file_id": "...", "drive_id": "..."},
"headers": {"Content-Type": "application/json"},
"id": "...",
"method": "POST"
}
],
"resource": "file"
}
"""
if not file_ids:
return self._error("文件 ID 列表为空")
drive_id = self.drive_id
if not drive_id:
drive_id = self.credential.get_drive_id()
if not drive_id:
return self._error("缺少 drive_id,无法执行删除操作")
# 构建批量请求体
requests_list = []
for fid in file_ids:
requests_list.append({
"url": "/recyclebin/trash",
"body": {
"drive_id": drive_id,
"file_id": fid,
},
"headers": {"Content-Type": "application/json"},
"id": fid,
"method": "POST",
})
try:
headers = self.credential.get_headers()
resp = self._session.post(
BATCH_URL,
json={"requests": requests_list, "resource": "file"},
headers=headers,
timeout=self.request_timeout,
)
data = resp.json()
if resp.status_code != 200:
logger.error(
f"[AliyunCleanup] 批量删除失败: "
f"HTTP {resp.status_code}, {data}"
)
return self._error(f"HTTP {resp.status_code}")
code = data.get("code", "")
if code:
logger.error(
f"[AliyunCleanup] 批量删除 API 错误: "
f"code={code}, message={data.get('message', '')}"
)
return self._error(data.get("message", f"API code={code}"))
# 统计结果
responses = data.get("responses", [])
success_ids = []
failed_ids = []
for item in responses:
status = item.get("status", 0)
fid = item.get("id", "")
if status in (200, 201, 202):
success_ids.append(fid)
else:
logger.warning(
f"[AliyunCleanup] 删除文件失败: "
f"id={fid}, status={status}, body={item.get('body', {})}"
)
failed_ids.append(fid)
logger.info(
f"[AliyunCleanup] 删除完成: "
f"成功={len(success_ids)}, 失败={len(failed_ids)}, 总计={len(file_ids)}"
)
return {
"success": len(failed_ids) == 0,
"deleted_count": len(success_ids),
"total_count": len(file_ids),
"success_ids": success_ids,
"failed_ids": failed_ids,
"error": None,
}
except requests.RequestException as e:
logger.error(f"[AliyunCleanup] 批量删除网络异常: {e}")
return self._error(str(e))
except Exception as e:
logger.exception(f"[AliyunCleanup] 批量删除异常: {e}")
return self._error(str(e))
def empty_recycle_bin(self) -> Dict:
"""
清空回收站(永久删除回收站中的所有文件)。
NOTE: 阿里云盘 API 目前不直接支持清空回收站,
此方法作为占位,需要逐个文件 ID 调用 delete_files。
实际使用请先 list 回收站内容再调用 delete_files。
Returns:
{"success": False, "error": "清空回收站需要通过 list + delete 两步完成"}
"""
logger.warning("[AliyunCleanup] 清空回收站 API 暂未实现,需要 list+delete 两步")
return self._error("清空回收站需要通过列出回收站内容 + 逐个删除两步完成,尚未实现")
# ─── 工具方法 ──────────────────────────────────────────
def _error(self, message: str) -> Dict:
"""构造错误返回"""
return {
"success": False,
"deleted_count": 0,
"total_count": 0,
"success_ids": [],
"failed_ids": [],
"error": message,
}
@@ -1,216 +0,0 @@
"""
阿里云盘凭证管理器 v1.0.0
refresh_token → access_token 刷新 + 自动缓存 + 过期前自动刷新
"""
import time
import logging
import threading
from typing import Dict, Optional
from dataclasses import dataclass, field
import requests
logger = logging.getLogger(__name__)
# ─── 常量 ──────────────────────────────────────────────────
API_HOST = "https://api.aliyundrive.com"
TOKEN_REFRESH_URL = f"{API_HOST}/token/refresh"
DEFAULT_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/135.0.0.0 Safari/537.36"
),
"Accept": "application/json, text/plain, */*",
"Content-Type": "application/json",
}
@dataclass
class TokenInfo:
"""缓存的 Token 信息"""
access_token: str = ""
refresh_token: str = ""
expires_at: float = 0.0 # Unix 时间戳
drive_id: str = ""
user_id: str = ""
nick_name: str = ""
default_sbox_drive_id: str = ""
@property
def is_expired(self) -> bool:
"""检查 access_token 是否已过期(提前 60s 视为过期)"""
return time.time() >= (self.expires_at - 60)
@property
def is_valid(self) -> bool:
return bool(self.access_token) and not self.is_expired
class AliyunCredentialManager:
"""
阿里云盘凭证管理器
职责:
- 使用 refresh_token 换取 access_token
- 缓存 access_token / expires_at / drive_id
- 过期前自动刷新(提前 60s)
- 线程安全
用法:
mgr = AliyunCredentialManager(refresh_token="xxx")
mgr.refresh() # 强制刷新
headers = mgr.get_headers() # 获取带 Auth 的请求头
is_ok = mgr.validate() # 验证 refresh_token 有效性
"""
def __init__(self, refresh_token: str = ""):
self._refresh_token = refresh_token.strip()
self._token: Optional[TokenInfo] = None
self._lock = threading.Lock()
self._session = requests.Session()
self._session.headers.update(DEFAULT_HEADERS)
# ─── 公开 API ──────────────────────────────────────────
def refresh(self) -> bool:
"""
使用 refresh_token 换取 access_token。
返回 True 表示成功,False 表示失败。
"""
with self._lock:
return self._do_refresh()
def get_headers(self) -> Dict[str, str]:
"""
获取带 Authorization 的请求头。
自动检查 token 有效性,必要时自动刷新。
Returns:
{"Authorization": "Bearer <access_token>", ...}
"""
self._ensure_token_valid()
headers = {}
if self._token and self._token.access_token:
headers["Authorization"] = f"Bearer {self._token.access_token}"
return headers
def get_access_token(self) -> str:
"""获取当前有效的 access_token(必要时自动刷新)"""
self._ensure_token_valid()
return self._token.access_token if self._token else ""
def get_drive_id(self) -> str:
"""获取默认 drive_id"""
self._ensure_token_valid()
return self._token.drive_id if self._token else ""
def get_sbox_drive_id(self) -> str:
"""获取保险箱 drive_id"""
self._ensure_token_valid()
return self._token.default_sbox_drive_id if self._token else ""
def validate(self) -> bool:
"""
验证 refresh_token 是否有效。
要求 refresh_token 长度 >= 20,且能成功换取 access_token。
"""
if not self._refresh_token or len(self._refresh_token) < 20:
logger.warning("[AliyunCredential] refresh_token 长度不足 20,验证失败")
return False
return self.refresh()
@property
def refresh_token(self) -> str:
return self._refresh_token
@refresh_token.setter
def refresh_token(self, value: str):
"""更新 refresh_token(通常在 API 返回新 refresh_token 后调用)"""
self._refresh_token = value.strip()
# 清除旧缓存,下次请求自动刷新
with self._lock:
self._token = None
# ─── 内部方法 ──────────────────────────────────────────
def _ensure_token_valid(self):
"""确保 token 有效(过期则自动刷新)"""
if self._token is None or self._token.is_expired:
self.refresh()
def _do_refresh(self) -> bool:
"""实际执行 token 刷新"""
if not self._refresh_token:
logger.error("[AliyunCredential] 没有 refresh_token,无法刷新")
return False
try:
resp = self._session.post(
TOKEN_REFRESH_URL,
json={"refresh_token": self._refresh_token},
timeout=30,
)
data = resp.json()
if resp.status_code != 200 or "access_token" not in data:
code = data.get("code", "Unknown")
message = data.get("message", "")
logger.error(
f"[AliyunCredential] 刷新 token 失败: "
f"HTTP {resp.status_code} code={code} msg={message}"
)
return False
# 解析响应
access_token = data.get("access_token", "")
expires_in = int(data.get("expires_in", 7200))
new_refresh = data.get("refresh_token", self._refresh_token)
self._token = TokenInfo(
access_token=access_token,
refresh_token=new_refresh,
expires_at=time.time() + expires_in,
drive_id=str(data.get("default_drive_id", "")),
user_id=str(data.get("user_id", "")),
nick_name=str(data.get("nick_name", "")),
default_sbox_drive_id=str(data.get("default_sbox_drive_id", "")),
)
# 更新 refresh_token(服务端可能下发新的)
if new_refresh != self._refresh_token:
logger.info(
"[AliyunCredential] refresh_token 已轮换,新旧前缀: "
f"{self._refresh_token[:8]}... → {new_refresh[:8]}..."
)
self._refresh_token = new_refresh
logger.info(
f"[AliyunCredential] Token 刷新成功 "
f"(user={self._token.nick_name}, "
f"expires_in={expires_in}s, "
f"drive_id={self._token.drive_id[:8]}...)"
)
return True
except requests.RequestException as e:
logger.error(f"[AliyunCredential] 刷新 token 网络异常: {e}")
return False
except Exception as e:
logger.exception(f"[AliyunCredential] 刷新 token 未知异常: {e}")
return False
def to_dict(self) -> dict:
"""导出当前状态(用于持久化)"""
self._ensure_token_valid()
return {
"refresh_token": self._refresh_token,
"access_token": self._token.access_token if self._token else "",
"expires_at": self._token.expires_at if self._token else 0,
"drive_id": self._token.drive_id if self._token else "",
"user_id": self._token.user_id if self._token else "",
"nick_name": self._token.nick_name if self._token else "",
}
@@ -1,493 +0,0 @@
"""
阿里云盘转存模块 v1.0.0
实现 4 步批量转存流程:获取分享详情 → 获取分享令牌 → 批量复制文件 → 创建新分享
"""
import re
import time
import logging
from typing import List, Dict, Tuple, Optional
import requests
from .credential import AliyunCredentialManager, API_HOST
logger = logging.getLogger(__name__)
# ─── API 端点 ──────────────────────────────────────────────
# ① 获取分享详情(匿名)
SHARE_INFO_URL = f"{API_HOST}/adrive/v3/share_link/get_share_by_anonymous"
# ② 获取分享令牌(需 Auth
SHARE_TOKEN_URL = f"{API_HOST}/v2/share_link/get_share_token"
# ③ 批量操作(复制文件)
BATCH_URL = f"{API_HOST}/adrive/v4/batch"
# ④ 创建分享
CREATE_SHARE_URL = f"{API_HOST}/adrive/v2/share_link/create"
# ─── URL 模式 ──────────────────────────────────────────────
# 匹配 aliyundrive.com/s/<share_id>
URL_PATTERN = re.compile(r'aliyundrive\.com/s/([a-zA-Z0-9]+)')
# ─── 默认请求头 ────────────────────────────────────────────
DEFAULT_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/135.0.0.0 Safari/537.36"
),
"Accept": "application/json, text/plain, */*",
"Content-Type": "application/json",
"Referer": "https://aliyundrive.com",
}
class AliyunTransfer:
"""
阿里云盘批量转存
四步流程:
① 获取分享详情(匿名):POST /adrive/v3/share_link/get_share_by_anonymous
② 获取分享令牌(Auth):POST /v2/share_link/get_share_token
③ 批量复制文件:POST /adrive/v4/batch (X-Share-Token 头)
④ 创建新分享:POST /adrive/v2/share_link/create
用法:
credential = AliyunCredentialManager(refresh_token="xxx")
transfer = AliyunTransfer(credential, drive_id="12345")
result = transfer.transfer(
share_url="https://www.aliyundrive.com/s/abc123",
share_password="",
to_parent_file_id="root",
)
"""
def __init__(
self,
credential: AliyunCredentialManager,
drive_id: str = "",
to_parent_file_id: str = "root",
request_timeout: int = 30,
):
self.credential = credential
self.drive_id = drive_id or credential.get_drive_id()
self.to_parent_file_id = to_parent_file_id
self.request_timeout = request_timeout
self._session = requests.Session()
self._session.headers.update(DEFAULT_HEADERS)
# ─── 公开 API ──────────────────────────────────────────
def transfer(
self,
share_url: str,
share_password: str = "",
to_parent_file_id: str = None,
new_share_password: str = "",
expiration: str = "",
) -> Dict:
"""
执行完整的转存流程。
Args:
share_url: 阿里云盘分享链接(如 https://www.aliyundrive.com/s/abc123
share_password: 分享提取码(如有)
to_parent_file_id: 转存目标目录 file_id,默认用初始化时的值
new_share_password: 新分享的密码(空=无密码)
expiration: 分享有效期,空=永久
Returns:
{
"success": True/False,
"share_name": "...",
"new_file_ids": ["id1", "id2"],
"new_share_url": "https://...",
"new_share_password": "...",
"error": None or "...",
}
"""
parent_id = to_parent_file_id or self.to_parent_file_id
try:
# ① 获取分享详情
share_id = self._extract_share_id(share_url)
if not share_id:
return self._error("无法从 URL 提取分享 ID")
share_info = self._get_share_info(share_id)
if not share_info:
return self._error("分享不存在或已失效")
share_name = share_info.get("share_name", "")
file_infos = share_info.get("file_infos", [])
if not file_infos:
return self._error("分享内容为空")
logger.info(
f"[AliyunTransfer] 分享详情获取成功: "
f"name={share_name}, files={len(file_infos)}"
)
# ② 获取分享令牌
share_token = self._get_share_token(share_id, share_password)
if not share_token:
return self._error("获取分享令牌失败(可能需要提取码)")
logger.info(f"[AliyunTransfer] 分享令牌获取成功")
# ③ 批量复制文件
file_ids = [fi.get("file_id", "") for fi in file_infos if fi.get("file_id")]
if not file_ids:
return self._error("无法提取文件 ID")
new_file_ids = self._batch_copy(share_id, share_token, file_ids, parent_id)
if not new_file_ids:
return self._error("批量转存失败,请检查权限或容量")
logger.info(f"[AliyunTransfer] 批量转存成功: {len(new_file_ids)} 个文件")
# ④ 创建新分享
share_result = self._create_share(
new_file_ids,
share_password=new_share_password,
expiration=expiration,
)
new_share_url = share_result.get("share_url", "")
new_share_pwd = share_result.get("share_pwd", new_share_password)
logger.info(f"[AliyunTransfer] 新分享创建成功: {new_share_url}")
return {
"success": True,
"share_name": share_name,
"share_id": share_id,
"new_file_ids": new_file_ids,
"new_share_url": new_share_url,
"new_share_password": new_share_pwd,
"error": None,
}
except Exception as e:
logger.exception(f"[AliyunTransfer] 转存异常: {e}")
return self._error(str(e))
def get_share_info(self, share_url: str) -> Optional[Dict]:
"""
仅获取分享详情(不转存)。
Returns:
{"share_name": "...", "file_infos": [...]} or None
"""
share_id = self._extract_share_id(share_url)
if not share_id:
logger.error(f"[AliyunTransfer] 无法从 URL 提取 share_id: {share_url}")
return None
return self._get_share_info(share_id)
# ─── 步骤 ①:获取分享详情 ───────────────────────────────
def _get_share_info(self, share_id: str) -> Optional[Dict]:
"""
POST /adrive/v3/share_link/get_share_by_anonymous
请求体: {"share_id": "..."}
响应: {"share_name": "...", "file_infos": [{"file_id": "...", "name": "...", ...}]}
"""
try:
resp = self._session.post(
SHARE_INFO_URL,
json={"share_id": share_id},
timeout=self.request_timeout,
)
data = resp.json()
if resp.status_code != 200:
logger.error(
f"[AliyunTransfer] 获取分享详情失败: "
f"HTTP {resp.status_code}, {data}"
)
return None
# 检查业务错误码
code = data.get("code", "")
if code:
logger.error(
f"[AliyunTransfer] 获取分享详情 API 错误: "
f"code={code}, message={data.get('message', '')}"
)
return None
return {
"share_name": data.get("share_name", ""),
"share_title": data.get("share_title", data.get("share_name", "")),
"file_infos": data.get("file_infos", []),
"expiration": data.get("expiration", ""),
"creator_name": data.get("creator_name", ""),
"creator_id": data.get("creator_id", ""),
}
except requests.RequestException as e:
logger.error(f"[AliyunTransfer] 获取分享详情网络异常: {e}")
return None
except Exception as e:
logger.exception(f"[AliyunTransfer] 获取分享详情异常: {e}")
return None
# ─── 步骤 ②:获取分享令牌 ────────────────────────────────
def _get_share_token(self, share_id: str, share_password: str = "") -> Optional[str]:
"""
POST /v2/share_link/get_share_token
请求体: {"share_id": "..."}
需要 Auth 头
响应: {"share_token": "..."}
"""
try:
headers = self.credential.get_headers()
resp = self._session.post(
SHARE_TOKEN_URL,
json={
"share_id": share_id,
"share_pwd": share_password,
},
headers=headers,
timeout=self.request_timeout,
)
data = resp.json()
if resp.status_code != 200:
logger.error(
f"[AliyunTransfer] 获取分享令牌失败: "
f"HTTP {resp.status_code}, {data}"
)
return None
code = data.get("code", "")
if code:
logger.error(
f"[AliyunTransfer] 获取分享令牌 API 错误: "
f"code={code}, message={data.get('message', '')}"
)
return None
share_token = data.get("share_token", "")
if not share_token:
logger.error("[AliyunTransfer] 响应中缺少 share_token")
return None
return share_token
except requests.RequestException as e:
logger.error(f"[AliyunTransfer] 获取分享令牌网络异常: {e}")
return None
except Exception as e:
logger.exception(f"[AliyunTransfer] 获取分享令牌异常: {e}")
return None
# ─── 步骤 ③:批量复制文件 ────────────────────────────────
def _batch_copy(
self,
share_id: str,
share_token: str,
file_ids: List[str],
to_parent_file_id: str = "root",
) -> List[str]:
"""
POST /adrive/v4/batch
头: X-Share-Token: <share_token>
请求体:
{
"requests": [
{
"url": "/file/copy",
"body": {
"file_id": "...",
"share_id": "...",
"to_drive_id": "...",
"to_parent_file_id": "..."
}
}
]
}
响应: {"responses": [{"status": 200, "body": {"file_id": "new_id"}}, ...]}
返回新的 file_id 列表
"""
drive_id = self.drive_id
if not drive_id:
drive_id = self.credential.get_drive_id()
if not drive_id:
logger.error("[AliyunTransfer] 缺少 drive_id,无法转存")
return []
# 构建批量请求体
requests_list = []
for fid in file_ids:
requests_list.append({
"url": "/file/copy",
"body": {
"file_id": fid,
"share_id": share_id,
"to_drive_id": drive_id,
"to_parent_file_id": to_parent_file_id,
},
"headers": {"Content-Type": "application/json"},
"id": fid,
"method": "POST",
})
try:
headers = self.credential.get_headers()
headers["X-Share-Token"] = share_token
resp = self._session.post(
BATCH_URL,
json={"requests": requests_list, "resource": "file"},
headers=headers,
timeout=self.request_timeout * 2, # 批量操作可能较慢
)
data = resp.json()
if resp.status_code != 200:
logger.error(
f"[AliyunTransfer] 批量复制失败: "
f"HTTP {resp.status_code}, {data}"
)
return []
code = data.get("code", "")
if code:
logger.error(
f"[AliyunTransfer] 批量复制 API 错误: "
f"code={code}, message={data.get('message', '')}"
)
return []
# 提取新 file_id
new_ids = []
responses = data.get("responses", [])
for item in responses:
status = item.get("status", 0)
body = item.get("body", {})
if status in (200, 201, 202):
new_fid = body.get("file_id", "")
if new_fid:
new_ids.append(new_fid)
else:
logger.warning(
f"[AliyunTransfer] 单个文件复制失败: "
f"id={item.get('id')}, status={status}, body={body}"
)
if not new_ids:
logger.error("[AliyunTransfer] 所有文件复制均失败")
elif len(new_ids) < len(file_ids):
logger.warning(
f"[AliyunTransfer] 部分文件复制成功: "
f"{len(new_ids)}/{len(file_ids)}"
)
return new_ids
except requests.RequestException as e:
logger.error(f"[AliyunTransfer] 批量复制网络异常: {e}")
return []
except Exception as e:
logger.exception(f"[AliyunTransfer] 批量复制异常: {e}")
return []
# ─── 步骤 ④:创建新分享 ──────────────────────────────────
def _create_share(
self,
file_ids: List[str],
share_password: str = "",
expiration: str = "",
) -> Dict:
"""
POST /adrive/v2/share_link/create
请求体: {"drive_id": "...", "file_id_list": [...], "share_pwd": "...", "expiration": "..."}
响应: {"share_url": "...", "share_id": "..."}
"""
drive_id = self.drive_id or self.credential.get_drive_id()
if not drive_id:
logger.error("[AliyunTransfer] 缺少 drive_id,无法创建分享")
return {"share_url": "", "share_pwd": ""}
body = {
"drive_id": drive_id,
"file_id_list": file_ids,
"share_pwd": share_password or "",
"expiration": expiration or "",
}
try:
headers = self.credential.get_headers()
resp = self._session.post(
CREATE_SHARE_URL,
json=body,
headers=headers,
timeout=self.request_timeout,
)
data = resp.json()
if resp.status_code != 200:
logger.error(
f"[AliyunTransfer] 创建分享失败: "
f"HTTP {resp.status_code}, {data}"
)
return {"share_url": "", "share_pwd": share_password}
code = data.get("code", "")
if code:
logger.error(
f"[AliyunTransfer] 创建分享 API 错误: "
f"code={code}, message={data.get('message', '')}"
)
return {"share_url": "", "share_pwd": share_password}
share_url = data.get("share_url", "")
share_pwd = data.get("share_pwd", share_password)
return {"share_url": share_url, "share_pwd": share_pwd}
except requests.RequestException as e:
logger.error(f"[AliyunTransfer] 创建分享网络异常: {e}")
return {"share_url": "", "share_pwd": share_password}
except Exception as e:
logger.exception(f"[AliyunTransfer] 创建分享异常: {e}")
return {"share_url": "", "share_pwd": share_password}
# ─── URL 解析 ──────────────────────────────────────────
@staticmethod
def _extract_share_id(url: str) -> Optional[str]:
"""从阿里云盘分享 URL 中提取 share_id"""
m = URL_PATTERN.search(url)
if m:
return m.group(1)
return None
@staticmethod
def extract_share_id_static(url: str) -> Optional[str]:
"""静态方法:提取 share_id"""
return AliyunTransfer._extract_share_id(url)
# ─── 工具方法 ──────────────────────────────────────────
def _error(self, message: str) -> Dict:
"""构造错误返回"""
return {
"success": False,
"share_name": "",
"share_id": "",
"new_file_ids": [],
"new_share_url": "",
"new_share_password": "",
"error": message,
}
@@ -1,329 +0,0 @@
"""
百度网盘适配器 — CloudSearch Transfer v1.0.0
参考 cloud-auto-save 的 BaiduNetDisk + netdisk 的 PanbaiduSave
完整的 5 步转存流程 + bdstoken 管理 + 路径删除 + 广告过滤
"""
import logging
from typing import List, Tuple, Dict
from ..base import BaseCloudDriveAdapter, FileInfo
from ...config import PlatformConfig, TransferConfig
from ...errors import TransferError, TransferErrorCode
from .credential import BaiduCredentialManager
from .transfer import BaiduTransfer
from .cleanup import BaiduCleanup
logger = logging.getLogger(__name__)
class BaiduAdapter(BaseCloudDriveAdapter):
"""百度网盘适配器
完整的 Cookie + bdstoken 机制,支持:
- 验证分享链接 + 提取码
- 5 步转存到自己的网盘
- 创建新分享
- 按文件名删除文件
- 广告文件过滤
"""
PLATFORM_NAME = "百度网盘"
PLATFORM_KEY = "baidu"
URL_PATTERNS = [
r'pan\.baidu\.com/s/1([A-Za-z0-9_-]+)',
]
capabilities: Dict[str, bool] = {
**BaseCloudDriveAdapter.capabilities,
"ensure_dir": True,
"save_files": True,
"rename": True,
"move_files": True,
"delete_files": True,
}
def __init__(self, config: PlatformConfig, transfer_config: TransferConfig):
super().__init__(config, transfer_config)
# 凭证管理器
self.credential = BaiduCredentialManager(
cookie=config.cookie,
session=self.session,
)
if not self.credential.validate():
raise TransferError(
TransferErrorCode.NOT_LOGIN,
message="百度网盘 Cookie 无效或太短 (需 >= 50 字符)",
platform=self.PLATFORM_KEY,
)
# 预热 bdstoken
try:
self.credential.get_bdstoken()
except TransferError as e:
logger.warning(f"预取 bdstoken 失败: {e},将在首次使用时重试")
# 转存执行器 & 清理器
self._transfer = BaiduTransfer(self.session, self.credential)
self._cleanup = BaiduCleanup(
self.session, self.credential,
ad_keywords=config.banned_keywords or None,
)
# 暂存最近一次转存的文件信息(供 _filter_ads 使用)
self._last_transfer_files: List[dict] = []
# ─── session 初始化 ─────────────────────────────────────
def _setup_session(self):
"""设置 session 级别的 Cookie"""
if self.config.cookie:
self.session.headers["Cookie"] = self.config.cookie
self.session.headers["Referer"] = "https://pan.baidu.com/"
# ─── 核心抽象方法实现 ──────────────────────────────────
def _get_share_detail(self, pwd_id: str, passcode: str = "") -> dict:
"""获取百度分享详情(步骤 ①+②)
Args:
pwd_id: URL 中的 surl (s/1 后面的部分)
passcode: 提取码(可选)
Returns:
{"title": str, "fs_ids": [str], "filenames": [str], ...}
"""
bdstoken = self.credential.get_bdstoken()
# ① 验证提取码(如果有)
if passcode:
self._transfer._verify_password(pwd_id, passcode, bdstoken)
# ② 解析分享页
share_info = self._transfer._parse_share_page(pwd_id)
return {
"title": share_info.get("title", ""),
"shareid": share_info["shareid"],
"uk": share_info["uk"],
"fs_ids": share_info["fs_ids"],
"filenames": share_info["filenames"],
}
def _save_files(self, pwd_id: str, detail: dict,
save_dir: str) -> List[str]:
"""转存文件到自己的百度网盘(步骤 ③+④)
Args:
pwd_id: surl
detail: _get_share_detail 返回的 dict
save_dir: 目标目录
Returns:
转存后的新 fs_id 列表
"""
bdstoken = self.credential.get_bdstoken()
shareid = detail["shareid"]
uk = detail["uk"]
fs_ids = detail["fs_ids"]
filenames = detail.get("filenames", [])
target_dir = self.ensure_dir(save_dir) if save_dir and save_dir.startswith("/") else (save_dir or "/")
# ③ 转存
self._transfer._transfer_files(shareid, uk, fs_ids, target_dir, bdstoken)
# ④ 列出目录匹配新 fs_id
new_fs_ids = self._transfer._list_and_match(target_dir, filenames, bdstoken)
# 暂存文件信息供 _filter_ads + _create_share 使用
self._last_transfer_files = [
{"fs_id": fid, "name": name}
for fid, name in zip(new_fs_ids, filenames)
if fid
]
return new_fs_ids
def _create_share(self, file_ids: List[str], title: str,
password: str = "") -> Tuple[str, str]:
"""创建百度分享(步骤 ⑤)
Args:
file_ids: 转存后的新 fs_id 列表
title: 原标题
password: 分享密码
Returns:
(new_share_url, share_password)
"""
# 如果 file_ids 中包含非数字,尝试从暂存信息中查找
numeric_ids = []
for fid in file_ids:
try:
int(fid)
numeric_ids.append(fid)
except ValueError:
logger.warning(f"忽略非数字 fs_id: {fid}")
return self._transfer.create_share(
fids=[int(x) for x in numeric_ids] if numeric_ids else [int(x) for x in file_ids],
password=password,
period=0, # 永久
)
# ─── 文件列表 & 删除 ────────────────────────────────────
def get_files(self, parent_fid: str = "0") -> List[FileInfo]:
"""列出百度网盘目录下的文件
GET /api/list?dir={parent_fid}
Args:
parent_fid: 目录路径 (默认 "0" = 根目录)
注意: parent_fid 对百度网盘而言是目录路径而非数字 ID。
根目录传 "/""0"
"""
bdstoken = self.credential.get_bdstoken()
dir_path = parent_fid if parent_fid != "0" else "/"
url = "https://pan.baidu.com/api/list"
params = {"dir": dir_path, "bdstoken": bdstoken}
headers = self.credential.get_headers()
try:
resp = self._get(url, params=params, headers=headers)
data = resp.json()
except Exception as e:
logger.error(f"百度列出目录失败: {e}")
return []
errno = data.get("errno", -1)
if errno != 0:
logger.error(f"百度列出目录 errno={errno}: {data}")
return []
files = []
for item in data.get("list", []):
fid = str(item.get("fs_id", ""))
name = item.get("server_filename", "")
size = item.get("size", 0)
is_dir = item.get("isdir", 0) == 1
ext = ""
if not is_dir and "." in name:
ext = name.rsplit(".", 1)[-1]
files.append(FileInfo(
fid=fid,
name=name,
size=size,
is_dir=is_dir,
ext=ext,
))
return files
def delete(self, file_ids: List[str]) -> bool:
"""删除百度网盘文件(按路径)
file_ids 应为网盘中的完整路径,如 ["/dir/file.txt", "/dir/file2.zip"]
Args:
file_ids: 网盘路径列表
Returns:
True 删除成功(或文件不存在)
"""
return self._cleanup.delete_files(file_ids)
# ─── 广告过滤 ────────────────────────────────────────────
def _filter_ads(self, file_ids: List[str]) -> List[str]:
"""广告过滤 — 基于最近一次转存暂存的文件名"""
if not self._last_transfer_files:
return file_ids
names = []
for f in self._last_transfer_files:
if f["fs_id"] in file_ids:
names.append(f["name"])
else:
names.append("")
return self._cleanup.filter_ad_ids(file_ids, names)
# ─── 扩展方法 ────────────────────────────────────────────
# ─── Optional Drive API capability methods ─────────────────────
def ensure_dir(self, dir_path: str) -> str:
normalized = "/" + (dir_path or "/").strip("/")
if normalized == "/":
return "/"
current = ""
for part in [p for p in normalized.split("/") if p]:
parent = current or "/"
current = f"{current}/{part}" if current else f"/{part}"
exists = any(item.is_dir and item.name == part for item in self.get_files(parent))
if not exists:
self.mkdir(current)
return normalized
def get_fids(self, file_paths: List[str]) -> List[Dict]:
"""Resolve existing Baidu paths by listing their parent directories."""
results: List[Dict] = []
for path in file_paths:
normalized = "/" + (path or "").strip("/")
if normalized == "/":
results.append({"file_path": path, "fid": "/", "path": "/"})
continue
parent, name = normalized.rsplit("/", 1)
parent = parent or "/"
match = next((item for item in self.get_files(parent) if item.name == name), None)
if match:
results.append({"file_path": path, "fid": match.fid, "path": normalized})
return results
def mkdir(self, dir_path: str) -> Dict:
bdstoken = self.credential.get_bdstoken()
url = "https://pan.baidu.com/api/create"
params = {"a": "commit", "bdstoken": bdstoken}
data = {"path": dir_path, "isdir": 1, "block_list": "[]"}
resp = self._post(url, data=data, params=params, headers=self.credential.get_headers())
payload = self._drive_api_json(resp, context="百度网盘创建目录")
errno = payload.get("errno", 0)
if errno not in (0, -8):
raise TransferError(TransferErrorCode.NETWORK_ERROR, message=f"百度创建目录失败 errno={errno}", platform=self.PLATFORM_KEY, details=payload)
return {"code": 0, "status": 200, "data": {"path": dir_path, **payload}}
def rename(self, fid: str, file_name: str) -> Dict:
return self._filemanager("rename", [{"path": fid, "newname": file_name}])
def move_files(self, fids: List[str], to_pdir_fid: str) -> Dict:
target = self.ensure_dir(to_pdir_fid) if to_pdir_fid.startswith("/") else to_pdir_fid
return self._filemanager("move", [{"path": fid, "dest": target} for fid in fids])
def delete_files(self, fids: List[str]) -> Dict:
return {"code": 0, "status": 200} if self.delete(fids) else {"code": -1, "status": 500}
def _filemanager(self, opera: str, filelist: List[Dict]) -> Dict:
import json
bdstoken = self.credential.get_bdstoken()
url = "https://pan.baidu.com/api/filemanager"
params = {"opera": opera, "bdstoken": bdstoken}
data = {"filelist": json.dumps(filelist, ensure_ascii=False)}
payload = self._drive_api_json(self._post(url, data=data, params=params, headers=self.credential.get_headers()), context=f"百度网盘{opera}")
errno = payload.get("errno", 0)
if errno != 0:
raise TransferError(TransferErrorCode.NETWORK_ERROR, message=f"百度文件操作失败 errno={errno}", platform=self.PLATFORM_KEY, details=payload)
return {"code": 0, "status": 200, "data": payload}
def delete_paths(self, paths: List[str]) -> bool:
"""便捷删除方法(直接调用 cleanup)"""
return self._cleanup.delete_files(paths)
@@ -1,154 +0,0 @@
"""
百度网盘文件清理 — 删除文件 & 广告过滤
参考 cloud-auto-save 的 filter_ads + netdisk 的 delete
"""
import json
import logging
from typing import List
import requests
from ...errors import TransferError, TransferErrorCode
from .credential import BaiduCredentialManager, BAIDU_PAN_API
logger = logging.getLogger(__name__)
# 默认广告关键词
DEFAULT_AD_KEYWORDS = [
"公众号", "微信", "扫码", "加群", "QQ群", "广告",
"关注", "免费领取", "点击领取", "全网", "最全",
"防走丢", "防迷路", "备用", "务必下载", "必看",
"解压密码", "压缩密码",
]
class BaiduCleanup:
"""百度网盘文件清理 & 广告过滤"""
def __init__(self, session: requests.Session,
credential: BaiduCredentialManager,
ad_keywords: List[str] = None):
self.session = session
self.credential = credential
self.ad_keywords = ad_keywords or DEFAULT_AD_KEYWORDS
# ─── 删除文件 ────────────────────────────────────────────
def delete_files(self, paths: List[str]) -> bool:
"""批量删除文件(按网盘路径)
POST /api/filemanager?opera=delete&bdstoken={bdstoken}
Body: filelist=["/path/to/file1","/path/to/file2"]
Args:
paths: 文件在网盘中的完整路径列表,如 ["/dir/file.txt"]
Returns:
True 全部成功(包括文件不存在的 errno=2)
Raises:
TransferError: 删除失败
"""
if not paths:
logger.info("删除列表为空,跳过")
return True
bdstoken = self.credential.get_bdstoken()
url = f"{BAIDU_PAN_API}/api/filemanager"
params = {
"opera": "delete",
"bdstoken": bdstoken,
}
data = {
"filelist": json.dumps(paths, ensure_ascii=False),
}
headers = self.credential.get_headers()
headers["Content-Type"] = "application/x-www-form-urlencoded"
try:
resp = self.session.post(
url, params=params, data=data, headers=headers, timeout=30
)
resp.raise_for_status()
result = resp.json()
except Exception as e:
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=f"百度删除请求失败: {e}",
platform="baidu",
)
errno = result.get("errno", -1)
# errno=0 成功; errno=2 文件不存在(视为成功)
if errno in (0, 2):
logger.info(f"百度删除完成: {len(paths)} 个路径 (errno={errno})")
return True
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=f"百度删除失败 (errno={errno})",
platform="baidu",
details=result,
)
# ─── 广告过滤 ────────────────────────────────────────────
def filter_ads(self, files: List[dict]) -> List[dict]:
"""根据文件名过滤广告文件
Args:
files: [{"fs_id": "xxx", "name": "xxx"}, ...]
Returns:
过滤后的文件列表,仅保留非广告文件
"""
if not self.ad_keywords:
return files
retained = []
removed = []
for f in files:
name = f.get("name", "")
if self._is_ad(name):
removed.append(name)
else:
retained.append(f)
if removed:
logger.info(f"广告过滤: 移除 {len(removed)} 个文件: {removed}")
return retained
def filter_ad_ids(self, file_ids: List[str],
file_names: List[str]) -> List[str]:
"""根据文件名过滤广告,返回保留的 file_ids
Args:
file_ids: 文件 ID 列表
file_names: 对应的文件名列表(与 file_ids 一一对应)
Returns:
过滤后的 file_ids
"""
if not self.ad_keywords:
return file_ids
retained = []
for fid, name in zip(file_ids, file_names):
if not self._is_ad(name):
retained.append(fid)
else:
logger.info(f"广告过滤: 移除 {name}")
return retained
def _is_ad(self, filename: str) -> bool:
"""判断文件名是否为广告"""
if not filename:
return False
name_lower = filename.lower()
for kw in self.ad_keywords:
if kw.lower() in name_lower:
return True
return False
@@ -1,101 +0,0 @@
"""
百度网盘凭证管理器 — bdstoken 获取与校验
参考 cloud-auto-save 的 BaiduNetDisk.cookie 机制
"""
import logging
import requests
from ...errors import TransferError, TransferErrorCode
logger = logging.getLogger(__name__)
# 百度网盘 API 基础 URL
BAIDU_PAN_API = "https://pan.baidu.com"
class BaiduCredentialManager:
"""百度网盘 Cookie 凭证 + bdstoken 管理
百度网盘的大多数受保护 API 都需要 bdstoken 参数,
该 token 通过 API 获取并缓存在实例中。
"""
def __init__(self, cookie: str, session: requests.Session):
"""
Args:
cookie: 完整的百度 Cookie 字符串
session: 共享的 requests.Session(继承 User-Agent 等 headers
"""
self.cookie = cookie
self.session = session
self._bdstoken: str = ""
# ─── 公开方法 ──────────────────────────────────────────
def validate(self) -> bool:
"""校验 Cookie 是否有效:长度 >= 50 视为合格"""
return bool(self.cookie and len(self.cookie.strip()) >= 50)
def get_bdstoken(self, force_refresh: bool = False) -> str:
"""
获取 bdstoken,首次调用会请求 API 获取并缓存。
API: GET /api/gettemplatevariable?fields=["bdstoken"]
Raises:
TransferError: 获取失败 (BAIDU_BDSTOKEN_FAIL)
"""
if self._bdstoken and not force_refresh:
return self._bdstoken
url = f"{BAIDU_PAN_API}/api/gettemplatevariable"
params = {"fields": '["bdstoken"]'}
headers = self.get_headers()
try:
resp = self.session.get(url, params=params, headers=headers, timeout=15)
resp.raise_for_status()
data = resp.json()
except Exception as e:
logger.error(f"获取 bdstoken 网络异常: {e}")
raise TransferError(
TransferErrorCode.BAIDU_BDSTOKEN_FAIL,
message=f"百度 bdstoken 请求失败: {e}",
platform="baidu",
)
errno = data.get("errno", -1)
if errno != 0:
logger.error(f"获取 bdstoken API 返回 errno={errno}: {data}")
raise TransferError(
TransferErrorCode.BAIDU_BDSTOKEN_FAIL,
message=f"百度 bdstoken 获取失败 (errno={errno})",
platform="baidu",
details={"response": data},
)
self._bdstoken = data.get("result", {}).get("bdstoken", "")
if not self._bdstoken:
raise TransferError(
TransferErrorCode.BAIDU_BDSTOKEN_FAIL,
message="百度 bdstoken 为空",
platform="baidu",
)
logger.info("bdstoken 获取成功")
return self._bdstoken
def get_headers(self) -> dict:
"""构建携带 Cookie 的请求头(继承 session 默认 headers 外的额外字段)"""
headers = {
"Cookie": self.cookie,
"Referer": "https://pan.baidu.com/",
"Origin": "https://pan.baidu.com",
}
return headers
def invalidate_bdstoken(self):
"""使缓存失效,下次调用 get_bdstoken 会重新获取"""
self._bdstoken = ""
logger.info("bdstoken 缓存已失效")
@@ -1,448 +0,0 @@
"""
百度网盘转存核心 — 5 步转存流程
参考 netdisk 的 PanbaiduSave + cloud-auto-save 的 BaiduNetDisk.transfer
流程:
① 验证提取码 → POST /share/verify
② 解析分享页 → GET /s/1{surl}
③ 转存文件 → POST /share/transfer
④ 列出目录 → GET /api/list
⑤ 创建分享 → POST /share/set
"""
import re
import json
import logging
from typing import List, Tuple
import requests
from ...errors import TransferError, TransferErrorCode
from .credential import BaiduCredentialManager, BAIDU_PAN_API
logger = logging.getLogger(__name__)
# ─── 正则 ──────────────────────────────────────────────────
# 从 HTML 中提取 shareid
RE_SHAREID = re.compile(r"""shareid["\s:=]+(\d+)""")
# 从 HTML 中提取 uk
RE_UK = re.compile(r"""uk["\s:=]+(\d+)""")
# 从 HTML 中提取 fs_id
RE_FS_ID = re.compile(r'"fs_id"\s*:\s*(\d+)')
# 从 HTML 中提取 server_filename
RE_FILENAME = re.compile(r'"server_filename"\s*:\s*"([^"]*)"')
# 从 HTML/JSON 中提取标题
RE_TITLE = re.compile(r'"title"\s*:\s*"([^"]*)"')
# 从 HTML 中提取文件列表 JSON 块 (file_list 对象) — 标记位置
RE_FILE_LIST_MARK = re.compile(r'"file_list"\s*:\s*(\{)', re.DOTALL)
# 提取单个文件条目 (fallback)
RE_FILE_ENTRY = re.compile(r'\{"fs_id":(\d+),"server_filename":"([^"]+)"')
class BaiduTransfer:
"""百度网盘 5 步转存执行器
每个实例绑定一个 Session + Cookie + bdstoken
执行完整的「验证→解析→转存→查目录→创建分享」流程。
"""
def __init__(self, session: requests.Session,
credential: BaiduCredentialManager):
self.session = session
self.credential = credential
self.cookie = credential.cookie
# ─── 5 步主流程 ────────────────────────────────────────
def execute(self, surl: str, password: str,
save_dir: str = "/") -> Tuple[List[str], dict]:
"""执行完整的 5 步转存流程
Args:
surl: 分享短码 (s/1 后面的部分)
password: 提取码
save_dir: 转存目标目录
Returns:
(new_fs_ids, file_info_dict)
new_fs_ids: 转存后的文件 fs_id 列表
file_info_dict: {fs_id: name} 映射
Raises:
TransferError: 任何一步失败
"""
bdstoken = self.credential.get_bdstoken()
# ① 验证提取码
logger.info(f"[百度转存] ① 验证提取码 surl={surl}")
self._verify_password(surl, password, bdstoken)
# ② 解析分享页
logger.info(f"[百度转存] ② 解析分享页 surl={surl}")
share_info = self._parse_share_page(surl)
shareid = share_info["shareid"]
uk = share_info["uk"]
fs_ids = share_info["fs_ids"]
filenames = share_info["filenames"]
title = share_info.get("title", "")
if not fs_ids:
raise TransferError(
TransferErrorCode.RESOURCE_EMPTY,
message="分享中没有找到可转存的文件",
platform="baidu",
)
# ③ 转存到自己的网盘
logger.info(f"[百度转存] ③ 转存 {len(fs_ids)} 个文件到 {save_dir}")
self._transfer_files(shareid, uk, fs_ids, save_dir, bdstoken)
# ④ 列出目标目录,按文件名匹配新的 fs_id
logger.info(f"[百度转存] ④ 列出目录 {save_dir} 匹配新 fs_id")
new_fs_ids = self._list_and_match(save_dir, filenames, bdstoken)
if not new_fs_ids:
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message="转存后无法匹配到新文件 ID",
platform="baidu",
)
# 构建返回的 info dict
file_info = {}
for name, fid in zip(filenames, new_fs_ids) if len(filenames) == len(new_fs_ids) else []:
file_info[fid] = name
if not file_info:
for fid in new_fs_ids:
file_info[fid] = title or fid
return new_fs_ids, file_info
def create_share(self, fids: List[int], password: str = "",
period: int = 0) -> Tuple[str, str]:
"""⑤ 创建新分享
Args:
fids: 转存后的文件 fs_id 列表
password: 分享密码(空 = 无密码)
period: 分享有效期 (0=永久)
Returns:
(share_url, share_password)
"""
bdstoken = self.credential.get_bdstoken()
url = f"{BAIDU_PAN_API}/share/set"
params = {
"channel": "chunlei",
"clienttype": "0",
"web": "1",
"bdstoken": bdstoken,
}
data = {
"fid_list": json.dumps(fids),
"period": period,
"pwd": password,
}
headers = self.credential.get_headers()
try:
resp = self.session.post(
url, params=params, data=data, headers=headers, timeout=30
)
resp.raise_for_status()
except Exception as e:
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=f"创建分享请求失败: {e}",
platform="baidu",
)
result = resp.json()
errno = result.get("errno", -1)
if errno == 9219:
raise TransferError(
TransferErrorCode.SHARE_LIMIT,
message="百度今日分享次数过多",
platform="baidu",
)
if errno != 0:
raise TransferError(
TransferErrorCode.SHARE_LINK_FAIL,
message=f"创建分享失败 (errno={errno})",
platform="baidu",
details=result,
)
share_url = result.get("link", "")
share_password = result.get("pwd", password) or password
logger.info(f"[百度转存] ⑤ 分享创建成功: {share_url}")
return share_url, share_password
# ─── 5 步内部方法 ──────────────────────────────────────
def _verify_password(self, surl: str, password: str, bdstoken: str):
"""① 验证提取码
POST /share/verify?surl={surl}&bdstoken={bdstoken}
Body: {"pwd": "xxxx"}
errno=0 表示通过;errno=-9 表示提取码错误;errno=2 表示分享不存在
"""
url = f"{BAIDU_PAN_API}/share/verify"
params = {
"surl": surl,
"bdstoken": bdstoken,
}
data = {"pwd": password}
headers = self.credential.get_headers()
headers["Content-Type"] = "application/x-www-form-urlencoded"
try:
resp = self.session.post(
url, params=params, data=data, headers=headers, timeout=15
)
resp.raise_for_status()
except Exception as e:
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=f"验证提取码请求失败: {e}",
platform="baidu",
)
result = resp.json()
errno = result.get("errno", -1)
if errno == 0:
logger.info("提取码验证通过")
return
if errno == -9 or errno == -62:
raise TransferError(
TransferErrorCode.PASSCODE_WRONG,
message="百度提取码错误",
platform="baidu",
)
if errno == 2 or errno == 118:
raise TransferError(
TransferErrorCode.SHARE_NOT_EXIST,
message="百度分享不存在或已失效",
platform="baidu",
)
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=f"验证提取码失败 (errno={errno})",
platform="baidu",
details=result,
)
def _parse_share_page(self, surl: str) -> dict:
"""② 解析分享页面 HTML
GET /s/1{surl}
从 HTML 中正则提取 shareid, uk, fs_id[], server_filename[]
"""
url = f"{BAIDU_PAN_API}/s/1{surl}"
headers = self.credential.get_headers()
try:
resp = self.session.get(url, headers=headers, timeout=20)
resp.raise_for_status()
html = resp.text
except Exception as e:
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=f"打开分享页面失败: {e}",
platform="baidu",
)
# 提取 shareid
m_shareid = RE_SHAREID.search(html)
if not m_shareid:
raise TransferError(
TransferErrorCode.SHARE_NOT_EXIST,
message="无法从页面中提取 shareid,分享可能已失效",
platform="baidu",
)
shareid = m_shareid.group(1)
# 提取 uk
m_uk = RE_UK.search(html)
uk = m_uk.group(1) if m_uk else ""
# 提取标题
m_title = RE_TITLE.search(html)
title = m_title.group(1) if m_title else ""
# 提取文件列表 — 优先从 file_list JSON 块中提取
fs_ids = []
filenames = []
# 方法1:查找 file_list JSON 块(使用括号计数提取平衡 JSON)
m_fl = RE_FILE_LIST_MARK.search(html)
if m_fl:
start = m_fl.start(1) # { 的位置
depth = 1
end = start + 1
while end < len(html) and depth > 0:
if html[end] == '{':
depth += 1
elif html[end] == '}':
depth -= 1
end += 1
file_list_json = html[start:end]
try:
file_list = json.loads(file_list_json)
for entry in file_list.get("list", []):
fs_ids.append(str(entry.get("fs_id", "")))
filenames.append(entry.get("server_filename", ""))
except json.JSONDecodeError:
pass
# 方法2:退化为正则提取所有 fs_id + server_filename
if not fs_ids:
for m in RE_FILE_ENTRY.finditer(html):
fs_ids.append(m.group(1))
filenames.append(m.group(2))
if not fs_ids:
# 可能只有一个文件,尝试单个提取
m_fsid = RE_FS_ID.search(html)
m_name = RE_FILENAME.search(html)
if m_fsid:
fs_ids.append(m_fsid.group(1))
filenames.append(m_name.group(1) if m_name else "")
logger.info(
f"解析分享页: shareid={shareid}, uk={uk}, "
f"文件数={len(fs_ids)}, title={title[:30]}"
)
return {
"shareid": shareid,
"uk": uk,
"fs_ids": fs_ids,
"filenames": filenames,
"title": title,
}
def _transfer_files(self, shareid: str, uk: str,
fs_ids: List[str], save_dir: str, bdstoken: str):
"""③ 转存文件到自己的网盘
POST /share/transfer?shareid={shareid}&from={uk}&bdstoken={bdstoken}
Body: fsidlist=[1,2,3]&path=/dir
"""
url = f"{BAIDU_PAN_API}/share/transfer"
params = {
"shareid": shareid,
"from": uk,
"bdstoken": bdstoken,
}
data = {
"fsidlist": json.dumps([int(x) for x in fs_ids]),
"path": save_dir,
}
headers = self.credential.get_headers()
headers["Content-Type"] = "application/x-www-form-urlencoded"
try:
resp = self.session.post(
url, params=params, data=data, headers=headers, timeout=30
)
resp.raise_for_status()
except Exception as e:
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=f"转存请求失败: {e}",
platform="baidu",
)
result = resp.json()
errno = result.get("errno", -1)
if errno == 0:
logger.info(f"转存成功: {len(fs_ids)} 个文件 → {save_dir}")
return
if errno == 12:
raise TransferError(
TransferErrorCode.CAPACITY_FULL,
message="百度网盘空间不足",
platform="baidu",
)
if errno == 9013:
raise TransferError(
TransferErrorCode.SENSITIVE_RESOURCE,
message="文件包含违规内容,无法转存",
platform="baidu",
)
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=f"转存失败 (errno={errno})",
platform="baidu",
details=result,
)
def _list_and_match(self, save_dir: str, filenames: List[str],
bdstoken: str) -> List[str]:
"""④ 列出目标目录,按文件名匹配新的 fs_id
GET /api/list?dir={dir}&bdstoken={bdstoken}
从返回的 list 中按 server_filename 匹配,返回按原顺序排列的 fs_id 列表
"""
url = f"{BAIDU_PAN_API}/api/list"
params = {
"dir": save_dir,
"bdstoken": bdstoken,
}
headers = self.credential.get_headers()
try:
resp = self.session.get(url, params=params, headers=headers, timeout=15)
resp.raise_for_status()
data = resp.json()
except Exception as e:
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=f"列出目录失败: {e}",
platform="baidu",
)
errno = data.get("errno", -1)
if errno == -12:
raise TransferError(
TransferErrorCode.DIR_NOT_EXIST,
message=f"百度目录不存在: {save_dir}",
platform="baidu",
)
if errno != 0:
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=f"列出目录失败 (errno={errno})",
platform="baidu",
details=data,
)
file_list = data.get("list", [])
# 构建文件名 → fs_id 映射
name_to_fid = {}
for item in file_list:
name = item.get("server_filename", "")
fid = str(item.get("fs_id", ""))
if name and fid:
name_to_fid[name] = fid
# 按原文件名顺序匹配
new_fs_ids = []
for fname in filenames:
if fname in name_to_fid:
new_fs_ids.append(name_to_fid[fname])
else:
logger.warning(f"目录中未找到文件: {fname}")
logger.info(
f"目录匹配: 期望 {len(filenames)} 个, 匹配到 {len(new_fs_ids)}"
)
return new_fs_ids
-400
View File
@@ -1,400 +0,0 @@
"""
CloudSearch Transfer — 适配器抽象基类 v1.0.0
参考 cloud-auto-save 的 BaseCloudDriveAdapter + netdisk 的 Pan 接口
"""
import time
import re
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Optional, List, Tuple, Dict, Any
from urllib.parse import urlparse, parse_qs
import requests
from ..config import PlatformConfig, TransferConfig
from ..errors import TransferError, TransferErrorCode
logger = logging.getLogger(__name__)
@dataclass
class FileInfo:
"""文件信息"""
fid: str # 文件ID
name: str # 文件名
size: int = 0 # 文件大小
is_dir: bool = False
ext: str = "" # 扩展名
@dataclass
class TransferResult:
"""转存结果"""
success: bool
platform: str
new_file_id: str = "" # 转存后的文件ID
file_name: str = "" # 文件名
share_url: str = "" # 新的分享链接
share_password: str = "" # 分享密码
original_url: str = "" # 原始分享链接
elapsed_ms: int = 0 # 耗时
error: Optional[TransferError] = None
@dataclass
class VerifyResult:
"""链接验证结果"""
valid: bool
platform: str
title: str = ""
file_count: int = 0
files: List[FileInfo] = None
error: Optional[TransferError] = None
def __post_init__(self):
if self.files is None:
self.files = []
class BaseCloudDriveAdapter(ABC):
"""
网盘适配器抽象基类
每个网盘平台实现此基类,统一接口:
- transfer(): 转存分享到自己网盘 → 创建新分享
- verify(): 验证分享链接有效性
- get_files(): 列出目录文件
- delete(): 删除文件
"""
# 子类必须覆盖
PLATFORM_NAME: str = ""
PLATFORM_KEY: str = "" # quark/baidu/aliyun/uc/xunlei/pan123/cloud189
# URL匹配正则(子类覆盖)
URL_PATTERNS: List[str] = []
# 可选 Drive API 能力;子类按需覆盖为 True 并实现对应方法。
capabilities: Dict[str, bool] = {
"ensure_dir": False,
"save_files": False,
"poll_task": False,
"rename": False,
"move_files": False,
"delete_files": False,
"cleanup_recycle": False,
"share_staging_folder": False,
}
# 默认请求头
DEFAULT_HEADERS: Dict[str, str] = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/135.0.0.0 Safari/537.36",
"Accept": "application/json, text/plain, */*",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
}
def __init__(self, config: PlatformConfig, transfer_config: TransferConfig):
self.config = config
self.transfer_config = transfer_config
self.session = requests.Session()
self.session.headers.update(self.DEFAULT_HEADERS)
self._setup_session()
def _setup_session(self):
"""子类可覆盖,初始化session特有的headers/cookies"""
pass
# ─── 公开接口 ──────────────────────────────────────────
def transfer(self, share_url: str, save_dir: str = "",
share_password: str = "") -> TransferResult:
"""
转存分享到自己网盘 → 创建新分享
Args:
share_url: 原始分享链接
save_dir: 转存到的目录(空=使用配置的默认目录)
share_password: 新分享的密码(空=使用配置的密码)
"""
start = time.time()
try:
# 1. 解析URL提取pwd_id
pwd_id, passcode = self._parse_share_url(share_url)
# 2. 获取分享详情
detail = self._get_share_detail(pwd_id, passcode)
if not detail:
raise TransferError(TransferErrorCode.SHARE_NOT_EXIST,
platform=self.PLATFORM_KEY)
# 3. 执行转存
save_dir = save_dir or self.config.save_dir or "/"
new_fids = self._save_files(pwd_id, detail, save_dir)
if not new_fids:
raise TransferError(TransferErrorCode.RESOURCE_EMPTY,
platform=self.PLATFORM_KEY)
# 4. 广告过滤
if self.transfer_config.ad_filter_enabled:
new_fids = self._filter_ads(new_fids)
if not new_fids:
raise TransferError(TransferErrorCode.RESOURCE_EMPTY,
platform=self.PLATFORM_KEY)
# 5. 创建新分享
pwd = share_password or self.config.share_password
share_url_new, share_pwd = self._create_share(new_fids, detail.get("title", ""), pwd)
elapsed = int((time.time() - start) * 1000)
return TransferResult(
success=True,
platform=self.PLATFORM_KEY,
new_file_id=",".join(new_fids),
file_name=detail.get("title", ""),
share_url=share_url_new,
share_password=share_pwd,
original_url=share_url,
elapsed_ms=elapsed,
)
except TransferError:
raise
except Exception as e:
logger.exception(f"[{self.PLATFORM_KEY}] transfer failed: {share_url}")
raise TransferError(TransferErrorCode.NETWORK_ERROR,
message=str(e), platform=self.PLATFORM_KEY)
def verify(self, share_url: str) -> VerifyResult:
"""验证分享链接有效性"""
try:
pwd_id, passcode = self._parse_share_url(share_url)
detail = self._get_share_detail(pwd_id, passcode)
files = self._extract_file_list(detail)
return VerifyResult(
valid=True,
platform=self.PLATFORM_KEY,
title=detail.get("title", ""),
file_count=len(files),
files=files,
)
except TransferError as e:
return VerifyResult(valid=False, platform=self.PLATFORM_KEY, error=e)
except Exception as e:
return VerifyResult(
valid=False,
platform=self.PLATFORM_KEY,
error=TransferError(TransferErrorCode.NETWORK_ERROR, message=str(e)),
)
@abstractmethod
def get_files(self, parent_fid: str = "0") -> List[FileInfo]:
"""列出目录下的文件"""
...
@abstractmethod
def delete(self, file_ids: List[str]) -> bool:
"""删除文件"""
...
# ─── URL解析 ──────────────────────────────────────────
def _parse_share_url(self, url: str) -> Tuple[str, str]:
"""
解析分享URL → (pwd_id, passcode)
子类可覆盖
"""
for pattern in self.URL_PATTERNS:
m = re.search(pattern, url)
if m:
pwd_id = m.group(1)
passcode = ""
# 尝试从URL参数提取密码
parsed = urlparse(url)
params = parse_qs(parsed.query)
passcode = params.get("pwd", params.get("code", [""]))[0]
return pwd_id, passcode
raise TransferError(TransferErrorCode.URL_INVALID,
message=f"无法解析{self.PLATFORM_NAME}链接: {url}")
# ─── 核心抽象方法(子类必须实现)────────────────────────
@abstractmethod
def _get_share_detail(self, pwd_id: str, passcode: str = "") -> dict:
"""获取分享详情 → {title, fid/fs_id, ...}"""
...
@abstractmethod
def _save_files(self, pwd_id: str, detail: dict, save_dir: str) -> List[str]:
"""转存文件 → 返回新文件ID列表"""
...
@abstractmethod
def _create_share(self, file_ids: List[str], title: str,
password: str = "") -> Tuple[str, str]:
"""创建分享 → (share_url, share_password)"""
...
def _extract_file_list(self, detail: dict) -> List[FileInfo]:
"""从分享详情提取文件列表(默认实现,子类可覆盖)"""
return []
def _filter_ads(self, file_ids: List[str]) -> List[str]:
"""广告过滤(默认不实现,子类可覆盖)"""
return file_ids
# ─── Optional Drive API capability protocol ─────────────────────
def _unsupported_capability(self, capability: str) -> None:
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=f"{self.PLATFORM_KEY or self.PLATFORM_NAME} 不支持 Drive API 能力: {capability}",
platform=self.PLATFORM_KEY,
)
def ensure_dir(self, dir_path: str) -> str:
self._unsupported_capability("ensure_dir")
def get_fids(self, file_paths: List[str]) -> List[Dict[str, Any]]:
self._unsupported_capability("get_fids")
def mkdir(self, dir_path: str) -> Dict[str, Any]:
self._unsupported_capability("mkdir")
def rename(self, fid: str, file_name: str) -> Dict[str, Any]:
self._unsupported_capability("rename")
def move_files(self, fids: List[str], to_pdir_fid: str) -> Dict[str, Any]:
self._unsupported_capability("move_files")
def delete_files(self, fids: List[str]) -> Dict[str, Any]:
self._unsupported_capability("delete_files")
def query_task(self, task_id: str) -> Dict[str, Any]:
self._unsupported_capability("poll_task")
def cleanup_recycle(self, fids: List[str]) -> Dict[str, Any]:
self._unsupported_capability("cleanup_recycle")
# ─── HTTP 工具方法 ─────────────────────────────────────
def _get(self, url: str, params: dict = None, headers: dict = None,
retry: int = None) -> requests.Response:
return self._request("GET", url, params=params, headers=headers, retry=retry)
def _post(self, url: str, json_data: dict = None, data: dict = None,
params: dict = None, headers: dict = None, retry: int = None) -> requests.Response:
return self._request("POST", url, json=json_data, data=data,
params=params, headers=headers, retry=retry)
def _request(self, method: str, url: str, **kwargs) -> requests.Response:
"""统一HTTP请求,带重试"""
retry = kwargs.pop("retry", None)
max_retries = retry if retry is not None else self.transfer_config.max_retries
last_exc = None
for attempt in range(max_retries + 1):
try:
resp = self.session.request(
method, url,
timeout=self.transfer_config.request_timeout,
**kwargs
)
return resp
except requests.RequestException as e:
last_exc = e
if attempt < max_retries:
delay = self.transfer_config.retry_delay * (2 ** attempt)
logger.warning(f"[{self.PLATFORM_KEY}] HTTP retry {attempt+1}/{max_retries} "
f"after {delay:.1f}s: {url}")
time.sleep(delay)
raise TransferError(TransferErrorCode.NETWORK_ERROR,
message=str(last_exc), platform=self.PLATFORM_KEY)
def _drive_api_json(self, resp: requests.Response, context: str = "网盘 API") -> Dict[str, Any]:
"""Validate HTTP response and decode JSON for drive helper APIs."""
try:
resp.raise_for_status()
except requests.HTTPError as exc:
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=f"{context} HTTP错误: {exc}",
platform=self.PLATFORM_KEY,
details={"status_code": getattr(resp, "status_code", None)},
) from exc
try:
return resp.json()
except ValueError as exc:
text = getattr(resp, "text", "") or ""
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=f"{context} JSON解析失败: {text[:200]}",
platform=self.PLATFORM_KEY,
) from exc
def _poll_task(self, task_url: str, task_id: str,
status_field: str = "status",
success_value: Any = 2,
result_path: str = None,
query_params: dict = None) -> dict:
"""
轮询异步任务直到完成
参考 netdisk 的任务轮询机制
"""
interval = self.transfer_config.task_poll_interval
max_attempts = self.transfer_config.task_poll_max_attempts
max_wait = self.transfer_config.task_poll_max_wait
started = time.time()
for attempt in range(max_attempts):
if time.time() - started > max_wait:
raise TransferError(TransferErrorCode.TIMEOUT,
platform=self.PLATFORM_KEY,
details={"task_id": task_id})
try:
base_params = query_params(attempt) if callable(query_params) else (query_params or {})
params = dict(base_params)
params["task_id"] = task_id
resp = self._get(task_url, params=params, retry=1)
payload = resp.json()
data = payload.get("data", payload)
current_status = data.get(status_field)
if current_status == success_value:
if result_path:
# 支持点号路径如 "save_as.save_as_top_fids"
for key in result_path.split("."):
data = data.get(key, {}) if isinstance(data, dict) else data
return data
if current_status is False or current_status == -1:
raise TransferError(TransferErrorCode.NETWORK_ERROR,
message=f"任务失败: {data}",
platform=self.PLATFORM_KEY)
except (requests.RequestException, ValueError):
pass
time.sleep(interval)
raise TransferError(TransferErrorCode.TIMEOUT,
platform=self.PLATFORM_KEY,
details={"task_id": task_id, "attempts": max_attempts})
# ─── 工厂函数(adapter/factory.py 使用)───────────────────
def match_url(url: str, adapter_cls: type) -> bool:
"""URL是否匹配某个适配器"""
for pattern in adapter_cls.URL_PATTERNS:
if re.search(pattern, url):
return True
return False
@@ -1,45 +0,0 @@
"""天翼云盘适配器 v1.0.0"""
from ..base import BaseCloudDriveAdapter, FileInfo, TransferResult, VerifyResult
from ...errors import TransferError, TransferErrorCode
from .credential import Cloud189CredentialManager
from .transfer import Cloud189Transfer
from .cleanup import Cloud189Cleanup
class Cloud189Adapter(BaseCloudDriveAdapter):
PLATFORM_NAME = "天翼云盘"
PLATFORM_KEY = "cloud189"
URL_PATTERNS = [r"cloud\.189\.cn/t/([A-Za-z0-9]+)"]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._cred = Cloud189CredentialManager(self.config)
self._transfer_engine = None
self._cln = Cloud189Cleanup()
def _setup_session(self):
if self._cred:
self._cred.login_if_needed(self.session)
@property
def _transfer(self):
if self._transfer_engine is None:
self._transfer_engine = Cloud189Transfer(
self.session, self._cred, self.config, self.transfer_config)
return self._transfer_engine
def _get_share_detail(self, pwd_id, passcode=""):
return self._transfer.get_share_info(pwd_id, passcode)
def _save_files(self, pwd_id, detail, save_dir):
return self._transfer.save_files(pwd_id, detail, save_dir)
def _create_share(self, file_ids, title, password=""):
return self._transfer.create_share(file_ids, title, password)
def get_files(self, parent_fid="-11"):
return self._transfer.list_files(parent_fid)
def delete(self, file_ids):
return self._cln.delete_files(self.session, self._cred, file_ids)
@@ -1,26 +0,0 @@
"""天翼云盘数据清理 v1.0.0"""
import logging
from typing import List
logger = logging.getLogger(__name__)
class Cloud189Cleanup:
API_BASE = "https://cloud.189.cn/api/open/file"
def delete_files(self, session, credential_mgr, file_ids: List[str]) -> bool:
try:
resp = session.post(
f"{self.API_BASE}/deleteFiles.action",
data={"fileIdList": ",".join(file_ids)},
timeout=30,
)
return resp.json().get("res_code") == 0
except Exception as e:
logger.error(f"189 delete failed: {e}")
return False
def filter_ad_ids(self, file_ids: List[str], file_names: List[str],
banned_keywords: List[str]) -> List[str]:
return file_ids
@@ -1,64 +0,0 @@
"""天翼云盘凭证管理 v1.0.0 — Cookie + 账号密码双模式"""
import re
import base64
import logging
from typing import Optional
logger = logging.getLogger(__name__)
class Cloud189CredentialManager:
LOGIN_URL = "https://cloud.189.cn/api/portal/loginUrl.action"
SSO_URL = "https://open.e.189.cn/api/logbox/oauth2/ssoLogin.action"
def __init__(self, config):
self.config = config
self._cookie: Optional[str] = None
def validate(self) -> bool:
if self.config.cookie:
return len(self.config.cookie) >= 30
extra = self.config.extra or {}
return bool(extra.get("username") and extra.get("password"))
def get_headers(self) -> dict:
return {
"Cookie": self._cookie or self.config.cookie,
"Referer": "https://cloud.189.cn/",
}
def login_if_needed(self, session) -> bool:
"""如需账号密码登录,在此执行"""
if self.config.cookie:
self._cookie = self.config.cookie
return True
extra = self.config.extra or {}
username = extra.get("username", "")
password = extra.get("password", "")
if not username or not password:
return False
try:
logger.info("Attempting 189 cloud login...")
resp = session.get(self.LOGIN_URL, timeout=30)
data = resp.json()
login_url = data.get("toUrl", "")
session.cookies.clear()
sso_resp = session.post(
self.SSO_URL,
data={"account": username, "password": password,
"appKey": "cloud", "returnUrl": login_url},
timeout=30,
)
sso_data = sso_resp.json()
redirect_url = sso_data.get("toUrl", "")
if redirect_url:
session.get(redirect_url, timeout=30)
self._cookie = "; ".join(
f"{c.name}={c.value}" for c in session.cookies
)
logger.info("189 cloud login successful")
return bool(self._cookie)
except Exception as e:
logger.error(f"189 cloud login failed: {e}")
return False
@@ -1,68 +0,0 @@
"""天翼云盘转存逻辑 v1.0.0"""
import re
import logging
from typing import List, Tuple
logger = logging.getLogger(__name__)
class Cloud189Transfer:
API_BASE = "https://cloud.189.cn/api/open/share"
def __init__(self, session, credential_mgr, config, transfer_config):
self.session = session
self.credential = credential_mgr
self.config = config
self.transfer_config = transfer_config
self._last_file_names = []
@staticmethod
def parse_share_url(url: str) -> Tuple[str, str]:
m = re.search(r"cloud\.189\.cn/t/([A-Za-z0-9]+)", url)
if not m:
raise ValueError("Invalid 189 cloud share URL")
return m.group(1), ""
def get_share_info(self, share_code: str, password: str = "") -> dict:
params = {"shareCode": share_code}
if password:
params["accessCode"] = password
resp = self.session.get(
f"{self.API_BASE}/getShareInfoByShareId.action",
params=params,
timeout=self.transfer_config.request_timeout,
)
data = resp.json()
if not data.get("res_code") == 0:
raise Exception(f"189 share info failed: {data}")
info = data.get("data", {})
files = info.get("fileList", [])
return {
"title": info.get("shareName", ""),
"files": [{"id": f.get("fileId", ""), "name": f.get("fileName", ""),
"size": int(f.get("fileSize", 0))} for f in files],
"share_id": info.get("shareId", ""),
}
def save_files(self, share_code: str, detail: dict, save_dir: str) -> List[str]:
payload = {
"shareId": detail.get("share_id", ""),
"parentId": save_dir or "-11",
}
resp = self.session.post(
f"{self.API_BASE}/shareToMe.action",
data=payload,
timeout=self.transfer_config.request_timeout,
)
data = resp.json()
if not data.get("res_code") == 0:
raise Exception(f"189 save failed: {data}")
return ["0"]
def create_share(self, file_ids: List[str], title: str,
password: str = "") -> Tuple[str, str]:
return "", ""
def list_files(self, parent_id: str = "-11") -> list:
return []
-112
View File
@@ -1,112 +0,0 @@
"""
CloudSearch Transfer — 适配器工厂 v1.0.0
参考 cloud-auto-save 的 AdapterFactory + AccountManager
"""
import hashlib
import logging
from typing import Optional, Dict, Type
from .base import BaseCloudDriveAdapter, match_url
from ..config import ConfigManager
from ..errors import TransferError, TransferErrorCode
logger = logging.getLogger(__name__)
class AdapterFactory:
"""
适配器工厂
- URL正则自动识别网盘类型
- 实例缓存:同平台+同Cookie单例
- 多账号路由
"""
# 平台注册表(延迟导入避免循环引用)
_registry: Dict[str, Type[BaseCloudDriveAdapter]] = {}
# 实例缓存 key: "platform:cookie_hash[:16]"
_cache: Dict[str, BaseCloudDriveAdapter] = {}
def __init__(self, config_manager: ConfigManager):
self.config_manager = config_manager
self._register_all()
def _register_all(self):
"""注册所有平台适配器"""
from .quark import QuarkAdapter
from .baidu import BaiduAdapter
from .aliyun import AliyunAdapter
from .uc import UcAdapter
from .xunlei import XunleiAdapter
from .pan115 import Pan115Adapter
from .pan123 import Pan123Adapter
from .cloud189 import Cloud189Adapter
self._registry = {
"quark": QuarkAdapter,
"baidu": BaiduAdapter,
"aliyun": AliyunAdapter,
"uc": UcAdapter,
"xunlei": XunleiAdapter,
"pan115": Pan115Adapter,
"pan123": Pan123Adapter,
"cloud189": Cloud189Adapter,
}
def detect_platform(self, url: str) -> Optional[str]:
"""根据URL自动识别网盘平台"""
for platform_key, adapter_cls in self._registry.items():
if match_url(url, adapter_cls):
return platform_key
return None
def get_adapter(self, platform_key: str) -> Optional[BaseCloudDriveAdapter]:
"""获取适配器实例(带缓存)"""
config = self.config_manager.get_platform(platform_key)
if not config:
return None
adapter_cls = self._registry.get(platform_key)
if not adapter_cls:
return None
# 构建缓存键
cache_key = self._cache_key(platform_key, config)
if cache_key in self._cache:
return self._cache[cache_key]
# 创建新实例
adapter = adapter_cls(config, self.config_manager.transfer)
self._cache[cache_key] = adapter
logger.info(f"[Factory] Created adapter: {platform_key} "
f"(cache_key={cache_key})")
return adapter
def get_adapter_for_url(self, url: str) -> Optional[BaseCloudDriveAdapter]:
"""根据URL自动获取适配器"""
platform = self.detect_platform(url)
if not platform:
raise TransferError(TransferErrorCode.URL_INVALID,
message=f"无法识别链接平台: {url}")
adapter = self.get_adapter(platform)
if not adapter:
raise TransferError(TransferErrorCode.NO_CONFIG,
message=f"平台 {platform} 未配置凭证",
platform=platform)
return adapter
def invalidate_cache(self, platform_key: str = None):
"""清除缓存"""
if platform_key:
keys = [k for k in self._cache if k.startswith(platform_key)]
for k in keys:
del self._cache[k]
else:
self._cache.clear()
def _cache_key(self, platform: str, config) -> str:
"""构建缓存键"""
credential = config.cookie or config.refresh_token or ""
token_hash = hashlib.md5(credential.encode()).hexdigest()[:16]
return f"{platform}:{config.account_name}:{token_hash}"
@@ -1,41 +0,0 @@
"""115网盘适配器 v1.0.0"""
from ..base import BaseCloudDriveAdapter, FileInfo, TransferResult, VerifyResult
from ...errors import TransferError, TransferErrorCode
from .credential import Pan115CredentialManager
from .transfer import Pan115Transfer, parse_share_url
from .cleanup import Pan115Cleanup
class Pan115Adapter(BaseCloudDriveAdapter):
PLATFORM_NAME = "115网盘"
PLATFORM_KEY = "pan115"
URL_PATTERNS = [r"115\.com/s/([a-z0-9]+)"]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._cred = Pan115CredentialManager(self.config)
self._transfer_engine = None
self._cln = Pan115Cleanup()
@property
def _transfer(self):
if self._transfer_engine is None:
self._transfer_engine = Pan115Transfer(
self.session, self._cred, self.config, self.transfer_config)
return self._transfer_engine
def _get_share_detail(self, pwd_id, passcode=""):
return self._transfer.get_share_info(pwd_id, passcode)
def _save_files(self, pwd_id, detail, save_dir):
return self._transfer.save_files(pwd_id, detail, save_dir)
def _create_share(self, file_ids, title, password=""):
return self._transfer.create_share(file_ids, title, password)
def get_files(self, parent_fid="0"):
return self._transfer.list_files(parent_fid)
def delete(self, file_ids):
return self._cln.delete_files(self.session, self._cred, file_ids)
@@ -1,24 +0,0 @@
"""115网盘数据清理 v1.0.0"""
import logging
from typing import List
logger = logging.getLogger(__name__)
class Pan115Cleanup:
def delete_files(self, session, credential_mgr, file_ids: List[str]) -> bool:
try:
resp = session.post(
"https://webapi.115.com/rb/delete",
json={"fid": file_ids},
timeout=30,
)
return resp.json().get("state", False)
except Exception as e:
logger.error(f"115 delete failed: {e}")
return False
def filter_ad_ids(self, file_ids: List[str], file_names: List[str],
banned_keywords: List[str]) -> List[str]:
return file_ids
@@ -1,11 +0,0 @@
"""115网盘凭证管理 v1.0.0 — Cookie直传"""
class Pan115CredentialManager:
def __init__(self, config):
self.config = config
def validate(self) -> bool:
return bool(self.config.cookie and len(self.config.cookie) >= 30)
def get_headers(self) -> dict:
return {"Cookie": self.config.cookie, "Referer": "https://115.com/"}
@@ -1,69 +0,0 @@
"""115网盘转存逻辑 v1.0.0"""
import re
import logging
from typing import List, Tuple
logger = logging.getLogger(__name__)
class Pan115Transfer:
def __init__(self, session, credential_mgr, config, transfer_config):
self.session = session
self.credential = credential_mgr
self.config = config
self.transfer_config = transfer_config
self._last_file_names = []
def parse_share_url(url: str) -> Tuple[str, str]:
m = re.search(r"115\.com/s/([a-z0-9]+)", url)
if not m:
raise ValueError("Invalid 115 share URL")
code = m.group(1)
m2 = re.search(r"password[=:](\w+)", url)
return code, m2.group(1) if m2 else ""
def get_share_info(self, code: str, password: str = "") -> dict:
params = {"share_code": code}
if password:
params["receive_code"] = password
resp = self.session.get(
"https://webapi.115.com/share/snap",
params=params,
timeout=self.transfer_config.request_timeout,
)
data = resp.json()
if not data.get("state"):
raise Exception(f"115 share info failed: {data}")
snap = data.get("data", {})
files = snap.get("list", [])
return {
"title": snap.get("shareinfo", {}).get("share_title", ""),
"files": [{"id": f.get("fid", ""), "name": f.get("n", ""),
"size": int(f.get("s", 0))} for f in files],
"cid": files[0].get("cid", "") if files else "",
}
def save_files(self, share_code: str, detail: dict, save_dir: str) -> List[str]:
cid = detail.get("cid", "0")
payload = {"share_code": share_code, "receive_code": "",
"cid": cid, "pick_code": ""}
resp = self.session.post(
"https://webapi.115.com/share/receive",
json=payload,
timeout=self.transfer_config.request_timeout,
)
data = resp.json()
if not data.get("state"):
raise Exception(f"115 save failed: {data}")
return [str(data.get("data", {}).get("cid", ""))]
def create_share(self, file_ids: List[str], title: str,
password: str = "") -> Tuple[str, str]:
return "", ""
def list_files(self, cid: str = "0") -> list:
return []
parse_share_url = staticmethod(Pan115Transfer.parse_share_url)
@@ -1,41 +0,0 @@
"""123云盘适配器 v1.0.0"""
from ..base import BaseCloudDriveAdapter, FileInfo, TransferResult, VerifyResult
from ...errors import TransferError, TransferErrorCode
from .credential import Pan123CredentialManager
from .transfer import Pan123Transfer
from .cleanup import Pan123Cleanup
class Pan123Adapter(BaseCloudDriveAdapter):
PLATFORM_NAME = "123云盘"
PLATFORM_KEY = "pan123"
URL_PATTERNS = [r"123pan\.com/s/([A-Za-z0-9]+)"]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._cred = Pan123CredentialManager(self.config)
self._transfer_engine = None
self._cln = Pan123Cleanup()
@property
def _transfer(self):
if self._transfer_engine is None:
self._transfer_engine = Pan123Transfer(
self.session, self._cred, self.config, self.transfer_config)
return self._transfer_engine
def _get_share_detail(self, pwd_id, passcode=""):
return self._transfer.get_share_info(pwd_id, passcode)
def _save_files(self, pwd_id, detail, save_dir):
return self._transfer.save_files(pwd_id, detail, save_dir)
def _create_share(self, file_ids, title, password=""):
return self._transfer.create_share(file_ids, title, password)
def get_files(self, parent_fid="0"):
return self._transfer.list_files(parent_fid)
def delete(self, file_ids):
return self._cln.delete_files(self.session, self._cred, file_ids)
@@ -1,26 +0,0 @@
"""123云盘数据清理 v1.0.0"""
import logging
from typing import List
logger = logging.getLogger(__name__)
class Pan123Cleanup:
API_BASE = "https://www.123pan.com/api"
def delete_files(self, session, credential_mgr, file_ids: List[str]) -> bool:
try:
resp = session.post(
f"{self.API_BASE}/file/delete",
json={"fileIds": file_ids},
timeout=30,
)
return resp.json().get("code") == 0
except Exception as e:
logger.error(f"123 delete failed: {e}")
return False
def filter_ad_ids(self, file_ids: List[str], file_names: List[str],
banned_keywords: List[str]) -> List[str]:
return file_ids
@@ -1,16 +0,0 @@
"""123云盘凭证管理 v1.0.0 — Cookie直传"""
class Pan123CredentialManager:
def __init__(self, config):
self.config = config
def validate(self) -> bool:
return bool(self.config.cookie and len(self.config.cookie) >= 30)
def get_headers(self) -> dict:
return {
"Cookie": self.config.cookie,
"Referer": "https://www.123pan.com/",
"Origin": "https://www.123pan.com",
}
@@ -1,71 +0,0 @@
"""123云盘转存逻辑 v1.0.0"""
import re
import logging
from typing import List, Tuple
logger = logging.getLogger(__name__)
class Pan123Transfer:
API_BASE = "https://www.123pan.com/api"
def __init__(self, session, credential_mgr, config, transfer_config):
self.session = session
self.credential = credential_mgr
self.config = config
self.transfer_config = transfer_config
self._last_file_names = []
@staticmethod
def parse_share_url(url: str) -> Tuple[str, str]:
m = re.search(r"123pan\.com/s/([A-Za-z0-9]+)", url)
if not m:
raise ValueError("Invalid 123pan share URL")
code = m.group(1)
m2 = re.search(r"[?&]pwd=(\w+)", url)
return code, m2.group(1) if m2 else ""
def get_share_info(self, share_key: str, password: str = "") -> dict:
payload = {"shareKey": share_key}
if password:
payload["sharePwd"] = password
resp = self.session.post(
f"{self.API_BASE}/share/info",
json=payload,
timeout=self.transfer_config.request_timeout,
)
data = resp.json()
if data.get("code") != 0:
raise Exception(f"123 share info failed: {data}")
info = data.get("data", {})
files = info.get("fileList", [])
return {
"title": info.get("shareName", ""),
"files": [{"id": f.get("fileId", ""), "name": f.get("fileName", ""),
"size": f.get("fileSize", 0)} for f in files],
"share_id": info.get("shareId", ""),
}
def save_files(self, share_key: str, detail: dict, save_dir: str) -> List[str]:
payload = {
"shareKey": share_key,
"shareId": detail.get("share_id", ""),
"parentFileId": save_dir or "0",
}
resp = self.session.post(
f"{self.API_BASE}/share/save",
json=payload,
timeout=self.transfer_config.request_timeout,
)
data = resp.json()
if data.get("code") != 0:
raise Exception(f"123 save failed: {data}")
return [str(data.get("data", {}).get("fileId", ""))]
def create_share(self, file_ids: List[str], title: str,
password: str = "") -> Tuple[str, str]:
return "", ""
def list_files(self, parent_id: str = "0") -> list:
return []
@@ -1,662 +0,0 @@
"""
CloudSearch Transfer — 夸克网盘适配器 v1.0.0
将 QuarkCredentialManager、QuarkTransfer、QuarkCleanup 组合为
BaseCloudDriveAdapter 的完整实现。
夸克网盘 7 步 API 转存流程:
① POST .../share/sharepage/token → stoken
② GET .../share/sharepage/detail → fid, share_fid_token, title
③ POST .../share/sharepage/save → task_id (转存)
④ 轮询 GET .../task → save_as_top_fids
⑤ POST .../share → task_id (创建分享)
⑥ 轮询 GET .../task → share_id
⑦ POST .../share/password → share_url, passcode
参考 cloud-auto-save 的 quark 实现 + netdisk 的 Pan 接口约定。
"""
from __future__ import annotations
import logging
import time
from typing import Any, Dict, List, Optional, Tuple
from ..base import BaseCloudDriveAdapter, FileInfo, TransferResult, VerifyResult
from ...config import PlatformConfig, TransferConfig
from ...errors import TransferError, TransferErrorCode
from .credential import QuarkCredentialManager
from .transfer import QuarkTransfer, SHARE_URL_PATTERN
from .cleanup import QuarkCleanup
logger = logging.getLogger(__name__)
class QuarkAdapter(BaseCloudDriveAdapter):
"""夸克网盘适配器。
组合 credential / transfer / cleanup 三个模块,
实现 BaseCloudDriveAdapter 定义的所有抽象方法。
Attributes:
PLATFORM_NAME: 展示用平台名称。
PLATFORM_KEY: 内部平台标识。
URL_PATTERNS: 夸克分享链接匹配正则列表。
"""
# ─── 平台标识 ──────────────────────────────────────────────
PLATFORM_NAME: str = "夸克网盘"
PLATFORM_KEY: str = "quark"
# ─── URL 匹配 ──────────────────────────────────────────────
# 支持 pan.quark.cn/s/<share_id>
URL_PATTERNS: List[str] = [
r"pan\.quark\.cn/s/(\w+)",
]
capabilities: Dict[str, bool] = {
"ensure_dir": True,
"save_files": True,
"poll_task": True,
"rename": True,
"move_files": True,
"delete_files": True,
"cleanup_recycle": True,
"share_staging_folder": False,
}
def __init__(self, config: PlatformConfig, transfer_config: TransferConfig) -> None:
"""初始化适配器。"""
self._credential: QuarkCredentialManager = QuarkCredentialManager(cookie=config.cookie)
self._transfer_engine: QuarkTransfer = QuarkTransfer(
credential=self._credential,
timeout=transfer_config.request_timeout,
poll_interval=transfer_config.task_poll_interval,
poll_max_attempts=transfer_config.task_poll_max_attempts,
)
self._cleanup: QuarkCleanup = QuarkCleanup(
credential=self._credential,
timeout=transfer_config.request_timeout,
)
super().__init__(config, transfer_config)
# ═══════════════════════════════════════════════════════════════
# 公开接口实现
# ═══════════════════════════════════════════════════════════════
def _setup_session(self) -> None:
"""将夸克 Cookie 注入 session 的默认 headers。"""
headers = self._credential.get_headers()
if headers:
self.session.headers.update(headers)
logger.debug("[QuarkAdapter] Session headers updated with Cookie")
# ─── transfer() 使用基类模板,子类实现 _transfer ──────────
def _transfer(self, share_url: str, save_dir: str = "",
share_password: str = "") -> TransferResult:
"""执行转存的核心逻辑(被基类 transfer() 调用)。
通过 QuarkTransfer 引擎执行完整的 7 步流程。
Args:
share_url: 夸克分享链接。
save_dir: 目标目录,空则使用配置的默认目录。
share_password: 新分享的密码。
Returns:
TransferResult 包含转存结果。
"""
start: float = time.time()
# 凭证检查
if not self._credential.validate():
raise TransferError(
TransferErrorCode.NOT_LOGIN,
message="夸克 Cookie 无效或长度不足",
platform=self.PLATFORM_KEY,
)
# 目标目录:支持 fid 或路径;路径会先创建/解析为 fid。
requested_dir: str = save_dir or self.config.save_dir or "/"
target_dir: str = requested_dir if requested_dir and not str(requested_dir).startswith("/") else self.ensure_dir(requested_dir)
# 分享密码
pwd: str = share_password or self.config.share_password or ""
try:
result: Dict[str, Any] = self._transfer_engine.transfer(
share_url=share_url,
save_dir=target_dir,
share_password=pwd,
)
except ValueError as exc:
raise TransferError(
TransferErrorCode.URL_INVALID,
message=str(exc),
platform=self.PLATFORM_KEY,
) from exc
except RuntimeError as exc:
msg: str = str(exc)
if "stoken" in msg or "status" in msg:
raise TransferError(
TransferErrorCode.SHARE_NOT_EXIST,
message=msg,
platform=self.PLATFORM_KEY,
) from exc
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=msg,
platform=self.PLATFORM_KEY,
) from exc
elapsed: int = int((time.time() - start) * 1000)
# 广告过滤:在转存完成后对 new_file_ids 进行过滤
new_fids: List[str] = result.get("new_file_ids", [])
if self.transfer_config.ad_filter_enabled and new_fids:
new_fids = self._filter_ads(new_fids)
if not new_fids:
raise TransferError(
TransferErrorCode.RESOURCE_EMPTY,
platform=self.PLATFORM_KEY,
)
return TransferResult(
success=True,
platform=self.PLATFORM_KEY,
new_file_id=",".join(new_fids),
file_name=result.get("file_name", ""),
share_url=result.get("share_url", ""),
share_password=result.get("passcode", pwd),
original_url=share_url,
elapsed_ms=elapsed,
)
# ─── verify() 使用基类模板,子类实现 _verify ───────────────
def _verify(self, share_url: str) -> VerifyResult:
"""验证夸克分享链接有效性。
通过获取 stoken → 获取详情来验证链接。
Args:
share_url: 夸克分享链接。
Returns:
VerifyResult 包含验证结果。
"""
try:
pwd_id, passcode = self._parse_share_url(share_url)
if not self._credential.validate():
return VerifyResult(
valid=False,
platform=self.PLATFORM_KEY,
error=TransferError(
TransferErrorCode.NOT_LOGIN,
platform=self.PLATFORM_KEY,
),
)
stoken: str = self._transfer_engine._get_stoken(pwd_id, passcode)
detail: Dict[str, Any] = self._transfer_engine._get_detail(pwd_id, stoken)
files: List[FileInfo] = self._extract_file_list(detail)
return VerifyResult(
valid=True,
platform=self.PLATFORM_KEY,
title=detail.get("title", ""),
file_count=len(files),
files=files,
)
except TransferError:
raise
except (ValueError, RuntimeError) as exc:
return VerifyResult(
valid=False,
platform=self.PLATFORM_KEY,
error=TransferError(
TransferErrorCode.SHARE_NOT_EXIST,
message=str(exc),
platform=self.PLATFORM_KEY,
),
)
except Exception as exc:
return VerifyResult(
valid=False,
platform=self.PLATFORM_KEY,
error=TransferError(
TransferErrorCode.NETWORK_ERROR,
message=str(exc),
platform=self.PLATFORM_KEY,
),
)
# ─── 核心抽象方法 ─────────────────────────────────────────
def _get_share_detail(self, pwd_id: str, passcode: str = "") -> dict:
"""获取夸克分享详情(基类 transfer() 流程中的步骤②)。
Args:
pwd_id: 分享 ID。
passcode: 提取码。
Returns:
分享详情字典,包含 title, fid, share_fid_token 等字段。
"""
stoken: str = self._transfer_engine._get_stoken(pwd_id, passcode)
return self._transfer_engine._get_detail(pwd_id, stoken)
def _save_files(self, pwd_id: str, detail: dict, save_dir: str) -> List[str]:
"""转存文件到自己的夸克网盘(基类 transfer() 流程中的步骤③④)。
save_dir 可传 fid 或路径;路径会先通过 ensure_dir 创建/解析为 fid。
"""
# 需要 stoken,从 detail 间接获取(重新请求)
stoken: str = self._transfer_engine._get_stoken(pwd_id)
target_fid = save_dir if save_dir and not str(save_dir).startswith("/") else self.ensure_dir(save_dir or "/")
task_id: str = self._transfer_engine._init_save(
pwd_id, stoken, detail, to_pdir_fid=target_fid
)
return self._transfer_engine._poll_save_task(task_id)
def _create_share(self, file_ids: List[str], title: str,
password: str = "") -> Tuple[str, str]:
"""创建夸克分享链接(基类 transfer() 流程中的步骤⑤⑥⑦)。
Args:
file_ids: 要分享的文件 ID 列表。
title: 分享标题。
password: 分享密码。
Returns:
(share_url, share_password) 元组。
"""
task_id: str = self._transfer_engine._init_share(file_ids, title)
share_id: str = self._transfer_engine._poll_share_task(task_id)
return self._transfer_engine._set_password(share_id, password)
def _extract_file_list(self, detail: dict) -> List[FileInfo]:
"""从夸克分享详情中提取文件列表。
夸克的 sharepage/detail 返回格式:
{
"files": [
{"fid": "...", "file_name": "...", "size": 123, "dir": false, ...},
]
}
Args:
detail: 分享详情字典。
Returns:
FileInfo 对象列表。
"""
files_data: List[Dict[str, Any]] = detail.get("files", [])
result: List[FileInfo] = []
for f in files_data:
file_info = FileInfo(
fid=str(f.get("fid", f.get("file_id", ""))),
name=str(f.get("file_name", f.get("name", ""))),
size=int(f.get("size", 0)),
is_dir=bool(f.get("dir", f.get("is_dir", False))),
ext=str(f.get("ext", f.get("file_extension", ""))),
)
result.append(file_info)
# 如果 files 为空,尝试用 detail 顶层字段构造单个文件信息
if not result and detail.get("fid"):
result.append(FileInfo(
fid=str(detail.get("fid", "")),
name=str(detail.get("title", detail.get("file_name", ""))),
size=0,
is_dir=False,
))
return result
def _filter_ads(self, file_ids: List[str]) -> List[str]:
"""过滤广告文件。
合并配置层和平台层的 banned_keywords,调用 QuarkCleanup 执行过滤。
当前实现基于 file_ids 列表过滤(无文件名信息时保持原样)。
Args:
file_ids: 文件 ID 列表。
Returns:
过滤后的文件 ID 列表。
"""
keywords: List[str] = list(
set(self.config.banned_keywords)
| set(self.transfer_config.default_banned_keywords)
)
if not keywords:
return file_ids
# 获取文件信息以进行名称匹配
# 在基类 transfer() 流程中,此处 file_ids 已为转存后的新 IDs
try:
files: List[FileInfo] = self.get_files()
file_names: List[str] = [f.name for f in files]
return QuarkCleanup.filter_ad_ids(file_ids, file_names, keywords)
except Exception:
# 如果无法获取文件名列表,跳过广告过滤
logger.warning("[QuarkAdapter] Cannot fetch file list for ad filtering, skipping")
return file_ids
# ─── Drive API capability helpers ─────────────────────────────
@staticmethod
def _normalize_dir_path(dir_path: str) -> str:
path = "/" + str(dir_path or "").strip().strip("/")
return "/" if path == "/" else path
@staticmethod
def _api_success(payload: Dict[str, Any]) -> bool:
if not isinstance(payload, dict):
return False
code = payload.get("code")
status = payload.get("status")
return code == 0 or status in (0, 200)
def ensure_dir(self, dir_path: str) -> str:
normalized = self._normalize_dir_path(dir_path)
if normalized == "/":
return "0"
parts = [part for part in normalized.strip("/").split("/") if part]
prefixes = ["/" + "/".join(parts[:idx]) for idx in range(1, len(parts) + 1)]
existing = {
item.get("file_path"): str(item.get("fid"))
for item in self.get_fids(prefixes)
if item.get("file_path") and item.get("fid")
}
leaf_fid = existing.get(normalized)
if leaf_fid:
return leaf_fid
for prefix in prefixes:
if prefix in existing:
continue
result = self.mkdir(prefix)
if self._api_success(result) and result.get("data", {}).get("fid"):
existing[prefix] = str(result["data"]["fid"])
continue
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=f"创建目录失败: {result.get('message', result)}",
platform=self.PLATFORM_KEY,
)
return existing[normalized]
def mkdir(self, dir_path: str) -> Dict[str, Any]:
url = "https://drive-pc.quark.cn/1/clouddrive/file"
params = {"pr": "ucpro", "fr": "pc", "uc_param_str": ""}
payload = {
"pdir_fid": "0",
"file_name": "",
"dir_path": self._normalize_dir_path(dir_path),
"dir_init_lock": False,
}
return self._drive_api_json(self._post(url, json_data=payload, params=params, headers=self._credential.get_headers()), context="写入网盘目录")
def rename(self, fid: str, file_name: str) -> Dict[str, Any]:
url = "https://drive-pc.quark.cn/1/clouddrive/file/rename"
params = {"pr": "ucpro", "fr": "pc", "uc_param_str": ""}
payload = {"fid": fid, "file_name": file_name}
return self._drive_api_json(self._post(url, json_data=payload, params=params, headers=self._credential.get_headers()), context="写入网盘目录")
def get_fids(self, file_paths: List[str]) -> List[Dict[str, Any]]:
pending = [self._normalize_dir_path(p) for p in file_paths]
result: List[Dict[str, Any]] = []
while pending:
batch, pending = pending[:50], pending[50:]
url = "https://drive-pc.quark.cn/1/clouddrive/file/info/path_list"
params = {"pr": "ucpro", "fr": "pc"}
payload = {"file_path": batch, "namespace": "0"}
data = self._drive_api_json(self._post(url, json_data=payload, params=params, headers=self._credential.get_headers()), context="按路径获取文件ID")
if not self._api_success(data):
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=f"获取目录ID失败: {data.get('message', data)}",
platform=self.PLATFORM_KEY,
)
result.extend(data.get("data", []))
return result
def move_files(self, fids: List[str], to_pdir_fid: str) -> Dict[str, Any]:
if not fids:
return {"code": 0, "message": "无文件需要移动"}
last: Dict[str, Any] = {"code": 0, "message": "success"}
for offset in range(0, len(fids), 100):
batch = fids[offset:offset + 100]
url = "https://drive-pc.quark.cn/1/clouddrive/file/move"
params = {"uc_param_str": "", "fr": "pc", "pr": "ucpro"}
payload = {"filelist": batch, "to_pdir_fid": to_pdir_fid, "exclude_fids": [], "action_type": 1}
last = self._drive_api_json(self._post(url, json_data=payload, params=params, headers=self._credential.get_headers()), context="移动网盘文件")
if not self._api_success(last):
return last
task_id = last.get("data", {}).get("task_id")
if task_id:
task = self.query_task(task_id)
if not self._api_success(task) or task.get("data", {}).get("status") == -1:
return {"code": 1, "message": task.get("data", {}).get("message", task.get("message", "移动任务失败")), "data": task.get("data", {})}
return {"code": 0, "message": "移动完成", "data": last.get("data", {})}
def _task_query_params(self, retry_index: int = 0) -> Dict[str, Any]:
now_ms = int(time.time() * 1000)
return {
"pr": "ucpro",
"fr": "pc",
"uc_param_str": "",
"retry_index": retry_index,
"__dt": 300,
"__t": now_ms,
}
def delete_files(self, fids: List[str]) -> Dict[str, Any]:
if not fids:
return {"code": 0, "status": 200}
if self.delete(fids):
return {"code": 0, "status": 200}
return {"code": 1, "status": 500, "message": "删除文件失败"}
def query_task(self, task_id: str) -> Dict[str, Any]:
url = "https://drive-pc.quark.cn/1/clouddrive/task"
try:
data = self._poll_task(url, task_id, query_params=self._task_query_params)
return {"code": 0, "status": 200, "data": data}
except TransferError as exc:
return {"code": 1, "status": 500, "message": str(exc), "data": {"status": -1}}
def recycle_list(self, page: int = 1, size: int = 30) -> List[Dict[str, Any]]:
url = "https://drive-pc.quark.cn/1/clouddrive/file/recycle/list"
params = {"_page": page, "_size": size, "pr": "ucpro", "fr": "pc", "uc_param_str": ""}
data = self._drive_api_json(self._get(url, params=params, headers=self._credential.get_headers()), context="列出回收站")
return data.get("data", {}).get("list", [])
def recycle_remove(self, record_list: List[Dict[str, Any]]) -> Dict[str, Any]:
url = "https://drive-pc.quark.cn/1/clouddrive/file/recycle/remove"
params = {"uc_param_str": "", "fr": "pc", "pr": "ucpro"}
payload = {"select_mode": 2, "record_list": record_list}
return self._drive_api_json(self._post(url, json_data=payload, params=params, headers=self._credential.get_headers()), context="写入网盘目录")
def cleanup_recycle(self, fids: List[str]) -> Dict[str, Any]:
target_fids = {str(fid) for fid in fids if fid}
if not target_fids:
return {"code": 0, "message": "无回收站记录需要清理", "data": {"removed": 0}}
records = [
item for item in self.recycle_list()
if str(item.get("fid") or item.get("file_id") or "") in target_fids
]
if not records:
return {"code": 0, "message": "未找到匹配的回收站记录", "data": {"removed": 0}}
result = self.recycle_remove(records)
if self._api_success(result):
result.setdefault("data", {})["removed"] = len(records)
return result
# ─── get_files / delete ────────────────────────────────────
def get_files(self, parent_fid: str = "0") -> List[FileInfo]:
"""列出夸克网盘指定目录下的文件。
GET /1/clouddrive/file/sort?pdir_fid=<parent_fid>&_page=1&_size=100&_sort=updated_at:desc
Args:
parent_fid: 父目录 ID,默认 "0" 即根目录。
Returns:
FileInfo 列表。
"""
url: str = "https://drive-pc.quark.cn/1/clouddrive/file/sort"
params: Dict[str, str] = {
"pdir_fid": parent_fid,
"_page": "1",
"_size": "100",
"_sort": "updated_at:desc",
}
headers: Dict[str, str] = self._credential.get_headers()
try:
resp = self._get(url, params=params, headers=headers)
except Exception as exc:
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=f"获取文件列表失败: {exc}",
platform=self.PLATFORM_KEY,
) from exc
data: Dict[str, Any] = resp.json()
status: int = data.get("status", -1)
if status != 0 and data.get("code") not in (0, None):
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=f"获取文件列表失败: {data.get('message')}",
platform=self.PLATFORM_KEY,
)
files_data: List[Dict[str, Any]] = data.get("data", {}).get("list", [])
result: List[FileInfo] = []
for f in files_data:
result.append(FileInfo(
fid=str(f.get("fid", "")),
name=str(f.get("file_name", f.get("name", ""))),
size=int(f.get("size", 0)),
is_dir=bool(f.get("dir", f.get("is_dir", False))),
ext=str(f.get("file_extension", f.get("ext", ""))),
))
logger.debug("[QuarkAdapter] Listed %d files in dir=%s", len(result), parent_fid)
return result
def delete(self, file_ids: List[str]) -> bool:
"""删除夸克网盘文件(移到回收站)。
Args:
file_ids: 要删除的文件 ID 列表。
Returns:
True 表示删除成功。
"""
if not self._credential.validate():
raise TransferError(
TransferErrorCode.NOT_LOGIN,
platform=self.PLATFORM_KEY,
)
try:
return self._cleanup.delete_files(file_ids)
except RuntimeError as exc:
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=str(exc),
platform=self.PLATFORM_KEY,
) from exc
def delete_permanent(self, file_ids: List[str]) -> bool:
"""彻底删除夸克网盘文件(不可恢复)。
Args:
file_ids: 要彻底删除的文件 ID 列表。
Returns:
True 表示删除成功。
"""
if not self._credential.validate():
raise TransferError(
TransferErrorCode.NOT_LOGIN,
platform=self.PLATFORM_KEY,
)
try:
return self._cleanup.delete_files_permanent(file_ids)
except RuntimeError as exc:
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=str(exc),
platform=self.PLATFORM_KEY,
) from exc
# ─── 工具方法 ─────────────────────────────────────────────
def _parse_share_url(self, url: str) -> Tuple[str, str]:
"""解析夸克分享 URL 提取 (pwd_id, passcode)。
夸克链接格式:https://pan.quark.cn/s/<pwd_id> 或带 ?pwd=xxxx
Args:
url: 夸克分享链接。
Returns:
(pwd_id, passcode) 元组。
Raises:
TransferError: URL 格式无法识别。
"""
pwd_id: Optional[str] = QuarkTransfer.parse_share_url(url)
if not pwd_id:
raise TransferError(
TransferErrorCode.URL_INVALID,
message=f"无法解析夸克链接: {url}",
platform=self.PLATFORM_KEY,
)
# 提取密码参数
from urllib.parse import urlparse, parse_qs
parsed = urlparse(url)
params = parse_qs(parsed.query)
passcode: str = params.get("pwd", params.get("code", [""]))[0]
return pwd_id, passcode
def update_cookie(self, cookie: str) -> None:
"""动态更新 Cookie 并同步到 session headers。
Args:
cookie: 新的 Cookie 字符串。
"""
self._credential.update_cookie(cookie)
self._setup_session()
logger.info("[QuarkAdapter] Cookie updated, new length=%d", len(cookie))
def close(self) -> None:
"""关闭所有子模块的 HTTP 会话。"""
self._transfer_engine.close()
self._cleanup.close()
self.session.close()
def __repr__(self) -> str:
return (
f"QuarkAdapter(name={self.PLATFORM_NAME}, "
f"account={self.config.account_name}, "
f"credential_valid={self._credential.validate()})"
)
@@ -1,209 +0,0 @@
"""
CloudSearch Transfer — 夸克网盘清理模块 v1.0.0
提供文件删除和广告过滤功能。
"""
from __future__ import annotations
import logging
from typing import Any, Dict, List
import requests
from .credential import QuarkCredentialManager
logger = logging.getLogger(__name__)
# ─── 夸克 API ─────────────────────────────────────────────────────
QUARK_API_BASE = "https://drive-pc.quark.cn"
QUARK_FILE_API = f"{QUARK_API_BASE}/1/clouddrive/file"
class QuarkCleanup:
"""夸克网盘文件清理器。
提供批量删除文件和广告文件过滤功能。
Attributes:
credential: 夸克凭证管理器。
session: 复用的 requests.Session。
timeout: HTTP 请求超时秒数。
"""
def __init__(
self,
credential: QuarkCredentialManager,
timeout: int = 30,
) -> None:
"""初始化清理器。
Args:
credential: 有效的夸克凭证管理器。
timeout: HTTP 请求超时秒数。
"""
self.credential: QuarkCredentialManager = credential
self.timeout: int = timeout
self.session: requests.Session = requests.Session()
def delete_files(self, file_ids: List[str]) -> bool:
"""批量删除文件(回收站方式)。
POST /1/clouddrive/file/delete
Body: {
"action_type": 2,
"filelist": ["<fid1>", "<fid2>", ...]
}
action_type=1 表示彻底删除,action_type=2 表示移入回收站。
Args:
file_ids: 要删除的文件 ID 列表。
Returns:
True 表示删除请求已提交成功,False 表示失败。
Raises:
RuntimeError: HTTP 请求错误。
"""
if not file_ids:
logger.warning("[QuarkCleanup] delete_files called with empty list")
return True
url: str = f"{QUARK_FILE_API}/delete"
body: Dict[str, Any] = {
"action_type": 2, # 2=回收站, 1=彻底删除
"filelist": file_ids,
}
headers = self.credential.get_headers()
headers.setdefault("Content-Type", "application/json")
logger.info("[QuarkCleanup] Deleting %d files: %s", len(file_ids), file_ids)
try:
resp = self.session.post(url, json=body, headers=headers, timeout=self.timeout)
resp.raise_for_status()
except requests.RequestException as exc:
raise RuntimeError(f"删除文件失败: {exc}") from exc
data: Dict[str, Any] = resp.json()
status: int = data.get("status", -1)
if status != 0 and data.get("code") not in (0, None):
logger.error("[QuarkCleanup] Delete returned error: status=%s, message=%s",
status, data.get("message"))
return False
logger.info("[QuarkCleanup] Delete succeeded for %d files", len(file_ids))
return True
def delete_files_permanent(self, file_ids: List[str]) -> bool:
"""彻底删除文件(不从回收站恢复)。
与 delete_files 类似,但 action_type=1。
Args:
file_ids: 要彻底删除的文件 ID 列表。
Returns:
True 表示删除请求已提交成功。
"""
if not file_ids:
return True
url: str = f"{QUARK_FILE_API}/delete"
body: Dict[str, Any] = {
"action_type": 1, # 1=彻底删除
"filelist": file_ids,
}
headers = self.credential.get_headers()
headers.setdefault("Content-Type", "application/json")
logger.info("[QuarkCleanup] Permanently deleting %d files", len(file_ids))
try:
resp = self.session.post(url, json=body, headers=headers, timeout=self.timeout)
resp.raise_for_status()
except requests.RequestException as exc:
raise RuntimeError(f"彻底删除失败: {exc}") from exc
data: Dict[str, Any] = resp.json()
return data.get("status") == 0 or data.get("code") in (0, None)
@staticmethod
def filter_ads(
files: List[Dict[str, Any]],
banned_keywords: List[str],
) -> List[Dict[str, Any]]:
"""按关键词过滤文件列表中的广告文件。
遍历文件列表,剔除文件名中包含任一 banned_keywords 的文件。
匹配方式:不区分大小写的子串匹配。
Args:
files: 文件信息字典列表,每个字典需包含 "name" 字段。
banned_keywords: 被禁关键词列表(匹配不区分大小写)。
Returns:
过滤后的文件信息列表。
"""
if not banned_keywords:
return files
filtered: List[Dict[str, Any]] = []
removed_count: int = 0
for f in files:
name: str = f.get("name", "")
name_lower: str = str(name).lower()
if any(keyword.lower() in name_lower for keyword in banned_keywords):
logger.info("[QuarkCleanup] Filtered ad file: '%s'", name)
removed_count += 1
continue
filtered.append(f)
if removed_count > 0:
logger.info("[QuarkCleanup] Ad filter removed %d/%d files", removed_count, len(files))
return filtered
@staticmethod
def filter_ad_ids(
file_ids: List[str],
file_names: List[str],
banned_keywords: List[str],
) -> List[str]:
"""按关键词过滤文件 ID 列表。
根据 file_names 判断是否为广告,返回对应的 file_ids。
Args:
file_ids: 文件 ID 列表。
file_names: 与 file_ids 一一对应的 文件名列表。
banned_keywords: 被禁关键词列表。
Returns:
过滤后的 file_ids 列表。
"""
if not banned_keywords or len(file_ids) != len(file_names):
return file_ids
filtered_ids: List[str] = []
for fid, name in zip(file_ids, file_names):
name_lower: str = str(name).lower()
if any(kw.lower() in name_lower for kw in banned_keywords):
logger.info("[QuarkCleanup] Filtered ad file: '%s' (id=%s)", name, fid)
continue
filtered_ids.append(fid)
return filtered_ids
def close(self) -> None:
"""关闭 HTTP 会话。"""
self.session.close()
def __enter__(self) -> "QuarkCleanup":
return self
def __exit__(self, *args: Any) -> None:
self.close()
@@ -1,89 +0,0 @@
"""
CloudSearch Transfer — 夸克网盘凭证管理 v1.0.0
夸克网盘使用 Cookie 直传,无需 token 刷新机制。
验证方式:检查 Cookie 字符串长度是否 >= 50。
"""
from __future__ import annotations
import logging
from typing import Dict
logger = logging.getLogger(__name__)
class QuarkCredentialManager:
"""夸克网盘凭证管理器。
夸克网盘的上传/转存 API 直接从 Cookie 中读取认证信息,
无需 OAuth 或 refresh_token 刷新流程。
Attributes:
cookie: 存储的夸克 Cookie 字符串。
"""
# 夸克 Cookie 最小长度阈值(经验值,正常 Cookie 远超此长度)
MIN_COOKIE_LENGTH: int = 50
def __init__(self, cookie: str = "") -> None:
"""初始化凭证管理器。
Args:
cookie: 夸克网盘的 Cookie 字符串。
"""
self.cookie: str = cookie
def validate(self) -> bool:
"""验证 Cookie 是否满足最小长度要求。
Returns:
True 表示 Cookie 长度 >= MIN_COOKIE_LENGTH,否则为 False。
"""
if not self.cookie:
logger.warning("[QuarkCredential] Cookie is empty")
return False
valid = len(self.cookie) >= self.MIN_COOKIE_LENGTH
if not valid:
logger.warning(
"[QuarkCredential] Cookie too short: len=%d, min=%d",
len(self.cookie),
self.MIN_COOKIE_LENGTH,
)
return valid
def is_valid(self) -> bool:
"""validate() 的别名,便于适配器层调用。"""
return self.validate()
def get_headers(self) -> Dict[str, str]:
"""构建带 Cookie 认证的 HTTP 请求头。
夸克 API 需要在每次请求头中携带完整的 Cookie 字符串。
Returns:
包含 Cookie 字段的请求头字典。Cookie 无效时仍返回空字典。
"""
if not self.validate():
logger.warning("[QuarkCredential] Cannot build headers: cookie invalid")
return {}
return {
"Cookie": self.cookie,
}
def update_cookie(self, cookie: str) -> None:
"""更新 Cookie 字符串(用于手动刷新场景)。
Args:
cookie: 新的 Cookie 字符串。
"""
self.cookie = cookie
logger.info("[QuarkCredential] Cookie updated, new length=%d", len(cookie))
def __repr__(self) -> str:
return (
f"QuarkCredentialManager(cookie_len={len(self.cookie) if self.cookie else 0}, "
f"valid={self.validate()})"
)
@@ -1,554 +0,0 @@
"""
CloudSearch Transfer — 夸克网盘转存核心 v1.0.0
夸克网盘 7 步转存流程:
① POST .../share/sharepage/token → stoken
② GET .../share/sharepage/detail → fid, share_fid_token, title
③ POST .../share/sharepage/save → task_id (转存任务)
④ 轮询 GET .../task → save_as_top_fids (status==2 完成)
⑤ POST .../share → task_id (创建分享任务)
⑥ 轮询 GET .../task → share_id
⑦ POST .../share/password → share_url, passcode
参考 cloud-auto-save 的 quark.py 实现。
"""
from __future__ import annotations
import logging
import re
import time
from typing import Any, Dict, List, Optional, Tuple
import requests
from .credential import QuarkCredentialManager
logger = logging.getLogger(__name__)
# ─── 夸克 API 基础地址 ──────────────────────────────────────────────
QUARK_API_BASE = "https://drive-pc.quark.cn"
QUARK_SHARE_API = f"{QUARK_API_BASE}/1/clouddrive/share"
# ─── URL 解析正则 ───────────────────────────────────────────────────
# 匹配 pan.quark.cn/s/<share_id>
SHARE_URL_PATTERN = re.compile(r"pan\.quark\.cn/s/(\w+)")
class QuarkTransfer:
"""夸克网盘转存引擎。
封装完整的 7 步 API 流程:获取 stoken → 获取详情 → 保存文件 →
创建分享 → 设置密码。
Attributes:
credential: 夸克凭证管理器实例。
session: 复用的 requests.Session。
timeout: 请求超时(秒)。
poll_interval: 轮询间隔(秒)。
poll_max_attempts: 最大轮询次数。
"""
def __init__(
self,
credential: QuarkCredentialManager,
timeout: int = 30,
poll_interval: float = 0.5,
poll_max_attempts: int = 50,
) -> None:
"""初始化转存引擎。
Args:
credential: 有效的夸克凭证管理器。
timeout: HTTP 请求超时秒数。
poll_interval: 异步任务轮询间隔秒数。
poll_max_attempts: 异步任务最大轮询次数(默认 50,同 base 层配置)。
"""
self.credential: QuarkCredentialManager = credential
self.timeout: int = timeout
self.poll_interval: float = poll_interval
self.poll_max_attempts: int = poll_max_attempts
self.session: requests.Session = requests.Session()
# ─── 步骤 ①:获取 stoken ───────────────────────────────────────
def _get_stoken(self, pwd_id: str, passcode: str = "") -> str:
"""步骤①:向夸克交换 stoken。
POST /1/clouddrive/share/sharepage/token
Body: {"passcode": "", "pwd_id": "<share_id>"}
Args:
pwd_id: 分享 ID(从 URL 解析)。
passcode: 分享提取码,无密码时为空字符串。
Returns:
stoken 字符串。
Raises:
RuntimeError: API 返回错误或 stoken 缺失。
"""
url = f"{QUARK_SHARE_API}/sharepage/token"
body: Dict[str, str] = {
"passcode": passcode,
"pwd_id": pwd_id,
}
headers = self.credential.get_headers()
headers.setdefault("Content-Type", "application/json")
logger.info("[QuarkTransfer] ① Getting stoken for pwd_id=%s", pwd_id)
try:
resp = self.session.post(url, json=body, headers=headers, timeout=self.timeout)
resp.raise_for_status()
except requests.RequestException as exc:
raise RuntimeError(f"获取 stoken 失败: {exc}") from exc
data: Dict[str, Any] = resp.json()
stoken: Optional[str] = data.get("data", {}).get("stoken")
if not stoken:
raise RuntimeError(f"stoken 缺失, response: {data}")
logger.info("[QuarkTransfer] ① stoken obtained")
return stoken
# ─── 步骤 ②:获取分享详情 ─────────────────────────────────────
def _get_detail(self, pwd_id: str, stoken: str) -> Dict[str, Any]:
"""步骤②:获取分享详情。
GET /1/clouddrive/share/sharepage/detail?pwd_id=xx&stoken=xx&_fetch_share=1
返回字段包含:title, fid, share_fid_token 等。
Args:
pwd_id: 分享 ID。
stoken: 步骤①获取的 stoken。
Returns:
分享详情字典。
Raises:
RuntimeError: API 返回错误。
"""
url = f"{QUARK_SHARE_API}/sharepage/detail"
params: Dict[str, str] = {
"pwd_id": pwd_id,
"stoken": stoken,
"_fetch_share": "1",
}
headers = self.credential.get_headers()
logger.info("[QuarkTransfer] ② Fetching share detail for pwd_id=%s", pwd_id)
try:
resp = self.session.get(url, params=params, headers=headers, timeout=self.timeout)
resp.raise_for_status()
except requests.RequestException as exc:
raise RuntimeError(f"获取分享详情失败: {exc}") from exc
data: Dict[str, Any] = resp.json()
status: int = data.get("status", -1)
if status != 0 and data.get("code") not in (0, None):
raise RuntimeError(f"分享详情API返回错误: status={status}, message={data.get('message')}")
detail: Optional[Dict[str, Any]] = data.get("data")
if not detail:
raise RuntimeError(f"分享详情数据为空, response: {data}")
# 提取关键字段供后续使用
logger.info(
"[QuarkTransfer] ② Detail: title=%s, fid=%s",
detail.get("title"),
detail.get("fid"),
)
return detail
# ─── 步骤 ③:发起转存 ─────────────────────────────────────────
def _init_save(self, pwd_id: str, stoken: str, detail: Dict[str, Any],
to_pdir_fid: str = "0") -> str:
"""步骤③:发起转存请求。
POST /1/clouddrive/share/sharepage/save
Body: {
"fid_list": [<fid>, ...],
"fid_token_list": [<share_fid_token>, ...],
"to_pdir_fid": "0",
"pwd_id": "<pwd_id>",
"stoken": "<stoken>",
"pdir_fid": "0",
"scene": "link"
}
Args:
pwd_id: 分享 ID。
stoken: stoken。
detail: 步骤②的分享详情。
to_pdir_fid: 目标目录 ID,默认 "0" 即根目录。
Returns:
task_id 字符串,用于步骤④轮询。
Raises:
RuntimeError: API 返回错误。
"""
url = f"{QUARK_SHARE_API}/sharepage/save"
fid_list: List[str] = detail.get("fid_list", [detail.get("fid", [])])
fid_token_list: List[str] = detail.get("fid_token_list", [detail.get("share_fid_token", [])])
# 如果 detail 的 fid/fid_token 是单值而非列表,则包装为列表
if not isinstance(fid_list, list):
fid_list = [fid_list] if fid_list else []
if not isinstance(fid_token_list, list):
fid_token_list = [fid_token_list] if fid_token_list else []
body: Dict[str, Any] = {
"fid_list": fid_list,
"fid_token_list": fid_token_list,
"to_pdir_fid": to_pdir_fid,
"pwd_id": pwd_id,
"stoken": stoken,
"pdir_fid": "0",
"scene": "link",
}
headers = self.credential.get_headers()
headers.setdefault("Content-Type", "application/json")
logger.info("[QuarkTransfer] ③ Initiating save: %d files to dir=%s", len(fid_list), to_pdir_fid)
try:
resp = self.session.post(url, json=body, headers=headers, timeout=self.timeout)
resp.raise_for_status()
except requests.RequestException as exc:
raise RuntimeError(f"发起转存失败: {exc}") from exc
data: Dict[str, Any] = resp.json()
status: int = data.get("status", -1)
if status != 0:
raise RuntimeError(f"转存请求失败: status={status}, message={data.get('message')}")
task_id: Optional[str] = data.get("data", {}).get("task_id")
if not task_id:
raise RuntimeError(f"转存 task_id 缺失, response: {data}")
logger.info("[QuarkTransfer] ③ Save task created: task_id=%s", task_id)
return task_id
# ─── 步骤 ④:轮询转存任务 ─────────────────────────────────────
def _poll_save_task(self, task_id: str) -> List[str]:
"""步骤④:轮询转存任务直到完成。
GET /1/clouddrive/task?task_id=<task_id>&retry_index=0
轮询最多 poll_max_attempts 次,
当 status==2 时表示任务成功完成,
status==-1 表示失败。
Args:
task_id: 步骤③返回的 task_id。
Returns:
save_as_top_fids 列表(转存后的文件 ID)。
Raises:
RuntimeError: 任务失败或超时。
"""
url = f"{QUARK_API_BASE}/1/clouddrive/task"
headers = self.credential.get_headers()
for attempt in range(1, self.poll_max_attempts + 1):
params: Dict[str, str] = {
"task_id": task_id,
"retry_index": "0",
}
try:
resp = self.session.get(url, params=params, headers=headers, timeout=self.timeout)
resp.raise_for_status()
except requests.RequestException:
logger.warning("[QuarkTransfer] ④ Poll attempt %d/%d failed, retrying...",
attempt, self.poll_max_attempts)
time.sleep(self.poll_interval)
continue
data: Dict[str, Any] = resp.json()
task_status: int = data.get("data", {}).get("status", -1)
logger.debug("[QuarkTransfer] ④ Poll %d/%d: status=%d", attempt, self.poll_max_attempts, task_status)
if task_status == 2: # 成功
save_as_top_fids: List[str] = (
data.get("data", {}).get("save_as", {}).get("save_as_top_fids", [])
)
logger.info("[QuarkTransfer] ④ Save completed: %d files saved", len(save_as_top_fids))
return save_as_top_fids
if task_status == -1:
raise RuntimeError(f"转存任务失败: task_id={task_id}, response={data}")
time.sleep(self.poll_interval)
raise RuntimeError(
f"转存任务超时: task_id={task_id}, 已轮询 {self.poll_max_attempts}"
)
# ─── 步骤 ⑤:发起创建分享 ─────────────────────────────────────
def _init_share(self, fid_list: List[str], title: str,
expired_type: int = 1) -> str:
"""步骤⑤:创建分享链接。
POST /1/clouddrive/share
Body: {
"fid_list": [<fid>, ...],
"title": "<title>",
"expired_type": 1
}
Args:
fid_list: 要分享的文件 ID 列表。
title: 分享标题。
expired_type: 过期类型,1=永久有效(默认)。
Returns:
task_id 字符串,用于步骤⑥轮询。
Raises:
RuntimeError: API 返回错误。
"""
url = f"{QUARK_SHARE_API}"
body: Dict[str, Any] = {
"fid_list": fid_list,
"title": title or "分享",
"expired_type": expired_type,
}
headers = self.credential.get_headers()
headers.setdefault("Content-Type", "application/json")
logger.info("[QuarkTransfer] ⑤ Creating share: %d files, title='%s'", len(fid_list), title)
try:
resp = self.session.post(url, json=body, headers=headers, timeout=self.timeout)
resp.raise_for_status()
except requests.RequestException as exc:
raise RuntimeError(f"创建分享失败: {exc}") from exc
data: Dict[str, Any] = resp.json()
status: int = data.get("status", -1)
if status != 0 and data.get("code") not in (0, None):
raise RuntimeError(f"创建分享请求失败: status={status}, message={data.get('message')}")
task_id: Optional[str] = data.get("data", {}).get("task_id")
if not task_id:
raise RuntimeError(f"分享 task_id 缺失, response: {data}")
logger.info("[QuarkTransfer] ⑤ Share task created: task_id=%s", task_id)
return task_id
# ─── 步骤 ⑥:轮询分享任务 ─────────────────────────────────────
def _poll_share_task(self, task_id: str) -> str:
"""步骤⑥:轮询分享任务直到完成。
GET /1/clouddrive/task?task_id=<task_id>&retry_index=0
轮询最多 poll_max_attempts 次,status==2 完成,
返回 share_id。
Args:
task_id: 步骤⑤返回的 task_id。
Returns:
share_id 字符串。
Raises:
RuntimeError: 任务失败或超时。
"""
url = f"{QUARK_API_BASE}/1/clouddrive/task"
headers = self.credential.get_headers()
for attempt in range(1, self.poll_max_attempts + 1):
params: Dict[str, str] = {
"task_id": task_id,
"retry_index": "0",
}
try:
resp = self.session.get(url, params=params, headers=headers, timeout=self.timeout)
resp.raise_for_status()
except requests.RequestException:
logger.warning("[QuarkTransfer] ⑥ Poll attempt %d/%d failed, retrying...",
attempt, self.poll_max_attempts)
time.sleep(self.poll_interval)
continue
data: Dict[str, Any] = resp.json()
task_status: int = data.get("data", {}).get("status", -1)
logger.debug("[QuarkTransfer] ⑥ Poll %d/%d: status=%d", attempt, self.poll_max_attempts, task_status)
if task_status == 2: # 成功
share_id: Optional[str] = data.get("data", {}).get("share_id")
if not share_id:
# 有时 share_id 在嵌套位置
share_id = data.get("data", {}).get("result", {}).get("share_id", "")
if not share_id:
raise RuntimeError(f"分享完成但 share_id 缺失: {data}")
logger.info("[QuarkTransfer] ⑥ Share completed: share_id=%s", share_id)
return share_id
if task_status == -1:
raise RuntimeError(f"分享任务失败: task_id={task_id}, response={data}")
time.sleep(self.poll_interval)
raise RuntimeError(
f"分享任务超时: task_id={task_id}, 已轮询 {self.poll_max_attempts}"
)
# ─── 步骤 ⑦:设置分享密码 ─────────────────────────────────────
def _set_password(self, share_id: str, password: str = "") -> Tuple[str, str]:
"""步骤⑦:设置分享密码并获取分享链接。
POST /1/clouddrive/share/password
Body: {"share_id": "<share_id>"}
即使不设密码也要调用此 API 以获取正式的 share_url。
Args:
share_id: 步骤⑥返回的 share_id。
password: 分享密码,空字符串表示无密码。
Returns:
(share_url, passcode) 元组。
Raises:
RuntimeError: API 返回错误。
"""
url = f"{QUARK_SHARE_API}/password"
body: Dict[str, str] = {
"share_id": share_id,
}
headers = self.credential.get_headers()
headers.setdefault("Content-Type", "application/json")
logger.info("[QuarkTransfer] ⑦ Setting password for share_id=%s", share_id)
try:
resp = self.session.post(url, json=body, headers=headers, timeout=self.timeout)
resp.raise_for_status()
except requests.RequestException as exc:
raise RuntimeError(f"设置分享密码失败: {exc}") from exc
data: Dict[str, Any] = resp.json()
status: int = data.get("status", -1)
if status != 0 and data.get("code") not in (0, None):
raise RuntimeError(f"设置密码失败: status={status}, message={data.get('message')}")
share_url: str = data.get("data", {}).get("share_url", "")
passcode: str = data.get("data", {}).get("passcode", password)
if not share_url:
# 用 share_id 构造默认分享链接
share_url = f"https://pan.quark.cn/s/{share_id}"
logger.info("[QuarkTransfer] ⑦ Password set: share_url=%s, passcode=%s", share_url, passcode)
return share_url, passcode
# ─── 公开入口 ─────────────────────────────────────────────────
def transfer(
self,
share_url: str,
save_dir: str = "0",
share_password: str = "",
) -> Dict[str, Any]:
"""执行完整的 7 步转存流程。
从原始夸克分享链接开始,将文件转存到自己网盘,再创建新分享。
Args:
share_url: 原始夸克分享链接,如 https://pan.quark.cn/s/xxxxx。
save_dir: 转存目标目录 ID,默认 "0"(根目录)。
share_password: 新分享的密码,空字符串表示无密码。
Returns:
包含以下字段的字典:
- success: bool
- new_file_ids: List[str] — 转存后的文件ID列表
- file_name: str — 分享标题
- share_url: str — 新分享链接
- passcode: str — 新分享密码
Raises:
RuntimeError: 任一步骤失败。
ValueError: URL 解析失败。
"""
# 0. 解析 URL 提取 pwd_id
match = SHARE_URL_PATTERN.search(share_url)
if not match:
raise ValueError(f"无法从URL中提取夸克分享ID: {share_url}")
pwd_id: str = match.group(1)
logger.info("[QuarkTransfer] Starting 7-step transfer for pwd_id=%s", pwd_id)
# ① 获取 stoken
stoken: str = self._get_stoken(pwd_id)
# ② 获取分享详情
detail: Dict[str, Any] = self._get_detail(pwd_id, stoken)
# ③ 发起转存
save_task_id: str = self._init_save(pwd_id, stoken, detail, to_pdir_fid=save_dir)
# ④ 轮询转存任务
new_fids: List[str] = self._poll_save_task(save_task_id)
if not new_fids:
raise RuntimeError("转存完成但未获取到文件ID")
# ⑤ 发起创建分享
title: str = detail.get("title", "分享")
share_task_id: str = self._init_share(new_fids, title)
# ⑥ 轮询分享任务
share_id: str = self._poll_share_task(share_task_id)
# ⑦ 设置密码
new_share_url, passcode = self._set_password(share_id, share_password)
result: Dict[str, Any] = {
"success": True,
"new_file_ids": new_fids,
"file_name": title,
"share_url": new_share_url,
"passcode": passcode,
}
logger.info("[QuarkTransfer] 7-step transfer complete: %s", result)
return result
@staticmethod
def parse_share_url(url: str) -> Optional[str]:
"""从夸克分享链接中提取 pwd_id。
Args:
url: 夸克分享链接。
Returns:
pwd_id 字符串,解析失败返回 None。
"""
match = SHARE_URL_PATTERN.search(url)
return match.group(1) if match else None
def close(self) -> None:
"""关闭 HTTP 会话。"""
self.session.close()
def __enter__(self) -> "QuarkTransfer":
return self
def __exit__(self, *args: Any) -> None:
self.close()
-638
View File
@@ -1,638 +0,0 @@
"""
CloudSearch Transfer — UC网盘适配器 v1.0.0
将 UcCredentialManager、UcTransfer、UcCleanup 组合为
BaseCloudDriveAdapter 的完整实现。
UC网盘 7 步 API 转存流程(与夸克高度相似,API 域名不同):
① POST .../share/sharepage/v2/detail?pr=UCBrowser&fr=pc → stoken
② GET .../share/sharepage/detail → fid, share_fid_token, title
③ POST .../share/sharepage/save → task_id (转存)
④ 轮询 GET .../task → save_as_top_fids
⑤ POST .../share → task_id (创建分享)
⑥ 轮询 GET .../task → share_id
⑦ POST .../share/password → share_url, passcode
参考 cloud-auto-save 的 quark 实现,域名从 drive-pc.quark.cn 改为 pc-api.uc.cn。
"""
from __future__ import annotations
import logging
import time
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import urlparse, parse_qs
from ..base import BaseCloudDriveAdapter, FileInfo, TransferResult, VerifyResult
from ...config import PlatformConfig, TransferConfig
from ...errors import TransferError, TransferErrorCode
from .credential import UcCredentialManager
from .transfer import UcTransfer, SHARE_URL_PATTERN
from .cleanup import UcCleanup
logger = logging.getLogger(__name__)
class UcAdapter(BaseCloudDriveAdapter):
"""UC网盘适配器。
组合 credential / transfer / cleanup 三个模块,
实现 BaseCloudDriveAdapter 定义的所有抽象方法。
Attributes:
PLATFORM_NAME: 展示用平台名称。
PLATFORM_KEY: 内部平台标识。
URL_PATTERNS: UC 分享链接匹配正则列表。
"""
# ─── 平台标识 ──────────────────────────────────────────────
PLATFORM_NAME: str = "UC网盘"
PLATFORM_KEY: str = "uc"
# ─── URL 匹配 ──────────────────────────────────────────────
# 支持 drive.uc.cn/s/<share_id>
URL_PATTERNS: List[str] = [
r"drive\.uc\.cn/s/(\w+)",
]
capabilities: Dict[str, bool] = {
"ensure_dir": True,
"save_files": True,
"poll_task": True,
"rename": True,
"move_files": True,
"delete_files": True,
"cleanup_recycle": False,
"share_staging_folder": True,
}
def __init__(self, config: PlatformConfig, transfer_config: TransferConfig) -> None:
"""初始化适配器。"""
self._credential: UcCredentialManager = UcCredentialManager(cookie=config.cookie)
self._transfer_engine: UcTransfer = UcTransfer(
credential=self._credential,
timeout=transfer_config.request_timeout,
poll_interval=transfer_config.task_poll_interval,
poll_max_attempts=transfer_config.task_poll_max_attempts,
)
self._cleanup: UcCleanup = UcCleanup(
credential=self._credential,
timeout=transfer_config.request_timeout,
)
super().__init__(config, transfer_config)
# ═══════════════════════════════════════════════════════════════
# 公开接口实现
# ═══════════════════════════════════════════════════════════════
def _setup_session(self) -> None:
"""将 UC Cookie 注入 session 的默认 headers。"""
headers = self._credential.get_headers()
if headers:
self.session.headers.update(headers)
logger.debug("[UcAdapter] Session headers updated with Cookie")
def transfer(self, share_url: str, save_dir: str = "",
share_password: str = "") -> TransferResult:
start: float = time.time()
if not self._credential.validate():
raise TransferError(
TransferErrorCode.NOT_LOGIN,
message="UC Cookie 无效或长度不足",
platform=self.PLATFORM_KEY,
)
requested_dir: str = save_dir or self.config.save_dir or "/"
target_dir: str = requested_dir if requested_dir and not str(requested_dir).startswith("/") else self.ensure_dir(requested_dir)
staging_dir: str = self.get_or_create_share_folder() or target_dir
pwd: str = share_password or self.config.share_password or ""
try:
pwd_id, passcode = self._parse_share_url(share_url)
stoken: str = self._transfer_engine._get_stoken(pwd_id, passcode)
detail: Dict[str, Any] = self._transfer_engine._get_detail(pwd_id, stoken)
task_id: str = self._transfer_engine._init_save(
pwd_id, stoken, detail, to_pdir_fid=staging_dir
)
new_fids: List[str] = self._transfer_engine._poll_save_task(task_id)
if not new_fids:
raise RuntimeError("转存完成但未获取到文件ID")
if staging_dir != target_dir:
move_result = self.move_files(new_fids, target_dir)
if not self._api_success(move_result):
raise RuntimeError(f"移动到目标目录失败: {move_result.get('message', move_result)}")
if self.transfer_config.ad_filter_enabled and new_fids:
new_fids = self._filter_ads(new_fids)
if not new_fids:
raise RuntimeError("广告过滤后无可分享文件")
title: str = detail.get("title", "分享")
share_task_id: str = self._transfer_engine._init_share(new_fids, title)
share_id: str = self._transfer_engine._poll_share_task(share_task_id)
share_url_new, passcode_new = self._transfer_engine._set_password(share_id, pwd)
except ValueError as exc:
raise TransferError(
TransferErrorCode.URL_INVALID,
message=str(exc),
platform=self.PLATFORM_KEY,
) from exc
except RuntimeError as exc:
msg: str = str(exc)
if "stoken" in msg or "status" in msg:
raise TransferError(
TransferErrorCode.SHARE_NOT_EXIST,
message=msg,
platform=self.PLATFORM_KEY,
) from exc
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=msg,
platform=self.PLATFORM_KEY,
) from exc
elapsed: int = int((time.time() - start) * 1000)
return TransferResult(
success=True,
platform=self.PLATFORM_KEY,
new_file_id=",".join(new_fids),
file_name=title,
share_url=share_url_new,
share_password=passcode_new,
original_url=share_url,
elapsed_ms=elapsed,
)
def verify(self, share_url: str) -> VerifyResult:
"""验证 UC 分享链接有效性。
Args:
share_url: UC 分享链接。
Returns:
VerifyResult 包含验证结果。
"""
try:
pwd_id, passcode = self._parse_share_url(share_url)
if not self._credential.validate():
return VerifyResult(
valid=False,
platform=self.PLATFORM_KEY,
error=TransferError(
TransferErrorCode.NOT_LOGIN,
platform=self.PLATFORM_KEY,
),
)
stoken: str = self._transfer_engine._get_stoken(pwd_id, passcode)
detail: Dict[str, Any] = self._transfer_engine._get_detail(pwd_id, stoken)
files: List[FileInfo] = self._extract_file_list(detail)
return VerifyResult(
valid=True,
platform=self.PLATFORM_KEY,
title=detail.get("title", ""),
file_count=len(files),
files=files,
)
except TransferError as exc:
return VerifyResult(valid=False, platform=self.PLATFORM_KEY, error=exc)
except (ValueError, RuntimeError) as exc:
return VerifyResult(
valid=False,
platform=self.PLATFORM_KEY,
error=TransferError(
TransferErrorCode.SHARE_NOT_EXIST,
message=str(exc),
platform=self.PLATFORM_KEY,
),
)
except Exception as exc:
return VerifyResult(
valid=False,
platform=self.PLATFORM_KEY,
error=TransferError(
TransferErrorCode.NETWORK_ERROR,
message=str(exc),
platform=self.PLATFORM_KEY,
),
)
# ─── 核心抽象方法 ─────────────────────────────────────────
def _get_share_detail(self, pwd_id: str, passcode: str = "") -> dict:
"""获取 UC 分享详情。
Args:
pwd_id: 分享 ID。
passcode: 提取码。
Returns:
分享详情字典,包含 title, fid, share_fid_token 等字段。
"""
stoken: str = self._transfer_engine._get_stoken(pwd_id, passcode)
return self._transfer_engine._get_detail(pwd_id, stoken)
def _save_files(self, pwd_id: str, detail: dict, save_dir: str) -> List[str]:
"""转存文件到自己的 UC 网盘。
Args:
pwd_id: 分享 ID。
detail: 分享详情(来自 _get_share_detail)。
save_dir: 目标目录 ID。
Returns:
转存后的新文件 ID 列表。
"""
stoken: str = self._transfer_engine._get_stoken(pwd_id)
task_id: str = self._transfer_engine._init_save(
pwd_id, stoken, detail, to_pdir_fid=save_dir
)
return self._transfer_engine._poll_save_task(task_id)
def _create_share(
self, file_ids: List[str], title: str, password: str = ""
) -> Tuple[str, str]:
"""创建 UC 分享链接。
Args:
file_ids: 要分享的文件 ID 列表。
title: 分享标题。
password: 分享密码。
Returns:
(share_url, share_password) 元组。
"""
task_id: str = self._transfer_engine._init_share(file_ids, title)
share_id: str = self._transfer_engine._poll_share_task(task_id)
return self._transfer_engine._set_password(share_id, password)
def _extract_file_list(self, detail: dict) -> List[FileInfo]:
"""从 UC 分享详情中提取文件列表。
UC 的 sharepage/detail 返回格式与夸克一致:
{
"files": [
{"fid": "...", "file_name": "...", "size": 123, "dir": false, ...},
]
}
Args:
detail: 分享详情字典。
Returns:
FileInfo 对象列表。
"""
files_data: List[Dict[str, Any]] = detail.get("files", [])
result: List[FileInfo] = []
for f in files_data:
file_info = FileInfo(
fid=str(f.get("fid", f.get("file_id", ""))),
name=str(f.get("file_name", f.get("name", ""))),
size=int(f.get("size", 0)),
is_dir=bool(f.get("dir", f.get("is_dir", False))),
ext=str(f.get("ext", f.get("file_extension", ""))),
)
result.append(file_info)
# 如果 files 为空,尝试用 detail 顶层字段构造单个文件信息
if not result and detail.get("fid"):
result.append(
FileInfo(
fid=str(detail.get("fid", "")),
name=str(detail.get("title", detail.get("file_name", ""))),
size=0,
is_dir=False,
)
)
return result
def _filter_ads(self, file_ids: List[str]) -> List[str]:
"""过滤广告文件。
Args:
file_ids: 文件 ID 列表。
Returns:
过滤后的文件 ID 列表。
"""
keywords: List[str] = list(
set(self.config.banned_keywords)
| set(self.transfer_config.default_banned_keywords)
)
if not keywords:
return file_ids
try:
files: List[FileInfo] = self.get_files()
file_names: List[str] = [f.name for f in files]
return UcCleanup.filter_ad_ids(file_ids, file_names, keywords)
except Exception:
logger.warning(
"[UcAdapter] Cannot fetch file list for ad filtering, skipping"
)
return file_ids
# ─── Drive API capability helpers ─────────────────────────────
@staticmethod
def _normalize_dir_path(dir_path: str) -> str:
path = "/" + str(dir_path or "").strip().strip("/")
return "/" if path == "/" else path
@staticmethod
def _api_success(payload: Dict[str, Any]) -> bool:
if not isinstance(payload, dict):
return False
code = payload.get("code")
status = payload.get("status")
return code == 0 or status in (0, 200)
def ensure_dir(self, dir_path: str) -> str:
normalized = self._normalize_dir_path(dir_path)
if normalized == "/":
return "0"
parts = [part for part in normalized.strip("/").split("/") if part]
prefixes = ["/" + "/".join(parts[:idx]) for idx in range(1, len(parts) + 1)]
existing = {
item.get("file_path"): str(item.get("fid"))
for item in self.get_fids(prefixes)
if item.get("file_path") and item.get("fid")
}
leaf_fid = existing.get(normalized)
if leaf_fid:
return leaf_fid
for prefix in prefixes:
if prefix in existing:
continue
result = self.mkdir(prefix)
if self._api_success(result) and result.get("data", {}).get("fid"):
existing[prefix] = str(result["data"]["fid"])
continue
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=f"创建目录失败: {result.get('message', result)}",
platform=self.PLATFORM_KEY,
)
return existing[normalized]
def mkdir(self, dir_path: str) -> Dict[str, Any]:
url = "https://pc-api.uc.cn/1/clouddrive/file"
params = {"pr": "UCBrowser", "fr": "pc", "uc_param_str": ""}
payload = {
"pdir_fid": "0",
"file_name": "",
"dir_path": self._normalize_dir_path(dir_path),
"dir_init_lock": False,
}
return self._drive_api_json(self._post(url, json_data=payload, params=params, headers=self._credential.get_headers()), context="写入网盘目录")
def rename(self, fid: str, file_name: str) -> Dict[str, Any]:
url = "https://pc-api.uc.cn/1/clouddrive/file/rename"
params = {"pr": "UCBrowser", "fr": "pc", "uc_param_str": ""}
payload = {"fid": fid, "file_name": file_name}
return self._drive_api_json(self._post(url, json_data=payload, params=params, headers=self._credential.get_headers()), context="写入网盘目录")
def get_fids(self, file_paths: List[str]) -> List[Dict[str, Any]]:
pending = [self._normalize_dir_path(p) for p in file_paths]
result: List[Dict[str, Any]] = []
while pending:
batch, pending = pending[:50], pending[50:]
url = "https://pc-api.uc.cn/1/clouddrive/file/info/path_list"
params = {"pr": "UCBrowser", "fr": "pc"}
payload = {"file_path": batch, "namespace": "0"}
data = self._drive_api_json(self._post(url, json_data=payload, params=params, headers=self._credential.get_headers()), context="按路径获取文件ID")
if not self._api_success(data):
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=f"获取目录ID失败: {data.get('message', data)}",
platform=self.PLATFORM_KEY,
)
result.extend(data.get("data", []))
return result
def move_files(self, fids: List[str], to_pdir_fid: str) -> Dict[str, Any]:
if not fids:
return {"code": 0, "message": "无文件需要移动"}
last: Dict[str, Any] = {"code": 0, "message": "success"}
for offset in range(0, len(fids), 100):
batch = fids[offset:offset + 100]
url = "https://pc-api.uc.cn/1/clouddrive/file/move"
params = {"uc_param_str": "", "fr": "pc", "pr": "UCBrowser"}
payload = {"filelist": batch, "to_pdir_fid": to_pdir_fid, "exclude_fids": [], "action_type": 1}
last = self._drive_api_json(self._post(url, json_data=payload, params=params, headers=self._credential.get_headers()), context="移动网盘文件")
if not self._api_success(last):
return last
task_id = last.get("data", {}).get("task_id")
if task_id:
task = self.query_task(task_id)
if not self._api_success(task) or task.get("data", {}).get("status") == -1:
return {"code": 1, "message": task.get("data", {}).get("message", task.get("message", "移动任务失败")), "data": task.get("data", {})}
return {"code": 0, "message": "移动完成", "data": last.get("data", {})}
def _task_query_params(self, retry_index: int = 0) -> Dict[str, Any]:
now_ms = int(time.time() * 1000)
return {
"pr": "UCBrowser",
"fr": "pc",
"uc_param_str": "",
"retry_index": retry_index,
"__dt": 300,
"__t": now_ms,
}
def delete_files(self, fids: List[str]) -> Dict[str, Any]:
if not fids:
return {"code": 0, "status": 200}
if self.delete(fids):
return {"code": 0, "status": 200}
return {"code": 1, "status": 500, "message": "删除文件失败"}
def query_task(self, task_id: str) -> Dict[str, Any]:
url = "https://pc-api.uc.cn/1/clouddrive/task"
try:
data = self._poll_task(url, task_id, query_params=self._task_query_params)
return {"code": 0, "status": 200, "data": data}
except TransferError as exc:
return {"code": 1, "status": 500, "message": str(exc), "data": {"status": -1}}
def get_or_create_share_folder(self) -> Optional[str]:
if getattr(self, "_share_folder_fid", None):
return self._share_folder_fid
root = self.ls_dir("0")
if self._api_success(root):
for item in root.get("data", {}).get("list", []):
if item.get("file_name") == "来自:分享" and item.get("dir"):
self._share_folder_fid = str(item["fid"])
return self._share_folder_fid
result = self.mkdir("/来自:分享")
if self._api_success(result) and result.get("data", {}).get("fid"):
self._share_folder_fid = str(result["data"]["fid"])
return self._share_folder_fid
return None
def ls_dir(self, pdir_fid: str) -> Dict[str, Any]:
url = "https://pc-api.uc.cn/1/clouddrive/file/sort"
params = {"pr": "UCBrowser", "fr": "pc", "pdir_fid": pdir_fid or "0", "_page": 1, "_size": 50, "_fetch_total": 1, "_fetch_sub_dirs": 0, "_sort": "file_type:asc,updated_at:desc"}
return self._drive_api_json(self._get(url, params=params, headers=self._credential.get_headers()), context="列出网盘目录")
# ─── get_files / delete ────────────────────────────────────
def get_files(self, parent_fid: str = "0") -> List[FileInfo]:
"""列出 UC 网盘指定目录下的文件。
GET /1/clouddrive/file/sort?pdir_fid=<parent_fid>&_page=1&_size=100&_sort=updated_at:desc
Args:
parent_fid: 父目录 ID,默认 "0" 即根目录。
Returns:
FileInfo 列表。
"""
url: str = f"https://pc-api.uc.cn/1/clouddrive/file/sort"
params: Dict[str, str] = {
"pdir_fid": parent_fid,
"_page": "1",
"_size": "100",
"_sort": "updated_at:desc",
}
headers: Dict[str, str] = self._credential.get_headers()
try:
resp = self._get(url, params=params, headers=headers)
except Exception as exc:
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=f"获取文件列表失败: {exc}",
platform=self.PLATFORM_KEY,
) from exc
data: Dict[str, Any] = resp.json()
status: int = data.get("status", -1)
if status != 0 and data.get("code") not in (0, None):
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=f"获取文件列表失败: {data.get('message')}",
platform=self.PLATFORM_KEY,
)
files_data: List[Dict[str, Any]] = data.get("data", {}).get("list", [])
result: List[FileInfo] = []
for f in files_data:
result.append(
FileInfo(
fid=str(f.get("fid", "")),
name=str(f.get("file_name", f.get("name", ""))),
size=int(f.get("size", 0)),
is_dir=bool(f.get("dir", f.get("is_dir", False))),
ext=str(f.get("file_extension", f.get("ext", ""))),
)
)
logger.debug("[UcAdapter] Listed %d files in dir=%s", len(result), parent_fid)
return result
def delete(self, file_ids: List[str]) -> bool:
"""删除 UC 网盘文件(移到回收站)。
Args:
file_ids: 要删除的文件 ID 列表。
Returns:
True 表示删除成功。
"""
if not self._credential.validate():
raise TransferError(
TransferErrorCode.NOT_LOGIN,
platform=self.PLATFORM_KEY,
)
try:
return self._cleanup.delete_files(file_ids)
except RuntimeError as exc:
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=str(exc),
platform=self.PLATFORM_KEY,
) from exc
def delete_permanent(self, file_ids: List[str]) -> bool:
"""彻底删除 UC 网盘文件(不可恢复)。
Args:
file_ids: 要彻底删除的文件 ID 列表。
Returns:
True 表示删除成功。
"""
if not self._credential.validate():
raise TransferError(
TransferErrorCode.NOT_LOGIN,
platform=self.PLATFORM_KEY,
)
try:
return self._cleanup.delete_files_permanent(file_ids)
except RuntimeError as exc:
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=str(exc),
platform=self.PLATFORM_KEY,
) from exc
# ─── 工具方法 ─────────────────────────────────────────────
def _parse_share_url(self, url: str) -> Tuple[str, str]:
"""解析 UC 分享 URL 提取 (pwd_id, passcode)。
UC 链接格式:https://drive.uc.cn/s/<pwd_id> 或带 ?pwd=xxxx
Args:
url: UC 分享链接。
Returns:
(pwd_id, passcode) 元组。
Raises:
TransferError: URL 格式无法识别。
"""
pwd_id: Optional[str] = UcTransfer.parse_share_url(url)
if not pwd_id:
raise TransferError(
TransferErrorCode.URL_INVALID,
message=f"无法解析UC链接: {url}",
platform=self.PLATFORM_KEY,
)
parsed = urlparse(url)
params = parse_qs(parsed.query)
passcode: str = params.get("pwd", params.get("code", [""]))[0]
return pwd_id, passcode
def update_cookie(self, cookie: str) -> None:
"""动态更新 Cookie 并同步到 session headers。
Args:
cookie: 新的 Cookie 字符串。
"""
self._credential.update_cookie(cookie)
self._setup_session()
logger.info("[UcAdapter] Cookie updated, new length=%d", len(cookie))
def close(self) -> None:
"""关闭所有子模块的 HTTP 会话。"""
self._transfer_engine.close()
-218
View File
@@ -1,218 +0,0 @@
"""
CloudSearch Transfer — UC网盘清理模块 v1.0.0
提供文件删除和广告过滤功能。API 与夸克相同,仅域名不同。
"""
from __future__ import annotations
import logging
from typing import Any, Dict, List
import requests
from .credential import UcCredentialManager
logger = logging.getLogger(__name__)
# ─── UC API ─────────────────────────────────────────────────────────
UC_API_BASE = "https://pc-api.uc.cn"
UC_FILE_API = f"{UC_API_BASE}/1/clouddrive/file"
class UcCleanup:
"""UC 网盘文件清理器。
提供批量删除文件和广告文件过滤功能。
Attributes:
credential: UC 凭证管理器。
session: 复用的 requests.Session。
timeout: HTTP 请求超时秒数。
"""
def __init__(
self,
credential: UcCredentialManager,
timeout: int = 30,
) -> None:
"""初始化清理器。
Args:
credential: 有效的 UC 凭证管理器。
timeout: HTTP 请求超时秒数。
"""
self.credential: UcCredentialManager = credential
self.timeout: int = timeout
self.session: requests.Session = requests.Session()
def delete_files(self, file_ids: List[str]) -> bool:
"""批量删除文件(回收站方式)。
POST /1/clouddrive/file/delete
Body: {
"action_type": 2,
"filelist": ["<fid1>", "<fid2>", ...]
}
action_type=1 表示彻底删除,action_type=2 表示移入回收站。
Args:
file_ids: 要删除的文件 ID 列表。
Returns:
True 表示删除请求已提交成功,False 表示失败。
Raises:
RuntimeError: HTTP 请求错误。
"""
if not file_ids:
logger.warning("[UcCleanup] delete_files called with empty list")
return True
url: str = f"{UC_FILE_API}/delete"
body: Dict[str, Any] = {
"action_type": 2, # 2=回收站, 1=彻底删除
"filelist": file_ids,
}
headers = self.credential.get_headers()
headers.setdefault("Content-Type", "application/json")
logger.info("[UcCleanup] Deleting %d files: %s", len(file_ids), file_ids)
try:
resp = self.session.post(
url, json=body, headers=headers, timeout=self.timeout
)
resp.raise_for_status()
except requests.RequestException as exc:
raise RuntimeError(f"删除文件失败: {exc}") from exc
data: Dict[str, Any] = resp.json()
status: int = data.get("status", -1)
if status != 0 and data.get("code") not in (0, None):
logger.error(
"[UcCleanup] Delete returned error: status=%s, message=%s",
status,
data.get("message"),
)
return False
logger.info("[UcCleanup] Delete succeeded for %d files", len(file_ids))
return True
def delete_files_permanent(self, file_ids: List[str]) -> bool:
"""彻底删除文件(不从回收站恢复)。
与 delete_files 类似,但 action_type=1。
Args:
file_ids: 要彻底删除的文件 ID 列表。
Returns:
True 表示删除请求已提交成功。
"""
if not file_ids:
return True
url: str = f"{UC_FILE_API}/delete"
body: Dict[str, Any] = {
"action_type": 1, # 1=彻底删除
"filelist": file_ids,
}
headers = self.credential.get_headers()
headers.setdefault("Content-Type", "application/json")
logger.info("[UcCleanup] Permanently deleting %d files", len(file_ids))
try:
resp = self.session.post(
url, json=body, headers=headers, timeout=self.timeout
)
resp.raise_for_status()
except requests.RequestException as exc:
raise RuntimeError(f"彻底删除失败: {exc}") from exc
data: Dict[str, Any] = resp.json()
return data.get("status") == 0 or data.get("code") in (0, None)
@staticmethod
def filter_ads(
files: List[Dict[str, Any]],
banned_keywords: List[str],
) -> List[Dict[str, Any]]:
"""按关键词过滤文件列表中的广告文件。
遍历文件列表,剔除文件名中包含任一 banned_keywords 的文件。
匹配方式:不区分大小写的子串匹配。
Args:
files: 文件信息字典列表,每个字典需包含 "name" 字段。
banned_keywords: 被禁关键词列表(匹配不区分大小写)。
Returns:
过滤后的文件信息列表。
"""
if not banned_keywords:
return files
filtered: List[Dict[str, Any]] = []
removed_count: int = 0
for f in files:
name: str = f.get("name", "")
name_lower: str = str(name).lower()
if any(keyword.lower() in name_lower for keyword in banned_keywords):
logger.info("[UcCleanup] Filtered ad file: '%s'", name)
removed_count += 1
continue
filtered.append(f)
if removed_count > 0:
logger.info(
"[UcCleanup] Ad filter removed %d/%d files", removed_count, len(files)
)
return filtered
@staticmethod
def filter_ad_ids(
file_ids: List[str],
file_names: List[str],
banned_keywords: List[str],
) -> List[str]:
"""按关键词过滤文件 ID 列表。
根据 file_names 判断是否为广告,返回对应的 file_ids。
Args:
file_ids: 文件 ID 列表。
file_names: 与 file_ids 一一对应的文件名列表。
banned_keywords: 被禁关键词列表。
Returns:
过滤后的 file_ids 列表。
"""
if not banned_keywords or len(file_ids) != len(file_names):
return file_ids
filtered_ids: List[str] = []
for fid, name in zip(file_ids, file_names):
name_lower: str = str(name).lower()
if any(kw.lower() in name_lower for kw in banned_keywords):
logger.info("[UcCleanup] Filtered ad file: '%s' (id=%s)", name, fid)
continue
filtered_ids.append(fid)
return filtered_ids
def close(self) -> None:
"""关闭 HTTP 会话。"""
self.session.close()
def __enter__(self) -> "UcCleanup":
return self
def __exit__(self, *args: Any) -> None:
self.close()
@@ -1,95 +0,0 @@
"""
CloudSearch Transfer — UC网盘凭证管理 v1.0.0
UC网盘使用 Cookie 直传(与夸克高度相似),无需 token 刷新机制。
验证方式:检查 Cookie 字符串长度是否 >= 50。
"""
from __future__ import annotations
import logging
from typing import Dict
logger = logging.getLogger(__name__)
class UcCredentialManager:
"""UC 网盘凭证管理器。
UC 网盘的转存 API 直接从 Cookie 中读取认证信息,
与夸克网盘机制完全一致,只是 API 域名不同(pc-api.uc.cn)。
Attributes:
cookie: 存储的 UC Cookie 字符串。
"""
# UC Cookie 最小长度阈值(与夸克一致)
MIN_COOKIE_LENGTH: int = 50
# UC 网盘 Referer
REFERER: str = "https://drive.uc.cn/"
def __init__(self, cookie: str = "") -> None:
"""初始化凭证管理器。
Args:
cookie: UC 网盘的 Cookie 字符串。
"""
self.cookie: str = cookie
def validate(self) -> bool:
"""验证 Cookie 是否满足最小长度要求。
Returns:
True 表示 Cookie 长度 >= MIN_COOKIE_LENGTH,否则为 False。
"""
if not self.cookie:
logger.warning("[UcCredential] Cookie is empty")
return False
valid = len(self.cookie) >= self.MIN_COOKIE_LENGTH
if not valid:
logger.warning(
"[UcCredential] Cookie too short: len=%d, min=%d",
len(self.cookie),
self.MIN_COOKIE_LENGTH,
)
return valid
def is_valid(self) -> bool:
"""validate() 的别名,便于适配器层调用。"""
return self.validate()
def get_headers(self) -> Dict[str, str]:
"""构建带 Cookie 认证的 HTTP 请求头。
UC API 需要在每次请求头中携带完整的 Cookie 字符串,
以及 Referer: https://drive.uc.cn/。
Returns:
包含 Cookie 和 Referer 字段的请求头字典。
Cookie 无效时仍返回空字典。
"""
if not self.validate():
logger.warning("[UcCredential] Cannot build headers: cookie invalid")
return {}
return {
"Cookie": self.cookie,
"Referer": self.REFERER,
}
def update_cookie(self, cookie: str) -> None:
"""更新 Cookie 字符串(用于手动刷新场景)。
Args:
cookie: 新的 Cookie 字符串。
"""
self.cookie = cookie
logger.info("[UcCredential] Cookie updated, new length=%d", len(cookie))
def __repr__(self) -> str:
return (
f"UcCredentialManager(cookie_len={len(self.cookie) if self.cookie else 0}, "
f"valid={self.validate()})"
)
-619
View File
@@ -1,619 +0,0 @@
"""
CloudSearch Transfer — UC网盘转存核心 v1.0.0
UC网盘 7 步转存流程(与夸克高度相似,API 域名不同):
① POST .../share/sharepage/v2/detail?pr=UCBrowser&fr=pc → stoken
② GET .../share/sharepage/detail → fid, share_fid_token, title
③ POST .../share/sharepage/save → task_id (转存)
④ 轮询 GET .../task → save_as_top_fids (status==2 完成)
⑤ POST .../share → task_id (创建分享)
⑥ 轮询 GET .../task → share_id
⑦ POST .../share/password → share_url, passcode
参考 cloud-auto-save 的 quark 实现,域名从 drive-pc.quark.cn 改为 pc-api.uc.cn。
"""
from __future__ import annotations
import logging
import re
import time
from typing import Any, Dict, List, Optional, Tuple
import requests
from .credential import UcCredentialManager
logger = logging.getLogger(__name__)
# ─── UC API 基础地址 ────────────────────────────────────────────────
UC_API_BASE = "https://pc-api.uc.cn"
UC_SHARE_API = f"{UC_API_BASE}/1/clouddrive/share"
# ─── URL 解析正则 ───────────────────────────────────────────────────
# 匹配 drive.uc.cn/s/<share_id>
SHARE_URL_PATTERN = re.compile(r"drive\.uc\.cn/s/(\w+)")
class UcTransfer:
"""UC 网盘转存引擎。
封装完整的 7 步 API 流程:获取 stoken → 获取详情 → 保存文件 →
创建分享 → 设置密码。
Attributes:
credential: UC 凭证管理器实例。
session: 复用的 requests.Session。
timeout: 请求超时(秒)。
poll_interval: 轮询间隔(秒)。
poll_max_attempts: 最大轮询次数。
"""
def __init__(
self,
credential: UcCredentialManager,
timeout: int = 30,
poll_interval: float = 0.5,
poll_max_attempts: int = 50,
) -> None:
"""初始化转存引擎。
Args:
credential: 有效的 UC 凭证管理器。
timeout: HTTP 请求超时秒数。
poll_interval: 异步任务轮询间隔秒数。
poll_max_attempts: 异步任务最大轮询次数。
"""
self.credential: UcCredentialManager = credential
self.timeout: int = timeout
self.poll_interval: float = poll_interval
self.poll_max_attempts: int = poll_max_attempts
self.session: requests.Session = requests.Session()
# ─── 步骤 ①:获取 stoken ───────────────────────────────────────
def _get_stoken(self, pwd_id: str, passcode: str = "") -> str:
"""步骤①:向 UC 交换 stoken。
POST /1/clouddrive/share/sharepage/v2/detail?pr=UCBrowser&fr=pc
Body: {"passcode": "", "pwd_id": "<share_id>"}
响应: data.token_info.stoken
UC 使用 v2/detail 接口获取 stoken,与夸克的 sharepage/token 不同。
Args:
pwd_id: 分享 ID(从 URL 解析)。
passcode: 分享提取码,无密码时为空字符串。
Returns:
stoken 字符串。
Raises:
RuntimeError: API 返回错误或 stoken 缺失。
"""
url = f"{UC_SHARE_API}/sharepage/v2/detail"
params: Dict[str, str] = {
"pr": "UCBrowser",
"fr": "pc",
}
body: Dict[str, str] = {
"passcode": passcode,
"pwd_id": pwd_id,
}
headers = self.credential.get_headers()
headers.setdefault("Content-Type", "application/json")
logger.info("[UcTransfer] ① Getting stoken for pwd_id=%s", pwd_id)
try:
resp = self.session.post(
url, json=body, params=params, headers=headers, timeout=self.timeout
)
resp.raise_for_status()
except requests.RequestException as exc:
raise RuntimeError(f"获取 stoken 失败: {exc}") from exc
data: Dict[str, Any] = resp.json()
# UC 的 stoken 在 data.token_info.stoken
stoken: Optional[str] = data.get("data", {}).get("token_info", {}).get("stoken")
if not stoken:
raise RuntimeError(f"stoken 缺失, response: {data}")
logger.info("[UcTransfer] ① stoken obtained")
return stoken
# ─── 步骤 ②:获取分享详情 ─────────────────────────────────────
def _get_detail(self, pwd_id: str, stoken: str) -> Dict[str, Any]:
"""步骤②:获取分享详情。
GET /1/clouddrive/share/sharepage/detail?pwd_id=xx&stoken=xx&_fetch_share=1
返回字段包含:title, fid, share_fid_token 等。
Args:
pwd_id: 分享 ID。
stoken: 步骤①获取的 stoken。
Returns:
分享详情字典。
Raises:
RuntimeError: API 返回错误。
"""
url = f"{UC_SHARE_API}/sharepage/detail"
params: Dict[str, str] = {
"pwd_id": pwd_id,
"stoken": stoken,
"_fetch_share": "1",
}
headers = self.credential.get_headers()
logger.info("[UcTransfer] ② Fetching share detail for pwd_id=%s", pwd_id)
try:
resp = self.session.get(
url, params=params, headers=headers, timeout=self.timeout
)
resp.raise_for_status()
except requests.RequestException as exc:
raise RuntimeError(f"获取分享详情失败: {exc}") from exc
data: Dict[str, Any] = resp.json()
status: int = data.get("status", -1)
if status != 0 and data.get("code") not in (0, None):
raise RuntimeError(
f"分享详情API返回错误: status={status}, message={data.get('message')}"
)
detail: Optional[Dict[str, Any]] = data.get("data")
if not detail:
raise RuntimeError(f"分享详情数据为空, response: {data}")
logger.info(
"[UcTransfer] ② Detail: title=%s, fid=%s",
detail.get("title"),
detail.get("fid"),
)
return detail
# ─── 步骤 ③:发起转存 ─────────────────────────────────────────
def _init_save(
self,
pwd_id: str,
stoken: str,
detail: Dict[str, Any],
to_pdir_fid: str = "0",
) -> str:
"""步骤③:发起转存请求。
POST /1/clouddrive/share/sharepage/save
Body: {
"fid_list": [<fid>, ...],
"fid_token_list": [<share_fid_token>, ...],
"to_pdir_fid": "0",
"pwd_id": "<pwd_id>",
"stoken": "<stoken>",
"pdir_fid": "0",
"scene": "link"
}
Args:
pwd_id: 分享 ID。
stoken: stoken。
detail: 步骤②的分享详情。
to_pdir_fid: 目标目录 ID,默认 "0" 即根目录。
Returns:
task_id 字符串,用于步骤④轮询。
Raises:
RuntimeError: API 返回错误。
"""
url = f"{UC_SHARE_API}/sharepage/save"
fid_list: List[str] = detail.get("fid_list", [detail.get("fid", [])])
fid_token_list: List[str] = detail.get(
"fid_token_list", [detail.get("share_fid_token", [])]
)
# 如果 detail 的 fid/fid_token 是单值而非列表,则包装为列表
if not isinstance(fid_list, list):
fid_list = [fid_list] if fid_list else []
if not isinstance(fid_token_list, list):
fid_token_list = [fid_token_list] if fid_token_list else []
body: Dict[str, Any] = {
"fid_list": fid_list,
"fid_token_list": fid_token_list,
"to_pdir_fid": to_pdir_fid,
"pwd_id": pwd_id,
"stoken": stoken,
"pdir_fid": "0",
"scene": "link",
}
headers = self.credential.get_headers()
headers.setdefault("Content-Type", "application/json")
logger.info(
"[UcTransfer] ③ Initiating save: %d files to dir=%s",
len(fid_list),
to_pdir_fid,
)
try:
resp = self.session.post(
url, json=body, headers=headers, timeout=self.timeout
)
resp.raise_for_status()
except requests.RequestException as exc:
raise RuntimeError(f"发起转存失败: {exc}") from exc
data: Dict[str, Any] = resp.json()
status: int = data.get("status", -1)
if status != 0:
raise RuntimeError(
f"转存请求失败: status={status}, message={data.get('message')}"
)
task_id: Optional[str] = data.get("data", {}).get("task_id")
if not task_id:
raise RuntimeError(f"转存 task_id 缺失, response: {data}")
logger.info("[UcTransfer] ③ Save task created: task_id=%s", task_id)
return task_id
# ─── 步骤 ④:轮询转存任务 ─────────────────────────────────────
def _poll_save_task(self, task_id: str) -> List[str]:
"""步骤④:轮询转存任务直到完成。
GET /1/clouddrive/task?task_id=<task_id>&retry_index=0
当 status==2 时表示任务成功完成,status==-1 表示失败。
Args:
task_id: 步骤③返回的 task_id。
Returns:
save_as_top_fids 列表(转存后的文件 ID)。
Raises:
RuntimeError: 任务失败或超时。
"""
url = f"{UC_API_BASE}/1/clouddrive/task"
headers = self.credential.get_headers()
for attempt in range(1, self.poll_max_attempts + 1):
params: Dict[str, str] = {
"task_id": task_id,
"retry_index": "0",
}
try:
resp = self.session.get(
url, params=params, headers=headers, timeout=self.timeout
)
resp.raise_for_status()
except requests.RequestException:
logger.warning(
"[UcTransfer] ④ Poll attempt %d/%d failed, retrying...",
attempt,
self.poll_max_attempts,
)
time.sleep(self.poll_interval)
continue
data: Dict[str, Any] = resp.json()
task_status: int = data.get("data", {}).get("status", -1)
logger.debug(
"[UcTransfer] ④ Poll %d/%d: status=%d",
attempt,
self.poll_max_attempts,
task_status,
)
if task_status == 2: # 成功
save_as_top_fids: List[str] = (
data.get("data", {})
.get("save_as", {})
.get("save_as_top_fids", [])
)
logger.info(
"[UcTransfer] ④ Save completed: %d files saved",
len(save_as_top_fids),
)
return save_as_top_fids
if task_status == -1:
raise RuntimeError(
f"转存任务失败: task_id={task_id}, response={data}"
)
time.sleep(self.poll_interval)
raise RuntimeError(
f"转存任务超时: task_id={task_id}, 已轮询 {self.poll_max_attempts}"
)
# ─── 步骤 ⑤:发起创建分享 ─────────────────────────────────────
def _init_share(
self, fid_list: List[str], title: str, expired_type: int = 1
) -> str:
"""步骤⑤:创建分享链接。
POST /1/clouddrive/share
Body: {"fid_list": [<fid>, ...], "title": "<title>", "expired_type": 1}
Args:
fid_list: 要分享的文件 ID 列表。
title: 分享标题。
expired_type: 过期类型,1=永久有效(默认)。
Returns:
task_id 字符串,用于步骤⑥轮询。
Raises:
RuntimeError: API 返回错误。
"""
url = f"{UC_SHARE_API}"
body: Dict[str, Any] = {
"fid_list": fid_list,
"title": title or "分享",
"expired_type": expired_type,
}
headers = self.credential.get_headers()
headers.setdefault("Content-Type", "application/json")
logger.info(
"[UcTransfer] ⑤ Creating share: %d files, title='%s'", len(fid_list), title
)
try:
resp = self.session.post(
url, json=body, headers=headers, timeout=self.timeout
)
resp.raise_for_status()
except requests.RequestException as exc:
raise RuntimeError(f"创建分享失败: {exc}") from exc
data: Dict[str, Any] = resp.json()
status: int = data.get("status", -1)
if status != 0 and data.get("code") not in (0, None):
raise RuntimeError(
f"创建分享请求失败: status={status}, message={data.get('message')}"
)
task_id: Optional[str] = data.get("data", {}).get("task_id")
if not task_id:
raise RuntimeError(f"分享 task_id 缺失, response: {data}")
logger.info("[UcTransfer] ⑤ Share task created: task_id=%s", task_id)
return task_id
# ─── 步骤 ⑥:轮询分享任务 ─────────────────────────────────────
def _poll_share_task(self, task_id: str) -> str:
"""步骤⑥:轮询分享任务直到完成。
GET /1/clouddrive/task?task_id=<task_id>&retry_index=0
status==2 完成,返回 share_id。
Args:
task_id: 步骤⑤返回的 task_id。
Returns:
share_id 字符串。
Raises:
RuntimeError: 任务失败或超时。
"""
url = f"{UC_API_BASE}/1/clouddrive/task"
headers = self.credential.get_headers()
for attempt in range(1, self.poll_max_attempts + 1):
params: Dict[str, str] = {
"task_id": task_id,
"retry_index": "0",
}
try:
resp = self.session.get(
url, params=params, headers=headers, timeout=self.timeout
)
resp.raise_for_status()
except requests.RequestException:
logger.warning(
"[UcTransfer] ⑥ Poll attempt %d/%d failed, retrying...",
attempt,
self.poll_max_attempts,
)
time.sleep(self.poll_interval)
continue
data: Dict[str, Any] = resp.json()
task_status: int = data.get("data", {}).get("status", -1)
logger.debug(
"[UcTransfer] ⑥ Poll %d/%d: status=%d",
attempt,
self.poll_max_attempts,
task_status,
)
if task_status == 2: # 成功
share_id: Optional[str] = data.get("data", {}).get("share_id")
if not share_id:
share_id = (
data.get("data", {}).get("result", {}).get("share_id", "")
)
if not share_id:
raise RuntimeError(f"分享完成但 share_id 缺失: {data}")
logger.info("[UcTransfer] ⑥ Share completed: share_id=%s", share_id)
return share_id
if task_status == -1:
raise RuntimeError(
f"分享任务失败: task_id={task_id}, response={data}"
)
time.sleep(self.poll_interval)
raise RuntimeError(
f"分享任务超时: task_id={task_id}, 已轮询 {self.poll_max_attempts}"
)
# ─── 步骤 ⑦:设置分享密码 ─────────────────────────────────────
def _set_password(self, share_id: str, password: str = "") -> Tuple[str, str]:
"""步骤⑦:设置分享密码并获取分享链接。
POST /1/clouddrive/share/password
Body: {"share_id": "<share_id>"}
Args:
share_id: 步骤⑥返回的 share_id。
password: 分享密码,空字符串表示无密码。
Returns:
(share_url, passcode) 元组。
Raises:
RuntimeError: API 返回错误。
"""
url = f"{UC_SHARE_API}/password"
body: Dict[str, str] = {
"share_id": share_id,
}
headers = self.credential.get_headers()
headers.setdefault("Content-Type", "application/json")
logger.info("[UcTransfer] ⑦ Setting password for share_id=%s", share_id)
try:
resp = self.session.post(
url, json=body, headers=headers, timeout=self.timeout
)
resp.raise_for_status()
except requests.RequestException as exc:
raise RuntimeError(f"设置分享密码失败: {exc}") from exc
data: Dict[str, Any] = resp.json()
status: int = data.get("status", -1)
if status != 0 and data.get("code") not in (0, None):
raise RuntimeError(
f"设置密码失败: status={status}, message={data.get('message')}"
)
share_url: str = data.get("data", {}).get("share_url", "")
passcode: str = data.get("data", {}).get("passcode", password)
if not share_url:
# 用 share_id 构造默认分享链接
share_url = f"https://drive.uc.cn/s/{share_id}"
logger.info(
"[UcTransfer] ⑦ Password set: share_url=%s, passcode=%s",
share_url,
passcode,
)
return share_url, passcode
# ─── 公开入口 ─────────────────────────────────────────────────
def transfer(
self,
share_url: str,
save_dir: str = "0",
share_password: str = "",
) -> Dict[str, Any]:
"""执行完整的 7 步转存流程。
从原始 UC 分享链接开始,将文件转存到自己网盘,再创建新分享。
Args:
share_url: 原始 UC 分享链接,如 https://drive.uc.cn/s/xxxxx。
save_dir: 转存目标目录 ID,默认 "0"(根目录)。
share_password: 新分享的密码,空字符串表示无密码。
Returns:
包含以下字段的字典:
- success: bool
- new_file_ids: List[str] — 转存后的文件ID列表
- file_name: str — 分享标题
- share_url: str — 新分享链接
- passcode: str — 新分享密码
Raises:
RuntimeError: 任一步骤失败。
ValueError: URL 解析失败。
"""
# 0. 解析 URL 提取 pwd_id
match = SHARE_URL_PATTERN.search(share_url)
if not match:
raise ValueError(f"无法从URL中提取UC分享ID: {share_url}")
pwd_id: str = match.group(1)
logger.info("[UcTransfer] Starting 7-step transfer for pwd_id=%s", pwd_id)
# ① 获取 stoken
stoken: str = self._get_stoken(pwd_id)
# ② 获取分享详情
detail: Dict[str, Any] = self._get_detail(pwd_id, stoken)
# ③ 发起转存 → ④ 轮询
task_id: str = self._init_save(pwd_id, stoken, detail, to_pdir_fid=save_dir)
new_file_ids: List[str] = self._poll_save_task(task_id)
if not new_file_ids:
raise RuntimeError("转存完成但未获取到文件ID")
# ⑤ 创建分享 → ⑥ 轮询
title: str = detail.get("title", "分享")
share_task_id: str = self._init_share(new_file_ids, title)
share_id: str = self._poll_share_task(share_task_id)
# ⑦ 设置密码
share_url_new, passcode = self._set_password(share_id, share_password)
logger.info(
"[UcTransfer] Transfer complete: %d files, new_share=%s",
len(new_file_ids),
share_url_new,
)
return {
"success": True,
"new_file_ids": new_file_ids,
"file_name": title,
"share_url": share_url_new,
"passcode": passcode,
}
@staticmethod
def parse_share_url(url: str) -> Optional[str]:
"""从 UC 分享 URL 中提取 pwd_id。
Args:
url: UC 分享链接。
Returns:
pwd_id 字符串,解析失败返回 None。
"""
match = SHARE_URL_PATTERN.search(url)
return match.group(1) if match else None
def close(self) -> None:
"""关闭 HTTP 会话。"""
self.session.close()
def __enter__(self) -> "UcTransfer":
return self
def __exit__(self, *args: Any) -> None:
self.close()
@@ -1,200 +0,0 @@
"""
CloudSearch Transfer — 迅雷网盘适配器 v1.0.0
PLATFORM_KEY = 'xunlei'
迅雷网盘使用 refresh_token + captcha_token 双重认证。
"""
from __future__ import annotations
import logging
from typing import List, Optional, Tuple, Dict
from ..base import (
BaseCloudDriveAdapter,
FileInfo,
TransferResult,
VerifyResult,
)
from ...config import PlatformConfig, TransferConfig
from ...errors import TransferError, TransferErrorCode
from .credential import XunleiCredentialManager
from .transfer import XunleiTransfer
from .cleanup import XunleiCleanup
logger = logging.getLogger(__name__)
class XunleiAdapter(BaseCloudDriveAdapter):
"""迅雷网盘适配器"""
PLATFORM_NAME = "迅雷网盘"
PLATFORM_KEY = "xunlei"
URL_PATTERNS = [r"pan\.xunlei\.com/s/([A-Za-z0-9]+)"]
capabilities: Dict[str, bool] = {
**BaseCloudDriveAdapter.capabilities,
"ensure_dir": True,
"save_files": True,
"poll_task": True,
"rename": True,
"move_files": True,
# batchDelete is permanent on Xunlei, so do not advertise it as generic delete_files.
"delete_files": False,
}
def __init__(self, config: PlatformConfig, transfer_config: TransferConfig):
# BaseCloudDriveAdapter.__init__ calls _setup_session(), so credential
# state must exist before super().__init__.
self._credential = XunleiCredentialManager(config)
self._transfer_engine: Optional[XunleiTransfer] = None
self._cleanup = XunleiCleanup(self._credential)
super().__init__(config, transfer_config)
def _setup_session(self):
"""初始化 session 认证头"""
headers = self._credential.get_auth_headers()
if headers:
self.session.headers.update(headers)
def _ensure_auth(self):
"""确保认证头是最新的"""
headers = self._credential.get_auth_headers()
self.session.headers.update(headers)
@property
def _transfer(self) -> XunleiTransfer:
"""懒加载转存引擎"""
if self._transfer_engine is None:
self._transfer_engine = XunleiTransfer(
credential=self._credential,
timeout=self.transfer_config.request_timeout,
poll_interval=self.transfer_config.task_poll_interval,
poll_max_attempts=self.transfer_config.task_poll_max_attempts,
)
self._transfer_engine.session = self.session
return self._transfer_engine
# ─── 抽象方法实现 ──────────────────────────────
def _get_share_detail(self, pwd_id: str, passcode: str = "") -> dict:
self._ensure_auth()
return self._transfer._get_share_info(pwd_id, passcode)
def _save_files(self, pwd_id: str, detail: dict, save_dir: str) -> List[str]:
self._ensure_auth()
target = self.ensure_dir(save_dir) if save_dir and save_dir.startswith("/") else (save_dir or "")
files = detail.get("files", [])
file_ids = [f.get("file_id") or f.get("fid") or f.get("id") for f in files if f.get("file_id") or f.get("fid") or f.get("id")]
if not file_ids:
raise RuntimeError("无法从分享中提取文件ID")
task_id = self._transfer._restore_files(pwd_id, detail.get("pass_code_token", ""), file_ids, parent_id=target)
mapping = self._transfer._poll_restore_task(task_id)
return [mapping.get(fid, "") for fid in file_ids if mapping.get(fid, "")]
def _create_share(self, file_ids: List[str], title: str,
password: str = "") -> Tuple[str, str]:
self._ensure_auth()
return self._transfer._create_share(file_ids, password=password)
def _extract_file_list(self, detail: dict) -> List[FileInfo]:
files = detail.get("files", [])
return [
FileInfo(fid=f.get("id", ""), name=f.get("name", ""),
size=f.get("size", 0), is_dir=f.get("is_dir", False))
for f in files
]
def _filter_ads(self, file_ids: List[str]) -> List[str]:
banned = self._get_banned_keywords()
return self._cleanup.filter_ad_ids(
file_ids,
getattr(self._transfer, "_last_file_names", []),
banned,
)
def get_files(self, parent_fid: str = "0") -> List[FileInfo]:
self._ensure_auth()
url = "https://api-pan.xunlei.com/drive/v1/files"
params = {"parent_id": "" if parent_fid in ("0", "/") else parent_fid}
data = self._drive_api_json(self._get(url, params=params, headers=self._credential.get_headers()), context="迅雷网盘列目录")
items = data.get("files", data.get("list", []))
return [FileInfo(fid=item.get("id") or item.get("file_id", ""), name=item.get("name", ""), size=item.get("size", 0), is_dir=item.get("kind") == "drive#folder" or item.get("is_dir", False)) for item in items]
def ensure_dir(self, dir_path: str) -> str:
normalized = "/" + (dir_path or "/").strip("/")
if normalized == "/":
return ""
parent_id = ""
current = ""
for part in [p for p in normalized.split("/") if p]:
current = f"{current}/{part}" if current else f"/{part}"
existing = next((item for item in self.get_files(parent_id or "0") if item.is_dir and item.name == part), None)
if existing:
parent_id = existing.fid
continue
result = self.mkdir(current if not parent_id else part, parent_id=parent_id)
parent_id = result.get("data", {}).get("fid", parent_id)
return parent_id
def get_fids(self, file_paths: List[str]) -> List[Dict]:
"""Resolve existing file/directory paths without creating anything."""
results: List[Dict] = []
for path in file_paths:
normalized = "/" + (path or "").strip("/")
if normalized == "/":
results.append({"file_path": path, "fid": ""})
continue
parent_id = ""
found: Optional[FileInfo] = None
missing = False
parts = [p for p in normalized.split("/") if p]
for index, part in enumerate(parts):
found = next((item for item in self.get_files(parent_id or "0") if item.name == part), None)
if not found:
missing = True
break
if index < len(parts) - 1 and not found.is_dir:
missing = True
break
parent_id = found.fid
if not missing and found:
results.append({"file_path": path, "fid": found.fid})
return results
def mkdir(self, dir_path: str, parent_id: str = "") -> Dict:
name = dir_path.rstrip("/").rsplit("/", 1)[-1]
body = {"kind": "drive#folder", "name": name, "parent_id": parent_id or ""}
data = self._drive_api_json(self._post("https://api-pan.xunlei.com/drive/v1/files", json_data=body, headers=self._credential.get_headers_with_captcha(action="mkdir")), context="迅雷网盘创建目录")
return {"code": 0, "status": 200, "data": {"fid": data.get("id") or data.get("file_id", ""), **data}}
def rename(self, fid: str, file_name: str) -> Dict:
data = self._drive_api_json(self._post(f"https://api-pan.xunlei.com/drive/v1/files/{fid}", json_data={"name": file_name}, headers=self._credential.get_headers()), context="迅雷网盘重命名")
return {"code": 0, "status": 200, "data": data}
def move_files(self, fids: List[str], to_pdir_fid: str) -> Dict:
target = self.ensure_dir(to_pdir_fid) if to_pdir_fid.startswith("/") else to_pdir_fid
data = self._drive_api_json(self._post("https://api-pan.xunlei.com/drive/v1/files:batchMove", json_data={"ids": fids, "parent_id": target}, headers=self._credential.get_headers()), context="迅雷网盘移动文件")
return {"code": 0, "status": 200, "data": data}
def query_task(self, task_id: str) -> Dict:
return self.poll_task(task_id)
def poll_task(self, task_id: str) -> Dict:
return {"code": 0, "status": 200, "data": self._transfer._poll_restore_task(task_id)}
def delete_files(self, fids: List[str]) -> Dict:
return {"code": 0, "status": 200} if self.delete(fids) else {"code": -1, "status": 500}
def delete(self, file_ids: List[str]) -> bool:
self._ensure_auth()
return self._cleanup.delete_files(file_ids)
def _get_banned_keywords(self) -> List[str]:
return self.config.banned_keywords or self.transfer_config.default_banned_keywords
def close(self):
self.session.close()
def __repr__(self):
return f"<XunleiAdapter account={self.config.account_name}>"
@@ -1,198 +0,0 @@
"""
CloudSearch Transfer — 迅雷网盘清理模块 v1.0.0
提供文件删除和广告过滤功能。
"""
from __future__ import annotations
import logging
from typing import Any, Dict, List
import requests
from .credential import XunleiCredentialManager
logger = logging.getLogger(__name__)
# ─── 迅雷 API ─────────────────────────────────────────────────────────
XUNLEI_PAN_API = "https://api-pan.xunlei.com"
class XunleiCleanup:
"""迅雷网盘文件清理器。
提供批量删除文件和广告文件过滤功能。
Attributes:
credential: 迅雷凭证管理器。
session: 复用的 requests.Session。
timeout: HTTP 请求超时秒数。
"""
def __init__(
self,
credential: XunleiCredentialManager,
timeout: int = 30,
) -> None:
"""初始化清理器。
Args:
credential: 有效的迅雷凭证管理器。
timeout: HTTP 请求超时秒数。
"""
self.credential: XunleiCredentialManager = credential
self.timeout: int = timeout
self.session: requests.Session = requests.Session()
def delete_files(self, file_ids: List[str]) -> bool:
"""批量删除文件。
POST /drive/v1/files:batchDelete
Body: {
"ids": ["<fid1>", "<fid2>", ...],
"space": ""
}
Args:
file_ids: 要删除的文件 ID 列表。
Returns:
True 表示删除请求已提交成功,False 表示失败。
Raises:
RuntimeError: HTTP 请求错误。
"""
if not file_ids:
logger.warning("[XunleiCleanup] delete_files called with empty list")
return True
url: str = f"{XUNLEI_PAN_API}/drive/v1/files:batchDelete"
body: Dict[str, Any] = {
"ids": file_ids,
"space": "",
}
headers = self.credential.get_headers()
headers.setdefault("Content-Type", "application/json")
logger.info("[XunleiCleanup] Deleting %d files: %s", len(file_ids), file_ids)
try:
resp = self.session.post(
url, json=body, headers=headers, timeout=self.timeout
)
resp.raise_for_status()
except requests.RequestException as exc:
raise RuntimeError(f"删除文件失败: {exc}") from exc
data: Dict[str, Any] = resp.json()
errcode = data.get("errcode", data.get("error_code", 0))
if errcode != 0:
logger.error(
"[XunleiCleanup] Delete returned error: errcode=%s, message=%s",
errcode,
data.get("message", data.get("error", "")),
)
return False
logger.info("[XunleiCleanup] Delete succeeded for %d files", len(file_ids))
return True
def delete_files_permanent(self, file_ids: List[str]) -> bool:
"""彻底删除文件。
迅雷的 batchDelete 默认为彻底删除(与回收站不同),
此方法与 delete_files 行为一致。
Args:
file_ids: 要彻底删除的文件 ID 列表。
Returns:
True 表示删除请求已提交成功。
"""
return self.delete_files(file_ids)
@staticmethod
def filter_ads(
files: List[Dict[str, Any]],
banned_keywords: List[str],
) -> List[Dict[str, Any]]:
"""按关键词过滤文件列表中的广告文件。
遍历文件列表,剔除文件名中包含任一 banned_keywords 的文件。
匹配方式:不区分大小写的子串匹配。
Args:
files: 文件信息字典列表,每个字典需包含 "name""file_name" 字段。
banned_keywords: 被禁关键词列表(匹配不区分大小写)。
Returns:
过滤后的文件信息列表。
"""
if not banned_keywords:
return files
filtered: List[Dict[str, Any]] = []
removed_count: int = 0
for f in files:
name: str = f.get("name", f.get("file_name", ""))
name_lower: str = str(name).lower()
if any(keyword.lower() in name_lower for keyword in banned_keywords):
logger.info("[XunleiCleanup] Filtered ad file: '%s'", name)
removed_count += 1
continue
filtered.append(f)
if removed_count > 0:
logger.info(
"[XunleiCleanup] Ad filter removed %d/%d files",
removed_count,
len(files),
)
return filtered
@staticmethod
def filter_ad_ids(
file_ids: List[str],
file_names: List[str],
banned_keywords: List[str],
) -> List[str]:
"""按关键词过滤文件 ID 列表。
根据 file_names 判断是否为广告,返回对应的 file_ids。
Args:
file_ids: 文件 ID 列表。
file_names: 与 file_ids 一一对应的文件名列表。
banned_keywords: 被禁关键词列表。
Returns:
过滤后的 file_ids 列表。
"""
if not banned_keywords or len(file_ids) != len(file_names):
return file_ids
filtered_ids: List[str] = []
for fid, name in zip(file_ids, file_names):
name_lower: str = str(name).lower()
if any(kw.lower() in name_lower for kw in banned_keywords):
logger.info(
"[XunleiCleanup] Filtered ad file: '%s' (id=%s)", name, fid
)
continue
filtered_ids.append(fid)
return filtered_ids
def close(self) -> None:
"""关闭 HTTP 会话。"""
self.session.close()
def __enter__(self) -> "XunleiCleanup":
return self
def __exit__(self, *args: Any) -> None:
self.close()
@@ -1,339 +0,0 @@
"""
CloudSearch Transfer — 迅雷网盘凭证管理器 v1.0.0
迅雷网盘使用 refresh_token + captcha_token 双重认证机制:
1. refresh_token → access_token (OAuth)
POST https://xluser-ssl.xunlei.com/v1/auth/token
Body: {"grant_type": "refresh_token", "refresh_token": "...", "client_id": "..."}
2. captcha_token 获取(某些操作需要)
POST /v1/shield/captcha/init
Body: {"client_id": "...", "action": "...", "device_id": "...", "meta": {"captcha_sign": "..."}}
3. get_headers() 返回所有需要的认证头:
Authorization: Bearer <access_token>
x-captcha-token: <captcha_token>
x-client-id: <client_id>
x-device-id: <device_id>
"""
from __future__ import annotations
import logging
import time
import threading
from typing import Dict, Optional
import requests
logger = logging.getLogger(__name__)
# ─── 常量 ───────────────────────────────────────────────────────────
# 迅雷网盘 OAuth 认证端点
XUNLEI_AUTH_API = "https://xluser-ssl.xunlei.com"
# 迅雷网盘客户端标识(固定值)
CLIENT_ID = "Xqp0kJBXWhwaTpB6"
DEVICE_ID = "925b7631473a13716b791d7f28289cad"
# ─── 默认请求头 ─────────────────────────────────────────────────────
DEFAULT_HEADERS: Dict[str, str] = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/135.0.0.0 Safari/537.36"
),
"Accept": "application/json, text/plain, */*",
"Content-Type": "application/json",
}
class XunleiCredentialManager:
"""迅雷网盘凭证管理器。
职责:
- 使用 refresh_token 换取 access_token
- 获取 captcha_token(特定 action 需要)
- 构建包含所有认证头的请求头字典
- 访问令牌过期前自动刷新(提前 60s)
用法:
mgr = XunleiCredentialManager(refresh_token="xxx")
mgr.refresh_access_token() # 刷新 access_token
captcha = mgr.get_captcha_token("restore") # 获取验证码令牌
headers = mgr.get_headers() # 获取完整的认证请求头
is_ok = mgr.validate() # 验证凭证有效性
Attributes:
CLIENT_ID: 迅雷客户端 ID。
DEVICE_ID: 设备标识。
"""
# ─── 类常量 ────────────────────────────────────────────────
CLIENT_ID: str = CLIENT_ID
DEVICE_ID: str = DEVICE_ID
def __init__(self, refresh_token: str = "") -> None:
"""初始化迅雷凭证管理器。
Args:
refresh_token: 迅雷网盘的 refresh_token。
"""
self._refresh_token: str = refresh_token.strip()
self._access_token: str = ""
self._expires_at: float = 0.0
self._captcha_tokens: Dict[str, str] = {} # action → captcha_token
self._lock: threading.Lock = threading.Lock()
self._session: requests.Session = requests.Session()
self._session.headers.update(DEFAULT_HEADERS)
# ─── 公开 API ──────────────────────────────────────────────
def validate(self) -> bool:
"""验证 refresh_token 是否有效。
要求 refresh_token 长度 >= 20,且能成功换取 access_token。
Returns:
True 表示凭证有效。
"""
if not self._refresh_token or len(self._refresh_token) < 20:
logger.warning(
"[XunleiCredential] refresh_token 长度不足 20,验证失败"
)
return False
return self.refresh_access_token()
def is_valid(self) -> bool:
"""validate() 的别名。"""
return self.validate()
def refresh_access_token(self) -> bool:
"""使用 refresh_token 换取 access_token。
POST /v1/auth/token
Body: {"grant_type": "refresh_token", "refresh_token": "...", "client_id": "..."}
返回 True 表示成功,False 表示失败。
"""
with self._lock:
return self._do_refresh()
def get_captcha_token(self, action: str) -> str:
"""获取指定 action 的 captcha_token。
POST /v1/shield/captcha/init
Body: {
"client_id": "...",
"action": "...",
"device_id": "...",
"meta": {"captcha_sign": "..."}
}
captcha_token 会按 action 缓存,避免重复获取。
Args:
action: 操作类型,如 "restore""share" 等。
Returns:
captcha_token 字符串,获取失败返回空字符串。
"""
with self._lock:
# 检查缓存
if action in self._captcha_tokens:
return self._captcha_tokens[action]
return self._do_get_captcha(action)
def get_headers(self) -> Dict[str, str]:
"""构建包含所有认证头的请求头字典。
返回:
- Authorization: Bearer <access_token>
- x-captcha-token: <captcha_token> (如有)
- x-client-id: <client_id>
- x-device-id: <device_id>
Returns:
认证请求头字典。
"""
self._ensure_token_valid()
headers: Dict[str, str] = {
"x-client-id": self.CLIENT_ID,
"x-device-id": self.DEVICE_ID,
}
if self._access_token:
headers["Authorization"] = f"Bearer {self._access_token}"
return headers
def get_headers_with_captcha(self, action: str = "") -> Dict[str, str]:
"""获取带 captcha_token 的完整认证头。
Args:
action: captcha 操作类型,空字符串表示不需要 captcha。
Returns:
包含 Authorization + x-captcha-token 的请求头字典。
"""
headers = self.get_headers()
if action:
captcha = self.get_captcha_token(action)
if captcha:
headers["x-captcha-token"] = captcha
return headers
def get_access_token(self) -> str:
"""获取当前有效的 access_token(必要时自动刷新)。"""
self._ensure_token_valid()
return self._access_token
@property
def refresh_token(self) -> str:
"""返回当前 refresh_token。"""
return self._refresh_token
@refresh_token.setter
def refresh_token(self, value: str) -> None:
"""更新 refresh_token。"""
self._refresh_token = value.strip()
with self._lock:
self._access_token = ""
self._expires_at = 0.0
self._captcha_tokens.clear()
# ─── 内部方法 ──────────────────────────────────────────────
def _ensure_token_valid(self) -> None:
"""确保 access_token 有效(过期则自动刷新)。"""
if not self._access_token or time.time() >= (self._expires_at - 60):
self.refresh_access_token()
def _do_refresh(self) -> bool:
"""实际执行 token 刷新。
POST https://xluser-ssl.xunlei.com/v1/auth/token
"""
if not self._refresh_token:
logger.error("[XunleiCredential] 没有 refresh_token,无法刷新")
return False
url = f"{XUNLEI_AUTH_API}/v1/auth/token"
body: Dict[str, str] = {
"grant_type": "refresh_token",
"refresh_token": self._refresh_token,
"client_id": self.CLIENT_ID,
}
try:
resp = self._session.post(url, json=body, timeout=30)
data = resp.json()
if resp.status_code != 200:
logger.error(
"[XunleiCredential] 刷新 token 失败: HTTP %d, %s",
resp.status_code,
data,
)
return False
access_token = data.get("access_token", "")
if not access_token:
logger.error(
"[XunleiCredential] 响应中缺少 access_token: %s", data
)
return False
expires_in = int(data.get("expires_in", 7200))
new_refresh = data.get("refresh_token", self._refresh_token)
self._access_token = access_token
self._expires_at = time.time() + expires_in
# 更新 refresh_token(服务端可能下发新的)
if new_refresh != self._refresh_token:
logger.info(
"[XunleiCredential] refresh_token 已轮换: "
f"{self._refresh_token[:8]}... → {new_refresh[:8]}..."
)
self._refresh_token = new_refresh
# 清除 captcha 缓存(token 变了,captcha 可能也失效了)
self._captcha_tokens.clear()
logger.info(
"[XunleiCredential] Token 刷新成功 (expires_in=%ds)", expires_in
)
return True
except requests.RequestException as e:
logger.error(f"[XunleiCredential] 刷新 token 网络异常: {e}")
return False
except Exception as e:
logger.exception(f"[XunleiCredential] 刷新 token 未知异常: {e}")
return False
def _do_get_captcha(self, action: str) -> str:
"""获取 captcha_token。
POST /v1/shield/captcha/init
"""
url = f"{XUNLEI_AUTH_API}/v1/shield/captcha/init"
body: Dict[str, Any] = {
"client_id": self.CLIENT_ID,
"action": action,
"device_id": self.DEVICE_ID,
"meta": {
"captcha_sign": "",
},
}
# 需要 Authorization 头
if not self._access_token:
if not self._do_refresh():
logger.error("[XunleiCredential] 无法获取 access_token,跳过 captcha")
return ""
headers: Dict[str, str] = {
"Authorization": f"Bearer {self._access_token}",
"Content-Type": "application/json",
}
try:
resp = self._session.post(url, json=body, headers=headers, timeout=15)
data = resp.json()
captcha_token = data.get("captcha_token", "")
if captcha_token:
self._captcha_tokens[action] = captcha_token
logger.info(
"[XunleiCredential] captcha_token 获取成功 for action=%s",
action,
)
else:
logger.warning(
"[XunleiCredential] captcha_token 为空 for action=%s: %s",
action,
data,
)
return captcha_token
except requests.RequestException as e:
logger.error(f"[XunleiCredential] 获取 captcha_token 网络异常: {e}")
return ""
except Exception as e:
logger.exception(f"[XunleiCredential] 获取 captcha_token 异常: {e}")
return ""
def __repr__(self) -> str:
return (
f"XunleiCredentialManager("
f"refresh_token={'***' if self._refresh_token else 'None'}, "
f"has_access_token={bool(self._access_token)}, "
f"captcha_actions={list(self._captcha_tokens.keys())})"
)
@@ -1,523 +0,0 @@
"""
CloudSearch Transfer — 迅雷网盘转存核心 v1.0.0
迅雷网盘 4 步转存流程:
① GET .../drive/v1/share?share_id=xx → pass_code_token, files[], title
② POST .../share/restore → restore_task_id (转存)
③ 轮询 GET .../tasks/{task_id} → progress==100, trace_file_ids → oldId→newId映射
④ POST .../share → share_url + pass_code
迅雷网盘需要 refresh_token + captcha_token 双重认证。
"""
from __future__ import annotations
import json
import logging
import re
import time
from typing import Any, Dict, List, Optional, Tuple
import requests
from .credential import XunleiCredentialManager
logger = logging.getLogger(__name__)
# ─── 迅雷 API 基础地址 ──────────────────────────────────────────────
XUNLEI_PAN_API = "https://api-pan.xunlei.com"
# ─── URL 解析正则 ───────────────────────────────────────────────────
# 匹配 pan.xunlei.com/s/<share_id>
SHARE_URL_PATTERN = re.compile(r"pan\.xunlei\.com/s/([A-Za-z0-9]+)")
class XunleiTransfer:
"""迅雷网盘转存引擎。
封装完整的 4 步 API 流程:获取分享详情 → 转存文件 →
轮询转存任务 → 创建新分享。
Attributes:
credential: 迅雷凭证管理器实例。
session: 复用的 requests.Session。
timeout: 请求超时(秒)。
poll_interval: 轮询间隔(秒)。
poll_max_attempts: 最大轮询次数。
"""
def __init__(
self,
credential: XunleiCredentialManager,
timeout: int = 30,
poll_interval: float = 1.0,
poll_max_attempts: int = 60,
) -> None:
"""初始化转存引擎。
Args:
credential: 有效的迅雷凭证管理器。
timeout: HTTP 请求超时秒数。
poll_interval: 异步任务轮询间隔秒数。
poll_max_attempts: 异步任务最大轮询次数。
"""
self.credential: XunleiCredentialManager = credential
self.timeout: int = timeout
self.poll_interval: float = poll_interval
self.poll_max_attempts: int = poll_max_attempts
self.session: requests.Session = requests.Session()
# ─── 步骤 ①:获取分享详情 ─────────────────────────────────────
def _get_share_info(self, share_id: str, passcode: str = "") -> Dict[str, Any]:
"""步骤①:获取分享详情。
GET /drive/v1/share?share_id=<share_id>
返回字段包含:pass_code_token, files[], title 等。
Args:
share_id: 分享 ID(从 URL 解析)。
Returns:
分享信息字典,包含 files, title, pass_code_token。
Raises:
RuntimeError: API 返回错误。
"""
url = f"{XUNLEI_PAN_API}/drive/v1/share"
params: Dict[str, str] = {"share_id": share_id}
if passcode:
params["pass_code"] = passcode
headers = self.credential.get_headers()
logger.info("[XunleiTransfer] ① Fetching share info for share_id=%s", share_id)
try:
resp = self.session.get(
url, params=params, headers=headers, timeout=self.timeout
)
resp.raise_for_status()
except requests.RequestException as exc:
raise RuntimeError(f"获取分享详情失败: {exc}") from exc
data: Dict[str, Any] = resp.json()
# 检查业务错误
errcode = data.get("errcode", data.get("error_code", 0))
if errcode != 0:
raise RuntimeError(
f"分享详情API返回错误: errcode={errcode}, message={data.get('message', data.get('error', ''))}"
)
# 提取关键字段
pass_code_token: str = data.get("pass_code_token", "")
files: List[Dict[str, Any]] = data.get("files", [])
title: str = data.get("title", data.get("share_name", ""))
if not files:
raise RuntimeError("分享内容为空")
logger.info(
"[XunleiTransfer] ① Share info: title=%s, files=%d, has_pass_code_token=%s",
title,
len(files),
bool(pass_code_token),
)
return {
"pass_code_token": pass_code_token,
"files": files,
"title": title,
"share_id": share_id,
}
# ─── 步骤 ②:转存文件 ─────────────────────────────────────────
def _restore_files(
self,
share_id: str,
pass_code_token: str,
file_ids: List[str],
parent_id: str = "",
) -> str:
"""步骤②:转存文件到自己的迅雷网盘。
POST /drive/v1/share/restore
Body: {
"file_ids": ["<fid1>", ...],
"pass_code_token": "<token>",
"share_id": "<share_id>",
"parent_id": "",
"specify_parent_id": true
}
Args:
share_id: 分享 ID。
pass_code_token: 步骤①获取的 pass_code_token。
file_ids: 要转存的文件 ID 列表。
parent_id: 目标父目录 ID,空字符串表示根目录。
Returns:
restore_task_id 字符串,用于步骤③轮询。
Raises:
RuntimeError: API 返回错误。
"""
url = f"{XUNLEI_PAN_API}/drive/v1/share/restore"
body: Dict[str, Any] = {
"file_ids": file_ids,
"pass_code_token": pass_code_token,
"share_id": share_id,
"parent_id": parent_id or "",
"specify_parent_id": True,
}
# restore 操作可能需要 captcha_token
headers = self.credential.get_headers_with_captcha(action="restore")
headers.setdefault("Content-Type", "application/json")
logger.info(
"[XunleiTransfer] ② Restoring %d files from share_id=%s",
len(file_ids),
share_id,
)
try:
resp = self.session.post(
url, json=body, headers=headers, timeout=self.timeout
)
resp.raise_for_status()
except requests.RequestException as exc:
raise RuntimeError(f"转存请求失败: {exc}") from exc
data: Dict[str, Any] = resp.json()
errcode = data.get("errcode", data.get("error_code", 0))
if errcode != 0:
raise RuntimeError(
f"转存请求失败: errcode={errcode}, message={data.get('message', data.get('error', ''))}"
)
task_id: Optional[str] = data.get("restore_task_id", data.get("task_id"))
if not task_id:
raise RuntimeError(f"转存 task_id 缺失, response: {data}")
logger.info("[XunleiTransfer] ② Restore task created: task_id=%s", task_id)
return task_id
# ─── 步骤 ③:轮询转存任务 ─────────────────────────────────────
def _poll_restore_task(self, task_id: str) -> Dict[str, str]:
"""步骤③:轮询转存任务直到完成。
GET /drive/v1/tasks/{task_id}
当 progress==100 时表示完成,返回 oldId→newId 映射。
从 params.trace_file_ids 解析 JSON 字符串获取映射关系。
Args:
task_id: 步骤②返回的 restore_task_id。
Returns:
{"oldId": "newId", ...} 文件 ID 映射字典。
Raises:
RuntimeError: 任务失败或超时。
"""
url = f"{XUNLEI_PAN_API}/drive/v1/tasks/{task_id}"
headers = self.credential.get_headers()
for attempt in range(1, self.poll_max_attempts + 1):
try:
resp = self.session.get(url, headers=headers, timeout=self.timeout)
resp.raise_for_status()
except requests.RequestException:
logger.warning(
"[XunleiTransfer] ③ Poll attempt %d/%d failed, retrying...",
attempt,
self.poll_max_attempts,
)
time.sleep(self.poll_interval)
continue
data: Dict[str, Any] = resp.json()
progress: int = data.get("progress", 0)
status: str = data.get("status", "")
logger.debug(
"[XunleiTransfer] ③ Poll %d/%d: progress=%d, status=%s",
attempt,
self.poll_max_attempts,
progress,
status,
)
if status == "failed" or status == "error":
raise RuntimeError(
f"转存任务失败: task_id={task_id}, status={status}"
)
if progress == 100:
# 从 params.trace_file_ids 解析 oldId→newId 映射
params: Dict[str, Any] = data.get("params", {})
trace_file_ids: str = params.get("trace_file_ids", "")
if trace_file_ids:
try:
id_mapping: Dict[str, str] = json.loads(trace_file_ids)
logger.info(
"[XunleiTransfer] ③ Restore completed: %d files mapped",
len(id_mapping),
)
return id_mapping
except json.JSONDecodeError:
logger.warning(
"[XunleiTransfer] ③ Failed to parse trace_file_ids: %s",
trace_file_ids,
)
# fallback: 检查 result 字段
result = data.get("result", {})
if result:
logger.info("[XunleiTransfer] ③ Restore completed via result field")
return result
# 最后的 fallback: 返回空映射
logger.warning(
"[XunleiTransfer] ③ Restore completed but no file mapping found"
)
return {}
if progress < 0:
raise RuntimeError(
f"转存任务异常: task_id={task_id}, progress={progress}"
)
time.sleep(self.poll_interval)
raise RuntimeError(
f"转存任务超时: task_id={task_id}, 已轮询 {self.poll_max_attempts}"
)
# ─── 步骤 ④:创建新分享 ─────────────────────────────────────
def _create_share(
self,
file_ids: List[str],
password: str = "",
expiration_days: str = "-1",
) -> Tuple[str, str]:
"""步骤④:创建新分享链接。
POST /drive/v1/share
Body: {
"file_ids": ["<fid1>", ...],
"expiration_days": "-1"
}
expiration_days: "-1" 表示永久有效。
Args:
file_ids: 要分享的文件 ID 列表。
expiration_days: 过期天数,"-1" 表示永久。
Returns:
(share_url, pass_code) 元组。
Raises:
RuntimeError: API 返回错误。
"""
url = f"{XUNLEI_PAN_API}/drive/v1/share"
body: Dict[str, Any] = {
"file_ids": file_ids,
"expiration_days": expiration_days,
}
if password:
body["pass_code"] = password
# share 操作可能需要 captcha_token
headers = self.credential.get_headers_with_captcha(action="share")
headers.setdefault("Content-Type", "application/json")
logger.info(
"[XunleiTransfer] ④ Creating share: %d files", len(file_ids)
)
try:
resp = self.session.post(
url, json=body, headers=headers, timeout=self.timeout
)
resp.raise_for_status()
except requests.RequestException as exc:
raise RuntimeError(f"创建分享失败: {exc}") from exc
data: Dict[str, Any] = resp.json()
errcode = data.get("errcode", data.get("error_code", 0))
if errcode != 0:
raise RuntimeError(
f"创建分享失败: errcode={errcode}, message={data.get('message', data.get('error', ''))}"
)
share_url: str = data.get("share_url", data.get("link", ""))
pass_code: str = data.get("pass_code", data.get("code", ""))
if not share_url:
share_id = data.get("share_id", "")
if share_id:
share_url = f"https://pan.xunlei.com/s/{share_id}"
logger.info(
"[XunleiTransfer] ④ Share created: url=%s, pass_code=%s",
share_url,
pass_code,
)
return share_url, pass_code or password
# ─── 公开入口 ─────────────────────────────────────────────────
def transfer(
self,
share_url: str,
save_dir: str = "",
share_password: str = "",
) -> Dict[str, Any]:
"""执行完整的 4 步转存流程。
从原始迅雷分享链接开始,将文件转存到自己网盘,再创建新分享。
Args:
share_url: 原始迅雷分享链接,如 https://pan.xunlei.com/s/xxxxx。
save_dir: 转存目标目录 ID,空字符串表示根目录。
share_password: 新分享的密码(迅雷使用 pass_code)。
Returns:
包含以下字段的字典:
- success: bool
- new_file_ids: List[str] — 转存后的文件ID列表(newId)
- file_name: str — 分享标题
- share_url: str — 新分享链接
- passcode: str — 新分享 pass_code
Raises:
RuntimeError: 任一步骤失败。
ValueError: URL 解析失败。
"""
# 0. 解析 URL 提取 share_id
match = SHARE_URL_PATTERN.search(share_url)
if not match:
raise ValueError(f"无法从URL中提取迅雷分享ID: {share_url}")
share_id: str = match.group(1)
logger.info(
"[XunleiTransfer] Starting 4-step transfer for share_id=%s", share_id
)
# ① 获取分享详情
share_info: Dict[str, Any] = self._get_share_info(share_id)
files: List[Dict[str, Any]] = share_info.get("files", [])
title: str = share_info.get("title", "分享")
pass_code_token: str = share_info.get("pass_code_token", "")
# 提取原始文件 ID
file_ids: List[str] = [
f.get("file_id", f.get("fid", f.get("id", "")))
for f in files
if f.get("file_id") or f.get("fid") or f.get("id")
]
if not file_ids:
raise RuntimeError("无法从分享中提取文件ID")
# ② 发起转存
task_id: str = self._restore_files(
share_id, pass_code_token, file_ids, parent_id=save_dir
)
# ③ 轮询转存任务 → 获取 oldId→newId 映射
id_mapping: Dict[str, str] = self._poll_restore_task(task_id)
# 从映射中提取新的文件 ID
new_file_ids: List[str] = []
for old_fid in file_ids:
new_fid = id_mapping.get(old_fid, "")
if new_fid:
new_file_ids.append(new_fid)
else:
logger.warning(
"[XunleiTransfer] No newId mapped for old_fid=%s", old_fid
)
if not new_file_ids:
raise RuntimeError("转存完成但未获取到新文件ID")
# ④ 创建新分享
share_url_new, pass_code = self._create_share(new_file_ids)
logger.info(
"[XunleiTransfer] Transfer complete: %d files, new_share=%s",
len(new_file_ids),
share_url_new,
)
return {
"success": True,
"new_file_ids": new_file_ids,
"file_name": title,
"share_url": share_url_new,
"passcode": pass_code or share_password,
}
@staticmethod
def parse_share_url(url: str) -> Optional[str]:
"""从迅雷分享 URL 中提取 share_id。
Args:
url: 迅雷分享链接。
Returns:
share_id 字符串,解析失败返回 None。
"""
match = SHARE_URL_PATTERN.search(url)
return match.group(1) if match else None
@staticmethod
def extract_file_ids(files: List[Dict[str, Any]]) -> List[str]:
"""从文件列表中提取 file_id。
Args:
files: 文件信息字典列表。
Returns:
file_id 字符串列表。
"""
return [
f.get("file_id", f.get("fid", f.get("id", "")))
for f in files
if f.get("file_id") or f.get("fid") or f.get("id")
]
@staticmethod
def parse_trace_file_ids(trace: str) -> Dict[str, str]:
"""解析 trace_file_ids JSON 字符串为 oldId→newId 映射。
Args:
trace: trace_file_ids JSON 字符串,如 '{"oldId":"newId"}'.
Returns:
{"oldId": "newId", ...} 映射字典。
"""
try:
return json.loads(trace)
except (json.JSONDecodeError, TypeError):
return {}
def close(self) -> None:
"""关闭 HTTP 会话。"""
self.session.close()
def __enter__(self) -> "XunleiTransfer":
return self
def __exit__(self, *args: Any) -> None:
self.close()
-172
View File
@@ -1,172 +0,0 @@
"""
CloudSearch Transfer — 配置管理 v1.0.0
支持环境变量 + JSON文件 + 数据库多级配置源
"""
import os
import json
from pathlib import Path
from typing import Optional, Dict, Any
from dataclasses import dataclass, field
@dataclass
class PlatformConfig:
"""单个网盘平台的配置"""
enabled: bool = False
cookie: str = "" # Cookie字符串(夸克/百度/UC/123
refresh_token: str = "" # OAuth RefreshToken(阿里/迅雷)
access_token: str = "" # 运行时AccessToken(自动刷新)
account_name: str = "" # 账号名(多账号路由)
save_dir: str = "/" # 默认转存目录
share_password: str = "" # 分享密码
banned_keywords: list = field(default_factory=list) # 广告过滤关键词
extra: Dict[str, Any] = field(default_factory=dict) # 扩展字段
@dataclass
class TransferConfig:
"""转存服务配置"""
# HTTP
request_timeout: int = 30 # 请求超时(秒)
max_retries: int = 3 # 最大重试次数
retry_delay: float = 1.0 # 重试延迟(秒)
# 任务轮询
task_poll_interval: float = 0.5 # 轮询间隔(秒)
task_poll_max_attempts: int = 50 # 最大轮询次数
task_poll_max_wait: int = 60 # 最大等待时间(秒)
# 并发控制
max_concurrent_transfers: int = 5 # 最大并发转存数
transfer_queue_size: int = 100 # 转存队列大小
# 广告过滤
ad_filter_enabled: bool = True # 是否启用广告过滤
default_banned_keywords: list = field(default_factory=lambda: [
"公众号", "微信", "扫码", "加群", "QQ群", "广告",
"关注", "免费领取", "点击领取", "全网", "最全",
])
# 分享设置
default_share_period: str = "permanent" # 永久/7d/30d
auto_generate_password: bool = False # 自动生成分享密码
class ConfigManager:
"""统一配置管理器"""
def __init__(self, config_path: Optional[str] = None):
self._config_path = config_path or os.getenv(
"TRANSFER_CONFIG_PATH",
"/data/transfer_config.json"
)
self.platforms: Dict[str, PlatformConfig] = {}
self.transfer: TransferConfig = TransferConfig()
self._load()
def _load(self):
"""加载配置:环境变量 → JSON文件 → 默认值"""
# 1. 从JSON文件加载
if Path(self._config_path).exists():
with open(self._config_path) as f:
data = json.load(f)
self._parse_json(data)
# 2. 环境变量覆盖
self._apply_env_overrides()
def _parse_json(self, data: dict):
"""解析JSON配置"""
# 平台配置
platforms_data = data.get("platforms", {})
for name, cfg in platforms_data.items():
self.platforms[name] = PlatformConfig(
enabled=cfg.get("enabled", False),
cookie=cfg.get("cookie", ""),
refresh_token=cfg.get("refresh_token", ""),
access_token=cfg.get("access_token", ""),
account_name=cfg.get("account_name", name),
save_dir=cfg.get("save_dir", "/"),
share_password=cfg.get("share_password", ""),
banned_keywords=cfg.get("banned_keywords", []),
extra=cfg.get("extra", {}),
)
# 传输配置
transfer_data = data.get("transfer", {})
if transfer_data:
self.transfer = TransferConfig(
request_timeout=transfer_data.get("request_timeout", 30),
max_retries=transfer_data.get("max_retries", 3),
retry_delay=transfer_data.get("retry_delay", 1.0),
task_poll_interval=transfer_data.get("task_poll_interval", 0.5),
task_poll_max_attempts=transfer_data.get("task_poll_max_attempts", 50),
max_concurrent_transfers=transfer_data.get("max_concurrent_transfers", 5),
ad_filter_enabled=transfer_data.get("ad_filter_enabled", True),
)
def _apply_env_overrides(self):
"""环境变量覆盖:TRANSFER_<PLATFORM>_COOKIE 等"""
env_map = {
"quark": "QUARK",
"baidu": "BAIDU",
"aliyun": "ALIYUN",
"uc": "UC",
"xunlei": "XUNLEI",
"pan123": "PAN123",
"cloud189": "CLOUD189",
}
for platform, prefix in env_map.items():
cookie = os.getenv(f"TRANSFER_{prefix}_COOKIE")
if cookie:
if platform not in self.platforms:
self.platforms[platform] = PlatformConfig()
self.platforms[platform].cookie = cookie
self.platforms[platform].enabled = True
token = os.getenv(f"TRANSFER_{prefix}_REFRESH_TOKEN")
if token:
if platform not in self.platforms:
self.platforms[platform] = PlatformConfig()
self.platforms[platform].refresh_token = token
self.platforms[platform].enabled = True
def get_platform(self, name: str) -> Optional[PlatformConfig]:
"""获取平台配置"""
config = self.platforms.get(name)
if config and config.enabled:
return config
return None
def get_enabled_platforms(self) -> list:
"""获取所有已启用的平台名"""
return [name for name, cfg in self.platforms.items() if cfg.enabled]
def save(self):
"""保存配置到文件"""
data = {
"platforms": {
name: {
"enabled": cfg.enabled,
"cookie": cfg.cookie,
"refresh_token": cfg.refresh_token,
"account_name": cfg.account_name,
"save_dir": cfg.save_dir,
"share_password": cfg.share_password,
"banned_keywords": cfg.banned_keywords,
"extra": cfg.extra,
}
for name, cfg in self.platforms.items()
},
"transfer": {
"request_timeout": self.transfer.request_timeout,
"max_retries": self.transfer.max_retries,
"max_concurrent_transfers": self.transfer.max_concurrent_transfers,
"ad_filter_enabled": self.transfer.ad_filter_enabled,
}
}
Path(self._config_path).parent.mkdir(parents=True, exist_ok=True)
with open(self._config_path, "w") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
@@ -1 +0,0 @@
"""CloudSearch Transfer — 凭证管理包"""
-130
View File
@@ -1,130 +0,0 @@
"""
CloudSearch Transfer — 凭证管理器 v1.0.0
参考 search-ucmao 的 get_and_validate_credential + cloud-auto-save 的 Token回写
"""
import time
import logging
from typing import Optional, Dict, Any
from dataclasses import dataclass, field
from ..config import PlatformConfig
logger = logging.getLogger(__name__)
@dataclass
class CredentialStatus:
"""凭证状态"""
valid: bool
platform: str
last_check: float = 0.0
last_error: str = ""
checks_count: int = 0
fail_count: int = 0
class CredentialManager:
"""
凭证管理器
- 凭证校验(各平台最小长度要求不同)
- Token自动刷新(阿里云/迅雷)
- 健康检测
"""
# 各平台最小凭证长度
MIN_LENGTH_MAP = {
"quark": 50, # Cookie ≥ 50字符
"baidu": 50, # Cookie ≥ 50字符
"uc": 50, # Cookie ≥ 50字符
"aliyun": 20, # refresh_token ≥ 20字符
"xunlei": 30, # refresh_token ≥ 30字符
"pan123": 30,
"cloud189": 30,
}
# 凭证类型:cookie / refresh_token
CREDENTIAL_TYPE = {
"quark": "cookie",
"baidu": "cookie",
"uc": "cookie",
"aliyun": "refresh_token",
"xunlei": "refresh_token",
"pan123": "cookie",
"cloud189": "cookie",
}
def __init__(self):
self._status: Dict[str, CredentialStatus] = {}
self._token_cache: Dict[str, Dict[str, Any]] = {}
def validate(self, platform: str, config: PlatformConfig) -> bool:
"""
校验凭证有效性
参考 search-ucmao 的 get_and_validate_credential 逻辑
"""
min_len = self.MIN_LENGTH_MAP.get(platform, 20)
if self.CREDENTIAL_TYPE.get(platform) == "refresh_token":
token = config.refresh_token
valid = bool(token and len(token) >= min_len)
else:
cookie = config.cookie
valid = bool(cookie and len(cookie) >= min_len)
# 记录状态
status = self._status.get(platform, CredentialStatus(valid=False, platform=platform))
status.last_check = time.time()
status.checks_count += 1
if not valid:
status.fail_count += 1
status.last_error = f"凭证长度不足 (需要≥{min_len})"
else:
status.valid = True
self._status[platform] = status
return valid
def get_credential(self, platform: str, config: PlatformConfig) -> str:
"""
获取有效凭证
对于Token类型会自动刷新
"""
if not self.validate(platform, config):
return ""
cred_type = self.CREDENTIAL_TYPE.get(platform, "cookie")
if cred_type == "refresh_token":
# 优先使用缓存的access_token
cached = self._token_cache.get(platform, {})
if cached.get("access_token") and cached.get("expires_at", 0) > time.time() + 60:
return cached["access_token"]
return config.refresh_token
else:
return config.cookie
def update_access_token(self, platform: str, access_token: str,
expires_in: int = 3600):
"""更新缓存的access_token"""
self._token_cache[platform] = {
"access_token": access_token,
"expires_at": time.time() + expires_in,
}
def get_status(self, platform: str) -> Optional[CredentialStatus]:
"""获取凭证状态"""
return self._status.get(platform)
def get_all_status(self) -> Dict[str, CredentialStatus]:
"""获取所有平台凭证状态"""
return dict(self._status)
def mark_invalid(self, platform: str, reason: str = ""):
"""标记凭证失效"""
status = self._status.get(platform, CredentialStatus(valid=False, platform=platform))
status.valid = False
status.last_error = reason
status.fail_count += 1
status.last_check = time.time()
self._status[platform] = status
logger.warning(f"[Credential] {platform} marked invalid: {reason}")
-68
View File
@@ -1,68 +0,0 @@
"""
CloudSearch Transfer — 错误码定义 v1.0.0
参考 netdisk Go SDK 的错误码设计 + 各项目实践
"""
from enum import IntEnum
class TransferErrorCode(IntEnum):
"""统一错误码"""
# 通用错误 (40xxx)
URL_INVALID = 40001 # URL格式错误或无法识别平台
NOT_LOGIN = 40002 # 未登录或凭证已失效
CAPACITY_FULL = 40003 # 存储空间容量不足
SHARE_NOT_EXIST = 40004 # 分享不存在或已失效
PASSCODE_WRONG = 40005 # 提取码错误
RESOURCE_EMPTY = 40006 # 资源内容为空或全为广告文件
NETWORK_ERROR = 40007 # 网络请求失败
TIMEOUT = 40008 # 操作超时
NO_CONFIG = 40009 # 该平台未配置凭证
SHARE_LINK_FAIL = 40010 # 分享创建失败
SHARE_LIMIT = 40011 # 今日分享次数过多
DIR_NOT_EXIST = 40012 # 目标存储目录不存在
SENSITIVE_RESOURCE = 40013 # 资源内容违规
# 平台特有错误 (41xxx)
BAIDU_BDSTOKEN_FAIL = 41001 # 百度bdstoken获取失败
ALIYUN_TOKEN_EXPIRED = 41002 # 阿里Token过期
XUNLEI_CAPTCHA_FAIL = 41003 # 迅雷验证码失败
QUARK_LOGIN_REQUIRED = 41004 # 夸克需要重新登录
class TransferError(Exception):
"""转存异常"""
def __init__(self, code: TransferErrorCode, message: str = None,
platform: str = None, details: dict = None):
self.code = code
self.message = message or self._default_message(code)
self.platform = platform
self.details = details or {}
super().__init__(self.message)
@staticmethod
def _default_message(code: TransferErrorCode) -> str:
messages = {
TransferErrorCode.URL_INVALID: "URL格式错误或无法识别平台",
TransferErrorCode.NOT_LOGIN: "未登录或凭证已失效",
TransferErrorCode.CAPACITY_FULL: "存储空间容量不足",
TransferErrorCode.SHARE_NOT_EXIST: "分享不存在或已失效",
TransferErrorCode.PASSCODE_WRONG: "提取码错误",
TransferErrorCode.RESOURCE_EMPTY: "资源内容为空或全为广告文件",
TransferErrorCode.NETWORK_ERROR: "网络请求失败",
TransferErrorCode.TIMEOUT: "操作超时",
TransferErrorCode.NO_CONFIG: "该平台未配置凭证",
TransferErrorCode.SHARE_LINK_FAIL: "分享创建失败",
TransferErrorCode.SHARE_LIMIT: "今日分享次数过多",
TransferErrorCode.DIR_NOT_EXIST: "目标存储目录不存在",
TransferErrorCode.SENSITIVE_RESOURCE: "资源内容违规",
}
return messages.get(code, f"未知错误 (code={code})")
def to_dict(self) -> dict:
return {
"code": self.code.value,
"message": self.message,
"platform": self.platform,
"details": self.details,
}
-68
View File
@@ -1,68 +0,0 @@
"""
Feature Flags 统一管理 v2.1.0
环境变量 + 配置文件双层控制
"""
import os
from typing import Dict
class FeatureFlags:
"""功能开关管理器"""
# 所有功能及其默认值
DEFAULTS: Dict[str, bool] = {
# 核心功能
"quark_pid": True,
"seo": True,
"link_monitor": True,
# 增强功能
"tmdb": True,
"telegram_bot": False,
"subscription": False,
"alist": False,
# 转存平台
"transfer_quark": True,
"transfer_baidu": False,
"transfer_aliyun": False,
"transfer_uc": False,
"transfer_xunlei": False,
"transfer_pan115": False,
"transfer_pan123": False,
"transfer_cloud189": False,
}
def __init__(self):
self._flags: Dict[str, bool] = {}
self._load()
def _load(self):
for key, default in self.DEFAULTS.items():
env_key = f"FEATURE_{key.upper()}"
val = os.getenv(env_key, str(default)).lower()
self._flags[key] = val in ("true", "1", "yes", "on")
def is_enabled(self, feature: str) -> bool:
return self._flags.get(feature, False)
def enable(self, feature: str):
self._flags[feature] = True
def disable(self, feature: str):
self._flags[feature] = False
def list_all(self) -> Dict[str, bool]:
return dict(self._flags)
def get_enabled_platforms(self) -> list:
return [
k.replace("transfer_", "")
for k, v in self._flags.items()
if k.startswith("transfer_") and v
]
# 全局单例
features = FeatureFlags()
@@ -1 +0,0 @@
"""CloudSearch Transfer — 编排包"""
@@ -1,214 +0,0 @@
"""
CloudSearch Transfer — 转存编排器 v1.0.0
参考 search-ucmao 的 pan_operator.create_share + cloud-auto-save 的任务调度
"""
import time
import logging
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from typing import Optional, List, Dict, Any, Callable
from ..adapter.base import TransferResult, VerifyResult, BaseCloudDriveAdapter
from ..adapter.factory import AdapterFactory
from ..config import ConfigManager
from ..credential.manager import CredentialManager
from ..errors import TransferError, TransferErrorCode
logger = logging.getLogger(__name__)
@dataclass
class TransferTask:
"""转存任务"""
task_id: str
share_url: str
platform: str = ""
status: str = "pending" # pending/running/completed/failed
result: Optional[TransferResult] = None
error: Optional[str] = None
created_at: float = field(default_factory=time.time)
completed_at: Optional[float] = None
callback: Optional[Callable] = None
class TransferOrchestrator:
"""
转存编排器
- 统一入口:接受分享链接 → 自动识别平台 → 转存
- 并发控制:ThreadPoolExecutor
- 任务追踪:内存队列 + 回调通知
- 凭证健康检测
- 重试机制
"""
def __init__(self, config_manager: ConfigManager = None):
self.config = config_manager or ConfigManager()
self.credential_mgr = CredentialManager()
self.factory = AdapterFactory(self.config)
self._executor = ThreadPoolExecutor(
max_workers=self.config.transfer.max_concurrent_transfers,
thread_name_prefix="transfer-",
)
self._tasks: Dict[str, TransferTask] = {}
self._task_lock = threading.Lock()
self._seq = 0
def transfer(self, share_url: str, save_dir: str = "",
share_password: str = "",
callback: Callable = None) -> TransferResult:
"""
转存单个分享链接(同步)
Args:
share_url: 分享链接
save_dir: 目标目录
share_password: 新分享密码
callback: 完成回调 callback(TransferResult)
Returns:
TransferResult
"""
start = time.time()
try:
adapter = self.factory.get_adapter_for_url(share_url)
if not adapter:
raise TransferError(TransferErrorCode.URL_INVALID)
result = adapter.transfer(
share_url=share_url,
save_dir=save_dir,
share_password=share_password,
)
if callback:
callback(result)
return result
except TransferError:
raise
except Exception as e:
logger.exception(f"Transfer failed: {share_url}")
raise TransferError(TransferErrorCode.NETWORK_ERROR, message=str(e))
def transfer_async(self, share_url: str, save_dir: str = "",
share_password: str = "",
callback: Callable = None) -> str:
"""
异步转存 → 返回task_id
Returns:
task_id (str)
"""
with self._task_lock:
self._seq += 1
task_id = f"transfer_{int(time.time())}_{self._seq}"
task = TransferTask(
task_id=task_id,
share_url=share_url,
status="pending",
callback=callback,
)
self._tasks[task_id] = task
future = self._executor.submit(
self._run_transfer, task, save_dir, share_password
)
future.add_done_callback(lambda f: self._on_task_done(task, f))
return task_id
def _run_transfer(self, task: TransferTask, save_dir: str, share_password: str):
"""在线程池中执行转存"""
with self._task_lock:
task.status = "running"
try:
result = self.transfer(task.share_url, save_dir, share_password)
with self._task_lock:
task.result = result
task.status = "completed"
task.completed_at = time.time()
except TransferError as e:
with self._task_lock:
task.error = str(e)
task.status = "failed"
task.completed_at = time.time()
raise
def _on_task_done(self, task: TransferTask, future):
"""任务完成回调"""
try:
future.result() # 触发异常传播
except Exception:
pass
if task.callback:
try:
task.callback(task.result)
except Exception:
logger.exception("Callback error")
def verify(self, share_url: str) -> VerifyResult:
"""验证分享链接有效性"""
try:
adapter = self.factory.get_adapter_for_url(share_url)
return adapter.verify(share_url)
except TransferError as e:
return VerifyResult(valid=False, platform="", error=e)
def get_task(self, task_id: str) -> Optional[TransferTask]:
"""获取任务状态"""
return self._tasks.get(task_id)
def list_tasks(self, status: str = None, limit: int = 50) -> List[TransferTask]:
"""列出任务"""
tasks = list(self._tasks.values())
if status:
tasks = [t for t in tasks if t.status == status]
tasks.sort(key=lambda t: t.created_at, reverse=True)
return tasks[:limit]
def get_stats(self) -> Dict[str, Any]:
"""获取统计信息"""
enabled = self.config.get_enabled_platforms()
credentials = {}
for p in enabled:
status = self.credential_mgr.get_status(p)
credentials[p] = {
"valid": status.valid if status else False,
"last_check": status.last_check if status else 0,
"fail_count": status.fail_count if status else 0,
} if status else {}
tasks = self._tasks.values()
return {
"enabled_platforms": enabled,
"credentials": credentials,
"total_tasks": len(tasks),
"pending": sum(1 for t in tasks if t.status == "pending"),
"running": sum(1 for t in tasks if t.status == "running"),
"completed": sum(1 for t in tasks if t.status == "completed"),
"failed": sum(1 for t in tasks if t.status == "failed"),
}
def check_health(self) -> Dict[str, Any]:
"""健康检查"""
results = {}
for platform in self.config.get_enabled_platforms():
try:
adapter = self.factory.get_adapter(platform)
if adapter:
results[platform] = "ok"
else:
results[platform] = "no_adapter"
except Exception as e:
results[platform] = f"error: {e}"
return results
def shutdown(self):
"""关闭编排器"""
self._executor.shutdown(wait=True, cancel_futures=False)
logger.info("TransferOrchestrator shutdown complete")
-2
View File
@@ -1,2 +0,0 @@
flask>=3.0
requests>=2.28
-215
View File
@@ -1,215 +0,0 @@
"""
CloudSearch Transfer — HTTP API 服务 v1.0.0
以 Flask 微服务形式运行,与 CloudSearch 主应用通过 HTTP 通信
"""
import os
import uuid
import logging
from flask import Flask, request, jsonify
from cloudsearch_transfer.config import ConfigManager
from cloudsearch_transfer.orchestration.transfer import TransferOrchestrator
# ─── 初始化 ────────────────────────────────────────────
app = Flask(__name__)
config = ConfigManager()
orchestrator = TransferOrchestrator(config)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger("transfer_api")
# ─── 健康检查 ──────────────────────────────────────────
@app.route("/health", methods=["GET"])
def health():
return jsonify({
"status": "ok",
"version": "1.0.0",
"platforms": orchestrator.get_stats(),
})
# ─── 转存接口 ──────────────────────────────────────────
@app.route("/api/transfer", methods=["POST"])
def transfer():
"""转存分享链接"""
data = request.get_json() or {}
share_url = data.get("share_url", "").strip()
if not share_url:
return jsonify({"error": "share_url is required"}), 400
save_dir = data.get("save_dir", "")
share_password = data.get("share_password", "")
async_mode = data.get("async", False)
try:
if async_mode:
task_id = orchestrator.transfer_async(share_url, save_dir, share_password)
return jsonify({"task_id": task_id, "status": "pending"})
else:
result = orchestrator.transfer(share_url, save_dir, share_password)
return jsonify({
"success": result.success,
"platform": result.platform,
"new_file_id": result.new_file_id,
"file_name": result.file_name,
"share_url": result.share_url,
"share_password": result.share_password,
"elapsed_ms": result.elapsed_ms,
})
except Exception as e:
logger.exception("Transfer failed")
return jsonify({"error": str(e), "code": getattr(e, "code", 500)}), 500
# ─── 验证接口 ──────────────────────────────────────────
@app.route("/api/verify", methods=["POST"])
def verify():
"""验证分享链接有效性"""
data = request.get_json() or {}
share_url = data.get("share_url", "").strip()
if not share_url:
return jsonify({"error": "share_url is required"}), 400
result = orchestrator.verify(share_url)
return jsonify({
"valid": result.valid,
"platform": result.platform,
"title": result.title,
"file_count": result.file_count,
"files": [{"fid": f.fid, "name": f.name, "size": f.size}
for f in (result.files or [])],
"error": result.error.to_dict() if result.error else None,
})
# ─── 任务查询 ──────────────────────────────────────────
@app.route("/api/task/<task_id>", methods=["GET"])
def get_task(task_id):
"""查询异步任务状态"""
task = orchestrator.get_task(task_id)
if not task:
return jsonify({"error": "task not found"}), 404
result = {
"task_id": task.task_id,
"status": task.status,
"share_url": task.share_url,
"platform": task.platform,
"created_at": task.created_at,
"completed_at": task.completed_at,
}
if task.result:
result["result"] = {
"success": task.result.success,
"share_url": task.result.share_url,
"file_name": task.result.file_name,
"elapsed_ms": task.result.elapsed_ms,
}
if task.error:
result["error"] = task.error
return jsonify(result)
@app.route("/api/tasks", methods=["GET"])
def list_tasks():
"""列出任务"""
status = request.args.get("status")
limit = int(request.args.get("limit", 50))
tasks = orchestrator.list_tasks(status=status, limit=limit)
return jsonify({
"tasks": [
{
"task_id": t.task_id,
"status": t.status,
"share_url": t.share_url[:80],
"platform": t.platform,
"created_at": t.created_at,
}
for t in tasks
],
"total": len(tasks),
})
# ─── 统计 ──────────────────────────────────────────────
@app.route("/api/stats", methods=["GET"])
def stats():
"""获取统计信息"""
return jsonify(orchestrator.get_stats())
def require_config_auth():
token = os.getenv("TRANSFER_API_TOKEN", "")
if not token:
return jsonify({"error": "TRANSFER_API_TOKEN is required"}), 401
supplied = request.headers.get("X-Transfer-Token") or request.headers.get("Authorization", "")
if supplied.startswith("Bearer "):
supplied = supplied[7:]
if supplied != token:
return jsonify({"error": "unauthorized"}), 401
return None
# ─── 配置管理 ──────────────────────────────────────────
@app.route("/api/config/platforms", methods=["GET"])
def get_platforms():
"""获取平台配置列表"""
platforms = {}
for name, cfg in config.platforms.items():
platforms[name] = {
"enabled": cfg.enabled,
"account_name": cfg.account_name,
"save_dir": cfg.save_dir,
"has_cookie": bool(cfg.cookie),
"has_refresh_token": bool(cfg.refresh_token),
}
return jsonify({"platforms": platforms})
@app.route("/api/config/platforms/<name>", methods=["PUT"])
def update_platform(name):
"""更新平台配置"""
auth_error = require_config_auth()
if auth_error:
return auth_error
data = request.get_json() or {}
if name not in config.platforms:
from cloudsearch_transfer.config import PlatformConfig
config.platforms[name] = PlatformConfig()
cfg = config.platforms[name]
if "enabled" in data:
cfg.enabled = bool(data["enabled"])
if "cookie" in data:
cfg.cookie = data["cookie"]
if "refresh_token" in data:
cfg.refresh_token = data["refresh_token"]
if "save_dir" in data:
cfg.save_dir = data["save_dir"]
if "share_password" in data:
cfg.share_password = data["share_password"]
config.save()
orchestrator.factory.invalidate_cache(name)
return jsonify({"status": "ok", "platform": name})
# ─── 启动 ──────────────────────────────────────────────
if __name__ == "__main__":
port = int(os.getenv("PORT", 9528))
debug = os.getenv("FLASK_DEBUG", "0") == "1"
logger.info(f"Starting transfer service on port {port}")
app.run(host="0.0.0.0", port=port, debug=debug)
@@ -1,198 +0,0 @@
import unittest
from unittest.mock import patch
from cloudsearch_transfer.adapter.aliyun import AliyunAdapter
from cloudsearch_transfer.adapter.baidu import BaiduAdapter
from cloudsearch_transfer.adapter.xunlei import XunleiAdapter
from cloudsearch_transfer.config import PlatformConfig, TransferConfig
from cloudsearch_transfer.adapter.base import FileInfo
class DummyCredential:
cookie = "k=xxxxxxxx"
refresh_token = "rt"
def validate(self):
return True
def get_bdstoken(self):
return "bdstoken"
def get_drive_id(self):
return "drive-id"
def get_headers(self):
return {}
def get_auth_headers(self):
return {}
def make_config(**kwargs):
data = dict(enabled=True, cookie="k=" + "x" * 80, refresh_token="rt", account_name="test", save_dir="/")
data.update(kwargs)
return PlatformConfig(**data)
def make_transfer_config():
return TransferConfig(request_timeout=1, max_retries=0, ad_filter_enabled=False, task_poll_interval=0, task_poll_max_attempts=2)
class P1DriveApiCapabilityTests(unittest.TestCase):
def make_aliyun(self):
with patch("cloudsearch_transfer.adapter.aliyun.AliyunCredentialManager", return_value=DummyCredential()):
return AliyunAdapter(make_config(), make_transfer_config())
def make_baidu(self):
with patch("cloudsearch_transfer.adapter.baidu.BaiduCredentialManager", return_value=DummyCredential()):
return BaiduAdapter(make_config(), make_transfer_config())
def make_xunlei(self):
with patch("cloudsearch_transfer.adapter.xunlei.XunleiCredentialManager", return_value=DummyCredential()):
return XunleiAdapter(make_config(), make_transfer_config())
def test_p1_adapters_declare_callable_core_drive_api_methods(self):
expectations = {
"aliyun": (self.make_aliyun(), ["ensure_dir", "save_files", "rename", "move_files", "delete_files"]),
"baidu": (self.make_baidu(), ["ensure_dir", "save_files", "rename", "move_files", "delete_files"]),
"xunlei": (self.make_xunlei(), ["ensure_dir", "save_files", "poll_task", "rename", "move_files"]),
}
for platform, (adapter, required) in expectations.items():
with self.subTest(platform=platform):
for capability in required:
self.assertTrue(adapter.capabilities.get(capability), f"{platform}.{capability} capability not declared")
for method in ["ensure_dir", "mkdir", "rename", "move_files"]:
self.assertNotEqual(getattr(type(adapter), method), getattr(adapter.__class__.__mro__[1], method, None), f"{platform}.{method} not overridden")
if adapter.capabilities.get("delete_files"):
self.assertNotEqual(getattr(type(adapter), "delete_files"), getattr(adapter.__class__.__mro__[1], "delete_files", None), f"{platform}.delete_files not overridden")
def test_aliyun_save_files_resolves_path_to_file_id_before_copy(self):
adapter = self.make_aliyun()
adapter.ensure_dir = lambda path: "target-folder-id"
captured = {}
transfer = adapter._get_transfer()
def fake_batch_copy(share_id, share_token, file_ids, to_parent):
captured["to_parent"] = to_parent
return ["new-file"]
transfer._batch_copy = fake_batch_copy
detail = {"share_token": "share-token", "files": [{"file_id": "src-file"}]}
self.assertEqual(adapter._save_files("share-id", detail, "/Media"), ["new-file"])
self.assertEqual(captured["to_parent"], "target-folder-id")
def test_aliyun_ensure_dir_creates_nested_paths_progressively(self):
adapter = self.make_aliyun()
existing = {"/A": "fid-A"}
created = []
adapter.get_fids = lambda paths: [{"file_path": p, "fid": existing[p]} for p in paths if p in existing]
def fake_mkdir(path):
created.append(path)
existing[path] = "fid-" + path.rsplit("/", 1)[-1]
return {"code": 0, "data": {"fid": existing[path]}}
adapter.mkdir = fake_mkdir
self.assertEqual(adapter.ensure_dir("/A/B"), "fid-B")
self.assertEqual(created, ["/A/B"])
def test_baidu_save_files_ensures_path_before_transfer_and_matching(self):
adapter = self.make_baidu()
adapter.ensure_dir = lambda path: "/CloudSearch/Media"
captured = {}
adapter.credential.get_bdstoken = lambda: "bdstoken"
def fake_transfer_files(shareid, uk, fs_ids, save_dir, token):
captured["transfer_dir"] = save_dir
adapter._transfer._transfer_files = fake_transfer_files
def fake_list_and_match(save_dir, filenames, token):
captured["list_dir"] = save_dir
return ["1001"]
adapter._transfer._list_and_match = fake_list_and_match
detail = {"shareid": "sid", "uk": "uk", "fs_ids": ["old"], "filenames": ["demo.mkv"]}
self.assertEqual(adapter._save_files("pwd", detail, "/CloudSearch/Media"), ["1001"])
self.assertEqual(captured["transfer_dir"], "/CloudSearch/Media")
self.assertEqual(captured["list_dir"], "/CloudSearch/Media")
def test_baidu_ensure_dir_creates_nested_paths_progressively(self):
adapter = self.make_baidu()
dirs = {"/A"}
created = []
adapter.get_files = lambda parent="/": [FileInfo(fid=parent.rstrip("/") + "/A", name="A", is_dir=True)] if parent == "/" else []
def fake_mkdir(path):
created.append(path)
dirs.add(path)
return {"code": 0, "data": {"path": path}}
adapter.mkdir = fake_mkdir
self.assertEqual(adapter.ensure_dir("/A/B"), "/A/B")
self.assertEqual(created, ["/A/B"])
def test_xunlei_save_files_resolves_path_and_polls_restore_task(self):
adapter = self.make_xunlei()
adapter.ensure_dir = lambda path: "target-parent-id"
transfer = adapter._transfer
transfer._restore_files = lambda share_id, token, fids, parent_id="": "task-1" if parent_id == "target-parent-id" else (_ for _ in ()).throw(AssertionError(parent_id))
transfer._poll_restore_task = lambda task_id: {"old-1": "new-1"}
detail = {"share_id": "share", "pass_code_token": "pct", "files": [{"id": "old-1", "name": "demo"}]}
self.assertEqual(adapter._save_files("share", detail, "/Media"), ["new-1"])
def test_xunlei_query_task_delegates_to_restore_poll(self):
adapter = self.make_xunlei()
adapter._transfer._poll_restore_task = lambda task_id: {"old": "new"}
self.assertEqual(adapter.query_task("task-1"), {"code": 0, "status": 200, "data": {"old": "new"}})
def test_xunlei_does_not_advertise_permanent_delete_as_drive_delete_capability(self):
adapter = self.make_xunlei()
self.assertFalse(adapter.capabilities.get("delete_files"), "迅雷 batchDelete is permanent and must not be advertised as generic delete_files")
def test_xunlei_get_share_detail_passes_extraction_code_to_transfer_engine(self):
adapter = self.make_xunlei()
captured = {}
def fake_get_share_info(share_id, passcode=""):
captured["share_id"] = share_id
captured["passcode"] = passcode
return {"title": "demo", "files": [{"id": "old"}], "pass_code_token": "pct"}
adapter._transfer._get_share_info = fake_get_share_info
detail = adapter._get_share_detail("share-id", "abcd")
self.assertEqual(detail["title"], "demo")
self.assertEqual(captured, {"share_id": "share-id", "passcode": "abcd"})
def test_xunlei_create_share_forwards_requested_share_password(self):
adapter = self.make_xunlei()
captured = {}
def fake_create_share(file_ids, password=""):
captured["file_ids"] = file_ids
captured["password"] = password
return ("https://pan.xunlei.com/s/new", password)
adapter._transfer._create_share = fake_create_share
self.assertEqual(adapter._create_share(["fid-1"], "demo", "xy12"), ("https://pan.xunlei.com/s/new", "xy12"))
self.assertEqual(captured, {"file_ids": ["fid-1"], "password": "xy12"})
def test_xunlei_poll_task_alias_matches_declared_capability(self):
adapter = self.make_xunlei()
adapter._transfer._poll_restore_task = lambda task_id: {"old": "new"}
self.assertEqual(adapter.poll_task("task-1"), {"code": 0, "status": 200, "data": {"old": "new"}})
def test_xunlei_get_fids_is_read_only_and_does_not_create_missing_paths(self):
adapter = self.make_xunlei()
calls = []
def fake_get_files(parent="0"):
calls.append(("get_files", parent))
if parent in ("0", ""):
return [FileInfo(fid="fid-A", name="A", is_dir=True)]
if parent == "fid-A":
return [FileInfo(fid="fid-B", name="B", is_dir=False)]
return []
def fail_mkdir(*args, **kwargs):
raise AssertionError("get_fids must not create directories")
adapter.get_files = fake_get_files
adapter.mkdir = fail_mkdir
self.assertEqual(adapter.get_fids(["/A/B", "/A/Missing"]), [{"file_path": "/A/B", "fid": "fid-B"}])
self.assertEqual(calls, [("get_files", "0"), ("get_files", "fid-A"), ("get_files", "0"), ("get_files", "fid-A")])
def test_baidu_get_fids_checks_existing_parent_listing(self):
adapter = self.make_baidu()
calls = []
def fake_get_files(parent="/"):
calls.append(parent)
if parent == "/A":
return [FileInfo(fid="1001", name="movie.mkv", is_dir=False)]
return []
adapter.get_files = fake_get_files
self.assertEqual(adapter.get_fids(["/A/movie.mkv", "/A/missing.mkv"]), [{"file_path": "/A/movie.mkv", "fid": "1001", "path": "/A/movie.mkv"}])
self.assertEqual(calls, ["/A", "/A"])
if __name__ == "__main__":
unittest.main()
@@ -1,308 +0,0 @@
import unittest
import requests
from unittest.mock import patch
from cloudsearch_transfer.adapter.quark import QuarkAdapter
from cloudsearch_transfer.adapter.uc import UcAdapter
from cloudsearch_transfer.config import PlatformConfig, TransferConfig
from cloudsearch_transfer.adapter.base import BaseCloudDriveAdapter
from cloudsearch_transfer.errors import TransferError
class DummyResponse:
def __init__(self, payload):
self._payload = payload
def json(self):
return self._payload
def raise_for_status(self):
return None
def make_adapter(adapter_cls):
return adapter_cls(
PlatformConfig(enabled=True, cookie="k=" + "x" * 80, account_name="test"),
TransferConfig(request_timeout=1, max_retries=0, ad_filter_enabled=False),
)
class QuarkUcCapabilityTests(unittest.TestCase):
def test_quark_declares_p0_drive_api_capabilities(self):
adapter = make_adapter(QuarkAdapter)
self.assertTrue(adapter.capabilities["ensure_dir"])
self.assertTrue(adapter.capabilities["save_files"])
self.assertTrue(adapter.capabilities["poll_task"])
self.assertTrue(adapter.capabilities["rename"])
self.assertTrue(adapter.capabilities["move_files"])
self.assertTrue(adapter.capabilities["delete_files"])
self.assertTrue(adapter.capabilities["cleanup_recycle"])
def test_uc_declares_p0_drive_api_capabilities_and_share_staging(self):
adapter = make_adapter(UcAdapter)
self.assertTrue(adapter.capabilities["ensure_dir"])
self.assertTrue(adapter.capabilities["save_files"])
self.assertTrue(adapter.capabilities["poll_task"])
self.assertTrue(adapter.capabilities["rename"])
self.assertTrue(adapter.capabilities["move_files"])
self.assertTrue(adapter.capabilities["delete_files"])
self.assertTrue(adapter.capabilities["share_staging_folder"])
def test_quark_ensure_dir_uses_path_lookup_before_mkdir(self):
adapter = make_adapter(QuarkAdapter)
mkdir_calls = []
adapter.get_fids = lambda paths: [{"file_path": "/Media", "fid": "fid-media"}]
adapter.mkdir = lambda path: mkdir_calls.append(path) or {"code": 0, "data": {"fid": "new"}}
self.assertEqual(adapter.ensure_dir("/Media"), "fid-media")
self.assertEqual(mkdir_calls, [])
def test_quark_ensure_dir_creates_missing_path(self):
adapter = make_adapter(QuarkAdapter)
adapter.get_fids = lambda paths: []
adapter.mkdir = lambda path: {"code": 0, "data": {"fid": "created-fid"}}
self.assertEqual(adapter.ensure_dir("Media/Shows"), "created-fid")
def test_uc_share_folder_created_when_missing(self):
adapter = make_adapter(UcAdapter)
adapter.ls_dir = lambda parent: {"code": 0, "data": {"list": []}}
adapter.mkdir = lambda path: {"code": 0, "data": {"fid": "share-folder-fid"}}
self.assertEqual(adapter.get_or_create_share_folder(), "share-folder-fid")
def test_uc_move_files_polls_task_until_complete(self):
adapter = make_adapter(UcAdapter)
calls = []
def fake_post(url, json_data=None, params=None, headers=None):
calls.append((url, json_data))
return DummyResponse({"code": 0, "status": 200, "data": {"task_id": "task-1"}})
adapter._post = fake_post
adapter.query_task = lambda task_id: {"code": 0, "status": 200, "data": {"status": 2}}
result = adapter.move_files(["fid-1", "fid-2"], "target-fid")
self.assertEqual(result["code"], 0)
self.assertEqual(calls[0][1]["filelist"], ["fid-1", "fid-2"])
self.assertEqual(calls[0][1]["to_pdir_fid"], "target-fid")
def test_quark_save_files_resolves_path_to_fid(self):
adapter = make_adapter(QuarkAdapter)
adapter.ensure_dir = lambda path: "target-fid"
adapter._transfer_engine._get_stoken = lambda pwd_id: "stoken"
captured = {}
adapter._transfer_engine._init_save = lambda pwd_id, stoken, detail, to_pdir_fid: captured.setdefault("to_pdir_fid", to_pdir_fid) or "task-1"
adapter._transfer_engine._poll_save_task = lambda task_id: ["new-fid"]
self.assertEqual(adapter._save_files("pwd", {"fid": "src"}, "/Media"), ["new-fid"])
self.assertEqual(captured["to_pdir_fid"], "target-fid")
def test_uc_transfer_resolves_path_to_fid(self):
adapter = make_adapter(UcAdapter)
adapter._credential.validate = lambda: True
adapter.ensure_dir = lambda path: "target-fid"
adapter.get_or_create_share_folder = lambda: "target-fid"
adapter._parse_share_url = lambda url: ("pwd", "pass")
adapter._transfer_engine._get_stoken = lambda pwd_id, passcode="": "stoken"
adapter._transfer_engine._get_detail = lambda pwd_id, stoken: {"title": "demo", "fid": "src"}
captured = {}
def fake_init_save(pwd_id, stoken, detail, to_pdir_fid):
captured["save_dir"] = to_pdir_fid
return "save-task"
adapter._transfer_engine._init_save = fake_init_save
adapter._transfer_engine._poll_save_task = lambda task_id: ["new-fid"]
adapter._transfer_engine._init_share = lambda fids, title: "share-task"
adapter._transfer_engine._poll_share_task = lambda task_id: "share-id"
adapter._transfer_engine._set_password = lambda share_id, password: ("https://drive.uc.cn/s/new", password)
result = adapter.transfer("https://drive.uc.cn/s/abcdef", save_dir="/Media", share_password="pw")
self.assertTrue(result.success)
self.assertEqual(captured["save_dir"], "target-fid")
def test_quark_transfer_resolves_path_to_fid(self):
adapter = make_adapter(QuarkAdapter)
adapter.ensure_dir = lambda path: "target-fid"
adapter._credential.validate = lambda: True
captured = {}
def fake_transfer(share_url, save_dir, share_password):
captured["save_dir"] = save_dir
return {
"new_file_ids": ["new-fid"],
"file_name": "demo",
"share_url": "https://pan.quark.cn/s/new",
"passcode": share_password,
}
adapter._transfer_engine.transfer = fake_transfer
result = adapter._transfer("https://pan.quark.cn/s/abcdef", save_dir="/Media", share_password="pw")
self.assertTrue(result.success)
self.assertEqual(captured["save_dir"], "target-fid")
def test_ensure_dir_creates_nested_paths_progressively(self):
adapter = make_adapter(QuarkAdapter)
adapter.get_fids = lambda paths: []
created = []
def fake_mkdir(path):
created.append(path)
return {"code": 0, "data": {"fid": "fid-" + path.rsplit("/", 1)[-1]}}
adapter.mkdir = fake_mkdir
self.assertEqual(adapter.ensure_dir("/A/B"), "fid-B")
self.assertEqual(created, ["/A", "/A/B"])
def test_uc_transfer_uses_staging_folder_then_moves_to_target(self):
adapter = make_adapter(UcAdapter)
adapter._credential.validate = lambda: True
adapter.ensure_dir = lambda path: "target-fid"
adapter.get_or_create_share_folder = lambda: "staging-fid"
adapter._parse_share_url = lambda url: ("pwd", "pass")
adapter._transfer_engine._get_stoken = lambda pwd_id, passcode="": "stoken"
adapter._transfer_engine._get_detail = lambda pwd_id, stoken: {"title": "demo", "fid": "src"}
captured = {}
def fake_init_save(pwd_id, stoken, detail, to_pdir_fid):
captured["save_to"] = to_pdir_fid
return "save-task"
adapter._transfer_engine._init_save = fake_init_save
adapter._transfer_engine._poll_save_task = lambda task_id: ["new-fid"]
def fake_move(fids, to_pdir_fid):
captured["move_to"] = to_pdir_fid
return {"code": 0, "status": 200}
adapter.move_files = fake_move
adapter._transfer_engine._init_share = lambda fids, title: "share-task"
adapter._transfer_engine._poll_share_task = lambda task_id: "share-id"
adapter._transfer_engine._set_password = lambda share_id, password: ("https://drive.uc.cn/s/new", password)
result = adapter.transfer("https://drive.uc.cn/s/abcdef", save_dir="/Media", share_password="pw")
self.assertTrue(result.success)
self.assertEqual(captured["save_to"], "staging-fid")
self.assertEqual(captured["move_to"], "target-fid")
def test_api_success_requires_explicit_success_code_or_status(self):
adapter = make_adapter(QuarkAdapter)
self.assertFalse(adapter._api_success({}))
self.assertFalse(adapter._api_success({"message": "bad"}))
self.assertTrue(adapter._api_success({"code": 0}))
self.assertTrue(adapter._api_success({"status": 200}))
def test_query_task_passes_retry_index_and_timestamp_params(self):
adapter = make_adapter(QuarkAdapter)
captured_params = []
attempts = {"count": 0}
def fake_get(url, params=None, retry=None):
captured_params.append(dict(params))
attempts["count"] += 1
if attempts["count"] == 1:
return DummyResponse({"data": {"status": 1}})
return DummyResponse({"data": {"status": 2, "task_id": "task-1"}})
adapter._get = fake_get
result = adapter.query_task("task-1")
self.assertEqual(result["code"], 0)
self.assertEqual(captured_params[0]["retry_index"], 0)
self.assertEqual(captured_params[1]["retry_index"], 1)
self.assertIn("__dt", captured_params[0])
self.assertIn("__t", captured_params[0])
def test_quark_cleanup_recycle_only_removes_matching_fids(self):
adapter = make_adapter(QuarkAdapter)
adapter.recycle_list = lambda: [
{"fid": "keep", "record_id": "r-keep"},
{"fid": "target", "record_id": "r-target"},
]
captured = {}
def fake_recycle_remove(records):
captured["records"] = records
return {"code": 0}
adapter.recycle_remove = fake_recycle_remove
result = adapter.cleanup_recycle(["target"])
self.assertEqual(result["code"], 0)
self.assertEqual(captured["records"], [{"fid": "target", "record_id": "r-target"}])
def test_drive_api_json_raises_transfer_error_on_http_error(self):
adapter = make_adapter(QuarkAdapter)
class ErrorResponse(DummyResponse):
status_code = 429
text = "too many requests"
def raise_for_status(self):
raise requests.HTTPError("429 Too Many Requests")
with self.assertRaises(Exception) as ctx:
adapter._drive_api_json(ErrorResponse({"code": 0}), context="限流测试")
self.assertIn("限流测试", str(ctx.exception))
def test_drive_api_json_raises_transfer_error_on_invalid_json(self):
adapter = make_adapter(QuarkAdapter)
class InvalidJsonResponse(DummyResponse):
status_code = 200
text = "<html>bad gateway</html>"
def json(self):
raise ValueError("not json")
with self.assertRaises(Exception) as ctx:
adapter._drive_api_json(InvalidJsonResponse({}), context="JSON测试")
self.assertIn("JSON测试", str(ctx.exception))
def test_quark_delete_files_capability_calls_drive_delete(self):
adapter = make_adapter(QuarkAdapter)
calls = []
adapter.delete = lambda fids: calls.append(list(fids)) or True
self.assertEqual(adapter.delete_files(["fid-1", "fid-2"]), {"code": 0, "status": 200})
self.assertEqual(calls, [["fid-1", "fid-2"]])
def test_uc_delete_files_capability_calls_drive_delete(self):
adapter = make_adapter(UcAdapter)
calls = []
adapter.delete = lambda fids: calls.append(list(fids)) or True
self.assertEqual(adapter.delete_files(["fid-1"]), {"code": 0, "status": 200})
self.assertEqual(calls, [["fid-1"]])
def test_uc_transfer_filters_ads_after_staging_save(self):
adapter = make_adapter(UcAdapter)
adapter.ensure_dir = lambda path: "target-dir"
adapter.get_or_create_share_folder = lambda: "staging-dir"
adapter._parse_share_url = lambda url: ("pwd-id", "")
adapter._filter_ads = lambda fids: [fid for fid in fids if fid != "ad-fid"]
adapter.transfer_config.ad_filter_enabled = True
adapter._transfer_engine._get_stoken = lambda pwd_id, passcode: "stoken"
adapter._transfer_engine._get_detail = lambda pwd_id, stoken: {"title": "Title"}
adapter._transfer_engine._init_save = lambda pwd_id, stoken, detail, to_pdir_fid: "save-task"
adapter._transfer_engine._poll_save_task = lambda task_id: ["keep-fid", "ad-fid"]
adapter.move_files = lambda fids, target: {"code": 0}
adapter._transfer_engine._init_share = lambda fids, title: "share-task" if fids == ["keep-fid"] else (_ for _ in ()).throw(AssertionError(f"unfiltered fids: {fids}"))
adapter._transfer_engine._poll_share_task = lambda task_id: "share-id"
adapter._transfer_engine._set_password = lambda share_id, pwd: ("https://share", pwd)
result = adapter.transfer("https://drive.uc.cn/s/abc")
self.assertEqual(result.new_file_id, "keep-fid")
def test_uc_ls_dir_wraps_http_and_json_errors(self):
adapter = make_adapter(UcAdapter)
class BadResponse:
text = "<html>bad gateway</html>"
def raise_for_status(self):
raise requests.HTTPError("HTTP 502")
def json(self):
raise AssertionError("json() should not be called after HTTP error")
adapter._get = lambda *args, **kwargs: BadResponse()
with self.assertRaises(TransferError):
adapter.ls_dir("0")
def test_base_declares_optional_drive_api_methods(self):
required = [
"ensure_dir", "get_fids", "mkdir", "rename", "move_files",
"delete_files", "query_task", "cleanup_recycle",
]
for name in required:
self.assertTrue(hasattr(BaseCloudDriveAdapter, name), name)
def test_base_optional_drive_api_methods_raise_transfer_error(self):
class MinimalAdapter(BaseCloudDriveAdapter):
PLATFORM_KEY = "minimal"
def verify(self, share_url):
raise NotImplementedError
def _get_share_detail(self, pwd_id, passcode=""):
raise NotImplementedError
def _save_files(self, pwd_id, detail, save_dir):
raise NotImplementedError
def _create_share(self, file_ids, title, password=""):
raise NotImplementedError
def get_files(self, parent_fid="0"):
raise NotImplementedError
def delete(self, file_ids):
raise NotImplementedError
adapter = MinimalAdapter(PlatformConfig(), TransferConfig())
with self.assertRaises(TransferError):
adapter.ensure_dir("/Media")
with self.assertRaises(TransferError):
adapter.move_files(["fid"], "target")
if __name__ == "__main__":
unittest.main()
@@ -1,135 +0,0 @@
import subprocess
import unittest
from pathlib import Path
class TransferServerRuntimeTests(unittest.TestCase):
def test_transfer_image_imports_server_module_from_app_workdir(self):
result = subprocess.run(
[
"docker", "run", "--rm",
"-e", "TRANSFER_CONFIG_PATH=/tmp/transfer_config.json",
"cloudsearch-transfer:test",
"python", "-c",
"import cloudsearch_transfer.server as s; print(hasattr(s, 'app'))",
],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=20,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("True", result.stdout)
def test_update_platform_creates_config_with_package_import(self):
result = subprocess.run(
[
"docker", "run", "--rm",
"-e", "TRANSFER_CONFIG_PATH=/tmp/transfer_config_server_test.json",
"cloudsearch-transfer:test",
"python", "-c",
"""
import os
os.environ['TRANSFER_API_TOKEN'] = 'test-token'
from cloudsearch_transfer.server import app
client = app.test_client()
resp = client.put('/api/config/platforms/newplatform', headers={'X-Transfer-Token': 'test-token'}, json={'enabled': True, 'save_dir': '/Media'})
print(resp.status_code)
print(resp.get_data(as_text=True))
""",
],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=20,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("200", result.stdout)
self.assertIn("newplatform", result.stdout)
def test_dockerfile_healthcheck_uses_http_health_probe(self):
dockerfile = Path(__file__).resolve().parents[1] / "Dockerfile"
content = dockerfile.read_text()
healthcheck_block = content.split("HEALTHCHECK", 1)[1].split("\n\nCMD", 1)[0]
self.assertIn("/health", healthcheck_block)
self.assertNotIn('python -m cloudsearch_transfer.server', healthcheck_block)
def test_config_update_requires_api_token_when_configured(self):
script = """
import os
os.environ['TRANSFER_API_TOKEN'] = 'secret-token'
os.environ['TRANSFER_CONFIG_PATH'] = '/tmp/transfer_config_auth_test.json'
from cloudsearch_transfer.server import app
client = app.test_client()
unauth = client.put('/api/config/platforms/newplatform', json={'enabled': True})
auth = client.put('/api/config/platforms/newplatform', headers={'X-Transfer-Token': 'secret-token'}, json={'enabled': True, 'save_dir': '/Media'})
print(unauth.status_code)
print(auth.status_code)
"""
result = subprocess.run(
[
"docker", "run", "--rm",
"-e", "TRANSFER_CONFIG_PATH=/tmp/transfer_config_auth_test.json",
"cloudsearch-transfer:test",
"python", "-c", script,
],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=20,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("401", result.stdout)
self.assertIn("200", result.stdout)
def test_config_update_requires_token_by_default(self):
script = """
import os
os.environ.pop('TRANSFER_API_TOKEN', None)
os.environ['TRANSFER_CONFIG_PATH'] = '/tmp/transfer_config_default_auth_test.json'
from cloudsearch_transfer.server import app
client = app.test_client()
resp = client.put('/api/config/platforms/newplatform', json={'enabled': True})
print(resp.status_code)
"""
result = subprocess.run(
[
"docker", "run", "--rm",
"-e", "TRANSFER_CONFIG_PATH=/tmp/transfer_config_default_auth_test.json",
"cloudsearch-transfer:test",
"python", "-c", script,
],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=20,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("401", result.stdout)
def test_config_save_persists_full_credentials_for_runtime_reload(self):
script = """
from cloudsearch_transfer.config import ConfigManager, PlatformConfig
path = '/tmp/transfer_config_persist_test.json'
cfg = ConfigManager(path)
cfg.platforms['quark'] = PlatformConfig(enabled=True, cookie='cookie-value-that-is-longer-than-twenty-characters', refresh_token='refresh-token-that-is-longer-than-twenty-characters')
cfg.save()
reloaded = ConfigManager(path)
print(reloaded.platforms['quark'].cookie)
print(reloaded.platforms['quark'].refresh_token)
"""
result = subprocess.run(
["docker", "run", "--rm", "cloudsearch-transfer:test", "python", "-c", script],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=20,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("cookie-value-that-is-longer-than-twenty-characters", result.stdout)
self.assertIn("refresh-token-that-is-longer-than-twenty-characters", result.stdout)
self.assertNotIn("...", result.stdout)
if __name__ == "__main__":
unittest.main()
-37
View File
@@ -1,37 +0,0 @@
# CloudSearch 环境变量 — 统一管理
# 复制此文件为 .env 使用:cp docker-compose.env .env
# ── 核心服务 ──
PORT=9527
NODE_ENV=production
TZ=Asia/Shanghai
# ── 安全 ──
JWT_SECRET=cloudsearch-jwt-prod-v1
ADMIN_USERNAME=admin
ADMIN_PASSWORD=0nL5kLhMIJ1121PYmQb25A
COOKIE_ENCRYPTION_KEY=
CORS_ORIGIN=http://jp-cs.timaa.cn
# ── 数据库 & 缓存 ──
DB_PATH=/data/database.sqlite
REDIS_URL=redis://:redis_GbR7XZ@1Panel-redis-aDp3:6379
# ── 外部服务 ──
PANSOU_URL=http://pansou:80
PANSOU_AUTH_TOKEN=
VIDEO_PARSER_URL=http://localhost:3001
# ── 网盘校验 ──
VALIDATION_CONCURRENCY=10
VALIDATION_TIMEOUT=5000
CACHE_TTL_VALID=14400
CACHE_TTL_INVALID=3600
# ── 路径 ──
CHROMIUM_PATH=/usr/bin/chromium-browser
APP_VERSION_FILE=/data/VERSION
UPLOAD_DIR=/app/uploads
# ── 日志 ──
LOG_LEVEL=info
-153
View File
@@ -1,153 +0,0 @@
# =============================================================================
# CloudSearch · Docker Compose 部署编排
# =============================================================================
#
# 双模式使用:
#
# 开发/测试模式(从源码构建):
# docker compose up -d
#
# 生产模式(使用预构建镜像):
# 1. 注释掉 services.app.build 段
# 2. 取消注释 services.app.image 行
# 3. docker compose up -d
#
# 构建并推送镜像到私有仓库:
# ./scripts/build-and-push.sh v0.3.32
#
# =============================================================================
networks:
cloudsearch-network:
driver: bridge
volumes:
app-data: # 主应用数据(SQLite 数据库、上传文件)
redis-data: # Redis 数据持久化
pansou-data: # PanSou 缓存
# ── 日志轮转模板 ────────────────────────────────────────────────────────────
x-logging: &default-logging
driver: json-file
options:
max-size: "50m"
max-file: "10"
# ── 服务定义 ────────────────────────────────────────────────────────────────
services:
# ═══════════════════════════════════════════════════════════════════════════
# CloudSearch 主应用 — Node.js + Express + TypeScript
# ═══════════════════════════════════════════════════════════════════════════
app:
container_name: CloudSearch_App
# ┌─ 开发模式:从源码构建 ──────────────────────────────────────────┐
build:
context: ./source_clean
dockerfile: Dockerfile
# └────────────────────────────────────────────────────────────────┘
# ┌─ 生产模式:使用预构建镜像(切换时取消注释下行,注释 build 段)───┐
# image: gitea.timxx.cn/admin/cloudsearch:latest
# image: gitea.timxx.cn/admin/cloudsearch:v0.3.32
# └────────────────────────────────────────────────────────────────┘
ports:
- "${PORT:-9527}:9527"
environment:
# ── 核心 ──
- NODE_ENV=${NODE_ENV:-production}
- PORT=${PORT:-9527}
- TZ=${TZ:-Asia/Shanghai}
# ── 安全 ──
- JWT_SECRET=${JWT_SECRET}
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
- COOKIE_ENCRYPTION_KEY=${COOKIE_ENCRYPTION_KEY:-}
- CORS_ORIGIN=${CORS_ORIGIN}
# ── 数据库 & 缓存 ──
- DATA_DIR=/data
- DB_PATH=${DB_PATH:-/data/database.sqlite}
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
# ── 搜索服务 ──
- PANSOU_URL=${PANSOU_URL:-http://pansou:80}
- PANSOU_AUTH_TOKEN=${PANSOU_AUTH_TOKEN:-}
# ── 网盘校验 ──
- VALIDATION_CONCURRENCY=${VALIDATION_CONCURRENCY:-10}
- VALIDATION_TIMEOUT=${VALIDATION_TIMEOUT:-5000}
- CACHE_TTL_VALID=${CACHE_TTL_VALID:-14400}
- CACHE_TTL_INVALID=${CACHE_TTL_INVALID:-3600}
# ── 路径 ──
- CHROMIUM_PATH=${CHROMIUM_PATH:-/usr/bin/chromium-browser}
- APP_VERSION_FILE=${APP_VERSION_FILE:-/data/VERSION}
- UPLOAD_DIR=${UPLOAD_DIR:-/app/uploads}
# ── 日志 ──
- LOG_LEVEL=${LOG_LEVEL:-info}
volumes:
- app-data:/data # 数据库
- ./uploads:/app/uploads # 上传文件
- ./VERSION:/app/VERSION # 版本文件
- ./icons:/app/dist/frontend/admin/icons # 云盘图标
depends_on:
redis:
condition: service_healthy
pansou:
condition: service_started
restart: always
networks:
- cloudsearch-network
logging: *default-logging
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:9527/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
# ═══════════════════════════════════════════════════════════════════════════
# Redis — 缓存 & 会话
# ═══════════════════════════════════════════════════════════════════════════
redis:
container_name: CloudSearch_Redis
image: redis:7-alpine
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redis-data:/data
restart: always
networks:
- cloudsearch-network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3
start_period: 5s
# ═══════════════════════════════════════════════════════════════════════════
# PanSou — 第三方搜索聚合
# ═══════════════════════════════════════════════════════════════════════════
pansou:
container_name: CloudSearch_PanSou
image: ghcr.io/fish2018/pansou-web:latest
networks:
cloudsearch-network:
aliases:
- pansou
environment:
- DOMAIN=${DOMAIN:-localhost}
- CACHE_TTL=60
volumes:
- pansou-data:/app/data
restart: always
logging: *default-logging
-19
View File
@@ -1,19 +0,0 @@
# CloudSearch 测试服部署记录
- 服务器:泽御云香港测试服,SSH 入口 root@82.158.228.152:18924。
- 当前运行来源:`/root/cloudsearch_deploy`
- 主应用目录:`/root/cloudsearch_deploy/source_clean`
- 网盘能力层:`/root/cloudsearch_deploy/cloudsearch_transfer`
- 启动方式:Docker Compose,主容器挂载 `/root/cloudsearch_deploy` 相关目录。
- 集成边界:只集成 cloud-auto-save 中可复用的网盘 API 调用能力;不集成签到、收益事件、收益上报,也不长期部署独立 cloud-auto-save 服务。
- Video Parser 当前未部署,属于测试环境预期缺口,不作为主服务故障处理。
- 本轮隔离点:`backup-before-drive-api-20260522-152138`;开发分支:`feature/cloudsearch-drive-api-20260522-152138`
## 验证命令
```bash
python3 -m unittest discover -s cloudsearch_transfer/tests -p 'test_*.py' -v
python3 /tmp/verify_compile.py
docker compose ps
curl -fsS http://127.0.0.1:3000/health
```
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 381 B

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 467 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 894 B

+4147
View File
File diff suppressed because it is too large Load Diff
Executable
+41
View File
@@ -0,0 +1,41 @@
{
"name": "cloudsearch-backend",
"version": "0.1.1",
"private": true,
"scripts": {
"dev": "tsx watch src/main.ts",
"build": "tsc",
"start": "node dist/main.js",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.0.0",
"cors": "^2.8.5",
"express": "^4.21.0",
"express-rate-limit": "^7.4.0",
"helmet": "^8.0.0",
"https-proxy-agent": "^9.0.0",
"ioredis": "^5.4.0",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"playwright": "^1.52.0",
"sharp": "^0.33.0",
"uuid": "^10.0.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.11",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.6",
"@types/morgan": "^1.9.9",
"@types/multer": "^1.4.12",
"@types/uuid": "^10.0.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0",
"vitest": "^2.1.0"
}
}
+4200
View File
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
{
"name": "cloudsearch-backend",
"version": "0.0.0",
"version": "0.0.3",
"private": true,
"scripts": {
"dev": "tsx watch src/main.ts",
@@ -17,14 +17,14 @@
"express-rate-limit": "^7.4.0",
"helmet": "^8.0.0",
"https-proxy-agent": "^9.0.0",
"socks-proxy-agent": "^9.0.0",
"ioredis": "^5.4.0",
"jsonwebtoken": "^9.0.2",
"jsqr": "^1.4.0",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"playwright": "^1.52.0",
"sharp": "^0.33.0",
"socks-proxy-agent": "^10.0.0",
"uuid": "^10.0.0"
},
"devDependencies": {
@@ -37,7 +37,7 @@
"@types/multer": "^1.4.12",
"@types/uuid": "^10.0.0",
"tsx": "^4.19.0",
"typescript": "^5.9.3",
"typescript": "^5.6.0",
"vitest": "^2.1.0"
}
}
}
@@ -22,18 +22,18 @@ export function getSystemConfig(key: string): string | null {
export function updateSystemConfig(key: string, value: string): void {
const db = getDb();
db.prepare(
"INSERT INTO system_configs (key, value, updated_at, description) VALUES (?, ?, ?, '') ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at"
).run(key, value, localTimestamp());
"UPDATE system_configs SET value = ?, updated_at = ? WHERE key = ?"
).run(value, localTimestamp(), key);
}
export function updateSystemConfigs(entries: { key: string; value: string }[]): void {
const db = getDb();
const upsert = db.prepare(
"INSERT INTO system_configs (key, value, updated_at, description) VALUES (?, ?, ?, '') ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at"
const update = db.prepare(
"UPDATE system_configs SET value = ?, updated_at = ? WHERE key = ?"
);
const tx = db.transaction((items: { key: string; value: string }[]) => {
for (const item of items) {
upsert.run(item.key, item.value, localTimestamp());
update.run(item.value, localTimestamp(), item.key);
}
});
tx(entries);
@@ -1,4 +1,3 @@
import { TMDB_API_HOST, EP as TE } from '../content/tmdb-api';
import { Router, Request, Response } from 'express';
// Native fetch available in Node 20+
import fs from "fs";
@@ -6,21 +5,16 @@ import { execSync } from 'child_process';
import { adminLimiter, loginLimiter } from '../middleware/rate-limit';
import { getSaveRecords } from '../cloud/cloud.service';
import { getCloudConfigs, getCloudConfigById, saveCloudConfig, deleteCloudConfig, getCloudConfigByType, testCloudConnection, testCloudConnectionWithCookie } from '../cloud/credential.service';
import { dailyCheckIn, skipCheckin, getCheckinSummary, getDrivesForCheckin } from '../cloud/checkin.service';
// Note: check-in routes were removed (sign-in feature removed)
import { getAllCloudTypes } from '../cloud/cloud-types.service';
import { login, authMiddleware, verifyToken, changePassword } from '../admin/auth.service';
import { getAllPushUsers, upsertPushUser, updatePushUser, deletePushUser } from '../cloud/push-user.service';
import { getAllNotifierParams, testChannel, saveConfigNotifySettings, getConfigNotifySettingsJSON, getGlobalNotifyConfig } from '../cloud/notification.service';
import { getStats } from '../admin/stats.service';
import { getAllSystemConfigs, updateSystemConfig, updateSystemConfigs, getSystemConfig } from '../admin/system-config.service';
import { testProxyConnection } from '../utils/proxy-agent';
import { getDb } from '../database/database';
import config from '../config';
import { reconnectRedis, testRedisConnection } from '../middleware/cache';
import { startQrLogin, getQrLoginStatus, cancelQrLogin } from '../cloud/qr-login.service';
import { BaiduDriver } from '../cloud/drivers/baidu.driver';
import { testProxyConnection } from '../utils/proxy-agent';
import { mergeRedactedUpdate, redactSensitive } from '../utils/redact';
import { mergeNotifySettingsUpdate, mergePushUserNotifyConfigUpdate, mergeSystemConfigEntriesUpdate } from './admin-config-helpers';
const router = Router();
@@ -149,14 +143,14 @@ router.use('/admin', authMiddleware);
router.get('/admin/cloud-configs', (_req: Request, res: Response) => {
try {
const configs = getCloudConfigs();
res.json(redactSensitive(configs));
res.json(configs);
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to fetch cloud configs' });
}
});
/** POST /api/admin/cloud-configs — create or smart-replace a cloud config */
router.post('/admin/cloud-configs', async (req: Request, res: Response) => {
router.post('/admin/cloud-configs', (req: Request, res: Response) => {
try {
const data = req.body;
if (!data.cloud_type) {
@@ -165,26 +159,10 @@ router.post('/admin/cloud-configs', async (req: Request, res: Response) => {
}
// Normalize is_active: frontend sends boolean, SQLite needs 0/1
if (typeof data.is_active === 'boolean') data.is_active = data.is_active ? 1 : 0;
// Normalize is_transfer_enabled: frontend sends boolean, SQLite needs 0/1
if (typeof data.is_transfer_enabled === 'boolean') data.is_transfer_enabled = data.is_transfer_enabled ? 1 : 0;
const saved = saveCloudConfig(data);
// Auto-validate if cookie was provided (best-effort, non-blocking)
if (data.cookie && saved.id) {
try {
const result = await testCloudConnectionWithCookie(data.cloud_type, data.cookie);
if (result.success) {
const updateData: any = { id: saved.id, cloud_type: data.cloud_type };
if (result.nickname) updateData.nickname = result.nickname;
if (result.storage_used) updateData.storage_used = result.storage_used;
if (result.storage_total) updateData.storage_total = result.storage_total;
saveCloudConfig(updateData);
Object.assign(saved, { nickname: result.nickname, storage_used: result.storage_used, storage_total: result.storage_total });
}
} catch (_) {
// Auto-validation is best-effort, don't fail the save
}
}
res.json(redactSensitive(saved));
res.json(saved);
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to save cloud config' });
}
@@ -199,9 +177,8 @@ router.put('/admin/cloud-configs/:id', (req: Request, res: Response) => {
res.status(404).json({ error: 'Cloud config not found' });
return;
}
const update = mergeRedactedUpdate(req.body, existing);
const saved = saveCloudConfig({ ...update, id });
res.json(redactSensitive(saved));
const saved = saveCloudConfig({ ...req.body, id });
res.json(saved);
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to update cloud config' });
}
@@ -248,85 +225,6 @@ router.post('/admin/cloud-configs/:type/test', async (req: Request, res: Respons
}
});
// ═══════════════════════════════════════
// Daily Check-in
// ═══════════════════════════════════════
/** PUT /api/admin/cloud-configs/:id/primary — set this config as the primary/default account for its cloud type */
router.put('/admin/cloud-configs/:id/primary', (req: Request, res: Response) => {
const id = Number(req.params.id);
const { primary } = req.body;
try {
const db = getDb();
const config = db.prepare('SELECT id, cloud_type FROM cloud_configs WHERE id = ?').get(id) as any;
if (!config) { res.status(404).json({ error: '网盘配置不存在' }); return; }
if (primary) {
// Unset primary for all other configs of the same cloud type
db.prepare('UPDATE cloud_configs SET is_primary = 0 WHERE cloud_type = ?').run(config.cloud_type);
// Set this one as primary
db.prepare('UPDATE cloud_configs SET is_primary = 1 WHERE id = ?').run(id);
} else {
db.prepare('UPDATE cloud_configs SET is_primary = 0 WHERE id = ?').run(id);
}
res.json({ success: true, is_primary: primary ? 1 : 0 });
} catch (err: any) {
res.status(500).json({ error: err.message || '操作失败' });
}
});
/** POST /api/admin/cloud-configs/:id/checkin */
router.post('/admin/cloud-configs/:id/checkin', async (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id as string);
const result = await dailyCheckIn(id);
res.json(result);
} catch (err: any) {
res.status(500).json({ success: false, message: err.message || 'Check-in failed' });
}
});
/** POST /api/admin/cloud-configs/:id/skip-checkin */
router.post('/admin/cloud-configs/:id/skip-checkin', async (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id as string);
const ok = skipCheckin(id);
res.json({ success: ok });
} catch (err: any) {
res.status(500).json({ success: false, message: err.message || 'Skip check-in failed' });
}
});
/** POST /api/admin/cloud-configs/checkin-all */
router.post('/admin/cloud-configs/checkin-all', async (_req: Request, res: Response) => {
try {
const drives = getDrivesForCheckin();
const results: { id: number; nickname: string; success: boolean; message: string }[] = [];
let total = 0;
for (const drive of drives) {
total++;
try {
const result = await dailyCheckIn(drive.id);
results.push({ id: drive.id, nickname: drive.nickname || '', success: result.success, message: result.message });
} catch (err: any) {
results.push({ id: drive.id, nickname: drive.nickname || '', success: false, message: err.message || 'Check-in failed' });
}
}
res.json({ total, results });
} catch (err: any) {
res.status(500).json({ error: err.message || 'Check-in all failed' });
}
});
/** GET /api/admin/cloud-configs/checkin-summary */
router.get('/admin/cloud-configs/checkin-summary', (_req: Request, res: Response) => {
try {
const summary = getCheckinSummary();
res.json(summary);
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to get check-in summary' });
}
});
// ═══════════════════════════════════════
// Stats
// ═══════════════════════════════════════
@@ -371,7 +269,7 @@ router.get('/admin/save-records', (req: Request, res: Response) => {
router.get('/admin/system-configs', (_req: Request, res: Response) => {
try {
const configs = getAllSystemConfigs();
res.json(redactSensitive(configs));
res.json(configs);
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to get system configs' });
}
@@ -385,8 +283,7 @@ router.put('/admin/system-configs', (req: Request, res: Response) => {
res.status(400).json({ error: 'entries array is required' });
return;
}
const mergedEntries = mergeSystemConfigEntriesUpdate(entries, getAllSystemConfigs());
updateSystemConfigs(mergedEntries);
updateSystemConfigs(entries);
res.json({ success: true });
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to update system configs' });
@@ -450,7 +347,7 @@ router.post('/admin/change-password', (req: Request, res: Response) => {
/** GET /api/admin/db-status */
router.get('/admin/db-status', async (_req: Request, res: Response) => {
try {
const dbFile = getSystemConfig('db_path') || config.dbPath || '';
const dbFile = getSystemConfig('db_path') || '';
let dbSize = 'N/A';
if (dbFile) {
try {
@@ -473,9 +370,9 @@ router.get('/admin/db-status', async (_req: Request, res: Response) => {
let redis_url = getSystemConfig('redis_url') || '';
try {
const testResult = await testRedisConnection(redis_url);
redis_status = testResult.ok ? '已连接' : '未连接';
redis_status = testResult.ok ? 'connected' : 'disconnected';
} catch {
redis_status = '错误';
redis_status = 'error';
}
res.json({
@@ -483,7 +380,7 @@ router.get('/admin/db-status', async (_req: Request, res: Response) => {
db_path: dbFile,
...counts,
redis_status,
redis_url: redis_url ? '***REDACTED***' : redis_url,
redis_url,
});
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to get DB status' });
@@ -552,12 +449,12 @@ router.post('/admin/test-external-service', async (req: Request, res: Response)
break;
}
case 'tmdb': {
const tmdbToken = token || getSystemConfig('tmdb_api_token') || '';
const tmdbToken = token || getSystemConfig('tmdb_api_key') || '';
if (!tmdbToken) {
res.json({ ok: false, info: 'TMDB API Key not configured' });
return;
}
const response = await fetch(TMDB_API_HOST + TE.CONFIGURATION, {
const response = await fetch('https://api.themoviedb.org/3/configuration', {
headers: { Authorization: `Bearer ${tmdbToken}` },
signal: AbortSignal.timeout(8000),
});
@@ -570,7 +467,7 @@ router.post('/admin/test-external-service', async (req: Request, res: Response)
break;
}
case 'proxy': {
const proxyUrl = url || getSystemConfig('proxy_url') || '';
const proxyUrl = url || getSystemConfig('search_proxy_url') || '';
if (!proxyUrl) {
res.json({ ok: false, info: 'Proxy URL not configured' });
return;
@@ -580,17 +477,16 @@ router.post('/admin/test-external-service', async (req: Request, res: Response)
break;
}
case 'ip_geo': {
const apiId = url || getSystemConfig('ip_geo_api_id') || '';
const apiKey = getSystemConfig('ip_geo_api_key') || '';
if (!apiId || !apiKey) {
res.json({ ok: false, info: '请先配置 IP 归属地 API ID 和 Key' });
const geoUrl = url || getSystemConfig('ip_geo_api_url') || '';
if (!geoUrl) {
res.json({ ok: false, info: '请先输入 IP 归属地查询 API 地址' });
return;
}
const testUrl = `https://cn.apihz.cn/api/ip/chaapi.php?id=${encodeURIComponent(apiId)}&key=${encodeURIComponent(apiKey)}&ip=8.8.8.8&td=0`;
const testUrl = geoUrl.replace('{ip}', '8.8.8.8');
const response = await fetch(testUrl, { signal: AbortSignal.timeout(8000) });
const data: any = await response.json();
const latency = Date.now() - start;
const valid = data?.code === 200;
const valid = !!(data?.country || data?.region || data?.city || data?.countryCode);
res.json({ ok: valid, latency, info: valid ? '连接成功' : '响应格式不符' });
break;
}
@@ -716,269 +612,4 @@ router.post('/admin/update-pansou', async (_req: Request, res: Response) => {
}
});
// ======================== Notification / Push Users ========================
/** GET /api/admin/cloud-configs/:id/notify */
router.get('/admin/cloud-configs/:id/notify', (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id as string);
const settings = getConfigNotifySettingsJSON(id);
res.json(redactSensitive(settings));
} catch (err: any) {
res.status(400).json({ error: err.message || 'Failed to get notification settings' });
}
});
/** PUT /api/admin/cloud-configs/:id/notify */
router.put('/admin/cloud-configs/:id/notify', (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id as string);
const existing = getConfigNotifySettingsJSON(id);
const settings = mergeNotifySettingsUpdate(req.body, existing);
saveConfigNotifySettings(id, settings);
res.json({ success: true, message: 'Push config saved' });
} catch (err: any) {
res.status(400).json({ error: err.message || 'Failed to save notification settings' });
}
});
/** POST /api/admin/notify/test */
router.post('/admin/notify/test', async (req: Request, res: Response) => {
try {
const { channelType, account, configId, params } = req.body;
const ctx = account || (configId ? String(configId) : undefined);
const result = await testChannel(channelType, ctx, params);
res.json(result);
} catch (err: any) {
res.json({ success: false, message: err.message || 'Test send failed' });
}
});
/** GET /api/admin/notify/providers */
router.get('/admin/notify/providers', (_req: Request, res: Response) => {
try {
const providers = getAllNotifierParams();
res.json(providers);
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to get providers' });
}
});
/** GET /api/admin/notify/global-config */
router.get('/admin/notify/global-config', (_req, res) => {
try {
const cfg = getGlobalNotifyConfig();
res.json(redactSensitive(cfg));
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to get global config' });
}
});
/** PUT /api/admin/notify/global-config */
router.put('/admin/notify/global-config', (req, res) => {
try {
const cfg = req.body;
if (!cfg || typeof cfg !== 'object') {
res.status(400).json({ error: 'Invalid config object' });
return;
}
const existing = getGlobalNotifyConfig();
const merged = mergeNotifySettingsUpdate(cfg, existing);
updateSystemConfig('global_notify_config', JSON.stringify(merged));
res.json({ success: true });
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to save global config' });
}
});
/** GET /api/admin/push-users */
router.get('/admin/push-users', (_req: Request, res: Response) => {
try {
const users = getAllPushUsers();
const parsed = users.map(u => ({
...u,
notify_config: (() => { try { return JSON.parse(u.notify_config); } catch { return {}; } })(),
}));
res.json(redactSensitive(parsed));
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to list push users' });
}
});
/** POST /api/admin/push-users */
router.post('/admin/push-users', (req: Request, res: Response) => {
try {
const { account, notify_config } = req.body;
if (!account) return res.status(400).json({ error: 'account is required' });
const existing = getAllPushUsers().find(u => u.account === account);
const mergedConfig = mergePushUserNotifyConfigUpdate({ notify_config }, existing);
const configStr = JSON.stringify(mergedConfig);
const user = upsertPushUser(account, configStr);
res.json(redactSensitive({ ...user, notify_config: JSON.parse(user!.notify_config) }));
} catch (err: any) {
res.status(400).json({ error: err.message || 'Failed to save push user' });
}
});
/** PUT /api/admin/push-users/:id */
router.put('/admin/push-users/:id', (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id as string);
const { account, notify_config } = req.body;
if (!account) return res.status(400).json({ error: 'account is required' });
const existing = getAllPushUsers().find(u => u.id === id);
const mergedConfig = mergePushUserNotifyConfigUpdate({ notify_config }, existing);
const configStr = JSON.stringify(mergedConfig);
const user = updatePushUser(id, account, configStr);
res.json(redactSensitive({ ...user, notify_config: JSON.parse(user!.notify_config) }));
} catch (err: any) {
res.status(400).json({ error: err.message || 'Failed to update push user' });
}
});
/** DELETE /api/admin/push-users/:id */
router.delete('/admin/push-users/:id', (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id as string);
const ok = deletePushUser(id);
if (ok) res.json({ success: true });
else res.status(404).json({ error: 'Push user not found' });
} catch (err: any) {
res.status(400).json({ error: err.message || 'Failed to delete push user' });
}
});
// ═══════════════════════════════════════════════
// Daily Report
// ═══════════════════════════════════════════════
/** GET /api/admin/daily-report/config */
router.get('/admin/daily-report/config', (_req, res) => {
try {
const { getDailyReportConfig } = require('../services/daily-report.service');
const cfg = getDailyReportConfig();
res.json(redactSensitive(cfg));
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to get daily report config' });
}
});
/** PUT /api/admin/daily-report/config */
router.put('/admin/daily-report/config', (req, res) => {
try {
const { saveDailyReportConfig } = require('../services/daily-report.service');
saveDailyReportConfig(req.body);
const { getDailyReportConfig } = require('../services/daily-report.service');
res.json(redactSensitive(getDailyReportConfig()));
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to save daily report config' });
}
});
/** GET /api/admin/daily-report/preview */
router.get('/admin/daily-report/preview', (req, res) => {
try {
const { previewDailyReport, generateDailyReport } = require('../services/daily-report.service');
const date = req.query.date as string || undefined;
const content = previewDailyReport(date);
const report = generateDailyReport(date);
res.json({ content, report });
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to preview daily report' });
}
});
/** POST /api/admin/daily-report/test — send a test report immediately */
router.post('/admin/daily-report/test', async (_req, res) => {
try {
const { sendTestDailyReport } = require('../services/daily-report.service');
const result = await sendTestDailyReport();
res.json(result);
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to send test report' });
}
});
/** GET /api/admin/daily-report/last-run */
router.get('/admin/daily-report/last-run', (_req, res) => {
try {
const { getSystemConfig } = require('../admin/system-config.service');
const raw = getSystemConfig('daily_report_last_run') || '{}';
let data: any = {};
try { data = JSON.parse(raw); } catch {}
res.json(data);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// ── 推广平台管理 (Promotion Platforms for Registration) ──
/** GET /api/promotion-platforms — public, list all platforms for registration page */
router.get('/promotion-platforms', (_req: Request, res: Response) => {
try {
const db = getDb();
const platforms = db.prepare('SELECT id, name, join_url, sort_order FROM promotion_platforms ORDER BY sort_order, id').all();
const { getSystemConfig } = require('../admin/system-config.service');
const qrTitle = getSystemConfig('promotion_qr_title') || '扫码加入推广团队';
res.json({ title: qrTitle, platforms });
} catch (e: any) { res.status(500).json({ error: e.message }); }
});
/** GET /api/admin/promotion-platforms — admin list */
router.get('/admin/promotion-platforms', (_req: Request, res: Response) => {
try {
const db = getDb();
const platforms = db.prepare('SELECT id, name, join_url, sort_order, created_at FROM promotion_platforms ORDER BY sort_order, id').all();
res.json(platforms);
} catch (e: any) { res.status(500).json({ error: e.message }); }
});
/** POST /api/admin/promotion-platforms — admin create */
router.post('/admin/promotion-platforms', (req: Request, res: Response) => {
try {
const db = getDb();
const { name, join_url, sort_order } = req.body;
if (!name || !join_url) { res.status(400).json({ error: '平台名称和邀请链接不能为空' }); return; }
const result = db.prepare(
'INSERT INTO promotion_platforms (name, join_url, sort_order) VALUES (?, ?, ?)'
).run(name, join_url, sort_order || 0);
res.json({ id: result.lastInsertRowid, name, join_url, sort_order });
} catch (e: any) {
if (e.message && e.message.includes('UNIQUE')) {
res.status(409).json({ error: '该平台名称已存在' });
} else {
res.status(500).json({ error: e.message || '创建失败' });
}
}
});
/** PUT /api/admin/promotion-platforms/:id — admin update */
router.put('/admin/promotion-platforms/:id', (req: Request, res: Response) => {
try {
const db = getDb();
const id = Number(req.params.id);
const { name, join_url, sort_order } = req.body;
const existing = db.prepare('SELECT id FROM promotion_platforms WHERE id = ?').get(id);
if (!existing) { res.status(404).json({ error: '平台不存在' }); return; }
db.prepare('UPDATE promotion_platforms SET name = ?, join_url = ?, sort_order = ? WHERE id = ?')
.run(name, join_url, sort_order || 0, id);
res.json({ success: true });
} catch (e: any) { res.status(500).json({ error: e.message }); }
});
/** DELETE /api/admin/promotion-platforms/:id — admin delete */
router.delete('/admin/promotion-platforms/:id', (req: Request, res: Response) => {
try {
const db = getDb();
const id = Number(req.params.id);
db.prepare('DELETE FROM promotion_platforms WHERE id = ?').run(id);
res.json({ success: true });
} catch (e: any) { res.status(500).json({ error: e.message }); }
});
export default router;
@@ -2,7 +2,6 @@ import { getDb } from '../database/database';
import { getSystemConfig, updateSystemConfig } from '../admin/system-config.service';
import { formatLocalDate, formatLocalDateTime } from '../utils/time';
import { QuarkDriver } from './drivers/quark.driver';
import { CloudConfig, getActiveCloudConfigs } from './credential.service';
import { BaiduDriver } from './drivers/baidu.driver';
// ═══════════════════════════════════════════════════════════════════════════
@@ -20,7 +19,7 @@ interface CleanupOpResult { trashed: number; errors: string[] }
interface CloudCleanupDriver {
/** Trash date folders (YYYY-MM-DD) older than `days`. */
cleanupOldDateFolders(days: number, whitelistDirs?: string[]): Promise<CleanupOpResult>;
cleanupOldDateFolders(days: number): Promise<CleanupOpResult>;
/**
* If used space exceeds thresholdPercent% of TOTAL capacity,
* delete oldest date folders until totalBytes * deletePercent/100
@@ -28,7 +27,7 @@ interface CloudCleanupDriver {
* @param thresholdPercent trigger when usage >= this % of total
* @param deletePercent free this % of total capacity
*/
cleanupBySpaceThreshold(thresholdPercent: number, deletePercent: number, whitelistDirs?: string[]): Promise<CleanupOpResult>;
cleanupBySpaceThreshold(thresholdPercent: number, deletePercent: number): Promise<CleanupOpResult>;
/** Permanently empty the recycle bin. */
emptyTrash(): Promise<boolean>;
}
@@ -64,9 +63,12 @@ interface CleanupStats {
}
/** Get all active cloud configs (any type). Used by the orchestrator. */
function getActiveCleanupConfigs() {
const configs = getActiveCloudConfigs();
return configs.filter((c): c is CloudConfig & { cookie: string } => !!c.cookie);
function getActiveCleanupConfigs(): Array<{ id: number; cloud_type: string; cookie: string; nickname?: string }> {
const db = getDb();
return db.prepare(
`SELECT id, cloud_type, cookie, nickname FROM cloud_configs
WHERE is_active = 1 AND cookie IS NOT NULL AND cookie != ''`
).all() as Array<{ id: number; cloud_type: string; cookie: string; nickname?: string }>;
}
/**
@@ -78,13 +80,6 @@ async function cleanupCloudFiles(days: number): Promise<CleanupOpResult> {
const errors: string[] = [];
let totalTrashed = 0;
// Read whitelist from system config
let whitelist: string[] = [];
try {
const raw = getSystemConfig('cleanup_whitelist_dirs');
if (raw) whitelist = JSON.parse(raw);
} catch {}
for (const cfg of configs) {
const driver = getDriverForCleanup(cfg);
if (!driver) {
@@ -92,7 +87,7 @@ async function cleanupCloudFiles(days: number): Promise<CleanupOpResult> {
continue;
}
try {
const result = await driver.cleanupOldDateFolders(days, whitelist);
const result = await driver.cleanupOldDateFolders(days);
totalTrashed += result.trashed;
errors.push(...result.errors.map(e => `[${cfg.cloud_type}#${cfg.id}] ${e}`));
} catch (err: any) {
@@ -116,13 +111,6 @@ async function cleanupAllBySpaceThreshold(
const errors: string[] = [];
let totalTrashed = 0;
// Read whitelist from system config
let whitelist: string[] = [];
try {
const raw = getSystemConfig('cleanup_whitelist_dirs');
if (raw) whitelist = JSON.parse(raw);
} catch {}
for (const cfg of configs) {
const driver = getDriverForCleanup(cfg);
if (!driver) {
@@ -130,7 +118,7 @@ async function cleanupAllBySpaceThreshold(
continue;
}
try {
const result = await driver.cleanupBySpaceThreshold(thresholdPercent, deletePercent, whitelist);
const result = await driver.cleanupBySpaceThreshold(thresholdPercent, deletePercent);
totalTrashed += result.trashed;
errors.push(...result.errors.map(e => `[${cfg.cloud_type}#${cfg.id}] ${e}`));
} catch (err: any) {
+65
View File
@@ -0,0 +1,65 @@
import { getSystemConfig } from '../admin/system-config.service';
export interface CloudTypeInfo {
type: string;
label: string;
icon: string;
enabled: boolean;
}
/**
* 网盘图标 — 使用打包进镜像的 PNG 图标文件
* 图标存放在 /app/dist/frontend/icons/,通过 Express static 中间件对外提供
*/
/**
/**
* 网盘图标 — 使用仓库中的 PNG 图标文件
* 图标存放在 /app/dist/frontend/icons/,通过 Express static 对外提供
*/
const ICONS: Record<string, string> = {
baidu: '/icons/baidu.png',
aliyun: '/icons/aliyun.png',
quark: '/icons/quark.png',
'115': '/icons/115.png',
tianyi: '/icons/tianyi.png',
'123pan': '/icons/123pan.png',
uc: '/icons/uc.png',
xunlei: '/icons/xunlei.png',
pikpak: '/icons/pikpak.png',
magnet: '/icons/magnet.png',
ed2k: '/icons/ed2k.png',
others: '/icons/others.png',
};
const ALL_CLOUD_TYPES: { type: string; label: string; icon: string }[] = [
{ type: 'quark', label: '夸克网盘', icon: ICONS.quark },
{ type: 'baidu', label: '百度网盘', icon: ICONS.baidu },
{ type: 'aliyun', label: '阿里云盘', icon: ICONS.aliyun },
{ type: '115', label: '115 网盘', icon: ICONS['115'] },
{ type: 'tianyi', label: '天翼云盘', icon: ICONS.tianyi },
{ type: '123pan', label: '123 云盘', icon: ICONS['123pan'] },
{ type: 'uc', label: 'UC 网盘', icon: ICONS.uc },
{ type: 'xunlei', label: '迅雷网盘', icon: ICONS.xunlei },
{ type: 'pikpak', label: 'PikPak', icon: ICONS.pikpak },
{ type: 'magnet', label: '磁力链接', icon: ICONS.magnet },
{ type: 'ed2k', label: '电驴链接', icon: ICONS.ed2k },
{ type: 'others', label: '其他', icon: ICONS.others },
];
export function isCloudTypeEnabled(type: string): boolean {
const val = getSystemConfig(`cloud_type_${type}_enabled`);
if (val === null) return type !== 'others';
return val === "true" || val === "1";
}
export function getAllCloudTypes(): CloudTypeInfo[] {
return ALL_CLOUD_TYPES.map(ct => ({ ...ct, enabled: isCloudTypeEnabled(ct.type) }));
}
export function getEnabledCloudTypeSet(): Set<string> {
const enabled = new Set<string>();
for (const ct of ALL_CLOUD_TYPES) {
if (isCloudTypeEnabled(ct.type)) enabled.add(ct.type);
}
return enabled;
}
@@ -1,11 +1,11 @@
import { getDb } from '../database/database';
import { localTimestamp, formatLocalDateTime } from '../utils/time';
import { getSystemConfig } from '../admin/system-config.service';
import { decrypt } from '../utils/crypto';
import { QuarkDriver } from './drivers/quark.driver';
import { BaiduDriver } from './drivers/baidu.driver';
import { CloudConfig, getAndValidateCredential, getActiveCloudConfigs } from './credential.service';
import { lookupIpLocation } from './ip-lookup';
import { notifyConfigEvent } from './notification.service';
/** In-flight save dedup: prevents concurrent saves of the same URL (race condition fix) */
const inFlightSaves = new Map<string, Promise<SaveResult>>();
@@ -58,11 +58,11 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
dedupCutoff = recentCutoff.cutoff;
const recentRecord = db.prepare(
`SELECT share_url, share_pwd, status, file_size, error_message, folder_name, original_folder_name FROM save_records
`SELECT share_url, share_pwd, status, error_message, folder_name, original_folder_name FROM save_records
WHERE source_url = ? AND created_at >= ?
ORDER BY created_at DESC LIMIT 1`
).get(shareUrl, dedupCutoff) as {
share_url: string | null; share_pwd: string | null; status: string; file_size: string | null;
share_url: string | null; share_pwd: string | null; status: string;
error_message: string | null; folder_name: string | null; original_folder_name: string | null;
} | undefined;
@@ -71,14 +71,14 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
if (alreadySaved && recentRecord.share_url) {
console.log(`[Share] 🛡️ Dedup: ${shareUrl} was saved ${DEDUP_WINDOW_SEC}s ago (status=${recentRecord.status}), returning existing share link`);
db.prepare(
`INSERT INTO save_records (source_type, source_title, source_url, target_cloud, share_url, share_pwd, file_size, file_count, folder_count, duration_ms, status, error_message, folder_name, original_folder_name, ip_address, ip_location, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
`INSERT INTO save_records (source_type, source_title, source_url, target_cloud, share_url, share_pwd, file_size, file_count, folder_count, duration_ms, status, error_message, folder_name, original_folder_name, ip_address, ip_location, created_at, config_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
cloudType, sourceTitle || null, shareUrl, cloudType,
recentRecord.share_url, recentRecord.share_pwd || null,
recentRecord.file_size || null, 0, 0, 0, 'reused', null,
null, 0, 0, 0, 'reused', null,
recentRecord.folder_name || null, recentRecord.original_folder_name || null,
ipAddress || null, ipLocation, localTimestamp(),
ipAddress || null, ipLocation, localTimestamp(), null,
);
return {
success: true,
@@ -98,10 +98,10 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
if (reuseEnabled !== 'false') {
try {
const existing = db.prepare(
`SELECT share_url, share_pwd, file_size, folder_name, original_folder_name FROM save_records
`SELECT share_url, share_pwd, folder_name, original_folder_name FROM save_records
WHERE source_url = ? AND status IN ('success', 'reused') AND share_url IS NOT NULL AND share_url != ''
ORDER BY created_at DESC LIMIT 1`
).get(shareUrl) as { share_url: string; share_pwd: string; file_size: string | null; folder_name: string | null; original_folder_name: string | null } | undefined;
).get(shareUrl) as { share_url: string; share_pwd: string; folder_name: string | null; original_folder_name: string | null } | undefined;
if (existing?.share_url) {
const { LinkValidator } = await import('../validation/link-validator.service');
@@ -123,7 +123,7 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
).run(
cloudType, sourceTitle || null, shareUrl, cloudType,
existing.share_url, existing.share_pwd || null,
existing.file_size || null, 0, 0, 0, reuseStatus, null,
null, 0, 0, 0, reuseStatus, null,
existing.folder_name || null, existing.original_folder_name || null,
ipAddress || null, ipLocation, localTimestamp(),
);
@@ -142,24 +142,30 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
}
// ── Unified credential validation ──
const credential = await getAndValidateCredential(cloudType);
const credential = await getAndValidateCredential(cloudType, ipAddress);
if (!credential.valid || !credential.config) {
return { success: false, message: credential.message };
}
const config = credential.config;
// ── Check transfer enabled ──
if (config.is_transfer_enabled === 0) {
return { success: false, message: `${config.nickname || cloudType} 的转存功能已关闭,请先在后台开启` };
}
const startTime = Date.now();
try {
let driverResult: { success: boolean; message: string; shareUrl?: string; sharePwd?: string; folderName?: string; fileCount?: number; folderCount?: number; originalFolderName?: string };
let driverResult: { success: boolean; message: string; shareUrl?: string; sharePwd?: string; folderName?: string; fileCount?: number; folderCount?: number; fileSize?: number; originalFolderName?: string };
switch (cloudType) {
case 'quark': {
const driver = new QuarkDriver({ cookie: config.cookie!, nickname: config.nickname });
const driver = new QuarkDriver({ cookie: decrypt(config.cookie!), nickname: config.nickname });
driverResult = await driver.saveFromShare(shareUrl, sourceTitle);
break;
}
case 'baidu': {
const driver = new BaiduDriver({ cookie: config.cookie!, nickname: config.nickname });
const driver = new BaiduDriver({ cookie: decrypt(config.cookie!), nickname: config.nickname });
driverResult = await driver.saveFromShare(shareUrl, sourceTitle);
break;
}
@@ -169,62 +175,46 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
return { success: false, message: `暂不支持 ${cloudType} 的保存功能` };
}
// ── If save failed, get actual error reason from PanSou validation ──
let actualError: string | null = null;
if (!driverResult.success) {
try {
const { LinkValidator } = await import('../validation/link-validator.service');
const validator = new LinkValidator();
const validation = await validator.validate(shareUrl, cloudType);
if (validation.message) {
actualError = validation.message;
}
} catch {
// PanSou unreachable
}
}
const durationMs = Date.now() - startTime;
if (driverResult.success) {
db.prepare(
`UPDATE cloud_configs SET last_used_at = datetime('now','localtime'), total_saves = total_saves + 1, consecutive_failures = 0 WHERE id = ?`
).run(config.id);
const nickname = config.nickname || cloudType;
notifyConfigEvent(config.id, 'save_success', `✅ 转存成功`, `**${cloudType}** · ${nickname}
文件: ${driverResult.folderName || sourceTitle || shareUrl}
耗时: ${((Date.now() - startTime) / 1000).toFixed(1)}s`, 'info', {
file_name: driverResult.folderName || sourceTitle || shareUrl || '',
file_size: '',
cloud_type: cloudType,
nickname: nickname || '',
duration: ((Date.now() - startTime) / 1000).toFixed(1),
share_url: shareUrl,
});
} else if ((driverResult as any).cookieExpired) {
// Cookie expired — don't count as failure, user needs to re-login
notifyConfigEvent(config.id, 'cookie_expire', `⚠️ Cookie过期`, `**${cloudType}** · ${config.nickname || '未知'}
链接: ${shareUrl}
`, 'error', {
cloud_type: cloudType,
nickname: config.nickname || '',
share_url: shareUrl,
});
} else {
db.prepare(
`UPDATE cloud_configs SET consecutive_failures = consecutive_failures + 1 WHERE id = ?`
).run(config.id);
const failCount = (db.prepare(`SELECT consecutive_failures FROM cloud_configs WHERE id = ?`).get(config.id) as any)?.consecutive_failures || 0;
if (failCount >= 3) {
notifyConfigEvent(config.id, 'save_fail', `❌ 转存连续失败 ${failCount}`, `**${cloudType}** · ${config.nickname || '未知'}
链接: ${shareUrl}
错误: ${driverResult.message}`, 'warn', {
file_name: sourceTitle || shareUrl || '',
fail_count: String(failCount),
cloud_type: cloudType,
nickname: config.nickname || '',
error: driverResult.message || '',
share_url: shareUrl,
});
}
}
db.prepare(
`INSERT INTO save_records (source_type, source_title, source_url, target_cloud, config_id, promotion_account, share_url, share_pwd, file_size, file_count, folder_count, duration_ms, status, error_message, folder_name, original_folder_name, ip_address, ip_location, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
`INSERT INTO save_records (source_type, source_title, source_url, target_cloud, share_url, share_pwd, file_size, file_count, folder_count, duration_ms, status, error_message, folder_name, original_folder_name, ip_address, ip_location, created_at, config_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
cloudType, sourceTitle || driverResult.folderName || null, shareUrl, cloudType, config.id, config.promotion_account || null,
cloudType, sourceTitle || driverResult.folderName || null, shareUrl, cloudType,
driverResult.shareUrl || null, driverResult.sharePwd || null,
(driverResult as any).fileSize || null, driverResult.fileCount || 0, driverResult.folderCount || 0,
driverResult.fileSize == null ? null : String(driverResult.fileSize), driverResult.fileCount || 0, driverResult.folderCount || 0,
durationMs, driverResult.success ? 'success' : 'failed',
driverResult.success ? null : driverResult.message,
driverResult.success ? null : (actualError ? `${driverResult.message} | ${actualError}` : driverResult.message),
driverResult.folderName || null, driverResult.originalFolderName || null,
ipAddress || null, ipLocation, localTimestamp(),
ipAddress || null, ipLocation, localTimestamp(), config.id
);
return {
@@ -247,9 +237,9 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
).run(config.id);
db.prepare(
`INSERT INTO save_records (source_type, source_url, target_cloud, config_id, promotion_account, duration_ms, status, error_message, ip_address, ip_location, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(cloudType, shareUrl, cloudType, config.id, config.promotion_account || null, durationMs, 'failed', errorMessage, ipAddress || null, ipLocation, localTimestamp());
`INSERT INTO save_records (source_type, source_url, target_cloud, duration_ms, status, error_message, ip_address, ip_location, created_at, config_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(cloudType, shareUrl, cloudType, durationMs, 'failed', errorMessage, ipAddress || null, ipLocation, localTimestamp(), null);
return { success: false, message: errorMessage };
}
@@ -296,15 +286,19 @@ export function getSaveRecords(page: number = 1, pageSize: number = 20, startDat
summaryConditions.push('source_type = ?'); summaryParams.push(sourceType);
}
if (keyword) { conditions.push('source_title LIKE ?'); params.push(`%${keyword}%`); }
const where = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : '';
const total = (db.prepare(`SELECT COUNT(*) as count FROM save_records ${where}`).get(...params) as any).count;
const srWhere = conditions.length > 0 ? 'WHERE sr.' + conditions.join(' AND sr.') : '';
const total = (db.prepare(`SELECT COUNT(*) as count FROM save_records ${srWhere.replace(/sr\./g, '')}`).get(...params) as any).count;
const records = db.prepare(
`SELECT * FROM save_records ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`
).all(...params, pageSize, offset) as SaveRecord[];
`SELECT sr.*, cc.nickname as config_nickname
FROM save_records sr
LEFT JOIN cloud_configs cc ON sr.config_id = cc.id
${srWhere}
ORDER BY sr.created_at DESC LIMIT ? OFFSET ?`
).all(...params, pageSize, offset) as any[];
const summaryWhere = summaryConditions.length > 0 ? 'WHERE ' + summaryConditions.join(' AND ') : '';
const summaryWhere = summaryConditions.length > 0 ? 'WHERE sr.' + summaryConditions.join(' AND sr.') : '';
const summaryRows = db.prepare(
`SELECT status, COUNT(*) as cnt FROM save_records ${summaryWhere} GROUP BY status`
`SELECT status, COUNT(*) as cnt FROM save_records ${summaryWhere.replace(/sr\./g, '')} GROUP BY status`
).all(...summaryParams) as { status: string; cnt: number }[];
let sumTotal = 0, sumSuccess = 0, sumFailed = 0, sumReused = 0;
for (const r of summaryRows) {
@@ -327,63 +321,32 @@ export function cleanupOldSaveRecords(): void {
// ── Storage Refresh ───────────────────────────────────────────────
/**
* Refresh storage info for all active cloud configs that have a getStorageInfo method.
* Supports quark and baidu drivers.
*/
export async function refreshAllStorageInfo(): Promise<void> {
const configs = getActiveCloudConfigs().filter(c => c.cookie);
const configs = getActiveCloudConfigs().filter(c => c.cloud_type === 'quark' && c.cookie);
if (configs.length === 0) return;
// Driver mapping: cloud_type → { module, class }
const DRIVER_REGISTRY: Record<string, { module: string; cls: string }> = {
quark: { module: './drivers/quark.driver', cls: 'QuarkDriver' },
baidu: { module: './drivers/baidu.driver', cls: 'BaiduDriver' },
};
for (const cfg of configs) {
const entry = DRIVER_REGISTRY[cfg.cloud_type];
if (!entry) continue; // no getStorageInfo support for this cloud type
try {
const mod = require(entry.module);
const Driver = mod[entry.cls];
if (!Driver) continue;
const driver = new Driver({ cookie: cfg.cookie, nickname: cfg.nickname });
// Try getStorageInfo (now uses fast /member API for accurate data)
let storage: any;
try {
storage = await driver.getStorageInfo();
} catch {
if (typeof driver.getStorageInfoQuick === 'function') {
storage = await driver.getStorageInfoQuick();
} else {
continue;
const { QuarkDriver } = require('./drivers/quark.driver');
const driver = new QuarkDriver({ cookie: decrypt(cfg.cookie!), nickname: cfg.nickname });
const storage = await driver.getStorageInfo(
decrypt(cfg.cookie!),
(fullUsed: string, total: string) => {
const db = getDb();
db.prepare(
`UPDATE cloud_configs SET storage_used = ?, storage_total = ? WHERE id = ?`
).run(fullUsed, total, cfg.id);
console.log(`[Storage] Background calibration done for quark#${cfg.id}: ${fullUsed} / ${total}`);
}
}
if (!storage) continue;
// Get formatted strings — some drivers return {used, total, usedBytes, totalBytes}
const used = storage.used || '计算中...';
const total = storage.total || '-';
// Only update if we got meaningful data
const hasRealData =
(storage.totalBytes > 0 || storage.usedBytes > 0) || // quark returns these
(used !== '-' && used !== '0 B' && used !== '计算中...'); // baidu check
if (hasRealData) {
);
if (storage.totalBytes > 0 || storage.usedBytes > 0) {
const db = getDb();
db.prepare(
`UPDATE cloud_configs SET storage_used = ?, storage_total = ? WHERE id = ?`
).run(used, total, cfg.id);
console.log(`[Storage] Refreshed ${cfg.cloud_type}#${cfg.id}: ${used} / ${total}`);
).run(storage.used, storage.total, cfg.id);
}
} catch (err: any) {
console.error(`[Storage] Failed to refresh ${cfg.cloud_type}#${cfg.id}:`, err.message);
console.error(`[Storage] Failed to refresh quark#${cfg.id}:`, err.message);
}
}
}
}
@@ -0,0 +1,585 @@
import { getDb } from '../database/database';
import { localTimestamp, formatLocalDate, formatLocalDateTime } from '../utils/time';
import { encrypt, decrypt, isEncrypted } from '../utils/crypto';
// ── Background Used-Space Calculation ──────────────────────────
/**
* Fire-and-forget: recursively calculate used space for a quark drive
* and update the database when done.
*/
async function calculateUsedSpaceAsync(cookie: string, configId: number): Promise<void> {
const { calculateUsedSpace } = require('./drivers/quark-cleanup');
const usedBytes = await calculateUsedSpace(cookie);
if (usedBytes > 0) {
const usedFormatted = usedBytes >= 1024 ** 4
? (usedBytes / 1024 ** 4).toFixed(1) + ' TB'
: usedBytes >= 1024 ** 3
? (usedBytes / 1024 ** 3).toFixed(1) + ' GB'
: (usedBytes / 1024 ** 2).toFixed(1) + ' MB';
const db = getDb();
db.prepare(
`UPDATE cloud_configs SET storage_used = ?, updated_at = ? WHERE id = ?`
).run(usedFormatted, localTimestamp(), configId);
console.log(`[UsedSpace] Updated config #${configId}: used=${usedFormatted}`);
}
}
export interface CloudConfig {
id: number;
cloud_type: string;
cookie?: string;
nickname?: string;
is_active: number;
promotion_account?: string;
is_transfer_enabled: number;
is_primary: number;
storage_used?: string;
storage_total?: string;
checkin_status: string; // 'none'|'success'|'failed'|'pending'|'skipped'
last_checkin_at?: string;
checkin_message?: string;
consecutive_failures: number;
last_used_at?: string;
total_saves: number;
created_at: string;
updated_at: string;
verification_status?: string;
cloud_type_uid?: string;
}
// ── Cookie Encryption Helper ──────────────────────────────────────
/** Decrypt cookie. Handles legacy plaintext data transparently. */
function decryptCookie(encrypted: string | null | undefined): string {
if (!encrypted) return '';
// If already plaintext (legacy data), return as-is
if (!isEncrypted(encrypted)) return encrypted;
return decrypt(encrypted);
}
/**
* Extract Quark __uid from cookie string.
* Used for dedup: same cloud_type + same __uid = same account.
*/
function extractQuarkUid(cookie: string): string | null {
const match = cookie.match(/(?:^|;\s*)__uid=([^;]+)/);
return match ? match[1] : null;
}
// ── Config CRUD ──────────────────────────────────────────────────
export function getCloudConfigs(): CloudConfig[] {
const db = getDb();
return db.prepare(
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, is_primary, storage_used, storage_total,
cloud_type_uid,
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
last_used_at, total_saves, created_at, updated_at, verification_status
FROM cloud_configs ORDER BY id ASC`
).all() as CloudConfig[];
}
export function getAvailableClouds(): CloudConfig[] {
const db = getDb();
return db.prepare(
`SELECT id, cloud_type, nickname, is_active, promotion_account, is_transfer_enabled, is_primary, storage_used, storage_total,
cloud_type_uid,
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
last_used_at, total_saves, created_at, updated_at
FROM cloud_configs WHERE is_active = 1 ORDER BY id ASC`
).all() as CloudConfig[];
}
/** Returns the first active config matching the given cloud type. */
export function getCloudConfigByType(cloudType: string): CloudConfig | undefined {
const db = getDb();
return db.prepare(
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, is_primary, storage_used, storage_total,
cloud_type_uid,
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
last_used_at, total_saves, created_at, updated_at, verification_status
FROM cloud_configs WHERE cloud_type = ? AND is_active = 1
ORDER BY id ASC LIMIT 1`
).get(cloudType) as CloudConfig | undefined;
}
export function getCloudConfigById(id: number): CloudConfig | undefined {
const db = getDb();
return db.prepare(
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, is_primary, storage_used, storage_total,
cloud_type_uid,
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
last_used_at, total_saves, created_at, updated_at, verification_status
FROM cloud_configs WHERE id = ?`
).get(id) as CloudConfig | undefined;
}
/** Returns all active cloud configs (used by save flow for cloud type switching). */
export function getActiveCloudConfigs(): CloudConfig[] {
const db = getDb();
return db.prepare(
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, is_primary, storage_used, storage_total,
cloud_type_uid,
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
last_used_at, total_saves, created_at, updated_at
FROM cloud_configs WHERE is_active = 1
ORDER BY cloud_type ASC, id ASC`
).all() as CloudConfig[];
}
/**
* Toggle the is_primary flag for a cloud config.
* Enforces max 2 primary accounts per cloud type.
*/
export function togglePrimary(id: number, setPrimary: boolean): CloudConfig {
const db = getDb();
const config = getCloudConfigById(id);
if (!config) throw new Error(`Cloud config ${id} not found`);
if (setPrimary) {
// Check how many primary accounts already exist for this cloud type
const primaryCount = db.prepare(
`SELECT COUNT(*) as c FROM cloud_configs WHERE cloud_type = ? AND is_primary = 1 AND id != ?`
).get(config.cloud_type, id) as { c: number };
if (primaryCount.c >= 2) {
throw new Error(`同类型网盘最多只能设置 2 个默认账号(已存在 ${primaryCount.c} 个)`);
}
}
db.prepare(`UPDATE cloud_configs SET is_primary = ?, updated_at = datetime('now', 'localtime') WHERE id = ?`)
.run(setPrimary ? 1 : 0, id);
return getCloudConfigById(id)!;
}
export function saveCloudConfig(data: {
id?: number;
cloud_type: string;
cookie?: string;
nickname?: string;
is_active?: number;
promotion_account?: string;
is_transfer_enabled?: number;
storage_used?: string;
storage_total?: string;
}): CloudConfig {
const db = getDb();
// Encrypt cookie before storing
const encryptedCookie = data.cookie ? encrypt(data.cookie) : null;
// Extract cloud_type_uid from cookie (Quark __uid)
let cloudTypeUid: string | null = null;
if (data.cookie) {
cloudTypeUid = extractQuarkUid(data.cookie);
}
if (data.id) {
// Update by ID — always succeeds
db.prepare(
`UPDATE cloud_configs SET
cloud_type = COALESCE(?, cloud_type),
cookie = COALESCE(?, cookie),
nickname = COALESCE(?, nickname),
is_active = COALESCE(?, is_active),
promotion_account = COALESCE(?, promotion_account),
is_transfer_enabled = COALESCE(?, is_transfer_enabled),
storage_used = COALESCE(?, storage_used),
storage_total = COALESCE(?, storage_total),
cloud_type_uid = COALESCE(?, cloud_type_uid),
consecutive_failures = 0,
updated_at = ?
WHERE id = ?`
).run(data.cloud_type, encryptedCookie || null, data.nickname || null, data.is_active == null ? 1 : Number(data.is_active), data.promotion_account ?? '', data.is_transfer_enabled == null ? 1 : Number(data.is_transfer_enabled), data.storage_used || null, data.storage_total || null, cloudTypeUid || null, localTimestamp(), data.id);
} else {
// Try to find existing config by cloud_type + cloud_type_uid
let existing: any = null;
if (cloudTypeUid) {
existing = db.prepare(
`SELECT id FROM cloud_configs WHERE cloud_type = ? AND cloud_type_uid = ? LIMIT 1`
).get(data.cloud_type, cloudTypeUid);
}
// Fallback: match by cloud_type alone (legacy records without cloud_type_uid)
if (!existing) {
existing = db.prepare(
'SELECT id FROM cloud_configs WHERE cloud_type = ? AND is_active = 1 LIMIT 1'
).get(data.cloud_type) as any;
}
if (existing) {
db.prepare(
`UPDATE cloud_configs SET
cookie = COALESCE(?, cookie),
nickname = COALESCE(?, nickname),
is_active = COALESCE(?, is_active),
promotion_account = COALESCE(?, promotion_account),
is_transfer_enabled = COALESCE(?, is_transfer_enabled),
storage_used = COALESCE(?, storage_used),
storage_total = COALESCE(?, storage_total),
cloud_type_uid = COALESCE(?, cloud_type_uid),
consecutive_failures = 0,
updated_at = ?
WHERE id = ?`
).run(encryptedCookie || null, data.nickname || null, data.is_active == null ? 1 : Number(data.is_active), data.promotion_account ?? '', data.is_transfer_enabled == null ? 1 : Number(data.is_transfer_enabled), data.storage_used || null, data.storage_total || null, cloudTypeUid || null, localTimestamp(), existing.id);
// Re-read savedId for return
const savedId = existing.id;
return db.prepare(
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, is_primary, storage_used, storage_total,
cloud_type_uid,
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
last_used_at, total_saves, created_at, updated_at
FROM cloud_configs WHERE id = ?`
).get(savedId) as CloudConfig;
}
// No existing config found — insert new
db.prepare(
'INSERT INTO cloud_configs (cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total, cloud_type_uid, consecutive_failures) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0)'
).run(data.cloud_type, encryptedCookie || null, data.nickname || null, data.is_active == null ? 1 : Number(data.is_active), data.promotion_account ?? '', data.is_transfer_enabled == null ? 1 : Number(data.is_transfer_enabled), data.storage_used || null, data.storage_total || null, cloudTypeUid || null);
}
const savedId = data.id || (db.prepare('SELECT last_insert_rowid() as id').get() as any).id;
return db.prepare(
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, is_primary, storage_used, storage_total,
cloud_type_uid,
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
last_used_at, total_saves, created_at, updated_at
FROM cloud_configs WHERE id = ?`
).get(savedId) as CloudConfig;
}
export function deleteCloudConfig(id: number): boolean {
const db = getDb();
const result = db.prepare('DELETE FROM cloud_configs WHERE id = ?').run(id);
return result.changes > 0;
}
// ── Cookie Validation ────────────────────────────────────────────
async function fetchQuarkNickname(cookie: string): Promise<string | null> {
const MAX_RETRIES = 2;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
const response = await fetch('https://pan.quark.cn/account/info?fr=pc&platform=pc', {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) quark-cloud-drive/3.14.2 Chrome/112.0.5615.165 Electron/24.1.3.8 Safari/537.36 Channel/pckk_other_ch',
'Cookie': cookie,
'Accept': 'application/json',
'Referer': 'https://pan.quark.cn/',
},
signal: AbortSignal.timeout(15000),
});
if (!response.ok) return null;
const data = await response.json() as any;
if (data?.data?.nickname) return data.data.nickname;
} catch {
if (attempt < MAX_RETRIES) {
await new Promise(r => setTimeout(r, 1500));
continue;
}
}
}
return null;
}
export async function testCloudConnection(id: number): Promise<{
success: boolean;
message: string;
nickname?: string;
storage_used?: string;
storage_total?: string;
}> {
const config = getCloudConfigById(id);
if (!config) {
return { success: false, message: 'Cloud config not found' };
}
if (!config.cookie) {
return { success: false, message: 'Cookie not configured' };
}
try {
let valid = false;
let nickname = '';
let storageUsed = config.storage_used || '';
let storageTotal = config.storage_total || '';
if (config.cloud_type === 'baidu') {
const { BaiduDriver } = require('./drivers/baidu.driver');
const driver = new BaiduDriver({ cookie: config.cookie, nickname: config.nickname });
valid = await driver.validate();
if (valid) {
const info = await driver.getUserInfo();
if (info) {
nickname = config.nickname || info.nickname || '百度网盘';
const fmt = (b: number) => b >= 1024**3 ? (b/1024**3).toFixed(2)+' GB' : (b/1024**2).toFixed(2)+' MB';
storageUsed = fmt(info.usedBytes);
storageTotal = fmt(info.totalBytes);
}
}
} else {
const decodedCookie = decrypt(config.cookie);
const { QuarkDriver } = require('./drivers/quark.driver');
const driver = new QuarkDriver({ cookie: decodedCookie, nickname: config.nickname });
valid = await driver.validate();
if (valid) {
nickname = config.nickname || (await fetchQuarkNickname(decodedCookie)) || '夸克网盘';
const storage = await driver.getStorageInfoQuick(config.storage_total);
storageTotal = (storage.total !== '-' && storage.total !== '0 B') ? storage.total : (config.storage_total || '');
storageUsed = (storage.used && storage.used !== '-' && storage.used !== '0 B') ? storage.used : (config.storage_used || '');
}
}
const db = getDb();
if (!valid) {
db.prepare(
`UPDATE cloud_configs SET verification_status = 'invalid', updated_at = ? WHERE id = ?`
).run(localTimestamp(), id);
return { success: false, message: '连接失败:Cookie 无效或已过期,或网络暂时异常' };
}
db.prepare(
`UPDATE cloud_configs SET nickname = ?, storage_total = ?, storage_used = ?, is_active = 1, verification_status = 'valid', updated_at = ? WHERE id = ?`
).run(nickname, storageTotal, storageUsed, localTimestamp(), id);
// Fire-and-forget: recalculate used space in background (slow for big drives)
if (config.cloud_type === 'quark') {
calculateUsedSpaceAsync(decrypt(config.cookie), id).catch(err => console.error(`[UsedSpace] Background calc failed for #${id}:`, err.message));
}
return {
success: true,
message: '连接成功',
nickname,
storage_used: storageUsed,
storage_total: storageTotal,
};
} catch (err: any) {
try {
const db = getDb();
db.prepare(
`UPDATE cloud_configs SET verification_status = 'invalid', updated_at = ? WHERE id = ?`
).run(localTimestamp(), id);
} catch {}
return { success: false, message: `连接失败:${err.message || '未知错误'}` };
}
}
export async function testCloudConnectionWithCookie(cloudType: string, cookie: string): Promise<{
success: boolean;
message: string;
nickname?: string;
storage_used?: string;
storage_total?: string;
}> {
try {
const { QuarkDriver } = require('./drivers/quark.driver');
const driver = new QuarkDriver({ cookie, nickname: '' });
const valid = await driver.validate();
if (!valid) {
return { success: false, message: '连接失败:Cookie 无效或已过期' };
}
const nickname = (await fetchQuarkNickname(cookie)) || cloudType;
// getStorageInfo may timeout from overseas servers, don't fail if it does
let storage: { used: string; total: string } = { used: '-', total: '-' };
try {
const s = await driver.getStorageInfoQuick();
if (s) {
storage = { used: s.used || '-', total: s.total || '-' };
}
} catch {
// storage info is optional
}
return {
success: true,
message: '连接成功',
nickname,
storage_used: storage.used,
storage_total: storage.total,
};
} catch (err: any) {
return { success: false, message: `连接失败:${err.message || '未知错误'}` };
}
}
// ── Unified Credential Validation ─────────────────────────────────
export interface CredentialValidationResult {
valid: boolean;
config?: CloudConfig;
errorCode?: string;
message: string;
}
/**
* Get and validate a credential for the given cloud type.
*
* This is the unified entry point for all save/transfer operations.
* It handles:
* 1. Finding an active config with < 5 consecutive failures (round-robin)
* 2. Validating cookie freshness via driver.validate()
* 3. Returning structured result with error codes
*
* Reference: search-ucmao get_and_validate_credential() pattern.
*/
export async function getAndValidateCredential(cloudType: string, ipAddress?: string): Promise<CredentialValidationResult> {
const db = getDb();
let config: CloudConfig | undefined;
if (!ipAddress) {
// No IP info — fallback to simple LUR
config = db.prepare(
`SELECT * FROM cloud_configs
WHERE cloud_type = ? AND is_active = 1
AND consecutive_failures < 5
ORDER BY is_primary DESC, last_used_at ASC NULLS FIRST
LIMIT 1`
).get(cloudType) as CloudConfig | undefined;
} else {
// Get today's date in Shanghai time
const today = (() => {
const now = new Date();
const shanghai = new Date(now.toLocaleString('en-US', { timeZone: 'Asia/Shanghai' }));
return shanghai.toISOString().slice(0, 10);
})();
// Count how many times this IP has saved today for this cloud type
const ipCountRow = db.prepare(
`SELECT COALESCE(SUM(save_count), 0) as total
FROM ip_daily_save_counts
WHERE ip_address = ? AND date = ? AND cloud_type = ?`
).get(ipAddress, today, cloudType) as { total: number };
const ipTodayCount = ipCountRow?.total || 0;
// How many primary accounts does this cloud type have?
const primaryCountRow = db.prepare(
`SELECT COUNT(*) as c FROM cloud_configs WHERE cloud_type = ? AND is_primary = 1 AND is_active = 1`
).get(cloudType) as { c: number };
const primaryCount = primaryCountRow?.c || 0;
const primaryThreshold = primaryCount * 2; // Each primary account gets 2 uses per IP
if (ipTodayCount < primaryThreshold) {
// First N saves (primaryCount × 2) — use primary accounts (is_primary=1), fallback to any healthy
config = db.prepare(
`SELECT * FROM cloud_configs
WHERE cloud_type = ? AND is_active = 1
AND consecutive_failures < 5
ORDER BY is_primary DESC, last_used_at ASC NULLS FIRST
LIMIT 1`
).get(cloudType) as CloudConfig | undefined;
} else {
// After primary threshold — exclude accounts this IP has already used today,
// fall back to other available accounts round-robin
const usedConfigIds = db.prepare(
`SELECT DISTINCT config_id FROM ip_daily_save_counts
WHERE ip_address = ? AND date = ? AND cloud_type = ?
ORDER BY config_id`
).all(ipAddress, today, cloudType) as { config_id: number }[];
const usedIds = usedConfigIds.map(r => r.config_id);
const placeholders = usedIds.length > 0 ? usedIds.map(() => '?').join(',') : '-1';
config = db.prepare(
`SELECT * FROM cloud_configs
WHERE cloud_type = ? AND is_active = 1
AND consecutive_failures < 5
AND id NOT IN (${placeholders})
ORDER BY last_used_at ASC NULLS FIRST
LIMIT 1`
).get(cloudType, ...usedIds) as CloudConfig | undefined;
// If all accounts have been used by this IP, fall back to primary
if (!config) {
config = db.prepare(
`SELECT * FROM cloud_configs
WHERE cloud_type = ? AND is_active = 1
AND consecutive_failures < 5
ORDER BY is_primary DESC, last_used_at ASC NULLS FIRST
LIMIT 1`
).get(cloudType) as CloudConfig | undefined;
}
}
}
if (!config) {
return {
valid: false,
errorCode: 'NO_AVAILABLE_DRIVE',
message: `Cloud type "${cloudType}" is not configured or no available drives`,
};
}
if (!config.cookie) {
return {
valid: false,
errorCode: 'COOKIE_MISSING',
message: `Cookie not configured for ${cloudType} drive #${config.id}`,
};
}
try {
// Decrypt cookie before validation
const decryptedCookie = decryptCookie(config.cookie);
if (!decryptedCookie) {
return {
valid: false,
errorCode: 'COOKIE_MISSING',
message: `Cookie not configured for ${cloudType} drive #${config.id}`,
};
}
let cookieValid = false;
if (cloudType === 'baidu') {
const { BaiduDriver } = require('./drivers/baidu.driver');
const driver = new BaiduDriver({ cookie: decryptedCookie, nickname: config.nickname });
cookieValid = await driver.validate();
} else {
const { QuarkDriver } = require('./drivers/quark.driver');
const driver = new QuarkDriver({ cookie: decryptedCookie, nickname: config.nickname });
cookieValid = await driver.validate();
}
if (!cookieValid) {
db.prepare(
`UPDATE cloud_configs SET verification_status = 'invalid', updated_at = ? WHERE id = ?`
).run(localTimestamp(), config.id);
return {
valid: false,
errorCode: 'COOKIE_EXPIRED',
message: `Cookie expired or invalid for ${cloudType} drive #${config.id}`,
};
}
// Track IP daily usage count (if ipAddress provided)
if (ipAddress && config) {
const today = (() => {
const now = new Date();
const shanghai = new Date(now.toLocaleString('en-US', { timeZone: 'Asia/Shanghai' }));
return shanghai.toISOString().slice(0, 10);
})();
db.prepare(
`INSERT INTO ip_daily_save_counts (ip_address, date, cloud_type, config_id, save_count)
VALUES (?, ?, ?, ?, 1)
ON CONFLICT(ip_address, date, cloud_type, config_id)
DO UPDATE SET save_count = save_count + 1`
).run(ipAddress, today, cloudType, config.id);
}
return {
valid: true,
config: { ...config, cookie: decryptedCookie },
message: 'ok',
};
} catch (err: any) {
return {
valid: false,
errorCode: 'VALIDATION_ERROR',
message: `Credential validation failed: ${err.message}`,
};
}
}
@@ -223,6 +223,27 @@ function migrateCloudConfigs(db: Database.Database): void {
db.exec("ALTER TABLE cloud_configs ADD COLUMN verification_status TEXT DEFAULT NULL");
console.log('[DB] cloud_configs migration: verification_status column added');
}
// Migration 3: Add cloud_type_uid column (for Quark __uid dedup)
const row3 = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%cloud_type_uid%'").get();
if (!row3) {
db.exec("ALTER TABLE cloud_configs ADD COLUMN cloud_type_uid TEXT DEFAULT NULL");
console.log('[DB] cloud_configs migration: cloud_type_uid column added');
}
// Migration 4: Add promotion_account column
const row4 = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%promotion_account%'").get();
if (!row4) {
db.exec("ALTER TABLE cloud_configs ADD COLUMN promotion_account TEXT DEFAULT ''");
console.log('[DB] cloud_configs migration: promotion_account column added');
}
// Migration 5: Add is_transfer_enabled column
const row5 = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%is_transfer_enabled%'").get();
if (!row5) {
db.exec("ALTER TABLE cloud_configs ADD COLUMN is_transfer_enabled INTEGER DEFAULT 1");
console.log('[DB] cloud_configs migration: is_transfer_enabled column added');
}
}
function seedAdmin(db: Database.Database): void {
+623
View File
@@ -0,0 +1,623 @@
<template>
<div class="cloud-config">
<!-- 网盘类型开关 -->
<el-card class="toggle-card" style="margin-bottom: 20px;">
<template #header><span>📂 网盘设置及授权</span></template>
<div class="cloud-toggle-grid">
<div
v-for="ct in cloudTypes"
:key="ct.type"
class="cloud-toggle-chip"
>
<img :src="ct.icon" class="cloud-icon-img" />
<span class="cloud-label">{{ ct.label }}</span>
<el-tag v-if="ct.type === 'others'" size="small" type="info"></el-tag>
<el-switch
:model-value="ct.enabled"
size="small"
@change="(val: boolean) => handleCloudToggle(ct.type, val)"
/>
</div>
</div>
<div class="form-tip" style="margin-top: 12px;">
关闭的网盘类型在搜索结果中不会展示修改后立即生效无需点击保存
</div>
</el-card>
<div class="toolbar">
<el-button type="primary" @click="openDialog(null)">新增配置</el-button>
<el-button @click="verifyAll">全部重新验证</el-button>
</div>
<el-table :data="configs" stripe style="width: 100%">
<el-table-column label="网盘类型" width="110">
<template #default="{ row }">
<CloudBadge :cloud_type="row.cloud_type" />
</template>
</el-table-column>
<el-table-column prop="nickname" label="昵称" width="140">
<template #default="{ row }">
<span v-if="row.nickname" class="nickname-text">{{ row.nickname }}</span>
<el-text v-else type="info" size="small">未设置</el-text>
</template>
</el-table-column>
<el-table-column prop="cloud_type_uid" label="标识(__uid)" width="180">
<template #default="{ row }">
<span v-if="row.cloud_type_uid" class="uid-cell">{{ row.cloud_type_uid }}</span>
<el-text v-else type="info" size="small">-</el-text>
</template>
</el-table-column>
<el-table-column label="验证" width="100" align="center">
<template #default="{ row }">
<span v-if="row._verifying" class="verifying">
<el-icon class="is-loading"><Loading /></el-icon>
</span>
<el-tag v-else-if="row.verification_status === 'valid'" type="success" size="small">有效</el-tag>
<el-tag v-else-if="row.verification_status === 'invalid'" type="danger" size="small">无效</el-tag>
<el-tag v-else type="info" size="small">未验证</el-tag>
</template>
</el-table-column>
<el-table-column label="空间" width="200">
<template #default="{ row }">
<div v-if="row.storage_total" class="storage-cell">
<div class="storage-bar-wrap">
<div
class="storage-bar-fill"
:style="{ width: storagePercent(row) + '%' }"
:class="storageBarClass(row)"
></div>
</div>
<div class="storage-text">
<span class="storage-used">{{ row.storage_used || '?' }}</span>
<span class="storage-sep">/</span>
<span class="storage-total">{{ row.storage_total }}</span>
<span class="storage-free">(可用 {{ storageFree(row) }})</span>
</div>
</div>
<el-text v-else type="info" size="small"></el-text>
</template>
</el-table-column>
<!-- 转存统计 -->
<el-table-column label="转存" width="80" align="center">
<template #default="{ row }">
<span v-if="row.total_saves > 0" class="save-count">{{ row.total_saves }}</span>
<el-text v-else type="info" size="small">-</el-text>
</template>
</el-table-column>
<el-table-column label="操作" width="390" align="center">
<template #default="{ row }">
<el-button text type="primary" @click="openDialog(row)">编辑</el-button>
<el-button text type="primary" @click="verifyOne(row)">验证</el-button>
<el-popconfirm title="确定删除该配置?" @confirm="handleDelete(row)">
<template #reference>
<el-button text type="danger">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<!-- 新增/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑配置' : '新增配置'" width="560px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="网盘类型" prop="cloud_type">
<el-select v-model="form.cloud_type" style="width: 100%" :disabled="!!editingId" @change="onCloudTypeChange">
<el-option
v-for="[key, label] in cloudTypeOptions"
:key="key"
:label="label"
:value="key"
/>
</el-select>
</el-form-item>
<el-form-item label="昵称" prop="nickname">
<el-input v-model="form.nickname" placeholder="必填,用于区分多个同类型网盘">
<template #append>
<el-button :loading="form._verifying" @click="verifyAndFillNickname">自动获取</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item label="Cookie" prop="cookie">
<el-input
v-model="form.cookie"
type="textarea"
:autosize="{ minRows: 2, maxRows: 4 }"
:placeholder="cookiePlaceholder"
input-style="font-family: monospace; font-size: 12px;"
/>
</el-form-item>
<!-- Cookie 获取教程根据网盘类型切换 -->
<el-form-item label=" " v-if="form.cloud_type && form.cloud_type !== ''" class="cookie-tips-item">
<div class="cookie-tips" :class="`cookie-tips-${form.cloud_type}`">
<div class="cookie-tips-header">
<span class="cookie-tips-title">📖 {{ cloudTypeLabel }} Cookie 获取教程</span>
</div>
<ol class="cookie-tips-steps" v-html="cookieTutorialHtml"></ol>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, nextTick, onUnmounted } from 'vue'
import { Loading } from '@element-plus/icons-vue'
import { CLOUD_LABELS } from '../../types'
import type { CloudType, CloudConfig } from '../../types'
import { ElMessage } from 'element-plus'
import { getCloudConfigs, saveCloudConfig, updateCloudConfig, deleteCloudConfig, testCloudConnection, getCloudTypes, toggleCloudType } from '../../api'
import CloudBadge from '../../components/CloudBadge.vue'
import type { ElForm } from 'element-plus'
interface CloudTypeInfo { type: string; label: string; icon: string; enabled: boolean }
const cloudTypes = ref<CloudTypeInfo[]>([])
const formRef = ref<InstanceType<typeof ElForm>>()
const configs = ref<(CloudConfig & { _verifying?: boolean })[]>([])
const dialogVisible = ref(false)
const saving = ref(false)
const editingId = ref<number | null>(null)
const defaultForm = () => ({
cloud_type: '' as CloudType | '',
nickname: '',
cookie: '',
_verifying: false,
_storageUsed: '',
_storageTotal: '',
})
const form = reactive<{
cloud_type: CloudType | ''
nickname: string
cookie: string
_verifying: boolean
_storageUsed: string
_storageTotal: string
}>(defaultForm())
const rules = computed(() => ({
cloud_type: [{ required: true, message: '请选择网盘类型', trigger: 'change' }],
nickname: [{ required: true, message: '请填写昵称(区分多个同类型网盘)', trigger: 'blur' }],
}))
const cloudTypeOptions = computed(() => {
return Object.entries(CLOUD_LABELS) as [CloudType, string][]
})
const cookiePlaceholder = computed(() => {
if (!form.cloud_type) return '请先选择网盘类型'
const t = form.cloud_type
if (t === 'quark' || t === 'baidu') return `请输入 ${CLOUD_LABELS[t] || t} 的完整 Cookie`
return editingId.value ? '留空则保持原有' : '输入完整 Cookie'
})
const cloudTypeLabel = computed(() => {
return CLOUD_LABELS[form.cloud_type as CloudType] || form.cloud_type || ''
})
/** Cookie 获取教程 HTML(根据不同网盘类型) */
const cookieTutorialHtml = computed(() => {
const t = form.cloud_type
if (!t) return ''
const tutorials: Record<string, string> = {
quark: `<li>在电脑上打开 <a href="https://pan.quark.cn" target="_blank">pan.quark.cn</a> 并登录你的夸克账号</li>
<li>按 <code>F12</code> 打开开发者工具 → 切换到 <strong>网络 (Network)</strong> 选项卡</li>
<li>刷新页面,在请求列表中点击任意一个请求(如 <code>account/info</code></li>
<li>在右侧 <strong>请求头 (Request Headers)</strong> 中找到 <code>Cookie</code> 字段</li>
<li>复制整个 Cookie 值(<b>从开头到结束的完整内容</b>),粘贴到上方输入框</li>
<li>点击「<b>自动获取</b>」按钮验证 Cookie 是否有效</li>
.cookie-tips-note">⚠️ 必须包含 <code>__st=s%...</code> 字段!请复制浏览器请求头的 <b>整个 Cookie</b>F12 → Network → 请求头 → Cookie 项),不要只复制部分。</div>`,
baidu: `<li>在电脑上打开 <a href="https://pan.baidu.com" target="_blank">pan.baidu.com</a> 并登录你的百度账号</li>
<li>按 <code>F12</code> 打开开发者工具 → 切换到 <strong>网络 (Network)</strong> 选项卡</li>
<li>刷新页面,在请求列表中点击任意一个请求</li>
<li>在右侧 <strong>请求头 (Request Headers)</strong> 中找到 <code>Cookie</code> 字段</li>
<li>复制整个 Cookie 值,粘贴到上方输入框</li>
<li>点击「<b>自动获取</b>」按钮验证 Cookie 是否有效</li>
<div class="cookie-tips-note">💡 需要包含 <code>BDUSS</code> 和 <code>STOKEN</code></div>`,
aliyun: `<li>在电脑上打开 <a href="https://www.aliyundrive.com" target="_blank">aliyundrive.com</a> 并登录</li>
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
<li>粘贴到上方输入框,点击「自动获取」验证</li>
<div class="cookie-tips-note">💡 需包含 <code>token</code> 等有效字段</div>`,
'115': `<li>在电脑上打开 <a href="https://115.com" target="_blank">115.com</a> 并登录</li>
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
<li>粘贴到上方输入框,点击「自动获取」验证</li>
<div class="cookie-tips-note">💡 需包含 <code>UID</code>、<code>CID</code>、<code>SEID</code> 等字段</div>`,
tianyi: `<li>在电脑上打开 <a href="https://cloud.189.cn" target="_blank">cloud.189.cn</a> 并登录</li>
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
<li>粘贴到上方输入框,点击「自动获取」验证</li>
<div class="cookie-tips-note">💡 需包含 <code>COOKIE_LOGIN_USER</code>、<code>SESSION</code> 等字段</div>`,
'123pan': `<li>在电脑上打开 <a href="https://www.123pan.com" target="_blank">123pan.com</a> 并登录</li>
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
<li>粘贴到上方输入框,点击「自动获取」验证</li>`,
uc: `<li>在电脑上打开 <a href="https://drive.uc.cn" target="_blank">drive.uc.cn</a> 并登录</li>
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
<li>粘贴到上方输入框,点击「自动获取」验证</li>`,
xunlei: `<li>在电脑上打开 <a href="https://pan.xunlei.com" target="_blank">pan.xunlei.com</a> 并登录</li>
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
<li>粘贴到上方输入框,点击「自动获取」验证</li>`,
pikpak: `<li>在电脑上打开 <a href="https://www.mypikpak.com" target="_blank">mypikpak.com</a> 并登录</li>
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
<li>粘贴到上方输入框,点击「自动获取」验证</li>`,
}
return tutorials[t] || `<li>在电脑上打开该网盘网站并登录</li>
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
<li>刷新页面,复制任意请求的 <code>Cookie</code></li>
<li>粘贴到上方输入框,点击「自动获取」验证</li>`
})
onMounted(async () => {
await loadConfigs()
await loadCloudTypes()
})
// 每30分钟自动验证一次
let verifyTimer: ReturnType<typeof setInterval> | null = null
onMounted(() => {
verifyTimer = setInterval(() => {
autoVerifyAll()
}, 30 * 60 * 1000)
})
onUnmounted(() => {
if (verifyTimer) clearInterval(verifyTimer)
})
async function loadCloudTypes() {
try {
const result = await getCloudTypes()
cloudTypes.value = result.types
} catch (e) { console.error('加载网盘类型失败', e) }
}
async function handleCloudToggle(type: string, enabled: boolean) {
const ct = cloudTypes.value.find(c => c.type === type)
if (!ct) return
try {
await toggleCloudType(type, enabled)
ct.enabled = enabled
} catch (e: any) { ElMessage.error(e.message || '切换失败'); ct.enabled = !enabled }
}
async function loadConfigs() {
try {
configs.value = await getCloudConfigs()
} catch (e) {
console.error('加载网盘配置失败', e)
}
}
async function autoVerifyAll() {
for (const cfg of configs.value) {
if (cfg.cookie_preview || cfg.nickname) {
await verifyOne(cfg, true)
}
}
}
async function verifyAll() {
for (const cfg of configs.value) {
if ((cfg.cookie_preview || cfg.nickname) && !cfg._verifying) {
await verifyOne(cfg, false)
}
}
ElMessage.success('全部验证完成')
}
async function verifyOne(row: CloudConfig & { _verifying?: boolean }, silent = false) {
if (!row.cookie_preview && !row.nickname) {
if (!silent) ElMessage.warning('该配置没有 Cookie,请先编辑保存后再验证')
return
}
row._verifying = true
try {
const result = await testCloudConnection(row.cloud_type, undefined, row.id)
row.verification_status = result.success ? 'valid' : 'invalid'
if (result.success) {
if (result.nickname && !row.nickname) row.nickname = result.nickname
if (result.storage_used) row.storage_used = result.storage_used
if (result.storage_total) row.storage_total = result.storage_total
if (!silent) ElMessage.success(`${CLOUD_LABELS[row.cloud_type]}${result.message}`)
} else {
if (!silent) ElMessage.error(`${CLOUD_LABELS[row.cloud_type]}${result.message}`)
}
} catch (e: any) {
row.verification_status = 'invalid'
if (!silent) ElMessage.error(`${CLOUD_LABELS[row.cloud_type]}:验证失败`)
} finally {
row._verifying = false
}
}
async function verifyAndFillNickname() {
if (!form.cookie) {
ElMessage.warning('请先输入 Cookie')
return
}
if (!form.cloud_type) {
ElMessage.warning('请先选择网盘类型')
return
}
form._verifying = true
try {
const result = await testCloudConnection(form.cloud_type as CloudType, form.cookie)
if (result.success) {
if (result.nickname) form.nickname = result.nickname
if (result.storage_used) form._storageUsed = result.storage_used
if (result.storage_total) form._storageTotal = result.storage_total
ElMessage.success(`昵称:${result.nickname || '获取成功'}`)
} else {
ElMessage.warning(result.message || '验证失败,请检查 Cookie')
}
} catch (e: any) {
ElMessage.error(e.response?.data?.error || '验证失败,请检查 Cookie')
} finally {
form._verifying = false
}
}
function openDialog(row: CloudConfig | null) {
if (row) {
editingId.value = row.id ?? null
form.cloud_type = row.cloud_type
form.nickname = row.nickname || ''
form.cookie = row.cookie || ''
form._verifying = false
} else {
editingId.value = null
form.cloud_type = '' as CloudType | ''
form.nickname = ''
form.cookie = ''
form._verifying = false
}
dialogVisible.value = true
}
function onCloudTypeChange() {
// Cookie 输入框提示会自动更新(computed)
}
async function handleSave() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
if (editingId.value) {
await updateCloudConfig({
id: editingId.value,
cloud_type: form.cloud_type as CloudType,
nickname: form.nickname,
cookie: form.cookie || undefined,
is_active: true,
storage_used: form._storageUsed || undefined,
storage_total: form._storageTotal || undefined,
})
ElMessage.success('配置更新成功')
} else {
const saved = await saveCloudConfig({
cloud_type: form.cloud_type as CloudType,
nickname: form.nickname,
cookie: form.cookie,
is_active: true,
storage_used: form._storageUsed || undefined,
storage_total: form._storageTotal || undefined,
})
ElMessage.success('配置保存成功')
if (!form._storageTotal) {
const result = await testCloudConnection(form.cloud_type as CloudType, undefined, saved.id)
if (!result.success) {
ElMessage.warning(`配置已保存,但连接验证失败:${result.message}`)
}
}
}
dialogVisible.value = false
editingId.value = null
await loadConfigs()
} catch (e: any) {
ElMessage.error(e.response?.data?.error || '保存失败')
} finally {
saving.value = false
}
}
async function handleDelete(row: CloudConfig) {
try {
await deleteCloudConfig(row.id!)
ElMessage.success('删除成功')
await loadConfigs()
} catch (e) {
ElMessage.error('删除失败')
}
}
/** 解析字节数 → 数值 */
function parseBytes(s: string): number {
const m = s.match(/^([\d.]+)\s*(B|KB|MB|GB|TB)$/i)
if (!m) return 0
const n = parseFloat(m[1])
const units: Record<string, number> = { B: 1, KB: 1024, MB: 1024**2, GB: 1024**3, TB: 1024**4 }
return n * (units[m[2].toUpperCase()] || 1)
}
function storagePercent(row: CloudConfig): number {
if (!row.storage_total || !row.storage_used) return 0
const total = parseBytes(row.storage_total)
const used = parseBytes(row.storage_used)
if (total === 0) return 0
return Math.min(100, Math.round((used / total) * 100))
}
function storageBarClass(row: CloudConfig): string {
const pct = storagePercent(row)
if (pct >= 90) return 'bar-danger'
if (pct >= 70) return 'bar-warning'
return 'bar-normal'
}
function storageFree(row: CloudConfig): string {
if (!row.storage_total || !row.storage_used) return '?'
const total = parseBytes(row.storage_total)
const used = parseBytes(row.storage_used)
if (total === 0) return '?'
const free = total - used
if (free < 1024) return '小于 1 KB'
if (free < 1024 * 1024) return (free / 1024).toFixed(1) + ' KB'
if (free < 1024 * 1024 * 1024) return (free / (1024 * 1024)).toFixed(1) + ' MB'
if (free < 1024 * 1024 * 1024 * 1024) return (free / (1024 * 1024 * 1024)).toFixed(1) + ' GB'
return (free / (1024 * 1024 * 1024 * 1024)).toFixed(1) + ' TB'
}
</script>
<style scoped>
.cloud-config {
background: var(--bg-white);
border-radius: var(--radius-card);
padding: 24px;
}
.cloud-toggle-grid { display: flex; flex-wrap: wrap; gap: 12px; }
.cloud-toggle-chip { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border: 1px solid var(--el-border-color-light); border-radius: 8px; background: var(--el-bg-color); }
.cloud-toggle-chip:hover { border-color: var(--el-color-primary-light-5); }
.cloud-icon-img { width: 20px; height: 20px; object-fit: contain; }
.cloud-label { font-size: 13px; font-weight: 500; }
.form-tip { font-size: 12px; color: var(--el-text-color-secondary); }
.toolbar {
margin-bottom: 16px;
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.sign-summary-tag {
margin-left: 4px;
}
.nickname-text {
font-weight: 600;
color: #303133;
}
.uid-cell {
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
font-size: 11px;
color: #909399;
letter-spacing: 0.3px;
}
/* 空间进度条 */
.storage-cell {
display: flex;
flex-direction: column;
gap: 3px;
padding: 2px 0;
}
.storage-bar-wrap {
height: 4px;
background: #f0f2f5;
border-radius: 2px;
overflow: hidden;
}
.storage-bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.3s;
}
.storage-bar-fill.bar-normal { background: #67c23a; }
.storage-bar-fill.bar-warning { background: #e6a23c; }
.storage-bar-fill.bar-danger { background: #f56c6c; }
.storage-text {
font-size: 11px;
color: #909399;
display: flex;
align-items: center;
gap: 3px;
}
.storage-used { color: #606266; font-weight: 600; }
.storage-total { color: #303133; font-weight: 600; }
.storage-free { color: #909399; }
.save-count {
font-size: 12px;
color: #909399;
}
.verifying {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #909399;
}
:deep(.el-input-group__append) {
padding: 0;
}
:deep(.el-input-group__append .el-button) {
border-radius: 0;
}
/* Cookie 教程卡片 */
.cookie-tips-item :deep(.el-form-item__content) {
margin-left: 0 !important;
}
.cookie-tips {
background: #f8faff;
border: 1px solid #e8f0fe;
border-radius: 8px;
padding: 14px 16px;
font-size: 12px;
line-height: 1.8;
color: #606266;
width: 100%;
box-sizing: border-box;
}
.cookie-tips-header {
margin-bottom: 10px;
}
.cookie-tips-title {
font-weight: 700;
color: #409eff;
font-size: 13px;
}
.cookie-tips-steps {
margin: 0;
padding-left: 20px;
}
.cookie-tips-steps li {
margin-bottom: 4px;
}
.cookie-tips-steps code {
background: #ecf5ff;
padding: 1px 5px;
border-radius: 3px;
font-size: 11px;
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
}
.cookie-tips-note {
margin-top: 8px;
padding: 6px 10px;
background: #fffbe6;
border: 1px solid #fff3c4;
border-radius: 4px;
color: #8a6d3b;
font-size: 11px;
line-height: 1.5;
}
.cookie-tips-note code {
background: #f5f0e0;
font-size: 11px;
}
</style>
+113
View File
@@ -0,0 +1,113 @@
// Native fetch available in Node 20+
export interface AliyunConfig {
cookie?: string;
nickname?: string;
}
export class AliyunDriver {
private config: AliyunConfig;
private baseUrl = 'https://api.aliyundrive.com';
constructor(config: AliyunConfig = {}) {
this.config = config;
}
/**
* Extract share_id from an Aliyun share URL.
* Supports:
* https://www.aliyundrive.com/s/XXXYYY
* https://www.alipan.com/s/XXXYYY
* https://api.aliyundrive.com/v2/share_link/XXXYYY
*/
private extractShareId(shareUrl: string): string | null {
try {
const url = new URL(shareUrl);
const pathMatch = url.pathname.match(/\/s\/([a-zA-Z0-9]+)/);
if (pathMatch) return pathMatch[1];
const shareMatch = url.pathname.match(/\/share_link\/([a-zA-Z0-9]+)/);
if (shareMatch) return shareMatch[1];
return null;
} catch {
return null;
}
}
/**
* Validate a share link using Aliyun's public anonymous API.
* No cookie or token required — this endpoint is open.
*
* API:
* POST https://api.aliyundrive.com/v2/share_link/get_share_by_anonymous
* Body: { "share_id": "XXXYYY", "share_pwd": "" }
*
* Success: returns share_name, file_infos, creator info
* Failure: returns error code (ShareLinkExpired, ShareLinkCancelled, etc.)
*/
async validateShareLink(shareUrl: string): Promise<{
valid: boolean;
message: string;
fileCount?: number;
shareName?: string;
}> {
const shareId = this.extractShareId(shareUrl);
if (!shareId) {
return { valid: false, message: '无法解析阿里云盘链接格式' };
}
try {
const response = await fetch(
`${this.baseUrl}/v2/share_link/get_share_by_anonymous`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Referer': 'https://www.aliyundrive.com/',
'Accept-Language': 'zh-CN,zh;q=0.9',
},
body: JSON.stringify({
share_id: shareId,
share_pwd: '',
}),
signal: AbortSignal.timeout(10000),
}
);
if (!response.ok) {
return { valid: false, message: `HTTP ${response.status}: API 请求失败` };
}
const data = await response.json() as any;
// Check for error codes
if (data.code) {
switch (data.code) {
case 'ShareLinkExpired':
return { valid: false, message: '分享已失效(已过期)' };
case 'ShareLinkCancelled':
return { valid: false, message: '分享已被取消' };
case 'NotFound.ShareLink':
return { valid: false, message: '分享链接不存在' };
case 'ShareLinkPasswordIncorrect':
return { valid: true, message: '需要提取码(链接有效)' };
default:
return { valid: false, message: data.message || `未知错误 (${data.code})` };
}
}
// Success — valid share link
const fileInfos = data.file_infos || [];
return {
valid: true,
message: `有效链接(${fileInfos.length} 个文件)`,
fileCount: fileInfos.length,
shareName: data.share_name || '',
};
} catch (err: any) {
return { valid: false, message: `网络错误: ${err.message || err}` };
}
}
}
@@ -1,6 +1,4 @@
import { BAIDU_PAN_HOST, BAIDU_PASSPORT_HOST, BAIDU_WAPPASS_HOST, EP as BE, buildHeaders as bdHeaders } from './baidu-api';
// Baidu Netdisk Driver v4 — Cookie-based (Playwright QR login + HTTP API)
const APP_ID_WEB = "38824127"; // Web app ID from BaiduPanFilesTransfers
// Uses full browser Cookie string for all operations (no OAuth access_token needed).
// Share operations use internal web API (/share/verify, /share/transfer, parse HTML).
// Reference: https://github.com/hxz393/BaiduPanFilesTransfers
@@ -38,11 +36,27 @@ interface ShareDetail {
// ═══════════════════════════════════
// Constants
// ═══════════════════════════════════
import config from '../../config';
const CHROMIUM_PATH = config.chromiumPath;
const API_HOST = "https://pan.baidu.com";
const CHROMIUM_PATH = process.env.CHROMIUM_PATH || "/usr/bin/chromium-browser";
const APP_ID_WEB = "38824127"; // Web app ID from BaiduPanFilesTransfers
// HTTP headers matching BaiduPanFilesTransfers
const WEB_HEADERS: Record<string, string> = {
'Host': 'pan.baidu.com',
'Connection': 'keep-alive',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Accept-Encoding': 'gzip, deflate, br',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36',
'Referer': 'https://pan.baidu.com',
};
function buildHeaders(cookie: string): Record<string, string> {
if (cookie) {
return { ...WEB_HEADERS, 'Cookie': cookie };
}
return { ...WEB_HEADERS };
}
// ═══════════════════════════════════
// Playwright singleton for QR login
@@ -141,9 +155,9 @@ export class BaiduDriver {
if (!cookie) return null;
try {
const url = `${BAIDU_PAN_HOST}/api/gettemplatevariable?clienttype=0&app_id=${APP_ID_WEB}&web=1&fields=["bdstoken","token","uk","isdocuser","servertime"]`;
const url = `${API_HOST}/api/gettemplatevariable?clienttype=0&app_id=${APP_ID_WEB}&web=1&fields=["bdstoken","token","uk","isdocuser","servertime"]`;
const res = await fetch(url, {
headers: bdHeaders(cookie),
headers: buildHeaders(cookie),
signal: AbortSignal.timeout(10000),
});
if (!res.ok) {
@@ -198,7 +212,7 @@ export class BaiduDriver {
// Navigate directly to passport QR login page (the actual login page with QR code)
console.log('[BaiduQR] Navigating to passport QR login...');
await page.goto('BAIDU_PASSPORT_HOST + BE.PAGE_LOGIN_QR?login&qrlogin&tpl=netdisk', { waitUntil: 'commit', timeout: 30000 });
await page.goto('https://passport.baidu.com/v2/?login&qrlogin&tpl=netdisk', { waitUntil: 'commit', timeout: 30000 });
await page.waitForTimeout(4000);
// Check if we landed on the right page
@@ -233,7 +247,7 @@ export class BaiduDriver {
const sign = imgUrlObj.searchParams.get('sign') || '';
const logPage = imgUrlObj.searchParams.get('logPage') || '';
const t = Math.floor(Date.now() / 1000);
qrContent = `BAIDU_WAPPASS_HOST + BE.PAGE_QR_GEN?qrlogin&t=${t}&error=0&sign=${sign}&cmd=login&lp=pc&tpl=netdisk&adapter=3&logPage=${encodeURIComponent(logPage)}&qrloginfrom=pc`;
qrContent = `https://wappass.baidu.com/wp/?qrlogin&t=${t}&error=0&sign=${sign}&cmd=login&lp=pc&tpl=netdisk&adapter=3&logPage=${encodeURIComponent(logPage)}&qrloginfrom=pc`;
} catch {
qrContent = qrImgSrc; // fallback: raw image URL
}
@@ -318,7 +332,7 @@ export class BaiduDriver {
// Navigate to disk home to ensure cookies are fully set
try {
await page.goto('BAIDU_PAN_HOST + BE.PAGE_DISK_HOME', { waitUntil: 'commit', timeout: 15000 });
await page.goto('https://pan.baidu.com/disk/home', { waitUntil: 'commit', timeout: 15000 });
await page.waitForTimeout(2000);
} catch {}
@@ -335,8 +349,8 @@ export class BaiduDriver {
let bdstoken = '';
try {
const bdres = await fetch(
`${BAIDU_PAN_HOST}/api/gettemplatevariable?clienttype=0&app_id=${APP_ID_WEB}&web=1&fields=["bdstoken","uk"]`,
{ headers: bdHeaders(cookieStr), signal: AbortSignal.timeout(10000) }
`${API_HOST}/api/gettemplatevariable?clienttype=0&app_id=${APP_ID_WEB}&web=1&fields=["bdstoken","uk"]`,
{ headers: buildHeaders(cookieStr), signal: AbortSignal.timeout(10000) }
);
if (bdres.ok) {
const bddata = await bdres.json() as any;
@@ -351,8 +365,8 @@ export class BaiduDriver {
let storage_total = '';
if (bdstoken) {
try {
const qRes = await fetch(`${BAIDU_PAN_HOST}/api/quota?checkfree=1&checkexpire=1&bdstoken=${bdstoken}`, {
headers: bdHeaders(cookieStr),
const qRes = await fetch(`${API_HOST}/api/quota?checkfree=1&checkexpire=1&bdstoken=${bdstoken}`, {
headers: buildHeaders(cookieStr),
signal: AbortSignal.timeout(10000),
});
if (qRes.ok) {
@@ -374,8 +388,8 @@ export class BaiduDriver {
// Get nickname from Baidu REST API (baidu_name field)
if (bdstoken) {
try {
const uRes = await fetch(`${BAIDU_PAN_HOST}/rest/2.0/xpan/nas?method=uinfo`, {
headers: bdHeaders(cookieStr),
const uRes = await fetch(`${API_HOST}/rest/2.0/xpan/nas?method=uinfo`, {
headers: buildHeaders(cookieStr),
signal: AbortSignal.timeout(10000),
});
if (uRes.ok) {
@@ -444,8 +458,8 @@ export class BaiduDriver {
let totalBytes = 0;
// Try to get user info from /api/userinfo
const uRes = await fetch(`${BAIDU_PAN_HOST}/api/userinfo?act=getuserinfo&bdstoken=${await this.getBdstoken()}`, {
headers: bdHeaders(cookie),
const uRes = await fetch(`${API_HOST}/api/userinfo?act=getuserinfo&bdstoken=${await this.getBdstoken()}`, {
headers: buildHeaders(cookie),
signal: AbortSignal.timeout(10000),
});
if (uRes.ok) {
@@ -457,8 +471,8 @@ export class BaiduDriver {
// Get quota
try {
const qRes = await fetch(`${BAIDU_PAN_HOST}/api/quota?checkfree=1&checkexpire=1&bdstoken=${await this.getBdstoken()}`, {
headers: bdHeaders(cookie),
const qRes = await fetch(`${API_HOST}/api/quota?checkfree=1&checkexpire=1&bdstoken=${await this.getBdstoken()}`, {
headers: buildHeaders(cookie),
signal: AbortSignal.timeout(10000),
});
if (qRes.ok) {
@@ -499,9 +513,9 @@ export class BaiduDriver {
if (!bdstoken) return [];
try {
const url = `${BAIDU_PAN_HOST}/api/list?order=time&desc=1&showempty=0&web=1&page=1&num=1000&dir=/&bdstoken=${bdstoken}`;
const url = `${API_HOST}/api/list?order=time&desc=1&showempty=0&web=1&page=1&num=1000&dir=/&bdstoken=${bdstoken}`;
const res = await fetch(url, {
headers: bdHeaders(cookie),
headers: buildHeaders(cookie),
signal: AbortSignal.timeout(15000),
});
if (!res.ok) return [];
@@ -529,11 +543,11 @@ export class BaiduDriver {
if (!bdstoken) return false;
try {
const url = `${BAIDU_PAN_HOST}/api/create?a=commit&bdstoken=${bdstoken}`;
const url = `${API_HOST}/api/create?a=commit&bdstoken=${bdstoken}`;
const body = new URLSearchParams({ path, isdir: '1', block_list: '[]' });
const res = await fetch(url, {
method: 'POST',
headers: { ...bdHeaders(cookie), 'Content-Type': 'application/x-www-form-urlencoded' },
headers: { ...buildHeaders(cookie), 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
signal: AbortSignal.timeout(15000),
});
@@ -575,9 +589,9 @@ export class BaiduDriver {
try {
const filelist = JSON.stringify(fsIds);
const body = new URLSearchParams({ async: '2', filelist });
const res = await fetch(`${BAIDU_PAN_HOST}/api/filemanager?opera=delete&bdstoken=${bdstoken}`, {
const res = await fetch(`${API_HOST}/api/filemanager?opera=delete&bdstoken=${bdstoken}`, {
method: 'POST',
headers: { ...bdHeaders(cookie), 'Content-Type': 'application/x-www-form-urlencoded' },
headers: { ...buildHeaders(cookie), 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
signal: AbortSignal.timeout(30000),
});
@@ -636,12 +650,12 @@ export class BaiduDriver {
if (pwd) {
console.log(`[Baidu:Share] Verifying password for surl=${surl}...`);
const t = String(Date.now());
const verifyUrl = `${BAIDU_PAN_HOST}/share/verify?surl=${surl}&bdstoken=${bdstoken}&t=${t}&channel=chunlei&web=1&clienttype=0`;
const verifyUrl = `${API_HOST}/share/verify?surl=${surl}&bdstoken=${bdstoken}&t=${t}&channel=chunlei&web=1&clienttype=0`;
const verifyBody = new URLSearchParams({ pwd, vcode: '', vcode_str: '' });
const vRes = await fetch(verifyUrl, {
method: 'POST',
headers: { ...bdHeaders(workingCookie), 'Content-Type': 'application/x-www-form-urlencoded' },
headers: { ...buildHeaders(workingCookie), 'Content-Type': 'application/x-www-form-urlencoded' },
body: verifyBody.toString(),
signal: AbortSignal.timeout(10000),
});
@@ -671,7 +685,7 @@ export class BaiduDriver {
const shareUrl = `https://pan.baidu.com/s/1${surl}`;
console.log(`[Baidu:Share] Fetching share page: ${shareUrl}`);
const sRes = await fetch(shareUrl, {
headers: bdHeaders(workingCookie),
headers: buildHeaders(workingCookie),
signal: AbortSignal.timeout(15000),
redirect: 'follow',
});
@@ -768,12 +782,12 @@ export class BaiduDriver {
// Verify password first if needed
if (pwd) {
const t = String(Date.now());
const vUrl = `${BAIDU_PAN_HOST}/share/verify?surl=${surl}&bdstoken=${bdstoken}&t=${t}&channel=chunlei&web=1&clienttype=0`;
const vUrl = `${API_HOST}/share/verify?surl=${surl}&bdstoken=${bdstoken}&t=${t}&channel=chunlei&web=1&clienttype=0`;
const vBody = new URLSearchParams({ pwd, vcode: '', vcode_str: '' });
const vRes = await fetch(vUrl, {
method: 'POST',
headers: { ...bdHeaders(workingCookie), 'Content-Type': 'application/x-www-form-urlencoded' },
headers: { ...buildHeaders(workingCookie), 'Content-Type': 'application/x-www-form-urlencoded' },
body: vBody.toString(),
signal: AbortSignal.timeout(10000),
});
@@ -790,7 +804,7 @@ export class BaiduDriver {
// Get share page to extract shareid + uk
const sRes = await fetch(shareUrl, {
headers: bdHeaders(workingCookie),
headers: buildHeaders(workingCookie),
signal: AbortSignal.timeout(15000),
redirect: 'follow',
});
@@ -812,7 +826,7 @@ export class BaiduDriver {
const fsidlist = `[${fsIds.join(',')}]`;
const path = destPath === '/' ? '/' : `/${destPath.replace(/^\//, '')}`;
const tUrl = `${BAIDU_PAN_HOST}/share/transfer?shareid=${shareid}&from=${uk}&bdstoken=${bdstoken}&channel=chunlei&web=1&clienttype=0`;
const tUrl = `${API_HOST}/share/transfer?shareid=${shareid}&from=${uk}&bdstoken=${bdstoken}&channel=chunlei&web=1&clienttype=0`;
const tBody = new URLSearchParams({ fsidlist, path });
// Retry up to 3 times for transient fetch failures
@@ -822,7 +836,7 @@ export class BaiduDriver {
try {
tRes = await fetch(tUrl, {
method: 'POST',
headers: { ...bdHeaders(workingCookie), 'Content-Type': 'application/x-www-form-urlencoded' },
headers: { ...buildHeaders(workingCookie), 'Content-Type': 'application/x-www-form-urlencoded' },
body: tBody.toString(),
signal: AbortSignal.timeout(30000),
});
@@ -1005,8 +1019,8 @@ export class BaiduDriver {
try {
const res = await fetch(
`${BAIDU_PAN_HOST}/api/list?dir=${encodeURIComponent(parentPath)}&bdstoken=${bdstoken}&order=time&desc=1`,
{ headers: bdHeaders(cookie), signal: AbortSignal.timeout(10000) }
`${API_HOST}/api/list?dir=${encodeURIComponent(parentPath)}&bdstoken=${bdstoken}&order=time&desc=1`,
{ headers: buildHeaders(cookie), signal: AbortSignal.timeout(10000) }
);
if (!res.ok) return null;
const data = await res.json() as any;
@@ -1036,10 +1050,10 @@ export class BaiduDriver {
pwd,
});
const url = `${BAIDU_PAN_HOST}/share/set?bdstoken=${bdstoken}&channel=chunlei&web=1&clienttype=0&app_id=250528`;
const url = `${API_HOST}/share/set?bdstoken=${bdstoken}&channel=chunlei&web=1&clienttype=0&app_id=250528`;
const res = await fetch(url, {
method: 'POST',
headers: { ...bdHeaders(cookie), 'Content-Type': 'application/x-www-form-urlencoded' },
headers: { ...buildHeaders(cookie), 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
signal: AbortSignal.timeout(15000),
});
@@ -1090,7 +1104,7 @@ export class BaiduDriver {
}
}
async cleanupOldDateFolders(days: number, _whitelistDirs?: string[]): Promise<{ trashed: number; errors: string[] }> {
async cleanupOldDateFolders(days: number): Promise<{ trashed: number; errors: string[] }> {
const errors: string[] = [];
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - days);
@@ -1116,7 +1130,7 @@ export class BaiduDriver {
}
}
async cleanupBySpaceThreshold(thresholdPercent: number, deletePercent: number, _whitelistDirs?: string[]): Promise<{ trashed: number; errors: string[] }> {
async cleanupBySpaceThreshold(thresholdPercent: number, deletePercent: number): Promise<{ trashed: number; errors: string[] }> {
const errors: string[] = [];
try {
const info = await this.getUserInfo();
@@ -0,0 +1,291 @@
import { getSystemConfig } from "../../admin/system-config.service";
import { getHeaders, makeQuery } from "./quark-api";
import { listDir, listDirAllPages } from "./quark-api";
import { humanDelay } from "./quark-api";
/**
* 广告关键词清理模块。
* 在转存完成后执行:
* 1. 遍历转存的目录,删除文件名/文件夹名含广告关键词的内容
* 2. 在转存根目录下创建警示文件夹(置顶提醒)
*/
// ==================== 配置读取 ====================
/** 从 DB 读取广告关键词列表 */
export function getAdKeywords(): string[] {
const raw = getSystemConfig("quark_ad_keywords") || "";
return raw
.split("\n")
.map((s) => s.trim())
.filter(Boolean);
}
/** 从 DB 读取警示文件夹名称列表 */
export function getWarningFolderNames(): string[] {
const raw = getSystemConfig("quark_warning_folder_names") || "";
return raw
.split("\n")
.map((s) => s.trim())
.filter(Boolean);
}
/** 从 DB 读取可疑文件后缀列表 */
export function getSusExtensions(): string[] {
const raw = getSystemConfig("quark_sus_extensions") || "";
if (raw.trim()) {
return raw
.split("\n")
.map((s) => s.trim().toLowerCase().replace(/^\./, ""))
.filter(Boolean);
}
// 默认可疑后缀
return ["bat", "exe", "vbs", "scr", "cmd", "com", "pif", "js", "jar", "msi", "reg", "inf", "ps1"];
}
// ==================== 关键词检测 ====================
/** 检查文件名是否包含任意广告关键词 */
export function containsAdKeyword(
fileName: string,
keywords: string[],
): boolean {
if (!keywords.length) return false;
const lower = fileName.toLowerCase();
return keywords.some((kw) => kw && lower.includes(kw.toLowerCase()));
}
// ==================== 删除操作 ====================
/**
* 遍历指定目录(含子目录),删除匹配广告关键词的文件和文件夹。
* 返回删除的文件数。
*/
export async function deleteAdFiles(
cookie: string,
dirFid: string,
keywords: string[],
): Promise<number> {
if (!keywords.length) return 0;
let deletedCount = 0;
const stack: string[] = [dirFid];
const visited = new Set<string>();
while (stack.length > 0) {
const fid = stack.pop()!;
if (visited.has(fid)) continue;
visited.add(fid);
await humanDelay();
const files = await listDir(cookie, fid);
if (!files || files.length === 0) continue;
// 先收集所有需要删除的 fid
const toDelete: string[] = [];
const toKeep: string[] = [];
const extensions = getSusExtensions();
for (const file of files) {
const ext = file.file_name.split(".").pop()?.toLowerCase() || "";
const isSusExt = extensions.includes(ext);
if (containsAdKeyword(file.file_name, keywords) || isSusExt) {
toDelete.push(file.fid);
console.log(
`[Quark-AdCleanup] 标记删除: "${file.file_name}" (fid: ${file.fid})${isSusExt ? " [可疑后缀]" : " [广告关键词]"}`,
);
} else {
toKeep.push(file.fid);
// 如果是目录且不删除,继续遍历子目录
if (file.dir) {
stack.push(file.fid);
}
}
}
// 批量删除
if (toDelete.length > 0) {
const deleteOk = await batchDeleteFiles(cookie, toDelete);
if (deleteOk) {
deletedCount += toDelete.length;
console.log(
`[Quark-AdCleanup] 已删除 ${toDelete.length} 个广告文件`,
);
}
}
}
return deletedCount;
}
/**
* 批量删除文件/文件夹(移入回收站)。
*/
async function batchDeleteFiles(
cookie: string,
fids: string[],
): Promise<boolean> {
try {
const resp = await fetch(
`https://drive-pc.quark.cn/1/clouddrive/file/trash?${makeQuery()}`,
{
method: "POST",
headers: {
...getHeaders(cookie),
"Content-Type": "application/json",
},
body: JSON.stringify({
action_type: 2, // 2 = 移入回收站
file_list: fids.map((fid) => ({ fid })),
exclude_fids: [],
}),
signal: AbortSignal.timeout(15000),
},
);
const data = (await resp.json()) as any;
if (data.status === 200) {
return true;
}
console.log(
`[Quark-AdCleanup] batchDelete 返回非200: status=${data.status} msg=${data.message}`,
);
return false;
} catch (err: any) {
console.log(`[Quark-AdCleanup] batchDelete 错误: ${err.message}`);
return false;
}
}
// ==================== 警示文件夹创建 ====================
/**
* 在转存根目录下创建警示文件夹。
* 文件夹名前加 ⚠️ 和空格,让其按字母排序置顶。
* 已存在的则跳过。
*/
export async function createWarningDirectories(
cookie: string,
dirNames: string[],
parentDirFid: string = "0",
): Promise<void> {
if (!dirNames.length) return;
// 先获取根目录下所有文件夹,避免重复创建
await humanDelay();
const rootFiles = await listDirAllPages(cookie, parentDirFid);
const existingDirs = new Set(
rootFiles.filter((f) => f.dir).map((f) => f.file_name),
);
for (const name of dirNames) {
// 格式化名称:确保以 ⚠️ 开头
let formattedName = name;
if (!formattedName.startsWith("⚠️") && !formattedName.startsWith("⚠")) {
formattedName = `⚠️ ${formattedName}`;
}
// 去掉多余空格
formattedName = formattedName.replace(/\s+/g, " ").trim();
if (existingDirs.has(formattedName)) {
console.log(
`[Quark-AdCleanup] 警示文件夹已存在,跳过: "${formattedName}"`,
);
continue;
}
await createSingleDir(cookie, formattedName, parentDirFid);
// 加入已存在集合,防止同名重试
existingDirs.add(formattedName);
}
}
/**
* 创建单个文件夹。
*/
async function createSingleDir(
cookie: string,
dirName: string,
pdirFid: string = "0",
): Promise<boolean> {
try {
const resp = await fetch(
`https://drive-pc.quark.cn/1/clouddrive/file?${makeQuery()}`,
{
method: "POST",
headers: {
...getHeaders(cookie),
"Content-Type": "application/json",
},
body: JSON.stringify({
pdir_fid: pdirFid,
file_name: dirName,
dir: true,
dir_path: "",
}),
signal: AbortSignal.timeout(10000),
},
);
const data = (await resp.json()) as any;
if (data.status === 200 && data.data?.fid) {
console.log(
`[Quark-AdCleanup] 已创建警示文件夹: "${dirName}" (fid: ${data.data.fid})`,
);
return true;
}
console.log(
`[Quark-AdCleanup] 创建文件夹失败: status=${data.status} msg=${data.message}`,
);
return false;
} catch (err: any) {
console.log(
`[Quark-AdCleanup] 创建文件夹错误: "${dirName}" — ${err.message}`,
);
return false;
}
}
// ==================== 主入口 ====================
/**
* 执行广告清理 + 创建警示文件夹。
* 在转存重命名后调用。
*/
export async function runAdCleanup(
cookie: string,
savedDirFid: string,
): Promise<{ adDeleted: number; warningDirs: number }> {
const keywords = getAdKeywords();
const warningNames = getWarningFolderNames();
let adDeleted = 0;
let warningDirs = 0;
// 1. 广告关键词清理
if (keywords.length > 0) {
console.log(
`[Quark-AdCleanup] 开始广告关键词清理: ${keywords.length} 个关键词`,
);
adDeleted = await deleteAdFiles(cookie, savedDirFid, keywords);
console.log(
`[Quark-AdCleanup] 广告清理完成,共删除 ${adDeleted} 个文件/文件夹`,
);
} else {
console.log("[Quark-AdCleanup] 无广告关键词配置,跳过清理");
}
// 2. 创建警示文件夹
if (warningNames.length > 0) {
console.log(
`[Quark-AdCleanup] 开始创建警示文件夹: ${warningNames.length}`,
);
await createWarningDirectories(cookie, warningNames, savedDirFid);
warningDirs = warningNames.length;
console.log(
`[Quark-AdCleanup] 警示文件夹创建完成(共 ${warningDirs} 个)`,
);
} else {
console.log("[Quark-AdCleanup] 无警示文件夹配置,跳过创建");
}
return { adDeleted, warningDirs };
}

Some files were not shown because too many files have changed in this diff Show More