// ==UserScript== // @name 抖音/快手/微视/instagram/TIKTOK/小红书 主页视频下载 // @namespace shortvideo_homepage_downloader // @version 1.0.2 // @description 在抖音/快手/微视/instagram/TIKTOK/小红书 主页右小角显示视频下载按钮 // @author hunmer // @match https://www.douyin.com/user/* // @match https://www.douyin.com/search/* // @match https://www.douyin.com/video/* // @match https://www.kuaishou.com/profile/* // @match https://isee.weishi.qq.com/ws/app-pages/wspersonal/index.html* // @match https://www.instagram.com/* // @match https://www.xiaohongshu.com/user/profile/* // @match https://www.tiktok.com/@* // @icon https://lf1-cdn-tos.bytegoofy.com/goofy/ies/douyin_web/public/favicon.ico // @grant GM_download // @grant GM_addStyle // @grant GM_setValue // @grant GM_getValue // @grant unsafeWindow // @grant GM_xmlhttpRequest // @run-at document-start // @license MIT // @downloadURL none // ==/UserScript== /* 测试页面: https://isee.weishi.qq.com/ws/app-pages/wspersonal/index.html?id=1538201906643006 https://www.douyin.com/user/MS4wLjABAAAANfnAjG-xB__cCOB4hTXFBvG6yZFWNl-FkgCWvpwGN2M https://www.douyin.com/search/%E6%88%91%E4%BB%AC https://www.kuaishou.com/profile/3xqyyjytuef8nsq https://www.tiktok.com/@simonboyyyyyyy https://www.xiaohongshu.com/user/profile/60f0ecec0000000001004874 https://www.instagram.com/rohman__oficial/ */ const $ = selector => document.querySelectorAll('#_dialog '+selector) const ERROR = -1, WAITTING = 0, DOWNLOADING = 1, DOWNLOADED = 2 const RETRY_MAX = 7 const VERSION = '1.0.2', RELEASE_DATE = '2024/08/02' const DEBUG = (...args) => console.log.apply(this, args) const cutString = (s_text, s_start, s_end, i_start = 0, fill = false) => { i_start = s_text.indexOf(s_start, i_start) if (i_start === -1) return '' i_start += s_start.length i_end = s_text.indexOf(s_end, i_start) if (i_end === -1) { if (!fill) return '' i_end = s_text.length } return s_text.substr(i_start, i_end - i_start) } const getParent = (el, callback) => { let par = el while(par && !callback(par)){ par = par.parentElement } return par } // 样式 GM_addStyle(` ._dialog { table tr td, table tr th { vertical-align: middle; } input[type=text], button { color: white !important; background-color: unset !important; } input[type=checkbox] { width: 20px; height: 20px; transform: scale(1.5); -webkit-appearance: checkbox; } } body:has(dialog[open]) { overflow: hidden; } `); ({ resources: [], running: false, options: GM_getValue('config', { threads: 4, douyin_host: 1 // 抖音默认第二个线路 }), saveOptions(opts){ GM_setValue('config', Object.assign(this.options, opts)) }, init(){ // 初始化 this.HOSTS = { // 网站规则 'www.xiaohongshu.com': { title: '小红书', id: 'xhs', getVideoURL: item => new Promise(reslove => { fetch(item.url).then(resp => resp.text()).then(text => { let json = JSON.parse(cutString(text, '"noteDetailMap":', ',"serverRequestInfo":')) let meta = item.meta = json[item.id] reslove(meta.note.video.media.stream.h264[0].masterUrl) }) }), rules: [ { type: 'object', getObject: window => window?.__INITIAL_STATE__?.user.notes?._rawValue, parseList: json => json?.[0], parseItem: data => { let { cover, displayTitle, noteId, type, user, xsecToken } = data?.noteCard if(type == 'video') return { status: WAITTING, author_name: user.nickname, id: noteId, url: 'https://www.xiaohongshu.com/explore/'+noteId+'?xsec_token='+xsecToken+'=&xsec_source=pc_user', cover: cover.urlDefault, title: displayTitle.replaceAll('🥹', ''), data } } } ] }, 'isee.weishi.qq.com': { title: '微视', id: 'weishi', rules: [ { url: 'https://api.weishi.qq.com/trpc.weishi.weishi_h5_proxy.weishi_h5_proxy/GetPersonalFeedList', type: 'network', parseList: json => json?.rsp_body?.feeds, parseItem: data => { let {feed_desc, id, poster, publishtime, video_url, video_cover } = data return { status: WAITTING, author_name: poster.nick, id, url: 'https://isee.weishi.qq.com/ws/app-pages/share/index.html?id='+id, cover: video_cover.static_cover.url, video_url, title: feed_desc, data } } } ] }, 'www.kuaishou.com': { title: '快手', id: 'kuaishou', rules: [ { url: 'https://www.kuaishou.com/graphql', type: 'json', parseList: json => json?.data?.visionProfilePhotoList?.feeds, parseItem: data => { let {photo, author} = data return { status: WAITTING, author_name: author.name, id: photo.id, url: 'https://www.kuaishou.com/short-video/'+photo.id, cover: photo.coverUrl, video_url: photo.photoUrl, // video_url: photo.videoResource.h264.adaptationSet[0].representation[0].url, title: photo.originCaption, data } } } ], }, 'www.douyin.com': { title: '抖音', id: 'douyin', hosts: [0, 1, 2], // 3个线路 runAtWindowLoaded: true, bindVideoElement: { initElement: node => { let par = getParent(node, el => el?.dataset?.e2eVid) if(par) return {id: par.dataset.e2eVid} let id = cutString(location.href + '?', '/video/', '?') if(id) return {id} } }, /*timeout: { '/search/': 500, },*/ rules: [ { url: 'https://www.douyin.com/aweme/v1/web/aweme/post/', type: 'network', parseList: json => json?.aweme_list, parseItem: data => { let {video, desc, author, aweme_id} = data let {uri, height} = video.play_addr || {} let xl = this.options.douyin_host if(video.format == 'mp4') return { status: WAITTING, id: aweme_id, url: 'https://www.douyin.com/video/'+aweme_id, cover: video.cover.url_list[0], author_name: author.nickname, video_url: video.play_addr.url_list.at(xl), //video_url: `https://aweme.snssdk.com/aweme/v1/playwm/?video_id=${uri}&ratio=${height}p&line=0`, // 有水印 title: desc, data } } }, { url: 'https://www.douyin.com/aweme/v1/web/general/search/single/', type: 'network', parseList: json => json?.data, parseItem: data => { let {video, desc, author, aweme_id} = data.aweme_info || {} let xl = this.options.douyin_host if(video) return { status: WAITTING, id: aweme_id, url: 'https://www.douyin.com/video/'+aweme_id, cover: video.cover.url_list[0], author_name: author.nickname, video_url: video.play_addr.url_list.at(xl), title: desc, data } } },{ url: 'https://www.douyin.com/aweme/v1/web/aweme/detail/', type: 'network', parseList: json => [json.aweme_detail], parseItem: data => { let {video, desc, author, aweme_id} = data let cover = video?.cover?.url_list if(cover) return { status: WAITTING, id: aweme_id, url: 'https://www.douyin.com/video/'+aweme_id, cover: cover[0], author_name: author.nickname, video_url: video.play_addr.url_list.at(this.options.douyin_host), title: desc, data } } }, ] }, 'www.tiktok.com': { title: '国际版抖音', id: 'tiktok', rules: [ { url: 'https://www.tiktok.com/api/post/item_list/', type: 'respone.json', parseList: json => json?.itemList, parseItem: data => { let {video, desc, author, id} = data return { status: WAITTING, id, url: 'https://www.tiktok.com/@'+ author.uniqueId +'/video/'+id, cover: video.originCover, author_name: author.nickname, //video_url: video.downloadAddr, video_url: video?.bitrateInfo?.[0]?.PlayAddr.UrlList?.at(-1), title: desc, data } } } ] }, 'www.instagram.com': { title: 'INS', id: 'instagram', rules: [ { url: 'https://www.instagram.com/graphql/query', type: 'network', parseList: json => json?.data?.xdt_api__v1__feed__user_timeline_graphql_connection?.edges, parseItem: data => { // media_type == 2 let {code, owner, product_type, image_versions2, video_versions, caption } = data.node if(product_type == "clips") return { // owner.id status: WAITTING, id: code, url: 'https://www.instagram.com/reel/'+code+'/', cover: image_versions2.candidates[0].url, author_name: owner.username, video_url: video_versions[0].url, title: caption.text, data } } } ] } } let DETAIL = this.DETAIL = this.HOSTS[location.host] if(!DETAIL) return console.log(DETAIL) var originalParse, originalSend, originalFetch, originalResponseJson const initFun = () => { originalParse = JSON.parse, originalSend = XMLHttpRequest.prototype.send, originalFetch = unsafeWindow._fetch = unsafeWindow.fetch, originalResponseJson = Response.prototype.json } var resources = this.resources, object_callbacks = [] const hook = () => { let json_callbacks = [], network_callbacks = [], fetch_callbacks = [], respone_json_callbacks = [] DETAIL.rules.forEach(({type, parseList, parseItem, url, getObject}, rule_index) => { const callback = json => { // console.log(json) try { // TODO sort let cnt = resources.push(...(parseList(json) || []).map(item => Object.assign(parseItem(item) || {}, {rule_index})).filter(item => item.id && !resources.find(({id}) => id == item.id))) if(!cnt > 0) return let fv = document.querySelector('#_ftb') if(!fv){ fv = document.createElement('div') fv.id = '_ftb' fv.style.cssText = `position: fixed;bottom: 50px;right: 50px;border-radius: 20px;background-color: #fe2c55;color: white;z-index: 999;cursor: pointer;` fv.onclick = () => this.showList(), document.body.append(fv) } fv.innerHTML = `下载 ${cnt} 个视频` } catch(err){ console.error(err) } } switch(type){ case 'object': let obj = getObject(unsafeWindow) return callback(obj) case 'json': return json_callbacks.push(json => callback(Object.assign({}, json))) case 'network': return network_callbacks.push({url, callback}) case 'fetch': return fetch_callbacks.push({url, callback}) case 'respone.json': return respone_json_callbacks.push(json => callback(Object.assign({}, json))) } }) if(json_callbacks.length){ JSON.parse = function(...args) { let json = originalParse.call(JSON, args) json_callbacks.forEach(cb => cb(json)) return json } } if(respone_json_callbacks.length){ Object.defineProperty(Response.prototype, 'json', { value: function() { let ret = originalResponseJson.apply(this, arguments) ret.then(json => respone_json_callbacks.forEach(cb => cb(json))) return ret }, writable: true, enumerable: false, configurable: true }); } const cb = (callbacks, {fullURL, raw}) => { callbacks.forEach(({url, callback}) => { // console.log({fullURL, url}) if(fullURL.startsWith(url) && typeof(raw) == 'string' && raw.startsWith('{') && raw.endsWith('}')){ callback(JSON.parse(raw)) } }) } if(network_callbacks.length){ XMLHttpRequest.prototype.send = function() { this.addEventListener('load', function() { if(['', 'text'].includes(this.responseType)) cb(network_callbacks ,{fullURL: this.responseURL, raw: this.responseText}) }) originalSend.apply(this, arguments) } } if(fetch_callbacks.length){ unsafeWindow.fetch = function() { return originalFetch.apply(this, arguments).then(response => { if (response.status == 200) { response.clone().text().then(raw => { cb(fetch_callbacks, {fullURL: response.url, raw}) }) } return response }) } } } let timeout = Object.entries(DETAIL.timeout || {}).find(([path, ms]) => (unsafeWindow.location.pathname || '').startsWith(path))?.[1] || 0 const start = () => { if(!this.inited){ this.inited = true setTimeout(() => initFun() & hook() & setInterval(() => hook(), 250), timeout) } } if(!DETAIL.runAtWindowLoaded) start() window.onload = () => start() & DETAIL.bindVideoElement && this.bindVideoElement(DETAIL.bindVideoElement) }, bindVideoElement({callback, initElement}){ const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { if (mutation.type !== 'childList') return mutation.addedNodes.forEach((node) => { if (node.nodeType === Node.ELEMENT_NODE && node.nodeName == 'VIDEO') { let {id} = initElement(node) || {} let item = this.findItem(id) if(!item) return let url = item.video_url || node.currentSrc || node.querySelector('source')?.src // if(!url || url.startsWith('blob')){ } if(!node.querySelector('._btn_download')){ let el = document.createElement('div') el.classList.className = '_btn_download' el.style.cssText = 'width: 30px;margin: 5px;background-color: rgba(0, 0, 0, .4);color: white;cursor: pointer;position: relative;left: 0;top: 0;z-index: 9999;' el.onclick = ev => { const onError = () => false && alert(`下载失败`) GM_download({ url, name: this.safeFileName(item.title) + '.mp4', headers: this.getHeaders(url), onload: ({status}) => { if(status == 502 || status == 404){ onError() } }, ontimeout: onError, onerror: onError, }) el.remove() & ev.stopPropagation(true) & ev.preventDefault() } el.innerHTML = '下载' el.title = '点击下载' node.before(el) } } }) } }) observer.observe(document.body, { childList: true, // 观察子节点的增减 subtree: true // 观察后代节点 }) }, getHeaders(url){ return { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36', 'Referer': url, } }, showList(){ // 展示主界面 console.log(this.resources) let threads = this.options['threads'] this.showDialog({ id: '_dialog', html: `
命名规则:
下载线程数: ${threads}
${this.resources.map((item, index) => { let {video_url, title, cover, url, id} = item || {} return ` ` }).join('')}
编号 选中 封面 标题 状态
${index+1} ${title} 等待中...

          
`, onClose: () => this.resources.forEach(item => item.status = WAITTING) }) & this.bindEvents() & [ `欢迎使用!当前版本: ${VERSION} 发布日期: ${RELEASE_DATE}`, `此脚本仅供学习交流使用!!请勿用于非法用途!`, ` -------------------------------------------------------------------------------------- 常见问题: 为什么没有显示入口按钮? 可能是脚本插入时机慢了,可以多滚动或者多刷新几次 为什么下载显示失败 常见于抖音,抖音每个视频有三个线路,但并不是每个线路都是有视频存在的。所以目前的解决是 每个线路都尝试下载一次 为什么捕获的数量不等于主页作品数量 目前只能捕获视频作品,而非图文作品。 为什么只能捕获一页的数据/翻页不了 有些不常用的站点可能存在这些问题待修复 计划列表:导出/导入数据,自动选择适合的线路,区间选择,右键菜单选择,下载链接可视化,发送aria2下载 -------------------------------------------------------------------------------------- ` ].forEach(msg => this.writeLog(msg, '声明')) }, showDialog({html, id, callback, onClose}){ // 弹窗 document.body.insertAdjacentHTML('beforeEnd', ` X ${html} `) setTimeout(() => { let dialog = document.querySelector('#'+id) dialog.querySelector('._dialog_close').onclick = () => dialog.remove() & (onClose && onClose()) callback && callback(dialog) }, 500) }, bindEvents(){ // 绑定DOM事件 $('#_threads')[0].oninput = function(ev){ $('#_threads_span')[0].innerHTML = this.value } $('#_apply_filename')[0].onclick = () => { for(let tr of $('table tr[data-id]')){ let item = this.findItem(tr.dataset.id) if(!item) return let {title, author_name, id} = item tr.querySelector('td[contenteditable]').innerHTML = $('#_filename')[0].value.replace('{标题}', title).replace('{id}', id).replace('{发布者}', author_name) } } $('#_selectAll')[0].onclick = () => $('table input[type=checkbox]').forEach(el => el.checked = true) $('#_reverSelectAll')[0].onclick = () => $('table input[type=checkbox]').forEach(el => el.checked = !el.checked) $('#_clear_log')[0].onclick = () => $('#_log')[0].innerHTML = '' $('#_switchRunning')[0].onclick = () => this.switchRunning() $('#_settings')[0].onclick = () => { this.showDialog({ id: '_dialog_settings', html: `

