Puppeteerを使ってX(旧Twitter)へのポストを自動化する

x.comこと旧TwitterにてAPIの利用規制強化が発表されて、高額の利用料を払う必要になり、無償版の枠だけで自動投稿運用してる人も多いでしょう(データの取得は有料であるため)。しかし、さらに無償版は規制強化されたようで、本来月間500ポストできるハズが、50ポストまで?みたいなポストが出回っています。

何はともあれ、Xの規制はどんどん強化されて無償での利用が厳しくなっていってるのは事実なので、APIを使わずにポストする手段を構築しました。例の如くPuppeteerを使っての自動操縦です。

今回利用するツール等

久しぶりにNode.jsを使っていますが、だいぶバージョンも進んで、単一EXE化する機能pkgというnpmを使っていました)が備わったようで、今研究中。ただデフォルトだとindex.jsしか取り込まれずNPMモジュールを含められないので、とりあえずNode.jsだけでXに対してポストの自動化を構築することにしました。

コマンドラインでポスト内容を受け取って、投稿するのを自動化します。

作成支援する拡張機能はChromeのDeveloper Consoleに取り込まれていますが、正直あのコードのまま動かせるか?といったら問題も多いのでかなり手直ししています。また、今回はmacOSで作業を行なっています。

Node.jsとPuppeteerでChromeを自動操縦する

事前準備

デフォルトプロファイルでログイン

今回のPuppeteerでの自動化ではログイン自動化は装備していません。Puppeteer-coreで普段使ってるChromeを起動して使うようにしているため、ログイン処理をいちいち行わずに動かすために、ChromeのデフォルトプロファイルでXへのログイン→2段階認証済ませるというところまで完了しておく必要があります。

ターミナルを開いて以下のコマンドラインを実行し、開いたChromeでXへログインを完了させておいてください。ユーザ名の場所は自身のログインユーザ名を入れてください。

//macOSの場合
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --args --profile-directory=Default --user-data-dir=/Users/ユーザ名/Library/Application\ Support/Google/Chrome/Default

//Windowsの場合
"C:/Program Files/Google/Chrome/Application/chrome.exe" --args --profile-directory=Default --user-data-dir="C:/Users/ユーザ名/AppData/Local/Google/Chrome/User Data"

※但し、Windowsの場合、上記の場所にインストールされているとは限らないので要注意。(C:/Program Files (x86)/Google/Chrome/Application/chrome.exeというケースがある)。インストール先を見つけてパスは書き換えてください。

これで、Puppeteer-coreで起動したときも同じプロファイルを使って操作されるので、ログイン画面が出ることがなくなります。ユーザに毎回入力してもらい、ログイン完了まで待機させるという方法もあります。

うまくいかない場合は、初回だけPuppeteerで起動時にログインし、もう一度Puppeteerで実行すると良いでしょう。ログイン情報を利用して再度ログインは不要になります。

Puppeteerで途中でユーザに入力してもらうようにする

プロジェクトの作成

Node.jsが入ってるマシンに於いてまずはプロジェクトを作成し、必要なモジュールを追加します。ターミナルから作業を行います。あらかじめプロジェクトを格納するフォルダの作成と、ターミナルでそこまで移動しておきましょう。

npm init -y
npm i puppeteer-core
npm i command-line-args

index.jsを手動で作成して、次項のコードを記述します。

入力欄等のselectorの取得方法

ソースコードの中にあるselectorやsubmitmanという変数に入れてる入力欄やボタンの長々しい値。これはHTMLをレンダリングした結果表示されてる要素までのフルパスみたいなものです。

IDやClassがついていれば特定がしやすいのですが、Webサービスの場合動的に生成してるためついていないことも多い。そこでこのセレクターというものを使うのですが取得方法は以下のようになります。

  1. デフォルトプロファイルでChromeを起動する
  2. Xのポスト画面まで移動する
  3. 入力欄である「いまどうしてる?」の欄をクリックして、右クリック→検証をクリック
  4. 右サイドバーが開いたら、elementが開かれてるので対象の要素が水色で選択されてる状態になります。
  5. その水色の部分を右クリック→Copy→Selectorをクリック
  6. コピーした内容がselectorなので入力欄やボタンなどの要素指定として使用する

