// ==UserScript== // @name 抖音账号视频批量下载器 // @namespace https://github.com/jsk789456 // @version 0.4.1 // @description 抖音账号视频批量下载:①可锁定“电脑任意盘任意文件夹”(Chrome/Edge选目录,FSA直写);②统计作品年份+按年份筛选;③设置一次性最多下载N个;④开始/暂停/停止下载可控;⑤自动模式无人值守。仅个人学习备份,勿二次分发或商用。 // @author 爱国者 / 姜老师可视化课堂 // @match https://www.douyin.com/user/* // @grant GM_download // @grant GM_setValue // @grant GM_getValue // @connect douyin.com // @connect *.douyinvod.com // @connect *.tiktokcdn.com // @run-at document-start // @license All Rights Reserved // ==/UserScript== (function () { 'use strict'; /* ========================================================= * 说明(v0.4): * v0.3 基础上新增/增强: * 1)【保存位置·绝对路径】:Tampermonkey 的 GM_download 只能落到“浏览器默认下载目录” * 下的相对子路径,无法指定 D:\xxx 这种绝对盘符路径。 * 因此新增「📁 选择保存文件夹」按钮:用 File System Access API 让用户亲自授权 * 一个绝对目录(如 D:\教学素材\徐老师),脚本之后用 fetch+blob 直接写入该绝对路径, * 并记到 IndexedDB 跨会话记忆。 * ⚠️ 限制:仅 Chrome/Edge 支持;且部分抖音视频 CDN 跨域,fetch 会被拦 → 此时自动 * 回退到 GM_download(落到浏览器下载目录)。若想 100% 落绝对盘,请在浏览器设置里把 * “默认下载目录”改成目标盘根,本脚本“保存位置(子路径)”填子文件夹名即可。 * 2)【作品年份】:解析接口里的 create_time,统计年份分布,可按年份筛选下载。 * 3)【一次性下载多少个】:设“最多下载 N 个”(0=不限),超限不再发起新下载。 * 4)【开始/停止下载】:新增 ⏸暂停/继续、⏹停止下载 控制;🚀自动开始 即“开始”。 * * 抖音接口字段/路径会随版本漂移,首次运行请按浏览器实测微调下方 CFG。 * ========================================================= */ // ---------- 配置 ---------- const CFG = { listApiRegex: /aweme\/post|user\/post|aweme\/v1\/web\/aweme\/post/i, noWatermark: true, autoScrollMax: 60, scrollIntervalMs: 1300, noNewStop: 9, maxConcurrent: 3, downloadGapMs: 600, fileNameMax: 40, saveFolder: GM_getValue('jb_save_folder', '抖音视频'), // 相对子路径(GM_download 回退用) maxDownloadN: GM_getValue('jb_max_n', 0) // 0=不限;>0 一次性最多下载N个 }; // ---------- 数据 ---------- const collected = new Map(); const downloaded = new Set(GM_getValue('jb_downloaded', [])); let fileSeq = GM_getValue('jb_file_seq', 0); let dlCount = 0; // 本次运行已发起下载数(受 maxDownloadN 约束) const yearCount = {}; // 年份 -> 数量 let selectedYear = 'all'; // 年份筛选 // 下载控制 let dlPaused = false; let dlStopped = false; // 绝对目录句柄(FSA) let saveDirHandle = null; // ---------- 工具 ---------- function cleanName(s) { return (s || '无标题').replace(/[\\/:*?"<>|]/g, '_').replace(/\s+/g, ' ').slice(0, CFG.fileNameMax); } function cleanFolder(s) { return (s || '抖音视频') .replace(/[\\:*?"<>|]/g, '/') .replace(/\/{2,}/g, '/') .replace(/^\/|\/$/g, '') .trim() || '抖音视频'; } function setFolder(v) { CFG.saveFolder = cleanFolder(v); GM_setValue('jb_save_folder', CFG.saveFolder); updateSaveHint(); } function markDownloaded(id) { downloaded.add(id); GM_setValue('jb_downloaded', Array.from(downloaded)); } function finalFname(it) { return String(it.seq).padStart(3, '0') + '_' + it.desc + '_' + it.awemeId + '.mp4'; } // ---------- IndexedDB 存取目录句柄 ---------- const FS_DB = 'jb-dy-fs', FS_STORE = 'handles'; function idbOpen() { return new Promise(function (res, rej) { const r = indexedDB.open(FS_DB, 1); r.onupgradeneeded = function () { r.result.createObjectStore(FS_STORE); }; r.onsuccess = function () { res(r.result); }; r.onerror = function () { rej(r.error); }; }); } async function saveHandle(h) { const db = await idbOpen(); return new Promise(function (res) { const tx = db.transaction(FS_STORE, 'readwrite'); tx.objectStore(FS_STORE).put(h, 'dir'); tx.oncomplete = res; tx.onerror = function () { res(); }; }); } async function loadHandle() { try { const db = await idbOpen(); return await new Promise(function (res) { const tx = db.transaction(FS_STORE, 'readonly'); const rq = tx.objectStore(FS_STORE).get('dir'); rq.onsuccess = function () { res(rq.result || null); }; rq.onerror = function () { res(null); }; }); } catch (e) { return null; } } async function chooseFolder() { if (!window.showDirectoryPicker) { setStatus('当前浏览器不支持“直接选文件夹”(请用 Chrome/Edge)。可改用:浏览器设置→下载→默认下载目录设为目标盘,本脚本“保存位置”填子文件夹名。'); return; } try { const h = await window.showDirectoryPicker(); const opt = { mode: 'readwrite' }; let perm = 'granted'; try { perm = (await h.queryPermission(opt)); } catch (e) {} if (perm !== 'granted') { try { perm = (await h.requestPermission(opt)); } catch (e) {} } if (perm !== 'granted') { setStatus('未授权写入,已取消。'); return; } saveDirHandle = h; await saveHandle(h); updateAbsLabel(); setStatus('✅ 已锁定绝对保存目录,下载将直接写入该路径(跨域视频会自动回退浏览器下载目录)。'); } catch (e) { setStatus('选择取消/失败:' + (e && e.message ? e.message : e)); } } function updateAbsLabel() { const el = panel && panel.querySelector('#jb-dy-absdir'); if (el) el.textContent = saveDirHandle ? ('绝对目录:' + (saveDirHandle.name || '已授权') + '(直接写入)') : '绝对目录:未设置(用下方“保存位置”子路径→浏览器下载目录)'; } // ---------- 解析作品列表 ---------- function parseAwemeList(txt, autoEnqueue) { let data; try { data = JSON.parse(txt); } catch (e) { return; } const list = data.aweme_list || (data.data && data.data.aweme_list) || (Array.isArray(data.aweme_list) ? data.aweme_list : null); if (!Array.isArray(list)) return; let added = 0; for (const aw of list) { if (!aw || !aw.aweme_id || !aw.video) continue; const playAddr = aw.video.play_addr && aw.video.play_addr.url_list; const dlAddr = aw.video.download_addr && aw.video.download_addr.url_list; let url = (playAddr && playAddr[0]) || (dlAddr && dlAddr[0]); if (!url) continue; if (CFG.noWatermark) url = url.replace(/playwm/g, 'play'); const key = String(aw.aweme_id); if (!collected.has(key)) { const ct = aw.create_time; const year = (ct && !isNaN(ct)) ? new Date(ct * 1000).getFullYear() : '未知'; const item = { desc: cleanName(aw.desc), url, awemeId: key, year: year }; collected.set(key, item); added++; yearCount[year] = (yearCount[year] || 0) + 1; refreshYearOptions(); if (autoEnqueue && !downloaded.has(key)) enqueueDownload(item); } } if (added > 0) { updatePanel(); updateYearStat(); } } // ---------- 拦截 fetch ---------- const origFetch = window.fetch; window.fetch = function (input, init) { const url = typeof input === 'string' ? input : (input && input.url) || ''; return origFetch.apply(this, arguments).then(function (resp) { if (CFG.listApiRegex.test(url)) { try { resp.clone().text().then(function (t) { parseAwemeList(t, autoMode); }).catch(function () {}); } catch (e) {} } return resp; }); }; const origOpen = XMLHttpRequest.prototype.open; const origSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function (method, url) { this.__url = url; return origOpen.apply(this, arguments); }; XMLHttpRequest.prototype.send = function () { const self = this, url = self.__url || ''; if (CFG.listApiRegex.test(url)) { self.addEventListener('readystatechange', function () { if (self.readyState === 4 && self.status === 200) { try { parseAwemeList(self.responseText, autoMode); } catch (e) {} } }); } return origSend.apply(self, arguments); }; // ---------- 自动点击“作品/视频”标签 ---------- function autoClickWorksTab() { try { const tabs = document.querySelectorAll('div[class*="tab"], li[class*="tab"], [role="tab"], a[class*="tab"]'); for (const t of tabs) { const txt = (t.textContent || '').trim(); if (/作品|视频/.test(txt) && !/喜欢|收藏|推荐|直播/.test(txt)) { if (!t.__jbClicked) { t.__jbClicked = true; t.click(); } break; } } } catch (e) {} } // ---------- 下载队列(并发 + 暂停/停止) ---------- const downloadQueue = []; let activeDownloads = 0; function enqueueDownload(it) { if (selectedYear !== 'all' && String(it.year) !== String(selectedYear)) return; if (CFG.maxDownloadN > 0 && dlCount >= CFG.maxDownloadN) return; dlCount++; it.seq = ++fileSeq; GM_setValue('jb_file_seq', fileSeq); downloadQueue.push(it); pump(); } function pump() { while (!dlPaused && !dlStopped && activeDownloads < CFG.maxConcurrent && downloadQueue.length) { const it = downloadQueue.shift(); activeDownloads++; if (saveDirHandle) downloadViaFSA(it); else downloadViaGM(it); } } function finishOne(it) { activeDownloads--; markDownloaded(it.awemeId); updatePanel(); pump(); checkAutoDone(); } function downloadViaGM(it) { GM_download({ url: it.url, name: CFG.saveFolder + '/' + finalFname(it), saveAs: false, headers: { Referer: 'https://www.douyin.com/' }, onload: function () { finishOne(it); }, onerror: function (e) { finishOne(it); setStatus('⚠️ 下载失败 ' + it.awemeId + ':' + ((e && e.error) || '未知')); } }); } async function downloadViaFSA(it) { const fname = finalFname(it); try { const resp = await fetch(it.url, { mode: 'cors', headers: { Referer: 'https://www.douyin.com/' } }); if (!resp.ok) throw new Error('HTTP ' + resp.status); const blob = await resp.blob(); const fh = await saveDirHandle.getFileHandle(fname, { create: true }); const w = await fh.createWritable(); await w.write(blob); await w.close(); finishOne(it); } catch (e) { // 跨域或权限失败 → 回退 GM_download GM_download({ url: it.url, name: CFG.saveFolder + '/' + fname, saveAs: false, headers: { Referer: 'https://www.douyin.com/' }, onload: function () { finishOne(it); }, onerror: function () { finishOne(it); } }); setStatus('⚠️ 直接写失败(' + (e && e.message ? e.message : e) + '),已回退到浏览器下载目录'); } } function waitQueueDrain(cb) { const t = setInterval(function () { if (downloadQueue.length === 0 && activeDownloads === 0) { clearInterval(t); cb(); } }, 500); } // ---------- 手动:滚动收集 ---------- let scrolling = false, stopFlag = false; function autoCollect() { if (scrolling) return; scrolling = true; stopFlag = false; let i = 0; autoClickWorksTab(); setStatus('正在滚动收集链接…(可点“停止滚动”)'); const timer = setInterval(function () { window.scrollTo(0, document.body.scrollHeight); autoClickWorksTab(); i++; if (i >= CFG.autoScrollMax || stopFlag) { clearInterval(timer); scrolling = false; const pending = collected.size - downloaded.size; setStatus('收集完成,共 ' + collected.size + ' 条,待下载 ' + pending + ' 条。点🚀或②下载。'); } }, CFG.scrollIntervalMs); } // ---------- 手动:一键下载 ---------- function startDownload() { dlCount = 0; dlStopped = false; dlPaused = false; const pending = Array.from(collected.values()).filter(function (it) { return !downloaded.has(it.awemeId); }); if (!pending.length) { setStatus('没有待下载视频(已全部下载或尚未收集)。'); return; } setStatus('开始下载 ' + pending.length + ' 条 → ' + (saveDirHandle ? '绝对目录' : (CFG.saveFolder + '/'))); pending.forEach(function (it, idx) { setTimeout(function () { enqueueDownload(it); }, idx * CFG.downloadGapMs); }); } // ---------- 自动模式 ---------- let autoMode = GM_getValue('jb_auto', false); let autoRunning = false, autoTimer = null, autoScrollMaxGuard = 0; function autoRun() { if (autoRunning) return; autoRunning = true; dlCount = 0; dlStopped = false; dlPaused = false; let lastSize = collected.size, noNew = 0; autoScrollMaxGuard = 0; setStatus('⚙️ 自动模式:自动收集并下载到「' + (saveDirHandle ? '绝对目录' : CFG.saveFolder) + '」…'); autoClickWorksTab(); autoTimer = setInterval(function () { window.scrollTo(0, document.body.scrollHeight); autoClickWorksTab(); if (collected.size === lastSize) noNew++; else { noNew = 0; lastSize = collected.size; } if (noNew >= CFG.noNewStop || autoScrollMaxGuard++ >= CFG.autoScrollMax) { clearInterval(autoTimer); waitQueueDrain(function () { autoRunning = false; setStatus('✅ 自动完成:共 ' + collected.size + ' 条,已下载 ' + downloaded.size + ' 条 → ' + (saveDirHandle ? '绝对目录' : (CFG.saveFolder + '/'))); }); } }, CFG.scrollIntervalMs); } function checkAutoDone() {} // ---------- 暂停 / 停止 下载 ---------- function togglePause() { if (dlStopped) { setStatus('已停止,点🚀/②可重新开始。'); return; } dlPaused = !dlPaused; if (dlPaused) { setStatus('⏸ 已暂停下载(已发起的继续完成)。点“暂停/继续”恢复。'); } else { setStatus('▶ 继续下载。'); pump(); } const btn = panel && panel.querySelector('#jb-dy-pause'); if (btn) btn.textContent = dlPaused ? '▶ 继续下载' : '⏸ 暂停下载'; } function stopDownload() { dlStopped = true; dlPaused = false; downloadQueue.length = 0; const btn = panel && panel.querySelector('#jb-dy-pause'); if (btn) btn.textContent = '⏸ 暂停下载'; setStatus('⏹ 已停止下载(已发起的将继续完成,已下载的不重复)。'); } // ---------- 面板 UI ---------- let panel, statusEl, countEl, doneEl, saveHintEl, yearSelEl, yearStatEl; let yearOptionsBuilt = false; function ensurePanel() { if (panel) return; const s = document.createElement('style'); s.textContent = ` #jb-dy-panel{position:fixed;right:16px;bottom:16px;z-index:99999;width:330px; background:rgba(20,33,61,0.78);backdrop-filter:blur(12px);border:1px solid rgba(255,255,255,0.18); border-radius:16px;padding:16px;color:#fff;font:14px/1.5 "Microsoft YaHei",sans-serif; box-shadow:0 8px 32px rgba(0,0,0,0.4)} #jb-dy-panel h4{margin:0 0 10px;font-size:15px; background:linear-gradient(90deg,#FFB347,#FF8C42);-webkit-background-clip:text;background-clip:text;color:transparent} #jb-dy-panel button{width:100%;margin:6px 0;padding:9px;border:none;border-radius:10px;cursor:pointer; font-weight:700;color:#14213d;background:linear-gradient(90deg,#FFB347,#FF8C42)} #jb-dy-panel .mini{background:rgba(255,255,255,0.15);color:#fff} #jb-dy-panel .auto{background:linear-gradient(90deg,#4ade80,#22c55e);color:#06301a} #jb-dy-panel .row{display:flex;align-items:center;gap:8px;margin:8px 0;font-size:13px} #jb-dy-panel .cnt{color:#FFB347;font-weight:700} #jb-dy-panel input[type=text],#jb-dy-panel select{flex:1;padding:6px 8px;border-radius:8px; border:1px solid rgba(255,255,255,0.3);background:rgba(255,255,255,0.1);color:#fff;font-size:13px} #jb-dy-status{font-size:12px;opacity:.85;margin-top:6px;min-height:16px} #jb-dy-savehint,#jb-dy-absdir,#jb-dy-yearstat{font-size:11px;opacity:.7;margin-top:2px;word-break:break-all} #jb-dy-tip{font-size:11px;opacity:.6;margin-top:6px;line-height:1.4} `; document.head.appendChild(s); panel = document.createElement('div'); panel.id = 'jb-dy-panel'; panel.innerHTML = `