您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Display following viewings on Annict work page.
// ==UserScript== // @name Annict Following Viewings // @namespace https://github.com/SlashNephy // @version 0.4.2 // @author SlashNephy // @description Display following viewings on Annict work page. // @description:ja Annictの作品ページにフォロー中のユーザーの視聴状況を表示します。 // @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 // @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 // @icon https://www.google.com/s2/favicons?sz=64&domain=annict.com // @supportURL https://github.com/SlashNephy/userscripts/issues // @match https://annict.com/* // @require https://cdn.jsdelivr.net/gh/sizzlemctwizzle/GM_config@2207c5c1322ebb56e401f03c2e581719f909762a/gm_config.js // @connect api.annict.com // @connect raw.githubusercontent.com // @connect graphql.anilist.co // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @license MIT license // ==/UserScript== (function () { 'use strict'; /** * Checks whether given array's length is equal to given number. * * @example * ```ts * hasLength(arr, 1) // equivalent to arr.length === 1 * ``` */ /** * Checks whether given array's length is greather than or equal to given number. * * @example * ```ts * hasMinLength(arr, 1) // equivalent to arr.length >= 1 * ``` */ function hasMinLength(arr, length) { return arr.length >= length; } async function fetchAniListFollowingStatuses(mediaId, page, token) { const response = await fetch('https://graphql.anilist.co', { method: 'POST', body: JSON.stringify({ query: ` query($mediaId: Int!, $page: Int!) { Page(page: $page, perPage: 50) { mediaList(type: ANIME, mediaId: $mediaId, isFollowing: true) { user { name avatar { large } } status score progress media { episodes } notes } pageInfo { hasNextPage } } } `, variables: { mediaId, page, }, }), headers: { 'Content-Type': 'application/json', authorization: `Bearer ${token}`, }, }); return response.json(); } async function fetchPaginatedAniListFollowingStatuses(mediaId, token) { const results = []; let page = 1; while (true) { const response = await fetchAniListFollowingStatuses(mediaId, page, token); if ('errors' in response) { return response; } results.push(response); if (!response.data.Page.pageInfo.hasNextPage) { break; } page++; } return results; } async function fetchAnnictFollowingStatuses(workId, cursor, token) { const response = await fetch('https://api.annict.com/graphql', { method: 'POST', body: JSON.stringify({ query: ` query($workId: Int!, $cursor: String) { viewer { following(after: $cursor) { nodes { name username avatarUrl watched: works(annictIds: [$workId], state: WATCHED) { nodes { annictId } } watching: works(annictIds: [$workId], state: WATCHING) { nodes { annictId } } stopWatching: works(annictIds: [$workId], state: STOP_WATCHING) { nodes { annictId } } onHold: works(annictIds: [$workId], state: ON_HOLD) { nodes { annictId } } wannaWatch: works(annictIds: [$workId], state: WANNA_WATCH) { nodes { annictId } } } pageInfo { hasNextPage endCursor } } } } `, variables: { workId, cursor, }, }), headers: { 'Content-Type': 'application/json', authorization: `Bearer ${token}`, }, }); return response.json(); } async function fetchPaginatedAnnictFollowingStatuses(workId, token) { const results = []; let cursor = null; while (true) { const response = await fetchAnnictFollowingStatuses(workId, cursor, token); if ('errors' in response) { return response; } results.push(response); if (!response.data.viewer.following.pageInfo.hasNextPage) { break; } cursor = response.data.viewer.following.pageInfo.endCursor; } return results; } async function fetchArmEntries(branch = 'master') { const response = await fetch(`https://raw.githubusercontent.com/SlashNephy/arm-supplementary/${branch}/dist/arm.json`); return response.json(); } class GM_Value { key; defaultValue; constructor(key, defaultValue, initialize = true) { this.key = key; this.defaultValue = defaultValue; const value = GM_getValue(key, null); if (initialize && value === null) { GM_setValue(key, defaultValue); } } get() { return GM_getValue(this.key, this.defaultValue); } set(value) { GM_setValue(this.key, value); } delete() { GM_deleteValue(this.key); } pop() { const value = this.get(); this.delete(); return value; } } const annictTokenKey = 'annict_token'; const anilistTokenKey = 'anilist_token'; const anilistCallbackKey = 'anilist_callback'; const anilistClientId = '12566'; const style = document.createElement('style'); document.head.appendChild(style); GM_config.init({ id: 'annict_following_viewings', title: 'Annict Following Viewings 設定', fields: { [annictTokenKey]: { label: 'Annict 個人用アクセストークン', type: 'text', default: '', }, annictTokenButton: { type: 'annictTokenButton', }, [anilistTokenKey]: { label: 'AniList アクセストークン', type: 'text', default: '', }, anilistAuthorizeButton: { type: 'anilistAuthorizeButton', }, [anilistCallbackKey]: { type: 'hidden', }, }, types: { annictTokenButton: { default: null, toNode() { const div = document.createElement('div'); const anchor = document.createElement('a'); anchor.classList.add('button'); anchor.href = 'https://annict.com/settings/tokens/new'; anchor.textContent = 'Annict のアクセストークンを発行する'; anchor.target = '_blank'; div.appendChild(anchor); const description = document.createElement('p'); description.classList.add('description'); description.textContent = 'スコープは「読み取り専用」を選択してください。発行されたアクセストークンを上に貼り付けてください。'; div.appendChild(description); return div; }, toValue() { return null; }, reset() { }, }, anilistAuthorizeButton: { default: null, toNode() { const anchor = document.createElement('a'); anchor.classList.add('button'); anchor.href = `https://anilist.co/api/v2/oauth/authorize?client_id=${anilistClientId}&response_type=token`; anchor.textContent = 'AniList と連携する'; anchor.target = '_top'; anchor.addEventListener('click', () => { GM_config.set(anilistCallbackKey, window.location.href); GM_config.write(); }); return anchor; }, toValue() { return null; }, reset() { }, }, }, events: { open() { style.textContent = ` .l-default { filter: blur(10px); } iframe#annict_following_viewings { border: 0 !important; border-radius: 20px; height: 70% !important; width: 50% !important; left: 50% !important; top: 50% !important; opacity: 0.9 !important; transform: translate(-50%, -50%); } `; }, close() { style.textContent = ''; }, save() { window.location.reload(); }, }, css: ` body { background: #33363a !important; color: #e9ecef !important; -webkit-font-smoothing: antialiased !important; text-rendering: optimizeSpeed !important; } .config_header { font-weight: 700 !important; font-size: 1.75rem !important; padding: 1em !important; } .config_var { padding: 2em !important; } .field_label { font-weight: normal !important; font-size: 1.2rem !important; } input { background-color: #212529 !important; color: #e9ecef; display: block; width: 100%; padding: 0.375rem 0.75rem; font-size: 1rem; font-weight: 400; line-height: 1.5; background-clip: padding-box; border: 1px solid #495057; appearance: none; border-radius: 0.3rem; transition: border-color .15s ease-in-out,box-shadow .15s ease-in-out; } div:has(> .saveclose_buttons) { text-align: center !important; } .saveclose_buttons { box-sizing: border-box; display: inline-block; font-weight: 400; line-height: 1.5; vertical-align: middle; cursor: pointer; user-select: none; border: 1px solid transparent; padding: 0.375rem 0.75rem !important; font-size: 1rem; border-radius: 50rem; transition: color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out; color: #fff; background-color: #d51c5b; border-color: #d51c5b; -webkit-appearance: button; } .reset { color: #e9ecef !important; } a.button { color: #7ca1f3; text-decoration: none; padding-left: 2em; } p.description { padding-left: 3em; margin-top: 4px; } div#annict_following_viewings_anilist_callback_var { display: none; } `, }); const migrate = () => { const annictTokenRef = new GM_Value('ANNICT_TOKEN'); const annictToken = annictTokenRef.pop(); if (annictToken !== undefined) { GM_config.set(annictTokenKey, annictToken); } }; const parseAnnictFollowingStatuses = (response) => response.data.viewer.following.nodes .map((u) => { let label; let iconClasses; let iconColor; if (u.watched.nodes.length > 0) { label = '見た'; iconClasses = ['fa-solid', 'fa-check']; iconColor = '--ann-status-completed-color'; } else if (u.watching.nodes.length > 0) { label = '見てる'; iconClasses = ['fa-solid', 'fa-play']; iconColor = '--ann-status-watching-color'; } else if (u.stopWatching.nodes.length > 0) { label = '視聴停止'; iconClasses = ['fa-solid', 'fa-stop']; iconColor = '--ann-status-dropped-color'; } else if (u.onHold.nodes.length > 0) { label = '一時中断'; iconClasses = ['fa-solid', 'fa-pause']; iconColor = '--ann-status-on-hold-color'; } else if (u.wannaWatch.nodes.length > 0) { label = '見たい'; iconClasses = ['fa-solid', 'fa-circle']; iconColor = '--ann-status-plan-to-watch-color'; } else { return null; } return { name: u.name, service: 'annict', username: u.username, avatarUrl: u.avatarUrl, label, iconClasses, iconColor, }; }) .filter((x) => !!x); const parseAniListFollowingStatuses = (response) => response.data.Page.mediaList.map((u) => { let statusLabel; let iconClasses; let iconColor; switch (u.status) { case 'CURRENT': statusLabel = '見てる'; iconClasses = ['fa-solid', 'fa-play']; iconColor = '--ann-status-watching-color'; break; case 'PLANNING': statusLabel = '見たい'; iconClasses = ['fa-solid', 'fa-circle']; iconColor = '--ann-status-plan-to-watch-color'; break; case 'COMPLETED': statusLabel = '見た'; iconClasses = ['fa-solid', 'fa-check']; iconColor = '--ann-status-completed-color'; break; case 'DROPPED': statusLabel = '視聴停止'; iconClasses = ['fa-solid', 'fa-stop']; iconColor = '--ann-status-dropped-color'; break; case 'PAUSED': statusLabel = '一時中断'; iconClasses = ['fa-solid', 'fa-pause']; iconColor = '--ann-status-on-hold-color'; break; case 'REPEATING': statusLabel = 'リピート中'; iconClasses = ['fa-solid', 'fa-forward']; iconColor = '--ann-status-watching-color'; break; } let label = statusLabel; if (u.progress > 0 && u.progress !== u.media.episodes && u.status !== 'COMPLETED') { label += ` (${u.progress}話まで見た)`; } if (u.score > 0) { label += ` [${u.score} / 10]`; } return { name: u.user.name, service: 'anilist', username: u.user.name, avatarUrl: u.user.avatar.large, label, comment: u.notes ?? undefined, iconClasses, iconColor, }; }); const annictWorkPageUrlPattern = /^https:\/\/annict\.com\/works\/(\d+)/; const renderSectionTitle = () => { const title = document.createElement('div'); title.classList.add('container', 'mt-5'); { const div = document.createElement('div'); div.classList.add('d-flex', 'justify-content-between'); title.appendChild(div); } { const h2 = document.createElement('h2'); h2.classList.add('fw-bold', 'h3', 'mb-3'); h2.textContent = 'フォロー中のユーザーの視聴状況'; title.appendChild(h2); } return title; }; const renderSectionBody = () => { const body = document.createElement('div'); body.classList.add('container', 'u-container-flat'); { const card = document.createElement('div'); card.classList.add('card', 'u-card-flat'); body.appendChild(card); { const cardBody = document.createElement('div'); { cardBody.classList.add('card-body'); const loading = document.createElement('div'); loading.classList.add('loading'); loading.textContent = '読み込み中...'; cardBody.appendChild(loading); } const row = document.createElement('div'); row.classList.add('row', 'g-3'); cardBody.appendChild(row); card.appendChild(cardBody); return [body, cardBody, row]; } } }; const renderSectionBodyContent = (row, statuses) => { for (const status of statuses) { const col = document.createElement('div'); col.classList.add('col-6', 'col-sm-3'); col.style.display = 'flex'; row.appendChild(col); { const avatarCol = document.createElement('div'); avatarCol.classList.add('col-auto', 'pe-0'); col.appendChild(avatarCol); { const a = document.createElement('a'); if (status.service === 'annict') { a.href = `/@${status.username}`; } else { a.href = `https://anilist.co/user/${status.username}`; a.target = '_blank'; } avatarCol.appendChild(a); { const img = document.createElement('img'); img.classList.add('img-thumbnail', 'rounded-circle'); img.style.width = '50px'; img.style.height = '50px'; img.style.marginRight = '1em'; img.src = status.avatarUrl; a.appendChild(img); } } const userCol = document.createElement('div'); userCol.classList.add('col'); col.appendChild(userCol); { const div1 = document.createElement('div'); userCol.appendChild(div1); { const a = document.createElement('a'); a.classList.add('fw-bold', 'me-1', 'text-body'); if (status.service === 'annict') { a.href = `/@${status.username}`; } else { a.href = `https://anilist.co/user/${status.username}`; a.target = '_blank'; } div1.appendChild(a); { const span = document.createElement('span'); span.classList.add('me-1'); span.textContent = status.name; a.appendChild(span); } { const small = document.createElement('small'); small.style.marginRight = '1em'; small.classList.add('text-muted'); if (status.service === 'annict') { small.textContent = `@${status.username}`; } a.appendChild(small); } } const div2 = document.createElement('div'); div2.classList.add('small', 'text-body'); userCol.appendChild(div2); { const i = document.createElement('i'); i.classList.add(...status.iconClasses); i.style.color = `var(${status.iconColor})`; div2.appendChild(i); } { const small = document.createElement('small'); small.style.marginLeft = '5px'; small.textContent = status.label; div2.appendChild(small); } if (status.comment) { const p = document.createElement('p'); { const i = document.createElement('i'); i.textContent = status.comment ?? ''; p.appendChild(i); } div2.appendChild(p); } } } } }; const handle = async () => { if (window.location.pathname === '/') { const hash = new URLSearchParams(window.location.hash.substring(1)); const token = hash.get('access_token'); if (token !== null) { GM_config.set(anilistTokenKey, token); window.location.hash = ''; alert('[Annict Following Viewings] AniList と接続しました。'); const callback = GM_config.get(anilistCallbackKey); GM_config.set(anilistCallbackKey, ''); GM_config.write(); if (typeof callback === 'string' && callback.length > 0) { window.location.href = callback; } } return; } const workMatch = annictWorkPageUrlPattern.exec(window.location.href); if (!workMatch || !hasMinLength(workMatch, 2)) { return; } const annictWorkId = parseInt(workMatch[1], 10); if (!annictWorkId) { throw new Error('failed to extract Annict work ID'); } const header = document.querySelector('.c-work-header'); if (header === null) { throw new Error('failed to find .c-work-header'); } const title = renderSectionTitle(); header.insertAdjacentElement('afterend', title); const [body, card, row] = renderSectionBody(); title.insertAdjacentElement('afterend', body); const settingsAnchor = document.createElement('a'); settingsAnchor.href = 'about:blank'; settingsAnchor.textContent = '設定'; settingsAnchor.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); GM_config.open(); }); const annictToken = GM_config.get(annictTokenKey); const anilistToken = GM_config.get(anilistTokenKey); if (!annictToken && !anilistToken) { const guideAnchor = document.createElement('a'); guideAnchor.href = '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'; guideAnchor.textContent = 'ガイド'; guideAnchor.target = '_blank'; card.textContent = ''; card.append('Annict Following Viewings の動作にはアクセストークンの設定が必要です。', guideAnchor, 'を参考に', settingsAnchor, 'を行ってください。'); return; } card.append(document.createElement('br'), settingsAnchor); const promises = []; if (typeof annictToken === 'string' && annictToken.length > 0) { promises.push(insertAnnictFollowingStatuses(annictWorkId, annictToken, card, row)); } if (typeof anilistToken === 'string' && anilistToken.length > 0) { promises.push(insertAniListFollowingStatuses(annictWorkId, anilistToken, card, row)); } await Promise.all(promises); if (row.children.length === 0) { card.append('フォロー中のユーザーの視聴状況はありません。'); } }; const insertAnnictFollowingStatuses = async (annictWorkId, annictToken, card, row) => { const responses = await fetchPaginatedAnnictFollowingStatuses(annictWorkId, annictToken); card.querySelector('.loading')?.remove(); if ('errors' in responses) { const error = responses.errors.map(({ message }) => message).join('\n'); card.append(`Annict GraphQL API がエラーを返しました。\n${error}`); return; } const statuses = responses.map((r) => parseAnnictFollowingStatuses(r)).flat(); if (statuses.length > 0) { renderSectionBodyContent(row, statuses); } }; const insertAniListFollowingStatuses = async (annictWorkId, anilistToken, card, row) => { const armEntries = await fetchArmEntries(); const mediaId = armEntries.find((x) => x.annict_id === annictWorkId)?.anilist_id; if (!mediaId) { return; } card.querySelector('.loading')?.remove(); const responses = await fetchPaginatedAniListFollowingStatuses(mediaId, anilistToken); if ('errors' in responses) { const error = responses.errors.map(({ message }) => message).join('\n'); card.append(`AniList GraphQL API がエラーを返しました。\n${error}`); return; } const statuses = responses.map((r) => parseAniListFollowingStatuses(r)).flat(); if (statuses.length > 0) { renderSectionBodyContent(row, statuses); } }; migrate(); document.addEventListener('turbo:load', () => { handle().catch(console.error); }); })();