Compare commits

..

81 Commits

Author SHA1 Message Date
timaa 3b67df19e7 release: v0.5.5 2026-05-20 02:48:17 +08:00
timaa c4ca8edd58 fix: 搜索路由参数校验 — page>=1、limit 1-500 2026-05-20 02:46:13 +08:00
timaa d7b055f88b v0.5.4: 全面修复 — template literal URL, Cookie验证, 用户默认is_active, 默认账号路由, 空间信息, 密钥清理, promoForm修复
修复:
- quark-share.ts/storage.ts: 9处template literal ${}缺失导致fetch URL写死
- user/routes.ts: testCloudConnectionWithCookie缺await + 按cloudType分发驱动
- credential.service.ts: INSERT缺?参数 (9values/10cols)
- user/routes.ts: 用户新增网盘默认is_active=0
- admin.routes.ts: 新增PUT /admin/cloud-configs/:id/primary路由
- database.ts: is_primary列迁移
- UserDashboard.vue: 保存时传递storage_used/storage_total
- SystemConfig.vue: promoForm const重赋值bug
- config/index.ts: 移除泄露的默认密钥token
2026-05-19 23:09:11 +08:00
timaa 39724e6e73 fix(audit): 修复3个审计发现的bug
- migrateCloudConfigs 缺 } 导致 notify_config/cloud_type_uid 嵌套在 promotion_account 内不迁移
- cloud_configs_v2 INSERT/SELECT 缺 cloud_type_uid/cookie_uid 导致数据丢失
- deploy.sh 密码嵌入改成 docker exec -e 传参(防特殊字符注入)
2026-05-19 14:23:26 +08:00
timaa 288d30698a hotfix: 补回system_configs表缺失的); 2026-05-19 05:21:28 +08:00
timaa a9dc056506 hotfix: 移除push_users表多余的);(导致SQL语法错误→容器重启) 2026-05-19 05:13:24 +08:00
timaa 9ef58b5724 fix: push_users表 + cookie_uid列 + __uid正则修复 + deploy.sh重写 2026-05-19 05:06:17 +08:00
timaa c9067179ff fix: save_records表补全config_id/promotion_account列(修复转存500) 2026-05-19 04:52:08 +08:00
timaa 5e3b5e83a4 fix(deploy): 不再硬编码CORS_ORIGIN + 强制覆盖管理员密码(解决401) 2026-05-19 04:44:19 +08:00
timaa d4e62b1fb1 feat: 一键部署脚本 deploy.sh (curl | bash 方式) 2026-05-19 04:32:19 +08:00
timaa 7471d9aece v0.4.8: docker-compose添加DATA_DIR=/data确保secrets.json路径一致 2026-05-19 03:34:23 +08:00
timaa f0c0d0f487 v0.4.8: 同步根VERSION文件 2026-05-19 02:29:12 +08:00
timaa e4e1890da3 v0.4.8: 补播link_invalid_keywords种子键 2026-05-19 02:28:49 +08:00
timaa 470ebac20e v0.4.7: 修复网盘类型开关key不匹配+补全10个缺失种子配置键 2026-05-19 02:02:48 +08:00
timaa f284e14630 v0.4.6: 保存网盘配置后自动验证+获取昵称空间 2026-05-19 00:36:42 +08:00
timaa bff955e45b v0.4.5: 修复双重解密/配置key/版本号同步/buil+ssh脚本 2026-05-18 20:25:40 +08:00
timaa 6f7ab6dbc6 feat: deploy.sh三重Redis密码检测(参数/conf/cli) + URL编码特殊字符 2026-05-18 18:16:33 +08:00
timaa 498b593b28 fix: 添加缺失的cloud_type_uid列(CREATE TABLE和迁移) 2026-05-18 18:00:55 +08:00
timaa c57af012b1 feat: 自动生成JWT_SECRET和ADMIN_PASSWORD并持久化到secrets.json 2026-05-18 16:48:04 +08:00
timaa 86d79e550b fix: docker-compose.yml 镜像版本改为 latest 2026-05-18 16:35:53 +08:00
timaa 07d66ac666 fix: 数据库初始化顺序(索引移到migrateCloudConfigs后) + deploy.sh自动检测Redis密码 2026-05-18 06:14:06 +08:00
timaa 42d2b5bf1c deploy.sh: 自动下载docker-compose.yml,真正一键部署 2026-05-18 06:03:49 +08:00
timaa d7c2a9cfad 智能部署脚本:自动检测Redis,有则跳过无则创建 2026-05-18 06:03:09 +08:00
timaa 8da99d6861 docker-compose: fill real credentials 2026-05-18 06:00:14 +08:00
timaa 6f20c662eb simplify docker-compose.yml 2026-05-18 05:57:16 +08:00
timaa 48d6b642e0 add docker-compose.yml for one-click deployment 2026-05-18 05:28:23 +08:00
timaa da5bd01535 release: v0.4.0 2026-05-18 05:11:57 +08:00
timaa b758391861 v0.3.53: Fix ad cleanup batchDelete HTTP 404 (remove exclude_filelist), rename cleanup section to 转存文件清理 2026-05-18 01:58:32 +08:00
timaa 38097da994 v0.3.52: Retry creates subfolder inside daily folder for new share link, preserving folder structure 2026-05-18 01:48:05 +08:00
timaa 94d8fa455d v0.3.51: Fix broken seed, retry save with random suffix folder for new share link 2026-05-18 01:38:10 +08:00
timaa 9f959ca87b v0.3.50: Retry save creates new folder with random suffix to produce genuinely different share link 2026-05-18 01:24:37 +08:00
timaa 7f4ab50557 v0.3.49: Dedup validation — validate cached link before returning to avoid showing invalid links 2026-05-18 01:06:28 +08:00
timaa e4e3884ffc fix: 转存记录时间列155px+推广账号列140px,避免截断 2026-05-17 23:19:52 +08:00
timaa a609379d20 v0.3.41 2026-05-17 23:12:50 +08:00
timaa d78412646e v0.3.41: save_records新增推广账号/promotion_account字段 2026-05-17 23:12:46 +08:00
timaa 7e22c879b9 v0.3.40: 6组累积修复 — cookie解密/rename模块/通知模板/IP归属地 2026-05-17 23:04:00 +08:00
timaa 879d5bea95 refactor: IP归属地改为apihz.cn id+key配置,后端测试修复验证条件 2026-05-17 23:02:16 +08:00
timaa 8333d203db fix: applyTemplate用??替代||避免空字符串被回退为占位符 2026-05-17 22:46:00 +08:00
timaa a51ffb4de3 fix: notifyEvent二次应用模板冲掉已替换变量 → 传递templateVars 2026-05-17 22:43:32 +08:00
timaa 1080c530a7 fix: 恢复丢失的 HOMOPHONE_MAP 同音字映射表 (从 v0.2.4) 2026-05-17 22:22:15 +08:00
timaa b22cddc7f7 fix: quark-rename.js 缺少 crypto import 2026-05-17 22:14:48 +08:00
timaa 98b779a622 fix: getAndValidateCredential 返回加密cookie导致夸克API 401 2026-05-17 22:11:05 +08:00
timaa cf2796666d v0.3.34: 每日汇报增加推送通道选择UI 2026-05-17 21:52:37 +08:00
timaa 17b40cea7b docs: 添加 README、Release 模板、docker-compose 编排、构建发布脚本 2026-05-17 20:05:45 +08:00
timaa 389df53d2d v0.3.31: 全局配置保留title/content仅移除level/priority;推送用户SMTP只显示收件人 2026-05-17 19:37:52 +08:00
timaa 4c46685d1f v0.3.30: 清理notifier参数 — 移除title/content/level/priority,标题内容由消息模板统一管理 2026-05-17 19:29:05 +08:00
timaa 063fa6b065 v0.3.29: 推送用户改为单一渠道+参数输入 — 选频道后显示 webhook/ token 等输入框,列表显示渠道摘要 2026-05-17 19:18:52 +08:00
timaa 26b579717c v0.3.28: 消息模板改为弹窗编辑 — 每个事件开关旁加✏️按钮,点击弹窗编辑标题和内容,变量带中文说明 2026-05-17 19:09:16 +08:00
timaa 4131ccece9 v0.3.27: 全局事件开关下展示自定义消息模板,支持编辑标题和内容 2026-05-17 18:49:15 +08:00
timaa 71990554c4 v0.3.26: 保存配置按钮改为 sticky 定位,不再遮挡底部内容 2026-05-17 18:41:55 +08:00
timaa 0bc848e196 v0.3.25: 外部依赖内置化 — qrcodejs/Hitokoto/IPgeo/代理测试 2026-05-17 18:31:23 +08:00
timaa 32dcc44524 v0.3.24: 清理设置4个后端接线 — 白名单/cookie检测/空间校准开关
补全前端4个控制项的后端实现:
- cleanup_whitelist_dirs → cleanupCloudFiles + cleanupAllBySpaceThreshold 读取并传递
- cleanup_auto_refresh_storage → scheduleStorageRefresh 读取开关,false时跳过
- cleanup_verify_enabled + cleanup_verify_interval → 新增Cookie验证调度器
- CloudCleanupDriver 接口 + baidu.driver 签名同步支持 whitelistDirs 可选参数

验证: 4个key从仅前端 → 全部有后端读取模块
2026-05-17 18:10:12 +08:00
timaa 1fd64e0788 v0.3.23: 去重 — 删除重复模块,统一为单一实现+调用
修复的重复:
- 删除 src/utils/qr-login.service.ts(与 src/cloud/ 完全一致)
- qr-login.service.ts 本地 formatBytes → import { formatBytes } from quark-api
- daily-report.service.ts 本地 CLOUD_LABELS → import from cloud-constants
- 新增 cloudLabel() 本地映射(短标签 vs cloud-constants 全名)

确认无其他重复: 文件级(utils vs cloud)、函数级(export function)、字节解析
2026-05-17 17:59:23 +08:00
timaa f9338e5906 v0.3.22: 存储刷新改用 /member API 秒级精准获取,删除文件遍历
根因: /1/clouddrive/capacity/detail 只返回总容量不含已用空间。
      之前的方案遍历所有文件计算已用空间,根目录估算只有 2.38 GB,
      全量遍历走后台回调但不被 refreshAllStorageInfo 接收。

修复: 发现 /1/clouddrive/member API 直接返回 use_capacity + total_capacity。
      getStorageInfoQuick/getStorageInfo 统一改为 member API 单次调用,
      返回 2.76 TB / 6 TB 精准值。删除昂贵的文件遍历逻辑。
      refreshAllStorageInfo 移除后台回调复杂度
2026-05-17 17:52:06 +08:00
timaa d87bc6fd5a v0.3.21: 修复存储空间自动刷新不生效
根因:
1. refreshAllStorageInfo 只过滤 quark, 百度等其他网盘永远不会刷新
2. 主调度器硬编码 60 分钟, 未读取 storage_refresh_interval 配置(用户设的180分钟)
3. 条件检查用 totalBytes/usedBytes, 百度返回对象无此字段 → 永远跳过

修复:
- refreshAllStorageInfo: 改为驱动注册表模式, 支持 quark+baidu, 兼容不同驱动返回格式
- main.ts: 用 setTimeout 链替代 setInterval, 每次动态读 storage_refresh_interval, 修改后无需重启即生效
2026-05-17 17:26:45 +08:00
timaa 0e0cad1271 v0.3.20: 每日汇报系统 — 每天8点自动收集前一日数据并推送汇总报告
新增:
- src/services/daily-report.service.ts (核心服务: 数据收集/报告生成/格式化/调度器)
- API: GET/PUT daily-report/config, GET daily-report/preview, POST daily-report/test, GET daily-report/last-run
- 前端: 侧边栏"📊 每日汇报"菜单 + SystemConfig.vue 配置面板(时间/内容开关/预览/测试发送)
- main.ts: 每60秒检查调度, 08:00-08:04 窗口内运行

