目次
X(旧Twitter)の過去ポストをまとめて整理したい場合、X で自分のポストを1件ずつ削除する処理を組める。削除対象の一覧をXのアーカイブから作れば、一覧作成のためにAPIで投稿履歴を取得する必要もない。重要なのは、残す投稿を先に選び、認証先と削除対象を確認してから少数件ずつ進めることだ。
Into the Programでは、Xアカウント(@itp_media)の運用方針をセキュリティ情報の配信へ切り替える際、のスクリプトで912件、手動で20件のポストを削除した。最初の「Twitterはじめました。」は残している。本記事はこの運用記録をもとに、手順と費用の読み方を整理した実践解説である。
掲載コードは原稿の実装を見直した版で、削除前の確認と停止条件を追加している。当時の912件を処理したコードそのものではない。2026年10月31日に公式資料を確認し、構文確認と模擬応答による確認を行った。改訂版によるX実環境での削除試験は行っていない。
スポンサーリンク
X APIで削除できる範囲と先に決めること
対象は、自分のアカウントが作成したポストである。X公式のDelete PostリファレンスにあるDELETE /2/tweets/:idへ、選んだIDを順に送る。「一括削除」は複数の削除要求を繰り返す意味であり、1回のAPI呼び出しですべて消す処理ではない。他人の投稿や、外部に保存されたコピーまで消せる機能でもない。
削除したポストは復元できない。最初にアーカイブを保存し、固定ポスト、告知、問い合わせ対応など残す情報を決めておく。アーカイブがあっても元の投稿URLや反応を復元できるわけではない。公式のポスト削除の説明も確認してから実行したい。
また、ポスト削除とフォロー解除は分けて考える。Xの自動化ルールは、大量・過剰・無差別な自動フォロー/フォロー解除を認めていない。APIに解除用があることは、大量実行の許可を意味しない。このため、本記事の実行手順とコードはポスト削除に絞り、フォロー整理は公式画面で対象を確認して行う。
912件の削除にかかった時間と9.12ドルの費用
| 項目 | 今回の運用記録 |
|---|---|
| APIによるポスト削除 | 912件 |
| 手動によるポスト削除 | 20件 |
| 残したポスト | 最初の1件 |
| APIによる削除の所要時間 | 途中の中断を除いて約5時間 |
| ポスト削除のAPI費用 | 9.12ドル(912件×0.01ドル) |
今回の運用で、ポスト削除は1リクエスト当たり0.01ドルだった。1件につき1リクエストで912件を削除したため、ポスト削除分は9.12ドルとなる。手動で削除した20件は、このAPI処理件数に含まれない。同期間の利用総額は19.71ドルだったが、別用途のAPI利用も含むため、ポスト削除分の9.12ドルとは区別する。この単価は今回の利用時の記録であり、実行前には最新の料金を確認する。
X公式のUsage and Billingでは、クレジットを使う従量課金と、エンドポイントによって費用が異なることが説明されている。実行前にDeveloper Consoleで利用条件・費用・残高を確認し、少数件を処理した時点の使用状況を次の判断材料にする。同じアプリで自動投稿などを動かしている場合は、その利用も分けて確認する。
スポンサーリンク
準備するものはNode.jsと書き込み権限のある認証情報
実行には、サポート期間中のNode.jsと、自分のXアカウントを操作するためのが必要になる。コードは標準のfetchとnode:cryptoを使い、外部を追加しない。本記事のローカル確認にはNode.js 24.22.1を使用した。
は 1.0aのUser Contextを使う。X公式のエンドポイント別認証対応表では、ポスト削除はOAuth 1.0aのユーザー認証に対応する。アプリ専用のBearer Tokenだけで、自分のポストを削除する構成ではない。Developer Consoleでアプリのユーザー認証と書き込み権限を確認し、必要な権限を持つユーザー用トークンを用意する。
使用する値はAPI Key、API Key Secret、Access Token、Access Token Secretの4つ。コードへ直接書かず、環境変数に設定する。権限変更後に認証エラーが出る場合は、トークンの再発行・再認可も確認対象になる。認証方式の詳細はX公式のOAuth 1.0aユーザートークン取得手順を参照してほしい。
以下はPowerShellの例で、引用符内は自分の値に置き換える。PSReadLineを使う環境では、入力したコマンドが履歴ファイルに保存される。Windowsの標準的なConsoleHostでは保存先は$env:APPDATA\Microsoft\Windows\PowerShell\PSReadLine\ConsoleHost_history.txtで、実際の場所は(Get-PSReadLineOption).HistorySavePathで確認できる。秘密情報の除外機能はあるが、版や設定で挙動が異なるため、それだけに頼らない。キーを入力した画面や保存した設定ファイルにも注意する。作業フォルダーを公開へ入れず、共有画面に値を映さないようにする。
$env:X_API_KEY = "API Key"
$env:X_API_SECRET = "API Key Secret"
$env:X_ACCESS_TOKEN = "Access Token"
$env:X_ACCESS_SECRET = "Access Token Secret"
履歴に実際のキーを保存してしまった場合は、ほかのPowerShellを終了し、対象の履歴ファイルから該当行を取り除く。Clear-HistoryだけではPSReadLineの保存履歴は消えない。共有・同期先へ露出した可能性があれば、履歴の編集だけで済ませずキーを失効・再発行する。履歴の保存と除外の動作はMicrosoft公式のPSReadLine解説を確認してほしい。
アーカイブから削除対象を作り残すポストを除外する
まずXの設定からデータのアーカイブを申請し、ダウンロードしたZIPを展開する。X公式のアーカイブ取得手順では、準備に数日かかる場合があると説明されている。作業直前に申請するより、整理の計画を立てた時点で保存しておくと進めやすい。
掲載コードは、展開先にあるdata/account.jsとdata/tweets.js、またはtweets-partN.js形式を想定する。これは今回の原稿で使ったファイル構成に合わせた実装であり、将来のアーカイブ形式すべてに対応する保証はない。JavaScriptとして実行せず、代入記号の後ろをとして読み取る。形式が違う場合は停止し、実ファイルの構造を確認する。
node x-cleanup.mjs archive "C:\Users\you\Downloads\twitter-archive"
node x-cleanup.mjs plan
同じフォルダーにtweets.jsonが作られる。itemsから、残したいポストの項目を削除しておく。ここで消すのは削除対象リストの項目であり、X上のポストではない。JSONの区切りを壊さないように編集し、planで未処理件数と先頭10件を確認する。全対象はJSONを開いて点検する。
アーカイブは取得時点の記録なので、取得後の新しいポストや、その後の削除状態まで反映するとは限らない。リポスト解除も別の操作となるため、エラーになった項目を機械的に削除済みへ移さない。今回は最初のポストを対象リストから外し、残す方針にした。
スポンサーリンク
Node.jsのポスト削除スクリプト全文
次をx-cleanup.mjsとして保存する。planはAPIを呼ばず、checkは認証先を表示する。削除はアカウント名・--execute・1〜50件の実行上限を明示した場合だけ進む。複数のプロセスを同じフォルダーで同時実行する使い方は想定していない。
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const dir = path.dirname(fileURLToPath(import.meta.url));
const listFile = path.join(dir, 'tweets.json');
const doneFile = path.join(dir, 'done.json');
const [mode, arg, ...flags] = process.argv.slice(2);
const read = file => JSON.parse(fs.readFileSync(file, 'utf8'));
function save(file, data) {
fs.writeFileSync(file + '.tmp', JSON.stringify(data, null, 2));
fs.renameSync(file + '.tmp', file);
}
function readYtd(file) {
const src = fs.readFileSync(file, 'utf8');
const pos = src.indexOf('=');
if (pos < 0) throw new Error('未対応のアーカイブ形式: ' + file);
return JSON.parse(src.slice(pos + 1).trim().replace(/;\s*$/, ''));
}
function archive(root) {
if (!root) throw new Error('展開したアーカイブのフォルダーを指定してください');
const data = path.join(root, 'data');
const account = readYtd(path.join(data, 'account.js'))[0].account;
const user = { id: String(account.accountId), username: account.username };
const files = fs.readdirSync(data).filter(f => /^tweets(?:-part\d+)?\.js$/.test(f));
if (!files.length) throw new Error('tweets.js / tweets-partN.js がありません');
const items = files.flatMap(f => readYtd(path.join(data, f))).map(({ tweet: t }) => ({
id: t.id_str, created_at: t.created_at, text: t.full_text.slice(0, 100)
}));
if (!/^\d+$/.test(user.id) || !user.username || items.some(t => !/^\d+$/.test(t.id))) {
throw new Error('アカウントまたはポストIDの形式を確認してください');
}
if (fs.existsSync(listFile)) throw new Error('tweets.json が存在します。退避してから再作成してください');
save(listFile, { user, items });
console.log(`@${user.username}: ${items.length}件を保存しました`);
}
function loadPlan() {
const plan = read(listFile);
if (!/^\d+$/.test(plan.user?.id) || !Array.isArray(plan.items) ||
plan.items.some(t => !/^\d+$/.test(t.id))) throw new Error('tweets.json の形式が不正です');
const state = fs.existsSync(doneFile) ? read(doneFile) : { userId: plan.user.id, ids: [] };
if (state.userId !== plan.user.id || !Array.isArray(state.ids)) {
throw new Error('done.json のアカウントまたは形式が一致しません');
}
const done = new Set(state.ids);
const unique = [...new Map(plan.items.map(t => [t.id, t])).values()];
return { ...plan, done, rest: unique.filter(t => !done.has(t.id)) };
}
const enc = s => encodeURIComponent(s).replace(/[!'()*]/g,
c => '%' + c.charCodeAt(0).toString(16).toUpperCase());
function auth(method, url) {
const names = ['X_API_KEY', 'X_API_SECRET', 'X_ACCESS_TOKEN', 'X_ACCESS_SECRET'];
const values = names.map(n => process.env[n]);
if (values.some(v => !v)) throw new Error('4つの認証用環境変数を設定してください');
const [key, secret, token, tokenSecret] = values;
const oauth = {
oauth_consumer_key: key, oauth_token: token,
oauth_nonce: crypto.randomBytes(16).toString('hex'),
oauth_signature_method: 'HMAC-SHA1',
oauth_timestamp: String(Math.floor(Date.now() / 1000)), oauth_version: '1.0'
};
const params = Object.keys(oauth).sort().map(k => `${enc(k)}=${enc(oauth[k])}`).join('&');
const base = [method, enc(url), enc(params)].join('&');
oauth.oauth_signature = crypto.createHmac('sha1', `${enc(secret)}&${enc(tokenSecret)}`)
.update(base).digest('base64');
return 'OAuth ' + Object.keys(oauth).sort().map(k => `${enc(k)}="${enc(oauth[k])}"`).join(', ');
}
async function call(method, endpoint) {
const url = 'https://api.x.com/2' + endpoint;
const res = await fetch(url, {
method, headers: { Authorization: auth(method, url) }, signal: AbortSignal.timeout(30000)
});
if (res.status === 429) {
const reset = Number(res.headers.get('x-rate-limit-reset'));
const at = Number.isFinite(reset) && reset > 0 ? new Date(reset * 1000).toISOString() : '不明';
throw new Error(`429: 制限解除予定 ${at}。時刻と利用状況を確認して再実行してください`);
}
if (!res.ok) throw new Error(`HTTP ${res.status}: 対象ID・権限・利用状況・サービス状態を確認してください`);
return await res.json();
}
async function main() {
if (mode === 'archive') return archive(arg);
if (mode === 'plan') {
const p = loadPlan();
console.log(`@${p.user.username}: 未処理 ${p.rest.length}件 / 成功記録 ${p.done.size}件`);
console.table(p.rest.slice(0, 10));
return;
}
if (mode === 'check') {
const { data } = await call('GET', '/users/me');
if (!data?.id) throw new Error('認証ユーザーを確認できません');
console.log(`認証先: @${data.username}(${data.id})`);
return;
}
if (mode !== 'delete' || !arg || !flags.includes('--execute')) {
throw new Error('archive <folder> | plan | check | delete <username> --execute --limit=1');
}
const limitArg = flags.find(f => f.startsWith('--limit='));
const limit = Number(limitArg?.slice(8));
if (!Number.isInteger(limit) || limit < 1 || limit > 50) throw new Error('--limit は1〜50を指定してください');
const p = loadPlan();
const { data: current } = await call('GET', '/users/me');
if (current?.id !== p.user.id || current?.username?.toLowerCase() !== arg.replace(/^@/, '').toLowerCase()) {
throw new Error('指定名・認証先・アーカイブのアカウントが一致しません');
}
for (const item of p.rest.slice(0, limit)) {
console.log('削除要求: ' + item.id);
const body = await call('DELETE', `/tweets/${item.id}`);
if (body.data?.deleted !== true || body.errors?.length) throw new Error('削除成功を確認できません: ' + item.id);
p.done.add(item.id);
save(doneFile, { userId: p.user.id, ids: [...p.done] });
console.log('削除成功: ' + item.id);
}
console.log('今回の処理を終了しました。plan とX画面で残りを確認してください');
}
main().catch(e => { console.error(e.message); process.exitCode = 1; });
まず1件だけ削除して結果と料金を確認する
node x-cleanup.mjs check
node x-cleanup.mjs delete itp_media --execute --limit=1
node x-cleanup.mjs plan
itp_mediaは操作する自分のアカウント名に置き換える。スクリプトは、引数の名前、認証先、アーカイブのアカウントIDを照合する。最初の1件を実行したら、Xの画面でも対象が消えていること、残すポストが維持されていること、Developer Consoleの利用状況に想定外の増加がないことを確認する。
問題がなければ、次回の上限を明示して進める。たとえば--limit=10なら未処理の先頭10件が対象になる。成功が確認できたIDだけをdone.jsonへ保存するため、同じ一覧と成功記録を維持すれば次の未処理項目から進められる。成功記録にはアカウントIDも含め、別アカウントの記録を誤用した場合は停止する。
元の運用では、PCの再起動による中断後も成功記録を使って続きから処理できた。ただし、削除成功の応答を受け取った直後、記録を保存する前に停止すると、X側とローカルの状態に差が生じ得る。done.jsonだけで全処理の成否を断定せず、結果不明のIDは個別に確認する。
429や404が出たときは成功扱いにせず止める
X公式のレート制限表では、ポスト削除はユーザー当たり15分に50回とされている。当サイトの元の運用でも、おおむね50件ごとに待機が発生した。ただし、約5時間という所要時間は今回の記録であり、件数だけから完了時刻を保証するものではない。別の処理による利用や通信失敗も影響する。
掲載版では429が返ると、x-rate-limit-resetの時刻を表示して停止する。解除予定時刻と利用状況を確認してから再実行する方式だ。制限の種類や残高を確認せず、無期限に再試行する作りにはしていない。
401・403は認証情報、権限、利用条件を確認する。404も「すでに削除できた」とは決めず、対象IDと投稿種別を確かめる。タイムアウトや通信切断は、削除されていない証拠にはならない。ログの「削除要求」に出たIDを確認してから、その項目の扱いを決める。APIの成功応答だけでなく、data.deletedがtrueであることを成功記録の条件にしている。
オフラインの確認では、アーカイブの読込、一覧の上書き防止、事前表示、別アカウント時の停止、429・404時に成功を記録しないこと、成功応答時の記録を確かめた。実際の認証・請求・削除反映まで保証する試験ではないため、初回の1件確認を省略しないでほしい。
整理後は不要な権限を残さず運用方針を伝える
作業が終わったら、削除対象の残りとX上の表示を照合し、もう使わないトークンやアプリの権限を見直す。同じアプリを自動投稿にも使っている場合は、失効によって配信まで止めないよう、用途を確認してから変更する。アーカイブやJSONには投稿履歴とアカウント情報が含まれるため、保存先と保持期間も決めておく。
運用方針を変える場合は、プロフィールや固定ポストで配信内容を説明すると、既存のフォロワーにも意図が伝わりやすい。自動配信へ移行するなら、X公式の自動アカウントラベルの説明も確認したい。ラベルの表示は、大量フォロー解除など別の操作を許可するものではない。
今回の整理で役立ったのは、削除対象をアーカイブから作り、残す投稿を先に除外できたことだった。費用と権限を確認し、少数件の結果を見ながら進めれば、処理の途中でも判断を修正しやすい。アカウント全体を一度に消す前提にせず、残す情報と整理する情報を分けるところから始めたい。
スポンサーリンク








