Annict Following Viewings

Display following viewings on Annict work page.

当前为 2023-09-20 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Annict Following Viewings
  3. // @namespace https://github.com/SlashNephy
  4. // @version 0.3.2
  5. // @author SlashNephy
  6. // @description Display following viewings on Annict work page.
  7. // @description:ja Annictの作品ページにフォロー中のユーザーの視聴状況を表示します。
  8. // @homepage https://scrapbox.io/slashnephy/Annict_%E3%81%AE%E4%BD%9C%E5%93%81%E3%83%9A%E3%83%BC%E3%82%B8%E3%81%AB%E3%83%95%E3%82%A9%E3%83%AD%E3%83%BC%E4%B8%AD%E3%81%AE%E3%83%A6%E3%83%BC%E3%82%B6%E3%83%BC%E3%81%AE%E8%A6%96%E8%81%B4%E7%8A%B6%E6%B3%81%E3%82%92%E8%A1%A8%E7%A4%BA%E3%81%99%E3%82%8B_UserScript
  9. // @homepageURL https://scrapbox.io/slashnephy/Annict_%E3%81%AE%E4%BD%9C%E5%93%81%E3%83%9A%E3%83%BC%E3%82%B8%E3%81%AB%E3%83%95%E3%82%A9%E3%83%AD%E3%83%BC%E4%B8%AD%E3%81%AE%E3%83%A6%E3%83%BC%E3%82%B6%E3%83%BC%E3%81%AE%E8%A6%96%E8%81%B4%E7%8A%B6%E6%B3%81%E3%82%92%E8%A1%A8%E7%A4%BA%E3%81%99%E3%82%8B_UserScript
  10. // @icon https://www.google.com/s2/favicons?sz=64&domain=annict.com
  11. // @supportURL https://github.com/SlashNephy/.github/issues
  12. // @match https://annict.com/*
  13. // @require https://cdn.jsdelivr.net/gh/sizzlemctwizzle/GM_config@2207c5c1322ebb56e401f03c2e581719f909762a/gm_config.js
  14. // @connect api.annict.com
  15. // @connect raw.githubusercontent.com
  16. // @connect graphql.anilist.co
  17. // @grant GM_xmlhttpRequest
  18. // @grant GM_getValue
  19. // @grant GM_setValue
  20. // @grant GM_deleteValue
  21. // @license MIT license
  22. // ==/UserScript==
  23.  
  24. (function () {
  25. 'use strict';
  26.  
  27. /**
  28. * Checks whether given array's length is equal to given number.
  29. *
  30. * @example
  31. * ```ts
  32. * hasLength(arr, 1) // equivalent to arr.length === 1
  33. * ```
  34. */
  35. /**
  36. * Checks whether given array's length is greather than or equal to given number.
  37. *
  38. * @example
  39. * ```ts
  40. * hasMinLength(arr, 1) // equivalent to arr.length >= 1
  41. * ```
  42. */
  43. function hasMinLength(arr, length) {
  44. return arr.length >= length;
  45. }
  46.  
  47. async function fetchAniListViewer(token) {
  48. const response = await fetch('https://graphql.anilist.co', {
  49. method: 'POST',
  50. body: JSON.stringify({
  51. query: `
  52. query {
  53. Viewer {
  54. id
  55. }
  56. }
  57. `,
  58. }),
  59. headers: {
  60. 'Content-Type': 'application/json',
  61. authorization: `Bearer ${token}`,
  62. },
  63. });
  64. return response.json();
  65. }
  66. async function fetchAniListFollowings(userId, page, token) {
  67. const response = await fetch('https://graphql.anilist.co', {
  68. method: 'POST',
  69. body: JSON.stringify({
  70. query: `
  71. query($userId: Int!, $page: Int!) {
  72. Page(page: $page, perPage: 50) {
  73. followers(userId: $userId) {
  74. id
  75. }
  76. pageInfo {
  77. hasNextPage
  78. }
  79. }
  80. }
  81. `,
  82. variables: {
  83. userId,
  84. page,
  85. },
  86. }),
  87. headers: {
  88. 'Content-Type': 'application/json',
  89. authorization: `Bearer ${token}`,
  90. },
  91. });
  92. return response.json();
  93. }
  94. async function fetchPaginatedAniListFollowings(userId, token) {
  95. const results = [];
  96. let page = 1;
  97. while (true) {
  98. const response = await fetchAniListFollowings(userId, page, token);
  99. if ('errors' in response) {
  100. return response;
  101. }
  102. results.push(response);
  103. if (!response.data.Page.pageInfo.hasNextPage) {
  104. break;
  105. }
  106. page++;
  107. }
  108. return results;
  109. }
  110. async function fetchAniListFollowingStatuses(mediaId, userIds, page, token) {
  111. const response = await fetch('https://graphql.anilist.co', {
  112. method: 'POST',
  113. body: JSON.stringify({
  114. query: `
  115. query($mediaId: Int!, $userIds: [Int!]!, $page: Int!) {
  116. Page(page: $page, perPage: 50) {
  117. mediaList(type: ANIME, mediaId: $mediaId, userId_in: $userIds) {
  118. user {
  119. name
  120. avatar {
  121. large
  122. }
  123. }
  124. status
  125. score
  126. }
  127. pageInfo {
  128. hasNextPage
  129. }
  130. }
  131. }
  132. `,
  133. variables: {
  134. mediaId,
  135. userIds,
  136. page,
  137. },
  138. }),
  139. headers: {
  140. 'Content-Type': 'application/json',
  141. authorization: `Bearer ${token}`,
  142. },
  143. });
  144. return response.json();
  145. }
  146. async function fetchPaginatedAniListFollowingStatuses(mediaId, userIds, token) {
  147. const results = [];
  148. let page = 1;
  149. while (true) {
  150. const response = await fetchAniListFollowingStatuses(mediaId, userIds, page, token);
  151. if ('errors' in response) {
  152. return response;
  153. }
  154. results.push(response);
  155. if (!response.data.Page.pageInfo.hasNextPage) {
  156. break;
  157. }
  158. page++;
  159. }
  160. return results;
  161. }
  162.  
  163. async function fetchAnnictFollowingStatuses(workId, cursor, token) {
  164. const response = await fetch('https://api.annict.com/graphql', {
  165. method: 'POST',
  166. body: JSON.stringify({
  167. query: `
  168. query($workId: Int!, $cursor: String) {
  169. viewer {
  170. following(after: $cursor) {
  171. nodes {
  172. name
  173. username
  174. avatarUrl
  175. watched: works(annictIds: [$workId], state: WATCHED) {
  176. nodes {
  177. annictId
  178. }
  179. }
  180. watching: works(annictIds: [$workId], state: WATCHING) {
  181. nodes {
  182. annictId
  183. }
  184. }
  185. stopWatching: works(annictIds: [$workId], state: STOP_WATCHING) {
  186. nodes {
  187. annictId
  188. }
  189. }
  190. onHold: works(annictIds: [$workId], state: ON_HOLD) {
  191. nodes {
  192. annictId
  193. }
  194. }
  195. wannaWatch: works(annictIds: [$workId], state: WANNA_WATCH) {
  196. nodes {
  197. annictId
  198. }
  199. }
  200. }
  201. pageInfo {
  202. hasNextPage
  203. endCursor
  204. }
  205. }
  206. }
  207. }
  208. `,
  209. variables: {
  210. workId,
  211. cursor,
  212. },
  213. }),
  214. headers: {
  215. 'Content-Type': 'application/json',
  216. authorization: `Bearer ${token}`,
  217. },
  218. });
  219. return response.json();
  220. }
  221. async function fetchPaginatedAnnictFollowingStatuses(workId, token) {
  222. const results = [];
  223. let cursor = null;
  224. while (true) {
  225. const response = await fetchAnnictFollowingStatuses(workId, cursor, token);
  226. if ('errors' in response) {
  227. return response;
  228. }
  229. results.push(response);
  230. if (!response.data.viewer.following.pageInfo.hasNextPage) {
  231. break;
  232. }
  233. cursor = response.data.viewer.following.pageInfo.endCursor;
  234. }
  235. return results;
  236. }
  237.  
  238. async function fetchArmEntries(branch = 'master') {
  239. const response = await fetch(`https://raw.githubusercontent.com/SlashNephy/arm-supplementary/${branch}/dist/arm.json`);
  240. return response.json();
  241. }
  242.  
  243. class GM_Value {
  244. key;
  245. defaultValue;
  246. constructor(key, defaultValue, initialize = true) {
  247. this.key = key;
  248. this.defaultValue = defaultValue;
  249. const value = GM_getValue(key, null);
  250. if (initialize && value === null) {
  251. GM_setValue(key, defaultValue);
  252. }
  253. }
  254. get() {
  255. return GM_getValue(this.key, this.defaultValue);
  256. }
  257. set(value) {
  258. GM_setValue(this.key, value);
  259. }
  260. delete() {
  261. GM_deleteValue(this.key);
  262. }
  263. pop() {
  264. const value = this.get();
  265. this.delete();
  266. return value;
  267. }
  268. }
  269.  
  270. const annictTokenKey = 'annict_token';
  271. const anilistTokenKey = 'anilist_token';
  272. const anilistCallbackKey = 'anilist_callback';
  273. const anilistClientId = '12566';
  274. const style = document.createElement('style');
  275. document.head.appendChild(style);
  276. GM_config.init({
  277. id: 'annict_following_viewings',
  278. title: 'Annict Following Viewings 設定',
  279. fields: {
  280. [annictTokenKey]: {
  281. label: 'Annict 個人用アクセストークン',
  282. type: 'text',
  283. default: '',
  284. },
  285. [anilistTokenKey]: {
  286. label: 'AniList アクセストークン',
  287. type: 'text',
  288. default: '',
  289. },
  290. anilistAuthorizeLabel: {
  291. type: 'label',
  292. },
  293. [anilistCallbackKey]: {
  294. type: 'hidden',
  295. },
  296. },
  297. types: {
  298. label: {
  299. default: null,
  300. toNode() {
  301. const anchor = document.createElement('a');
  302. anchor.classList.add('authorize');
  303. anchor.href = `https://anilist.co/api/v2/oauth/authorize?client_id=${anilistClientId}&response_type=token`;
  304. anchor.textContent = 'AniList と連携する';
  305. anchor.target = '_top';
  306. anchor.addEventListener('click', () => {
  307. GM_config.set(anilistCallbackKey, window.location.href);
  308. GM_config.write();
  309. });
  310. return anchor;
  311. },
  312. toValue() {
  313. return null;
  314. },
  315. reset() { },
  316. },
  317. },
  318. events: {
  319. open() {
  320. style.textContent = `
  321. .l-default {
  322. filter: blur(10px);
  323. }
  324. iframe#annict_following_viewings {
  325. border: 0 !important;
  326. border-radius: 20px;
  327. height: 40% !important;
  328. width: 50% !important;
  329. left: 25% !important;
  330. top: 33% !important;
  331. opacity: 0.9 !important;
  332. }
  333. `;
  334. },
  335. close() {
  336. style.textContent = '';
  337. },
  338. save() {
  339. window.location.reload();
  340. },
  341. },
  342. css: `
  343. body {
  344. background: #33363a !important;
  345. color: #e9ecef !important;
  346. -webkit-font-smoothing: antialiased !important;
  347. text-rendering: optimizeSpeed !important;
  348. }
  349. .config_header {
  350. font-weight: 700 !important;
  351. font-size: 1.75rem !important;
  352. padding: 1em !important;
  353. }
  354. .config_var {
  355. padding: 2em !important;
  356. }
  357. .field_label {
  358. font-weight: normal !important;
  359. font-size: 1.2rem !important;
  360. }
  361. input {
  362. background-color: #212529 !important;
  363. color: #e9ecef;
  364. display: block;
  365. width: 100%;
  366. padding: 0.375rem 0.75rem;
  367. font-size: 1rem;
  368. font-weight: 400;
  369. line-height: 1.5;
  370. background-clip: padding-box;
  371. border: 1px solid #495057;
  372. appearance: none;
  373. border-radius: 0.3rem;
  374. transition: border-color .15s ease-in-out,box-shadow .15s ease-in-out;
  375. }
  376. div:has(> .saveclose_buttons) {
  377. text-align: center !important;
  378. }
  379. .saveclose_buttons {
  380. box-sizing: border-box;
  381. display: inline-block;
  382. font-weight: 400;
  383. line-height: 1.5;
  384. vertical-align: middle;
  385. cursor: pointer;
  386. user-select: none;
  387. border: 1px solid transparent;
  388. padding: 0.375rem 0.75rem !important;
  389. font-size: 1rem;
  390. border-radius: 50rem;
  391. transition: color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;
  392. color: #fff;
  393. background-color: #d51c5b;
  394. border-color: #d51c5b;
  395. -webkit-appearance: button;
  396. }
  397. .reset {
  398. color: #e9ecef !important;
  399. }
  400. a.authorize {
  401. color: #7ca1f3;
  402. text-decoration: none;
  403. padding-left: 2em;
  404. }
  405. div#annict_following_viewings_anilist_callback_var {
  406. display: none;
  407. }
  408. `,
  409. });
  410. const migrate = () => {
  411. const annictTokenRef = new GM_Value('ANNICT_TOKEN');
  412. const annictToken = annictTokenRef.pop();
  413. if (annictToken !== undefined) {
  414. GM_config.set(annictTokenKey, annictToken);
  415. }
  416. };
  417. const parseAnnictFollowingStatuses = (response) => response.data.viewer.following.nodes
  418. .map((u) => {
  419. let label;
  420. let iconClasses;
  421. let iconColor;
  422. if (u.watched.nodes.length > 0) {
  423. label = '見た';
  424. iconClasses = ['far', 'fa-check'];
  425. iconColor = '--ann-status-completed-color';
  426. }
  427. else if (u.watching.nodes.length > 0) {
  428. label = '見てる';
  429. iconClasses = ['far', 'fa-play'];
  430. iconColor = '--ann-status-watching-color';
  431. }
  432. else if (u.stopWatching.nodes.length > 0) {
  433. label = '視聴停止';
  434. iconClasses = ['far', 'fa-stop'];
  435. iconColor = '--ann-status-dropped-color';
  436. }
  437. else if (u.onHold.nodes.length > 0) {
  438. label = '一時中断';
  439. iconClasses = ['far', 'fa-pause'];
  440. iconColor = '--ann-status-on-hold-color';
  441. }
  442. else if (u.wannaWatch.nodes.length > 0) {
  443. label = '見たい';
  444. iconClasses = ['far', 'fa-circle'];
  445. iconColor = '--ann-status-plan-to-watch-color';
  446. }
  447. else {
  448. return null;
  449. }
  450. return {
  451. name: u.name,
  452. service: 'annict',
  453. username: u.username,
  454. avatarUrl: u.avatarUrl,
  455. label,
  456. iconClasses,
  457. iconColor,
  458. };
  459. })
  460. .filter((x) => !!x);
  461. const parseAniListFollowingStatuses = (response) => response.data.Page.mediaList.map((u) => {
  462. let label;
  463. let iconClasses;
  464. let iconColor;
  465. switch (u.status) {
  466. case 'CURRENT':
  467. label = '見てる';
  468. iconClasses = ['far', 'fa-play'];
  469. iconColor = '--ann-status-watching-color';
  470. break;
  471. case 'PLANNING':
  472. label = '見たい';
  473. iconClasses = ['far', 'fa-circle'];
  474. iconColor = '--ann-status-plan-to-watch-color';
  475. break;
  476. case 'COMPLETED':
  477. label = '見た';
  478. iconClasses = ['far', 'fa-check'];
  479. iconColor = '--ann-status-completed-color';
  480. break;
  481. case 'DROPPED':
  482. label = '視聴停止';
  483. iconClasses = ['far', 'fa-stop'];
  484. iconColor = '--ann-status-dropped-color';
  485. break;
  486. case 'PAUSED':
  487. label = '一時中断';
  488. iconClasses = ['far', 'fa-pause'];
  489. iconColor = '--ann-status-on-hold-color';
  490. break;
  491. case 'REPEATING':
  492. label = 'リピート中';
  493. iconClasses = ['far', 'fa-forward'];
  494. iconColor = '--ann-status-watching-color';
  495. break;
  496. }
  497. return {
  498. name: u.user.name,
  499. service: 'anilist',
  500. username: u.user.name,
  501. avatarUrl: u.user.avatar.large,
  502. label: u.score > 0 ? `${label} (${u.score} / 10)` : label,
  503. iconClasses,
  504. iconColor,
  505. };
  506. });
  507. const annictWorkPageUrlPattern = /^https:\/\/annict\.com\/works\/(\d+)/;
  508. const renderSectionTitle = () => {
  509. const title = document.createElement('div');
  510. title.classList.add('container', 'mt-5');
  511. {
  512. const div = document.createElement('div');
  513. div.classList.add('d-flex', 'justify-content-between');
  514. title.appendChild(div);
  515. }
  516. {
  517. const h2 = document.createElement('h2');
  518. h2.classList.add('fw-bold', 'h3', 'mb-3');
  519. h2.textContent = 'フォロー中のユーザーの視聴状況';
  520. title.appendChild(h2);
  521. }
  522. return title;
  523. };
  524. const renderSectionBody = () => {
  525. const body = document.createElement('div');
  526. body.classList.add('container', 'u-container-flat');
  527. {
  528. const card = document.createElement('div');
  529. card.classList.add('card', 'u-card-flat');
  530. body.appendChild(card);
  531. {
  532. const cardBody = document.createElement('div');
  533. {
  534. cardBody.classList.add('card-body');
  535. const loading = document.createElement('div');
  536. loading.classList.add('loading');
  537. loading.textContent = '読み込み中...';
  538. cardBody.appendChild(loading);
  539. }
  540. const row = document.createElement('div');
  541. row.classList.add('row', 'g-3');
  542. cardBody.appendChild(row);
  543. card.appendChild(cardBody);
  544. return [body, cardBody, row];
  545. }
  546. }
  547. };
  548. const renderSectionBodyContent = (row, statuses) => {
  549. for (const status of statuses) {
  550. const col = document.createElement('div');
  551. col.classList.add('col-6', 'col-sm-3');
  552. col.style.display = 'flex';
  553. row.appendChild(col);
  554. {
  555. const avatarCol = document.createElement('div');
  556. avatarCol.classList.add('col-auto', 'pe-0');
  557. col.appendChild(avatarCol);
  558. {
  559. const a = document.createElement('a');
  560. if (status.service === 'annict') {
  561. a.href = `/@${status.username}`;
  562. }
  563. else {
  564. a.href = `https://anilist.co/user/${status.username}`;
  565. a.target = '_blank';
  566. }
  567. avatarCol.appendChild(a);
  568. {
  569. const img = document.createElement('img');
  570. img.classList.add('img-thumbnail', 'rounded-circle');
  571. img.style.width = '50px';
  572. img.style.height = '50px';
  573. img.style.marginRight = '1em';
  574. img.src = status.avatarUrl;
  575. a.appendChild(img);
  576. }
  577. }
  578. const userCol = document.createElement('div');
  579. userCol.classList.add('col');
  580. col.appendChild(userCol);
  581. {
  582. const div1 = document.createElement('div');
  583. userCol.appendChild(div1);
  584. {
  585. const a = document.createElement('a');
  586. a.classList.add('fw-bold', 'me-1', 'text-body');
  587. if (status.service === 'annict') {
  588. a.href = `/@${status.username}`;
  589. }
  590. else {
  591. a.href = `https://anilist.co/user/${status.username}`;
  592. a.target = '_blank';
  593. }
  594. div1.appendChild(a);
  595. {
  596. const span = document.createElement('span');
  597. span.classList.add('me-1');
  598. span.textContent = status.name;
  599. a.appendChild(span);
  600. }
  601. {
  602. const small = document.createElement('small');
  603. small.style.marginRight = '1em';
  604. small.classList.add('text-muted');
  605. if (status.service === 'annict') {
  606. small.textContent = `@${status.username}`;
  607. }
  608. a.appendChild(small);
  609. }
  610. }
  611. const div2 = document.createElement('div');
  612. div2.classList.add('small', 'text-body');
  613. userCol.appendChild(div2);
  614. {
  615. const i = document.createElement('i');
  616. i.classList.add(...status.iconClasses);
  617. i.style.color = `var(${status.iconColor})`;
  618. div2.appendChild(i);
  619. }
  620. {
  621. const small = document.createElement('small');
  622. small.style.marginLeft = '5px';
  623. small.textContent = status.label;
  624. div2.appendChild(small);
  625. }
  626. }
  627. }
  628. }
  629. };
  630. const handle = async () => {
  631. if (window.location.pathname === '/') {
  632. const hash = new URLSearchParams(window.location.hash.substring(1));
  633. const token = hash.get('access_token');
  634. if (token !== null) {
  635. GM_config.set(anilistTokenKey, token);
  636. window.location.hash = '';
  637. alert('[Annict Following Viewings] AniList と接続しました。');
  638. const callback = GM_config.get(anilistCallbackKey);
  639. GM_config.set(anilistCallbackKey, '');
  640. GM_config.write();
  641. if (typeof callback === 'string' && callback.length > 0) {
  642. window.location.href = callback;
  643. }
  644. }
  645. return;
  646. }
  647. const workMatch = annictWorkPageUrlPattern.exec(window.location.href);
  648. if (!workMatch || !hasMinLength(workMatch, 2)) {
  649. return;
  650. }
  651. const annictWorkId = parseInt(workMatch[1], 10);
  652. if (!annictWorkId) {
  653. throw new Error('failed to extract Annict work ID');
  654. }
  655. const header = document.querySelector('.c-work-header');
  656. if (header === null) {
  657. throw new Error('failed to find .c-work-header');
  658. }
  659. const title = renderSectionTitle();
  660. header.insertAdjacentElement('afterend', title);
  661. const [body, card, row] = renderSectionBody();
  662. title.insertAdjacentElement('afterend', body);
  663. const settingsAnchor = document.createElement('a');
  664. settingsAnchor.href = 'about:blank';
  665. settingsAnchor.textContent = '設定';
  666. settingsAnchor.addEventListener('click', (e) => {
  667. e.preventDefault();
  668. e.stopPropagation();
  669. GM_config.open();
  670. });
  671. const annictToken = GM_config.get(annictTokenKey);
  672. const anilistToken = GM_config.get(anilistTokenKey);
  673. if (!annictToken && !anilistToken) {
  674. const guideAnchor = document.createElement('a');
  675. guideAnchor.href =
  676. 'https://scrapbox.io/slashnephy/Annict_%E3%81%AE%E4%BD%9C%E5%93%81%E3%83%9A%E3%83%BC%E3%82%B8%E3%81%AB%E3%83%95%E3%82%A9%E3%83%AD%E3%83%BC%E4%B8%AD%E3%81%AE%E3%83%A6%E3%83%BC%E3%82%B6%E3%83%BC%E3%81%AE%E8%A6%96%E8%81%B4%E7%8A%B6%E6%B3%81%E3%82%92%E8%A1%A8%E7%A4%BA%E3%81%99%E3%82%8B_UserScript';
  677. guideAnchor.textContent = 'ガイド';
  678. guideAnchor.target = '_blank';
  679. card.textContent = '';
  680. card.append('Annict Following Viewings の動作にはアクセストークンの設定が必要です。', guideAnchor, 'を参考に', settingsAnchor, 'を行ってください。');
  681. return;
  682. }
  683. card.append(document.createElement('br'), settingsAnchor);
  684. const promises = [];
  685. if (typeof annictToken === 'string' && annictToken.length > 0) {
  686. promises.push(insertAnnictFollowingStatuses(annictWorkId, annictToken, card, row));
  687. }
  688. if (typeof anilistToken === 'string' && anilistToken.length > 0) {
  689. promises.push(insertAniListFollowingStatuses(annictWorkId, anilistToken, card, row));
  690. }
  691. await Promise.all(promises);
  692. if (row.children.length === 0) {
  693. card.append('フォロー中のユーザーの視聴状況はありません。');
  694. }
  695. };
  696. const insertAnnictFollowingStatuses = async (annictWorkId, annictToken, card, row) => {
  697. const responses = await fetchPaginatedAnnictFollowingStatuses(annictWorkId, annictToken);
  698. card.querySelector('.loading')?.remove();
  699. if ('errors' in responses) {
  700. const error = responses.errors.map(({ message }) => message).join('\n');
  701. card.append(`Annict GraphQL API がエラーを返しました。\n${error}`);
  702. return;
  703. }
  704. const statuses = responses.map((r) => parseAnnictFollowingStatuses(r)).flat();
  705. if (statuses.length > 0) {
  706. renderSectionBodyContent(row, statuses);
  707. }
  708. };
  709. const insertAniListFollowingStatuses = async (annictWorkId, anilistToken, card, row) => {
  710. const armEntries = await fetchArmEntries();
  711. const mediaId = armEntries.find((x) => x.annict_id === annictWorkId)?.anilist_id;
  712. if (!mediaId) {
  713. return;
  714. }
  715. const viewerResponse = await fetchAniListViewer(anilistToken);
  716. card.querySelector('.loading')?.remove();
  717. if ('errors' in viewerResponse) {
  718. const error = viewerResponse.errors.map(({ message }) => message).join('\n');
  719. card.append(`AniList GraphQL API がエラーを返しました。\n${error}`);
  720. return;
  721. }
  722. const followingsResponses = await fetchPaginatedAniListFollowings(viewerResponse.data.Viewer.id, anilistToken);
  723. if ('errors' in followingsResponses) {
  724. const error = followingsResponses.errors.map(({ message }) => message).join('\n');
  725. card.append(`AniList GraphQL API がエラーを返しました。\n${error}`);
  726. return;
  727. }
  728. const followings = followingsResponses.map((r) => r.data.Page.followers.map((f) => f.id)).flat();
  729. const responses = await fetchPaginatedAniListFollowingStatuses(mediaId, followings, anilistToken);
  730. if ('errors' in responses) {
  731. const error = responses.errors.map(({ message }) => message).join('\n');
  732. card.append(`AniList GraphQL API がエラーを返しました。\n${error}`);
  733. return;
  734. }
  735. const statuses = responses.map((r) => parseAniListFollowingStatuses(r)).flat();
  736. if (statuses.length > 0) {
  737. renderSectionBodyContent(row, statuses);
  738. }
  739. };
  740. migrate();
  741. document.addEventListener('turbo:load', () => {
  742. handle().catch(console.error);
  743. });
  744.  
  745. })();