#!/usr/bin/env node // 外部素材抓取:由系統排程(cron 或 systemd timer)執行,把天氣與新聞寫進快取檔; // companion 的 hook 只讀這個檔,絕不連網。只存標題、摘要、連結、時間(at 為抓取時間,毫秒)。 // // node fetch-materials.mjs # 讀 ~/.companion/user.json 的 location 與 feeds // COMPANION_ROOT=/path node fetch-materials.mjs // // 沒有 setup location= 就不做任何事(素材功能未啟用)。 import { mkdir, readFile, rename, writeFile } from 'node:fs/promises' import { homedir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' export const NEWS_PER_FEED = 5 export const NEWS_MAX = 20 export const SUMMARY_MAX = 120 // WMO 天氣代碼 → 標題(與 hooks/companion/rules.ts 的 WEATHER_MOOD 對應) export function weatherTitle(code) { if (code === 0 || code === 1) return '晴' if (code === 2) return '多雲' if (code === 3) return '陰' if (code === 45 || code === 48) return '霧' if (code >= 51 && code <= 57) return '毛毛雨' if ((code >= 61 && code <= 67) || (code >= 80 && code <= 82)) return '雨' if ((code >= 71 && code <= 77) || code === 85 || code === 86) return '雪' if (code >= 95) return '雷雨' return '陰' } // 先還原跳脫的標籤再整個移除(RSS 的 description 常是跳脫過的 HTML),最後才還原 & const decode = s => s .replace(//g, '$1') .replace(/</g, '<').replace(/>/g, '>') .replace(/<[^>]*>/g, ' ') .replace(/"/g, '"').replace(/'|'/g, "'").replace(/&/g, '&') .replace(/\s+/g, ' ') .trim() const tag = (block, name) => { const m = new RegExp(`<${name}[^>]*>([\\s\\S]*?)`, 'i').exec(block) return m ? decode(m[1]) : '' } /** 解析 RSS 2.0 與 Atom,只留標題、摘要、連結 */ export function parseFeed(xml, at) { const blocks = xml.match(/][\s\S]*?<\/item>|][\s\S]*?<\/entry>/gi) ?? [] const items = [] for (const b of blocks) { const title = tag(b, 'title') const link = tag(b, 'link') || (/]*href=["']([^"']+)["']/i.exec(b)?.[1] ?? '') if (!title || !/^https?:\/\//.test(link)) continue const summary = (tag(b, 'description') || tag(b, 'summary') || tag(b, 'content')).slice(0, SUMMARY_MAX) items.push({ title, summary, link, at }) } return items.slice(0, NEWS_PER_FEED) } async function getJson(fetchFn, url) { const res = await fetchFn(url) if (!res.ok) throw new Error(`${res.status} ${url}`) return res.json() } /** location 可以是「緯度,經度」或地名(用 Open-Meteo 地理編碼查) */ export async function locate(fetchFn, location) { const m = /^\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*$/.exec(location) if (m) return { lat: Number(m[1]), lon: Number(m[2]), name: location.trim() } const j = await getJson(fetchFn, `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(location)}&count=1&language=zh`) const r = j.results?.[0] if (!r) throw new Error(`找不到地點:${location}`) return { lat: r.latitude, lon: r.longitude, name: r.name ?? location } } export async function fetchWeather(fetchFn, location, at) { const { lat, lon, name } = await locate(fetchFn, location) const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}¤t=temperature_2m,weather_code&timezone=auto` const j = await getJson(fetchFn, url) const cur = j.current if (!cur || typeof cur.weather_code !== 'number') throw new Error('天氣資料格式不符') return { title: weatherTitle(cur.weather_code), summary: `${name} ${cur.temperature_2m}°C`, link: url, at } } async function readJson(path) { try { return JSON.parse(await readFile(path, 'utf8')) } catch { return null } } /** * 抓取並寫入快取;任何一部分失敗就保留該部分原本的快取(過期後 hook 自然當作沒查)。 * 回傳 { enabled, weather, news, errors }。 */ export async function run({ root = process.env.COMPANION_ROOT || join(homedir(), '.companion'), fetch: fetchFn = globalThis.fetch, now = Date.now(), log = () => {} } = {}) { const user = (await readJson(join(root, 'user.json'))) ?? {} if (!user.location) { log('未設定 location,素材功能未啟用(/jsc-mod-companion:setup location=…)。') return { enabled: false, weather: false, news: 0, errors: [] } } const path = join(root, 'materials.json') const prev = (await readJson(path)) ?? {} const errors = [] let weather = prev.weather ?? null let news = Array.isArray(prev.news) ? prev.news : [] let gotWeather = false try { weather = await fetchWeather(fetchFn, user.location, now) gotWeather = true } catch (e) { errors.push(`天氣:${e.message}`) } const feeds = Array.isArray(user.feeds) ? user.feeds : [] if (feeds.length) { const fresh = [] for (const url of feeds) { try { const res = await fetchFn(url) if (!res.ok) throw new Error(`${res.status}`) fresh.push(...parseFeed(await res.text(), now)) } catch (e) { errors.push(`RSS ${url}:${e.message}`) } } if (fresh.length) { const seen = new Set() news = fresh.filter(n => !seen.has(n.link) && seen.add(n.link)).slice(0, NEWS_MAX) } } await mkdir(dirname(path), { recursive: true }) const tmp = `${path}.tmp` await writeFile(tmp, JSON.stringify({ weather, news }, null, 2)) await rename(tmp, path) for (const e of errors) log(e) return { enabled: true, weather: gotWeather, news: news.length, errors } } if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { run({ log: m => console.error(m) }) .then(r => console.log(r.enabled ? `完成:天氣${r.weather ? '已更新' : '未更新'},新聞 ${r.news} 則` : '未啟用')) .catch(e => { console.error(e.message) process.exitCode = 1 }) }