図:Selectorを入手する方法

ソースコード

'use strict';

//使用するモジュール
const puppeteer = require('puppeteer-core');
const fs = require('fs');
const path = require("path");
const shell = require('child_process').exec;
const commandLineArgs = require('command-line-args');
const os = require('os');

//OS判定
const osver = process.platform;

//ログインユーザ名を取得 
const username = os.userInfo().username;

//Chromeのパスを規定する
//const homedir = require('os').homedir();
let chromepath = "";
let userDataDir = "";
if(osver == "darwin"){
    //macOSの場合
    chromepath = "/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome";
    userDataDir = `/Users/${username}/Library/'Application Support'/Google/Chrome/Default`
}else{
    //Windowsの場合
    userDataDir = `C:\\Users\\${username}\\AppData\\Local\\Google\\Chrome\\User Data`;

    //各種ブラウザのパス
    const userHome = process.env[process.platform == "win32" ? "USERPROFILE" : "HOME"];
    const kiteipath = "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe";
    const temppath = path.join(userHome, "AppData\\Local\\Google\\Chrome\\Application\\chrome.exe");
    const edgepath = "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe";


    if (fs.existsSync(kiteipath)) {
        chromepath = kiteipath
    } else {
        if (fs.existsSync(temppath)) {
          chromepath = temppath;
        } else {
          //Chromium Edgeの場合に対応
          if(fs.existsSync(edgepath)){
              chromepath = edgepath;
          }else{
              //IEを起動してChromeのインストールを促す   
              shellexec('start "" "iexplore" "https://www.google.co.jp/chrome/"')
              return;
          }
        }
    }
}

//Chromeの存在チェック
if(fs.existsSync(chromepath)){
    console.log("OK")
}else{
    console.log("Chromeがインストールされてないみたいですよ")
    return;
}

//コマンドライン引数を取得
const optionDefinitions = [
	{
		name: 'gettweet',
		alias: 'g',
		type: String
	}
];
const args = commandLineArgs(optionDefinitions);

//puppeteer実行
main();

//Puppeteerで自動操縦する
async function main() {
    //puppeteer-coreを起動する
    //一回defaultプロファイルでTwitterにログインしておく必要がある
    const browser = await puppeteer.launch({
		headless: false,
		executablePath: chromepath,
		//slowMo:100,
        userDataDir: userDataDir
	});

    //ページ設定とタイムアウトデフォルト値設定
    const page = await browser.newPage();
    const timeout = 5000;
    page.setDefaultTimeout(timeout);

    //自動化対策に対する対策
    await page.evaluateOnNewDocument(() => {
        Object.defineProperty(navigator, 'webdriver', ()=>{});
        delete navigator.__proto__.webdriver;
    });

    //Xの投稿ページを開く
    const targetPage = page;
    await targetPage.goto('https://x.com/compose/post');

    //Viewportを設定
    await targetPage.setViewport({
        width: 885,
        height: 754
    })

    //文字を送り込む
    const selector = "#layers > div:nth-child(2) > div > div > div > div > div > div.css-175oi2r.r-1ny4l3l.r-18u37iz.r-1pi2tsx.r-1777fci.r-1xcajam.r-ipm5af.r-g6jmlv.r-1habvwh > div.css-175oi2r.r-1wbh5a2.r-htvplk.r-1udh08x.r-1867qdf.r-rsyp9y.r-1pjcn9w.r-1potc6q > div > div > div > div:nth-child(3) > div.css-175oi2r.r-kemksi.r-1h8ys4a.r-slzeqm.r-ly4kne > div:nth-child(1) > div > div > div > div.css-175oi2r.r-18u37iz.r-184en5c > div.css-175oi2r.r-1iusvr4.r-16y2uox.r-1777fci.r-1h8ys4a.r-1bylmt5.r-13tjlyg.r-7qyjyx.r-1ftll1t > div > div > div > div > div > div > div.css-175oi2r.r-16y2uox.r-bnwqim.r-13qz1uu.r-1g40b8q > div > div > div > div > div > div.css-175oi2r.r-1wbh5a2.r-16y2uox > div > div > div > div > div > div.DraftEditor-editorContainer > div > div > div > div"
    await targetPage.waitForSelector(selector)
    await targetPage.type(selector, args.gettweet, {delay: 5});

    //送信する
    const submitman = "#layers > div:nth-child(2) > div > div > div > div > div > div.css-175oi2r.r-1ny4l3l.r-18u37iz.r-1pi2tsx.r-1777fci.r-1xcajam.r-ipm5af.r-g6jmlv.r-1habvwh > div.css-175oi2r.r-1wbh5a2.r-htvplk.r-1udh08x.r-1867qdf.r-rsyp9y.r-1pjcn9w.r-1potc6q > div > div > div > div:nth-child(3) > div.css-175oi2r.r-kemksi.r-1h8ys4a.r-slzeqm.r-ly4kne > div:nth-child(1) > div > div > div > div.css-175oi2r.r-kemksi.r-jumn1c.r-xd6kpl.r-gtdqiz.r-ipm5af.r-184en5c > div:nth-child(2) > div > div > div > button.css-175oi2r.r-sdzlij.r-1phboty.r-rs99b7.r-lrvibr.r-1cwvpvk.r-2yi16.r-1qi8awa.r-3pj75a.r-1loqt21.r-o7ynqc.r-6416eg.r-1ny4l3l";
    await targetPage.click(submitman);

    //待機する
    await page.waitForNavigation({waitUntil: ['load', 'networkidle2']}),
    sleep(2000);
    
    //ブラウザを閉じる
	await browser.close()
}

