neysummer 发表于 2024-7-25 17:29

【油猴脚本】抖音/快手/微视/instagram/TIKTOK/XHS/微博 主页视频下载

本帖最后由 neysummer 于 2024-8-5 17:37 编辑

最近有批量下载主页的需求,但好像类似的软件好像都失效了?
于是想到可以编写个油猴脚本来HOOK数据,虽然没什么技术含量但是简单粗暴,足够轻量且不易失效。

使用方法: 进入抖音/快手/微视/instagram/TIKTOK/XHS 主页视频下载主页,滚动,页面右下角会显示下载按钮。点击之后会逐个下载视频到浏览器保存目录
edge需要打开 edge://extensions,然后勾选 开发人员模式
其他站点或者增加更多自定义下载选项看大家的需求度吧...






安装地址: https://greasyfork.org/zh-CN/scr ... 1%E4%B8%8B%E8%BD%BD

脚本代码:

// ==UserScript==
// @name         抖音/快手/微视/instagram/TIKTOK/小红书 主页视频下载
// @namespace    shortvideo_homepage_downloader
// @version      0.0.9
// @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.co ... /public/favicon.ico
// @grant      GM_download
// @grant      GM_addStyle
// @grant      GM_setValue
// @grant      GM_getValue
// @grant      unsafeWindow
// @grant      GM_xmlhttpRequest
// @license      MIT
// @downloadURL https://update.greasyfork.org/sc ... 8B%E8%BD%BD.user.js
// @updateURL https://update.greasyfork.org/sc ... 8B%E8%BD%BD.meta.js
// ==/UserScript==

const $ = selector => document.querySelectorAll('#_dialog '+selector)
const ERROR = -1, WAITTING = 0, DOWNLOADING = 1, DOWNLOADED = 2
const RETRY_MAX = 7
const VERSION = '0.0.9', 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 : '';
}

// 样式
GM_addStyle(`
._dialog {
    input, button {
      color: white !important;
      background-color: unset !important;
    }
    input {
         width: 20px;
         height: 20px;
         transform: scale(1.5);
         -webkit-appearance: checkbox;
    }
}
body:has(dialog) {
    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
                reslove(meta.note.video.media.stream.h264.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.representation.url,
                  title: photo.originCaption,
                  data
                }
            }
      },
      'www.douyin.com': {
            title: '抖音', id: 'douyin',
            url: 'https://www.douyin.com/aweme/v1/web/aweme/post/',
            type: 'network',
            hosts: , // 3个线路
            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,
                  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
                }
            }
      },
         '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.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.url,
                  author_name: owner.username,
                  video_url: video_versions.url,
                  title: caption.text,
                  data
                }
            }
      }
    }
    let DETAIL = this.DETAIL = this.HOSTS
    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;
            });
          }
          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: `
      <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 type="text" 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=32>
            <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 `
                <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() & [
      `欢迎使用!当前版本: ${VERSION} 发布日期: ${RELEASE_DATE}`,
      `此脚本仅供学习交流使用!!请勿用于非法用途!`,
      `
    --------------------------------------------------------------------------------------
    常见问题:

    为什么没有显示入口按钮?
    可能是脚本插入时机慢了,可以多滚动或者多刷新几次

    为什么下载显示失败
    常见于抖音,抖音每个视频有三个线路,但并不是每个线路都是有视频存在的。所以目前的解决是 每个线路都尝试下载一次

    为什么捕获的数量不等于主页作品数量
    目前只能捕获视频作品,而非图文作品。

    为什么只能捕获一页的数据/翻页不了
    有些不常用的站点可能存在这些问题待修复

    计划列表:导出/导入数据,自动选择适合的线路,区间选择,右键菜单选择,下载链接可视化,发送aria2下载
    --------------------------------------------------------------------------------------
    `
    ].forEach(msg => this.writeLog(msg))
},
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').oninput = function(ev){
      $('#_threads_span').innerHTML = this.value
    }
    $('#_apply_filename').onclick = () => {
      for(let tr of $('table tr')){
      let item = this.findItem(tr.dataset.id)
      if(!item) return
      let {title, author_name, id} = item
      tr.querySelector('td').innerHTML = $('#_filename').value.replace('{标题}', title).replace('{id}', id).replace('{发布者}', author_name)
      }
    }
    $('#_selectAll').onclick = () => $('table input').forEach(el => el.checked = true)
    $('#_reverSelectAll').onclick = () => $('table input').forEach(el => el.checked = !el.checked)
    $('#_clear_log').onclick = () => $('#_log').innerHTML = ''
    $('#_switchRunning').onclick = () => this.switchRunning()
    $('#_settings').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 == 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').onclick = () => {
      if(this.running) return alert('请先暂停任务')
      for(let i=this.resources.length-1;i>=0;i--){
      let item = this.resources
      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.style.backgroundColor = 'unset'
          td.innerHTML = '等待中...'
      }
      item.status = WAITTING
      }
    }
},
switchRunning(running){ // 切换运行状态
    this.running = running ??= !this.running
    $('#_switchRunning').innerHTML = running ? '暂停' : '运行'
    if(running){
      let threads = parseInt($('#_threads').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(status == WAITTING){
          let tr = this.findElement(id)
          if(!tr) return

          let td = tr.querySelectorAll('td')
          let checked = td.querySelector('input').checked
          let title = td.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)
                if(next) this.nextDownload()
                item.status = color == 'success' ? DOWNLOADED : ERROR
            }
            // 预先下载并尝试重试(多线程下需要重试才能正常下载)
            let retry = 0, headers = 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,
                  }
            }
            const httpRequest = url => GM_xmlhttpRequest({
                method: "GET", url, headers: headers(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:headers(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){
                if(!this.DETAIL.getVideoURL) return log(`无下载地址`, 'error')
               this.DETAIL.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`)
},
writeLog(msg, prefix = '提示', color = 'info', el){ // 输出日志
    color = {success: '#8bc34a', error: '#a31545', info: '#fff' }
    $('#_log').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(_item => _item.id == id)
},
safeFileName: str => str.replaceAll('\n', ' ').replaceAll('(', '(').replaceAll(')', ')').replaceAll(':', ':').replaceAll('*', '*').replaceAll('?', '?').replaceAll('"', '"').replaceAll('<', '<').replaceAll('>', '>').replaceAll("|", "|").replaceAll('\\', '\').replaceAll('/', '/')
}).init()


