// その日の小平市の予定を 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); }