30/7/26 see feedgate_readme.txt for licence, setup instructions, and terms of use. // ===================================================================== // FEEDGATE v7.8 - Personal RSS Aggregator & Webhook Engine // ===================================================================== // ===================================================================== // PART 1: CLOUDFLARE WORKER // Save as worker.js and deploy via the Cloudflare Workers dashboard. // // Bindings required (set in the Worker's Settings > Variables): // FEEDGATE_KV -- KV Namespace binding // WEBHOOK_SECRET -- Secret (authenticates incoming webhook posts) // ===================================================================== // ═══════════════════════════════════════════════════════════════════════════ // FEEDGATE WORKER v7.8 // ═══════════════════════════════════════════════════════════════════════════ // Changes in v7.8: // fixed new feed old item cap, webhook budget, increasted lock ttl // Changes in v7.6: // - scheduled() rewritten to process gates in a controllable order // (cron_order in KV), chunked to stay under the free-tier subrequest // cap, with a KV lock to prevent overlapping runs. // Changes in v7.5 // Adding a feed to an existing gate now adds only one old item. // Items from feeds with no on items will then show the channel name instead. // Changes in v7.4: // - checkRule() replaced \b regex word boundary logic with a manual loop-based // word-boundary check using indexOf and character inspections. // - buildRss() wraps dateObj.toUTCString() in try/catch with fallback. // RETAINED FEATURES: // - Restored try/catch around dateObj.toISOString() in the items:slug JSON // export. // - Image extraction now checks the type= attribute on media:content / // media:thumbnail / enclosure tags and rejects obviously non-image URLs // (video/audio extensions, YouTube/Vimeo/Dailymotion links) before // falling through to <img> tags in description/content. // - imageUrl extraction and propagation to RSS <media:content> output and // the items:slug JSON export. // - Webhook ingest logic and master weighting. // ═══════════════════════════════════════════════════════════════════════════ const FETCH_TIMEOUT_MS = 3000; const MAX_WEBHOOK_ITEMS = 100; const MAX_SEEN_ITEMS = 300; const UA_SPOOF = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'; export default { async fetch(request, env, ctx) { if (request.method === "OPTIONS") { return new Response(null, { headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, HEAD, POST, PUT, OPTIONS", "Access-Control-Allow-Headers": "Cache-Control, Pragma, Content-Type", "Access-Control-Max-Age": "86400", }, }); } const url = new URL(request.url); const path = url.pathname.substring(1); if (!path || path === "") { return new Response("FeedGate v7.8 Active.", { status: 200 }); } if (request.method === "POST" || request.method === "PUT") { return handleWebhook(request, env, path); } const raw = await env.FEEDGATE_KV.get("gate:" + path); if (!raw) return new Response("Gate Not Found: " + path, { status: 404 }); const gate = JSON.parse(raw); const isHardRefresh = request.headers.get("Cache-Control") === "no-cache" || request.headers.get("Pragma") === "no-cache"; return processGate(request, env, ctx, gate, isHardRefresh); }, async scheduled(event, env, ctx) { const LOCK_TTL_SECONDS = 180; const SUBREQUEST_BUDGET = 45; const lockRaw = await env.FEEDGATE_KV.get("cron_lock"); if (lockRaw) return; // a previous run is still in progress await env.FEEDGATE_KV.put("cron_lock", "1", { expirationTtl: LOCK_TTL_SECONDS }); try { const list = await env.FEEDGATE_KV.list({ prefix: "gate:" }); const realSlugs = new Set(list.keys.map(k => k.name.substring(5))); if (realSlugs.size === 0) return; const orderRaw = await env.FEEDGATE_KV.get("cron_order"); let order = orderRaw ? JSON.parse(orderRaw) : []; order = order.filter(slug => realSlugs.has(slug)); for (const slug of realSlugs) { if (!order.includes(slug)) order.push(slug); } await env.FEEDGATE_KV.put("cron_order", JSON.stringify(order)); const n = order.length; let cursorRaw = await env.FEEDGATE_KV.get("cron_cursor"); let i = cursorRaw ? (parseInt(cursorRaw) % n) : 0; const toProcess = []; let subrequestTotal = 0; for (let visited = 0; visited < n; visited++) { const slug = order[i]; const gateRaw = await env.FEEDGATE_KV.get("gate:" + slug); const gate = gateRaw ? JSON.parse(gateRaw) : null; const sourceCount = gate ? (gate.sources || []).filter(s => s.url && s.url.startsWith('http')).length : 0; const webhookCost = gate ? (gate.outputWebhooks?.length || 0) * 3 : 0; const gateCost = sourceCount + webhookCost; if (toProcess.length > 0 && subrequestTotal + gateCost > SUBREQUEST_BUDGET) break; toProcess.push(slug); subrequestTotal += gateCost; i = (i + 1) % n; } for (const slug of toProcess) { await processSlugOutput(slug, env, ctx); } await env.FEEDGATE_KV.put("cron_cursor", String(i)); } finally { await env.FEEDGATE_KV.delete("cron_lock"); } } }; async function processSlugOutput(slug, env, ctx) { const raw = await env.FEEDGATE_KV.get("gate:" + slug); if (!raw) return; const gate = JSON.parse(raw); if (!gate.outputWebhooks || gate.outputWebhooks.length === 0) return; const fakeRequest = new Request("https://internal/" + slug); await processGate(fakeRequest, env, ctx, gate, false); const items = await env.FEEDGATE_KV.get("items:" + slug); if (!items) return; const itemsJson = JSON.parse(items); const seenKey = "webhook_seen:" + slug; const seenRaw = await env.FEEDGATE_KV.get(seenKey); const isFirstRun = !seenRaw; let seen = seenRaw ? JSON.parse(seenRaw) : []; const seenSet = new Set(seen); const makeGuid = (it) => it.link || (it.title + "|" + (it.description || "").slice(0, 100)); const newItems = itemsJson.items.filter(it => !seenSet.has(makeGuid(it))); for (const it of itemsJson.items) { const guid = makeGuid(it); if (!seenSet.has(guid)) { seen.push(guid); seenSet.add(guid); } } seen = seen.slice(-MAX_SEEN_ITEMS); await env.FEEDGATE_KV.put(seenKey, JSON.stringify(seen)); if (isFirstRun) return; for (const it of newItems) { const payload = { title: it.title || "", link: it.link || "", text: it.description || it.content || "", source: gate?.proxy?.title || slug, imageUrl: it.imageUrl || "" }; for (const wh of gate.outputWebhooks) { if (!wh.url) continue; const whUrl = wh.url + (wh.secret ? (wh.url.includes('?') ? '&' : '?') + `secret=${encodeURIComponent(wh.secret)}` : ""); try { const isFeedPost = env.FEEDPOST && whUrl.includes("feedpost"); const whRes = isFeedPost ? await env.FEEDPOST.fetch(new Request(whUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) })) : await fetch(whUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }); const whBodyText = await whRes.text(); console.log(`Webhook push to ${wh.name || whUrl}: HTTP ${whRes.status} — Body: ${whBodyText.slice(0, 300)}`); } catch(e) { console.log(`Webhook push FAILED to ${wh.name || whUrl}: ${e.message}`); } } } } // ── Webhook Handler ───────────────────────────────────────────────────────── async function handleWebhook(request, env, gateId) { const url = new URL(request.url); const secret = url.searchParams.get("secret"); if (!env.WEBHOOK_SECRET || secret !== env.WEBHOOK_SECRET) { return new Response("Unauthorised", { status: 401 }); } const gateRaw = await env.FEEDGATE_KV.get("gate:" + gateId); if (!gateRaw) return new Response("Gate Not Found", { status: 404 }); let payload; try { payload = await request.json(); } catch(e) { return new Response("Invalid JSON", { status: 400 }); } const now = Date.now(); const bodyText = payload.text || payload.summary || payload.description || ""; const item = { title: (payload.title || bodyText.slice(0, 80) || "New item").trim(), description: bodyText, content: bodyText, imageUrl: payload.imageUrl || "", link: payload.link || payload.url || "", pubDate: new Date(now).toUTCString(), sourceTitle: payload.source || "Webhook", isWebhook: true }; const indexKey = "webhook_index:" + gateId; const itemKey = "webhook_item:" + gateId + ":" + now; const rawIndex = await env.FEEDGATE_KV.get(indexKey); let index = rawIndex ? JSON.parse(rawIndex) : []; index.unshift(itemKey); const trimmed = index.slice(0, MAX_WEBHOOK_ITEMS); await env.FEEDGATE_KV.put(itemKey, JSON.stringify(item)); await env.FEEDGATE_KV.put(indexKey, JSON.stringify(trimmed)); return new Response("OK", { status: 200 }); } // ── Gate Processing Logic ─────────────────────────────────────────────────── async function processGate(request, env, ctx, gate, refresh) { try { const gateId = new URL(request.url).pathname.substring(1); const rssSources = (gate.sources || []).filter(s => s.url.startsWith('http')); const webhookConfigs = (gate.sources || []).filter(s => !s.url.startsWith('http')); const rssResults = await Promise.allSettled(rssSources.map(s => fetchSource(s.url, request, env, refresh))); let feedData = rssResults.map((res, i) => ({ items: res.status === 'fulfilled' ? parseItems(res.value, rssSources[i].url) : [], weight: parseInt(rssSources[i].weight) || 1, url: rssSources[i].url })); // First-seen cap: limit new RSS sources to 1 item on their first appearance const seenSourcesKey = "source_first_seen:" + gateId; const seenSourcesRaw = await env.FEEDGATE_KV.get(seenSourcesKey); const isFirstSourceRun = !seenSourcesRaw; const seenSources = new Set(seenSourcesRaw ? JSON.parse(seenSourcesRaw) : []); let seenSourcesChanged = false; for (const f of feedData) { if (!seenSources.has(f.url)) { if (!isFirstSourceRun) f.items = f.items.slice(0, 1); seenSources.add(f.url); seenSourcesChanged = true; } } if (seenSourcesChanged) { await env.FEEDGATE_KV.put(seenSourcesKey, JSON.stringify([...seenSources])); } const webhookRawIndex = await env.FEEDGATE_KV.get("webhook_index:" + gateId); if (webhookRawIndex) { const webhookIndex = JSON.parse(webhookRawIndex); const webhookItemsRaw = await Promise.all(webhookIndex.map(k => env.FEEDGATE_KV.get(k))); const webhookItems = webhookItemsRaw.filter(Boolean).map(v => { const it = JSON.parse(v); it.dateObj = new Date(it.pubDate); return it; }); const groups = webhookItems.reduce((acc, it) => { const name = it.sourceTitle; if (!acc[name]) { const config = webhookConfigs.find(c => c.url.toLowerCase() === name.toLowerCase()); acc[name] = { items: [], weight: config ? parseInt(config.weight) : 1, url: "webhook:" + name }; } acc[name].items.push(it); return acc; }, {}); Object.values(groups).forEach(group => feedData.push(group)); } let ageLimit = parseInt(gate?.ageLimit); if (isNaN(ageLimit)) { ageLimit = parseInt(gate?.proxy?.ageLimit) || 0; } if (ageLimit && ageLimit > 0) { const cutoff = Date.now() - (ageLimit * 24 * 60 * 60 * 1000); feedData.forEach(f => { f.items = f.items.filter(it => it.dateObj.getTime() > cutoff); }); } feedData.forEach(f => { f.items = f.items.filter(item => { let incPass = true; if (gate.rulesInclude?.length) { incPass = checkRule(item, gate.rulesInclude[0]); for (let i = 1; i < gate.rulesInclude.length; i++) { const m = checkRule(item, gate.rulesInclude[i]); if (gate.rulesInclude[i].logic === 'Or') incPass = incPass || m; else incPass = incPass && m; } } let excFail = false; if (gate.rulesExclude?.length) { excFail = checkRule(item, gate.rulesExclude[0]); for (let i = 1; i < gate.rulesExclude.length; i++) { const m = checkRule(item, gate.rulesExclude[i]); if (gate.rulesExclude[i].logic === 'Or') excFail = excFail || m; else excFail = excFail && m; } } return incPass && !excFail; }); }); let totalSlots = parseInt(gate?.maxItems); if (isNaN(totalSlots)) { totalSlots = parseInt(gate?.proxy?.maxItems) || 20; } totalSlots = totalSlots || 20; let finalItems = []; let scores = feedData.map(() => 0); const totalWeight = feedData.reduce((sum, f) => sum + f.weight, 0); for (let s = 0; s < totalSlots; s++) { let bestIdx = -1; for (let i = 0; i < feedData.length; i++) { if (feedData[i].items.length > 0) { scores[i] += feedData[i].weight; if (bestIdx === -1 || scores[i] > scores[bestIdx]) bestIdx = i; } } if (bestIdx === -1) break; finalItems.push(feedData[bestIdx].items.shift()); if (totalWeight > 0) scores[bestIdx] -= totalWeight; } finalItems.sort((a, b) => b.dateObj - a.dateObj); if (gateId) { try { const itemsJson = { slug: gateId, generatedAt: new Date().toISOString(), title: gate?.proxy?.title || gate?.widget?.title || gateId, items: finalItems.map(it => { let pubDateIso; try { pubDateIso = it.dateObj.toISOString(); } catch(e) { pubDateIso = new Date().toISOString(); } return { title: it.title, link: it.link, pubDate: pubDateIso, description: it.description, content: it.content, author: it.author || "", sourceTitle: it.sourceTitle, sourceUrl: it.sourceUrl || it.link, imageUrl: it.imageUrl || "" }; }) }; await env.FEEDGATE_KV.put("items:" + gateId, JSON.stringify(itemsJson), { expirationTtl: 86400 }); } catch(e) {} } const body = buildRss(gate?.proxy?.title || "FeedGate", finalItems, request.url); return serveResponse(body); } catch(e) { return new Response("Error: " + e.message, { status: 500 }); } } // ── Helpers ────────────────────────────────────────────────────────────────── function serveResponse(body) { return new Response(body, { headers: { "Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=60", "Access-Control-Allow-Headers": "Cache-Control, Pragma", "Content-Type": "application/rss+xml;charset=UTF-8" } }); } function extractRootDomain(url) { try { const hostname = new URL(url).hostname.toLowerCase(); const parts = hostname.split('.').filter(p => p !== 'www'); if (parts.length <= 2) return parts.join('.'); const isTwoPartTld = parts[parts.length - 2].length <= 3; return isTwoPartTld ? parts.slice(-3).join('.') : parts.slice(-2).join('.'); } catch(e) { return "News Source"; } } function decodeEntities(s) { if (!s) return ""; return s.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(n)) .replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCharCode(parseInt(n, 16))) .replace(/"/g, '"').replace(/&/g, '&').replace(/</g, '<') .replace(/>/g, '>').replace(/'/g, "'").replace(/ /g, ' '); } async function fetchSource(urlStr, request, env, refresh) { const target = new URL(urlStr.includes('freshrss') ? urlStr.replace('a=normal','a=rss') : urlStr); const worker = new URL(request.url); if (target.host === worker.host) { const raw = await env.FEEDGATE_KV.get("gate:" + target.pathname.substring(1)); if (raw) return (await processGate(request, env, {waitUntil:()=>{}}, JSON.parse(raw), refresh)).text(); } const res = await fetch(urlStr, { headers: {'User-Agent': UA_SPOOF}, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); if (!res.ok) throw new Error("HTTP " + res.status + " on " + urlStr); return res.text(); } function checkRule(item, rule) { const map = { title: item.title, text: (item.title + " " + item.description + " " + item.content), author: item.author, url: item.sourceUrl || item.link }; const key = (rule.key || "").trim(); let pattern = key.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&"); if (!pattern) return true; const text = map[rule.target] || ""; const rx = new RegExp(pattern, "i"); if (!rx.test(text)) return false; // Manual word-boundary check for title/text targets if (rule.target === 'title' || rule.target === 'text') { const lowerText = text.toLowerCase(); const lowerKey = key.toLowerCase(); let pos = lowerText.indexOf(lowerKey); while (pos !== -1) { const before = pos > 0 ? text.charAt(pos - 1) : ""; const after = pos + key.length < text.length ? text.charAt(pos + key.length) : ""; if (!/\w/.test(before) && !/\w/.test(after)) return true; pos = lowerText.indexOf(lowerKey, pos + 1); } return false; } return true; } function parseItems(xml, sourceUrl) { const matches = [...xml.matchAll(/<(item|entry)>([\s\S]*?)<\/(item|entry)>/gi)]; const feedTitle = (xml.match(/<title[^>]*>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/title>/i) || [])[1]?.replace(/<[^>]*>/g,"").trim() || "Source"; return matches.map(m => { const b = m[2]; const rawT = (b.match(/<title[^>]*>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/title>/i) || [])[1] || feedTitle; let l = (b.match(/<link>([^<]+)<\/link>/i) || b.match(/<link[^>]+href="([^"]+)"/i) || [])[1]?.trim() || "#"; try { l = new URL(l, sourceUrl).href; } catch(e){} const d = b.match(/<(pubDate|updated|published)>(.*?)<\/(pubDate|updated|published)>/i); const rawDesc = (b.match(/<(description|summary)[^>]*>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/(description|summary)>/i) || [])[2] || ""; const rawCont = (b.match(/<content:encoded>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/content:encoded>/i) || b.match(/<content[^>]*>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/content>/i) || [])[1] || ""; let img = ""; const mediaRe = /<(?:media:content|media:thumbnail|enclosure)([^>]*)>/gi; let mm; while ((mm = mediaRe.exec(b)) !== null) { const attrs = mm[1]; const urlMatch = attrs.match(/url=["']([^"']+)["']/i); if (!urlMatch) continue; const candidate = urlMatch[1]; const typeMatch = attrs.match(/type=["']([^"']+)["']/i); if (typeMatch && !typeMatch[1].toLowerCase().startsWith("image")) continue; if (/\.(mp4|m4v|mov|webm|m3u8|avi|mkv|flv|mp3|m4a|wav|ogg)(?:\?.*)?$/i.test(candidate)) continue; if (/(?:youtube\.com|youtu\.be|vimeo\.com|dailymotion\.com)/i.test(candidate)) continue; img = candidate; break; } if (!img) { const inDesc = rawDesc.match(/<img[^>]+src=["']([^"']+)["']/i) || rawCont.match(/<img[^>]+src=["']([^"']+)["']/i); if (inDesc) img = inDesc[1]; } let authMatch = b.match(/<(dc:creator|author)[^>]*>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/(dc:creator|author)>/i); let author = ""; if (authMatch && authMatch[2]) { author = decodeEntities(authMatch[2].replace(/<[^>]*>/g,"").trim()); if (author.toLowerCase().includes("author") || author.toLowerCase().includes("dc:creator")) author = ""; } const isrc = b.match(/<source[^>]*>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/source>/i); let resolvedSource = decodeEntities(isrc ? isrc[1] : feedTitle).trim(); const lowerSource = resolvedSource.toLowerCase(); if (!resolvedSource || lowerSource === "source" || lowerSource === "unknown source") { resolvedSource = extractRootDomain(sourceUrl); } return { title: decodeEntities(rawT.replace(/<[^>]*>/g,"")), link: l, pubDate: d ? d[2] : "", dateObj: d ? new Date(d[2]) : new Date(), description: decodeEntities(rawDesc), content: decodeEntities(rawCont), imageUrl: img, author: author, sourceTitle: resolvedSource, sourceUrl: sourceUrl }; }); } function esc(s) { return String(s||"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'"); } function buildRss(title, items, url) { const itemXml = items.map(it => { const safeDesc = it.description.replace(/<!\[CDATA\[/gi, "").replace(/\]\]>/gi, ""); const mediaTag = it.imageUrl ? `<media:content url="${esc(it.imageUrl)}" medium="image"/>` : ""; let pubDateStr; try { pubDateStr = it.dateObj.toUTCString(); } catch(e) { pubDateStr = new Date().toUTCString(); } return `<item><title>${esc(it.title)}${esc(it.link)}${pubDateStr}${esc(it.link + it.title)}${esc(it.sourceTitle)}${mediaTag}`; }).join(""); return `${esc(title)}${esc(url)}FeedGate v7.8 Merged Feed${itemXml}`; } // ===================================================================== // PART 2: PAGES FUNCTIONS (Admin API) // // Before uploading to GitHub, you need to create these files on your // computer. Create a folder called "functions", then create the // subfolders and files inside it exactly as shown below. The folder // names and file names must be copied exactly -- Cloudflare Pages // uses them to set up your API routes automatically. // // Your folder structure should look like this: // index.html // functions/ // _middleware.js // api/ // config.js // cron-order.js // feeds/ // [[path]].js // // Once created, upload the index.html file AND the entire functions/ // folder to your GitHub repository (step 6 of the setup guide). // // Bindings required on the Pages project (Settings > Functions): // FEEDGATE_KV -- KV Namespace binding (same namespace as the Worker) // ADMIN_SECRET -- Secret (your admin UI login password) // WORKER_URL -- Environment Variable (your Worker's full URL) // ===================================================================== // --------------------------------------------------------------------- // FILE: functions/_middleware.js // Runs on every /api/ request. Checks the admin password header, // except /api/config which is public (needed to load the UI). // --------------------------------------------------------------------- export async function onRequest(context) { const { request, env } = context; const url = new URL(request.url); if (!url.pathname.startsWith("/api/") || url.pathname === "/api/config") { return context.next(); } const secret = request.headers.get("X-FeedGate-Secret"); if (secret !== env.ADMIN_SECRET) { return new Response("Unauthorized", { status: 401 }); } return context.next(); } // --------------------------------------------------------------------- // FILE: functions/api/config.js // Returns the Worker URL to the Admin UI so it can build feed links. // --------------------------------------------------------------------- export async function onRequest(context) { return new Response(JSON.stringify({ workerUrl: context.env.WORKER_URL || "" }), { headers: { "Content-Type": "application/json" } }); } // --------------------------------------------------------------------- // FILE: functions/api/feeds/[[path]].js // Handles all gate CRUD operations: // GET /api/feeds -- list all gates // GET /api/feeds/:slug -- get one gate // POST /api/feeds -- create gate // PUT /api/feeds/:slug -- update gate // DELETE /api/feeds/:slug -- delete gate // --------------------------------------------------------------------- export async function onRequest(context) { const { request, env } = context; const url = new URL(request.url); const segments = url.pathname.split('/').filter(Boolean); const slug = segments.length > 2 ? segments[2] : null; if (request.method === "GET") { if (slug) { const data = await env.FEEDGATE_KV.get("gate:" + slug); if (!data) return new Response("Not Found", { status: 404 }); return new Response(data, { headers: { "Content-Type": "application/json" } }); } const list = await env.FEEDGATE_KV.list({ prefix: "gate:" }); const feeds = []; for (const k of list.keys) { const val = await env.FEEDGATE_KV.get(k.name); try { const parsed = JSON.parse(val); feeds.push({ slug: k.name.replace("gate:", ""), title: parsed.proxy?.title || "Untitled", primarySource: parsed.sources?.[0]?.url || "None" }); } catch (e) { continue; } } return new Response(JSON.stringify(feeds), { headers: { "Content-Type": "application/json" } }); } if (request.method === "POST" || request.method === "PUT") { const body = await request.json(); const targetSlug = body.slug; if (!targetSlug) return new Response("Slug Required", { status: 400 }); if (request.method === "POST") { const exists = await env.FEEDGATE_KV.get("gate:" + targetSlug); if (exists) return new Response("Conflict", { status: 409 }); } await env.FEEDGATE_KV.put("gate:" + targetSlug, JSON.stringify(body.gate)); return new Response("OK"); } if (request.method === "DELETE" && slug) { await env.FEEDGATE_KV.delete("gate:" + slug); return new Response("Deleted"); } return new Response("Method Not Allowed", { status: 405 }); } // --------------------------------------------------------------------- // FILE: functions/api/cron-order.js // GET: returns the saved gate processing order. // PUT: saves a new order (array of slug strings). // --------------------------------------------------------------------- export async function onRequest(context) { const { request, env } = context; if (request.method === "GET") { const raw = await env.FEEDGATE_KV.get("cron_order"); const order = raw ? JSON.parse(raw) : []; return new Response(JSON.stringify({ order }), { headers: { "Content-Type": "application/json" } }); } if (request.method === "PUT") { const body = await request.json(); const order = Array.isArray(body.order) ? body.order.filter(s => typeof s === "string") : []; await env.FEEDGATE_KV.put("cron_order", JSON.stringify(order)); return new Response("OK"); } return new Response("Method Not Allowed", { status: 405 }); } // ===================================================================== // PART 3: ADMIN UI // Save as index.html in the root of your GitHub repository. // ===================================================================== FeedGate 3.3

FeedGate 3.3

Cron Processing Order

Controls which gates the scheduled cron run processes first. When the subrequest budget runs out mid-cycle, gates further down this list wait for the next run.

Loading order...
Loading feeds...