您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Generate a list of everything you watched or read on Natively this year
// ==UserScript== // @name Natively Recap // @namespace https://learnnatively.com // @description Generate a list of everything you watched or read on Natively this year // @author araigoshi // @version 1.0.9 // @match https://learnnatively.com/* // @license MIT // @grant none // ==/UserScript== (async function () { 'use strict'; const CSS_TEXT = ` .recap-hide-ui { #main-nav { display: none; } .body-footer { display: none; } .content-wrapper { padding-top: 0; } } #recap-takeover { display: grid; grid-template-columns: auto; justify-content: center; justify-items: center; padding: 1em; header div { display: flex; gap: 2em; align-items: center; margin: 5px 0; h2 { font-size: var(24px); } p { margin-bottom: 0; } input[type=number] { width: 3em; } } .recap-body { padding-top: 1em; display: grid; width: fit-content; grid-template-columns: repeat(10, 150px); justify-content: center; grid-gap: 0.5em; } .hide-title figure .title { display: none; } .hide-series figure .series-progress { display: none; } .hide-level figure .level { display: none; } .hide-dates figure .dates { display: none; } figure .level { cursor: default; } figure { margin: 0; text-align: center; padding: 0.3em; } figure img { height: 200px; aspect-ratio: keep; } span.series-progress { font-size: 10px; white-space: pre; } p.dates { font-size: 10px; margin-bottom: 0; } } #recap-dialog { padding: 1em; header { display: flex; align-items: center; margin-bottom: 1em; h2 { flex-grow: 1; } } #recap-settings-body { width: 900px; display: grid; grid-template-columns: 1fr 1fr auto; gap: 0.5em; .full-width { grid-column: 1/4; } h6.action { text-align: center; } .show-settings { display: flex; gap: 2em; } h4.full-width:not(:first-child) { margin-top: 1em; } } } `; const RECAP_DIALOG_ID = 'recap-dialog'; const NATIVELY_LEVEL_RANGES = { 'n5': { min: 0, max: 12 }, 'n4': { min: 13, max: 19 }, 'n3': { min: 20, max: 26 }, 'n2': { min: 27, max: 33 }, 'n1': { min: 34, max: 40 }, 'n1_plus': { min: 41, max: 999 }, } const DEFAULT_COLORS = { 'recap-background': 'rgba(255, 255, 255, 0)', 'recap-text': '#161314', 'manga': '#f7eafa', 'manhwa': '#f7eafa', 'comic': '#f7eafa', 'novel': '#d9c1de', 'light_novel': '#d9c1de', 'childrens_book': '#d9c1de', 'graded_reader': '#d9c1de', 'textbook': '#d9c1de', 'nonfiction': '#d9c1de', 'movie': '#c7dec1', 'tv_season': '#bce3b2', 'other': '#d9d9d9', } const COLOR_NAME_LOOKUPS = { 'recap-background': 'Recap Background', 'recap-text': 'Text', 'manga': 'Manga', 'manhwa': 'Manhwa', 'comic': 'Comic', 'novel': 'Novel', 'light_novel': 'Light Novel', 'childrens_book': 'Children\'s Book', 'graded_reader': 'Graded Reader', 'textbook': 'Textbook', 'nonfiction': 'Non-fiction', 'movie': 'Movie', 'tv_season': 'TV Season', 'other': 'Other', }; let state = { startDate: new Date("2024-01-01"), endDate: new Date("2024-12-31"), finishedFilter: {}, settings: { columns: 10, nameReplacements: new Map(), colorOverrides: new Map(), defaultStartDate: "2024-01-01", defaultEndDate: "2024-12-31", show: { dates: true, title: true, seriesPos: true, level: false, } } }; function saveSettings() { window.localStorage.setItem("araigoshi-recap-settings", JSON.stringify({ columns: state.settings.columns, show: state.settings.show, nameReplacements: Object.fromEntries(state.settings.nameReplacements.entries()), colorOverrides: Object.fromEntries(state.settings.colorOverrides.entries()), defaultStartDate: state.startDate.toISOString(), defaultEndDate: state.endDate.toISOString(), })) } function loadSettings() { const savedSettingsStr = window.localStorage.getItem("araigoshi-recap-settings"); if (savedSettingsStr === null) { return; } const savedSettings = JSON.parse(savedSettingsStr); if (savedSettings?.columns) { state.settings.columns = savedSettings.columns; } if (savedSettings?.nameReplacements) { state.settings.nameReplacements = new Map(Object.entries(savedSettings.nameReplacements)); } if (savedSettings?.colorOverrides) { state.settings.colorOverrides = new Map(Object.entries(savedSettings.colorOverrides)); } if (savedSettings?.show) { state.settings.show = { ...state.settings.show, ...savedSettings.show, }; } if (savedSettings?.defaultStartDate) { state.startDate = new Date(savedSettings.defaultStartDate); } if (savedSettings?.defaultEndDate) { state.endDate = new Date(savedSettings.defaultEndDate); } } function loadStylesheet() { console.log('Loading recap stylesheet'); if (document.getElementById('recap-styles') === null) { let styleSheet = document.createElement('style'); styleSheet.id = 'recap-styles'; styleSheet.textContent = CSS_TEXT; document.head.appendChild(styleSheet); } if (document.getElementById('recap-style-overrides') === null) { let overrideStyleSheet = document.createElement('style'); overrideStyleSheet.id = 'recap-style-overrides'; document.head.appendChild(overrideStyleSheet); } } function createDialog() { const el = document.createElement('dialog'); el.id = RECAP_DIALOG_ID; document.body.appendChild(el); state.recapDialog = el; } function buildRequest(libraryType, page, finishedFilterId) { return { "page": page, "numOfPages": 1, "totalCount": 0, "itemType": "", "genre": "", "q": "", "bookProviders": "", "watchProviders": "", "location": "", "minLevel": "", "maxLevel": "", "levelFilter": "", "bookType": "", "tagFilters": "", "loading": false, "onlyFree": false, "excludeTemporaryRatings": false, "excludeLibrary": false, "wanikani": false, "mobileFilter": false, "tags": "", "includeSpoilers": false, "includeMinorElements": false, "includeMajorElementsOnly": false, "itemTypes": [], "genreOptions": [], "openSeries": [], "error": "", "libraryType": libraryType, "sort": "-recent", "subs": "", "favorite": false, "resultActive": null, "listFilter": finishedFilterId, "lists": [], "customTags": "", "collapseSeries": false, "pageSize": 50, "needsBackendSearch": false }; } function isItemInRange(startDate, endDate) { return function (item) { const itemFinishedUnixDate = item.dateFinishedData?.timestampSeconds; if (!itemFinishedUnixDate) { return false; } const itemFinishedDate = new Date(itemFinishedUnixDate * 1000); const nextDay = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate() + 1); return startDate <= itemFinishedDate && itemFinishedDate < nextDay; } } function jsToIsoDate(jsDate) { return jsDate.toISOString().substring(0, 10); } function transformItemDate(item, key) { if (!item[key]) { return 'Unknown'; } const unixDate = item[key].timestampSeconds; const jsDate = new Date(unixDate * 1000); return jsToIsoDate(jsDate); } function calculateRatingGroup(rating) { for (const [key, def] of Object.entries(NATIVELY_LEVEL_RANGES)) { if (def.min <= rating && rating <= def.max) { return key; } } return ''; } function generateItemSummary(item, seriesCache) { let adjustedName = item.item.title; for (const [src, replacement] of state.settings.nameReplacements.entries()) { adjustedName = adjustedName.replace(src, replacement); } const simpleType = item.item.mediaType.replace(' ', '_').toLowerCase(); return { 'image': item.item.image?.url, 'url': item.item.url, 'level': 'L' + (item.ratingLevel ? '' + item.ratingLevel : '?') + (item.item.rating?.temporary ? '?' : ''), 'levelGroup': calculateRatingGroup(item.ratingLevel), 'seriesPos': item.item.seriesOrder || 1, 'seriesLength': item.item.seriesId ? (seriesCache[item.item.seriesId]?.numOfItems || 0) : 1, 'name': adjustedName, 'started': transformItemDate(item, 'dateStartedData'), 'finished': transformItemDate(item, 'dateFinishedData'), 'finishedUnix': item.dateFinishedData?.timestampSeconds || 0, 'type': simpleType, } } function itemElement(parsedItem) { const figure = document.createElement('figure'); figure.classList.add(`item-${parsedItem['type']}`) figure.innerHTML = ` <a href="${parsedItem['url']}"><img src="${parsedItem['image']}" /></a> <figcaption> <span class="level ${parsedItem['levelGroup']}">${parsedItem['level']}</span> <a class="title" href="${parsedItem['url']}">${parsedItem['name']}</a> <span class="series-progress">(${parsedItem['seriesPos']} / ${parsedItem['seriesLength']})</span> <p class="dates"><span class="started">${parsedItem['started']}</span> - <span class="ended">${parsedItem['finished']}</span></p> </figcaption> `; return figure; } async function findFinishedFilter(libraryType) { if (state.finishedFilter[libraryType]) { return state.finishedFilter[libraryType]; } const listsResult = await fetch(`/item-list-api?user=${state.user}&library_type=${libraryType}`); const resultBody = await listsResult.json(); const listFilter = resultBody.find(listFilter => listFilter.correlatedStatus === 'finished'); if (listFilter) { state.finishedFilter[libraryType] = listFilter.id; return listFilter.id; } else { state.finishedFilter[libraryType] = ''; return ''; } } function generateItemList(nativelyData) { const dateFilter = isItemInRange(state.startDate, state.endDate); const items = nativelyData.results .filter(item => item.item != null) .filter(dateFilter); return items.map(item => generateItemSummary(item, nativelyData.seriesCache)); } function setDialogToLoadingText(text) { state.recapDialog.innerHTML = ` <h2>Loading Natively Recap</h2> <p>${text}</p> ` } function addSettingsReplacementRow(recapBody, addReplButton, src, dest) { const srcInput = document.createElement('input'); srcInput.type = 'text'; srcInput.value = src; srcInput.classList.add('recap-replacement-src'); recapBody.insertBefore(srcInput, addReplButton); const destInput = document.createElement('input'); destInput.type = 'text'; destInput.value = dest; destInput.classList.add('recap-replacement-dest'); recapBody.insertBefore(destInput, addReplButton); const deleteButton = document.createElement('button'); deleteButton.type = 'button'; deleteButton.textContent = 'X'; deleteButton.addEventListener('click', (evt) => { recapBody.removeChild(srcInput); recapBody.removeChild(destInput); recapBody.removeChild(deleteButton); evt.stopPropagation(); }); recapBody.insertBefore(deleteButton, addReplButton); } function addColourConfigRow(recapBody, key, currentValue) { const label = document.createElement('p'); label.textContent = COLOR_NAME_LOOKUPS[key]; recapBody.appendChild(label); const textInput = document.createElement('input'); textInput.type = 'text'; textInput.dataset.colorKey = key; textInput.value = currentValue; textInput.classList.add('color-text-code'); recapBody.appendChild(textInput); const colorInput = document.createElement('input'); colorInput.type = 'color'; colorInput.value = currentValue; colorInput.addEventListener('change', (evt) => { textInput.value = evt.target.value; }); recapBody.appendChild(colorInput); } function showSettingsDialog() { const initialTitleShowState = state.settings.show.title ? 'checked' : '' const initialSeriesShowState = state.settings.show.seriesPos ? 'checked' : '' const initialDatesShowState = state.settings.show.dates ? 'checked' : '' const initialLevelShowState = state.settings.show.level ? 'checked' : '' state.recapDialog.innerHTML = ` <header> <h2>Recap Settings</h2> <button type="button" id="recap-dialog-close-button">X</button> </header> <main id="recap-settings-body"> <h4 class="full-width">Show/Hide Info</h4> <div class="full-width show-settings"> <div><input type="checkbox" id="recap-show-title" name="recap-show-title" ${initialTitleShowState}> <label for="recap-show-title">Title</label></div> <div><input type="checkbox" id="recap-show-series" name="recap-show-series" ${initialSeriesShowState}> <label for="recap-show-series">Series Position</label></div> <div><input type="checkbox" id="recap-show-dates" name="recap-show-dates" ${initialDatesShowState}> <label for="recap-show-dates">Dates</label></div> <div><input type="checkbox" id="recap-show-level" name="recap-show-level" ${initialLevelShowState}> <label for="recap-show-level">Level</label></div> </div> <h4 class="full-width">Name Replacements</h4> <p class="full-width"> Do some of your shows/books have long/ugly names? Make them shorter </p> <h6>Text to change</h6><h6>Replacement text</h6><h6 class="material-icons-outlined action">delete</h6> <div class="full-width" id="recap-add-more-replacements"> <button type="button" id="add-more-button">+ Add more</button> </div> <h4 class="full-width">Custom Colors</h4> <h6>Type</h6><h6>Colour</h6><h6 class="action">Prv</h6> </main> `; const recapBody = state.recapDialog.querySelector('#recap-settings-body'); const addReplButton = state.recapDialog.querySelector('#recap-add-more-replacements'); const nameReplacements = state.settings.nameReplacements.size > 0 ? [...state.settings.nameReplacements] : [["", ""]]; for (const [src, dest] of nameReplacements) { addSettingsReplacementRow(recapBody, addReplButton, src, dest); } state.recapDialog.querySelector('#add-more-button').addEventListener('click', () => { addSettingsReplacementRow(recapBody, addReplButton, "", ""); }); const resolvedColors = resolveCurrentColors(); for (const [key, color] of resolvedColors.entries()) { addColourConfigRow(recapBody, key, color); } state.recapDialog.querySelector('#recap-dialog-close-button').addEventListener('click', () => { const replacementSrcs = [...recapBody.querySelectorAll('.recap-replacement-src').values()].map(x => x.value); const replacementValues = [...recapBody.querySelectorAll('.recap-replacement-dest').values()].map(x => x.value); let newNameReplacements = new Map(); for (let i = 0; i < replacementSrcs.length; i++) { let src = replacementSrcs[i]; let value = replacementValues[i] || ""; if (src !== "") { newNameReplacements.set(src, value); } } state.settings.nameReplacements = newNameReplacements; let colorOverrides = new Map(); for (const element of recapBody.querySelectorAll('.color-text-code')) { const key = element.dataset.colorKey; const value = element.value; colorOverrides.set(key, value); } state.settings.colorOverrides = colorOverrides; state.settings.show.title = document.querySelector('#recap-show-title').checked; state.settings.show.seriesPos = document.querySelector('#recap-show-series').checked; state.settings.show.dates = document.querySelector('#recap-show-dates').checked; state.settings.show.level = document.querySelector('#recap-show-level').checked; saveSettings(); updateRecapBody(); updateStyleOverrides(); state.recapDialog.close(); }); state.recapDialog.showModal(); } async function fetchLibrary(libraryType) { setDialogToLoadingText(`Loading filters for ${libraryType}`); const filterId = await findFinishedFilter(libraryType); let seriesCache = {}; let results = []; let totalPages = 1; let currentPage = 0; do { setDialogToLoadingText(`Loading ${libraryType} page ${currentPage + 1} of ${totalPages}`); const csrfToken = document.querySelector('meta[name=csrf-token]').getAttribute('content'); const response = await fetch(`/api/item-library-search-api/${state.user}/`, { method: 'POST', headers: { 'content-type': 'application/json', 'X-CSRFToken': csrfToken, }, body: JSON.stringify(buildRequest(libraryType, currentPage + 1, filterId)), }); const responseBody = await response.json(); if (responseBody.numOfPages) { totalPages = responseBody.numOfPages; } currentPage++; for (const result of responseBody.results) { if (result.item) { results.push(result); } else if (result.itemList) { for (const listItem of result.results) { results.push(listItem.data); } } } if (responseBody.seriesCache) { seriesCache = { ...seriesCache, ...responseBody.seriesCache }; } } while (currentPage < totalPages); return { seriesCache, results, }; } async function loadData() { setDialogToLoadingText('Building your recap'); state.recapDialog.showModal(); if (!state.rawBookData) { console.log('Loading data'); state.rawBookData = await fetchLibrary('books'); } if (!state.rawVideoData) { state.rawVideoData = await fetchLibrary('videos'); } const bookList = generateItemList(state.rawBookData); const videoList = generateItemList(state.rawVideoData); const parsedData = [...bookList, ...videoList]; parsedData.sort((a, b) => a.finishedUnix - b.finishedUnix); state.recapDialog.close(); console.log('Loaded item data'); console.log(parsedData); return parsedData; } async function updateRecapBody() { const data = await loadData(); let recapBodies = document.querySelectorAll('.recap-body'); for (const recapBody of recapBodies) { recapBody.innerHTML = ''; for (const item of data) { recapBody.appendChild(itemElement(item)); } } } function addLink() { const navbar = document.querySelector('.navbar-nav'); const lastLink = document.querySelector('.last-link'); const recapLink = document.createElement('a'); recapLink.textContent = 'Recap'; recapLink.classList.add('nav-link'); recapLink.href = '#recap'; recapLink.addEventListener('click', showRecap); navbar.insertBefore(recapLink, lastLink); } function resolveCurrentColors() { const resolvedColors = new Map(); for (const [k, v] of Object.entries(DEFAULT_COLORS)) { resolvedColors.set(k, v); } for (const [k, v] of state.settings.colorOverrides.entries()) { resolvedColors.set(k, v); } return resolvedColors; } function updateStyleOverrides() { const resolvedColors = resolveCurrentColors(); const itemStyles = resolvedColors.entries().map(([key, value]) => ` .item-${key} { background: ${value}; } `).reduce((a, b) => a + '\n' + b); const textColor = resolvedColors.get('recap-text'); const bgColor = resolvedColors.get('recap-background'); const styles = ` #recap-takeover { .recap-body { padding: 1em; grid-template-columns: repeat(${state.settings.columns}, 150px); color: ${textColor}; background-color: ${bgColor}; } .recap-body a { color: ${textColor}; } ${itemStyles} } `; document.querySelector('#recap-style-overrides').innerHTML = styles; if (document.querySelector('.recap-body')) { document.querySelector('.recap-body').classList.toggle('hide-title', !state.settings.show.title); document.querySelector('.recap-body').classList.toggle('hide-series', !state.settings.show.seriesPos); document.querySelector('.recap-body').classList.toggle('hide-dates', !state.settings.show.dates); document.querySelector('.recap-body').classList.toggle('hide-level', !state.settings.show.level); } } function createRecapElement(elementType, id) { const recapEl = document.createElement(elementType); recapEl.id = id; recapEl.innerHTML = ` <header> <div> <h3>${state.user}'s Natively Recap</h3> <button class="hide-natively-ui">Toggle Natively UI</button> </div> <div> <p>Date range</p> <input type="date" id="recap-start-date" value="${jsToIsoDate(state.startDate)}" min="2000-01-01" max="2050-12-31" /> <input type="date" id="recap-end-date" value="${jsToIsoDate(state.endDate)}" min="2000-01-01" max="2025-12-31" /> <p>Columns</p> <input type="number" id="recap-columns" value="${state.settings.columns}" /> <a class="material-icons-outlined" id="recap-settings-link" href="#">settings</a> </div> </header> <main class="recap-body"> </main> `; recapEl.querySelector('.hide-natively-ui').addEventListener('click', (evt) => { document.body.classList.toggle('recap-hide-ui'); }); recapEl.querySelector('#recap-start-date').addEventListener('change', (evt) => { state.startDate = new Date(evt.target.valueAsNumber); updateRecapBody(); saveSettings(); evt.preventDefault(); }); recapEl.querySelector('#recap-end-date').addEventListener('change', (evt) => { state.endDate = new Date(evt.target.valueAsNumber); updateRecapBody(); saveSettings(); }); recapEl.querySelector('#recap-columns').addEventListener('change', (evt) => { state.settings.columns = evt.target.value; saveSettings(); updateStyleOverrides(); }); recapEl.querySelector('#recap-settings-link').addEventListener('click', (evt) => { showSettingsDialog(); evt.stopPropagation(); }); recapEl.querySelector('.recap-body').classList.toggle('hide-title', !state.settings.show.title); recapEl.querySelector('.recap-body').classList.toggle('hide-series', !state.settings.show.seriesPos); recapEl.querySelector('.recap-body').classList.toggle('hide-dates', !state.settings.show.dates); recapEl.querySelector('.recap-body').classList.toggle('hide-level', !state.settings.show.level); document.body.appendChild(recapEl); return recapEl; } function findCurrentUser() { const hashParams = document.location.hash.split('?')[1]?.split('&') || []; let hashParamsLookup = {}; for (const hashParam of hashParams) { let parts = hashParam.split('='); let key = parts[0]; let value = parts[1] || ""; hashParamsLookup[key] = value; } state.user = hashParamsLookup["recap-user"] || window.gs.initialState.user.username; } function showRecap() { const recapEl = createRecapElement('div', 'recap-takeover'); document.querySelector('.content-wrapper').replaceChildren(recapEl); updateRecapBody(); } function init() { createDialog(); loadStylesheet(); findCurrentUser(); addLink(); loadSettings(); updateStyleOverrides(); window.RECAP_STATE = state; if (window.location.hash.startsWith('#recap')) { showRecap(); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址