chore: initial commit - CloudSearch v0.0.2
This commit is contained in:
623
packages/backend/src/cloud/drivers/CloudConfig.vue
Executable file
623
packages/backend/src/cloud/drivers/CloudConfig.vue
Executable file
@@ -0,0 +1,623 @@
|
||||
<template>
|
||||
<div class="cloud-config">
|
||||
<!-- 网盘类型开关 -->
|
||||
<el-card class="toggle-card" style="margin-bottom: 20px;">
|
||||
<template #header><span>📂 网盘设置及授权</span></template>
|
||||
<div class="cloud-toggle-grid">
|
||||
<div
|
||||
v-for="ct in cloudTypes"
|
||||
:key="ct.type"
|
||||
class="cloud-toggle-chip"
|
||||
>
|
||||
<img :src="ct.icon" class="cloud-icon-img" />
|
||||
<span class="cloud-label">{{ ct.label }}</span>
|
||||
<el-tag v-if="ct.type === 'others'" size="small" type="info">关</el-tag>
|
||||
<el-switch
|
||||
:model-value="ct.enabled"
|
||||
size="small"
|
||||
@change="(val: boolean) => handleCloudToggle(ct.type, val)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-tip" style="margin-top: 12px;">
|
||||
关闭的网盘类型在搜索结果中不会展示。修改后立即生效,无需点击保存。
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openDialog(null)">新增配置</el-button>
|
||||
<el-button @click="verifyAll">全部重新验证</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="configs" stripe style="width: 100%">
|
||||
<el-table-column label="网盘类型" width="110">
|
||||
<template #default="{ row }">
|
||||
<CloudBadge :cloud_type="row.cloud_type" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="nickname" label="昵称" width="140">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.nickname" class="nickname-text">{{ row.nickname }}</span>
|
||||
<el-text v-else type="info" size="small">未设置</el-text>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="cloud_type_uid" label="标识(__uid)" width="180">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.cloud_type_uid" class="uid-cell">{{ row.cloud_type_uid }}</span>
|
||||
<el-text v-else type="info" size="small">-</el-text>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="验证" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row._verifying" class="verifying">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
</span>
|
||||
<el-tag v-else-if="row.verification_status === 'valid'" type="success" size="small">有效</el-tag>
|
||||
<el-tag v-else-if="row.verification_status === 'invalid'" type="danger" size="small">无效</el-tag>
|
||||
<el-tag v-else type="info" size="small">未验证</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="空间" width="200">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.storage_total" class="storage-cell">
|
||||
<div class="storage-bar-wrap">
|
||||
<div
|
||||
class="storage-bar-fill"
|
||||
:style="{ width: storagePercent(row) + '%' }"
|
||||
:class="storageBarClass(row)"
|
||||
></div>
|
||||
</div>
|
||||
<div class="storage-text">
|
||||
<span class="storage-used">{{ row.storage_used || '?' }}</span>
|
||||
<span class="storage-sep">/</span>
|
||||
<span class="storage-total">{{ row.storage_total }}</span>
|
||||
<span class="storage-free">(可用 {{ storageFree(row) }})</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-text v-else type="info" size="small">—</el-text>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<!-- 转存统计 -->
|
||||
<el-table-column label="转存" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.total_saves > 0" class="save-count">{{ row.total_saves }}次</span>
|
||||
<el-text v-else type="info" size="small">-</el-text>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="390" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="primary" @click="openDialog(row)">编辑</el-button>
|
||||
<el-button text type="primary" @click="verifyOne(row)">验证</el-button>
|
||||
<el-popconfirm title="确定删除该配置?" @confirm="handleDelete(row)">
|
||||
<template #reference>
|
||||
<el-button text type="danger">删除</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑配置' : '新增配置'" width="560px">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||||
<el-form-item label="网盘类型" prop="cloud_type">
|
||||
<el-select v-model="form.cloud_type" style="width: 100%" :disabled="!!editingId" @change="onCloudTypeChange">
|
||||
<el-option
|
||||
v-for="[key, label] in cloudTypeOptions"
|
||||
:key="key"
|
||||
:label="label"
|
||||
:value="key"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="昵称" prop="nickname">
|
||||
<el-input v-model="form.nickname" placeholder="必填,用于区分多个同类型网盘">
|
||||
<template #append>
|
||||
<el-button :loading="form._verifying" @click="verifyAndFillNickname">自动获取</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="Cookie" prop="cookie">
|
||||
<el-input
|
||||
v-model="form.cookie"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 2, maxRows: 4 }"
|
||||
:placeholder="cookiePlaceholder"
|
||||
input-style="font-family: monospace; font-size: 12px;"
|
||||
/>
|
||||
</el-form-item>
|
||||
<!-- Cookie 获取教程(根据网盘类型切换) -->
|
||||
<el-form-item label=" " v-if="form.cloud_type && form.cloud_type !== ''" class="cookie-tips-item">
|
||||
<div class="cookie-tips" :class="`cookie-tips-${form.cloud_type}`">
|
||||
<div class="cookie-tips-header">
|
||||
<span class="cookie-tips-title">📖 {{ cloudTypeLabel }} Cookie 获取教程</span>
|
||||
</div>
|
||||
<ol class="cookie-tips-steps" v-html="cookieTutorialHtml"></ol>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, nextTick, onUnmounted } from 'vue'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
import { CLOUD_LABELS } from '../../types'
|
||||
import type { CloudType, CloudConfig } from '../../types'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getCloudConfigs, saveCloudConfig, updateCloudConfig, deleteCloudConfig, testCloudConnection, getCloudTypes, toggleCloudType } from '../../api'
|
||||
import CloudBadge from '../../components/CloudBadge.vue'
|
||||
import type { ElForm } from 'element-plus'
|
||||
|
||||
interface CloudTypeInfo { type: string; label: string; icon: string; enabled: boolean }
|
||||
const cloudTypes = ref<CloudTypeInfo[]>([])
|
||||
|
||||
const formRef = ref<InstanceType<typeof ElForm>>()
|
||||
const configs = ref<(CloudConfig & { _verifying?: boolean })[]>([])
|
||||
const dialogVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
|
||||
const defaultForm = () => ({
|
||||
cloud_type: '' as CloudType | '',
|
||||
nickname: '',
|
||||
cookie: '',
|
||||
_verifying: false,
|
||||
_storageUsed: '',
|
||||
_storageTotal: '',
|
||||
})
|
||||
|
||||
const form = reactive<{
|
||||
cloud_type: CloudType | ''
|
||||
nickname: string
|
||||
cookie: string
|
||||
_verifying: boolean
|
||||
_storageUsed: string
|
||||
_storageTotal: string
|
||||
}>(defaultForm())
|
||||
|
||||
const rules = computed(() => ({
|
||||
cloud_type: [{ required: true, message: '请选择网盘类型', trigger: 'change' }],
|
||||
nickname: [{ required: true, message: '请填写昵称(区分多个同类型网盘)', trigger: 'blur' }],
|
||||
}))
|
||||
|
||||
const cloudTypeOptions = computed(() => {
|
||||
return Object.entries(CLOUD_LABELS) as [CloudType, string][]
|
||||
})
|
||||
|
||||
const cookiePlaceholder = computed(() => {
|
||||
if (!form.cloud_type) return '请先选择网盘类型'
|
||||
const t = form.cloud_type
|
||||
if (t === 'quark' || t === 'baidu') return `请输入 ${CLOUD_LABELS[t] || t} 的完整 Cookie`
|
||||
return editingId.value ? '留空则保持原有' : '输入完整 Cookie'
|
||||
})
|
||||
|
||||
const cloudTypeLabel = computed(() => {
|
||||
return CLOUD_LABELS[form.cloud_type as CloudType] || form.cloud_type || ''
|
||||
})
|
||||
|
||||
/** Cookie 获取教程 HTML(根据不同网盘类型) */
|
||||
const cookieTutorialHtml = computed(() => {
|
||||
const t = form.cloud_type
|
||||
if (!t) return ''
|
||||
const tutorials: Record<string, string> = {
|
||||
quark: `<li>在电脑上打开 <a href="https://pan.quark.cn" target="_blank">pan.quark.cn</a> 并登录你的夸克账号</li>
|
||||
<li>按 <code>F12</code> 打开开发者工具 → 切换到 <strong>网络 (Network)</strong> 选项卡</li>
|
||||
<li>刷新页面,在请求列表中点击任意一个请求(如 <code>account/info</code>)</li>
|
||||
<li>在右侧 <strong>请求头 (Request Headers)</strong> 中找到 <code>Cookie</code> 字段</li>
|
||||
<li>复制整个 Cookie 值(<b>从开头到结束的完整内容</b>),粘贴到上方输入框</li>
|
||||
<li>点击「<b>自动获取</b>」按钮验证 Cookie 是否有效</li>
|
||||
.cookie-tips-note">⚠️ 必须包含 <code>__st=s%...</code> 字段!请复制浏览器请求头的 <b>整个 Cookie</b>(F12 → Network → 请求头 → Cookie 项),不要只复制部分。</div>`,
|
||||
|
||||
baidu: `<li>在电脑上打开 <a href="https://pan.baidu.com" target="_blank">pan.baidu.com</a> 并登录你的百度账号</li>
|
||||
<li>按 <code>F12</code> 打开开发者工具 → 切换到 <strong>网络 (Network)</strong> 选项卡</li>
|
||||
<li>刷新页面,在请求列表中点击任意一个请求</li>
|
||||
<li>在右侧 <strong>请求头 (Request Headers)</strong> 中找到 <code>Cookie</code> 字段</li>
|
||||
<li>复制整个 Cookie 值,粘贴到上方输入框</li>
|
||||
<li>点击「<b>自动获取</b>」按钮验证 Cookie 是否有效</li>
|
||||
<div class="cookie-tips-note">💡 需要包含 <code>BDUSS</code> 和 <code>STOKEN</code></div>`,
|
||||
|
||||
aliyun: `<li>在电脑上打开 <a href="https://www.aliyundrive.com" target="_blank">aliyundrive.com</a> 并登录</li>
|
||||
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
|
||||
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
|
||||
<li>粘贴到上方输入框,点击「自动获取」验证</li>
|
||||
<div class="cookie-tips-note">💡 需包含 <code>token</code> 等有效字段</div>`,
|
||||
|
||||
'115': `<li>在电脑上打开 <a href="https://115.com" target="_blank">115.com</a> 并登录</li>
|
||||
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
|
||||
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
|
||||
<li>粘贴到上方输入框,点击「自动获取」验证</li>
|
||||
<div class="cookie-tips-note">💡 需包含 <code>UID</code>、<code>CID</code>、<code>SEID</code> 等字段</div>`,
|
||||
|
||||
tianyi: `<li>在电脑上打开 <a href="https://cloud.189.cn" target="_blank">cloud.189.cn</a> 并登录</li>
|
||||
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
|
||||
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
|
||||
<li>粘贴到上方输入框,点击「自动获取」验证</li>
|
||||
<div class="cookie-tips-note">💡 需包含 <code>COOKIE_LOGIN_USER</code>、<code>SESSION</code> 等字段</div>`,
|
||||
|
||||
'123pan': `<li>在电脑上打开 <a href="https://www.123pan.com" target="_blank">123pan.com</a> 并登录</li>
|
||||
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
|
||||
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
|
||||
<li>粘贴到上方输入框,点击「自动获取」验证</li>`,
|
||||
|
||||
uc: `<li>在电脑上打开 <a href="https://drive.uc.cn" target="_blank">drive.uc.cn</a> 并登录</li>
|
||||
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
|
||||
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
|
||||
<li>粘贴到上方输入框,点击「自动获取」验证</li>`,
|
||||
|
||||
xunlei: `<li>在电脑上打开 <a href="https://pan.xunlei.com" target="_blank">pan.xunlei.com</a> 并登录</li>
|
||||
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
|
||||
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
|
||||
<li>粘贴到上方输入框,点击「自动获取」验证</li>`,
|
||||
|
||||
pikpak: `<li>在电脑上打开 <a href="https://www.mypikpak.com" target="_blank">mypikpak.com</a> 并登录</li>
|
||||
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
|
||||
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
|
||||
<li>粘贴到上方输入框,点击「自动获取」验证</li>`,
|
||||
}
|
||||
return tutorials[t] || `<li>在电脑上打开该网盘网站并登录</li>
|
||||
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
|
||||
<li>刷新页面,复制任意请求的 <code>Cookie</code></li>
|
||||
<li>粘贴到上方输入框,点击「自动获取」验证</li>`
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadConfigs()
|
||||
await loadCloudTypes()
|
||||
})
|
||||
|
||||
// 每30分钟自动验证一次
|
||||
let verifyTimer: ReturnType<typeof setInterval> | null = null
|
||||
onMounted(() => {
|
||||
verifyTimer = setInterval(() => {
|
||||
autoVerifyAll()
|
||||
}, 30 * 60 * 1000)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
if (verifyTimer) clearInterval(verifyTimer)
|
||||
})
|
||||
|
||||
async function loadCloudTypes() {
|
||||
try {
|
||||
const result = await getCloudTypes()
|
||||
cloudTypes.value = result.types
|
||||
} catch (e) { console.error('加载网盘类型失败', e) }
|
||||
}
|
||||
|
||||
async function handleCloudToggle(type: string, enabled: boolean) {
|
||||
const ct = cloudTypes.value.find(c => c.type === type)
|
||||
if (!ct) return
|
||||
try {
|
||||
await toggleCloudType(type, enabled)
|
||||
ct.enabled = enabled
|
||||
} catch (e: any) { ElMessage.error(e.message || '切换失败'); ct.enabled = !enabled }
|
||||
}
|
||||
|
||||
async function loadConfigs() {
|
||||
try {
|
||||
configs.value = await getCloudConfigs()
|
||||
} catch (e) {
|
||||
console.error('加载网盘配置失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function autoVerifyAll() {
|
||||
for (const cfg of configs.value) {
|
||||
if (cfg.cookie_preview || cfg.nickname) {
|
||||
await verifyOne(cfg, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyAll() {
|
||||
for (const cfg of configs.value) {
|
||||
if ((cfg.cookie_preview || cfg.nickname) && !cfg._verifying) {
|
||||
await verifyOne(cfg, false)
|
||||
}
|
||||
}
|
||||
ElMessage.success('全部验证完成')
|
||||
}
|
||||
|
||||
async function verifyOne(row: CloudConfig & { _verifying?: boolean }, silent = false) {
|
||||
if (!row.cookie_preview && !row.nickname) {
|
||||
if (!silent) ElMessage.warning('该配置没有 Cookie,请先编辑保存后再验证')
|
||||
return
|
||||
}
|
||||
row._verifying = true
|
||||
try {
|
||||
const result = await testCloudConnection(row.cloud_type, undefined, row.id)
|
||||
row.verification_status = result.success ? 'valid' : 'invalid'
|
||||
if (result.success) {
|
||||
if (result.nickname && !row.nickname) row.nickname = result.nickname
|
||||
if (result.storage_used) row.storage_used = result.storage_used
|
||||
if (result.storage_total) row.storage_total = result.storage_total
|
||||
if (!silent) ElMessage.success(`${CLOUD_LABELS[row.cloud_type]}:${result.message}`)
|
||||
} else {
|
||||
if (!silent) ElMessage.error(`${CLOUD_LABELS[row.cloud_type]}:${result.message}`)
|
||||
}
|
||||
} catch (e: any) {
|
||||
row.verification_status = 'invalid'
|
||||
if (!silent) ElMessage.error(`${CLOUD_LABELS[row.cloud_type]}:验证失败`)
|
||||
} finally {
|
||||
row._verifying = false
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyAndFillNickname() {
|
||||
if (!form.cookie) {
|
||||
ElMessage.warning('请先输入 Cookie')
|
||||
return
|
||||
}
|
||||
if (!form.cloud_type) {
|
||||
ElMessage.warning('请先选择网盘类型')
|
||||
return
|
||||
}
|
||||
form._verifying = true
|
||||
try {
|
||||
const result = await testCloudConnection(form.cloud_type as CloudType, form.cookie)
|
||||
if (result.success) {
|
||||
if (result.nickname) form.nickname = result.nickname
|
||||
if (result.storage_used) form._storageUsed = result.storage_used
|
||||
if (result.storage_total) form._storageTotal = result.storage_total
|
||||
ElMessage.success(`昵称:${result.nickname || '获取成功'}`)
|
||||
} else {
|
||||
ElMessage.warning(result.message || '验证失败,请检查 Cookie')
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.response?.data?.error || '验证失败,请检查 Cookie')
|
||||
} finally {
|
||||
form._verifying = false
|
||||
}
|
||||
}
|
||||
|
||||
function openDialog(row: CloudConfig | null) {
|
||||
if (row) {
|
||||
editingId.value = row.id ?? null
|
||||
form.cloud_type = row.cloud_type
|
||||
form.nickname = row.nickname || ''
|
||||
form.cookie = row.cookie || ''
|
||||
form._verifying = false
|
||||
} else {
|
||||
editingId.value = null
|
||||
form.cloud_type = '' as CloudType | ''
|
||||
form.nickname = ''
|
||||
form.cookie = ''
|
||||
form._verifying = false
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function onCloudTypeChange() {
|
||||
// Cookie 输入框提示会自动更新(computed)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await updateCloudConfig({
|
||||
id: editingId.value,
|
||||
cloud_type: form.cloud_type as CloudType,
|
||||
nickname: form.nickname,
|
||||
cookie: form.cookie || undefined,
|
||||
is_active: true,
|
||||
storage_used: form._storageUsed || undefined,
|
||||
storage_total: form._storageTotal || undefined,
|
||||
})
|
||||
ElMessage.success('配置更新成功')
|
||||
} else {
|
||||
const saved = await saveCloudConfig({
|
||||
cloud_type: form.cloud_type as CloudType,
|
||||
nickname: form.nickname,
|
||||
cookie: form.cookie,
|
||||
is_active: true,
|
||||
storage_used: form._storageUsed || undefined,
|
||||
storage_total: form._storageTotal || undefined,
|
||||
})
|
||||
ElMessage.success('配置保存成功')
|
||||
if (!form._storageTotal) {
|
||||
const result = await testCloudConnection(form.cloud_type as CloudType, undefined, saved.id)
|
||||
if (!result.success) {
|
||||
ElMessage.warning(`配置已保存,但连接验证失败:${result.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
dialogVisible.value = false
|
||||
editingId.value = null
|
||||
await loadConfigs()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.response?.data?.error || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(row: CloudConfig) {
|
||||
try {
|
||||
await deleteCloudConfig(row.id!)
|
||||
ElMessage.success('删除成功')
|
||||
await loadConfigs()
|
||||
} catch (e) {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析字节数 → 数值 */
|
||||
function parseBytes(s: string): number {
|
||||
const m = s.match(/^([\d.]+)\s*(B|KB|MB|GB|TB)$/i)
|
||||
if (!m) return 0
|
||||
const n = parseFloat(m[1])
|
||||
const units: Record<string, number> = { B: 1, KB: 1024, MB: 1024**2, GB: 1024**3, TB: 1024**4 }
|
||||
return n * (units[m[2].toUpperCase()] || 1)
|
||||
}
|
||||
|
||||
function storagePercent(row: CloudConfig): number {
|
||||
if (!row.storage_total || !row.storage_used) return 0
|
||||
const total = parseBytes(row.storage_total)
|
||||
const used = parseBytes(row.storage_used)
|
||||
if (total === 0) return 0
|
||||
return Math.min(100, Math.round((used / total) * 100))
|
||||
}
|
||||
|
||||
function storageBarClass(row: CloudConfig): string {
|
||||
const pct = storagePercent(row)
|
||||
if (pct >= 90) return 'bar-danger'
|
||||
if (pct >= 70) return 'bar-warning'
|
||||
return 'bar-normal'
|
||||
}
|
||||
|
||||
function storageFree(row: CloudConfig): string {
|
||||
if (!row.storage_total || !row.storage_used) return '?'
|
||||
const total = parseBytes(row.storage_total)
|
||||
const used = parseBytes(row.storage_used)
|
||||
if (total === 0) return '?'
|
||||
const free = total - used
|
||||
if (free < 1024) return '小于 1 KB'
|
||||
if (free < 1024 * 1024) return (free / 1024).toFixed(1) + ' KB'
|
||||
if (free < 1024 * 1024 * 1024) return (free / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
if (free < 1024 * 1024 * 1024 * 1024) return (free / (1024 * 1024 * 1024)).toFixed(1) + ' GB'
|
||||
return (free / (1024 * 1024 * 1024 * 1024)).toFixed(1) + ' TB'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cloud-config {
|
||||
background: var(--bg-white);
|
||||
border-radius: var(--radius-card);
|
||||
padding: 24px;
|
||||
}
|
||||
.cloud-toggle-grid { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
.cloud-toggle-chip { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border: 1px solid var(--el-border-color-light); border-radius: 8px; background: var(--el-bg-color); }
|
||||
.cloud-toggle-chip:hover { border-color: var(--el-color-primary-light-5); }
|
||||
.cloud-icon-img { width: 20px; height: 20px; object-fit: contain; }
|
||||
.cloud-label { font-size: 13px; font-weight: 500; }
|
||||
.form-tip { font-size: 12px; color: var(--el-text-color-secondary); }
|
||||
.toolbar {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.sign-summary-tag {
|
||||
margin-left: 4px;
|
||||
}
|
||||
.nickname-text {
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
.uid-cell {
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
/* 空间进度条 */
|
||||
.storage-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
.storage-bar-wrap {
|
||||
height: 4px;
|
||||
background: #f0f2f5;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.storage-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
.storage-bar-fill.bar-normal { background: #67c23a; }
|
||||
.storage-bar-fill.bar-warning { background: #e6a23c; }
|
||||
.storage-bar-fill.bar-danger { background: #f56c6c; }
|
||||
.storage-text {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
.storage-used { color: #606266; font-weight: 600; }
|
||||
.storage-total { color: #303133; font-weight: 600; }
|
||||
.storage-free { color: #909399; }
|
||||
.save-count {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
.verifying {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
:deep(.el-input-group__append) {
|
||||
padding: 0;
|
||||
}
|
||||
:deep(.el-input-group__append .el-button) {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* Cookie 教程卡片 */
|
||||
.cookie-tips-item :deep(.el-form-item__content) {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
.cookie-tips {
|
||||
background: #f8faff;
|
||||
border: 1px solid #e8f0fe;
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
color: #606266;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.cookie-tips-header {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.cookie-tips-title {
|
||||
font-weight: 700;
|
||||
color: #409eff;
|
||||
font-size: 13px;
|
||||
}
|
||||
.cookie-tips-steps {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.cookie-tips-steps li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.cookie-tips-steps code {
|
||||
background: #ecf5ff;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
|
||||
}
|
||||
.cookie-tips-note {
|
||||
margin-top: 8px;
|
||||
padding: 6px 10px;
|
||||
background: #fffbe6;
|
||||
border: 1px solid #fff3c4;
|
||||
border-radius: 4px;
|
||||
color: #8a6d3b;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.cookie-tips-note code {
|
||||
background: #f5f0e0;
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
113
packages/backend/src/cloud/drivers/aliyun.driver.ts
Executable file
113
packages/backend/src/cloud/drivers/aliyun.driver.ts
Executable file
@@ -0,0 +1,113 @@
|
||||
// Native fetch available in Node 20+
|
||||
|
||||
export interface AliyunConfig {
|
||||
cookie?: string;
|
||||
nickname?: string;
|
||||
}
|
||||
|
||||
export class AliyunDriver {
|
||||
private config: AliyunConfig;
|
||||
private baseUrl = 'https://api.aliyundrive.com';
|
||||
|
||||
constructor(config: AliyunConfig = {}) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract share_id from an Aliyun share URL.
|
||||
* Supports:
|
||||
* https://www.aliyundrive.com/s/XXXYYY
|
||||
* https://www.alipan.com/s/XXXYYY
|
||||
* https://api.aliyundrive.com/v2/share_link/XXXYYY
|
||||
*/
|
||||
private extractShareId(shareUrl: string): string | null {
|
||||
try {
|
||||
const url = new URL(shareUrl);
|
||||
const pathMatch = url.pathname.match(/\/s\/([a-zA-Z0-9]+)/);
|
||||
if (pathMatch) return pathMatch[1];
|
||||
|
||||
const shareMatch = url.pathname.match(/\/share_link\/([a-zA-Z0-9]+)/);
|
||||
if (shareMatch) return shareMatch[1];
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a share link using Aliyun's public anonymous API.
|
||||
* No cookie or token required — this endpoint is open.
|
||||
*
|
||||
* API:
|
||||
* POST https://api.aliyundrive.com/v2/share_link/get_share_by_anonymous
|
||||
* Body: { "share_id": "XXXYYY", "share_pwd": "" }
|
||||
*
|
||||
* Success: returns share_name, file_infos, creator info
|
||||
* Failure: returns error code (ShareLinkExpired, ShareLinkCancelled, etc.)
|
||||
*/
|
||||
async validateShareLink(shareUrl: string): Promise<{
|
||||
valid: boolean;
|
||||
message: string;
|
||||
fileCount?: number;
|
||||
shareName?: string;
|
||||
}> {
|
||||
const shareId = this.extractShareId(shareUrl);
|
||||
if (!shareId) {
|
||||
return { valid: false, message: '无法解析阿里云盘链接格式' };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/v2/share_link/get_share_by_anonymous`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Referer': 'https://www.aliyundrive.com/',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
share_id: shareId,
|
||||
share_pwd: '',
|
||||
}),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return { valid: false, message: `HTTP ${response.status}: API 请求失败` };
|
||||
}
|
||||
|
||||
const data = await response.json() as any;
|
||||
|
||||
// Check for error codes
|
||||
if (data.code) {
|
||||
switch (data.code) {
|
||||
case 'ShareLinkExpired':
|
||||
return { valid: false, message: '分享已失效(已过期)' };
|
||||
case 'ShareLinkCancelled':
|
||||
return { valid: false, message: '分享已被取消' };
|
||||
case 'NotFound.ShareLink':
|
||||
return { valid: false, message: '分享链接不存在' };
|
||||
case 'ShareLinkPasswordIncorrect':
|
||||
return { valid: true, message: '需要提取码(链接有效)' };
|
||||
default:
|
||||
return { valid: false, message: data.message || `未知错误 (${data.code})` };
|
||||
}
|
||||
}
|
||||
|
||||
// Success — valid share link
|
||||
const fileInfos = data.file_infos || [];
|
||||
return {
|
||||
valid: true,
|
||||
message: `有效链接(${fileInfos.length} 个文件)`,
|
||||
fileCount: fileInfos.length,
|
||||
shareName: data.share_name || '',
|
||||
};
|
||||
} catch (err: any) {
|
||||
return { valid: false, message: `网络错误: ${err.message || err}` };
|
||||
}
|
||||
}
|
||||
}
|
||||
1189
packages/backend/src/cloud/drivers/baidu.driver.ts
Normal file
1189
packages/backend/src/cloud/drivers/baidu.driver.ts
Normal file
File diff suppressed because it is too large
Load Diff
289
packages/backend/src/cloud/drivers/quark-ad-cleanup.ts
Normal file
289
packages/backend/src/cloud/drivers/quark-ad-cleanup.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { getSystemConfig } from "../../admin/system-config.service";
|
||||
import { getHeaders, makeQuery } from "./quark-api";
|
||||
import { listDir, listDirAllPages } from "./quark-api";
|
||||
import { humanDelay } from "./quark-api";
|
||||
|
||||
/**
|
||||
* 广告关键词清理模块。
|
||||
* 在转存完成后执行:
|
||||
* 1. 遍历转存的目录,删除文件名/文件夹名含广告关键词的内容
|
||||
* 2. 在转存根目录下创建警示文件夹(置顶提醒)
|
||||
*/
|
||||
|
||||
// ==================== 配置读取 ====================
|
||||
|
||||
/** 从 DB 读取广告关键词列表 */
|
||||
export function getAdKeywords(): string[] {
|
||||
const raw = getSystemConfig("quark_ad_keywords") || "";
|
||||
return raw
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/** 从 DB 读取警示文件夹名称列表 */
|
||||
export function getWarningFolderNames(): string[] {
|
||||
const raw = getSystemConfig("quark_warning_folder_names") || "";
|
||||
return raw
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/** 从 DB 读取可疑文件后缀列表 */
|
||||
export function getSusExtensions(): string[] {
|
||||
const raw = getSystemConfig("quark_sus_extensions") || "";
|
||||
if (raw.trim()) {
|
||||
return raw
|
||||
.split("\n")
|
||||
.map((s) => s.trim().toLowerCase().replace(/^\./, ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
// 默认可疑后缀
|
||||
return ["bat", "exe", "vbs", "scr", "cmd", "com", "pif", "js", "jar", "msi", "reg", "inf", "ps1"];
|
||||
}
|
||||
|
||||
// ==================== 关键词检测 ====================
|
||||
|
||||
/** 检查文件名是否包含任意广告关键词 */
|
||||
export function containsAdKeyword(
|
||||
fileName: string,
|
||||
keywords: string[],
|
||||
): boolean {
|
||||
if (!keywords.length) return false;
|
||||
const lower = fileName.toLowerCase();
|
||||
return keywords.some((kw) => kw && lower.includes(kw.toLowerCase()));
|
||||
}
|
||||
|
||||
// ==================== 删除操作 ====================
|
||||
|
||||
/**
|
||||
* 遍历指定目录(含子目录),删除匹配广告关键词的文件和文件夹。
|
||||
* 返回删除的文件数。
|
||||
*/
|
||||
export async function deleteAdFiles(
|
||||
cookie: string,
|
||||
dirFid: string,
|
||||
keywords: string[],
|
||||
): Promise<number> {
|
||||
if (!keywords.length) return 0;
|
||||
|
||||
let deletedCount = 0;
|
||||
const stack: string[] = [dirFid];
|
||||
const visited = new Set<string>();
|
||||
|
||||
while (stack.length > 0) {
|
||||
const fid = stack.pop()!;
|
||||
if (visited.has(fid)) continue;
|
||||
visited.add(fid);
|
||||
|
||||
await humanDelay();
|
||||
const files = await listDir(cookie, fid);
|
||||
if (!files || files.length === 0) continue;
|
||||
|
||||
// 先收集所有需要删除的 fid
|
||||
const toDelete: string[] = [];
|
||||
const toKeep: string[] = [];
|
||||
|
||||
const extensions = getSusExtensions();
|
||||
for (const file of files) {
|
||||
const ext = file.file_name.split(".").pop()?.toLowerCase() || "";
|
||||
const isSusExt = extensions.includes(ext);
|
||||
if (containsAdKeyword(file.file_name, keywords) || isSusExt) {
|
||||
toDelete.push(file.fid);
|
||||
console.log(
|
||||
`[Quark-AdCleanup] 标记删除: "${file.file_name}" (fid: ${file.fid})${isSusExt ? " [可疑后缀]" : " [广告关键词]"}`,
|
||||
);
|
||||
} else {
|
||||
toKeep.push(file.fid);
|
||||
// 如果是目录且不删除,继续遍历子目录
|
||||
if (file.dir) {
|
||||
stack.push(file.fid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 批量删除
|
||||
if (toDelete.length > 0) {
|
||||
const deleteOk = await batchDeleteFiles(cookie, toDelete);
|
||||
if (deleteOk) {
|
||||
deletedCount += toDelete.length;
|
||||
console.log(
|
||||
`[Quark-AdCleanup] 已删除 ${toDelete.length} 个广告文件`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除文件/文件夹(移入回收站)。
|
||||
*/
|
||||
async function batchDeleteFiles(
|
||||
cookie: string,
|
||||
fids: string[],
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`https://drive-pc.quark.cn/1/clouddrive/file/trash?${makeQuery()}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...getHeaders(cookie),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action_type: 2, // 2 = 移入回收站
|
||||
file_list: fids.map((fid) => ({ fid })),
|
||||
exclude_fids: [],
|
||||
}),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
},
|
||||
);
|
||||
const data = (await resp.json()) as any;
|
||||
if (data.status === 200) {
|
||||
return true;
|
||||
}
|
||||
console.log(
|
||||
`[Quark-AdCleanup] batchDelete 返回非200: status=${data.status} msg=${data.message}`,
|
||||
);
|
||||
return false;
|
||||
} catch (err: any) {
|
||||
console.log(`[Quark-AdCleanup] batchDelete 错误: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 警示文件夹创建 ====================
|
||||
|
||||
/**
|
||||
* 在转存根目录下创建警示文件夹。
|
||||
* 文件夹名前加 ⚠️ 和空格,让其按字母排序置顶。
|
||||
* 已存在的则跳过。
|
||||
*/
|
||||
export async function createWarningDirectories(
|
||||
cookie: string,
|
||||
dirNames: string[],
|
||||
): Promise<void> {
|
||||
if (!dirNames.length) return;
|
||||
|
||||
// 先获取根目录下所有文件夹,避免重复创建
|
||||
await humanDelay();
|
||||
const rootFiles = await listDirAllPages(cookie, "0");
|
||||
const existingDirs = new Set(
|
||||
rootFiles.filter((f) => f.dir).map((f) => f.file_name),
|
||||
);
|
||||
|
||||
for (const name of dirNames) {
|
||||
// 格式化名称:确保以 ⚠️ 开头
|
||||
let formattedName = name;
|
||||
if (!formattedName.startsWith("⚠️") && !formattedName.startsWith("⚠")) {
|
||||
formattedName = `⚠️ ${formattedName}`;
|
||||
}
|
||||
// 去掉多余空格
|
||||
formattedName = formattedName.replace(/\s+/g, " ").trim();
|
||||
|
||||
if (existingDirs.has(formattedName)) {
|
||||
console.log(
|
||||
`[Quark-AdCleanup] 警示文件夹已存在,跳过: "${formattedName}"`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
await createSingleDir(cookie, formattedName);
|
||||
// 加入已存在集合,防止同名重试
|
||||
existingDirs.add(formattedName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建单个文件夹。
|
||||
*/
|
||||
async function createSingleDir(
|
||||
cookie: string,
|
||||
dirName: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`https://drive-pc.quark.cn/1/clouddrive/file?${makeQuery()}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...getHeaders(cookie),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
pdir_fid: "0",
|
||||
file_name: dirName,
|
||||
dir: true,
|
||||
dir_path: "",
|
||||
}),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
},
|
||||
);
|
||||
const data = (await resp.json()) as any;
|
||||
if (data.status === 200 && data.data?.fid) {
|
||||
console.log(
|
||||
`[Quark-AdCleanup] 已创建警示文件夹: "${dirName}" (fid: ${data.data.fid})`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
console.log(
|
||||
`[Quark-AdCleanup] 创建文件夹失败: status=${data.status} msg=${data.message}`,
|
||||
);
|
||||
return false;
|
||||
} catch (err: any) {
|
||||
console.log(
|
||||
`[Quark-AdCleanup] 创建文件夹错误: "${dirName}" — ${err.message}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 主入口 ====================
|
||||
|
||||
/**
|
||||
* 执行广告清理 + 创建警示文件夹。
|
||||
* 在转存重命名后调用。
|
||||
*/
|
||||
export async function runAdCleanup(
|
||||
cookie: string,
|
||||
savedDirFid: string,
|
||||
): Promise<{ adDeleted: number; warningDirs: number }> {
|
||||
const keywords = getAdKeywords();
|
||||
const warningNames = getWarningFolderNames();
|
||||
|
||||
let adDeleted = 0;
|
||||
let warningDirs = 0;
|
||||
|
||||
// 1. 广告关键词清理
|
||||
if (keywords.length > 0) {
|
||||
console.log(
|
||||
`[Quark-AdCleanup] 开始广告关键词清理: ${keywords.length} 个关键词`,
|
||||
);
|
||||
adDeleted = await deleteAdFiles(cookie, savedDirFid, keywords);
|
||||
console.log(
|
||||
`[Quark-AdCleanup] 广告清理完成,共删除 ${adDeleted} 个文件/文件夹`,
|
||||
);
|
||||
} else {
|
||||
console.log("[Quark-AdCleanup] 无广告关键词配置,跳过清理");
|
||||
}
|
||||
|
||||
// 2. 创建警示文件夹
|
||||
if (warningNames.length > 0) {
|
||||
console.log(
|
||||
`[Quark-AdCleanup] 开始创建警示文件夹: ${warningNames.length} 个`,
|
||||
);
|
||||
await createWarningDirectories(cookie, warningNames);
|
||||
warningDirs = warningNames.length;
|
||||
console.log(
|
||||
`[Quark-AdCleanup] 警示文件夹创建完成(共 ${warningDirs} 个)`,
|
||||
);
|
||||
} else {
|
||||
console.log("[Quark-AdCleanup] 无警示文件夹配置,跳过创建");
|
||||
}
|
||||
|
||||
return { adDeleted, warningDirs };
|
||||
}
|
||||
237
packages/backend/src/cloud/drivers/quark-api.ts
Normal file
237
packages/backend/src/cloud/drivers/quark-api.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
// Native fetch available in Node 20+
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* HTTP 封装层 — 统一处理夸克 API 的请求签名、headers、query params。
|
||||
* 所有模块共用此单例/函数集,不持有状态。
|
||||
*/
|
||||
|
||||
export interface QuarkConfig {
|
||||
cookie: string;
|
||||
nickname?: string;
|
||||
}
|
||||
|
||||
// ==================== Headers & Params ====================
|
||||
|
||||
const BASE_URL = 'https://drive-pc.quark.cn';
|
||||
|
||||
export function getHeaders(cookie: string): Record<string, string> {
|
||||
return {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Cookie': cookie,
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||||
'Referer': 'https://pan.quark.cn/',
|
||||
'Origin': 'https://pan.quark.cn',
|
||||
};
|
||||
}
|
||||
|
||||
export function getCommonParams(): Record<string, string> {
|
||||
return { pr: 'ucpro', fr: 'pc' };
|
||||
}
|
||||
|
||||
/** Generate query string with common params + random timing to mimic browser */
|
||||
export function makeQuery(extra: Record<string, string> = {}): string {
|
||||
const __dt = Math.floor(Math.random() * 240000 + 60000);
|
||||
const __t = Date.now() / 1000;
|
||||
return new URLSearchParams({
|
||||
...getCommonParams(),
|
||||
uc_param_str: '',
|
||||
app: 'clouddrive',
|
||||
__dt: String(__dt),
|
||||
__t: String(__t),
|
||||
...extra,
|
||||
}).toString();
|
||||
}
|
||||
|
||||
/** Random delay to mimic human behavior (500-2000ms) */
|
||||
export async function humanDelay(): Promise<void> {
|
||||
const ms = Math.floor(Math.random() * 1500) + 500;
|
||||
await new Promise(r => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
/** Generate a random password for share links */
|
||||
export function randomSharePwd(): string {
|
||||
return Math.floor(1000 + Math.random() * 9000).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract kps/sign/vcode from cookie for API signing (bare keys, no __ prefix).
|
||||
*/
|
||||
export function getMparam(cookie: string): { kps?: string; sign?: string; vcode?: string } {
|
||||
// Match both __kps and kps (with or without __ prefix)
|
||||
const kpsMatch = cookie.match(/__?kps=([a-zA-Z0-9%+/=]+)/);
|
||||
const signMatch = cookie.match(/__?sign=([a-zA-Z0-9%+/=]+)/);
|
||||
const vcodeMatch = cookie.match(/__?vcode=([a-zA-Z0-9%+/=]+)/);
|
||||
if (kpsMatch && signMatch && vcodeMatch) {
|
||||
return {
|
||||
kps: kpsMatch[1],
|
||||
sign: signMatch[1].replace(/%25/g, '%'),
|
||||
vcode: vcodeMatch[1],
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// ==================== Shared fetch helpers ====================
|
||||
|
||||
/**
|
||||
* Raw fetch wrapper with JSON parse + status check.
|
||||
* Returns parsed JSON body on 2xx, null on network error.
|
||||
*/
|
||||
export async function apiFetch<T = any>(
|
||||
path: string,
|
||||
options: {
|
||||
method?: string;
|
||||
query?: Record<string, string>;
|
||||
body?: any;
|
||||
cookie: string;
|
||||
timeout?: number;
|
||||
},
|
||||
): Promise<T | null> {
|
||||
const { method = 'GET', query, body, cookie, timeout = 10000 } = options;
|
||||
let url = `${BASE_URL}${path}`;
|
||||
if (query) url += `?${new URLSearchParams(query).toString()}`;
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
...getHeaders(cookie),
|
||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
return (await resp.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== File listing (shared across modules) ====================
|
||||
|
||||
export interface QuarkFile {
|
||||
fid: string;
|
||||
file_name: string;
|
||||
share_fid_token?: string;
|
||||
dir: boolean;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* List files in a directory by FID.
|
||||
*/
|
||||
export async function listDir(cookie: string, pdirFid: string, page = 1, pageSize = 50): Promise<QuarkFile[]> {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
...getCommonParams(),
|
||||
uc_param_str: '',
|
||||
pdir_fid: pdirFid,
|
||||
_page: String(page),
|
||||
_size: String(pageSize),
|
||||
_fetch_total: '1',
|
||||
_fetch_sub_dirs: '0',
|
||||
_sort: 'file_type:asc,updated_at:desc',
|
||||
fetch_all_file: '1',
|
||||
fetch_risk_file_name: '1',
|
||||
});
|
||||
const resp = await fetch(
|
||||
`${BASE_URL}/1/clouddrive/file/sort?${params.toString()}`,
|
||||
{ headers: getHeaders(cookie), signal: AbortSignal.timeout(15000) },
|
||||
);
|
||||
if (!resp.ok) return [];
|
||||
const data = await resp.json() as any;
|
||||
if (data.status !== 200) return [];
|
||||
return (data.data?.list || []).filter((f: any) => f.fid).map((f: any) => ({
|
||||
fid: f.fid,
|
||||
file_name: f.file_name,
|
||||
share_fid_token: '',
|
||||
dir: f.dir || false,
|
||||
size: f.size || 0,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List root directory (pdir_fid=0) — returns all top-level dirs/files.
|
||||
*/
|
||||
export async function listRootDir(cookie: string): Promise<QuarkFile[]> {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
pr: 'ucpro', fr: 'pc',
|
||||
pdir_fid: '0',
|
||||
_page: '1', _size: '200',
|
||||
_fetch_total: '1', _fetch_sub_dirs: '0',
|
||||
_sort: 'file_type:asc,updated_at:desc',
|
||||
fetch_all_file: '1',
|
||||
fetch_risk_file_name: '1',
|
||||
});
|
||||
const resp = await fetch(
|
||||
`${BASE_URL}/1/clouddrive/file/sort?${params.toString()}`,
|
||||
{ headers: getHeaders(cookie), signal: AbortSignal.timeout(15000) },
|
||||
);
|
||||
if (!resp.ok) return [];
|
||||
const data = await resp.json() as any;
|
||||
if (data.status !== 200 || !data.data?.list) return [];
|
||||
return (data.data.list || []).map((f: any) => ({
|
||||
fid: f.fid,
|
||||
file_name: f.file_name,
|
||||
dir: f.dir || false,
|
||||
size: f.size || 0,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all files in a directory, handling pagination.
|
||||
* Fetches all pages until no more results.
|
||||
*/
|
||||
export async function listDirAllPages(cookie: string, pdirFid: string): Promise<QuarkFile[]> {
|
||||
const allFiles: QuarkFile[] = [];
|
||||
let page = 1;
|
||||
const pageSize = 100;
|
||||
let total = -1;
|
||||
while (total === -1 || (page - 1) * pageSize < total) {
|
||||
const files = await listDir(cookie, pdirFid, page, pageSize);
|
||||
if (!files.length) break;
|
||||
allFiles.push(...files);
|
||||
if (total === -1) {
|
||||
total = files.length;
|
||||
}
|
||||
page++;
|
||||
}
|
||||
return allFiles;
|
||||
}
|
||||
|
||||
// ==================== Format utilities ====================
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/** Generate a daily folder name (e.g. "2026-05-03") for organizing saves */
|
||||
export function dailyFolderName(): string {
|
||||
const d = new Date();
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
/** Generate a random folder name for saving (fallback) */
|
||||
export function randomFolderName(): string {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let name = '';
|
||||
for (let i = 0; i < 12; i++) {
|
||||
name += chars[Math.floor(Math.random() * chars.length)];
|
||||
}
|
||||
return name;
|
||||
}
|
||||
60
packages/backend/src/cloud/drivers/quark-auth.ts
Normal file
60
packages/backend/src/cloud/drivers/quark-auth.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { QuarkConfig } from './quark-api';
|
||||
import { getHeaders, getMparam, apiFetch, makeQuery } from './quark-api';
|
||||
|
||||
/**
|
||||
* 认证模块 — Cookie 验证、账号信息获取、QR 登录状态检查。
|
||||
* 所有方法以 cookie 字符串为参数,不持有驱动状态。
|
||||
*/
|
||||
|
||||
// ==================== Validate ====================
|
||||
|
||||
/**
|
||||
* Validate the cookie by fetching user info.
|
||||
*/
|
||||
export async function validate(cookie: string): Promise<boolean> {
|
||||
const MAX_RETRIES = 2;
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
// Use account/info API (same as quark-auto-save project)
|
||||
// Only needs __uid cookie, no mparam (kps/sign/vcode) required
|
||||
const url = 'https://pan.quark.cn/account/info?fr=pc&platform=pc';
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
...getHeaders(cookie),
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) quark-cloud-drive/3.14.2 Chrome/112.0.5615.165 Electron/24.1.3.8 Safari/537.36 Channel/pckk_other_ch',
|
||||
},
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!response.ok) return false;
|
||||
const data = await response.json() as any;
|
||||
if (data?.data?.nickname) return true;
|
||||
} catch (err: any) {
|
||||
if (attempt < MAX_RETRIES) {
|
||||
console.log(`[Quark] validate attempt ${attempt + 1} failed: ${err.message}, retrying...`);
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
continue;
|
||||
}
|
||||
console.log(`[Quark] validate all ${MAX_RETRIES + 1} attempts failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Fetch nickname from Quark account info (same API used by quark-auto-save) */
|
||||
export async function fetchNickname(cookie: string): Promise<string | null> {
|
||||
try {
|
||||
const url = 'https://pan.quark.cn/account/info?fr=pc&platform=pc';
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
...getHeaders(cookie),
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) quark-cloud-drive/3.14.2 Chrome/112.0.5615.165 Electron/24.1.3.8 Safari/537.36 Channel/pckk_other_ch',
|
||||
},
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json() as any;
|
||||
return data?.data?.nickname || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
315
packages/backend/src/cloud/drivers/quark-cleanup.ts
Normal file
315
packages/backend/src/cloud/drivers/quark-cleanup.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
import { getHeaders, getCommonParams, getMparam, listRootDir, listDirAllPages, formatBytes, humanDelay, makeQuery, listDir, QuarkFile } from './quark-api';
|
||||
|
||||
/**
|
||||
* 容量信息 & 空间清理模块。
|
||||
*/
|
||||
const BASE_URL = 'https://drive-pc.quark.cn';
|
||||
|
||||
// ==================== Storage Info ====================
|
||||
|
||||
/** Cached used space, keyed by hour block (3h window) */
|
||||
const cachedUsedSpace: { bytes: number; hourBlock: number } | null = null;
|
||||
|
||||
// We use a function-scoped cache instead of instance field
|
||||
const storageCache: { bytes: number; hourBlock: number } = { bytes: 0, hourBlock: -1 };
|
||||
|
||||
/**
|
||||
* Get total capacity from /capacity/detail API.
|
||||
* Also does a quick used-space estimate by summing root-level file sizes + subdir sizes
|
||||
* (夸克目录的 size 字段 = 该目录内所有文件总大小,无需递归).
|
||||
* If the API fails (e.g. missing sign params), falls back to fallbackTotal if provided.
|
||||
*/
|
||||
export async function getStorageInfoQuick(cookie: string, fallbackTotal?: string): Promise<{ total: string; totalBytes: number; used: string; usedBytes: number }> {
|
||||
try {
|
||||
const mparam = getMparam(cookie);
|
||||
const params = new URLSearchParams({
|
||||
...getCommonParams(),
|
||||
kps: mparam.kps || '',
|
||||
sign: mparam.sign || '',
|
||||
vcode: mparam.vcode || '',
|
||||
});
|
||||
const capResponse = await fetch(`${BASE_URL}/1/clouddrive/capacity/detail?${params.toString()}`, {
|
||||
headers: getHeaders(cookie),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
let totalBytes = 0;
|
||||
if (capResponse.ok) {
|
||||
const data = await capResponse.json() as any;
|
||||
if (data.status === 200 && data.data) {
|
||||
totalBytes = data.data.capacity_summary?.sum_capacity || 0;
|
||||
if (totalBytes === 0) {
|
||||
const memberships = [...(data.data.effect || []), ...(data.data.expired || [])];
|
||||
totalBytes = memberships.reduce((max: number, m: any) => Math.max(max, m.capacity || 0), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Quick used-space estimate: sum root-level file sizes + subdir sizes
|
||||
let usedBytes = 0;
|
||||
try {
|
||||
const rootFiles = await listRootDir(cookie);
|
||||
for (const f of rootFiles) {
|
||||
usedBytes += f.size || 0;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Cache the result (3h window)
|
||||
const currentHourBlock = Math.floor(new Date().getHours() / 3);
|
||||
storageCache.bytes = usedBytes;
|
||||
storageCache.hourBlock = currentHourBlock;
|
||||
|
||||
if (totalBytes > 0) {
|
||||
return {
|
||||
total: formatBytes(totalBytes),
|
||||
totalBytes,
|
||||
used: formatBytes(usedBytes),
|
||||
usedBytes,
|
||||
};
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Fallback: try to parse from a human-readable string like "6 TB"
|
||||
if (fallbackTotal) {
|
||||
const match = fallbackTotal.match(/^([\d.]+)\s*([KMGT]B?)/i);
|
||||
if (match) {
|
||||
const num = parseFloat(match[1]);
|
||||
const unit = match[2].toUpperCase();
|
||||
const multipliers: Record<string, number> = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3, TB: 1024 ** 4, PB: 1024 ** 5 };
|
||||
const multiplier = multipliers[unit] || multipliers[unit.replace('B', '') + 'B'] || 0;
|
||||
if (multiplier > 0) {
|
||||
return { total: fallbackTotal, totalBytes: Math.round(num * multiplier), used: '-', usedBytes: 0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { total: '-', totalBytes: 0, used: '-', usedBytes: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get storage info with used space calculation.
|
||||
*/
|
||||
export async function getStorageInfo(cookie: string): Promise<{ used: string; total: string; usedBytes: number; totalBytes: number }> {
|
||||
try {
|
||||
const mparam = getMparam(cookie);
|
||||
let totalBytes = 0;
|
||||
const params = new URLSearchParams({
|
||||
...getCommonParams(),
|
||||
kps: mparam.kps || '',
|
||||
sign: mparam.sign || '',
|
||||
vcode: mparam.vcode || '',
|
||||
});
|
||||
const response = await fetch(`${BASE_URL}/1/clouddrive/capacity/detail?${params.toString()}`, {
|
||||
headers: getHeaders(cookie),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json() as any;
|
||||
if (data.status === 200 && data.data) {
|
||||
totalBytes = data.data.capacity_summary?.sum_capacity || 0;
|
||||
if (totalBytes === 0) {
|
||||
const memberships = [...(data.data.effect || []), ...(data.data.expired || [])];
|
||||
totalBytes = memberships.reduce((max: number, m: any) => Math.max(max, m.capacity || 0), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const usedBytes = await calculateUsedSpace(cookie);
|
||||
|
||||
if (totalBytes > 0 || usedBytes > 0) {
|
||||
return {
|
||||
total: totalBytes > 0 ? formatBytes(totalBytes) : '-',
|
||||
used: formatBytes(usedBytes),
|
||||
usedBytes,
|
||||
totalBytes: totalBytes > 0 ? totalBytes : 0,
|
||||
};
|
||||
}
|
||||
return { used: '0 B', total: '-', usedBytes: 0, totalBytes: 0 };
|
||||
} catch {
|
||||
return { used: '-', total: '-', usedBytes: 0, totalBytes: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total used space by recursively traversing all files
|
||||
* and summing their sizes. Uses 3-hour time window cache.
|
||||
*/
|
||||
export async function calculateUsedSpace(cookie: string): Promise<number> {
|
||||
const currentHourBlock = Math.floor(new Date().getHours() / 3);
|
||||
if (storageCache.hourBlock === currentHourBlock && storageCache.bytes > 0) {
|
||||
return storageCache.bytes;
|
||||
}
|
||||
let totalUsed = 0;
|
||||
const stack: string[] = ['0'];
|
||||
const visited = new Set<string>();
|
||||
while (stack.length > 0) {
|
||||
const fid = stack.pop()!;
|
||||
if (visited.has(fid)) continue;
|
||||
visited.add(fid);
|
||||
const files = await listDirAllPages(cookie, fid);
|
||||
if (!files.length) continue;
|
||||
for (const f of files) {
|
||||
if (f.dir) {
|
||||
stack.push(f.fid);
|
||||
} else {
|
||||
totalUsed += f.size || 0;
|
||||
}
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
}
|
||||
storageCache.bytes = totalUsed;
|
||||
storageCache.hourBlock = currentHourBlock;
|
||||
return totalUsed;
|
||||
}
|
||||
|
||||
// ==================== Cleanup ====================
|
||||
|
||||
/**
|
||||
* Trash specified files/folders (move to recycle bin).
|
||||
*/
|
||||
export async function trashFiles(cookie: string, fids: string[]): Promise<boolean> {
|
||||
if (!fids.length) return true;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${BASE_URL}/1/clouddrive/file/trash?${makeQuery()}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { ...getHeaders(cookie), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action_type: 1, // 1 = move to trash
|
||||
filelist: fids,
|
||||
exclude_filelist: [],
|
||||
}),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
},
|
||||
);
|
||||
if (!response.ok) return false;
|
||||
const data = await response.json() as any;
|
||||
if (data.status === 200) return true;
|
||||
console.error(`[Quark] trashFiles failed: ${data.message || data.status}`);
|
||||
return false;
|
||||
} catch (err: any) {
|
||||
console.error(`[Quark] trashFiles error: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty the recycle bin — permanently delete all files in trash.
|
||||
*/
|
||||
export async function emptyTrash(cookie: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${BASE_URL}/1/clouddrive/file/trash/clear?${makeQuery()}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { ...getHeaders(cookie), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
signal: AbortSignal.timeout(60000),
|
||||
},
|
||||
);
|
||||
if (!response.ok) return false;
|
||||
const data = await response.json() as any;
|
||||
if (data.status === 200) return true;
|
||||
console.error(`[Quark] emptyTrash failed: ${data.message || data.status}`);
|
||||
return false;
|
||||
} catch (err: any) {
|
||||
console.error(`[Quark] emptyTrash error: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup: trash date-named folders (YYYY-MM-DD) older than `days`.
|
||||
*/
|
||||
export async function cleanupOldDateFolders(cookie: string, days: number): Promise<{ trashed: number; errors: string[] }> {
|
||||
const errors: string[] = [];
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - days);
|
||||
const cutoffStr = cutoff.toISOString().slice(0, 10);
|
||||
|
||||
try {
|
||||
const rootItems = await listRootDir(cookie);
|
||||
const oldFolders = rootItems.filter(item => {
|
||||
if (!item.dir) return false;
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(item.file_name)) return false;
|
||||
return item.file_name < cutoffStr;
|
||||
});
|
||||
|
||||
if (oldFolders.length === 0) {
|
||||
return { trashed: 0, errors: [] };
|
||||
}
|
||||
|
||||
const fids = oldFolders.map(f => f.fid);
|
||||
console.log(`[Quark] Trashing ${fids.length} old date folders (before ${cutoffStr}): ${oldFolders.map(f => f.file_name).join(', ')}`);
|
||||
const ok = await trashFiles(cookie, fids);
|
||||
if (ok) {
|
||||
return { trashed: fids.length, errors: [] };
|
||||
}
|
||||
return { trashed: 0, errors: [`Trash API returned failure for ${fids.length} folders`] };
|
||||
} catch (err: any) {
|
||||
return { trashed: 0, errors: [err.message] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup: if used space exceeds thresholdPercent% of total,
|
||||
* delete the oldest date folders until totalBytes * deletePercent/100
|
||||
* of total capacity is freed.
|
||||
*/
|
||||
export async function cleanupBySpaceThreshold(
|
||||
cookie: string,
|
||||
thresholdPercent: number,
|
||||
deletePercent: number,
|
||||
): Promise<{ trashed: number; errors: string[] }> {
|
||||
const errors: string[] = [];
|
||||
|
||||
try {
|
||||
const storage = await getStorageInfo(cookie);
|
||||
if (storage.totalBytes <= 0) return { trashed: 0, errors: [] };
|
||||
|
||||
const usagePercent = (storage.usedBytes / storage.totalBytes) * 100;
|
||||
if (usagePercent < thresholdPercent) {
|
||||
console.log(`[Quark] Usage ${usagePercent.toFixed(1)}% below threshold ${thresholdPercent}%, skipping`);
|
||||
return { trashed: 0, errors: [] };
|
||||
}
|
||||
|
||||
const targetBytesToFree = Math.floor(storage.totalBytes * Math.min(deletePercent, 100) / 100);
|
||||
|
||||
const rootItems = await listRootDir(cookie);
|
||||
const dateFolders = rootItems
|
||||
.filter(item => item.dir && /^\d{4}-\d{2}-\d{2}$/.test(item.file_name))
|
||||
.sort((a, b) => a.file_name.localeCompare(b.file_name));
|
||||
|
||||
if (dateFolders.length === 0) return { trashed: 0, errors: [] };
|
||||
|
||||
const hasSizes = dateFolders.some(f => f.size && f.size > 0);
|
||||
let cumulativeSize = 0;
|
||||
const foldersToTrash: typeof dateFolders = [];
|
||||
|
||||
if (hasSizes) {
|
||||
for (const folder of dateFolders) {
|
||||
foldersToTrash.push(folder);
|
||||
cumulativeSize += folder.size || 0;
|
||||
if (cumulativeSize >= targetBytesToFree) break;
|
||||
}
|
||||
} else {
|
||||
const avgSizePerFolder = storage.usedBytes / dateFolders.length;
|
||||
const estCount = Math.max(1, Math.ceil(targetBytesToFree / avgSizePerFolder));
|
||||
foldersToTrash.push(...dateFolders.slice(0, estCount));
|
||||
cumulativeSize = estCount * avgSizePerFolder;
|
||||
}
|
||||
|
||||
const freedMB = (cumulativeSize / 1024 / 1024).toFixed(0);
|
||||
const targetMB = (targetBytesToFree / 1024 / 1024).toFixed(0);
|
||||
const fidsToTrash = foldersToTrash.map(f => f.fid);
|
||||
console.log(`[Quark] Space threshold: trashing ${foldersToTrash.length}/${dateFolders.length} oldest folders (~${freedMB} MB) to free ${targetMB} MB (${deletePercent}% of ${(storage.totalBytes/1024/1024/1024).toFixed(0)} GB total)`);
|
||||
|
||||
const ok = await trashFiles(cookie, fidsToTrash);
|
||||
if (ok) {
|
||||
console.log(`[Quark] ✅ Space-threshold trashed ${foldersToTrash.length} folders (~${freedMB} MB)`);
|
||||
return { trashed: foldersToTrash.length, errors: [] };
|
||||
}
|
||||
return { trashed: 0, errors: [`Space-threshold trash failed for ${foldersToTrash.length} folders`] };
|
||||
} catch (err: any) {
|
||||
return { trashed: 0, errors: [err.message] };
|
||||
}
|
||||
}
|
||||
259
packages/backend/src/cloud/drivers/quark-rename.ts
Normal file
259
packages/backend/src/cloud/drivers/quark-rename.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* 防和谐重命名模块。
|
||||
* 对文件名/目录名执行谐音替换 + 可读标签保留(集数、画质、语言等)。
|
||||
*/
|
||||
|
||||
// ==================== Homophone Map ====================
|
||||
|
||||
const HOMOPHONE_MAP: Record<string, string> = {
|
||||
// 网盘热门番名 — 谐音替换 (same sound, different char)
|
||||
'斗':'陡','破':'坡','苍':'仓','穹':'穷',
|
||||
'完':'玩','美':'每','世':'士','界':'介',
|
||||
'凡':'烦','人':'仁','修':'休','罗':'络',
|
||||
'仙':'先','逆':'腻','遮':'折','天':'添',
|
||||
'吞':'屯','噬':'逝','大':'达','主':'嘱','宰':'崽',
|
||||
'星':'惺','辰':'晨','变':'便','一':'伊','念':'捻',
|
||||
'永':'泳','恒':'横','神':'申','墓':'暮','长':'尝','生':'甥',
|
||||
'剑':'箭','来':'莱','诡':'鬼','秘':'蜜',
|
||||
'全':'泉','职':'值','盘':'磐','龙':'笼',
|
||||
'雪':'血','鹰':'莺','莽':'蟒','荒':'慌','纪':'记',
|
||||
'珠':'株','王':'亡','座':'坐','牧':'木','记':'计',
|
||||
'沧':'舱','元':'圆','图':'涂','紫':'仔','川':'串',
|
||||
'百':'白','炼':'恋','成':'程','饶':'绕','命':'冥',
|
||||
// 通用谐音替换
|
||||
'的':'得','了':'啦','是':'事','不':'布','我':'窝',
|
||||
'你':'尼','他':'她','有':'友','和':'合','与':'予',
|
||||
'上':'尚','下':'夏','中':'忠','第':'弟','集':'级',
|
||||
'话':'划','季':'际','年':'念','月':'阅','日':'曰',
|
||||
'新':'心','版':'板','高':'糕','清':'青','原':'源',
|
||||
'小':'晓','片':'篇','视':'市','频':'贫','道':'到',
|
||||
'动':'洞','画':'话','声':'升','音':'因','文':'闻',
|
||||
'明':'名','暗':'黯','光':'广','影':'映','色':'瑟',
|
||||
'风':'疯','雨':'语','花':'华','国':'果','家':'佳',
|
||||
'战':'站','争':'挣','士':'仕','兵':'宾',
|
||||
'皇':'惶','帝':'谛','魔':'磨','鬼':'诡','怪':'乖',
|
||||
'精':'经','灵':'铃','妖':'夭','武':'舞','侠':'狭',
|
||||
'杀':'刹','血':'雪','刀':'叨','枪':'呛','炮':'泡',
|
||||
'时':'石','空':'孔','前':'钱','后':'厚','东':'冬',
|
||||
'南':'难','西':'夕','北':'备','开':'凯','关':'官',
|
||||
'出':'初','进':'近','去':'趣',
|
||||
'短':'短','多':'多','少':'少','真':'贞','假':'价',
|
||||
'好':'郝','坏':'怀','对':'队','错':'措','以':'已',
|
||||
'从':'从','被':'被','把':'把','将':'将','在':'在',
|
||||
'但':'但','就':'就','才':'才','也':'也','很':'狠',
|
||||
'又':'又','再':'再','更':'更','最':'最','总':'总',
|
||||
'共':'共','只':'只','各':'各','每':'每','任':'任',
|
||||
'所':'所','该':'该','本':'本',
|
||||
};
|
||||
|
||||
const NOISE_CJK = '的了在是不有会可对所之也同与及但或如且乃而岂乎焉兮哉亦犹尚乃其若故盖诸焉欤' +
|
||||
'么个着过把对为从以到说时要就这那和上人家下能出得发来年心开物力些长样吧啊哦嗯嚯哇咯呗哟嘿呵哈';
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
/** Convert Chinese text to homophonic (substitute chars with same sound) */
|
||||
function homophonicText(text: string): string {
|
||||
let result = '';
|
||||
for (const ch of text) {
|
||||
if (/[\u4e00-\u9fff]/.test(ch)) {
|
||||
const homophone = HOMOPHONE_MAP[ch];
|
||||
result += homophone || ch;
|
||||
} else {
|
||||
result += ch;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Convert Chinese text to pinyin-initial-like string (each char → first pinyin letter or fallback) */
|
||||
function pinyinLike(text: string): string {
|
||||
let result = '';
|
||||
for (const ch of text) {
|
||||
if (/[\u4e00-\u9fff]/.test(ch)) {
|
||||
const homophone = HOMOPHONE_MAP[ch];
|
||||
if (homophone) {
|
||||
result += pinyinInitial(homophone);
|
||||
} else {
|
||||
const code = ch.charCodeAt(0);
|
||||
result += String.fromCharCode(97 + (code % 26));
|
||||
}
|
||||
} else if (/[a-zA-Z0-9]/.test(ch)) {
|
||||
result += ch;
|
||||
} else if (/[\s._-]/.test(ch)) {
|
||||
result += '_';
|
||||
}
|
||||
}
|
||||
return result.replace(/_+/g, '_').replace(/^_|_$/g, '');
|
||||
}
|
||||
|
||||
/** Get pinyin initial (first letter of pinyin) for a Chinese character */
|
||||
function pinyinInitial(ch: string): string {
|
||||
const code = ch.charCodeAt(0);
|
||||
if (code >= 0x4E00 && code <= 0x9FFF) {
|
||||
const initials = ['b','p','m','f','d','t','n','l','g','k','h','j','q','x','zh','ch','sh','r','z','c','s','y','w'];
|
||||
const idx = Math.min(Math.floor((code - 0x4E00) / 700), initials.length - 1);
|
||||
return initials[idx];
|
||||
}
|
||||
return ch.toLowerCase();
|
||||
}
|
||||
|
||||
// ==================== Public API ====================
|
||||
|
||||
/**
|
||||
* Anti-harmony rename for directories.
|
||||
* 80%: light homophonic replacement, 20%: partial pinyin.
|
||||
*/
|
||||
export function magicRenameDir(dirName: string): string {
|
||||
const hash = crypto.createHash('md5').update(dirName + Date.now()).digest('hex').slice(0, 4);
|
||||
|
||||
let cleanName = dirName.trim().replace(/\s+/g, ' ');
|
||||
if (!cleanName) {
|
||||
return `media_${hash}`;
|
||||
}
|
||||
|
||||
let baseName: string;
|
||||
|
||||
if (Math.random() < 0.2) {
|
||||
// Partial pinyin: 30% of CJK chars → pinyin initial, 70% stay as-is
|
||||
const chars = [...cleanName];
|
||||
const result: string[] = [];
|
||||
for (const ch of chars) {
|
||||
if (/[\u4e00-\u9fff]/.test(ch) && Math.random() < 0.3) {
|
||||
result.push(pinyinInitial(ch));
|
||||
} else {
|
||||
result.push(ch);
|
||||
}
|
||||
}
|
||||
baseName = result.join('');
|
||||
} else {
|
||||
// Light homophonic: replace each CJK char, keep everything else as-is
|
||||
const chars = [...cleanName];
|
||||
const result: string[] = [];
|
||||
for (const ch of chars) {
|
||||
if (/[\u4e00-\u9fff]/.test(ch)) {
|
||||
result.push(HOMOPHONE_MAP[ch] || ch);
|
||||
} else {
|
||||
result.push(ch);
|
||||
}
|
||||
}
|
||||
baseName = result.join('');
|
||||
|
||||
// Optional: insert 0-2 light noise chars (low probability)
|
||||
const noiseCount = Math.random() < 0.3 ? (Math.random() < 0.5 ? 1 : 2) : 0;
|
||||
for (let n = 0; n < noiseCount; n++) {
|
||||
const pos = Math.floor(Math.random() * (baseName.length + 1));
|
||||
const ink = NOISE_CJK[Math.floor(Math.random() * NOISE_CJK.length)];
|
||||
baseName = baseName.slice(0, pos) + ink + baseName.slice(pos);
|
||||
}
|
||||
}
|
||||
|
||||
baseName = baseName.replace(/[^\u4e00-\u9fff\w]/g, '_');
|
||||
baseName = baseName.replace(/_+/g, '_').replace(/^_|_$/g, '');
|
||||
if (baseName.length > 30) baseName = baseName.slice(0, 30);
|
||||
|
||||
return `${baseName}_${hash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Anti-harmony rename for files.
|
||||
* KEEPS: episode numbers, quality, language tags, original extension.
|
||||
* REPLACES: Chinese title with homophonic/pinyin.
|
||||
*/
|
||||
export function magicRename(filename: string): string {
|
||||
const hash = crypto.createHash('md5').update(filename + Date.now()).digest('hex').slice(0, 8);
|
||||
|
||||
let ext = '';
|
||||
const extMatch = filename.match(/\.[a-zA-Z0-9]+$/);
|
||||
if (extMatch) {
|
||||
ext = extMatch[0];
|
||||
filename = filename.slice(0, -ext.length);
|
||||
}
|
||||
|
||||
// Extract and REMEMBER: episode info, quality, language, year
|
||||
const episodePatterns = [
|
||||
{ regex: /第\s*(\d+)\s*[集话話話話话回章期]/, format: (m: string) => 'Ep' + m.replace(/[^\d]/g, '') },
|
||||
{ regex: /Ep\d+|ep\d+/i, format: (m: string) => m.toUpperCase() },
|
||||
{ regex: /Part\s*\d+/i, format: (m: string) => m.replace(/\s+/g, '') },
|
||||
{ regex: /E\d{2,}/i, format: (m: string) => m.toUpperCase() },
|
||||
];
|
||||
let episodeTag = '';
|
||||
for (const { regex, format } of episodePatterns) {
|
||||
const m = filename.match(regex);
|
||||
if (m) {
|
||||
episodeTag = format(m[0]);
|
||||
filename = filename.replace(m[0], '');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract and REMEMBER: quality tags
|
||||
const qualityPattern = /\b(4k|1080p|1080P|2160p|720p|HD|BluRay|Blu-ray|HDR|WEB-DL|WEBRip|BDRip|REMUX|DV|Dovi|HEVC|x264|x265|H\.264|H\.265)\b/;
|
||||
const qualityMatch = filename.match(qualityPattern);
|
||||
const qualityTag = qualityMatch ? qualityMatch[0] : '';
|
||||
if (qualityMatch) filename = filename.replace(qualityMatch[0], '');
|
||||
|
||||
// Extract and REMEMBER: language tags
|
||||
const langPattern = /\b(CHS|CHT|JP|EN|BIG5|GB|粤语|国语|日语|英语|中字|日字|英字|繁体中字)\b/;
|
||||
const langMatch = filename.match(langPattern);
|
||||
const langTag = langMatch ? langMatch[0] : '';
|
||||
if (langMatch) filename = filename.replace(langMatch[0], '');
|
||||
|
||||
// Extract and REMEMBER: year
|
||||
const yearMatch = filename.match(/\b(20\d{2})\b/);
|
||||
const yearTag = yearMatch ? yearMatch[0] : '';
|
||||
if (yearMatch) filename = filename.replace(yearMatch[0], '');
|
||||
|
||||
// Extract and REMEMBER: season info
|
||||
const seasonMatch = filename.match(/第?\s*(\d+)\s*[季部期]/);
|
||||
const seasonTag = seasonMatch ? `${seasonMatch[1]}季` : '';
|
||||
if (seasonMatch) filename = filename.replace(seasonMatch[0], '');
|
||||
|
||||
// Now process the remaining name (mostly Chinese title)
|
||||
filename = filename.replace(/[._\-【】\[\]()()\s]+/g, '_').trim();
|
||||
|
||||
const useHomophonic = Math.random() > 0.5;
|
||||
let titlePart: string;
|
||||
if (useHomophonic) {
|
||||
titlePart = homophonicText(filename);
|
||||
titlePart = titlePart.replace(/[^\u4e00-\u9fff\wa-zA-Z0-9]/g, '_');
|
||||
titlePart = titlePart.replace(/_+/g, '_').replace(/^_|_$/g, '');
|
||||
if (titlePart.length > 15) titlePart = titlePart.slice(0, 15);
|
||||
} else {
|
||||
titlePart = pinyinLike(filename);
|
||||
titlePart = titlePart.replace(/[^a-zA-Z0-9]/g, '_');
|
||||
titlePart = titlePart.replace(/_+/g, '_').replace(/^_|_$/g, '');
|
||||
if (titlePart.length > 15) titlePart = titlePart.slice(0, 15);
|
||||
}
|
||||
|
||||
// Remove sensitive keywords from title part
|
||||
const sensitiveWords = /斗破|完美|凡人|仙逆|遮天|吞噬|大主宰|绝世|武动|星辰变|一念永恒|修罗|神墓|长生|剑来|诡秘|全职|斗罗|盘龙|雪鹰|莽荒纪|天珠变|神印王座|牧神记|沧元图|紫川|百炼成神|大王饶命|全球高考/ig;
|
||||
titlePart = titlePart.replace(sensitiveWords, '');
|
||||
titlePart = titlePart.replace(/_+/g, '_').replace(/^_|_$/g, '');
|
||||
|
||||
// Build preserved tags
|
||||
const tags: string[] = [];
|
||||
if (seasonTag) tags.push(seasonTag);
|
||||
if (episodeTag) tags.push(episodeTag);
|
||||
if (qualityTag) tags.push(qualityTag.toUpperCase());
|
||||
if (langTag) tags.push(langTag);
|
||||
if (yearTag) tags.push(yearTag);
|
||||
tags.push(hash); // Always add hash for uniqueness
|
||||
|
||||
const newExt = ext || '.bin';
|
||||
|
||||
const parts = [titlePart, ...tags].filter(Boolean);
|
||||
let result = parts.join('_');
|
||||
|
||||
if (result.length > 80) {
|
||||
result = result.slice(0, 80);
|
||||
}
|
||||
|
||||
if (result.length < 10) {
|
||||
const filler = crypto.randomBytes(4).toString('hex');
|
||||
result = `${filler}_${result}`;
|
||||
}
|
||||
|
||||
return result + newExt;
|
||||
}
|
||||
409
packages/backend/src/cloud/drivers/quark-share.ts
Normal file
409
packages/backend/src/cloud/drivers/quark-share.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
import { getHeaders, getCommonParams, makeQuery, getMparam, humanDelay, randomSharePwd, apiFetch, QuarkFile } from './quark-api';
|
||||
|
||||
/**
|
||||
* 分享模块 — 分享链接解析、转存任务、创建分享链接。
|
||||
*/
|
||||
|
||||
const BASE_URL = 'https://drive-pc.quark.cn';
|
||||
|
||||
// ==================== Acquire Stoken ====================
|
||||
|
||||
/**
|
||||
* Acquire stoken for a share link (needed for detail/save).
|
||||
*/
|
||||
export async function acquireStoken(cookie: string, pwdId: string): Promise<string | null> {
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
const params = new URLSearchParams(getCommonParams());
|
||||
const resp = await fetch(
|
||||
`${BASE_URL}/1/clouddrive/share/sharepage/token?${params.toString()}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { ...getHeaders(cookie), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pwd_id: pwdId, passcode: '' }),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
},
|
||||
);
|
||||
if (!resp.ok) {
|
||||
if (attempt < 2) continue;
|
||||
return null;
|
||||
}
|
||||
const data = await resp.json() as any;
|
||||
if (data.status === 200 && data.data?.stoken) {
|
||||
return data.data.stoken;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
if (attempt >= 2) return null;
|
||||
await new Promise(r => setTimeout(r, 500 * (attempt + 1)));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ==================== Get Share Files ====================
|
||||
|
||||
/**
|
||||
* Fetch detail at a given pdir_fid within a share.
|
||||
*/
|
||||
export async function getDetailAt(
|
||||
cookie: string,
|
||||
pwdId: string,
|
||||
stoken: string,
|
||||
pdirFid: string,
|
||||
): Promise<QuarkFile[]> {
|
||||
const params = new URLSearchParams({
|
||||
...getCommonParams(),
|
||||
pwd_id: pwdId,
|
||||
stoken,
|
||||
pdir_fid: pdirFid,
|
||||
force: '0',
|
||||
_page: '1',
|
||||
_size: '50',
|
||||
_fetch_banner: '0',
|
||||
_fetch_share: '1',
|
||||
_fetch_total: '1',
|
||||
_sort: 'file_type:asc,updated_at:desc',
|
||||
ver: '2',
|
||||
fetch_share_full_path: '0',
|
||||
});
|
||||
const resp = await fetch(
|
||||
`${BASE_URL}/1/clouddrive/share/sharepage/detail?${params.toString()}`,
|
||||
{ headers: getHeaders(cookie), signal: AbortSignal.timeout(15000) },
|
||||
);
|
||||
if (!resp.ok) return [];
|
||||
const data = await resp.json() as any;
|
||||
if (data.status !== 200) return [];
|
||||
return (data.data?.list || []).filter((f: any) => f.fid).map((f: any) => ({
|
||||
fid: f.fid,
|
||||
file_name: f.file_name,
|
||||
share_fid_token: f.share_fid_token || '',
|
||||
dir: f.dir || false,
|
||||
size: f.size || 0,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively collect files from a share.
|
||||
* If the share contains a single directory, drill into it to list contents
|
||||
* but still save the directory itself.
|
||||
*/
|
||||
export async function getShareFiles(
|
||||
cookie: string,
|
||||
pwdId: string,
|
||||
stoken: string,
|
||||
): Promise<{ files: QuarkFile[]; topDir: boolean; childFiles?: QuarkFile[] } | null> {
|
||||
try {
|
||||
const topLevel = await getDetailAt(cookie, pwdId, stoken, '0');
|
||||
if (!topLevel || topLevel.length === 0) return null;
|
||||
|
||||
// If the share is a single directory, we save the directory itself
|
||||
// and fetch its contents for renaming later
|
||||
if (topLevel.length === 1 && topLevel[0].dir) {
|
||||
const innerFiles = await getDetailAt(cookie, pwdId, stoken, topLevel[0].fid);
|
||||
return {
|
||||
files: topLevel,
|
||||
topDir: true,
|
||||
childFiles: innerFiles || [],
|
||||
};
|
||||
}
|
||||
|
||||
// Multiple top-level items: save them directly
|
||||
return {
|
||||
files: topLevel,
|
||||
topDir: false,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Save Files (share → cloud) ====================
|
||||
|
||||
/**
|
||||
* Save shared files to the user's cloud directory.
|
||||
*/
|
||||
export async function saveFiles(
|
||||
cookie: string,
|
||||
pwdId: string,
|
||||
stoken: string,
|
||||
fids: string[],
|
||||
fidTokens: string[],
|
||||
toPdirFid: string,
|
||||
): Promise<{ success: boolean; message: string; taskId?: string }> {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${BASE_URL}/1/clouddrive/share/sharepage/save?${makeQuery()}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { ...getHeaders(cookie), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
fid_list: fids,
|
||||
fid_token_list: fidTokens,
|
||||
to_pdir_fid: toPdirFid,
|
||||
pwd_id: pwdId,
|
||||
stoken,
|
||||
pdir_fid: '0',
|
||||
scene: 'link',
|
||||
}),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
},
|
||||
);
|
||||
const data = await resp.json() as any;
|
||||
if (data.status === 200 && data.data?.task_id) {
|
||||
return { success: true, message: 'Save task created', taskId: data.data.task_id };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: data.message === 'require login [guest]'
|
||||
? '夸克网盘 Cookie 已过期,请在后台重新配置 Cookie'
|
||||
: (data.message || `API 返回错误 (status=${data.status}, code=${data.code})`),
|
||||
};
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message || 'Network error' };
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Wait for Save Task ====================
|
||||
|
||||
/**
|
||||
* Poll task status until complete or timeout.
|
||||
* Returns the saved file FIDs (save_as_top_fids).
|
||||
*/
|
||||
export async function waitForTask(cookie: string, taskId: string, timeoutMs: number): Promise<string[] | null> {
|
||||
const start = Date.now();
|
||||
let retryIndex = 0;
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
...getCommonParams(),
|
||||
uc_param_str: '',
|
||||
task_id: taskId,
|
||||
retry_index: String(retryIndex),
|
||||
__dt: String(Math.floor(Math.random() * 240000 + 60000)),
|
||||
__t: String(Date.now() / 1000),
|
||||
});
|
||||
const resp = await fetch(
|
||||
`${BASE_URL}/1/clouddrive/task?${params.toString()}`,
|
||||
{ headers: getHeaders(cookie), signal: AbortSignal.timeout(10000) },
|
||||
);
|
||||
const data = await resp.json() as any;
|
||||
if (data.status === 200) {
|
||||
if (data.data?.status === 2) {
|
||||
// Task completed
|
||||
const savedFids: string[] = data.data?.save_as?.save_as_top_fids || [];
|
||||
return savedFids;
|
||||
}
|
||||
// Still in progress
|
||||
retryIndex++;
|
||||
}
|
||||
} catch {
|
||||
// Network error, retry
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
return null; // Timeout
|
||||
}
|
||||
|
||||
// ==================== Rename File ====================
|
||||
|
||||
/**
|
||||
* Rename a file by its FID.
|
||||
*/
|
||||
export async function renameFile(cookie: string, fid: string, newName: string): Promise<boolean> {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${BASE_URL}/1/clouddrive/file/rename?${makeQuery()}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { ...getHeaders(cookie), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ fid, file_name: newName }),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
},
|
||||
);
|
||||
const data = await resp.json() as any;
|
||||
return data.status === 200 || data.code === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Create Share Link ====================
|
||||
|
||||
/**
|
||||
* Create a share link for a file/folder.
|
||||
* Flow: create task → poll for share_id → submit to get short URL.
|
||||
*/
|
||||
export async function createShareLink(cookie: string, fileId: string): Promise<{ success: boolean; shareUrl?: string; sharePwd?: string; message: string }> {
|
||||
try {
|
||||
const sharePwd = randomSharePwd();
|
||||
|
||||
// Try different share_type values (1=7天, 0=无限制)
|
||||
const shareTypes = ['1', '0'];
|
||||
let lastError = '';
|
||||
|
||||
for (const st of shareTypes) {
|
||||
await humanDelay();
|
||||
// Step 1: Create share task - get task_id
|
||||
const response = await fetch(
|
||||
`${BASE_URL}/1/clouddrive/share?${makeQuery()}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { ...getHeaders(cookie), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
fid_list: [fileId],
|
||||
share_type: st,
|
||||
url_type: '1',
|
||||
share_pwd: sharePwd,
|
||||
}),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
},
|
||||
);
|
||||
const data = await response.json() as any;
|
||||
const taskId = data.data?.task_id;
|
||||
if (!taskId) {
|
||||
lastError = data.message || `share_type=${st} 失败`;
|
||||
console.error('[Quark] Create share task failed (type=%s):', st, data.message || JSON.stringify(data).slice(0, 200));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Step 2: Poll task until complete
|
||||
const result = await waitForShareTask(cookie, taskId, 20000);
|
||||
if (!result?.shareId) {
|
||||
lastError = result?.message || '任务超时';
|
||||
console.error('[Quark] Wait for share task failed (type=%s):', st, result?.message || 'unknown');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Step 3: Submit share via /password endpoint
|
||||
const shareUrl = await submitShare(cookie, result.shareId, sharePwd);
|
||||
if (shareUrl) {
|
||||
return {
|
||||
success: true,
|
||||
shareUrl,
|
||||
sharePwd,
|
||||
message: `分享链接已生成(密码:${sharePwd})`,
|
||||
};
|
||||
}
|
||||
lastError = '提交密码后未获取到短链接';
|
||||
}
|
||||
|
||||
return { success: false, message: lastError || '🤷 各种姿势都试过了,就是分享不出来…' };
|
||||
} catch (err: any) {
|
||||
console.error('[Quark] createShareLink error:', err.message);
|
||||
return { success: false, message: err.message || '🌩️ 网络开小差了,再试试?' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit share via /password endpoint to get the actual short URL.
|
||||
*/
|
||||
async function submitShare(cookie: string, shareId: string, sharePwd?: string): Promise<string | null> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${BASE_URL}/1/clouddrive/share/password?${makeQuery()}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { ...getHeaders(cookie), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ share_id: shareId, share_pwd: sharePwd || '' }),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
},
|
||||
);
|
||||
const data = await response.json() as any;
|
||||
if (data.status === 200 && data.data?.share_url) {
|
||||
console.log('[Quark] Share short URL:', data.data.share_url);
|
||||
return data.data.share_url;
|
||||
}
|
||||
console.log('[Quark] /password response:', JSON.stringify(data).slice(0, 300));
|
||||
console.error('[Quark] /password FAIL status=%s msg=%s', data.status, data.message || '');
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.log('[Quark] /password error:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll share task until complete and extract share URL/shortcode.
|
||||
*/
|
||||
async function waitForShareTask(cookie: string, taskId: string, timeoutMs: number): Promise<{ shareId?: string; message?: string } | null> {
|
||||
const start = Date.now();
|
||||
let retryIndex = 0;
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
...getCommonParams(),
|
||||
uc_param_str: '',
|
||||
task_id: taskId,
|
||||
retry_index: String(retryIndex),
|
||||
__dt: String(Math.floor(Math.random() * 240000 + 60000)),
|
||||
__t: String(Date.now() / 1000),
|
||||
});
|
||||
const resp = await fetch(
|
||||
`${BASE_URL}/1/clouddrive/task?${params.toString()}`,
|
||||
{ headers: getHeaders(cookie), signal: AbortSignal.timeout(10000) },
|
||||
);
|
||||
const data = await resp.json() as any;
|
||||
if (data.data?.status === 2) {
|
||||
// Task completed — try multiple extraction approaches
|
||||
// 1. Direct share_url field
|
||||
if (data.data?.share_url) {
|
||||
const match = data.data.share_url.match(/\/s\/([a-zA-Z0-9]+)/);
|
||||
if (match) return { shareId: match[1] };
|
||||
}
|
||||
|
||||
// 2. Nested share object
|
||||
if (data.data?.share?.url) {
|
||||
const match = data.data.share.url.match(/\/s\/([a-zA-Z0-9]+)/);
|
||||
if (match) return { shareId: match[1] };
|
||||
}
|
||||
if (data.data?.share?.short_url) {
|
||||
const match = data.data.share.short_url.match(/\/s\/([a-zA-Z0-9]+)/);
|
||||
if (match) return { shareId: match[1] };
|
||||
}
|
||||
|
||||
// 3. share_id — validate it's a reasonable short code (8-20 chars, not UUID-like)
|
||||
const shareId = data.data?.share_id;
|
||||
if (shareId && shareId.length <= 20 && shareId.length >= 8) {
|
||||
return { shareId };
|
||||
}
|
||||
|
||||
// 4. Regex search through the full response for a URL pattern
|
||||
const str = JSON.stringify(data);
|
||||
const urlMatch = str.match(/https?:\/\/pan\.quark\.cn\/s\/([a-zA-Z0-9]{6,16})/);
|
||||
if (urlMatch) {
|
||||
return { shareId: urlMatch[1] };
|
||||
}
|
||||
|
||||
// 5. Extract from any URL field in the response
|
||||
const urlFields = ['url', 'link', 'share_url', 'short_url', 'share_link'];
|
||||
for (const field of urlFields) {
|
||||
const val = data.data?.[field] || data.data?.share?.[field];
|
||||
if (typeof val === 'string' && val.includes('pan.quark.cn/s/')) {
|
||||
const m = val.match(/\/s\/([a-zA-Z0-9]+)/);
|
||||
if (m) return { shareId: m[1] };
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Log full share task response for debugging
|
||||
console.log('[Quark] Full share task response:', JSON.stringify(data, null, 2).slice(0, 2000));
|
||||
|
||||
// 7. Even if shareId is UUID-like (32 hex chars), use it anyway as last resort
|
||||
if (shareId) {
|
||||
return { shareId };
|
||||
}
|
||||
|
||||
return { message: 'Share task completed but no share URL found' };
|
||||
}
|
||||
if (data.data?.status === 3) {
|
||||
return { message: data.message || 'Share task failed' };
|
||||
}
|
||||
retryIndex++;
|
||||
} catch {
|
||||
// Retry
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
return { message: 'Share task timed out' };
|
||||
}
|
||||
308
packages/backend/src/cloud/drivers/quark-storage.ts
Normal file
308
packages/backend/src/cloud/drivers/quark-storage.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import { getHeaders, getCommonParams, makeQuery, getMparam, humanDelay, dailyFolderName, formatBytes, apiFetch, listDir, listDirAllPages, listRootDir, QuarkFile } from './quark-api';
|
||||
import { acquireStoken, getShareFiles, saveFiles, waitForTask } from './quark-share';
|
||||
|
||||
/**
|
||||
* 转存 & 存储管理模块。
|
||||
* 处理分享链接解析 → 转存 → 查/创建目标文件夹 → 文件重命名 → 递归统计。
|
||||
*/
|
||||
|
||||
// ==================== saveFromShare — 核心转存流水线 ====================
|
||||
|
||||
/**
|
||||
* Save files from a share link → magic rename → create shared link.
|
||||
*
|
||||
* Flow: token → detail → save → wait_task → rename → share
|
||||
*/
|
||||
export async function saveFromShare(
|
||||
cookie: string,
|
||||
nickname: string | undefined,
|
||||
shareUrl: string,
|
||||
sourceTitle?: string,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
shareUrl?: string;
|
||||
sharePwd?: string;
|
||||
folderName?: string;
|
||||
taskId?: string;
|
||||
renamed?: string[];
|
||||
fileCount?: number;
|
||||
folderCount?: number;
|
||||
originalFolderName?: string;
|
||||
}> {
|
||||
try {
|
||||
// Parse share token from URL
|
||||
const urlObj = new URL(shareUrl);
|
||||
const pwdId = urlObj.pathname.split('/').filter(Boolean).pop();
|
||||
if (!pwdId) {
|
||||
return { success: false, message: 'Invalid share URL: could not extract share token' };
|
||||
}
|
||||
|
||||
// Step 1: Acquire stoken
|
||||
const stoken = await acquireStoken(cookie, pwdId);
|
||||
if (!stoken) {
|
||||
return { success: false, message: '😅 Oops!资源好像偷偷溜走了,换个链接试试吧~' };
|
||||
}
|
||||
|
||||
// Step 2: Get share detail
|
||||
const shareInfo = await getShareFiles(cookie, pwdId, stoken);
|
||||
if (!shareInfo || !shareInfo.files || shareInfo.files.length === 0) {
|
||||
return { success: false, message: '🌚 空的!这个分享里啥都没有…' };
|
||||
}
|
||||
|
||||
const { files: topFiles, topDir, childFiles } = shareInfo;
|
||||
const originalFolderName = topFiles[0]?.file_name || '';
|
||||
const fids = topFiles.map(f => f.fid);
|
||||
const fidTokens = topFiles.map(f => f.share_fid_token);
|
||||
|
||||
// 按日期创建/查找文件夹,每天的转存存入当天文件夹
|
||||
await humanDelay();
|
||||
const saveDirName = dailyFolderName();
|
||||
console.log(`[Quark] saveFromShare: looking for/create dir "${saveDirName}"`);
|
||||
const saveDirFid = await findOrCreateDir(cookie, saveDirName);
|
||||
const targetPdirFid = saveDirFid || '0';
|
||||
if (saveDirFid) {
|
||||
console.log(`[Quark] Using save directory: ${saveDirName} (fid: ${saveDirFid})`);
|
||||
} else {
|
||||
console.log(`[Quark] WARNING: failed to create/find dir "${saveDirName}", saving to root`);
|
||||
}
|
||||
|
||||
// Step 3: Save top-level item(s) to the target directory
|
||||
const saveResult = await saveFiles(cookie, pwdId, stoken, fids, fidTokens.filter(Boolean) as string[], targetPdirFid);
|
||||
if (!saveResult.success) {
|
||||
return saveResult;
|
||||
}
|
||||
|
||||
const taskId = saveResult.taskId!;
|
||||
|
||||
// Step 4: Wait for save task to complete (poll up to 30s)
|
||||
const savedFids = await waitForTask(cookie, taskId, 30000);
|
||||
if (!savedFids || savedFids.length === 0) {
|
||||
return { success: true, message: '文件已保存,但获取保存结果超时' };
|
||||
}
|
||||
|
||||
// Step 5: Magic rename files — with random delay to avoid detection
|
||||
await humanDelay();
|
||||
const renamed: Array<{ original: string; renamed: string }> = [];
|
||||
let shareFid = '';
|
||||
let savedFolderName = '';
|
||||
let newInnerDirName = '';
|
||||
|
||||
if (topDir && childFiles && childFiles.length > 0) {
|
||||
// ── Single folder share ──
|
||||
const savedDirFid = savedFids[0];
|
||||
shareFid = savedDirFid;
|
||||
savedFolderName = topFiles[0]?.file_name || '';
|
||||
} else {
|
||||
// ── Multiple files at top level ──
|
||||
shareFid = savedFids[0];
|
||||
savedFolderName = topFiles[0]?.file_name || '';
|
||||
}
|
||||
|
||||
// Step 6: Create share link FIRST (before rename), so all files are guaranteed to be shared
|
||||
await humanDelay();
|
||||
let shareUrlResult = '';
|
||||
let sharePwdResult = '';
|
||||
let shareMsg = '';
|
||||
let successCount = 0; // total items (files + folders) actually saved
|
||||
|
||||
const { createShareLink } = await import('./quark-share');
|
||||
if (shareFid) {
|
||||
const shareResult = await createShareLink(cookie, shareFid);
|
||||
if (shareResult.success && shareResult.shareUrl) {
|
||||
shareUrlResult = shareResult.shareUrl;
|
||||
if (shareResult.sharePwd) sharePwdResult = shareResult.sharePwd;
|
||||
} else {
|
||||
shareMsg = `(分享失败:${shareResult.message})`;
|
||||
}
|
||||
}
|
||||
|
||||
const { magicRenameDir, magicRename } = await import('./quark-rename');
|
||||
const { renameFile } = await import('./quark-share');
|
||||
|
||||
// Step 7: Rename files AFTER creating the share link (anti-harmony, won't affect the share)
|
||||
if (topDir && childFiles && childFiles.length > 0) {
|
||||
// ── Single folder share ──
|
||||
const savedDirFid = savedFids[0];
|
||||
|
||||
// List files inside the saved directory
|
||||
const dirFiles = await listDir(cookie, savedDirFid);
|
||||
if (dirFiles && dirFiles.length > 0) {
|
||||
for (const file of dirFiles) {
|
||||
if (file.dir) continue;
|
||||
const newName = magicRename(file.file_name);
|
||||
const renameOk = await renameFile(cookie, file.fid, newName);
|
||||
if (renameOk) {
|
||||
renamed.push({ original: file.file_name, renamed: newName });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also rename the inner folder itself (the actual shared folder)
|
||||
const innerDirOriginalName = sourceTitle || topFiles[0]?.file_name || '';
|
||||
if (innerDirOriginalName) {
|
||||
newInnerDirName = magicRenameDir(innerDirOriginalName);
|
||||
const innerDirRenameOk = await renameFile(cookie, savedDirFid, newInnerDirName);
|
||||
if (innerDirRenameOk) {
|
||||
console.log(`[Quark] Renamed inner folder: ${innerDirOriginalName} → ${newInnerDirName}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// ── Multiple files at top level ──
|
||||
for (let i = 0; i < savedFids.length && i < topFiles.length; i++) {
|
||||
const originalName = topFiles[i].file_name;
|
||||
if (topFiles[i].dir) continue;
|
||||
const newName = magicRename(originalName);
|
||||
const renameOk = await renameFile(cookie, savedFids[i], newName);
|
||||
if (renameOk) {
|
||||
renamed.push({ original: originalName, renamed: newName });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 7.5: 广告关键词清理 + 创建警示文件夹
|
||||
if (shareFid) {
|
||||
try {
|
||||
const { runAdCleanup } = await import('./quark-ad-cleanup');
|
||||
const adResult = await runAdCleanup(cookie, shareFid);
|
||||
if (adResult.adDeleted > 0) {
|
||||
console.log(`[Quark] 广告清理完成: 删除了 ${adResult.adDeleted} 个广告文件/文件夹`);
|
||||
}
|
||||
if (adResult.warningDirs > 0) {
|
||||
console.log(`[Quark] 已创建 ${adResult.warningDirs} 个警示文件夹`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.log(`[Quark] 广告清理/警示文件夹创建失败(非致命): ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 8: DAY FOLDER STAYS AS-IS (e.g. "2026-05-03")
|
||||
// DO NOT rename the date folder — it serves as the organizational container.
|
||||
savedFolderName = newInnerDirName ? `${saveDirName}/${newInnerDirName}` : saveDirName;
|
||||
|
||||
// Recursively count files and folders from saved cloud directory
|
||||
let fileCount = 0;
|
||||
let folderCount = 0;
|
||||
if (shareFid) {
|
||||
try {
|
||||
const counts = await countRecursive(cookie, shareFid);
|
||||
fileCount = counts.fileCount;
|
||||
folderCount = counts.folderCount;
|
||||
} catch {
|
||||
console.log('[Quark] Recursive count failed, using fallback');
|
||||
}
|
||||
}
|
||||
// If recursive count returned nothing, try fallback
|
||||
if (fileCount === 0 && folderCount === 0) {
|
||||
if (topDir && childFiles) {
|
||||
folderCount = 1 + childFiles.filter(f => f.dir).length;
|
||||
fileCount = childFiles.filter(f => !f.dir).length;
|
||||
} else {
|
||||
folderCount = topFiles.filter(f => f.dir).length;
|
||||
fileCount = topFiles.filter(f => !f.dir).length;
|
||||
}
|
||||
}
|
||||
|
||||
const renameMsg = renamed.length > 0
|
||||
? `,已重命名 ${renamed.length} 个文件`
|
||||
: '';
|
||||
const folderMsg = savedFolderName ? `到文件夹「${savedFolderName}」` : '';
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `已保存${folderMsg}${renameMsg}${shareMsg}`,
|
||||
shareUrl: shareUrlResult || undefined,
|
||||
sharePwd: sharePwdResult || undefined,
|
||||
folderName: savedFolderName,
|
||||
taskId,
|
||||
renamed: renamed.map(r => `${r.original} → ${r.renamed}`),
|
||||
fileCount,
|
||||
folderCount,
|
||||
originalFolderName,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message || 'Network error' };
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Dir Management ====================
|
||||
|
||||
/**
|
||||
* Create a new directory at root.
|
||||
*/
|
||||
export async function createDir(cookie: string, dirName: string): Promise<string | null> {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`https://drive-pc.quark.cn/1/clouddrive/file?${makeQuery()}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { ...getHeaders(cookie), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
pdir_fid: '0',
|
||||
file_name: dirName,
|
||||
dir: true,
|
||||
dir_path: '',
|
||||
}),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
},
|
||||
);
|
||||
const data = await resp.json() as any;
|
||||
if (data.status === 200 && data.data?.fid) {
|
||||
console.log(`[Quark] Created dir "${dirName}" (fid: ${data.data.fid})`);
|
||||
return data.data.fid;
|
||||
}
|
||||
console.log(`[Quark] createDir API returned non-200: status=${data.status} msg=${data.message}`);
|
||||
return null;
|
||||
} catch (err: any) {
|
||||
console.log(`[Quark] createDir error: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an existing directory by name, or create it if not found.
|
||||
*/
|
||||
export async function findOrCreateDir(cookie: string, dirName: string): Promise<string | null> {
|
||||
try {
|
||||
const rootFiles = await listDirAllPages(cookie, '0');
|
||||
const existing = rootFiles.find(f => f.dir && f.file_name === dirName);
|
||||
if (existing?.fid) {
|
||||
console.log(`[Quark] Found existing daily folder: ${dirName} (fid: ${existing.fid})`);
|
||||
return existing.fid;
|
||||
}
|
||||
console.log(`[Quark] Daily folder "${dirName}" not found, creating...`);
|
||||
} catch (err: any) {
|
||||
console.log(`[Quark] findOrCreateDir list error: ${err.message}`);
|
||||
}
|
||||
const fid = await createDir(cookie, dirName);
|
||||
console.log(`[Quark] createDir result for "${dirName}": ${fid || 'null'}`);
|
||||
return fid;
|
||||
}
|
||||
|
||||
// ==================== Recursive Count ====================
|
||||
|
||||
/**
|
||||
* Recursively count files and folders for a saved cloud directory.
|
||||
*/
|
||||
export async function countRecursive(cookie: string, pdirFid: string): Promise<{ fileCount: number; folderCount: number }> {
|
||||
let fileCount = 0;
|
||||
let folderCount = 0;
|
||||
const stack = [pdirFid];
|
||||
const visited = new Set<string>();
|
||||
while (stack.length > 0) {
|
||||
const fid = stack.pop()!;
|
||||
if (visited.has(fid)) continue;
|
||||
visited.add(fid);
|
||||
const files = await listDir(cookie, fid);
|
||||
if (!files) continue;
|
||||
for (const f of files) {
|
||||
if (f.dir) {
|
||||
folderCount++;
|
||||
stack.push(f.fid);
|
||||
} else {
|
||||
fileCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { fileCount, folderCount };
|
||||
}
|
||||
122
packages/backend/src/cloud/drivers/quark.driver.ts
Executable file
122
packages/backend/src/cloud/drivers/quark.driver.ts
Executable file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* QuarkDriver — 夸克网盘统一驱动
|
||||
*
|
||||
* 为保持向后兼容性,此类将所有方法委托到子模块。
|
||||
* 新代码应直接导入子模块函数。
|
||||
*
|
||||
* 模块结构:
|
||||
* quark-api.ts — HTTP 封装、headers、params、共享工具函数
|
||||
* quark-auth.ts — Cookie 验证
|
||||
* quark-storage.ts — 转存流水线、目录管理、递归统计
|
||||
* quark-share.ts — 分享链接解析、转存任务、创建分享链接
|
||||
* quark-rename.ts — 防和谐重命名(文件名/目录名)
|
||||
* quark-cleanup.ts — 容量信息、空间清理
|
||||
* quark-driver.ts — 统一导出类(兼容旧代码)
|
||||
*/
|
||||
|
||||
import { QuarkConfig } from './quark-api';
|
||||
import { validate } from './quark-auth';
|
||||
import { saveFromShare, createDir, findOrCreateDir, countRecursive } from './quark-storage';
|
||||
import { createShareLink, renameFile } from './quark-share';
|
||||
import {
|
||||
getStorageInfoQuick, getStorageInfo,
|
||||
calculateUsedSpace, trashFiles, emptyTrash,
|
||||
cleanupOldDateFolders, cleanupBySpaceThreshold,
|
||||
} from './quark-cleanup';
|
||||
|
||||
export type { QuarkConfig, QuarkFile } from './quark-api';
|
||||
export * from './quark-api';
|
||||
export * from './quark-auth';
|
||||
export * from './quark-storage';
|
||||
export * from './quark-share';
|
||||
export * from './quark-rename';
|
||||
export * from './quark-cleanup';
|
||||
|
||||
export { validate } from './quark-auth';
|
||||
|
||||
/**
|
||||
* QuarkDriver — 向后兼容的驱动类。
|
||||
* 所有方法委托到纯函数模块,不持有状态。
|
||||
*/
|
||||
export class QuarkDriver {
|
||||
private config: QuarkConfig;
|
||||
|
||||
constructor(config: QuarkConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
get cookie(): string {
|
||||
return this.config.cookie;
|
||||
}
|
||||
|
||||
// ==================== Auth ====================
|
||||
|
||||
async validate(): Promise<boolean> {
|
||||
return validate(this.config.cookie);
|
||||
}
|
||||
|
||||
// ==================== Storage (Save from Share) ====================
|
||||
|
||||
async saveFromShare(shareUrl: string, sourceTitle?: string) {
|
||||
return saveFromShare(this.config.cookie, this.config.nickname, shareUrl, sourceTitle);
|
||||
}
|
||||
|
||||
async createDir(dirName: string): Promise<string | null> {
|
||||
return createDir(this.config.cookie, dirName);
|
||||
}
|
||||
|
||||
async findOrCreateDir(dirName: string): Promise<string | null> {
|
||||
return findOrCreateDir(this.config.cookie, dirName);
|
||||
}
|
||||
|
||||
async countRecursive(pdirFid: string) {
|
||||
return countRecursive(this.config.cookie, pdirFid);
|
||||
}
|
||||
|
||||
// ==================== Share ====================
|
||||
|
||||
async createShareLink(fileId: string) {
|
||||
return createShareLink(this.config.cookie, fileId);
|
||||
}
|
||||
|
||||
async renameFile(fid: string, newName: string): Promise<boolean> {
|
||||
return renameFile(this.config.cookie, fid, newName);
|
||||
}
|
||||
|
||||
// ==================== Storage Info ====================
|
||||
|
||||
async getStorageInfoQuick() {
|
||||
return getStorageInfoQuick(this.config.cookie);
|
||||
}
|
||||
|
||||
async getStorageInfo() {
|
||||
return getStorageInfo(this.config.cookie);
|
||||
}
|
||||
|
||||
async calculateUsedSpace(): Promise<number> {
|
||||
return calculateUsedSpace(this.config.cookie);
|
||||
}
|
||||
|
||||
// ==================== Cleanup ====================
|
||||
|
||||
async listRootDir() {
|
||||
const { listRootDir } = await import('./quark-api');
|
||||
return listRootDir(this.config.cookie);
|
||||
}
|
||||
|
||||
async trashFiles(fids: string[]): Promise<boolean> {
|
||||
return trashFiles(this.config.cookie, fids);
|
||||
}
|
||||
|
||||
async emptyTrash(): Promise<boolean> {
|
||||
return emptyTrash(this.config.cookie);
|
||||
}
|
||||
|
||||
async cleanupOldDateFolders(days: number) {
|
||||
return cleanupOldDateFolders(this.config.cookie, days);
|
||||
}
|
||||
|
||||
async cleanupBySpaceThreshold(thresholdPercent: number, deletePercent: number) {
|
||||
return cleanupBySpaceThreshold(this.config.cookie, thresholdPercent, deletePercent);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user