//スリープ用関数
function sleep(milliSeconds) {
    return new Promise((resolve, reject) => {
      setTimeout(resolve, milliSeconds);
    });
}
  • 起動時に利用してるOS判定して、Windows/macOSのそれぞれのChromeが存在するパスを取得しています。同時に、ユーザのデータディレクトリを特定しています。
  • ログインユーザ名を取得してユーザのデータディレクトリ特定に利用しています。
  • Commandline-argsを使って、引数オプションが-gの場合、引数を取得してポストする内容として利用します。
  • puppeteer-launchが重要で、ここでDefaultプロファイルで使ったログイン情報を利用するために、userDataDirを使っています
  • page.evaluateOnNewDocumentを利用して、navigator.webdriver=trueだった場合アクセス拒否されるケースを考慮してロボットであることを隠蔽します。
  • 以降はポスト画面表示、引数で得た文字列を入力、ポスト投稿をクリックを順次実行する
  • 最後にポスト後のネットワークアイドル状態を取得して、表示終了していればブラウザをクローズする
  • headlessをTrueにすると何故か入力欄の捕捉に失敗するのでFalseとしています。

通常のブラウザで入力欄や投稿ボタンのセレクタを取得するとreact・・・といった文字が出てくるのですが、Default Profileの場合と違った内容なので、嵌りました。入力欄のセレクタ等取得する場合は、Default ProfileでChromeを起動してから調査するべきでしょう。

また、slowMoを指定してるとpage.typeが異様に遅いので今回はコメントアウトしています。

使い方

まだ単独EXE化していないので、ターミナルから作業を行います。

  1. ターミナルを起動する
  2. index.jsのあるディレクトリまで移動する
  3. ターミナルで以下のコマンドを実行する
    node index.js -g "ここにポストしたいワードを入れる"
  4. Chromeが自動操縦されて自動ポストされる

あっという間に起動してポストしてChromeが終了します。X側の規制により同じ文言のポストを連続して投稿することはできませんので要注意です。

Cloud Run Functionを使って同じくPuppeteerをGCP上で動かして操作ができますので、サーバーレスでXへのポストの自動化が可能です。

以下のエントリーを参考に構築してみてください。

図:自動実行されてるChromeの様子

PuppeteerでXにポスト自動化

動画:自動投稿されてる様子

Puppeteerでウェブサイトをスクレイピング

単一実行ファイルを作成する

