Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b437c34c6 | |||
| cf965bcfee | |||
| dfcdddabad | |||
| 4b9bcd7a96 | |||
| a12fec4d82 | |||
| 1c0c024b9a | |||
| 359e15a82d | |||
| b7702d0285 | |||
| 37aa05b1e1 | |||
| 329256bd33 | |||
| 58caaae37a | |||
| abd0cb26f5 | |||
| e046514fee | |||
| d83225d736 |
@@ -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
|
||||
@@ -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=
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
@@ -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.1,minor 每轮测试稳定 +0.1.0
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
私有项目 — 保留所有权利。
|
||||
1. 登录管理后台 `/admin/login`(账号: admin,密码: 你在 .env 中设置的)
|
||||
2. 在「网盘配置」中添加夸克网盘 Cookie
|
||||
3. 在「推广管理」中添加首页推广内容(可选)
|
||||
4. 返回首页即可开始搜索
|
||||
|
||||
@@ -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 需确认
|
||||
@@ -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,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"
|
||||
@@ -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"]
|
||||
@@ -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>
|
||||
@@ -1,3 +0,0 @@
|
||||
flask>=3.0
|
||||
waitress>=2.1
|
||||
pymysql>=1.1
|
||||
@@ -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)
|
||||
@@ -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>功能开关 & 系统配置</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>
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -1,3 +0,0 @@
|
||||
flask>=3.0
|
||||
requests>=2.28
|
||||
python-telegram-bot>=20.0
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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}",
|
||||
)
|
||||
@@ -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"]
|
||||
@@ -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 +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
|
||||
@@ -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 []
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()})"
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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 — 凭证管理包"""
|
||||
@@ -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}")
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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")
|
||||
@@ -1,2 +0,0 @@
|
||||
flask>=3.0
|
||||
requests>=2.28
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 381 B |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 467 KiB |
|
Before Width: | Height: | Size: 894 B |
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
@@ -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 {
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
}
|
||||