Files
jsc-marketplace/plugins/jsc-mod-companion/scripts/fetch-materials.mjs
T
adminandClaude Sonnet 5.5 7e6e323473 feat: companion 作息、主動發話、適應度與外部素材
- 作息與疲勞、想念累積與時間感,主動發話沿用 care.ts 的冷卻、日限、靜音
- 想家與適應度(9.13)
- 獨立腳本 fetch-materials.mjs 由系統排程寫入快取,hook 只讀、不連網
- 新增 /jsc-mod-companion:materials 與 setup location=、feeds=
- 天氣微調心情基準,新聞依適應度每天最多 2 則並寫入情節記憶
- 版本 0.0.5

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 09:29:46 +00:00

148 lines
5.9 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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),最後才還原 &amp;
const decode = s =>
s
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
.replace(/&lt;/g, '<').replace(/&gt;/g, '>')
.replace(/<[^>]*>/g, ' ')
.replace(/&quot;/g, '"').replace(/&#39;|&apos;/g, "'").replace(/&amp;/g, '&')
.replace(/\s+/g, ' ')
.trim()
const tag = (block, name) => {
const m = new RegExp(`<${name}[^>]*>([\\s\\S]*?)</${name}>`, 'i').exec(block)
return m ? decode(m[1]) : ''
}
/** 解析 RSS 2.0 與 Atom,只留標題、摘要、連結 */
export function parseFeed(xml, at) {
const blocks = xml.match(/<item[\s>][\s\S]*?<\/item>|<entry[\s>][\s\S]*?<\/entry>/gi) ?? []
const items = []
for (const b of blocks) {
const title = tag(b, 'title')
const link = tag(b, 'link') || (/<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}&current=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
})
}