Compare commits
85 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce3f95b8b9 | |||
| 75f5d26964 | |||
| 4c161b63c6 | |||
| d79c11fb15 | |||
| 3b67df19e7 | |||
| c4ca8edd58 | |||
| d7b055f88b | |||
| 39724e6e73 | |||
| 288d30698a | |||
| a9dc056506 | |||
| 9ef58b5724 | |||
| c9067179ff | |||
| 5e3b5e83a4 | |||
| d4e62b1fb1 | |||
| 7471d9aece | |||
| f0c0d0f487 | |||
| e4e1890da3 | |||
| 470ebac20e | |||
| f284e14630 | |||
| bff955e45b | |||
| 6f7ab6dbc6 | |||
| 498b593b28 | |||
| c57af012b1 | |||
| 86d79e550b | |||
| 07d66ac666 | |||
| 42d2b5bf1c | |||
| d7c2a9cfad | |||
| 8da99d6861 | |||
| 6f20c662eb | |||
| 48d6b642e0 | |||
| da5bd01535 | |||
| b758391861 | |||
| 38097da994 | |||
| 94d8fa455d | |||
| 9f959ca87b | |||
| 7f4ab50557 | |||
| e4e3884ffc | |||
| a609379d20 | |||
| d78412646e | |||
| 7e22c879b9 | |||
| 879d5bea95 | |||
| 8333d203db | |||
| a51ffb4de3 | |||
| 1080c530a7 | |||
| b22cddc7f7 | |||
| 98b779a622 | |||
| cf2796666d | |||
| 17b40cea7b | |||
| 389df53d2d | |||
| 4c46685d1f | |||
| 063fa6b065 | |||
| 26b579717c | |||
| 4131ccece9 | |||
| 71990554c4 | |||
| 0bc848e196 | |||
| 32dcc44524 | |||
| 1fd64e0788 | |||
| f9338e5906 | |||
| d87bc6fd5a | |||
| 0e0cad1271 | |||
| 95df193e26 | |||
| ad186431dd | |||
| e1931cdee6 | |||
| ea03ff2203 | |||
| eebf4b6c97 | |||
| 1e0d408dd2 | |||
| 77a8c5126f | |||
| 7d2d883b81 | |||
| 514922f21a | |||
| 25ad1802dc | |||
| 8cd4dabb60 | |||
| 09be4c307e | |||
| 64b00661a2 | |||
| 29e0fcbd43 | |||
| b852d9577a | |||
| 9a4751692f | |||
| e57298471a | |||
| 3d6add4636 | |||
| d5aa799acc | |||
| 476a7d458a | |||
| 1c5deb08f6 | |||
| 9a1e16d31c | |||
| 41e68789d3 | |||
| 96b989ec25 | |||
| 83cbfaf03f |
@@ -1,51 +0,0 @@
|
|||||||
# ============================================================
|
|
||||||
# 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
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# 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,27 +1,12 @@
|
|||||||
|
.env
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-shm
|
||||||
|
*.sqlite-wal
|
||||||
|
uploads/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.DS_Store
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
.env
|
|
||||||
.h5/
|
|
||||||
*.log
|
|
||||||
.DS_Store
|
|
||||||
.vite/
|
|
||||||
*.bak
|
|
||||||
*.bak2
|
|
||||||
*.bak3
|
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
*.tar
|
*.zip
|
||||||
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/
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
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,32 +1,245 @@
|
|||||||
# 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
|
```bash
|
||||||
# 1. 解压
|
git clone https://gitea.timxx.cn/admin/CloudSearch.git
|
||||||
tar xzf cloudsearch-deploy.tar.gz
|
cd CloudSearch
|
||||||
cd cloudsearch
|
|
||||||
|
|
||||||
# 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"
|
|
||||||
|
|
||||||
# 3. 一键启动
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
# 4. 访问
|
|
||||||
# 首页: http://服务器IP
|
|
||||||
# 管理后台: http://服务器IP/admin/login
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 首次使用
|
### 2. 配置环境变量
|
||||||
|
|
||||||
1. 登录管理后台 `/admin/login`(账号: admin,密码: 你在 .env 中设置的)
|
```bash
|
||||||
2. 在「网盘配置」中添加夸克网盘 Cookie
|
cp docker-compose.env .env
|
||||||
3. 在「推广管理」中添加首页推广内容(可选)
|
# 编辑 .env,修改以下必填项:
|
||||||
4. 返回首页即可开始搜索
|
# 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
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
访问 `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
|
||||||
|
|
||||||
|
私有项目 — 保留所有权利。
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# 🏷️ 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 需确认
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
# 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** — 保持镜像仓库与源码一致
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
#!/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"
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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"]
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
<!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>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
flask>=3.0
|
||||||
|
waitress>=2.1
|
||||||
|
pymysql>=1.1
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
#!/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)
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<!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>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
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"]
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
"""
|
||||||
|
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()
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
"""
|
||||||
|
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()
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
flask>=3.0
|
||||||
|
requests>=2.28
|
||||||
|
python-telegram-bot>=20.0
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"""
|
||||||
|
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)
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"""
|
||||||
|
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)
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
"""
|
||||||
|
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()
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
"""
|
||||||
|
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}",
|
||||||
|
)
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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"]
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""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"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""CloudSearch Transfer — 适配器包"""
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
"""
|
||||||
|
阿里云盘适配器 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
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
"""
|
||||||
|
阿里云盘回收站清理模块 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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"""
|
||||||
|
阿里云盘凭证管理器 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 "",
|
||||||
|
}
|
||||||
@@ -0,0 +1,493 @@
|
|||||||
|
"""
|
||||||
|
阿里云盘转存模块 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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
"""
|
||||||
|
百度网盘适配器 — 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)
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""
|
||||||
|
百度网盘文件清理 — 删除文件 & 广告过滤
|
||||||
|
参考 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
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""
|
||||||
|
百度网盘凭证管理器 — 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 缓存已失效")
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
"""
|
||||||
|
百度网盘转存核心 — 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
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""天翼云盘适配器 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)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""天翼云盘数据清理 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
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""天翼云盘凭证管理 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
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""天翼云盘转存逻辑 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 []
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""
|
||||||
|
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}"
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""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)
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"""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/"}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""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)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""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)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""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",
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""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 []
|
||||||
@@ -0,0 +1,662 @@
|
|||||||
|
"""
|
||||||
|
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()})"
|
||||||
|
)
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"""
|
||||||
|
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()
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""
|
||||||
|
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()})"
|
||||||
|
)
|
||||||
@@ -0,0 +1,554 @@
|
|||||||
|
"""
|
||||||
|
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()
|
||||||
@@ -0,0 +1,638 @@
|
|||||||
|
"""
|
||||||
|
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()
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
"""
|
||||||
|
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()
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""
|
||||||
|
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()})"
|
||||||
|
)
|
||||||
@@ -0,0 +1,619 @@
|
|||||||
|
"""
|
||||||
|
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()
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"""
|
||||||
|
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}>"
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"""
|
||||||
|
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()
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
"""
|
||||||
|
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())})"
|
||||||
|
)
|
||||||
@@ -0,0 +1,523 @@
|
|||||||
|
"""
|
||||||
|
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()
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
"""
|
||||||
|
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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""CloudSearch Transfer — 凭证管理包"""
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"""
|
||||||
|
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}")
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""
|
||||||
|
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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""
|
||||||
|
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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""CloudSearch Transfer — 编排包"""
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
"""
|
||||||
|
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")
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
flask>=3.0
|
||||||
|
requests>=2.28
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
"""
|
||||||
|
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)
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
|
||||||
|
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()
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
|
||||||
|
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()
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
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()
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 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
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 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
|
||||||
|
```
|
||||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 381 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 467 KiB |
|
After Width: | Height: | Size: 894 B |
@@ -1,41 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "cloudsearch-backend",
|
|
||||||
"version": "2.1.0",
|
|
||||||
"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,65 +0,0 @@
|
|||||||
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,623 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
// 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,289 +0,0 @@
|
|||||||
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[],
|
|
||||||
): Promise<void> {
|
|
||||||
if (!dirNames.length) return;
|
|
||||||
|
|
||||||
// 先获取根目录下所有文件夹,避免重复创建
|
|
||||||
await humanDelay();
|
|
||||||
const rootFiles = await listDirAllPages(cookie, "0");
|
|
||||||
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);
|
|
||||||
// 加入已存在集合,防止同名重试
|
|
||||||
existingDirs.add(formattedName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建单个文件夹。
|
|
||||||
*/
|
|
||||||
async function createSingleDir(
|
|
||||||
cookie: string,
|
|
||||||
dirName: string,
|
|
||||||
): 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: "0",
|
|
||||||
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);
|
|
||||||
warningDirs = warningNames.length;
|
|
||||||
console.log(
|
|
||||||
`[Quark-AdCleanup] 警示文件夹创建完成(共 ${warningDirs} 个)`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.log("[Quark-AdCleanup] 无警示文件夹配置,跳过创建");
|
|
||||||
}
|
|
||||||
|
|
||||||
return { adDeleted, warningDirs };
|
|
||||||
}
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
// Native fetch available in Node 20+
|
|
||||||
import * as crypto from 'crypto';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* HTTP 封装层 — 统一处理夸克 API 的请求签名、headers、query params。
|
|
||||||
* 所有模块共用此单例/函数集,不持有状态。
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface QuarkConfig {
|
|
||||||
cookie: string;
|
|
||||||
nickname?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Headers & Params ====================
|
|
||||||
|
|
||||||
const BASE_URL = 'https://drive-pc.quark.cn';
|
|
||||||
|
|
||||||
export function getHeaders(cookie: string): Record<string, string> {
|
|
||||||
return {
|
|
||||||
'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',
|
|
||||||
'Cookie': cookie,
|
|
||||||
'Accept': 'application/json, text/plain, */*',
|
|
||||||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
|
||||||
'Referer': 'https://pan.quark.cn/',
|
|
||||||
'Origin': 'https://pan.quark.cn',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCommonParams(): Record<string, string> {
|
|
||||||
return { pr: 'ucpro', fr: 'pc' };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Generate query string with common params + random timing to mimic browser */
|
|
||||||
export function makeQuery(extra: Record<string, string> = {}): string {
|
|
||||||
const __dt = Math.floor(Math.random() * 240000 + 60000);
|
|
||||||
const __t = Date.now() / 1000;
|
|
||||||
return new URLSearchParams({
|
|
||||||
...getCommonParams(),
|
|
||||||
uc_param_str: '',
|
|
||||||
app: 'clouddrive',
|
|
||||||
__dt: String(__dt),
|
|
||||||
__t: String(__t),
|
|
||||||
...extra,
|
|
||||||
}).toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Random delay to mimic human behavior (500-2000ms) */
|
|
||||||
export async function humanDelay(): Promise<void> {
|
|
||||||
const ms = Math.floor(Math.random() * 1500) + 500;
|
|
||||||
await new Promise(r => setTimeout(r, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Generate a random password for share links */
|
|
||||||
export function randomSharePwd(): string {
|
|
||||||
return Math.floor(1000 + Math.random() * 9000).toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract kps/sign/vcode from cookie for API signing (bare keys, no __ prefix).
|
|
||||||
*/
|
|
||||||
export function getMparam(cookie: string): { kps?: string; sign?: string; vcode?: string } {
|
|
||||||
// Match both __kps and kps (with or without __ prefix)
|
|
||||||
const kpsMatch = cookie.match(/__?kps=([a-zA-Z0-9%+/=]+)/);
|
|
||||||
const signMatch = cookie.match(/__?sign=([a-zA-Z0-9%+/=]+)/);
|
|
||||||
const vcodeMatch = cookie.match(/__?vcode=([a-zA-Z0-9%+/=]+)/);
|
|
||||||
if (kpsMatch && signMatch && vcodeMatch) {
|
|
||||||
return {
|
|
||||||
kps: kpsMatch[1],
|
|
||||||
sign: signMatch[1].replace(/%25/g, '%'),
|
|
||||||
vcode: vcodeMatch[1],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Shared fetch helpers ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Raw fetch wrapper with JSON parse + status check.
|
|
||||||
* Returns parsed JSON body on 2xx, null on network error.
|
|
||||||
*/
|
|
||||||
export async function apiFetch<T = any>(
|
|
||||||
path: string,
|
|
||||||
options: {
|
|
||||||
method?: string;
|
|
||||||
query?: Record<string, string>;
|
|
||||||
body?: any;
|
|
||||||
cookie: string;
|
|
||||||
timeout?: number;
|
|
||||||
},
|
|
||||||
): Promise<T | null> {
|
|
||||||
const { method = 'GET', query, body, cookie, timeout = 10000 } = options;
|
|
||||||
let url = `${BASE_URL}${path}`;
|
|
||||||
if (query) url += `?${new URLSearchParams(query).toString()}`;
|
|
||||||
try {
|
|
||||||
const resp = await fetch(url, {
|
|
||||||
method,
|
|
||||||
headers: {
|
|
||||||
...getHeaders(cookie),
|
|
||||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
|
||||||
},
|
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
|
||||||
signal: AbortSignal.timeout(timeout),
|
|
||||||
});
|
|
||||||
if (!resp.ok) return null;
|
|
||||||
return (await resp.json()) as T;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== File listing (shared across modules) ====================
|
|
||||||
|
|
||||||
export interface QuarkFile {
|
|
||||||
fid: string;
|
|
||||||
file_name: string;
|
|
||||||
share_fid_token?: string;
|
|
||||||
dir: boolean;
|
|
||||||
size?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List files in a directory by FID.
|
|
||||||
*/
|
|
||||||
export async function listDir(cookie: string, pdirFid: string, page = 1, pageSize = 50): Promise<QuarkFile[]> {
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
...getCommonParams(),
|
|
||||||
uc_param_str: '',
|
|
||||||
pdir_fid: pdirFid,
|
|
||||||
_page: String(page),
|
|
||||||
_size: String(pageSize),
|
|
||||||
_fetch_total: '1',
|
|
||||||
_fetch_sub_dirs: '0',
|
|
||||||
_sort: 'file_type:asc,updated_at:desc',
|
|
||||||
fetch_all_file: '1',
|
|
||||||
fetch_risk_file_name: '1',
|
|
||||||
});
|
|
||||||
const resp = await fetch(
|
|
||||||
`${BASE_URL}/1/clouddrive/file/sort?${params.toString()}`,
|
|
||||||
{ headers: getHeaders(cookie), signal: AbortSignal.timeout(15000) },
|
|
||||||
);
|
|
||||||
if (!resp.ok) return [];
|
|
||||||
const data = await resp.json() as any;
|
|
||||||
if (data.status !== 200) return [];
|
|
||||||
return (data.data?.list || []).filter((f: any) => f.fid).map((f: any) => ({
|
|
||||||
fid: f.fid,
|
|
||||||
file_name: f.file_name,
|
|
||||||
share_fid_token: '',
|
|
||||||
dir: f.dir || false,
|
|
||||||
size: f.size || 0,
|
|
||||||
}));
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List root directory (pdir_fid=0) — returns all top-level dirs/files.
|
|
||||||
*/
|
|
||||||
export async function listRootDir(cookie: string): Promise<QuarkFile[]> {
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
pr: 'ucpro', fr: 'pc',
|
|
||||||
pdir_fid: '0',
|
|
||||||
_page: '1', _size: '200',
|
|
||||||
_fetch_total: '1', _fetch_sub_dirs: '0',
|
|
||||||
_sort: 'file_type:asc,updated_at:desc',
|
|
||||||
fetch_all_file: '1',
|
|
||||||
fetch_risk_file_name: '1',
|
|
||||||
});
|
|
||||||
const resp = await fetch(
|
|
||||||
`${BASE_URL}/1/clouddrive/file/sort?${params.toString()}`,
|
|
||||||
{ headers: getHeaders(cookie), signal: AbortSignal.timeout(15000) },
|
|
||||||
);
|
|
||||||
if (!resp.ok) return [];
|
|
||||||
const data = await resp.json() as any;
|
|
||||||
if (data.status !== 200 || !data.data?.list) return [];
|
|
||||||
return (data.data.list || []).map((f: any) => ({
|
|
||||||
fid: f.fid,
|
|
||||||
file_name: f.file_name,
|
|
||||||
dir: f.dir || false,
|
|
||||||
size: f.size || 0,
|
|
||||||
}));
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List all files in a directory, handling pagination.
|
|
||||||
* Fetches all pages until no more results.
|
|
||||||
*/
|
|
||||||
export async function listDirAllPages(cookie: string, pdirFid: string): Promise<QuarkFile[]> {
|
|
||||||
const allFiles: QuarkFile[] = [];
|
|
||||||
let page = 1;
|
|
||||||
const pageSize = 100;
|
|
||||||
let total = -1;
|
|
||||||
while (total === -1 || (page - 1) * pageSize < total) {
|
|
||||||
const files = await listDir(cookie, pdirFid, page, pageSize);
|
|
||||||
if (!files.length) break;
|
|
||||||
allFiles.push(...files);
|
|
||||||
if (total === -1) {
|
|
||||||
total = files.length;
|
|
||||||
}
|
|
||||||
page++;
|
|
||||||
}
|
|
||||||
return allFiles;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Format utilities ====================
|
|
||||||
|
|
||||||
export function formatBytes(bytes: number): string {
|
|
||||||
if (bytes === 0) return '0 B';
|
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
||||||
return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + sizes[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Generate a daily folder name (e.g. "2026-05-03") for organizing saves */
|
|
||||||
export function dailyFolderName(): string {
|
|
||||||
const d = new Date();
|
|
||||||
const y = d.getFullYear();
|
|
||||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(d.getDate()).padStart(2, '0');
|
|
||||||
return `${y}-${m}-${day}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Generate a random folder name for saving (fallback) */
|
|
||||||
export function randomFolderName(): string {
|
|
||||||
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
|
||||||
let name = '';
|
|
||||||
for (let i = 0; i < 12; i++) {
|
|
||||||
name += chars[Math.floor(Math.random() * chars.length)];
|
|
||||||
}
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { QuarkConfig } from './quark-api';
|
|
||||||
import { getHeaders, getMparam, apiFetch, makeQuery } from './quark-api';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 认证模块 — Cookie 验证、账号信息获取、QR 登录状态检查。
|
|
||||||
* 所有方法以 cookie 字符串为参数,不持有驱动状态。
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ==================== Validate ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate the cookie by fetching user info.
|
|
||||||
*/
|
|
||||||
export async function validate(cookie: string): Promise<boolean> {
|
|
||||||
const MAX_RETRIES = 2;
|
|
||||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
||||||
try {
|
|
||||||
// Use account/info API (same as quark-auto-save project)
|
|
||||||
// Only needs __uid cookie, no mparam (kps/sign/vcode) required
|
|
||||||
const url = 'https://pan.quark.cn/account/info?fr=pc&platform=pc';
|
|
||||||
const response = await fetch(url, {
|
|
||||||
headers: {
|
|
||||||
...getHeaders(cookie),
|
|
||||||
'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',
|
|
||||||
},
|
|
||||||
signal: AbortSignal.timeout(15000),
|
|
||||||
});
|
|
||||||
if (!response.ok) return false;
|
|
||||||
const data = await response.json() as any;
|
|
||||||
if (data?.data?.nickname) return true;
|
|
||||||
} catch (err: any) {
|
|
||||||
if (attempt < MAX_RETRIES) {
|
|
||||||
console.log(`[Quark] validate attempt ${attempt + 1} failed: ${err.message}, retrying...`);
|
|
||||||
await new Promise(r => setTimeout(r, 2000));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
console.log(`[Quark] validate all ${MAX_RETRIES + 1} attempts failed: ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fetch nickname from Quark account info (same API used by quark-auto-save) */
|
|
||||||
export async function fetchNickname(cookie: string): Promise<string | null> {
|
|
||||||
try {
|
|
||||||
const url = 'https://pan.quark.cn/account/info?fr=pc&platform=pc';
|
|
||||||
const response = await fetch(url, {
|
|
||||||
headers: {
|
|
||||||
...getHeaders(cookie),
|
|
||||||
'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',
|
|
||||||
},
|
|
||||||
signal: AbortSignal.timeout(15000),
|
|
||||||
});
|
|
||||||
if (!response.ok) return null;
|
|
||||||
const data = await response.json() as any;
|
|
||||||
return data?.data?.nickname || null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
import { getHeaders, getCommonParams, getMparam, listRootDir, listDirAllPages, formatBytes, humanDelay, makeQuery, listDir, QuarkFile } from './quark-api';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 容量信息 & 空间清理模块。
|
|
||||||
*/
|
|
||||||
const BASE_URL = 'https://drive-pc.quark.cn';
|
|
||||||
|
|
||||||
// ==================== Storage Info ====================
|
|
||||||
|
|
||||||
/** Cached used space, keyed by hour block (3h window) */
|
|
||||||
const cachedUsedSpace: { bytes: number; hourBlock: number } | null = null;
|
|
||||||
|
|
||||||
// We use a function-scoped cache instead of instance field
|
|
||||||
const storageCache: { bytes: number; hourBlock: number } = { bytes: 0, hourBlock: -1 };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get total capacity from /capacity/detail API.
|
|
||||||
* Also does a quick used-space estimate by summing root-level file sizes + subdir sizes
|
|
||||||
* (夸克目录的 size 字段 = 该目录内所有文件总大小,无需递归).
|
|
||||||
* If the API fails (e.g. missing sign params), falls back to fallbackTotal if provided.
|
|
||||||
*/
|
|
||||||
export async function getStorageInfoQuick(cookie: string, fallbackTotal?: string): Promise<{ total: string; totalBytes: number; used: string; usedBytes: number }> {
|
|
||||||
try {
|
|
||||||
const mparam = getMparam(cookie);
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
...getCommonParams(),
|
|
||||||
kps: mparam.kps || '',
|
|
||||||
sign: mparam.sign || '',
|
|
||||||
vcode: mparam.vcode || '',
|
|
||||||
});
|
|
||||||
const capResponse = await fetch(`${BASE_URL}/1/clouddrive/capacity/detail?${params.toString()}`, {
|
|
||||||
headers: getHeaders(cookie),
|
|
||||||
signal: AbortSignal.timeout(10000),
|
|
||||||
});
|
|
||||||
let totalBytes = 0;
|
|
||||||
if (capResponse.ok) {
|
|
||||||
const data = await capResponse.json() as any;
|
|
||||||
if (data.status === 200 && data.data) {
|
|
||||||
totalBytes = data.data.capacity_summary?.sum_capacity || 0;
|
|
||||||
if (totalBytes === 0) {
|
|
||||||
const memberships = [...(data.data.effect || []), ...(data.data.expired || [])];
|
|
||||||
totalBytes = memberships.reduce((max: number, m: any) => Math.max(max, m.capacity || 0), 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Quick used-space estimate: sum root-level file sizes + subdir sizes
|
|
||||||
let usedBytes = 0;
|
|
||||||
try {
|
|
||||||
const rootFiles = await listRootDir(cookie);
|
|
||||||
for (const f of rootFiles) {
|
|
||||||
usedBytes += f.size || 0;
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
// Cache the result (3h window)
|
|
||||||
const currentHourBlock = Math.floor(new Date().getHours() / 3);
|
|
||||||
storageCache.bytes = usedBytes;
|
|
||||||
storageCache.hourBlock = currentHourBlock;
|
|
||||||
|
|
||||||
if (totalBytes > 0) {
|
|
||||||
return {
|
|
||||||
total: formatBytes(totalBytes),
|
|
||||||
totalBytes,
|
|
||||||
used: formatBytes(usedBytes),
|
|
||||||
usedBytes,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
// Fallback: try to parse from a human-readable string like "6 TB"
|
|
||||||
if (fallbackTotal) {
|
|
||||||
const match = fallbackTotal.match(/^([\d.]+)\s*([KMGT]B?)/i);
|
|
||||||
if (match) {
|
|
||||||
const num = parseFloat(match[1]);
|
|
||||||
const unit = match[2].toUpperCase();
|
|
||||||
const multipliers: Record<string, number> = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3, TB: 1024 ** 4, PB: 1024 ** 5 };
|
|
||||||
const multiplier = multipliers[unit] || multipliers[unit.replace('B', '') + 'B'] || 0;
|
|
||||||
if (multiplier > 0) {
|
|
||||||
return { total: fallbackTotal, totalBytes: Math.round(num * multiplier), used: '-', usedBytes: 0 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { total: '-', totalBytes: 0, used: '-', usedBytes: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get storage info with used space calculation.
|
|
||||||
*/
|
|
||||||
export async function getStorageInfo(cookie: string): Promise<{ used: string; total: string; usedBytes: number; totalBytes: number }> {
|
|
||||||
try {
|
|
||||||
const mparam = getMparam(cookie);
|
|
||||||
let totalBytes = 0;
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
...getCommonParams(),
|
|
||||||
kps: mparam.kps || '',
|
|
||||||
sign: mparam.sign || '',
|
|
||||||
vcode: mparam.vcode || '',
|
|
||||||
});
|
|
||||||
const response = await fetch(`${BASE_URL}/1/clouddrive/capacity/detail?${params.toString()}`, {
|
|
||||||
headers: getHeaders(cookie),
|
|
||||||
signal: AbortSignal.timeout(10000),
|
|
||||||
});
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json() as any;
|
|
||||||
if (data.status === 200 && data.data) {
|
|
||||||
totalBytes = data.data.capacity_summary?.sum_capacity || 0;
|
|
||||||
if (totalBytes === 0) {
|
|
||||||
const memberships = [...(data.data.effect || []), ...(data.data.expired || [])];
|
|
||||||
totalBytes = memberships.reduce((max: number, m: any) => Math.max(max, m.capacity || 0), 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const usedBytes = await calculateUsedSpace(cookie);
|
|
||||||
|
|
||||||
if (totalBytes > 0 || usedBytes > 0) {
|
|
||||||
return {
|
|
||||||
total: totalBytes > 0 ? formatBytes(totalBytes) : '-',
|
|
||||||
used: formatBytes(usedBytes),
|
|
||||||
usedBytes,
|
|
||||||
totalBytes: totalBytes > 0 ? totalBytes : 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { used: '0 B', total: '-', usedBytes: 0, totalBytes: 0 };
|
|
||||||
} catch {
|
|
||||||
return { used: '-', total: '-', usedBytes: 0, totalBytes: 0 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate total used space by recursively traversing all files
|
|
||||||
* and summing their sizes. Uses 3-hour time window cache.
|
|
||||||
*/
|
|
||||||
export async function calculateUsedSpace(cookie: string): Promise<number> {
|
|
||||||
const currentHourBlock = Math.floor(new Date().getHours() / 3);
|
|
||||||
if (storageCache.hourBlock === currentHourBlock && storageCache.bytes > 0) {
|
|
||||||
return storageCache.bytes;
|
|
||||||
}
|
|
||||||
let totalUsed = 0;
|
|
||||||
const stack: string[] = ['0'];
|
|
||||||
const visited = new Set<string>();
|
|
||||||
while (stack.length > 0) {
|
|
||||||
const fid = stack.pop()!;
|
|
||||||
if (visited.has(fid)) continue;
|
|
||||||
visited.add(fid);
|
|
||||||
const files = await listDirAllPages(cookie, fid);
|
|
||||||
if (!files.length) continue;
|
|
||||||
for (const f of files) {
|
|
||||||
if (f.dir) {
|
|
||||||
stack.push(f.fid);
|
|
||||||
} else {
|
|
||||||
totalUsed += f.size || 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await new Promise(r => setTimeout(r, 50));
|
|
||||||
}
|
|
||||||
storageCache.bytes = totalUsed;
|
|
||||||
storageCache.hourBlock = currentHourBlock;
|
|
||||||
return totalUsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Cleanup ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Trash specified files/folders (move to recycle bin).
|
|
||||||
*/
|
|
||||||
export async function trashFiles(cookie: string, fids: string[]): Promise<boolean> {
|
|
||||||
if (!fids.length) return true;
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`${BASE_URL}/1/clouddrive/file/trash?${makeQuery()}`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: { ...getHeaders(cookie), 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
action_type: 1, // 1 = move to trash
|
|
||||||
filelist: fids,
|
|
||||||
exclude_filelist: [],
|
|
||||||
}),
|
|
||||||
signal: AbortSignal.timeout(30000),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!response.ok) return false;
|
|
||||||
const data = await response.json() as any;
|
|
||||||
if (data.status === 200) return true;
|
|
||||||
console.error(`[Quark] trashFiles failed: ${data.message || data.status}`);
|
|
||||||
return false;
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error(`[Quark] trashFiles error: ${err.message}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Empty the recycle bin — permanently delete all files in trash.
|
|
||||||
*/
|
|
||||||
export async function emptyTrash(cookie: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`${BASE_URL}/1/clouddrive/file/trash/clear?${makeQuery()}`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: { ...getHeaders(cookie), 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
signal: AbortSignal.timeout(60000),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!response.ok) return false;
|
|
||||||
const data = await response.json() as any;
|
|
||||||
if (data.status === 200) return true;
|
|
||||||
console.error(`[Quark] emptyTrash failed: ${data.message || data.status}`);
|
|
||||||
return false;
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error(`[Quark] emptyTrash error: ${err.message}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cleanup: trash date-named folders (YYYY-MM-DD) older than `days`.
|
|
||||||
*/
|
|
||||||
export async function cleanupOldDateFolders(cookie: string, days: number): Promise<{ trashed: number; errors: string[] }> {
|
|
||||||
const errors: string[] = [];
|
|
||||||
const cutoff = new Date();
|
|
||||||
cutoff.setDate(cutoff.getDate() - days);
|
|
||||||
const cutoffStr = cutoff.toISOString().slice(0, 10);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const rootItems = await listRootDir(cookie);
|
|
||||||
const oldFolders = rootItems.filter(item => {
|
|
||||||
if (!item.dir) return false;
|
|
||||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(item.file_name)) return false;
|
|
||||||
return item.file_name < cutoffStr;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (oldFolders.length === 0) {
|
|
||||||
return { trashed: 0, errors: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const fids = oldFolders.map(f => f.fid);
|
|
||||||
console.log(`[Quark] Trashing ${fids.length} old date folders (before ${cutoffStr}): ${oldFolders.map(f => f.file_name).join(', ')}`);
|
|
||||||
const ok = await trashFiles(cookie, fids);
|
|
||||||
if (ok) {
|
|
||||||
return { trashed: fids.length, errors: [] };
|
|
||||||
}
|
|
||||||
return { trashed: 0, errors: [`Trash API returned failure for ${fids.length} folders`] };
|
|
||||||
} catch (err: any) {
|
|
||||||
return { trashed: 0, errors: [err.message] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cleanup: if used space exceeds thresholdPercent% of total,
|
|
||||||
* delete the oldest date folders until totalBytes * deletePercent/100
|
|
||||||
* of total capacity is freed.
|
|
||||||
*/
|
|
||||||
export async function cleanupBySpaceThreshold(
|
|
||||||
cookie: string,
|
|
||||||
thresholdPercent: number,
|
|
||||||
deletePercent: number,
|
|
||||||
): Promise<{ trashed: number; errors: string[] }> {
|
|
||||||
const errors: string[] = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const storage = await getStorageInfo(cookie);
|
|
||||||
if (storage.totalBytes <= 0) return { trashed: 0, errors: [] };
|
|
||||||
|
|
||||||
const usagePercent = (storage.usedBytes / storage.totalBytes) * 100;
|
|
||||||
if (usagePercent < thresholdPercent) {
|
|
||||||
console.log(`[Quark] Usage ${usagePercent.toFixed(1)}% below threshold ${thresholdPercent}%, skipping`);
|
|
||||||
return { trashed: 0, errors: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetBytesToFree = Math.floor(storage.totalBytes * Math.min(deletePercent, 100) / 100);
|
|
||||||
|
|
||||||
const rootItems = await listRootDir(cookie);
|
|
||||||
const dateFolders = rootItems
|
|
||||||
.filter(item => item.dir && /^\d{4}-\d{2}-\d{2}$/.test(item.file_name))
|
|
||||||
.sort((a, b) => a.file_name.localeCompare(b.file_name));
|
|
||||||
|
|
||||||
if (dateFolders.length === 0) return { trashed: 0, errors: [] };
|
|
||||||
|
|
||||||
const hasSizes = dateFolders.some(f => f.size && f.size > 0);
|
|
||||||
let cumulativeSize = 0;
|
|
||||||
const foldersToTrash: typeof dateFolders = [];
|
|
||||||
|
|
||||||
if (hasSizes) {
|
|
||||||
for (const folder of dateFolders) {
|
|
||||||
foldersToTrash.push(folder);
|
|
||||||
cumulativeSize += folder.size || 0;
|
|
||||||
if (cumulativeSize >= targetBytesToFree) break;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const avgSizePerFolder = storage.usedBytes / dateFolders.length;
|
|
||||||
const estCount = Math.max(1, Math.ceil(targetBytesToFree / avgSizePerFolder));
|
|
||||||
foldersToTrash.push(...dateFolders.slice(0, estCount));
|
|
||||||
cumulativeSize = estCount * avgSizePerFolder;
|
|
||||||
}
|
|
||||||
|
|
||||||
const freedMB = (cumulativeSize / 1024 / 1024).toFixed(0);
|
|
||||||
const targetMB = (targetBytesToFree / 1024 / 1024).toFixed(0);
|
|
||||||
const fidsToTrash = foldersToTrash.map(f => f.fid);
|
|
||||||
console.log(`[Quark] Space threshold: trashing ${foldersToTrash.length}/${dateFolders.length} oldest folders (~${freedMB} MB) to free ${targetMB} MB (${deletePercent}% of ${(storage.totalBytes/1024/1024/1024).toFixed(0)} GB total)`);
|
|
||||||
|
|
||||||
const ok = await trashFiles(cookie, fidsToTrash);
|
|
||||||
if (ok) {
|
|
||||||
console.log(`[Quark] ✅ Space-threshold trashed ${foldersToTrash.length} folders (~${freedMB} MB)`);
|
|
||||||
return { trashed: foldersToTrash.length, errors: [] };
|
|
||||||
}
|
|
||||||
return { trashed: 0, errors: [`Space-threshold trash failed for ${foldersToTrash.length} folders`] };
|
|
||||||
} catch (err: any) {
|
|
||||||
return { trashed: 0, errors: [err.message] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
import * as crypto from 'crypto';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 防和谐重命名模块。
|
|
||||||
* 对文件名/目录名执行谐音替换 + 可读标签保留(集数、画质、语言等)。
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ==================== Homophone Map ====================
|
|
||||||
|
|
||||||
const HOMOPHONE_MAP: Record<string, string> = {
|
|
||||||
// 网盘热门番名 — 谐音替换 (same sound, different char)
|
|
||||||
'斗':'陡','破':'坡','苍':'仓','穹':'穷',
|
|
||||||
'完':'玩','美':'每','世':'士','界':'介',
|
|
||||||
'凡':'烦','人':'仁','修':'休','罗':'络',
|
|
||||||
'仙':'先','逆':'腻','遮':'折','天':'添',
|
|
||||||
'吞':'屯','噬':'逝','大':'达','主':'嘱','宰':'崽',
|
|
||||||
'星':'惺','辰':'晨','变':'便','一':'伊','念':'捻',
|
|
||||||
'永':'泳','恒':'横','神':'申','墓':'暮','长':'尝','生':'甥',
|
|
||||||
'剑':'箭','来':'莱','诡':'鬼','秘':'蜜',
|
|
||||||
'全':'泉','职':'值','盘':'磐','龙':'笼',
|
|
||||||
'雪':'血','鹰':'莺','莽':'蟒','荒':'慌','纪':'记',
|
|
||||||
'珠':'株','王':'亡','座':'坐','牧':'木','记':'计',
|
|
||||||
'沧':'舱','元':'圆','图':'涂','紫':'仔','川':'串',
|
|
||||||
'百':'白','炼':'恋','成':'程','饶':'绕','命':'冥',
|
|
||||||
// 通用谐音替换
|
|
||||||
'的':'得','了':'啦','是':'事','不':'布','我':'窝',
|
|
||||||
'你':'尼','他':'她','有':'友','和':'合','与':'予',
|
|
||||||
'上':'尚','下':'夏','中':'忠','第':'弟','集':'级',
|
|
||||||
'话':'划','季':'际','年':'念','月':'阅','日':'曰',
|
|
||||||
'新':'心','版':'板','高':'糕','清':'青','原':'源',
|
|
||||||
'小':'晓','片':'篇','视':'市','频':'贫','道':'到',
|
|
||||||
'动':'洞','画':'话','声':'升','音':'因','文':'闻',
|
|
||||||
'明':'名','暗':'黯','光':'广','影':'映','色':'瑟',
|
|
||||||
'风':'疯','雨':'语','花':'华','国':'果','家':'佳',
|
|
||||||
'战':'站','争':'挣','士':'仕','兵':'宾',
|
|
||||||
'皇':'惶','帝':'谛','魔':'磨','鬼':'诡','怪':'乖',
|
|
||||||
'精':'经','灵':'铃','妖':'夭','武':'舞','侠':'狭',
|
|
||||||
'杀':'刹','血':'雪','刀':'叨','枪':'呛','炮':'泡',
|
|
||||||
'时':'石','空':'孔','前':'钱','后':'厚','东':'冬',
|
|
||||||
'南':'难','西':'夕','北':'备','开':'凯','关':'官',
|
|
||||||
'出':'初','进':'近','去':'趣',
|
|
||||||
'短':'短','多':'多','少':'少','真':'贞','假':'价',
|
|
||||||
'好':'郝','坏':'怀','对':'队','错':'措','以':'已',
|
|
||||||
'从':'从','被':'被','把':'把','将':'将','在':'在',
|
|
||||||
'但':'但','就':'就','才':'才','也':'也','很':'狠',
|
|
||||||
'又':'又','再':'再','更':'更','最':'最','总':'总',
|
|
||||||
'共':'共','只':'只','各':'各','每':'每','任':'任',
|
|
||||||
'所':'所','该':'该','本':'本',
|
|
||||||
};
|
|
||||||
|
|
||||||
const NOISE_CJK = '的了在是不有会可对所之也同与及但或如且乃而岂乎焉兮哉亦犹尚乃其若故盖诸焉欤' +
|
|
||||||
'么个着过把对为从以到说时要就这那和上人家下能出得发来年心开物力些长样吧啊哦嗯嚯哇咯呗哟嘿呵哈';
|
|
||||||
|
|
||||||
// ==================== Helpers ====================
|
|
||||||
|
|
||||||
/** Convert Chinese text to homophonic (substitute chars with same sound) */
|
|
||||||
function homophonicText(text: string): string {
|
|
||||||
let result = '';
|
|
||||||
for (const ch of text) {
|
|
||||||
if (/[\u4e00-\u9fff]/.test(ch)) {
|
|
||||||
const homophone = HOMOPHONE_MAP[ch];
|
|
||||||
result += homophone || ch;
|
|
||||||
} else {
|
|
||||||
result += ch;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Convert Chinese text to pinyin-initial-like string (each char → first pinyin letter or fallback) */
|
|
||||||
function pinyinLike(text: string): string {
|
|
||||||
let result = '';
|
|
||||||
for (const ch of text) {
|
|
||||||
if (/[\u4e00-\u9fff]/.test(ch)) {
|
|
||||||
const homophone = HOMOPHONE_MAP[ch];
|
|
||||||
if (homophone) {
|
|
||||||
result += pinyinInitial(homophone);
|
|
||||||
} else {
|
|
||||||
const code = ch.charCodeAt(0);
|
|
||||||
result += String.fromCharCode(97 + (code % 26));
|
|
||||||
}
|
|
||||||
} else if (/[a-zA-Z0-9]/.test(ch)) {
|
|
||||||
result += ch;
|
|
||||||
} else if (/[\s._-]/.test(ch)) {
|
|
||||||
result += '_';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result.replace(/_+/g, '_').replace(/^_|_$/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get pinyin initial (first letter of pinyin) for a Chinese character */
|
|
||||||
function pinyinInitial(ch: string): string {
|
|
||||||
const code = ch.charCodeAt(0);
|
|
||||||
if (code >= 0x4E00 && code <= 0x9FFF) {
|
|
||||||
const initials = ['b','p','m','f','d','t','n','l','g','k','h','j','q','x','zh','ch','sh','r','z','c','s','y','w'];
|
|
||||||
const idx = Math.min(Math.floor((code - 0x4E00) / 700), initials.length - 1);
|
|
||||||
return initials[idx];
|
|
||||||
}
|
|
||||||
return ch.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Public API ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Anti-harmony rename for directories.
|
|
||||||
* 80%: light homophonic replacement, 20%: partial pinyin.
|
|
||||||
*/
|
|
||||||
export function magicRenameDir(dirName: string): string {
|
|
||||||
const hash = crypto.createHash('md5').update(dirName + Date.now()).digest('hex').slice(0, 4);
|
|
||||||
|
|
||||||
let cleanName = dirName.trim().replace(/\s+/g, ' ');
|
|
||||||
if (!cleanName) {
|
|
||||||
return `media_${hash}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let baseName: string;
|
|
||||||
|
|
||||||
if (Math.random() < 0.2) {
|
|
||||||
// Partial pinyin: 30% of CJK chars → pinyin initial, 70% stay as-is
|
|
||||||
const chars = [...cleanName];
|
|
||||||
const result: string[] = [];
|
|
||||||
for (const ch of chars) {
|
|
||||||
if (/[\u4e00-\u9fff]/.test(ch) && Math.random() < 0.3) {
|
|
||||||
result.push(pinyinInitial(ch));
|
|
||||||
} else {
|
|
||||||
result.push(ch);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
baseName = result.join('');
|
|
||||||
} else {
|
|
||||||
// Light homophonic: replace each CJK char, keep everything else as-is
|
|
||||||
const chars = [...cleanName];
|
|
||||||
const result: string[] = [];
|
|
||||||
for (const ch of chars) {
|
|
||||||
if (/[\u4e00-\u9fff]/.test(ch)) {
|
|
||||||
result.push(HOMOPHONE_MAP[ch] || ch);
|
|
||||||
} else {
|
|
||||||
result.push(ch);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
baseName = result.join('');
|
|
||||||
|
|
||||||
// Optional: insert 0-2 light noise chars (low probability)
|
|
||||||
const noiseCount = Math.random() < 0.3 ? (Math.random() < 0.5 ? 1 : 2) : 0;
|
|
||||||
for (let n = 0; n < noiseCount; n++) {
|
|
||||||
const pos = Math.floor(Math.random() * (baseName.length + 1));
|
|
||||||
const ink = NOISE_CJK[Math.floor(Math.random() * NOISE_CJK.length)];
|
|
||||||
baseName = baseName.slice(0, pos) + ink + baseName.slice(pos);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
baseName = baseName.replace(/[^\u4e00-\u9fff\w]/g, '_');
|
|
||||||
baseName = baseName.replace(/_+/g, '_').replace(/^_|_$/g, '');
|
|
||||||
if (baseName.length > 30) baseName = baseName.slice(0, 30);
|
|
||||||
|
|
||||||
return `${baseName}_${hash}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Anti-harmony rename for files.
|
|
||||||
* KEEPS: episode numbers, quality, language tags, original extension.
|
|
||||||
* REPLACES: Chinese title with homophonic/pinyin.
|
|
||||||
*/
|
|
||||||
export function magicRename(filename: string): string {
|
|
||||||
const hash = crypto.createHash('md5').update(filename + Date.now()).digest('hex').slice(0, 8);
|
|
||||||
|
|
||||||
let ext = '';
|
|
||||||
const extMatch = filename.match(/\.[a-zA-Z0-9]+$/);
|
|
||||||
if (extMatch) {
|
|
||||||
ext = extMatch[0];
|
|
||||||
filename = filename.slice(0, -ext.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract and REMEMBER: episode info, quality, language, year
|
|
||||||
const episodePatterns = [
|
|
||||||
{ regex: /第\s*(\d+)\s*[集话話話話话回章期]/, format: (m: string) => 'Ep' + m.replace(/[^\d]/g, '') },
|
|
||||||
{ regex: /Ep\d+|ep\d+/i, format: (m: string) => m.toUpperCase() },
|
|
||||||
{ regex: /Part\s*\d+/i, format: (m: string) => m.replace(/\s+/g, '') },
|
|
||||||
{ regex: /E\d{2,}/i, format: (m: string) => m.toUpperCase() },
|
|
||||||
];
|
|
||||||
let episodeTag = '';
|
|
||||||
for (const { regex, format } of episodePatterns) {
|
|
||||||
const m = filename.match(regex);
|
|
||||||
if (m) {
|
|
||||||
episodeTag = format(m[0]);
|
|
||||||
filename = filename.replace(m[0], '');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract and REMEMBER: quality tags
|
|
||||||
const qualityPattern = /\b(4k|1080p|1080P|2160p|720p|HD|BluRay|Blu-ray|HDR|WEB-DL|WEBRip|BDRip|REMUX|DV|Dovi|HEVC|x264|x265|H\.264|H\.265)\b/;
|
|
||||||
const qualityMatch = filename.match(qualityPattern);
|
|
||||||
const qualityTag = qualityMatch ? qualityMatch[0] : '';
|
|
||||||
if (qualityMatch) filename = filename.replace(qualityMatch[0], '');
|
|
||||||
|
|
||||||
// Extract and REMEMBER: language tags
|
|
||||||
const langPattern = /\b(CHS|CHT|JP|EN|BIG5|GB|粤语|国语|日语|英语|中字|日字|英字|繁体中字)\b/;
|
|
||||||
const langMatch = filename.match(langPattern);
|
|
||||||
const langTag = langMatch ? langMatch[0] : '';
|
|
||||||
if (langMatch) filename = filename.replace(langMatch[0], '');
|
|
||||||
|
|
||||||
// Extract and REMEMBER: year
|
|
||||||
const yearMatch = filename.match(/\b(20\d{2})\b/);
|
|
||||||
const yearTag = yearMatch ? yearMatch[0] : '';
|
|
||||||
if (yearMatch) filename = filename.replace(yearMatch[0], '');
|
|
||||||
|
|
||||||
// Extract and REMEMBER: season info
|
|
||||||
const seasonMatch = filename.match(/第?\s*(\d+)\s*[季部期]/);
|
|
||||||
const seasonTag = seasonMatch ? `${seasonMatch[1]}季` : '';
|
|
||||||
if (seasonMatch) filename = filename.replace(seasonMatch[0], '');
|
|
||||||
|
|
||||||
// Now process the remaining name (mostly Chinese title)
|
|
||||||
filename = filename.replace(/[._\-【】\[\]()()\s]+/g, '_').trim();
|
|
||||||
|
|
||||||
const useHomophonic = Math.random() > 0.5;
|
|
||||||
let titlePart: string;
|
|
||||||
if (useHomophonic) {
|
|
||||||
titlePart = homophonicText(filename);
|
|
||||||
titlePart = titlePart.replace(/[^\u4e00-\u9fff\wa-zA-Z0-9]/g, '_');
|
|
||||||
titlePart = titlePart.replace(/_+/g, '_').replace(/^_|_$/g, '');
|
|
||||||
if (titlePart.length > 15) titlePart = titlePart.slice(0, 15);
|
|
||||||
} else {
|
|
||||||
titlePart = pinyinLike(filename);
|
|
||||||
titlePart = titlePart.replace(/[^a-zA-Z0-9]/g, '_');
|
|
||||||
titlePart = titlePart.replace(/_+/g, '_').replace(/^_|_$/g, '');
|
|
||||||
if (titlePart.length > 15) titlePart = titlePart.slice(0, 15);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove sensitive keywords from title part
|
|
||||||
const sensitiveWords = /斗破|完美|凡人|仙逆|遮天|吞噬|大主宰|绝世|武动|星辰变|一念永恒|修罗|神墓|长生|剑来|诡秘|全职|斗罗|盘龙|雪鹰|莽荒纪|天珠变|神印王座|牧神记|沧元图|紫川|百炼成神|大王饶命|全球高考/ig;
|
|
||||||
titlePart = titlePart.replace(sensitiveWords, '');
|
|
||||||
titlePart = titlePart.replace(/_+/g, '_').replace(/^_|_$/g, '');
|
|
||||||
|
|
||||||
// Build preserved tags
|
|
||||||
const tags: string[] = [];
|
|
||||||
if (seasonTag) tags.push(seasonTag);
|
|
||||||
if (episodeTag) tags.push(episodeTag);
|
|
||||||
if (qualityTag) tags.push(qualityTag.toUpperCase());
|
|
||||||
if (langTag) tags.push(langTag);
|
|
||||||
if (yearTag) tags.push(yearTag);
|
|
||||||
tags.push(hash); // Always add hash for uniqueness
|
|
||||||
|
|
||||||
const newExt = ext || '.bin';
|
|
||||||
|
|
||||||
const parts = [titlePart, ...tags].filter(Boolean);
|
|
||||||
let result = parts.join('_');
|
|
||||||
|
|
||||||
if (result.length > 80) {
|
|
||||||
result = result.slice(0, 80);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.length < 10) {
|
|
||||||
const filler = crypto.randomBytes(4).toString('hex');
|
|
||||||
result = `${filler}_${result}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result + newExt;
|
|
||||||
}
|
|
||||||
@@ -1,409 +0,0 @@
|
|||||||
import { getHeaders, getCommonParams, makeQuery, getMparam, humanDelay, randomSharePwd, apiFetch, QuarkFile } from './quark-api';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 分享模块 — 分享链接解析、转存任务、创建分享链接。
|
|
||||||
*/
|
|
||||||
|
|
||||||
const BASE_URL = 'https://drive-pc.quark.cn';
|
|
||||||
|
|
||||||
// ==================== Acquire Stoken ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Acquire stoken for a share link (needed for detail/save).
|
|
||||||
*/
|
|
||||||
export async function acquireStoken(cookie: string, pwdId: string): Promise<string | null> {
|
|
||||||
for (let attempt = 0; attempt < 3; attempt++) {
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams(getCommonParams());
|
|
||||||
const resp = await fetch(
|
|
||||||
`${BASE_URL}/1/clouddrive/share/sharepage/token?${params.toString()}`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: { ...getHeaders(cookie), 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ pwd_id: pwdId, passcode: '' }),
|
|
||||||
signal: AbortSignal.timeout(10000),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!resp.ok) {
|
|
||||||
if (attempt < 2) continue;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const data = await resp.json() as any;
|
|
||||||
if (data.status === 200 && data.data?.stoken) {
|
|
||||||
return data.data.stoken;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch {
|
|
||||||
if (attempt >= 2) return null;
|
|
||||||
await new Promise(r => setTimeout(r, 500 * (attempt + 1)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Get Share Files ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch detail at a given pdir_fid within a share.
|
|
||||||
*/
|
|
||||||
export async function getDetailAt(
|
|
||||||
cookie: string,
|
|
||||||
pwdId: string,
|
|
||||||
stoken: string,
|
|
||||||
pdirFid: string,
|
|
||||||
): Promise<QuarkFile[]> {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
...getCommonParams(),
|
|
||||||
pwd_id: pwdId,
|
|
||||||
stoken,
|
|
||||||
pdir_fid: pdirFid,
|
|
||||||
force: '0',
|
|
||||||
_page: '1',
|
|
||||||
_size: '50',
|
|
||||||
_fetch_banner: '0',
|
|
||||||
_fetch_share: '1',
|
|
||||||
_fetch_total: '1',
|
|
||||||
_sort: 'file_type:asc,updated_at:desc',
|
|
||||||
ver: '2',
|
|
||||||
fetch_share_full_path: '0',
|
|
||||||
});
|
|
||||||
const resp = await fetch(
|
|
||||||
`${BASE_URL}/1/clouddrive/share/sharepage/detail?${params.toString()}`,
|
|
||||||
{ headers: getHeaders(cookie), signal: AbortSignal.timeout(15000) },
|
|
||||||
);
|
|
||||||
if (!resp.ok) return [];
|
|
||||||
const data = await resp.json() as any;
|
|
||||||
if (data.status !== 200) return [];
|
|
||||||
return (data.data?.list || []).filter((f: any) => f.fid).map((f: any) => ({
|
|
||||||
fid: f.fid,
|
|
||||||
file_name: f.file_name,
|
|
||||||
share_fid_token: f.share_fid_token || '',
|
|
||||||
dir: f.dir || false,
|
|
||||||
size: f.size || 0,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recursively collect files from a share.
|
|
||||||
* If the share contains a single directory, drill into it to list contents
|
|
||||||
* but still save the directory itself.
|
|
||||||
*/
|
|
||||||
export async function getShareFiles(
|
|
||||||
cookie: string,
|
|
||||||
pwdId: string,
|
|
||||||
stoken: string,
|
|
||||||
): Promise<{ files: QuarkFile[]; topDir: boolean; childFiles?: QuarkFile[] } | null> {
|
|
||||||
try {
|
|
||||||
const topLevel = await getDetailAt(cookie, pwdId, stoken, '0');
|
|
||||||
if (!topLevel || topLevel.length === 0) return null;
|
|
||||||
|
|
||||||
// If the share is a single directory, we save the directory itself
|
|
||||||
// and fetch its contents for renaming later
|
|
||||||
if (topLevel.length === 1 && topLevel[0].dir) {
|
|
||||||
const innerFiles = await getDetailAt(cookie, pwdId, stoken, topLevel[0].fid);
|
|
||||||
return {
|
|
||||||
files: topLevel,
|
|
||||||
topDir: true,
|
|
||||||
childFiles: innerFiles || [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Multiple top-level items: save them directly
|
|
||||||
return {
|
|
||||||
files: topLevel,
|
|
||||||
topDir: false,
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Save Files (share → cloud) ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save shared files to the user's cloud directory.
|
|
||||||
*/
|
|
||||||
export async function saveFiles(
|
|
||||||
cookie: string,
|
|
||||||
pwdId: string,
|
|
||||||
stoken: string,
|
|
||||||
fids: string[],
|
|
||||||
fidTokens: string[],
|
|
||||||
toPdirFid: string,
|
|
||||||
): Promise<{ success: boolean; message: string; taskId?: string }> {
|
|
||||||
try {
|
|
||||||
const resp = await fetch(
|
|
||||||
`${BASE_URL}/1/clouddrive/share/sharepage/save?${makeQuery()}`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: { ...getHeaders(cookie), 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
fid_list: fids,
|
|
||||||
fid_token_list: fidTokens,
|
|
||||||
to_pdir_fid: toPdirFid,
|
|
||||||
pwd_id: pwdId,
|
|
||||||
stoken,
|
|
||||||
pdir_fid: '0',
|
|
||||||
scene: 'link',
|
|
||||||
}),
|
|
||||||
signal: AbortSignal.timeout(30000),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const data = await resp.json() as any;
|
|
||||||
if (data.status === 200 && data.data?.task_id) {
|
|
||||||
return { success: true, message: 'Save task created', taskId: data.data.task_id };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: data.message === 'require login [guest]'
|
|
||||||
? '夸克网盘 Cookie 已过期,请在后台重新配置 Cookie'
|
|
||||||
: (data.message || `API 返回错误 (status=${data.status}, code=${data.code})`),
|
|
||||||
};
|
|
||||||
} catch (err: any) {
|
|
||||||
return { success: false, message: err.message || 'Network error' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Wait for Save Task ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Poll task status until complete or timeout.
|
|
||||||
* Returns the saved file FIDs (save_as_top_fids).
|
|
||||||
*/
|
|
||||||
export async function waitForTask(cookie: string, taskId: string, timeoutMs: number): Promise<string[] | null> {
|
|
||||||
const start = Date.now();
|
|
||||||
let retryIndex = 0;
|
|
||||||
|
|
||||||
while (Date.now() - start < timeoutMs) {
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
...getCommonParams(),
|
|
||||||
uc_param_str: '',
|
|
||||||
task_id: taskId,
|
|
||||||
retry_index: String(retryIndex),
|
|
||||||
__dt: String(Math.floor(Math.random() * 240000 + 60000)),
|
|
||||||
__t: String(Date.now() / 1000),
|
|
||||||
});
|
|
||||||
const resp = await fetch(
|
|
||||||
`${BASE_URL}/1/clouddrive/task?${params.toString()}`,
|
|
||||||
{ headers: getHeaders(cookie), signal: AbortSignal.timeout(10000) },
|
|
||||||
);
|
|
||||||
const data = await resp.json() as any;
|
|
||||||
if (data.status === 200) {
|
|
||||||
if (data.data?.status === 2) {
|
|
||||||
// Task completed
|
|
||||||
const savedFids: string[] = data.data?.save_as?.save_as_top_fids || [];
|
|
||||||
return savedFids;
|
|
||||||
}
|
|
||||||
// Still in progress
|
|
||||||
retryIndex++;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Network error, retry
|
|
||||||
}
|
|
||||||
await new Promise(r => setTimeout(r, 1000));
|
|
||||||
}
|
|
||||||
return null; // Timeout
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Rename File ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rename a file by its FID.
|
|
||||||
*/
|
|
||||||
export async function renameFile(cookie: string, fid: string, newName: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const resp = await fetch(
|
|
||||||
`${BASE_URL}/1/clouddrive/file/rename?${makeQuery()}`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: { ...getHeaders(cookie), 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ fid, file_name: newName }),
|
|
||||||
signal: AbortSignal.timeout(10000),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const data = await resp.json() as any;
|
|
||||||
return data.status === 200 || data.code === 0;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Create Share Link ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a share link for a file/folder.
|
|
||||||
* Flow: create task → poll for share_id → submit to get short URL.
|
|
||||||
*/
|
|
||||||
export async function createShareLink(cookie: string, fileId: string): Promise<{ success: boolean; shareUrl?: string; sharePwd?: string; message: string }> {
|
|
||||||
try {
|
|
||||||
const sharePwd = randomSharePwd();
|
|
||||||
|
|
||||||
// Try different share_type values (1=7天, 0=无限制)
|
|
||||||
const shareTypes = ['1', '0'];
|
|
||||||
let lastError = '';
|
|
||||||
|
|
||||||
for (const st of shareTypes) {
|
|
||||||
await humanDelay();
|
|
||||||
// Step 1: Create share task - get task_id
|
|
||||||
const response = await fetch(
|
|
||||||
`${BASE_URL}/1/clouddrive/share?${makeQuery()}`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: { ...getHeaders(cookie), 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
fid_list: [fileId],
|
|
||||||
share_type: st,
|
|
||||||
url_type: '1',
|
|
||||||
share_pwd: sharePwd,
|
|
||||||
}),
|
|
||||||
signal: AbortSignal.timeout(15000),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const data = await response.json() as any;
|
|
||||||
const taskId = data.data?.task_id;
|
|
||||||
if (!taskId) {
|
|
||||||
lastError = data.message || `share_type=${st} 失败`;
|
|
||||||
console.error('[Quark] Create share task failed (type=%s):', st, data.message || JSON.stringify(data).slice(0, 200));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Poll task until complete
|
|
||||||
const result = await waitForShareTask(cookie, taskId, 20000);
|
|
||||||
if (!result?.shareId) {
|
|
||||||
lastError = result?.message || '任务超时';
|
|
||||||
console.error('[Quark] Wait for share task failed (type=%s):', st, result?.message || 'unknown');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Submit share via /password endpoint
|
|
||||||
const shareUrl = await submitShare(cookie, result.shareId, sharePwd);
|
|
||||||
if (shareUrl) {
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
shareUrl,
|
|
||||||
sharePwd,
|
|
||||||
message: `分享链接已生成(密码:${sharePwd})`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
lastError = '提交密码后未获取到短链接';
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: false, message: lastError || '🤷 各种姿势都试过了,就是分享不出来…' };
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('[Quark] createShareLink error:', err.message);
|
|
||||||
return { success: false, message: err.message || '🌩️ 网络开小差了,再试试?' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Submit share via /password endpoint to get the actual short URL.
|
|
||||||
*/
|
|
||||||
async function submitShare(cookie: string, shareId: string, sharePwd?: string): Promise<string | null> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`${BASE_URL}/1/clouddrive/share/password?${makeQuery()}`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: { ...getHeaders(cookie), 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ share_id: shareId, share_pwd: sharePwd || '' }),
|
|
||||||
signal: AbortSignal.timeout(15000),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const data = await response.json() as any;
|
|
||||||
if (data.status === 200 && data.data?.share_url) {
|
|
||||||
console.log('[Quark] Share short URL:', data.data.share_url);
|
|
||||||
return data.data.share_url;
|
|
||||||
}
|
|
||||||
console.log('[Quark] /password response:', JSON.stringify(data).slice(0, 300));
|
|
||||||
console.error('[Quark] /password FAIL status=%s msg=%s', data.status, data.message || '');
|
|
||||||
return null;
|
|
||||||
} catch (err) {
|
|
||||||
console.log('[Quark] /password error:', err);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Poll share task until complete and extract share URL/shortcode.
|
|
||||||
*/
|
|
||||||
async function waitForShareTask(cookie: string, taskId: string, timeoutMs: number): Promise<{ shareId?: string; message?: string } | null> {
|
|
||||||
const start = Date.now();
|
|
||||||
let retryIndex = 0;
|
|
||||||
while (Date.now() - start < timeoutMs) {
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
...getCommonParams(),
|
|
||||||
uc_param_str: '',
|
|
||||||
task_id: taskId,
|
|
||||||
retry_index: String(retryIndex),
|
|
||||||
__dt: String(Math.floor(Math.random() * 240000 + 60000)),
|
|
||||||
__t: String(Date.now() / 1000),
|
|
||||||
});
|
|
||||||
const resp = await fetch(
|
|
||||||
`${BASE_URL}/1/clouddrive/task?${params.toString()}`,
|
|
||||||
{ headers: getHeaders(cookie), signal: AbortSignal.timeout(10000) },
|
|
||||||
);
|
|
||||||
const data = await resp.json() as any;
|
|
||||||
if (data.data?.status === 2) {
|
|
||||||
// Task completed — try multiple extraction approaches
|
|
||||||
// 1. Direct share_url field
|
|
||||||
if (data.data?.share_url) {
|
|
||||||
const match = data.data.share_url.match(/\/s\/([a-zA-Z0-9]+)/);
|
|
||||||
if (match) return { shareId: match[1] };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Nested share object
|
|
||||||
if (data.data?.share?.url) {
|
|
||||||
const match = data.data.share.url.match(/\/s\/([a-zA-Z0-9]+)/);
|
|
||||||
if (match) return { shareId: match[1] };
|
|
||||||
}
|
|
||||||
if (data.data?.share?.short_url) {
|
|
||||||
const match = data.data.share.short_url.match(/\/s\/([a-zA-Z0-9]+)/);
|
|
||||||
if (match) return { shareId: match[1] };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. share_id — validate it's a reasonable short code (8-20 chars, not UUID-like)
|
|
||||||
const shareId = data.data?.share_id;
|
|
||||||
if (shareId && shareId.length <= 20 && shareId.length >= 8) {
|
|
||||||
return { shareId };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Regex search through the full response for a URL pattern
|
|
||||||
const str = JSON.stringify(data);
|
|
||||||
const urlMatch = str.match(/https?:\/\/pan\.quark\.cn\/s\/([a-zA-Z0-9]{6,16})/);
|
|
||||||
if (urlMatch) {
|
|
||||||
return { shareId: urlMatch[1] };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Extract from any URL field in the response
|
|
||||||
const urlFields = ['url', 'link', 'share_url', 'short_url', 'share_link'];
|
|
||||||
for (const field of urlFields) {
|
|
||||||
const val = data.data?.[field] || data.data?.share?.[field];
|
|
||||||
if (typeof val === 'string' && val.includes('pan.quark.cn/s/')) {
|
|
||||||
const m = val.match(/\/s\/([a-zA-Z0-9]+)/);
|
|
||||||
if (m) return { shareId: m[1] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Log full share task response for debugging
|
|
||||||
console.log('[Quark] Full share task response:', JSON.stringify(data, null, 2).slice(0, 2000));
|
|
||||||
|
|
||||||
// 7. Even if shareId is UUID-like (32 hex chars), use it anyway as last resort
|
|
||||||
if (shareId) {
|
|
||||||
return { shareId };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { message: 'Share task completed but no share URL found' };
|
|
||||||
}
|
|
||||||
if (data.data?.status === 3) {
|
|
||||||
return { message: data.message || 'Share task failed' };
|
|
||||||
}
|
|
||||||
retryIndex++;
|
|
||||||
} catch {
|
|
||||||
// Retry
|
|
||||||
}
|
|
||||||
await new Promise(r => setTimeout(r, 1000));
|
|
||||||
}
|
|
||||||
return { message: 'Share task timed out' };
|
|
||||||
}
|
|
||||||
@@ -1,308 +0,0 @@
|
|||||||
import { getHeaders, getCommonParams, makeQuery, getMparam, humanDelay, dailyFolderName, formatBytes, apiFetch, listDir, listDirAllPages, listRootDir, QuarkFile } from './quark-api';
|
|
||||||
import { acquireStoken, getShareFiles, saveFiles, waitForTask } from './quark-share';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 转存 & 存储管理模块。
|
|
||||||
* 处理分享链接解析 → 转存 → 查/创建目标文件夹 → 文件重命名 → 递归统计。
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ==================== saveFromShare — 核心转存流水线 ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save files from a share link → magic rename → create shared link.
|
|
||||||
*
|
|
||||||
* Flow: token → detail → save → wait_task → rename → share
|
|
||||||
*/
|
|
||||||
export async function saveFromShare(
|
|
||||||
cookie: string,
|
|
||||||
nickname: string | undefined,
|
|
||||||
shareUrl: string,
|
|
||||||
sourceTitle?: string,
|
|
||||||
): Promise<{
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
shareUrl?: string;
|
|
||||||
sharePwd?: string;
|
|
||||||
folderName?: string;
|
|
||||||
taskId?: string;
|
|
||||||
renamed?: string[];
|
|
||||||
fileCount?: number;
|
|
||||||
folderCount?: number;
|
|
||||||
originalFolderName?: string;
|
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
// Parse share token from URL
|
|
||||||
const urlObj = new URL(shareUrl);
|
|
||||||
const pwdId = urlObj.pathname.split('/').filter(Boolean).pop();
|
|
||||||
if (!pwdId) {
|
|
||||||
return { success: false, message: 'Invalid share URL: could not extract share token' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: Acquire stoken
|
|
||||||
const stoken = await acquireStoken(cookie, pwdId);
|
|
||||||
if (!stoken) {
|
|
||||||
return { success: false, message: '😅 Oops!资源好像偷偷溜走了,换个链接试试吧~' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Get share detail
|
|
||||||
const shareInfo = await getShareFiles(cookie, pwdId, stoken);
|
|
||||||
if (!shareInfo || !shareInfo.files || shareInfo.files.length === 0) {
|
|
||||||
return { success: false, message: '🌚 空的!这个分享里啥都没有…' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const { files: topFiles, topDir, childFiles } = shareInfo;
|
|
||||||
const originalFolderName = topFiles[0]?.file_name || '';
|
|
||||||
const fids = topFiles.map(f => f.fid);
|
|
||||||
const fidTokens = topFiles.map(f => f.share_fid_token);
|
|
||||||
|
|
||||||
// 按日期创建/查找文件夹,每天的转存存入当天文件夹
|
|
||||||
await humanDelay();
|
|
||||||
const saveDirName = dailyFolderName();
|
|
||||||
console.log(`[Quark] saveFromShare: looking for/create dir "${saveDirName}"`);
|
|
||||||
const saveDirFid = await findOrCreateDir(cookie, saveDirName);
|
|
||||||
const targetPdirFid = saveDirFid || '0';
|
|
||||||
if (saveDirFid) {
|
|
||||||
console.log(`[Quark] Using save directory: ${saveDirName} (fid: ${saveDirFid})`);
|
|
||||||
} else {
|
|
||||||
console.log(`[Quark] WARNING: failed to create/find dir "${saveDirName}", saving to root`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Save top-level item(s) to the target directory
|
|
||||||
const saveResult = await saveFiles(cookie, pwdId, stoken, fids, fidTokens.filter(Boolean) as string[], targetPdirFid);
|
|
||||||
if (!saveResult.success) {
|
|
||||||
return saveResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
const taskId = saveResult.taskId!;
|
|
||||||
|
|
||||||
// Step 4: Wait for save task to complete (poll up to 30s)
|
|
||||||
const savedFids = await waitForTask(cookie, taskId, 30000);
|
|
||||||
if (!savedFids || savedFids.length === 0) {
|
|
||||||
return { success: true, message: '文件已保存,但获取保存结果超时' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 5: Magic rename files — with random delay to avoid detection
|
|
||||||
await humanDelay();
|
|
||||||
const renamed: Array<{ original: string; renamed: string }> = [];
|
|
||||||
let shareFid = '';
|
|
||||||
let savedFolderName = '';
|
|
||||||
let newInnerDirName = '';
|
|
||||||
|
|
||||||
if (topDir && childFiles && childFiles.length > 0) {
|
|
||||||
// ── Single folder share ──
|
|
||||||
const savedDirFid = savedFids[0];
|
|
||||||
shareFid = savedDirFid;
|
|
||||||
savedFolderName = topFiles[0]?.file_name || '';
|
|
||||||
} else {
|
|
||||||
// ── Multiple files at top level ──
|
|
||||||
shareFid = savedFids[0];
|
|
||||||
savedFolderName = topFiles[0]?.file_name || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 6: Create share link FIRST (before rename), so all files are guaranteed to be shared
|
|
||||||
await humanDelay();
|
|
||||||
let shareUrlResult = '';
|
|
||||||
let sharePwdResult = '';
|
|
||||||
let shareMsg = '';
|
|
||||||
let successCount = 0; // total items (files + folders) actually saved
|
|
||||||
|
|
||||||
const { createShareLink } = await import('./quark-share');
|
|
||||||
if (shareFid) {
|
|
||||||
const shareResult = await createShareLink(cookie, shareFid);
|
|
||||||
if (shareResult.success && shareResult.shareUrl) {
|
|
||||||
shareUrlResult = shareResult.shareUrl;
|
|
||||||
if (shareResult.sharePwd) sharePwdResult = shareResult.sharePwd;
|
|
||||||
} else {
|
|
||||||
shareMsg = `(分享失败:${shareResult.message})`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { magicRenameDir, magicRename } = await import('./quark-rename');
|
|
||||||
const { renameFile } = await import('./quark-share');
|
|
||||||
|
|
||||||
// Step 7: Rename files AFTER creating the share link (anti-harmony, won't affect the share)
|
|
||||||
if (topDir && childFiles && childFiles.length > 0) {
|
|
||||||
// ── Single folder share ──
|
|
||||||
const savedDirFid = savedFids[0];
|
|
||||||
|
|
||||||
// List files inside the saved directory
|
|
||||||
const dirFiles = await listDir(cookie, savedDirFid);
|
|
||||||
if (dirFiles && dirFiles.length > 0) {
|
|
||||||
for (const file of dirFiles) {
|
|
||||||
if (file.dir) continue;
|
|
||||||
const newName = magicRename(file.file_name);
|
|
||||||
const renameOk = await renameFile(cookie, file.fid, newName);
|
|
||||||
if (renameOk) {
|
|
||||||
renamed.push({ original: file.file_name, renamed: newName });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also rename the inner folder itself (the actual shared folder)
|
|
||||||
const innerDirOriginalName = sourceTitle || topFiles[0]?.file_name || '';
|
|
||||||
if (innerDirOriginalName) {
|
|
||||||
newInnerDirName = magicRenameDir(innerDirOriginalName);
|
|
||||||
const innerDirRenameOk = await renameFile(cookie, savedDirFid, newInnerDirName);
|
|
||||||
if (innerDirRenameOk) {
|
|
||||||
console.log(`[Quark] Renamed inner folder: ${innerDirOriginalName} → ${newInnerDirName}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// ── Multiple files at top level ──
|
|
||||||
for (let i = 0; i < savedFids.length && i < topFiles.length; i++) {
|
|
||||||
const originalName = topFiles[i].file_name;
|
|
||||||
if (topFiles[i].dir) continue;
|
|
||||||
const newName = magicRename(originalName);
|
|
||||||
const renameOk = await renameFile(cookie, savedFids[i], newName);
|
|
||||||
if (renameOk) {
|
|
||||||
renamed.push({ original: originalName, renamed: newName });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 7.5: 广告关键词清理 + 创建警示文件夹
|
|
||||||
if (shareFid) {
|
|
||||||
try {
|
|
||||||
const { runAdCleanup } = await import('./quark-ad-cleanup');
|
|
||||||
const adResult = await runAdCleanup(cookie, shareFid);
|
|
||||||
if (adResult.adDeleted > 0) {
|
|
||||||
console.log(`[Quark] 广告清理完成: 删除了 ${adResult.adDeleted} 个广告文件/文件夹`);
|
|
||||||
}
|
|
||||||
if (adResult.warningDirs > 0) {
|
|
||||||
console.log(`[Quark] 已创建 ${adResult.warningDirs} 个警示文件夹`);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
console.log(`[Quark] 广告清理/警示文件夹创建失败(非致命): ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 8: DAY FOLDER STAYS AS-IS (e.g. "2026-05-03")
|
|
||||||
// DO NOT rename the date folder — it serves as the organizational container.
|
|
||||||
savedFolderName = newInnerDirName ? `${saveDirName}/${newInnerDirName}` : saveDirName;
|
|
||||||
|
|
||||||
// Recursively count files and folders from saved cloud directory
|
|
||||||
let fileCount = 0;
|
|
||||||
let folderCount = 0;
|
|
||||||
if (shareFid) {
|
|
||||||
try {
|
|
||||||
const counts = await countRecursive(cookie, shareFid);
|
|
||||||
fileCount = counts.fileCount;
|
|
||||||
folderCount = counts.folderCount;
|
|
||||||
} catch {
|
|
||||||
console.log('[Quark] Recursive count failed, using fallback');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If recursive count returned nothing, try fallback
|
|
||||||
if (fileCount === 0 && folderCount === 0) {
|
|
||||||
if (topDir && childFiles) {
|
|
||||||
folderCount = 1 + childFiles.filter(f => f.dir).length;
|
|
||||||
fileCount = childFiles.filter(f => !f.dir).length;
|
|
||||||
} else {
|
|
||||||
folderCount = topFiles.filter(f => f.dir).length;
|
|
||||||
fileCount = topFiles.filter(f => !f.dir).length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const renameMsg = renamed.length > 0
|
|
||||||
? `,已重命名 ${renamed.length} 个文件`
|
|
||||||
: '';
|
|
||||||
const folderMsg = savedFolderName ? `到文件夹「${savedFolderName}」` : '';
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: `已保存${folderMsg}${renameMsg}${shareMsg}`,
|
|
||||||
shareUrl: shareUrlResult || undefined,
|
|
||||||
sharePwd: sharePwdResult || undefined,
|
|
||||||
folderName: savedFolderName,
|
|
||||||
taskId,
|
|
||||||
renamed: renamed.map(r => `${r.original} → ${r.renamed}`),
|
|
||||||
fileCount,
|
|
||||||
folderCount,
|
|
||||||
originalFolderName,
|
|
||||||
};
|
|
||||||
} catch (err: any) {
|
|
||||||
return { success: false, message: err.message || 'Network error' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Dir Management ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new directory at root.
|
|
||||||
*/
|
|
||||||
export async function createDir(cookie: string, dirName: string): Promise<string | null> {
|
|
||||||
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: '0',
|
|
||||||
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] Created dir "${dirName}" (fid: ${data.data.fid})`);
|
|
||||||
return data.data.fid;
|
|
||||||
}
|
|
||||||
console.log(`[Quark] createDir API returned non-200: status=${data.status} msg=${data.message}`);
|
|
||||||
return null;
|
|
||||||
} catch (err: any) {
|
|
||||||
console.log(`[Quark] createDir error: ${err.message}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find an existing directory by name, or create it if not found.
|
|
||||||
*/
|
|
||||||
export async function findOrCreateDir(cookie: string, dirName: string): Promise<string | null> {
|
|
||||||
try {
|
|
||||||
const rootFiles = await listDirAllPages(cookie, '0');
|
|
||||||
const existing = rootFiles.find(f => f.dir && f.file_name === dirName);
|
|
||||||
if (existing?.fid) {
|
|
||||||
console.log(`[Quark] Found existing daily folder: ${dirName} (fid: ${existing.fid})`);
|
|
||||||
return existing.fid;
|
|
||||||
}
|
|
||||||
console.log(`[Quark] Daily folder "${dirName}" not found, creating...`);
|
|
||||||
} catch (err: any) {
|
|
||||||
console.log(`[Quark] findOrCreateDir list error: ${err.message}`);
|
|
||||||
}
|
|
||||||
const fid = await createDir(cookie, dirName);
|
|
||||||
console.log(`[Quark] createDir result for "${dirName}": ${fid || 'null'}`);
|
|
||||||
return fid;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Recursive Count ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recursively count files and folders for a saved cloud directory.
|
|
||||||
*/
|
|
||||||
export async function countRecursive(cookie: string, pdirFid: string): Promise<{ fileCount: number; folderCount: number }> {
|
|
||||||
let fileCount = 0;
|
|
||||||
let folderCount = 0;
|
|
||||||
const stack = [pdirFid];
|
|
||||||
const visited = new Set<string>();
|
|
||||||
while (stack.length > 0) {
|
|
||||||
const fid = stack.pop()!;
|
|
||||||
if (visited.has(fid)) continue;
|
|
||||||
visited.add(fid);
|
|
||||||
const files = await listDir(cookie, fid);
|
|
||||||
if (!files) continue;
|
|
||||||
for (const f of files) {
|
|
||||||
if (f.dir) {
|
|
||||||
folderCount++;
|
|
||||||
stack.push(f.fid);
|
|
||||||
} else {
|
|
||||||
fileCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { fileCount, folderCount };
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
/**
|
|
||||||
* QuarkDriver — 夸克网盘统一驱动
|
|
||||||
*
|
|
||||||
* 为保持向后兼容性,此类将所有方法委托到子模块。
|
|
||||||
* 新代码应直接导入子模块函数。
|
|
||||||
*
|
|
||||||
* 模块结构:
|
|
||||||
* quark-api.ts — HTTP 封装、headers、params、共享工具函数
|
|
||||||
* quark-auth.ts — Cookie 验证
|
|
||||||
* quark-storage.ts — 转存流水线、目录管理、递归统计
|
|
||||||
* quark-share.ts — 分享链接解析、转存任务、创建分享链接
|
|
||||||
* quark-rename.ts — 防和谐重命名(文件名/目录名)
|
|
||||||
* quark-cleanup.ts — 容量信息、空间清理
|
|
||||||
* quark-driver.ts — 统一导出类(兼容旧代码)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { QuarkConfig } from './quark-api';
|
|
||||||
import { validate } from './quark-auth';
|
|
||||||
import { saveFromShare, createDir, findOrCreateDir, countRecursive } from './quark-storage';
|
|
||||||
import { createShareLink, renameFile } from './quark-share';
|
|
||||||
import {
|
|
||||||
getStorageInfoQuick, getStorageInfo,
|
|
||||||
calculateUsedSpace, trashFiles, emptyTrash,
|
|
||||||
cleanupOldDateFolders, cleanupBySpaceThreshold,
|
|
||||||
} from './quark-cleanup';
|
|
||||||
|
|
||||||
export type { QuarkConfig, QuarkFile } from './quark-api';
|
|
||||||
export * from './quark-api';
|
|
||||||
export * from './quark-auth';
|
|
||||||
export * from './quark-storage';
|
|
||||||
export * from './quark-share';
|
|
||||||
export * from './quark-rename';
|
|
||||||
export * from './quark-cleanup';
|
|
||||||
|
|
||||||
export { validate } from './quark-auth';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* QuarkDriver — 向后兼容的驱动类。
|
|
||||||
* 所有方法委托到纯函数模块,不持有状态。
|
|
||||||
*/
|
|
||||||
export class QuarkDriver {
|
|
||||||
private config: QuarkConfig;
|
|
||||||
|
|
||||||
constructor(config: QuarkConfig) {
|
|
||||||
this.config = config;
|
|
||||||
}
|
|
||||||
|
|
||||||
get cookie(): string {
|
|
||||||
return this.config.cookie;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Auth ====================
|
|
||||||
|
|
||||||
async validate(): Promise<boolean> {
|
|
||||||
return validate(this.config.cookie);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Storage (Save from Share) ====================
|
|
||||||
|
|
||||||
async saveFromShare(shareUrl: string, sourceTitle?: string) {
|
|
||||||
return saveFromShare(this.config.cookie, this.config.nickname, shareUrl, sourceTitle);
|
|
||||||
}
|
|
||||||
|
|
||||||
async createDir(dirName: string): Promise<string | null> {
|
|
||||||
return createDir(this.config.cookie, dirName);
|
|
||||||
}
|
|
||||||
|
|
||||||
async findOrCreateDir(dirName: string): Promise<string | null> {
|
|
||||||
return findOrCreateDir(this.config.cookie, dirName);
|
|
||||||
}
|
|
||||||
|
|
||||||
async countRecursive(pdirFid: string) {
|
|
||||||
return countRecursive(this.config.cookie, pdirFid);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Share ====================
|
|
||||||
|
|
||||||
async createShareLink(fileId: string) {
|
|
||||||
return createShareLink(this.config.cookie, fileId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async renameFile(fid: string, newName: string): Promise<boolean> {
|
|
||||||
return renameFile(this.config.cookie, fid, newName);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Storage Info ====================
|
|
||||||
|
|
||||||
async getStorageInfoQuick() {
|
|
||||||
return getStorageInfoQuick(this.config.cookie);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getStorageInfo() {
|
|
||||||
return getStorageInfo(this.config.cookie);
|
|
||||||
}
|
|
||||||
|
|
||||||
async calculateUsedSpace(): Promise<number> {
|
|
||||||
return calculateUsedSpace(this.config.cookie);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Cleanup ====================
|
|
||||||
|
|
||||||
async listRootDir() {
|
|
||||||
const { listRootDir } = await import('./quark-api');
|
|
||||||
return listRootDir(this.config.cookie);
|
|
||||||
}
|
|
||||||
|
|
||||||
async trashFiles(fids: string[]): Promise<boolean> {
|
|
||||||
return trashFiles(this.config.cookie, fids);
|
|
||||||
}
|
|
||||||
|
|
||||||
async emptyTrash(): Promise<boolean> {
|
|
||||||
return emptyTrash(this.config.cookie);
|
|
||||||
}
|
|
||||||
|
|
||||||
async cleanupOldDateFolders(days: number) {
|
|
||||||
return cleanupOldDateFolders(this.config.cookie, days);
|
|
||||||
}
|
|
||||||
|
|
||||||
async cleanupBySpaceThreshold(thresholdPercent: number, deletePercent: number) {
|
|
||||||
return cleanupBySpaceThreshold(this.config.cookie, thresholdPercent, deletePercent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
// Native fetch available in Node 20+
|
|
||||||
import { getSystemConfig } from '../admin/system-config.service';
|
|
||||||
|
|
||||||
type NotifyLevel = 'info' | 'warn' | 'error';
|
|
||||||
|
|
||||||
interface NotifyChannel {
|
|
||||||
send(title: string, content: string, level: NotifyLevel): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Feishu Webhook Channel ----
|
|
||||||
class FeishuChannel implements NotifyChannel {
|
|
||||||
private webhookUrl: string;
|
|
||||||
|
|
||||||
constructor(webhookUrl: string) {
|
|
||||||
this.webhookUrl = webhookUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
async send(title: string, content: string, _level: NotifyLevel): Promise<void> {
|
|
||||||
try {
|
|
||||||
const body = JSON.stringify({
|
|
||||||
msg_type: 'interactive',
|
|
||||||
card: {
|
|
||||||
header: {
|
|
||||||
title: { tag: 'plain_text', content: title },
|
|
||||||
template: _level === 'error' ? 'red' : _level === 'warn' ? 'orange' : 'blue',
|
|
||||||
},
|
|
||||||
elements: [
|
|
||||||
{ tag: 'div', text: { tag: 'lark_md', content } },
|
|
||||||
{
|
|
||||||
tag: 'note',
|
|
||||||
elements: [
|
|
||||||
{ tag: 'plain_text', content: `CloudSearch · ${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}` },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const resp = await fetch(this.webhookUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!resp.ok) {
|
|
||||||
console.error(`[Notify] Feishu send failed: ${resp.status}`);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('[Notify] Feishu send error:', err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Notification Manager ----
|
|
||||||
let _channel: NotifyChannel | null = null;
|
|
||||||
|
|
||||||
function getChannel(): NotifyChannel | null {
|
|
||||||
const feishuUrl = process.env.FEISHU_WEBHOOK || getSystemConfig('feishu_webhook_url');
|
|
||||||
if (!feishuUrl) return null;
|
|
||||||
|
|
||||||
if (!_channel) {
|
|
||||||
_channel = new FeishuChannel(feishuUrl);
|
|
||||||
console.log('[Notify] Feishu webhook configured');
|
|
||||||
}
|
|
||||||
return _channel;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a notification through configured channels.
|
|
||||||
* Returns immediately — failures are logged silently.
|
|
||||||
*/
|
|
||||||
export function notify(title: string, content: string, level: NotifyLevel = 'info'): void {
|
|
||||||
const ch = getChannel();
|
|
||||||
if (!ch) return;
|
|
||||||
// Fire-and-forget — don't block the caller
|
|
||||||
ch.send(title, content, level).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Notify on critical events:
|
|
||||||
* - Cookie expired / login failed
|
|
||||||
* - Save/transfer failed repeatedly
|
|
||||||
* - Storage below threshold
|
|
||||||
*/
|
|
||||||
export function notifyError(title: string, detail: string): void {
|
|
||||||
notify(`⚠️ ${title}`, detail, 'error');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function notifyWarn(title: string, detail: string): void {
|
|
||||||
notify(`🔔 ${title}`, detail, 'warn');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function notifyInfo(title: string, detail: string): void {
|
|
||||||
notify(`ℹ️ ${title}`, detail, 'info');
|
|
||||||
}
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
// Native fetch available in Node 20+
|
|
||||||
import * as crypto from 'crypto';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* HTTP 封装层 — 统一处理夸克 API 的请求签名、headers、query params。
|
|
||||||
* 所有模块共用此单例/函数集,不持有状态。
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface QuarkConfig {
|
|
||||||
cookie: string;
|
|
||||||
nickname?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Headers & Params ====================
|
|
||||||
|
|
||||||
const BASE_URL = 'https://drive-pc.quark.cn';
|
|
||||||
|
|
||||||
export function getHeaders(cookie: string): Record<string, string> {
|
|
||||||
return {
|
|
||||||
'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',
|
|
||||||
'Cookie': cookie,
|
|
||||||
'Accept': 'application/json, text/plain, */*',
|
|
||||||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
|
||||||
'Referer': 'https://pan.quark.cn/',
|
|
||||||
'Origin': 'https://pan.quark.cn',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCommonParams(): Record<string, string> {
|
|
||||||
return { pr: 'ucpro', fr: 'pc' };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Generate query string with common params + random timing to mimic browser */
|
|
||||||
export function makeQuery(extra: Record<string, string> = {}): string {
|
|
||||||
const __dt = Math.floor(Math.random() * 240000 + 60000);
|
|
||||||
const __t = Date.now() / 1000;
|
|
||||||
return new URLSearchParams({
|
|
||||||
...getCommonParams(),
|
|
||||||
uc_param_str: '',
|
|
||||||
app: 'clouddrive',
|
|
||||||
__dt: String(__dt),
|
|
||||||
__t: String(__t),
|
|
||||||
...extra,
|
|
||||||
}).toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Random delay to mimic human behavior (500-2000ms) */
|
|
||||||
export async function humanDelay(): Promise<void> {
|
|
||||||
const ms = Math.floor(Math.random() * 1500) + 500;
|
|
||||||
await new Promise(r => setTimeout(r, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Generate a random password for share links */
|
|
||||||
export function randomSharePwd(): string {
|
|
||||||
return Math.floor(1000 + Math.random() * 9000).toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract kps/sign/vcode from cookie for API signing (bare keys, no __ prefix).
|
|
||||||
*/
|
|
||||||
export function getMparam(cookie: string): { kps?: string; sign?: string; vcode?: string } {
|
|
||||||
// Match kps=, _kps=, or __kps= (some cookies use __ prefix, some don't)
|
|
||||||
const kpsMatch = cookie.match(/_{0,2}kps=([a-zA-Z0-9%+/=]+)/);
|
|
||||||
const signMatch = cookie.match(/_{0,2}sign=([a-zA-Z0-9%+/=]+)/);
|
|
||||||
const vcodeMatch = cookie.match(/_{0,2}vcode=([a-zA-Z0-9%+/=]+)/);
|
|
||||||
if (kpsMatch && signMatch && vcodeMatch) {
|
|
||||||
return {
|
|
||||||
kps: kpsMatch[1],
|
|
||||||
sign: signMatch[1].replace(/%25/g, '%'),
|
|
||||||
vcode: vcodeMatch[1],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Shared fetch helpers ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Raw fetch wrapper with JSON parse + status check.
|
|
||||||
* Returns parsed JSON body on 2xx, null on network error.
|
|
||||||
*/
|
|
||||||
export async function apiFetch<T = any>(
|
|
||||||
path: string,
|
|
||||||
options: {
|
|
||||||
method?: string;
|
|
||||||
query?: Record<string, string>;
|
|
||||||
body?: any;
|
|
||||||
cookie: string;
|
|
||||||
timeout?: number;
|
|
||||||
},
|
|
||||||
): Promise<T | null> {
|
|
||||||
const { method = 'GET', query, body, cookie, timeout = 10000 } = options;
|
|
||||||
let url = `${BASE_URL}${path}`;
|
|
||||||
if (query) url += `?${new URLSearchParams(query).toString()}`;
|
|
||||||
try {
|
|
||||||
const resp = await fetch(url, {
|
|
||||||
method,
|
|
||||||
headers: {
|
|
||||||
...getHeaders(cookie),
|
|
||||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
|
||||||
},
|
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
|
||||||
signal: AbortSignal.timeout(timeout),
|
|
||||||
});
|
|
||||||
if (!resp.ok) return null;
|
|
||||||
return (await resp.json()) as T;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== File listing (shared across modules) ====================
|
|
||||||
|
|
||||||
export interface QuarkFile {
|
|
||||||
fid: string;
|
|
||||||
file_name: string;
|
|
||||||
share_fid_token?: string;
|
|
||||||
dir: boolean;
|
|
||||||
size?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List files in a directory by FID.
|
|
||||||
*/
|
|
||||||
export async function listDir(cookie: string, pdirFid: string, page = 1, pageSize = 50): Promise<QuarkFile[]> {
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
...getCommonParams(),
|
|
||||||
uc_param_str: '',
|
|
||||||
pdir_fid: pdirFid,
|
|
||||||
_page: String(page),
|
|
||||||
_size: String(pageSize),
|
|
||||||
_fetch_total: '1',
|
|
||||||
_fetch_sub_dirs: '0',
|
|
||||||
_sort: 'file_type:asc,updated_at:desc',
|
|
||||||
fetch_all_file: '1',
|
|
||||||
fetch_risk_file_name: '1',
|
|
||||||
});
|
|
||||||
const resp = await fetch(
|
|
||||||
`${BASE_URL}/1/clouddrive/file/sort?${params.toString()}`,
|
|
||||||
{ headers: getHeaders(cookie), signal: AbortSignal.timeout(15000) },
|
|
||||||
);
|
|
||||||
if (!resp.ok) return [];
|
|
||||||
const data = await resp.json() as any;
|
|
||||||
if (data.status !== 200) return [];
|
|
||||||
return (data.data?.list || []).filter((f: any) => f.fid).map((f: any) => ({
|
|
||||||
fid: f.fid,
|
|
||||||
file_name: f.file_name,
|
|
||||||
share_fid_token: '',
|
|
||||||
dir: f.dir || false,
|
|
||||||
size: f.size || 0,
|
|
||||||
}));
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List root directory (pdir_fid=0) — returns all top-level dirs/files.
|
|
||||||
*/
|
|
||||||
export async function listRootDir(cookie: string): Promise<QuarkFile[]> {
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
pr: 'ucpro', fr: 'pc',
|
|
||||||
pdir_fid: '0',
|
|
||||||
_page: '1', _size: '200',
|
|
||||||
_fetch_total: '1', _fetch_sub_dirs: '0',
|
|
||||||
_sort: 'file_type:asc,updated_at:desc',
|
|
||||||
fetch_all_file: '1',
|
|
||||||
fetch_risk_file_name: '1',
|
|
||||||
});
|
|
||||||
const resp = await fetch(
|
|
||||||
`${BASE_URL}/1/clouddrive/file/sort?${params.toString()}`,
|
|
||||||
{ headers: getHeaders(cookie), signal: AbortSignal.timeout(15000) },
|
|
||||||
);
|
|
||||||
if (!resp.ok) return [];
|
|
||||||
const data = await resp.json() as any;
|
|
||||||
if (data.status !== 200 || !data.data?.list) return [];
|
|
||||||
return (data.data.list || []).map((f: any) => ({
|
|
||||||
fid: f.fid,
|
|
||||||
file_name: f.file_name,
|
|
||||||
dir: f.dir || false,
|
|
||||||
size: f.size || 0,
|
|
||||||
}));
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List all files in a directory, handling pagination.
|
|
||||||
* Fetches all pages until no more results.
|
|
||||||
*/
|
|
||||||
export async function listDirAllPages(cookie: string, pdirFid: string): Promise<QuarkFile[]> {
|
|
||||||
const allFiles: QuarkFile[] = [];
|
|
||||||
let page = 1;
|
|
||||||
const pageSize = 100;
|
|
||||||
let total = -1;
|
|
||||||
while (total === -1 || (page - 1) * pageSize < total) {
|
|
||||||
const files = await listDir(cookie, pdirFid, page, pageSize);
|
|
||||||
if (!files.length) break;
|
|
||||||
allFiles.push(...files);
|
|
||||||
if (total === -1) {
|
|
||||||
total = files.length;
|
|
||||||
}
|
|
||||||
page++;
|
|
||||||
}
|
|
||||||
return allFiles;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Format utilities ====================
|
|
||||||
|
|
||||||
export function formatBytes(bytes: number): string {
|
|
||||||
if (bytes === 0) return '0 B';
|
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
||||||
return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + sizes[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Generate a daily folder name (e.g. "2026-05-03") for organizing saves */
|
|
||||||
export function dailyFolderName(): string {
|
|
||||||
const d = new Date();
|
|
||||||
const y = d.getFullYear();
|
|
||||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(d.getDate()).padStart(2, '0');
|
|
||||||
return `${y}-${m}-${day}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Generate a random folder name for saving (fallback) */
|
|
||||||
export function randomFolderName(): string {
|
|
||||||
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
|
||||||
let name = '';
|
|
||||||
for (let i = 0; i < 12; i++) {
|
|
||||||
name += chars[Math.floor(Math.random() * chars.length)];
|
|
||||||
}
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
/**
|
|
||||||
* Cloud type labels and colors
|
|
||||||
* Shared between backend and frontend-facing routes
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Cloud domain → type regex mapping (single source of truth) */
|
|
||||||
export const CLOUD_DOMAIN_PATTERNS: Array<{ regex: RegExp; type: string }> = [
|
|
||||||
{ regex: /pan\.baidu\.com/i, type: 'baidu' },
|
|
||||||
{ regex: /pan\.quark\.cn/i, type: 'quark' },
|
|
||||||
{ regex: /aliyundrive\.com|alipan\.com/i, type: 'aliyun' },
|
|
||||||
{ regex: /115\.com|115cdn\.com/i, type: '115' },
|
|
||||||
{ regex: /cloud\.189\.cn/i, type: 'tianyi' },
|
|
||||||
{ regex: /123pan\.com|123684\.com|123912\.com/i, type: '123pan' },
|
|
||||||
{ regex: /drive\.uc\.cn/i, type: 'uc' },
|
|
||||||
{ regex: /pan\.xunlei\.com/i, type: 'xunlei' },
|
|
||||||
{ regex: /magnet:/i, type: 'magnet' },
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Detect cloud type from a URL string */
|
|
||||||
export function detectCloudType(url: string | undefined | null): string {
|
|
||||||
if (!url) return 'others';
|
|
||||||
for (const { regex, type } of CLOUD_DOMAIN_PATTERNS) {
|
|
||||||
if (regex.test(url)) return type;
|
|
||||||
}
|
|
||||||
return 'others';
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CLOUD_LABELS: Record<string, string> = {
|
|
||||||
quark: '夸克网盘',
|
|
||||||
baidu: '百度网盘',
|
|
||||||
aliyun: '阿里云盘',
|
|
||||||
'115': '115网盘',
|
|
||||||
tianyi: '天翼云盘',
|
|
||||||
'123pan': '123云盘',
|
|
||||||
uc: 'UC网盘',
|
|
||||||
xunlei: '迅雷云盘',
|
|
||||||
pikpak: 'PikPak',
|
|
||||||
magnet: '磁力链接',
|
|
||||||
ed2k: '电驴链接',
|
|
||||||
others: '其他',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CLOUD_COLORS: Record<string, string> = {
|
|
||||||
quark: '#07c160',
|
|
||||||
baidu: '#4e6ef2',
|
|
||||||
aliyun: '#ff6a00',
|
|
||||||
'115': '#9b59b6',
|
|
||||||
tianyi: '#00a1d6',
|
|
||||||
'123pan': '#e74c3c',
|
|
||||||
uc: '#f39c12',
|
|
||||||
xunlei: '#2ecc71',
|
|
||||||
pikpak: '#8e44ad',
|
|
||||||
magnet: '#95a5a6',
|
|
||||||
ed2k: '#7f8c8d',
|
|
||||||
others: '#95a5a6',
|
|
||||||
};
|
|
||||||