Kemono助手

提供更好的Kemono使用体验

  1. // ==UserScript==
  2. // @name Kemono助手
  3. // @version 2.2.0
  4. // @description 提供更好的Kemono使用体验
  5. // @author ZIDOUZI
  6. // @match https://*.kemono.party/*
  7. // @match https://*.kemono.su/*
  8. // @match https://*.nekohouse.su/*
  9. // @icon https://kemono.su/static/favicon.ico
  10. // @require https://cdn.jsdelivr.net/npm/sweetalert2@11
  11. // @require https://kit.fontawesome.com/101092ae56.js
  12. // @grant GM_xmlhttpRequest
  13. // @grant GM_getValue
  14. // @grant GM_setValue
  15. // @grant GM_registerMenuCommand
  16. // @grant GM_unregisterMenuCommand
  17. // @license GPL-3.0
  18. // @namespace https://greasyfork.org/users/448292
  19. // ==/UserScript==
  20.  
  21. (async function () {
  22. try {
  23. const root = document.querySelector('#root')
  24. const observer = new MutationObserver(async function (mutations) {
  25. observer.disconnect();
  26. for (const mutation of mutations) {
  27. if (mutation.type === "childList" && mutation.addedNodes.length > 0 && await wrapper() !== undefined) break
  28. }
  29. });
  30. observer.observe(root, { childList: true, subtree: true })
  31. } catch (e) {
  32. console.log(e)
  33. await wrapper()
  34. }
  35. })();
  36.  
  37. async function wrapper() {
  38. try {
  39. return await main()
  40. } catch (error) {
  41. console.log(error)
  42. alert(`Kemono助手发生错误: ${error.status} ${error.statusText}`)
  43. return undefined
  44. }
  45. }
  46.  
  47. async function main() {
  48.  
  49. const language = navigator.language || navigator.userLanguage;
  50.  
  51. const domain = window.location.href.match(/https:\/\/([^/]+)/)[1];
  52.  
  53. const menuId = GM_registerMenuCommand('Kemono助手设置', showSettingsDialog);
  54.  
  55. const data = await (async () => {
  56. let _vimMode = GM_getValue('vimMode', false)
  57. let _rpc = GM_getValue('rpc', 'http://localhost:6800/jsonrpc')
  58. let _token = GM_getValue('token', '')
  59. let _dir = GM_getValue('dir', '')
  60. let _first = GM_getValue('first', true)
  61. let _saveSource = GM_getValue('saveSource', false)
  62. if (_dir === '' && !_first) try {
  63. _dir = await fetchDownloadDir(_rpc, _token) + "/kemono/{service}-{artist_id}/{post}-{post_id}/{index}_{auto_named}.{extension}";
  64. } finally {
  65. GM_setValue('dir', _dir);
  66. }
  67. return {
  68. get vimMode() {
  69. return _vimMode;
  70. }, set vimMode(value) {
  71. _vimMode = value;
  72. GM_setValue('vimMode', value);
  73. }, get rpc() {
  74. return _rpc;
  75. }, set rpc(value) {
  76. _rpc = value;
  77. GM_setValue('rpc', value);
  78. }, get token() {
  79. return _token;
  80. }, set token(value) {
  81. _token = value;
  82. GM_setValue('token', value);
  83. }, get dir() {
  84. return _dir;
  85. }, set dir(value) {
  86. // if (/[:*?"<>|]/g.test(value)) {
  87. // alert('下载目录中不能包含以下字符: : * ? " < > |\n您可以选择换为全角字符')
  88. // return;
  89. // }
  90. _dir = value;
  91. GM_setValue('dir', value);
  92. }, get saveSource() {
  93. return _saveSource
  94. }, set saveSource(value) {
  95. _saveSource = value
  96. GM_setValue('saveSource', value)
  97. }, format(date) {
  98. return date.toISOString().split('.')[0].replace('T', ' ').replaceAll(':', '-')
  99. }, formatDir(post, index, name, extension, padStart = 3, pattern = undefined) {
  100. const indexString = index.toString().padStart(padStart, '0');
  101. const shouldReplace = /^([0-9a-fA-F]{4}-?){8}$/.test(name)
  102. || (/^[a-zA-Z0-9]+$/.test(name) && post.service === "fanbox") || name.length + extension.length > 255;
  103. if (name.length + extension.length > 255) name = name.slice(0, 255 - extension.length);
  104. return (pattern || _dir)
  105. .replaceAll(/\{js:(.*)#}/g, function (_, code) {
  106. return eval(code);
  107. })
  108. .replaceAll(/[\/\\]/g, '≸∱').replaceAll(':', '∱≸')
  109. .replaceAll('{service}', post.service)
  110. .replaceAll('{artist_id}', post.user)
  111. .replaceAll('{date}', this.format(new Date(Date.parse(post.published))))
  112. .replaceAll('{post}', post.title)
  113. .replaceAll('{post_id}', post.id)
  114. .replaceAll('{time}', this.format(new Date()))
  115. .replaceAll('{index0}', indexString)
  116. .replaceAll('{index}', index === 0 ? '' : indexString)
  117. .replaceAll('{auto_named}', shouldReplace ? indexString : name)
  118. .replaceAll('{name}', name)
  119. .replaceAll('{extension}', extension)
  120. // avoid illegal characters in windows folder and file name
  121. .replaceAll(':', ':')
  122. .replaceAll('*', '*')
  123. .replaceAll('?', '?')
  124. .replaceAll('"', '“')
  125. .replaceAll('<', '《')
  126. .replaceAll('>', '》')
  127. .replaceAll('|', '|')
  128. .replaceAll('/', '/')
  129. .replaceAll('\\', '\')
  130. .replaceAll('≸∱', '/').replace('∱≸', ':')
  131. .replaceAll(/[\s.]+[\/\\]/g, '/'); // remove space and dot
  132. }
  133. }
  134. })()
  135.  
  136. const postContent = document.querySelector('.post__content')
  137. if (postContent) {
  138. replaceAsync(postContent.innerHTML, /(?<!a href="|<a [^>]+">)(https?:\/\/[^\s<]+)/g, async function (match) {
  139. let [service, id, post] = await getKemonoUrl(match);
  140. if (service === undefined) return `<a href="${match}" target="_self">${match}</a>`;
  141. id = id || window.location.href.match(/\/user\/(\d+)/)[1];
  142. const domain = window.location.href.match(/https:\/\/([^/]+)/)[1];
  143. const url = `${service}/user/${id}${post ? `/post/${post}` : ""}`;
  144. return `<a href="https://${domain}/${url}" target="_self">[已替换]${match}</a>`;
  145. }).then(function (result) {
  146. postContent.innerHTML = result
  147. .replace(/<a href="(https:\/\/[^\s<]+)">\1<\/a>\n?(#[^\s<]+)/g, `<a href="$1$2">$1$2</a>`)
  148. .replace(/<a href="(https:\/\/[^\s<]+)">(.*?)<\/a>\n?(#[^\s<]+)/g, `<a href="$1$3">$2</a>`)
  149. })
  150. }
  151.  
  152. const prev = document.querySelector(".post__nav-link.prev");
  153. if (prev) {
  154. document.addEventListener("keydown", function (e) {
  155. if (e.key === "Right" || e.key === "ArrowRight" || data.vimMode && (e.key === "h" || e.key === "H")) {
  156. prev.click();
  157. }
  158. });
  159. }
  160.  
  161. const next = document.querySelector(".post__nav-link.next");
  162. if (next) {
  163. document.addEventListener("keydown", function (e) {
  164. if (e.key === "Left" || e.key === "ArrowLeft" || data.vimMode && (e.key === "l" || e.key === "L")) {
  165. next.click();
  166. }
  167. });
  168. }
  169.  
  170. if (language === 'zh-CN') {
  171. const dms = document.querySelector('.user-header__dms');
  172.  
  173. if (dms) dms.innerHTML = '私信'
  174.  
  175. const flagText = document.querySelector('.post__flag')
  176. ?.querySelector('span:last-child');
  177.  
  178. if (flagText) {
  179. flagText.textContent = '标记';
  180. flagText.title = '标记为需要重新导入的内容'
  181. }
  182. }
  183.  
  184. async function downloadPostContent(post, params = undefined) {
  185. if (Object.keys(post.file).length !== 0) post.attachments.unshift(post.file)
  186. const padStart = Math.max(3, post.attachments.length.toString().length);
  187. const published = new Date(Date.parse(post.published));
  188. if (!params.after.isEmpty() && published < new Date(params.after)) return;
  189. if (!params.before.isEmpty() && published > new Date(params.before)) return;
  190. if (!params.regex.isEmpty() && !new RegExp(params.regex).test(post.title)) return;
  191. for (let [i, {name, path}] of post.attachments.entries()) {
  192. let extension = path.split('.').pop();
  193. if (extension == "bin" || extension == "zip") extension = name.split('.').pop();
  194. if (!params.extension.includes(extension) && !params.extension.includes('*')) continue;
  195. const filename = name.replace(/\.\w+$/, '');
  196. const dir = data.formatDir(post, i, filename, extension, padStart, params.pattern);
  197. if (!params.js.isEmpty() && !eval(params.js)) continue;
  198. await downloadContent(data.rpc, data.token, dir, `https://${domain}/data${path}`);
  199. }
  200. if (params.saveSource) {
  201. await downloadContent(
  202. data.rpc, data.token,
  203. data.formatDir(post, 0, 'content', 'json', 0, params.pattern),
  204. `https://${domain}/api/v1/${post.service}/user/${post.user}/post/${post.id}`);
  205. // aria2 may cannot download this successfully. error 22
  206. }
  207. }
  208.  
  209. function showSettingsDialog() {
  210. swal.fire({
  211. title: '设置', html: `
  212. <div>
  213. <label for="rpc">Aria2 RPC地址</label>
  214. <input type="text" id="rpc" value="${data.rpc}">
  215. </div>
  216. <div>
  217. <label for="token">Aria2 Token</label>
  218. <input type="text" id="token" value="${data.token}">
  219. </div>
  220. <div>
  221. <label for="dir">下载目录</label>
  222. <i class="fa-solid fa-info-circle" title="支持的占位符:
  223. {service}: 服务器, fanbox
  224. {artist_id}: 作者ID
  225. {date}: 发布时间
  226. {post}: 作品标题
  227. {post_id}: 作品ID
  228. {time}: 下载时间
  229. {index0}: 0开始的序号。cover将编号为0
  230. {index}: 1开始的序号。cover的编号为空白
  231. {auto_named}: 当文件名为uuid时将使用空命名。否则使用文件名
  232. {name}: 文件名
  233. {extension}: 文件扩展名。必须带有此项
  234. {js:...#}: js代码. 可用参数请参考源代码85行处formatDir方法"></i>
  235. <textarea cols="20" id="dir">${data.dir}</textarea>
  236. </div>
  237. <div>
  238. <label for="save-sourcedata">保存原始数据</label>
  239. <input type="checkbox" id="save-sourcedata" ${data.saveSource ? 'checked' : ''} >
  240. </div>
  241. <div>
  242. <label for="vimMode">Vim模式</label>
  243. <input type="checkbox" id="vimMode" ${data.vimMode ? 'checked' : ''}>
  244. </div>
  245. `, showCancelButton: true, confirmButtonText: '保存', cancelButtonText: '取消'
  246. }).then((result) => {
  247. if (result.isConfirmed) {
  248. data.rpc = document.getElementById('rpc').value;
  249. data.token = document.getElementById('token').value;
  250. data.dir = document.getElementById('dir').value;
  251. data.vimMode = document.getElementById('vimMode').checked;
  252. data.saveSource = document.getElementById('save-sourcedata').checked;
  253. location.reload();
  254. }
  255. });
  256. }
  257.  
  258. function showDownloadDialog() {
  259. swal.fire({
  260. title: '下载选项', html: `
  261. <div>
  262. <label for="final-dir">下载目录</label>
  263. <textarea cols="20" id="final-dir">${data.dir}</textarea>
  264. </div>
  265. <div>
  266. <label for="save-sourcedata-final">保存原始数据</label>
  267. <input type="checkbox" id="save-sourcedata-final" ${data.saveSource ? 'checked' : ''} >
  268. </div>
  269. <div>
  270. <label for="date-after">时间上限(较早日期)</label>
  271. <input type="datetime-local" id="date-after" value="${new Date(0).toDateInputString()}">
  272. </div>
  273. <div>
  274. <label for="date-before">时间下限(较晚日期)</label>
  275. <input type="datetime-local" id="date-before" value="${new Date().toDateInputString()}">
  276. </div>
  277. <div>
  278. <label for="regex">正则检查标题</label>
  279. <input type="text" id="regex" value="">
  280. </div>
  281. <div>
  282. <label for="extension">保留扩展名</label>
  283. <input type="text" id="extension" value="*" placeholder="rar,zip,jpg">
  284. </div>
  285. <div>
  286. <label for="costomize-js">自定义JS</label>
  287. <textarea cols="20" id="customize-js"></textarea>
  288. </div>
  289. `, showCancelButton: true, confirmButtonText: '下载', cancelButtonText: '取消'
  290. }).then(async (result) => {
  291. if (result.isConfirmed) {
  292. await listener({
  293. pattern: document.getElementById('final-dir').value,
  294. after: document.getElementById('date-after').value,
  295. before: document.getElementById('date-before').value,
  296. regex: document.getElementById('regex').value,
  297. extension: document.getElementById('extension').value.split(','),
  298. js: document.getElementById('customize-js').value,
  299. saveSource: document.getElementById('save-sourcedata-final').checked,
  300. });
  301. swal.fire({title: '下载任务已添加', icon: 'success'});
  302. }
  303. })
  304. }
  305.  
  306. let mode;
  307. let header
  308. let listener;
  309. if (window.location.href.match(/\/user\/\w+\/post\/\w+\/revision\/\w+/)) {
  310. mode = 'post';
  311. header = document.querySelector('.post__actions');
  312. listener = async (p) => {
  313. let revisions = await (await fetch(`/api/v1${window.location.pathname.replace(/revision\/\w+/, "revisions")}`)).json()
  314. let revision = window.location.pathname.split("/").at(-1)
  315. await downloadPostContent(revisions.find(v => v.revision_id.toString() === revision), p)
  316. }
  317. } else if (window.location.href.match(/\/user\/\w+\/post\/\w+/)) {
  318. mode = 'post';
  319. header = document.querySelector('.post__actions');
  320. listener = async (p) => await downloadPostContent((await (await fetch(`/api/v1${window.location.pathname}`)).json()).post, p)
  321. } else if (window.location.href.match(/\/user\/\w+/)) {
  322. mode = 'user';
  323. header = document.querySelector('.user-header__actions');
  324. listener = async (p) => {
  325. for (let post of await getPosts(window.location.pathname)) await downloadPostContent(post, p)
  326. }
  327. } else if (window.location.href.match(/\/favorites/)) {
  328. mode = 'favor';
  329. const content = document.querySelector('.site-section.site-section--favorites');
  330. header = document.createElement('div');
  331. header.classList.add(`favorites-header__actions`);
  332. header.style.display = 'flex';
  333. header.style.justifyContent = 'center';
  334. content.insertBefore(header, content.querySelector('form#filter-favorites').nextSibling);
  335. const type = window.location.href.match(/\/favorites\/(\w+)/).pop();
  336. listener = async (p) => {
  337. const posts = type === 'post'
  338. ? await (await fetch(`/api/v1/account/favorites?type=post`)).json()
  339. : await (async () => {
  340. const response = await fetch(`/api/v1/account/favorites?type=artist`)
  341. const result = []
  342. for (const artist of await response.json()) {
  343. result.push(...await getPosts(`${artist.service}/user/${artist.id}`))
  344. }
  345. return result;
  346. })()
  347. for (let post of posts) await downloadPostContent(post, p)
  348. }
  349. }
  350.  
  351. if (header) {
  352. const settings = document.createElement('button');
  353. settings.classList.add(`${mode}-header__settings`);
  354. settings.style.backgroundColor = 'transparent';
  355. settings.style.borderColor = 'transparent';
  356. settings.style.color = 'white';
  357. settings.innerHTML = `
  358. <i class="fa-solid fa-gear ${mode}-header_settings-icon"/>
  359. `;
  360. settings.addEventListener('click', showSettingsDialog);
  361.  
  362. const download = document.createElement('button');
  363. download.classList.add(`${mode}-header__download`);
  364. download.style.backgroundColor = 'transparent';
  365. download.style.borderColor = 'transparent';
  366. download.style.color = 'white';
  367. download.innerHTML = `
  368. <i class="fa-solid fa-download ${mode}-header_download-icon"/>
  369. <span class="${mode}-header__download-text">下载</span>
  370. `;
  371.  
  372. download.addEventListener('click', showDownloadDialog);
  373.  
  374. header.appendChild(settings);
  375. header.appendChild(download);
  376. return mode;
  377. } else if (mode !== undefined) {
  378. alert('未找到插入位置, 请将本页源代码发送给开发者以解决此问题');
  379. return undefined
  380. }
  381. }
  382.  
  383. async function replaceAsync(str, regex, asyncFn) {
  384. const promises = [];
  385. str.replace(regex, (match, ...args) => {
  386. const promise = asyncFn(match, ...args);
  387. promises.push(promise);
  388. });
  389. const data = await Promise.all(promises);
  390. return str.replace(regex, () => data.shift());
  391. }
  392.  
  393. async function getKemonoUrl(url) {
  394.  
  395. function getFanbox(creatorId) {
  396. // 同步执行promise
  397. return new Promise((resolve, reject) => {
  398. GM_xmlhttpRequest({
  399. method: "GET", url: `https://api.fanbox.cc/creator.get?creatorId=${creatorId}`, headers: {
  400. "Content-Type": "application/json",
  401. "Accept": "application/json",
  402. "Origin": "https://www.fanbox.cc",
  403. "Referer": "https://www.fanbox.cc/"
  404. }, onload: function (response) {
  405. if (response.status === 200) {
  406. resolve(JSON.parse(response.responseText))
  407. } else {
  408. reject({status: response.status, statusText: response.statusText})
  409. }
  410. }, onerror: function (response) {
  411. reject({status: response.status, statusText: response.statusText})
  412. }
  413. })
  414. })
  415. }
  416.  
  417. const pixiv_user = /https:\/\/www\.pixiv\.net\/users\/(\d+)/i;
  418. const fantia_user = /https:\/\/fantia\.jp\/fanclubs\/(\d+)(\/posts(\S+))?/i;
  419. const fanbox_user1 = /https:\/\/www\.fanbox\.cc\/@([^/]+)(?:\/posts\/(\d+))?/i;
  420. const fanbox_user2 = /https:\/\/(.+)\.fanbox\.cc(?:\/posts\/(\d+))?/i;
  421. const dlsite_user = /https:\/\/www.dlsite.com\/.+?\/profile\/=\/maker_id\/(RG\d+).html/i;
  422. const patreon_user1 = /https:\/\/www.patreon.com\/user\?u=(\d+)/i;
  423. const patreon_user2 = /https:\/\/www.patreon.com\/(\w+)/i;
  424. const patreon_post1 = /https:\/\/www.patreon.com\/posts\/(\d+)/i;
  425. const patreon_post2 = /https:\/\/www.patreon.com\/posts\/video-download-(\d+)/i;
  426.  
  427. let service;
  428. let id;
  429. let post = null;
  430.  
  431. if (pixiv_user.test(url)) {
  432. //pixiv artist
  433. service = "fanbox"
  434. id = url.match(pixiv_user)[1]
  435. } else if (fantia_user.test(url)) {
  436. //fantia
  437. service = "fantia"
  438. id = url.match(fantia_user)[1]
  439. } else if (dlsite_user.test(url)) {
  440. service = "dlsite"
  441. id = url.match(dlsite_user)[1]
  442. } else if (fanbox_user1.test(url) || fanbox_user2.test(url)) {
  443. //fanbox
  444. service = "fanbox"
  445. let matches = fanbox_user1.test(url) ? url.match(fanbox_user1) : url.match(fanbox_user2);
  446. id = (await getFanbox(matches[1])).body.user.userId.toString()
  447. post = matches[2]
  448. } else if (patreon_user1.test(url)) {
  449. // patreon
  450. service = "patreon"
  451. id = url.match(patreon_user1)[1]
  452. } else if (patreon_post1.test(url)) {
  453. // patreon post
  454. service = "patreon"
  455. post = url.match(patreon_post1)[1]
  456. } else if (patreon_post2.test(url)) {
  457. // patreon post
  458. service = "patreon"
  459. post = url.match(patreon_post2)[1]
  460. } else {
  461. return [undefined, undefined, undefined];
  462. }
  463.  
  464. return [service, id, post]
  465. }
  466.  
  467. async function getPosts(path, order = 0) {
  468. let posts = [];
  469. while (true) {
  470. const response = await fetch(`/api/v1/${path}?o=${order}`)
  471. // TODO: 429 too many requests, 80 request per minute
  472. if (response.status === 429) {
  473. await new Promise(resolve => setTimeout(resolve, 60000))
  474. continue;
  475. }
  476. if (response.status !== 200) throw {status: response.status, statusText: response.statusText}
  477. const items = await response.json();
  478. posts.push(...items);
  479. if (items.length < 50) break;
  480. order += items.length;
  481. }
  482. return posts;
  483. }
  484.  
  485. /**
  486. * send request to aria2 for download
  487. * @param {string} rpc
  488. * @param {string} token
  489. * @param {string} file
  490. * @param {string} url
  491. */
  492. async function downloadContent(rpc, token, file, ...url) {
  493. const dir = file.replace(/(.+?[\/\\])[^\/\\]+$/, "$1");
  494. const out = file.slice(dir.length);
  495. const params = token === undefined
  496. ? out === ""
  497. ? [url, {"dir": dir}]
  498. : [url, {"dir": dir, "out": out}]
  499. : out === ""
  500. ? [`token:${token}`, url, {"dir": dir}]
  501. : [`token:${token}`, url, {"dir": dir, "out": out}]
  502. return new Promise((resolve) => {
  503. GM_xmlhttpRequest({
  504. method: "POST", url: rpc, data: JSON.stringify({
  505. jsonrpc: "2.0", id: `kemono-${crypto.randomUUID()}`, method: "aria2.addUri", params: params
  506. }), onload: function (response) {
  507. if (response.status === 200) {
  508. resolve(JSON.parse(response.responseText))
  509. } else {
  510. console.log(`添加下载任务失败: ${response.status} ${response.statusText}`)
  511. }
  512. }, onerror: function (response) {
  513. console.log(`添加下载任务失败: ${response.status} ${response.statusText}`)
  514. }
  515. })
  516. })
  517. }
  518.  
  519. async function fetchDownloadDir(rpc, token) {
  520. return new Promise((resolve, reject) => {
  521. GM_xmlhttpRequest({
  522. method: "POST", url: rpc, headers: {
  523. "Content-Type": "application/json", "Accept": "application/json",
  524. }, data: JSON.stringify({
  525. jsonrpc: "2.0", id: "Kemono", method: "aria2.getGlobalOption", params: token ? [`token:${token}`] : []
  526. }), onload: function (response) {
  527. if (response.status === 200) {
  528. resolve(JSON.parse(response.responseText))
  529. } else {
  530. reject({status: response.status, statusText: response.statusText})
  531. }
  532. }, onerror: function (response) {
  533. reject({status: response.status, statusText: response.statusText})
  534. }
  535. })
  536. }).then(function (result) {
  537. return result.result.dir;
  538. })
  539. }
  540.  
  541. String.prototype.isEmpty = function() {
  542. return (this.length === 0 || !this.trim())
  543. }
  544.  
  545. Date.prototype.toDateInputString = function () {
  546. return new Date().toJSON().slice(0, 10)
  547. }