Kemono助手

提供更好的Kemono使用体验

As of 15/03/2024. See the latest version.

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