抖音/快手/微视/instagram/TIKTOK/小红书/微博/今日头条 主页视频下载

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

// ==UserScript==
// @name         抖音/快手/微视/instagram/TIKTOK/小红书/微博/今日头条 主页视频下载
// @namespace    shortvideo_homepage_downloader
// @version      1.2.7
// @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.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://fikfap.com/user/*
// @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
// ==/UserScript==
const $ = selector => document.querySelectorAll('#_dialog '+selector)
const ERROR = -1, WAITTING = 0, DOWNLOADING = 1, DOWNLOADED = 2
const VERSION = '1.2.7', RELEASE_DATE = '2024/11/26'
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 {
  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,
    autoRename: false,
    douyin_host: 1, // 抖音默认第二个线路
    timeout: 1000 * 60,
    retry_max: 60,
    autoScroll: true,
    aria2c_port: 6800,
    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){
      console.log({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: 'localhost',
                      port: this.options.aria2c_port,
                      secure: false,
                      secret: '',
                      path: '/jsonrpc',
                      jsonp: false
                  })
                  aria2.open().then(() => {
                      aria2.opening = true
                      this.writeLog('aria2成功连接!', 'ARIA2C')
                      $('[data-for="useAria2c"]')[0].checked = true
                  })
                  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', '下载失败'))
              }
          }
          GM_xmlhttpRequest({
              url, headers,
              redirect: 'follow', responseType: 'blob', method: "GET",
              onload: ({response, status}) => {
                  // BUG 不知为啥tiktok无法使用流保存
                  if(location.host == 'www.tiktok.com' || 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]
  },
  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
                        }
                    }
                }
            ]
        },
        'fikfap.com': {
            title: 'fikfap', id: 'fikfap',
            runAtWindowLoaded: false,
            timeout: {
                '/user/': 100
            },
            rules: [
                 {
                    url: 'https://api.fikfap.com/profile/username/(.*?)/posts?',
                    type: 'fetch',
                    parseList: json => json,
                    parseItem: data => {
                        let {author, createdAt, label, postId,thumbnailStreamUrl, videoStreamUrl} = data
                        return {
                            status: WAITTING,
                            id: postId,
                            url: 'https://fikfap.com/user/'+author.userId+'/post/'+postId,
                            cover: thumbnailStreamUrl,
                            author_name: author.username,
                            urls: videoStreamUrl,
                            title: label, create_time: createdAt,
                            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.xiaohongshu.com': {
            title: '小红书', id: 'xhs',
            getVideoURL: item => new Promise(reslove => {
                fetch(item.url).then(resp => resp.text()).then(text => {
                    console.log({text})
                    let json = JSON.parse(cutString(text, '"noteDetailMap":', ',"serverRequestInfo":'))
                    let meta = item.meta = json[item.id]
                    let {time} = meta.note
                    Object.assign(item, {create_time: time})
                    reslove(meta.note.video.media.stream.h264[0].masterUrl)
                })
            }),
            rules: [
                /*{
                    type: 'object',
                    getObject: window => window?.__INITIAL_STATE__?.note?.noteDetailMap || {},
                    parseList: json => {
                        return Object.values(json).filter(({note}) => note).map(({note}) => note)
                    },
                    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.h264[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),
                    parseList: json => {
                        let list = Array.isArray(json) ? (json.length == 4 ? json[0] : json) : []
                        return list
                    },
                    parseItem: data => {
                        let { cover, displayTitle, noteId, type, user, xsecToken} = data?.noteCard || {}
                        let id = noteId ?? data.id
                        if(type == 'video') 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 => {
                        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,
                            urls: video.play_addr.url_list.at(xl),
                            title: desc,
                            data
                        }
                    }
                },{
                    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: 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,
                            urls: 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: '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
    }

    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()
                    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()
                        fv.oncontextmenu = ev => {
                          this.setList([], false)
                          fv.remove()
                          ev.stopPropagation(true) & ev.preventDefault()
                        }
                        document.body.append(fv)
                    }
                    fv.innerHTML = createHTML(`下载 ${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}){
        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,
        }
    },

  showList(){ // 展示主界面
    let threads = this.options['threads']
    this.showDialog({
      id: '_dialog',
      html: `
      <div style="display: inline-flex;flex-wrap: wrap;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>
            <button id="_apply_filename_help">帮助</button>
          </div>
          <div>
            下载线程数:
            <input id="_threads" type="range" value=${threads} step=1 min=1 max=32>
            <span id="_threads_span">${threads}</span>
            <span style="margin-right: 10px;">Aria2下载</span><input type="checkbox" data-for="useAria2c" ${this.options.useAria2c ? 'checked': ''}>
          </div>
          <div>
            <button id="_settings">设置</button>
            <button id="_autoScroll">滚动捕获</button>
            <button id="_clearDownloads">清空已下载</button>
            <button id="_reDownloads">重新下载</button>
            <button id="_switchRunning" disabled>开始</button>
          </div>
        </div>
        <div style="height: 70%;overflow-y: scroll;">
          <table width="90%" border="2" style="margin: 0 auto;"></table>
          </div>
          <div style="height: 25%; width: 100%;border-top: 2px solid white;">
            <div style="position: relative;height: 100%;">
              <div style="position: absolute;right: 0;top: 0;padding: 10px;"><span style="margin-right: 10px;">自动滚动</span><input type="checkbox" data-for="autoScroll" ${this.options.autoScroll ? 'checked': ''}></div>
              <pre id="_log" style="background-color: rgba(255, 255, 255, .2);color: rgba(0, 0, 0, .8);overflow-y: scroll;height: 90%;"></pre>
            </div>
          </div>`,
         callback: dialog => {
             if(!this.aria2c) this.enableAria2c(this.options.useAria2c)
             this.initInputs(dialog) & this.updateTable()
             this.tryAutoRenameAll()
         },
         onClose: () => this.resources.forEach(item => item.status = WAITTING)
    }) & this.bindEvents() & [
      `欢迎使用!当前版本: ${VERSION} 发布日期: ${RELEASE_DATE}`,
      `此脚本仅供学习交流使用!!请勿用于非法用途!`
    ].forEach(msg => this.writeLog(msg, '声明')) & this.loadDownloader()
  },
  loadDownloader(){
    this.writeLog('正在加载引擎...')
    loadRes(['https://cdn.jsdelivr.net/npm/web-streams-polyfill@2.0.2/dist/ponyfill.min.js', 'https://cdn.jsdelivr.net/npm/streamsaver@2.0.3/StreamSaver.min.js', 'https://cdn.jsdelivr.net/npm/file-saver@2.0.5/dist/FileSaver.min.js'], () => {
        this.writeLog('加载下载引擎成功!')
        $('#_switchRunning')[0].disabled = false
        /*unsafeWindow.onunload = () => {
            writableStream.abort()
            writer.abort()
        }
        unsafeWindow.onbeforeunload = evt => {
            if (!done) {
                evt.returnValue = `Are you sure you want to leave?`;
            }
        }*/
    })
  },
  updateTable(){
    $('table')[0].innerHTML = createHTML(`
    <tr align="center">
      <th>编号</th>
      <th>选中</th>
      <th>封面</th>
      <th>标题</th>
      <th>状态</th>
      </tr>
      ${this.resources.map((item, index) => {
          let {urls, title, cover, url, id} = item || {}
          return `
                <tr align="center" data-id="${id}">
                    <td style="width: 50px;">${index+1}<p><a href="#" data-action="addDownload" style="color:blue">下载</a></p></td>
                    <td style="width: 50px;"><input type="checkbox" style="transform: scale(1.5);" checked></td>
                    <td style="width: 100px;"><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 style="width: 100px;">等待中...</td>
                </tr>`

      }).join('')}`)
  },
  getDialog(id){
    return document.querySelector('#'+id)
  },
  isShowing(id = '_dialog'){
    return this.getDialog(id) !== null
  },
  showDialog({html, id, callback, onClose}){ // 弹窗
    let dialog = this.getDialog(id)
    dialog && dialog.remove()

    document.body.insertAdjacentHTML('beforeEnd', createHTML(`
    <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: 20px;top: 20px;padding: 10px;background-color: rgba(255, 255, 255, .4);" class="_dialog_close">X</a>
      ${html}
    <dialog>`))
    setTimeout(() => {
      let dialog = this.getDialog(id)
      dialog.querySelector('._dialog_close').onclick = () => dialog.remove() & (onClose && onClose())
      callback && callback(dialog)
    }, 500)
  },
    applyRenameAll(){
      let format = $('#_filename')[0].value
      this.saveOptions({lastRename: format})
      for(let tr of $('table tr[data-id]')){
       this.applyRename(tr.dataset.id, tr, format)
      }
    },
    applyRename(tid, tr, format){
        tr ??= this.findElement(tid)
        if(!tr) return
        let item = this.findItem(tid)
        if(!item) return
        format ??= $('#_filename')[0].value
        if(typeof(format) != 'string' || format == '') return
        let {title, author_name, id, create_time} = Object.assign(item, {renamed: true})
        let s = format.replace('{标题}', title ?? '').replace('{id}', id).replace('{发布者}', author_name ?? '')
        if(create_time){
            s = new Date(create_time).format(s)
        }
        tr.querySelector('td[contenteditable]').innerHTML = createHTML(s)
    },
  bindEvents(){ // 绑定DOM事件
    $('#_threads')[0].oninput = function(ev){
      $('#_threads_span')[0].innerHTML = createHTML(this.value)
    }
    $('#_apply_filename')[0].onclick = () => this.applyRenameAll() & (['www.xiaohongshu.com'].includes(location.host) && alert("请注意:小红书网站上日期规则预览不会立刻生效,只有在开始下载的时候才会生效!"))
    $('#_apply_filename_help')[0].onclick = () => this.showDialog({
        id: '_dialog_rename_help',
        html: `
        <p>
          <h1>变量<h1>
          <h3>{标题} {id} {发布者} yyyy年MM月dd日_hh时mm分ss秒<h3>
        </p>
        <p>
          <h1>常见问题<h1>
          <h3>
            <pre>
            为什么没有显示入口按钮?(可能是脚本插入时机慢了,可以多滚动或者多刷新几次)
            为什么下载显示失败(常见于抖音,抖音每个视频有三个线路,但并不是每个线路都是有视频存在的。所以目前的解决是 每个线路都尝试下载一次)
            为什么捕获的数量不等于主页作品数量(目前只能捕获视频作品,而非图文作品)
            为什么只能下载一个文件?(请检查网站是否有开启允许同时下载多个文件选项)
            为什么只能捕获一页的数据/翻页不了(有些不常用的站点可能存在这些问题待修复)
            </pre>
          <h3>
        </p>
        <p>
          <h1>测试页面<h1>
          <h3>
            <pre>
            https://isee.weishi.qq.com/ws/app-pages/wspersonal/index.html?id=1538201906643006
            https://www.douyin.com/user/MS4wLjABAAAANfnAjG-xB__cCOB4hTXFBvG6yZFWNl-FkgCWvpwGN2M
            https://www.douyin.com/search/%E6%88%91%E4%BB%AC
            https://www.kuaishou.com/profile/3xqyyjytuef8nsq
            https://www.tiktok.com/@simonboyyyyyyy
            https://www.xiaohongshu.com/user/profile/60f0ecec0000000001004874
            https://www.instagram.com/rohman__oficial/
            https://weibo.com/u/2328516855?tabtype=newVideo
            https://x.com/pentarouX/media
            https://www.toutiao.com/c/user/token/MS4wLjABAAAAzCbyoWKVhqhvIgUd49i5o43v4-YcICXye1glC0Xefok/?entrance_gid=7417305773065929267&log_from=f6060c90895cc_1727227709729&tab=video
            </pre>
          <h3>
        </p>
        <p>
          <h1>使用Aria2c下载<h1>
          <h3>
            <pre>
            如何安装? 从https://wwas.lanzouj.com/b032c68ozc 密码:36yz 下载解压,双击bat文件开启
            </pre>
          <h3>
        </p>
        `,
    })
    $('#_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 = createHTML('')
    $('#_switchRunning')[0].onclick = () => this.switchRunning()
    $('#_autoScroll')[0].onclick = () => this.switchAutoScroll()
    $('#_settings')[0].onclick = () => {
      this.showDialog({
        id: '_dialog_settings',
        html: `
          <div style="display: flex;width: 100%;gap: 20px;">
            <div>
              <h3>线路设置</h3>
              ${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('')}
            </div>
            <div>
              <h3>下载设置</h3>
              <div>自动重命名<input type="checkbox" data-for="autoRename" ${this.options.autoRename ? 'checked': ''}></div>
              <div>超时时间(毫秒): <input type="number" value="${this.options.timeout}" data-for="timeout"></div>
              <div>重试次数: <input type="number" value="${this.options.retry_max}" data-for="retry_max"></div>
            </div>
            <div>
              <h3>数据设置</h3>
              <div>
                <button data-action="exportData">导出数据</button>
                <button data-action="exportUrls">导出视频链接</button>
                <button data-action="importData">导入数据</button>
              </div>
            </div>
            <div>
              <h3>Aria2c设置</h3>
              <div>
                <div>端口: <input type="number" value="${this.options.aria2c_port}" data-for="aria2c_port"></div>
                <div>保存目录: <input type="text" value="${this.options.aria2c_saveTo}" data-for="aria2c_saveTo"></div>
              </div>
            </div>
          </div>
        `,
        callback: dialog => this.initInputs(dialog),
        onClose: () => this.resources = this.resources.map(item => this.DETAIL.rules[item.rule_index].parseItem(item.data))
      })
    }
    $('#_clearDownloads')[0].onclick = () => this.clearDownloads()
    $('#_reDownloads')[0].onclick = () => this.reDownloads()
  },
    initAction(){
        const onEvent = ev => {
            let {srcElement} = ev
            let {action} = srcElement.dataset
            switch(action){
                case 'addDownload':
                    let par = getParent(srcElement, el => el?.dataset?.id)
                    if(par){
                        this.downloadItem(this.findItem(par.dataset.id), true)
                    }
                    return
                case 'exportUrls':
                    return this.addDownload({
                        url: URL.createObjectURL(new Blob([flattenArray(this.resources.map(({urls}) => Array.isArray(urls) ? urls.map(({url}) => url) : urls)).join("\r\n")])),
                        name: '导出链接.txt'
                    })
                case 'exportData':
                    // todo csv
                    if(!this.resources.length) return alert('没有任何数据')
                    return this.addDownload({
                        url: URL.createObjectURL(new Blob([JSON.stringify(this.resources)])),
                        name: '导出数据.txt'
                    })
                case 'importData':
                    return openFileDialog({
                        accept: '.txt',
                        callback: files => {
                            let reader = new FileReader()
                            reader.readAsText(files[0])
                            reader.onload = e => {
                                try {
                                    json = JSON.parse(reader.result)
                                    let cnt = json.length
                                    if(cnt){
                                        if(confirm(`发现${cnt}条数据!是否重置下载状态?`)) json = json.map(item => Object.assign(item, {status: WAITTING}))
                                        this.setList(json) & this.writeLog('成功导入数据')
                                    }
                                } catch (err) {
                                    alert(err.toString())
                                }
                            }
                        }
                    })
                default:
                    return
            }
            ev.stopPropagation(true) & ev.preventDefault()
        }
        document.body.addEventListener('click', onEvent)
  },
  initInputs(dialog){
    const self = this
    for(let select of dialog.querySelectorAll('select')) select.onchange = function(){
      self.saveOptions({[`${this.dataset.for}_host`]: this.value})
    }
    for(let input of dialog.querySelectorAll('input')) input.onchange = function(){
      let value, key = this.dataset.for
      switch(this.type){
        case 'checkbox':
        case 'switch':
          value = this.checked
          break
        default:
          value = this.value
      }
      self.saveOptions({[key]: value})
      if(key == 'useAria2c') self.enableAria2c(value)
    }
  },
  clearDownloads(){
      this.eachItems(DOWNLOADED, ({tr, item, index}) => {
          this.resources.splice(index, 1)
          tr && tr.remove()
      })
  },
  reDownloads(){
      this.cancelDownloads()
      let cnt = this.eachItems([DOWNLOADING, ERROR], ({tr, item}) => {
          if(tr){
              let td = tr.querySelectorAll('td')
              td[4].style.backgroundColor = 'unset'
              td[4].innerHTML = createHTML('等待中...')
          }
          item.status = WAITTING
      }).length
      cnt ? this.writeLog(`重新下载${cnt}个视频`) & this.switchRunning(true) : alert('没有需要重新下载的任务')
  },
  cancelDownloads(){
      Object.keys(this.downloads).forEach(id => this.removeDownload(id))
      this.writeLog(`成功取消所有下载`)
  },
  eachItems(status_id, callback){
       let ret = []
       status_id = toArr(status_id)
       for(let i=this.resources.length-1;i>=0;i--){
          let item = this.resources[i]
          ret.push(item)
          let {status, id} = item
          if(status_id.includes(status)){
              let tr = this.findElement(id)
              callback({tr, item, index: i})
          }
      }
      return ret
  },
  checkFinishTimer: -1,
  switchRunning(running){ // 切换运行状态
    this.running = running ??= !this.running
    $('#_switchRunning')[0].innerHTML = createHTML(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)
  },
  getDownloadName(id){
       let tr = this.findElement(id)
       if(tr){
           let td = tr.querySelectorAll('td')
           return td[3].outerText
       }
      return null
  },
  downloadItem(item, checked){
      let {status, id, urls, rule_index, downloadTool} = item
        if(status == WAITTING){
          let tr = this.findElement(id)
          if(!tr) return

          let td = tr.querySelectorAll('td')
          checked ??= td[1].querySelector('input[type=checkbox]').checked
          if(checked){
              item.status = DOWNLOADING
              const log = ({msg, color, next = true, status}) => {
                this.writeLog(msg, `<a href="${item.url}" target="_blank" style="color: white;">${this.safeFileName(item.title)}</a>`, color)
                status ??= {success: DOWNLOADED, error: ERROR}[color]
                this.setItemStatus({id, color, msg, el: tr, item, status})
                if(next) this.nextDownload()
              }
              log({msg: '正在下载', color: 'primary', next: false})

              // 预先下载并尝试重试(多线程下需要重试才能正常下载)
              let retry = 0
              const httpRequest = url => {
                  toArr(url).forEach(download => {
                      if(typeof(download) == 'string') download = {url: download, type: 'video', title: item.title}
                      var {url} = download
                      const done = (url, headers) => this.addDownload({
                          download, url, id, headers, downloadTool,
                          error: msg => log({msg, color: 'error'}),
                          success: msg => log({msg, color: 'success'}),
                      })
                      return done(url)
                      /*
                      if(this.aria2c){
                          done(url)
                      }else{
                          GM_xmlhttpRequest({
                              method: "GET", url, headers: this.getHeaders(url),
                              redirect: 'follow',
                              //responseType: "blob",
                              timeout: this.options.timeout,
                              anonymous: true,
                              onload: ({status, response, finalUrl}) => {
                                  console.log({status, finalUrl, response})
                                  if (status === 200) {
                                      if(!response){
                                          if(!finalUrl) return log({msg: `请求错误`, color: 'error'})
                                          done(finalUrl)
                                      }else{
                                          done(blobUrl)
                                      }
                                  }else
                                      if(retry++ < this.options.retry_max){
                                          // console.log('下载失败,重试中...', urls)
                                          setTimeout(() => httpRequest(), 500)
                                      }else{
                                          log({msg: `重试下载错误`, color: 'error'})
                                      }
                              },
                              onerror: err => console.error({msg: '获取链接失败', err}) & done(url)
                          })
                      }*/
                  })
              }
              if(!urls){
                  let getVideoURL = this.DETAIL[rule_index]?.getVideoURL || this.DETAIL.getVideoURL
                  if(!getVideoURL) return log({msg: `无下载地址`, color: 'error'})
                  getVideoURL(item).then(url => {
                      if(item.renamed){ // 获取详细信息后再改变名称
                          delete item.renamed
                          this.applyRename(item.id)
                      }
                      httpRequest(Object.assign(item, {url}))
                  })
              }else{
                  httpRequest(urls)
              }
              return true
          }
        }
  },
  nextDownload(){ // 进行下一次下载
      if(!this.running) return
      let {resources} = this
      if(!resources.some(item => this.downloadItem(item))){
        if(this.running){
          clearInterval(this.checkFinishTimer)
          this.checkFinishTimer = setInterval(() => {
              if(this.getItems(WAITTING).length == 0 && this.getItems(DOWNLOADING).length == 0){
                  clearInterval(this.checkFinishTimer)
                  this.switchRunning(false)
                  let msg = '所有任务下载完成!'
                  this.writeLog(msg) & alert(msg)
              }
          }, 1000)
        }
      }
  },
  findElement: id => $(`tr[data-id="${id}"]`)[0],  // 根据Id查找dom
  writeLog(msg, prefix = '提示', color = 'info'){ // 输出日志
    let div = $('#_log')[0]
    div.insertAdjacentHTML('beforeEnd', createHTML(`<p style="color: ${this.getColor(color)}">【${prefix}】 ${msg}</p>`))
    if(this.options.autoScroll) div.scrollTop = div.scrollHeight
  },
  getColor: color => ({success: '#8bc34a', error: '#a31545', info: '#fff', primary: '#3fa9fa' })[color] || color,
  setItemStatus({id, color, msg, el, item, status}){
      item ??= this.findItem(id)
      if(!item) return
      if(status !== undefined) item.status = status
      if(el === false) return
      el ??= this.findElement(id)
      let td = el.querySelectorAll('td')
      if(td[4]){
          td[4].style.backgroundColor = this.getColor(color)
          td[4].innerHTML = createHTML(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('/', '/')
}
_downloader.init()

function Base64() {
    // private property
        _keyStr = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
    // public method for encoding
    this.encode = function (input) {
    var output = "";
    var chr1, chr2, chr3, enc1, enc2, enc3, enc4;
    var i = 0;
            input = _utf8_encode(input);
    while (i < input.length) {
                chr1 = input.charCodeAt(i++);
                chr2 = input.charCodeAt(i++);
                chr3 = input.charCodeAt(i++);
                enc1 = chr1 >> 2;
                enc2 = ((chr1 & 3) << 4) | (chr2 >> 4);
                enc3 = ((chr2 & 15) << 2) | (chr3 >> 6);
                enc4 = chr3 & 63;
    if (isNaN(chr2)) {
                    enc3 = enc4 = 64;
                } else if (isNaN(chr3)) {
                    enc4 = 64;
                }
                output = output +
                _keyStr.charAt(enc1) + _keyStr.charAt(enc2) +
                _keyStr.charAt(enc3) + _keyStr.charAt(enc4);
            }
    return output;
        }
    // public method for decoding
    this.decode = function (input) {
    var output = "";
    var chr1, chr2, chr3;
    var enc1, enc2, enc3, enc4;
    var i = 0;
            input = input.replace(/[^A-Za-z0-9\+\/\=]/g, "");
    while (i < input.length) {
                enc1 = _keyStr.indexOf(input.charAt(i++));
                enc2 = _keyStr.indexOf(input.charAt(i++));
                enc3 = _keyStr.indexOf(input.charAt(i++));
            enc4 = _keyStr.indexOf(input.charAt(i++));
            chr1 = (enc1 << 2) | (enc2 >> 4);
            chr2 = ((enc2 & 15) << 4) | (enc3 >> 2);
            chr3 = ((enc3 & 3) << 6) | enc4;
            output = output + String.fromCharCode(chr1);
if (enc3 != 64) {
                output = output + String.fromCharCode(chr2);
            }
if (enc4 != 64) {
                output = output + String.fromCharCode(chr3);
            }
        }
        output = _utf8_decode(output);
return output;
    }
// private method for UTF-8 encoding
    _utf8_encode = function (string) {
        string = string.replace(/\r\n/g,"\n");
var utftext = "";
for (var n = 0; n < string.length; n++) {
var c = string.charCodeAt(n);
if (c < 128) {
                utftext += String.fromCharCode(c);
            } else if((c > 127) && (c < 2048)) {
                utftext += String.fromCharCode((c >> 6) | 192);
                utftext += String.fromCharCode((c & 63) | 128);
            } else {
                utftext += String.fromCharCode((c >> 12) | 224);
                utftext += String.fromCharCode(((c >> 6) & 63) | 128);
                utftext += String.fromCharCode((c & 63) | 128);
            }
        }
return utftext;
    }
    // private method for UTF-8 decoding
_utf8_decode = function (utftext) {
    var string = "";
    var i = 0;
    var c = c1 = c2 = 0;
    while ( i < utftext.length ) {
                c = utftext.charCodeAt(i);
        if (c < 128) {
                string += String.fromCharCode(c);
                i++;
            } else if((c > 191) && (c < 224)) {
                c2 = utftext.charCodeAt(i+1);
                string += String.fromCharCode(((c & 31) << 6) | (c2 & 63));
                i += 2;
            } else {
                c2 = utftext.charCodeAt(i+1);
                c3 = utftext.charCodeAt(i+2);
                string += String.fromCharCode(((c & 15) << 12) | ((c2 & 63) << 6) | (c3 & 63));
                i += 3;
            }
        }
            return string;
    }
}