报告内容: 搜索统计/转存统计(成功率)/各网盘容量和活跃状态/用户数
2026-05-17 17:10:39 +08:00
timaa 95df193e26 v0.3.18: 消息模版API + global-config端点 2026-05-17 16:41:46 +08:00
timaa ad186431dd v0.3.16: 消息推送模版功能恢复 + global-config API
- 写入global_notify_config系统配置(含3个事件的默认模版)
- 新增GET/PUT /api/admin/notify/global-config端点
- notification.service导出getGlobalNotifyConfig/getEventTemplates
- 模版支持{variable}变量替换: cloud_type/nickname/file_name/file_size/duration等
2026-05-17 16:20:01 +08:00
timaa e1931cdee6 v0.3.15: SELECT补全cloud_type_uid和promotion_account 2026-05-17 16:01:50 +08:00
timaa ea03ff2203 v0.3.14: cookie_uid/空间修复 + checkin/cleanup统一走getter解密
修复:
- testCloudConnection: 验证成功后更新cookie_uid和storageUsed
- 5个getter SELECT添加cookie_uid列
- checkin.service: dailyCheckIn改用getCloudConfigById(自带解密)
- cleanup.service: getActiveCleanupConfigs改用getActiveCloudConfigs(自带解密)
- checkin/cleanup不再直接查DB绕开解密
2026-05-17 15:49:43 +08:00
timaa eebf4b6c97 v0.3.13: Cookie解密修复 + 配置统一化
修复:
- credential.service.ts: 5个getter函数统一解密cookie (解决夸克连接失败)
- decryptCookie从extractCookieUid嵌套作用域提到模块顶层
- testCloudConnection/getAndValidateCredential添加解密调用
- 去掉docker run的COOKIE_ENCRYPTION_KEY(回退默认key与旧数据一致)

配置统一化:
- config/index.ts新增: corsOrigin/cookieEncryptionKey/logLevel/appVersionFile/uploadDir
- main.ts: CORS_ORIGIN/REDIS_URL/uploads改用config而非raw process.env
- middleware/cache.ts: REDIS_URL改用config
- docker-compose.env: 完整环境变量模板(18个变量)
2026-05-17 15:07:38 +08:00
timaa 1e0d408dd2 v0.3.12: 统一环境变量管理 + 去重
- CHROMIUM_PATH 从3处散落移到 config/index.ts 统一管理
- startup-validator 移除冗余 CORS_ORIGIN 检查 (main.ts 已处理)
- docker run 补充 JWT_SECRET/ADMIN_PASSWORD/COOKIE_ENCRYPTION_KEY
- 云盘常量统一到 cloud-constants.ts (图标/标签/颜色)
2026-05-17 14:13:51 +08:00
timaa 77a8c5126f v0.3.11: 统一云盘常量到 cloud-constants.ts (图标/标签/颜色) 2026-05-17 14:10:39 +08:00
timaa 7d2d883b81 v0.3.10: cloud-types API 图标从内联SVG改为PNG路径 /icons/xxx.png 2026-05-17 14:02:21 +08:00
timaa 514922f21a v0.3.9: 替换占位图标为原始高清云盘图标 2026-05-17 13:42:41 +08:00
timaa 25ad1802dc v0.3.8: 修复网盘图标(icons/)未包含在Vite构建产物中 2026-05-17 13:34:59 +08:00
timaa 8cd4dabb60 v0.3.7: 恢复前端Vue源码 + 修复AdminDashboard 401根源 2026-05-17 13:26:36 +08:00
timaa 09be4c307e v0.3.6: 恢复丢失的11个模块 + 接线基础设施
恢复内容:
- quark驱动拆解为7个子模块 (quark-api/auth/share/storage/cleanup/rename/ad-cleanup)
- 工具模块: utils/crypto, utils/logger, utils/proxy-agent
- 配置校验: config/startup-validator
- 接线: main.ts(checkStartup), credential.service.ts(加密Cookie), admin.routes.ts(代理测试)
- quark.driver.ts 从1533行巨兽瘦身到130行壳子
2026-05-17 06:05:47 +08:00
timaa 64b00661a2 v0.3.5: restore full push notification system (14 channels)
Restored from v0.2.4:
- notifiers/: 14 push channels (bark/serverchan/telegram/lark/webhook/wechat/discord/smtp/...)
- push-user.service.ts: multi-user push config linked to cloud_configs.promotion_account
- notification.service.ts: full dispatcher with per-config + global fallback

New integrations:
- cloud.service.ts: notifyConfigEvent on save_success/cookie_expire/save_fail
- admin.routes.ts: 7 new API endpoints for push users, notify providers, channel test
- database.ts: migration for cloud_configs.notify_config column

How it works:
- Configure push channels in /admin/system-configs (global_notify_config JSON)
- Or per-cloud: link push_users.account = cloud_configs.promotion_account
- Notify events: save_success (green), cookie_expire (red), save_fail >=3 consecutive (yellow)
2026-05-17 05:15:26 +08:00
timaa 29e0fcbd43 v0.3.4: redis_status returns Chinese labels for frontend color
- db-status endpoint: redis_status returns 已连接/未连接/错误 instead of English
- Frontend compares k.redis_status === "已连接" → green text-success badge
- English connected was not matching, always showed yellow text-warning
2026-05-17 04:40:53 +08:00
timaa b852d9577a v0.3.3: fix db_size N/A + fix test-redis missing await
- db-status endpoint: fallback to config.dbPath when system_config db_path is empty
- test-redis endpoint: handler wasnt async, causing Promise serialization to {} instead of result
- Both endpoint handlers now properly async with await
2026-05-17 04:31:14 +08:00
timaa 9a4751692f v0.3.2: fix 401 on save-records fetch + fix 429 rate limiter behind proxy
- AdminDashboard: M() now sends admin_token from localStorage with fetch
- rate-limit: keyGenerator uses req.ip instead of req.socket.remoteAddress
  (Express trust proxy reads X-Forwarded-For for real client IP)
- main.ts: moved global rateLimiter after express.static so static files
  (JS/CSS/admin page/favicon) are never rate-limited
