RSS Feed 查找器

偵測目前網站的feed,方便訂閱RSS內容。

目前為 2025-11-21 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

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