|
| 1 | +// Default RSS subscriptions (from the user's OPML). Overridable via OPML import |
| 2 | +// in Settings (stored in chrome.storage.local under "feeds"). |
| 3 | +export const DEFAULT_FEEDS = [ |
| 4 | + { category: 'Food', title: 'Taste of Home', xmlUrl: 'https://www.tasteofhome.com/feed/', htmlUrl: 'https://www.tasteofhome.com/' }, |
| 5 | + { category: 'Profullstack, Inc.', title: 'BitTorrented Blog', xmlUrl: 'https://bittorrented.com/blog/rss.xml', htmlUrl: 'https://bittorrented.com/blog' }, |
| 6 | + { category: 'Profullstack, Inc.', title: 'bl0ggers Blog', xmlUrl: 'https://bl0ggers.com/blog/rss.xml', htmlUrl: 'https://bl0ggers.com/blog' }, |
| 7 | + { category: 'Profullstack, Inc.', title: 'c0mpute blog', xmlUrl: 'https://c0mpute.com/blog/rss.xml', htmlUrl: 'https://c0mpute.com/blog' }, |
| 8 | + { category: 'Profullstack, Inc.', title: 'c0upons Blog', xmlUrl: 'https://c0upons.com/blog/rss.xml', htmlUrl: 'https://c0upons.com/blog' }, |
| 9 | + { category: 'Profullstack, Inc.', title: 'CoinPay Blog', xmlUrl: 'https://coinpayportal.com/blog/rss.xml', htmlUrl: 'https://coinpayportal.com/blog' }, |
| 10 | + { category: 'Profullstack, Inc.', title: 'CrawlProof blog', xmlUrl: 'https://crawlproof.com/blog/rss.xml', htmlUrl: 'https://crawlproof.com/blog' }, |
| 11 | + { category: 'Profullstack, Inc.', title: 'd0rz blog', xmlUrl: 'https://d0rz.com/blog/rss.xml', htmlUrl: 'https://d0rz.com/blog' }, |
| 12 | + { category: 'Profullstack, Inc.', title: 'PairUX Blog', xmlUrl: 'https://pairux.com/blog/rss.xml', htmlUrl: 'https://pairux.com/blog' }, |
| 13 | + { category: 'Profullstack, Inc.', title: 'QryptChat Blog', xmlUrl: 'https://qrypt.chat/blog/rss.xml', htmlUrl: 'https://qrypt.chat/blog' }, |
| 14 | + { category: 'Profullstack, Inc.', title: 'SaaSRow Blog', xmlUrl: 'https://saasrow.com/blog/rss.xml', htmlUrl: 'https://saasrow.com/blog' }, |
| 15 | + { category: 'Profullstack, Inc.', title: 'sh1pt Blog', xmlUrl: 'https://sh1pt.com/blog/rss.xml', htmlUrl: 'https://sh1pt.com/blog' }, |
| 16 | + { category: 'Profullstack, Inc.', title: 'ThreatCrush Blog', xmlUrl: 'https://threatcrush.com/blog/rss.xml', htmlUrl: 'https://threatcrush.com/blog' }, |
| 17 | + { category: 'Profullstack, Inc.', title: 'ugig blog', xmlUrl: 'https://ugig.net/blog/rss.xml', htmlUrl: 'https://ugig.net/blog' }, |
| 18 | + { category: 'Profullstack, Inc.', title: 'vu1nz Blog', xmlUrl: 'https://vu1nz.com/blog/rss.xml', htmlUrl: 'https://vu1nz.com/blog' }, |
| 19 | + { category: 'Projects', title: 'MLT', xmlUrl: 'https://www.mltframework.org/feed.xml', htmlUrl: 'https://mltframework.org/' }, |
| 20 | +]; |
| 21 | + |
| 22 | +/** Parse an OPML string into the feeds array. */ |
| 23 | +export function parseOpml(xml) { |
| 24 | + const doc = new DOMParser().parseFromString(xml, 'text/xml'); |
| 25 | + const out = []; |
| 26 | + for (const node of doc.querySelectorAll('outline[xmlUrl]')) { |
| 27 | + const parent = node.parentElement; |
| 28 | + const category = parent && parent.tagName === 'outline' |
| 29 | + ? parent.getAttribute('text') || parent.getAttribute('title') || 'Feeds' |
| 30 | + : 'Feeds'; |
| 31 | + out.push({ |
| 32 | + category, |
| 33 | + title: node.getAttribute('text') || node.getAttribute('title') || node.getAttribute('xmlUrl'), |
| 34 | + xmlUrl: node.getAttribute('xmlUrl'), |
| 35 | + htmlUrl: node.getAttribute('htmlUrl') || node.getAttribute('xmlUrl'), |
| 36 | + }); |
| 37 | + } |
| 38 | + return out; |
| 39 | +} |
| 40 | + |
| 41 | +/** Parse an RSS/Atom feed document into {title, link, date, image} items. */ |
| 42 | +export function parseFeed(xml) { |
| 43 | + const doc = new DOMParser().parseFromString(xml, 'text/xml'); |
| 44 | + const items = []; |
| 45 | + // RSS |
| 46 | + for (const it of doc.querySelectorAll('item')) { |
| 47 | + items.push({ |
| 48 | + title: text(it, 'title'), |
| 49 | + link: text(it, 'link'), |
| 50 | + date: text(it, 'pubDate') || text(it, 'date'), |
| 51 | + image: image(it), |
| 52 | + }); |
| 53 | + } |
| 54 | + // Atom |
| 55 | + if (items.length === 0) { |
| 56 | + for (const e of doc.querySelectorAll('entry')) { |
| 57 | + const link = e.querySelector('link'); |
| 58 | + items.push({ |
| 59 | + title: text(e, 'title'), |
| 60 | + link: link ? link.getAttribute('href') : '', |
| 61 | + date: text(e, 'updated') || text(e, 'published'), |
| 62 | + image: image(e), |
| 63 | + }); |
| 64 | + } |
| 65 | + } |
| 66 | + return items; |
| 67 | +} |
| 68 | + |
| 69 | +function text(parent, tag) { |
| 70 | + const el = parent.querySelector(tag); |
| 71 | + return el ? el.textContent.trim() : ''; |
| 72 | +} |
| 73 | + |
| 74 | +const IMG_RE = /\.(jpe?g|png|gif|webp|avif)(\?|#|$)/i; |
| 75 | +function okUrl(u) { return /^https?:\/\//i.test(u || '') ? u : ''; } |
| 76 | + |
| 77 | +// Best-effort thumbnail for a feed item: Media RSS, image enclosure, podcast art, |
| 78 | +// or the first <img> embedded in the content. Returns '' when none is available. |
| 79 | +function image(it) { |
| 80 | + const tag = (n) => it.getElementsByTagName(n)[0]; |
| 81 | + // Media RSS: <media:thumbnail url> / <media:content url medium="image"> |
| 82 | + const mt = tag('media:thumbnail'); |
| 83 | + if (mt && okUrl(mt.getAttribute('url'))) return mt.getAttribute('url'); |
| 84 | + for (const mc of it.getElementsByTagName('media:content')) { |
| 85 | + const url = mc.getAttribute('url'); |
| 86 | + const medium = mc.getAttribute('medium') || mc.getAttribute('type') || ''; |
| 87 | + if (okUrl(url) && (/image/i.test(medium) || IMG_RE.test(url))) return url; |
| 88 | + } |
| 89 | + // <enclosure type="image/..."> or an image-looking url |
| 90 | + for (const enc of it.getElementsByTagName('enclosure')) { |
| 91 | + const url = enc.getAttribute('url'); |
| 92 | + const type = enc.getAttribute('type') || ''; |
| 93 | + if (okUrl(url) && (/^image\//i.test(type) || IMG_RE.test(url))) return url; |
| 94 | + } |
| 95 | + // Podcast/iTunes artwork |
| 96 | + const ii = tag('itunes:image'); |
| 97 | + if (ii && okUrl(ii.getAttribute('href'))) return ii.getAttribute('href'); |
| 98 | + // First <img src> inside content:encoded or description HTML |
| 99 | + const html = (tag('content:encoded')?.textContent) || text(it, 'description') || ''; |
| 100 | + const m = html.match(/<img[^>]+src=["']([^"']+)["']/i); |
| 101 | + if (m && okUrl(m[1])) return m[1]; |
| 102 | + return ''; |
| 103 | +} |
| 104 | + |
| 105 | +/** Serialize feeds back to OPML (grouped by category). */ |
| 106 | +export function toOpml(feeds) { |
| 107 | + const byCat = {}; |
| 108 | + for (const f of feeds) (byCat[f.category || 'Feeds'] ||= []).push(f); |
| 109 | + const esc = (s) => String(s || '').replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<'); |
| 110 | + let body = ''; |
| 111 | + for (const [cat, items] of Object.entries(byCat)) { |
| 112 | + body += ` <outline text="${esc(cat)}" title="${esc(cat)}">\n`; |
| 113 | + for (const f of items) { |
| 114 | + body += ` <outline text="${esc(f.title)}" title="${esc(f.title)}" type="rss" xmlUrl="${esc(f.xmlUrl)}" htmlUrl="${esc(f.htmlUrl)}"/>\n`; |
| 115 | + } |
| 116 | + body += ' </outline>\n'; |
| 117 | + } |
| 118 | + return `<?xml version="1.0" encoding="UTF-8"?> |
| 119 | +<opml version="2.0"> |
| 120 | + <head> |
| 121 | + <title>TronBrowser RSS Subscriptions</title> |
| 122 | + <dateCreated>${new Date().toUTCString()}</dateCreated> |
| 123 | + </head> |
| 124 | + <body> |
| 125 | +${body} </body> |
| 126 | +</opml> |
| 127 | +`; |
| 128 | +} |
| 129 | + |
| 130 | +/** Load feeds from storage, falling back to the OPML defaults. */ |
| 131 | +export async function loadFeeds() { |
| 132 | + const { feeds } = await chrome.storage.local.get('feeds'); |
| 133 | + return Array.isArray(feeds) && feeds.length ? feeds : DEFAULT_FEEDS; |
| 134 | +} |
| 135 | + |
| 136 | +export async function saveFeeds(feeds) { |
| 137 | + await chrome.storage.local.set({ feeds }); |
| 138 | +} |
0 commit comments