// ==UserScript==
// @name 123云盘秒传链接
// @namespace http://tampermonkey.net/
// @version 1.1.50 // <-- Increment version for Base62 ETag feature
// @description 123FastLink是一款适用于123网盘(123Pan) 的秒传链接生成与转存的用户脚本。
// @author Gemini
// @match *://*.123pan.com/*
// @match *://*.123pan.cn/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=123pan.com
// @license MIT
// @grant GM_setClipboard
// @grant GM_addStyle
// @downloadURL none
// ==/UserScript==
(function() {
'use strict';
// --- Constants and Configuration ---
const SCRIPT_NAME = "123FastLink";
const SCRIPT_VERSION = "1.1.50"; // Updated version for Base62 ETag
const LEGACY_FOLDER_LINK_PREFIX_V1 = "123FSLinkV1$"; // Old prefix
const COMMON_PATH_LINK_PREFIX_V1 = "123FLCPV1$"; // Old prefix for common path
// V2 Prefixes for links with Base62 encoded ETags
const LEGACY_FOLDER_LINK_PREFIX_V2 = "123FSLinkV2$";
const COMMON_PATH_LINK_PREFIX_V2 = "123FLCPV2$";
const COMMON_PATH_DELIMITER = "%"; // Delimiter between common path and file data
const BASE62_CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
const API_PATHS = {
UPLOAD_REQUEST: "/b/api/file/upload_request",
LIST_NEW: "/b/api/file/list/new",
FILE_INFO: "/b/api/file/info"
};
const DOM_SELECTORS = {
TARGET_BUTTON_AREA: '.ant-dropdown-trigger.sysdiv.parmiryButton',
FILE_ROW_SELECTOR: ".ant-table-row.ant-table-row-level-0.editable-row",
FILE_CHECKBOX_SELECTOR: "input[type='checkbox']"
};
const RETRY_AND_DELAY_CONFIG = {
RATE_LIMIT_ITEM_RETRY_DELAY_MS: 5000,
RATE_LIMIT_MAX_ITEM_RETRIES: 2,
RATE_LIMIT_GLOBAL_PAUSE_TRIGGER_FAILURES: 3,
RATE_LIMIT_GLOBAL_PAUSE_DURATION_MS: 30000,
GENERAL_API_RETRY_DELAY_MS: 3000,
GENERAL_API_MAX_RETRIES: 2,
PROACTIVE_DELAY_MS: 250
};
// --- API Helper Functions ---
const apiHelper = {
buildURL: (host, path, queryParams = {}) => { const queryString = new URLSearchParams(queryParams).toString(); return `${host}${path}${queryString ? '?' + queryString : ''}`; },
sendRequest: async function(method, path, queryParams = {}, body = null) { const config = { host: 'https://' + window.location.host, authToken: localStorage['authorToken'], loginUuid: localStorage['LoginUuid'], appVersion: '3', referer: document.location.href, }; const headers = { 'Content-Type': 'application/json;charset=UTF-8', 'Authorization': 'Bearer ' + config.authToken, 'platform': 'web', 'App-Version': config.appVersion, 'LoginUuid': config.loginUuid, 'Origin': config.host, 'Referer': config.referer, }; try { const urlToFetch = this.buildURL(config.host, path, queryParams); const response = await fetch(urlToFetch, { method, headers, body: body ? JSON.stringify(body) : null, credentials: 'include' }); const responseText = await response.text(); let responseData; try { responseData = JSON.parse(responseText); } catch (e) { if (!response.ok) throw new Error(`❗ HTTP ${response.status}: ${responseText || response.statusText}`); throw new Error(`❗ 响应解析JSON失败: ${e.message}`); } if (responseData.code !== 0) { const message = responseData.message || 'API业务逻辑错误'; const apiError = new Error(`❗ ${message}`); if (typeof message === 'string' && (message.includes("频繁") || message.includes("操作过快") || message.includes("rate limit") || message.includes("too many requests"))) { apiError.isRateLimit = true; } throw apiError; } return responseData; } catch (error) { if (!error.isRateLimit && !error.message?.startsWith("UserStopped")) {} throw error; } },
createFolder: async function(parentId, folderName) { return coreLogic._executeApiWithRetries(() => this._createFolderInternal(parentId, folderName), `创建文件夹: ${folderName}`, coreLogic.currentOperationRateLimitStatus); },
_createFolderInternal: async function(parentId, folderName) { if (parentId === undefined || parentId === null || isNaN(parseInt(parentId))) { throw new Error(`创建文件夹 "${folderName}" 失败:父文件夹ID无效 (${parentId})。`); } const requestBody = { driveId: 0, etag: "", fileName: folderName, parentFileId: parseInt(parentId, 10), size: 0, type: 1, NotReuse: true, RequestSource: null, duplicate: 1, event: "newCreateFolder", operateType: 1 }; const responseData = await this.sendRequest("POST", API_PATHS.UPLOAD_REQUEST, {}, requestBody); if (responseData?.data?.Info?.FileId !== undefined) return responseData.data.Info; throw new Error('创建文件夹失败或API响应缺少FileId'); },
listDirectoryContents: async function(parentId, limit = 100) { return coreLogic._executeApiWithRetries(() => this._listDirectoryContentsInternal(parentId, limit), `列出目录ID: ${parentId}`, coreLogic.currentOperationRateLimitStatus); },
_listDirectoryContentsInternal: async function(parentId, limit = 100) { if (parentId === undefined || parentId === null || isNaN(parseInt(parentId))) { throw new Error(`无效的文件夹ID: ${parentId},无法列出内容。`); } let allItems = []; let nextMarker = "0"; let currentPage = 1; do { const queryParams = { driveId: 0, limit: limit, next: nextMarker, orderBy: "file_name", orderDirection: "asc", parentFileId: parseInt(parentId, 10), trashed: false, SearchData: "", Page: currentPage, OnlyLookAbnormalFile: 0, event: "homeListFile", operateType: 4, inDirectSpace: false }; const responseData = await this.sendRequest("GET", API_PATHS.LIST_NEW, queryParams); if (responseData?.data?.InfoList) { const newItems = responseData.data.InfoList.map(item => ({ FileID: parseInt(item.FileId, 10) || NaN, FileName: item.FileName || "Unknown", Type: parseInt(item.Type, 10) || 0, Size: parseInt(item.Size, 10) || 0, Etag: item.Etag || "", ParentFileID: parseInt(item.ParentFileId, 10) })); allItems = allItems.concat(newItems); nextMarker = responseData.data.Next; currentPage++; } else { nextMarker = "-1"; } } while (nextMarker !== "-1" && nextMarker !== null && nextMarker !== undefined && String(nextMarker).trim() !== ""); return allItems; },
getFileInfo: async function(idList) { return coreLogic._executeApiWithRetries(() => this._getFileInfoInternal(idList), `获取文件信息: ${idList.join(',')}`, coreLogic.currentOperationRateLimitStatus); },
_getFileInfoInternal: async function(idList) { if (!idList || idList.length === 0) return { data: { infoList: [] } }; const requestBody = { fileIdList: idList.map(id => ({ fileId: String(id) })) }; const responseData = await this.sendRequest("POST", API_PATHS.FILE_INFO, {}, requestBody); if (responseData?.data?.infoList) { responseData.data.infoList = responseData.data.infoList.map(info => ({ ...info, FileID: parseInt(info.FileId || info.FileID, 10) || NaN, FileName: info.Name || info.FileName || "Unknown", Type: parseInt(info.Type || info.type, 10) || 0, Size: parseInt(info.Size || info.size, 10) || 0, Etag: info.Etag || info.etag || "" })); } return responseData; },
rapidUpload: async function(etag, size, fileName, parentId) { return coreLogic._executeApiWithRetries(() => this._rapidUploadInternal(etag, size, fileName, parentId), `秒传: ${fileName}`, coreLogic.currentOperationRateLimitStatus); },
_rapidUploadInternal: async function(etag, size, fileName, parentId) { if (parentId === undefined || parentId === null || isNaN(parseInt(parentId))) { throw new Error(`秒传文件 "${fileName}" 失败:父文件夹ID无效 (${parentId})。`); } const requestBody = { driveId: 0, etag: etag, fileName: fileName, parentFileId: parseInt(parentId, 10), size: parseInt(size, 10), type: 0, NotReuse: false, RequestSource: null, duplicate: 1, event: "rapidUpload", operateType: 1 }; const responseData = await this.sendRequest("POST", API_PATHS.UPLOAD_REQUEST, {}, requestBody); if (responseData?.data?.Info?.FileId !== undefined) return responseData.data.Info; throw new Error(responseData.message || '秒传文件失败或API响应异常'); },
};
// --- Process State & UI Manager ---
const processStateManager = { _userRequestedStop: false, _modalStopButtonId: 'fl-modal-stop-btn', reset: function() { this._userRequestedStop = false; }, requestStop: function() { this._userRequestedStop = true; const btn = document.getElementById(this._modalStopButtonId); if(btn){btn.textContent = "正在停止..."; btn.disabled = true;} console.log(`[${SCRIPT_NAME}] User requested stop.`); }, isStopRequested: function() { return this._userRequestedStop; }, getStopButtonId: function() { return this._modalStopButtonId; }, updateProgressUI: function(processed, total, successes, failures, currentFileName, extraStatus = "") { const bar = document.querySelector('.fastlink-progress-bar'); if (bar) bar.style.width = `${total > 0 ? Math.round((processed / total) * 100) : 0}%`; const statTxt = document.querySelector('.fastlink-status p:first-child'); if (statTxt) statTxt.textContent = `处理中: ${processed} / ${total} 项 (预估)`; const sucCnt = document.querySelector('.fastlink-stats .success-count'); if (sucCnt) sucCnt.textContent = `✅ 成功:${successes}`; const failCnt = document.querySelector('.fastlink-stats .failed-count'); if (failCnt) failCnt.textContent = `❌ 失败:${failures}`; const curFile = document.querySelector('.fastlink-current-file .file-name'); if (curFile) curFile.textContent = currentFileName ? `📄 ${currentFileName}` : "准备中..."; const extraEl = document.querySelector('.fastlink-status .extra-status-message'); if (extraEl) { extraEl.textContent = extraStatus; extraEl.style.display = extraStatus ? 'block' : 'none';} }, appendLogMessage: function(message, isError = false) { const logArea = document.querySelector('.fastlink-status'); if (logArea) { const p = document.createElement('p'); p.className = isError ? 'error-message' : 'info-message'; p.innerHTML = message; const extraStatusSibling = logArea.querySelector('.extra-status-message'); if (extraStatusSibling) logArea.insertBefore(p, extraStatusSibling.nextSibling); else logArea.appendChild(p); logArea.scrollTop = logArea.scrollHeight; } } };
// --- Core Logic ---
const coreLogic = {
currentOperationRateLimitStatus: { consecutiveRateLimitFailures: 0 },
_executeApiWithRetries: async function(apiFunctionExecutor, itemNameForLog, rateLimitStatusRef) { let generalErrorRetries = 0; while (generalErrorRetries <= RETRY_AND_DELAY_CONFIG.GENERAL_API_MAX_RETRIES) { if (processStateManager.isStopRequested()) throw new Error("UserStopped"); let rateLimitRetriesForCurrentGeneralAttempt = 0; while (rateLimitRetriesForCurrentGeneralAttempt <= RETRY_AND_DELAY_CONFIG.RATE_LIMIT_MAX_ITEM_RETRIES) { if (processStateManager.isStopRequested()) throw new Error("UserStopped"); try { const result = await apiFunctionExecutor(); rateLimitStatusRef.consecutiveRateLimitFailures = 0; return result; } catch (error) { if (processStateManager.isStopRequested()) throw error; if (error.isRateLimit) { rateLimitStatusRef.consecutiveRateLimitFailures++; const rlRetryAttemptDisplay = rateLimitRetriesForCurrentGeneralAttempt + 1; const currentFileEl = document.querySelector('.fastlink-current-file .file-name'); if(currentFileEl) processStateManager.appendLogMessage(`⏳ ${currentFileEl.textContent}: 操作频繁 (RL ${rlRetryAttemptDisplay}/${RETRY_AND_DELAY_CONFIG.RATE_LIMIT_MAX_ITEM_RETRIES + 1})`, true); if (rateLimitRetriesForCurrentGeneralAttempt >= RETRY_AND_DELAY_CONFIG.RATE_LIMIT_MAX_ITEM_RETRIES) { processStateManager.appendLogMessage(`❌ ${itemNameForLog}: 已达当前常规尝试的最大API限流重试次数。`, true); throw error; } rateLimitRetriesForCurrentGeneralAttempt++; if (rateLimitStatusRef.consecutiveRateLimitFailures >= RETRY_AND_DELAY_CONFIG.RATE_LIMIT_GLOBAL_PAUSE_TRIGGER_FAILURES) { processStateManager.appendLogMessage(`[全局暂停] API持续频繁,暂停 ${RETRY_AND_DELAY_CONFIG.RATE_LIMIT_GLOBAL_PAUSE_DURATION_MS / 1000} 秒...`, true); const extraStatusEl = document.querySelector('.fastlink-status .extra-status-message'); if(extraStatusEl) extraStatusEl.textContent = `全局暂停中... ${RETRY_AND_DELAY_CONFIG.RATE_LIMIT_GLOBAL_PAUSE_DURATION_MS / 1000}s`; await new Promise(r => setTimeout(r, RETRY_AND_DELAY_CONFIG.RATE_LIMIT_GLOBAL_PAUSE_DURATION_MS)); if(extraStatusEl) extraStatusEl.textContent = ""; rateLimitStatusRef.consecutiveRateLimitFailures = 0; rateLimitRetriesForCurrentGeneralAttempt = 0; } else { await new Promise(r => setTimeout(r, RETRY_AND_DELAY_CONFIG.RATE_LIMIT_ITEM_RETRY_DELAY_MS)); } } else { const genRetryAttemptDisplay = generalErrorRetries + 1; processStateManager.appendLogMessage(`❌ ${itemNameForLog}: ${error.message} (常规重试 ${genRetryAttemptDisplay}/${RETRY_AND_DELAY_CONFIG.GENERAL_API_MAX_RETRIES + 1})`, true); generalErrorRetries++; if (generalErrorRetries > RETRY_AND_DELAY_CONFIG.GENERAL_API_MAX_RETRIES) { throw error; } await new Promise(r => setTimeout(r, RETRY_AND_DELAY_CONFIG.GENERAL_API_RETRY_DELAY_MS)); break; } } } } throw new Error(`[${SCRIPT_NAME}] 所有API重试均失败: ${itemNameForLog}`); },
getSelectedFileIds: () => { return Array.from(document.querySelectorAll(DOM_SELECTORS.FILE_ROW_SELECTOR)).filter(row => (row.querySelector(DOM_SELECTORS.FILE_CHECKBOX_SELECTOR) || {}).checked).map(row => String(row.getAttribute('data-row-key'))).filter(id => id != null); },
// --- MODIFICATION START: Improved getCurrentDirectoryId ---
getCurrentDirectoryId: () => {
const url = window.location.href;
const homeFilePathMatch = url.match(/[?&]homeFilePath=([^&]*)/);
if (homeFilePathMatch) {
if (homeFilePathMatch[1] && homeFilePathMatch[1] !== "") {
return homeFilePathMatch[1]; // Found ID in homeFilePath
} else {
return "0"; // homeFilePath is present but empty, signifies root
}
}
// Regex to capture various ID patterns. Order implies priority.
const regexes = [
/fid=(\d+)/, // 1. fid=ID (query parameter)
/#\/list\/folder\/(\d+)/, // 2. #/list/folder/ID (hash navigation)
/\/drive\/(?:folder\/)?(\d+)/, // 3. /drive/[folder/]ID (drive paths)
/\/s\/[a-zA-Z0-9_-]+\/(\d+)/, // 4. /s/SHARE_ID/FOLDER_ID (share view)
/(?:\/|^)(\d+)(?=[/?#]|$)/ // 5. /FOLDER_ID or just FOLDER_ID if it's the whole path part
// Matches /123, /123?, /123#, or 123 at path end.
// Ensures it's a full segment.
];
for (const regex of regexes) {
const match = url.match(regex);
if (match && match[1]) {
// For /drive/0 (or other patterns that might resolve to ID "0"),
// treat as root unless it's a specific non-root context.
if (match[1] === "0") {
// If current URL is literally like ".../drive/0" or ".../s/blah/0", it's specific.
// But if it's just a generic ID '0' from a broad regex match, prefer root fallback.
// The regex /\/drive\/(?:folder\/)?(\d+)/ specifically can match /drive/0.
if (regex.source === String(/\/\drive\/(?:folder\/)?(\d+)/) && url.includes("/drive/0")) {
return "0"; // Explicit /drive/0 is root
}
// For other regexes, if '0' is matched, it *could* be a folder named "0".
// However, "0" is conventionally the root ID.
// We'll let it pass here, but the root checks later are a safeguard.
// If a folder is genuinely named '0', this should capture it.
// If it's a misinterpretation of a root URL as ID '0', the default is also '0'.
// This is fine.
}
return match[1];
}
}
// Fallback to "0" for root or unrecognized structures
const lowerUrl = url.toLowerCase();
if (lowerUrl.includes("/drive/0") ||
lowerUrl.endsWith("/drive") || lowerUrl.endsWith("/drive/") ||
lowerUrl.match(/^https?:\/\/[^/]+\/?([#?].*)?$/) || // domain.com or domain.com/
lowerUrl.endsWith(".123pan.com") || lowerUrl.endsWith(".123pan.cn") ||
lowerUrl.endsWith(".123pan.com/") || lowerUrl.endsWith(".123pan.cn/")
) {
return "0";
}
try {
const pathname = new URL(url).pathname;
if (pathname === '/' || pathname.toLowerCase() === '/drive/' || pathname.toLowerCase() === '/index.html') {
return "0";
}
} catch(e) { /*ignore invalid URL for pathname */ }
// console.warn(`[${SCRIPT_NAME}] Directory ID not reliably determined for ${url}, defaulting to root (0). Pathname: ${new URL(url).pathname}`);
return "0";
},
// --- MODIFICATION END: Improved getCurrentDirectoryId ---
// --- MODIFICATION START: Link Shortening Logic ---
_findLongestCommonPrefix: function(paths) {
if (!paths || paths.length === 0) return "";
if (paths.length === 1 && paths[0].includes('/')) { // Single item in a folder path
const lastSlash = paths[0].lastIndexOf('/');
if (lastSlash > -1) return paths[0].substring(0, lastSlash + 1);
return ""; // Single file at root of selection
}
if (paths.length === 1 && !paths[0].includes('/')) return ""; // Single file, no path
const sortedPaths = [...paths].sort();
const firstPath = sortedPaths[0];
const lastPath = sortedPaths[sortedPaths.length - 1];
let i = 0;
while (i < firstPath.length && firstPath.charAt(i) === lastPath.charAt(i)) {
i++;
}
let prefix = firstPath.substring(0, i);
// Ensure prefix ends with a slash if it's a directory prefix
if (prefix.includes('/')) {
prefix = prefix.substring(0, prefix.lastIndexOf('/') + 1);
} else {
// If no slash, it means the common part is a file/folder name itself.
// This is only a valid "common prefix" if all paths either are this prefix
// or start with this prefix + "/"
if (!paths.every(p => p === prefix || p.startsWith(prefix + "/"))) {
return ""; // Not a valid common directory/file prefix
}
// If it is valid, and it's a folder-like prefix, add a slash if needed for consistency
// Example: selected "FolderA", files are "File1", "File2" -> paths "FolderA/File1", "FolderA/File2"
// Common prefix would be "FolderA/".
// If selected "FileA", "FileB" -> paths "FileA", "FileB", common prefix ""
// If a single folder "MyFolder" is selected, paths are like "MyFolder/file1.txt"
// Common prefix will be "MyFolder/"
}
// Only return prefix if it's of meaningful length (e.g., more than just "/")
// and actually reduces path length for most items.
return (prefix.length > 1 && prefix.endsWith('/')) ? prefix : "";
},
// --- MODIFICATION END: Link Shortening Logic ---
generateShareLink: async function() {
const selectedItemIds = this.getSelectedFileIds();
if (!selectedItemIds.length) { uiManager.showAlert("请先勾选要分享的文件或文件夹。"); return ""; }
processStateManager.reset();
this.currentOperationRateLimitStatus.consecutiveRateLimitFailures = 0;
let allFileEntriesData = []; // Store as {etag, size, fullPath} objects
let processedAnyFolder = false;
let totalDiscoveredItemsForProgress = selectedItemIds.length;
let itemsProcessedForProgress = 0;
let successes = 0, failures = 0;
uiManager.showModal("生成秒传链接", `
✅ 成功:0 ❌ 失败:0
`, 'progress_stoppable', false);
const startTime = Date.now();
async function processSingleItem(itemId, currentRelativePath) {
if (processStateManager.isStopRequested()) throw new Error("UserStopped");
const baseItemName = `${currentRelativePath || '根目录'}/${itemId}`;
processStateManager.updateProgressUI(itemsProcessedForProgress, totalDiscoveredItemsForProgress, successes, failures, baseItemName, "获取信息...");
let itemDetails;
try {
const itemInfoResponse = await apiHelper.getFileInfo([String(itemId)]);
if (processStateManager.isStopRequested()) throw new Error("UserStopped");
if (!itemInfoResponse?.data?.infoList?.length) throw new Error(`项目 ${itemId} 信息未找到`);
itemDetails = itemInfoResponse.data.infoList[0];
itemsProcessedForProgress++;
} catch (e) {
if (processStateManager.isStopRequested()) throw e;
failures++;
itemsProcessedForProgress++;
processStateManager.appendLogMessage(`❌ 获取项目 "${baseItemName}" 详情最终失败: ${e.message}`, true);
processStateManager.updateProgressUI(itemsProcessedForProgress, totalDiscoveredItemsForProgress, successes, failures, baseItemName, "获取信息失败");
return;
}
if (isNaN(itemDetails.FileID)) {
failures++;
processStateManager.appendLogMessage(`❌ 项目 "${itemDetails.FileName || itemId}" FileID无效`, true);
processStateManager.updateProgressUI(itemsProcessedForProgress, totalDiscoveredItemsForProgress, successes, failures, baseItemName);
return;
}
const cleanName = (itemDetails.FileName || "Unknown").replace(/[#$%\/]/g, "_").replace(new RegExp(COMMON_PATH_DELIMITER.replace(/[.*+?^${}()|[\\\]\\]/g, '\\$&'), 'g'), '_');
const itemDisplayPath = `${currentRelativePath ? currentRelativePath + '/' : ''}${cleanName}`;
processStateManager.updateProgressUI(itemsProcessedForProgress, totalDiscoveredItemsForProgress, successes, failures, itemDisplayPath);
if (itemDetails.Type === 0) { // File
if (itemDetails.Etag && itemDetails.Size !== undefined) {
allFileEntriesData.push({ etag: itemDetails.Etag, size: itemDetails.Size, fullPath: itemDisplayPath });
successes++;
processStateManager.appendLogMessage(`✔️ 文件: ${itemDisplayPath}`);
} else {
failures++;
processStateManager.appendLogMessage(`❌ 文件 "${itemDisplayPath}" 缺少Etag或大小`, true);
}
} else if (itemDetails.Type === 1) { // Folder
processedAnyFolder = true;
processStateManager.appendLogMessage(`📁 扫描文件夹: ${itemDisplayPath}`);
processStateManager.updateProgressUI(itemsProcessedForProgress, totalDiscoveredItemsForProgress, successes, failures, itemDisplayPath, "列出内容...");
try {
const contents = await apiHelper.listDirectoryContents(itemDetails.FileID);
if (processStateManager.isStopRequested()) throw new Error("UserStopped");
totalDiscoveredItemsForProgress += contents.length;
for (const contentItem of contents) {
if (processStateManager.isStopRequested()) throw new Error("UserStopped");
if (isNaN(contentItem.FileID)) {
itemsProcessedForProgress++;
failures++;
processStateManager.appendLogMessage(`❌ 文件夹 "${itemDisplayPath}" 内发现无效项目ID`, true);
continue;
}
await processSingleItem(contentItem.FileID, itemDisplayPath);
await new Promise(r => setTimeout(r, RETRY_AND_DELAY_CONFIG.PROACTIVE_DELAY_MS / 2));
}
} catch (e) {
if (processStateManager.isStopRequested()) throw e;
processStateManager.appendLogMessage(`❌ 处理文件夹 "${itemDisplayPath}" 内容最终失败: ${e.message}`, true);
processStateManager.updateProgressUI(itemsProcessedForProgress, totalDiscoveredItemsForProgress, successes, failures, itemDisplayPath, "列出内容失败");
}
}
await new Promise(r => setTimeout(r, RETRY_AND_DELAY_CONFIG.PROACTIVE_DELAY_MS));
}
try {
processStateManager.updateProgressUI(itemsProcessedForProgress, totalDiscoveredItemsForProgress, successes, failures, "准备开始...");
for (let i = 0; i < selectedItemIds.length; i++) {
if (processStateManager.isStopRequested()) break;
await processSingleItem(selectedItemIds[i], "");
}
} catch (e) {
if (e.message === "UserStopped") processStateManager.appendLogMessage("🛑 用户已停止操作。", true);
else { processStateManager.appendLogMessage(`SYSTEM ERROR: ${e.message}`, true); console.error("Error during generation:", e); }
}
processStateManager.updateProgressUI(itemsProcessedForProgress, totalDiscoveredItemsForProgress, successes, failures, "处理完成", "");
const totalTime = Math.round((Date.now() - startTime) / 1000);
let summary;
if (processStateManager.isStopRequested()) summary = `🔴 操作已停止 部分项目可能未处理
⏱️ 耗时: ${totalTime} 秒
`;
else if (!allFileEntriesData.length && failures > 0) summary = `😢 生成失败 未能提取有效文件信息 (${successes} 成功, ${failures} 失败)
⏱️ 耗时: ${totalTime} 秒
`;
else if (!allFileEntriesData.length) summary = ``;
else {
let link = "";
const allPaths = allFileEntriesData.map(entry => entry.fullPath);
const commonPrefix = this._findLongestCommonPrefix(allPaths);
let useV2Format = true;
const processedEntries = allFileEntriesData.map(entry => {
const etagConversion = hexToOptimizedEtag(entry.etag);
if (!etagConversion.useV2) {
useV2Format = false; // If any ETag cannot be optimized for V2, fallback whole link to V1
}
return {
...entry,
processedEtag: etagConversion.useV2 ? etagConversion.optimized : entry.etag // Store the one to be used
};
});
// If any etag failed V2, allFileEntriesData should use original etags for V1 link.
// The check for useV2Format will handle this.
if (commonPrefix && (processedAnyFolder || allPaths.some(p => p.includes('/')))) {
const fileStrings = processedEntries.map(entry =>
`${useV2Format ? entry.processedEtag : entry.etag}#${entry.size}#${entry.fullPath.substring(commonPrefix.length)}`
);
link = (useV2Format ? COMMON_PATH_LINK_PREFIX_V2 : COMMON_PATH_LINK_PREFIX_V1) + commonPrefix + COMMON_PATH_DELIMITER + fileStrings.join('$');
} else {
// Use old format (or V2 file-only if applicable)
const fileStrings = processedEntries.map(entry =>
`${useV2Format ? entry.processedEtag : entry.etag}#${entry.size}#${entry.fullPath}`
);
link = fileStrings.join('$');
// If it's a folder structure (even single folder) or contains paths, use folder prefixes
if (processedAnyFolder || allPaths.some(p => p.includes('/'))) {
link = (useV2Format ? LEGACY_FOLDER_LINK_PREFIX_V2 : LEGACY_FOLDER_LINK_PREFIX_V1) + link;
} else if (useV2Format) {
// This case implies individual files, no common prefix, no folders.
// To signify V2 ETags, we might need a simple V2 file prefix if not covered by LEGACY_FOLDER_LINK_PREFIX_V2.
// For now, LEGACY_FOLDER_LINK_PREFIX_V2 implicitly covers this if only files are selected and processedAnyFolder is false.
// Let's be explicit: if NOT a folder structure, and V2, use a distinct file-only V2 prefix or ensure LEGACY_FOLDER_LINK_PREFIX_V2 is okay.
// The current structure with processedAnyFolder should correctly apply LEGACY_FOLDER_LINK_PREFIX_V2/V1.
// If !processedAnyFolder AND !allPaths.some(p => p.includes('/')), it's just files.
// The current logic: it just joins them. It needs a prefix if V2.
// To keep it simple: if V2 is active, even for non-folder, non-common-path files, we'll use LEGACY_FOLDER_LINK_PREFIX_V2.
// This simplifies parsing logic: any V2 prefix means Base62 ETags.
if(!link.startsWith(LEGACY_FOLDER_LINK_PREFIX_V2) && !link.startsWith(COMMON_PATH_LINK_PREFIX_V2) && useV2Format){
// If it's just files and V2, and no other prefix was added, use the V2 legacy one.
// This might occur if commonPrefix is empty AND processedAnyFolder is false.
link = LEGACY_FOLDER_LINK_PREFIX_V2 + link;
}
}
}
if (useV2Format) processStateManager.appendLogMessage('💡 使用V2链接格式 (Base62 ETags) 生成。');
else processStateManager.appendLogMessage('ℹ️ 使用V1链接格式 (标准 ETags) 生成。');
summary = `🎉 生成成功 ✅ 文件数量: ${successes} 个 (项目处理失败 ${failures})
⏱️ 耗时: ${totalTime} 秒
`;
uiManager.showModal("秒传链接已生成", summary, 'showLink', true, link); return link;
}
uiManager.updateModalContent(summary); uiManager.enableModalCloseButton(true); return "";
},
parseShareLink: (shareLink) => {
let commonBasePath = "";
let isCommonPathFormat = false;
let isV2EtagFormat = false; // Flag to indicate Base62 ETags
// Check for V2 common path first
if (shareLink.startsWith(COMMON_PATH_LINK_PREFIX_V2)) {
isCommonPathFormat = true;
isV2EtagFormat = true;
shareLink = shareLink.substring(COMMON_PATH_LINK_PREFIX_V2.length);
} else if (shareLink.startsWith(COMMON_PATH_LINK_PREFIX_V1)) {
isCommonPathFormat = true;
// isV2EtagFormat remains false
shareLink = shareLink.substring(COMMON_PATH_LINK_PREFIX_V1.length);
}
if (isCommonPathFormat) {
const delimiterPos = shareLink.indexOf(COMMON_PATH_DELIMITER);
if (delimiterPos > -1) {
commonBasePath = shareLink.substring(0, delimiterPos);
shareLink = shareLink.substring(delimiterPos + 1);
} else { // Malformed link
console.error("Malformed common path link: delimiter not found after prefix.");
isCommonPathFormat = false; // Revert, treat as non-common path or fail
// Attempt to re-evaluate original shareLink for legacy prefixes
// This part needs to be careful not to re-parse if already stripped.
// For simplicity, if delimiter is missing, it's an error for common path.
// Let's assume for now the link is invalid if common path prefix is there but no delimiter.
}
} else { // Not a common path format, check legacy V2 then V1
if (shareLink.startsWith(LEGACY_FOLDER_LINK_PREFIX_V2)) {
isV2EtagFormat = true;
shareLink = shareLink.substring(LEGACY_FOLDER_LINK_PREFIX_V2.length);
} else if (shareLink.startsWith(LEGACY_FOLDER_LINK_PREFIX_V1)) {
// isV2EtagFormat remains false
shareLink = shareLink.substring(LEGACY_FOLDER_LINK_PREFIX_V1.length);
}
// If no prefix is matched at all, isV2EtagFormat remains false (plain files, V1 ETags)
}
return shareLink.split('$').map(sLink => {
const parts = sLink.split('#');
if (parts.length >= 3) {
let etag = parts[0];
try {
etag = optimizedEtagToHex(parts[0], isV2EtagFormat);
} catch (e) {
console.error(`[${SCRIPT_NAME}] Error decoding ETag for V2 link: ${parts[0]}, ${e.message}`);
return null; // Skip this file if ETag decoding fails for V2
}
let filePath = parts.slice(2).join('#');
if (isCommonPathFormat && commonBasePath) { // Ensure commonBasePath is not empty
filePath = commonBasePath + filePath;
}
return { etag: etag, size: parts[1], fileName: filePath };
}
return null;
}).filter(i => i);
},
transferFromShareLink: async function(shareLink) {
if (!shareLink?.trim()) { uiManager.showAlert("链接为空"); return; }
processStateManager.reset();
this.currentOperationRateLimitStatus.consecutiveRateLimitFailures = 0;
// --- MODIFICATION: Check both new and old folder prefixes ---
// The isFolderStructure check becomes less direct, parseShareLink now handles prefix variations.
// const isFolderStructure = shareLink.startsWith(LEGACY_FOLDER_LINK_PREFIX_V1) || shareLink.startsWith(COMMON_PATH_LINK_PREFIX_V1) || shareLink.startsWith(LEGACY_FOLDER_LINK_PREFIX_V2) || shareLink.startsWith(COMMON_PATH_LINK_PREFIX_V2);
const files = this.parseShareLink(shareLink); // This now handles V1/V2 ETags
if (!files.length) { uiManager.showAlert("无法解析链接或链接中无有效文件信息"); return; }
let rootDirId = this.getCurrentDirectoryId(); // This will now use the improved function
if (rootDirId === null || isNaN(parseInt(rootDirId))) {
uiManager.showAlert("无法确定当前目标目录ID。将尝试转存到根目录。");
rootDirId = "0"; // Default to root if something went wrong
}
rootDirId = parseInt(rootDirId);
console.log(`[${SCRIPT_NAME}] Transferring to directory ID: ${rootDirId}`); // For debugging
uiManager.showModal("转存状态", `
🚀 准备转存 ${files.length} 个文件到目录ID ${rootDirId}
✅ 成功:0
❌ 失败:0
`, 'progress_stoppable', false);
let successes = 0, failures = 0;
const folderCache = {}; // Key: full path string, Value: folderId
const startTime = Date.now();
for (let i = 0; i < files.length; i++) {
if (processStateManager.isStopRequested()) break;
const file = files[i];
processStateManager.updateProgressUI(i, files.length, successes, failures, file.fileName, "");
let effectiveParentId = rootDirId;
let actualFileName = file.fileName;
try {
// Create folder structure if needed (isFolderStructure implies paths might exist)
// The file.fileName from parseShareLink will always be the full relative path.
if (file.fileName.includes('/')) {
const pathParts = file.fileName.split('/');
actualFileName = pathParts.pop();
let parentIdForPathSegment = rootDirId;
let currentCumulativePath = "";
for (let j = 0; j < pathParts.length; j++) {
if (processStateManager.isStopRequested()) throw new Error("UserStopped");
const part = pathParts[j];
if (!part) continue; // Skip empty parts (e.g., from "folder//file.txt")
// Build cumulative path for cache key, relative to rootDirId
currentCumulativePath = j === 0 ? part : `${currentCumulativePath}/${part}`;
processStateManager.updateProgressUI(i, files.length, successes, failures, file.fileName, `检查/创建路径: ${currentCumulativePath}`);
if (folderCache[currentCumulativePath]) {
parentIdForPathSegment = folderCache[currentCumulativePath];
} else {
let existingFolderId = null;
const dirContents = await apiHelper.listDirectoryContents(parentIdForPathSegment, 500); // Increased limit
if (processStateManager.isStopRequested()) throw new Error("UserStopped");
const foundFolder = dirContents.find(it => it.Type === 1 && it.FileName === part && !isNaN(it.FileID));
if (foundFolder) {
existingFolderId = foundFolder.FileID;
}
if (existingFolderId) {
parentIdForPathSegment = existingFolderId;
} else {
processStateManager.updateProgressUI(i, files.length, successes, failures, file.fileName, `创建文件夹: ${currentCumulativePath}`);
const createdFolder = await apiHelper.createFolder(parentIdForPathSegment, part);
if (processStateManager.isStopRequested()) throw new Error("UserStopped");
parentIdForPathSegment = parseInt(createdFolder.FileId);
}
folderCache[currentCumulativePath] = parentIdForPathSegment;
await new Promise(r => setTimeout(r, RETRY_AND_DELAY_CONFIG.PROACTIVE_DELAY_MS));
}
}
effectiveParentId = parentIdForPathSegment;
}
if (isNaN(effectiveParentId) || effectiveParentId < 0) {
throw new Error(`路径创建失败或父ID无效 (${effectiveParentId}) for ${file.fileName}`);
}
processStateManager.updateProgressUI(i, files.length, successes, failures, actualFileName, `秒传到ID: ${effectiveParentId}`);
await apiHelper.rapidUpload(file.etag, file.size, actualFileName, effectiveParentId);
if (processStateManager.isStopRequested()) throw new Error("UserStopped");
successes++;
processStateManager.appendLogMessage(`✔️ 文件: ${file.fileName}`);
} catch (e) {
if (processStateManager.isStopRequested()) break;
failures++;
processStateManager.appendLogMessage(`❌ 文件 "${actualFileName}" (原始: ${file.fileName}) 失败: ${e.message}`, true);
processStateManager.updateProgressUI(i, files.length, successes, failures, actualFileName, "操作失败");
}
await new Promise(r => setTimeout(r, RETRY_AND_DELAY_CONFIG.PROACTIVE_DELAY_MS));
}
const totalTime = Math.round((Date.now() - startTime) / 1000);
let resultEmoji = successes > 0 && failures === 0 ? '🎉' : (successes > 0 ? '🎯' : '😢');
if (processStateManager.isStopRequested()) resultEmoji = '🔴';
const finalMessage = processStateManager.isStopRequested() ? "操作已由用户停止" : "转存完成";
const summary = `
${resultEmoji} ${finalMessage}
✅ 成功: ${successes} 个文件
❌ 失败: ${failures} 个文件
⏱️ 耗时: ${totalTime} 秒
${!processStateManager.isStopRequested() ? '
📢 请手动刷新页面查看结果
' : ''}
`;
uiManager.updateModalContent(summary);
uiManager.enableModalCloseButton(true);
}
};
// --- UI Manager ---
const uiManager = { modalElement: null, dropdownMenuElement: null, STYLE_ID: 'fastlink-dynamic-styles', MODAL_CONTENT_ID: 'fastlink-modal-content-area', applyStyles: function() { if (document.getElementById(this.STYLE_ID)) return; GM_addStyle(`.fastlink-modal{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background-color:white;padding:20px;border-radius:8px;box-shadow:0 0 15px rgba(0,0,0,.3);z-index:10001;width:420px;text-align:center}.fastlink-modal-title{font-size:18px;font-weight:700;margin-bottom:15px}.fastlink-modal-content textarea,.fastlink-modal-content div[contenteditable]{width:100%;min-height:80px;max-height:200px;overflow-y:auto;margin-bottom:15px;padding:8px;border:1px solid #ccc;border-radius:4px;box-sizing:border-box;white-space:pre-wrap;word-wrap:break-word}.fastlink-modal-content .fastlink-link-text{width:calc(100% - 16px)!important;min-height:80px;margin-bottom:0!important}.fastlink-modal-input{width:calc(100% - 16px);padding:8px;margin-bottom:10px;border:1px solid #ccc;border-radius:4px}.fastlink-modal-buttons button{padding:8px 15px;margin:0 5px;border-radius:4px;cursor:pointer;border:1px solid transparent;font-size:14px}.fastlink-modal-buttons .confirm-btn{background-color:#28a745;color:#fff}.fastlink-modal-buttons .confirm-btn:disabled{background-color:#94d3a2;cursor:not-allowed}.fastlink-modal-buttons .cancel-btn,.fastlink-modal-buttons .close-btn{background-color:#6c757d;color:#fff}.fastlink-modal-buttons .stop-btn{background-color:#dc3545;color:#fff}.fastlink-modal-buttons .copy-btn{background-color:#007bff;color:#fff}.fastlink-progress-container{width:100%;height:10px;background-color:#f0f0f0;border-radius:5px;margin:10px 0 15px;overflow:hidden}.fastlink-progress-bar{height:100%;background-color:#1890ff;transition:width .3s ease}.fastlink-status{text-align:left;margin-bottom:10px;max-height:150px;overflow-y:auto;border:1px solid #eee;padding:5px;font-size:.9em}.fastlink-status p{margin:3px 0;line-height:1.3}.fastlink-stats{display:flex;justify-content:space-between;margin:10px 0;border-top:1px solid #eee;border-bottom:1px solid #eee;padding:5px 0}.fastlink-current-file{background-color:#f9f9f9;padding:5px;border-radius:4px;margin:5px 0;min-height:1.5em;word-break:break-all}.error-message{color:#d9534f;font-size:.9em}.info-message{color:#28a745;font-size:.9em}.fastlink-result{text-align:center}.fastlink-result h3{font-size:18px;margin:5px 0 15px}.fastlink-result p{margin:8px 0}#fastlink-dropdown-menu-container{position:absolute;background:#fff;border:1px solid #ccc;padding:2px;box-shadow:0 4px 6px rgba(0,0,0,.1);margin-top:5px;z-index:10000}`); }, createDropdownButton: function() { const existingButtons = document.querySelectorAll('.fastlink-main-button-container'); existingButtons.forEach(btn => btn.remove()); const targetElement = document.querySelector(DOM_SELECTORS.TARGET_BUTTON_AREA); if (targetElement && targetElement.parentNode) { const buttonContainer = document.createElement('div'); buttonContainer.className = 'fastlink-main-button-container ant-dropdown-trigger sysdiv parmiryButton'; buttonContainer.style.borderRight = '0.5px solid rgb(217, 217, 217)'; buttonContainer.style.cursor = 'pointer'; buttonContainer.style.marginLeft = '20px'; buttonContainer.innerHTML = ` 秒传 `; const dropdownMenu = document.createElement('div'); dropdownMenu.id = 'fastlink-dropdown-menu-container'; dropdownMenu.style.display = 'none'; dropdownMenu.innerHTML = ``; this.dropdownMenuElement = dropdownMenu; buttonContainer.addEventListener('click', (e) => { e.stopPropagation(); dropdownMenu.style.display = dropdownMenu.style.display === 'none' ? 'block' : 'none'; }); document.addEventListener('click', (e) => { if (this.dropdownMenuElement && !buttonContainer.contains(e.target) && !this.dropdownMenuElement.contains(e.target)) { if (this.dropdownMenuElement.style.display !== 'none') this.dropdownMenuElement.style.display = 'none'; } }); dropdownMenu.querySelector('#fastlink-closeMenu').addEventListener('click', (e) => { e.stopPropagation(); dropdownMenu.style.display = 'none'; }); dropdownMenu.querySelector('#fastlink-generateShare').addEventListener('click', async (e) => { e.stopPropagation(); dropdownMenu.style.display = 'none'; await coreLogic.generateShareLink(); }); dropdownMenu.querySelector('#fastlink-receiveDirect').addEventListener('click', (e) => { e.stopPropagation(); dropdownMenu.style.display = 'none'; this.showModal("粘贴秒传链接转存", "", 'inputLink'); }); targetElement.parentNode.insertBefore(buttonContainer, targetElement.nextSibling); buttonContainer.appendChild(dropdownMenu); console.log(`[${SCRIPT_NAME}] 秒传按钮已添加。`); return true; } else { console.warn(`[${SCRIPT_NAME}] 目标按钮区域 '${DOM_SELECTORS.TARGET_BUTTON_AREA}' 未找到。`); return false; } }, showModal: function(title, content, type = 'info', closable = true, pureLinkForClipboard = null) { if (this.modalElement) this.modalElement.remove(); this.modalElement = document.createElement('div'); this.modalElement.className = 'fastlink-modal'; let htmlContent = `${title}
`; if (type === 'inputLink') htmlContent += ``; else htmlContent += content; htmlContent += `
`; if (type === 'inputLink') { htmlContent += `转存 取消 `; } else if (type === 'showLink') { htmlContent += `复制 关闭 `; } else if (type === 'progress_stoppable') { htmlContent += `停止 关闭 `; } else { htmlContent += `关闭 `; } htmlContent += `
`; this.modalElement.innerHTML = htmlContent; document.body.appendChild(this.modalElement); const confirmBtn = this.modalElement.querySelector('#fl-m-confirm'); const copyBtn = this.modalElement.querySelector('#fl-m-copy'); const cancelBtn = this.modalElement.querySelector('#fl-m-cancel'); const stopBtn = this.modalElement.querySelector(`#${processStateManager.getStopButtonId()}`); if(confirmBtn){ confirmBtn.onclick = async () => { const linkInput = this.modalElement.querySelector(`.fastlink-modal-input`); const link = linkInput ? linkInput.value : null; if (link) { confirmBtn.disabled = true; if(cancelBtn) cancelBtn.disabled = true; await coreLogic.transferFromShareLink(link); if(this.modalElement){ confirmBtn.disabled = false; if(cancelBtn) cancelBtn.disabled = false;}} else this.showAlert("请输入链接"); };} if(copyBtn){ copyBtn.onclick = () => { const textToCopy = pureLinkForClipboard || this.modalElement.querySelector('.fastlink-link-text')?.value; if (textToCopy) { GM_setClipboard(textToCopy); this.showAlert("已复制到剪贴板!");} else this.showError("无法找到链接文本。"); };} if(cancelBtn && (closable || type === 'progress_stoppable')){ cancelBtn.onclick = () => this.hideModal(); } if(stopBtn){ stopBtn.onclick = () => processStateManager.requestStop(); } if(!closable && cancelBtn && type !== 'progress_stoppable') cancelBtn.disabled = true; }, enableModalCloseButton: function(enable = true) { if (this.modalElement) { const closeBtn = this.modalElement.querySelector('#fl-m-cancel.close-btn'); if (closeBtn) closeBtn.disabled = !enable; const stopBtn = this.modalElement.querySelector(`#${processStateManager.getStopButtonId()}`); if (stopBtn) stopBtn.disabled = true; } }, updateModalContent: function(newContent) { if (this.modalElement) { const ca = this.modalElement.querySelector(`#${this.MODAL_CONTENT_ID}`); if (ca) { if (ca.tagName === 'TEXTAREA' || ca.hasAttribute('contenteditable')) ca.value = newContent; else ca.innerHTML = newContent; ca.scrollTop = ca.scrollHeight;} } }, hideModal: function() { if (this.modalElement) { this.modalElement.remove(); this.modalElement = null; } }, showAlert: function(message, duration = 2000) { this.showModal("提示", message, 'info'); setTimeout(() => { if (this.modalElement && this.modalElement.querySelector('.fastlink-modal-title')?.textContent === "提示") this.hideModal(); }, duration); }, showError: function(message, duration = 3000) { this.showModal("错误", `${message} `, 'info'); setTimeout(() => { if (this.modalElement && this.modalElement.querySelector('.fastlink-modal-title')?.textContent === "错误") this.hideModal(); }, duration); }, getModalElement: function() { return this.modalElement; }, };
// --- Initialization ---
function initialize() { console.log(`[${SCRIPT_NAME}] ${SCRIPT_VERSION} 初始化...`); uiManager.applyStyles(); let loadAttempts = 0; const maxAttempts = 10; function tryAddButton() { loadAttempts++; const pageSeemsReady = document.querySelector(DOM_SELECTORS.TARGET_BUTTON_AREA) || document.querySelector('.Header_header__A5PFb'); if (pageSeemsReady) { if (document.querySelector('.fastlink-main-button-container')) return; if (uiManager.createDropdownButton()) return; } if (loadAttempts < maxAttempts) { const delay = loadAttempts < 3 ? 1500 : 3000; setTimeout(tryAddButton, delay); } else console.warn(`[${SCRIPT_NAME}] 达到最大尝试次数,未能添加按钮。`); } const observer = new MutationObserver((mutations, obs) => { const targetAreaExists = !!document.querySelector(DOM_SELECTORS.TARGET_BUTTON_AREA); const ourButtonExists = !!document.querySelector('.fastlink-main-button-container'); if (targetAreaExists && !ourButtonExists) { loadAttempts = 0; setTimeout(tryAddButton, 700); } }); observer.observe(document.documentElement, { childList: true, subtree: true }); setTimeout(tryAddButton, 500); }
if (document.readyState === 'complete' || document.readyState === 'interactive') setTimeout(initialize, 300); else window.addEventListener('DOMContentLoaded', () => setTimeout(initialize, 300));
// --- Base62 Helper Functions ---
function isValidHex(str) {
if (typeof str !== 'string' || str.length === 0) return false;
return /^[0-9a-fA-F]+$/.test(str);
}
function bigIntToBase62(num) {
if (typeof num !== 'bigint') throw new Error("Input must be a BigInt for Base62 conversion.");
if (num === 0n) return BASE62_CHARS[0];
let base62 = "";
let n = num; // ETags are positive, no need to check sign explicitly
while (n > 0n) {
base62 = BASE62_CHARS[Number(n % 62n)] + base62;
n = n / 62n;
}
return base62;
}
function base62ToBigInt(str) {
if (typeof str !== 'string' || str.length === 0) throw new Error("Input must be a non-empty string for Base62 conversion.");
let num = 0n;
for (let i = 0; i < str.length; i++) {
const char = str[i];
const val = BASE62_CHARS.indexOf(char);
if (val === -1) throw new Error(`Invalid Base62 character: ${char}`);
num = num * 62n + BigInt(val);
}
return num;
}
function hexToOptimizedEtag(hexEtag) { // Tries to convert to Base62
if (!isValidHex(hexEtag) || hexEtag.length === 0) { // Also handle empty etag as non-convertible to V2
return { original: hexEtag, optimized: null, useV2: false }; // Cannot convert to Base62
}
try {
const bigIntValue = BigInt('0x' + hexEtag);
const base62Value = bigIntToBase62(bigIntValue);
// Optional: Only use base62 if it's actually shorter, though for typical ETags it will be.
// And ensure it's not empty in case of some weird input like "0" hex.
if (base62Value.length > 0 && base62Value.length < hexEtag.length) {
return { original: hexEtag, optimized: base62Value, useV2: true };
}
return { original: hexEtag, optimized: hexEtag, useV2: false }; // Not shorter or empty, use original
} catch (e) {
console.warn(`[${SCRIPT_NAME}] Failed to convert ETag "${hexEtag}" to Base62: ${e.message}. Using original.`);
return { original: hexEtag, optimized: null, useV2: false }; // Error during conversion
}
}
function optimizedEtagToHex(optimizedEtag, isV2Etag) {
if (!isV2Etag) return optimizedEtag; // It's already hex (or original format)
if (typeof optimizedEtag !== 'string' || optimizedEtag.length === 0) {
throw new Error("V2 ETag cannot be empty for hex conversion.");
}
try {
const bigIntValue = base62ToBigInt(optimizedEtag);
let hex = bigIntValue.toString(16);
// Minimal padding for common ETag (MD5 is 32 chars).
// This is a heuristic. Server ultimately validates.
// if (hex.length < 32 && optimizedEtag.length >= 22) { // 22 is approx Base62 for 32 hex
// // Only pad if it looks like a typical ETag that got shortened
// }
// For now, no explicit padding to a fixed length like 32,
// as original length isn't stored and ETags could vary.
return hex;
} catch (e) {
throw new Error(`Failed to convert Base62 ETag "${optimizedEtag}" to Hex: ${e.message}`);
}
}
// --- End Base62 Helper Functions ---
})();