您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
在 PikPak 中批量重命名带有番号的视频文件或者文件夹。
// ==UserScript== // @name PikPak 批量番号重命名助手 // @name:en PikPak Batch JAV Renamer Assistant // @name:ja PikPak バッチJAV リネームアシスタント // @name:zh-CN PikPak 批量番号重命名助手 // @namespace https://github.com/CheerChen // @version 0.0.29 // @description Batch rename video files and folders with JAV codes in PikPak. // @description:en Batch rename video files and folders with JAV codes in PikPak. // @description:ja PikPakで品番付きの動画ファイルやフォルダを一括リネーム。 // @description:zh-CN 在 PikPak 中批量重命名带有番号的视频文件或者文件夹。 // @author cheerchen37 // @match *://*mypikpak.com/* // @require https://unpkg.com/react@18/umd/react.production.min.js // @require https://unpkg.com/react-dom@18/umd/react-dom.production.min.js // @grant GM_xmlhttpRequest // @grant GM_openInTab // @connect av-wiki.net // @connect api-drive.mypikpak.com // @connect api.dmm.com // @icon https://www.google.com/s2/favicons?domain=mypikpak.com // @license MIT // @homepage https://github.com/CheerChen/userscripts // @supportURL https://github.com/CheerChen/userscripts/issues // ==/UserScript== (function() { 'use strict'; const { React, ReactDOM } = window; const { useState, useEffect, useRef } = React; const { createRoot } = ReactDOM; console.log("PikPak 批量重命名脚本已加载"); // 获取认证头部信息(参考helper脚本) function getHeader() { let token = ""; let captcha = ""; for (let i = 0; i < 40; i++) { let key = window.localStorage.key(i); if (key === null) break; if (key && key.startsWith("credentials")) { let tokenData = JSON.parse(window.localStorage.getItem(key)); token = tokenData.token_type + " " + tokenData.access_token; continue; } if (key && key.startsWith("captcha")) { let tokenData = JSON.parse(window.localStorage.getItem(key)); captcha = tokenData.captcha_token; } } return { Authorization: token, "x-device-id": window.localStorage.getItem("deviceid"), "x-captcha-token": captcha }; } // 获取文件列表 function getList(parent_id) { const url = `https://api-drive.mypikpak.com/drive/v1/files?thumbnail_size=SIZE_MEDIUM&limit=500&parent_id=${parent_id}&with_audit=true&filters=%7B%22phase%22%3A%7B%22eq%22%3A%22PHASE_TYPE_COMPLETE%22%7D%2C%22trashed%22%3A%7B%22eq%22%3Afalse%7D%7D`; return fetch(url, { method: "GET", mode: "cors", cache: "no-cache", credentials: "same-origin", headers: { "Content-Type": "application/json", ...getHeader() }, redirect: "follow", referrerPolicy: "no-referrer" }).then(response => response.json()); } // 重命名文件API function renameFile(fileId, newName) { return fetch(`https://api-drive.mypikpak.com/drive/v1/files/${fileId}`, { method: "PATCH", headers: { "Content-Type": "application/json", ...getHeader() }, body: JSON.stringify({ name: newName }) }).then(async response => { const data = await response.json(); // 检查是否有错误 if (data.error || !response.ok) { const error = new Error(getErrorMessage(data.error, data.error_description)); error.code = data.error; error.details = data; throw error; } return data; }); } // 获取错误信息 function getErrorMessage(errorCode, errorDescription) { // 直接使用 API 返回的错误描述 return errorDescription || `重命名失败 (${errorCode})`; } // 根据MIME类型获取推荐的文件扩展名 function getExtensionByMimeType(mimeType) { const mimeToExt = { // 视频格式 'video/mp4': '.mp4', 'video/avi': '.avi', 'video/quicktime': '.mov', 'video/x-msvideo': '.avi', 'video/x-ms-wmv': '.wmv', 'video/webm': '.webm', 'video/x-flv': '.flv', 'video/3gpp': '.3gp', 'video/x-matroska': '.mkv', // 音频格式 'audio/mpeg': '.mp3', 'audio/wav': '.wav', 'audio/x-wav': '.wav', 'audio/flac': '.flac', 'audio/aac': '.aac', 'audio/ogg': '.ogg', 'audio/webm': '.webm', // 图片格式 'image/jpeg': '.jpg', 'image/png': '.png', 'image/gif': '.gif', 'image/webp': '.webp', 'image/bmp': '.bmp', 'image/svg+xml': '.svg', // 文档格式 'application/pdf': '.pdf', 'application/zip': '.zip', 'application/x-rar-compressed': '.rar', 'application/x-7z-compressed': '.7z', 'text/plain': '.txt', // 默认二进制文件 'application/octet-stream': '.bin' }; return mimeToExt[mimeType] || ''; } // 三种提取格式的番号识别 function extractKeyword(fileName, isFile = false) { // 对于文件和文件夹,都去掉扩展名,因为扩展名通常不包含番号信息 // 而且可能造成误判(如 .mp4 被识别为 mp-4) const cleanName = fileName.replace(/\.[^.]+$/, ''); // 处理前导0的函数:保留最少3位数字 function cleanLeadingZeros(numberStr) { // 先去掉所有前导0,看看实际的数字位数 const withoutLeadingZeros = numberStr.replace(/^0+/, '') || '0'; const actualDigits = withoutLeadingZeros.length; // 如果去掉前导0后的数字位数 >= 3位,则去除前导0 if (actualDigits >= 3) { return withoutLeadingZeros; } // 如果去掉前导0后的数字位数 < 3位,则补足到3位 return withoutLeadingZeros.padStart(3, '0'); } // 使用全局匹配找到所有可能的番号,然后取最后一个 let allMatches = []; // 格式1: 标准格式 ABC-123 (优先级最高) let matches = [...cleanName.matchAll(/([a-zA-Z]+)-(\d+)/g)]; matches.forEach(match => { // 检查原始数字部分长度(包括前导0),少于3位不认为是番号 if (match[2].length < 3) { return; // 跳过数字部分少于3位的匹配 } const cleanedNumber = cleanLeadingZeros(match[2]); allMatches.push({ format: 'standard', keyword: `${match[1].toLowerCase()}-${cleanedNumber}`, originalMatch: match[0], // 保存原始匹配 series: match[1].toLowerCase(), number: cleanedNumber, index: match.index, priority: 1 }); }); // 格式2: 无连字符 ABC123 matches = [...cleanName.matchAll(/([a-zA-Z]+)(\d+)/g)]; matches.forEach(match => { // 检查原始数字部分长度(包括前导0),少于3位不认为是番号 if (match[2].length < 3) { return; // 跳过数字部分少于3位的匹配 } const cleanedNumber = cleanLeadingZeros(match[2]); allMatches.push({ format: 'no-dash', keyword: `${match[1].toLowerCase()}-${cleanedNumber}`, originalMatch: match[0], // 保存原始匹配 series: match[1].toLowerCase(), number: cleanedNumber, index: match.index, priority: 2 }); }); // 格式3: 通用匹配 matches = [...cleanName.matchAll(/([a-zA-Z]{3,})(\d+)/g)]; matches.forEach(match => { // 检查原始数字部分长度(包括前导0),少于3位不认为是番号 if (match[2].length < 3) { return; // 跳过数字部分少于3位的匹配 } const cleanedNumber = cleanLeadingZeros(match[2]); allMatches.push({ format: 'generic', keyword: `${match[1].toLowerCase()}-${cleanedNumber}`, originalMatch: match[0], // 保存原始匹配 series: match[1].toLowerCase(), number: cleanedNumber, index: match.index, priority: 3 }); }); if (allMatches.length === 0) { return null; } // 按位置排序(最后出现的优先),如果位置相同则按优先级排序 allMatches.sort((a, b) => { if (a.index !== b.index) { return b.index - a.index; // 位置越靠后越优先 } return a.priority - b.priority; // 优先级越小越优先 }); const result = allMatches[0]; return { format: result.format, keyword: result.keyword, originalMatch: result.originalMatch, series: result.series, number: result.number }; } // 构建直接访问URL function buildDirectAccessUrl(keyword) { return `https://av-wiki.net/${keyword.toLowerCase()}/`; } // 构建搜索URL function buildSearchUrl(searchTerm) { return `https://av-wiki.net/?s=${encodeURIComponent(searchTerm)}&post_type=product`; } // 为了测试目的而保留的函数,实际逻辑已整合到主要函数中 function predictDirectAccess(keyword) { if (!keyword) { return { url: '', likely: false }; } return { url: buildDirectAccessUrl(keyword), likely: keyword.match(/^[a-zA-Z]+-\d+$/) !== null }; } // 为了测试目的而保留的函数,实际逻辑已整合到主要函数中 function getSearchFallback(originalMatch) { if (!originalMatch) { return { searchUrl: '', }; } return { searchUrl: buildSearchUrl(originalMatch), }; } // 获取回退搜索的详情页链接 function getFallbackDetailUrl(searchTerm) { return new Promise((resolve, reject) => { if (!searchTerm) { resolve(null); return; } const searchUrl = buildSearchUrl(searchTerm); httpRequest({ method: "GET", url: searchUrl }).then(function(response) { const parser = new DOMParser(); const doc = parser.parseFromString(response.responseText, "text/html"); const listItems = doc.querySelectorAll('.read-more a'); const seriesMatch = searchTerm.match(/[a-zA-Z]+/); if (!seriesMatch) { resolve(null); return; } const seriesName = seriesMatch[0].toLowerCase(); const keywordRegex = new RegExp(seriesName, 'i'); for (let item of listItems) { const href = item.href; if (href && keywordRegex.test(href.toLowerCase())) { resolve(href); return; } } resolve(null); }).catch(function(error) { console.error(`[getFallbackDetailUrl] HTTP request failed:`, error); resolve(null); }); }); } // HTTP 请求适配器 - 在测试环境使用代理,在 userscript 环境使用 GM_xmlhttpRequest function httpRequest(options) { return new Promise((resolve, reject) => { // 检查是否在 userscript 环境中 if (typeof GM_xmlhttpRequest !== 'undefined') { GM_xmlhttpRequest({ method: options.method || 'GET', url: options.url, headers: options.headers || {}, onload: function(response) { resolve({ status: response.status, responseText: response.responseText }); }, onerror: function(error) { reject(new Error(`Request failed: ${error.statusText || 'Network error'}`)); }, ontimeout: function() { reject(new Error('Request timeout')); } }); } else { // 测试环境中使用代理服务器 const proxyUrl = `http://localhost:3001?url=${encodeURIComponent(options.url)}`; fetch(proxyUrl, { method: options.method || 'GET' }) .then(response => response.text()) .then(responseText => { resolve({ status: 200, responseText: responseText }); }) .catch(reject); } }); } // 解析详情页内容,提取标题和日期 function parseDetailPage(responseText) { const parser = new DOMParser(); const doc = parser.parseFromString(responseText, "text/html"); const ogTitle = doc.querySelector('.blockquote-like p'); const dateElement = doc.querySelector('time.date.published'); let name = ogTitle ? ogTitle.textContent : null; let date = dateElement ? dateElement.getAttribute('datetime') : null; if (name) { name = name.replace(/[\/:*?"<>|\x00-\x1F]/g, '_'); } return { title: name, date: date }; } // Query DMM API for title and date function queryDMM(extractionResult, dmmConfig = null) { return new Promise((resolve, reject) => { if (!extractionResult?.keyword) { return reject('Invalid extraction result provided.'); } if (!dmmConfig && typeof window !== 'undefined' && window.PikPakRenamerConfig) { dmmConfig = window.PikPakRenamerConfig.dmm; } if (!dmmConfig?.enabled) { return reject('DMM query not enabled or configured'); } if (!dmmConfig.apiId || !dmmConfig.affiliateId) { return reject('DMM API configuration incomplete'); } const searchQuery = `${extractionResult.series}00${extractionResult.number}`; const apiUrl = new URL('https://api.dmm.com/affiliate/v3/ItemList'); apiUrl.searchParams.set('api_id', dmmConfig.apiId); apiUrl.searchParams.set('affiliate_id', dmmConfig.affiliateId); apiUrl.searchParams.set('site', 'FANZA'); apiUrl.searchParams.set('keyword', searchQuery); apiUrl.searchParams.set('output', 'json'); console.log(`[queryDMM] Searching: ${searchQuery}`); httpRequest({ method: "GET", url: apiUrl.toString() }) .then(response => { if (response.status !== 200) { throw new Error(`HTTP ${response.status}`); } let jsonData; try { jsonData = JSON.parse(response.responseText); } catch (parseError) { throw new Error('API response parsing failed'); } if (jsonData.result?.status !== 200) { throw new Error(`API error: ${jsonData.result?.message || 'Unknown error'}`); } if (!jsonData.result?.items?.length) { throw new Error('No matching videos found'); } const firstItem = jsonData.result.items[0]; let title = firstItem.title; let date = firstItem.date; if (title) { title = title.replace(/[\/:*?"<>|\x00-\x1F]/g, '_'); } if (date?.includes(' ')) { date = date.split(' ')[0]; } if (!title) { throw new Error('API returned incomplete data'); } const finalTitle = `【${extractionResult.keyword.toUpperCase()}】${title}`; console.log(`[queryDMM] Success: ${extractionResult.keyword} -> ${finalTitle}`); resolve({ title: finalTitle, date: date || null }); }) .catch(error => { console.error(`[queryDMM] Failed: ${extractionResult.keyword}`, error); reject(`DMM query failed: ${error.message}`); }); }); } // 查询AV-wiki获取标题和日期 function queryAVwiki(extractionResult) { return new Promise((resolve, reject) => { if (!extractionResult || !extractionResult.keyword) { return reject('Invalid extraction result provided.'); } const directUrl = buildDirectAccessUrl(extractionResult.keyword); httpRequest({ method: "GET", url: directUrl }) .then(response => { // 检查是否成功获取到详情页 if (response.status === 200 && response.responseText.includes('blockquote-like')) { const { title, date } = parseDetailPage(response.responseText); if (title) { console.log(`[queryAVwiki] DirectAccess 成功: ${extractionResult.keyword}`); resolve({ title, date }); return; // 成功,终止Promise链 } } // 若无有效标题或页面结构不对,抛出错误进入fallback console.log(`[queryAVwiki] DirectAccess 失败,准备进入 Fallback: ${extractionResult.keyword}`); throw new Error('Direct access failed or page content invalid.'); }) .catch(async () => { // 直接访问失败,回退到搜索方式 console.log(`[queryAVwiki] 开始 Fallback 搜索: ${extractionResult.originalMatch}`); try { const detailUrl = await getFallbackDetailUrl(extractionResult.originalMatch); if (detailUrl) { const detailResponse = await httpRequest({ method: "GET", url: detailUrl }); const { title, date } = parseDetailPage(detailResponse.responseText); if (title) { console.log(`[queryAVwiki] Fallback 成功: ${extractionResult.originalMatch} -> ${detailUrl}`); resolve({ title, date }); } else { console.log(`[queryAVwiki] Fallback 失败 - 未找到标题: ${extractionResult.originalMatch}`); reject('未找到标题 (Fallback)'); } } else { console.log(`[queryAVwiki] Fallback 失败 - 未找到匹配的番号: ${extractionResult.originalMatch}`); reject('未找到匹配的番号 (Fallback)'); } } catch (fallbackError) { console.error(`[queryAVwiki] Fallback 网络请求失败: ${extractionResult.originalMatch}`, fallbackError); reject('网络请求失败 (Fallback)'); } }); }); } // 延迟函数 const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms)); // 通用样式常量 const STYLES = { overlay: { position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0, 0, 0, 0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center' }, modal: { backgroundColor: '#fff', borderRadius: '8px', padding: '24px', boxShadow: '0 10px 25px rgba(0, 0, 0, 0.2)' }, header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px', borderBottom: '1px solid #ebeef5', paddingBottom: '16px' }, button: { padding: '8px 16px', border: 'none', borderRadius: '4px', cursor: 'pointer' }, primaryBtn: { backgroundColor: '#409eff', color: '#fff' }, secondaryBtn: { backgroundColor: '#fff', color: '#606266', border: '1px solid #dcdfe6' }, disabledBtn: { backgroundColor: '#c0c4cc', cursor: 'not-allowed', opacity: 0.6 }, text: { primary: '#303133', secondary: '#606266', success: '#67c23a', danger: '#f56c6c', warning: '#e6a23c' } }; // 配置存储 const CONFIG_KEY = 'pikpak-batch-renamer-config'; const getConfig = () => { try { return JSON.parse(localStorage.getItem(CONFIG_KEY)) || { addDatePrefix: false, fixFileExtension: true, useDMM: false, dmmApiId: '', dmmAffiliateId: '' }; } catch { return { addDatePrefix: false, fixFileExtension: true, useDMM: false, dmmApiId: '', dmmAffiliateId: '' }; } }; const setConfig = (config) => { localStorage.setItem(CONFIG_KEY, JSON.stringify(config)); }; // 内联配置面板组件 const ConfigPanel = ({ config, onConfigChange }) => { const configOptions = [ { key: 'addDatePrefix', label: '在文件名开头增加发行日期', desc: '启用后文件名格式为: {日期} {标题},例如: 2025-09-12 标题名称.mp4', type: 'checkbox' }, { key: 'fixFileExtension', label: '修复文件扩展名', desc: '当文件缺少扩展名时,根据文件MIME类型自动添加合适的扩展名', type: 'checkbox' }, { key: 'useDMM', label: '使用 DMM API 查询', desc: '启用后将使用 DMM 官方 API 进行查询,需要配置 API ID 和 Affiliate ID', type: 'checkbox' }, { key: 'dmmApiId', label: 'DMM API ID', desc: '从 DMM 官方申请的 API ID', type: 'text', placeholder: '请输入 DMM API ID', dependsOn: 'useDMM' }, { key: 'dmmAffiliateId', label: 'DMM Affiliate ID', desc: '从 DMM 官方申请的 Affiliate ID', type: 'text', placeholder: '请输入 DMM Affiliate ID', dependsOn: 'useDMM' } ]; const handleConfigChange = (newConfig) => { setConfig(newConfig); // Save to localStorage onConfigChange(newConfig); // Update parent component state }; return React.createElement('div', { style: { padding: '12px', backgroundColor: '#f8f9fa', borderRadius: '6px', marginTop: '-4px', marginBottom: '16px', borderTop: '1px solid #ebeef5' } }, configOptions.map((option, i) => { // 检查依赖条件 if (option.dependsOn && !config[option.dependsOn]) { return null; } return React.createElement('div', { key: `config-option-${i}` }, [ option.type === 'checkbox' ? [ React.createElement('label', { key: `option${i}`, style: { display: 'flex', alignItems: 'center', cursor: 'pointer', padding: '8px 0' } }, [ React.createElement('input', { key: 'checkbox', type: 'checkbox', checked: config[option.key], onChange: (e) => handleConfigChange({ ...config, [option.key]: e.target.checked }), style: { marginRight: '8px' } }), React.createElement('span', { key: 'label', style: { fontSize: '14px', color: STYLES.text.primary } }, option.label) ]), React.createElement('div', { key: `desc${i}`, style: { fontSize: '12px', color: STYLES.text.secondary, marginLeft: '24px', lineHeight: '1.4', marginBottom: '12px' } }, option.desc) ] : [ React.createElement('div', { key: `label${i}`, style: { fontSize: '14px', color: STYLES.text.primary, marginBottom: '4px' } }, option.label), React.createElement('input', { key: `input${i}`, type: 'text', value: config[option.key] || '', placeholder: option.placeholder || '', onChange: (e) => handleConfigChange({ ...config, [option.key]: e.target.value }), style: { width: '100%', padding: '6px 8px', border: '1px solid #dcdfe6', borderRadius: '4px', fontSize: '13px', marginBottom: '4px' } }), React.createElement('div', { key: `desc${i}`, style: { fontSize: '12px', color: STYLES.text.secondary, lineHeight: '1.4', marginBottom: '12px' } }, option.desc) ] ]); }).filter(Boolean) ); }; // 文件项组件 const FileItem = ({ file, selected, onSelect, validationStatus, newName, sortBy }) => { const statusMap = { valid: { icon: '✅', color: STYLES.text.success }, invalid: { icon: '❌', color: STYLES.text.danger }, loading: { icon: '⏳', color: STYLES.text.warning } }; const status = statusMap[validationStatus] || { icon: '', color: STYLES.text.secondary }; const formatBytes = (bytes, decimals = 2) => { if (!bytes || bytes === 0) return '0 Bytes'; const k = 1024; const dm = decimals < 0 ? 0 : decimals; const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; }; const formatFileInfo = (item) => { switch (sortBy) { case 'size': return item.size ? formatBytes(parseInt(item.size)) : 'N/A'; case 'created_time': return item.created_time ? new Date(item.created_time).toLocaleString() : 'N/A'; case 'modified_time': return item.modified_time ? new Date(item.modified_time).toLocaleString() : 'N/A'; default: return ''; } }; return React.createElement('div', { style: { display: 'flex', alignItems: 'center', padding: '8px 0', borderBottom: '1px solid #f0f0f0', opacity: validationStatus === 'invalid' ? 0.5 : 1 } }, [ React.createElement('input', { key: 'checkbox', type: 'checkbox', checked: selected, onChange: (e) => onSelect(file.id, e.target.checked), disabled: validationStatus === 'invalid', style: { marginRight: '10px' } }), React.createElement('span', { key: 'icon', style: { marginRight: '8px', fontSize: '16px' } }, file.kind === 'drive#folder' ? '📁' : '📄'), React.createElement('div', { key: 'content', style: { flex: 1, minWidth: 0 } }, [ React.createElement('div', { key: 'name', style: { fontWeight: '500', color: STYLES.text.primary, wordBreak: 'break-word' } }, file.name), newName && React.createElement('div', { key: 'newname', style: { fontSize: '12px', color: STYLES.text.success, marginTop: '2px', wordBreak: 'break-word' } }, `→ ${newName}`) ]), React.createElement('span', { key: 'info', style: { marginLeft: '16px', fontSize: '12px', color: STYLES.text.secondary, whiteSpace: 'nowrap' } }, formatFileInfo(file)), React.createElement('span', { key: 'status', style: { marginLeft: '16px', color: status.color, fontSize: '16px' } }, status.icon) ]); }; // 辅助函数:创建按钮 const createButton = (key, text, onClick, styleType = 'primary', disabled = false) => { const btnStyle = { ...STYLES.button }; if (disabled) Object.assign(btnStyle, STYLES.disabledBtn); else Object.assign(btnStyle, STYLES[styleType + 'Btn']); return React.createElement('button', { key, onClick, disabled, style: btnStyle }, text); }; // 主模态窗口组件 const BatchRenameModal = ({ isOpen, onClose }) => { const [files, setFiles] = useState([]); const [selectedFiles, setSelectedFiles] = useState(new Set()); const [validationResults, setValidationResults] = useState({}); const [newNames, setNewNames] = useState({}); const [isValidating, setIsValidating] = useState(false); const [isRenaming, setIsRenaming] = useState(false); const [progress, setProgress] = useState({ current: 0, total: 0 }); const [showConfirmation, setShowConfirmation] = useState(false); const [renameResults, setRenameResults] = useState(null); const [config, setConfigState] = useState(getConfig()); const [showConfigPanel, setShowConfigPanel] = useState(false); const [sortBy, setSortBy] = useState('name'); const [sortDirection, setSortDirection] = useState('asc'); // 设置全局配置供core-functions使用 useEffect(() => { window.PikPakRenamerConfig = { dmm: { enabled: config.useDMM, apiId: config.dmmApiId, affiliateId: config.dmmAffiliateId } }; }, [config.useDMM, config.dmmApiId, config.dmmAffiliateId]); const sortFiles = (filesToSort, currentSortBy, currentSortDirection) => { const sorted = [...filesToSort].sort((a, b) => { const aIsFolder = a.kind === 'drive#folder'; const bIsFolder = b.kind === 'drive#folder'; if (aIsFolder && !bIsFolder) return -1; if (!aIsFolder && bIsFolder) return 1; let aValue = a[currentSortBy]; let bValue = b[currentSortBy]; if (currentSortBy === 'size') { aValue = parseInt(aValue || '0'); bValue = parseInt(bValue || '0'); } else if (currentSortBy === 'created_time' || currentSortBy === 'modified_time') { aValue = new Date(aValue).getTime(); bValue = new Date(bValue).getTime(); } else { aValue = aValue?.toLowerCase() || ''; bValue = bValue?.toLowerCase() || ''; } let comparison = 0; if (aValue > bValue) { comparison = 1; } else if (aValue < bValue) { comparison = -1; } return currentSortDirection === 'asc' ? comparison : -comparison; }); setFiles(sorted); }; useEffect(() => { if (isOpen) { let parent_id = window.location.href.split("/").pop(); if (parent_id === "all") parent_id = ""; getList(parent_id).then(res => { if (res.files) { sortFiles(res.files, sortBy, sortDirection); } }).catch(error => { console.error('获取文件列表失败:', error); }); } }, [isOpen]); useEffect(() => { sortFiles(files, sortBy, sortDirection); }, [sortBy, sortDirection]); const handleConfigChange = (newConfig) => { setConfigState(newConfig); setConfig(newConfig); // Persist to localStorage }; // 选择文件 const handleFileSelect = (fileId, selected) => { const newSelected = new Set(selectedFiles); if (selected) { newSelected.add(fileId); } else { newSelected.delete(fileId); } setSelectedFiles(newSelected); }; // 全选/全取消 const handleSelectAll = (selectAll) => { if (selectAll) { const validFileIds = files .filter(file => validationResults[file.id] !== 'invalid') .map(file => file.id); setSelectedFiles(new Set(validFileIds)); } else { setSelectedFiles(new Set()); } }; // 验证AV番号 const validateFiles = async () => { if (selectedFiles.size === 0) { alert('请先选择要扫描的文件'); return; } setIsValidating(true); const results = {}; const names = {}; const selectedFilesList = files.filter(file => selectedFiles.has(file.id)); const batchSize = 3; const delay_ms = 2000; for (let i = 0; i < selectedFilesList.length; i += batchSize) { const batch = selectedFilesList.slice(i, i + batchSize); await Promise.all(batch.map(async (file) => { const isFile = file.kind !== 'drive#folder'; const keyword = extractKeyword(file.name, isFile); if (!keyword) { results[file.id] = 'invalid'; return; } results[file.id] = 'loading'; setValidationResults(prev => ({...prev, ...results})); try { let result; if (config.useDMM && config.dmmApiId && config.dmmAffiliateId) { // 使用DMM API查询 result = await queryDMM(keyword); } else { // 使用AV-wiki查询 result = await queryAVwiki(keyword); } results[file.id] = 'valid'; let extension = ''; if (isFile) { const extensionMatch = file.name.match(/(\.[^.]+)$/); extension = extensionMatch ? extensionMatch[1] : ''; if (!extension && config.fixFileExtension && file.mime_type) { extension = getExtensionByMimeType(file.mime_type); } } let finalName = config.addDatePrefix && result.date ? `${result.date} ${result.title}` : result.title; names[file.id] = extension ? `${finalName}${extension}` : finalName; } catch (error) { results[file.id] = 'invalid'; } })); setValidationResults(prev => ({...prev, ...results})); setNewNames(prev => ({...prev, ...names})); if (i + batchSize < selectedFilesList.length) { await delay(delay_ms); } } setIsValidating(false); }; // 执行批量重命名 const performBatchRename = async () => { setIsRenaming(true); const selectedFilesList = files.filter(file => selectedFiles.has(file.id) && validationResults[file.id] === 'valid' ); const total = selectedFilesList.length; let success = 0; let failed = 0; const failedFiles = []; const batchSize = 5; const delay_ms = 1000; for (let i = 0; i < selectedFilesList.length; i += batchSize) { const batch = selectedFilesList.slice(i, i + batchSize); await Promise.all(batch.map(async (file) => { const newName = newNames[file.id]; if (file.name === newName) { success++; setProgress({ current: success + failed, total }); return; } try { await renameFile(file.id, newName); success++; } catch (error) { failed++; failedFiles.push({ name: file.name, error: error.message, code: error.code || 'unknown' }); } setProgress({ current: success + failed, total }); })); if (i + batchSize < selectedFilesList.length) { await delay(delay_ms); } } setRenameResults({ success, failed, total, failedFiles }); setIsRenaming(false); }; // 重置状态 const resetModal = () => { setFiles([]); setSelectedFiles(new Set()); setValidationResults({}); setNewNames({}); setShowConfirmation(false); setRenameResults(null); setProgress({ current: 0, total: 0 }); setShowConfigPanel(false); }; if (!isOpen) return null; return React.createElement('div', { style: { ...STYLES.overlay, zIndex: 10000 } }, React.createElement('div', { style: { ...STYLES.modal, width: '90%', maxWidth: '800px', maxHeight: '80vh', display: 'flex', flexDirection: 'column' } }, [ React.createElement('div', { key: 'header', style: STYLES.header }, [ React.createElement('h2', { key: 'title', style: { margin: 0, color: STYLES.text.primary, fontSize: '18px' } }, renameResults ? '重命名完成' : (showConfirmation ? '确认重命名' : '批量重命名文件')), React.createElement('button', { key: 'close', onClick: () => { resetModal(); onClose(); if (renameResults && renameResults.success > 0) { setTimeout(() => window.location.reload(), 300); } }, style: { background: 'none', border: 'none', fontSize: '24px', cursor: 'pointer', color: STYLES.text.secondary, padding: '4px' } }, '×') ]), // 内容区域 React.createElement('div', { key: 'content', style: { flex: 1, overflowY: 'auto' } }, [ // 结果显示 renameResults && React.createElement('div', { key: 'results', style: { padding: '20px', backgroundColor: '#f0f9ff', borderRadius: '6px', marginBottom: '20px' } }, [ React.createElement('div', { key: 'summary', style: { fontSize: '16px', fontWeight: '500', marginBottom: '10px' } }, `重命名完成!成功: ${renameResults.success}, 失败: ${renameResults.failed}, 总计: ${renameResults.total}`), renameResults.failedFiles.length > 0 && React.createElement('div', { key: 'failed', style: { fontSize: '14px', color: '#f56c6c' } }, [ React.createElement('div', { key: 'title' }, '失败的文件:'), ...renameResults.failedFiles.map((file, index) => React.createElement('div', { key: index }, `${file.name}: ${file.error}`) ) ]) ]), // 确认页面 showConfirmation && !renameResults && React.createElement('div', { key: 'confirmation' }, [ React.createElement('div', { key: 'info', style: { padding: '16px', backgroundColor: '#fff7e6', borderRadius: '6px', marginBottom: '16px', border: '1px solid #ffd666' } }, `即将重命名 ${Array.from(selectedFiles).filter(id => validationResults[id] === 'valid').length} 个文件,请确认后继续。`), React.createElement('div', { key: 'preview', style: { maxHeight: '400px', overflowY: 'auto' } }, files.filter(file => selectedFiles.has(file.id) && validationResults[file.id] === 'valid').map(file => React.createElement('div', { key: file.id, style: { padding: '8px', borderBottom: '1px solid #f0f0f0', fontSize: '14px' } }, [ React.createElement('div', { key: 'old', style: { color: '#909399' } }, `原名: ${file.name}`), React.createElement('div', { key: 'new', style: { color: '#67c23a' } }, `新名: ${newNames[file.id]}`) ]) )) ]), // 文件列表 !showConfirmation && !renameResults && React.createElement('div', { key: 'filelist' }, [ // 工具栏 React.createElement('div', { key: 'toolbar-wrapper', style: { padding: '12px', backgroundColor: '#f8f9fa', borderRadius: '6px', marginBottom: '16px' } }, [ React.createElement('div', { key: 'toolbar-main', style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } }, [ React.createElement('div', { key: 'select-all', style: { display: 'flex', alignItems: 'center' } }, [ React.createElement('input', { key: 'selectall-cb', type: 'checkbox', onChange: (e) => handleSelectAll(e.target.checked), style: { marginRight: '8px' } }), React.createElement('span', { key: 'label' }, '全选') ]), React.createElement('div', { key: 'sort-controls', style: { display: 'flex', alignItems: 'center', gap: '8px' } }, [ React.createElement('select', { value: sortBy, onChange: e => setSortBy(e.target.value), style: { padding: '4px', borderRadius: '4px', border: '1px solid #dcdfe6' } }, [ React.createElement('option', { value: 'name' }, '名称'), React.createElement('option', { value: 'created_time' }, '创建时间'), React.createElement('option', { value: 'modified_time' }, '修改时间'), React.createElement('option', { value: 'size' }, '大小') ]), React.createElement('select', { value: sortDirection, onChange: e => setSortDirection(e.target.value), style: { padding: '4px', borderRadius: '4px', border: '1px solid #dcdfe6' } }, [ React.createElement('option', { value: 'asc' }, '升序'), React.createElement('option', { value: 'desc' }, '降序') ]) ]), React.createElement('div', { key: 'actions', style: { display: 'flex', gap: '8px' } }, [ createButton('validate', isValidating ? '扫描中...' : (selectedFiles.size === 0 ? '请选择文件' : '扫描番号'), validateFiles, 'primary', isValidating || selectedFiles.size === 0), React.createElement('button', { key: 'config-toggle', onClick: () => setShowConfigPanel(!showConfigPanel), title: '配置选项', style: { padding: '8px', backgroundColor: showConfigPanel ? '#e9ecef' : 'transparent', color: STYLES.text.secondary, border: '1px solid #dcdfe6', borderRadius: '4px', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' } }, React.createElement('svg', { width: '16', height: '16', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: '2' }, [ React.createElement('circle', { cx: '12', cy: '12', r: '3' }), React.createElement('path', { d: 'M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z' }) ])) ]) ]), showConfigPanel && React.createElement(ConfigPanel, { key: 'config-panel', config: config, onConfigChange: handleConfigChange }) ]), // 文件项 React.createElement('div', { key: 'items', style: { maxHeight: '400px', overflowY: 'auto' } }, files.map(file => React.createElement(FileItem, { key: file.id, file: file, selected: selectedFiles.has(file.id), onSelect: handleFileSelect, validationStatus: validationResults[file.id], newName: newNames[file.id], sortBy: sortBy }) )) ]) ]), // 底部按钮 React.createElement('div', { key: 'footer', style: { display: 'flex', justifyContent: 'flex-end', gap: '12px', marginTop: '20px', paddingTop: '16px', borderTop: '1px solid #ebeef5' } }, [ // 进度显示 isRenaming && React.createElement('div', { key: 'progress', style: { flex: 1, display: 'flex', alignItems: 'center', color: '#606266' } }, `重命名进度: ${progress.current}/${progress.total}`), // 按钮组 !renameResults && React.createElement('div', { key: 'buttons', style: { display: 'flex', gap: '12px' } }, [ !showConfirmation ? [ createButton('cancel', '取消', () => { resetModal(); onClose(); }, 'secondary'), createButton('next', '下一步', () => setShowConfirmation(true), 'primary', selectedFiles.size === 0 || Object.values(validationResults).every(s => s !== 'valid')) ] : [ createButton('back', '上一步', () => setShowConfirmation(false), 'secondary', isRenaming), createButton('confirm', isRenaming ? '重命名中...' : '确认重命名', performBatchRename, 'primary', isRenaming) ] ]) ]) ])); }; // 等待页面加载完成后挂载React应用 function initApp() { if (location.pathname === '/') return; // 不在首页显示 // 查找现有的 file-operations 容器 const fileOperations = document.querySelector('.file-operations'); if (fileOperations) { // 检查是否已经添加过按钮 if (fileOperations.querySelector('.batch-rename-button')) return; // 创建按钮HTML结构 const batchRenameItem = document.createElement('li'); batchRenameItem.className = 'icon-with-label batch-rename-button'; batchRenameItem.innerHTML = ` <a aria-label="批量重命名" class="pp-link-button hover-able" href="javascript:void(0)"> <span class="icon-hover-able pp-icon" style="--icon-color: var(--color-secondary-text); --icon-color-hover: var(--color-primary); display: flex; flex: 0 0 24px; width: 24px; height: 24px;"> <svg fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <path stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/> </svg> </span> <span class="label">批量重命名</span> </a> `; // 添加点击事件 batchRenameItem.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); // 创建模态窗口容器 if (!document.getElementById('pikpak-batch-renamer-modal')) { const modalContainer = document.createElement('div'); modalContainer.id = 'pikpak-batch-renamer-modal'; document.body.appendChild(modalContainer); const root = createRoot(modalContainer); root.render(React.createElement(BatchRenameModal, { isOpen: true, onClose: () => { root.unmount(); document.body.removeChild(modalContainer); } })); } }); // 查找合适的插入位置(在分割线之前) const divider = fileOperations.querySelector('.divider-in-operations'); if (divider) { fileOperations.insertBefore(batchRenameItem, divider); } else { // 如果没有分割线,添加到末尾 fileOperations.appendChild(batchRenameItem); } } else { // 如果找不到 file-operations,延迟重试 setTimeout(initApp, 1000); } } // 页面加载完成后初始化 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initApp); } else { setTimeout(initApp, 1000); } })();