速報

プレステージで最大約550万件の情報持ち出し|ロードサービスは代替運用で継続

CATEGORY

Xの過去ポストをAPIで削除する方法|Node.jsスクリプトと料金の実例

※本ページのリンクにはプロモーションが含まれています。

アーカイブから取り出した過去ポストをNode.jsの処理で残す投稿と削除対象へ選別する概念イラスト

目次

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公式の自動アカウントラベルの説明も確認したい。ラベルの表示は、大量フォロー解除など別の操作を許可するものではない。

今回の整理で役立ったのは、削除対象をアーカイブから作り、残す投稿を先に除外できたことだった。費用と権限を確認し、少数件の結果を見ながら進めれば、処理の途中でも判断を修正しやすい。アカウント全体を一度に消す前提にせず、残す情報と整理する情報を分けるところから始めたい。

スポンサーリンク

参考資料

X API:Delete Post

X API:認証方式の対応表

X API:OAuth 1.0aユーザートークン

X API:レート制限

X API:Usage and Billing

Xヘルプ:アーカイブのダウンロード

Xヘルプ:ポストの削除

Xヘルプ:自動化ルール

Xヘルプ:自動アカウントラベル

この記事を書いた人

Ryohei

Webエンジニア / アプリ開発者 / 技術ブログ運営者

福岡のWeb制作会社に勤務するWebエンジニアです。10年以上にわたりWeb制作・開発に携わり、ヒアリングや進行管理から、コーディング、CMSの構築、システム開発、サーバーの構築・保守・運用まで、Webサイト制作の工程を幅広く担当しています。Webサイトは、これまでに100以上の構築・保守を手がけてきました。

個人でもWebサイトの制作・改修やWebアプリの開発・運用を行い、AIを活用した開発にも取り組んでいます。近年はWebセキュリティにも注力しています。

2015年から技術メディア「Into the Program」を運営し、実務や開発で得た知識をもとに、プログラミングの実装方法やWebサイトの運用・セキュリティ情報を発信しています。

主な分野
Webサイト構築・保守・運用 / HTML/CSS / JavaScript / PHP / WordPress / サーバー構築・保守・運用 / アプリ開発 / セキュリティ対策
資格・試験合格
情報セキュリティマネジメント試験
基本情報技術者試験
AWS認定ソリューションアーキテクト アソシエイト
PHP8技術者認定ウィザード
メディア運営
2015年〜

記事の作成・更新について

セキュリティ記事は、ベンダーの公式アドバイザリやCVE・JVNなどの一次情報を参照し、出典と情報確認日を記載します。実環境での検証を行った場合は、その環境と確認範囲を明示します。重要な追加情報や誤りを把握した場合は、内容を確認して追記・訂正します。

関連する記事

おすすめの書籍

キーワードから探す

スポンサーリンク

JSON

データを「名前と値」の組で表す、軽くて読みやすいテキストの形式。APIのデータのやりとりなどで広く使われる。

詳しく見る

セッション

利用者とシステムの一連のやり取りを、ひとまとまりとして扱う単位。Webでは、ログイン状態などをページをまたいで保つために使う。

詳しく見る

リポジトリ

ソースコードなどのファイルと、その変更の履歴をまとめて保管する場所。Gitで管理し、GitHubなどで共有する。

詳しく見る

OAuth

パスワードを渡さずに、外部のアプリにアカウントの一部の操作を許可するための仕組み(認可の標準)。

詳しく見る

認証

アクセスしてきた相手が本人であることを確かめる仕組み。パスワードや生体情報、電子証明書などを使う。

詳しく見る

パッケージ

ソフトウェアを配布・インストールしやすいように、プログラムと必要な情報をひとまとめにしたもの。npmやComposerなどで管理する。

詳しく見る

認証情報

本人であることを証明するための情報。IDとパスワード、APIキー、アクセストークン、秘密鍵など。

詳しく見る

エンドポイント

APIでリクエストを送る先のURL。また、ネットワークにつながるパソコンやスマートフォンなどの端末のことも指す。

詳しく見る

Node.js

JavaScriptをブラウザーの外、主にサーバーやパソコンで動かすための実行環境。

詳しく見る

API

ソフトウェアやサービスの機能を、ほかのプログラムから決まった方法で呼び出して使えるようにした窓口。

詳しく見る