Kemono助手

提供更好的Kemono使用体验

ของเมื่อวันที่ 17-02-2024 ดู เวอร์ชันล่าสุด

  1. // ==UserScript==
  2. // @name Kemono助手
  3. // @version 2.0.0
  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. // @grant GM_xmlhttpRequest
  11. // @grant GM_getValue
  12. // @grant GM_setValue
  13. // @grant GM_registerMenuCommand
  14. // @grant GM_unregisterMenuCommand
  15. // @namespace https://greasyfork.org/users/448292
  16. // @license GPL-3.0
  17. // ==/UserScript==
  18.  
  19. (async function () {
  20.  
  21. const language = navigator.language || navigator.userLanguage;
  22.  
  23. const domain = window.location.href.match(/https:\/\/([^/]+)/)[1];
  24.  
  25. const data = await (async () => {
  26. let _vimMode = GM_getValue('vimMode', false)
  27. let _rpc = GM_getValue('rpc', 'http://localhost:6800/jsonrpc')
  28. let _token = GM_getValue('token', '')
  29. let _dir = GM_getValue('dir', '')
  30. if (_dir === '') {
  31. _dir = await fetchDownloadDir(_rpc, _token) + "/kemono/{service}-{artist_id}/{post}-{post_id}/{index0}.{extension}";
  32. GM_setValue('dir', _dir);
  33. }
  34. return {
  35. get vimMode() {
  36. return _vimMode;
  37. }, set vimMode(value) {
  38. _vimMode = value;
  39. GM_setValue('vimMode', value);
  40. }, get rpc() {
  41. return _rpc;
  42. }, set rpc(value) {
  43. _rpc = value;
  44. GM_setValue('rpc', value);
  45. }, get token() {
  46. return _token;
  47. }, set token(value) {
  48. _token = value;
  49. GM_setValue('token', value);
  50. }, get dir() {
  51. return _dir;
  52. }, set dir(value) {
  53. _dir = value;
  54. GM_setValue('dir', value);
  55. }, format(date) {
  56. const year = date.getFullYear();
  57. const month = date.getMonth() + 1;
  58. const day = date.getDate();
  59. const hour = date.getHours();
  60. const minute = date.getMinutes();
  61. const second = date.getSeconds();
  62. return `${year}-${month}-${day} ${hour}-${minute}-${second}`; // TODO: custom format
  63. }, formatDir(post, index, name, artist = undefined, padStart = 3) {
  64. const indexString = index.toString().padStart(padStart, '0');
  65. return _dir
  66. .replace('{service}', post.service)
  67. .replace('{artist_id}', post.user)
  68. .replace('{date}', this.format(new Date(Date.parse(post.published))))
  69. .replace('{post}', post.title)
  70. .replace('{post_id}', post.id)
  71. .replace('{time}', this.format(new Date()))
  72. .replace('{index0}', indexString)
  73. .replace('{index}', index === 0 ? '' : indexString)
  74. .replace('{name}', name.slice(0, name.lastIndexOf(".")))
  75. .replace('{extension}', name.slice(name.lastIndexOf(".") + 1));
  76. }
  77. }
  78. })()
  79.  
  80. const postContent = document.querySelector('.post__content')
  81. if (postContent) {
  82. replaceAsync(postContent.innerHTML, /(?<!a href="|<a [^>]+">)(https?:\/\/[^\s<]+)/g, async function (match) {
  83. let [service, id, post] = await getKemonoUrl(match);
  84. if (service === null) return `<a href="${match}" target="_blank">${match}</a>`;
  85. id = id || window.location.href.match(/\/user\/(\d+)/)[1];
  86. const domain = window.location.href.match(/https:\/\/([^/]+)/)[1];
  87. const url = `${service}/user/${id}${post ? `/post/${post}` : ""}`;
  88. return `<a href="https://${domain}/${url}" target="_self">[已替换]${match}</a>`;
  89. }).then(function (result) {
  90. postContent.innerHTML = result
  91. .replace(/<a href="(https:\/\/[^\s<]+)">\1<\/a>\n?(#[^\s<]+)/g, `<a href="$1$2">$1$2</a>`)
  92. .replace(/<a href="(https:\/\/[^\s<]+)">(.*?)<\/a>\n?(#[^\s<]+)/g, `<a href="$1$3">$2</a>`)
  93. })
  94. }
  95.  
  96. const prev = document.querySelector(".post__nav-link.prev");
  97. if (prev) {
  98. document.addEventListener("keydown", function (e) {
  99. if (e.key === "Right" || e.key === "ArrowRight" || data.vimMode && (e.key === "h" || e.key === "H")) {
  100. prev.click();
  101. }
  102. });
  103. }
  104.  
  105. const next = document.querySelector(".post__nav-link.next");
  106. if (next) {
  107. document.addEventListener("keydown", function (e) {
  108. if (e.key === "Left" || e.key === "ArrowLeft" || data.vimMode && (e.key === "l" || e.key === "L")) {
  109. next.click();
  110. }
  111. });
  112. }
  113.  
  114. if (language === 'zh-CN') {
  115. const dms = document.querySelector('.user-header__dms');
  116.  
  117. if (dms) dms.innerHTML = '私信'
  118.  
  119. const flagText = document.querySelector('.post__flag')
  120. ?.querySelector('span:last-child');
  121.  
  122. if (flagText) {
  123. flagText.textContent = '标记';
  124. flagText.title = '标记为需要重新导入的内容'
  125. }
  126. }
  127.  
  128. async function downloadPostContent(post) {
  129. if (Object.keys(post.file).length !== 0) post.attachments.push(post.file)
  130. for (let [i, {name, path}] of post.attachments.entries()) {
  131. await downloadContent(data.rpc, data.token, data.formatDir(post, i, name), `https://${domain}/data${path}`);
  132. }
  133. }
  134.  
  135. let mode;
  136. let header;
  137. let listener;
  138. if (window.location.href.match(/\/user\/\w+\/post\/\w+/)) {
  139. mode = 'post';
  140. header = document.querySelector('.post__actions');
  141. listener = async () => await downloadPostContent(await (await fetch(`/api/v1/${window.location.pathname}`)).json())
  142. } else if (window.location.href.match(/\/user\/\w+/)) {
  143. mode = 'user';
  144. header = document.querySelector('.user-header__actions');
  145. listener = async () => {
  146. for (let post of await getPosts(window.location.pathname)) await downloadPostContent(post)
  147. }
  148. } else if (window.location.href.match(/\/favorites/)) {
  149. mode = 'favor';
  150. header = document.querySelector('.dropdowns');
  151. const type = document.querySelector('.dropdowns>select:nth-child(2)>option:nth-child(1)');
  152. listener = async () => {
  153. const posts = type.selected !== true
  154. ? await (await fetch(`/api/v1/account/favorites?type=post`)).json()
  155. : await (async () => {
  156. const response = await fetch(`/api/v1/account/favorites?type=artist`)
  157. const result = []
  158. for (const artist of await response.json()) {
  159. result.push(...await getPosts(`${artist.service}/user/${artist.id}`))
  160. }
  161. return result;
  162. })()
  163. for (let post of posts) await downloadPostContent(post)
  164. }
  165. }
  166.  
  167. if (header) {
  168. const settings = document.createElement('button');
  169. settings.classList.add(`${mode}-header__settings`);
  170. settings.textContent = '⚙';
  171. settings.style = header.style
  172. settings.addEventListener('click', showDialog);
  173.  
  174. const download = document.createElement('button');
  175. download.classList.add(`${mode}-header__download`);
  176. download.innerHTML = `
  177. <span class="${mode}-header__download-icon">⬇</span>
  178. <span class="${mode}-header__download-text">下载</span>
  179. `;
  180.  
  181. download.addEventListener('click', listener);
  182.  
  183. header.appendChild(settings);
  184. header.appendChild(download);
  185. }
  186.  
  187. function showDialog() {
  188. swal.fire({
  189. title: '设置', html: `
  190. <div>
  191. <label for="rpc">Aria2 RPC地址</label>
  192. <input type="text" id="rpc" value="${data.rpc}">
  193. </div>
  194. <div>
  195. <label for="token">Aria2 Token</label>
  196. <input type="text" id="token" value="${data.token}">
  197. </div>
  198. <div>
  199. <label for="dir">下载目录</label>
  200. <textarea cols="20" id="dir">${data.dir}</textarea>
  201. <icon title=""></icon>
  202. </div>
  203. <div>
  204. <label for="vimMode">Vim模式</label>
  205. <input type="checkbox" id="vimMode" checked="${data.vimMode}">
  206. </div>
  207. `, showCancelButton: true, confirmButtonText: '保存', cancelButtonText: '取消'
  208. }).then((result) => {
  209. if (result.isConfirmed) {
  210. data.rpc = document.getElementById('rpc').value;
  211. data.token = document.getElementById('token').value;
  212. data.dir = document.getElementById('dir').value;
  213. data.vimMode = document.getElementById('vimMode').checked;
  214. location.reload();
  215. }
  216. });
  217. }
  218.  
  219. })();
  220.  
  221. async function replaceAsync(str, regex, asyncFn) {
  222. const promises = [];
  223. str.replace(regex, (match, ...args) => {
  224. const promise = asyncFn(match, ...args);
  225. promises.push(promise);
  226. });
  227. const data = await Promise.all(promises);
  228. return str.replace(regex, () => data.shift());
  229. }
  230.  
  231. async function getKemonoUrl(url) {
  232.  
  233. function getFanbox(creatorId) {
  234. // 同步执行promise
  235. return new Promise((resolve, reject) => {
  236. GM_xmlhttpRequest({
  237. method: "GET", url: `https://api.fanbox.cc/creator.get?creatorId=${creatorId}`, headers: {
  238. "Content-Type": "application/json",
  239. "Accept": "application/json",
  240. "Origin": "https://www.fanbox.cc",
  241. "Referer": "https://www.fanbox.cc/"
  242. }, onload: function (response) {
  243. if (response.status === 200) {
  244. resolve(JSON.parse(response.responseText))
  245. } else {
  246. reject({status: response.status, statusText: response.statusText})
  247. }
  248. }, onerror: function (response) {
  249. reject({status: response.status, statusText: response.statusText})
  250. }
  251. })
  252. })
  253. }
  254.  
  255. const pixiv_user = /https:\/\/www\.pixiv\.net\/users\/(\d+)/i;
  256. const fantia_user = /https:\/\/fantia\.jp\/fanclubs\/(\d+)(\/posts(\S+))?/i;
  257. const fanbox_user1 = /https:\/\/www\.fanbox\.cc\/@([^/]+)(?:\/posts\/(\d+))?/i;
  258. const fanbox_user2 = /https:\/\/(.+)\.fanbox\.cc(?:\/posts\/(\d+))?/i;
  259. const dlsite_user = /https:\/\/www.dlsite.com\/.+?\/profile\/=\/maker_id\/(RG\d+).html/i;
  260. const patreon_user1 = /https:\/\/www.patreon.com\/user\?u=(\d+)/i;
  261. const patreon_user2 = /https:\/\/www.patreon.com\/(\w+)/i;
  262. const patreon_post1 = /https:\/\/www.patreon.com\/posts\/(\d+)/i;
  263. const patreon_post2 = /https:\/\/www.patreon.com\/posts\/video-download-(\d+)/i;
  264.  
  265. let service;
  266. let id;
  267. let post = null;
  268.  
  269. if (pixiv_user.test(url)) {
  270. //pixiv artist
  271. service = "fanbox"
  272. id = url.match(pixiv_user)[1]
  273. } else if (fantia_user.test(url)) {
  274. //fantia
  275. service = "fantia"
  276. id = url.match(fantia_user)[1]
  277. } else if (dlsite_user.test(url)) {
  278. service = "dlsite"
  279. id = url.match(dlsite_user)[1]
  280. } else if (fanbox_user1.test(url) || fanbox_user2.test(url)) {
  281. //fanbox
  282. service = "fanbox"
  283. let matches = fanbox_user1.test(url) ? url.match(fanbox_user1) : url.match(fanbox_user2);
  284. id = (await getFanbox(matches[1])).body.user.userId.toString()
  285. post = matches[2]
  286. } else if (patreon_user1.test(url)) {
  287. // patreon
  288. service = "patreon"
  289. id = url.match(patreon_user1)[1]
  290. } else if (patreon_post1.test(url)) {
  291. // patreon post
  292. service = "patreon"
  293. post = url.match(patreon_post1)[1]
  294. } else if (patreon_post2.test(url)) {
  295. // patreon post
  296. service = "patreon"
  297. post = url.match(patreon_post2)[1]
  298. } else {
  299. return null;
  300. }
  301.  
  302. return [service, id, post]
  303. }
  304.  
  305. async function getPosts(path, order = 0) {
  306. let posts = [];
  307. while (true) {
  308. const response = await fetch(`/api/v1/${path}?o=${order}`)
  309. // TODO: 429 too many requests, 80 request per minute
  310. if (response.status === 429) {
  311. await new Promise(resolve => setTimeout(resolve, 60000))
  312. continue;
  313. }
  314. if (response.status !== 200) throw {status: response.status, statusText: response.statusText}
  315. const items = await response.json();
  316. posts.push(...items);
  317. if (items.length < 50) break;
  318. order += items.length;
  319. }
  320. return posts;
  321. }
  322.  
  323. /**
  324. * send request to aria2 for download
  325. * @param {string} rpc
  326. * @param {string} token
  327. * @param {string} file
  328. * @param {string} url
  329. */
  330. async function downloadContent(rpc, token, file, ...url) {
  331. const dir = file.replace(/(.+?[\/\\])[^\/\\]+$/, "$1");
  332. const out = file.slice(dir.length);
  333. const params = token === undefined
  334. ? out === ""
  335. ? [url, {"dir": dir}]
  336. : [url, {"dir": dir, "out": out}]
  337. : out === ""
  338. ? [`token:${token}`, url, {"dir": dir}]
  339. : [`token:${token}`, url, {"dir": dir, "out": out}]
  340. return new Promise((resolve, reject) => {
  341. GM_xmlhttpRequest({
  342. method: "POST", url: rpc, data: JSON.stringify({
  343. jsonrpc: "2.0", id: `kemono-${crypto.randomUUID()}`, method: "aria2.addUri", params: params
  344. }), onload: function (response) {
  345. if (response.status === 200) {
  346. resolve(JSON.parse(response.responseText))
  347. } else {
  348. reject({status: response.status, statusText: response.statusText})
  349. }
  350. }, onerror: function (response) {
  351. reject({status: response.status, statusText: response.statusText})
  352. }
  353. })
  354. })
  355. }
  356.  
  357. async function fetchDownloadDir(rpc, token) {
  358. return new Promise((resolve, reject) => {
  359. GM_xmlhttpRequest({
  360. method: "POST", url: rpc, headers: {
  361. "Content-Type": "application/json", "Accept": "application/json",
  362. }, data: JSON.stringify({
  363. jsonrpc: "2.0", id: "Kemono", method: "aria2.getGlobalOption", params: token ? [`token:${token}`] : []
  364. }), onload: function (response) {
  365. if (response.status === 200) {
  366. resolve(JSON.parse(response.responseText))
  367. } else {
  368. reject({status: response.status, statusText: response.statusText})
  369. }
  370. }, onerror: function (response) {
  371. reject({status: response.status, statusText: response.statusText})
  372. }
  373. })
  374. }).then(function (result) {
  375. return result.result.dir;
  376. })
  377. }