自架RSS to JSON服務:用Cloudflare Workers取代rss2json.com
這幾天登入rss2json.com出現「419 Please try again」訊息:
試過各種方式一直無法登入,所以請AI幫忙找了一些免費的替代方案,大部份限制都很多,最終還是使用Cloudflare Workers自行架設RSS to JSON服務。
注意
本文由 Claude Sonnet 4.6 語言模型產生。
前言
如果你曾經用過 rss2json.com 把 RSS feed 轉成 JSON 供前端使用,你大概也遇過它偶爾無法連線、API Key 額度不夠用,或是某天突然掛掉的狀況。
這篇教學示範如何用 Cloudflare Workers 自架一個相同功能的服務,完全免費、全球低延遲、自己掌控,不再依賴第三方。完成後你會有:
- 一個接受 RSS URL、回傳 JSON 的 API Endpoint
- CORS 白名單 + Secret Token 雙層防護,避免被盜用
- Worker 內部 Cache API,5 分鐘內不重複打 RSS 來源,大幅降低回應延遲
- 一個測試用 HTML 頁面
- 一個可直接套用的前端嵌入範例
為什麼選 Cloudflare Workers?
| 比較項目 | rss2json.com | Cloudflare Workers |
|---|---|---|
| 穩定性 | 依賴第三方 | 自主掌控 |
| 免費額度 | 每日 1 萬次請求 | 每日 10 萬次請求 |
| 延遲 | 單一伺服器 | 全球 Edge 節點 |
| 防護機制 | API Key + HTTP Referrer 白名單或 IP 限制 | Secret Token + CORS Origin 白名單 |
| 費用 | 免費/付費方案 | 免費(一般個人用量綽綽有餘) |
防護機制補充:兩者都是雙層防護,概念相近。主要差異在技術可靠性:rss2json.com 使用的
Refererheader 在隱私模式或部分跨協定情境下不一定會被送出,可靠性略低於自架版使用的Originheader。此外 API Key 在 rss2json.com 後台管理,自架的 Secret Token 則完全由自己掌控。
步驟一:建立 Cloudflare Worker
1-1 登入 Cloudflare Dashboard
前往 dash.cloudflare.com,登入後在左側選單點選 Workers & Pages,再點 Create。
1-2 選擇 Hello World 範本
選擇 Hello World 範本,幫 Worker 取個名稱(例如 rss2json),按 Deploy。
1-3 進入編輯器貼上程式碼
部署成功後,點 Edit Code 進入線上編輯器,將預設內容全部清除,貼上以下完整程式碼:
// ============================================================
// 設定區:請依實際情況修改這兩個變數
// ============================================================
const ALLOWED_ORIGINS = [
'https://你的網域.com', // 正式站網域
'http://localhost:4321', // 本地開發(依你的 dev server port 調整)
'http://127.0.0.1:4321',
];
const SECRET_TOKEN = '請替換成自訂的亂數字串'; // 建議 40 字元以上
const CACHE_TTL = 300; // Edge 快取秒數,300 = 5 分鐘
// ============================================================
// Worker 主體
// ============================================================
export default {
async fetch(request) {
const origin = request.headers.get('Origin') || '';
const isAllowedOrigin = ALLOWED_ORIGINS.includes(origin);
// OPTIONS preflight(瀏覽器跨域預檢)
if (request.method === 'OPTIONS') {
if (!isAllowedOrigin) return new Response(null, { status: 403 });
return new Response(null, { status: 204, headers: corsHeaders(origin) });
}
// GET 請求也驗證 Origin(有 Origin header 才驗,沒有就靠 token 把關)
if (origin && !isAllowedOrigin) {
return new Response(JSON.stringify({ error: 'Forbidden' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
const url = new URL(request.url);
// Token 驗證
const token = url.searchParams.get('token');
if (token !== SECRET_TOKEN) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
const rssUrl = url.searchParams.get('url');
const count = parseInt(url.searchParams.get('count') || '15', 10);
if (!rssUrl) {
return new Response(JSON.stringify({ error: 'Missing url parameter' }), {
status: 400,
headers: { 'Content-Type': 'application/json', ...corsHeaders(origin) },
});
}
// --- Cloudflare Edge Cache ---
// 快取 key 只用 rssUrl + count,不含 token(避免不同 token 產生重複快取)
const cache = caches.default;
const cacheKey = new Request(
`https://cache-key/${encodeURIComponent(rssUrl)}?count=${count}`
);
const cachedRes = await cache.match(cacheKey);
if (cachedRes) {
// 命中快取:回傳時補上 CORS header 和 X-Cache 標記
const headers = new Headers(cachedRes.headers);
Object.entries(corsHeaders(origin)).forEach(([k, v]) => headers.set(k, v));
headers.set('X-Cache', 'HIT');
return new Response(cachedRes.body, { status: 200, headers });
}
// 未命中:即時抓取 RSS
try {
const rssRes = await fetch(rssUrl, {
headers: { 'User-Agent': 'Mozilla/5.0 (RSS Reader)' },
});
if (!rssRes.ok) throw new Error(`RSS fetch failed: ${rssRes.status}`);
const xml = await rssRes.text();
const items = parseRSS(xml, count);
// 存入 Edge 快取(不含 CORS header,避免快取到特定 Origin 的值)
const cacheResponse = new Response(JSON.stringify({ status: 'ok', items }), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': `public, max-age=${CACHE_TTL}`,
},
});
await cache.put(cacheKey, cacheResponse.clone());
// 回傳給前端(補上 CORS header 和 X-Cache 標記)
return new Response(cacheResponse.body, {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': `public, max-age=${CACHE_TTL}`,
'X-Cache': 'MISS',
...corsHeaders(origin),
},
});
} catch (err) {
return new Response(JSON.stringify({ status: 'error', message: err.message }), {
status: 502,
headers: { 'Content-Type': 'application/json', ...corsHeaders(origin) },
});
}
},
};
// ============================================================
// 工具函式
// ============================================================
function corsHeaders(origin) {
return {
'Access-Control-Allow-Origin': origin || '',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
}
function getTagContent(xml, tag) {
// 將標籤中的冒號跳脫,確保含冒號的標籤(如 content:encoded)能正確解析
const safeTag = tag.replace(/:/g, '\\:');
const cdataRe = new RegExp(`<${safeTag}[^>]*><!\\[CDATA\\[([\\s\\S]*?)\\]\\]></${safeTag}>`, 'i');
const textRe = new RegExp(`<${safeTag}[^>]*>([\\s\\S]*?)</${safeTag}>`, 'i');
const m = xml.match(cdataRe) || xml.match(textRe);
return m ? m[1].trim() : '';
}
function parseRSS(xml, count) {
const items = [];
const itemRe = /<item[\s>]([\s\S]*?)<\/item>/gi;
let match;
while ((match = itemRe.exec(xml)) !== null && items.length < count) {
const block = match[1];
// 三重防護:依序嘗試 content:encoded(長文)→ content → description(短摘要)
let rawDesc = getTagContent(block, 'content:encoded')
|| getTagContent(block, 'content')
|| getTagContent(block, 'description');
// 移除 HTML 標籤,並統一截到 200 字
let cleanDesc = rawDesc.replace(/<[^>]*>?/gm, '').trim();
if (cleanDesc.length > 200) cleanDesc = cleanDesc.substring(0, 200) + '...';
items.push({
title: getTagContent(block, 'title'),
link: getTagContent(block, 'link') || getTagContent(block, 'guid'),
pubDate: getTagContent(block, 'pubDate'),
description: cleanDesc,
});
}
return items;
}
貼上後按右上角 Deploy 部署。
注意:
ALLOWED_ORIGINS和SECRET_TOKEN務必在部署前替換,不要用預設值。
步驟二:(選用)綁定自訂網域
Cloudflare Workers 部署後會產生一個 *.workers.dev 的網址。如果你想用自己的網域(例如 rss2json.yourdomain.com),可以在 Worker 設定頁面的 Triggers → Custom Domains 中新增。
網域必須已在 Cloudflare 管理 DNS 才能使用此功能。
步驟三:測試 Worker
建立以下 test.html,用瀏覽器直接開啟(不需要 server),確認 Worker 運作正常。
⚠️ 直接開啟 HTML 檔案前的必要設定
用瀏覽器直接開啟本機 HTML 檔案(
file://協定)時,瀏覽器送出的Origin是字串"null",不在白名單裡,Worker 會把請求擋掉並顯示錯誤。測試前請先在 Worker 程式碼的 Origin 判斷處加上這個豁免:
// 修改前 const isAllowedOrigin = ALLOWED_ORIGINS.includes(origin); // 修改後(加上 file:// 豁免,僅測試用) const isAllowedOrigin = ALLOWED_ORIGINS.includes(origin) || origin === 'null';⚠️ 重要:確認測試沒問題後,務必改回原本的版本再重新部署。留著
origin === 'null'會讓任何人用curl -H "Origin: null"繞過 CORS 防護。
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<title>RSS Worker 測試</title>
<style>
body { font-family: sans-serif; max-width: 800px; margin: 2rem auto; padding: 0 1rem; }
label { display: block; margin-top: 1rem; font-weight: bold; }
input { width: 100%; padding: 0.4rem; margin-top: 0.3rem; box-sizing: border-box; }
button { margin-top: 1rem; padding: 0.5rem 1.5rem; background: #0366d6; color: #fff;
border: none; border-radius: 4px; cursor: pointer; font-size: 1rem; }
pre { background: #f6f8fa; padding: 1rem; border-radius: 6px;
overflow: auto; font-size: 0.85em; margin-top: 1rem; }
.ok { color: #28a745; font-weight: bold; }
.err { color: #d73a49; font-weight: bold; }
</style>
</head>
<body>
<h2>🔧 RSS Worker 測試工具</h2>
<label>Worker URL
<input id="workerUrl" value="https://你的worker名稱.workers.dev">
</label>
<label>Token
<input id="token" placeholder="貼上你的 SECRET_TOKEN">
</label>
<label>RSS URL
<input id="rssUrl" value="https://feeds.feedburner.com/rsscna/intworld">
</label>
<button onclick="runTest()">▶ 測試</button>
<p id="status"></p>
<pre id="output">(結果會顯示在這裡)</pre>
<script>
async function runTest() {
const base = document.getElementById('workerUrl').value.trim();
const token = document.getElementById('token').value.trim();
const rss = document.getElementById('rssUrl').value.trim();
const status = document.getElementById('status');
const output = document.getElementById('output');
status.textContent = '請求中…';
output.textContent = '';
try {
const url = `${base}?url=${encodeURIComponent(rss)}&count=5&token=${token}`;
const res = await fetch(url);
const data = await res.json();
status.innerHTML = res.ok
? `<span class="ok">✅ 成功(HTTP ${res.status}),共 ${data.items?.length ?? 0} 筆</span>`
: `<span class="err">❌ 失敗(HTTP ${res.status})</span>`;
output.textContent = JSON.stringify(data, null, 2);
} catch (e) {
status.innerHTML = `<span class="err">❌ 錯誤:${e.message}</span>`;
}
}
</script>
</body>
</html>
測試成功的話,你會看到類似下面的 JSON 回應:
{
"status": "ok",
"items": [
{
"title": "文章標題",
"link": "https://...",
"pubDate": "Fri, 06 Mar 2026 06:50:13 +0000",
"description": "文章摘要..."
}
]
}
測試結果如下圖:
步驟四:API 參數說明
| 參數 | 必填 | 說明 |
|---|---|---|
url |
✅ | RSS feed 網址(需 URL encode) |
token |
✅ | 你設定的 SECRET_TOKEN |
count |
❌ | 回傳筆數,預設 15 |
呼叫範例:
https://你的worker.workers.dev?url=https%3A%2F%2Ffeeds.feedburner.com%2Frsscna%2Fintworld&count=10&token=你的token
關於 Worker 內部快取(Cache API)
Worker 程式碼使用了 caches.default(Cache API)在 Worker 內部建立快取。這跟 Cloudflare CDN 邊緣快取是不同的東西,需要釐清:
*.workers.dev的請求預設不會被 Cloudflare CDN 快取,每次請求都會喚醒 Worker 並消耗一次額度。你在 DevTools 看到的cf-cache-status: HIT,實際上是 Worker 內部 Cache API 回傳時附帶的標籤,並不代表 Worker 被繞過了。
這份程式碼真正達成的效果:
每次請求 → 消耗 1 次 Worker 額度(無論如何)
└─ Cache API HIT → 不打 RSS 來源伺服器 ✅
└─ Cache API MISS → 打 RSS 來源,存入 Cloudflare 邊緣機房快取
快取存放的位置是 Cloudflare 邊緣機房(例如台灣使用者的資料存在台北節點),不是使用者的瀏覽器。前端的 cache: 'no-store' 確保瀏覽器每次都去打 Cloudflare,而 Cloudflare 節點再決定要從快取回應還是去抓 RSS。
新版(有 Cache API)vs 舊版(無 Cache API)的三個差異:
- Worker 請求次數不變:每次都要喚醒 Worker 執行 Token 驗證,額度消耗相同
- 保護 RSS 來源伺服器:5 分鐘內不管幾個人瀏覽,RSS 來源最多只被打一次
- 回應速度與 CPU 時間大幅改善:
- 舊版每次都要等 Yahoo/TechNews 回應(300~500ms),還要跑正規表達式解析 XML,大量消耗 CPU 時間
- 新版 Cache API HIT 時直接從邊緣機房回傳已解析好的 JSON,回應時間約 10~20ms,且完全不消耗 XML 解析的 CPU 時間
Worker 回應的自訂 header:
| Header | 值 | 說明 |
|---|---|---|
X-Cache |
MISS |
Cache API 未命中,Worker 去抓 RSS 來源 |
X-Cache |
HIT |
Cache API 命中,直接回傳快取,不打 RSS 來源 |
測試步驟:
- 打開瀏覽器 DevTools(F12)→ 切到 Network 分頁
- 重新整理頁面,點選 Worker 的請求(網址包含
workers.dev) - 查看右側 Response Headers 裡的
X-Cache
第一次請求應看到 X-Cache: MISS,5 分鐘內的後續請求應看到 X-Cache: HIT。
快取更新週期:
0:00 第一個人刷新 → MISS → 抓 RSS → 存快取(消耗 1 次額度)
0:01 第二個人刷新 → HIT(不打 RSS,但仍消耗 1 次額度)
0:02 第三個人刷新 → HIT(不打 RSS,但仍消耗 1 次額度)
...(5 分鐘內 RSS 來源只被打 1 次,但 Worker 額度每次都扣)
5:00 快取過期
5:01 第一個人刷新 → MISS → 抓 RSS → 重置快取計時
對個人網站來說這已經完全足夠,也不建議加 Cache Rules。原因是 Cache Rules 命中時請求不會進 Worker,Token 驗證和 CORS 檢查都會被跳過,任何人只要知道 Worker URL 就能在快取期間直接拿到資料,造成防護漏洞得不償失。
為什麼前端不需要修改?
cache: 'no-store'確保瀏覽器每次都去打 Cloudflare,取得最新資料,SWR 的 localStorage 快取也完全不受影響。Cache API 在 Worker 內部處理,前端感知不到。
步驟五:前端嵌入範例
以下是一個完整、可直接使用的 HTML 範例,讀取 RSS 並渲染成新聞列表,帶有分類過濾與 SWR 快取策略(先顯示舊快取、背景更新後無縫替換):
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RSS 新聞列表</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
max-width: 800px; margin: 0 auto; padding: 1rem; color: #24292e; }
/* 分類過濾列 */
.filter-nav {
display: flex; gap: 0.5rem; flex-wrap: wrap;
padding: 0.8rem 0; border-bottom: 1px solid #eaeaea; margin-bottom: 1.5rem;
}
.filter-btn {
padding: 0.35rem 1rem; border: 1px solid #d0d7de; border-radius: 20px;
background: #fff; cursor: pointer; font-size: 0.9rem; color: #555;
transition: all 0.2s;
}
.filter-btn:hover { background: #f0f4f8; color: #0366d6; }
.filter-btn.active { background: #0366d6; color: #fff; border-color: #0366d6; }
/* 新聞項目 */
.news-item {
border-bottom: 1px solid #eaeaea; padding: 1.2rem 0;
animation: fadeIn 0.3s ease;
}
.news-item:last-child { border-bottom: none; }
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.news-header { display: flex; align-items: center; gap: 0.5rem;
flex-wrap: wrap; margin-bottom: 0.4rem; }
.news-tag {
font-size: 0.72rem; padding: 0.2em 0.55em; border-radius: 4px;
color: #fff; font-weight: 600; white-space: nowrap;
}
.tag-a { background: #0366d6; }
.tag-b { background: #28a745; }
.tag-c { background: #fd7e14; }
.tag-d { background: #6f42c1; }
.news-title {
font-size: 1.1rem; font-weight: 600; color: #24292e;
text-decoration: none; transition: color 0.2s;
}
.news-title:hover { color: #0366d6; text-decoration: underline; }
.news-date { font-size: 0.82rem; color: #6a737d; margin: 0.3rem 0 0.6rem; }
.news-summary { font-size: 0.95rem; line-height: 1.6; color: #444; margin: 0;
/* 多行截斷:超過 3 行自動省略,保持排版整齊 */
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 狀態訊息 */
.status-msg {
text-align: center; padding: 2rem; background: #f6f8fa;
border-radius: 6px; color: #666;
}
.error-msg { color: #d73a49; }
.empty-msg {
text-align: center; padding: 3rem 2rem; color: #6a737d;
background: #f6f8fa; border-radius: 6px; border: 2px dashed #eaeaea;
}
</style>
</head>
<body>
<h1 style="font-size:1.6rem; margin-bottom:0.5rem;">最新新聞</h1>
<nav class="filter-nav" id="filter-nav">
<button class="filter-btn active" data-filter="all">全部</button>
<button class="filter-btn" data-filter="科技">科技</button>
<button class="filter-btn" data-filter="財經">財經</button>
<button class="filter-btn" data-filter="國際">國際</button>
<button class="filter-btn" data-filter="測試">測試</button>
</nav>
<div id="loading" class="status-msg">讀取中…</div>
<div id="empty" class="empty-msg" style="display:none">此分類目前沒有新聞。</div>
<div id="output"></div>
<script>
// ============================================================
// ★ 請修改以下三個設定
// ============================================================
const WORKER_URL = 'https://你的worker.workers.dev';
const RSS_TOKEN = '你的SECRET_TOKEN';
const COUNT = 15; // 每個來源抓幾筆
// RSS 來源清單:依需求新增或刪除
const FEEDS = [
{ url: 'https://feeds.feedburner.com/rsscna/technology', tag: '科技', colorClass: 'tag-a' },
{ url: 'https://feeds.feedburner.com/rsscna/finance', tag: '財經', colorClass: 'tag-b' },
{ url: 'https://feeds.feedburner.com/rsscna/intworld', tag: '國際', colorClass: 'tag-c' },
{ url: '', tag: '測試', colorClass: 'tag-d' },
];
// ============================================================
const CACHE_KEY = 'rss_news_cache_v1';
async function fetchFeed(feedDef) {
const api = `${WORKER_URL}?url=${encodeURIComponent(feedDef.url)}&count=${COUNT}&token=${RSS_TOKEN}`;
try {
const res = await fetch(api, { cache: 'no-store' });
const data = await res.json();
if (data.status === 'ok' && data.items?.length > 0) {
return data.items.map(item => ({ ...item, feedTag: feedDef.tag, feedColor: feedDef.colorClass }));
}
return [];
} catch (e) {
console.warn(`[RSS] 無法載入 ${feedDef.tag}:`, e);
return [];
}
}
function formatDate(pubDate) {
const d = new Date(pubDate.replace(/-/g, '/') + ' UTC');
if (isNaN(d)) return pubDate;
return d.toLocaleString('zh-TW', {
timeZone: 'Asia/Taipei',
year: 'numeric', month: 'long', day: 'numeric',
hour12: true, hour: '2-digit', minute: '2-digit',
});
}
function renderNews(items) {
const output = document.getElementById('output');
output.innerHTML = '';
items.forEach(item => {
const el = document.createElement('div');
el.className = 'news-item';
el.dataset.tag = item.feedTag;
// 用瀏覽器內建 DOM 解析摘要,自動處理 HTML 標籤與 … 等 HTML 實體
const tempDiv = document.createElement('div');
tempDiv.innerHTML = item.description;
let summary = tempDiv.textContent || tempDiv.innerText || '';
// 清理部分來源特有的 [...] 或 […] 殘留,避免與結尾省略號重複疊加
summary = summary.replace(/\[\s*…\s*\]/g, '').replace(/\[\s*\.\.\.\s*\]/g, '').trim();
el.innerHTML = `
<div class="news-header">
<span class="news-tag ${item.feedColor}">${item.feedTag}</span>
<a class="news-title" href="${item.link}" target="_blank" rel="noopener noreferrer">
${item.title}
</a>
</div>
<p class="news-date">${formatDate(item.pubDate)}</p>
<p class="news-summary">${summary}${summary ? '…' : ''}</p>
`;
output.appendChild(el);
});
}
function applyFilter(tag) {
const items = document.querySelectorAll('.news-item');
const emptyEl = document.getElementById('empty');
// API 失敗時 output 裡會有 .error-msg,這時不應再顯示空狀態框
const hasError = document.getElementById('output').querySelector('.error-msg') !== null;
let count = 0;
items.forEach(item => {
const show = tag === 'all' || item.dataset.tag === tag;
item.style.display = show ? '' : 'none';
if (show) count++;
});
if (emptyEl) {
emptyEl.style.display = (!hasError && count === 0) ? 'block' : 'none';
}
}
async function loadNews() {
const loadingEl = document.getElementById('loading');
const outputEl = document.getElementById('output');
// SWR:先顯示快取
const cached = localStorage.getItem(CACHE_KEY);
if (cached) {
try {
renderNews(JSON.parse(cached));
loadingEl.style.display = 'none';
} catch (_) {}
}
// 背景拉最新資料
try {
const results = await Promise.all(FEEDS.map(fetchFeed));
const combined = results.flat();
if (combined.length === 0 && !cached) {
loadingEl.style.display = 'none';
outputEl.innerHTML = '<p class="status-msg error-msg">無法載入新聞,請稍後再試。</p>';
return;
}
if (combined.length > 0) {
combined.sort((a, b) =>
new Date(b.pubDate.replace(/-/g, '/') + ' UTC') -
new Date(a.pubDate.replace(/-/g, '/') + ' UTC')
);
localStorage.setItem(CACHE_KEY, JSON.stringify(combined));
loadingEl.style.display = 'none';
renderNews(combined);
// 維持使用者目前選取的分類
const activeBtn = document.querySelector('.filter-btn.active');
if (activeBtn) applyFilter(activeBtn.dataset.filter);
}
} catch (e) {
console.error('[RSS] 背景更新失敗:', e);
if (!cached) {
loadingEl.style.display = 'none';
outputEl.innerHTML = '<p class="status-msg error-msg">無法載入新聞,請稍後再試。</p>';
}
}
}
// 分類過濾
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
applyFilter(btn.dataset.filter);
});
});
loadNews();
</script>
</body>
</html>
使用前只需修改最上方設定區的三個變數:
WORKER_URL:你的 Worker 網址RSS_TOKEN:你設定的 SECRET_TOKENFEEDS:依需求填入 RSS 來源
測試結果如下圖:
測試 HTML 無法呼叫 Worker?
如果你直接用瀏覽器開啟測試 HTML(file:// 協定),會看到這個錯誤:
❌ 錯誤:NetworkError when attempting to fetch resource.
原因是從本機檔案開啟的頁面,瀏覽器送出的 Origin 是字串 "null",不在白名單內,Worker 會直接拒絕請求。
快速解法:暫時允許 null Origin
將 Worker 的 Origin 判斷這一行:
const isAllowedOrigin = ALLOWED_ORIGINS.includes(origin);
暫時改成:
// ⚠️ 僅限本機測試用,確認正常後務必改回上面那行
const isAllowedOrigin = ALLOWED_ORIGINS.includes(origin) || origin === 'null';
⚠️ 安全提醒:
origin === 'null'這個豁免允許任何人用curl -H "Origin: null"繞過 CORS 檢查,因此只能在測試期間使用。確認 Worker 功能正常後,務必改回ALLOWED_ORIGINS.includes(origin),再重新部署。
防護機制說明
這個 Worker 具備兩層防護:
CORS Origin 白名單:瀏覽器發出跨域請求時一定會帶 Origin header,Worker 會核對是否在白名單內,不在的話直接回 403,其他網域的網頁無法使用你的服務。
Secret Token:所有請求都必須帶正確的 token 參數,沒有就回 401。這層防護可以擋住直接用 curl 或 Postman 嘗試呼叫的情況。
⚠️ 提醒:Token 是放在前端 JS 裡的,有心人士開 DevTools 還是看得到。這兩層防護的目的是擋住無差別濫用,而非對抗針對性攻擊。如有更高安全需求,可考慮在 Cloudflare Workers 加入 Rate Limiting。
Cloudflare Workers 免費方案限制
| 項目 | 免費額度 |
|---|---|
| 每日請求數 | 100,000 次 |
| 每次 CPU 時間 | 10 ms |
| Workers 數量 | 100 個 |
一般個人網站的新聞頁面遠遠用不到這個上限,免費方案完全足夠。
小結
用 Cloudflare Workers 自架 RSS to JSON 服務,一次性設定完成後就不需要再維護,也不用擔心第三方服務的穩定性。整個流程大約 15 分鐘就能完成,非常適合靜態網站或前端專案使用。
如果你的網站是用 Astro、Next.js、Nuxt 等框架,把上面的 WORKER_URL 和 RSS_TOKEN 改成從環境變數讀取(如 import.meta.env.PUBLIC_WORKER_URL),就能更安全地管理設定。