v0.2.7: 修复Redis连接 + 启动管理后台
- 修复Redis认证 (配置密码) - 启动Python管理后台 (端口9531, 15个功能开关) - 统一版本号 0.2.7 - 更新docker-compose.yml (镜像版本/Redis URL/Admin服务)
35
.env.template
Normal 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
@@ -0,0 +1,12 @@
|
||||
.env
|
||||
*.sqlite
|
||||
*.sqlite-shm
|
||||
*.sqlite-wal
|
||||
uploads/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.DS_Store
|
||||
node_modules/
|
||||
dist/
|
||||
*.tar.gz
|
||||
*.zip
|
||||
18
cloudsearch_admin/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY server.py .
|
||||
COPY templates/ templates/
|
||||
|
||||
RUN mkdir -p /data
|
||||
|
||||
EXPOSE 9531
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:9531/health')"
|
||||
|
||||
CMD ["python", "server.py"]
|
||||
196
cloudsearch_admin/features.html
Normal file
@@ -0,0 +1,196 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>功能开关 - CloudSearch</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f5f7fa; --card-bg: #fff; --text: #303133; --muted: #909399;
|
||||
--border: #e4e7ed; --primary: #409eff; --success: #67c23a; --danger: #f56c6c;
|
||||
--warning: #e6a23c;
|
||||
}
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { font-family: -apple-system, 'Helvetica Neue', sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
|
||||
.nav { background: var(--card-bg); border-bottom: 1px solid var(--border); padding: 0 24px; height: 56px; display: flex; align-items: center; justify-content: space-between; }
|
||||
.nav h2 { font-size: 18px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
|
||||
.nav .badge { font-size: 11px; background: var(--primary); color: #fff; padding: 2px 8px; border-radius: 10px; }
|
||||
.nav a { color: var(--primary); text-decoration: none; font-size: 13px; }
|
||||
.container { max-width: 800px; margin: 0 auto; padding: 24px 16px; }
|
||||
.group { margin-bottom: 24px; }
|
||||
.group-title { font-size: 13px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 1px; padding: 8px 0; border-bottom: 1px solid var(--border); margin-bottom: 12px; }
|
||||
.card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 16px 20px; margin-bottom: 8px; display: flex; align-items: center; justify-content: space-between; transition: box-shadow .15s; }
|
||||
.card:hover { box-shadow: 0 2px 8px rgba(0,0,0,.06); }
|
||||
.card-info h3 { font-size: 14px; font-weight: 500; }
|
||||
.card-info .key { font-size: 11px; color: var(--muted); font-family: 'SF Mono', Monaco, monospace; margin-top: 2px; }
|
||||
.toggle-wrap { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
|
||||
.toggle { position: relative; width: 44px; height: 24px; border-radius: 24px; border: none; cursor: pointer; transition: background .2s; outline: none; }
|
||||
.toggle:focus-visible { box-shadow: 0 0 0 2px rgba(64,158,255,.4); }
|
||||
.toggle.off { background: #c0c4cc; }
|
||||
.toggle.on { background: var(--success); }
|
||||
.toggle::after { content: ''; position: absolute; top: 2px; left: 2px; width: 20px; height: 20px; border-radius: 50%; background: #fff; transition: transform .2s ease; box-shadow: 0 1px 2px rgba(0,0,0,.15); }
|
||||
.toggle.on::after { transform: translateX(20px); }
|
||||
.toggle:disabled { opacity: .4; cursor: not-allowed; }
|
||||
.status { font-size: 12px; min-width: 36px; text-align: center; }
|
||||
.status.on { color: var(--success); }
|
||||
.status.off { color: var(--muted); }
|
||||
.actions { display: flex; gap: 12px; justify-content: flex-end; margin-top: 24px; }
|
||||
.btn { padding: 8px 20px; border: 1px solid var(--border); border-radius: 6px; background: var(--card-bg); color: var(--text); cursor: pointer; font-size: 13px; transition: .15s; }
|
||||
.btn:hover { border-color: var(--primary); color: var(--primary); }
|
||||
.btn.primary { background: var(--primary); border-color: var(--primary); color: #fff; }
|
||||
.btn.primary:hover { opacity: .85; }
|
||||
.toast { position: fixed; top: 20px; left: 50%; transform: translateX(-50%); background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 10px 24px; font-size: 14px; box-shadow: 0 4px 12px rgba(0,0,0,.1); z-index: 999; opacity: 0; transition: opacity .25s; }
|
||||
.toast.show { opacity: 1; }
|
||||
.toast.ok { border-color: var(--success); }
|
||||
.toast.err { border-color: var(--danger); }
|
||||
.loading-overlay { text-align: center; padding: 60px; color: var(--muted); font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="nav">
|
||||
<h2>🔧 功能开关 <span class="badge">v2.2</span></h2>
|
||||
<a href="/admin">← 返回管理后台</a>
|
||||
</div>
|
||||
<div class="container" id="app">
|
||||
<div class="loading-overlay">加载中…</div>
|
||||
</div>
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
// ========== Feature Definitions ==========
|
||||
const FEATURES = [
|
||||
// 核心功能
|
||||
{ key: 'feature_quark_pid', name: '夸克推广PID', group: '核心功能', default: true },
|
||||
{ key: 'feature_seo', name: 'SEO / Sitemap', group: '核心功能', default: true },
|
||||
{ key: 'feature_link_monitor', name: '失效链接监控', group: '核心功能', default: true },
|
||||
// 增强功能
|
||||
{ key: 'feature_tmdb', name: 'TMDB影视刮削', group: '增强功能', default: true },
|
||||
{ key: 'feature_telegram_bot', name: 'Telegram Bot', group: '增强功能', default: false },
|
||||
{ key: 'feature_subscription', name: '关键词订阅通知', group: '增强功能', default: false },
|
||||
{ key: 'feature_alist', name: 'AList打通', group: '增强功能', default: false },
|
||||
// 网盘转存
|
||||
{ key: 'feature_transfer_quark', name: '夸克转存', group: '网盘转存', default: true },
|
||||
{ key: 'feature_transfer_baidu', name: '百度转存', group: '网盘转存', default: false },
|
||||
{ key: 'feature_transfer_aliyun', name: '阿里转存', group: '网盘转存', default: false },
|
||||
{ key: 'feature_transfer_uc', name: 'UC转存', group: '网盘转存', default: false },
|
||||
{ key: 'feature_transfer_xunlei', name: '迅雷转存', group: '网盘转存', default: false },
|
||||
{ key: 'feature_transfer_115', name: '115转存', group: '网盘转存', default: false },
|
||||
{ key: 'feature_transfer_123', name: '123转存', group: '网盘转存', default: false },
|
||||
{ key: 'feature_transfer_cloud189',name: '天翼转存', group: '网盘转存', default: false },
|
||||
];
|
||||
|
||||
// ========== API helpers ==========
|
||||
const BASE = '';
|
||||
function token() { return localStorage.getItem('admin_token') || ''; }
|
||||
function headers() { const h = {'Content-Type':'application/json'}; const t=token(); if(t) h['Authorization']='Bearer '+t; return h; }
|
||||
|
||||
async function apiGet(url) {
|
||||
const res = await fetch(BASE + url, { headers: headers() });
|
||||
if (res.status === 401) { alert('登录已过期,请重新登录'); location.href = '/admin'; return null; }
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
return res.json();
|
||||
}
|
||||
async function apiPut(url, body) {
|
||||
const res = await fetch(BASE + url, { method:'PUT', headers:headers(), body:JSON.stringify(body) });
|
||||
if (res.status === 401) { alert('登录已过期,请重新登录'); location.href = '/admin'; return null; }
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ========== State ==========
|
||||
let configs = {}; // key -> value from system_configs
|
||||
|
||||
// ========== Toast ==========
|
||||
function toast(msg, type) {
|
||||
const el = document.getElementById('toast');
|
||||
el.textContent = msg; el.className = 'toast ' + (type||'ok') + ' show';
|
||||
clearTimeout(el._t); el._t = setTimeout(() => el.classList.remove('show'), 2500);
|
||||
}
|
||||
|
||||
// ========== Toggle ==========
|
||||
async function toggleFeature(key, currentVal) {
|
||||
const newVal = !currentVal;
|
||||
// Optimistic UI update
|
||||
configs[key] = String(newVal);
|
||||
render();
|
||||
|
||||
try {
|
||||
await apiPut('/api/admin/system-configs', {
|
||||
entries: [{ key, value: newVal ? 'true' : 'false' }]
|
||||
});
|
||||
toast(newVal ? `✅ ${key.replace('feature_','')} 已开启` : `❌ ${key.replace('feature_','')} 已关闭`, 'ok');
|
||||
} catch(e) {
|
||||
// Revert
|
||||
configs[key] = String(currentVal);
|
||||
render();
|
||||
toast('切换失败: ' + e.message, 'err');
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleAll(groupKeys, enable) {
|
||||
const entries = groupKeys.map(k => ({ key: k, value: enable ? 'true' : 'false' }));
|
||||
for (const e of entries) configs[e.key] = e.value;
|
||||
render();
|
||||
try {
|
||||
await apiPut('/api/admin/system-configs', { entries });
|
||||
toast(`${enable ? '✅' : '❌'} 已${enable ? '开启' : '关闭'} ${entries.length} 个开关`, 'ok');
|
||||
} catch(e) {
|
||||
await load(); // revert
|
||||
toast('批量操作失败: ' + e.message, 'err');
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Load ==========
|
||||
async function load() {
|
||||
try {
|
||||
const data = await apiGet('/api/admin/system-configs');
|
||||
if (!data) return;
|
||||
configs = {};
|
||||
for (const row of data) { configs[row.key] = row.value; }
|
||||
render();
|
||||
} catch(e) {
|
||||
document.getElementById('app').innerHTML =
|
||||
'<div class="loading-overlay" style="color:var(--danger)">加载失败: ' + e.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Render ==========
|
||||
function render() {
|
||||
const groups = {};
|
||||
for (const f of FEATURES) {
|
||||
(groups[f.group] || (groups[f.group] = [])).push(f);
|
||||
}
|
||||
|
||||
let html = '';
|
||||
for (const [group, items] of Object.entries(groups)) {
|
||||
const keys = items.map(f => f.key);
|
||||
const allOn = keys.every(k => configs[k] === 'true');
|
||||
const allOff = keys.every(k => configs[k] !== 'true');
|
||||
html += '<div class="group">';
|
||||
html += '<div class="group-title" style="display:flex;justify-content:space-between;align-items:center">';
|
||||
html += '<span>' + group + '</span>';
|
||||
html += '<span style="font-weight:400;font-size:12px">';
|
||||
html += '<a href="#" onclick="toggleAll(' + JSON.stringify(keys) + ',true);return false" style="margin-right:8px">全部开启</a>';
|
||||
html += '<a href="#" onclick="toggleAll(' + JSON.stringify(keys) + ',false);return false">全部关闭</a>';
|
||||
html += '</span></div>';
|
||||
for (const f of items) {
|
||||
const val = configs[f.key] === 'true';
|
||||
html += '<div class="card">';
|
||||
html += '<div class="card-info"><h3>' + f.name + '</h3><div class="key">' + f.key + '</div></div>';
|
||||
html += '<div class="toggle-wrap">';
|
||||
html += '<span class="status ' + (val ? 'on' : 'off') + '">' + (val ? '开' : '关') + '</span>';
|
||||
html += '<button class="toggle ' + (val ? 'on' : 'off') + '" onclick="toggleFeature(\'' + f.key + '\',' + val + ')" title="点击切换"></button>';
|
||||
html += '</div></div>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '<div class="actions"><button class="btn" onclick="load()">🔄 刷新状态</button></div>';
|
||||
document.getElementById('app').innerHTML = html;
|
||||
}
|
||||
|
||||
// ========== Init ==========
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
3
cloudsearch_admin/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
flask>=3.0
|
||||
waitress>=2.1
|
||||
pymysql>=1.1
|
||||
206
cloudsearch_admin/server.py
Normal 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)
|
||||
149
cloudsearch_admin/templates/admin.html
Normal 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>
|
||||
8
cloudsearch_enrich/Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY *.py .
|
||||
EXPOSE 9530 9532
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:9530/health')"
|
||||
CMD ["sh", "-c", "python search_enricher.py & python feishu_bot.py & python subscription_monitor.py & wait"]
|
||||
319
cloudsearch_enrich/feishu_bot.py
Normal file
@@ -0,0 +1,319 @@
|
||||
"""
|
||||
CloudSearch 飞书 Bot v1.0.0
|
||||
替代 Telegram Bot,支持 /search /subscribe 命令 + Webhook 推送
|
||||
通过飞书开放平台事件订阅接收消息
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import hmac
|
||||
import hashlib
|
||||
import logging
|
||||
import sqlite3
|
||||
from typing import Optional
|
||||
from flask import Flask, request, jsonify
|
||||
|
||||
import requests
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger("feishubot")
|
||||
|
||||
# ── 飞书配置 ──────────────────────────────────
|
||||
APP_ID = os.environ.get("FEISHU_APP_ID", "")
|
||||
APP_SECRET = os.environ.get("FEISHU_APP_SECRET", "")
|
||||
VERIFY_TOKEN = os.environ.get("FEISHU_VERIFY_TOKEN", "")
|
||||
WEBHOOK_URL = os.environ.get("FEISHU_WEBHOOK_URL", "")
|
||||
CLOUDSEARCH_API = os.environ.get("CLOUDSEARCH_API", "http://app:9527")
|
||||
DB_PATH = os.environ.get("BOT_DB_PATH", "/data/bot.db")
|
||||
|
||||
# ── 飞书API ───────────────────────────────────
|
||||
FEISHU_TOKEN_URL = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
|
||||
FEISHU_SEND_URL = "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=open_id"
|
||||
|
||||
_tenant_token = None
|
||||
_token_expire = 0
|
||||
|
||||
def get_tenant_token() -> str:
|
||||
"""获取飞书 tenant_access_token(缓存2h)"""
|
||||
global _tenant_token, _token_expire
|
||||
if _tenant_token and time.time() < _token_expire:
|
||||
return _tenant_token
|
||||
resp = requests.post(FEISHU_TOKEN_URL, json={
|
||||
"app_id": APP_ID, "app_secret": APP_SECRET
|
||||
}, timeout=10)
|
||||
data = resp.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"获取飞书Token失败: {data}")
|
||||
_tenant_token = data["tenant_access_token"]
|
||||
_token_expire = time.time() + data.get("expire", 7200) - 300
|
||||
logger.info("飞书 tenant_token 已刷新")
|
||||
return _tenant_token
|
||||
|
||||
def send_feishu_msg(open_id: str, content: str, msg_type: str = "text"):
|
||||
"""发送飞书消息"""
|
||||
body = {
|
||||
"receive_id": open_id,
|
||||
"msg_type": msg_type,
|
||||
"content": json.dumps({"text": content}) if msg_type == "text" else content
|
||||
}
|
||||
resp = requests.post(
|
||||
FEISHU_SEND_URL,
|
||||
headers={"Authorization": f"Bearer {get_tenant_token()}"},
|
||||
json=body, timeout=10
|
||||
)
|
||||
data = resp.json()
|
||||
if data.get("code") != 0:
|
||||
logger.error(f"发送飞书消息失败: {data}")
|
||||
return data.get("code") == 0
|
||||
|
||||
def send_feishu_card(open_id: str, card: dict):
|
||||
"""发送飞书卡片消息"""
|
||||
body = {
|
||||
"receive_id": open_id,
|
||||
"msg_type": "interactive",
|
||||
"content": json.dumps(card)
|
||||
}
|
||||
resp = requests.post(
|
||||
FEISHU_SEND_URL,
|
||||
headers={"Authorization": f"Bearer {get_tenant_token()}"},
|
||||
json=body, timeout=10
|
||||
)
|
||||
return resp.json().get("code") == 0
|
||||
|
||||
def send_webhook(text: str):
|
||||
"""通过 Webhook 推送通知(用于订阅变更)"""
|
||||
if not WEBHOOK_URL:
|
||||
return
|
||||
try:
|
||||
requests.post(WEBHOOK_URL, json={
|
||||
"msg_type": "text",
|
||||
"content": {"text": text}
|
||||
}, timeout=10)
|
||||
except Exception as e:
|
||||
logger.error(f"Webhook推送失败: {e}")
|
||||
|
||||
# ── Bot 核心逻辑 ────────────────────────────────
|
||||
class FeishuBot:
|
||||
def __init__(self):
|
||||
self.db = sqlite3.connect(DB_PATH, check_same_thread=False)
|
||||
self._init_db()
|
||||
|
||||
def _init_db(self):
|
||||
self.db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
open_id TEXT NOT NULL,
|
||||
keyword TEXT NOT NULL,
|
||||
last_check TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now','localtime')),
|
||||
UNIQUE(open_id, keyword)
|
||||
)
|
||||
""")
|
||||
self.db.commit()
|
||||
logger.info("订阅数据库就绪")
|
||||
|
||||
def handle_text(self, open_id: str, text: str):
|
||||
"""处理文本消息"""
|
||||
text = text.strip()
|
||||
if text.startswith("/search"):
|
||||
keyword = text.replace("/search", "", 1).strip()
|
||||
return self._cmd_search(open_id, keyword)
|
||||
elif text.startswith("/subscribe"):
|
||||
keyword = text.replace("/subscribe", "", 1).strip()
|
||||
return self._cmd_subscribe(open_id, keyword)
|
||||
elif text.startswith("/unsub"):
|
||||
keyword = text.replace("/unsub", "", 1).strip()
|
||||
return self._cmd_unsub(open_id, keyword)
|
||||
elif text.startswith("/mysubs"):
|
||||
return self._cmd_mysubs(open_id)
|
||||
elif text.startswith("/help") or text.lower() == "help":
|
||||
return self._cmd_help(open_id)
|
||||
else:
|
||||
return self._cmd_search(open_id, text) # 默认搜索
|
||||
|
||||
def _cmd_help(self, open_id: str):
|
||||
help_text = (
|
||||
"🔍 CloudSearch Bot\n\n"
|
||||
"命令:\n"
|
||||
"/search 关键词 — 搜索网盘资源\n"
|
||||
"直接输入关键词也可以搜索\n"
|
||||
"/subscribe 关键词 — 订阅关键词\n"
|
||||
"/unsub 关键词 — 取消订阅\n"
|
||||
"/mysubs — 查看我的订阅\n"
|
||||
"/help — 帮助"
|
||||
)
|
||||
send_feishu_msg(open_id, help_text)
|
||||
|
||||
def _cmd_search(self, open_id: str, keyword: str):
|
||||
if not keyword:
|
||||
send_feishu_msg(open_id, "用法: /search 流浪地球2\n或直接输入关键词")
|
||||
return
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{CLOUDSEARCH_API}/api/query",
|
||||
json={"q": keyword}, timeout=15
|
||||
)
|
||||
results = []
|
||||
for line in resp.text.strip().split("\n"):
|
||||
try:
|
||||
d = json.loads(line)
|
||||
if d.get("type") == "result":
|
||||
results.append(d)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
if not results:
|
||||
send_feishu_msg(open_id, f"😞 未找到「{keyword}」的相关资源")
|
||||
return
|
||||
|
||||
# 构建飞书卡片
|
||||
elements = []
|
||||
for i, r in enumerate(results[:5]):
|
||||
title = (r.get("title") or r.get("content", ""))[:50]
|
||||
cloud = r.get("cloud_type", "?").upper()
|
||||
pwd = r.get("password", "")
|
||||
pwd_str = f" 🔑{pwd}" if pwd else ""
|
||||
elements.append({
|
||||
"tag": "div",
|
||||
"text": {"tag": "lark_md", "content": f"**{i+1}.** [{cloud}] {title}{pwd_str}"}
|
||||
})
|
||||
|
||||
card = {
|
||||
"header": {
|
||||
"title": {"tag": "plain_text", "content": f"🔎 {keyword} — {len(results)}个结果"},
|
||||
"template": "blue"
|
||||
},
|
||||
"elements": elements + [{
|
||||
"tag": "action",
|
||||
"actions": [{
|
||||
"tag": "button",
|
||||
"text": {"tag": "plain_text", "content": "🌐 查看更多"},
|
||||
"type": "primary",
|
||||
"url": f"{CLOUDSEARCH_API}/?q={keyword}"
|
||||
}]
|
||||
}]
|
||||
}
|
||||
send_feishu_card(open_id, card)
|
||||
|
||||
except Exception as e:
|
||||
send_feishu_msg(open_id, f"❌ 搜索失败: {e}")
|
||||
|
||||
def _cmd_subscribe(self, open_id: str, keyword: str):
|
||||
if not keyword:
|
||||
send_feishu_msg(open_id, "用法: /subscribe 流浪地球")
|
||||
return
|
||||
try:
|
||||
self.db.execute(
|
||||
"INSERT OR IGNORE INTO subscriptions (open_id, keyword) VALUES (?, ?)",
|
||||
(open_id, keyword)
|
||||
)
|
||||
self.db.commit()
|
||||
send_feishu_msg(open_id, f"✅ 已订阅「{keyword}」,有新结果会通知你")
|
||||
except Exception as e:
|
||||
send_feishu_msg(open_id, f"❌ 订阅失败: {e}")
|
||||
|
||||
def _cmd_unsub(self, open_id: str, keyword: str):
|
||||
if not keyword:
|
||||
send_feishu_msg(open_id, "用法: /unsub 流浪地球")
|
||||
return
|
||||
cur = self.db.execute(
|
||||
"DELETE FROM subscriptions WHERE open_id=? AND keyword=?",
|
||||
(open_id, keyword)
|
||||
)
|
||||
self.db.commit()
|
||||
if cur.rowcount > 0:
|
||||
send_feishu_msg(open_id, f"✅ 已取消订阅「{keyword}」")
|
||||
else:
|
||||
send_feishu_msg(open_id, f"未找到「{keyword}」的订阅")
|
||||
|
||||
def _cmd_mysubs(self, open_id: str):
|
||||
rows = self.db.execute(
|
||||
"SELECT keyword, created_at FROM subscriptions WHERE open_id=? ORDER BY created_at DESC",
|
||||
(open_id,)
|
||||
).fetchall()
|
||||
if not rows:
|
||||
send_feishu_msg(open_id, "你还没有订阅任何关键词")
|
||||
return
|
||||
text = "📋 我的订阅:\n"
|
||||
for kw, dt in rows:
|
||||
text += f"• {kw} ({dt[:10]})\n"
|
||||
send_feishu_msg(open_id, text)
|
||||
|
||||
def check_subscriptions(self):
|
||||
"""检查所有订阅,有新结果时推送通知"""
|
||||
subs = self.db.execute("SELECT DISTINCT keyword FROM subscriptions").fetchall()
|
||||
for (kw,) in subs:
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{CLOUDSEARCH_API}/api/query",
|
||||
json={"q": kw}, timeout=10
|
||||
)
|
||||
count = sum(1 for line in resp.text.split("\n")
|
||||
if '"type":"result"' in line)
|
||||
if count > 0:
|
||||
# 通知所有订阅此关键词的用户
|
||||
users = self.db.execute(
|
||||
"SELECT open_id FROM subscriptions WHERE keyword=?",
|
||||
(kw,)
|
||||
).fetchall()
|
||||
for (uid,) in users:
|
||||
send_feishu_msg(uid, f"🔔「{kw}」有新资源({count}个)!\n/search {kw}")
|
||||
# Webhook 也推送
|
||||
send_webhook(f"🔔 关键词「{kw}」发现 {count} 个新资源")
|
||||
except Exception as e:
|
||||
logger.error(f"检查订阅[{kw}]失败: {e}")
|
||||
|
||||
# ── Flask Web 服务 ─────────────────────────────
|
||||
bot = FeishuBot()
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.route("/health")
|
||||
def health():
|
||||
return jsonify({"status": "ok", "bot": "feishu"})
|
||||
|
||||
@app.route("/feishu/event", methods=["POST"])
|
||||
def feishu_event():
|
||||
"""飞书事件订阅回调"""
|
||||
body = request.get_json()
|
||||
logger.info(f"飞书事件: {json.dumps(body, ensure_ascii=False)[:300]}")
|
||||
|
||||
# Token 验证(首次配置URL时)
|
||||
if body.get("type") == "url_verification":
|
||||
token = body.get("token", "")
|
||||
if token == VERIFY_TOKEN:
|
||||
return jsonify({"challenge": body.get("challenge", "")})
|
||||
return jsonify({"error": "invalid token"}), 403
|
||||
|
||||
# 事件回调验证
|
||||
if "header" in body:
|
||||
# 收到消息事件
|
||||
event = body.get("event", {})
|
||||
msg_type = event.get("message", {}).get("message_type", "")
|
||||
if msg_type == "text":
|
||||
content = event["message"].get("content", "{}")
|
||||
try:
|
||||
text = json.loads(content).get("text", "")
|
||||
except json.JSONDecodeError:
|
||||
text = content
|
||||
open_id = event.get("sender", {}).get("sender_id", {}).get("open_id", "")
|
||||
if text and open_id:
|
||||
bot.handle_text(open_id, text)
|
||||
|
||||
return jsonify({"code": 0})
|
||||
|
||||
@app.route("/feishu/check", methods=["POST"])
|
||||
def trigger_check():
|
||||
"""手动触发订阅检查"""
|
||||
bot.check_subscriptions()
|
||||
return jsonify({"ok": True})
|
||||
|
||||
# ── 启动入口 ───────────────────────────────────
|
||||
def main():
|
||||
if not APP_ID:
|
||||
logger.warning("FEISHU_APP_ID 未设置,Bot 无法接收消息(仅 Webhook 可用)")
|
||||
logger.info("飞书 Bot 启动,端口9531")
|
||||
app.run(host="0.0.0.0", port=9532)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
319
cloudsearch_enrich/feishu_bot_tmp.py
Normal file
@@ -0,0 +1,319 @@
|
||||
"""
|
||||
CloudSearch 飞书 Bot v1.0.0
|
||||
替代 Telegram Bot,支持 /search /subscribe 命令 + Webhook 推送
|
||||
通过飞书开放平台事件订阅接收消息
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import hmac
|
||||
import hashlib
|
||||
import logging
|
||||
import sqlite3
|
||||
from typing import Optional
|
||||
from flask import Flask, request, jsonify
|
||||
|
||||
import requests
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger("feishubot")
|
||||
|
||||
# ── 飞书配置 ──────────────────────────────────
|
||||
APP_ID = os.environ.get("FEISHU_APP_ID", "")
|
||||
APP_SECRET = os.environ.get("FEISHU_APP_SECRET", "")
|
||||
VERIFY_TOKEN = os.environ.get("FEISHU_VERIFY_TOKEN", "")
|
||||
WEBHOOK_URL = os.environ.get("FEISHU_WEBHOOK_URL", "")
|
||||
CLOUDSEARCH_API = os.environ.get("CLOUDSEARCH_API", "http://app:9527")
|
||||
DB_PATH = os.environ.get("BOT_DB_PATH", "/data/bot.db")
|
||||
|
||||
# ── 飞书API ───────────────────────────────────
|
||||
FEISHU_TOKEN_URL = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
|
||||
FEISHU_SEND_URL = "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=open_id"
|
||||
|
||||
_tenant_token = None
|
||||
_token_expire = 0
|
||||
|
||||
def get_tenant_token() -> str:
|
||||
"""获取飞书 tenant_access_token(缓存2h)"""
|
||||
global _tenant_token, _token_expire
|
||||
if _tenant_token and time.time() < _token_expire:
|
||||
return _tenant_token
|
||||
resp = requests.post(FEISHU_TOKEN_URL, json={
|
||||
"app_id": APP_ID, "app_secret": APP_SECRET
|
||||
}, timeout=10)
|
||||
data = resp.json()
|
||||
if data.get("code") != 0:
|
||||
raise Exception(f"获取飞书Token失败: {data}")
|
||||
_tenant_token = data["tenant_access_token"]
|
||||
_token_expire = time.time() + data.get("expire", 7200) - 300
|
||||
logger.info("飞书 tenant_token 已刷新")
|
||||
return _tenant_token
|
||||
|
||||
def send_feishu_msg(open_id: str, content: str, msg_type: str = "text"):
|
||||
"""发送飞书消息"""
|
||||
body = {
|
||||
"receive_id": open_id,
|
||||
"msg_type": msg_type,
|
||||
"content": json.dumps({"text": content}) if msg_type == "text" else content
|
||||
}
|
||||
resp = requests.post(
|
||||
FEISHU_SEND_URL,
|
||||
headers={"Authorization": f"Bearer {get_tenant_token()}"},
|
||||
json=body, timeout=10
|
||||
)
|
||||
data = resp.json()
|
||||
if data.get("code") != 0:
|
||||
logger.error(f"发送飞书消息失败: {data}")
|
||||
return data.get("code") == 0
|
||||
|
||||
def send_feishu_card(open_id: str, card: dict):
|
||||
"""发送飞书卡片消息"""
|
||||
body = {
|
||||
"receive_id": open_id,
|
||||
"msg_type": "interactive",
|
||||
"content": json.dumps(card)
|
||||
}
|
||||
resp = requests.post(
|
||||
FEISHU_SEND_URL,
|
||||
headers={"Authorization": f"Bearer {get_tenant_token()}"},
|
||||
json=body, timeout=10
|
||||
)
|
||||
return resp.json().get("code") == 0
|
||||
|
||||
def send_webhook(text: str):
|
||||
"""通过 Webhook 推送通知(用于订阅变更)"""
|
||||
if not WEBHOOK_URL:
|
||||
return
|
||||
try:
|
||||
requests.post(WEBHOOK_URL, json={
|
||||
"msg_type": "text",
|
||||
"content": {"text": text}
|
||||
}, timeout=10)
|
||||
except Exception as e:
|
||||
logger.error(f"Webhook推送失败: {e}")
|
||||
|
||||
# ── Bot 核心逻辑 ────────────────────────────────
|
||||
class FeishuBot:
|
||||
def __init__(self):
|
||||
self.db = sqlite3.connect(DB_PATH, check_same_thread=False)
|
||||
self._init_db()
|
||||
|
||||
def _init_db(self):
|
||||
self.db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
open_id TEXT NOT NULL,
|
||||
keyword TEXT NOT NULL,
|
||||
last_check TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now','localtime')),
|
||||
UNIQUE(open_id, keyword)
|
||||
)
|
||||
""")
|
||||
self.db.commit()
|
||||
logger.info("订阅数据库就绪")
|
||||
|
||||
def handle_text(self, open_id: str, text: str):
|
||||
"""处理文本消息"""
|
||||
text = text.strip()
|
||||
if text.startswith("/search"):
|
||||
keyword = text.replace("/search", "", 1).strip()
|
||||
return self._cmd_search(open_id, keyword)
|
||||
elif text.startswith("/subscribe"):
|
||||
keyword = text.replace("/subscribe", "", 1).strip()
|
||||
return self._cmd_subscribe(open_id, keyword)
|
||||
elif text.startswith("/unsub"):
|
||||
keyword = text.replace("/unsub", "", 1).strip()
|
||||
return self._cmd_unsub(open_id, keyword)
|
||||
elif text.startswith("/mysubs"):
|
||||
return self._cmd_mysubs(open_id)
|
||||
elif text.startswith("/help") or text.lower() == "help":
|
||||
return self._cmd_help(open_id)
|
||||
else:
|
||||
return self._cmd_search(open_id, text) # 默认搜索
|
||||
|
||||
def _cmd_help(self, open_id: str):
|
||||
help_text = (
|
||||
"🔍 CloudSearch Bot\n\n"
|
||||
"命令:\n"
|
||||
"/search 关键词 — 搜索网盘资源\n"
|
||||
"直接输入关键词也可以搜索\n"
|
||||
"/subscribe 关键词 — 订阅关键词\n"
|
||||
"/unsub 关键词 — 取消订阅\n"
|
||||
"/mysubs — 查看我的订阅\n"
|
||||
"/help — 帮助"
|
||||
)
|
||||
send_feishu_msg(open_id, help_text)
|
||||
|
||||
def _cmd_search(self, open_id: str, keyword: str):
|
||||
if not keyword:
|
||||
send_feishu_msg(open_id, "用法: /search 流浪地球2\n或直接输入关键词")
|
||||
return
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{CLOUDSEARCH_API}/api/query",
|
||||
json={"q": keyword}, timeout=15
|
||||
)
|
||||
results = []
|
||||
for line in resp.text.strip().split("\n"):
|
||||
try:
|
||||
d = json.loads(line)
|
||||
if d.get("type") == "result":
|
||||
results.append(d)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
if not results:
|
||||
send_feishu_msg(open_id, f"😞 未找到「{keyword}」的相关资源")
|
||||
return
|
||||
|
||||
# 构建飞书卡片
|
||||
elements = []
|
||||
for i, r in enumerate(results[:5]):
|
||||
title = (r.get("title") or r.get("content", ""))[:50]
|
||||
cloud = r.get("cloud_type", "?").upper()
|
||||
pwd = r.get("password", "")
|
||||
pwd_str = f" 🔑{pwd}" if pwd else ""
|
||||
elements.append({
|
||||
"tag": "div",
|
||||
"text": {"tag": "lark_md", "content": f"**{i+1}.** [{cloud}] {title}{pwd_str}"}
|
||||
})
|
||||
|
||||
card = {
|
||||
"header": {
|
||||
"title": {"tag": "plain_text", "content": f"🔎 {keyword} — {len(results)}个结果"},
|
||||
"template": "blue"
|
||||
},
|
||||
"elements": elements + [{
|
||||
"tag": "action",
|
||||
"actions": [{
|
||||
"tag": "button",
|
||||
"text": {"tag": "plain_text", "content": "🌐 查看更多"},
|
||||
"type": "primary",
|
||||
"url": f"{CLOUDSEARCH_API}/?q={keyword}"
|
||||
}]
|
||||
}]
|
||||
}
|
||||
send_feishu_card(open_id, card)
|
||||
|
||||
except Exception as e:
|
||||
send_feishu_msg(open_id, f"❌ 搜索失败: {e}")
|
||||
|
||||
def _cmd_subscribe(self, open_id: str, keyword: str):
|
||||
if not keyword:
|
||||
send_feishu_msg(open_id, "用法: /subscribe 流浪地球")
|
||||
return
|
||||
try:
|
||||
self.db.execute(
|
||||
"INSERT OR IGNORE INTO subscriptions (open_id, keyword) VALUES (?, ?)",
|
||||
(open_id, keyword)
|
||||
)
|
||||
self.db.commit()
|
||||
send_feishu_msg(open_id, f"✅ 已订阅「{keyword}」,有新结果会通知你")
|
||||
except Exception as e:
|
||||
send_feishu_msg(open_id, f"❌ 订阅失败: {e}")
|
||||
|
||||
def _cmd_unsub(self, open_id: str, keyword: str):
|
||||
if not keyword:
|
||||
send_feishu_msg(open_id, "用法: /unsub 流浪地球")
|
||||
return
|
||||
cur = self.db.execute(
|
||||
"DELETE FROM subscriptions WHERE open_id=? AND keyword=?",
|
||||
(open_id, keyword)
|
||||
)
|
||||
self.db.commit()
|
||||
if cur.rowcount > 0:
|
||||
send_feishu_msg(open_id, f"✅ 已取消订阅「{keyword}」")
|
||||
else:
|
||||
send_feishu_msg(open_id, f"未找到「{keyword}」的订阅")
|
||||
|
||||
def _cmd_mysubs(self, open_id: str):
|
||||
rows = self.db.execute(
|
||||
"SELECT keyword, created_at FROM subscriptions WHERE open_id=? ORDER BY created_at DESC",
|
||||
(open_id,)
|
||||
).fetchall()
|
||||
if not rows:
|
||||
send_feishu_msg(open_id, "你还没有订阅任何关键词")
|
||||
return
|
||||
text = "📋 我的订阅:\n"
|
||||
for kw, dt in rows:
|
||||
text += f"• {kw} ({dt[:10]})\n"
|
||||
send_feishu_msg(open_id, text)
|
||||
|
||||
def check_subscriptions(self):
|
||||
"""检查所有订阅,有新结果时推送通知"""
|
||||
subs = self.db.execute("SELECT DISTINCT keyword FROM subscriptions").fetchall()
|
||||
for (kw,) in subs:
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{CLOUDSEARCH_API}/api/query",
|
||||
json={"q": kw}, timeout=10
|
||||
)
|
||||
count = sum(1 for line in resp.text.split("\n")
|
||||
if '"type":"result"' in line)
|
||||
if count > 0:
|
||||
# 通知所有订阅此关键词的用户
|
||||
users = self.db.execute(
|
||||
"SELECT open_id FROM subscriptions WHERE keyword=?",
|
||||
(kw,)
|
||||
).fetchall()
|
||||
for (uid,) in users:
|
||||
send_feishu_msg(uid, f"🔔「{kw}」有新资源({count}个)!\n/search {kw}")
|
||||
# Webhook 也推送
|
||||
send_webhook(f"🔔 关键词「{kw}」发现 {count} 个新资源")
|
||||
except Exception as e:
|
||||
logger.error(f"检查订阅[{kw}]失败: {e}")
|
||||
|
||||
# ── Flask Web 服务 ─────────────────────────────
|
||||
bot = FeishuBot()
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.route("/health")
|
||||
def health():
|
||||
return jsonify({"status": "ok", "bot": "feishu"})
|
||||
|
||||
@app.route("/feishu/event", methods=["POST"])
|
||||
def feishu_event():
|
||||
"""飞书事件订阅回调"""
|
||||
body = request.get_json()
|
||||
logger.info(f"飞书事件: {json.dumps(body, ensure_ascii=False)[:300]}")
|
||||
|
||||
# Token 验证(首次配置URL时)
|
||||
if body.get("type") == "url_verification":
|
||||
token = body.get("token", "")
|
||||
if token == VERIFY_TOKEN:
|
||||
return jsonify({"challenge": body.get("challenge", "")})
|
||||
return jsonify({"error": "invalid token"}), 403
|
||||
|
||||
# 事件回调验证
|
||||
if "header" in body:
|
||||
# 收到消息事件
|
||||
event = body.get("event", {})
|
||||
msg_type = event.get("message", {}).get("message_type", "")
|
||||
if msg_type == "text":
|
||||
content = event["message"].get("content", "{}")
|
||||
try:
|
||||
text = json.loads(content).get("text", "")
|
||||
except json.JSONDecodeError:
|
||||
text = content
|
||||
open_id = event.get("sender", {}).get("sender_id", {}).get("open_id", "")
|
||||
if text and open_id:
|
||||
bot.handle_text(open_id, text)
|
||||
|
||||
return jsonify({"code": 0})
|
||||
|
||||
@app.route("/feishu/check", methods=["POST"])
|
||||
def trigger_check():
|
||||
"""手动触发订阅检查"""
|
||||
bot.check_subscriptions()
|
||||
return jsonify({"ok": True})
|
||||
|
||||
# ── 启动入口 ───────────────────────────────────
|
||||
def main():
|
||||
if not APP_ID:
|
||||
logger.warning("FEISHU_APP_ID 未设置,Bot 无法接收消息(仅 Webhook 可用)")
|
||||
logger.info("飞书 Bot 启动,端口9531")
|
||||
app.run(host="0.0.0.0", port=9531)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
3
cloudsearch_enrich/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
flask>=3.0
|
||||
requests>=2.28
|
||||
python-telegram-bot>=20.0
|
||||
132
cloudsearch_enrich/search_enricher.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
CloudSearch Search Enricher v1.0.0
|
||||
搜索结果增强:TMDB匹配 + 过期检测 + 内容去重
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
from typing import List, Dict, Any, Optional
|
||||
from tmdb_enricher import TMDBEnricher
|
||||
|
||||
logger = logging.getLogger("enricher")
|
||||
|
||||
|
||||
class SearchEnricher:
|
||||
"""搜索结果增强器"""
|
||||
|
||||
def __init__(self, tmdb_api_key: str = "", cache_ttl: int = 86400):
|
||||
self.tmdb = TMDBEnricher(tmdb_api_key, cache_ttl=cache_ttl) if tmdb_api_key else None
|
||||
|
||||
def enrich_results(self, results: List[Dict], keyword: str = "") -> List[Dict]:
|
||||
"""批量增强搜索结果"""
|
||||
if not results:
|
||||
return results
|
||||
|
||||
enriched = []
|
||||
titles_to_lookup = []
|
||||
|
||||
# 收集需要查 TMDB 的标题
|
||||
for r in results:
|
||||
title = r.get("title", "")
|
||||
if title and self.tmdb:
|
||||
titles_to_lookup.append(title)
|
||||
|
||||
# 批量查询 TMDB
|
||||
tmdb_results = {}
|
||||
if titles_to_lookup and self.tmdb:
|
||||
tmdb_results = self.tmdb.enrich_batch(titles_to_lookup[:20], max_concurrent=5)
|
||||
|
||||
# 应用增强
|
||||
for r in results:
|
||||
title = r.get("title", "")
|
||||
media = tmdb_results.get(title)
|
||||
|
||||
enriched_item = dict(r)
|
||||
if media:
|
||||
enriched_item.update({
|
||||
"tmdb_id": media.tmdb_id,
|
||||
"tmdb_url": media.tmdb_url,
|
||||
"poster": media.poster_url,
|
||||
"backdrop": media.backdrop_url,
|
||||
"rating": media.rating,
|
||||
"rating_count": media.rating_count,
|
||||
"year": media.year,
|
||||
"genres": media.genres,
|
||||
"description": media.description,
|
||||
"media_type": media.media_type,
|
||||
"directors": media.directors,
|
||||
"actors": media.actors[:5],
|
||||
"enriched": True,
|
||||
})
|
||||
|
||||
# 自动生成更好的标题
|
||||
if media.year and media.rating:
|
||||
enriched_item["display_title"] = (
|
||||
f"{title} ({media.year}) ⭐{media.rating}"
|
||||
)
|
||||
|
||||
enriched.append(enriched_item)
|
||||
|
||||
return enriched
|
||||
|
||||
def enrich_single(self, title: str, keyword: str = "") -> Optional[Dict]:
|
||||
"""增强单个标题"""
|
||||
if not self.tmdb:
|
||||
return None
|
||||
media = self.tmdb.enrich(title)
|
||||
if not media:
|
||||
return None
|
||||
return {
|
||||
"title": title,
|
||||
"tmdb_id": media.tmdb_id,
|
||||
"poster": media.poster_url,
|
||||
"rating": media.rating,
|
||||
"year": media.year,
|
||||
"genres": media.genres,
|
||||
"description": media.description,
|
||||
"media_type": media.media_type,
|
||||
}
|
||||
|
||||
|
||||
# Flask API wrapper
|
||||
def create_enricher_api(tmdb_key: str = ""):
|
||||
from flask import Flask, request, jsonify
|
||||
app = Flask(__name__)
|
||||
enricher = SearchEnricher(tmdb_key)
|
||||
|
||||
@app.route("/health", methods=["GET"])
|
||||
def health():
|
||||
return jsonify({"status": "ok", "version": "1.0.0"})
|
||||
|
||||
@app.route("/enrich", methods=["POST"])
|
||||
def enrich():
|
||||
data = request.get_json() or {}
|
||||
results = data.get("results", [])
|
||||
keyword = data.get("keyword", "")
|
||||
|
||||
if not results:
|
||||
return jsonify({"error": "results required"}), 400
|
||||
|
||||
enriched = enricher.enrich_results(results, keyword)
|
||||
return jsonify({"results": enriched, "count": len(enriched)})
|
||||
|
||||
@app.route("/lookup", methods=["POST"])
|
||||
def lookup():
|
||||
data = request.get_json() or {}
|
||||
title = data.get("title", "")
|
||||
if not title:
|
||||
return jsonify({"error": "title required"}), 400
|
||||
|
||||
info = enricher.enrich_single(title)
|
||||
return jsonify(info or {})
|
||||
|
||||
return app
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import os
|
||||
api_key = os.getenv("TMDB_API_KEY", "")
|
||||
port = int(os.getenv("PORT", "9530"))
|
||||
app = create_enricher_api(api_key)
|
||||
logger.info(f"Enricher API on port {port}")
|
||||
app.run(host="0.0.0.0", port=port)
|
||||
204
cloudsearch_enrich/subscription_monitor.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""
|
||||
CloudSearch Subscription Monitor v1.0.0
|
||||
关键词订阅 + 新资源检测 + 多渠道通知
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import sqlite3
|
||||
import logging
|
||||
import requests
|
||||
from typing import List, Dict, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger("subscription")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Notification:
|
||||
chat_id: int
|
||||
keyword: str
|
||||
new_count: int
|
||||
results: List[dict]
|
||||
channel: str = "telegram" # telegram / feishu / dingtalk
|
||||
|
||||
|
||||
class SubscriptionMonitor:
|
||||
"""订阅监控:定时搜索关键词,发现新资源后推送通知"""
|
||||
|
||||
def __init__(self, api_base: str, db_path: str = "/data/subscriptions.db",
|
||||
tg_bot_token: str = None):
|
||||
self.api_base = api_base.rstrip("/")
|
||||
self.tg_token = tg_bot_token
|
||||
self.db = sqlite3.connect(db_path, check_same_thread=False)
|
||||
self._init_db()
|
||||
|
||||
def _init_db(self):
|
||||
self.db.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chat_id INTEGER NOT NULL,
|
||||
keyword TEXT NOT NULL,
|
||||
last_result_hash TEXT,
|
||||
last_check TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now','localtime')),
|
||||
UNIQUE(chat_id, keyword)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sent_notifications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
subscription_id INTEGER,
|
||||
result_hash TEXT,
|
||||
sent_at TEXT DEFAULT (datetime('now','localtime')),
|
||||
FOREIGN KEY(subscription_id) REFERENCES subscriptions(id)
|
||||
);
|
||||
""")
|
||||
self.db.commit()
|
||||
|
||||
def check_all(self, batch_size: int = 10) -> List[Notification]:
|
||||
"""检查所有订阅,返回需要通知的列表"""
|
||||
subs = self.db.execute(
|
||||
"SELECT id, chat_id, keyword, last_result_hash FROM subscriptions ORDER BY last_check ASC LIMIT ?",
|
||||
(batch_size,)
|
||||
).fetchall()
|
||||
|
||||
notifications = []
|
||||
for sub_id, chat_id, keyword, last_hash in subs:
|
||||
try:
|
||||
result = self._search(keyword)
|
||||
new_hash = self._hash_results(result)
|
||||
|
||||
if new_hash and new_hash != last_hash:
|
||||
new_results = self._filter_new(sub_id, result, last_hash)
|
||||
if new_results:
|
||||
notifications.append(Notification(
|
||||
chat_id=chat_id,
|
||||
keyword=keyword,
|
||||
new_count=len(new_results),
|
||||
results=new_results[:5],
|
||||
))
|
||||
|
||||
# 更新状态
|
||||
self.db.execute(
|
||||
"UPDATE subscriptions SET last_result_hash=?, last_check=datetime('now','localtime') WHERE id=?",
|
||||
(new_hash, sub_id)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Check failed: {keyword} - {e}")
|
||||
|
||||
self.db.commit()
|
||||
return notifications
|
||||
|
||||
def _search(self, keyword: str) -> list:
|
||||
"""搜索关键词"""
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{self.api_base}/api/query",
|
||||
json={"q": keyword},
|
||||
timeout=20
|
||||
)
|
||||
results = []
|
||||
for line in resp.text.strip().split("\n"):
|
||||
try:
|
||||
d = json.loads(line)
|
||||
if d.get("type") == "result":
|
||||
results.append({
|
||||
"title": d.get("title", ""),
|
||||
"url": d.get("share_url", ""),
|
||||
"cloud": d.get("cloud_type", ""),
|
||||
"source": d.get("source", ""),
|
||||
})
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return results
|
||||
except Exception as e:
|
||||
logger.error(f"Search error: {e}")
|
||||
return []
|
||||
|
||||
def _hash_results(self, results: list) -> str:
|
||||
"""计算结果哈希"""
|
||||
import hashlib
|
||||
key = "|".join(
|
||||
r.get("url", "")[:50] for r in sorted(
|
||||
results, key=lambda x: x.get("url", "")
|
||||
)
|
||||
)
|
||||
return hashlib.md5(key.encode()).hexdigest()
|
||||
|
||||
def _filter_new(self, sub_id: int, results: list, last_hash: str) -> list:
|
||||
"""过滤出新结果"""
|
||||
new = []
|
||||
for r in results:
|
||||
rhash = str(hash(r.get("url", "")))
|
||||
existing = self.db.execute(
|
||||
"SELECT id FROM sent_notifications WHERE subscription_id=? AND result_hash=?",
|
||||
(sub_id, rhash)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
new.append(r)
|
||||
self.db.execute(
|
||||
"INSERT OR IGNORE INTO sent_notifications (subscription_id, result_hash) VALUES (?,?)",
|
||||
(sub_id, rhash)
|
||||
)
|
||||
return new
|
||||
|
||||
def notify_telegram(self, notif: Notification):
|
||||
"""通过 Telegram 发送通知"""
|
||||
if not self.tg_token:
|
||||
return
|
||||
text = f"🔔 *{notif.keyword}* 有新资源!({notif.new_count}个)\n\n"
|
||||
for i, r in enumerate(notif.results[:5]):
|
||||
title = r.get("title", "")[:40]
|
||||
url = r.get("url", "")
|
||||
cloud = r.get("cloud", "?").upper()
|
||||
text += f"{i+1}. [{cloud}] [{title}]({url})\n"
|
||||
|
||||
try:
|
||||
requests.post(
|
||||
f"https://api.telegram.org/bot{self.tg_token}/sendMessage",
|
||||
json={
|
||||
"chat_id": notif.chat_id,
|
||||
"text": text,
|
||||
"parse_mode": "Markdown",
|
||||
"disable_web_page_preview": True,
|
||||
},
|
||||
timeout=10
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"TG notify failed: {e}")
|
||||
|
||||
def notify_feishu(self, notif: Notification, webhook_url: str):
|
||||
"""通过飞书发送通知"""
|
||||
text = f"🔔 {notif.keyword} 有新资源!({notif.new_count}个)\n"
|
||||
for r in notif.results[:5]:
|
||||
text += f"• [{r.get('cloud','?').upper()}] {r.get('title','')[:40]} {r.get('url','')}\n"
|
||||
try:
|
||||
requests.post(webhook_url, json={
|
||||
"msg_type": "text",
|
||||
"content": {"text": text}
|
||||
}, timeout=10)
|
||||
except Exception as e:
|
||||
logger.error(f"Feishu notify failed: {e}")
|
||||
|
||||
def run_loop(self, interval_minutes: int = 15):
|
||||
"""循环运行"""
|
||||
logger.info(f"Subscription monitor started (interval={interval_minutes}min)")
|
||||
while True:
|
||||
try:
|
||||
notifs = self.check_all()
|
||||
for n in notifs:
|
||||
self.notify_telegram(n)
|
||||
if notifs:
|
||||
logger.info(f"Sent {len(notifs)} notifications")
|
||||
except Exception as e:
|
||||
logger.error(f"Monitor error: {e}")
|
||||
time.sleep(interval_minutes * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
api = os.getenv("CLOUDSEARCH_API", "http://127.0.0.1:9527")
|
||||
token = os.getenv("TG_BOT_TOKEN", "")
|
||||
interval = int(os.getenv("CHECK_INTERVAL", "15"))
|
||||
monitor = SubscriptionMonitor(api, tg_bot_token=token)
|
||||
monitor.run_loop(interval)
|
||||
183
cloudsearch_enrich/tg_bot.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
CloudSearch Telegram Bot v1.0.0
|
||||
提供: /search /subscribe /hot /help
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
import sqlite3
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from telegram.ext import (
|
||||
Application, CommandHandler, MessageHandler,
|
||||
CallbackQueryHandler, ContextTypes, filters
|
||||
)
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger("tgbot")
|
||||
|
||||
|
||||
class CloudSearchBot:
|
||||
def __init__(self, token: str, api_base: str, db_path: str = "/data/bot.db"):
|
||||
self.token = token
|
||||
self.api_base = api_base.rstrip("/")
|
||||
self.db = sqlite3.connect(db_path, check_same_thread=False)
|
||||
self._init_db()
|
||||
|
||||
def _init_db(self):
|
||||
self.db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chat_id INTEGER NOT NULL,
|
||||
keyword TEXT NOT NULL,
|
||||
last_check TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now', 'localtime')),
|
||||
UNIQUE(chat_id, keyword)
|
||||
)
|
||||
""")
|
||||
self.db.commit()
|
||||
|
||||
async def start(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
await update.message.reply_text(
|
||||
"🔍 *CloudSearch Bot* v1.0\n\n"
|
||||
"命令:\n"
|
||||
"/search 关键词 — 搜索网盘资源\n"
|
||||
"/hot — 热门搜索\n"
|
||||
"/subscribe 关键词 — 订阅关键词\n"
|
||||
"/unsub 关键词 — 取消订阅\n"
|
||||
"/mysubs — 我的订阅\n"
|
||||
"/help — 帮助",
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
|
||||
async def search(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
keyword = " ".join(context.args) if context.args else ""
|
||||
if not keyword:
|
||||
await update.message.reply_text("用法: /search 流浪地球2")
|
||||
return
|
||||
|
||||
msg = await update.message.reply_text(f"🔎 搜索中: *{keyword}*...", parse_mode="Markdown")
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{self.api_base}/api/query",
|
||||
json={"q": keyword},
|
||||
timeout=15
|
||||
)
|
||||
|
||||
# Parse NDJSON response
|
||||
results = []
|
||||
content_info = None
|
||||
for line in resp.text.strip().split("\n"):
|
||||
try:
|
||||
data = json.loads(line)
|
||||
if data.get("type") == "result":
|
||||
results.append(data)
|
||||
elif data.get("type") == "stats":
|
||||
content_info = data.get("content_info")
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
if not results:
|
||||
await msg.edit_text(f"😞 未找到「{keyword}」的相关资源")
|
||||
return
|
||||
|
||||
# Format top 5 results
|
||||
text = f"🔎 *{keyword}* — {len(results)} 个结果\n\n"
|
||||
for i, r in enumerate(results[:5]):
|
||||
title = (r.get("title") or r.get("content", ""))[:40]
|
||||
cloud = r.get("cloud_type", "?").upper()
|
||||
url = r.get("share_url", "")
|
||||
pwd = r.get("password", "")
|
||||
pwd_str = f" 🔑`{pwd}`" if pwd else ""
|
||||
text += f"{i+1}. [{cloud}] [{title}]({url}){pwd_str}\n"
|
||||
|
||||
keyboard = [[
|
||||
InlineKeyboardButton("🌐 查看更多", url=f"{self.api_base}/?q={keyword}")
|
||||
]]
|
||||
await msg.edit_text(
|
||||
text,
|
||||
parse_mode="Markdown",
|
||||
disable_web_page_preview=True,
|
||||
reply_markup=InlineKeyboardMarkup(keyboard)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await msg.edit_text(f"❌ 搜索失败: {e}")
|
||||
|
||||
async def subscribe(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
keyword = " ".join(context.args) if context.args else ""
|
||||
if not keyword:
|
||||
await update.message.reply_text("用法: /subscribe 流浪地球")
|
||||
return
|
||||
|
||||
try:
|
||||
self.db.execute(
|
||||
"INSERT OR IGNORE INTO subscriptions (chat_id, keyword) VALUES (?, ?)",
|
||||
(update.effective_chat.id, keyword)
|
||||
)
|
||||
self.db.commit()
|
||||
await update.message.reply_text(f"✅ 已订阅: *{keyword}*", parse_mode="Markdown")
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"❌ 订阅失败: {e}")
|
||||
|
||||
async def unsub(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
keyword = " ".join(context.args) if context.args else ""
|
||||
self.db.execute(
|
||||
"DELETE FROM subscriptions WHERE chat_id=? AND keyword=?",
|
||||
(update.effective_chat.id, keyword)
|
||||
)
|
||||
self.db.commit()
|
||||
await update.message.reply_text(f"🗑 已取消: *{keyword}*", parse_mode="Markdown")
|
||||
|
||||
async def mysubs(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
subs = self.db.execute(
|
||||
"SELECT keyword, created_at FROM subscriptions WHERE chat_id=? ORDER BY created_at DESC LIMIT 20",
|
||||
(update.effective_chat.id,)
|
||||
).fetchall()
|
||||
if not subs:
|
||||
await update.message.reply_text("📭 暂无订阅")
|
||||
return
|
||||
text = "📋 *我的订阅*\n" + "\n".join(f"• {s[0]}" for s in subs)
|
||||
await update.message.reply_text(text, parse_mode="Markdown")
|
||||
|
||||
async def hot(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
try:
|
||||
resp = requests.get(f"{self.api_base}/api/rankings/hot?limit=10", timeout=10)
|
||||
data = resp.json()
|
||||
keywords = data if isinstance(data, list) else data.get("keywords", [])
|
||||
text = "🔥 *热门搜索*\n" + "\n".join(
|
||||
f"{i+1}. {kw.get('keyword', str(kw))}" for i, kw in enumerate(keywords[:10])
|
||||
)
|
||||
except:
|
||||
text = "🔥 获取热门失败,请稍后重试"
|
||||
await update.message.reply_text(text, parse_mode="Markdown")
|
||||
|
||||
async def help_cmd(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
await self.start(update, context)
|
||||
|
||||
def run(self):
|
||||
app = Application.builder().token(self.token).build()
|
||||
app.add_handler(CommandHandler("start", self.start))
|
||||
app.add_handler(CommandHandler("search", self.search))
|
||||
app.add_handler(CommandHandler("s", self.search))
|
||||
app.add_handler(CommandHandler("hot", self.hot))
|
||||
app.add_handler(CommandHandler("subscribe", self.subscribe))
|
||||
app.add_handler(CommandHandler("sub", self.subscribe))
|
||||
app.add_handler(CommandHandler("unsub", self.unsub))
|
||||
app.add_handler(CommandHandler("mysubs", self.mysubs))
|
||||
app.add_handler(CommandHandler("help", self.help_cmd))
|
||||
|
||||
logger.info("Bot starting...")
|
||||
app.run_polling()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
token = os.getenv("TG_BOT_TOKEN", "")
|
||||
api = os.getenv("CLOUDSEARCH_API", "http://127.0.0.1:9527")
|
||||
bot = CloudSearchBot(token, api)
|
||||
bot.run()
|
||||
179
cloudsearch_enrich/tmdb_enricher.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
CloudSearch TMDB Enricher v1.0.0
|
||||
自动匹配影视元数据:海报、评分、简介、年份、类型
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, List
|
||||
from dataclasses import dataclass, field
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TMDB_API_BASE = "https://api.themoviedb.org/3"
|
||||
TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p/w500"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MediaInfo:
|
||||
"""影视元数据"""
|
||||
title: str = ""
|
||||
original_title: str = ""
|
||||
year: str = ""
|
||||
poster_url: str = ""
|
||||
backdrop_url: str = ""
|
||||
rating: str = ""
|
||||
rating_count: int = 0
|
||||
description: str = ""
|
||||
genres: List[str] = field(default_factory=list)
|
||||
media_type: str = "" # movie / tv
|
||||
tmdb_id: int = 0
|
||||
directors: List[str] = field(default_factory=list)
|
||||
actors: List[str] = field(default_factory=list)
|
||||
region: str = ""
|
||||
duration: str = ""
|
||||
seasons: int = 0
|
||||
episodes: int = 0
|
||||
source: str = "tmdb"
|
||||
tmdb_url: str = ""
|
||||
|
||||
|
||||
class TMDBEnricher:
|
||||
"""TMDB 影视信息增强器"""
|
||||
|
||||
# 常见网盘文件名模式 → 影视标题提取
|
||||
TITLE_PATTERNS = [
|
||||
# [4K] 流浪地球2 (2023)
|
||||
(r'\[.*?\]\s*(.+?)\s*[\((](\d{4})[\))]', 2),
|
||||
# 流浪地球2.2023.4K
|
||||
(r'(.+?)\.(\d{4})\.(?:4K|1080[Pp]|2160[Pp]|HD)', 2),
|
||||
# 流浪地球2 2023
|
||||
(r'(.+?)\s+(\d{4})\s', 2),
|
||||
# S01E01 格式
|
||||
(r'(.+?)[\.\s][Ss](\d{2})[Ee](\d{2})', 1),
|
||||
]
|
||||
|
||||
def __init__(self, api_key: str, language: str = "zh-CN",
|
||||
cache_ttl: int = 86400):
|
||||
self.api_key = api_key
|
||||
self.language = language
|
||||
self.cache_ttl = cache_ttl
|
||||
self._cache: Dict[str, tuple] = {} # key → (data, timestamp)
|
||||
|
||||
def enrich(self, title: str, media_type: str = None) -> Optional[MediaInfo]:
|
||||
"""根据标题查询 TMDB 元数据"""
|
||||
clean_title, year = self._extract_title_year(title)
|
||||
|
||||
cache_key = f"{clean_title}:{year}:{media_type}"
|
||||
if cache_key in self._cache:
|
||||
data, ts = self._cache[cache_key]
|
||||
if time.time() - ts < self.cache_ttl:
|
||||
return data
|
||||
|
||||
# 智能判断类型
|
||||
if not media_type:
|
||||
media_type = self._guess_type(clean_title)
|
||||
|
||||
info = self._search(clean_title, year, media_type)
|
||||
if info:
|
||||
self._cache[cache_key] = (info, time.time())
|
||||
return info
|
||||
|
||||
def enrich_batch(self, titles: List[str], max_concurrent: int = 5) -> Dict[str, MediaInfo]:
|
||||
"""批量查询"""
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
results = {}
|
||||
with ThreadPoolExecutor(max_workers=max_concurrent) as ex:
|
||||
futures = {ex.submit(self.enrich, t): t for t in titles}
|
||||
for f in as_completed(futures):
|
||||
try:
|
||||
results[futures[f]] = f.result()
|
||||
except Exception as e:
|
||||
logger.warning(f"TMDB enrich failed: {futures[f]} - {e}")
|
||||
return results
|
||||
|
||||
def _extract_title_year(self, title: str) -> tuple:
|
||||
"""从文件名提取标题和年份"""
|
||||
import re
|
||||
for pattern, year_group in self.TITLE_PATTERNS:
|
||||
m = re.search(pattern, title, re.IGNORECASE)
|
||||
if m:
|
||||
name = m.group(1).strip()
|
||||
year = m.group(year_group) if year_group <= len(m.groups()) else ""
|
||||
# 去掉常见的后缀
|
||||
name = re.sub(r'\s*[\[((].*?(?:完结|全\d+集|更新).*?[\]))]', '', name)
|
||||
return name.strip(), year
|
||||
return title.strip(), ""
|
||||
|
||||
def _guess_type(self, title: str) -> str:
|
||||
"""根据标题特征判断电影/电视剧"""
|
||||
import re
|
||||
tv_patterns = [
|
||||
r'[Ss]\d{2}[Ee]\d{2}', r'第[一二三四五六七八九十\d]+季',
|
||||
r'[Ss]eason\s*\d+', r'全\d+集', r'更新至\d+',
|
||||
]
|
||||
for p in tv_patterns:
|
||||
if re.search(p, title):
|
||||
return "tv"
|
||||
return "movie"
|
||||
|
||||
def _search(self, title: str, year: str = "", media_type: str = "movie") -> Optional[MediaInfo]:
|
||||
"""搜索 TMDB"""
|
||||
try:
|
||||
# 搜索
|
||||
search_type = "tv" if media_type == "tv" else "movie"
|
||||
params = {
|
||||
"api_key": self.api_key,
|
||||
"query": title,
|
||||
"language": self.language,
|
||||
"page": 1,
|
||||
}
|
||||
if year:
|
||||
params["year" if search_type == "movie" else "first_air_date_year"] = year
|
||||
|
||||
resp = requests.get(
|
||||
f"{TMDB_API_BASE}/search/{search_type}",
|
||||
params=params, timeout=10
|
||||
)
|
||||
data = resp.json()
|
||||
results = data.get("results", [])
|
||||
|
||||
if not results and search_type == "movie":
|
||||
# 电视剧也试一下
|
||||
resp2 = requests.get(
|
||||
f"{TMDB_API_BASE}/search/tv",
|
||||
params=params, timeout=10
|
||||
)
|
||||
data2 = resp2.json()
|
||||
results = data2.get("results", [])
|
||||
|
||||
if not results:
|
||||
return None
|
||||
|
||||
item = results[0]
|
||||
return self._parse_result(item, media_type)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"TMDB search error: {title} - {e}")
|
||||
return None
|
||||
|
||||
def _parse_result(self, item: dict, media_type: str) -> MediaInfo:
|
||||
"""解析 TMDB 返回"""
|
||||
mid = item.get("id", 0)
|
||||
is_tv = media_type == "tv" or item.get("media_type") == "tv"
|
||||
|
||||
return MediaInfo(
|
||||
title=item.get("title") or item.get("name", ""),
|
||||
original_title=item.get("original_title") or item.get("original_name", ""),
|
||||
year=str(item.get("release_date", item.get("first_air_date", ""))[:4]),
|
||||
poster_url=f"{TMDB_IMAGE_BASE}{item['poster_path']}" if item.get("poster_path") else "",
|
||||
backdrop_url=f"{TMDB_IMAGE_BASE}{item['backdrop_path']}" if item.get("backdrop_path") else "",
|
||||
rating=str(round(item.get("vote_average", 0), 1)),
|
||||
rating_count=item.get("vote_count", 0),
|
||||
description=(item.get("overview") or "")[:500],
|
||||
genres=[g.get("name", "") for g in item.get("genre_ids", [])],
|
||||
media_type="tv" if is_tv else "movie",
|
||||
tmdb_id=mid,
|
||||
tmdb_url=f"https://www.themoviedb.org/{'tv' if is_tv else 'movie'}/{mid}",
|
||||
)
|
||||
18
cloudsearch_transfer/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
ENV PORT=9528
|
||||
ENV TRANSFER_CONFIG_PATH=/data/transfer_config.json
|
||||
|
||||
EXPOSE 9528
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD ["python", "server.py"]
|
||||
|
||||
CMD ["python", "server.py"]
|
||||
32
cloudsearch_transfer/__init__.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""CloudSearch Transfer v1.0.0 — 多网盘转存模块化服务
|
||||
|
||||
支持平台: quark, baidu, aliyun, uc, xunlei (+ 115/123/cloud189 扩展)
|
||||
|
||||
架构:
|
||||
cloudsearch_transfer/
|
||||
├── adapter/ # 网盘适配器(每平台独立子包)
|
||||
│ ├── base.py # 抽象基类
|
||||
│ ├── factory.py # 工厂+缓存
|
||||
│ ├── quark/ # 夸克网盘 (credential/transfer/cleanup)
|
||||
│ ├── baidu/ # 百度网盘
|
||||
│ ├── aliyun/ # 阿里云盘
|
||||
│ ├── uc/ # UC网盘
|
||||
│ └── xunlei/ # 迅雷网盘
|
||||
├── credential/ # 统一凭证管理
|
||||
│ └── manager.py
|
||||
├── orchestration/ # 转存编排
|
||||
│ └── transfer.py
|
||||
├── config.py # 配置管理
|
||||
├── errors.py # 错误码
|
||||
└── server.py # HTTP API 服务
|
||||
|
||||
使用:
|
||||
from cloudsearch_transfer import TransferOrchestrator, ConfigManager
|
||||
|
||||
cm = ConfigManager()
|
||||
orch = TransferOrchestrator(cm)
|
||||
result = orch.transfer("https://pan.quark.cn/s/xxxx")
|
||||
print(result.share_url)
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
1
cloudsearch_transfer/adapter/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""CloudSearch Transfer — 适配器包"""
|
||||
297
cloudsearch_transfer/adapter/aliyun/__init__.py
Normal 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
|
||||
203
cloudsearch_transfer/adapter/aliyun/cleanup.py
Normal 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,
|
||||
}
|
||||
216
cloudsearch_transfer/adapter/aliyun/credential.py
Normal 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 "",
|
||||
}
|
||||
493
cloudsearch_transfer/adapter/aliyun/transfer.py
Normal 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
|
||||
② 获取分享令牌(Auth):POST /v2/share_link/get_share_token
|
||||
③ 批量复制文件:POST /adrive/v4/batch (X-Share-Token 头)
|
||||
④ 创建新分享:POST /adrive/v2/share_link/create
|
||||
|
||||
用法:
|
||||
credential = AliyunCredentialManager(refresh_token="xxx")
|
||||
transfer = AliyunTransfer(credential, drive_id="12345")
|
||||
result = transfer.transfer(
|
||||
share_url="https://www.aliyundrive.com/s/abc123",
|
||||
share_password="",
|
||||
to_parent_file_id="root",
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
credential: AliyunCredentialManager,
|
||||
drive_id: str = "",
|
||||
to_parent_file_id: str = "root",
|
||||
request_timeout: int = 30,
|
||||
):
|
||||
self.credential = credential
|
||||
self.drive_id = drive_id or credential.get_drive_id()
|
||||
self.to_parent_file_id = to_parent_file_id
|
||||
self.request_timeout = request_timeout
|
||||
self._session = requests.Session()
|
||||
self._session.headers.update(DEFAULT_HEADERS)
|
||||
|
||||
# ─── 公开 API ──────────────────────────────────────────
|
||||
|
||||
def transfer(
|
||||
self,
|
||||
share_url: str,
|
||||
share_password: str = "",
|
||||
to_parent_file_id: str = None,
|
||||
new_share_password: str = "",
|
||||
expiration: str = "",
|
||||
) -> Dict:
|
||||
"""
|
||||
执行完整的转存流程。
|
||||
|
||||
Args:
|
||||
share_url: 阿里云盘分享链接(如 https://www.aliyundrive.com/s/abc123)
|
||||
share_password: 分享提取码(如有)
|
||||
to_parent_file_id: 转存目标目录 file_id,默认用初始化时的值
|
||||
new_share_password: 新分享的密码(空=无密码)
|
||||
expiration: 分享有效期,空=永久
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": True/False,
|
||||
"share_name": "...",
|
||||
"new_file_ids": ["id1", "id2"],
|
||||
"new_share_url": "https://...",
|
||||
"new_share_password": "...",
|
||||
"error": None or "...",
|
||||
}
|
||||
"""
|
||||
parent_id = to_parent_file_id or self.to_parent_file_id
|
||||
|
||||
try:
|
||||
# ① 获取分享详情
|
||||
share_id = self._extract_share_id(share_url)
|
||||
if not share_id:
|
||||
return self._error("无法从 URL 提取分享 ID")
|
||||
|
||||
share_info = self._get_share_info(share_id)
|
||||
if not share_info:
|
||||
return self._error("分享不存在或已失效")
|
||||
|
||||
share_name = share_info.get("share_name", "")
|
||||
file_infos = share_info.get("file_infos", [])
|
||||
if not file_infos:
|
||||
return self._error("分享内容为空")
|
||||
|
||||
logger.info(
|
||||
f"[AliyunTransfer] 分享详情获取成功: "
|
||||
f"name={share_name}, files={len(file_infos)}"
|
||||
)
|
||||
|
||||
# ② 获取分享令牌
|
||||
share_token = self._get_share_token(share_id, share_password)
|
||||
if not share_token:
|
||||
return self._error("获取分享令牌失败(可能需要提取码)")
|
||||
|
||||
logger.info(f"[AliyunTransfer] 分享令牌获取成功")
|
||||
|
||||
# ③ 批量复制文件
|
||||
file_ids = [fi.get("file_id", "") for fi in file_infos if fi.get("file_id")]
|
||||
if not file_ids:
|
||||
return self._error("无法提取文件 ID")
|
||||
|
||||
new_file_ids = self._batch_copy(share_id, share_token, file_ids, parent_id)
|
||||
if not new_file_ids:
|
||||
return self._error("批量转存失败,请检查权限或容量")
|
||||
|
||||
logger.info(f"[AliyunTransfer] 批量转存成功: {len(new_file_ids)} 个文件")
|
||||
|
||||
# ④ 创建新分享
|
||||
share_result = self._create_share(
|
||||
new_file_ids,
|
||||
share_password=new_share_password,
|
||||
expiration=expiration,
|
||||
)
|
||||
|
||||
new_share_url = share_result.get("share_url", "")
|
||||
new_share_pwd = share_result.get("share_pwd", new_share_password)
|
||||
|
||||
logger.info(f"[AliyunTransfer] 新分享创建成功: {new_share_url}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"share_name": share_name,
|
||||
"share_id": share_id,
|
||||
"new_file_ids": new_file_ids,
|
||||
"new_share_url": new_share_url,
|
||||
"new_share_password": new_share_pwd,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"[AliyunTransfer] 转存异常: {e}")
|
||||
return self._error(str(e))
|
||||
|
||||
def get_share_info(self, share_url: str) -> Optional[Dict]:
|
||||
"""
|
||||
仅获取分享详情(不转存)。
|
||||
|
||||
Returns:
|
||||
{"share_name": "...", "file_infos": [...]} or None
|
||||
"""
|
||||
share_id = self._extract_share_id(share_url)
|
||||
if not share_id:
|
||||
logger.error(f"[AliyunTransfer] 无法从 URL 提取 share_id: {share_url}")
|
||||
return None
|
||||
return self._get_share_info(share_id)
|
||||
|
||||
# ─── 步骤 ①:获取分享详情 ───────────────────────────────
|
||||
|
||||
def _get_share_info(self, share_id: str) -> Optional[Dict]:
|
||||
"""
|
||||
POST /adrive/v3/share_link/get_share_by_anonymous
|
||||
请求体: {"share_id": "..."}
|
||||
响应: {"share_name": "...", "file_infos": [{"file_id": "...", "name": "...", ...}]}
|
||||
"""
|
||||
try:
|
||||
resp = self._session.post(
|
||||
SHARE_INFO_URL,
|
||||
json={"share_id": share_id},
|
||||
timeout=self.request_timeout,
|
||||
)
|
||||
data = resp.json()
|
||||
|
||||
if resp.status_code != 200:
|
||||
logger.error(
|
||||
f"[AliyunTransfer] 获取分享详情失败: "
|
||||
f"HTTP {resp.status_code}, {data}"
|
||||
)
|
||||
return None
|
||||
|
||||
# 检查业务错误码
|
||||
code = data.get("code", "")
|
||||
if code:
|
||||
logger.error(
|
||||
f"[AliyunTransfer] 获取分享详情 API 错误: "
|
||||
f"code={code}, message={data.get('message', '')}"
|
||||
)
|
||||
return None
|
||||
|
||||
return {
|
||||
"share_name": data.get("share_name", ""),
|
||||
"share_title": data.get("share_title", data.get("share_name", "")),
|
||||
"file_infos": data.get("file_infos", []),
|
||||
"expiration": data.get("expiration", ""),
|
||||
"creator_name": data.get("creator_name", ""),
|
||||
"creator_id": data.get("creator_id", ""),
|
||||
}
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"[AliyunTransfer] 获取分享详情网络异常: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.exception(f"[AliyunTransfer] 获取分享详情异常: {e}")
|
||||
return None
|
||||
|
||||
# ─── 步骤 ②:获取分享令牌 ────────────────────────────────
|
||||
|
||||
def _get_share_token(self, share_id: str, share_password: str = "") -> Optional[str]:
|
||||
"""
|
||||
POST /v2/share_link/get_share_token
|
||||
请求体: {"share_id": "..."}
|
||||
需要 Auth 头
|
||||
响应: {"share_token": "..."}
|
||||
"""
|
||||
try:
|
||||
headers = self.credential.get_headers()
|
||||
resp = self._session.post(
|
||||
SHARE_TOKEN_URL,
|
||||
json={
|
||||
"share_id": share_id,
|
||||
"share_pwd": share_password,
|
||||
},
|
||||
headers=headers,
|
||||
timeout=self.request_timeout,
|
||||
)
|
||||
data = resp.json()
|
||||
|
||||
if resp.status_code != 200:
|
||||
logger.error(
|
||||
f"[AliyunTransfer] 获取分享令牌失败: "
|
||||
f"HTTP {resp.status_code}, {data}"
|
||||
)
|
||||
return None
|
||||
|
||||
code = data.get("code", "")
|
||||
if code:
|
||||
logger.error(
|
||||
f"[AliyunTransfer] 获取分享令牌 API 错误: "
|
||||
f"code={code}, message={data.get('message', '')}"
|
||||
)
|
||||
return None
|
||||
|
||||
share_token = data.get("share_token", "")
|
||||
if not share_token:
|
||||
logger.error("[AliyunTransfer] 响应中缺少 share_token")
|
||||
return None
|
||||
|
||||
return share_token
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"[AliyunTransfer] 获取分享令牌网络异常: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.exception(f"[AliyunTransfer] 获取分享令牌异常: {e}")
|
||||
return None
|
||||
|
||||
# ─── 步骤 ③:批量复制文件 ────────────────────────────────
|
||||
|
||||
def _batch_copy(
|
||||
self,
|
||||
share_id: str,
|
||||
share_token: str,
|
||||
file_ids: List[str],
|
||||
to_parent_file_id: str = "root",
|
||||
) -> List[str]:
|
||||
"""
|
||||
POST /adrive/v4/batch
|
||||
头: X-Share-Token: <share_token>
|
||||
请求体:
|
||||
{
|
||||
"requests": [
|
||||
{
|
||||
"url": "/file/copy",
|
||||
"body": {
|
||||
"file_id": "...",
|
||||
"share_id": "...",
|
||||
"to_drive_id": "...",
|
||||
"to_parent_file_id": "..."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
响应: {"responses": [{"status": 200, "body": {"file_id": "new_id"}}, ...]}
|
||||
返回新的 file_id 列表
|
||||
"""
|
||||
drive_id = self.drive_id
|
||||
if not drive_id:
|
||||
drive_id = self.credential.get_drive_id()
|
||||
if not drive_id:
|
||||
logger.error("[AliyunTransfer] 缺少 drive_id,无法转存")
|
||||
return []
|
||||
|
||||
# 构建批量请求体
|
||||
requests_list = []
|
||||
for fid in file_ids:
|
||||
requests_list.append({
|
||||
"url": "/file/copy",
|
||||
"body": {
|
||||
"file_id": fid,
|
||||
"share_id": share_id,
|
||||
"to_drive_id": drive_id,
|
||||
"to_parent_file_id": to_parent_file_id,
|
||||
},
|
||||
"headers": {"Content-Type": "application/json"},
|
||||
"id": fid,
|
||||
"method": "POST",
|
||||
})
|
||||
|
||||
try:
|
||||
headers = self.credential.get_headers()
|
||||
headers["X-Share-Token"] = share_token
|
||||
|
||||
resp = self._session.post(
|
||||
BATCH_URL,
|
||||
json={"requests": requests_list, "resource": "file"},
|
||||
headers=headers,
|
||||
timeout=self.request_timeout * 2, # 批量操作可能较慢
|
||||
)
|
||||
data = resp.json()
|
||||
|
||||
if resp.status_code != 200:
|
||||
logger.error(
|
||||
f"[AliyunTransfer] 批量复制失败: "
|
||||
f"HTTP {resp.status_code}, {data}"
|
||||
)
|
||||
return []
|
||||
|
||||
code = data.get("code", "")
|
||||
if code:
|
||||
logger.error(
|
||||
f"[AliyunTransfer] 批量复制 API 错误: "
|
||||
f"code={code}, message={data.get('message', '')}"
|
||||
)
|
||||
return []
|
||||
|
||||
# 提取新 file_id
|
||||
new_ids = []
|
||||
responses = data.get("responses", [])
|
||||
for item in responses:
|
||||
status = item.get("status", 0)
|
||||
body = item.get("body", {})
|
||||
if status in (200, 201, 202):
|
||||
new_fid = body.get("file_id", "")
|
||||
if new_fid:
|
||||
new_ids.append(new_fid)
|
||||
else:
|
||||
logger.warning(
|
||||
f"[AliyunTransfer] 单个文件复制失败: "
|
||||
f"id={item.get('id')}, status={status}, body={body}"
|
||||
)
|
||||
|
||||
if not new_ids:
|
||||
logger.error("[AliyunTransfer] 所有文件复制均失败")
|
||||
elif len(new_ids) < len(file_ids):
|
||||
logger.warning(
|
||||
f"[AliyunTransfer] 部分文件复制成功: "
|
||||
f"{len(new_ids)}/{len(file_ids)}"
|
||||
)
|
||||
|
||||
return new_ids
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"[AliyunTransfer] 批量复制网络异常: {e}")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.exception(f"[AliyunTransfer] 批量复制异常: {e}")
|
||||
return []
|
||||
|
||||
# ─── 步骤 ④:创建新分享 ──────────────────────────────────
|
||||
|
||||
def _create_share(
|
||||
self,
|
||||
file_ids: List[str],
|
||||
share_password: str = "",
|
||||
expiration: str = "",
|
||||
) -> Dict:
|
||||
"""
|
||||
POST /adrive/v2/share_link/create
|
||||
请求体: {"drive_id": "...", "file_id_list": [...], "share_pwd": "...", "expiration": "..."}
|
||||
响应: {"share_url": "...", "share_id": "..."}
|
||||
"""
|
||||
drive_id = self.drive_id or self.credential.get_drive_id()
|
||||
if not drive_id:
|
||||
logger.error("[AliyunTransfer] 缺少 drive_id,无法创建分享")
|
||||
return {"share_url": "", "share_pwd": ""}
|
||||
|
||||
body = {
|
||||
"drive_id": drive_id,
|
||||
"file_id_list": file_ids,
|
||||
"share_pwd": share_password or "",
|
||||
"expiration": expiration or "",
|
||||
}
|
||||
|
||||
try:
|
||||
headers = self.credential.get_headers()
|
||||
resp = self._session.post(
|
||||
CREATE_SHARE_URL,
|
||||
json=body,
|
||||
headers=headers,
|
||||
timeout=self.request_timeout,
|
||||
)
|
||||
data = resp.json()
|
||||
|
||||
if resp.status_code != 200:
|
||||
logger.error(
|
||||
f"[AliyunTransfer] 创建分享失败: "
|
||||
f"HTTP {resp.status_code}, {data}"
|
||||
)
|
||||
return {"share_url": "", "share_pwd": share_password}
|
||||
|
||||
code = data.get("code", "")
|
||||
if code:
|
||||
logger.error(
|
||||
f"[AliyunTransfer] 创建分享 API 错误: "
|
||||
f"code={code}, message={data.get('message', '')}"
|
||||
)
|
||||
return {"share_url": "", "share_pwd": share_password}
|
||||
|
||||
share_url = data.get("share_url", "")
|
||||
share_pwd = data.get("share_pwd", share_password)
|
||||
|
||||
return {"share_url": share_url, "share_pwd": share_pwd}
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"[AliyunTransfer] 创建分享网络异常: {e}")
|
||||
return {"share_url": "", "share_pwd": share_password}
|
||||
except Exception as e:
|
||||
logger.exception(f"[AliyunTransfer] 创建分享异常: {e}")
|
||||
return {"share_url": "", "share_pwd": share_password}
|
||||
|
||||
# ─── URL 解析 ──────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _extract_share_id(url: str) -> Optional[str]:
|
||||
"""从阿里云盘分享 URL 中提取 share_id"""
|
||||
m = URL_PATTERN.search(url)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def extract_share_id_static(url: str) -> Optional[str]:
|
||||
"""静态方法:提取 share_id"""
|
||||
return AliyunTransfer._extract_share_id(url)
|
||||
|
||||
# ─── 工具方法 ──────────────────────────────────────────
|
||||
|
||||
def _error(self, message: str) -> Dict:
|
||||
"""构造错误返回"""
|
||||
return {
|
||||
"success": False,
|
||||
"share_name": "",
|
||||
"share_id": "",
|
||||
"new_file_ids": [],
|
||||
"new_share_url": "",
|
||||
"new_share_password": "",
|
||||
"error": message,
|
||||
}
|
||||
253
cloudsearch_transfer/adapter/baidu/__init__.py
Normal 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)
|
||||
154
cloudsearch_transfer/adapter/baidu/cleanup.py
Normal 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
|
||||
101
cloudsearch_transfer/adapter/baidu/credential.py
Normal 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 缓存已失效")
|
||||
448
cloudsearch_transfer/adapter/baidu/transfer.py
Normal 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
|
||||
330
cloudsearch_transfer/adapter/base.py
Normal 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
|
||||
45
cloudsearch_transfer/adapter/cloud189/__init__.py
Normal 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)
|
||||
26
cloudsearch_transfer/adapter/cloud189/cleanup.py
Normal 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
|
||||
64
cloudsearch_transfer/adapter/cloud189/credential.py
Normal 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
|
||||
68
cloudsearch_transfer/adapter/cloud189/transfer.py
Normal 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 []
|
||||
112
cloudsearch_transfer/adapter/factory.py
Normal 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}"
|
||||
41
cloudsearch_transfer/adapter/pan115/__init__.py
Normal 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)
|
||||
24
cloudsearch_transfer/adapter/pan115/cleanup.py
Normal 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
|
||||
11
cloudsearch_transfer/adapter/pan115/credential.py
Normal 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/"}
|
||||
69
cloudsearch_transfer/adapter/pan115/transfer.py
Normal 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)
|
||||
41
cloudsearch_transfer/adapter/pan123/__init__.py
Normal 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)
|
||||
26
cloudsearch_transfer/adapter/pan123/cleanup.py
Normal 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
|
||||
16
cloudsearch_transfer/adapter/pan123/credential.py
Normal 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",
|
||||
}
|
||||
71
cloudsearch_transfer/adapter/pan123/transfer.py
Normal 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 []
|
||||
509
cloudsearch_transfer/adapter/quark/__init__.py
Normal 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()})"
|
||||
)
|
||||
209
cloudsearch_transfer/adapter/quark/cleanup.py
Normal 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()
|
||||
89
cloudsearch_transfer/adapter/quark/credential.py
Normal 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()})"
|
||||
)
|
||||
554
cloudsearch_transfer/adapter/quark/transfer.py
Normal 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()
|
||||
493
cloudsearch_transfer/adapter/uc/__init__.py
Normal file
@@ -0,0 +1,493 @@
|
||||
"""
|
||||
CloudSearch Transfer — UC网盘适配器 v1.0.0
|
||||
|
||||
将 UcCredentialManager、UcTransfer、UcCleanup 组合为
|
||||
BaseCloudDriveAdapter 的完整实现。
|
||||
|
||||
UC网盘 7 步 API 转存流程(与夸克高度相似,API 域名不同):
|
||||
① POST .../share/sharepage/v2/detail?pr=UCBrowser&fr=pc → stoken
|
||||
② GET .../share/sharepage/detail → fid, share_fid_token, title
|
||||
③ POST .../share/sharepage/save → task_id (转存)
|
||||
④ 轮询 GET .../task → save_as_top_fids
|
||||
⑤ POST .../share → task_id (创建分享)
|
||||
⑥ 轮询 GET .../task → share_id
|
||||
⑦ POST .../share/password → share_url, passcode
|
||||
|
||||
参考 cloud-auto-save 的 quark 实现,域名从 drive-pc.quark.cn 改为 pc-api.uc.cn。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
from ..base import BaseCloudDriveAdapter, FileInfo, TransferResult, VerifyResult
|
||||
from ...config import PlatformConfig, TransferConfig
|
||||
from ...errors import TransferError, TransferErrorCode
|
||||
|
||||
from .credential import UcCredentialManager
|
||||
from .transfer import UcTransfer, SHARE_URL_PATTERN
|
||||
from .cleanup import UcCleanup
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UcAdapter(BaseCloudDriveAdapter):
|
||||
"""UC网盘适配器。
|
||||
|
||||
组合 credential / transfer / cleanup 三个模块,
|
||||
实现 BaseCloudDriveAdapter 定义的所有抽象方法。
|
||||
|
||||
Attributes:
|
||||
PLATFORM_NAME: 展示用平台名称。
|
||||
PLATFORM_KEY: 内部平台标识。
|
||||
URL_PATTERNS: UC 分享链接匹配正则列表。
|
||||
"""
|
||||
|
||||
# ─── 平台标识 ──────────────────────────────────────────────
|
||||
PLATFORM_NAME: str = "UC网盘"
|
||||
PLATFORM_KEY: str = "uc"
|
||||
|
||||
# ─── URL 匹配 ──────────────────────────────────────────────
|
||||
# 支持 drive.uc.cn/s/<share_id>
|
||||
URL_PATTERNS: List[str] = [
|
||||
r"drive\.uc\.cn/s/(\w+)",
|
||||
]
|
||||
|
||||
def __init__(self, config: PlatformConfig, transfer_config: TransferConfig) -> None:
|
||||
"""初始化 UC 适配器。
|
||||
|
||||
Args:
|
||||
config: 平台配置(含 Cookie 等)。
|
||||
transfer_config: 全局转存配置(超时、重试、轮询参数等)。
|
||||
"""
|
||||
super().__init__(config, transfer_config)
|
||||
|
||||
# 初始化三个子模块
|
||||
self._credential: UcCredentialManager = UcCredentialManager(
|
||||
cookie=config.cookie
|
||||
)
|
||||
self._transfer_engine: UcTransfer = UcTransfer(
|
||||
credential=self._credential,
|
||||
timeout=transfer_config.request_timeout,
|
||||
poll_interval=transfer_config.task_poll_interval,
|
||||
poll_max_attempts=transfer_config.task_poll_max_attempts,
|
||||
)
|
||||
self._cleanup: UcCleanup = UcCleanup(
|
||||
credential=self._credential,
|
||||
timeout=transfer_config.request_timeout,
|
||||
)
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# 公开接口实现
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
def _setup_session(self) -> None:
|
||||
"""将 UC Cookie 注入 session 的默认 headers。"""
|
||||
headers = self._credential.get_headers()
|
||||
if headers:
|
||||
self.session.headers.update(headers)
|
||||
logger.debug("[UcAdapter] Session headers updated with Cookie")
|
||||
|
||||
def transfer(self, share_url: str, save_dir: str = "",
|
||||
share_password: str = "") -> TransferResult:
|
||||
"""执行转存的核心逻辑(覆盖基类实现 UC 专用流程)。
|
||||
|
||||
通过 UcTransfer 引擎执行完整的 7 步流程。
|
||||
|
||||
Args:
|
||||
share_url: UC 分享链接。
|
||||
save_dir: 目标目录,空则使用配置的默认目录。
|
||||
share_password: 新分享的密码。
|
||||
|
||||
Returns:
|
||||
TransferResult 包含转存结果。
|
||||
"""
|
||||
start: float = time.time()
|
||||
|
||||
# 凭证检查
|
||||
if not self._credential.validate():
|
||||
raise TransferError(
|
||||
TransferErrorCode.NOT_LOGIN,
|
||||
message="UC Cookie 无效或长度不足",
|
||||
platform=self.PLATFORM_KEY,
|
||||
)
|
||||
|
||||
# 目标目录:默认根目录 "0"
|
||||
target_dir: str = save_dir or self.config.save_dir or "0"
|
||||
|
||||
# 分享密码
|
||||
pwd: str = share_password or self.config.share_password or ""
|
||||
|
||||
try:
|
||||
result: Dict[str, Any] = self._transfer_engine.transfer(
|
||||
share_url=share_url,
|
||||
save_dir=target_dir,
|
||||
share_password=pwd,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise TransferError(
|
||||
TransferErrorCode.URL_INVALID,
|
||||
message=str(exc),
|
||||
platform=self.PLATFORM_KEY,
|
||||
) from exc
|
||||
except RuntimeError as exc:
|
||||
msg: str = str(exc)
|
||||
if "stoken" in msg or "status" in msg:
|
||||
raise TransferError(
|
||||
TransferErrorCode.SHARE_NOT_EXIST,
|
||||
message=msg,
|
||||
platform=self.PLATFORM_KEY,
|
||||
) from exc
|
||||
raise TransferError(
|
||||
TransferErrorCode.NETWORK_ERROR,
|
||||
message=msg,
|
||||
platform=self.PLATFORM_KEY,
|
||||
) from exc
|
||||
|
||||
elapsed: int = int((time.time() - start) * 1000)
|
||||
|
||||
# 广告过滤
|
||||
new_fids: List[str] = result.get("new_file_ids", [])
|
||||
if self.transfer_config.ad_filter_enabled and new_fids:
|
||||
new_fids = self._filter_ads(new_fids)
|
||||
if not new_fids:
|
||||
raise TransferError(
|
||||
TransferErrorCode.RESOURCE_EMPTY,
|
||||
platform=self.PLATFORM_KEY,
|
||||
)
|
||||
|
||||
return TransferResult(
|
||||
success=True,
|
||||
platform=self.PLATFORM_KEY,
|
||||
new_file_id=",".join(new_fids),
|
||||
file_name=result.get("file_name", ""),
|
||||
share_url=result.get("share_url", ""),
|
||||
share_password=result.get("passcode", pwd),
|
||||
original_url=share_url,
|
||||
elapsed_ms=elapsed,
|
||||
)
|
||||
|
||||
def verify(self, share_url: str) -> VerifyResult:
|
||||
"""验证 UC 分享链接有效性。
|
||||
|
||||
Args:
|
||||
share_url: UC 分享链接。
|
||||
|
||||
Returns:
|
||||
VerifyResult 包含验证结果。
|
||||
"""
|
||||
try:
|
||||
pwd_id, passcode = self._parse_share_url(share_url)
|
||||
|
||||
if not self._credential.validate():
|
||||
return VerifyResult(
|
||||
valid=False,
|
||||
platform=self.PLATFORM_KEY,
|
||||
error=TransferError(
|
||||
TransferErrorCode.NOT_LOGIN,
|
||||
platform=self.PLATFORM_KEY,
|
||||
),
|
||||
)
|
||||
|
||||
stoken: str = self._transfer_engine._get_stoken(pwd_id, passcode)
|
||||
detail: Dict[str, Any] = self._transfer_engine._get_detail(pwd_id, stoken)
|
||||
files: List[FileInfo] = self._extract_file_list(detail)
|
||||
|
||||
return VerifyResult(
|
||||
valid=True,
|
||||
platform=self.PLATFORM_KEY,
|
||||
title=detail.get("title", ""),
|
||||
file_count=len(files),
|
||||
files=files,
|
||||
)
|
||||
|
||||
except TransferError:
|
||||
raise
|
||||
except (ValueError, RuntimeError) as exc:
|
||||
return VerifyResult(
|
||||
valid=False,
|
||||
platform=self.PLATFORM_KEY,
|
||||
error=TransferError(
|
||||
TransferErrorCode.SHARE_NOT_EXIST,
|
||||
message=str(exc),
|
||||
platform=self.PLATFORM_KEY,
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
return VerifyResult(
|
||||
valid=False,
|
||||
platform=self.PLATFORM_KEY,
|
||||
error=TransferError(
|
||||
TransferErrorCode.NETWORK_ERROR,
|
||||
message=str(exc),
|
||||
platform=self.PLATFORM_KEY,
|
||||
),
|
||||
)
|
||||
|
||||
# ─── 核心抽象方法 ─────────────────────────────────────────
|
||||
|
||||
def _get_share_detail(self, pwd_id: str, passcode: str = "") -> dict:
|
||||
"""获取 UC 分享详情。
|
||||
|
||||
Args:
|
||||
pwd_id: 分享 ID。
|
||||
passcode: 提取码。
|
||||
|
||||
Returns:
|
||||
分享详情字典,包含 title, fid, share_fid_token 等字段。
|
||||
"""
|
||||
stoken: str = self._transfer_engine._get_stoken(pwd_id, passcode)
|
||||
return self._transfer_engine._get_detail(pwd_id, stoken)
|
||||
|
||||
def _save_files(self, pwd_id: str, detail: dict, save_dir: str) -> List[str]:
|
||||
"""转存文件到自己的 UC 网盘。
|
||||
|
||||
Args:
|
||||
pwd_id: 分享 ID。
|
||||
detail: 分享详情(来自 _get_share_detail)。
|
||||
save_dir: 目标目录 ID。
|
||||
|
||||
Returns:
|
||||
转存后的新文件 ID 列表。
|
||||
"""
|
||||
stoken: str = self._transfer_engine._get_stoken(pwd_id)
|
||||
task_id: str = self._transfer_engine._init_save(
|
||||
pwd_id, stoken, detail, to_pdir_fid=save_dir
|
||||
)
|
||||
return self._transfer_engine._poll_save_task(task_id)
|
||||
|
||||
def _create_share(
|
||||
self, file_ids: List[str], title: str, password: str = ""
|
||||
) -> Tuple[str, str]:
|
||||
"""创建 UC 分享链接。
|
||||
|
||||
Args:
|
||||
file_ids: 要分享的文件 ID 列表。
|
||||
title: 分享标题。
|
||||
password: 分享密码。
|
||||
|
||||
Returns:
|
||||
(share_url, share_password) 元组。
|
||||
"""
|
||||
task_id: str = self._transfer_engine._init_share(file_ids, title)
|
||||
share_id: str = self._transfer_engine._poll_share_task(task_id)
|
||||
return self._transfer_engine._set_password(share_id, password)
|
||||
|
||||
def _extract_file_list(self, detail: dict) -> List[FileInfo]:
|
||||
"""从 UC 分享详情中提取文件列表。
|
||||
|
||||
UC 的 sharepage/detail 返回格式与夸克一致:
|
||||
{
|
||||
"files": [
|
||||
{"fid": "...", "file_name": "...", "size": 123, "dir": false, ...},
|
||||
]
|
||||
}
|
||||
|
||||
Args:
|
||||
detail: 分享详情字典。
|
||||
|
||||
Returns:
|
||||
FileInfo 对象列表。
|
||||
"""
|
||||
files_data: List[Dict[str, Any]] = detail.get("files", [])
|
||||
result: List[FileInfo] = []
|
||||
|
||||
for f in files_data:
|
||||
file_info = FileInfo(
|
||||
fid=str(f.get("fid", f.get("file_id", ""))),
|
||||
name=str(f.get("file_name", f.get("name", ""))),
|
||||
size=int(f.get("size", 0)),
|
||||
is_dir=bool(f.get("dir", f.get("is_dir", False))),
|
||||
ext=str(f.get("ext", f.get("file_extension", ""))),
|
||||
)
|
||||
result.append(file_info)
|
||||
|
||||
# 如果 files 为空,尝试用 detail 顶层字段构造单个文件信息
|
||||
if not result and detail.get("fid"):
|
||||
result.append(
|
||||
FileInfo(
|
||||
fid=str(detail.get("fid", "")),
|
||||
name=str(detail.get("title", detail.get("file_name", ""))),
|
||||
size=0,
|
||||
is_dir=False,
|
||||
)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def _filter_ads(self, file_ids: List[str]) -> List[str]:
|
||||
"""过滤广告文件。
|
||||
|
||||
Args:
|
||||
file_ids: 文件 ID 列表。
|
||||
|
||||
Returns:
|
||||
过滤后的文件 ID 列表。
|
||||
"""
|
||||
keywords: List[str] = list(
|
||||
set(self.config.banned_keywords)
|
||||
| set(self.transfer_config.default_banned_keywords)
|
||||
)
|
||||
if not keywords:
|
||||
return file_ids
|
||||
|
||||
try:
|
||||
files: List[FileInfo] = self.get_files()
|
||||
file_names: List[str] = [f.name for f in files]
|
||||
return UcCleanup.filter_ad_ids(file_ids, file_names, keywords)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[UcAdapter] Cannot fetch file list for ad filtering, skipping"
|
||||
)
|
||||
return file_ids
|
||||
|
||||
# ─── get_files / delete ────────────────────────────────────
|
||||
|
||||
def get_files(self, parent_fid: str = "0") -> List[FileInfo]:
|
||||
"""列出 UC 网盘指定目录下的文件。
|
||||
|
||||
GET /1/clouddrive/file/sort?pdir_fid=<parent_fid>&_page=1&_size=100&_sort=updated_at:desc
|
||||
|
||||
Args:
|
||||
parent_fid: 父目录 ID,默认 "0" 即根目录。
|
||||
|
||||
Returns:
|
||||
FileInfo 列表。
|
||||
"""
|
||||
url: str = f"https://pc-api.uc.cn/1/clouddrive/file/sort"
|
||||
params: Dict[str, str] = {
|
||||
"pdir_fid": parent_fid,
|
||||
"_page": "1",
|
||||
"_size": "100",
|
||||
"_sort": "updated_at:desc",
|
||||
}
|
||||
headers: Dict[str, str] = self._credential.get_headers()
|
||||
|
||||
try:
|
||||
resp = self._get(url, params=params, headers=headers)
|
||||
except Exception as exc:
|
||||
raise TransferError(
|
||||
TransferErrorCode.NETWORK_ERROR,
|
||||
message=f"获取文件列表失败: {exc}",
|
||||
platform=self.PLATFORM_KEY,
|
||||
) from exc
|
||||
|
||||
data: Dict[str, Any] = resp.json()
|
||||
status: int = data.get("status", -1)
|
||||
if status != 0 and data.get("code") not in (0, None):
|
||||
raise TransferError(
|
||||
TransferErrorCode.NETWORK_ERROR,
|
||||
message=f"获取文件列表失败: {data.get('message')}",
|
||||
platform=self.PLATFORM_KEY,
|
||||
)
|
||||
|
||||
files_data: List[Dict[str, Any]] = data.get("data", {}).get("list", [])
|
||||
result: List[FileInfo] = []
|
||||
for f in files_data:
|
||||
result.append(
|
||||
FileInfo(
|
||||
fid=str(f.get("fid", "")),
|
||||
name=str(f.get("file_name", f.get("name", ""))),
|
||||
size=int(f.get("size", 0)),
|
||||
is_dir=bool(f.get("dir", f.get("is_dir", False))),
|
||||
ext=str(f.get("file_extension", f.get("ext", ""))),
|
||||
)
|
||||
)
|
||||
|
||||
logger.debug("[UcAdapter] Listed %d files in dir=%s", len(result), parent_fid)
|
||||
return result
|
||||
|
||||
def delete(self, file_ids: List[str]) -> bool:
|
||||
"""删除 UC 网盘文件(移到回收站)。
|
||||
|
||||
Args:
|
||||
file_ids: 要删除的文件 ID 列表。
|
||||
|
||||
Returns:
|
||||
True 表示删除成功。
|
||||
"""
|
||||
if not self._credential.validate():
|
||||
raise TransferError(
|
||||
TransferErrorCode.NOT_LOGIN,
|
||||
platform=self.PLATFORM_KEY,
|
||||
)
|
||||
|
||||
try:
|
||||
return self._cleanup.delete_files(file_ids)
|
||||
except RuntimeError as exc:
|
||||
raise TransferError(
|
||||
TransferErrorCode.NETWORK_ERROR,
|
||||
message=str(exc),
|
||||
platform=self.PLATFORM_KEY,
|
||||
) from exc
|
||||
|
||||
def delete_permanent(self, file_ids: List[str]) -> bool:
|
||||
"""彻底删除 UC 网盘文件(不可恢复)。
|
||||
|
||||
Args:
|
||||
file_ids: 要彻底删除的文件 ID 列表。
|
||||
|
||||
Returns:
|
||||
True 表示删除成功。
|
||||
"""
|
||||
if not self._credential.validate():
|
||||
raise TransferError(
|
||||
TransferErrorCode.NOT_LOGIN,
|
||||
platform=self.PLATFORM_KEY,
|
||||
)
|
||||
|
||||
try:
|
||||
return self._cleanup.delete_files_permanent(file_ids)
|
||||
except RuntimeError as exc:
|
||||
raise TransferError(
|
||||
TransferErrorCode.NETWORK_ERROR,
|
||||
message=str(exc),
|
||||
platform=self.PLATFORM_KEY,
|
||||
) from exc
|
||||
|
||||
# ─── 工具方法 ─────────────────────────────────────────────
|
||||
|
||||
def _parse_share_url(self, url: str) -> Tuple[str, str]:
|
||||
"""解析 UC 分享 URL 提取 (pwd_id, passcode)。
|
||||
|
||||
UC 链接格式:https://drive.uc.cn/s/<pwd_id> 或带 ?pwd=xxxx
|
||||
|
||||
Args:
|
||||
url: UC 分享链接。
|
||||
|
||||
Returns:
|
||||
(pwd_id, passcode) 元组。
|
||||
|
||||
Raises:
|
||||
TransferError: URL 格式无法识别。
|
||||
"""
|
||||
pwd_id: Optional[str] = UcTransfer.parse_share_url(url)
|
||||
if not pwd_id:
|
||||
raise TransferError(
|
||||
TransferErrorCode.URL_INVALID,
|
||||
message=f"无法解析UC链接: {url}",
|
||||
platform=self.PLATFORM_KEY,
|
||||
)
|
||||
|
||||
parsed = urlparse(url)
|
||||
params = parse_qs(parsed.query)
|
||||
passcode: str = params.get("pwd", params.get("code", [""]))[0]
|
||||
|
||||
return pwd_id, passcode
|
||||
|
||||
def update_cookie(self, cookie: str) -> None:
|
||||
"""动态更新 Cookie 并同步到 session headers。
|
||||
|
||||
Args:
|
||||
cookie: 新的 Cookie 字符串。
|
||||
"""
|
||||
self._credential.update_cookie(cookie)
|
||||
self._setup_session()
|
||||
logger.info("[UcAdapter] Cookie updated, new length=%d", len(cookie))
|
||||
|
||||
def close(self) -> None:
|
||||
"""关闭所有子模块的 HTTP 会话。"""
|
||||
self._transfer_engine.close()
|
||||
218
cloudsearch_transfer/adapter/uc/cleanup.py
Normal 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()
|
||||
95
cloudsearch_transfer/adapter/uc/credential.py
Normal 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()})"
|
||||
)
|
||||
619
cloudsearch_transfer/adapter/uc/transfer.py
Normal 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()
|
||||
112
cloudsearch_transfer/adapter/xunlei/__init__.py
Normal 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}>"
|
||||
198
cloudsearch_transfer/adapter/xunlei/cleanup.py
Normal 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()
|
||||
339
cloudsearch_transfer/adapter/xunlei/credential.py
Normal 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())})"
|
||||
)
|
||||
518
cloudsearch_transfer/adapter/xunlei/transfer.py
Normal 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()
|
||||
172
cloudsearch_transfer/config.py
Normal 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)
|
||||
1
cloudsearch_transfer/credential/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""CloudSearch Transfer — 凭证管理包"""
|
||||
130
cloudsearch_transfer/credential/manager.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
CloudSearch Transfer — 凭证管理器 v1.0.0
|
||||
参考 search-ucmao 的 get_and_validate_credential + cloud-auto-save 的 Token回写
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from ..config import PlatformConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CredentialStatus:
|
||||
"""凭证状态"""
|
||||
valid: bool
|
||||
platform: str
|
||||
last_check: float = 0.0
|
||||
last_error: str = ""
|
||||
checks_count: int = 0
|
||||
fail_count: int = 0
|
||||
|
||||
|
||||
class CredentialManager:
|
||||
"""
|
||||
凭证管理器
|
||||
- 凭证校验(各平台最小长度要求不同)
|
||||
- Token自动刷新(阿里云/迅雷)
|
||||
- 健康检测
|
||||
"""
|
||||
|
||||
# 各平台最小凭证长度
|
||||
MIN_LENGTH_MAP = {
|
||||
"quark": 50, # Cookie ≥ 50字符
|
||||
"baidu": 50, # Cookie ≥ 50字符
|
||||
"uc": 50, # Cookie ≥ 50字符
|
||||
"aliyun": 20, # refresh_token ≥ 20字符
|
||||
"xunlei": 30, # refresh_token ≥ 30字符
|
||||
"pan123": 30,
|
||||
"cloud189": 30,
|
||||
}
|
||||
|
||||
# 凭证类型:cookie / refresh_token
|
||||
CREDENTIAL_TYPE = {
|
||||
"quark": "cookie",
|
||||
"baidu": "cookie",
|
||||
"uc": "cookie",
|
||||
"aliyun": "refresh_token",
|
||||
"xunlei": "refresh_token",
|
||||
"pan123": "cookie",
|
||||
"cloud189": "cookie",
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
self._status: Dict[str, CredentialStatus] = {}
|
||||
self._token_cache: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
def validate(self, platform: str, config: PlatformConfig) -> bool:
|
||||
"""
|
||||
校验凭证有效性
|
||||
参考 search-ucmao 的 get_and_validate_credential 逻辑
|
||||
"""
|
||||
min_len = self.MIN_LENGTH_MAP.get(platform, 20)
|
||||
|
||||
if self.CREDENTIAL_TYPE.get(platform) == "refresh_token":
|
||||
token = config.refresh_token
|
||||
valid = bool(token and len(token) >= min_len)
|
||||
else:
|
||||
cookie = config.cookie
|
||||
valid = bool(cookie and len(cookie) >= min_len)
|
||||
|
||||
# 记录状态
|
||||
status = self._status.get(platform, CredentialStatus(valid=False, platform=platform))
|
||||
status.last_check = time.time()
|
||||
status.checks_count += 1
|
||||
if not valid:
|
||||
status.fail_count += 1
|
||||
status.last_error = f"凭证长度不足 (需要≥{min_len})"
|
||||
else:
|
||||
status.valid = True
|
||||
self._status[platform] = status
|
||||
|
||||
return valid
|
||||
|
||||
def get_credential(self, platform: str, config: PlatformConfig) -> str:
|
||||
"""
|
||||
获取有效凭证
|
||||
对于Token类型会自动刷新
|
||||
"""
|
||||
if not self.validate(platform, config):
|
||||
return ""
|
||||
|
||||
cred_type = self.CREDENTIAL_TYPE.get(platform, "cookie")
|
||||
if cred_type == "refresh_token":
|
||||
# 优先使用缓存的access_token
|
||||
cached = self._token_cache.get(platform, {})
|
||||
if cached.get("access_token") and cached.get("expires_at", 0) > time.time() + 60:
|
||||
return cached["access_token"]
|
||||
return config.refresh_token
|
||||
else:
|
||||
return config.cookie
|
||||
|
||||
def update_access_token(self, platform: str, access_token: str,
|
||||
expires_in: int = 3600):
|
||||
"""更新缓存的access_token"""
|
||||
self._token_cache[platform] = {
|
||||
"access_token": access_token,
|
||||
"expires_at": time.time() + expires_in,
|
||||
}
|
||||
|
||||
def get_status(self, platform: str) -> Optional[CredentialStatus]:
|
||||
"""获取凭证状态"""
|
||||
return self._status.get(platform)
|
||||
|
||||
def get_all_status(self) -> Dict[str, CredentialStatus]:
|
||||
"""获取所有平台凭证状态"""
|
||||
return dict(self._status)
|
||||
|
||||
def mark_invalid(self, platform: str, reason: str = ""):
|
||||
"""标记凭证失效"""
|
||||
status = self._status.get(platform, CredentialStatus(valid=False, platform=platform))
|
||||
status.valid = False
|
||||
status.last_error = reason
|
||||
status.fail_count += 1
|
||||
status.last_check = time.time()
|
||||
self._status[platform] = status
|
||||
logger.warning(f"[Credential] {platform} marked invalid: {reason}")
|
||||
68
cloudsearch_transfer/errors.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
CloudSearch Transfer — 错误码定义 v1.0.0
|
||||
参考 netdisk Go SDK 的错误码设计 + 各项目实践
|
||||
"""
|
||||
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class TransferErrorCode(IntEnum):
|
||||
"""统一错误码"""
|
||||
# 通用错误 (40xxx)
|
||||
URL_INVALID = 40001 # URL格式错误或无法识别平台
|
||||
NOT_LOGIN = 40002 # 未登录或凭证已失效
|
||||
CAPACITY_FULL = 40003 # 存储空间容量不足
|
||||
SHARE_NOT_EXIST = 40004 # 分享不存在或已失效
|
||||
PASSCODE_WRONG = 40005 # 提取码错误
|
||||
RESOURCE_EMPTY = 40006 # 资源内容为空或全为广告文件
|
||||
NETWORK_ERROR = 40007 # 网络请求失败
|
||||
TIMEOUT = 40008 # 操作超时
|
||||
NO_CONFIG = 40009 # 该平台未配置凭证
|
||||
SHARE_LINK_FAIL = 40010 # 分享创建失败
|
||||
SHARE_LIMIT = 40011 # 今日分享次数过多
|
||||
DIR_NOT_EXIST = 40012 # 目标存储目录不存在
|
||||
SENSITIVE_RESOURCE = 40013 # 资源内容违规
|
||||
|
||||
# 平台特有错误 (41xxx)
|
||||
BAIDU_BDSTOKEN_FAIL = 41001 # 百度bdstoken获取失败
|
||||
ALIYUN_TOKEN_EXPIRED = 41002 # 阿里Token过期
|
||||
XUNLEI_CAPTCHA_FAIL = 41003 # 迅雷验证码失败
|
||||
QUARK_LOGIN_REQUIRED = 41004 # 夸克需要重新登录
|
||||
|
||||
|
||||
class TransferError(Exception):
|
||||
"""转存异常"""
|
||||
def __init__(self, code: TransferErrorCode, message: str = None,
|
||||
platform: str = None, details: dict = None):
|
||||
self.code = code
|
||||
self.message = message or self._default_message(code)
|
||||
self.platform = platform
|
||||
self.details = details or {}
|
||||
super().__init__(self.message)
|
||||
|
||||
@staticmethod
|
||||
def _default_message(code: TransferErrorCode) -> str:
|
||||
messages = {
|
||||
TransferErrorCode.URL_INVALID: "URL格式错误或无法识别平台",
|
||||
TransferErrorCode.NOT_LOGIN: "未登录或凭证已失效",
|
||||
TransferErrorCode.CAPACITY_FULL: "存储空间容量不足",
|
||||
TransferErrorCode.SHARE_NOT_EXIST: "分享不存在或已失效",
|
||||
TransferErrorCode.PASSCODE_WRONG: "提取码错误",
|
||||
TransferErrorCode.RESOURCE_EMPTY: "资源内容为空或全为广告文件",
|
||||
TransferErrorCode.NETWORK_ERROR: "网络请求失败",
|
||||
TransferErrorCode.TIMEOUT: "操作超时",
|
||||
TransferErrorCode.NO_CONFIG: "该平台未配置凭证",
|
||||
TransferErrorCode.SHARE_LINK_FAIL: "分享创建失败",
|
||||
TransferErrorCode.SHARE_LIMIT: "今日分享次数过多",
|
||||
TransferErrorCode.DIR_NOT_EXIST: "目标存储目录不存在",
|
||||
TransferErrorCode.SENSITIVE_RESOURCE: "资源内容违规",
|
||||
}
|
||||
return messages.get(code, f"未知错误 (code={code})")
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"code": self.code.value,
|
||||
"message": self.message,
|
||||
"platform": self.platform,
|
||||
"details": self.details,
|
||||
}
|
||||
68
cloudsearch_transfer/feature_flags.py
Normal 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()
|
||||
1
cloudsearch_transfer/orchestration/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""CloudSearch Transfer — 编排包"""
|
||||
214
cloudsearch_transfer/orchestration/transfer.py
Normal 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")
|
||||
2
cloudsearch_transfer/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
flask>=3.0
|
||||
requests>=2.28
|
||||
200
cloudsearch_transfer/server.py
Normal 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
@@ -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
|
After Width: | Height: | Size: 14 KiB |
BIN
icons/123pan.png
Normal file
|
After Width: | Height: | Size: 381 B |
BIN
icons/aliyun.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
icons/baidu.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
icons/pikpak.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
icons/quark.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
icons/tianyi.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
icons/uc.png
Normal file
|
After Width: | Height: | Size: 467 KiB |
BIN
icons/xunlei.png
Normal file
|
After Width: | Height: | Size: 894 B |
29
source_clean/Dockerfile
Normal 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"]
|
||||
79
source_clean/frontend/admin.html
Normal 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>
|
||||
15
source_clean/frontend/admin/js/admin-boot.js
Normal 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');
|
||||
}
|
||||
})();
|
||||
155
source_clean/frontend/admin/js/admin-cleanup.js
Normal 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||''));
|
||||
});
|
||||
}
|
||||
39
source_clean/frontend/admin/js/admin-core.js
Normal 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();
|
||||
}
|
||||
9
source_clean/frontend/admin/js/admin-dashboard.js
Normal 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>'; });
|
||||
}
|
||||
1
source_clean/frontend/admin/js/admin-helpers.js
Normal file
@@ -0,0 +1 @@
|
||||
// Helper utilities
|
||||
14
source_clean/frontend/admin/js/admin-login.js
Normal 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'); }
|
||||
});
|
||||
}
|
||||
13
source_clean/frontend/admin/js/admin-password.js
Normal 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>'; }
|
||||
10
source_clean/frontend/admin/js/admin-services.js
Normal 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>';
|
||||
});
|
||||
}
|
||||
14
source_clean/frontend/admin/js/admin-site.js
Normal 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'); });
|
||||
}
|
||||
80
source_clean/frontend/admin/js/cloud/cloud-actions.js
Normal 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);
|
||||
});
|
||||
}
|
||||
43
source_clean/frontend/admin/js/cloud/cloud-actions.js.bak
Normal 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);
|
||||
});
|
||||
}
|
||||
43
source_clean/frontend/admin/js/cloud/cloud-actions.js.bak3
Normal 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);
|
||||
});
|
||||
}
|
||||
38
source_clean/frontend/admin/js/cloud/cloud-core.js
Normal 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>';
|
||||
});
|
||||
}
|
||||
|
||||
107
source_clean/frontend/admin/js/cloud/cloud-dialog.js
Normal 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=''; }
|
||||
}
|
||||
92
source_clean/frontend/admin/js/cloud/cloud-render.js
Normal 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;
|
||||
}
|
||||
|
||||
92
source_clean/frontend/admin/js/cloud/cloud-render.js.bak3
Normal 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;
|
||||
}
|
||||
|
||||
39
source_clean/frontend/assets/AdminDashboard-CYT9FxBx.js
Normal file
1
source_clean/frontend/assets/AdminDashboard-CxAY_FWD.css
Normal file
1
source_clean/frontend/assets/AdminLayout-BX867Wt6.css
Normal 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)}
|
||||
1
source_clean/frontend/assets/AdminLayout-CxD2j-KS.js
Normal 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};
|
||||
1
source_clean/frontend/assets/AdminLogin-Dydh9B_2.css
Normal 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}
|
||||
1
source_clean/frontend/assets/AdminLogin-xBXneZTD.js
Normal 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};
|
||||
1
source_clean/frontend/assets/Cleanup-GlGrtKk0.js
Normal file
1
source_clean/frontend/assets/Cleanup-xBIb8eSW.css
Normal 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)}
|
||||
1
source_clean/frontend/assets/CloudBadge-JtUrWwGU.css
Normal 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}
|
||||
1
source_clean/frontend/assets/CloudBadge-sfzDTvGE.js
Normal 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};
|
||||