v0.2.7: 修复Redis连接 + 启动管理后台

- 修复Redis认证 (配置密码)
- 启动Python管理后台 (端口9531, 15个功能开关)
- 统一版本号 0.2.7
- 更新docker-compose.yml (镜像版本/Redis URL/Admin服务)
This commit is contained in:
2026-05-17 02:22:18 +08:00
commit 83cbfaf03f
164 changed files with 25195 additions and 0 deletions

35
.env.template Normal file
View File

@@ -0,0 +1,35 @@
# CloudSearch v2.1.0 — 功能开关
# true=启用 false=禁用 改完重启生效
# ===== 核心开关 =====
FEATURE_QUARK_PID=true
FEATURE_SEO=true
FEATURE_LINK_MONITOR=true
# ===== 增强功能 =====
FEATURE_TMDB=true
FEATURE_TELEGRAM_BOT=false
FEATURE_SUBSCRIPTION=false
FEATURE_ALIST=false
# ===== 转存平台 =====
FEATURE_TRANSFER_QUARK=true
FEATURE_TRANSFER_BAIDU=false
FEATURE_TRANSFER_ALIYUN=false
FEATURE_TRANSFER_UC=false
FEATURE_TRANSFER_XUNLEI=false
FEATURE_TRANSFER_PAN115=false
FEATURE_TRANSFER_PAN123=false
FEATURE_TRANSFER_CLOUD189=false
# ===== 转存凭证 (仅启用的平台需要填) =====
QUARK_COOKIE=
BAIDU_COOKIE=
ALIYUN_REFRESH_TOKEN=
UC_COOKIE=
XUNLEI_REFRESH_TOKEN=
# ===== TMDB / Bot =====
TMDB_API_KEY=
TG_BOT_TOKEN=
FEISHU_WEBHOOK=

12
.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
.env
*.sqlite
*.sqlite-shm
*.sqlite-wal
uploads/
__pycache__/
*.pyc
.DS_Store
node_modules/
dist/
*.tar.gz
*.zip

1
VERSION Normal file
View File

@@ -0,0 +1 @@
0.2.7

View File

@@ -0,0 +1,18 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY server.py .
COPY templates/ templates/
RUN mkdir -p /data
EXPOSE 9531
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:9531/health')"
CMD ["python", "server.py"]

View File