如果可以正常下载,请勿设置线路!!!

${Object.values(this.HOSTS).map(({hosts, title, id}) => { hosts ??= [] let html = `${title}线路: ` return hosts.length ? html : '' }).join('')} `, callback: dialog => { for(let select of dialog.querySelectorAll('select')) select.onchange = () => { let opts = {} opts[`${select.dataset.for}_host`] = select.value this.saveOptions(opts) } }, // onClose: () => this.resources = this.resources.map(item => this.DETAIL.parseItem(item.data)) }) } $('#_clearDownloads')[0].onclick = () => { if(this.running) return alert('请先暂停任务') for(let i=this.resources.length-1;i>=0;i--){ let item = this.resources[i] let {status, id} = item let tr = this.findElement(item.id) if(tr){ if(status == DOWNLOADED){ this.resources.splice(i, 1) tr.remove() continue } let td = tr.querySelectorAll('td') td[4].style.backgroundColor = 'unset' td[4].innerHTML = '等待中...' } item.status = WAITTING } } }, switchRunning(running){ // 切换运行状态 this.running = running ??= !this.running $('#_switchRunning')[0].innerHTML = running ? '暂停' : '运行' if(running){ let threads = parseInt($('#_threads')[0].value) let cnt = threads - this.getItems(DOWNLOADING).length if(cnt){ this.writeLog('开始线程下载:'+cnt) this.saveOptions({threads}) for(let i=0;i status == _status) }, nextDownload(){ // 进行下一次下载 let {resources} = this if(!resources.some(item => { let {status, id, video_url, rule_index} = item if(status == WAITTING){ let tr = this.findElement(id) if(!tr) return let td = tr.querySelectorAll('td') let checked = td[1].querySelector('input[type=checkbox]').checked let title = td[3].outerText if(checked){ item.status = DOWNLOADING const log = (msg, color, next = true) => { this.writeLog(msg, `${title}`, color, td[4]) if(next) this.nextDownload() item.status = color == 'success' ? DOWNLOADED : ERROR } // 预先下载并尝试重试(多线程下需要重试才能正常下载) let retry = 0 const httpRequest = url => GM_xmlhttpRequest({ method: "GET", url, headers: this.getHeaders(url), // redirect: 'follow', responseType: "blob", timeout: 999999, // anonymous:true, onload: ({status, response, finalUrl}) => { // console.log({status, finalUrl, response}) if (status === 200) { const downloadURL = url => GM_download({ url, name: this.safeFileName(title) + '.mp4', headers: this.getHeaders(url), onload: ({status}) => { if(status == 502 || status == 404){ log(`下载失败`, 'error') }else{ log(`下载完成...`, 'success') } }, timeout: 999999, // 无效 ontimeout: () => log(`超时`, 'error'), onerror: () => log(`下载失败`, 'error'), }) if(!response){ if(!finalUrl) return log(`请求错误`, 'error') downloadURL(finalUrl) }else{ downloadURL(URL.createObjectURL(response)) } }else if(retry++ < RETRY_MAX){ // console.log('下载失败,重试中...', video_url) setTimeout(() => httpRequest(), 500) }else{ log(`重试下载错误`, 'error') } } }) if(!video_url){ let getVideoURL = this.DETAIL[rule_index]?.getVideoURL || this.DETAIL.getVideoURL if(!getVideoURL) return log(`无下载地址`, 'error') getVideoURL(item).then(url => { item.video_url = url httpRequest(url) }) }else{ httpRequest(video_url) } return true } } })){ if(this.running){ this.writeLog('下载完成!') & this.switchRunning(false) } } }, findElement(id){ // 根据Id查找dom return $(`tr[data-id="${id}"]`)[0] }, writeLog(msg, prefix = '提示', color = 'info', el){ // 输出日志 color = {success: '#8bc34a', error: '#a31545', info: '#fff' }[color] $('#_log')[0].insertAdjacentHTML('beforeEnd', `

【${prefix}】 ${msg}

`) if(el){ el.style.backgroundColor = color el.innerHTML = msg } }, findItem(id, method = 'find'){ // 根据Item查找资源信息 return this.resources[method](_item => _item.id == id) }, safeFileName: str => str.replaceAll('\n', ' ').replaceAll('(', '(').replaceAll(')', ')').replaceAll(':', ':').replaceAll('*', '*').replaceAll('?', '?').replaceAll('"', '"').replaceAll('<', '<').replaceAll('>', '>').replaceAll("|", "|").replaceAll('\\', '\').replaceAll('/', '/') }).init()