- // ==UserScript==
- // @name 抖音/快手/微视/instagram/TIKTOK/小红书/微博/今日头条 主页视频下载
- // @namespace shortvideo_homepage_downloader
- // @version 1.3.4
- // @description 在抖音/快手/微视/instagram/TIKTOK/小红书/微博/今日头条 主页右小角显示视频下载按钮
- // @author hunmer
- // @match https://pixabay.com/videos/search/*
- // @match https://www.xinpianchang.com/discover/*
- // @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://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.3.4', RELEASE_DATE = '2025/03/31'
- 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 {
- input[type=checkbox] {
- -webkit-appearance: auto !important;
- }
- 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,
- firstRun: true,
- autoRename: false,
- alert_done: true,
- show_img_limit: 500,
- 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){
- 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', '下载失败'))
- }
- }
- let isTiktok = location.host == 'www.tiktok.com'
- if(isTiktok) headers.Referer = url
- GM_xmlhttpRequest({
- url, headers,
- redirect: 'follow', responseType: 'blob', method: "GET",
- onload: ({response, status}) => {
- console.log({response, status})
- // BUG 不知为啥tiktok无法使用流保存
- if(isTiktok || 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]
- },
- setBadge(html){
- 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;padding: 5px;`
- fv.onclick = () => this.showList()
- fv.oncontextmenu = ev => {
- this.setList([], false)
- fv.remove()
- ev.stopPropagation(true) & ev.preventDefault()
- }
- document.body.append(fv)
- }
- fv.innerHTML = createHTML(html)
- },
- 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
- }
- }
- }
- ]
- },
- 'pixabay.com': {
- title: 'pixabay', id: 'pixabay',
- rules: [
- {
- type: 'object',
- getObject: window => window?.__BOOTSTRAP__?.page?.results,
- parseList: json => json,
- parseItem: data => {
- let {id, description, href , user, uploadDate, name, sources} = data
- return {
- status: WAITTING,id, url: 'https://pixabay.com'+href, cover: sources.thumbnail,
- author_name: user.username,
- urls: sources.mp4.replace('_tiny', ''),
- title: `【${name}】${description}`, create_time: uploadDate,
- 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.xinpianchang.com': {
- title: '新片场', id: 'xinpianchang',
- runAtWindowLoaded: false,
- getVideoURL: item => new Promise(reslove => {
- fetch(`https://mod-api.xinpianchang.com/mod/api/v2/media/${item.media_id}?appKey=61a2f329348b3bf77&extend=userInfo%2CuserStatus`).then(resp => resp.json()).then(json => {
- reslove(json.data.resource.progressive.find(({url}) => url != '').url)
- })
- }),
- rules: [
- {
- url: 'https://www.xinpianchang.com/_next/data/',
- type: 'json',
- parseList: json => {
- return flattenArray((json?.pageProps?.squareData?.section || []).map(({articles}) => articles || []))
- },
- parseItem: data => {
- let {author, content, cover, media_id, title, web_url, publish_time, id} = data
- return {
- status: WAITTING,id, url: web_url, cover, title, media_id,
- author_name: author.userinfo.username,
- create_time: publish_time,
- data
- }
- }
- }
- ]
- },
- '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 note = json[item.id].note
- Object.assign(item, {create_time: note.time, meta: note})
- console.log(note)
- reslove(note.type == 'video' ? {url: note.video.media.stream.h265[0].masterUrl, type: 'video'} : note.imageList.map(({urlDefault}) => {
- return {url: urlDefault, type: 'photo'}
- }))
- })
- }),
- rules: [
- {
- type: 'object',
- getObject: window => location.href.startsWith('https://www.xiaohongshu.com/explore/') ? window?.__INITIAL_STATE__?.note?.noteDetailMap : {},
- parseList: json => {
- let list = Object.values(json).filter(({note}) => note).map(({note}) => note)
- return list
- },
- 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.h265[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, window?.__INITIAL_STATE__?.feed.feeds?._rawValue),
- parseList: json => {
- let list = []
- Array.isArray(json) && json.forEach(items => {
- if(Array.isArray(items)) {
- items.forEach(item => {
- if(item.noteCard) list.push(item)
- })
- }else
- if(items?.noteCard){
- list.push(items)
- }
- })
- return list
- },
- parseItem: data => {
- let { cover, displayTitle, noteId, type, user, xsecToken} = data?.noteCard || {}
- let id = noteId ?? data.id
- xsecToken ??= data.xsecToken ??= ''
- // if(xsecToken) {
- 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 => parseDouyinList(data.aweme_info)
- },{
- 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: parseDouyinList
- },
- ]
- },
- '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
- if(this.options.firstRun){
- this.options.firstRun = false
- this.saveOptions()
- alert("欢迎使用此视频批量下载脚本,以下是常见问题:\n【1】.Q:为什么没有显示下载入口?A:当前网址不支持\n【2】Q:为什么捕获不到视频?A:试着滚动视频列表,让他进行加载\n【3】Q:为什么抖音主页显示用户未找到?A:多刷新几次【4】Q:提示下载失败怎么办?A:可以批量导出链接用第三方软件进行下载(如IDM)")
- }
- this.setBadge("等待滚动捕获数据中...")
- }
-
- 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()
- this.setBadge(`下载 ${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}){
- return
- 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,
- 'Range': 'bytes=0-',
- 'Referer': location.protocol+'//'+ location.host
- }
- },
-
- 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(){
- let cnt = this.resources.length
- console.log(cnt, this.options.show_img_limit)
- $('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" style="color: #fff;">${this.options.show_img_limit > 0 && cnt >= this.options.show_img_limit ? `不显示预览图` : `<a href="${url}" target="_blank"><img loading="lazy" src="${cover}" style="width: 100px;min-height: 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="alert_done" ${this.options.alert_done ? 'checked': ''}></div>
- <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>封面超过不显示: <input type="number" value="${this.options.show_img_limit}" data-for="show_img_limit"></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(urls => {
- if(item.renamed){ // 获取详细信息后再改变名称
- delete item.renamed
- this.applyRename(item.id)
- }
- httpRequest(Object.assign(item, {urls}).urls)
- })
- }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) & (this.options.alert_done && 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;
- }
- }
-