// ==UserScript== // @name 光鸭云盘批量助手 V4 // @namespace serenalee.guangyapan.batch-helper // @version 0.5.17 // @description 为光鸭云盘网页端提供批量重命名、重复项预览/勾选/删除、TXT/JSON 磁力批量云添加、最里层空目录扫描与删除、进度显示与滚动列表累计识别功能。 // @author Serena Lee // @license Copyright (c) 2026 Serena Lee. All rights reserved. // @match https://www.guangyapan.com/* // @icon https://image.868717.xyz/file/1776301692011_3.svg // @run-at document-start // @grant GM_xmlhttpRequest // @grant GM_registerMenuCommand // @grant unsafeWindow // @connect api.guangyapan.com // ==/UserScript== (function () { 'use strict'; const SCRIPT_VERSION = '0.5.17'; // ========================= // 用户配置区:主要改这里 // ========================= const CONFIG = { debug: false, request: { apiHost: 'https://api.guangyapan.com', listPath: '/nd.bizuserres.s/v1/file/get_file_list', renamePath: '/nd.bizuserres.s/v1/file/rename', deletePath: '/nd.bizuserres.s/v1/file/delete_file', createDirPath: '/nd.bizuserres.s/v1/file/create_dir', taskStatusPath: '/nd.bizuserres.s/v1/get_task_status', resolveResPath: '/nd.bizcloudcollection.s/v1/resolve_res', cloudCreateTaskPath: '/nd.bizcloudcollection.s/v1/create_task', cloudListTaskPath: '/nd.bizcloudcollection.s/v1/list_task', // 自动抓不到时,再手工填写这些值。 manualHeaders: { authorization: '', did: '', dt: '', appid: '', timestamp: '', signature: '', nonce: '', }, // 自动抓不到当前目录时,再手工填写。 manualListBody: { parentId: '', pageSize: 100, orderBy: 0, sortType: 0, }, }, batch: { delayMs: 300, confirmBeforeRun: true, stopOnError: false, taskPollMs: 1500, taskPollMaxTries: 180, }, rename: { ruleMode: 'remove-leading-bracket', // 默认效果:删除开头第一个 [] 或 【】 以及里面的内容。 // 可按顺序继续加规则。 rules: [ { enabled: true, type: 'regex', pattern: '^\\s*[\\[【][^\\]】]*[\\]】]\\s*', flags: 'u', replace: '', }, ], output: { mode: 'keep-clean', addText: '', addPosition: 'suffix', findText: '', replaceText: '', formatStyle: 'text-and-index', formatText: '文件', formatPosition: 'suffix', startIndex: 0, template: '{clean}', }, // 可用占位符: // {original} 原文件名 // {clean} 应用 rules 后的名字 // {base} clean 去掉最后一个扩展名后的部分 // {ext} clean 的最后一个扩展名,例:.mkv // {fileId} 文件 ID // {index} 序号(格式命名或自定义模板时可用) template: '{clean}', trimResult: true, buildName(item, utils, context = {}) { const original = String(item.name || ''); const clean = utils.applyRules(original, item); const ext = utils.getExt(clean); const base = utils.getBaseName(clean); const output = CONFIG.rename.output || {}; const mode = output.mode || 'keep-clean'; const renameIndex = Number(context.renameIndex || 0); const serial = Number(output.startIndex || 0) + renameIndex; if (mode === 'add-text') { const addText = String(output.addText || ''); if (!addText) { return clean; } return output.addPosition === 'prefix' ? `${addText}${clean}` : `${clean}${addText}`; } if (mode === 'replace-text') { const findText = String(output.findText || ''); if (!findText) { return clean; } return clean.split(findText).join(String(output.replaceText || '')); } if (mode === 'format') { const formatText = String(output.formatText || '').trim() || '文件'; if (output.formatStyle === 'text-only') { return formatText; } return output.formatPosition === 'prefix' ? `${serial}${formatText}` : `${formatText}${serial}`; } if (mode === 'custom-template') { const template = String(output.template || CONFIG.rename.template || '{clean}').trim() || '{clean}'; return utils.renderTemplate(template, { original, clean, base, ext, fileId: item.fileId, index: serial, }); } return clean; }, }, filter: { // 想跳过某些名字时在这里写条件。 // 例:item => !item.name.includes('不要改') predicate: () => true, }, duplicate: { mode: 'numbers', numbers: '1,2,3', // 默认识别文件夹名末尾带 (1) / (2) / (3) 或中文括号版本。 pattern: '[((]\\s*(?:1|2|3|1|2|3)\\s*[))]\\s*(?:\\.[a-zA-Z0-9]{1,12})?$', flags: 'u', }, cloud: { maxFilesPerTask: 500, sourceDirPrefix: '磁力导入', createMagnetSubdir: false, listTaskPageSize: 50, }, }; const LOG_PREFIX = '[光鸭云盘批量助手]'; const CAPTURE_EVENT = '__GYP_BATCH_RENAME_CAPTURE__'; const PAGE_REQUEST_EVENT = '__GYP_BATCH_RENAME_PAGE_REQUEST__'; const PAGE_RESPONSE_EVENT = '__GYP_BATCH_RENAME_PAGE_RESPONSE__'; const CONFIG_STORAGE_KEY = '__GYP_BATCH_RENAME_CONFIG_V1__'; const STATE = { headers: {}, lastApiHeaders: null, lastListHeaders: null, lastListUrl: '', lastListBody: null, lastListItems: [], capturedLists: {}, lastCapturedParentId: '', lastItemsSource: 'none', lastListResponse: null, lastListCapturedAt: 0, lastRenameRequest: null, duplicatePreviewItems: [], duplicateSelection: {}, emptyDirSelection: {}, magnetImportFiles: [], lastCloudImportSummary: null, lastCloudTaskList: null, lastEmptyDirScan: null, activeTaskControl: null, lastProgressState: { visible: false, percent: 0, indeterminate: false, text: '', }, installedAt: new Date().toISOString(), }; const UI = { root: null, panel: null, mini: null, status: null, progressWrap: null, progressBar: null, progressText: null, pauseTaskButton: null, stopTaskButton: null, fields: {}, summary: null, duplicateList: null, duplicateCount: null, emptyDirList: null, emptyDirCount: null, emptyDirDetails: null, magnetFileInput: null, magnetFileList: null, magnetFileCount: null, }; const KEEP_HEADER_NAMES = [ 'authorization', 'did', 'dt', ]; const FORBIDDEN_FORWARD_HEADERS = new Set([ 'accept-encoding', 'content-length', 'cookie', 'host', 'origin', 'priority', 'referer', ]); const SAFE_FORWARD_HEADERS = new Set([ 'accept', 'authorization', 'content-type', 'did', 'dt', 'x-device-id', 'x-requested-with', ]); const DEFAULT_LEADING_BRACKET_PATTERN = '^\\s*[\\[【][^\\]】]*[\\]】]\\s*'; const DEFAULT_DUPLICATE_NUMBERS = '1,2,3'; const EMPTY_STATE_TEXT_PATTERNS = [ /暂无文件/u, /空文件夹/u, /暂无数据/u, /文件夹为空/u, /没有文件/u, /这里空空如也/u, /什么都没有/u, ]; const ROOT_DIRECTORY_NAMES = new Set([ '我的云盘', '首页', '全部文件', '光鸭云盘', '文件', ]); const TRANSIENT_LIST_BODY_KEYS = new Set([ 'cursor', 'nextCursor', 'nextKey', 'nextToken', 'pageToken', 'continueToken', 'marker', 'offset', 'start', 'startId', 'startKey', 'lastId', 'lastKey', 'lastFileId', 'lastSortValue', 'pageNo', 'pageNum', 'pageIndex', 'page', 'scrollId', ]); const KNOWN_COMPOUND_FILE_EXTENSIONS = [ '.tar.gz', '.tar.bz2', '.tar.xz', '.user.js', '.d.ts', ]; const KNOWN_FILE_EXTENSIONS = new Set([ '7z', 'aac', 'ape', 'ass', 'avi', 'azw3', 'bmp', 'bz2', 'csv', 'cue', 'doc', 'docx', 'epub', 'flac', 'flv', 'gif', 'gz', 'heic', 'idx', 'iso', 'jpeg', 'jpg', 'json', 'm4a', 'm4v', 'mkv', 'mobi', 'mov', 'mp3', 'mp4', 'mpeg', 'mpg', 'nfo', 'ogg', 'opus', 'pdf', 'png', 'ppt', 'pptx', 'rar', 'rm', 'rmvb', 'srt', 'ssa', 'strm', 'sub', 'sup', 'tar', 'tif', 'tiff', 'torrent', 'ts', 'txt', 'vtt', 'wav', 'webm', 'webp', 'wmv', 'xls', 'xlsx', 'xml', 'xz', 'yaml', 'yml', 'zip', ]); const CLOUD_VIDEO_EXTENSIONS = new Set([ '3gp', 'asf', 'avi', 'flv', 'iso', 'm2ts', 'm4v', 'mkv', 'mov', 'mp4', 'mpeg', 'mpg', 'rm', 'rmvb', 'ts', 'vob', 'webm', 'wmv', ]); const CLOUD_JUNK_EXTENSIONS = new Set([ 'bmp', 'gif', 'jpeg', 'jpg', 'nfo', 'png', 'txt', 'url', 'webp', ]); const CLOUD_SKIP_NAME_PATTERNS = [ /(^|[^\w])(sample|trailer|teaser|preview|screencap|poster|cover)([^\w]|$)/i, /预告|花絮|海报|封面|说明|访问|网址/i, ]; const EMPTY_DIR_SCAN_MAX_DIRS = 3000; const EMPTY_DIR_SCAN_MAX_PAGES_PER_DIR = 200; const EMPTY_SCAN_EXTRA_FILE_EXTENSIONS = new Set([ 'apk', 'cia', 'ipa', 'nsp', 'nsz', 'pkg', 'xci', 'xcz', ]); function getForwardableHeadersFromCaptured(headersLike) { const captured = sanitizeHeaders(headersLike); const forwardable = {}; for (const [key, value] of Object.entries(captured)) { if ( !key || key.startsWith(':') || key.startsWith('sec-') || FORBIDDEN_FORWARD_HEADERS.has(key) || !SAFE_FORWARD_HEADERS.has(key) ) { continue; } forwardable[key] = value; } return forwardable; } function pickFirstNonEmptyHeaders(...sources) { for (const source of sources) { if (source && Object.keys(sanitizeHeaders(source)).length) { return source; } } return null; } function log(...args) { if (CONFIG.debug) { console.log(LOG_PREFIX, ...args); } } function warn(...args) { console.warn(LOG_PREFIX, ...args); } function fail(...args) { console.error(LOG_PREFIX, ...args); } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function safeJsonParse(value) { if (typeof value !== 'string') { return value; } try { return JSON.parse(value); } catch { return null; } } function loadPersistedConfig() { try { const raw = window.localStorage.getItem(CONFIG_STORAGE_KEY); if (!raw) { return; } const saved = JSON.parse(raw); if (saved && typeof saved === 'object') { if (saved.manualHeaders && typeof saved.manualHeaders === 'object') { Object.assign(CONFIG.request.manualHeaders, saved.manualHeaders); } if (saved.manualListBody && typeof saved.manualListBody === 'object') { Object.assign(CONFIG.request.manualListBody, saved.manualListBody); } if (saved.batch && typeof saved.batch === 'object') { if (saved.batch.delayMs != null && !Number.isNaN(Number(saved.batch.delayMs))) { CONFIG.batch.delayMs = Number(saved.batch.delayMs); } } if (typeof saved.renameTemplate === 'string') { CONFIG.rename.template = saved.renameTemplate; } if (typeof saved.renameRuleMode === 'string') { CONFIG.rename.ruleMode = saved.renameRuleMode; } if (saved.renameOutput && typeof saved.renameOutput === 'object') { Object.assign(CONFIG.rename.output, saved.renameOutput); } if (saved.firstRule && typeof saved.firstRule === 'object' && CONFIG.rename.rules[0]) { Object.assign(CONFIG.rename.rules[0], saved.firstRule); } if (saved.duplicate && typeof saved.duplicate === 'object') { if (typeof saved.duplicate.mode === 'string') { CONFIG.duplicate.mode = saved.duplicate.mode; } if (typeof saved.duplicate.numbers === 'string') { CONFIG.duplicate.numbers = saved.duplicate.numbers; } if (typeof saved.duplicate.pattern === 'string') { CONFIG.duplicate.pattern = saved.duplicate.pattern; } if (typeof saved.duplicate.flags === 'string') { CONFIG.duplicate.flags = saved.duplicate.flags; } } if (saved.cloud && typeof saved.cloud === 'object') { if (saved.cloud.maxFilesPerTask != null && !Number.isNaN(Number(saved.cloud.maxFilesPerTask))) { CONFIG.cloud.maxFilesPerTask = Math.max(1, Number(saved.cloud.maxFilesPerTask)); } if (typeof saved.cloud.sourceDirPrefix === 'string') { CONFIG.cloud.sourceDirPrefix = saved.cloud.sourceDirPrefix; } if (typeof saved.cloud.createMagnetSubdir === 'boolean') { CONFIG.cloud.createMagnetSubdir = saved.cloud.createMagnetSubdir; } if (saved.cloud.listTaskPageSize != null && !Number.isNaN(Number(saved.cloud.listTaskPageSize))) { CONFIG.cloud.listTaskPageSize = Math.max(1, Number(saved.cloud.listTaskPageSize)); } } } } catch (err) { warn('读取已保存配置失败:', err); } } function savePersistedConfig() { try { const payload = { manualHeaders: { ...CONFIG.request.manualHeaders }, manualListBody: { ...CONFIG.request.manualListBody }, batch: { delayMs: CONFIG.batch.delayMs, }, renameTemplate: CONFIG.rename.template, renameRuleMode: CONFIG.rename.ruleMode, renameOutput: { ...CONFIG.rename.output }, firstRule: CONFIG.rename.rules[0] ? { enabled: CONFIG.rename.rules[0].enabled !== false, type: CONFIG.rename.rules[0].type || 'regex', pattern: CONFIG.rename.rules[0].pattern || '', flags: CONFIG.rename.rules[0].flags || '', search: CONFIG.rename.rules[0].search || '', replace: CONFIG.rename.rules[0].replace || '', } : null, duplicate: { mode: CONFIG.duplicate.mode, numbers: CONFIG.duplicate.numbers, pattern: CONFIG.duplicate.pattern, flags: CONFIG.duplicate.flags, }, cloud: { maxFilesPerTask: CONFIG.cloud.maxFilesPerTask, sourceDirPrefix: CONFIG.cloud.sourceDirPrefix, createMagnetSubdir: CONFIG.cloud.createMagnetSubdir, listTaskPageSize: CONFIG.cloud.listTaskPageSize, }, }; window.localStorage.setItem(CONFIG_STORAGE_KEY, JSON.stringify(payload)); } catch (err) { warn('保存配置失败:', err); } } function sanitizeHeaders(headersLike) { const out = {}; if (!headersLike) { return out; } if (headersLike instanceof Headers) { for (const [key, value] of headersLike.entries()) { out[String(key).toLowerCase()] = value; } return out; } if (Array.isArray(headersLike)) { for (const [key, value] of headersLike) { out[String(key).toLowerCase()] = value; } return out; } if (typeof headersLike === 'object') { for (const [key, value] of Object.entries(headersLike)) { out[String(key).toLowerCase()] = value; } } return out; } function mergeHeaders(headersLike) { const normalized = sanitizeHeaders(headersLike); for (const key of KEEP_HEADER_NAMES) { if (normalized[key]) { STATE.headers[key] = normalized[key]; } } } function getMergedHeaders() { const out = {}; const manual = CONFIG.request.manualHeaders || {}; for (const key of KEEP_HEADER_NAMES) { // 优先使用最新的 STATE (捕获到的),如果没有,再用 manual (保存的) out[key] = STATE.headers[key] || manual[key] || ''; } return out; } function normalizeParentId(value) { return String(value || '').trim(); } function getParentIdFromListBody(body) { return normalizeParentId(body && typeof body === 'object' ? body.parentId : ''); } function sanitizeListBody(body = {}) { const source = body && typeof body === 'object' ? body : {}; const out = {}; for (const [key, value] of Object.entries(source)) { if (value === '' || value == null) { continue; } if (TRANSIENT_LIST_BODY_KEYS.has(String(key))) { continue; } out[key] = value; } return out; } function getCapturedItemKey(item) { if (!item || typeof item !== 'object') { return ''; } const fileId = String(item.fileId || '').trim(); if (fileId) { return `id:${fileId}`; } const name = normalizeDomName(item.name); return name ? `name:${name}` : ''; } function createCapturedListBucket(parentId) { return { parentId, items: [], indexByKey: {}, batchCount: 0, lastBatchSize: 0, listUrl: '', lastBody: null, updatedAt: '', }; } function getCapturedListBucket(parentId, options = {}) { const key = normalizeParentId(parentId); if (!key) { return null; } if (!STATE.capturedLists[key] && options.create !== false) { STATE.capturedLists[key] = createCapturedListBucket(key); } return STATE.capturedLists[key] || null; } function rebuildCapturedListBucketIndex(bucket) { if (!bucket || !Array.isArray(bucket.items)) { return; } const next = {}; const deduped = []; for (const item of bucket.items) { const itemKey = getCapturedItemKey(item); if (!itemKey || Object.prototype.hasOwnProperty.call(next, itemKey)) { continue; } next[itemKey] = deduped.length; deduped.push(item); } bucket.items = deduped; bucket.indexByKey = next; } function mergeCapturedItems(parentId, items, meta = {}) { const normalizedParentId = normalizeParentId(parentId); const normalizedItems = dedupeItems( (items || []).map((item) => ({ fileId: String(item?.fileId || ''), dirId: String(item?.dirId || item?.fileId || ''), dirIdCandidates: normalizeIdCandidates(item?.dirIdCandidates || [item?.dirId, item?.fileId]), name: String(item?.name || ''), parentId: String(item?.parentId || ''), isDir: item?.isDir === true, raw: item?.raw, })) ); if (!normalizedParentId) { STATE.lastListItems = normalizedItems; STATE.lastItemsSource = 'api'; return { items: normalizedItems, total: normalizedItems.length, added: normalizedItems.length, updated: 0, batchCount: normalizedItems.length ? 1 : 0, lastBatchSize: normalizedItems.length, parentId: '', }; } const bucket = getCapturedListBucket(normalizedParentId); let added = 0; let updated = 0; for (const item of normalizedItems) { const itemKey = getCapturedItemKey(item); if (!itemKey) { continue; } if (Object.prototype.hasOwnProperty.call(bucket.indexByKey, itemKey)) { bucket.items[bucket.indexByKey[itemKey]] = item; updated += 1; } else { bucket.indexByKey[itemKey] = bucket.items.length; bucket.items.push(item); added += 1; } } if (meta.countAsBatch !== false) { bucket.batchCount += 1; } bucket.lastBatchSize = normalizedItems.length; bucket.updatedAt = new Date().toISOString(); if (meta.listUrl) { bucket.listUrl = String(meta.listUrl); } if (meta.requestBody && typeof meta.requestBody === 'object') { bucket.lastBody = { ...meta.requestBody }; } STATE.lastCapturedParentId = normalizedParentId; STATE.lastListItems = bucket.items; STATE.lastItemsSource = bucket.batchCount > 1 ? 'api-merged' : 'api'; return { items: bucket.items, total: bucket.items.length, added, updated, batchCount: bucket.batchCount, lastBatchSize: bucket.lastBatchSize, parentId: normalizedParentId, }; } function removeCapturedItemsByIds(fileIds) { const ids = new Set((fileIds || []).map((id) => String(id)).filter(Boolean)); if (!ids.size) { return; } for (const bucket of Object.values(STATE.capturedLists || {})) { if (!bucket || !Array.isArray(bucket.items) || !bucket.items.length) { continue; } bucket.items = bucket.items.filter((item) => !ids.has(String(item.fileId || ''))); rebuildCapturedListBucketIndex(bucket); } const currentBucket = getCapturedListBucket(STATE.lastCapturedParentId, { create: false }); if (currentBucket) { STATE.lastListItems = currentBucket.items; } } function renameCapturedItem(fileId, newName) { const id = String(fileId || '').trim(); if (!id) { return; } for (const bucket of Object.values(STATE.capturedLists || {})) { if (!bucket || !Array.isArray(bucket.items) || !bucket.items.length) { continue; } const index = bucket.indexByKey[`id:${id}`]; if (typeof index === 'number' && bucket.items[index]) { bucket.items[index] = { ...bucket.items[index], name: String(newName || ''), }; } } } function getCapturedListStats(parentId = '') { const normalizedParentId = normalizeParentId(parentId) || getParentIdFromListBody(STATE.lastListBody) || normalizeParentId(CONFIG.request.manualListBody.parentId) || normalizeParentId(STATE.lastCapturedParentId); const bucket = getCapturedListBucket(normalizedParentId, { create: false }); const fallbackItems = Array.isArray(STATE.lastListItems) ? STATE.lastListItems : []; return { parentId: normalizedParentId, total: bucket?.items?.length || fallbackItems.length || 0, lastBatchSize: bucket?.lastBatchSize || fallbackItems.length || 0, batchCount: bucket?.batchCount || (fallbackItems.length ? 1 : 0), listUrl: bucket?.listUrl || STATE.lastListUrl || '', updatedAt: bucket?.updatedAt || '', }; } function getItemsSourceLabel(source = STATE.lastItemsSource) { if (source === 'api-merged') { return 'api(累计)'; } if (source === 'dom') { return '页面可见项'; } return source || 'none'; } function getCurrentListContext() { const stats = getCapturedListStats(); const bucket = getCapturedListBucket(stats.parentId, { create: false }); const body = resolveListBody(bucket?.lastBody || STATE.lastListBody || {}); return { parentId: body.parentId || stats.parentId || '', pageSize: body.pageSize || '', listUrl: stats.listUrl || '', capturedCount: stats.total, lastBatchSize: stats.lastBatchSize, batchCount: stats.batchCount, }; } function getRequestHeaders() { const extra = getMergedHeaders(); const captured = getForwardableHeadersFromCaptured( pickFirstNonEmptyHeaders(STATE.lastApiHeaders, STATE.lastListHeaders, STATE.lastRenameRequest?.headers) ); return { ...captured, accept: 'application/json, text/plain, */*', 'content-type': 'application/json', ...extra, }; } function pickExistingKey(obj, candidates, fallback) { if (!obj || typeof obj !== 'object') { return fallback; } for (const key of candidates) { if (Object.prototype.hasOwnProperty.call(obj, key)) { return key; } } return fallback; } function getRenameUrl() { return STATE.lastRenameRequest?.url || `${CONFIG.request.apiHost}${CONFIG.request.renamePath}`; } function getDeleteUrl() { return `${CONFIG.request.apiHost}${CONFIG.request.deletePath}`; } function getCreateDirUrl() { return `${CONFIG.request.apiHost}${CONFIG.request.createDirPath}`; } function getTaskStatusUrl() { return `${CONFIG.request.apiHost}${CONFIG.request.taskStatusPath}`; } function getResolveResUrl() { return `${CONFIG.request.apiHost}${CONFIG.request.resolveResPath}`; } function getCloudCreateTaskUrl() { return `${CONFIG.request.apiHost}${CONFIG.request.cloudCreateTaskPath}`; } function getCloudListTaskUrl() { return `${CONFIG.request.apiHost}${CONFIG.request.cloudListTaskPath}`; } function getRenameHeaders() { const forwardable = getForwardableHeadersFromCaptured( pickFirstNonEmptyHeaders(STATE.lastRenameRequest?.headers, STATE.lastApiHeaders, STATE.lastListHeaders) ); return { ...forwardable, ...getRequestHeaders(), }; } function getCommonApiRequestOptions(body, headers = getRequestHeaders()) { return { method: 'POST', headers, mode: 'cors', credentials: 'include', body: JSON.stringify(body), }; } async function postJson(url, body, headers = getRequestHeaders()) { const response = await pageRequest(url, getCommonApiRequestOptions(body, headers)); const payload = safeJsonParse(response.text || ''); return { ok: response.ok, status: response.status, text: response.text || '', payload, }; } function findFirstValueByKeys(node, keys) { if (!node || typeof node !== 'object') { return null; } if (Array.isArray(node)) { for (const item of node) { const found = findFirstValueByKeys(item, keys); if (found != null) { return found; } } return null; } for (const key of keys) { if (Object.prototype.hasOwnProperty.call(node, key) && node[key] != null) { return node[key]; } } for (const value of Object.values(node)) { const found = findFirstValueByKeys(value, keys); if (found != null) { return found; } } return null; } function decodeUrlParam(value) { try { return decodeURIComponent(String(value || '').replace(/\+/g, '%20')); } catch { return String(value || ''); } } function getMagnetQueryParams(magnetUrl) { const text = String(magnetUrl || '').trim(); const query = text.includes('?') ? text.slice(text.indexOf('?') + 1) : ''; return new URLSearchParams(query); } function getMagnetBtih(magnetUrl) { const xt = getMagnetQueryParams(magnetUrl).get('xt') || ''; const matched = xt.match(/btih:([^&]+)/i); return matched ? String(matched[1] || '').trim() : ''; } function getMagnetDisplayName(magnetUrl) { const params = getMagnetQueryParams(magnetUrl); const dn = params.get('dn'); if (dn) { return decodeUrlParam(dn); } const btih = getMagnetBtih(magnetUrl); return btih ? `磁力_${btih.slice(0, 12)}` : '磁力资源'; } function getMagnetIdentityKey(magnetUrl) { const btih = String(getMagnetBtih(magnetUrl) || '').trim().toLowerCase(); if (btih) { return `btih:${btih}`; } const normalizedUrl = String(magnetUrl || '').trim().toLowerCase(); return normalizedUrl ? `url:${normalizedUrl}` : ''; } function sanitizeCloudDirName(name, fallback = '磁力资源') { const text = String(name || '') .replace(/[\\/:*?"<>|\u0000-\u001f]+/g, ' ') .replace(/\s+/g, ' ') .trim(); const normalized = text || fallback; return normalized.length > 96 ? normalized.slice(0, 96).trim() : normalized; } function buildTimestampToken(date = new Date()) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const hour = String(date.getHours()).padStart(2, '0'); const minute = String(date.getMinutes()).padStart(2, '0'); const second = String(date.getSeconds()).padStart(2, '0'); return `${year}${month}${day}-${hour}${minute}${second}`; } function stripGenericExtension(name) { return String(name || '').replace(/\.[^.]+$/, '') || String(name || ''); } function chunkArray(items, chunkSize) { const size = Math.max(1, Number(chunkSize || 1)); const source = Array.isArray(items) ? items : []; const chunks = []; for (let index = 0; index < source.length; index += size) { chunks.push(source.slice(index, index + size)); } return chunks; } function extractCreatedDirId(payload) { const value = findFirstValueByKeys(payload, ['dirId', 'dir_id', 'fileId', 'folderId', 'folder_id', 'id']); return value == null ? '' : String(value); } function collectObjectArrays(node, out = [], seen = new WeakSet()) { if (!node || typeof node !== 'object') { return out; } if (seen.has(node)) { return out; } seen.add(node); if (Array.isArray(node)) { if (node.length && node.every((item) => item && typeof item === 'object' && !Array.isArray(item))) { out.push(node); } for (const item of node) { collectObjectArrays(item, out, seen); } return out; } for (const value of Object.values(node)) { collectObjectArrays(value, out, seen); } return out; } function normalizeResolvedFileEntry(obj) { if (!obj || typeof obj !== 'object' || Array.isArray(obj)) { return null; } const strongIndex = toFiniteNumberOrNull( obj.fileIndex ?? obj.file_index ?? obj.fileNo ?? obj.file_no ); const weakIndex = toFiniteNumberOrNull( obj.index ?? obj.idx ?? obj.seq ); const index = strongIndex ?? weakIndex; if (index == null || index < 0) { return null; } const name = chooseBestNameCandidate([ obj.name, obj.fileName, obj.file_name, obj.filename, obj.path, obj.filePath, obj.file_path, obj.fullPath, obj.full_path, obj.resName, obj.resourceName, obj.title, ]); if ( strongIndex == null && !( name && !Object.values(obj).some((value) => Array.isArray(value) && value.some((item) => item && typeof item === 'object')) ) ) { return null; } return { index, name, raw: obj, fromExplicitIndex: true, }; } function scanResolvedFileEntries(node, out = [], seen = new WeakSet()) { if (!node || typeof node !== 'object') { return out; } if (seen.has(node)) { return out; } seen.add(node); if (Array.isArray(node)) { for (const item of node) { scanResolvedFileEntries(item, out, seen); } return out; } const normalized = normalizeResolvedFileEntry(node); if (normalized) { out.push(normalized); } for (const value of Object.values(node)) { scanResolvedFileEntries(value, out, seen); } return out; } function extractResolvedFileEntries(payload) { const explicitEntries = scanResolvedFileEntries(payload); if (explicitEntries.length) { const byIndex = new Map(); for (const entry of explicitEntries.sort((a, b) => a.index - b.index)) { if (!byIndex.has(entry.index)) { byIndex.set(entry.index, entry); } } return Array.from(byIndex.values()).sort((a, b) => a.index - b.index); } const explicitIndexes = findFirstValueByKeys(payload, ['fileIndexes', 'file_indexes', 'indexes']); if (Array.isArray(explicitIndexes) && explicitIndexes.length) { return explicitIndexes .map((value) => toFiniteNumberOrNull(value)) .filter((value) => value != null) .filter((value, index, list) => list.indexOf(value) === index) .sort((a, b) => a - b) .map((index) => ({ index, name: '', raw: null })); } const total = extractResolvedFileCount(payload, 0); if (total > 0) { return Array.from({ length: total }, (_, index) => ({ index, name: '', raw: null, })); } const arrays = collectObjectArrays(payload); const positionalCandidates = []; for (const arr of arrays) { const positionalEntries = arr .map((item, index) => { const name = chooseBestNameCandidate([ item?.name, item?.fileName, item?.file_name, item?.filename, item?.path, item?.filePath, item?.file_path, item?.fullPath, item?.full_path, item?.resName, item?.resourceName, item?.title, ]); if (!name) { return null; } return { index, name, raw: item, fromExplicitIndex: false, }; }) .filter(Boolean); if (positionalEntries.length) { positionalCandidates.push(positionalEntries); } } let best = []; let bestScore = -1; for (const positionalEntries of positionalCandidates) { const entries = positionalEntries; if (!entries.length) { continue; } const nameCount = entries.filter((item) => item.name).length; const score = entries.length * 10 + nameCount * 3 + (entries.length > 1 ? 5 : 0); if (score > bestScore) { best = entries; bestScore = score; } } if (best.length) { return best; } return []; } function extractResolvedFileCount(payload, fallback = 0) { const explicit = toFiniteNumberOrNull( findFirstValueByKeys(payload, ['fileCount', 'file_count', 'totalCount', 'total_count', 'count', 'total']) ); if (explicit != null && explicit > 0) { return explicit; } return Math.max(0, Number(fallback || 0)); } function getResolvedEntryExt(entry) { return String(getExt(entry?.name || '') || '').replace(/^\./, '').toLowerCase(); } function isLikelyJunkResolvedEntry(entry) { const ext = getResolvedEntryExt(entry); const name = String(entry?.name || ''); if (ext && CLOUD_JUNK_EXTENSIONS.has(ext)) { return true; } return CLOUD_SKIP_NAME_PATTERNS.some((pattern) => pattern.test(name)); } function isLikelyVideoResolvedEntry(entry) { const ext = getResolvedEntryExt(entry); return Boolean(ext && CLOUD_VIDEO_EXTENSIONS.has(ext)); } function selectResolvedEntriesForImport(entries) { const source = Array.isArray(entries) ? entries.filter(Boolean) : []; if (!source.length) { return []; } const videoEntries = source.filter((entry) => isLikelyVideoResolvedEntry(entry)); if (videoEntries.length) { const nonSampleVideoEntries = videoEntries.filter((entry) => !isLikelyJunkResolvedEntry(entry)); return nonSampleVideoEntries.length ? nonSampleVideoEntries : videoEntries; } const nonJunkEntries = source.filter((entry) => !isLikelyJunkResolvedEntry(entry)); if (nonJunkEntries.length) { return nonJunkEntries; } return source; } function extractResolvedResourceName(payload, magnetUrl, fallback = '') { return sanitizeCloudDirName( chooseBestNameCandidate([ getMagnetDisplayName(magnetUrl), findFirstValueByKeys(payload, ['resourceName', 'resName', 'taskName', 'title', 'name', 'displayName']), fallback, ]) || '磁力资源', '磁力资源' ); } function looksLikeNameExistError(detail) { const text = getErrorText(detail).toLowerCase(); return ['exist', 'exists', 'already', '重复', '已存在', '同名'].some((keyword) => text.includes(keyword)); } async function createDirectory(dirName, parentId = '', options = {}) { const response = await postJson( getCreateDirUrl(), { dirName, parentId: String(parentId || ''), failIfNameExist: options.failIfNameExist !== false, }, getRequestHeaders() ); if (!response.ok || !isProbablySuccess(response.payload, response)) { throw new Error(getErrorText(response.payload || response.text || `HTTP ${response.status}`)); } const dirId = extractCreatedDirId(response.payload); if (!dirId) { throw new Error(`创建目录成功但未返回目录 ID:${dirName}`); } return { dirId, dirName, response, }; } async function createDirectoryWithFallback(baseName, parentId = '', options = {}) { const normalized = sanitizeCloudDirName(baseName, options.fallbackName || '磁力资源'); const token = buildTimestampToken(); const candidates = [ normalized, `${normalized}-${token}`, `${normalized}-${token}-${Math.random().toString(36).slice(2, 6)}`, ].filter((value, index, list) => list.indexOf(value) === index); let lastError = null; for (const name of candidates) { try { return await createDirectory(name, parentId, { failIfNameExist: true, }); } catch (err) { lastError = err; if (!looksLikeNameExistError(err) && name === normalized) { throw err; } } } throw lastError || new Error(`创建目录失败:${normalized}`); } async function resolveCloudResource(url) { return postJson(getResolveResUrl(), { url: String(url || '').trim() }, getRequestHeaders()); } async function createCloudTask(fileIndexes, url, parentId) { return postJson( getCloudCreateTaskUrl(), { fileIndexes, url: String(url || '').trim(), parentId: String(parentId || ''), }, getRequestHeaders() ); } function normalizeCloudTaskRow(obj) { if (!obj || typeof obj !== 'object' || Array.isArray(obj)) { return null; } const taskId = findFirstValueByKeys(obj, ['taskId', 'task_id', 'id']); const status = findFirstValueByKeys(obj, ['taskStatus', 'task_status', 'status', 'state']); const url = findFirstValueByKeys(obj, ['url', 'sourceUrl', 'source_url']); const name = chooseBestNameCandidate([ obj.name, obj.taskName, obj.resourceName, obj.resName, obj.displayName, getMagnetDisplayName(url || ''), ]); if (taskId == null || (!status && !url && !name)) { return null; } return { taskId: String(taskId), status: status == null ? '' : String(status), url: url == null ? '' : String(url), name, raw: obj, }; } function scanCloudTaskRows(node, out = []) { if (!node || typeof node !== 'object') { return out; } if (Array.isArray(node)) { for (const item of node) { scanCloudTaskRows(item, out); } return out; } const normalized = normalizeCloudTaskRow(node); if (normalized) { out.push(normalized); } for (const value of Object.values(node)) { scanCloudTaskRows(value, out); } return out; } function extractCloudTaskRows(payload) { const rows = scanCloudTaskRows(payload); const seen = new Set(); return rows.filter((row) => { if (!row.taskId || seen.has(row.taskId)) { return false; } seen.add(row.taskId); return true; }); } async function listCloudTasks(options = {}) { const statuses = Array.isArray(options.statuses) && options.statuses.length ? options.statuses : [0, 1, 2, 3, 4]; const pageSize = Math.max(1, Number(options.pageSize || CONFIG.cloud.listTaskPageSize || 50)); const response = await postJson( getCloudListTaskUrl(), { pageSize, status: statuses, }, getRequestHeaders() ); STATE.lastCloudTaskList = response.payload || response.text || null; return response; } function extractMagnetLinks(text) { const matches = String(text || '').match(/magnet:\?[^\s"'<>]+/ig) || []; const seen = new Set(); const out = []; for (const match of matches) { const magnet = String(match || '').trim().replace(/[),.;]+$/g, ''); if (!magnet || seen.has(magnet)) { continue; } seen.add(magnet); out.push(magnet); } return out; } function extractMagnetLinksFromJsonNode(node, out = [], seen = new Set()) { if (node == null) { return out; } if (typeof node === 'string') { for (const magnet of extractMagnetLinks(node)) { if (!seen.has(magnet)) { seen.add(magnet); out.push(magnet); } } return out; } if (Array.isArray(node)) { for (const item of node) { extractMagnetLinksFromJsonNode(item, out, seen); } return out; } if (typeof node === 'object') { for (const value of Object.values(node)) { extractMagnetLinksFromJsonNode(value, out, seen); } } return out; } function extractMagnetLinksFromAnyContent(text, fileName = '') { const raw = String(text || ''); const ext = String(fileName || '').toLowerCase(); const magnetsFromText = extractMagnetLinks(raw); if (!/\.json$/i.test(ext)) { return magnetsFromText; } const json = safeJsonParse(raw); if (json == null) { return magnetsFromText; } const magnetsFromJson = extractMagnetLinksFromJsonNode(json); return magnetsFromJson.length ? magnetsFromJson : magnetsFromText; } async function readMagnetImportFiles(fileList, options = {}) { const files = Array.from(fileList || []).filter(Boolean); const onProgress = typeof options.onProgress === 'function' ? options.onProgress : null; const parsed = []; for (let index = 0; index < files.length; index += 1) { const file = files[index]; const text = await file.text(); const magnets = extractMagnetLinksFromAnyContent(text, file.name); parsed.push({ key: [file.name, file.size, file.lastModified].join(':'), name: file.name, size: file.size, lastModified: file.lastModified, magnets, magnetCount: magnets.length, sampleMagnet: magnets[0] || '', }); if (onProgress) { onProgress({ visible: true, percent: Math.round(((index + 1) / Math.max(1, files.length)) * 100), indeterminate: false, text: `正在识别本地磁力文件 ${index + 1}/${files.length}:${file.name} | 磁力 ${magnets.length} 条`, }); } } return parsed; } function setMagnetImportFiles(entries, options = {}) { const append = Boolean(options.append); const next = append ? [...(STATE.magnetImportFiles || [])] : []; const indexByKey = new Map(next.map((item, index) => [item.key, index])); for (const entry of entries || []) { if (!entry || !entry.key) { continue; } if (indexByKey.has(entry.key)) { next[indexByKey.get(entry.key)] = entry; } else { indexByKey.set(entry.key, next.length); next.push(entry); } } STATE.magnetImportFiles = next; renderMagnetImportList(); } function getSelectedMagnetImportStats() { const files = Array.isArray(STATE.magnetImportFiles) ? STATE.magnetImportFiles : []; const magnets = files.reduce((sum, item) => sum + Number(item.magnetCount || item.magnets?.length || 0), 0); return { fileCount: files.length, magnetCount: magnets, }; } function renderMagnetImportList() { if (!UI.magnetFileList || !UI.magnetFileCount) { return; } const files = Array.isArray(STATE.magnetImportFiles) ? STATE.magnetImportFiles : []; const stats = getSelectedMagnetImportStats(); UI.magnetFileCount.textContent = `磁力文本 ${stats.fileCount} 个 / 磁力 ${stats.magnetCount} 条`; if (!files.length) { UI.magnetFileList.innerHTML = '
选择包含 magnet 链接的 txt 或 json 文件后,脚本会自动识别并按每批 500 文件拆分云添加。
'; return; } UI.magnetFileList.innerHTML = files.map((item) => `
${escapeHtml(item.name)}
磁力 ${Number(item.magnetCount || 0)} 条${item.sampleMagnet ? ` | 示例:${escapeHtml(shortDisplayName(getMagnetDisplayName(item.sampleMagnet), 32))}` : ''}
`).join(''); } function renderEmptyDirScanList() { if (!UI.emptyDirList || !UI.emptyDirCount) { return; } const scan = STATE.lastEmptyDirScan || null; const items = Array.isArray(scan?.emptyDirs) ? scan.emptyDirs : []; const selected = items.filter((item) => STATE.emptyDirSelection[String(item.fileId || '')] !== false); UI.emptyDirCount.textContent = scan ? `删除勾选 ${selected.length}/${items.length} | 已扫目录 ${Number(scan.scannedDirs || 0)} 个${scan.truncated ? ' / 可能未扫全' : ''}` : '空目录 0 个'; if (!scan) { UI.emptyDirList.innerHTML = '
点“扫描空目录”后,这里会列出当前目录树里最里层且完全空的目录。
'; return; } if (!items.length) { UI.emptyDirList.innerHTML = `
${scan.truncated ? `本次已扫描 ${Number(scan.scannedDirs || 0)} 个目录,暂未发现空目录;因为分页或目录数量较多,结果可能还不完整。` : `本次已扫描 ${Number(scan.scannedDirs || 0)} 个目录,当前目录树下没有发现最里层空目录。`}
`; return; } UI.emptyDirList.innerHTML = items.map((item) => `
${item.depth > 0 ? `层级 ${Number(item.depth)} | ` : ''}${item.confidence === 'likely' ? '低置信度(默认不勾选) | ' : ''}目录ID: ${escapeHtml(String(item.fileId || ''))}
`).join(''); } function setEmptyDirScanResult(summary, options = {}) { if (!summary || !Array.isArray(summary.emptyDirs)) { STATE.lastEmptyDirScan = summary || null; STATE.emptyDirSelection = {}; renderEmptyDirScanList(); return; } const preserveSelection = Boolean(options.preserveSelection); const nextSelection = {}; const emptyDirs = summary.emptyDirs .filter((item) => item && item.fileId) .map((item) => ({ ...item, fileId: String(item.fileId), dirId: String(item.dirId || item.fileId), dirIdCandidates: normalizeIdCandidates(item.dirIdCandidates || [item.dirId, item.fileId]), confidence: item.confidence === 'likely' ? 'likely' : 'confirmed', })); for (const item of emptyDirs) { if (preserveSelection && Object.prototype.hasOwnProperty.call(STATE.emptyDirSelection, item.fileId)) { nextSelection[item.fileId] = STATE.emptyDirSelection[item.fileId] !== false; } else { nextSelection[item.fileId] = item.confidence !== 'likely'; } } STATE.lastEmptyDirScan = { ...summary, emptyDirs, }; STATE.emptyDirSelection = nextSelection; renderEmptyDirScanList(); } function getSelectedEmptyDirItems() { const items = Array.isArray(STATE.lastEmptyDirScan?.emptyDirs) ? STATE.lastEmptyDirScan.emptyDirs : []; return items.filter((item) => STATE.emptyDirSelection[String(item.fileId || '')] !== false); } function removeEmptyDirScanItemsByIds(fileIds) { const deletedIds = new Set((fileIds || []).map((id) => String(id)).filter(Boolean)); if (!deletedIds.size || !STATE.lastEmptyDirScan) { return; } removeCapturedItemsByIds(Array.from(deletedIds)); setEmptyDirScanResult({ ...STATE.lastEmptyDirScan, emptyDirs: (STATE.lastEmptyDirScan.emptyDirs || []).filter((item) => !deletedIds.has(String(item.fileId || ''))), }, { preserveSelection: true, }); } async function fetchDirectoryItemsByParentId(parentId, options = {}) { const normalizedParentId = String(parentId || '').trim(); if (!normalizedParentId) { return { items: [], pageCount: 0, truncated: false, }; } const pageSize = Math.max(1, Number(options.pageSize || UI.fields.pageSize?.value || CONFIG.request.manualListBody.pageSize || 100)); const maxPages = Math.max(1, Number(options.maxPages || EMPTY_DIR_SCAN_MAX_PAGES_PER_DIR)); const delayMs = Math.max(0, Number(options.delayMs != null ? options.delayMs : CONFIG.batch.delayMs || 0)); const taskControl = options.taskControl || null; const seenIds = new Set(); const allItems = []; let truncated = false; let pageCount = 0; let hitPageLimit = true; for (let pageIndex = 0; pageIndex < maxPages; pageIndex += 1) { await waitForTaskControl(taskControl); const requestBody = { parentId: normalizedParentId, pageSize, }; if (pageIndex > 0) { requestBody.page = pageIndex; } const { items } = await requestListBatch(requestBody); pageCount = pageIndex + 1; const batchItems = dedupeItems(items); if (!batchItems.length) { hitPageLimit = false; break; } let newCount = 0; for (const item of batchItems) { const key = String(item?.fileId || ''); if (!key || seenIds.has(key)) { continue; } seenIds.add(key); allItems.push(item); newCount += 1; } if (batchItems.length < pageSize) { hitPageLimit = false; break; } if (!newCount) { truncated = true; break; } if (delayMs > 0) { await controlledDelay(delayMs, taskControl); } } if (hitPageLimit && pageCount >= maxPages) { truncated = true; } return { items: allItems, pageCount, truncated, }; } async function fetchDirectoryItems(parentId, options = {}) { const candidates = normalizeIdCandidates([ ...(Array.isArray(options.idCandidates) ? options.idCandidates : []), parentId, ]); const forwardedOptions = { ...options }; delete forwardedOptions.idCandidates; let lastError = null; let hadError = false; let bestResult = { items: [], pageCount: 0, truncated: false, usedParentId: '', uncertain: false, }; for (const candidate of candidates) { try { const result = await fetchDirectoryItemsByParentId(candidate, forwardedOptions); if (result.items.length) { return { ...result, usedParentId: candidate, }; } if (!bestResult.items.length) { bestResult = { ...result, usedParentId: candidate, uncertain: false, }; } } catch (err) { hadError = true; lastError = err; } } if (lastError && !bestResult.usedParentId) { throw lastError; } return { ...bestResult, uncertain: hadError && !bestResult.items.length, }; } function getEmptyScanItemTypeHints(item) { const raw = item && item.raw && typeof item.raw === 'object' ? item.raw : {}; const values = [ raw.itemType, raw.item_type, raw.nodeType, raw.node_type, raw.resourceType, raw.resource_type, raw.resType, raw.res_type, raw.fileType, raw.file_type, raw.type, raw.kind, raw.bizType, raw.biz_type, ]; return values .map((value) => String(value == null ? '' : value).trim().toLowerCase()) .filter((value) => value !== ''); } function getEmptyScanNameExtension(name = '') { const normalized = String(getExt(name) || '').replace(/^\./, '').toLowerCase(); if (normalized) { return normalized; } const fallback = String(name || '').trim().match(/\.([a-z0-9]{1,8})$/i); return fallback ? String(fallback[1] || '').toLowerCase() : ''; } function hasPositiveSizeLikeField(raw) { const source = raw && typeof raw === 'object' ? raw : {}; const keys = [ 'size', 'fileSize', 'file_size', 'contentLength', 'content_length', 'byteSize', 'byte_size', 'bytes', ]; return keys.some((key) => { const value = source[key]; if (typeof value === 'number') { return Number.isFinite(value) && value > 0; } if (typeof value === 'string') { const text = value.trim(); if (!text) { return false; } if (/^\d+$/u.test(text)) { return Number(text) > 0; } return /^\d+(?:\.\d+)?\s*(?:b|kb|mb|gb|tb)$/iu.test(text); } return false; }); } function hasMeaningfulDirectoryValue(value) { if (value == null) { return false; } if (typeof value === 'string') { return value.trim() !== ''; } return true; } function hasDirectoryCountHint(value) { const numeric = toFiniteNumberOrNull(value); if (numeric != null) { return numeric >= 0; } return hasMeaningfulDirectoryValue(value); } function isStrongFileLikeItem(item) { if (!item) { return false; } const raw = item && item.raw && typeof item.raw === 'object' ? item.raw : {}; const explicitFlags = [ raw.isDir, raw.is_dir, raw.isFolder, raw.is_folder, raw.folder, raw.directory, raw.dir, ].map((value) => normalizeBooleanish(value)).find((value) => value != null); if (explicitFlags === true) { return false; } if (explicitFlags === false) { return true; } const ext = getEmptyScanNameExtension(item.name || ''); if (ext && (KNOWN_FILE_EXTENSIONS.has(ext) || EMPTY_SCAN_EXTRA_FILE_EXTENSIONS.has(ext))) { return true; } const typeHints = getEmptyScanItemTypeHints(item); if (typeHints.some((value) => /(dir|folder|directory|catalog)/i.test(value))) { return false; } if (typeHints.some((value) => /(file|video|image|audio|doc|text|subtitle|torrent)/i.test(value))) { return true; } // 光鸭返回的 type / fileType 有时目录和文件都会是数字,不能只凭数字就判死为文件。 if (hasPositiveSizeLikeField(raw)) { return true; } return false; } function getEmptyScanVisibleDirNameSet(rows = []) { return new Set( (rows || []) .filter((item) => item && item.isDir) .map((item) => normalizeDomName(item.name)) .filter(Boolean) ); } function getEmptyScanDirectoryHintLevel(item, visibleDirNameSet = null) { if (!item) { return 0; } const id = String(item.fileId || item.dirId || '').trim(); if (!id || isSyntheticDomId(id)) { return 0; } const raw = item && item.raw && typeof item.raw === 'object' ? item.raw : {}; const explicitFlags = [ raw.isDir, raw.is_dir, raw.isFolder, raw.is_folder, raw.folder, raw.directory, raw.dir, ].map((value) => normalizeBooleanish(value)).find((value) => value != null); if (explicitFlags === true) { return 2; } if (explicitFlags === false) { return -1; } if (item.isDir === true || shouldTreatItemAsDirectory(item) || raw.domIsDir) { return 2; } const nameKey = normalizeDomName(item.name || ''); if (visibleDirNameSet instanceof Set && nameKey && visibleDirNameSet.has(nameKey)) { return 2; } if (isStrongFileLikeItem(item)) { return -1; } const hasDirStructure = Boolean( hasMeaningfulDirectoryValue(raw.dirName) || hasMeaningfulDirectoryValue(raw.dir_name) || hasMeaningfulDirectoryValue(raw.folderName) || hasMeaningfulDirectoryValue(raw.folder_name) || hasMeaningfulDirectoryValue(raw.folderId) || hasMeaningfulDirectoryValue(raw.folder_id) || hasDirectoryCountHint(raw.childCount) || hasDirectoryCountHint(raw.childrenCount) || hasDirectoryCountHint(raw.children_count) || hasDirectoryCountHint(raw.dirCount) || hasDirectoryCountHint(raw.dir_count) || hasDirectoryCountHint(raw.folderCount) || hasDirectoryCountHint(raw.folder_count) || hasDirectoryCountHint(raw.subCount) || hasDirectoryCountHint(raw.sub_count) || hasMeaningfulDirectoryValue(raw.dirId) || hasMeaningfulDirectoryValue(raw.dir_id) ); if (hasDirStructure) { return 2; } const hasTypeHints = getEmptyScanItemTypeHints(item).length > 0; const hasFileExtension = Boolean(getEmptyScanNameExtension(item.name || '')); if (!hasTypeHints && !hasFileExtension && !hasPositiveSizeLikeField(raw)) { return 1; } return 0; } async function fetchDirectoryListingForEmptyScan(parentId, options = {}) { const result = await fetchDirectoryItems(parentId, options); let items = Array.isArray(result.items) ? result.items : []; if (options.includeCurrentSnapshot) { const snapshotItems = buildCurrentDirectoryItemsSnapshot(parentId); items = dedupeItems([...(Array.isArray(items) ? items : []), ...snapshotItems]); } return { ...result, items, }; } async function scanEmptyLeafDirectories(options = {}) { const rootSnapshot = getDirectoryContextSnapshot(); const rootParentId = String(options.parentId || rootSnapshot.parentId || CONFIG.request.manualListBody.parentId || '').trim(); if (!rootParentId) { throw new Error('没有拿到 parentId。请先打开要扫描的目录,或在高级兜底里手填 parentId。'); } const onProgress = typeof options.onProgress === 'function' ? options.onProgress : null; const taskControl = options.taskControl || null; const maxDirs = Math.max(1, Number(options.maxDirs || EMPTY_DIR_SCAN_MAX_DIRS)); const rootName = String(options.rootName || rootSnapshot.name || '(当前目录)').trim() || '(当前目录)'; const rootNode = { fileId: rootParentId, dirId: rootParentId, dirIdCandidates: [rootParentId], name: rootName, path: rootName, depth: 0, isRoot: true, confidence: 'confirmed', }; const visited = new Set([rootParentId]); const emptyDirs = []; const rootVisibleDirNameSet = getEmptyScanVisibleDirNameSet(collectVisibleDirectoryRows()); let scannedDirs = 0; let scannedItems = 0; let truncated = false; let aborted = false; async function walkDirectory(current, prefetchedListing = null) { if (!current || aborted) { return; } await waitForTaskControl(taskControl); if (visited.size > maxDirs) { truncated = true; aborted = true; return; } if (onProgress) { onProgress({ visible: true, percent: Math.min(95, Math.max(1, scannedDirs)), indeterminate: true, text: `正在扫描空目录:${shortDisplayName(current.path, 42)} | 已扫 ${scannedDirs} 个目录`, }); } if (CONFIG.debug) { log(`空目录扫描检查目录:${current.path} | dirId=${current.dirId || ''} | fileId=${current.fileId || ''}`); } let inspection = prefetchedListing; try { if (!inspection) { inspection = await fetchDirectoryListingForEmptyScan(current.dirId || current.fileId, { pageSize: options.pageSize, maxPages: options.maxPages, delayMs: options.delayMs, idCandidates: current.dirIdCandidates, includeCurrentSnapshot: Boolean(current.isRoot), taskControl, }); } } catch (err) { truncated = true; warn('空目录扫描拉取目录失败,已跳过:', { path: current.path, dirId: current.dirId, error: getErrorText(err), }); return; } scannedDirs += 1; scannedItems += inspection.items.length; if (inspection.truncated || inspection.uncertain) { truncated = true; } if (!inspection.items.length) { emptyDirs.push({ fileId: String(current.fileId || current.dirId || '').trim(), dirId: String(current.dirId || current.fileId || '').trim(), dirIdCandidates: normalizeIdCandidates([ current.dirId, current.fileId, ...(current.dirIdCandidates || []), ]), name: current.name || '(当前目录)', path: current.path || '(当前目录)', depth: current.depth || 0, confidence: current.confidence === 'likely' ? 'likely' : 'confirmed', }); return; } const visibleDirNameSet = current.isRoot ? rootVisibleDirNameSet : null; const childItems = Array.isArray(inspection.items) ? inspection.items.filter(Boolean) : []; for (const child of childItems) { await waitForTaskControl(taskControl); if (aborted) { break; } const childFileId = String(child.fileId || '').trim(); if (!childFileId || isSyntheticDomId(childFileId) || !String(child.name || '').trim()) { continue; } const hintLevel = getEmptyScanDirectoryHintLevel(child, visibleDirNameSet); if (hintLevel < 0) { continue; } if (visited.size >= maxDirs) { truncated = true; aborted = true; break; } let childInspection = null; try { childInspection = await fetchDirectoryListingForEmptyScan(childFileId, { pageSize: options.pageSize, maxPages: options.maxPages, delayMs: options.delayMs, idCandidates: normalizeIdCandidates([ childFileId, child.dirId, ...(child.dirIdCandidates || []), ]), taskControl, }); } catch (err) { truncated = true; warn('空目录扫描探测子项失败,已跳过:', { path: current.path, childName: child.name, childFileId, error: getErrorText(err), }); continue; } if (childInspection.truncated || childInspection.uncertain) { truncated = true; } const actualChildParentId = String(childInspection.usedParentId || child.dirId || childFileId).trim(); if (childInspection.items.length) { if (!actualChildParentId || visited.has(actualChildParentId)) { continue; } visited.add(actualChildParentId); await walkDirectory({ fileId: childFileId, dirId: actualChildParentId, dirIdCandidates: normalizeIdCandidates([ actualChildParentId, child.dirId, childFileId, ...(child.dirIdCandidates || []), ]), name: String(child.name || actualChildParentId), path: current.isRoot ? String(child.name || actualChildParentId) : `${current.path}/${String(child.name || actualChildParentId)}`, depth: Number(current.depth || 0) + 1, isRoot: false, confidence: hintLevel >= 2 ? 'confirmed' : 'likely', }, childInspection); continue; } if (hintLevel <= 0) { continue; } emptyDirs.push({ fileId: childFileId, dirId: actualChildParentId || childFileId, dirIdCandidates: normalizeIdCandidates([ actualChildParentId, child.dirId, childFileId, ...(child.dirIdCandidates || []), ]), name: String(child.name || childFileId), path: current.isRoot ? String(child.name || childFileId) : `${current.path}/${String(child.name || childFileId)}`, depth: Number(current.depth || 0) + 1, confidence: hintLevel >= 2 ? 'confirmed' : 'likely', }); } } await waitForTaskControl(taskControl); const rootInspection = await fetchDirectoryListingForEmptyScan(rootParentId, { pageSize: options.pageSize, maxPages: options.maxPages, delayMs: options.delayMs, idCandidates: [rootParentId], includeCurrentSnapshot: true, taskControl, }); await walkDirectory(rootNode, rootInspection); const summary = { rootParentId, scannedDirs, scannedItems, emptyDirs, truncated, scannedAt: new Date().toISOString(), }; setEmptyDirScanResult(summary); if (UI.emptyDirDetails) { UI.emptyDirDetails.open = true; } if (onProgress) { onProgress({ visible: true, percent: 100, indeterminate: false, text: truncated ? `空目录扫描完成:找到 ${emptyDirs.length} 个空目录,已扫 ${scannedDirs} 个目录,结果可能未扫全` : `空目录扫描完成:找到 ${emptyDirs.length} 个空目录,已扫 ${scannedDirs} 个目录`, }); } console.table(emptyDirs.map((item) => ({ path: item.path, dirId: item.fileId, depth: item.depth, confidence: item.confidence || 'confirmed', }))); return summary; } function getCloudImportParentId() { const context = getCurrentListContext(); return String(context.parentId || CONFIG.request.manualListBody.parentId || '').trim(); } function buildSourceImportDirName(fileName, runToken) { const prefix = sanitizeCloudDirName(CONFIG.cloud.sourceDirPrefix || '磁力导入', '磁力导入'); const base = sanitizeCloudDirName(stripGenericExtension(fileName), '磁力文本'); return `${prefix}-${base}-${runToken}`; } function buildMagnetImportDirName(magnetUrl, payload, magnetIndex) { return extractResolvedResourceName(payload, magnetUrl, `磁力_${magnetIndex}`); } async function importMagnetTextFiles(options = {}) { const onProgress = typeof options.onProgress === 'function' ? options.onProgress : null; const taskControl = options.taskControl || null; const sourceFiles = (STATE.magnetImportFiles || []).filter((item) => Array.isArray(item.magnets) && item.magnets.length); if (!sourceFiles.length) { throw new Error('还没有选择可导入的磁力 txt/json 文件。请先点“选择TXT/JSON”。'); } const batchLimit = Math.max(1, Number(options.batchLimit || CONFIG.cloud.maxFilesPerTask || 500)); const requestDelayMs = Math.max(0, Number(options.delayMs ?? CONFIG.batch.delayMs ?? 300)); const parentId = String(options.parentId != null ? options.parentId : getCloudImportParentId()); const runToken = buildTimestampToken(); const totalMagnets = sourceFiles.reduce((sum, item) => sum + item.magnets.length, 0); const summary = { parentId, batchLimit, sourceFileCount: sourceFiles.length, totalMagnets, submittedMagnets: 0, skippedMagnets: 0, skippedExistingMagnets: 0, skippedDuplicateMagnets: 0, failedMagnets: 0, totalResolvedFiles: 0, totalTaskBatches: 0, submittedTaskBatches: 0, failedTaskBatches: 0, taskIds: [], skipped: [], failures: [], sourceDirs: [], existingTaskCount: 0, startedAt: new Date().toISOString(), finishedAt: '', }; const existingTaskKeys = new Set(); const inputSeenMagnetKeys = new Set(); let processedMagnets = 0; try { await waitForTaskControl(taskControl); if (onProgress) { onProgress({ visible: true, percent: 1, indeterminate: true, text: '正在读取云任务历史,用于识别已添加磁力...', }); } const existingTasks = await listCloudTasks({ pageSize: CONFIG.cloud.listTaskPageSize || 50, }); if (existingTasks.ok && isProbablySuccess(existingTasks.payload, existingTasks)) { const rows = extractCloudTaskRows(existingTasks.payload); summary.existingTaskCount = rows.length; for (const row of rows) { const key = getMagnetIdentityKey(row.url); if (key) { existingTaskKeys.add(key); } } } } catch (err) { warn('读取云任务历史失败,将仅对本次导入内容做去重:', err); } for (const sourceFile of sourceFiles) { await waitForTaskControl(taskControl); const pendingMagnets = []; for (const magnetUrl of sourceFile.magnets) { await waitForTaskControl(taskControl); const magnetKey = getMagnetIdentityKey(magnetUrl); if (magnetKey && existingTaskKeys.has(magnetKey)) { summary.skippedMagnets += 1; summary.skippedExistingMagnets += 1; if (summary.skipped.length < 200) { summary.skipped.push({ sourceFile: sourceFile.name, magnet: magnetUrl, reason: '历史云任务中已存在', }); } processedMagnets += 1; continue; } if (magnetKey && inputSeenMagnetKeys.has(magnetKey)) { summary.skippedMagnets += 1; summary.skippedDuplicateMagnets += 1; if (summary.skipped.length < 200) { summary.skipped.push({ sourceFile: sourceFile.name, magnet: magnetUrl, reason: '本次导入文件中重复', }); } processedMagnets += 1; continue; } if (magnetKey) { inputSeenMagnetKeys.add(magnetKey); } pendingMagnets.push({ magnetUrl, magnetKey, }); } if (!pendingMagnets.length) { continue; } let sourceDir = null; try { await waitForTaskControl(taskControl); sourceDir = await createDirectoryWithFallback(buildSourceImportDirName(sourceFile.name, runToken), parentId, { fallbackName: '磁力导入', }); summary.sourceDirs.push({ name: sourceDir.dirName, dirId: sourceDir.dirId, sourceFile: sourceFile.name, }); } catch (err) { summary.failedMagnets += pendingMagnets.length; for (const { magnetUrl } of pendingMagnets) { summary.failures.push({ sourceFile: sourceFile.name, magnet: magnetUrl, message: `创建导入目录失败:${getErrorText(err)}`, submittedTaskBatches: 0, }); } processedMagnets += pendingMagnets.length; warn('为磁力文本创建父目录失败,已跳过该文件:', { sourceFile: sourceFile.name, error: err, }); continue; } for (let magnetIndex = 0; magnetIndex < pendingMagnets.length; magnetIndex += 1) { await waitForTaskControl(taskControl); const magnetUrl = pendingMagnets[magnetIndex].magnetUrl; const currentMagnetNo = processedMagnets + 1; let submittedForCurrentMagnet = 0; if (onProgress) { onProgress({ visible: true, percent: Math.round(((currentMagnetNo - 1) / Math.max(1, totalMagnets)) * 100), indeterminate: true, text: `正在解析磁力 ${currentMagnetNo}/${totalMagnets}:${shortDisplayName(getMagnetDisplayName(magnetUrl), 42)}`, }); } try { await waitForTaskControl(taskControl); const resolveRes = await resolveCloudResource(magnetUrl); if (!resolveRes.ok || !isProbablySuccess(resolveRes.payload, resolveRes)) { throw new Error(getErrorText(resolveRes.payload || resolveRes.text || `HTTP ${resolveRes.status}`)); } const resolvedFiles = extractResolvedFileEntries(resolveRes.payload); const fileIndexes = resolvedFiles.map((item) => item.index); if (!fileIndexes.length) { throw new Error('resolve_res 没有返回可识别的文件列表,暂时无法自动拆分 fileIndexes'); } summary.totalResolvedFiles += fileIndexes.length; const batches = chunkArray(fileIndexes, batchLimit); summary.totalTaskBatches += batches.length; let taskParentId = sourceDir.dirId; if (CONFIG.cloud.createMagnetSubdir !== false) { await waitForTaskControl(taskControl); const magnetDir = await createDirectoryWithFallback( buildMagnetImportDirName(magnetUrl, resolveRes.payload, currentMagnetNo), sourceDir.dirId, { fallbackName: `磁力_${currentMagnetNo}` } ); taskParentId = magnetDir.dirId; } for (let batchIndex = 0; batchIndex < batches.length; batchIndex += 1) { await waitForTaskControl(taskControl); const indexes = batches[batchIndex]; if (onProgress) { const basePercent = ((currentMagnetNo - 1) / Math.max(1, totalMagnets)) * 100; const innerPercent = ((batchIndex + 1) / Math.max(1, batches.length)) * (100 / Math.max(1, totalMagnets)); onProgress({ visible: true, percent: Math.min(99, Math.round(basePercent + innerPercent)), indeterminate: false, text: `正在提交云添加 ${currentMagnetNo}/${totalMagnets} | ${shortDisplayName(getMagnetDisplayName(magnetUrl), 36)} | 第 ${batchIndex + 1}/${batches.length} 批,文件 ${indexes.length} 个`, }); } const taskRes = await createCloudTask(indexes, magnetUrl, taskParentId); if (!taskRes.ok || !isProbablySuccess(taskRes.payload, taskRes)) { summary.failedTaskBatches += 1; throw new Error(getErrorText(taskRes.payload || taskRes.text || `HTTP ${taskRes.status}`)); } const taskId = extractTaskId(taskRes.payload); if (taskId) { summary.taskIds.push(taskId); } summary.submittedTaskBatches += 1; submittedForCurrentMagnet += 1; if (requestDelayMs > 0) { await controlledDelay(requestDelayMs, taskControl); } } summary.submittedMagnets += 1; } catch (err) { summary.failedMagnets += 1; summary.failures.push({ sourceFile: sourceFile.name, magnet: magnetUrl, message: getErrorText(err), submittedTaskBatches: submittedForCurrentMagnet, }); warn('磁力云添加失败:', { sourceFile: sourceFile.name, magnetUrl, error: err, }); } finally { processedMagnets += 1; STATE.lastCloudImportSummary = { ...summary }; } } } try { await waitForTaskControl(taskControl); const cloudTasks = await listCloudTasks({ pageSize: CONFIG.cloud.listTaskPageSize || 50, }); if (cloudTasks.ok) { summary.taskRows = extractCloudTaskRows(cloudTasks.payload); } } catch (err) { warn('读取云添加任务列表失败:', err); } summary.finishedAt = new Date().toISOString(); STATE.lastCloudImportSummary = { ...summary }; if (onProgress) { onProgress({ visible: true, percent: 100, indeterminate: false, text: `云添加提交完成:磁力成功 ${summary.submittedMagnets} 条,跳过 ${summary.skippedMagnets} 条,失败 ${summary.failedMagnets} 条;任务批次成功 ${summary.submittedTaskBatches} 个,失败 ${summary.failedTaskBatches} 个`, }); } return summary; } function extractTaskId(payload) { const taskId = findFirstValueByKeys(payload, ['taskId', 'task_id', 'id']); return taskId == null ? '' : String(taskId); } function extractTaskStatus(payload) { const raw = findFirstValueByKeys(payload, [ 'taskStatus', 'task_status', 'status', 'state', 'taskState', 'task_state', ]); return raw == null ? '' : String(raw).toUpperCase(); } function getNumericTaskStatus(status) { const value = Number(status); return Number.isFinite(value) ? value : null; } function toFiniteNumberOrNull(value) { if (value == null || value === '') { return null; } const numeric = Number(value); return Number.isFinite(numeric) ? numeric : null; } function getTaskStatusLabel(status) { const code = getNumericTaskStatus(status); if (code === null) { return status || 'UNKNOWN'; } if (code === 0) { return '0(等待中)'; } if (code === 1) { return '1(执行中)'; } if (code === 2) { return '2(已完成)'; } if (code === 3) { return '3(失败)'; } if (code === 4) { return '4(已取消)'; } return `${code}(未知状态码)`; } function normalizeBooleanish(value) { if (typeof value === 'boolean') { return value; } if (typeof value === 'number') { return value !== 0; } if (typeof value === 'string') { const text = value.trim().toLowerCase(); if (!text) { return null; } if (['1', 'true', 'yes', 'y', 'dir', 'folder', 'directory'].includes(text)) { return true; } if (['0', 'false', 'no', 'n', 'file'].includes(text)) { return false; } } return null; } function normalizeIdCandidates(values = []) { return Array.from(new Set( (values || []) .map((value) => String(value || '').trim()) .filter(Boolean) )); } function isLikelyDirectoryIdKey(key) { const text = String(key || '').trim(); if (!text) { return false; } if (/parentid/i.test(text)) { return false; } if (/(user|owner|creator|modifier|account|tenant|project|trace|task|category)id$/i.test(text)) { return false; } return /(dir|folder|file|resource|res|biz|obj|share).*id$/i.test(text) || /(^|_|\b)id$/i.test(text); } function collectIdLikeValues(node, out = [], seen = new WeakSet(), depth = 0) { if (!node || typeof node !== 'object' || depth > 3) { return out; } if (seen.has(node)) { return out; } seen.add(node); if (Array.isArray(node)) { for (const item of node) { collectIdLikeValues(item, out, seen, depth + 1); } return out; } for (const [key, value] of Object.entries(node)) { if (isLikelyDirectoryIdKey(key) && (typeof value === 'string' || typeof value === 'number')) { out.push(value); } if (value && typeof value === 'object') { collectIdLikeValues(value, out, seen, depth + 1); } } return out; } function extractTaskCounts(payload, expectedTotal = 0) { const successCount = toFiniteNumberOrNull(findFirstValueByKeys(payload, ['successCount', 'success_count', 'doneCount', 'done_count'])); const failedCount = toFiniteNumberOrNull(findFirstValueByKeys(payload, ['failedCount', 'failCount', 'failed_count', 'fail_count'])); const totalCount = toFiniteNumberOrNull(findFirstValueByKeys(payload, ['totalCount', 'total_count', 'count', 'total'])); const hasSuccessCount = successCount != null; const hasFailedCount = failedCount != null; const hasTotalCount = totalCount != null && totalCount > 0; const success = hasSuccessCount ? successCount : 0; const failed = hasFailedCount ? failedCount : 0; const total = hasTotalCount ? totalCount : (Number(expectedTotal) > 0 ? Number(expectedTotal) : 0); return { success, failed, total, processed: success + failed, hasSuccessCount, hasFailedCount, hasTotalCount, hasExplicitCounts: hasSuccessCount || hasFailedCount || hasTotalCount, }; } function isTaskFinished(payload, options = {}) { const status = extractTaskStatus(payload); if (status) { const numericStatus = getNumericTaskStatus(status); if (numericStatus != null) { if ([2, 3, 4].includes(numericStatus)) { return true; } if ([0, 1].includes(numericStatus)) { return false; } } const doneWords = ['SUCCESS', 'SUCCEEDED', 'DONE', 'FINISH', 'FINISHED', 'COMPLETED', 'FAILED', 'ERROR', 'CANCEL', '成功', '完成', '失败']; if (doneWords.some((word) => status.includes(word))) { return true; } const runningWords = ['RUN', 'PROCESS', 'PENDING', 'QUEUE', 'WAIT', '进行', '处理中', '等待']; if (runningWords.some((word) => status.includes(word))) { return false; } } const finished = findFirstValueByKeys(payload, ['finished', 'done', 'completed', 'isFinished']); if (typeof finished === 'boolean') { return finished; } const progress = Number(findFirstValueByKeys(payload, ['progress', 'percent', 'percentage'])); if (Number.isFinite(progress) && progress >= 100) { return true; } const counts = extractTaskCounts(payload, options.expectedTotal || 0); if (counts.total > 0 && counts.processed >= counts.total) { return true; } if (counts.failed === 0 && counts.success > 0 && Number(options.expectedTotal || 0) > 0 && counts.success >= Number(options.expectedTotal || 0)) { return true; } return false; } function isTaskSuccessful(payload, options = {}) { const status = extractTaskStatus(payload); if (status) { const numericStatus = getNumericTaskStatus(status); if (numericStatus != null) { if (numericStatus === 2) { return true; } if ([3, 4].includes(numericStatus)) { return false; } } } if (status && ['FAILED', 'ERROR', 'CANCEL', 'CANCELLED', '失败'].some((word) => status.includes(word))) { return false; } if (status && ['SUCCESS', 'SUCCEEDED', 'DONE', 'FINISH', 'FINISHED', 'COMPLETED', '成功', '完成'].some((word) => status.includes(word))) { return true; } const success = findFirstValueByKeys(payload, ['success', 'ok']); if (typeof success === 'boolean') { return success; } const counts = extractTaskCounts(payload, options.expectedTotal || 0); if (counts.total > 0 && counts.processed >= counts.total) { return counts.failed === 0; } if (counts.failed > 0) { return false; } return true; } async function deleteFiles(fileIds) { return postJson(getDeleteUrl(), { fileIds }, getRequestHeaders()); } async function getTaskStatus(taskId) { return postJson(getTaskStatusUrl(), { taskId }, getRequestHeaders()); } function buildRenamePayload(target) { const base = STATE.lastRenameRequest?.requestBody && typeof STATE.lastRenameRequest.requestBody === 'object' ? JSON.parse(JSON.stringify(STATE.lastRenameRequest.requestBody)) : {}; const idKey = pickExistingKey(base, ['fileId', 'id', 'resourceId', 'resId', 'bizId', 'objId'], 'fileId'); const nameKey = pickExistingKey(base, ['newName', 'name', 'fileName', 'file_name', 'filename', 'title'], 'newName'); const payload = {}; const stableExtraKeys = [ 'parentId', 'shareId', 'shareFileId', 'spaceId', 'driveId', 'folderId', 'resourceType', 'resType', 'fileType', 'bizType', ]; for (const key of stableExtraKeys) { if (base[key] != null && base[key] !== '') { payload[key] = base[key]; } } payload[idKey] = target.fileId; payload[nameKey] = target.newName; return payload; } function getErrorText(detail) { if (!detail) { return ''; } if (typeof detail === 'string') { return detail; } if (detail instanceof Error) { return detail.message || String(detail); } if (typeof detail === 'object') { return detail.message || detail.error || detail.msg || detail.code || JSON.stringify(detail); } return String(detail); } function escapeHtml(text) { return String(text || '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function shortDisplayName(name, max = 24) { const text = String(name || '').trim(); if (text.length <= max) { return text; } return `${text.slice(0, max)}...`; } function escapeRegExp(text) { return String(text || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function toHalfWidthDigits(text) { return String(text || '').replace(/[0-9]/g, (ch) => String(ch.charCodeAt(0) - 65248)); } function normalizeDuplicateName(name) { return toHalfWidthDigits(String(name || '')) .replace(/[(]/g, '(') .replace(/[)]/g, ')') .replace(/\u00a0/g, ' ') .replace(/[\u200b-\u200d\ufeff]/g, '') .replace(/\s+/g, ' ') .trim(); } function getConfiguredDuplicateNumbers() { const values = String(CONFIG.duplicate.numbers || DEFAULT_DUPLICATE_NUMBERS) .split(/[\s,,、]+/) .map((x) => toHalfWidthDigits(x).trim()) .filter(Boolean); return new Set(values.length ? values : DEFAULT_DUPLICATE_NUMBERS.split(',')); } function getDuplicateInfo(name) { const original = String(name || ''); const normalized = normalizeDuplicateName(original); if (!normalized) { return null; } const configuredNumbers = getConfiguredDuplicateNumbers(); const directMatch = normalized.match(/^(.*?)[(]([0-9]+)[)]\s*$/u); if (directMatch && configuredNumbers.has(directMatch[2])) { return { matched: true, number: directMatch[2], baseName: directMatch[1].trim(), normalized, mode: 'direct-tail', }; } const withExtMatch = normalized.match(/^(.*?)[(]([0-9]+)[)](\.[a-z0-9]{1,12})$/iu); if (withExtMatch && configuredNumbers.has(withExtMatch[2])) { return { matched: true, number: withExtMatch[2], baseName: withExtMatch[1].trim(), normalized, extension: withExtMatch[3], mode: 'before-extension', }; } return null; } function isDuplicateName(name) { return Boolean(getDuplicateInfo(name)); } function scoreNameCandidate(name) { const text = normalizeDuplicateName(name); if (!isProbablyUsefulName(text) || isProbablyMetadataText(text)) { return -1; } let score = text.length; if (isDuplicateName(text)) score += 120; if (/[((][0-90-9]+[))]/.test(text)) score += 40; if (/[.\u4e00-\u9fa5A-Za-z]/.test(text)) score += 20; if (/\.[a-z0-9]{1,12}$/i.test(text)) score += 12; return score; } function chooseBestNameCandidate(candidates) { const values = Array.from(new Set((candidates || []).map((x) => String(x || '').trim()).filter(Boolean))); if (!values.length) { return ''; } values.sort((a, b) => scoreNameCandidate(b) - scoreNameCandidate(a) || b.length - a.length); return values[0] || ''; } function buildDuplicatePatternFromNumbers(numbersText) { const values = String(numbersText || DEFAULT_DUPLICATE_NUMBERS) .split(/[\s,,、]+/) .map((x) => x.trim()) .filter(Boolean) .map((x) => escapeRegExp(toHalfWidthDigits(x))); const group = values.length ? values.join('|') : '1|2|3'; return `[((]\\s*(?:${group})\\s*[))]\\s*(?:\\.[a-zA-Z0-9]{1,12})?$`; } function getCurrentRuleMode(firstRule = CONFIG.rename.rules[0] || {}) { if (CONFIG.rename.ruleMode) { return CONFIG.rename.ruleMode; } if (firstRule.enabled === false) { return 'none'; } if ((firstRule.type || '') === 'text') { return 'replace-text'; } if ((firstRule.pattern || '') === DEFAULT_LEADING_BRACKET_PATTERN && (firstRule.flags || '') === 'u') { return 'remove-leading-bracket'; } return 'custom-regex'; } function getRuleModeLabel(mode = getCurrentRuleMode()) { if (mode === 'remove-leading-bracket') { return '删除开头第一个 [] / 【】 段'; } if (mode === 'replace-text') { return '按固定文字查找并替换'; } if (mode === 'none') { return '不处理前缀'; } if (mode === 'custom-regex') { return '自定义正则(高级)'; } return mode || '(未设置)'; } function getRenameOutputModeLabel(mode = (CONFIG.rename.output || {}).mode || 'keep-clean') { if (mode === 'keep-clean') { return '直接使用处理后的名字'; } if (mode === 'add-text') { return '增加文字'; } if (mode === 'replace-text') { return '替换文字'; } if (mode === 'format') { return '格式命名'; } if (mode === 'custom-template') { return '自定义模板(高级)'; } return mode || '(未设置)'; } function splitRecognizedExtension(name) { const str = String(name || ''); if (!str) { return { base: '', ext: '', }; } const lower = str.toLowerCase(); for (const ext of KNOWN_COMPOUND_FILE_EXTENSIONS) { if (lower.endsWith(ext) && str.length > ext.length) { return { base: str.slice(0, -ext.length), ext: str.slice(-ext.length), }; } } const idx = str.lastIndexOf('.'); if (idx <= 0) { return { base: str, ext: '', }; } const extBody = lower.slice(idx + 1); if (!KNOWN_FILE_EXTENSIONS.has(extBody)) { return { base: str, ext: '', }; } return { base: str.slice(0, idx), ext: str.slice(idx), }; } function getBaseName(name) { return splitRecognizedExtension(name).base; } function getExt(name) { return splitRecognizedExtension(name).ext; } function renderTemplate(template, values) { return String(template || '').replace(/\{([a-zA-Z0-9_]+)\}/g, (_, key) => { if (Object.prototype.hasOwnProperty.call(values, key)) { return values[key] == null ? '' : String(values[key]); } return ''; }); } function applyRulesWithRuleSet(name, rules, options = {}) { let result = String(name || ''); const list = Array.isArray(rules) ? rules : []; for (const rule of list) { if (!rule || rule.enabled === false) { continue; } if (rule.type === 'regex') { const pattern = String(rule.pattern || ''); if (!pattern) { continue; } const re = new RegExp(pattern, rule.flags || ''); result = result.replace(re, rule.replace ?? ''); continue; } if (rule.type === 'text') { const search = String(rule.search ?? ''); if (!search) { continue; } result = result.split(search).join(rule.replace ?? ''); } } if (options.trimResult !== false && CONFIG.rename.trimResult) { result = result.trim(); } return result; } function applyRules(name, item) { return applyRulesWithRuleSet(name, CONFIG.rename.rules, { trimResult: CONFIG.rename.trimResult, }); } function getDefaultExampleName() { const captured = getCapturedItems().find((item) => item && item.name); if (captured && captured.name) { return String(captured.name); } return '[高清剧集网]访达[全12集].2025.2160p.WEB-DL.H265.AAC-ColorTV'; } function getPanelFirstRuleDraft() { const ruleMode = UI.fields.ruleMode?.value || getCurrentRuleMode(); const rule = { enabled: ruleMode !== 'none', type: 'regex', pattern: '', flags: '', search: '', replace: '', }; if (ruleMode === 'remove-leading-bracket') { rule.type = 'regex'; rule.pattern = DEFAULT_LEADING_BRACKET_PATTERN; rule.flags = 'u'; rule.replace = ''; return rule; } if (ruleMode === 'replace-text') { rule.type = 'text'; rule.search = String(UI.fields.ruleSearchText?.value || ''); rule.replace = String(UI.fields.ruleReplaceText?.value || ''); return rule; } if (ruleMode === 'custom-regex') { rule.type = 'regex'; rule.pattern = String(UI.fields.rulePattern?.value || ''); rule.flags = String(UI.fields.ruleFlags?.value || ''); rule.replace = String(UI.fields.ruleReplace?.value || ''); return rule; } return rule; } function getPanelOutputDraft() { const mode = UI.fields.outputMode?.value || ((CONFIG.rename.output || {}).mode || 'keep-clean'); return { mode, addText: String(UI.fields.addText?.value || ''), addPosition: String(UI.fields.addPosition?.value || 'suffix'), findText: String(UI.fields.outputFindText?.value || ''), replaceText: String(UI.fields.outputReplaceText?.value || ''), formatStyle: String(UI.fields.formatStyle?.value || 'text-and-index'), formatText: String(UI.fields.formatText?.value || ''), formatPosition: String(UI.fields.formatPosition?.value || 'suffix'), startIndex: Number(UI.fields.startIndex?.value || 0), template: String(UI.fields.template?.value || '{clean}').trim() || '{clean}', }; } function buildFinalNameFromDraft(original, clean, fileId, output, renameIndex = 0) { const ext = getExt(clean); const base = getBaseName(clean); const serial = Number(output.startIndex || 0) + Number(renameIndex || 0); if (output.mode === 'add-text') { if (!output.addText) { return clean; } return output.addPosition === 'prefix' ? `${output.addText}${clean}` : `${clean}${output.addText}`; } if (output.mode === 'replace-text') { if (!output.findText) { return clean; } return clean.split(output.findText).join(output.replaceText || ''); } if (output.mode === 'format') { const formatText = String(output.formatText || '').trim() || '文件'; if (output.formatStyle === 'text-only') { return formatText; } return output.formatPosition === 'prefix' ? `${serial}${formatText}` : `${formatText}${serial}`; } if (output.mode === 'custom-template') { return renderTemplate(output.template || '{clean}', { original, clean, base, ext, fileId, index: serial, }); } return clean; } function getPanelPreviewDraft() { return { ruleMode: UI.fields.ruleMode?.value || getCurrentRuleMode(), outputMode: UI.fields.outputMode?.value || ((CONFIG.rename.output || {}).mode || 'keep-clean'), firstRule: getPanelFirstRuleDraft(), output: getPanelOutputDraft(), }; } function getRenameExampleDescription(draft) { if (draft.ruleMode === 'replace-text') { const search = String(draft.firstRule.search || ''); const replace = String(draft.firstRule.replace || ''); if (search) { return replace ? `预处理会把所有“${search}”替换成“${replace}”。` : `预处理会删除所有“${search}”。`; } } if (draft.output.mode === 'add-text') { return draft.output.addText ? `最终会在名字${draft.output.addPosition === 'prefix' ? '前面' : '后面'}增加“${draft.output.addText}”。` : '增加文字模式下,先填写要增加的内容。'; } if (draft.output.mode === 'replace-text') { return draft.output.findText ? `最终会把名字里的所有“${draft.output.findText}”替换成“${draft.output.replaceText || ''}”。` : '替换文字模式下,先填写“查找文本”。'; } if (draft.output.mode === 'format') { return draft.output.formatStyle === 'text-only' ? '格式命名会把名字统一改成你填写的“自定义格式”。' : '格式命名会按“自定义格式 + 序号”来生成新名字。'; } if (draft.output.mode === 'custom-template') { return '高级模板模式会按你填写的模板生成名字,比如 {clean}、{original}、{index}。'; } return '最终会直接使用“预处理后”的名字。'; } function updateRenameModePreview() { if (!UI.root) { return; } const ruleMode = UI.fields.ruleMode?.value || getCurrentRuleMode(); const outputMode = UI.fields.outputMode?.value || ((CONFIG.rename.output || {}).mode || 'keep-clean'); const advanced = UI.root.querySelector('[data-role="advanced-details"]'); if (advanced && (ruleMode === 'custom-regex' || outputMode === 'custom-template')) { advanced.open = true; } UI.root.querySelectorAll('[data-role="rule-text-group"]').forEach((node) => { node.style.display = ruleMode === 'replace-text' ? '' : 'none'; }); UI.root.querySelectorAll('[data-role="output-add-group"]').forEach((node) => { node.style.display = outputMode === 'add-text' ? '' : 'none'; }); UI.root.querySelectorAll('[data-role="output-replace-group"]').forEach((node) => { node.style.display = outputMode === 'replace-text' ? '' : 'none'; }); UI.root.querySelectorAll('[data-role="output-format-group"]').forEach((node) => { node.style.display = outputMode === 'format' ? '' : 'none'; }); UI.root.querySelectorAll('[data-role="output-template-group"]').forEach((node) => { node.style.display = outputMode === 'custom-template' ? '' : 'none'; }); const exampleField = UI.fields.exampleName; if (exampleField && !String(exampleField.value || '').trim()) { exampleField.value = getDefaultExampleName(); } const original = String(exampleField?.value || getDefaultExampleName()); const draft = getPanelPreviewDraft(); const clean = applyRulesWithRuleSet(original, [draft.firstRule], { trimResult: CONFIG.rename.trimResult, }); const finalName = buildFinalNameFromDraft(original, clean, 'demo', draft.output, 0); const desc = UI.root.querySelector('[data-role="rename-example-desc"]'); const cleanEl = UI.root.querySelector('[data-role="rename-example-clean"]'); const finalEl = UI.root.querySelector('[data-role="rename-example-final"]'); if (desc) { desc.textContent = getRenameExampleDescription(draft); } if (cleanEl) { cleanEl.textContent = clean || '(空)'; } if (finalEl) { finalEl.textContent = finalName || '(空)'; } } function guessItemIsDirectory(obj, name = '') { if (!obj || typeof obj !== 'object' || Array.isArray(obj)) { return false; } const explicit = [ obj.isDir, obj.is_dir, obj.isFolder, obj.is_folder, obj.folder, obj.directory, obj.dir, ].map((value) => normalizeBooleanish(value)).find((value) => value != null); if (explicit != null) { return explicit; } const dirType = toFiniteNumberOrNull(obj.dirType ?? obj.dir_type); if (dirType != null) { return dirType > 0; } if ( hasMeaningfulDirectoryValue(obj.dirName) || hasMeaningfulDirectoryValue(obj.dir_name) || hasMeaningfulDirectoryValue(obj.folderName) || hasMeaningfulDirectoryValue(obj.folder_name) || hasMeaningfulDirectoryValue(obj.folderId) || hasMeaningfulDirectoryValue(obj.folder_id) || hasDirectoryCountHint(obj.childCount) || hasDirectoryCountHint(obj.childrenCount) || hasDirectoryCountHint(obj.children_count) || hasDirectoryCountHint(obj.dirCount) || hasDirectoryCountHint(obj.dir_count) || hasDirectoryCountHint(obj.folderCount) || hasDirectoryCountHint(obj.folder_count) || hasDirectoryCountHint(obj.subCount) || hasDirectoryCountHint(obj.sub_count) || hasMeaningfulDirectoryValue(obj.dirId) || hasMeaningfulDirectoryValue(obj.dir_id) ) { return true; } const typeHints = [ obj.itemType, obj.item_type, obj.nodeType, obj.node_type, obj.resourceType, obj.resource_type, obj.resType, obj.res_type, obj.fileType, obj.file_type, obj.type, obj.kind, obj.bizType, obj.biz_type, ]; for (const hint of typeHints) { if (hint == null || hint === '') { continue; } const text = String(hint).trim().toLowerCase(); if (!text) { continue; } if (/(dir|folder|directory|catalog)/i.test(text)) { return true; } if (/(file|video|image|audio|doc|text|subtitle|torrent)/i.test(text)) { return false; } } if (obj.folderId != null || obj.folder_id != null) { return !getExt(name); } return false; } function extractDirectoryIdCandidates(obj, fallbackId = '') { if (!obj || typeof obj !== 'object' || Array.isArray(obj)) { return normalizeIdCandidates([fallbackId]); } return normalizeIdCandidates([ obj.dirId, obj.dir_id, obj.folderId, obj.folder_id, obj.fileId, obj.id, obj.resourceId, obj.resId, obj.bizId, obj.objId, obj.shareFileId, obj.share_file_id, ...collectIdLikeValues(obj), fallbackId, ]); } function normalizeItem(obj) { if (!obj || typeof obj !== 'object' || Array.isArray(obj)) { return null; } const fileId = obj.fileId ?? obj.id ?? obj.resourceId ?? obj.resId ?? obj.bizId ?? obj.objId ?? obj.shareFileId ?? obj.share_file_id ?? obj.dirId ?? obj.dir_id ?? obj.folderId ?? obj.folder_id; const dirIdCandidates = extractDirectoryIdCandidates(obj, fileId); const dirId = dirIdCandidates[0] || fileId; const name = chooseBestNameCandidate([ obj.name, obj.fileName, obj.file_name, obj.filename, obj.resName, obj.resourceName, obj.title, obj.displayName, obj.display_name, obj.originalName, obj.original_name, obj.fileFullName, obj.fullName, ]) || chooseBestNameCandidate([ obj.dirName, obj.dir_name, obj.folderName, obj.folder_name, ]); if ((typeof fileId === 'string' || typeof fileId === 'number') && typeof name === 'string') { return { fileId: String(fileId), dirId: dirId == null ? String(fileId) : String(dirId), dirIdCandidates, name, parentId: String(obj.parentId ?? obj.parent_id ?? obj.pid ?? obj.parentFileId ?? obj.parent_file_id ?? ''), isDir: guessItemIsDirectory(obj, name), raw: obj, }; } return null; } function scanItems(node, out = []) { if (!node || typeof node !== 'object') { return out; } if (Array.isArray(node)) { for (const item of node) { scanItems(item, out); } return out; } const normalized = normalizeItem(node); if (normalized) { out.push(normalized); } for (const value of Object.values(node)) { scanItems(value, out); } return out; } function dedupeItems(items) { const seen = new Set(); const out = []; for (const item of items) { if (!item || seen.has(item.fileId)) { continue; } seen.add(item.fileId); out.push(item); } return out; } function normalizeItemsFromArray(items = []) { return dedupeItems( (Array.isArray(items) ? items : []) .map((item) => normalizeItem(item)) .filter(Boolean) ); } function isLikelyListArrayKey(key = '') { const text = String(key || '').trim().toLowerCase(); if (!text) { return false; } return /^(data|list|items|rows|records|files|filelist|file_list|children|child_list|childlist|result)$/i.test(text) || /(list|item|row|record|file|child|result)s?$/i.test(text); } function collectItemArrayCandidates(node, out = [], options = {}) { const seen = options.seen || new WeakSet(); const pathKeys = Array.isArray(options.pathKeys) ? options.pathKeys : []; const depth = Number(options.depth || 0); if (!node || typeof node !== 'object' || depth > 5) { return out; } if (seen.has(node)) { return out; } seen.add(node); if (Array.isArray(node)) { const normalizedItems = normalizeItemsFromArray(node); const lastKey = String(pathKeys[pathKeys.length - 1] || '').trim(); const likelyKey = isLikelyListArrayKey(lastKey); const pathBonus = pathKeys.some((key) => isLikelyListArrayKey(key)) ? 40 : 0; const dataBonus = pathKeys[0] === 'data' ? 30 : 0; const sizeBonus = normalizedItems.length; if (normalizedItems.length || likelyKey || node.length === 0) { out.push({ items: normalizedItems, score: sizeBonus + (likelyKey ? 200 : 0) + pathBonus + dataBonus, isExplicitEmpty: node.length === 0 && likelyKey, path: pathKeys.join('.'), }); } for (const item of node) { if (item && typeof item === 'object') { collectItemArrayCandidates(item, out, { seen, pathKeys, depth: depth + 1, }); } } return out; } for (const [key, value] of Object.entries(node)) { if (!value || typeof value !== 'object') { continue; } collectItemArrayCandidates(value, out, { seen, pathKeys: [...pathKeys, key], depth: depth + 1, }); } return out; } function pickBestItemArrayCandidate(payload) { const candidates = collectItemArrayCandidates(payload); if (!candidates.length) { return null; } candidates.sort((left, right) => { if (right.score !== left.score) { return right.score - left.score; } if (left.isExplicitEmpty !== right.isExplicitEmpty) { return Number(right.isExplicitEmpty) - Number(left.isExplicitEmpty); } return String(left.path || '').length - String(right.path || '').length; }); return candidates[0] || null; } function extractItemsFromPayload(payload) { const explicitCandidate = pickBestItemArrayCandidate(payload); if (explicitCandidate) { return explicitCandidate.items; } return dedupeItems(scanItems(payload)); } function looksLikeListRequest(url, requestBody) { if (typeof url === 'string' && /get_file_list|list|share/i.test(url)) { return true; } if (!requestBody || typeof requestBody !== 'object') { return false; } return ['parentId', 'pageSize', 'pageNum', 'pageNo', 'sortType', 'orderBy'].some((key) => Object.prototype.hasOwnProperty.call(requestBody, key) ); } function looksLikeListResponse(payload) { if (!payload || typeof payload !== 'object') { return false; } return extractItemsFromPayload(payload).length > 0; } function isLikelyListCapture(url, requestBody, responseBody) { return looksLikeListRequest(url, requestBody) || looksLikeListResponse(responseBody); } function normalizeDomName(name) { return String(name || '').replace(/\s+/g, ' ').trim(); } function getVisibleNodeText(node) { if (!node) { return ''; } return normalizeDomName(node.textContent || node.innerText || ''); } function cleanDirectoryTitleCandidate(text) { const value = normalizeDomName(text) .replace(/\s*[-||丨]\s*光鸭云盘.*$/u, '') .replace(/\s*[-||丨]\s*www\.guangyapan\.com.*$/iu, '') .trim(); return value; } function getCurrentDirectoryDisplayName() { const selectors = [ '[aria-label*="breadcrumb" i] [aria-current="page"]', '[class*="breadcrumb"] [aria-current="page"]', '[class*="crumb"] [aria-current="page"]', '[class*="breadcrumb"] [class*="item"]:last-child', '[class*="crumb"] [class*="item"]:last-child', '[class*="path"] [class*="name"]:last-child', '[class*="path"] [class*="item"]:last-child', 'nav [aria-current="page"]', ]; for (const selector of selectors) { const nodes = Array.from(document.querySelectorAll(selector)); for (const node of nodes.reverse()) { const text = cleanDirectoryTitleCandidate(getVisibleNodeText(node)); if (isProbablyUsefulName(text) && !/^光鸭云盘工具$/u.test(text)) { return text; } } } const title = cleanDirectoryTitleCandidate(document.title || ''); if (isProbablyUsefulName(title) && !/^(光鸭云盘|首页|我的网盘)$/u.test(title)) { return title; } return '(当前目录)'; } function isHelperPanelNode(node) { return Boolean(node && typeof node.closest === 'function' && node.closest('#gyp-batch-rename-root')); } function isSyntheticDomId(value) { return /^dom(?:dir)?:/u.test(String(value || '').trim()); } function isBreadcrumbContainerNode(node) { return Boolean( node && typeof node.closest === 'function' && node.closest('[aria-label*="breadcrumb" i], [class*="breadcrumb"], [class*="crumb"], [class*="path"], nav') ); } function isLikelyListHeaderRow(row) { const text = normalizeDomName(row?.innerText || row?.textContent || ''); if (!text) { return false; } if (/^(文件名称|大小|类型|修改时间)(\s+(文件名称|大小|类型|修改时间))+$/u.test(text)) { return true; } return ['文件名称', '大小', '类型', '修改时间'].every((keyword) => text.includes(keyword)) && text.length <= 40; } function isUsableListRow(row) { if (!row || !isVisibleElement(row) || isHelperPanelNode(row) || isBreadcrumbContainerNode(row) || isLikelyListHeaderRow(row)) { return false; } const text = normalizeDomName(row.innerText || row.textContent || ''); if (!text) { return false; } return true; } async function waitForCondition(check, options = {}) { const timeoutMs = Math.max(200, Number(options.timeoutMs || 3000)); const intervalMs = Math.max(60, Number(options.intervalMs || 120)); const deadline = Date.now() + timeoutMs; while (Date.now() <= deadline) { try { const result = check(); if (result) { return result; } } catch {} await sleep(intervalMs); } return null; } function getDirectoryContextSnapshot() { const context = getCurrentListContext(); return { parentId: String(context.parentId || STATE.lastCapturedParentId || '').trim(), name: String(getCurrentDirectoryDisplayName() || '(当前目录)').trim() || '(当前目录)', url: String(location.href || ''), hash: String(location.hash || ''), capturedAt: Number(STATE.lastListCapturedAt || 0), }; } function isSameDirectorySnapshot(left, right) { if (!left || !right) { return false; } const leftParentId = String(left.parentId || '').trim(); const rightParentId = String(right.parentId || '').trim(); if (leftParentId && rightParentId) { return leftParentId === rightParentId; } const leftName = normalizeDomName(left.name); const rightName = normalizeDomName(right.name); if (leftName && rightName && leftName !== rightName) { return false; } const leftUrl = String(left.url || ''); const rightUrl = String(right.url || ''); if (leftUrl && rightUrl) { return leftUrl === rightUrl; } return Boolean(leftName && rightName && leftName === rightName); } function hasDirectoryContextChanged(previous, current) { if (!previous || !current) { return false; } const previousParentId = String(previous.parentId || '').trim(); const currentParentId = String(current.parentId || '').trim(); if (previousParentId && currentParentId) { return previousParentId !== currentParentId; } const previousUrl = String(previous.url || ''); const currentUrl = String(current.url || ''); const previousName = normalizeDomName(previous.name); const currentName = normalizeDomName(current.name); const urlChanged = Boolean(previousUrl && currentUrl && previousUrl !== currentUrl); const nameChanged = Boolean(previousName && currentName && previousName !== currentName); if (!urlChanged && !nameChanged) { return false; } return Number(current.capturedAt || 0) > Number(previous.capturedAt || 0); } function findVisibleEmptyStateInfo() { const selectors = [ '.ant-empty', '.arco-empty', '[class*="empty"]', '[class*="blank"]', '[class*="no-data"]', '[class*="nodata"]', '[class*="zero-state"]', '[data-empty]', '[data-status="empty"]', '[aria-label*="空"]', ]; const selectorNodes = dedupeElements(Array.from(document.querySelectorAll(selectors.join(', ')))) .filter((node) => isVisibleElement(node) && !isHelperPanelNode(node)); for (const node of selectorNodes) { const text = normalizeDomName(node.innerText || node.textContent || ''); const matched = EMPTY_STATE_TEXT_PATTERNS.find((pattern) => pattern.test(text)); if (matched || !text) { return { visible: true, text: text || '(空态组件)', via: 'selector', }; } } const roots = dedupeElements([ findScrollableListContainer(), findScrollableListContainer()?.parentElement, document.body, ]).filter((node) => node && !isHelperPanelNode(node)); for (const root of roots) { const text = normalizeDomName(root?.innerText || root?.textContent || ''); const matched = EMPTY_STATE_TEXT_PATTERNS.find((pattern) => pattern.test(text)); if (matched) { return { visible: true, text: matched.exec(text)?.[0] || matched.source, via: 'text', }; } } return { visible: false, text: '', via: '', }; } async function waitForDirectoryChange(previousSnapshot, options = {}) { return waitForCondition(() => { const currentSnapshot = getDirectoryContextSnapshot(); return hasDirectoryContextChanged(previousSnapshot, currentSnapshot) ? currentSnapshot : null; }, options); } async function waitForDirectoryMatch(targetSnapshot, options = {}) { return waitForCondition(() => { const currentSnapshot = getDirectoryContextSnapshot(); return isSameDirectorySnapshot(targetSnapshot, currentSnapshot) ? currentSnapshot : null; }, options); } function normalizeHashRoute(hash) { const text = String(hash || '').trim(); if (!text) { return ''; } return text.startsWith('#') ? text : `#${text.replace(/^#*/, '')}`; } function buildChildDirectoryHash(previousSnapshot, childItem) { const childId = String(childItem?.fileId || childItem?.dirId || '').trim(); const childName = String(childItem?.name || '').trim(); if (!childId || isSyntheticDomId(childId) || !childName) { return ''; } const baseHash = normalizeHashRoute(previousSnapshot?.hash || location.hash || ''); if (!baseHash) { return ''; } const cleanBase = baseHash.replace(/\/+$/u, ''); const encodedName = encodeURIComponent(childName); const segment = `${childId}-${encodedName}`; return `${cleanBase}/${segment}`; } async function navigateToDirectoryHash(targetHash, previousSnapshot, options = {}) { const normalizedHash = normalizeHashRoute(targetHash); if (!normalizedHash || normalizeHashRoute(previousSnapshot?.hash || '') === normalizedHash) { return null; } try { location.hash = normalizedHash; } catch { return null; } await waitForCondition(() => normalizeHashRoute(location.hash) === normalizedHash, { timeoutMs: 800, intervalMs: 80, }); return waitForFreshDirectoryContext(previousSnapshot, { expectedName: options.expectedName || '', timeoutMs: Number(options.timeoutMs || 4200), intervalMs: Number(options.intervalMs || 180), stableMs: Number(options.stableMs || 420), }); } async function waitForFreshDirectoryContext(previousSnapshot, options = {}) { const timeoutMs = Math.max(400, Number(options.timeoutMs || 2600)); const intervalMs = Math.max(80, Number(options.intervalMs || 180)); const stableMs = Math.max(intervalMs, Number(options.stableMs || 360)); const expectedName = normalizeDomName(options.expectedName || ''); const previousParentId = String(previousSnapshot?.parentId || '').trim(); const deadline = Date.now() + timeoutMs; let candidate = null; let candidateAt = 0; while (Date.now() <= deadline) { const snapshot = getDirectoryContextSnapshot(); const currentParentId = String(snapshot.parentId || '').trim(); const parentChanged = Boolean(currentParentId && currentParentId !== previousParentId && !isSyntheticDomId(currentParentId)); const nameMatches = !expectedName || !snapshot.name || textLooksLikeExpected(snapshot.name, expectedName) || textLooksLikeExpected(expectedName, snapshot.name); if (parentChanged && nameMatches) { if (!candidate || candidate.parentId !== currentParentId) { candidate = snapshot; candidateAt = Date.now(); } else if (Date.now() - candidateAt >= stableMs) { return snapshot; } } else { candidate = null; candidateAt = 0; } await sleep(intervalMs); } return candidate; } function isProbablyUsefulName(name) { const text = normalizeDomName(name); if (!text) { return false; } const compact = text.replace(/\s+/gu, ''); if (compact.length < 2 && !/^[A-Za-z0-9一-龥]$/u.test(compact)) { return false; } const blacklist = ['上传', '新建文件夹', '云添加', '文件', '文件名称', '大小', '类型', '文件夹', '-']; if (blacklist.includes(text)) { return false; } return true; } function isProbablyMetadataText(text) { const value = normalizeDomName(text); if (!value) { return true; } return ( /^\d+(\.\d+)?\s*(B|KB|MB|GB|TB|PB)$/i.test(value) || /^\d{4}[-/.年]\d{1,2}[-/.月]\d{1,2}/.test(value) || /^(今天|昨天|刚刚|\d{1,2}:\d{2})$/.test(value) || /^\d+$/.test(value) ); } function rowHasFileSizeHint(row) { const text = normalizeDomName(row?.innerText || row?.textContent || ''); if (!text) { return false; } return /\b\d+(?:\.\d+)?\s*(B|KB|MB|GB|TB|PB)\b/i.test(text); } function guessDomRowIsDirectory(row, name = '') { if (!row) { return false; } const folderSelectors = [ '[data-type*="folder" i]', '[data-kind*="folder" i]', '[data-icon*="folder" i]', '[class*="folder"]', '[class*="dir-icon"]', '[class*="folder-icon"]', '[aria-label*="文件夹"]', '[title*="文件夹"]', 'img[alt*="folder" i]', 'img[alt*="文件夹"]', 'img[src*="folder" i]', 'svg[aria-label*="folder" i]', ]; for (const selector of folderSelectors) { if (row.querySelector(selector)) { return true; } } const textCandidates = collectTextCandidates(row); if (textCandidates.some((text) => /^(文件夹|folder|directory)$/iu.test(text))) { return true; } const cleanName = normalizeDomName(name); if (cleanName && !getExt(cleanName) && !rowHasFileSizeHint(row)) { return true; } return false; } function collectTextCandidates(row) { const out = new Set(); const push = (value) => { const text = normalizeDomName(value); if (!isProbablyUsefulName(text) || isProbablyMetadataText(text)) { return; } out.add(text); }; if (!row) { return []; } push(row.getAttribute && row.getAttribute('title')); push(row.getAttribute && row.getAttribute('aria-label')); const attrNodes = Array.from(row.querySelectorAll('[title], [aria-label], [data-name], [data-filename]')); for (const node of attrNodes) { push(node.getAttribute && node.getAttribute('title')); push(node.getAttribute && node.getAttribute('aria-label')); push(node.getAttribute && node.getAttribute('data-name')); push(node.getAttribute && node.getAttribute('data-filename')); } const leafNodes = Array.from(row.querySelectorAll('span, div, p, a, strong, td')) .filter((el) => el && el.childElementCount === 0) .map((el) => el.textContent); for (const value of leafNodes) { push(value); } const rowText = String(row.innerText || row.textContent || ''); for (const line of rowText.split(/\n+/)) { push(line); } return Array.from(out); } function findExpectedNameInRow(row, expectedSet) { if (!row || !expectedSet || !expectedSet.size) return ''; const candidates = collectTextCandidates(row); for (const candidate of candidates) { const domName = normalizeDomName(candidate); for (const expected of expectedSet) { if (domName === expected) { return expected; } if (textLooksLikeExpected(domName, expected)) { return expected; } if ((domName.includes('...') || domName.length > 20) && expected.startsWith(domName.replace('...', ''))) { return expected; } if (expected.split('.')[0] === domName) { return expected; } } } return ''; } function extractNameFromRow(row) { if (!row) { return ''; } const candidates = collectTextCandidates(row) .sort((a, b) => b.length - a.length); return candidates[0] || ''; } function collectDomItems() { const rows = Array.from( document.querySelectorAll('[role="row"], li, tr, [class*="row"], [class*="item"], [class*="file"]') ); const items = []; const seen = new Set(); rows.forEach((row, index) => { if (isHelperPanelNode(row)) { return; } const checkbox = row.querySelector( 'input[type="checkbox"], [role="checkbox"], [class*="checkbox"], [class*="check"]' ); if (!checkbox) { return; } const name = extractNameFromRow(row); if (!isProbablyUsefulName(name) || seen.has(name)) { return; } seen.add(name); items.push({ fileId: `dom:${index}:${name}`, dirId: `dom:${index}:${name}`, dirIdCandidates: [`dom:${index}:${name}`], name, isDir: guessDomRowIsDirectory(row, name), raw: { fromDom: true, domIsDir: guessDomRowIsDirectory(row, name), }, }); }); return items; } function collectVisibleDirectoryHints(expectedNames = []) { const expectedSet = new Set((expectedNames || []).map((name) => normalizeDomName(name)).filter(Boolean)); if (!expectedSet.size) { return new Set(); } const checkboxNodes = Array.from(document.querySelectorAll( 'input[type="checkbox"], [role="checkbox"], [class*="checkbox"], [class*="check"]' )); const matched = new Set(); for (const checkbox of checkboxNodes) { if (isHelperPanelNode(checkbox)) { continue; } let node = checkbox; let depth = 0; while (node && depth < 8) { const candidates = collectTextCandidates(node); const matchedName = candidates.find((text) => expectedSet.has(normalizeDomName(text))); if (matchedName) { const normalizedName = normalizeDomName(matchedName); if (guessDomRowIsDirectory(node, normalizedName)) { matched.add(normalizedName); } break; } node = node.parentElement; depth += 1; } } return matched; } function getUtils() { return { applyRules, getBaseName, getExt, renderTemplate, }; } function buildNewName(item, context = {}) { const next = CONFIG.rename.buildName(item, getUtils(), context); return CONFIG.rename.trimResult ? String(next || '').trim() : String(next || ''); } function buildTargets(items) { const targets = []; const usedNames = new Set(); // 第一步:把所有“不改名”的文件名字存起来,作为不可占用的“坑位” items.forEach((item, index) => { const nextName = buildNewName(item, { renameIndex: index }); const isSkipped = CONFIG.filter.predicate(item) === false; // 如果被过滤了,或者改名后和原名一样,那它现在的名字就是被占用的 if (isSkipped || !nextName || nextName === item.name) { usedNames.add(item.name); } }); // 第二步:处理需要改名的文件 let renameIndex = 0; items.forEach((item) => { let finalName = buildNewName(item, { renameIndex }); const isSkipped = CONFIG.filter.predicate(item) === false; if (isSkipped || !finalName || finalName === item.name) return; // 如果新名字冲突了,就循环加 (1), (2)... if (usedNames.has(finalName)) { let counter = 1; const base = getBaseName(finalName); const ext = getExt(finalName); let candidate = `${base}(${counter})${ext}`; while (usedNames.has(candidate)) { counter++; candidate = `${base}(${counter})${ext}`; } finalName = candidate; } // 把确定要用的新名字也存入占用列表,防止后续文件撞车 usedNames.add(finalName); targets.push({ fileId: item.fileId, oldName: item.name, newName: finalName, raw: item.raw, }); renameIndex += 1; }); return targets; } function getDuplicateRegex() { if (CONFIG.duplicate.mode === 'numbers') { return new RegExp(buildDuplicatePatternFromNumbers(CONFIG.duplicate.numbers), 'u'); } return new RegExp(CONFIG.duplicate.pattern, CONFIG.duplicate.flags || ''); } function buildDuplicateTargets(items) { const re = getDuplicateRegex(); return items.filter((item) => { const isMatch = re.test(String(item.name || '')); if (isMatch && CONFIG.debug) { console.log(LOG_PREFIX, `[重复项匹配成功]: ${item.name}`); } return isMatch; }); } function resolveListBody(overrideBody = {}) { // 基础数据优先从上次捕获中拿,保证 parentId 等参数正确 let body = sanitizeListBody( (STATE.lastListBody && Object.keys(STATE.lastListBody).length > 0) ? STATE.lastListBody : CONFIG.request.manualListBody ); for (const [key, value] of Object.entries(overrideBody || {})) { if (value !== '' && value != null) { body[key] = value; } } body = sanitizeListBody(body); // 刷新预览时要回到当前目录第一页,不能沿用滚动加载时的分页游标。 const manualSize = Number(UI.fields.pageSize?.value || body.pageSize || CONFIG.request.manualListBody.pageSize || 100); if (manualSize > 0) { body.pageSize = manualSize; } if (!body.parentId) { body.parentId = normalizeParentId(CONFIG.request.manualListBody.parentId); } return body; } function normalizePageRequestOptions(options = {}) { const source = options && typeof options === 'object' ? options : {}; const normalized = {}; const stringFields = ['method', 'mode', 'credentials', 'cache', 'redirect', 'referrer', 'referrerPolicy']; for (const key of stringFields) { if (typeof source[key] === 'string' && source[key]) { normalized[key] = source[key]; } } if (typeof source.keepalive === 'boolean') { normalized.keepalive = source.keepalive; } const headers = sanitizeHeaders(source.headers); if (Object.keys(headers).length) { normalized.headers = headers; } if (typeof source.body === 'string') { normalized.body = source.body; } return normalized; } function pageRequest(url, options = {}) { const normalizedOptions = normalizePageRequestOptions(options); return new Promise((resolve, reject) => { if (typeof GM_xmlhttpRequest !== 'function') { reject(new Error(`未检测到 GM_xmlhttpRequest 权限 | ${normalizedOptions.method || 'GET'} ${url}`)); return; } try { GM_xmlhttpRequest({ method: normalizedOptions.method || 'GET', url: String(url), headers: normalizedOptions.headers || {}, data: typeof normalizedOptions.body === 'string' ? normalizedOptions.body : undefined, timeout: 30000, anonymous: false, onload: (res) => { resolve({ ok: res.status >= 200 && res.status < 300, status: res.status, text: typeof res.responseText === 'string' ? res.responseText : '', via: 'GM_xmlhttpRequest', }); }, onerror: (err) => { reject( new Error( `GM_xmlhttpRequest 网络请求异常 (${getErrorText(err) || '未知错误'}) | ${normalizedOptions.method || 'GET'} ${url}` ) ); }, ontimeout: () => { reject(new Error(`GM_xmlhttpRequest 请求超时 | ${normalizedOptions.method || 'GET'} ${url}`)); }, onabort: () => { reject(new Error(`GM_xmlhttpRequest 请求被中止 | ${normalizedOptions.method || 'GET'} ${url}`)); }, }); } catch (err) { reject(new Error(`${getErrorText(err) || 'GM_xmlhttpRequest 调用失败'} | ${normalizedOptions.method || 'GET'} ${url}`)); } }); } async function requestListBatch(overrideBody = {}) { const headers = getRequestHeaders(); const body = resolveListBody(overrideBody); if (Object.prototype.hasOwnProperty.call(overrideBody || {}, 'page')) { body.page = overrideBody.page; } const listUrl = STATE.lastListUrl || `${CONFIG.request.apiHost}${CONFIG.request.listPath}`; if (!body.parentId) { throw new Error('没有拿到 parentId。请先打开目标目录等待列表加载,或在 CONFIG.request.manualListBody.parentId 里手填。'); } const response = await pageRequest(listUrl, { method: 'POST', headers, mode: 'cors', credentials: 'include', body: JSON.stringify(body), }); if (!response.ok) { throw new Error(`获取列表失败:HTTP ${response.status}`); } const payload = safeJsonParse(response.text); if (!payload) { throw new Error('获取列表失败:响应不是有效 JSON'); } return { headers, body, listUrl, response, payload, items: extractItemsFromPayload(payload), }; } async function fetchCurrentList(overrideBody = {}) { const override = overrideBody && typeof overrideBody === 'object' ? { ...overrideBody } : {}; const returnBatchOnly = Boolean(override.__returnBatchOnly); delete override.__returnBatchOnly; const { body, listUrl, payload, items } = await requestListBatch(override); const merged = mergeCapturedItems(body.parentId, items, { listUrl, requestBody: body, }); STATE.lastListUrl = listUrl; STATE.lastListBody = body; STATE.lastListResponse = payload; STATE.lastListItems = merged.items; STATE.lastCapturedParentId = normalizeParentId(body.parentId); STATE.lastItemsSource = merged.batchCount > 1 ? 'api-merged' : 'api'; log(`重新拉取列表完成:本批 ${items.length} 项,当前目录累计 ${merged.total} 项(共 ${merged.batchCount} 批)。`); return returnBatchOnly ? items : merged.items; } function getCapturedItems() { const stats = getCapturedListStats(); const bucket = getCapturedListBucket(stats.parentId, { create: false }); if (bucket && Array.isArray(bucket.items) && bucket.items.length) { return bucket.items; } return Array.isArray(STATE.lastListItems) ? STATE.lastListItems : []; } function buildCurrentDirectoryItemsSnapshot(parentId = '') { const currentParentId = String(getCurrentListContext().parentId || '').trim(); const targetParentId = String(parentId || '').trim(); if (!currentParentId || !targetParentId || currentParentId !== targetParentId) { return []; } const captured = Array.isArray(getCapturedItems()) ? getCapturedItems() : []; if (!captured.length) { return []; } const visibleDirNames = collectVisibleDirectoryHints(captured.map((item) => item?.name || '')); const domItems = collectDomItems(); const domByName = new Map( (domItems || []) .filter((item) => item && item.name) .map((item) => [normalizeDomName(item.name), item]) ); return dedupeItems(captured.map((item) => { const key = normalizeDomName(item?.name || ''); const domItem = key ? domByName.get(key) : null; if (!visibleDirNames.has(key) && !domItem?.isDir) { return item; } return { ...item, isDir: true, dirIdCandidates: normalizeIdCandidates(item?.dirIdCandidates || [item?.dirId, item?.fileId]), raw: { ...(item.raw || {}), domIsDir: true, }, }; })); } function shouldTreatItemAsDirectory(item) { if (!item) { return false; } if (item.isDir === true) { return true; } return guessItemIsDirectory(item.raw || {}, item.name || ''); } function getDomItems() { const items = collectDomItems(); if (items.length) { STATE.lastItemsSource = 'dom'; } return items; } async function getPreviewItems(options = {}) { const refresh = Boolean(options.refresh); const overrideBody = options.listBody || {}; if (refresh) { try { const apiItems = await fetchCurrentList(overrideBody); if (apiItems.length) { return apiItems; } } catch (err) { warn('接口刷新列表失败,改用页面可见项目兜底:', err); } } const captured = getCapturedItems(); if (captured.length) { return captured; } const domItems = getDomItems(); if (domItems.length) { warn('当前未捕获到接口列表,已退回到页面可见项目模式。此模式可做预览和勾选重复项,但不能直接执行改名。'); return domItems; } return []; } function summarizeTargets(targets) { const rows = targets.map((item) => ({ fileId: item.fileId, oldName: item.oldName, newName: item.newName, })); console.table(rows); const duplicateNames = {}; for (const item of targets) { duplicateNames[item.newName] = (duplicateNames[item.newName] || 0) + 1; } const collisions = Object.entries(duplicateNames) .filter(([, count]) => count > 1) .map(([name, count]) => ({ newName: name, count })); if (collisions.length) { warn('检测到潜在重名,相关项目可能改名失败:'); console.table(collisions); } } function logZeroPreviewDiagnostics(items) { if (!items.length) { return; } const samples = items.slice(0, 8).map((item, index) => { const original = String(item.name || ''); const clean = applyRules(original, item); const finalName = buildNewName(item, { renameIndex: index }); return { fileId: item.fileId, original, clean, finalName, startsWithBracket: /^\s*[\[【]/.test(original), }; }); const leadingBracketCount = items.filter((item) => /^\s*[\[【]/.test(String(item?.name || ''))).length; console.table(samples); warn(`预览结果为 0。当前目录累计 ${items.length} 项,其中 ${leadingBracketCount} 项以 [] / 【】 开头;已在控制台输出诊断样本。`); } function isProbablySuccess(payload, response) { if (!response.ok) { return false; } if (!payload || typeof payload !== 'object') { return true; } if (payload.success === false) { return false; } if (payload.status === 'error') { return false; } if ('code' in payload) { const code = String(payload.code); if (!['0', '200', '2000'].includes(code) && !code.startsWith('2')) { return false; } } return true; } async function renameOne(target) { const response = await pageRequest(getRenameUrl(), { method: 'POST', headers: getRenameHeaders(), mode: 'cors', credentials: 'include', body: JSON.stringify(buildRenamePayload(target)), }); const text = response.text || ''; const payload = safeJsonParse(text); return { ok: isProbablySuccess(payload, response), status: response.status, text, payload, }; } async function preview(options = {}) { const items = await getPreviewItems(options); if (!items.length) { warn('当前没有拿到可用项目。先刷新当前分享目录,再试一次。'); return []; } const targets = buildTargets(items); if (!targets.length) { logZeroPreviewDiagnostics(items); } summarizeTargets(targets); log(`预览完成:当前共 ${targets.length} 个项目将被改名。`); return targets; } async function previewDuplicates(options = {}) { const items = await getPreviewItems(options); if (!items.length) { warn('当前没有拿到可用项目。先刷新当前分享目录,再试一次。'); return []; } const duplicates = buildDuplicateTargets(items).map((item) => ({ fileId: item.fileId, name: item.name, })); console.table(duplicates); log(`重复项预览完成:共 ${duplicates.length} 个项目匹配尾部 (1)/(2)/(3) 规则。`); return duplicates; } function resolveItemsByName(previewItems, sourceItems) { const exactMap = new Map(); for (const item of sourceItems || []) { if (!item || !item.fileId || String(item.fileId).startsWith('dom:')) { continue; } const key = normalizeDomName(item.name); if (!key) { continue; } if (!exactMap.has(key)) { exactMap.set(key, []); } exactMap.get(key).push(item); } const merged = []; const resolved = []; const unresolved = []; for (const item of previewItems || []) { if (!item) { continue; } if (item.fileId && !String(item.fileId).startsWith('dom:')) { const normalized = { fileId: String(item.fileId), name: String(item.name || ''), }; merged.push(normalized); resolved.push(normalized); continue; } const key = normalizeDomName(item.name); const matches = key ? (exactMap.get(key) || []) : []; if (matches.length === 1) { const normalized = { fileId: String(matches[0].fileId), name: String(matches[0].name || item.name || ''), }; merged.push(normalized); resolved.push(normalized); } else { const fallback = { fileId: String(item.fileId || ''), name: String(item.name || ''), }; merged.push(fallback); unresolved.push(fallback); } } return { merged, resolved, unresolved, }; } function updateDuplicatePreviewResolvedItems(items) { const selectionByName = new Map( (STATE.duplicatePreviewItems || []).map((item) => [ normalizeDomName(item.name), STATE.duplicateSelection[item.fileId] !== false, ]) ); setDuplicatePreview( (items || []).map((item) => ({ fileId: String(item.fileId), name: String(item.name || ''), })) ); for (const item of STATE.duplicatePreviewItems || []) { const saved = selectionByName.get(normalizeDomName(item.name)); if (typeof saved === 'boolean') { STATE.duplicateSelection[item.fileId] = saved; } } renderDuplicatePreviewList(); } async function ensureDuplicateItemsHaveRealIds(previewItems, options = {}) { const domItems = (previewItems || []).filter((item) => String(item?.fileId || '').startsWith('dom:')); if (!domItems.length) { return { mergedItems: (previewItems || []).map((item) => ({ fileId: String(item.fileId), name: String(item.name || ''), })), resolved: (previewItems || []).map((item) => ({ fileId: String(item.fileId), name: String(item.name || ''), })), unresolved: [], source: 'existing', }; } const onProgress = typeof options.onProgress === 'function' ? options.onProgress : null; const candidateSources = []; const verifiedPageSize = Math.max( Number(UI.fields.pageSize?.value || 0), Number(CONFIG.request.manualListBody.pageSize || 0), Number(getCapturedItems().length || 0), Number((previewItems || []).length || 0) + 50, 200 ); if (onProgress) { onProgress({ visible: true, percent: 8, indeterminate: true, text: '正在补齐真实 fileId...', }); } try { const fetched = await fetchCurrentList({ pageSize: verifiedPageSize }); if (fetched.length) { candidateSources.push({ source: 'api', items: fetched, }); } } catch (err) { warn('删除前补齐真实 fileId 时,接口拉取列表失败:', err); } const captured = getCapturedItems(); if (captured.length) { candidateSources.push({ source: 'captured', items: captured, }); } for (const entry of candidateSources) { const mapping = resolveItemsByName(previewItems, entry.items); if (!mapping.unresolved.length) { return { mergedItems: mapping.merged, resolved: mapping.resolved, unresolved: [], source: entry.source, }; } } const best = candidateSources.length ? resolveItemsByName(previewItems, candidateSources[0].items) : { merged: (previewItems || []).map((item) => ({ fileId: String(item.fileId || ''), name: String(item.name || ''), })), resolved: (previewItems || []).filter((item) => !String(item?.fileId || '').startsWith('dom:')).map((item) => ({ fileId: String(item.fileId || ''), name: String(item.name || ''), })), unresolved: domItems.map((item) => ({ fileId: String(item.fileId || ''), name: String(item.name || ''), })), }; return { mergedItems: best.merged, resolved: best.resolved, unresolved: best.unresolved, source: candidateSources[0]?.source || 'none', }; } function getSelectedDuplicatePreviewItems() { return (STATE.duplicatePreviewItems || []).filter((item) => STATE.duplicateSelection[item.fileId] !== false); } function renderDuplicatePreviewList() { if (!UI.duplicateList || !UI.duplicateCount) { return; } const items = STATE.duplicatePreviewItems || []; const selected = getSelectedDuplicatePreviewItems(); UI.duplicateCount.textContent = `删除勾选 ${selected.length}/${items.length}`; if (!items.length) { UI.duplicateList.innerHTML = '
先点“重复项预览”,再在这里取消不想删的项目。
'; return; } UI.duplicateList.innerHTML = items.map((item) => ` `).join(''); } function setDuplicatePreview(items, options = {}) { const preserveSelection = Boolean(options.preserveSelection); const deduped = []; const seen = new Set(); for (const item of items || []) { if (!item || !item.fileId || seen.has(item.fileId)) { continue; } seen.add(item.fileId); deduped.push({ fileId: String(item.fileId), name: String(item.name || ''), }); } const nextSelection = {}; for (const item of deduped) { if (preserveSelection && Object.prototype.hasOwnProperty.call(STATE.duplicateSelection, item.fileId)) { nextSelection[item.fileId] = STATE.duplicateSelection[item.fileId] !== false; } else { nextSelection[item.fileId] = true; } } STATE.duplicatePreviewItems = deduped; STATE.duplicateSelection = nextSelection; renderDuplicatePreviewList(); } async function run(options = {}) { const targets = await preview(options); const onProgress = typeof options.onProgress === 'function' ? options.onProgress : null; const taskControl = options.taskControl || null; if (!targets.length) { warn('没有需要改名的项目。'); return { ok: 0, fail: 0, targets }; } if (targets.some((item) => String(item.fileId || '').startsWith('dom:'))) { throw new Error('当前只拿到了页面名称,请刷新页面等待脚本捕获真实列表后再试。'); } if (CONFIG.batch.confirmBeforeRun && !window.confirm(`准备重命名 ${targets.length} 个项目,是否继续?`)) { return { ok: 0, fail: 0, targets }; } let ok = 0; let failed = 0; let index = 0; let firstError = ''; for (const target of targets) { await waitForTaskControl(taskControl); index += 1; let currentTarget = { ...target }; let success = false; let attempt = 0; let lastRes = null; // 自动重试逻辑:如果重名,尝试 (1), (2), (3) while (attempt <= 3 && !success) { await waitForTaskControl(taskControl); if (onProgress) { onProgress({ visible: true, percent: ((index - 1) / targets.length) * 100, text: `进度 ${index}/${targets.length} | 成功 ${ok} | 失败 ${failed}\n当前:${shortDisplayName(currentTarget.oldName)} -> ${shortDisplayName(currentTarget.newName)}`, }); } try { lastRes = await renameOne(currentTarget); if (lastRes.ok) { success = true; ok += 1; renameCapturedItem(target.fileId, currentTarget.newName); console.log(LOG_PREFIX, `成功:${currentTarget.oldName} -> ${currentTarget.newName}`); } else if (lastRes.payload && lastRes.payload.code === 160) { // 【核心逻辑】如果服务器报 160 (已存在),自动加后缀重试 attempt++; const base = getBaseName(target.newName); const ext = getExt(target.newName); currentTarget.newName = `${base}(${attempt})${ext}`; console.warn(LOG_PREFIX, `重名冲突,自动尝试第 ${attempt} 次重试: ${currentTarget.newName}`); } else { break; // 其他错误(如 token 失效),跳出重试 } } catch (err) { break; } } if (!success) { failed += 1; const errMsg = lastRes?.payload?.msg || lastRes?.text || '未知错误'; firstError = firstError || errMsg; fail(`最终失败 [${target.oldName}] -> 尝试改名为 [${currentTarget.newName}]`, errMsg); if (CONFIG.batch.stopOnError) break; } if (CONFIG.batch.delayMs > 0) await controlledDelay(CONFIG.batch.delayMs, taskControl); } if (onProgress) { onProgress({ visible: true, percent: 100, text: `执行完成!成功 ${ok},失败 ${failed}` }); } return { ok, fail: failed, firstError }; } function exportState() { const visibleHeaders = { ...getMergedHeaders() }; if (visibleHeaders.authorization) { visibleHeaders.authorization = 'Bearer ***'; } return { installedAt: STATE.installedAt, headers: visibleHeaders, lastListUrl: STATE.lastListUrl, lastListBody: STATE.lastListBody, lastItemsSource: STATE.lastItemsSource, lastRenameUrl: STATE.lastRenameRequest?.url || '', lastRenameBody: STATE.lastRenameRequest?.requestBody || null, capturedItemCount: getCapturedItems().length, magnetImportFiles: (STATE.magnetImportFiles || []).map((item) => ({ name: item.name, magnetCount: item.magnetCount || item.magnets?.length || 0, })), lastCloudImportSummary: STATE.lastCloudImportSummary, lastCloudTaskCount: extractCloudTaskRows(STATE.lastCloudTaskList).length, lastEmptyDirScan: STATE.lastEmptyDirScan ? { rootParentId: STATE.lastEmptyDirScan.rootParentId, scannedDirs: STATE.lastEmptyDirScan.scannedDirs, scannedItems: STATE.lastEmptyDirScan.scannedItems, emptyDirCount: STATE.lastEmptyDirScan.emptyDirs?.length || 0, truncated: Boolean(STATE.lastEmptyDirScan.truncated), } : null, sampleItems: getCapturedItems().slice(0, 5), }; } function isElementChecked(node) { if (!node) { return false; } if (node instanceof HTMLInputElement && node.type === 'checkbox') { return Boolean(node.checked); } const ariaChecked = node.getAttribute && node.getAttribute('aria-checked'); if (ariaChecked === 'true') { return true; } const className = String(node.className || '').toLowerCase(); return className.includes('checked') || className.includes('selected') || className.includes('is-checked'); } function isVisibleElement(node) { return Boolean(node && typeof node.getClientRects === 'function' && node.getClientRects().length > 0); } function dedupeElements(nodes) { const out = []; const seen = new Set(); for (const node of nodes || []) { if (!node || seen.has(node)) { continue; } seen.add(node); out.push(node); } return out; } function getRowSelector() { return [ '[role="row"]', 'tr', 'li', '[class*="row"]', '[class*="item"]', '[class*="file"]', '[class*="entry"]', '[class*="list-item"]', '[data-row-key]', '[data-id]', ].join(', '); } function getClosestRow(node) { if (!node || typeof node.closest !== 'function') { return null; } return node.closest(getRowSelector()); } function buildExpectedNameTokens(name) { const normalized = normalizeDomName(name); if (!normalized) { return []; } const stem = normalized.replace(/\.[a-z0-9]{1,12}$/i, ''); const tokens = [ normalized, stem, stem.slice(0, 18), stem.slice(0, 28), ].map((x) => normalizeDomName(x)).filter((x) => x && x.length >= 8); return Array.from(new Set(tokens)).sort((a, b) => b.length - a.length); } function textLooksLikeExpected(text, expectedName) { const normalizedText = normalizeDomName(text); const normalizedExpected = normalizeDomName(expectedName); if (!normalizedText || !normalizedExpected) { return false; } if (normalizedText === normalizedExpected) { return true; } if (normalizedText.includes(normalizedExpected) || normalizedExpected.includes(normalizedText)) { return true; } const tokens = buildExpectedNameTokens(normalizedExpected); return tokens.some((token) => normalizedText.includes(token)); } function getCheckboxInRow(row) { if (!row) { return null; } const selectors = [ 'label[role="checkbox"]', '[role="checkbox"]', '[aria-label*="选择"]', 'button[aria-label*="选择"]', '[data-testid*="checkbox"]', '[class*="checkbox"]', '[class*="check"]', 'input[type="checkbox"]', ]; const searchRoots = dedupeElements([ row, row.parentElement, row.previousElementSibling, row.nextElementSibling, row.firstElementChild, row.lastElementChild, ]); for (const root of searchRoots) { if (!root) { continue; } for (const selector of selectors) { const nodes = []; if (root.matches && root.matches(selector)) { nodes.push(root); } if (root.querySelectorAll) { nodes.push(...root.querySelectorAll(selector)); } for (const node of nodes) { let current = node; while (current && current !== document.body) { if (isVisibleElement(current)) { return current; } if (current === row || current === root) { break; } current = current.parentElement; } } } } return null; } function getListRows() { return dedupeElements(Array.from(document.querySelectorAll(getRowSelector())).filter((node) => isUsableListRow(node))); } function findScrollableListContainer() { const rows = getListRows().filter(isVisibleElement).slice(0, 12); const scored = []; for (const row of rows) { let current = row.parentElement; while (current && current !== document.body) { const style = window.getComputedStyle(current); const overflowY = style ? style.overflowY : ''; const canScroll = current.scrollHeight > current.clientHeight + 40 && /(auto|scroll|overlay)/i.test(String(overflowY || '')); if (canScroll) { scored.push({ node: current, score: current.scrollHeight - current.clientHeight, }); } current = current.parentElement; } } scored.sort((a, b) => b.score - a.score); return scored[0]?.node || document.scrollingElement || document.documentElement; } async function scrollListContainer(container, deltaY) { if (!container) { return false; } const isDocumentScroller = container === document.scrollingElement || container === document.documentElement || container === document.body; const before = isDocumentScroller ? (window.scrollY || window.pageYOffset || 0) : container.scrollTop; if (isDocumentScroller) { window.scrollTo({ top: Math.max(0, before + deltaY), behavior: 'auto' }); } else { container.scrollTop = Math.max(0, before + deltaY); } await sleep(220); const after = isDocumentScroller ? (window.scrollY || window.pageYOffset || 0) : container.scrollTop; return after !== before; } function isCheckboxLikeNode(node) { if (!node || typeof node.closest !== 'function') { return false; } return Boolean(node.closest( 'label[role="checkbox"], [role="checkbox"], [aria-label*="选择"], button[aria-label*="选择"], [data-testid*="checkbox"], [class*="checkbox"], [class*="check"], input[type="checkbox"]' )); } function collectVisibleListRowEntries(expectedNames = []) { const expectedSet = new Set((expectedNames || []).map((name) => normalizeDomName(name)).filter(Boolean)); const seen = new Set(); return getListRows() .filter((row) => isUsableListRow(row)) .map((row, index) => { const matchedName = expectedSet.size ? findExpectedNameInRow(row, expectedSet) : ''; const name = matchedName || extractNameFromRow(row); const normalizedName = normalizeDomName(name); if (!isProbablyUsefulName(name) || !normalizedName || seen.has(normalizedName)) { return null; } seen.add(normalizedName); return { index, row, name, normalizedName, checkbox: getCheckboxInRow(row), isDir: guessDomRowIsDirectory(row, name), }; }) .filter(Boolean); } function collectVisibleDirectoryRows(expectedNames = []) { return collectVisibleListRowEntries(expectedNames).filter((item) => item.isDir); } function buildEmptyScanChildDirs(items = [], visibleRows = []) { const normalizedItems = Array.isArray(items) ? items.filter(Boolean) : []; const visibleDirs = (Array.isArray(visibleRows) ? visibleRows : []) .filter((item) => item && item.isDir && String(item.name || '').trim()); const visibleByName = new Map(); for (const item of visibleDirs) { const key = normalizeDomName(item.name); if (key && !visibleByName.has(key)) { visibleByName.set(key, item); } } const merged = []; const seenKeys = new Set(); const pushMerged = (item) => { if (!item) { return; } const key = String(item.fileId || normalizeDomName(item.name) || '').trim(); if (!key || seenKeys.has(key)) { return; } seenKeys.add(key); merged.push(item); }; for (const item of normalizedItems) { const nameKey = normalizeDomName(item?.name || ''); const visibleMatch = nameKey ? visibleByName.get(nameKey) : null; if (!visibleMatch && !shouldTreatItemAsDirectory(item)) { continue; } pushMerged({ ...item, isDir: true, dirIdCandidates: normalizeIdCandidates(item?.dirIdCandidates || [item?.dirId, item?.fileId]), raw: { ...(item?.raw || {}), domIsDir: Boolean(visibleMatch || item?.raw?.domIsDir), }, }); if (visibleMatch && nameKey) { visibleByName.delete(nameKey); } } for (const visible of visibleByName.values()) { const syntheticId = `domdir:${normalizeDomName(visible.name)}`; pushMerged({ fileId: syntheticId, dirId: syntheticId, dirIdCandidates: [syntheticId], name: String(visible.name || ''), isDir: true, raw: { fromDom: true, domIsDir: true, }, }); } return merged; } function scoreDirectoryOpenTarget(node, row, expectedName = '') { if (!node || !isVisibleElement(node) || isHelperPanelNode(node) || isCheckboxLikeNode(node)) { return Number.NEGATIVE_INFINITY; } let score = node === row ? 5 : 0; if (node.matches && node.matches('a, [role="link"]')) { score += 80; } if (node.matches && node.matches('button')) { score += 30; } if (node.matches && node.matches('[data-name], [data-filename], [title], [aria-label], [class*="name"], [class*="title"]')) { score += 45; } const expected = normalizeDomName(expectedName); if (expected) { const candidates = collectTextCandidates(node); const matched = candidates.find((text) => textLooksLikeExpected(text, expected)); if (matched) { score += 140 - Math.min(40, Math.abs(normalizeDomName(matched).length - expected.length)); } } return score; } function getDirectoryOpenTarget(row, expectedName = '') { if (!row) { return null; } const candidates = dedupeElements([ row, ...Array.from(row.querySelectorAll('a, button, [role="link"], [data-name], [data-filename], [title], [aria-label], [class*="name"], [class*="title"], span, div, p, strong, td')), ]).filter((node) => !isHelperPanelNode(node) && !isBreadcrumbContainerNode(node)); candidates.sort((left, right) => scoreDirectoryOpenTarget(right, row, expectedName) - scoreDirectoryOpenTarget(left, row, expectedName)); return candidates[0] || row; } async function locateDirectoryRowByName(expectedName, options = {}) { const expected = normalizeDomName(expectedName); if (!expected) { return null; } const container = options.container || findScrollableListContainer(); const maxRounds = Math.max(1, Number(options.maxRounds || 20)); const deltaY = Math.max(280, Math.floor((container?.clientHeight || window.innerHeight || 640) * 0.72)); if (container && options.resetScroll !== false) { const isDocumentScroller = container === document.scrollingElement || container === document.documentElement || container === document.body; if (isDocumentScroller) { window.scrollTo({ top: 0, behavior: 'auto' }); } else { container.scrollTop = 0; } await sleep(180); } for (let round = 0; round < maxRounds; round += 1) { const visibleRows = collectVisibleDirectoryRows([expected]); const exact = visibleRows.find((item) => item.normalizedName === expected); if (exact) { return exact; } if (visibleRows.length) { return visibleRows[0]; } const searchedRows = collectRowsByDocumentSearch([expected]) .filter((item) => item.row && isUsableListRow(item.row) && guessDomRowIsDirectory(item.row, item.name || expected)); if (searchedRows.length) { return searchedRows[0]; } if (round >= maxRounds - 1) { break; } const moved = await scrollListContainer(container, deltaY); if (!moved) { break; } } return null; } function triggerSyntheticDblClick(target) { if (!target) { return; } try { if (target.scrollIntoView) { target.scrollIntoView({ block: 'center', inline: 'nearest' }); } } catch {} const mouseInit = { bubbles: true, cancelable: true, composed: true, view: window, }; try { target.dispatchEvent(new MouseEvent('dblclick', mouseInit)); } catch {} } async function openDirectoryByName(expectedName, options = {}) { const previousSnapshot = options.previousSnapshot || getDirectoryContextSnapshot(); const childItem = options.childItem || null; if (childItem && !isSyntheticDomId(childItem.fileId || childItem.dirId || '')) { const directHash = buildChildDirectoryHash(previousSnapshot, childItem); const directSnapshot = await navigateToDirectoryHash(directHash, previousSnapshot, { expectedName, timeoutMs: 4600, intervalMs: 180, stableMs: 420, }); if (directSnapshot) { return directSnapshot; } } const rowEntry = options.rowEntry || await locateDirectoryRowByName(expectedName, options); if (!rowEntry?.row) { return null; } const targets = dedupeElements([ getDirectoryOpenTarget(rowEntry.row, expectedName), rowEntry.row, ]); for (const target of targets) { triggerSyntheticClick(target); let changed = await waitForDirectoryChange(previousSnapshot, { timeoutMs: Number(options.timeoutMs || 4200), intervalMs: 120, }); if (changed) { await sleep(520); const fresh = await waitForFreshDirectoryContext(previousSnapshot, { expectedName, timeoutMs: 2400, intervalMs: 180, stableMs: 360, }); if (fresh) { return fresh; } } triggerSyntheticDblClick(target); changed = await waitForDirectoryChange(previousSnapshot, { timeoutMs: Number(options.timeoutMs || 4200), intervalMs: 120, }); if (changed) { await sleep(520); const fresh = await waitForFreshDirectoryContext(previousSnapshot, { expectedName, timeoutMs: 2400, intervalMs: 180, stableMs: 360, }); if (fresh) { return fresh; } } } return null; } function collectBreadcrumbTargets(expectedName = '') { const expected = normalizeDomName(expectedName); const selectors = [ '[aria-label*="breadcrumb" i] a', '[aria-label*="breadcrumb" i] button', '[aria-label*="breadcrumb" i] [role="link"]', '[aria-label*="breadcrumb" i] [role="button"]', '[class*="breadcrumb"] a', '[class*="breadcrumb"] button', '[class*="breadcrumb"] [role="link"]', '[class*="breadcrumb"] [role="button"]', '[class*="crumb"] a', '[class*="crumb"] button', '[class*="crumb"] [role="link"]', '[class*="crumb"] [role="button"]', '[class*="path"] a', '[class*="path"] button', '[class*="path"] [role="link"]', '[class*="path"] [role="button"]', 'nav a', 'nav button', 'nav [role="link"]', 'nav [role="button"]', ]; const nodes = dedupeElements(Array.from(document.querySelectorAll(selectors.join(', ')))) .filter((node) => isVisibleElement(node) && !isHelperPanelNode(node) && !node.querySelector('a, button, [role="link"], [role="button"]')); return nodes .map((node) => { const text = cleanDirectoryTitleCandidate(getVisibleNodeText(node)); const normalizedText = normalizeDomName(text); if (!normalizedText || node.getAttribute?.('aria-current') === 'page') { return null; } if (ROOT_DIRECTORY_NAMES.has(normalizedText) && normalizedText !== expected) { return null; } let score = 0; if (expected) { if (!textLooksLikeExpected(normalizedText, expected)) { return null; } score += normalizedText === expected ? 200 : 120; } if (node.matches && node.matches('a, button, [role="link"], [role="button"]')) { score += 40; } return { node, text: normalizedText, score }; }) .filter(Boolean) .sort((left, right) => right.score - left.score); } async function returnToDirectorySnapshot(targetSnapshot, options = {}) { if (!targetSnapshot) { return false; } const alreadyThere = await waitForDirectoryMatch(targetSnapshot, { timeoutMs: 120, intervalMs: 60, }); if (alreadyThere) { return true; } const hashMatched = await navigateToDirectoryHash(targetSnapshot.hash, getDirectoryContextSnapshot(), { expectedName: targetSnapshot.name, timeoutMs: Number(options.timeoutMs || 4200), intervalMs: 180, stableMs: 420, }); if (hashMatched && isSameDirectorySnapshot(targetSnapshot, hashMatched)) { await sleep(260); return true; } const breadcrumbTargets = collectBreadcrumbTargets(targetSnapshot.name); const bestTarget = breadcrumbTargets[0] || null; if (bestTarget) { triggerSyntheticClick(bestTarget.node); const matched = await waitForDirectoryMatch(targetSnapshot, { timeoutMs: Number(options.timeoutMs || 4200), intervalMs: 120, }); if (matched) { await sleep(420); return true; } } const historyBackTries = Math.max(1, Number(options.historyBackTries || 1)); for (let index = 0; index < historyBackTries; index += 1) { try { window.history.back(); } catch {} const matched = await waitForDirectoryMatch(targetSnapshot, { timeoutMs: Number(options.timeoutMs || 4200), intervalMs: 120, }); if (matched) { await sleep(420); return true; } } return false; } async function inspectCurrentDirectoryForEmptyScan(options = {}) { const currentSnapshot = getDirectoryContextSnapshot(); const parentId = String(options.parentId || currentSnapshot.parentId || '').trim(); if (!parentId || isSyntheticDomId(parentId)) { return { items: [], childDirs: [], visibleRows: [], emptyStateInfo: { visible: false, text: '', via: '' }, isEmpty: false, uncertain: true, requestError: new Error(!parentId ? '当前目录 parentId 为空' : `当前目录仍是伪 ID:${parentId}`), }; } let items = []; let visibleRows = []; let requestError = null; let emptyStateInfo = { visible: false, text: '', via: '', }; const maxAttempts = Math.max(2, Number(options.maxAttempts || 3)); const settleDelayMs = Math.max(240, Number(options.settleDelayMs || 800)); for (let attempt = 0; attempt < maxAttempts; attempt += 1) { requestError = null; let apiItems = []; try { apiItems = await fetchCurrentList({ parentId }); } catch (err) { requestError = err; } const snapshotItems = buildCurrentDirectoryItemsSnapshot(parentId); items = dedupeItems([...(Array.isArray(apiItems) ? apiItems : []), ...snapshotItems]); visibleRows = collectVisibleListRowEntries(); emptyStateInfo = findVisibleEmptyStateInfo(); const childDirs = buildEmptyScanChildDirs(items, visibleRows); const hasRenderableContent = Boolean(items.length || visibleRows.length || childDirs.length); const stillOnExpectedDirectory = !getDirectoryContextSnapshot().parentId || getDirectoryContextSnapshot().parentId === parentId; if (hasRenderableContent) { break; } if (emptyStateInfo.visible && stillOnExpectedDirectory && !requestError) { break; } if (attempt >= maxAttempts - 1) { break; } await sleep(settleDelayMs); } const childDirs = buildEmptyScanChildDirs(items, visibleRows); const hasRenderableContent = Boolean(items.length || visibleRows.length || childDirs.length); const isEmptyConfirmed = !hasRenderableContent && emptyStateInfo.visible && !requestError; const isUncertain = !hasRenderableContent && !isEmptyConfirmed; return { items, childDirs, visibleRows, emptyStateInfo, isEmpty: isEmptyConfirmed, uncertain: isUncertain, requestError, }; } function collectVisibleDuplicateRows(expectedNames = null) { const duplicateRe = getDuplicateRegex(); const expectedSet = expectedNames && expectedNames.length ? new Set(expectedNames.map((name) => normalizeDomName(name))) : null; const rows = getListRows(); return rows .map((row, index) => { const expectedName = findExpectedNameInRow(row, expectedSet); const name = expectedName || extractNameFromRow(row); const checkbox = getCheckboxInRow(row); return { index, row, name, normalizedName: normalizeDomName(name), checkbox, }; }) .filter((item) => { if (!item.checkbox || !isProbablyUsefulName(item.name)) { return false; } if (expectedSet && expectedSet.size) { return expectedSet.has(item.normalizedName); } return duplicateRe.test(item.name); }); } function collectRowsByDocumentSearch(expectedNames = []) { const expectedList = Array.from(new Set((expectedNames || []).map((name) => normalizeDomName(name)).filter(Boolean))); if (!expectedList.length) { return []; } const nodes = Array.from( document.querySelectorAll('[title], [aria-label], [data-name], [data-filename], span, div, p, a, strong, td') ); const rows = []; for (const node of nodes) { if (!isVisibleElement(node) || isHelperPanelNode(node)) { continue; } const texts = dedupeElements([ node.getAttribute && node.getAttribute('title'), node.getAttribute && node.getAttribute('aria-label'), node.getAttribute && node.getAttribute('data-name'), node.getAttribute && node.getAttribute('data-filename'), node.textContent, ]).map((x) => String(x || '')); const matchedExpected = expectedList.find((expected) => texts.some((text) => textLooksLikeExpected(text, expected))); if (!matchedExpected) { continue; } const row = getClosestRow(node); if (isHelperPanelNode(row) || !isUsableListRow(row)) { continue; } const checkbox = getCheckboxInRow(row); if (!row || !checkbox) { continue; } rows.push({ row, checkbox, name: matchedExpected, normalizedName: matchedExpected, }); } return dedupeElements(rows.map((item) => item.row)).map((row, index) => ({ index, row, checkbox: getCheckboxInRow(row), name: expectedList.find((expected) => textLooksLikeExpected(row.innerText || row.textContent || '', expected)) || extractNameFromRow(row), normalizedName: normalizeDomName( expectedList.find((expected) => textLooksLikeExpected(row.innerText || row.textContent || '', expected)) || extractNameFromRow(row) ), })).filter((item) => item.checkbox); } function triggerSyntheticClick(target) { if (!target) { return; } try { if (target.scrollIntoView) { target.scrollIntoView({ block: 'center', inline: 'nearest' }); } } catch {} const mouseInit = { bubbles: true, cancelable: true, composed: true, view: window, }; for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) { try { const EventCtor = type.startsWith('pointer') && typeof PointerEvent === 'function' ? PointerEvent : MouseEvent; target.dispatchEvent(new EventCtor(type, mouseInit)); } catch {} } try { if (typeof target.click === 'function') { target.click(); } } catch {} } async function toggleCheckboxRobustly(item) { const targets = dedupeElements([ item.checkbox, item.checkbox?.closest?.('label, button, [role="checkbox"]'), item.row, item.row?.querySelector?.('label[role="checkbox"], [role="checkbox"], input[type="checkbox"], [class*="checkbox"], [class*="check"]'), ]); for (const target of targets) { triggerSyntheticClick(target); await sleep(120); if (isElementChecked(item.checkbox)) { return true; } } return isElementChecked(item.checkbox); } async function selectDuplicateRows(options = {}) { const onProgress = typeof options.onProgress === 'function' ? options.onProgress : null; const taskControl = options.taskControl || null; let duplicates = getSelectedDuplicatePreviewItems(); try { if (!duplicates.length) { const previewItems = await previewDuplicates({ refresh: Boolean(options.refresh), listBody: options.listBody || {}, }); setDuplicatePreview(previewItems, { preserveSelection: true }); duplicates = getSelectedDuplicatePreviewItems(); } } catch (err) { warn('重复项预览失败,改为直接扫描当前可见行:', err); } const expectedNames = duplicates.map((item) => item.name); const pendingNames = new Set(expectedNames.map((name) => normalizeDomName(name)).filter(Boolean)); const matchedNames = new Set(); const visibleOnlyMode = !pendingNames.size; if (visibleOnlyMode) { warn('接口没有识别到重复项,勾选将退回为当前页面可见项扫描模式。'); } let clicked = 0; let skipped = 0; const container = findScrollableListContainer(); const maxRounds = visibleOnlyMode ? 1 : 24; if (container && !visibleOnlyMode) { const isDocumentScroller = container === document.scrollingElement || container === document.documentElement || container === document.body; if (isDocumentScroller) { window.scrollTo({ top: 0, behavior: 'auto' }); } else { container.scrollTop = 0; } await controlledDelay(160, taskControl); } for (let round = 0; round < maxRounds; round += 1) { await waitForTaskControl(taskControl); const targetNames = visibleOnlyMode ? null : Array.from(pendingNames); let visibleRows = collectVisibleDuplicateRows(targetNames); if (!visibleRows.length && targetNames && targetNames.length) { visibleRows = collectRowsByDocumentSearch(targetNames); } for (let i = 0; i < visibleRows.length; i += 1) { await waitForTaskControl(taskControl); const item = visibleRows[i]; if (matchedNames.has(item.normalizedName)) { continue; } matchedNames.add(item.normalizedName); pendingNames.delete(item.normalizedName); updatePanelStatus( visibleOnlyMode ? `勾选当前可见重复项 ${matchedNames.size} | 当前:${shortDisplayName(item.name)}` : `勾选重复项 ${matchedNames.size}/${duplicates.length} | 当前:${shortDisplayName(item.name)}` ); if (onProgress) { onProgress({ visible: true, percent: visibleOnlyMode ? Math.min(95, matchedNames.size * 12) : Math.min(95, (matchedNames.size / Math.max(1, duplicates.length)) * 100), indeterminate: false, text: visibleOnlyMode ? `勾选当前可见重复项 ${matchedNames.size} | 当前:${shortDisplayName(item.name)}` : `勾选重复项 ${matchedNames.size}/${duplicates.length} | 当前:${shortDisplayName(item.name)}`, }); } if (!isElementChecked(item.checkbox)) { const checked = await toggleCheckboxRobustly(item); if (checked) { clicked += 1; } } else { skipped += 1; } } if (visibleOnlyMode || !pendingNames.size) { break; } const deltaY = Math.max(280, Math.floor((container?.clientHeight || window.innerHeight || 640) * 0.75)); await waitForTaskControl(taskControl); const moved = await scrollListContainer(container, deltaY); if (!moved) { break; } } const missing = Array.from(pendingNames); if (!matchedNames.size) { updatePanelStatus(visibleOnlyMode ? '当前页面没有识别到可勾选的重复项' : `接口识别到 ${duplicates.length} 个重复项,但页面里一个都没定位到`); return { matched: 0, clicked: 0, skipped: 0, missing }; } updatePanelStatus( missing.length ? `接口识别 ${duplicates.length} 个,已定位 ${matchedNames.size} 个,勾选 ${clicked} 个,剩余 ${missing.length} 个未定位` : `重复项已定位 ${matchedNames.size} 个,勾选 ${clicked} 个,已跳过 ${skipped} 个` ); if (onProgress) { onProgress({ visible: true, percent: 100, indeterminate: false, text: missing.length ? `接口识别 ${duplicates.length} 个,已定位 ${matchedNames.size} 个,勾选 ${clicked} 个,剩余 ${missing.length} 个未定位` : `重复项已定位 ${matchedNames.size} 个,勾选 ${clicked} 个,已跳过 ${skipped} 个`, }); } console.table(Array.from(matchedNames).map((name) => ({ name }))); return { matched: matchedNames.size, clicked, skipped, missing }; } async function collectCheckedDuplicateTargets(duplicates, options = {}) { const onProgress = typeof options.onProgress === 'function' ? options.onProgress : null; const taskControl = options.taskControl || null; const expectedNames = duplicates.map((item) => item.name); const pendingNames = new Set(expectedNames.map((name) => normalizeDomName(name)).filter(Boolean)); const checkedNames = new Set(); const scannedNames = new Set(); const container = findScrollableListContainer(); const maxRounds = 24; if (container) { const isDocumentScroller = container === document.scrollingElement || container === document.documentElement || container === document.body; if (isDocumentScroller) { window.scrollTo({ top: 0, behavior: 'auto' }); } else { container.scrollTop = 0; } await controlledDelay(180, taskControl); } for (let round = 0; round < maxRounds; round += 1) { await waitForTaskControl(taskControl); const targetNames = Array.from(pendingNames); let visibleRows = collectVisibleDuplicateRows(targetNames); if (!visibleRows.length && targetNames.length) { visibleRows = collectRowsByDocumentSearch(targetNames); } for (const item of visibleRows) { scannedNames.add(item.normalizedName); pendingNames.delete(item.normalizedName); if (isElementChecked(item.checkbox)) { checkedNames.add(item.normalizedName); } else { checkedNames.delete(item.normalizedName); } } if (onProgress) { onProgress({ visible: true, percent: Math.min(95, ((round + 1) / maxRounds) * 100), indeterminate: false, text: `正在读取当前勾选状态 | 已扫描 ${scannedNames.size}/${duplicates.length} | 已勾选 ${checkedNames.size}`, }); } if (!pendingNames.size) { break; } const deltaY = Math.max(280, Math.floor((container?.clientHeight || window.innerHeight || 640) * 0.75)); await waitForTaskControl(taskControl); const moved = await scrollListContainer(container, deltaY); if (!moved) { break; } } const checkedTargets = duplicates.filter((item) => checkedNames.has(normalizeDomName(item.name))); return { checkedTargets, checkedNames, scannedNames, missing: duplicates.filter((item) => !scannedNames.has(normalizeDomName(item.name))), }; } function getTaskProgressState(payload, fallbackPercent = 10) { const raw = Number(findFirstValueByKeys(payload, ['progress', 'percent', 'percentage'])); if (!Number.isFinite(raw)) { return { percent: fallbackPercent, indeterminate: true, }; } if (raw <= 1) { return { percent: Math.max(0, Math.min(100, raw * 100)), indeterminate: false, }; } return { percent: Math.max(0, Math.min(100, raw)), indeterminate: false, }; } function getTaskProgressText(payload, attempt, maxTries, expectedTotal = 0) { const status = extractTaskStatus(payload) || 'UNKNOWN'; const counts = extractTaskCounts(payload, expectedTotal); const parts = [`任务状态: ${getTaskStatusLabel(status)}`, `轮询 ${attempt}/${maxTries}`]; if (counts.total > 0) { parts.push(counts.hasExplicitCounts ? `已处理 ${counts.processed}/${counts.total}` : `目标 ${counts.total} 项`); } if (counts.hasSuccessCount) { parts.push(`成功 ${counts.success}`); } if (counts.hasFailedCount) { parts.push(`失败 ${counts.failed}`); } return parts.join(' | '); } async function waitTaskUntilDone(taskId, options = {}) { const onProgress = typeof options.onProgress === 'function' ? options.onProgress : null; const taskControl = options.taskControl || null; const maxTries = Number(options.maxTries || CONFIG.batch.taskPollMaxTries || 30); const intervalMs = Number(options.intervalMs || CONFIG.batch.taskPollMs || 1200); const expectedTotal = Number(options.expectedTotal || 0); let lastResult = null; for (let attempt = 1; attempt <= maxTries; attempt += 1) { await waitForTaskControl(taskControl); const result = await getTaskStatus(taskId); lastResult = result; const progress = getTaskProgressState(result.payload, 10); if (onProgress) { onProgress({ visible: true, percent: Math.max(10, progress.percent), indeterminate: progress.indeterminate, text: getTaskProgressText(result.payload, attempt, maxTries, expectedTotal), }); } if (!result.ok) { throw new Error(`任务状态查询失败:HTTP ${result.status}`); } if (isTaskFinished(result.payload, { expectedTotal })) { return { ok: isTaskSuccessful(result.payload, { expectedTotal }), taskId, result, timeout: false, }; } if (attempt < maxTries) { await controlledDelay(intervalMs, taskControl); } } return { ok: false, taskId, result: lastResult, timeout: true, }; } function removeDuplicatePreviewItemsByIds(fileIds, options = {}) { const deletedIds = new Set((fileIds || []).map((id) => String(id))); if (!deletedIds.size) { return; } removeCapturedItemsByIds(Array.from(deletedIds)); setDuplicatePreview( (STATE.duplicatePreviewItems || []).filter((item) => !deletedIds.has(String(item.fileId))), { preserveSelection: options.preserveSelection !== false } ); } async function verifyDeletedItemsByList(targets, options = {}) { const onProgress = typeof options.onProgress === 'function' ? options.onProgress : null; const taskControl = options.taskControl || null; const maxRounds = Math.max(1, Number(options.maxRounds || 8)); const intervalMs = Math.max(300, Number(options.intervalMs || 1800)); const verifiedPageSize = Math.max( Number(UI.fields.pageSize?.value || 0), Number(CONFIG.request.manualListBody.pageSize || 0), Number(getCapturedItems().length || 0), Number((targets || []).length || 0) + 50, 200 ); const sourceTargets = Array.isArray(targets) ? targets.filter(Boolean) : []; let remaining = sourceTargets.slice(); let confirmedDeleted = []; let lastError = null; for (let round = 1; round <= maxRounds; round += 1) { await waitForTaskControl(taskControl); try { const items = await fetchCurrentList({ pageSize: verifiedPageSize, __returnBatchOnly: true }); const existingIds = new Set((items || []).map((item) => String(item.fileId))); remaining = sourceTargets.filter((item) => existingIds.has(String(item.fileId))); confirmedDeleted = sourceTargets.filter((item) => !existingIds.has(String(item.fileId))); if (onProgress) { onProgress({ visible: true, percent: Math.min(99, 82 + (round / maxRounds) * 16), indeterminate: false, text: `任务状态未确认,正在刷新目录核对删除结果 | 已确认 ${confirmedDeleted.length}/${sourceTargets.length} | 第 ${round}/${maxRounds} 次`, }); } if (!remaining.length) { return { ok: true, deletedItems: confirmedDeleted, remaining: [], rounds: round, pageSize: verifiedPageSize, }; } } catch (err) { lastError = err; warn('删除结果核对时刷新目录失败:', err); } if (round < maxRounds) { await controlledDelay(intervalMs, taskControl); } } return { ok: false, deletedItems: confirmedDeleted, remaining, rounds: maxRounds, pageSize: verifiedPageSize, error: lastError, }; } async function deleteDuplicateItems(options = {}) { const onProgress = typeof options.onProgress === 'function' ? options.onProgress : null; const taskControl = options.taskControl || null; if (!STATE.duplicatePreviewItems.length) { await waitForTaskControl(taskControl); const previewItems = await previewDuplicates({ refresh: options.refresh !== false, listBody: options.listBody || {}, }); setDuplicatePreview(previewItems); throw new Error(`已加载 ${previewItems.length} 个重复项。请先在面板里取消不想删的项目,再点“删除重复项”。`); } let duplicates = getSelectedDuplicatePreviewItems(); if (!duplicates.length) { warn('当前面板里没有勾选任何重复项。'); return { ok: 0, fail: 0, deleted: 0, taskId: '', duplicates }; } if (duplicates.some((item) => String(item.fileId || '').startsWith('dom:'))) { await waitForTaskControl(taskControl); const ensured = await ensureDuplicateItemsHaveRealIds(STATE.duplicatePreviewItems, { onProgress, }); if (ensured.mergedItems.length) { updateDuplicatePreviewResolvedItems(ensured.mergedItems); } duplicates = getSelectedDuplicatePreviewItems(); if (duplicates.some((item) => String(item.fileId || '').startsWith('dom:'))) { const unresolvedSelected = duplicates.filter((item) => String(item.fileId || '').startsWith('dom:')); throw new Error( `当前仍有 ${unresolvedSelected.length} 个重复项没拿到真实 fileId。已自动补齐 ${ensured.resolved.length} 项;请稍等页面继续加载这一批项目,或下拉目录后再重试。` ); } updatePanelStatus(`已自动补齐真实 fileId,准备删除 ${duplicates.length} 个重复项`); } if (CONFIG.batch.confirmBeforeRun && !window.confirm(`准备删除面板里已勾选的 ${duplicates.length} 个重复项,是否继续?`)) { return { ok: 0, fail: 0, deleted: 0, taskId: '', duplicates }; } if (onProgress) { onProgress({ visible: true, percent: 5, indeterminate: true, text: `正在提交删除任务,共 ${duplicates.length} 项`, }); } await waitForTaskControl(taskControl); const deleteRes = await deleteFiles(duplicates.map((item) => item.fileId)); if (!deleteRes.ok || !isProbablySuccess(deleteRes.payload, deleteRes)) { throw new Error(getErrorText(deleteRes.payload || deleteRes.text || `HTTP ${deleteRes.status}`)); } const taskId = extractTaskId(deleteRes.payload); if (!taskId) { removeDuplicatePreviewItemsByIds(duplicates.map((item) => item.fileId)); if (onProgress) { onProgress({ visible: true, percent: 100, indeterminate: false, text: `删除请求已提交,共 ${duplicates.length} 项;接口未返回 taskId,无法轮询进度`, }); } return { ok: duplicates.length, fail: 0, deleted: duplicates.length, taskId: '', duplicates, deleteRes, }; } if (onProgress) { onProgress({ visible: true, percent: 10, indeterminate: true, text: `删除任务已提交,taskId: ${taskId}`, }); } const task = await waitTaskUntilDone(taskId, { onProgress, taskControl, expectedTotal: duplicates.length, maxTries: Math.max(CONFIG.batch.taskPollMaxTries || 180, 180), intervalMs: Math.max(CONFIG.batch.taskPollMs || 1500, 1500), }); if (!task.ok) { const payload = task.result?.payload || task.result?.text || {}; warn('删除任务轮询未确认,准备改用目录列表核对结果:', { taskId, payload, }); const verification = await verifyDeletedItemsByList(duplicates, { onProgress, taskControl, maxRounds: 8, intervalMs: 1800, }); if (verification.deletedItems.length) { removeDuplicatePreviewItemsByIds(verification.deletedItems.map((item) => item.fileId)); } if (verification.ok) { if (onProgress) { onProgress({ visible: true, percent: 100, indeterminate: false, text: `删除已确认完成,共 ${verification.deletedItems.length} 项,taskId: ${taskId}`, }); } return { ok: verification.deletedItems.length, fail: 0, deleted: verification.deletedItems.length, taskId, duplicates, task, verification, }; } const partialText = verification.deletedItems.length ? `;已确认删除 ${verification.deletedItems.length} 项,剩余 ${verification.remaining.length} 项未确认` : ''; const verifyErrorText = verification.error ? `;列表核对失败:${getErrorText(verification.error)}` : ''; throw new Error( task.timeout ? `删除任务超时,taskId: ${taskId}${partialText}${verifyErrorText}` : `删除任务失败,taskId: ${taskId},${getErrorText(payload) || '未返回更多信息'}${partialText}${verifyErrorText}` ); } const taskCounts = extractTaskCounts(task.result?.payload, duplicates.length); const deletedCount = taskCounts.hasSuccessCount ? taskCounts.success : duplicates.length; const failedCount = taskCounts.hasFailedCount ? taskCounts.failed : 0; if (onProgress) { onProgress({ visible: true, percent: 100, indeterminate: false, text: `删除完成,共处理 ${deletedCount + failedCount} 项,成功 ${deletedCount} 项,失败 ${failedCount} 项,taskId: ${taskId}`, }); } removeDuplicatePreviewItemsByIds(duplicates.map((item) => item.fileId)); return { ok: deletedCount, fail: failedCount, deleted: deletedCount, taskId, duplicates, task, }; } async function deleteEmptyDirItems(options = {}) { const onProgress = typeof options.onProgress === 'function' ? options.onProgress : null; const taskControl = options.taskControl || null; const scan = STATE.lastEmptyDirScan || null; if (!scan || !Array.isArray(scan.emptyDirs) || !scan.emptyDirs.length) { const result = await scanEmptyLeafDirectories({ onProgress, taskControl, }); if (!result.emptyDirs.length) { return { ok: 0, fail: 0, deleted: 0, taskId: '', emptyDirs: [] }; } } const targets = getSelectedEmptyDirItems(); if (!targets.length) { warn('当前面板里没有勾选任何空目录。'); return { ok: 0, fail: 0, deleted: 0, taskId: '', emptyDirs: [] }; } if (CONFIG.batch.confirmBeforeRun && !window.confirm(`准备删除面板里已勾选的 ${targets.length} 个空目录,是否继续?`)) { return { ok: 0, fail: 0, deleted: 0, taskId: '', emptyDirs: targets }; } if (onProgress) { onProgress({ visible: true, percent: 5, indeterminate: true, text: `正在提交空目录删除任务,共 ${targets.length} 项`, }); } await waitForTaskControl(taskControl); const deleteRes = await deleteFiles(targets.map((item) => item.fileId)); if (!deleteRes.ok || !isProbablySuccess(deleteRes.payload, deleteRes)) { throw new Error(getErrorText(deleteRes.payload || deleteRes.text || `HTTP ${deleteRes.status}`)); } const taskId = extractTaskId(deleteRes.payload); if (!taskId) { removeEmptyDirScanItemsByIds(targets.map((item) => item.fileId)); if (onProgress) { onProgress({ visible: true, percent: 100, indeterminate: false, text: `空目录删除请求已提交,共 ${targets.length} 项;接口未返回 taskId,先按成功处理`, }); } return { ok: targets.length, fail: 0, deleted: targets.length, taskId: '', emptyDirs: targets, deleteRes, }; } if (onProgress) { onProgress({ visible: true, percent: 10, indeterminate: true, text: `空目录删除任务已提交,taskId: ${taskId}`, }); } const task = await waitTaskUntilDone(taskId, { onProgress, taskControl, expectedTotal: targets.length, maxTries: Math.max(CONFIG.batch.taskPollMaxTries || 180, 180), intervalMs: Math.max(CONFIG.batch.taskPollMs || 1500, 1500), }); if (!task.ok) { const payload = task.result?.payload || task.result?.text || {}; throw new Error( task.timeout ? `空目录删除任务超时,taskId: ${taskId}` : `空目录删除任务失败,taskId: ${taskId},${getErrorText(payload) || '未返回更多信息'}` ); } const taskCounts = extractTaskCounts(task.result?.payload, targets.length); const deletedCount = taskCounts.hasSuccessCount ? taskCounts.success : targets.length; const failedCount = taskCounts.hasFailedCount ? taskCounts.failed : 0; removeEmptyDirScanItemsByIds(targets.map((item) => item.fileId)); if (onProgress) { onProgress({ visible: true, percent: 100, indeterminate: false, text: `空目录删除完成,共处理 ${deletedCount + failedCount} 项,成功 ${deletedCount} 项,失败 ${failedCount} 项,taskId: ${taskId}`, }); } return { ok: deletedCount, fail: failedCount, deleted: deletedCount, taskId, emptyDirs: targets, task, }; } function getStatusSummary(extraText = '') { const mergedHeaders = getMergedHeaders(); const context = getCurrentListContext(); const magnetStats = getSelectedMagnetImportStats(); const bits = [ mergedHeaders.authorization ? '授权已就绪' : '授权未就绪', context.parentId ? `目录已识别` : '目录未识别', `已累计 ${context.capturedCount || getCapturedItems().length} 项`, `来源:${getItemsSourceLabel()}`, STATE.lastRenameRequest?.url ? '改名请求已学习' : '改名请求未学习', ]; if (magnetStats.fileCount > 0) { bits.push(`磁力文本 ${magnetStats.fileCount} 个 / 磁力 ${magnetStats.magnetCount} 条`); } if (STATE.lastEmptyDirScan) { bits.push(`空目录 ${Number(STATE.lastEmptyDirScan.emptyDirs?.length || 0)} 个`); } if (context.batchCount > 1) { bits.push(`已合并 ${context.batchCount} 批`); } if (extraText) { bits.push(extraText); } return bits.join(' | '); } function updatePanelStatus(extraText = '') { if (UI.status) { UI.status.textContent = getStatusSummary(extraText); } if (UI.summary) { const context = getCurrentListContext(); const duplicateSelected = getSelectedDuplicatePreviewItems().length; const duplicateTotal = (STATE.duplicatePreviewItems || []).length; const magnetStats = getSelectedMagnetImportStats(); const cloudSummary = STATE.lastCloudImportSummary || null; const emptyDirSummary = STATE.lastEmptyDirScan || null; const authStatus = STATE.headers.authorization ? '已自动识别最新认证' : (CONFIG.request.manualHeaders.authorization ? '使用手填认证兜底' : '未识别'); UI.summary.textContent = [ `parentId: ${context.parentId || '(未获取)'}`, `当前目录累计: ${context.capturedCount || getCapturedItems().length}`, `最近一批: ${context.lastBatchSize || 0}`, `已捕获批次: ${context.batchCount || 0}`, `listUrl: ${context.listUrl || '(未识别)'}`, `认证: ${authStatus}`, `预处理: ${getRuleModeLabel()}`, `改名方式: ${getRenameOutputModeLabel()}`, `重复项编号: ${CONFIG.duplicate.numbers || DEFAULT_DUPLICATE_NUMBERS}`, `删除勾选: ${duplicateSelected}/${duplicateTotal}`, `磁力文本: ${magnetStats.fileCount} 个`, `磁力条数: ${magnetStats.magnetCount} 条`, `云添加每批: ${CONFIG.cloud.maxFilesPerTask || 500} 文件`, `云添加目录前缀: ${CONFIG.cloud.sourceDirPrefix || '磁力导入'}`, emptyDirSummary ? `最近空目录扫描: 空目录 ${emptyDirSummary.emptyDirs?.length || 0} / 已扫目录 ${emptyDirSummary.scannedDirs || 0}${emptyDirSummary.truncated ? ' / 可能未扫全' : ''}` : '最近空目录扫描: 暂无记录', cloudSummary ? `最近云添加: 成功磁力 ${cloudSummary.submittedMagnets || 0} / 跳过 ${cloudSummary.skippedMagnets || 0} / 失败 ${cloudSummary.failedMagnets || 0} / 提交批次 ${cloudSummary.submittedTaskBatches || 0}` : '最近云添加: 暂无记录', '说明: 页面继续下拉时,新一批 get_file_list 会自动累计进当前目录。', ].join('\n'); } } function createTaskAbortError(message = '已停止当前任务') { const error = new Error(String(message || '已停止当前任务')); error.name = 'GypTaskAbortError'; error.isUserAbort = true; return error; } function isTaskAbortError(err) { return Boolean(err && (err.isUserAbort || err.name === 'GypTaskAbortError')); } function getActiveTaskControl() { return STATE.activeTaskControl || null; } function releaseTaskControlWaiters(control) { if (!control || !Array.isArray(control.waiters) || !control.waiters.length) { return; } const waiters = control.waiters.splice(0, control.waiters.length); for (const resolve of waiters) { try { resolve(); } catch {} } } function syncTaskControlUi() { if (!UI.pauseTaskButton || !UI.stopTaskButton) { return; } const control = getActiveTaskControl(); const hasActive = Boolean(control); UI.pauseTaskButton.disabled = !hasActive; UI.stopTaskButton.disabled = !hasActive; UI.pauseTaskButton.textContent = hasActive && control.paused ? '继续' : '暂停'; } function beginTaskControl(label = '') { const control = { id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, label: String(label || '当前任务'), paused: false, stopped: false, waiters: [], }; STATE.activeTaskControl = control; syncTaskControlUi(); return control; } function finishTaskControl(control) { if (control && STATE.activeTaskControl === control) { STATE.activeTaskControl = null; } syncTaskControlUi(); } function togglePauseActiveTask() { const control = getActiveTaskControl(); if (!control || control.stopped) { return false; } control.paused = !control.paused; if (!control.paused) { releaseTaskControlWaiters(control); } syncTaskControlUi(); const baseState = STATE.lastProgressState || { visible: true, percent: 0, indeterminate: true, text: control.label || '当前任务', }; setProgressBar({ ...baseState, visible: true, text: control.paused ? `已暂停 | ${baseState.text || control.label || '当前任务'}` : String(baseState.text || control.label || '当前任务').replace(/^已暂停\s*\|\s*/u, ''), }); updatePanelStatus(`${control.paused ? '已暂停' : '已继续'}:${control.label || '当前任务'}`); return true; } function stopActiveTask() { const control = getActiveTaskControl(); if (!control || control.stopped) { return false; } control.stopped = true; control.paused = false; releaseTaskControlWaiters(control); syncTaskControlUi(); const baseState = STATE.lastProgressState || { visible: true, percent: 100, indeterminate: false, text: control.label || '当前任务', }; setProgressBar({ ...baseState, visible: true, indeterminate: false, percent: Math.max(0, Math.min(100, Number(baseState.percent || 100))), text: `正在停止 | ${baseState.text || control.label || '当前任务'}`, }); updatePanelStatus(`正在停止:${control.label || '当前任务'}(会在当前步骤结束后停下)`); return true; } async function waitForTaskControl(taskControl) { if (!taskControl) { return; } if (taskControl.stopped) { throw createTaskAbortError(`${taskControl.label || '当前任务'}已停止`); } while (taskControl.paused) { await new Promise((resolve) => { taskControl.waiters.push(resolve); }); if (taskControl.stopped) { throw createTaskAbortError(`${taskControl.label || '当前任务'}已停止`); } } if (taskControl.stopped) { throw createTaskAbortError(`${taskControl.label || '当前任务'}已停止`); } } async function controlledDelay(ms, taskControl) { let remaining = Math.max(0, Number(ms || 0)); while (remaining > 0) { await waitForTaskControl(taskControl); const step = Math.min(150, remaining); await sleep(step); remaining -= step; } await waitForTaskControl(taskControl); } async function runWithTaskControl(label, taskRunner) { const control = beginTaskControl(label); try { return await taskRunner(control); } finally { finishTaskControl(control); } } function setProgressBar(state = {}) { if (!UI.progressWrap || !UI.progressBar || !UI.progressText) { return; } const visible = Boolean(state.visible); const percent = Math.max(0, Math.min(100, Number(state.percent || 0))); const indeterminate = Boolean(state.indeterminate); const control = getActiveTaskControl(); let text = state.text || ''; if (visible && control?.paused) { text = `已暂停 | ${text || control.label || '当前任务'}`; } else if (visible && control?.stopped) { text = `正在停止 | ${text || control.label || '当前任务'}`; } STATE.lastProgressState = { visible, percent, indeterminate, text: state.text || '', }; UI.progressWrap.style.display = visible ? 'block' : 'none'; if (!visible) { UI.progressBar.style.width = '0%'; UI.progressText.textContent = ''; return; } UI.progressBar.classList.toggle('gyp-indeterminate', indeterminate); UI.progressBar.style.width = indeterminate ? '36%' : `${percent}%`; UI.progressText.textContent = text; } function syncPanelFromConfig(options = {}) { if (!UI.root) { return; } const fillEmptyOnly = Boolean(options.fillEmptyOnly); const firstRule = CONFIG.rename.rules[0] || {}; const context = getCurrentListContext(); const ruleMode = getCurrentRuleMode(firstRule); const output = CONFIG.rename.output || {}; const values = { ruleMode, outputMode: output.mode || 'keep-clean', authorization: STATE.headers.authorization || CONFIG.request.manualHeaders.authorization || '', did: STATE.headers.did || CONFIG.request.manualHeaders.did || '', dt: STATE.headers.dt || CONFIG.request.manualHeaders.dt || '', parentId: context.parentId || CONFIG.request.manualListBody.parentId || '', pageSize: String(CONFIG.request.manualListBody.pageSize || context.pageSize || 100), template: output.template || CONFIG.rename.template || '{clean}', duplicateNumbers: CONFIG.duplicate.numbers || DEFAULT_DUPLICATE_NUMBERS, cloudBatchLimit: String(CONFIG.cloud.maxFilesPerTask || 500), cloudDirPrefix: CONFIG.cloud.sourceDirPrefix || '磁力导入', ruleSearchText: firstRule.type === 'text' ? (firstRule.search || '') : '', ruleReplaceText: firstRule.type === 'text' ? (firstRule.replace || '') : '', addText: output.addText || '', addPosition: output.addPosition || 'suffix', outputFindText: output.findText || '', outputReplaceText: output.replaceText || '', formatStyle: output.formatStyle || 'text-and-index', formatText: output.formatText || '文件', formatPosition: output.formatPosition || 'suffix', startIndex: String(output.startIndex ?? 0), exampleName: getDefaultExampleName(), rulePattern: firstRule.pattern || DEFAULT_LEADING_BRACKET_PATTERN, ruleFlags: firstRule.flags || '', ruleReplace: firstRule.replace || '', delayMs: String(CONFIG.batch.delayMs ?? 300), }; for (const [key, value] of Object.entries(values)) { const el = UI.fields[key]; if (!el) { continue; } if (!fillEmptyOnly || !el.value) { el.value = value; } } updatePanelStatus(); updateRenameModePreview(); } function applyPanelConfig() { if (!UI.root) { return; } const firstRule = CONFIG.rename.rules[0] || { enabled: true, type: 'regex' }; CONFIG.rename.rules[0] = firstRule; const ruleMode = UI.fields.ruleMode?.value || 'remove-leading-bracket'; CONFIG.request.manualHeaders.authorization = (UI.fields.authorization?.value || '').trim(); CONFIG.request.manualHeaders.did = (UI.fields.did?.value || '').trim(); CONFIG.request.manualHeaders.dt = (UI.fields.dt?.value || '').trim(); CONFIG.request.manualListBody.parentId = (UI.fields.parentId?.value || '').trim(); const pageSize = Number(UI.fields.pageSize?.value || 100); CONFIG.request.manualListBody.pageSize = Number.isFinite(pageSize) && pageSize > 0 ? pageSize : 100; CONFIG.rename.template = (UI.fields.template?.value || '{clean}').trim() || '{clean}'; CONFIG.rename.ruleMode = ruleMode; firstRule.enabled = ruleMode !== 'none'; firstRule.type = 'regex'; firstRule.search = ''; if (ruleMode === 'remove-leading-bracket') { firstRule.pattern = DEFAULT_LEADING_BRACKET_PATTERN; firstRule.flags = 'u'; firstRule.replace = ''; } else if (ruleMode === 'replace-text') { firstRule.type = 'text'; firstRule.pattern = ''; firstRule.flags = ''; firstRule.search = UI.fields.ruleSearchText?.value || ''; firstRule.replace = UI.fields.ruleReplaceText?.value || ''; } else if (ruleMode === 'custom-regex') { firstRule.pattern = UI.fields.rulePattern?.value || ''; firstRule.flags = UI.fields.ruleFlags?.value || ''; firstRule.replace = UI.fields.ruleReplace?.value || ''; } CONFIG.rename.output.mode = UI.fields.outputMode?.value || 'keep-clean'; CONFIG.rename.output.addText = UI.fields.addText?.value || ''; CONFIG.rename.output.addPosition = UI.fields.addPosition?.value || 'suffix'; CONFIG.rename.output.findText = UI.fields.outputFindText?.value || ''; CONFIG.rename.output.replaceText = UI.fields.outputReplaceText?.value || ''; CONFIG.rename.output.formatStyle = UI.fields.formatStyle?.value || 'text-and-index'; CONFIG.rename.output.formatText = UI.fields.formatText?.value || '文件'; CONFIG.rename.output.formatPosition = UI.fields.formatPosition?.value || 'suffix'; const startIndex = Number(UI.fields.startIndex?.value || 0); CONFIG.rename.output.startIndex = Number.isFinite(startIndex) ? startIndex : 0; CONFIG.rename.output.template = CONFIG.rename.template; CONFIG.duplicate.mode = 'numbers'; CONFIG.duplicate.numbers = (UI.fields.duplicateNumbers?.value || DEFAULT_DUPLICATE_NUMBERS).trim() || DEFAULT_DUPLICATE_NUMBERS; CONFIG.duplicate.pattern = buildDuplicatePatternFromNumbers(CONFIG.duplicate.numbers); CONFIG.duplicate.flags = 'u'; const cloudBatchLimit = Number(UI.fields.cloudBatchLimit?.value || 500); CONFIG.cloud.maxFilesPerTask = Number.isFinite(cloudBatchLimit) && cloudBatchLimit > 0 ? Math.max(1, cloudBatchLimit) : 500; CONFIG.cloud.sourceDirPrefix = (UI.fields.cloudDirPrefix?.value || '磁力导入').trim() || '磁力导入'; const delayMs = Number(UI.fields.delayMs?.value || 300); CONFIG.batch.delayMs = Number.isFinite(delayMs) && delayMs >= 0 ? delayMs : 300; savePersistedConfig(); updatePanelStatus('配置已应用'); updateRenameModePreview(); } function fillPanelFromCaptured() { if (!UI.root) { return; } const context = getCurrentListContext(); if (UI.fields.did && STATE.headers.did) { UI.fields.did.value = STATE.headers.did; } if (UI.fields.dt && STATE.headers.dt) { UI.fields.dt.value = STATE.headers.dt; } if (UI.fields.parentId && context.parentId) { UI.fields.parentId.value = context.parentId; } if (UI.fields.pageSize && context.pageSize) { UI.fields.pageSize.value = String(context.pageSize); } if (UI.fields.authorization) { UI.fields.authorization.value = STATE.headers.authorization || CONFIG.request.manualHeaders.authorization || UI.fields.authorization.value || ''; } updatePanelStatus('已把可见上下文填入表单'); updateRenameModePreview(); } function setPanelBusy(busy) { if (!UI.root) { return; } UI.root.querySelectorAll('button, input, textarea, select').forEach((el) => { if (el.dataset.keepEnabled === 'true') { return; } el.disabled = busy; }); } function createPanel() { if (UI.root || !document.body) { return; } const style = document.createElement('style'); style.textContent = ` #gyp-batch-rename-root { position: fixed; right: 16px; bottom: 16px; z-index: 2147483647; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #172033; width: 64px; height: 64px; } #gyp-batch-rename-root .gyp-fab { position: absolute; right: 0; bottom: 0; width: 62px; height: 62px; padding: 0; border: 0; border-radius: 999px; cursor: pointer; display: flex; align-items: center; justify-content: center; background: radial-gradient(circle at 30% 30%, #ffffff 0%, #edf5ff 100%); border: 1px solid rgba(15, 23, 42, 0.08); box-shadow: 0 16px 34px rgba(15, 98, 254, 0.24); overflow: hidden; user-select: none; transition: transform 0.18s ease, box-shadow 0.18s ease; } #gyp-batch-rename-root .gyp-fab:hover { transform: translateY(-1px) scale(1.02); box-shadow: 0 20px 40px rgba(15, 98, 254, 0.3); } #gyp-batch-rename-root .gyp-fab:active { transform: scale(0.98); } #gyp-batch-rename-root .gyp-fab-icon { width: 78%; height: 78%; display: flex; align-items: center; justify-content: center; } #gyp-batch-rename-root .gyp-fab svg { display: block; width: 100%; height: 100%; } #gyp-batch-rename-root .gyp-panel { position: absolute; right: 0; bottom: 72px; width: min(468px, calc(100vw - 24px)); max-height: min(76vh, 760px); overflow-x: hidden; overflow-y: auto; padding: 14px; box-sizing: border-box; border: 1px solid rgba(15, 23, 42, 0.14); border-radius: 18px; background: rgba(255, 255, 255, 0.96); box-shadow: 0 18px 48px rgba(15, 23, 42, 0.18); backdrop-filter: blur(12px); opacity: 0; transform: translateY(8px) scale(0.98); transform-origin: bottom right; pointer-events: none; transition: opacity 0.18s ease, transform 0.18s ease; } #gyp-batch-rename-root.gyp-open .gyp-panel { opacity: 1; transform: translateY(0) scale(1); pointer-events: auto; } #gyp-batch-rename-root .gyp-head { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: flex-start; margin-bottom: 10px; gap: 10px; } #gyp-batch-rename-root .gyp-head > div:first-child { min-width: 0; flex: 1 1 260px; } #gyp-batch-rename-root .gyp-title { font-size: 14px; font-weight: 700; } #gyp-batch-rename-root .gyp-subtitle { margin-top: 2px; font-size: 11px; color: #667085; } #gyp-batch-rename-root .gyp-version { margin-top: 2px; font-size: 11px; color: #0f62fe; font-weight: 600; } #gyp-batch-rename-root .gyp-head button, #gyp-batch-rename-root .gyp-actions button, #gyp-batch-rename-root .gyp-config-actions button { border: 0; border-radius: 10px; background: #0f62fe; color: #fff; padding: 8px 10px; cursor: pointer; font-size: 12px; } #gyp-batch-rename-root .gyp-head button { padding: 7px 10px; white-space: nowrap; } #gyp-batch-rename-root .gyp-head button.secondary, #gyp-batch-rename-root .gyp-actions button.secondary, #gyp-batch-rename-root .gyp-config-actions button.secondary { background: #eef2ff; color: #1f2a44; } #gyp-batch-rename-root .gyp-head button.danger, #gyp-batch-rename-root .gyp-actions button.danger, #gyp-batch-rename-root .gyp-config-actions button.danger { background: #d92d20; color: #fff; } #gyp-batch-rename-root .gyp-status { margin-bottom: 10px; padding: 10px; border-radius: 10px; background: #f5f8ff; font-size: 12px; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; } #gyp-batch-rename-root .gyp-progress { display: none; margin-bottom: 10px; padding: 10px; border-radius: 10px; background: #eef4ff; } #gyp-batch-rename-root .gyp-progress-track { height: 10px; border-radius: 999px; background: rgba(15, 98, 254, 0.12); overflow: hidden; } #gyp-batch-rename-root .gyp-progress-bar { width: 0%; height: 100%; border-radius: 999px; background: linear-gradient(90deg, #0f62fe, #45a1ff); transition: width 0.2s ease; } #gyp-batch-rename-root .gyp-progress-bar.gyp-indeterminate { background: linear-gradient(90deg, #0f62fe 0%, #69baff 48%, #0f62fe 100%); animation: gyp-progress-indeterminate 1.15s ease-in-out infinite; will-change: transform; } #gyp-batch-rename-root .gyp-progress-text { margin-top: 8px; font-size: 12px; line-height: 1.5; color: #26437a; white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; } #gyp-batch-rename-root .gyp-progress-tools { display: flex; gap: 8px; margin-top: 8px; } #gyp-batch-rename-root .gyp-progress-tools button { flex: 1 1 0; font-weight: 700; border: 1px solid transparent; } #gyp-batch-rename-root .gyp-progress-tools button.secondary { background: #facc15; color: #422006; border-color: #eab308; } #gyp-batch-rename-root .gyp-progress-tools button.secondary:hover:not(:disabled) { background: #eab308; } #gyp-batch-rename-root .gyp-progress-tools button.danger { background: #dc2626; color: #fff; border-color: #b91c1c; } #gyp-batch-rename-root .gyp-progress-tools button.danger:hover:not(:disabled) { background: #b91c1c; } #gyp-batch-rename-root .gyp-progress-tools button:disabled { opacity: 0.45; cursor: not-allowed; } @keyframes gyp-progress-indeterminate { 0% { transform: translateX(-130%); } 100% { transform: translateX(260%); } } #gyp-batch-rename-root .gyp-actions, #gyp-batch-rename-root .gyp-config-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; } #gyp-batch-rename-root .gyp-actions button { flex: 1 1 calc(50% - 4px); } #gyp-batch-rename-root details { margin: 10px 0; border: 1px solid rgba(15, 23, 42, 0.1); border-radius: 10px; padding: 10px; background: #fbfcfe; } #gyp-batch-rename-root summary { cursor: pointer; font-size: 13px; font-weight: 600; } #gyp-batch-rename-root .gyp-field { display: flex; flex-direction: column; gap: 6px; margin-top: 10px; } #gyp-batch-rename-root .gyp-field span { font-size: 12px; color: #344054; } #gyp-batch-rename-root .gyp-field input, #gyp-batch-rename-root .gyp-field select, #gyp-batch-rename-root .gyp-field textarea { width: 100%; box-sizing: border-box; border: 1px solid #d0d5dd; border-radius: 8px; padding: 8px 10px; font-size: 12px; color: #172033; background: #fff; } #gyp-batch-rename-root .gyp-field textarea { min-height: 58px; resize: vertical; } #gyp-batch-rename-root .gyp-inline-help { font-size: 11px; line-height: 1.5; color: #667085; overflow-wrap: anywhere; word-break: break-word; } #gyp-batch-rename-root .gyp-example { margin-top: 10px; padding: 10px; border-radius: 12px; background: #f7faff; border: 1px solid rgba(15, 98, 254, 0.12); } #gyp-batch-rename-root .gyp-example-title { font-size: 12px; font-weight: 700; color: #22324d; margin-bottom: 8px; } #gyp-batch-rename-root .gyp-example-row { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; } #gyp-batch-rename-root .gyp-example-label { font-size: 11px; color: #667085; } #gyp-batch-rename-root .gyp-example-value { font-size: 12px; line-height: 1.5; color: #172033; padding: 8px 10px; border-radius: 8px; background: #fff; border: 1px solid rgba(15, 23, 42, 0.08); overflow-wrap: anywhere; word-break: break-word; } #gyp-batch-rename-root .gyp-advanced { margin-top: 12px; border-top: 1px dashed rgba(15, 23, 42, 0.12); padding-top: 10px; } #gyp-batch-rename-root .gyp-advanced-title { font-size: 12px; font-weight: 600; color: #344054; } #gyp-batch-rename-root .gyp-help, #gyp-batch-rename-root .gyp-summary { font-size: 12px; line-height: 1.5; color: #475467; white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; } #gyp-batch-rename-root .gyp-summary { padding: 10px; border-radius: 10px; background: #fff7ed; margin-top: 10px; } #gyp-batch-rename-root .gyp-duplicate-panel { margin-bottom: 10px; border: 1px solid rgba(15, 23, 42, 0.1); border-radius: 12px; background: #f9fbff; padding: 10px; } #gyp-batch-rename-root .gyp-duplicate-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; } #gyp-batch-rename-root .gyp-duplicate-title { font-size: 12px; font-weight: 700; color: #22324d; } #gyp-batch-rename-root .gyp-duplicate-tools { display: flex; gap: 6px; } #gyp-batch-rename-root .gyp-duplicate-tools button { border: 0; border-radius: 8px; padding: 6px 8px; font-size: 11px; background: #eef2ff; color: #22324d; cursor: pointer; } #gyp-batch-rename-root .gyp-duplicate-list { max-height: 180px; overflow-x: hidden; overflow-y: auto; border-radius: 10px; background: #fff; border: 1px solid rgba(15, 23, 42, 0.08); } #gyp-batch-rename-root .gyp-duplicate-row { display: flex; align-items: flex-start; gap: 8px; padding: 8px 10px; border-bottom: 1px solid rgba(15, 23, 42, 0.06); cursor: pointer; } #gyp-batch-rename-root .gyp-duplicate-row:last-child { border-bottom: 0; } #gyp-batch-rename-root .gyp-duplicate-row input { margin-top: 2px; flex: 0 0 auto; } #gyp-batch-rename-root .gyp-duplicate-name { font-size: 12px; line-height: 1.45; color: #23314b; overflow-wrap: anywhere; word-break: break-word; } #gyp-batch-rename-root .gyp-duplicate-empty { padding: 12px 10px; font-size: 12px; line-height: 1.5; color: #667085; } #gyp-batch-rename-root .gyp-import-details { margin-top: 0; } #gyp-batch-rename-root .gyp-import-summary { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; color: #22324d; } #gyp-batch-rename-root .gyp-import-list { margin-top: 10px; max-height: 140px; overflow-x: hidden; overflow-y: auto; border-radius: 10px; background: #fff; border: 1px solid rgba(15, 23, 42, 0.08); } #gyp-batch-rename-root .gyp-import-row { padding: 8px 10px; border-bottom: 1px solid rgba(15, 23, 42, 0.06); } #gyp-batch-rename-root .gyp-import-row:last-child { border-bottom: 0; } #gyp-batch-rename-root .gyp-import-name { font-size: 12px; line-height: 1.45; color: #23314b; font-weight: 600; overflow-wrap: anywhere; word-break: break-word; } #gyp-batch-rename-root .gyp-import-meta, #gyp-batch-rename-root .gyp-import-empty { margin-top: 4px; font-size: 11px; line-height: 1.5; color: #667085; overflow-wrap: anywhere; word-break: break-word; } #gyp-batch-rename-root .gyp-import-empty { padding: 12px 10px; margin-top: 0; } /* 增强输入框聚焦时的蓝色高亮 */ #gyp-batch-rename-root .gyp-field input:focus, #gyp-batch-rename-root .gyp-field select:focus, #gyp-batch-rename-root .gyp-field textarea:focus { outline: none; border-color: #0f62fe !important; box-shadow: 0 0 0 3px rgba(15, 98, 254, 0.2) !important; background: #fff; } /* 悬停效果 */ #gyp-batch-rename-root .gyp-field input:hover, #gyp-batch-rename-root .gyp-field select:hover { border-color: #0f62fe; } /* 修复:强制显示文字选中后的高亮颜色 */ #gyp-batch-rename-root input::selection, #gyp-batch-rename-root textarea::selection { background-color: #0078d4 !important; color: #ffffff !important; } /* 针对不同浏览器的兼容 */ #gyp-batch-rename-root input::-moz-selection, #gyp-batch-rename-root textarea::-moz-selection { background-color: #0078d4 !important; color: #ffffff !important; } /* 让下拉列表在悬停时也有反应 */ #gyp-batch-rename-root .gyp-field select:hover, #gyp-batch-rename-root .gyp-field input:hover { border-color: #0f62fe; } @media (max-width: 640px) { #gyp-batch-rename-root { right: 10px; bottom: 10px; } #gyp-batch-rename-root .gyp-panel { width: min(380px, calc(100vw - 20px)); max-height: 72vh; } } `; document.head.appendChild(style); const root = document.createElement('div'); root.id = 'gyp-batch-rename-root'; root.innerHTML = ` `; document.body.appendChild(root); UI.root = root; UI.panel = root.querySelector('.gyp-panel'); UI.mini = root.querySelector('.gyp-fab'); UI.status = root.querySelector('[data-role="status"]'); UI.progressWrap = root.querySelector('[data-role="progress"]'); UI.progressBar = root.querySelector('[data-role="progress-bar"]'); UI.progressText = root.querySelector('[data-role="progress-text"]'); UI.pauseTaskButton = root.querySelector('[data-action="pause-task"]'); UI.stopTaskButton = root.querySelector('[data-action="stop-task"]'); UI.summary = root.querySelector('[data-role="summary"]'); UI.duplicateList = root.querySelector('[data-role="duplicate-list"]'); UI.duplicateCount = root.querySelector('[data-role="duplicate-count"]'); UI.emptyDirList = root.querySelector('[data-role="empty-dir-list"]'); UI.emptyDirCount = root.querySelector('[data-role="empty-dir-count"]'); UI.emptyDirDetails = root.querySelector('[data-role="empty-dir-details"]'); UI.magnetFileInput = root.querySelector('[data-role="magnet-file-input"]'); UI.magnetFileList = root.querySelector('[data-role="magnet-file-list"]'); UI.magnetFileCount = root.querySelector('[data-role="magnet-file-count"]'); UI.fields.ruleMode = root.querySelector('[data-field="ruleMode"]'); UI.fields.outputMode = root.querySelector('[data-field="outputMode"]'); UI.fields.ruleSearchText = root.querySelector('[data-field="ruleSearchText"]'); UI.fields.ruleReplaceText = root.querySelector('[data-field="ruleReplaceText"]'); UI.fields.addText = root.querySelector('[data-field="addText"]'); UI.fields.addPosition = root.querySelector('[data-field="addPosition"]'); UI.fields.outputFindText = root.querySelector('[data-field="outputFindText"]'); UI.fields.outputReplaceText = root.querySelector('[data-field="outputReplaceText"]'); UI.fields.formatStyle = root.querySelector('[data-field="formatStyle"]'); UI.fields.formatText = root.querySelector('[data-field="formatText"]'); UI.fields.formatPosition = root.querySelector('[data-field="formatPosition"]'); UI.fields.startIndex = root.querySelector('[data-field="startIndex"]'); UI.fields.exampleName = root.querySelector('[data-field="exampleName"]'); UI.fields.authorization = root.querySelector('[data-field="authorization"]'); UI.fields.did = root.querySelector('[data-field="did"]'); UI.fields.dt = root.querySelector('[data-field="dt"]'); UI.fields.parentId = root.querySelector('[data-field="parentId"]'); UI.fields.pageSize = root.querySelector('[data-field="pageSize"]'); UI.fields.template = root.querySelector('[data-field="template"]'); UI.fields.duplicateNumbers = root.querySelector('[data-field="duplicateNumbers"]'); UI.fields.cloudBatchLimit = root.querySelector('[data-field="cloudBatchLimit"]'); UI.fields.cloudDirPrefix = root.querySelector('[data-field="cloudDirPrefix"]'); UI.fields.rulePattern = root.querySelector('[data-field="rulePattern"]'); UI.fields.ruleFlags = root.querySelector('[data-field="ruleFlags"]'); UI.fields.ruleReplace = root.querySelector('[data-field="ruleReplace"]'); UI.fields.delayMs = root.querySelector('[data-field="delayMs"]'); const closePanel = () => { root.classList.remove('gyp-open'); }; const openPanel = () => { root.classList.add('gyp-open'); syncPanelFromConfig(); updatePanelStatus(); updateRenameModePreview(); }; root.addEventListener('click', async (event) => { const btn = event.target.closest('[data-action]'); if (!btn) { return; } const action = btn.dataset.action; if (action === 'pause-task') { togglePauseActiveTask(); return; } if (action === 'stop-task') { stopActiveTask(); return; } if (action === 'toggle-panel') { if (root.classList.contains('gyp-open')) { closePanel(); } else { openPanel(); } return; } if (action === 'close-panel') { closePanel(); return; } try { if (action === 'pick-magnet-files') { UI.magnetFileInput?.click(); return; } if (action === 'clear-magnet-files') { STATE.magnetImportFiles = []; STATE.lastCloudImportSummary = null; if (UI.magnetFileInput) { UI.magnetFileInput.value = ''; } renderMagnetImportList(); updatePanelStatus('已清空待导入的磁力 txt 列表'); return; } if (action === 'fill-captured') { fillPanelFromCaptured(); return; } if (action === 'apply-config') { applyPanelConfig(); return; } if (action === 'save-config') { applyPanelConfig(); savePersistedConfig(); updatePanelStatus('配置已保存到本地'); return; } if (action === 'select-all-duplicates') { for (const item of STATE.duplicatePreviewItems || []) { STATE.duplicateSelection[item.fileId] = true; } renderDuplicatePreviewList(); updatePanelStatus('已全选当前面板里的重复项'); return; } if (action === 'clear-all-duplicates') { for (const item of STATE.duplicatePreviewItems || []) { STATE.duplicateSelection[item.fileId] = false; } renderDuplicatePreviewList(); updatePanelStatus('已取消当前面板里的重复项勾选'); return; } if (action === 'select-all-empty-dirs') { for (const item of STATE.lastEmptyDirScan?.emptyDirs || []) { STATE.emptyDirSelection[String(item.fileId || '')] = true; } renderEmptyDirScanList(); updatePanelStatus('已全选当前面板里的空目录'); return; } if (action === 'clear-all-empty-dirs') { for (const item of STATE.lastEmptyDirScan?.emptyDirs || []) { STATE.emptyDirSelection[String(item.fileId || '')] = false; } renderEmptyDirScanList(); updatePanelStatus('已取消当前面板里的空目录勾选'); return; } applyPanelConfig(); setPanelBusy(true); if (action === 'preview') { setProgressBar({ visible: false }); const targets = await preview(); updatePanelStatus(`预览完成,共 ${targets.length} 个待改名`); return; } if (action === 'refresh-preview') { setProgressBar({ visible: false }); const targets = await preview({ refresh: true }); updatePanelStatus(`刷新预览完成,共 ${targets.length} 个待改名`); return; } if (action === 'run') { await runWithTaskControl('批量改名', async (taskControl) => { setProgressBar({ visible: true, percent: 0, text: '准备执行...' }); const result = await run({ onProgress: (state) => setProgressBar(state), taskControl, }); updatePanelStatus( result.fail ? `执行完成,成功 ${result.ok} 个,失败 ${result.fail} 个,首个错误:${result.firstError || '(未返回详情)'}` : `执行完成,成功 ${result.ok} 个,失败 ${result.fail} 个` ); }); return; } if (action === 'scan-empty-dirs') { await runWithTaskControl('扫描空目录', async (taskControl) => { setProgressBar({ visible: true, percent: 0, indeterminate: true, text: '准备扫描当前目录树里的空目录...' }); const result = await scanEmptyLeafDirectories({ onProgress: (state) => setProgressBar(state), taskControl, }); updatePanelStatus( result.truncated ? `空目录扫描完成:找到 ${result.emptyDirs.length} 个,已扫 ${result.scannedDirs} 个目录,结果可能未扫全` : `空目录扫描完成:找到 ${result.emptyDirs.length} 个,已扫 ${result.scannedDirs} 个目录` ); }); return; } if (action === 'delete-empty-dirs') { await runWithTaskControl('删除空目录', async (taskControl) => { setProgressBar({ visible: true, percent: 0, indeterminate: true, text: '准备删除空目录...' }); const result = await deleteEmptyDirItems({ onProgress: (state) => setProgressBar(state), taskControl, }); updatePanelStatus(`空目录删除完成:成功 ${result.deleted} 个,失败 ${result.fail} 个`); }); return; } if (action === 'preview-duplicates') { setProgressBar({ visible: false }); const duplicates = await previewDuplicates({ refresh: true }); setDuplicatePreview(duplicates); updatePanelStatus(`重复项预览完成,共 ${duplicates.length} 个;当前范围 ${getCapturedItems().length} 条已捕获记录`); return; } if (action === 'select-duplicates') { await runWithTaskControl('勾选重复项', async (taskControl) => { setProgressBar({ visible: true, percent: 0, text: '准备勾选重复项...' }); const result = await selectDuplicateRows({ refresh: true, onProgress: (state) => setProgressBar(state), taskControl, }); updatePanelStatus(`重复项已处理:匹配 ${result.matched} 个,勾选 ${result.clicked} 个`); }); return; } if (action === 'delete-duplicates') { await runWithTaskControl('删除重复项', async (taskControl) => { setProgressBar({ visible: true, percent: 0, indeterminate: true, text: '准备删除重复项...' }); const result = await deleteDuplicateItems({ refresh: true, onProgress: (state) => setProgressBar(state), taskControl, }); updatePanelStatus(`重复项删除完成:成功 ${result.deleted} 个,失败 ${result.fail} 个`); }); return; } if (action === 'import-magnets') { await runWithTaskControl('磁力云添加', async (taskControl) => { setProgressBar({ visible: true, percent: 0, indeterminate: true, text: '准备提交磁力云添加...' }); const result = await importMagnetTextFiles({ onProgress: (state) => setProgressBar(state), taskControl, }); const firstFailure = result.failures && result.failures[0] ? `;首个失败:${result.failures[0].message}` : ''; updatePanelStatus( `云添加提交完成:磁力成功 ${result.submittedMagnets} 条,跳过 ${result.skippedMagnets} 条,失败 ${result.failedMagnets} 条;任务批次成功 ${result.submittedTaskBatches} 个,失败 ${result.failedTaskBatches} 个${firstFailure}` ); renderMagnetImportList(); }); return; } if (action === 'list-cloud-tasks') { setProgressBar({ visible: true, percent: 25, indeterminate: true, text: '正在读取云添加任务列表...' }); const response = await listCloudTasks(); if (!response.ok || !isProbablySuccess(response.payload, response)) { throw new Error(getErrorText(response.payload || response.text || `HTTP ${response.status}`)); } const rows = extractCloudTaskRows(response.payload); console.table(rows.map((item) => ({ taskId: item.taskId, status: item.status, name: item.name, url: item.url, }))); setProgressBar({ visible: true, percent: 100, indeterminate: false, text: `已读取云添加任务 ${rows.length} 条,详情已输出到控制台`, }); updatePanelStatus(`已读取云添加任务 ${rows.length} 条,详情已输出到控制台`); return; } if (action === 'state') { setProgressBar({ visible: false }); console.log(LOG_PREFIX, exportState()); updatePanelStatus('状态已输出到控制台'); } } catch (err) { if (isTaskAbortError(err)) { setProgressBar({ visible: true, percent: 100, indeterminate: false, text: err.message || '已停止当前任务' }); updatePanelStatus(err.message || '已停止当前任务'); } else { fail('面板操作失败:', err); setProgressBar({ visible: true, percent: 100, text: `失败:${err.message || err}` }); updatePanelStatus(`失败:${err.message || err}`); } } finally { setPanelBusy(false); } }); root.addEventListener('input', (event) => { if (event.target.closest('[data-field]')) { updateRenameModePreview(); } }); root.addEventListener('change', (event) => { if (event.target.closest('[data-field]')) { updateRenameModePreview(); } if (event.target === UI.magnetFileInput) { const files = Array.from(UI.magnetFileInput?.files || []); if (!files.length) { return; } setProgressBar({ visible: true, percent: 0, indeterminate: true, text: `正在识别 ${files.length} 个本地磁力文本...`, }); readMagnetImportFiles(files, { onProgress: (state) => setProgressBar(state), }) .then((entries) => { setMagnetImportFiles(entries, { append: true }); const stats = getSelectedMagnetImportStats(); setProgressBar({ visible: true, percent: 100, indeterminate: false, text: `已识别磁力文本 ${stats.fileCount} 个,共 ${stats.magnetCount} 条磁力`, }); updatePanelStatus(`已识别磁力文本 ${stats.fileCount} 个,共 ${stats.magnetCount} 条磁力`); }) .catch((err) => { fail('读取磁力文本失败:', err); setProgressBar({ visible: true, percent: 100, indeterminate: false, text: `读取磁力文本失败:${getErrorText(err)}`, }); updatePanelStatus(`读取磁力文本失败:${getErrorText(err)}`); }) .finally(() => { if (UI.magnetFileInput) { UI.magnetFileInput.value = ''; } }); return; } const duplicateInput = event.target.closest('[data-action="toggle-duplicate"]'); if (duplicateInput) { const fileId = duplicateInput.dataset.fileId || ''; if (!fileId) { return; } STATE.duplicateSelection[fileId] = Boolean(duplicateInput.checked); renderDuplicatePreviewList(); updatePanelStatus('已更新删除勾选清单'); return; } const emptyDirInput = event.target.closest('[data-action="toggle-empty-dir"]'); if (!emptyDirInput) { return; } const emptyDirId = emptyDirInput.dataset.fileId || ''; if (!emptyDirId) { return; } STATE.emptyDirSelection[emptyDirId] = Boolean(emptyDirInput.checked); renderEmptyDirScanList(); updatePanelStatus('已更新空目录删除勾选清单'); }); document.addEventListener('pointerdown', (event) => { if (!root.classList.contains('gyp-open')) { return; } if (root.contains(event.target)) { return; } closePanel(); }); document.addEventListener('keydown', (event) => { if (event.key === 'Escape') { closePanel(); } }); syncPanelFromConfig(); renderDuplicatePreviewList(); renderEmptyDirScanList(); renderMagnetImportList(); updateRenameModePreview(); syncTaskControlUi(); } function mountPanelWhenReady() { if (UI.root) { return; } if (document.body) { createPanel(); return; } const tryMount = () => { if (document.body && !UI.root) { createPanel(); } }; document.addEventListener('DOMContentLoaded', tryMount, { once: true }); window.addEventListener('load', tryMount, { once: true }); const timer = window.setInterval(() => { if (UI.root) { window.clearInterval(timer); return; } if (document.body) { createPanel(); window.clearInterval(timer); } }, 300); } function handleCapture(detail) { if (!detail || typeof detail !== 'object') { return; } const url = String(detail.url || ''); if (!url.includes(CONFIG.request.apiHost)) { return; } STATE.lastApiHeaders = sanitizeHeaders(detail.headers); mergeHeaders(detail.headers); const requestBody = safeJsonParse(detail.requestBody); const responseBody = safeJsonParse(detail.responseText); if (isLikelyListCapture(url, requestBody, responseBody)) { STATE.lastListHeaders = sanitizeHeaders(detail.headers); STATE.lastListCapturedAt = Date.now(); if (requestBody && typeof requestBody === 'object') { STATE.lastListBody = sanitizeListBody(requestBody); STATE.lastCapturedParentId = getParentIdFromListBody(STATE.lastListBody) || STATE.lastCapturedParentId; } if (responseBody && typeof responseBody === 'object') { const items = extractItemsFromPayload(responseBody); STATE.lastListUrl = url; STATE.lastListResponse = responseBody; if (items.length) { const merged = mergeCapturedItems(getParentIdFromListBody(requestBody || STATE.lastListBody), items, { listUrl: url, requestBody: STATE.lastListBody, }); log(`已捕获列表响应:本批 ${items.length} 项,当前目录累计 ${merged.total} 项(共 ${merged.batchCount} 批)。`); syncPanelFromConfig({ fillEmptyOnly: true }); updatePanelStatus(`已累计当前目录 ${merged.total} 项`); } } } if (url.includes(CONFIG.request.renamePath) || /\/rename(?:\?|$)/i.test(url)) { STATE.lastRenameRequest = { url, headers: sanitizeHeaders(detail.headers), requestBody, responseBody, }; updatePanelStatus('已捕获改名请求上下文'); } } function injectNetworkHook() { const code = ` (() => { if (window.__gypBatchRenameHookInstalled) { return; } window.__gypBatchRenameHookInstalled = true; const EVENT_NAME = ${JSON.stringify(CAPTURE_EVENT)}; const REQUEST_EVENT = ${JSON.stringify(PAGE_REQUEST_EVENT)}; const RESPONSE_EVENT = ${JSON.stringify(PAGE_RESPONSE_EVENT)}; const API_HOST = ${JSON.stringify(CONFIG.request.apiHost)}; const RENAME_PATH = ${JSON.stringify(CONFIG.request.renamePath)}; const shouldCapture = (url) => typeof url === 'string' && url.includes(API_HOST); const normalizeHeaders = (headersLike) => { const out = {}; if (!headersLike) { return out; } if (headersLike instanceof Headers) { for (const [key, value] of headersLike.entries()) { out[String(key).toLowerCase()] = value; } return out; } if (Array.isArray(headersLike)) { for (const [key, value] of headersLike) { out[String(key).toLowerCase()] = value; } return out; } if (typeof headersLike === 'object') { for (const [key, value] of Object.entries(headersLike)) { out[String(key).toLowerCase()] = value; } } return out; }; const emit = (detail) => { window.dispatchEvent(new CustomEvent(EVENT_NAME, { detail })); }; const emitResponse = (detail) => { window.dispatchEvent(new CustomEvent(RESPONSE_EVENT, { detail })); }; const originalFetch = window.fetch.bind(window); const buildNativeRequestOptions = (optionsLike) => { const source = optionsLike && typeof optionsLike === 'object' ? optionsLike : {}; const out = {}; const stringFields = ['method', 'mode', 'credentials', 'cache', 'redirect', 'referrer', 'referrerPolicy']; for (const key of stringFields) { if (typeof source[key] === 'string' && source[key]) { out[key] = String(source[key]); } } if (typeof source.keepalive === 'boolean') { out.keepalive = source.keepalive; } const headers = normalizeHeaders(source.headers); if (Object.keys(headers).length) { out.headers = { ...headers }; } if (typeof source.body === 'string') { out.body = source.body; } return out; }; const requestViaXhr = (url, optionsLike) => new Promise((resolve, reject) => { const options = buildNativeRequestOptions(optionsLike); const xhr = new XMLHttpRequest(); xhr.open(options.method || 'GET', String(url), true); xhr.withCredentials = options.credentials === 'include'; xhr.timeout = 30000; const headers = normalizeHeaders(options.headers); for (const [key, value] of Object.entries(headers)) { try { xhr.setRequestHeader(key, value); } catch (err) { reject(new Error(\`XHR setRequestHeader failed for \${key}: \${err && err.message ? err.message : err}\`)); return; } } xhr.onload = () => { resolve({ ok: xhr.status >= 200 && xhr.status < 300, status: xhr.status, text: typeof xhr.responseText === 'string' ? xhr.responseText : '', via: 'xhr', }); }; xhr.onerror = () => reject(new Error('XHR network error')); xhr.ontimeout = () => reject(new Error('XHR timeout')); xhr.send(typeof options.body === 'string' ? options.body : null); }); window.addEventListener(REQUEST_EVENT, async (event) => { const detail = event.detail || {}; if (!detail.requestId || !detail.url) { return; } try { const requestUrl = String(detail.url); const requestOptions = buildNativeRequestOptions(detail.options); try { const response = await originalFetch(requestUrl, requestOptions); const text = await response.clone().text(); emitResponse({ requestId: detail.requestId, ok: response.ok, status: response.status, text, via: 'fetch', }); return; } catch (fetchErr) { try { const fallback = await requestViaXhr(requestUrl, requestOptions); emitResponse({ requestId: detail.requestId, ok: fallback.ok, status: fallback.status, text: fallback.text, via: fallback.via, fallbackFrom: String(fetchErr && fetchErr.message ? fetchErr.message : fetchErr), }); return; } catch (xhrErr) { emitResponse({ requestId: detail.requestId, error: \`fetch failed: \${String(fetchErr && fetchErr.message ? fetchErr.message : fetchErr)} | xhr failed: \${String(xhrErr && xhrErr.message ? xhrErr.message : xhrErr)}\`, }); return; } } } catch (err) { emitResponse({ requestId: detail.requestId, error: String(err && err.message ? err.message : err), }); } }); window.fetch = async function patchedFetch(input, init) { const url = typeof input === 'string' ? input : (input && input.url) || ''; const requestHeaders = normalizeHeaders((init && init.headers) || (input && input.headers)); const requestBody = init && typeof init.body === 'string' ? init.body : ''; const response = await originalFetch(input, init); if (shouldCapture(url)) { try { const cloned = response.clone(); const responseText = await cloned.text(); emit({ type: 'fetch', url, headers: requestHeaders, requestBody, responseText, status: response.status, }); } catch (err) { emit({ type: 'fetch', url, headers: requestHeaders, requestBody, responseText: '', status: response.status, captureError: String(err), }); } } return response; }; const rawOpen = XMLHttpRequest.prototype.open; const rawSetHeader = XMLHttpRequest.prototype.setRequestHeader; const rawSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function patchedOpen(method, url) { this.__gypCapture = { method, url, headers: {}, requestBody: '', }; return rawOpen.apply(this, arguments); }; XMLHttpRequest.prototype.setRequestHeader = function patchedSetHeader(name, value) { if (this.__gypCapture) { this.__gypCapture.headers[String(name).toLowerCase()] = value; } return rawSetHeader.apply(this, arguments); }; XMLHttpRequest.prototype.send = function patchedSend(body) { if (this.__gypCapture && typeof body === 'string') { this.__gypCapture.requestBody = body; } this.addEventListener('load', function onLoad() { const url = this.responseURL || (this.__gypCapture && this.__gypCapture.url) || ''; if (!shouldCapture(url)) { return; } emit({ type: 'xhr', url, headers: this.__gypCapture ? this.__gypCapture.headers : {}, requestBody: this.__gypCapture ? this.__gypCapture.requestBody : '', responseText: this.responseText || '', status: this.status, }); }); return rawSend.apply(this, arguments); }; })(); `; const script = document.createElement('script'); script.textContent = code; (document.documentElement || document.head || document.body).appendChild(script); script.remove(); } function registerMenu() { if (typeof GM_registerMenuCommand !== 'function') { return; } GM_registerMenuCommand('光鸭云盘:预览当前已捕获列表', () => { preview().catch((err) => fail('预览失败:', err)); }); GM_registerMenuCommand('光鸭云盘:重新拉取当前目录并预览', () => { preview({ refresh: true }).catch((err) => fail('预览失败:', err)); }); GM_registerMenuCommand('光鸭云盘:执行批量改名', () => { run().catch((err) => fail('执行失败:', err)); }); GM_registerMenuCommand('光鸭云盘:扫描最里层空目录', () => { scanEmptyLeafDirectories().catch((err) => fail('空目录扫描失败:', err)); }); GM_registerMenuCommand('光鸭云盘:删除已勾选空目录', () => { deleteEmptyDirItems().catch((err) => fail('删除空目录失败:', err)); }); GM_registerMenuCommand('光鸭云盘:预览重复项', () => { previewDuplicates({ refresh: true }).catch((err) => fail('重复项预览失败:', err)); }); GM_registerMenuCommand('光鸭云盘:删除重复项', () => { deleteDuplicateItems({ refresh: true }).catch((err) => fail('删除重复项失败:', err)); }); GM_registerMenuCommand('光鸭云盘:查看云添加任务', () => { listCloudTasks() .then((response) => { console.table(extractCloudTaskRows(response.payload)); }) .catch((err) => fail('读取云添加任务失败:', err)); }); GM_registerMenuCommand('光鸭云盘:查看捕获状态', () => { console.log(LOG_PREFIX, exportState()); }); } window.addEventListener(CAPTURE_EVENT, (event) => { handleCapture(event.detail); }); loadPersistedConfig(); injectNetworkHook(); registerMenu(); mountPanelWhenReady(); const api = { config: CONFIG, state: STATE, preview, previewDuplicates, run, fetchCurrentList, exportState, selectDuplicateRows, deleteDuplicateItems, deleteEmptyDirItems, importMagnetTextFiles, listCloudTasks, scanEmptyLeafDirectories, extractMagnetLinks, applyPanelConfig, savePersistedConfig, }; const pageWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; pageWindow.gypBatchRenamer = api; log('脚本已加载。页面右下角会出现光鸭云盘悬浮面板,也可以在控制台运行 gypBatchRenamer.preview() / gypBatchRenamer.run() / gypBatchRenamer.deleteDuplicateItems() / gypBatchRenamer.importMagnetTextFiles() / gypBatchRenamer.scanEmptyLeafDirectories()。'); })();