これまで、Node.jsで作成したアプリを単一の実行バイナリ化する為に、pkgnexeを使って単一実行ファイルを作成していたのですが、Node.js18にてこの単一実行ファイル作成機能が標準装備されて、pkgについては開発が終わってしまいました。ということで、今回この「Single executable applications」という機能を使って単一実行ファイル化してみました。

但し、Nodeのドキュメントそのままでは追加した外部モジュール(Puppeteerやcommandline-argsなどのnpm package)が組み込まれないので、そのままでは残念なファイルが出来上がります。

追加のモジュールのインストール

このnpm packageを含んだ状態で単一実行ファイルを作るには、esbuildが必要になります。といっても自分はTypeScript使いではないのですが、ターミナルから以下のコマンドを実行してインストールしておきます。

npm install --save-exact --save-dev esbuild -g

package.jsonに追記

追加したesbuildを使ってビルドするためのスクリプトをpackage.jsonに追記します。以下のようなscriptsの項目に1行、buildの項目を挿入するだけです。

{
  "name": "xpost",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "build": "esbuild index.js --bundle --platform=node --outfile=index-out.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "description": "",
  "dependencies": {
    "command-line-args": "^6.0.1",
    "puppeteer-core": "^23.6.1"
  }
}
  • scriptsに1行、buildの行を追加します。
  • esbuildの引数には自分自身であるindex.jsを指定し、出力ファイルはindex-out.jsとして指定します。

追加のファイルを作成する

テキストエディタで以下の2つのファイルをindex.jsと同じディレクトリに作成します。

tsconfig.json

{
  "compilerOptions": {
    "target": "es2022",
    "lib": ["es2022"],
    "module": "commonjs",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true
  }
}

このまんまのファイルを作成します。

sea-config.json

{
  "main": "index-out.js",
  "output": "sea-prep.blob"
}

mainに出力ファイルであるindex-out.jsを指定します。

ビルドの実行

ターミナルから一連の作業を行い単一実行ファイルをビルドしますが、今回はmacOS単体で作っています(どうやらpkg同様にクロスプラットフォームで作れるらしい。ただそれぞれのプラットフォームでビルドしたほうが個人的には良いと思う)。

ビルドを行ってみる

ターミナルを開き、プロジェクトのフォルダまで移動。そこで以下のコマンドを実行してビルドします。

//Win・macOS共通
npm install
npm run build
node --experimental-sea-config sea-config.json

//macOSの場合
cp $(command -v node) indexman

//Windowsの場合
echo f | xcopy /f /y "C:\Program Files\nodejs\node.exe" .\indexman.exe

4つ目のコマンドでindexmanと付けていますが、これが単一実行のファイル名になります。

図:無事生成された単一の実行ファイル

署名の実行

既存のNodeの実行ファイルにindex.jsをビルドしたものを埋め込んでるようで、このままだと実行に支障がある。よって、以下のコマンドを実行して既存のコード署名を削除し、改めてコード署名を追加します。

また、Windowsの場合には、Windows SDKのSigntool.exeを別途インストールが必要です。以下の手順でインストールします。

  1. インストーラをダウンロードして実行する
  2. 全てインストールする必要はありません。Windows SDK Signing Tools for Desktop Appsがあれば十分なのでそれだけを選んで進める
  3. C:\Program Files (x86)\Windows Kits\10\App Certification Kit\signtool.exe がインストールされたsigntoolのフルパスになります

図:署名用ツールをインストールする

以下のコマンドが署名の削除と新規つけ直しのコマンドです。indexmanに対して実行します。

//macOSの場合
codesign --remove-signature indexman
sudo npx postject indexman NODE_SEA_BLOB sea-prep.blob --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 --macho-segment-name NODE_SEA
codesign --sign - indexman

