检测当前网站的feed,方便订阅RSS内容。
当前为
// ==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);
}
})();