2026-05-17 04:20:30 +08:00
timaa e57298471a v0.3.1-hotfix: 修复 AdminDashboard TDZ 错误 (x 声明提升)
- 问题: qu(()=>x.value) 在 const x=Ce("dashboard") 之前调用
- 修复: 将 const x 声明提升到 setup() 顶部
2026-05-17 03:58:46 +08:00
timaa 3d6add4636 v0.3.1: 修复仪表盘缓存问题 + 版本号同步
- 修复: admin.html 无缓存, /assets/ immutable + 1年缓存
- 修复: 清理过期 index-Bz21yOih.js 残留文件
- 版本: VERSION → 0.3.1 (唯一来源)
2026-05-17 03:44:43 +08:00
timaa d5aa799acc v0.3.1: 版本号架构重构 — VERSION为唯一真相来源
- 新增 src/version.ts: 从文件读取版本(APP_VERSION_FILE → /app/VERSION)
- main.ts 改为 import { VERSION } from './version'(不再读 package.json)
- Dockerfile 构建时 COPY VERSION 到 /app/VERSION
- 新增 build.sh: 一键读取VERSION→构建→打标签
- docker-compose.yml: 清理重复/损坏内容, 添加 pansou network-alias
- package.json version 设为 0.0.0 (不再作为真相来源)
- 清理 .env 和 compose 注释中的过期版本号
2026-05-17 03:26:00 +08:00
timaa 476a7d458a fix: compose镜像标签同步至v0.3.0 2026-05-17 02:55:38 +08:00
timaa 1c5deb08f6 v0.3.0: 容器内版本号同步至0.3.0 2026-05-17 02:53:35 +08:00
timaa 9a1e16d31c v0.3.0: 管理后台UI全面美化
- 全新玻璃拟态设计(Glassmorphism)
- 渐变背景+模糊效果
- 统计面板集成(今日搜索/转存/总计)
- 功能卡片悬停动画+状态指示条
- 滑块开关带发光效果
- Toast通知动画优化
- 完全响应式适配移动端
2026-05-17 02:50:10 +08:00
timaa 41e68789d3 v0.2.9: videoParser状态优化 + 磁盘清理
- videoParser未配置时显示disabled而非unreachable
- 清理/tmp/cloudsearch-*临时文件 (~1.4G)
- 移除旧Docker镜像(v0.1.1-v0.2.3)
- 清理Docker build cache (~1.3G)
- 清理旧备份保留最新3个
2026-05-17 02:39:43 +08:00
timaa 96b989ec25 v0.2.8: XSS净化 + 速率限制修复
- 搜索关键词存储前HTML标签剥离
- 速率限制keyGenerator改用getClientIP(读X-Forwarded-For)
- 搜索限流从150/min降至60/min
- 清理数据库中历史XSS条目
2026-05-17 02:32:48 +08:00
timaa 83cbfaf03f v0.2.7: 修复Redis连接 + 启动管理后台
- 修复Redis认证 (配置密码)
- 启动Python管理后台 (端口9531, 15个功能开关)
- 统一版本号 0.2.7
- 更新docker-compose.yml (镜像版本/Redis URL/Admin服务)
2026-05-17 02:22:18 +08:00
684 changed files with 33479 additions and 16167 deletions
-51
View File
@@ -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
+35
View File
@@ -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=
+9 -24
View File
@@ -1,27 +1,12 @@
.env
*.sqlite
*.sqlite-shm
*.sqlite-wal
uploads/
__pycache__/
*.pyc
.DS_Store
node_modules/
dist/
.env
.h5/
*.log
.DS_Store
.vite/
*.bak
*.bak2
*.bak3
*.tar.gz
*.tar
deploy.sh
backup.sh
check.sh
icons/
!/packages/frontend/public/icons/
scripts/
Dockerfile
Dockerfile.bak
.dockerignore
docker-compose.yml
docker-compose.yml.bak
pansou-web-latest.tar.gz
video-parser-latest.tar.gz
cloudsearch-app-v2.0.26.tar.gz
uploads/
*.zip
Executable → Regular
+208 -133
View File
@@ -1,170 +1,245 @@
# CloudSearch — 网盘内容搜索与智能管理平台
# CloudSearch · 自托管云盘资源搜索引擎
CloudSearch 是一款集**跨网盘搜索引擎、网盘资源管理、存储空间清理、转存自动化**于一体的全栈 Web 应用。支持夸克网盘、百度网盘、阿里云盘等主流云盘,提供统一的管理后台和用户友好的搜索首页。
**CloudSearch** 是一个自托管的网盘资源搜索引擎。支持多网盘(夸克、百度、阿里云盘等)Cookie 接入、关键词搜索、一键转存、多通道消息推送、每日数据汇报。
> 🏷️ 当前版本:**v0.3.32** · 活跃开发中
> 🔒 私有项目 — [Gitea 镜像](https://gitea.timxx.cn/admin/CloudSearch)
---
## 功能全景
## ✨ 主要特性
### 🔍 搜索引擎
- **多数据源聚合**:集成 PanSou 搜索引擎 + 多个 TG 频道资源,一站式查找网盘资源
- **多盘直链转存**:支持夸克网盘、百度网盘、阿里云盘、115 网盘等主流平台的转存操作
- **分享链接复用**:相同原始链接自动复用已有资源,60 秒内重复请求直接返回缓存
- **Cookie 验证**:自动检测各网盘 Cookie 有效性,过期自动标记
### 📊 管理后台
- **仪表盘**:实时展示网盘数量、总空间、转存统计、活跃配置等核心指标
- **网盘配置**:添加/管理各网盘账号(夸克、百度、阿里云、115 等),支持主账号设置、转存启用控制
- **存储清理**:
- 自动清理策略设置(保留天数、回收站清空、空间阈值自动清理)
- 白名单目录保护(排除不清理的目录)
- 一键清理、一键清空回收站
- 空间校准与 Cookie 检测间隔配置
- **系统配置**:管理员密码修改、Logo 自定义、搜索策略配置
- **转存日志**:查看所有转存操作记录,支持搜索与详情展开
### 🧹 智能存储管理
- **存储空间校准**:快速获取网盘实际使用空间(支持夸克 `/member` 接口,单次 API 调用)
- **空间阈值自动清理**:超出设定使用率阈值时按比例自动删除最旧文件
- **回收站清空**:一键清空所有已启用驱动的网盘回收站
### 🌐 用户前端(首页 + H5)
- 响应式搜索界面,支持桌面端与移动端
- 搜索结果即时展示
- 推广内容展示
### 🔐 安全特性
- Cookie 加密存储(AES-256-GCM)
- JWT 登录认证
- 暗色/亮色主题切换
- 非 root 容器运行
- 🔍 **多源搜索** — 聚合 PanSou + 内置搜索引擎,毫秒级返回
- ☁️ **多云盘接入** — 支持夸克、百度网盘、阿里云盘、115、UC、迅雷、天翼云、PikPak、123 云盘
- 📦 **一键转存** — 搜索结果直接转存到已连接的网盘
- 📊 **管理面板** — 网盘管理、搜索统计、转存记录、系统配置
- 🔔 **14 通道通知** — 飞书/Lark、Telegram、钉钉、企业微信、Discord、Bark、Server酱、PushPlus、Gotify、ntfy、SMTP 邮件、Qmsg酱、自定义 Webhook
- 📈 **每日汇报** — 定时统计搜索/转存/用户数据,自动推送到通知通道
- 🧹 **自动清理** — Cookie 到期检测、空间自动清理、关键词审核
- 🎬 **TMDB 增强** — 搜索结果自动匹配影视元数据(海报、评分、简介)
---
## 技术架构
## 📋 前置要求
```
┌──────────────────────────────────────────────────┐
│ 用户浏览器 │
│ Vue 3 SPA(Element Plus UI) │
└──────────────┬──────────────────┬────────────────┘
│ http/9527 │
┌──────────────▼──────────────────▼────────────────┐
│ Express.js 后端 API │
│ TypeScript + better-sqlite3 │
├──────────────────────────────────────────────────┤
│ 📦 网盘驱动 │
│ ┌──────┐ ┌──────┐ ┌────────┐ ┌──────┐ │
│ │ 夸克 │ │ 百度 │ │ 阿里云 │ │ 115 │ … │
│ └──────┘ └──────┘ └────────┘ └──────┘ │
├──────────────────────────────────────────────────┤
│ 🗃️ 数据层 │
│ SQLite(系统配置 + 转存记录 + Cookie缓存) │
│ Redis 缓存(可选自动降级) │
└──────────────┬────────────────────────────────────┘
│
┌──────────────▼────────────────────────────────────┐
│ PanSou 搜索引擎容器 │
│ 80+ 数据源(网盘/资源站/TG 频道/磁力) │
└───────────────────────────────────────────────────┘
```
### 组件栈
| 层 | 技术 |
|---|---|
| **前端** | Vue 3 (Composition API) + TypeScript + Element Plus + Vite |
| **后端** | Node.js + Express + TypeScript + better-sqlite3 |
| **搜索引擎** | PanSou(独立容器,80+ 数据源插件) |
| **缓存** | Redis 7(可选,自动降级内存缓存) |
| **容器化** | Docker + Docker Compose(3 服务:app + pansou + redis) |
| 软件 | 最低版本 | 说明 |
|------|---------|------|
| Docker | 24.0+ | 构建和运行容器 |
| Docker Compose | v2.20+ | 编排多服务 |
| Git | 2.40+ | 克隆仓库 |
---
## 一键部署
## 🚀 快速部署(3 步)
### 前置条件
- 服务器:**Linux x86_64**(推荐 2C4G 以上)
- Docker Engine 24+ & Docker Compose v2
### 部署命令
### 1. 克隆仓库
```bash
# 1. 下载部署包
wget https://gitea.timxx.cn/admin/CloudSearch/releases/download/v0.1.6/cloudsearch-deploy.tar.gz
# 或直接从仓库克隆
# git clone https://gitea.timxx.cn/admin/CloudSearch.git && cd CloudSearch
git clone https://gitea.timxx.cn/admin/CloudSearch.git
cd CloudSearch
```
# 2. 解压 & 进入目录
tar xzf cloudsearch-deploy.tar.gz
cd cloudsearch
### 2. 配置环境变量
# 3. 一键配置 + 启动
cp .env.example .env
sed -i "s/change-me-to-a-random-64-char-hex-string/$(openssl rand -hex 32)/" .env
sed -i "s/change-me-to-a-random-key/$(openssl rand -hex 32)/" .env
echo "⚠️ 请编辑 .env 文件,修改 ADMIN_PASSWORD 和 CORS_ORIGIN"
```bash
cp docker-compose.env .env
# 编辑 .env,修改以下必填项:
# JWT_SECRET — JWT 签名密钥(生成:openssl rand -hex 32)
# ADMIN_PASSWORD — 管理后台登录密码
# CORS_ORIGIN — 前端访问域名(如 http://your-domain.com)
# REDIS_URL — Redis 连接地址
# PANSOU_AUTH_TOKEN — PanSou 搜索 Token(可选)
```
<details>
<summary>📋 完整环境变量参考</summary>
| 变量 | 默认值 | 必填 | 说明 |
|------|--------|------|------|
| `PORT` | `9527` | | 服务端口 |
| `NODE_ENV` | `production` | | 运行环境 |
| `TZ` | `Asia/Shanghai` | | 时区 |
| `JWT_SECRET` | — | ✅ | JWT 签名密钥 |
| `ADMIN_USERNAME` | `admin` | | 管理员用户名 |
| `ADMIN_PASSWORD` | — | ✅ | 管理员密码 |
| `CORS_ORIGIN` | — | ✅ | CORS 允许的域名 |
| `COOKIE_ENCRYPTION_KEY` | — | | Cookie 加密密钥(生产环境建议设置) |
| `DB_PATH` | `/data/database.sqlite` | | SQLite 数据库路径 |
| `REDIS_URL` | — | ✅ | Redis 连接(格式 `redis://[:password@]host:port`) |
| `PANSOU_URL` | `http://pansou:80` | | PanSou 搜索服务地址 |
| `PANSOU_AUTH_TOKEN` | — | | PanSou 鉴权 Token |
| `VALIDATION_CONCURRENCY` | `10` | | 网盘并发校验数 |
| `VALIDATION_TIMEOUT` | `5000` | | 单次校验超时(ms) |
| `CACHE_TTL_VALID` | `14400` | | 有效结果缓存(秒) |
| `CACHE_TTL_INVALID` | `3600` | | 无效结果缓存(秒) |
| `LOG_LEVEL` | `info` | | 日志级别(debug/info/warn/error) |
| `UPLOAD_DIR` | `/app/uploads` | | 上传文件目录 |
| `APP_VERSION_FILE` | `/app/VERSION` | | 版本文件路径 |
</details>
### 3. 启动服务
```bash
docker compose up -d
# 4. 检查服务状态
docker compose ps
# 预期输出:CloudSearch_App、CloudSearch_PanSou 均为 Up
# 5. 访问
# 搜索页面: http://your-server-ip:9527
# 管理后台: http://your-server-ip:9527/admin/login
```
### 环境变量说明
| 变量 | 必需 | 说明 |
|---|---|---|
| `ADMIN_USERNAME` | ✅ | 管理员用户名(默认 admin) |
| `ADMIN_PASSWORD` | ✅ | **务必修改默认密码** |
| `JWT_SECRET` | ✅ | 运行 `openssl rand -hex 32` 生成 |
| `CORS_ORIGIN` | ✅ | 你的站点域名(如 `https://search.example.com`) |
| `COOKIE_ENCRYPTION_KEY` | ⚠️ 推荐 | Cookie 加密密钥,不设置则重启后旧数据不可解密 |
| `REDIS_URL` | ❌ 可选 | Redis 连接(不设则自动降级) |
### 首次使用
1. 登录管理后台 `/admin/login`
2. 在「网盘配置」→「添加配置」中填入网盘 Cookie
3. 启用对应网盘配置
4. 返回首页即可搜索并转存资源
访问 `http://localhost:9527` — 管理面板:`http://localhost:9527/admin`
---
## 开发环境
## 📦 服务架构
```bash
# 后端
cd packages/backend
npm install
cp .env.example .env # 编辑数据库路径等
npm run dev
# 前端(新终端)
cd packages/frontend
npm install
npm run dev
```
┌─────────────────────────────────────────────────────┐
│ 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
cd /opt/CloudSearch
git pull origin master
docker compose down
docker compose build --no-cache app
docker compose up -d
./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
```
---
## 许可
## 📁 目录结构
本项目基于 Gitea 私有仓库维护,仅供授权用户使用。
```
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/ # 📤 上传文件
```
> **注意**:本系统仅用于合法用途,使用者需遵守当地法律法规。网盘 Cookie 等凭证由用户自行保管,系统以加密形式存储。
---
## 🏗️ 开发指南
### 后端开发
```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
私有项目 — 保留所有权利。
+83
View File
@@ -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 需确认
+157
View File
@@ -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** — 保持镜像仓库与源码一致
+1
View File
@@ -0,0 +1 @@
0.5.5
Executable
+11
View File
@@ -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"
+18
View File
@@ -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"]
+196
View File
@@ -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>
+3
View File
@@ -0,0 +1,3 @@
flask>=3.0
waitress>=2.1
pymysql>=1.1
+206
View File
@@ -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)
+104
View File
@@ -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>功能开关 &amp; 系统配置</span></div></div><div class="header-actions"><button class="btn" onclick="refreshAll()">🔄 刷新</button><button class="btn btn-primary" onclick="saveAll()" id="btn-save-all">💾 全部保存</button></div></div></header>
<div class="stats-strip" id="stats"></div>
<main class="main" id="main"><div class="loader"><div class="spinner"></div><span>加载功能开关...</span></div></main>
<div class="toast-container" id="toast-container"></div>
<script>
var FEATURES={{ features | tojson }};
var currentFlags={},statsData=null;
function refreshAll(){Promise.all([loadFlags(),loadStats()]).then(render)}
function loadFlags(){fetch('/api/flags').then(function(r){return r.json()}).then(function(d){currentFlags=d}).catch(function(){showToast('无法连接','error')})}
function loadStats(){var t=sessionStorage.getItem('admin_token');if(!t)return;fetch('http://127.0.0.1:9527/api/admin/stats',{headers:{'Authorization':'Bearer '+t}}).then(function(r){return r.ok?r.json():null}).then(function(d){statsData=d}).catch(function(){})}
function render(){renderStats();renderFeatures()}
function renderStats(){var e=document.getElementById('stats');if(!statsData){e.innerHTML='';return}e.innerHTML='<div class="stat-mini"><div class="stat-num">'+(statsData.todaySearches||0)+'</div><div class="stat-label">今日搜索</div></div><div class="stat-mini"><div class="stat-num">'+(statsData.todaySaves||0)+'</div><div class="stat-label">今日转存</div></div><div class="stat-mini"><div class="stat-num">'+(statsData.totalSearches||0)+'</div><div class="stat-label">总搜索</div></div><div class="stat-mini"><div class="stat-num">'+(statsData.totalSaves||0)+'</div><div class="stat-label">总转存</div></div>'}
function renderFeatures(){var entries=Object.entries(FEATURES).map(function(e){e[1]=Object.assign({},e[1],{flag:currentFlags[e[0]]});return e});var groups={};entries.forEach(function(e){var g=e[1].group;(groups[g]=groups[g]||[]).push(e)});var order=['核心','增强','转存'],icons={'核心':'🔵','增强':'🟣','转存':'🟢'},html='';order.forEach(function(g){if(!groups[g])return;html+='<div class="section-title">'+(icons[g]||'📌')+' '+g+'功能</div><div class="feature-grid">';groups[g].forEach(function(p){var key=p[0],item=p[1],flag=item.flag||{},on=flag.value,synced=flag.synced!==false,updated=flag.updated_at?flag.updated_at.replace('T',' ').slice(0,16):'';html+='<div class="feature-card '+(on?'on':'off')+'" id="card-'+key+'"><div class="feature-info"><h3>'+item.name+(synced?'':' <span class="badge badge-mismatch">⚠ 未同步</span>')+(synced&&on?' <span class="badge badge-synced">● 已启用</span>':'')+'</h3><span class="code">'+key+'</span>'+(updated?'<span class="updated">'+updated+'</span>':'')+'</div><div class="toggle-wrap"><button class="toggle '+(on?'on':'off')+'" onclick="toggleFlag(\''+key+'\','+(!on)+')"><span style="display:none">开关</span></button></div></div>'});html+='</div>'});document.getElementById('main').innerHTML=html}
function toggleFlag(key,value){var card=document.getElementById('card-'+key);if(!card)return;card.classList.add('loading');fetch('/api/flags/'+key,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({value:value})}).then(function(r){return r.json()}).then(function(d){if(d.ok){showToast(FEATURES[key].name+' → '+(value?'✅ 已开启':'❌ 已关闭'),'success');loadFlags().then(render)}else showToast(d.error||'失败','error')}).catch(function(e){showToast('网络错误: '+e.message,'error');card.classList.remove('loading')}).finally(function(){card.classList.remove('loading')})}
function saveAll(){var btn=document.getElementById('btn-save-all');btn.classList.add('saving');btn.innerHTML='⏳ 保存中...';fetch('/api/flags').then(function(r){return r.json()}).then(function(d){showToast('已同步 '+Object.keys(d).length+' 个开关 ✓','success')}).catch(function(){showToast('同步失败','error')}).finally(function(){setTimeout(function(){btn.classList.remove('saving');btn.innerHTML='💾 全部保存'},600)})}
function showToast(msg,type){var c=document.getElementById('toast-container'),icons={success:'✓',error:'✕',info:'ℹ'},el=document.createElement('div');el.className='toast '+(type||'info');el.innerHTML='<span class="toast-icon">'+(icons[type]||'')+'</span>'+msg;c.appendChild(el);requestAnimationFrame(function(){el.classList.add('show')});setTimeout(function(){el.classList.remove('show');setTimeout(function(){el.remove()},300)},2800)}
(function init(){try{fetch('http://127.0.0.1:9527/api/admin/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:'admin',password:'0nL5kLhMIJ1121PYmQb25A'})}).then(function(r){return r.ok?r.json():null}).then(function(d){if(d&&d.token)sessionStorage.setItem('admin_token',d.token)}).catch(function(){})}catch(e){}refreshAll()})();
</script>
</body>
</html>
+8
View File
@@ -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"]
+319
View File
@@ -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()
+319
View File
@@ -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()
+3
View File
@@ -0,0 +1,3 @@
flask>=3.0
requests>=2.28
python-telegram-bot>=20.0
+132
View File
@@ -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)
+204
View File
@@ -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)
+183
View File
@@ -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()
+179
View File
@@ -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}",
)
+18
View File
@@ -0,0 +1,18 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV PORT=9528
ENV TRANSFER_CONFIG_PATH=/data/transfer_config.json
EXPOSE 9528
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD ["python", "server.py"]
CMD ["python", "server.py"]
+32
View File
@@ -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"
+1
View File
@@ -0,0 +1 @@
"""CloudSearch Transfer — 适配器包"""
@@ -0,0 +1,297 @@
"""
阿里云盘适配器 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",
}
def __init__(self, config: PlatformConfig, transfer_config: TransferConfig):
super().__init__(config, transfer_config)
# 创建凭证管理器(AliyunCredentialManager)
refresh_token = config.refresh_token or config.cookie or ""
self._credential = AliyunCredentialManager(refresh_token=refresh_token)
# 初始化 drive_id
self._drive_id = ""
# 创建子模块
self._transfer: Optional[AliyunTransfer] = None
self._cleanup: Optional[AliyunCleanup] = None
def _setup_session(self):
"""初始化 session 和凭证"""
if self._credential.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,
)
# 确定目标目录
to_parent = save_dir if save_dir and save_dir != "/" else "root"
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)
# ─── 扩展功能 ──────────────────────────────────────────
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,253 @@
"""
百度网盘适配器 — CloudSearch Transfer v1.0.0
参考 cloud-auto-save 的 BaiduNetDisk + netdisk 的 PanbaiduSave
完整的 5 步转存流程 + bdstoken 管理 + 路径删除 + 广告过滤
"""
import logging
from typing import List, Tuple
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_-]+)',
]
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", [])
# ③ 转存
self._transfer._transfer_files(shareid, uk, fs_ids, save_dir, bdstoken)
# ④ 列出目录匹配新 fs_id
new_fs_ids = self._transfer._list_and_match(save_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)
# ─── 扩展方法 ────────────────────────────────────────────
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
+330
View File
@@ -0,0 +1,330 @@
"""
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] = []
# 默认请求头
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
# ─── 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 _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:
params = query_params or {}
params["task_id"] = task_id
resp = self._get(task_url, params=params, retry=1)
data = resp.json().get("data", resp.json())
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 []
+112
View File
@@ -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,509 @@
"""
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+)",
]
def __init__(self, config: PlatformConfig, transfer_config: TransferConfig) -> None:
"""初始化夸克适配器。
Args:
config: 平台配置(含 Cookie 等)。
transfer_config: 全局转存配置(超时、重试、轮询参数等)。
"""
super().__init__(config, transfer_config)
# 初始化三个子模块
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,
)
# ═══════════════════════════════════════════════════════════════
# 公开接口实现
# ═══════════════════════════════════════════════════════════════
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,
)
# 目标目录:默认根目录 "0"
target_dir: str = save_dir or self.config.save_dir or "0"
# 分享密码
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() 流程中的步骤③④)。
Args:
pwd_id: 分享 ID。
detail: 分享详情(来自 _get_share_detail)。
save_dir: 目标目录 ID。
Returns:
转存后的新文件 ID 列表。
"""
# 需要 stoken,从 detail 间接获取(重新请求)
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]:
"""创建夸克分享链接(基类 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
# ─── 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()
+493
View File
@@ -0,0 +1,493 @@
"""
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+)",
]
def __init__(self, config: PlatformConfig, transfer_config: TransferConfig) -> None:
"""初始化 UC 适配器。
Args:
config: 平台配置(含 Cookie 等)。
transfer_config: 全局转存配置(超时、重试、轮询参数等)。
"""
super().__init__(config, transfer_config)
# 初始化三个子模块
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,
)
# ═══════════════════════════════════════════════════════════════
# 公开接口实现
# ═══════════════════════════════════════════════════════════════
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:
"""执行转存的核心逻辑(覆盖基类实现 UC 专用流程)。
通过 UcTransfer 引擎执行完整的 7 步流程。
Args:
share_url: UC 分享链接。
save_dir: 目标目录,空则使用配置的默认目录。
share_password: 新分享的密码。
Returns:
TransferResult 包含转存结果。
"""
start: float = time.time()
# 凭证检查
if not self._credential.validate():
raise TransferError(
TransferErrorCode.NOT_LOGIN,
message="UC Cookie 无效或长度不足",
platform=self.PLATFORM_KEY,
)
# 目标目录:默认根目录 "0"
target_dir: str = save_dir or self.config.save_dir or "0"
# 分享密码
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_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,
)
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:
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:
"""获取 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
# ─── get_files / delete ────────────────────────────────────
def get_files(self, parent_fid: str = "0") -> List[FileInfo]:
"""列出 UC 网盘指定目录下的文件。
GET /1/clouddrive/file/sort?pdir_fid=<parent_fid>&_page=1&_size=100&_sort=updated_at:desc
Args:
parent_fid: 父目录 ID,默认 "0" 即根目录。
Returns:
FileInfo 列表。
"""
url: str = f"https://pc-api.uc.cn/1/clouddrive/file/sort"
params: Dict[str, str] = {
"pdir_fid": parent_fid,
"_page": "1",
"_size": "100",
"_sort": "updated_at:desc",
}
headers: Dict[str, str] = self._credential.get_headers()
try:
resp = self._get(url, params=params, headers=headers)
except Exception as exc:
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=f"获取文件列表失败: {exc}",
platform=self.PLATFORM_KEY,
) from exc
data: Dict[str, Any] = resp.json()
status: int = data.get("status", -1)
if status != 0 and data.get("code") not in (0, None):
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=f"获取文件列表失败: {data.get('message')}",
platform=self.PLATFORM_KEY,
)
files_data: List[Dict[str, Any]] = data.get("data", {}).get("list", [])
result: List[FileInfo] = []
for f in files_data:
result.append(
FileInfo(
fid=str(f.get("fid", "")),
name=str(f.get("file_name", f.get("name", ""))),
size=int(f.get("size", 0)),
is_dir=bool(f.get("dir", f.get("is_dir", False))),
ext=str(f.get("file_extension", f.get("ext", ""))),
)
)
logger.debug("[UcAdapter] Listed %d files in dir=%s", len(result), parent_fid)
return result
def delete(self, file_ids: List[str]) -> bool:
"""删除 UC 网盘文件(移到回收站)。
Args:
file_ids: 要删除的文件 ID 列表。
Returns:
True 表示删除成功。
"""
if not self._credential.validate():
raise TransferError(
TransferErrorCode.NOT_LOGIN,
platform=self.PLATFORM_KEY,
)
try:
return self._cleanup.delete_files(file_ids)
except RuntimeError as exc:
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=str(exc),
platform=self.PLATFORM_KEY,
) from exc
def delete_permanent(self, file_ids: List[str]) -> bool:
"""彻底删除 UC 网盘文件(不可恢复)。
Args:
file_ids: 要彻底删除的文件 ID 列表。
Returns:
True 表示删除成功。
"""
if not self._credential.validate():
raise TransferError(
TransferErrorCode.NOT_LOGIN,
platform=self.PLATFORM_KEY,
)
try:
return self._cleanup.delete_files_permanent(file_ids)
except RuntimeError as exc:
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=str(exc),
platform=self.PLATFORM_KEY,
) from exc
# ─── 工具方法 ─────────────────────────────────────────────
def _parse_share_url(self, url: str) -> Tuple[str, str]:
"""解析 UC 分享 URL 提取 (pwd_id, passcode)。
UC 链接格式:https://drive.uc.cn/s/<pwd_id> 或带 ?pwd=xxxx
Args:
url: UC 分享链接。
Returns:
(pwd_id, passcode) 元组。
Raises:
TransferError: URL 格式无法识别。
"""
pwd_id: Optional[str] = UcTransfer.parse_share_url(url)
if not pwd_id:
raise TransferError(
TransferErrorCode.URL_INVALID,
message=f"无法解析UC链接: {url}",
platform=self.PLATFORM_KEY,
)
parsed = urlparse(url)
params = parse_qs(parsed.query)
passcode: str = params.get("pwd", params.get("code", [""]))[0]
return pwd_id, passcode
def update_cookie(self, cookie: str) -> None:
"""动态更新 Cookie 并同步到 session headers。
Args:
cookie: 新的 Cookie 字符串。
"""
self._credential.update_cookie(cookie)
self._setup_session()
logger.info("[UcAdapter] Cookie updated, new length=%d", len(cookie))
def close(self) -> None:
"""关闭所有子模块的 HTTP 会话。"""
self._transfer_engine.close()
+218
View File
@@ -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()})"
)
+619
View File
@@ -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,112 @@
"""
CloudSearch Transfer — 迅雷网盘适配器 v1.0.0
PLATFORM_KEY = 'xunlei'
迅雷网盘使用 refresh_token + captcha_token 双重认证。
"""
from __future__ import annotations
import logging
from typing import List, Optional, Tuple
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]+)"]
def __init__(self, config: PlatformConfig, transfer_config: TransferConfig):
super().__init__(config, transfer_config)
self._credential = XunleiCredentialManager(config)
self._transfer_engine: Optional[XunleiTransfer] = None
self._cleanup = XunleiCleanup()
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(
self.session,
self._credential,
self.config,
self.transfer_config,
)
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()
return self._transfer.save_files(pwd_id, detail, save_dir)
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, title, 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()
return self._transfer.list_files(parent_fid)
def delete(self, file_ids: List[str]) -> bool:
self._ensure_auth()
return self._cleanup.delete_files(
self.session, self._credential, 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,518 @@
"""
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) -> 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}
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],
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,
}
# 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
# ─── 公开入口 ─────────────────────────────────────────────────
def transfer(
self,
share_url: str,
save_dir: str = "",
share_password: str = "",
) -> Dict[str, Any]:
"""执行完整的 4 步转存流程。
从原始迅雷分享链接开始,将文件转存到自己网盘,再创建新分享。
Args:
share_url: 原始迅雷分享链接,如 https://pan.xunlei.com/s/xxxxx。
save_dir: 转存目标目录 ID,空字符串表示根目录。
share_password: 新分享的密码(迅雷使用 pass_code)。
Returns:
包含以下字段的字典:
- success: bool
- new_file_ids: List[str] — 转存后的文件ID列表(newId)
- file_name: str — 分享标题
- share_url: str — 新分享链接
- passcode: str — 新分享 pass_code
Raises:
RuntimeError: 任一步骤失败。
ValueError: URL 解析失败。
"""
# 0. 解析 URL 提取 share_id
match = SHARE_URL_PATTERN.search(share_url)
if not match:
raise ValueError(f"无法从URL中提取迅雷分享ID: {share_url}")
share_id: str = match.group(1)
logger.info(
"[XunleiTransfer] Starting 4-step transfer for share_id=%s", share_id
)
# ① 获取分享详情
share_info: Dict[str, Any] = self._get_share_info(share_id)
files: List[Dict[str, Any]] = share_info.get("files", [])
title: str = share_info.get("title", "分享")
pass_code_token: str = share_info.get("pass_code_token", "")
# 提取原始文件 ID
file_ids: List[str] = [
f.get("file_id", f.get("fid", f.get("id", "")))
for f in files
if f.get("file_id") or f.get("fid") or f.get("id")
]
if not file_ids:
raise RuntimeError("无法从分享中提取文件ID")
# ② 发起转存
task_id: str = self._restore_files(
share_id, pass_code_token, file_ids, parent_id=save_dir
)
# ③ 轮询转存任务 → 获取 oldId→newId 映射
id_mapping: Dict[str, str] = self._poll_restore_task(task_id)
# 从映射中提取新的文件 ID
new_file_ids: List[str] = []
for old_fid in file_ids:
new_fid = id_mapping.get(old_fid, "")
if new_fid:
new_file_ids.append(new_fid)
else:
logger.warning(
"[XunleiTransfer] No newId mapped for old_fid=%s", old_fid
)
if not new_file_ids:
raise RuntimeError("转存完成但未获取到新文件ID")
# ④ 创建新分享
share_url_new, pass_code = self._create_share(new_file_ids)
logger.info(
"[XunleiTransfer] Transfer complete: %d files, new_share=%s",
len(new_file_ids),
share_url_new,
)
return {
"success": True,
"new_file_ids": new_file_ids,
"file_name": title,
"share_url": share_url_new,
"passcode": pass_code or share_password,
}
@staticmethod
def parse_share_url(url: str) -> Optional[str]:
"""从迅雷分享 URL 中提取 share_id。
Args:
url: 迅雷分享链接。
Returns:
share_id 字符串,解析失败返回 None。
"""
match = SHARE_URL_PATTERN.search(url)
return match.group(1) if match else None
@staticmethod
def extract_file_ids(files: List[Dict[str, Any]]) -> List[str]:
"""从文件列表中提取 file_id。
Args:
files: 文件信息字典列表。
Returns:
file_id 字符串列表。
"""
return [
f.get("file_id", f.get("fid", f.get("id", "")))
for f in files
if f.get("file_id") or f.get("fid") or f.get("id")
]
@staticmethod
def parse_trace_file_ids(trace: str) -> Dict[str, str]:
"""解析 trace_file_ids JSON 字符串为 oldId→newId 映射。
Args:
trace: trace_file_ids JSON 字符串,如 '{"oldId":"newId"}'.
Returns:
{"oldId": "newId", ...} 映射字典。
"""
try:
return json.loads(trace)
except (json.JSONDecodeError, TypeError):
return {}
def close(self) -> None:
"""关闭 HTTP 会话。"""
self.session.close()
def __enter__(self) -> "XunleiTransfer":
return self
def __exit__(self, *args: Any) -> None:
self.close()
+172
View File
@@ -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[:20] + "..." if cfg.cookie else "",
"refresh_token": cfg.refresh_token[:20] + "..." if cfg.refresh_token else "",
"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 — 凭证管理包"""
+130
View File
@@ -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}")
+68
View File
@@ -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,
}
+68
View File
@@ -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")
+2
View File
@@ -0,0 +1,2 @@
flask>=3.0
requests>=2.28
+200
View File
@@ -0,0 +1,200 @@
"""
CloudSearch Transfer — HTTP API 服务 v1.0.0
以 Flask 微服务形式运行,与 CloudSearch 主应用通过 HTTP 通信
"""
import os
import uuid
import logging
from flask import Flask, request, jsonify
from config import ConfigManager
from 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())
# ─── 配置管理 ──────────────────────────────────────────
@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):
"""更新平台配置"""
data = request.get_json() or {}
if name not in config.platforms:
from config import PlatformConfig
config.platforms[name] = PlatformConfig()
cfg = config.platforms[name]
if "enabled" in data:
cfg.enabled = 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)
+37
View File
@@ -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
+153
View File
@@ -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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 381 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 467 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 894 B

