// その日の小平市の予定を 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 # 投稿済みでも再投稿する // node scripts/post-x-events.mjs --check # 認証情報が通るか確認する(投稿しない) // node scripts/post-x-events.mjs --no-reply # X_REPLY_TO_ID を無視して通常のポストにする // node scripts/post-x-events.mjs --days 10 # 10日分の予定をまとめて投稿(既定は7日分) // // 週1回の運転: // 毎日タスクを動かしても、前回から X_POST_INTERVAL_DAYS 日(未設定なら --days の値) // 経っていなければ投稿しない。--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 // // 毎日の投稿を「あるポストへの返信」にすると、プロフィールの「ポスト」タブが // 予定投稿で埋まらない(返信は「返信」タブに入り、元ポストのスレッドに溜まる)。 // その場合は返信先のポストIDを設定する(--reply-to でも可): // X_REPLY_TO_ID=1234567890123456789 // // 予定データは 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 replyToArg = valueOf('--reply-to'); // ---------------------------------------------------------------- 日付 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(); } // 対象期間(--days N で N日分。既定は7日分=1週間) const rangeDays = (() => { const n = Number(valueOf('--days')); return Number.isInteger(n) && n > 0 && n <= 31 ? n : 7; })(); const endDate = new Date(target); endDate.setDate(endDate.getDate() + rangeDays - 1); const endKey = toKey(endDate.getFullYear(), endDate.getMonth() + 1, endDate.getDate()); const collect = (rows, type) => rows .map((row) => { const dates = entryDates(type, row.date, row.place).filter( (date) => date >= todayKey && date <= endKey, ); const time = startTime(row.place); return { type, dates, time, sortKey: time || '99:99', title: stripTags(row.title), venue: venueOf(row.place), }; }) .filter((entry) => entry.dates.length > 0); const entries = [ ...collect(readRows('events.json'), 'event'), ...(eventsOnly ? [] : collect(readRows('meetings.json'), 'meeting')), ]; // 1日分のときは、その日の分を時刻順に並べて使う const items = entries .filter((entry) => entry.dates.includes(todayKey)) .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 autoNote = '※この投稿は自動で作成しています(予定データから)。'; // 誤りがあり得ることを明示する 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${autoNote}\n\n${lines.join('\n')}${footer}`; } const dayLabel = (key) => { const [y, m, d] = key.split('-').map(Number); return `${m}月${d}日(${WEEKDAYS[new Date(y, m - 1, d).getDay()]})`; }; /** 複数日分のまとめ(日付ごとに見出しを付ける) */ function composeDigest() { if (entries.length === 0) return ''; // 4日以上続くもの(展示・受付など)は日別に繰り返さず末尾にまとめる const ongoing = entries.filter((entry) => entry.dates.length >= 4); const byDate = new Map(); for (const entry of entries) { if (ongoing.includes(entry)) continue; for (const date of entry.dates) { if (!byDate.has(date)) byDate.set(date, []); byDate.get(date).push(entry); } } const dayBlocks = [...byDate.keys()] .sort() .map((date) => { const list = byDate.get(date).sort((a, b) => a.sortKey.localeCompare(b.sortKey)); return `\n■ ${dayLabel(date)}\n${list.map((entry) => itemLine(entry)).join('\n')}`; }); const ongoingBlock = ongoing.length ? `\n\n【期間中】\n${ongoing .sort((a, b) => a.sortKey.localeCompare(b.sortKey)) .map((entry) => itemLine(entry)) .join('\n')}` : ''; const rangeWord = rangeDays === 7 ? '1週間' : `${rangeDays}日間`; return `【今後${rangeWord}の小平市イベント等予定(自動投稿)】\n${dayBlocks.join('\n')}${ongoingBlock}${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, replyTo) { 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 payload = { text }; // 返信先を指定すると、そのポストのスレッドにぶら下がる if (replyTo) payload.reply = { in_reply_to_tweet_id: replyTo }; const res = await fetch(url, { method: 'POST', headers: { Authorization: oauthHeader({ method: 'POST', url, ...creds }), 'Content-Type': 'application/json', }, body: JSON.stringify(payload), }); 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 ?? ''; } // ---------------------------------------------------------------- 認証の確認 /** 認証情報の形と、GET /2/users/me で実際に通るかを確かめる(投稿はしない) */ async function checkAuth() { const entries = [ ['X_API_KEY', process.env.X_API_KEY, 25], ['X_API_SECRET', process.env.X_API_SECRET, 50], ['X_ACCESS_TOKEN', process.env.X_ACCESS_TOKEN, 50], ['X_ACCESS_SECRET', process.env.X_ACCESS_SECRET, 45], ]; console.log('--- 認証情報の形(値は表示しません)---'); for (const [name, value, typical] of entries) { if (!value) { console.log(`${name}: 未設定`); continue; } const notes = []; if (/[^\x20-\x7e]/.test(value)) notes.push('全角文字などを含む'); if (value.length !== typical) notes.push(`長さが通常と違う(通常${typical}文字前後)`); console.log(`${name}: ${value.length}文字 ${notes.join(' / ')}`); } if (process.env.X_ACCESS_TOKEN) { const token = process.env.X_ACCESS_TOKEN; if (token.length > 80) { console.log( '※ この長さ(110文字前後)は「Bearer Token」か「OAuth 2.0 のアクセストークン」です。OAuth 1.0a の Access Token は「数字-英数字」の50文字前後です。', ); } else if (!token.includes('-')) { console.log('※ X_ACCESS_TOKEN は通常「数字-英数字」の形式です。値が違う可能性があります。'); } } 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, }; if (Object.values(creds).some((v) => !v)) { console.log('\n認証情報が欠けているため、通信テストは行いません。'); process.exitCode = 1; return; } console.log('\n--- GET /2/users/me で認証を確認 ---'); const url = 'https://api.x.com/2/users/me'; try { const res = await fetch(url, { headers: { Authorization: oauthHeader({ method: 'GET', url, ...creds }) }, }); const body = await res.text(); console.log(`HTTP ${res.status}`); console.log(body); if (res.ok) { console.log('\n✅ 認証は通っています(この状態で投稿が 401 なら権限かプランの問題)'); } else if (body.includes('client-not-enrolled')) { console.log('\n❌ アプリが Project に属していません(client-not-enrolled)。'); console.log(' X API v2 は「Project の中にあるアプリ」のキーでしか使えません。'); console.log(' 1. 新しいコンソール https://console.x.com にログインし、Developer Agreement を承諾'); console.log(' 2. 「New App」でアプリを作成(自動的に Project に入ります)'); console.log(' 3. そのアプリの User authentication settings で Read and write に設定'); console.log(' 4. Keys and tokens で Consumer Keys と Access Token/Secret を生成し、.env を差し替え'); console.log(' ※旧ポータル(developer.x.com/en/portal)の Standalone App のキーは v2 では使えません'); process.exitCode = 1; } else if (res.status === 401) { console.log('\n❌ 認証が通りません。X ポータルで次を確認してください:'); console.log(' 1. Keys and tokens → Access Token and Secret を Revoke → Generate し直す'); console.log(' (Bearer Token や OAuth 2.0 のトークンではありません)'); console.log(' 2. 権限を Read and write にしたあとの Generate か'); console.log(' 3. アプリが Project に入っているか(Standalone のままだと v2 は 401 になります)'); process.exitCode = 1; } else { process.exitCode = 1; } } catch (error) { console.log(`通信できませんでした: ${error.message}`); process.exitCode = 1; } } // ---------------------------------------------------------------- メイン async function main() { loadEnvFile(); if (has('--check')) { await checkAuth(); return; } // 1日の投稿上限(このスクリプト経由の投稿のみ。--force でも超えられない) const dailyLimit = (() => { 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]; }; /** いちばん新しい投稿日時(ISO文字列) */ const lastPostAt = (state) => { let latest = null; for (const entry of Object.values(state)) { for (const post of Array.isArray(entry) ? entry : [entry]) { if (post?.at && (!latest || post.at > latest)) latest = post.at; } } return latest; }; const text = rangeDays > 1 ? composeDigest() : composeText(); if (!text) { log(`${todayKey}: 本日の予定はありません。投稿しません。`); return; } // 返信先(スレッドの元ポスト)。未設定なら通常の投稿になる const replyTo = has('--no-reply') ? '' : (replyToArg ?? process.env.X_REPLY_TO_ID ?? '').trim(); if (replyTo && !/^\d+$/.test(replyTo)) { log(`返信先のID(${replyTo})が数字ではありません。X_REPLY_TO_ID を確認してください。`); process.exitCode = 1; return; } log( `--- ${todayKey} の投稿文(${rangeDays > 1 ? entries.length : items.length}件 / 重み ${weight(text)})${replyTo ? ` / 返信先 ${replyTo}` : ''} ---`, ); console.log(text); console.log('---'); const state = readState(); const todaysPosts = postsOf(state, todayKey); // 前回から一定日数経っていなければ投稿しない(毎日起動しても週1回に抑える) const intervalDays = (() => { const n = Number(process.env.X_POST_INTERVAL_DAYS); return Number.isFinite(n) && n > 0 ? Math.floor(n) : rangeDays; })(); const lastAt = lastPostAt(state); const elapsedDays = lastAt ? (Date.now() - new Date(lastAt).getTime()) / 86400000 : Infinity; if (!doPost) { log('表示のみ(投稿するには --post を付けてください)'); if (todaysPosts.length) { log(`※ この日は ${todaysPosts.length} 件投稿済みです(上限 ${dailyLimit} 件)`); } log( lastAt ? `※ 前回の投稿から ${elapsedDays.toFixed(1)} 日(間隔 ${intervalDays} 日)` : `※ 投稿履歴なし(間隔 ${intervalDays} 日)`, ); return; } if (elapsedDays < intervalDays && !force) { log( `前回の投稿から ${elapsedDays.toFixed(1)} 日しか経っていないため投稿しません(間隔 ${intervalDays} 日)。`, ); return; } if (todaysPosts.length >= dailyLimit) { log( `${todayKey}: 1日の上限 ${dailyLimit} 件に達しているため投稿しません(--force でも解除されません)。`, ); return; } if (todaysPosts.length > 0 && !force) { const last = todaysPosts[todaysPosts.length - 1]; log(`${todayKey}: 投稿済みのためスキップします(id: ${last.id})。再投稿は --force。`); return; } try { const id = await postTweet(text, replyTo); state[todayKey] = [ ...todaysPosts, { id, at: new Date().toISOString(), weight: weight(text) }, ]; writeState(state); log( `${todayKey}: 投稿しました(id: ${id})。本日 ${todaysPosts.length + 1}/${dailyLimit} 件`, ); } catch (error) { log(`${todayKey}: 投稿に失敗しました: ${error.message}`); // process.exit は fetch の後始末と競合して Windows で libuv の assert を出すため使わない process.exitCode = 1; } } await main();