// ==UserScript== // @name 移动云盘批量重命名 // @namespace https://github.com/YPJCoding/139-drive-rename // @version 1.1.0 // @description 移动云盘文件批量重命名工具 // @author YPJCoding // @match https://yun.139.com/* // @icon https://yun.139.com/w/static/img/LOGO.png // @homepageURL https://github.com/YPJCoding/139-drive-rename // @supportURL https://github.com/YPJCoding/139-drive-rename/issues // @downloadURL https://raw.githubusercontent.com/YPJCoding/139-drive-rename/main/mobile-cloud-rename.user.js // @updateURL https://raw.githubusercontent.com/YPJCoding/139-drive-rename/main/mobile-cloud-rename.user.js // @license MIT // @grant none // ==/UserScript== /* ── 动态加载依赖 ── */ (function loadDeps() { function inject(src, onload) { const s = document.createElement('script') s.src = src s.onload = onload document.head.appendChild(s) } let loaded = 0 function check() { if (++loaded === 2) init() } inject('https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.prod.js', check) inject('https://cdn.jsdelivr.net/npm/js-md5@0.8.3/build/md5.min.js', check) })() // ⚠️ XHR 拦截器必须最先安装,不等 Vue/md5 加载完毕, // 否则 CMCC 页面的 XHR 请求在 init() 调用前就已经发出,headers 无法捕获 /* ── ① 常量 ── */ const API_FILE_LIST = 'https://personal-kd-njs.yun.139.com/hcy/file/list' const API_FILE_UPDATE = 'https://personal-kd-njs.yun.139.com/hcy/file/update' const SIGN_RANDOM = 'FO6ezlJ9BkwfHVZd' const VideoExts = ['mp4','flv','f4v','webm','m4v','mov','cpk','dirac','3gp','3g2','rm','rmvb','wmv','avi','asf','mpg','mpeg','mpe','vob','mkv','ram','qt','fli','flc','mod','iso','ts'] const SubTitleExts = ['srt','ass','ssa','sub','idx','vtt','ttml','dfxp','lrc','smi','sami','txt','xml','usf','psb','rt','sbv','mpl','aqt','jss','dks','pjs','mks','cap'] /* ── ② 工具函数 ── */ function getToken(key) { for (const part of document.cookie.split(';')) { const item = part.trim() const idx = item.indexOf('=') if (idx > -1 && item.slice(0, idx) === key) return item.slice(idx + 1) } return '' } function safeAtob(value) { try { return value ? atob(value) : '' } catch { return '' } } function getAccountInfo() { return { account: safeAtob(getToken('ORCHES-I-ACCOUNT-ENCRYPT')), accountType: 1, } } // JS-MD5 包装 const createMD5 = s => { const h = md5.create(); h.update(s); return h.hex() } // 生成 Mcloud-Sign 头所需的时间戳格式:"YYYY-MM-DD HH:mm:ss" const now = () => { const d = new Date(); return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0')+' '+String(d.getHours()).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0')+':'+String(d.getSeconds()).padStart(2,'0') } const delay = ms => new Promise(resolve => setTimeout(resolve, ms)) function fileExt(item) { return String(item?.file_extension || '').toLowerCase() } function stripExtension(name, ext) { name = String(name || '') if (!ext) return name.replace(/\.[a-z0-9]+$/i, '') const suffix = `.${ext}` return name.toLowerCase().endsWith(suffix.toLowerCase()) ? name.slice(0, -suffix.length) : name } function getNameExtension(name) { const m = String(name).match(/(\.[a-z0-9]+)$/i) return m ? m[1] : '' } function isMediaItem(item, includeSubtitle) { const ext = fileExt(item) return item.type === 'file' && (VideoExts.includes(ext) || (includeSubtitle && SubTitleExts.includes(ext))) } // 移动云盘签名算法:将 signPayload JSON 序列化 → URL 编码 → 字符排序 → base64 → MD5 // 然后与 "时间:随机串" 的 MD5 拼接再 MD5,最终输出大写十六进制字符串 function getNewSign(t, a, n) { let r = '' if (t) { let s = Object.assign({}, t) r = JSON.stringify(s) r = encodeURIComponent(r) r = r.split('').sort().join('') } const d = createMD5(btoa(r)) const f = createMD5(`${a}:${n}`) return createMD5(d + f).toUpperCase() } /* ── ③ 请求头捕获 ── */ // 移动云盘页面自己的 XHR 请求会带上一系列鉴权和安全 header // 这里拦截 XMLHttpRequest.setRequestHeader 来捕获这些 header 值 // 后续脚本自己的 fetch 请求需要带上同样的 header 才能通过服务端校验 // 注意:hcy-cool-flag 会导致服务端返回加密密文,必须过滤掉 const capturedHeaders = {} const HeaderWhitelist = new Set(['caller','Cms-Device','Mcloud-Channel','Mcloud-Client','Mcloud-Route','Mcloud-version','X-Deviceinfo','x-huawei-channelSrc','x-m4c-src','x-inner-ntwk','x-m4c-caller','INNER-HCY-ROUTER-HTTPS','X-Svctype','x-yun-Api-Version','x-yun-channel-source','x-yun-app-channel','x-yun-client-info','x-yun-module-type','x-yun-svc-type'].map(x => x.toLowerCase())) const _origXHR = XMLHttpRequest.prototype.setRequestHeader XMLHttpRequest.prototype.setRequestHeader = function (key, value) { if (HeaderWhitelist.has(key.toLowerCase())) capturedHeaders[key] = value return _origXHR.apply(this, [key, value]) } function init() { /* ── ④ API 层 ── */ // 移动云盘通用 POST 请求 // signPayload 用于计算 Mcloud-Sign 签名,与 payload 共同发送但参与签名算法 function post(api, payload, signPayload) { const time = now() return fetch(api, { method: 'POST', headers: { 'Content-Type': 'application/json;charset=UTF-8', 'Authorization': getToken('authorization'), 'Mcloud-Sign': `${time},${SIGN_RANDOM},${getNewSign(signPayload, time, SIGN_RANDOM)}`, ...capturedHeaders, }, credentials: 'include', body: JSON.stringify(payload), }).then(res => { console.log('[rename-debug] API response status:', res.status) if (res.ok) return res.json() return Promise.reject(new Error(`HTTP ${res.status}`)) }) } // 获取当前目录文件列表(游标分页) // 目录 ID 由移动云盘网页存入 localStorage.currentCatalogID async function fetchFileList() { const result = [] let cursor = { type: 'initial', value: null } const parentFileId = localStorage.getItem('currentCatalogID') while (cursor.type === 'initial' || cursor.value) { // 每次请求 100 条,按更新时间降序排列 const res = await post( API_FILE_LIST, { pageInfo: { pageSize: 100, pageCursor: cursor.value }, orderBy: 'updated_at', orderDirection: 'DESC', parentFileId, imageThumbnailStyleList: ['Small', 'Large'], }, { commonAccountInfo: getAccountInfo(), catalogID: parentFileId, catalogSortType: 0, contentSortType: 0, endNumber: 100, filterType: 0, sortDirection: 1, startNumber: cursor.type === 'initial' ? 1 : cursor.value, } ) const items = res?.data?.items || [] const nextPageCursor = res?.data?.nextPageCursor cursor = { type: 'normal', value: nextPageCursor } // 只提取脚本需要的字段 result.push(...items.map(x => ({ file_id: x.fileId, name: x.name, file_extension: x.fileExtension || '', type: x.type, }))) } return result } // 单个文件重命名 async function renameOne(fileId, newName) { await post( API_FILE_UPDATE, { fileId, name: newName, description: '' }, { commonAccountInfo: getAccountInfo(), contentID: fileId, contentName: newName, } ) } /* ── ⑤ 重命名逻辑 ── */ // 剧集提取正则(优先级从高到低): // 1. S01E17 标准格式 2. E17 / EP17 格式 // 3. 非数字/中文上下文的纯数字 4. 宽松版(允许中文上下文) const SeasonEpisodeExtract = /S(?:eason)?[._\- ]?(\d{1,3})(?:[._\- ]?E|[._\- ])(\d{1,3})(?!\d)/i const EpisodeExtract1 = /EP?(\d{1,3})(?!\d)/i const EpisodeExtract2 = /(? x[0]) const mR = [...refName.matchAll(/\d+/g)].map(x => x[0]) if (!mO.length || mO.length !== mR.length) return const diff = mO.filter((v, i) => v !== mR[i]) const filtered = diff.filter(x => { const n = +x; return !isNaN(n) && n > 0 && n < 1000 }) return filtered.length === 1 ? normalizeEpisode(filtered[0], leadingZeroCount) : undefined } // 辅助定位法提取集数:用户指定集数前后的标记字符串来定位集数位置 function getEpisodeByHelpers(oldName, helpers, leadingZeroCount) { const { pre, post } = helpers if (!pre && !post) return const preIdx = pre ? oldName.indexOf(pre) : 0 if (pre && preIdx === -1) return const start = pre ? preIdx + pre.length : 0 const postIdx = post ? oldName.indexOf(post, start) : oldName.length if (post && postIdx === -1) return const segment = oldName.slice(start, postIdx).trim() if (!segment) return return extractEpisode(segment, leadingZeroCount, false) } // 剧集模式新文件名生成:{剧名}.S{季}E{集数}.{扩展名} // 例如:进击的巨人.S01E001.mp4 function getNewNameByExtract(oldName, prefix, season, helpers, refName, offset, leadingZeroCount) { let episode = getEpisodeByHelpers(oldName, helpers, leadingZeroCount) if (!episode && refName) episode = getEpisodeByCompare(oldName, refName, leadingZeroCount) if (!episode) episode = extractEpisode(oldName, leadingZeroCount, true) const ext = getNameExtension(oldName) const epNum = +episode + (+offset || 0) episode = String(epNum).padStart(leadingZeroCount, '0') const s = season ? String(+(season) || 1).padStart(2, '0') : '01' prefix = prefix || '' const dot = prefix.endsWith('.') ? '' : '.' return `${prefix}${dot}S${s}E${episode}${ext}` } // 正则模式:直接执行 JS 正则替换 function getNewNameByExp(oldName, from, to) { try { return oldName.replace(new RegExp(from), to) } catch { return '' } } // 从文件列表中自动推测剧名: // 1. 有中文取中文字段 2. 单文件去扩展名和季集信息 // 3. 多文件取最长公共子串 function guessPrefix(list) { if (!list.length) return '' const m = list[0].name.match(/([\u4E00-\u9FA5]+)/) if (m?.[1]) return m[1] if (list.length < 2) { return stripExtension(list[0].name, list[0].file_extension).replace(/\s*S\d+E\d*|\s*E\d+/i, '').trim() } const [a, b] = list.slice(-2).map(x => stripExtension(x.name, x.file_extension)) let maxLen = 0, maxEnd = 0 let prev = [], curr = [] for (let i = 0; i < a.length; i++) prev[i] = a[0] === b[i] ? 1 : 0 for (let i = 1; i < a.length; i++) { curr[0] = a[i] === b[0] ? 1 : 0 for (let j = 1; j < b.length; j++) { curr[j] = a[i] === b[j] ? prev[j - 1] + 1 : 0 if (curr[j] > maxLen) { maxLen = curr[j]; maxEnd = j } } [prev, curr] = [curr, []] } const lcs = b.slice(maxEnd - maxLen + 1, maxEnd + 1) return lcs ? lcs.replace(/\s*S\d+E\d*|\s*E\d+/i, '').trim() : '' } // 从文件列表中自动推测季数 function guessSeason(list) { for (const v of list) { const m = v.name.match(SeasonEpisodeExtract); if (m?.[1]) return m[1] } return '1' } /* ── ⑥ Vue 应用 ── */ // 使用 Vue 3 CDN 构建,通过 reactive 数据驱动全部 UI 渲染 const app = Vue.createApp({ data() { return { show: false, // 面板显隐 list: [], // 原始文件列表(来自 API) loading: false, // 是否正在加载 running: false, // 是否正在执行重命名 activeMode: 'extract', // 当前模式:extract=剧集模式 / regexp=正则模式 includeSubtitle: false,// 是否包含字幕文件 from: '', // 正则模式:查找表达式 to: '', // 正则模式:替换表达式 prefix: '', // 剧集模式:剧名 season: '', // 剧集模式:季数 offset: '', // 剧集模式:集数偏移 leadingZeroCount: 2, // 集数前缀补零个数 epHelperPre: '', // 集数辅助定位:前置标记 epHelperPost: '', // 集数辅助定位:后置标记 uncheckList: new Set(),// 用户取消勾选的文件 ID 集合 doneList: new Set(), // 已成功重命名的文件 ID 集合 errorList: new Set(), // 重命名失败的文件 ID 集合 newNameMap: {}, // file_id → 新文件名 的映射 totalDone: 0, // 已完成数量 catalogId: '', // 当前目录 ID,用于检测目录切换 _catalogTimer: null, // 目录轮询定时器 } }, computed: { // 视频文件列表(剧集模式使用) videoList() { return this.list.filter(x => isMediaItem(x, this.includeSubtitle)) }, // 当前展示列表(剧集模式=仅视频,正则模式=全部文件) displayList() { return this.activeMode === 'extract' ? this.videoList : this.list }, // 已选中待执行的条目(排除未勾选、无新名、新旧名相同的) selectedList() { return this.displayList.filter(x => !this.uncheckList.has(x.file_id) && this.newNameMap[x.file_id] && x.name !== this.newNameMap[x.file_id] ) }, checkedCount() { return this.displayList.filter(x => !this.uncheckList.has(x.file_id)).length }, // 计算新文件名冲突的 file_id 集合(重名检测) conflictFileIds() { const m = new Map(), r = new Set() const currentNameOwner = new Map(this.list.map(x => [x.name, x.file_id])) for (const x of this.selectedList) { const nn = this.newNameMap[x.file_id] if (m.has(nn)) { r.add(m.get(nn)); r.add(x.file_id) } else m.set(nn, x.file_id) const existingId = currentNameOwner.get(nn) if (existingId && existingId !== x.file_id) { r.add(x.file_id) r.add(existingId) } } return r }, hasConflict() { return this.conflictFileIds.size > 0 }, // 执行按钮禁用的条件 disabled() { return (this.activeMode === 'regexp' && !this.from) || (this.activeMode === 'extract' && (!this.prefix || !this.season)) || this.loading || !this.selectedList.length || this.hasConflict }, renameInputs() { return [ this.activeMode, this.from, this.to, this.prefix, this.season, this.offset, this.leadingZeroCount, this.epHelperPre, this.epHelperPost, ] }, }, watch: { // 面板打开时自动拉取文件列表(首次打开或目录已切换时) show(val) { if (val) { const currentId = localStorage.getItem('currentCatalogID') this.catalogId = currentId this.refetch() } }, // 文件列表变化时重置状态并重新生成新名 list() { this.uncheckList = new Set() this.doneList.clear() this.errorList.clear() this.newNameMap = {} if (this.videoList.length) { this.prefix = guessPrefix(this.videoList) // 自动推测剧名 this.season = guessSeason(this.videoList) // 自动推测季数 } this.regenerateNames() }, renameInputs() { this.regenerateNames() }, }, mounted() { // 轮询检测目录切换。移动云盘是 SPA,切换目录不会触发页面 reload, // 但 localStorage.currentCatalogID 会更新,通过轮询感知变化后自动刷新列表 this._catalogTimer = setInterval(() => { const currentId = localStorage.getItem('currentCatalogID') if (currentId && currentId !== this.catalogId) { this.catalogId = currentId if (this.show) this.refetch() } }, 800) }, beforeUnmount() { clearInterval(this._catalogTimer) }, methods: { // 拉取当前目录文件列表 async refetch() { this.loading = true try { const parentId = localStorage.getItem('currentCatalogID') console.log('[rename-debug] refetch, catalogID:', parentId, 'captured headers keys:', Object.keys(capturedHeaders)) this.list = await fetchFileList() console.log('[rename-debug] got list:', this.list.length, 'items') } catch(e) { console.log('[rename-debug] refetch error:', e) this.list = [] } finally { this.loading = false } }, selectAll() { this.uncheckList = new Set() }, selectNone() { this.uncheckList = new Set(this.displayList.map(x => x.file_id)) }, toggleItem(id, checked) { const next = new Set(this.uncheckList) if (checked) next.delete(id) else next.add(id) this.uncheckList = next }, isChecked(id) { return !this.uncheckList.has(id) }, // 根据当前模式生成所有文件的新文件名映射 regenerateNames() { if (!this.list.length) return const map = {} if (this.activeMode === 'extract' || this.from) { for (let i = 0; i < this.list.length; i++) { const item = this.list[i] const ref = this.list[i === 0 ? 1 : 0] map[item.file_id] = this.activeMode === 'extract' ? getNewNameByExtract(item.name, this.prefix.trim(), this.season.trim(), { pre: this.epHelperPre, post: this.epHelperPost }, ref?.name, this.offset, this.leadingZeroCount) : getNewNameByExp(item.name, this.from, this.to ?? '') } } this.newNameMap = map }, // 执行重命名(并发 3,间隔 200ms) async run() { if (this.disabled || this.running) return this.running = true this.totalDone = 0 const queue = [...this.selectedList] while (queue.length) { const batch = queue.splice(0, 3) await Promise.all(batch.map(async item => { const nn = this.newNameMap[item.file_id] if (!nn) return try { await renameOne(item.file_id, nn) this.doneList.add(item.file_id) } catch { this.errorList.add(item.file_id) } this.totalDone++ })) await delay(200) } this.running = false // 执行完毕后 3 秒刷新页面,确保云盘 UI 同步 setTimeout(() => location.reload(), 3000) }, // 点击文件名将其填充为剧名(方便快捷输入) pickName(id) { const found = this.videoList.find(x => x.file_id === id) if (found) { this.prefix = stripExtension(found.name, found.file_extension) } }, // 随机选取一个文件名作为剧名 fillRandom() { const len = this.videoList.length if (!len) return const found = this.videoList[Math.floor(Math.random() * len)] if (found) this.prefix = stripExtension(found.name, found.file_extension) }, }, template: `
移动云盘批量重命名
定位集数: [集数]
共 {{displayList.length}} 个 已勾选 {{checkedCount}} 个 ⚠ 冲突
获取文件列表中...
{{item.name}} {{newNameMap[item.file_id] || '(无匹配)'}}
当前目录和模式下,没有满足要求的条目~
`, }) /* ── ⑦ 注入到移动云盘页面 ── */ // 监听 DOM 变化,等待 .top_button 元素出现后挂载 Vue 应用 function mount() { const container = document.querySelector('.top_button') if (!container || document.getElementById('pc3-rename-root')) return const el = document.createElement('div') el.id = 'pc3-rename-root' el.style.cssText = 'float:left;margin:20px 24px 20px 0' container.insertBefore(el, container.firstElementChild) app.mount(el) } // SPA 页面可能需要等路由切换后才渲染 .top_button new MutationObserver(() => mount()).observe(document.body, { childList: true, subtree: true }) mount() /* ── ⑧ 样式 ── */ // 所有样式使用 pc3- 前缀避免与移动云盘页面自身的样式冲突 const style = document.createElement('style') style.textContent = ` .pc3-rename-btn { float:left; height:36px; border:1px solid #3181f9; border-radius:6px; background:transparent; color:#3181f9; padding:0 20px; cursor:pointer; font-size:12px; font-weight:500 } .pc3-rename-btn:hover { background:rgba(49,129,249,0.2); color:#fff } .pc3-overlay { position:fixed; inset:0; z-index:99; backdrop-filter:blur(3px) } .pc3-panel { position:absolute; z-index:100; top:50px; right:16px; width:450px; max-height:calc(100vh - 80px); background:#f8f9fa; border:2px solid #3181f9; border-radius:10px; box-shadow:0 4px 20px rgba(0,0,0,.15); display:flex; flex-direction:column; overflow:hidden; font-size:13px; color:#333 } .pc3-panel-header { display:flex; justify-content:space-between; align-items:center; padding:10px 14px; background:#3181f9; color:#fff; font-weight:600; font-size:14px } .pc3-panel-header button { background:none; border:none; color:#fff; font-size:20px; cursor:pointer; line-height:1 } .pc3-mode-bar { display:flex; gap:2px; padding:10px 14px 6px } .pc3-mode-bar button { flex:1; padding:4px 0; border:1px solid #ccc; background:#fff; border-radius:4px; cursor:pointer; font-size:12px } .pc3-mode-bar button.active { background:#3181f9; color:#fff; border-color:#3181f9 } .pc3-form { padding:0 14px; display:flex; flex-direction:column; gap:8px } .pc3-form label { display:block; font-size:12px; color:#666 } .pc3-form input { width:100%; height:30px; border:1px solid #ddd; border-radius:4px; padding:0 8px; font-size:13px; outline:none; box-sizing:border-box } .pc3-form input:focus { border-color:#3181f9 } .pc3-subtitle { display:flex!important; align-items:center; gap:4px; cursor:pointer } .pc3-check { appearance:none!important; -webkit-appearance:none!important; flex:0 0 auto!important; display:inline-grid!important; place-content:center!important; width:14px!important; height:14px!important; min-width:14px!important; min-height:14px!important; margin:0!important; padding:0!important; border:1px solid #9ca3af!important; border-radius:3px!important; background:#fff!important; box-sizing:border-box!important; opacity:1!important; position:static!important; pointer-events:auto!important; cursor:pointer!important; vertical-align:middle!important } .pc3-check:checked { border-color:#3181f9!important; background:#3181f9!important } .pc3-check:checked::after { content:""; width:4px; height:8px; border:solid #fff; border-width:0 2px 2px 0; transform:rotate(45deg); margin-bottom:1px } .pc3-check:focus-visible { outline:2px solid rgba(49,129,249,.35)!important; outline-offset:2px!important } .pc3-row .pc3-check { margin-top:2px!important } .pc3-dice { cursor:pointer; font-size:14px; vertical-align:middle } .pc3-helper { padding:6px 14px; font-size:11px; color:#999; display:flex; align-items:center; gap:4px } .pc3-helper input { width:60px; height:24px; border:1px solid #ddd; border-radius:3px; padding:0 4px; font-size:11px; outline:none; text-align:center } .pc3-toolbar { display:flex; align-items:center; gap:8px; padding:8px 14px; font-size:11px; color:#666 } .pc3-toolbar button { border:none; background:none; color:#3181f9; cursor:pointer; font-size:11px } .pc3-toolbar button:hover { text-decoration:underline } .pc3-conflict { color:#e53e3e; font-weight:600 } .pc3-loading { padding:30px; text-align:center; color:#999 } .pc3-list { flex:1; overflow-y:auto; padding:0 14px } .pc3-row { display:flex; align-items:flex-start; gap:6px; padding:4px 6px; border-radius:4px; font-size:12px } .pc3-row:nth-child(odd) { background:#fff } .pc3-row.conflict { background:#fff5f5 } .pc3-row.done { background:#f0fff4 } .pc3-row.error { background:#fff5f5 } .pc3-row.unchecked { opacity:.45 } .pc3-old { flex:1; min-width:0; overflow-wrap:anywhere; word-break:break-word; cursor:pointer; color:#555; line-height:1.4 } .pc3-old:hover { color:#3181f9 } .pc3-arrow { flex-shrink:0; color:#3181f9; line-height:1.4 } .pc3-new { flex:1; min-width:0; overflow-wrap:anywhere; word-break:break-word; color:#3181f9; line-height:1.4 } .pc3-new.empty { color:#ccc; font-style:italic } .pc3-empty { padding:30px; text-align:center; color:#999; font-size:12px } .pc3-footer { display:flex; align-items:center; justify-content:space-between; padding:10px 14px; border-top:1px solid #eee; font-size:12px; color:#999 } .pc3-run { padding:6px 20px; border:none; border-radius:6px; background:#3181f9; color:#fff; cursor:pointer; font-size:13px; font-weight:600 } .pc3-run:hover { opacity:.9 } .pc3-run:disabled { background:#ccc; cursor:not-allowed } ` document.head.appendChild(style) } // end init