v0.2.7: 修复Redis连接 + 启动管理后台
- 修复Redis认证 (配置密码) - 启动Python管理后台 (端口9531, 15个功能开关) - 统一版本号 0.2.7 - 更新docker-compose.yml (镜像版本/Redis URL/Admin服务)
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
# Stage 1: Build
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY tsconfig.json ./
|
||||
COPY src/ ./src/
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Runner
|
||||
FROM node:20-alpine
|
||||
RUN apk add --no-cache \
|
||||
chromium \
|
||||
nss \
|
||||
freetype \
|
||||
harfbuzz \
|
||||
ca-certificates \
|
||||
ttf-freefont \
|
||||
dumb-init
|
||||
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY frontend/ ./dist/frontend/
|
||||
EXPOSE 9527
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
CMD ["node", "dist/main.js"]
|
||||
@@ -0,0 +1,79 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>CloudSearch 管理后台</title>
|
||||
<style>
|
||||
:root{--bg:#f5f6fa;--card:#fff;--text:#2c3e50;--sub:#7f8c8d;--pri:#3498db;--pri-hover:#2980b9;--danger:#e74c3c;--success:#27ae60;--warn:#f39c12;--border:#e8ecf1;--sidebar-bg:#1a1a2e;--sidebar-text:#b0b8c8;--sidebar-active:#fff;--radius:8px}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
#app{display:flex;flex:1}body{font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--text);display:flex;min-height:100vh}
|
||||
.sidebar{width:220px;background:var(--sidebar-bg);color:var(--sidebar-text);flex-shrink:0;display:flex;flex-direction:column}
|
||||
.sidebar-title{padding:20px;font-size:17px;font-weight:700;color:#fff;border-bottom:1px solid rgba(255,255,255,.08)}
|
||||
.nav-item{display:flex;align-items:center;gap:8px;padding:9px 16px 9px 20px;cursor:pointer;color:var(--sidebar-text);font-size:13px;transition:.15s;border-left:3px solid transparent}
|
||||
.nav-item:hover{color:#fff;background:rgba(255,255,255,.05)}
|
||||
.nav-item.active{color:var(--sidebar-active);background:rgba(52,152,219,.15);border-left-color:var(--pri)}
|
||||
.main{flex:1;display:flex;flex-direction:column;overflow:hidden}
|
||||
.topbar{padding:16px 24px;font-size:16px;font-weight:600;background:var(--card);border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center}
|
||||
#content{flex:1;overflow-y:auto;padding:24px}
|
||||
.card{background:var(--card);border-radius:var(--radius);padding:20px;margin-bottom:16px;box-shadow:0 1px 3px rgba(0,0,0,.04)}
|
||||
.card-title{font-size:14px;font-weight:600;margin-bottom:12px;display:flex;align-items:center;gap:8px}
|
||||
.stats-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:12px;margin-bottom:20px}
|
||||
.stat-card{background:var(--card);border-radius:var(--radius);padding:8px 12px;box-shadow:0 1px 3px rgba(0,0,0,.04);display:flex;align-items:center;justify-content:space-between}
|
||||
.form-group{margin-bottom:14px;display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap}
|
||||
.form-group label{font-size:13px;color:var(--text);font-weight:500;min-width:130px;padding-top:6px}
|
||||
.form-control{padding:7px 10px;border:1px solid var(--border);border-radius:6px;font-size:13px;outline:none;transition:.15s;max-width:360px;width:100%}
|
||||
.form-control:focus{border-color:var(--pri)}
|
||||
.form-control.wide{max-width:500px}
|
||||
textarea.form-control{min-height:70px;resize:vertical}
|
||||
.btn{padding:7px 16px;border:none;border-radius:6px;font-size:13px;cursor:pointer;font-weight:500;transition:.15s;display:inline-flex;align-items:center;gap:4px}
|
||||
.btn-pri{background:var(--pri);color:#fff}.btn-pri:hover{background:var(--pri-hover)}
|
||||
.btn-danger{background:var(--danger);color:#fff}.btn-danger:hover{background:#c0392b}
|
||||
.btn-outline{background:transparent;border:1px solid var(--border);color:var(--text)}.btn-outline:hover{background:var(--bg)}
|
||||
.btn-sm{padding:4px 10px;font-size:12px}
|
||||
.toggle{position:relative;display:inline-block;width:42px;height:22px}
|
||||
.toggle input{opacity:0;width:0;height:0}
|
||||
.toggle-slider{position:absolute;cursor:pointer;top:0;left:0;right:0;bottom:0;background:#ccc;border-radius:22px;transition:.2s}
|
||||
.toggle-slider:before{position:absolute;content:"";height:16px;width:16px;left:3px;bottom:3px;background:#fff;border-radius:50%;transition:.2s}
|
||||
.toggle input:checked+.toggle-slider{background:var(--pri)}
|
||||
.toggle input:checked+.toggle-slider:before{transform:translateX(20px)}
|
||||
.toast{position:fixed;top:16px;right:16px;padding:12px 20px;border-radius:8px;color:#fff;font-size:13px;z-index:999;animation:slideIn .3s ease}
|
||||
.toast-success{background:var(--success)}.toast-error{background:var(--danger)}
|
||||
@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}
|
||||
.loading{text-align:center;padding:40px;color:var(--sub)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-title">CloudSearch</div>
|
||||
<div class="nav-item active" data-page="dashboard" onclick="nav('dashboard')">📊 仪表盘</div>
|
||||
<div class="nav-item" data-page="sys-site" onclick="nav('sys-site')">⚙️ 网站设置</div>
|
||||
<div class="nav-item" data-page="sys-services" onclick="nav('sys-services')">🔗 外部服务</div>
|
||||
<div class="nav-item" data-page="sys-manage" onclick="nav('sys-manage')">🖥 服务管理</div>
|
||||
<div class="nav-item" data-page="sys-strategy" onclick="nav('sys-strategy')">🚀 性能配置</div>
|
||||
<div class="nav-item" data-page="sys-password" onclick="nav('sys-password')">🔑 修改密码</div>
|
||||
<div class="nav-item" data-page="cloud-config" onclick="nav('cloud-config')">☁️ 网盘设置</div>
|
||||
<div class="nav-item" data-page="cleanup" onclick="nav('cleanup')">🗑 存储清理</div>
|
||||
<div style="margin-top:auto;padding:12px"><button class="btn btn-outline btn-sm" onclick="logout()" style="width:100%;color:var(--sidebar-text)">退出登录</button></div>
|
||||
</div>
|
||||
<div class="main">
|
||||
<div class="topbar"><span id="pageTitle">仪表盘</span><span id="appVersion" style="font-size:12px;color:var(--sub)">v-</span></div>
|
||||
<div id="content"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/admin/js/admin-core.js"></script>
|
||||
<script src="/admin/js/admin-helpers.js"></script>
|
||||
<script src="/admin/js/admin-login.js"></script>
|
||||
<script src="/admin/js/admin-dashboard.js"></script>
|
||||
<script src="/admin/js/admin-site.js"></script>
|
||||
<script src="/admin/js/admin-services.js"></script>
|
||||
<script src="/admin/js/admin-password.js"></script>
|
||||
<script src="/admin/js/cloud/cloud-core.js"></script>
|
||||
<script src="/admin/js/cloud/cloud-render.js"></script>
|
||||
<script src="/admin/js/cloud/cloud-dialog.js"></script>
|
||||
<script src="/admin/js/cloud/cloud-actions.js"></script>
|
||||
<script src="/admin/js/admin-cleanup.js"></script>
|
||||
<script src="/admin/js/admin-boot.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,15 @@
|
||||
|
||||
// Fetch app version
|
||||
fetch('/api/version').then(r => r.json()).then(d => {
|
||||
var el = document.getElementById('appVersion');
|
||||
if (el && d.version) el.textContent = 'v' + d.version;
|
||||
}).catch(function(){});
|
||||
|
||||
(function(){
|
||||
if(!TOKEN){
|
||||
document.getElementById('app').innerHTML = '<div class="login-wrap"><div id="content"></div></div>';
|
||||
page_login(document.getElementById('content'));
|
||||
} else {
|
||||
nav('dashboard');
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,155 @@
|
||||
function page_cleanup(c){
|
||||
c.innerHTML = '<div id="cleanupRoot"><p style="text-align:center;padding:40px">Loading...</p></div>';
|
||||
loadCleanupPage();
|
||||
}
|
||||
|
||||
function loadCleanupPage(){
|
||||
Promise.all([
|
||||
api('/api/admin/cloud-configs').catch(function(){ return []; }),
|
||||
api('/api/admin/verify/status').catch(function(){ return {}; }),
|
||||
api('/api/admin/system-configs').catch(function(){ return {}; })
|
||||
]).then(function(res){
|
||||
var accounts = res[0] || [];
|
||||
var vStatus = res[1] || {};
|
||||
var configs = res[2] || {};
|
||||
renderCleanupPage(accounts, vStatus, configs);
|
||||
});
|
||||
}
|
||||
|
||||
function renderCleanupPage(accounts, vStatus, configs){
|
||||
var lastVerify = vStatus.last_run ? new Date(vStatus.last_run).toLocaleString() : 'never';
|
||||
var lastCleanup = configs.cleanup_last_run || 'never';
|
||||
var h = '';
|
||||
|
||||
h += '<div class="card"><div class="card-title">Storage Cleanup & Management</div>';
|
||||
h += '<p style="color:var(--sub);font-size:12px">Cloud verification, space refresh, scheduled cleanup</p></div>';
|
||||
|
||||
// Quick Actions
|
||||
h += '<div class="card"><div class="card-title">Quick Actions</div>';
|
||||
h += '<div style="display:flex;flex-wrap:wrap;gap:10px;margin-bottom:12px">';
|
||||
h += '<button class="btn btn-pri" onclick="runVerifyAll()">Verify All</button>';
|
||||
h += '<button class="btn btn-pri" onclick="runStorageRefresh()">Refresh All Space</button>';
|
||||
h += '<button class="btn btn-warn" onclick="runFullCleanup()">Full Cleanup</button>';
|
||||
h += '<button class="btn btn-outline" onclick="runEmptyTrash()">Empty Trash</button>';
|
||||
h += '</div>';
|
||||
h += '<div id="actionStatus" style="font-size:12px;color:var(--sub);margin-top:8px"></div>';
|
||||
h += '<div style="font-size:12px;color:var(--sub);margin-top:4px">Last verify: ' + lastVerify + ' | Last cleanup: ' + lastCleanup + '</div></div>';
|
||||
|
||||
// Account Table
|
||||
h += '<div class="card"><div class="card-title">Account Verification</div>';
|
||||
h += '<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:13px">';
|
||||
h += '<thead><tr style="background:var(--bg);border-bottom:2px solid var(--border);text-align:left">';
|
||||
h += '<th style="padding:8px 10px">Type</th><th style="padding:8px 10px">Nickname</th>';
|
||||
h += '<th style="padding:8px 10px">Status</th><th style="padding:8px 10px">Space</th>';
|
||||
h += '<th style="padding:8px 10px">Action</th></tr></thead><tbody>';
|
||||
|
||||
accounts.forEach(function(cfg){
|
||||
var v = cfg.verification_status;
|
||||
var vIcon = v==='valid'?'OK':(v==='invalid'?'FAIL':'PENDING');
|
||||
var space = cfg.storage_used ? (cfg.storage_used + ' / ' + (cfg.storage_total||'?')) : '-';
|
||||
h += '<tr style="border-bottom:1px solid var(--border)">';
|
||||
h += '<td style="padding:8px 10px">' + (cfg.cloud_type||'?') + '</td>';
|
||||
h += '<td style="padding:8px 10px">' + (cfg.nickname||'-') + '</td>';
|
||||
h += '<td style="padding:8px 10px">' + vIcon + '</td>';
|
||||
h += '<td style="padding:8px 10px;font-size:12px;color:var(--sub)">' + space + '</td>';
|
||||
h += '<td style="padding:8px 10px"><button class="btn btn-sm btn-outline" onclick="runSingleVerify('+cfg.id+',this)">Verify</button></td></tr>';
|
||||
});
|
||||
|
||||
if(accounts.length===0){
|
||||
h += '<tr><td colspan="5" style="padding:20px;text-align:center;color:var(--sub)">No accounts found</td></tr>';
|
||||
}
|
||||
h += '</tbody></table></div></div>';
|
||||
|
||||
// Cleanup Config
|
||||
h += '<div class="card"><div class="card-title">Cleanup Config</div>';
|
||||
h += '<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;font-size:13px">';
|
||||
h += cfgRow('Cleanup Enabled', 'cleanup_enabled', configs, 'checkbox');
|
||||
h += cfgRow('File Retention (days)', 'cleanup_file_retention_days', configs, 'number', '7');
|
||||
h += cfgRow('Log Retention (days)', 'cleanup_log_retention_days', configs, 'number', '30');
|
||||
h += cfgRow('Empty Trash After', 'cleanup_empty_trash', configs, 'checkbox');
|
||||
h += cfgRow('Space Threshold Cleanup', 'cleanup_space_threshold_enabled', configs, 'checkbox');
|
||||
h += cfgRow('Threshold %', 'cleanup_space_threshold_percent', configs, 'number', '90');
|
||||
h += cfgRow('Delete %', 'cleanup_space_threshold_delete_percent', configs, 'number', '10');
|
||||
h += '</div>';
|
||||
h += '<button class="btn btn-pri" style="margin-top:12px" onclick="saveCleanupConfig()">Save Config</button></div>';
|
||||
|
||||
document.getElementById('cleanupRoot').innerHTML = h;
|
||||
}
|
||||
|
||||
function cfgRow(label, key, configs, type, placeholder){
|
||||
var val = configs[key] || '';
|
||||
if(type==='checkbox'){
|
||||
var chk = val==='true'?'checked':'';
|
||||
return '<div><label style="display:flex;align-items:center;gap:8px;cursor:pointer"><input type="checkbox" class="cleanupCfg" data-key="'+key+'" '+chk+'><span>'+label+'</span></label></div>';
|
||||
}
|
||||
return '<div><label style="font-size:12px;color:var(--sub)">'+label+'</label><input class="form-control cleanupCfg" data-key="'+key+'" value="'+val+'" placeholder="'+placeholder+'" style="max-width:120px"></div>';
|
||||
}
|
||||
|
||||
function runVerifyAll(){
|
||||
var el = document.getElementById('actionStatus');
|
||||
el.innerHTML = 'Verifying all accounts...';
|
||||
api('/api/admin/verify/run', {method:'POST'}).then(function(r){
|
||||
el.innerHTML = 'Done: '+(r.ok||0)+'/'+r.total+' OK, '+r.failed+' failed';
|
||||
setTimeout(loadCleanupPage, 2000);
|
||||
}).catch(function(e){
|
||||
el.innerHTML = 'Verify failed: '+(e.message||'network error');
|
||||
});
|
||||
}
|
||||
|
||||
function runSingleVerify(id, btn){
|
||||
btn.disabled = true; btn.textContent = '...';
|
||||
api('/api/admin/verify/single/'+id, {method:'POST'}).then(function(r){
|
||||
btn.textContent = r.success ? 'OK' : 'FAIL';
|
||||
}).catch(function(){ btn.textContent = 'ERR'; })
|
||||
.finally(function(){ setTimeout(loadCleanupPage, 1500); });
|
||||
}
|
||||
|
||||
function runStorageRefresh(){
|
||||
var el = document.getElementById('actionStatus');
|
||||
el.innerHTML = 'Refreshing space info...';
|
||||
api('/api/admin/storage/refresh', {method:'POST'}).then(function(){
|
||||
el.innerHTML = 'Space refreshed';
|
||||
setTimeout(loadCleanupPage, 2000);
|
||||
}).catch(function(e){
|
||||
el.innerHTML = 'Refresh failed: '+(e.message||'network error');
|
||||
});
|
||||
}
|
||||
|
||||
function runFullCleanup(){
|
||||
var el = document.getElementById('actionStatus');
|
||||
el.innerHTML = 'Running full cleanup...';
|
||||
api('/api/admin/cleanup/run', {method:'POST'}).then(function(r){
|
||||
el.innerHTML = (r.message||'Cleanup done');
|
||||
setTimeout(loadCleanupPage, 2000);
|
||||
}).catch(function(e){
|
||||
el.innerHTML = 'Cleanup failed: '+(e.message||'network error');
|
||||
});
|
||||
}
|
||||
|
||||
function runEmptyTrash(){
|
||||
var el = document.getElementById('actionStatus');
|
||||
if(!confirm('Empty all trash? This cannot be undone.')) return;
|
||||
el.innerHTML = 'Emptying trash...';
|
||||
api('/api/admin/cleanup/empty-trash', {method:'POST'}).then(function(r){
|
||||
el.innerHTML = r.emptied ? 'Trash emptied' : (r.message||'Done');
|
||||
}).catch(function(e){
|
||||
el.innerHTML = 'Failed: '+(e.message||'network error');
|
||||
});
|
||||
}
|
||||
|
||||
function saveCleanupConfig(){
|
||||
var entries = [];
|
||||
document.querySelectorAll('.cleanupCfg').forEach(function(el){
|
||||
var key = el.dataset.key;
|
||||
var val = el.type==='checkbox' ? (el.checked?'true':'false') : el.value.trim();
|
||||
entries.push({key:key, value:val});
|
||||
});
|
||||
api('/api/admin/system-configs', {
|
||||
method:'PUT', headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({entries:entries})
|
||||
}).then(function(){
|
||||
toast('Config saved');
|
||||
}).catch(function(e){
|
||||
toast('Save failed: '+(e.message||''));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
var TOKEN = localStorage.getItem('admin_token') || '';
|
||||
var TITLES = {dashboard:'仪表盘','sys-site':'网站设置','sys-services':'外部服务','sys-manage':'服务管理','sys-strategy':'性能配置','sys-password':'修改密码','cloud-config':'网盘设置',cleanup:'存储清理'};
|
||||
|
||||
function api(path, opts){
|
||||
opts = opts || {};
|
||||
opts.headers = opts.headers || {};
|
||||
if(TOKEN) opts.headers['Authorization'] = 'Bearer ' + TOKEN;
|
||||
return fetch(path, opts).then(function(r){
|
||||
if(r.status === 401){ localStorage.removeItem('admin_token'); location.reload(); throw new Error('unauth'); }
|
||||
return r.json();
|
||||
});
|
||||
}
|
||||
|
||||
function toast(msg, type){
|
||||
type = type || 'success';
|
||||
var t = document.createElement('div'); t.className = 'toast toast-'+type; t.textContent = msg;
|
||||
document.body.appendChild(t);
|
||||
setTimeout(function(){ t.remove(); }, 2500);
|
||||
}
|
||||
|
||||
document.querySelectorAll('.nav-item').forEach(function(el){
|
||||
el.onclick = function(){ nav(this.dataset.page); };
|
||||
});
|
||||
|
||||
function nav(page){
|
||||
document.querySelectorAll('.nav-item').forEach(function(e){ e.classList.remove('active'); });
|
||||
var target = document.querySelector('[data-page="'+page+'"]');
|
||||
if(target) target.classList.add('active');
|
||||
document.getElementById('pageTitle').textContent = TITLES[page] || page;
|
||||
var c = document.getElementById('content');
|
||||
c.innerHTML = '<div class="loading">加载中...</div>';
|
||||
try{ window['page_'+page.replace(/-/g,'_')](c); }
|
||||
catch(e){ c.innerHTML = '<div class="card"><p>加载出错: '+e.message+'</p></div>'; }
|
||||
}
|
||||
|
||||
function logout(){
|
||||
localStorage.removeItem('admin_token');
|
||||
location.reload();
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
function page_dashboard(c){
|
||||
api('/api/admin/services').then(function(d){
|
||||
var h = '<div class="card"><div class="card-title">系统概览</div>'+
|
||||
'<div class="stats-grid">'+
|
||||
'<div class="stat-card"><div class="stat-value">'+(d.length||0)+'</div><div class="stat-label">服务数</div></div>'+
|
||||
'</div></div>';
|
||||
c.innerHTML = h;
|
||||
}).catch(function(){ c.innerHTML = '<div class="card">加载失败</div>'; });
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// Helper utilities
|
||||
@@ -0,0 +1,14 @@
|
||||
function page_login(c){
|
||||
c.innerHTML = '<div style="max-width:400px;margin:60px auto"><div class="card"><div class="card-title">管理后台登录</div>'+
|
||||
'<div class="form-group"><label>密码</label><input id="pwd" class="form-control" type="password" onkeydown="if(event.key===\'Enter\')doLogin()"></div>'+
|
||||
'<button class="btn btn-pri" onclick="doLogin()">登录</button></div></div>';
|
||||
}
|
||||
|
||||
function doLogin(){
|
||||
var pwd = document.getElementById('pwd').value;
|
||||
api('/api/admin/login', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:pwd})})
|
||||
.then(function(d){
|
||||
if(d.token){ localStorage.setItem('admin_token', d.token); TOKEN = d.token; nav('dashboard'); }
|
||||
else{ toast(d.error||'密码错误','error'); }
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
function page_sys_password(c){
|
||||
c.innerHTML = '<div class="card"><div class="card-title">修改密码</div>'+
|
||||
'<div class="form-group"><label>当前密码</label><input id="oldPwd" class="form-control" type="password"></div>'+
|
||||
'<div class="form-group"><label>新密码</label><input id="newPwd" class="form-control" type="password"></div>'+
|
||||
'<button class="btn btn-pri" onclick="changePwd()">修改</button></div>';
|
||||
}
|
||||
function changePwd(){
|
||||
api('/api/admin/change-password', {method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({old:document.getElementById('oldPwd').value,new:document.getElementById('newPwd').value})})
|
||||
.then(function(d){ toast(d.error||'密码修改成功', d.error?'error':'success'); });
|
||||
}
|
||||
function page_sys_manage(c){ page_sys_services(c); }
|
||||
function page_sys_strategy(c){ c.innerHTML = '<div class="card">性能配置页面</div>'; }
|
||||
@@ -0,0 +1,10 @@
|
||||
function page_sys_services(c){
|
||||
api('/api/admin/services').then(function(d){
|
||||
var h = '<div class="card"><div class="card-title">外部服务</div>';
|
||||
(d||[]).forEach(function(s){
|
||||
h += '<div class="service-card"><div class="service-info"><span class="name">'+s.name+'</span><span class="desc">'+s.url+'</span></div>'+
|
||||
'<div class="service-actions"><span class="status-dot '+(s.enabled?'status-running':'status-stopped')+'"></span>'+(s.enabled?'运行中':'已停止')+'</div></div>';
|
||||
});
|
||||
c.innerHTML = h+'</div>';
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
function page_sys_site(c){
|
||||
api('/api/admin/system-configs').then(function(d){
|
||||
var h = '<div class="card"><div class="card-title">网站设置</div>';
|
||||
h += '<div class="form-group"><label>网站名称</label><input id="site_name" class="form-control" value="'+(d.site_name||'')+'"></div>';
|
||||
h += '<button class="btn btn-pri" onclick="saveSite()">保存</button></div>';
|
||||
c.innerHTML = h;
|
||||
});
|
||||
}
|
||||
function saveSite(){
|
||||
var name = document.getElementById('site_name').value;
|
||||
api('/api/admin/system-configs', {method:'PUT',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({entries:[{key:'site_name',value:name}]})})
|
||||
.then(function(d){ toast(d.error||'保存成功', d.error?'error':'success'); });
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
function togCloudType(type, cb){
|
||||
__cloudToggles[type] = cb.checked;
|
||||
api('/api/admin/system-configs', {
|
||||
method:'PUT', headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({entries:[{key:'cloud_type_'+type+'_enabled', value:cb.checked?'true':'false'}]})
|
||||
});
|
||||
}
|
||||
|
||||
function togCloudAcc(id, cb){
|
||||
api('/api/admin/cloud-configs/'+id, {method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({is_active:cb.checked})})
|
||||
.then(function(d){ if(d.error){ cb.checked=!cb.checked; toast(d.error,'error'); } });
|
||||
}
|
||||
function delCloudAcc(id){ var name = '账号#'+id;
|
||||
if(!confirm('确定删除 '+name+' 吗?')) return;
|
||||
api('/api/admin/cloud-configs/'+id, {method:'DELETE'}).then(function(d){
|
||||
toast(d.error?d.error:'已删除 '+name, d.error?'error':'success');
|
||||
nav('cloud-config');
|
||||
});
|
||||
}
|
||||
function doSaveAccount(){
|
||||
var body = {
|
||||
cloud_type: document.getElementById('dlg_type').value,
|
||||
// default off, will be set by manual toggle
|
||||
};
|
||||
var cookie = document.getElementById('dlg_cookie').value.trim();
|
||||
if(!cookie){ toast('请先输入或扫码获取 Cookie','error'); return; }
|
||||
body.cookie = cookie;
|
||||
body.promotion_account = document.getElementById('dlg_promo').value.trim() || '';
|
||||
body.nickname = document.getElementById('dlg_nick').value.trim() || '';
|
||||
body.storage_used = __qrStorageUsed || '';
|
||||
body.storage_total = __qrStorageTotal || '';
|
||||
toast('⏳ 正在保存...');
|
||||
api('/api/admin/cloud-configs', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)
|
||||
}).then(function(d){
|
||||
if(d && d.error){ toast(d.error,'error'); return; }
|
||||
var savedId = d.id;
|
||||
toast('⏳ 正在验证 Cookie 并查询空间...');
|
||||
return api('/api/admin/cloud-configs/'+body.cloud_type+'/test', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({id: savedId})
|
||||
});
|
||||
}).then(function(r){
|
||||
if(r && r.success){
|
||||
toast('✅ 验证通过!空间: '+(r.storage_used||'?')+'/'+(r.storage_total||'?'));
|
||||
} else {
|
||||
toast('⚠️ 已保存但验证失败: '+(r?r.message:'未知错误'),'error');
|
||||
}
|
||||
closeDialog();
|
||||
nav('cloud-config');
|
||||
}).catch(function(e){
|
||||
toast('保存失败: '+(e.message||'网络错误'),'error');
|
||||
console.error('doSaveAccount error:', e);
|
||||
});
|
||||
}
|
||||
|
||||
function doSaveAccount_OLD(){
|
||||
var body = {
|
||||
cloud_type: document.getElementById('dlg_type').value,
|
||||
// default off, will be set by verification
|
||||
};
|
||||
var cookie = document.getElementById('dlg_cookie').value.trim();
|
||||
if(!cookie){ toast('请先输入或扫码获取 Cookie','error'); return; }
|
||||
body.cookie = cookie;
|
||||
console.log("SAVE promo raw:", document.getElementById("dlg_promo").value); body.promotion_account = document.getElementById('dlg_promo').value.trim() || '';
|
||||
body.nickname = document.getElementById('dlg_nick').value.trim() || '';
|
||||
body.storage_used = __qrStorageUsed || '';
|
||||
body.storage_total = __qrStorageTotal || '';
|
||||
api('/api/admin/cloud-configs', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)
|
||||
}).then(function(d){
|
||||
if(d && d.error){ toast(d.error,'error'); return; }
|
||||
toast('✅ 网盘配置更新成功');
|
||||
closeDialog();
|
||||
nav('cloud-config');
|
||||
}).catch(function(e){
|
||||
toast('保存失败: '+(e.message||'网络错误'),'error');
|
||||
console.error('doSaveAccount error:', e);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
function togCloudType(type, cb){
|
||||
__cloudToggles[type] = cb.checked;
|
||||
api('/api/admin/system-configs', {
|
||||
method:'PUT', headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({entries:[{key:'cloud_type_'+type+'_enabled', value:cb.checked?'true':'false'}]})
|
||||
});
|
||||
}
|
||||
|
||||
function togCloudAcc(id, cb){
|
||||
api('/api/admin/cloud-configs/'+id, {method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({is_active:cb.checked})})
|
||||
.then(function(d){ if(d.error){ cb.checked=!cb.checked; toast(d.error,'error'); } });
|
||||
}
|
||||
function delCloudAcc(id){ var name = '账号#'+id;
|
||||
if(!confirm('确定删除 '+name+' 吗?')) return;
|
||||
api('/api/admin/cloud-configs/'+id, {method:'DELETE'}).then(function(d){
|
||||
toast(d.error?d.error:'已删除 '+name, d.error?'error':'success');
|
||||
nav('cloud-config');
|
||||
});
|
||||
}
|
||||
function doSaveAccount(){
|
||||
var body = {
|
||||
cloud_type: document.getElementById('dlg_type').value,
|
||||
is_active: true
|
||||
};
|
||||
var cookie = document.getElementById('dlg_cookie').value.trim();
|
||||
if(!cookie){ toast('请先输入或扫码获取 Cookie','error'); return; }
|
||||
body.cookie = cookie;
|
||||
console.log("SAVE promo raw:", document.getElementById("dlg_promo").value); body.promotion_account = document.getElementById('dlg_promo').value.trim() || '';
|
||||
body.nickname = document.getElementById('dlg_nick').value.trim() || '';
|
||||
body.storage_used = __qrStorageUsed || '';
|
||||
body.storage_total = __qrStorageTotal || '';
|
||||
api('/api/admin/cloud-configs', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)
|
||||
}).then(function(d){
|
||||
if(d && d.error){ toast(d.error,'error'); return; }
|
||||
toast('✅ 网盘配置更新成功');
|
||||
closeDialog();
|
||||
nav('cloud-config');
|
||||
}).catch(function(e){
|
||||
toast('保存失败: '+(e.message||'网络错误'),'error');
|
||||
console.error('doSaveAccount error:', e);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
function togCloudType(type, cb){
|
||||
__cloudToggles[type] = cb.checked;
|
||||
api('/api/admin/system-configs', {
|
||||
method:'PUT', headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({entries:[{key:'cloud_type_'+type+'_enabled', value:cb.checked?'true':'false'}]})
|
||||
});
|
||||
}
|
||||
|
||||
function togCloudAcc(id, cb){
|
||||
api('/api/admin/cloud-configs/'+id, {method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({is_active:cb.checked})})
|
||||
.then(function(d){ if(d.error){ cb.checked=!cb.checked; toast(d.error,'error'); } });
|
||||
}
|
||||
function delCloudAcc(id){ var name = '账号#'+id;
|
||||
if(!confirm('确定删除 '+name+' 吗?')) return;
|
||||
api('/api/admin/cloud-configs/'+id, {method:'DELETE'}).then(function(d){
|
||||
toast(d.error?d.error:'已删除 '+name, d.error?'error':'success');
|
||||
nav('cloud-config');
|
||||
});
|
||||
}
|
||||
function doSaveAccount(){
|
||||
var body = {
|
||||
cloud_type: document.getElementById('dlg_type').value,
|
||||
// default off, will be set by verification
|
||||
};
|
||||
var cookie = document.getElementById('dlg_cookie').value.trim();
|
||||
if(!cookie){ toast('请先输入或扫码获取 Cookie','error'); return; }
|
||||
body.cookie = cookie;
|
||||
console.log("SAVE promo raw:", document.getElementById("dlg_promo").value); body.promotion_account = document.getElementById('dlg_promo').value.trim() || '';
|
||||
body.nickname = document.getElementById('dlg_nick').value.trim() || '';
|
||||
body.storage_used = __qrStorageUsed || '';
|
||||
body.storage_total = __qrStorageTotal || '';
|
||||
api('/api/admin/cloud-configs', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)
|
||||
}).then(function(d){
|
||||
if(d && d.error){ toast(d.error,'error'); return; }
|
||||
toast('✅ 网盘配置更新成功');
|
||||
closeDialog();
|
||||
nav('cloud-config');
|
||||
}).catch(function(e){
|
||||
toast('保存失败: '+(e.message||'网络错误'),'error');
|
||||
console.error('doSaveAccount error:', e);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
|
||||
// --- Cloud Config ---
|
||||
var CLOUD_TYPES = [
|
||||
{type:'quark',label:'夸克网盘',icon:'/admin/icons/quark.png'},
|
||||
{type:'baidu',label:'百度网盘',icon:'/admin/icons/baidu.png'},
|
||||
{type:'aliyun',label:'阿里云盘',icon:'/admin/icons/aliyun.png'},
|
||||
{type:'115',label:'115网盘',icon:'/admin/icons/115.png'},
|
||||
{type:'tianyi',label:'天翼云盘',icon:'/admin/icons/tianyi.png'},
|
||||
{type:'123pan',label:'123云盘',icon:'/admin/icons/123pan.png'},
|
||||
{type:'uc',label:'UC网盘',icon:'/admin/icons/uc.png'},
|
||||
{type:'xunlei',label:'迅雷网盘',icon:'/admin/icons/xunlei.png'},
|
||||
{type:'pikpak',label:'PikPak',icon:'/admin/icons/pikpak.png'},
|
||||
{type:'magnet',label:'磁力链接',icon:'🧲'},
|
||||
{type:'ed2k',label:'电驴链接',icon:'🔗'},
|
||||
{type:'others',label:'其他',icon:'⬜'}
|
||||
];
|
||||
var QR_TYPES = ['quark','baidu'];
|
||||
var __cloudToggles = {};
|
||||
var __qrType = '', __qrSession = '', __qrCookie = '', __qrTimer = null;
|
||||
|
||||
function page_cloud_config(c){
|
||||
c.innerHTML = '<div class="loading">加载中...</div>';
|
||||
Promise.all([
|
||||
api('/api/admin/system-configs').catch(function(e){ console.error(e); return {}; }),
|
||||
api('/api/admin/cloud-configs').catch(function(e){ console.error(e); return []; })
|
||||
]).then(function(res){
|
||||
var d = res[0]||{}, accounts = res[1]||[];
|
||||
__cloudToggles = {};
|
||||
CLOUD_TYPES.forEach(function(ct){
|
||||
var v = d['cloud_type_'+ct.type+'_enabled'];
|
||||
__cloudToggles[ct.type] = v===undefined ? (ct.type!=='others') : (v==='true'||v==='1');
|
||||
});
|
||||
renderCloudPage(c, accounts);
|
||||
}).catch(function(e){
|
||||
c.innerHTML = '<div class="card"><p style="color:var(--danger)">加载失败: '+e.message+'</p></div>';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
var __qrSession = '';
|
||||
var __qrCookie = '';
|
||||
var __qrType = '';
|
||||
var __qrTimer = null;
|
||||
var __qrNickname = '';
|
||||
var __qrStorageUsed = '';
|
||||
var __qrStorageTotal = '';
|
||||
var QR_TYPES = ['quark','baidu'];
|
||||
|
||||
function openAddDialog(){
|
||||
document.getElementById('dlg_type').value = 'quark';
|
||||
document.getElementById('dlg_cookie').value = '';
|
||||
document.getElementById('dlg_promo').value = '';
|
||||
document.getElementById('dlg_nick').value = '';
|
||||
document.getElementById('dlg_storage_info').textContent = '扫码后自动获取';
|
||||
resetQR();
|
||||
onDlgTypeChange();
|
||||
document.getElementById('modalBg').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeDialog(){
|
||||
cancelQRPoll();
|
||||
document.getElementById('modalBg').style.display = 'none';
|
||||
}
|
||||
|
||||
function onDlgTypeChange(){
|
||||
var t = document.getElementById('dlg_type').value;
|
||||
document.getElementById('dlg_qr_section').style.display = (QR_TYPES.indexOf(t)!==-1) ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function resetQR(){
|
||||
cancelQRPoll();
|
||||
__qrSession = ''; __qrCookie = ''; __qrNickname = ''; __qrStorageUsed = ''; __qrStorageTotal = '';
|
||||
document.getElementById('dlg_qr_status').textContent = '点击下方按钮生成扫码链接';
|
||||
document.getElementById('dlg_qr_btn').disabled = false;
|
||||
document.getElementById('dlg_qr_btn').textContent = '生成扫码链接';
|
||||
document.getElementById('dlg_qr_btn').style.display = '';
|
||||
document.getElementById('dlg_qr_hint').textContent = '';
|
||||
}
|
||||
|
||||
function doStartQR(){
|
||||
__qrType = document.getElementById('dlg_type').value;
|
||||
document.getElementById('dlg_qr_btn').disabled = true;
|
||||
document.getElementById('dlg_qr_btn').textContent = '生成中...';
|
||||
document.getElementById('dlg_qr_status').textContent = '正在生成二维码...';
|
||||
api('/api/admin/'+__qrType+'/qr-login/start', {method:'POST'}).then(function(d){
|
||||
if(d.error){
|
||||
document.getElementById('dlg_qr_status').innerHTML = '<span style="color:var(--danger)">❌ '+d.error+'</span>';
|
||||
document.getElementById('dlg_qr_btn').disabled=false;
|
||||
document.getElementById('dlg_qr_btn').textContent='重试';
|
||||
return;
|
||||
}
|
||||
__qrSession = d.sessionId;
|
||||
var url = d.qrUrl||d.url||'';
|
||||
if(url){
|
||||
var qrSrc = 'https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=' + encodeURIComponent(url);
|
||||
document.getElementById('dlg_qr_status').innerHTML = '<img src="'+qrSrc+'" style="max-width:200px;max-height:200px;border:1px solid var(--border);border-radius:4px;display:block;margin:0 auto"><p style="font-size:11px;color:var(--sub);margin-top:6px;text-align:center">用对应 App 扫码并在手机上确认登录</p>';
|
||||
}
|
||||
document.getElementById('dlg_qr_hint').textContent = '等待扫码确认...';
|
||||
document.getElementById('dlg_qr_btn').textContent = '重新生成';
|
||||
document.getElementById('dlg_qr_btn').disabled = false;
|
||||
pollQR();
|
||||
});
|
||||
}
|
||||
|
||||
function pollQR(){
|
||||
if(!__qrSession) return;
|
||||
__qrTimer = setTimeout(function(){
|
||||
api('/api/admin/'+__qrType+'/qr-login/'+__qrSession+'/status').then(function(d){
|
||||
if(d.status==='logged_in'){
|
||||
__qrCookie = d.cookie||'';
|
||||
__qrNickname = d.nickname||'';
|
||||
__qrStorageUsed = d.storage_used||'';
|
||||
__qrStorageTotal = d.storage_total||'';
|
||||
if(__qrNickname){ var n = document.getElementById('dlg_nick'); if(!n.value.trim()) n.value = __qrNickname; }
|
||||
if(d.promotion_account){ var p = document.getElementById('dlg_promo'); if(!p.value.trim()) p.value = d.promotion_account; }
|
||||
var si = document.getElementById('dlg_storage_info');
|
||||
if(__qrStorageTotal){
|
||||
si.textContent = '💾 ' + (__qrStorageUsed||'?') + ' / ' + __qrStorageTotal;
|
||||
si.style.color = 'var(--success)';
|
||||
} else { si.textContent = '空间信息未获取'; si.style.color = 'var(--sub)'; }
|
||||
if(d.autoUpdated){
|
||||
document.getElementById('dlg_qr_status').innerHTML = '<span style="color:var(--success);font-size:14px">✅ 扫码成功!已自动更新现有账号 #'+d.updatedConfigId+'</span>';
|
||||
cancelQRPoll();
|
||||
toast('✅ 已自动更新账号','success');
|
||||
setTimeout(function(){ closeDialog(); nav('cloud-config'); }, 1000);
|
||||
return;
|
||||
}
|
||||
document.getElementById('dlg_qr_hint').textContent = '✅ 登录成功!';
|
||||
document.getElementById('dlg_qr_status').innerHTML = '<span style="color:var(--success);font-size:14px">✅ 扫码成功!已自动填入</span>';
|
||||
document.getElementById('dlg_cookie').value = __qrCookie;
|
||||
document.getElementById('dlg_qr_btn').style.display = 'none';
|
||||
cancelQRPoll();
|
||||
}else if(d.status==='expired'){
|
||||
cancelQRPoll();
|
||||
setTimeout(function(){ doStartQR(); }, 500);
|
||||
}else{
|
||||
pollQR();
|
||||
}
|
||||
}).catch(function(){ pollQR(); });
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function cancelQRPoll(){
|
||||
if(__qrTimer){ clearTimeout(__qrTimer); __qrTimer = null; }
|
||||
if(__qrSession){ api('/api/admin/'+__qrType+'/qr-login/'+__qrSession+'/cancel', {method:'POST'}).catch(function(){}); __qrSession=''; }
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
function renderCloudPage(c, accounts){
|
||||
accounts = accounts || [];
|
||||
var h = '';
|
||||
|
||||
// Render icon: URL → <img>, emoji/text → plain text
|
||||
function iconHtml(icon, size){
|
||||
if(!icon) return '';
|
||||
if(icon.indexOf('/')===0 || icon.indexOf('http')===0){
|
||||
return '<img src="'+icon+'" style="width:'+size+'px;height:'+size+'px;vertical-align:middle;margin-right:4px" onerror="this.remove()">';
|
||||
}
|
||||
return '<span style="font-size:'+(size-2)+'px;vertical-align:middle;margin-right:4px">'+icon+'</span>';
|
||||
}
|
||||
|
||||
// Toggle grid
|
||||
h += '<div class="card"><div class="card-title">⚡ 搜索网盘类型控制</div>'+
|
||||
'<p style="color:var(--sub);font-size:12px;margin-bottom:14px">控制搜索引擎检索哪些网盘类型的资源</p>'+
|
||||
'<div class="stats-grid" style="grid-template-columns:repeat(auto-fill,minmax(170px,1fr))">';
|
||||
CLOUD_TYPES.forEach(function(ct){
|
||||
var on = __cloudToggles[ct.type];
|
||||
h += '<div class="stat-card" style="padding:8px 12px;display:flex;align-items:center;justify-content:space-between">'+
|
||||
'<span>'+iconHtml(ct.icon,20)+' <span style="font-size:12px">'+ct.label+'</span></span>'+
|
||||
'<label class="toggle"><input type="checkbox" '+(on?'checked':'')+' onchange="togCloudType(\''+ct.type+'\',this)"><span class="toggle-slider"></span></label>'+
|
||||
'</div>';
|
||||
});
|
||||
h += '</div></div>';
|
||||
|
||||
// Account list — table style
|
||||
if(accounts.length > 0){
|
||||
h += '<div class="card"><div class="card-title">📋 已有账号 ('+accounts.length+') <button class="btn btn-pri btn-sm" onclick="openAddDialog()" style="margin-left:12px">➕ 新增</button></div>';
|
||||
h += '<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:13px">';
|
||||
h += '<thead><tr style="background:var(--bg);border-bottom:2px solid var(--border);text-align:left">'+
|
||||
'<th style="padding:10px 12px;white-space:nowrap">📱推广平台</th>'+
|
||||
'<th style="padding:10px 12px;white-space:nowrap">推广平台账号</th>'+
|
||||
'<th style="padding:10px 12px;white-space:nowrap">网盘昵称</th>'+'<th style="padding:10px 12px;white-space:nowrap">网盘UID</th>'+
|
||||
'<th style="padding:10px 12px;white-space:nowrap">验证</th>'+
|
||||
'<th style="padding:10px 12px;white-space:nowrap">空间</th>'+
|
||||
'<th style="padding:10px 12px;white-space:nowrap">转存</th>'+
|
||||
'<th style="padding:10px 12px;white-space:nowrap">操作</th>'+
|
||||
'</tr></thead><tbody>';
|
||||
accounts.forEach(function(cfg){
|
||||
var label = CLOUD_TYPES.find(function(ct){ return ct.type===cfg.cloud_type; });
|
||||
var icon = (label||{}).icon||'⬜';
|
||||
var active = cfg.is_active===1||cfg.is_active===true;
|
||||
var ck = cfg.verification_status==='valid'?'✅':(cfg.verification_status==='invalid'?'❌':'⏳');
|
||||
h += '<tr style="border-bottom:1px solid var(--border)">'+
|
||||
'<td style="padding:10px 12px;white-space:nowrap">'+iconHtml(icon,18)+(label||{}).label+'</td>'+
|
||||
'<td style="padding:10px 12px;white-space:nowrap">'+(cfg.promotion_account||'—')+'</td>'+
|
||||
'<td style="padding:10px 12px;white-space:nowrap">'+(cfg.nickname||cfg.cloud_type)+'</td>'+
|
||||
'<td style="padding:10px 12px;white-space:nowrap;font-size:12px;color:var(--sub)">'+(cfg.cookie_uid||cfg.cloud_type)+'</td>'+
|
||||
'<td style="padding:10px 12px">'+ck+'</td>'+
|
||||
'<td style="padding:10px 12px;white-space:nowrap;font-size:12px;color:var(--sub)">'+(cfg.storage_used||cfg.storage_total?'💾 '+(cfg.storage_used||'?')+'/'+(cfg.storage_total||'?'):'—')+'</td>'+
|
||||
'<td style="padding:10px 12px;white-space:nowrap;font-size:12px;color:var(--sub)">'+(cfg.total_saves?'转存'+cfg.total_saves+'次':'—')+'</td>'+
|
||||
'<td style="padding:10px 12px;white-space:nowrap">'+
|
||||
'<label class="toggle"><input type="checkbox" '+(active?'checked':'')+' onchange="togCloudAcc('+cfg.id+',this)"><span class="toggle-slider"></span></label>'+
|
||||
'<button class="btn btn-danger btn-sm" onclick="delCloudAcc('+cfg.id+')" style="margin-left:8px">🗑</button>'+
|
||||
'</td>'+
|
||||
'</tr>';
|
||||
});
|
||||
h += '</tbody></table></div></div>';
|
||||
}
|
||||
// No accounts yet — show add button
|
||||
if(accounts.length === 0){
|
||||
h += '<div class="card"><div class="card-title">📋 已有账号 (0)</div><p style="color:var(--sub);font-size:13px;margin-bottom:12px">还没有添加任何网盘账号</p><button class="btn btn-pri" onclick="openAddDialog()">➕ 新增网盘</button></div>';
|
||||
}
|
||||
|
||||
// Modal dialog
|
||||
h += '<div id="modalBg" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.4);z-index:100;align-items:center;justify-content:center" onclick="if(event.target===this)closeDialog()">'+
|
||||
'<div class="card" style="width:500px;max-width:95vw;max-height:90vh;overflow-y:auto" onclick="event.stopPropagation()">'+
|
||||
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">'+
|
||||
'<span style="font-weight:600;font-size:15px">新增网盘账号</span>'+
|
||||
'<button class="btn btn-outline btn-sm" onclick="closeDialog()">✕</button></div>'+
|
||||
'<div class="form-group"><label>网盘类型</label><select id="dlg_type" class="form-control" style="max-width:200px" onchange="onDlgTypeChange()">';
|
||||
CLOUD_TYPES.forEach(function(ct){ h+='<option value="'+ct.type+'">'+ct.label+'</option>'; });
|
||||
h+='</select></div>'+
|
||||
'<div class="form-group"><label>推广平台账号</label><input id="dlg_promo" class="form-control" placeholder="平台注册所用手机号" style="max-width:250px"></div>'+
|
||||
'<div class="form-group"><label>Cookie</label><textarea id="dlg_cookie" class="form-control wide" rows="5" placeholder="粘贴 Cookie 或通过下方扫码获取"></textarea></div>'+
|
||||
'<div class="form-group"><label>网盘昵称</label><input id="dlg_nick" class="form-control" placeholder="扫码后自动获取" style="max-width:250px;background:#f5f6fa" readonly></div>'+
|
||||
'<div class="form-group"><label>空间信息</label><span id="dlg_storage_info" style="font-size:13px;color:var(--sub);padding-top:6px">扫码后自动获取</span></div>'+
|
||||
'<div id="dlg_qr_section" style="margin-top:12px;padding:12px;background:#f9fafb;border-radius:8px">'+
|
||||
'<p style="font-size:12px;font-weight:600;margin-bottom:8px">📱 扫码获取 Cookie(仅夸克/百度)</p>'+
|
||||
'<div id="dlg_qr_status" style="font-size:12px;color:var(--sub);margin-bottom:8px">点击下方按钮生成扫码链接</div>'+
|
||||
''+
|
||||
'<div style="margin-top:8px;display:flex;gap:8px;align-items:center">'+
|
||||
'<button class="btn btn-pri btn-sm" id="dlg_qr_btn" onclick="doStartQR()">生成扫码链接</button>'+
|
||||
'<span id="dlg_qr_hint" style="font-size:11px;color:var(--sub)"></span></div></div>'+
|
||||
'<div style="margin-top:16px;display:flex;gap:8px">'+
|
||||
'<button class="btn btn-pri" onclick="doSaveAccount()">💾 保存账号</button>'+
|
||||
'<button class="btn btn-outline" onclick="closeDialog()">取消</button></div></div></div>';
|
||||
|
||||
c.innerHTML = h;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
function renderCloudPage(c, accounts){
|
||||
accounts = accounts || [];
|
||||
var h = '';
|
||||
|
||||
// Render icon: URL → <img>, emoji/text → plain text
|
||||
function iconHtml(icon, size){
|
||||
if(!icon) return '';
|
||||
if(icon.indexOf('/')===0 || icon.indexOf('http')===0){
|
||||
return '<img src="'+icon+'" style="width:'+size+'px;height:'+size+'px;vertical-align:middle;margin-right:4px" onerror="this.remove()">';
|
||||
}
|
||||
return '<span style="font-size:'+(size-2)+'px;vertical-align:middle;margin-right:4px">'+icon+'</span>';
|
||||
}
|
||||
|
||||
// Toggle grid
|
||||
h += '<div class="card"><div class="card-title">⚡ 搜索网盘类型控制</div>'+
|
||||
'<p style="color:var(--sub);font-size:12px;margin-bottom:14px">控制搜索引擎检索哪些网盘类型的资源</p>'+
|
||||
'<div class="stats-grid" style="grid-template-columns:repeat(auto-fill,minmax(170px,1fr))">';
|
||||
CLOUD_TYPES.forEach(function(ct){
|
||||
var on = __cloudToggles[ct.type];
|
||||
h += '<div class="stat-card" style="padding:8px 12px;display:flex;align-items:center;justify-content:space-between">'+
|
||||
'<span>'+iconHtml(ct.icon,20)+' <span style="font-size:12px">'+ct.label+'</span></span>'+
|
||||
'<label class="toggle"><input type="checkbox" '+(on?'checked':'')+' onchange="togCloudType(\''+ct.type+'\',this)"><span class="toggle-slider"></span></label>'+
|
||||
'</div>';
|
||||
});
|
||||
h += '</div></div>';
|
||||
|
||||
// Account list — table style
|
||||
if(accounts.length > 0){
|
||||
h += '<div class="card"><div class="card-title">📋 已有账号 ('+accounts.length+') <button class="btn btn-pri btn-sm" onclick="openAddDialog()" style="margin-left:12px">➕ 新增</button></div>';
|
||||
h += '<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:13px">';
|
||||
h += '<thead><tr style="background:var(--bg);border-bottom:2px solid var(--border);text-align:left">'+
|
||||
'<th style="padding:10px 12px;white-space:nowrap">📱推广平台</th>'+
|
||||
'<th style="padding:10px 12px;white-space:nowrap">推广平台账号</th>'+
|
||||
'<th style="padding:10px 12px;white-space:nowrap">网盘昵称</th>'+'<th style="padding:10px 12px;white-space:nowrap">网盘UID</th>'+
|
||||
'<th style="padding:10px 12px;white-space:nowrap">验证</th>'+
|
||||
'<th style="padding:10px 12px;white-space:nowrap">空间</th>'+
|
||||
'<th style="padding:10px 12px;white-space:nowrap">转存</th>'+
|
||||
'<th style="padding:10px 12px;white-space:nowrap">操作</th>'+
|
||||
'</tr></thead><tbody>';
|
||||
accounts.forEach(function(cfg){
|
||||
var label = CLOUD_TYPES.find(function(ct){ return ct.type===cfg.cloud_type; });
|
||||
var icon = (label||{}).icon||'⬜';
|
||||
var active = cfg.is_active===1||cfg.is_active===true;
|
||||
var ck = cfg.verification_status==='valid'?'✅':(cfg.verification_status==='invalid'?'❌':'—');
|
||||
h += '<tr style="border-bottom:1px solid var(--border)">'+
|
||||
'<td style="padding:10px 12px;white-space:nowrap">'+iconHtml(icon,18)+(label||{}).label+'</td>'+
|
||||
'<td style="padding:10px 12px;white-space:nowrap">'+(cfg.promotion_account||'—')+'</td>'+
|
||||
'<td style="padding:10px 12px;white-space:nowrap">'+(cfg.nickname||cfg.cloud_type)+'</td>'+
|
||||
'<td style="padding:10px 12px;white-space:nowrap;font-size:12px;color:var(--sub)">'+(cfg.cookie_uid||cfg.cloud_type)+'</td>'+
|
||||
'<td style="padding:10px 12px">'+ck+'</td>'+
|
||||
'<td style="padding:10px 12px;white-space:nowrap;font-size:12px;color:var(--sub)">'+(cfg.storage_used||cfg.storage_total?'💾 '+(cfg.storage_used||'?')+'/'+(cfg.storage_total||'?'):'—')+'</td>'+
|
||||
'<td style="padding:10px 12px;white-space:nowrap;font-size:12px;color:var(--sub)">'+(cfg.total_saves?'转存'+cfg.total_saves+'次':'—')+'</td>'+
|
||||
'<td style="padding:10px 12px;white-space:nowrap">'+
|
||||
'<label class="toggle"><input type="checkbox" '+(active?'checked':'')+' onchange="togCloudAcc('+cfg.id+',this)"><span class="toggle-slider"></span></label>'+
|
||||
'<button class="btn btn-danger btn-sm" onclick="delCloudAcc('+cfg.id+')" style="margin-left:8px">🗑</button>'+
|
||||
'</td>'+
|
||||
'</tr>';
|
||||
});
|
||||
h += '</tbody></table></div></div>';
|
||||
}
|
||||
// No accounts yet — show add button
|
||||
if(accounts.length === 0){
|
||||
h += '<div class="card"><div class="card-title">📋 已有账号 (0)</div><p style="color:var(--sub);font-size:13px;margin-bottom:12px">还没有添加任何网盘账号</p><button class="btn btn-pri" onclick="openAddDialog()">➕ 新增网盘</button></div>';
|
||||
}
|
||||
|
||||
// Modal dialog
|
||||
h += '<div id="modalBg" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.4);z-index:100;align-items:center;justify-content:center" onclick="if(event.target===this)closeDialog()">'+
|
||||
'<div class="card" style="width:500px;max-width:95vw;max-height:90vh;overflow-y:auto" onclick="event.stopPropagation()">'+
|
||||
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">'+
|
||||
'<span style="font-weight:600;font-size:15px">新增网盘账号</span>'+
|
||||
'<button class="btn btn-outline btn-sm" onclick="closeDialog()">✕</button></div>'+
|
||||
'<div class="form-group"><label>网盘类型</label><select id="dlg_type" class="form-control" style="max-width:200px" onchange="onDlgTypeChange()">';
|
||||
CLOUD_TYPES.forEach(function(ct){ h+='<option value="'+ct.type+'">'+ct.label+'</option>'; });
|
||||
h+='</select></div>'+
|
||||
'<div class="form-group"><label>推广平台账号</label><input id="dlg_promo" class="form-control" placeholder="平台注册所用手机号" style="max-width:250px"></div>'+
|
||||
'<div class="form-group"><label>Cookie</label><textarea id="dlg_cookie" class="form-control wide" rows="5" placeholder="粘贴 Cookie 或通过下方扫码获取"></textarea></div>'+
|
||||
'<div class="form-group"><label>网盘昵称</label><input id="dlg_nick" class="form-control" placeholder="扫码后自动获取" style="max-width:250px;background:#f5f6fa" readonly></div>'+
|
||||
'<div class="form-group"><label>空间信息</label><span id="dlg_storage_info" style="font-size:13px;color:var(--sub);padding-top:6px">扫码后自动获取</span></div>'+
|
||||
'<div id="dlg_qr_section" style="margin-top:12px;padding:12px;background:#f9fafb;border-radius:8px">'+
|
||||
'<p style="font-size:12px;font-weight:600;margin-bottom:8px">📱 扫码获取 Cookie(仅夸克/百度)</p>'+
|
||||
'<div id="dlg_qr_status" style="font-size:12px;color:var(--sub);margin-bottom:8px">点击下方按钮生成扫码链接</div>'+
|
||||
''+
|
||||
'<div style="margin-top:8px;display:flex;gap:8px;align-items:center">'+
|
||||
'<button class="btn btn-pri btn-sm" id="dlg_qr_btn" onclick="doStartQR()">生成扫码链接</button>'+
|
||||
'<span id="dlg_qr_hint" style="font-size:11px;color:var(--sub)"></span></div></div>'+
|
||||
'<div style="margin-top:16px;display:flex;gap:8px">'+
|
||||
'<button class="btn btn-pri" onclick="doSaveAccount()">💾 保存账号</button>'+
|
||||
'<button class="btn btn-outline" onclick="closeDialog()">取消</button></div></div></div>';
|
||||
|
||||
c.innerHTML = h;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.admin-menu .menu-header[data-v-529b614b]{padding:16px 20px 8px;text-align:center;border-bottom:1px solid var(--el-border-color-light)}.admin-menu .menu-header h2[data-v-529b614b]{margin:0;font-size:16px;color:var(--el-color-primary)}.admin-menu .menu-header p[data-v-529b614b]{margin:4px 0 0;font-size:12px;color:var(--el-text-color-secondary)}.version-footer[data-v-529b614b]{padding:8px;text-align:center;font-size:11px;color:var(--el-text-color-placeholder);border-top:1px solid var(--el-border-color-light);margin-top:auto}.admin-layout[data-v-529b614b]{display:flex;height:100vh}.admin-menu[data-v-529b614b]{width:220px;flex-shrink:0;display:flex;flex-direction:column}.admin-content[data-v-529b614b]{flex:1;display:flex;flex-direction:column;overflow:hidden}.content-header[data-v-529b614b]{display:flex;align-items:center;justify-content:space-between;padding:12px 24px;border-bottom:1px solid var(--el-border-color-light);background:var(--el-bg-color)}.content-header h2[data-v-529b614b]{margin:0;font-size:18px}.content-body[data-v-529b614b]{flex:1;overflow-y:auto;padding:20px 24px;background:var(--el-bg-color-page)}
|
||||
@@ -0,0 +1 @@
|
||||
import{d as B,o as N,a as T,c as V,f as s,w as t,b as o,t as c,h as v,v as y,j as d,k as r,C as I,l as u,D as M,G as j,H as q,I as A,u as D,z as H}from"./index-C5b4pIQL.js";import{a as L,_ as R}from"./_plugin-vue_export-helper-CzL5NdOX.js";const z={class:"admin-layout"},E={class:"menu-header"},G={class:"version-footer"},W={class:"admin-content"},F={class:"content-header"},J={class:"content-body"},K=B({__name:"AdminLayout",setup(O){const l=D(),f=H(),m=v(""),_=v(""),b={dashboard:"仪表盘","cloud-configs-toggle":"网盘设置及授权","cloud-configs-cleanup":"存储清理","sys-site":"网站设置","sys-services":"外部服务 & 缓存","sys-strategy":"性能配置","sys-password":"修改管理员密码","save-records":"转存日志"},p=y(()=>{const n=f.name;return n==="admin-cloud-configs"?"cloud-configs-toggle":n==="admin-cleanup"?"cloud-configs-cleanup":n==="admin-system"?f.query.section||"sys-site":n==="admin-save-records"?"save-records":"dashboard"}),h=y(()=>b[p.value]||"仪表盘");function x(n){n==="dashboard"?l.push("/admin/dashboard"):n==="cloud-configs-toggle"?l.push("/admin/cloud-configs"):n==="cloud-configs-cleanup"?l.push("/admin/cleanup"):n.startsWith("sys-")?l.push({path:"/admin/system",query:{section:n}}):n==="save-records"?l.push("/admin/save-records"):n==="logout"&&(localStorage.removeItem("admin_token"),l.push("/admin/login"))}function w(){l.push("/")}return N(async()=>{try{const n=await L();m.value=n.site_name||""}catch{}try{const e=await(await fetch("/health")).json();_.value=e.version}catch{}}),(n,e)=>{const i=d("el-icon"),a=d("el-menu-item"),g=d("el-sub-menu"),C=d("el-menu"),k=d("el-button"),S=d("router-view");return T(),V("div",z,[s(C,{"default-active":p.value,class:"admin-menu",onSelect:x},{default:t(()=>[o("div",E,[o("h2",null,c(m.value||"CloudSearch"),1),e[0]||(e[0]=o("p",null,"管理后台",-1))]),s(a,{index:"dashboard"},{default:t(()=>[s(i,null,{default:t(()=>[s(r(I))]),_:1}),e[1]||(e[1]=o("span",null,"仪表盘",-1))]),_:1}),s(g,{index:"cloud-configs"},{title:t(()=>[s(i,null,{default:t(()=>[s(r(M))]),_:1}),e[2]||(e[2]=o("span",null,"网盘配置",-1))]),default:t(()=>[s(a,{index:"cloud-configs-toggle"},{default:t(()=>[...e[3]||(e[3]=[u("网盘设置及授权",-1)])]),_:1}),s(a,{index:"cloud-configs-cleanup"},{default:t(()=>[...e[4]||(e[4]=[u("存储清理",-1)])]),_:1})]),_:1}),s(g,{index:"system"},{title:t(()=>[s(i,null,{default:t(()=>[s(r(j))]),_:1}),e[5]||(e[5]=o("span",null,"系统配置",-1))]),default:t(()=>[s(a,{index:"sys-site"},{default:t(()=>[...e[6]||(e[6]=[u("网站设置",-1)])]),_:1}),s(a,{index:"sys-services"},{default:t(()=>[...e[7]||(e[7]=[u("外部服务和缓存",-1)])]),_:1}),s(a,{index:"sys-strategy"},{default:t(()=>[...e[8]||(e[8]=[u("性能配置",-1)])]),_:1}),s(a,{index:"sys-password"},{default:t(()=>[...e[9]||(e[9]=[u("修改管理员密码",-1)])]),_:1})]),_:1}),s(a,{index:"save-records"},{default:t(()=>[s(i,null,{default:t(()=>[s(r(q))]),_:1}),e[10]||(e[10]=o("span",null,"转存日志",-1))]),_:1}),o("div",G,"T "+c(_.value),1),s(a,{index:"logout"},{default:t(()=>[s(i,null,{default:t(()=>[s(r(A))]),_:1}),e[11]||(e[11]=o("span",null,"退出登录",-1))]),_:1})]),_:1},8,["default-active"]),o("div",W,[o("div",F,[o("h2",null,c(h.value),1),s(k,{text:"",onClick:w},{default:t(()=>[...e[12]||(e[12]=[u("返回前台",-1)])]),_:1})]),o("div",J,[s(S)])])])}}}),U=R(K,[["__scopeId","data-v-529b614b"]]);export{U as default};
|
||||
@@ -0,0 +1 @@
|
||||
.admin-login-page[data-v-513ea931]{min-height:100vh;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#667eea,#764ba2)}.login-card[data-v-513ea931]{width:400px;padding:40px;background:var(--bg-white);border-radius:16px;box-shadow:0 8px 32px #00000026}.login-title[data-v-513ea931]{text-align:center;font-size:24px;font-weight:700;color:#303133;margin-bottom:32px}.login-btn[data-v-513ea931]{width:100%}.error-msg[data-v-513ea931]{text-align:center;color:#f56c6c;font-size:14px;margin-top:12px}
|
||||
@@ -0,0 +1 @@
|
||||
import{d as k,a as g,c as v,b as w,t as h,f as s,w as l,g as b,e as x,h as d,j as m,l as C,i as L,E as N}from"./index-C5b4pIQL.js";import{a as S,d as B,_ as E}from"./_plugin-vue_export-helper-CzL5NdOX.js";const U={class:"admin-login-page"},q={class:"login-card"},A={class:"login-title"},I={key:0,class:"error-msg"},K=k({__name:"AdminLogin",setup(M){const p=d(),u=d(!1),r=d(""),_=d("");S().then(i=>{i.site_name&&(_.value=i.site_name)}).catch(()=>{});const o=L({username:"",password:""}),y={username:[{required:!0,message:"请输入用户名",trigger:"blur"}],password:[{required:!0,message:"请输入密码",trigger:"blur"}]};async function f(){var a,n,t;if(await((a=p.value)==null?void 0:a.validate().catch(()=>!1))){u.value=!0,r.value="";try{const e=await B(o.username,o.password);localStorage.setItem("admin_token",e.token),N.success("登录成功"),window.location.href="/admin"}catch(e){r.value=((t=(n=e==null?void 0:e.response)==null?void 0:n.data)==null?void 0:t.message)||(e==null?void 0:e.message)||"登录失败"}finally{u.value=!1}}}return(i,a)=>{const n=m("el-input"),t=m("el-form-item"),e=m("el-button"),V=m("el-form");return g(),v("div",U,[w("div",q,[w("h1",A,h(_.value||"CloudSearch")+" 管理后台",1),s(V,{ref_key:"formRef",ref:p,model:o,rules:y,"label-width":"0",size:"large",onKeyup:b(f,["enter"])},{default:l(()=>[s(t,{prop:"username"},{default:l(()=>[s(n,{modelValue:o.username,"onUpdate:modelValue":a[0]||(a[0]=c=>o.username=c),placeholder:"用户名","prefix-icon":"User"},null,8,["modelValue"])]),_:1}),s(t,{prop:"password"},{default:l(()=>[s(n,{modelValue:o.password,"onUpdate:modelValue":a[1]||(a[1]=c=>o.password=c),type:"password",placeholder:"密码","prefix-icon":"Lock","show-password":""},null,8,["modelValue"])]),_:1}),s(t,null,{default:l(()=>[s(e,{type:"primary",loading:u.value,class:"login-btn",onClick:f},{default:l(()=>[...a[2]||(a[2]=[C(" 登录 ",-1)])]),_:1},8,["loading"])]),_:1})]),_:1},8,["model"]),r.value?(g(),v("p",I,h(r.value),1)):x("",!0)])])}}}),z=E(K,[["__scopeId","data-v-513ea931"]]);export{z as default};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.cleanup-section .config-card[data-v-96d69897]{max-width:800px}.form-tip[data-v-96d69897]{font-size:12px;color:var(--el-text-color-secondary)}.cleanup-info[data-v-96d69897]{font-size:13px;color:var(--el-text-color-secondary)}
|
||||
@@ -0,0 +1 @@
|
||||
.cloud-badge[data-v-5857e8ce]{display:inline-block;padding:2px 8px;border-radius:4px;color:#fff;font-size:12px;line-height:1.5;white-space:nowrap}
|
||||
@@ -0,0 +1 @@
|
||||
import{C as o,a as t}from"./index-Bz21yOih.js";import{d as s,a as c,c as r,p as n,k as a,t as p}from"./index-C5b4pIQL.js";import{_ as d}from"./_plugin-vue_export-helper-CzL5NdOX.js";const l=s({__name:"CloudBadge",props:{cloud_type:{}},setup(e){return(_,m)=>(c(),r("span",{class:"cloud-badge",style:n({background:a(o)[e.cloud_type]})},p(a(t)[e.cloud_type]),5))}}),f=d(l,[["__scopeId","data-v-5857e8ce"]]);export{f as C};
|
||||
@@ -0,0 +1 @@
|
||||
.cloud-config[data-v-d5c0f4b4]{background:var(--bg-white);border-radius:var(--radius-card);padding:24px}.cloud-toggle-grid[data-v-d5c0f4b4]{display:flex;flex-wrap:wrap;gap:12px}.cloud-toggle-chip[data-v-d5c0f4b4]{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[data-v-d5c0f4b4]:hover{border-color:var(--el-color-primary-light-5)}.cloud-icon-img[data-v-d5c0f4b4]{width:20px;height:20px;object-fit:contain}.cloud-label[data-v-d5c0f4b4]{font-size:13px;font-weight:500}.form-tip[data-v-d5c0f4b4]{font-size:12px;color:var(--el-text-color-secondary)}.toolbar[data-v-d5c0f4b4]{margin-bottom:16px;display:flex;gap:8px;align-items:center;flex-wrap:wrap}.sign-summary-tag[data-v-d5c0f4b4]{margin-left:4px}.nickname-text[data-v-d5c0f4b4]{font-weight:600;color:#303133}.storage-cell[data-v-d5c0f4b4]{display:flex;flex-direction:column;gap:3px;padding:2px 0}.storage-bar-wrap[data-v-d5c0f4b4]{height:4px;background:#f0f2f5;border-radius:2px;overflow:hidden}.storage-bar-fill[data-v-d5c0f4b4]{height:100%;border-radius:2px;transition:width .3s}.storage-bar-fill.bar-normal[data-v-d5c0f4b4]{background:#67c23a}.storage-bar-fill.bar-warning[data-v-d5c0f4b4]{background:#e6a23c}.storage-bar-fill.bar-danger[data-v-d5c0f4b4]{background:#f56c6c}.storage-text[data-v-d5c0f4b4]{font-size:11px;color:#909399;display:flex;align-items:center;gap:3px}.storage-used[data-v-d5c0f4b4]{color:#606266;font-weight:600}.storage-total[data-v-d5c0f4b4]{color:#303133;font-weight:600}.storage-free[data-v-d5c0f4b4]{color:#909399}.save-count[data-v-d5c0f4b4]{font-size:12px;color:#909399}.verifying[data-v-d5c0f4b4]{display:inline-flex;align-items:center;gap:4px;font-size:12px;color:#909399}[data-v-d5c0f4b4] .el-input-group__append{padding:0}[data-v-d5c0f4b4] .el-input-group__append .el-button{border-radius:0}.cookie-help[data-v-d5c0f4b4]{background:#f8faff;border:1px solid #e8f0fe;border-radius:8px;padding:12px 14px;font-size:12px;line-height:1.6;color:#606266}.cookie-help-header[data-v-d5c0f4b4]{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px}.cookie-help-title[data-v-d5c0f4b4]{font-weight:700;color:#409eff;margin-bottom:0;font-size:13px}.cookie-help-steps[data-v-d5c0f4b4]{margin:0;padding-left:20px}.cookie-help-steps li[data-v-d5c0f4b4]{margin-bottom:2px}.cookie-help-steps code[data-v-d5c0f4b4]{background:#ecf5ff;padding:1px 4px;border-radius:3px;font-size:11px}.cookie-help-format[data-v-d5c0f4b4]{margin-top:6px;padding-top:6px;border-top:1px dashed #e8f0fe}.cookie-help-format code[data-v-d5c0f4b4]{background:#ecf5ff;padding:1px 6px;border-radius:3px;font-size:11px;word-break:break-all}.qr-login-body[data-v-d5c0f4b4]{display:flex;gap:28px;align-items:flex-start;padding:6px 0}.qr-login-qr-wrap[data-v-d5c0f4b4]{flex-shrink:0;width:200px;display:flex;align-items:center;justify-content:center}.qr-loading[data-v-d5c0f4b4]{width:200px;height:200px;display:flex;flex-direction:column;align-items:center;justify-content:center;background:#f8f9fa;border:1px solid #e4e7ed;border-radius:12px;gap:10px;color:#909399;font-size:13px}.qr-canvas[data-v-d5c0f4b4]{width:200px;height:200px;border-radius:12px;border:1px solid #e4e7ed}.qr-login-right[data-v-d5c0f4b4]{flex:1;display:flex;flex-direction:column;gap:20px;min-height:200px}.qr-login-steps[data-v-d5c0f4b4]{display:flex;flex-direction:column;gap:14px}.qr-step[data-v-d5c0f4b4]{display:flex;align-items:flex-start;gap:10px;font-size:14px;line-height:1.6;color:#303133}.qr-step-num[data-v-d5c0f4b4]{flex-shrink:0;width:24px;height:24px;border-radius:50%;background:#409eff;color:#fff;display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:700;margin-top:1px}.qr-login-status-area[data-v-d5c0f4b4]{display:flex;flex-direction:column;align-items:flex-start;gap:10px;padding:12px;background:#f8faff;border:1px solid #e8f0fe;border-radius:8px}.qr-status-icon[data-v-d5c0f4b4]{font-size:16px;margin-right:4px}.qr-status-tip[data-v-d5c0f4b4]{font-size:13px;color:#606266;line-height:1.5}.qr-status-warn[data-v-d5c0f4b4]{color:#f56c6c}.qr-refresh-btn[data-v-d5c0f4b4]{margin-top:2px}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{d as K,o as G,a as n,c as a,b as o,F as w,t as c,e as h,f as v,w as f,g as Q,r as z,h as l,i as B,j as x,u as W,k as Y,s as J,l as D,n as I}from"./index-C5b4pIQL.js";import{g as X,a as Z,_ as ee}from"./_plugin-vue_export-helper-CzL5NdOX.js";const te={class:"home-page"},se={class:"hero-section"},oe=["src","alt"],ne={key:1,class:"logo-text"},ae={class:"search-box"},ie={key:1,class:"quote-section"},ce={class:"quote-text"},le={class:"quote-author"},re={class:"content-section"},ue={key:0,class:"rankings-grid"},de={class:"panel-header"},_e={class:"panel-title"},he={class:"panel-tabs"},ve=["onClick"],pe=["onClick"],ge={class:"panel-body"},fe=["onClick"],me={class:"rank-name"},ye={class:"rank-cnt"},ke=["onClick"],Ce={class:"panel-footer"},be={key:0},we={key:1},xe={key:2},Ie={key:3},qe={class:"footer-time"},Ae={key:0,class:"site-footer"},Se={class:"footer-inner"},Ne={class:"footer-actions"},R=8,Te=K({__name:"HomePage",setup(Ve){const q=W(),m=l(""),u=l([]),d=B({}),_=B({}),p=l(""),y=l(""),k=l(""),A=l(!1),g=l(""),C=l(""),S=l(""),F={movie:"🎬",western_movie:"🎥",western_tv:"🌍",donghua:"🐉",global_anime:"🌐",tv:"📺",niche:"💎",hotsite:"🏆"};function L(e){return F[e]||"📋"}function j(e){const t=[];if(e.rating&&t.push(`⭐${e.rating}`),e.searchCount>0){const i=e.searchCount;i>=1e8?t.push(`${(i/1e8).toFixed(1)}亿`):i>=1e4?t.push(`${(i/1e4).toFixed(0)}万`):t.push(String(i))}return t.join(" ")||""}function N(e){return(_[e.category]||"hot")==="hot"?e.hot||[]:e.newest||[]}function E(e){const t=N(e);return d[e.category]?t:t.slice(0,R)}function H(e){return N(e).length>R&&!d[e.category]}function M(e){d[e]=!0}function T(e,t){_[e]=t,d[e]=!1}function O(){window.open("/disclaimer/","_blank")}G(async()=>{try{const t=await(await fetch("https://v1.hitokoto.cn/")).json();g.value=t.hitokoto||"",C.value=t.from_who||t.from||""}catch{g.value="学而时习之,不亦说乎。",C.value="孔子"}try{const[e,t]=await Promise.all([X(),Z()]);e.fetchedAt?(S.value=e.fetchedAt,u.value=e.categories||[]):u.value=Array.isArray(e)?e:[];for(const i of u.value)_[i.category]="hot",d[i.category]=!1;t.site_logo&&(p.value=t.site_logo),t.site_name&&(y.value=t.site_name),t.site_disclaimer&&(k.value=t.site_disclaimer),A.value=!0}catch(e){console.error("加载首页数据失败",e)}});function V(){const e=m.value.trim();e&&q.push("/search?q="+encodeURIComponent(e))}function P(e){q.push("/search?q="+encodeURIComponent(e))}return(e,t)=>{const i=x("el-icon"),U=x("el-input"),$=x("el-button");return n(),a("div",te,[o("div",se,[A.value?(n(),a(w,{key:0},[p.value?(n(),a("img",{key:0,src:p.value,alt:y.value||"CloudSearch",class:"logo-img",onError:t[0]||(t[0]=s=>{s.target.style.display="none",p.value=""})},null,40,oe)):(n(),a("div",ne,c(y.value||"CloudSearch"),1))],64)):h("",!0),o("div",ae,[v(U,{modelValue:m.value,"onUpdate:modelValue":t[1]||(t[1]=s=>m.value=s),placeholder:"搜索网盘资源,或粘贴视频/网盘链接...",size:"large",clearable:"",onKeyup:Q(V,["enter"])},{prefix:f(()=>[v(i,null,{default:f(()=>[v(Y(J))]),_:1})]),_:1},8,["modelValue"]),v($,{type:"primary",size:"large",onClick:V,class:"search-btn"},{default:f(()=>[...t[2]||(t[2]=[D(" 搜 索 ",-1)])]),_:1})]),g.value?(n(),a("div",ie,[o("span",ce,"「 "+c(g.value)+" 」",1),o("span",le,"---"+c(C.value),1)])):h("",!0)]),o("div",re,[u.value.length>0?(n(),a("div",ue,[(n(!0),a(w,null,z(u.value,s=>(n(),a("div",{key:s.category,class:"rank-panel"},[o("div",de,[o("span",_e,c(L(s.category))+" "+c(s.label),1),o("div",he,[o("span",{class:I(["panel-tab",{active:_[s.category]==="hot"}]),onClick:r=>T(s.category,"hot")},"热榜",10,ve),o("span",{class:I(["panel-tab",{active:_[s.category]==="newest"}]),onClick:r=>T(s.category,"newest")},"最新",10,pe)])]),o("div",ge,[(n(!0),a(w,null,z(E(s),(r,b)=>(n(),a("div",{key:s.category+"-"+b,class:"rank-item",onClick:$e=>P(r.keyword)},[o("span",{class:I(["rank-idx",{"top-three":b<3}])},c(b+1),3),o("span",me,c(r.keyword),1),o("span",ye,c(j(r)),1)],8,fe))),128)),H(s)?(n(),a("div",{key:0,class:"rank-expand",onClick:r=>M(s.category)}," 展开全部 ▼ ",8,ke)):h("",!0)]),o("div",Ce,[s.category==="hotsite"?(n(),a("span",be,"基于本站搜索数据")):s.category==="donghua"||s.category==="global_anime"?(n(),a("span",we,"数据来源:Bilibili")):s.category==="movie"||s.category==="tv"?(n(),a("span",xe,"数据来源:百度")):(n(),a("span",Ie,"数据来源:TMDB")),o("span",qe,c(S.value),1)])]))),128))])):h("",!0)]),k.value?(n(),a("div",Ae,[o("div",Se,c(k.value),1),o("div",Ne,[v($,{class:"footer-disclaimer-btn",size:"small",onClick:O},{default:f(()=>[...t[3]||(t[3]=[D("📜 免责声明",-1)])]),_:1})])])):h("",!0)])}}}),De=ee(Te,[["__scopeId","data-v-1f536d99"]]);export{De as default};
|
||||
@@ -0,0 +1 @@
|
||||
.home-page[data-v-1f536d99]{min-height:100vh;display:flex;flex-direction:column}.hero-section[data-v-1f536d99]{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:60px 24px 40px}.logo-text[data-v-1f536d99]{font-size:64px;font-weight:700;color:var(--primary-color);margin-bottom:32px;letter-spacing:-2px}.logo-img[data-v-1f536d99]{max-width:500px;max-height:120px;width:auto;height:auto;object-fit:contain;margin-bottom:32px}.search-box[data-v-1f536d99]{display:flex;align-items:center;width:100%;max-width:640px;border:1px solid #dfe1e5;border-radius:24px;background:#fff;box-shadow:none;transition:box-shadow .2s,border-color .2s;overflow:hidden}.search-box[data-v-1f536d99]:focus-within{box-shadow:0 1px 6px #20212447;border-color:#dfe1e500}.search-box[data-v-1f536d99] .el-input__wrapper{border:none;box-shadow:none;background:transparent;padding:4px 20px;border-radius:0}.search-box[data-v-1f536d99] .el-input__inner{font-size:15px}.search-btn[data-v-1f536d99]{flex-shrink:0;border:none;border-radius:999px;padding:0 24px;height:38px;line-height:38px;margin:4px;font-size:14px;font-weight:600;background:var(--primary-color);color:#fff;cursor:pointer;transition:all .2s;letter-spacing:1px}.search-btn[data-v-1f536d99]:hover{background:#3a7be0}.search-btn[data-v-1f536d99]:active{background:#2d6ccf}.quote-section[data-v-1f536d99]{margin-top:18px;max-width:640px;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.quote-text[data-v-1f536d99]{font-size:14px;color:#aab0b8;font-style:italic;letter-spacing:.5px}.quote-author[data-v-1f536d99]{font-size:12px;color:#c0c4cc;display:inline-block;margin-left:4px}.content-section[data-v-1f536d99]{max-width:1500px;width:100%;margin:0 auto;padding:0 16px 60px}.rankings-grid[data-v-1f536d99]{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:14px;margin-top:8px}.rank-panel[data-v-1f536d99]{background:var(--bg-white,#fff);border-radius:12px;padding:14px;border:1px solid #ebeef5;box-shadow:0 1px 4px #0000000a;display:flex;flex-direction:column}.panel-header[data-v-1f536d99]{display:flex;align-items:center;justify-content:space-between;padding-bottom:10px;border-bottom:2px solid #f0f0f0;margin-bottom:4px}.panel-title[data-v-1f536d99]{font-size:15px;font-weight:700;color:#303133;white-space:nowrap}.panel-tabs[data-v-1f536d99]{display:flex;gap:2px;background:#f0f2f5;border-radius:6px;padding:2px}.panel-tab[data-v-1f536d99]{font-size:11px;padding:3px 10px;border-radius:5px;cursor:pointer;color:#909399;font-weight:500;transition:all .2s;-webkit-user-select:none;user-select:none}.panel-tab.active[data-v-1f536d99]{background:#fff;color:var(--primary-color);font-weight:600;box-shadow:0 1px 3px #0000000f}.panel-body[data-v-1f536d99]{flex:1;display:flex;flex-direction:column;gap:1px}.rank-item[data-v-1f536d99]{display:flex;align-items:center;gap:8px;padding:5px 6px;border-radius:6px;cursor:pointer;transition:background .15s}.rank-item[data-v-1f536d99]:hover{background:#f0f5ff}.rank-item[data-v-1f536d99]:active{background:#e6f0ff}.rank-idx[data-v-1f536d99]{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;color:#909399;background:#f0f0f0;flex-shrink:0}.rank-idx.top-three[data-v-1f536d99]{background:var(--primary-color);color:#fff}.rank-name[data-v-1f536d99]{flex:1;min-width:0;font-size:13px;font-weight:500;color:#303133;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rank-cnt[data-v-1f536d99]{font-size:11px;color:#c0c4cc;white-space:nowrap;flex-shrink:0}.rank-expand[data-v-1f536d99]{text-align:center;padding:6px;margin-top:2px;font-size:12px;color:var(--primary-color);cursor:pointer;border-radius:6px;transition:background .15s;-webkit-user-select:none;user-select:none}.rank-expand[data-v-1f536d99]:hover{background:#ecf5ff}.panel-footer[data-v-1f536d99]{margin-top:8px;padding-top:8px;border-top:1px solid #f0f0f0;display:flex;align-items:center;justify-content:space-between;font-size:11px;color:#c0c4cc}.footer-time[data-v-1f536d99]{font-family:monospace;font-size:10px}@media (max-width: 900px){.hero-section[data-v-1f536d99]{padding:36px 16px 24px}.logo-text[data-v-1f536d99]{font-size:36px;margin-bottom:20px}.logo-img[data-v-1f536d99]{max-width:360px;max-height:100px;margin-bottom:20px}.rankings-scroll[data-v-1f536d99]{gap:12px}}.site-footer[data-v-1f536d99]{margin-top:auto;padding:20px 16px 32px;background:#f9fafb;border-top:1px solid #ebeef5}.footer-inner[data-v-1f536d99]{max-width:800px;margin:0 auto;font-size:12px;line-height:1.8;color:#909399;text-align:center;white-space:pre-line}.footer-actions[data-v-1f536d99]{display:flex;justify-content:center;align-items:center;gap:12px;margin-top:12px}.footer-disclaimer-btn[data-v-1f536d99]{font-size:12px!important;color:#909399!important}.footer-disclaimer-btn[data-v-1f536d99]:hover{color:#409eff!important}
|
||||
@@ -0,0 +1 @@
|
||||
.cloud-select[data-v-098423df]{width:100%}.result-detail-page[data-v-755e2105]{min-height:100vh;background:#f5f7fa;padding:40px 24px}.detail-container[data-v-755e2105]{max-width:1080px;margin:0 auto}.detail-card[data-v-755e2105]{background:var(--bg-white);border-radius:var(--radius-card);box-shadow:var(--shadow-card);padding:32px}.detail-header[data-v-755e2105]{display:flex;gap:24px;margin-bottom:24px}.detail-cover[data-v-755e2105]{position:relative;flex-shrink:0;width:240px;height:180px;border-radius:12px;overflow:hidden}.detail-cover img[data-v-755e2105]{width:100%;height:100%;object-fit:cover}.detail-info[data-v-755e2105]{flex:1}.detail-info h1[data-v-755e2105]{font-size:24px;font-weight:700;color:#303133;margin-bottom:12px;line-height:1.4}.detail-meta[data-v-755e2105]{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px}.detail-desc[data-v-755e2105]{font-size:15px;color:#606266;line-height:1.6}.detail-actions[data-v-755e2105]{border-top:1px solid var(--border-color);padding-top:20px}.detail-video[data-v-755e2105]{margin-bottom:24px}.video-preview[data-v-755e2105]{position:relative;width:100%;border-radius:12px;overflow:hidden;margin-bottom:20px}.video-preview img[data-v-755e2105]{width:100%;max-height:400px;object-fit:cover;display:block}.play-overlay[data-v-755e2105]{position:absolute;top:0;left:0;right:0;bottom:0;display:flex;align-items:center;justify-content:center;background:#0000004d;cursor:pointer;transition:background .2s}.play-overlay[data-v-755e2105]:hover{background:#0006}.play-btn[data-v-755e2105]{padding:12px 32px;background:#ffffffe6;border-radius:24px;font-size:18px;font-weight:600;color:#303133}.video-info h1[data-v-755e2105]{font-size:24px;font-weight:700;color:#303133;margin-bottom:12px}.video-author[data-v-755e2105],.video-platform[data-v-755e2105]{font-size:15px;color:var(--text-secondary);margin-bottom:6px}.video-player-wrapper[data-v-755e2105]{margin-top:24px;border-top:1px solid var(--border-color);padding-top:24px}.video-player[data-v-755e2105]{width:100%;border-radius:8px}.loading-state[data-v-755e2105]{padding:40px 0}.save-dialog-content[data-v-755e2105]{display:flex;flex-direction:column;gap:16px}.save-file-name[data-v-755e2105]{font-size:15px;font-weight:500;color:#303133}.result-dialog-content[data-v-755e2105]{display:flex;flex-direction:column;gap:16px}.share-link-box[data-v-755e2105]{display:flex;flex-direction:column;gap:8px}.share-label[data-v-755e2105]{font-size:14px;color:#606266}.share-link-row[data-v-755e2105]{display:flex;gap:8px}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.el-card[data-v-79a959cf]{margin-bottom:20px}.el-card[data-v-79a959cf] .el-card__header{font-weight:600;font-size:15px}[data-v-79a959cf] .el-divider__text.is-left{left:0;padding-left:0}.form-tip[data-v-79a959cf]{font-size:12px;color:#909399;margin-top:4px}.fallback-upload-wrap[data-v-79a959cf]{display:flex;flex-direction:column;gap:12px}.fallback-upload-row[data-v-79a959cf]{display:flex;align-items:center;flex-wrap:wrap}.fallback-preview[data-v-79a959cf]{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.fallback-preview img[data-v-79a959cf]{max-width:100%;height:auto;max-height:120px;border-radius:8px;border:1px solid var(--border-color);background:#f0f0f0;object-fit:contain}.save-bar[data-v-79a959cf]{position:sticky;bottom:0;background:var(--bg-white);padding:16px 24px 16px 0;border-top:1px solid var(--border-color);margin-top:24px;display:flex;justify-content:flex-end;gap:12px}.strategy-grid[data-v-79a959cf]{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px 16px}.grid-cell[data-v-79a959cf]{display:flex;flex-direction:column;gap:4px}.strategy-section[data-v-79a959cf]{padding:0 4px}.field-block[data-v-79a959cf]{margin:12px 0}.field-label-row[data-v-79a959cf]{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.field-label[data-v-79a959cf]{font-size:14px;font-weight:500;color:#303133;white-space:nowrap}.field-desc[data-v-79a959cf]{font-size:12px;color:#909399;margin:3px 0 0;line-height:1.5}.keyword-input-row[data-v-79a959cf]{display:flex;gap:8px;flex:1;min-width:200px}.tag-list[data-v-79a959cf]{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}.tag-empty[data-v-79a959cf]{font-size:13px;color:#c0c4cc;margin-top:8px}.filter-rule-help[data-v-79a959cf]{margin-top:8px;padding:10px 12px;background:#f8f9fa;border-radius:8px;border:1px solid #e8e8e8}.filter-rule-help .help-title[data-v-79a959cf]{font-weight:600;font-size:13px;margin:8px 0 4px;color:#333}.filter-rule-help .help-title[data-v-79a959cf]:first-child{margin-top:0}.filter-rule-help .help-row[data-v-79a959cf]{font-size:12px;color:#555;margin:3px 0;line-height:1.6}.filter-rule-help .help-row code[data-v-79a959cf]{background:#eef1f5;padding:1px 5px;border-radius:3px;font-size:11px;font-family:monospace}.filter-rules-help[data-v-79a959cf]{margin-top:8px;padding:12px;background:#f8f9fa;border-radius:8px;border:1px solid #e8e8e8}.help-title[data-v-79a959cf]{font-weight:600;font-size:13px;margin:10px 0 6px;color:#333}.help-title[data-v-79a959cf]:first-child{margin-top:0}.help-row[data-v-79a959cf]{font-size:12px;color:#555;margin:3px 0;line-height:1.6}.help-row code[data-v-79a959cf]{background:#eef1f5;padding:1px 5px;border-radius:3px;font-size:11px;font-family:monospace}.help-sample[data-v-79a959cf]{background:#1e1e1e;color:#d4d4d4;padding:10px 14px;border-radius:6px;font-size:12px;line-height:1.6;overflow-x:auto;white-space:pre;margin:6px 0 0;font-family:monospace}.help-preview-row[data-v-79a959cf]{font-size:13px;margin:4px 0;display:flex;align-items:center;gap:6px}.help-preview-label[data-v-79a959cf]{color:#888;min-width:70px;font-size:12px}.help-preview-original[data-v-79a959cf]{color:#e74c3c}.help-preview-filtered[data-v-79a959cf]{color:#27ae60;font-weight:500}.filter-input-row[data-v-79a959cf]{display:flex;gap:8px;width:100%;margin-bottom:8px}.filter-tag-list[data-v-79a959cf]{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px}.filter-empty[data-v-79a959cf]{font-size:13px;color:#c0c4cc;padding:8px 0;margin-bottom:8px}.db-status-grid[data-v-79a959cf]{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px;margin-top:8px}.db-stat-item[data-v-79a959cf]{background:#f8f9fa;border-radius:10px;padding:16px 12px;text-align:center;border:1px solid #eee;transition:transform .15s,box-shadow .15s}.db-stat-item[data-v-79a959cf]:hover{transform:translateY(-2px);box-shadow:0 4px 12px #0000000f}.db-stat-value[data-v-79a959cf]{white-space:nowrap;font-size:24px;font-weight:700;color:#303133;margin-bottom:4px}.db-stat-value.text-success[data-v-79a959cf]{color:#67c23a}.db-stat-value.text-warning[data-v-79a959cf]{color:#e6a23c}.db-stat-label[data-v-79a959cf]{font-size:12px;color:#909399}@media (max-width: 900px){.strategy-grid[data-v-79a959cf]{grid-template-columns:1fr 1fr}}@media (max-width: 600px){.strategy-grid[data-v-79a959cf]{grid-template-columns:1fr}}.pansou-status-grid[data-v-79a959cf]{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px;margin-top:8px}.status-dot[data-v-79a959cf]{width:8px;height:8px;border-radius:50%;display:inline-block}.dot-ok[data-v-79a959cf]{background:#67c23a}.dot-err[data-v-79a959cf]{background:#f56c6c}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
const a={quark:"夸克网盘",baidu:"百度网盘",aliyun:"阿里云盘",115:"115网盘",tianyi:"天翼云盘","123pan":"123云盘",uc:"UC网盘",xunlei:"迅雷云盘",pikpak:"PikPak",magnet:"磁力链接",ed2k:"电驴链接",others:"其他"},e={quark:"#07c160",baidu:"#4e6ef2",aliyun:"#ff6a00",115:"#9b59b6",tianyi:"#00a1d6","123pan":"#e74c3c",uc:"#f39c12",xunlei:"#2ecc71",pikpak:"#8e44ad",magnet:"#95a5a6",ed2k:"#7f8c8d",others:"#95a5a6"};export{e as C,a};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Executable
+72
@@ -0,0 +1,72 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>免责声明 - 资源分享</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
background: #f5f7fa;
|
||||
color: #333;
|
||||
line-height: 1.8;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
|
||||
padding: 40px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 22px;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
color: #1a1a2e;
|
||||
border-bottom: 2px solid #e8e8e8;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
h2 { font-size: 16px; margin: 24px 0 10px; color: #1a1a2e; }
|
||||
h3 { font-size: 15px; margin: 18px 0 8px; color: #303133; }
|
||||
p { margin: 8px 0; text-indent: 2em; font-size: 14px; }
|
||||
.highlight { background: #fff3cd; padding: 1px 4px; border-radius: 3px; }
|
||||
.footer { margin-top: 30px; padding-top: 16px; border-top: 1px solid #e8e8e8; text-align: center; font-size: 12px; color: #999; }
|
||||
.back-link { display: inline-block; margin-top: 20px; color: #409eff; text-decoration: none; font-size: 14px; }
|
||||
.back-link:hover { text-decoration: underline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>📜 网站免责声明</h1>
|
||||
|
||||
<h2>一、版权与资源声明</h2>
|
||||
<p>本网站(<a href="https://hk-zy.timaa.cn" target="_blank">hk-zy.timaa.cn</a>)是一个基于开源项目搭建的<strong>非盈利性个人站点</strong>,旨在分享与交流技术资源。本网站所有资源均收集整理自互联网,其版权、著作权均归原作者或发行公司所有。本网站不对资源的版权归属进行实质审查,对于任何由资源本身引发的版权争议概不负责。</p>
|
||||
|
||||
<h2>二、使用限制与法律责任</h2>
|
||||
<p>用户在本网站下载的所有软件、资料等资源,仅供<strong>个人学习、研究、技术交流</strong>,严禁用于任何商业或非法用途。用户必须在下载后的<strong class="highlight">24小时内</strong>,从个人电脑及存储设备中彻底删除相关内容。如用户喜欢该程序或内容,请支持正版,到官方网站购买注册。</p>
|
||||
<p>因用户不当使用(包括但不限于商业使用、非法传播、破解侵权)而引发的一切法律纠纷及后果,由用户<strong>自行承担</strong>,本网站及网站管理者不承担任何连带责任。</p>
|
||||
|
||||
<h2>三、"避风港原则"与侵权处理</h2>
|
||||
<p>依据《信息网络传播权保护条例》,本网站仅提供信息存储空间服务或资源索引服务。若用户上传或分享的内容侵犯了您的合法权益,请您立即通过以下联系方式与我们交涉。</p>
|
||||
<p><strong>联系方式:</strong> 3337598077@qq.com</p>
|
||||
<p><strong>处理措施:</strong> 我们在收到权利人发出的合格通知(包括权属证明和侵权链接)后,将在合理期限内对涉嫌侵权内容进行核实、断开链接或直接删除。</p>
|
||||
<p><strong>唯一目的:</strong> 本网站为纯公益、非盈利性分享,绝无意侵害任何第三方权益。若内容涉及侵权,实属无意,请版权方及时通知以便我们处理。</p>
|
||||
|
||||
<h2>四、用户行为与网站免责</h2>
|
||||
<p>访问者在本网站进行下载、浏览时,须自行承担风险。本网站不保证资源完全无毒、无缺陷或绝对安全,对于因使用本站资源而造成的硬件损坏、数据丢失等损失,本网站不负任何责任。</p>
|
||||
<p>本站内容仅代表资源提供者的个人观点,不代表本站立场。对于任何站点外部链接的真实性、合法性,本站不承担担保责任。</p>
|
||||
<p>凡以任何方式登陆本网站或直接、间接使用本网站资源者,视为自愿接受本网站免责声明的约束。</p>
|
||||
|
||||
<h2>五、法律适用</h2>
|
||||
<p>本声明未涉及的问题参见国家有关法律法规。当本声明与国家法律法规冲突时,以国家法律法规为准。本网站保留对本声明的最终解释权。</p>
|
||||
|
||||
<div class="footer">
|
||||
<p style="text-indent:0">更新日期:2026年5月 · 版本 V1.0</p>
|
||||
<a class="back-link" href="/" onclick="history.length > 1 ? history.back() : (location.href='/'); return false;">← 返回首页</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Executable
+923
@@ -0,0 +1,923 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||
<meta http-equiv="Pragma" content="no-cache" />
|
||||
<meta http-equiv="Expires" content="0" />
|
||||
<title>CloudSearch - 搜索</title>
|
||||
<script>
|
||||
// 替换标题为网站名称
|
||||
fetch('/api/site-config').then(function(r){return r.json()}).then(function(cfg){
|
||||
if(cfg.site_name) document.title = cfg.site_name + ' - 搜索';
|
||||
}).catch(function(){});
|
||||
</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
|
||||
<style>
|
||||
/* ===== Reset & Base ===== */
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
html{font-size:16px;-webkit-text-size-adjust:100%}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;background:#f5f5f5;color:#303133;min-height:100vh;overflow-x:hidden}
|
||||
:root{--primary:#409eff;--primary-dark:#337ecc;--primary-light:rgba(64,158,255,0.08);--text:#303133;--text2:#909399;--border:#ebeef5;--bg:#f5f5f5;--white:#fff;--radius:10px;--shadow:0 1px 4px rgba(0,0,0,0.04);--safe-bottom:env(safe-area-inset-bottom,0px)}
|
||||
a{color:var(--primary);text-decoration:none}
|
||||
img{display:block;max-width:100%}
|
||||
|
||||
/* ===== Home Page ===== */
|
||||
.home-page{padding-bottom:calc(30px + var(--safe-bottom))}
|
||||
.home-hero{display:flex;flex-direction:column;align-items:center;padding:36px 16px 20px}
|
||||
.home-logo{font-size:32px;font-weight:700;color:var(--primary);margin-bottom:20px;text-align:center}
|
||||
.home-logo-img{max-width:360px;max-height:80px;width:auto;height:auto;object-fit:contain}
|
||||
.home-search-box{display:flex;width:100%;max-width:500px;border:1px solid var(--border);border-radius:20px;overflow:hidden;background:var(--bg);transition:border-color .2s}
|
||||
.home-search-box:focus-within{border-color:var(--primary);background:var(--white);box-shadow:0 0 0 3px rgba(64,158,255,.1)}
|
||||
.home-search-box input{flex:1;height:40px;border:none;padding:0 14px;font-size:14px;outline:none;background:transparent}
|
||||
.home-search-box button{flex-shrink:0;height:32px;margin:4px;padding:0 22px;border:none;border-radius:999px;background:var(--primary);color:var(--white);font-size:13px;font-weight:600;cursor:pointer}
|
||||
.home-search-box button:active{background:var(--primary-dark)}
|
||||
.home-quote{margin-top:12px;font-size:12px;color:#b0b8c4;font-style:italic;text-align:center;max-width:500px;line-height:1.5}
|
||||
.home-quote-author{font-size:11px;color:#c0c4cc;display:inline-block;margin-top:2px}
|
||||
|
||||
/* ===== Home Rankings ===== */
|
||||
.home-rankings{padding:8px 12px;display:flex;flex-direction:column;gap:10px}
|
||||
.rank-block{background:var(--white);border-radius:var(--radius);padding:12px;border:1px solid var(--border);box-shadow:var(--shadow)}
|
||||
.rank-block-hdr{display:flex;align-items:center;justify-content:space-between;padding-bottom:8px;border-bottom:2px solid #f0f0f0;margin-bottom:4px}
|
||||
.rank-block-title{font-size:14px;font-weight:700;color:var(--text);white-space:nowrap}
|
||||
.rank-block-tabs{display:flex;gap:2px;background:#f0f2f5;border-radius:5px;padding:2px}
|
||||
.rank-tab{font-size:11px;padding:2px 9px;border-radius:4px;cursor:pointer;color:#909399;font-weight:500;transition:all .2s;user-select:none}
|
||||
.rank-tab.active{background:var(--white);color:var(--primary);font-weight:600;box-shadow:0 1px 2px rgba(0,0,0,.06)}
|
||||
.rank-item{display:flex;align-items:center;gap:7px;padding:5px 6px;border-radius:6px;cursor:pointer;transition:background .15s}
|
||||
.rank-item:active{background:#f0f5ff}
|
||||
.rank-idx{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:#909399;background:#f0f0f0;flex-shrink:0}
|
||||
.rank-idx.top3{background:var(--primary);color:var(--white);font-size:12px}
|
||||
.rank-name{flex:1;min-width:0;font-size:13px;font-weight:500;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.rank-cnt{font-size:11px;color:#c0c4cc;white-space:nowrap;flex-shrink:0}
|
||||
.rank-expand{text-align:center;padding:5px;margin-top:2px;font-size:12px;color:var(--primary);cursor:pointer;border-radius:5px;user-select:none}
|
||||
.rank-expand:active{background:#ecf5ff}
|
||||
.rank-block-ftr{margin-top:6px;padding-top:6px;border-top:1px solid #f0f0f0;display:flex;align-items:center;justify-content:space-between;font-size:10px;color:#c0c4cc}
|
||||
.ftr-time{font-family:monospace;font-size:9px}
|
||||
|
||||
/* ===== Layout ===== */
|
||||
.app{max-width:100%;margin:0 auto;padding-bottom:calc(20px + var(--safe-bottom))}
|
||||
.header{position:sticky;top:0;z-index:50;background:var(--white);border-bottom:1px solid var(--border);padding:8px 12px}
|
||||
.header-row{display:flex;align-items:center;gap:10px}
|
||||
.header-title{font-size:18px;font-weight:700;color:var(--primary);flex-shrink:0}
|
||||
.header-title-link{text-decoration:none;flex-shrink:0;display:flex;align-items:center}
|
||||
.header-logo-img{max-width:160px;max-height:36px;width:auto;height:auto;object-fit:contain;display:block}
|
||||
.header-actions{margin-left:auto;display:flex;gap:6px;align-items:center}
|
||||
|
||||
/* ===== Search Bar ===== */
|
||||
.search-wrap{flex:1;display:flex;border:1px solid var(--border);border-radius:18px;overflow:hidden;background:var(--bg);transition:border-color .2s}
|
||||
.search-wrap:focus-within{border-color:var(--primary);background:var(--white)}
|
||||
.search-wrap input{flex:1;height:36px;border:none;padding:0 14px;font-size:14px;outline:none;background:transparent}
|
||||
.search-wrap button{flex-shrink:0;height:28px;margin:4px;padding:0 18px;border:none;border-radius:999px;background:var(--primary);color:var(--white);font-size:13px;font-weight:600;cursor:pointer;transition:background .2s}
|
||||
.search-wrap button:active{background:var(--primary-dark)}
|
||||
.search-wrap button:disabled{opacity:.5}
|
||||
|
||||
/* ===== Footer ===== */
|
||||
.site-footer{margin-top:30px;padding:16px 12px 24px;background:#f9fafb;border-top:1px solid var(--border)}
|
||||
.footer-inner{max-width:500px;margin:0 auto;font-size:11px;line-height:1.8;color:#909399;text-align:center;white-space:pre-line}
|
||||
.footer-actions{display:flex;justify-content:center;gap:10px;margin-top:12px;flex-wrap:wrap}
|
||||
.footer-btn{padding:8px 20px;border:1px solid var(--border);border-radius:8px;background:var(--white);color:var(--text);font-size:13px;cursor:pointer;transition:all .2s}
|
||||
.footer-btn:active{background:var(--primary-light);border-color:var(--primary);color:var(--primary)}
|
||||
|
||||
/* ===== Info Bar ===== */
|
||||
.info-bar{display:flex;align-items:center;gap:8px;padding:10px 12px 0;font-size:12px;color:var(--text2);flex-wrap:wrap}
|
||||
.info-bar .count{font-weight:600;color:var(--text)}
|
||||
.info-bar .time{font-family:monospace;background:#f4f4f5;padding:1px 6px;border-radius:4px}
|
||||
.info-bar .badge-err{background:#fef0f0;color:#f56c6c;padding:1px 6px;border-radius:4px}
|
||||
|
||||
/* ===== Loading ===== */
|
||||
.loading{padding:24px 12px;text-align:center;font-size:13px;color:var(--text2)}
|
||||
.loading-bar{width:100%;height:3px;background:#e8e8e8;border-radius:2px;overflow:hidden;margin-top:8px}
|
||||
.loading-bar-inner{height:100%;background:linear-gradient(90deg,var(--primary),#67c23a);border-radius:2px;transition:width .3s ease;width:0%}
|
||||
|
||||
/* ===== Tabs ===== */
|
||||
.tabs{display:flex;gap:4px;padding:8px 12px;overflow-x:auto;-webkit-overflow-scrolling:touch;scrollbar-width:none}
|
||||
.tabs::-webkit-scrollbar{display:none}
|
||||
.tab{flex-shrink:0;padding:5px 12px;border-radius:16px;font-size:12px;color:#606266;background:#f0f2f5;cursor:pointer;white-space:nowrap;transition:all .2s;user-select:none}
|
||||
.tab:active{transform:scale(.95)}
|
||||
.tab.active{background:var(--primary-light);color:var(--primary);font-weight:600}
|
||||
|
||||
/* ===== Results ===== */
|
||||
.results{display:flex;flex-direction:column;gap:10px;padding:8px 12px}
|
||||
.empty{padding:40px 12px;text-align:center;color:var(--text2);font-size:14px}
|
||||
|
||||
/* ===== Card ===== */
|
||||
.card{display:flex;gap:10px;background:var(--white);border-radius:var(--radius);padding:10px;border:1px solid var(--border);transition:border-color .2s}
|
||||
.card:active{border-color:#c0c4cc}
|
||||
.card-cover{flex-shrink:0;width:90px;height:120px;border-radius:8px;overflow:hidden;background:var(--bg);position:relative}
|
||||
.card-cover img{width:100%;height:100%;object-fit:cover}
|
||||
.card-cover .placeholder{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:28px;background:linear-gradient(135deg,#667eea,#764ba2);color:rgba(255,255,255,.6)}
|
||||
.card-cover .tag{position:absolute;bottom:3px;left:3px;padding:1px 5px;border-radius:3px;color:#fff;font-size:10px;font-weight:600;backdrop-filter:blur(2px)}
|
||||
.card-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px}
|
||||
.card-title{font-size:14px;font-weight:700;color:var(--text);line-height:1.3;display:-webkit-box;-webkit-line-clamp:1;-webkit-box-orient:vertical;overflow:hidden}
|
||||
.card-meta{font-size:11px;color:var(--text2);display:flex;align-items:center;gap:8px}
|
||||
.card-meta .size{color:#67c23a}
|
||||
.card-tags{display:flex;flex-wrap:wrap;gap:4px;margin-top:2px}
|
||||
.card-tags span{font-size:10px;padding:1px 6px;border-radius:4px;background:#ecf5ff;color:#409eff;white-space:nowrap}
|
||||
.card-tags .quality{background:#fef0f0;color:#e74c3c}
|
||||
.card-actions{margin-top:auto;display:flex;align-items:center;justify-content:space-between;gap:6px}
|
||||
.card-source{font-size:10px;color:var(--text2);background:#f4f4f5;padding:1px 6px;border-radius:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:120px}
|
||||
.card-btn{padding:4px 10px;border:none;border-radius:6px;background:var(--primary);color:var(--white);font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap;transition:background .2s}
|
||||
.card-btn:active{background:var(--primary-dark)}
|
||||
.card-btn:disabled{opacity:.5}
|
||||
|
||||
/* ===== Toast ===== */
|
||||
.toast{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:rgba(0,0,0,.75);color:#fff;padding:10px 20px;border-radius:8px;font-size:14px;z-index:200;pointer-events:none;opacity:0;transition:opacity .3s}
|
||||
.toast.show{opacity:1}
|
||||
.toast.error{background:rgba(245,108,108,.9)}
|
||||
|
||||
/* ===== Overlay / Modal ===== */
|
||||
.overlay{position:fixed;inset:0;z-index:100;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;padding:16px;animation:fadeIn .2s}
|
||||
.modal{background:var(--white);border-radius:14px;width:100%;max-width:420px;max-height:90vh;overflow-y:auto;padding:0;animation:slideUp .25s}
|
||||
.modal-hdr{padding:14px 16px;border-bottom:1px solid var(--border);font-size:15px;font-weight:700;color:var(--text)}
|
||||
.modal-body{padding:14px 16px}
|
||||
.modal-ftr{padding:10px 16px;border-top:1px solid var(--border);display:flex;gap:8px;justify-content:flex-end}
|
||||
.modal-ftr button{height:36px;padding:0 16px;border:none;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer}
|
||||
.modal-ftr .btn-close{background:#f4f4f5;color:#606266}
|
||||
.modal-ftr .btn-disclaimer{background:#fdf6ec;color:#d46b08;margin-right:auto}
|
||||
.modal-ftr .btn-primary{background:var(--primary);color:var(--white)}
|
||||
.modal-ftr .btn-primary:active{background:var(--primary-dark)}
|
||||
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
|
||||
@keyframes slideUp{from{transform:translateY(40px);opacity:0}to{transform:translateY(0);opacity:1}}
|
||||
|
||||
/* ===== Share Modal ===== */
|
||||
.share-section{display:flex;flex-direction:column;gap:10px}
|
||||
.share-row{display:flex;gap:8px}
|
||||
.share-row input{flex:1;height:36px;border:1px solid var(--border);border-radius:6px;padding:0 10px;font-size:13px;outline:none;background:var(--bg);color:var(--text)}
|
||||
.share-row input:focus{border-color:var(--primary)}
|
||||
.share-row .copy-btn{height:36px;padding:0 12px;border:none;border-radius:6px;background:var(--primary);color:var(--white);font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap}
|
||||
.share-pwd{display:flex;align-items:center;gap:6px;font-size:13px}
|
||||
.share-pwd .pwd-tag{padding:2px 8px;background:#fdf6ec;color:#e6a23c;border-radius:4px;font-weight:700}
|
||||
.share-pwd .pwd-hint{font-size:11px;color:var(--text2)}
|
||||
.share-tip{padding:8px 10px;background:#fdf6ec;border-radius:6px;font-size:12px;line-height:1.5;color:#d46b08;display:flex;gap:6px;align-items:flex-start}
|
||||
.share-tip .warn-icon{font-size:18px;line-height:1.5;flex-shrink:0}
|
||||
.share-tip .tip-text{flex:1;min-width:0}
|
||||
.share-tip strong{font-weight:700}
|
||||
.warning-box{background:#fff2f0;border:1px solid #ffccc7;border-radius:8px;padding:8px 10px;overflow-x:auto;-webkit-overflow-scrolling:touch}
|
||||
.warning-item{margin:0;font-size:12px;line-height:1.8;font-weight:700;white-space:nowrap}
|
||||
.warning-item:nth-child(odd){color:#cf1322}
|
||||
.warning-item:nth-child(even){color:#d46b08}
|
||||
.warning-item:last-child{color:#b71c1c;font-size:13px}
|
||||
.share-qr{display:flex;flex-direction:column;align-items:center;gap:4px;padding:8px}
|
||||
.share-qr canvas{border-radius:8px}
|
||||
.share-qr .qr-label{font-size:12px;font-weight:600;color:var(--primary)}
|
||||
.share-qr .qr-sub{font-size:11px;color:var(--text2)}
|
||||
.share-disclaimer{display:flex;align-items:center;justify-content:center;gap:8px;margin-top:10px;padding:8px 10px;background:#fdf6ec;border-radius:6px;font-size:12px;color:#d46b08;flex-wrap:wrap}
|
||||
|
||||
/* ===== Login Modal ===== */
|
||||
.login-form{display:flex;flex-direction:column;gap:12px}
|
||||
.login-form input{height:40px;border:1px solid var(--border);border-radius:8px;padding:0 12px;font-size:14px;outline:none}
|
||||
.login-form input:focus{border-color:var(--primary)}
|
||||
.login-form .login-btn{height:40px;border:none;border-radius:8px;background:var(--primary);color:var(--white);font-size:14px;font-weight:600;cursor:pointer;transition:background .2s}
|
||||
.login-form .login-btn:active{background:var(--primary-dark)}
|
||||
.login-form .login-btn:disabled{opacity:.5}
|
||||
.login-form .login-err{font-size:12px;color:#f56c6c;text-align:center}
|
||||
|
||||
/* ===== Progress Steps ===== */
|
||||
.steps{display:flex;flex-direction:column;gap:10px;padding:8px 0}
|
||||
.step{display:flex;align-items:flex-start;gap:10px;opacity:.4;transition:opacity .3s}
|
||||
.step.active{opacity:1}
|
||||
.step.done{opacity:.7}
|
||||
.step-dot{flex-shrink:0;width:26px;height:26px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;background:#e4e7ed;color:#909399}
|
||||
.step.active .step-dot{background:var(--primary);color:#fff;box-shadow:0 0 0 3px rgba(64,158,255,.2)}
|
||||
.step.done .step-dot{background:#67c23a;color:#fff}
|
||||
.step-body{flex:1;padding-top:3px;display:flex;align-items:center;gap:8px}
|
||||
.step-title{font-size:13px;color:var(--text);font-weight:500}
|
||||
.step-status{font-size:11px;padding:1px 7px;border-radius:10px;white-space:nowrap}
|
||||
.step-status.doing{background:#ecf5ff;color:var(--primary)}
|
||||
.step-status.done{background:#f0f9eb;color:#67c23a}
|
||||
.step-status.wait{background:#f4f4f5;color:#c0c4cc}
|
||||
.error-alert{padding:12px 16px;background:#fef0f0;border:1px solid #fde2e2;border-radius:8px;display:flex;align-items:center;gap:8px;font-size:14px;color:#f56c6c}
|
||||
|
||||
/* ===== User Badge ===== */
|
||||
.user-badge{font-size:12px;color:var(--primary);font-weight:600;white-space:nowrap}
|
||||
.login-btn-small{height:30px;padding:0 10px;border:none;border-radius:6px;background:var(--primary-light);color:var(--primary);font-size:12px;font-weight:600;cursor:pointer}
|
||||
.logout-btn-small{height:30px;padding:0 8px;border:none;border-radius:6px;background:transparent;color:var(--text2);font-size:12px;cursor:pointer}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app" id="app">
|
||||
<!-- ===== Home Page (shown when no search) ===== -->
|
||||
<div id="homePage" class="home-page">
|
||||
<div class="home-hero">
|
||||
<div class="home-logo" id="homeLogo" style="display:none"></div>
|
||||
<div class="home-search-box">
|
||||
<input id="homeSearchInput" type="text" placeholder="搜索网盘资源..." />
|
||||
<button id="homeSearchBtn" onclick="homeSearch()">搜 索</button>
|
||||
</div>
|
||||
<div class="home-quote" id="homeQuote"></div>
|
||||
<div class="home-quote-author" id="homeQuoteAuthor"></div>
|
||||
</div>
|
||||
<div class="home-rankings" id="homeRankings"></div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Search Results View ===== -->
|
||||
<div id="searchView" style="display:none">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<div class="header-row">
|
||||
<a href="/h5" class="header-title-link"><div class="header-title" id="headerTitle" style="display:none">CloudSearch</div></a>
|
||||
<div class="search-wrap">
|
||||
<input id="searchInput" type="text" placeholder="搜索网盘资源..." @keydown="handleKeydown" />
|
||||
<button id="searchBtn" onclick="doSearch()">搜 索</button>
|
||||
</div>
|
||||
<div class="header-actions" id="userArea">
|
||||
<template id="userLoggedIn">
|
||||
<span class="user-badge" id="usernameDisplay"></span>
|
||||
<button class="logout-btn-small" onclick="logout()">退出</button>
|
||||
</template>
|
||||
<template id="userLoggedOut">
|
||||
<button class="login-btn-small" onclick="showLogin()">登录</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info Bar -->
|
||||
<div id="infoBar" class="info-bar" style="display:none">
|
||||
<span id="infoCount" class="count"></span>
|
||||
<span id="infoTime" class="time"></span>
|
||||
<span id="infoFiltered" class="badge-err"></span>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div id="loading" class="loading" style="display:none">
|
||||
<div id="loadingText">🔍 正在搜索中...</div>
|
||||
<div class="loading-bar"><div class="loading-bar-inner" id="loadingBar"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div id="tabs" class="tabs" style="display:none"></div>
|
||||
|
||||
<!-- Results -->
|
||||
<div id="results" class="results"></div>
|
||||
|
||||
<!-- Overlay -->
|
||||
<div class="overlay" id="overlay" style="display:none" onclick="closeModal()"></div>
|
||||
|
||||
<!-- Share Modal -->
|
||||
<div class="modal" id="shareModal" style="display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:101">
|
||||
<div class="modal-hdr" id="shareTitle">分享链接</div>
|
||||
<div class="modal-body">
|
||||
<div id="progressSteps" class="steps" style="display:none">
|
||||
<div class="step" id="step1"><div class="step-dot"><span>1</span></div><div class="step-body"><span class="step-title">正在转存...</span><span class="step-status doing">进行中</span></div></div>
|
||||
<div class="step" id="step2"><div class="step-dot"><span>2</span></div><div class="step-body"><span class="step-title">重命名文件(防和谐)...</span><span class="step-status wait">等待中</span></div></div>
|
||||
<div class="step" id="step3"><div class="step-dot"><span>3</span></div><div class="step-body"><span class="step-title">生成分享链接...</span><span class="step-status wait">等待中</span></div></div>
|
||||
</div>
|
||||
<div id="saveError" class="error-alert" style="display:none"></div>
|
||||
<div id="shareContent" style="display:none">
|
||||
<div class="share-qr">
|
||||
<div id="qrContainer"></div>
|
||||
<div class="qr-label" id="qrLabel"></div>
|
||||
<div class="qr-sub">保存到你自己的网盘</div>
|
||||
</div>
|
||||
<div class="share-section">
|
||||
<div class="share-row">
|
||||
<input id="shareLinkInput" type="text" readonly />
|
||||
</div>
|
||||
<div id="sharePwdRow" class="share-pwd" style="display:none">
|
||||
<span>🔑 提取密码:</span>
|
||||
<span class="pwd-tag" id="sharePwdTag"></span>
|
||||
<span class="pwd-hint">打开链接后需输入密码</span>
|
||||
</div>
|
||||
<div class="share-tip">
|
||||
<span class="warn-icon">⚠️</span>
|
||||
<div class="tip-text">
|
||||
<strong>请尽快复制链接到浏览器打开</strong> 或 <strong>用夸克APP扫码</strong><br>
|
||||
<strong>转存至您的网盘,以免资源被官方和谐</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="warning-box">
|
||||
<p class="warning-item">郑重警告一:网盘内除您所需资源外,不要打开任何不相关内容。</p>
|
||||
<p class="warning-item">郑重警告二:网盘内除您所需资源外,不要打开任何不相关内容。</p>
|
||||
<p class="warning-item">郑重警告三:网盘内除您所需资源外,不要打开任何不相关内容。</p>
|
||||
<p class="warning-item">郑重警告四:以上警告说三遍,你还要明知故犯吗?</p>
|
||||
</div>
|
||||
<div class="share-disclaimer">
|
||||
<span>⚠️ 本站资源仅供学习交流,请于24h内删除</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-ftr">
|
||||
<button class="btn-disclaimer" onclick="openDisclaimer()">📜 免责声明</button>
|
||||
<button class="btn-close" onclick="closeModal()">关闭</button>
|
||||
<button class="btn-primary" id="copyBtn2" onclick="copyShareLink()" style="display:none">一键复制链接</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login Modal -->
|
||||
<div class="modal" id="loginModal" style="display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:101">
|
||||
<div class="modal-hdr">登录</div>
|
||||
<div class="modal-body">
|
||||
<div class="login-form">
|
||||
<input id="loginUser" type="text" placeholder="用户名" />
|
||||
<input id="loginPass" type="password" placeholder="密码" />
|
||||
<button class="login-btn" id="loginBtn" onclick="handleLogin()">登录</button>
|
||||
<div class="login-err" id="loginErr"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-ftr">
|
||||
<button class="btn-close" onclick="closeLogin()">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
// ===== Anime keywords for categorization =====
|
||||
const ANIME_KWS=['仙逆','凡人修仙传','斗破苍穹','斗破','盘龙','完美世界','一念永恒','妖神记','星辰变','遮天','神墓','吞噬星空','武动乾坤','大主宰','全职高手','鬼灭之刃','海贼王','火影忍者','死神','龙珠','进击的巨人','咒术回战','一人之下','狐妖小红娘','魔道祖师','天官赐福','时光代理人','大王饶命','斗罗大陆','绝世唐门','不良人','秦时明月','全职法师','牧神记','三体','灵笼','雾山五行','凡人','仙王的日常生活','百妖谱','眷思量','镖人','伍六七','刺客伍六七','葬送的芙莉莲','间谍过家家']
|
||||
|
||||
// ===== Quotes =====
|
||||
const QUOTES=['学而时习之,不亦说乎。','温故而知新,可以为师矣。','三人行,必有我师焉。','学而不思则罔,思而不学则殆。','博学之,审问之,慎思之,明辨之,笃行之。','千里之行,始于足下。','不积跬步,无以至千里。','知之为知之,不知为不知,是知也。','工欲善其事,必先利其器。','玉不琢,不成器;人不学,不知道。','学以致用,知行合一。','学海无涯,勤作舟。','书山有路,勤为径。','宝剑锋从磨砺出,梅花香自苦寒来。','锲而不舍,金石可镂。','业精于勤,荒于嬉。','读书破万卷,下笔如有神。','路漫漫其修远兮,吾将上下而求索。','采菊东篱下,悠然见南山。','海内存知己,天涯若比邻。','长风破浪会有时,直挂云帆济沧海。','会当凌绝顶,一览众山小。','山重水复疑无路,柳暗花明又一村。']
|
||||
|
||||
// ===== Home Page =====
|
||||
function homeSearch(){
|
||||
const q=document.getElementById('homeSearchInput').value.trim()
|
||||
if(q)doSearchFromHome(q)
|
||||
}
|
||||
|
||||
function doSearchFromHome(q){
|
||||
document.getElementById('homePage').style.display='none'
|
||||
document.getElementById('searchView').style.display='block'
|
||||
document.getElementById('searchInput').value=q
|
||||
window.history.replaceState({},'','/h5?q='+encodeURIComponent(q))
|
||||
doSearch()
|
||||
}
|
||||
function renderHomePage(data){
|
||||
fetch("/api/site-config").then(r=>r.json()).then(cfg=>{
|
||||
// 显示 Logo(优先图片,其次文字)
|
||||
var logoEl=document.getElementById("homeLogo");
|
||||
var headerEl=document.getElementById("headerTitle");
|
||||
if(cfg.site_logo){
|
||||
logoEl.innerHTML='<img src="'+cfg.site_logo+'" class="home-logo-img" alt="logo" />';
|
||||
logoEl.style.display="";
|
||||
headerEl.innerHTML='<img src="'+cfg.site_logo+'" class="header-logo-img" alt="logo" />';
|
||||
headerEl.style.display="";
|
||||
}else if(cfg.site_name){
|
||||
logoEl.textContent=cfg.site_name;
|
||||
logoEl.style.display="";
|
||||
headerEl.textContent=cfg.site_name;
|
||||
headerEl.style.display="";
|
||||
}else{
|
||||
logoEl.textContent="CloudSearch";
|
||||
logoEl.style.display="";
|
||||
headerEl.textContent="CloudSearch";
|
||||
headerEl.style.display="";
|
||||
}
|
||||
if(cfg.site_disclaimer){
|
||||
document.getElementById("footerContent").innerHTML=cfg.site_disclaimer.replace(/\n/g,'<br>');
|
||||
document.getElementById("siteFooter").style.display="block";
|
||||
}
|
||||
}).catch(()=>{})
|
||||
const categories=data.categories||[]
|
||||
const fetchedAt=data.fetchedAt||''
|
||||
// Quote
|
||||
fetch('https://v1.hitokoto.cn/').then(r=>r.json()).then(d=>{
|
||||
document.getElementById('homeQuote').textContent='「 '+d.hitokoto+' 」'
|
||||
document.getElementById('homeQuoteAuthor').textContent='---'+(d.from_who||d.from||'')
|
||||
}).catch(()=>{
|
||||
document.getElementById('homeQuote').textContent='「 学而时习之,不亦说乎。 」'
|
||||
document.getElementById('homeQuoteAuthor').textContent='---孔子'
|
||||
})
|
||||
|
||||
// Store expanded state per category
|
||||
window.__expanded=window.__expanded||{}
|
||||
window.__activeTab=window.__activeTab||{}
|
||||
|
||||
const el=document.getElementById('homeRankings')
|
||||
let html=''
|
||||
for(const cat of categories){
|
||||
const icons={movie:'🎬',tv:'📺',western_movie:'🎥',western:'🌍',donghua:'🐉',global_anime:'🌐',variety:'🎤',niche:'💎',hotsite:'🏆'}
|
||||
const icon=icons[cat.category]||'📋'
|
||||
const key=cat.category
|
||||
if(!window.__activeTab[key])window.__activeTab[key]='hot'
|
||||
|
||||
html+='<div class="rank-block">'
|
||||
html+='<div class="rank-block-hdr">'+
|
||||
'<span class="rank-block-title">'+icon+' '+cat.label+'</span>'+
|
||||
'<div class="rank-block-tabs" id="rtabs-'+key+'">'+
|
||||
'<span class="rank-tab'+(window.__activeTab[key]==='hot'?' active':'')+'" onclick="switchRankTab(\''+key+'\',\'hot\')">热榜</span>'+
|
||||
'<span class="rank-tab'+(window.__activeTab[key]==='newest'?' active':'')+'" onclick="switchRankTab(\''+key+'\',\'newest\')">最新</span>'+
|
||||
'</div>'+
|
||||
'</div>'
|
||||
html+='<div class="rank-block-items" id="ritems-'+key+'" data-hot=\''+JSON.stringify({items:cat.hot||[]}).replace(/'/g,"'")+'\' data-newest=\''+JSON.stringify({items:cat.newest||[]}).replace(/'/g,"'")+'\'>'
|
||||
const items=window.__activeTab[key]==='hot'?(cat.hot||[]):(cat.newest||[])
|
||||
html+=renderRankItems(items,key,false)
|
||||
html+='</div>'
|
||||
// 数据来源
|
||||
html+='<div class="rank-block-ftr">'+
|
||||
'<span>'+(cat.category!=='hotsite'?'数据来源:TMDB':'本站搜索数据')+'</span>'+
|
||||
'<span class="ftr-time">'+fetchedAt+'</span>'+
|
||||
'</div></div>'
|
||||
}
|
||||
el.innerHTML=html
|
||||
}
|
||||
|
||||
function renderRankItems(items,key,expanded){
|
||||
if(!items||items.length===0)return'<div style="padding:10px;text-align:center;color:#c0c4cc;font-size:12px">暂无数据</div>'
|
||||
const limit=3
|
||||
const show=expanded?items.length:Math.min(limit,items.length)
|
||||
let html=items.slice(0,show).map((item,i)=>{
|
||||
const c=i<3?' rank-idx top3':' rank-idx'
|
||||
return '<div class="rank-item" onclick="doSearchFromHome(\''+item.keyword.replace(/'/g,"\\'")+'\')">'+
|
||||
'<span class="'+c+'">'+(i+1)+'</span>'+
|
||||
'<span class="rank-name">'+item.keyword+'</span>'+
|
||||
'<span class="rank-cnt">'+(item.rating?'⭐'+item.rating:item.searchCount)+'</span>'+
|
||||
'</div>'
|
||||
}).join('')
|
||||
if(items.length>limit&&!expanded){
|
||||
html+='<div class="rank-expand" onclick="expandRank(\''+key+'\')">展开全部 ▼</div>'
|
||||
}
|
||||
return html
|
||||
}
|
||||
|
||||
function expandRank(key){
|
||||
const container=document.getElementById('ritems-'+key)
|
||||
if(!container)return
|
||||
const tab=window.__activeTab[key]||'hot'
|
||||
const data=JSON.parse(tab==='hot'?container.dataset.hot:container.dataset.newest)
|
||||
container.innerHTML=renderRankItems(data.items,key,true)
|
||||
}
|
||||
|
||||
function switchRankTab(category,tab){
|
||||
window.__activeTab[category]=tab
|
||||
const tabsContainer=document.getElementById('rtabs-'+category)
|
||||
if(tabsContainer){
|
||||
tabsContainer.querySelectorAll('.rank-tab').forEach(t=>t.className='rank-tab')
|
||||
tabsContainer.querySelector(tab==='hot'?'.rank-tab:first-child':'.rank-tab:last-child').className='rank-tab active'
|
||||
}
|
||||
const container=document.getElementById('ritems-'+category)
|
||||
if(container){
|
||||
const data=JSON.parse(tab==='hot'?container.dataset.hot:container.dataset.newest)
|
||||
container.innerHTML=renderRankItems(data.items,category,false)
|
||||
}
|
||||
}
|
||||
let userInfo = null
|
||||
let allResults = []
|
||||
let allChannels = []
|
||||
let activeTab = ''
|
||||
let currentSaveItem = null
|
||||
const CLOUD_ICONS = {quark:'☁️',baidu:'🔵',aliyun:'🟠','115':'🟣',tianyi:'🔷','123pan':'🔴',uc:'🟡',xunlei:'🟢',pikpak:'🟤',magnet:'🧲',ed2k:'🔗',others:'📁'}
|
||||
const CLOUD_LABELS = {quark:'夸克网盘',baidu:'百度网盘',aliyun:'阿里云盘','115':'115网盘',tianyi:'天翼云盘','123pan':'123云盘',uc:'UC网盘',xunlei:'迅雷云盘',pikpak:'PikPak',magnet:'磁力链接',ed2k:'电驴链接',others:'其他'}
|
||||
const CLOUD_COLORS = {quark:'#07c160',baidu:'#4e6ef2',aliyun:'#ff6a00','115':'#9b59b6',tianyi:'#00a1d6','123pan':'#e74c3c',uc:'#f39c12',xunlei:'#2ecc71',pikpak:'#8e44ad',magnet:'#95a5a6',ed2k:'#7f8c8d',others:'#95a5a6'}
|
||||
const CLOUD_ORDER = {quark:1,baidu:2,aliyun:3,'115':4,tianyi:5,'123pan':6,uc:7,xunlei:8,pikpak:9,magnet:10,ed2k:11,others:12}
|
||||
|
||||
// ===== Fetch helpers =====
|
||||
function getToken(){return localStorage.getItem('h5_admin_token')}
|
||||
function apiHeaders(){const h={'Content-Type':'application/json'};const t=getToken();if(t)h['Authorization']='Bearer '+t;return h}
|
||||
|
||||
// ===== Toast =====
|
||||
let toastTimer
|
||||
function showToast(msg,isError){
|
||||
const el=document.getElementById('toast')
|
||||
el.textContent=msg
|
||||
el.className='toast show'+(isError?' error':'')
|
||||
clearTimeout(toastTimer)
|
||||
toastTimer=setTimeout(()=>el.className='toast',2000)
|
||||
}
|
||||
|
||||
// ===== User =====
|
||||
async function checkLogin(){
|
||||
try{
|
||||
const res=await fetch('/api/me',{headers:apiHeaders()})
|
||||
if(res.ok){
|
||||
const data=await res.json()
|
||||
if(data.loggedIn){
|
||||
userInfo=data
|
||||
document.getElementById('userArea').innerHTML='<span class="user-badge">'+data.username+'</span><button class="logout-btn-small" onclick="logout()">退出</button>'
|
||||
}
|
||||
}
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
function logout(){
|
||||
localStorage.removeItem('h5_admin_token')
|
||||
userInfo=null
|
||||
document.getElementById('userArea').innerHTML='<button class="login-btn-small" onclick="showLogin()">登录</button>'
|
||||
showToast('已退出')
|
||||
}
|
||||
|
||||
function showLogin(){
|
||||
document.getElementById('loginErr').textContent=''
|
||||
document.getElementById('loginUser').value=''
|
||||
document.getElementById('loginPass').value=''
|
||||
document.getElementById('loginModal').style.display='block'
|
||||
document.getElementById('overlay').style.display='block'
|
||||
}
|
||||
|
||||
function closeLogin(){
|
||||
document.getElementById('loginModal').style.display='none'
|
||||
document.getElementById('overlay').style.display='none'
|
||||
}
|
||||
|
||||
async function handleLogin(){
|
||||
const user=document.getElementById('loginUser').value.trim()
|
||||
const pass=document.getElementById('loginPass').value
|
||||
if(!user||!pass){showToast('请输入用户名和密码',true);return}
|
||||
const btn=document.getElementById('loginBtn')
|
||||
btn.disabled=true;btn.textContent='登录中...'
|
||||
try{
|
||||
const res=await fetch('/api/admin/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:user,password:pass})})
|
||||
if(res.ok){
|
||||
const data=await res.json()
|
||||
localStorage.setItem('h5_admin_token',data.token)
|
||||
userInfo={username:user}
|
||||
document.getElementById('userArea').innerHTML='<span class="user-badge">'+user+'</span><button class="logout-btn-small" onclick="logout()">退出</button>'
|
||||
closeLogin()
|
||||
showToast('登录成功')
|
||||
}else{
|
||||
const err=await res.json().catch(()=>({}))
|
||||
document.getElementById('loginErr').textContent=err.error||'登录失败'
|
||||
}
|
||||
}catch(e){
|
||||
document.getElementById('loginErr').textContent='网络错误'
|
||||
}finally{
|
||||
btn.disabled=false;btn.textContent='登录'
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Search =====
|
||||
function handleKeydown(e){if(e.key==='Enter')doSearch()}
|
||||
|
||||
let searchTimer
|
||||
function doSearch(){
|
||||
const q=document.getElementById('searchInput').value.trim()
|
||||
if(!q)return
|
||||
// Update URL
|
||||
window.history.replaceState({},'','/h5?q='+encodeURIComponent(q))
|
||||
// Show loading
|
||||
document.getElementById('results').innerHTML=''
|
||||
document.getElementById('tabs').style.display='none'
|
||||
document.getElementById('infoBar').style.display='none'
|
||||
document.getElementById('loading').style.display='block'
|
||||
document.getElementById('loadingText').textContent='🔍 正在搜索中...'
|
||||
document.getElementById('searchBtn').disabled=true
|
||||
|
||||
let progress=0
|
||||
const bar=document.getElementById('loadingBar')
|
||||
const progressTimer=setInterval(()=>{
|
||||
if(progress<60)progress+=1+Math.random()*3
|
||||
else if(progress<85)progress+=0.5+Math.random()
|
||||
bar.style.width=progress+'%'
|
||||
},200)
|
||||
|
||||
// Use streaming search for live updates
|
||||
streamSearch(q,progressTimer,bar)
|
||||
}
|
||||
|
||||
async function streamSearch(q,progressTimer,bar){
|
||||
const startTime=Date.now()
|
||||
try{
|
||||
const response=await fetch('/api/query',{method:'POST',headers:apiHeaders(),body:JSON.stringify({q})})
|
||||
if(!response.ok)throw new Error('搜索失败 ('+response.status+')')
|
||||
|
||||
const reader=response.body.getReader()
|
||||
const decoder=new TextDecoder()
|
||||
let buffer=''
|
||||
let allItems=[]
|
||||
let channels=[]
|
||||
let totalCount=0
|
||||
let filteredCount=0
|
||||
|
||||
while(true){
|
||||
const {done,value}=await reader.read()
|
||||
if(done)break
|
||||
|
||||
buffer+=decoder.decode(value,{stream:true})
|
||||
const lines=buffer.split('\n')
|
||||
buffer=lines.pop()||''
|
||||
|
||||
for(const line of lines){
|
||||
if(!line.trim())continue
|
||||
try{
|
||||
const msg=JSON.parse(line)
|
||||
if(msg.type==='stats'){
|
||||
totalCount=msg.total||0
|
||||
filteredCount=msg.filtered||0
|
||||
document.getElementById('loadingText').textContent='🔍 搜索到 '+totalCount+' 条,正在验证...'
|
||||
}else if(msg.type==='result'){
|
||||
if(msg.valid&&msg.id){
|
||||
allItems.push(msg.id)
|
||||
}
|
||||
}else if(msg.type==='complete'){
|
||||
const results=msg.results||[]
|
||||
channels=msg.channels||[]
|
||||
clearInterval(progressTimer)
|
||||
bar.style.width='100%'
|
||||
setTimeout(()=>renderResults(results,channels,totalCount,filteredCount,Date.now()-startTime),300)
|
||||
return
|
||||
}
|
||||
}catch(e){}
|
||||
}
|
||||
}
|
||||
}catch(e){
|
||||
clearInterval(progressTimer)
|
||||
document.getElementById('loading').style.display='none'
|
||||
document.getElementById('searchBtn').disabled=false
|
||||
document.getElementById('results').innerHTML='<div class="empty">搜索失败:'+e.message+'</div>'
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Render =====
|
||||
function renderResults(results,channels,totalCount,filteredCount,time){
|
||||
document.getElementById('loading').style.display='none'
|
||||
document.getElementById('searchBtn').disabled=false
|
||||
allResults=results
|
||||
allChannels=channels||[]
|
||||
|
||||
// Info bar
|
||||
if(totalCount>0){
|
||||
document.getElementById('infoBar').style.display='flex'
|
||||
document.getElementById('infoCount').textContent='已为您挑选到最符合 '+totalCount+' 条结果'
|
||||
document.getElementById('infoTime').textContent='⏱ '+time+'ms'
|
||||
if(filteredCount>0)document.getElementById('infoFiltered').textContent='❌ 失效 '+filteredCount
|
||||
else document.getElementById('infoFiltered').textContent=''
|
||||
}else{
|
||||
document.getElementById('infoBar').style.display='none'
|
||||
}
|
||||
|
||||
// Build tabs
|
||||
const tabsEl=document.getElementById('tabs')
|
||||
tabsEl.innerHTML=''
|
||||
const typeCounts={}
|
||||
for(const r of results){const ct=r.cloud_type||'others';typeCounts[ct]=(typeCounts[ct]||0)+1}
|
||||
const sorted=Object.keys(typeCounts).sort((a,b)=>(CLOUD_ORDER[a]||99)-(CLOUD_ORDER[b]||99))
|
||||
// "全部" tab
|
||||
const allTab=document.createElement('div')
|
||||
allTab.className='tab active'
|
||||
allTab.textContent='📋 全部 ('+results.length+')'
|
||||
allTab.onclick=()=>{setActiveTab('');renderCardList(results)}
|
||||
tabsEl.appendChild(allTab)
|
||||
for(const ct of sorted){
|
||||
const tab=document.createElement('div')
|
||||
tab.className='tab'
|
||||
tab.textContent=(CLOUD_ICONS[ct]||'📁')+' '+(CLOUD_LABELS[ct]||ct)+' ('+typeCounts[ct]+')'
|
||||
tab.onclick=()=>{setActiveTab(ct);renderCardList(results.filter(r=>(r.cloud_type||'others')===ct))}
|
||||
tabsEl.appendChild(tab)
|
||||
}
|
||||
tabsEl.style.display=results.length>0?'flex':'none'
|
||||
activeTab=''
|
||||
|
||||
// Render cards
|
||||
renderCardList(results)
|
||||
}
|
||||
|
||||
function setActiveTab(ct){
|
||||
activeTab=ct
|
||||
document.querySelectorAll('.tab').forEach((t,i)=>{
|
||||
const isAll=i===0&&!ct
|
||||
const active=i>0&&ct&&t.textContent.includes(CLOUD_LABELS[ct])
|
||||
t.className='tab'+(active||isAll?' active':'')
|
||||
})
|
||||
}
|
||||
|
||||
function renderCardList(items){
|
||||
const el=document.getElementById('results')
|
||||
if(items.length===0){
|
||||
el.innerHTML='<div class="empty">暂无结果</div>'
|
||||
return
|
||||
}
|
||||
el.innerHTML=items.map((item,idx)=>{
|
||||
const coverHtml=item.cover
|
||||
? '<img src="'+escapeHtml(item.cover)+'" alt="" onerror="this.parentElement.innerHTML=\'<div class=placeholder>'+escapeHtml(CLOUD_ICONS[item.cloud_type||'others'])+'</div>\'" loading="lazy" />'
|
||||
: '<div class="placeholder">'+escapeHtml(CLOUD_ICONS[item.cloud_type||'others'])+'</div>'
|
||||
const cloudLabel=CLOUD_LABELS[item.cloud_type]||item.cloud_type||''
|
||||
const cloudColor=CLOUD_COLORS[item.cloud_type]||'#95a5a6'
|
||||
const tags=extractTags(item.title||'')
|
||||
const cleanTitle=(item.title||'').replace(/【[^】]+】/g,'').trim()
|
||||
const relativeTime=formatTime(item.update_time||item.datetime||'')
|
||||
return '<div class="card" onclick="saveItem('+idx+')">'+
|
||||
'<div class="card-cover">'+coverHtml+'<span class="tag" style="background:'+cloudColor+'">'+cloudLabel+'</span></div>'+
|
||||
'<div class="card-body">'+
|
||||
'<div class="card-title">'+escapeHtml(cleanTitle)+'</div>'+
|
||||
'<div class="card-meta"><span>🕐 '+relativeTime+'</span>'+(item.file_size?'<span class="size">📦 '+escapeHtml(item.file_size)+'</span>':'')+'</div>'+
|
||||
(tags.length>0?'<div class="card-tags">'+tags.map(t=>'<span'+(isQualityTag(t)?' class="quality"':'')+'>'+escapeHtml(t)+'</span>').join('')+'</div>':'')+
|
||||
'<div class="card-actions">'+
|
||||
'<span class="card-source">'+(item.source?escapeHtml(item.source):'网盘')+'</span>'+
|
||||
'<button class="card-btn" onclick="event.stopPropagation();saveItem('+idx+')">🔗 获取分享链接</button>'+
|
||||
'</div>'+
|
||||
'</div>'+
|
||||
'</div>'
|
||||
}).join('')
|
||||
|
||||
// Store items for save reference
|
||||
window.__h5Results=items
|
||||
}
|
||||
|
||||
function escapeHtml(s){if(!s)return '';return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
|
||||
|
||||
function extractTags(title){
|
||||
const tags=[]
|
||||
// Quality tags
|
||||
const quality=['4K','1080P','2160P','720P','480P','HDR','HDR10','BluRay','REMUX','HEVC','x264','x265','WEB-DL','WEBRip']
|
||||
for(const q of quality){if(title.includes(q)&&!tags.includes(q))tags.push(q)}
|
||||
const kw=['杜比视界','杜比全景声','高码率','内封简繁英字幕','内嵌字幕','中文字幕','中英字幕']
|
||||
for(const k of kw){if(title.includes(k)&&!tags.includes(k))tags.push(k)}
|
||||
return tags.slice(0,6)
|
||||
}
|
||||
|
||||
function isQualityTag(t){const q=['4K','1080P','2160P','720P','480P','HDR','HDR10','BluRay','REMUX','HEVC','x264','x265','臻彩','高清','WEB-DL','WEBRip'];return q.includes(t)}
|
||||
|
||||
function formatTime(s){
|
||||
if(!s)return ''
|
||||
const d=new Date(s)
|
||||
if(isNaN(d.getTime()))return s.slice(0,10)
|
||||
const diff=Date.now()-d.getTime()
|
||||
if(diff<0)return s.slice(0,10)
|
||||
const mins=Math.floor(diff/60000)
|
||||
if(mins<60)return mins<=1?'刚刚':mins+' 分钟前'
|
||||
const hours=Math.floor(mins/60)
|
||||
if(hours<24)return hours+' 小时前'
|
||||
const days=Math.floor(hours/24)
|
||||
if(days<30)return days+' 天前'
|
||||
return Math.floor(days/30)+' 个月前'
|
||||
}
|
||||
|
||||
// ===== Save / Share =====
|
||||
function saveItem(idx){
|
||||
const items=window.__h5Results||[]
|
||||
currentSaveItem=items[idx]
|
||||
if(!currentSaveItem)return
|
||||
|
||||
document.getElementById('progressSteps').style.display='block'
|
||||
document.getElementById('shareContent').style.display='none'
|
||||
document.getElementById('saveError').style.display='none'
|
||||
document.getElementById('copyBtn2').style.display='none'
|
||||
|
||||
const title=(currentSaveItem.title||'').replace(/【[^】]+】/g,'').trim()||'资源'
|
||||
document.getElementById('shareTitle').textContent=title
|
||||
|
||||
// Show modal
|
||||
document.getElementById('overlay').style.display='block'
|
||||
document.getElementById('shareModal').style.display='block'
|
||||
|
||||
// Reset steps
|
||||
resetSteps()
|
||||
advanceStep(1)
|
||||
|
||||
// Call save API
|
||||
doSave()
|
||||
}
|
||||
|
||||
async function doSave(){
|
||||
try{
|
||||
const res=await fetch('/api/save',{method:'POST',headers:apiHeaders(),body:JSON.stringify({type:'search',source:currentSaveItem,target_cloud:currentSaveItem.cloud_type||'quark'})})
|
||||
const data=await res.json()
|
||||
|
||||
if(!data.success){
|
||||
document.getElementById('progressSteps').style.display='none'
|
||||
document.getElementById('saveError').style.display='flex'
|
||||
document.getElementById('saveError').textContent=data.message||data.error||'保存失败'
|
||||
return
|
||||
}
|
||||
|
||||
// Step 2
|
||||
advanceStep(2)
|
||||
await sleep(500)
|
||||
|
||||
// Step 3
|
||||
advanceStep(3)
|
||||
await sleep(300)
|
||||
|
||||
if(data.share_url){
|
||||
advanceStep(4)
|
||||
await sleep(200)
|
||||
showShareResult(data)
|
||||
}else{
|
||||
advanceStep(4)
|
||||
document.getElementById('progressSteps').style.display='none'
|
||||
document.getElementById('saveError').style.display='flex'
|
||||
document.getElementById('saveError').textContent='生成分享链接失败'
|
||||
}
|
||||
}catch(e){
|
||||
document.getElementById('progressSteps').style.display='none'
|
||||
document.getElementById('saveError').style.display='flex'
|
||||
document.getElementById('saveError').textContent=e.message||'保存请求失败'
|
||||
}
|
||||
}
|
||||
|
||||
function showShareResult(data){
|
||||
document.getElementById('progressSteps').style.display='none'
|
||||
document.getElementById('shareContent').style.display='block'
|
||||
|
||||
const link=data.share_url
|
||||
document.getElementById('shareLinkInput').value=link
|
||||
|
||||
const diskLabel=CLOUD_LABELS[currentSaveItem.cloud_type]||'夸克网盘'
|
||||
document.getElementById('qrLabel').textContent=diskLabel+' APP扫码转存'
|
||||
|
||||
// Generate QR
|
||||
const qrContainer=document.getElementById('qrContainer')
|
||||
qrContainer.innerHTML=''
|
||||
new QRCode(qrContainer,{text:link,width:140,height:140})
|
||||
|
||||
// Password
|
||||
const pwd=data.share_pwd||data.sharePwd||''
|
||||
if(pwd){
|
||||
document.getElementById('sharePwdRow').style.display='flex'
|
||||
document.getElementById('sharePwdTag').textContent=pwd
|
||||
}else{
|
||||
document.getElementById('sharePwdRow').style.display='none'
|
||||
}
|
||||
|
||||
document.getElementById('copyBtn2').style.display='inline-block'
|
||||
}
|
||||
|
||||
function resetSteps(){
|
||||
for(let i=1;i<=3;i++){
|
||||
const el=document.getElementById('step'+i)
|
||||
el.className='step'
|
||||
el.querySelector('.step-dot').innerHTML='<span>'+i+'</span>'
|
||||
el.querySelector('.step-status').textContent='等待中'
|
||||
el.querySelector('.step-status').className='step-status wait'
|
||||
}
|
||||
}
|
||||
|
||||
function advanceStep(n){
|
||||
for(let i=1;i<=3;i++){
|
||||
const el=document.getElementById('step'+i)
|
||||
if(i<n){
|
||||
el.className='step done'
|
||||
el.querySelector('.step-dot').innerHTML='<span class="step-check">✓</span>'
|
||||
el.querySelector('.step-status').textContent='已完成'
|
||||
el.querySelector('.step-status').className='step-status done'
|
||||
}else if(i===n){
|
||||
el.className='step active'
|
||||
el.querySelector('.step-dot').innerHTML='<span>'+i+'</span>'
|
||||
const titles=['正在转存到','正在重命名文件(防和谐)','正在生成分享链接']
|
||||
el.querySelector('.step-title').textContent=titles[i-1]+'...'
|
||||
el.querySelector('.step-status').textContent='进行中'
|
||||
el.querySelector('.step-status').className='step-status doing'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms){return new Promise(r=>setTimeout(r,ms))}
|
||||
|
||||
function copyShareLink(){
|
||||
const input=document.getElementById('shareLinkInput')
|
||||
if(!input.value)return
|
||||
if(navigator.clipboard&&navigator.clipboard.writeText){
|
||||
navigator.clipboard.writeText(input.value).then(()=>showToast('链接已复制')).catch(()=>fallbackCopy(input.value))
|
||||
}else{
|
||||
fallbackCopy(input.value)
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackCopy(text){
|
||||
const ta=document.createElement('textarea')
|
||||
ta.value=text;ta.style.position='fixed';ta.style.left='-9999px';document.body.appendChild(ta)
|
||||
ta.select()
|
||||
try{document.execCommand('copy');showToast('链接已复制')}catch{showToast('复制失败',true)}
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
|
||||
function openDisclaimer(){
|
||||
window.open('/disclaimer/','_blank')
|
||||
}
|
||||
|
||||
function closeModal(){
|
||||
document.getElementById('overlay').style.display='none'
|
||||
document.getElementById('shareModal').style.display='none'
|
||||
document.getElementById('loginModal').style.display='none'
|
||||
}
|
||||
|
||||
// ===== Init =====
|
||||
checkLogin()
|
||||
|
||||
// Add Enter key handler for home search
|
||||
document.getElementById('homeSearchInput').addEventListener('keydown',function(e){if(e.key==='Enter')homeSearch()})
|
||||
// Also add for search view input
|
||||
document.getElementById('searchInput').addEventListener('keydown',function(e){if(e.key==='Enter')doSearch()})
|
||||
|
||||
// Fetch home page data
|
||||
fetch('/api/rankings/categorized').then(r=>r.json()).then(data=>{
|
||||
renderHomePage(data)
|
||||
}).catch(()=>{
|
||||
document.getElementById('homeQuote').textContent='「 学而时习之,不亦说乎。 」'
|
||||
document.getElementById('homeQuoteAuthor').textContent='---孔子'
|
||||
})
|
||||
|
||||
// Check URL for query
|
||||
const params=new URLSearchParams(window.location.search)
|
||||
const q=params.get('q')
|
||||
if(q){
|
||||
document.getElementById('homePage').style.display='none'
|
||||
document.getElementById('searchView').style.display='block'
|
||||
document.getElementById('searchInput').value=q
|
||||
doSearch()
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
<!-- Footer -->
|
||||
<div id="siteFooter" class="site-footer" style="display:none">
|
||||
<div id="footerContent" class="footer-inner"></div>
|
||||
<div class="footer-actions" id="footerActions">
|
||||
<button class="footer-btn" onclick="openDisclaimer()">📜 免责声明</button>
|
||||
</div>
|
||||
</div>
|
||||
</html>
|
||||
@@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CloudSearch - 网盘资源搜索</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<script>
|
||||
(function() {
|
||||
// 替换标题为网站名称
|
||||
fetch('/api/site-config').then(r=>r.json()).then(cfg=>{
|
||||
if(cfg.site_name) document.title = cfg.site_name + ' - 网盘资源搜索';
|
||||
}).catch(function(){});
|
||||
// 跳过:参数 ?desktop=1 强制使用桌面版
|
||||
if (window.location.search.includes('desktop=1')) return;
|
||||
var ua = navigator.userAgent;
|
||||
var isMobile = /Android|webOS|iPhone|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(ua);
|
||||
var isTablet = /iPad|Android(?!.*Mobile)/i.test(ua);
|
||||
if (isMobile || isTablet) {
|
||||
window.location.replace(window.location.origin + '/h5');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-C5b4pIQL.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D-B10deg.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2600
File diff suppressed because it is too large
Load Diff
Executable
+43
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "cloudsearch-backend",
|
||||
"version": "2.0.26",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/main.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/main.js",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^11.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.0",
|
||||
"express-rate-limit": "^7.4.0",
|
||||
"helmet": "^8.0.0",
|
||||
"https-proxy-agent": "^9.0.0",
|
||||
"ioredis": "^5.4.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jsqr": "^1.4.0",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"playwright": "^1.52.0",
|
||||
"sharp": "^0.33.0",
|
||||
"socks-proxy-agent": "^10.0.0",
|
||||
"uuid": "^10.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/better-sqlite3": "^7.6.11",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
}
|
||||
Executable
+76
@@ -0,0 +1,76 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import config from '../config';
|
||||
import { getDb } from '../database/database';
|
||||
|
||||
export interface AuthPayload {
|
||||
id: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export function login(username: string, password: string): string | null {
|
||||
const db = getDb();
|
||||
const row = db.prepare('SELECT id, username, password_hash FROM admins WHERE username = ?').get(username) as any;
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
const valid = bcrypt.compareSync(password, row.password_hash);
|
||||
if (!valid) return null;
|
||||
|
||||
// Update last login
|
||||
db.prepare('UPDATE admins SET last_login = datetime(\'now\') WHERE id = ?').run(row.id);
|
||||
|
||||
// Generate JWT
|
||||
const payload: AuthPayload = { id: row.id, username: row.username };
|
||||
const token = jwt.sign(payload, config.jwtSecret, { expiresIn: '24h' });
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
export function verifyToken(token: string): AuthPayload | null {
|
||||
try {
|
||||
const decoded = jwt.verify(token, config.jwtSecret) as AuthPayload;
|
||||
return decoded;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
res.status(401).json({ error: 'Missing or invalid authorization header', code: 401 });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const payload = verifyToken(token);
|
||||
|
||||
if (!payload) {
|
||||
res.status(401).json({ error: 'Invalid or expired token', code: 401 });
|
||||
return;
|
||||
}
|
||||
|
||||
(req as any).user = payload;
|
||||
next();
|
||||
}
|
||||
|
||||
export function changePassword(username: string, oldPassword: string, newPassword: string): { success: boolean; message: string } {
|
||||
const db = getDb();
|
||||
const row = db.prepare('SELECT id, password_hash FROM admins WHERE username = ?').get(username) as any;
|
||||
if (!row) {
|
||||
return { success: false, message: '用户不存在' };
|
||||
}
|
||||
|
||||
const valid = bcrypt.compareSync(oldPassword, row.password_hash);
|
||||
if (!valid) {
|
||||
return { success: false, message: '原密码错误' };
|
||||
}
|
||||
|
||||
const salt = bcrypt.genSaltSync(10);
|
||||
const hash = bcrypt.hashSync(newPassword, salt);
|
||||
db.prepare("UPDATE admins SET password_hash = ? WHERE id = ?").run(hash, row.id);
|
||||
return { success: true, message: '密码修改成功' };
|
||||
}
|
||||
Executable
+161
@@ -0,0 +1,161 @@
|
||||
import { getDb } from '../database/database';
|
||||
import { formatLocalDate } from '../utils/time';
|
||||
|
||||
export interface AdminStats {
|
||||
todaySearches: number;
|
||||
todaySaves: number;
|
||||
monthSearches: number;
|
||||
monthSaves: number;
|
||||
totalSearches: number;
|
||||
totalSaves: number;
|
||||
hotKeywords: Array<{ keyword: string; count: number }>;
|
||||
trendTrend: Array<{ date: string; searches: number; saves: number; searchDelta: number; saveDelta: number }>;
|
||||
cloudUsage: Array<{
|
||||
cloudType: string;
|
||||
nickname: string;
|
||||
storageUsed: string;
|
||||
storageTotal: string;
|
||||
isActive: boolean;
|
||||
}>;
|
||||
topIps: Array<{ ip: string; ip_location: string | null; count: number }>;
|
||||
provinceRankings: Array<{ province: string; count: number }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get today's date string in the configured timezone (e.g. "2026-05-04").
|
||||
* Delegates to shared formatLocalDate() in utils/time.ts.
|
||||
*/
|
||||
function todayLocalDate(): string {
|
||||
return formatLocalDate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the first day of the current month in the configured timezone.
|
||||
*/
|
||||
function monthStartLocalDate(): string {
|
||||
return todayLocalDate().slice(0, 7) + '-01';
|
||||
}
|
||||
|
||||
export function getStats(trendDays: number = 7): AdminStats {
|
||||
const db = getDb();
|
||||
|
||||
// Use local timezone date — NOT UTC via toISOString()
|
||||
const today = todayLocalDate();
|
||||
const monthStart = monthStartLocalDate();
|
||||
|
||||
// IMPORTANT: created_at is stored as "YYYY-MM-DDTHH:mm:ss+08:00" (localTimestamp)
|
||||
// SQLite's date() function would interpret the +08:00 timezone offset and
|
||||
// convert to UTC, giving wrong date. Instead, use SUBSTR to get first 10 chars.
|
||||
const todaySearchesRow = db.prepare(
|
||||
"SELECT COUNT(*) as count FROM search_stats WHERE SUBSTR(created_at, 1, 10) = ?"
|
||||
).get(today) as any;
|
||||
|
||||
const todaySavesRow = db.prepare(
|
||||
"SELECT COUNT(*) as count FROM save_records WHERE SUBSTR(created_at, 1, 10) = ?"
|
||||
).get(today) as any;
|
||||
|
||||
const monthSearchesRow = db.prepare(
|
||||
"SELECT COUNT(*) as count FROM search_stats WHERE SUBSTR(created_at, 1, 10) >= ?"
|
||||
).get(monthStart) as any;
|
||||
|
||||
const monthSavesRow = db.prepare(
|
||||
"SELECT COUNT(*) as count FROM save_records WHERE SUBSTR(created_at, 1, 10) >= ?"
|
||||
).get(monthStart) as any;
|
||||
|
||||
// Total searches
|
||||
const totalSearchesRow = db.prepare(
|
||||
"SELECT COUNT(*) as count FROM search_stats"
|
||||
).get() as any;
|
||||
|
||||
// Total saves
|
||||
const totalSavesRow = db.prepare(
|
||||
"SELECT COUNT(*) as count FROM save_records"
|
||||
).get() as any;
|
||||
|
||||
// Hot keywords
|
||||
const hotKeywords = db.prepare(
|
||||
'SELECT keyword, search_count as count FROM hot_keywords ORDER BY search_count DESC LIMIT 20'
|
||||
).all() as Array<{ keyword: string; count: number }>;
|
||||
|
||||
// Trend data (configurable days, default 7)
|
||||
const trendLen = Math.min(Math.max(trendDays, 1), 90);
|
||||
const trendTrend: Array<{ date: string; searches: number; saves: number; searchDelta: number; saveDelta: number }> = [];
|
||||
for (let i = trendLen - 1; i >= 0; i--) {
|
||||
const d = new Date();
|
||||
const target = new Date(d.getTime() - i * 86400000);
|
||||
const dateStr = formatLocalDate(target);
|
||||
const searchRow = db.prepare(
|
||||
"SELECT COUNT(*) as count FROM search_stats WHERE SUBSTR(created_at, 1, 10) = ?"
|
||||
).get(dateStr) as any;
|
||||
const saveRow = db.prepare(
|
||||
"SELECT COUNT(*) as count FROM save_records WHERE SUBSTR(created_at, 1, 10) = ?"
|
||||
).get(dateStr) as any;
|
||||
trendTrend.push({
|
||||
date: dateStr,
|
||||
searches: searchRow?.count || 0,
|
||||
saves: saveRow?.count || 0,
|
||||
searchDelta: 0,
|
||||
saveDelta: 0,
|
||||
});
|
||||
}
|
||||
// Compute day-over-day delta (absolute change from previous day)
|
||||
for (let i = trendTrend.length - 1; i > 0; i--) {
|
||||
const prev = trendTrend[i - 1];
|
||||
const curr = trendTrend[i];
|
||||
curr.searchDelta = curr.searches - prev.searches;
|
||||
curr.saveDelta = curr.saves - prev.saves;
|
||||
}
|
||||
|
||||
// Cloud usage
|
||||
const cloudUsage = db.prepare(
|
||||
'SELECT cloud_type as cloudType, nickname, storage_used as storageUsed, storage_total as storageTotal, is_active as isActive FROM cloud_configs ORDER BY id ASC'
|
||||
).all() as Array<{
|
||||
cloudType: string;
|
||||
nickname: string;
|
||||
storageUsed: string;
|
||||
storageTotal: string;
|
||||
isActive: boolean;
|
||||
}>;
|
||||
|
||||
// Top IPs from save_records — correctly count total per IP, then get latest location
|
||||
const topIps = db.prepare(
|
||||
`SELECT ip_address as ip, COUNT(*) as count,
|
||||
(SELECT ip_location FROM save_records s2
|
||||
WHERE s2.ip_address = s1.ip_address AND s2.ip_location IS NOT NULL AND s2.ip_location != ''
|
||||
ORDER BY s2.created_at DESC LIMIT 1) as ip_location
|
||||
FROM save_records s1
|
||||
WHERE ip_address IS NOT NULL AND ip_address != ''
|
||||
GROUP BY ip_address
|
||||
ORDER BY count DESC LIMIT 10`
|
||||
).all() as Array<{ ip: string; ip_location: string | null; count: number }>;
|
||||
|
||||
// Province rankings — extract province from ip_location (first segment)
|
||||
let provinceRankings: Array<{ province: string; count: number }> = [];
|
||||
const locRows = db.prepare(
|
||||
`SELECT ip_location FROM save_records WHERE ip_location IS NOT NULL AND ip_location != ''`
|
||||
).all() as Array<{ ip_location: string }>;
|
||||
const provMap = new Map<string, number>();
|
||||
for (const row of locRows) {
|
||||
const parts = row.ip_location.trim().split(/\s+/);
|
||||
const province = parts[0] || '未知';
|
||||
provMap.set(province, (provMap.get(province) || 0) + 1);
|
||||
}
|
||||
provinceRankings = Array.from(provMap.entries())
|
||||
.map(([province, count]) => ({ province, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 15);
|
||||
|
||||
return {
|
||||
todaySearches: (todaySearchesRow as any)?.count || 0,
|
||||
todaySaves: (todaySavesRow as any)?.count || 0,
|
||||
monthSearches: (monthSearchesRow as any)?.count || 0,
|
||||
monthSaves: (monthSavesRow as any)?.count || 0,
|
||||
totalSearches: (totalSearchesRow as any)?.count || 0,
|
||||
totalSaves: (totalSavesRow as any)?.count || 0,
|
||||
hotKeywords,
|
||||
trendTrend,
|
||||
cloudUsage,
|
||||
topIps,
|
||||
provinceRankings,
|
||||
};
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { getDb } from '../database/database';
|
||||
import { localTimestamp } from '../utils/time';
|
||||
|
||||
export interface SystemConfigEntry {
|
||||
key: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export function getAllSystemConfigs(): SystemConfigEntry[] {
|
||||
const db = getDb();
|
||||
return db.prepare('SELECT key, value, description, updated_at FROM system_configs ORDER BY key').all() as SystemConfigEntry[];
|
||||
}
|
||||
|
||||
export function getSystemConfig(key: string): string | null {
|
||||
const db = getDb();
|
||||
const row = db.prepare('SELECT value FROM system_configs WHERE key = ?').get(key) as { value: string } | undefined;
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
export function updateSystemConfig(key: string, value: string): void {
|
||||
const db = getDb();
|
||||
db.prepare(
|
||||
"UPDATE system_configs SET value = ?, updated_at = ? WHERE key = ?"
|
||||
).run(value, localTimestamp(), key);
|
||||
}
|
||||
|
||||
export function updateSystemConfigs(entries: { key: string; value: string }[]): void {
|
||||
const db = getDb();
|
||||
const update = db.prepare(
|
||||
"UPDATE system_configs SET value = ?, updated_at = ? WHERE key = ?"
|
||||
);
|
||||
const tx = db.transaction((items: { key: string; value: string }[]) => {
|
||||
for (const item of items) {
|
||||
update.run(item.value, localTimestamp(), item.key);
|
||||
}
|
||||
});
|
||||
tx(entries);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { getDb } from '../database/database';
|
||||
import { localTimestamp, formatLocalDate } from '../utils/time';
|
||||
import { CloudConfig, getCloudConfigById } from './credential.service';
|
||||
|
||||
export async function dailyCheckIn(id: number): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
signedDays?: number;
|
||||
}> {
|
||||
const db = getDb();
|
||||
const config = db.prepare(
|
||||
'SELECT * FROM cloud_configs WHERE id = ?'
|
||||
).get(id) as CloudConfig | undefined;
|
||||
if (!config || !config.cookie) {
|
||||
return { success: false, message: '未找到该网盘的有效配置' };
|
||||
}
|
||||
try {
|
||||
const { QuarkDriver } = require('./drivers/quark.driver');
|
||||
const driver = new QuarkDriver({ cookie: config.cookie });
|
||||
const result = await driver.checkIn();
|
||||
|
||||
if (result.success) {
|
||||
db.prepare(
|
||||
`UPDATE cloud_configs SET
|
||||
checkin_status = 'success',
|
||||
last_checkin_at = datetime('now','localtime'),
|
||||
checkin_message = ?,
|
||||
consecutive_failures = 0
|
||||
WHERE id = ?`
|
||||
).run(result.message || '签到成功', id);
|
||||
} else {
|
||||
db.prepare(
|
||||
`UPDATE cloud_configs SET
|
||||
checkin_status = 'failed',
|
||||
last_checkin_at = datetime('now','localtime'),
|
||||
checkin_message = ?,
|
||||
consecutive_failures = consecutive_failures + 1
|
||||
WHERE id = ?`
|
||||
).run(result.message || '签到失败', id);
|
||||
}
|
||||
|
||||
try {
|
||||
const storage = await driver.getStorageInfo();
|
||||
db.prepare(
|
||||
`UPDATE cloud_configs SET storage_used = ?, storage_total = ? WHERE id = ?`
|
||||
).run(storage.used, storage.total, id);
|
||||
} catch {
|
||||
// Storage info fetch is best-effort
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err: any) {
|
||||
db.prepare(
|
||||
`UPDATE cloud_configs SET
|
||||
checkin_status = 'failed',
|
||||
last_checkin_at = datetime('now','localtime'),
|
||||
checkin_message = ?,
|
||||
consecutive_failures = consecutive_failures + 1
|
||||
WHERE id = ?`
|
||||
).run(err.message || '签到失败:未知错误', id);
|
||||
|
||||
return { success: false, message: `签到失败:${err.message || '未知错误'}` };
|
||||
}
|
||||
}
|
||||
|
||||
/** Get all quark drives that need checkin today. */
|
||||
export function getDrivesForCheckin(): CloudConfig[] {
|
||||
const db = getDb();
|
||||
return db.prepare(
|
||||
`SELECT * FROM cloud_configs
|
||||
WHERE cloud_type = 'quark' AND is_active = 1
|
||||
AND (checkin_status IS NULL OR checkin_status != 'success'
|
||||
OR last_checkin_at IS NULL
|
||||
OR last_checkin_at < date('now','localtime'))`
|
||||
).all() as CloudConfig[];
|
||||
}
|
||||
|
||||
/** Skip checkin for a drive. */
|
||||
export function skipCheckin(id: number): boolean {
|
||||
const db = getDb();
|
||||
const result = db.prepare(
|
||||
`UPDATE cloud_configs SET
|
||||
checkin_status = 'skipped',
|
||||
last_checkin_at = datetime('now','localtime'),
|
||||
checkin_message = '已跳过签到'
|
||||
WHERE id = ?`
|
||||
).run(id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
/** Get today's checkin summary for quark drives. */
|
||||
export function getCheckinSummary(): {
|
||||
total: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
pending: number;
|
||||
skipped: number;
|
||||
} {
|
||||
const db = getDb();
|
||||
const todayStr = formatLocalDate();
|
||||
|
||||
const all = db.prepare(
|
||||
`SELECT checkin_status, COUNT(*) as count FROM cloud_configs
|
||||
WHERE cloud_type = 'quark' AND is_active = 1
|
||||
GROUP BY checkin_status`
|
||||
).all() as { checkin_status: string; count: number }[];
|
||||
|
||||
const neverCheckedInToday = db.prepare(
|
||||
`SELECT COUNT(*) as count FROM cloud_configs
|
||||
WHERE cloud_type = 'quark' AND is_active = 1
|
||||
AND (last_checkin_at IS NULL OR last_checkin_at < ?)`
|
||||
).get(todayStr) as { count: number };
|
||||
|
||||
const summary: Record<string, number> = {
|
||||
success: 0,
|
||||
failed: 0,
|
||||
pending: 0,
|
||||
skipped: 0,
|
||||
};
|
||||
|
||||
for (const row of all) {
|
||||
if (row.checkin_status === 'none' || row.checkin_status === null) {
|
||||
summary.pending += row.count;
|
||||
} else if (row.checkin_status in summary) {
|
||||
summary[row.checkin_status] += row.count;
|
||||
}
|
||||
}
|
||||
|
||||
summary.pending += neverCheckedInToday.count;
|
||||
|
||||
const total = Object.values(summary).reduce((a, b) => a + b, 0);
|
||||
|
||||
return {
|
||||
total,
|
||||
success: summary.success,
|
||||
failed: summary.failed,
|
||||
pending: summary.pending,
|
||||
skipped: summary.skipped,
|
||||
};
|
||||
}
|
||||
Executable
+254
@@ -0,0 +1,254 @@
|
||||
import { getDb } from '../database/database';
|
||||
import { getSystemConfig, updateSystemConfig } from '../admin/system-config.service';
|
||||
import { formatLocalDate, formatLocalDateTime } from '../utils/time';
|
||||
import { QuarkDriver } from './drivers/quark.driver';
|
||||
import { BaiduDriver } from './drivers/baidu.driver';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// CloudCleanupDriver — contract that each cloud driver must fulfill
|
||||
// to participate in the cleanup cycle.
|
||||
//
|
||||
// To add a new cloud type (e.g. Baidu, Aliyun), implement these three
|
||||
// methods in the driver and register it in getDriverForCleanup() below.
|
||||
// The controller (this file) handles WHEN and WITH WHAT parameters;
|
||||
// the driver handles HOW.
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** Each cleanup operation returns { trashed: number; errors: string[] } */
|
||||
interface CleanupOpResult { trashed: number; errors: string[] }
|
||||
|
||||
interface CloudCleanupDriver {
|
||||
/** Trash date folders (YYYY-MM-DD) older than `days`. */
|
||||
cleanupOldDateFolders(days: number): Promise<CleanupOpResult>;
|
||||
/**
|
||||
* If used space exceeds thresholdPercent% of TOTAL capacity,
|
||||
* delete oldest date folders until totalBytes * deletePercent/100
|
||||
* of TOTAL capacity is freed.
|
||||
* @param thresholdPercent — trigger when usage >= this % of total
|
||||
* @param deletePercent — free this % of total capacity
|
||||
*/
|
||||
cleanupBySpaceThreshold(thresholdPercent: number, deletePercent: number): Promise<CleanupOpResult>;
|
||||
/** Permanently empty the recycle bin. */
|
||||
emptyTrash(): Promise<boolean>;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Driver factory — create the right driver for a given cloud config.
|
||||
// When adding a new cloud type, add a case here.
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function getDriverForCleanup(config: { cloud_type: string; cookie: string }): CloudCleanupDriver | null {
|
||||
switch (config.cloud_type) {
|
||||
case 'quark':
|
||||
return new QuarkDriver({ cookie: config.cookie });
|
||||
case 'baidu':
|
||||
return new BaiduDriver({ cookie: config.cookie });
|
||||
// case 'aliyun': return new AliyunDriver({ cookie: config.cookie });
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Cleanup controller — reads system configs and dispatches to each
|
||||
// active cloud driver. Every driver receives the same parameters;
|
||||
// the driver decides whether/how to act.
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
interface CleanupStats {
|
||||
filesTrashed: number;
|
||||
logsDeleted: number;
|
||||
trashEmptied: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/** Get all active cloud configs (any type). Used by the orchestrator. */
|
||||
function getActiveCleanupConfigs(): Array<{ id: number; cloud_type: string; cookie: string; nickname?: string }> {
|
||||
const db = getDb();
|
||||
return db.prepare(
|
||||
`SELECT id, cloud_type, cookie, nickname FROM cloud_configs
|
||||
WHERE is_active = 1 AND cookie IS NOT NULL AND cookie != ''`
|
||||
).all() as Array<{ id: number; cloud_type: string; cookie: string; nickname?: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch cleanupOldDateFolders to every active driver.
|
||||
* Each driver receives the same `days` parameter.
|
||||
*/
|
||||
async function cleanupCloudFiles(days: number): Promise<CleanupOpResult> {
|
||||
const configs = getActiveCleanupConfigs();
|
||||
const errors: string[] = [];
|
||||
let totalTrashed = 0;
|
||||
|
||||
for (const cfg of configs) {
|
||||
const driver = getDriverForCleanup(cfg);
|
||||
if (!driver) {
|
||||
console.log(`[Cleanup] No driver for cloud_type="${cfg.cloud_type}", skipping config #${cfg.id}`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const result = await driver.cleanupOldDateFolders(days);
|
||||
totalTrashed += result.trashed;
|
||||
errors.push(...result.errors.map(e => `[${cfg.cloud_type}#${cfg.id}] ${e}`));
|
||||
} catch (err: any) {
|
||||
errors.push(`[${cfg.cloud_type}#${cfg.id}] cleanupOldDateFolders: ${err.message}`);
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
|
||||
return { trashed: totalTrashed, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch cleanupBySpaceThreshold to every active driver.
|
||||
* Each driver receives the same threshold/delete percentages.
|
||||
*/
|
||||
async function cleanupAllBySpaceThreshold(
|
||||
thresholdPercent: number,
|
||||
deletePercent: number,
|
||||
): Promise<CleanupOpResult> {
|
||||
const configs = getActiveCleanupConfigs();
|
||||
const errors: string[] = [];
|
||||
let totalTrashed = 0;
|
||||
|
||||
for (const cfg of configs) {
|
||||
const driver = getDriverForCleanup(cfg);
|
||||
if (!driver) {
|
||||
console.log(`[Cleanup] No driver for cloud_type="${cfg.cloud_type}", skipping config #${cfg.id}`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const result = await driver.cleanupBySpaceThreshold(thresholdPercent, deletePercent);
|
||||
totalTrashed += result.trashed;
|
||||
errors.push(...result.errors.map(e => `[${cfg.cloud_type}#${cfg.id}] ${e}`));
|
||||
} catch (err: any) {
|
||||
errors.push(`[${cfg.cloud_type}#${cfg.id}] cleanupBySpaceThreshold: ${err.message}`);
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
|
||||
return { trashed: totalTrashed, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch emptyTrash to every active driver.
|
||||
*/
|
||||
export async function emptyAllTrash(): Promise<{ emptied: boolean; errors: string[] }> {
|
||||
const configs = getActiveCleanupConfigs();
|
||||
const errors: string[] = [];
|
||||
let emptied = false;
|
||||
|
||||
for (const cfg of configs) {
|
||||
const driver = getDriverForCleanup(cfg);
|
||||
if (!driver) continue;
|
||||
try {
|
||||
const ok = await driver.emptyTrash();
|
||||
if (ok) {
|
||||
emptied = true;
|
||||
console.log(`[Cleanup] ✅ Emptied trash for [${cfg.cloud_type}#${cfg.id}]`);
|
||||
} else {
|
||||
errors.push(`[${cfg.cloud_type}#${cfg.id}] empty trash failed`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
errors.push(`[${cfg.cloud_type}#${cfg.id}]: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { emptied, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete save_records older than the specified number of days.
|
||||
*/
|
||||
function cleanupLogs(days: number): number {
|
||||
const db = getDb();
|
||||
const cutoffStr = formatLocalDateTime(new Date(Date.now() - days * 24 * 60 * 60 * 1000));
|
||||
|
||||
const result = db.prepare('DELETE FROM save_records WHERE created_at < ?').run(cutoffStr);
|
||||
console.log(`[Cleanup] Deleted ${result.changes} save records older than ${days} days (before ${cutoffStr})`);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run full cleanup cycle:
|
||||
* 0. Force-clean by space threshold (if enabled & exceeded) — priority highest
|
||||
* 1. Delete old save_records
|
||||
* 2. Trash old date folders by retention days
|
||||
* 3. Empty recycle bin (permanently free space)
|
||||
*/
|
||||
export async function runFullCleanup(): Promise<CleanupStats> {
|
||||
const fileDays = parseInt(getSystemConfig('cleanup_file_retention_days') || '7', 10);
|
||||
const logDays = parseInt(getSystemConfig('cleanup_log_retention_days') || '30', 10);
|
||||
const emptyTrashEnabled = getSystemConfig('cleanup_empty_trash') !== 'false';
|
||||
|
||||
const stats: CleanupStats = { filesTrashed: 0, logsDeleted: 0, trashEmptied: false, errors: [] };
|
||||
|
||||
// 0. Space threshold (highest priority)
|
||||
const thresholdEnabled = getSystemConfig('cleanup_space_threshold_enabled');
|
||||
if (thresholdEnabled === 'true') {
|
||||
const thresholdPercent = parseInt(getSystemConfig('cleanup_space_threshold_percent') || '90', 10);
|
||||
const deletePercent = parseInt(getSystemConfig('cleanup_space_threshold_delete_percent') || '10', 10);
|
||||
if (thresholdPercent > 0 && thresholdPercent < 100) {
|
||||
try {
|
||||
const result = await cleanupAllBySpaceThreshold(thresholdPercent, deletePercent);
|
||||
stats.filesTrashed += result.trashed;
|
||||
stats.errors.push(...result.errors);
|
||||
} catch (err: any) {
|
||||
stats.errors.push(`空间阈值清理失败: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Delete old save_records
|
||||
try {
|
||||
stats.logsDeleted = cleanupLogs(logDays);
|
||||
} catch (err: any) {
|
||||
stats.errors.push(`日志清理失败: ${err.message}`);
|
||||
}
|
||||
|
||||
// 2. Trash old files from cloud drives
|
||||
try {
|
||||
const result = await cleanupCloudFiles(fileDays);
|
||||
stats.filesTrashed += result.trashed;
|
||||
stats.errors.push(...result.errors);
|
||||
} catch (err: any) {
|
||||
stats.errors.push(`文件清理失败: ${err.message}`);
|
||||
}
|
||||
|
||||
// 3. Empty recycle bin (only if enabled, and only if we trashed something)
|
||||
if (emptyTrashEnabled && stats.filesTrashed > 0) {
|
||||
try {
|
||||
const result = await emptyAllTrash();
|
||||
stats.trashEmptied = result.emptied;
|
||||
stats.errors.push(...result.errors);
|
||||
} catch (err: any) {
|
||||
stats.errors.push(`清空回收站失败: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Save last run timestamp and stats
|
||||
updateSystemConfig('cleanup_last_run', formatLocalDateTime());
|
||||
updateSystemConfig('cleanup_last_stats',
|
||||
JSON.stringify({ filesTrashed: stats.filesTrashed, logsDeleted: stats.logsDeleted, trashEmptied: stats.trashEmptied, errors: stats.errors.length })
|
||||
);
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a daily cleanup is due and run it.
|
||||
* Called periodically by the scheduler (setInterval).
|
||||
*/
|
||||
export async function checkAndRunScheduledCleanup(): Promise<void> {
|
||||
const enabled = getSystemConfig('cleanup_enabled');
|
||||
if (enabled !== 'true') return;
|
||||
|
||||
const lastRun = getSystemConfig('cleanup_last_run');
|
||||
const todayStr = formatLocalDate();
|
||||
|
||||
if (lastRun && lastRun.startsWith(todayStr)) return;
|
||||
|
||||
console.log(`[Cleanup] Scheduled cleanup starting at ${new Date().toISOString()}...`);
|
||||
const stats = await runFullCleanup();
|
||||
console.log(`[Cleanup] Done: trashed ${stats.filesTrashed} folders, deleted ${stats.logsDeleted} logs, emptied trash: ${stats.trashEmptied}, errors: ${stats.errors.length}`);
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import { getSystemConfig } from '../admin/system-config.service';
|
||||
|
||||
export interface CloudTypeInfo {
|
||||
type: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/** Official brand icons — Baidu via SimpleIcons, Aliyun via SimpleIcons Alibaba Cloud. */
|
||||
|
||||
const ICONS: Record<string, string> = {
|
||||
baidu: 'data:image/svg+xml,%3Csvg%20fill%3D%22%232932E1%22%20role%3D%22img%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EBaidu%3C%2Ftitle%3E%3Cpath%20d%3D%22M9.154%200C7.71%200%206.54%201.658%206.54%203.707c0%202.051%201.171%203.71%202.615%203.71%201.446%200%202.614-1.659%202.614-3.71C11.768%201.658%2010.6%200%209.154%200zm7.025.594C14.86.58%2013.347%202.589%2013.2%203.927c-.187%201.745.25%203.487%202.179%203.735%201.933.25%203.175-1.806%203.422-3.364.252-1.555-.995-3.364-2.362-3.674a1.218%201.218%200%200%200-.261-.03zM3.582%205.535a2.811%202.811%200%200%200-.156.008c-2.118.19-2.428%203.24-2.428%203.24-.287%201.41.686%204.425%203.297%203.864%202.617-.561%202.262-3.68%202.183-4.362-.125-1.018-1.292-2.773-2.896-2.75zm16.534%201.753c-2.308%200-2.617%202.119-2.617%203.616%200%201.43.121%203.425%202.988%203.362%202.867-.063%202.553-3.238%202.553-3.988%200-.745-.62-2.99-2.924-2.99zm-8.264%202.478c-1.424.014-2.708.925-3.323%201.947-1.118%201.868-2.863%203.05-3.112%203.363-.25.309-3.61%202.116-2.864%205.42.746%203.301%203.365%203.237%203.365%203.237s1.93.19%204.171-.31c2.24-.495%204.17.123%204.17.123s5.233%201.748%206.665-1.616c1.43-3.364-.808-5.109-.808-5.109s-2.99-2.306-4.736-4.798c-1.072-1.665-2.348-2.268-3.528-2.257zm-2.234%203.84l1.542.024v8.197H7.758c-1.47-.291-2.055-1.292-2.13-1.462-.072-.173-.488-.976-.268-2.343.635-2.049%202.447-2.196%202.447-2.196h1.81zm3.964%202.39v3.881c.096.413.612.488.612.488h1.614v-4.343h1.689v5.782h-3.915c-1.517-.39-1.59-1.465-1.59-1.465v-4.317zm-5.458%201.147c-.66.197-.978.708-1.05.928-.076.22-.247.78-.1%201.269.294%201.095%201.248%201.144%201.248%201.144h1.37v-3.34z%22%2F%3E%3C%2Fsvg%3E',
|
||||
aliyun: 'data:image/svg+xml,%3Csvg%20fill%3D%22%23FF6A00%22%20role%3D%22img%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EAlibaba%20Cloud%3C%2Ftitle%3E%3Cpath%20d%3D%22M3.996%204.517h5.291L8.01%206.324%204.153%207.506a1.668%201.668%200%200%200-1.165%201.601v5.786a1.668%201.668%200%200%200%201.165%201.6l3.857%201.183%201.277%201.807H3.996A3.996%203.996%200%200%201%200%2015.487V8.513a3.996%203.996%200%200%201%203.996-3.996m16.008%200h-5.291l1.277%201.807%203.857%201.182c.715.227%201.17.889%201.165%201.601v5.786a1.668%201.668%200%200%201-1.165%201.6l-3.857%201.183-1.277%201.807h5.291A3.996%203.996%200%200%200%2024%2015.487V8.513a3.996%203.996%200%200%200-3.996-3.996m-4.007%208.345H8.002v-1.804h7.995Z%22%2F%3E%3C%2Fsvg%3E',
|
||||
quark: 'data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20width%3D%2224%22%20height%3D%2224%22%20rx%3D%224%22%20fill%3D%22%231A7BFF%22%2F%3E%3Cpath%20d%3D%22M12%204C8%204%206%207%205%2010l3%202-2%204c3%200%205-1%206-3%201%202%203%203%206%203l-2-4%203-2c-1-3-3-6-7-6z%22%20fill%3D%22%23fff%22%20opacity%3D%22.9%22%2F%3E%3Ccircle%20cx%3D%2212%22%20cy%3D%229%22%20r%3D%222%22%20fill%3D%22%23fff%22%2F%3E%3C%2Fsvg%3E',
|
||||
'115': 'data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20width%3D%2224%22%20height%3D%2224%22%20rx%3D%224%22%20fill%3D%22%232377E0%22%2F%3E%3Ctext%20x%3D%2212%22%20y%3D%2217%22%20font-size%3D%2211%22%20font-weight%3D%22bold%22%20fill%3D%22%23fff%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2Csans-serif%22%3E115%3C%2Ftext%3E%3C%2Fsvg%3E',
|
||||
tianyi: 'data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20width%3D%2224%22%20height%3D%2224%22%20rx%3D%224%22%20fill%3D%22%230066CC%22%2F%3E%3Cpath%20d%3D%22M7%2017c-2.5%200-4.5-1.8-4.5-4.2S4.5%208.6%207%208.6c.3-2.3%202.3-4%204.7-4%202.2%200%204%201.4%204.5%203.3.3-.1.6-.1.9-.1%202.2%200%204%201.6%204%203.6S19.3%2015%2017.1%2015H7v2z%22%20fill%3D%22%23fff%22%20opacity%3D%22.9%22%2F%3E%3Cpath%20d%3D%22M14%2012l-3-3v6l3-3z%22%20fill%3D%22%230066CC%22%2F%3E%3C%2Fsvg%3E',
|
||||
'123pan': 'data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20width%3D%2224%22%20height%3D%2224%22%20rx%3D%224%22%20fill%3D%22%232875FF%22%2F%3E%3Ctext%20x%3D%2212%22%20y%3D%2217%22%20font-size%3D%2210%22%20font-weight%3D%22bold%22%20fill%3D%22%23fff%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2Csans-serif%22%3E123%3C%2Ftext%3E%3C%2Fsvg%3E',
|
||||
uc: 'data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20width%3D%2224%22%20height%3D%2224%22%20rx%3D%224%22%20fill%3D%22%23FF7A00%22%2F%3E%3Ctext%20x%3D%2212%22%20y%3D%2217%22%20font-size%3D%2212%22%20font-weight%3D%22bold%22%20fill%3D%22%23fff%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2Csans-serif%22%3EUC%3C%2Ftext%3E%3C%2Fsvg%3E',
|
||||
xunlei: 'data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20width%3D%2224%22%20height%3D%2224%22%20rx%3D%224%22%20fill%3D%22%230084FF%22%2F%3E%3Cpath%20d%3D%22M12%202L6%2013h3.5L9%2022l7-12h-3.5L12%202z%22%20fill%3D%22%23fff%22%2F%3E%3C%2Fsvg%3E',
|
||||
pikpak: 'data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20width%3D%2224%22%20height%3D%2224%22%20rx%3D%224%22%20fill%3D%22%237B2FF7%22%2F%3E%3Cpath%20d%3D%22M7%207h3v10H7V7zm7%200h3v10h-3V7z%22%20fill%3D%22%23fff%22%20opacity%3D%22.3%22%2F%3E%3Crect%20x%3D%2210%22%20y%3D%225%22%20width%3D%224%22%20height%3D%2214%22%20rx%3D%221%22%20fill%3D%22%23fff%22%2F%3E%3C%2Fsvg%3E',
|
||||
magnet: 'data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20width%3D%2224%22%20height%3D%2224%22%20rx%3D%224%22%20fill%3D%22%236366F1%22%2F%3E%3Cpath%20d%3D%22M7%2016l5-5m-5%200l5%205m5-5l-5-5m5%200l-5%205%22%20stroke%3D%22%23fff%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20fill%3D%22none%22%2F%3E%3Ccircle%20cx%3D%2212%22%20cy%3D%2211%22%20r%3D%221%22%20fill%3D%22%23fff%22%2F%3E%3C%2Fsvg%3E',
|
||||
ed2k: 'data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20width%3D%2224%22%20height%3D%2224%22%20rx%3D%224%22%20fill%3D%22%238B4513%22%2F%3E%3Ctext%20x%3D%2212%22%20y%3D%2217%22%20font-size%3D%2211%22%20font-weight%3D%22bold%22%20fill%3D%22%23fff%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2Csans-serif%22%3EeD%3C%2Ftext%3E%3C%2Fsvg%3E',
|
||||
others: 'data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20width%3D%2224%22%20height%3D%2224%22%20rx%3D%224%22%20fill%3D%22%239CA3AF%22%2F%3E%3Cpath%20d%3D%22M6%2013c0-2.8%202.2-5%205-5a5%205%200%200%201%204.5%202.7A4%204%200%200%201%2020%2014a4%204%200%200%201-3%203.9h-8A4%204%200%200%201%206%2013z%22%20fill%3D%22none%22%20stroke%3D%22%23fff%22%20stroke-width%3D%221.5%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E',
|
||||
};
|
||||
|
||||
export const ALL_CLOUD_TYPES: { type: string; label: string; icon: string }[] = [
|
||||
{ type: 'quark', label: '夸克网盘', icon: ICONS.quark },
|
||||
{ type: 'baidu', label: '百度网盘', icon: ICONS.baidu },
|
||||
{ type: 'aliyun', label: '阿里云盘', icon: ICONS.aliyun },
|
||||
{ type: '115', label: '115 网盘', icon: ICONS['115'] },
|
||||
{ type: 'tianyi', label: '天翼云盘', icon: ICONS.tianyi },
|
||||
{ type: '123pan', label: '123 云盘', icon: ICONS['123pan'] },
|
||||
{ type: 'uc', label: 'UC 网盘', icon: ICONS.uc },
|
||||
{ type: 'xunlei', label: '迅雷网盘', icon: ICONS.xunlei },
|
||||
{ type: 'pikpak', label: 'PikPak', icon: ICONS.pikpak },
|
||||
{ type: 'magnet', label: '磁力链接', icon: ICONS.magnet },
|
||||
{ type: 'ed2k', label: '电驴链接', icon: ICONS.ed2k },
|
||||
{ type: 'others', label: '其他', icon: ICONS.others },
|
||||
];
|
||||
|
||||
export function isCloudTypeEnabled(type: string): boolean {
|
||||
const val = getSystemConfig(`cloud_type_${type}_enabled`);
|
||||
if (val === null) return type !== 'others';
|
||||
return val === "true" || val === "1";
|
||||
}
|
||||
|
||||
export function getAllCloudTypes(): CloudTypeInfo[] {
|
||||
return ALL_CLOUD_TYPES.map(ct => ({ ...ct, enabled: isCloudTypeEnabled(ct.type) }));
|
||||
}
|
||||
|
||||
export function getEnabledCloudTypeSet(): Set<string> {
|
||||
const enabled = new Set<string>();
|
||||
for (const ct of ALL_CLOUD_TYPES) {
|
||||
if (isCloudTypeEnabled(ct.type)) enabled.add(ct.type);
|
||||
}
|
||||
return enabled;
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import { getDb } from '../database/database';
|
||||
import { localTimestamp, formatLocalDateTime } from '../utils/time';
|
||||
import { getSystemConfig } from '../admin/system-config.service';
|
||||
import { QuarkDriver } from './drivers/quark.driver';
|
||||
import { BaiduDriver } from './drivers/baidu.driver';
|
||||
import { CloudConfig, getAndValidateCredential, getActiveCloudConfigs } from './credential.service';
|
||||
import { lookupIpLocation } from './ip-lookup';
|
||||
|
||||
/** In-flight save dedup: prevents concurrent saves of the same URL (race condition fix) */
|
||||
const inFlightSaves = new Map<string, Promise<SaveResult>>();
|
||||
|
||||
export interface SaveResult {
|
||||
success: boolean;
|
||||
shareUrl?: string;
|
||||
share_url?: string;
|
||||
sharePwd?: string;
|
||||
folderName?: string;
|
||||
message: string;
|
||||
file_count?: number;
|
||||
folder_count?: number;
|
||||
duration_ms?: number;
|
||||
}
|
||||
|
||||
export interface SaveRecord {
|
||||
id: number;
|
||||
source_type: string;
|
||||
source_title: string | null;
|
||||
source_url: string;
|
||||
target_cloud: string;
|
||||
share_url: string | null;
|
||||
share_pwd: string | null;
|
||||
file_size: string | null;
|
||||
file_count: number;
|
||||
folder_count: number;
|
||||
duration_ms: number;
|
||||
status: string;
|
||||
error_message: string | null;
|
||||
folder_name: string | null;
|
||||
original_folder_name: string | null;
|
||||
ip_address: string | null;
|
||||
ip_location: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** Core save logic extracted so inFlight dedup can wrap it */
|
||||
async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?: string, ipAddress?: string): Promise<SaveResult> {
|
||||
const db = getDb();
|
||||
const ipLocation = await lookupIpLocation(ipAddress || '');
|
||||
|
||||
// ── Short-term dedup: prevent duplicate saves of the same URL within 60 seconds ──
|
||||
const DEDUP_WINDOW_SEC = 60;
|
||||
let dedupCutoff = '';
|
||||
try {
|
||||
const recentCutoff = db.prepare(
|
||||
`SELECT datetime('now','localtime', '-${DEDUP_WINDOW_SEC} seconds') as cutoff`
|
||||
).get() as { cutoff: string };
|
||||
dedupCutoff = recentCutoff.cutoff;
|
||||
|
||||
const recentRecord = db.prepare(
|
||||
`SELECT share_url, share_pwd, status, error_message, folder_name, original_folder_name FROM save_records
|
||||
WHERE source_url = ? AND created_at >= ?
|
||||
ORDER BY created_at DESC LIMIT 1`
|
||||
).get(shareUrl, dedupCutoff) as {
|
||||
share_url: string | null; share_pwd: string | null; status: string;
|
||||
error_message: string | null; folder_name: string | null; original_folder_name: string | null;
|
||||
} | undefined;
|
||||
|
||||
if (recentRecord) {
|
||||
const alreadySaved = recentRecord.status === 'success' || recentRecord.status === 'reused';
|
||||
if (alreadySaved && recentRecord.share_url) {
|
||||
console.log(`[Share] 🛡️ Dedup: ${shareUrl} was saved ${DEDUP_WINDOW_SEC}s ago (status=${recentRecord.status}), returning existing share link`);
|
||||
db.prepare(
|
||||
`INSERT INTO save_records (source_type, source_title, source_url, target_cloud, share_url, share_pwd, file_size, file_count, folder_count, duration_ms, status, error_message, folder_name, original_folder_name, ip_address, ip_location, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
cloudType, sourceTitle || null, shareUrl, cloudType,
|
||||
recentRecord.share_url, recentRecord.share_pwd || null,
|
||||
null, 0, 0, 0, 'reused', null,
|
||||
recentRecord.folder_name || null, recentRecord.original_folder_name || null,
|
||||
ipAddress || null, ipLocation, localTimestamp(),
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
message: `🛡️ 此资源刚在 ${DEDUP_WINDOW_SEC} 秒内转存过,直接返回已有分享链接`,
|
||||
share_url: recentRecord.share_url, shareUrl: recentRecord.share_url,
|
||||
sharePwd: recentRecord.share_pwd || '', folderName: '',
|
||||
file_count: 0, folder_count: 0, duration_ms: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.log(`[Share] Dedup check failed: ${err.message}, proceeding with normal save`);
|
||||
}
|
||||
|
||||
// ── Share link reuse: if same source URL was already saved successfully, validate and reuse ──
|
||||
const reuseEnabled = getSystemConfig('save_reuse_enabled');
|
||||
if (reuseEnabled !== 'false') {
|
||||
try {
|
||||
const existing = db.prepare(
|
||||
`SELECT share_url, share_pwd, folder_name, original_folder_name FROM save_records
|
||||
WHERE source_url = ? AND status IN ('success', 'reused') AND share_url IS NOT NULL AND share_url != ''
|
||||
ORDER BY created_at DESC LIMIT 1`
|
||||
).get(shareUrl) as { share_url: string; share_pwd: string; folder_name: string | null; original_folder_name: string | null } | undefined;
|
||||
|
||||
if (existing?.share_url) {
|
||||
const { LinkValidator } = await import('../validation/link-validator.service');
|
||||
const validator = new LinkValidator();
|
||||
const validation = await validator.validate(existing.share_url, 'quark');
|
||||
if (validation.status === 'valid') {
|
||||
const isFirstReuse = dedupCutoff ? !db.prepare(
|
||||
`SELECT 1 FROM save_records WHERE source_url = ? AND created_at >= ? AND status = 'reused' LIMIT 1`
|
||||
).get(shareUrl, dedupCutoff) : true;
|
||||
const reuseStatus = isFirstReuse ? 'success' : 'reused';
|
||||
const reuseMsg = isFirstReuse
|
||||
? `♻️ 检测到此资源之前已转存过,直接复用已存在的分享链接`
|
||||
: `♻️ 短时间内重复请求,复用已有分享链接`;
|
||||
|
||||
console.log(`[Share] ♻️ Reusing existing share link for ${shareUrl}: ${existing.share_url} (firstReuse=${isFirstReuse})`);
|
||||
db.prepare(
|
||||
`INSERT INTO save_records (source_type, source_title, source_url, target_cloud, share_url, share_pwd, file_size, file_count, folder_count, duration_ms, status, error_message, folder_name, original_folder_name, ip_address, ip_location, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
cloudType, sourceTitle || null, shareUrl, cloudType,
|
||||
existing.share_url, existing.share_pwd || null,
|
||||
null, 0, 0, 0, reuseStatus, null,
|
||||
existing.folder_name || null, existing.original_folder_name || null,
|
||||
ipAddress || null, ipLocation, localTimestamp(),
|
||||
);
|
||||
return {
|
||||
success: true, message: reuseMsg,
|
||||
share_url: existing.share_url, shareUrl: existing.share_url,
|
||||
sharePwd: existing.share_pwd || '', folderName: '',
|
||||
file_count: 0, folder_count: 0, duration_ms: 0,
|
||||
};
|
||||
}
|
||||
console.log(`[Share] Existing share link for ${shareUrl} is invalid/expired, will re-save`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.log(`[Share] Link reuse check failed: ${err.message}, proceeding with normal save`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Unified credential validation ──
|
||||
const credential = await getAndValidateCredential(cloudType);
|
||||
if (!credential.valid || !credential.config) {
|
||||
return { success: false, message: credential.message };
|
||||
}
|
||||
const config = credential.config;
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
let driverResult: { success: boolean; message: string; shareUrl?: string; sharePwd?: string; folderName?: string; fileCount?: number; folderCount?: number; originalFolderName?: string };
|
||||
|
||||
switch (cloudType) {
|
||||
case 'quark': {
|
||||
const driver = new QuarkDriver({ cookie: config.cookie!, nickname: config.nickname });
|
||||
driverResult = await driver.saveFromShare(shareUrl, sourceTitle);
|
||||
break;
|
||||
}
|
||||
case 'baidu': {
|
||||
const driver = new BaiduDriver({ cookie: config.cookie!, nickname: config.nickname });
|
||||
driverResult = await driver.saveFromShare(shareUrl, sourceTitle);
|
||||
break;
|
||||
}
|
||||
case 'aliyun':
|
||||
return { success: false, message: '阿里云盘保存功能暂未实现' };
|
||||
default:
|
||||
return { success: false, message: `暂不支持 ${cloudType} 的保存功能` };
|
||||
}
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
if (driverResult.success) {
|
||||
db.prepare(
|
||||
`UPDATE cloud_configs SET last_used_at = datetime('now','localtime'), total_saves = total_saves + 1, consecutive_failures = 0 WHERE id = ?`
|
||||
).run(config.id);
|
||||
} else if ((driverResult as any).cookieExpired) {
|
||||
// Cookie expired — don't count as failure, user needs to re-login
|
||||
} else {
|
||||
db.prepare(
|
||||
`UPDATE cloud_configs SET consecutive_failures = consecutive_failures + 1 WHERE id = ?`
|
||||
).run(config.id);
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO save_records (source_type, source_title, source_url, target_cloud, share_url, share_pwd, file_size, file_count, folder_count, duration_ms, status, error_message, folder_name, original_folder_name, ip_address, ip_location, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
cloudType, sourceTitle || driverResult.folderName || null, shareUrl, cloudType,
|
||||
driverResult.shareUrl || null, driverResult.sharePwd || null,
|
||||
null, driverResult.fileCount || 0, driverResult.folderCount || 0,
|
||||
durationMs, driverResult.success ? 'success' : 'failed',
|
||||
driverResult.success ? null : driverResult.message,
|
||||
driverResult.folderName || null, driverResult.originalFolderName || null,
|
||||
ipAddress || null, ipLocation, localTimestamp(),
|
||||
);
|
||||
|
||||
return {
|
||||
success: driverResult.success,
|
||||
message: driverResult.message,
|
||||
share_url: driverResult.shareUrl || '',
|
||||
shareUrl: driverResult.shareUrl,
|
||||
sharePwd: (driverResult as any).sharePwd || '',
|
||||
folderName: driverResult.folderName || '',
|
||||
file_count: driverResult.fileCount || 0,
|
||||
folder_count: driverResult.folderCount || 0,
|
||||
duration_ms: durationMs,
|
||||
};
|
||||
} catch (err: any) {
|
||||
const durationMs = Date.now() - startTime;
|
||||
const errorMessage = err.message || 'Failed to save to cloud';
|
||||
|
||||
db.prepare(
|
||||
`UPDATE cloud_configs SET consecutive_failures = consecutive_failures + 1 WHERE id = ?`
|
||||
).run(config.id);
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO save_records (source_type, source_url, target_cloud, duration_ms, status, error_message, ip_address, ip_location, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(cloudType, shareUrl, cloudType, durationMs, 'failed', errorMessage, ipAddress || null, ipLocation, localTimestamp());
|
||||
|
||||
return { success: false, message: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveFromShare(shareUrl: string, cloudType: string, sourceTitle?: string, ipAddress?: string): Promise<SaveResult> {
|
||||
const key = `${cloudType}:${shareUrl}`;
|
||||
|
||||
const inflight = inFlightSaves.get(key);
|
||||
if (inflight) {
|
||||
console.log(`[Share] ⏳ In-flight: ${shareUrl} — another save is already running, awaiting result`);
|
||||
return inflight;
|
||||
}
|
||||
|
||||
const promise = doSaveFromShare(shareUrl, cloudType, sourceTitle, ipAddress);
|
||||
inFlightSaves.set(key, promise);
|
||||
try {
|
||||
return await promise;
|
||||
} finally {
|
||||
inFlightSaves.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Save Records ──────────────────────────────────────────────────
|
||||
|
||||
export function getSaveRecords(page: number = 1, pageSize: number = 20, startDate?: string, endDate?: string, status?: string, sourceType?: string, keyword?: string): { total: number; records: SaveRecord[]; summary?: { total: number; success: number; failed: number; reused: number } } {
|
||||
const db = getDb();
|
||||
const offset = (page - 1) * pageSize;
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
const summaryConditions: string[] = [];
|
||||
const summaryParams: any[] = [];
|
||||
if (startDate) {
|
||||
conditions.push('created_at >= ?'); params.push(startDate);
|
||||
summaryConditions.push('created_at >= ?'); summaryParams.push(startDate);
|
||||
}
|
||||
if (endDate) {
|
||||
conditions.push('created_at < ?'); params.push(endDate);
|
||||
summaryConditions.push('created_at < ?'); summaryParams.push(endDate);
|
||||
}
|
||||
if (status) { conditions.push('status = ?'); params.push(status); }
|
||||
if (sourceType) {
|
||||
conditions.push('source_type = ?'); params.push(sourceType);
|
||||
summaryConditions.push('source_type = ?'); summaryParams.push(sourceType);
|
||||
}
|
||||
if (keyword) { conditions.push('source_title LIKE ?'); params.push(`%${keyword}%`); }
|
||||
const where = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : '';
|
||||
const total = (db.prepare(`SELECT COUNT(*) as count FROM save_records ${where}`).get(...params) as any).count;
|
||||
const records = db.prepare(
|
||||
`SELECT * FROM save_records ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`
|
||||
).all(...params, pageSize, offset) as SaveRecord[];
|
||||
|
||||
const summaryWhere = summaryConditions.length > 0 ? 'WHERE ' + summaryConditions.join(' AND ') : '';
|
||||
const summaryRows = db.prepare(
|
||||
`SELECT status, COUNT(*) as cnt FROM save_records ${summaryWhere} GROUP BY status`
|
||||
).all(...summaryParams) as { status: string; cnt: number }[];
|
||||
let sumTotal = 0, sumSuccess = 0, sumFailed = 0, sumReused = 0;
|
||||
for (const r of summaryRows) {
|
||||
sumTotal += r.cnt;
|
||||
if (r.status === 'success') sumSuccess = r.cnt;
|
||||
else if (r.status === 'failed') sumFailed = r.cnt;
|
||||
else if (r.status === 'reused') sumReused = r.cnt;
|
||||
}
|
||||
const summary = { total: sumTotal, success: sumSuccess, failed: sumFailed, reused: sumReused };
|
||||
|
||||
return { total, records, summary };
|
||||
}
|
||||
|
||||
export function cleanupOldSaveRecords(): void {
|
||||
const db = getDb();
|
||||
const cutoff = formatLocalDateTime(new Date(Date.now() - 60 * 24 * 60 * 60 * 1000));
|
||||
const deleted = db.prepare('DELETE FROM save_records WHERE created_at < ?').run(cutoff);
|
||||
console.log(`[Cleanup] Deleted ${deleted.changes} save records older than 60 days (before ${cutoff})`);
|
||||
}
|
||||
|
||||
// ── Storage Refresh ───────────────────────────────────────────────
|
||||
|
||||
export async function refreshAllStorageInfo(): Promise<void> {
|
||||
const configs = getActiveCloudConfigs().filter(c => c.cloud_type === 'quark' && c.cookie);
|
||||
if (configs.length === 0) return;
|
||||
|
||||
for (const cfg of configs) {
|
||||
try {
|
||||
const { QuarkDriver } = require('./drivers/quark.driver');
|
||||
const driver = new QuarkDriver({ cookie: cfg.cookie, nickname: cfg.nickname });
|
||||
const storage = await driver.getStorageInfo();
|
||||
if (storage.totalBytes > 0 || storage.usedBytes > 0) {
|
||||
const db = getDb();
|
||||
db.prepare(
|
||||
`UPDATE cloud_configs SET storage_used = ?, storage_total = ? WHERE id = ?`
|
||||
).run(storage.used, storage.total, cfg.id);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`[Storage] Failed to refresh quark#${cfg.id}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
import { getDb } from '../database/database';
|
||||
import { localTimestamp, formatLocalDate, formatLocalDateTime } from '../utils/time';
|
||||
|
||||
export interface CloudConfig {
|
||||
id: number;
|
||||
cloud_type: string;
|
||||
cookie?: string;
|
||||
nickname?: string;
|
||||
is_active: number;
|
||||
storage_used?: string;
|
||||
storage_total?: string;
|
||||
checkin_status: string; // 'none'|'success'|'failed'|'pending'|'skipped'
|
||||
last_checkin_at?: string;
|
||||
checkin_message?: string;
|
||||
consecutive_failures: number;
|
||||
last_used_at?: string;
|
||||
total_saves: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
verification_status?: string;
|
||||
}
|
||||
|
||||
// ── Cookie UID Extraction ────────────────────────────────────────
|
||||
|
||||
function extractCookieUid(cookie: string): string {
|
||||
if (!cookie) return '';
|
||||
let m = cookie.match(/__uid=([a-zA-Z0-9+/=_-]+)/);
|
||||
if (m) return m[1];
|
||||
m = cookie.match(/b-user-id=([a-zA-Z0-9-]+)/);
|
||||
if (m) return m[1];
|
||||
return '';
|
||||
}
|
||||
|
||||
// ── Config CRUD ──────────────────────────────────────────────────
|
||||
|
||||
export function getCloudConfigs(): CloudConfig[] {
|
||||
const db = getDb();
|
||||
return db.prepare(
|
||||
`SELECT id, cloud_type, cookie, nickname, is_active, storage_used, storage_total,
|
||||
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
|
||||
last_used_at, total_saves, created_at, updated_at, verification_status
|
||||
FROM cloud_configs ORDER BY id ASC`
|
||||
).all() as CloudConfig[];
|
||||
}
|
||||
|
||||
export function getAvailableClouds(): CloudConfig[] {
|
||||
const db = getDb();
|
||||
return db.prepare(
|
||||
`SELECT id, cloud_type, nickname, is_active, storage_used, storage_total,
|
||||
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
|
||||
last_used_at, total_saves, created_at, updated_at
|
||||
FROM cloud_configs WHERE is_active = 1 ORDER BY id ASC`
|
||||
).all() as CloudConfig[];
|
||||
}
|
||||
|
||||
/** Returns the first active config matching the given cloud type. */
|
||||
export function getCloudConfigByType(cloudType: string): CloudConfig | undefined {
|
||||
const db = getDb();
|
||||
return db.prepare(
|
||||
`SELECT id, cloud_type, cookie, nickname, is_active, storage_used, storage_total,
|
||||
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
|
||||
last_used_at, total_saves, created_at, updated_at, verification_status
|
||||
FROM cloud_configs WHERE cloud_type = ? AND is_active = 1
|
||||
ORDER BY id ASC LIMIT 1`
|
||||
).get(cloudType) as CloudConfig | undefined;
|
||||
}
|
||||
|
||||
export function getCloudConfigById(id: number): CloudConfig | undefined {
|
||||
const db = getDb();
|
||||
return db.prepare(
|
||||
`SELECT id, cloud_type, cookie, nickname, is_active, storage_used, storage_total,
|
||||
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
|
||||
last_used_at, total_saves, created_at, updated_at, verification_status
|
||||
FROM cloud_configs WHERE id = ?`
|
||||
).get(id) as CloudConfig | undefined;
|
||||
}
|
||||
|
||||
/** Returns all active cloud configs (used by save flow for cloud type switching). */
|
||||
export function getActiveCloudConfigs(): CloudConfig[] {
|
||||
const db = getDb();
|
||||
return db.prepare(
|
||||
`SELECT id, cloud_type, cookie, nickname, is_active, storage_used, storage_total,
|
||||
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
|
||||
last_used_at, total_saves, created_at, updated_at
|
||||
FROM cloud_configs WHERE is_active = 1
|
||||
ORDER BY cloud_type ASC, id ASC`
|
||||
).all() as CloudConfig[];
|
||||
}
|
||||
|
||||
export function saveCloudConfig(data: {
|
||||
id?: number;
|
||||
cloud_type: string;
|
||||
cookie?: string;
|
||||
nickname?: string;
|
||||
cookie_uid?: string;
|
||||
promotion_account?: string;
|
||||
is_active?: number;
|
||||
storage_used?: string;
|
||||
storage_total?: string;
|
||||
}): CloudConfig {
|
||||
const db = getDb();
|
||||
|
||||
const cookieUidForUpdate = data.cookie ? extractCookieUid(data.cookie) : null;
|
||||
|
||||
if (data.id) {
|
||||
db.prepare(
|
||||
`UPDATE cloud_configs SET
|
||||
cloud_type = COALESCE(?, cloud_type),
|
||||
cookie = COALESCE(?, cookie),
|
||||
nickname = COALESCE(?, nickname),
|
||||
cookie_uid = COALESCE(?, cookie_uid),
|
||||
promotion_account = COALESCE(?, promotion_account),
|
||||
is_active = COALESCE(?, is_active),
|
||||
storage_used = COALESCE(?, storage_used),
|
||||
storage_total = COALESCE(?, storage_total),
|
||||
consecutive_failures = 0,
|
||||
updated_at = ?
|
||||
WHERE id = ?`
|
||||
).run(data.cloud_type, data.cookie || null, data.nickname || null, cookieUidForUpdate || null, data.promotion_account || null, data.is_active ?? 1, data.storage_used || null, data.storage_total || null, localTimestamp(), data.id);
|
||||
} else {
|
||||
const existing = db.prepare(
|
||||
'SELECT id, nickname FROM cloud_configs WHERE cloud_type = ? AND is_active = 1 LIMIT 1'
|
||||
).get(data.cloud_type) as any;
|
||||
if (existing) {
|
||||
db.prepare(
|
||||
`UPDATE cloud_configs SET
|
||||
cookie = COALESCE(?, cookie),
|
||||
nickname = COALESCE(?, nickname),
|
||||
cookie_uid = COALESCE(?, cookie_uid),
|
||||
promotion_account = COALESCE(?, promotion_account),
|
||||
is_active = COALESCE(?, is_active),
|
||||
storage_used = COALESCE(?, storage_used),
|
||||
storage_total = COALESCE(?, storage_total),
|
||||
consecutive_failures = 0,
|
||||
updated_at = ?
|
||||
WHERE id = ?`
|
||||
).run(data.cookie || null, data.nickname || null, cookieUidForUpdate || null, data.promotion_account || null, data.is_active ?? 1, data.storage_used || null, data.storage_total || null, localTimestamp(), existing.id);
|
||||
} else {
|
||||
db.prepare(
|
||||
'INSERT INTO cloud_configs (cloud_type, cookie, nickname, cookie_uid, promotion_account, is_active, storage_used, storage_total, consecutive_failures) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)'
|
||||
).run(data.cloud_type, data.cookie || null, data.nickname || null, cookieUidForUpdate || null, data.promotion_account || null, data.is_active ?? 1, data.storage_used || null, data.storage_total || null);
|
||||
}
|
||||
}
|
||||
|
||||
const savedId = data.id || (db.prepare('SELECT last_insert_rowid() as id').get() as any).id;
|
||||
return db.prepare(
|
||||
`SELECT id, cloud_type, cookie, nickname, is_active, storage_used, storage_total,
|
||||
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
|
||||
last_used_at, total_saves, created_at, updated_at
|
||||
FROM cloud_configs WHERE id = ?`
|
||||
).get(savedId) as CloudConfig;
|
||||
}
|
||||
|
||||
export function deleteCloudConfig(id: number): boolean {
|
||||
const db = getDb();
|
||||
const result = db.prepare('DELETE FROM cloud_configs WHERE id = ?').run(id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
// ── Cookie Validation ────────────────────────────────────────────
|
||||
|
||||
async function fetchQuarkNickname(cookie: string): Promise<string | null> {
|
||||
const MAX_RETRIES = 2;
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const response = await fetch('https://pan.quark.cn/account/info', {
|
||||
headers: {
|
||||
'Cookie': cookie,
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
'Referer': 'https://pan.quark.cn/',
|
||||
},
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json() as any;
|
||||
if (data?.data?.nickname) return data.data.nickname;
|
||||
} catch {
|
||||
if (attempt < MAX_RETRIES) {
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function testCloudConnection(id: number): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
nickname?: string;
|
||||
storage_used?: string;
|
||||
storage_total?: string;
|
||||
}> {
|
||||
const config = getCloudConfigById(id);
|
||||
if (!config) {
|
||||
return { success: false, message: 'Cloud config not found' };
|
||||
}
|
||||
|
||||
if (!config.cookie) {
|
||||
return { success: false, message: 'Cookie not configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
let valid = false;
|
||||
let nickname = '';
|
||||
let storageUsed = config.storage_used || '';
|
||||
let storageTotal = config.storage_total || '';
|
||||
|
||||
if (config.cloud_type === 'baidu') {
|
||||
const { BaiduDriver } = require('./drivers/baidu.driver');
|
||||
const driver = new BaiduDriver({ cookie: config.cookie, nickname: config.nickname });
|
||||
valid = await driver.validate();
|
||||
if (valid) {
|
||||
const info = await driver.getUserInfo();
|
||||
if (info) {
|
||||
nickname = config.nickname || info.nickname || '百度网盘';
|
||||
const fmt = (b: number) => b >= 1024**3 ? (b/1024**3).toFixed(2)+' GB' : (b/1024**2).toFixed(2)+' MB';
|
||||
storageUsed = fmt(info.usedBytes);
|
||||
storageTotal = fmt(info.totalBytes);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const { QuarkDriver } = require('./drivers/quark.driver');
|
||||
const driver = new QuarkDriver({ cookie: config.cookie, nickname: config.nickname });
|
||||
valid = await driver.validate();
|
||||
if (valid) {
|
||||
nickname = config.nickname || (await fetchQuarkNickname(config.cookie)) || '夸克网盘';
|
||||
const storage = await driver.getStorageInfoQuick();
|
||||
storageTotal = (storage.total !== '-' && storage.total !== '0 B') ? storage.total : (config.storage_total || '');
|
||||
}
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
if (!valid) {
|
||||
db.prepare(
|
||||
`UPDATE cloud_configs SET verification_status = 'invalid', updated_at = ? WHERE id = ?`
|
||||
).run(localTimestamp(), id);
|
||||
return { success: false, message: '连接失败:Cookie 无效或已过期,或网络暂时异常' };
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`UPDATE cloud_configs SET nickname = ?, storage_total = ?, storage_used = ?, is_active = 1, verification_status = 'valid', updated_at = ? WHERE id = ?`
|
||||
).run(nickname, storageTotal, storageUsed, localTimestamp(), id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '连接成功',
|
||||
nickname,
|
||||
storage_used: storageUsed,
|
||||
storage_total: storageTotal,
|
||||
};
|
||||
} catch (err: any) {
|
||||
try {
|
||||
const db = getDb();
|
||||
db.prepare(
|
||||
`UPDATE cloud_configs SET verification_status = 'invalid', updated_at = ? WHERE id = ?`
|
||||
).run(localTimestamp(), id);
|
||||
} catch {}
|
||||
return { success: false, message: `连接失败:${err.message || '未知错误'}` };
|
||||
}
|
||||
}
|
||||
|
||||
export async function testCloudConnectionWithCookie(cloudType: string, cookie: string): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
nickname?: string;
|
||||
storage_used?: string;
|
||||
storage_total?: string;
|
||||
}> {
|
||||
try {
|
||||
const { QuarkDriver } = require('./drivers/quark.driver');
|
||||
const driver = new QuarkDriver({ cookie, nickname: '' });
|
||||
const valid = await driver.validate();
|
||||
if (!valid) {
|
||||
return { success: false, message: '连接失败:Cookie 无效或已过期' };
|
||||
}
|
||||
const nickname = (await fetchQuarkNickname(cookie)) || cloudType;
|
||||
const storage = await driver.getStorageInfo();
|
||||
return {
|
||||
success: true,
|
||||
message: '连接成功',
|
||||
nickname,
|
||||
storage_used: storage.used,
|
||||
storage_total: storage.total,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return { success: false, message: `连接失败:${err.message || '未知错误'}` };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Unified Credential Validation ─────────────────────────────────
|
||||
|
||||
export interface CredentialValidationResult {
|
||||
valid: boolean;
|
||||
config?: CloudConfig;
|
||||
errorCode?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get and validate a credential for the given cloud type.
|
||||
*
|
||||
* This is the unified entry point for all save/transfer operations.
|
||||
* It handles:
|
||||
* 1. Finding an active config with < 5 consecutive failures (round-robin)
|
||||
* 2. Validating cookie freshness via driver.validate()
|
||||
* 3. Returning structured result with error codes
|
||||
*
|
||||
* Reference: search-ucmao get_and_validate_credential() pattern.
|
||||
*/
|
||||
export async function getAndValidateCredential(cloudType: string): Promise<CredentialValidationResult> {
|
||||
const db = getDb();
|
||||
|
||||
const config = db.prepare(
|
||||
`SELECT * FROM cloud_configs
|
||||
WHERE cloud_type = ? AND is_active = 1
|
||||
AND consecutive_failures < 5
|
||||
ORDER BY last_used_at ASC NULLS FIRST
|
||||
LIMIT 1`
|
||||
).get(cloudType) as CloudConfig | undefined;
|
||||
|
||||
if (!config) {
|
||||
return {
|
||||
valid: false,
|
||||
errorCode: 'NO_AVAILABLE_DRIVE',
|
||||
message: `Cloud type "${cloudType}" is not configured or no available drives`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!config.cookie) {
|
||||
return {
|
||||
valid: false,
|
||||
errorCode: 'COOKIE_MISSING',
|
||||
message: `Cookie not configured for ${cloudType} drive #${config.id}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
let cookieValid = false;
|
||||
if (cloudType === 'baidu') {
|
||||
const { BaiduDriver } = require('./drivers/baidu.driver');
|
||||
const driver = new BaiduDriver({ cookie: config.cookie, nickname: config.nickname });
|
||||
cookieValid = await driver.validate();
|
||||
} else {
|
||||
const { QuarkDriver } = require('./drivers/quark.driver');
|
||||
const driver = new QuarkDriver({ cookie: config.cookie, nickname: config.nickname });
|
||||
cookieValid = await driver.validate();
|
||||
}
|
||||
|
||||
if (!cookieValid) {
|
||||
db.prepare(
|
||||
`UPDATE cloud_configs SET verification_status = 'invalid', updated_at = ? WHERE id = ?`
|
||||
).run(localTimestamp(), config.id);
|
||||
return {
|
||||
valid: false,
|
||||
errorCode: 'COOKIE_EXPIRED',
|
||||
message: `Cookie expired or invalid for ${cloudType} drive #${config.id}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
config,
|
||||
message: 'ok',
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
valid: false,
|
||||
errorCode: 'VALIDATION_ERROR',
|
||||
message: `Credential validation failed: ${err.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import { getDb } from '../database/database';
|
||||
import { localTimestamp, formatLocalDate, formatLocalDateTime } from '../utils/time';
|
||||
|
||||
export interface CloudConfig {
|
||||
id: number;
|
||||
cloud_type: string;
|
||||
cookie?: string;
|
||||
nickname?: string;
|
||||
is_active: number;
|
||||
storage_used?: string;
|
||||
storage_total?: string;
|
||||
checkin_status: string; // 'none'|'success'|'failed'|'pending'|'skipped'
|
||||
last_checkin_at?: string;
|
||||
checkin_message?: string;
|
||||
consecutive_failures: number;
|
||||
last_used_at?: string;
|
||||
total_saves: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
verification_status?: string;
|
||||
}
|
||||
|
||||
// ── Config CRUD ──────────────────────────────────────────────────
|
||||
|
||||
export function getCloudConfigs(): CloudConfig[] {
|
||||
const db = getDb();
|
||||
return db.prepare(
|
||||
`SELECT id, cloud_type, cookie, nickname, is_active, storage_used, storage_total,
|
||||
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
|
||||
last_used_at, total_saves, created_at, updated_at, verification_status
|
||||
FROM cloud_configs ORDER BY id ASC`
|
||||
).all() as CloudConfig[];
|
||||
}
|
||||
|
||||
export function getAvailableClouds(): CloudConfig[] {
|
||||
const db = getDb();
|
||||
return db.prepare(
|
||||
`SELECT id, cloud_type, nickname, is_active, storage_used, storage_total,
|
||||
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
|
||||
last_used_at, total_saves, created_at, updated_at
|
||||
FROM cloud_configs WHERE is_active = 1 ORDER BY id ASC`
|
||||
).all() as CloudConfig[];
|
||||
}
|
||||
|
||||
/** Returns the first active config matching the given cloud type. */
|
||||
export function getCloudConfigByType(cloudType: string): CloudConfig | undefined {
|
||||
const db = getDb();
|
||||
return db.prepare(
|
||||
`SELECT id, cloud_type, cookie, nickname, is_active, storage_used, storage_total,
|
||||
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
|
||||
last_used_at, total_saves, created_at, updated_at, verification_status
|
||||
FROM cloud_configs WHERE cloud_type = ? AND is_active = 1
|
||||
ORDER BY id ASC LIMIT 1`
|
||||
).get(cloudType) as CloudConfig | undefined;
|
||||
}
|
||||
|
||||
export function getCloudConfigById(id: number): CloudConfig | undefined {
|
||||
const db = getDb();
|
||||
return db.prepare(
|
||||
`SELECT id, cloud_type, cookie, nickname, is_active, storage_used, storage_total,
|
||||
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
|
||||
last_used_at, total_saves, created_at, updated_at, verification_status
|
||||
FROM cloud_configs WHERE id = ?`
|
||||
).get(id) as CloudConfig | undefined;
|
||||
}
|
||||
|
||||
/** Returns all active cloud configs (used by save flow for cloud type switching). */
|
||||
export function getActiveCloudConfigs(): CloudConfig[] {
|
||||
const db = getDb();
|
||||
return db.prepare(
|
||||
`SELECT id, cloud_type, cookie, nickname, is_active, storage_used, storage_total,
|
||||
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
|
||||
last_used_at, total_saves, created_at, updated_at
|
||||
FROM cloud_configs WHERE is_active = 1
|
||||
ORDER BY cloud_type ASC, id ASC`
|
||||
).all() as CloudConfig[];
|
||||
}
|
||||
|
||||
export function saveCloudConfig(data: {
|
||||
id?: number;
|
||||
cloud_type: string;
|
||||
cookie?: string;
|
||||
nickname?: string;
|
||||
is_active?: number;
|
||||
storage_used?: string;
|
||||
storage_total?: string;
|
||||
}): CloudConfig {
|
||||
const db = getDb();
|
||||
|
||||
if (data.id) {
|
||||
db.prepare(
|
||||
`UPDATE cloud_configs SET
|
||||
cloud_type = COALESCE(?, cloud_type),
|
||||
cookie = COALESCE(?, cookie),
|
||||
nickname = COALESCE(?, nickname),
|
||||
is_active = COALESCE(?, is_active),
|
||||
storage_used = COALESCE(?, storage_used),
|
||||
storage_total = COALESCE(?, storage_total),
|
||||
consecutive_failures = 0,
|
||||
updated_at = ?
|
||||
WHERE id = ?`
|
||||
).run(data.cloud_type, data.cookie || null, data.nickname || null, data.is_active ?? 1, data.storage_used || null, data.storage_total || null, localTimestamp(), data.id);
|
||||
} else {
|
||||
const existing = db.prepare(
|
||||
'SELECT id, nickname FROM cloud_configs WHERE cloud_type = ? AND is_active = 1 LIMIT 1'
|
||||
).get(data.cloud_type) as any;
|
||||
if (existing) {
|
||||
db.prepare(
|
||||
`UPDATE cloud_configs SET
|
||||
cookie = COALESCE(?, cookie),
|
||||
nickname = COALESCE(?, nickname),
|
||||
is_active = COALESCE(?, is_active),
|
||||
storage_used = COALESCE(?, storage_used),
|
||||
storage_total = COALESCE(?, storage_total),
|
||||
consecutive_failures = 0,
|
||||
updated_at = ?
|
||||
WHERE id = ?`
|
||||
).run(data.cookie || null, data.nickname || null, data.is_active ?? 1, data.storage_used || null, data.storage_total || null, localTimestamp(), existing.id);
|
||||
} else {
|
||||
db.prepare(
|
||||
'INSERT INTO cloud_configs (cloud_type, cookie, nickname, is_active, storage_used, storage_total, consecutive_failures) VALUES (?, ?, ?, ?, ?, ?, 0)'
|
||||
).run(data.cloud_type, data.cookie || null, data.nickname || null, data.is_active ?? 1, data.storage_used || null, data.storage_total || null);
|
||||
}
|
||||
}
|
||||
|
||||
const savedId = data.id || (db.prepare('SELECT last_insert_rowid() as id').get() as any).id;
|
||||
return db.prepare(
|
||||
`SELECT id, cloud_type, cookie, nickname, is_active, storage_used, storage_total,
|
||||
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
|
||||
last_used_at, total_saves, created_at, updated_at
|
||||
FROM cloud_configs WHERE id = ?`
|
||||
).get(savedId) as CloudConfig;
|
||||
}
|
||||
|
||||
export function deleteCloudConfig(id: number): boolean {
|
||||
const db = getDb();
|
||||
const result = db.prepare('DELETE FROM cloud_configs WHERE id = ?').run(id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
// ── Cookie Validation ────────────────────────────────────────────
|
||||
|
||||
async function fetchQuarkNickname(cookie: string): Promise<string | null> {
|
||||
const MAX_RETRIES = 2;
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const response = await fetch('https://pan.quark.cn/account/info', {
|
||||
headers: {
|
||||
'Cookie': cookie,
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
'Referer': 'https://pan.quark.cn/',
|
||||
},
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json() as any;
|
||||
if (data?.data?.nickname) return data.data.nickname;
|
||||
} catch {
|
||||
if (attempt < MAX_RETRIES) {
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function testCloudConnection(id: number): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
nickname?: string;
|
||||
storage_used?: string;
|
||||
storage_total?: string;
|
||||
}> {
|
||||
const config = getCloudConfigById(id);
|
||||
if (!config) {
|
||||
return { success: false, message: 'Cloud config not found' };
|
||||
}
|
||||
|
||||
if (!config.cookie) {
|
||||
return { success: false, message: 'Cookie not configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
let valid = false;
|
||||
let nickname = '';
|
||||
let storageUsed = config.storage_used || '';
|
||||
let storageTotal = config.storage_total || '';
|
||||
|
||||
if (config.cloud_type === 'baidu') {
|
||||
const { BaiduDriver } = require('./drivers/baidu.driver');
|
||||
const driver = new BaiduDriver({ cookie: config.cookie, nickname: config.nickname });
|
||||
valid = await driver.validate();
|
||||
if (valid) {
|
||||
const info = await driver.getUserInfo();
|
||||
if (info) {
|
||||
nickname = config.nickname || info.nickname || '百度网盘';
|
||||
const fmt = (b: number) => b >= 1024**3 ? (b/1024**3).toFixed(2)+' GB' : (b/1024**2).toFixed(2)+' MB';
|
||||
storageUsed = fmt(info.usedBytes);
|
||||
storageTotal = fmt(info.totalBytes);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const { QuarkDriver } = require('./drivers/quark.driver');
|
||||
const driver = new QuarkDriver({ cookie: config.cookie, nickname: config.nickname });
|
||||
valid = await driver.validate();
|
||||
if (valid) {
|
||||
nickname = config.nickname || (await fetchQuarkNickname(config.cookie)) || '夸克网盘';
|
||||
const storage = await driver.getStorageInfoQuick();
|
||||
storageTotal = (storage.total !== '-' && storage.total !== '0 B') ? storage.total : (config.storage_total || '');
|
||||
}
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
if (!valid) {
|
||||
db.prepare(
|
||||
`UPDATE cloud_configs SET verification_status = 'invalid', updated_at = ? WHERE id = ?`
|
||||
).run(localTimestamp(), id);
|
||||
return { success: false, message: '连接失败:Cookie 无效或已过期,或网络暂时异常' };
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`UPDATE cloud_configs SET nickname = ?, storage_total = ?, storage_used = ?, is_active = 1, verification_status = 'valid', updated_at = ? WHERE id = ?`
|
||||
).run(nickname, storageTotal, storageUsed, localTimestamp(), id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '连接成功',
|
||||
nickname,
|
||||
storage_used: storageUsed,
|
||||
storage_total: storageTotal,
|
||||
};
|
||||
} catch (err: any) {
|
||||
try {
|
||||
const db = getDb();
|
||||
db.prepare(
|
||||
`UPDATE cloud_configs SET verification_status = 'invalid', updated_at = ? WHERE id = ?`
|
||||
).run(localTimestamp(), id);
|
||||
} catch {}
|
||||
return { success: false, message: `连接失败:${err.message || '未知错误'}` };
|
||||
}
|
||||
}
|
||||
|
||||
export async function testCloudConnectionWithCookie(cloudType: string, cookie: string): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
nickname?: string;
|
||||
storage_used?: string;
|
||||
storage_total?: string;
|
||||
}> {
|
||||
try {
|
||||
const { QuarkDriver } = require('./drivers/quark.driver');
|
||||
const driver = new QuarkDriver({ cookie, nickname: '' });
|
||||
const valid = await driver.validate();
|
||||
if (!valid) {
|
||||
return { success: false, message: '连接失败:Cookie 无效或已过期' };
|
||||
}
|
||||
const nickname = (await fetchQuarkNickname(cookie)) || cloudType;
|
||||
const storage = await driver.getStorageInfo();
|
||||
return {
|
||||
success: true,
|
||||
message: '连接成功',
|
||||
nickname,
|
||||
storage_used: storage.used,
|
||||
storage_total: storage.total,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return { success: false, message: `连接失败:${err.message || '未知错误'}` };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Unified Credential Validation ─────────────────────────────────
|
||||
|
||||
export interface CredentialValidationResult {
|
||||
valid: boolean;
|
||||
config?: CloudConfig;
|
||||
errorCode?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get and validate a credential for the given cloud type.
|
||||
*
|
||||
* This is the unified entry point for all save/transfer operations.
|
||||
* It handles:
|
||||
* 1. Finding an active config with < 5 consecutive failures (round-robin)
|
||||
* 2. Validating cookie freshness via driver.validate()
|
||||
* 3. Returning structured result with error codes
|
||||
*
|
||||
* Reference: search-ucmao get_and_validate_credential() pattern.
|
||||
*/
|
||||
export async function getAndValidateCredential(cloudType: string): Promise<CredentialValidationResult> {
|
||||
const db = getDb();
|
||||
|
||||
const config = db.prepare(
|
||||
`SELECT * FROM cloud_configs
|
||||
WHERE cloud_type = ? AND is_active = 1
|
||||
AND consecutive_failures < 5
|
||||
ORDER BY last_used_at ASC NULLS FIRST
|
||||
LIMIT 1`
|
||||
).get(cloudType) as CloudConfig | undefined;
|
||||
|
||||
if (!config) {
|
||||
return {
|
||||
valid: false,
|
||||
errorCode: 'NO_AVAILABLE_DRIVE',
|
||||
message: `Cloud type "${cloudType}" is not configured or no available drives`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!config.cookie) {
|
||||
return {
|
||||
valid: false,
|
||||
errorCode: 'COOKIE_MISSING',
|
||||
message: `Cookie not configured for ${cloudType} drive #${config.id}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
let cookieValid = false;
|
||||
if (cloudType === 'baidu') {
|
||||
const { BaiduDriver } = require('./drivers/baidu.driver');
|
||||
const driver = new BaiduDriver({ cookie: config.cookie, nickname: config.nickname });
|
||||
cookieValid = await driver.validate();
|
||||
} else {
|
||||
const { QuarkDriver } = require('./drivers/quark.driver');
|
||||
const driver = new QuarkDriver({ cookie: config.cookie, nickname: config.nickname });
|
||||
cookieValid = await driver.validate();
|
||||
}
|
||||
|
||||
if (!cookieValid) {
|
||||
db.prepare(
|
||||
`UPDATE cloud_configs SET verification_status = 'invalid', updated_at = ? WHERE id = ?`
|
||||
).run(localTimestamp(), config.id);
|
||||
return {
|
||||
valid: false,
|
||||
errorCode: 'COOKIE_EXPIRED',
|
||||
message: `Cookie expired or invalid for ${cloudType} drive #${config.id}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
config,
|
||||
message: 'ok',
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
valid: false,
|
||||
errorCode: 'VALIDATION_ERROR',
|
||||
message: `Credential validation failed: ${err.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
+113
@@ -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
+1533
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,70 @@
|
||||
// Standard error codes for all cloud drivers
|
||||
export const ErrCode = {
|
||||
COOKIE_EXPIRED: 'COOKIE_EXPIRED',
|
||||
COOKIE_INVALID: 'COOKIE_INVALID',
|
||||
TOKEN_EXPIRED: 'TOKEN_EXPIRED',
|
||||
SHARE_NOT_FOUND: 'SHARE_NOT_FOUND',
|
||||
SHARE_EXPIRED: 'SHARE_EXPIRED',
|
||||
PASSWORD_REQUIRED: 'PASSWORD_REQUIRED',
|
||||
PASSWORD_WRONG: 'PASSWORD_WRONG',
|
||||
CAPACITY_FULL: 'CAPACITY_FULL',
|
||||
FILE_EXISTS: 'FILE_EXISTS',
|
||||
RATE_LIMITED: 'RATE_LIMITED',
|
||||
TRANSFER_FAILED: 'TRANSFER_FAILED',
|
||||
NETWORK_ERROR: 'NETWORK_ERROR',
|
||||
UNSUPPORTED: 'UNSUPPORTED',
|
||||
UNKNOWN: 'UNKNOWN',
|
||||
} as const;
|
||||
|
||||
export type ErrorCode = typeof ErrCode[keyof typeof ErrCode];
|
||||
|
||||
const messages: Record<string, string> = {
|
||||
[ErrCode.COOKIE_EXPIRED]: 'Cookie已过期,请重新登录',
|
||||
[ErrCode.COOKIE_INVALID]: 'Cookie无效,请检查配置',
|
||||
[ErrCode.TOKEN_EXPIRED]: 'Token已过期,请刷新',
|
||||
[ErrCode.SHARE_NOT_FOUND]: '分享链接不存在或已被删除',
|
||||
[ErrCode.SHARE_EXPIRED]: '分享链接已过期',
|
||||
[ErrCode.PASSWORD_REQUIRED]: '需要提取码',
|
||||
[ErrCode.PASSWORD_WRONG]: '提取码错误',
|
||||
[ErrCode.CAPACITY_FULL]: '网盘容量不足',
|
||||
[ErrCode.RATE_LIMITED]: '请求过于频繁,请稍后重试',
|
||||
[ErrCode.TRANSFER_FAILED]: '转存失败',
|
||||
[ErrCode.NETWORK_ERROR]: '网络请求失败',
|
||||
[ErrCode.UNKNOWN]: '未知错误',
|
||||
};
|
||||
|
||||
export function errorResponse(code: ErrorCode, detail?: string) {
|
||||
return {
|
||||
success: false,
|
||||
code,
|
||||
message: messages[code] + (detail ? ': ' + detail : ''),
|
||||
};
|
||||
}
|
||||
|
||||
export class TransferError extends Error {
|
||||
code: ErrorCode;
|
||||
detail?: string;
|
||||
cookieExpired: boolean;
|
||||
|
||||
constructor(code: ErrorCode, detail?: string) {
|
||||
super(messages[code] + (detail ? ': ' + detail : ''));
|
||||
this.code = code;
|
||||
this.detail = detail;
|
||||
this.cookieExpired = (code === ErrCode.COOKIE_EXPIRED || code === ErrCode.COOKIE_INVALID);
|
||||
}
|
||||
}
|
||||
|
||||
/** Detect error code from driver result message (for untagged drivers) */
|
||||
export function detectErrorCode(result: { message?: string; cookieExpired?: boolean }): ErrorCode | null {
|
||||
if (!result || !result.message) return null;
|
||||
if (result.cookieExpired) return ErrCode.COOKIE_EXPIRED;
|
||||
const msg = result.message.toLowerCase();
|
||||
if (msg.includes('cookie') || msg.includes('登录') || msg.includes('bdstoken')) return ErrCode.COOKIE_EXPIRED;
|
||||
if (msg.includes('不存在') || msg.includes('not found') || msg.includes('已删除')) return ErrCode.SHARE_NOT_FOUND;
|
||||
if (msg.includes('过期') || msg.includes('expired')) return ErrCode.SHARE_EXPIRED;
|
||||
if (msg.includes('提取码') || msg.includes('密码') || msg.includes('password')) return ErrCode.PASSWORD_WRONG;
|
||||
if (msg.includes('容量') || msg.includes('空间') || msg.includes('capacity')) return ErrCode.CAPACITY_FULL;
|
||||
if (msg.includes('频繁') || msg.includes('稍后') || msg.includes('rate')) return ErrCode.RATE_LIMITED;
|
||||
if (msg.includes('网络') || msg.includes('fetch') || msg.includes('timeout')) return ErrCode.NETWORK_ERROR;
|
||||
return ErrCode.TRANSFER_FAILED;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* IP 归属地查询工具
|
||||
* 通过系统配置中的 IP 地理接口查询
|
||||
*/
|
||||
|
||||
import { getSystemConfig } from '../admin/system-config.service';
|
||||
|
||||
export async function lookupIpLocation(ip: string): Promise<string | null> {
|
||||
if (!ip || ip === '127.0.0.1' || ip === '::1' || ip.startsWith('192.168.') || ip.startsWith('10.')) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const apiUrlTemplate = getSystemConfig('ip_geo_api_url');
|
||||
if (!apiUrlTemplate) return null;
|
||||
const url = apiUrlTemplate.replace('{ip}', encodeURIComponent(ip));
|
||||
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json() as {
|
||||
code: number; sheng?: string; shi?: string; qu?: string;
|
||||
isp?: string; msg?: string; guo?: string;
|
||||
};
|
||||
if (data.code !== 200) return null;
|
||||
// Format: "四川 绵阳 江油 中国联通" — strip 省/市/区/州 suffixes for compact display
|
||||
const stripSuffix = (s: string | undefined) => s?.replace(/[省市州区]$/, '');
|
||||
const parts = [stripSuffix(data.sheng), stripSuffix(data.shi), stripSuffix(data.qu), data.isp].filter(Boolean);
|
||||
return parts.join(' ');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Native fetch available in Node 20+
|
||||
import { getSystemConfig } from '../admin/system-config.service';
|
||||
|
||||
type NotifyLevel = 'info' | 'warn' | 'error';
|
||||
|
||||
interface NotifyChannel {
|
||||
send(title: string, content: string, level: NotifyLevel): Promise<void>;
|
||||
}
|
||||
|
||||
// ---- Feishu Webhook Channel ----
|
||||
class FeishuChannel implements NotifyChannel {
|
||||
private webhookUrl: string;
|
||||
|
||||
constructor(webhookUrl: string) {
|
||||
this.webhookUrl = webhookUrl;
|
||||
}
|
||||
|
||||
async send(title: string, content: string, _level: NotifyLevel): Promise<void> {
|
||||
try {
|
||||
const body = JSON.stringify({
|
||||
msg_type: 'interactive',
|
||||
card: {
|
||||
header: {
|
||||
title: { tag: 'plain_text', content: title },
|
||||
template: _level === 'error' ? 'red' : _level === 'warn' ? 'orange' : 'blue',
|
||||
},
|
||||
elements: [
|
||||
{ tag: 'div', text: { tag: 'lark_md', content } },
|
||||
{
|
||||
tag: 'note',
|
||||
elements: [
|
||||
{ tag: 'plain_text', content: `CloudSearch · ${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}` },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const resp = await fetch(this.webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
console.error(`[Notify] Feishu send failed: ${resp.status}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[Notify] Feishu send error:', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Notification Manager ----
|
||||
let _channel: NotifyChannel | null = null;
|
||||
|
||||
function getChannel(): NotifyChannel | null {
|
||||
const feishuUrl = process.env.FEISHU_WEBHOOK || getSystemConfig('feishu_webhook_url');
|
||||
if (!feishuUrl) return null;
|
||||
|
||||
if (!_channel) {
|
||||
_channel = new FeishuChannel(feishuUrl);
|
||||
console.log('[Notify] Feishu webhook configured');
|
||||
}
|
||||
return _channel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification through configured channels.
|
||||
* Returns immediately — failures are logged silently.
|
||||
*/
|
||||
export function notify(title: string, content: string, level: NotifyLevel = 'info'): void {
|
||||
const ch = getChannel();
|
||||
if (!ch) return;
|
||||
// Fire-and-forget — don't block the caller
|
||||
ch.send(title, content, level).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify on critical events:
|
||||
* - Cookie expired / login failed
|
||||
* - Save/transfer failed repeatedly
|
||||
* - Storage below threshold
|
||||
*/
|
||||
export function notifyError(title: string, detail: string): void {
|
||||
notify(`⚠️ ${title}`, detail, 'error');
|
||||
}
|
||||
|
||||
export function notifyWarn(title: string, detail: string): void {
|
||||
notify(`🔔 ${title}`, detail, 'warn');
|
||||
}
|
||||
|
||||
export function notifyInfo(title: string, detail: string): void {
|
||||
notify(`ℹ️ ${title}`, detail, 'info');
|
||||
}
|
||||
Executable
+407
@@ -0,0 +1,407 @@
|
||||
import { chromium, BrowserContext, Page } from 'playwright';
|
||||
import jsQR from 'jsqr';
|
||||
import { getDb } from '../database/database';
|
||||
import { escapeLike } from '../utils/time';
|
||||
|
||||
interface QrSession {
|
||||
id: string;
|
||||
browserContext: BrowserContext;
|
||||
page: Page;
|
||||
createdAt: number;
|
||||
cookieSnapshot: string;
|
||||
lastPollAt: number;
|
||||
qrUrl: string;
|
||||
status: 'pending' | 'scanned' | 'logged_in' | 'expired' | 'error';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const SESSIONS = new Map<string, QrSession>();
|
||||
const SESSION_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
const COOKIE_CHECK_INTERVAL = 1500; // 1.5s between cookie checks
|
||||
|
||||
const CHROMIUM_PATH = process.env.CHROMIUM_PATH || '/usr/bin/chromium-browser';
|
||||
|
||||
// Clean up old sessions periodically
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [id, session] of SESSIONS.entries()) {
|
||||
if (now - session.createdAt > SESSION_TTL) {
|
||||
cleanupSession(id);
|
||||
}
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
function cleanupSession(id: string) {
|
||||
const session = SESSIONS.get(id);
|
||||
if (session) {
|
||||
try {
|
||||
session.browserContext.close().catch(() => {});
|
||||
} catch {}
|
||||
SESSIONS.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract QR code URL from the login page canvas using jsQR.
|
||||
* The actual login QR code is Canvas #0 (anonymous, 177x177), NOT #react-qrcode-logo.
|
||||
*/
|
||||
async function extractQrUrl(page: Page): Promise<string> {
|
||||
// Run inside Playwright's browser context (as a string to avoid Node TS type errors)
|
||||
const raw = await page.evaluate(`(() => {
|
||||
const canvases = document.querySelectorAll('canvas');
|
||||
var results = [];
|
||||
for (var i = 0; i < canvases.length; i++) {
|
||||
try {
|
||||
var c = canvases[i];
|
||||
var ctx = c.getContext('2d');
|
||||
if (!ctx) continue;
|
||||
var imageData = ctx.getImageData(0, 0, c.width, c.height);
|
||||
results.push({
|
||||
index: i,
|
||||
w: c.width,
|
||||
h: c.height,
|
||||
data: Array.from(imageData.data)
|
||||
});
|
||||
} catch(e) {}
|
||||
}
|
||||
return results;
|
||||
})()`) as unknown as { index: number; w: number; h: number; data: number[] }[];
|
||||
|
||||
if (!raw || raw.length === 0) {
|
||||
throw new Error('页面没有可用的 canvas');
|
||||
}
|
||||
|
||||
// Try to decode each canvas, preferring the one with su.quark.cn URL
|
||||
let bestUrl = '';
|
||||
let bestResult: { index: number; w: number; h: number; data: number[] } | null = null;
|
||||
|
||||
for (const canvas of raw) {
|
||||
const code = jsQR(new Uint8ClampedArray(canvas.data), canvas.w, canvas.h);
|
||||
if (code && code.data) {
|
||||
// If this is the login QR code (has su.quark.cn), use it immediately
|
||||
if (code.data.includes('su.quark.cn')) {
|
||||
return code.data;
|
||||
}
|
||||
// Otherwise keep it as fallback
|
||||
if (!bestUrl) {
|
||||
bestUrl = code.data;
|
||||
bestResult = canvas;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestUrl) {
|
||||
return bestUrl;
|
||||
}
|
||||
|
||||
throw new Error('无法解析二维码内容');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a QR code login session.
|
||||
* Launches headless Chromium, navigates to Quark login page, extracts QR code URL.
|
||||
*/
|
||||
export async function startQrLogin(): Promise<{
|
||||
sessionId: string;
|
||||
qrUrl: string;
|
||||
expiresIn: number;
|
||||
}> {
|
||||
// Clean up any existing expired sessions
|
||||
for (const [id, session] of SESSIONS.entries()) {
|
||||
if (Date.now() - session.createdAt > SESSION_TTL) {
|
||||
cleanupSession(id);
|
||||
}
|
||||
}
|
||||
|
||||
const browser = await chromium.launch({
|
||||
executablePath: CHROMIUM_PATH,
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-gpu',
|
||||
'--no-first-run',
|
||||
'--no-zygote',
|
||||
],
|
||||
});
|
||||
|
||||
const browserContext = await browser.newContext({
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
viewport: { width: 1280, height: 800 },
|
||||
locale: 'zh-CN',
|
||||
});
|
||||
|
||||
const page = await browserContext.newPage();
|
||||
const sessionId = Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
||||
|
||||
try {
|
||||
// Navigate to Quark login page (now the homepage itself has QR login)
|
||||
await page.goto('https://pan.quark.cn/', {
|
||||
waitUntil: 'commit',
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// Wait for the QR code canvas to appear
|
||||
await page.waitForSelector('canvas', { timeout: 15000 });
|
||||
|
||||
// Extra wait for the QR code to fully render
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Extract the QR code URL from the canvas
|
||||
const qrUrl = await extractQrUrl(page);
|
||||
|
||||
// Take initial cookie snapshot
|
||||
const cookies = await browserContext.cookies();
|
||||
const cookieSnapshot = cookies.map(c => `${c.name}=${c.value}`).join('; ');
|
||||
|
||||
const session: QrSession = {
|
||||
id: sessionId,
|
||||
browserContext,
|
||||
page,
|
||||
createdAt: Date.now(),
|
||||
cookieSnapshot,
|
||||
lastPollAt: Date.now(),
|
||||
qrUrl,
|
||||
status: 'pending',
|
||||
};
|
||||
|
||||
SESSIONS.set(sessionId, session);
|
||||
|
||||
// Start background polling for login detection
|
||||
pollLoginStatus(session);
|
||||
|
||||
// Handle page navigation (like redirect after login)
|
||||
page.on('framenavigated', async (frame) => {
|
||||
if (frame === page.mainFrame()) {
|
||||
const url = frame.url();
|
||||
if (url === 'about:blank') {
|
||||
await checkAndCaptureCookies(session);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle popups/dialogs
|
||||
page.on('popup', async (popup) => {
|
||||
try {
|
||||
await popup.waitForLoadState('networkidle', { timeout: 10000 });
|
||||
await checkAndCaptureCookies(session);
|
||||
} catch {}
|
||||
});
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
qrUrl,
|
||||
expiresIn: SESSION_TTL / 1000,
|
||||
};
|
||||
} catch (err: any) {
|
||||
// Clean up on failure
|
||||
try { await browserContext.close(); } catch {}
|
||||
try { browser.close().catch(() => {}); } catch {}
|
||||
SESSIONS.delete(sessionId);
|
||||
throw new Error(`启动扫码登录失败: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll login status in background.
|
||||
* Checks cookies every COOKIE_CHECK_INTERVAL ms for new session tokens.
|
||||
*/
|
||||
async function pollLoginStatus(session: QrSession) {
|
||||
const checkInterval = setInterval(async () => {
|
||||
try {
|
||||
const now = Date.now();
|
||||
|
||||
// Check if expired
|
||||
if (now - session.createdAt > SESSION_TTL) {
|
||||
clearInterval(checkInterval);
|
||||
session.status = 'expired';
|
||||
cleanupSession(session.id);
|
||||
return;
|
||||
}
|
||||
|
||||
session.lastPollAt = now;
|
||||
|
||||
// Check cookies
|
||||
const cookies = await session.browserContext.cookies();
|
||||
const cookieStr = cookies.map(c => `${c.name}=${c.value}`).join('; ');
|
||||
|
||||
// Check for session cookies indicating login
|
||||
const hasSessionCookie = cookies.some(
|
||||
c => (c.name === '__st' || c.name === 'pus' || c.name === '__pus' || c.name === '__ktd')
|
||||
);
|
||||
|
||||
if (hasSessionCookie) {
|
||||
session.cookieSnapshot = cookieStr;
|
||||
session.status = 'logged_in';
|
||||
clearInterval(checkInterval);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check URL change as alternative indicator
|
||||
const url = session.page.url();
|
||||
if (!url.includes('login') && !url.includes('qrcode') && url !== 'about:blank' && url !== 'https://pan.quark.cn/' && url.length > 10) {
|
||||
await checkAndCaptureCookies(session);
|
||||
}
|
||||
} catch (err: any) {
|
||||
// Page might have been closed
|
||||
clearInterval(checkInterval);
|
||||
}
|
||||
}, COOKIE_CHECK_INTERVAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check cookies after navigation/redirect and capture them if login succeeded.
|
||||
*/
|
||||
async function checkAndCaptureCookies(session: QrSession) {
|
||||
try {
|
||||
const cookies = await session.browserContext.cookies();
|
||||
const cookieStr = cookies.map(c => `${c.name}=${c.value}`).join('; ');
|
||||
const hasSessionCookie = cookies.some(
|
||||
c => (c.name === '__st' || c.name === 'pus' || c.name === '__pus' || c.name === '__ktd')
|
||||
);
|
||||
|
||||
if (hasSessionCookie) {
|
||||
session.cookieSnapshot = cookieStr;
|
||||
session.status = 'logged_in';
|
||||
} else if (cookies.length > 3) {
|
||||
const newCookies = cookies.filter(
|
||||
c => !['ctoken', 'b-user-id', '__wpkreporterwid_'].includes(c.name)
|
||||
);
|
||||
if (newCookies.length > 0) {
|
||||
session.cookieSnapshot = cookieStr;
|
||||
try {
|
||||
const resp = await session.page.evaluate(async () => {
|
||||
const r = await fetch('https://pan.quark.cn/account/info', {
|
||||
credentials: 'include',
|
||||
});
|
||||
return await r.text();
|
||||
});
|
||||
const data = JSON.parse(resp);
|
||||
if (data?.data?.nickname) {
|
||||
session.status = 'logged_in';
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the login status for a session.
|
||||
*/
|
||||
export async function getQrLoginStatus(sessionId: string): Promise<{
|
||||
status: string;
|
||||
cookie?: string;
|
||||
nickname?: string;
|
||||
storage_used?: string;
|
||||
storage_total?: string;
|
||||
autoUpdated?: boolean;
|
||||
updatedConfigId?: number;
|
||||
}> {
|
||||
const session = SESSIONS.get(sessionId);
|
||||
if (!session) {
|
||||
return { status: 'expired' };
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if (Date.now() - session.createdAt > SESSION_TTL) {
|
||||
session.status = 'expired';
|
||||
cleanupSession(sessionId);
|
||||
return { status: 'expired' };
|
||||
}
|
||||
|
||||
if (session.status === 'logged_in') {
|
||||
// Try to get nickname too
|
||||
let nickname = '';
|
||||
try {
|
||||
const resp = await session.page.evaluate(async () => {
|
||||
const r = await fetch('https://pan.quark.cn/account/info', {
|
||||
credentials: 'include',
|
||||
});
|
||||
return await r.text();
|
||||
});
|
||||
const data = JSON.parse(resp);
|
||||
nickname = data?.data?.nickname || '';
|
||||
} catch {}
|
||||
|
||||
// Fetch capacity info from within the browser context (has full JS signing)
|
||||
let storageTotal = '';
|
||||
let storageUsed = '';
|
||||
try {
|
||||
const capResp = await session.page.evaluate(async () => {
|
||||
const r = await fetch(
|
||||
'https://pan.quark.cn/1/clouddrive/capacity/detail?pr=ucpro&fr=pc',
|
||||
{ credentials: 'include' }
|
||||
);
|
||||
return await r.text();
|
||||
});
|
||||
const capData = JSON.parse(capResp);
|
||||
if (capData.status === 200 && capData.data?.capacity_summary) {
|
||||
const summary = capData.data.capacity_summary;
|
||||
const total = summary.sum_capacity || 0;
|
||||
storageTotal = formatBytes(total);
|
||||
storageUsed = '0 B'; // capacity/detail doesn't return used_size
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Build full cookie string including httpOnly cookies
|
||||
const cookies = await session.browserContext.cookies();
|
||||
const cookieStr = cookies.map(c => `${c.name}=${c.value}`).join('; ');
|
||||
|
||||
// Extract __uid from cookie for duplicate detection
|
||||
const uidMatch = cookieStr.match(/(?<!\\w)__uid=([a-f0-9-]+)/);
|
||||
let autoUpdated = false;
|
||||
let updatedConfigId: number | undefined;
|
||||
|
||||
if (uidMatch) {
|
||||
const uid = uidMatch[1];
|
||||
try {
|
||||
const db = getDb();
|
||||
const existing = db.prepare(
|
||||
`SELECT id, nickname FROM cloud_configs WHERE cloud_type = 'quark' AND cookie LIKE ?`
|
||||
).get(`%${escapeLike(uid)}%`) as { id: number; nickname: string } | undefined;
|
||||
|
||||
if (existing) {
|
||||
// Same account → auto-update cookie with capacity info too
|
||||
const localTimestamp = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
||||
db.prepare(
|
||||
`UPDATE cloud_configs SET cookie = ?, storage_used = ?, storage_total = ?, updated_at = ? WHERE id = ?`
|
||||
).run(cookieStr, storageUsed || null, storageTotal || null, localTimestamp, existing.id);
|
||||
autoUpdated = true;
|
||||
updatedConfigId = existing.id;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Clean up session after successful login
|
||||
cleanupSession(sessionId);
|
||||
|
||||
return {
|
||||
status: 'logged_in',
|
||||
cookie: cookieStr,
|
||||
nickname,
|
||||
storage_used: storageUsed,
|
||||
storage_total: storageTotal,
|
||||
autoUpdated,
|
||||
updatedConfigId,
|
||||
};
|
||||
}
|
||||
|
||||
return { status: session.status };
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a QR login session.
|
||||
*/
|
||||
export async function cancelQrLogin(sessionId: string): Promise<void> {
|
||||
cleanupSession(sessionId);
|
||||
}
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Cloud type labels and colors
|
||||
* Shared between backend and frontend-facing routes
|
||||
*/
|
||||
|
||||
/** Cloud domain → type regex mapping (single source of truth) */
|
||||
export const CLOUD_DOMAIN_PATTERNS: Array<{ regex: RegExp; type: string }> = [
|
||||
{ regex: /pan\.baidu\.com/i, type: 'baidu' },
|
||||
{ regex: /pan\.quark\.cn/i, type: 'quark' },
|
||||
{ regex: /aliyundrive\.com|alipan\.com/i, type: 'aliyun' },
|
||||
{ regex: /115\.com|115cdn\.com/i, type: '115' },
|
||||
{ regex: /cloud\.189\.cn/i, type: 'tianyi' },
|
||||
{ regex: /123pan\.com|123684\.com|123912\.com/i, type: '123pan' },
|
||||
{ regex: /drive\.uc\.cn/i, type: 'uc' },
|
||||
{ regex: /pan\.xunlei\.com/i, type: 'xunlei' },
|
||||
{ regex: /magnet:/i, type: 'magnet' },
|
||||
];
|
||||
|
||||
/** Detect cloud type from a URL string */
|
||||
export function detectCloudType(url: string | undefined | null): string {
|
||||
if (!url) return 'others';
|
||||
for (const { regex, type } of CLOUD_DOMAIN_PATTERNS) {
|
||||
if (regex.test(url)) return type;
|
||||
}
|
||||
return 'others';
|
||||
}
|
||||
|
||||
export const CLOUD_LABELS: Record<string, string> = {
|
||||
quark: '夸克网盘',
|
||||
baidu: '百度网盘',
|
||||
aliyun: '阿里云盘',
|
||||
'115': '115网盘',
|
||||
tianyi: '天翼云盘',
|
||||
'123pan': '123云盘',
|
||||
uc: 'UC网盘',
|
||||
xunlei: '迅雷云盘',
|
||||
pikpak: 'PikPak',
|
||||
magnet: '磁力链接',
|
||||
ed2k: '电驴链接',
|
||||
others: '其他',
|
||||
};
|
||||
|
||||
export const CLOUD_COLORS: Record<string, string> = {
|
||||
quark: '#07c160',
|
||||
baidu: '#4e6ef2',
|
||||
aliyun: '#ff6a00',
|
||||
'115': '#9b59b6',
|
||||
tianyi: '#00a1d6',
|
||||
'123pan': '#e74c3c',
|
||||
uc: '#f39c12',
|
||||
xunlei: '#2ecc71',
|
||||
pikpak: '#8e44ad',
|
||||
magnet: '#95a5a6',
|
||||
ed2k: '#7f8c8d',
|
||||
others: '#95a5a6',
|
||||
};
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
export interface Config {
|
||||
port: number;
|
||||
nodeEnv: string;
|
||||
redisUrl: string;
|
||||
pansouUrl: string;
|
||||
pansouAuthToken: string;
|
||||
videoParserUrl: string;
|
||||
jwtSecret: string;
|
||||
adminUsername: string;
|
||||
adminPassword: string;
|
||||
validation: {
|
||||
concurrency: number;
|
||||
timeout: number;
|
||||
cacheTtlValid: number;
|
||||
cacheTtlInvalid: number;
|
||||
};
|
||||
dbPath: string;
|
||||
}
|
||||
|
||||
const config: Config = {
|
||||
port: parseInt(process.env.PORT || '9527', 10),
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
redisUrl: process.env.REDIS_URL || 'redis://localhost:6379',
|
||||
pansouUrl: process.env.PANSOU_URL || 'http://localhost:8888',
|
||||
pansouAuthToken: process.env.PANSOU_AUTH_TOKEN || '',
|
||||
videoParserUrl: process.env.VIDEO_PARSER_URL || 'http://localhost:3001',
|
||||
jwtSecret: process.env.JWT_SECRET || 'cloudsearch-jwt-secret-dev',
|
||||
adminUsername: process.env.ADMIN_USERNAME || 'admin',
|
||||
adminPassword: process.env.ADMIN_PASSWORD || 'admin123',
|
||||
validation: {
|
||||
concurrency: parseInt(process.env.VALIDATION_CONCURRENCY || '10', 10),
|
||||
timeout: parseInt(process.env.VALIDATION_TIMEOUT || '5000', 10),
|
||||
cacheTtlValid: parseInt(process.env.CACHE_TTL_VALID || '14400', 10), // 4小时
|
||||
cacheTtlInvalid: parseInt(process.env.CACHE_TTL_INVALID || '3600', 10), // 1小时
|
||||
},
|
||||
dbPath: process.env.DB_PATH || './data/cloudsearch.db',
|
||||
};
|
||||
|
||||
// 生产环境强制校验关键安全配置
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
if (!process.env.JWT_SECRET || process.env.JWT_SECRET === 'cloudsearch-jwt-secret-dev') {
|
||||
console.error('[FATAL] JWT_SECRET 未设置或使用了默认值,请在 .env 中设置强密码')
|
||||
process.exit(1)
|
||||
}
|
||||
if (!process.env.ADMIN_PASSWORD || process.env.ADMIN_PASSWORD === 'admin123') {
|
||||
console.error('[FATAL] ADMIN_PASSWORD 未设置或使用了默认值,请在 .env 中设置强密码')
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
export default config;
|
||||
Executable
+325
@@ -0,0 +1,325 @@
|
||||
// Native fetch available in Node 20+
|
||||
import { getDb } from '../database/database';
|
||||
import { localTimestamp } from '../utils/time';
|
||||
|
||||
export interface ContentInfo {
|
||||
keyword: string;
|
||||
title: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
cover: string;
|
||||
source: string;
|
||||
/** TMDB 详情页链接 */
|
||||
tmdb_url?: string;
|
||||
/** 评分 e.g. "7.3" */
|
||||
rating?: string;
|
||||
/** 评分人数 e.g. "12345" */
|
||||
rating_count?: string;
|
||||
/** 发布年份 e.g. "2025" */
|
||||
year?: string;
|
||||
/** 类型标签 e.g. ["动作", "科幻"] */
|
||||
genres?: string[];
|
||||
/** 导演 e.g. "克里斯托弗·诺兰" */
|
||||
directors?: string;
|
||||
/** 演员(前5个) e.g. "基里安·墨菲 / 艾米莉·布朗特" */
|
||||
actors?: string;
|
||||
/** 制片国家/地区 e.g. "美国 / 英国" */
|
||||
region?: string;
|
||||
/** 片长 e.g. "180分钟" */
|
||||
duration?: string;
|
||||
}
|
||||
|
||||
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export async function getContentInfo(keyword: string): Promise<ContentInfo | null> {
|
||||
if (!keyword || keyword.length < 1) return null;
|
||||
|
||||
const db = getDb();
|
||||
const tmdbToken = (db.prepare('SELECT value FROM system_configs WHERE key = ?').get('tmdb_api_token') as any)?.value || '';
|
||||
if (!tmdbToken) return null;
|
||||
|
||||
const cached = db.prepare('SELECT * FROM content_cache WHERE keyword = ?').get(keyword) as any;
|
||||
if (cached) {
|
||||
const age = Date.now() - new Date(cached.updated_at + 'Z').getTime();
|
||||
if (age < CACHE_TTL_MS) {
|
||||
return rowToContentInfo(cached);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await fetchFromTMDB(keyword, tmdbToken);
|
||||
if (info) {
|
||||
db.prepare(`
|
||||
INSERT OR REPLACE INTO content_cache
|
||||
(keyword, title, description, tags, cover, douban_url, source, updated_at,
|
||||
rating, rating_count, year, genres, directors, actors, region, duration)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'tmdb', ?,
|
||||
?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
keyword, info.title, info.description, JSON.stringify(info.tags), info.cover, info.tmdb_url || '', localTimestamp(),
|
||||
info.rating || '', info.rating_count || '', info.year || '',
|
||||
JSON.stringify(info.genres || []), info.directors || '', info.actors || '',
|
||||
info.region || '', info.duration || ''
|
||||
);
|
||||
return info;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Content] Failed to fetch for "${keyword}":`, err);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function rowToContentInfo(row: any): ContentInfo {
|
||||
return {
|
||||
keyword: row.keyword,
|
||||
title: row.title || '',
|
||||
description: row.description || '',
|
||||
tags: safeParseTags(row.tags),
|
||||
cover: row.cover || '',
|
||||
source: row.source || (row.title ? 'tmdb' : 'cache'),
|
||||
tmdb_url: row.douban_url || '',
|
||||
rating: row.rating || '',
|
||||
rating_count: row.rating_count || '',
|
||||
year: row.year || '',
|
||||
genres: safeParseTags(row.genres),
|
||||
directors: row.directors || '',
|
||||
actors: row.actors || '',
|
||||
region: row.region || '',
|
||||
duration: row.duration || '',
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchFromTMDB(keyword: string, tmdbToken: string): Promise<ContentInfo | null> {
|
||||
// Step 1: TMDB search — search both movie and TV in parallel
|
||||
let movieResults: any[] = [];
|
||||
let tvResults: any[] = [];
|
||||
|
||||
try {
|
||||
const searchUrl = `https://api.themoviedb.org/3/search/movie?query=${encodeURIComponent(keyword)}&language=zh-CN&page=1`;
|
||||
const searchResp = await fetch(searchUrl, {
|
||||
headers: { 'Authorization': `Bearer ${tmdbToken}` },
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (searchResp.ok) {
|
||||
const searchData = await searchResp.json() as any;
|
||||
if (Array.isArray(searchData.results)) {
|
||||
movieResults = searchData.results;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
console.warn(`[Content] TMDB movie search failed for "${keyword}"`);
|
||||
}
|
||||
|
||||
try {
|
||||
const searchUrl = `https://api.themoviedb.org/3/search/tv?query=${encodeURIComponent(keyword)}&language=zh-CN&page=1`;
|
||||
const searchResp = await fetch(searchUrl, {
|
||||
headers: { 'Authorization': `Bearer ${tmdbToken}` },
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (searchResp.ok) {
|
||||
const searchData = await searchResp.json() as any;
|
||||
if (Array.isArray(searchData.results)) {
|
||||
tvResults = searchData.results;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
console.warn(`[Content] TMDB TV search failed for "${keyword}"`);
|
||||
}
|
||||
|
||||
// Step 2: Score and rank all results
|
||||
const isChineseKeyword = /[\u4e00-\u9fff]/.test(keyword);
|
||||
const kwLower = keyword.toLowerCase();
|
||||
|
||||
// Score function: higher = better match
|
||||
function scoreResult(item: any, type: 'tv' | 'movie'): number {
|
||||
const name = (type === 'tv' ? (item.name || item.original_name || '') : (item.title || item.original_title || '')).toLowerCase();
|
||||
// Exact match gets highest priority
|
||||
if (name === kwLower) return 100;
|
||||
// Name starts with keyword
|
||||
if (name.startsWith(kwLower)) return 80;
|
||||
// Name contains keyword as a standalone segment
|
||||
if (name.includes(kwLower)) return 60;
|
||||
// Keyword contains significant portion of name
|
||||
const cleanName = name.replace(/[^a-z0-9\u4e00-\u9fff]/g, '');
|
||||
if (kwLower.includes(cleanName) && cleanName.length >= 2) return 40;
|
||||
// Partial match
|
||||
if (name.includes(kwLower) || kwLower.includes(cleanName)) return 20;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Score all TV results
|
||||
const scoredTV = tvResults.map((r: any) => ({ item: r, score: scoreResult(r, 'tv') })).filter(r => r.score > 0);
|
||||
// Score all movie results
|
||||
const scoredMovie = movieResults.map((r: any) => ({ item: r, score: scoreResult(r, 'movie') })).filter(r => r.score > 0);
|
||||
|
||||
// Sort by score descending
|
||||
scoredTV.sort((a, b) => b.score - a.score);
|
||||
scoredMovie.sort((a, b) => b.score - a.score);
|
||||
|
||||
const tvBest = scoredTV[0]?.item || null;
|
||||
const movieBest = scoredMovie[0]?.item || null;
|
||||
const tvBestScore = scoredTV[0]?.score || 0;
|
||||
const movieBestScore = scoredMovie[0]?.score || 0;
|
||||
|
||||
let best: any = null;
|
||||
let mediaType: 'movie' | 'tv' = 'movie';
|
||||
let movie: any = null;
|
||||
|
||||
if (tvBest && movieBest) {
|
||||
// Both have matches — score-based comparison
|
||||
// For Chinese keywords: TV gets +15 score bonus to prefer series over movies
|
||||
const tvScore = tvBestScore + (isChineseKeyword ? 15 : 0);
|
||||
const movieScore = movieBestScore;
|
||||
if (tvScore > movieScore) {
|
||||
best = tvBest;
|
||||
mediaType = 'tv';
|
||||
} else if (movieScore > tvScore) {
|
||||
best = movieBest;
|
||||
mediaType = 'movie';
|
||||
} else {
|
||||
// Tie — prefer TV for Chinese keywords, otherwise pick higher vote count
|
||||
if (isChineseKeyword) {
|
||||
best = tvBest;
|
||||
mediaType = 'tv';
|
||||
} else {
|
||||
const tvVotes = tvBest.vote_count || 0;
|
||||
const movieVotes = movieBest.vote_count || 0;
|
||||
best = tvVotes >= movieVotes ? tvBest : movieBest;
|
||||
mediaType = tvVotes >= movieVotes ? 'tv' : 'movie';
|
||||
}
|
||||
}
|
||||
} else if (tvBest) {
|
||||
best = tvBest;
|
||||
mediaType = 'tv';
|
||||
} else if (movieBest) {
|
||||
best = movieBest;
|
||||
mediaType = 'movie';
|
||||
} else if (scoredTV.length > 0 && !scoredMovie.length) {
|
||||
best = scoredTV[0].item;
|
||||
mediaType = 'tv';
|
||||
} else if (scoredMovie.length > 0) {
|
||||
best = scoredMovie[0].item;
|
||||
mediaType = 'movie';
|
||||
} else if (tvResults.length > 0 && !movieResults.length) {
|
||||
best = tvResults[0];
|
||||
mediaType = 'tv';
|
||||
} else if (movieResults.length > 0) {
|
||||
best = movieResults[0];
|
||||
mediaType = 'movie';
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
let tmdbId = best.id;
|
||||
try {
|
||||
const detailUrl = `https://api.themoviedb.org/3/${mediaType}/${tmdbId}?language=zh-CN&append_to_response=credits`;
|
||||
const detailResp = await fetch(detailUrl, {
|
||||
headers: { 'Authorization': `Bearer ${tmdbToken}` },
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (detailResp.ok) {
|
||||
movie = await detailResp.json() as any;
|
||||
}
|
||||
} catch {
|
||||
console.warn(`[Content] TMDB detail failed for ${mediaType} id ${tmdbId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!movie) return null;
|
||||
|
||||
// Extract TMDB data (use title for movie, name for TV)
|
||||
const title = movie.title || movie.name || keyword;
|
||||
const rating = movie.vote_average > 0 ? String(Math.round(movie.vote_average * 10) / 10) : '';
|
||||
const ratingCount = movie.vote_count ? String(movie.vote_count) : '';
|
||||
// Use release_date for movie, first_air_date for TV
|
||||
const year = movie.release_date ? movie.release_date.substring(0, 4) : (movie.first_air_date ? movie.first_air_date.substring(0, 4) : '');
|
||||
const genres = Array.isArray(movie.genres) ? movie.genres.map((g: any) => g.name).filter(Boolean) : [];
|
||||
// Directors: tv shows have limited crew data, fall back to "creator" for TV
|
||||
const directors = Array.isArray(movie.credits?.crew)
|
||||
? movie.credits.crew.filter((c: any) => c.job === 'Director').map((c: any) => c.name).filter(Boolean).join(' / ')
|
||||
: '';
|
||||
const actors = Array.isArray(movie.credits?.cast)
|
||||
? movie.credits.cast.slice(0, 5).map((c: any) => c.name).filter(Boolean).join(' / ')
|
||||
: '';
|
||||
const region = Array.isArray(movie.production_countries)
|
||||
? movie.production_countries.map((c: any) => c.name).filter(Boolean).join(' / ')
|
||||
: (Array.isArray(movie.origin_country) ? movie.origin_country.join(' / ') : '');
|
||||
const duration = mediaType === 'movie'
|
||||
? (movie.runtime > 0 ? `${movie.runtime}分钟` : '')
|
||||
: (movie.episode_run_time && movie.episode_run_time.length > 0 ? `每集${movie.episode_run_time[0]}分钟` : '');
|
||||
const description = movie.overview ? movie.overview.substring(0, 200) : '';
|
||||
const cover = movie.poster_path ? `https://image.tmdb.org/t/p/w500${movie.poster_path}` : '';
|
||||
|
||||
// TMDB detail page URL
|
||||
const tmdbUrl = `https://www.themoviedb.org/${mediaType}/${tmdbId}`;
|
||||
|
||||
// Generate tags from keyword + title
|
||||
const tags = genTags({ keyword, title });
|
||||
|
||||
// Build description fallback
|
||||
let desc = description;
|
||||
if (!desc) {
|
||||
const parts: string[] = [];
|
||||
if (year) parts.push(`${year}年`);
|
||||
if (genres.length > 0) parts.push(genres.slice(0, 3).join(' / '));
|
||||
if (duration) parts.push(duration);
|
||||
desc = parts.length > 0 ? parts.join(' · ') : '';
|
||||
}
|
||||
|
||||
return {
|
||||
keyword,
|
||||
title,
|
||||
description: desc,
|
||||
tags,
|
||||
cover,
|
||||
source: 'tmdb',
|
||||
tmdb_url: tmdbUrl,
|
||||
rating,
|
||||
rating_count: ratingCount,
|
||||
year,
|
||||
genres,
|
||||
directors,
|
||||
actors,
|
||||
region,
|
||||
duration,
|
||||
};
|
||||
}
|
||||
|
||||
function genTags(opts: { keyword: string; title: string }): string[] {
|
||||
const { keyword, title } = opts;
|
||||
const tags: string[] = [];
|
||||
if (keyword.length <= 8) tags.push(keyword);
|
||||
|
||||
const txt = (title + ' ' + keyword).toLowerCase();
|
||||
const isDonghua = /动画|动漫/i.test(txt);
|
||||
if (isDonghua) {
|
||||
tags.push('动画'); tags.push('国漫');
|
||||
} else {
|
||||
tags.push('电影');
|
||||
}
|
||||
|
||||
const genreMap: Record<string, string[]> = {
|
||||
'动画': ['动画'], '动漫': ['动漫'], '国漫': ['国漫'],
|
||||
'剧场版': ['剧场版'], '年番': ['年番'],
|
||||
'动作': ['动作'], '奇幻': ['奇幻'], '玄幻': ['玄幻'],
|
||||
'仙侠': ['仙侠'], '古装': ['古装'], '爱情': ['爱情'],
|
||||
'科幻': ['科幻'], '喜剧': ['喜剧'], '悬疑': ['悬疑'],
|
||||
'冒险': ['冒险'], '战争': ['战争'], '纪录': ['纪录片'], '真人': ['真人秀'],
|
||||
};
|
||||
for (const [key, vals] of Object.entries(genreMap)) {
|
||||
if (txt.includes(key)) {
|
||||
for (const v of vals) { if (!tags.includes(v)) tags.push(v); }
|
||||
}
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
function safeParseTags(tagsStr: string | null | undefined): string[] {
|
||||
if (!tagsStr) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(tagsStr);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
Executable
+327
@@ -0,0 +1,327 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import config from '../config';
|
||||
import { formatLocalDateTime } from '../utils/time';
|
||||
|
||||
let db: Database.Database | null = null;
|
||||
|
||||
export function getDb(): Database.Database {
|
||||
if (db) return db;
|
||||
|
||||
const dbDir = path.dirname(config.dbPath);
|
||||
const fs = require('fs');
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
|
||||
db = new Database(config.dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
// Performance indexes (IF NOT EXISTS ensures idempotent)
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_cc_type_active ON cloud_configs(cloud_type, is_active);
|
||||
CREATE INDEX IF NOT EXISTS idx_cc_uid ON cloud_configs(cookie_uid);
|
||||
CREATE INDEX IF NOT EXISTS idx_cc_verification ON cloud_configs(verification_status);
|
||||
`);
|
||||
|
||||
runMigrations(db);
|
||||
seedAdmin(db);
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
function runMigrations(db: Database.Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS admins (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
||||
last_login TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cloud_configs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cloud_type TEXT NOT NULL,
|
||||
cookie TEXT,
|
||||
nickname TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
storage_used TEXT,
|
||||
storage_total TEXT,
|
||||
checkin_status TEXT NOT NULL DEFAULT 'none',
|
||||
last_checkin_at TEXT,
|
||||
checkin_message TEXT,
|
||||
consecutive_failures INTEGER DEFAULT 0,
|
||||
last_used_at TEXT,
|
||||
total_saves INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS promotions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
image_url TEXT,
|
||||
link_url TEXT,
|
||||
position TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
click_count INTEGER NOT NULL DEFAULT 0,
|
||||
start_time TEXT,
|
||||
end_time TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS save_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_type TEXT,
|
||||
source_title TEXT,
|
||||
source_url TEXT,
|
||||
target_cloud TEXT,
|
||||
share_url TEXT,
|
||||
share_pwd TEXT,
|
||||
file_size TEXT,
|
||||
file_count INTEGER DEFAULT 0,
|
||||
duration_ms INTEGER DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT '',
|
||||
error_message TEXT,
|
||||
ip_address TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS search_stats (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
keyword TEXT,
|
||||
intent TEXT,
|
||||
result_count INTEGER DEFAULT 0,
|
||||
ip_address TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS hot_keywords (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
keyword TEXT UNIQUE NOT NULL,
|
||||
search_count INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS system_configs (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT '',
|
||||
description TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS content_cache (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
keyword TEXT UNIQUE NOT NULL,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
tags TEXT,
|
||||
cover TEXT,
|
||||
source TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||
);
|
||||
`);
|
||||
seedSystemConfigs(db);
|
||||
migrateSaveRecords(db);
|
||||
migrateContentCache(db);
|
||||
migrateCloudConfigs(db);
|
||||
cleanupOldSaveRecords(db);
|
||||
}
|
||||
|
||||
/** 迁移: 给已有 save_records 表补充新列 */
|
||||
function migrateSaveRecords(db: Database.Database): void {
|
||||
const newCols: { col: string; def: string }[] = [
|
||||
{ col: 'share_pwd', def: 'TEXT' },
|
||||
{ col: 'file_count', def: 'INTEGER DEFAULT 0' },
|
||||
{ col: 'folder_count', def: 'INTEGER DEFAULT 0' },
|
||||
{ col: 'duration_ms', def: 'INTEGER DEFAULT 0' },
|
||||
{ col: 'status', def: "TEXT NOT NULL DEFAULT ''" },
|
||||
{ col: 'error_message', def: 'TEXT' },
|
||||
{ col: 'folder_name', def: 'TEXT' },
|
||||
{ col: 'request_url', def: 'TEXT' },
|
||||
{ col: 'ip_location', def: 'TEXT' },
|
||||
{ col: 'original_folder_name', def: 'TEXT' },
|
||||
];
|
||||
for (const { col, def } of newCols) {
|
||||
try {
|
||||
db.exec(`ALTER TABLE save_records ADD COLUMN ${col} ${def}`);
|
||||
} catch {
|
||||
// Column already exists — ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 迁移: 给 content_cache 表加 douban_url 列 */
|
||||
function migrateContentCache(db: Database.Database): void {
|
||||
const columns: { col: string; def: string }[] = [
|
||||
{ col: 'douban_url', def: 'TEXT' },
|
||||
{ col: 'rating', def: 'TEXT' },
|
||||
{ col: 'rating_count', def: 'TEXT' },
|
||||
{ col: 'year', def: 'TEXT' },
|
||||
{ col: 'genres', def: 'TEXT' },
|
||||
{ col: 'directors', def: 'TEXT' },
|
||||
{ col: 'actors', def: 'TEXT' },
|
||||
{ col: 'region', def: 'TEXT' },
|
||||
{ col: 'duration', def: 'TEXT' },
|
||||
];
|
||||
for (const { col, def } of columns) {
|
||||
try {
|
||||
db.exec(`ALTER TABLE content_cache ADD COLUMN ${col} ${def}`);
|
||||
} catch {
|
||||
// Column already exists — ignore
|
||||
}
|
||||
}
|
||||
// 修复旧记录:source 为 NULL 但实际有 TMDB 数据的,标记为 tmdb
|
||||
db.exec(`UPDATE content_cache SET source = 'tmdb' WHERE source IS NULL AND title IS NOT NULL AND title != ''`);
|
||||
}
|
||||
|
||||
/** 迁移: 给 cloud_configs 表去UNIQUE约束 + 加签到/轮训字段 */
|
||||
function migrateCloudConfigs(db: Database.Database): void {
|
||||
// 加新列
|
||||
const newCols: { col: string; def: string }[] = [
|
||||
{ col: 'checkin_status', def: "TEXT NOT NULL DEFAULT 'none'" },
|
||||
{ col: 'last_checkin_at', def: 'TEXT' },
|
||||
{ col: 'checkin_message', def: 'TEXT' },
|
||||
{ col: 'consecutive_failures', def: 'INTEGER DEFAULT 0' },
|
||||
{ col: 'last_used_at', def: 'TEXT' },
|
||||
{ col: 'total_saves', def: 'INTEGER DEFAULT 0' },
|
||||
];
|
||||
for (const { col, def } of newCols) {
|
||||
try { db.exec(`ALTER TABLE cloud_configs ADD COLUMN ${col} ${def}`); } catch {}
|
||||
}
|
||||
// 检查旧表是否有 UNIQUE 约束,有则重建表
|
||||
const row = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='cloud_configs'`).get() as any;
|
||||
if (row && row.sql && row.sql.includes('cloud_type TEXT UNIQUE')) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS cloud_configs_v2 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cloud_type TEXT NOT NULL,
|
||||
cookie TEXT,
|
||||
nickname TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
storage_used TEXT,
|
||||
storage_total TEXT,
|
||||
checkin_status TEXT NOT NULL DEFAULT 'none',
|
||||
last_checkin_at TEXT,
|
||||
checkin_message TEXT,
|
||||
consecutive_failures INTEGER DEFAULT 0,
|
||||
last_used_at TEXT,
|
||||
total_saves INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||
);
|
||||
INSERT INTO cloud_configs_v2 (id, cloud_type, cookie, nickname, is_active, storage_used, storage_total, checkin_status, last_checkin_at, checkin_message, consecutive_failures, last_used_at, total_saves, created_at, updated_at)
|
||||
SELECT id, cloud_type, cookie, nickname, is_active, storage_used, storage_total, COALESCE(checkin_status,'none'), last_checkin_at, checkin_message, COALESCE(consecutive_failures,0), last_used_at, COALESCE(total_saves,0), created_at, updated_at FROM cloud_configs;
|
||||
DROP TABLE cloud_configs;
|
||||
ALTER TABLE cloud_configs_v2 RENAME TO cloud_configs;
|
||||
`);
|
||||
console.log('[DB] cloud_configs migration: UNIQUE constraint removed, new fields added');
|
||||
}
|
||||
|
||||
// Migration 2: Add verification_status column
|
||||
const row2 = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%verification_status%'").get();
|
||||
if (!row2) {
|
||||
db.exec("ALTER TABLE cloud_configs ADD COLUMN verification_status TEXT DEFAULT NULL");
|
||||
console.log('[DB] cloud_configs migration: verification_status column added');
|
||||
}
|
||||
|
||||
// Migration 3: Add cookie_uid column
|
||||
const hasCookieUid = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%cookie_uid%'").get();
|
||||
if (!hasCookieUid) {
|
||||
db.exec("ALTER TABLE cloud_configs ADD COLUMN cookie_uid TEXT DEFAULT NULL");
|
||||
console.log('[DB] cloud_configs migration: cookie_uid column added');
|
||||
}
|
||||
|
||||
// Migration 4: Add promotion_account column
|
||||
const hasPromotionAccount = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%promotion_account%'").get();
|
||||
if (!hasPromotionAccount) {
|
||||
db.exec("ALTER TABLE cloud_configs ADD COLUMN promotion_account TEXT DEFAULT NULL");
|
||||
console.log('[DB] cloud_configs migration: promotion_account column added');
|
||||
}
|
||||
}
|
||||
|
||||
function seedAdmin(db: Database.Database): void {
|
||||
const existing = db.prepare('SELECT id FROM admins WHERE username = ?').get(config.adminUsername);
|
||||
if (existing) return;
|
||||
|
||||
const salt = bcrypt.genSaltSync(10);
|
||||
const hash = bcrypt.hashSync(config.adminPassword, salt);
|
||||
|
||||
db.prepare(
|
||||
'INSERT INTO admins (username, password_hash) VALUES (?, ?)'
|
||||
).run(config.adminUsername, hash);
|
||||
|
||||
console.log(`[DB] Admin user "${config.adminUsername}" created`);
|
||||
}
|
||||
|
||||
function seedSystemConfigs(db: Database.Database): void {
|
||||
const defaults: { key: string; value: string; description: string }[] = [
|
||||
{ key: 'pansou_url', value: config.pansouUrl, description: 'PanSou 搜索引擎服务地址' },
|
||||
{ key: 'video_parser_url', value: config.videoParserUrl, description: '视频解析服务地址' },
|
||||
{ key: 'validation_concurrency', value: String(config.validation.concurrency), description: '链接验证并发数' },
|
||||
{ key: 'validation_timeout', value: String(config.validation.timeout), description: '链接验证超时(ms)' },
|
||||
{ key: 'validation_cache_ttl_valid', value: String(config.validation.cacheTtlValid), description: '有效链接缓存时间(s)' },
|
||||
{ key: 'validation_cache_ttl_invalid', value: String(config.validation.cacheTtlInvalid), description: '无效链接缓存时间(s)' },
|
||||
{ key: 'search_proxy_enabled', value: 'false', description: '搜索代理开关(true/false)' },
|
||||
{ key: 'search_proxy_url', value: '', description: '搜索代理地址 (如 http://127.0.0.1:7890)' },
|
||||
{ key: 'search_strategy', value: 'wait_all', description: '搜索结果展示方式: wait_all=等待全部后展示, stream_channel=频道逐步展示' },
|
||||
{ key: 'link_validation_enabled', value: 'true', description: '资源链接有效性检测开关(true/false)' },
|
||||
{ key: 'cloud_enabled_quark', value: 'true', description: '夸克网盘' },
|
||||
{ key: 'cloud_enabled_baidu', value: 'true', description: '百度网盘' },
|
||||
{ key: 'cloud_enabled_aliyun', value: 'true', description: '阿里云盘' },
|
||||
{ key: 'cloud_enabled_115', value: 'true', description: '115 网盘' },
|
||||
{ key: 'cloud_enabled_tianyi', value: 'true', description: '天翼云盘' },
|
||||
{ key: 'cloud_enabled_123pan', value: 'true', description: '123 云盘' },
|
||||
{ key: 'cloud_enabled_uc', value: 'true', description: 'UC 网盘' },
|
||||
{ key: 'cloud_enabled_xunlei', value: 'true', description: '迅雷网盘' },
|
||||
{ key: 'cloud_enabled_pikpak', value: 'true', description: 'PikPak 网盘' },
|
||||
{ key: 'cloud_enabled_magnet', value: 'true', description: '磁力链接' },
|
||||
{ key: 'cloud_enabled_ed2k', value: 'true', description: '电驴链接' },
|
||||
{ key: 'cloud_enabled_others', value: 'false', description: '其他类型(默认关闭)' },
|
||||
{ key: 'search_result_limit', value: '10', description: '每类网盘最多展示的有效结果数' },
|
||||
{ key: 'search_fallback_image', value: '', description: '无图资源的兜底封面图 URL(留空使用渐变色)' },
|
||||
{ key: 'site_logo', value: '', description: '网站 LOGO 图片 URL(留空使用默认图标/文字)' },
|
||||
{ key: 'site_name', value: 'CloudSearch', description: '网站名称(显示在首页标题/页脚)' },
|
||||
{ key: 'site_disclaimer', value: '本站为非盈利性个人站点,所有资源仅供学习、研究使用,版权归原作者所有。请于下载后24小时内删除,切勿用于商业或非法用途。若侵犯了您的权益,请联系我们(邮箱:3337598077@qq.com),我们将及时处理。', description: '网站底部免责声明' },
|
||||
{ key: 'site_marquee', value: '📢 欢迎使用CloudSearch,所有资源仅供学习交流,请于下载后24小时内删除', description: '搜索栏下方滚动通知文字(从右往左滚动显示)' },
|
||||
{ key: 'tmdb_api_token', value: '', description: 'TMDB API 读取令牌(用于增强豆瓣内容信息)' },
|
||||
{ key: 'ip_geo_api_url', value: 'https://cn.apihz.cn/api/ip/chaapi.php?id=10014356&key=ca7ccb3b9ca044dd993c8604bc9afd93&ip={ip}&td=0', description: 'IP 归属地查询接口({ip} 会被替换为实际IP)' },
|
||||
{ key: 'ip_geo_api_key', value: '', description: 'IP 归属地备用 API Key(留空使用默认)' },
|
||||
{ key: 'title_filter_rules', value: '', description: '搜索结果标题过滤规则(一行一条:纯文本直接移除 / 正则用/包围/)' },
|
||||
{ key: 'timezone', value: 'Asia/Shanghai', description: '系统时区(如 Asia/Shanghai、America/New_York、UTC)' },
|
||||
{ key: 'redis_url', value: 'redis://redis:6379', description: 'Redis 连接地址(用于缓存优化)' },
|
||||
{ key: 'pansou_auth_token', value: '', description: 'PanSou API 认证令牌(用于私有搜索服务)' },
|
||||
{ key: 'pansou_web_enabled', value: 'false', description: '启用 PanSou Web 端访问(在 /pansou 路径提供 PanSou 搜索引擎管理界面)' },
|
||||
{ key: 'cleanup_enabled', value: 'true', description: '启用自动清理(每天检查一次,移入回收站+清空日志+清空回收站)' },
|
||||
{ key: 'cleanup_file_retention_days', value: '7', description: '云盘文件保留天数(超过此天数的日期文件夹将被移入回收站)' },
|
||||
{ key: 'cleanup_log_retention_days', value: '30', description: '转存日志保留天数' },
|
||||
{ key: 'cleanup_empty_trash', value: 'true', description: '清理时是否清空回收站(永久删除释放空间)' },
|
||||
{ key: 'cleanup_space_threshold_enabled', value: 'false', description: '启用空间阈值自动清理(已用空间超过XX%时按比例删除最旧的转存文件)' },
|
||||
{ key: 'cleanup_space_threshold_percent', value: '90', description: '空间使用阈值百分比(超过此值时触发强制清理)' },
|
||||
{ key: 'cleanup_space_threshold_delete_percent', value: '10', description: '触发阈值清理时释放总空间的百分比(如 10 表示累计删除最旧文件直到达到总空间的 10%,6TB 总空间 → 释放 ~600GB)' },
|
||||
{ key: 'save_reuse_enabled', value: 'true', description: '启用分享链接复用(相同原始链接不再重复转存,直接复用之前的分享链接)' },
|
||||
{ key: 'cleanup_last_run', value: '', description: '上次自动清理时间' },
|
||||
{ key: 'cleanup_last_stats', value: '', description: '上次清理结果统计(JSON)' },
|
||||
];
|
||||
const insert = db.prepare(
|
||||
'INSERT OR IGNORE INTO system_configs (key, value, description) VALUES (?, ?, ?)'
|
||||
);
|
||||
for (const entry of defaults) {
|
||||
insert.run(entry.key, entry.value, entry.description);
|
||||
}
|
||||
}
|
||||
|
||||
/** 清理 60 天前的转存记录 */
|
||||
function cleanupOldSaveRecords(db: Database.Database): void {
|
||||
const cutoff = formatLocalDateTime(new Date(Date.now() - 60 * 24 * 60 * 60 * 1000));
|
||||
const deleted = db.prepare('DELETE FROM save_records WHERE created_at < ?').run(cutoff);
|
||||
console.log(`[DB] Cleaned up ${deleted.changes} save records older than 60 days (before ${cutoff})`);
|
||||
}
|
||||
|
||||
export default getDb;
|
||||
+306
@@ -0,0 +1,306 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import config from '../config';
|
||||
import { formatLocalDateTime } from '../utils/time';
|
||||
|
||||
let db: Database.Database | null = null;
|
||||
|
||||
export function getDb(): Database.Database {
|
||||
if (db) return db;
|
||||
|
||||
const dbDir = path.dirname(config.dbPath);
|
||||
const fs = require('fs');
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
|
||||
db = new Database(config.dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
runMigrations(db);
|
||||
seedAdmin(db);
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
function runMigrations(db: Database.Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS admins (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
||||
last_login TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cloud_configs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cloud_type TEXT NOT NULL,
|
||||
cookie TEXT,
|
||||
nickname TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
storage_used TEXT,
|
||||
storage_total TEXT,
|
||||
checkin_status TEXT NOT NULL DEFAULT 'none',
|
||||
last_checkin_at TEXT,
|
||||
checkin_message TEXT,
|
||||
consecutive_failures INTEGER DEFAULT 0,
|
||||
last_used_at TEXT,
|
||||
total_saves INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS promotions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
image_url TEXT,
|
||||
link_url TEXT,
|
||||
position TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
click_count INTEGER NOT NULL DEFAULT 0,
|
||||
start_time TEXT,
|
||||
end_time TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS save_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_type TEXT,
|
||||
source_title TEXT,
|
||||
source_url TEXT,
|
||||
target_cloud TEXT,
|
||||
share_url TEXT,
|
||||
share_pwd TEXT,
|
||||
file_size TEXT,
|
||||
file_count INTEGER DEFAULT 0,
|
||||
duration_ms INTEGER DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT '',
|
||||
error_message TEXT,
|
||||
ip_address TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS search_stats (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
keyword TEXT,
|
||||
intent TEXT,
|
||||
result_count INTEGER DEFAULT 0,
|
||||
ip_address TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS hot_keywords (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
keyword TEXT UNIQUE NOT NULL,
|
||||
search_count INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS system_configs (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT '',
|
||||
description TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS content_cache (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
keyword TEXT UNIQUE NOT NULL,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
tags TEXT,
|
||||
cover TEXT,
|
||||
source TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||
);
|
||||
`);
|
||||
seedSystemConfigs(db);
|
||||
migrateSaveRecords(db);
|
||||
migrateContentCache(db);
|
||||
migrateCloudConfigs(db);
|
||||
cleanupOldSaveRecords(db);
|
||||
}
|
||||
|
||||
/** 迁移: 给已有 save_records 表补充新列 */
|
||||
function migrateSaveRecords(db: Database.Database): void {
|
||||
const newCols: { col: string; def: string }[] = [
|
||||
{ col: 'share_pwd', def: 'TEXT' },
|
||||
{ col: 'file_count', def: 'INTEGER DEFAULT 0' },
|
||||
{ col: 'folder_count', def: 'INTEGER DEFAULT 0' },
|
||||
{ col: 'duration_ms', def: 'INTEGER DEFAULT 0' },
|
||||
{ col: 'status', def: "TEXT NOT NULL DEFAULT ''" },
|
||||
{ col: 'error_message', def: 'TEXT' },
|
||||
{ col: 'folder_name', def: 'TEXT' },
|
||||
{ col: 'request_url', def: 'TEXT' },
|
||||
{ col: 'ip_location', def: 'TEXT' },
|
||||
{ col: 'original_folder_name', def: 'TEXT' },
|
||||
];
|
||||
for (const { col, def } of newCols) {
|
||||
try {
|
||||
db.exec(`ALTER TABLE save_records ADD COLUMN ${col} ${def}`);
|
||||
} catch {
|
||||
// Column already exists — ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 迁移: 给 content_cache 表加 douban_url 列 */
|
||||
function migrateContentCache(db: Database.Database): void {
|
||||
const columns: { col: string; def: string }[] = [
|
||||
{ col: 'douban_url', def: 'TEXT' },
|
||||
{ col: 'rating', def: 'TEXT' },
|
||||
{ col: 'rating_count', def: 'TEXT' },
|
||||
{ col: 'year', def: 'TEXT' },
|
||||
{ col: 'genres', def: 'TEXT' },
|
||||
{ col: 'directors', def: 'TEXT' },
|
||||
{ col: 'actors', def: 'TEXT' },
|
||||
{ col: 'region', def: 'TEXT' },
|
||||
{ col: 'duration', def: 'TEXT' },
|
||||
];
|
||||
for (const { col, def } of columns) {
|
||||
try {
|
||||
db.exec(`ALTER TABLE content_cache ADD COLUMN ${col} ${def}`);
|
||||
} catch {
|
||||
// Column already exists — ignore
|
||||
}
|
||||
}
|
||||
// 修复旧记录:source 为 NULL 但实际有 TMDB 数据的,标记为 tmdb
|
||||
db.exec(`UPDATE content_cache SET source = 'tmdb' WHERE source IS NULL AND title IS NOT NULL AND title != ''`);
|
||||
}
|
||||
|
||||
/** 迁移: 给 cloud_configs 表去UNIQUE约束 + 加签到/轮训字段 */
|
||||
function migrateCloudConfigs(db: Database.Database): void {
|
||||
// 加新列
|
||||
const newCols: { col: string; def: string }[] = [
|
||||
{ col: 'checkin_status', def: "TEXT NOT NULL DEFAULT 'none'" },
|
||||
{ col: 'last_checkin_at', def: 'TEXT' },
|
||||
{ col: 'checkin_message', def: 'TEXT' },
|
||||
{ col: 'consecutive_failures', def: 'INTEGER DEFAULT 0' },
|
||||
{ col: 'last_used_at', def: 'TEXT' },
|
||||
{ col: 'total_saves', def: 'INTEGER DEFAULT 0' },
|
||||
];
|
||||
for (const { col, def } of newCols) {
|
||||
try { db.exec(`ALTER TABLE cloud_configs ADD COLUMN ${col} ${def}`); } catch {}
|
||||
}
|
||||
// 检查旧表是否有 UNIQUE 约束,有则重建表
|
||||
const row = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='cloud_configs'`).get() as any;
|
||||
if (row && row.sql && row.sql.includes('cloud_type TEXT UNIQUE')) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS cloud_configs_v2 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cloud_type TEXT NOT NULL,
|
||||
cookie TEXT,
|
||||
nickname TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
storage_used TEXT,
|
||||
storage_total TEXT,
|
||||
checkin_status TEXT NOT NULL DEFAULT 'none',
|
||||
last_checkin_at TEXT,
|
||||
checkin_message TEXT,
|
||||
consecutive_failures INTEGER DEFAULT 0,
|
||||
last_used_at TEXT,
|
||||
total_saves INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||
);
|
||||
INSERT INTO cloud_configs_v2 (id, cloud_type, cookie, nickname, is_active, storage_used, storage_total, checkin_status, last_checkin_at, checkin_message, consecutive_failures, last_used_at, total_saves, created_at, updated_at)
|
||||
SELECT id, cloud_type, cookie, nickname, is_active, storage_used, storage_total, COALESCE(checkin_status,'none'), last_checkin_at, checkin_message, COALESCE(consecutive_failures,0), last_used_at, COALESCE(total_saves,0), created_at, updated_at FROM cloud_configs;
|
||||
DROP TABLE cloud_configs;
|
||||
ALTER TABLE cloud_configs_v2 RENAME TO cloud_configs;
|
||||
`);
|
||||
console.log('[DB] cloud_configs migration: UNIQUE constraint removed, new fields added');
|
||||
}
|
||||
|
||||
// Migration 2: Add verification_status column
|
||||
const row2 = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%verification_status%'").get();
|
||||
if (!row2) {
|
||||
db.exec("ALTER TABLE cloud_configs ADD COLUMN verification_status TEXT DEFAULT NULL");
|
||||
console.log('[DB] cloud_configs migration: verification_status column added');
|
||||
}
|
||||
}
|
||||
|
||||
function seedAdmin(db: Database.Database): void {
|
||||
const existing = db.prepare('SELECT id FROM admins WHERE username = ?').get(config.adminUsername);
|
||||
if (existing) return;
|
||||
|
||||
const salt = bcrypt.genSaltSync(10);
|
||||
const hash = bcrypt.hashSync(config.adminPassword, salt);
|
||||
|
||||
db.prepare(
|
||||
'INSERT INTO admins (username, password_hash) VALUES (?, ?)'
|
||||
).run(config.adminUsername, hash);
|
||||
|
||||
console.log(`[DB] Admin user "${config.adminUsername}" created`);
|
||||
}
|
||||
|
||||
function seedSystemConfigs(db: Database.Database): void {
|
||||
const defaults: { key: string; value: string; description: string }[] = [
|
||||
{ key: 'pansou_url', value: config.pansouUrl, description: 'PanSou 搜索引擎服务地址' },
|
||||
{ key: 'video_parser_url', value: config.videoParserUrl, description: '视频解析服务地址' },
|
||||
{ key: 'validation_concurrency', value: String(config.validation.concurrency), description: '链接验证并发数' },
|
||||
{ key: 'validation_timeout', value: String(config.validation.timeout), description: '链接验证超时(ms)' },
|
||||
{ key: 'validation_cache_ttl_valid', value: String(config.validation.cacheTtlValid), description: '有效链接缓存时间(s)' },
|
||||
{ key: 'validation_cache_ttl_invalid', value: String(config.validation.cacheTtlInvalid), description: '无效链接缓存时间(s)' },
|
||||
{ key: 'search_proxy_enabled', value: 'false', description: '搜索代理开关(true/false)' },
|
||||
{ key: 'search_proxy_url', value: '', description: '搜索代理地址 (如 http://127.0.0.1:7890)' },
|
||||
{ key: 'search_strategy', value: 'wait_all', description: '搜索结果展示方式: wait_all=等待全部后展示, stream_channel=频道逐步展示' },
|
||||
{ key: 'link_validation_enabled', value: 'true', description: '资源链接有效性检测开关(true/false)' },
|
||||
{ key: 'cloud_enabled_quark', value: 'true', description: '夸克网盘' },
|
||||
{ key: 'cloud_enabled_baidu', value: 'true', description: '百度网盘' },
|
||||
{ key: 'cloud_enabled_aliyun', value: 'true', description: '阿里云盘' },
|
||||
{ key: 'cloud_enabled_115', value: 'true', description: '115 网盘' },
|
||||
{ key: 'cloud_enabled_tianyi', value: 'true', description: '天翼云盘' },
|
||||
{ key: 'cloud_enabled_123pan', value: 'true', description: '123 云盘' },
|
||||
{ key: 'cloud_enabled_uc', value: 'true', description: 'UC 网盘' },
|
||||
{ key: 'cloud_enabled_xunlei', value: 'true', description: '迅雷网盘' },
|
||||
{ key: 'cloud_enabled_pikpak', value: 'true', description: 'PikPak 网盘' },
|
||||
{ key: 'cloud_enabled_magnet', value: 'true', description: '磁力链接' },
|
||||
{ key: 'cloud_enabled_ed2k', value: 'true', description: '电驴链接' },
|
||||
{ key: 'cloud_enabled_others', value: 'false', description: '其他类型(默认关闭)' },
|
||||
{ key: 'search_result_limit', value: '10', description: '每类网盘最多展示的有效结果数' },
|
||||
{ key: 'search_fallback_image', value: '', description: '无图资源的兜底封面图 URL(留空使用渐变色)' },
|
||||
{ key: 'site_logo', value: '', description: '网站 LOGO 图片 URL(留空使用默认图标/文字)' },
|
||||
{ key: 'site_name', value: 'CloudSearch', description: '网站名称(显示在首页标题/页脚)' },
|
||||
{ key: 'site_disclaimer', value: '本站为非盈利性个人站点,所有资源仅供学习、研究使用,版权归原作者所有。请于下载后24小时内删除,切勿用于商业或非法用途。若侵犯了您的权益,请联系我们(邮箱:3337598077@qq.com),我们将及时处理。', description: '网站底部免责声明' },
|
||||
{ key: 'site_marquee', value: '📢 欢迎使用CloudSearch,所有资源仅供学习交流,请于下载后24小时内删除', description: '搜索栏下方滚动通知文字(从右往左滚动显示)' },
|
||||
{ key: 'tmdb_api_token', value: '', description: 'TMDB API 读取令牌(用于增强豆瓣内容信息)' },
|
||||
{ key: 'ip_geo_api_url', value: 'https://cn.apihz.cn/api/ip/chaapi.php?id=10014356&key=ca7ccb3b9ca044dd993c8604bc9afd93&ip={ip}&td=0', description: 'IP 归属地查询接口({ip} 会被替换为实际IP)' },
|
||||
{ key: 'ip_geo_api_key', value: '', description: 'IP 归属地备用 API Key(留空使用默认)' },
|
||||
{ key: 'title_filter_rules', value: '', description: '搜索结果标题过滤规则(一行一条:纯文本直接移除 / 正则用/包围/)' },
|
||||
{ key: 'timezone', value: 'Asia/Shanghai', description: '系统时区(如 Asia/Shanghai、America/New_York、UTC)' },
|
||||
{ key: 'redis_url', value: 'redis://redis:6379', description: 'Redis 连接地址(用于缓存优化)' },
|
||||
{ key: 'pansou_auth_token', value: '', description: 'PanSou API 认证令牌(用于私有搜索服务)' },
|
||||
{ key: 'pansou_web_enabled', value: 'false', description: '启用 PanSou Web 端访问(在 /pansou 路径提供 PanSou 搜索引擎管理界面)' },
|
||||
{ key: 'cleanup_enabled', value: 'true', description: '启用自动清理(每天检查一次,移入回收站+清空日志+清空回收站)' },
|
||||
{ key: 'cleanup_file_retention_days', value: '7', description: '云盘文件保留天数(超过此天数的日期文件夹将被移入回收站)' },
|
||||
{ key: 'cleanup_log_retention_days', value: '30', description: '转存日志保留天数' },
|
||||
{ key: 'cleanup_empty_trash', value: 'true', description: '清理时是否清空回收站(永久删除释放空间)' },
|
||||
{ key: 'cleanup_space_threshold_enabled', value: 'false', description: '启用空间阈值自动清理(已用空间超过XX%时按比例删除最旧的转存文件)' },
|
||||
{ key: 'cleanup_space_threshold_percent', value: '90', description: '空间使用阈值百分比(超过此值时触发强制清理)' },
|
||||
{ key: 'cleanup_space_threshold_delete_percent', value: '10', description: '触发阈值清理时释放总空间的百分比(如 10 表示累计删除最旧文件直到达到总空间的 10%,6TB 总空间 → 释放 ~600GB)' },
|
||||
{ key: 'save_reuse_enabled', value: 'true', description: '启用分享链接复用(相同原始链接不再重复转存,直接复用之前的分享链接)' },
|
||||
{ key: 'cleanup_last_run', value: '', description: '上次自动清理时间' },
|
||||
{ key: 'cleanup_last_stats', value: '', description: '上次清理结果统计(JSON)' },
|
||||
];
|
||||
const insert = db.prepare(
|
||||
'INSERT OR IGNORE INTO system_configs (key, value, description) VALUES (?, ?, ?)'
|
||||
);
|
||||
for (const entry of defaults) {
|
||||
insert.run(entry.key, entry.value, entry.description);
|
||||
}
|
||||
}
|
||||
|
||||
/** 清理 60 天前的转存记录 */
|
||||
function cleanupOldSaveRecords(db: Database.Database): void {
|
||||
const cutoff = formatLocalDateTime(new Date(Date.now() - 60 * 24 * 60 * 60 * 1000));
|
||||
const deleted = db.prepare('DELETE FROM save_records WHERE created_at < ?').run(cutoff);
|
||||
console.log(`[DB] Cleaned up ${deleted.changes} save records older than 60 days (before ${cutoff})`);
|
||||
}
|
||||
|
||||
export default getDb;
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
export type IntentType = 'SEARCH' | 'VIDEO_PARSE' | 'CLOUD_SAVE';
|
||||
|
||||
export interface IntentResult {
|
||||
type: IntentType;
|
||||
platform?: string;
|
||||
rawInput: string;
|
||||
cleanInput: string;
|
||||
}
|
||||
|
||||
const VIDEO_PLATFORMS = [
|
||||
{ domain: /douyin\.com|v\.douyin\.com/i, name: 'douyin' },
|
||||
{ domain: /kuaishou\.com/i, name: 'kuaishou' },
|
||||
{ domain: /xiaohongshu\.com/i, name: 'xiaohongshu' },
|
||||
{ domain: /bilibili\.com|b23\.tv/i, name: 'bilibili' },
|
||||
{ domain: /weibo\.com/i, name: 'weibo' },
|
||||
{ domain: /pipixia\.com/i, name: 'pipixia' },
|
||||
{ domain: /y\.qq\.com/i, name: 'qqmusic' },
|
||||
];
|
||||
|
||||
import { CLOUD_DOMAIN_PATTERNS } from '../config/cloud-labels';
|
||||
|
||||
export function detectIntent(input: string): IntentResult {
|
||||
const urlMatch = input.match(/(https?:\/\/[^\s]+)/i);
|
||||
if (urlMatch) {
|
||||
const url = urlMatch[1];
|
||||
|
||||
for (const p of VIDEO_PLATFORMS) {
|
||||
if (p.domain.test(url)) {
|
||||
return { type: 'VIDEO_PARSE', platform: p.name, rawInput: input, cleanInput: url };
|
||||
}
|
||||
}
|
||||
|
||||
for (const p of CLOUD_DOMAIN_PATTERNS) {
|
||||
if (p.regex.test(url)) {
|
||||
return { type: 'CLOUD_SAVE', platform: p.type, rawInput: input, cleanInput: url };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { type: 'SEARCH', rawInput: input, cleanInput: input.trim() };
|
||||
}
|
||||
Executable
+187
@@ -0,0 +1,187 @@
|
||||
import express from 'express';
|
||||
import path from 'path';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import morgan from 'morgan';
|
||||
import config from './config';
|
||||
const { version } = require('../package.json');
|
||||
import { getDb } from './database/database';
|
||||
import { connectRedis, disconnectRedis, reconnectRedis, testRedisConnection } from './middleware/cache';
|
||||
import rateLimiter from './middleware/rate-limit';
|
||||
import routes from './routes';
|
||||
import { pansouWebProxy } from './proxy/pansou-web';
|
||||
import { checkAndRunScheduledCleanup } from './cloud/cleanup.service';
|
||||
import { refreshAllStorageInfo } from './cloud/cloud.service';
|
||||
|
||||
const app = express();
|
||||
|
||||
// ============ Middleware ============
|
||||
app.set('trust proxy', true);
|
||||
|
||||
// CORS — 生产环境必须配置真实域名(空值或占位符用 * 并打警告日志)
|
||||
const corsOrigin = process.env.CORS_ORIGIN || '';
|
||||
const isPlaceholder = !corsOrigin || corsOrigin === 'https://your-domain.com';
|
||||
if (config.nodeEnv === 'production' && isPlaceholder) {
|
||||
console.error('[FATAL] CORS_ORIGIN 未配置或使用了占位符 https://your-domain.com,生产环境必须设置真实域名。应用拒绝启动。');
|
||||
process.exit(1);
|
||||
}
|
||||
if (config.nodeEnv === 'production' && !isPlaceholder) {
|
||||
app.use(cors({ origin: corsOrigin, credentials: true }));
|
||||
} else {
|
||||
app.use(cors({ origin: '*', credentials: false }));
|
||||
}
|
||||
|
||||
app.use(helmet({ contentSecurityPolicy: false }));
|
||||
|
||||
// morgan 日志格式:不记录 IP,避免隐私合规问题
|
||||
app.use(morgan(':method :url :status :res[content-length] - :response-time ms'));
|
||||
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||
app.use(rateLimiter);
|
||||
|
||||
// ============ 前端静态文件 ============
|
||||
const frontendDist = path.join(__dirname, 'frontend');
|
||||
app.use(express.static(frontendDist));
|
||||
|
||||
// ============ Routes ============
|
||||
app.use('/api/uploads', express.static('/app/uploads'));
|
||||
app.use('/api', routes);
|
||||
|
||||
// ============ Health Check(增强版:覆盖 Redis / PanSou / VideoParser 状态) ============
|
||||
app.get('/health', async (_req, res) => {
|
||||
const dbOk = (() => {
|
||||
try { getDb(); return true; } catch { return false; }
|
||||
})();
|
||||
|
||||
const redisStatus = await (async () => {
|
||||
try {
|
||||
const { getRedis } = require('./middleware/cache');
|
||||
const redis = getRedis();
|
||||
if (!redis) return 'disconnected';
|
||||
const pong = await redis.ping().catch(() => null);
|
||||
return pong === 'PONG' ? 'connected' : 'error';
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
} catch { return 'unknown'; }
|
||||
})();
|
||||
|
||||
const pansouStatus = await (async () => {
|
||||
try {
|
||||
// Native fetch available in Node 20+
|
||||
const url = (config.pansouUrl || 'http://pansou:80').replace(/\/+$/, '') + '/api/search';
|
||||
const controller = new AbortController();
|
||||
const t = setTimeout(() => controller.abort(), 3000);
|
||||
const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kw: 'health', page: 1 }), signal: controller.signal });
|
||||
clearTimeout(t);
|
||||
return r.ok ? 'ok' : 'degraded';
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
} catch { return 'unreachable'; }
|
||||
})();
|
||||
|
||||
const videoParserStatus = await (async () => {
|
||||
try {
|
||||
// Native fetch available in Node 20+
|
||||
const url = (config.videoParserUrl || 'http://video-parser:3001').replace(/\/+$/, '');
|
||||
const controller = new AbortController();
|
||||
const t = setTimeout(() => controller.abort(), 3000);
|
||||
const r = await fetch(url, { signal: controller.signal });
|
||||
clearTimeout(t);
|
||||
return r.ok ? 'ok' : 'degraded';
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
} catch { return 'unreachable'; }
|
||||
})();
|
||||
|
||||
const overall = dbOk && redisStatus === 'connected' && pansouStatus === 'ok' && videoParserStatus === 'ok'
|
||||
? 'ok'
|
||||
: dbOk && redisStatus !== 'unknown' && pansouStatus !== 'unreachable'
|
||||
? 'degraded'
|
||||
: 'unhealthy';
|
||||
|
||||
res.json({
|
||||
version,
|
||||
status: overall,
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: Math.floor(process.uptime()),
|
||||
memory: process.memoryUsage().rss,
|
||||
components: {
|
||||
db: dbOk ? 'connected' : 'error',
|
||||
redis: redisStatus,
|
||||
pansou: pansouStatus,
|
||||
videoParser: videoParserStatus,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ============ PanSou Web UI Proxy ============
|
||||
app.use('/pansou', pansouWebProxy);
|
||||
|
||||
// SPA fallback
|
||||
app.use((req, res, next) => {
|
||||
if (req.path.startsWith('/api/') || req.path === '/health') return next();
|
||||
res.sendFile(path.join(frontendDist, 'index.html'), (err) => { if (err) next(); });
|
||||
});
|
||||
|
||||
// Global error handler
|
||||
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
console.error('[Error]', err);
|
||||
res.status(err.status || 500).json({
|
||||
error: config.nodeEnv === 'production' ? 'Internal server error' : err.message,
|
||||
code: err.status || 500,
|
||||
});
|
||||
});
|
||||
|
||||
// ============ Server Start ============
|
||||
async function start(): Promise<void> {
|
||||
try {
|
||||
getDb();
|
||||
console.log('[DB] SQLite database initialized');
|
||||
try {
|
||||
const { getSystemConfig } = require('./admin/system-config.service');
|
||||
const tz = getSystemConfig('timezone');
|
||||
if (tz) { process.env.TZ = tz; console.log(`[Config] Timezone set to: ${tz}`); }
|
||||
} catch { console.warn('[Config] Could not set timezone, using default'); }
|
||||
} catch (err) {
|
||||
console.error('[DB] Failed to initialize database:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const { getSystemConfig } = require('./admin/system-config.service');
|
||||
const redisUrl = process.env.REDIS_URL || getSystemConfig('redis_url');
|
||||
if (redisUrl) {
|
||||
const ok = await reconnectRedis(redisUrl);
|
||||
if (ok) console.log('[Redis] Connected to', redisUrl);
|
||||
} else {
|
||||
await connectRedis();
|
||||
}
|
||||
} catch { console.warn('[Redis] Redis not available, continuing without cache'); }
|
||||
|
||||
// Cleanup scheduler
|
||||
const CLEANUP_INTERVAL = 10 * 60 * 1000;
|
||||
setInterval(() => { checkAndRunScheduledCleanup().catch(err => console.error('[Cleanup] Scheduler error:', err.message)); }, CLEANUP_INTERVAL);
|
||||
setTimeout(() => { checkAndRunScheduledCleanup().catch(err => console.error('[Cleanup] Initial check error:', err.message)); }, 30000);
|
||||
|
||||
// Storage info refresh scheduler — every 60 minutes
|
||||
const STORAGE_REFRESH_INTERVAL = 60 * 60 * 1000;
|
||||
setInterval(() => { refreshAllStorageInfo().catch(err => console.error('[Storage] Refresh error:', err.message)); }, STORAGE_REFRESH_INTERVAL);
|
||||
setTimeout(() => { refreshAllStorageInfo().catch(err => console.error('[Storage] Initial refresh error:', err.message)); }, 60000);
|
||||
|
||||
const server = app.listen(config.port, () => {
|
||||
console.log(`[Server] CloudSearch Backend running on port ${config.port} (${config.nodeEnv})`);
|
||||
});
|
||||
|
||||
const shutdown = async (signal: string) => {
|
||||
console.log(`\n[Server] Received ${signal}, shutting down gracefully...`);
|
||||
server.close(async () => { await disconnectRedis(); console.log('[Server] Closed'); process.exit(0); });
|
||||
setTimeout(() => { console.error('[Server] Force shutdown'); process.exit(1); }, 10000);
|
||||
};
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
process.on('uncaughtException', (err) => { console.error('[FATAL] Uncaught Exception:', err); setTimeout(() => process.exit(1), 1000); });
|
||||
process.on('unhandledRejection', (reason) => { const msg = reason instanceof Error ? reason.message : String(reason); console.error('[FATAL] Unhandled Rejection:', msg); });
|
||||
}
|
||||
|
||||
start().catch((err) => { console.error('[Server] Failed to start:', err); process.exit(1); });
|
||||
|
||||
export default app;
|
||||
Executable
+186
@@ -0,0 +1,186 @@
|
||||
import express from 'express';
|
||||
import path from 'path';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import morgan from 'morgan';
|
||||
import config from './config';
|
||||
const { version } = require('../package.json');
|
||||
import { getDb } from './database/database';
|
||||
import { connectRedis, disconnectRedis, reconnectRedis, testRedisConnection } from './middleware/cache';
|
||||
import rateLimiter from './middleware/rate-limit';
|
||||
import routes from './routes';
|
||||
import { pansouWebProxy } from './proxy/pansou-web';
|
||||
import { checkAndRunScheduledCleanup } from './cloud/cleanup.service';
|
||||
import { refreshAllStorageInfo } from './cloud/cloud.service';
|
||||
|
||||
const app = express();
|
||||
|
||||
// ============ Middleware ============
|
||||
app.set('trust proxy', true);
|
||||
|
||||
// CORS — 生产环境必须配置真实域名(空值或占位符用 * 并打警告日志)
|
||||
const corsOrigin = process.env.CORS_ORIGIN || '';
|
||||
const isPlaceholder = !corsOrigin || corsOrigin === 'https://your-domain.com';
|
||||
if (config.nodeEnv === 'production' && isPlaceholder) {
|
||||
console.warn('[WARN] CORS_ORIGIN 未配置或使用了占位符,生产环境建议设置真实域名,当前临时允许所有来源');
|
||||
}
|
||||
if (config.nodeEnv === 'production' && !isPlaceholder) {
|
||||
app.use(cors({ origin: corsOrigin, credentials: true }));
|
||||
} else {
|
||||
app.use(cors({ origin: '*', credentials: false }));
|
||||
}
|
||||
|
||||
app.use(helmet({ contentSecurityPolicy: false }));
|
||||
|
||||
// morgan 日志格式:不记录 IP,避免隐私合规问题
|
||||
app.use(morgan(':method :url :status :res[content-length] - :response-time ms'));
|
||||
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||
app.use(rateLimiter);
|
||||
|
||||
// ============ 前端静态文件 ============
|
||||
const frontendDist = path.join(__dirname, 'frontend');
|
||||
app.use(express.static(frontendDist));
|
||||
|
||||
// ============ Routes ============
|
||||
app.use('/api/uploads', express.static('/app/uploads'));
|
||||
app.use('/api', routes);
|
||||
|
||||
// ============ Health Check(增强版:覆盖 Redis / PanSou / VideoParser 状态) ============
|
||||
app.get('/health', async (_req, res) => {
|
||||
const dbOk = (() => {
|
||||
try { getDb(); return true; } catch { return false; }
|
||||
})();
|
||||
|
||||
const redisStatus = await (async () => {
|
||||
try {
|
||||
const { getRedis } = require('./middleware/cache');
|
||||
const redis = getRedis();
|
||||
if (!redis) return 'disconnected';
|
||||
const pong = await redis.ping().catch(() => null);
|
||||
return pong === 'PONG' ? 'connected' : 'error';
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
} catch { return 'unknown'; }
|
||||
})();
|
||||
|
||||
const pansouStatus = await (async () => {
|
||||
try {
|
||||
// Native fetch available in Node 20+
|
||||
const url = (config.pansouUrl || 'http://pansou:80').replace(/\/+$/, '') + '/api/search';
|
||||
const controller = new AbortController();
|
||||
const t = setTimeout(() => controller.abort(), 3000);
|
||||
const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kw: 'health', page: 1 }), signal: controller.signal });
|
||||
clearTimeout(t);
|
||||
return r.ok ? 'ok' : 'degraded';
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
} catch { return 'unreachable'; }
|
||||
})();
|
||||
|
||||
const videoParserStatus = await (async () => {
|
||||
try {
|
||||
// Native fetch available in Node 20+
|
||||
const url = (config.videoParserUrl || 'http://video-parser:3001').replace(/\/+$/, '');
|
||||
const controller = new AbortController();
|
||||
const t = setTimeout(() => controller.abort(), 3000);
|
||||
const r = await fetch(url, { signal: controller.signal });
|
||||
clearTimeout(t);
|
||||
return r.ok ? 'ok' : 'degraded';
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
} catch { return 'unreachable'; }
|
||||
})();
|
||||
|
||||
const overall = dbOk && redisStatus === 'connected' && pansouStatus === 'ok' && videoParserStatus === 'ok'
|
||||
? 'ok'
|
||||
: dbOk && redisStatus !== 'unknown' && pansouStatus !== 'unreachable'
|
||||
? 'degraded'
|
||||
: 'unhealthy';
|
||||
|
||||
res.json({
|
||||
version,
|
||||
status: overall,
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: Math.floor(process.uptime()),
|
||||
memory: process.memoryUsage().rss,
|
||||
components: {
|
||||
db: dbOk ? 'connected' : 'error',
|
||||
redis: redisStatus,
|
||||
pansou: pansouStatus,
|
||||
videoParser: videoParserStatus,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ============ PanSou Web UI Proxy ============
|
||||
app.use('/pansou', pansouWebProxy);
|
||||
|
||||
// SPA fallback
|
||||
app.use((req, res, next) => {
|
||||
if (req.path.startsWith('/api/') || req.path === '/health') return next();
|
||||
res.sendFile(path.join(frontendDist, 'index.html'), (err) => { if (err) next(); });
|
||||
});
|
||||
|
||||
// Global error handler
|
||||
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
console.error('[Error]', err);
|
||||
res.status(err.status || 500).json({
|
||||
error: config.nodeEnv === 'production' ? 'Internal server error' : err.message,
|
||||
code: err.status || 500,
|
||||
});
|
||||
});
|
||||
|
||||
// ============ Server Start ============
|
||||
async function start(): Promise<void> {
|
||||
try {
|
||||
getDb();
|
||||
console.log('[DB] SQLite database initialized');
|
||||
try {
|
||||
const { getSystemConfig } = require('./admin/system-config.service');
|
||||
const tz = getSystemConfig('timezone');
|
||||
if (tz) { process.env.TZ = tz; console.log(`[Config] Timezone set to: ${tz}`); }
|
||||
} catch { console.warn('[Config] Could not set timezone, using default'); }
|
||||
} catch (err) {
|
||||
console.error('[DB] Failed to initialize database:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const { getSystemConfig } = require('./admin/system-config.service');
|
||||
const redisUrl = process.env.REDIS_URL || getSystemConfig('redis_url');
|
||||
if (redisUrl) {
|
||||
const ok = await reconnectRedis(redisUrl);
|
||||
if (ok) console.log('[Redis] Connected to', redisUrl);
|
||||
} else {
|
||||
await connectRedis();
|
||||
}
|
||||
} catch { console.warn('[Redis] Redis not available, continuing without cache'); }
|
||||
|
||||
// Cleanup scheduler
|
||||
const CLEANUP_INTERVAL = 10 * 60 * 1000;
|
||||
setInterval(() => { checkAndRunScheduledCleanup().catch(err => console.error('[Cleanup] Scheduler error:', err.message)); }, CLEANUP_INTERVAL);
|
||||
setTimeout(() => { checkAndRunScheduledCleanup().catch(err => console.error('[Cleanup] Initial check error:', err.message)); }, 30000);
|
||||
|
||||
// Storage info refresh scheduler — every 60 minutes
|
||||
const STORAGE_REFRESH_INTERVAL = 60 * 60 * 1000;
|
||||
setInterval(() => { refreshAllStorageInfo().catch(err => console.error('[Storage] Refresh error:', err.message)); }, STORAGE_REFRESH_INTERVAL);
|
||||
setTimeout(() => { refreshAllStorageInfo().catch(err => console.error('[Storage] Initial refresh error:', err.message)); }, 60000);
|
||||
|
||||
const server = app.listen(config.port, () => {
|
||||
console.log(`[Server] CloudSearch Backend running on port ${config.port} (${config.nodeEnv})`);
|
||||
});
|
||||
|
||||
const shutdown = async (signal: string) => {
|
||||
console.log(`\n[Server] Received ${signal}, shutting down gracefully...`);
|
||||
server.close(async () => { await disconnectRedis(); console.log('[Server] Closed'); process.exit(0); });
|
||||
setTimeout(() => { console.error('[Server] Force shutdown'); process.exit(1); }, 10000);
|
||||
};
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
process.on('uncaughtException', (err) => { console.error('[FATAL] Uncaught Exception:', err); setTimeout(() => process.exit(1), 1000); });
|
||||
process.on('unhandledRejection', (reason) => { const msg = reason instanceof Error ? reason.message : String(reason); console.error('[FATAL] Unhandled Rejection:', msg); });
|
||||
}
|
||||
|
||||
start().catch((err) => { console.error('[Server] Failed to start:', err); process.exit(1); });
|
||||
|
||||
export default app;
|
||||
Executable
+161
@@ -0,0 +1,161 @@
|
||||
import Redis from 'ioredis';
|
||||
|
||||
let client: Redis | null = null;
|
||||
let currentUrl: string = '';
|
||||
|
||||
export function getRedis(): Redis | null {
|
||||
return client;
|
||||
}
|
||||
|
||||
export function getRedisClient(): Redis {
|
||||
if (client) return client;
|
||||
return createClient();
|
||||
}
|
||||
|
||||
function createClient(url?: string): Redis {
|
||||
const redisUrl = url || process.env.REDIS_URL || currentUrl || getSystemConfigRedisUrl();
|
||||
currentUrl = redisUrl;
|
||||
|
||||
if (client) {
|
||||
try { client.disconnect(); } catch {}
|
||||
client = null;
|
||||
}
|
||||
|
||||
client = new Redis(redisUrl, {
|
||||
maxRetriesPerRequest: 3,
|
||||
retryStrategy(times: number) {
|
||||
if (times > 3) return null;
|
||||
return Math.min(times * 200, 2000);
|
||||
},
|
||||
lazyConnect: true,
|
||||
});
|
||||
|
||||
client.on('error', (err: Error) => {
|
||||
console.error('[Redis] Error:', err.message);
|
||||
});
|
||||
|
||||
client.on('connect', () => {
|
||||
console.log('[Redis] Connected to', currentUrl);
|
||||
});
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
function getSystemConfigRedisUrl(): string {
|
||||
try {
|
||||
const { getSystemConfig } = require('./admin/system-config.service');
|
||||
return getSystemConfig('redis_url') || process.env.REDIS_URL || 'redis://redis:6379';
|
||||
} catch {
|
||||
return 'redis://redis:6379';
|
||||
}
|
||||
}
|
||||
|
||||
export async function connectRedis(): Promise<void> {
|
||||
const redis = createClient();
|
||||
try {
|
||||
await redis.connect();
|
||||
} catch (err) {
|
||||
console.warn('[Redis] Connection failed, running without cache');
|
||||
}
|
||||
}
|
||||
|
||||
export async function reconnectRedis(url: string): Promise<boolean> {
|
||||
try {
|
||||
if (client) {
|
||||
await client.quit().catch(() => {});
|
||||
client = null;
|
||||
}
|
||||
const redis = createClient(url);
|
||||
await redis.connect();
|
||||
console.log('[Redis] Reconnected with new URL:', url);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[Redis] Reconnect failed:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function disconnectRedis(): Promise<void> {
|
||||
if (client) {
|
||||
await client.quit();
|
||||
client = null;
|
||||
currentUrl = '';
|
||||
console.log('[Redis] Disconnected');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test a Redis URL without affecting the running client.
|
||||
* @returns { ok: boolean, latency: number, info?: string }
|
||||
*/
|
||||
export async function testRedisConnection(url: string): Promise<{ ok: boolean; latency: number; info?: string }> {
|
||||
const start = Date.now();
|
||||
const testClient = new Redis(url, {
|
||||
maxRetriesPerRequest: 1,
|
||||
retryStrategy() { return null; },
|
||||
lazyConnect: true,
|
||||
connectTimeout: 5000,
|
||||
});
|
||||
try {
|
||||
await testClient.connect();
|
||||
const pong = await testClient.ping();
|
||||
const latency = Date.now() - start;
|
||||
await testClient.quit();
|
||||
return { ok: pong === 'PONG', latency, info: `响应时间 ${latency}ms` };
|
||||
} catch (err: any) {
|
||||
try { await testClient.disconnect(); } catch {}
|
||||
const latency = Date.now() - start;
|
||||
return { ok: false, latency, info: err.message || '连接失败' };
|
||||
}
|
||||
}
|
||||
|
||||
export class RedisClient {
|
||||
private redis: Redis;
|
||||
|
||||
constructor() {
|
||||
this.redis = getRedisClient();
|
||||
}
|
||||
|
||||
async get(key: string): Promise<string | null> {
|
||||
try {
|
||||
return await this.redis.get(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async set(key: string, value: string): Promise<void> {
|
||||
try {
|
||||
await this.redis.set(key, value);
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
}
|
||||
|
||||
async setEx(key: string, ttl: number, value: string): Promise<void> {
|
||||
try {
|
||||
await this.redis.setex(key, ttl, value);
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
}
|
||||
|
||||
async del(key: string): Promise<void> {
|
||||
try {
|
||||
await this.redis.del(key);
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
}
|
||||
|
||||
async exists(key: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await this.redis.exists(key);
|
||||
return result === 1;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default RedisClient;
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
import rateLimit from 'express-rate-limit';
|
||||
|
||||
/** 公开搜索接口:较宽松 */
|
||||
export const searchLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 150,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => req.socket.remoteAddress ?? 'unknown',
|
||||
message: { error: '搜索请求过于频繁,请稍后再试', code: 429 },
|
||||
});
|
||||
|
||||
/** 管理接口(admin/*):较严格 */
|
||||
export const adminLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 30,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => req.socket.remoteAddress ?? 'unknown',
|
||||
message: { error: '操作过于频繁,请稍后再试', code: 429 },
|
||||
});
|
||||
|
||||
/** 登录接口:极严格,防暴力破解 */
|
||||
export const loginLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 5,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => req.socket.remoteAddress ?? 'unknown',
|
||||
message: { error: '登录尝试次数过多,请一分钟后重试', code: 429 },
|
||||
});
|
||||
|
||||
/** 转存/保存接口:中等等级 */
|
||||
export const saveLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 30,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => req.socket.remoteAddress ?? 'unknown',
|
||||
message: { error: '转存操作过于频繁,请稍后再试', code: 429 },
|
||||
});
|
||||
|
||||
/** 默认全局限流(兜底,未匹配上述规则的路由) */
|
||||
const defaultLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 100,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => req.socket.remoteAddress ?? 'unknown',
|
||||
message: { error: 'Too many requests, please try again later.', code: 429 },
|
||||
});
|
||||
|
||||
export default defaultLimiter;
|
||||
Executable
+137
@@ -0,0 +1,137 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
// Native fetch available in Node 20+
|
||||
import { getSystemConfig } from '../admin/system-config.service';
|
||||
|
||||
const PANSOU_UPSTREAM = 'http://pansou:80';
|
||||
|
||||
// Content types that need sub_filter path rewriting
|
||||
const TEXT_TYPES = ['text/html', 'application/javascript', 'text/javascript'];
|
||||
|
||||
// Hop-by-hop headers that should not be forwarded
|
||||
const HOP_HEADERS = new Set([
|
||||
'host', 'connection', 'content-length', 'transfer-encoding',
|
||||
'keep-alive', 'proxy-authenticate', 'proxy-authorization',
|
||||
'te', 'trailer', 'upgrade',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Apply sub_filter string replacements to HTML/JS content.
|
||||
* This matches what the nginx pansou.conf sub_filter does.
|
||||
*/
|
||||
function applySubFilter(text: string): string {
|
||||
return text
|
||||
// Replace HTML/JS path references: /api/ -> /pansou/api/
|
||||
.replace(/\/api\//g, '/pansou/api/')
|
||||
// baseURL rewrite (Vue SPA config)
|
||||
.replace(/baseURL:"\/api"/g, 'baseURL:"/pansou/api"')
|
||||
.replace(/baseURL:'\/api'/g, "baseURL:'/pansou/api'")
|
||||
// Static asset path rewrites
|
||||
.replace(/src="\/assets\//g, 'src="/pansou/assets/')
|
||||
.replace(/src='\/assets\//g, "src='/pansou/assets/")
|
||||
.replace(/href="\/assets\//g, 'href="/pansou/assets/')
|
||||
.replace(/href='\/assets\//g, "href='/pansou/assets/")
|
||||
// Favicon path rewrite
|
||||
.replace(/href="\/favicon\.ico/g, 'href="/pansou/favicon.ico')
|
||||
.replace(/href='\/favicon\.ico/g, "href='/pansou/favicon.ico");
|
||||
}
|
||||
|
||||
/**
|
||||
* Express middleware that proxies /pansou/* requests to the PanSou web container.
|
||||
*
|
||||
* How it works:
|
||||
* 1. Strips the /pansou prefix from the request path
|
||||
* 2. Forwards the request to http://pansou:80/{path}
|
||||
* 3. For HTML/JS responses, applies sub_filter path rewriting
|
||||
* so that /api/ becomes /pansou/api/ and /assets/ becomes /pansou/assets/
|
||||
* 4. For static assets (CSS, images, fonts), pipes through as-is
|
||||
*
|
||||
* Controlled by system config key 'pansou_web_enabled' (true/false).
|
||||
*/
|
||||
export async function pansouWebProxy(req: Request, res: Response, _next: NextFunction): Promise<void> {
|
||||
try {
|
||||
// Check if PanSou web is enabled
|
||||
const enabled = getSystemConfig('pansou_web_enabled');
|
||||
if (enabled !== 'true') {
|
||||
res.status(404).send('PanSou Web UI is disabled by administrator');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build upstream URL: strip /pansou prefix
|
||||
let targetPath = req.path;
|
||||
targetPath = targetPath.replace(/^\/pansou/, '') || '/';
|
||||
|
||||
// Preserve query string
|
||||
const queryIndex = req.url.indexOf('?');
|
||||
const query = queryIndex >= 0 ? req.url.substring(queryIndex) : '';
|
||||
const upstreamUrl = `${PANSOU_UPSTREAM}${targetPath}${query}`;
|
||||
|
||||
// Build forwarded headers (filter out hop-by-hop headers)
|
||||
const forwardHeaders: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(req.headers)) {
|
||||
if (!HOP_HEADERS.has(key.toLowerCase()) && value !== undefined) {
|
||||
forwardHeaders[key] = Array.isArray(value) ? value.join(', ') : value;
|
||||
}
|
||||
}
|
||||
// Override Host header to target the upstream
|
||||
forwardHeaders['Host'] = 'pansou';
|
||||
// Remove Accept-Encoding so we get uncompressed content for text rewriting
|
||||
forwardHeaders['accept-encoding'] = '';
|
||||
|
||||
// Forward the request
|
||||
const response = await fetch(upstreamUrl, {
|
||||
method: req.method as any,
|
||||
headers: forwardHeaders,
|
||||
body: ['GET', 'HEAD'].includes(req.method) ? undefined : JSON.stringify(req.body),
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
|
||||
// Set response status
|
||||
res.status(response.status);
|
||||
|
||||
// Handle redirects - rewrite Location header to include /pansou prefix
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const location = response.headers.get('location');
|
||||
if (location) {
|
||||
if (location.startsWith('/')) {
|
||||
res.setHeader('location', '/pansou' + location);
|
||||
} else {
|
||||
res.setHeader('location', location);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For HTML/JS content, apply sub_filter string replacements
|
||||
if (TEXT_TYPES.some(t => contentType.includes(t))) {
|
||||
const text = await response.text();
|
||||
const modified = applySubFilter(text);
|
||||
res.setHeader('content-type', contentType);
|
||||
// Remove content-encoding since we decompressed
|
||||
res.setHeader('content-length', Buffer.byteLength(modified, 'utf-8').toString());
|
||||
res.send(modified);
|
||||
return;
|
||||
}
|
||||
|
||||
// For other content (CSS, images, fonts, etc.), pipe through as-is
|
||||
const excludedHeaders = new Set([
|
||||
'content-encoding', 'content-length', 'transfer-encoding',
|
||||
'keep-alive', 'connection',
|
||||
]);
|
||||
response.headers.forEach((value, key) => {
|
||||
if (!excludedHeaders.has(key.toLowerCase())) {
|
||||
res.setHeader(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
// Use buffer for reliability
|
||||
const buffer = await response.arrayBuffer().then(buf => Buffer.from(buf));
|
||||
res.end(buffer);
|
||||
} catch (err: any) {
|
||||
console.error(`[PanSou Web Proxy] Error proxying ${req.path}:`, err.message);
|
||||
if (!res.headersSent) {
|
||||
res.status(502).send(`PanSou Web proxy error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,674 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
// Native fetch available in Node 20+
|
||||
import fs from "fs";
|
||||
import { execSync } from 'child_process';
|
||||
import { adminLimiter, loginLimiter } from '../middleware/rate-limit';
|
||||
import { getSaveRecords } from '../cloud/cloud.service';
|
||||
import { getCloudConfigs, getCloudConfigById, saveCloudConfig, deleteCloudConfig, getCloudConfigByType, testCloudConnection, testCloudConnectionWithCookie } from '../cloud/credential.service';
|
||||
import { dailyCheckIn, skipCheckin, getCheckinSummary, getDrivesForCheckin } from '../cloud/checkin.service';
|
||||
import { getAllCloudTypes } from '../cloud/cloud-types.service';
|
||||
import { login, authMiddleware, verifyToken, changePassword } from '../admin/auth.service';
|
||||
import { getStats } from '../admin/stats.service';
|
||||
import { getAllSystemConfigs, updateSystemConfig, updateSystemConfigs, getSystemConfig } from '../admin/system-config.service';
|
||||
import { getDb } from '../database/database';
|
||||
import { reconnectRedis, testRedisConnection } from '../middleware/cache';
|
||||
import { startQrLogin, getQrLoginStatus, cancelQrLogin } from '../cloud/qr-login.service';
|
||||
import { BaiduDriver } from '../cloud/drivers/baidu.driver';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// Public routes (no auth required)
|
||||
// ═══════════════════════════════════════
|
||||
|
||||
/**
|
||||
* POST /api/admin/login
|
||||
* Admin login
|
||||
*/
|
||||
router.post('/admin/login', loginLimiter, (req: Request, res: Response) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
if (!username || !password) {
|
||||
res.status(400).json({ error: 'Username and password are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = login(username, password);
|
||||
if (!token) {
|
||||
res.status(401).json({ error: 'Invalid credentials' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ token });
|
||||
} catch (err: any) {
|
||||
console.error('[Login] Error:', err);
|
||||
res.status(500).json({ error: err.message || 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/admin/cloud-types
|
||||
* List all cloud types (public, read-only).
|
||||
*/
|
||||
router.get('/admin/cloud-types', (_req: Request, res: Response) => {
|
||||
try {
|
||||
const types = getAllCloudTypes();
|
||||
res.json({ types });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message || 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// QR Login routes (no auth — user not logged in yet)
|
||||
// MUST be before authMiddleware!
|
||||
// ═══════════════════════════════════════
|
||||
|
||||
// ===== 夸克扫码登录 =====
|
||||
router.post('/admin/quark/qr-login/start', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const result = await startQrLogin();
|
||||
res.json({ ok: true, ...result });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/admin/quark/qr-login/:sessionId/status', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const sessionId = req.params.sessionId as string;
|
||||
const result = await getQrLoginStatus(sessionId);
|
||||
res.json({ ok: true, ...result });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/admin/quark/qr-login/:sessionId/cancel', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const sessionId = req.params.sessionId as string;
|
||||
await cancelQrLogin(sessionId);
|
||||
res.json({ ok: true });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ===== 百度扫码登录 =====
|
||||
router.post("/admin/baidu/qr-login/start", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const result = await BaiduDriver.startQrLogin();
|
||||
res.json({ ok: true, ...result });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/admin/baidu/qr-login/:sessionId/status", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const sessionId = req.params.sessionId as string;
|
||||
const result: any = await BaiduDriver.getQrLoginStatus(sessionId);
|
||||
// Map to frontend-expected format (frontend reads data.cookie)
|
||||
res.json({
|
||||
ok: true,
|
||||
status: result.status,
|
||||
cookie: result.cookie || result.access_token || "",
|
||||
nickname: result.nickname || "",
|
||||
storage_used: result.storage_used || "",
|
||||
storage_total: result.storage_total || "",
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/admin/baidu/qr-login/:sessionId/cancel", async (req: Request, res: Response) => {
|
||||
try {
|
||||
BaiduDriver.cancelQrLogin(req.params.sessionId as string);
|
||||
} catch {}
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// Auth wall — all routes below require JWT
|
||||
// ═══════════════════════════════════════
|
||||
router.use('/admin', authMiddleware);
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// Cloud Configs CRUD
|
||||
// ═══════════════════════════════════════
|
||||
|
||||
/** GET /api/admin/cloud-configs — list all cloud configs */
|
||||
router.get('/admin/cloud-configs', (_req: Request, res: Response) => {
|
||||
try {
|
||||
const configs = getCloudConfigs();
|
||||
res.json(configs);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message || 'Failed to fetch cloud configs' });
|
||||
}
|
||||
});
|
||||
|
||||
/** POST /api/admin/cloud-configs — create or smart-replace a cloud config */
|
||||
router.post('/admin/cloud-configs', (req: Request, res: Response) => {
|
||||
try {
|
||||
const data = req.body;
|
||||
if (!data.cloud_type) {
|
||||
res.status(400).json({ error: 'cloud_type is required' });
|
||||
return;
|
||||
}
|
||||
// Normalize is_active: frontend sends boolean, SQLite needs 0/1
|
||||
if (typeof data.is_active === 'boolean') data.is_active = data.is_active ? 1 : 0;
|
||||
const saved = saveCloudConfig(data);
|
||||
res.json(saved);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message || 'Failed to save cloud config' });
|
||||
}
|
||||
});
|
||||
|
||||
/** PUT /api/admin/cloud-configs/:id — update an existing cloud config */
|
||||
router.put('/admin/cloud-configs/:id', (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id as string);
|
||||
const existing = getCloudConfigById(id);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: 'Cloud config not found' });
|
||||
return;
|
||||
}
|
||||
const saved = saveCloudConfig({ ...req.body, id });
|
||||
res.json(saved);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message || 'Failed to update cloud config' });
|
||||
}
|
||||
});
|
||||
|
||||
/** DELETE /api/admin/cloud-configs/:id */
|
||||
router.delete('/admin/cloud-configs/:id', (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id as string);
|
||||
const ok = deleteCloudConfig(id);
|
||||
if (!ok) {
|
||||
res.status(404).json({ error: 'Cloud config not found' });
|
||||
return;
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message || 'Failed to delete cloud config' });
|
||||
}
|
||||
});
|
||||
|
||||
/** POST /api/admin/cloud-configs/:type/test — test cloud connection (by type or id) */
|
||||
router.post('/admin/cloud-configs/:type/test', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const type = req.params.type as string;
|
||||
const { cookie, id } = req.body;
|
||||
|
||||
// If cookie is provided directly, test with it (for new configs not yet saved)
|
||||
if (cookie) {
|
||||
const result = await testCloudConnectionWithCookie(type, cookie);
|
||||
res.json(result);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise test by config id
|
||||
if (id) {
|
||||
const result = await testCloudConnection(parseInt(id));
|
||||
res.json(result);
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(400).json({ success: false, message: 'Provide either cookie or id' });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ success: false, message: err.message || 'Connection test failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// Daily Check-in
|
||||
// ═══════════════════════════════════════
|
||||
|
||||
/** POST /api/admin/cloud-configs/:id/checkin */
|
||||
router.post('/admin/cloud-configs/:id/checkin', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id as string);
|
||||
const result = await dailyCheckIn(id);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ success: false, message: err.message || 'Check-in failed' });
|
||||
}
|
||||
});
|
||||
|
||||
/** POST /api/admin/cloud-configs/:id/skip-checkin */
|
||||
router.post('/admin/cloud-configs/:id/skip-checkin', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id as string);
|
||||
const ok = skipCheckin(id);
|
||||
res.json({ success: ok });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ success: false, message: err.message || 'Skip check-in failed' });
|
||||
}
|
||||
});
|
||||
|
||||
/** POST /api/admin/cloud-configs/checkin-all */
|
||||
router.post('/admin/cloud-configs/checkin-all', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const drives = getDrivesForCheckin();
|
||||
const results: { id: number; nickname: string; success: boolean; message: string }[] = [];
|
||||
let total = 0;
|
||||
for (const drive of drives) {
|
||||
total++;
|
||||
try {
|
||||
const result = await dailyCheckIn(drive.id);
|
||||
results.push({ id: drive.id, nickname: drive.nickname || '', success: result.success, message: result.message });
|
||||
} catch (err: any) {
|
||||
results.push({ id: drive.id, nickname: drive.nickname || '', success: false, message: err.message || 'Check-in failed' });
|
||||
}
|
||||
}
|
||||
res.json({ total, results });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message || 'Check-in all failed' });
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /api/admin/cloud-configs/checkin-summary */
|
||||
router.get('/admin/cloud-configs/checkin-summary', (_req: Request, res: Response) => {
|
||||
try {
|
||||
const summary = getCheckinSummary();
|
||||
res.json(summary);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message || 'Failed to get check-in summary' });
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// Stats
|
||||
// ═══════════════════════════════════════
|
||||
|
||||
/** GET /api/admin/stats */
|
||||
router.get('/admin/stats', (req: Request, res: Response) => {
|
||||
try {
|
||||
const days = req.query.days ? parseInt(req.query.days as string) : 7;
|
||||
const stats = getStats(days);
|
||||
res.json(stats);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message || 'Failed to get stats' });
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// Save Records (转存日志)
|
||||
// ═══════════════════════════════════════
|
||||
|
||||
/** GET /api/admin/save-records */
|
||||
router.get('/admin/save-records', (req: Request, res: Response) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const pageSize = parseInt(req.query.pageSize as string) || 20;
|
||||
const startDate = req.query.startDate as string | undefined;
|
||||
const endDate = req.query.endDate as string | undefined;
|
||||
const status = req.query.status as string | undefined;
|
||||
const sourceType = req.query.sourceType as string | undefined;
|
||||
const keyword = req.query.keyword as string | undefined;
|
||||
const result = getSaveRecords(page, pageSize, startDate, endDate, status, sourceType, keyword);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message || 'Failed to get save records' });
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// System Configs
|
||||
// ═══════════════════════════════════════
|
||||
|
||||
/** GET /api/admin/system-configs */
|
||||
router.get('/admin/system-configs', (_req: Request, res: Response) => {
|
||||
try {
|
||||
const configs = getAllSystemConfigs();
|
||||
res.json(configs);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message || 'Failed to get system configs' });
|
||||
}
|
||||
});
|
||||
|
||||
/** PUT /api/admin/system-configs — batch update */
|
||||
router.put('/admin/system-configs', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { entries } = req.body;
|
||||
if (!entries || !Array.isArray(entries)) {
|
||||
res.status(400).json({ error: 'entries array is required' });
|
||||
return;
|
||||
}
|
||||
updateSystemConfigs(entries);
|
||||
res.json({ success: true });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message || 'Failed to update system configs' });
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// Cloud Types Toggle
|
||||
// ═══════════════════════════════════════
|
||||
|
||||
/** PUT /api/admin/cloud-types — toggle cloud type enabled/disabled */
|
||||
router.put('/admin/cloud-types', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { type, enabled } = req.body;
|
||||
if (!type) {
|
||||
res.status(400).json({ error: 'type is required' });
|
||||
return;
|
||||
}
|
||||
const db = getDb();
|
||||
db.prepare(
|
||||
`INSERT INTO system_configs (key, value, description) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
|
||||
).run(`cloud_type_${type}_enabled`, enabled ? '1' : '0', `Enable/disable ${type} cloud drive`);
|
||||
res.json({ success: true });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message || 'Failed to toggle cloud type' });
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// Change Password
|
||||
// ═══════════════════════════════════════
|
||||
|
||||
/** POST /api/admin/change-password */
|
||||
router.post('/admin/change-password', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { oldPassword, newPassword } = req.body;
|
||||
if (!oldPassword || !newPassword) {
|
||||
res.status(400).json({ error: 'Both old and new passwords are required' });
|
||||
return;
|
||||
}
|
||||
// Get username from JWT
|
||||
const authHeader = req.headers.authorization || '';
|
||||
const token = authHeader.replace('Bearer ', '');
|
||||
const payload = verifyToken(token);
|
||||
if (!payload) {
|
||||
res.status(401).json({ error: 'Invalid token' });
|
||||
return;
|
||||
}
|
||||
const result = changePassword(payload.username, oldPassword, newPassword);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message || 'Failed to change password' });
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// DB Status
|
||||
// ═══════════════════════════════════════
|
||||
|
||||
/** GET /api/admin/db-status */
|
||||
router.get('/admin/db-status', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const dbFile = getSystemConfig('db_path') || '';
|
||||
let dbSize = 'N/A';
|
||||
if (dbFile) {
|
||||
try {
|
||||
const stats = fs.statSync(dbFile);
|
||||
dbSize = (stats.size / 1024 / 1024).toFixed(2) + ' MB';
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const counts = {
|
||||
save_records: (db.prepare('SELECT COUNT(*) as c FROM save_records').get() as any)?.c || 0,
|
||||
search_stats: (db.prepare('SELECT COUNT(*) as c FROM search_stats').get() as any)?.c || 0,
|
||||
system_configs: (db.prepare('SELECT COUNT(*) as c FROM system_configs').get() as any)?.c || 0,
|
||||
cloud_configs: (db.prepare('SELECT COUNT(*) as c FROM cloud_configs').get() as any)?.c || 0,
|
||||
content_cache: (db.prepare('SELECT COUNT(*) as c FROM content_cache').get() as any)?.c || 0,
|
||||
};
|
||||
|
||||
// Redis status
|
||||
let redis_status = 'disconnected';
|
||||
let redis_url = getSystemConfig('redis_url') || '';
|
||||
try {
|
||||
const testResult = await testRedisConnection(redis_url);
|
||||
redis_status = testResult.ok ? 'connected' : 'disconnected';
|
||||
} catch {
|
||||
redis_status = 'error';
|
||||
}
|
||||
|
||||
res.json({
|
||||
db_size: dbSize,
|
||||
db_path: dbFile,
|
||||
...counts,
|
||||
redis_status,
|
||||
redis_url,
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message || 'Failed to get DB status' });
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// Test Redis Connection
|
||||
// ═══════════════════════════════════════
|
||||
|
||||
/** POST /api/admin/test-redis */
|
||||
router.post('/admin/test-redis', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { url } = req.body;
|
||||
if (!url) {
|
||||
res.status(400).json({ ok: false, info: 'Redis URL is required' });
|
||||
return;
|
||||
}
|
||||
const result = testRedisConnection(url);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ ok: false, info: err.message || 'Redis test failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// Test External Service
|
||||
// ═══════════════════════════════════════
|
||||
|
||||
/** POST /api/admin/test-external-service */
|
||||
router.post('/admin/test-external-service', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { type, url, token } = req.body;
|
||||
const start = Date.now();
|
||||
|
||||
switch (type) {
|
||||
case 'pansou': {
|
||||
const pansouUrl = url || getSystemConfig('pansou_url') || '';
|
||||
if (!pansouUrl) {
|
||||
res.json({ ok: false, info: 'PanSou URL not configured' });
|
||||
return;
|
||||
}
|
||||
const response = await fetch(pansouUrl + '/api/health', { signal: AbortSignal.timeout(8000) });
|
||||
const data: any = await response.json();
|
||||
const latency = Date.now() - start;
|
||||
res.json({
|
||||
ok: response.ok && data?.status === 'ok',
|
||||
latency,
|
||||
info: response.ok ? `连接成功 (${data?.channels_count || 0} 频道, ${data?.plugin_count || 0} 插件)` : '连接失败',
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'video_parser': {
|
||||
const parserUrl = url || getSystemConfig('video_parser_url') || '';
|
||||
if (!parserUrl) {
|
||||
res.json({ ok: false, info: 'Video Parser URL not configured' });
|
||||
return;
|
||||
}
|
||||
const response = await fetch(parserUrl + '/health', { signal: AbortSignal.timeout(8000) });
|
||||
const latency = Date.now() - start;
|
||||
res.json({
|
||||
ok: response.ok,
|
||||
latency,
|
||||
info: response.ok ? '连接成功' : `HTTP ${response.status}`,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'tmdb': {
|
||||
const tmdbToken = token || getSystemConfig('tmdb_api_key') || '';
|
||||
if (!tmdbToken) {
|
||||
res.json({ ok: false, info: 'TMDB API Key not configured' });
|
||||
return;
|
||||
}
|
||||
const response = await fetch('https://api.themoviedb.org/3/configuration', {
|
||||
headers: { Authorization: `Bearer ${tmdbToken}` },
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
const latency = Date.now() - start;
|
||||
res.json({
|
||||
ok: response.ok,
|
||||
latency,
|
||||
info: response.ok ? '连接成功' : `HTTP ${response.status}`,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'proxy': {
|
||||
const proxyUrl = url || getSystemConfig('proxy_url') || '';
|
||||
if (!proxyUrl) {
|
||||
res.json({ ok: false, info: 'Proxy URL not configured' });
|
||||
return;
|
||||
}
|
||||
const response = await fetch(proxyUrl, { signal: AbortSignal.timeout(8000) });
|
||||
const latency = Date.now() - start;
|
||||
res.json({
|
||||
ok: response.ok,
|
||||
latency,
|
||||
info: response.ok ? '连接成功' : `HTTP ${response.status}`,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'ip_geo': {
|
||||
const geoUrl = url || getSystemConfig('ip_geo_api_url') || '';
|
||||
if (!geoUrl) {
|
||||
res.json({ ok: false, info: '请先输入 IP 归属地查询 API 地址' });
|
||||
return;
|
||||
}
|
||||
const testUrl = geoUrl.replace('{ip}', '8.8.8.8');
|
||||
const response = await fetch(testUrl, { signal: AbortSignal.timeout(8000) });
|
||||
const data: any = await response.json();
|
||||
const latency = Date.now() - start;
|
||||
const valid = !!(data?.country || data?.region || data?.city || data?.countryCode);
|
||||
res.json({ ok: valid, latency, info: valid ? '连接成功' : '响应格式不符' });
|
||||
break;
|
||||
}
|
||||
default:
|
||||
res.json({ ok: false, info: `Unknown service type: ${type}` });
|
||||
}
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ ok: false, info: err.message || 'External service test failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// Pansou Info & Update
|
||||
// ═══════════════════════════════════════
|
||||
|
||||
/** GET /api/admin/pansou-info — pansou health + version + update check */
|
||||
router.get('/admin/pansou-info', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const baseUrl = getSystemConfig('pansou_url') || '';
|
||||
if (!baseUrl) {
|
||||
res.json({ status: 'disconnected', channelCount: 0, pluginCount: 0, diskCount: 0, version: '', hasUpdate: false, latestVersion: '' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch PanSou health
|
||||
const healthUrl = baseUrl + '/api/health';
|
||||
const response = await fetch(healthUrl, { signal: AbortSignal.timeout(8000) });
|
||||
const healthData: any = await response.json();
|
||||
const channelCount = healthData.channels_count || 0;
|
||||
const pluginCount = healthData.plugin_count || 0;
|
||||
|
||||
// Derive disk count from channel names
|
||||
const driveKeywords = ['aliyun', 'baidu', 'quark', '115', 'pikpak', 'xunlei', 'uc', '123', '139', '189', 'tianyi', 'netease'];
|
||||
const drives = new Set<string>();
|
||||
for (const ch of (healthData.channels || [])) {
|
||||
for (const kw of driveKeywords) {
|
||||
if (ch.toLowerCase().includes(kw)) { drives.add(kw); break; }
|
||||
}
|
||||
}
|
||||
const diskCount = drives.size || 5;
|
||||
|
||||
// Get local version from docker label
|
||||
let version = '';
|
||||
let hasUpdate = false;
|
||||
let latestVersion = '';
|
||||
try {
|
||||
const created = execSync(
|
||||
`docker inspect CloudSearch_PanSou --format '{{index .Config.Labels "org.opencontainers.image.created"}}'`,
|
||||
{ timeout: 5000, encoding: 'utf8' }
|
||||
).trim();
|
||||
version = created ? created.slice(0, 10) : '';
|
||||
|
||||
// Check update cache
|
||||
const cacheFile = '/tmp/pansou-update-cache.json';
|
||||
let cache: any = null;
|
||||
try { cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8') || 'null'); } catch {}
|
||||
const threeDays = 3 * 24 * 3600 * 1000;
|
||||
|
||||
if (!cache || (Date.now() - cache.checkedAt) > threeDays) {
|
||||
// Check GHCR for latest version
|
||||
try {
|
||||
const tokenRes = await fetch(
|
||||
'https://ghcr.io/token?scope=repository:fish2018/pansou-web:pull&service=ghcr.io'
|
||||
);
|
||||
const ghcrToken = (await tokenRes.json() as any).token;
|
||||
const manifestRes = await fetch(
|
||||
'https://ghcr.io/v2/fish2018/pansou-web/manifests/latest',
|
||||
{ headers: { Authorization: `Bearer ${ghcrToken}`, Accept: 'application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' } }
|
||||
);
|
||||
const manifestList: any = await manifestRes.json();
|
||||
const amd64 = manifestList.manifests?.find((m: any) => m.platform?.architecture === 'amd64' && m.platform?.os === 'linux');
|
||||
if (amd64) {
|
||||
const blobRes = await fetch(
|
||||
`https://ghcr.io/v2/fish2018/pansou-web/manifests/${amd64.digest}`,
|
||||
{ headers: { Authorization: `Bearer ${ghcrToken}`, Accept: 'application/vnd.oci.image.manifest.v1+json' } }
|
||||
);
|
||||
const blobData: any = await blobRes.json();
|
||||
const cfgDigest = blobData.config?.digest;
|
||||
if (cfgDigest) {
|
||||
const cfgRes = await fetch(
|
||||
`https://ghcr.io/v2/fish2018/pansou-web/blobs/${cfgDigest}`,
|
||||
{ headers: { Authorization: `Bearer ${ghcrToken}` } }
|
||||
);
|
||||
const cfgData: any = await cfgRes.json();
|
||||
const remoteCreated = cfgData.config?.Labels?.['org.opencontainers.image.created'];
|
||||
if (remoteCreated) {
|
||||
latestVersion = remoteCreated.slice(0, 10);
|
||||
if (version && latestVersion !== version) hasUpdate = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
fs.writeFileSync(cacheFile, JSON.stringify({ checkedAt: Date.now(), hasUpdate, latestVersion }));
|
||||
} else {
|
||||
hasUpdate = cache.hasUpdate;
|
||||
latestVersion = cache.latestVersion;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
res.json({
|
||||
status: response.ok ? 'connected' : 'disconnected',
|
||||
channelCount,
|
||||
pluginCount,
|
||||
diskCount,
|
||||
version,
|
||||
hasUpdate,
|
||||
latestVersion,
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.json({ status: 'error', channelCount: 0, pluginCount: 0, diskCount: 0, version: '', hasUpdate: false, latestVersion: '', error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/** POST /api/admin/update-pansou — pull latest pansou image + recreate container */
|
||||
router.post('/admin/update-pansou', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
execSync('docker pull ghcr.io/fish2018/pansou-web:latest', { timeout: 120000 });
|
||||
execSync('docker compose -p cloudsearch -f /app/docker-compose.yml up -d pansou', { timeout: 60000 });
|
||||
try { fs.unlinkSync('/tmp/pansou-update-cache.json'); } catch {}
|
||||
res.json({ success: true, message: 'PanSou 更新成功' });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ success: false, error: err.message || 'PanSou 更新失败' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { runFullCleanup, emptyAllTrash } from '../cloud/cleanup.service';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ============ Cleanup & Storage Management ============
|
||||
|
||||
/**
|
||||
* POST /api/admin/cleanup/run
|
||||
* Manually trigger a cleanup cycle:
|
||||
* - Trash old date folders from cloud drives
|
||||
* - Delete old save_records
|
||||
* - Empty recycle bin
|
||||
*/
|
||||
router.post('/admin/cleanup/run', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const stats = await runFullCleanup();
|
||||
res.json({
|
||||
success: stats.errors.length === 0,
|
||||
files_trashed: stats.filesTrashed,
|
||||
logs_deleted: stats.logsDeleted,
|
||||
trash_emptied: stats.trashEmptied,
|
||||
errors: stats.errors,
|
||||
message: stats.errors.length === 0
|
||||
? `✅ 清理完成:移入回收站 ${stats.filesTrashed} 个文件夹,删除 ${stats.logsDeleted} 条日志,清空回收站${stats.trashEmptied ? '✓' : '-'}`
|
||||
: `清理完成,但有 ${stats.errors.length} 个错误`,
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/cleanup/empty-trash
|
||||
* Empty recycle bin for all cloud drives (permanently delete, frees space).
|
||||
*/
|
||||
router.post('/admin/cleanup/empty-trash', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const result = await emptyAllTrash();
|
||||
res.json({
|
||||
success: result.errors.length === 0,
|
||||
emptied: result.emptied,
|
||||
errors: result.errors,
|
||||
message: result.emptied
|
||||
? '✅ 回收站已清空,存储空间已释放'
|
||||
: (result.errors.length > 0 ? `清空回收站部分失败:${result.errors.join('; ')}` : '没有可清空的网盘'),
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* Extract genre tags from search result titles.
|
||||
*/
|
||||
function extractTagsFromResults(results: any[], keyword: string): string[] {
|
||||
const tags: string[] = [];
|
||||
if (keyword) tags.push(keyword);
|
||||
|
||||
const genreKeywords: Record<string, string> = {
|
||||
'动画': '动画', '动漫': '动画', '国漫': '国漫',
|
||||
'剧场版': '剧场版', '年番': '年番',
|
||||
'动作': '动作', '奇幻': '奇幻', '玄幻': '玄幻',
|
||||
'仙侠': '仙侠', '古装': '古装', '爱情': '爱情',
|
||||
'科幻': '科幻', '喜剧': '喜剧', '悬疑': '悬疑',
|
||||
'恐怖': '恐怖', '惊悚': '惊悚', '剧情': '剧情',
|
||||
'冒险': '冒险', '战争': '战争', '武侠': '武侠',
|
||||
'纪录': '纪录片', '真人': '真人秀', '短片': '短片',
|
||||
};
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (const r of results) {
|
||||
const title = (r.title || r.note || '') as string;
|
||||
for (const [key, val] of Object.entries(genreKeywords)) {
|
||||
if (title.includes(key) && !seen.has(val)) {
|
||||
seen.add(val);
|
||||
tags.push(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
|
||||
export default router;
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
import { Router } from 'express';
|
||||
import searchRoutes from './search.routes';
|
||||
import adminRoutes from './admin.routes';
|
||||
import uploadRoutes from './upload.routes';
|
||||
import cleanupRoutes from './cleanup.routes';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(searchRoutes);
|
||||
router.use(adminRoutes);
|
||||
router.use(uploadRoutes);
|
||||
router.use(cleanupRoutes);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,630 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
// Native fetch available in Node 20+
|
||||
import { searchLimiter, saveLimiter } from '../middleware/rate-limit';
|
||||
import { detectIntent } from '../intent/intent.service';
|
||||
import { search, applyTitleFilter } from '../search/search.service';
|
||||
import { getRankings, getHotKeywords, getCategorizedRankings } from '../search/rankings.service';
|
||||
import { parseVideo } from '../video/video.service';
|
||||
import { saveFromShare } from '../cloud/cloud.service';
|
||||
import { getEnabledCloudTypeSet } from '../cloud/cloud-types.service';
|
||||
import { getSystemConfig } from '../admin/system-config.service';
|
||||
import { verifyToken } from '../admin/auth.service';
|
||||
import { LinkValidator } from '../validation/link-validator.service';
|
||||
import { getContentInfo } from '../content/content.service';
|
||||
import { detectCloudType } from '../config/cloud-labels';
|
||||
import { CLOUD_LABELS, CLOUD_COLORS } from '../config/cloud-labels';
|
||||
import { getDb } from '../database/database';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ============ Search & Query ============
|
||||
|
||||
/**
|
||||
* POST /api/query
|
||||
* Intent recognition + execution
|
||||
*/
|
||||
router.post('/query', searchLimiter, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { input, q } = req.body;
|
||||
const query = input || q;
|
||||
if (!query || typeof query !== 'string') {
|
||||
res.status(400).json({ error: 'Input is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const intent = detectIntent(query);
|
||||
const ip = req.ip || req.socket.remoteAddress || '';
|
||||
|
||||
switch (intent.type) {
|
||||
case 'SEARCH': {
|
||||
const result = await search(intent.cleanInput, 1, ip);
|
||||
|
||||
// Pass through: use all results, group by cloud type
|
||||
const allResults = result.results || [];
|
||||
|
||||
// Transform to frontend-friendly format
|
||||
let formatted = (allResults || []).map((item: any, idx: number) => ({
|
||||
id: `search_${idx}`,
|
||||
title: filterTitle(item.title || item.content || ''),
|
||||
description: item.content || '',
|
||||
share_url: item.url || '',
|
||||
cloud_type: detectCloudType(item.url || ''),
|
||||
file_size: '',
|
||||
update_time: item.datetime || '',
|
||||
source: item.source || '',
|
||||
file_id: '',
|
||||
cover: Array.isArray(item.images) && item.images.length > 0 ? item.images[0] : '',
|
||||
password: item.password || '',
|
||||
}));
|
||||
|
||||
// Filter out expired/invalid links
|
||||
formatted = formatted.filter(r => !r.share_url || !isExpiredShareLink(r.share_url));
|
||||
|
||||
// Filter by enabled cloud types (admin-configurable per-type toggle)
|
||||
// Skip filter if search_all_channels is enabled
|
||||
const searchAllChannels = getSystemConfig('search_all_channels') === 'true';
|
||||
if (!searchAllChannels) {
|
||||
const enabledSet = getEnabledCloudTypeSet();
|
||||
formatted = formatted.filter(r => !r.cloud_type || enabledSet.has(r.cloud_type));
|
||||
}
|
||||
|
||||
const contentQuery = intent.cleanInput || query;
|
||||
const contentInfo = await getContentInfo(contentQuery).catch(() => null);
|
||||
const extractedTags = extractTagsFromResults(formatted, contentQuery);
|
||||
const linkValidationEnabled = getSystemConfig('link_validation_enabled') !== 'false';
|
||||
|
||||
// Set up streaming response (NDJSON)
|
||||
res.setHeader('Content-Type', 'application/x-ndjson');
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
|
||||
// 0. Send searching signal immediately so frontend shows feedback
|
||||
res.write(JSON.stringify({ type: 'searching' }) + '\n');
|
||||
|
||||
// 0.5 Query local DB for previously saved resources matching keyword
|
||||
const savedResults = getSavedResources(intent.cleanInput);
|
||||
if (savedResults.length > 0) {
|
||||
res.write(JSON.stringify({
|
||||
type: 'saved',
|
||||
results: savedResults,
|
||||
total: savedResults.length,
|
||||
}) + '\n');
|
||||
}
|
||||
|
||||
// 1. Send stats immediately
|
||||
const fallbackImage = getSystemConfig('search_fallback_image') || '';
|
||||
const siteLogo = getSystemConfig('site_logo') || '';
|
||||
const siteNameInStats = getSystemConfig('site_name') || 'CloudSearch';
|
||||
const siteDisclaimer = getSystemConfig('site_disclaimer') || '';
|
||||
const siteMarquee = getSystemConfig('site_marquee') || '';
|
||||
const statsPayload = {
|
||||
type: 'stats',
|
||||
total: formatted.length,
|
||||
channels: groupResultsByChannel(formatted, (item: any) => item.cloud_type),
|
||||
content_info: contentInfo,
|
||||
content_tags: extractedTags,
|
||||
link_validation: linkValidationEnabled,
|
||||
fallback_image: fallbackImage,
|
||||
site_logo: siteLogo,
|
||||
site_name: siteNameInStats,
|
||||
site_disclaimer: siteDisclaimer,
|
||||
site_marquee: siteMarquee,
|
||||
};
|
||||
res.write(JSON.stringify(statsPayload) + '\n');
|
||||
|
||||
// 2. Validate links — per-type grouping, newest-first, per-type cap from config
|
||||
if (linkValidationEnabled) {
|
||||
const validator = new LinkValidator();
|
||||
const resultLimit = parseInt(getSystemConfig('search_result_limit') || '10', 10);
|
||||
const MAX_VALID_PER_TYPE = Math.min(100, Math.max(1, resultLimit)); // configurable, 1-100
|
||||
const MAX_TOTAL_VALID = MAX_VALID_PER_TYPE * 6; // up to 6 cloud types
|
||||
const pool = validator['pool']; // concurrency: 10
|
||||
|
||||
// Group formatted results by cloud_type, then sort each group by time desc
|
||||
const byType: Record<string, any[]> = {};
|
||||
for (const item of formatted) {
|
||||
const ct = item.cloud_type || 'others';
|
||||
if (!byType[ct]) byType[ct] = [];
|
||||
byType[ct].push(item);
|
||||
}
|
||||
|
||||
// Sort each group by update_time descending (newest first)
|
||||
for (const ct of Object.keys(byType)) {
|
||||
byType[ct].sort((a: any, b: any) => {
|
||||
const ta = a.update_time || '';
|
||||
const tb = b.update_time || '';
|
||||
if (!ta && !tb) return 0;
|
||||
if (!ta) return 1;
|
||||
if (!tb) return -1;
|
||||
return tb.localeCompare(ta);
|
||||
});
|
||||
}
|
||||
|
||||
// Build a round-robin validation queue: interleave items from each type
|
||||
// to give fair priority across all cloud types
|
||||
const typeOrder = ['quark', 'baidu', 'aliyun', '115', 'tianyi', '123pan', 'uc', 'xunlei', 'pikpak', 'magnet', 'ed2k', 'others'];
|
||||
const sortedTypes = typeOrder.filter(ct => byType[ct] && byType[ct].length > 0);
|
||||
// Sort by total count descending so types with more results get more validation slots
|
||||
sortedTypes.sort((a, b) => (byType[b]?.length || 0) - (byType[a]?.length || 0));
|
||||
|
||||
const validationQueue: { item: any; type: string }[] = [];
|
||||
const maxLen = Math.max(...sortedTypes.map(ct => byType[ct].length), 0);
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
for (const ct of sortedTypes) {
|
||||
if (i < byType[ct].length) {
|
||||
validationQueue.push({ item: byType[ct][i], type: ct });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const validResults: any[] = [];
|
||||
const perTypeValid: Record<string, number> = {};
|
||||
let totalValid = 0;
|
||||
let totalInvalid = 0;
|
||||
let totalChecked = 0;
|
||||
const unknownItemIds: number[] = []; // IDs that got 'unknown' from PanSou
|
||||
|
||||
// Pass 1: PanSou-only validation
|
||||
const tasks = validationQueue.map(({ item, type }) => pool.run(async () => {
|
||||
// Stop if we've hit overall cap or per-type cap
|
||||
if (totalValid >= MAX_TOTAL_VALID) return;
|
||||
if ((perTypeValid[type] || 0) >= MAX_VALID_PER_TYPE) return;
|
||||
|
||||
totalChecked++;
|
||||
try {
|
||||
const vr = await validator.validate(item.share_url, item.cloud_type);
|
||||
// 'unknown' = PanSou couldn't determine → treat as valid for now
|
||||
if (vr.status === 'valid' || vr.status === 'unknown') {
|
||||
if (vr.status === 'unknown') {
|
||||
unknownItemIds.push(item.id);
|
||||
}
|
||||
if (totalValid < MAX_TOTAL_VALID && (perTypeValid[type] || 0) < MAX_VALID_PER_TYPE) {
|
||||
validResults.push(item);
|
||||
perTypeValid[type] = (perTypeValid[type] || 0) + 1;
|
||||
totalValid++;
|
||||
res.write(JSON.stringify({ type: 'result', id: item.id, valid: true, message: vr.message }) + '\n');
|
||||
}
|
||||
} else {
|
||||
totalInvalid++;
|
||||
res.write(JSON.stringify({ type: 'result', id: item.id, valid: false, message: vr.message }) + '\n');
|
||||
}
|
||||
} catch {
|
||||
if (totalValid < MAX_TOTAL_VALID && (perTypeValid[type] || 0) < MAX_VALID_PER_TYPE) {
|
||||
validResults.push(item);
|
||||
perTypeValid[type] = (perTypeValid[type] || 0) + 1;
|
||||
totalValid++;
|
||||
}
|
||||
res.write(JSON.stringify({ type: 'result', id: item.id, valid: true }) + '\n');
|
||||
}
|
||||
}));
|
||||
await Promise.all(tasks);
|
||||
|
||||
// Pass 2: If PanSou didn't provide enough valid results, validate
|
||||
// uncertain items with local fallback (external API calls)
|
||||
if (totalValid < MAX_TOTAL_VALID && unknownItemIds.length > 0) {
|
||||
const unknownItems = validationQueue.filter(({ item }) => unknownItemIds.includes(item.id));
|
||||
for (const { item, type } of unknownItems) {
|
||||
if (totalValid >= MAX_TOTAL_VALID) break;
|
||||
if ((perTypeValid[type] || 0) >= MAX_VALID_PER_TYPE) break;
|
||||
try {
|
||||
const vr = await validator.validateWithLocalFallback(item.share_url, item.cloud_type);
|
||||
if (vr.status === 'valid') {
|
||||
// Already in validResults from pass 1, just count it again
|
||||
perTypeValid[type] = (perTypeValid[type] || 0) + 1;
|
||||
totalValid++;
|
||||
res.write(JSON.stringify({ type: 'result', id: item.id, valid: true, message: vr.message + ' (本地确认)' }) + '\n');
|
||||
} else if (vr.status === 'invalid') {
|
||||
// Remove from validResults — was previously included as unknown
|
||||
const idx = validResults.findIndex(r => r.id === item.id);
|
||||
if (idx >= 0) {
|
||||
validResults.splice(idx, 1);
|
||||
perTypeValid[type] = Math.max(0, (perTypeValid[type] || 1) - 1);
|
||||
totalValid--;
|
||||
}
|
||||
totalInvalid++;
|
||||
res.write(JSON.stringify({ type: 'result', id: item.id, valid: false, message: vr.message + ' (本地确认失效)' }) + '\n');
|
||||
}
|
||||
} catch {
|
||||
// Keep as-is (already treated as valid from pass 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const skippedCount = validationQueue.length - totalChecked;
|
||||
|
||||
res.write(JSON.stringify({
|
||||
type: 'complete',
|
||||
results: validResults,
|
||||
channels: groupResultsByChannel(validResults, (item: any) => item.cloud_type),
|
||||
total: validResults.length,
|
||||
filtered: totalInvalid,
|
||||
per_type: perTypeValid,
|
||||
skipped: skippedCount,
|
||||
}) + '\n');
|
||||
} else {
|
||||
// No validation - just send all results
|
||||
res.write(JSON.stringify({
|
||||
type: 'complete',
|
||||
results: formatted,
|
||||
channels: groupResultsByChannel(formatted, (item: any) => item.cloud_type),
|
||||
total: formatted.length,
|
||||
filtered: 0,
|
||||
}) + '\n');
|
||||
}
|
||||
|
||||
res.end();
|
||||
break;
|
||||
}
|
||||
case 'VIDEO_PARSE': {
|
||||
const videoInfo = await parseVideo(intent.cleanInput);
|
||||
res.json({ intent: intent.type, platform: intent.platform, data: videoInfo });
|
||||
break;
|
||||
}
|
||||
case 'CLOUD_SAVE': {
|
||||
const result = await saveFromShare(intent.cleanInput, intent.platform || '', undefined, req.ip);
|
||||
res.json({ intent: intent.type, platform: intent.platform, ...result });
|
||||
break;
|
||||
}
|
||||
default:
|
||||
res.status(400).json({ error: 'Unknown intent type' });
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[Query] Error:', err);
|
||||
res.status(500).json({ error: err.message || 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/search
|
||||
* Search with optional link validation filtering
|
||||
*/
|
||||
router.get('/search', searchLimiter, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const keyword = (req.query.q || req.query.kw) as string;
|
||||
const page = parseInt(req.query.page as string || '1', 10);
|
||||
const ip = req.ip || req.socket.remoteAddress || '';
|
||||
|
||||
if (!keyword) {
|
||||
res.status(400).json({ error: 'Query parameter "q" is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await search(keyword, page, ip);
|
||||
|
||||
// Pass through: use all results
|
||||
const allResults = result.results || [];
|
||||
|
||||
// Transform to frontend format
|
||||
let formatted = (allResults || []).map((item: any) => ({
|
||||
id: item.id || '',
|
||||
title: filterTitle(item.title || item.content || ''),
|
||||
description: item.content || item.snippet || '',
|
||||
share_url: item.url || '',
|
||||
cloud_type: detectCloudType(item.url || ''),
|
||||
file_size: '',
|
||||
source: item.source || '',
|
||||
datetime: item.datetime || '',
|
||||
cover: Array.isArray(item.images) && item.images.length > 0 ? item.images[0] : '',
|
||||
password: item.password || '',
|
||||
}));
|
||||
|
||||
// Filter out expired/invalid links
|
||||
const expiredCount = formatted.filter(r => r.share_url && isExpiredShareLink(r.share_url)).length;
|
||||
formatted = formatted.filter(r => !r.share_url || !isExpiredShareLink(r.share_url));
|
||||
|
||||
// Filter by enabled cloud types (admin-configurable per-type toggle)
|
||||
const enabledSet = getEnabledCloudTypeSet();
|
||||
formatted = formatted.filter(r => !r.cloud_type || enabledSet.has(r.cloud_type));
|
||||
|
||||
// Return results immediately without blocking validation
|
||||
const channels = groupResultsByChannel(formatted, (item: any) =>
|
||||
detectCloudType(item.url || '')
|
||||
);
|
||||
|
||||
res.json({
|
||||
results: formatted,
|
||||
channels,
|
||||
total: formatted.length,
|
||||
filtered: expiredCount,
|
||||
link_validation: false,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('[Search] Error:', err);
|
||||
res.status(500).json({ error: err.message || 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Load title filter rules from DB and apply to a title.
|
||||
*/
|
||||
function filterTitle(title: string): string {
|
||||
const rules = getSystemConfig('title_filter_rules') || '';
|
||||
return applyTitleFilter(title, rules);
|
||||
}
|
||||
|
||||
// detectCloudType is imported from config/cloud-labels
|
||||
|
||||
// 检测失效的分享链接(支持多种模式)
|
||||
function isExpiredShareLink(url: string): boolean {
|
||||
if (!url) return false;
|
||||
|
||||
// 空链接/纯片段(无实际链接内容)
|
||||
if (url.startsWith('#') || url.length < 10) return true;
|
||||
|
||||
// PanSou 有时返回残缺链接如 "/s/xxx" 或只有 "#/list/share"
|
||||
if (url.startsWith('/') && !url.startsWith('//') && !url.startsWith('http')) return true;
|
||||
|
||||
// 夸克链接格式校验
|
||||
if (url.includes('pan.quark.cn')) {
|
||||
const baseUrl = url.split('#')[0]; // 去掉 hash 路由片段
|
||||
// 有效格式必须是 pan.quark.cn/s/xxxxxx
|
||||
if (!/pan\.quark\.cn\/s\/[a-zA-Z0-9]+/.test(baseUrl)) return true;
|
||||
}
|
||||
|
||||
// 百度网盘常见失效格式
|
||||
if (url.includes('pan.baidu.com') && /share\/init\?surl=$/.test(url)) return true;
|
||||
|
||||
// 阿里云盘失效格式(短到异常的链接)
|
||||
if (url.includes('aliyundrive.com') && url.length < 30) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group search results into channels by cloud type.
|
||||
* Each channel: { cloud_type, label, color, count, items }
|
||||
*/
|
||||
|
||||
function groupResultsByChannel(results: any[], getCloudType?: (item: any) => string): any[] {
|
||||
const groups: Record<string, any[]> = {};
|
||||
const order: Record<string, number> = {
|
||||
quark: 1, baidu: 2, aliyun: 3, '115': 4,
|
||||
tianyi: 5, '123pan': 6, uc: 7, xunlei: 8,
|
||||
pikpak: 9, magnet: 10, ed2k: 11, others: 12,
|
||||
};
|
||||
for (const item of results) {
|
||||
const ct = getCloudType ? getCloudType(item) : (item.source || detectCloudType(item.url || '') || 'others');
|
||||
if (!groups[ct]) groups[ct] = [];
|
||||
groups[ct].push(item);
|
||||
}
|
||||
return Object.entries(groups)
|
||||
.sort((a, b) => (order[a[0]] ?? 99) - (order[b[0]] ?? 99))
|
||||
.map(([cloud_type, items]) => ({
|
||||
cloud_type,
|
||||
label: (CLOUD_LABELS as any)[cloud_type] || cloud_type,
|
||||
color: (CLOUD_COLORS as any)[cloud_type] || '#95a5a6',
|
||||
count: items.length,
|
||||
items,
|
||||
}));
|
||||
}
|
||||
|
||||
// ============ Video ============
|
||||
// ============ Video ============
|
||||
|
||||
/**
|
||||
* POST /api/video/parse
|
||||
* Parse a video URL
|
||||
*/
|
||||
router.post('/video/parse', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { url } = req.body;
|
||||
if (!url) {
|
||||
res.status(400).json({ error: 'URL is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const videoInfo = await parseVideo(url);
|
||||
res.json(videoInfo);
|
||||
} catch (err: any) {
|
||||
console.error('[Video] Parse error:', err);
|
||||
res.status(500).json({ error: err.message || 'Failed to parse video' });
|
||||
}
|
||||
});
|
||||
|
||||
// ============ Cloud Save ============
|
||||
// ============ Cloud Save ============
|
||||
|
||||
/**
|
||||
* POST /api/save
|
||||
* Save a share link to a specific cloud
|
||||
*/
|
||||
router.post('/save', saveLimiter, async (req: Request, res: Response) => {
|
||||
try {
|
||||
// Support both formats:
|
||||
// 1. Backend-style: { url, cloudType }
|
||||
// 2. Frontend-style: { source: { share_url }, target_cloud }
|
||||
const url = req.body.url || req.body.source?.share_url || req.body.source?.url;
|
||||
const cloudType = req.body.cloudType || req.body.target_cloud || (req.body.source as any)?.cloud_type;
|
||||
const sourceTitle = req.body.source_title || req.body.source?.title || req.body.title;
|
||||
if (!url || !cloudType) {
|
||||
res.status(400).json({ error: 'URL and cloudType/cloud_type are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const ip = req.ip || (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || '';
|
||||
const result = await saveFromShare(url, cloudType, sourceTitle, ip);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
console.error('[Save] Error:', err);
|
||||
res.status(500).json({ error: err.message || 'Failed to save to cloud' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/video/save-to-cloud
|
||||
* Save a video to cloud
|
||||
*/
|
||||
router.post('/video/save-to-cloud', saveLimiter, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { videoUrl, cloudType, title } = req.body;
|
||||
if (!videoUrl || !cloudType) {
|
||||
res.status(400).json({ error: 'videoUrl and cloudType are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const ip = req.ip || (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || '';
|
||||
const result = await saveFromShare(videoUrl, cloudType, title, ip);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
console.error('[Video] Save-to-cloud error:', err);
|
||||
res.status(500).json({ error: err.message || 'Failed to save video to cloud' });
|
||||
}
|
||||
});
|
||||
|
||||
// ============ Rankings ============
|
||||
// ============ Rankings ============
|
||||
|
||||
/**
|
||||
* GET /api/rankings
|
||||
* Get search rankings
|
||||
*/
|
||||
router.get('/rankings', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const rankings = await getRankings();
|
||||
res.json(rankings);
|
||||
} catch (err: any) {
|
||||
console.error('[Rankings] Error:', err);
|
||||
res.status(500).json({ error: err.message || 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/rankings/hot
|
||||
* Get hot keywords
|
||||
*/
|
||||
router.get('/rankings/hot', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const keywords = await getHotKeywords();
|
||||
res.json(keywords);
|
||||
} catch (err: any) {
|
||||
console.error('[Hot] Error:', err);
|
||||
res.status(500).json({ error: err.message || 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/rankings/categorized
|
||||
* Get categorized rankings (hot + newest per category), cached for 12h
|
||||
*/
|
||||
router.get('/rankings/categorized', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const data = await getCategorizedRankings();
|
||||
res.json(data);
|
||||
} catch (err: any) {
|
||||
console.error('[Categorized] Error:', err);
|
||||
res.status(500).json({ error: err.message || 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/site-config
|
||||
* Public site configuration (no auth required).
|
||||
*/
|
||||
router.get('/site-config', (_req: Request, res: Response) => {
|
||||
try {
|
||||
const siteLogo = getSystemConfig('site_logo') || '';
|
||||
const siteName = getSystemConfig('site_name') || 'CloudSearch';
|
||||
const fallbackImage = getSystemConfig('search_fallback_image') || '';
|
||||
const siteDisclaimer = getSystemConfig('site_disclaimer') || '';
|
||||
const siteMarquee = getSystemConfig('site_marquee') || '';
|
||||
res.json({ site_logo: siteLogo, site_name: siteName, search_fallback_image: fallbackImage, site_disclaimer: siteDisclaimer, site_marquee: siteMarquee });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message || 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/me
|
||||
* Get current user info from token (public, no auth middleware).
|
||||
*/
|
||||
router.get('/me', (req: Request, res: Response) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
res.json({ loggedIn: false });
|
||||
return;
|
||||
}
|
||||
const token = authHeader.split(' ')[1];
|
||||
const payload = verifyToken(token);
|
||||
if (!payload) {
|
||||
res.json({ loggedIn: false });
|
||||
return;
|
||||
}
|
||||
res.json({ loggedIn: true, id: payload.id, username: payload.username });
|
||||
} catch (err: any) {
|
||||
res.json({ loggedIn: false });
|
||||
}
|
||||
});
|
||||
|
||||
// ============ Admin ============
|
||||
|
||||
|
||||
/**
|
||||
* Extract genre tags from search result titles.
|
||||
*/
|
||||
function extractTagsFromResults(results: any[], keyword: string): string[] {
|
||||
const tags: string[] = [];
|
||||
if (keyword) tags.push(keyword);
|
||||
|
||||
const genreKeywords: Record<string, string> = {
|
||||
'动画': '动画', '动漫': '动画', '国漫': '国漫',
|
||||
'剧场版': '剧场版', '年番': '年番',
|
||||
'动作': '动作', '奇幻': '奇幻', '玄幻': '玄幻',
|
||||
'仙侠': '仙侠', '古装': '古装', '爱情': '爱情',
|
||||
'科幻': '科幻', '喜剧': '喜剧', '悬疑': '悬疑',
|
||||
'恐怖': '恐怖', '惊悚': '惊悚', '剧情': '剧情',
|
||||
'冒险': '冒险', '战争': '战争', '武侠': '武侠',
|
||||
'纪录': '纪录片', '真人': '真人秀', '短片': '短片',
|
||||
};
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (const r of results) {
|
||||
const title = (r.title || r.note || '') as string;
|
||||
for (const [key, val] of Object.entries(genreKeywords)) {
|
||||
if (title.includes(key) && !seen.has(val)) {
|
||||
seen.add(val);
|
||||
tags.push(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query DB for previously saved resources that match the keyword.
|
||||
* Returns formatted results for immediate streaming before external API call.
|
||||
*/
|
||||
function getSavedResources(keyword: string): any[] {
|
||||
try {
|
||||
const db = getDb();
|
||||
const rows = db.prepare(`
|
||||
SELECT source_url, source_title, target_cloud, share_url, created_at
|
||||
FROM save_records
|
||||
WHERE status = 'success'
|
||||
AND (source_title LIKE ? OR source_url LIKE ?)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 20
|
||||
`).all(`%${keyword}%`, `%${keyword}%`) as any[];
|
||||
|
||||
return rows.map((row: any, idx: number) => ({
|
||||
id: `saved_${idx}`,
|
||||
title: row.source_title || row.source_url || '',
|
||||
description: '',
|
||||
share_url: row.share_url || row.source_url || '',
|
||||
cloud_type: detectCloudType(row.share_url || row.source_url || ''),
|
||||
file_size: '',
|
||||
update_time: row.created_at || '',
|
||||
source: 'local',
|
||||
file_id: '',
|
||||
cover: '',
|
||||
password: '',
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('[SavedResources] DB query error:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import multer from 'multer';
|
||||
import sharp from 'sharp';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { authMiddleware } from '../admin/auth.service';
|
||||
import { updateSystemConfig } from '../admin/system-config.service';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ============ Upload ============
|
||||
|
||||
/**
|
||||
* POST /api/admin/upload-fallback-image
|
||||
* Upload a fallback cover image for search results without covers.
|
||||
* Recommended: 320×180 JPEG/PNG (16:9), max 2MB.
|
||||
*/
|
||||
const uploadDir = path.resolve('/app/uploads/fallback');
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
const fallbackStorage = multer.diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, uploadDir),
|
||||
filename: (_req, _file, cb) => {
|
||||
const ext = '.jpg';
|
||||
cb(null, `fallback_cover_tmp${ext}`);
|
||||
},
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage: fallbackStorage,
|
||||
limits: { fileSize: 2 * 1024 * 1024 }, // 2MB max
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (file.mimetype.startsWith('image/')) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('仅支持图片文件(JPEG/PNG)'));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
router.post('/admin/upload-fallback-image', authMiddleware, upload.single('image'), async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
res.status(400).json({ error: '请选择要上传的图片' });
|
||||
return;
|
||||
}
|
||||
// 压缩:最大宽度320px,JPEG quality 80
|
||||
const outPath = path.resolve(uploadDir, 'fallback_cover.jpg');
|
||||
await sharp(req.file.path)
|
||||
.resize(320, undefined, { fit: 'inside', withoutEnlargement: true })
|
||||
.jpeg({ quality: 80 })
|
||||
.toFile(outPath);
|
||||
// 删除原始上传文件(如果路径不同)
|
||||
if (req.file.path !== outPath) {
|
||||
fs.unlink(req.file.path, () => {});
|
||||
}
|
||||
const url = `/api/uploads/fallback/fallback_cover.jpg`;
|
||||
updateSystemConfig('search_fallback_image', url);
|
||||
const stat = fs.statSync(outPath);
|
||||
res.json({ success: true, url, message: `✅ 兜底图已压缩上传 (${(stat.size / 1024).toFixed(1)}KB)` });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message || '上传失败' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/upload-logo
|
||||
* Upload a site logo image displayed on search page (home link) and homepage.
|
||||
* Recommended: 320×60 or similar wide/banner ratio, JPEG/PNG/WebP, max 2MB.
|
||||
*/
|
||||
const logoUploadDir = path.resolve('/app/uploads/logo');
|
||||
if (!fs.existsSync(logoUploadDir)) {
|
||||
fs.mkdirSync(logoUploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
const logoStorage = multer.diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, logoUploadDir),
|
||||
filename: (_req, _file, cb) => {
|
||||
cb(null, `site_logo_tmp.png`);
|
||||
},
|
||||
});
|
||||
|
||||
const logoUpload = multer({
|
||||
storage: logoStorage,
|
||||
limits: { fileSize: 2 * 1024 * 1024 }, // 2MB max
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (file.mimetype.startsWith('image/')) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('仅支持图片文件(JPEG/PNG/WebP)'));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
router.post('/admin/upload-logo', authMiddleware, logoUpload.single('image'), async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
res.status(400).json({ error: '请选择要上传的图片' });
|
||||
return;
|
||||
}
|
||||
// 压缩:最大宽度640px,PNG格式
|
||||
const outPath = path.resolve(logoUploadDir, 'site_logo.png');
|
||||
await sharp(req.file.path)
|
||||
.resize(640, undefined, { fit: 'inside', withoutEnlargement: true })
|
||||
.png({ compressionLevel: 9 })
|
||||
.toFile(outPath);
|
||||
if (req.file.path !== outPath) {
|
||||
fs.unlink(req.file.path, () => {});
|
||||
}
|
||||
const url = `/api/uploads/logo/site_logo.png`;
|
||||
updateSystemConfig('site_logo', url);
|
||||
const stat = fs.statSync(outPath);
|
||||
res.json({ success: true, url, message: `✅ 站点图标已压缩上传 (${(stat.size / 1024).toFixed(1)}KB)` });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message || '上传失败' });
|
||||
}
|
||||
});
|
||||
|
||||
import { startQrLogin, getQrLoginStatus, cancelQrLogin } from '../cloud/qr-login.service';
|
||||
|
||||
// ===== 夸克扫码登录 (不需要 auth,用户未登录时也需要能用) =====
|
||||
|
||||
export default router;
|
||||
Executable
+351
@@ -0,0 +1,351 @@
|
||||
// Native fetch available in Node 20+
|
||||
import { getDb } from '../database/database';
|
||||
import { getTimezone, formatLocalDateTime } from '../utils/time';
|
||||
|
||||
export interface RankingItem {
|
||||
keyword: string;
|
||||
searchCount: number;
|
||||
updatedAt: string;
|
||||
rating?: number;
|
||||
}
|
||||
|
||||
export interface CategorizedRanking {
|
||||
category: string;
|
||||
label: string;
|
||||
hot: RankingItem[];
|
||||
newest: RankingItem[];
|
||||
}
|
||||
|
||||
export interface CategorizedResponse {
|
||||
fetchedAt: string;
|
||||
categories: CategorizedRanking[];
|
||||
}
|
||||
|
||||
// ===== Bilibili PGC 排行榜配置 =====
|
||||
interface BiliPgcDef {
|
||||
category: string;
|
||||
label: string;
|
||||
season_type: number; // 1=番剧, 2=电影, 3=纪录片, 4=国创, 5=电视剧, 7=综艺
|
||||
}
|
||||
|
||||
const BILI_PGC_CATEGORIES: BiliPgcDef[] = [
|
||||
// 国创:凡人修仙传、灵笼、斗破苍穹等官方国产动画
|
||||
{ category: 'donghua', label: '国产动漫', season_type: 4 },
|
||||
// 番剧:日漫等全球动画
|
||||
{ category: 'global_anime', label: '热门动漫', season_type: 1 },
|
||||
];
|
||||
|
||||
// ===== 百度热搜榜配置 =====
|
||||
interface BaiduBoardDef {
|
||||
category: string;
|
||||
label: string;
|
||||
tab: string; // movie=电影热搜, teleplay=电视剧热搜
|
||||
}
|
||||
|
||||
const BAIDU_BOARDS: BaiduBoardDef[] = [
|
||||
// 百度电影热搜:实时反映国内电影热度
|
||||
{ category: 'movie', label: '国内电影', tab: 'movie' },
|
||||
// 百度电视剧热搜:国内剧集热度
|
||||
{ category: 'tv', label: '热门剧集', tab: 'teleplay' },
|
||||
];
|
||||
|
||||
// ===== TMDB 分类配置(保留欧美和冷门内容)=====
|
||||
interface TmdbCategoryDef {
|
||||
category: string;
|
||||
label: string;
|
||||
hotUrl: string;
|
||||
newestUrl: string;
|
||||
}
|
||||
|
||||
const TMDB_CATEGORIES: TmdbCategoryDef[] = [
|
||||
{
|
||||
category: 'western_movie', label: '欧美电影',
|
||||
hotUrl: 'https://api.themoviedb.org/3/discover/movie?with_origin_country=US&sort_by=vote_average.desc&vote_count.gte=10',
|
||||
newestUrl: 'https://api.themoviedb.org/3/discover/movie?with_origin_country=US&sort_by=release_date.desc&vote_count.gte=1',
|
||||
},
|
||||
{
|
||||
category: 'western_tv', label: '欧美剧集',
|
||||
hotUrl: 'https://api.themoviedb.org/3/discover/tv?with_origin_country=US&sort_by=vote_average.desc&vote_count.gte=10',
|
||||
newestUrl: 'https://api.themoviedb.org/3/discover/tv?with_origin_country=US&sort_by=first_air_date.desc&vote_count.gte=10',
|
||||
},
|
||||
{
|
||||
category: 'niche', label: '冷门佳片',
|
||||
hotUrl: 'https://api.themoviedb.org/3/discover/movie?sort_by=vote_average.desc&vote_count.gte=10&vote_count.lte=500',
|
||||
newestUrl: 'https://api.themoviedb.org/3/discover/movie?sort_by=release_date.desc&vote_count.gte=1&vote_count.lte=500',
|
||||
},
|
||||
];
|
||||
|
||||
// ===== 显示顺序 =====
|
||||
const CATEGORY_ORDER: Record<string, number> = {
|
||||
donghua: 1,
|
||||
movie: 2,
|
||||
tv: 3,
|
||||
global_anime: 4,
|
||||
western_movie: 5,
|
||||
western_tv: 6,
|
||||
niche: 7,
|
||||
hotsite: 8,
|
||||
};
|
||||
|
||||
// ===== 12小时缓存 =====
|
||||
let cache: { data: CategorizedResponse; time: number } | null = null;
|
||||
const CACHE_TTL = 12 * 60 * 60 * 1000;
|
||||
|
||||
function isCacheValid(): boolean {
|
||||
return cache !== null && (Date.now() - cache.time) < CACHE_TTL;
|
||||
}
|
||||
|
||||
// ===== Bilibili PGC API =====
|
||||
|
||||
/**
|
||||
* 抓取 Bilibili PGC 排行榜(番剧/国创)
|
||||
*/
|
||||
async function fetchFromBiliPgc(season_type: number): Promise<RankingItem[]> {
|
||||
try {
|
||||
const url = `https://api.bilibili.com/pgc/web/rank/list?season_type=${season_type}&day=7`;
|
||||
const resp = await fetch(url, {
|
||||
headers: {
|
||||
'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.bilibili.com/',
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9',
|
||||
},
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.error(`[BiliPGC] HTTP ${resp.status} for season_type=${season_type}`);
|
||||
return [];
|
||||
}
|
||||
const json = await resp.json() as any;
|
||||
if (json.code !== 0 || !json.result?.list) {
|
||||
console.error(`[BiliPGC] API error code=${json.code} for season_type=${season_type}`);
|
||||
return [];
|
||||
}
|
||||
return json.result.list.slice(0, 20).map((item: any) => {
|
||||
const stat = item.stat || {};
|
||||
const viewCount = stat.view || 0;
|
||||
const followCount = stat.follow || 0;
|
||||
const searchCount = viewCount > 0 ? viewCount : followCount;
|
||||
|
||||
let rating = 0;
|
||||
if (item.rating) {
|
||||
const m = String(item.rating).match(/([\d.]+)/);
|
||||
if (m) rating = parseFloat(m[1]);
|
||||
}
|
||||
|
||||
return {
|
||||
keyword: item.title || '',
|
||||
searchCount,
|
||||
updatedAt: item.new_ep?.index_show || item.new_ep?.cover || '',
|
||||
rating,
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[BiliPGC] Fetch error for season_type=${season_type}:`, (err as Error).message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 百度热搜榜 API =====
|
||||
|
||||
/**
|
||||
* 抓取百度热搜榜
|
||||
* tab: movie=电影, teleplay=电视剧
|
||||
*/
|
||||
async function fetchFromBaidu(tab: string): Promise<RankingItem[]> {
|
||||
try {
|
||||
const url = `https://top.baidu.com/api/board?tab=${tab}`;
|
||||
const resp = await fetch(url, {
|
||||
headers: {
|
||||
'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://top.baidu.com/board',
|
||||
},
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.error(`[Baidu] HTTP ${resp.status} for tab=${tab}`);
|
||||
return [];
|
||||
}
|
||||
const json = await resp.json() as any;
|
||||
if (!json.success || !json.data?.cards) {
|
||||
console.error(`[Baidu] API error for tab=${tab}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const results: RankingItem[] = [];
|
||||
for (const card of json.data.cards) {
|
||||
for (const item of (card.content || [])) {
|
||||
results.push({
|
||||
keyword: item.word || '',
|
||||
// hotScore can be like "96438", parse as number
|
||||
searchCount: parseInt(item.hotScore || '0', 10) || 0,
|
||||
updatedAt: item.desc || '',
|
||||
rating: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results.slice(0, 20);
|
||||
} catch (err) {
|
||||
console.error(`[Baidu] Fetch error for tab=${tab}:`, (err as Error).message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ===== TMDB =====
|
||||
|
||||
function getTmdbToken(): string {
|
||||
const db = getDb();
|
||||
return (db.prepare('SELECT value FROM system_configs WHERE key = ?').get('tmdb_api_token') as any)?.value || '';
|
||||
}
|
||||
|
||||
function tmdbResultToRanking(item: any): RankingItem {
|
||||
const title = item.title || item.name || '';
|
||||
const date = item.release_date || item.first_air_date || '';
|
||||
const rating = item.vote_average ? Math.round(item.vote_average * 10) / 10 : 0;
|
||||
return {
|
||||
keyword: title,
|
||||
searchCount: item.vote_count || 0,
|
||||
updatedAt: date,
|
||||
rating,
|
||||
};
|
||||
}
|
||||
|
||||
async function tmdbFetch(url: string, token: string): Promise<any[]> {
|
||||
const fullUrl = `${url}${url.includes('?') ? '&' : '?'}language=zh-CN`;
|
||||
try {
|
||||
const resp = await fetch(fullUrl, {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.error(`[TMDB] HTTP ${resp.status} for ${url}`);
|
||||
return [];
|
||||
}
|
||||
const data = await resp.json() as any;
|
||||
return (data.results || []).slice(0, 20);
|
||||
} catch (err) {
|
||||
console.error(`[TMDB] Fetch error for ${url}:`, err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 主流程 =====
|
||||
|
||||
async function fetchRankings(): Promise<CategorizedResponse> {
|
||||
const fetchedAt = formatLocalDateTime();
|
||||
|
||||
// 1. 并行抓取 Bilibili PGC 数据(国漫、番剧)
|
||||
const biliPromises = BILI_PGC_CATEGORIES.map(async (cat) => {
|
||||
const results = await fetchFromBiliPgc(cat.season_type);
|
||||
const mid = Math.ceil(results.length / 2);
|
||||
return {
|
||||
category: cat.category,
|
||||
label: cat.label,
|
||||
hot: results.slice(0, mid),
|
||||
newest: results.slice(mid),
|
||||
};
|
||||
});
|
||||
|
||||
// 2. 并行抓取百度热搜数据(电影、电视剧)
|
||||
// 百度只有热榜没有最新榜,全部放 hot
|
||||
const baiduPromises = BAIDU_BOARDS.map(async (board) => {
|
||||
const results = await fetchFromBaidu(board.tab);
|
||||
return {
|
||||
category: board.category,
|
||||
label: board.label,
|
||||
hot: results,
|
||||
newest: [],
|
||||
};
|
||||
});
|
||||
|
||||
// 3. 并行抓取 TMDB 数据(欧美观影、剧集、冷门)
|
||||
const token = getTmdbToken();
|
||||
let tmdbResults: CategorizedRanking[] = [];
|
||||
if (token) {
|
||||
const tmdbPromises = TMDB_CATEGORIES.map(async (cat) => {
|
||||
const [hotResults, newestResults] = await Promise.all([
|
||||
tmdbFetch(cat.hotUrl, token),
|
||||
tmdbFetch(cat.newestUrl, token),
|
||||
]);
|
||||
return {
|
||||
category: cat.category,
|
||||
label: cat.label,
|
||||
hot: hotResults.map(tmdbResultToRanking),
|
||||
newest: newestResults.map(tmdbResultToRanking),
|
||||
};
|
||||
});
|
||||
tmdbResults = await Promise.all(tmdbPromises);
|
||||
}
|
||||
|
||||
// 4. 本站热搜
|
||||
const db = getDb();
|
||||
const rows = db.prepare(
|
||||
'SELECT keyword, search_count as searchCount, updated_at as updatedAt FROM hot_keywords ORDER BY search_count DESC LIMIT 20'
|
||||
).all() as RankingItem[];
|
||||
const newestRows = db.prepare(
|
||||
'SELECT keyword, search_count as searchCount, updated_at as updatedAt FROM hot_keywords ORDER BY updated_at DESC LIMIT 20'
|
||||
).all() as RankingItem[];
|
||||
|
||||
const hotsiteCategory: CategorizedRanking = {
|
||||
category: 'hotsite',
|
||||
label: '本站热搜',
|
||||
hot: rows,
|
||||
newest: newestRows,
|
||||
};
|
||||
|
||||
// 5. 合并所有结果
|
||||
const [biliResults, baiduResults] = await Promise.all([
|
||||
Promise.all(biliPromises),
|
||||
Promise.all(baiduPromises),
|
||||
]);
|
||||
const allCategories = [...biliResults, ...baiduResults, ...tmdbResults, hotsiteCategory];
|
||||
|
||||
// 按 CATEGORY_ORDER 排序
|
||||
allCategories.sort((a, b) => (CATEGORY_ORDER[a.category] || 99) - (CATEGORY_ORDER[b.category] || 99));
|
||||
|
||||
return { fetchedAt, categories: allCategories };
|
||||
}
|
||||
|
||||
export async function getCategorizedRankings(): Promise<CategorizedResponse> {
|
||||
if (isCacheValid()) {
|
||||
return cache!.data;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetchRankings();
|
||||
cache = { data, time: Date.now() };
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error('[Rankings] Fetch error:', err);
|
||||
if (cache) return cache.data;
|
||||
const db = getDb();
|
||||
const rows = db.prepare(
|
||||
'SELECT keyword, search_count as searchCount, updated_at as updatedAt FROM hot_keywords ORDER BY search_count DESC LIMIT 20'
|
||||
).all() as RankingItem[];
|
||||
return {
|
||||
fetchedAt: formatLocalDateTime(),
|
||||
categories: [{
|
||||
category: 'hotsite', label: '本站热搜',
|
||||
hot: rows,
|
||||
newest: [...rows].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, 20),
|
||||
}],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRankings(): Promise<RankingItem[]> {
|
||||
const db = getDb();
|
||||
const rows = db.prepare(
|
||||
'SELECT keyword, search_count as searchCount, updated_at as updatedAt FROM hot_keywords ORDER BY search_count DESC LIMIT 20'
|
||||
).all() as RankingItem[];
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function getHotKeywords(): Promise<string[]> {
|
||||
const db = getDb();
|
||||
const rows = db.prepare(
|
||||
'SELECT keyword FROM hot_keywords ORDER BY search_count DESC LIMIT 20'
|
||||
).all() as { keyword: string }[];
|
||||
return rows.map(r => r.keyword);
|
||||
}
|
||||
Executable
+125
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Search Results Optimizer
|
||||
*
|
||||
* For each cloud type, keep only the top N most relevant results.
|
||||
* Order groups by priority: real cloud storage > other providers > magnet/others.
|
||||
*
|
||||
* Goal: give users a manageable, high-quality result set instead of overwhelming them
|
||||
* with hundreds of results dominated by magnet links.
|
||||
*/
|
||||
|
||||
import { detectCloudType } from '../config/cloud-labels';
|
||||
|
||||
/** Minimal result shape the optimizer needs */
|
||||
interface OptimizableResult {
|
||||
title?: string;
|
||||
url?: string;
|
||||
source?: string;
|
||||
score?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/** Priority tiers for result ordering */
|
||||
const CLOUD_PRIORITY: Record<string, number> = {
|
||||
// Tier 1: Major cloud storage (most useful for save-to-cloud feature)
|
||||
baidu: 10,
|
||||
quark: 10,
|
||||
aliyun: 10,
|
||||
// Tier 2: Other cloud storage
|
||||
'115': 20,
|
||||
tianyi: 20,
|
||||
'123pan': 20,
|
||||
uc: 20,
|
||||
xunlei: 20,
|
||||
pikpak: 20,
|
||||
// Tier 3: Mobile/app links (not very useful)
|
||||
mobile: 50,
|
||||
// Tier 4: Direct links (lowest utility for cloud saving)
|
||||
magnet: 100,
|
||||
ed2k: 100,
|
||||
others: 100,
|
||||
};
|
||||
|
||||
const DEFAULT_PRIORITY = 50;
|
||||
|
||||
/** Get cloud type for a result, with an extra check for tracker URLs */
|
||||
function getCloudType(result: OptimizableResult): string {
|
||||
const url = result.url;
|
||||
// Check for tracker/private-site URLs not covered by shared detection
|
||||
if (url && /mteam|hdarea|hdsky/i.test(url)) return 'others';
|
||||
return detectCloudType(url);
|
||||
}
|
||||
|
||||
function getPriority(cloudType: string): number {
|
||||
return CLOUD_PRIORITY[cloudType] ?? DEFAULT_PRIORITY;
|
||||
}
|
||||
|
||||
export interface OptimizationResult {
|
||||
results: OptimizableResult[];
|
||||
/** Per-type stats for display */
|
||||
perType: Array<{ type: string; count: number; total: number }>;
|
||||
/** How many items were kept vs filtered */
|
||||
keptCount: number;
|
||||
filteredCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize search results:
|
||||
* 1. Group by cloud type
|
||||
* 2. Sort by score descending within each group
|
||||
* 3. Keep only top `maxPerType` results per type
|
||||
* 4. Order groups by priority (cloud storage first)
|
||||
*/
|
||||
export function optimizeSearchResults(
|
||||
items: OptimizableResult[],
|
||||
maxPerType: number = 20
|
||||
): OptimizationResult {
|
||||
// Step 1: Group by cloud type
|
||||
const grouped: Record<string, OptimizableResult[]> = {};
|
||||
const typeTotals: Record<string, number> = {};
|
||||
|
||||
for (const item of items) {
|
||||
const ct = getCloudType(item);
|
||||
if (!grouped[ct]) {
|
||||
grouped[ct] = [];
|
||||
}
|
||||
grouped[ct].push(item);
|
||||
typeTotals[ct] = (typeTotals[ct] || 0) + 1;
|
||||
}
|
||||
|
||||
// Step 2 & 3: Sort each group by score desc, take top N
|
||||
const kept: OptimizableResult[] = [];
|
||||
const perType: Array<{ type: string; count: number; total: number }> = [];
|
||||
|
||||
for (const [ct, groupItems] of Object.entries(grouped)) {
|
||||
// Sort by score descending (higher score = more relevant)
|
||||
groupItems.sort((a, b) => (b.score || 0) - (a.score || 0));
|
||||
|
||||
const top = groupItems.slice(0, maxPerType);
|
||||
kept.push(...top);
|
||||
|
||||
perType.push({
|
||||
type: ct,
|
||||
count: top.length,
|
||||
total: typeTotals[ct],
|
||||
});
|
||||
}
|
||||
|
||||
// Step 4: Sort kept results by cloud priority, then by score within same priority
|
||||
kept.sort((a, b) => {
|
||||
const pa = getPriority(getCloudType(a));
|
||||
const pb = getPriority(getCloudType(b));
|
||||
if (pa !== pb) return pa - pb;
|
||||
return (b.score || 0) - (a.score || 0);
|
||||
});
|
||||
|
||||
const keptCount = kept.length;
|
||||
const filteredCount = items.length - keptCount;
|
||||
|
||||
return {
|
||||
results: kept,
|
||||
perType,
|
||||
keptCount,
|
||||
filteredCount,
|
||||
};
|
||||
}
|
||||
Executable
+344
@@ -0,0 +1,344 @@
|
||||
// Native fetch available in Node 20+
|
||||
import config from '../config';
|
||||
import { getDb } from '../database/database';
|
||||
import { localTimestamp } from '../utils/time';
|
||||
|
||||
export interface SearchResult {
|
||||
title: string;
|
||||
url: string;
|
||||
content: string;
|
||||
score?: number;
|
||||
source?: string;
|
||||
password?: string;
|
||||
datetime?: string;
|
||||
responseTimeMs?: number;
|
||||
}
|
||||
|
||||
export interface SearchResponse {
|
||||
results: SearchResult[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface ApiSearchSource {
|
||||
name: string;
|
||||
url: string;
|
||||
method?: string; // GET | POST (default POST)
|
||||
headers?: Record<string, string>;
|
||||
body?: string; // JSON body template, supports {keyword} {page}
|
||||
resultPath: string; // dot-notation path to results array (e.g. "data.list")
|
||||
fieldMap: { // maps SearchResult fields to JSON response fields
|
||||
title?: string;
|
||||
url?: string;
|
||||
content?: string;
|
||||
password?: string;
|
||||
datetime?: string;
|
||||
};
|
||||
timeout?: number; // per-source timeout (ms), default 10000
|
||||
}
|
||||
|
||||
/** Simple dot/bracket notation JSON path accessor. */
|
||||
function jsonPathGet(obj: any, path: string): any {
|
||||
if (!obj || !path) return undefined;
|
||||
const parts = path
|
||||
.replace(/\[(\d+)\]/g, '.$1') // items[0] → items.0
|
||||
.split('.')
|
||||
.filter(Boolean);
|
||||
let current = obj;
|
||||
for (const part of parts) {
|
||||
if (current == null) return undefined;
|
||||
current = current[part];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/** Parse configured API search sources from system config. */
|
||||
function getApiSearchSources(): ApiSearchSource[] {
|
||||
try {
|
||||
const db = getDb();
|
||||
const raw = (db.prepare("SELECT value FROM system_configs WHERE key = 'api_search_sources'").get() as any)?.value;
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter((s: any) => s.url && s.resultPath);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Query a single API search source and return results with timing. */
|
||||
async function queryApiSource(
|
||||
source: ApiSearchSource,
|
||||
keyword: string,
|
||||
page: number,
|
||||
): Promise<{ source: string; results: SearchResult[]; responseTimeMs: number; error?: string }> {
|
||||
const startTime = Date.now();
|
||||
const timeout = source.timeout || 10000;
|
||||
const method = (source.method || 'POST').toUpperCase();
|
||||
|
||||
try {
|
||||
let url = source.url;
|
||||
const headers: Record<string, string> = { ...source.headers };
|
||||
|
||||
let body: string | undefined;
|
||||
if (source.body) {
|
||||
body = source.body
|
||||
.replace(/\{keyword\}/g, encodeURIComponent(keyword))
|
||||
.replace(/\{page\}/g, String(page));
|
||||
}
|
||||
|
||||
// For GET requests, append query params; for POST, use body
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
};
|
||||
|
||||
if (method === 'GET') {
|
||||
// Parse body as JSON and convert to query string
|
||||
if (body) {
|
||||
try {
|
||||
const params = JSON.parse(body);
|
||||
const qs = new URLSearchParams(params).toString();
|
||||
url += (url.includes('?') ? '&' : '?') + qs;
|
||||
} catch {
|
||||
// If body is not JSON, append as raw query
|
||||
url += (url.includes('?') ? '&' : '?') + body;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(fetchOptions as any).body = body || JSON.stringify({ keyword, page });
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions);
|
||||
const responseTimeMs = Date.now() - startTime;
|
||||
|
||||
if (!response.ok) {
|
||||
return { source: source.name, results: [], responseTimeMs, error: `HTTP ${response.status}` };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const resultTimeMs = Date.now() - startTime;
|
||||
|
||||
// Extract results array using JSONPath
|
||||
const items = jsonPathGet(data, source.resultPath);
|
||||
if (!Array.isArray(items)) {
|
||||
return { source: source.name, results: [], responseTimeMs: resultTimeMs, error: 'resultPath not found or not an array' };
|
||||
}
|
||||
|
||||
// Map fields
|
||||
const fm = source.fieldMap || {};
|
||||
const results: SearchResult[] = items.map((item: any) => ({
|
||||
title: (fm.title ? item[fm.title] : item.title) || item.name || '',
|
||||
url: (fm.url ? item[fm.url] : item.url) || item.link || '',
|
||||
content: (fm.content ? item[fm.content] : item.content) || item.snippet || '',
|
||||
password: (fm.password ? item[fm.password] : item.password) || '',
|
||||
datetime: (fm.datetime ? item[fm.datetime] : item.datetime) || item.date || '',
|
||||
source: source.name,
|
||||
responseTimeMs: resultTimeMs,
|
||||
}));
|
||||
|
||||
return { source: source.name, results, responseTimeMs: resultTimeMs };
|
||||
} catch (err: any) {
|
||||
const responseTimeMs = Date.now() - startTime;
|
||||
return { source: source.name, results: [], responseTimeMs, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
/** Query all configured API search sources in parallel. */
|
||||
async function searchApiSources(keyword: string, page: number): Promise<{
|
||||
results: SearchResult[];
|
||||
sourceStats: { name: string; count: number; responseTimeMs: number; error?: string }[];
|
||||
}> {
|
||||
const sources = getApiSearchSources();
|
||||
if (sources.length === 0) return { results: [], sourceStats: [] };
|
||||
|
||||
const promises = sources.map(s => queryApiSource(s, keyword, page));
|
||||
const allResults = await Promise.all(promises);
|
||||
|
||||
const sourceStats = allResults.map(r => ({
|
||||
name: r.source,
|
||||
count: r.results.length,
|
||||
responseTimeMs: r.responseTimeMs,
|
||||
error: r.error,
|
||||
}));
|
||||
|
||||
// Merge all results, tag with source name, sort by response time (fastest first)
|
||||
const results = allResults
|
||||
.flatMap(r => r.results)
|
||||
.sort((a, b) => (a.responseTimeMs || 99999) - (b.responseTimeMs || 99999));
|
||||
|
||||
return { results, sourceStats };
|
||||
}
|
||||
|
||||
export async function search(keyword: string, page: number = 1, ip?: string): Promise<SearchResponse> {
|
||||
const db = getDb();
|
||||
const pansouUrl = (db.prepare('SELECT value FROM system_configs WHERE key = ?').get('pansou_url') as any)?.value || config.pansouUrl;
|
||||
const proxyEnabled = (db.prepare('SELECT value FROM system_configs WHERE key = ?').get('search_proxy_enabled') as any)?.value === 'true';
|
||||
const proxyUrl = (db.prepare('SELECT value FROM system_configs WHERE key = ?').get('search_proxy_url') as any)?.value || '';
|
||||
|
||||
// ── Run PanSou and API sources in parallel ──
|
||||
const pansouPromise = (async () => {
|
||||
const url = `${pansouUrl}/api/search`;
|
||||
const fetchOptions: any = {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ kw: keyword, page }),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
};
|
||||
const pansouStart = Date.now();
|
||||
const response = await fetch(url, fetchOptions);
|
||||
if (!response.ok) throw new Error(`PanSou API error: ${response.status}`);
|
||||
const data = await response.json() as any;
|
||||
return { data, responseTimeMs: Date.now() - pansouStart };
|
||||
})();
|
||||
|
||||
const apiSourcesPromise = searchApiSources(keyword, page);
|
||||
|
||||
const [pansouResult, apiSourcesResult] = await Promise.all([pansouPromise, apiSourcesPromise]);
|
||||
|
||||
const { data, responseTimeMs: pansouTime } = pansouResult;
|
||||
|
||||
// ── Parse PanSou results ──
|
||||
let items: any[] = [];
|
||||
let total = 0;
|
||||
|
||||
if (data.data?.merged_by_type) {
|
||||
for (const [cloudType, cloudItems] of Object.entries(data.data.merged_by_type)) {
|
||||
if (Array.isArray(cloudItems)) {
|
||||
items.push(...cloudItems.map((item: any) => ({
|
||||
...item,
|
||||
_cloud_type: cloudType,
|
||||
})));
|
||||
}
|
||||
}
|
||||
total = data.data.total || items.length;
|
||||
} else if (Array.isArray(data.data)) {
|
||||
items = data.data;
|
||||
total = data.total || items.length;
|
||||
} else if (Array.isArray(data.results)) {
|
||||
items = data.results;
|
||||
total = data.total || items.length;
|
||||
}
|
||||
|
||||
const pansouResults: SearchResult[] = items.map((item: any) => ({
|
||||
title: item.note || item.title || '',
|
||||
url: item.url || item.link || '',
|
||||
content: item.content || item.snippet || item.note || '',
|
||||
score: item.score || 0,
|
||||
source: item.source || item._cloud_type || 'pansou',
|
||||
password: item.password || '',
|
||||
datetime: item.datetime || '',
|
||||
responseTimeMs: pansouTime,
|
||||
images: item.images || [],
|
||||
}));
|
||||
|
||||
// ── Merge PanSou + API sources, sort by response time (fastest first) ──
|
||||
const allResults = [...apiSourcesResult.results, ...pansouResults]
|
||||
.sort((a, b) => (a.responseTimeMs || 99999) - (b.responseTimeMs || 99999));
|
||||
|
||||
// Deduplicate by URL within merged results
|
||||
const seenUrls = new Set<string>();
|
||||
const results: SearchResult[] = [];
|
||||
for (const r of allResults) {
|
||||
if (r.url && !seenUrls.has(r.url)) {
|
||||
seenUrls.add(r.url);
|
||||
results.push(r);
|
||||
} else if (!r.url) {
|
||||
results.push(r); // keep results without URLs (unlikely but safe)
|
||||
}
|
||||
}
|
||||
|
||||
total = results.length;
|
||||
|
||||
// Sort by datetime descending as secondary sort (preserve response-time groups)
|
||||
results.sort((a: any, b: any) => {
|
||||
const ta = a.datetime || '';
|
||||
const tb = b.datetime || '';
|
||||
if (!ta && !tb) return 0;
|
||||
if (!ta) return 1;
|
||||
if (!tb) return -1;
|
||||
return tb.localeCompare(ta);
|
||||
});
|
||||
|
||||
// Record search statistics
|
||||
recordSearchStats(keyword, results.length, ip);
|
||||
|
||||
// Update hot keywords
|
||||
updateHotKeywords(keyword);
|
||||
|
||||
return {
|
||||
results,
|
||||
total,
|
||||
page,
|
||||
pageSize: data.pageSize || 10,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply title filter rules to clean up search result titles.
|
||||
* Rules format (one per line):
|
||||
* # comment lines are ignored (hash must be followed by space)
|
||||
* /pattern/flags → regex: matched content is deleted from title
|
||||
* plain text → literal text: exact text is deleted from title wherever it appears
|
||||
*/
|
||||
export function applyTitleFilter(title: string, rules: string): string {
|
||||
if (!title || !rules) return title;
|
||||
const lines = rules.split('\n');
|
||||
let result = title;
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('# ')) continue;
|
||||
try {
|
||||
if (line.startsWith('/') && line.lastIndexOf('/') > 0) {
|
||||
const lastSlashIdx = line.lastIndexOf('/');
|
||||
const pattern = line.substring(1, lastSlashIdx);
|
||||
const flags = line.substring(lastSlashIdx + 1);
|
||||
const anchored = pattern.startsWith('^') ? pattern : '^' + pattern;
|
||||
const re = new RegExp(anchored, flags);
|
||||
const match = re.exec(result);
|
||||
if (match && match.index === 0) {
|
||||
result = result.slice(match[0].length);
|
||||
}
|
||||
} else {
|
||||
if (result.startsWith(line)) {
|
||||
result = result.slice(line.length);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return result.trim();
|
||||
}
|
||||
|
||||
function recordSearchStats(keyword: string, resultCount: number, ip?: string): void {
|
||||
try {
|
||||
const db = getDb();
|
||||
db.prepare(
|
||||
'INSERT INTO search_stats (keyword, intent, result_count, ip_address, created_at) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(keyword, 'SEARCH', resultCount, ip || '', localTimestamp());
|
||||
} catch (err) {
|
||||
console.error('[Search] Failed to record stats:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function updateHotKeywords(keyword: string): void {
|
||||
try {
|
||||
const db = getDb();
|
||||
const existing = db.prepare('SELECT id FROM hot_keywords WHERE keyword = ?').get(keyword) as any;
|
||||
|
||||
if (existing) {
|
||||
db.prepare(
|
||||
"UPDATE hot_keywords SET search_count = search_count + 1, updated_at = ? WHERE keyword = ?"
|
||||
).run(localTimestamp(), keyword);
|
||||
} else {
|
||||
db.prepare(
|
||||
"INSERT INTO hot_keywords (keyword, search_count, updated_at) VALUES (?, 1, ?)"
|
||||
).run(keyword, localTimestamp());
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Search] Failed to update hot keywords:', err);
|
||||
}
|
||||
}
|
||||
Executable
+407
@@ -0,0 +1,407 @@
|
||||
import { chromium, BrowserContext, Page } from 'playwright';
|
||||
import jsQR from 'jsqr';
|
||||
import { getDb } from '../database/database';
|
||||
import { escapeLike } from '../utils/time';
|
||||
|
||||
interface QrSession {
|
||||
id: string;
|
||||
browserContext: BrowserContext;
|
||||
page: Page;
|
||||
createdAt: number;
|
||||
cookieSnapshot: string;
|
||||
lastPollAt: number;
|
||||
qrUrl: string;
|
||||
status: 'pending' | 'scanned' | 'logged_in' | 'expired' | 'error';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const SESSIONS = new Map<string, QrSession>();
|
||||
const SESSION_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
const COOKIE_CHECK_INTERVAL = 1500; // 1.5s between cookie checks
|
||||
|
||||
const CHROMIUM_PATH = process.env.CHROMIUM_PATH || '/usr/bin/chromium-browser';
|
||||
|
||||
// Clean up old sessions periodically
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [id, session] of SESSIONS.entries()) {
|
||||
if (now - session.createdAt > SESSION_TTL) {
|
||||
cleanupSession(id);
|
||||
}
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
function cleanupSession(id: string) {
|
||||
const session = SESSIONS.get(id);
|
||||
if (session) {
|
||||
try {
|
||||
session.browserContext.close().catch(() => {});
|
||||
} catch {}
|
||||
SESSIONS.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract QR code URL from the login page canvas using jsQR.
|
||||
* The actual login QR code is Canvas #0 (anonymous, 177x177), NOT #react-qrcode-logo.
|
||||
*/
|
||||
async function extractQrUrl(page: Page): Promise<string> {
|
||||
// Run inside Playwright's browser context (as a string to avoid Node TS type errors)
|
||||
const raw = await page.evaluate(`(() => {
|
||||
const canvases = document.querySelectorAll('canvas');
|
||||
var results = [];
|
||||
for (var i = 0; i < canvases.length; i++) {
|
||||
try {
|
||||
var c = canvases[i];
|
||||
var ctx = c.getContext('2d');
|
||||
if (!ctx) continue;
|
||||
var imageData = ctx.getImageData(0, 0, c.width, c.height);
|
||||
results.push({
|
||||
index: i,
|
||||
w: c.width,
|
||||
h: c.height,
|
||||
data: Array.from(imageData.data)
|
||||
});
|
||||
} catch(e) {}
|
||||
}
|
||||
return results;
|
||||
})()`) as unknown as { index: number; w: number; h: number; data: number[] }[];
|
||||
|
||||
if (!raw || raw.length === 0) {
|
||||
throw new Error('页面没有可用的 canvas');
|
||||
}
|
||||
|
||||
// Try to decode each canvas, preferring the one with su.quark.cn URL
|
||||
let bestUrl = '';
|
||||
let bestResult: { index: number; w: number; h: number; data: number[] } | null = null;
|
||||
|
||||
for (const canvas of raw) {
|
||||
const code = jsQR(new Uint8ClampedArray(canvas.data), canvas.w, canvas.h);
|
||||
if (code && code.data) {
|
||||
// If this is the login QR code (has su.quark.cn), use it immediately
|
||||
if (code.data.includes('su.quark.cn')) {
|
||||
return code.data;
|
||||
}
|
||||
// Otherwise keep it as fallback
|
||||
if (!bestUrl) {
|
||||
bestUrl = code.data;
|
||||
bestResult = canvas;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestUrl) {
|
||||
return bestUrl;
|
||||
}
|
||||
|
||||
throw new Error('无法解析二维码内容');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a QR code login session.
|
||||
* Launches headless Chromium, navigates to Quark login page, extracts QR code URL.
|
||||
*/
|
||||
export async function startQrLogin(): Promise<{
|
||||
sessionId: string;
|
||||
qrUrl: string;
|
||||
expiresIn: number;
|
||||
}> {
|
||||
// Clean up any existing expired sessions
|
||||
for (const [id, session] of SESSIONS.entries()) {
|
||||
if (Date.now() - session.createdAt > SESSION_TTL) {
|
||||
cleanupSession(id);
|
||||
}
|
||||
}
|
||||
|
||||
const browser = await chromium.launch({
|
||||
executablePath: CHROMIUM_PATH,
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-gpu',
|
||||
'--no-first-run',
|
||||
'--no-zygote',
|
||||
],
|
||||
});
|
||||
|
||||
const browserContext = await browser.newContext({
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
viewport: { width: 1280, height: 800 },
|
||||
locale: 'zh-CN',
|
||||
});
|
||||
|
||||
const page = await browserContext.newPage();
|
||||
const sessionId = Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
||||
|
||||
try {
|
||||
// Navigate to Quark login page (now the homepage itself has QR login)
|
||||
await page.goto('https://pan.quark.cn/', {
|
||||
waitUntil: 'commit',
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// Wait for the QR code canvas to appear
|
||||
await page.waitForSelector('canvas', { timeout: 15000 });
|
||||
|
||||
// Extra wait for the QR code to fully render
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Extract the QR code URL from the canvas
|
||||
const qrUrl = await extractQrUrl(page);
|
||||
|
||||
// Take initial cookie snapshot
|
||||
const cookies = await browserContext.cookies();
|
||||
const cookieSnapshot = cookies.map(c => `${c.name}=${c.value}`).join('; ');
|
||||
|
||||
const session: QrSession = {
|
||||
id: sessionId,
|
||||
browserContext,
|
||||
page,
|
||||
createdAt: Date.now(),
|
||||
cookieSnapshot,
|
||||
lastPollAt: Date.now(),
|
||||
qrUrl,
|
||||
status: 'pending',
|
||||
};
|
||||
|
||||
SESSIONS.set(sessionId, session);
|
||||
|
||||
// Start background polling for login detection
|
||||
pollLoginStatus(session);
|
||||
|
||||
// Handle page navigation (like redirect after login)
|
||||
page.on('framenavigated', async (frame) => {
|
||||
if (frame === page.mainFrame()) {
|
||||
const url = frame.url();
|
||||
if (url === 'about:blank') {
|
||||
await checkAndCaptureCookies(session);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle popups/dialogs
|
||||
page.on('popup', async (popup) => {
|
||||
try {
|
||||
await popup.waitForLoadState('networkidle', { timeout: 10000 });
|
||||
await checkAndCaptureCookies(session);
|
||||
} catch {}
|
||||
});
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
qrUrl,
|
||||
expiresIn: SESSION_TTL / 1000,
|
||||
};
|
||||
} catch (err: any) {
|
||||
// Clean up on failure
|
||||
try { await browserContext.close(); } catch {}
|
||||
try { browser.close().catch(() => {}); } catch {}
|
||||
SESSIONS.delete(sessionId);
|
||||
throw new Error(`启动扫码登录失败: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll login status in background.
|
||||
* Checks cookies every COOKIE_CHECK_INTERVAL ms for new session tokens.
|
||||
*/
|
||||
async function pollLoginStatus(session: QrSession) {
|
||||
const checkInterval = setInterval(async () => {
|
||||
try {
|
||||
const now = Date.now();
|
||||
|
||||
// Check if expired
|
||||
if (now - session.createdAt > SESSION_TTL) {
|
||||
clearInterval(checkInterval);
|
||||
session.status = 'expired';
|
||||
cleanupSession(session.id);
|
||||
return;
|
||||
}
|
||||
|
||||
session.lastPollAt = now;
|
||||
|
||||
// Check cookies
|
||||
const cookies = await session.browserContext.cookies();
|
||||
const cookieStr = cookies.map(c => `${c.name}=${c.value}`).join('; ');
|
||||
|
||||
// Check for session cookies indicating login
|
||||
const hasSessionCookie = cookies.some(
|
||||
c => (c.name === '__st' || c.name === 'pus' || c.name === '__pus' || c.name === '__ktd')
|
||||
);
|
||||
|
||||
if (hasSessionCookie) {
|
||||
session.cookieSnapshot = cookieStr;
|
||||
session.status = 'logged_in';
|
||||
clearInterval(checkInterval);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check URL change as alternative indicator
|
||||
const url = session.page.url();
|
||||
if (!url.includes('login') && !url.includes('qrcode') && url !== 'about:blank' && url !== 'https://pan.quark.cn/' && url.length > 10) {
|
||||
await checkAndCaptureCookies(session);
|
||||
}
|
||||
} catch (err: any) {
|
||||
// Page might have been closed
|
||||
clearInterval(checkInterval);
|
||||
}
|
||||
}, COOKIE_CHECK_INTERVAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check cookies after navigation/redirect and capture them if login succeeded.
|
||||
*/
|
||||
async function checkAndCaptureCookies(session: QrSession) {
|
||||
try {
|
||||
const cookies = await session.browserContext.cookies();
|
||||
const cookieStr = cookies.map(c => `${c.name}=${c.value}`).join('; ');
|
||||
const hasSessionCookie = cookies.some(
|
||||
c => (c.name === '__st' || c.name === 'pus' || c.name === '__pus' || c.name === '__ktd')
|
||||
);
|
||||
|
||||
if (hasSessionCookie) {
|
||||
session.cookieSnapshot = cookieStr;
|
||||
session.status = 'logged_in';
|
||||
} else if (cookies.length > 3) {
|
||||
const newCookies = cookies.filter(
|
||||
c => !['ctoken', 'b-user-id', '__wpkreporterwid_'].includes(c.name)
|
||||
);
|
||||
if (newCookies.length > 0) {
|
||||
session.cookieSnapshot = cookieStr;
|
||||
try {
|
||||
const resp = await session.page.evaluate(async () => {
|
||||
const r = await fetch('https://pan.quark.cn/account/info', {
|
||||
credentials: 'include',
|
||||
});
|
||||
return await r.text();
|
||||
});
|
||||
const data = JSON.parse(resp);
|
||||
if (data?.data?.nickname) {
|
||||
session.status = 'logged_in';
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the login status for a session.
|
||||
*/
|
||||
export async function getQrLoginStatus(sessionId: string): Promise<{
|
||||
status: string;
|
||||
cookie?: string;
|
||||
nickname?: string;
|
||||
storage_used?: string;
|
||||
storage_total?: string;
|
||||
autoUpdated?: boolean;
|
||||
updatedConfigId?: number;
|
||||
}> {
|
||||
const session = SESSIONS.get(sessionId);
|
||||
if (!session) {
|
||||
return { status: 'expired' };
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if (Date.now() - session.createdAt > SESSION_TTL) {
|
||||
session.status = 'expired';
|
||||
cleanupSession(sessionId);
|
||||
return { status: 'expired' };
|
||||
}
|
||||
|
||||
if (session.status === 'logged_in') {
|
||||
// Try to get nickname too
|
||||
let nickname = '';
|
||||
try {
|
||||
const resp = await session.page.evaluate(async () => {
|
||||
const r = await fetch('https://pan.quark.cn/account/info', {
|
||||
credentials: 'include',
|
||||
});
|
||||
return await r.text();
|
||||
});
|
||||
const data = JSON.parse(resp);
|
||||
nickname = data?.data?.nickname || '';
|
||||
} catch {}
|
||||
|
||||
// Fetch capacity info from within the browser context (has full JS signing)
|
||||
let storageTotal = '';
|
||||
let storageUsed = '';
|
||||
try {
|
||||
const capResp = await session.page.evaluate(async () => {
|
||||
const r = await fetch(
|
||||
'https://pan.quark.cn/1/clouddrive/capacity/detail?pr=ucpro&fr=pc',
|
||||
{ credentials: 'include' }
|
||||
);
|
||||
return await r.text();
|
||||
});
|
||||
const capData = JSON.parse(capResp);
|
||||
if (capData.status === 200 && capData.data?.capacity_summary) {
|
||||
const summary = capData.data.capacity_summary;
|
||||
const total = summary.sum_capacity || 0;
|
||||
storageTotal = formatBytes(total);
|
||||
storageUsed = '0 B'; // capacity/detail doesn't return used_size
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Build full cookie string including httpOnly cookies
|
||||
const cookies = await session.browserContext.cookies();
|
||||
const cookieStr = cookies.map(c => `${c.name}=${c.value}`).join('; ');
|
||||
|
||||
// Extract __uid from cookie for duplicate detection
|
||||
const uidMatch = cookieStr.match(/(?<!\\w)__uid=([a-f0-9-]+)/);
|
||||
let autoUpdated = false;
|
||||
let updatedConfigId: number | undefined;
|
||||
|
||||
if (uidMatch) {
|
||||
const uid = uidMatch[1];
|
||||
try {
|
||||
const db = getDb();
|
||||
const existing = db.prepare(
|
||||
`SELECT id, nickname FROM cloud_configs WHERE cloud_type = 'quark' AND cookie LIKE ?`
|
||||
).get(`%${escapeLike(uid)}%`) as { id: number; nickname: string } | undefined;
|
||||
|
||||
if (existing) {
|
||||
// Same account → auto-update cookie with capacity info too
|
||||
const localTimestamp = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
||||
db.prepare(
|
||||
`UPDATE cloud_configs SET cookie = ?, storage_used = ?, storage_total = ?, updated_at = ? WHERE id = ?`
|
||||
).run(cookieStr, storageUsed || null, storageTotal || null, localTimestamp, existing.id);
|
||||
autoUpdated = true;
|
||||
updatedConfigId = existing.id;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Clean up session after successful login
|
||||
cleanupSession(sessionId);
|
||||
|
||||
return {
|
||||
status: 'logged_in',
|
||||
cookie: cookieStr,
|
||||
nickname,
|
||||
storage_used: storageUsed,
|
||||
storage_total: storageTotal,
|
||||
autoUpdated,
|
||||
updatedConfigId,
|
||||
};
|
||||
}
|
||||
|
||||
return { status: session.status };
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a QR login session.
|
||||
*/
|
||||
export async function cancelQrLogin(sessionId: string): Promise<void> {
|
||||
cleanupSession(sessionId);
|
||||
}
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
import { Response } from 'express';
|
||||
|
||||
/**
|
||||
* Send a successful JSON response.
|
||||
* Uses the standard format: { error: null, data }
|
||||
*/
|
||||
export function sendSuccess<T>(res: Response, data: T, status: number = 200) {
|
||||
res.status(status).json({ error: null, ...(data as any) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an error JSON response.
|
||||
* Uses the standard format: { error: string }
|
||||
* All routes should use this for consistent frontend error handling.
|
||||
*/
|
||||
export function sendError(res: Response, status: number, message: string) {
|
||||
res.status(status).json({ error: message });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a 500 error response from a caught exception.
|
||||
* Prevents leaking stack traces in production.
|
||||
*/
|
||||
export function sendServerError(res: Response, err: unknown, fallbackMessage: string = 'Internal server error') {
|
||||
const message = err instanceof Error ? err.message : fallbackMessage;
|
||||
res.status(500).json({ error: message || fallbackMessage });
|
||||
}
|
||||
Executable
+116
@@ -0,0 +1,116 @@
|
||||
import { getDb } from '../database/database';
|
||||
|
||||
/**
|
||||
* Get the current timezone from DB config, with fallback.
|
||||
*/
|
||||
export function getTimezone(): string {
|
||||
try {
|
||||
const db = getDb();
|
||||
const row = db.prepare('SELECT value FROM system_configs WHERE key = ?').get('timezone') as any;
|
||||
return row?.value || 'Asia/Shanghai';
|
||||
} catch {
|
||||
return 'Asia/Shanghai';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns current local time as an ISO 8601 string with timezone offset.
|
||||
* Example: "2026-05-02T14:32:23+08:00"
|
||||
* This format is reliably parsed by JavaScript Date() in all browsers.
|
||||
*/
|
||||
export function localTimestamp(): string {
|
||||
const tz = getTimezone();
|
||||
const now = new Date();
|
||||
// Format as local time string with timezone offset
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat('sv-SE', {
|
||||
timeZone: tz,
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hour12: false,
|
||||
}).formatToParts(now);
|
||||
const get = (type: string) => parts.find(p => p.type === type)?.value || '00';
|
||||
const dateStr = `${get('year')}-${get('month')}-${get('day')}T${get('hour')}:${get('minute')}:${get('second')}`;
|
||||
// Calculate timezone offset for the configured timezone
|
||||
// Use getTimezoneOffset difference between UTC and the target timezone
|
||||
const utcMs = now.getTime();
|
||||
const localStr = dateStr.replace('T', ' ');
|
||||
// Get offset in minutes for the configured timezone
|
||||
const formatter = new Intl.DateTimeFormat('sv-SE', {
|
||||
timeZone: tz,
|
||||
timeZoneName: 'longOffset',
|
||||
});
|
||||
const tzName = formatter.formatToParts(now).find(p => p.type === 'timeZoneName')?.value || '';
|
||||
// tzName is like "GMT+8" or "GMT-05:00"
|
||||
let offset = '+00:00';
|
||||
if (tzName) {
|
||||
const match = tzName.match(/GMT([+-])(\d+)(?::(\d+))?/);
|
||||
if (match) {
|
||||
const sign = match[1];
|
||||
const hours = match[2].padStart(2, '0');
|
||||
const mins = (match[3] || '00').padStart(2, '0');
|
||||
offset = `${sign}${hours}:${mins}`;
|
||||
}
|
||||
}
|
||||
return dateStr + offset;
|
||||
} catch {
|
||||
// Fallback: use UTC
|
||||
return now.toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns today's (or given date's) date string in the configured timezone.
|
||||
* Example: "2026-05-04"
|
||||
*/
|
||||
export function formatLocalDate(date?: Date): string {
|
||||
const tz = getTimezone();
|
||||
const d = date || new Date();
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat('sv-SE', {
|
||||
timeZone: tz,
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
}).formatToParts(d);
|
||||
const get = (type: string) => parts.find(p => p.type === type)?.value || '00';
|
||||
return `${get('year')}-${get('month')}-${get('day')}`;
|
||||
} catch {
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape SQL LIKE wildcards (% and _) in user input to prevent unintended pattern matching.
|
||||
*/
|
||||
export function escapeLike(str: string): string {
|
||||
return str.replace(/[%_\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a local datetime string in the configured timezone.
|
||||
* Example: "2026-05-04 14:32:23" — intentionally space-separated (no T) for DB compatibility.
|
||||
*/
|
||||
export function formatLocalDateTime(date?: Date): string {
|
||||
const tz = getTimezone();
|
||||
const d = date || new Date();
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat('sv-SE', {
|
||||
timeZone: tz,
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hour12: false,
|
||||
}).formatToParts(d);
|
||||
const get = (type: string) => parts.find(p => p.type === type)?.value || '00';
|
||||
return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')}:${get('second')}`;
|
||||
} catch {
|
||||
const y = d.getFullYear();
|
||||
const mo = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const da = String(d.getDate()).padStart(2, '0');
|
||||
const h = String(d.getHours()).padStart(2, '0');
|
||||
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||
const s = String(d.getSeconds()).padStart(2, '0');
|
||||
return `${y}-${mo}-${da} ${h}:${mi}:${s}`;
|
||||
}
|
||||
}
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
export class BoundedPool {
|
||||
private concurrency: number;
|
||||
private running: number;
|
||||
private queue: Array<() => Promise<void>>;
|
||||
|
||||
constructor(concurrency: number = 10) {
|
||||
this.concurrency = concurrency;
|
||||
this.running = 0;
|
||||
this.queue = [];
|
||||
}
|
||||
|
||||
async run<T>(fn: () => Promise<T>): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const task = async () => {
|
||||
this.running++;
|
||||
try {
|
||||
const result = await fn();
|
||||
resolve(result);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
} finally {
|
||||
this.running--;
|
||||
this.processQueue();
|
||||
}
|
||||
};
|
||||
|
||||
if (this.running < this.concurrency) {
|
||||
task();
|
||||
} else {
|
||||
this.queue.push(task);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private processQueue(): void {
|
||||
while (this.running < this.concurrency && this.queue.length > 0) {
|
||||
const task = this.queue.shift();
|
||||
if (task) task();
|
||||
}
|
||||
}
|
||||
|
||||
get pending(): number {
|
||||
return this.queue.length;
|
||||
}
|
||||
|
||||
get active(): number {
|
||||
return this.running;
|
||||
}
|
||||
}
|
||||
+375
@@ -0,0 +1,375 @@
|
||||
// Native fetch available in Node 20+
|
||||
import config from '../config';
|
||||
import { RedisClient } from '../middleware/cache';
|
||||
import { BoundedPool } from './bounded-pool';
|
||||
import { BaiduDriver } from '../cloud/drivers/baidu.driver';
|
||||
import { AliyunDriver } from '../cloud/drivers/aliyun.driver';
|
||||
import { getSystemConfig } from '../admin/system-config.service';
|
||||
|
||||
export type LinkStatus = 'valid' | 'invalid' | 'unknown';
|
||||
|
||||
export interface ValidationResult {
|
||||
url: string;
|
||||
status: LinkStatus;
|
||||
cloudType: string;
|
||||
checkedAt: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从系统配置加载自定义关键词列表(一行一条)
|
||||
*/
|
||||
function loadCustomKeywords(configKey: string): string[] {
|
||||
try {
|
||||
const rules = getSystemConfig(configKey);
|
||||
if (rules) {
|
||||
return rules.split('\n').map(k => k.trim()).filter(k => k.length > 0);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export class LinkValidator {
|
||||
private cache: RedisClient;
|
||||
private pool: BoundedPool;
|
||||
|
||||
constructor(concurrency?: number) {
|
||||
this.cache = new RedisClient();
|
||||
this.pool = new BoundedPool(concurrency || config.validation.concurrency);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a single share link — PanSou only, no local fallback.
|
||||
*/
|
||||
async validate(url: string, cloudType: string): Promise<ValidationResult> {
|
||||
// Check cache first
|
||||
const cacheKey = `link:valid:${cloudType}:${Buffer.from(url).toString('base64').slice(0, 64)}`;
|
||||
|
||||
try {
|
||||
const cached = await this.cache.get(cacheKey);
|
||||
if (cached) {
|
||||
const parsed = JSON.parse(cached);
|
||||
return parsed as ValidationResult;
|
||||
}
|
||||
} catch {
|
||||
// ignore cache errors
|
||||
}
|
||||
|
||||
// Try PanSou's /api/check/links
|
||||
const pansouResult = await this.validateViaPansou(url, cloudType);
|
||||
if (pansouResult) {
|
||||
if (pansouResult.status === 'valid' || pansouResult.status === 'invalid') {
|
||||
// Cache definitive result
|
||||
const ttl = pansouResult.status === 'valid' ? config.validation.cacheTtlValid : config.validation.cacheTtlInvalid;
|
||||
try { await this.cache.setEx(cacheKey, ttl, JSON.stringify(pansouResult)); } catch {}
|
||||
return pansouResult;
|
||||
}
|
||||
// PanSou returned locked/unsupported/uncertain → return unknown, no local fallback
|
||||
return pansouResult;
|
||||
}
|
||||
|
||||
// PanSou unreachable → return unknown
|
||||
return { url, status: 'unknown' as LinkStatus, cloudType, checkedAt: new Date().toISOString(), message: '盘搜不可达' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Full validation with local fallback when PanSou can't determine.
|
||||
*/
|
||||
async validateWithLocalFallback(url: string, cloudType: string): Promise<ValidationResult> {
|
||||
// Check cache first
|
||||
const cacheKey = `link:valid:${cloudType}:${Buffer.from(url).toString('base64').slice(0, 64)}`;
|
||||
|
||||
try {
|
||||
const cached = await this.cache.get(cacheKey);
|
||||
if (cached) {
|
||||
const parsed = JSON.parse(cached);
|
||||
return parsed as ValidationResult;
|
||||
}
|
||||
} catch {
|
||||
// ignore cache errors
|
||||
}
|
||||
|
||||
// Try PanSou
|
||||
const pansouResult = await this.validateViaPansou(url, cloudType);
|
||||
if (pansouResult) {
|
||||
if (pansouResult.status === 'valid' || pansouResult.status === 'invalid') {
|
||||
const ttl = pansouResult.status === 'valid' ? config.validation.cacheTtlValid : config.validation.cacheTtlInvalid;
|
||||
try { await this.cache.setEx(cacheKey, ttl, JSON.stringify(pansouResult)); } catch {}
|
||||
return pansouResult;
|
||||
}
|
||||
// PanSou uncertain → fall through to local validation
|
||||
}
|
||||
|
||||
// Fall back to own validation
|
||||
let result: ValidationResult;
|
||||
|
||||
switch (cloudType) {
|
||||
case 'quark':
|
||||
result = await this.validateQuark(url);
|
||||
break;
|
||||
case 'baidu':
|
||||
result = await this.validateBaidu(url);
|
||||
break;
|
||||
case 'aliyun':
|
||||
result = await this.validateAliyun(url);
|
||||
break;
|
||||
default:
|
||||
result = await this.validateByHtml(url, cloudType);
|
||||
}
|
||||
|
||||
const ttl = result.status === 'valid' ? config.validation.cacheTtlValid : config.validation.cacheTtlInvalid;
|
||||
try { await this.cache.setEx(cacheKey, ttl, JSON.stringify(result)); } catch {}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try PanSou's /api/check/links for validation.
|
||||
* Returns null if PanSou is unreachable.
|
||||
*
|
||||
* Judgment order:
|
||||
* 1. summary "链接有效" → valid (PanSou's own OK signal)
|
||||
* 2. summary 含自定义确认关键词 → valid (from DB link_valid_keywords)
|
||||
* 3. summary 含自定义失效关键词 → invalid (from DB link_invalid_keywords)
|
||||
* 4. 其他 → unknown
|
||||
*/
|
||||
private async validateViaPansou(url: string, cloudType: string): Promise<ValidationResult | null> {
|
||||
const checkedAt = new Date().toISOString();
|
||||
try {
|
||||
const pansouApiUrl = `${config.pansouUrl}/api/check/links`;
|
||||
const response = await fetch(pansouApiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
items: [{ disk_type: cloudType, url }],
|
||||
}),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
if (!response.ok) return null;
|
||||
|
||||
const data = await response.json() as any;
|
||||
const pansouResult = data.results?.[0];
|
||||
if (!pansouResult) return null;
|
||||
|
||||
const summary = pansouResult.summary || '';
|
||||
|
||||
// 1. PanSou 明确返回"链接有效"
|
||||
if (summary.includes('链接有效')) {
|
||||
return { url, status: 'valid', cloudType, checkedAt, message: summary };
|
||||
}
|
||||
|
||||
// 2. 自定义确认关键词(用户配置的"有效"信号)
|
||||
const validKeywords = loadCustomKeywords('link_valid_keywords');
|
||||
if (validKeywords.some(kw => summary.includes(kw))) {
|
||||
return { url, status: 'valid', cloudType, checkedAt, message: summary };
|
||||
}
|
||||
|
||||
// 3. 自定义失效关键词(用户配置的"失效"信号)
|
||||
const invalidKeywords = loadCustomKeywords('link_invalid_keywords');
|
||||
if (invalidKeywords.some(kw => summary.includes(kw))) {
|
||||
return { url, status: 'invalid', cloudType, checkedAt, message: summary };
|
||||
}
|
||||
|
||||
// 4. 其余全部返回 unknown
|
||||
return { url, status: 'unknown', cloudType, checkedAt, message: summary || '盘搜无法确认' };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a Quark share link using the public share token API.
|
||||
*/
|
||||
private async validateQuark(url: string): Promise<ValidationResult> {
|
||||
const checkedAt = new Date().toISOString();
|
||||
|
||||
try {
|
||||
const cleanUrl = url.split('#')[0];
|
||||
const urlObj = new URL(cleanUrl);
|
||||
const pathParts = urlObj.pathname.split('/');
|
||||
const shareToken = pathParts[pathParts.length - 1] || pathParts[pathParts.length - 2];
|
||||
|
||||
if (!shareToken) {
|
||||
return { url, status: 'unknown', cloudType: 'quark', checkedAt, message: '无法解析分享链接 token' };
|
||||
}
|
||||
|
||||
const tokenUrl = 'https://drive-pc.quark.cn/1/clouddrive/share/sharepage/token?pr=ucpro&fr=pc';
|
||||
const response = await fetch(tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Origin': 'https://pan.quark.cn',
|
||||
'Referer': 'https://pan.quark.cn/',
|
||||
},
|
||||
body: JSON.stringify({ pwd_id: shareToken, passcode: '' }),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const msg = response.status === 403 ? '分享已过期或需要密码' : `HTTP ${response.status}`;
|
||||
return { url, status: 'invalid', cloudType: 'quark', checkedAt, message: msg };
|
||||
}
|
||||
|
||||
const data = await response.json() as any;
|
||||
if (data.status === 200 && data.data?.stoken) {
|
||||
const title = data.data?.title || '';
|
||||
const author = data.data?.author?.nick_name || '';
|
||||
const expiredAt = data.data?.expired_at || 0;
|
||||
const expireDate = expiredAt > 0 ? new Date(expiredAt).toISOString().slice(0, 10) : '';
|
||||
return {
|
||||
url,
|
||||
status: 'valid',
|
||||
cloudType: 'quark',
|
||||
checkedAt,
|
||||
message: expireDate ? `有效链接,过期时间: ${expireDate}` : '有效链接',
|
||||
};
|
||||
}
|
||||
|
||||
// API 返回了 200 但无 stoken — 可能是临时异常,保守判 unknown
|
||||
return { url, status: 'unknown', cloudType: 'quark', checkedAt, message: 'API 返回异常(无 stoken),不做失效判定' };
|
||||
} catch (err: any) {
|
||||
return {
|
||||
url,
|
||||
status: 'unknown',
|
||||
cloudType: 'quark',
|
||||
checkedAt,
|
||||
message: `校验异常: ${err.message?.slice(0, 50) || '未知错误'}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async validateBaidu(url: string): Promise<ValidationResult> {
|
||||
const checkedAt = new Date().toISOString();
|
||||
|
||||
try {
|
||||
const driver = new BaiduDriver();
|
||||
const result = await driver.validateShareLink(url);
|
||||
|
||||
return {
|
||||
url,
|
||||
status: result.valid ? 'valid' : 'invalid',
|
||||
cloudType: 'baidu',
|
||||
checkedAt,
|
||||
message: result.message,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
url,
|
||||
status: 'unknown',
|
||||
cloudType: 'baidu',
|
||||
checkedAt,
|
||||
message: `校验失败: ${err.message || err}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async validateAliyun(url: string): Promise<ValidationResult> {
|
||||
const checkedAt = new Date().toISOString();
|
||||
|
||||
try {
|
||||
const driver = new AliyunDriver();
|
||||
const result = await driver.validateShareLink(url);
|
||||
|
||||
return {
|
||||
url,
|
||||
status: result.valid ? 'valid' : 'invalid',
|
||||
cloudType: 'aliyun',
|
||||
checkedAt,
|
||||
message: result.message,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
url,
|
||||
status: 'unknown',
|
||||
cloudType: 'aliyun',
|
||||
checkedAt,
|
||||
message: `校验失败: ${err.message || err}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: validate by fetching the share page as HTML and checking for
|
||||
* custom failure keywords from DB config. Used for providers without a
|
||||
* dedicated API (115, tianyi, 123pan, etc.).
|
||||
*/
|
||||
private async validateByHtml(url: string, cloudType: string): Promise<ValidationResult> {
|
||||
let status: LinkStatus = 'valid';
|
||||
const checkedAt = new Date().toISOString();
|
||||
let message = '';
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), config.validation.timeout);
|
||||
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal as any,
|
||||
headers: {
|
||||
'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',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||||
},
|
||||
redirect: 'follow',
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const text = await response.text();
|
||||
const keywords = loadCustomKeywords('link_invalid_keywords');
|
||||
|
||||
const isHttpError = response.status >= 400;
|
||||
if (isHttpError) {
|
||||
status = 'invalid';
|
||||
message = `HTTP ${response.status} ${response.statusText}`;
|
||||
} else {
|
||||
const matched = keywords.find(kw => text.includes(kw));
|
||||
if (matched) {
|
||||
status = 'invalid';
|
||||
message = `页面包含自定义失效关键词: "${matched}"`;
|
||||
} else {
|
||||
message = 'HTML 页面可访问,未检测到失效关键词';
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
// On timeout or network error, conservatively mark as valid
|
||||
status = 'valid';
|
||||
message = `网络校验超时,保守标记为有效`;
|
||||
}
|
||||
|
||||
return { url, status, cloudType, checkedAt, message };
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch validate multiple links with bounded concurrency.
|
||||
*/
|
||||
async validateBatch(urls: Array<{ url: string; cloudType: string }>): Promise<ValidationResult[]> {
|
||||
const tasks = urls.map(item => () => this.validate(item.url, item.cloudType));
|
||||
const results: ValidationResult[] = [];
|
||||
|
||||
for (const task of tasks) {
|
||||
try {
|
||||
const result = await this.pool.run(task);
|
||||
results.push(result);
|
||||
} catch (err) {
|
||||
results.push({
|
||||
url: '',
|
||||
status: 'unknown',
|
||||
cloudType: '',
|
||||
checkedAt: new Date().toISOString(),
|
||||
message: '校验执行异常',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async validateBatchWithPool(urls: Array<{ url: string; cloudType: string }>): Promise<ValidationResult[]> {
|
||||
return this.validateBatch(urls);
|
||||
}
|
||||
}
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
// Native fetch available in Node 20+
|
||||
import config from '../config';
|
||||
|
||||
export interface VideoInfo {
|
||||
title: string;
|
||||
coverUrl: string;
|
||||
videoUrl: string;
|
||||
author: string;
|
||||
platform: string;
|
||||
duration?: string;
|
||||
}
|
||||
|
||||
export async function parseVideo(url: string): Promise<VideoInfo> {
|
||||
const apiUrl = `${config.videoParserUrl}/parse`;
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url }),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Video parser API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as any;
|
||||
|
||||
return {
|
||||
title: data.title || '',
|
||||
coverUrl: data.coverUrl || data.cover || '',
|
||||
videoUrl: data.videoUrl || data.url || data.video || '',
|
||||
author: data.author || data.nickname || '',
|
||||
platform: data.platform || '',
|
||||
duration: data.duration || '',
|
||||
};
|
||||
}
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user