// ==UserScript== // @name 抖音/快手/微视/instagram/TIKTOK/小红书/微博/今日头条 主页视频下载 // @namespace shortvideo_homepage_downloader // @version 1.3.6 // @description 在抖音/快手/微视/instagram/TIKTOK/小红书/微博/今日头条 主页右小角显示视频下载按钮 // @author hunmer // @match https://pixabay.com/videos/search/* // @match https://www.xinpianchang.com/discover/* // @match https://www.douyin.com/user/* // @match https://www.douyin.com/search/* // @match https://www.douyin.com/video/* // @match https://www.douyin.com/note/* // @match https://www.toutiao.com/c/user/token/* // @match https://www.kuaishou.com/profile/* // @match https://www.kuaishou.com/search/video* // @match1 https://www.youtube.com/@*/shorts // @match https://x.com/*/media // @match https://weibo.com/u/*?tabtype=newVideo* // @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.xiaohongshu.com/search_result/* // @match https://www.xiaohongshu.com/explore* // @match https://www.tiktok.com/@* // @match https://www.tiktok.com/search* // @match https://artlist.io/stock-footage/story/* // @match https://artlist.io/stock-footage/search?* // @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 GM_addElement // @grant unsafeWindow // @grant GM_xmlhttpRequest // @run-at document-start // @license MIT // @downloadURL none // ==/UserScript== const $ = selector => document.querySelectorAll('#_dialog '+selector) const ERROR = -1, WAITTING = 0, DOWNLOADING = 1, DOWNLOADED = 2 const VERSION = '1.3.6', RELEASE_DATE = '2025/04/11' const DEBUGING = false const DEBUG = (...args) => DEBUGING && console.log.apply(this, args) const toArr = arr => Array.isArray(arr) ? arr : [arr] const guid = () => { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8) return v.toString(16) }) } Date.prototype.format = function (fmt) { var o = { "M+": this.getMonth() + 1, "d+": this.getDate(), "h+": this.getHours(), "m+": this.getMinutes(), "s+": this.getSeconds(), "q+": Math.floor((this.getMonth() + 3) / 3), "S": this.getMilliseconds() }; if (/(y+)/.test(fmt)) { fmt = fmt.replace(RegExp.$1, (this.getFullYear() + "").substr(4 - RegExp.$1.length)) } for (var k in o) { if (new RegExp("(" + k + ")").test(fmt)) { fmt = fmt.replace(RegExp.$1, (RegExp.$1.length == 1) ? (o[k]) : (("00" + o[k]).substr(("" + o[k]).length))) } } return fmt } const flattenArray = arr => { if(!Array.isArray(arr)) return [] var result = [] for (var i = 0; i < arr.length; i++) { if (Array.isArray(arr[i])) { result = result.concat(flattenArray(arr[i])) } else { result.push(arr[i]) } } return result } const getExtName = name => { switch(name){ case 'video': return 'mp4' case 'image': case 'photo': return 'jpg' } return name ?? 'mp4' } const escapeHTMLPolicy = typeof(trustedTypes) != 'undefined' ? trustedTypes.createPolicy("forceInner", { createHTML: html => html, }) : { createHTML: html => html } const createHTML = html => escapeHTMLPolicy.createHTML(html) const openFileDialog = ({callback, accept = '*'}) => { let input = document.createElement('input') input.type = 'file' input.style.display = 'none' input.accept = accept document.body.appendChild(input) input.addEventListener('change', ev => callback(ev.target.files) & input.remove()) input.click() } const loadRes = (files, callback) => { return new Promise(reslove => { files = [...files] var next = () => { let url = files.shift() if (url == undefined) { callback && callback() return reslove() } let fileref, ext = url.split('.').at(-1) if (ext == 'js') { fileref = GM_addElement('script', { src: url, type: ext == 'js' ? "text/javascript" : 'module' }) } else if (ext == "css") { fileref = GM_addElement('link', { href: url, rel: "stylesheet", type: "text/css" }) } if (fileref != undefined) { let el = document.getElementsByTagName("head")[0].appendChild(fileref) el.onload = next, el.onerror = next } else { next() } } next() }) } 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 } const chooseObject = (cb, ...objs) => { let callback = typeof(cb) == 'function' ? cb : obj => obj?.[cb] return objs.find(callback) } // 样式 GM_addStyle(` ._dialog { input[type=checkbox] { -webkit-appearance: auto !important; } color: white !important; font-size: large !important; font-family: unset !important; input { color: white; border: 1px solid; } table tr td, table tr th { vertical-align: middle; } input[type=text], button { color: white !important; background-color: unset !important; } table input[type=checkbox] { width: 20px; height: 20px; transform: scale(1.5); -webkit-appearance: checkbox; } } body:has(dialog[open]) { overflow: hidden; } `); unsafeWindow._downloader = _downloader = { loadRes, resources: [], running: false, downloads: {}, options: Object.assign({ threads: 8, firstRun: true, autoRename: false, alert_done: true, show_img_limit: 500, douyin_host: 1, // 抖音默认第二个线路 timeout: 1000 * 60, retry_max: 60, autoScroll: true, aria2c_host: '127.0.0.1', aria2c_port: 6800, aria2c_secret: '', aria2c_saveTo: './downloads' }, GM_getValue('config', {})), saveOptions(opts = {}){ opts = Object.assign(this.options, opts) GM_setValue('config', opts) }, _aria_callbacks: [], bindAria2Event(method, gid, callback){ this._aria_callbacks.push({ method: 'aria2.' + method, gid, callback }) }, enableAria2c(enable){ if(enable){ if(!this.aria2c){ loadRes(['https://www.unpkg.com/httpclient@0.1.0/bundle.js', 'https://www.unpkg.com/aria2@2.0.1/bundle.js'], () => { this.writeLog('正在连接aria2,请等待连接成功后再开始下载!!!', 'ARIA2C') var aria2 = this.aria2c = new unsafeWindow.Aria2({ host: this.options.aria2c_host, port: this.options.aria2c_port, secure: false, secret: this.options.aria2c_secret, path: '/jsonrpc', jsonp: false }) aria2.open().then(() => { aria2.opening = true this.writeLog('aria2成功连接!', 'ARIA2C') $('[data-for="useAria2c"]')[0].checked = true }).catch((err) => this.writeLog('aria2链接失败,'+err.toString(), 'ARIA2C')); aria2.onclose = () => { aria2.opening = false this.writeLog('aria2失去连接!', 'ARIA2C') $('[data-for="useAria2c"]')[0].checked = false } aria2.onmessage = ({ method: _method, id, result, params }) => { console.log({_method, result, params}) switch (_method) { // case 'aria2.onDownloadError': // 下载完成了还莫名触发? case 'aria2.onDownloadComplete': for (let i = this._aria_callbacks.length - 1; i >= 0; i--) { let { gid, method, callback } = this._aria_callbacks[i] if (gid == params[0].gid) { if (method == _method) { // 如果gid有任何一个事件成功了则删除其他事件绑定 callback() } this._aria_callbacks.splice(i, 1) } } return } } }) } }else{ if(this.aria2c){ this.aria2c.close() this.aria2c = undefined } } }, addDownload(opts){ console.log(opts) let _id = guid() var {id, url, name, error, success, download, downloadTool} = opts if(download){ // 命名规则 let {ext, type, title} = download ext ||= getExtName(type) name = this.safeFileName(this.getDownloadName(id) ?? title) + (ext != '' ? '.' + ext : '') } const callback = (status, msg) => { let cb = opts[status] cb && cb(msg) this.removeDownload(_id) } var abort, timer var headers = this.getHeaders(url) if(downloadTool == 'm3u8dl'){ let base64 = new Base64().encode(`"${url}" --workDir "${this.options.aria2c_saveTo}" --saveName "${name}" --enableDelAfterDone --headers "Referer:https://artlist.io/" --maxThreads "6" --downloadRange "0-1"`) unsafeWindow.open(`m3u8dl://`+base64, '_blank') return callback('success', '下载完成...') } if(this.aria2c){ var _guid this.aria2c.send("addUri", [url], { dir: this.options.aria2c_saveTo, header: Object.entries(headers).map(([k, v]) => `${k}: ${v}`), out: name, }).then(guid => { _guid = guid this.bindAria2Event('onDownloadComplete', guid, () => callback('success', '下载完成...')) this.bindAria2Event('onDownloadError', guid, () => callback('error', '下载失败')) }) abort = () => _guid && this.aria2c.send("remove", [_guid]) }else{ var fileStream abort = () => fileStream.abort() timer = setTimeout(() => { callback('error', '超时') this.removeDownload(_id, true) }, this.options.timeout) const writeStream = readableStream => { if (unsafeWindow.WritableStream && readableStream.pipeTo) { return readableStream.pipeTo(fileStream).then(() => callback('success', '下载完成...')).catch(err => callback('error', '下载失败')) } } let isTiktok = location.host == 'www.tiktok.com' if(isTiktok) headers.Referer = url GM_xmlhttpRequest({ url, headers, redirect: 'follow', responseType: 'blob', method: "GET", onload: ({response, status}) => { console.log({response, status}) // BUG 不知为啥tiktok无法使用流保存 if(isTiktok || typeof(streamSaver) == 'undefined'){ return unsafeWindow.saveAs(response, name) & callback('success', '下载完成...') } let res = new Response(response).clone() fileStream = streamSaver.createWriteStream(name, {size: response.size}) writeStream(res.body) //writeStream(response.stream()) } }) } return this.downloads[_id] = {abort, timer} }, removeDownload(id, cancel = false){ let {timer, abort} = this.downloads[id] ?? {} if(timer) clearTimeout(timer) cancel && abort() delete this.downloads[id] }, setBadge(html){ 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;padding: 5px;` fv.onclick = () => this.showList() fv.oncontextmenu = ev => { this.setList([], false) fv.remove() ev.stopPropagation(true) & ev.preventDefault() } document.body.append(fv) } fv.innerHTML = createHTML(html) }, init(){ // 初始化 const parseDouyinList = data => { let {video, desc, images} = data let author = data.author ?? data.authorInfo let aweme_id = data.aweme_id ?? data.awemeId let create_time = data.create_time ?? data.createTime //let {uri, height} = video.play_addr || {} let xl = this.options.douyin_host return { status: WAITTING, id: aweme_id, url: 'https://www.douyin.com/video/'+aweme_id, cover: (video?.cover?.url_list || video?.coverUrlList)[0], author_name: author.nickname, create_time: create_time * 1000, urls: images ? images.map(({height, width, download_url_list, downloadUrlList}, index) => { return {url: (download_url_list ?? downloadUrlList)[0], type: 'photo'} }) : video.play_addr.url_list.at(xl), title: desc, data } } this.HOSTS = { // 网站规则 'x.com': { title: '推特', id: 'twitter', rules: [ { url: 'https://x.com/i/api/graphql/(.*?)/UserMedia', type: 'network', parseList: json => json?.data?.user?.result?.timeline_v2?.timeline?.instructions?.[0]?.moduleItems, parseItem: data => { let {legacy, user_results, core, views: {count: view_count}} = data.item.itemContent.tweet_results.result let {description: author_desc, name: author_name, id: author_id,} = core.user_results.result let {created_at, full_text: title, lang, extended_entities, favorite_count, bookmark_count, quote_count, reply_count, retweet_count, id_str: id} = legacy if(extended_entities?.media) return extended_entities.media.map(({type, media_url_https: url, original_info: {height, width}}, index) => { return { status: WAITTING, url: 'https://x.com/pentarouX/status/'+id, cover: url+'?format=jpg&name=360x360', id: id, author_name, urls: [{url, type}], title, index, create_time: created_at, data } }) } } ] }, 'www.youtube.com': { title: '油管', id: 'youtube', 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: [ { url: 'https://www.youtube.com/youtubei/v1/browse', type: 'fetch', parseList: json => json?.onResponseReceivedActions[0].appendContinuationItemsAction.continuationItems, parseItem: data => { if(!data.richItemRenderer) return let {videoId, headline, thumbnail} = data.richItemRenderer.content.reelItemRenderer return { status: WAITTING, id: videoId, url: 'https://www.youtube.com/shorts/'+videoId, cover: thumbnail.thumbnails[0].url, author_name: '', urls: '', title: headline.simpleText, data } } } ] }, 'pixabay.com': { title: 'pixabay', id: 'pixabay', rules: [ { type: 'object', getObject: window => window?.__BOOTSTRAP__?.page?.results, parseList: json => json, parseItem: data => { let {id, description, href , user, uploadDate, name, sources} = data return { status: WAITTING,id, url: 'https://pixabay.com'+href, cover: sources.thumbnail, author_name: user.username, urls: sources.mp4.replace('_tiny', ''), title: `【${name}】${description}`, create_time: uploadDate, data } } } ] }, 'weibo.com': { title: '微博', id: 'weibo', rules: [ { url: 'https://weibo.com/ajax/profile/getWaterFallContent', type: 'network', parseList: json => json?.data?.list, parseItem: data => { let {page_info, created_at, text_raw} = data let {short_url, object_id, media_info, page_pic} = page_info return { status: WAITTING, id: object_id, url: short_url, cover: page_pic, author_name: media_info.author_name, urls: media_info.playback_list[0].play_info.url, title: text_raw, create_time: created_at, data } } } ] }, 'www.xinpianchang.com': { title: '新片场', id: 'xinpianchang', runAtWindowLoaded: false, getVideoURL: item => new Promise(reslove => { fetch(`https://mod-api.xinpianchang.com/mod/api/v2/media/${item.media_id}?appKey=61a2f329348b3bf77&extend=userInfo%2CuserStatus`).then(resp => resp.json()).then(json => { reslove(json.data.resource.progressive.find(({url}) => url != '').url) }) }), rules: [ { url: 'https://www.xinpianchang.com/_next/data/', type: 'json', parseList: json => { return flattenArray((json?.pageProps?.squareData?.section || []).map(({articles}) => articles || [])) }, parseItem: data => { let {author, content, cover, media_id, title, web_url, publish_time, id} = data return { status: WAITTING,id, url: web_url, cover, title, media_id, author_name: author.userinfo.username, create_time: publish_time, data } } } ] }, '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 note = json[item.id].note Object.assign(item, {create_time: note.time, meta: note}) console.log(note) reslove(note.type == 'video' ? {url: note.video.media.stream.h265[0].masterUrl, type: 'video'} : note.imageList.map(({urlDefault}) => { return {url: urlDefault, type: 'photo'} })) }) }), rules: [ { type: 'object', getObject: window => location.href.startsWith('https://www.xiaohongshu.com/explore/') ? window?.__INITIAL_STATE__?.note?.noteDetailMap : {}, parseList: json => { let list = Object.values(json).filter(({note}) => note).map(({note}) => note) return list }, parseItem: data => { let { desc, imageList = [], noteId: id, time, user, xsecToken, title, type, video} = data let images = imageList.map(({urlDefault}) => { return {url: urlDefault, type: 'photo'} }) let urls = type == 'normal' ? images : video.media.stream.h265[0].masterUrl return { status: WAITTING, author_name: user.nickname, id, url: 'https://www.xiaohongshu.com/explore/'+id, urls, cover: images[0].url, title: desc, data } } }, { type: 'object', getObject: window => chooseObject(obj => flattenArray(obj).length > 0, window?.__INITIAL_STATE__?.user.notes?._rawValue, window?.__INITIAL_STATE__?.search.feeds?._rawValue, window?.__INITIAL_STATE__?.feed.feeds?._rawValue), parseList: json => { let list = [] Array.isArray(json) && json.forEach(items => { if(Array.isArray(items)) { items.forEach(item => { if(item.noteCard) list.push(item) }) }else if(items?.noteCard){ list.push(items) } }) return list }, parseItem: data => { let { cover, displayTitle, noteId, type, user, xsecToken} = data?.noteCard || {} let id = noteId ?? data.id xsecToken ??= data.xsecToken ??= '' // if(xsecToken) { return { status: WAITTING, author_name: user.nickname, id, url: `https://www.xiaohongshu.com/explore/${id}?source=webshare&xhsshare=pc_web&xsec_token=${xsecToken.slice(0, -1)}=&xsec_source=pc_share`, // +'?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, urls, video_cover, createtime } = 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, urls, title: feed_desc, create_time: createtime * 1000, data } } } ] }, 'www.kuaishou.com': { title: '快手', id: 'kuaishou', rules: [ { url: 'https://www.kuaishou.com/graphql', type: 'json', parseList: json => { let href = location.href if(href.startsWith('https://www.kuaishou.com/profile/')){ return json?.data?.visionProfileLikePhotoList?.feeds || json?.data?.visionProfilePhotoList?.feeds } if(href.startsWith('https://www.kuaishou.com/search/')){ return json?.data?.visionSearchPhoto?.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, urls: photo.photoUrl, create_time: photo.timestamp, // urls: photo.videoResource.h264.adaptationSet[0].representation[0].url, title: photo.originCaption, data } } } ], }, 'www.toutiao.com': { title: '今日头条短视频', id: 'toutiao', rules: [ { url: 'https://www.toutiao.com/api/pc/list/user/feed', type: 'json', parseList: json => json?.data, parseItem: data => { let {video, title, id, user, thumb_image_list, create_time} = data return { status: WAITTING, id, title, data, url: 'https://www.toutiao.com/video/'+id, cover: thumb_image_list[0].url, author_name: user.info.name, create_time: create_time * 1000, urls: video.download_addr.url_list[0], } } } ], }, 'www.douyin.com': { title: '抖音', id: 'douyin', scrollContainer: { 'https://www.douyin.com/user/': '.route-scroll-container' }, hosts: [0, 1, 2], // 3个线路 runAtWindowLoaded: false, 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: { '/user/': 500, '/note/': 500, '/video/': 500, '/search/': 500, }, rules: [ { type: 'object', getObject: window => { let noteId = cutString(window.location.href + '#', '/note/', '#') if(noteId){ let raw = cutString((window?.self?.__pace_f ?? []).filter(arr => arr.length == 2).map(([k, v]) => v || '').join(''), '"aweme":{', ',"comment').replaceAll(`\\"`, '') if(raw.at(-1) == '}'){ let json = JSON.parse("{"+raw) if(json.detail.awemeId == noteId) return json } } }, parseList: json => { return json ? [json.detail] : [] }, parseItem: parseDouyinList }, { // 个人喜欢 url: 'https://www.douyin.com/aweme/v1/web/aweme/favorite/', type: 'network', parseList: json => location.href == 'https://www.douyin.com/user/self?from_tab_name=main&showTab=like' ? json?.aweme_list : [], parseItem: parseDouyinList, }, { // 个人收藏 url: 'https://www.douyin.com/aweme/v1/web/aweme/listcollection/', type: 'network', parseList: json => location.href == 'https://www.douyin.com/user/self?from_tab_name=main&showTab=favorite_collection' ? json?.aweme_list : [], parseItem: parseDouyinList, }, { url: 'https://(.*?).douyin.com/aweme/v1/web/aweme/post/', type: 'network', parseList: json => location.href.startsWith('https://www.douyin.com/user/') ? json?.aweme_list : [], parseItem: parseDouyinList }, { url: 'https://www.douyin.com/aweme/v1/web/general/search/single/', type: 'network', parseList: json => json?.data, parseItem: data => parseDouyinList(data.aweme_info) },{ url: 'https://www.douyin.com/aweme/v1/web/aweme/detail/', type: 'network', parseList: json => location.href.startsWith('https://www.douyin.com/video/') ? [json.aweme_detail] : [], parseItem: parseDouyinList }, ] }, '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, createTime} = data return { status: WAITTING, id, url: 'https://www.tiktok.com/@'+ author.uniqueId +'/video/'+id, cover: video.originCover, author_name: author.nickname, create_time: createTime * 1000, //urls: video.downloadAddr, urls: video?.bitrateInfo?.[0]?.PlayAddr.UrlList[0], title: desc, data } } }, { url: 'https://www.tiktok.com/api/search/general/full/', type: 'respone.json', parseList: json => json?.data, parseItem: data => { let {video, desc, author, id, createTime} = data.item return { status: WAITTING, id, url: 'https://www.tiktok.com/@'+ author.uniqueId +'/video/'+id, cover: video.originCover, author_name: author.nickname, create_time: createTime * 1000, urls: 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, urls: video_versions[0].url, create_time: caption.created_at * 1000, title: caption.text, data } } } ] }, 'artlist.io': { title: 'artlist', id: 'artlist', rules: [ { // url: 'https://search-api.artlist.io/v1/graphql', type: 'json', parseList: json => { return json?.data?.story?.clips || json?.data?.clipList?.exactResults }, parseItem: data => { let {thumbnailUrl, clipPath, clipName, orientation, id, clipNameForUrl, storyNameForURL } = data return { status: WAITTING, id, downloadTool: 'm3u8dl', url: 'https://artlist.io/stock-footage/clip/'+clipNameForUrl+'/'+id, cover: thumbnailUrl, author_name: storyNameForURL, urls: [{url: clipPath.replace('playlist', '1080p'), type: ""}], title: clipName, 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 if(this.options.firstRun){ this.options.firstRun = false this.saveOptions() alert("欢迎使用此视频批量下载脚本,以下是常见问题:\n【1】.Q:为什么没有显示下载入口?A:当前网址不支持\n【2】Q:为什么捕获不到视频?A:试着滚动视频列表,让他进行加载\n【3】Q:为什么抖音主页显示用户未找到?A:多刷新几次【4】Q:提示下载失败怎么办?A:可以批量导出链接用第三方软件进行下载(如IDM)") } this.setBadge("等待滚动捕获数据中...") } 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, match}, rule_index) => { const callback = json => { // console.log(json) try { // TODO sort let cnt = resources.push(...(flattenArray((parseList(json) || []).map(item => toArr(parseItem(item)).map(_item => Object.assign(_item || {}, {rule_index})))).filter(item => item.id && !resources.find(({id, index}) => id == item.id && index == item.index)))) if(cnt <= 0) return this.tryAutoRenameAll() this.setBadge(`下载 ${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.apply(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}) => { if(new RegExp(url).test(fullURL) && typeof(raw) == 'string' && (raw.startsWith('{') && raw.endsWith('}') || 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)) & this.initAction() }, tryAutoRenameAll(){ if(this.options.autoRename && this.isShowing()){ if(!this.initedRename){ this.initedRename = true let lastName = this.options.lastRename if(typeof(lastName) == 'string') $('#_filename')[0].value = lastName } this.applyRenameAll() } }, autoScroll_timer: -1, autoScroll: false, switchAutoScroll(enable){ if(this.autoScroll_timer){ clearInterval(this.autoScroll_timer) this.autoScroll_timer = -1 } if(this.autoScroll = enable ?? !this.autoScroll){ let auto_download = confirm('捕获结束后是否开启自动下载?(不要最小化浏览器窗口!!!)') var auto_rename = false if(auto_download) auto_rename = confirm('下载前是否应用名称更改?') this.writeLog(`开启自动滚动捕获,自动下载【${auto_download ? '开' : '关'}】`) let _max = 10, _retry = 0 const next = () => { let scrollContainer = Object.entries(this.DETAIL.scrollContainer ?? {}).find(([host, selector]) => new RegExp(host).test(location.href)) if(scrollContainer){ let container = document.querySelectorAll(scrollContainer[1])[0] if(container) container.scrollTop = container.scrollHeight }else{ unsafeWindow.scrollTo(0, document.body.scrollHeight) } let _old = this.resources.length setTimeout(() => { let _new = this.resources.length if(_old == _new){ this.writeLog(`没有捕获到视频,将会在重试${_max - _retry}次后结束`) if(_max - _retry++ <= 0){ this.writeLog('成功捕获所有的视频') this.switchAutoScroll(false) if(auto_download){ auto_rename && this.applyRenameAll() this.switchRunning(true) } return } }else{ this.writeLog(`捕获到${_new - _old}个视频,当前视频总数${_new}`) this.updateTable() } setTimeout(() => next(), 500) }, 2000) } next() }else{ this.writeLog(`开启关闭滚动捕获`) } }, setList(list, refresh = true){ this.resources = list refresh && this.refresh() }, refresh(){ this.showList() document.querySelector('#_ftb').innerHTML = createHTML(`下载 ${this.resources.length} 个视频`) }, bindVideoElement({callback, initElement}){ return 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.urls || 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 = createHTML('下载') 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, 'Range': 'bytes=0-', 'Referer': location.protocol+'//'+ location.host } }, showList(){ // 展示主界面 let threads = this.options['threads'] this.showDialog({ id: '_dialog', html: `