aboutsummaryrefslogtreecommitdiffhomepage
path: root/scripts/post-x-events.mjs
blob: a776fc0e8d32d83051db89ee36a78ccc6acf50a3 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
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);
}