NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript== // @name AnimeGoSync // @namespace https://animego.org/anime/* // @version 1.1.2 // @description Simple script for syncing watch status from AnimeGO to Shikimori! // @author lelykmaryan (1.0) / secondthunder (1.1+) // @match https://animego.org/anime/* // @icon https://www.google.com/s2/favicons?domain=animego.org // @grant none // @run-at document-end // @license MIT // ==/UserScript== // Константы const shikimoriURL = 'https://shikimori.one'; const animesEndpoint = `${shikimoriURL}/api/animes`; const userRatesEndpoint = `${shikimoriURL}/api/v2/user_rates`; const oauthEndpoint = `${shikimoriURL}/oauth/token`; const authorizeEndpoint = `${shikimoriURL}/oauth/authorize`; const whoamiEndpoint = `${shikimoriURL}/api/users/whoami`; const oauthWindowParams = `scrollbars=no,resizable=no,status=no,location=no,toolbar=no,menubar=no,width=450,height=650`; const appData = { clientID: 'IGtPiT4zLudryFhJMA4yPdNtZ_3T_E4GFVrma1O4KkI', clientSecret: 'bxTapg5D3FIoNVFwxeowYyQizqQLbN0kdITcZVOzmUo', redirectURI: 'https://animego.org/', scope: 'user_rates', }; const grantTypes = { refreshToken: 'refresh_token', code: 'code', authorizationCode: 'authorization_code', }; const extensionDivProperties = { false: { textColor: '#ff6666', fontWeight: 'bold', mainText: 'Авторизируйтесь на Shikimori, чтобы сохранять данные в своем списке', btnText: 'Авторизоваться', btnOnClick: loginBtnOnClick, }, true: { textColor: '#fff', fontWeight: 'normal', mainText: 'Вы зашли в Shikimori', btnText: 'Выйти с аккаунта', btnOnClick: logoutBtnOnClick, }, }; // Переменные-хранители /* Переменная прокси для индикации входа Управляет пересозданием контейнера при входе/выходе из аккаунта */ const loginProxy = new Proxy( { status: false, onLoadCompleted: false }, { set(target, prop, val) { target[prop] = val; if (prop === 'status' && target['onLoadCompleted'] === true) { rebuildExtensionDivData(); } }, } ); /* Переменная прокси для данных об аниме Управляет заменой контента о текущем статусе просмотра аниме (В случае, если выполнена авторизация и инициализация при загрузке) */ const currentAnimeDataProxy = new Proxy( { id: undefined, status: undefined, episodes: undefined, myEpisodes: undefined, rewatches: undefined, }, { set(target, prop, val) { target[prop] = val; if ( prop === 'myEpisodes' && loginProxy.onLoadCompleted && loginProxy.status ) { updateExtensionAnimeData(); } }, } ); // Функции-утилиты // Генерация контейнера скрипта function generateExtensionDiv() { // Получение параметров контейнера в зависимости от статуса входа в аккаунт const divProperties = extensionDivProperties[loginProxy.status]; // Создание контейнера для данных из скрипта const extensionDiv = document.createElement('div'); extensionDiv.setAttribute('id', 'animeGoSync'); // Создание разделителя (для красоты) const customHr = document.createElement('hr'); customHr.style.cssText = 'border-top: 1px solid rgb(70, 70, 70)'; // Создание заголовка с названием const divHeading = document.createElement('h4'); divHeading.appendChild(document.createTextNode('AnimeGoSync')); // Создание текста с текущими данными // Если выполнен вход, то выводится также ссылка на аккаунт в Shikimori const divInfo = document.createElement('h6'); divInfo.setAttribute('id', 'animeGoSyncInfo'); divInfo.style.cssText = `color: ${divProperties.textColor};`; divInfo.className = `mb-${loginProxy.status ? 0 : 3} font-weight-${ divProperties.fontWeight }`; if (localStorage.getItem('shiki_nickname') !== null) { divInfo.innerHTML = `${ divProperties.mainText }${` как <a href="${shikimoriURL}/${localStorage.getItem( 'shiki_nickname' )}" target="_blank">${localStorage.getItem('shiki_nickname')}</a>`}`; } else { divInfo.appendChild(document.createTextNode(divProperties.mainText)); } // Создаем контейнер с данными из списка просмотренного // Если вход не совершен, не создаем контейнер let watchStatusParentDiv; if (loginProxy.status) { watchStatusParentDiv = document.createElement('div'); watchStatusParentDiv.className = `episode-info mb-3`; const watchStatusDiv = document.createElement('div'); watchStatusDiv.className = `episode-info-item mt-2`; const watchStatusHeadingText = document.createElement('span'); watchStatusHeadingText.className = 'text-player-gray mr-1'; watchStatusHeadingText.appendChild( document.createTextNode('Статус аниме в списке:') ); const watchStatusDataText = document.createElement('span'); watchStatusDataText.setAttribute('id', 'currentUserRateData'); watchStatusDataText.appendChild( document.createTextNode(getUserRateString()) ); watchStatusDiv.appendChild(watchStatusHeadingText); watchStatusDiv.appendChild(watchStatusDataText); watchStatusParentDiv.appendChild(watchStatusDiv); } // Создание кнопки для входа/выхода const divBtn = document.createElement('div'); divBtn.setAttribute('id', 'animeGoSyncBtn'); divBtn.className = 'btn btn-primary br-2 cursor-pointer'; divBtn.onclick = divProperties.btnOnClick; divBtn.appendChild(document.createTextNode(divProperties.btnText)); // Добавление элементов в родительский контейнер extensionDiv.appendChild(customHr); extensionDiv.appendChild(divHeading); extensionDiv.appendChild(divInfo); if (loginProxy.status) extensionDiv.appendChild(watchStatusParentDiv); extensionDiv.appendChild(divBtn); return extensionDiv; } // Установка созданного контейнера под плеером function createAndSetExtensionDiv() { const newExtensionDiv = generateExtensionDiv(); document.querySelector('.dropdown').after(newExtensionDiv); } // Пересборка контейнера function rebuildExtensionDivData() { document.getElementById('animeGoSync').remove(); createAndSetExtensionDiv(); } // Обновление текста о текущем статусе просмотра аниме function updateExtensionAnimeData() { const newUserRateString = getUserRateString(); const userRateSpan = document.getElementById('currentUserRateData'); userRateSpan.textContent = newUserRateString; } // Получение названия текущего аниме на странице function getAnimeNameFromPage() { return document.querySelector('.list-unstyled > li').innerText; } // Создание нового окна для входа в аккаунта function openNewOAuthWindow() { const authorizeLink = `${authorizeEndpoint}?` + new URLSearchParams({ client_id: appData.clientID, redirect_uri: appData.redirectURI, response_type: grantTypes.code, scope: appData.scope, }); return open(authorizeLink, 'oauthWindow', oauthWindowParams); } // Функция для кнопки входа function loginBtnOnClick() { const oauthWindow = openNewOAuthWindow(); const checkForOAuthInterval = setInterval(async () => { try { const windowParams = new URL(oauthWindow.location).searchParams; const authCode = windowParams.get('code'); if (authCode !== null) oauthWindow.close(); const authorizationFormData = createFormData(authCode); await setNewAuthData(authorizationFormData); await setCurrentUserData(); await setCurrentAnimeUserRate(); loginProxy.status = true; clearInterval(checkForOAuthInterval); } catch (e) { if (!(e instanceof DOMException)) { console.log(`There is an error: ${e}`); console.log( `Clearing OAuth check interval. Try to login again.` ); clearInterval(checkForOAuthInterval); } } }, 500); } // Убирает кастомный onClick для кнопки отметить просмотренным function removeWatchedBtnOnClick() { document .querySelector('.video-player-bar-series-watch') .removeAttribute('onclick'); } /* Создание тела для запроса авторизации/OAuth 2.0 Если data не равняется null, то запрос на авторизацию иначе на OAuth 2.0 */ function createFormData(data = null) { const newFormData = new FormData(); if (data !== null) { newFormData.append('grant_type', grantTypes.authorizationCode); newFormData.append('client_id', appData.clientID); newFormData.append('client_secret', appData.clientSecret); newFormData.append('code', data); newFormData.append('redirect_uri', appData.redirectURI); } else { newFormData.append('grant_type', grantTypes.refreshToken); newFormData.append('client_id', appData.clientID); newFormData.append('client_secret', appData.clientSecret); newFormData.append( 'refresh_token', localStorage.getItem('refresh_token') ); } return newFormData; } // Проверка на истечение токена function isAccessTokenExpired() { return Date.now() / 1000 > localStorage.getItem('expired_token'); } // Проверка на наличие токена в localStorage function isAccessTokenInLocalStorage() { return ( String(localStorage.getItem('access_token')) != undefined && localStorage.getItem('access_token') != null ); } // Перевод строки статуса в локализованное значение function getLocalizedUserRateStatus(status) { switch (String(status)) { case 'completed': return 'Просмотрено'; case 'planned': return 'Запланировано'; case 'watching': return 'Смотрю'; case 'rewatching': return 'Пересматриваю'; case 'on_hold': return 'Отложено'; case 'dropped': return 'Брошено'; case 'undefined': return 'Нет статуса'; } } // Получение строкового статуса о текущем статусе аниме в списке function getUserRateString() { let currentUserRateStatus = getLocalizedUserRateStatus( currentAnimeDataProxy.status ); if (currentAnimeDataProxy.status === undefined) return currentUserRateStatus; currentUserRateStatus += ` (${currentAnimeDataProxy.myEpisodes}/${currentAnimeDataProxy.episodes} эпизодов)`; return currentUserRateStatus; } // Получение и установка данных для входа в localStorage async function setNewAuthData(formData) { const oauthData = await fetchOAuthData(formData); localStorage.setItem( 'expired_token', oauthData.created_at + oauthData.expires_in ); localStorage.setItem('access_token', oauthData.access_token); localStorage.setItem('refresh_token', oauthData.refresh_token); } // Установка данных о пользователе в localStorage function setNewUserData(userData) { localStorage.setItem('shiki_id', userData.id); localStorage.setItem('shiki_nickname', userData.nickname); } // Удаление данных для входа и о пользователе из localStorage function removeLocalShikiData() { localStorage.removeItem('expired_token'); localStorage.removeItem('access_token'); localStorage.removeItem('refresh_token'); localStorage.removeItem('shiki_id'); localStorage.removeItem('shiki_nickname'); } // Генерация данных для отправки обновленного статуса аниме в списке function generateUserRateBody() { return { user_rate: { episodes: `${currentAnimeDataProxy.myEpisodes}`, status: `${currentAnimeDataProxy.status}`, rewatches: `${currentAnimeDataProxy.rewatches}`, target_id: `${currentAnimeDataProxy.id}`, target_type: 'Anime', user_id: `${localStorage.getItem('shiki_id')}`, }, }; } /* Устанавливает нажатие на кнопку для отметки аниме как просмотренного Заметка: В данный момент нет хендлера кнопок отметки в списке серий аниме (Будет добавлено потом) */ function setWatchedBtnOnClick() { const watchCheckInteval = setInterval(() => { document.querySelector('.video-player-bar-series-watch').onclick = playerWatchedBtnLogic; clearInterval(watchCheckInteval); }, 1000); } /* Первая инициализация при загрузке Проверяется наличие токена в localStorage Если присутствует, получаем информацию об аниме от пользователя, обновляем статусы и показываем кнопку для выхода из аккаунта Иначе показываем кнопку для авторизации */ async function onLoadInitialization() { await setInitialAnimeData(); if (!isAccessTokenInLocalStorage()) { createAndSetExtensionDiv(); loginProxy.onLoadCompleted = true; return; } loginProxy.status = true; await refreshAccessToken(); await setCurrentAnimeUserRate(); setWatchedBtnOnClick(); createAndSetExtensionDiv(); loginProxy.onLoadCompleted = true; } /* Функция для кнопки выхода Вызывает метод для выхода из аккаунта, затем очищает данные из localStorage и убирает onclick для кнопки отметить просмотренным */ async function logoutBtnOnClick() { await fetch('https://shikimori.one/api/users/sign_out'); removeLocalShikiData(); removeWatchedBtnOnClick(); loginProxy.status = false; } // Получение информации об аниме на текущей странице async function fetchCurrentAnimeData() { const animesResponse = await fetch( `${animesEndpoint}?` + new URLSearchParams({ search: getAnimeNameFromPage(), }) ); return await animesResponse.json(); } // Установка начального значения об аниме на текущей странице async function setInitialAnimeData() { const animeShikiData = await fetchCurrentAnimeData(); currentAnimeDataProxy.id = animeShikiData[0].id; currentAnimeDataProxy.name = animeShikiData[0].russian || animeShikiData[0].name; currentAnimeDataProxy.episodes = animeShikiData[0].episodes; } // Получение текущих данных о профиле пользователя Shikimori async function fetchCurrentUser() { const currentUserData = await fetch(whoamiEndpoint, { headers: { Authorization: `Bearer ${localStorage.getItem('access_token')}`, }, }); return await currentUserData.json(); } // Получение и установка данных о текущем пользователе async function setCurrentUserData() { const userData = await fetchCurrentUser(); localStorage.setItem('shiki_id', userData.id); localStorage.setItem('shiki_nickname', userData.nickname); } // Запрос на обновление токена доступа async function fetchOAuthData(formData) { const oauthResponse = await fetch(oauthEndpoint, { method: 'POST', body: formData, }); return await oauthResponse.json(); } /* Обновление токенов Если не устарел, пропускаем Иначе запрашиваем новые Заметка: refresh_token активен 28 дней, если он истечёт, наиболее вероятно будет ошибка, исправлю позже */ async function refreshAccessToken() { if (!isAccessTokenExpired()) return; const oauthFormData = createFormData(); await setNewAuthData(oauthFormData); } // Получение текущего статуса просмотра аниме в профиле async function fetchAnimeUserRate() { const response = await fetch( `${userRatesEndpoint}?` + new URLSearchParams({ target_id: currentAnimeDataProxy.id, target_type: 'Anime', user_id: localStorage.getItem('shiki_id'), }), { headers: { Authorization: `Bearer ${localStorage.getItem('access_token')}`, }, } ); return await response.json(); } // Установка текущего статуса просмотра аниме в профиле async function setCurrentAnimeUserRate() { const animeData = await fetchAnimeUserRate(); currentAnimeDataProxy.status = animeData[0]?.status; currentAnimeDataProxy.myEpisodes = animeData[0]?.episodes != undefined ? animeData[0]?.episodes : 0; currentAnimeDataProxy.rewatches = animeData[0]?.episodes != undefined ? animeData[0]?.rewatches : 0; } /* Логика обновления данных для кнопки отметки аниме как просмотренного в плеере - Если отметка была поставлена как "Не просмотрено", то игнорируем - Иначе устанавливаем новую серию в плеере и отправляем новый эпизод/статус аниме в профиле - Также есть проверка от дурака (Попытка установить серию, которая еще не вышла в Японии) Заметка: Возможно потом будет придуман некий откат при снятии отметки просмотрено (Формула: Устанавливается серия, которая находится то той, на которой была снята метка просмотрено) */ async function playerWatchedBtnLogic() { const currentWatchBtnClasses = document.querySelector( '.video-player-bar-series-watch' ).classList; if (currentWatchBtnClasses.contains('watched')) return; const iFramesList = document .querySelector('.video-player-online') .getElementsByTagName('iframe'); if (iFramesList.length === 0) return; const numSeries = document.querySelector( `.video-player-bar-series-item.video-player__active` ) != null ? document .querySelector( `.video-player-bar-series-item.video-player__active` ) .getAttribute('data-episode') : 1; if (+numSeries <= currentAnimeDataProxy.myEpisodes) return; const nextSeriesBtn = document.getElementById( `video-carousel-item${+numSeries}` ); nextSeriesBtn != null ? nextSeriesBtn.querySelector(`.video-player-bar-series-item`).click() : false; currentAnimeDataProxy.status = +numSeries === currentAnimeDataProxy.episodes ? 'completed' : 'watching'; currentAnimeDataProxy.myEpisodes = +numSeries; await sendNewUserRateStatus(); } // Отправка обновленного статуса аниме в списке async function sendNewUserRateStatus() { await fetch(userRatesEndpoint, { method: 'POST', headers: { Authorization: `Bearer ${localStorage.getItem('access_token')}`, 'Content-Type': 'application/json', }, body: JSON.stringify(generateUserRateBody()), }); } /* Ожидание загрузки всей страницы После загрузки страницы, идет запуск инициализации скрипта */ window.addEventListener('load', async () => { console.log('Страница загружена! Идет запуск скрипта AnimeGoSync'); await onLoadInitialization(); });