Kemono助手

提供更好的Kemono使用体验

От 12.03.2024. Виж последната версия.

  1. // ==UserScript==
  2. // @name Kemono助手
  3. // @version 2.0.16
  4. // @description 提供更好的Kemono使用体验
  5. // @author ZIDOUZI
  6. // @match https://*.kemono.party/*
  7. // @match https://*.kemono.su/*
  8. // @icon https://kemono.su/static/favicon.ico
  9. // @require https://cdn.jsdelivr.net/npm/sweetalert2@11
  10. // @require https://kit.fontawesome.com/101092ae56.js
  11. // @grant GM_xmlhttpRequest
  12. // @grant GM_getValue
  13. // @grant GM_setValue
  14. // @grant GM_registerMenuCommand
  15. // @grant GM_unregisterMenuCommand
  16. // @license GPL-3.0
  17. // @namespace https://greasyfork.org/users/448292
  18. // ==/UserScript==
  19. (async function () {
  20. 'use strict';
  21. await main().catch(function (error) {
  22. console.log(error)
  23. alert(`Kemono助手发生错误: ${error.status} ${error.statusText}`)}
  24. );
  25. })();
  26. async function main() {
  27. const language = navigator.language || navigator.userLanguage;
  28. const domain = window.location.href.match(/https:\/\/([^/]+)/)[1];
  29. const menuId = GM_registerMenuCommand('Kemono助手设置', showDialog);
  30. const data = await (async () => {
  31. let _vimMode = GM_getValue('vimMode', false)
  32. let _rpc = GM_getValue('rpc', 'http://localhost:6800/jsonrpc')
  33. let _token = GM_getValue('token', '')
  34. let _dir = GM_getValue('dir', '')
  35. let _first = GM_getValue('first', true)
  36. if (_dir === '' && !_first) {
  37. try {
  38. _dir = await fetchDownloadDir(_rpc, _token) + "/kemono/{service}-{artist_id}/{post}-{post_id}/{index}_{auto_named}.{extension}";
  39. } catch (e) {
  40. // ignore
  41. }
  42. GM_setValue('dir', _dir);
  43. }
  44. return {
  45. get vimMode() {
  46. return _vimMode;
  47. }, set vimMode(value) {
  48. _vimMode = value;
  49. GM_setValue('vimMode', value);
  50. }, get rpc() {
  51. return _rpc;
  52. }, set rpc(value) {
  53. _rpc = value;
  54. GM_setValue('rpc', value);
  55. }, get token() {
  56. return _token;
  57. }, set token(value) {
  58. _token = value;
  59. GM_setValue('token', value);
  60. }, get dir() {
  61. return _dir;
  62. }, set dir(value) {
  63. // if (/[:*?"<>|]/g.test(value)) {
  64. // alert('下载目录中不能包含以下字符: : * ? " < > |\n您可以选择换为全角字符')
  65. // return;
  66. // }
  67. _dir = value;
  68. GM_setValue('dir', value);
  69. }, format(date) {
  70. date.toISOString().split('.')[0].replace('T', ' ').replaceAll(':', '-')
  71. }, formatDir(post, index, name, extension, padStart = 3) {
  72. const indexString = index.toString().padStart(padStart, '0');
  73. const shouldReplace = /^([0-9a-fA-F]{4}-?){8}$/.test(name)
  74. || (/^[a-zA-Z0-9]+$/.test(name) && post.service === "fanbox");
  75. return _dir
  76. .replaceAll(/\{js:(.*)#}/g, function (_, code) {
  77. return eval(code);
  78. })
  79. .replaceAll(/[\/\\]/g, '≸∱').replaceAll(':', '∱≸')
  80. .replaceAll('{service}', post.service)
  81. .replaceAll('{artist_id}', post.user)
  82. .replaceAll('{date}', this.format(new Date(Date.parse(post.published))))
  83. .replaceAll('{post}', post.title)
  84. .replaceAll('{post_id}', post.id)
  85. .replaceAll('{time}', this.format(new Date()))
  86. .replaceAll('{index0}', indexString)
  87. .replaceAll('{index}', index === 0 ? '' : indexString)
  88. .replaceAll('{auto_named}', shouldReplace ? indexString : name)
  89. .replaceAll('{name}', name)
  90. .replaceAll('{extension}', extension)
  91. // avoid illegal characters in windows folder and file name
  92. .replaceAll(':', ':')
  93. .replaceAll('*', '*')
  94. .replaceAll('?', '?')
  95. .replaceAll('"', '“')
  96. .replaceAll('<', '《')
  97. .replaceAll('>', '》')
  98. .replaceAll('|', '|')
  99. .replaceAll('/', '/')
  100. .replaceAll('\\', '\')
  101. .replaceAll('≸∱', '\\').replace('∱≸', ':')
  102. .replaceAll(/[\s.]+\\/g, '\\'); // remove space and dot
  103. }
  104. }
  105. })()
  106. const postContent = document.querySelector('.post__content')
  107. if (postContent) {
  108. replaceAsync(postContent.innerHTML, /(?<!a href="|<a [^>]+">)(https?:\/\/[^\s<]+)/g, async function (match) {
  109. let [service, id, post] = await getKemonoUrl(match);
  110. if (service === null) return `<a href="${match}" target="_blank">${match}</a>`;
  111. id = id || window.location.href.match(/\/user\/(\d+)/)[1];
  112. const domain = window.location.href.match(/https:\/\/([^/]+)/)[1];
  113. const url = `${service}/user/${id}${post ? `/post/${post}` : ""}`;
  114. return `<a href="https://${domain}/${url}" target="_self">[已替换]${match}</a>`;
  115. }).then(function (result) {
  116. postContent.innerHTML = result
  117. .replace(/<a href="(https:\/\/[^\s<]+)">\1<\/a>\n?(#[^\s<]+)/g, `<a href="$1$2">$1$2</a>`)
  118. .replace(/<a href="(https:\/\/[^\s<]+)">(.*?)<\/a>\n?(#[^\s<]+)/g, `<a href="$1$3">$2</a>`)
  119. })
  120. }
  121. const prev = document.querySelector(".post__nav-link.prev");
  122. if (prev) {
  123. document.addEventListener("keydown", function (e) {
  124. if (e.key === "Right" || e.key === "ArrowRight" || data.vimMode && (e.key === "h" || e.key === "H")) {
  125. prev.click();
  126. }
  127. });
  128. }
  129. const next = document.querySelector(".post__nav-link.next");
  130. if (next) {
  131. document.addEventListener("keydown", function (e) {
  132. if (e.key === "Left" || e.key === "ArrowLeft" || data.vimMode && (e.key === "l" || e.key === "L")) {
  133. next.click();
  134. }
  135. });
  136. }
  137. if (language === 'zh-CN') {
  138. const dms = document.querySelector('.user-header__dms');
  139. if (dms) dms.innerHTML = '私信'
  140. const flagText = document.querySelector('.post__flag')
  141. ?.querySelector('span:last-child');
  142. if (flagText) {
  143. flagText.textContent = '标记';
  144. flagText.title = '标记为需要重新导入的内容'
  145. }
  146. }
  147. async function downloadPostContent(post) {
  148. if (Object.keys(post.file).length !== 0) post.attachments.unshift(post.file)
  149. const padStart = Math.max(3, post.attachments.length.toString().length);
  150. for (let [i, { name, path }] of post.attachments.entries()) {
  151. const extension = path.split('.').pop();
  152. const filename = name.replace(/\.\w+$/, '');
  153. await downloadContent(data.rpc, data.token, data.formatDir(post, i, filename, extension, padStart), `https://${domain}/data${path}`);
  154. }
  155. }
  156. let mode;
  157. let header;
  158. let listener;
  159. if (window.location.href.match(/\/user\/\w+\/post\/\w+/)) {
  160. mode = 'post';
  161. header = document.querySelector('.post__actions');
  162. listener = async () => await downloadPostContent(await (await fetch(`/api/v1${window.location.pathname}`)).json())
  163. } else if (window.location.href.match(/\/user\/\w+/)) {
  164. mode = 'user';
  165. header = document.querySelector('.user-header__actions');
  166. listener = async () => {
  167. for (let post of await getPosts(window.location.pathname)) await downloadPostContent(post)
  168. }
  169. } else if (window.location.href.match(/\/favorites/)) {
  170. mode = 'favor';
  171. header = document.querySelector('.dropdowns');
  172. const type = document.querySelector('.dropdowns>select:nth-child(2)>option:nth-child(1)');
  173. listener = async () => {
  174. const posts = type.selected !== true
  175. ? await (await fetch(`/api/v1/account/favorites?type=post`)).json()
  176. : await (async () => {
  177. const response = await fetch(`/api/v1/account/favorites?type=artist`)
  178. const result = []
  179. for (const artist of await response.json()) {
  180. result.push(...await getPosts(`${artist.service}/user/${artist.id}`))
  181. }
  182. return result;
  183. })()
  184. for (let post of posts) await downloadPostContent(post)
  185. }
  186. }
  187. if (header) {
  188. const settings = document.createElement('button');
  189. settings.classList.add(`${mode}-header__settings`);
  190. settings.style.backgroundColor = 'transparent';
  191. settings.style.borderColor = 'transparent';
  192. settings.style.color = 'white';
  193. settings.innerHTML = `
  194. <i class="fa-solid fa-gear ${mode}-header_settings-icon"/>
  195. `;
  196. settings.addEventListener('click', showDialog);
  197. const download = document.createElement('button');
  198. download.classList.add(`${mode}-header__download`);
  199. download.style.backgroundColor = 'transparent';
  200. download.style.borderColor = 'transparent';
  201. download.style.color = 'white';
  202. download.innerHTML = `
  203. <i class="fa-solid fa-download ${mode}-header_download-icon"/>
  204. <span class="${mode}-header__download-text">下载</span>
  205. `;
  206. download.addEventListener('click', async function () {
  207. download.innerHTML = `
  208. <i class="fa-solid fa-spinner fa-spin-pulse ${mode}-header_download-icon"></i>
  209. <span class="${mode}-header__download-text">下载中...</span>
  210. `;
  211. await listener();
  212. download.innerHTML = `
  213. <i class="fa-solid fa-check ${mode}-header_download-icon"/>
  214. <span class="${mode}-header__download-text">下载/推送完成</span>
  215. `;
  216. }, { once: true });
  217. header.appendChild(settings);
  218. header.appendChild(download);
  219. } else if (mode !== undefined) {
  220. alert('未找到插入位置, 请将本页源代码发送给开发者以解决此问题');
  221. }
  222. function showDialog() {
  223. swal.fire({
  224. title: '设置', html: `
  225. <div>
  226. <label for="rpc">Aria2 RPC地址</label>
  227. <input type="text" id="rpc" value="${data.rpc}">
  228. </div>
  229. <div>
  230. <label for="token">Aria2 Token</label>
  231. <input type="text" id="token" value="${data.token}">
  232. </div>
  233. <div>
  234. <label for="dir">下载目录</label>
  235. <i class="fa-solid fa-info-circle" title="支持的变量:
  236. {service}: 服务器, fanbox
  237. {artist_id}: 作者ID
  238. {date}: 发布时间
  239. {post}: 作品标题
  240. {post_id}: 作品ID
  241. {time}: 下载时间
  242. {index0}: 0开始的序号。cover将编号为0
  243. {index}: 1开始的序号。cover的编号为空白
  244. {auto_named}: 当文件名为uuid时将使用空命名。否则使用文件名
  245. {name}: 文件名
  246. {extension}: 文件扩展名。必须带有此项
  247. {js:...#}: js代码. 可用参数请参考源代码85行处formatDir方法"></i>
  248. <textarea cols="20" id="dir">${data.dir}</textarea>
  249. </div>
  250. <div>
  251. <label for="vimMode">Vim模式</label>
  252. <input type="checkbox" id="vimMode" checked="${data.vimMode}">
  253. </div>
  254. `, showCancelButton: true, confirmButtonText: '保存', cancelButtonText: '取消'
  255. }).then((result) => {
  256. if (result.isConfirmed) {
  257. data.rpc = document.getElementById('rpc').value;
  258. data.token = document.getElementById('token').value;
  259. data.dir = document.getElementById('dir').value;
  260. data.vimMode = document.getElementById('vimMode').checked;
  261. location.reload();
  262. }
  263. });
  264. }
  265. }
  266. async function replaceAsync(str, regex, asyncFn) {
  267. const promises = [];
  268. str.replace(regex, (match, ...args) => {
  269. const promise = asyncFn(match, ...args);
  270. promises.push(promise);
  271. });
  272. const data = await Promise.all(promises);
  273. return str.replace(regex, () => data.shift());
  274. }
  275. async function getKemonoUrl(url) {
  276. function getFanbox(creatorId) {
  277. // 同步执行promise
  278. return new Promise((resolve, reject) => {
  279. GM_xmlhttpRequest({
  280. method: "GET", url: `https://api.fanbox.cc/creator.get?creatorId=${creatorId}`, headers: {
  281. "Content-Type": "application/json",
  282. "Accept": "application/json",
  283. "Origin": "https://www.fanbox.cc",
  284. "Referer": "https://www.fanbox.cc/"
  285. }, onload: function (response) {
  286. if (response.status === 200) {
  287. resolve(JSON.parse(response.responseText))
  288. } else {
  289. reject({ status: response.status, statusText: response.statusText })
  290. }
  291. }, onerror: function (response) {
  292. reject({ status: response.status, statusText: response.statusText })
  293. }
  294. })
  295. })
  296. }
  297. const pixiv_user = /https:\/\/www\.pixiv\.net\/users\/(\d+)/i;
  298. const fantia_user = /https:\/\/fantia\.jp\/fanclubs\/(\d+)(\/posts(\S+))?/i;
  299. const fanbox_user1 = /https:\/\/www\.fanbox\.cc\/@([^/]+)(?:\/posts\/(\d+))?/i;
  300. const fanbox_user2 = /https:\/\/(.+)\.fanbox\.cc(?:\/posts\/(\d+))?/i;
  301. const dlsite_user = /https:\/\/www.dlsite.com\/.+?\/profile\/=\/maker_id\/(RG\d+).html/i;
  302. const patreon_user1 = /https:\/\/www.patreon.com\/user\?u=(\d+)/i;
  303. const patreon_user2 = /https:\/\/www.patreon.com\/(\w+)/i;
  304. const patreon_post1 = /https:\/\/www.patreon.com\/posts\/(\d+)/i;
  305. const patreon_post2 = /https:\/\/www.patreon.com\/posts\/video-download-(\d+)/i;
  306. let service;
  307. let id;
  308. let post = null;
  309. if (pixiv_user.test(url)) {
  310. //pixiv artist
  311. service = "fanbox"
  312. id = url.match(pixiv_user)[1]
  313. } else if (fantia_user.test(url)) {
  314. //fantia
  315. service = "fantia"
  316. id = url.match(fantia_user)[1]
  317. } else if (dlsite_user.test(url)) {
  318. service = "dlsite"
  319. id = url.match(dlsite_user)[1]
  320. } else if (fanbox_user1.test(url) || fanbox_user2.test(url)) {
  321. //fanbox
  322. service = "fanbox"
  323. let matches = fanbox_user1.test(url) ? url.match(fanbox_user1) : url.match(fanbox_user2);
  324. id = (await getFanbox(matches[1])).body.user.userId.toString()
  325. post = matches[2]
  326. } else if (patreon_user1.test(url)) {
  327. // patreon
  328. service = "patreon"
  329. id = url.match(patreon_user1)[1]
  330. } else if (patreon_post1.test(url)) {
  331. // patreon post
  332. service = "patreon"
  333. post = url.match(patreon_post1)[1]
  334. } else if (patreon_post2.test(url)) {
  335. // patreon post
  336. service = "patreon"
  337. post = url.match(patreon_post2)[1]
  338. } else {
  339. return null;
  340. }
  341. return [service, id, post]
  342. }
  343. async function getPosts(path, order = 0) {
  344. let posts = [];
  345. while (true) {
  346. const response = await fetch(`/api/v1/${path}?o=${order}`)
  347. // TODO: 429 too many requests, 80 request per minute
  348. if (response.status === 429) {
  349. await new Promise(resolve => setTimeout(resolve, 60000))
  350. continue;
  351. }
  352. if (response.status !== 200) throw { status: response.status, statusText: response.statusText }
  353. const items = await response.json();
  354. posts.push(...items);
  355. if (items.length < 50) break;
  356. order += items.length;
  357. }
  358. return posts;
  359. }
  360. /**
  361. * send request to aria2 for download
  362. * @param {string} rpc
  363. * @param {string} token
  364. * @param {string} file
  365. * @param {string} url
  366. */
  367. async function downloadContent(rpc, token, file, ...url) {
  368. const dir = file.replace(/(.+?[\/\\])[^\/\\]+$/, "$1");
  369. const out = file.slice(dir.length);
  370. const params = token === undefined
  371. ? out === ""
  372. ? [url, { "dir": dir }]
  373. : [url, { "dir": dir, "out": out }]
  374. : out === ""
  375. ? [`token:${token}`, url, { "dir": dir }]
  376. : [`token:${token}`, url, { "dir": dir, "out": out }]
  377. return new Promise((resolve) => {
  378. GM_xmlhttpRequest({
  379. method: "POST", url: rpc, data: JSON.stringify({
  380. jsonrpc: "2.0", id: `kemono-${crypto.randomUUID()}`, method: "aria2.addUri", params: params
  381. }), onload: function (response) {
  382. if (response.status === 200) {
  383. resolve(JSON.parse(response.responseText))
  384. } else {
  385. console.log(`添加下载任务失败: ${response.status} ${response.statusText}`)
  386. }
  387. }, onerror: function (response) {
  388. console.log(`添加下载任务失败: ${response.status} ${response.statusText}`)
  389. }
  390. })
  391. })
  392. }
  393. async function fetchDownloadDir(rpc, token) {
  394. return new Promise((resolve, reject) => {
  395. GM_xmlhttpRequest({
  396. method: "POST", url: rpc, headers: {
  397. "Content-Type": "application/json", "Accept": "application/json",
  398. }, data: JSON.stringify({
  399. jsonrpc: "2.0", id: "Kemono", method: "aria2.getGlobalOption", params: token ? [`token:${token}`] : []
  400. }), onload: function (response) {
  401. if (response.status === 200) {
  402. resolve(JSON.parse(response.responseText))
  403. } else {
  404. reject({ status: response.status, statusText: response.statusText })
  405. }
  406. }, onerror: function (response) {
  407. reject({ status: response.status, statusText: response.statusText })
  408. }
  409. })
  410. }).then(function (result) {
  411. return result.result.dir;
  412. })
  413. }