aboutsummaryrefslogtreecommitdiffhomepage
path: root/scripts
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-11-08 22:43:11 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-11-08 22:43:11 +0900
commitdd576c9dd9cb838ec4834e8bd4d3ba1df35084f3 (patch)
tree72e30a8d9d30d9d2603f5c6316dba0d90a66a70b /scripts
parent148cacfce5014932ada747da2a2bbda51b94aac7 (diff)
post-x-events.mjs: 当日の予定をXへ自動投稿するスクリプトを追加
その日の予定を全件掲載し、誤りがあり得る旨の注意書きを付けて投稿する。1日の投稿上限(既定3件、X_MAX_POSTS_PER_DAY で変更可)を設け、投稿履歴とログは logs/ に保存する。package.json に x:preview / x:post を追加。
Diffstat (limited to 'scripts')
-rw-r--r--scripts/post-x-events.mjs345
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(/&nbsp;/g, ' ')
+ .replace(/&amp;/g, '&')
+ .replace(/&lt;/g, '<')
+ .replace(/&gt;/g, '>')
+ .replace(/&quot;/g, '"')
+ .replace(/&#39;/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);
+}