Annict Following Viewings

Display following viewings on Annict work page.

目前為 2023-03-05 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name            Annict Following Viewings
// @namespace       https://github.com/SlashNephy
// @version         0.1.1
// @author          SlashNephy
// @description     Display following viewings on Annict work page.
// @description:ja  Annictの作品ページにフォロー中のユーザーの視聴状況を表示します。
// @homepage        https://scrapbox.io/slashnephy
// @homepageURL     https://scrapbox.io/slashnephy
// @icon            https://www.google.com/s2/favicons?sz=64&domain=annict.com
// @supportURL      https://github.com/SlashNephy/.github/issues
// @match           https://annict.com/*
// @connect         api.annict.com
// @grant           GM_xmlhttpRequest
// @grant           GM_getValue
// @grant           GM_setValue
// @license         MIT license
// ==/UserScript==

const executeXhr = async (request) =>
  new Promise((resolve, reject) => {
    GM_xmlhttpRequest({
      ...request,
      onload: (response) => {
        resolve(response)
      },
      onerror: (error) => {
        reject(error)
      },
    })
  })

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)
  }
}

const annictTokenRef = new GM_Value('ANNICT_TOKEN')
const fetchFollowingStatuses = async (workId, cursor, token) => {
  const response = await executeXhr({
    url: 'https://api.annict.com/graphql',
    method: 'POST',
    data: 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 JSON.parse(response.responseText)
}
const fetchPaginatedFollowingStatuses = async (workId, token) => {
  const results = []
  let cursor = null
  while (true) {
    const response = await fetchFollowingStatuses(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
}
const parseFollowingStatuses = (response) =>
  response.data.viewer.following.nodes
    .map((u) => {
      let label
      let iconClasses
      let iconColor
      if (u.watched.nodes.length > 0) {
        label = '見た'
        iconClasses = ['far', 'fa-check']
        iconColor = '--ann-status-completed-color'
      } else if (u.watching.nodes.length > 0) {
        label = '見てる'
        iconClasses = ['far', 'fa-play']
        iconColor = '--ann-status-watching-color'
      } else if (u.stopWatching.nodes.length > 0) {
        label = '視聴停止'
        iconClasses = ['far', 'fa-stop']
        iconColor = '--ann-status-dropped-color'
      } else if (u.onHold.nodes.length > 0) {
        label = '一時中断'
        iconClasses = ['far', 'fa-pause']
        iconColor = '--ann-status-on-hold-color'
      } else if (u.wannaWatch.nodes.length > 0) {
        label = '見たい'
        iconClasses = ['far', 'fa-circle']
        iconColor = '--ann-status-plan-to-watch-color'
      } else {
        return null
      }
      return {
        name: u.name,
        username: u.username,
        avatarUrl: u.avatarUrl,
        label,
        iconClasses,
        iconColor,
      }
    })
    .filter((x) => !!x)
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')
      cardBody.textContent = '読み込み中...'
      card.appendChild(cardBody)
      return [body, cardBody]
    }
  }
}
const renderSectionBodyContent = (statuses) => {
  const row = document.createElement('div')
  row.classList.add('row', 'g-3')
  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')
        a.href = `/@${status.username}`
        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')
          a.href = `/@${status.username}`
          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')
            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)
        }
      }
    }
  }
  return row
}
const handle = async () => {
  const match = annictWorkPageUrlPattern.exec(window.location.href)
  if (!match) {
    return
  }
  const workId = parseInt(match[1], 10)
  if (!workId) {
    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] = renderSectionBody()
  title.insertAdjacentElement('afterend', body)
  const token = annictTokenRef.get()
  if (token === undefined) {
    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(
      'この UserScript の動作には Annict の個人用アクセストークンの設定が必要です。こちらの',
      guideAnchor,
      'を参考に設定を行ってください。'
    )
    return
  }
  const responses = await fetchPaginatedFollowingStatuses(workId, token)
  if ('errors' in responses) {
    const error = responses.errors.map(({ message }) => message).join('\n')
    card.textContent = ''
    card.append(`Annict GraphQL API がエラーを返しました。\n${error}`)
    return
  }
  const statuses = responses.map((r) => parseFollowingStatuses(r)).flat()
  if (statuses.length > 0) {
    const content = renderSectionBodyContent(statuses)
    card.textContent = ''
    card.appendChild(content)
    return
  }
  card.textContent = 'フォロー中のユーザーの視聴状況はありません。'
}
document.addEventListener('turbo:load', () => {
  handle().catch(console.error)
})