Sleazy Fork is available in English.

最愛「新作品」更新

為何要一個一個點擊進入追蹤的作者頁面才能看到最新的更新?讓這個腳本為你代勞。支援Kemono/Coomer。

// ==UserScript==
// @name         最愛「新作品」更新
// @name:ja      お気に入りの「新しいアート」更新
// @name:en      Favorites"NewArt"Update
// @namespace    https://greasyfork.org/zh-TW/users/1021017-max46656
// @version      1.1.1
// @description  為何要一個一個點擊進入追蹤的作者頁面才能看到最新的更新?讓這個腳本為你代勞。支援Kemono/Coomer。
// @description:ja それぞれのフォローアーティストのページに一つずつクリックして最新の更新を見る必要がありますか?このスクリプトに任せてください。Kemono/Coomerに対応しています。
// @description:en Why click into each followed artist's page one by one to see the latest updates? Let this script do it for you. Suppper Kemono/Coomer.
// @author       Max
// @match        *://kemono.su/account/favorites/artists*
// @match        *://*.kemono.su/account/favorites/artists*
// @match        *://coomer.su/account/favorites/artists*
// @match        *://*.coomer.su/account/favorites/artists*
// @match        *://kemono.su/favorites*
// @match        *://coomer.su/favorites*
// @match        *://*.kemono.su/favorites*
// @match        *://*.coomer.su/favorites*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=kemono.su
// @license MPL2.0
// ==/UserScript==

class ArtistUpdateCatcher {
    constructor(timeRange) {
        this.timeRange = timeRange;
        this.queue = [];
        this.observer = null;
        this.init();
    }

    init() {
        this.loadArtistCards();
        this.setupMutationObserver();
    }

    loadArtistCards() {
        this.artistCards = Array.from(document.querySelectorAll('a.user-card'));

        const invalidCards = this.artistCards.filter(card => !card.href);

        if (invalidCards.length > 0) {
            console.warn(`${invalidCards.length}項作者卡尚未載入完成,重試中`);
            setTimeout(() => this.loadArtistCards(), 1000);
            return;
        }

        this.queue = Array.from(this.artistCards);
        if (this.queue.length > 0 &&invalidCards.length == 0) {
            this.processQueue();
        }
    }

    setupMutationObserver() {
        const observer = new MutationObserver(() => {
            this.loadArtistCards();
        });
        observer.observe(document.body, { childList: true, subtree: true });
        this.observer = observer;
    }

    async fetchUpdateArticles(url) {
        const articles = [];
        const isKemono = url.includes('kemono');
        let cleanUrl = url.replace(/^.*(?=\/[^\/]+\/user\/[^\/]+)/, "");
        let creatorPostsApi,creatorInfoApi;
        if(isKemono){
            creatorPostsApi ='https://kemono.su/api/v1' + cleanUrl + '?o=0';
            creatorInfoApi = 'https://kemono.su/api/v1' + cleanUrl + '/profile?o=0';
        }else{
            creatorPostsApi ='https://coomer.su/api/v1' + cleanUrl + '?o=0';
            creatorInfoApi = 'https://coomer.su/api/v1' + cleanUrl + '/profile?o=0';
        }
        try {
            const postsResponse = await fetch(creatorPostsApi);
            if (!postsResponse.ok) {
                await this.delay(2000);
                return this.fetchUpdateArticles(url);
            }
            const posts = await postsResponse.json();

            if (posts.length === 0) {
                return articles;
            }

            const firstPostTime = new Date(posts[0].published || posts[0].added).getTime();
            const seventyTwoHoursLater = firstPostTime - this.timeRange;

            const newerPosts = posts.filter(post => {
                const postTime = new Date(post.published || post.added).getTime();
                return postTime >= seventyTwoHoursLater;
            });

            const infoResponse = await fetch(creatorInfoApi);
            const info = await infoResponse.json();
            const userName = info.name;

            for (let post of newerPosts) {
                const articleId = post.id;
                const service = post.service;
                const user = post.user;
                const title = post.title;
                const filePath = post.file ? post.file.path : '';
                const timestamp = post.published || post.added;
                const attachmentsCount = post.attachments ? post.attachments.length : 0;

                const href = `/${service}/user/${user}/post/${articleId}`;
                const imgSrc = `${filePath}`;

                const articleHtml = `
                <article class="post-card post-card--preview" data-id=${articleId} data-service=${service} data-user=${user} style="position: relative; overflow: hidden; border-radius: 2%;font-size: larger;">
                  <a class="fancy-link fancy-link--kemono" href=${href}>
                      <header class="post-card__header">${userName}</header>
                      <div class="post-card__image-container"><img class="post-card__image" src=${imgSrc}></div>
                      <footer class="post-card__footer">
                          <div>
                    <div style="width: clamp(30px, 6%, 30px);display: flex; align-items: center; gap: 10%;">${attachmentsCount}
                                  <svg viewBox="0 0 10 10" style="width: 100%; height: 100%; fill: white;">
                                      <path d="M8,3 C8.55228475,3 9,3.44771525 9,4 L9,9 C9,9.55228475 8.55228475,10 8,10 L3,10
                                          C2.44771525,10 2,9.55228475 2,9 L6,9 C7.1045695,9 8,8.1045695 8,7 L8,3 Z M1,1 L6,1
                                          C6.55228475,1 7,1.44771525 7,2 L7,7 C7,7.55228475 6,8 6,8 L1,8 C0.44771525,8
                                          0,7.55228475 0,7 L0,2 C0,1.44771525 0.44771525,1 1,1 Z" transform="">
                                      </path>
                                  </svg>
                              </div>
                              <div>
                                  <div>${title}</div>
                              </div>
                          </div>
                      </footer>
                  </a>
                  <time class="timestamp" datetime=${timestamp}></time>
              </article>`;
                const parser = new DOMParser();
                const doc = parser.parseFromString(articleHtml, 'text/html');
                const articleElement = doc.body.firstChild;
                articles.push(articleElement);
            }
        } catch (error) {
            //console.error(`獲取字作品 ${url} 失敗:`, error);
        }
        return articles;
    }