-4147
View File
File diff suppressed because it is too large Load Diff
-41
View File
@@ -1,41 +0,0 @@
{
"name": "cloudsearch-backend",
"version": "0.1.1",
"private": true,
"scripts": {
"dev": "tsx watch src/main.ts",
"build": "tsc",
"start": "node dist/main.js",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.0.0",
"cors": "^2.8.5",
"express": "^4.21.0",
"express-rate-limit": "^7.4.0",
"helmet": "^8.0.0",
"https-proxy-agent": "^9.0.0",
"ioredis": "^5.4.0",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"playwright": "^1.52.0",
"sharp": "^0.33.0",
"uuid": "^10.0.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.11",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.6",
"@types/morgan": "^1.9.9",
"@types/multer": "^1.4.12",
"@types/uuid": "^10.0.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0",
"vitest": "^2.1.0"
}
}
-4273
View File
File diff suppressed because it is too large Load Diff
@@ -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,585 +0,0 @@
import { getDb } from '../database/database';
import { localTimestamp, formatLocalDate, formatLocalDateTime } from '../utils/time';
import { encrypt, decrypt, isEncrypted } from '../utils/crypto';
// ── Background Used-Space Calculation ──────────────────────────
/**
* Fire-and-forget: recursively calculate used space for a quark drive
* and update the database when done.
*/
async function calculateUsedSpaceAsync(cookie: string, configId: number): Promise<void> {
const { calculateUsedSpace } = require('./drivers/quark-cleanup');
const usedBytes = await calculateUsedSpace(cookie);
if (usedBytes > 0) {
const usedFormatted = usedBytes >= 1024 ** 4
? (usedBytes / 1024 ** 4).toFixed(1) + ' TB'
: usedBytes >= 1024 ** 3
? (usedBytes / 1024 ** 3).toFixed(1) + ' GB'
: (usedBytes / 1024 ** 2).toFixed(1) + ' MB';
const db = getDb();
db.prepare(
`UPDATE cloud_configs SET storage_used = ?, updated_at = ? WHERE id = ?`
).run(usedFormatted, localTimestamp(), configId);
console.log(`[UsedSpace] Updated config #${configId}: used=${usedFormatted}`);
}
}
export interface CloudConfig {
id: number;
cloud_type: string;
cookie?: string;
nickname?: string;
is_active: number;
promotion_account?: string;
is_transfer_enabled: number;
is_primary: number;
storage_used?: string;
storage_total?: string;
checkin_status: string; // 'none'|'success'|'failed'|'pending'|'skipped'
last_checkin_at?: string;
checkin_message?: string;
consecutive_failures: number;
last_used_at?: string;
total_saves: number;
created_at: string;
updated_at: string;
verification_status?: string;
cloud_type_uid?: string;
}
// ── Cookie Encryption Helper ──────────────────────────────────────
/** Decrypt cookie. Handles legacy plaintext data transparently. */
function decryptCookie(encrypted: string | null | undefined): string {
if (!encrypted) return '';
// If already plaintext (legacy data), return as-is
if (!isEncrypted(encrypted)) return encrypted;
return decrypt(encrypted);
}
/**
* Extract Quark __uid from cookie string.
* Used for dedup: same cloud_type + same __uid = same account.
*/
function extractQuarkUid(cookie: string): string | null {
const match = cookie.match(/(?:^|;\s*)__uid=([^;]+)/);
return match ? match[1] : null;
}
// ── Config CRUD ──────────────────────────────────────────────────
export function getCloudConfigs(): CloudConfig[] {
const db = getDb();
return db.prepare(
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, is_primary, storage_used, storage_total,
cloud_type_uid,
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
last_used_at, total_saves, created_at, updated_at, verification_status
FROM cloud_configs ORDER BY id ASC`
).all() as CloudConfig[];
}
export function getAvailableClouds(): CloudConfig[] {
const db = getDb();
return db.prepare(
`SELECT id, cloud_type, nickname, is_active, promotion_account, is_transfer_enabled, is_primary, storage_used, storage_total,
cloud_type_uid,
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
last_used_at, total_saves, created_at, updated_at
FROM cloud_configs WHERE is_active = 1 ORDER BY id ASC`
).all() as CloudConfig[];
}
/** Returns the first active config matching the given cloud type. */
export function getCloudConfigByType(cloudType: string): CloudConfig | undefined {
const db = getDb();
return db.prepare(
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, is_primary, storage_used, storage_total,
cloud_type_uid,
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
last_used_at, total_saves, created_at, updated_at, verification_status
FROM cloud_configs WHERE cloud_type = ? AND is_active = 1
ORDER BY id ASC LIMIT 1`
).get(cloudType) as CloudConfig | undefined;
}
export function getCloudConfigById(id: number): CloudConfig | undefined {
const db = getDb();
return db.prepare(
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, is_primary, storage_used, storage_total,
cloud_type_uid,
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
last_used_at, total_saves, created_at, updated_at, verification_status
FROM cloud_configs WHERE id = ?`
).get(id) as CloudConfig | undefined;
}
/** Returns all active cloud configs (used by save flow for cloud type switching). */
export function getActiveCloudConfigs(): CloudConfig[] {
const db = getDb();
return db.prepare(
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, is_primary, storage_used, storage_total,
cloud_type_uid,
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
last_used_at, total_saves, created_at, updated_at
FROM cloud_configs WHERE is_active = 1
ORDER BY cloud_type ASC, id ASC`
).all() as CloudConfig[];
}
/**
* Toggle the is_primary flag for a cloud config.
* Enforces max 2 primary accounts per cloud type.
*/
export function togglePrimary(id: number, setPrimary: boolean): CloudConfig {
const db = getDb();
const config = getCloudConfigById(id);
if (!config) throw new Error(`Cloud config ${id} not found`);
if (setPrimary) {
// Check how many primary accounts already exist for this cloud type
const primaryCount = db.prepare(
`SELECT COUNT(*) as c FROM cloud_configs WHERE cloud_type = ? AND is_primary = 1 AND id != ?`
).get(config.cloud_type, id) as { c: number };
if (primaryCount.c >= 2) {
throw new Error(`同类型网盘最多只能设置 2 个默认账号(已存在 ${primaryCount.c} 个)`);
}
}
db.prepare(`UPDATE cloud_configs SET is_primary = ?, updated_at = datetime('now', 'localtime') WHERE id = ?`)
.run(setPrimary ? 1 : 0, id);
return getCloudConfigById(id)!;
}
export function saveCloudConfig(data: {
id?: number;
cloud_type: string;
cookie?: string;
nickname?: string;
is_active?: number;
promotion_account?: string;
is_transfer_enabled?: number;
storage_used?: string;
storage_total?: string;
}): CloudConfig {
const db = getDb();
// Encrypt cookie before storing
const encryptedCookie = data.cookie ? encrypt(data.cookie) : null;
// Extract cloud_type_uid from cookie (Quark __uid)
let cloudTypeUid: string | null = null;
if (data.cookie) {
cloudTypeUid = extractQuarkUid(data.cookie);
}
if (data.id) {
// Update by ID — always succeeds
db.prepare(
`UPDATE cloud_configs SET
cloud_type = COALESCE(?, cloud_type),
cookie = COALESCE(?, cookie),
nickname = COALESCE(?, nickname),
is_active = COALESCE(?, is_active),
promotion_account = COALESCE(?, promotion_account),
is_transfer_enabled = COALESCE(?, is_transfer_enabled),
storage_used = COALESCE(?, storage_used),
storage_total = COALESCE(?, storage_total),
cloud_type_uid = COALESCE(?, cloud_type_uid),
consecutive_failures = 0,
updated_at = ?
WHERE id = ?`
).run(data.cloud_type, encryptedCookie || null, data.nickname || null, data.is_active == null ? 1 : Number(data.is_active), data.promotion_account ?? '', data.is_transfer_enabled == null ? 1 : Number(data.is_transfer_enabled), data.storage_used || null, data.storage_total || null, cloudTypeUid || null, localTimestamp(), data.id);
} else {
// Try to find existing config by cloud_type + cloud_type_uid
let existing: any = null;
if (cloudTypeUid) {
existing = db.prepare(
`SELECT id FROM cloud_configs WHERE cloud_type = ? AND cloud_type_uid = ? LIMIT 1`
).get(data.cloud_type, cloudTypeUid);
}
// Fallback: match by cloud_type alone (legacy records without cloud_type_uid)
if (!existing) {
existing = db.prepare(
'SELECT id FROM cloud_configs WHERE cloud_type = ? AND is_active = 1 LIMIT 1'
).get(data.cloud_type) as any;
}
if (existing) {
db.prepare(
`UPDATE cloud_configs SET
cookie = COALESCE(?, cookie),
nickname = COALESCE(?, nickname),
is_active = COALESCE(?, is_active),
promotion_account = COALESCE(?, promotion_account),
is_transfer_enabled = COALESCE(?, is_transfer_enabled),
storage_used = COALESCE(?, storage_used),
storage_total = COALESCE(?, storage_total),
cloud_type_uid = COALESCE(?, cloud_type_uid),
consecutive_failures = 0,
updated_at = ?
WHERE id = ?`
).run(encryptedCookie || null, data.nickname || null, data.is_active == null ? 1 : Number(data.is_active), data.promotion_account ?? '', data.is_transfer_enabled == null ? 1 : Number(data.is_transfer_enabled), data.storage_used || null, data.storage_total || null, cloudTypeUid || null, localTimestamp(), existing.id);
// Re-read savedId for return
const savedId = existing.id;
return db.prepare(
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, is_primary, storage_used, storage_total,
cloud_type_uid,
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
last_used_at, total_saves, created_at, updated_at
FROM cloud_configs WHERE id = ?`
).get(savedId) as CloudConfig;
}
// No existing config found — insert new
db.prepare(
'INSERT INTO cloud_configs (cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total, cloud_type_uid, consecutive_failures) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0)'
).run(data.cloud_type, encryptedCookie || null, data.nickname || null, data.is_active == null ? 1 : Number(data.is_active), data.promotion_account ?? '', data.is_transfer_enabled == null ? 1 : Number(data.is_transfer_enabled), data.storage_used || null, data.storage_total || null, cloudTypeUid || null);
}
const savedId = data.id || (db.prepare('SELECT last_insert_rowid() as id').get() as any).id;
return db.prepare(
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, is_primary, storage_used, storage_total,
cloud_type_uid,
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
last_used_at, total_saves, created_at, updated_at
FROM cloud_configs WHERE id = ?`
).get(savedId) as CloudConfig;
}
export function deleteCloudConfig(id: number): boolean {
const db = getDb();
const result = db.prepare('DELETE FROM cloud_configs WHERE id = ?').run(id);
return result.changes > 0;
}
// ── Cookie Validation ────────────────────────────────────────────
async function fetchQuarkNickname(cookie: string): Promise<string | null> {
const MAX_RETRIES = 2;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
const response = await fetch('https://pan.quark.cn/account/info?fr=pc&platform=pc', {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) quark-cloud-drive/3.14.2 Chrome/112.0.5615.165 Electron/24.1.3.8 Safari/537.36 Channel/pckk_other_ch',
'Cookie': cookie,
'Accept': 'application/json',
'Referer': 'https://pan.quark.cn/',
},
signal: AbortSignal.timeout(15000),
});
if (!response.ok) return null;
const data = await response.json() as any;
if (data?.data?.nickname) return data.data.nickname;
} catch {
if (attempt < MAX_RETRIES) {
await new Promise(r => setTimeout(r, 1500));
continue;
}
}
}
return null;
}
export async function testCloudConnection(id: number): Promise<{
success: boolean;
message: string;
nickname?: string;
storage_used?: string;
storage_total?: string;
}> {
const config = getCloudConfigById(id);
if (!config) {
return { success: false, message: 'Cloud config not found' };
}
if (!config.cookie) {
return { success: false, message: 'Cookie not configured' };
}
try {
let valid = false;
let nickname = '';
let storageUsed = config.storage_used || '';
let storageTotal = config.storage_total || '';
if (config.cloud_type === 'baidu') {
const { BaiduDriver } = require('./drivers/baidu.driver');
const driver = new BaiduDriver({ cookie: config.cookie, nickname: config.nickname });
valid = await driver.validate();
if (valid) {
const info = await driver.getUserInfo();
if (info) {
nickname = config.nickname || info.nickname || '百度网盘';
const fmt = (b: number) => b >= 1024**3 ? (b/1024**3).toFixed(2)+' GB' : (b/1024**2).toFixed(2)+' MB';
storageUsed = fmt(info.usedBytes);
storageTotal = fmt(info.totalBytes);
}
}
} else {
const decodedCookie = decrypt(config.cookie);
const { QuarkDriver } = require('./drivers/quark.driver');
const driver = new QuarkDriver({ cookie: decodedCookie, nickname: config.nickname });
valid = await driver.validate();
if (valid) {
nickname = config.nickname || (await fetchQuarkNickname(decodedCookie)) || '夸克网盘';
const storage = await driver.getStorageInfoQuick(config.storage_total);
storageTotal = (storage.total !== '-' && storage.total !== '0 B') ? storage.total : (config.storage_total || '');
storageUsed = (storage.used && storage.used !== '-' && storage.used !== '0 B') ? storage.used : (config.storage_used || '');
}
}
const db = getDb();
if (!valid) {
db.prepare(
`UPDATE cloud_configs SET verification_status = 'invalid', updated_at = ? WHERE id = ?`
).run(localTimestamp(), id);
return { success: false, message: '连接失败:Cookie 无效或已过期,或网络暂时异常' };
}
db.prepare(
`UPDATE cloud_configs SET nickname = ?, storage_total = ?, storage_used = ?, is_active = 1, verification_status = 'valid', updated_at = ? WHERE id = ?`
).run(nickname, storageTotal, storageUsed, localTimestamp(), id);
// Fire-and-forget: recalculate used space in background (slow for big drives)
if (config.cloud_type === 'quark') {
calculateUsedSpaceAsync(decrypt(config.cookie), id).catch(err => console.error(`[UsedSpace] Background calc failed for #${id}:`, err.message));
}
return {
success: true,
message: '连接成功',
nickname,
storage_used: storageUsed,
storage_total: storageTotal,
};
} catch (err: any) {
try {
const db = getDb();
db.prepare(
`UPDATE cloud_configs SET verification_status = 'invalid', updated_at = ? WHERE id = ?`
).run(localTimestamp(), id);
} catch {}
return { success: false, message: `连接失败:${err.message || '未知错误'}` };
}
}
export async function testCloudConnectionWithCookie(cloudType: string, cookie: string): Promise<{
success: boolean;
message: string;
nickname?: string;
storage_used?: string;
storage_total?: string;
}> {
try {
const { QuarkDriver } = require('./drivers/quark.driver');
const driver = new QuarkDriver({ cookie, nickname: '' });
const valid = await driver.validate();
if (!valid) {
return { success: false, message: '连接失败:Cookie 无效或已过期' };
}
const nickname = (await fetchQuarkNickname(cookie)) || cloudType;
// getStorageInfo may timeout from overseas servers, don't fail if it does
let storage: { used: string; total: string } = { used: '-', total: '-' };
try {
const s = await driver.getStorageInfoQuick();
if (s) {
storage = { used: s.used || '-', total: s.total || '-' };
}
} catch {
// storage info is optional
}
return {
success: true,
message: '连接成功',
nickname,
storage_used: storage.used,
storage_total: storage.total,
};
} catch (err: any) {
return { success: false, message: `连接失败:${err.message || '未知错误'}` };
}
}
// ── Unified Credential Validation ─────────────────────────────────
export interface CredentialValidationResult {
valid: boolean;
config?: CloudConfig;
errorCode?: string;
message: string;
}
/**
* Get and validate a credential for the given cloud type.
*
* This is the unified entry point for all save/transfer operations.
* It handles:
* 1. Finding an active config with < 5 consecutive failures (round-robin)
* 2. Validating cookie freshness via driver.validate()
* 3. Returning structured result with error codes
*
* Reference: search-ucmao get_and_validate_credential() pattern.
*/
export async function getAndValidateCredential(cloudType: string, ipAddress?: string): Promise<CredentialValidationResult> {
const db = getDb();
let config: CloudConfig | undefined;
if (!ipAddress) {
// No IP info — fallback to simple LUR
config = db.prepare(
`SELECT * FROM cloud_configs
WHERE cloud_type = ? AND is_active = 1
AND consecutive_failures < 5
ORDER BY is_primary DESC, last_used_at ASC NULLS FIRST
LIMIT 1`
).get(cloudType) as CloudConfig | undefined;
} else {
// Get today's date in Shanghai time
const today = (() => {
const now = new Date();
const shanghai = new Date(now.toLocaleString('en-US', { timeZone: 'Asia/Shanghai' }));
return shanghai.toISOString().slice(0, 10);
})();
// Count how many times this IP has saved today for this cloud type
const ipCountRow = db.prepare(
`SELECT COALESCE(SUM(save_count), 0) as total
FROM ip_daily_save_counts
WHERE ip_address = ? AND date = ? AND cloud_type = ?`
).get(ipAddress, today, cloudType) as { total: number };
const ipTodayCount = ipCountRow?.total || 0;
// How many primary accounts does this cloud type have?
const primaryCountRow = db.prepare(
`SELECT COUNT(*) as c FROM cloud_configs WHERE cloud_type = ? AND is_primary = 1 AND is_active = 1`
).get(cloudType) as { c: number };
const primaryCount = primaryCountRow?.c || 0;
const primaryThreshold = primaryCount * 2; // Each primary account gets 2 uses per IP
if (ipTodayCount < primaryThreshold) {
// First N saves (primaryCount × 2) — use primary accounts (is_primary=1), fallback to any healthy
config = db.prepare(
`SELECT * FROM cloud_configs
WHERE cloud_type = ? AND is_active = 1
AND consecutive_failures < 5
ORDER BY is_primary DESC, last_used_at ASC NULLS FIRST
LIMIT 1`
).get(cloudType) as CloudConfig | undefined;
} else {
// After primary threshold — exclude accounts this IP has already used today,
// fall back to other available accounts round-robin
const usedConfigIds = db.prepare(
`SELECT DISTINCT config_id FROM ip_daily_save_counts
WHERE ip_address = ? AND date = ? AND cloud_type = ?
ORDER BY config_id`
).all(ipAddress, today, cloudType) as { config_id: number }[];
const usedIds = usedConfigIds.map(r => r.config_id);
const placeholders = usedIds.length > 0 ? usedIds.map(() => '?').join(',') : '-1';
config = db.prepare(
`SELECT * FROM cloud_configs
WHERE cloud_type = ? AND is_active = 1
AND consecutive_failures < 5
AND id NOT IN (${placeholders})
ORDER BY last_used_at ASC NULLS FIRST
LIMIT 1`
).get(cloudType, ...usedIds) as CloudConfig | undefined;
// If all accounts have been used by this IP, fall back to primary
if (!config) {
config = db.prepare(
`SELECT * FROM cloud_configs
WHERE cloud_type = ? AND is_active = 1
AND consecutive_failures < 5
ORDER BY is_primary DESC, last_used_at ASC NULLS FIRST
LIMIT 1`
).get(cloudType) as CloudConfig | undefined;
}
}
}
if (!config) {
return {
valid: false,
errorCode: 'NO_AVAILABLE_DRIVE',
message: `Cloud type "${cloudType}" is not configured or no available drives`,
};
}
if (!config.cookie) {
return {
valid: false,
errorCode: 'COOKIE_MISSING',
message: `Cookie not configured for ${cloudType} drive #${config.id}`,
};
}
try {
// Decrypt cookie before validation
const decryptedCookie = decryptCookie(config.cookie);
if (!decryptedCookie) {
return {
valid: false,
errorCode: 'COOKIE_MISSING',
message: `Cookie not configured for ${cloudType} drive #${config.id}`,
};
}
let cookieValid = false;
if (cloudType === 'baidu') {
const { BaiduDriver } = require('./drivers/baidu.driver');
const driver = new BaiduDriver({ cookie: decryptedCookie, nickname: config.nickname });
cookieValid = await driver.validate();
} else {
const { QuarkDriver } = require('./drivers/quark.driver');
const driver = new QuarkDriver({ cookie: decryptedCookie, nickname: config.nickname });
cookieValid = await driver.validate();
}
if (!cookieValid) {
db.prepare(
`UPDATE cloud_configs SET verification_status = 'invalid', updated_at = ? WHERE id = ?`
).run(localTimestamp(), config.id);
return {
valid: false,
errorCode: 'COOKIE_EXPIRED',
message: `Cookie expired or invalid for ${cloudType} drive #${config.id}`,
};
}
// Track IP daily usage count (if ipAddress provided)
if (ipAddress && config) {
const today = (() => {
const now = new Date();
const shanghai = new Date(now.toLocaleString('en-US', { timeZone: 'Asia/Shanghai' }));
return shanghai.toISOString().slice(0, 10);
})();
db.prepare(
`INSERT INTO ip_daily_save_counts (ip_address, date, cloud_type, config_id, save_count)
VALUES (?, ?, ?, ?, 1)
ON CONFLICT(ip_address, date, cloud_type, config_id)
DO UPDATE SET save_count = save_count + 1`
).run(ipAddress, today, cloudType, config.id);
}
return {
valid: true,
config: { ...config, cookie: decryptedCookie },
message: 'ok',
};
} catch (err: any) {
return {
valid: false,
errorCode: 'VALIDATION_ERROR',
message: `Credential validation failed: ${err.message}`,
};
}
}
@@ -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,301 +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")
.flatMap((line) => line.split(","))
.map((s) => s.trim())
.filter(Boolean);
}
/** 从 DB 读取警示文件夹名称列表 */
export function getWarningFolderNames(): string[] {
const raw = getSystemConfig("quark_warning_folder_names") || "";
return raw
.split("\n")
.flatMap((line) => line.split(","))
.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> {
const extensions = getSusExtensions();
if (!keywords.length && !extensions.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> {
if (!fids.length) return true;
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: 1,
filelist: fids,
exclude_filelist: [],
}),
signal: AbortSignal.timeout(30000),
},
);
if (!resp.ok) {
console.log(`[Quark-AdCleanup] batchDelete HTTP ${resp.status}`);
return false;
}
const data = (await resp.json()) as any;
if (data.status === 200) {
return true;
}
console.log(
`[Quark-AdCleanup] batchDelete 返回非200: status=${data.status} msg=${data.message}`,
);
return false;
} catch (err: any) {
console.log(`[Quark-AdCleanup] batchDelete 错误: ${err.message}`);
return false;
}
}
// ==================== 警示文件夹创建 ====================
/**
* 在转存根目录下创建警示文件夹。
* 文件夹名前加 ⚠️ 和空格,让其按字母排序置顶。
* 已存在的则跳过。
*/
export async function createWarningDirectories(
cookie: string,
dirNames: string[],
parentDirFid: string = "0",
): Promise<void> {
if (!dirNames.length) return;
// 先获取根目录下所有文件夹,避免重复创建
await humanDelay();
const rootFiles = await listDirAllPages(cookie, parentDirFid);
const existingDirs = new Set(
rootFiles.filter((f) => f.dir).map((f) => f.file_name),
);
for (const name of dirNames) {
// 格式化名称:确保以 ⚠️ 开头
let formattedName = name;
if (!formattedName.startsWith("⚠️") && !formattedName.startsWith("⚠")) {
formattedName = `⚠️ ${formattedName}`;
}
// 去掉多余空格
formattedName = formattedName.replace(/\s+/g, " ").trim();
if (existingDirs.has(formattedName)) {
console.log(
`[Quark-AdCleanup] 警示文件夹已存在,跳过: "${formattedName}"`,
);
continue;
}
await createSingleDir(cookie, formattedName, parentDirFid);
// 加入已存在集合,防止同名重试
existingDirs.add(formattedName);
}
}
/**
* 创建单个文件夹。
*/
async function createSingleDir(
cookie: string,
dirName: string,
pdirFid: string = "0",
): Promise<boolean> {
try {
const resp = await fetch(
`https://drive-pc.quark.cn/1/clouddrive/file?${makeQuery()}`,
{
method: "POST",
headers: {
...getHeaders(cookie),
"Content-Type": "application/json",
},
body: JSON.stringify({
pdir_fid: pdirFid,
file_name: dirName,
dir: true,
dir_path: "",
}),
signal: AbortSignal.timeout(10000),
},
);
const data = (await resp.json()) as any;
if (data.status === 200 && data.data?.fid) {
console.log(
`[Quark-AdCleanup] 已创建警示文件夹: "${dirName}" (fid: ${data.data.fid})`,
);
return true;
}
console.log(
`[Quark-AdCleanup] 创建文件夹失败: status=${data.status} msg=${data.message}`,
);
return false;
} catch (err: any) {
console.log(
`[Quark-AdCleanup] 创建文件夹错误: "${dirName}" — ${err.message}`,
);
return false;
}
}
// ==================== 主入口 ====================
/**
* 执行广告清理 + 创建警示文件夹。
* 在转存重命名后调用。
*/
export async function runAdCleanup(
cookie: string,
savedDirFid: string,
): Promise<{ adDeleted: number; warningDirs: number }> {
const keywords = getAdKeywords();
const susExtensions = getSusExtensions();
const warningNames = getWarningFolderNames();
let adDeleted = 0;
let warningDirs = 0;
// 1. 广告关键词 + 可疑后缀清理
if (keywords.length > 0 || susExtensions.length > 0) {
console.log(
`[Quark-AdCleanup] 开始文件清理: ${keywords.length} 个关键词, ${susExtensions.length} 个可疑后缀`,
);
adDeleted = await deleteAdFiles(cookie, savedDirFid, keywords);
console.log(
`[Quark-AdCleanup] 清理完成,共删除 ${adDeleted} 个文件/文件夹`,
);
} else {
console.log("[Quark-AdCleanup] 无关键词/可疑后缀配置,跳过清理");
}
// 2. 创建警示文件夹
if (warningNames.length > 0) {
console.log(
`[Quark-AdCleanup] 开始创建警示文件夹: ${warningNames.length} 个`,
);
await createWarningDirectories(cookie, warningNames, savedDirFid);
warningDirs = warningNames.length;
console.log(
`[Quark-AdCleanup] 警示文件夹创建完成(共 ${warningDirs} 个)`,
);
} else {
console.log("[Quark-AdCleanup] 无警示文件夹配置,跳过创建");
}
return { adDeleted, warningDirs };
}
@@ -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,347 +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 params = new URLSearchParams(getCommonParams());
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);
}
}
}
// Accurate used space via /member API (1 call, no full traversal needed)
// Ref: pan.quark.cn/1/clouddrive/member returns use_capacity + total_capacity
let usedBytes = 0;
try {
const memberParams = new URLSearchParams({ pr: 'ucpro', fr: 'pc', uc_param_str: '', __t: String(Date.now()), __dt: '1000' });
const memberResp = await fetch(`https://pan.quark.cn/1/clouddrive/member?${memberParams.toString()}`, {
headers: getHeaders(cookie),
signal: AbortSignal.timeout(10000),
});
if (memberResp.ok) {
const memberData = await memberResp.json() as any;
if (memberData.status === 200 && memberData.data?.use_capacity != null) {
usedBytes = memberData.data.use_capacity;
}
}
} catch {}
// Fallback: sum root-level file sizes (夸克 folders return size=0)
if (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.
*/
/**
* Fast estimation (root-level files only) + background full traversal.
* First call returns quickly; full traversal runs async and updates DB later.
* `onBackgroundComplete` is called when traversal finishes.
*/
export async function getStorageInfo(
cookie: string,
onBackgroundComplete?: (used: string, total: string) => void
): Promise<{ used: string; total: string; usedBytes: number; totalBytes: number }> {
try {
const params = new URLSearchParams(getCommonParams());
const response = await fetch(`${BASE_URL}/1/clouddrive/capacity/detail?${params.toString()}`, {
headers: getHeaders(cookie),
signal: AbortSignal.timeout(10000),
});
let totalBytes = 0;
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 totalFormatted = totalBytes > 0 ? formatBytes(totalBytes) : '-';
// Quick estimation: sum root-level files only
let quickUsed = 0;
try {
const rootFiles = await listRootDir(cookie);
for (const f of rootFiles) {
quickUsed += f.size || 0;
}
} catch {}
// Budget full traversal in background (no await)
calculateUsedSpace(cookie).then(fullUsed => {
if (onBackgroundComplete) {
onBackgroundComplete(formatBytes(fullUsed), totalFormatted);
}
}).catch(err => {
console.error('[Storage] Background full traversal failed:', err.message);
});
return {
total: totalFormatted,
totalBytes,
used: formatBytes(quickUsed),
usedBytes: quickUsed,
};
} 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/delete?${makeQuery()}`,
{
method: 'POST',
headers: { ...getHeaders(cookie), 'Content-Type': 'application/json' },
body: JSON.stringify({
action_type: 1, // 1 = move to trash
filelist: fids.map(fid => ({ fid })),
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, whitelistDirs?: string[]): 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;
if (whitelistDirs && whitelistDirs.includes(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,
whitelistDirs?: string[],
): 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))
.filter(item => !whitelistDirs || !whitelistDirs.includes(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,314 +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;
fileSize?: 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;
}
}
// Calculate total file size
const allFiles = topDir && childFiles ? childFiles : topFiles;
const fileSize = allFiles.reduce((sum, f) => sum + (Number(f.size) || 0), 0);
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,
fileSize,
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(onBackgroundComplete?: (used: string, total: string) => void) {
return getStorageInfo(this.config.cookie, onBackgroundComplete);
}
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,227 +0,0 @@
// ============================================================
// notification.service.ts — 插件化消息推送
// 基于 notifiers/ 注册器,支持 14+ 个推送通道
// 支持:全局推送(系统配置)和按网盘配置推送
// ============================================================
import { getSystemConfig } from '../admin/system-config.service';
import { findPushUserForConfig } from './push-user.service';
import { getAllNotifiers, getNotifier, notifyWith } from './notifiers';
export { getAllNotifiers, getNotifier, getAllNotifierParams } from './notifiers';
export type { NotifyLevel, Notifier, NotifierParam, NotifyParams, NotifyResult } from './notifiers/notifier.types';
/** 用户级推送配置(存储在 cloud_configs.notify_config) */
export interface PerConfigNotify {
// 每个通道的配置 = { channelName: { paramKey: value, ... } }
channels?: Record<string, Record<string, string>>;
events?: {
on_save_success?: boolean;
on_save_fail?: boolean;
on_cookie_expire?: boolean;
on_cleanup?: boolean;
};
}
// ======================== 全局(系统级)通道管理 ========================
let _globalChannelsCache: { name: string; params: Record<string, string> }[] | null = null;
let _configHash: string = '';
function getGlobalNotifyConfigs(): { name: string; params: Record<string, string> }[] {
// 从 global_notify_config JSON 读取(格式同 push user 的 notify_config)
const raw = getSystemConfig('global_notify_config') || '{}';
let globalConfig: any = {};
try { globalConfig = JSON.parse(raw); } catch {}
const channels: { name: string; params: Record<string, string> }[] = [];
if (globalConfig.channels) {
for (const [name, params] of Object.entries(globalConfig.channels)) {
if (params && typeof params === 'object') {
channels.push({ name, params: { ...(params as Record<string, string>), title: 'CloudSearch' } });
}
}
}
return channels;
}
function checkEventEnabled(eventName: string): boolean {
try {
// 优先读 global_notify_config.events
const raw = getSystemConfig('global_notify_config') || '{}';
let globalConfig: any = {};
try { globalConfig = JSON.parse(raw); } catch {}
if (globalConfig.events && globalConfig.events[`on_${eventName}`] !== undefined) {
return globalConfig.events[`on_${eventName}`] !== false;
}
// 降级到旧字段
const val = getSystemConfig(`notify_on_${eventName}`);
return val !== 'false' && val !== '0';
} catch {
return true;
}
}
// ======================== 核心发送函数 ========================
async function sendToChannels(
channels: { name: string; params: Record<string, string> }[],
title: string,
content: string,
level: string
): Promise<void> {
for (const ch of channels) {
try {
await notifyWith(ch.name, {
...ch.params,
title,
content,
level,
});
} catch (err: any) {
console.error(`[Notify] ${ch.name} error:`, err.message);
}
}
}
// ======================== 导出 API ========================
/** 通过全局通道发送通知(不检查事件开关) */
export function notify(title: string, content: string, level: 'info' | 'warn' | 'error' = 'info'): void {
const channels = getGlobalNotifyConfigs();
if (channels.length === 0) return;
sendToChannels(channels, title, content, level).catch(() => {});
}
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');
}
/** 事件通知(检查全局事件开关) */
export function notifyEvent(
eventName: string,
title: string,
content: string,
level: 'info' | 'warn' | 'error' = 'info'
): void {
if (!checkEventEnabled(eventName)) return;
notify(title, content, level);
}
/** 按网盘配置发送通知(检查用户级推送配置,无配置则降级到全局) */
const DB_PATH = '/app/dist/database/database';
function getConfigNotifySettings(configId: number): PerConfigNotify {
try {
const { getDb } = require(DB_PATH);
const db = getDb();
const row = db.prepare('SELECT notify_config FROM cloud_configs WHERE id = ?').get(configId) as any;
if (row && row.notify_config) {
return JSON.parse(row.notify_config);
}
} catch {}
return {};
}
export function notifyConfigEvent(
configId: number,
eventName: string,
title: string,
content: string,
level: 'info' | 'warn' | 'error' = 'info'
): void {
// Find matching push user by cloud_configs.promotion_account
const pushUser = findPushUserForConfig(configId);
if (!pushUser) {
// No matching push user, fallback to global
notifyEvent(eventName, title, content, level);
return;
}
let notifyConfig: any = {};
try { notifyConfig = JSON.parse(pushUser.notify_config); } catch {}
// Check event switch
const eventKey = 'on_' + eventName;
if (notifyConfig.events && notifyConfig.events[eventKey] === false) return;
// Build channels from push user config
const userChannels: { name: string; params: Record<string, string> }[] = [];
if (notifyConfig.channels) {
for (const [name, params] of Object.entries(notifyConfig.channels)) {
userChannels.push({ name, params: { ...(params as Record<string, string>), title } });
}
}
if (userChannels.length > 0) {
sendToChannels(userChannels, title, content, level).catch(() => {});
} else {
// Fallback to global
notifyEvent(eventName, title, content, level);
}
}
/** 测试某个通道 */
export async function testChannel(
channelName: string,
account?: string
): Promise<{ success: boolean; message: string }> {
let params: Record<string, string> = {};
if (account) {
const pushUser = findPushUserForConfig(undefined);
// Use pushUser lookup by account instead
const { getPushUserByAccount } = require('./push-user.service');
const user = getPushUserByAccount(account);
if (user) {
let notifyConfig: any = {};
try { notifyConfig = JSON.parse(user.notify_config); } catch {}
const chParams = notifyConfig.channels?.[channelName];
if (!chParams) return { success: false, message: '该用户未配置此渠道' };
params = chParams;
} else {
return { success: false, message: '未找到该推送用户' };
}
} else {
const channels = getGlobalNotifyConfigs();
const ch = channels.find(c => c.name === channelName);
if (!ch) return { success: false, message: '全局未配置此渠道' };
params = ch.params;
}
const result = await notifyWith(channelName, {
...params,
title: '\ud83d\udd14 CloudSearch \u6d4b\u8bd5',
content: `\u8fd9\u662f\u4e00\u6761\u6d4b\u8bd5\u6d88\u606f\n\u901a\u9053: ${channelName}\n\u65f6\u95f4: ${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}`,
level: 'info',
});
return result;
}
/** 保存用户级推送配置到数据库 */
export function saveConfigNotifySettings(configId: number, notify: PerConfigNotify): void {
try {
const { getDb } = require(DB_PATH);
const db = getDb();
db.prepare('UPDATE cloud_configs SET notify_config = ? WHERE id = ?').run(
JSON.stringify(notify),
configId
);
} catch (err: any) {
console.error('[Notify] Failed to save config notify settings:', err.message);
}
}
/** 获取用户级推送配置 */
export function getConfigNotifySettingsJSON(configId: number): PerConfigNotify {
return getConfigNotifySettings(configId);
}
@@ -1,44 +0,0 @@
import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types';
const params: NotifierParam[] = [
{ key: 'key', label: 'Bark Key', type: 'text', required: true, placeholder: 'xxxxxxxxxxxxxxxxx' },
{ key: 'server', label: '服务器', type: 'url', default: 'https://api.day.app', required: false, placeholder: 'https://api.day.app' },
{ key: 'title', label: '标题', type: 'text', default: 'CloudSearch 通知', required: false },
{ key: 'content', label: '内容', type: 'text', required: true },
{ key: 'level', label: '级别', type: 'text', default: 'info', required: false },
];
export const barkNotifier: Notifier = {
name: 'bark',
label: 'Bark',
params,
async notify(params: NotifyParams): Promise<NotifyResult> {
try {
const key = params.key;
const server = (params.server || 'https://api.day.app').replace(/\/+$/, '');
const title = params.title || 'CloudSearch';
const content = params.content || '';
const level: string = params.level || 'info';
const icon = level === 'error' ? '⚠️' : level === 'warn' ? '🔔' : 'ℹ️';
const resp = await fetch(`${server}/${key}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: `${icon} ${title}`,
body: content,
group: 'CloudSearch',
level: level === 'error' ? 'timeSensitive' : 'active',
icon: '',
}),
});
if (!resp.ok) {
const text = await resp.text();
return { success: false, message: `HTTP ${resp.status}: ${text.slice(0, 100)}` };
}
return { success: true, message: 'Bark 推送成功' };
} catch (err: any) {
return { success: false, message: err.message };
}
},
};
@@ -1,34 +0,0 @@
import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types';
const params: NotifierParam[] = [
{ key: 'webhook_url', label: 'Webhook URL', type: 'url', required: true, placeholder: 'https://oapi.dingtalk.com/robot/send?access_token=xxx' },
{ key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false },
{ key: 'content', label: '内容', type: 'text', required: true },
{ key: 'level', label: '级别', type: 'text', default: 'info', required: false },
];
export const dingtalkNotifier: Notifier = {
name: 'dingtalk',
label: '钉钉机器人',
params,
async notify(params: NotifyParams): Promise<NotifyResult> {
try {
const level: string = params.level || 'info';
const text = `## ${params.title || 'CloudSearch'}\n${params.content || ''}`;
const resp = await fetch(params.webhook_url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
msgtype: 'markdown',
markdown: { title: params.title || 'CloudSearch', text },
at: { isAtAll: false },
}),
});
const data: any = await resp.json();
if (data.errcode === 0) return { success: true, message: '钉钉推送成功' };
return { success: false, message: data.errmsg || `HTTP ${resp.status}` };
} catch (err: any) {
return { success: false, message: err.message };
}
},
};
@@ -1,36 +0,0 @@
import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types';
const params: NotifierParam[] = [
{ key: 'webhook_url', label: 'Webhook URL', type: 'url', required: true, placeholder: 'https://discord.com/api/webhooks/...' },
{ key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false },
{ key: 'content', label: '内容', type: 'text', required: true },
{ key: 'level', label: '级别', type: 'text', default: 'info', required: false },
];
export const discordNotifier: Notifier = {
name: 'discord',
label: 'Discord',
params,
async notify(params: NotifyParams): Promise<NotifyResult> {
try {
const level: string = params.level || 'info';
const color = level === 'error' ? 0xff0000 : level === 'warn' ? 0xffa500 : 0x3498db;
const resp = await fetch(params.webhook_url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
embeds: [{
title: params.title || 'CloudSearch',
description: params.content || '',
color,
footer: { text: 'CloudSearch \u00b7 ' + new Date().toLocaleString('zh-CN') },
}],
}),
});
if (resp.ok) return { success: true, message: 'Discord 推送成功' };
return { success: false, message: `HTTP ${resp.status}` };
} catch (err: any) {
return { success: false, message: err.message };
}
},
};
@@ -1,33 +0,0 @@
import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types';
const params: NotifierParam[] = [
{ key: 'server', label: '服务器地址', type: 'url', required: true, placeholder: 'https://gotify.example.com' },
{ key: 'token', label: 'App Token', type: 'password', required: true, placeholder: 'Gotify App Token' },
{ key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false },
{ key: 'content', label: '内容', type: 'text', required: true },
{ key: 'priority', label: '优先级', type: 'number', default: 5, required: false },
];
export const gotifyNotifier: Notifier = {
name: 'gotify',
label: 'Gotify',
params,
async notify(params: NotifyParams): Promise<NotifyResult> {
try {
const server = params.server.replace(/\/+$/, '');
const resp = await fetch(`${server}/message?token=${params.token}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: params.title || 'CloudSearch',
message: params.content || '',
priority: params.priority || 5,
}),
});
if (resp.ok) return { success: true, message: 'Gotify 推送成功' };
return { success: false, message: `HTTP ${resp.status}` };
} catch (err: any) {
return { success: false, message: err.message };
}
},
};
@@ -1,34 +0,0 @@
/**
* 通知器类型定义 —— 参考 onepush 插件化设计
* 每个 provider 注册为一个 Notifier,统一 notify(params) 接口
* 新增通道只需在这里加一个文件 + 在 index.ts 注册
*/
export type NotifyLevel = 'info' | 'warn' | 'error';
export interface NotifyParams {
[key: string]: any;
}
export interface NotifyResult {
success: boolean;
message: string;
}
/** 每个 provider 必须实现这个接口 */
export interface Notifier {
name: string;
label: string;
/** 参数描述(用于前端动态生成表单) */
params: NotifierParam[];
notify(params: NotifyParams): Promise<NotifyResult>;
}
export interface NotifierParam {
key: string;
label: string;
type: 'text' | 'password' | 'url' | 'switch' | 'number';
placeholder?: string;
default?: any;
required: boolean;
}
@@ -1,34 +0,0 @@
import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types';
const params: NotifierParam[] = [
{ key: 'topic', label: 'Topic', type: 'text', required: true, placeholder: 'my-notification-topic' },
{ key: 'server', label: '服务器', type: 'url', default: 'https://ntfy.sh', required: false, placeholder: 'https://ntfy.sh' },
{ key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false },
{ key: 'content', label: '内容', type: 'text', required: true },
{ key: 'priority', label: '优先级(1-5)', type: 'number', default: 3, required: false },
];
export const ntfyNotifier: Notifier = {
name: 'ntfy',
label: 'ntfy',
params,
async notify(params: NotifyParams): Promise<NotifyResult> {
try {
const server = (params.server || 'https://ntfy.sh').replace(/\/+$/, '');
const resp = await fetch(`${server}/${params.topic}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: params.title || 'CloudSearch',
message: params.content || '',
priority: params.priority || 3,
tags: ['cloudsearch'],
}),
});
if (resp.ok) return { success: true, message: 'ntfy 推送成功' };
return { success: false, message: `HTTP ${resp.status}` };
} catch (err: any) {
return { success: false, message: err.message };
}
},
};

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