Greasy Fork

Greasy Fork is available in English.

抖音/快手/微视/instagram/TIKTOK/小红书 主页视频下载

在抖音/快手/微视/instagram/TIKTOK/小红书 主页右小角显示视频下载按钮

当前为 2024-08-01 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         抖音/快手/微视/instagram/TIKTOK/小红书 主页视频下载
// @namespace    shortvideo_homepage_downloader
// @version      1.0.1
// @description  在抖音/快手/微视/instagram/TIKTOK/小红书 主页右小角显示视频下载按钮
// @author       hunmer
// @match        https://www.douyin.com/user/*
// @match        https://www.douyin.com/search/*
// @match        https://www.douyin.com/video/*
// @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
// @run-at       document-end
// @license      MIT
// ==/UserScript==

const $ = selector => document.querySelectorAll('#_dialog '+selector)
const ERROR = -1, WAITTING = 0, DOWNLOADING = 1, DOWNLOADED = 2
const RETRY_MAX = 7
const VERSION = '1.0.1', RELEASE_DATE = '2024/08/01'
const DEBUG = (...args) => console.log.apply(this, args)
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
}

// 样式
GM_addStyle(`
  ._dialog {

table tr td,
table tr th {
  vertical-align: middle;
}

    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',
            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://edith.xiaohongshu.com/api/sns/web/v1/user_posted',
                    type: 'network',
                    parseList: json => json?.data?.notes,
                    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
                        }
                    }
                },*/
                {
                    type: 'object',
                    getObject: window => window.__INITIAL_STATE__.user.notes._rawValue,
                    parseList: json => json?.[0],
                    parseItem: data => {
                        let { cover, displayTitle, noteId, type, user, xsecToken } = data?.noteCard
                        if(type == 'video') return {
                            status: WAITTING, author_name: user.nickname, id: noteId, url: 'https://www.xiaohongshu.com/explore/'+noteId+'?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, 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',
            rules: [
                {
                    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',
            hosts: [0, 1, 2], // 3个线路
            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}
                }
            },
            rules: [
                {
                    url: 'https://www.douyin.com/aweme/v1/web/aweme/post/',
                    type: 'network',
                    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[0],
                            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
                        }
                    }
                }, {
                    url: 'https://www.douyin.com/aweme/v1/web/general/search/single/',
                    type: 'network',
                    parseList: json => json?.data,
                    parseItem: data => {
                        let {video, desc, author, aweme_id} = data.aweme_info || {}
                        let xl = this.options.douyin_host
                        if(video) 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(xl),
                            title: desc,
                            data
                        }
                    }
                },{
                    url: 'https://www.douyin.com/aweme/v1/web/aweme/detail/',
                    type: 'network',
                    parseList: json => [json.aweme_detail],
                    parseItem: data => {
                        let {video, desc, author, aweme_id} = data
                        let cover = video?.cover?.url_list
                        if(cover) return {
                            status: WAITTING,
                            id: aweme_id,
                            url: 'https://www.douyin.com/video/'+aweme_id,
                            cover: cover[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',
            rules: [
                {
                    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',
            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,
                            video_url: video_versions[0].url,
                            title: caption.text,
                            data
                        }
                    }
                }
            ]
        }
    }
    let DETAIL = this.DETAIL = this.HOSTS[location.host]
    if(!DETAIL) return
    console.log(DETAIL)

    var parse = JSON.parse, originalSend = XMLHttpRequest.prototype.send, originalFetch = window.fetch
    var resources = this.resources, object_callbacks = []
    const hook = () => {
        let json_callbacks = [], network_callbacks = []
        DETAIL.rules.forEach(({type, parseList, parseItem, url, getObject}, rule_index) => {
            const callback = json => {
                // console.log(json)
                let cnt = resources.push(...(parseList(json) || []).map(item => Object.assign(parseItem(item) || {}, {rule_index})).filter(item => item.id && !resources.find(({id}) => id == item.id)))
                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} 个视频`
            }
            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})
            }
        })
        if(json_callbacks.length){
            JSON.parse = function(raw) {
                let json = parse(raw)
                json_callbacks.forEach(cb => cb(json))
                return json;
            }
        }
        if(network_callbacks.length){
            const cb = ({fullURL, raw}) => {
                network_callbacks.forEach(({url, callback}) => {
                    // console.log({fullURL, url})
                    if(fullURL.startsWith(url)) callback(JSON.parse(raw))
                })
            }
            XMLHttpRequest.prototype.send = function() {
                this.addEventListener('load', function() {
                    if(['', 'text'].includes(this.responseType)) cb({fullURL: this.responseURL, raw: this.responseText})
                })
                originalSend.apply(this, arguments)
            }
            unsafeWindow.fetch = function() {
                return originalFetch.apply(this, arguments).then(response => {
                    if (response.status == 200) {
                        response.clone().text().then(raw => {
                           cb({fullURL: response.url, raw})
                        })
                    }
                    return response
                })
            }
        }
        if(object_callbacks.length){
            object_callbacks.forEach(cb => cb())
        }
    }
    hook() & setInterval(() => hook(), 250)
    window.onload = () => DETAIL.bindVideoElement && this.bindVideoElement(DETAIL.bindVideoElement)
  },

    bindVideoElement({callback, initElement}){
        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.video_url || 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 = '下载'
                            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,
        }
    },

  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 loading="lazy" 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')[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, rule_index} = item
        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 retry = 0
              const httpRequest = url => GM_xmlhttpRequest({
                method: "GET", url, headers: this.getHeaders(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:  this.getHeaders(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){
                  let getVideoURL = this.DETAIL[rule_index]?.getVideoURL || this.DETAIL.getVideoURL
                if(!getVideoURL) return log(`无下载地址`, 'error')
                 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[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()