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
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
|
// その日の小平市の予定を 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日分)
// node scripts/post-x-events.mjs --at 18:00 # 終了判定の時刻を指定(動作確認用)
// node scripts/post-x-events.mjs --replace # 前回の投稿を削除してから投稿する
//
// 今日の分は、終了時刻を過ぎた予定を除く(終了時刻が読めないものは残す)。
//
// 週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 <id> でも可):
// 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, normalizeDigits, 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 replaceMode = has('--replace');
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()]})`;
// 今日の分だけ「すでに終了した予定」を除くために使う(--at で時刻を差し替えられる)
const atArg = valueOf('--at');
const nowMinutes = (() => {
if (atArg) {
const [h, m] = atArg.split(':').map(Number);
if (Number.isFinite(h)) return h * 60 + (Number.isFinite(m) ? m : 0);
}
return now.getHours() * 60 + now.getMinutes();
})();
const isTodayTarget =
toKey(now.getFullYear(), now.getMonth() + 1, now.getDate()) === todayKey;
// ---------------------------------------------------------------- 予定の抽出
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();
}
/**
* 時間・会場セルから開始・終了時刻(0時からの分)を読む。
* 読み取れないときは null。終了時刻が「未定」のものは unknown を立てて残す。
*/
function timeRange(placeText) {
const text = normalizeDigits(stripTags(placeText));
const unknown = /未定|終日/.test(text);
const startMatch = text.match(/(午前|午後)?\s*(\d{1,2})時(?:(\d{1,2})分|半)?/);
if (!startMatch) return { start: null, end: null, unknown };
const toMinutes = (meridiem, hourText, minuteText, whole) => {
let hour = Number(hourText);
if (meridiem === '午後' && hour < 12) hour += 12;
if (meridiem === '午前' && hour === 12) hour = 0;
const minute = minuteText ? Number(minuteText) : whole.includes('半') ? 30 : 0;
return hour * 60 + minute;
};
const start = toMinutes(startMatch[1], startMatch[2], startMatch[3], startMatch[0]);
const rest = text.slice(startMatch.index + startMatch[0].length);
const startMeridiem = startMatch[1] ?? null;
// 「正午まで」
if (/正午まで/.test(rest)) return { start, end: 12 * 60, unknown };
// 「午後3時まで」「午後3時45分まで」
const until = rest.match(/(?:午前|午後)?\s*(\d{1,2})時(?:(\d{1,2})分|半)?\s*まで/);
if (until) {
return { start, end: toMinutes(startMeridiem, until[1], until[2], until[0]), unknown };
}
// 「から午後3時」「〜3時」
const range = rest.match(/(?:から|〜|~|[-−—])\s*(午前|午後)?\s*(\d{1,2})時(?:(\d{1,2})分|半)?/);
if (range) {
const meridiem = range[1] ?? startMeridiem;
return { start, end: toMinutes(meridiem, range[2], range[3], range[0]), unknown };
}
// 「午後1時ごろ解散」
const dismiss = rest.match(/(?:午前|午後)?\s*(\d{1,2})時(?:(\d{1,2})分|半)?\s*(?:ごろ)?(?:に)?解散/);
if (dismiss) {
return { start, end: toMinutes(startMeridiem, dismiss[1], dismiss[2], dismiss[0]), unknown };
}
return { start, end: null, unknown };
}
// 対象期間(--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);
const range = timeRange(row.place);
return {
type,
dates,
time,
startMinutes: range.start,
endMinutes: range.end,
unknownEnd: range.unknown,
sortKey: time || '99:99',
title: stripTags(row.title),
venue: venueOf(row.place),
};
})
.filter((entry) => entry.dates.length > 0);
/** 今日の分で、すでに終了しているか(終了時刻が読めないものは残す) */
const endedOnToday = (entry) => {
if (!isTodayTarget) return false;
if (entry.startMinutes === null || entry.unknownEnd) return false;
// 終了時刻が書かれていない場合は2時間と仮定する
const end = entry.endMinutes ?? entry.startMinutes + 120;
return nowMinutes > end;
};
const entries = [
...collect(readRows('events.json'), 'event'),
...(eventsOnly ? [] : collect(readRows('meetings.json'), 'meeting')),
];
// 1日分のときは、その日の分を時刻順に並べて使う
const items = entries
.filter((entry) => entry.dates.includes(todayKey))
.filter((entry) => !endedOnToday(entry))
.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 = isTodayTarget;
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 (date === todayKey && endedOnToday(entry)) continue;
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(', ')}`;
}
function credentials() {
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 に設定してください。`,
);
}
return creds;
}
async function postTweet(text, replyTo) {
const creds = credentials();
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 ?? '';
}
/** 自分の投稿を削除する(--replace 用) */
async function deleteTweet(id) {
const creds = credentials();
const url = `https://api.x.com/2/tweets/${id}`;
const res = await fetch(url, {
method: 'DELETE',
headers: { Authorization: oauthHeader({ method: 'DELETE', url, ...creds }) },
});
const body = await res.text();
if (!res.ok) throw new Error(`X API エラー ${res.status}: ${body}`);
}
// ---------------------------------------------------------------- 認証の確認
/** 認証情報の形と、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);
// --replace: 直近の前回投稿を削除してから投稿する(プロフィールに1件だけ残す)
const replaceTargets = (() => {
if (!replaceMode) return [];
const dates = Object.keys(state)
.filter((date) => date !== todayKey)
.sort();
const lastDate = dates[dates.length - 1];
if (!lastDate) return [];
return postsOf(state, lastDate)
.filter((post) => post.id)
.map((post) => ({ date: lastDate, id: post.id }));
})();
// 前回から一定日数経っていなければ投稿しない(毎日起動しても週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} 日)`,
);
if (replaceMode) {
log(
replaceTargets.length
? `※ --replace: 前回の投稿 ${replaceTargets.length} 件(${replaceTargets[0].date})を削除してから投稿します`
: '※ --replace: 削除する前回の投稿はありません',
);
}
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;
}
// 前回の投稿を削除(失敗しても投稿は続行する)
for (const target of replaceTargets) {
try {
await deleteTweet(target.id);
const remaining = postsOf(state, target.date).filter((post) => post.id !== target.id);
if (remaining.length) state[target.date] = remaining;
else delete state[target.date];
writeState(state);
log(`前回の投稿を削除しました(${target.date} / id: ${target.id})`);
} catch (error) {
log(`前回の投稿の削除に失敗しました(id: ${target.id}): ${error.message}`);
}
}
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();
|