RSS Feed 查找器

检测当前网站的feed,方便订阅RSS内容。

当前为 2025-11-21 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

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