// ==UserScript== // @name PikPak Batch JAV Renamer Assistant // @name:en PikPak Batch JAV Renamer Assistant // @name:ja PikPak バッチJAV リネームアシスタント // @name:zh-CN PikPak 批量番号重命名助手 // @name:zh-TW PikPak 批量番號重命名助手 // @name:ko PikPak 일괄 JAV 이름 변경 도우미 // @name:ru PikPak Пакетное переименование JAV // @name:es PikPak Renombrador JAV por lotes // @name:pt-BR PikPak Renomeador JAV em lote // @name:fr PikPak Renommeur JAV par lots // @name:de PikPak JAV-Batch-Umbenennung // @namespace https://github.com/CheerChen // @version 0.1.3 // @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 中批量重命名带有番号的视频文件或者文件夹。 // @description:zh-TW 在 PikPak 中批量重新命名帶有番號的影片檔案或資料夾。 // @description:ko PikPak에서 JAV 코드가 포함된 비디오 파일과 폴더를 일괄 이름 변경합니다. // @description:ru Пакетное переименование видеофайлов и папок с кодами JAV в PikPak. // @description:es Renombrar por lotes archivos de video y carpetas con códigos JAV en PikPak. // @description:pt-BR Renomear em lote arquivos de vídeo e pastas com códigos JAV no PikPak. // @description:fr Renommer par lots les fichiers vidéo et dossiers avec des codes JAV dans PikPak. // @description:de Batch-Umbenennung von Videodateien und Ordnern mit JAV-Codes in PikPak. // @author cheerchen37 // @match *://*mypikpak.com/* // @match *://*mypikpak.net/* // @match *://*pikpak.me/* // @require https://unpkg.com/preact@10/dist/preact.umd.js // @require https://unpkg.com/preact@10/hooks/dist/hooks.umd.js // @require https://unpkg.com/htm@3/dist/htm.umd.js // @grant GM_xmlhttpRequest // @grant GM_openInTab // @connect av-wiki.net // @connect api-drive.mypikpak.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 // @downloadURL https://update.greasyfork.icu/scripts/549418/PikPak%20Batch%20JAV%20Renamer%20Assistant.user.js // @updateURL https://update.greasyfork.icu/scripts/549418/PikPak%20Batch%20JAV%20Renamer%20Assistant.meta.js // ==/UserScript== (function () { 'use strict'; const { h, render } = preact; const { useState, useEffect } = preactHooks; const html = htm.bind(h); // ─── Parser (ported from bangou/parser/parser.go) ─── const sitePrefixRe = /^([a-zA-Z0-9.-]+)@/; const tokenizeRe = /[^a-zA-Z0-9]+/; const partTokenRe = /^part(\d+)$/i; const tagTokenRe = /^(8k|4k|vr)$/i; const heyzoRe = /^(heyzo)(\d{4})(?:\D|$)/i; const mgstageRe = /^(\d{3,4}[a-zA-Z]{2,6})(\d{3,6})(?:\D|$)/i; const standardRe = /^\d*([a-zA-Z]{2,6})(\d{3,6})(?:\D|$)/i; const DEBUG_KEY = 'pikpak-batch-renamer-debug'; const FLOAT_BUTTON_POS_KEY = 'pikpak-batch-renamer-fab-pos'; const commonDomainTokenRe = /^(com|net|org|me|cn|jp|tv|xyz|club)$/i; const DEBUG_ENABLED = (() => { try { const v = localStorage.getItem(DEBUG_KEY); if (v == null) return true; // default on for troubleshooting parser/query mismatches return v === '1' || v === 'true'; } catch { return true; } })(); function debugLog(label, payload) { if (!DEBUG_ENABLED) return; if (payload === undefined) console.log(`[PBR] ${label}`); else console.log(`[PBR] ${label}`, payload); } function debugRawHtml(label, url, resp) { return; } function trimLeadingZeros(s) { let n = parseInt(s, 10); if (isNaN(n)) return s; let out = String(n); while (out.length < 3) out = '0' + out; return out; } function hasLetter(s) { return /[a-zA-Z]/.test(s); } function endsWithLetter(s) { return s.length > 0 && /[a-zA-Z]$/.test(s); } function isPureDigits(s) { return s.length > 0 && /^\d+$/.test(s); } function extractNumber(raw) { const rules = [ { re: heyzoRe, fmt: m => m[1].toUpperCase() + '-' + m[2] }, { re: mgstageRe, fmt: m => m[1].toUpperCase() + '-' + trimLeadingZeros(m[2]) }, { re: standardRe, fmt: m => m[1].toUpperCase() + '-' + trimLeadingZeros(m[2]) }, ]; for (const { re, fmt } of rules) { const m = raw.match(re); if (!m || m.length <= 2) continue; // find end of capture group 2 to get rawMatch const fullMatch = m[0]; const rawMatch = raw.substring(0, raw.indexOf(fullMatch) + fullMatch.replace(/\D$/, '').length); return { number: fmt(m), rawNumber: rawMatch.toLowerCase() }; } return { number: '', rawNumber: '' }; } function parseNumberParts(number) { const m = number.match(/^([0-9]*[A-Z]+)-(\d+)$/); if (!m) return null; return { series: m[1].replace(/^\d+/, '').toLowerCase(), numRaw: m[2], num: parseInt(m[2], 10) }; } function extractExt(filename) { const m = filename.match(/\.([a-z0-9]{2,5})$/i); if (!m) return { ext: '', base: filename }; if (partTokenRe.test(m[1])) return { ext: '', base: filename }; // ".part1" is a split marker, not extension const ext = '.' + m[1].toLowerCase(); return { ext, base: filename.substring(0, filename.length - ext.length) }; } function isLikelyWrappedCode(nameLower, number) { const p = parseNumberParts(number); if (!p) return false; const noPad = String(p.num); const re = new RegExp(`[\\(\\[]\\d*${p.series}[-_ ]*0*${noPad}[\\)\\]]`, 'i'); return re.test(nameLower); } function scoreCandidate({ raw, number, idx, tokens, nameLower }) { const p = parseNumberParts(number); if (!p) return -999; let score = 0; if (p.series.length >= 4) score += 3; if (p.num >= 1000) score += 2; if (idx > 0) score += 1; if (isLikelyWrappedCode(nameLower, number)) score += 4; if (nameLower.includes(`@${raw}@`)) score -= 6; if (/^\d+[A-Z]+-/.test(number)) score -= 2; const next = (tokens[idx + 1] || '').toLowerCase(); if (commonDomainTokenRe.test(next)) score -= 6; if (/^(www|com|net|org|me)$/.test(raw)) score -= 8; return score; } function buildNumberTokens(tokens, idx) { const t = (tokens[idx] || '').toLowerCase(); if (!t || !hasLetter(t)) return []; if (partTokenRe.test(t) || tagTokenRe.test(t)) return []; const out = [t]; // e.g. "1155crvr00238" -> additionally try "crvr00238" const withoutVendorPrefix = t.match(/^\d{3,4}([a-z]{2,6}\d{3,6})$/i)?.[1]; if (withoutVendorPrefix) out.push(withoutVendorPrefix.toLowerCase()); const next = tokens[idx + 1]; if (next && endsWithLetter(t) && isPureDigits(next) && next.length >= 3) out.push(t + next); return out; } function parse(filename) { const { ext, base } = extractExt(filename); let name = base; const res = { number: '', rawNumber: '', part: 0, tags: [], ext, sourceSite: '' }; const siteMatch = name.match(sitePrefixRe); if (siteMatch) { res.sourceSite = siteMatch[1].toLowerCase(); name = name.replace(sitePrefixRe, ''); } const tokens = name.split(tokenizeRe).filter(Boolean); if (tokens.length === 0) return res; for (let i = 0; i < tokens.length; i++) { const t = tokens[i]; const pm = t.match(partTokenRe); if (pm) { if (res.part === 0) res.part = parseInt(pm[1], 10); continue; } if (tagTokenRe.test(t)) { res.tags.push(t.toLowerCase()); continue; } if (isPureDigits(t) && t.length <= 2 && res.part === 0) { res.part = parseInt(t, 10); continue; } } res.tags = [...new Set(res.tags)]; const candidates = []; const seen = new Set(); const nameLower = name.toLowerCase(); for (let i = 0; i < tokens.length; i++) { for (const raw of buildNumberTokens(tokens, i)) { const { number, rawNumber } = extractNumber(raw); if (!number) continue; const key = `${number}|${rawNumber}|${i}`; if (seen.has(key)) continue; seen.add(key); candidates.push({ idx: i, raw, number, rawNumber, score: scoreCandidate({ raw, number, idx: i, tokens, nameLower }), }); } } candidates.sort((a, b) => b.score - a.score || a.idx - b.idx); if (candidates[0]) { res.number = candidates[0].number; res.rawNumber = candidates[0].rawNumber; } debugLog('parse', { filename, tokens, selected: { number: res.number, rawNumber: res.rawNumber, part: res.part, tags: res.tags, ext: res.ext }, candidates: candidates.map(c => ({ idx: c.idx, raw: c.raw, number: c.number, rawNumber: c.rawNumber, score: c.score })), }); return res; } // ─── PikPak API ─── function getHeader() { let token = '', captcha = ''; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (!key) continue; if (key.startsWith('credentials')) { const d = JSON.parse(localStorage.getItem(key)); token = d.token_type + ' ' + d.access_token; } if (key.startsWith('captcha')) { const d = JSON.parse(localStorage.getItem(key)); captcha = d.captcha_token; } } let deviceId = localStorage.getItem('deviceid') || ''; if (deviceId.includes('.')) deviceId = deviceId.split('.')[1]?.substring(0, 32) || deviceId; return { Authorization: token, 'x-device-id': deviceId, 'x-captcha-token': captcha }; } function getList(parentId) { const url = `https://api-drive.mypikpak.com/drive/v1/files?thumbnail_size=SIZE_MEDIUM&limit=500&parent_id=${parentId}&with_audit=true&filters=${encodeURIComponent('{"phase":{"eq":"PHASE_TYPE_COMPLETE"},"trashed":{"eq":false}}')}`; return fetch(url, { headers: { 'Content-Type': 'application/json', ...getHeader() }, }).then(r => r.json()); } 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 r => { const data = await r.json(); if (data.error || !r.ok) { const err = new Error(data.error_description || t('renameFailed')(data.error)); err.code = data.error; throw err; } return data; }); } // ─── AV-wiki Query ─── function httpRequest(opts) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: opts.method || 'GET', url: opts.url, headers: opts.headers || {}, onload: r => resolve({ status: r.status, responseText: r.responseText }), onerror: e => reject(new Error(e.statusText || 'Network error')), ontimeout: () => reject(new Error('Request timeout')), }); }); } function parseDetailPage(html) { const doc = new DOMParser().parseFromString(html, 'text/html'); let name = doc.querySelector('.blockquote-like p')?.textContent || null; if (!name) { const entry = doc.querySelector('.entry-title'); if (entry) { const clone = entry.cloneNode(true); clone.querySelectorAll('.entry-subtitle, span').forEach(n => n.remove()); name = clone.textContent || null; } } const date = doc.querySelector('time.date.published')?.getAttribute('datetime') || doc.querySelector('meta[property="article:published_time"]')?.getAttribute('content')?.slice(0, 10) || null; if (name) name = name.trim(); if (name) name = name.replace(/[\/:*?"<>|\x00-\x1F]/g, '_'); return { title: name, date }; } function buildDirectUrl(keyword) { return `https://av-wiki.net/${keyword.toLowerCase()}/`; } function buildSearchUrl(term) { return `https://av-wiki.net/?s=${encodeURIComponent(term)}&post_type=product`; } function buildDirectUrlCandidates(parsed) { const urls = []; const seen = new Set(); const add = slug => { if (!slug) return; const url = `https://av-wiki.net/${slug.toLowerCase()}/`; if (seen.has(url)) return; seen.add(url); urls.push(url); }; const fromNumber = parsed.number.toLowerCase(); add(fromNumber); const raw = (parsed.rawNumber || '').toLowerCase(); const rawMatch = raw.match(/^(\d*[a-z]{2,10})(\d{1,6})$/); if (rawMatch) { const series = rawMatch[1]; const num = String(parseInt(rawMatch[2], 10)); add(`${series}-${num}`); add(`${series}-${rawMatch[2]}`); } return urls; } function buildSearchTerms(parsed) { const terms = []; const seen = new Set(); const add = term => { if (!term) return; if (seen.has(term)) return; seen.add(term); terms.push(term); }; add(parsed.number); add(parsed.number?.toLowerCase()); add(parsed.rawNumber); const raw = (parsed.rawNumber || '').toLowerCase(); const rawMatch = raw.match(/^(\d*[a-z]{2,10})(\d{1,6})$/); if (rawMatch) { const series = rawMatch[1]; const num = String(parseInt(rawMatch[2], 10)); add(`${series}-${num}`); add(`${series}-${rawMatch[2]}`); } return terms; } function extractSlug(url) { try { const path = new URL(url).pathname; return path.split('/').filter(Boolean)[0] || ''; } catch { return ''; } } function numberMentionVariants(number) { const p = parseNumberParts(number); if (!p) return []; const noPad = String(p.num); const pad3 = noPad.padStart(3, '0'); return [...new Set([`${p.series}${noPad}`, `${p.series}${pad3}`, `${p.series}${p.numRaw}`])]; } function containsExpectedNumber(text, number) { const norm = (text || '').toLowerCase().replace(/[^a-z0-9]+/g, ''); return numberMentionVariants(number).some(v => norm.includes(v)); } function isSameNumberBySlug(slug, number) { const p = parseNumberParts(number); if (!p) return false; const m = slug.toLowerCase().match(/^(\d*[a-z]{2,6})[-_]?0*(\d{1,6})(?:$|[-_])/); if (!m) return false; const series = m[1].replace(/^\d+/, ''); const num = parseInt(m[2], 10); return series === p.series && num === p.num; } function extractSearchResultLinks(doc) { const selectors = [ '.read-more a[href^="https://av-wiki.net/"]', '.archive-list .read-more a[href^="https://av-wiki.net/"]', '.archive-list a[href^="https://av-wiki.net/"][title]', '.column-flex .archive-list a[href^="https://av-wiki.net/"][title]', ]; const links = []; const seen = new Set(); for (const selector of selectors) { for (const a of doc.querySelectorAll(selector)) { const href = a.href; if (!href) continue; if (!/^https:\/\/av-wiki\.net\/[^/?#]+\/?$/i.test(href)) continue; if (seen.has(href)) continue; seen.add(href); links.push(href); } } return links; } async function queryAVwiki(parsed) { if (!parsed.number) throw new Error('No number'); const directUrls = buildDirectUrlCandidates(parsed); debugLog('direct-candidates', { number: parsed.number, rawNumber: parsed.rawNumber, directUrls }); for (const directUrl of directUrls) { const directResp = await httpRequest({ url: directUrl }); debugRawHtml('direct', directUrl, directResp); if (directResp.status !== 200) continue; const { title, date } = parseDetailPage(directResp.responseText); debugLog('direct-parse', { number: parsed.number, directUrl, title, date }); if (title && containsExpectedNumber(title, parsed.number)) return { title, date }; } // Fallback: search const searchTerms = buildSearchTerms(parsed); debugLog('search-terms', { number: parsed.number, rawNumber: parsed.rawNumber, searchTerms }); for (const searchTerm of searchTerms) { const searchUrl = buildSearchUrl(searchTerm); const searchResp = await httpRequest({ url: searchUrl }); debugRawHtml('search', searchUrl, searchResp); const doc = new DOMParser().parseFromString(searchResp.responseText, 'text/html'); const links = extractSearchResultLinks(doc); debugLog('search-candidates', { number: parsed.number, searchTerm, links }); for (const link of links) { const slug = extractSlug(link); const matchedBySlug = isSameNumberBySlug(slug, parsed.number); debugLog('search-link-check', { link, slug, number: parsed.number, searchTerm, matchedBySlug }); if (!matchedBySlug) continue; const detailResp = await httpRequest({ url: link }); debugRawHtml('search-detail', link, detailResp); if (detailResp.status === 200) { const { title, date } = parseDetailPage(detailResp.responseText); debugLog('search-detail-parse', { link, title, date }); if (title && containsExpectedNumber(title, parsed.number)) return { title, date }; } } } throw new Error('Not found'); } // ─── Config ─── const CONFIG_KEY = 'pikpak-batch-renamer-config'; const defaultConfig = { addDatePrefix: false, fixFileExtension: true, sortBy: 'name', sortDir: 'asc' }; const getConfig = () => { try { return { ...defaultConfig, ...JSON.parse(localStorage.getItem(CONFIG_KEY)) }; } catch { return { ...defaultConfig }; } }; const setConfig = c => localStorage.setItem(CONFIG_KEY, JSON.stringify(c)); // ─── i18n ─── const i18n = { zh: { batchRename: '批量重命名', batchRenameFiles: '批量重命名文件', confirmRename: '确认重命名', renameComplete: '重命名完成', selectAll: '全选', name: '名称', createdTime: '创建时间', modifiedTime: '修改时间', size: '大小', asc: '升序', desc: '降序', selectFiles: '请选择文件', scanning: '扫描中...', scanCodes: '扫描番号', config: '配置选项', addDatePrefix: '在文件名开头增加发行日期', addDatePrefixDesc: '启用后文件名格式为: 2025-09-12 标题名称.mp4', fixExt: '修复文件扩展名', fixExtDesc: '当文件缺少扩展名时,根据文件名信息自动补充', aboutToRename: n => `即将重命名 ${n} 个文件,请确认后继续。`, original: '原名', newName: '新名', progress: (cur, total) => `重命名进度: ${cur}/${total}`, cancel: '取消', next: '下一步', back: '上一步', confirming: '确认重命名', renaming: '重命名中...', resultSummary: (s, f, t) => `重命名完成!成功: ${s}, 失败: ${f}, 总计: ${t}`, failedFiles: '失败的文件:', renameFailed: code => `重命名失败 (${code})`, }, en: { batchRename: 'Batch Rename', batchRenameFiles: 'Batch Rename Files', confirmRename: 'Confirm Rename', renameComplete: 'Rename Complete', selectAll: 'Select All', name: 'Name', createdTime: 'Created', modifiedTime: 'Modified', size: 'Size', asc: 'Asc', desc: 'Desc', selectFiles: 'Select files', scanning: 'Scanning...', scanCodes: 'Scan Codes', config: 'Settings', addDatePrefix: 'Prepend release date to filename', addDatePrefixDesc: 'Format: 2025-09-12 Title.mp4', fixExt: 'Fix file extension', fixExtDesc: 'Auto-add extension when missing based on file info', aboutToRename: n => `About to rename ${n} file(s). Please confirm.`, original: 'From', newName: 'To', progress: (cur, total) => `Renaming: ${cur}/${total}`, cancel: 'Cancel', next: 'Next', back: 'Back', confirming: 'Confirm Rename', renaming: 'Renaming...', resultSummary: (s, f, t) => `Done! Success: ${s}, Failed: ${f}, Total: ${t}`, failedFiles: 'Failed files:', renameFailed: code => `Rename failed (${code})`, }, }; const lang = (navigator.language || '').startsWith('zh') ? 'zh' : 'en'; const t = key => i18n[lang][key]; // ─── Styles ─── const colors = { primary: '#303133', secondary: '#606266', success: '#67c23a', danger: '#f56c6c', warning: '#e6a23c', blue: '#409eff' }; // ─── Components ─── const delay = ms => new Promise(r => setTimeout(r, ms)); function ConfigPanel({ config, onChange }) { const toggle = key => { const c = { ...config, [key]: !config[key] }; setConfig(c); onChange(c); }; return html`