diff options
Diffstat (limited to 'scripts')
| -rw-r--r-- | scripts/post-x-events.mjs | 345 |
1 files changed, 345 insertions, 0 deletions
diff --git a/scripts/post-x-events.mjs b/scripts/post-x-events.mjs new file mode 100644 index 0000000..a776fc0 --- /dev/null +++ b/scripts/post-x-events.mjs @@ -0,0 +1,345 @@ +// その日の小平市の予定を X(旧Twitter)へ投稿する。 +// +// 使い方: +// node scripts/post-x-events.mjs # 投稿文を表示するだけ(投稿しない) +// node scripts/post-x-events.mjs --post # 実際に投稿する +// node scripts/post-x-events.mjs --date 2026-11-20 # 日付を指定して確認 +// node scripts/post-x-events.mjs --events-only # 審議会等・市議会を除く +// node scripts/post-x-events.mjs --post --force # 投稿済みでも再投稿する +// +// 安全装置: +// 1日の投稿は既定で3件まで(.env の X_MAX_POSTS_PER_DAY で変更可。--force でも超えられない)。 +// ただしこの上限は「このスクリプト経由」の投稿にしか効かない。キーが漏えいして +// 第三者が直接 API を叩く場合は止められないので、心当たりがあるときは X ポータルで +// キーを再生成(Regenerate)して .env を更新すること。 +// +// 投稿には X API v2(OAuth 1.0a)を使う。キーはリポジトリ直下の .env に置く(.gitignore 済み): +// X_API_KEY=... # API Key (Consumer Key) +// X_API_SECRET=... # API Key Secret +// X_ACCESS_TOKEN=... # Access Token +// X_ACCESS_SECRET=... # Access Token Secret +// +// 予定データは src/data/schedule/{events,meetings}.json を直接読み、 +// 開催日の判定はページ本体と同じ src/lib/schedule-dates.ts の entryDates() を使う。 +// +// 投稿文は、その日の予定をすべて掲載し、最後に「誤りがある場合があります」の +// 注意書きを付ける(X の有料プランを前提にしているため、文字数は実質無制限)。 +import { createHmac, randomBytes } from 'node:crypto'; +import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { entryDates, toKey } from '../src/lib/schedule-dates.ts'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const LOG_DIR = join(root, 'logs'); +const STATE_FILE = join(LOG_DIR, 'x-post-state.json'); +const LOG_FILE = join(LOG_DIR, 'x-post.log'); +const PAGE_URL = 'https://yasutakeyohei.com/kodaira/events/'; + +// ---------------------------------------------------------------- 引数 + +const args = process.argv.slice(2); +const has = (name) => args.includes(name); +const valueOf = (name) => { + const i = args.indexOf(name); + return i >= 0 ? args[i + 1] : undefined; +}; + +const doPost = has('--post'); +const force = has('--force'); +const eventsOnly = has('--events-only'); +const dateArg = valueOf('--date'); + +// ---------------------------------------------------------------- 日付 + +const now = new Date(); +const target = dateArg + ? (() => { + const [y, m, d] = dateArg.split('-').map(Number); + return new Date(y, m - 1, d); + })() + : now; +const todayKey = toKey(target.getFullYear(), target.getMonth() + 1, target.getDate()); +const WEEKDAYS = ['日', '月', '火', '水', '木', '金', '土']; +const dateLabel = `${target.getMonth() + 1}月${target.getDate()}日(${WEEKDAYS[target.getDay()]})`; + +// ---------------------------------------------------------------- 予定の抽出 + +const readRows = (file) => { + const data = JSON.parse(readFileSync(join(root, 'src/data/schedule', file), 'utf-8')); + return data.rows ?? []; +}; + +const stripTags = (value) => + String(value ?? '') + .replace(/<[^>]*>/g, '') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\s+/g, ' ') + .trim(); + +/** 「午後1時30分から午後3時まで」→ 13:30 */ +function startTime(placeText) { + const text = stripTags(placeText); + const m = text.match(/(午前|午後)?\s*(\d{1,2})時(?:(\d{1,2})分|半)?/); + if (!m) return ''; + let hour = Number(m[2]); + const minute = m[3] ? Number(m[3]) : m[0].includes('半') ? 30 : 0; + if (m[1] === '午後' && hour < 12) hour += 12; + if (m[1] === '午前' && hour === 12) hour = 0; + if (!m[1] && hour < 7) hour += 12; // 「2時から」のような書き方は午後とみなす + if (hour > 23 || minute > 59) return ''; + return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`; +} + +/** 「午前10時から/市役所5階 504会議室」→ 市役所5階 504会議室 */ +function venueOf(placeText) { + const text = stripTags(placeText); + const parts = text.split('/'); + if (parts.length < 2) return ''; + return parts[parts.length - 1].replace(/([^)]*)/g, '').trim(); +} + +const collect = (rows, type) => + rows + .filter((row) => entryDates(type, row.date, row.place).includes(todayKey)) + .map((row) => { + const time = startTime(row.place); + const venue = venueOf(row.place); + return { + type, + time, + sortKey: time || '99:99', + title: stripTags(row.title), + venue, + }; + }) + .sort((a, b) => a.sortKey.localeCompare(b.sortKey)); + +const items = [ + ...collect(readRows('events.json'), 'event'), + ...(eventsOnly ? [] : collect(readRows('meetings.json'), 'meeting')), +].sort((a, b) => a.sortKey.localeCompare(b.sortKey)); + +// ---------------------------------------------------------------- 投稿文の組み立て + +// X の重み付き文字数(全角・絵文字は 2、URL は 23 として数える) +const WIDE = + /[\u1100-\u115F\u2E80-\u303F\u3040-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]|[\u{1F000}-\u{1FAFF}\u{2600}-\u27BF]/u; +const weight = (text) => + [...text].reduce((sum, ch) => sum + (WIDE.test(ch) ? 2 : 1), 0) + + (text.match(/https?:\/\/\S+/g) ?? []).reduce((sum, url) => sum + 23 - weightPlain(url), 0); + +function weightPlain(text) { + return [...text].reduce((sum, ch) => sum + (WIDE.test(ch) ? 2 : 1), 0); +} + +const isToday = + toKey(now.getFullYear(), now.getMonth() + 1, now.getDate()) === todayKey; +const header = `【${isToday ? '今日の小平市' : '小平市'}】${dateLabel}の予定`; +// 誤りがあり得ることを明示する +const disclaimer = + '※掲載内容に誤りがある場合があります。最新の情報は、小平市や各主催者の公式発表でご確認ください。'; +const footer = `\n\n${disclaimer}\n\nくわしくはこちら #小平市\n${PAGE_URL}`; + +// 有料プラン(X Premium)の投稿上限。よほどでない限り超えないが、念のため見張る +const HARD_LIMIT = 25000; + +function itemLine(item) { + const time = item.time ? `${item.time} ` : ''; + const venue = item.venue && !item.title.includes(item.venue) ? `(${item.venue})` : ''; + return `・${time}${item.title}${venue}`; +} + +/** その日の予定をすべて載せる(有料プランのため文字数は実質無制限) */ +function composeText() { + if (items.length === 0) return ''; + + const lines = []; + let used = weight(header) + weight(footer) + weight('\n\n'); + for (let i = 0; i < items.length; i += 1) { + const line = itemLine(items[i]); + const addition = weight(`\n${line}`); + if (used + addition > HARD_LIMIT) { + lines.push(`ほか${items.length - lines.length}件`); + break; + } + lines.push(line); + used += addition; + } + + return `${header}\n\n${lines.join('\n')}${footer}`; +} + +// ---------------------------------------------------------------- 出力・記録 + +const ensureLogDir = () => mkdirSync(LOG_DIR, { recursive: true }); + +function log(message) { + ensureLogDir(); + const line = `[${new Date().toISOString()}] ${message}`; + console.log(message); + appendFileSync(LOG_FILE, `${line}\n`, 'utf-8'); +} + +function readState() { + if (!existsSync(STATE_FILE)) return {}; + try { + return JSON.parse(readFileSync(STATE_FILE, 'utf-8')); + } catch { + return {}; + } +} + +function writeState(state) { + ensureLogDir(); + writeFileSync(STATE_FILE, `${JSON.stringify(state, null, 2)}\n`, 'utf-8'); +} + +// ---------------------------------------------------------------- X API + +function loadEnvFile() { + const file = join(root, '.env'); + if (!existsSync(file)) return; + for (const raw of readFileSync(file, 'utf-8').split(/\r?\n/)) { + const m = raw.match(/^\s*([A-Za-z0-9_]+)\s*=\s*(.*?)\s*$/); + if (!m || raw.trim().startsWith('#')) continue; + let value = m[2]; + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + if (!process.env[m[1]]) process.env[m[1]] = value; + } +} + +const pct = (value) => + encodeURIComponent(value).replace(/[!*'()]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`); + +function oauthHeader({ method, url, apiKey, apiSecret, accessToken, accessSecret }) { + const oauth = { + oauth_consumer_key: apiKey, + oauth_nonce: randomBytes(16).toString('hex'), + oauth_signature_method: 'HMAC-SHA1', + oauth_timestamp: String(Math.floor(Date.now() / 1000)), + oauth_token: accessToken, + oauth_version: '1.0', + }; + const paramString = Object.keys(oauth) + .sort() + .map((k) => `${pct(k)}=${pct(oauth[k])}`) + .join('&'); + const baseString = [method.toUpperCase(), pct(url), pct(paramString)].join('&'); + const signingKey = `${pct(apiSecret)}&${pct(accessSecret)}`; + const signature = createHmac('sha1', signingKey).update(baseString).digest('base64'); + const all = { ...oauth, oauth_signature: signature }; + return `OAuth ${Object.keys(all) + .sort() + .map((k) => `${pct(k)}="${pct(all[k])}"`) + .join(', ')}`; +} + +async function postTweet(text) { + loadEnvFile(); + const creds = { + apiKey: process.env.X_API_KEY, + apiSecret: process.env.X_API_SECRET, + accessToken: process.env.X_ACCESS_TOKEN, + accessSecret: process.env.X_ACCESS_SECRET, + }; + const missing = Object.entries(creds) + .filter(([, v]) => !v) + .map(([k]) => k); + if (missing.length) { + throw new Error( + `X の認証情報がありません(${missing.join(', ')})。リポジトリ直下の .env に設定してください。`, + ); + } + + const url = 'https://api.x.com/2/tweets'; + const res = await fetch(url, { + method: 'POST', + headers: { + Authorization: oauthHeader({ method: 'POST', url, ...creds }), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ text }), + }); + const body = await res.text(); + if (!res.ok) throw new Error(`X API エラー ${res.status}: ${body}`); + const json = JSON.parse(body); + return json.data?.id ?? ''; +} + +// ---------------------------------------------------------------- メイン + +loadEnvFile(); + +// 1日の投稿上限(このスクリプト経由の投稿のみ。--force でも超えられない) +const DAILY_LIMIT = (() => { + const n = Number(process.env.X_MAX_POSTS_PER_DAY); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : 3; +})(); + +/** その日の投稿履歴(旧形式=1日1件のオブジェクトも受け付ける) */ +const postsOf = (state, key) => { + const entry = state[key]; + if (!entry) return []; + return Array.isArray(entry) ? entry : [entry]; +}; + +const text = composeText(); + +if (!text) { + log(`${todayKey}: 本日の予定はありません。投稿しません。`); + process.exit(0); +} + +log(`--- ${todayKey} の投稿文(${items.length}件 / 重み ${weight(text)})---`); +console.log(text); +console.log('---'); + +const state = readState(); +const todaysPosts = postsOf(state, todayKey); + +if (!doPost) { + log('表示のみ(投稿するには --post を付けてください)'); + if (todaysPosts.length) { + log(`※ この日は ${todaysPosts.length} 件投稿済みです(上限 ${DAILY_LIMIT} 件)`); + } + process.exit(0); +} + +if (todaysPosts.length >= DAILY_LIMIT) { + log( + `${todayKey}: 1日の上限 ${DAILY_LIMIT} 件に達しているため投稿しません(--force でも解除されません)。`, + ); + process.exit(0); +} + +if (todaysPosts.length > 0 && !force) { + const last = todaysPosts[todaysPosts.length - 1]; + log(`${todayKey}: 投稿済みのためスキップします(id: ${last.id})。再投稿は --force。`); + process.exit(0); +} + +try { + const id = await postTweet(text); + state[todayKey] = [ + ...todaysPosts, + { id, at: new Date().toISOString(), weight: weight(text) }, + ]; + writeState(state); + log( + `${todayKey}: 投稿しました(id: ${id})。本日 ${todaysPosts.length + 1}/${DAILY_LIMIT} 件`, + ); +} catch (error) { + log(`${todayKey}: 投稿に失敗しました: ${error.message}`); + process.exit(1); +} |
