// ==UserScript== // @name 抖音/快手/微视/instagram/TIKTOK/小红书 主页视频下载 // @namespace shortvideo_homepage_downloader // @version 0.0.8 // @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.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 // @license MIT // @downloadURL none // ==/UserScript== const $ = selector => document.querySelectorAll('#_dialog '+selector) const DOWNLOADED = 2 const DOWNLOADING = 1 const WAITTING = 0 const ERROR = -1 const RETRY_MAX = 7 const VERSION = '0.0.8' const RELEASE_DATE = '2024/07/27' const DEBUG = (...args) => console.log.apply(this, args) const cutString1 = (str, key1, key2) => { var m = str.match(new RegExp(key1 + '(.*?)' + key2)); return m ? m[1] : ''; } // 样式 GM_addStyle(` ._dialog { 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', url: 'https://edith.xiaohongshu.com/api/sns/web/v1/user_posted', type: 'network', parseList: json => json?.data?.notes, getVideoURL: item => new Promise(reslove => { fetch(item.url).then(resp => resp.text()).then(text => { let json = JSON.parse(cutString1(text, '"noteDetailMap":', ',"serverRequestInfo":')) let meta = item.meta = json[item.id] reslove(meta.note.video.media.stream.h264[0].masterUrl) }) }), parseItem: data => { let { cover, display_title, note_id, type, user, xsec_token } = data if(type == 'video') return { status: WAITTING, author_name: user.nickname, id: note_id, url: 'https://www.xiaohongshu.com/explore/'+note_id+'?xsec_token='+xsec_token+'=&xsec_source=pc_user', cover: cover.url_default, title: display_title.replaceAll('🥹', ''), data } } }, '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(item => parseItem(item)).filter(item => item)) 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(){ // 展示主界面 console.log(this.resources) let threads = this.options['threads'] this.showDialog({ id: '_dialog', html: `
编号 | 选中 | 封面 | 标题 | 状态 |
---|---|---|---|---|
${index+1} | ${title} | 等待中... |