17/8/26 see feedpost_readme.txt for licence, setup instructions, and terms of use. // ===================================================================== // FEEDPOST v6.8 - Personal RSS-to-Social Publishing Engine // ===================================================================== // FeedPost Worker v6.8 // Changes in v6.8: // - Redacted session tokens from Bluesky auth-failure logging. // - Added strict array validation to /api/channels and /api/processes POST handlers. // - Added User-Agent header to scrapeOgImage to prevent basic blocking. // - Stripped trailing punctuation from Bluesky URL facet matches and hoisted TextEncoder. // - Added error logging to cleanupTempImages instead of swallowing silently. // - Simplified header/title length calculation in runProcess. // - Removed dead Mastodon branch from postBuffer. // - Base catch-all route now returns 404 instead of 200 (except for root). // - Added missing ' entity to decodeEntities. // - Merged redundant fallback image check blocks in handleRss. // - Added more common image formats to EXT_MIME mapping. // Changes in v6.7: // - Added detailed hashtag routing logs (unmatched tags, empty targets) and a silent no-op return if no channels are resolved. // - Instagram channels are now skipped automatically if there is no image (Instagram API requires an image). // - Added webhook idempotency using a 6-hour KV dedup lock to prevent duplicate webhook payloads from being processed multiple times. // - Enhanced extractRootDomain to properly parse subdomains on multi-tenant hosting platforms (e.g. github.io, vercel.app). // Changes in v6.6: // - Group 1: Added JSON validation to POST /api/processes and /api/channels. Added try/catch to JSON.parse(channels) in runProcess. Removed legacy 'workflows' fallback KV key everywhere for consistency. // - Group 2: Wrapped request.json() calls in /api/run-process and /webhook/ endpoints with try/catch, returning 400 on bad JSON. // - Group 3: Added 'content:encoded' to RSS parsing tags. Added r.ok check before parsing RSS text. scrapeOgImage now resolves relative URLs against the page URL. // - Group 4: Added 10000ms AbortSignal timeouts to handleRss feed fetching (both FeedGate and native, as FeedGate might block on slow external feeds). Added 5000ms timeout to fetchImageBytes. // - Group 5: Fixed restFixedLen math double-counting separators. Decoded HTML entities on constituent text parts before truncation math to ensure accurate lengths, removing the final combined decodeEntities call. // - Group 6: postMastodon now extracts proper image extension from contentType instead of hardcoding .jpg. // - Group 7: Webhook trigger checks proc.type === 'webhook'. Added prefix restriction (manual-temp/ and library/) to /image/ route. Added body validation to library DELETE handler. // - Group 8: Added KV-based TTL lock to scheduled() to prevent overlapping cron runs. // Changes in v6.3: // - RSS handling now falls back to scraping og:image from the article // page itself when the feed provides no per-item image (e.g. The West // Ham Way's feed carries no //inline ). // Only triggers when item.image is still empty after the normal feed // extraction, before falling back to the random summariser image. // Adds one extra subrequest per such item. // Changes in v6.2: // - Webhook trigger processes now respect postDesc (default OFF), matching // RSS behaviour, instead of always appending item.text to the Buffer // post. Needed because FeedSum now sends a distinct title (social, // with image credit baked in) and a separate longer body (blog text) // in the same payload — without this, Buffer posts would get // title+body squashed together by default. // Changes in v6.1: // - Webhook trigger path now proxies images through R2 (same as RSS), // with fallback to a random summariser-fallback image. // Changes in v6.0: // - Removed manual and sequence process types, along with all related // API endpoints, scheduled handlers, and UI support. // - Only RSS/Atom feeds and Webhook triggers remain. // - RSS handling unchanged: proxies images through R2, uses fallbacks, // writes error state on failure, only updates last‑seen on success. // - Webhook endpoints still accept POST requests to /webhook/. // - runProcess(), postBuffer(), cleanupTempImages(), and helpers unchanged. export default { async fetch(request, env) { const url = new URL(request.url), path = url.pathname, kv = env.KV; const respond = (data, status = 200, type = "application/json") => { const headers = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, X-Admin-Key", }; if (type === "application/json") headers["Content-Type"] = "application/json"; let body = (status === 204 || status === 304) ? null : (type === "application/json" ? JSON.stringify(data) : data); return new Response(body, { status, headers }); }; if (request.method === "OPTIONS") return respond(null, 204); if (path.startsWith("/image/")) { const key = decodeURIComponent(path.replace("/image/", "")); if (!key.startsWith("manual-temp/") && !key.startsWith("library/")) { return respond("Forbidden", 403, "text/plain"); } const object = await env.BUCKET.get(key); if (!object) return respond("Not Found", 404, "text/plain"); const h = new Headers(); object.writeHttpMetadata(h); h.set("Access-Control-Allow-Origin", "*"); return new Response(object.body, { headers: h }); } if (path.startsWith("/api/")) { const auth = request.headers.get("X-Admin-Key"); if (auth !== env.ADMIN_KEY) return respond({ error: "Unauthorized" }, 401); if (path === "/api/channels") { if (request.method === "GET") return respond(JSON.parse(await kv.get("channels") || "[]")); try { const text = await request.text(); const parsed = JSON.parse(text); if (!Array.isArray(parsed)) throw new Error("Body must be a JSON array"); await kv.put("channels", text); return respond({ success: true }); } catch (e) { return respond({ error: "Invalid JSON: " + e.message }, 400); } } if (path === "/api/processes") { if (request.method === "GET") { const data = await kv.get("processes") || "[]"; return respond(JSON.parse(data)); } try { const text = await request.text(); const parsed = JSON.parse(text); if (!Array.isArray(parsed)) throw new Error("Body must be a JSON array"); await kv.put("processes", text); return respond({ success: true }); } catch (e) { return respond({ error: "Invalid JSON: " + e.message }, 400); } } // ---- Shared image library (folder-scoped) ---- if (path === "/api/library") { const folder = url.searchParams.get("folder") || ""; if (request.method === "GET") { if (!folder) return respond({ error: "folder required" }, 400); const list = await env.BUCKET.list({ prefix: `library/${folder}/` }); const files = list.objects.map(o => ({ key: o.key, url: `https://${url.host}/image/${encodeURIComponent(o.key)}` })); return respond(files); } if (request.method === "POST") { const fd = await request.formData(), img = fd.get("image"), fol = fd.get("folder"); if (!img || img.size === 0) return respond({ error: "No image" }, 400); if (!fol) return respond({ error: "No folder" }, 400); const key = `library/${fol}/${Date.now()}_${img.name.replace(/[^a-zA-Z0-9.-]/g, '_')}`; await env.BUCKET.put(key, img, { httpMetadata: { contentType: img.type } }); return respond({ success: true }); } if (request.method === "DELETE") { let payload; try { payload = await request.json(); } catch(e) { return respond({ error: "Invalid JSON" }, 400); } const { key } = payload; if (typeof key !== "string" || !key.startsWith("library/")) return respond({ error: "Invalid key" }, 400); await env.BUCKET.delete(key); return respond({ success: true }); } } // Remaining endpoint: run any process (only RSS is supported now) if (path === "/api/run-process") { let payload; try { payload = await request.json(); } catch (e) { return respond({ error: "Invalid JSON" }, 400); } const { id } = payload; const proc = JSON.parse(await kv.get("processes") || "[]").find(p => p.id === id); if (!proc) return respond({ error: "Not found" }, 404); if (proc.type === "rss") return respond({ result: await handleRss(proc, env, url.host, null, true) }); // Webhook trigger processes are triggered externally, not via run-process return respond({ error: "Only RSS processes can be run manually" }, 400); } } if (path.startsWith("/webhook/")) { const proc = JSON.parse(await kv.get("processes") || "[]").find(p => p.id === path.split("/")[2]); if (!proc || proc.type !== "webhook" || url.searchParams.get("key") !== env.ADMIN_KEY) return respond("Error", 403, "text/plain"); let item; try { item = await request.json(); } catch (e) { return respond({ error: "Invalid JSON" }, 400); } const dedupKey = `state:webhook_seen:${proc.id}:${simpleHash((item.title || "") + "|" + (item.link || ""))}`; const alreadySeen = await kv.get(dedupKey); if (alreadySeen) { console.log(`[webhook] Duplicate payload for proc="${proc.name}" — skipped.`); return respond({ result: [], skipped: true, reason: "Duplicate webhook payload, already processed recently." }); } if (item.imageUrl && !item.image) item.image = item.imageUrl; if (item.image && proc.postImage !== false) { item.image = (await proxyImageToR2(item.image, env, url.host)) || (await getRandomFallbackImage(env, url.host, proc.folder)); } try { const result = await runProcess(proc, env, item, url.host); await kv.put(dedupKey, "1", { expirationTtl: 21600 }); return respond({ result }); } catch (e) { console.log(`[webhook] runProcess THREW for proc="${proc.name}": ${e.message}\n${e.stack}`); return respond({ error: e.message }, 500); } } if (path === "" || path === "/") { return respond("FeedPost v6.8 Active", 200, "text/plain"); } return respond("Not Found", 404, "text/plain"); }, async scheduled(event, env) { const lockKey = "state:lock:scheduled"; const locked = await env.KV.get(lockKey); if (locked) return; await env.KV.put(lockKey, "1", { expirationTtl: 60 }); try { const procs = JSON.parse(await env.KV.get("processes") || "[]"), host = await env.KV.get("settings:base_host") || "", hr = new Date().getUTCHours(); await cleanupTempImages(env); for (const p of procs) { if (p.type === "rss") await handleRss(p, env, host, hr); } } finally { await env.KV.delete(lockKey); } } }; // ---------- RSS handling ---------- async function handleRss(c, env, host, hr, force = false) { if (!force && hr !== null && hr % (parseInt(c.interval) || 1) !== 0) return; try { const fetchOpts = { signal: AbortSignal.timeout(10000) }; const r = await (env.FEEDGATE && c.source.includes("feedgate") ? env.FEEDGATE.fetch(new Request(c.source, fetchOpts)) : fetch(c.source, fetchOpts)); if (!r.ok) throw new Error(`Feed returned HTTP ${r.status}`); const raw = await r.text(); const item = raw.trim().startsWith('{') ? parseJsonFeed(raw, c.source) : parseXml(raw, c.source); if (!item || (!force && (await env.KV.get(`state:rss:last:${c.id}`)) === (item.link || item.title))) return; // Proxy feed image through R2 so Buffer receives a stable, accessible URL if (item.image && c.postImage !== false) { item.image = await proxyImageToR2(item.image, env, host); } // If feed had no image, try scraping og:image from the article page itself if (!item.image && c.postImage !== false && item.link) { console.log(`[og:image] No feed image for "${item.title}" — scraping ${item.link}`); const scraped = await scrapeOgImage(item.link); console.log(`[og:image] Scrape result: ${scraped || 'NONE FOUND'}`); if (scraped) item.image = await proxyImageToR2(scraped, env, host); } // If still no image, log and try a random library fallback if (!item.image && c.postImage !== false) { console.log(`[og:image] Falling back to random library image for "${item.title}"`); item.image = await getRandomFallbackImage(env, host, c.folder); } const res = await runProcess(c, env, item, host); const failed = res ? res.filter(r => !r.success).map(r => r.name) : []; const succeeded = res ? res.filter(r => r.success).map(r => r.name) : []; if (failed.length > 0) { await env.KV.put(`state:rss:error:${c.id}`, JSON.stringify({ timestamp: new Date().toISOString(), item: item.link || item.title, failed, succeeded })); } else { await env.KV.delete(`state:rss:error:${c.id}`); } // Only mark as seen if at least one channel succeeded if (succeeded.length > 0) { await env.KV.put(`state:rss:last:${c.id}`, item.link || item.title); } return res; } catch (e) { console.error(`[handleRss] ${c.name}: ${e.message}`); await env.KV.put(`state:rss:error:${c.id}`, JSON.stringify({ timestamp: new Date().toISOString(), error: e.message })); } } // Fetches an image URL and uploads it to R2 under manual-temp/. // Returns the R2-backed URL on success, or null on failure. const EXT_MIME = { jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp", svg: "image/svg+xml", avif: "image/avif", bmp: "image/bmp", ico: "image/x-icon" }; async function proxyImageToR2(imageUrl, env, host) { try { const isFeedWrite = env.FEEDWRITE && imageUrl.includes("feedwrite"); const res = isFeedWrite ? await env.FEEDWRITE.fetch(new Request(imageUrl, { signal: AbortSignal.timeout(5000) })) : await fetch(imageUrl, { signal: AbortSignal.timeout(5000) }); if (!res.ok) return null; let contentType = res.headers.get("content-type") || ""; if (!contentType.startsWith("image/")) { // Header missing/wrong — fall back to sniffing the URL's file extension const urlExt = (imageUrl.split(/[?#]/)[0].split(".").pop() || "").toLowerCase(); contentType = EXT_MIME[urlExt] || ""; } if (!contentType.startsWith("image/")) return null; const ext = contentType.split("/")[1].split(";")[0] || "jpg"; const key = `manual-temp/${Date.now()}.${ext}`; const buf = await res.arrayBuffer(); await env.BUCKET.put(key, buf, { httpMetadata: { contentType } }); return `https://${host}/image/${encodeURIComponent(key)}`; } catch (e) { return null; } } // Fetches an article page and extracts the og:image meta tag URL, if present. async function scrapeOgImage(pageUrl) { try { const res = await fetch(pageUrl, { signal: AbortSignal.timeout(5000), headers: { "User-Agent": "Mozilla/5.0 (compatible; FeedPostBot/1.0)" } }); if (!res.ok) return null; const html = await res.text(); const m = html.match(/]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i) || html.match(/]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i); if (!m) return null; try { return new URL(m[1], pageUrl).href; } catch(e) { return m[1]; } } catch (e) { return null; } } // Returns a random image URL from the given library folder, or null if none exist. async function getRandomFallbackImage(env, host, folder) { try { if (!folder) return null; const list = await env.BUCKET.list({ prefix: `library/${folder}/` }); if (!list.objects.length) return null; const obj = list.objects[Math.floor(Math.random() * list.objects.length)]; return `https://${host}/image/${encodeURIComponent(obj.key)}`; } catch (e) { return null; } } // ---------- Core posting ---------- function simpleHash(str) { let hash = 0; for (let i = 0; i < str.length; i++) { hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0; } return (hash >>> 0).toString(36); } function extractHashtagRouting(rawText) { if (!rawText) return { cleanText: rawText, tags: [] }; const lines = rawText.trim().split(/\n/); let idx = lines.length - 1; while (idx >= 0 && !lines[idx].trim()) idx--; if (idx < 0) return { cleanText: rawText, tags: [] }; const lastLineStripped = lines[idx].replace(/<[^>]+>/g, "").trim(); const tagMatches = lastLineStripped.match(/^\/\/\s*((?:#[\w-]+\s*)+)$/); if (!tagMatches) return { cleanText: rawText, tags: [] }; const tags = (tagMatches[1].match(/#[\w-]+/g) || []).map(t => t.slice(1).toLowerCase()); const cleanText = lines.slice(0, idx).concat(lines.slice(idx + 1)).join("\n").trim(); return { cleanText, tags }; } async function runProcess(c, env, item, host) { let chans = []; try { chans = JSON.parse(await env.KV.get("channels") || "[]"); } catch (e) { console.error("[runProcess] Error parsing channels from KV:", e.message); } const incT = c.postTitle !== false; const incL = c.postLink !== false; const incI = c.postImage !== false; console.log(`[hashtag-debug] raw item.text: ${JSON.stringify(item.text)}`); const { cleanText, tags: routeTags } = extractHashtagRouting(item.text || ""); console.log(`[hashtag-debug] routeTags: ${JSON.stringify(routeTags)}`); item = { ...item, text: cleanText }; const descText = decodeEntities(c.postDesc ? stripHtml(item.text || "") : ""); const creditText = decodeEntities((c.postSource && item.sourceName) ? `Source: ${item.sourceName}` : ""); const maxLen = c.maxLen || 280; const headerText = decodeEntities(c.header || ""); const titleText = incT ? decodeEntities(item.title||"") : ""; const linkText = incL ? decodeEntities(item.link||"") : ""; const footerText = decodeEntities(c.footer || ""); const headerTitleParts = [headerText, titleText].filter(Boolean); const headerTitleLen = headerTitleParts.join("\n\n").length; const restFixedParts = [linkText, footerText, creditText].filter(Boolean); const restFixedLen = restFixedParts.length ? restFixedParts.join(" ").length : 0; const separatorLen = (headerTitleParts.length > 0 && descText) || (restFixedParts.length && descText) ? 2 : 0; const availForDesc = Math.max(0, maxLen - headerTitleLen - restFixedLen - separatorLen); const trimmedDesc = descText.length > availForDesc ? (availForDesc > 1 ? descText.slice(0, availForDesc - 1).trim() + "…" : "") : descText; const bodyParts = [headerText, titleText].filter(Boolean).join("\n\n"); const restParts = [trimmedDesc, linkText, footerText, creditText].filter(Boolean).join(" "); const combined = [bodyParts, restParts].filter(Boolean).join("\n\n"); const cleaned = combined.replace(/[^\S\r\n]+/g, " ").trim(); const body = cleaned.length > maxLen ? cleaned.slice(0, maxLen - 1).trim() + "…" : cleaned; const res = []; let targetChans; if (routeTags.length) { const matchedChans = chans.filter(ch => ch.hashtag && routeTags.includes(ch.hashtag)); const matchedTags = new Set(matchedChans.map(ch => ch.hashtag)); const unmatchedTags = routeTags.filter(t => !matchedTags.has(t)); if (unmatchedTags.length) { console.error(`[hashtag-routing] Process "${c.name}": no channel found for hashtag(s): ${unmatchedTags.join(", ")}`); } targetChans = matchedChans; } else { targetChans = (c.destinations || []).map(id => chans.find(x => x.id === id)).filter(Boolean); } if (targetChans.length === 0) { if (routeTags.length) { console.error(`[hashtag-routing] Process "${c.name}": hashtags present (${routeTags.join(", ")}) but none matched a known channel. No post sent.`); } else { console.error(`[runProcess] Process "${c.name}": no destinations configured and no hashtags present. No post sent.`); } return res; } for (const ch of targetChans) { { const img = incI ? item.image : null; let ok; if (ch.platform === 'instagram' && !img) { console.log(`[runProcess] Skipping Instagram channel "${ch.name}" for process "${c.name}": no image available.`); ok = false; } else if (ch.platform === 'bluesky') ok = await postBluesky(body, img, ch, env, host); else if (ch.platform === 'mastodon') ok = await postMastodon(body, img, ch, env, host); else ok = await postBuffer(body, img, ch); res.push({ name: ch.name, success: ok }); } } return res; } async function fetchImageBytes(imgUrl, env, host) { const prefix = `https://${host}/image/`; if (imgUrl.startsWith(prefix)) { const key = decodeURIComponent(imgUrl.slice(prefix.length)); const object = await env.BUCKET.get(key); if (!object) return null; const contentType = object.httpMetadata?.contentType || "image/jpeg"; const buf = await object.arrayBuffer(); return { buf, contentType }; } const res = await fetch(imgUrl, { signal: AbortSignal.timeout(5000) }); if (!res.ok) return null; const contentType = res.headers.get("content-type") || "image/jpeg"; const buf = await res.arrayBuffer(); return { buf, contentType }; } async function postBluesky(text, img, ch, env, host) { try { const sessRes = await fetch("https://bsky.social/xrpc/com.atproto.server.createSession", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ identifier: ch.profileId, password: ch.token }) }); const sess = await sessRes.json(); if (!sess.accessJwt) { console.log(`postBluesky auth failed for [${ch.name}]: ${sess.error || 'unknown error'} - ${sess.message || ''}`); return false; } let embed = null; if (img) { const fetched = await fetchImageBytes(img, env, host); if (fetched) { const { buf, contentType } = fetched; const blobRes = await fetch("https://bsky.social/xrpc/com.atproto.repo.uploadBlob", { method: "POST", headers: { "Authorization": `Bearer ${sess.accessJwt}`, "Content-Type": contentType }, body: buf }); const blobData = await blobRes.json(); if (blobData.blob) embed = { $type: "app.bsky.embed.images", images: [{ image: blobData.blob, alt: "" }] }; } } const finalText = text.slice(0, 300); const facets = []; const urlRe = /https?:\/\/[^\s]+/g; let match; const encoder = new TextEncoder(); while ((match = urlRe.exec(finalText)) !== null) { let url = match[0]; const trailingPunct = /[.,!?;:)]+$/; const trimMatch = url.match(trailingPunct); if (trimMatch) url = url.slice(0, url.length - trimMatch[0].length); if (!url) continue; const byteStart = encoder.encode(finalText.slice(0, match.index)).length; const byteEnd = byteStart + encoder.encode(url).length; facets.push({ index: { byteStart, byteEnd }, features: [{ $type: "app.bsky.richtext.facet#link", uri: url }] }); } const record = { $type: "app.bsky.feed.post", text: finalText, createdAt: new Date().toISOString() }; if (facets.length) record.facets = facets; if (embed) record.embed = embed; const postRes = await fetch("https://bsky.social/xrpc/com.atproto.repo.createRecord", { method: "POST", headers: { "Authorization": `Bearer ${sess.accessJwt}`, "Content-Type": "application/json" }, body: JSON.stringify({ repo: sess.did, collection: "app.bsky.feed.post", record }) }); const postData = await postRes.json(); const ok = !!postData.uri; console.log(`postBluesky [${ch.name}]: success=${ok}${ok ? '' : ` error="${JSON.stringify(postData)}"`}`); return ok; } catch (e) { console.log(`postBluesky [${ch.name}] THREW: ${e.message}`); return false; } } async function postMastodon(text, img, ch, env, host) { try { const instance = ch.profileId.replace(/^https?:\/\//, "").replace(/\/$/, ""); let mediaId = null; if (img) { const fetched = await fetchImageBytes(img, env, host); if (fetched) { const blob = new Blob([fetched.buf], { type: fetched.contentType }); const fd = new FormData(); const ext = fetched.contentType.split("/")[1]?.split(";")[0] || "jpg"; fd.append("file", blob, `image.${ext}`); const mediaRes = await fetch(`https://${instance}/api/v1/media`, { method: "POST", headers: { "Authorization": `Bearer ${ch.token}` }, body: fd }); const mediaData = await mediaRes.json(); mediaId = mediaData.id || null; } } const body = { status: text.slice(0, 500) }; if (mediaId) body.media_ids = [mediaId]; const r = await fetch(`https://${instance}/api/v1/statuses`, { method: "POST", headers: { "Authorization": `Bearer ${ch.token}`, "Content-Type": "application/json" }, body: JSON.stringify(body) }); const d = await r.json(); const ok = !!d.id; console.log(`postMastodon [${ch.name}]: success=${ok}${ok ? '' : ` error="${JSON.stringify(d)}"`}`); return ok; } catch (e) { console.log(`postMastodon [${ch.name}] THREW: ${e.message}`); return false; } } async function postBuffer(text, img, ch) { const input = { channelId: ch.profileId, text, schedulingType: "automatic", mode: "shareNow" }; if (ch.platform === "facebook") input.metadata = { facebook: { type: "post" } }; if (ch.platform === "instagram") input.metadata = { instagram: { type: "post", shouldShareToFeed: true } }; if (img) input.assets = [{ image: { url: img } }]; try { const r = await fetch("https://api.buffer.com/graphql", { method: "POST", headers: { "Authorization": `Bearer ${ch.token}`, "Content-Type": "application/json" }, body: JSON.stringify({ query: `mutation CreatePost($input: CreatePostInput!) { createPost(input: $input) { ... on PostActionSuccess { post { id } } ... on MutationError { message } } }`, variables: { input } }) }); const d = await r.json(); const ok = !!d.data?.createPost?.post?.id; const errMsg = d.data?.createPost?.message || d.errors?.[0]?.message; console.log(`postBuffer [${ch.name}/${ch.platform}]: hasImage=${!!img} success=${ok}${errMsg ? ` error="${errMsg}"` : ''}`); return ok; } catch (e) { console.log(`postBuffer [${ch.name}/${ch.platform}] THREW: ${e.message}`); return false; } } async function cleanupTempImages(env) { try { const list = await env.BUCKET.list({ prefix: "manual-temp/" }), cutoff = Date.now() - 86400000; for (const o of list.objects) { const ts = parseInt(o.key.split("/")[1].split(".")[0]); if (!isNaN(ts) && ts < cutoff) await env.BUCKET.delete(o.key); } } catch (e) { console.error(`[cleanupTempImages] Error: ${e.message}`); } } // ---------- XML parsing helpers ---------- function parseXml(xml, sourceUrl) { const m = xml.match(/<(item|entry)>([\s\S]*?)<\/\1>/i); if (!m) return null; const b = m[2], get = (ts) => { for (const t of ts) { const r = new RegExp("<"+t.replace(":","\\:")+"[^>]*>([\\s\\S]*?)<\\/"+t.replace(":","\\:")+">","i"); const v = b.match(r); if (v) return decodeEntities(v[1].replace(//g, "$1").trim()); } return ""; }; const d = get(["content:encoded","description","summary","content"]), i = b.match(/]+url="([^"]+)"/i) || b.match(/]+url="([^"]+)"/i) || d.match(/src="([^"]+)"/i); const isrc = b.match(/]*>(?:)?<\/source>/i); let sourceName = isrc ? decodeEntities(isrc[1].replace(/<[^>]*>/g,"").trim()) : ""; const lowerSrc = sourceName.toLowerCase(); if (!sourceName || lowerSrc === "source" || lowerSrc === "unknown source") { sourceName = extractRootDomain(sourceUrl); } return { title: get(["title"]), text: d, link: (b.match(/]+href=["']([^"']+)["']/i) || b.match(/([^<]+)<\/link>/i) || ["",""])[1], image: i ? i[1] : null, sourceName }; } const MULTI_TENANT_HOST_SUFFIXES = ["github.io", "herokuapp.com", "jsdelivr.net", "netlify.app", "vercel.app", "pages.dev", "workers.dev"]; function extractRootDomain(url) { try { const hostname = new URL(url).hostname.toLowerCase(); const parts = hostname.split('.').filter(p => p !== 'www'); const lastTwo = parts.slice(-2).join('.'); if (MULTI_TENANT_HOST_SUFFIXES.includes(lastTwo)) { return parts.length >= 3 ? parts.slice(-3).join('.') : lastTwo; } 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 stripHtml(s) { return s.replace(/<[^>]+>/g, " ").replace(/[^\S\r\n]+/g, " ").trim(); } // Parses JSON Feed (https://www.jsonfeed.org/) — takes the first item. function parseJsonFeed(raw, sourceUrl) { try { const feed = JSON.parse(raw); const item = (feed.items || [])[0]; if (!item) return null; const html = item.content_html || item.summary || item.content_text || ""; const text = stripHtml(html); let image = item.image || item.banner_image || ""; if (!image) { const m = html.match(/]+src=["']([^"']+)["']/i); if (m) image = m[1]; } let sourceName = feed.title || ""; const lowerSrc = sourceName.toLowerCase(); if (!sourceName || lowerSrc === "source" || lowerSrc === "unknown source") { sourceName = extractRootDomain(sourceUrl); } return { title: item.title || "", text, link: item.url || item.id || "", image: image || null, sourceName }; } catch (e) { return null; } } function decodeEntities(s) { if(!s) return ""; return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/'/g, "'").replace(/ /g, " ").replace(/&#(\d+);/g, (_, n) => String.fromCharCode(n)).replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCharCode(parseInt(n, 16))); } // ===================================================================== // PART 2: ADMIN UI // ===================================================================== FeedPost Admin v6.8

FeedPost Admin 6.8

0. Connection