aboutsummaryrefslogtreecommitdiffhomepage
path: root/scripts/post-x-events.mjs
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-11-08 23:54:04 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-11-08 23:54:04 +0900
commit27b5325f2d89b3a714059719e4c4cbe931b5b1a3 (patch)
treeeeb4896bf97ebd462b01f3cab502e55bd6972df5 /scripts/post-x-events.mjs
parentdd576c9dd9cb838ec4834e8bd4d3ba1df35084f3 (diff)
post-x-events.mjs: 週1回分の予定投稿と認証確認に対応
既定を「今後1週間分」(--days 7)にし、日付ごとの見出しと「期間中」のまとめを追加した。前回から7日経っていなければ投稿しない間隔判定(X_POST_INTERVAL_DAYS)と1日3件の上限を設け、--check で認証を確認できるようにした。返信投稿(X_REPLY_TO_ID / --reply-to)と、掲載漏れがあり得る旨の注意書きも追加。
Diffstat (limited to 'scripts/post-x-events.mjs')
-rw-r--r--scripts/post-x-events.mjs326
1 files changed, 266 insertions, 60 deletions
diff --git a/scripts/post-x-events.mjs b/scripts/post-x-events.mjs
index a776fc0..0465cac 100644
--- a/scripts/post-x-events.mjs
+++ b/scripts/post-x-events.mjs
@@ -6,6 +6,13 @@
// 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 でも超えられない)。
@@ -19,6 +26,11 @@
// 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() を使う。
//
@@ -49,6 +61,7 @@ const doPost = has('--post');
const force = has('--force');
const eventsOnly = has('--events-only');
const dateArg = valueOf('--date');
+const replyToArg = valueOf('--reply-to');
// ---------------------------------------------------------------- 日付
@@ -104,26 +117,42 @@ function venueOf(placeText) {
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
- .filter((row) => entryDates(type, row.date, row.place).includes(todayKey))
.map((row) => {
+ const dates = entryDates(type, row.date, row.place).filter(
+ (date) => date >= todayKey && date <= endKey,
+ );
const time = startTime(row.place);
- const venue = venueOf(row.place);
return {
type,
+ dates,
time,
sortKey: time || '99:99',
title: stripTags(row.title),
- venue,
+ venue: venueOf(row.place),
};
})
- .sort((a, b) => a.sortKey.localeCompare(b.sortKey));
+ .filter((entry) => entry.dates.length > 0);
-const items = [
+const entries = [
...collect(readRows('events.json'), 'event'),
...(eventsOnly ? [] : collect(readRows('meetings.json'), 'meeting')),
-].sort((a, b) => a.sortKey.localeCompare(b.sortKey));
+];
+
+// 1日分のときは、その日の分を時刻順に並べて使う
+const items = entries
+ .filter((entry) => entry.dates.includes(todayKey))
+ .sort((a, b) => a.sortKey.localeCompare(b.sortKey));
// ---------------------------------------------------------------- 投稿文の組み立て
@@ -141,9 +170,11 @@ function weightPlain(text) {
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)の投稿上限。よほどでない限り超えないが、念のため見張る
@@ -172,7 +203,45 @@ function composeText() {
used += addition;
}
- return `${header}\n\n${lines.join('\n')}${footer}`;
+ 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}`;
}
// ---------------------------------------------------------------- 出力・記録
@@ -245,7 +314,7 @@ function oauthHeader({ method, url, apiKey, apiSecret, accessToken, accessSecret
.join(', ')}`;
}
-async function postTweet(text) {
+async function postTweet(text, replyTo) {
loadEnvFile();
const creds = {
apiKey: process.env.X_API_KEY,
@@ -263,13 +332,16 @@ async function postTweet(text) {
}
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({ text }),
+ body: JSON.stringify(payload),
});
const body = await res.text();
if (!res.ok) throw new Error(`X API エラー ${res.status}: ${body}`);
@@ -277,69 +349,203 @@ async function postTweet(text) {
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;
+ }
+}
+
// ---------------------------------------------------------------- メイン
-loadEnvFile();
+async function main() {
+ 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;
-})();
+ if (has('--check')) {
+ await checkAuth();
+ return;
+ }
-/** その日の投稿履歴(旧形式=1日1件のオブジェクトも受け付ける) */
-const postsOf = (state, key) => {
- const entry = state[key];
- if (!entry) return [];
- return Array.isArray(entry) ? entry : [entry];
-};
+ // 1日の投稿上限(このスクリプト経由の投稿のみ。--force でも超えられない)
+ const dailyLimit = (() => {
+ const n = Number(process.env.X_MAX_POSTS_PER_DAY);
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 3;
+ })();
-const text = composeText();
+ /** その日の投稿履歴(旧形式=1日1件のオブジェクトも受け付ける) */
+ const postsOf = (state, key) => {
+ const entry = state[key];
+ if (!entry) return [];
+ return Array.isArray(entry) ? entry : [entry];
+ };
-if (!text) {
- log(`${todayKey}: 本日の予定はありません。投稿しません。`);
- process.exit(0);
-}
+ /** いちばん新しい投稿日時(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;
+ };
-log(`--- ${todayKey} の投稿文(${items.length}件 / 重み ${weight(text)})---`);
-console.log(text);
-console.log('---');
+ const text = rangeDays > 1 ? composeDigest() : composeText();
-const state = readState();
-const todaysPosts = postsOf(state, todayKey);
+ if (!text) {
+ log(`${todayKey}: 本日の予定はありません。投稿しません。`);
+ return;
+ }
-if (!doPost) {
- log('表示のみ(投稿するには --post を付けてください)');
- if (todaysPosts.length) {
- log(`※ この日は ${todaysPosts.length} 件投稿済みです(上限 ${DAILY_LIMIT} 件)`);
+ // 返信先(スレッドの元ポスト)。未設定なら通常の投稿になる
+ 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;
}
- process.exit(0);
-}
-if (todaysPosts.length >= DAILY_LIMIT) {
log(
- `${todayKey}: 1日の上限 ${DAILY_LIMIT} 件に達しているため投稿しません(--force でも解除されません)。`,
+ `--- ${todayKey} の投稿文(${rangeDays > 1 ? entries.length : items.length}件 / 重み ${weight(text)})${replyTo ? ` / 返信先 ${replyTo}` : ''} ---`,
);
- process.exit(0);
-}
+ console.log(text);
+ console.log('---');
-if (todaysPosts.length > 0 && !force) {
- const last = todaysPosts[todaysPosts.length - 1];
- log(`${todayKey}: 投稿済みのためスキップします(id: ${last.id})。再投稿は --force。`);
- process.exit(0);
-}
+ const state = readState();
+ const todaysPosts = postsOf(state, todayKey);
-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);
+ // 前回から一定日数経っていなければ投稿しない(毎日起動しても週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();