    async replaceArtistCard(artistCard, articles) {
        const parentElement = document.querySelector('div.card-list__items');
        if(!parentElement.contains(artistCard)){
            return;
        }
        const userId = artistCard.getAttribute("data-id");
        const service = artistCard.getAttribute("data-service");
        const userName = artistCard.querySelector(".user-card__name").textContent.trim();
        const userIcon = artistCard.querySelector(".fancy-image__image").src;
        const userHref = `/${service}/user/${userId}`;

        parentElement.removeChild(artistCard);

        for (const article of articles) {
            const userProfile = document.createElement("a");
            userProfile.setAttribute("data-id",userId);
            userProfile.setAttribute("data-service",service);
            userProfile.setAttribute("href",userHref);
            userProfile.style = `
              position: absolute;
              top: 8%;
              left: 1%;
              z-index: 10;
              display: inline-flex;
              align-items: center;
              background: rgba(0, 0, 0, 0.01);
              border-radius: 5%;
              text-decoration: none;
              width: 15%;
              height:min-content`;

            userProfile.innerHTML=`
                    <div>
                        <span class="fancy-image">
                            <picture class="fancy-image__picture">
                                <img class="fancy-image__image" src=${userIcon} loading="lazy" style="width: 100%; border-radius: 50%;">
                            </picture>
                        </span>
                    </div>`;
            article.prepend(userProfile);
            parentElement.prepend(article);
        }
    }

    sortArticlesByDatetime() {
        const articles = Array.from(document.querySelectorAll('article'));
        articles.sort((a, b) => {
            const timeA = a.querySelector('time') ? a.querySelector('time').getAttribute('datetime') : '';
            const timeB = b.querySelector('time') ? b.querySelector('time').getAttribute('datetime') : '';

            return new Date(timeB) - new Date(timeA);
        });

        const container = articles[0].parentElement;
        articles.forEach(article => {
            container.appendChild(article);
        });
    }

    async processQueue(){
        while (this.queue.length > 0) {
            const card = this.queue.shift();
            try {
                if (!card.href) {
                    throw new Error("Card href 為null");
                }

                const articles = await this.fetchUpdateArticles(card.href);
                await this.replaceArtistCard(card, articles);
                document.title = "[🈱favoritesReading]";
            } catch (e){
                console.error(`${card}錯誤:`, e);
                document.title = "[🈲waitForApi]";
                if (card) this.queue.push(card);
                await this.delay(1000);
            }
        }
        this.sortArticlesByDatetime();
        document.title = "[🈵pageDone!]";
    }



    delay(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

}

class PageIndicatorObserver {
    constructor(selector, checkInterval = 1000) {
        this.selector = selector;
        this.checkInterval = checkInterval;
        this.pageIndicator = null;
        this.retryInterval = null;
        this.observer = null;
        this.init();
    }

    init() {
        this.retryInterval = setInterval(() => {
            this.pageIndicator = document.querySelector(this.selector);
            if (this.pageIndicator) {
                clearInterval(this.retryInterval);
                this.setupObserver();
            } else {
                console.log(`${this.selector} 頁數顯示器未獲取`);
            }
        }, this.checkInterval);
    }

    setupObserver() {
        if (!this.pageIndicator)
            return;

        console.log("pageIndicator:", this.pageIndicator);

        this.observer = new MutationObserver((mutationsList) => {
            mutationsList.forEach((mutation) => {
                //console.log("翻頁偵測:,", this.pageIndicator.textContent);
                this.stop();
                location.reload();
            });
        });

        const observerOptions = {
          childList: true,
          subtree: true,
        };

        this.observer.observe(this.pageIndicator, observerOptions);
    }

    stop() {
        if (this.retryInterval) {
            clearInterval(this.retryInterval);
            this.retryInterval = null;
        }
        if (this.observer) {
            this.observer.disconnect();
            this.observer = null;
        }
        //console.log("停止觀察");
    }
}

new ArtistUpdateCatcher(1000, 4,24*60*60*1000);

new PageIndicatorObserver("#paginator-top", 500);