Bangumi shared book collections

共读 @ Bangumi。Ref: https://github.com/bangumi/scripts/tree/b0113743743dba35accb28e9b7b9da8cbbea6952/yonjar#%E7%94%A8%E6%88%B7%E8%AF%A6%E6%83%85%E7%88%AC%E5%8F%96

  1. // ==UserScript==
  2. // @name Bangumi shared book collections
  3. // @namespace http://tampermonkey.net/
  4. // @version 1.0.11
  5. // @author txfs19260817
  6. // @source https://github.com/txfs19260817/bangumi-shared-book-collections
  7. // @license WTFPL
  8. // @icon https://bangumi.tv/img/favicon.ico
  9. // @match http*://*.bangumi.tv/
  10. // @match http*://*.bgm.tv/
  11. // @match http*://*.chii.in/
  12. // @grant GM_registerMenuCommand
  13. // @grant GM_getValue
  14. // @grant GM_setValue
  15. // @description 共读 @ Bangumi。Ref: https://github.com/bangumi/scripts/tree/b0113743743dba35accb28e9b7b9da8cbbea6952/yonjar#%E7%94%A8%E6%88%B7%E8%AF%A6%E6%83%85%E7%88%AC%E5%8F%96
  16. // ==/UserScript==
  17.  
  18. /******/ (() => { // webpackBootstrap
  19. /******/ "use strict";
  20. var __webpack_exports__ = {};
  21.  
  22. ;// CONCATENATED MODULE: ./src/utils.ts
  23. const parseTimestamp = s => {
  24. if (!s.includes("ago")) {
  25. return new Date(s);
  26. }
  27. const now = new Date();
  28. const d = s.match(/(\d+)d/i)?.[1] || "0";
  29. const h = s.match(/(\d+)h/i)?.[1] || "0";
  30. const m = s.match(/(\d+)m/i)?.[1] || "0";
  31. now.setDate(now.getDate() - +d);
  32. now.setHours(now.getHours() - +h);
  33. now.setMinutes(now.getMinutes() - +m);
  34. return now;
  35. };
  36. const fetchHTMLDocument = (url, fetchMethod = "GET") => {
  37. return fetch(url, {
  38. method: fetchMethod,
  39. credentials: "include"
  40. }).then(r => r.text(), err => Promise.reject(err)).then(t => {
  41. const parser = new DOMParser();
  42. return parser.parseFromString(t, "text/html");
  43. });
  44. };
  45. function htmlToElement(html) {
  46. const template = document.createElement('template');
  47. html = html.trim(); // Never return a text node of whitespace as the result
  48. template.innerHTML = html;
  49. return template.content.firstChild;
  50. }
  51.  
  52. ;// CONCATENATED MODULE: ./src/CommentParser.ts
  53.  
  54. class CommentParser {
  55. uid = CommentParser.getUID();
  56. constructor(max_pages = 5, max_results = 100, show_stars = true, watchlist = []) {
  57. this.MAX_PAGES = max_pages;
  58. this.MAX_RESULTS = max_results;
  59. this.SHOW_STARS = show_stars;
  60. this.WATCHLIST = watchlist;
  61. }
  62. async fetchComments() {
  63. const readCollectionURL = `${location.origin}/book/list/${this.uid}/collect`;
  64. const readCollectionFirstPage = await fetchHTMLDocument(readCollectionURL);
  65. const maxPageNum = this.getMaxPageNumber(readCollectionFirstPage);
  66. const urls = Array.from({
  67. length: maxPageNum - 1
  68. }, (_, i) => `${readCollectionURL}?page=${i + 2}`);
  69. const followingPages = await Promise.all(urls.map(url => fetchHTMLDocument(url)));
  70. console.log(urls);
  71. const subjects = this.extractSubjects([readCollectionFirstPage, ...followingPages]);
  72. const watchlistSubjects = await this.fetchWatchlistSubjects(subjects);
  73. const comments = await this.fetchCommentDetails([...watchlistSubjects, ...subjects]);
  74. return this.sortAndFilterComments(comments);
  75. }
  76. extractSubjects(pages) {
  77. return pages.flatMap(page => Array.from(page.getElementById("browserItemList").children).map(child => ({
  78. url: child.firstElementChild.href + "/comments",
  79. title: child.getElementsByTagName('h3')[0].textContent.trim(),
  80. cover: child.getElementsByTagName('img')[0].src
  81. })));
  82. }
  83. async fetchWatchlistSubjects(subjects) {
  84. if (!this.WATCHLIST) {
  85. return [];
  86. }
  87. const sidSet = new Set(subjects.map(s => s.url.split("/").at(-2)));
  88. const filteredWatchlist = this.WATCHLIST.filter(id => !sidSet.has(id));
  89. return this.sids2subjects(filteredWatchlist);
  90. }
  91. async sids2subjects(sids) {
  92. const DOMs = await Promise.all(sids.map(sid => fetchHTMLDocument(`${location.origin}/subject/${sid}/comments`)));
  93. return DOMs.map((doc, index) => ({
  94. url: `${location.origin}/subject/${sids[index]}/comments`,
  95. title: doc.querySelector("#headerSubject > h1 > a").textContent,
  96. cover: doc.querySelector("#subject_inner_info > a > img").src
  97. }));
  98. }
  99. async fetchCommentDetails(subjects) {
  100. const commentPages = await Promise.all(subjects.map(subject => fetchHTMLDocument(subject.url)));
  101. return commentPages.flatMap((page, i) => this.extractCommentsFromPage(page, subjects[i]));
  102. }
  103. extractCommentsFromPage(page, subject) {
  104. const commentDivs = Array.from(page.getElementsByClassName("item clearit"));
  105. return commentDivs.map(c => this.parseCommentDivToBgmComment(c, subject));
  106. }
  107. parseCommentDivToBgmComment(commentDiv, subject) {
  108. const avatarElement = commentDiv.querySelector('.avatar > span');
  109. const userUrl = `${location.origin}${commentDiv.querySelector('a.avatar').getAttribute('href')}`;
  110. const username = commentDiv.querySelector('.text_container .l').textContent;
  111. const dateText = commentDiv.querySelector('.text_container small:last-of-type').textContent.split('@')[1].trim();
  112. const commentText = commentDiv.querySelector('.text_container p').textContent;
  113. const starElement = commentDiv.querySelector('.starlight');
  114. const stars = starElement ? +starElement.classList.value.match(/\d+/)?.[0] ?? 0 : 0;
  115. return {
  116. subjectCover: subject.cover,
  117. subjectTitle: subject.title,
  118. subjectUrl: subject.url,
  119. userAvatarElement: this.createAvatarElement(avatarElement.style.backgroundImage, userUrl),
  120. userUrl,
  121. username,
  122. date: parseTimestamp(dateText),
  123. comment: commentText,
  124. stars
  125. };
  126. }
  127. createAvatarElement(imageUrl, userUrl) {
  128. const outerSpan = document.createElement('span');
  129. outerSpan.classList.add('avatar');
  130. const anchor = document.createElement('a');
  131. anchor.href = userUrl;
  132. anchor.classList.add('avatar');
  133. const imgSpan = document.createElement('span');
  134. imgSpan.classList.add('avatarNeue', 'avatarReSize40', 'll');
  135. imgSpan.style.backgroundImage = imageUrl;
  136. anchor.appendChild(imgSpan);
  137. outerSpan.appendChild(anchor);
  138. return outerSpan;
  139. }
  140. commentDataToTLList(comments) {
  141. const ul = document.createElement("ul");
  142. comments.forEach(comment => {
  143. const li = this.createDetailedLI(comment);
  144. ul.appendChild(li);
  145. });
  146. return ul;
  147. }
  148. createDetailedLI(comment) {
  149. const li = document.createElement('li');
  150. li.className = 'clearit tml_item';
  151. li.appendChild(comment.userAvatarElement);
  152. const coverAnchor = document.createElement('a');
  153. coverAnchor.href = comment.subjectUrl;
  154. coverAnchor.className = 'l rr';
  155. const coverSpan = document.createElement('span');
  156. coverSpan.className = 'cover';
  157. const img = document.createElement('img');
  158. img.src = comment.subjectCover;
  159. img.alt = comment.subjectTitle;
  160. img.width = 60;
  161. coverSpan.appendChild(img);
  162. coverAnchor.appendChild(coverSpan);
  163. li.appendChild(coverAnchor);
  164. const infoSpan = document.createElement('span');
  165. infoSpan.className = 'info clearit';
  166. const userAnchor = document.createElement('a');
  167. userAnchor.href = comment.userUrl;
  168. userAnchor.className = 'l';
  169. userAnchor.textContent = comment.username;
  170. infoSpan.appendChild(userAnchor);
  171. const readText = document.createTextNode(' 读过 ');
  172. infoSpan.appendChild(readText);
  173. const subjectAnchor = document.createElement('a');
  174. subjectAnchor.href = comment.subjectUrl;
  175. subjectAnchor.className = 'l';
  176. subjectAnchor.textContent = comment.subjectTitle;
  177. infoSpan.appendChild(subjectAnchor);
  178. const collectInfoDiv = document.createElement('div');
  179. collectInfoDiv.className = 'collectInfo';
  180. const commentDiv = document.createElement('div');
  181. commentDiv.className = 'comment';
  182. commentDiv.textContent = comment.comment;
  183. if (this.SHOW_STARS && comment.stars > 0) {
  184. const starsSpan = document.createElement('span');
  185. starsSpan.className = 'starstop-s';
  186. const starlightSpan = document.createElement('span');
  187. starlightSpan.className = `starlight stars${comment.stars}`;
  188. starsSpan.appendChild(starlightSpan);
  189. commentDiv.appendChild(starsSpan);
  190. }
  191. collectInfoDiv.appendChild(commentDiv);
  192. infoSpan.appendChild(collectInfoDiv);
  193. const dateDiv = document.createElement('div');
  194. dateDiv.className = 'post_actions date';
  195. dateDiv.textContent = comment.date.toLocaleString();
  196. infoSpan.appendChild(dateDiv);
  197. li.appendChild(infoSpan);
  198. return li;
  199. }
  200. getMaxPageNumber(firstPage) {
  201. const paginator = firstPage.getElementsByClassName('page_inner')[0];
  202. const pageLinks = Array.from(paginator.childNodes).filter(node => node instanceof HTMLAnchorElement).map(node => +node.href.match(/[0-9]+$/)[0]);
  203. return Math.min(this.MAX_PAGES, Math.max(...pageLinks));
  204. }
  205. sortAndFilterComments(comments) {
  206. return comments.filter(comment => !comment.userUrl.includes(this.uid)).sort((a, b) => +b.date - +a.date).slice(0, this.MAX_RESULTS);
  207. }
  208. static getUID() {
  209. return document.querySelector("#headerNeue2 > div > div.idBadgerNeue > a").href.split("user/")[1];
  210. }
  211. }
  212. ;// CONCATENATED MODULE: ./src/Dialog.ts
  213.  
  214. const createSettingsDialog = () => {
  215. const dialog = htmlToElement(`
  216. <dialog id="dialog">
  217. <form id="dialog-form" method="dialog">
  218. <h2>共读设置</h2>
  219. <h3>提交后请刷新以生效改动</h3>
  220. <div>
  221. <label for="maxpages">获取最近读过的前多少页条目的评论:</label>
  222. <input id="maxpages" name="maxpages" type="number" value="${GM_getValue("maxpages") || cp.MAX_PAGES}" min="1" />
  223. </div>
  224. <div>
  225. <label for="maxresults">最多显示评论的数目:</label>
  226. <input id="maxresults" name="maxresults" type="number" value="${GM_getValue("maxresults") || cp.MAX_RESULTS}" min="1" />
  227. </div>
  228. <div>
  229. <label for="showstars">显示评分:</label>
  230. <input type="hidden" name="showstars" value="false" />
  231. <input id="showstars" name="showstars" type="checkbox" value="true" ${GM_getValue("showstars") ? "checked" : ""} />
  232. </div>
  233. <div>
  234. <label for="disablesettings">不在首页显示设置按钮:</label>
  235. <input type="hidden" name="disablesettings" value="false" />
  236. <input id="disablesettings" name="disablesettings" type="checkbox" value="true" ${GM_getValue("disablesettings") ? "checked" : ""} />
  237. <p style="color: gray;">(控制设置按钮在首页的可见性,选中后仍可在Tampermonkey类插件中设置)</p>
  238. </div>
  239. <div>
  240. <label for="watchlist">关注列表(每行一个条目数字id,列表中的条目的最新评论一定会被收集):</label>
  241. <br />
  242. <textarea id="watchlist" name="watchlist" class="quick" rows="6" cols="10" placeholder="例:\n326125\n329803">${GM_getValue("watchlist").map(s => s.trim()).join("\n")}</textarea>
  243. </div>
  244. <div>
  245. <button type="submit">Submit</button>
  246. <button type="reset">Reset</button>
  247. <button type="button" onclick="document.getElementById('dialog').close()">Close</button>
  248. </div>
  249. </form>
  250. </dialog>`);
  251. dialog.firstElementChild.addEventListener("submit", function (e) {
  252. e.preventDefault();
  253. const data = new FormData(e.target);
  254. [...data.entries()].forEach(kv => {
  255. const k = kv[0];
  256. let v = kv[1];
  257. if (k === "watchlist") {
  258. v = kv[1].split("\n").filter(n => Number.isInteger(Number(n)) && Number(n) > 0);
  259. } else if (k === "showstars" || k === "disablesettings") {
  260. v = v === "true";
  261. }
  262. GM_setValue(k, v);
  263. });
  264. dialog.close();
  265. });
  266.  
  267. // dialog style
  268. dialog.style.borderRadius = "12px";
  269. dialog.style.borderColor = "#F09199";
  270. dialog.style.boxShadow = "0 0 #0000, 0 0 #0000, 0 25px 50px -12px rgba(0, 0, 0, 0.25)";
  271.  
  272. // inject dialog element
  273. document.body.appendChild(dialog);
  274.  
  275. // userscript menu
  276. GM_registerMenuCommand("设置", () => {
  277. dialog.showModal();
  278. });
  279. };
  280. ;// CONCATENATED MODULE: ./src/TabItem.ts
  281. class TabItem {
  282. states = {
  283. loading: {
  284. text: "⏳",
  285. cursor: "wait"
  286. },
  287. done: {
  288. text: "共读",
  289. cursor: "pointer"
  290. }
  291. };
  292. li = document.createElement("li");
  293. a = document.createElement("a");
  294. constructor(disable_settings = false) {
  295. this.DISABLE_SETTINGS = disable_settings;
  296. // initialize
  297. this.a.id = "tab_bsbc";
  298. this.applyState(this.states.loading);
  299. this.li.appendChild(this.a);
  300. document.getElementById('timelineTabs').appendChild(this.li);
  301. }
  302. settingAnchor() {
  303. const a = document.createElement("a");
  304. a.text = "⚙️设置";
  305. a.style.cursor = "pointer";
  306. a.onclick = function () {
  307. document.getElementById("dialog").showModal();
  308. };
  309. const li = document.createElement("li");
  310. li.appendChild(a);
  311. return li;
  312. }
  313. applyState(state) {
  314. this.a.text = state.text;
  315. this.a.style.cursor = state.cursor;
  316. }
  317. loaded(...nodes) {
  318. this.applyState(this.states.done);
  319. // add onclick handler
  320. const a = this.a;
  321. this.a.onclick = function () {
  322. if (a.classList.contains("focus")) return;
  323. ["tab_all", "tab_say", "tab_subject", "tab_progress", "tab_blog"].forEach(id => {
  324. document.getElementById(id).classList.remove("focus");
  325. });
  326. a.classList.add("focus");
  327. document.getElementById("timeline").replaceChildren(...nodes);
  328. };
  329. if (!this.DISABLE_SETTINGS) {
  330. // add settings button
  331. document.getElementById('timelineTabs').appendChild(this.settingAnchor());
  332. }
  333. }
  334. }
  335. ;// CONCATENATED MODULE: ./src/index.ts
  336.  
  337.  
  338.  
  339. async function main() {
  340. const tabItem = new TabItem(!!GM_getValue("disablesettings"));
  341. const cp = new CommentParser(GM_getValue("maxpages"), GM_getValue("maxresults"), !!GM_getValue("showstars"), GM_getValue("watchlist"));
  342. cp.fetchComments().then(data => {
  343. createSettingsDialog();
  344. tabItem.loaded(cp.commentDataToTLList(data)); // TODO: pagination?
  345. });
  346. }
  347. main().catch(e => {
  348. console.error(e);
  349. });
  350. /******/ })()
  351. ;

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址