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

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

  1. // ==UserScript==
  2. // @name 抖音/快手/微视/instagram/TIKTOK/小红书/微博/今日头条 主页视频下载
  3. // @namespace shortvideo_homepage_downloader
  4. // @version 1.3.4
  5. // @description 在抖音/快手/微视/instagram/TIKTOK/小红书/微博/今日头条 主页右小角显示视频下载按钮
  6. // @author hunmer
  7. // @match https://pixabay.com/videos/search/*
  8. // @match https://www.xinpianchang.com/discover/*
  9. // @match https://www.douyin.com/user/*
  10. // @match https://www.douyin.com/search/*
  11. // @match https://www.douyin.com/video/*
  12. // @match https://www.douyin.com/note/*
  13. // @match https://www.toutiao.com/c/user/token/*
  14. // @match https://www.kuaishou.com/profile/*
  15. // @match https://www.kuaishou.com/search/video*
  16. // @match1 https://www.youtube.com/@*/shorts
  17. // @match https://x.com/*/media
  18. // @match https://weibo.com/u/*?tabtype=newVideo*
  19. // @match https://isee.weishi.qq.com/ws/app-pages/wspersonal/index.html*
  20. // @match https://www.instagram.com/*
  21. // @match https://www.xiaohongshu.com/user/profile/*
  22. // @match https://www.xiaohongshu.com/search_result/*
  23. // @match https://www.xiaohongshu.com/explore*
  24. // @match https://www.tiktok.com/@*
  25. // @match https://www.tiktok.com/search*
  26. // @match https://artlist.io/stock-footage/story/*
  27. // @match https://artlist.io/stock-footage/search?*
  28. // @icon https://lf1-cdn-tos.bytegoofy.com/goofy/ies/douyin_web/public/favicon.ico
  29. // @grant GM_download
  30. // @grant GM_addStyle
  31. // @grant GM_setValue
  32. // @grant GM_getValue
  33. // @grant GM_addElement
  34. // @grant unsafeWindow
  35. // @grant GM_xmlhttpRequest
  36. // @run-at document-start
  37. // @license MIT
  38. // ==/UserScript==
  39. const $ = selector => document.querySelectorAll('#_dialog '+selector)
  40. const ERROR = -1, WAITTING = 0, DOWNLOADING = 1, DOWNLOADED = 2
  41. const VERSION = '1.3.4', RELEASE_DATE = '2025/03/31'
  42. const DEBUGING = false
  43. const DEBUG = (...args) => DEBUGING && console.log.apply(this, args)
  44. const toArr = arr => Array.isArray(arr) ? arr : [arr]
  45. const guid = () => {
  46. return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
  47. var r = Math.random() * 16 | 0,
  48. v = c == 'x' ? r : (r & 0x3 | 0x8)
  49. return v.toString(16)
  50. })
  51. }
  52. Date.prototype.format = function (fmt) {
  53. var o = {
  54. "M+": this.getMonth() + 1,
  55. "d+": this.getDate(),
  56. "h+": this.getHours(),
  57. "m+": this.getMinutes(),
  58. "s+": this.getSeconds(),
  59. "q+": Math.floor((this.getMonth() + 3) / 3),
  60. "S": this.getMilliseconds()
  61. };
  62. if (/(y+)/.test(fmt)) {
  63. fmt = fmt.replace(RegExp.$1, (this.getFullYear() + "").substr(4 - RegExp.$1.length))
  64. }
  65. for (var k in o) {
  66. if (new RegExp("(" + k + ")").test(fmt)) {
  67. fmt = fmt.replace(RegExp.$1, (RegExp.$1.length == 1) ? (o[k]) : (("00" + o[k]).substr(("" + o[k]).length)))
  68. }
  69. }
  70. return fmt
  71. }
  72. const flattenArray = arr => {
  73. if(!Array.isArray(arr)) return []
  74. var result = []
  75. for (var i = 0; i < arr.length; i++) {
  76. if (Array.isArray(arr[i])) {
  77. result = result.concat(flattenArray(arr[i]))
  78. } else {
  79. result.push(arr[i])
  80. }
  81. }
  82. return result
  83. }
  84. const getExtName = name => {
  85. switch(name){
  86. case 'video':
  87. return 'mp4'
  88. case 'image':
  89. case 'photo':
  90. return 'jpg'
  91. }
  92. return name ?? 'mp4'
  93. }
  94. const escapeHTMLPolicy = typeof(trustedTypes) != 'undefined' ? trustedTypes.createPolicy("forceInner", {
  95. createHTML: html => html,
  96. }) : {
  97. createHTML: html => html
  98. }
  99. const createHTML = html => escapeHTMLPolicy.createHTML(html)
  100. const openFileDialog = ({callback, accept = '*'}) => {
  101. let input = document.createElement('input')
  102. input.type = 'file'
  103. input.style.display = 'none'
  104. input.accept = accept
  105. document.body.appendChild(input)
  106. input.addEventListener('change', ev => callback(ev.target.files) & input.remove())
  107. input.click()
  108. }
  109.  
  110. const loadRes = (files, callback) => {
  111. return new Promise(reslove => {
  112. files = [...files]
  113. var next = () => {
  114. let url = files.shift()
  115. if (url == undefined) {
  116. callback && callback()
  117. return reslove()
  118. }
  119. let fileref, ext = url.split('.').at(-1)
  120. if (ext == 'js') {
  121. fileref = GM_addElement('script', {
  122. src: url,
  123. type: ext == 'js' ? "text/javascript" : 'module'
  124. })
  125. } else if (ext == "css") {
  126. fileref = GM_addElement('link', {
  127. href: url,
  128. rel: "stylesheet",
  129. type: "text/css"
  130. })
  131. }
  132. if (fileref != undefined) {
  133. let el = document.getElementsByTagName("head")[0].appendChild(fileref)
  134. el.onload = next, el.onerror = next
  135. } else {
  136. next()
  137. }
  138. }
  139. next()
  140. })
  141. }
  142.  
  143. const cutString = (s_text, s_start, s_end, i_start = 0, fill = false) => {
  144. i_start = s_text.indexOf(s_start, i_start)
  145. if (i_start === -1) return ''
  146. i_start += s_start.length
  147. i_end = s_text.indexOf(s_end, i_start)
  148. if (i_end === -1) {
  149. if (!fill) return ''
  150. i_end = s_text.length
  151. }
  152. return s_text.substr(i_start, i_end - i_start)
  153. }
  154. const getParent = (el, callback) => {
  155. let par = el
  156. while(par && !callback(par)){
  157. par = par.parentElement
  158. }
  159. return par
  160. }
  161. const chooseObject = (cb, ...objs) => {
  162. let callback = typeof(cb) == 'function' ? cb : obj => obj?.[cb]
  163. return objs.find(callback)
  164. }
  165.  
  166. // 样式
  167. GM_addStyle(`
  168. ._dialog {
  169. input[type=checkbox] {
  170. -webkit-appearance: auto !important;
  171. }
  172. color: white !important;
  173. font-size: large !important;
  174. font-family: unset !important;
  175. input {
  176. color: white;
  177. border: 1px solid;
  178. }
  179. table tr td, table tr th {
  180. vertical-align: middle;
  181. }
  182. input[type=text], button {
  183. color: white !important;
  184. background-color: unset !important;
  185. }
  186. table input[type=checkbox] {
  187. width: 20px;
  188. height: 20px;
  189. transform: scale(1.5);
  190. -webkit-appearance: checkbox;
  191. }
  192. }
  193. body:has(dialog[open]) {
  194. overflow: hidden;
  195. }
  196. `);
  197.  
  198. unsafeWindow._downloader = _downloader = {
  199. loadRes,
  200. resources: [], running: false, downloads: {},
  201. options: Object.assign({
  202. threads: 8,
  203. firstRun: true,
  204. autoRename: false,
  205. alert_done: true,
  206. show_img_limit: 500,
  207. douyin_host: 1, // 抖音默认第二个线路
  208. timeout: 1000 * 60,
  209. retry_max: 60,
  210. autoScroll: true,
  211. aria2c_port: 6800,
  212. aria2c_saveTo: './downloads'
  213. }, GM_getValue('config', {})),
  214. saveOptions(opts = {}){
  215. opts = Object.assign(this.options, opts)
  216. GM_setValue('config', opts)
  217. },
  218. _aria_callbacks: [],
  219. bindAria2Event(method, gid, callback){
  220. this._aria_callbacks.push({
  221. method: 'aria2.' + method,
  222. gid, callback
  223. })
  224. },
  225. enableAria2c(enable){
  226. if(enable){
  227. if(!this.aria2c){
  228. loadRes(['https://www.unpkg.com/httpclient@0.1.0/bundle.js', 'https://www.unpkg.com/aria2@2.0.1/bundle.js'], () => {
  229. this.writeLog('正在连接aria2,请等待连接成功后再开始下载!!!', 'ARIA2C')
  230. var aria2 = this.aria2c = new unsafeWindow.Aria2({
  231. host: 'localhost',
  232. port: this.options.aria2c_port,
  233. secure: false,
  234. secret: '',
  235. path: '/jsonrpc',
  236. jsonp: false
  237. })
  238. aria2.open().then(() => {
  239. aria2.opening = true
  240. this.writeLog('aria2成功连接!', 'ARIA2C')
  241. $('[data-for="useAria2c"]')[0].checked = true
  242. })
  243. aria2.onclose = () => {
  244. aria2.opening = false
  245. this.writeLog('aria2失去连接!', 'ARIA2C')
  246. $('[data-for="useAria2c"]')[0].checked = false
  247. }
  248. aria2.onmessage = ({ method: _method, id, result, params }) => {
  249. console.log({_method, result, params})
  250. switch (_method) {
  251. // case 'aria2.onDownloadError': // 下载完成了还莫名触发?
  252. case 'aria2.onDownloadComplete':
  253. for (let i = this._aria_callbacks.length - 1; i >= 0; i--) {
  254. let { gid, method, callback } = this._aria_callbacks[i]
  255. if (gid == params[0].gid) {
  256. if (method == _method) { // 如果gid有任何一个事件成功了则删除其他事件绑定
  257. callback()
  258. }
  259. this._aria_callbacks.splice(i, 1)
  260. }
  261. }
  262. return
  263. }
  264. }
  265. })
  266. }
  267. }else{
  268. if(this.aria2c){
  269. this.aria2c.close()
  270. this.aria2c = undefined
  271. }
  272. }
  273. },
  274. addDownload(opts){
  275. console.log(opts)
  276. let _id = guid()
  277. var {id, url, name, error, success, download, downloadTool} = opts
  278. if(download){ // 命名规则
  279. let {ext, type, title} = download
  280. ext ||= getExtName(type)
  281. name = this.safeFileName(this.getDownloadName(id) ?? title) + (ext != '' ? '.' + ext : '')
  282. }
  283. const callback = (status, msg) => {
  284. let cb = opts[status]
  285. cb && cb(msg)
  286. this.removeDownload(_id)
  287. }
  288. var abort, timer
  289. var headers = this.getHeaders(url)
  290. if(downloadTool == 'm3u8dl'){
  291. let base64 = new Base64().encode(`"${url}" --workDir "${this.options.aria2c_saveTo}" --saveName "${name}" --enableDelAfterDone --headers "Referer:https://artlist.io/" --maxThreads "6" --downloadRange "0-1"`)
  292. unsafeWindow.open(`m3u8dl://`+base64, '_blank')
  293. return callback('success', '下载完成...')
  294. }
  295. if(this.aria2c){
  296. var _guid
  297. this.aria2c.send("addUri", [url], {
  298. dir: this.options.aria2c_saveTo,
  299. header: Object.entries(headers).map(([k, v]) => `${k}: ${v}`),
  300. out: name,
  301. }).then(guid => {
  302. _guid = guid
  303. this.bindAria2Event('onDownloadComplete', guid, () => callback('success', '下载完成...'))
  304. this.bindAria2Event('onDownloadError', guid, () => callback('error', '下载失败'))
  305. })
  306. abort = () => _guid && this.aria2c.send("remove", [_guid])
  307. }else{
  308. var fileStream
  309. abort = () => fileStream.abort()
  310. timer = setTimeout(() => {
  311. callback('error', '超时')
  312. this.removeDownload(_id, true)
  313. }, this.options.timeout)
  314. const writeStream = readableStream => {
  315. if (unsafeWindow.WritableStream && readableStream.pipeTo) {
  316. return readableStream.pipeTo(fileStream).then(() => callback('success', '下载完成...')).catch(err => callback('error', '下载失败'))
  317. }
  318. }
  319. let isTiktok = location.host == 'www.tiktok.com'
  320. if(isTiktok) headers.Referer = url
  321. GM_xmlhttpRequest({
  322. url, headers,
  323. redirect: 'follow', responseType: 'blob', method: "GET",
  324. onload: ({response, status}) => {
  325. console.log({response, status})
  326. // BUG 不知为啥tiktok无法使用流保存
  327. if(isTiktok || typeof(streamSaver) == 'undefined'){
  328. return unsafeWindow.saveAs(response, name) & callback('success', '下载完成...')
  329. }
  330. let res = new Response(response).clone()
  331. fileStream = streamSaver.createWriteStream(name, {size: response.size})
  332. writeStream(res.body)
  333. //writeStream(response.stream())
  334. }
  335. })
  336. }
  337. return this.downloads[_id] = {abort, timer}
  338. },
  339. removeDownload(id, cancel = false){
  340. let {timer, abort} = this.downloads[id] ?? {}
  341. if(timer) clearTimeout(timer)
  342. cancel && abort()
  343. delete this.downloads[id]
  344. },
  345. setBadge(html){
  346. let fv = document.querySelector('#_ftb')
  347. if(!fv){
  348. fv = document.createElement('div')
  349. fv.id = '_ftb'
  350. fv.style.cssText = `position: fixed;bottom: 50px;right: 50px;border-radius: 20px;background-color: #fe2c55;color: white;z-index: 999;cursor: pointer;padding: 5px;`
  351. fv.onclick = () => this.showList()
  352. fv.oncontextmenu = ev => {
  353. this.setList([], false)
  354. fv.remove()
  355. ev.stopPropagation(true) & ev.preventDefault()
  356. }
  357. document.body.append(fv)
  358. }
  359. fv.innerHTML = createHTML(html)
  360. },
  361. init(){ // 初始化
  362. const parseDouyinList = data => {
  363. let {video, desc, images} = data
  364. let author = data.author ?? data.authorInfo
  365. let aweme_id = data.aweme_id ?? data.awemeId
  366. let create_time = data.create_time ?? data.createTime
  367. //let {uri, height} = video.play_addr || {}
  368. let xl = this.options.douyin_host
  369. return {
  370. status: WAITTING,
  371. id: aweme_id,
  372. url: 'https://www.douyin.com/video/'+aweme_id,
  373. cover: (video?.cover?.url_list || video?.coverUrlList)[0],
  374. author_name: author.nickname,
  375. create_time: create_time * 1000,
  376. urls: images ? images.map(({height, width, download_url_list, downloadUrlList}, index) => {
  377. return {url: (download_url_list ?? downloadUrlList)[0], type: 'photo'}
  378. }) : video.play_addr.url_list.at(xl),
  379. title: desc,
  380. data
  381. }
  382. }
  383. this.HOSTS = { // 网站规则
  384. 'x.com': {
  385. title: '推特', id: 'twitter',
  386. rules: [
  387. {
  388. url: 'https://x.com/i/api/graphql/(.*?)/UserMedia',
  389. type: 'network',
  390. parseList: json => json?.data?.user?.result?.timeline_v2?.timeline?.instructions?.[0]?.moduleItems,
  391. parseItem: data => {
  392. let {legacy, user_results, core, views: {count: view_count}} = data.item.itemContent.tweet_results.result
  393. let {description: author_desc, name: author_name, id: author_id,} = core.user_results.result
  394. let {created_at, full_text: title, lang, extended_entities, favorite_count, bookmark_count, quote_count, reply_count, retweet_count, id_str: id} = legacy
  395. if(extended_entities?.media) return extended_entities.media.map(({type, media_url_https: url, original_info: {height, width}}, index) => {
  396. return {
  397. status: WAITTING,
  398. url: 'https://x.com/pentarouX/status/'+id,
  399. cover: url+'?format=jpg&name=360x360',
  400. id: id, author_name, urls: [{url, type}], title, index, create_time: created_at,
  401. data
  402. }
  403. })
  404. }
  405. }
  406. ]
  407. },
  408. 'www.youtube.com': {
  409. title: '油管', id: 'youtube',
  410. getVideoURL: item => new Promise(reslove => {
  411. fetch(item.url).then(resp => resp.text()).then(text => {
  412. let json = JSON.parse(cutString(text, '"noteDetailMap":', ',"serverRequestInfo":'))
  413. let meta = item.meta = json[item.id]
  414. reslove(meta.note.video.media.stream.h264[0].masterUrl)
  415. })
  416. }),
  417. rules: [
  418. {
  419. url: 'https://www.youtube.com/youtubei/v1/browse',
  420. type: 'fetch',
  421. parseList: json => json?.onResponseReceivedActions[0].appendContinuationItemsAction.continuationItems,
  422. parseItem: data => {
  423. if(!data.richItemRenderer) return
  424. let {videoId, headline, thumbnail} = data.richItemRenderer.content.reelItemRenderer
  425. return {
  426. status: WAITTING,
  427. id: videoId,
  428. url: 'https://www.youtube.com/shorts/'+videoId,
  429. cover: thumbnail.thumbnails[0].url,
  430. author_name: '', urls: '', title: headline.simpleText,
  431. data
  432. }
  433. }
  434. }
  435. ]
  436. },
  437. 'pixabay.com': {
  438. title: 'pixabay', id: 'pixabay',
  439. rules: [
  440. {
  441. type: 'object',
  442. getObject: window => window?.__BOOTSTRAP__?.page?.results,
  443. parseList: json => json,
  444. parseItem: data => {
  445. let {id, description, href , user, uploadDate, name, sources} = data
  446. return {
  447. status: WAITTING,id, url: 'https://pixabay.com'+href, cover: sources.thumbnail,
  448. author_name: user.username,
  449. urls: sources.mp4.replace('_tiny', ''),
  450. title: `【${name}】${description}`, create_time: uploadDate,
  451. data
  452. }
  453. }
  454. }
  455. ]
  456. },
  457. 'weibo.com': {
  458. title: '微博', id: 'weibo',
  459. rules: [
  460. {
  461. url: 'https://weibo.com/ajax/profile/getWaterFallContent',
  462. type: 'network',
  463. parseList: json => json?.data?.list,
  464. parseItem: data => {
  465. let {page_info, created_at, text_raw} = data
  466. let {short_url, object_id, media_info, page_pic} = page_info
  467. return {
  468. status: WAITTING,
  469. id: object_id,
  470. url: short_url,
  471. cover: page_pic,
  472. author_name: media_info.author_name,
  473. urls: media_info.playback_list[0].play_info.url,
  474. title: text_raw, create_time: created_at,
  475. data
  476. }
  477. }
  478. }
  479. ]
  480. },
  481. 'www.xinpianchang.com': {
  482. title: '新片场', id: 'xinpianchang',
  483. runAtWindowLoaded: false,
  484. getVideoURL: item => new Promise(reslove => {
  485. fetch(`https://mod-api.xinpianchang.com/mod/api/v2/media/${item.media_id}?appKey=61a2f329348b3bf77&extend=userInfo%2CuserStatus`).then(resp => resp.json()).then(json => {
  486. reslove(json.data.resource.progressive.find(({url}) => url != '').url)
  487. })
  488. }),
  489. rules: [
  490. {
  491. url: 'https://www.xinpianchang.com/_next/data/',
  492. type: 'json',
  493. parseList: json => {
  494. return flattenArray((json?.pageProps?.squareData?.section || []).map(({articles}) => articles || []))
  495. },
  496. parseItem: data => {
  497. let {author, content, cover, media_id, title, web_url, publish_time, id} = data
  498. return {
  499. status: WAITTING,id, url: web_url, cover, title, media_id,
  500. author_name: author.userinfo.username,
  501. create_time: publish_time,
  502. data
  503. }
  504. }
  505. }
  506. ]
  507. },
  508. 'www.xiaohongshu.com': {
  509. title: '小红书', id: 'xhs',
  510. getVideoURL: item => new Promise(reslove => {
  511. fetch(item.url).then(resp => resp.text()).then(text => {
  512. let json = JSON.parse(cutString(text, '"noteDetailMap":', ',"serverRequestInfo":'))
  513. let note = json[item.id].note
  514. Object.assign(item, {create_time: note.time, meta: note})
  515. console.log(note)
  516. reslove(note.type == 'video' ? {url: note.video.media.stream.h265[0].masterUrl, type: 'video'} : note.imageList.map(({urlDefault}) => {
  517. return {url: urlDefault, type: 'photo'}
  518. }))
  519. })
  520. }),
  521. rules: [
  522. {
  523. type: 'object',
  524. getObject: window => location.href.startsWith('https://www.xiaohongshu.com/explore/') ? window?.__INITIAL_STATE__?.note?.noteDetailMap : {},
  525. parseList: json => {
  526. let list = Object.values(json).filter(({note}) => note).map(({note}) => note)
  527. return list
  528. },
  529. parseItem: data => {
  530. let { desc, imageList = [], noteId: id, time, user, xsecToken, title, type, video} = data
  531. let images = imageList.map(({urlDefault}) => {
  532. return {url: urlDefault, type: 'photo'}
  533. })
  534. let urls = type == 'normal' ? images : video.media.stream.h265[0].masterUrl
  535. return {
  536. status: WAITTING, author_name: user.nickname, id, url: 'https://www.xiaohongshu.com/explore/'+id, urls,
  537. cover: images[0].url,
  538. title: desc, data
  539. }
  540. }
  541. },
  542. {
  543. type: 'object',
  544. 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),
  545. parseList: json => {
  546. let list = []
  547. Array.isArray(json) && json.forEach(items => {
  548. if(Array.isArray(items)) {
  549. items.forEach(item => {
  550. if(item.noteCard) list.push(item)
  551. })
  552. }else
  553. if(items?.noteCard){
  554. list.push(items)
  555. }
  556. })
  557. return list
  558. },
  559. parseItem: data => {
  560. let { cover, displayTitle, noteId, type, user, xsecToken} = data?.noteCard || {}
  561. let id = noteId ?? data.id
  562. xsecToken ??= data.xsecToken ??= ''
  563. // if(xsecToken) {
  564. return {
  565. 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`,
  566. // +'?xsec_token='+xsecToken+'=&xsec_source=pc_user',
  567. cover: cover.urlDefault,
  568. title: (displayTitle ?? '').replaceAll('🥹', ''), data
  569. }
  570. // }
  571. }
  572. }
  573. ]
  574. },
  575. 'isee.weishi.qq.com': {
  576. title: '微视', id: 'weishi',
  577. rules: [
  578. {
  579. url: 'https://api.weishi.qq.com/trpc.weishi.weishi_h5_proxy.weishi_h5_proxy/GetPersonalFeedList',
  580. type: 'network',
  581. parseList: json => json?.rsp_body?.feeds,
  582. parseItem: data => {
  583. let {feed_desc, id, poster, publishtime, urls, video_cover, createtime } = data
  584. return {
  585. status: WAITTING, author_name: poster?.nick, id, url: 'https://isee.weishi.qq.com/ws/app-pages/share/index.html?id='+id,
  586. cover: video_cover.static_cover.url,
  587. urls, title: feed_desc,
  588. create_time: createtime * 1000,
  589. data
  590. }
  591. }
  592. }
  593. ]
  594. },
  595. 'www.kuaishou.com': {
  596. title: '快手', id: 'kuaishou',
  597. rules: [
  598. {
  599. url: 'https://www.kuaishou.com/graphql',
  600. type: 'json',
  601. parseList: json => {
  602. let href = location.href
  603. if(href.startsWith('https://www.kuaishou.com/profile/')){
  604. return json?.data?.visionProfileLikePhotoList?.feeds || json?.data?.visionProfilePhotoList?.feeds
  605. }
  606. if(href.startsWith('https://www.kuaishou.com/search/')){
  607. return json?.data?.visionSearchPhoto?.feeds
  608. }
  609. },
  610. parseItem: data => {
  611. let {photo, author} = data
  612. return {
  613. status: WAITTING, author_name: author.name, id: photo.id, url: 'https://www.kuaishou.com/short-video/'+photo.id,
  614. cover: photo.coverUrl,
  615. urls: photo.photoUrl,
  616. create_time: photo.timestamp,
  617. // urls: photo.videoResource.h264.adaptationSet[0].representation[0].url,
  618. title: photo.originCaption,
  619. data
  620. }
  621. }
  622. }
  623. ],
  624. },
  625. 'www.toutiao.com': {
  626. title: '今日头条短视频', id: 'toutiao',
  627. rules: [
  628. {
  629. url: 'https://www.toutiao.com/api/pc/list/user/feed',
  630. type: 'json',
  631. parseList: json => json?.data,
  632. parseItem: data => {
  633. let {video, title, id, user, thumb_image_list, create_time} = data
  634. return {
  635. status: WAITTING, id, title, data,
  636. url: 'https://www.toutiao.com/video/'+id,
  637. cover: thumb_image_list[0].url,
  638. author_name: user.info.name,
  639. create_time: create_time * 1000,
  640. urls: video.download_addr.url_list[0],
  641. }
  642. }
  643. }
  644. ],
  645. },
  646. 'www.douyin.com': {
  647. title: '抖音', id: 'douyin',
  648. scrollContainer: {
  649. 'https://www.douyin.com/user/': '.route-scroll-container'
  650. },
  651. hosts: [0, 1, 2], // 3个线路
  652. runAtWindowLoaded: false,
  653. bindVideoElement: {
  654. initElement: node => {
  655. let par = getParent(node, el => el?.dataset?.e2eVid)
  656. if(par) return {id: par.dataset.e2eVid}
  657. let id = cutString(location.href + '?', '/video/', '?')
  658. if(id) return {id}
  659. }
  660. },
  661. timeout: {
  662. '/user/': 500,
  663. '/note/': 500,
  664. '/video/': 500,
  665. '/search/': 500,
  666. },
  667. rules: [
  668. {
  669. type: 'object',
  670. getObject: window => {
  671. let noteId = cutString(window.location.href + '#', '/note/', '#')
  672. if(noteId){
  673. let raw = cutString((window?.self?.__pace_f ?? []).filter(arr => arr.length == 2).map(([k, v]) => v || '').join(''), '"aweme":{', ',"comment').replaceAll(`\\"`, '')
  674. if(raw.at(-1) == '}'){
  675. let json = JSON.parse("{"+raw)
  676. if(json.detail.awemeId == noteId) return json
  677. }
  678. }
  679. },
  680. parseList: json => {
  681. return json ? [json.detail] : []
  682. },
  683. parseItem: parseDouyinList
  684. },
  685. { // 个人喜欢
  686. url: 'https://www.douyin.com/aweme/v1/web/aweme/favorite/',
  687. type: 'network',
  688. parseList: json => location.href == 'https://www.douyin.com/user/self?from_tab_name=main&showTab=like' ? json?.aweme_list : [],
  689. parseItem: parseDouyinList,
  690. },
  691. { // 个人收藏
  692. url: 'https://www.douyin.com/aweme/v1/web/aweme/listcollection/',
  693. type: 'network',
  694. parseList: json => location.href == 'https://www.douyin.com/user/self?from_tab_name=main&showTab=favorite_collection' ? json?.aweme_list : [],
  695. parseItem: parseDouyinList,
  696. },
  697. {
  698. url: 'https://(.*?).douyin.com/aweme/v1/web/aweme/post/',
  699. type: 'network',
  700. parseList: json => location.href.startsWith('https://www.douyin.com/user/') ? json?.aweme_list : [],
  701. parseItem: parseDouyinList
  702. }, {
  703. url: 'https://www.douyin.com/aweme/v1/web/general/search/single/',
  704. type: 'network',
  705. parseList: json => json?.data,
  706. parseItem: data => parseDouyinList(data.aweme_info)
  707. },{
  708. url: 'https://www.douyin.com/aweme/v1/web/aweme/detail/',
  709. type: 'network',
  710. parseList: json => location.href.startsWith('https://www.douyin.com/video/') ? [json.aweme_detail] : [],
  711. parseItem: parseDouyinList
  712. },
  713. ]
  714. },
  715. 'www.tiktok.com': {
  716. title: '国际版抖音', id: 'tiktok',
  717. rules: [
  718. {
  719. url: 'https://www.tiktok.com/api/post/item_list/',
  720. type: 'respone.json',
  721. parseList: json => json?.itemList,
  722. parseItem: data => {
  723. let {video, desc, author, id, createTime} = data
  724. return {
  725. status: WAITTING, id,
  726. url: 'https://www.tiktok.com/@'+ author.uniqueId +'/video/'+id,
  727. cover: video.originCover,
  728. author_name: author.nickname,
  729. create_time: createTime * 1000,
  730. //urls: video.downloadAddr,
  731. urls: video?.bitrateInfo?.[0]?.PlayAddr.UrlList[0],
  732. title: desc,
  733. data
  734. }
  735. }
  736. },
  737. {
  738. url: 'https://www.tiktok.com/api/search/general/full/',
  739. type: 'respone.json',
  740. parseList: json => json?.data,
  741. parseItem: data => {
  742. let {video, desc, author, id, createTime} = data.item
  743. return {
  744. status: WAITTING, id,
  745. url: 'https://www.tiktok.com/@'+ author.uniqueId +'/video/'+id,
  746. cover: video.originCover,
  747. author_name: author.nickname,
  748. create_time: createTime * 1000,
  749. urls: video?.bitrateInfo?.[0]?.PlayAddr.UrlList?.at(-1),
  750. title: desc,
  751. data
  752. }
  753. }
  754. }
  755. ]
  756. },
  757. 'www.instagram.com': {
  758. title: 'INS', id: 'instagram',
  759. rules: [
  760. {
  761. url: 'https://www.instagram.com/graphql/query',
  762. type: 'network',
  763. parseList: json => json?.data?.xdt_api__v1__feed__user_timeline_graphql_connection?.edges,
  764. parseItem: data => {
  765. // media_type == 2
  766. let {code, owner, product_type, image_versions2, video_versions, caption } = data.node
  767. if(product_type == "clips") return {
  768. // owner.id
  769. status: WAITTING, id: code,
  770. url: 'https://www.instagram.com/reel/'+code+'/',
  771. cover: image_versions2.candidates[0].url,
  772. author_name: owner.username,
  773. urls: video_versions[0].url,
  774. create_time: caption.created_at * 1000,
  775. title: caption.text,
  776. data
  777. }
  778. }
  779. }
  780. ]
  781. },
  782. 'artlist.io': {
  783. title: 'artlist', id: 'artlist',
  784. rules: [
  785. {
  786. // url: 'https://search-api.artlist.io/v1/graphql',
  787. type: 'json',
  788. parseList: json => {
  789. return json?.data?.story?.clips || json?.data?.clipList?.exactResults
  790. },
  791. parseItem: data => {
  792. let {thumbnailUrl, clipPath, clipName, orientation, id, clipNameForUrl, storyNameForURL } = data
  793. return {
  794. status: WAITTING, id, downloadTool: 'm3u8dl',
  795. url: 'https://artlist.io/stock-footage/clip/'+clipNameForUrl+'/'+id,
  796. cover: thumbnailUrl,
  797. author_name: storyNameForURL,
  798. urls: [{url: clipPath.replace('playlist', '1080p'), type: ""}],
  799. title: clipName,
  800. data
  801. }
  802. }
  803. }
  804. ]
  805. }
  806. }
  807. let DETAIL = this.DETAIL = this.HOSTS[location.host]
  808. if(!DETAIL) return
  809. console.log(DETAIL)
  810. var originalParse, originalSend, originalFetch, originalResponseJson
  811. const initFun = () => {
  812. originalParse = JSON.parse, originalSend = XMLHttpRequest.prototype.send, originalFetch = unsafeWindow._fetch = unsafeWindow.fetch, originalResponseJson = Response.prototype.json
  813. if(this.options.firstRun){
  814. this.options.firstRun = false
  815. this.saveOptions()
  816. alert("欢迎使用此视频批量下载脚本,以下是常见问题:\n【1】.Q:为什么没有显示下载入口?A:当前网址不支持\n【2】Q:为什么捕获不到视频?A:试着滚动视频列表,让他进行加载\n【3】Q:为什么抖音主页显示用户未找到?A:多刷新几次【4】Q:提示下载失败怎么办?A:可以批量导出链接用第三方软件进行下载(如IDM)")
  817. }
  818. this.setBadge("等待滚动捕获数据中...")
  819. }
  820.  
  821. var resources = this.resources, object_callbacks = []
  822. const hook = () => {
  823. let json_callbacks = [], network_callbacks = [], fetch_callbacks = [], respone_json_callbacks = []
  824. DETAIL.rules.forEach(({type, parseList, parseItem, url, getObject, match}, rule_index) => {
  825. const callback = json => {
  826. // console.log(json)
  827. try {
  828. // TODO sort
  829. 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))))
  830. if(cnt <= 0) return
  831. this.tryAutoRenameAll()
  832. this.setBadge(`下载 ${cnt} 个视频`)
  833. } catch(err){
  834. console.error(err)
  835. }
  836. }
  837. switch(type){
  838. case 'object':
  839. let obj = getObject(unsafeWindow)
  840. return callback(obj)
  841. case 'json':
  842. return json_callbacks.push(json => callback(Object.assign({}, json)))
  843. case 'network':
  844. return network_callbacks.push({url, callback})
  845. case 'fetch':
  846. return fetch_callbacks.push({url, callback})
  847. case 'respone.json':
  848. return respone_json_callbacks.push(json => callback(Object.assign({}, json)))
  849. }
  850. })
  851. if(json_callbacks.length){
  852. JSON.parse = function(...args) {
  853. let json = originalParse.apply(JSON, args)
  854. json_callbacks.forEach(cb => cb(json))
  855. return json
  856. }
  857. }
  858. if(respone_json_callbacks.length){
  859. Object.defineProperty(Response.prototype, 'json', {
  860. value: function() {
  861. let ret = originalResponseJson.apply(this, arguments)
  862. ret.then(json => respone_json_callbacks.forEach(cb => cb(json)))
  863. return ret
  864. },
  865. writable: true,
  866. enumerable: false,
  867. configurable: true
  868. });
  869. }
  870. const cb = (callbacks, {fullURL, raw}) => {
  871. callbacks.forEach(({url, callback}) => {
  872. if(new RegExp(url).test(fullURL) && typeof(raw) == 'string' && (raw.startsWith('{') && raw.endsWith('}') || raw.startsWith('[') && raw.endsWith(']'))){
  873. callback(JSON.parse(raw))
  874. }
  875. })
  876. }
  877. if(network_callbacks.length){
  878. XMLHttpRequest.prototype.send = function() {
  879. this.addEventListener('load', function() {
  880. if(['', 'text'].includes(this.responseType)) cb(network_callbacks ,{fullURL: this.responseURL, raw: this.responseText})
  881. })
  882. originalSend.apply(this, arguments)
  883. }
  884. }
  885. if(fetch_callbacks.length){
  886. unsafeWindow.fetch = function() {
  887. return originalFetch.apply(this, arguments).then(response => {
  888. if (response.status == 200) {
  889. response.clone().text().then(raw => {
  890. cb(fetch_callbacks, {fullURL: response.url, raw})
  891. })
  892. }
  893. return response
  894. })
  895. }
  896. }
  897. }
  898. let timeout = Object.entries(DETAIL.timeout || {}).find(([path, ms]) => (unsafeWindow.location.pathname || '').startsWith(path))?.[1] || 0
  899. const start = () => {
  900. if(!this.inited){
  901. this.inited = true
  902. setTimeout(() => initFun() & hook() & setInterval(() => hook(), 250), timeout)
  903. }
  904. }
  905. if(!DETAIL.runAtWindowLoaded) start()
  906. window.onload = () => start() & (DETAIL.bindVideoElement && this.bindVideoElement(DETAIL.bindVideoElement)) & this.initAction()
  907. },
  908.  
  909. tryAutoRenameAll(){
  910. if(this.options.autoRename && this.isShowing()){
  911. if(!this.initedRename){
  912. this.initedRename = true
  913. let lastName = this.options.lastRename
  914. if(typeof(lastName) == 'string') $('#_filename')[0].value = lastName
  915. }
  916. this.applyRenameAll()
  917. }
  918. },
  919.  
  920. autoScroll_timer: -1, autoScroll: false,
  921. switchAutoScroll(enable){
  922. if(this.autoScroll_timer){
  923. clearInterval(this.autoScroll_timer)
  924. this.autoScroll_timer = -1
  925. }
  926. if(this.autoScroll = enable ?? !this.autoScroll){
  927. let auto_download = confirm('捕获结束后是否开启自动下载?(不要最小化浏览器窗口!!!)')
  928. var auto_rename = false
  929. if(auto_download) auto_rename = confirm('下载前是否应用名称更改?')
  930. this.writeLog(`开启自动滚动捕获,自动下载【${auto_download ? '开' : '关'}】`)
  931. let _max = 10, _retry = 0
  932. const next = () => {
  933. let scrollContainer = Object.entries(this.DETAIL.scrollContainer ?? {}).find(([host, selector]) => new RegExp(host).test(location.href))
  934. if(scrollContainer){
  935. let container = document.querySelectorAll(scrollContainer[1])[0]
  936. if(container) container.scrollTop = container.scrollHeight
  937. }else{
  938. unsafeWindow.scrollTo(0, document.body.scrollHeight)
  939. }
  940. let _old = this.resources.length
  941. setTimeout(() => {
  942. let _new = this.resources.length
  943. if(_old == _new){
  944. this.writeLog(`没有捕获到视频,将会在重试${_max - _retry}次后结束`)
  945. if(_max - _retry++ <= 0){
  946. this.writeLog('成功捕获所有的视频')
  947. this.switchAutoScroll(false)
  948. if(auto_download){
  949. auto_rename && this.applyRenameAll()
  950. this.switchRunning(true)
  951. }
  952. return
  953. }
  954. }else{
  955. this.writeLog(`捕获到${_new - _old}个视频,当前视频总数${_new}`)
  956. this.updateTable()
  957. }
  958. setTimeout(() => next(), 500)
  959. }, 2000)
  960. }
  961. next()
  962. }else{
  963. this.writeLog(`开启关闭滚动捕获`)
  964. }
  965. },
  966.  
  967. setList(list, refresh = true){
  968. this.resources = list
  969. refresh && this.refresh()
  970. },
  971.  
  972. refresh(){
  973. this.showList()
  974. document.querySelector('#_ftb').innerHTML = createHTML(`下载 ${this.resources.length} 个视频`)
  975. },
  976.  
  977. bindVideoElement({callback, initElement}){
  978. return
  979. const observer = new MutationObserver((mutations) => {
  980. for (const mutation of mutations) {
  981. if (mutation.type !== 'childList') return
  982. mutation.addedNodes.forEach((node) => {
  983. if (node.nodeType === Node.ELEMENT_NODE && node.nodeName == 'VIDEO') {
  984. let {id} = initElement(node) || {}
  985. let item = this.findItem(id)
  986. if(!item) return
  987. let url = item.urls || node.currentSrc || node.querySelector('source')?.src
  988. // if(!url || url.startsWith('blob')){ }
  989. if(!node.querySelector('._btn_download')){
  990. let el = document.createElement('div')
  991. el.classList.className = '_btn_download'
  992. 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;'
  993. el.onclick = ev => {
  994. const onError = () => false && alert(`下载失败`)
  995. GM_download({
  996. url, name: this.safeFileName(item.title) + '.mp4', headers: this.getHeaders(url),
  997. onload: ({status}) => {
  998. if(status == 502 || status == 404){
  999. onError()
  1000. }
  1001. },
  1002. ontimeout: onError,
  1003. onerror: onError,
  1004. })
  1005. el.remove() & ev.stopPropagation(true) & ev.preventDefault()
  1006. }
  1007. el.innerHTML = createHTML('下载')
  1008. el.title = '点击下载'
  1009. node.before(el)
  1010. }
  1011. }
  1012. })
  1013. }
  1014. })
  1015. observer.observe(document.body, {
  1016. childList: true, // 观察子节点的增减
  1017. subtree: true // 观察后代节点
  1018. })
  1019. },
  1020.  
  1021. getHeaders(url){
  1022. return {
  1023. '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',
  1024. // 'Referer': url,
  1025. 'Range': 'bytes=0-',
  1026. 'Referer': location.protocol+'//'+ location.host
  1027. }
  1028. },
  1029.  
  1030. showList(){ // 展示主界面
  1031. let threads = this.options['threads']
  1032. this.showDialog({
  1033. id: '_dialog',
  1034. html: `
  1035. <div style="display: inline-flex;flex-wrap: wrap;width: 100%;justify-content: space-around;height: 5%;min-height: 30px;">
  1036. <div>
  1037. <button id="_selectAll">全选</button>
  1038. <button id="_reverSelectAll">反选</button>
  1039. <button id="_clear_log">清空日志</button>
  1040. </div>
  1041. <div>
  1042. 命名规则:
  1043. <input type="text" id="_filename" value="【{发布者}】{标题}({id})" title="允许的变量:{发布者} {标题} {id}">
  1044. <button id="_apply_filename">应用</button>
  1045. <button id="_apply_filename_help">帮助</button>
  1046. </div>
  1047. <div>
  1048. 下载线程数:
  1049. <input id="_threads" type="range" value=${threads} step=1 min=1 max=32>
  1050. <span id="_threads_span">${threads}</span>
  1051. <span style="margin-right: 10px;">Aria2下载</span><input type="checkbox" data-for="useAria2c" ${this.options.useAria2c ? 'checked': ''}>
  1052. </div>
  1053. <div>
  1054. <button id="_settings">设置</button>
  1055. <button id="_autoScroll">滚动捕获</button>
  1056. <button id="_clearDownloads">清空已下载</button>
  1057. <button id="_reDownloads">重新下载</button>
  1058. <button id="_switchRunning" disabled>开始</button>
  1059. </div>
  1060. </div>
  1061. <div style="height: 70%;overflow-y: scroll;">
  1062. <table width="90%" border="2" style="margin: 0 auto;"></table>
  1063. </div>
  1064. <div style="height: 25%; width: 100%;border-top: 2px solid white;">
  1065. <div style="position: relative;height: 100%;">
  1066. <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>
  1067. <pre id="_log" style="background-color: rgba(255, 255, 255, .2);color: rgba(0, 0, 0, .8);overflow-y: scroll;height: 90%;"></pre>
  1068. </div>
  1069. </div>`,
  1070. callback: dialog => {
  1071. if(!this.aria2c) this.enableAria2c(this.options.useAria2c)
  1072. this.initInputs(dialog) & this.updateTable()
  1073. this.tryAutoRenameAll()
  1074. },
  1075. onClose: () => this.resources.forEach(item => item.status = WAITTING)
  1076. }) & this.bindEvents() & [
  1077. `欢迎使用本脚本!当前版本: ${VERSION} 发布日期: ${RELEASE_DATE}`,
  1078. `此脚本仅供学习交流使用!!请勿用于非法用途!`
  1079. ].forEach(msg => this.writeLog(msg, '声明')) & this.loadDownloader()
  1080. },
  1081. loadDownloader(){
  1082. this.writeLog('正在加载下载功能模块...')
  1083. 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'], () => {
  1084. this.writeLog('加载下载功能模块成功!')
  1085. $('#_switchRunning')[0].disabled = false
  1086. /*unsafeWindow.onunload = () => {
  1087. writableStream.abort()
  1088. writer.abort()
  1089. }
  1090. unsafeWindow.onbeforeunload = evt => {
  1091. if (!done) {
  1092. evt.returnValue = `Are you sure you want to leave?`;
  1093. }
  1094. }*/
  1095. })
  1096. },
  1097. updateTable(){
  1098. let cnt = this.resources.length
  1099. console.log(cnt, this.options.show_img_limit)
  1100. $('table')[0].innerHTML = createHTML(`
  1101. <tr align="center">
  1102. <th>编号</th>
  1103. <th>选中</th>
  1104. <th>封面</th>
  1105. <th>标题</th>
  1106. <th>状态</th>
  1107. </tr>
  1108. ${this.resources.map((item, index) => {
  1109. let {urls, title, cover, url, id} = item || {}
  1110. return `
  1111. <tr align="center" data-id="${id}">
  1112. <td style="width: 50px;">${index+1}<p><a href="#" data-action="addDownload" style="color:blue">下载</a></p></td>
  1113. <td style="width: 50px;"><input type="checkbox" style="transform: scale(1.5);" checked></td>
  1114. <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>
  1115. <td contenteditable style="width: 400px;max-width: 400px;">${title}</td>
  1116. <td style="width: 100px;">等待中...</td>
  1117. </tr>`
  1118.  
  1119. }).join('')}`)
  1120. },
  1121. getDialog(id){
  1122. return document.querySelector('#'+id)
  1123. },
  1124. isShowing(id = '_dialog'){
  1125. return this.getDialog(id) !== null
  1126. },
  1127. showDialog({html, id, callback, onClose}){ // 弹窗
  1128. let dialog = this.getDialog(id)
  1129. dialog && dialog.remove()
  1130.  
  1131. document.body.insertAdjacentHTML('beforeEnd', createHTML(`
  1132. <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>
  1133. <a href="#" style="position: absolute;right: 20px;top: 20px;padding: 10px;background-color: rgba(255, 255, 255, .4);" class="_dialog_close">X</a>
  1134. ${html}
  1135. <dialog>`))
  1136. setTimeout(() => {
  1137. let dialog = this.getDialog(id)
  1138. dialog.querySelector('._dialog_close').onclick = () => dialog.remove() & (onClose && onClose())
  1139. callback && callback(dialog)
  1140. }, 500)
  1141. },
  1142. applyRenameAll(){
  1143. let format = $('#_filename')[0].value
  1144. this.saveOptions({lastRename: format})
  1145. for(let tr of $('table tr[data-id]')){
  1146. this.applyRename(tr.dataset.id, tr, format)
  1147. }
  1148. },
  1149. applyRename(tid, tr, format){
  1150. tr ??= this.findElement(tid)
  1151. if(!tr) return
  1152. let item = this.findItem(tid)
  1153. if(!item) return
  1154. format ??= $('#_filename')[0].value
  1155. if(typeof(format) != 'string' || format == '') return
  1156. let {title, author_name, id, create_time} = Object.assign(item, {renamed: true})
  1157. let s = format.replace('{标题}', title ?? '').replace('{id}', id).replace('{发布者}', author_name ?? '')
  1158. if(create_time){
  1159. s = new Date(create_time).format(s)
  1160. }
  1161. tr.querySelector('td[contenteditable]').innerHTML = createHTML(s)
  1162. },
  1163. bindEvents(){ // 绑定DOM事件
  1164. $('#_threads')[0].oninput = function(ev){
  1165. $('#_threads_span')[0].innerHTML = createHTML(this.value)
  1166. }
  1167. $('#_apply_filename')[0].onclick = () => this.applyRenameAll() & (['www.xiaohongshu.com'].includes(location.host) && alert("请注意:小红书网站上日期规则预览不会立刻生效,只有在开始下载的时候才会生效!"))
  1168. $('#_apply_filename_help')[0].onclick = () => this.showDialog({
  1169. id: '_dialog_rename_help',
  1170. html: `
  1171. <p>
  1172. <h1>变量<h1>
  1173. <h3>{标题} {id} {发布者} yyyyMMdd_hhmmss秒<h3>
  1174. </p>
  1175. <p>
  1176. <h1>常见问题<h1>
  1177. <h3>
  1178. <pre>
  1179. 为什么没有显示入口按钮?(可能是脚本插入时机慢了,可以多滚动或者多刷新几次)
  1180. 为什么下载显示失败(常见于抖音,抖音每个视频有三个线路,但并不是每个线路都是有视频存在的。所以目前的解决是 每个线路都尝试下载一次)
  1181. 为什么捕获的数量不等于主页作品数量(目前只能捕获视频作品,而非图文作品)
  1182. 为什么只能下载一个文件?(请检查网站是否有开启允许同时下载多个文件选项)
  1183. 为什么只能捕获一页的数据/翻页不了(有些不常用的站点可能存在这些问题待修复)
  1184. </pre>
  1185. <h3>
  1186. </p>
  1187. <p>
  1188. <h1>测试页面<h1>
  1189. <h3>
  1190. <pre>
  1191. https://isee.weishi.qq.com/ws/app-pages/wspersonal/index.html?id=1538201906643006
  1192. https://www.douyin.com/user/MS4wLjABAAAANfnAjG-xB__cCOB4hTXFBvG6yZFWNl-FkgCWvpwGN2M
  1193. https://www.douyin.com/search/%E6%88%91%E4%BB%AC
  1194. https://www.kuaishou.com/profile/3xqyyjytuef8nsq
  1195. https://www.tiktok.com/@simonboyyyyyyy
  1196. https://www.xiaohongshu.com/user/profile/60f0ecec0000000001004874
  1197. https://www.instagram.com/rohman__oficial/
  1198. https://weibo.com/u/2328516855?tabtype=newVideo
  1199. https://x.com/pentarouX/media
  1200. https://www.toutiao.com/c/user/token/MS4wLjABAAAAzCbyoWKVhqhvIgUd49i5o43v4-YcICXye1glC0Xefok/?entrance_gid=7417305773065929267&log_from=f6060c90895cc_1727227709729&tab=video
  1201. </pre>
  1202. <h3>
  1203. </p>
  1204. <p>
  1205. <h1>使用Aria2c下载<h1>
  1206. <h3>
  1207. <pre>
  1208. 如何安装? https://wwas.lanzouj.com/b032c68ozc 密码:36yz 下载解压,双击bat文件开启
  1209. </pre>
  1210. <h3>
  1211. </p>
  1212. `,
  1213. })
  1214. $('#_selectAll')[0].onclick = () => $('table input[type=checkbox]').forEach(el => el.checked = true)
  1215. $('#_reverSelectAll')[0].onclick = () => $('table input[type=checkbox]').forEach(el => el.checked = !el.checked)
  1216. $('#_clear_log')[0].onclick = () => $('#_log')[0].innerHTML = createHTML('')
  1217. $('#_switchRunning')[0].onclick = () => this.switchRunning()
  1218. $('#_autoScroll')[0].onclick = () => this.switchAutoScroll()
  1219. $('#_settings')[0].onclick = () => {
  1220. this.showDialog({
  1221. id: '_dialog_settings',
  1222. html: `
  1223. <div style="display: flex;width: 100%;gap: 20px;">
  1224. <div>
  1225. <h3>线路设置</h3>
  1226. ${Object.values(this.HOSTS).map(({hosts, title, id}) => {
  1227. hosts ??= []
  1228. let html = `${title}线路: <select data-for="${id}">${hosts.map(host => `<option ${this.options[id+'_host'] == host ? 'selected' : ''}>${host}</option>`).join('')}</select>`
  1229. return hosts.length ? html : ''}).join('')}
  1230. </div>
  1231. <div>
  1232. <h3>下载设置</h3>
  1233. <div>下载结束提示<input type="checkbox" data-for="alert_done" ${this.options.alert_done ? 'checked': ''}></div>
  1234. <div>自动重命名<input type="checkbox" data-for="autoRename" ${this.options.autoRename ? 'checked': ''}></div>
  1235. <div>超时时间(毫秒): <input type="number" value="${this.options.timeout}" data-for="timeout"></div>
  1236. <div>重试次数: <input type="number" value="${this.options.retry_max}" data-for="retry_max"></div>
  1237. <div>封面超过不显示: <input type="number" value="${this.options.show_img_limit}" data-for="show_img_limit"></div>
  1238. </div>
  1239. <div>
  1240. <h3>数据设置</h3>
  1241. <div>
  1242. <button data-action="exportData">导出数据</button>
  1243. <button data-action="exportUrls">导出视频链接</button>
  1244. <button data-action="importData">导入数据</button>
  1245. </div>
  1246. </div>
  1247. <div>
  1248. <h3>Aria2c设置</h3>
  1249. <div>
  1250. <div>端口: <input type="number" value="${this.options.aria2c_port}" data-for="aria2c_port"></div>
  1251. <div>保存目录: <input type="text" value="${this.options.aria2c_saveTo}" data-for="aria2c_saveTo"></div>
  1252. </div>
  1253. </div>
  1254. </div>
  1255. `,
  1256. callback: dialog => this.initInputs(dialog),
  1257. onClose: () => this.resources = this.resources.map(item => this.DETAIL.rules[item.rule_index].parseItem(item.data))
  1258. })
  1259. }
  1260. $('#_clearDownloads')[0].onclick = () => this.clearDownloads()
  1261. $('#_reDownloads')[0].onclick = () => this.reDownloads()
  1262. },
  1263. initAction(){
  1264. const onEvent = ev => {
  1265. let {srcElement} = ev
  1266. let {action} = srcElement.dataset
  1267. switch(action){
  1268. case 'addDownload':
  1269. let par = getParent(srcElement, el => el?.dataset?.id)
  1270. if(par){
  1271. this.downloadItem(this.findItem(par.dataset.id), true)
  1272. }
  1273. return
  1274. case 'exportUrls':
  1275. return this.addDownload({
  1276. url: URL.createObjectURL(new Blob([flattenArray(this.resources.map(({urls}) => Array.isArray(urls) ? urls.map(({url}) => url) : urls)).join("\r\n")])),
  1277. name: '导出链接.txt'
  1278. })
  1279. case 'exportData':
  1280. // todo csv
  1281. if(!this.resources.length) return alert('没有任何数据')
  1282. return this.addDownload({
  1283. url: URL.createObjectURL(new Blob([JSON.stringify(this.resources)])),
  1284. name: '导出数据.txt'
  1285. })
  1286. case 'importData':
  1287. return openFileDialog({
  1288. accept: '.txt',
  1289. callback: files => {
  1290. let reader = new FileReader()
  1291. reader.readAsText(files[0])
  1292. reader.onload = e => {
  1293. try {
  1294. json = JSON.parse(reader.result)
  1295. let cnt = json.length
  1296. if(cnt){
  1297. if(confirm(`发现${cnt}条数据!是否重置下载状态?`)) json = json.map(item => Object.assign(item, {status: WAITTING}))
  1298. this.setList(json) & this.writeLog('成功导入数据')
  1299. }
  1300. } catch (err) {
  1301. alert(err.toString())
  1302. }
  1303. }
  1304. }
  1305. })
  1306. default:
  1307. return
  1308. }
  1309. ev.stopPropagation(true) & ev.preventDefault()
  1310. }
  1311. document.body.addEventListener('click', onEvent)
  1312. },
  1313. initInputs(dialog){
  1314. const self = this
  1315. for(let select of dialog.querySelectorAll('select')) select.onchange = function(){
  1316. self.saveOptions({[`${this.dataset.for}_host`]: this.value})
  1317. }
  1318. for(let input of dialog.querySelectorAll('input')) input.onchange = function(){
  1319. let value, key = this.dataset.for
  1320. switch(this.type){
  1321. case 'checkbox':
  1322. case 'switch':
  1323. value = this.checked
  1324. break
  1325. default:
  1326. value = this.value
  1327. }
  1328. self.saveOptions({[key]: value})
  1329. if(key == 'useAria2c') self.enableAria2c(value)
  1330. }
  1331. },
  1332. clearDownloads(){
  1333. this.eachItems(DOWNLOADED, ({tr, item, index}) => {
  1334. this.resources.splice(index, 1)
  1335. tr && tr.remove()
  1336. })
  1337. },
  1338. reDownloads(){
  1339. this.cancelDownloads()
  1340. let cnt = this.eachItems([DOWNLOADING, ERROR], ({tr, item}) => {
  1341. if(tr){
  1342. let td = tr.querySelectorAll('td')
  1343. td[4].style.backgroundColor = 'unset'
  1344. td[4].innerHTML = createHTML('等待中...')
  1345. }
  1346. item.status = WAITTING
  1347. }).length
  1348. cnt ? this.writeLog(`重新下载${cnt}个视频`) & this.switchRunning(true) : alert('没有需要重新下载的任务')
  1349. },
  1350. cancelDownloads(){
  1351. Object.keys(this.downloads).forEach(id => this.removeDownload(id))
  1352. this.writeLog(`成功取消所有下载`)
  1353. },
  1354. eachItems(status_id, callback){
  1355. let ret = []
  1356. status_id = toArr(status_id)
  1357. for(let i=this.resources.length-1;i>=0;i--){
  1358. let item = this.resources[i]
  1359. ret.push(item)
  1360. let {status, id} = item
  1361. if(status_id.includes(status)){
  1362. let tr = this.findElement(id)
  1363. callback({tr, item, index: i})
  1364. }
  1365. }
  1366. return ret
  1367. },
  1368. checkFinishTimer: -1,
  1369. switchRunning(running){ // 切换运行状态
  1370. this.running = running ??= !this.running
  1371. $('#_switchRunning')[0].innerHTML = createHTML(running ? '暂停' : '运行')
  1372. if(running){
  1373. let threads = parseInt($('#_threads')[0].value)
  1374. let cnt = threads - this.getItems(DOWNLOADING).length
  1375. if(cnt){
  1376. this.writeLog('开始线程下载:'+cnt)
  1377. this.saveOptions({threads})
  1378. for(let i=0;i<cnt;i++) this.nextDownload()
  1379. }
  1380. }
  1381. },
  1382. getItems(_status){ // 获取指定状态任务
  1383. return this.resources.filter(({status}) => status == _status)
  1384. },
  1385. getDownloadName(id){
  1386. let tr = this.findElement(id)
  1387. if(tr){
  1388. let td = tr.querySelectorAll('td')
  1389. return td[3].outerText
  1390. }
  1391. return null
  1392. },
  1393. downloadItem(item, checked){
  1394. let {status, id, urls, rule_index, downloadTool} = item
  1395. if(status == WAITTING){
  1396. let tr = this.findElement(id)
  1397. if(!tr) return
  1398.  
  1399. let td = tr.querySelectorAll('td')
  1400. checked ??= td[1].querySelector('input[type=checkbox]').checked
  1401. if(checked){
  1402. item.status = DOWNLOADING
  1403. const log = ({msg, color, next = true, status}) => {
  1404. this.writeLog(msg, `<a href="${item.url}" target="_blank" style="color: white;">${this.safeFileName(item.title)}</a>`, color)
  1405. status ??= {success: DOWNLOADED, error: ERROR}[color]
  1406. this.setItemStatus({id, color, msg, el: tr, item, status})
  1407. if(next) this.nextDownload()
  1408. }
  1409. log({msg: '正在下载', color: 'primary', next: false})
  1410.  
  1411. // 预先下载并尝试重试(多线程下需要重试才能正常下载)
  1412. let retry = 0
  1413. const httpRequest = url => {
  1414. toArr(url).forEach(download => {
  1415. if(typeof(download) == 'string') download = {url: download, type: 'video', title: item.title}
  1416. var {url} = download
  1417. const done = (url, headers) => this.addDownload({
  1418. download, url, id, headers, downloadTool,
  1419. error: msg => log({msg, color: 'error'}),
  1420. success: msg => log({msg, color: 'success'}),
  1421. })
  1422. return done(url)
  1423. /*
  1424. if(this.aria2c){
  1425. done(url)
  1426. }else{
  1427. GM_xmlhttpRequest({
  1428. method: "GET", url, headers: this.getHeaders(url),
  1429. redirect: 'follow',
  1430. //responseType: "blob",
  1431. timeout: this.options.timeout,
  1432. anonymous: true,
  1433. onload: ({status, response, finalUrl}) => {
  1434. console.log({status, finalUrl, response})
  1435. if (status === 200) {
  1436. if(!response){
  1437. if(!finalUrl) return log({msg: `请求错误`, color: 'error'})
  1438. done(finalUrl)
  1439. }else{
  1440. done(blobUrl)
  1441. }
  1442. }else
  1443. if(retry++ < this.options.retry_max){
  1444. // console.log('下载失败,重试中...', urls)
  1445. setTimeout(() => httpRequest(), 500)
  1446. }else{
  1447. log({msg: `重试下载错误`, color: 'error'})
  1448. }
  1449. },
  1450. onerror: err => console.error({msg: '获取链接失败', err}) & done(url)
  1451. })
  1452. }*/
  1453. })
  1454. }
  1455. if(!urls){
  1456. let getVideoURL = this.DETAIL[rule_index]?.getVideoURL || this.DETAIL.getVideoURL
  1457. if(!getVideoURL) return log({msg: `无下载地址`, color: 'error'})
  1458. getVideoURL(item).then(urls => {
  1459. if(item.renamed){ // 获取详细信息后再改变名称
  1460. delete item.renamed
  1461. this.applyRename(item.id)
  1462. }
  1463. httpRequest(Object.assign(item, {urls}).urls)
  1464. })
  1465. }else{
  1466. httpRequest(urls)
  1467. }
  1468. return true
  1469. }
  1470. }
  1471. },
  1472. nextDownload(){ // 进行下一次下载
  1473. if(!this.running) return
  1474. let {resources} = this
  1475. if(!resources.some(item => this.downloadItem(item))){
  1476. if(this.running){
  1477. clearInterval(this.checkFinishTimer)
  1478. this.checkFinishTimer = setInterval(() => {
  1479. if(this.getItems(WAITTING).length == 0 && this.getItems(DOWNLOADING).length == 0){
  1480. clearInterval(this.checkFinishTimer)
  1481. this.switchRunning(false)
  1482. let msg = '所有任务下载完成!'
  1483. this.writeLog(msg) & (this.options.alert_done && alert(msg))
  1484. }
  1485. }, 1000)
  1486. }
  1487. }
  1488. },
  1489. findElement: id => $(`tr[data-id="${id}"]`)[0], // 根据Id查找dom
  1490. writeLog(msg, prefix = '提示', color = 'info'){ // 输出日志
  1491. let div = $('#_log')[0]
  1492. div.insertAdjacentHTML('beforeEnd', createHTML(`<p style="color: ${this.getColor(color)}">【${prefix}】 ${msg}</p>`))
  1493. if(this.options.autoScroll) div.scrollTop = div.scrollHeight
  1494. },
  1495. getColor: color => ({success: '#8bc34a', error: '#a31545', info: '#fff', primary: '#3fa9fa' })[color] || color,
  1496. setItemStatus({id, color, msg, el, item, status}){
  1497. item ??= this.findItem(id)
  1498. if(!item) return
  1499. if(status !== undefined) item.status = status
  1500. if(el === false) return
  1501. el ??= this.findElement(id)
  1502. let td = el.querySelectorAll('td')
  1503. if(td[4]){
  1504. td[4].style.backgroundColor = this.getColor(color)
  1505. td[4].innerHTML = createHTML(msg)
  1506. }
  1507. },
  1508. findItem(id, method = 'find'){ // 根据Item查找资源信息
  1509. return this.resources[method](_item => _item.id == id)
  1510. },
  1511. safeFileName: str => str.replaceAll('\n', ' ').replaceAll('(', '(').replaceAll(')', ')').replaceAll(':', ':').replaceAll('*', '*').replaceAll('?', '?').replaceAll('"', '"').replaceAll('<', '<').replaceAll('>', '>').replaceAll("|", "|").replaceAll('\\', '\').replaceAll('/', '/')
  1512. }
  1513. _downloader.init()
  1514.  
  1515. function Base64() {
  1516. // private property
  1517. _keyStr = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
  1518. // public method for encoding
  1519. this.encode = function (input) {
  1520. var output = "";
  1521. var chr1, chr2, chr3, enc1, enc2, enc3, enc4;
  1522. var i = 0;
  1523. input = _utf8_encode(input);
  1524. while (i < input.length) {
  1525. chr1 = input.charCodeAt(i++);
  1526. chr2 = input.charCodeAt(i++);
  1527. chr3 = input.charCodeAt(i++);
  1528. enc1 = chr1 >> 2;
  1529. enc2 = ((chr1 & 3) << 4) | (chr2 >> 4);
  1530. enc3 = ((chr2 & 15) << 2) | (chr3 >> 6);
  1531. enc4 = chr3 & 63;
  1532. if (isNaN(chr2)) {
  1533. enc3 = enc4 = 64;
  1534. } else if (isNaN(chr3)) {
  1535. enc4 = 64;
  1536. }
  1537. output = output +
  1538. _keyStr.charAt(enc1) + _keyStr.charAt(enc2) +
  1539. _keyStr.charAt(enc3) + _keyStr.charAt(enc4);
  1540. }
  1541. return output;
  1542. }
  1543. // public method for decoding
  1544. this.decode = function (input) {
  1545. var output = "";
  1546. var chr1, chr2, chr3;
  1547. var enc1, enc2, enc3, enc4;
  1548. var i = 0;
  1549. input = input.replace(/[^A-Za-z0-9\+\/\=]/g, "");
  1550. while (i < input.length) {
  1551. enc1 = _keyStr.indexOf(input.charAt(i++));
  1552. enc2 = _keyStr.indexOf(input.charAt(i++));
  1553. enc3 = _keyStr.indexOf(input.charAt(i++));
  1554. enc4 = _keyStr.indexOf(input.charAt(i++));
  1555. chr1 = (enc1 << 2) | (enc2 >> 4);
  1556. chr2 = ((enc2 & 15) << 4) | (enc3 >> 2);
  1557. chr3 = ((enc3 & 3) << 6) | enc4;
  1558. output = output + String.fromCharCode(chr1);
  1559. if (enc3 != 64) {
  1560. output = output + String.fromCharCode(chr2);
  1561. }
  1562. if (enc4 != 64) {
  1563. output = output + String.fromCharCode(chr3);
  1564. }
  1565. }
  1566. output = _utf8_decode(output);
  1567. return output;
  1568. }
  1569. // private method for UTF-8 encoding
  1570. _utf8_encode = function (string) {
  1571. string = string.replace(/\r\n/g,"\n");
  1572. var utftext = "";
  1573. for (var n = 0; n < string.length; n++) {
  1574. var c = string.charCodeAt(n);
  1575. if (c < 128) {
  1576. utftext += String.fromCharCode(c);
  1577. } else if((c > 127) && (c < 2048)) {
  1578. utftext += String.fromCharCode((c >> 6) | 192);
  1579. utftext += String.fromCharCode((c & 63) | 128);
  1580. } else {
  1581. utftext += String.fromCharCode((c >> 12) | 224);
  1582. utftext += String.fromCharCode(((c >> 6) & 63) | 128);
  1583. utftext += String.fromCharCode((c & 63) | 128);
  1584. }
  1585. }
  1586. return utftext;
  1587. }
  1588. // private method for UTF-8 decoding
  1589. _utf8_decode = function (utftext) {
  1590. var string = "";
  1591. var i = 0;
  1592. var c = c1 = c2 = 0;
  1593. while ( i < utftext.length ) {
  1594. c = utftext.charCodeAt(i);
  1595. if (c < 128) {
  1596. string += String.fromCharCode(c);
  1597. i++;
  1598. } else if((c > 191) && (c < 224)) {
  1599. c2 = utftext.charCodeAt(i+1);
  1600. string += String.fromCharCode(((c & 31) << 6) | (c2 & 63));
  1601. i += 2;
  1602. } else {
  1603. c2 = utftext.charCodeAt(i+1);
  1604. c3 = utftext.charCodeAt(i+2);
  1605. string += String.fromCharCode(((c & 15) << 12) | ((c2 & 63) << 6) | (c3 & 63));
  1606. i += 3;
  1607. }
  1608. }
  1609. return string;
  1610. }
  1611. }
  1612.