Feed Finder

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

Versión del día 21/11/2025. Echa un vistazo a la versión más reciente.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Necesitará instalar una extensión como Tampermonkey para instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==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);
    }
})();