From 27b5325f2d89b3a714059719e4c4cbe931b5b1a3 Mon Sep 17 00:00:00 2001 From: Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> Date: Sun, 8 Nov 2026 23:54:04 +0900 Subject: post-x-events.mjs: 週1回分の予定投稿と認証確認に対応 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 既定を「今後1週間分」(--days 7)にし、日付ごとの見出しと「期間中」のまとめを追加した。前回から7日経っていなければ投稿しない間隔判定(X_POST_INTERVAL_DAYS)と1日3件の上限を設け、--check で認証を確認できるようにした。返信投稿(X_REPLY_TO_ID / --reply-to)と、掲載漏れがあり得る旨の注意書きも追加。 --- package.json | 1 + scripts/post-x-events.mjs | 328 +++++++++++++++++++++++++++++++++++++--------- 2 files changed, 268 insertions(+), 61 deletions(-) diff --git a/package.json b/package.json index 4c41862..b6f377b 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "indexnow": "node scripts/indexnow.mjs", "x:preview": "node --no-warnings scripts/post-x-events.mjs", "x:post": "node --no-warnings scripts/post-x-events.mjs --post", + "x:check": "node --no-warnings scripts/post-x-events.mjs --check", "library:update": "node scripts/update-library-dates.mjs", "library:apply": "node scripts/update-library-dates.mjs --apply", "park-editor": "node scripts/park-editor.mjs", 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 でも可): +// 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 ?? ''; } -// ---------------------------------------------------------------- メイン - -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; -})(); +/** 認証情報の形と、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], + ]; -/** その日の投稿履歴(旧形式=1日1件のオブジェクトも受け付ける) */ -const postsOf = (state, key) => { - const entry = state[key]; - if (!entry) return []; - return Array.isArray(entry) ? entry : [entry]; -}; + 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 text = composeText(); + 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; + } -if (!text) { - log(`${todayKey}: 本日の予定はありません。投稿しません。`); - process.exit(0); + 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; + } } -log(`--- ${todayKey} の投稿文(${items.length}件 / 重み ${weight(text)})---`); -console.log(text); -console.log('---'); +// ---------------------------------------------------------------- メイン -const state = readState(); -const todaysPosts = postsOf(state, todayKey); +async function main() { + loadEnvFile(); -if (!doPost) { - log('表示のみ(投稿するには --post を付けてください)'); - if (todaysPosts.length) { - log(`※ この日は ${todaysPosts.length} 件投稿済みです(上限 ${DAILY_LIMIT} 件)`); + if (has('--check')) { + await checkAuth(); + return; } - process.exit(0); -} -if (todaysPosts.length >= DAILY_LIMIT) { - log( - `${todayKey}: 1日の上限 ${DAILY_LIMIT} 件に達しているため投稿しません(--force でも解除されません)。`, - ); - process.exit(0); -} + // 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]; + }; -if (todaysPosts.length > 0 && !force) { - const last = todaysPosts[todaysPosts.length - 1]; - log(`${todayKey}: 投稿済みのためスキップします(id: ${last.id})。再投稿は --force。`); - 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; + }; + + 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; + } -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} 件`, + `--- ${todayKey} の投稿文(${rangeDays > 1 ? entries.length : items.length}件 / 重み ${weight(text)})${replyTo ? ` / 返信先 ${replyTo}` : ''} ---`, ); -} catch (error) { - log(`${todayKey}: 投稿に失敗しました: ${error.message}`); - process.exit(1); + 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(); -- cgit v1.3.1