Feed Finder

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

Version au 21/11/2025. Voir la dernière version.

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==UserScript==
// @name                Feed Finder
// @name:zh-TW          RSS Feed 查找器
// @namespace           https://github.com/Gholts
// @version             13.2
// @description         Detect the feed of the current website to facilitate subscription of RSS content.
// @description:zh-TW   偵測目前網站的feed,方便訂閱RSS內容。
// @author              Gholts
// @license             GNU Affero General Public License v3.0
// @match               *://*/*
// @grant               GM_xmlhttpRequest
// @grant               GM_setClipboard
// @run-at              document-idle
// ==/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) {
                        // 優化 Header 解析邏輯,防止值中包含冒號導致截斷
                        const parts = line.split(':');
                        const key = parts.shift();
                        const value = parts.join(':');
                        if (key && value) {
                            headers.set(key.trim().toLowerCase(), value.trim());
                        }
                    }
                    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) => {
                        if (!feeds.has(href)) feeds.set(href, title);
                    });
                }
                // [Logic Fix] 不要 return,繼續執行後續掃描,以防遺漏頁面上的其他 Feed
            } 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;
                // [Logic Fix] 移除無效的 el.shadowRoot 檢查,因為 a/link 標籤通常不具備 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 (Optimized) ---
        const baseUrls = new Set([`${parsedUrl.protocol}//${parsedUrl.host}`]);
        if (parsedUrl.pathname && parsedUrl.pathname !== "/") {
            baseUrls.add(
                `${parsedUrl.protocol}//${parsedUrl.host}${parsedUrl.pathname.replace(/\/$/, "")}`,
            );
        }

        // 封裝探測邏輯:支持 405 回退
        async function probeUrl(targetUrl) {
            if (feeds.has(targetUrl)) return;
            try {
                let res = await gmFetch(targetUrl, { method: "HEAD" });
                // 如果不支持 HEAD,回退到 GET
                if (res.status === 405) {
                    res = await gmFetch(targetUrl, { method: "GET" });
                }

                const contentType = res.headers.get("content-type") || "";
                if (
                    res.ok &&
                    SCRIPT_CONSTANTS.FEED_CONTENT_TYPES.test(contentType)
                ) {
                    if (!feeds.has(targetUrl)) {
                        feeds.set(targetUrl, `Discovered Feed`);
                    }
                }
            } catch (err) {
                // 探測失敗則忽略
            }
        }

        const probePromises = [];
        baseUrls.forEach((base) => {
            SCRIPT_CONSTANTS.PROBE_PATHS.forEach((path) => {
                const targetUrl = base + path;
                probePromises.push(probeUrl(targetUrl));
            });
        });

        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: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", 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;
        }

        /* 新增 Flex 佈局 */
        .ff-item-row {
            display: flex;
            align-items: center;
            justify-content: space-between;
            gap: 8px;
        }
        .ff-item-info {
            flex: 1;
            overflow: hidden; /* 確保文本截斷生效 */
            min-width: 0; /* Flexbox 文本溢出修復 */
        }

        .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-copy-btn {
            background: transparent;
            border: 1px solid var(--ff-border);
            color: var(--ff-accent);
            cursor: pointer;
            border-radius: 4px;
            width: 24px;
            height: 24px;
            display: flex;
            align-items: center;
            justify-content: center;
            padding: 0;
            transition: all 0.2s;
            flex-shrink: 0;
        }
        .ff-copy-btn:hover {
            background: var(--ff-accent);
            color: #fff;
        }
        .ff-copy-btn svg {
            width: 14px;
            height: 14px;
            fill: currentColor;
        }

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

    // --- 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");
        li.className = "ff-item-row"; // 使用 Flex 佈局

        // 左側信息區
        const infoDiv = document.createElement("div");
        infoDiv.className = "ff-item-info";

        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;

        infoDiv.appendChild(titleLink);
        infoDiv.appendChild(urlLink);

        // 右側複製按鈕
        const copyBtn = document.createElement("button");
        copyBtn.className = "ff-copy-btn";
        copyBtn.title = "Copy Feed URL";
        // 使用內聯 SVG 圖標
        copyBtn.innerHTML = `<svg viewBox="0 0 24 24"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>`;

        copyBtn.onclick = (e) => {
            e.stopPropagation(); // 防止觸發父級點擊事件
            GM_setClipboard(feed.url, "text");

            // 複製成功反饋 (綠色勾勾)
            const originalHtml = copyBtn.innerHTML;
            copyBtn.style.borderColor = "#4CAF50";
            copyBtn.style.color = "#4CAF50";
            copyBtn.innerHTML = `<svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>`;

            setTimeout(() => {
                copyBtn.style.borderColor = "";
                copyBtn.style.color = "";
                copyBtn.innerHTML = originalHtml;
            }, 1500);
        };

        li.appendChild(infoDiv);
        li.appendChild(copyBtn);

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