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.4
// @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;
        }

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

    const MANGA_TYPES = ['manga', 'manhwa'];
    const NOVEL_TYPES = ['Light novel', 'light_novel', 'childrens_book', 'novel'];
    const MOVIE_TYPES = ['movie'];
    const SEASON_TYPES = ['tv_season'];

    const LOADING_DIALOG_ID = 'loading-dialog';

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

    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);
        }
    }// ==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.3
// @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;
        }

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

    const MANGA_TYPES = ['manga', 'manhwa'];
    const NOVEL_TYPES = ['Light novel', 'light_novel', 'childrens_book', 'novel'];
    const MOVIE_TYPES = ['movie'];
    const SEASON_TYPES = ['tv_season'];

    const LOADING_DIALOG_ID = 'loading-dialog';

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

    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 createLoadingDialog() {
        const el = document.createElement('dialog');
        el.id = LOADING_DIALOG_ID;
        document.body.appendChild(el);
        state.loadingDialog = 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);

            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 || 0) : 1,
            'name': item.item.title,
            'started': transformItemDate(item, 'dateStartedData'),
            'finished': transformItemDate(item, 'dateFinishedData'),
            'finishedUnix': item.dateFinishedData?.timestampSeconds || 0,
            '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>
        `
    }

    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, itemType, nativelyItemTypes, invertItemFilter) {
        const itemTypes = nativelyItemTypes ? nativelyItemTypes : [itemType];
        const dateFilter = isItemInRange(state.startDate, state.endDate);
        const items = nativelyData.results
            .filter(item => item.item != null)
            .filter(item => invertItemFilter ? !itemTypes.includes(item.item.mediaType) : itemTypes.includes(item.item.mediaType))
            .filter(dateFilter);
        return items.map(item => generateItemSummary(item, itemType, nativelyData.seriesCache));
    }

    async function fetchLibrary(libraryType) {
        state.loadingDialog.innerHTML = `Loading filters for ${libraryType}`;
        const filterId = await findFinishedFilter(libraryType);
        let seriesCache = {};
        let results = [];
        let totalPages = 1;
        let currentPage = 0;
        do {
            state.loadingDialog.innerHTML = `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() {
        state.loadingDialog.showModal();
        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', MANGA_TYPES);
        const novels = generateItemList(state.rawBookData, 'novel', NOVEL_TYPES)
        const otherBooks = generateItemList(state.rawBookData, 'other-books', [...MANGA_TYPES, ...NOVEL_TYPES], true);
        const movies = generateItemList(state.rawVideoData, 'movie', MOVIE_TYPES);
        const anime = generateItemList(state.rawVideoData, 'anime', SEASON_TYPES);
        const otherVideos = generateItemList(state.rawVideoData, 'other-videos', [...MOVIE_TYPES, ...SEASON_TYPES], true);
        const parsedData = [...manga, ...novels, ...movies, ...anime, ...otherBooks, ...otherVideos];
        parsedData.sort((a, b) => a.finishedUnix - b.finishedUnix);
        state.loadingDialog.close();
        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 updateStyleOverrides() {
        const styles = `
        #recap-takeover {
            .recap-body {
                grid-template-columns: repeat(${state.columns}, 150px);
            }
        }
        `;
        document.querySelector('#recap-style-overrides').innerHTML = styles;
    }

    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)}" />
                    <input type="date" id="recap-end-date" value="${jsToIsoDate(state.endDate)}" />
                    <p>Columns</p>
                    <input type="number" id="recap-columns" value="${state.columns}" />
                </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();
        });
        recapEl.querySelector('#recap-end-date').addEventListener('change', (evt) => {
            state.endDate = new Date(evt.target.valueAsNumber);
            updateRecapBody();
        });
        recapEl.querySelector('#recap-columns').addEventListener('change', (evt) => {
            state.columns = evt.target.value;
            updateStyleOverrides();
        });
        document.body.appendChild(recapEl);
        return recapEl;
    }

    function findCurrentUser() {
        var navLink = document.querySelector('a[href^="/user"]');
        state.user = new URL(navLink.href).pathname.split('/')[2];
    }

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

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

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


    function createLoadingDialog() {
        const el = document.createElement('dialog');
        el.id = LOADING_DIALOG_ID;
        document.body.appendChild(el);
        state.loadingDialog = 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);

            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 || 0) : 1,
            'name': item.item.title,
            'started': transformItemDate(item, 'dateStartedData'),
            'finished': transformItemDate(item, 'dateFinishedData'),
            'finishedUnix': item.dateFinishedData?.timestampSeconds || 0,
            '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>
        `
    }

    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, itemType, nativelyItemTypes, invertItemFilter) {
        const itemTypes = nativelyItemTypes ? nativelyItemTypes : [itemType];
        const dateFilter = isItemInRange(state.startDate, state.endDate);
        const items = nativelyData.results
            .filter(item => item.item != null)
            .filter(item => invertItemFilter ? !itemTypes.includes(item.item.mediaType) : itemTypes.includes(item.item.mediaType))
            .filter(dateFilter);
        return items.map(item => generateItemSummary(item, itemType, nativelyData.seriesCache));
    }

    async function fetchLibrary(libraryType) {
        state.loadingDialog.innerHTML = `Loading filters for ${libraryType}`;
        const filterId = await findFinishedFilter(libraryType);
        let seriesCache = {};
        let results = [];
        let totalPages = 1;
        let currentPage = 0;
        do {
            state.loadingDialog.innerHTML = `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() {
        state.loadingDialog.showModal();
        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', MANGA_TYPES);
        const novels = generateItemList(state.rawBookData, 'novel', NOVEL_TYPES)
        const otherBooks = generateItemList(state.rawBookData, 'other-books', [...MANGA_TYPES, ...NOVEL_TYPES], true);
        const movies = generateItemList(state.rawVideoData, 'movie', MOVIE_TYPES);
        const anime = generateItemList(state.rawVideoData, 'anime', SEASON_TYPES);
        const otherVideos = generateItemList(state.rawVideoData, 'other-videos', [...MOVIE_TYPES, ...SEASON_TYPES], true);
        const parsedData = [...manga, ...novels, ...movies, ...anime, ...otherBooks, ...otherVideos];
        parsedData.sort((a, b) => a.finishedUnix - b.finishedUnix);
        state.loadingDialog.close();
        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 updateStyleOverrides() {
        const styles = `
        #recap-takeover {
            .recap-body {
                grid-template-columns: repeat(${state.columns}, 150px);
            }
        }
        `;
        document.querySelector('#recap-style-overrides').innerHTML = styles;
    }

    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)}" />
                    <input type="date" id="recap-end-date" value="${jsToIsoDate(state.endDate)}" />
                    <p>Columns</p>
                    <input type="number" id="recap-columns" value="${state.columns}" />
                </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();
        });
        recapEl.querySelector('#recap-end-date').addEventListener('change', (evt) => {
            state.endDate = new Date(evt.target.valueAsNumber);
            updateRecapBody();
        });
        recapEl.querySelector('#recap-columns').addEventListener('change', (evt) => {
            state.columns = evt.target.value;
            updateStyleOverrides();
        });
        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() {
        createLoadingDialog();
        loadStylesheet();
        findCurrentUser();
        addLink();
        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或关注我们的公众号极客氢云获取最新地址