Friction River Software

  • お問い合わせ

CakePHP5入門【WebAPI編⑪】データ自動更新②

B美

それでは今回、スクレイピングとデータベース登録をやっていきましょう
まずは必要なライブラリをcomposerでインストールします

ちなみに、これまで私は「kub-at/php-simple-html-dom-parser」を使ってたんだけど、ChatGPT先生によればもっと良いのがあるらしいのよね

C菜

教えてくださいです~

B美

Symfonyシンフォニー DomCrawlerドムクローラー」よ

cd html/numapp[Enter]
composer require symfony/dom-crawler symfony/css-selector[Enter]

これを「MATE端末」上で実行してね


A子

なんかエラーっぽいのが出たよ

大丈夫なの?

B美

PHPのバージョンが足りないから「DomCrawler」も最新バージョンが入らないよ…ってことだから大丈夫
「8.0.6」は入らないけど、代わりに「7.4.6」をインストールしたよ…ってこと

あと、前回に引き続き「Skipped installation ・・・」というメッセージが出たのは、私が以前こっそり(六曜計算用に)「japanese-date」を入れたのが「composer.json」というファイル内に残ってるから…
(結局はエラーが出て、使えなかったんだけど…(苦笑))

C菜

消したいです~

B美

「html/numapp」の直下に「composer.json」があるから、それをエディターで開いて「japanese-date」の行を削除してね
(たったそれだけの話)

A子

前回、「気にすんな」って言ってたのはそういうことか…

B美

次は、それ(DomCrawler)の使い方なんだけど、まずはuse文ね

use Symfony\Component\DomCrawler\Crawler;

実装方法については、こんな感じよ
「$text3」にHTMLデータが格納されているとして…

//スクレイピング開始(ナンバーズ3)
$crawler3 = new Crawler($text3, null, 'UTF-8');

//CSSクラス指定で要素を抽出
$issues3 = $crawler3->filter('.js-lottery-issue-pc');
$dates3 = $crawler3->filter('.js-lottery-date-pc');
$nums3 = $crawler3->filter('.js-lottery-number-pc');

//最新のデータを取得
$latestIssue3 = trim($issues3->first()->text());
$latestDateTmp3 = trim($dates3->first()->text());
$latestNum3 = trim($nums3->first()->text());

「$latestIssue3」がナンバーズ3の最新の「実施回」、「$latestDateTmp3」が「抽選日」、「$latestNum3」が「当選番号」になるわ

C菜

みずほ銀行のWebサイトをブラウザで直接確認してみると~
「実施回」は「第6949回」、「抽選日」は「2026年3月27日」、「当選番号」は「048」って感じですね~

ということは、「第6949回」という文字列は「6949」という整数型に、「2026年3月27日」については「2026-03-27」に変換しないとダメですね~
(「当選番号」だけはそのままでもOKです~)

A子

ふむ…
だったら、こうしよう

//実施回の「第」と「回」を削除し、int型に変換
$latestIssue3 = mb_substr($latestIssue3, 1);
$latestIssue3 = mb_substr($latestIssue3, 0, mb_strlen($latestIssue3) - 1, "UTF-8");
$latestIssue3 = intval($latestIssue3);

//日付の加工(例えば、'2026年3月31日'を'2026-03-31'に変換)
if (preg_match('/^(\d{1,4})年(\d{1,2})月(\d{1,2})日$/u', $latestDateTmp3, $matches)) {
    $year = $matches[1];
    $month = $matches[2];
    $day = $matches[3];
    $latestDate3 = $year.'-'.str_pad($month, 2, '0', STR_PAD_LEFT).'-'.str_pad($day, 2, '0', STR_PAD_LEFT);
}

どうよ(ドヤ顔)

B美

おぉ、たいしたものね

A子

でしょ?
Google先生に聞いた!

B美

いいえ、それでも褒めてあげるわよ
(プログラマにとって、ググるのは超・基本だからね)

C菜

全体の処理の流れとしては、こんな感じでどうでしょうか~?

1.ブラウザ(Chromium)を起動
2.ページを作成し、ナンバーズ3サイトをアクセス
3.別のページを作成し、ナンバーズ4サイトをアクセス
4.「DomCrawler」を用いて2と3の結果をスクレイピング
5.どちらも同じ回である場合、両方のデータ取得に成功したとみなす
6.データベース格納用データを作成
7.データベース登録を実行
8.1から7のいずれかでエラーとなった場合は、管理者宛てにメール送信

1から3までは前回やったやつです~

A子

ん?
3番って「別のページ」を作らないとダメなの?

B美

良い質問ね

実は、同じページを使いまわしても「成功」する可能性はあるわ
でも、「失敗」する可能性もあるのよ

C菜

余計なリスクを負わず、確実に成功する道を選ぶ

…ってことじゃないですか~?

B美

まさにその通り!

開発環境では動いても、本番環境では動かない…ってケースもよくあるからね(苦笑)

A子

あ、そういや1番って例外を捕捉してないよね?

したほうが良いんじゃないの?

B美

良いところに気が付いたわね
(A子のくせに…(苦笑))

use HeadlessChromium\Exception\BrowserConnectionFailed;
use HeadlessChromium\Exception\OperationTimedOut;
use HeadlessChromium\Exception\CommunicationException;

上記のuse文を記述したあと、「execute」メソッド内では下記のように記述すれば良いかな

try {
    ・・・(ブラウザの起動処理)・・・
} catch (BrowserConnectionFailed | OperationTimedOut | CommunicationException $e) {
    ・・・(ブラウザ特有の例外を捕捉)・・・
} catch (\Throwable $e) {
    ・・・(念のため、全てのエラーを捕捉)・・・
}

C菜

catch」は複数個書けるし、「finally」も必須じゃないんですね~?

B美

そうよ

まぁ、三つの例外をそれぞれ三つの「catch」で捕まえるほうが、本当は良いんでしょうけどね
(「|パイプ」で繋げるのは、PHP8から使えるようになった「Union catch」という書き方よ)

A子

あとさ、「$error_flag」という変数って「true」か「false」しかないよね

それだと「どこでエラーが出たのか」が分からないと思うんだけど…

C菜

整数型として取り扱ったらどうでしょうか~?

初期値を「0」として、エラーになった際に「1」や「2」や「3」なんかを代入するんですよ~

A子

良いね、それ

あと、6番では「DateTime」クラス、8番では「Mailer」クラスを使うよね
use文を追加しておこう

use Cake\I18n\DateTime;
use Cake\Mailer\Mailer;

C菜

了解です~
それでは、use文の全体像(追加分のみ)を書いてみますね~

use Cake\Controller\ComponentRegistry;
use App\Controller\Component\SixSevenWeekComponent;

use HeadlessChromium\BrowserFactory;
use HeadlessChromium\Exception\BrowserException;
use HeadlessChromium\Exception\BrowserConnectionFailed;
use HeadlessChromium\Exception\OperationTimedOut;
use HeadlessChromium\Exception\CommunicationException;

use Symfony\Component\DomCrawler\Crawler;
use Cake\I18n\DateTime;
use Cake\Mailer\Mailer;

A子

それじゃ、まずは1番からだね

1.ブラウザ(Chromium)を起動

基本的には前回B美から教えてもらったやつで、さっきの「try catch」を追加したものだよ

//ブラウザ生成
try {
    $browser = $browserFactory->createBrowser([
        'headless' => true,
        'noSandbox' => true,
        'startupTimeout' => 60,
        'connectionDelay' => 30,
        'args' => [
            '--user-data-dir='.$userDataDir,
            '--disable-blink-features=AutomationControlled',
            '--disable-gpu',
            '--disable-dev-shm-usage',
            '--disable-features=VizDisplayCompositor',
            '--no-sandbox',
            '--disable-setuid-sandbox',
            '--single-process',
            '--no-zygote',
            '--disable-software-rasterizer',
            '--disable-extensions',
            '--disable-background-networking',
            '--disable-sync',
            '--metrics-recording-only',
            '--mute-audio',
        ]
    ]);
} catch (BrowserConnectionFailed | OperationTimedOut | CommunicationException $e) {
    $error_flag = 1;
} catch (\Throwable $e) {
    $error_flag = 2;
}

・・・(Webサイトへのアクセス)・・・

$browser->close();

B美

ちょっと待って

その書き方じゃ、最後の「$browser->close();」でエラーになるわよ

C菜

えっ?

問題ないように見えますけど~

B美

ブロック内で宣言された変数は、そのブロック内でのみ有効

この原則に従えば、変数「$browser」はtryブロック内(正確にはfinallyも含む)でしか使えないのよ

A子

あっ、分かったよ

$browser = null;

//ブラウザ生成
try {
    $browser = $browserFactory->createBrowser([
        'headless' => true,
        'noSandbox' => true,
        'startupTimeout' => 60,
        'connectionDelay' => 30,
        'args' => [
            '--user-data-dir='.$userDataDir,
            '--disable-blink-features=AutomationControlled',
            '--disable-gpu',
            '--disable-dev-shm-usage',
            '--disable-features=VizDisplayCompositor',
            '--no-sandbox',
            '--disable-setuid-sandbox',
            '--single-process',
            '--no-zygote',
            '--disable-software-rasterizer',
            '--disable-extensions',
            '--disable-background-networking',
            '--disable-sync',
            '--metrics-recording-only',
            '--mute-audio',
        ]
    ]);
} catch (BrowserConnectionFailed | OperationTimedOut | CommunicationException $e) {
    $error_flag = 1;
} catch (\Throwable $e) {
    $error_flag = 2;
}

・・・(Webサイトへのアクセス)・・・

//ブラウザのクローズ
if ($browser != null) {
    $browser->close();
}

これならどうよ

B美

ふむ、良いでしょう

あ、同じことが「$page3」や「$page4」にも言えるからね
(まぁ、tryブロックで宣言された変数って、finallyブロックからは見えるんだけど…)

C菜

では2番と3番ですけど~

2.ページを作成し、ナンバーズ3サイトをアクセス
3.別のページを作成し、ナンバーズ4サイトをアクセス

$page3 = null;
$page4 = null;

if ($error_flag == 0) {

    try {
        //ブラウザページを作成(これを使ってURLにアクセスする)
        $page3 = $browser->createPage();

        //ヘッドレス検知回避
        $page3->addPreScript("
            Object.defineProperty(navigator, 'webdriver', {
                get: () => undefined
            });
        ");

        //ユーザエージェント偽装
        $page3->setUserAgent(
            'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' .
            '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
        );

        //ナンバーズ3サイトをアクセス
        $page3->navigate($url3)->waitForNavigation();
        $this->waitForSelector($page3, '.js-lottery-number-pc');
        $text3 = $page3->evaluate('document.documentElement.outerHTML')->getReturnValue();

    } catch (BrowserException $e) {
        $error_flag = 3;
    } finally {
        if ($page3 != null) {
            $page3->close();
        }
    }
}

if ($error_flag == 0) {

    try {
        //ブラウザページを作成(これを使ってURLにアクセスする)
        $page4 = $browser->createPage();

        //ヘッドレス検知回避
        $page4->addPreScript("
            Object.defineProperty(navigator, 'webdriver', {
                get: () => undefined
            });
        ");

        //ユーザエージェント偽装
        $page4->setUserAgent(
            'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' .
            '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
        );

        //ナンバーズ4サイトをアクセス
        $page4->navigate($url4)->waitForNavigation();
        $this->waitForSelector($page4, '.js-lottery-number-pc');
        $text4 = $page4->evaluate('document.documentElement.outerHTML')->getReturnValue();

    } catch (BrowserException $e) {
        $error_flag = 4;
    } finally {
        if ($page4 != null) {
            $page4->close();
        }
    }
}

基本的には前回のコードをそのまま流用してます~
赤字が追加・変更箇所です~)

B美

OK、OK

finallyブロック内では、null判定を入れておくほうがより安全なのよね
(前回のコードでは省略しちゃったけど…(苦笑))

A子

よし、どんどんいこう
次は5番と6番だね

5.どちらも同じ回である場合、両方のデータ取得に成功したとみなす
6.データベース格納用データを作成

ナンバーズ3とナンバーズ4の「実施回」が同じなら、データベース登録用のデータを作成しているよ

if ($error_flag == 0) {
    //スクレイピング開始(ナンバーズ3)
    $crawler3 = new Crawler($text3, null, 'UTF-8');

    //CSSクラス指定で要素を抽出
    $issues3 = $crawler3->filter('.js-lottery-issue-pc');
    $dates3 = $crawler3->filter('.js-lottery-date-pc');
    $nums3 = $crawler3->filter('.js-lottery-number-pc');

    //最新のデータを取得
    $latestIssue3 = trim($issues3->first()->text());
    $latestDateTmp3 = trim($dates3->first()->text());
    $latestNum3 = trim($nums3->first()->text());

    //実施回の「第」と「回」を削除し、int型に変換
    $latestIssue3 = mb_substr($latestIssue3, 1);
    $latestIssue3 = mb_substr($latestIssue3, 0, mb_strlen($latestIssue3) - 1, "UTF-8");
    $latestIssue3 = intval($latestIssue3);

    //日付の加工(例えば、'2026年3月31日'を'2026-03-31'に変換)
    if (preg_match('/^(\d{1,4})年(\d{1,2})月(\d{1,2})日$/u', $latestDateTmp3, $matches)) {
        $year = $matches[1];
        $month = $matches[2];
        $day = $matches[3];
        $latestDate3 = $year.'-'.str_pad($month, 2, '0', STR_PAD_LEFT).'-'.str_pad($day, 2, '0', STR_PAD_LEFT);
    }

    //スクレイピング開始(ナンバーズ4)
    $crawler4 = new Crawler($text4, null, 'UTF-8');

    //CSSクラス指定で要素を抽出
    $issues4 = $crawler4->filter('.js-lottery-issue-pc');
    $nums4 = $crawler4->filter('.js-lottery-number-pc');

    //最新のデータを取得
    $latestIssue4 = trim($issues4->first()->text());
    $latestNum4 = trim($nums4->first()->text());

    //実施回の「第」と「回」を削除し、int型に変換
    $latestIssue4 = mb_substr($latestIssue4, 1);
    $latestIssue4 = mb_substr($latestIssue4, 0, mb_strlen($latestIssue4) - 1, "UTF-8");
    $latestIssue4 = intval($latestIssue4);

    //ナンバーズ3と4が同じ抽選日ならば
    if ($latestIssue3 == $latestIssue4) {
        $lottery_time = $latestIssue3;
        $lottery_date_dt = $latestDate3;
        $lottery_date_str = $latestDateTmp3;
        $lottery_date_year = intval($year);
        $lottery_date_month = intval($month);
        $lottery_date_day = intval($day);
        $seven = $this->SixSevenWeek::getSeven($lottery_date_year, $lottery_date_month, $lottery_date_day);
        $lottery_week_int = $seven['week_int'];
        $lottery_week_str1 = $seven['week_str_long'];
        $lottery_week_str2 = $seven['week_str_short'];
        $six = $this->SixSevenWeek::getSix($lottery_date_dt);
        $lottery_rokuyo_int = $six['rokuyo_int'];
        $lottery_rokuyo_str = $six['rokuyo_str'];
        $num3_str = $latestNum3;
        $num3_int = intval($num3_str);
        $num3_place1 = intval($num3_int % 10);
        $num3_place10 = intval($num3_int % 100 / 10);
        $num3_place100 = intval($num3_int / 100);
        $num4_str = $latestNum4;
        $num4_int = intval($num4_str);
        $num4_place1 = intval($num4_int % 10);
        $num4_place10 = intval($num4_int % 100 / 10);
        $num4_place100 = intval($num4_int % 1000 / 100);
        $num4_place1000 = intval($num4_int / 1000);
    } else {
        $error_flag = 5;
    }
}

C菜

7番は簡単です~
(「AdminController.php」からのコピペですよ~)

7.データベース登録を実行

//データベース登録
if ($error_flag == 0) {
    $numbers = $this->Numbers->newEmptyEntity();
    $numbersData = [
        'lottery_time' => $lottery_time,
        'lottery_date_dt' => new DateTime($lottery_date_dt.' 00:00:00'),
        'lottery_date_str' => $lottery_date_str,
        'lottery_date_year' => $lottery_date_year,
        'lottery_date_month' => $lottery_date_month,
        'lottery_date_day' => $lottery_date_day,
        'lottery_week_int' => $lottery_week_int,
        'lottery_week_str1' => $lottery_week_str1,
        'lottery_week_str2' => $lottery_week_str2,
        'lottery_rokuyo_int' => $lottery_rokuyo_int,
        'lottery_rokuyo_str' => $lottery_rokuyo_str,
        'num3_str' => $num3_str,
        'num3_int' => $num3_int,
        'num3_place1' => $num3_place1,
        'num3_place10' => $num3_place10,
        'num3_place100' => $num3_place100,
        'num4_str' => $num4_str,
        'num4_int' => $num4_int,
        'num4_place1' => $num4_place1,
        'num4_place10' => $num4_place10,
        'num4_place100' => $num4_place100,
        'num4_place1000' => $num4_place1000,
    ];
    $this->Numbers->patchEntity($numbers, $numbersData);

    if (!$this->Numbers->save($numbers)) {
        $error_flag = 6;
    }
}

B美

ちょーっと待ったー!

いきなりデータベース登録する前に一つチェックが必要

A子

ん?
なんだろ?

B美

現時点でのデータベース上の「実施回」の最大値と、Webスクレイピングした「実施回」の値が同じだったら二重登録しちゃうことになるじゃない

A子

あ、たしかに…(苦笑)

うーん、あと考えられるのは…
例えばデータベース上の値が「7000」で、スクレイピングで取得した値が「7020」だったらどうすんの?

「第7001回」から「第7020回」までの20件を順に登録していく?

C菜

それは処理が複雑化して無理ですぅ~

「7000」と「7001」の関係ならチェックOKで、「第7001回」のデータを登録するで良いんじゃないでしょうか~?
(だって、毎日クーロンで自動実行していくわけですから~)

B美

私もC菜の案に賛成よ
(複雑化はバグの温床になるからね(苦笑))

そうねぇ
コネクションマネージャーによる「SQL文の直接実行」ではない・・・・方法で「lottery_time」の最大値をとってみましょうか

//データベース上の「実施回」の最大値を取得
if ($error_flag == 0) {
    $query = $this->Numbers->find();
    $query->select([
        'lottery_time_max' => $query->func()->max('lottery_time')
    ]);
    $time_max = $query->first()->lottery_time_max;

    //Web上の最新データがDB登録済みデータの次の回でなければエラー
    if ($time_max + 1 != $lottery_time) {
        $error_flag = 6;
    }
}

A子

たしかにこれはSQL文を実行したほうが簡単だね(苦笑)

あ!
さっきのデータベース登録のエラーコードは「6」ではなく「7」にしないとね

//データベース登録
if ($error_flag == 0) {

    ・・・

    if (!$this->Numbers->save($numbers)) {
        $error_flag = 7;
    }
}

C菜

良いと思います~
それでは最後の8番ですね~

8.1から7のいずれかでエラーとなった場合は、管理者宛てにメール送信

メーラークラスをわざわざ作るほどでもないと思うので、こんな感じでいかがでしょうか~?

//エラーが発生した場合、管理者あてにメール送信
if ($error_flag > 0) {
    $mailer = new Mailer('default');
    $mailer->setFrom([FROM_ADDRESS => FROM_NAME])
        ->setTo(TO_ADDRESS)
        ->setSubject(MAIL_SUBJECT)
        ->deliver(MAIL_BODY[$error_flag]);
}

もちろん「config/const.php」内に定数定義も行いますよ~

//メール送信
define("FROM_NAME", "ナンバーズシステム");
define("MAIL_SUBJECT", "エラー通知");

define("MAIL_BODY",[
    "-",
    "ブラウザの起動に失敗しました(想定内)。",
    "ブラウザの起動に失敗しました(想定外)。",
    "ナンバーズ3サイトのアクセスに失敗しました。",
    "ナンバーズ4サイトのアクセスに失敗しました。",
    "ナンバーズ3またはナンバーズ4のデータ取得に失敗しました。",
    "Web上の最新データがDB登録済みデータの次の回ではありません。",
    "データベース登録に失敗しました。"
]);

if (ENVIRONMENT == 1) {
    define("FROM_ADDRESS", "root@friction-river.mydns.jp");
    define("TO_ADDRESS", "xxxx@xxxx");
} else if (ENVIRONMENT == 2) {
    define("FROM_ADDRESS", "root@friction-river.jp");
    define("TO_ADDRESS", "xxxx@xxxx");
}

なお、「TO_ADDRESS」については伏字にしてます~

B美

ほほう
MAIL_BODY」を配列にしているのはなかなか大したものね

C菜

えへへぇ~
(ちょっと考えました~)

最後に「execute」メソッドの全体像を表示しておきますね~










A子

コード量、多過ぎー(苦笑)

最初にこれを見せられたら、途方に暮れたね(多分)

C菜

ですね~

B美

あ、そうだ
「LotteryCommand.php」と「const.php」の本番環境へのアップロードだけど、私のほうでやっといたわ
(composerインストールとaptインストール、メール設定も含めて…)

あと、以下のクーロン設定もね

0 22 * * 1-5 cd /home/****/html/numapp && bin/cake lottery
(「****」部分は伏字)

A子

「1-5」って何?

C菜

「1」が月曜日で、「5」が金曜日だったはずです~

A子

月曜日から金曜日までの毎日、22時ちょうどに実行…ってこと?

B美

正解!

ちなみに、夜の10時(22時)にしている理由は、抽選時間からWeb掲載までのタイムラグを考慮した結果よ
(抽選自体は、夜の7時過ぎみたいだけどね)