自架RSS to JSON服務:用Cloudflare Workers取代rss2json.com

這幾天登入rss2json.com出現「419 Please try again」訊息:
RSS Worker測試工具

試過各種方式一直無法登入,所以請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 使用的 Referer header 在隱私模式或部分跨協定情境下不一定會被送出,可靠性略低於自架版使用的 Origin header。此外 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_ORIGINSSECRET_TOKEN 務必在部署前替換,不要用預設值。

步驟二:(選用)綁定自訂網域

Cloudflare Workers 部署後會產生一個 *.workers.dev 的網址。如果你想用自己的網域(例如 rss2json.yourdomain.com),可以在 Worker 設定頁面的 TriggersCustom 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": "文章摘要..."
    }
  ]
}

測試結果如下圖:
RSS Worker測試工具

步驟四: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)的三個差異:

  1. Worker 請求次數不變:每次都要喚醒 Worker 執行 Token 驗證,額度消耗相同
  2. 保護 RSS 來源伺服器:5 分鐘內不管幾個人瀏覽,RSS 來源最多只被打一次
  3. 回應速度與 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 來源

測試步驟:

  1. 打開瀏覽器 DevTools(F12)→ 切到 Network 分頁
  2. 重新整理頁面,點選 Worker 的請求(網址包含 workers.dev
  3. 查看右側 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 標籤與 &#8230; 等 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_TOKEN
  • FEEDS:依需求填入 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_URLRSS_TOKEN 改成從環境變數讀取(如 import.meta.env.PUBLIC_WORKER_URL),就能更安全地管理設定。