// ==UserScript==
// @name ASMR Online 一键下载
// @name:zh-CN ASMR Online 一键下载
// @name:en ASMR Online Work Downloader
// @namespace ASMR-ONE
// @version 1.0.0
// @description 一键下载asmr.one上的整个作品(或者选择文件下载),包括全部的文件和目录结构
// @description:zh-CN 一键下载asmr.one上的整个作品(或者选择文件下载),包括全部的文件和目录结构
// @description:en Download all(selected) folders and files for current work on asmr.one in one click, preserving folder structures
// @author PY-DNG
// @license MIT
// @match https://www.asmr.one/*
// @match https://www.asmr-100.com/*
// @match https://www.asmr-200.com/*
// @match https://www.asmr-300.com/*
// @match https://asmr.one/*
// @match https://asmr-100.com/*
// @match https://asmr-200.com/*
// @match https://asmr-300.com/*
// @connect asmr.one
// @connect asmr-100.com
// @connect asmr-200.com
// @connect asmr-300.com
// @require https://update.gf.qytechs.cn/scripts/456034/1532680/Basic%20Functions%20%28For%20userscripts%29.js
// @require https://update.gf.qytechs.cn/scripts/458132/1138364/ItemSelector.js
// @icon https://www.asmr.one/statics/app-logo-128x128.png
// @grant GM_download
// @grant GM_registerMenuCommand
// @grant GM_xmlhttpRequest
// ==/UserScript==
/* eslint-disable no-multi-spaces */
/* eslint-disable no-return-assign */
/* global LogLevel DoLog Err $ $All $CrE $AEL $$CrE addStyle detectDom destroyEvent copyProp copyProps parseArgs escJsStr replaceText getUrlArgv dl_browser dl_GM AsyncManager queueTask FunctionLoader loadFuncs require isLoaded */
/* global ItemSelector */
(function __MAIN__() {
'use strict';
const CONST = {
HTML: {
DownloadButton: `
<button tabindex="0" type="button" id="download-btn"
class="q-btn q-btn-item non-selectable no-outline q-btn--standard q-btn--rectangle bg-cyan q-mt-sm shadow-4 q-mx-xs q-px-sm text-white q-btn--actionable q-focusable q-hoverable q-btn--wrap q-btn--dense">
<span class="q-focus-helper"></span><span class="q-btn__wrapper col row q-anchor--skip"><span
class="q-btn__content text-center col items-center q-anchor--skip justify-center row"><span class="block" id="download-btn-inner">DOWNLOAD</span></span></span>
</button>
`
},
Text: {
DownloadFolder: 'ASMR-ONE',
WorkFolder: '{RJ} - {WorkName}',
DownloadButton: 'Download',
DownloadButton_Working: 'Downloading({Done}/{All})',
DownloadButton_Done: 'Download(Finished)',
SelectDownloadFiles: '选择下载的文件:',
RootFolder: 'Root',
Prefix_File: '[文件] ',
Prefix_Folder: '[文件夹] ',
NoTitle: 'No Title'
},
Number: {
Max_Download: 2,
GUITextChangeDelay: 1500
}
}
loadFuncs([{
id: 'utils',
func() {
const win = typeof unsafeWindow === 'object' && unsafeWindow !== null ? unsafeWindow : window;
function htmlElm(html) {
const parent = $CrE('div');
parent.innerHTML = html;
return parent.children.length > 1 ? Array.from(parent.children) : parent.children[0];
}
function getOSSep() {
return ({
'Windows': '\\',
'Mac': '/',
'Linux': '/',
'Null': '-'
})[getOS()];
}
function getOS() {
const info = (navigator.platform || navigator.userAgent).toLowerCase();
const test = (s) => (info.includes(s));
const map = {
'Windows': ['window', 'win32', 'win64', 'win86'],
'Mac': ['mac', 'os x'],
'Linux': ['linux']
}
for (const [sys, strs] of Object.entries(map)) {
if (strs.some(test)) {
return sys;
}
}
return 'Null';
}
// Returns a random string
function randstr(length=16, nums=true, cases=true) {
const all = 'abcdefghijklmnopqrstuvwxyz' + (nums ? '0123456789' : '') + (cases ? 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' : '');
return Array(length).fill(0).reduce(pre => (pre += all.charAt(randint(0, all.length-1))), '');
}
function randint(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function cloneObject(obj) {
return window.structuredClone?.(obj) ?? JSON.parse(JSON.stringify(obj));
}
// Save text to textfile
function downloadText(text, name) {
if (!text || !name) {return false;};
const blob = new Blob([text], { type:"text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
dl_browser(url, name);
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
return {
window: win,
htmlElm, getOSSep, getOS, randstr, randint, cloneObject, downloadText
}
}
}, {
id: 'debug',
dependencies: 'utils',
func() {
const utils = require('utils');
GM_registerMenuCommand('导出调试包', debugInfo);
function debugInfo() {
const win = utils.window;
const DebugInfo = {
version: GM_info.script.version,
GM_info: GM_info,
platform: navigator.platform,
userAgent: navigator.userAgent,
getOS: utils.getOS(),
getOSSep: utils.getOSSep(),
url: location.href,
topurl: win.top.location.href,
iframe: win.top !== win,
languages: [...navigator.languages],
timestamp: (new Date()).getTime()
};
// Log in console
DoLog(LogLevel.Debug, '=== Userscript [' + GM_info.script.name + '] debug info ===');
DoLog(LogLevel.Debug, DebugInfo);
DoLog(LogLevel.Debug, '=== /Userscript [' + GM_info.script.name + '] debug info ===');
// Save to file
utils.downloadText(JSON.stringify(DebugInfo), 'Debug Info_' + GM_info.script.name + '_' + (new Date()).getTime().toString() + '.json');
}
return { debugInfo };
}
}, {
id: 'item-selector',
desc: 'Initialize an ItemSelector instance and return it as is',
detectDom: 'body',
func() {
const IS = new ItemSelector();
const observer = new MutationObserver(setTheme);
observer.observe(document.body, {attributes: true, attributeFilter: ['class']});
setTheme();
return IS;
function setTheme() {
IS.setTheme([...document.body.classList].includes('body--dark') ? 'dark' : 'light');
}
}
}, {
id: 'api',
func() {
function tracks(id) {
return callApi({
endpoint: `tracks/${id}`
});
}
/**
* callApi detail object
* @typedef {Object} api_detail
* @property {string} endpoint - api endpoint
* @property {Object} [search] - search params
* @property {string} [method='GET']
*/
/**
* Do basic asmr-online api request
* This is the queued version of _callApi
* @param {api_detail} detail
* @returns
*/
function callApi(...args) {
return queueTask(() => _callApi(...args), 'callApi');
}
/**
* Do basic asmr-online api request
* @param {api_detail} detail
* @returns
*/
function _callApi(detail) {
const host = `api.${location.host.match(/(?:[^.]+\.)?([^.]+\.[^.]+)/)[1]}`;
const search_string = new URLSearchParams(detail.search).toString();
const url = `https://${host}/api/${detail.endpoint.replace(/^\//, '')}` + (search_string ? '?' + search_string : '');
const method = detail.method ?? 'GET';
return new Promise((resolve, reject) => {
const options = {
method, url,
headers: {
accept: 'application/json, text/plain, */*'
},
onload(e) {
try {
e.status === 200 ? resolve(JSON.parse(e.responseText)) : reject(e.responseText);
} catch(err) {
reject(err);
}
},
onerror: err => reject(err)
}
GM_xmlhttpRequest(options);
});
}
return {
tracks,
callApi
};
}
}, {
id: 'main',
dependencies: ['utils', 'api', 'item-selector'],
func() {
const utils = require('utils');
const api = require('api');
const IS = require('item-selector');
detectDom({
selector: '#work-tree',
callback: e => pageWork()
});
async function pageWork() {
// Make button
const downloadBtn = utils.htmlElm(CONST.HTML.DownloadButton);
const downloadBtn_inner = $(downloadBtn, '#download-btn-inner');
(await detectDom(".q-page-container .q-pa-sm")).append(downloadBtn);
$AEL(downloadBtn, 'click', batchDownload);
async function batchDownload() {
const count = {done: 0, all: 0};
const DATA = 'Original-Item-Properties-Data_' + utils.randstr();
const list = await api.tracks(getid());
const json = list2json(list);
IS.show(json, {
title: CONST.Text.SelectDownloadFiles,
onok: (e, json) => {
const list = json2list(json);
list.forEach(item => dealItem(item));
}
});
function list2json(list) {
list = structuredClone(list);
const json = {text: CONST.Text.RootFolder, children: [], [DATA]: {}};
json.children.push(...list.map(item => convert(item)));
return json;
function convert(item) {
const json = {};
switch (item.type) {
case 'folder': {
json.text = CONST.Text.Prefix_Folder + item.title;
json.children = item.children.map(child => convert(child));
break;
}
case 'audio':
case 'text':
case 'image':
case 'other': {
json.text = CONST.Text.Prefix_File + item.title;
break;
}
default:
//debugger;
DoLog(LogLevel.Warning, 'Unknown item type: ' + item.type);
}
json[DATA] = item;
delete json[DATA].children;
return json;
}
}
function json2list(json) {
if (json === null) {return [];}
json = structuredClone(json);
const root_item = convert(json);
const list = root_item.children;
return list;
function convert(json) {
const item = json[DATA];
if (Array.isArray(json.children)) {
item.children = [];
for (const child of json.children) {
item.children.push(convert(child));
}
}
return item;
}
}
function dealItem(item, path=[]) {
switch (item.type) {
case 'folder': {
for (const child of item.children) {
dealItem(child, path.concat([item.title]));
}
break;
}
case 'audio':
case 'text':
case 'image':
case 'other': {
const sep = utils.getOSSep();
const _sep = ({'/': '/', '\\': '\'})[sep];
const url = item.mediaDownloadUrl;
const RJ = location.pathname.split('/').pop();
const name = [CONST.Text.DownloadFolder].concat([replaceText(CONST.Text.WorkFolder, {'{RJ}': RJ, '{WorkName}': item.workTitle || CONST.Text.NoTitle})]).concat(path).concat([item.title]).map((name) => (name.replaceAll(sep, _sep))).join(sep);
DoLog([name, url, item]);
dl(url, name);
count.all++;
display();
break;
}
default:
//debugger;
DoLog(LogLevel.Warning, 'Unknown item type: ' + item.type);
}
}
function dl(url, name, retry=3) {
GM_download({
method: 'GET',
url: url,
name: name,
onload: function(e) {
count.done++;
display();
},
onerror: function() {
debugger;
--retry > 0 && dl(url, name, retry);
},
ontimeout: err => {debugger;},
onabort: err => {debugger;}
});
}
function display() {
downloadBtn_inner.innerText = replaceText(CONST.Text.DownloadButton_Working, {'{Done}': count.done, '{All}': count.all});
count.done === count.all && setTimeout(() => (downloadBtn_inner.innerText = CONST.Text.DownloadButton_Done), CONST.Number.GUITextChangeDelay);
}
}
}
function getid() {
return location.pathname.split('/').pop().substring(2);
}
}
}]);
})();