import fs from 'node:fs'; import { wechatDailyNewsInlineInternals } from '../../wechat-daily-news-inline.mjs'; import { findLatestNewsMorningPage, isNewsMorningPageForToday, } from '../../wechat-news-morning-draft.mjs'; import { resolveNewsMorningPushConfig } from './news-morning-delivery.mjs'; const DEFAULT_WEATHER_CITY = '北京'; function stripInlineText(value) { return String(value ?? '') .replace(/<[^>]+>/g, '') .replace(/\s+/g, ' ') .trim(); } function extractDivBlock(html, className) { const source = String(html ?? ''); const openRe = new RegExp(`
]*>`, 'i'); const openMatch = openRe.exec(source); if (!openMatch) return ''; let pos = openMatch.index + openMatch[0].length; let depth = 1; while (pos < source.length && depth > 0) { const nextOpen = source.indexOf('', pos); if (nextClose === -1) break; if (nextOpen !== -1 && nextOpen < nextClose) { depth += 1; pos = nextOpen + 4; } else { depth -= 1; pos = nextClose + 6; } } return source.slice(openMatch.index, pos); } function extractClassDivBlocks(parentHtml, classPrefix) { const source = String(parentHtml ?? ''); const blocks = []; const openRe = new RegExp(`
]*>`, 'gi'); let openMatch = openRe.exec(source); while (openMatch) { const start = openMatch.index; let pos = start + openMatch[0].length; let depth = 1; while (pos < source.length && depth > 0) { const nextOpen = source.indexOf('', pos); if (nextClose === -1) break; if (nextOpen !== -1 && nextOpen < nextClose) { depth += 1; pos = nextOpen + 4; } else { depth -= 1; pos = nextClose + 6; } } blocks.push(source.slice(start, pos)); openMatch = openRe.exec(source); } return blocks; } function normalizeWeatherCity(raw) { return stripInlineText(raw).replace(/市$/u, '').trim(); } function parseWeatherCard(cardHtml) { const city = normalizeWeatherCity( cardHtml.match(/([\s\S]*?)<\/span>/i)?.[1] ?? cardHtml.match(/
([\s\S]*?)<\/div>/i)?.[1] ?? '', ); const cond = stripInlineText( cardHtml.match(/([\s\S]*?)<\/span>/i)?.[1] ?? cardHtml.match(/
([\s\S]*?)<\/div>/i)?.[1] ?? '', ); const temp = stripInlineText( cardHtml.match(/([\s\S]*?)<\/span>/i)?.[1] ?? cardHtml.match(/
([\s\S]*?)<\/div>/i)?.[1] ?? '', ); if (!city) return null; return { city, cond, temp }; } function extractWeatherCardsFromHtml(html) { const grid = extractDivBlock(html, 'weather-grid'); const cards = extractClassDivBlocks(grid, 'weather-card') .map(parseWeatherCard) .filter(Boolean); if (cards.length > 0) return cards; const { selectTouristWeatherCards } = wechatDailyNewsInlineInternals; return selectTouristWeatherCards(extractClassDivBlocks(html, 'weather-card')) .map(parseWeatherCard) .filter(Boolean); } function extractWeatherTodaySummary(html) { const todayBlock = extractDivBlock(html, 'weather-today'); if (!todayBlock) return ''; return stripInlineText(todayBlock).slice(0, 120); } export function normalizePushWeatherCity(raw, { defaultCity = DEFAULT_WEATHER_CITY } = {}) { const city = normalizeWeatherCity(raw); if (!city) return defaultCity; if (wechatDailyNewsInlineInternals.isPopularTouristCityName(city)) return city; if (/^[\u4e00-\u9fff]{2,6}$/u.test(city)) return city; return defaultCity; } function buildDressingAdvice({ cond = '', temp = '' }) { const text = `${cond} ${temp}`; if (/雨|雪/u.test(text)) return '建议带伞,穿防水外套。'; if (/冷|冰|0°|1°|2°|3°|4°|5°/u.test(text)) return '偏冷,注意保暖,外套加厚。'; if (/30|31|32|33|34|35|36/u.test(text)) return '偏热,轻薄透气,注意防晒补水。'; if (/晴/u.test(text)) return '晴好天气,适合户外,记得防晒。'; if (/阴|云/u.test(text)) return '温差可能较大,建议洋葱式穿衣。'; return '根据体感增减衣物,出门前再看一眼实时预报。'; } export function buildWeatherPushTextFromHtml( html, { city = DEFAULT_WEATHER_CITY, manageHint = '回复「取消3」关闭推送' } = {}, ) { const normalizedCity = normalizePushWeatherCity(city); const cards = extractWeatherCardsFromHtml(html); const matched = cards.find((item) => normalizeWeatherCity(item.city) === normalizedCity) ?? cards[0] ?? null; const todaySummary = extractWeatherTodaySummary(html); const lines = [`🌤 每日天气 · ${normalizedCity}`]; if (matched) { const detail = [matched.cond, matched.temp].filter(Boolean).join(' · '); lines.push('', detail || '详见下方概览'); lines.push('', `穿衣建议:${buildDressingAdvice(matched)}`); } else if (todaySummary) { lines.push('', todaySummary); } else { lines.push('', '今日天气数据正在更新,请稍后再看。'); } if (cards.length > 1 && matched) { const others = cards .filter((item) => normalizeWeatherCity(item.city) !== normalizedCity) .slice(0, 3) .map((item) => `${item.city} ${[item.cond, item.temp].filter(Boolean).join(' ')}`.trim()); if (others.length) { lines.push('', `周边速览:${others.join(' · ')}`); } } if (manageHint) lines.push('', manageHint); return lines.join('\n'); } export async function formatWeatherPushDelivery({ city = DEFAULT_WEATHER_CITY, now = Date.now(), timezone = 'Asia/Shanghai', env = process.env, h5Root = process.cwd(), pool = null, } = {}) { const normalizedCity = normalizePushWeatherCity(city); const config = await resolveNewsMorningPushConfig({ pool, env }); const effectiveTimezone = config.timezone || timezone; if (!config.sourceUserId) { return { text: `🌤 每日天气 · ${normalizedCity}\n\n天气数据暂不可用,请稍后再试。\n\n回复「取消3」关闭推送`, verifiedHtmlUrls: [], }; } const page = findLatestNewsMorningPage({ h5Root, userId: config.sourceUserId, slugPattern: config.pageSlugPattern, date: new Date(now), timezone: effectiveTimezone, }); if ( !page?.localPath || !fs.existsSync(page.localPath) || !isNewsMorningPageForToday(page, { date: new Date(now), timezone: effectiveTimezone }) ) { return { text: `🌤 每日天气 · ${normalizedCity}\n\n今日天气正在整理中,稍后会推送到本服务号。\n\n回复「取消3」关闭推送`, verifiedHtmlUrls: [], }; } const html = fs.readFileSync(page.localPath, 'utf8'); return { text: buildWeatherPushTextFromHtml(html, { city: normalizedCity }), verifiedHtmlUrls: [], }; }