// ==UserScript==
// @name 抖音/快手/微视/instagram/TIKTOK/小红书/微博/今日头条 主页视频下载
// @namespace shortvideo_homepage_downloader
// @version 1.2.5
// @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.tiktok.com/@*
// @match https://www.tiktok.com/search*
// @match https://fikfap.com/user/*
// @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.5', RELEASE_DATE = '2024/11/01'
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} = opts
if(download){ // 命名规则
let {ext, type, title} = download
name = this.safeFileName(this.getDownloadName(id) ?? title) + '.' + (ext || getExtName(type))
}
const callback = (status, msg) => {
let cb = opts[status]
cb && cb(msg)
this.removeDownload(_id)
}
var abort, timer
var headers = this.getHeaders(url)
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) {
console.log({readableStream, fileStream})
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} = 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: media_info.video_title, 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 => {
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})
console.log(item)
reslove(meta.note.video.media.stream.h264[0].masterUrl)
})
}),
rules: [
{
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,
// +'?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
}
}
}
]
}
}
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([this.resources.map(({urls}) => Array.isArray(urls) ? urls[0] : 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} = 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,
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()