Feed Finder

Detect the feed of the current website to facilitate subscription of RSS content.

Mint 2025.11.21.. Lásd a legutóbbi verzió

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name                Feed Finder
// @name:zh-TW          RSS Feed 查找器
// @name:zh-CN          RSS Feed 查找器
// @namespace           https://github.com/Gholts
// @version             13.1
// @description         Detect the feed of the current website to facilitate subscription of RSS content.
// @description:zh-TW   偵測目前網站的feed,方便訂閱RSS內容。
// @description:zh-CN   检测当前网站的feed,方便订阅RSS内容。
// @author              Gholts
// @license             GNU Affero General Public License v3.0
// @match               *://*/*
// @grant               GM_xmlhttpRequest
// ==/UserScript==

(function () {
    "use strict";

    // --- 硬編碼站點規則模塊 ---
    const siteSpecificRules = {
        "github.com": (url) => {
            const siteFeeds = new Map();
            const pathParts = url.pathname.split("/").filter((p) => p);
            if (pathParts.length >= 2) {
                const [user, repo] = pathParts;
                siteFeeds.set(
                    `${url.origin}/${user}/${repo}/releases.atom`,
                    "Releases",
                );
                siteFeeds.set(`${url.origin}/${user}/${repo}/commits.atom`, "Commits");
            } else if (pathParts.length === 1) {
                const [user] = pathParts;
                siteFeeds.set(`${url.origin}/${user}.atom`, `${user} Activity`);
            }
            return siteFeeds.size > 0 ? siteFeeds : null;
        },
        "example.com": (url) => {
            const siteFeeds = new Map();
            siteFeeds.set(`${url.origin}/feed.xml`, "Example.com Feed");
            return siteFeeds;
        },
        "medium.com": (url) => {
            const siteFeeds = new Map();
            const parts = url.pathname.split("/").filter(Boolean);
            if (parts.length >= 1) {
                const first = parts[0];
                if (first.startsWith("@"))
                    siteFeeds.set(`${url.origin}/${first}/feed`, `${first} (Medium)`);
                    else siteFeeds.set(`${url.origin}/feed`, `Medium Feed`);
            } else siteFeeds.set(`${url.origin}/feed`, `Medium Feed`);
            return siteFeeds;
        },
    };

    const SCRIPT_CONSTANTS = {
        PROBE_PATHS: [
            "/feed",
            "/rss",
            "/atom.xml",
            "/rss.xml",
            "/feed.xml",
            "/feed.json",
        ],
        FEED_CONTENT_TYPES:
        /^(application\/(rss|atom|rdf)\+xml|application\/(json|xml)|text\/xml)/i,
        UNIFIED_SELECTOR:
        'link[type*="rss"], link[type*="atom"], link[type*="xml"], link[type*="json"], link[rel="alternate"], a[href*="rss"], a[href*="feed"], a[href*="atom"], a[href$=".xml"], a[href$=".json"]',
        HREF_INFERENCE_REGEX: /(\/feed|\/rss|\/atom|(\.(xml|rss|atom|json))$)/i,
    };

    // --- gmFetch 封裝 ---
    function gmFetch(url, options = {}) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: options.method || "GET",
                url: url,
                headers: options.headers,
                responseType: "text",
                timeout: options.timeout || 5000,
                onload: (res) => {
                    const headerLines = (res.responseHeaders || "")
                    .trim()
                    .split(/[\r\n]+/);
                    const headers = new Map();
                    for (const line of headerLines) {
                        const [k, ...rest] = line.split(": ");
                        if (k && rest.length) headers.set(k.toLowerCase(), rest.join(": "));
                    }
                    resolve({
                        ok: res.status >= 200 && res.status < 300,
                        status: res.status,
                        headers: { get: (name) => headers.get(name.toLowerCase()) },
                    });
                },
                onerror: (err) =>
                    reject(
                        new Error(
                            `[gmFetch] Network error for ${url}: ${JSON.stringify(err)}`,
                        ),
                    ),
                ontimeout: () =>
                    reject(new Error(`[gmFetch] Request timed out for ${url}`)),
            });
        });
    }

    // --- 排除 SVG ---
    function isInsideSVG(el) {
        if (!el) return false;
        let node = el;
        while (node) {
            if (node.nodeName && node.nodeName.toLowerCase() === "svg") return true;
            node = node.parentNode;
        }
        return false;
    }

    function safeURL(href) {
        try {
            const url = new URL(href, window.location.href);
            if (url.pathname.toLowerCase().endsWith(".svg")) return null; // 排除 svg
            return url.href;
        } catch {
            return null;
        }
    }

    function titleForElement(el, fallback) {
        const t =
            (el.getAttribute &&
                (el.getAttribute("title") || el.getAttribute("aria-label"))) ||
                el.title ||
                "";
        const txt = t.trim() || (el.textContent ? el.textContent.trim() : "");
        return txt || fallback || null;
    }

    // --- 發現 Feed 主函數 ---
    async function discoverFeeds(initialDocument, url) {
        const feeds = new Map();
        let parsedUrl;
        try {
            parsedUrl = new URL(url);
        } catch (e) {
            console.warn("[FeedFinder] invalid url", url);
            return [];
        }

        // --- Phase 1: Site-Specific Rules ---
        const rule = siteSpecificRules[parsedUrl.hostname];
        if (rule) {
            try {
                const siteFeeds = rule(parsedUrl);
                if (siteFeeds)
                    siteFeeds.forEach((title, href) => feeds.set(href, title));
                // For site-specific rules, we assume they are comprehensive and skip other methods.
                return Array.from(feeds, ([u, t]) => ({ url: u, title: t }));
            } catch (e) {
                console.error(
                    "[FeedFinder] siteSpecific rule error for",
                    parsedUrl.hostname,
                    e,
                );
            }
        }

        // --- Phase 2: DOM Scanning ---
        function findFeedsInNode(node) {
            node.querySelectorAll(SCRIPT_CONSTANTS.UNIFIED_SELECTOR).forEach((el) => {
                if (isInsideSVG(el)) return;
                if (el.shadowRoot) findFeedsInNode(el.shadowRoot);

                let isFeed = false;
                const nodeName = el.nodeName.toLowerCase();

                if (nodeName === "link") {
                    const type = el.getAttribute("type");
                    const rel = el.getAttribute("rel");
                    if (
                        (type && /(rss|atom|xml|json)/.test(type)) ||
                            (rel === "alternate" && type)
                    ) {
                        isFeed = true;
                    }
                } else if (nodeName === "a") {
                    const hrefAttr = el.getAttribute("href");
                    if (hrefAttr && !/^(javascript|data):/i.test(hrefAttr)) {
                        if (SCRIPT_CONSTANTS.HREF_INFERENCE_REGEX.test(hrefAttr)) {
                            isFeed = true;
                        } else {
                            const img = el.querySelector("img");
                            if (img) {
                                const src = (img.getAttribute("src") || "").toLowerCase();
                                const className = (img.className || "").toLowerCase();
                                if (
                                    /(rss|feed|atom)/.test(src) ||
                                            /(rss|feed|atom)/.test(className)
                                ) {
                                    isFeed = true;
                                }
                            }
                            if (!isFeed && /(rss|feed)/i.test(el.textContent.trim())) {
                                isFeed = true;
                            }
                        }
                    }
                }

                if (isFeed) {
                    const feedUrl = safeURL(el.href);
                    if (feedUrl && !feeds.has(feedUrl)) {
                        const feedTitle = titleForElement(el, feedUrl);
                        feeds.set(feedUrl, feedTitle);
                    }
                }
            });
        }

        try {
            findFeedsInNode(initialDocument);
        } catch (e) {
            console.warn("[FeedFinder] findFeedsInNode failure", e);
        }

        // --- Phase 3: Network Probing ---
        const baseUrls = new Set([`${parsedUrl.protocol}//${parsedUrl.host}`]);
        if (parsedUrl.pathname && parsedUrl.pathname !== "/") {
            baseUrls.add(
                `${parsedUrl.protocol}//${parsedUrl.host}${parsedUrl.pathname.replace(/\/$/, "")}`,
            );
        }

        const probePromises = [];
        baseUrls.forEach((base) => {
            SCRIPT_CONSTANTS.PROBE_PATHS.forEach((path) => {
                const probeUrl = base + path;
                if (feeds.has(probeUrl)) return;
                const p = gmFetch(probeUrl, { method: "HEAD" })
                .then((response) => {
                    const contentType = response.headers.get("content-type") || "";
                    if (
                        response.ok &&
                            SCRIPT_CONSTANTS.FEED_CONTENT_TYPES.test(contentType)
                    ) {
                        if (!feeds.has(probeUrl)) {
                            feeds.set(probeUrl, `Discovered Feed: `);
                        }
                    }
                })
                .catch((err) =>
                    console.debug(
                        "[FeedFinder] probe failed",
                        probeUrl,
                        err && err.message,
                    ),
                );
                probePromises.push(p);
            });
        });

        await Promise.allSettled(probePromises);

        return Array.from(feeds, ([u, t]) => ({ url: u, title: t }));
    }

    // --- UI CSS ---
    function injectCSS(cssString) {
        const style = document.createElement("style");
        style.textContent = cssString;
        (document.head || document.documentElement).appendChild(style);
    }

    const css = `
        :root {
            --ff-collapsed: 32px;
            --ff-expanded-width: 340px;
            --ff-expanded-height: 260px;
            --ff-accent: #7c9796;
            --ff-bg-light: rgba(250, 250, 250, 0.95);
            --ff-bg-dark: rgba(28, 28, 28, 0.95);
            --ff-text-light: #1a1a1a;
            --ff-text-dark: #eeeeee;
            --ff-border: rgba(127, 127, 127, 0.2);
            --ff-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
            --ff-font: 'Monaspace Neon', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
            --ff-transition: cubic-bezier(0.25, 0.8, 0.25, 1);
        }

        /* 容器基礎樣式 & 重置 */
        .ff-widget, .ff-widget * {
            box-sizing: border-box;
            outline: none;
        }

        .ff-widget {
            position: fixed;
            bottom: 20px;
            right: 20px;
            width: var(--ff-collapsed);
            height: var(--ff-collapsed);
            background: var(--ff-accent);
            border-radius: 50%;
            box-shadow: var(--ff-shadow);
            z-index: 2147483647; /* Max Z-Index */
            cursor: pointer;
            overflow: hidden;
            font-family: var(--ff-font);
            font-size: 13px;
            line-height: 1.4;
            transition: 
                width 0.3s var(--ff-transition),
                height 0.3s var(--ff-transition),
                border-radius 0.3s var(--ff-transition),
                background-color 0.2s ease,
                transform 0.2s ease;
            -webkit-tap-highlight-color: transparent;
        }

        .ff-widget:not(.ff-active):hover {
            transform: scale(1.1);
        }

        .ff-widget.ff-active {
            width: var(--ff-expanded-width);
            height: var(--ff-expanded-height);
            border-radius: 12px;
            background: var(--ff-bg-light);
            border: 1px solid var(--ff-border);
            backdrop-filter: blur(10px);
            -webkit-backdrop-filter: blur(10px);
            cursor: default;
        }

        @media (prefers-color-scheme: dark) {
            .ff-widget.ff-active {
                background: var(--ff-bg-dark);
                color: var(--ff-text-dark);
            }
            .ff-content h4 { border-color: rgba(255,255,255,0.15); }
        }

        .ff-content {
            position: absolute;
            inset: 0;
            padding: 16px;
            display: flex;
            flex-direction: column;
            opacity: 0;
            visibility: hidden;
            transition: opacity 0.2s ease, visibility 0s linear 0.2s;
            color: var(--ff-text-light);
            text-align: left;
        }

        @media (prefers-color-scheme: dark) {
            .ff-content { color: var(--ff-text-dark); }
        }

        .ff-widget.ff-active .ff-content {
            opacity: 1;
            visibility: visible;
            transition-delay: 0.15s;
            transition: opacity 0.25s ease 0.1s;
        }

        .ff-content.hide { opacity: 0 !important; transition-delay: 0s !important; }

        .ff-content h4 {
            margin: 0 0 10px 0;
            padding-bottom: 8px;
            border-bottom: 1px solid rgba(0,0,0,0.1);
            font-size: 14px;
            font-weight: 700;
            letter-spacing: 0.5px;
            text-transform: uppercase;
        }

        .ff-list {
            list-style: none;
            margin: 0;
            padding: 0;
            overflow-y: auto;
            flex: 1;
            scrollbar-width: thin;
            scrollbar-color: var(--ff-accent) transparent;
        }

        .ff-list li {
            margin-bottom: 10px;
            padding-right: 8px;
        }

        .ff-list a {
            display: block;
            text-decoration: none;
            color: inherit;
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
            transition: color 0.15s ease;
        }

        .ff-list a.title {
            font-weight: 600;
            font-size: 13px;
            margin-bottom: 2px;
        }
        
        .ff-list a.title:hover {
            color: var(--ff-accent);
        }

        .ff-list a.url {
            font-size: 11px;
            color: #888;
            font-family: sans-serif;
            opacity: 0.8;
        }

        .ff-counter {
            position: absolute;
            inset: 0;
            display: flex;
            align-items: center;
            justify-content: center;
            font-weight: 800;
            font-size: 14px;
            color: #fff;
            opacity: 1;
            transition: opacity 0.1s ease;
        }

        .ff-widget.ff-active .ff-counter {
            opacity: 0;
            pointer-events: none;
        }

        /* WebKit Scrollbar */
        .ff-list::-webkit-scrollbar { width: 4px; }
        .ff-list::-webkit-scrollbar-track { background: transparent; }
        .ff-list::-webkit-scrollbar-thumb { background-color: rgba(124, 151, 150, 0.5); border-radius: 4px; }
        .ff-list::-webkit-scrollbar-thumb:hover { background-color: var(--ff-accent); }
    `;
    injectCSS(css);

    // Fetch and inject font
    GM_xmlhttpRequest({
        method: "GET",
        url: "https://cdn.jsdelivr.net/npm/[email protected]/neon.min.css",
        responseType: "text",
        onload: (res) => {
            if (res.status === 200 && res.responseText) {
                const baseUrl = "https://cdn.jsdelivr.net/npm/[email protected]/";
                const correctedCss = res.responseText.replace(
                    /url\((files\/.*?)\)/g,
                    `url(${baseUrl}$1)`,
                );
                injectCSS(correctedCss);
            } else {
                console.warn(
                    `[FeedFinder] Failed to load font stylesheet. Status: ${res.status}`,
                );
            }
        },
        onerror: (err) =>
            console.error("[FeedFinder] Error loading font stylesheet:", err),
    });

    // --- UI Elements (Created globally in scope so event listeners can access them) ---
    const widget = document.createElement("div");
    widget.className = "ff-widget";
    
    const counter = document.createElement("div");
    counter.className = "ff-counter";

    const content = document.createElement("div");
    content.className = "ff-content";

    const header = document.createElement("h4");
    header.textContent = "Discovered Feeds";

    const listEl = document.createElement("ul");
    listEl.className = "ff-list";

    // Assemble internal structure
    content.appendChild(header);
    content.appendChild(listEl);
    widget.appendChild(counter);
    widget.appendChild(content);

    // --- Initialization Function ---
    function initialize() {
        // 1. 防止 iframe 執行
        if (window.self !== window.top) return;

        // 2. 單例檢查:確保 ID 不重複
        const widgetId = "ff-widget-unique-instance";
        if (document.getElementById(widgetId)) return;
        
        widget.id = widgetId;

        // 3. 掛載到 documentElement (html),與 body 同級
        document.documentElement.appendChild(widget);

        if (typeof debouncedPerformDiscovery === 'function') {
            debouncedPerformDiscovery();
        }
    }

    let hasSearched = false;
    let currentUrl = window.location.href;
    const logger = (...args) => console.log("[FeedFinder]", ...args);
    function delay(ms) {
        return new Promise((r) => setTimeout(r, ms));
    }

    function createFeedListItem(feed) {
        const li = document.createElement("li");
        const titleLink = document.createElement("a");
        titleLink.href = feed.url;
        titleLink.target = "_blank";
        titleLink.className = "title";

        let titleText;
        try {
            titleText =
                feed.title && feed.title !== feed.url
                    ? feed.title
                    : new URL(feed.url).pathname
                        .split("/")
                        .filter(Boolean)
                        .slice(-1)[0] || feed.url;
        } catch (e) {
            titleText = feed.title || feed.url;
            console.warn(
                "[FeedFinder] Could not parse feed URL for title:",
                feed.url,
            );
        }
        titleLink.textContent = titleText;

        const urlLink = document.createElement("a");
        urlLink.href = feed.url;
        urlLink.target = "_blank";
        urlLink.className = "url";
        urlLink.textContent = feed.url;

        li.appendChild(titleLink);
        li.appendChild(urlLink);

        return li;
    }

    function setListMessage(message) {
        listEl.textContent = "";
        const li = document.createElement("li");
        li.className = "list-message";
        li.textContent = message;
        listEl.appendChild(li);
    }

    function renderResults(feeds) {
        listEl.textContent = "";
        if (!feeds || feeds.length === 0) {
            return;
        }

        const fragment = document.createDocumentFragment();
        feeds.forEach((feed) => {
            const li = createFeedListItem(feed);
            fragment.appendChild(li);
        });

        listEl.appendChild(fragment);
    }

    async function performDiscoveryInBackground() {
        if (hasSearched) return;
        hasSearched = true;
        setListMessage("Finding Feeds...");

        try {
            await delay(1000);

            const foundFeeds = await discoverFeeds(document, window.location.href);

            renderResults(foundFeeds);

            const feedCount = foundFeeds.length;
            counter.textContent = feedCount > 0 ? feedCount : "";

            if (feedCount === 0) {
                logger("Discovery complete. No feeds found.");
                setListMessage("No Feeds Found.");
            } else {
                logger("Discovery complete.", feedCount, "feeds found.");
            }
        } catch (e) {
            console.error("[FeedFinder] discovery error", e);
            setListMessage("An Error Occurred While Scanning.");
        }
    }

    function debounce(fn, ms) {
        let t;
        return (...a) => {
            clearTimeout(t);
            t = setTimeout(() => fn(...a), ms);
        };
    }
    const debouncedPerformDiscovery = debounce(performDiscoveryInBackground, 500);

    function handleClickOutside(e) {
        if (widget.classList.contains("ff-active") && !widget.contains(e.target)) {
            content.classList.add("hide");
            setTimeout(() => {
                widget.classList.remove("ff-active");
                content.classList.remove("hide");
            }, 230);
            document.removeEventListener("click", handleClickOutside, true);
        }
    }

    widget.addEventListener("click", (e) => {
        e.stopPropagation();
        if (!widget.classList.contains("ff-active")) {
            if (!hasSearched) performDiscoveryInBackground();
            widget.classList.add("ff-active");
            document.addEventListener("click", handleClickOutside, true);
        }
    });

    function handleUrlChange() {
        if (window.location.href !== currentUrl) {
            logger("URL changed", window.location.href);
            currentUrl = window.location.href;
            hasSearched = false;
            if (widget.classList.contains("ff-active")) {
                widget.classList.remove("ff-active");
                document.removeEventListener("click", handleClickOutside, true);
            }
            listEl.innerHTML = "";
            counter.textContent = "";
            debouncedPerformDiscovery();
        }
    }

    // --- More Efficient SPA Navigation Handling ---
    function patchHistoryMethod(methodName) {
        const originalMethod = history[methodName];
        if (originalMethod._ffPatched) {
            return;
        }
        history[methodName] = function (...args) {
            const result = originalMethod.apply(this, args);
            window.dispatchEvent(new Event(methodName.toLowerCase()));
            return result;
        };
        history[methodName]._ffPatched = true;
    }

    patchHistoryMethod("pushState");
    patchHistoryMethod("replaceState");

    const debouncedUrlChangeCheck = debounce(handleUrlChange, 250);
    ["popstate", "hashchange", "pushstate", "replacestate"].forEach(
        (eventType) => {
            window.addEventListener(eventType, debouncedUrlChangeCheck);
        },
    );

    if (document.readyState === "complete") {
        initialize();
    } else {
        window.addEventListener("load", initialize);
    }
})();