[JavaScript] 纯文本查看 复制代码
// ==UserScript==
// @name 抖音/快手/微视/instagram/TIKTOK/小红书 主页视频下载
// @namespace shortvideo_homepage_downloader
// @version 0.0.9
// @description 在抖音/快手/微视/instagram/TIKTOK/小红书 主页右小角显示视频下载按钮
// @author hunmer
// @match [url=https://www.douyin.com/user/]https://www.douyin.com/user/[/url]*
// @match [url=https://www.kuaishou.com/profile/]https://www.kuaishou.com/profile/[/url]*
// @match [url=https://isee.weishi.qq.com/ws/app-pages/wspersonal/index.html]https://isee.weishi.qq.com/ws/app-pages/wspersonal/index.html[/url]*
// @match [url=https://www.instagram.com/]https://www.instagram.com/[/url]*/
// @match [url=https://www.xiaohongshu.com/user/profile/]https://www.xiaohongshu.com/user/profile/[/url]*
// @match [url=https://www.tiktok.com/@]https://www.tiktok.com/@[/url]*
// @icon [url=https://lf1-cdn-tos.bytegoofy.com/goofy/ies/douyin_web/public/favicon.ico]https://lf1-cdn-tos.bytegoofy.co ... /public/favicon.ico[/url]
// @grant GM_download
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// @grant unsafeWindow
// @grant GM_xmlhttpRequest
// @license MIT
// @downloadURL [url=https://update.greasyfork.org/scripts/501746/%E6%8A%96%E9%9F%B3%E5%BF%AB%E6%89%8B%E5%BE%AE%E8%A7%86instagramTIKTOK%E5%B0%8F%E7%BA%A2%E4%B9%A6%20%E4%B8%BB%E9%A1%B5%E8%A7%86%E9%A2%91%E4%B8%8B%E8%BD%BD.user.js]https://update.greasyfork.org/sc ... 8B%E8%BD%BD.user.js[/url]
// @updateURL [url=https://update.greasyfork.org/scripts/501746/%E6%8A%96%E9%9F%B3%E5%BF%AB%E6%89%8B%E5%BE%AE%E8%A7%86instagramTIKTOK%E5%B0%8F%E7%BA%A2%E4%B9%A6%20%E4%B8%BB%E9%A1%B5%E8%A7%86%E9%A2%91%E4%B8%8B%E8%BD%BD.meta.js]https://update.greasyfork.org/sc ... 8B%E8%BD%BD.meta.js[/url]
// ==/UserScript==
const $ = selector => document.querySelectorAll('#_dialog '+selector)
const ERROR = -1, WAITTING = 0, DOWNLOADING = 1, DOWNLOADED = 2
const RETRY_MAX = 7
const VERSION = '0.0.9', RELEASE_DATE = '2024/07/27'
const DEBUG = (...args) => console.log.apply(this, args)
const cutString1 = (str, key1, key2) => {
var m = str.match(new RegExp(key1 + '(.*?)' + key2));
return m ? m[1] : '';
}
// 样式
GM_addStyle(`
._dialog {
input[type=text], button {
color: white !important;
background-color: unset !important;
}
input[type=checkbox] {
width: 20px;
height: 20px;
transform: scale(1.5);
-webkit-appearance: checkbox;
}
}
body:has(dialog[open]) {
overflow: hidden;
}
`);
({
resources: [], running: false,
options: GM_getValue('config', {
threads: 4,
douyin_host: 1 // 抖音默认第二个线路
}),
saveOptions(opts){
GM_setValue('config', Object.assign(this.options, opts))
},
init(){ // 初始化
this.HOSTS = { // 网站规则
'www.xiaohongshu.com': {
title: '小红书', id: 'xhs',
url: 'https://edith.xiaohongshu.com/api/sns/web/v1/user_posted',
type: 'network',
parseList: json => json?.data?.notes,
getVideoURL: item => new Promise(reslove => {
fetch(item.url).then(resp => resp.text()).then(text => {
let json = JSON.parse(cutString1(text, '"noteDetailMap":', ',"serverRequestInfo":'))
let meta = item.meta = json[item.id]
reslove(meta.note.video.media.stream.h264[0].masterUrl)
})
}),
parseItem: data => {
let { cover, display_title, note_id, type, user, xsec_token } = data
if(type == 'video') return {
status: WAITTING, author_name: user.nickname, id: note_id, url: 'https://www.xiaohongshu.com/explore/'+note_id+'?xsec_token='+xsec_token+'=&xsec_source=pc_user',
cover: cover.url_default,
title: display_title.replaceAll('🥹', ''), data
}
}
},
'isee.weishi.qq.com': {
title: '微视', id: 'weishi',
url: 'https://api.weishi.qq.com/trpc.weishi.weishi_h5_proxy.weishi_h5_proxy/GetPersonalFeedList',
type: 'network',
parseList: json => json?.rsp_body?.feeds,
parseItem: data => {
let {feed_desc, id, poster, publishtime, video_url, video_cover } = data
return {
status: WAITTING, author_name: poster.nick, id, url: 'https://isee.weishi.qq.com/ws/app-pages/share/index.html?id='+id,
cover: video_cover.static_cover.url,
video_url, title: feed_desc,
data
}
}
},
'www.kuaishou.com': {
title: '快手', id: 'kuaishou',
url: 'https://www.kuaishou.com/graphql',
type: 'json',
parseList: json => json?.data?.visionProfilePhotoList?.feeds,
parseItem: data => {
let {photo, author} = data
return {
status: WAITTING, author_name: author.name, id: photo.id, url: 'https://www.kuaishou.com/short-video/'+photo.id,
cover: photo.coverUrl,
video_url: photo.photoUrl,
// video_url: photo.videoResource.h264.adaptationSet[0].representation[0].url,
title: photo.originCaption,
data
}
}
},
'www.douyin.com': {
title: '抖音', id: 'douyin',
url: 'https://www.douyin.com/aweme/v1/web/aweme/post/',
type: 'network',
hosts: [0, 1, 2], // 3个线路
parseList: json => json?.aweme_list,
parseItem: data => {
let {video, desc, author, aweme_id} = data
let {uri, height} = video.play_addr || {}
let xl = this.options.douyin_host
if(video.format == 'mp4') return {
status: WAITTING,
id: aweme_id,
url: 'https://www.douyin.com/video/'+aweme_id,
cover: video.cover.url_list[0],
author_name: author.nickname,
video_url: video.play_addr.url_list.at(xl),
//video_url: `[url=https://aweme.snssdk.com/aweme/v1/playwm/?video_id=]https://aweme.snssdk.com/aweme/v1/playwm/?video_id=[/url]${uri}&ratio=${height}p&line=0`, // 有水印
title: desc,
data
}
}
},
'www.tiktok.com': {
title: '国际版抖音', id: 'tiktok',
url: 'https://www.tiktok.com/api/post/item_list/',
type: 'network',
parseList: json => json?.itemList,
parseItem: data => {
let {video, desc, author, id} = data
return {
status: WAITTING, id,
url: 'https://www.tiktok.com/@'+ author.uniqueId +'/video/'+id,
cover: video.originCover,
author_name: author.nickname,
//video_url: video.downloadAddr,
video_url: video.bitrateInfo[0].PlayAddr.UrlList.at(-1),
title: desc,
data
}
}
},
'www.instagram.com': {
title: 'INS', id: 'instagram',
url: 'https://www.instagram.com/graphql/query',
type: 'network',
parseList: json => json?.data?.xdt_api__v1__feed__user_timeline_graphql_connection?.edges,
parseItem: data => {
// media_type == 2
let {code, owner, product_type, image_versions2, video_versions, caption } = data.node
if(product_type == "clips") return {
// owner.id
status: WAITTING, id: code,
url: 'https://www.instagram.com/reel/'+code+'/',
cover: image_versions2.candidates[0].url,
author_name: owner.username,
video_url: video_versions[0].url,
title: caption.text,
data
}
}
}
}
let DETAIL = this.DETAIL = this.HOSTS[location.host]
if(!DETAIL) return
console.log(DETAIL)
let callback = (...args) => this.callback.apply(this, args)
var parse = JSON.parse, originalSend = XMLHttpRequest.prototype.send, originalFetch = window.fetch
const hook = () => {
switch(DETAIL.type){
case 'json':
JSON.parse = function(raw) {
let json = parse(raw)
callback(Object.assign({}, json))
return json;
}
return
case 'network':
XMLHttpRequest.prototype.send = function() {
this.addEventListener('load', function() {
// DEBUG(this.responseURL)
if (this.responseURL.startsWith(DETAIL.url)) {
callback(JSON.parse(this.responseText))
}
});
originalSend.apply(this, arguments);
};
unsafeWindow.fetch = function() {
return originalFetch.apply(this, arguments).then(response => {
DEBUG(response.url)
if (response.status == 200 && response.url.startsWith(DETAIL.url)) {
response.clone().text().then(raw => {
if(raw != '') callback(JSON.parse(raw))
})
}
return response;
});
}
return
}
}
hook() & setInterval(() => hook(), 250)
},
callback(json){ // 捕获数据回调
console.log(json)
let {resources, DETAIL} = this
let {parseList, parseItem} = DETAIL
let cnt = resources.push(...(parseList(json) || []).map(item => parseItem(item)).filter(item => item))
if(!cnt > 0) return
let fv = document.querySelector('#_ftb')
if(!fv){
fv = document.createElement('div')
fv.id = '_ftb'
fv.style.cssText = `position: fixed;bottom: 50px;right: 50px;border-radius: 20px;background-color: #fe2c55;color: white;z-index: 999;cursor: pointer;`
fv.onclick = () => this.showList(),
document.body.append(fv)
}
fv.innerHTML = `下载 ${cnt} 个视频`
},
showList(){ // 展示主界面
console.log(this.resources)
let threads = this.options['threads']
this.showDialog({
id: '_dialog',
html: `
<div style="display: inline-flex;width: 100%;justify-content: space-around;height: 5%;min-height: 30px;">
<div>
<button id="_selectAll">全选</button>
<button id="_reverSelectAll">反选</button>
<button id="_clear_log">清空日志</button>
</div>
<div>
命名规则:
<input type="text" id="_filename" value="【{发布者}】{标题}({id})" title="允许的变量:{发布者} {标题} {id}">
<button id="_apply_filename">应用</button>
</div>
<div>
下载线程数:
<input id="_threads" type="range" value=${threads} step=1 min=1 max=32>
<span id="_threads_span">${threads}</span>
</div>
<div>
<button id="_settings">线路</button>
<button id="_clearDownloads">清空已下载</button>
<button id="_switchRunning">开始</button>
</div>
</div>
<div style="height: 70%;overflow-y: scroll;">
<table width="90%" border="2" style="margin: 0 auto;">
<tr align="center">
<th>编号</th>
<th>选中</th>
<th>封面</th>
<th>标题</th>
<th>状态</th>
</tr>
${this.resources.map((item, index) => {
let {video_url, title, cover, url, id} = item || {}
return `
<tr align="center" data-id="${id}">
<td>${index+1}</td>
<td><input type="checkbox" style="transform: scale(1.5);" checked></td>
<td><a href="${url}" target="_blank"><img src="${cover}" style="width: 100px;"></a></td>
<td contenteditable style="width: 400px;max-width: 400px;">${title}</td>
<td>等待中...</td>
</tr>`
}).join('')}
</table>
</div>
<div style="height: 25%; width: 100%;overflow-y: scroll;border-top: 2px solid white;">
<pre id="_log" style="background-color: rgba(255, 255, 255, .2);color: rgba(0, 0, 0, .8);"></pre>
</div>`,
onClose: () => this.resources.forEach(item => item.status = WAITTING)
}) & this.bindEvents() & [
`欢迎使用!当前版本: ${VERSION} 发布日期: ${RELEASE_DATE}`,
`此脚本仅供学习交流使用!!请勿用于非法用途!`,
`
--------------------------------------------------------------------------------------
常见问题:
为什么没有显示入口按钮?
可能是脚本插入时机慢了,可以多滚动或者多刷新几次
为什么下载显示失败
常见于抖音,抖音每个视频有三个线路,但并不是每个线路都是有视频存在的。所以目前的解决是 每个线路都尝试下载一次
为什么捕获的数量不等于主页作品数量
目前只能捕获视频作品,而非图文作品。
为什么只能捕获一页的数据/翻页不了
有些不常用的站点可能存在这些问题待修复
计划列表:导出/导入数据,自动选择适合的线路,区间选择,右键菜单选择,下载链接可视化,发送aria2下载
--------------------------------------------------------------------------------------
`
].forEach(msg => this.writeLog(msg))
},
showDialog({html, id, callback, onClose}){ // 弹窗
document.body.insertAdjacentHTML('beforeEnd', `
<dialog class="_dialog" id="${id}" style="top: 0;left: 0;width: 100%;height: 100%;position: fixed;z-index: 9999;background-color: rgba(0, 0, 0, .8);color: #fff;padding: 10px;overflow: auto; overscroll-behavior: contain;" open>
<a href="#" style="position: absolute;right: 0;top: 0;padding: 10px;background-color: rgba(255, 255, 255, .4);" class="_dialog_close">X</a>
${html}
<dialog>`)
setTimeout(() => {
let dialog = document.querySelector('#'+id)
dialog.querySelector('._dialog_close').onclick = () => dialog.remove() & (onClose && onClose())
callback && callback(dialog)
}, 500)
},
bindEvents(){ // 绑定DOM事件
$('#_threads')[0].oninput = function(ev){
$('#_threads_span')[0].innerHTML = this.value
}
$('#_apply_filename')[0].onclick = () => {
for(let tr of $('table tr[data-id]')){
let item = this.findItem(tr.dataset.id)
if(!item) return
let {title, author_name, id} = item
tr.querySelector('td[contenteditable]').innerHTML = $('#_filename')[0].value.replace('{标题}', title).replace('{id}', id).replace('{发布者}', author_name)
}
}
$('#_selectAll')[0].onclick = () => $('table input[type=checkbox]').forEach(el => el.checked = true)
$('#_reverSelectAll')[0].onclick = () => $('table input[type=checkbox]').forEach(el => el.checked = !el.checked)
$('#_clear_log')[0].onclick = () => $('#_log')[0].innerHTML = ''
$('#_switchRunning')[0].onclick = () => this.switchRunning()
$('#_settings')[0].onclick = () => {
this.showDialog({
id: '_dialog_settings',
html: `
<h1>如果可以正常下载,请勿设置线路!!!</h1>
${Object.values(this.HOSTS).map(({hosts, title, id}) => {
hosts ??= []
let html = `${title}线路: <select data-for="${id}">${hosts.map(host => `<option ${this.options[id+'_host'] == host ? 'selected' : ''}>${host}</option>`).join('')}</select>`
return hosts.length ? html : ''
}).join('')}
`,
callback: dialog => {
for(let select of dialog.querySelectorAll('select')) select.onchange = () => {
let opts = {}
opts[`${select.dataset.for}_host`] = select.value
this.saveOptions(opts)
}
},
onClose: () => this.resources = this.resources.map(item => this.DETAIL.parseItem(item.data))
})
}
$('#_clearDownloads')[0].onclick = () => {
if(this.running) return alert('请先暂停任务')
for(let i=this.resources.length-1;i>=0;i--){
let item = this.resources[i]
let {status, id} = item
let tr = this.findElement(item.id)
if(tr){
if(status == DOWNLOADED){
this.resources.splice(i, 1)
tr.remove()
continue
}
let td = tr.querySelectorAll('td')
td[4].style.backgroundColor = 'unset'
td[4].innerHTML = '等待中...'
}
item.status = WAITTING
}
}
},
switchRunning(running){ // 切换运行状态
this.running = running ??= !this.running
$('#_switchRunning')[0].innerHTML = running ? '暂停' : '运行'
if(running){
let threads = parseInt($('#_threads')[0].value)
let cnt = threads - this.getItems(DOWNLOADING).length
if(cnt){
this.writeLog('开始线程下载:'+cnt)
this.saveOptions({threads})
for(let i=0;i<cnt;i++) this.nextDownload()
}
}
},
getItems(_status){ // 获取指定状态任务
return this.resources.filter(({status}) => status == _status)
},
nextDownload(){ // 进行下一次下载
let {resources} = this
if(!resources.some(item => {
let {status, id, video_url} = item
if(status == WAITTING){
let tr = this.findElement(id)
if(!tr) return
let td = tr.querySelectorAll('td')
let checked = td[1].querySelector('input[type=checkbox]').checked
let title = td[3].outerText
if(checked){
item.status = DOWNLOADING
const log = (msg, color, next = true) => {
this.writeLog(msg, `<a href="${item.url}" target="_blank" style="color: white;">${title}</a>`, color, td[4])
if(next) this.nextDownload()
item.status = color == 'success' ? DOWNLOADED : ERROR
}
// 预先下载并尝试重试(多线程下需要重试才能正常下载)
let retry = 0, headers = 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,
}
}
const httpRequest = url => GM_xmlhttpRequest({
method: "GET", url, headers: headers(url),
// redirect: 'follow',
responseType: "blob",
timeout: 999999,
// anonymous:true,
onload: ({status, response, finalUrl}) => {
console.log({status, finalUrl, response})
if (status === 200) {
const downloadURL = url => GM_download({
url, name: this.safeFileName(title) + '.mp4', headers: headers(url),
onload: ({status}) => {
if(status == 502 || status == 404){
log(`下载失败`, 'error')
}else{
log(`下载完成...`, 'success')
}
},
timeout: 999999, // 无效
ontimeout: () => log(`超时`, 'error'),
onerror: () => log(`下载失败`, 'error'),
})
if(!response){
if(!finalUrl) return log(`请求错误`, 'error')
downloadURL(finalUrl)
}else{
downloadURL(URL.createObjectURL(response))
}
}else
if(retry++ < RETRY_MAX){
// console.log('下载失败,重试中...', video_url)
setTimeout(() => httpRequest(), 500)
}else{
log(`重试下载错误`, 'error')
}
}
})
if(!video_url){
if(!this.DETAIL.getVideoURL) return log(`无下载地址`, 'error')
this.DETAIL.getVideoURL(item).then(url => {
item.video_url = url
httpRequest(url)
})
}else{
httpRequest(video_url)
}
return true
}
}
})){
if(this.running){
this.writeLog('下载完成!') & this.switchRunning(false)
}
}
},
findElement(id){ // 根据Id查找dom
return $(`tr[data-id="${id}"]`)[0]
},
writeLog(msg, prefix = '提示', color = 'info', el){ // 输出日志
color = {success: '#8bc34a', error: '#a31545', info: '#fff' }[color]
$('#_log')[0].insertAdjacentHTML('beforeEnd', `<p style="color: ${color}">【${prefix}】 ${msg}</p>`)
if(el){
el.style.backgroundColor = color
el.innerHTML = msg
}
},
findItem(id, method = 'find'){ // 根据Item查找资源信息
return this.resources[method](_item => _item.id == id)
},
safeFileName: str => str.replaceAll('\n', ' ').replaceAll('(', '(').replaceAll(')', ')').replaceAll(':', ':').replaceAll('*', '*').replaceAll('?', '?').replaceAll('"', '"').replaceAll('<', '<').replaceAll('>', '>').replaceAll("|", "|").replaceAll('\\', '\').replaceAll('/', '/')
}).init()