抖音/快手主页视频下载

在抖音/快手主页右小角显示视频下载按钮

目前为 2024-07-25 提交的版本。查看 最新版本

// ==UserScript==
// @name         抖音/快手主页视频下载
// @namespace    shortvideo_homepage_downloader
// @version      0.0.6
// @description  在抖音/快手主页右小角显示视频下载按钮
// @author       hunmer
// @match        https://www.douyin.com/user/*
// @match        https://www.kuaishou.com/profile/*
// @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_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.6'
const RELEASE_DATE = '2024/07/26'

// 样式
GM_addStyle(`
  body:has(dialog[open]) {
    overflow: hidden;
  }
`);

({
  resources: [], running: false,
  options: GM_getValue('config', {
    threads: 2,
    douyin_host: 0, // 抖音默认第一个线路
  }),
  saveOptions(opts){
    GM_setValue('config', Object.assign(this.options, opts))
  },
  init(){ // 初始化
    this.HOSTS = { // 网站规则
        '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
                }
            }
        },
    }
    let DETAIL = this.DETAIL = this.HOSTS[location.host]
    if(!DETAIL) return

    let callback = (...args) => this.callback.apply(this, args)
     switch(DETAIL.type){
        case 'json':
            let parse = JSON.parse;
            JSON.parse = function(raw) {
                let json = parse(raw)
                callback(json)
                return json;
            }
            return
        case 'network':
            let originalSend = XMLHttpRequest.prototype.send, resources = []
             XMLHttpRequest.prototype.send = function() {
                 this.addEventListener('load', function() {
                     if (this.responseURL.startsWith(DETAIL.url)) {
                         callback(JSON.parse(this.responseText))
                     }
                 });
                 originalSend.apply(this, arguments);
             };
            let originalFetch = window.fetch;
            window.fetch = function() {
                return originalFetch.apply(this, arguments).then(response => {
                    if (response.url.startsWith(DETAIL.url)) {
                        response.clone().json().then(json => callback(json));
                    }
                    return response;
                });
            }
            return
    }
  },
   callback(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 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",
                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 == 200){
                            log(`下载完成...`, 'success')
                          }else{
                            log(`下载失败`, 'error')
                          }
                        },
                        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('(', '(').replaceAll(')', ')').replaceAll(':', ':').replaceAll('*', '*').replaceAll('?', '?').replaceAll('"', '"').replaceAll('<', '<').replaceAll('>', '>').replaceAll("|", "|").replaceAll('\\', '\').replaceAll('/', '/')
}).init()

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址