chore: initial commit - CloudSearch v0.0.2

This commit is contained in:
2026-05-15 05:50:50 +08:00
commit d83225d736
102 changed files with 37926 additions and 0 deletions

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

View 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}` };
}
}
}

File diff suppressed because it is too large Load Diff

View 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 };
}

View 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;
}

View 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;
}
}

View 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] };
}
}

View 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;
}

View 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' };
}

View 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 };
}

View 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);
}
}