CakePHP5入門【WebAPI編⑪】データ自動更新②
B美
まずは必要なライブラリをcomposerでインストールします
ちなみに、これまで私は「kub-at/php-simple-html-dom-parser」を使ってたんだけど、ChatGPT先生によればもっと良いのがあるらしいのよね
C菜
B美
|
cd html/numapp[Enter]
composer require symfony/dom-crawler symfony/css-selector[Enter] |
これを「MATE端末」上で実行してね
A子
大丈夫なの?
B美
「8.0.6」は入らないけど、代わりに「7.4.6」をインストールしたよ…ってこと
あと、前回に引き続き「Skipped installation ・・・」というメッセージが出たのは、私が以前こっそり(六曜計算用に)「japanese-date」を入れたのが「composer.json」というファイル内に残ってるから…
(結局はエラーが出て、使えなかったんだけど…(苦笑))
C菜
B美
(たったそれだけの話)
A子
B美
| 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菜
「実施回」は「第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子
したほうが良いんじゃないの?
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菜
B美
まぁ、三つの例外をそれぞれ三つの「catch」で捕まえるほうが、本当は良いんでしょうけどね
(「|」で繋げるのは、PHP8から使えるようになった「Union catch」という書き方よ)
A子
それだと「どこでエラーが出たのか」が分からないと思うんだけど…
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.ブラウザ(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サイトをアクセス
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美
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菜
(「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美
A子
うーん、あと考えられるのは…
例えばデータベース上の値が「7000」で、スクレイピングで取得した値が「7020」だったらどうすんの?
「第7001回」から「第7020回」までの20件を順に登録していく?
C菜
「7000」と「7001」の関係ならチェックOKで、「第7001回」のデータを登録するで良いんじゃないでしょうか~?
(だって、毎日クーロンで自動実行していくわけですから~)
B美
(複雑化はバグの温床になるからね(苦笑))
そうねぇ
コネクションマネージャーによる「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子
あ!
さっきのデータベース登録のエラーコードは「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子
C菜
A子
B美
ちなみに、夜の10時(22時)にしている理由は、抽選時間からWeb掲載までのタイムラグを考慮した結果よ
(抽選自体は、夜の7時過ぎみたいだけどね)