@@ -0,0 +1,196 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>功能开关 - CloudSearch</title>
<style>
:root {
--bg: #f5f7fa; --card-bg: #fff; --text: #303133; --muted: #909399;
--border: #e4e7ed; --primary: #409eff; --success: #67c23a; --danger: #f56c6c;
--warning: #e6a23c;
}
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: -apple-system, 'Helvetica Neue', sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
.nav { background: var(--card-bg); border-bottom: 1px solid var(--border); padding: 0 24px; height: 56px; display: flex; align-items: center; justify-content: space-between; }
.nav h2 { font-size: 18px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
.nav .badge { font-size: 11px; background: var(--primary); color: #fff; padding: 2px 8px; border-radius: 10px; }
.nav a { color: var(--primary); text-decoration: none; font-size: 13px; }
.container { max-width: 800px; margin: 0 auto; padding: 24px 16px; }
.group { margin-bottom: 24px; }
.group-title { font-size: 13px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 1px; padding: 8px 0; border-bottom: 1px solid var(--border); margin-bottom: 12px; }
.card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 16px 20px; margin-bottom: 8px; display: flex; align-items: center; justify-content: space-between; transition: box-shadow .15s; }
.card:hover { box-shadow: 0 2px 8px rgba(0,0,0,.06); }
.card-info h3 { font-size: 14px; font-weight: 500; }
.card-info .key { font-size: 11px; color: var(--muted); font-family: 'SF Mono', Monaco, monospace; margin-top: 2px; }
.toggle-wrap { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
.toggle { position: relative; width: 44px; height: 24px; border-radius: 24px; border: none; cursor: pointer; transition: background .2s; outline: none; }
.toggle:focus-visible { box-shadow: 0 0 0 2px rgba(64,158,255,.4); }
.toggle.off { background: #c0c4cc; }
.toggle.on { background: var(--success); }
.toggle::after { content: ''; position: absolute; top: 2px; left: 2px; width: 20px; height: 20px; border-radius: 50%; background: #fff; transition: transform .2s ease; box-shadow: 0 1px 2px rgba(0,0,0,.15); }
.toggle.on::after { transform: translateX(20px); }
.toggle:disabled { opacity: .4; cursor: not-allowed; }
.status { font-size: 12px; min-width: 36px; text-align: center; }
.status.on { color: var(--success); }
.status.off { color: var(--muted); }
.actions { display: flex; gap: 12px; justify-content: flex-end; margin-top: 24px; }
.btn { padding: 8px 20px; border: 1px solid var(--border); border-radius: 6px; background: var(--card-bg); color: var(--text); cursor: pointer; font-size: 13px; transition: .15s; }
.btn:hover { border-color: var(--primary); color: var(--primary); }
.btn.primary { background: var(--primary); border-color: var(--primary); color: #fff; }
.btn.primary:hover { opacity: .85; }
.toast { position: fixed; top: 20px; left: 50%; transform: translateX(-50%); background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 10px 24px; font-size: 14px; box-shadow: 0 4px 12px rgba(0,0,0,.1); z-index: 999; opacity: 0; transition: opacity .25s; }
.toast.show { opacity: 1; }
.toast.ok { border-color: var(--success); }
.toast.err { border-color: var(--danger); }
.loading-overlay { text-align: center; padding: 60px; color: var(--muted); font-size: 14px; }
</style>
</head>
<body>
<div class="nav">
<h2>🔧 功能开关 <span class="badge">v2.2</span></h2>
<a href="/admin">← 返回管理后台</a>
</div>
<div class="container" id="app">
<div class="loading-overlay">加载中…</div>
</div>
<div class="toast" id="toast"></div>
<script>
// ========== Feature Definitions ==========
const FEATURES = [
// 核心功能
{ key: 'feature_quark_pid', name: '夸克推广PID', group: '核心功能', default: true },
{ key: 'feature_seo', name: 'SEO / Sitemap', group: '核心功能', default: true },
{ key: 'feature_link_monitor', name: '失效链接监控', group: '核心功能', default: true },
// 增强功能
{ key: 'feature_tmdb', name: 'TMDB影视刮削', group: '增强功能', default: true },
{ key: 'feature_telegram_bot', name: 'Telegram Bot', group: '增强功能', default: false },
{ key: 'feature_subscription', name: '关键词订阅通知', group: '增强功能', default: false },
{ key: 'feature_alist', name: 'AList打通', group: '增强功能', default: false },
// 网盘转存
{ key: 'feature_transfer_quark', name: '夸克转存', group: '网盘转存', default: true },
{ key: 'feature_transfer_baidu', name: '百度转存', group: '网盘转存', default: false },
{ key: 'feature_transfer_aliyun', name: '阿里转存', group: '网盘转存', default: false },
{ key: 'feature_transfer_uc', name: 'UC转存', group: '网盘转存', default: false },
{ key: 'feature_transfer_xunlei', name: '迅雷转存', group: '网盘转存', default: false },
{ key: 'feature_transfer_115', name: '115转存', group: '网盘转存', default: false },
{ key: 'feature_transfer_123', name: '123转存', group: '网盘转存', default: false },
{ key: 'feature_transfer_cloud189',name: '天翼转存', group: '网盘转存', default: false },
];
// ========== API helpers ==========
const BASE = '';
function token() { return localStorage.getItem('admin_token') || ''; }
function headers() { const h = {'Content-Type':'application/json'}; const t=token(); if(t) h['Authorization']='Bearer '+t; return h; }
async function apiGet(url) {
const res = await fetch(BASE + url, { headers: headers() });
if (res.status === 401) { alert('登录已过期,请重新登录'); location.href = '/admin'; return null; }
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
}
async function apiPut(url, body) {
const res = await fetch(BASE + url, { method:'PUT', headers:headers(), body:JSON.stringify(body) });
if (res.status === 401) { alert('登录已过期,请重新登录'); location.href = '/admin'; return null; }
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
}
// ========== State ==========
let configs = {}; // key -> value from system_configs
// ========== Toast ==========
function toast(msg, type) {
const el = document.getElementById('toast');
el.textContent = msg; el.className = 'toast ' + (type||'ok') + ' show';
clearTimeout(el._t); el._t = setTimeout(() => el.classList.remove('show'), 2500);
}
// ========== Toggle ==========
async function toggleFeature(key, currentVal) {
const newVal = !currentVal;
// Optimistic UI update
configs[key] = String(newVal);
render();
try {
await apiPut('/api/admin/system-configs', {
entries: [{ key, value: newVal ? 'true' : 'false' }]
});
toast(newVal ? `${key.replace('feature_','')} 已开启` : `${key.replace('feature_','')} 已关闭`, 'ok');
} catch(e) {
// Revert
configs[key] = String(currentVal);
render();
toast('切换失败: ' + e.message, 'err');
}
}
async function toggleAll(groupKeys, enable) {
const entries = groupKeys.map(k => ({ key: k, value: enable ? 'true' : 'false' }));
for (const e of entries) configs[e.key] = e.value;
render();
try {
await apiPut('/api/admin/system-configs', { entries });
toast(`${enable ? '✅' : '❌'}${enable ? '开启' : '关闭'} ${entries.length} 个开关`, 'ok');
} catch(e) {
await load(); // revert
toast('批量操作失败: ' + e.message, 'err');
}
}
// ========== Load ==========
async function load() {
try {
const data = await apiGet('/api/admin/system-configs');
if (!data) return;
configs = {};
for (const row of data) { configs[row.key] = row.value; }
render();
} catch(e) {
document.getElementById('app').innerHTML =
'<div class="loading-overlay" style="color:var(--danger)">加载失败: ' + e.message + '</div>';
}
}
// ========== Render ==========
function render() {
const groups = {};
for (const f of FEATURES) {
(groups[f.group] || (groups[f.group] = [])).push(f);
}
let html = '';
for (const [group, items] of Object.entries(groups)) {
const keys = items.map(f => f.key);
const allOn = keys.every(k => configs[k] === 'true');
const allOff = keys.every(k => configs[k] !== 'true');
html += '<div class="group">';
html += '<div class="group-title" style="display:flex;justify-content:space-between;align-items:center">';
html += '<span>' + group + '</span>';
html += '<span style="font-weight:400;font-size:12px">';
html += '<a href="#" onclick="toggleAll(' + JSON.stringify(keys) + ',true);return false" style="margin-right:8px">全部开启</a>';
html += '<a href="#" onclick="toggleAll(' + JSON.stringify(keys) + ',false);return false">全部关闭</a>';
html += '</span></div>';
for (const f of items) {
const val = configs[f.key] === 'true';
html += '<div class="card">';
html += '<div class="card-info"><h3>' + f.name + '</h3><div class="key">' + f.key + '</div></div>';
html += '<div class="toggle-wrap">';
html += '<span class="status ' + (val ? 'on' : 'off') + '">' + (val ? '开' : '关') + '</span>';
html += '<button class="toggle ' + (val ? 'on' : 'off') + '" onclick="toggleFeature(\'' + f.key + '\',' + val + ')" title="点击切换"></button>';
html += '</div></div>';
}
html += '</div>';
}
html += '<div class="actions"><button class="btn" onclick="load()">🔄 刷新状态</button></div>';
document.getElementById('app').innerHTML = html;
}
// ========== Init ==========
load();
</script>
</body>
</html>

View File

@@ -0,0 +1,3 @@
flask>=3.0
waitress>=2.1
pymysql>=1.1

206
cloudsearch_admin/server.py Normal file
View File

@@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""
CloudSearch 管理后台 v2.2.0
功能开关一键管理 — 支持本地 SQLite + MySQL 双向同步
"""
import os
import json
import sqlite3
import logging
from datetime import datetime
from typing import Dict, Optional
from flask import Flask, render_template, request, jsonify
# ── 日志 ──────────────────────────────────────────────────
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
log = logging.getLogger("admin")
app = Flask(__name__)
# ── 配置 ──────────────────────────────────────────────────
ADMIN_PORT = int(os.getenv("ADMIN_PORT", "9531"))
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin123")
DB_PATH = os.getenv("ADMIN_DB_PATH", "/data/admin_flags.sqlite")
# MySQL主应用 system_configs 表,可选)
MYSQL_HOST = os.getenv("MYSQL_HOST", "")
MYSQL_PORT = int(os.getenv("MYSQL_PORT", "3306"))
MYSQL_USER = os.getenv("MYSQL_USER", "")
MYSQL_PASS = os.getenv("MYSQL_PASS", "")
MYSQL_DB = os.getenv("MYSQL_DB", "cloudsearch")
# ── 功能开关定义 ──────────────────────────────────────────
FEATURES: Dict[str, dict] = {
"feature_quark_pid": {"name": "夸克推广PID", "group": "核心", "default": True},
"feature_seo": {"name": "SEO / Sitemap", "group": "核心", "default": True},
"feature_link_monitor": {"name": "失效链接监控", "group": "核心", "default": True},
"feature_tmdb": {"name": "TMDB影视刮削", "group": "增强", "default": True},
"feature_telegram_bot": {"name": "Telegram Bot", "group": "增强", "default": False},
"feature_subscription": {"name": "关键词订阅通知", "group": "增强", "default": False},
"feature_alist": {"name": "AList打通", "group": "增强", "default": False},
"feature_transfer_quark": {"name": "夸克转存", "group": "转存", "default": True},
"feature_transfer_baidu": {"name": "百度转存", "group": "转存", "default": False},
"feature_transfer_aliyun": {"name": "阿里转存", "group": "转存", "default": False},
"feature_transfer_uc": {"name": "UC转存", "group": "转存", "default": False},
"feature_transfer_xunlei": {"name": "迅雷转存", "group": "转存", "default": False},
"feature_transfer_115": {"name": "115转存", "group": "转存", "default": False},
"feature_transfer_123": {"name": "123转存", "group": "转存", "default": False},
"feature_transfer_cloud189":{"name": "天翼转存", "group": "转存", "default": False},
}
# ── SQLite ────────────────────────────────────────────────
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("""
CREATE TABLE IF NOT EXISTS flags (
key TEXT PRIMARY KEY,
value INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
# 初始化默认值
for key in FEATURES:
conn.execute(
"INSERT OR IGNORE INTO flags(key, value) VALUES(?, ?)",
(key, int(FEATURES[key]["default"]))
)
conn.commit()
return conn
def read_flags_sqlite() -> Dict[str, bool]:
conn = get_db()
rows = conn.execute("SELECT key, value, updated_at FROM flags ORDER BY key").fetchall()
conn.close()
return {r["key"]: bool(r["value"]) for r in rows}, {r["key"]: r["updated_at"] for r in rows}
def write_flag_sqlite(key: str, value: bool):
conn = get_db()
conn.execute(
"INSERT INTO flags(key, value, updated_at) VALUES(?, ?, datetime('now')) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=datetime('now')",
(key, int(value))
)
conn.commit()
conn.close()
# ── MySQL 同步 ────────────────────────────────────────────
def sync_to_mysql(key: str, value: bool):
"""将开关状态同步到主应用的 system_configs 表"""
if not MYSQL_HOST:
return # MySQL 未配置,跳过
try:
import pymysql
conn = pymysql.connect(
host=MYSQL_HOST, port=MYSQL_PORT,
user=MYSQL_USER, password=MYSQL_PASS,
database=MYSQL_DB, charset="utf8mb4",
connect_timeout=5
)
with conn.cursor() as cur:
cur.execute("""
INSERT INTO system_configs (config_key, config_value, updated_at)
VALUES (%s, %s, NOW())
ON DUPLICATE KEY UPDATE config_value=VALUES(config_value), updated_at=NOW()
""", (key, "true" if value else "false"))
conn.commit()
conn.close()
log.info(f"MySQL 同步成功: {key} = {value}")
except Exception as e:
log.warning(f"MySQL 同步失败 ({key}): {e}")
def read_flags_mysql() -> Optional[Dict[str, bool]]:
if not MYSQL_HOST:
return None
try:
import pymysql
conn = pymysql.connect(
host=MYSQL_HOST, port=MYSQL_PORT,
user=MYSQL_USER, password=MYSQL_PASS,
database=MYSQL_DB, charset="utf8mb4",
connect_timeout=5
)
with conn.cursor(pymysql.cursors.DictCursor) as cur:
cur.execute("SELECT config_key, config_value FROM system_configs WHERE config_key LIKE 'feature_%'")
rows = cur.fetchall()
conn.close()
return {r["config_key"]: r["config_value"].lower() == "true" for r in rows}
except Exception as e:
log.warning(f"MySQL 读取失败: {e}")
return None
# ── 路由 ──────────────────────────────────────────────────
@app.route("/")
def index():
"""管理后台首页"""
return render_template("admin.html", features=FEATURES)
@app.route("/health")
def health():
return jsonify({"status": "ok", "service": "cloudsearch-admin"})
@app.route("/api/flags", methods=["GET"])
def api_list_flags():
"""列出所有开关"""
flags_sqlite, updated = read_flags_sqlite()
flags_mysql = read_flags_mysql()
result = {}
for key, meta in FEATURES.items():
result[key] = {
"name": meta["name"],
"group": meta["group"],
"value": flags_sqlite.get(key, meta["default"]),
"mysql_value": flags_mysql.get(key) if flags_mysql else None,
"synced": (flags_mysql is None) or (flags_sqlite.get(key) == flags_mysql.get(key)),
"updated_at": updated.get(key, ""),
}
return jsonify(result)
@app.route("/api/flags/<key>", methods=["PUT"])
def api_set_flag(key):
"""设置单个开关"""
if key not in FEATURES:
return jsonify({"error": f"未知开关: {key}"}), 404
data = request.get_json(force=True)
value = bool(data.get("value", False))
# 写本地 SQLite
write_flag_sqlite(key, value)
# 同步到 MySQL
sync_to_mysql(key, value)
log.info(f"开关切换: {key} = {value}")
return jsonify({"ok": True, "key": key, "value": value})
@app.route("/api/flags/batch", methods=["PUT"])
def api_batch_set_flags():
"""批量设置开关"""
data = request.get_json(force=True)
if not isinstance(data, dict):
return jsonify({"error": "请求体需为 {key: value} 字典"}), 400
results = {}
for key, value in data.items():
if key not in FEATURES:
results[key] = {"error": "未知"}
continue
val = bool(value)
write_flag_sqlite(key, val)
sync_to_mysql(key, val)
results[key] = val
log.info(f"批量切换: {key} = {val}")
return jsonify({"ok": True, "results": results})
if __name__ == "__main__":
from waitress import serve
log.info(f"管理后台启动: http://0.0.0.0:{ADMIN_PORT}")
log.info(f"MySQL 同步: {'已配置' if MYSQL_HOST else '未配置 (仅使用本地 SQLite)'}")
serve(app, host="0.0.0.0", port=ADMIN_PORT, threads=4)

View File

@@ -0,0 +1,149 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CloudSearch 管理后台 v2.2</title>
<style>
:root {
--bg: #111827; --card: #1f2937; --border: #374151;
--text: #f3f4f6; --muted: #9ca3af; --accent: #3b82f6;
--accent-hover: #2563eb; --on: #22c55e; --off: #ef4444;
--danger: #dc2626;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
header { background: var(--card); border-bottom: 1px solid var(--border); padding: 16px 24px; display: flex; align-items: center; justify-content: space-between; }
header h1 { font-size: 20px; font-weight: 700; }
header .meta { font-size: 12px; color: var(--muted); }
.toolbar { display: flex; gap: 8px; }
.btn { padding: 8px 16px; border: 1px solid var(--border); border-radius: 8px; background: var(--card); color: var(--text); cursor: pointer; font-size: 13px; transition: .15s; }
.btn:hover { background: #374151; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: white; }
.btn.primary:hover { background: var(--accent-hover); }
.btn.danger { background: var(--danger); border-color: var(--danger); color: white; }
main { max-width: 720px; margin: 24px auto; padding: 0 16px; }
.group-title { font-size: 13px; text-transform: uppercase; letter-spacing: 1px; color: var(--muted); padding: 12px 0 8px; font-weight: 600; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 16px 20px; margin-bottom: 12px; }
.feature-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.feature-info h3 { font-size: 15px; margin-bottom: 2px; }
.feature-info .code { font-size: 11px; color: var(--muted); font-family: monospace; background: var(--bg); padding: 1px 6px; border-radius: 4px; }
.toggle { width: 52px; height: 28px; border-radius: 28px; border: none; cursor: pointer; position: relative; transition: background .2s; }
.toggle.off { background: #4b5563; }
.toggle.on { background: var(--on); }
.toggle::after { content: ''; position: absolute; top: 3px; left: 3px; width: 22px; height: 22px; border-radius: 50%; background: white; transition: transform .2s; box-shadow: 0 1px 3px rgba(0,0,0,.3); }
.toggle.on::after { transform: translateX(24px); }
.sync-badge { font-size: 11px; padding: 2px 8px; border-radius: 999px; margin-left: 8px; }
.sync-badge.ok { background: rgba(34,197,94,.2); color: var(--on); }
.sync-badge.mismatch { background: rgba(239,68,68,.2); color: var(--off); }
.toast { position: fixed; bottom: 24px; right: 24px; background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 12px 20px; font-size: 14px; opacity: 0; transform: translateY(20px); transition: .25s; z-index: 100; }
.toast.show { opacity: 1; transform: translateY(0); }
.toast.ok { border-color: var(--on); }
.toast.err { border-color: var(--off); }
.loading { opacity: .5; pointer-events: none; }
</style>
</head>
<body>
<header>
<div>
<h1>🔧 CloudSearch 管理后台</h1>
<div class="meta">v2.2 · 功能开关一键管理</div>
</div>
<div class="toolbar">
<button class="btn" onclick="location.reload()">🔄 刷新</button>
<button class="btn primary" onclick="saveAll()">💾 全部保存</button>
</div>
</header>
<main id="main">
<div style="text-align:center;padding:60px;color:var(--muted)">加载中...</div>
</main>
<div class="toast" id="toast"></div>
<script>
const FEATURES = {{ features | tojson }};
function groupBy(list, key) {
return list.reduce((acc, [k,v]) => {
(acc[v[key]] = acc[v[key]] || []).push([k,v]);
return acc;
}, {});
}
async function load() {
const res = await fetch('/api/flags');
const data = await res.json();
const entries = Object.entries(FEATURES).map(([k, meta]) => [k, { ...meta, flag: data[k] }]);
const groups = groupBy(entries, 'group');
let html = '';
const order = ['核心', '增强', '转存'];
for (const g of order) {
if (!groups[g]) continue;
html += `<div class="group-title">${g}功能</div>`;
for (const [key, item] of groups[g]) {
const flag = item.flag || {};
const on = flag.value;
const synced = flag.synced !== false;
const updated = flag.updated_at ? flag.updated_at.replace('T',' ').slice(0,16) : '';
html += `
<div class="card" id="card-${key}">
<div class="feature-row">
<div class="feature-info">
<h3>
${item.name}
${!synced ? '<span class="sync-badge mismatch">⚠ 未同步</span>' : ''}
</h3>
<span class="code">${key}</span>
${updated ? `<span style="font-size:11px;color:var(--muted);margin-left:6px">${updated}</span>` : ''}
</div>
<button class="toggle ${on ? 'on' : 'off'}" onclick="toggle('${key}', ${!on})" title="点击切换"><span style="display:none">开关</span></button>
</div>
</div>`;
}
}
document.getElementById('main').innerHTML = html;
}
async function toggle(key, value) {
const card = document.getElementById('card-' + key);
card.classList.add('loading');
try {
const res = await fetch(`/api/flags/${key}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({value})
});
const data = await res.json();
if (data.ok) {
showToast(`${FEATURES[key].name}${value ? '✅ 已开启' : '❌ 已关闭'}`, 'ok');
load();
} else {
showToast(data.error || '未知错误', 'err');
}
} catch(e) {
showToast('网络错误: ' + e.message, 'err');
}
card.classList.remove('loading');
}
async function saveAll() {
const res = await fetch('/api/flags');
const data = await res.json();
showToast('所有开关状态已保存 ✓', 'ok');
}
function showToast(msg, type) {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = 'toast ' + (type || 'ok') + ' show';
clearTimeout(el._t);
el._t = setTimeout(() => el.classList.remove('show'), 2500);
}
load();
</script>
</body>
</html>

View File

@@ -0,0 +1,8 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY *.py .
EXPOSE 9530 9532
HEALTHCHECK --interval=30s --timeout=5s --retries=3 CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:9530/health')"
CMD ["sh", "-c", "python search_enricher.py & python feishu_bot.py & python subscription_monitor.py & wait"]

View File

@@ -0,0 +1,319 @@
"""
CloudSearch 飞书 Bot v1.0.0
替代 Telegram Bot支持 /search /subscribe 命令 + Webhook 推送
通过飞书开放平台事件订阅接收消息
"""
import os
import json
import time
import hmac
import hashlib
import logging
import sqlite3
from typing import Optional
from flask import Flask, request, jsonify
import requests
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("feishubot")
# ── 飞书配置 ──────────────────────────────────
APP_ID = os.environ.get("FEISHU_APP_ID", "")
APP_SECRET = os.environ.get("FEISHU_APP_SECRET", "")
VERIFY_TOKEN = os.environ.get("FEISHU_VERIFY_TOKEN", "")
WEBHOOK_URL = os.environ.get("FEISHU_WEBHOOK_URL", "")
CLOUDSEARCH_API = os.environ.get("CLOUDSEARCH_API", "http://app:9527")
DB_PATH = os.environ.get("BOT_DB_PATH", "/data/bot.db")
# ── 飞书API ───────────────────────────────────
FEISHU_TOKEN_URL = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
FEISHU_SEND_URL = "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=open_id"
_tenant_token = None
_token_expire = 0
def get_tenant_token() -> str:
"""获取飞书 tenant_access_token缓存2h"""
global _tenant_token, _token_expire
if _tenant_token and time.time() < _token_expire:
return _tenant_token
resp = requests.post(FEISHU_TOKEN_URL, json={
"app_id": APP_ID, "app_secret": APP_SECRET
}, timeout=10)
data = resp.json()
if data.get("code") != 0:
raise Exception(f"获取飞书Token失败: {data}")
_tenant_token = data["tenant_access_token"]
_token_expire = time.time() + data.get("expire", 7200) - 300
logger.info("飞书 tenant_token 已刷新")
return _tenant_token
def send_feishu_msg(open_id: str, content: str, msg_type: str = "text"):
"""发送飞书消息"""
body = {
"receive_id": open_id,
"msg_type": msg_type,
"content": json.dumps({"text": content}) if msg_type == "text" else content
}
resp = requests.post(
FEISHU_SEND_URL,
headers={"Authorization": f"Bearer {get_tenant_token()}"},
json=body, timeout=10
)
data = resp.json()
if data.get("code") != 0:
logger.error(f"发送飞书消息失败: {data}")
return data.get("code") == 0
def send_feishu_card(open_id: str, card: dict):
"""发送飞书卡片消息"""
body = {
"receive_id": open_id,
"msg_type": "interactive",
"content": json.dumps(card)
}
resp = requests.post(
FEISHU_SEND_URL,
headers={"Authorization": f"Bearer {get_tenant_token()}"},
json=body, timeout=10
)
return resp.json().get("code") == 0
def send_webhook(text: str):
"""通过 Webhook 推送通知(用于订阅变更)"""
if not WEBHOOK_URL:
return
try:
requests.post(WEBHOOK_URL, json={
"msg_type": "text",
"content": {"text": text}
}, timeout=10)
except Exception as e:
logger.error(f"Webhook推送失败: {e}")
# ── Bot 核心逻辑 ────────────────────────────────
class FeishuBot:
def __init__(self):
self.db = sqlite3.connect(DB_PATH, check_same_thread=False)
self._init_db()
def _init_db(self):
self.db.execute("""
CREATE TABLE IF NOT EXISTS subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
open_id TEXT NOT NULL,
keyword TEXT NOT NULL,
last_check TEXT,
created_at TEXT DEFAULT (datetime('now','localtime')),
UNIQUE(open_id, keyword)
)
""")
self.db.commit()
logger.info("订阅数据库就绪")
def handle_text(self, open_id: str, text: str):
"""处理文本消息"""
text = text.strip()
if text.startswith("/search"):
keyword = text.replace("/search", "", 1).strip()
return self._cmd_search(open_id, keyword)
elif text.startswith("/subscribe"):
keyword = text.replace("/subscribe", "", 1).strip()
return self._cmd_subscribe(open_id, keyword)
elif text.startswith("/unsub"):
keyword = text.replace("/unsub", "", 1).strip()
return self._cmd_unsub(open_id, keyword)
elif text.startswith("/mysubs"):
return self._cmd_mysubs(open_id)
elif text.startswith("/help") or text.lower() == "help":
return self._cmd_help(open_id)
else:
return self._cmd_search(open_id, text) # 默认搜索
def _cmd_help(self, open_id: str):
help_text = (
"🔍 CloudSearch Bot\n\n"
"命令:\n"
"/search 关键词 — 搜索网盘资源\n"
"直接输入关键词也可以搜索\n"
"/subscribe 关键词 — 订阅关键词\n"
"/unsub 关键词 — 取消订阅\n"
"/mysubs — 查看我的订阅\n"
"/help — 帮助"
)
send_feishu_msg(open_id, help_text)
def _cmd_search(self, open_id: str, keyword: str):
if not keyword:
send_feishu_msg(open_id, "用法: /search 流浪地球2\n或直接输入关键词")
return
try:
resp = requests.post(
f"{CLOUDSEARCH_API}/api/query",
json={"q": keyword}, timeout=15
)
results = []
for line in resp.text.strip().split("\n"):
try:
d = json.loads(line)
if d.get("type") == "result":
results.append(d)
except json.JSONDecodeError:
continue
if not results:
send_feishu_msg(open_id, f"😞 未找到「{keyword}」的相关资源")
return
# 构建飞书卡片
elements = []
for i, r in enumerate(results[:5]):
title = (r.get("title") or r.get("content", ""))[:50]
cloud = r.get("cloud_type", "?").upper()
pwd = r.get("password", "")
pwd_str = f" 🔑{pwd}" if pwd else ""
elements.append({
"tag": "div",
"text": {"tag": "lark_md", "content": f"**{i+1}.** [{cloud}] {title}{pwd_str}"}
})
card = {
"header": {
"title": {"tag": "plain_text", "content": f"🔎 {keyword}{len(results)}个结果"},
"template": "blue"
},
"elements": elements + [{
"tag": "action",
"actions": [{
"tag": "button",
"text": {"tag": "plain_text", "content": "🌐 查看更多"},
"type": "primary",
"url": f"{CLOUDSEARCH_API}/?q={keyword}"
}]
}]
}
send_feishu_card(open_id, card)
except Exception as e:
send_feishu_msg(open_id, f"❌ 搜索失败: {e}")
def _cmd_subscribe(self, open_id: str, keyword: str):
if not keyword:
send_feishu_msg(open_id, "用法: /subscribe 流浪地球")
return
try:
self.db.execute(
"INSERT OR IGNORE INTO subscriptions (open_id, keyword) VALUES (?, ?)",
(open_id, keyword)
)
self.db.commit()
send_feishu_msg(open_id, f"✅ 已订阅「{keyword}」,有新结果会通知你")
except Exception as e:
send_feishu_msg(open_id, f"❌ 订阅失败: {e}")
def _cmd_unsub(self, open_id: str, keyword: str):
if not keyword:
send_feishu_msg(open_id, "用法: /unsub 流浪地球")
return
cur = self.db.execute(
"DELETE FROM subscriptions WHERE open_id=? AND keyword=?",
(open_id, keyword)
)
self.db.commit()
if cur.rowcount > 0:
send_feishu_msg(open_id, f"✅ 已取消订阅「{keyword}")
else:
send_feishu_msg(open_id, f"未找到「{keyword}」的订阅")
def _cmd_mysubs(self, open_id: str):
rows = self.db.execute(
"SELECT keyword, created_at FROM subscriptions WHERE open_id=? ORDER BY created_at DESC",
(open_id,)
).fetchall()
if not rows:
send_feishu_msg(open_id, "你还没有订阅任何关键词")
return
text = "📋 我的订阅:\n"
for kw, dt in rows:
text += f"{kw} ({dt[:10]})\n"
send_feishu_msg(open_id, text)
def check_subscriptions(self):
"""检查所有订阅,有新结果时推送通知"""
subs = self.db.execute("SELECT DISTINCT keyword FROM subscriptions").fetchall()
for (kw,) in subs:
try:
resp = requests.post(
f"{CLOUDSEARCH_API}/api/query",
json={"q": kw}, timeout=10
)
count = sum(1 for line in resp.text.split("\n")
if '"type":"result"' in line)
if count > 0:
# 通知所有订阅此关键词的用户
users = self.db.execute(
"SELECT open_id FROM subscriptions WHERE keyword=?",
(kw,)
).fetchall()
for (uid,) in users:
send_feishu_msg(uid, f"🔔「{kw}」有新资源({count}个)\n/search {kw}")
# Webhook 也推送
send_webhook(f"🔔 关键词「{kw}」发现 {count} 个新资源")
except Exception as e:
logger.error(f"检查订阅[{kw}]失败: {e}")
# ── Flask Web 服务 ─────────────────────────────
bot = FeishuBot()
app = Flask(__name__)
@app.route("/health")
def health():
return jsonify({"status": "ok", "bot": "feishu"})
@app.route("/feishu/event", methods=["POST"])
def feishu_event():
"""飞书事件订阅回调"""
body = request.get_json()
logger.info(f"飞书事件: {json.dumps(body, ensure_ascii=False)[:300]}")
# Token 验证首次配置URL时
if body.get("type") == "url_verification":
token = body.get("token", "")
if token == VERIFY_TOKEN:
return jsonify({"challenge": body.get("challenge", "")})
return jsonify({"error": "invalid token"}), 403
# 事件回调验证
if "header" in body:
# 收到消息事件
event = body.get("event", {})
msg_type = event.get("message", {}).get("message_type", "")
if msg_type == "text":
content = event["message"].get("content", "{}")
try:
text = json.loads(content).get("text", "")
except json.JSONDecodeError:
text = content
open_id = event.get("sender", {}).get("sender_id", {}).get("open_id", "")
if text and open_id:
bot.handle_text(open_id, text)
return jsonify({"code": 0})
@app.route("/feishu/check", methods=["POST"])
def trigger_check():
"""手动触发订阅检查"""
bot.check_subscriptions()
return jsonify({"ok": True})
# ── 启动入口 ───────────────────────────────────
def main():
if not APP_ID:
logger.warning("FEISHU_APP_ID 未设置Bot 无法接收消息(仅 Webhook 可用)")
logger.info("飞书 Bot 启动端口9531")
app.run(host="0.0.0.0", port=9532)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,319 @@
"""
CloudSearch 飞书 Bot v1.0.0
替代 Telegram Bot支持 /search /subscribe 命令 + Webhook 推送
通过飞书开放平台事件订阅接收消息
"""
import os
import json
import time
import hmac
import hashlib
import logging
import sqlite3
from typing import Optional
from flask import Flask, request, jsonify
import requests
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("feishubot")
# ── 飞书配置 ──────────────────────────────────
APP_ID = os.environ.get("FEISHU_APP_ID", "")
APP_SECRET = os.environ.get("FEISHU_APP_SECRET", "")
VERIFY_TOKEN = os.environ.get("FEISHU_VERIFY_TOKEN", "")
WEBHOOK_URL = os.environ.get("FEISHU_WEBHOOK_URL", "")
CLOUDSEARCH_API = os.environ.get("CLOUDSEARCH_API", "http://app:9527")
DB_PATH = os.environ.get("BOT_DB_PATH", "/data/bot.db")
# ── 飞书API ───────────────────────────────────
FEISHU_TOKEN_URL = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
FEISHU_SEND_URL = "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=open_id"
_tenant_token = None
_token_expire = 0
def get_tenant_token() -> str:
"""获取飞书 tenant_access_token缓存2h"""
global _tenant_token, _token_expire
if _tenant_token and time.time() < _token_expire:
return _tenant_token
resp = requests.post(FEISHU_TOKEN_URL, json={
"app_id": APP_ID, "app_secret": APP_SECRET
}, timeout=10)
data = resp.json()
if data.get("code") != 0:
raise Exception(f"获取飞书Token失败: {data}")
_tenant_token = data["tenant_access_token"]
_token_expire = time.time() + data.get("expire", 7200) - 300
logger.info("飞书 tenant_token 已刷新")
return _tenant_token
def send_feishu_msg(open_id: str, content: str, msg_type: str = "text"):
"""发送飞书消息"""
body = {
"receive_id": open_id,
"msg_type": msg_type,
"content": json.dumps({"text": content}) if msg_type == "text" else content
}
resp = requests.post(
FEISHU_SEND_URL,
headers={"Authorization": f"Bearer {get_tenant_token()}"},
json=body, timeout=10
)
data = resp.json()
if data.get("code") != 0:
logger.error(f"发送飞书消息失败: {data}")
return data.get("code") == 0
def send_feishu_card(open_id: str, card: dict):
"""发送飞书卡片消息"""
body = {
"receive_id": open_id,
"msg_type": "interactive",
"content": json.dumps(card)
}
resp = requests.post(
FEISHU_SEND_URL,
headers={"Authorization": f"Bearer {get_tenant_token()}"},
json=body, timeout=10
)
return resp.json().get("code") == 0
def send_webhook(text: str):
"""通过 Webhook 推送通知(用于订阅变更)"""
if not WEBHOOK_URL:
return
try:
requests.post(WEBHOOK_URL, json={
"msg_type": "text",
"content": {"text": text}
}, timeout=10)
except Exception as e:
logger.error(f"Webhook推送失败: {e}")
# ── Bot 核心逻辑 ────────────────────────────────
class FeishuBot:
def __init__(self):
self.db = sqlite3.connect(DB_PATH, check_same_thread=False)
self._init_db()
def _init_db(self):
self.db.execute("""
CREATE TABLE IF NOT EXISTS subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
open_id TEXT NOT NULL,
keyword TEXT NOT NULL,
last_check TEXT,
created_at TEXT DEFAULT (datetime('now','localtime')),
UNIQUE(open_id, keyword)
)
""")
self.db.commit()
logger.info("订阅数据库就绪")
def handle_text(self, open_id: str, text: str):
"""处理文本消息"""
text = text.strip()
if text.startswith("/search"):
keyword = text.replace("/search", "", 1).strip()
return self._cmd_search(open_id, keyword)
elif text.startswith("/subscribe"):
keyword = text.replace("/subscribe", "", 1).strip()
return self._cmd_subscribe(open_id, keyword)
elif text.startswith("/unsub"):
keyword = text.replace("/unsub", "", 1).strip()
return self._cmd_unsub(open_id, keyword)
elif text.startswith("/mysubs"):
return self._cmd_mysubs(open_id)
elif text.startswith("/help") or text.lower() == "help":
return self._cmd_help(open_id)
else:
return self._cmd_search(open_id, text) # 默认搜索
def _cmd_help(self, open_id: str):
help_text = (
"🔍 CloudSearch Bot\n\n"
"命令:\n"
"/search 关键词 — 搜索网盘资源\n"
"直接输入关键词也可以搜索\n"
"/subscribe 关键词 — 订阅关键词\n"
"/unsub 关键词 — 取消订阅\n"
"/mysubs — 查看我的订阅\n"
"/help — 帮助"
)
send_feishu_msg(open_id, help_text)
def _cmd_search(self, open_id: str, keyword: str):
if not keyword:
send_feishu_msg(open_id, "用法: /search 流浪地球2\n或直接输入关键词")
return
try:
resp = requests.post(
f"{CLOUDSEARCH_API}/api/query",
json={"q": keyword}, timeout=15
)
results = []
for line in resp.text.strip().split("\n"):
try:
d = json.loads(line)
if d.get("type") == "result":
results.append(d)
except json.JSONDecodeError:
continue
if not results:
send_feishu_msg(open_id, f"😞 未找到「{keyword}」的相关资源")
return
# 构建飞书卡片
elements = []
for i, r in enumerate(results[:5]):
title = (r.get("title") or r.get("content", ""))[:50]
cloud = r.get("cloud_type", "?").upper()
pwd = r.get("password", "")
pwd_str = f" 🔑{pwd}" if pwd else ""
elements.append({
"tag": "div",
"text": {"tag": "lark_md", "content": f"**{i+1}.** [{cloud}] {title}{pwd_str}"}
})
card = {
"header": {
"title": {"tag": "plain_text", "content": f"🔎 {keyword}{len(results)}个结果"},
"template": "blue"
},
"elements": elements + [{
"tag": "action",
"actions": [{
"tag": "button",
"text": {"tag": "plain_text", "content": "🌐 查看更多"},
"type": "primary",
"url": f"{CLOUDSEARCH_API}/?q={keyword}"
}]
}]
}
send_feishu_card(open_id, card)
except Exception as e:
send_feishu_msg(open_id, f"❌ 搜索失败: {e}")
def _cmd_subscribe(self, open_id: str, keyword: str):
if not keyword:
send_feishu_msg(open_id, "用法: /subscribe 流浪地球")
return
try:
self.db.execute(
"INSERT OR IGNORE INTO subscriptions (open_id, keyword) VALUES (?, ?)",
(open_id, keyword)
)
self.db.commit()
send_feishu_msg(open_id, f"✅ 已订阅「{keyword}」,有新结果会通知你")
except Exception as e:
send_feishu_msg(open_id, f"❌ 订阅失败: {e}")
def _cmd_unsub(self, open_id: str, keyword: str):
if not keyword:
send_feishu_msg(open_id, "用法: /unsub 流浪地球")
return
cur = self.db.execute(
"DELETE FROM subscriptions WHERE open_id=? AND keyword=?",
(open_id, keyword)
)
self.db.commit()
if cur.rowcount > 0:
send_feishu_msg(open_id, f"✅ 已取消订阅「{keyword}")
else:
send_feishu_msg(open_id, f"未找到「{keyword}」的订阅")
def _cmd_mysubs(self, open_id: str):
rows = self.db.execute(
"SELECT keyword, created_at FROM subscriptions WHERE open_id=? ORDER BY created_at DESC",
(open_id,)
).fetchall()
if not rows:
send_feishu_msg(open_id, "你还没有订阅任何关键词")
return
text = "📋 我的订阅:\n"
for kw, dt in rows:
text += f"{kw} ({dt[:10]})\n"
send_feishu_msg(open_id, text)
def check_subscriptions(self):
"""检查所有订阅,有新结果时推送通知"""
subs = self.db.execute("SELECT DISTINCT keyword FROM subscriptions").fetchall()
for (kw,) in subs:
try:
resp = requests.post(
f"{CLOUDSEARCH_API}/api/query",
json={"q": kw}, timeout=10
)
count = sum(1 for line in resp.text.split("\n")
if '"type":"result"' in line)
if count > 0:
# 通知所有订阅此关键词的用户
users = self.db.execute(
"SELECT open_id FROM subscriptions WHERE keyword=?",
(kw,)
).fetchall()
for (uid,) in users:
send_feishu_msg(uid, f"🔔「{kw}」有新资源({count}个)\n/search {kw}")
# Webhook 也推送
send_webhook(f"🔔 关键词「{kw}」发现 {count} 个新资源")
except Exception as e:
logger.error(f"检查订阅[{kw}]失败: {e}")
# ── Flask Web 服务 ─────────────────────────────
bot = FeishuBot()
app = Flask(__name__)
@app.route("/health")
def health():
return jsonify({"status": "ok", "bot": "feishu"})
@app.route("/feishu/event", methods=["POST"])
def feishu_event():
"""飞书事件订阅回调"""
body = request.get_json()
logger.info(f"飞书事件: {json.dumps(body, ensure_ascii=False)[:300]}")
# Token 验证首次配置URL时
if body.get("type") == "url_verification":
token = body.get("token", "")
if token == VERIFY_TOKEN:
return jsonify({"challenge": body.get("challenge", "")})
return jsonify({"error": "invalid token"}), 403
# 事件回调验证
if "header" in body:
# 收到消息事件
event = body.get("event", {})
msg_type = event.get("message", {}).get("message_type", "")
if msg_type == "text":
content = event["message"].get("content", "{}")
try:
text = json.loads(content).get("text", "")
except json.JSONDecodeError:
text = content
open_id = event.get("sender", {}).get("sender_id", {}).get("open_id", "")
if text and open_id:
bot.handle_text(open_id, text)
return jsonify({"code": 0})
@app.route("/feishu/check", methods=["POST"])
def trigger_check():
"""手动触发订阅检查"""
bot.check_subscriptions()
return jsonify({"ok": True})
# ── 启动入口 ───────────────────────────────────
def main():
if not APP_ID:
logger.warning("FEISHU_APP_ID 未设置Bot 无法接收消息(仅 Webhook 可用)")
logger.info("飞书 Bot 启动端口9531")
app.run(host="0.0.0.0", port=9531)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,3 @@
flask>=3.0
requests>=2.28
python-telegram-bot>=20.0

View File

@@ -0,0 +1,132 @@
"""
CloudSearch Search Enricher v1.0.0
搜索结果增强TMDB匹配 + 过期检测 + 内容去重
"""
import time
import logging
from typing import List, Dict, Any, Optional
from tmdb_enricher import TMDBEnricher
logger = logging.getLogger("enricher")
class SearchEnricher:
"""搜索结果增强器"""
def __init__(self, tmdb_api_key: str = "", cache_ttl: int = 86400):
self.tmdb = TMDBEnricher(tmdb_api_key, cache_ttl=cache_ttl) if tmdb_api_key else None
def enrich_results(self, results: List[Dict], keyword: str = "") -> List[Dict]:
"""批量增强搜索结果"""
if not results:
return results
enriched = []
titles_to_lookup = []
# 收集需要查 TMDB 的标题
for r in results:
title = r.get("title", "")
if title and self.tmdb:
titles_to_lookup.append(title)
# 批量查询 TMDB
tmdb_results = {}
if titles_to_lookup and self.tmdb:
tmdb_results = self.tmdb.enrich_batch(titles_to_lookup[:20], max_concurrent=5)
# 应用增强
for r in results:
title = r.get("title", "")
media = tmdb_results.get(title)
enriched_item = dict(r)
if media:
enriched_item.update({
"tmdb_id": media.tmdb_id,
"tmdb_url": media.tmdb_url,
"poster": media.poster_url,
"backdrop": media.backdrop_url,
"rating": media.rating,
"rating_count": media.rating_count,
"year": media.year,
"genres": media.genres,
"description": media.description,
"media_type": media.media_type,
"directors": media.directors,
"actors": media.actors[:5],
"enriched": True,
})
# 自动生成更好的标题
if media.year and media.rating:
enriched_item["display_title"] = (
f"{title} ({media.year}) ⭐{media.rating}"
)
enriched.append(enriched_item)
return enriched
def enrich_single(self, title: str, keyword: str = "") -> Optional[Dict]:
"""增强单个标题"""
if not self.tmdb:
return None
media = self.tmdb.enrich(title)
if not media:
return None
return {
"title": title,
"tmdb_id": media.tmdb_id,
"poster": media.poster_url,
"rating": media.rating,
"year": media.year,
"genres": media.genres,
"description": media.description,
"media_type": media.media_type,
}
# Flask API wrapper
def create_enricher_api(tmdb_key: str = ""):
from flask import Flask, request, jsonify
app = Flask(__name__)
enricher = SearchEnricher(tmdb_key)
@app.route("/health", methods=["GET"])
def health():
return jsonify({"status": "ok", "version": "1.0.0"})
@app.route("/enrich", methods=["POST"])
def enrich():
data = request.get_json() or {}
results = data.get("results", [])
keyword = data.get("keyword", "")
if not results:
return jsonify({"error": "results required"}), 400
enriched = enricher.enrich_results(results, keyword)
return jsonify({"results": enriched, "count": len(enriched)})
@app.route("/lookup", methods=["POST"])
def lookup():
data = request.get_json() or {}
title = data.get("title", "")
if not title:
return jsonify({"error": "title required"}), 400
info = enricher.enrich_single(title)
return jsonify(info or {})
return app
if __name__ == "__main__":
import os
api_key = os.getenv("TMDB_API_KEY", "")
port = int(os.getenv("PORT", "9530"))
app = create_enricher_api(api_key)
logger.info(f"Enricher API on port {port}")
app.run(host="0.0.0.0", port=port)

View File

@@ -0,0 +1,204 @@
"""
CloudSearch Subscription Monitor v1.0.0
关键词订阅 + 新资源检测 + 多渠道通知
"""
import os
import json
import time
import sqlite3
import logging
import requests
from typing import List, Dict, Optional
from dataclasses import dataclass
logger = logging.getLogger("subscription")
@dataclass
class Notification:
chat_id: int
keyword: str
new_count: int
results: List[dict]
channel: str = "telegram" # telegram / feishu / dingtalk
class SubscriptionMonitor:
"""订阅监控:定时搜索关键词,发现新资源后推送通知"""
def __init__(self, api_base: str, db_path: str = "/data/subscriptions.db",
tg_bot_token: str = None):
self.api_base = api_base.rstrip("/")
self.tg_token = tg_bot_token
self.db = sqlite3.connect(db_path, check_same_thread=False)
self._init_db()
def _init_db(self):
self.db.executescript("""
CREATE TABLE IF NOT EXISTS subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_id INTEGER NOT NULL,
keyword TEXT NOT NULL,
last_result_hash TEXT,
last_check TEXT,
created_at TEXT DEFAULT (datetime('now','localtime')),
UNIQUE(chat_id, keyword)
);
CREATE TABLE IF NOT EXISTS sent_notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subscription_id INTEGER,
result_hash TEXT,
sent_at TEXT DEFAULT (datetime('now','localtime')),
FOREIGN KEY(subscription_id) REFERENCES subscriptions(id)
);
""")
self.db.commit()
def check_all(self, batch_size: int = 10) -> List[Notification]:
"""检查所有订阅,返回需要通知的列表"""
subs = self.db.execute(
"SELECT id, chat_id, keyword, last_result_hash FROM subscriptions ORDER BY last_check ASC LIMIT ?",
(batch_size,)
).fetchall()
notifications = []
for sub_id, chat_id, keyword, last_hash in subs:
try:
result = self._search(keyword)
new_hash = self._hash_results(result)
if new_hash and new_hash != last_hash:
new_results = self._filter_new(sub_id, result, last_hash)
if new_results:
notifications.append(Notification(
chat_id=chat_id,
keyword=keyword,
new_count=len(new_results),
results=new_results[:5],
))
# 更新状态
self.db.execute(
"UPDATE subscriptions SET last_result_hash=?, last_check=datetime('now','localtime') WHERE id=?",
(new_hash, sub_id)
)
except Exception as e:
logger.error(f"Check failed: {keyword} - {e}")
self.db.commit()
return notifications
def _search(self, keyword: str) -> list:
"""搜索关键词"""
try:
resp = requests.post(
f"{self.api_base}/api/query",
json={"q": keyword},
timeout=20
)
results = []
for line in resp.text.strip().split("\n"):
try:
d = json.loads(line)
if d.get("type") == "result":
results.append({
"title": d.get("title", ""),
"url": d.get("share_url", ""),
"cloud": d.get("cloud_type", ""),
"source": d.get("source", ""),
})
except json.JSONDecodeError:
continue
return results
except Exception as e:
logger.error(f"Search error: {e}")
return []
def _hash_results(self, results: list) -> str:
"""计算结果哈希"""
import hashlib
key = "|".join(
r.get("url", "")[:50] for r in sorted(
results, key=lambda x: x.get("url", "")
)
)
return hashlib.md5(key.encode()).hexdigest()
def _filter_new(self, sub_id: int, results: list, last_hash: str) -> list:
"""过滤出新结果"""
new = []
for r in results:
rhash = str(hash(r.get("url", "")))
existing = self.db.execute(
"SELECT id FROM sent_notifications WHERE subscription_id=? AND result_hash=?",
(sub_id, rhash)
).fetchone()
if not existing:
new.append(r)
self.db.execute(
"INSERT OR IGNORE INTO sent_notifications (subscription_id, result_hash) VALUES (?,?)",
(sub_id, rhash)
)
return new
def notify_telegram(self, notif: Notification):
"""通过 Telegram 发送通知"""
if not self.tg_token:
return
text = f"🔔 *{notif.keyword}* 有新资源!({notif.new_count}个)\n\n"
for i, r in enumerate(notif.results[:5]):
title = r.get("title", "")[:40]
url = r.get("url", "")
cloud = r.get("cloud", "?").upper()
text += f"{i+1}. [{cloud}] [{title}]({url})\n"
try:
requests.post(
f"https://api.telegram.org/bot{self.tg_token}/sendMessage",
json={
"chat_id": notif.chat_id,
"text": text,
"parse_mode": "Markdown",
"disable_web_page_preview": True,
},
timeout=10
)
except Exception as e:
logger.error(f"TG notify failed: {e}")
def notify_feishu(self, notif: Notification, webhook_url: str):
"""通过飞书发送通知"""
text = f"🔔 {notif.keyword} 有新资源!({notif.new_count}个)\n"
for r in notif.results[:5]:
text += f"• [{r.get('cloud','?').upper()}] {r.get('title','')[:40]} {r.get('url','')}\n"
try:
requests.post(webhook_url, json={
"msg_type": "text",
"content": {"text": text}
}, timeout=10)
except Exception as e:
logger.error(f"Feishu notify failed: {e}")
def run_loop(self, interval_minutes: int = 15):
"""循环运行"""
logger.info(f"Subscription monitor started (interval={interval_minutes}min)")
while True:
try:
notifs = self.check_all()
for n in notifs:
self.notify_telegram(n)
if notifs:
logger.info(f"Sent {len(notifs)} notifications")
except Exception as e:
logger.error(f"Monitor error: {e}")
time.sleep(interval_minutes * 60)
if __name__ == "__main__":
api = os.getenv("CLOUDSEARCH_API", "http://127.0.0.1:9527")
token = os.getenv("TG_BOT_TOKEN", "")
interval = int(os.getenv("CHECK_INTERVAL", "15"))
monitor = SubscriptionMonitor(api, tg_bot_token=token)
monitor.run_loop(interval)

View File

@@ -0,0 +1,183 @@
"""
CloudSearch Telegram Bot v1.0.0
提供: /search /subscribe /hot /help
"""
import os
import json
import time
import logging
import sqlite3
from typing import Optional
import requests
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import (
Application, CommandHandler, MessageHandler,
CallbackQueryHandler, ContextTypes, filters
)
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("tgbot")
class CloudSearchBot:
def __init__(self, token: str, api_base: str, db_path: str = "/data/bot.db"):
self.token = token
self.api_base = api_base.rstrip("/")
self.db = sqlite3.connect(db_path, check_same_thread=False)
self._init_db()
def _init_db(self):
self.db.execute("""
CREATE TABLE IF NOT EXISTS subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_id INTEGER NOT NULL,
keyword TEXT NOT NULL,
last_check TEXT,
created_at TEXT DEFAULT (datetime('now', 'localtime')),
UNIQUE(chat_id, keyword)
)
""")
self.db.commit()
async def start(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
await update.message.reply_text(
"🔍 *CloudSearch Bot* v1.0\n\n"
"命令:\n"
"/search 关键词 — 搜索网盘资源\n"
"/hot — 热门搜索\n"
"/subscribe 关键词 — 订阅关键词\n"
"/unsub 关键词 — 取消订阅\n"
"/mysubs — 我的订阅\n"
"/help — 帮助",
parse_mode="Markdown"
)
async def search(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
keyword = " ".join(context.args) if context.args else ""
if not keyword:
await update.message.reply_text("用法: /search 流浪地球2")
return
msg = await update.message.reply_text(f"🔎 搜索中: *{keyword}*...", parse_mode="Markdown")
try:
resp = requests.post(
f"{self.api_base}/api/query",
json={"q": keyword},
timeout=15
)
# Parse NDJSON response
results = []
content_info = None
for line in resp.text.strip().split("\n"):
try:
data = json.loads(line)
if data.get("type") == "result":
results.append(data)
elif data.get("type") == "stats":
content_info = data.get("content_info")
except json.JSONDecodeError:
continue
if not results:
await msg.edit_text(f"😞 未找到「{keyword}」的相关资源")
return
# Format top 5 results
text = f"🔎 *{keyword}* — {len(results)} 个结果\n\n"
for i, r in enumerate(results[:5]):
title = (r.get("title") or r.get("content", ""))[:40]
cloud = r.get("cloud_type", "?").upper()
url = r.get("share_url", "")
pwd = r.get("password", "")
pwd_str = f" 🔑`{pwd}`" if pwd else ""
text += f"{i+1}. [{cloud}] [{title}]({url}){pwd_str}\n"
keyboard = [[
InlineKeyboardButton("🌐 查看更多", url=f"{self.api_base}/?q={keyword}")
]]
await msg.edit_text(
text,
parse_mode="Markdown",
disable_web_page_preview=True,
reply_markup=InlineKeyboardMarkup(keyboard)
)
except Exception as e:
await msg.edit_text(f"❌ 搜索失败: {e}")
async def subscribe(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
keyword = " ".join(context.args) if context.args else ""
if not keyword:
await update.message.reply_text("用法: /subscribe 流浪地球")
return
try:
self.db.execute(
"INSERT OR IGNORE INTO subscriptions (chat_id, keyword) VALUES (?, ?)",
(update.effective_chat.id, keyword)
)
self.db.commit()
await update.message.reply_text(f"✅ 已订阅: *{keyword}*", parse_mode="Markdown")
except Exception as e:
await update.message.reply_text(f"❌ 订阅失败: {e}")
async def unsub(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
keyword = " ".join(context.args) if context.args else ""
self.db.execute(
"DELETE FROM subscriptions WHERE chat_id=? AND keyword=?",
(update.effective_chat.id, keyword)
)
self.db.commit()
await update.message.reply_text(f"🗑 已取消: *{keyword}*", parse_mode="Markdown")
async def mysubs(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
subs = self.db.execute(
"SELECT keyword, created_at FROM subscriptions WHERE chat_id=? ORDER BY created_at DESC LIMIT 20",
(update.effective_chat.id,)
).fetchall()
if not subs:
await update.message.reply_text("📭 暂无订阅")
return
text = "📋 *我的订阅*\n" + "\n".join(f"{s[0]}" for s in subs)
await update.message.reply_text(text, parse_mode="Markdown")
async def hot(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
try:
resp = requests.get(f"{self.api_base}/api/rankings/hot?limit=10", timeout=10)
data = resp.json()
keywords = data if isinstance(data, list) else data.get("keywords", [])
text = "🔥 *热门搜索*\n" + "\n".join(
f"{i+1}. {kw.get('keyword', str(kw))}" for i, kw in enumerate(keywords[:10])
)
except:
text = "🔥 获取热门失败,请稍后重试"
await update.message.reply_text(text, parse_mode="Markdown")
async def help_cmd(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
await self.start(update, context)
def run(self):
app = Application.builder().token(self.token).build()
app.add_handler(CommandHandler("start", self.start))
app.add_handler(CommandHandler("search", self.search))
app.add_handler(CommandHandler("s", self.search))
app.add_handler(CommandHandler("hot", self.hot))
app.add_handler(CommandHandler("subscribe", self.subscribe))
app.add_handler(CommandHandler("sub", self.subscribe))
app.add_handler(CommandHandler("unsub", self.unsub))
app.add_handler(CommandHandler("mysubs", self.mysubs))
app.add_handler(CommandHandler("help", self.help_cmd))
logger.info("Bot starting...")
app.run_polling()
if __name__ == "__main__":
token = os.getenv("TG_BOT_TOKEN", "")
api = os.getenv("CLOUDSEARCH_API", "http://127.0.0.1:9527")
bot = CloudSearchBot(token, api)
bot.run()

View File

@@ -0,0 +1,179 @@
"""
CloudSearch TMDB Enricher v1.0.0
自动匹配影视元数据:海报、评分、简介、年份、类型
"""
import time
import logging
from typing import Optional, Dict, Any, List
from dataclasses import dataclass, field
import requests
logger = logging.getLogger(__name__)
TMDB_API_BASE = "https://api.themoviedb.org/3"
TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p/w500"
@dataclass
class MediaInfo:
"""影视元数据"""
title: str = ""
original_title: str = ""
year: str = ""
poster_url: str = ""
backdrop_url: str = ""
rating: str = ""
rating_count: int = 0
description: str = ""
genres: List[str] = field(default_factory=list)
media_type: str = "" # movie / tv
tmdb_id: int = 0
directors: List[str] = field(default_factory=list)
actors: List[str] = field(default_factory=list)
region: str = ""
duration: str = ""
seasons: int = 0
episodes: int = 0
source: str = "tmdb"
tmdb_url: str = ""
class TMDBEnricher:
"""TMDB 影视信息增强器"""
# 常见网盘文件名模式 → 影视标题提取
TITLE_PATTERNS = [
# [4K] 流浪地球2 (2023)
(r'\[.*?\]\s*(.+?)\s*[\(](\d{4})[\)]', 2),
# 流浪地球2.2023.4K
(r'(.+?)\.(\d{4})\.(?:4K|1080[Pp]|2160[Pp]|HD)', 2),
# 流浪地球2 2023
(r'(.+?)\s+(\d{4})\s', 2),
# S01E01 格式
(r'(.+?)[\.\s][Ss](\d{2})[Ee](\d{2})', 1),
]
def __init__(self, api_key: str, language: str = "zh-CN",
cache_ttl: int = 86400):
self.api_key = api_key
self.language = language
self.cache_ttl = cache_ttl
self._cache: Dict[str, tuple] = {} # key → (data, timestamp)
def enrich(self, title: str, media_type: str = None) -> Optional[MediaInfo]:
"""根据标题查询 TMDB 元数据"""
clean_title, year = self._extract_title_year(title)
cache_key = f"{clean_title}:{year}:{media_type}"
if cache_key in self._cache:
data, ts = self._cache[cache_key]
if time.time() - ts < self.cache_ttl:
return data
# 智能判断类型
if not media_type:
media_type = self._guess_type(clean_title)
info = self._search(clean_title, year, media_type)
if info:
self._cache[cache_key] = (info, time.time())
return info
def enrich_batch(self, titles: List[str], max_concurrent: int = 5) -> Dict[str, MediaInfo]:
"""批量查询"""
from concurrent.futures import ThreadPoolExecutor, as_completed
results = {}
with ThreadPoolExecutor(max_workers=max_concurrent) as ex:
futures = {ex.submit(self.enrich, t): t for t in titles}
for f in as_completed(futures):
try:
results[futures[f]] = f.result()
except Exception as e:
logger.warning(f"TMDB enrich failed: {futures[f]} - {e}")
return results
def _extract_title_year(self, title: str) -> tuple:
"""从文件名提取标题和年份"""
import re
for pattern, year_group in self.TITLE_PATTERNS:
m = re.search(pattern, title, re.IGNORECASE)
if m:
name = m.group(1).strip()
year = m.group(year_group) if year_group <= len(m.groups()) else ""
# 去掉常见的后缀
name = re.sub(r'\s*[\[(].*?(?:完结|全\d+集|更新).*?[\])]', '', name)
return name.strip(), year
return title.strip(), ""
def _guess_type(self, title: str) -> str:
"""根据标题特征判断电影/电视剧"""
import re
tv_patterns = [
r'[Ss]\d{2}[Ee]\d{2}', r'第[一二三四五六七八九十\d]+季',
r'[Ss]eason\s*\d+', r'\d+集', r'更新至\d+',
]
for p in tv_patterns:
if re.search(p, title):
return "tv"
return "movie"
def _search(self, title: str, year: str = "", media_type: str = "movie") -> Optional[MediaInfo]:
"""搜索 TMDB"""
try:
# 搜索
search_type = "tv" if media_type == "tv" else "movie"
params = {
"api_key": self.api_key,
"query": title,
"language": self.language,
"page": 1,
}
if year:
params["year" if search_type == "movie" else "first_air_date_year"] = year
resp = requests.get(
f"{TMDB_API_BASE}/search/{search_type}",
params=params, timeout=10
)
data = resp.json()
results = data.get("results", [])
if not results and search_type == "movie":
# 电视剧也试一下
resp2 = requests.get(
f"{TMDB_API_BASE}/search/tv",
params=params, timeout=10
)
data2 = resp2.json()
results = data2.get("results", [])
if not results:
return None
item = results[0]
return self._parse_result(item, media_type)
except Exception as e:
logger.error(f"TMDB search error: {title} - {e}")
return None
def _parse_result(self, item: dict, media_type: str) -> MediaInfo:
"""解析 TMDB 返回"""
mid = item.get("id", 0)
is_tv = media_type == "tv" or item.get("media_type") == "tv"
return MediaInfo(
title=item.get("title") or item.get("name", ""),
original_title=item.get("original_title") or item.get("original_name", ""),
year=str(item.get("release_date", item.get("first_air_date", ""))[:4]),
poster_url=f"{TMDB_IMAGE_BASE}{item['poster_path']}" if item.get("poster_path") else "",
backdrop_url=f"{TMDB_IMAGE_BASE}{item['backdrop_path']}" if item.get("backdrop_path") else "",
rating=str(round(item.get("vote_average", 0), 1)),
rating_count=item.get("vote_count", 0),
description=(item.get("overview") or "")[:500],
genres=[g.get("name", "") for g in item.get("genre_ids", [])],
media_type="tv" if is_tv else "movie",
tmdb_id=mid,
tmdb_url=f"https://www.themoviedb.org/{'tv' if is_tv else 'movie'}/{mid}",
)

View File

@@ -0,0 +1,18 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV PORT=9528
ENV TRANSFER_CONFIG_PATH=/data/transfer_config.json
EXPOSE 9528
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD ["python", "server.py"]
CMD ["python", "server.py"]

View File

@@ -0,0 +1,32 @@
"""CloudSearch Transfer v1.0.0 — 多网盘转存模块化服务
支持平台: quark, baidu, aliyun, uc, xunlei (+ 115/123/cloud189 扩展)
架构:
cloudsearch_transfer/
├── adapter/ # 网盘适配器(每平台独立子包)
│ ├── base.py # 抽象基类
│ ├── factory.py # 工厂+缓存
│ ├── quark/ # 夸克网盘 (credential/transfer/cleanup)
│ ├── baidu/ # 百度网盘
│ ├── aliyun/ # 阿里云盘
│ ├── uc/ # UC网盘
│ └── xunlei/ # 迅雷网盘
├── credential/ # 统一凭证管理
│ └── manager.py
├── orchestration/ # 转存编排
│ └── transfer.py
├── config.py # 配置管理
├── errors.py # 错误码
└── server.py # HTTP API 服务
使用:
from cloudsearch_transfer import TransferOrchestrator, ConfigManager
cm = ConfigManager()
orch = TransferOrchestrator(cm)
result = orch.transfer("https://pan.quark.cn/s/xxxx")
print(result.share_url)
"""
__version__ = "1.0.0"

View File

@@ -0,0 +1 @@
"""CloudSearch Transfer — 适配器包"""

View File

@@ -0,0 +1,297 @@
"""
阿里云盘适配器 v1.0.0
AliyunAdapter — 继承 BaseCloudDriveAdapter实现阿里云盘全部转存能力。
组件:
- AliyunCredentialManager: refresh_token 刷新 + 缓存
- AliyunTransfer: 4 步批量转存
- AliyunCleanup: 回收站清理
URL 匹配: aliyundrive.com/s/<share_id>
"""
import re
import logging
from typing import List, Dict, Tuple, Optional
from ..base import BaseCloudDriveAdapter, FileInfo, match_url
from ..config import PlatformConfig, TransferConfig
from ..errors import TransferError, TransferErrorCode
from .credential import AliyunCredentialManager
from .transfer import AliyunTransfer
from .cleanup import AliyunCleanup
logger = logging.getLogger(__name__)
class AliyunAdapter(BaseCloudDriveAdapter):
"""阿里云盘适配器"""
PLATFORM_NAME = "阿里云盘"
PLATFORM_KEY = "aliyun"
URL_PATTERNS = [
r'aliyundrive\.com/s/([a-zA-Z0-9]+)',
r'alipan\.com/s/([a-zA-Z0-9]+)',
]
DEFAULT_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/135.0.0.0 Safari/537.36"
),
"Accept": "application/json, text/plain, */*",
"Content-Type": "application/json",
"Referer": "https://aliyundrive.com",
}
def __init__(self, config: PlatformConfig, transfer_config: TransferConfig):
super().__init__(config, transfer_config)
# 创建凭证管理器AliyunCredentialManager
refresh_token = config.refresh_token or config.cookie or ""
self._credential = AliyunCredentialManager(refresh_token=refresh_token)
# 初始化 drive_id
self._drive_id = ""
# 创建子模块
self._transfer: Optional[AliyunTransfer] = None
self._cleanup: Optional[AliyunCleanup] = None
def _setup_session(self):
"""初始化 session 和凭证"""
if self._credential.refresh_token:
# 验证 refresh_token 并获取 drive_id
if self._credential.validate():
self._drive_id = self._credential.get_drive_id()
logger.info(
f"[AliyunAdapter] 凭证验证成功, drive_id={self._drive_id[:8]}..."
)
else:
logger.warning("[AliyunAdapter] 凭证验证失败,转存功能可能不可用")
else:
logger.warning("[AliyunAdapter] 未配置 refresh_token")
# ─── 核心抽象方法实现 ──────────────────────────────────
def _get_share_detail(self, pwd_id: str, passcode: str = "") -> dict:
"""
获取分享详情。
步骤①②: 先获取匿名分享信息,再获取 share_token。
Returns:
{
"title": "分享标题",
"share_id": "...",
"share_token": "...",
"files": [{"file_id": "...", "name": "...", "size": 0, "type": "file"}, ...],
}
"""
try:
transfer = self._get_transfer()
# ① 获取分享信息(匿名)
share_info = transfer._get_share_info(pwd_id)
if not share_info:
raise TransferError(
TransferErrorCode.SHARE_NOT_EXIST,
platform=self.PLATFORM_KEY,
)
# ② 获取分享令牌Auth
share_token = transfer._get_share_token(pwd_id, passcode)
if not share_token:
raise TransferError(
TransferErrorCode.PASSCODE_WRONG if passcode else TransferErrorCode.SHARE_NOT_EXIST,
platform=self.PLATFORM_KEY,
message="获取分享令牌失败(可能需要提取码)",
)
return {
"title": share_info.get("share_name", share_info.get("share_title", "")),
"share_id": pwd_id,
"share_token": share_token,
"files": share_info.get("file_infos", []),
"creator_name": share_info.get("creator_name", ""),
}
except TransferError:
raise
except Exception as e:
logger.exception(f"[AliyunAdapter] 获取分享详情失败: {e}")
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=str(e),
platform=self.PLATFORM_KEY,
)
def _save_files(self, pwd_id: str, detail: dict, save_dir: str) -> List[str]:
"""
步骤③: 批量复制文件到自己的网盘。
Args:
pwd_id: 分享 ID
detail: _get_share_detail 的返回值
save_dir: 目标目录(根目录用 "root"
Returns:
新文件 ID 列表
"""
share_token = detail.get("share_token", "")
files = detail.get("files", [])
if not share_token:
raise TransferError(
TransferErrorCode.SHARE_NOT_EXIST,
message="缺少 share_token",
platform=self.PLATFORM_KEY,
)
if not files:
raise TransferError(
TransferErrorCode.RESOURCE_EMPTY,
platform=self.PLATFORM_KEY,
)
file_ids = [f.get("file_id", "") for f in files if f.get("file_id")]
if not file_ids:
raise TransferError(
TransferErrorCode.RESOURCE_EMPTY,
message="无法提取文件 ID",
platform=self.PLATFORM_KEY,
)
# 确定目标目录
to_parent = save_dir if save_dir and save_dir != "/" else "root"
transfer = self._get_transfer()
new_ids = transfer._batch_copy(pwd_id, share_token, file_ids, to_parent)
if not new_ids:
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message="批量转存失败,所有文件复制均失败",
platform=self.PLATFORM_KEY,
)
return new_ids
def _create_share(
self, file_ids: List[str], title: str, password: str = ""
) -> Tuple[str, str]:
"""
步骤④: 创建新分享链接。
Returns:
(share_url, share_password)
"""
if not file_ids:
raise TransferError(
TransferErrorCode.RESOURCE_EMPTY,
platform=self.PLATFORM_KEY,
)
transfer = self._get_transfer()
result = transfer._create_share(file_ids, password)
share_url = result.get("share_url", "")
share_pwd = result.get("share_pwd", password)
if not share_url:
raise TransferError(
TransferErrorCode.SHARE_LINK_FAIL,
message="创建分享链接失败",
platform=self.PLATFORM_KEY,
)
return share_url, share_pwd
def get_files(self, parent_fid: str = "0") -> List[FileInfo]:
"""
列出网盘目录下的文件。
NOTE: 当前实现为占位。如需完整功能,请调用阿里云盘 /adrive/v3/file/list API。
"""
logger.warning("[AliyunAdapter] get_files() 未完整实现,返回空列表")
return []
def delete(self, file_ids: List[str]) -> bool:
"""
删除文件(移入回收站)。
Args:
file_ids: 要删除的文件 ID 列表
Returns:
是否全部删除成功
"""
if not file_ids:
return True
cleanup = self._get_cleanup()
result = cleanup.delete_files(file_ids)
return result.get("success", False)
# ─── 扩展功能 ──────────────────────────────────────────
def cleanup_files(self, file_ids: List[str]) -> Dict:
"""
清理文件(移入回收站),返回详细结果。
Returns:
AliyunCleanup.delete_files() 的返回字典
"""
cleanup = self._get_cleanup()
return cleanup.delete_files(file_ids)
def force_refresh_token(self) -> bool:
"""强制刷新 access_token"""
return self._credential.refresh()
def get_credential_status(self) -> Dict:
"""获取当前凭证状态"""
return self._credential.to_dict()
# ─── 文件列表提取 ──────────────────────────────────────
def _extract_file_list(self, detail: dict) -> List[FileInfo]:
"""从分享详情中提取 FileInfo 列表"""
files = detail.get("files", [])
result = []
for f in files:
result.append(FileInfo(
fid=f.get("file_id", ""),
name=f.get("name", ""),
size=int(f.get("size", 0)),
is_dir=f.get("type", "") == "folder",
ext=f.get("file_extension", ""),
))
return result
# ─── 内部辅助方法 ──────────────────────────────────────
def _get_transfer(self) -> AliyunTransfer:
"""懒加载获取 AliyunTransfer 实例"""
if self._transfer is None:
drive_id = self._drive_id or self._credential.get_drive_id()
self._transfer = AliyunTransfer(
credential=self._credential,
drive_id=drive_id,
to_parent_file_id=self.config.save_dir or "root",
request_timeout=self.transfer_config.request_timeout,
)
return self._transfer
def _get_cleanup(self) -> AliyunCleanup:
"""懒加载获取 AliyunCleanup 实例"""
if self._cleanup is None:
drive_id = self._drive_id or self._credential.get_drive_id()
self._cleanup = AliyunCleanup(
credential=self._credential,
drive_id=drive_id,
request_timeout=self.transfer_config.request_timeout,
)
return self._cleanup

View File

@@ -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,
}

View File

@@ -0,0 +1,216 @@
"""
阿里云盘凭证管理器 v1.0.0
refresh_token → access_token 刷新 + 自动缓存 + 过期前自动刷新
"""
import time
import logging
import threading
from typing import Dict, Optional
from dataclasses import dataclass, field
import requests
logger = logging.getLogger(__name__)
# ─── 常量 ──────────────────────────────────────────────────
API_HOST = "https://api.aliyundrive.com"
TOKEN_REFRESH_URL = f"{API_HOST}/token/refresh"
DEFAULT_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/135.0.0.0 Safari/537.36"
),
"Accept": "application/json, text/plain, */*",
"Content-Type": "application/json",
}
@dataclass
class TokenInfo:
"""缓存的 Token 信息"""
access_token: str = ""
refresh_token: str = ""
expires_at: float = 0.0 # Unix 时间戳
drive_id: str = ""
user_id: str = ""
nick_name: str = ""
default_sbox_drive_id: str = ""
@property
def is_expired(self) -> bool:
"""检查 access_token 是否已过期(提前 60s 视为过期)"""
return time.time() >= (self.expires_at - 60)
@property
def is_valid(self) -> bool:
return bool(self.access_token) and not self.is_expired
class AliyunCredentialManager:
"""
阿里云盘凭证管理器
职责:
- 使用 refresh_token 换取 access_token
- 缓存 access_token / expires_at / drive_id
- 过期前自动刷新(提前 60s
- 线程安全
用法:
mgr = AliyunCredentialManager(refresh_token="xxx")
mgr.refresh() # 强制刷新
headers = mgr.get_headers() # 获取带 Auth 的请求头
is_ok = mgr.validate() # 验证 refresh_token 有效性
"""
def __init__(self, refresh_token: str = ""):
self._refresh_token = refresh_token.strip()
self._token: Optional[TokenInfo] = None
self._lock = threading.Lock()
self._session = requests.Session()
self._session.headers.update(DEFAULT_HEADERS)
# ─── 公开 API ──────────────────────────────────────────
def refresh(self) -> bool:
"""
使用 refresh_token 换取 access_token。
返回 True 表示成功False 表示失败。
"""
with self._lock:
return self._do_refresh()
def get_headers(self) -> Dict[str, str]:
"""
获取带 Authorization 的请求头。
自动检查 token 有效性,必要时自动刷新。
Returns:
{"Authorization": "Bearer <access_token>", ...}
"""
self._ensure_token_valid()
headers = {}
if self._token and self._token.access_token:
headers["Authorization"] = f"Bearer {self._token.access_token}"
return headers
def get_access_token(self) -> str:
"""获取当前有效的 access_token必要时自动刷新"""
self._ensure_token_valid()
return self._token.access_token if self._token else ""
def get_drive_id(self) -> str:
"""获取默认 drive_id"""
self._ensure_token_valid()
return self._token.drive_id if self._token else ""
def get_sbox_drive_id(self) -> str:
"""获取保险箱 drive_id"""
self._ensure_token_valid()
return self._token.default_sbox_drive_id if self._token else ""
def validate(self) -> bool:
"""
验证 refresh_token 是否有效。
要求 refresh_token 长度 >= 20且能成功换取 access_token。
"""
if not self._refresh_token or len(self._refresh_token) < 20:
logger.warning("[AliyunCredential] refresh_token 长度不足 20验证失败")
return False
return self.refresh()
@property
def refresh_token(self) -> str:
return self._refresh_token
@refresh_token.setter
def refresh_token(self, value: str):
"""更新 refresh_token通常在 API 返回新 refresh_token 后调用)"""
self._refresh_token = value.strip()
# 清除旧缓存,下次请求自动刷新
with self._lock:
self._token = None
# ─── 内部方法 ──────────────────────────────────────────
def _ensure_token_valid(self):
"""确保 token 有效(过期则自动刷新)"""
if self._token is None or self._token.is_expired:
self.refresh()
def _do_refresh(self) -> bool:
"""实际执行 token 刷新"""
if not self._refresh_token:
logger.error("[AliyunCredential] 没有 refresh_token无法刷新")
return False
try:
resp = self._session.post(
TOKEN_REFRESH_URL,
json={"refresh_token": self._refresh_token},
timeout=30,
)
data = resp.json()
if resp.status_code != 200 or "access_token" not in data:
code = data.get("code", "Unknown")
message = data.get("message", "")
logger.error(
f"[AliyunCredential] 刷新 token 失败: "
f"HTTP {resp.status_code} code={code} msg={message}"
)
return False
# 解析响应
access_token = data.get("access_token", "")
expires_in = int(data.get("expires_in", 7200))
new_refresh = data.get("refresh_token", self._refresh_token)
self._token = TokenInfo(
access_token=access_token,
refresh_token=new_refresh,
expires_at=time.time() + expires_in,
drive_id=str(data.get("default_drive_id", "")),
user_id=str(data.get("user_id", "")),
nick_name=str(data.get("nick_name", "")),
default_sbox_drive_id=str(data.get("default_sbox_drive_id", "")),
)
# 更新 refresh_token服务端可能下发新的
if new_refresh != self._refresh_token:
logger.info(
"[AliyunCredential] refresh_token 已轮换,新旧前缀: "
f"{self._refresh_token[:8]}... → {new_refresh[:8]}..."
)
self._refresh_token = new_refresh
logger.info(
f"[AliyunCredential] Token 刷新成功 "
f"(user={self._token.nick_name}, "
f"expires_in={expires_in}s, "
f"drive_id={self._token.drive_id[:8]}...)"
)
return True
except requests.RequestException as e:
logger.error(f"[AliyunCredential] 刷新 token 网络异常: {e}")
return False
except Exception as e:
logger.exception(f"[AliyunCredential] 刷新 token 未知异常: {e}")
return False
def to_dict(self) -> dict:
"""导出当前状态(用于持久化)"""
self._ensure_token_valid()
return {
"refresh_token": self._refresh_token,
"access_token": self._token.access_token if self._token else "",
"expires_at": self._token.expires_at if self._token else 0,
"drive_id": self._token.drive_id if self._token else "",
"user_id": self._token.user_id if self._token else "",
"nick_name": self._token.nick_name if self._token else "",
}

View File

@@ -0,0 +1,493 @@
"""
阿里云盘转存模块 v1.0.0
实现 4 步批量转存流程:获取分享详情 → 获取分享令牌 → 批量复制文件 → 创建新分享
"""
import re
import time
import logging
from typing import List, Dict, Tuple, Optional
import requests
from .credential import AliyunCredentialManager, API_HOST
logger = logging.getLogger(__name__)
# ─── API 端点 ──────────────────────────────────────────────
# ① 获取分享详情(匿名)
SHARE_INFO_URL = f"{API_HOST}/adrive/v3/share_link/get_share_by_anonymous"
# ② 获取分享令牌(需 Auth
SHARE_TOKEN_URL = f"{API_HOST}/v2/share_link/get_share_token"
# ③ 批量操作(复制文件)
BATCH_URL = f"{API_HOST}/adrive/v4/batch"
# ④ 创建分享
CREATE_SHARE_URL = f"{API_HOST}/adrive/v2/share_link/create"
# ─── URL 模式 ──────────────────────────────────────────────
# 匹配 aliyundrive.com/s/<share_id>
URL_PATTERN = re.compile(r'aliyundrive\.com/s/([a-zA-Z0-9]+)')
# ─── 默认请求头 ────────────────────────────────────────────
DEFAULT_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/135.0.0.0 Safari/537.36"
),
"Accept": "application/json, text/plain, */*",
"Content-Type": "application/json",
"Referer": "https://aliyundrive.com",
}
class AliyunTransfer:
"""
阿里云盘批量转存
四步流程:
① 获取分享详情匿名POST /adrive/v3/share_link/get_share_by_anonymous
② 获取分享令牌AuthPOST /v2/share_link/get_share_token
③ 批量复制文件POST /adrive/v4/batch (X-Share-Token 头)
④ 创建新分享POST /adrive/v2/share_link/create
用法:
credential = AliyunCredentialManager(refresh_token="xxx")
transfer = AliyunTransfer(credential, drive_id="12345")
result = transfer.transfer(
share_url="https://www.aliyundrive.com/s/abc123",
share_password="",
to_parent_file_id="root",
)
"""
def __init__(
self,
credential: AliyunCredentialManager,
drive_id: str = "",
to_parent_file_id: str = "root",
request_timeout: int = 30,
):
self.credential = credential
self.drive_id = drive_id or credential.get_drive_id()
self.to_parent_file_id = to_parent_file_id
self.request_timeout = request_timeout
self._session = requests.Session()
self._session.headers.update(DEFAULT_HEADERS)
# ─── 公开 API ──────────────────────────────────────────
def transfer(
self,
share_url: str,
share_password: str = "",
to_parent_file_id: str = None,
new_share_password: str = "",
expiration: str = "",
) -> Dict:
"""
执行完整的转存流程。
Args:
share_url: 阿里云盘分享链接(如 https://www.aliyundrive.com/s/abc123
share_password: 分享提取码(如有)
to_parent_file_id: 转存目标目录 file_id默认用初始化时的值
new_share_password: 新分享的密码(空=无密码)
expiration: 分享有效期,空=永久
Returns:
{
"success": True/False,
"share_name": "...",
"new_file_ids": ["id1", "id2"],
"new_share_url": "https://...",
"new_share_password": "...",
"error": None or "...",
}
"""
parent_id = to_parent_file_id or self.to_parent_file_id
try:
# ① 获取分享详情
share_id = self._extract_share_id(share_url)
if not share_id:
return self._error("无法从 URL 提取分享 ID")
share_info = self._get_share_info(share_id)
if not share_info:
return self._error("分享不存在或已失效")
share_name = share_info.get("share_name", "")
file_infos = share_info.get("file_infos", [])
if not file_infos:
return self._error("分享内容为空")
logger.info(
f"[AliyunTransfer] 分享详情获取成功: "
f"name={share_name}, files={len(file_infos)}"
)
# ② 获取分享令牌
share_token = self._get_share_token(share_id, share_password)
if not share_token:
return self._error("获取分享令牌失败(可能需要提取码)")
logger.info(f"[AliyunTransfer] 分享令牌获取成功")
# ③ 批量复制文件
file_ids = [fi.get("file_id", "") for fi in file_infos if fi.get("file_id")]
if not file_ids:
return self._error("无法提取文件 ID")
new_file_ids = self._batch_copy(share_id, share_token, file_ids, parent_id)
if not new_file_ids:
return self._error("批量转存失败,请检查权限或容量")
logger.info(f"[AliyunTransfer] 批量转存成功: {len(new_file_ids)} 个文件")
# ④ 创建新分享
share_result = self._create_share(
new_file_ids,
share_password=new_share_password,
expiration=expiration,
)
new_share_url = share_result.get("share_url", "")
new_share_pwd = share_result.get("share_pwd", new_share_password)
logger.info(f"[AliyunTransfer] 新分享创建成功: {new_share_url}")
return {
"success": True,
"share_name": share_name,
"share_id": share_id,
"new_file_ids": new_file_ids,
"new_share_url": new_share_url,
"new_share_password": new_share_pwd,
"error": None,
}
except Exception as e:
logger.exception(f"[AliyunTransfer] 转存异常: {e}")
return self._error(str(e))
def get_share_info(self, share_url: str) -> Optional[Dict]:
"""
仅获取分享详情(不转存)。
Returns:
{"share_name": "...", "file_infos": [...]} or None
"""
share_id = self._extract_share_id(share_url)
if not share_id:
logger.error(f"[AliyunTransfer] 无法从 URL 提取 share_id: {share_url}")
return None
return self._get_share_info(share_id)
# ─── 步骤 ①:获取分享详情 ───────────────────────────────
def _get_share_info(self, share_id: str) -> Optional[Dict]:
"""
POST /adrive/v3/share_link/get_share_by_anonymous
请求体: {"share_id": "..."}
响应: {"share_name": "...", "file_infos": [{"file_id": "...", "name": "...", ...}]}
"""
try:
resp = self._session.post(
SHARE_INFO_URL,
json={"share_id": share_id},
timeout=self.request_timeout,
)
data = resp.json()
if resp.status_code != 200:
logger.error(
f"[AliyunTransfer] 获取分享详情失败: "
f"HTTP {resp.status_code}, {data}"
)
return None
# 检查业务错误码
code = data.get("code", "")
if code:
logger.error(
f"[AliyunTransfer] 获取分享详情 API 错误: "
f"code={code}, message={data.get('message', '')}"
)
return None
return {
"share_name": data.get("share_name", ""),
"share_title": data.get("share_title", data.get("share_name", "")),
"file_infos": data.get("file_infos", []),
"expiration": data.get("expiration", ""),
"creator_name": data.get("creator_name", ""),
"creator_id": data.get("creator_id", ""),
}
except requests.RequestException as e:
logger.error(f"[AliyunTransfer] 获取分享详情网络异常: {e}")
return None
except Exception as e:
logger.exception(f"[AliyunTransfer] 获取分享详情异常: {e}")
return None
# ─── 步骤 ②:获取分享令牌 ────────────────────────────────
def _get_share_token(self, share_id: str, share_password: str = "") -> Optional[str]:
"""
POST /v2/share_link/get_share_token
请求体: {"share_id": "..."}
需要 Auth 头
响应: {"share_token": "..."}
"""
try:
headers = self.credential.get_headers()
resp = self._session.post(
SHARE_TOKEN_URL,
json={
"share_id": share_id,
"share_pwd": share_password,
},
headers=headers,
timeout=self.request_timeout,
)
data = resp.json()
if resp.status_code != 200:
logger.error(
f"[AliyunTransfer] 获取分享令牌失败: "
f"HTTP {resp.status_code}, {data}"
)
return None
code = data.get("code", "")
if code:
logger.error(
f"[AliyunTransfer] 获取分享令牌 API 错误: "
f"code={code}, message={data.get('message', '')}"
)
return None
share_token = data.get("share_token", "")
if not share_token:
logger.error("[AliyunTransfer] 响应中缺少 share_token")
return None
return share_token
except requests.RequestException as e:
logger.error(f"[AliyunTransfer] 获取分享令牌网络异常: {e}")
return None
except Exception as e:
logger.exception(f"[AliyunTransfer] 获取分享令牌异常: {e}")
return None
# ─── 步骤 ③:批量复制文件 ────────────────────────────────
def _batch_copy(
self,
share_id: str,
share_token: str,
file_ids: List[str],
to_parent_file_id: str = "root",
) -> List[str]:
"""
POST /adrive/v4/batch
头: X-Share-Token: <share_token>
请求体:
{
"requests": [
{
"url": "/file/copy",
"body": {
"file_id": "...",
"share_id": "...",
"to_drive_id": "...",
"to_parent_file_id": "..."
}
}
]
}
响应: {"responses": [{"status": 200, "body": {"file_id": "new_id"}}, ...]}
返回新的 file_id 列表
"""
drive_id = self.drive_id
if not drive_id:
drive_id = self.credential.get_drive_id()
if not drive_id:
logger.error("[AliyunTransfer] 缺少 drive_id无法转存")
return []
# 构建批量请求体
requests_list = []
for fid in file_ids:
requests_list.append({
"url": "/file/copy",
"body": {
"file_id": fid,
"share_id": share_id,
"to_drive_id": drive_id,
"to_parent_file_id": to_parent_file_id,
},
"headers": {"Content-Type": "application/json"},
"id": fid,
"method": "POST",
})
try:
headers = self.credential.get_headers()
headers["X-Share-Token"] = share_token
resp = self._session.post(
BATCH_URL,
json={"requests": requests_list, "resource": "file"},
headers=headers,
timeout=self.request_timeout * 2, # 批量操作可能较慢
)
data = resp.json()
if resp.status_code != 200:
logger.error(
f"[AliyunTransfer] 批量复制失败: "
f"HTTP {resp.status_code}, {data}"
)
return []
code = data.get("code", "")
if code:
logger.error(
f"[AliyunTransfer] 批量复制 API 错误: "
f"code={code}, message={data.get('message', '')}"
)
return []
# 提取新 file_id
new_ids = []
responses = data.get("responses", [])
for item in responses:
status = item.get("status", 0)
body = item.get("body", {})
if status in (200, 201, 202):
new_fid = body.get("file_id", "")
if new_fid:
new_ids.append(new_fid)
else:
logger.warning(
f"[AliyunTransfer] 单个文件复制失败: "
f"id={item.get('id')}, status={status}, body={body}"
)
if not new_ids:
logger.error("[AliyunTransfer] 所有文件复制均失败")
elif len(new_ids) < len(file_ids):
logger.warning(
f"[AliyunTransfer] 部分文件复制成功: "
f"{len(new_ids)}/{len(file_ids)}"
)
return new_ids
except requests.RequestException as e:
logger.error(f"[AliyunTransfer] 批量复制网络异常: {e}")
return []
except Exception as e:
logger.exception(f"[AliyunTransfer] 批量复制异常: {e}")
return []
# ─── 步骤 ④:创建新分享 ──────────────────────────────────
def _create_share(
self,
file_ids: List[str],
share_password: str = "",
expiration: str = "",
) -> Dict:
"""
POST /adrive/v2/share_link/create
请求体: {"drive_id": "...", "file_id_list": [...], "share_pwd": "...", "expiration": "..."}
响应: {"share_url": "...", "share_id": "..."}
"""
drive_id = self.drive_id or self.credential.get_drive_id()
if not drive_id:
logger.error("[AliyunTransfer] 缺少 drive_id无法创建分享")
return {"share_url": "", "share_pwd": ""}
body = {
"drive_id": drive_id,
"file_id_list": file_ids,
"share_pwd": share_password or "",
"expiration": expiration or "",
}
try:
headers = self.credential.get_headers()
resp = self._session.post(
CREATE_SHARE_URL,
json=body,
headers=headers,
timeout=self.request_timeout,
)
data = resp.json()
if resp.status_code != 200:
logger.error(
f"[AliyunTransfer] 创建分享失败: "
f"HTTP {resp.status_code}, {data}"
)
return {"share_url": "", "share_pwd": share_password}
code = data.get("code", "")
if code:
logger.error(
f"[AliyunTransfer] 创建分享 API 错误: "
f"code={code}, message={data.get('message', '')}"
)
return {"share_url": "", "share_pwd": share_password}
share_url = data.get("share_url", "")
share_pwd = data.get("share_pwd", share_password)
return {"share_url": share_url, "share_pwd": share_pwd}
except requests.RequestException as e:
logger.error(f"[AliyunTransfer] 创建分享网络异常: {e}")
return {"share_url": "", "share_pwd": share_password}
except Exception as e:
logger.exception(f"[AliyunTransfer] 创建分享异常: {e}")
return {"share_url": "", "share_pwd": share_password}
# ─── URL 解析 ──────────────────────────────────────────
@staticmethod
def _extract_share_id(url: str) -> Optional[str]:
"""从阿里云盘分享 URL 中提取 share_id"""
m = URL_PATTERN.search(url)
if m:
return m.group(1)
return None
@staticmethod
def extract_share_id_static(url: str) -> Optional[str]:
"""静态方法:提取 share_id"""
return AliyunTransfer._extract_share_id(url)
# ─── 工具方法 ──────────────────────────────────────────
def _error(self, message: str) -> Dict:
"""构造错误返回"""
return {
"success": False,
"share_name": "",
"share_id": "",
"new_file_ids": [],
"new_share_url": "",
"new_share_password": "",
"error": message,
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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 缓存已失效")

View File

@@ -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

View File

@@ -0,0 +1,330 @@
"""
CloudSearch Transfer — 适配器抽象基类 v1.0.0
参考 cloud-auto-save 的 BaseCloudDriveAdapter + netdisk 的 Pan 接口
"""
import time
import re
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Optional, List, Tuple, Dict, Any
from urllib.parse import urlparse, parse_qs
import requests
from ..config import PlatformConfig, TransferConfig
from ..errors import TransferError, TransferErrorCode
logger = logging.getLogger(__name__)
@dataclass
class FileInfo:
"""文件信息"""
fid: str # 文件ID
name: str # 文件名
size: int = 0 # 文件大小
is_dir: bool = False
ext: str = "" # 扩展名
@dataclass
class TransferResult:
"""转存结果"""
success: bool
platform: str
new_file_id: str = "" # 转存后的文件ID
file_name: str = "" # 文件名
share_url: str = "" # 新的分享链接
share_password: str = "" # 分享密码
original_url: str = "" # 原始分享链接
elapsed_ms: int = 0 # 耗时
error: Optional[TransferError] = None
@dataclass
class VerifyResult:
"""链接验证结果"""
valid: bool
platform: str
title: str = ""
file_count: int = 0
files: List[FileInfo] = None
error: Optional[TransferError] = None
def __post_init__(self):
if self.files is None:
self.files = []
class BaseCloudDriveAdapter(ABC):
"""
网盘适配器抽象基类
每个网盘平台实现此基类,统一接口:
- transfer(): 转存分享到自己网盘 → 创建新分享
- verify(): 验证分享链接有效性
- get_files(): 列出目录文件
- delete(): 删除文件
"""
# 子类必须覆盖
PLATFORM_NAME: str = ""
PLATFORM_KEY: str = "" # quark/baidu/aliyun/uc/xunlei/pan123/cloud189
# URL匹配正则子类覆盖
URL_PATTERNS: List[str] = []
# 默认请求头
DEFAULT_HEADERS: Dict[str, str] = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/135.0.0.0 Safari/537.36",
"Accept": "application/json, text/plain, */*",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
}
def __init__(self, config: PlatformConfig, transfer_config: TransferConfig):
self.config = config
self.transfer_config = transfer_config
self.session = requests.Session()
self.session.headers.update(self.DEFAULT_HEADERS)
self._setup_session()
def _setup_session(self):
"""子类可覆盖初始化session特有的headers/cookies"""
pass
# ─── 公开接口 ──────────────────────────────────────────
def transfer(self, share_url: str, save_dir: str = "",
share_password: str = "") -> TransferResult:
"""
转存分享到自己网盘 → 创建新分享
Args:
share_url: 原始分享链接
save_dir: 转存到的目录(空=使用配置的默认目录)
share_password: 新分享的密码(空=使用配置的密码)
"""
start = time.time()
try:
# 1. 解析URL提取pwd_id
pwd_id, passcode = self._parse_share_url(share_url)
# 2. 获取分享详情
detail = self._get_share_detail(pwd_id, passcode)
if not detail:
raise TransferError(TransferErrorCode.SHARE_NOT_EXIST,
platform=self.PLATFORM_KEY)
# 3. 执行转存
save_dir = save_dir or self.config.save_dir or "/"
new_fids = self._save_files(pwd_id, detail, save_dir)
if not new_fids:
raise TransferError(TransferErrorCode.RESOURCE_EMPTY,
platform=self.PLATFORM_KEY)
# 4. 广告过滤
if self.transfer_config.ad_filter_enabled:
new_fids = self._filter_ads(new_fids)
if not new_fids:
raise TransferError(TransferErrorCode.RESOURCE_EMPTY,
platform=self.PLATFORM_KEY)
# 5. 创建新分享
pwd = share_password or self.config.share_password
share_url_new, share_pwd = self._create_share(new_fids, detail.get("title", ""), pwd)
elapsed = int((time.time() - start) * 1000)
return TransferResult(
success=True,
platform=self.PLATFORM_KEY,
new_file_id=",".join(new_fids),
file_name=detail.get("title", ""),
share_url=share_url_new,
share_password=share_pwd,
original_url=share_url,
elapsed_ms=elapsed,
)
except TransferError:
raise
except Exception as e:
logger.exception(f"[{self.PLATFORM_KEY}] transfer failed: {share_url}")
raise TransferError(TransferErrorCode.NETWORK_ERROR,
message=str(e), platform=self.PLATFORM_KEY)
def verify(self, share_url: str) -> VerifyResult:
"""验证分享链接有效性"""
try:
pwd_id, passcode = self._parse_share_url(share_url)
detail = self._get_share_detail(pwd_id, passcode)
files = self._extract_file_list(detail)
return VerifyResult(
valid=True,
platform=self.PLATFORM_KEY,
title=detail.get("title", ""),
file_count=len(files),
files=files,
)
except TransferError as e:
return VerifyResult(valid=False, platform=self.PLATFORM_KEY, error=e)
except Exception as e:
return VerifyResult(
valid=False,
platform=self.PLATFORM_KEY,
error=TransferError(TransferErrorCode.NETWORK_ERROR, message=str(e)),
)
@abstractmethod
def get_files(self, parent_fid: str = "0") -> List[FileInfo]:
"""列出目录下的文件"""
...
@abstractmethod
def delete(self, file_ids: List[str]) -> bool:
"""删除文件"""
...
# ─── URL解析 ──────────────────────────────────────────
def _parse_share_url(self, url: str) -> Tuple[str, str]:
"""
解析分享URL → (pwd_id, passcode)
子类可覆盖
"""
for pattern in self.URL_PATTERNS:
m = re.search(pattern, url)
if m:
pwd_id = m.group(1)
passcode = ""
# 尝试从URL参数提取密码
parsed = urlparse(url)
params = parse_qs(parsed.query)
passcode = params.get("pwd", params.get("code", [""]))[0]
return pwd_id, passcode
raise TransferError(TransferErrorCode.URL_INVALID,
message=f"无法解析{self.PLATFORM_NAME}链接: {url}")
# ─── 核心抽象方法(子类必须实现)────────────────────────
@abstractmethod
def _get_share_detail(self, pwd_id: str, passcode: str = "") -> dict:
"""获取分享详情 → {title, fid/fs_id, ...}"""
...
@abstractmethod
def _save_files(self, pwd_id: str, detail: dict, save_dir: str) -> List[str]:
"""转存文件 → 返回新文件ID列表"""
...
@abstractmethod
def _create_share(self, file_ids: List[str], title: str,
password: str = "") -> Tuple[str, str]:
"""创建分享 → (share_url, share_password)"""
...
def _extract_file_list(self, detail: dict) -> List[FileInfo]:
"""从分享详情提取文件列表(默认实现,子类可覆盖)"""
return []
def _filter_ads(self, file_ids: List[str]) -> List[str]:
"""广告过滤(默认不实现,子类可覆盖)"""
return file_ids
# ─── HTTP 工具方法 ─────────────────────────────────────
def _get(self, url: str, params: dict = None, headers: dict = None,
retry: int = None) -> requests.Response:
return self._request("GET", url, params=params, headers=headers, retry=retry)
def _post(self, url: str, json_data: dict = None, data: dict = None,
params: dict = None, headers: dict = None, retry: int = None) -> requests.Response:
return self._request("POST", url, json=json_data, data=data,
params=params, headers=headers, retry=retry)
def _request(self, method: str, url: str, **kwargs) -> requests.Response:
"""统一HTTP请求带重试"""
retry = kwargs.pop("retry", None)
max_retries = retry if retry is not None else self.transfer_config.max_retries
last_exc = None
for attempt in range(max_retries + 1):
try:
resp = self.session.request(
method, url,
timeout=self.transfer_config.request_timeout,
**kwargs
)
return resp
except requests.RequestException as e:
last_exc = e
if attempt < max_retries:
delay = self.transfer_config.retry_delay * (2 ** attempt)
logger.warning(f"[{self.PLATFORM_KEY}] HTTP retry {attempt+1}/{max_retries} "
f"after {delay:.1f}s: {url}")
time.sleep(delay)
raise TransferError(TransferErrorCode.NETWORK_ERROR,
message=str(last_exc), platform=self.PLATFORM_KEY)
def _poll_task(self, task_url: str, task_id: str,
status_field: str = "status",
success_value: Any = 2,
result_path: str = None,
query_params: dict = None) -> dict:
"""
轮询异步任务直到完成
参考 netdisk 的任务轮询机制
"""
interval = self.transfer_config.task_poll_interval
max_attempts = self.transfer_config.task_poll_max_attempts
max_wait = self.transfer_config.task_poll_max_wait
started = time.time()
for attempt in range(max_attempts):
if time.time() - started > max_wait:
raise TransferError(TransferErrorCode.TIMEOUT,
platform=self.PLATFORM_KEY,
details={"task_id": task_id})
try:
params = query_params or {}
params["task_id"] = task_id
resp = self._get(task_url, params=params, retry=1)
data = resp.json().get("data", resp.json())
current_status = data.get(status_field)
if current_status == success_value:
if result_path:
# 支持点号路径如 "save_as.save_as_top_fids"
for key in result_path.split("."):
data = data.get(key, {}) if isinstance(data, dict) else data
return data
if current_status is False or current_status == -1:
raise TransferError(TransferErrorCode.NETWORK_ERROR,
message=f"任务失败: {data}",
platform=self.PLATFORM_KEY)
except (requests.RequestException, ValueError):
pass
time.sleep(interval)
raise TransferError(TransferErrorCode.TIMEOUT,
platform=self.PLATFORM_KEY,
details={"task_id": task_id, "attempts": max_attempts})
# ─── 工厂函数adapter/factory.py 使用)───────────────────
def match_url(url: str, adapter_cls: type) -> bool:
"""URL是否匹配某个适配器"""
for pattern in adapter_cls.URL_PATTERNS:
if re.search(pattern, url):
return True
return False

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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 []

View File

@@ -0,0 +1,112 @@
"""
CloudSearch Transfer — 适配器工厂 v1.0.0
参考 cloud-auto-save 的 AdapterFactory + AccountManager
"""
import hashlib
import logging
from typing import Optional, Dict, Type
from .base import BaseCloudDriveAdapter, match_url
from ..config import ConfigManager
from ..errors import TransferError, TransferErrorCode
logger = logging.getLogger(__name__)
class AdapterFactory:
"""
适配器工厂
- URL正则自动识别网盘类型
- 实例缓存:同平台+同Cookie单例
- 多账号路由
"""
# 平台注册表(延迟导入避免循环引用)
_registry: Dict[str, Type[BaseCloudDriveAdapter]] = {}
# 实例缓存 key: "platform:cookie_hash[:16]"
_cache: Dict[str, BaseCloudDriveAdapter] = {}
def __init__(self, config_manager: ConfigManager):
self.config_manager = config_manager
self._register_all()
def _register_all(self):
"""注册所有平台适配器"""
from .quark import QuarkAdapter
from .baidu import BaiduAdapter
from .aliyun import AliyunAdapter
from .uc import UcAdapter
from .xunlei import XunleiAdapter
from .pan115 import Pan115Adapter
from .pan123 import Pan123Adapter
from .cloud189 import Cloud189Adapter
self._registry = {
"quark": QuarkAdapter,
"baidu": BaiduAdapter,
"aliyun": AliyunAdapter,
"uc": UcAdapter,
"xunlei": XunleiAdapter,
"pan115": Pan115Adapter,
"pan123": Pan123Adapter,
"cloud189": Cloud189Adapter,
}
def detect_platform(self, url: str) -> Optional[str]:
"""根据URL自动识别网盘平台"""
for platform_key, adapter_cls in self._registry.items():
if match_url(url, adapter_cls):
return platform_key
return None
def get_adapter(self, platform_key: str) -> Optional[BaseCloudDriveAdapter]:
"""获取适配器实例(带缓存)"""
config = self.config_manager.get_platform(platform_key)
if not config:
return None
adapter_cls = self._registry.get(platform_key)
if not adapter_cls:
return None
# 构建缓存键
cache_key = self._cache_key(platform_key, config)
if cache_key in self._cache:
return self._cache[cache_key]
# 创建新实例
adapter = adapter_cls(config, self.config_manager.transfer)
self._cache[cache_key] = adapter
logger.info(f"[Factory] Created adapter: {platform_key} "
f"(cache_key={cache_key})")
return adapter
def get_adapter_for_url(self, url: str) -> Optional[BaseCloudDriveAdapter]:
"""根据URL自动获取适配器"""
platform = self.detect_platform(url)
if not platform:
raise TransferError(TransferErrorCode.URL_INVALID,
message=f"无法识别链接平台: {url}")
adapter = self.get_adapter(platform)
if not adapter:
raise TransferError(TransferErrorCode.NO_CONFIG,
message=f"平台 {platform} 未配置凭证",
platform=platform)
return adapter
def invalidate_cache(self, platform_key: str = None):
"""清除缓存"""
if platform_key:
keys = [k for k in self._cache if k.startswith(platform_key)]
for k in keys:
del self._cache[k]
else:
self._cache.clear()
def _cache_key(self, platform: str, config) -> str:
"""构建缓存键"""
credential = config.cookie or config.refresh_token or ""
token_hash = hashlib.md5(credential.encode()).hexdigest()[:16]
return f"{platform}:{config.account_name}:{token_hash}"

View File

@@ -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)

View File

@@ -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

View File

@@ -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/"}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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",
}

View File

@@ -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 []

View File

@@ -0,0 +1,509 @@
"""
CloudSearch Transfer — 夸克网盘适配器 v1.0.0
将 QuarkCredentialManager、QuarkTransfer、QuarkCleanup 组合为
BaseCloudDriveAdapter 的完整实现。
夸克网盘 7 步 API 转存流程:
① POST .../share/sharepage/token → stoken
② GET .../share/sharepage/detail → fid, share_fid_token, title
③ POST .../share/sharepage/save → task_id (转存)
④ 轮询 GET .../task → save_as_top_fids
⑤ POST .../share → task_id (创建分享)
⑥ 轮询 GET .../task → share_id
⑦ POST .../share/password → share_url, passcode
参考 cloud-auto-save 的 quark 实现 + netdisk 的 Pan 接口约定。
"""
from __future__ import annotations
import logging
import time
from typing import Any, Dict, List, Optional, Tuple
from ..base import BaseCloudDriveAdapter, FileInfo, TransferResult, VerifyResult
from ...config import PlatformConfig, TransferConfig
from ...errors import TransferError, TransferErrorCode
from .credential import QuarkCredentialManager
from .transfer import QuarkTransfer, SHARE_URL_PATTERN
from .cleanup import QuarkCleanup
logger = logging.getLogger(__name__)
class QuarkAdapter(BaseCloudDriveAdapter):
"""夸克网盘适配器。
组合 credential / transfer / cleanup 三个模块,
实现 BaseCloudDriveAdapter 定义的所有抽象方法。
Attributes:
PLATFORM_NAME: 展示用平台名称。
PLATFORM_KEY: 内部平台标识。
URL_PATTERNS: 夸克分享链接匹配正则列表。
"""
# ─── 平台标识 ──────────────────────────────────────────────
PLATFORM_NAME: str = "夸克网盘"
PLATFORM_KEY: str = "quark"
# ─── URL 匹配 ──────────────────────────────────────────────
# 支持 pan.quark.cn/s/<share_id>
URL_PATTERNS: List[str] = [
r"pan\.quark\.cn/s/(\w+)",
]
def __init__(self, config: PlatformConfig, transfer_config: TransferConfig) -> None:
"""初始化夸克适配器。
Args:
config: 平台配置(含 Cookie 等)。
transfer_config: 全局转存配置(超时、重试、轮询参数等)。
"""
super().__init__(config, transfer_config)
# 初始化三个子模块
self._credential: QuarkCredentialManager = QuarkCredentialManager(
cookie=config.cookie
)
self._transfer_engine: QuarkTransfer = QuarkTransfer(
credential=self._credential,
timeout=transfer_config.request_timeout,
poll_interval=transfer_config.task_poll_interval,
poll_max_attempts=transfer_config.task_poll_max_attempts,
)
self._cleanup: QuarkCleanup = QuarkCleanup(
credential=self._credential,
timeout=transfer_config.request_timeout,
)
# ═══════════════════════════════════════════════════════════════
# 公开接口实现
# ═══════════════════════════════════════════════════════════════
def _setup_session(self) -> None:
"""将夸克 Cookie 注入 session 的默认 headers。"""
headers = self._credential.get_headers()
if headers:
self.session.headers.update(headers)
logger.debug("[QuarkAdapter] Session headers updated with Cookie")
# ─── transfer() 使用基类模板,子类实现 _transfer ──────────
def _transfer(self, share_url: str, save_dir: str = "",
share_password: str = "") -> TransferResult:
"""执行转存的核心逻辑(被基类 transfer() 调用)。
通过 QuarkTransfer 引擎执行完整的 7 步流程。
Args:
share_url: 夸克分享链接。
save_dir: 目标目录,空则使用配置的默认目录。
share_password: 新分享的密码。
Returns:
TransferResult 包含转存结果。
"""
start: float = time.time()
# 凭证检查
if not self._credential.validate():
raise TransferError(
TransferErrorCode.NOT_LOGIN,
message="夸克 Cookie 无效或长度不足",
platform=self.PLATFORM_KEY,
)
# 目标目录:默认根目录 "0"
target_dir: str = save_dir or self.config.save_dir or "0"
# 分享密码
pwd: str = share_password or self.config.share_password or ""
try:
result: Dict[str, Any] = self._transfer_engine.transfer(
share_url=share_url,
save_dir=target_dir,
share_password=pwd,
)
except ValueError as exc:
raise TransferError(
TransferErrorCode.URL_INVALID,
message=str(exc),
platform=self.PLATFORM_KEY,
) from exc
except RuntimeError as exc:
msg: str = str(exc)
if "stoken" in msg or "status" in msg:
raise TransferError(
TransferErrorCode.SHARE_NOT_EXIST,
message=msg,
platform=self.PLATFORM_KEY,
) from exc
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=msg,
platform=self.PLATFORM_KEY,
) from exc
elapsed: int = int((time.time() - start) * 1000)
# 广告过滤:在转存完成后对 new_file_ids 进行过滤
new_fids: List[str] = result.get("new_file_ids", [])
if self.transfer_config.ad_filter_enabled and new_fids:
new_fids = self._filter_ads(new_fids)
if not new_fids:
raise TransferError(
TransferErrorCode.RESOURCE_EMPTY,
platform=self.PLATFORM_KEY,
)
return TransferResult(
success=True,
platform=self.PLATFORM_KEY,
new_file_id=",".join(new_fids),
file_name=result.get("file_name", ""),
share_url=result.get("share_url", ""),
share_password=result.get("passcode", pwd),
original_url=share_url,
elapsed_ms=elapsed,
)
# ─── verify() 使用基类模板,子类实现 _verify ───────────────
def _verify(self, share_url: str) -> VerifyResult:
"""验证夸克分享链接有效性。
通过获取 stoken → 获取详情来验证链接。
Args:
share_url: 夸克分享链接。
Returns:
VerifyResult 包含验证结果。
"""
try:
pwd_id, passcode = self._parse_share_url(share_url)
if not self._credential.validate():
return VerifyResult(
valid=False,
platform=self.PLATFORM_KEY,
error=TransferError(
TransferErrorCode.NOT_LOGIN,
platform=self.PLATFORM_KEY,
),
)
stoken: str = self._transfer_engine._get_stoken(pwd_id, passcode)
detail: Dict[str, Any] = self._transfer_engine._get_detail(pwd_id, stoken)
files: List[FileInfo] = self._extract_file_list(detail)
return VerifyResult(
valid=True,
platform=self.PLATFORM_KEY,
title=detail.get("title", ""),
file_count=len(files),
files=files,
)
except TransferError:
raise
except (ValueError, RuntimeError) as exc:
return VerifyResult(
valid=False,
platform=self.PLATFORM_KEY,
error=TransferError(
TransferErrorCode.SHARE_NOT_EXIST,
message=str(exc),
platform=self.PLATFORM_KEY,
),
)
except Exception as exc:
return VerifyResult(
valid=False,
platform=self.PLATFORM_KEY,
error=TransferError(
TransferErrorCode.NETWORK_ERROR,
message=str(exc),
platform=self.PLATFORM_KEY,
),
)
# ─── 核心抽象方法 ─────────────────────────────────────────
def _get_share_detail(self, pwd_id: str, passcode: str = "") -> dict:
"""获取夸克分享详情(基类 transfer() 流程中的步骤②)。
Args:
pwd_id: 分享 ID。
passcode: 提取码。
Returns:
分享详情字典,包含 title, fid, share_fid_token 等字段。
"""
stoken: str = self._transfer_engine._get_stoken(pwd_id, passcode)
return self._transfer_engine._get_detail(pwd_id, stoken)
def _save_files(self, pwd_id: str, detail: dict, save_dir: str) -> List[str]:
"""转存文件到自己的夸克网盘(基类 transfer() 流程中的步骤③④)。
Args:
pwd_id: 分享 ID。
detail: 分享详情(来自 _get_share_detail
save_dir: 目标目录 ID。
Returns:
转存后的新文件 ID 列表。
"""
# 需要 stoken从 detail 间接获取(重新请求)
stoken: str = self._transfer_engine._get_stoken(pwd_id)
task_id: str = self._transfer_engine._init_save(
pwd_id, stoken, detail, to_pdir_fid=save_dir
)
return self._transfer_engine._poll_save_task(task_id)
def _create_share(self, file_ids: List[str], title: str,
password: str = "") -> Tuple[str, str]:
"""创建夸克分享链接(基类 transfer() 流程中的步骤⑤⑥⑦)。
Args:
file_ids: 要分享的文件 ID 列表。
title: 分享标题。
password: 分享密码。
Returns:
(share_url, share_password) 元组。
"""
task_id: str = self._transfer_engine._init_share(file_ids, title)
share_id: str = self._transfer_engine._poll_share_task(task_id)
return self._transfer_engine._set_password(share_id, password)
def _extract_file_list(self, detail: dict) -> List[FileInfo]:
"""从夸克分享详情中提取文件列表。
夸克的 sharepage/detail 返回格式:
{
"files": [
{"fid": "...", "file_name": "...", "size": 123, "dir": false, ...},
]
}
Args:
detail: 分享详情字典。
Returns:
FileInfo 对象列表。
"""
files_data: List[Dict[str, Any]] = detail.get("files", [])
result: List[FileInfo] = []
for f in files_data:
file_info = FileInfo(
fid=str(f.get("fid", f.get("file_id", ""))),
name=str(f.get("file_name", f.get("name", ""))),
size=int(f.get("size", 0)),
is_dir=bool(f.get("dir", f.get("is_dir", False))),
ext=str(f.get("ext", f.get("file_extension", ""))),
)
result.append(file_info)
# 如果 files 为空,尝试用 detail 顶层字段构造单个文件信息
if not result and detail.get("fid"):
result.append(FileInfo(
fid=str(detail.get("fid", "")),
name=str(detail.get("title", detail.get("file_name", ""))),
size=0,
is_dir=False,
))
return result
def _filter_ads(self, file_ids: List[str]) -> List[str]:
"""过滤广告文件。
合并配置层和平台层的 banned_keywords调用 QuarkCleanup 执行过滤。
当前实现基于 file_ids 列表过滤(无文件名信息时保持原样)。
Args:
file_ids: 文件 ID 列表。
Returns:
过滤后的文件 ID 列表。
"""
keywords: List[str] = list(
set(self.config.banned_keywords)
| set(self.transfer_config.default_banned_keywords)
)
if not keywords:
return file_ids
# 获取文件信息以进行名称匹配
# 在基类 transfer() 流程中,此处 file_ids 已为转存后的新 IDs
try:
files: List[FileInfo] = self.get_files()
file_names: List[str] = [f.name for f in files]
return QuarkCleanup.filter_ad_ids(file_ids, file_names, keywords)
except Exception:
# 如果无法获取文件名列表,跳过广告过滤
logger.warning("[QuarkAdapter] Cannot fetch file list for ad filtering, skipping")
return file_ids
# ─── get_files / delete ────────────────────────────────────
def get_files(self, parent_fid: str = "0") -> List[FileInfo]:
"""列出夸克网盘指定目录下的文件。
GET /1/clouddrive/file/sort?pdir_fid=<parent_fid>&_page=1&_size=100&_sort=updated_at:desc
Args:
parent_fid: 父目录 ID默认 "0" 即根目录。
Returns:
FileInfo 列表。
"""
url: str = "https://drive-pc.quark.cn/1/clouddrive/file/sort"
params: Dict[str, str] = {
"pdir_fid": parent_fid,
"_page": "1",
"_size": "100",
"_sort": "updated_at:desc",
}
headers: Dict[str, str] = self._credential.get_headers()
try:
resp = self._get(url, params=params, headers=headers)
except Exception as exc:
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=f"获取文件列表失败: {exc}",
platform=self.PLATFORM_KEY,
) from exc
data: Dict[str, Any] = resp.json()
status: int = data.get("status", -1)
if status != 0 and data.get("code") not in (0, None):
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=f"获取文件列表失败: {data.get('message')}",
platform=self.PLATFORM_KEY,
)
files_data: List[Dict[str, Any]] = data.get("data", {}).get("list", [])
result: List[FileInfo] = []
for f in files_data:
result.append(FileInfo(
fid=str(f.get("fid", "")),
name=str(f.get("file_name", f.get("name", ""))),
size=int(f.get("size", 0)),
is_dir=bool(f.get("dir", f.get("is_dir", False))),
ext=str(f.get("file_extension", f.get("ext", ""))),
))
logger.debug("[QuarkAdapter] Listed %d files in dir=%s", len(result), parent_fid)
return result
def delete(self, file_ids: List[str]) -> bool:
"""删除夸克网盘文件(移到回收站)。
Args:
file_ids: 要删除的文件 ID 列表。
Returns:
True 表示删除成功。
"""
if not self._credential.validate():
raise TransferError(
TransferErrorCode.NOT_LOGIN,
platform=self.PLATFORM_KEY,
)
try:
return self._cleanup.delete_files(file_ids)
except RuntimeError as exc:
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=str(exc),
platform=self.PLATFORM_KEY,
) from exc
def delete_permanent(self, file_ids: List[str]) -> bool:
"""彻底删除夸克网盘文件(不可恢复)。
Args:
file_ids: 要彻底删除的文件 ID 列表。
Returns:
True 表示删除成功。
"""
if not self._credential.validate():
raise TransferError(
TransferErrorCode.NOT_LOGIN,
platform=self.PLATFORM_KEY,
)
try:
return self._cleanup.delete_files_permanent(file_ids)
except RuntimeError as exc:
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=str(exc),
platform=self.PLATFORM_KEY,
) from exc
# ─── 工具方法 ─────────────────────────────────────────────
def _parse_share_url(self, url: str) -> Tuple[str, str]:
"""解析夸克分享 URL 提取 (pwd_id, passcode)。
夸克链接格式https://pan.quark.cn/s/<pwd_id> 或带 ?pwd=xxxx
Args:
url: 夸克分享链接。
Returns:
(pwd_id, passcode) 元组。
Raises:
TransferError: URL 格式无法识别。
"""
pwd_id: Optional[str] = QuarkTransfer.parse_share_url(url)
if not pwd_id:
raise TransferError(
TransferErrorCode.URL_INVALID,
message=f"无法解析夸克链接: {url}",
platform=self.PLATFORM_KEY,
)
# 提取密码参数
from urllib.parse import urlparse, parse_qs
parsed = urlparse(url)
params = parse_qs(parsed.query)
passcode: str = params.get("pwd", params.get("code", [""]))[0]
return pwd_id, passcode
def update_cookie(self, cookie: str) -> None:
"""动态更新 Cookie 并同步到 session headers。
Args:
cookie: 新的 Cookie 字符串。
"""
self._credential.update_cookie(cookie)
self._setup_session()
logger.info("[QuarkAdapter] Cookie updated, new length=%d", len(cookie))
def close(self) -> None:
"""关闭所有子模块的 HTTP 会话。"""
self._transfer_engine.close()
self._cleanup.close()
self.session.close()
def __repr__(self) -> str:
return (
f"QuarkAdapter(name={self.PLATFORM_NAME}, "
f"account={self.config.account_name}, "
f"credential_valid={self._credential.validate()})"
)

View File

@@ -0,0 +1,209 @@
"""
CloudSearch Transfer — 夸克网盘清理模块 v1.0.0
提供文件删除和广告过滤功能。
"""
from __future__ import annotations
import logging
from typing import Any, Dict, List
import requests
from .credential import QuarkCredentialManager
logger = logging.getLogger(__name__)
# ─── 夸克 API ─────────────────────────────────────────────────────
QUARK_API_BASE = "https://drive-pc.quark.cn"
QUARK_FILE_API = f"{QUARK_API_BASE}/1/clouddrive/file"
class QuarkCleanup:
"""夸克网盘文件清理器。
提供批量删除文件和广告文件过滤功能。
Attributes:
credential: 夸克凭证管理器。
session: 复用的 requests.Session。
timeout: HTTP 请求超时秒数。
"""
def __init__(
self,
credential: QuarkCredentialManager,
timeout: int = 30,
) -> None:
"""初始化清理器。
Args:
credential: 有效的夸克凭证管理器。
timeout: HTTP 请求超时秒数。
"""
self.credential: QuarkCredentialManager = credential
self.timeout: int = timeout
self.session: requests.Session = requests.Session()
def delete_files(self, file_ids: List[str]) -> bool:
"""批量删除文件(回收站方式)。
POST /1/clouddrive/file/delete
Body: {
"action_type": 2,
"filelist": ["<fid1>", "<fid2>", ...]
}
action_type=1 表示彻底删除action_type=2 表示移入回收站。
Args:
file_ids: 要删除的文件 ID 列表。
Returns:
True 表示删除请求已提交成功False 表示失败。
Raises:
RuntimeError: HTTP 请求错误。
"""
if not file_ids:
logger.warning("[QuarkCleanup] delete_files called with empty list")
return True
url: str = f"{QUARK_FILE_API}/delete"
body: Dict[str, Any] = {
"action_type": 2, # 2=回收站, 1=彻底删除
"filelist": file_ids,
}
headers = self.credential.get_headers()
headers.setdefault("Content-Type", "application/json")
logger.info("[QuarkCleanup] Deleting %d files: %s", len(file_ids), file_ids)
try:
resp = self.session.post(url, json=body, headers=headers, timeout=self.timeout)
resp.raise_for_status()
except requests.RequestException as exc:
raise RuntimeError(f"删除文件失败: {exc}") from exc
data: Dict[str, Any] = resp.json()
status: int = data.get("status", -1)
if status != 0 and data.get("code") not in (0, None):
logger.error("[QuarkCleanup] Delete returned error: status=%s, message=%s",
status, data.get("message"))
return False
logger.info("[QuarkCleanup] Delete succeeded for %d files", len(file_ids))
return True
def delete_files_permanent(self, file_ids: List[str]) -> bool:
"""彻底删除文件(不从回收站恢复)。
与 delete_files 类似,但 action_type=1。
Args:
file_ids: 要彻底删除的文件 ID 列表。
Returns:
True 表示删除请求已提交成功。
"""
if not file_ids:
return True
url: str = f"{QUARK_FILE_API}/delete"
body: Dict[str, Any] = {
"action_type": 1, # 1=彻底删除
"filelist": file_ids,
}
headers = self.credential.get_headers()
headers.setdefault("Content-Type", "application/json")
logger.info("[QuarkCleanup] Permanently deleting %d files", len(file_ids))
try:
resp = self.session.post(url, json=body, headers=headers, timeout=self.timeout)
resp.raise_for_status()
except requests.RequestException as exc:
raise RuntimeError(f"彻底删除失败: {exc}") from exc
data: Dict[str, Any] = resp.json()
return data.get("status") == 0 or data.get("code") in (0, None)
@staticmethod
def filter_ads(
files: List[Dict[str, Any]],
banned_keywords: List[str],
) -> List[Dict[str, Any]]:
"""按关键词过滤文件列表中的广告文件。
遍历文件列表,剔除文件名中包含任一 banned_keywords 的文件。
匹配方式:不区分大小写的子串匹配。
Args:
files: 文件信息字典列表,每个字典需包含 "name" 字段。
banned_keywords: 被禁关键词列表(匹配不区分大小写)。
Returns:
过滤后的文件信息列表。
"""
if not banned_keywords:
return files
filtered: List[Dict[str, Any]] = []
removed_count: int = 0
for f in files:
name: str = f.get("name", "")
name_lower: str = str(name).lower()
if any(keyword.lower() in name_lower for keyword in banned_keywords):
logger.info("[QuarkCleanup] Filtered ad file: '%s'", name)
removed_count += 1
continue
filtered.append(f)
if removed_count > 0:
logger.info("[QuarkCleanup] Ad filter removed %d/%d files", removed_count, len(files))
return filtered
@staticmethod
def filter_ad_ids(
file_ids: List[str],
file_names: List[str],
banned_keywords: List[str],
) -> List[str]:
"""按关键词过滤文件 ID 列表。
根据 file_names 判断是否为广告,返回对应的 file_ids。
Args:
file_ids: 文件 ID 列表。
file_names: 与 file_ids 一一对应的 文件名列表。
banned_keywords: 被禁关键词列表。
Returns:
过滤后的 file_ids 列表。
"""
if not banned_keywords or len(file_ids) != len(file_names):
return file_ids
filtered_ids: List[str] = []
for fid, name in zip(file_ids, file_names):
name_lower: str = str(name).lower()
if any(kw.lower() in name_lower for kw in banned_keywords):
logger.info("[QuarkCleanup] Filtered ad file: '%s' (id=%s)", name, fid)
continue
filtered_ids.append(fid)
return filtered_ids
def close(self) -> None:
"""关闭 HTTP 会话。"""
self.session.close()
def __enter__(self) -> "QuarkCleanup":
return self
def __exit__(self, *args: Any) -> None:
self.close()

View File

@@ -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()})"
)

View File

@@ -0,0 +1,554 @@
"""
CloudSearch Transfer — 夸克网盘转存核心 v1.0.0
夸克网盘 7 步转存流程:
① POST .../share/sharepage/token → stoken
② GET .../share/sharepage/detail → fid, share_fid_token, title
③ POST .../share/sharepage/save → task_id (转存任务)
④ 轮询 GET .../task → save_as_top_fids (status==2 完成)
⑤ POST .../share → task_id (创建分享任务)
⑥ 轮询 GET .../task → share_id
⑦ POST .../share/password → share_url, passcode
参考 cloud-auto-save 的 quark.py 实现。
"""
from __future__ import annotations
import logging
import re
import time
from typing import Any, Dict, List, Optional, Tuple
import requests
from .credential import QuarkCredentialManager
logger = logging.getLogger(__name__)
# ─── 夸克 API 基础地址 ──────────────────────────────────────────────
QUARK_API_BASE = "https://drive-pc.quark.cn"
QUARK_SHARE_API = f"{QUARK_API_BASE}/1/clouddrive/share"
# ─── URL 解析正则 ───────────────────────────────────────────────────
# 匹配 pan.quark.cn/s/<share_id>
SHARE_URL_PATTERN = re.compile(r"pan\.quark\.cn/s/(\w+)")
class QuarkTransfer:
"""夸克网盘转存引擎。
封装完整的 7 步 API 流程:获取 stoken → 获取详情 → 保存文件 →
创建分享 → 设置密码。
Attributes:
credential: 夸克凭证管理器实例。
session: 复用的 requests.Session。
timeout: 请求超时(秒)。
poll_interval: 轮询间隔(秒)。
poll_max_attempts: 最大轮询次数。
"""
def __init__(
self,
credential: QuarkCredentialManager,
timeout: int = 30,
poll_interval: float = 0.5,
poll_max_attempts: int = 50,
) -> None:
"""初始化转存引擎。
Args:
credential: 有效的夸克凭证管理器。
timeout: HTTP 请求超时秒数。
poll_interval: 异步任务轮询间隔秒数。
poll_max_attempts: 异步任务最大轮询次数(默认 50同 base 层配置)。
"""
self.credential: QuarkCredentialManager = credential
self.timeout: int = timeout
self.poll_interval: float = poll_interval
self.poll_max_attempts: int = poll_max_attempts
self.session: requests.Session = requests.Session()
# ─── 步骤 ①:获取 stoken ───────────────────────────────────────
def _get_stoken(self, pwd_id: str, passcode: str = "") -> str:
"""步骤①:向夸克交换 stoken。
POST /1/clouddrive/share/sharepage/token
Body: {"passcode": "", "pwd_id": "<share_id>"}
Args:
pwd_id: 分享 ID从 URL 解析)。
passcode: 分享提取码,无密码时为空字符串。
Returns:
stoken 字符串。
Raises:
RuntimeError: API 返回错误或 stoken 缺失。
"""
url = f"{QUARK_SHARE_API}/sharepage/token"
body: Dict[str, str] = {
"passcode": passcode,
"pwd_id": pwd_id,
}
headers = self.credential.get_headers()
headers.setdefault("Content-Type", "application/json")
logger.info("[QuarkTransfer] ① Getting stoken for pwd_id=%s", pwd_id)
try:
resp = self.session.post(url, json=body, headers=headers, timeout=self.timeout)
resp.raise_for_status()
except requests.RequestException as exc:
raise RuntimeError(f"获取 stoken 失败: {exc}") from exc
data: Dict[str, Any] = resp.json()
stoken: Optional[str] = data.get("data", {}).get("stoken")
if not stoken:
raise RuntimeError(f"stoken 缺失, response: {data}")
logger.info("[QuarkTransfer] ① stoken obtained")
return stoken
# ─── 步骤 ②:获取分享详情 ─────────────────────────────────────
def _get_detail(self, pwd_id: str, stoken: str) -> Dict[str, Any]:
"""步骤②:获取分享详情。
GET /1/clouddrive/share/sharepage/detail?pwd_id=xx&stoken=xx&_fetch_share=1
返回字段包含title, fid, share_fid_token 等。
Args:
pwd_id: 分享 ID。
stoken: 步骤①获取的 stoken。
Returns:
分享详情字典。
Raises:
RuntimeError: API 返回错误。
"""
url = f"{QUARK_SHARE_API}/sharepage/detail"
params: Dict[str, str] = {
"pwd_id": pwd_id,
"stoken": stoken,
"_fetch_share": "1",
}
headers = self.credential.get_headers()
logger.info("[QuarkTransfer] ② Fetching share detail for pwd_id=%s", pwd_id)
try:
resp = self.session.get(url, params=params, headers=headers, timeout=self.timeout)
resp.raise_for_status()
except requests.RequestException as exc:
raise RuntimeError(f"获取分享详情失败: {exc}") from exc
data: Dict[str, Any] = resp.json()
status: int = data.get("status", -1)
if status != 0 and data.get("code") not in (0, None):
raise RuntimeError(f"分享详情API返回错误: status={status}, message={data.get('message')}")
detail: Optional[Dict[str, Any]] = data.get("data")
if not detail:
raise RuntimeError(f"分享详情数据为空, response: {data}")
# 提取关键字段供后续使用
logger.info(
"[QuarkTransfer] ② Detail: title=%s, fid=%s",
detail.get("title"),
detail.get("fid"),
)
return detail
# ─── 步骤 ③:发起转存 ─────────────────────────────────────────
def _init_save(self, pwd_id: str, stoken: str, detail: Dict[str, Any],
to_pdir_fid: str = "0") -> str:
"""步骤③:发起转存请求。
POST /1/clouddrive/share/sharepage/save
Body: {
"fid_list": [<fid>, ...],
"fid_token_list": [<share_fid_token>, ...],
"to_pdir_fid": "0",
"pwd_id": "<pwd_id>",
"stoken": "<stoken>",
"pdir_fid": "0",
"scene": "link"
}
Args:
pwd_id: 分享 ID。
stoken: stoken。
detail: 步骤②的分享详情。
to_pdir_fid: 目标目录 ID默认 "0" 即根目录。
Returns:
task_id 字符串,用于步骤④轮询。
Raises:
RuntimeError: API 返回错误。
"""
url = f"{QUARK_SHARE_API}/sharepage/save"
fid_list: List[str] = detail.get("fid_list", [detail.get("fid", [])])
fid_token_list: List[str] = detail.get("fid_token_list", [detail.get("share_fid_token", [])])
# 如果 detail 的 fid/fid_token 是单值而非列表,则包装为列表
if not isinstance(fid_list, list):
fid_list = [fid_list] if fid_list else []
if not isinstance(fid_token_list, list):
fid_token_list = [fid_token_list] if fid_token_list else []
body: Dict[str, Any] = {
"fid_list": fid_list,
"fid_token_list": fid_token_list,
"to_pdir_fid": to_pdir_fid,
"pwd_id": pwd_id,
"stoken": stoken,
"pdir_fid": "0",
"scene": "link",
}
headers = self.credential.get_headers()
headers.setdefault("Content-Type", "application/json")
logger.info("[QuarkTransfer] ③ Initiating save: %d files to dir=%s", len(fid_list), to_pdir_fid)
try:
resp = self.session.post(url, json=body, headers=headers, timeout=self.timeout)
resp.raise_for_status()
except requests.RequestException as exc:
raise RuntimeError(f"发起转存失败: {exc}") from exc
data: Dict[str, Any] = resp.json()
status: int = data.get("status", -1)
if status != 0:
raise RuntimeError(f"转存请求失败: status={status}, message={data.get('message')}")
task_id: Optional[str] = data.get("data", {}).get("task_id")
if not task_id:
raise RuntimeError(f"转存 task_id 缺失, response: {data}")
logger.info("[QuarkTransfer] ③ Save task created: task_id=%s", task_id)
return task_id
# ─── 步骤 ④:轮询转存任务 ─────────────────────────────────────
def _poll_save_task(self, task_id: str) -> List[str]:
"""步骤④:轮询转存任务直到完成。
GET /1/clouddrive/task?task_id=<task_id>&retry_index=0
轮询最多 poll_max_attempts 次,
当 status==2 时表示任务成功完成,
status==-1 表示失败。
Args:
task_id: 步骤③返回的 task_id。
Returns:
save_as_top_fids 列表(转存后的文件 ID
Raises:
RuntimeError: 任务失败或超时。
"""
url = f"{QUARK_API_BASE}/1/clouddrive/task"
headers = self.credential.get_headers()
for attempt in range(1, self.poll_max_attempts + 1):
params: Dict[str, str] = {
"task_id": task_id,
"retry_index": "0",
}
try:
resp = self.session.get(url, params=params, headers=headers, timeout=self.timeout)
resp.raise_for_status()
except requests.RequestException:
logger.warning("[QuarkTransfer] ④ Poll attempt %d/%d failed, retrying...",
attempt, self.poll_max_attempts)
time.sleep(self.poll_interval)
continue
data: Dict[str, Any] = resp.json()
task_status: int = data.get("data", {}).get("status", -1)
logger.debug("[QuarkTransfer] ④ Poll %d/%d: status=%d", attempt, self.poll_max_attempts, task_status)
if task_status == 2: # 成功
save_as_top_fids: List[str] = (
data.get("data", {}).get("save_as", {}).get("save_as_top_fids", [])
)
logger.info("[QuarkTransfer] ④ Save completed: %d files saved", len(save_as_top_fids))
return save_as_top_fids
if task_status == -1:
raise RuntimeError(f"转存任务失败: task_id={task_id}, response={data}")
time.sleep(self.poll_interval)
raise RuntimeError(
f"转存任务超时: task_id={task_id}, 已轮询 {self.poll_max_attempts}"
)
# ─── 步骤 ⑤:发起创建分享 ─────────────────────────────────────
def _init_share(self, fid_list: List[str], title: str,
expired_type: int = 1) -> str:
"""步骤⑤:创建分享链接。
POST /1/clouddrive/share
Body: {
"fid_list": [<fid>, ...],
"title": "<title>",
"expired_type": 1
}
Args:
fid_list: 要分享的文件 ID 列表。
title: 分享标题。
expired_type: 过期类型1=永久有效(默认)。
Returns:
task_id 字符串,用于步骤⑥轮询。
Raises:
RuntimeError: API 返回错误。
"""
url = f"{QUARK_SHARE_API}"
body: Dict[str, Any] = {
"fid_list": fid_list,
"title": title or "分享",
"expired_type": expired_type,
}
headers = self.credential.get_headers()
headers.setdefault("Content-Type", "application/json")
logger.info("[QuarkTransfer] ⑤ Creating share: %d files, title='%s'", len(fid_list), title)
try:
resp = self.session.post(url, json=body, headers=headers, timeout=self.timeout)
resp.raise_for_status()
except requests.RequestException as exc:
raise RuntimeError(f"创建分享失败: {exc}") from exc
data: Dict[str, Any] = resp.json()
status: int = data.get("status", -1)
if status != 0 and data.get("code") not in (0, None):
raise RuntimeError(f"创建分享请求失败: status={status}, message={data.get('message')}")
task_id: Optional[str] = data.get("data", {}).get("task_id")
if not task_id:
raise RuntimeError(f"分享 task_id 缺失, response: {data}")
logger.info("[QuarkTransfer] ⑤ Share task created: task_id=%s", task_id)
return task_id
# ─── 步骤 ⑥:轮询分享任务 ─────────────────────────────────────
def _poll_share_task(self, task_id: str) -> str:
"""步骤⑥:轮询分享任务直到完成。
GET /1/clouddrive/task?task_id=<task_id>&retry_index=0
轮询最多 poll_max_attempts 次status==2 完成,
返回 share_id。
Args:
task_id: 步骤⑤返回的 task_id。
Returns:
share_id 字符串。
Raises:
RuntimeError: 任务失败或超时。
"""
url = f"{QUARK_API_BASE}/1/clouddrive/task"
headers = self.credential.get_headers()
for attempt in range(1, self.poll_max_attempts + 1):
params: Dict[str, str] = {
"task_id": task_id,
"retry_index": "0",
}
try:
resp = self.session.get(url, params=params, headers=headers, timeout=self.timeout)
resp.raise_for_status()
except requests.RequestException:
logger.warning("[QuarkTransfer] ⑥ Poll attempt %d/%d failed, retrying...",
attempt, self.poll_max_attempts)
time.sleep(self.poll_interval)
continue
data: Dict[str, Any] = resp.json()
task_status: int = data.get("data", {}).get("status", -1)
logger.debug("[QuarkTransfer] ⑥ Poll %d/%d: status=%d", attempt, self.poll_max_attempts, task_status)
if task_status == 2: # 成功
share_id: Optional[str] = data.get("data", {}).get("share_id")
if not share_id:
# 有时 share_id 在嵌套位置
share_id = data.get("data", {}).get("result", {}).get("share_id", "")
if not share_id:
raise RuntimeError(f"分享完成但 share_id 缺失: {data}")
logger.info("[QuarkTransfer] ⑥ Share completed: share_id=%s", share_id)
return share_id
if task_status == -1:
raise RuntimeError(f"分享任务失败: task_id={task_id}, response={data}")
time.sleep(self.poll_interval)
raise RuntimeError(
f"分享任务超时: task_id={task_id}, 已轮询 {self.poll_max_attempts}"
)
# ─── 步骤 ⑦:设置分享密码 ─────────────────────────────────────
def _set_password(self, share_id: str, password: str = "") -> Tuple[str, str]:
"""步骤⑦:设置分享密码并获取分享链接。
POST /1/clouddrive/share/password
Body: {"share_id": "<share_id>"}
即使不设密码也要调用此 API 以获取正式的 share_url。
Args:
share_id: 步骤⑥返回的 share_id。
password: 分享密码,空字符串表示无密码。
Returns:
(share_url, passcode) 元组。
Raises:
RuntimeError: API 返回错误。
"""
url = f"{QUARK_SHARE_API}/password"
body: Dict[str, str] = {
"share_id": share_id,
}
headers = self.credential.get_headers()
headers.setdefault("Content-Type", "application/json")
logger.info("[QuarkTransfer] ⑦ Setting password for share_id=%s", share_id)
try:
resp = self.session.post(url, json=body, headers=headers, timeout=self.timeout)
resp.raise_for_status()
except requests.RequestException as exc:
raise RuntimeError(f"设置分享密码失败: {exc}") from exc
data: Dict[str, Any] = resp.json()
status: int = data.get("status", -1)
if status != 0 and data.get("code") not in (0, None):
raise RuntimeError(f"设置密码失败: status={status}, message={data.get('message')}")
share_url: str = data.get("data", {}).get("share_url", "")
passcode: str = data.get("data", {}).get("passcode", password)
if not share_url:
# 用 share_id 构造默认分享链接
share_url = f"https://pan.quark.cn/s/{share_id}"
logger.info("[QuarkTransfer] ⑦ Password set: share_url=%s, passcode=%s", share_url, passcode)
return share_url, passcode
# ─── 公开入口 ─────────────────────────────────────────────────
def transfer(
self,
share_url: str,
save_dir: str = "0",
share_password: str = "",
) -> Dict[str, Any]:
"""执行完整的 7 步转存流程。
从原始夸克分享链接开始,将文件转存到自己网盘,再创建新分享。
Args:
share_url: 原始夸克分享链接,如 https://pan.quark.cn/s/xxxxx。
save_dir: 转存目标目录 ID默认 "0"(根目录)。
share_password: 新分享的密码,空字符串表示无密码。
Returns:
包含以下字段的字典:
- success: bool
- new_file_ids: List[str] — 转存后的文件ID列表
- file_name: str — 分享标题
- share_url: str — 新分享链接
- passcode: str — 新分享密码
Raises:
RuntimeError: 任一步骤失败。
ValueError: URL 解析失败。
"""
# 0. 解析 URL 提取 pwd_id
match = SHARE_URL_PATTERN.search(share_url)
if not match:
raise ValueError(f"无法从URL中提取夸克分享ID: {share_url}")
pwd_id: str = match.group(1)
logger.info("[QuarkTransfer] Starting 7-step transfer for pwd_id=%s", pwd_id)
# ① 获取 stoken
stoken: str = self._get_stoken(pwd_id)
# ② 获取分享详情
detail: Dict[str, Any] = self._get_detail(pwd_id, stoken)
# ③ 发起转存
save_task_id: str = self._init_save(pwd_id, stoken, detail, to_pdir_fid=save_dir)
# ④ 轮询转存任务
new_fids: List[str] = self._poll_save_task(save_task_id)
if not new_fids:
raise RuntimeError("转存完成但未获取到文件ID")
# ⑤ 发起创建分享
title: str = detail.get("title", "分享")
share_task_id: str = self._init_share(new_fids, title)
# ⑥ 轮询分享任务
share_id: str = self._poll_share_task(share_task_id)
# ⑦ 设置密码
new_share_url, passcode = self._set_password(share_id, share_password)
result: Dict[str, Any] = {
"success": True,
"new_file_ids": new_fids,
"file_name": title,
"share_url": new_share_url,
"passcode": passcode,
}
logger.info("[QuarkTransfer] 7-step transfer complete: %s", result)
return result
@staticmethod
def parse_share_url(url: str) -> Optional[str]:
"""从夸克分享链接中提取 pwd_id。
Args:
url: 夸克分享链接。
Returns:
pwd_id 字符串,解析失败返回 None。
"""
match = SHARE_URL_PATTERN.search(url)
return match.group(1) if match else None
def close(self) -> None:
"""关闭 HTTP 会话。"""
self.session.close()
def __enter__(self) -> "QuarkTransfer":
return self
def __exit__(self, *args: Any) -> None:
self.close()

View File

@@ -0,0 +1,493 @@
"""
CloudSearch Transfer — UC网盘适配器 v1.0.0
将 UcCredentialManager、UcTransfer、UcCleanup 组合为
BaseCloudDriveAdapter 的完整实现。
UC网盘 7 步 API 转存流程与夸克高度相似API 域名不同):
① POST .../share/sharepage/v2/detail?pr=UCBrowser&fr=pc → stoken
② GET .../share/sharepage/detail → fid, share_fid_token, title
③ POST .../share/sharepage/save → task_id (转存)
④ 轮询 GET .../task → save_as_top_fids
⑤ POST .../share → task_id (创建分享)
⑥ 轮询 GET .../task → share_id
⑦ POST .../share/password → share_url, passcode
参考 cloud-auto-save 的 quark 实现,域名从 drive-pc.quark.cn 改为 pc-api.uc.cn。
"""
from __future__ import annotations
import logging
import time
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import urlparse, parse_qs
from ..base import BaseCloudDriveAdapter, FileInfo, TransferResult, VerifyResult
from ...config import PlatformConfig, TransferConfig
from ...errors import TransferError, TransferErrorCode
from .credential import UcCredentialManager
from .transfer import UcTransfer, SHARE_URL_PATTERN
from .cleanup import UcCleanup
logger = logging.getLogger(__name__)
class UcAdapter(BaseCloudDriveAdapter):
"""UC网盘适配器。
组合 credential / transfer / cleanup 三个模块,
实现 BaseCloudDriveAdapter 定义的所有抽象方法。
Attributes:
PLATFORM_NAME: 展示用平台名称。
PLATFORM_KEY: 内部平台标识。
URL_PATTERNS: UC 分享链接匹配正则列表。
"""
# ─── 平台标识 ──────────────────────────────────────────────
PLATFORM_NAME: str = "UC网盘"
PLATFORM_KEY: str = "uc"
# ─── URL 匹配 ──────────────────────────────────────────────
# 支持 drive.uc.cn/s/<share_id>
URL_PATTERNS: List[str] = [
r"drive\.uc\.cn/s/(\w+)",
]
def __init__(self, config: PlatformConfig, transfer_config: TransferConfig) -> None:
"""初始化 UC 适配器。
Args:
config: 平台配置(含 Cookie 等)。
transfer_config: 全局转存配置(超时、重试、轮询参数等)。
"""
super().__init__(config, transfer_config)
# 初始化三个子模块
self._credential: UcCredentialManager = UcCredentialManager(
cookie=config.cookie
)
self._transfer_engine: UcTransfer = UcTransfer(
credential=self._credential,
timeout=transfer_config.request_timeout,
poll_interval=transfer_config.task_poll_interval,
poll_max_attempts=transfer_config.task_poll_max_attempts,
)
self._cleanup: UcCleanup = UcCleanup(
credential=self._credential,
timeout=transfer_config.request_timeout,
)
# ═══════════════════════════════════════════════════════════════
# 公开接口实现
# ═══════════════════════════════════════════════════════════════
def _setup_session(self) -> None:
"""将 UC Cookie 注入 session 的默认 headers。"""
headers = self._credential.get_headers()
if headers:
self.session.headers.update(headers)
logger.debug("[UcAdapter] Session headers updated with Cookie")
def transfer(self, share_url: str, save_dir: str = "",
share_password: str = "") -> TransferResult:
"""执行转存的核心逻辑(覆盖基类实现 UC 专用流程)。
通过 UcTransfer 引擎执行完整的 7 步流程。
Args:
share_url: UC 分享链接。
save_dir: 目标目录,空则使用配置的默认目录。
share_password: 新分享的密码。
Returns:
TransferResult 包含转存结果。
"""
start: float = time.time()
# 凭证检查
if not self._credential.validate():
raise TransferError(
TransferErrorCode.NOT_LOGIN,
message="UC Cookie 无效或长度不足",
platform=self.PLATFORM_KEY,
)
# 目标目录:默认根目录 "0"
target_dir: str = save_dir or self.config.save_dir or "0"
# 分享密码
pwd: str = share_password or self.config.share_password or ""
try:
result: Dict[str, Any] = self._transfer_engine.transfer(
share_url=share_url,
save_dir=target_dir,
share_password=pwd,
)
except ValueError as exc:
raise TransferError(
TransferErrorCode.URL_INVALID,
message=str(exc),
platform=self.PLATFORM_KEY,
) from exc
except RuntimeError as exc:
msg: str = str(exc)
if "stoken" in msg or "status" in msg:
raise TransferError(
TransferErrorCode.SHARE_NOT_EXIST,
message=msg,
platform=self.PLATFORM_KEY,
) from exc
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=msg,
platform=self.PLATFORM_KEY,
) from exc
elapsed: int = int((time.time() - start) * 1000)
# 广告过滤
new_fids: List[str] = result.get("new_file_ids", [])
if self.transfer_config.ad_filter_enabled and new_fids:
new_fids = self._filter_ads(new_fids)
if not new_fids:
raise TransferError(
TransferErrorCode.RESOURCE_EMPTY,
platform=self.PLATFORM_KEY,
)
return TransferResult(
success=True,
platform=self.PLATFORM_KEY,
new_file_id=",".join(new_fids),
file_name=result.get("file_name", ""),
share_url=result.get("share_url", ""),
share_password=result.get("passcode", pwd),
original_url=share_url,
elapsed_ms=elapsed,
)
def verify(self, share_url: str) -> VerifyResult:
"""验证 UC 分享链接有效性。
Args:
share_url: UC 分享链接。
Returns:
VerifyResult 包含验证结果。
"""
try:
pwd_id, passcode = self._parse_share_url(share_url)
if not self._credential.validate():
return VerifyResult(
valid=False,
platform=self.PLATFORM_KEY,
error=TransferError(
TransferErrorCode.NOT_LOGIN,
platform=self.PLATFORM_KEY,
),
)
stoken: str = self._transfer_engine._get_stoken(pwd_id, passcode)
detail: Dict[str, Any] = self._transfer_engine._get_detail(pwd_id, stoken)
files: List[FileInfo] = self._extract_file_list(detail)
return VerifyResult(
valid=True,
platform=self.PLATFORM_KEY,
title=detail.get("title", ""),
file_count=len(files),
files=files,
)
except TransferError:
raise
except (ValueError, RuntimeError) as exc:
return VerifyResult(
valid=False,
platform=self.PLATFORM_KEY,
error=TransferError(
TransferErrorCode.SHARE_NOT_EXIST,
message=str(exc),
platform=self.PLATFORM_KEY,
),
)
except Exception as exc:
return VerifyResult(
valid=False,
platform=self.PLATFORM_KEY,
error=TransferError(
TransferErrorCode.NETWORK_ERROR,
message=str(exc),
platform=self.PLATFORM_KEY,
),
)
# ─── 核心抽象方法 ─────────────────────────────────────────
def _get_share_detail(self, pwd_id: str, passcode: str = "") -> dict:
"""获取 UC 分享详情。
Args:
pwd_id: 分享 ID。
passcode: 提取码。
Returns:
分享详情字典,包含 title, fid, share_fid_token 等字段。
"""
stoken: str = self._transfer_engine._get_stoken(pwd_id, passcode)
return self._transfer_engine._get_detail(pwd_id, stoken)
def _save_files(self, pwd_id: str, detail: dict, save_dir: str) -> List[str]:
"""转存文件到自己的 UC 网盘。
Args:
pwd_id: 分享 ID。
detail: 分享详情(来自 _get_share_detail
save_dir: 目标目录 ID。
Returns:
转存后的新文件 ID 列表。
"""
stoken: str = self._transfer_engine._get_stoken(pwd_id)
task_id: str = self._transfer_engine._init_save(
pwd_id, stoken, detail, to_pdir_fid=save_dir
)
return self._transfer_engine._poll_save_task(task_id)
def _create_share(
self, file_ids: List[str], title: str, password: str = ""
) -> Tuple[str, str]:
"""创建 UC 分享链接。
Args:
file_ids: 要分享的文件 ID 列表。
title: 分享标题。
password: 分享密码。
Returns:
(share_url, share_password) 元组。
"""
task_id: str = self._transfer_engine._init_share(file_ids, title)
share_id: str = self._transfer_engine._poll_share_task(task_id)
return self._transfer_engine._set_password(share_id, password)
def _extract_file_list(self, detail: dict) -> List[FileInfo]:
"""从 UC 分享详情中提取文件列表。
UC 的 sharepage/detail 返回格式与夸克一致:
{
"files": [
{"fid": "...", "file_name": "...", "size": 123, "dir": false, ...},
]
}
Args:
detail: 分享详情字典。
Returns:
FileInfo 对象列表。
"""
files_data: List[Dict[str, Any]] = detail.get("files", [])
result: List[FileInfo] = []
for f in files_data:
file_info = FileInfo(
fid=str(f.get("fid", f.get("file_id", ""))),
name=str(f.get("file_name", f.get("name", ""))),
size=int(f.get("size", 0)),
is_dir=bool(f.get("dir", f.get("is_dir", False))),
ext=str(f.get("ext", f.get("file_extension", ""))),
)
result.append(file_info)
# 如果 files 为空,尝试用 detail 顶层字段构造单个文件信息
if not result and detail.get("fid"):
result.append(
FileInfo(
fid=str(detail.get("fid", "")),
name=str(detail.get("title", detail.get("file_name", ""))),
size=0,
is_dir=False,
)
)
return result
def _filter_ads(self, file_ids: List[str]) -> List[str]:
"""过滤广告文件。
Args:
file_ids: 文件 ID 列表。
Returns:
过滤后的文件 ID 列表。
"""
keywords: List[str] = list(
set(self.config.banned_keywords)
| set(self.transfer_config.default_banned_keywords)
)
if not keywords:
return file_ids
try:
files: List[FileInfo] = self.get_files()
file_names: List[str] = [f.name for f in files]
return UcCleanup.filter_ad_ids(file_ids, file_names, keywords)
except Exception:
logger.warning(
"[UcAdapter] Cannot fetch file list for ad filtering, skipping"
)
return file_ids
# ─── get_files / delete ────────────────────────────────────
def get_files(self, parent_fid: str = "0") -> List[FileInfo]:
"""列出 UC 网盘指定目录下的文件。
GET /1/clouddrive/file/sort?pdir_fid=<parent_fid>&_page=1&_size=100&_sort=updated_at:desc
Args:
parent_fid: 父目录 ID默认 "0" 即根目录。
Returns:
FileInfo 列表。
"""
url: str = f"https://pc-api.uc.cn/1/clouddrive/file/sort"
params: Dict[str, str] = {
"pdir_fid": parent_fid,
"_page": "1",
"_size": "100",
"_sort": "updated_at:desc",
}
headers: Dict[str, str] = self._credential.get_headers()
try:
resp = self._get(url, params=params, headers=headers)
except Exception as exc:
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=f"获取文件列表失败: {exc}",
platform=self.PLATFORM_KEY,
) from exc
data: Dict[str, Any] = resp.json()
status: int = data.get("status", -1)
if status != 0 and data.get("code") not in (0, None):
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=f"获取文件列表失败: {data.get('message')}",
platform=self.PLATFORM_KEY,
)
files_data: List[Dict[str, Any]] = data.get("data", {}).get("list", [])
result: List[FileInfo] = []
for f in files_data:
result.append(
FileInfo(
fid=str(f.get("fid", "")),
name=str(f.get("file_name", f.get("name", ""))),
size=int(f.get("size", 0)),
is_dir=bool(f.get("dir", f.get("is_dir", False))),
ext=str(f.get("file_extension", f.get("ext", ""))),
)
)
logger.debug("[UcAdapter] Listed %d files in dir=%s", len(result), parent_fid)
return result
def delete(self, file_ids: List[str]) -> bool:
"""删除 UC 网盘文件(移到回收站)。
Args:
file_ids: 要删除的文件 ID 列表。
Returns:
True 表示删除成功。
"""
if not self._credential.validate():
raise TransferError(
TransferErrorCode.NOT_LOGIN,
platform=self.PLATFORM_KEY,
)
try:
return self._cleanup.delete_files(file_ids)
except RuntimeError as exc:
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=str(exc),
platform=self.PLATFORM_KEY,
) from exc
def delete_permanent(self, file_ids: List[str]) -> bool:
"""彻底删除 UC 网盘文件(不可恢复)。
Args:
file_ids: 要彻底删除的文件 ID 列表。
Returns:
True 表示删除成功。
"""
if not self._credential.validate():
raise TransferError(
TransferErrorCode.NOT_LOGIN,
platform=self.PLATFORM_KEY,
)
try:
return self._cleanup.delete_files_permanent(file_ids)
except RuntimeError as exc:
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=str(exc),
platform=self.PLATFORM_KEY,
) from exc
# ─── 工具方法 ─────────────────────────────────────────────
def _parse_share_url(self, url: str) -> Tuple[str, str]:
"""解析 UC 分享 URL 提取 (pwd_id, passcode)。
UC 链接格式https://drive.uc.cn/s/<pwd_id> 或带 ?pwd=xxxx
Args:
url: UC 分享链接。
Returns:
(pwd_id, passcode) 元组。
Raises:
TransferError: URL 格式无法识别。
"""
pwd_id: Optional[str] = UcTransfer.parse_share_url(url)
if not pwd_id:
raise TransferError(
TransferErrorCode.URL_INVALID,
message=f"无法解析UC链接: {url}",
platform=self.PLATFORM_KEY,
)
parsed = urlparse(url)
params = parse_qs(parsed.query)
passcode: str = params.get("pwd", params.get("code", [""]))[0]
return pwd_id, passcode
def update_cookie(self, cookie: str) -> None:
"""动态更新 Cookie 并同步到 session headers。
Args:
cookie: 新的 Cookie 字符串。
"""
self._credential.update_cookie(cookie)
self._setup_session()
logger.info("[UcAdapter] Cookie updated, new length=%d", len(cookie))
def close(self) -> None:
"""关闭所有子模块的 HTTP 会话。"""
self._transfer_engine.close()

View File

@@ -0,0 +1,218 @@
"""
CloudSearch Transfer — UC网盘清理模块 v1.0.0
提供文件删除和广告过滤功能。API 与夸克相同,仅域名不同。
"""
from __future__ import annotations
import logging
from typing import Any, Dict, List
import requests
from .credential import UcCredentialManager
logger = logging.getLogger(__name__)
# ─── UC API ─────────────────────────────────────────────────────────
UC_API_BASE = "https://pc-api.uc.cn"
UC_FILE_API = f"{UC_API_BASE}/1/clouddrive/file"
class UcCleanup:
"""UC 网盘文件清理器。
提供批量删除文件和广告文件过滤功能。
Attributes:
credential: UC 凭证管理器。
session: 复用的 requests.Session。
timeout: HTTP 请求超时秒数。
"""
def __init__(
self,
credential: UcCredentialManager,
timeout: int = 30,
) -> None:
"""初始化清理器。
Args:
credential: 有效的 UC 凭证管理器。
timeout: HTTP 请求超时秒数。
"""
self.credential: UcCredentialManager = credential
self.timeout: int = timeout
self.session: requests.Session = requests.Session()
def delete_files(self, file_ids: List[str]) -> bool:
"""批量删除文件(回收站方式)。
POST /1/clouddrive/file/delete
Body: {
"action_type": 2,
"filelist": ["<fid1>", "<fid2>", ...]
}
action_type=1 表示彻底删除action_type=2 表示移入回收站。
Args:
file_ids: 要删除的文件 ID 列表。
Returns:
True 表示删除请求已提交成功False 表示失败。
Raises:
RuntimeError: HTTP 请求错误。
"""
if not file_ids:
logger.warning("[UcCleanup] delete_files called with empty list")
return True
url: str = f"{UC_FILE_API}/delete"
body: Dict[str, Any] = {
"action_type": 2, # 2=回收站, 1=彻底删除
"filelist": file_ids,
}
headers = self.credential.get_headers()
headers.setdefault("Content-Type", "application/json")
logger.info("[UcCleanup] Deleting %d files: %s", len(file_ids), file_ids)
try:
resp = self.session.post(
url, json=body, headers=headers, timeout=self.timeout
)
resp.raise_for_status()
except requests.RequestException as exc:
raise RuntimeError(f"删除文件失败: {exc}") from exc
data: Dict[str, Any] = resp.json()
status: int = data.get("status", -1)
if status != 0 and data.get("code") not in (0, None):
logger.error(
"[UcCleanup] Delete returned error: status=%s, message=%s",
status,
data.get("message"),
)
return False
logger.info("[UcCleanup] Delete succeeded for %d files", len(file_ids))
return True
def delete_files_permanent(self, file_ids: List[str]) -> bool:
"""彻底删除文件(不从回收站恢复)。
与 delete_files 类似,但 action_type=1。
Args:
file_ids: 要彻底删除的文件 ID 列表。
Returns:
True 表示删除请求已提交成功。
"""
if not file_ids:
return True
url: str = f"{UC_FILE_API}/delete"
body: Dict[str, Any] = {
"action_type": 1, # 1=彻底删除
"filelist": file_ids,
}
headers = self.credential.get_headers()
headers.setdefault("Content-Type", "application/json")
logger.info("[UcCleanup] Permanently deleting %d files", len(file_ids))
try:
resp = self.session.post(
url, json=body, headers=headers, timeout=self.timeout
)
resp.raise_for_status()
except requests.RequestException as exc:
raise RuntimeError(f"彻底删除失败: {exc}") from exc
data: Dict[str, Any] = resp.json()
return data.get("status") == 0 or data.get("code") in (0, None)
@staticmethod
def filter_ads(
files: List[Dict[str, Any]],
banned_keywords: List[str],
) -> List[Dict[str, Any]]:
"""按关键词过滤文件列表中的广告文件。
遍历文件列表,剔除文件名中包含任一 banned_keywords 的文件。
匹配方式:不区分大小写的子串匹配。
Args:
files: 文件信息字典列表,每个字典需包含 "name" 字段。
banned_keywords: 被禁关键词列表(匹配不区分大小写)。
Returns:
过滤后的文件信息列表。
"""
if not banned_keywords:
return files
filtered: List[Dict[str, Any]] = []
removed_count: int = 0
for f in files:
name: str = f.get("name", "")
name_lower: str = str(name).lower()
if any(keyword.lower() in name_lower for keyword in banned_keywords):
logger.info("[UcCleanup] Filtered ad file: '%s'", name)
removed_count += 1
continue
filtered.append(f)
if removed_count > 0:
logger.info(
"[UcCleanup] Ad filter removed %d/%d files", removed_count, len(files)
)
return filtered
@staticmethod
def filter_ad_ids(
file_ids: List[str],
file_names: List[str],
banned_keywords: List[str],
) -> List[str]:
"""按关键词过滤文件 ID 列表。
根据 file_names 判断是否为广告,返回对应的 file_ids。
Args:
file_ids: 文件 ID 列表。
file_names: 与 file_ids 一一对应的文件名列表。
banned_keywords: 被禁关键词列表。
Returns:
过滤后的 file_ids 列表。
"""
if not banned_keywords or len(file_ids) != len(file_names):
return file_ids
filtered_ids: List[str] = []
for fid, name in zip(file_ids, file_names):
name_lower: str = str(name).lower()
if any(kw.lower() in name_lower for kw in banned_keywords):
logger.info("[UcCleanup] Filtered ad file: '%s' (id=%s)", name, fid)
continue
filtered_ids.append(fid)
return filtered_ids
def close(self) -> None:
"""关闭 HTTP 会话。"""
self.session.close()
def __enter__(self) -> "UcCleanup":
return self
def __exit__(self, *args: Any) -> None:
self.close()

View File

@@ -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()})"
)

View File

@@ -0,0 +1,619 @@
"""
CloudSearch Transfer — UC网盘转存核心 v1.0.0
UC网盘 7 步转存流程与夸克高度相似API 域名不同):
① POST .../share/sharepage/v2/detail?pr=UCBrowser&fr=pc → stoken
② GET .../share/sharepage/detail → fid, share_fid_token, title
③ POST .../share/sharepage/save → task_id (转存)
④ 轮询 GET .../task → save_as_top_fids (status==2 完成)
⑤ POST .../share → task_id (创建分享)
⑥ 轮询 GET .../task → share_id
⑦ POST .../share/password → share_url, passcode
参考 cloud-auto-save 的 quark 实现,域名从 drive-pc.quark.cn 改为 pc-api.uc.cn。
"""
from __future__ import annotations
import logging
import re
import time
from typing import Any, Dict, List, Optional, Tuple
import requests
from .credential import UcCredentialManager
logger = logging.getLogger(__name__)
# ─── UC API 基础地址 ────────────────────────────────────────────────
UC_API_BASE = "https://pc-api.uc.cn"
UC_SHARE_API = f"{UC_API_BASE}/1/clouddrive/share"
# ─── URL 解析正则 ───────────────────────────────────────────────────
# 匹配 drive.uc.cn/s/<share_id>
SHARE_URL_PATTERN = re.compile(r"drive\.uc\.cn/s/(\w+)")
class UcTransfer:
"""UC 网盘转存引擎。
封装完整的 7 步 API 流程:获取 stoken → 获取详情 → 保存文件 →
创建分享 → 设置密码。
Attributes:
credential: UC 凭证管理器实例。
session: 复用的 requests.Session。
timeout: 请求超时(秒)。
poll_interval: 轮询间隔(秒)。
poll_max_attempts: 最大轮询次数。
"""
def __init__(
self,
credential: UcCredentialManager,
timeout: int = 30,
poll_interval: float = 0.5,
poll_max_attempts: int = 50,
) -> None:
"""初始化转存引擎。
Args:
credential: 有效的 UC 凭证管理器。
timeout: HTTP 请求超时秒数。
poll_interval: 异步任务轮询间隔秒数。
poll_max_attempts: 异步任务最大轮询次数。
"""
self.credential: UcCredentialManager = credential
self.timeout: int = timeout
self.poll_interval: float = poll_interval
self.poll_max_attempts: int = poll_max_attempts
self.session: requests.Session = requests.Session()
# ─── 步骤 ①:获取 stoken ───────────────────────────────────────
def _get_stoken(self, pwd_id: str, passcode: str = "") -> str:
"""步骤①:向 UC 交换 stoken。
POST /1/clouddrive/share/sharepage/v2/detail?pr=UCBrowser&fr=pc
Body: {"passcode": "", "pwd_id": "<share_id>"}
响应: data.token_info.stoken
UC 使用 v2/detail 接口获取 stoken与夸克的 sharepage/token 不同。
Args:
pwd_id: 分享 ID从 URL 解析)。
passcode: 分享提取码,无密码时为空字符串。
Returns:
stoken 字符串。
Raises:
RuntimeError: API 返回错误或 stoken 缺失。
"""
url = f"{UC_SHARE_API}/sharepage/v2/detail"
params: Dict[str, str] = {
"pr": "UCBrowser",
"fr": "pc",
}
body: Dict[str, str] = {
"passcode": passcode,
"pwd_id": pwd_id,
}
headers = self.credential.get_headers()
headers.setdefault("Content-Type", "application/json")
logger.info("[UcTransfer] ① Getting stoken for pwd_id=%s", pwd_id)
try:
resp = self.session.post(
url, json=body, params=params, headers=headers, timeout=self.timeout
)
resp.raise_for_status()
except requests.RequestException as exc:
raise RuntimeError(f"获取 stoken 失败: {exc}") from exc
data: Dict[str, Any] = resp.json()
# UC 的 stoken 在 data.token_info.stoken
stoken: Optional[str] = data.get("data", {}).get("token_info", {}).get("stoken")
if not stoken:
raise RuntimeError(f"stoken 缺失, response: {data}")
logger.info("[UcTransfer] ① stoken obtained")
return stoken
# ─── 步骤 ②:获取分享详情 ─────────────────────────────────────
def _get_detail(self, pwd_id: str, stoken: str) -> Dict[str, Any]:
"""步骤②:获取分享详情。
GET /1/clouddrive/share/sharepage/detail?pwd_id=xx&stoken=xx&_fetch_share=1
返回字段包含title, fid, share_fid_token 等。
Args:
pwd_id: 分享 ID。
stoken: 步骤①获取的 stoken。
Returns:
分享详情字典。
Raises:
RuntimeError: API 返回错误。
"""
url = f"{UC_SHARE_API}/sharepage/detail"
params: Dict[str, str] = {
"pwd_id": pwd_id,
"stoken": stoken,
"_fetch_share": "1",
}
headers = self.credential.get_headers()
logger.info("[UcTransfer] ② Fetching share detail for pwd_id=%s", pwd_id)
try:
resp = self.session.get(
url, params=params, headers=headers, timeout=self.timeout
)
resp.raise_for_status()
except requests.RequestException as exc:
raise RuntimeError(f"获取分享详情失败: {exc}") from exc
data: Dict[str, Any] = resp.json()
status: int = data.get("status", -1)
if status != 0 and data.get("code") not in (0, None):
raise RuntimeError(
f"分享详情API返回错误: status={status}, message={data.get('message')}"
)
detail: Optional[Dict[str, Any]] = data.get("data")
if not detail:
raise RuntimeError(f"分享详情数据为空, response: {data}")
logger.info(
"[UcTransfer] ② Detail: title=%s, fid=%s",
detail.get("title"),
detail.get("fid"),
)
return detail
# ─── 步骤 ③:发起转存 ─────────────────────────────────────────
def _init_save(
self,
pwd_id: str,
stoken: str,
detail: Dict[str, Any],
to_pdir_fid: str = "0",
) -> str:
"""步骤③:发起转存请求。
POST /1/clouddrive/share/sharepage/save
Body: {
"fid_list": [<fid>, ...],
"fid_token_list": [<share_fid_token>, ...],
"to_pdir_fid": "0",
"pwd_id": "<pwd_id>",
"stoken": "<stoken>",
"pdir_fid": "0",
"scene": "link"
}
Args:
pwd_id: 分享 ID。
stoken: stoken。
detail: 步骤②的分享详情。
to_pdir_fid: 目标目录 ID默认 "0" 即根目录。
Returns:
task_id 字符串,用于步骤④轮询。
Raises:
RuntimeError: API 返回错误。
"""
url = f"{UC_SHARE_API}/sharepage/save"
fid_list: List[str] = detail.get("fid_list", [detail.get("fid", [])])
fid_token_list: List[str] = detail.get(
"fid_token_list", [detail.get("share_fid_token", [])]
)
# 如果 detail 的 fid/fid_token 是单值而非列表,则包装为列表
if not isinstance(fid_list, list):
fid_list = [fid_list] if fid_list else []
if not isinstance(fid_token_list, list):
fid_token_list = [fid_token_list] if fid_token_list else []
body: Dict[str, Any] = {
"fid_list": fid_list,
"fid_token_list": fid_token_list,
"to_pdir_fid": to_pdir_fid,
"pwd_id": pwd_id,
"stoken": stoken,
"pdir_fid": "0",
"scene": "link",
}
headers = self.credential.get_headers()
headers.setdefault("Content-Type", "application/json")
logger.info(
"[UcTransfer] ③ Initiating save: %d files to dir=%s",
len(fid_list),
to_pdir_fid,
)
try:
resp = self.session.post(
url, json=body, headers=headers, timeout=self.timeout
)
resp.raise_for_status()
except requests.RequestException as exc:
raise RuntimeError(f"发起转存失败: {exc}") from exc
data: Dict[str, Any] = resp.json()
status: int = data.get("status", -1)
if status != 0:
raise RuntimeError(
f"转存请求失败: status={status}, message={data.get('message')}"
)
task_id: Optional[str] = data.get("data", {}).get("task_id")
if not task_id:
raise RuntimeError(f"转存 task_id 缺失, response: {data}")
logger.info("[UcTransfer] ③ Save task created: task_id=%s", task_id)
return task_id
# ─── 步骤 ④:轮询转存任务 ─────────────────────────────────────
def _poll_save_task(self, task_id: str) -> List[str]:
"""步骤④:轮询转存任务直到完成。
GET /1/clouddrive/task?task_id=<task_id>&retry_index=0
当 status==2 时表示任务成功完成status==-1 表示失败。
Args:
task_id: 步骤③返回的 task_id。
Returns:
save_as_top_fids 列表(转存后的文件 ID
Raises:
RuntimeError: 任务失败或超时。
"""
url = f"{UC_API_BASE}/1/clouddrive/task"
headers = self.credential.get_headers()
for attempt in range(1, self.poll_max_attempts + 1):
params: Dict[str, str] = {
"task_id": task_id,
"retry_index": "0",
}
try:
resp = self.session.get(
url, params=params, headers=headers, timeout=self.timeout
)
resp.raise_for_status()
except requests.RequestException:
logger.warning(
"[UcTransfer] ④ Poll attempt %d/%d failed, retrying...",
attempt,
self.poll_max_attempts,
)
time.sleep(self.poll_interval)
continue
data: Dict[str, Any] = resp.json()
task_status: int = data.get("data", {}).get("status", -1)
logger.debug(
"[UcTransfer] ④ Poll %d/%d: status=%d",
attempt,
self.poll_max_attempts,
task_status,
)
if task_status == 2: # 成功
save_as_top_fids: List[str] = (
data.get("data", {})
.get("save_as", {})
.get("save_as_top_fids", [])
)
logger.info(
"[UcTransfer] ④ Save completed: %d files saved",
len(save_as_top_fids),
)
return save_as_top_fids
if task_status == -1:
raise RuntimeError(
f"转存任务失败: task_id={task_id}, response={data}"
)
time.sleep(self.poll_interval)
raise RuntimeError(
f"转存任务超时: task_id={task_id}, 已轮询 {self.poll_max_attempts}"
)
# ─── 步骤 ⑤:发起创建分享 ─────────────────────────────────────
def _init_share(
self, fid_list: List[str], title: str, expired_type: int = 1
) -> str:
"""步骤⑤:创建分享链接。
POST /1/clouddrive/share
Body: {"fid_list": [<fid>, ...], "title": "<title>", "expired_type": 1}
Args:
fid_list: 要分享的文件 ID 列表。
title: 分享标题。
expired_type: 过期类型1=永久有效(默认)。
Returns:
task_id 字符串,用于步骤⑥轮询。
Raises:
RuntimeError: API 返回错误。
"""
url = f"{UC_SHARE_API}"
body: Dict[str, Any] = {
"fid_list": fid_list,
"title": title or "分享",
"expired_type": expired_type,
}
headers = self.credential.get_headers()
headers.setdefault("Content-Type", "application/json")
logger.info(
"[UcTransfer] ⑤ Creating share: %d files, title='%s'", len(fid_list), title
)
try:
resp = self.session.post(
url, json=body, headers=headers, timeout=self.timeout
)
resp.raise_for_status()
except requests.RequestException as exc:
raise RuntimeError(f"创建分享失败: {exc}") from exc
data: Dict[str, Any] = resp.json()
status: int = data.get("status", -1)
if status != 0 and data.get("code") not in (0, None):
raise RuntimeError(
f"创建分享请求失败: status={status}, message={data.get('message')}"
)
task_id: Optional[str] = data.get("data", {}).get("task_id")
if not task_id:
raise RuntimeError(f"分享 task_id 缺失, response: {data}")
logger.info("[UcTransfer] ⑤ Share task created: task_id=%s", task_id)
return task_id
# ─── 步骤 ⑥:轮询分享任务 ─────────────────────────────────────
def _poll_share_task(self, task_id: str) -> str:
"""步骤⑥:轮询分享任务直到完成。
GET /1/clouddrive/task?task_id=<task_id>&retry_index=0
status==2 完成,返回 share_id。
Args:
task_id: 步骤⑤返回的 task_id。
Returns:
share_id 字符串。
Raises:
RuntimeError: 任务失败或超时。
"""
url = f"{UC_API_BASE}/1/clouddrive/task"
headers = self.credential.get_headers()
for attempt in range(1, self.poll_max_attempts + 1):
params: Dict[str, str] = {
"task_id": task_id,
"retry_index": "0",
}
try:
resp = self.session.get(
url, params=params, headers=headers, timeout=self.timeout
)
resp.raise_for_status()
except requests.RequestException:
logger.warning(
"[UcTransfer] ⑥ Poll attempt %d/%d failed, retrying...",
attempt,
self.poll_max_attempts,
)
time.sleep(self.poll_interval)
continue
data: Dict[str, Any] = resp.json()
task_status: int = data.get("data", {}).get("status", -1)
logger.debug(
"[UcTransfer] ⑥ Poll %d/%d: status=%d",
attempt,
self.poll_max_attempts,
task_status,
)
if task_status == 2: # 成功
share_id: Optional[str] = data.get("data", {}).get("share_id")
if not share_id:
share_id = (
data.get("data", {}).get("result", {}).get("share_id", "")
)
if not share_id:
raise RuntimeError(f"分享完成但 share_id 缺失: {data}")
logger.info("[UcTransfer] ⑥ Share completed: share_id=%s", share_id)
return share_id
if task_status == -1:
raise RuntimeError(
f"分享任务失败: task_id={task_id}, response={data}"
)
time.sleep(self.poll_interval)
raise RuntimeError(
f"分享任务超时: task_id={task_id}, 已轮询 {self.poll_max_attempts}"
)
# ─── 步骤 ⑦:设置分享密码 ─────────────────────────────────────
def _set_password(self, share_id: str, password: str = "") -> Tuple[str, str]:
"""步骤⑦:设置分享密码并获取分享链接。
POST /1/clouddrive/share/password
Body: {"share_id": "<share_id>"}
Args:
share_id: 步骤⑥返回的 share_id。
password: 分享密码,空字符串表示无密码。
Returns:
(share_url, passcode) 元组。
Raises:
RuntimeError: API 返回错误。
"""
url = f"{UC_SHARE_API}/password"
body: Dict[str, str] = {
"share_id": share_id,
}
headers = self.credential.get_headers()
headers.setdefault("Content-Type", "application/json")
logger.info("[UcTransfer] ⑦ Setting password for share_id=%s", share_id)
try:
resp = self.session.post(
url, json=body, headers=headers, timeout=self.timeout
)
resp.raise_for_status()
except requests.RequestException as exc:
raise RuntimeError(f"设置分享密码失败: {exc}") from exc
data: Dict[str, Any] = resp.json()
status: int = data.get("status", -1)
if status != 0 and data.get("code") not in (0, None):
raise RuntimeError(
f"设置密码失败: status={status}, message={data.get('message')}"
)
share_url: str = data.get("data", {}).get("share_url", "")
passcode: str = data.get("data", {}).get("passcode", password)
if not share_url:
# 用 share_id 构造默认分享链接
share_url = f"https://drive.uc.cn/s/{share_id}"
logger.info(
"[UcTransfer] ⑦ Password set: share_url=%s, passcode=%s",
share_url,
passcode,
)
return share_url, passcode
# ─── 公开入口 ─────────────────────────────────────────────────
def transfer(
self,
share_url: str,
save_dir: str = "0",
share_password: str = "",
) -> Dict[str, Any]:
"""执行完整的 7 步转存流程。
从原始 UC 分享链接开始,将文件转存到自己网盘,再创建新分享。
Args:
share_url: 原始 UC 分享链接,如 https://drive.uc.cn/s/xxxxx。
save_dir: 转存目标目录 ID默认 "0"(根目录)。
share_password: 新分享的密码,空字符串表示无密码。
Returns:
包含以下字段的字典:
- success: bool
- new_file_ids: List[str] — 转存后的文件ID列表
- file_name: str — 分享标题
- share_url: str — 新分享链接
- passcode: str — 新分享密码
Raises:
RuntimeError: 任一步骤失败。
ValueError: URL 解析失败。
"""
# 0. 解析 URL 提取 pwd_id
match = SHARE_URL_PATTERN.search(share_url)
if not match:
raise ValueError(f"无法从URL中提取UC分享ID: {share_url}")
pwd_id: str = match.group(1)
logger.info("[UcTransfer] Starting 7-step transfer for pwd_id=%s", pwd_id)
# ① 获取 stoken
stoken: str = self._get_stoken(pwd_id)
# ② 获取分享详情
detail: Dict[str, Any] = self._get_detail(pwd_id, stoken)
# ③ 发起转存 → ④ 轮询
task_id: str = self._init_save(pwd_id, stoken, detail, to_pdir_fid=save_dir)
new_file_ids: List[str] = self._poll_save_task(task_id)
if not new_file_ids:
raise RuntimeError("转存完成但未获取到文件ID")
# ⑤ 创建分享 → ⑥ 轮询
title: str = detail.get("title", "分享")
share_task_id: str = self._init_share(new_file_ids, title)
share_id: str = self._poll_share_task(share_task_id)
# ⑦ 设置密码
share_url_new, passcode = self._set_password(share_id, share_password)
logger.info(
"[UcTransfer] Transfer complete: %d files, new_share=%s",
len(new_file_ids),
share_url_new,
)
return {
"success": True,
"new_file_ids": new_file_ids,
"file_name": title,
"share_url": share_url_new,
"passcode": passcode,
}
@staticmethod
def parse_share_url(url: str) -> Optional[str]:
"""从 UC 分享 URL 中提取 pwd_id。
Args:
url: UC 分享链接。
Returns:
pwd_id 字符串,解析失败返回 None。
"""
match = SHARE_URL_PATTERN.search(url)
return match.group(1) if match else None
def close(self) -> None:
"""关闭 HTTP 会话。"""
self.session.close()
def __enter__(self) -> "UcTransfer":
return self
def __exit__(self, *args: Any) -> None:
self.close()

View File

@@ -0,0 +1,112 @@
"""
CloudSearch Transfer — 迅雷网盘适配器 v1.0.0
PLATFORM_KEY = 'xunlei'
迅雷网盘使用 refresh_token + captcha_token 双重认证。
"""
from __future__ import annotations
import logging
from typing import List, Optional, Tuple
from ..base import (
BaseCloudDriveAdapter,
FileInfo,
TransferResult,
VerifyResult,
)
from ...config import PlatformConfig, TransferConfig
from ...errors import TransferError, TransferErrorCode
from .credential import XunleiCredentialManager
from .transfer import XunleiTransfer
from .cleanup import XunleiCleanup
logger = logging.getLogger(__name__)
class XunleiAdapter(BaseCloudDriveAdapter):
"""迅雷网盘适配器"""
PLATFORM_NAME = "迅雷网盘"
PLATFORM_KEY = "xunlei"
URL_PATTERNS = [r"pan\.xunlei\.com/s/([A-Za-z0-9]+)"]
def __init__(self, config: PlatformConfig, transfer_config: TransferConfig):
super().__init__(config, transfer_config)
self._credential = XunleiCredentialManager(config)
self._transfer_engine: Optional[XunleiTransfer] = None
self._cleanup = XunleiCleanup()
def _setup_session(self):
"""初始化 session 认证头"""
headers = self._credential.get_auth_headers()
if headers:
self.session.headers.update(headers)
def _ensure_auth(self):
"""确保认证头是最新的"""
headers = self._credential.get_auth_headers()
self.session.headers.update(headers)
@property
def _transfer(self) -> XunleiTransfer:
"""懒加载转存引擎"""
if self._transfer_engine is None:
self._transfer_engine = XunleiTransfer(
self.session,
self._credential,
self.config,
self.transfer_config,
)
return self._transfer_engine
# ─── 抽象方法实现 ──────────────────────────────
def _get_share_detail(self, pwd_id: str, passcode: str = "") -> dict:
self._ensure_auth()
return self._transfer.get_share_info(pwd_id, passcode)
def _save_files(self, pwd_id: str, detail: dict, save_dir: str) -> List[str]:
self._ensure_auth()
return self._transfer.save_files(pwd_id, detail, save_dir)
def _create_share(self, file_ids: List[str], title: str,
password: str = "") -> Tuple[str, str]:
self._ensure_auth()
return self._transfer.create_share(file_ids, title, password)
def _extract_file_list(self, detail: dict) -> List[FileInfo]:
files = detail.get("files", [])
return [
FileInfo(fid=f.get("id", ""), name=f.get("name", ""),
size=f.get("size", 0), is_dir=f.get("is_dir", False))
for f in files
]
def _filter_ads(self, file_ids: List[str]) -> List[str]:
banned = self._get_banned_keywords()
return self._cleanup.filter_ad_ids(
file_ids,
getattr(self._transfer, "_last_file_names", []),
banned,
)
def get_files(self, parent_fid: str = "0") -> List[FileInfo]:
self._ensure_auth()
return self._transfer.list_files(parent_fid)
def delete(self, file_ids: List[str]) -> bool:
self._ensure_auth()
return self._cleanup.delete_files(
self.session, self._credential, file_ids
)
def _get_banned_keywords(self) -> List[str]:
return self.config.banned_keywords or self.transfer_config.default_banned_keywords
def close(self):
self.session.close()
def __repr__(self):
return f"<XunleiAdapter account={self.config.account_name}>"

View File

@@ -0,0 +1,198 @@
"""
CloudSearch Transfer — 迅雷网盘清理模块 v1.0.0
提供文件删除和广告过滤功能。
"""
from __future__ import annotations
import logging
from typing import Any, Dict, List
import requests
from .credential import XunleiCredentialManager
logger = logging.getLogger(__name__)
# ─── 迅雷 API ─────────────────────────────────────────────────────────
XUNLEI_PAN_API = "https://api-pan.xunlei.com"
class XunleiCleanup:
"""迅雷网盘文件清理器。
提供批量删除文件和广告文件过滤功能。
Attributes:
credential: 迅雷凭证管理器。
session: 复用的 requests.Session。
timeout: HTTP 请求超时秒数。
"""
def __init__(
self,
credential: XunleiCredentialManager,
timeout: int = 30,
) -> None:
"""初始化清理器。
Args:
credential: 有效的迅雷凭证管理器。
timeout: HTTP 请求超时秒数。
"""
self.credential: XunleiCredentialManager = credential
self.timeout: int = timeout
self.session: requests.Session = requests.Session()
def delete_files(self, file_ids: List[str]) -> bool:
"""批量删除文件。
POST /drive/v1/files:batchDelete
Body: {
"ids": ["<fid1>", "<fid2>", ...],
"space": ""
}
Args:
file_ids: 要删除的文件 ID 列表。
Returns:
True 表示删除请求已提交成功False 表示失败。
Raises:
RuntimeError: HTTP 请求错误。
"""
if not file_ids:
logger.warning("[XunleiCleanup] delete_files called with empty list")
return True
url: str = f"{XUNLEI_PAN_API}/drive/v1/files:batchDelete"
body: Dict[str, Any] = {
"ids": file_ids,
"space": "",
}
headers = self.credential.get_headers()
headers.setdefault("Content-Type", "application/json")
logger.info("[XunleiCleanup] Deleting %d files: %s", len(file_ids), file_ids)
try:
resp = self.session.post(
url, json=body, headers=headers, timeout=self.timeout
)
resp.raise_for_status()
except requests.RequestException as exc:
raise RuntimeError(f"删除文件失败: {exc}") from exc
data: Dict[str, Any] = resp.json()
errcode = data.get("errcode", data.get("error_code", 0))
if errcode != 0:
logger.error(
"[XunleiCleanup] Delete returned error: errcode=%s, message=%s",
errcode,
data.get("message", data.get("error", "")),
)
return False
logger.info("[XunleiCleanup] Delete succeeded for %d files", len(file_ids))
return True
def delete_files_permanent(self, file_ids: List[str]) -> bool:
"""彻底删除文件。
迅雷的 batchDelete 默认为彻底删除(与回收站不同),
此方法与 delete_files 行为一致。
Args:
file_ids: 要彻底删除的文件 ID 列表。
Returns:
True 表示删除请求已提交成功。
"""
return self.delete_files(file_ids)
@staticmethod
def filter_ads(
files: List[Dict[str, Any]],
banned_keywords: List[str],
) -> List[Dict[str, Any]]:
"""按关键词过滤文件列表中的广告文件。
遍历文件列表,剔除文件名中包含任一 banned_keywords 的文件。
匹配方式:不区分大小写的子串匹配。
Args:
files: 文件信息字典列表,每个字典需包含 "name""file_name" 字段。
banned_keywords: 被禁关键词列表(匹配不区分大小写)。
Returns:
过滤后的文件信息列表。
"""
if not banned_keywords:
return files
filtered: List[Dict[str, Any]] = []
removed_count: int = 0
for f in files:
name: str = f.get("name", f.get("file_name", ""))
name_lower: str = str(name).lower()
if any(keyword.lower() in name_lower for keyword in banned_keywords):
logger.info("[XunleiCleanup] Filtered ad file: '%s'", name)
removed_count += 1
continue
filtered.append(f)
if removed_count > 0:
logger.info(
"[XunleiCleanup] Ad filter removed %d/%d files",
removed_count,
len(files),
)
return filtered
@staticmethod
def filter_ad_ids(
file_ids: List[str],
file_names: List[str],
banned_keywords: List[str],
) -> List[str]:
"""按关键词过滤文件 ID 列表。
根据 file_names 判断是否为广告,返回对应的 file_ids。
Args:
file_ids: 文件 ID 列表。
file_names: 与 file_ids 一一对应的文件名列表。
banned_keywords: 被禁关键词列表。
Returns:
过滤后的 file_ids 列表。
"""
if not banned_keywords or len(file_ids) != len(file_names):
return file_ids
filtered_ids: List[str] = []
for fid, name in zip(file_ids, file_names):
name_lower: str = str(name).lower()
if any(kw.lower() in name_lower for kw in banned_keywords):
logger.info(
"[XunleiCleanup] Filtered ad file: '%s' (id=%s)", name, fid
)
continue
filtered_ids.append(fid)
return filtered_ids
def close(self) -> None:
"""关闭 HTTP 会话。"""
self.session.close()
def __enter__(self) -> "XunleiCleanup":
return self
def __exit__(self, *args: Any) -> None:
self.close()

View File

@@ -0,0 +1,339 @@
"""
CloudSearch Transfer — 迅雷网盘凭证管理器 v1.0.0
迅雷网盘使用 refresh_token + captcha_token 双重认证机制:
1. refresh_token → access_token (OAuth)
POST https://xluser-ssl.xunlei.com/v1/auth/token
Body: {"grant_type": "refresh_token", "refresh_token": "...", "client_id": "..."}
2. captcha_token 获取(某些操作需要)
POST /v1/shield/captcha/init
Body: {"client_id": "...", "action": "...", "device_id": "...", "meta": {"captcha_sign": "..."}}
3. get_headers() 返回所有需要的认证头:
Authorization: Bearer <access_token>
x-captcha-token: <captcha_token>
x-client-id: <client_id>
x-device-id: <device_id>
"""
from __future__ import annotations
import logging
import time
import threading
from typing import Dict, Optional
import requests
logger = logging.getLogger(__name__)
# ─── 常量 ───────────────────────────────────────────────────────────
# 迅雷网盘 OAuth 认证端点
XUNLEI_AUTH_API = "https://xluser-ssl.xunlei.com"
# 迅雷网盘客户端标识(固定值)
CLIENT_ID = "Xqp0kJBXWhwaTpB6"
DEVICE_ID = "925b7631473a13716b791d7f28289cad"
# ─── 默认请求头 ─────────────────────────────────────────────────────
DEFAULT_HEADERS: Dict[str, str] = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/135.0.0.0 Safari/537.36"
),
"Accept": "application/json, text/plain, */*",
"Content-Type": "application/json",
}
class XunleiCredentialManager:
"""迅雷网盘凭证管理器。
职责:
- 使用 refresh_token 换取 access_token
- 获取 captcha_token特定 action 需要)
- 构建包含所有认证头的请求头字典
- 访问令牌过期前自动刷新(提前 60s
用法:
mgr = XunleiCredentialManager(refresh_token="xxx")
mgr.refresh_access_token() # 刷新 access_token
captcha = mgr.get_captcha_token("restore") # 获取验证码令牌
headers = mgr.get_headers() # 获取完整的认证请求头
is_ok = mgr.validate() # 验证凭证有效性
Attributes:
CLIENT_ID: 迅雷客户端 ID。
DEVICE_ID: 设备标识。
"""
# ─── 类常量 ────────────────────────────────────────────────
CLIENT_ID: str = CLIENT_ID
DEVICE_ID: str = DEVICE_ID
def __init__(self, refresh_token: str = "") -> None:
"""初始化迅雷凭证管理器。
Args:
refresh_token: 迅雷网盘的 refresh_token。
"""
self._refresh_token: str = refresh_token.strip()
self._access_token: str = ""
self._expires_at: float = 0.0
self._captcha_tokens: Dict[str, str] = {} # action → captcha_token
self._lock: threading.Lock = threading.Lock()
self._session: requests.Session = requests.Session()
self._session.headers.update(DEFAULT_HEADERS)
# ─── 公开 API ──────────────────────────────────────────────
def validate(self) -> bool:
"""验证 refresh_token 是否有效。
要求 refresh_token 长度 >= 20且能成功换取 access_token。
Returns:
True 表示凭证有效。
"""
if not self._refresh_token or len(self._refresh_token) < 20:
logger.warning(
"[XunleiCredential] refresh_token 长度不足 20验证失败"
)
return False
return self.refresh_access_token()
def is_valid(self) -> bool:
"""validate() 的别名。"""
return self.validate()
def refresh_access_token(self) -> bool:
"""使用 refresh_token 换取 access_token。
POST /v1/auth/token
Body: {"grant_type": "refresh_token", "refresh_token": "...", "client_id": "..."}
返回 True 表示成功False 表示失败。
"""
with self._lock:
return self._do_refresh()
def get_captcha_token(self, action: str) -> str:
"""获取指定 action 的 captcha_token。
POST /v1/shield/captcha/init
Body: {
"client_id": "...",
"action": "...",
"device_id": "...",
"meta": {"captcha_sign": "..."}
}
captcha_token 会按 action 缓存,避免重复获取。
Args:
action: 操作类型,如 "restore""share" 等。
Returns:
captcha_token 字符串,获取失败返回空字符串。
"""
with self._lock:
# 检查缓存
if action in self._captcha_tokens:
return self._captcha_tokens[action]
return self._do_get_captcha(action)
def get_headers(self) -> Dict[str, str]:
"""构建包含所有认证头的请求头字典。
返回:
- Authorization: Bearer <access_token>
- x-captcha-token: <captcha_token> (如有)
- x-client-id: <client_id>
- x-device-id: <device_id>
Returns:
认证请求头字典。
"""
self._ensure_token_valid()
headers: Dict[str, str] = {
"x-client-id": self.CLIENT_ID,
"x-device-id": self.DEVICE_ID,
}
if self._access_token:
headers["Authorization"] = f"Bearer {self._access_token}"
return headers
def get_headers_with_captcha(self, action: str = "") -> Dict[str, str]:
"""获取带 captcha_token 的完整认证头。
Args:
action: captcha 操作类型,空字符串表示不需要 captcha。
Returns:
包含 Authorization + x-captcha-token 的请求头字典。
"""
headers = self.get_headers()
if action:
captcha = self.get_captcha_token(action)
if captcha:
headers["x-captcha-token"] = captcha
return headers
def get_access_token(self) -> str:
"""获取当前有效的 access_token必要时自动刷新"""
self._ensure_token_valid()
return self._access_token
@property
def refresh_token(self) -> str:
"""返回当前 refresh_token。"""
return self._refresh_token
@refresh_token.setter
def refresh_token(self, value: str) -> None:
"""更新 refresh_token。"""
self._refresh_token = value.strip()
with self._lock:
self._access_token = ""
self._expires_at = 0.0
self._captcha_tokens.clear()
# ─── 内部方法 ──────────────────────────────────────────────
def _ensure_token_valid(self) -> None:
"""确保 access_token 有效(过期则自动刷新)。"""
if not self._access_token or time.time() >= (self._expires_at - 60):
self.refresh_access_token()
def _do_refresh(self) -> bool:
"""实际执行 token 刷新。
POST https://xluser-ssl.xunlei.com/v1/auth/token
"""
if not self._refresh_token:
logger.error("[XunleiCredential] 没有 refresh_token无法刷新")
return False
url = f"{XUNLEI_AUTH_API}/v1/auth/token"
body: Dict[str, str] = {
"grant_type": "refresh_token",
"refresh_token": self._refresh_token,
"client_id": self.CLIENT_ID,
}
try:
resp = self._session.post(url, json=body, timeout=30)
data = resp.json()
if resp.status_code != 200:
logger.error(
"[XunleiCredential] 刷新 token 失败: HTTP %d, %s",
resp.status_code,
data,
)
return False
access_token = data.get("access_token", "")
if not access_token:
logger.error(
"[XunleiCredential] 响应中缺少 access_token: %s", data
)
return False
expires_in = int(data.get("expires_in", 7200))
new_refresh = data.get("refresh_token", self._refresh_token)
self._access_token = access_token
self._expires_at = time.time() + expires_in
# 更新 refresh_token服务端可能下发新的
if new_refresh != self._refresh_token:
logger.info(
"[XunleiCredential] refresh_token 已轮换: "
f"{self._refresh_token[:8]}... → {new_refresh[:8]}..."
)
self._refresh_token = new_refresh
# 清除 captcha 缓存token 变了captcha 可能也失效了)
self._captcha_tokens.clear()
logger.info(
"[XunleiCredential] Token 刷新成功 (expires_in=%ds)", expires_in
)
return True
except requests.RequestException as e:
logger.error(f"[XunleiCredential] 刷新 token 网络异常: {e}")
return False
except Exception as e:
logger.exception(f"[XunleiCredential] 刷新 token 未知异常: {e}")
return False
def _do_get_captcha(self, action: str) -> str:
"""获取 captcha_token。
POST /v1/shield/captcha/init
"""
url = f"{XUNLEI_AUTH_API}/v1/shield/captcha/init"
body: Dict[str, Any] = {
"client_id": self.CLIENT_ID,
"action": action,
"device_id": self.DEVICE_ID,
"meta": {
"captcha_sign": "",
},
}
# 需要 Authorization 头
if not self._access_token:
if not self._do_refresh():
logger.error("[XunleiCredential] 无法获取 access_token跳过 captcha")
return ""
headers: Dict[str, str] = {
"Authorization": f"Bearer {self._access_token}",
"Content-Type": "application/json",
}
try:
resp = self._session.post(url, json=body, headers=headers, timeout=15)
data = resp.json()
captcha_token = data.get("captcha_token", "")
if captcha_token:
self._captcha_tokens[action] = captcha_token
logger.info(
"[XunleiCredential] captcha_token 获取成功 for action=%s",
action,
)
else:
logger.warning(
"[XunleiCredential] captcha_token 为空 for action=%s: %s",
action,
data,
)
return captcha_token
except requests.RequestException as e:
logger.error(f"[XunleiCredential] 获取 captcha_token 网络异常: {e}")
return ""
except Exception as e:
logger.exception(f"[XunleiCredential] 获取 captcha_token 异常: {e}")
return ""
def __repr__(self) -> str:
return (
f"XunleiCredentialManager("
f"refresh_token={'***' if self._refresh_token else 'None'}, "
f"has_access_token={bool(self._access_token)}, "
f"captcha_actions={list(self._captcha_tokens.keys())})"
)

View File

@@ -0,0 +1,518 @@
"""
CloudSearch Transfer — 迅雷网盘转存核心 v1.0.0
迅雷网盘 4 步转存流程:
① GET .../drive/v1/share?share_id=xx → pass_code_token, files[], title
② POST .../share/restore → restore_task_id (转存)
③ 轮询 GET .../tasks/{task_id} → progress==100, trace_file_ids → oldId→newId映射
④ POST .../share → share_url + pass_code
迅雷网盘需要 refresh_token + captcha_token 双重认证。
"""
from __future__ import annotations
import json
import logging
import re
import time
from typing import Any, Dict, List, Optional, Tuple
import requests
from .credential import XunleiCredentialManager
logger = logging.getLogger(__name__)
# ─── 迅雷 API 基础地址 ──────────────────────────────────────────────
XUNLEI_PAN_API = "https://api-pan.xunlei.com"
# ─── URL 解析正则 ───────────────────────────────────────────────────
# 匹配 pan.xunlei.com/s/<share_id>
SHARE_URL_PATTERN = re.compile(r"pan\.xunlei\.com/s/([A-Za-z0-9]+)")
class XunleiTransfer:
"""迅雷网盘转存引擎。
封装完整的 4 步 API 流程:获取分享详情 → 转存文件 →
轮询转存任务 → 创建新分享。
Attributes:
credential: 迅雷凭证管理器实例。
session: 复用的 requests.Session。
timeout: 请求超时(秒)。
poll_interval: 轮询间隔(秒)。
poll_max_attempts: 最大轮询次数。
"""
def __init__(
self,
credential: XunleiCredentialManager,
timeout: int = 30,
poll_interval: float = 1.0,
poll_max_attempts: int = 60,
) -> None:
"""初始化转存引擎。
Args:
credential: 有效的迅雷凭证管理器。
timeout: HTTP 请求超时秒数。
poll_interval: 异步任务轮询间隔秒数。
poll_max_attempts: 异步任务最大轮询次数。
"""
self.credential: XunleiCredentialManager = credential
self.timeout: int = timeout
self.poll_interval: float = poll_interval
self.poll_max_attempts: int = poll_max_attempts
self.session: requests.Session = requests.Session()
# ─── 步骤 ①:获取分享详情 ─────────────────────────────────────
def _get_share_info(self, share_id: str) -> Dict[str, Any]:
"""步骤①:获取分享详情。
GET /drive/v1/share?share_id=<share_id>
返回字段包含pass_code_token, files[], title 等。
Args:
share_id: 分享 ID从 URL 解析)。
Returns:
分享信息字典,包含 files, title, pass_code_token。
Raises:
RuntimeError: API 返回错误。
"""
url = f"{XUNLEI_PAN_API}/drive/v1/share"
params: Dict[str, str] = {"share_id": share_id}
headers = self.credential.get_headers()
logger.info("[XunleiTransfer] ① Fetching share info for share_id=%s", share_id)
try:
resp = self.session.get(
url, params=params, headers=headers, timeout=self.timeout
)
resp.raise_for_status()
except requests.RequestException as exc:
raise RuntimeError(f"获取分享详情失败: {exc}") from exc
data: Dict[str, Any] = resp.json()
# 检查业务错误
errcode = data.get("errcode", data.get("error_code", 0))
if errcode != 0:
raise RuntimeError(
f"分享详情API返回错误: errcode={errcode}, message={data.get('message', data.get('error', ''))}"
)
# 提取关键字段
pass_code_token: str = data.get("pass_code_token", "")
files: List[Dict[str, Any]] = data.get("files", [])
title: str = data.get("title", data.get("share_name", ""))
if not files:
raise RuntimeError("分享内容为空")
logger.info(
"[XunleiTransfer] ① Share info: title=%s, files=%d, has_pass_code_token=%s",
title,
len(files),
bool(pass_code_token),
)
return {
"pass_code_token": pass_code_token,
"files": files,
"title": title,
"share_id": share_id,
}
# ─── 步骤 ②:转存文件 ─────────────────────────────────────────
def _restore_files(
self,
share_id: str,
pass_code_token: str,
file_ids: List[str],
parent_id: str = "",
) -> str:
"""步骤②:转存文件到自己的迅雷网盘。
POST /drive/v1/share/restore
Body: {
"file_ids": ["<fid1>", ...],
"pass_code_token": "<token>",
"share_id": "<share_id>",
"parent_id": "",
"specify_parent_id": true
}
Args:
share_id: 分享 ID。
pass_code_token: 步骤①获取的 pass_code_token。
file_ids: 要转存的文件 ID 列表。
parent_id: 目标父目录 ID空字符串表示根目录。
Returns:
restore_task_id 字符串,用于步骤③轮询。
Raises:
RuntimeError: API 返回错误。
"""
url = f"{XUNLEI_PAN_API}/drive/v1/share/restore"
body: Dict[str, Any] = {
"file_ids": file_ids,
"pass_code_token": pass_code_token,
"share_id": share_id,
"parent_id": parent_id or "",
"specify_parent_id": True,
}
# restore 操作可能需要 captcha_token
headers = self.credential.get_headers_with_captcha(action="restore")
headers.setdefault("Content-Type", "application/json")
logger.info(
"[XunleiTransfer] ② Restoring %d files from share_id=%s",
len(file_ids),
share_id,
)
try:
resp = self.session.post(
url, json=body, headers=headers, timeout=self.timeout
)
resp.raise_for_status()
except requests.RequestException as exc:
raise RuntimeError(f"转存请求失败: {exc}") from exc
data: Dict[str, Any] = resp.json()
errcode = data.get("errcode", data.get("error_code", 0))
if errcode != 0:
raise RuntimeError(
f"转存请求失败: errcode={errcode}, message={data.get('message', data.get('error', ''))}"
)
task_id: Optional[str] = data.get("restore_task_id", data.get("task_id"))
if not task_id:
raise RuntimeError(f"转存 task_id 缺失, response: {data}")
logger.info("[XunleiTransfer] ② Restore task created: task_id=%s", task_id)
return task_id
# ─── 步骤 ③:轮询转存任务 ─────────────────────────────────────
def _poll_restore_task(self, task_id: str) -> Dict[str, str]:
"""步骤③:轮询转存任务直到完成。
GET /drive/v1/tasks/{task_id}
当 progress==100 时表示完成,返回 oldId→newId 映射。
从 params.trace_file_ids 解析 JSON 字符串获取映射关系。
Args:
task_id: 步骤②返回的 restore_task_id。
Returns:
{"oldId": "newId", ...} 文件 ID 映射字典。
Raises:
RuntimeError: 任务失败或超时。
"""
url = f"{XUNLEI_PAN_API}/drive/v1/tasks/{task_id}"
headers = self.credential.get_headers()
for attempt in range(1, self.poll_max_attempts + 1):
try:
resp = self.session.get(url, headers=headers, timeout=self.timeout)
resp.raise_for_status()
except requests.RequestException:
logger.warning(
"[XunleiTransfer] ③ Poll attempt %d/%d failed, retrying...",
attempt,
self.poll_max_attempts,
)
time.sleep(self.poll_interval)
continue
data: Dict[str, Any] = resp.json()
progress: int = data.get("progress", 0)
status: str = data.get("status", "")
logger.debug(
"[XunleiTransfer] ③ Poll %d/%d: progress=%d, status=%s",
attempt,
self.poll_max_attempts,
progress,
status,
)
if status == "failed" or status == "error":
raise RuntimeError(
f"转存任务失败: task_id={task_id}, status={status}"
)
if progress == 100:
# 从 params.trace_file_ids 解析 oldId→newId 映射
params: Dict[str, Any] = data.get("params", {})
trace_file_ids: str = params.get("trace_file_ids", "")
if trace_file_ids:
try:
id_mapping: Dict[str, str] = json.loads(trace_file_ids)
logger.info(
"[XunleiTransfer] ③ Restore completed: %d files mapped",
len(id_mapping),
)
return id_mapping
except json.JSONDecodeError:
logger.warning(
"[XunleiTransfer] ③ Failed to parse trace_file_ids: %s",
trace_file_ids,
)
# fallback: 检查 result 字段
result = data.get("result", {})
if result:
logger.info("[XunleiTransfer] ③ Restore completed via result field")
return result
# 最后的 fallback: 返回空映射
logger.warning(
"[XunleiTransfer] ③ Restore completed but no file mapping found"
)
return {}
if progress < 0:
raise RuntimeError(
f"转存任务异常: task_id={task_id}, progress={progress}"
)
time.sleep(self.poll_interval)
raise RuntimeError(
f"转存任务超时: task_id={task_id}, 已轮询 {self.poll_max_attempts}"
)
# ─── 步骤 ④:创建新分享 ─────────────────────────────────────
def _create_share(
self,
file_ids: List[str],
expiration_days: str = "-1",
) -> Tuple[str, str]:
"""步骤④:创建新分享链接。
POST /drive/v1/share
Body: {
"file_ids": ["<fid1>", ...],
"expiration_days": "-1"
}
expiration_days: "-1" 表示永久有效。
Args:
file_ids: 要分享的文件 ID 列表。
expiration_days: 过期天数,"-1" 表示永久。
Returns:
(share_url, pass_code) 元组。
Raises:
RuntimeError: API 返回错误。
"""
url = f"{XUNLEI_PAN_API}/drive/v1/share"
body: Dict[str, Any] = {
"file_ids": file_ids,
"expiration_days": expiration_days,
}
# share 操作可能需要 captcha_token
headers = self.credential.get_headers_with_captcha(action="share")
headers.setdefault("Content-Type", "application/json")
logger.info(
"[XunleiTransfer] ④ Creating share: %d files", len(file_ids)
)
try:
resp = self.session.post(
url, json=body, headers=headers, timeout=self.timeout
)
resp.raise_for_status()
except requests.RequestException as exc:
raise RuntimeError(f"创建分享失败: {exc}") from exc
data: Dict[str, Any] = resp.json()
errcode = data.get("errcode", data.get("error_code", 0))
if errcode != 0:
raise RuntimeError(
f"创建分享失败: errcode={errcode}, message={data.get('message', data.get('error', ''))}"
)
share_url: str = data.get("share_url", data.get("link", ""))
pass_code: str = data.get("pass_code", data.get("code", ""))
if not share_url:
share_id = data.get("share_id", "")
if share_id:
share_url = f"https://pan.xunlei.com/s/{share_id}"
logger.info(
"[XunleiTransfer] ④ Share created: url=%s, pass_code=%s",
share_url,
pass_code,
)
return share_url, pass_code
# ─── 公开入口 ─────────────────────────────────────────────────
def transfer(
self,
share_url: str,
save_dir: str = "",
share_password: str = "",
) -> Dict[str, Any]:
"""执行完整的 4 步转存流程。
从原始迅雷分享链接开始,将文件转存到自己网盘,再创建新分享。
Args:
share_url: 原始迅雷分享链接,如 https://pan.xunlei.com/s/xxxxx。
save_dir: 转存目标目录 ID空字符串表示根目录。
share_password: 新分享的密码(迅雷使用 pass_code
Returns:
包含以下字段的字典:
- success: bool
- new_file_ids: List[str] — 转存后的文件ID列表newId
- file_name: str — 分享标题
- share_url: str — 新分享链接
- passcode: str — 新分享 pass_code
Raises:
RuntimeError: 任一步骤失败。
ValueError: URL 解析失败。
"""
# 0. 解析 URL 提取 share_id
match = SHARE_URL_PATTERN.search(share_url)
if not match:
raise ValueError(f"无法从URL中提取迅雷分享ID: {share_url}")
share_id: str = match.group(1)
logger.info(
"[XunleiTransfer] Starting 4-step transfer for share_id=%s", share_id
)
# ① 获取分享详情
share_info: Dict[str, Any] = self._get_share_info(share_id)
files: List[Dict[str, Any]] = share_info.get("files", [])
title: str = share_info.get("title", "分享")
pass_code_token: str = share_info.get("pass_code_token", "")
# 提取原始文件 ID
file_ids: List[str] = [
f.get("file_id", f.get("fid", f.get("id", "")))
for f in files
if f.get("file_id") or f.get("fid") or f.get("id")
]
if not file_ids:
raise RuntimeError("无法从分享中提取文件ID")
# ② 发起转存
task_id: str = self._restore_files(
share_id, pass_code_token, file_ids, parent_id=save_dir
)
# ③ 轮询转存任务 → 获取 oldId→newId 映射
id_mapping: Dict[str, str] = self._poll_restore_task(task_id)
# 从映射中提取新的文件 ID
new_file_ids: List[str] = []
for old_fid in file_ids:
new_fid = id_mapping.get(old_fid, "")
if new_fid:
new_file_ids.append(new_fid)
else:
logger.warning(
"[XunleiTransfer] No newId mapped for old_fid=%s", old_fid
)
if not new_file_ids:
raise RuntimeError("转存完成但未获取到新文件ID")
# ④ 创建新分享
share_url_new, pass_code = self._create_share(new_file_ids)
logger.info(
"[XunleiTransfer] Transfer complete: %d files, new_share=%s",
len(new_file_ids),
share_url_new,
)
return {
"success": True,
"new_file_ids": new_file_ids,
"file_name": title,
"share_url": share_url_new,
"passcode": pass_code or share_password,
}
@staticmethod
def parse_share_url(url: str) -> Optional[str]:
"""从迅雷分享 URL 中提取 share_id。
Args:
url: 迅雷分享链接。
Returns:
share_id 字符串,解析失败返回 None。
"""
match = SHARE_URL_PATTERN.search(url)
return match.group(1) if match else None
@staticmethod
def extract_file_ids(files: List[Dict[str, Any]]) -> List[str]:
"""从文件列表中提取 file_id。
Args:
files: 文件信息字典列表。
Returns:
file_id 字符串列表。
"""
return [
f.get("file_id", f.get("fid", f.get("id", "")))
for f in files
if f.get("file_id") or f.get("fid") or f.get("id")
]
@staticmethod
def parse_trace_file_ids(trace: str) -> Dict[str, str]:
"""解析 trace_file_ids JSON 字符串为 oldId→newId 映射。
Args:
trace: trace_file_ids JSON 字符串,如 '{"oldId":"newId"}'.
Returns:
{"oldId": "newId", ...} 映射字典。
"""
try:
return json.loads(trace)
except (json.JSONDecodeError, TypeError):
return {}
def close(self) -> None:
"""关闭 HTTP 会话。"""
self.session.close()
def __enter__(self) -> "XunleiTransfer":
return self
def __exit__(self, *args: Any) -> None:
self.close()

View File

@@ -0,0 +1,172 @@
"""
CloudSearch Transfer — 配置管理 v1.0.0
支持环境变量 + JSON文件 + 数据库多级配置源
"""
import os
import json
from pathlib import Path
from typing import Optional, Dict, Any
from dataclasses import dataclass, field
@dataclass
class PlatformConfig:
"""单个网盘平台的配置"""
enabled: bool = False
cookie: str = "" # Cookie字符串夸克/百度/UC/123
refresh_token: str = "" # OAuth RefreshToken阿里/迅雷)
access_token: str = "" # 运行时AccessToken自动刷新
account_name: str = "" # 账号名(多账号路由)
save_dir: str = "/" # 默认转存目录
share_password: str = "" # 分享密码
banned_keywords: list = field(default_factory=list) # 广告过滤关键词
extra: Dict[str, Any] = field(default_factory=dict) # 扩展字段
@dataclass
class TransferConfig:
"""转存服务配置"""
# HTTP
request_timeout: int = 30 # 请求超时(秒)
max_retries: int = 3 # 最大重试次数
retry_delay: float = 1.0 # 重试延迟(秒)
# 任务轮询
task_poll_interval: float = 0.5 # 轮询间隔(秒)
task_poll_max_attempts: int = 50 # 最大轮询次数
task_poll_max_wait: int = 60 # 最大等待时间(秒)
# 并发控制
max_concurrent_transfers: int = 5 # 最大并发转存数
transfer_queue_size: int = 100 # 转存队列大小
# 广告过滤
ad_filter_enabled: bool = True # 是否启用广告过滤
default_banned_keywords: list = field(default_factory=lambda: [
"公众号", "微信", "扫码", "加群", "QQ群", "广告",
"关注", "免费领取", "点击领取", "全网", "最全",
])
# 分享设置
default_share_period: str = "permanent" # 永久/7d/30d
auto_generate_password: bool = False # 自动生成分享密码
class ConfigManager:
"""统一配置管理器"""
def __init__(self, config_path: Optional[str] = None):
self._config_path = config_path or os.getenv(
"TRANSFER_CONFIG_PATH",
"/data/transfer_config.json"
)
self.platforms: Dict[str, PlatformConfig] = {}
self.transfer: TransferConfig = TransferConfig()
self._load()
def _load(self):
"""加载配置:环境变量 → JSON文件 → 默认值"""
# 1. 从JSON文件加载
if Path(self._config_path).exists():
with open(self._config_path) as f:
data = json.load(f)
self._parse_json(data)
# 2. 环境变量覆盖
self._apply_env_overrides()
def _parse_json(self, data: dict):
"""解析JSON配置"""
# 平台配置
platforms_data = data.get("platforms", {})
for name, cfg in platforms_data.items():
self.platforms[name] = PlatformConfig(
enabled=cfg.get("enabled", False),
cookie=cfg.get("cookie", ""),
refresh_token=cfg.get("refresh_token", ""),
access_token=cfg.get("access_token", ""),
account_name=cfg.get("account_name", name),
save_dir=cfg.get("save_dir", "/"),
share_password=cfg.get("share_password", ""),
banned_keywords=cfg.get("banned_keywords", []),
extra=cfg.get("extra", {}),
)
# 传输配置
transfer_data = data.get("transfer", {})
if transfer_data:
self.transfer = TransferConfig(
request_timeout=transfer_data.get("request_timeout", 30),
max_retries=transfer_data.get("max_retries", 3),
retry_delay=transfer_data.get("retry_delay", 1.0),
task_poll_interval=transfer_data.get("task_poll_interval", 0.5),
task_poll_max_attempts=transfer_data.get("task_poll_max_attempts", 50),
max_concurrent_transfers=transfer_data.get("max_concurrent_transfers", 5),
ad_filter_enabled=transfer_data.get("ad_filter_enabled", True),
)
def _apply_env_overrides(self):
"""环境变量覆盖TRANSFER_<PLATFORM>_COOKIE 等"""
env_map = {
"quark": "QUARK",
"baidu": "BAIDU",
"aliyun": "ALIYUN",
"uc": "UC",
"xunlei": "XUNLEI",
"pan123": "PAN123",
"cloud189": "CLOUD189",
}
for platform, prefix in env_map.items():
cookie = os.getenv(f"TRANSFER_{prefix}_COOKIE")
if cookie:
if platform not in self.platforms:
self.platforms[platform] = PlatformConfig()
self.platforms[platform].cookie = cookie
self.platforms[platform].enabled = True
token = os.getenv(f"TRANSFER_{prefix}_REFRESH_TOKEN")
if token:
if platform not in self.platforms:
self.platforms[platform] = PlatformConfig()
self.platforms[platform].refresh_token = token
self.platforms[platform].enabled = True
def get_platform(self, name: str) -> Optional[PlatformConfig]:
"""获取平台配置"""
config = self.platforms.get(name)
if config and config.enabled:
return config
return None
def get_enabled_platforms(self) -> list:
"""获取所有已启用的平台名"""
return [name for name, cfg in self.platforms.items() if cfg.enabled]
def save(self):
"""保存配置到文件"""
data = {
"platforms": {
name: {
"enabled": cfg.enabled,
"cookie": cfg.cookie[:20] + "..." if cfg.cookie else "",
"refresh_token": cfg.refresh_token[:20] + "..." if cfg.refresh_token else "",
"account_name": cfg.account_name,
"save_dir": cfg.save_dir,
"share_password": cfg.share_password,
"banned_keywords": cfg.banned_keywords,
"extra": cfg.extra,
}
for name, cfg in self.platforms.items()
},
"transfer": {
"request_timeout": self.transfer.request_timeout,
"max_retries": self.transfer.max_retries,
"max_concurrent_transfers": self.transfer.max_concurrent_transfers,
"ad_filter_enabled": self.transfer.ad_filter_enabled,
}
}
Path(self._config_path).parent.mkdir(parents=True, exist_ok=True)
with open(self._config_path, "w") as f:
json.dump(data, f, indent=2, ensure_ascii=False)

View File

@@ -0,0 +1 @@
"""CloudSearch Transfer — 凭证管理包"""

View File

@@ -0,0 +1,130 @@
"""
CloudSearch Transfer — 凭证管理器 v1.0.0
参考 search-ucmao 的 get_and_validate_credential + cloud-auto-save 的 Token回写
"""
import time
import logging
from typing import Optional, Dict, Any
from dataclasses import dataclass, field
from ..config import PlatformConfig
logger = logging.getLogger(__name__)
@dataclass
class CredentialStatus:
"""凭证状态"""
valid: bool
platform: str
last_check: float = 0.0
last_error: str = ""
checks_count: int = 0
fail_count: int = 0
class CredentialManager:
"""
凭证管理器
- 凭证校验(各平台最小长度要求不同)
- Token自动刷新阿里云/迅雷)
- 健康检测
"""
# 各平台最小凭证长度
MIN_LENGTH_MAP = {
"quark": 50, # Cookie ≥ 50字符
"baidu": 50, # Cookie ≥ 50字符
"uc": 50, # Cookie ≥ 50字符
"aliyun": 20, # refresh_token ≥ 20字符
"xunlei": 30, # refresh_token ≥ 30字符
"pan123": 30,
"cloud189": 30,
}
# 凭证类型cookie / refresh_token
CREDENTIAL_TYPE = {
"quark": "cookie",
"baidu": "cookie",
"uc": "cookie",
"aliyun": "refresh_token",
"xunlei": "refresh_token",
"pan123": "cookie",
"cloud189": "cookie",
}
def __init__(self):
self._status: Dict[str, CredentialStatus] = {}
self._token_cache: Dict[str, Dict[str, Any]] = {}
def validate(self, platform: str, config: PlatformConfig) -> bool:
"""
校验凭证有效性
参考 search-ucmao 的 get_and_validate_credential 逻辑
"""
min_len = self.MIN_LENGTH_MAP.get(platform, 20)
if self.CREDENTIAL_TYPE.get(platform) == "refresh_token":
token = config.refresh_token
valid = bool(token and len(token) >= min_len)
else:
cookie = config.cookie
valid = bool(cookie and len(cookie) >= min_len)
# 记录状态
status = self._status.get(platform, CredentialStatus(valid=False, platform=platform))
status.last_check = time.time()
status.checks_count += 1
if not valid:
status.fail_count += 1
status.last_error = f"凭证长度不足 (需要≥{min_len})"
else:
status.valid = True
self._status[platform] = status
return valid
def get_credential(self, platform: str, config: PlatformConfig) -> str:
"""
获取有效凭证
对于Token类型会自动刷新
"""
if not self.validate(platform, config):
return ""
cred_type = self.CREDENTIAL_TYPE.get(platform, "cookie")
if cred_type == "refresh_token":
# 优先使用缓存的access_token
cached = self._token_cache.get(platform, {})
if cached.get("access_token") and cached.get("expires_at", 0) > time.time() + 60:
return cached["access_token"]
return config.refresh_token
else:
return config.cookie
def update_access_token(self, platform: str, access_token: str,
expires_in: int = 3600):
"""更新缓存的access_token"""
self._token_cache[platform] = {
"access_token": access_token,
"expires_at": time.time() + expires_in,
}
def get_status(self, platform: str) -> Optional[CredentialStatus]:
"""获取凭证状态"""
return self._status.get(platform)
def get_all_status(self) -> Dict[str, CredentialStatus]:
"""获取所有平台凭证状态"""
return dict(self._status)
def mark_invalid(self, platform: str, reason: str = ""):
"""标记凭证失效"""
status = self._status.get(platform, CredentialStatus(valid=False, platform=platform))
status.valid = False
status.last_error = reason
status.fail_count += 1
status.last_check = time.time()
self._status[platform] = status
logger.warning(f"[Credential] {platform} marked invalid: {reason}")

View File

@@ -0,0 +1,68 @@
"""
CloudSearch Transfer — 错误码定义 v1.0.0
参考 netdisk Go SDK 的错误码设计 + 各项目实践
"""
from enum import IntEnum
class TransferErrorCode(IntEnum):
"""统一错误码"""
# 通用错误 (40xxx)
URL_INVALID = 40001 # URL格式错误或无法识别平台
NOT_LOGIN = 40002 # 未登录或凭证已失效
CAPACITY_FULL = 40003 # 存储空间容量不足
SHARE_NOT_EXIST = 40004 # 分享不存在或已失效
PASSCODE_WRONG = 40005 # 提取码错误
RESOURCE_EMPTY = 40006 # 资源内容为空或全为广告文件
NETWORK_ERROR = 40007 # 网络请求失败
TIMEOUT = 40008 # 操作超时
NO_CONFIG = 40009 # 该平台未配置凭证
SHARE_LINK_FAIL = 40010 # 分享创建失败
SHARE_LIMIT = 40011 # 今日分享次数过多
DIR_NOT_EXIST = 40012 # 目标存储目录不存在
SENSITIVE_RESOURCE = 40013 # 资源内容违规
# 平台特有错误 (41xxx)
BAIDU_BDSTOKEN_FAIL = 41001 # 百度bdstoken获取失败
ALIYUN_TOKEN_EXPIRED = 41002 # 阿里Token过期
XUNLEI_CAPTCHA_FAIL = 41003 # 迅雷验证码失败
QUARK_LOGIN_REQUIRED = 41004 # 夸克需要重新登录
class TransferError(Exception):
"""转存异常"""
def __init__(self, code: TransferErrorCode, message: str = None,
platform: str = None, details: dict = None):
self.code = code
self.message = message or self._default_message(code)
self.platform = platform
self.details = details or {}
super().__init__(self.message)
@staticmethod
def _default_message(code: TransferErrorCode) -> str:
messages = {
TransferErrorCode.URL_INVALID: "URL格式错误或无法识别平台",
TransferErrorCode.NOT_LOGIN: "未登录或凭证已失效",
TransferErrorCode.CAPACITY_FULL: "存储空间容量不足",
TransferErrorCode.SHARE_NOT_EXIST: "分享不存在或已失效",
TransferErrorCode.PASSCODE_WRONG: "提取码错误",
TransferErrorCode.RESOURCE_EMPTY: "资源内容为空或全为广告文件",
TransferErrorCode.NETWORK_ERROR: "网络请求失败",
TransferErrorCode.TIMEOUT: "操作超时",
TransferErrorCode.NO_CONFIG: "该平台未配置凭证",
TransferErrorCode.SHARE_LINK_FAIL: "分享创建失败",
TransferErrorCode.SHARE_LIMIT: "今日分享次数过多",
TransferErrorCode.DIR_NOT_EXIST: "目标存储目录不存在",
TransferErrorCode.SENSITIVE_RESOURCE: "资源内容违规",
}
return messages.get(code, f"未知错误 (code={code})")
def to_dict(self) -> dict:
return {
"code": self.code.value,
"message": self.message,
"platform": self.platform,
"details": self.details,
}

View File

@@ -0,0 +1,68 @@
"""
Feature Flags 统一管理 v2.1.0
环境变量 + 配置文件双层控制
"""
import os
from typing import Dict
class FeatureFlags:
"""功能开关管理器"""
# 所有功能及其默认值
DEFAULTS: Dict[str, bool] = {
# 核心功能
"quark_pid": True,
"seo": True,
"link_monitor": True,
# 增强功能
"tmdb": True,
"telegram_bot": False,
"subscription": False,
"alist": False,
# 转存平台
"transfer_quark": True,
"transfer_baidu": False,
"transfer_aliyun": False,
"transfer_uc": False,
"transfer_xunlei": False,
"transfer_pan115": False,
"transfer_pan123": False,
"transfer_cloud189": False,
}
def __init__(self):
self._flags: Dict[str, bool] = {}
self._load()
def _load(self):
for key, default in self.DEFAULTS.items():
env_key = f"FEATURE_{key.upper()}"
val = os.getenv(env_key, str(default)).lower()
self._flags[key] = val in ("true", "1", "yes", "on")
def is_enabled(self, feature: str) -> bool:
return self._flags.get(feature, False)
def enable(self, feature: str):
self._flags[feature] = True
def disable(self, feature: str):
self._flags[feature] = False
def list_all(self) -> Dict[str, bool]:
return dict(self._flags)
def get_enabled_platforms(self) -> list:
return [
k.replace("transfer_", "")
for k, v in self._flags.items()
if k.startswith("transfer_") and v
]
# 全局单例
features = FeatureFlags()

View File

@@ -0,0 +1 @@
"""CloudSearch Transfer — 编排包"""

View File

@@ -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")

View File

@@ -0,0 +1,2 @@
flask>=3.0
requests>=2.28

View File

@@ -0,0 +1,200 @@
"""
CloudSearch Transfer — HTTP API 服务 v1.0.0
以 Flask 微服务形式运行,与 CloudSearch 主应用通过 HTTP 通信
"""
import os
import uuid
import logging
from flask import Flask, request, jsonify
from config import ConfigManager
from orchestration.transfer import TransferOrchestrator
# ─── 初始化 ────────────────────────────────────────────
app = Flask(__name__)
config = ConfigManager()
orchestrator = TransferOrchestrator(config)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger("transfer_api")
# ─── 健康检查 ──────────────────────────────────────────
@app.route("/health", methods=["GET"])
def health():
return jsonify({
"status": "ok",
"version": "1.0.0",
"platforms": orchestrator.get_stats(),
})
# ─── 转存接口 ──────────────────────────────────────────
@app.route("/api/transfer", methods=["POST"])
def transfer():
"""转存分享链接"""
data = request.get_json() or {}
share_url = data.get("share_url", "").strip()
if not share_url:
return jsonify({"error": "share_url is required"}), 400
save_dir = data.get("save_dir", "")
share_password = data.get("share_password", "")
async_mode = data.get("async", False)
try:
if async_mode:
task_id = orchestrator.transfer_async(share_url, save_dir, share_password)
return jsonify({"task_id": task_id, "status": "pending"})
else:
result = orchestrator.transfer(share_url, save_dir, share_password)
return jsonify({
"success": result.success,
"platform": result.platform,
"new_file_id": result.new_file_id,
"file_name": result.file_name,
"share_url": result.share_url,
"share_password": result.share_password,
"elapsed_ms": result.elapsed_ms,
})
except Exception as e:
logger.exception("Transfer failed")
return jsonify({"error": str(e), "code": getattr(e, "code", 500)}), 500
# ─── 验证接口 ──────────────────────────────────────────
@app.route("/api/verify", methods=["POST"])
def verify():
"""验证分享链接有效性"""
data = request.get_json() or {}
share_url = data.get("share_url", "").strip()
if not share_url:
return jsonify({"error": "share_url is required"}), 400
result = orchestrator.verify(share_url)
return jsonify({
"valid": result.valid,
"platform": result.platform,
"title": result.title,
"file_count": result.file_count,
"files": [{"fid": f.fid, "name": f.name, "size": f.size}
for f in (result.files or [])],
"error": result.error.to_dict() if result.error else None,
})
# ─── 任务查询 ──────────────────────────────────────────
@app.route("/api/task/<task_id>", methods=["GET"])
def get_task(task_id):
"""查询异步任务状态"""
task = orchestrator.get_task(task_id)
if not task:
return jsonify({"error": "task not found"}), 404
result = {
"task_id": task.task_id,
"status": task.status,
"share_url": task.share_url,
"platform": task.platform,
"created_at": task.created_at,
"completed_at": task.completed_at,
}
if task.result:
result["result"] = {
"success": task.result.success,
"share_url": task.result.share_url,
"file_name": task.result.file_name,
"elapsed_ms": task.result.elapsed_ms,
}
if task.error:
result["error"] = task.error
return jsonify(result)
@app.route("/api/tasks", methods=["GET"])
def list_tasks():
"""列出任务"""
status = request.args.get("status")
limit = int(request.args.get("limit", 50))
tasks = orchestrator.list_tasks(status=status, limit=limit)
return jsonify({
"tasks": [
{
"task_id": t.task_id,
"status": t.status,
"share_url": t.share_url[:80],
"platform": t.platform,
"created_at": t.created_at,
}
for t in tasks
],
"total": len(tasks),
})
# ─── 统计 ──────────────────────────────────────────────
@app.route("/api/stats", methods=["GET"])
def stats():
"""获取统计信息"""
return jsonify(orchestrator.get_stats())
# ─── 配置管理 ──────────────────────────────────────────
@app.route("/api/config/platforms", methods=["GET"])
def get_platforms():
"""获取平台配置列表"""
platforms = {}
for name, cfg in config.platforms.items():
platforms[name] = {
"enabled": cfg.enabled,
"account_name": cfg.account_name,
"save_dir": cfg.save_dir,
"has_cookie": bool(cfg.cookie),
"has_refresh_token": bool(cfg.refresh_token),
}
return jsonify({"platforms": platforms})
@app.route("/api/config/platforms/<name>", methods=["PUT"])
def update_platform(name):
"""更新平台配置"""
data = request.get_json() or {}
if name not in config.platforms:
from config import PlatformConfig
config.platforms[name] = PlatformConfig()
cfg = config.platforms[name]
if "enabled" in data:
cfg.enabled = data["enabled"]
if "cookie" in data:
cfg.cookie = data["cookie"]
if "refresh_token" in data:
cfg.refresh_token = data["refresh_token"]
if "save_dir" in data:
cfg.save_dir = data["save_dir"]
if "share_password" in data:
cfg.share_password = data["share_password"]
config.save()
orchestrator.factory.invalidate_cache(name)
return jsonify({"status": "ok", "platform": name})
# ─── 启动 ──────────────────────────────────────────────
if __name__ == "__main__":
port = int(os.getenv("PORT", 9528))
debug = os.getenv("FLASK_DEBUG", "0") == "1"
logger.info(f"Starting transfer service on port {port}")
app.run(host="0.0.0.0", port=port, debug=debug)

119
docker-compose.yml Normal file
View File

@@ -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

BIN
icons/115.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
icons/123pan.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 B

BIN
icons/aliyun.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
icons/baidu.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
icons/pikpak.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
icons/quark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
icons/tianyi.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
icons/uc.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 KiB

BIN
icons/xunlei.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 894 B

29
source_clean/Dockerfile Normal file
View File

@@ -0,0 +1,29 @@
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build
# Stage 2: Runner
FROM node:20-alpine
RUN apk add --no-cache \
chromium \
nss \
freetype \
harfbuzz \
ca-certificates \
ttf-freefont \
dumb-init
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
ENV NODE_ENV=production
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
COPY frontend/ ./dist/frontend/
EXPOSE 9527
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "dist/main.js"]

View File

@@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>CloudSearch 管理后台</title>
<style>
:root{--bg:#f5f6fa;--card:#fff;--text:#2c3e50;--sub:#7f8c8d;--pri:#3498db;--pri-hover:#2980b9;--danger:#e74c3c;--success:#27ae60;--warn:#f39c12;--border:#e8ecf1;--sidebar-bg:#1a1a2e;--sidebar-text:#b0b8c8;--sidebar-active:#fff;--radius:8px}
*{margin:0;padding:0;box-sizing:border-box}
#app{display:flex;flex:1}body{font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--text);display:flex;min-height:100vh}
.sidebar{width:220px;background:var(--sidebar-bg);color:var(--sidebar-text);flex-shrink:0;display:flex;flex-direction:column}
.sidebar-title{padding:20px;font-size:17px;font-weight:700;color:#fff;border-bottom:1px solid rgba(255,255,255,.08)}
.nav-item{display:flex;align-items:center;gap:8px;padding:9px 16px 9px 20px;cursor:pointer;color:var(--sidebar-text);font-size:13px;transition:.15s;border-left:3px solid transparent}
.nav-item:hover{color:#fff;background:rgba(255,255,255,.05)}
.nav-item.active{color:var(--sidebar-active);background:rgba(52,152,219,.15);border-left-color:var(--pri)}
.main{flex:1;display:flex;flex-direction:column;overflow:hidden}
.topbar{padding:16px 24px;font-size:16px;font-weight:600;background:var(--card);border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center}
#content{flex:1;overflow-y:auto;padding:24px}
.card{background:var(--card);border-radius:var(--radius);padding:20px;margin-bottom:16px;box-shadow:0 1px 3px rgba(0,0,0,.04)}
.card-title{font-size:14px;font-weight:600;margin-bottom:12px;display:flex;align-items:center;gap:8px}
.stats-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:12px;margin-bottom:20px}
.stat-card{background:var(--card);border-radius:var(--radius);padding:8px 12px;box-shadow:0 1px 3px rgba(0,0,0,.04);display:flex;align-items:center;justify-content:space-between}
.form-group{margin-bottom:14px;display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap}
.form-group label{font-size:13px;color:var(--text);font-weight:500;min-width:130px;padding-top:6px}
.form-control{padding:7px 10px;border:1px solid var(--border);border-radius:6px;font-size:13px;outline:none;transition:.15s;max-width:360px;width:100%}
.form-control:focus{border-color:var(--pri)}
.form-control.wide{max-width:500px}
textarea.form-control{min-height:70px;resize:vertical}
.btn{padding:7px 16px;border:none;border-radius:6px;font-size:13px;cursor:pointer;font-weight:500;transition:.15s;display:inline-flex;align-items:center;gap:4px}
.btn-pri{background:var(--pri);color:#fff}.btn-pri:hover{background:var(--pri-hover)}
.btn-danger{background:var(--danger);color:#fff}.btn-danger:hover{background:#c0392b}
.btn-outline{background:transparent;border:1px solid var(--border);color:var(--text)}.btn-outline:hover{background:var(--bg)}
.btn-sm{padding:4px 10px;font-size:12px}
.toggle{position:relative;display:inline-block;width:42px;height:22px}
.toggle input{opacity:0;width:0;height:0}
.toggle-slider{position:absolute;cursor:pointer;top:0;left:0;right:0;bottom:0;background:#ccc;border-radius:22px;transition:.2s}
.toggle-slider:before{position:absolute;content:"";height:16px;width:16px;left:3px;bottom:3px;background:#fff;border-radius:50%;transition:.2s}
.toggle input:checked+.toggle-slider{background:var(--pri)}
.toggle input:checked+.toggle-slider:before{transform:translateX(20px)}
.toast{position:fixed;top:16px;right:16px;padding:12px 20px;border-radius:8px;color:#fff;font-size:13px;z-index:999;animation:slideIn .3s ease}
.toast-success{background:var(--success)}.toast-error{background:var(--danger)}
@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}
.loading{text-align:center;padding:40px;color:var(--sub)}
</style>
</head>
<body>
<div id="app">
<div class="sidebar">
<div class="sidebar-title">CloudSearch</div>
<div class="nav-item active" data-page="dashboard" onclick="nav('dashboard')">📊 仪表盘</div>
<div class="nav-item" data-page="sys-site" onclick="nav('sys-site')">⚙️ 网站设置</div>
<div class="nav-item" data-page="sys-services" onclick="nav('sys-services')">🔗 外部服务</div>
<div class="nav-item" data-page="sys-manage" onclick="nav('sys-manage')">🖥 服务管理</div>
<div class="nav-item" data-page="sys-strategy" onclick="nav('sys-strategy')">🚀 性能配置</div>
<div class="nav-item" data-page="sys-password" onclick="nav('sys-password')">🔑 修改密码</div>
<div class="nav-item" data-page="cloud-config" onclick="nav('cloud-config')">☁️ 网盘设置</div>
<div class="nav-item" data-page="cleanup" onclick="nav('cleanup')">🗑 存储清理</div>
<div style="margin-top:auto;padding:12px"><button class="btn btn-outline btn-sm" onclick="logout()" style="width:100%;color:var(--sidebar-text)">退出登录</button></div>
</div>
<div class="main">
<div class="topbar"><span id="pageTitle">仪表盘</span><span id="appVersion" style="font-size:12px;color:var(--sub)">v-</span></div>
<div id="content"></div>
</div>
</div>
<script src="/admin/js/admin-core.js"></script>
<script src="/admin/js/admin-helpers.js"></script>
<script src="/admin/js/admin-login.js"></script>
<script src="/admin/js/admin-dashboard.js"></script>
<script src="/admin/js/admin-site.js"></script>
<script src="/admin/js/admin-services.js"></script>
<script src="/admin/js/admin-password.js"></script>
<script src="/admin/js/cloud/cloud-core.js"></script>
<script src="/admin/js/cloud/cloud-render.js"></script>
<script src="/admin/js/cloud/cloud-dialog.js"></script>
<script src="/admin/js/cloud/cloud-actions.js"></script>
<script src="/admin/js/admin-cleanup.js"></script>
<script src="/admin/js/admin-boot.js"></script>
</body>
</html>

View File

@@ -0,0 +1,15 @@
// Fetch app version
fetch('/api/version').then(r => r.json()).then(d => {
var el = document.getElementById('appVersion');
if (el && d.version) el.textContent = 'v' + d.version;
}).catch(function(){});
(function(){
if(!TOKEN){
document.getElementById('app').innerHTML = '<div class="login-wrap"><div id="content"></div></div>';
page_login(document.getElementById('content'));
} else {
nav('dashboard');
}
})();

View File

@@ -0,0 +1,155 @@
function page_cleanup(c){
c.innerHTML = '<div id="cleanupRoot"><p style="text-align:center;padding:40px">Loading...</p></div>';
loadCleanupPage();
}
function loadCleanupPage(){
Promise.all([
api('/api/admin/cloud-configs').catch(function(){ return []; }),
api('/api/admin/verify/status').catch(function(){ return {}; }),
api('/api/admin/system-configs').catch(function(){ return {}; })
]).then(function(res){
var accounts = res[0] || [];
var vStatus = res[1] || {};
var configs = res[2] || {};
renderCleanupPage(accounts, vStatus, configs);
});
}
function renderCleanupPage(accounts, vStatus, configs){
var lastVerify = vStatus.last_run ? new Date(vStatus.last_run).toLocaleString() : 'never';
var lastCleanup = configs.cleanup_last_run || 'never';
var h = '';
h += '<div class="card"><div class="card-title">Storage Cleanup & Management</div>';
h += '<p style="color:var(--sub);font-size:12px">Cloud verification, space refresh, scheduled cleanup</p></div>';
// Quick Actions
h += '<div class="card"><div class="card-title">Quick Actions</div>';
h += '<div style="display:flex;flex-wrap:wrap;gap:10px;margin-bottom:12px">';
h += '<button class="btn btn-pri" onclick="runVerifyAll()">Verify All</button>';
h += '<button class="btn btn-pri" onclick="runStorageRefresh()">Refresh All Space</button>';
h += '<button class="btn btn-warn" onclick="runFullCleanup()">Full Cleanup</button>';
h += '<button class="btn btn-outline" onclick="runEmptyTrash()">Empty Trash</button>';
h += '</div>';
h += '<div id="actionStatus" style="font-size:12px;color:var(--sub);margin-top:8px"></div>';
h += '<div style="font-size:12px;color:var(--sub);margin-top:4px">Last verify: ' + lastVerify + ' | Last cleanup: ' + lastCleanup + '</div></div>';
// Account Table
h += '<div class="card"><div class="card-title">Account Verification</div>';
h += '<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:13px">';
h += '<thead><tr style="background:var(--bg);border-bottom:2px solid var(--border);text-align:left">';
h += '<th style="padding:8px 10px">Type</th><th style="padding:8px 10px">Nickname</th>';
h += '<th style="padding:8px 10px">Status</th><th style="padding:8px 10px">Space</th>';
h += '<th style="padding:8px 10px">Action</th></tr></thead><tbody>';
accounts.forEach(function(cfg){
var v = cfg.verification_status;
var vIcon = v==='valid'?'OK':(v==='invalid'?'FAIL':'PENDING');
var space = cfg.storage_used ? (cfg.storage_used + ' / ' + (cfg.storage_total||'?')) : '-';
h += '<tr style="border-bottom:1px solid var(--border)">';
h += '<td style="padding:8px 10px">' + (cfg.cloud_type||'?') + '</td>';
h += '<td style="padding:8px 10px">' + (cfg.nickname||'-') + '</td>';
h += '<td style="padding:8px 10px">' + vIcon + '</td>';
h += '<td style="padding:8px 10px;font-size:12px;color:var(--sub)">' + space + '</td>';
h += '<td style="padding:8px 10px"><button class="btn btn-sm btn-outline" onclick="runSingleVerify('+cfg.id+',this)">Verify</button></td></tr>';
});
if(accounts.length===0){
h += '<tr><td colspan="5" style="padding:20px;text-align:center;color:var(--sub)">No accounts found</td></tr>';
}
h += '</tbody></table></div></div>';
// Cleanup Config
h += '<div class="card"><div class="card-title">Cleanup Config</div>';
h += '<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;font-size:13px">';
h += cfgRow('Cleanup Enabled', 'cleanup_enabled', configs, 'checkbox');
h += cfgRow('File Retention (days)', 'cleanup_file_retention_days', configs, 'number', '7');
h += cfgRow('Log Retention (days)', 'cleanup_log_retention_days', configs, 'number', '30');
h += cfgRow('Empty Trash After', 'cleanup_empty_trash', configs, 'checkbox');
h += cfgRow('Space Threshold Cleanup', 'cleanup_space_threshold_enabled', configs, 'checkbox');
h += cfgRow('Threshold %', 'cleanup_space_threshold_percent', configs, 'number', '90');
h += cfgRow('Delete %', 'cleanup_space_threshold_delete_percent', configs, 'number', '10');
h += '</div>';
h += '<button class="btn btn-pri" style="margin-top:12px" onclick="saveCleanupConfig()">Save Config</button></div>';
document.getElementById('cleanupRoot').innerHTML = h;
}
function cfgRow(label, key, configs, type, placeholder){
var val = configs[key] || '';
if(type==='checkbox'){
var chk = val==='true'?'checked':'';
return '<div><label style="display:flex;align-items:center;gap:8px;cursor:pointer"><input type="checkbox" class="cleanupCfg" data-key="'+key+'" '+chk+'><span>'+label+'</span></label></div>';
}
return '<div><label style="font-size:12px;color:var(--sub)">'+label+'</label><input class="form-control cleanupCfg" data-key="'+key+'" value="'+val+'" placeholder="'+placeholder+'" style="max-width:120px"></div>';
}
function runVerifyAll(){
var el = document.getElementById('actionStatus');
el.innerHTML = 'Verifying all accounts...';
api('/api/admin/verify/run', {method:'POST'}).then(function(r){
el.innerHTML = 'Done: '+(r.ok||0)+'/'+r.total+' OK, '+r.failed+' failed';
setTimeout(loadCleanupPage, 2000);
}).catch(function(e){
el.innerHTML = 'Verify failed: '+(e.message||'network error');
});
}
function runSingleVerify(id, btn){
btn.disabled = true; btn.textContent = '...';
api('/api/admin/verify/single/'+id, {method:'POST'}).then(function(r){
btn.textContent = r.success ? 'OK' : 'FAIL';
}).catch(function(){ btn.textContent = 'ERR'; })
.finally(function(){ setTimeout(loadCleanupPage, 1500); });
}
function runStorageRefresh(){
var el = document.getElementById('actionStatus');
el.innerHTML = 'Refreshing space info...';
api('/api/admin/storage/refresh', {method:'POST'}).then(function(){
el.innerHTML = 'Space refreshed';
setTimeout(loadCleanupPage, 2000);
}).catch(function(e){
el.innerHTML = 'Refresh failed: '+(e.message||'network error');
});
}
function runFullCleanup(){
var el = document.getElementById('actionStatus');
el.innerHTML = 'Running full cleanup...';
api('/api/admin/cleanup/run', {method:'POST'}).then(function(r){
el.innerHTML = (r.message||'Cleanup done');
setTimeout(loadCleanupPage, 2000);
}).catch(function(e){
el.innerHTML = 'Cleanup failed: '+(e.message||'network error');
});
}
function runEmptyTrash(){
var el = document.getElementById('actionStatus');
if(!confirm('Empty all trash? This cannot be undone.')) return;
el.innerHTML = 'Emptying trash...';
api('/api/admin/cleanup/empty-trash', {method:'POST'}).then(function(r){
el.innerHTML = r.emptied ? 'Trash emptied' : (r.message||'Done');
}).catch(function(e){
el.innerHTML = 'Failed: '+(e.message||'network error');
});
}
function saveCleanupConfig(){
var entries = [];
document.querySelectorAll('.cleanupCfg').forEach(function(el){
var key = el.dataset.key;
var val = el.type==='checkbox' ? (el.checked?'true':'false') : el.value.trim();
entries.push({key:key, value:val});
});
api('/api/admin/system-configs', {
method:'PUT', headers:{'Content-Type':'application/json'},
body:JSON.stringify({entries:entries})
}).then(function(){
toast('Config saved');
}).catch(function(e){
toast('Save failed: '+(e.message||''));
});
}

View File

@@ -0,0 +1,39 @@
var TOKEN = localStorage.getItem('admin_token') || '';
var TITLES = {dashboard:'仪表盘','sys-site':'网站设置','sys-services':'外部服务','sys-manage':'服务管理','sys-strategy':'性能配置','sys-password':'修改密码','cloud-config':'网盘设置',cleanup:'存储清理'};
function api(path, opts){
opts = opts || {};
opts.headers = opts.headers || {};
if(TOKEN) opts.headers['Authorization'] = 'Bearer ' + TOKEN;
return fetch(path, opts).then(function(r){
if(r.status === 401){ localStorage.removeItem('admin_token'); location.reload(); throw new Error('unauth'); }
return r.json();
});
}
function toast(msg, type){
type = type || 'success';
var t = document.createElement('div'); t.className = 'toast toast-'+type; t.textContent = msg;
document.body.appendChild(t);
setTimeout(function(){ t.remove(); }, 2500);
}
document.querySelectorAll('.nav-item').forEach(function(el){
el.onclick = function(){ nav(this.dataset.page); };
});
function nav(page){
document.querySelectorAll('.nav-item').forEach(function(e){ e.classList.remove('active'); });
var target = document.querySelector('[data-page="'+page+'"]');
if(target) target.classList.add('active');
document.getElementById('pageTitle').textContent = TITLES[page] || page;
var c = document.getElementById('content');
c.innerHTML = '<div class="loading">加载中...</div>';
try{ window['page_'+page.replace(/-/g,'_')](c); }
catch(e){ c.innerHTML = '<div class="card"><p>加载出错: '+e.message+'</p></div>'; }
}
function logout(){
localStorage.removeItem('admin_token');
location.reload();
}

View File

@@ -0,0 +1,9 @@
function page_dashboard(c){
api('/api/admin/services').then(function(d){
var h = '<div class="card"><div class="card-title">系统概览</div>'+
'<div class="stats-grid">'+
'<div class="stat-card"><div class="stat-value">'+(d.length||0)+'</div><div class="stat-label">服务数</div></div>'+
'</div></div>';
c.innerHTML = h;
}).catch(function(){ c.innerHTML = '<div class="card">加载失败</div>'; });
}

View File

@@ -0,0 +1 @@
// Helper utilities

View File

@@ -0,0 +1,14 @@
function page_login(c){
c.innerHTML = '<div style="max-width:400px;margin:60px auto"><div class="card"><div class="card-title">管理后台登录</div>'+
'<div class="form-group"><label>密码</label><input id="pwd" class="form-control" type="password" onkeydown="if(event.key===\'Enter\')doLogin()"></div>'+
'<button class="btn btn-pri" onclick="doLogin()">登录</button></div></div>';
}
function doLogin(){
var pwd = document.getElementById('pwd').value;
api('/api/admin/login', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:pwd})})
.then(function(d){
if(d.token){ localStorage.setItem('admin_token', d.token); TOKEN = d.token; nav('dashboard'); }
else{ toast(d.error||'密码错误','error'); }
});
}

View File

@@ -0,0 +1,13 @@
function page_sys_password(c){
c.innerHTML = '<div class="card"><div class="card-title">修改密码</div>'+
'<div class="form-group"><label>当前密码</label><input id="oldPwd" class="form-control" type="password"></div>'+
'<div class="form-group"><label>新密码</label><input id="newPwd" class="form-control" type="password"></div>'+
'<button class="btn btn-pri" onclick="changePwd()">修改</button></div>';
}
function changePwd(){
api('/api/admin/change-password', {method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({old:document.getElementById('oldPwd').value,new:document.getElementById('newPwd').value})})
.then(function(d){ toast(d.error||'密码修改成功', d.error?'error':'success'); });
}
function page_sys_manage(c){ page_sys_services(c); }
function page_sys_strategy(c){ c.innerHTML = '<div class="card">性能配置页面</div>'; }

View File

@@ -0,0 +1,10 @@
function page_sys_services(c){
api('/api/admin/services').then(function(d){
var h = '<div class="card"><div class="card-title">外部服务</div>';
(d||[]).forEach(function(s){
h += '<div class="service-card"><div class="service-info"><span class="name">'+s.name+'</span><span class="desc">'+s.url+'</span></div>'+
'<div class="service-actions"><span class="status-dot '+(s.enabled?'status-running':'status-stopped')+'"></span>'+(s.enabled?'运行中':'已停止')+'</div></div>';
});
c.innerHTML = h+'</div>';
});
}

View File

@@ -0,0 +1,14 @@
function page_sys_site(c){
api('/api/admin/system-configs').then(function(d){
var h = '<div class="card"><div class="card-title">网站设置</div>';
h += '<div class="form-group"><label>网站名称</label><input id="site_name" class="form-control" value="'+(d.site_name||'')+'"></div>';
h += '<button class="btn btn-pri" onclick="saveSite()">保存</button></div>';
c.innerHTML = h;
});
}
function saveSite(){
var name = document.getElementById('site_name').value;
api('/api/admin/system-configs', {method:'PUT',headers:{'Content-Type':'application/json'},
body:JSON.stringify({entries:[{key:'site_name',value:name}]})})
.then(function(d){ toast(d.error||'保存成功', d.error?'error':'success'); });
}

View File

@@ -0,0 +1,80 @@
function togCloudType(type, cb){
__cloudToggles[type] = cb.checked;
api('/api/admin/system-configs', {
method:'PUT', headers:{'Content-Type':'application/json'},
body:JSON.stringify({entries:[{key:'cloud_type_'+type+'_enabled', value:cb.checked?'true':'false'}]})
});
}
function togCloudAcc(id, cb){
api('/api/admin/cloud-configs/'+id, {method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({is_active:cb.checked})})
.then(function(d){ if(d.error){ cb.checked=!cb.checked; toast(d.error,'error'); } });
}
function delCloudAcc(id){ var name = '账号#'+id;
if(!confirm('确定删除 '+name+' 吗?')) return;
api('/api/admin/cloud-configs/'+id, {method:'DELETE'}).then(function(d){
toast(d.error?d.error:'已删除 '+name, d.error?'error':'success');
nav('cloud-config');
});
}
function doSaveAccount(){
var body = {
cloud_type: document.getElementById('dlg_type').value,
// default off, will be set by manual toggle
};
var cookie = document.getElementById('dlg_cookie').value.trim();
if(!cookie){ toast('请先输入或扫码获取 Cookie','error'); return; }
body.cookie = cookie;
body.promotion_account = document.getElementById('dlg_promo').value.trim() || '';
body.nickname = document.getElementById('dlg_nick').value.trim() || '';
body.storage_used = __qrStorageUsed || '';
body.storage_total = __qrStorageTotal || '';
toast('⏳ 正在保存...');
api('/api/admin/cloud-configs', {
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)
}).then(function(d){
if(d && d.error){ toast(d.error,'error'); return; }
var savedId = d.id;
toast('⏳ 正在验证 Cookie 并查询空间...');
return api('/api/admin/cloud-configs/'+body.cloud_type+'/test', {
method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({id: savedId})
});
}).then(function(r){
if(r && r.success){
toast('✅ 验证通过!空间: '+(r.storage_used||'?')+'/'+(r.storage_total||'?'));
} else {
toast('⚠️ 已保存但验证失败: '+(r?r.message:'未知错误'),'error');
}
closeDialog();
nav('cloud-config');
}).catch(function(e){
toast('保存失败: '+(e.message||'网络错误'),'error');
console.error('doSaveAccount error:', e);
});
}
function doSaveAccount_OLD(){
var body = {
cloud_type: document.getElementById('dlg_type').value,
// default off, will be set by verification
};
var cookie = document.getElementById('dlg_cookie').value.trim();
if(!cookie){ toast('请先输入或扫码获取 Cookie','error'); return; }
body.cookie = cookie;
console.log("SAVE promo raw:", document.getElementById("dlg_promo").value); body.promotion_account = document.getElementById('dlg_promo').value.trim() || '';
body.nickname = document.getElementById('dlg_nick').value.trim() || '';
body.storage_used = __qrStorageUsed || '';
body.storage_total = __qrStorageTotal || '';
api('/api/admin/cloud-configs', {
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)
}).then(function(d){
if(d && d.error){ toast(d.error,'error'); return; }
toast('✅ 网盘配置更新成功');
closeDialog();
nav('cloud-config');
}).catch(function(e){
toast('保存失败: '+(e.message||'网络错误'),'error');
console.error('doSaveAccount error:', e);
});
}

View File

@@ -0,0 +1,43 @@
function togCloudType(type, cb){
__cloudToggles[type] = cb.checked;
api('/api/admin/system-configs', {
method:'PUT', headers:{'Content-Type':'application/json'},
body:JSON.stringify({entries:[{key:'cloud_type_'+type+'_enabled', value:cb.checked?'true':'false'}]})
});
}
function togCloudAcc(id, cb){
api('/api/admin/cloud-configs/'+id, {method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({is_active:cb.checked})})
.then(function(d){ if(d.error){ cb.checked=!cb.checked; toast(d.error,'error'); } });
}
function delCloudAcc(id){ var name = '账号#'+id;
if(!confirm('确定删除 '+name+' 吗?')) return;
api('/api/admin/cloud-configs/'+id, {method:'DELETE'}).then(function(d){
toast(d.error?d.error:'已删除 '+name, d.error?'error':'success');
nav('cloud-config');
});
}
function doSaveAccount(){
var body = {
cloud_type: document.getElementById('dlg_type').value,
is_active: true
};
var cookie = document.getElementById('dlg_cookie').value.trim();
if(!cookie){ toast('请先输入或扫码获取 Cookie','error'); return; }
body.cookie = cookie;
console.log("SAVE promo raw:", document.getElementById("dlg_promo").value); body.promotion_account = document.getElementById('dlg_promo').value.trim() || '';
body.nickname = document.getElementById('dlg_nick').value.trim() || '';
body.storage_used = __qrStorageUsed || '';
body.storage_total = __qrStorageTotal || '';
api('/api/admin/cloud-configs', {
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)
}).then(function(d){
if(d && d.error){ toast(d.error,'error'); return; }
toast('✅ 网盘配置更新成功');
closeDialog();
nav('cloud-config');
}).catch(function(e){
toast('保存失败: '+(e.message||'网络错误'),'error');
console.error('doSaveAccount error:', e);
});
}

View File

@@ -0,0 +1,43 @@
function togCloudType(type, cb){
__cloudToggles[type] = cb.checked;
api('/api/admin/system-configs', {
method:'PUT', headers:{'Content-Type':'application/json'},
body:JSON.stringify({entries:[{key:'cloud_type_'+type+'_enabled', value:cb.checked?'true':'false'}]})
});
}
function togCloudAcc(id, cb){
api('/api/admin/cloud-configs/'+id, {method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({is_active:cb.checked})})
.then(function(d){ if(d.error){ cb.checked=!cb.checked; toast(d.error,'error'); } });
}
function delCloudAcc(id){ var name = '账号#'+id;
if(!confirm('确定删除 '+name+' 吗?')) return;
api('/api/admin/cloud-configs/'+id, {method:'DELETE'}).then(function(d){
toast(d.error?d.error:'已删除 '+name, d.error?'error':'success');
nav('cloud-config');
});
}
function doSaveAccount(){
var body = {
cloud_type: document.getElementById('dlg_type').value,
// default off, will be set by verification
};
var cookie = document.getElementById('dlg_cookie').value.trim();
if(!cookie){ toast('请先输入或扫码获取 Cookie','error'); return; }
body.cookie = cookie;
console.log("SAVE promo raw:", document.getElementById("dlg_promo").value); body.promotion_account = document.getElementById('dlg_promo').value.trim() || '';
body.nickname = document.getElementById('dlg_nick').value.trim() || '';
body.storage_used = __qrStorageUsed || '';
body.storage_total = __qrStorageTotal || '';
api('/api/admin/cloud-configs', {
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)
}).then(function(d){
if(d && d.error){ toast(d.error,'error'); return; }
toast('✅ 网盘配置更新成功');
closeDialog();
nav('cloud-config');
}).catch(function(e){
toast('保存失败: '+(e.message||'网络错误'),'error');
console.error('doSaveAccount error:', e);
});
}

View File

@@ -0,0 +1,38 @@
// --- Cloud Config ---
var CLOUD_TYPES = [
{type:'quark',label:'夸克网盘',icon:'/admin/icons/quark.png'},
{type:'baidu',label:'百度网盘',icon:'/admin/icons/baidu.png'},
{type:'aliyun',label:'阿里云盘',icon:'/admin/icons/aliyun.png'},
{type:'115',label:'115网盘',icon:'/admin/icons/115.png'},
{type:'tianyi',label:'天翼云盘',icon:'/admin/icons/tianyi.png'},
{type:'123pan',label:'123云盘',icon:'/admin/icons/123pan.png'},
{type:'uc',label:'UC网盘',icon:'/admin/icons/uc.png'},
{type:'xunlei',label:'迅雷网盘',icon:'/admin/icons/xunlei.png'},
{type:'pikpak',label:'PikPak',icon:'/admin/icons/pikpak.png'},
{type:'magnet',label:'磁力链接',icon:'🧲'},
{type:'ed2k',label:'电驴链接',icon:'🔗'},
{type:'others',label:'其他',icon:'⬜'}
];
var QR_TYPES = ['quark','baidu'];
var __cloudToggles = {};
var __qrType = '', __qrSession = '', __qrCookie = '', __qrTimer = null;
function page_cloud_config(c){
c.innerHTML = '<div class="loading">加载中...</div>';
Promise.all([
api('/api/admin/system-configs').catch(function(e){ console.error(e); return {}; }),
api('/api/admin/cloud-configs').catch(function(e){ console.error(e); return []; })
]).then(function(res){
var d = res[0]||{}, accounts = res[1]||[];
__cloudToggles = {};
CLOUD_TYPES.forEach(function(ct){
var v = d['cloud_type_'+ct.type+'_enabled'];
__cloudToggles[ct.type] = v===undefined ? (ct.type!=='others') : (v==='true'||v==='1');
});
renderCloudPage(c, accounts);
}).catch(function(e){
c.innerHTML = '<div class="card"><p style="color:var(--danger)">加载失败: '+e.message+'</p></div>';
});
}

View File

@@ -0,0 +1,107 @@
var __qrSession = '';
var __qrCookie = '';
var __qrType = '';
var __qrTimer = null;
var __qrNickname = '';
var __qrStorageUsed = '';
var __qrStorageTotal = '';
var QR_TYPES = ['quark','baidu'];
function openAddDialog(){
document.getElementById('dlg_type').value = 'quark';
document.getElementById('dlg_cookie').value = '';
document.getElementById('dlg_promo').value = '';
document.getElementById('dlg_nick').value = '';
document.getElementById('dlg_storage_info').textContent = '扫码后自动获取';
resetQR();
onDlgTypeChange();
document.getElementById('modalBg').style.display = 'flex';
}
function closeDialog(){
cancelQRPoll();
document.getElementById('modalBg').style.display = 'none';
}
function onDlgTypeChange(){
var t = document.getElementById('dlg_type').value;
document.getElementById('dlg_qr_section').style.display = (QR_TYPES.indexOf(t)!==-1) ? 'block' : 'none';
}
function resetQR(){
cancelQRPoll();
__qrSession = ''; __qrCookie = ''; __qrNickname = ''; __qrStorageUsed = ''; __qrStorageTotal = '';
document.getElementById('dlg_qr_status').textContent = '点击下方按钮生成扫码链接';
document.getElementById('dlg_qr_btn').disabled = false;
document.getElementById('dlg_qr_btn').textContent = '生成扫码链接';
document.getElementById('dlg_qr_btn').style.display = '';
document.getElementById('dlg_qr_hint').textContent = '';
}
function doStartQR(){
__qrType = document.getElementById('dlg_type').value;
document.getElementById('dlg_qr_btn').disabled = true;
document.getElementById('dlg_qr_btn').textContent = '生成中...';
document.getElementById('dlg_qr_status').textContent = '正在生成二维码...';
api('/api/admin/'+__qrType+'/qr-login/start', {method:'POST'}).then(function(d){
if(d.error){
document.getElementById('dlg_qr_status').innerHTML = '<span style="color:var(--danger)">❌ '+d.error+'</span>';
document.getElementById('dlg_qr_btn').disabled=false;
document.getElementById('dlg_qr_btn').textContent='重试';
return;
}
__qrSession = d.sessionId;
var url = d.qrUrl||d.url||'';
if(url){
var qrSrc = 'https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=' + encodeURIComponent(url);
document.getElementById('dlg_qr_status').innerHTML = '<img src="'+qrSrc+'" style="max-width:200px;max-height:200px;border:1px solid var(--border);border-radius:4px;display:block;margin:0 auto"><p style="font-size:11px;color:var(--sub);margin-top:6px;text-align:center">用对应 App 扫码并在手机上确认登录</p>';
}
document.getElementById('dlg_qr_hint').textContent = '等待扫码确认...';
document.getElementById('dlg_qr_btn').textContent = '重新生成';
document.getElementById('dlg_qr_btn').disabled = false;
pollQR();
});
}
function pollQR(){
if(!__qrSession) return;
__qrTimer = setTimeout(function(){
api('/api/admin/'+__qrType+'/qr-login/'+__qrSession+'/status').then(function(d){
if(d.status==='logged_in'){
__qrCookie = d.cookie||'';
__qrNickname = d.nickname||'';
__qrStorageUsed = d.storage_used||'';
__qrStorageTotal = d.storage_total||'';
if(__qrNickname){ var n = document.getElementById('dlg_nick'); if(!n.value.trim()) n.value = __qrNickname; }
if(d.promotion_account){ var p = document.getElementById('dlg_promo'); if(!p.value.trim()) p.value = d.promotion_account; }
var si = document.getElementById('dlg_storage_info');
if(__qrStorageTotal){
si.textContent = '💾 ' + (__qrStorageUsed||'?') + ' / ' + __qrStorageTotal;
si.style.color = 'var(--success)';
} else { si.textContent = '空间信息未获取'; si.style.color = 'var(--sub)'; }
if(d.autoUpdated){
document.getElementById('dlg_qr_status').innerHTML = '<span style="color:var(--success);font-size:14px">✅ 扫码成功!已自动更新现有账号 #'+d.updatedConfigId+'</span>';
cancelQRPoll();
toast('✅ 已自动更新账号','success');
setTimeout(function(){ closeDialog(); nav('cloud-config'); }, 1000);
return;
}
document.getElementById('dlg_qr_hint').textContent = '✅ 登录成功!';
document.getElementById('dlg_qr_status').innerHTML = '<span style="color:var(--success);font-size:14px">✅ 扫码成功!已自动填入</span>';
document.getElementById('dlg_cookie').value = __qrCookie;
document.getElementById('dlg_qr_btn').style.display = 'none';
cancelQRPoll();
}else if(d.status==='expired'){
cancelQRPoll();
setTimeout(function(){ doStartQR(); }, 500);
}else{
pollQR();
}
}).catch(function(){ pollQR(); });
}, 3000);
}
function cancelQRPoll(){
if(__qrTimer){ clearTimeout(__qrTimer); __qrTimer = null; }
if(__qrSession){ api('/api/admin/'+__qrType+'/qr-login/'+__qrSession+'/cancel', {method:'POST'}).catch(function(){}); __qrSession=''; }
}

View File

@@ -0,0 +1,92 @@
function renderCloudPage(c, accounts){
accounts = accounts || [];
var h = '';
// Render icon: URL → <img>, emoji/text → plain text
function iconHtml(icon, size){
if(!icon) return '';
if(icon.indexOf('/')===0 || icon.indexOf('http')===0){
return '<img src="'+icon+'" style="width:'+size+'px;height:'+size+'px;vertical-align:middle;margin-right:4px" onerror="this.remove()">';
}
return '<span style="font-size:'+(size-2)+'px;vertical-align:middle;margin-right:4px">'+icon+'</span>';
}
// Toggle grid
h += '<div class="card"><div class="card-title">⚡ 搜索网盘类型控制</div>'+
'<p style="color:var(--sub);font-size:12px;margin-bottom:14px">控制搜索引擎检索哪些网盘类型的资源</p>'+
'<div class="stats-grid" style="grid-template-columns:repeat(auto-fill,minmax(170px,1fr))">';
CLOUD_TYPES.forEach(function(ct){
var on = __cloudToggles[ct.type];
h += '<div class="stat-card" style="padding:8px 12px;display:flex;align-items:center;justify-content:space-between">'+
'<span>'+iconHtml(ct.icon,20)+' <span style="font-size:12px">'+ct.label+'</span></span>'+
'<label class="toggle"><input type="checkbox" '+(on?'checked':'')+' onchange="togCloudType(\''+ct.type+'\',this)"><span class="toggle-slider"></span></label>'+
'</div>';
});
h += '</div></div>';
// Account list — table style
if(accounts.length > 0){
h += '<div class="card"><div class="card-title">📋 已有账号 ('+accounts.length+') <button class="btn btn-pri btn-sm" onclick="openAddDialog()" style="margin-left:12px"> 新增</button></div>';
h += '<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:13px">';
h += '<thead><tr style="background:var(--bg);border-bottom:2px solid var(--border);text-align:left">'+
'<th style="padding:10px 12px;white-space:nowrap">📱推广平台</th>'+
'<th style="padding:10px 12px;white-space:nowrap">推广平台账号</th>'+
'<th style="padding:10px 12px;white-space:nowrap">网盘昵称</th>'+'<th style="padding:10px 12px;white-space:nowrap">网盘UID</th>'+
'<th style="padding:10px 12px;white-space:nowrap">验证</th>'+
'<th style="padding:10px 12px;white-space:nowrap">空间</th>'+
'<th style="padding:10px 12px;white-space:nowrap">转存</th>'+
'<th style="padding:10px 12px;white-space:nowrap">操作</th>'+
'</tr></thead><tbody>';
accounts.forEach(function(cfg){
var label = CLOUD_TYPES.find(function(ct){ return ct.type===cfg.cloud_type; });
var icon = (label||{}).icon||'⬜';
var active = cfg.is_active===1||cfg.is_active===true;
var ck = cfg.verification_status==='valid'?'✅':(cfg.verification_status==='invalid'?'❌':'⏳');
h += '<tr style="border-bottom:1px solid var(--border)">'+
'<td style="padding:10px 12px;white-space:nowrap">'+iconHtml(icon,18)+(label||{}).label+'</td>'+
'<td style="padding:10px 12px;white-space:nowrap">'+(cfg.promotion_account||'—')+'</td>'+
'<td style="padding:10px 12px;white-space:nowrap">'+(cfg.nickname||cfg.cloud_type)+'</td>'+
'<td style="padding:10px 12px;white-space:nowrap;font-size:12px;color:var(--sub)">'+(cfg.cookie_uid||cfg.cloud_type)+'</td>'+
'<td style="padding:10px 12px">'+ck+'</td>'+
'<td style="padding:10px 12px;white-space:nowrap;font-size:12px;color:var(--sub)">'+(cfg.storage_used||cfg.storage_total?'💾 '+(cfg.storage_used||'?')+'/'+(cfg.storage_total||'?'):'—')+'</td>'+
'<td style="padding:10px 12px;white-space:nowrap;font-size:12px;color:var(--sub)">'+(cfg.total_saves?'转存'+cfg.total_saves+'次':'—')+'</td>'+
'<td style="padding:10px 12px;white-space:nowrap">'+
'<label class="toggle"><input type="checkbox" '+(active?'checked':'')+' onchange="togCloudAcc('+cfg.id+',this)"><span class="toggle-slider"></span></label>'+
'<button class="btn btn-danger btn-sm" onclick="delCloudAcc('+cfg.id+')" style="margin-left:8px">🗑</button>'+
'</td>'+
'</tr>';
});
h += '</tbody></table></div></div>';
}
// No accounts yet — show add button
if(accounts.length === 0){
h += '<div class="card"><div class="card-title">📋 已有账号 (0)</div><p style="color:var(--sub);font-size:13px;margin-bottom:12px">还没有添加任何网盘账号</p><button class="btn btn-pri" onclick="openAddDialog()"> 新增网盘</button></div>';
}
// Modal dialog
h += '<div id="modalBg" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.4);z-index:100;align-items:center;justify-content:center" onclick="if(event.target===this)closeDialog()">'+
'<div class="card" style="width:500px;max-width:95vw;max-height:90vh;overflow-y:auto" onclick="event.stopPropagation()">'+
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">'+
'<span style="font-weight:600;font-size:15px">新增网盘账号</span>'+
'<button class="btn btn-outline btn-sm" onclick="closeDialog()">✕</button></div>'+
'<div class="form-group"><label>网盘类型</label><select id="dlg_type" class="form-control" style="max-width:200px" onchange="onDlgTypeChange()">';
CLOUD_TYPES.forEach(function(ct){ h+='<option value="'+ct.type+'">'+ct.label+'</option>'; });
h+='</select></div>'+
'<div class="form-group"><label>推广平台账号</label><input id="dlg_promo" class="form-control" placeholder="平台注册所用手机号" style="max-width:250px"></div>'+
'<div class="form-group"><label>Cookie</label><textarea id="dlg_cookie" class="form-control wide" rows="5" placeholder="粘贴 Cookie 或通过下方扫码获取"></textarea></div>'+
'<div class="form-group"><label>网盘昵称</label><input id="dlg_nick" class="form-control" placeholder="扫码后自动获取" style="max-width:250px;background:#f5f6fa" readonly></div>'+
'<div class="form-group"><label>空间信息</label><span id="dlg_storage_info" style="font-size:13px;color:var(--sub);padding-top:6px">扫码后自动获取</span></div>'+
'<div id="dlg_qr_section" style="margin-top:12px;padding:12px;background:#f9fafb;border-radius:8px">'+
'<p style="font-size:12px;font-weight:600;margin-bottom:8px">📱 扫码获取 Cookie仅夸克/百度)</p>'+
'<div id="dlg_qr_status" style="font-size:12px;color:var(--sub);margin-bottom:8px">点击下方按钮生成扫码链接</div>'+
''+
'<div style="margin-top:8px;display:flex;gap:8px;align-items:center">'+
'<button class="btn btn-pri btn-sm" id="dlg_qr_btn" onclick="doStartQR()">生成扫码链接</button>'+
'<span id="dlg_qr_hint" style="font-size:11px;color:var(--sub)"></span></div></div>'+
'<div style="margin-top:16px;display:flex;gap:8px">'+
'<button class="btn btn-pri" onclick="doSaveAccount()">💾 保存账号</button>'+
'<button class="btn btn-outline" onclick="closeDialog()">取消</button></div></div></div>';
c.innerHTML = h;
}

View File

@@ -0,0 +1,92 @@
function renderCloudPage(c, accounts){
accounts = accounts || [];
var h = '';
// Render icon: URL → <img>, emoji/text → plain text
function iconHtml(icon, size){
if(!icon) return '';
if(icon.indexOf('/')===0 || icon.indexOf('http')===0){
return '<img src="'+icon+'" style="width:'+size+'px;height:'+size+'px;vertical-align:middle;margin-right:4px" onerror="this.remove()">';
}
return '<span style="font-size:'+(size-2)+'px;vertical-align:middle;margin-right:4px">'+icon+'</span>';
}
// Toggle grid
h += '<div class="card"><div class="card-title">⚡ 搜索网盘类型控制</div>'+
'<p style="color:var(--sub);font-size:12px;margin-bottom:14px">控制搜索引擎检索哪些网盘类型的资源</p>'+
'<div class="stats-grid" style="grid-template-columns:repeat(auto-fill,minmax(170px,1fr))">';
CLOUD_TYPES.forEach(function(ct){
var on = __cloudToggles[ct.type];
h += '<div class="stat-card" style="padding:8px 12px;display:flex;align-items:center;justify-content:space-between">'+
'<span>'+iconHtml(ct.icon,20)+' <span style="font-size:12px">'+ct.label+'</span></span>'+
'<label class="toggle"><input type="checkbox" '+(on?'checked':'')+' onchange="togCloudType(\''+ct.type+'\',this)"><span class="toggle-slider"></span></label>'+
'</div>';
});
h += '</div></div>';
// Account list — table style
if(accounts.length > 0){
h += '<div class="card"><div class="card-title">📋 已有账号 ('+accounts.length+') <button class="btn btn-pri btn-sm" onclick="openAddDialog()" style="margin-left:12px"> 新增</button></div>';
h += '<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:13px">';
h += '<thead><tr style="background:var(--bg);border-bottom:2px solid var(--border);text-align:left">'+
'<th style="padding:10px 12px;white-space:nowrap">📱推广平台</th>'+
'<th style="padding:10px 12px;white-space:nowrap">推广平台账号</th>'+
'<th style="padding:10px 12px;white-space:nowrap">网盘昵称</th>'+'<th style="padding:10px 12px;white-space:nowrap">网盘UID</th>'+
'<th style="padding:10px 12px;white-space:nowrap">验证</th>'+
'<th style="padding:10px 12px;white-space:nowrap">空间</th>'+
'<th style="padding:10px 12px;white-space:nowrap">转存</th>'+
'<th style="padding:10px 12px;white-space:nowrap">操作</th>'+
'</tr></thead><tbody>';
accounts.forEach(function(cfg){
var label = CLOUD_TYPES.find(function(ct){ return ct.type===cfg.cloud_type; });
var icon = (label||{}).icon||'⬜';
var active = cfg.is_active===1||cfg.is_active===true;
var ck = cfg.verification_status==='valid'?'✅':(cfg.verification_status==='invalid'?'❌':'—');
h += '<tr style="border-bottom:1px solid var(--border)">'+
'<td style="padding:10px 12px;white-space:nowrap">'+iconHtml(icon,18)+(label||{}).label+'</td>'+
'<td style="padding:10px 12px;white-space:nowrap">'+(cfg.promotion_account||'—')+'</td>'+
'<td style="padding:10px 12px;white-space:nowrap">'+(cfg.nickname||cfg.cloud_type)+'</td>'+
'<td style="padding:10px 12px;white-space:nowrap;font-size:12px;color:var(--sub)">'+(cfg.cookie_uid||cfg.cloud_type)+'</td>'+
'<td style="padding:10px 12px">'+ck+'</td>'+
'<td style="padding:10px 12px;white-space:nowrap;font-size:12px;color:var(--sub)">'+(cfg.storage_used||cfg.storage_total?'💾 '+(cfg.storage_used||'?')+'/'+(cfg.storage_total||'?'):'—')+'</td>'+
'<td style="padding:10px 12px;white-space:nowrap;font-size:12px;color:var(--sub)">'+(cfg.total_saves?'转存'+cfg.total_saves+'次':'—')+'</td>'+
'<td style="padding:10px 12px;white-space:nowrap">'+
'<label class="toggle"><input type="checkbox" '+(active?'checked':'')+' onchange="togCloudAcc('+cfg.id+',this)"><span class="toggle-slider"></span></label>'+
'<button class="btn btn-danger btn-sm" onclick="delCloudAcc('+cfg.id+')" style="margin-left:8px">🗑</button>'+
'</td>'+
'</tr>';
});
h += '</tbody></table></div></div>';
}
// No accounts yet — show add button
if(accounts.length === 0){
h += '<div class="card"><div class="card-title">📋 已有账号 (0)</div><p style="color:var(--sub);font-size:13px;margin-bottom:12px">还没有添加任何网盘账号</p><button class="btn btn-pri" onclick="openAddDialog()"> 新增网盘</button></div>';
}
// Modal dialog
h += '<div id="modalBg" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.4);z-index:100;align-items:center;justify-content:center" onclick="if(event.target===this)closeDialog()">'+
'<div class="card" style="width:500px;max-width:95vw;max-height:90vh;overflow-y:auto" onclick="event.stopPropagation()">'+
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">'+
'<span style="font-weight:600;font-size:15px">新增网盘账号</span>'+
'<button class="btn btn-outline btn-sm" onclick="closeDialog()">✕</button></div>'+
'<div class="form-group"><label>网盘类型</label><select id="dlg_type" class="form-control" style="max-width:200px" onchange="onDlgTypeChange()">';
CLOUD_TYPES.forEach(function(ct){ h+='<option value="'+ct.type+'">'+ct.label+'</option>'; });
h+='</select></div>'+
'<div class="form-group"><label>推广平台账号</label><input id="dlg_promo" class="form-control" placeholder="平台注册所用手机号" style="max-width:250px"></div>'+
'<div class="form-group"><label>Cookie</label><textarea id="dlg_cookie" class="form-control wide" rows="5" placeholder="粘贴 Cookie 或通过下方扫码获取"></textarea></div>'+
'<div class="form-group"><label>网盘昵称</label><input id="dlg_nick" class="form-control" placeholder="扫码后自动获取" style="max-width:250px;background:#f5f6fa" readonly></div>'+
'<div class="form-group"><label>空间信息</label><span id="dlg_storage_info" style="font-size:13px;color:var(--sub);padding-top:6px">扫码后自动获取</span></div>'+
'<div id="dlg_qr_section" style="margin-top:12px;padding:12px;background:#f9fafb;border-radius:8px">'+
'<p style="font-size:12px;font-weight:600;margin-bottom:8px">📱 扫码获取 Cookie仅夸克/百度)</p>'+
'<div id="dlg_qr_status" style="font-size:12px;color:var(--sub);margin-bottom:8px">点击下方按钮生成扫码链接</div>'+
''+
'<div style="margin-top:8px;display:flex;gap:8px;align-items:center">'+
'<button class="btn btn-pri btn-sm" id="dlg_qr_btn" onclick="doStartQR()">生成扫码链接</button>'+
'<span id="dlg_qr_hint" style="font-size:11px;color:var(--sub)"></span></div></div>'+
'<div style="margin-top:16px;display:flex;gap:8px">'+
'<button class="btn btn-pri" onclick="doSaveAccount()">💾 保存账号</button>'+
'<button class="btn btn-outline" onclick="closeDialog()">取消</button></div></div></div>';
c.innerHTML = h;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.admin-menu .menu-header[data-v-529b614b]{padding:16px 20px 8px;text-align:center;border-bottom:1px solid var(--el-border-color-light)}.admin-menu .menu-header h2[data-v-529b614b]{margin:0;font-size:16px;color:var(--el-color-primary)}.admin-menu .menu-header p[data-v-529b614b]{margin:4px 0 0;font-size:12px;color:var(--el-text-color-secondary)}.version-footer[data-v-529b614b]{padding:8px;text-align:center;font-size:11px;color:var(--el-text-color-placeholder);border-top:1px solid var(--el-border-color-light);margin-top:auto}.admin-layout[data-v-529b614b]{display:flex;height:100vh}.admin-menu[data-v-529b614b]{width:220px;flex-shrink:0;display:flex;flex-direction:column}.admin-content[data-v-529b614b]{flex:1;display:flex;flex-direction:column;overflow:hidden}.content-header[data-v-529b614b]{display:flex;align-items:center;justify-content:space-between;padding:12px 24px;border-bottom:1px solid var(--el-border-color-light);background:var(--el-bg-color)}.content-header h2[data-v-529b614b]{margin:0;font-size:18px}.content-body[data-v-529b614b]{flex:1;overflow-y:auto;padding:20px 24px;background:var(--el-bg-color-page)}

View File

@@ -0,0 +1 @@
import{d as B,o as N,a as T,c as V,f as s,w as t,b as o,t as c,h as v,v as y,j as d,k as r,C as I,l as u,D as M,G as j,H as q,I as A,u as D,z as H}from"./index-C5b4pIQL.js";import{a as L,_ as R}from"./_plugin-vue_export-helper-CzL5NdOX.js";const z={class:"admin-layout"},E={class:"menu-header"},G={class:"version-footer"},W={class:"admin-content"},F={class:"content-header"},J={class:"content-body"},K=B({__name:"AdminLayout",setup(O){const l=D(),f=H(),m=v(""),_=v(""),b={dashboard:"仪表盘","cloud-configs-toggle":"网盘设置及授权","cloud-configs-cleanup":"存储清理","sys-site":"网站设置","sys-services":"外部服务 & 缓存","sys-strategy":"性能配置","sys-password":"修改管理员密码","save-records":"转存日志"},p=y(()=>{const n=f.name;return n==="admin-cloud-configs"?"cloud-configs-toggle":n==="admin-cleanup"?"cloud-configs-cleanup":n==="admin-system"?f.query.section||"sys-site":n==="admin-save-records"?"save-records":"dashboard"}),h=y(()=>b[p.value]||"仪表盘");function x(n){n==="dashboard"?l.push("/admin/dashboard"):n==="cloud-configs-toggle"?l.push("/admin/cloud-configs"):n==="cloud-configs-cleanup"?l.push("/admin/cleanup"):n.startsWith("sys-")?l.push({path:"/admin/system",query:{section:n}}):n==="save-records"?l.push("/admin/save-records"):n==="logout"&&(localStorage.removeItem("admin_token"),l.push("/admin/login"))}function w(){l.push("/")}return N(async()=>{try{const n=await L();m.value=n.site_name||""}catch{}try{const e=await(await fetch("/health")).json();_.value=e.version}catch{}}),(n,e)=>{const i=d("el-icon"),a=d("el-menu-item"),g=d("el-sub-menu"),C=d("el-menu"),k=d("el-button"),S=d("router-view");return T(),V("div",z,[s(C,{"default-active":p.value,class:"admin-menu",onSelect:x},{default:t(()=>[o("div",E,[o("h2",null,c(m.value||"CloudSearch"),1),e[0]||(e[0]=o("p",null,"管理后台",-1))]),s(a,{index:"dashboard"},{default:t(()=>[s(i,null,{default:t(()=>[s(r(I))]),_:1}),e[1]||(e[1]=o("span",null,"仪表盘",-1))]),_:1}),s(g,{index:"cloud-configs"},{title:t(()=>[s(i,null,{default:t(()=>[s(r(M))]),_:1}),e[2]||(e[2]=o("span",null,"网盘配置",-1))]),default:t(()=>[s(a,{index:"cloud-configs-toggle"},{default:t(()=>[...e[3]||(e[3]=[u("网盘设置及授权",-1)])]),_:1}),s(a,{index:"cloud-configs-cleanup"},{default:t(()=>[...e[4]||(e[4]=[u("存储清理",-1)])]),_:1})]),_:1}),s(g,{index:"system"},{title:t(()=>[s(i,null,{default:t(()=>[s(r(j))]),_:1}),e[5]||(e[5]=o("span",null,"系统配置",-1))]),default:t(()=>[s(a,{index:"sys-site"},{default:t(()=>[...e[6]||(e[6]=[u("网站设置",-1)])]),_:1}),s(a,{index:"sys-services"},{default:t(()=>[...e[7]||(e[7]=[u("外部服务和缓存",-1)])]),_:1}),s(a,{index:"sys-strategy"},{default:t(()=>[...e[8]||(e[8]=[u("性能配置",-1)])]),_:1}),s(a,{index:"sys-password"},{default:t(()=>[...e[9]||(e[9]=[u("修改管理员密码",-1)])]),_:1})]),_:1}),s(a,{index:"save-records"},{default:t(()=>[s(i,null,{default:t(()=>[s(r(q))]),_:1}),e[10]||(e[10]=o("span",null,"转存日志",-1))]),_:1}),o("div",G,"T "+c(_.value),1),s(a,{index:"logout"},{default:t(()=>[s(i,null,{default:t(()=>[s(r(A))]),_:1}),e[11]||(e[11]=o("span",null,"退出登录",-1))]),_:1})]),_:1},8,["default-active"]),o("div",W,[o("div",F,[o("h2",null,c(h.value),1),s(k,{text:"",onClick:w},{default:t(()=>[...e[12]||(e[12]=[u("返回前台",-1)])]),_:1})]),o("div",J,[s(S)])])])}}}),U=R(K,[["__scopeId","data-v-529b614b"]]);export{U as default};

View File

@@ -0,0 +1 @@
.admin-login-page[data-v-513ea931]{min-height:100vh;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#667eea,#764ba2)}.login-card[data-v-513ea931]{width:400px;padding:40px;background:var(--bg-white);border-radius:16px;box-shadow:0 8px 32px #00000026}.login-title[data-v-513ea931]{text-align:center;font-size:24px;font-weight:700;color:#303133;margin-bottom:32px}.login-btn[data-v-513ea931]{width:100%}.error-msg[data-v-513ea931]{text-align:center;color:#f56c6c;font-size:14px;margin-top:12px}

View File

@@ -0,0 +1 @@
import{d as k,a as g,c as v,b as w,t as h,f as s,w as l,g as b,e as x,h as d,j as m,l as C,i as L,E as N}from"./index-C5b4pIQL.js";import{a as S,d as B,_ as E}from"./_plugin-vue_export-helper-CzL5NdOX.js";const U={class:"admin-login-page"},q={class:"login-card"},A={class:"login-title"},I={key:0,class:"error-msg"},K=k({__name:"AdminLogin",setup(M){const p=d(),u=d(!1),r=d(""),_=d("");S().then(i=>{i.site_name&&(_.value=i.site_name)}).catch(()=>{});const o=L({username:"",password:""}),y={username:[{required:!0,message:"请输入用户名",trigger:"blur"}],password:[{required:!0,message:"请输入密码",trigger:"blur"}]};async function f(){var a,n,t;if(await((a=p.value)==null?void 0:a.validate().catch(()=>!1))){u.value=!0,r.value="";try{const e=await B(o.username,o.password);localStorage.setItem("admin_token",e.token),N.success("登录成功"),window.location.href="/admin"}catch(e){r.value=((t=(n=e==null?void 0:e.response)==null?void 0:n.data)==null?void 0:t.message)||(e==null?void 0:e.message)||"登录失败"}finally{u.value=!1}}}return(i,a)=>{const n=m("el-input"),t=m("el-form-item"),e=m("el-button"),V=m("el-form");return g(),v("div",U,[w("div",q,[w("h1",A,h(_.value||"CloudSearch")+" 管理后台",1),s(V,{ref_key:"formRef",ref:p,model:o,rules:y,"label-width":"0",size:"large",onKeyup:b(f,["enter"])},{default:l(()=>[s(t,{prop:"username"},{default:l(()=>[s(n,{modelValue:o.username,"onUpdate:modelValue":a[0]||(a[0]=c=>o.username=c),placeholder:"用户名","prefix-icon":"User"},null,8,["modelValue"])]),_:1}),s(t,{prop:"password"},{default:l(()=>[s(n,{modelValue:o.password,"onUpdate:modelValue":a[1]||(a[1]=c=>o.password=c),type:"password",placeholder:"密码","prefix-icon":"Lock","show-password":""},null,8,["modelValue"])]),_:1}),s(t,null,{default:l(()=>[s(e,{type:"primary",loading:u.value,class:"login-btn",onClick:f},{default:l(()=>[...a[2]||(a[2]=[C(" 登录 ",-1)])]),_:1},8,["loading"])]),_:1})]),_:1},8,["model"]),r.value?(g(),v("p",I,h(r.value),1)):x("",!0)])])}}}),z=E(K,[["__scopeId","data-v-513ea931"]]);export{z as default};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.cleanup-section .config-card[data-v-96d69897]{max-width:800px}.form-tip[data-v-96d69897]{font-size:12px;color:var(--el-text-color-secondary)}.cleanup-info[data-v-96d69897]{font-size:13px;color:var(--el-text-color-secondary)}

View File

@@ -0,0 +1 @@
.cloud-badge[data-v-5857e8ce]{display:inline-block;padding:2px 8px;border-radius:4px;color:#fff;font-size:12px;line-height:1.5;white-space:nowrap}

View File

@@ -0,0 +1 @@
import{C as o,a as t}from"./index-Bz21yOih.js";import{d as s,a as c,c as r,p as n,k as a,t as p}from"./index-C5b4pIQL.js";import{_ as d}from"./_plugin-vue_export-helper-CzL5NdOX.js";const l=s({__name:"CloudBadge",props:{cloud_type:{}},setup(e){return(_,m)=>(c(),r("span",{class:"cloud-badge",style:n({background:a(o)[e.cloud_type]})},p(a(t)[e.cloud_type]),5))}}),f=d(l,[["__scopeId","data-v-5857e8ce"]]);export{f as C};

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