Natively Recap

Generate a list of everything you watched or read on Natively this year

当前为 2024-12-21 提交的版本,查看 最新版本

// ==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.0
// @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;
        padding: 1em;
        
        header {
            display: flex;
            gap: 2em;
            align-items: center;
            margin: 5px 0;

            h2 {
                font-size: var(24px);
            }

            p {
                margin-bottom: 0;
            }

        }

        .recap-body {
            padding-top: 1em;
            display: grid;
            width: fit-content;
            grid-template-columns: repeat(10, 150px);
            justify-content: center;
            grid-gap: 0.5em;
        }

        figure {
            margin: 0;
            text-align: center;
        }

        .item-manga {
            background: #f7eafa;
        }

        .item-novel {
            background: #d9c1de;
        }

        .item-anime {
            background: #c7dec1;
        }

        .item-movie {
            background: #bce3b2;
        }

        figure img {
            height: 200px;
            aspect-ratio: keep;
        }

        span.series-progress {
            font-size: 10px;
            white-space: pre;
        }
            
        p.dates {
            font-size: 10px;
        }
    }
    `;

    let state = {
        startDate: new Date("2024-01-01"),
        endDate: new Date("2024-12-31"),
    };

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

    function buildRequest(libraryType) {
        return {
            "page": 1,
            "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": null,
            "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);

            return startDate <= itemFinishedDate && itemFinishedDate <= endDate;
        }
    }

    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 generateItemSummary(item, itemType, seriesCache) {
        return {
            'image': item.item.image?.url,
            'seriesPos': item.item.seriesOrder || 1,
            'seriesLength': item.item.seriesId ? seriesCache[item.item.seriesId].numOfItems : 1,
            'name': item.item.title,
            'started': transformItemDate(item, 'dateStartedData'),
            'finished': transformItemDate(item, 'dateFinishedData'),
            'type': itemType,
        }
    }

    function itemHtmlSnippet(parsedItem) {
        return `
    <figure class="item-${parsedItem['type']}">
        <img src="${parsedItem['image']}" />
        <figcaption>
            ${parsedItem['name']} <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>
    </figure>
        `
    }

    function generateItemList(nativelyData, itemType, nativelyItemTypes) {
        const itemTypes = nativelyItemTypes ? nativelyItemTypes : [itemType];
        const dateFilter = isItemInRange(state.startDate, state.endDate);
        const typeFilter = (item) => itemTypes.includes(item.item.mediaType);
        const items = nativelyData.results.filter(typeFilter).filter(dateFilter);
        return items.map(item => generateItemSummary(item, itemType, nativelyData.seriesCache));
    }

    async function fetchLibrary(libraryType) {
        const csrfToken = document.querySelector('meta[name=csrf-token]').getAttribute('content');
        const response = await fetch('/api/item-library-search-api/araigoshi/', {
            method: 'POST',
            headers: {
                'content-type': 'application/json',
                'X-CSRFToken': csrfToken,
            },
            body: JSON.stringify(buildRequest(libraryType)),
        });
        return response.json();
    }

    async function loadData() {
        if (!state.rawBookData) {
            console.log('Loading data');
            state.rawBookData = await fetchLibrary('books');
        }
        if (!state.rawVideoData) {
            state.rawVideoData = await fetchLibrary('videos');
        }
        const manga = generateItemList(state.rawBookData, 'manga');
        const novels = generateItemList(state.rawBookData, 'novel', ['Light novel', 'light_novel', 'childrens_book', 'novel'])
        const movies = generateItemList(state.rawVideoData, 'movie');
        const anime = generateItemList(state.rawVideoData, 'movie', ['tv_season']);
        const parsedData = [...manga, ...novels, ...movies, ...anime];
        parsedData.sort((a, b) => a.finished.localeCompare(b.finished));
        console.log('Loaded item data');
        console.log(parsedData);
        return parsedData;
    }

    async function generateRecapBody() {
        const data = await loadData();
        const htmlComponents = data.map(itemHtmlSnippet);
        return htmlComponents.join('\n');
    }

    async function updateRecapBody() {
        let recapBodies = document.querySelectorAll('.recap-body');
        let bodyContent = await generateRecapBody();
        recapBodies.forEach(body => body.innerHTML = bodyContent);
    }

    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 createRecapElement(elementType, id) {
        const recapEl = document.createElement(elementType);
        recapEl.id = id;
        recapEl.innerHTML = `
            <header>
                <h2>Natively Recap</h2>
                <button class="hide-natively-ui">Show/Hide Natively UI</button>
                <p>Date range</p>
                <input type="date" id="recap-start-date" value="${jsToIsoDate(state.startDate)}" />
                <input type="date" id="recap-end-date" value="${jsToIsoDate(state.endDate)}" />
            </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();
        });
        recapEl.querySelector('#recap-end-date').addEventListener('change', (evt) => {
            state.endDate = new Date(evt.target.valueAsNumber);
            updateRecapBody();
        });
        document.body.appendChild(recapEl);
        return recapEl;
    }

    function showRecap() {
        const recapEl = createRecapElement('div', 'recap-takeover');
        document.querySelector('.content-wrapper').replaceChildren(recapEl);
        updateRecapBody();
    }

    function init() {
        loadStylesheet();
        addLink();
        window.RECAP_STATE = state;
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址