您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Greasy Fork is available in English.
检测当前网站的feed,方便订阅RSS内容。
当前为
// ==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); } })();