Greasy Fork

Greasy Fork is available in English.

RSS Feed 查找器

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

当前为 2025-10-08 提交的版本,查看 最新版本

// ==UserScript==
// @name                Feed Finder
// @name:zh-TW          RSS Feed 查找器
// @name:zh-CN          RSS Feed 查找器
// @namespace           https://github.com/Gholts
// @version             13.0
// @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 ---
  function injectCSS(cssString) {
    const style = document.createElement("style");
    style.textContent = cssString;
    (document.head || document.documentElement).appendChild(style);
  }

  const css = `
  :root{
  --ff-collapsed: 26px;
  --ff-width: 320px;
  --ff-height: 240px;
  --ff-accent: rgba(124, 151, 150);
  --ff-bg: rgba(245, 245, 245);
  --ff-bg-dark: rgba(20,20,20);
  --ff-border: rgba(127,127,127,0.18);
  --ff-shadow: rgba(0,0,0,0.18);
  --ff-font: 'Monaspace Neon', ui-monospace, monospace;
  }
  @media (prefers-color-scheme: dark) {
  :root { --ff-bg: var(--ff-bg-dark); }
  }
  .ff-widget {
  position: fixed; bottom: 20px; right: 20px;
  width: var(--ff-collapsed); height: var(--ff-collapsed);
  border-radius: 50%;
  background: var(--ff-accent);
  border: 2px solid var(--ff-border);
  box-shadow: 0 6px 18px var(--ff-shadow);
  z-index: 2147483647;
  cursor: pointer;
  overflow: hidden;
  display: flex; align-items: center; justify-content: center;
  transition: width 0.18s ease, height 0.18s ease, border-radius 0.28s ease, background-color 0.23s ease;
  }
  .ff-widget.ff-active {
  width: var(--ff-width); height: var(--ff-height);
  border-radius: 12px;
  background: var(--ff-bg);
  }
  .ff-content {
  position: absolute; inset: 0; padding: 12px;
  box-sizing: border-box; display:flex; flex-direction:column;
  opacity: 0; pointer-events: none; transition: opacity 0.22s ease;
  color: #000;
  }
  @media (prefers-color-scheme: dark) {
  .ff-content { color: #fff; }
  }
  .ff-widget.ff-active .ff-content {
  opacity: 1; pointer-events: auto; transition-delay: 0.18s;
  }
  .ff-content.hide { opacity: 0 !important; transition-delay: 0s !important; }
  .ff-content h4 { margin:0 0 8px 0; padding-bottom:6px; border-bottom:1px solid var(--ff-border); font-size:15px; font-weight: bold; }
  .ff-list {
    list-style: none;
    margin: 0;
    padding: 8px 4px 0 0; /* Adjusted padding for scrollbar */
    overflow: auto;
    flex: 1;
    /* Firefox Scrollbar Styles */
    scrollbar-width: thin;
    scrollbar-color: var(--ff-accent) transparent;
  }
  /* WebKit (Chrome, Safari) Scrollbar Styles */
  .ff-list::-webkit-scrollbar {
    width: 6px;
  }
  .ff-list::-webkit-scrollbar-track {
    background: transparent;
  }
  .ff-list::-webkit-scrollbar-thumb {
    background-color: var(--ff-accent);
    border-radius: 3px;
    border: none;
  }
  .ff-list li { margin-bottom:8px; }
  .ff-list a { font-family: var(--ff-font); color: inherit; text-decoration:none; font-size:13px; display:block; word-break:break-all; }
  .ff-list a.title { font-weight:600; margin-bottom:4px; }
  .ff-list a.url { font-size:12px; color: #7C9796; opacity:0.85; text-decoration:underline; }
  .ff-counter {
  font-family: var(--ff-font);
  color: var(--ff-bg); font-size: 14px; font-weight: bold;
  position: absolute; top:0; left:0; width:100%; height:100%;
  display: none; align-items: center; justify-content: center;
  }
  .ff-widget:not(.ff-active) .ff-counter { display: flex; }
  `;
  injectCSS(css);

  // Fetch and inject the font stylesheet content to comply with Content Security Policy (CSP).
  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) {
        // Define the correct base URL for the font files.
        const baseUrl = "https://cdn.jsdelivr.net/npm/[email protected]/";
        // Prepend the base URL to all relative font paths in the stylesheet.
        const correctedCss = res.responseText.replace(
          /url\((files\/.*?)\)/g,
          `url(${baseUrl}$1)`,
        );
        // Inject the corrected CSS content into a new style tag.
        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 元件 ---
  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";
  content.appendChild(header);
  content.appendChild(listEl);
  widget.appendChild(counter);
  widget.appendChild(content);
  function initialize() {
    if (document.body) {
      document.body.appendChild(widget);
      debouncedPerformDiscovery();
    } else {
      // Should not happen with modern run-at settings, but safe
    }
  }

  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 {
      // 防禦性 URL 解析
      titleText =
        feed.title && feed.title !== feed.url
          ? feed.title
          : new URL(feed.url).pathname
              .split("/")
              .filter(Boolean)
              .slice(-1)[0] || feed.url;
    } catch (e) {
      // 處理格式不正確的 URL
      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 = ""; // Safely clear any existing content
    const li = document.createElement("li");
    li.className = "list-message"; // Add identifying class name
    li.textContent = message;
    listEl.appendChild(li);
  }

  function renderResults(feeds) {
    listEl.textContent = ""; // Safely clear previous results
    if (!feeds || feeds.length === 0) {
      // This function will no longer set the "No Feeds Found" message by itself.
      // This state will be managed by the calling function after all async operations are complete.
      return;
    }

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

    listEl.appendChild(fragment); // Append to the DOM in one operation
  }

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

    try {
      // Allow some time for dynamic content to load before scanning.
      await delay(1000);

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

      // Final rendering based on the complete list of feeds.
      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 = ""; // Reset the counter display
      // Call the debounced function to reset and perform discovery
      debouncedPerformDiscovery();
    }
  }

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

  // Apply the patches
  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);
  }
})();