//Windowsの場合(管理者権限でcmdを起動して実行が必要)
"C:\Program Files (x86)\Windows Kits\10\App Certification Kit\signtool.exe" remove /s indexman.exe
npx postject indexman.exe NODE_SEA_BLOB sea-prep.blob --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2
"C:\Program Files (x86)\Windows Kits\10\App Certification Kit\signtool.exe" sign /fd SHA256 indexman.exe
  • 2行目は、sudoでないとCan't read and write to target executableというエラーが出てしまいます。またこの時、postjectの引数にもindexmanという実行ファイルのファイル名を指定しています。
  • --sentinel-fuse以下の引数はこれはこの値固定で指定します。
  • 3行目のコマンドでコードサインを再実行して署名しています。
  • 実行結果でInjection Doneと出れば成功です。
  • Windowsの場合、signtool.exeの場所が環境変数に登録されてなくPATHが通っていないのでフルパス指定しています。
  • npm postjectの行で実行しても先に進まない場合、Enterキーを押すと良いです。

図:無事にビルド出来ました

実行してみる

indexmanという実行ファイルが出来たので、ターミナルから以下のような形で実際に叩いてみました。

//macOSの場合
.\indexman -g "テスト投稿だよ🍺"

//Windowsの場合
.\indexman.exe -g "テスト投稿だよ🍺"

すると、Chromeが起動し、Xにポストして自動的にクローズ。終了。実際にXを開いてみると問題なく投稿されていました。これでターミナルで単一バイナリに対してぶん投げるだけでXにそのまんま投稿できる仕組みが完成です。

Windows環境でビルドしてパスの通ってる場所にでもEXEを配置すれば、VBAやVBS、PowerShellなどから自動投稿する事ができるのでExcelのデータを読み取って投稿させるであったり、タスクスケジューラに仕掛けて投稿させるなどが可能になります。

図:ターミナルからポスト実行

図:無事に投稿されました。

macOSでトラブルが起きる

Error: Could not find the sentinel...

純粋なmacOSの仮想環境を構築し、Homebrewではない形でNode.jsをインストールして、ビルドを実行した所、「Error: Could not find the sentinel NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 in the binary」というエラーが出て失敗。どうやら、同様の報告が上がっているようで・・・

そこでの解決策は、

  • Node 20.0のARM64 Binaryをダウンロードして、この中に入ってるnodeだけを取り出し、index.jsと同じフォルダにコピーする
  • 改めて、署名の実行を行う

この方法だと確かに問題なくビルドすることが完了できました。Node.js 23のNodeに何か問題が含まれているのかもしれません。

ビルドしたファイルが動かない

macOS15.x Sequoiaにてビルドしたものを実行してみたら起動すらしない。Sonomaなどでは動いてるのにと調べてみると、SequoiaからApple Storeと既知のデベロッパ以外のアプリの実行が阻害されている模様。システム設定を開き、プライバシーとセキュリティを開いてみると、確かにブロックされてる。

codesignで署名してるにも関わらず。なのでゴミ箱に入れるしかないというダイアログが出ます。Gatekeeperを無効化するために右クリック→開くも出来ず。このまま開くをクリックすれば起動はするのですが・・・。こちらにも詳細な内容が書かれています。

しかし「このまま開く」で開ければ次回以降は同じバイナリに対してまた再度チェックが入って聞いてくるということは無い模様なので、社内で使う分にはこのひと手間が必要ということになりそうです。

図:実行がブロックされてしまった

No such file問題

macOSにてHomebrewでNodeをインストールし普段は使っているのですが、この環境下でSingle Exectable Applicationをビルドすると他のユーザの環境で実行しようとすると、起動せずになにやらNo such fileと出て止まってる様子。調べてみると、homebrewでインストールされてるいくつかのモジュールを見に行ってる模様。

当然他の環境ではHomebrewはおろかモジュールなど入れてもいないのですが、これでは他の環境にHomebrewを入れた上に見つからないとされるモジュールをインストールする必要がある。これでは不便極まりない。

そこで純粋なmacOSのVM環境を用意してそこで同様にビルドをしてみるとこちらはそういったエラーが出ない。つまり、homebrewでインストールしたNodeでSingle Exectable Applicationをビルドするとこのような症状になってしまうようだ。故に単一実行ファイルをmacOS向けにビルドしたい場合には、HomebrewでNodeは入れては行けないよということのようです。

関連リンク

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)