neysummer 发表于 2024-8-5 17:36

1.0.4 更新微博作者主页视频下载支持,
大家有什么好的短视频平台可以提出来,看能不能整合一起

youzisky0701 发表于 2024-7-25 17:45

Edge浏览器,安装脚本后也没有下载按钮啊?

kangta520 发表于 2024-7-25 17:50

本帖最后由 kangta520 于 2024-7-25 18:04 编辑

牛啊,学习了,谢谢分享

楼主,谷歌浏览器上出问题,点击下载,没有内容,提示下载完成,edge浏览器没有问题,不知道其他吾友有没有这样问题

neysummer 发表于 2024-9-27 08:56

ebbinghaus 发表于 2024-9-26 18:20
之前有下过,不知道怎么用又删了,现在连下载链接都不知道在哪了,麻烦给个链接,指导一下怎么用,好人一 ...

从https://wwas.lanzouj.com/b032c68ozc 密码:36yz 下载解压,双击bat文件开启,然后在脚本勾选启用aria2

neysummer 发表于 2024-7-25 17:46

gscwhs4 发表于 2024-7-25 17:36
楼主可以弄个下载视频号的东西吗

批量下载视频号的吗?视频号只能在微信客户端打开,应该不好实现。单个下载可以使用res downloader

neysummer 发表于 2024-9-25 09:29

ayus 发表于 2024-9-24 20:46
要是支持头条就更好啦

新版支持了头条主页视频批量下载了

ffjsdde 发表于 2024-7-25 17:34

感谢分享

a171039745 发表于 2024-7-25 17:35

谢谢666

gscwhs4 发表于 2024-7-25 17:36

楼主可以弄个下载视频号的东西吗

neysummer 发表于 2024-7-25 17:53

youzisky0701 发表于 2024-7-25 17:45
Edge浏览器,安装脚本后也没有下载按钮啊?

edge需要打开 edge://extensions,然后勾选 开发人员模式

youzisky0701 发表于 2024-7-25 18:00

neysummer 发表于 2024-7-25 17:53
edge需要打开 edge://extensions,然后勾选 开发人员模式

开发人员模式一直开着呢,不知道什么原因?

xiaoYang250 发表于 2024-7-25 18:01

谢谢大佬666
页: [1] 2 3 4 5 6 7 8 9 10
查看完整版本: 【油猴脚本】抖音/快手/微视/instagram/TIKTOK/XHS/微博 主页视频下载