import { CliError } from '@jackwener/opencli/errors'; export const BLOOMBERG_FEEDS = { main: 'https://feeds.bloomberg.com/news.rss', markets: 'https://feeds.bloomberg.com/markets/news.rss', economics: 'https://feeds.bloomberg.com/economics/news.rss', industries: 'https://feeds.bloomberg.com/industries/news.rss', tech: 'https://feeds.bloomberg.com/technology/news.rss', politics: 'https://feeds.bloomberg.com/politics/news.rss', opinions: 'https://feeds.bloomberg.com/bview/news.rss', green: 'https://feeds.bloomberg.com/green/news.rss', crypto: 'https://feeds.bloomberg.com/crypto/news.rss', pursuits: 'https://feeds.bloomberg.com/pursuits/news.rss', }; // Note: the Businessweek RSS feed (feeds.bloomberg.com/businessweek/news.rss) is now served // empty by Bloomberg, so the `businessweek` command reads the section page instead (see // businessweek.js). Other sections still publish working RSS feeds. const DEFAULT_USER_AGENT = 'Mozilla/5.0 (compatible; opencli)'; // Bloomberg's edge occasionally serves a transient empty/non-OK RSS response under load; a // couple of quick retries turn those intermittent misses into a successful fetch instead of a // hard NOT_FOUND. A feed that is genuinely empty still surfaces NOT_FOUND after the retries. export async function fetchBloombergFeed(name, limit = 1) { const feedUrl = BLOOMBERG_FEEDS[name]; if (!feedUrl) { throw new CliError('ARGUMENT', `Unknown Bloomberg feed: ${name}`); } let lastError; for (let attempt = 0; attempt < 3; attempt += 1) { if (attempt > 0) { await new Promise((resolve) => setTimeout(resolve, 400 * attempt)); } const resp = await fetch(feedUrl, { headers: { 'User-Agent': DEFAULT_USER_AGENT }, }); if (!resp.ok) { lastError = new CliError('FETCH_ERROR', `Bloomberg RSS HTTP ${resp.status}`, 'Bloomberg may be temporarily unavailable; try again later.'); continue; } const xml = await resp.text(); const items = parseBloombergRss(xml); if (items.length) { const count = Math.max(1, Math.min(Number(limit) || 1, 20)); return items.slice(0, count); } lastError = new CliError('NOT_FOUND', 'Bloomberg RSS feed returned no items', 'Bloomberg may have changed the feed format.'); } throw lastError; } export function parseBloombergRss(xml) { const items = []; const itemRegex = /]*>([\s\S]*?)<\/item>/gi; let match; while ((match = itemRegex.exec(xml))) { const block = match[1]; const title = extractTagText(block, 'title'); const summary = extractTagText(block, 'description'); const link = extractTagText(block, 'link') || extractTagText(block, 'guid'); const mediaLinks = extractMediaLinksFromRssItem(block); if (!title || !link) continue; items.push({ title, summary, link, mediaLinks, }); } return items; } export function normalizeBloombergLink(input) { const raw = String(input || '').trim(); if (!raw) { throw new CliError('ARGUMENT', 'A Bloomberg link is required'); } if (raw.startsWith('/')) return `https://www.bloomberg.com${raw}`; return raw; } export function validateBloombergLink(input) { const normalized = normalizeBloombergLink(input); let url; try { url = new URL(normalized); } catch { throw new CliError('ARGUMENT', `Invalid Bloomberg link: ${input}`, 'Pass a full https://www.bloomberg.com/... URL or a relative Bloomberg path.'); } if (!/(?:\.|^)bloomberg\.com$/i.test(url.hostname)) { throw new CliError('ARGUMENT', `Expected a bloomberg.com link, got: ${url.hostname}`, 'Pass a Bloomberg article URL from bloomberg.com.'); } return url.toString(); } export function renderStoryBody(body) { const blocks = Array.isArray(body?.content) ? body.content : []; const parts = blocks .map((block) => renderBlock(block, 0)) .map((part) => normalizeBlockText(part)) .filter(Boolean); return parts.join('\n\n').replace(/\n{3,}/g, '\n\n').trim(); } export function extractStoryMediaLinks(story) { const urls = new Set(); collectMediaUrls(story?.ledeImageUrl, urls); collectMediaUrls(story?.socialImageUrl, urls); collectMediaUrls(story?.lede, urls); collectMediaUrls(story?.imageAttachments, urls); collectMediaUrls(story?.videoAttachments, urls); const mediaBlocks = Array.isArray(story?.body?.content) ? story.body.content.filter((block) => block?.type === 'media') : []; collectMediaUrls(mediaBlocks, urls); return [...urls]; } function renderBlock(block, depth) { if (!block || typeof block !== 'object') return ''; switch (block.type) { case 'paragraph': return renderInlineNodes(block.content || []); case 'heading': { const text = renderInlineNodes(block.content || []); if (!text) return ''; const level = Number(block.data?.level ?? block.data?.weight ?? 2); const prefix = level <= 1 ? '# ' : level === 2 ? '## ' : '### '; return `${prefix}${text}`; } case 'blockquote': { const text = renderInlineNodes(block.content || []); if (!text) return ''; return text.split('\n').map((line) => line ? `> ${line}` : '>').join('\n'); } case 'list': return renderListBlock(block, depth); case 'tabularData': return renderTabularDataBlock(block); case 'media': return renderMediaBlock(block); case 'inline-newsletter': case 'newsletter': case 'ad': return ''; default: { if (Array.isArray(block.content) && block.content.length > 0) { const inlineText = renderInlineNodes(block.content); if (inlineText) return inlineText; const nested = block.content.map((child) => renderBlock(child, depth + 1)).filter(Boolean); if (nested.length) return nested.join('\n'); } return extractGenericText(block); } } } function renderInlineNodes(nodes) { return nodes.map((node) => renderInlineNode(node)).join(''); } function renderInlineNode(node) { if (node == null) return ''; if (typeof node === 'string') return decodeXmlEntities(node); switch (node.type) { case 'text': return decodeXmlEntities(node.value || ''); case 'linebreak': return '\n'; case 'link': case 'entity': case 'strong': case 'emphasis': case 'italic': case 'underline': case 'span': if (Array.isArray(node.content) && node.content.length > 0) { return renderInlineNodes(node.content); } return decodeXmlEntities(node.value || ''); default: if (Array.isArray(node.content) && node.content.length > 0) { return renderInlineNodes(node.content); } if (typeof node.value === 'string') return decodeXmlEntities(node.value); return ''; } } function renderListBlock(block, depth) { const items = Array.isArray(block.content) ? block.content : []; if (!items.length) return ''; const listStyle = String(block.subType || block.data?.style || block.data?.listType || ''); const ordered = /\bordered\b|\bnumber(?:ed)?\b/i.test(listStyle); let index = 1; return items .map((item) => { const prefix = ordered ? `${index++}. ` : '- '; return renderListItem(item, prefix, depth); }) .filter(Boolean) .join('\n'); } function renderListItem(item, prefix, depth) { const indent = ' '.repeat(depth); const body = normalizeBlockText(renderListItemBody(item, depth + 1)); if (!body) return ''; const lines = body.split('\n'); const head = `${indent}${prefix}${lines[0]}`; if (lines.length === 1) return head; const continuationIndent = `${indent}${' '.repeat(prefix.length)}`; const tail = lines.slice(1).map((line) => `${continuationIndent}${line}`).join('\n'); return `${head}\n${tail}`; } function renderListItemBody(item, depth) { if (!item || typeof item !== 'object') return ''; if (item.type === 'list-item' && Array.isArray(item.content)) { const parts = item.content .map((child) => child?.type === 'paragraph' ? renderInlineNodes(child.content || []) : renderBlock(child, depth)) .map((part) => normalizeBlockText(part)) .filter(Boolean); return parts.join('\n'); } return renderBlock(item, depth); } function renderTabularDataBlock(block) { const rows = block?.data?.rows ?? block?.data?.table?.rows ?? block?.content; if (!Array.isArray(rows) || !rows.length) { return extractGenericText(block.data || block.content || block); } const lines = rows .map((row) => extractGenericText(row)) .map((line) => normalizeBlockText(line)) .filter(Boolean); return lines.join('\n'); } function renderMediaBlock(block) { const candidates = [ block?.data?.chart?.caption, block?.data?.attachment?.caption, block?.data?.attachment?.title, block?.data?.attachment?.subtitle, block?.data?.video?.caption, ]; const caption = candidates .map((value) => normalizeBlockText(stripHtml(String(value || '')))) .find(Boolean); return caption || ''; } function extractGenericText(value) { const parts = []; collectText(value, parts); return parts.join(' ').replace(/\s+/g, ' ').trim(); } function collectText(value, out) { if (value == null) return; if (typeof value === 'string') { const text = normalizeBlockText(stripHtml(decodeXmlEntities(value))); if (text) out.push(text); return; } if (Array.isArray(value)) { for (const item of value) collectText(item, out); return; } if (typeof value === 'object') { if (typeof value.value === 'string') { const text = normalizeBlockText(stripHtml(decodeXmlEntities(value.value))); if (text) out.push(text); return; } if (Array.isArray(value.content)) { collectText(value.content, out); return; } for (const entry of Object.values(value)) collectText(entry, out); } } function extractTagText(block, tag) { const safeTag = escapeRegExp(tag); const match = block.match(new RegExp(`<${safeTag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${safeTag}>`, 'i')); if (!match) return ''; return normalizeBlockText(stripHtml(decodeXmlEntities(stripCdata(match[1])))); } function extractMediaLinksFromRssItem(block) { const links = new Set(); const mediaRegex = /<(?:media:content|media:thumbnail|enclosure)\b[^>]*\burl="([^"]+)"[^>]*>/gi; let match; while ((match = mediaRegex.exec(block))) { const url = decodeXmlEntities(match[1] || '').trim(); if (url) links.add(url); } return [...links]; } function collectMediaUrls(value, out, seen = new WeakSet()) { if (value == null) return; if (typeof value === 'string') { const normalized = normalizeMediaUrl(value); if (normalized) out.add(normalized); return; } if (Array.isArray(value)) { for (const item of value) collectMediaUrls(item, out, seen); return; } if (typeof value === 'object') { if (seen.has(value)) return; seen.add(value); for (const key of ['url', 'src', 'fallback', 'poster']) { const candidate = value[key]; if (typeof candidate === 'string') { const normalized = normalizeMediaUrl(candidate); if (normalized) out.add(normalized); } } for (const entry of Object.values(value)) { collectMediaUrls(entry, out, seen); } } } function normalizeMediaUrl(value) { const url = decodeXmlEntities(String(value || '')).trim(); if (!/^https?:\/\//i.test(url)) return null; if (!looksLikeMediaUrl(url)) return null; return url; } function looksLikeMediaUrl(url) { return /(?:assets\.bwbx\.io|resource\.bloomberg\.com|media\.bloomberg\.com)/i.test(url) || /\.(?:jpg|jpeg|png|webp|gif|svg|mp4|m3u8)(?:[?#].*)?$/i.test(url); } function stripCdata(value) { const match = value.match(/^$/); return match ? match[1] : value; } function stripHtml(value) { return String(value || '').replace(/<[^>]+>/g, ' '); } function decodeXmlEntities(value) { return String(value || '') .replace(//g, '$1') .replace(/&#(\d+);/g, (_m, code) => String.fromCodePoint(Number(code))) .replace(/&#x([0-9a-f]+);/gi, (_m, code) => String.fromCodePoint(parseInt(code, 16))) .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/'/g, "'") .replace(/ /g, ' '); } function normalizeBlockText(value) { return String(value || '') .replace(/\r/g, '') .replace(/[ \t]+\n/g, '\n') .replace(/\n[ \t]+/g, '\n') .replace(/[ \t]{2,}/g, ' ') .trim(); } function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }