したらばBBSのスパム投稿を自動削除する

# したらばBBSの管理画面が使いづらい

それなりに居住者がいるしたらばBBS掲示板をひとつ管理しているのですが、とにかく管理画面が使いづらいです。

投稿ホストや本文を正規表現で検索して一括削除する方法がない。
むしろ本文については検索機能すらない。
スレッドを横断して検索することもできない。
URLや英語しかないSPAMとか、特定ワードが含まれている荒らし投稿とか、そういうやつをさくっと正規表現で一括削除したいんだ、みたいなことが全くできません。

NGワード機能はありますが、これも固定文であり、正規表現は登録できません。
それにSPAM業者は、書き込みに失敗したら、次はそれを回避する方法を考えてくるでしょう。
逆に書き込んだ後で削除されているかをいちいちチェックしているというのはあまり考えにくいです。
従って、入口で完全防御するのではなく、受け入れたふりをして後から消すのは有効なSPAM対策ではないかと思われます。
あと荒らしはボキャブラリーが少ないので、適当にNGワードを突っ込んでおけば9割方消せるでしょう。

別に完璧でなくてもいいので、後からの削除管理の手間を減らせるのであればそれでいいのです。

したらばBBSには、もちろんそういう機能はありません。

仕方ないので作った。


# ソースコード

## composer.json

```json
{
    "require": {
        "guzzlehttp/guzzle": "^6.3",
        "sunra/php-simple-html-dom-parser": "^1.5"
    }
}
```

## 本体

```php
require_once 'path/to/composer/vendor/autoload.php';

define('MAX_FILE_SIZE', 2000000); // パーサ上限変更

use Sunra\PhpSimple\HtmlDomParser;
use GuzzleHttp\Client;

(new ShitarabaDelete())->run();

class ShitarabaDelete
{
    const URL_DOMAIN = 'https://cms.jbbs.shitaraba.net/hoge/37564/';
    private $client = null;

    /**
     * コンストラクタ
     */
    public function __construct()
    {
        // エラー全部出す
        ini_set('display_errors', 1);
        error_reporting(-1);

        // Guzzle
        $this->client = new Client([
            'cookies' => true,
        ]);

        // 削除ワード
        $this->deleteTexts = [
            '|Live.*ライブ|',
            '|生中継|',
        ];
    }

    /**
     * 実行
     */
    public function run()
    {
        // ログインする
        $this->login();

        // スレ一覧を取得
        $threads = $this->getAllThreads();

        // スレでぐるぐる
        foreach ($threads as $thread) {
            // 削除対象抽出
            $deleteReses = $this->getDeleteReses($thread);
            if (!$deleteReses) {
                continue;
            }

            // 削除実行
            $this->deleteReses($thread, $deleteReses);
            // ログ
            $this->saveDeleteLog($thread, $deleteReses);
        }

        // 終了
        echo '1';
        exit;
    }

    /**
     * したらばログインする
     */
    private function login()
    {
        $this->requestUrl('POST', 'login/', [
            'login_email' => 'test@example.jp',
            'login_password' => 'password',
            'login' => 'ログイン',
        ]);
    }

    /**
     * スレッド一覧を取得する
     * @return array [スレッドID=>[スレ名,…]]
     */
    private function getAllThreads()
    {
        // 取得
        $html = $this->requestUrl('GET', 'config/data/');

        // パースしてスレッド毎に必要部分を抽出
        $dom = HtmlDomParser::str_get_html($html);
        $elems = $dom->find('table.table-basic tr');
        $loopFirst = true;

        foreach ($elems as $elem) {
            if ($loopFirst) {
                // 一周目はth行なのでパス
                $loopFirst = false;
                continue;
            }

            // 抽出
            $tmp = [
                'url' => $elem->find('div.url')[0]->innerText(),
                'title' => $elem->find('a')[0]->innerText(),
            ];
            // URLの末尾がスレID
            $tmp['id'] = basename($tmp['url'], '/');
            $ret[] = $tmp;
        }

        return $ret;
    }

    /**
     * 削除するレスを取得する
     * @param array スレッド概要
     * @return array [レスID,中身]
     */
    private function getDeleteReses($thread)
    {
        /*
            取得・パース・NGワードチェックを一緒にやっている。
        */
        $ret = [];

        // 取得
        $html = $this->requestUrl('GET', 'config/data/thread?key=' . $thread['id']);

        // パースしてレス毎にいいかんじにする
        $dom = HtmlDomParser::str_get_html($html);
        $elems = $dom->find('form table.table-basic tr');
        $loopCount = 0;

        foreach ($elems as $elem) {
            if ($loopCount < 2) {
                // 一周目はth行、2行目は1で削除できないのでパス
                $loopCount++;
                continue;
            }

            // 削除済であればcheck_singleが無い
            $ida = $elem->find('input.check_single');
            if (!$ida) {
                continue;
            }

            // 抽出
            $tmp = [
                'id' => $ida[0]->getAttribute('value'),
                'name' => $elem->find('strong')[0]->innerText(),
                'host' => $elem->find('span.hostcopy')[0]->find('strong')[0]->innerText(),
                'body' => $elem->find('p')[0]->innerText(),
            ];

            // 削除対象であれば返り値に追加
            if ($this->isDeleteRes($tmp)) {
                $ret[] = $tmp;
            }

        }

        // 返却
        return $ret;
    }

    /**
     * そのレスが削除対象であるか
     * @param array [id, 中身]
     * @return boolean 削除対象であればtrue
     */
    private function isDeleteRes($detail)
    {
        // 本文から改行スペースを削除
        $body = str_replace(['<br>', ' ', '　'], '', $detail['body']);

        // 半角のみは削除
        if (mb_strwidth($body, 'UTF-8') === mb_strlen($body, 'UTF-8')) {
            return true;
        }

        // 正規表現に引っかかったら削除対象
        foreach ($this->deleteTexts as $deleteText) {
            if (preg_match($deleteText, $body)) {
                return true;
            }
        }

        /* その他なんかあったら追加 */

        // 削除しない
        return false;
    }

    /**
     * 削除を実行する
     * @param array スレッド概要
     * @param array 削除対象レス一覧
     */
    private function deleteReses($thread, $deleteReses)
    {
        foreach ($deleteReses as $deleteRes) {
            // 削除確認
            $requestParams = [
                'res_num' => $deleteRes['id'],
                'invisible' => 'on',
                'key' => $thread['id'],
            ];
            try {
                // 削除実行
                $this->requestUrl('POST', 'config/data/thread?key=' . $thread['id'], $requestParams);
                $this->requestUrl('POST', 'config/data/thread_confirm');
            } catch (\Exception $e) {
                file_put_contents('path/to/cronlog.txt', "なんかエラー\n", \FILE_APPEND);
            }
        }

        return true;
    }

    /**
     * ログを取る
     * @param array スレッド概要
     * @param array 削除対象レス一覧
     */
    private function saveDeleteLog($thread, $deleteReses)
    {
        $logData = [
            'thread' => $thread,
            'deleteReses' => $deleteReses,
        ];

        $data = json_encode($logData, \JSON_UNESCAPED_UNICODE);
        file_put_contents('path/to/cronlog.txt', $data . "\n", \FILE_APPEND);
    }

    /**
     * リクエストを送る
     * @param string GET/POST
     * @param string URL "login/"等
     * @param array リクエストパラメータ
     * @return string HTML
     */
    private function requestUrl(string $method, string $url, array $params = [])
    {
        // したらばはEUC-JP
        mb_convert_variables('EUC-JP', 'UTF-8', $params);

        // リクエスト
        $requestParams = [
            'form_params' => $params,
        ];

        $fullurl = self::URL_DOMAIN . $url;
        $response = $this->client->request($method, $fullurl, $requestParams);
        $body = mb_convert_encoding((string) $response->getBody(), 'UTF-8', 'EUC-JP');

        return $body;
    }

}
```

</div></details>


# ざっくり解説

## 仕組み

基本的には、管理画面をブラウザ操作したときに発生するはずのリクエストをPHPで代行してるだけです。
スレ一覧を取得し、レス一覧を取得し、削除リクエストを呼び出す、という操作を行っています。

したらばBBSにはAPI的なものが(たぶん)存在しないため、直接管理画面のURLを呼んでは物理的スクレイピングで中身を取り出しています。
つまり、画面のレイアウトが変わるだけで死にます。


## Guzzle

HTTPリクエストはGuzzle任せ。
昔はセッション持たせるのが[そこそこ面倒だった](https://qiita.com/tnm18/items/36cb4eeb263528956486#cookie%E3%82%92%E4%BD%BF%E7%94%A8%E3%81%99%E3%82%8B)んだけど、今はコンストラクタに`cookies=true`って書くだけで後は勝手にどうにかしてくれます。楽ちん。

ちなみにリクエストパラメータの渡し方ですが、POSTパラメータは第三引数のform_paramsパラメータに渡します。
GETパラメータは第三引数のqueryパラメータに渡します。

やっつけプログラムなのでqueryにすら対応していないとかいう投げやり仕様。


## スクレイピング

[sunra/php-simple-html-dom-parser](https://packagist.org/packages/sunra/php-simple-html-dom-parser)を使用しました。
要素をjQueryセレクタ的なもので検索できて便利です。

初期状態だと何故か60キロバイト以上のHTMLはパースしないのですが、管理画面のHTMLは普通に1メガバイトとかあります。
定数MAX_FILE_SIZEで上限を変更することができます。

こういうのはtidyを使うとだいたいうまく行くのですが、今回は使っていません。
理由としては、後述しますがサーバとしてさくらインターネットを使用していて、そしてさくらインターネットのPHPにはtidyが入っていないからです。
一応入れる方法はあるみたいですが、面倒なのでやりませんでした。


## 削除対象の投稿を抽出

ここがこのプログラムの中心ですね。
といっても単に本文をpreg_matchしてるだけなので何も解説することはありませんが。


## 削除API

削除確認URLを呼び出した後、削除実行URLを呼び出すことで削除が完了します。

削除確認のURLがこんな。
POST:`config/data/thread?key=1234567890`
リクエストパラメータ：`res_num=100&invisible=on&key=1234567890`

削除実行のURLがこんな。
POST:`config/data/thread_confirm`
リクエストパラメータ：なし

トークンとかは特にないです。
CSRFし放題ですね。

ところで一回のリクエストで複数件の削除が可能なのですが、その場合POSTパラメータはこうなります。

`res_num=1000&res_num=200&res_num=300`

同一パラメータを複数送り付けるという、PHPでは対応できない形です。
GuzzleのPOSTパラメータは[http_build_query](https://github.com/guzzle/guzzle/blob/master/src/Client.php#L310)で組み立てられています。
そしてこの[http_build_query](https://www.php.net/manual/ja/function.http-build-query.php)、同一パラメータ名でのリクエスト作成ができません。
同一パラメータを配列で渡すと[勝手に`[]`がつきます](https://3v4l.org/WX41R)。
なんてこった。

結局、単独削除URLを何度も叩くという原始的解決法に落ち着きました。


## バッチ

さくらインターネットではCRONを使えます。
`*/2 * * * * cd /path/to/dir;/usr/local/bin/php index.php 1> /dev/null`
さくっと登録。

これでNGワードは2分以内に排除されるようになりました。めでたし。
これならほとんどの人の目に入ることもないでしょう。

ちなみに`* * * * *`は設定できません。
毎分実行は多過ぎじゃーと叱られます。
まあ月500円だから仕方ないね。


# FAQ

## 今どきレン鯖かよ、何故クラウドじゃないんだ。

クラウドは高いからな。

うん、がんばれば安くなるし、もっとがんばればタダでできるのは知ってる。

だが、頑張るのがめんどい。

PHPが安く使えるところを探して使い方を調べて、データベースが安く使えるところを探して使い方を調べて、cronが安く使えるところを探して使い方を調べて、なんて時間を使うくらいなら、最初からできることのわかってるレンタルサーバを使った方がずっと楽だ。
レンタルサーバならどう使おうが固定費用だしな。
AzureのMySQLなんて最低構成ですら月5kするから[^1]、うっかりインスタンス消し忘れたりしたら目も当てられない大惨事だぞ。

私が使っているのはさくらインターネットの[スタンダードプラン](https://www.sakura.ne.jp/standard.html)。
Web、MySQL、cron全て使えて年間わずか5千円。
Web容量100GB、転送量は80GB/日あり、私が作る程度のサービスであれば十分すぎる性能なのだ。

スケールできない？
そんなもん上限超えてから言え。

まあ、このプログラムはただのバッチなので、そもそもスケールとか全く関係ないんですけどね。


## 超べた書きで拡張性皆無。ちゃんと作れ。

昔話をしよう。

かつて、このプログラムと同じようなものを作った男がいた。
Web画面も完備し、そこからNGワードを正規表現で入力して削除することもできるという、このプログラムより高度な機能を持ったものだった。
さらに特筆すべきだったのが、インスタンスを差し替えることで別の掲示板にも対応できるような構造になっていたことだ。
まだDIという言葉がなかった(or知らなかった)ころに、稚拙で自己流ながらもDIの真似事を実現していたのだ。

その結果どうなったか。

誰も拡張など作らなかったし、そもそも誰も使わなかった。

そのとき、その男は悟ったのである。

**需要のない拡張性などゴミだ**


## HTTPリクエストに失敗したら死ぬ。例外捕捉とかちゃんとしろ。

別に人が死ぬシステムでもないし、2分後にまた実行すればいいんだよ。


## レン鯖しかも共有サーバなんてSLAが不安なんだけど。

別に人が死ぬシステムでもないし、復旧してからまた実行すればいいんだよ。


# 運用してみた

SPAMは半角フィルタだけでほぼ全部消えた。
たまに巻き添えを食らう書き込みもあるんだけど、まあ多少くらいは仕方ないね。

もっと正確にやるのであれば、同一ホストの他の書き込みを探して判断する、みたいなロジックを突っ込めばいいと思うけど、まあそこまでしなくてもいいしょう。


# 今後の展望

削除したログを使って、機械学習とかでスパム判定を強化できるとより良くなるかもしれません。
今のところ正規表現フィルタだけでほぼ完封できているので、対応しなければという動機が薄くてやる気も出ないのですが。

昔あるサイトで掲示板を作ったとき、物理削除ではなく論理削除する仕組みを作ったことがあります。
SPAM判定したホストからは論理削除したSPAM記事が表示されるが、普通のアクセスでは普通の投稿しか表示されないという仕組みでした。
個人的お勧めの対処法なのですが、したらばBBSではそこまで手を出せないのが残念なところです。


# まとめ

Qiitaもせめてこの程度のことくらいやれ。


[^1]: 無論価格に見合う高性能ではあるのだが、今回程度のサービスにその性能は過剰にも程がある。