From 83cbfaf03fe353da84dce8b7d75efb8b124c11c0 Mon Sep 17 00:00:00 2001
From: admin <362324317@qq.com>
Date: Sun, 17 May 2026 02:22:18 +0800
Subject: [PATCH] =?UTF-8?q?v0.2.7:=20=E4=BF=AE=E5=A4=8DRedis=E8=BF=9E?=
=?UTF-8?q?=E6=8E=A5=20+=20=E5=90=AF=E5=8A=A8=E7=AE=A1=E7=90=86=E5=90=8E?=
=?UTF-8?q?=E5=8F=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 修复Redis认证 (配置密码)
- 启动Python管理后台 (端口9531, 15个功能开关)
- 统一版本号 0.2.7
- 更新docker-compose.yml (镜像版本/Redis URL/Admin服务)
---
.env.template | 35 +
.gitignore | 12 +
VERSION | 1 +
cloudsearch_admin/Dockerfile | 18 +
cloudsearch_admin/features.html | 196 ++
cloudsearch_admin/requirements.txt | 3 +
cloudsearch_admin/server.py | 206 ++
cloudsearch_admin/templates/admin.html | 149 +
cloudsearch_enrich/Dockerfile | 8 +
cloudsearch_enrich/feishu_bot.py | 319 ++
cloudsearch_enrich/feishu_bot_tmp.py | 319 ++
cloudsearch_enrich/requirements.txt | 3 +
cloudsearch_enrich/search_enricher.py | 132 +
cloudsearch_enrich/subscription_monitor.py | 204 ++
cloudsearch_enrich/tg_bot.py | 183 ++
cloudsearch_enrich/tmdb_enricher.py | 179 ++
cloudsearch_transfer/Dockerfile | 18 +
cloudsearch_transfer/__init__.py | 32 +
cloudsearch_transfer/adapter/__init__.py | 1 +
.../adapter/aliyun/__init__.py | 297 ++
.../adapter/aliyun/cleanup.py | 203 ++
.../adapter/aliyun/credential.py | 216 ++
.../adapter/aliyun/transfer.py | 493 ++++
.../adapter/baidu/__init__.py | 253 ++
cloudsearch_transfer/adapter/baidu/cleanup.py | 154 +
.../adapter/baidu/credential.py | 101 +
.../adapter/baidu/transfer.py | 448 +++
cloudsearch_transfer/adapter/base.py | 330 +++
.../adapter/cloud189/__init__.py | 45 +
.../adapter/cloud189/cleanup.py | 26 +
.../adapter/cloud189/credential.py | 64 +
.../adapter/cloud189/transfer.py | 68 +
cloudsearch_transfer/adapter/factory.py | 112 +
.../adapter/pan115/__init__.py | 41 +
.../adapter/pan115/cleanup.py | 24 +
.../adapter/pan115/credential.py | 11 +
.../adapter/pan115/transfer.py | 69 +
.../adapter/pan123/__init__.py | 41 +
.../adapter/pan123/cleanup.py | 26 +
.../adapter/pan123/credential.py | 16 +
.../adapter/pan123/transfer.py | 71 +
.../adapter/quark/__init__.py | 509 ++++
cloudsearch_transfer/adapter/quark/cleanup.py | 209 ++
.../adapter/quark/credential.py | 89 +
.../adapter/quark/transfer.py | 554 ++++
cloudsearch_transfer/adapter/uc/__init__.py | 493 ++++
cloudsearch_transfer/adapter/uc/cleanup.py | 218 ++
cloudsearch_transfer/adapter/uc/credential.py | 95 +
cloudsearch_transfer/adapter/uc/transfer.py | 619 ++++
.../adapter/xunlei/__init__.py | 112 +
.../adapter/xunlei/cleanup.py | 198 ++
.../adapter/xunlei/credential.py | 339 +++
.../adapter/xunlei/transfer.py | 518 ++++
cloudsearch_transfer/config.py | 172 ++
cloudsearch_transfer/credential/__init__.py | 1 +
cloudsearch_transfer/credential/manager.py | 130 +
cloudsearch_transfer/errors.py | 68 +
cloudsearch_transfer/feature_flags.py | 68 +
.../orchestration/__init__.py | 1 +
.../orchestration/transfer.py | 214 ++
cloudsearch_transfer/requirements.txt | 2 +
cloudsearch_transfer/server.py | 200 ++
docker-compose.yml | 119 +
icons/115.png | Bin 0 -> 14346 bytes
icons/123pan.png | Bin 0 -> 381 bytes
icons/aliyun.png | Bin 0 -> 1150 bytes
icons/baidu.png | Bin 0 -> 16958 bytes
icons/pikpak.png | Bin 0 -> 15406 bytes
icons/quark.png | Bin 0 -> 16958 bytes
icons/tianyi.png | Bin 0 -> 12051 bytes
icons/uc.png | Bin 0 -> 478349 bytes
icons/xunlei.png | Bin 0 -> 894 bytes
source_clean/Dockerfile | 29 +
source_clean/frontend/admin.html | 79 +
source_clean/frontend/admin/js/admin-boot.js | 15 +
.../frontend/admin/js/admin-cleanup.js | 155 +
source_clean/frontend/admin/js/admin-core.js | 39 +
.../frontend/admin/js/admin-dashboard.js | 9 +
.../frontend/admin/js/admin-helpers.js | 1 +
source_clean/frontend/admin/js/admin-login.js | 14 +
.../frontend/admin/js/admin-password.js | 13 +
.../frontend/admin/js/admin-services.js | 10 +
source_clean/frontend/admin/js/admin-site.js | 14 +
.../frontend/admin/js/cloud/cloud-actions.js | 80 +
.../admin/js/cloud/cloud-actions.js.bak | 43 +
.../admin/js/cloud/cloud-actions.js.bak3 | 43 +
.../frontend/admin/js/cloud/cloud-core.js | 38 +
.../frontend/admin/js/cloud/cloud-dialog.js | 107 +
.../frontend/admin/js/cloud/cloud-render.js | 92 +
.../admin/js/cloud/cloud-render.js.bak3 | 92 +
.../assets/AdminDashboard-CYT9FxBx.js | 39 +
.../assets/AdminDashboard-CxAY_FWD.css | 1 +
.../frontend/assets/AdminLayout-BX867Wt6.css | 1 +
.../frontend/assets/AdminLayout-CxD2j-KS.js | 1 +
.../frontend/assets/AdminLogin-Dydh9B_2.css | 1 +
.../frontend/assets/AdminLogin-xBXneZTD.js | 1 +
.../frontend/assets/Cleanup-GlGrtKk0.js | 1 +
.../frontend/assets/Cleanup-xBIb8eSW.css | 1 +
.../frontend/assets/CloudBadge-JtUrWwGU.css | 1 +
.../frontend/assets/CloudBadge-sfzDTvGE.js | 1 +
.../frontend/assets/CloudConfig-DjBo6Nx5.css | 1 +
.../frontend/assets/CloudConfig-VN8uR29R.js | 1 +
.../frontend/assets/HomePage-6khP6FBC.js | 1 +
.../frontend/assets/HomePage-BVcQlSvu.css | 1 +
.../frontend/assets/ResultDetail-CVwsv2ff.css | 1 +
.../frontend/assets/ResultDetail-CbQPmE-g.js | 1 +
.../frontend/assets/SaveRecords-AwnaSQhs.js | 1 +
.../frontend/assets/SaveRecords-DU_-iTm4.css | 1 +
.../frontend/assets/SearchResult-An38JvmS.js | 1 +
.../frontend/assets/SearchResult-Ck_Ddgrj.css | 1 +
.../frontend/assets/SystemConfig-Bz24k5XV.css | 1 +
.../frontend/assets/SystemConfig-DRttMhxK.js | 7 +
.../_plugin-vue_export-helper-CzL5NdOX.js | 10 +
.../frontend/assets/browser-JP79f-a9.js | 8 +
.../frontend/assets/index-Bz21yOih.js | 1 +
.../frontend/assets/index-C5b4pIQL.js | 92 +
.../frontend/assets/index-D-B10deg.css | 1 +
source_clean/frontend/disclaimer/index.html | 72 +
source_clean/frontend/h5/index.html | 923 ++++++
source_clean/frontend/index.html | 30 +
source_clean/package-lock.json | 2600 +++++++++++++++++
source_clean/package.json | 43 +
source_clean/src/admin/auth.service.ts | 76 +
source_clean/src/admin/stats.service.ts | 161 +
.../src/admin/system-config.service.ts | 40 +
source_clean/src/cloud/checkin.service.ts | 140 +
source_clean/src/cloud/cleanup.service.ts | 254 ++
source_clean/src/cloud/cloud-types.service.ts | 58 +
source_clean/src/cloud/cloud.service.ts | 317 ++
source_clean/src/cloud/credential.service.ts | 373 +++
.../src/cloud/credential.service.ts.bak_p0fix | 354 +++
.../src/cloud/drivers/aliyun.driver.ts | 113 +
.../src/cloud/drivers/baidu.driver.ts | 1189 ++++++++
.../src/cloud/drivers/quark.driver.ts | 1533 ++++++++++
source_clean/src/cloud/error-codes.ts | 70 +
source_clean/src/cloud/ip-lookup.ts | 31 +
.../src/cloud/notification.service.ts | 95 +
source_clean/src/cloud/qr-login.service.ts | 407 +++
source_clean/src/config/cloud-labels.ts | 56 +
source_clean/src/config/index.ts | 51 +
source_clean/src/content/content.service.ts | 325 +++
source_clean/src/database/database.ts | 327 +++
source_clean/src/database/database.ts.bak_idx | 306 ++
source_clean/src/intent/intent.service.ts | 41 +
source_clean/src/main.ts | 187 ++
source_clean/src/main.ts.bak_p0fix | 186 ++
source_clean/src/middleware/cache.ts | 161 +
source_clean/src/middleware/rate-limit.ts | 53 +
source_clean/src/proxy/pansou-web.ts | 137 +
source_clean/src/routes/admin.routes.ts | 674 +++++
source_clean/src/routes/cleanup.routes.ts | 87 +
source_clean/src/routes/index.ts | 14 +
source_clean/src/routes/search.routes.ts | 630 ++++
source_clean/src/routes/upload.routes.ts | 125 +
source_clean/src/search/rankings.service.ts | 351 +++
source_clean/src/search/search-optimizer.ts | 125 +
source_clean/src/search/search.service.ts | 344 +++
source_clean/src/utils/qr-login.service.ts | 407 +++
source_clean/src/utils/response.ts | 27 +
source_clean/src/utils/time.ts | 116 +
source_clean/src/validation/bounded-pool.ts | 49 +
.../src/validation/link-validator.service.ts | 375 +++
source_clean/src/video/video.service.ts | 37 +
source_clean/tsconfig.json | 19 +
164 files changed, 25195 insertions(+)
create mode 100644 .env.template
create mode 100644 .gitignore
create mode 100644 VERSION
create mode 100644 cloudsearch_admin/Dockerfile
create mode 100644 cloudsearch_admin/features.html
create mode 100644 cloudsearch_admin/requirements.txt
create mode 100644 cloudsearch_admin/server.py
create mode 100644 cloudsearch_admin/templates/admin.html
create mode 100644 cloudsearch_enrich/Dockerfile
create mode 100644 cloudsearch_enrich/feishu_bot.py
create mode 100644 cloudsearch_enrich/feishu_bot_tmp.py
create mode 100644 cloudsearch_enrich/requirements.txt
create mode 100644 cloudsearch_enrich/search_enricher.py
create mode 100644 cloudsearch_enrich/subscription_monitor.py
create mode 100644 cloudsearch_enrich/tg_bot.py
create mode 100644 cloudsearch_enrich/tmdb_enricher.py
create mode 100644 cloudsearch_transfer/Dockerfile
create mode 100644 cloudsearch_transfer/__init__.py
create mode 100644 cloudsearch_transfer/adapter/__init__.py
create mode 100644 cloudsearch_transfer/adapter/aliyun/__init__.py
create mode 100644 cloudsearch_transfer/adapter/aliyun/cleanup.py
create mode 100644 cloudsearch_transfer/adapter/aliyun/credential.py
create mode 100644 cloudsearch_transfer/adapter/aliyun/transfer.py
create mode 100644 cloudsearch_transfer/adapter/baidu/__init__.py
create mode 100644 cloudsearch_transfer/adapter/baidu/cleanup.py
create mode 100644 cloudsearch_transfer/adapter/baidu/credential.py
create mode 100644 cloudsearch_transfer/adapter/baidu/transfer.py
create mode 100644 cloudsearch_transfer/adapter/base.py
create mode 100644 cloudsearch_transfer/adapter/cloud189/__init__.py
create mode 100644 cloudsearch_transfer/adapter/cloud189/cleanup.py
create mode 100644 cloudsearch_transfer/adapter/cloud189/credential.py
create mode 100644 cloudsearch_transfer/adapter/cloud189/transfer.py
create mode 100644 cloudsearch_transfer/adapter/factory.py
create mode 100644 cloudsearch_transfer/adapter/pan115/__init__.py
create mode 100644 cloudsearch_transfer/adapter/pan115/cleanup.py
create mode 100644 cloudsearch_transfer/adapter/pan115/credential.py
create mode 100644 cloudsearch_transfer/adapter/pan115/transfer.py
create mode 100644 cloudsearch_transfer/adapter/pan123/__init__.py
create mode 100644 cloudsearch_transfer/adapter/pan123/cleanup.py
create mode 100644 cloudsearch_transfer/adapter/pan123/credential.py
create mode 100644 cloudsearch_transfer/adapter/pan123/transfer.py
create mode 100644 cloudsearch_transfer/adapter/quark/__init__.py
create mode 100644 cloudsearch_transfer/adapter/quark/cleanup.py
create mode 100644 cloudsearch_transfer/adapter/quark/credential.py
create mode 100644 cloudsearch_transfer/adapter/quark/transfer.py
create mode 100644 cloudsearch_transfer/adapter/uc/__init__.py
create mode 100644 cloudsearch_transfer/adapter/uc/cleanup.py
create mode 100644 cloudsearch_transfer/adapter/uc/credential.py
create mode 100644 cloudsearch_transfer/adapter/uc/transfer.py
create mode 100644 cloudsearch_transfer/adapter/xunlei/__init__.py
create mode 100644 cloudsearch_transfer/adapter/xunlei/cleanup.py
create mode 100644 cloudsearch_transfer/adapter/xunlei/credential.py
create mode 100644 cloudsearch_transfer/adapter/xunlei/transfer.py
create mode 100644 cloudsearch_transfer/config.py
create mode 100644 cloudsearch_transfer/credential/__init__.py
create mode 100644 cloudsearch_transfer/credential/manager.py
create mode 100644 cloudsearch_transfer/errors.py
create mode 100644 cloudsearch_transfer/feature_flags.py
create mode 100644 cloudsearch_transfer/orchestration/__init__.py
create mode 100644 cloudsearch_transfer/orchestration/transfer.py
create mode 100644 cloudsearch_transfer/requirements.txt
create mode 100644 cloudsearch_transfer/server.py
create mode 100644 docker-compose.yml
create mode 100644 icons/115.png
create mode 100644 icons/123pan.png
create mode 100644 icons/aliyun.png
create mode 100644 icons/baidu.png
create mode 100644 icons/pikpak.png
create mode 100644 icons/quark.png
create mode 100644 icons/tianyi.png
create mode 100644 icons/uc.png
create mode 100644 icons/xunlei.png
create mode 100644 source_clean/Dockerfile
create mode 100644 source_clean/frontend/admin.html
create mode 100644 source_clean/frontend/admin/js/admin-boot.js
create mode 100644 source_clean/frontend/admin/js/admin-cleanup.js
create mode 100644 source_clean/frontend/admin/js/admin-core.js
create mode 100644 source_clean/frontend/admin/js/admin-dashboard.js
create mode 100644 source_clean/frontend/admin/js/admin-helpers.js
create mode 100644 source_clean/frontend/admin/js/admin-login.js
create mode 100644 source_clean/frontend/admin/js/admin-password.js
create mode 100644 source_clean/frontend/admin/js/admin-services.js
create mode 100644 source_clean/frontend/admin/js/admin-site.js
create mode 100644 source_clean/frontend/admin/js/cloud/cloud-actions.js
create mode 100644 source_clean/frontend/admin/js/cloud/cloud-actions.js.bak
create mode 100644 source_clean/frontend/admin/js/cloud/cloud-actions.js.bak3
create mode 100644 source_clean/frontend/admin/js/cloud/cloud-core.js
create mode 100644 source_clean/frontend/admin/js/cloud/cloud-dialog.js
create mode 100644 source_clean/frontend/admin/js/cloud/cloud-render.js
create mode 100644 source_clean/frontend/admin/js/cloud/cloud-render.js.bak3
create mode 100644 source_clean/frontend/assets/AdminDashboard-CYT9FxBx.js
create mode 100644 source_clean/frontend/assets/AdminDashboard-CxAY_FWD.css
create mode 100644 source_clean/frontend/assets/AdminLayout-BX867Wt6.css
create mode 100644 source_clean/frontend/assets/AdminLayout-CxD2j-KS.js
create mode 100644 source_clean/frontend/assets/AdminLogin-Dydh9B_2.css
create mode 100644 source_clean/frontend/assets/AdminLogin-xBXneZTD.js
create mode 100644 source_clean/frontend/assets/Cleanup-GlGrtKk0.js
create mode 100644 source_clean/frontend/assets/Cleanup-xBIb8eSW.css
create mode 100644 source_clean/frontend/assets/CloudBadge-JtUrWwGU.css
create mode 100644 source_clean/frontend/assets/CloudBadge-sfzDTvGE.js
create mode 100644 source_clean/frontend/assets/CloudConfig-DjBo6Nx5.css
create mode 100644 source_clean/frontend/assets/CloudConfig-VN8uR29R.js
create mode 100644 source_clean/frontend/assets/HomePage-6khP6FBC.js
create mode 100644 source_clean/frontend/assets/HomePage-BVcQlSvu.css
create mode 100644 source_clean/frontend/assets/ResultDetail-CVwsv2ff.css
create mode 100644 source_clean/frontend/assets/ResultDetail-CbQPmE-g.js
create mode 100644 source_clean/frontend/assets/SaveRecords-AwnaSQhs.js
create mode 100644 source_clean/frontend/assets/SaveRecords-DU_-iTm4.css
create mode 100644 source_clean/frontend/assets/SearchResult-An38JvmS.js
create mode 100644 source_clean/frontend/assets/SearchResult-Ck_Ddgrj.css
create mode 100644 source_clean/frontend/assets/SystemConfig-Bz24k5XV.css
create mode 100644 source_clean/frontend/assets/SystemConfig-DRttMhxK.js
create mode 100644 source_clean/frontend/assets/_plugin-vue_export-helper-CzL5NdOX.js
create mode 100644 source_clean/frontend/assets/browser-JP79f-a9.js
create mode 100644 source_clean/frontend/assets/index-Bz21yOih.js
create mode 100644 source_clean/frontend/assets/index-C5b4pIQL.js
create mode 100644 source_clean/frontend/assets/index-D-B10deg.css
create mode 100755 source_clean/frontend/disclaimer/index.html
create mode 100755 source_clean/frontend/h5/index.html
create mode 100644 source_clean/frontend/index.html
create mode 100644 source_clean/package-lock.json
create mode 100755 source_clean/package.json
create mode 100755 source_clean/src/admin/auth.service.ts
create mode 100755 source_clean/src/admin/stats.service.ts
create mode 100755 source_clean/src/admin/system-config.service.ts
create mode 100644 source_clean/src/cloud/checkin.service.ts
create mode 100755 source_clean/src/cloud/cleanup.service.ts
create mode 100755 source_clean/src/cloud/cloud-types.service.ts
create mode 100644 source_clean/src/cloud/cloud.service.ts
create mode 100644 source_clean/src/cloud/credential.service.ts
create mode 100644 source_clean/src/cloud/credential.service.ts.bak_p0fix
create mode 100755 source_clean/src/cloud/drivers/aliyun.driver.ts
create mode 100644 source_clean/src/cloud/drivers/baidu.driver.ts
create mode 100755 source_clean/src/cloud/drivers/quark.driver.ts
create mode 100644 source_clean/src/cloud/error-codes.ts
create mode 100644 source_clean/src/cloud/ip-lookup.ts
create mode 100644 source_clean/src/cloud/notification.service.ts
create mode 100755 source_clean/src/cloud/qr-login.service.ts
create mode 100755 source_clean/src/config/cloud-labels.ts
create mode 100755 source_clean/src/config/index.ts
create mode 100755 source_clean/src/content/content.service.ts
create mode 100755 source_clean/src/database/database.ts
create mode 100755 source_clean/src/database/database.ts.bak_idx
create mode 100755 source_clean/src/intent/intent.service.ts
create mode 100755 source_clean/src/main.ts
create mode 100755 source_clean/src/main.ts.bak_p0fix
create mode 100755 source_clean/src/middleware/cache.ts
create mode 100755 source_clean/src/middleware/rate-limit.ts
create mode 100755 source_clean/src/proxy/pansou-web.ts
create mode 100644 source_clean/src/routes/admin.routes.ts
create mode 100644 source_clean/src/routes/cleanup.routes.ts
create mode 100755 source_clean/src/routes/index.ts
create mode 100644 source_clean/src/routes/search.routes.ts
create mode 100644 source_clean/src/routes/upload.routes.ts
create mode 100755 source_clean/src/search/rankings.service.ts
create mode 100755 source_clean/src/search/search-optimizer.ts
create mode 100755 source_clean/src/search/search.service.ts
create mode 100755 source_clean/src/utils/qr-login.service.ts
create mode 100755 source_clean/src/utils/response.ts
create mode 100755 source_clean/src/utils/time.ts
create mode 100755 source_clean/src/validation/bounded-pool.ts
create mode 100755 source_clean/src/validation/link-validator.service.ts
create mode 100755 source_clean/src/video/video.service.ts
create mode 100755 source_clean/tsconfig.json
diff --git a/.env.template b/.env.template
new file mode 100644
index 0000000..55876ae
--- /dev/null
+++ b/.env.template
@@ -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=
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..700e012
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,12 @@
+.env
+*.sqlite
+*.sqlite-shm
+*.sqlite-wal
+uploads/
+__pycache__/
+*.pyc
+.DS_Store
+node_modules/
+dist/
+*.tar.gz
+*.zip
diff --git a/VERSION b/VERSION
new file mode 100644
index 0000000..b003284
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+0.2.7
diff --git a/cloudsearch_admin/Dockerfile b/cloudsearch_admin/Dockerfile
new file mode 100644
index 0000000..f5466d3
--- /dev/null
+++ b/cloudsearch_admin/Dockerfile
@@ -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"]
diff --git a/cloudsearch_admin/features.html b/cloudsearch_admin/features.html
new file mode 100644
index 0000000..515bf11
--- /dev/null
+++ b/cloudsearch_admin/features.html
@@ -0,0 +1,196 @@
+
+
+
+
+
+功能开关 - CloudSearch
+
+
+
+
+
+
+
+
+
+
diff --git a/cloudsearch_admin/requirements.txt b/cloudsearch_admin/requirements.txt
new file mode 100644
index 0000000..d5c6c91
--- /dev/null
+++ b/cloudsearch_admin/requirements.txt
@@ -0,0 +1,3 @@
+flask>=3.0
+waitress>=2.1
+pymysql>=1.1
diff --git a/cloudsearch_admin/server.py b/cloudsearch_admin/server.py
new file mode 100644
index 0000000..7c2970b
--- /dev/null
+++ b/cloudsearch_admin/server.py
@@ -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/", 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)
diff --git a/cloudsearch_admin/templates/admin.html b/cloudsearch_admin/templates/admin.html
new file mode 100644
index 0000000..016b454
--- /dev/null
+++ b/cloudsearch_admin/templates/admin.html
@@ -0,0 +1,149 @@
+
+
+
+
+
+CloudSearch 管理后台 v2.2
+
+
+
+
+
+
+
🔧 CloudSearch 管理后台
+
v2.2 · 功能开关一键管理
+
+
+
+
+
+
+
+
+ 加载中...
+
+
+
+
+
+
+
diff --git a/cloudsearch_enrich/Dockerfile b/cloudsearch_enrich/Dockerfile
new file mode 100644
index 0000000..117283e
--- /dev/null
+++ b/cloudsearch_enrich/Dockerfile
@@ -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"]
diff --git a/cloudsearch_enrich/feishu_bot.py b/cloudsearch_enrich/feishu_bot.py
new file mode 100644
index 0000000..de4fd97
--- /dev/null
+++ b/cloudsearch_enrich/feishu_bot.py
@@ -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()
diff --git a/cloudsearch_enrich/feishu_bot_tmp.py b/cloudsearch_enrich/feishu_bot_tmp.py
new file mode 100644
index 0000000..358e644
--- /dev/null
+++ b/cloudsearch_enrich/feishu_bot_tmp.py
@@ -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()
diff --git a/cloudsearch_enrich/requirements.txt b/cloudsearch_enrich/requirements.txt
new file mode 100644
index 0000000..6f879f5
--- /dev/null
+++ b/cloudsearch_enrich/requirements.txt
@@ -0,0 +1,3 @@
+flask>=3.0
+requests>=2.28
+python-telegram-bot>=20.0
diff --git a/cloudsearch_enrich/search_enricher.py b/cloudsearch_enrich/search_enricher.py
new file mode 100644
index 0000000..125a0c1
--- /dev/null
+++ b/cloudsearch_enrich/search_enricher.py
@@ -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)
diff --git a/cloudsearch_enrich/subscription_monitor.py b/cloudsearch_enrich/subscription_monitor.py
new file mode 100644
index 0000000..8bf5a18
--- /dev/null
+++ b/cloudsearch_enrich/subscription_monitor.py
@@ -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)
diff --git a/cloudsearch_enrich/tg_bot.py b/cloudsearch_enrich/tg_bot.py
new file mode 100644
index 0000000..7df91ce
--- /dev/null
+++ b/cloudsearch_enrich/tg_bot.py
@@ -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()
diff --git a/cloudsearch_enrich/tmdb_enricher.py b/cloudsearch_enrich/tmdb_enricher.py
new file mode 100644
index 0000000..dd739da
--- /dev/null
+++ b/cloudsearch_enrich/tmdb_enricher.py
@@ -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}",
+ )
diff --git a/cloudsearch_transfer/Dockerfile b/cloudsearch_transfer/Dockerfile
new file mode 100644
index 0000000..7aee082
--- /dev/null
+++ b/cloudsearch_transfer/Dockerfile
@@ -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"]
diff --git a/cloudsearch_transfer/__init__.py b/cloudsearch_transfer/__init__.py
new file mode 100644
index 0000000..03464b7
--- /dev/null
+++ b/cloudsearch_transfer/__init__.py
@@ -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"
diff --git a/cloudsearch_transfer/adapter/__init__.py b/cloudsearch_transfer/adapter/__init__.py
new file mode 100644
index 0000000..59e8061
--- /dev/null
+++ b/cloudsearch_transfer/adapter/__init__.py
@@ -0,0 +1 @@
+"""CloudSearch Transfer — 适配器包"""
diff --git a/cloudsearch_transfer/adapter/aliyun/__init__.py b/cloudsearch_transfer/adapter/aliyun/__init__.py
new file mode 100644
index 0000000..3a6e5be
--- /dev/null
+++ b/cloudsearch_transfer/adapter/aliyun/__init__.py
@@ -0,0 +1,297 @@
+"""
+阿里云盘适配器 v1.0.0
+AliyunAdapter — 继承 BaseCloudDriveAdapter,实现阿里云盘全部转存能力。
+
+组件:
+- AliyunCredentialManager: refresh_token 刷新 + 缓存
+- AliyunTransfer: 4 步批量转存
+- AliyunCleanup: 回收站清理
+
+URL 匹配: aliyundrive.com/s/
+"""
+
+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
diff --git a/cloudsearch_transfer/adapter/aliyun/cleanup.py b/cloudsearch_transfer/adapter/aliyun/cleanup.py
new file mode 100644
index 0000000..b70b930
--- /dev/null
+++ b/cloudsearch_transfer/adapter/aliyun/cleanup.py
@@ -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,
+ }
diff --git a/cloudsearch_transfer/adapter/aliyun/credential.py b/cloudsearch_transfer/adapter/aliyun/credential.py
new file mode 100644
index 0000000..3e3fbf6
--- /dev/null
+++ b/cloudsearch_transfer/adapter/aliyun/credential.py
@@ -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 ", ...}
+ """
+ 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 "",
+ }
diff --git a/cloudsearch_transfer/adapter/aliyun/transfer.py b/cloudsearch_transfer/adapter/aliyun/transfer.py
new file mode 100644
index 0000000..cb90a08
--- /dev/null
+++ b/cloudsearch_transfer/adapter/aliyun/transfer.py
@@ -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/
+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:
+ 请求体:
+ {
+ "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,
+ }
diff --git a/cloudsearch_transfer/adapter/baidu/__init__.py b/cloudsearch_transfer/adapter/baidu/__init__.py
new file mode 100644
index 0000000..5e20d45
--- /dev/null
+++ b/cloudsearch_transfer/adapter/baidu/__init__.py
@@ -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)
diff --git a/cloudsearch_transfer/adapter/baidu/cleanup.py b/cloudsearch_transfer/adapter/baidu/cleanup.py
new file mode 100644
index 0000000..3dc176a
--- /dev/null
+++ b/cloudsearch_transfer/adapter/baidu/cleanup.py
@@ -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
diff --git a/cloudsearch_transfer/adapter/baidu/credential.py b/cloudsearch_transfer/adapter/baidu/credential.py
new file mode 100644
index 0000000..6e8cf49
--- /dev/null
+++ b/cloudsearch_transfer/adapter/baidu/credential.py
@@ -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 缓存已失效")
diff --git a/cloudsearch_transfer/adapter/baidu/transfer.py b/cloudsearch_transfer/adapter/baidu/transfer.py
new file mode 100644
index 0000000..865e21b
--- /dev/null
+++ b/cloudsearch_transfer/adapter/baidu/transfer.py
@@ -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
diff --git a/cloudsearch_transfer/adapter/base.py b/cloudsearch_transfer/adapter/base.py
new file mode 100644
index 0000000..08bac8a
--- /dev/null
+++ b/cloudsearch_transfer/adapter/base.py
@@ -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
diff --git a/cloudsearch_transfer/adapter/cloud189/__init__.py b/cloudsearch_transfer/adapter/cloud189/__init__.py
new file mode 100644
index 0000000..577bc3e
--- /dev/null
+++ b/cloudsearch_transfer/adapter/cloud189/__init__.py
@@ -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)
diff --git a/cloudsearch_transfer/adapter/cloud189/cleanup.py b/cloudsearch_transfer/adapter/cloud189/cleanup.py
new file mode 100644
index 0000000..093b168
--- /dev/null
+++ b/cloudsearch_transfer/adapter/cloud189/cleanup.py
@@ -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
diff --git a/cloudsearch_transfer/adapter/cloud189/credential.py b/cloudsearch_transfer/adapter/cloud189/credential.py
new file mode 100644
index 0000000..2a7f8c2
--- /dev/null
+++ b/cloudsearch_transfer/adapter/cloud189/credential.py
@@ -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
diff --git a/cloudsearch_transfer/adapter/cloud189/transfer.py b/cloudsearch_transfer/adapter/cloud189/transfer.py
new file mode 100644
index 0000000..b98c7a1
--- /dev/null
+++ b/cloudsearch_transfer/adapter/cloud189/transfer.py
@@ -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 []
diff --git a/cloudsearch_transfer/adapter/factory.py b/cloudsearch_transfer/adapter/factory.py
new file mode 100644
index 0000000..13f6203
--- /dev/null
+++ b/cloudsearch_transfer/adapter/factory.py
@@ -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}"
diff --git a/cloudsearch_transfer/adapter/pan115/__init__.py b/cloudsearch_transfer/adapter/pan115/__init__.py
new file mode 100644
index 0000000..b50f024
--- /dev/null
+++ b/cloudsearch_transfer/adapter/pan115/__init__.py
@@ -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)
diff --git a/cloudsearch_transfer/adapter/pan115/cleanup.py b/cloudsearch_transfer/adapter/pan115/cleanup.py
new file mode 100644
index 0000000..ff8ea5a
--- /dev/null
+++ b/cloudsearch_transfer/adapter/pan115/cleanup.py
@@ -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
diff --git a/cloudsearch_transfer/adapter/pan115/credential.py b/cloudsearch_transfer/adapter/pan115/credential.py
new file mode 100644
index 0000000..d56e054
--- /dev/null
+++ b/cloudsearch_transfer/adapter/pan115/credential.py
@@ -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/"}
diff --git a/cloudsearch_transfer/adapter/pan115/transfer.py b/cloudsearch_transfer/adapter/pan115/transfer.py
new file mode 100644
index 0000000..b541dd9
--- /dev/null
+++ b/cloudsearch_transfer/adapter/pan115/transfer.py
@@ -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)
diff --git a/cloudsearch_transfer/adapter/pan123/__init__.py b/cloudsearch_transfer/adapter/pan123/__init__.py
new file mode 100644
index 0000000..a276bdc
--- /dev/null
+++ b/cloudsearch_transfer/adapter/pan123/__init__.py
@@ -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)
diff --git a/cloudsearch_transfer/adapter/pan123/cleanup.py b/cloudsearch_transfer/adapter/pan123/cleanup.py
new file mode 100644
index 0000000..1ba6cc3
--- /dev/null
+++ b/cloudsearch_transfer/adapter/pan123/cleanup.py
@@ -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
diff --git a/cloudsearch_transfer/adapter/pan123/credential.py b/cloudsearch_transfer/adapter/pan123/credential.py
new file mode 100644
index 0000000..6c9f743
--- /dev/null
+++ b/cloudsearch_transfer/adapter/pan123/credential.py
@@ -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",
+ }
diff --git a/cloudsearch_transfer/adapter/pan123/transfer.py b/cloudsearch_transfer/adapter/pan123/transfer.py
new file mode 100644
index 0000000..ebae64b
--- /dev/null
+++ b/cloudsearch_transfer/adapter/pan123/transfer.py
@@ -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 []
diff --git a/cloudsearch_transfer/adapter/quark/__init__.py b/cloudsearch_transfer/adapter/quark/__init__.py
new file mode 100644
index 0000000..55d8d93
--- /dev/null
+++ b/cloudsearch_transfer/adapter/quark/__init__.py
@@ -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/
+ 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=&_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=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()})"
+ )
diff --git a/cloudsearch_transfer/adapter/quark/cleanup.py b/cloudsearch_transfer/adapter/quark/cleanup.py
new file mode 100644
index 0000000..687356b
--- /dev/null
+++ b/cloudsearch_transfer/adapter/quark/cleanup.py
@@ -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": ["", "", ...]
+ }
+
+ 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()
diff --git a/cloudsearch_transfer/adapter/quark/credential.py b/cloudsearch_transfer/adapter/quark/credential.py
new file mode 100644
index 0000000..77dcc03
--- /dev/null
+++ b/cloudsearch_transfer/adapter/quark/credential.py
@@ -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()})"
+ )
diff --git a/cloudsearch_transfer/adapter/quark/transfer.py b/cloudsearch_transfer/adapter/quark/transfer.py
new file mode 100644
index 0000000..9138e4e
--- /dev/null
+++ b/cloudsearch_transfer/adapter/quark/transfer.py
@@ -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_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": ""}
+
+ 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_token_list": [, ...],
+ "to_pdir_fid": "0",
+ "pwd_id": "",
+ "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=&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": [, ...],
+ "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=&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": ""}
+
+ 即使不设密码也要调用此 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()
diff --git a/cloudsearch_transfer/adapter/uc/__init__.py b/cloudsearch_transfer/adapter/uc/__init__.py
new file mode 100644
index 0000000..3e87e09
--- /dev/null
+++ b/cloudsearch_transfer/adapter/uc/__init__.py
@@ -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/
+ 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=&_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=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()
diff --git a/cloudsearch_transfer/adapter/uc/cleanup.py b/cloudsearch_transfer/adapter/uc/cleanup.py
new file mode 100644
index 0000000..3511cb8
--- /dev/null
+++ b/cloudsearch_transfer/adapter/uc/cleanup.py
@@ -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": ["", "", ...]
+ }
+
+ 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()
diff --git a/cloudsearch_transfer/adapter/uc/credential.py b/cloudsearch_transfer/adapter/uc/credential.py
new file mode 100644
index 0000000..0df5b23
--- /dev/null
+++ b/cloudsearch_transfer/adapter/uc/credential.py
@@ -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()})"
+ )
diff --git a/cloudsearch_transfer/adapter/uc/transfer.py b/cloudsearch_transfer/adapter/uc/transfer.py
new file mode 100644
index 0000000..f0b5b78
--- /dev/null
+++ b/cloudsearch_transfer/adapter/uc/transfer.py
@@ -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_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": ""}
+ 响应: 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_token_list": [, ...],
+ "to_pdir_fid": "0",
+ "pwd_id": "",
+ "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=&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": [, ...], "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=&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": ""}
+
+ 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()
diff --git a/cloudsearch_transfer/adapter/xunlei/__init__.py b/cloudsearch_transfer/adapter/xunlei/__init__.py
new file mode 100644
index 0000000..13307e3
--- /dev/null
+++ b/cloudsearch_transfer/adapter/xunlei/__init__.py
@@ -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""
diff --git a/cloudsearch_transfer/adapter/xunlei/cleanup.py b/cloudsearch_transfer/adapter/xunlei/cleanup.py
new file mode 100644
index 0000000..aa0be00
--- /dev/null
+++ b/cloudsearch_transfer/adapter/xunlei/cleanup.py
@@ -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": ["", "", ...],
+ "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()
diff --git a/cloudsearch_transfer/adapter/xunlei/credential.py b/cloudsearch_transfer/adapter/xunlei/credential.py
new file mode 100644
index 0000000..32864c7
--- /dev/null
+++ b/cloudsearch_transfer/adapter/xunlei/credential.py
@@ -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
+ x-captcha-token:
+ x-client-id:
+ x-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
+ - x-captcha-token: (如有)
+ - x-client-id:
+ - x-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())})"
+ )
diff --git a/cloudsearch_transfer/adapter/xunlei/transfer.py b/cloudsearch_transfer/adapter/xunlei/transfer.py
new file mode 100644
index 0000000..2b763e8
--- /dev/null
+++ b/cloudsearch_transfer/adapter/xunlei/transfer.py
@@ -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_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=
+
+ 返回字段包含: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": ["", ...],
+ "pass_code_token": "",
+ "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": ["", ...],
+ "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()
diff --git a/cloudsearch_transfer/config.py b/cloudsearch_transfer/config.py
new file mode 100644
index 0000000..0f6bd6e
--- /dev/null
+++ b/cloudsearch_transfer/config.py
@@ -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__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)
diff --git a/cloudsearch_transfer/credential/__init__.py b/cloudsearch_transfer/credential/__init__.py
new file mode 100644
index 0000000..343b0d3
--- /dev/null
+++ b/cloudsearch_transfer/credential/__init__.py
@@ -0,0 +1 @@
+"""CloudSearch Transfer — 凭证管理包"""
diff --git a/cloudsearch_transfer/credential/manager.py b/cloudsearch_transfer/credential/manager.py
new file mode 100644
index 0000000..243b683
--- /dev/null
+++ b/cloudsearch_transfer/credential/manager.py
@@ -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}")
diff --git a/cloudsearch_transfer/errors.py b/cloudsearch_transfer/errors.py
new file mode 100644
index 0000000..914f6f1
--- /dev/null
+++ b/cloudsearch_transfer/errors.py
@@ -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,
+ }
diff --git a/cloudsearch_transfer/feature_flags.py b/cloudsearch_transfer/feature_flags.py
new file mode 100644
index 0000000..ca51199
--- /dev/null
+++ b/cloudsearch_transfer/feature_flags.py
@@ -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()
diff --git a/cloudsearch_transfer/orchestration/__init__.py b/cloudsearch_transfer/orchestration/__init__.py
new file mode 100644
index 0000000..b08eea7
--- /dev/null
+++ b/cloudsearch_transfer/orchestration/__init__.py
@@ -0,0 +1 @@
+"""CloudSearch Transfer — 编排包"""
diff --git a/cloudsearch_transfer/orchestration/transfer.py b/cloudsearch_transfer/orchestration/transfer.py
new file mode 100644
index 0000000..93cc4c9
--- /dev/null
+++ b/cloudsearch_transfer/orchestration/transfer.py
@@ -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")
diff --git a/cloudsearch_transfer/requirements.txt b/cloudsearch_transfer/requirements.txt
new file mode 100644
index 0000000..c70f770
--- /dev/null
+++ b/cloudsearch_transfer/requirements.txt
@@ -0,0 +1,2 @@
+flask>=3.0
+requests>=2.28
diff --git a/cloudsearch_transfer/server.py b/cloudsearch_transfer/server.py
new file mode 100644
index 0000000..0f74547
--- /dev/null
+++ b/cloudsearch_transfer/server.py
@@ -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/", 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/", 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)
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..9b02473
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,119 @@
+# CloudSearch v2.3.0 — 单容器部署(全功能集成)
+networks:
+ cloudsearch-net:
+ driver: bridge
+
+volumes:
+ admin-data:
+ app-data:
+ pansou-data:
+ redis-data:
+
+x-logging: &default-logging
+ driver: json-file
+ options:
+ max-size: "50m"
+ max-file: "10"
+
+services:
+ # ============ Redis ============
+ redis:
+ container_name: CloudSearch_Redis
+ image: redis:7-alpine
+ command: redis-server --save 60 1 --appendonly yes
+ volumes:
+ - redis-data:/data
+ restart: always
+ networks:
+ - cloudsearch-net
+ logging: *default-logging
+
+ # ============ 全功能主应用 ============
+ app:
+ container_name: CloudSearch_App
+ image: cloudsearch-app:v0.2.6
+ ports:
+ - "9527:9527"
+ environment:
+ - NODE_ENV=production
+ - CORS_ORIGIN=http://jp-cs.timaa.cn
+ - JWT_SECRET=u-_1wBd1IlQNYwZ9l5P1838x2fdsp0DI-BUhMouJeIg
+ - ADMIN_PASSWORD=0nL5kLhMIJ1121PYmQb25A
+ - PANSOU_URL=http://pansou:80
+ - DB_PATH=/data/database.sqlite
+ - REDIS_URL=redis://:redis_GbR7XZ@1Panel-redis-aDp3:6379
+ - CLOUDSEARCH_API=http://localhost:9527
+ - TRANSFER_CONFIG_PATH=/data/transfer_config.json
+ - TZ=Asia/Shanghai
+ - APP_VERSION_FILE=/data/VERSION
+ - FEISHU_APP_ID=${FEISHU_APP_ID:-}
+ - FEISHU_APP_SECRET=${FEISHU_APP_SECRET:-}
+ - FEISHU_VERIFY_TOKEN=${FEISHU_VERIFY_TOKEN:-}
+ - FEISHU_WEBHOOK_URL=${FEISHU_WEBHOOK_URL:-}
+ - TMDB_API_KEY=${TMDB_API_KEY:-}
+ volumes:
+ - app-data:/data
+ - ./uploads:/app/uploads
+ - ./icons:/app/dist/frontend/admin/icons
+ - ./VERSION:/data/VERSION
+ depends_on:
+
+ # ============ 管理后台 (功能开关) ============
+ admin:
+ container_name: CloudSearch_Admin
+ image: cloudsearch-admin:v0.1.0
+ ports:
+ - "127.0.0.1:9531:9531"
+ environment:
+ - ADMIN_PORT=9531
+ - ADMIN_PASSWORD=0nL5kLhMIJ1121PYmQb25A
+ - ADMIN_DB_PATH=/data/admin_flags.sqlite
+ volumes:
+ - admin-data:/data
+ restart: always
+ networks:
+ - cloudsearch-net
+ logging: *default-logging
+
+ pansou:
+ condition: service_started
+ redis:
+ condition: service_started
+ restart: always
+ networks:
+ - cloudsearch-net
+ logging: *default-logging
+
+
+ # ============ 管理后台 (功能开关) ============
+ admin:
+ container_name: CloudSearch_Admin
+ image: cloudsearch-admin:v0.1.0
+ ports:
+ - "127.0.0.1:9531:9531"
+ environment:
+ - ADMIN_PORT=9531
+ - ADMIN_PASSWORD=0nL5kLhMIJ1121PYmQb25A
+ - ADMIN_DB_PATH=/data/admin_flags.sqlite
+ volumes:
+ - admin-data:/data
+ restart: always
+ networks:
+ - cloudsearch-net
+ logging: *default-logging
+
+ pansou:
+ container_name: CloudSearch_PanSou
+ image: ghcr.io/fish2018/pansou-web:latest
+ expose:
+ - "80"
+ environment:
+ - DOMAIN=${DOMAIN:-localhost}
+ - CACHE_TTL=60
+ volumes:
+ - pansou-data:/app/data
+ restart: always
+ networks:
+ - cloudsearch-net
+ logging: *default-logging
+
diff --git a/icons/115.png b/icons/115.png
new file mode 100644
index 0000000000000000000000000000000000000000..5157653aa2ba3d1067ff3f84435c6ce739799b78
GIT binary patch
literal 14346
zcmaibbzD@>7xrC3x)BznySux)5dmos5UG`x+%MfACEeWu0@97Nh;+9|cf))Az3>0;
z-TT>?IcMgXGiRPTGjlgWOG6P0ofI7a04!xCIUN9ifL|d1>I?A0*rn74{6MmmR+k2V
zuW=X;7RcalDk~)&bpY^Y004L}0NjB|@IL_H&IJH}%>h6p6#$4~nJwC4V8#neRYf`A
z`QIzIy(AF;1cQ|2q+fe29Adr1dOYW|)4kfgmpf+|xF&Wr+d$>l-QK^IQRXpwr
z)fb)?_=XZE3*%3^WEEt27YfxgCQE$Y#WOIIV5z4|&H22tqVrQU{^b
zu@AK*IAmS#_m4!l9e-bSb(`3&Jies}^zSOyt57R1$JWy0kJb4=AKZs8GaAiJ0MJ^g
zSw^D)eXzyhJ%abaxGJdHkRQRgf>xtLPs8r~E=1_s%$5
zI4?+N__dxMpStTv9oypJ6+_0DO-)smBQq`{ynWX3}-?^U42YNNlB^RfQ>|<
zfIB;0;xoGjWf1oZ!xTsCu!6R-va-73;^KzF!Ywxo3yZX=y(Bbh7V5%CTpcH#wBeab
z!%fT{D|dQJ`(e;d(HbSLOmH?8yK*|^Uwsh-VKJ1F{yJ~pF8l}%4kiogrV3fqz*f{I
z{8m=B_s-gSJAzg*mQ2j;&rcFPJw0jM4?t4-*iQXFlaV=zw&Q(7IO!wMEM$pHkYy&f
zmwS&l;uFGOkOA^
zV@|-+Z8~gmHai>_K3$qsi9s;X1-
z^|npU^L5r!Ly3$d$kL@Rk@9{x2z(I-Ey}iZ6`}`Z`<^Kak;PJ{7ssH)3%Tx$F6~W~
ztj1?&OK9K*!@gjJ5%BS;%Yx0DG->syTK#IJNDWueCX6#+OL5hdd~};TK+gc!%sRyM
zrW(qC1ax%tVvQULKRXu}7lxd!%?n$oRcS>9>*6>2*%tK-fkR|1&d7}wVl!WEHrb%~
z*~Vm6{d8lFSvr-y6R_f4B0|EY&*|yu{j3s<>E_XSV0XDq6|38O9xmuHp~@($sf~8?
z+s_35ZlZek%?b3tzI9l1Zjy!?*jS?$x4ovi`udXdYEU^ZY2#vVs&%Hy=-R{D+Pa7e
zHH?jDq)clJCs+=6Q)yt~-nv{*uT0wPb-H2CsF~gN+d&{BD4s8DiW4T1HU4;iU30&c
zs41bZ_igwckE74g_uaP_{?$hawGQ)j-RZ?+Q2r0U!|7F1mOT4k>c9K@sD@oqqhn%X
zay6+)*{*GD?5jf9pC$QxJ2A0;E4GYe{%^SSuBR5-EtJYf5wWL=`&>1tF(hhFjKYK-
zAx(?y&WnwQhoDU_RJ94C*oj7@+l`~$=gI0V`_U_=_A)77y%PPK?Q?Xyp2Vz6q?*tt
zxB(NApi)i{a+s@)=nchEmGpn`(sOln{U9wZ-QVcAm}D-=`X|}t_G}w1GM^2^`}#=y
zda0gIOF{xZv!rCF#b7M?m3bjKA;h53
zVV+A*&z94)?WX1Sd^ceAJ>Qv$H<<>mT^4bwr9ehje}Df6e}Dh;SaMNPpA4rO{MHHR`Uqz_(zWmU==Qd9HuXEU&mg+Jmu3UxIr4#75
zMMZsFpM1p^-X2C9-;M0zI;@57LWC?<x*WE$vU1vspl*+iJ~Vx4>F)22i;cI0A#;q7
z_gD+U&c92wH;Fh+AE;L6de`NMf^jiP`QDF)U=X8Ml$RII+`=i2&+V|gz#$q>XSbF>
zuj=2|bm1cOIb!G?4~JphmSO@OmYWb6t?&1e!4%)^;baDR?VW!aul{)!`H`B7P-gYl
zw^e91_49_WU;h>gczQ^vGHUv;Y_e*F)3xnllAMx~BJk;Kt7v_kjM1A};<%6X*-83I
z_Y!*U#hZO^E8DCdX}|!EZa#K)_OWf3%Vw6JhiWTl+atO!v(abTNIUKnEcipJ_CFSRwS09y`;Skh&L>
zs`#>eg!L|!Z|vAso&Z#vD$&g8_%x9%j-@GayLDHMO}VE7kYOtl!3)2;?yhvpdWPjh
zdXv-y?Da5)L5=xdz^RuQ_JGs3^X;c6G99?nZ57RvwQde*gYm`RRN|eVUKm
z6XYq=uzOSFu01y!1f|iD5u;&p|DF*eqeHI;L^O?tDvxO+TaNe?P4UE*Oi%N^8_r0>8x%k)xU^;$>xG2Au*BHc04mp
zm6`nT@O}hoErKyysVJ!!ENZ#S==tRASEn^s&tQuqmsne^DLo&TX@zE1qMce(jPGd)<7@ULIL2DZ1h-Z4cc
zOx?C(Bh-GFW}z)|`;jT^x^sGBy1p%Fb``|mkt+k5Q33}aHJ{0HJX07s=@)gyo2tKB
z;p9l54Y2u1V~X=+!^Sb2CMQP&u=zK=u$&(kT`!eLHtW37kcR(Fx+F0QiBo%I3s2|c
zvhd2bwzkPP75vmoFBExe&Sp9hgYCTjyru9e7c>AT=YP3?2;06HKK$S0$6QoKz@WBP
z6#%_`Fz40yeU?%!J{zM4+on2t{%OPj1_+5d=`AkX!>XKf^bi;r`XtaP@9pjRhrQfD
z!jZT-MztWNF#cimZ2oW3R!*QBI`lAmyV6MqM1PHrAYjDi%@|wiXHoO@_!+C~-gfCc
z5ko-uuHvVJQd`2?*VF#n5yGR_L@Znm<^fqYd
z=5&*hd*DU2Y5UtQc}Tpqa0oX;QBhG0X48L@!*`D!Nx#o$I>qD6CgRh2TA#b=z>cA_
zQcJKFMkYp<(oT9^KkuNXr1WF&l0XP@n!)*Le>J=Hob2XmsTr0rc?^Ke#i-6y&va
zCY`XU_yq+8r_8xdOo%MjrXD>q?bfO!0RwtL(`Li|$RflZN0rV#b1tQSaUY=bk(&ar
zVzawBS)X4Q;CLHx^i?tl;y$%+5fw3*^D}Y1H_Y~Ux%HX}Py&0G=UYQV
z>TSpK$;ps}=(xa(&PzdDlBfYh-LMbMETS)hbk*QyVG1EHl7YM_eO$g^Af@G5MoHq~U2c?xW?lV3ktDuIR4Nm&Ew^cx(WSvEyIs#RTnos#
zur1tp)sPpdcRM_iyfJ3nJPPcC*~SobgFzIg7ZR05Zqi#YRvrUmR*9uh`B&Z9x1FAi
z7@ou@IiIJ*|D{Sutf(=6Ll2Ih8yy`*qIjTf@NF23t)psuqSZCNITiF8nb;$dL4CXL
zTPcOgT~b(h0#&7En5%jY!ti={5qtbE97m(J`H$NsGA?fSaRB#h-AQzOF>v-SPQ`sA
zJ;E0QWzcg2#6V)j0D*IJaKvIrp`v;0KjX$FjGQLUh&U5Mtr8~RZ(+|;Z{)NdHk;#^
z?&oCe^gI}^+_^sVzUNB^^Sht~a@t$nGct0uvyYTpj9V}SDMER0ZGj7fC(wP2`;2?i
zJ#LdEh*EQz!}lXZ*ve~v;xuPQgwy*92?N0l5ff75gzD6g6IQ)$6`)~mTzT$0DzR?q{?R%LrCE5%G0|W5cK+^rV^*sBV(wHe9FbRI`v7kMV-!>Dlqa|3p
zb3))E)E{$8(GSBW!XaU8?9GWz!OZ6!TNkAhk)#cuPzo8
zY@}}9e-c$eGJZBDw~4MZSIUoGQp3~7ibGx<=|@B207U9MTFLpP9UdW4L3p<5(L(jF
z0I|<*ayEl&NF=TU2J!q-0}Xfz(PVvAm@eL-7;p_O^0&{`Kg02g1ZbZ+Fuurj%aL+>
z;PzI2X4ooZNfqV5W~m0!>BJcolYHEFqkX|}7L)%-B7us@2Xk1fW8)uMej$nSFwn+38Wv7eet47PR{S{2-;9^
z3kC;ZJvAk+ZOta<~h>xmgw=&FWFD>ryeLM4v^JYH#d@O=flSiMw$BiZ0JzYgTv%<<71{
zJDz77$0p9IRkMrMg+`JBi!ne0*hh!kGcdT_RIXI7Rmh-dOp}~WYon;V8Z$3+ra*i%
zW!cyCtT~WzjX?VyRgMtUNGcRUbjset3mGKNuyB2UGm&RQ)QWt$6%R)Fwl5on_0u_5hD=de_wmB{+tZ
zr1_sNsF;@tTcbEX5O5P4O;7MnNvp^4%@;!bjFS_y^d83-VHi@+R#&M)Z7^H1#_GuK
zM_21U661A&A=V5{qmx!n$8`bPzv~D;HA!$;*Yox_YtPHPDoyEVg&EF**S?sAgVx7v
zBGJYohWp3T;rp^mVrM)HQb=|zVAVr~Gu)w{Pi|V#--S$|1Y>Xj+JSWVOFrsi0#yxb
zGs+scL?qp+-K;ov+BUXvDxc
zz1)9m`qy;pDD{~Zi)JSQuRd{7CBYzT#sgMS1gCQd!fZ5J3-7ucX(WhoS(*V@+gT9u
zsnEqkkMu4ho0ofE-^|ugD`w;jtnrIMzKsd*W}zA$O`c#z{H1*fDV!<1KIa-
z=2Nr2VYw?{J8tCj^?GD6WL(XM_C#cx>lcsL5xPz@TF*A)KTLMI&zQ?8%WsA7>}&p$
zjiCZ0*-*!r<-;aiAU_E>fJ}I^W9Uonz$aJ8H+H{sp@t3w5WZ9@CPIqF%=6{P4-c;`
z#K8j5QY#eqJGDz2Lc`Ff{->8F&VWrMZI<)sEgFZD%Lq25-SQ6A4=1%z^t)(nHdxz5
zq_W{%@$s9xfuTXamtb9{pVLa6G1yP#0TZyuy_W=`Y3}N)RG|a=TyOuEu&1mbH5eq1
zZ}o{-N)v3acMR%d%HJ3Cu9Cn%5PSUI9=~)qZ*c!_Cf2kzLM8qx#{aEc<_$B`BqwYz
z7qUKX@f?Z)l;{(F5q2j-;xbNlMDrxN=l=&Wzffh8-Lc}2l#3V+dj9`V%S;@$Z1UdX
z|IeBik?0xz$!-d<9ru_*{}kT!;yua)slOsRTDlAXAt4m!8OkTi<|wxqO#T-!KLfM<
zy4s5jC*XP&+W^Sr=)a$&lei^D|0ij4ZTM)K^PToqU`?wF6v5R<+o+fE`=!An%oX5k
z+*}WR30euMuVr~mD^-F5?m4=){H)dePm=OrYWKzog6=f2X^Rkw4!U^fy!KL{mEPUx
z)2h&amTLe9kQayn3_AN?ewR4v47R%oKyEt=!~Z8aXD}B{`|>UrV#Fie9e$Atv*j)a
zo%obQ-pXrT*RLl)S+1LQcK?-GysPj(4?OP)Q)hDg%_ukP=gR!+W=?YJpR4pe;iIf9
z6lfxkX}D~5*l+e`^_K>NR#hZDKu@Wf?ovAOd_vPYU2=SZDt2LJ@Nbk5#qca$+oB8~
z-ZebojO%k-Fj3xzfozfKWcXx*h@HI8%PYx06>(3*5G)!6G7X^dMeH)p+xPC9gpteC
z=DW5t@{VP}rIUD8vYJZY5(-MPnUcVCBogN(M=2e$>suT_5H1)H7+e+mvrAgA`1^ec
z)(-mKt4Rl%*EIs1OHiKvcI9Ds>-l4KHe;oVhHBo)qk}pCykOVZYt0$GJpJ-R8gsU3
zzQyFbjtR*G5OsG&4*&Y4IzH2d;a^&{-t-J6r{T?H$zN*-xc!;cZtJJtoCx3i6{Pr*
zyEeN@#1&xd&|E)qiU1N--}=l+#pRfnYZDUsBTGP_^1%KoxA_@NVx71Q1_`HuBCaeu
z4RSuqPSWigF)Ny_57>KV#DA`w*rC>dfyE5zqV>5j9#pY)xWXB6C?;e@yS9&Fbgbw=#M=_zFGyatUA
z3j8_#_$BACFMW}%ndbBH-mxkS@`v$&sm{uZyIb-8<(!@$f`>Ql^hQtOYErGauVr%-
zoB#kzVcK?g@^v2#X)mG?cv%LUj52xnc{BSeBD&R<=i%D@BNxd{c3lTLFeupe2`;m_KOTfA$l86HX(R^;ob}&r410hbgryXNaiK#
zow78?U#f-j8K}dj<#4U2;@=|fGqJ3_Avw?pB_e@UAiT4Y(g{wSVuXtY!Y<
zr$0XD#pVIDJ7)15^=L?tT0A1|{Qh73rUo)zq!BOdPXl7->Os$dC0g{2kp(LJXUt|R
z?bt$KtL-b;V5LE7Ok2h3>KZ~W0YN>x7yBzAv;BdEEcB
z0?UMz*9F1~XpI3k8(1Woq@hEPCK2MZ@cZ6SOGpm!2iW8fmpFC4#{Dh>O|$B{Q8T}T;aSyt
zsZz$kAgBcA1A|s8UTa_FiG-{p$3i?Z9l3IA)%ri{kX&jI@OAEDi^y68Vhp{(A_yBW
ze@80q-3B7Gc}I7OKCzB~iQe!1@5Z);3Qs0HMpI{4UUgYCXg?~
z2>QWs56g#w9HK!8a$0YMB}ijJ+L++X%NPmr@Bc^m#$ye&pBfR19lqRe?uj5k9JD-r
zRFjz3kJX$s6TlOsifqjD_L-+yPpSa0&h8|Bo}B5#gfjs&5Tx$&RRoJKPpee;eNdnt
z!ew%&YAyh9zv!~036FwoSfXx>No9V&bjHMWW>DfHpfeDg#1Q(7MhwT5Ar|Ylox^_g*(Zlwemyj%>=AEdtHWBYx0MdomIAq
zv#r)Q4ksIbiAu(BY`Yf*4AbhE)9R+tcGq~+Qu)bL?4PN`AxfHr@!3jTbjZ*I87?|y
zDTjCc+K0q2$O*xKSn`7#R&Sb!raY(pqi4rw>B+#8_*^LI)WFUve#Dq@!LGV#f3@o6$#G%@BkFA>-tDih#q2tSddA=a1Kk^bc=d4&t-tMuP6
zv4s)RXF4>1Ko@N)?3}|S!c$E8P#iiax7PbdRW2HJDFmE|Id)?J_`w1zkIZY1T;E5J
z+)U1XcKUd)KWV7>MKRFnf{TwT*exC9PxqBn7^$>c9CvPhc3xYyg}wG*w(K1c$Q^{L
zV2cXnH|R>0MhZ$q%}I5j|BECw3Exb{ROHYiG&L78?S_@ddQ)bg_>hsKZJxm-CHt(k
zC%d$?Cu;L*)uEPSNCiBo1K9Xn98u<$De%gqKZGIvoWSlr2|6biSx1+aS9X4BWY4n5
zA^8DO1}^0<%pTVs8M<)&`1c+>2doxzp5^L<8|+A%^{5P(cwGLaa5PyYy1~#zoyR?W
zgk0@4FXY}Qo=CqUOq2=T-`{F~1bn0|b@>V>tc?3rz2t};>L`!7xPV2?lYmxigOQra
zZvGcGu_P0)Pk&(tiPu}3Lk{(`CcIZ=#?H2Ve`U3_Vik30+kY_YULv_$g)zJjm8hIP
zS*Mt|;o?p)ABI76L(x)s0?n7G)_-Xq`z1@z14)qnmzbgxS_dNPAJ@b}RI$X`!$}bS
zNF!ac$Rsm%)*sm3@(J+zelqC&5+)XCE`X;-BF)9C*f*+;Fz^mHUSbB^6MzV<0tXVZ
zUpM*sk8~mx7|&Pl*|qLmaB|~rAxN7PorwoUN+QdcMseH@;yWY)bOvZP(S%%9g>b_H
zBuw=PU;c0*2&|&3qn=EN%|qp%d6%
z-l%)ZNUj6nsiJSXDnBDs;&}}l$)QSF6Vz4(Tb?UxvaV(osMG259HFeCbZcmaZCF1o
z%L&EfwKVf$D>(dB8z0y#XWU8_9
zvX6>vY6NDWf#0o=*;1)2p9~-$spl?!zuo1Jz?I=wcilh5HAKG3%)8k@>Mu?tbdP5Yvk~wvk|5Jp`)7Lk}Zod@D>LuUHGe&!L==%zv{SiwJ%xx0>tEb+n=5O1DR9
zZ8N{C0H|5?2jD>^OxW4^2Imx9!irs3RhIFjv!dL!i-+pKy9nHk#(4(_nv}M1Z;3pq
z1ZRF}gAZr&ibCaSO`o?O!J?q9OMhS_h)irw5X7W?FIaAbEW>sQINC
zCU{7gg}adgwHjYoSRgWUuKbFaLos#ok-C3KgwzZ2!r*2JCpYIO&LiN)jKisge!yHj
zr>k~4`qo^d8ea4XpJ)r6>$W*v%gAW42kNR4{dK3Vu8v^CmJI)3APlhmP_qZ0Ka4pk~l5?=kCLm!-cHGkg_37#x`
zX`gdj4~eEjNJPpko2il780)$q!>d=ocMwwfTBKVWuXP7Oil$Ue_CV=vHPUNqeW|3$
zaI?dQCpba)Icq^1?VTZLSAi0yNOo{*+CzP64JI_K&Y;^ihFmGBa{6R}HE3_Rs7v#G
z539j})*7EHxL*bJq~3@wX__~)^qr8$e|h<*E{HZ}%o{Uh*;P*8JVIv$N~P;v4OqI(
zj`(RNWloj`(Pxeh{HXe&h+JYZ=I_$spZAb->yV>I2&o#geLn}#_g+r4a`p!TJ^LuR
z_TB)?U+9WK%|_Gn8}3hq@I{*l1Gi+<}1=1Yh^i0$9JmCkOT^K913j$4w8r
z?sNw|2-0c2i~;f|aj2ZV277ZozTEr?B`<8tsMmcFmoB2}DYl;i
zxCexCWM}rVhA*K~%8yI#AZ6Gq}%1
zll0dGPud7`$bQ0B5uVAnUSbL9DW6^
z;gth=pBkd=RP|_}fi3KLHFuD31lY4N-u2j_0ok%UFXxUPAshu=Zb|>=-U^3?rCJ2N
z61qA2HI1pQPkT~`Hd3*5lZml$_s^}9OX}{dTM46H*kX;fn0q2bQ1cv9xsqY6UlhyW
zhF9=uS_9zwTQ=tu(!Fy*7G>iKAQGU})w~~>FsT_5f`#7s3&fsmn@Y8!BigeiNT@hI)kv6^>sk5mfm>zO_1E77PgM<36}W1fK@^
zbqexpJc~iPrmc|s`Wy68Ly1D|T}2l@ef34bdEYwD1euLJ7r)Xg2(q{?qwpr`t8Z)D
zFhQoDCLAFmG56&5efC5rCC}z_`O->df9QX%(fJrOtL$~MAn;jSZ*RAo?eptf*@g|d
z?czi=_hC7{Zv;TM!3b4FdHY%LJ9UaflRd-?qytuea5G&W3-9Dm3^E%{^oV}wwzsk;
zzO)#!Ty{$CJm(*o-#Fh?51DLH+r?;7KQxH2-9}AKo~qaC&p`8kbX>(b67`MUXk5Lh
zZ7($GcsfZAy|V#>jQ+3Zj%Lk4t!IA({kn0wR_}_0D>MK(`usd1V&q(#Ueff;XQkwd
z{1fxhuCJwc%Tcq9>DW{pF$HJctc@Fm^&a$+k(hY~JyQB`H
z!=ES%#4BAIRj~S323H+{p()v_&4SlGBe=N>M84J!<805~{N4%4GACn0c(@u(l=l!X
zdxz4$4k`PwnGmJh{s|7OT%@7b3f6;!)25$(rt-0MT3JqRqrjS@EpH|DKrX9nUU~NU
z(q5Ba*?sM^czmG4mpe(a^nj^O-OO84)gx?eKE%r7V4CeZiDSMUgYT5N^}VDzZ9VkM
zyBtQRZ%znm)@ITtO_U@b<;A*_ED*8{OQeo|Pv;28NY&va=F^IVOt*Mv0<9Ic_pw=z01?4JquJa&5G^w$H-K?S8xZlkzu?Nj+KubZ+4de$)Z!rPk@lnB?
zPl|WlwpA2!1!hL9ZsoG#*K5qn`z8lYVA_M|g2(6K#aG#fJ5LWgA^q9uBlU^h!@<>P
zNX+u3jcf9zV-{bDBEDTBjulq&G~sUq+!v{8j>@kdX4*#gNUoX|uQT{vT>iC_9eT=t)Q28-x0P2RO>6q-6h3DC
z+4YAkwQ-VXfo9%E%RV#D!#F_MVQg;s>95Bo(*L9pQSsURlWh0hT@}z%1zW1FnwtNU
zbVQpV+2QvH-IaK={$9{dm_xS6N>g|k&%Ix_3DJsp-;^5u&$Es8{~DLbeSqxFAQW6*pV08zf)H#~7Y&j*
z_w)@)AASdxBb&0APjZ_fiz0V4;Q8n`$B=HyK)&%CmrQj`kVVl
zg@?6kbnKMSpWMuWZ_je4n9a)D-9!r_jpEtJvGtEnJbpX)YG{lHY#EQFIhyqwbKiN#
zdyof%sNegf<`|;qhRZ?7GvVOUO_Bxow96MQ;frbQJnHBxdh-<~$fBmsgICptI~%1H
zVA`(w7nd!+IdFZ9eBBN_nQlp6PtjkxnQp@R(fMmiG(wQz;Z{+>ch{X{^-=QHC5)AG
z+xwXx|ERV2@MZRW!=ft@T}$WnFXf*gUtC@wniEzJJ7!4z_&yLc;c1Z5e(;hu-%Y1l
zT(5eLCC=j}zg4;G=-|XvU2N@w1}^16%wRZFL~akjHs$beI8RJ(MR{2jyNo3Hyu@MP
ze7-?1Xw+~;MqyMTb=a-&Yn$P$+6lF?@<3y4MUv$PW|l!k&Ris9sGL_y!I+2r+~!;C
z@t4i3bzo6)Z1xc6X~&RL67FDp_~+Rlz&!dZzAqiTP%PV7y7y3}-x(kiOry9r{(8UmfoCW^VQ3R_qd+CU#ooMW
z|4OgztMYln>#Du1O3OK4GB4Yv(B##4m~N~4g*<`WgGE&6Mr5q18%}%CMi2_t&7ubu
zC3#_O`dUyF5296JkTV!z+jyNr4`^yyHMMWNe`8v84PO&N<-1-E563Z`AezH>%2{3Y
z4%v|KqS{P$OaROj9tJ@{k?})AV#&L^u>3I`sw9JGDS2I^k+xf;{)$(v6eXhX)`sF<
ztC7(dM_$OhUG~knZMNdfZTr05I7^r`?Sns|LUVKee7;#1H(v@qpk}wb{>I+K4ivCKMNTgclHKI98|JV>kffRX2XXntr
z20^QS-7tk`caoO9M3C_fHt!=Gw=VlE>{(qpZs25?IUaPuj!kW
z-dr{A%e{`e-R{WZMKDQj|^I&IV
z^{~i-%(FbE`#yF-$M!a*9XFUZ24%*py>8@*irZiuQtB++{)ydSEo4UjU&XlJKe3Cg
zPBvSVWzlTNy|=y|xW=Fp^Q(s}W=D6IJBOacw>=%8bTMUa8_gh;63dNzujbgVYp@p(
zi%R+)cj%tEC{D#-wZWl#cCrVVpHu|P#1Ev9%6&Z#&EyH1|HG|AC6!6ycz@qM=Te?-
zu|CmyoMCo>)0X>~hu|BC?4CibF1)`pg2n~_dVOO?VQ
zshGEQX}T37;{DvBm$~|vS$Ec^Zy9Fg|6IS%|6sETi`Sn|nk#iox<39Maxu=gBkVJ|
zR6V;mb(T_@uS&eu=qA1@*mOQq&~Vvd?)0DIMNW>v
z^wIRJvV4h!Y3<&DA?0m(Y?{?>vNJ?DK~a%DqK~)K@N|PRHV}i5AwfyP2Yx=d*`8
zqkLVo&OhC983?%Df9ve*q&RNKNjyleFCNvt+uNnu$QuaaynwO{lr*VxrD}2)>&-_>
z+_LK}ON%NCEy?xJt)&oBJf;-V?X5kMg0aM{>>?P`&8yVDx6A-COLMd1*YfCe
zmd1TfGgSq6Ug&R+HzkK|mVl+tGzY|JhU}fEs~#2;8|j=;F55{yITuh{oFo_j&Lqi3
z@eD94jGe`!_6NqC*2?1a9{$EQ4wZi@Kyom!=_4NGOPb-CGs(`S_#TAjBKqk}_~lx}
z^~>i#ix)ES0fVhJQC#0La*UcdA2O1B83NznaP-%|7x^}&-SVVL1N9IffA~HT_IUTw
zJW-_5s?@OIbla&}kE7YxOe-gYIEMkj~e>#mjKp
z`6D7uN7)+8aLp*K_rH16huP7_nbj(Q^5F0hci@=qnm`g5(=(fPaXHLE!;8wm?(|*B
z@p-&zp-R}Mau+OFi1oiw+2|8}(&sX0F(KXZ2Rcs2DX{4IF*P|qbMY1JqBu(`pZ-?-
zps3lYVf|TBN9s*oKw7g95_FPd#XlOa**@h9)L&xjy5Nyez;+PBIh%#%>oTs*qeazy
zxNKG1lelb?c-A5U^0~4%4vn@N|^grS7L}vaf|MEbsqaxe>Qz>NvG0
zLDok%5PH)UtOn8V^U;|6CQLFe$aNszds5R&+QD)BoysYjW}{_kMG+yEAMfE9|2e=C
znDba#rZ)YL30TdVI{oHj@6-BrS-r7(6xHSRrc(I>B~n9XT)Z<$4D
zhmU%+C-m?{(jHD6MCHB_PIv^MdS#mrp{qmu_t?0TwUap?
z1AGuX+Nzh&baaO^6$SafIN(Q2k`jwLTzN`8pk9(eB;9IpP^MWY9*fQj_~9Rv`Fl@h
zs0WPvQ4h?TN^9LeCZ#pekY>ukrW%}9hCot)+rji40;b$8&INEk>Q3c-L5N+SugD_)
zW@K6o@}FBp`ybu^!nxn<$1@Aw(<4|g;BP>X&dAI@J%}T-N}fC$ns6Y$;Bwylv&CPx
z4(%VPS^m+f)8+VyMnZh66?kxr+WEq0zJi+P1Njr>52yuId{{-Hu%+ysDehfvy}Odr
z+KJRXqa+_F6`^&y$hWVOwOyY6B!qhq(Pdsl>;UBRPLM}TI
z{jQ`4>g%yMa#Jo*kG^TiHX-gG2+WnZ9T(k%kes*A9V~!__uiwo2Gvzan=hhjoSjqu
zNB`bqv1NZd`>FLWoK&btRh>s-O;b+f0u?INqOzq9lJ;a=4m@~RFOE=f8EA%5aJS1k
z?;u*!>maMoaxN!}dj1z~#!voaZC(2d`!9T1yJgCPT;M+Nm)lC|0&AxL<_}UuI@ikC
zsPB~Uj^J`O8NKL&!}=395|iwEnlGwfeJ^O>`bcpv=PU~EWA4P9u!?6(Rqswp
z*_fV!{_Xmsj;})GJB{JC%1@ZJC$!=ez@VZVY881Mg%s0p5cd!D5)T35$6M>Fq75ND
zbr8&5i1=LGwDs_cc8f^_`PVf*T{2kqs^^RRZiar_Xlj{Og1;?B*9X;^G|dTn++|QV
zZ?@H+nHabi7Z<6S*Rumum!@euDJI*CPb)5G%bp}t=n>s5?WbKBEeAc*?IJ~6k|L?v
zj-kw#!2)c*IS+5_g#7UX8|wGs
z13Dg`^6&H8Hvh=5#d4+z!
zlv^rNnT%}G?@kL(S{}FD3&4}6!xSV5`Q_&Q<+0DG(@p<)6(!!U&wP2&Ywb23X$9@J
z;)~8na70;kg_K>RFfZq9y7(?O7=ccA6G|YVsv5%YA%AS|t@pU!k1y!#hOpZ&>$PG)
z*7@+Pu=yzd2uJI4p&2bEWsn#vgK%d^
zM-VlKqj6Zl5GQM2b;rr^x#_pLZVk>~J^-qk7VfADA+tCf?ogs-
z#1hxSa$V{9FP^21p^~Bg%vKb0KSO0K*Y}6leMLp)r7Z)L97{nrdWe>X&9Hu#1m)cr
zVf0Ke_BOPSxWw!lpfLdulqbH}B7bGWMS}cW1%6;%UT0gqO8(_mP9|51985!FI81td
zOVp&PNh1R+Bqls3zE+qf;}QuDS4jMH@_UloZqrufqa8?-gB4slf{2$a?OJGXO}kKY
z5-xmiKm@A>+!~l6OGepVpcOUN^NC6=ZkGNbEQ87T3g?N2&E&BfJYZ;68eV#-P%ILL
z@B^WsOfJ4Kwc)fk8qt-JWaF0tdhrbz4XKEe?M@=RT?1zcr^|VfMDO+A#;n)m$&6bI
z1z^#=wopAi+`Jc5MS+B_jTUC_Z7v__Es=A@nO!LbdG*GT3|>HA{eUKhcRNIr2yslM
zCS|d|)v4=Cz4`fh<3@-z?lJ){TI(Zpf~nt-+z(_{ztLqZA(k&v(V|HpO6F=m7f;LdCHV3d^oUD=
z;6;!2;*qwJ3P^AuZZd>NY6)-+{Az=n7lN9QgB?jI$FIRTcg^*Ao1L
P9-u6*Ay+M99{7I%c(7B=
literal 0
HcmV?d00001
diff --git a/icons/123pan.png b/icons/123pan.png
new file mode 100644
index 0000000000000000000000000000000000000000..acac41ee4f7d685f44cb7a0e0a9c7f377d5c0ec1
GIT binary patch
literal 381
zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE3?yBabR7dyiUB?$uI>ds5jB5c=r@#63t{|@
zsQnYcFOdXP%vBQP7tE0L?5ps;UUsc8k$r1`f{cuwE{-7ei_MrqgT_Ji`WTlU}UJM%o&(Bz(x@tVJd{~7WPq(ZmO?G6Bj
O27{-opUXO@geCyHkEg@{
literal 0
HcmV?d00001
diff --git a/icons/aliyun.png b/icons/aliyun.png
new file mode 100644
index 0000000000000000000000000000000000000000..70f841e30e9c581eaf3c873ac2204bef9b644a03
GIT binary patch
literal 1150
zcmbW1%TE(g7{#ZFJ2xaIu3h@SwEu=OfS?6ROI!MGXIff|fJA&G3egaQxk(VJ#3BG#29woUZ#N`pD;cyQLmiMrCnVhk$#p}yr=beHN)-bQ
zRSH#ADdJTlX%GAzKC(6GBQrpSsBk+ZN+lR0bIR~KM&1uK9Dpi@pi0r2D#d*8+m#j@
zAQ#m9N#^uJW+s8B3&8Rc@UjBAKLeRaLGt~KLB1HJ;uZASyR6&f4w4khE=7?__0sti#EBV}bz>w6G`;~pe?X>2
z=|Ag9YX8Y*d$?2M8DM)8U~Mvendjs=D}+t}H|UqX>L2Mh{nOs34)_e&vk_qN0kHZW
za%GJ5Czzi(!{i6a&t5hBhd0dCeZX_}`n%43X&Csv4}95%jO1Fm#YwSm@?qA0ZPa+a
zvjvnRNQ9)!y0w|G!P}vcLl>iV54qo&~tZt0o}i5Cvre-#oVhd
zl8sS(7o03G#QDV-BgPnW*MZ-^0L9s_yk)=lfG3aW*}7r3Hh>Fdvhh}KahxXEUBt<2
z(QC}#0`_*z^M6-e_gW2@qQ|)*K>Mb1yDr)GN{eN`&|=NF=l~0Iz^i9`Cn~_*o4`5F
zE*mBnr|6r@p7MFJ?LP9g7R#AIE3S9Oq3=kaq3A`H{y0&?yYwyZ)X4kTw}-5ks*-Im
mziQQxWG^Z589&(o>kesN!A&%fPgjx8c~K~P{{9dA@BRnhNF#>;
literal 0
HcmV?d00001
diff --git a/icons/baidu.png b/icons/baidu.png
new file mode 100644
index 0000000000000000000000000000000000000000..0013c7e1b42ec9209e21551fbe205602fbfca0ec
GIT binary patch
literal 16958
zcmeHO>5~*S6d#Hz{15!99}#av1Vmf`6$AxE1Vy1JP*J>4E;+2FKrBlw5Lpfp6%P;!
z2X?w$LYUoUAgGkv7z*d4&Pl})CTyu4pJd3ni8+iacK
zUsaWj{lC)IwZF}Fxy@$l$^v_2t7ZYu6{>2BjQ$f*Hx?GLP|HGyg$td40a$>kn;v$S
z*M)@|7Sb$StORVp*hTCI{AOVf3zrH3tJ()FjXt^5s0L$zfyR8ln8&={dj)2$>-5SP
zK2HY&6y5y|`Ij!DrUkE1)BL&A^5$z4cyBp{zt}>t-+rZZz;DPyFEicJLb1cYPUXj@14}L7%nZ&6RhHavK57S%FX)=_H!B;(-
zGO?IURf^UAWhzU`RWHR$wh-1Iv5#}pezx9OArZgxo&l6@ZI!MrgFm?TBWZpz1sf-h
z)y3}`HJmcZWFDJLB2Hx$Kl(ShA00_`J+4;kxW|poDkI|euU?sz*R*h+T91#jq|2$6k-^`P)1F$n
zz=U&P-#nQho1NJKnN&*UnqoGa{w!e)=Rq7X&wcri94eU0Liz{hzDCJfA73L!2UD*)pluC<-qT}yI?@Z5pN{t`qB{)-QFTx(vJ@P)yK2Yby&DT)fE!QcbVZvisqKWt&Y&_Ku
z8>EIh)MI@q2ItU$YQA&u9a(wqF%K$dNLdZE!w={K-^>@YWD#W+)46tnh+iycHQj{0
zL!x~1U8WlY?=Ca#gHq$Sv%N)-odZ+~MRt5a_FMGNLQ9F?(dR~re!II;psRAkemO{v
z+k0w`g;L>n4(LPizy4I)th7Y@$RF%X`lh@-a6SA0B^`D(XxWHnb02GL;^grTgAAP?
zW;R`D-NzK(zLipL?K7s>p`QuokY;$Sb
z;J!K2HEnJ9eyrd#E2}l%;aBobdp@rn=tk`S;9Dt{;Dd4vyF0H9QQn5}j}QrGAiPcA
ztf_|eL>@+I_;+#%XQi-pjq1TZDRv(OUz<@IJWp#a%7j8x&+HanrjXb*VyNc1uz$rn
zQhZw$zmeN@(!X(zhp{9hw|mS8O}=qbemz~+4$$!siu`qqs
z#Lnb7ADKAS*r3TXym_M~KD;gXPqYYjJ})B^x4iY578}!NE@qR<@aJWs%-5qwVdm43
z9S2xHBRhBKu_EYr-lj~9{XroFJ4=Q?FB9Ff)36!LCo>;f|H`EVn+Rv6&O2`-&vTOqGRqL|M`1kSj1f8*dIsUA3XJB!gI_K
z**1;jf^Dle-yj?Ca*F?bg!Fu+kO_LBR4OD@gkDv*(a^lpF2~m-r;GZb(>=rW2{*s
z;cmN_ZlOmzLy5d6A&c%rJ}jtq1mt)jK?m?+~=^1&A{9G}f=Y_3y+n
zY5cGQDIV`|%(()OVRyN)U&Y=GXQKqXDbGCzV8=ILB^^{c
zT~ld~fm!(;mc=!dCR{tnqkR8(?i@H1?a}o
ztc{FO2NGZ76kR7`MLO2@;EJLfte{5#
literal 0
HcmV?d00001
diff --git a/icons/pikpak.png b/icons/pikpak.png
new file mode 100644
index 0000000000000000000000000000000000000000..a13400b85d36a1d0efb5cddbcf492221ecaacf05
GIT binary patch
literal 15406
zcmeHNSCAG(5MINBkNBX^_hj)vI1rICl&EJw%Q9fZP?VuOEiXvs06__dM=*dW8N@09
z1+<_LL^%NofF!J)$d-H1zV~UjJ=hxNWU>K{eGmPfKBTb}*DwjuKLUHRt6zA_2
zx+nwa|#G%)_4qw*3g~To+E{A+PGE-QQJn;7TWs0;}gK
zV&0j^>$TIGubmt~^%ffk^Ug%xu=f8R2S`@(zU$h5^9ew*lJ{NLe$#gV$x7aLU3;rt
z(9^H|vd@8|dx5Goz~-g?RJ9s7B0S{v+V7)6;J;c$Cy$B!Hv?l|1;)Ih@+`CJ$Gz@s
zZ;yeBr-3Owfv-j@9dkzlb4LKnXR0#Rjd;e}mSfOa7AG%l%{dUKjf2-U2Z_V(OE4z#
zn0f;jgcucEXYzzP3UPgkl2F{LgHM7lD;u
z0uxGrArA{Z0!%DbWvmNJl$})nnNvW;Q^2etz>3+5W)244l`__arBvt-ldr35?E1G<
z0;Hg%t54d0&S6qe($y!`Z;jm`{kr<3`gQ%&)hE@j`@XvRr22#G9<{tlzkdDYdQQJ?
z_a@-yg}}Fyftj+_el{Q;vCZO1z&eq!OJq^z(8u)pIfv-0T0RM%wKc%se*nL40M;%5
z7EJ&~J!i`r`K*+Um%91FvtIGCjZ?<1jW)kEPlKLQdbry<m
z(e)D-_t*JjWM37ZdiyHz??m7Evu-^fya>Gag1hX3@EqAAZNw*}KfYkB+Mk2^>i7Q@
z#9Pu97;vxX>5+>3$~87?i2~OlGekC)!7Hr?kvxaZKGj#VG}#o
zeFt;A^SiKbE}pFIT~YcYSOxQ?xhi9;@nBc|<9smJ8JXL;HCLn9A0}q5kzAh|o9%79
z#4hW5PLK7l??LR`CxUrOZ&d%*Lmvf(b9+r$2Y^Z4gt`OF>r!0)bmLz#RsE-wwgs5i
zrMUd*#?M%`wYM;@OL6&|@P}KJ@d4BRZB(ovoLlk4;ZL{yw^RcC?-42pkHK2~ip!sF
z{52P3Z{4qmd0mRjpKkm*yh-bu@F%rkvi6s={pmv;KO+AH2eU)R4=X>eA^r>f{^hfP
zv!?*YVY%)t{S=rz6d3m!Fj(#=4SFCoL|ME~%ezP4Pgj1z!JTq;@Q=Fx)T4vF$6I@S
zl;>pmCp&$@9mloe%=Dpn|99I;b>EhL6=TD^%jJ!?mdM`5^BmTVF9zmRsQbF~i;qf7
zn7*4O*ZCxV`fpLN`tkJb2<+SHFWafdndkb>yU4t6V99*vA-OBCQTdHdUR|DNK>hBv
z!28`i
zU*g>X@0|$tXVVh#*KY%Q-3qK<!i?z&5kRE>nA{JMZO>
zE7_&(Vq#7~Y>{^A)?REa_U2m0`TO>Ra;NPkMa;9T>@LMa-qhGu#VC`0m+ZCY`FZ1w
zUzo9BGX|4OvEHcTUt+>_ac#!JvNr~z;&t;`@jJ#K8H>#2xV6`|jP)|+TX&y8-5W4t
z@9sW&+vUnTW9^=M1m^t#mgjaHTjJJYXV3is>n?%$9aJnnx9)E+K5foX88aTcTiC6q
p9g{cX+E#ouJVu$8ab@nSRpuQY#;F-=*W916`q1=jvOrV|{0}BLTEzeW
literal 0
HcmV?d00001
diff --git a/icons/quark.png b/icons/quark.png
new file mode 100644
index 0000000000000000000000000000000000000000..6751c3c7ed5ee3d2c60f1ed95d891f5ad55c0bcc
GIT binary patch
literal 16958
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
literal 0
HcmV?d00001
diff --git a/icons/tianyi.png b/icons/tianyi.png
new file mode 100644
index 0000000000000000000000000000000000000000..00d13615b8b6b4a53a22c0463126aafa3f6ae873
GIT binary patch
literal 12051
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
YXd7y5+stqOQSJaaDW$I!65xRU2Y@Rl;{X5v
literal 0
HcmV?d00001
diff --git a/icons/uc.png b/icons/uc.png
new file mode 100644
index 0000000000000000000000000000000000000000..245733f4f4edb441aab2547ef265bb5ffee2a79d
GIT binary patch
literal 478349
zcmV)hK%>8jP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D|D{PpK~#8N?EMM2
z?aFl?1{Mm#-un!9diUNpad@0XN}?oEqczaBMmtUvTegx;>`pAV-QAy)PA8pqC!KtW
zqDXUiB*n+aH{ExqGwr$Mp_8Yz
zwxnIa;f12T32C6ng@cs#2rn`^Kq{>%KE*^hpHcx#!OP+s>!G|TjyX1a1B6>6
zl<}b=#Z*)T3%V4zb&3)!K;bYS?O9YHA%G$n3D`?nW^GU%bF?}rZsF!&q^*LoajKY(
z@(WZ~osx8dGzndq%@7x5MqR)nf`UuU0R-28O}Nd@Ex0efuci1<8OjS2VUgV`i`KNN
zsaoaL3<3Nls>6-x)4v(V>@^xr319AR{Jx-O=`To4riqC_6As-FVvezImD8+UijU2o
zX)pN1_zFd;mxV$cz?`BJHGv&r