您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
在抖音/快手/微视/instagram/TIKTOK主页右小角显示视频下载按钮
当前为
// ==UserScript== // @name 抖音/快手/微视/instagram/TIKTOK 主页视频下载 // @namespace shortvideo_homepage_downloader // @version 0.0.7 // @description 在抖音/快手/微视/instagram/TIKTOK主页右小角显示视频下载按钮 // @author hunmer // @match https://www.douyin.com/user/* // @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.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 // @license MIT // ==/UserScript== const $ = selector => document.querySelectorAll('#_dialog '+selector) const DOWNLOADED = 2 const DOWNLOADING = 1 const WAITTING = 0 const ERROR = -1 const RETRY_MAX = 20 const VERSION = '0.0.7' const RELEASE_DATE = '2024/07/26' const DEBUG = (...args) => console.log.apply(this, args) // 样式 GM_addStyle(` ._dialog { button { color: white !important; background-color: unset !important; } } body:has(dialog[open]) { overflow: hidden; } `); ({ resources: [], running: false, options: GM_getValue('config', { threads: 2, douyin_host: 1 // 抖音默认第二个线路 }), saveOptions(opts){ GM_setValue('config', Object.assign(this.options, opts)) }, init(){ // 初始化 this.HOSTS = { // 网站规则 'isee.weishi.qq.com': { title: '微视', id: 'weishi', 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', 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', url: 'https://www.douyin.com/aweme/v1/web/aweme/post/', type: 'network', hosts: [0, 1, 2], // 3个线路 parseList: json => json?.aweme_list, parseItem: data => { let {video, desc, author, aweme_id} = data 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(this.options.douyin_host), title: desc, data } } }, 'www.tiktok.com': { title: '国际版抖音', id: 'tiktok', url: 'https://www.tiktok.com/api/post/item_list/', type: 'network', 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', 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) let callback = (...args) => this.callback.apply(this, args) var parse = JSON.parse, originalSend = XMLHttpRequest.prototype.send, originalFetch = window.fetch const hook = () => { switch(DETAIL.type){ case 'json': JSON.parse = function(raw) { let json = parse(raw) callback(Object.assign({}, json)) return json; } return case 'network': XMLHttpRequest.prototype.send = function() { this.addEventListener('load', function() { // DEBUG(this.responseURL) if (this.responseURL.startsWith(DETAIL.url)) { callback(JSON.parse(this.responseText)) } }); originalSend.apply(this, arguments); }; unsafeWindow.fetch = function() { return originalFetch.apply(this, arguments).then(response => { DEBUG(response.url) if (response.status == 200 && response.url.startsWith(DETAIL.url)) { response.clone().text().then(raw => { if(raw != '') callback(JSON.parse(raw)) }) } return response; }); } /*unsafeWindow.fetch = function(){ return new Promise((resolve, reject) => { originalFetch.apply(this, arguments).then((response) => { const oldJson = response.json; response.json = function () { return new Promise((resolve, reject) => { oldJson.apply(this, arguments).then((result) => { callback(result) resolve(result); }); }); }; resolve(response); }) }) }*/ return } } hook() & setInterval(() => hook(), 250) }, callback(json){ // 捕获数据回调 console.log(json) let {resources, DETAIL} = this let {parseList, parseItem} = DETAIL let cnt = resources.push(...(parseList(json) || []).map(parseItem)) 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} 个视频` }, showList(){ // 展示主界面 let threads = this.options['threads'] this.showDialog({ id: '_dialog', html: ` <div style="display: inline-flex;width: 100%;justify-content: space-around;height: 5%;min-height: 30px;"> <div> <button id="_selectAll">全选</button> <button id="_reverSelectAll">反选</button> <button id="_clear_log">清空日志</button> </div> <div> 命名规则: <input id="_filename" value="【{发布者}】{标题}({id})" title="允许的变量:{发布者} {标题} {id}"> <button id="_apply_filename">应用</button> </div> <div> 下载线程数: <input id="_threads" type="range" value=${threads} step=1 min=1 max=8> <span id="_threads_span">${threads}</span> </div> <div> <button id="_settings">线路</button> <button id="_clearDownloads">清空已下载</button> <button id="_switchRunning">开始</button> </div> </div> <div style="height: 70%;overflow-y: scroll;"> <table width="90%" border="2" style="margin: 0 auto;"> <tr align="center"> <th>编号</th> <th>选中</th> <th>封面</th> <th>标题</th> <th>状态</th> </tr> ${this.resources.map((item, index) => { let {video_url, title, cover, url, id} = item || {} return video_url ? ` <tr align="center" data-id="${id}"> <td>${index+1}</td> <td><input type="checkbox" style="transform: scale(1.5);" checked></td> <td><a href="${url}" target="_blank"><img src="${cover}" style="width: 100px;"></a></td> <td contenteditable style="width: 400px;max-width: 400px;">${title}</td> <td>等待中...</td> </tr>` : '' }).join('')} </table> </div> <div style="height: 25%; width: 100%;overflow-y: scroll;border-top: 2px solid white;"> <pre id="_log" style="background-color: rgba(255, 255, 255, .2);color: rgba(0, 0, 0, .8);"></pre> </div>`, onClose: () => this.resources.forEach(item => item.status = WAITTING) }) & this.bindEvents() & this.writeLog(`欢迎使用!当前版本: ${VERSION} 发布日期: ${RELEASE_DATE}`) & this.writeLog(`此脚本仅供学习交流使用!!如果下载失败请尝试切换线路!`) }, showDialog({html, id, callback, onClose}){ // 弹窗 document.body.insertAdjacentHTML('beforeEnd', ` <dialog class="_dialog" id="${id}" style="top: 0;left: 0;width: 100%;height: 100%;position: fixed;z-index: 9999;background-color: rgba(0, 0, 0, .8);color: #fff;padding: 10px;overflow: auto; overscroll-behavior: contain;" open> <a href="#" style="position: absolute;right: 0;top: 0;padding: 10px;background-color: rgba(255, 255, 255, .4);" class="_dialog_close">X</a> ${html} <dialog>`) 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: ` <h1>线路设置后如果已经下载过了需要关闭主界面再打开重新打开!!!</h1> ${Object.values(this.HOSTS).map(({hosts, title, id}) => { hosts ??= [] let html = `${title}线路: <select data-for="${id}">${hosts.map(host => `<option ${this.options[id+'_host'] == host ? 'selected' : ''}>${host}</option>`).join('')}</select>` 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<cnt;i++) this.nextDownload() } } }, getItems(_status){ // 获取指定状态任务 return this.resources.filter(({status}) => status == _status) }, nextDownload(){ // 进行下一次下载 let {resources} = this if(!resources.some(item => { let {status, id, video_url} = item if(!video_url) return 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, `<a href="${item.url}" target="_blank" style="color: white;">${title}</a>`, color, td[4]) if(next) this.nextDownload() item.status = color == 'success' ? DOWNLOADED : ERROR } // 预先下载并尝试重试(多线程下需要重试才能正常下载) let headers = { 'User-Agent': 'Mozilla/5.0 (Linux; U; Android 4.0.2; en-us; Galaxy Nexus Build/ICL53F) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', 'Referer': location.host, }, retry = 0 const httpRequest = () => GM_xmlhttpRequest({ method: "GET", url: video_url, headers, responseType: "blob", // anonymous:true, onload: (response) => { if (response.status === 200) { const blobURL = URL.createObjectURL(response.response) GM_download({ url: blobURL, headers, name: this.safeFileName(title) + '.mp4', onload: ({status}) => { if(status == 502 || status == 404){ log(`下载失败`, 'error') }else{ log(`下载完成...`, 'success') } }, onerror: () => log(`下载失败`, 'error'), }) }else if(retry++ < RETRY_MAX){ // console.log('下载失败,重试中...', video_url) setTimeout(() => httpRequest(), 500) }else{ log(`重试下载错误`, 'error') } } }) httpRequest() 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', `<p style="color: ${color}">【${prefix}】 ${msg}</p>`) 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()
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址