// ==UserScript== // @name 光鸭云盘手动整理助手 // @namespace guanya.custom.organizer // @version 0.2.0 // @license AGPL // @description 在“云添加”后增加“整理”按钮,支持 TMDB 选择并按剧集规则整理 // @author You // @match https://www.guangyapan.com/* // @run-at document-end // @grant GM_xmlhttpRequest // @grant GM_registerMenuCommand // @grant unsafeWindow // @connect api.guangyapan.com // @connect www.guangyapan.com // @connect api.tmdb.org // @downloadURL https://update.greasyfork.icu/scripts/575867/%E5%85%89%E9%B8%AD%E4%BA%91%E7%9B%98%E6%89%8B%E5%8A%A8%E6%95%B4%E7%90%86%E5%8A%A9%E6%89%8B.user.js // @updateURL https://update.greasyfork.icu/scripts/575867/%E5%85%89%E9%B8%AD%E4%BA%91%E7%9B%98%E6%89%8B%E5%8A%A8%E6%95%B4%E7%90%86%E5%8A%A9%E6%89%8B.meta.js // ==/UserScript== (function () { "use strict"; const LOG_PREFIX = "[光鸭整理]"; const ORGANIZE_BTN_ATTR = "data-guanya-organize-btn"; const MODAL_ID = "guangya-organize-modal"; const STYLE_ID = "guangya-organize-style"; const TMDB_KEY_STORAGE = "__GUANGYA_TMDB_API_KEY__"; const REQUEST_EVENT = "__guangya_organize_request__"; const RESPONSE_EVENT = "__guangya_organize_response__"; const TRANSIENT_KEYS = new Set([ "cursor", "nextCursor", "nextKey", "nextToken", "pageToken", "continueToken", "marker", "offset", "start", "startId", "startKey", "lastId", "lastKey", "lastFileId", "lastSortValue", "pageNo", "pageNum", "pageIndex", "scrollId", ]); const LIST_FILTER_KEYS = new Set([ "isDir", "isFolder", "folderOnly", "onlyFolder", "onlyDir", "dirOnly", "showDir", "showFolder", "type", "fileType", "resType", "resourceType", "mediaType", "category", "fileCategory", "sourceType", "kind", ]); const VIDEO_EXTS = new Set([ "mp4", "mkv", "avi", "mov", "m4v", "flv", "wmv", "rmvb", "rm", "ts", "webm", "mpeg", "mpg", "strm", ]); const COMMON_FILE_EXTS = new Set([ "srt", "ass", "ssa", "vtt", "sub", "idx", "nfo", "txt", "md", "json", "xml", "yaml", "yml", "jpg", "jpeg", "png", "gif", "webp", "bmp", "heic", "mp3", "flac", "aac", "wav", "m4a", "ogg", "zip", "rar", "7z", "tar", "gz", "bz2", "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", ]); const API_PATH_HINT_RE = /\/nd\.bizuserres\.s\/v1\//i; const CONFIG = { apiHost: "https://api.guangyapan.com", listPath: "/nd.bizuserres.s/v1/file/get_file_list", renamePath: "/nd.bizuserres.s/v1/file/rename", createDirPath: "/nd.bizuserres.s/v1/file/create_dir", movePath: "/nd.bizuserres.s/v1/file/move_file", deletePath: "/nd.bizuserres.s/v1/file/delete_file", taskStatusPath: "/nd.bizuserres.s/v1/get_task_status", tmdbHost: "https://api.tmdb.org/3", tmdbLanguage: "zh-CN", listPageSize: 1000, taskPollMs: 1400, taskPollMaxTries: 80, }; const STATE = { headers: { authorization: "", did: "", dt: "", }, lastApiHeaders: {}, lastListUrl: "", lastListBody: null, listByParent: {}, lastListItems: [], lastRenameRequest: null, lastCreateDirRequest: null, lastMoveRequest: null, lastDeleteRequest: null, ui: null, selectedItems: [], selectedTmdb: null, busy: false, }; function log(...args) { 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 deepClone(value) { return value && typeof value === "object" ? JSON.parse(JSON.stringify(value)) : {}; } 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 mergeCoreHeaders(headersLike) { const headers = sanitizeHeaders(headersLike); STATE.lastApiHeaders = { ...STATE.lastApiHeaders, ...headers, }; for (const key of ["authorization", "did", "dt"]) { if (headers[key]) { STATE.headers[key] = headers[key]; } } } 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 sanitizeListBody(body) { const source = body && typeof body === "object" ? body : {}; const out = {}; for (const [key, value] of Object.entries(source)) { if ( value == null || value === "" || TRANSIENT_KEYS.has(String(key)) ) { continue; } out[key] = value; } return out; } function getRequestHeaders() { const forwarded = {}; for (const key of [ "x-device-id", "x-requested-with", "appid", "timestamp", "signature", "nonce", ]) { if (STATE.lastApiHeaders[key]) { forwarded[key] = STATE.lastApiHeaders[key]; } } const headers = { accept: "application/json, text/plain, */*", "content-type": "application/json", ...forwarded, }; if (STATE.headers.authorization) { headers.authorization = STATE.headers.authorization; } if (STATE.headers.did) { headers.did = STATE.headers.did; } if (STATE.headers.dt) { headers.dt = STATE.headers.dt; } return headers; } function getListUrl() { const fallback = `${CONFIG.apiHost}${CONFIG.listPath}`; const captured = String(STATE.lastListUrl || "").trim(); if (!captured) { return fallback; } if (!isLikelyFileListUrl(captured)) { return fallback; } return captured; } function getRenameUrl() { return ( STATE.lastRenameRequest?.url || `${CONFIG.apiHost}${CONFIG.renamePath}` ); } function getCreateDirUrl() { return ( STATE.lastCreateDirRequest?.url || `${CONFIG.apiHost}${CONFIG.createDirPath}` ); } function getMoveUrl() { return ( STATE.lastMoveRequest?.url || `${CONFIG.apiHost}${CONFIG.movePath}` ); } function getDeleteUrl() { return `${CONFIG.apiHost}${CONFIG.deletePath}`; } function getTaskStatusUrl() { return `${CONFIG.apiHost}${CONFIG.taskStatusPath}`; } function isLikelyFileListUrl(url) { const text = String(url || "").toLowerCase(); if (!text) { return false; } if (text.includes(String(CONFIG.listPath || "").toLowerCase())) { return true; } return ( /\/file\/(?:get_)?file[_-]?list(?:\?|$)/i.test(text) || /\/list(?:\?|$)/i.test(text) ); } function isPlatformApiUrl(url) { const text = String(url || ""); if (!text) { return false; } return text.includes(CONFIG.apiHost) || API_PATH_HINT_RE.test(text); } function getGmXhr() { if (typeof GM_xmlhttpRequest === "function") { return GM_xmlhttpRequest; } if ( typeof GM === "object" && GM && typeof GM.xmlHttpRequest === "function" ) { return GM.xmlHttpRequest.bind(GM); } return null; } function requestViaGM(url, optionsLike = {}) { const gmXhr = getGmXhr(); if (!gmXhr) { return Promise.reject( new Error("当前脚本环境不支持 GM_xmlhttpRequest"), ); } const options = buildBridgeRequestOptions(optionsLike); const resolvedUrl = (() => { try { return new URL( String(url || ""), window.location.origin, ).toString(); } catch { return String(url || ""); } })(); return new Promise((resolve, reject) => { gmXhr({ method: String(options.method || "GET").toUpperCase(), url: resolvedUrl, headers: sanitizeHeaders(options.headers), data: typeof options.body === "string" ? options.body : undefined, timeout: 30000, responseType: "text", anonymous: false, withCredentials: true, onload: (resp) => { resolve({ ok: Number(resp?.status || 0) >= 200 && Number(resp?.status || 0) < 300, status: Number(resp?.status || 0), text: String(resp?.responseText || ""), via: "gm_xhr", }); }, onerror: (err) => { reject( new Error( `GM_xmlhttpRequest error: ${getErrorText(err) || "unknown error"}`, ), ); }, ontimeout: () => { reject( new Error(`GM_xmlhttpRequest timeout: ${resolvedUrl}`), ); }, }); }); } function buildBridgeRequestOptions(optionsLike) { const source = optionsLike && typeof optionsLike === "object" ? optionsLike : {}; const out = { method: String(source.method || "GET").toUpperCase(), }; const headers = sanitizeHeaders(source.headers); if (Object.keys(headers).length) { out.headers = headers; } if (typeof source.body === "string") { out.body = source.body; } const credentials = String(source.credentials || "").toLowerCase(); if (["include", "same-origin", "omit"].includes(credentials)) { out.credentials = credentials; } const mode = String(source.mode || "").toLowerCase(); if (mode) { out.mode = mode; } return out; } function injectRequestBridge() { if (window.__guangyaOrganizeRequestBridgeReady) { return; } const code = ` (() => { if (window.__guangyaOrganizeRequestBridgeReady) { return; } window.__guangyaOrganizeRequestBridgeReady = true; const REQUEST_EVENT = ${JSON.stringify(REQUEST_EVENT)}; const RESPONSE_EVENT = ${JSON.stringify(RESPONSE_EVENT)}; const originalFetch = window.fetch.bind(window); const normalizeHeaders = (headersLike) => { const out = {}; if (!headersLike) { return out; } if (headersLike instanceof Headers) { for (const [k, v] of headersLike.entries()) { out[String(k)] = String(v); } return out; } if (Array.isArray(headersLike)) { for (const [k, v] of headersLike) { out[String(k)] = String(v); } return out; } if (typeof headersLike === 'object') { for (const [k, v] of Object.entries(headersLike)) { out[String(k)] = String(v); } } return out; }; const emitResponse = (detail) => { window.dispatchEvent(new CustomEvent(RESPONSE_EVENT, { detail })); }; const requestViaXhr = (url, optionsLike) => new Promise((resolve, reject) => { const options = optionsLike && typeof optionsLike === 'object' ? optionsLike : {}; const xhr = new XMLHttpRequest(); const method = String(options.method || 'GET').toUpperCase(); xhr.open(method, String(url), true); xhr.withCredentials = String(options.credentials || '').toLowerCase() === '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 : String(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')); const body = typeof options.body === 'string' ? options.body : null; xhr.send(body); }); window.addEventListener(REQUEST_EVENT, async (event) => { const detail = event && event.detail ? event.detail : {}; if (!detail.requestId || !detail.url) { return; } const requestUrl = String(detail.url); const sourceOptions = detail.options && typeof detail.options === 'object' ? detail.options : {}; const requestOptions = { method: String(sourceOptions.method || 'GET').toUpperCase(), headers: normalizeHeaders(sourceOptions.headers), body: typeof sourceOptions.body === 'string' ? sourceOptions.body : undefined, credentials: sourceOptions.credentials || 'same-origin', mode: sourceOptions.mode || 'cors', }; try { const response = await originalFetch(requestUrl, requestOptions); const text = await response.text(); emitResponse({ requestId: detail.requestId, ok: response.ok, status: response.status, text, via: 'fetch', }); } 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), }); } 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), }); } } }); })(); `; const script = document.createElement("script"); script.textContent = code; ( document.documentElement || document.head || document.body ).appendChild(script); script.remove(); } function requestViaPage(url, optionsLike = {}) { injectRequestBridge(); if (!window.__guangyaOrganizeRequestBridgeReady) { throw new Error("页面请求桥接器未就绪"); } const requestId = `gyreq_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; const options = buildBridgeRequestOptions(optionsLike); return new Promise((resolve, reject) => { const timeoutMs = 35000; let done = false; let timer = null; const cleanup = () => { if (timer) { window.clearTimeout(timer); timer = null; } window.removeEventListener(RESPONSE_EVENT, onResponse); }; const finalize = (fn) => { if (done) { return; } done = true; cleanup(); fn(); }; const onResponse = (event) => { const detail = event && event.detail ? event.detail : {}; if (detail.requestId !== requestId) { return; } if (detail.error) { finalize(() => reject(new Error(String(detail.error)))); return; } finalize(() => { resolve({ ok: Boolean(detail.ok), status: Number(detail.status || 0), text: typeof detail.text === "string" ? detail.text : "", via: String(detail.via || ""), }); }); }; timer = window.setTimeout(() => { finalize(() => reject(new Error(`页面请求超时:${url}`))); }, timeoutMs); window.addEventListener(RESPONSE_EVENT, onResponse); window.dispatchEvent( new CustomEvent(REQUEST_EVENT, { detail: { requestId, url: String(url), options, }, }), ); }); } async function postJson(url, body) { const urlCandidates = Array.isArray(url) ? url : [url]; const requestOptions = { method: "POST", headers: getRequestHeaders(), mode: "cors", credentials: "include", body: JSON.stringify(body), }; const errors = []; for (const endpoint of urlCandidates) { if (!endpoint) { continue; } try { const gmRes = await requestViaGM(endpoint, requestOptions); return { ok: gmRes.ok, status: gmRes.status, text: gmRes.text, payload: safeJsonParse(gmRes.text), }; } catch (gmErr) { warn( `GM_xmlhttpRequest 失败:${getErrorText(gmErr)} | ${endpoint}`, ); } try { const response = await window.fetch(endpoint, requestOptions); const text = await response.text(); const payload = safeJsonParse(text); return { ok: response.ok, status: response.status, text, payload, }; } catch (fetchErr) { warn( `window.fetch 失败,改用页面上下文请求:${getErrorText(fetchErr)} | ${endpoint}`, ); try { const bridged = await requestViaPage( endpoint, requestOptions, ); const payload = safeJsonParse(bridged.text); return { ok: bridged.ok, status: bridged.status, text: bridged.text, payload, }; } catch (bridgeErr) { errors.push(`${endpoint}: ${getErrorText(bridgeErr)}`); } } } throw new Error(errors.join(" || ") || "请求失败"); } 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 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 isTaskFinished(payload) { const status = extractTaskStatus(payload); if (status) { const numeric = getNumericTaskStatus(status); if (numeric != null) { if ([2, 3, 4].includes(numeric)) return true; if ([0, 1].includes(numeric)) return false; } if ( [ "SUCCESS", "SUCCEEDED", "DONE", "FINISH", "FINISHED", "COMPLETED", "FAILED", "ERROR", "CANCEL", "成功", "完成", "失败", ].some((w) => status.includes(w)) ) { return true; } if ( [ "RUN", "PROCESS", "PENDING", "QUEUE", "WAIT", "进行", "处理中", "等待", ].some((w) => status.includes(w)) ) { return false; } } const finished = findFirstValueByKeys(payload, [ "finished", "done", "completed", "isFinished", ]); if (typeof finished === "boolean") { return finished; } return false; } function isTaskSuccessful(payload) { const status = extractTaskStatus(payload); if (status) { const numeric = getNumericTaskStatus(status); if (numeric != null) { if (numeric === 2) return true; if ([3, 4].includes(numeric)) return false; } if ( ["FAILED", "ERROR", "CANCEL", "CANCELLED", "失败"].some((w) => status.includes(w), ) ) { return false; } if ( [ "SUCCESS", "SUCCEEDED", "DONE", "FINISH", "FINISHED", "COMPLETED", "成功", "完成", ].some((w) => status.includes(w)) ) { return true; } } const success = findFirstValueByKeys(payload, ["success", "ok"]); if (typeof success === "boolean") { return success; } return true; } function isProbablySuccess(payload, response) { if (!response.ok) { return false; } if (!payload || typeof payload !== "object") { return true; } if (payload.success === false || 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; } 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); } async function ensureTaskDoneIfNeeded(response, progressText = "") { if (!isProbablySuccess(response.payload, response)) { throw new Error( getErrorText( response.payload || response.text || `HTTP ${response.status}`, ), ); } const taskId = extractTaskId(response.payload); if (!taskId) { return; } for (let i = 1; i <= CONFIG.taskPollMaxTries; i += 1) { const taskRes = await postJson(getTaskStatusUrl(), { taskId }); if (!taskRes.ok) { throw new Error(`任务查询失败 HTTP ${taskRes.status}`); } if (isTaskFinished(taskRes.payload)) { if (!isTaskSuccessful(taskRes.payload)) { throw new Error( `${progressText || "任务"}失败:${getErrorText(taskRes.payload) || "未知原因"}`, ); } return; } await sleep(CONFIG.taskPollMs); } throw new Error(`${progressText || "任务"}超时:taskId=${taskId}`); } function extractFileExt(name) { const text = String(name || ""); const idx = text.lastIndexOf("."); if (idx <= 0) { return ""; } return text.slice(idx + 1).toLowerCase(); } function getItemVideoExt(item) { const nameExt = extractFileExt(item?.name || ""); if (nameExt) { return nameExt; } const raw = item?.raw || {}; const rawExt = String( raw.ext || raw.suffix || raw.fileSuffix || raw.fileExt || raw.extension || raw.file_extension || "", ) .replace(/^\./, "") .toLowerCase(); return rawExt; } function parseResTypeValue(value) { if (value == null || value === "") { return null; } if (typeof value === "number" && Number.isFinite(value)) { const n = Math.trunc(value); return n === 1 || n === 2 ? n : null; } const text = String(value).trim(); if (!text) { return null; } if (/^\d+$/.test(text)) { const n = Number(text); return n === 1 || n === 2 ? n : null; } const m = text.match(/\b([12])\b/); if (m) { return Number(m[1]); } return null; } function getResTypeFromRaw(raw) { const obj = raw && typeof raw === "object" ? raw : {}; const candidates = [ obj.resType, obj.res_type, obj.resourceType, obj.resource_type, obj.fileType, obj.file_type, obj.type, ]; for (const val of candidates) { const parsed = parseResTypeValue(val); if (parsed != null) { return parsed; } } return null; } function isFolderByResType(raw) { return getResTypeFromRaw(raw) === 2; } function isFileByResType(raw) { return getResTypeFromRaw(raw) === 1; } function isVideoItem(item) { if (!item) return false; if (isFolderByResType(item.raw || {})) return false; const ext = getItemVideoExt(item); if (ext && VIDEO_EXTS.has(ext)) { return true; } if (item.isFolder) return false; const typeText = String( item.raw?.mediaType || item.raw?.fileType || item.raw?.type || item.raw?.mimeType || item.raw?.mime || "", ).toLowerCase(); return /(video|视频|影片)/.test(typeText); } function normalizeDomText(text) { return String(text || "") .replace(/\s+/g, " ") .trim(); } function isProbablyMetadataText(text) { const value = normalizeDomText(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) ); } function collectTextCandidates(row) { const out = new Set(); const push = (value) => { const text = normalizeDomText(value); if (!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 chooseBestName(candidates) { const items = Array.from( new Set( (candidates || []) .map((x) => normalizeDomText(x)) .filter(Boolean), ), ); if (!items.length) { return ""; } items.sort((a, b) => b.length - a.length); return items[0]; } function extractNameFromRow(row) { return chooseBestName(collectTextCandidates(row)); } function extractFileIdFromNode(node) { if (!node || typeof node !== "object") { return ""; } const candidates = [ node.getAttribute && node.getAttribute("data-id"), node.getAttribute && node.getAttribute("data-file-id"), node.getAttribute && node.getAttribute("data-fileid"), node.getAttribute && node.getAttribute("data-resource-id"), node.getAttribute && node.getAttribute("data-res-id"), node.getAttribute && node.getAttribute("data-key"), node.getAttribute && node.getAttribute("data-row-key"), node.getAttribute && node.getAttribute("id"), node.getAttribute && node.getAttribute("value"), node.dataset && node.dataset.id, node.dataset && node.dataset.fileId, node.dataset && node.dataset.fileid, node.dataset && node.dataset.resourceId, node.dataset && node.dataset.resId, node.dataset && node.dataset.key, node.dataset && node.dataset.rowKey, ]; for (const raw of candidates) { const id = String(raw || "").trim(); if (!id) { continue; } const num = id.match(/\d{8,}/); if (num) { return num[0]; } if (/^[A-Za-z0-9_-]{8,}$/.test(id)) { return id; } } return ""; } function extractResTypeFromNode(node) { if (!node || typeof node !== "object") { return null; } const candidates = [ node.getAttribute && node.getAttribute("data-res-type"), node.getAttribute && node.getAttribute("data-restype"), node.getAttribute && node.getAttribute("data-resource-type"), node.getAttribute && node.getAttribute("data-resourceType"), node.getAttribute && node.getAttribute("data-file-type"), node.getAttribute && node.getAttribute("data-fileType"), node.getAttribute && node.getAttribute("data-type"), node.dataset && node.dataset.resType, node.dataset && node.dataset.restype, node.dataset && node.dataset.resourceType, node.dataset && node.dataset.fileType, node.dataset && node.dataset.type, ]; for (const raw of candidates) { const parsed = parseResTypeValue(raw); if (parsed != null) { return parsed; } } return null; } function getRowSelector() { return [ '[role="row"]', "tr", "li", '[class*="row"]', '[class*="item"]', '[class*="file"]', ].join(", "); } function resolveListParentId() { const body = STATE.lastListBody && typeof STATE.lastListBody === "object" ? STATE.lastListBody : {}; return String(body.parentId || "").trim(); } function getCapturedItemsForCurrentParent() { const parentId = resolveListParentId(); if (parentId && STATE.listByParent[parentId]) { return STATE.listByParent[parentId]; } return STATE.lastListItems || []; } function collectSelectedRows() { const checkedNodes = new Set(); const isNodeInsideModal = (node) => Boolean(node && node.closest && node.closest(`#${MODAL_ID}`)); const canUseCheckedNode = (node) => { if (!node || isNodeInsideModal(node)) { return false; } if ( node instanceof HTMLInputElement && String(node.type || "").toLowerCase() === "radio" ) { return false; } return true; }; document .querySelectorAll('input[type="checkbox"]:checked') .forEach((node) => { if (canUseCheckedNode(node)) { checkedNodes.add(node); } }); document .querySelectorAll('[role="checkbox"][aria-checked="true"]') .forEach((node) => { if (canUseCheckedNode(node)) { checkedNodes.add(node); } }); document .querySelectorAll( '[class*="checkbox"][class*="checked"], [class*="check"][class*="checked"]', ) .forEach((node) => { if (canUseCheckedNode(node)) { checkedNodes.add(node); } }); const rows = []; const seen = new Set(); for (const node of checkedNodes) { const row = node.closest ? node.closest(getRowSelector()) : null; if (!row || isNodeInsideModal(row) || seen.has(row)) { continue; } seen.add(row); rows.push(row); } return rows; } function collectSelectedItemsFromPage() { const rows = collectSelectedRows(); const capturedItems = getCapturedItemsForCurrentParent(); const byId = new Map(); const byName = new Map(); for (const item of capturedItems) { const id = String(item.fileId || ""); const name = normalizeDomText(item.name); if (id && !byId.has(id)) { byId.set(id, item); } if (name && !byName.has(name)) { byName.set(name, item); } } const result = []; const seenKey = new Set(); for (const row of rows) { let fileId = extractFileIdFromNode(row); let rowResType = extractResTypeFromNode(row); if (!fileId) { const desc = Array.from( row.querySelectorAll( "[data-id],[data-file-id],[data-row-key],[data-res-type],[data-restype],[data-type]", ), ); for (const node of desc) { fileId = extractFileIdFromNode(node); if (rowResType == null) { rowResType = extractResTypeFromNode(node); } if (fileId) break; } } const name = extractNameFromRow(row); const matchedById = fileId ? byId.get(fileId) : null; const matchedByName = !matchedById && name ? byName.get(normalizeDomText(name)) : null; let matchedByLoose = null; if (!matchedById && !matchedByName && name) { const rowKey = normalizeNameForMatch(name); if (rowKey) { matchedByLoose = capturedItems.find((item) => { const itemKey = normalizeNameForMatch(item.name); return ( itemKey && (rowKey.includes(itemKey) || itemKey.includes(rowKey)) ); }) || null; } } const matched = matchedById || matchedByName || matchedByLoose || null; const resolvedId = fileId || String(matched?.fileId || ""); const resolvedName = String(matched?.name || name || ""); const key = `${resolvedId}|${resolvedName}`; if (!resolvedName || seenKey.has(key)) { continue; } seenKey.add(key); const item = { fileId: resolvedId, name: resolvedName, parentId: String( matched?.parentId || matched?.raw?.parentId || matched?.raw?.pid || "", ), raw: matched?.raw || (rowResType != null ? { resType: rowResType } : null), isFolder: (() => { const matchedResType = getResTypeFromRaw( matched?.raw || {}, ); const effectiveResType = matchedResType != null ? matchedResType : rowResType; if (effectiveResType === 2) { return true; } if (effectiveResType === 1) { return false; } return ( Boolean(matched?.isFolder) || (!extractFileExt(resolvedName) && !isVideoItem({ name: resolvedName, isFolder: false, raw: matched?.raw || {}, })) ); })(), }; result.push(item); } return result; } function refreshSelectedItems(options = {}) { const keepPreviousIfEmpty = options.keepPreviousIfEmpty !== false; const latest = collectSelectedItemsFromPage(); if (latest.length || !keepPreviousIfEmpty) { STATE.selectedItems = latest; } renderSelectedItems(STATE.selectedItems || []); return STATE.selectedItems; } 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.file_id ?? obj.resource_id ?? obj.res_id ?? obj.obj_id ?? obj.fid ?? obj.fsId ?? obj.fileID; const name = chooseBestName([ obj.name, obj.fileName, obj.file_name, obj.filename, obj.fname, obj.fileTitle, obj.file_title, obj.resName, obj.res_name, obj.resourceName, obj.resource_name, obj.title, obj.objName, obj.obj_name, obj.displayName, obj.display_name, obj.originalName, obj.original_name, obj.fileFullName, obj.fullName, obj.pathName, obj.path_name, ]); if (!(typeof fileId === "string" || typeof fileId === "number")) { return null; } const hasKnownNameField = [ "name", "fileName", "file_name", "filename", "fname", "resName", "res_name", "resourceName", "resource_name", "title", "objName", "obj_name", "displayName", "display_name", "originalName", "original_name", "fileFullName", "fullName", "pathName", "path_name", ].some((k) => Object.prototype.hasOwnProperty.call(obj, k)); if (!name || !hasKnownNameField) { return null; } const raw = obj; const resType = getResTypeFromRaw(raw); const folderFlagValue = raw.isDir ?? raw.isFolder ?? raw.folder ?? raw.isDirectory ?? raw.dir ?? raw.is_dir ?? raw.is_folder; const folderFlag = folderFlagValue === true || folderFlagValue === "true" || Number(folderFlagValue) === 1; const typeText = String( raw.type || raw.fileType || raw.resType || raw.resourceType || raw.kind || raw.category || "", ).toLowerCase(); const maybeFileExt = getItemVideoExt({ name, raw }); const isFolder = resType === 2 || (resType === 1 ? false : (folderFlag && !maybeFileExt) || ["folder", "dir", "directory"].includes(typeText) || raw.childrenCount != null || raw.childCount != null || raw.dirCount != null); return { fileId: String(fileId), name, parentId: String( raw.parentId || raw.parent_id || raw.pid || raw.pId || raw.dirId || raw.folderId || "", ), isFolder, raw, }; } 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 scoreItemQuality(item) { if (!item) { return -1; } let score = 0; if (item.fileId) score += 2; if (item.parentId) score += 1; if (item.name && item.name.length > 1) score += 2; if (extractFileExt(item.name)) score += 2; if ( item.raw && (item.raw.fileName || item.raw.file_name || item.raw.resName || item.raw.resourceName) ) score += 1; if ( item.raw && (item.raw.parentId || item.raw.pid || item.raw.parent_id) ) score += 1; if ( item.raw && (item.raw.size != null || item.raw.fileSize != null || item.raw.resourceSize != null) ) score += 1; return score; } function dedupeItems(items) { const byId = new Map(); for (const item of items || []) { if (!item || !item.fileId) { continue; } const key = String(item.fileId); const prev = byId.get(key); if (!prev || scoreItemQuality(item) > scoreItemQuality(prev)) { byId.set(key, item); } } return Array.from(byId.values()); } function extractItemsFromPayload(payload) { const nodes = []; const pushArrayNode = (arr) => { if (Array.isArray(arr) && arr.length) { nodes.push(arr); } }; const data = payload && typeof payload === "object" ? payload.data || payload.result || payload.payload || payload : {}; pushArrayNode(payload && payload.list); pushArrayNode(payload && payload.records); pushArrayNode(payload && payload.rows); pushArrayNode(data && data.list); pushArrayNode(data && data.records); pushArrayNode(data && data.rows); pushArrayNode(data && data.fileList); pushArrayNode(data && data.items); pushArrayNode(data && data.children); let items = []; for (const node of nodes) { items = items.concat(scanItems(node, [])); } if (!items.length) { items = scanItems(payload, []); } return dedupeItems(items); } 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 mergeCapturedList(parentId, items) { const key = String(parentId || "").trim(); const merged = dedupeItems([ ...(STATE.listByParent[key] || []), ...(items || []), ]); STATE.listByParent[key] = merged; STATE.lastListItems = merged; } function handleCapture(detail) { if (!detail || typeof detail !== "object") { return; } const url = String(detail.url || ""); if (!isPlatformApiUrl(url)) { return; } mergeCoreHeaders(detail.headers); const requestBody = safeJsonParse(detail.requestBody); const responseBody = safeJsonParse(detail.responseText); if ( looksLikeListRequest(url, requestBody) || looksLikeListResponse(responseBody) ) { if (requestBody && typeof requestBody === "object") { STATE.lastListBody = sanitizeListBody(requestBody); } STATE.lastListUrl = url; const items = extractItemsFromPayload(responseBody); if (items.length) { const parentId = String( requestBody?.parentId || STATE.lastListBody?.parentId || "", ); mergeCapturedList(parentId, items); } } if (url.includes(CONFIG.renamePath) || /\/rename(?:\?|$)/i.test(url)) { STATE.lastRenameRequest = { url, headers: sanitizeHeaders(detail.headers), requestBody, }; } if ( url.includes(CONFIG.createDirPath) || /\/create[_-]?(dir|folder)(?:\?|$)/i.test(url) ) { STATE.lastCreateDirRequest = { url, headers: sanitizeHeaders(detail.headers), requestBody, }; } if ( url.includes(CONFIG.movePath) || /\/move(?:[_-]?(file|dir|folder))?(?:\?|$)/i.test(url) ) { STATE.lastMoveRequest = { url, headers: sanitizeHeaders(detail.headers), requestBody, }; } if ( url.includes(CONFIG.deletePath) || /\/(?:delete|remove|recycle)(?:[_-]?(file|dir|folder|batch))?(?:\?|$)/i.test( url, ) ) { STATE.lastDeleteRequest = { url, headers: sanitizeHeaders(detail.headers), requestBody, }; } } function installCaptureHooks() { if (window.__guangyaOrganizeCaptureInstalled) { return; } window.__guangyaOrganizeCaptureInstalled = true; const rawFetch = window.fetch.bind(window); window.fetch = async function patchedFetch(input, init) { const url = typeof input === "string" ? input : (input && input.url) || ""; const headers = sanitizeHeaders( (init && init.headers) || (input && input.headers), ); const requestBody = init && typeof init.body === "string" ? init.body : ""; const response = await rawFetch(input, init); if (isPlatformApiUrl(url)) { try { const cloned = response.clone(); const text = await cloned.text(); handleCapture({ type: "fetch", url, headers, requestBody, responseText: text, status: response.status, }); } catch (err) { warn("捕获 fetch 失败:", 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.__guangyaCapture = { method, url, headers: {}, requestBody: "", }; return rawOpen.apply(this, arguments); }; XMLHttpRequest.prototype.setRequestHeader = function patchedSetHeader( name, value, ) { if (this.__guangyaCapture) { this.__guangyaCapture.headers[String(name).toLowerCase()] = value; } return rawSetHeader.apply(this, arguments); }; XMLHttpRequest.prototype.send = function patchedSend(body) { if (this.__guangyaCapture && typeof body === "string") { this.__guangyaCapture.requestBody = body; } this.addEventListener("load", function onLoad() { const detail = this.__guangyaCapture || {}; const url = this.responseURL || detail.url || ""; if (!isPlatformApiUrl(url)) { return; } handleCapture({ type: "xhr", url, headers: detail.headers || {}, requestBody: detail.requestBody || "", responseText: this.responseText || "", status: this.status, }); }); return rawSend.apply(this, arguments); }; } function resolveListBody(parentId) { const base = sanitizeListBody(STATE.lastListBody || {}); const body = { ...base, parentId: String(parentId || base.parentId || ""), pageSize: Number(base.pageSize || CONFIG.listPageSize), }; if (!body.pageSize || body.pageSize < 1) { body.pageSize = CONFIG.listPageSize; } if (Number(body.pageSize) > CONFIG.listPageSize) { body.pageSize = CONFIG.listPageSize; } if (body.orderBy == null || body.orderBy === "") { body.orderBy = 3; } if (body.sortType == null || body.sortType === "") { body.sortType = 1; } return sanitizeListBody(body); } function stripObjectKeys(source, keysSet) { const out = {}; for (const [key, value] of Object.entries(source || {})) { if (keysSet && keysSet.has(String(key))) { continue; } out[key] = value; } return out; } function pickObjectKeys(source, allowList) { const out = {}; for (const key of allowList || []) { if ( source && Object.prototype.hasOwnProperty.call(source, key) && source[key] != null && source[key] !== "" ) { out[key] = source[key]; } } return out; } function buildListBodyVariants(parentId) { const normalizedParentId = String(parentId || "").trim(); const primary = resolveListBody(normalizedParentId); const variants = []; const push = (body) => { const sanitized = sanitizeListBody(body); if (!sanitized.parentId) { sanitized.parentId = normalizedParentId; } if (!sanitized.pageSize || Number(sanitized.pageSize) < 1) { sanitized.pageSize = CONFIG.listPageSize; } if (Number(sanitized.pageSize) > CONFIG.listPageSize) { sanitized.pageSize = CONFIG.listPageSize; } if (sanitized.orderBy == null || sanitized.orderBy === "") { sanitized.orderBy = 3; } if (sanitized.sortType == null || sanitized.sortType === "") { sanitized.sortType = 1; } const key = JSON.stringify(sanitized); if (!variants.some((x) => x.__key === key)) { variants.push({ ...sanitized, __key: key }); } }; push({ parentId: normalizedParentId, pageSize: CONFIG.listPageSize, orderBy: 3, sortType: 1, }); push(primary); push({ ...stripObjectKeys(primary, LIST_FILTER_KEYS), parentId: normalizedParentId, pageSize: Number(primary.pageSize || CONFIG.listPageSize), orderBy: primary.orderBy ?? 3, sortType: primary.sortType ?? 1, }); push({ ...pickObjectKeys(primary, [ "shareId", "shareFileId", "spaceId", "driveId", "bizType", "scene", "sceneType", ]), parentId: normalizedParentId, pageSize: Number(primary.pageSize || CONFIG.listPageSize), orderBy: primary.orderBy ?? 3, sortType: primary.sortType ?? 1, }); return variants.map((x) => { const copy = { ...x }; delete copy.__key; return copy; }); } async function fetchListByParent(parentId) { const normalizedParentId = String(parentId || "").trim(); if (!normalizedParentId) { throw new Error("缺少 parentId,先打开目标目录让脚本捕获列表请求"); } const bodies = buildListBodyVariants(normalizedParentId); let bestItems = []; const errors = []; for (const body of bodies) { try { const res = await postJson(getListUrl(), body); if (!res.ok) { errors.push(`HTTP ${res.status}`); continue; } const items = extractItemsFromPayload(res.payload); if (items.length) { mergeCapturedList(normalizedParentId, items); } if (items.length > bestItems.length) { bestItems = items; } if (items.some((item) => isVideoItem(item))) { return items; } } catch (err) { errors.push(getErrorText(err)); } } if (bestItems.length) { return bestItems; } const captured = (STATE.listByParent[normalizedParentId] || []).filter( Boolean, ); if (captured.length) { return captured; } if (errors.length) { throw new Error(`获取列表失败:${errors[0]}`); } return []; } function buildRenamePayload(fileId, newName) { const base = deepClone(STATE.lastRenameRequest?.requestBody); const payload = {}; const idKey = pickExistingKey( base, ["fileId", "id", "resourceId", "resId", "bizId", "objId"], "fileId", ); const nameKey = pickExistingKey( base, ["newName", "name", "fileName", "file_name", "filename", "title"], "newName", ); for (const key of [ "parentId", "shareId", "shareFileId", "spaceId", "driveId", "folderId", "resourceType", "resType", "fileType", "bizType", ]) { if (base[key] != null && base[key] !== "") { payload[key] = base[key]; } } payload[idKey] = String(fileId); payload[nameKey] = String(newName); return payload; } function buildCreateDirPayload(parentId, dirName) { const base = deepClone(STATE.lastCreateDirRequest?.requestBody); const payload = {}; const parentKey = pickExistingKey( base, ["parentId", "pid", "folderId", "targetParentId", "toParentId"], "parentId", ); const nameKey = pickExistingKey( base, [ "dirName", "name", "folderName", "newFolderName", "fileName", "title", ], "dirName", ); for (const key of [ "shareId", "shareFileId", "spaceId", "driveId", "resourceType", "resType", "bizType", ]) { if (base[key] != null && base[key] !== "") { payload[key] = base[key]; } } payload[parentKey] = String(parentId); payload[nameKey] = String(dirName); return payload; } function normalizeFileIds(fileIdsLike) { const list = Array.isArray(fileIdsLike) ? fileIdsLike : [fileIdsLike]; const out = []; const seen = new Set(); for (const raw of list) { const id = String(raw || "").trim(); if (!id || seen.has(id)) { continue; } seen.add(id); out.push(id); } return out; } function buildMovePayloadVariants( fileIdsLike, targetParentId, sourceParentId = "", ) { const target = String(targetParentId || "").trim(); const ids = normalizeFileIds(fileIdsLike); const firstId = ids[0] || ""; const buildCanonicalPayload = (idKey, targetKey) => { const payload = {}; if ( idKey === "fileIds" || idKey === "ids" || idKey === "resourceIds" || idKey === "resIds" || idKey === "objIds" ) { payload[idKey] = ids; } else { payload[idKey] = firstId; } payload[targetKey] = target; return payload; }; const rawVariants = []; // 先命中你提供的真实格式:{"fileIds":[...], "parentId":"目标目录ID"} rawVariants.push(buildCanonicalPayload("fileIds", "parentId")); rawVariants.push(buildCanonicalPayload("ids", "parentId")); rawVariants.push(buildCanonicalPayload("resourceIds", "parentId")); rawVariants.push(buildCanonicalPayload("objIds", "parentId")); // 兜底:其它常见目标字段 rawVariants.push(buildCanonicalPayload("fileIds", "toParentId")); rawVariants.push(buildCanonicalPayload("fileIds", "targetParentId")); rawVariants.push(buildCanonicalPayload("fileIds", "destParentId")); rawVariants.push(buildCanonicalPayload("fileIds", "folderId")); rawVariants.push(buildCanonicalPayload("fileId", "parentId")); const keysSeen = new Set(); const out = []; for (const payload of rawVariants) { const key = JSON.stringify(payload); if (keysSeen.has(key)) { continue; } keysSeen.add(key); out.push(payload); } return out; } function buildDeletePayload(fileIdsLike) { const ids = normalizeFileIds(fileIdsLike); return { fileIds: ids }; } async function renameFile(fileId, newName) { const response = await postJson( getRenameUrl(), buildRenamePayload(fileId, newName), ); await ensureTaskDoneIfNeeded(response, "重命名"); return response; } async function createDir(parentId, dirName) { const response = await postJson( getCreateDirUrl(), buildCreateDirPayload(parentId, dirName), ); await ensureTaskDoneIfNeeded(response, "创建目录"); return response; } async function moveFiles(fileIdsLike, targetParentId, sourceParentId = "") { const ids = normalizeFileIds(fileIdsLike); if (!ids.length) { return null; } const variants = buildMovePayloadVariants( ids, targetParentId, sourceParentId, ); let lastError = null; let lastResponse = null; let lastPayloadKeys = ""; const expectedFileSet = new Set(ids); const expectedParentId = String(targetParentId || ""); const verifyMoved = async () => { for (let i = 0; i < 3; i += 1) { let items = []; try { items = await fetchListByParent(expectedParentId); } catch (err) { warn(`移动后校验读取目录失败:${getErrorText(err)}`); } if (Array.isArray(items)) { const present = new Set( items.map((item) => String(item.fileId || "")), ); const allFound = Array.from(expectedFileSet).every((id) => present.has(id), ); if (allFound) { return true; } } const captured = ( STATE.listByParent[expectedParentId] || [] ).filter(Boolean); if (captured.length) { const present = new Set( captured.map((item) => String(item.fileId || "")), ); const allFound = Array.from(expectedFileSet).every((id) => present.has(id), ); if (allFound) { return true; } } await sleep(450); } return false; }; for (const payload of variants) { try { const response = await postJson(getMoveUrl(), payload); lastResponse = response; await ensureTaskDoneIfNeeded(response, "移动文件"); const movedOk = await verifyMoved(); if (movedOk) { return response; } warn( `移动返回成功但目标目录未找到文件,继续尝试其它参数:count=${ids.length} target=${expectedParentId}`, ); } catch (err) { lastError = err; lastPayloadKeys = Object.keys(payload).sort().join(","); warn( `移动参数尝试失败:${getErrorText(err)} | payload=${JSON.stringify(payload)}`, ); } } if (lastError) { const msg = getErrorText(lastError); throw new Error( lastPayloadKeys ? `${msg} (move字段=${lastPayloadKeys})` : msg, ); } throw new Error(getErrorText(lastResponse?.payload) || "移动文件失败"); } async function moveFile(fileId, targetParentId, sourceParentId = "") { return moveFiles([fileId], targetParentId, sourceParentId); } async function deleteFiles(fileIdsLike, parentId = "") { const ids = normalizeFileIds(fileIdsLike); if (!ids.length) { return null; } const payload = buildDeletePayload(ids); const verifyDeleted = async () => { if (!parentId) { return true; } const expected = new Set(ids); for (let i = 0; i < 3; i += 1) { let items = []; try { items = await fetchListByParent(parentId); } catch (err) { warn(`删除后校验读取目录失败:${getErrorText(err)}`); } if (Array.isArray(items)) { const present = new Set( items.map((item) => String(item.fileId || "")), ); const allRemoved = Array.from(expected).every( (id) => !present.has(id), ); if (allRemoved) { return true; } } await sleep(450); } return false; }; try { const response = await postJson(getDeleteUrl(), payload); await ensureTaskDoneIfNeeded(response, "删除文件"); const removedOk = await verifyDeleted(); if (removedOk) { return response; } throw new Error("删除返回成功但目录仍可见"); } catch (err) { const msg = getErrorText(err); throw new Error( `${msg}。删除接口目前固定为 /file/delete_file,参数固定为 {"fileIds":[...]}`, ); } } function extractCreatedId(payload) { const val = findFirstValueByKeys(payload, [ "folderId", "newFolderId", "fileId", "resourceId", "resId", "objId", "id", ]); return val == null ? "" : String(val); } function pad2(value) { return String(Number(value || 0)).padStart(2, "0"); } function extractResolution(name) { const match = String(name || "").match( /(4320p|2160p|2060p|1440p|1080p|720p|480p|8k|4k)/i, ); return match ? match[1].toLowerCase() : ""; } function extractQualityTags(name) { const text = String(name || ""); if (!text) { return []; } const tagRules = [ { tag: "DV", re: /\b(?:dolby[\s.\-]*vision|dovi|dv)\b/i }, { tag: "HDR10+", re: /\bhdr10\+\b/i }, { tag: "HDR10", re: /\bhdr10\b/i }, { tag: "HDR", re: /\bhdr\b/i }, { tag: "BluRay", re: /\bblu[\s.\-]*ray\b/i }, { tag: "BDRemux", re: /\bbd[\s.\-]*remux\b/i }, { tag: "BDRip", re: /\bbd[\s.\-]*rip\b/i }, { tag: "WEB-DL", re: /\bweb[\s.\-]*dl\b/i }, { tag: "WEBRip", re: /\bweb[\s.\-]*rip\b/i }, { tag: "UHD", re: /\buhd\b/i }, ]; const out = []; const seen = new Set(); for (const rule of tagRules) { if (rule.re.test(text) && !seen.has(rule.tag)) { seen.add(rule.tag); out.push(rule.tag); } } return out; } function buildVideoExtraPart(resolution, qualityTags) { const res = String(resolution || "").trim(); const tags = Array.isArray(qualityTags) ? Array.from( new Set( qualityTags .map((x) => String(x || "").trim()) .filter(Boolean), ), ) : []; if (res && tags.length) { return `${res}.${tags.join(".")}`; } if (res) { return res; } if (tags.length) { return tags.join("."); } return ""; } function toHalfWidthDigits(text) { return String(text || "").replace(/[0-9]/g, (ch) => String(ch.charCodeAt(0) - 65248), ); } function parseChineseNumber(raw) { const text = String(raw || "").trim(); if (!text) { return NaN; } const normalized = toHalfWidthDigits(text).replace(/\s+/g, ""); if (/^\d+$/.test(normalized)) { return Number(normalized); } const map = { 零: 0, 〇: 0, 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9, }; const unitMap = { 十: 10, 百: 100, 千: 1000 }; const chars = Array.from(normalized); let total = 0; let section = 0; let number = 0; for (const ch of chars) { if (Object.prototype.hasOwnProperty.call(map, ch)) { number = map[ch]; continue; } if (Object.prototype.hasOwnProperty.call(unitMap, ch)) { const unit = unitMap[ch]; section += (number || 1) * unit; number = 0; continue; } return NaN; } total += section + number; return total; } function parseSeasonEpisode(name) { const text = String(name || ""); const sxe = text.match(/S\s*([0-9]{1,2})\s*E\s*([0-9]{1,})/i); if (sxe) { return { season: Number(sxe[1]), episode: Number(sxe[2]), }; } const seasonMatch = text.match( /(?:第\s*([0-90-9一二三四五六七八九十两零〇]{1,3})\s*季|Season\s*([0-9]{1,2}))/iu, ); const episodeMatch = text.match( /(?:第\s*([0-90-9]{1,})\s*[集话]|EP?\s*([0-9]{1,}))/iu, ); let season = null; let episode = null; if (seasonMatch) { season = parseChineseNumber( (seasonMatch[1] || seasonMatch[2] || "").replace(/\s+/g, ""), ); if (!Number.isFinite(season) || season <= 0) { season = 1; } } if (episodeMatch) { episode = Number( (episodeMatch[1] || episodeMatch[2] || "").replace( /[^0-9]/g, "", ), ); if (!Number.isFinite(episode) || episode <= 0) { episode = null; } } // 兼容纯数字文件名:01.mp4 / 1.mkv -> E01 / E01 if (!episode) { const pureEpisode = text.match( /^\s*0*([1-9][0-9]{0,2})\s*(?:\.[^./\\]+)?\s*$/, ); if (pureEpisode) { const value = Number(pureEpisode[1]); if (Number.isFinite(value) && value > 0) { episode = value; } } } return { season: Number.isFinite(season) && season > 0 ? season : null, episode, }; } function inferSeasonFromFolderName(name) { const text = String(name || ""); const parsed = parseSeasonEpisode(text); if (Number.isFinite(parsed.season) && parsed.season > 0) { return parsed.season; } const sMatch = text.match(/\bS\s*([0-9]{1,2})\b/i); if (sMatch) { const value = Number(sMatch[1]); if (Number.isFinite(value) && value > 0) { return value; } } const seasonMatch = text.match(/season\s*([0-9]{1,2})/i); if (seasonMatch) { const value = Number(seasonMatch[1]); if (Number.isFinite(value) && value > 0) { return value; } } const zhSeason = text.match( /第\s*([0-90-9一二三四五六七八九十百千两零〇]{1,4})\s*季/u, ); if (zhSeason) { const value = parseChineseNumber(zhSeason[1]); if (Number.isFinite(value) && value > 0) { return value; } } // 兼容纯数字目录:01 / 1 / 002 -> S01 / S01 / S02 const pureSeason = normalizeDomText(text).match( /^0*([1-9][0-9]{0,2})$/, ); if (pureSeason) { const value = Number(pureSeason[1]); if (Number.isFinite(value) && value > 0) { return value; } } // 兼容目录前缀季号:01 xxxx / 02-字幕组 / 03_SeasonName const prefixSeason = normalizeDomText(text).match( /^0*([1-9][0-9]{0,2})(?=\s|[._\-])/, ); if (prefixSeason) { const value = Number(prefixSeason[1]); if (Number.isFinite(value) && value > 0) { return value; } } return 1; } function parseSeasonFolderNumber(label) { const text = normalizeDomText(label).toUpperCase().replace(/\s+/g, ""); if (!text) { return null; } let match = text.match(/^S0*([0-9]{1,3})$/); if (match) { const value = Number(match[1]); return Number.isFinite(value) && value > 0 ? value : null; } match = text.match(/^SEASON0*([0-9]{1,3})$/); if (match) { const value = Number(match[1]); return Number.isFinite(value) && value > 0 ? value : null; } match = normalizeDomText(label).match( /^第\s*([0-90-9一二三四五六七八九十百千两零〇]{1,4})\s*季$/u, ); if (match) { const value = parseChineseNumber(match[1]); return Number.isFinite(value) && value > 0 ? value : null; } match = normalizeDomText(label).match(/^0*([1-9][0-9]{0,2})$/); if (match) { const value = Number(match[1]); return Number.isFinite(value) && value > 0 ? value : null; } match = normalizeDomText(label).match( /^0*([1-9][0-9]{0,2})(?=\s|[._\-])/, ); if (match) { const value = Number(match[1]); return Number.isFinite(value) && value > 0 ? value : null; } return null; } function inferSeasonFromItemMeta(item) { const raw = item?.raw && typeof item.raw === "object" ? item.raw : {}; const numericSeasonKeys = [ "season", "seasonNo", "season_num", "seasonNumber", "season_index", ]; for (const key of numericSeasonKeys) { const value = Number(raw[key]); if (Number.isFinite(value) && value > 0) { return Math.trunc(value); } } const textSeasonFields = [ raw.parentName, raw.parent_name, raw.dirName, raw.dir_name, raw.folderName, raw.folder_name, ]; for (const value of textSeasonFields) { const season = parseSeasonFolderNumber(value); if (Number.isFinite(season) && season > 0) { return season; } } const pathCandidates = [ raw.pathName, raw.path_name, raw.path, raw.fullPath, raw.full_path, raw.filePath, raw.file_path, raw.parentPath, raw.parent_path, raw.dirPath, raw.dir_path, raw.folderPath, raw.folder_path, raw.objectPath, raw.object_path, item?.name, ]; for (const value of pathCandidates) { const text = String(value || "").trim(); if (!text) { continue; } const normalizedPath = text.replace(/\\/g, "/"); const parts = normalizedPath .split("/") .map((x) => normalizeDomText(x)) .filter(Boolean); if (!parts.length) { continue; } let end = parts.length; if (extractFileExt(parts[end - 1])) { end -= 1; } for (let i = end - 1; i >= 0; i -= 1) { const season = parseSeasonFolderNumber(parts[i]); if (Number.isFinite(season) && season > 0) { return season; } } } return 0; } function scoreSeasonFolderName(name, seasonNo) { const normalized = normalizeDomText(name) .toUpperCase() .replace(/\s+/g, ""); const target = String(Number(seasonNo || 0)); let score = 100; if ( normalized === `S${target}` || normalized === `S${target.padStart(2, "0")}` ) { score = 0; } else if ( normalized === `SEASON${target}` || normalized === `SEASON${target.padStart(2, "0")}` ) { score = 1; } else if ( normalizeDomText(name) === `第 ${target} 季` || normalizeDomText(name) === `第${target}季` ) { score = 2; } else { score = 10; } if (/\(\d+\)\s*$/i.test(String(name || ""))) { score += 50; } score += String(name || "").length * 0.01; return score; } function findExistingSeasonFolder(children, seasonFolderName) { const folders = (children || []).filter( (item) => item && item.isFolder && item.fileId, ); if (!folders.length) { return null; } const wantedName = normalizeDomText(seasonFolderName).toUpperCase(); const exact = folders.find( (item) => normalizeDomText(item.name).toUpperCase() === wantedName, ); if (exact) { return exact; } const wantedSeasonNo = parseSeasonFolderNumber(seasonFolderName); if (!Number.isFinite(wantedSeasonNo) || wantedSeasonNo <= 0) { return null; } const sameSeason = folders.filter( (item) => parseSeasonFolderNumber(item.name) === wantedSeasonNo, ); if (!sameSeason.length) { return null; } sameSeason.sort( (a, b) => scoreSeasonFolderName(a.name, wantedSeasonNo) - scoreSeasonFolderName(b.name, wantedSeasonNo), ); return sameSeason[0] || null; } function getCachedChildrenByParent(parentFolderId) { const parentId = String(parentFolderId || "").trim(); if (!parentId) { return []; } const merged = [ ...(STATE.listByParent[parentId] || []).filter(Boolean), ...(STATE.lastListItems || []).filter( (item) => String(item?.parentId || "") === parentId, ), ]; return dedupeItems(merged); } function findCachedItemByFileId(fileId) { const targetId = String(fileId || "").trim(); if (!targetId) { return null; } const pools = []; if (Array.isArray(STATE.lastListItems) && STATE.lastListItems.length) { pools.push(STATE.lastListItems); } for (const list of Object.values(STATE.listByParent || {})) { if (Array.isArray(list) && list.length) { pools.push(list); } } for (const pool of pools) { for (const item of pool) { if (item && String(item.fileId || "") === targetId) { return item; } } } return null; } function applyListPage(body, pageNo) { const size = Math.max( 1, Number(body?.pageSize || CONFIG.listPageSize || 50), ); const page = Math.max(1, Number(pageNo || 1)); return { ...body, pageNum: page, pageNo: page, start: (page - 1) * size, offset: (page - 1) * size, }; } async function findExistingSeasonFolderByPaging( parentFolderId, seasonFolderName, maxPages = 8, ) { const variants = buildListBodyVariants(parentFolderId); const merged = []; for (const baseBody of variants) { const pageSize = Math.max( 1, Number(baseBody.pageSize || CONFIG.listPageSize || 50), ); for (let page = 1; page <= maxPages; page += 1) { const body = applyListPage(baseBody, page); let response = null; try { response = await postJson(getListUrl(), body); } catch (err) { warn( `分页查找季目录失败:${getErrorText(err)} | page=${page}`, ); break; } if (!response || !response.ok) { break; } const items = extractItemsFromPayload(response.payload); if (items.length) { mergeCapturedList(String(parentFolderId || ""), items); merged.push(...items); } const found = findExistingSeasonFolder( dedupeItems(merged), seasonFolderName, ); if (found) { return found; } if (items.length < pageSize) { break; } } } const cached = getCachedChildrenByParent(parentFolderId); return findExistingSeasonFolder(cached, seasonFolderName); } function buildEpisodeName( seriesName, year, season, episode, resolution, qualityTags, ext, ) { const seasonStr = `S${pad2(season)}`; const episodeStr = pad2(episode); const title = `${seriesName} (${year}) - ${seasonStr}E${episodeStr} - 第 ${episodeStr} 集`; const extra = buildVideoExtraPart(resolution, qualityTags); const resPart = extra ? ` - ${extra}` : ""; const extPart = ext ? `.${ext}` : ""; return `${title}${resPart}${extPart}`; } function buildMovieName(seriesName, year, resolution, qualityTags, ext) { const extra = buildVideoExtraPart(resolution, qualityTags); const resPart = extra ? ` - ${extra}` : ""; const extPart = ext ? `.${ext}` : ""; return `${seriesName} (${year})${resPart}${extPart}`; } function getMovieVariantKey(plan) { const extra = buildVideoExtraPart(plan?.resolution, plan?.qualityTags); return normalizeDomText(extra).toUpperCase(); } function getSortedSeasonEntries(bySeason) { return Array.from(bySeason.entries()).sort((a, b) => a[0] - b[0]); } function buildTaskList(context) { const seasonEntries = getSortedSeasonEntries(context.bySeason); const tasks = []; const skippedMovieDuplicates = []; const seenMovieVariantKeys = new Set(); for (const [season, plans] of seasonEntries) { const seasonFolder = `S${pad2(season)}`; for (const plan of plans) { if (context?.isMovie) { const variantKey = getMovieVariantKey(plan); if (seenMovieVariantKeys.has(variantKey)) { skippedMovieDuplicates.push({ season, seasonFolder, plan, }); continue; } seenMovieVariantKeys.add(variantKey); } tasks.push({ season, seasonFolder, plan, }); } } return { seasonEntries, tasks, skippedMovieDuplicates }; } function buildTargetVideoName(context, task, index = 1, total = 1) { if (context.isMovie) { return buildMovieName( context.seriesName, context.year, task.plan.resolution, task.plan.qualityTags, task.plan.ext, ); } return buildEpisodeName( context.seriesName, context.year, task.season, task.plan.episode, task.plan.resolution, task.plan.qualityTags, task.plan.ext, ); } function cleanNameForTitle(text) { return String(text || "") .replace(/\.[a-z0-9]{1,6}$/i, "") .replace(/\{tmdbid-\d+\}/gi, "") .replace(/^[\s\[【((].*?[\]】))]\s*/g, "") .replace(/\bS\d{1,2}E\d{1,}\b/gi, " ") .replace(/\bS\d{1,2}\b/gi, " ") .replace(/\bE\d{1,}\b/gi, " ") .replace( /第\s*[0-90-9一二三四五六七八九十百零〇两]+\s*季/giu, " ", ) .replace(/第\s*[0-90-9]+\s*[集话]/gu, " ") .replace(/\bSeason\s*\d{1,2}\b/gi, " ") .replace(/\bEP?\s*\d{1,}\b/gi, " ") .replace( /\b(?:2160p|1080p|720p|4k|8k|web[- ]?dl|bluray|x264|x265|h264|h265|aac|dts)\b/gi, " ", ) .replace(/\b(?:19|20)\d{2}\b/g, " ") .replace(/[._-]+/g, " ") .replace(/\s+/g, " ") .replace(/^[\s\-_.:|/]+|[\s\-_.:|/]+$/g, "") .trim(); } function normalizeTitleKey(text) { return String(text || "") .toLowerCase() .replace(/\s+/g, "") .replace(/[【】[\]()()._\-:|/]/g, ""); } function extractTitleFromVideoName(name) { const original = String(name || ""); const direct = original.match( /^(.*?)(?:\s*[-_. ]*\bS\d{1,2}E\d{1,}\b|\s*[-_. ]*第\s*[0-90-9]+\s*[集话]|\s*[-_. ]*EP?\s*\d{1,})/i, ); if (direct && direct[1]) { return cleanNameForTitle(direct[1]); } return cleanNameForTitle(original); } function scoreTitleCandidate(name, sourceType, count) { const text = String(name || "").trim(); if (!text) { return -9999; } let score = 0; score += text.length * 2; score += Number(count || 1) * 22; if (sourceType === "folder") { score += 45; } if (/[\u4e00-\u9fff]/.test(text)) { score += 20; } if (/[A-Za-z]/.test(text)) { score += 8; } if (/\d/.test(text)) { score -= 10; } if (text.length < 2) { score -= 60; } if (/^(sample|trailer|预告|花絮|片头|片尾)$/i.test(text)) { score -= 80; } return score; } function guessSeriesTitle(selectedItems) { const candidates = []; for (const item of selectedItems || []) { if (!item || !item.name) { continue; } if (item.isFolder) { const folderTitle = cleanNameForTitle(item.name); if (folderTitle) { candidates.push({ sourceType: "folder", text: folderTitle, }); } } else { const fromVideo = extractTitleFromVideoName(item.name); if (fromVideo) { candidates.push({ sourceType: "video", text: fromVideo, }); } } } if (!candidates.length) { return ""; } const grouped = new Map(); for (const item of candidates) { const key = normalizeTitleKey(item.text); if (!key) { continue; } if (!grouped.has(key)) { grouped.set(key, { text: item.text, sourceType: item.sourceType, count: 0, }); } const slot = grouped.get(key); slot.count += 1; if (item.sourceType === "folder") { slot.sourceType = "folder"; } if (String(item.text).length > String(slot.text).length) { slot.text = item.text; } } const merged = Array.from(grouped.values()); merged.sort((a, b) => { const diff = scoreTitleCandidate(b.text, b.sourceType, b.count) - scoreTitleCandidate(a.text, a.sourceType, a.count); if (diff !== 0) { return diff; } return String(b.text).length - String(a.text).length; }); return merged[0]?.text || ""; } function normalizeNameForMatch(text) { return String(text || "") .toLowerCase() .replace(/\s+/g, "") .replace(/[【】[\]()()._\-:|/]/g, ""); } function looksLikeFolderByName(name) { const ext = extractFileExt(name); if (!ext) { return true; } if (VIDEO_EXTS.has(ext)) { return false; } if (COMMON_FILE_EXTS.has(ext)) { return false; } return true; } function pickTargetFolderFromSelected(selectedItems) { const list = Array.isArray(selectedItems) ? selectedItems.filter(Boolean) : []; if (!list.length) { return null; } const withId = list.filter((item) => String(item.fileId || "").trim()); const directFolder = withId.find((item) => item.isFolder); if (directFolder) { return directFolder; } const byNameLooksFolder = withId.find((item) => looksLikeFolderByName(item.name), ); if (byNameLooksFolder) { return { ...byNameLooksFolder, isFolder: true, }; } const allCaptured = dedupeItems([ ...(getCapturedItemsForCurrentParent() || []), ...(STATE.lastListItems || []), ...Object.values(STATE.listByParent || {}) .flat() .filter(Boolean), ]); const captured = allCaptured; if (captured.length) { for (const item of withId) { const matchedById = captured.find( (x) => String(x.fileId) === String(item.fileId) && x.isFolder, ); if (matchedById) { return { ...item, isFolder: true, fileId: String(matchedById.fileId), name: matchedById.name || item.name, raw: matchedById.raw || item.raw, }; } } for (const item of list) { const key = normalizeNameForMatch(item.name); if (!key) continue; const matchedByName = captured.find( (x) => normalizeNameForMatch(x.name) === key && (x.isFolder || looksLikeFolderByName(x.name)), ); if (matchedByName) { return { fileId: String(matchedByName.fileId), name: matchedByName.name, isFolder: true, raw: matchedByName.raw || null, }; } } for (const item of list) { const key = normalizeNameForMatch(item.name); if (!key || !looksLikeFolderByName(item.name)) { continue; } const matchedLoose = captured.find((x) => { const xKey = normalizeNameForMatch(x.name); return xKey && (xKey.includes(key) || key.includes(xKey)); }); if (matchedLoose && matchedLoose.fileId) { return { fileId: String(matchedLoose.fileId), name: String(matchedLoose.name || item.name), isFolder: true, raw: matchedLoose.raw || null, }; } } } return null; } function pickSelectedVideoFiles(selectedItems) { const list = Array.isArray(selectedItems) ? selectedItems.filter(Boolean) : []; const withId = list.filter((item) => String(item.fileId || "").trim()); const videos = withId.filter((item) => isVideoItem({ name: item.name, isFolder: Boolean(item.isFolder), raw: item.raw || {}, }), ); return videos.map((item) => ({ ...item, isFolder: false, parentId: String( item.parentId || item.raw?.parentId || item.raw?.pid || "", ), })); } function pickMainParentIdFromFiles(files) { const counts = new Map(); for (const file of files || []) { const parentId = String( file?.parentId || file?.raw?.parentId || file?.raw?.pid || "", ).trim(); if (!parentId) { continue; } counts.set(parentId, (counts.get(parentId) || 0) + 1); } let best = ""; let bestCount = -1; for (const [pid, count] of counts.entries()) { if (count > bestCount) { best = pid; bestCount = count; } } return best || String(resolveListParentId() || ""); } async function resolveSelectedFolderFallback(selectedItems) { const list = Array.isArray(selectedItems) ? selectedItems.filter(Boolean) : []; if (!list.length) { return { folder: null, children: null }; } const withId = list.filter((item) => String(item?.fileId || "").trim()); const folderLikeWithId = withId.filter( (item) => !isVideoItem({ name: item?.name, isFolder: Boolean(item?.isFolder), raw: item?.raw || {}, }), ); // 兜底1:逐个用 fileId 探测是否可作为目录读取 for (const item of folderLikeWithId) { const fileId = String(item.fileId || "").trim(); if (!fileId) { continue; } try { const children = await fetchListByParent(fileId); return { folder: { fileId, name: String(item.name || ""), isFolder: true, raw: item.raw || null, }, children, }; } catch { // ignore } } // 兜底2:按名称在当前目录列表中反查 const parentId = String(resolveListParentId() || "").trim(); if (!parentId) { return { folder: null, children: null }; } let siblings = []; try { siblings = await fetchListByParent(parentId); } catch { siblings = []; } const folders = (siblings || []).filter( (item) => item && item.fileId && (item.isFolder || looksLikeFolderByName(item.name)), ); if (!folders.length) { return { folder: null, children: null }; } const nameCandidates = Array.from( new Set( list .map((item) => normalizeNameForMatch(item?.name || "")) .filter(Boolean), ), ); for (const key of nameCandidates) { const exact = folders.find( (x) => normalizeNameForMatch(x.name) === key, ); if (exact) { return { folder: { fileId: String(exact.fileId), name: String(exact.name || ""), isFolder: true, raw: exact.raw || null, }, children: null, }; } } for (const key of nameCandidates) { const loose = folders.find((x) => { const xKey = normalizeNameForMatch(x.name); return xKey && (xKey.includes(key) || key.includes(xKey)); }); if (loose) { return { folder: { fileId: String(loose.fileId), name: String(loose.name || ""), isFolder: true, raw: loose.raw || null, }, children: null, }; } } return { folder: null, children: null }; } function resolveTmdbCredential(raw) { const value = String(raw || "").trim(); if (!value) { return { kind: "", token: "", apiKey: "" }; } if (/^Bearer\s+/i.test(value)) { return { kind: "token", token: value.replace(/^Bearer\s+/i, "").trim(), apiKey: "", }; } const jwtLike = /^[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+$/.test(value); if (jwtLike) { return { kind: "token", token: value, apiKey: "" }; } return { kind: "apiKey", token: "", apiKey: value }; } function normalizeTmdbResults(results) { const list = Array.isArray(results) ? results : []; const mapped = list .filter((item) => item && item.media_type !== "person") .map((item) => { const mediaType = String( item.media_type || (item.first_air_date ? "tv" : item.release_date ? "movie" : "tv"), ).toLowerCase(); const name = item.name || item.title || item.original_name || item.original_title || ""; const originalName = item.original_name || item.original_title || name; const firstAirDate = item.first_air_date || item.release_date || ""; return { ...item, media_type: mediaType, name, original_name: originalName, first_air_date: firstAirDate, }; }); mapped.sort((a, b) => { const aw = a.media_type === "tv" ? 0 : 1; const bw = b.media_type === "tv" ? 0 : 1; if (aw !== bw) { return aw - bw; } const ap = Number(a.popularity || 0); const bp = Number(b.popularity || 0); return bp - ap; }); return mapped; } async function searchTmdbTv(query, tmdbCredential) { const credential = resolveTmdbCredential(tmdbCredential); const params = new URLSearchParams({ query, language: CONFIG.tmdbLanguage, page: "1", include_adult: "false", }); if (credential.kind === "apiKey" && credential.apiKey) { params.set("api_key", credential.apiKey); } const url = `${CONFIG.tmdbHost}/search/multi?${params.toString()}`; let text = ""; let ok = false; let status = 0; const requestOptions = { method: "GET", headers: { accept: "application/json", }, mode: "cors", credentials: "omit", }; if (credential.kind === "token" && credential.token) { requestOptions.headers.authorization = `Bearer ${credential.token}`; } try { const gmRes = await requestViaGM(url, requestOptions); text = gmRes.text; ok = gmRes.ok; status = gmRes.status; } catch (gmErr) { warn( `TMDB GM_xmlhttpRequest 失败,改用 fetch:${getErrorText(gmErr)}`, ); try { const response = await window.fetch(url, requestOptions); text = await response.text(); ok = response.ok; status = response.status; } catch (fetchErr) { warn( `TMDB window.fetch 失败,改用页面上下文请求:${getErrorText(fetchErr)}`, ); const bridged = await requestViaPage(url, requestOptions); text = bridged.text; ok = bridged.ok; status = bridged.status; } } const payload = safeJsonParse(text); if (!ok) { throw new Error(`TMDB 搜索失败 HTTP ${status}`); } return normalizeTmdbResults(payload?.results); } function getButtonLabel(button) { const spans = button.querySelectorAll("span"); if (!spans.length) { return button.textContent ? button.textContent.trim() : ""; } return spans[spans.length - 1].textContent ? spans[spans.length - 1].textContent.trim() : ""; } function isCloudAddButton(button) { return ( button instanceof HTMLButtonElement && getButtonLabel(button) === "云添加" ); } function injectStyle() { if (document.getElementById(STYLE_ID)) { return; } const style = document.createElement("style"); style.id = STYLE_ID; style.textContent = ` #${MODAL_ID} { position: fixed; inset: 0; z-index: 2147483646; display: none; } #${MODAL_ID}.open { display: block; } #${MODAL_ID} .gy-mask { position: absolute; inset: 0; background: rgba(8, 16, 32, 0.52); } #${MODAL_ID} .gy-dialog { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); width: min(760px, calc(100vw - 28px)); max-height: min(86vh, 920px); overflow: auto; box-sizing: border-box; background: #fff; border-radius: 14px; padding: 16px; color: #1d2742; box-shadow: 0 16px 48px rgba(8, 24, 48, 0.34); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; -webkit-user-select: text !important; user-select: text !important; } #${MODAL_ID} .gy-dialog * { -webkit-user-select: text !important; user-select: text !important; } #${MODAL_ID} input[type="text"], #${MODAL_ID} input[type="password"], #${MODAL_ID} textarea { -webkit-user-select: text !important; user-select: text !important; } #${MODAL_ID} .gy-title { font-size: 16px; font-weight: 700; margin-bottom: 10px; } #${MODAL_ID} .gy-row { margin-bottom: 10px; } #${MODAL_ID} .gy-row label { display: block; font-size: 12px; color: #51628f; margin-bottom: 6px; } #${MODAL_ID} input[type="text"], #${MODAL_ID} input[type="password"] { width: 100%; box-sizing: border-box; border: 1px solid #c8d4ea; border-radius: 8px; padding: 8px 10px; font-size: 13px; } #${MODAL_ID} .gy-inline { display: grid; grid-template-columns: 1fr auto auto; gap: 8px; } #${MODAL_ID} button { border: 0; border-radius: 8px; padding: 8px 12px; font-size: 12px; cursor: pointer; } #${MODAL_ID} .gy-primary { background: #0f62fe; color: #fff; } #${MODAL_ID} .gy-secondary { background: #eef2ff; color: #2b3d66; } #${MODAL_ID} .gy-danger { background: #d92d20; color: #fff; } #${MODAL_ID} .gy-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; } #${MODAL_ID} .gy-selected, #${MODAL_ID} .gy-status { background: #f6f9ff; border-radius: 8px; padding: 8px 10px; font-size: 12px; white-space: pre-wrap; line-height: 1.5; } #${MODAL_ID} .gy-preview { background: #f8fafc; border: 1px solid #d7e0f5; border-radius: 8px; padding: 8px 10px; font-size: 12px; white-space: pre; line-height: 1.5; max-height: 220px; overflow: auto; overflow-x: auto; overflow-y: auto; } #${MODAL_ID} .gy-results { border: 1px solid #d7e0f5; border-radius: 8px; padding: 8px; max-height: 260px; overflow: auto; } #${MODAL_ID} .gy-item { display: block; border-radius: 8px; padding: 8px; cursor: pointer; } #${MODAL_ID} .gy-item-main { margin-top: 6px; display: grid; grid-template-columns: 56px 1fr; gap: 10px; align-items: start; } #${MODAL_ID} .gy-item-poster, #${MODAL_ID} .gy-item-no-poster { width: 56px; height: 84px; border-radius: 6px; border: 1px solid #d7e0f5; box-sizing: border-box; } #${MODAL_ID} .gy-item-poster { display: block; object-fit: cover; background: #e8efff; } #${MODAL_ID} .gy-item-no-poster { display: flex; align-items: center; justify-content: center; background: #f3f6ff; color: #6b7fa8; font-size: 10px; line-height: 1.2; text-align: center; padding: 4px; } #${MODAL_ID} .gy-item-meta { min-width: 0; } #${MODAL_ID} .gy-item:hover { background: #f7faff; } #${MODAL_ID} .gy-item-title { font-weight: 600; font-size: 13px; } #${MODAL_ID} .gy-item-sub { margin-top: 3px; color: #5b6d99; font-size: 12px; } #${MODAL_ID} button, #${MODAL_ID} input[type="radio"] { -webkit-user-select: none !important; user-select: none !important; } #${MODAL_ID} .gy-selected, #${MODAL_ID} .gy-status, #${MODAL_ID} .gy-preview, #${MODAL_ID} .gy-results, #${MODAL_ID} .gy-item-title, #${MODAL_ID} .gy-item-sub { cursor: text; } #${MODAL_ID} .gy-selected::selection, #${MODAL_ID} .gy-status::selection, #${MODAL_ID} .gy-preview::selection, #${MODAL_ID} .gy-item-title::selection, #${MODAL_ID} .gy-item-sub::selection { background: #0f62fe; color: #fff; } #${MODAL_ID} input[type="text"]::selection, #${MODAL_ID} input[type="password"]::selection, #${MODAL_ID} textarea::selection { background: #0f62fe; color: #fff; } #${MODAL_ID} input[type="text"]::-moz-selection, #${MODAL_ID} input[type="password"]::-moz-selection, #${MODAL_ID} textarea::-moz-selection { background: #0f62fe; color: #fff; } `; document.head.appendChild(style); } function ensureModal() { injectStyle(); let root = document.getElementById(MODAL_ID); if (root) { return root; } root = document.createElement("div"); root.id = MODAL_ID; root.innerHTML = `