| |
| const fs = require('fs'); |
| const path = require('path'); |
| const dns = require('node:dns').promises; |
| const net = require('node:net'); |
| const Parser = require('rss-parser'); |
|
|
| const { loadConfig } = require('../src/generator.js'); |
| const { createLogger, isVerbose, startTimer } = require('../src/generator/utils/logger'); |
|
|
| const log = createLogger('sync:articles'); |
|
|
| const DEFAULT_RSS_SETTINGS = { |
| enabled: true, |
| cacheDir: 'dev', |
| fetch: { |
| timeoutMs: 10_000, |
| maxRetries: 1, |
| concurrency: 5, |
| totalTimeoutMs: 60_000, |
| maxRedirects: 3, |
| userAgent: 'MeNavRSSSync/1.0', |
| htmlMaxBytes: 512 * 1024, |
| feedMaxBytes: 1024 * 1024, |
| }, |
| articles: { |
| perSite: 8, |
| total: 50, |
| summaryMaxLength: 200, |
| }, |
| }; |
|
|
| function parseBooleanEnv(value, fallback) { |
| if (value === undefined || value === null || value === '') return fallback; |
| const v = String(value).trim().toLowerCase(); |
| if (v === '1' || v === 'true' || v === 'yes' || v === 'y') return true; |
| if (v === '0' || v === 'false' || v === 'no' || v === 'n') return false; |
| return fallback; |
| } |
|
|
| function parseIntegerEnv(value, fallback) { |
| if (value === undefined || value === null || value === '') return fallback; |
| const n = Number.parseInt(String(value), 10); |
| return Number.isFinite(n) ? n : fallback; |
| } |
|
|
| function getRssSettings(config) { |
| const fromConfig = |
| config && config.site && config.site.rss && typeof config.site.rss === 'object' |
| ? config.site.rss |
| : {}; |
|
|
| const merged = { |
| ...DEFAULT_RSS_SETTINGS, |
| ...fromConfig, |
| fetch: { |
| ...DEFAULT_RSS_SETTINGS.fetch, |
| ...(fromConfig.fetch || {}), |
| }, |
| articles: { |
| ...DEFAULT_RSS_SETTINGS.articles, |
| ...(fromConfig.articles || {}), |
| }, |
| }; |
|
|
| |
| merged.enabled = parseBooleanEnv(process.env.RSS_ENABLED, merged.enabled); |
| merged.cacheDir = process.env.RSS_CACHE_DIR ? String(process.env.RSS_CACHE_DIR) : merged.cacheDir; |
|
|
| merged.fetch.timeoutMs = parseIntegerEnv(process.env.RSS_FETCH_TIMEOUT, merged.fetch.timeoutMs); |
| merged.fetch.maxRetries = parseIntegerEnv( |
| process.env.RSS_FETCH_MAX_RETRIES, |
| merged.fetch.maxRetries |
| ); |
| merged.fetch.concurrency = parseIntegerEnv( |
| process.env.RSS_FETCH_CONCURRENCY, |
| merged.fetch.concurrency |
| ); |
| merged.fetch.totalTimeoutMs = parseIntegerEnv( |
| process.env.RSS_TOTAL_TIMEOUT, |
| merged.fetch.totalTimeoutMs |
| ); |
| merged.fetch.maxRedirects = parseIntegerEnv( |
| process.env.RSS_FETCH_MAX_REDIRECTS, |
| merged.fetch.maxRedirects |
| ); |
|
|
| merged.articles.perSite = parseIntegerEnv( |
| process.env.RSS_ARTICLES_PER_SITE, |
| merged.articles.perSite |
| ); |
| merged.articles.total = parseIntegerEnv(process.env.RSS_ARTICLES_TOTAL, merged.articles.total); |
| merged.articles.summaryMaxLength = parseIntegerEnv( |
| process.env.RSS_SUMMARY_MAX_LENGTH, |
| merged.articles.summaryMaxLength |
| ); |
|
|
| |
| merged.fetch.timeoutMs = Math.max(1_000, merged.fetch.timeoutMs); |
| merged.fetch.totalTimeoutMs = Math.max(5_000, merged.fetch.totalTimeoutMs); |
| merged.fetch.concurrency = Math.max(1, Math.min(20, merged.fetch.concurrency)); |
| merged.fetch.maxRetries = Math.max(0, Math.min(3, merged.fetch.maxRetries)); |
| merged.fetch.maxRedirects = Math.max(0, Math.min(10, merged.fetch.maxRedirects)); |
|
|
| merged.articles.perSite = Math.max(1, Math.min(50, merged.articles.perSite)); |
| merged.articles.total = Math.max(1, Math.min(500, merged.articles.total)); |
| merged.articles.summaryMaxLength = Math.max(0, Math.min(2_000, merged.articles.summaryMaxLength)); |
|
|
| return merged; |
| } |
|
|
| function isHttpUrl(url) { |
| if (!url) return false; |
| try { |
| const u = new URL(String(url)); |
| return u.protocol === 'http:' || u.protocol === 'https:'; |
| } catch { |
| return false; |
| } |
| } |
|
|
| function isPrivateIp(ip) { |
| if (!ip) return true; |
|
|
| if (net.isIP(ip) === 4) { |
| const parts = ip.split('.').map((n) => Number.parseInt(n, 10)); |
| if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n) || n < 0 || n > 255)) |
| return true; |
|
|
| const [a, b] = parts; |
| if (a === 10) return true; |
| if (a === 127) return true; |
| if (a === 0) return true; |
| if (a === 169 && b === 254) return true; |
| if (a === 172 && b >= 16 && b <= 31) return true; |
| if (a === 192 && b === 168) return true; |
| if (a >= 224) return true; |
| return false; |
| } |
|
|
| if (net.isIP(ip) === 6) { |
| const normalized = String(ip).toLowerCase(); |
| if (normalized === '::1') return true; |
| if (normalized.startsWith('fe80:')) return true; |
| if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true; |
| return false; |
| } |
|
|
| return true; |
| } |
|
|
| async function withTimeout(promise, timeoutMs, label) { |
| let timer; |
| try { |
| const timeout = new Promise((_, reject) => { |
| timer = setTimeout(() => reject(new Error(`${label} 超时(${timeoutMs}ms)`)), timeoutMs); |
| }); |
| return await Promise.race([promise, timeout]); |
| } finally { |
| if (timer) clearTimeout(timer); |
| } |
| } |
|
|
| async function assertSafeToFetch(url, timeoutMs) { |
| const u = new URL(String(url)); |
| if (u.protocol !== 'http:' && u.protocol !== 'https:') { |
| throw new Error(`仅允许 http/https:${u.protocol}`); |
| } |
|
|
| if (u.username || u.password) { |
| throw new Error('禁止包含用户名/密码的 URL'); |
| } |
|
|
| const hostname = u.hostname.toLowerCase(); |
| if ( |
| hostname === 'localhost' || |
| hostname === '0.0.0.0' || |
| hostname === '127.0.0.1' || |
| hostname === '::1' |
| ) { |
| throw new Error('禁止访问本机地址'); |
| } |
| if (hostname.endsWith('.local')) { |
| throw new Error('禁止访问 .local 域名'); |
| } |
|
|
| if (net.isIP(hostname)) { |
| if (isPrivateIp(hostname)) throw new Error('禁止访问内网/保留 IP'); |
| return; |
| } |
|
|
| |
| const records = await withTimeout( |
| dns.lookup(hostname, { all: true, verbatim: true }), |
| Math.min(2_000, timeoutMs), |
| `DNS 解析 ${hostname}` |
| ); |
|
|
| if (!Array.isArray(records) || records.length === 0) { |
| throw new Error('DNS 解析失败或无结果'); |
| } |
|
|
| const hasPrivate = records.some((r) => isPrivateIp(r.address)); |
| if (hasPrivate) throw new Error('DNS 解析到内网/保留地址,已阻断'); |
| } |
|
|
| function buildHeaders(userAgent) { |
| return { |
| 'user-agent': userAgent, |
| accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', |
| }; |
| } |
|
|
| async function fetchWithRedirects(url, { timeoutMs, maxRedirects, headers, maxBytes }) { |
| let current = String(url); |
| for (let i = 0; i <= maxRedirects; i += 1) { |
| await assertSafeToFetch(current, timeoutMs); |
|
|
| const controller = new AbortController(); |
| const timer = setTimeout(() => controller.abort(), timeoutMs); |
|
|
| let response; |
| try { |
| response = await fetch(current, { |
| method: 'GET', |
| redirect: 'manual', |
| headers, |
| signal: controller.signal, |
| }); |
| } finally { |
| clearTimeout(timer); |
| } |
|
|
| const status = response.status; |
| if (status >= 300 && status < 400) { |
| const location = response.headers.get('location'); |
| if (!location) throw new Error(`重定向缺少 Location(${status})`); |
| current = new URL(location, current).toString(); |
| continue; |
| } |
|
|
| if (!response.ok) { |
| throw new Error(`HTTP ${status}`); |
| } |
|
|
| const text = await readResponseTextWithLimit(response, maxBytes); |
| return { url: current, response, text }; |
| } |
|
|
| throw new Error(`重定向次数超过上限(${maxRedirects})`); |
| } |
|
|
| async function readResponseTextWithLimit(response, maxBytes) { |
| if (!response.body || typeof response.body.getReader !== 'function') { |
| const text = await response.text(); |
| if (Buffer.byteLength(text, 'utf8') > maxBytes) { |
| throw new Error('响应体过大'); |
| } |
| return text; |
| } |
|
|
| const reader = response.body.getReader(); |
| const decoder = new TextDecoder('utf-8'); |
| let received = 0; |
| let text = ''; |
|
|
| while (true) { |
| |
| const { done, value } = await reader.read(); |
| if (done) break; |
| received += value.byteLength; |
| if (received > maxBytes) { |
| try { |
| reader.cancel(); |
| } catch { |
| |
| } |
| throw new Error('响应体过大'); |
| } |
| text += decoder.decode(value, { stream: true }); |
| } |
| text += decoder.decode(); |
| return text; |
| } |
|
|
| function extractFeedLinksFromHtml(html, baseUrl) { |
| const candidates = []; |
| if (!html) return candidates; |
|
|
| const linkTags = String(html).match(/<link\b[^>]*>/gi) || []; |
| for (const tag of linkTags) { |
| const rel = /rel\s*=\s*["']([^"']+)["']/i.exec(tag)?.[1] || ''; |
| if (!/alternate/i.test(rel)) continue; |
|
|
| const type = /type\s*=\s*["']([^"']+)["']/i.exec(tag)?.[1] || ''; |
| const isFeedType = /application\/(rss|atom)\+xml/i.test(type) || /(rss|atom)/i.test(type); |
| if (!isFeedType) continue; |
|
|
| const href = /href\s*=\s*["']([^"']+)["']/i.exec(tag)?.[1]; |
| if (!href) continue; |
|
|
| try { |
| const resolved = new URL(href, baseUrl).toString(); |
| if (isHttpUrl(resolved)) candidates.push(resolved); |
| } catch { |
| |
| } |
| } |
|
|
| |
| const rank = (url) => (url.includes('atom') ? 2 : 1); |
| return [...new Set(candidates)].sort((a, b) => rank(a) - rank(b)); |
| } |
|
|
| function buildCommonFeedUrls(siteUrl) { |
| const common = ['/feed', '/rss.xml', '/rss', '/atom.xml', '/atom', '/feed.xml']; |
| const out = []; |
| for (const p of common) { |
| try { |
| const u = new URL(p, siteUrl).toString(); |
| out.push(u); |
| } catch { |
| |
| } |
| } |
| return out; |
| } |
|
|
| async function discoverFeedUrl(siteUrl, settings, deadlineTs) { |
| const timeRemaining = deadlineTs - Date.now(); |
| if (timeRemaining <= 0) throw new Error('总超时:无法继续发现 RSS'); |
|
|
| const homepage = await fetchWithRedirects(siteUrl, { |
| timeoutMs: Math.min(settings.fetch.timeoutMs, timeRemaining), |
| maxRedirects: settings.fetch.maxRedirects, |
| headers: buildHeaders(settings.fetch.userAgent), |
| maxBytes: settings.fetch.htmlMaxBytes, |
| }); |
|
|
| const contentType = homepage.response.headers.get('content-type') || ''; |
| if ( |
| /text\/html/i.test(contentType) || |
| /application\/xhtml\+xml/i.test(contentType) || |
| !contentType |
| ) { |
| const candidates = extractFeedLinksFromHtml(homepage.text, homepage.url); |
| if (candidates.length > 0) { |
| return candidates[0]; |
| } |
| } |
|
|
| return null; |
| } |
|
|
| function stripHtmlToText(input) { |
| const raw = String(input || ''); |
| const withoutTags = raw |
| .replace(/<script[\s\S]*?<\/script>/gi, '') |
| .replace(/<style[\s\S]*?<\/style>/gi, '') |
| .replace(/<[^>]+>/g, ' '); |
|
|
| const decoded = withoutTags |
| .replace(/ /gi, ' ') |
| .replace(/&/gi, '&') |
| .replace(/</gi, '<') |
| .replace(/>/gi, '>') |
| .replace(/"/gi, '"') |
| .replace(/�?39;/g, "'") |
| .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(Number.parseInt(hex, 16))) |
| .replace(/&#(\d+);/g, (_, num) => String.fromCodePoint(Number.parseInt(num, 10))); |
|
|
| return decoded.replace(/\s+/g, ' ').trim(); |
| } |
|
|
| function truncateText(text, maxLen) { |
| if (!maxLen || maxLen <= 0) return ''; |
| const s = String(text || ''); |
| if (s.length <= maxLen) return s; |
| return s.slice(0, maxLen) + '...'; |
| } |
|
|
| function normalizePublishedAt(item) { |
| const iso = item && typeof item.isoDate === 'string' ? item.isoDate : ''; |
| if (iso) return iso; |
|
|
| const pub = item && typeof item.pubDate === 'string' ? item.pubDate : ''; |
| if (pub) { |
| const d = new Date(pub); |
| if (!Number.isNaN(d.getTime())) return d.toISOString(); |
| } |
|
|
| return ''; |
| } |
|
|
| function normalizeArticle(item, sourceSite, settings) { |
| const title = item && item.title !== undefined ? String(item.title).trim() : ''; |
| if (!title) return null; |
|
|
| const link = item && item.link ? String(item.link).trim() : ''; |
| if (!isHttpUrl(link)) return null; |
|
|
| const summaryRaw = |
| (item && item.contentSnippet) || (item && item.summary) || (item && item.content) || ''; |
| const summaryText = stripHtmlToText(summaryRaw); |
| const summary = settings.articles.summaryMaxLength |
| ? truncateText(summaryText, settings.articles.summaryMaxLength) |
| : summaryText; |
|
|
| const publishedAt = normalizePublishedAt(item); |
|
|
| const source = sourceSite && sourceSite.name ? String(sourceSite.name) : ''; |
| const sourceUrl = sourceSite && sourceSite.url ? String(sourceSite.url) : ''; |
| const icon = sourceSite && sourceSite.icon ? String(sourceSite.icon) : 'fas fa-pen'; |
|
|
| return { |
| title, |
| url: link, |
| summary, |
| publishedAt, |
| source, |
| |
| sourceUrl, |
| icon, |
| }; |
| } |
|
|
| async function fetchAndParseFeed(feedUrl, settings, parser, deadlineTs) { |
| const timeRemaining = deadlineTs - Date.now(); |
| if (timeRemaining <= 0) throw new Error('总超时:无法继续抓取 Feed'); |
|
|
| const feed = await fetchWithRedirects(feedUrl, { |
| timeoutMs: Math.min(settings.fetch.timeoutMs, timeRemaining), |
| maxRedirects: settings.fetch.maxRedirects, |
| headers: { |
| ...buildHeaders(settings.fetch.userAgent), |
| accept: 'application/rss+xml,application/atom+xml,application/xml,text/xml;q=0.9,*/*;q=0.8', |
| }, |
| maxBytes: settings.fetch.feedMaxBytes, |
| }); |
|
|
| const parsed = await parser.parseString(feed.text); |
| return { |
| feedUrl: feed.url, |
| feedTitle: parsed.title || '', |
| items: Array.isArray(parsed.items) ? parsed.items : [], |
| }; |
| } |
|
|
| async function processSourceSite(sourceSite, settings, parser, deadlineTs) { |
| const url = sourceSite && sourceSite.url ? String(sourceSite.url) : ''; |
| if (!isHttpUrl(url)) { |
| return { |
| site: { |
| name: sourceSite && sourceSite.name ? String(sourceSite.name) : '', |
| url, |
| feedUrl: '', |
| status: 'skipped', |
| error: '无效 URL(需为 http/https)', |
| fetchedAt: new Date().toISOString(), |
| }, |
| articles: [], |
| }; |
| } |
|
|
| let lastError = null; |
|
|
| const tryOnce = async (feedUrl) => { |
| const parsed = await fetchAndParseFeed(feedUrl, settings, parser, deadlineTs); |
| const normalized = parsed.items |
| .map((item) => normalizeArticle(item, sourceSite, settings)) |
| .filter(Boolean) |
| .slice(0, settings.articles.perSite); |
| return { feedUrl: parsed.feedUrl, articles: normalized }; |
| }; |
|
|
| const attempt = async () => { |
| const discovered = await discoverFeedUrl(url, settings, deadlineTs); |
| const candidates = discovered |
| ? [discovered, ...buildCommonFeedUrls(url)] |
| : buildCommonFeedUrls(url); |
|
|
| for (const candidate of [...new Set(candidates)]) { |
| try { |
| |
| const res = await tryOnce(candidate); |
| return res; |
| } catch (e) { |
| lastError = e; |
| } |
| } |
| throw lastError || new Error('未找到可用 Feed'); |
| }; |
|
|
| const elapsedMs = startTimer(); |
| for (let i = 0; i <= settings.fetch.maxRetries; i += 1) { |
| try { |
| |
| const res = await attempt(); |
| return { |
| site: { |
| name: sourceSite && sourceSite.name ? String(sourceSite.name) : '', |
| url, |
| feedUrl: res.feedUrl, |
| status: 'success', |
| error: '', |
| fetchedAt: new Date().toISOString(), |
| durationMs: elapsedMs(), |
| }, |
| articles: res.articles, |
| }; |
| } catch (e) { |
| lastError = e; |
| } |
| } |
|
|
| return { |
| site: { |
| name: sourceSite && sourceSite.name ? String(sourceSite.name) : '', |
| url, |
| feedUrl: '', |
| status: 'failed', |
| error: lastError ? String(lastError.message || lastError) : '未知错误', |
| fetchedAt: new Date().toISOString(), |
| durationMs: elapsedMs(), |
| }, |
| articles: [], |
| }; |
| } |
|
|
| async function mapWithConcurrency(items, concurrency, worker) { |
| const results = new Array(items.length); |
| let nextIndex = 0; |
|
|
| async function runOne() { |
| while (nextIndex < items.length) { |
| const currentIndex = nextIndex; |
| nextIndex += 1; |
|
|
| try { |
| |
| results[currentIndex] = await worker(items[currentIndex], currentIndex); |
| } catch (e) { |
| results[currentIndex] = { error: e }; |
| } |
| } |
| } |
|
|
| const runners = []; |
| const count = Math.max(1, Math.min(concurrency, items.length)); |
| for (let i = 0; i < count; i += 1) { |
| runners.push(runOne()); |
| } |
| await Promise.all(runners); |
| return results; |
| } |
|
|
| function collectSitesRecursively(node, output) { |
| if (!node || typeof node !== 'object') return; |
|
|
| if (Array.isArray(node.subcategories)) |
| node.subcategories.forEach((child) => collectSitesRecursively(child, output)); |
| if (Array.isArray(node.groups)) |
| node.groups.forEach((child) => collectSitesRecursively(child, output)); |
| if (Array.isArray(node.subgroups)) |
| node.subgroups.forEach((child) => collectSitesRecursively(child, output)); |
|
|
| if (Array.isArray(node.sites)) { |
| node.sites.forEach((site) => { |
| if (site && typeof site === 'object') output.push(site); |
| }); |
| } |
| } |
|
|
| function buildFlatSitesFromCategories(categories) { |
| const out = []; |
| if (!Array.isArray(categories)) return out; |
| categories.forEach((category) => collectSitesRecursively(category, out)); |
| return out; |
| } |
|
|
| async function syncArticlesForPage(pageId, pageConfig, config, settings) { |
| const sourceSites = Array.isArray(pageConfig && pageConfig.sites) |
| ? pageConfig.sites |
| : buildFlatSitesFromCategories( |
| pageConfig && Array.isArray(pageConfig.categories) ? pageConfig.categories : [] |
| ); |
|
|
| const elapsedMs = startTimer(); |
| const startedAt = Date.now(); |
| const deadlineTs = startedAt + settings.fetch.totalTimeoutMs; |
|
|
| const parser = new Parser({ |
| timeout: settings.fetch.timeoutMs, |
| }); |
|
|
| const results = await mapWithConcurrency(sourceSites, settings.fetch.concurrency, async (site) => |
| processSourceSite(site, settings, parser, deadlineTs) |
| ); |
|
|
| const sites = []; |
| const articles = []; |
| const seen = new Set(); |
|
|
| for (const r of results) { |
| if (!r || r.error) continue; |
| if (r.site) sites.push(r.site); |
| if (Array.isArray(r.articles)) { |
| for (const a of r.articles) { |
| if (!a || !a.url) continue; |
| if (seen.has(a.url)) continue; |
| seen.add(a.url); |
| articles.push(a); |
| } |
| } |
| } |
|
|
| articles.sort((a, b) => { |
| const ta = a.publishedAt ? new Date(a.publishedAt).getTime() : 0; |
| const tb = b.publishedAt ? new Date(b.publishedAt).getTime() : 0; |
| return tb - ta; |
| }); |
|
|
| const limitedArticles = articles.slice(0, settings.articles.total); |
|
|
| const successSites = sites.filter((s) => s.status === 'success').length; |
| const failedSites = sites.filter((s) => s.status === 'failed').length; |
| const skippedSites = sites.filter((s) => s.status === 'skipped').length; |
|
|
| const cache = { |
| version: '1.0', |
| pageId, |
| generatedAt: new Date().toISOString(), |
| title: pageConfig && pageConfig.title ? String(pageConfig.title) : '', |
| sites, |
| articles: limitedArticles, |
| stats: { |
| totalSites: sourceSites.length, |
| successSites, |
| failedSites, |
| skippedSites, |
| totalArticles: limitedArticles.length, |
| durationMs: elapsedMs(), |
| }, |
| }; |
|
|
| const cacheDir = path.resolve(process.cwd(), settings.cacheDir); |
| fs.mkdirSync(cacheDir, { recursive: true }); |
|
|
| const cachePath = path.join(cacheDir, `${pageId}.feed-cache.json`); |
| fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2)); |
|
|
| return { cachePath, cache }; |
| } |
|
|
| function pickArticlesPages(config, onlyPageId) { |
| const pages = []; |
| const nav = Array.isArray(config.navigation) ? config.navigation : []; |
|
|
| for (const item of nav) { |
| const pageId = item && item.id ? String(item.id) : ''; |
| if (!pageId) continue; |
| if (onlyPageId && pageId !== onlyPageId) continue; |
|
|
| const pageConfig = config[pageId]; |
| if (!pageConfig || typeof pageConfig !== 'object') continue; |
|
|
| const templateName = pageConfig.template ? String(pageConfig.template) : pageId; |
| if (templateName !== 'articles') continue; |
|
|
| pages.push({ pageId, pageConfig }); |
| } |
|
|
| return pages; |
| } |
|
|
| async function main() { |
| const elapsedMs = startTimer(); |
| const args = process.argv.slice(2); |
| const pageArgIndex = args.findIndex((a) => a === '--page'); |
| const onlyPageId = pageArgIndex >= 0 ? args[pageArgIndex + 1] : null; |
|
|
| log.info('开始', { page: onlyPageId || '' }); |
|
|
| const config = loadConfig(); |
| const settings = getRssSettings(config); |
|
|
| if (!settings.enabled) { |
| log.ok('RSS 已禁用,跳过', { env: 'RSS_ENABLED=false' }); |
| return; |
| } |
|
|
| const pages = pickArticlesPages(config, onlyPageId); |
| if (pages.length === 0) { |
| log.ok('未找到需要同步的 articles 页面,跳过'); |
| return; |
| } |
|
|
| log.info('准备同步 articles 页面缓存', { pages: pages.length }); |
|
|
| let success = 0; |
| let failed = 0; |
|
|
| for (const { pageId, pageConfig } of pages) { |
| try { |
| |
| const { cachePath, cache } = await syncArticlesForPage(pageId, pageConfig, config, settings); |
| success += 1; |
| log.ok('已生成缓存', { |
| page: pageId, |
| cache: cachePath, |
| articles: cache && cache.stats ? cache.stats.totalArticles : '', |
| sites: cache && cache.stats ? cache.stats.totalSites : '', |
| }); |
| } catch (e) { |
| failed += 1; |
| log.warn('页面同步失败,已跳过(best-effort)', { |
| page: pageId, |
| message: e && e.message ? e.message : String(e), |
| }); |
| if (isVerbose() && e && e.stack) console.error(e.stack); |
| |
| } |
| } |
|
|
| log.ok('完成', { ms: elapsedMs(), pages: pages.length, success, failed }); |
| } |
|
|
| if (require.main === module) { |
| main().catch((err) => { |
| log.error('执行失败(best-effort,不阻断后续 build/deploy)', { |
| message: err && err.message ? err.message : String(err), |
| }); |
| if (isVerbose() && err && err.stack) console.error(err.stack); |
| |
| process.exitCode = 0; |
| }); |
| } |
|
|
| module.exports = { |
| getRssSettings, |
| isPrivateIp, |
| extractFeedLinksFromHtml, |
| stripHtmlToText, |
| normalizeArticle, |
| buildFlatSitesFromCategories, |
| }; |
|
|