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>
This commit is contained in:
+147
@@ -0,0 +1,147 @@
|
||||
#!/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(/<!\[CDATA\[([\s\S]*?)\]\]>/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]*?)</${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}¤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
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// node --test scripts/fetch-materials.test.mjs
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { test } from 'node:test'
|
||||
import { promisify } from 'node:util'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { parseFeed, run, weatherTitle } from './fetch-materials.mjs'
|
||||
|
||||
const RSS = `<?xml version="1.0"?><rss><channel>
|
||||
<item><title><![CDATA[第一則 & 標題]]></title><link>https://n.example/1</link><description><p>摘要</p>內文</description><pubDate>x</pubDate></item>
|
||||
<item><title>沒有連結</title></item>
|
||||
<item><title>第二則</title><link>https://n.example/2</link><description>d2</description></item>
|
||||
</channel></rss>`
|
||||
const ATOM = `<feed><entry><title>原子</title><link href="https://a.example/1"/><summary>s</summary></entry></feed>`
|
||||
|
||||
const mkFetch = routes => async url => {
|
||||
for (const [k, v] of Object.entries(routes)) {
|
||||
if (url.includes(k)) {
|
||||
if (v instanceof Error) throw v
|
||||
return { ok: true, status: 200, json: async () => v, text: async () => v }
|
||||
}
|
||||
}
|
||||
return { ok: false, status: 404, json: async () => ({}), text: async () => '' }
|
||||
}
|
||||
const WEATHER = { current: { temperature_2m: 26.4, weather_code: 61 } }
|
||||
const NOW = Date.UTC(2026, 9, 14, 8, 0)
|
||||
|
||||
async function withRoot(user, fn) {
|
||||
const root = await mkdtemp(join(tmpdir(), 'companion-'))
|
||||
try {
|
||||
if (user) await writeFile(join(root, 'user.json'), JSON.stringify(user))
|
||||
return await fn(root)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
test('RSS 與 Atom 只留標題、摘要、連結、時間,沒有連結的略過', () => {
|
||||
const items = parseFeed(RSS, NOW)
|
||||
assert.deepEqual(items.map(i => i.title), ['第一則 & 標題', '第二則'])
|
||||
assert.equal(items[0].summary, '摘要 內文')
|
||||
assert.deepEqual(Object.keys(items[0]).sort(), ['at', 'link', 'summary', 'title'])
|
||||
assert.equal(parseFeed(ATOM, NOW)[0].link, 'https://a.example/1')
|
||||
})
|
||||
|
||||
test('天氣代碼對應標題', () => {
|
||||
assert.equal(weatherTitle(0), '晴')
|
||||
assert.equal(weatherTitle(63), '雨')
|
||||
assert.equal(weatherTitle(95), '雷雨')
|
||||
})
|
||||
|
||||
test('沒有 location 就不啟用,也不寫快取、不連網', async () => {
|
||||
await withRoot({ feeds: ['https://n.example/rss'] }, async root => {
|
||||
let called = 0
|
||||
const r = await run({ root, now: NOW, fetch: async () => void called++ })
|
||||
assert.equal(r.enabled, false)
|
||||
assert.equal(called, 0)
|
||||
await assert.rejects(readFile(join(root, 'materials.json')))
|
||||
})
|
||||
})
|
||||
|
||||
test('有 location:寫入天氣與新聞快取', async () => {
|
||||
await withRoot({ location: '25.03,121.56', feeds: ['https://n.example/rss'] }, async root => {
|
||||
const fetch = mkFetch({ 'api.open-meteo.com': WEATHER, 'n.example/rss': RSS })
|
||||
const r = await run({ root, now: NOW, fetch })
|
||||
assert.deepEqual([r.enabled, r.weather, r.news], [true, true, 2])
|
||||
const cache = JSON.parse(await readFile(join(root, 'materials.json'), 'utf8'))
|
||||
assert.equal(cache.weather.title, '雨')
|
||||
assert.equal(cache.weather.at, NOW)
|
||||
assert.equal(cache.news.length, 2)
|
||||
})
|
||||
})
|
||||
|
||||
test('地名會先做地理編碼', async () => {
|
||||
await withRoot({ location: '台北' }, async root => {
|
||||
const fetch = mkFetch({ 'geocoding-api': { results: [{ latitude: 25.04, longitude: 121.5, name: '台北' }] }, 'api.open-meteo.com': WEATHER })
|
||||
const r = await run({ root, now: NOW, fetch })
|
||||
assert.equal(r.weather, true)
|
||||
})
|
||||
})
|
||||
|
||||
test('抓取失敗時保留原快取,不覆蓋成空的', async () => {
|
||||
await withRoot({ location: '25,121', feeds: ['https://n.example/rss'] }, async root => {
|
||||
const old = { weather: { title: '晴', summary: 's', link: 'https://x', at: 1 }, news: [{ title: '舊聞', summary: '', link: 'https://o', at: 1 }] }
|
||||
await writeFile(join(root, 'materials.json'), JSON.stringify(old))
|
||||
const fetch = mkFetch({ 'api.open-meteo.com': new Error('offline'), 'n.example/rss': new Error('offline') })
|
||||
const r = await run({ root, now: NOW, fetch })
|
||||
assert.equal(r.errors.length, 2)
|
||||
assert.deepEqual(JSON.parse(await readFile(join(root, 'materials.json'), 'utf8')), old)
|
||||
})
|
||||
})
|
||||
|
||||
test('RSS 預設空清單時只更新天氣', async () => {
|
||||
await withRoot({ location: '25,121' }, async root => {
|
||||
const r = await run({ root, now: NOW, fetch: mkFetch({ 'api.open-meteo.com': WEATHER }) })
|
||||
assert.equal(r.news, 0)
|
||||
})
|
||||
})
|
||||
|
||||
test('腳本可單獨執行(未啟用時正常結束)', async () => {
|
||||
await withRoot(null, async root => {
|
||||
const { stdout } = await promisify(execFile)(process.execPath, [fileURLToPath(new URL('./fetch-materials.mjs', import.meta.url))], { env: { ...process.env, COMPANION_ROOT: root } })
|
||||
assert.match(stdout, /未啟用/)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user