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