PHPで天気予報を求める(その3)

(1/1)
PHP で天気予報を求める(その 2)」では livedoor 天気情報 を利用したが、2020 年(令和 2 年)7 月 31 日をもって、このサービスが終了することが発表された。
現在、海外を含めて様々な天気予報 WebAPI があるのだが、わが国独特の降水予報を出しているのは、やはり気象庁しかない。ところが、気象庁では天気予報を RSS や WebAPI で提供していない。
そこで今回は、気象庁の週間予報を Web スクレイピングし、「PHP で天気予報を求める(その 2)」とほぼ同等のプログラムを作ることを目指す。

(2020 年 11 月 03 日)pahooWeather::__jma_readWeeklyWeather() 月日取得方式変更

download プログラムを実行する

サンプル・プログラムの実行例

PHPで天気予報を求める(その3)

サンプル・プログラムのダウンロード

圧縮ファイルの内容
jmaWeeklyWeather.phpサンプル・プログラム本体。
pahooWeather.php気象情報に関わるクラス pahooWeather。
気象情報に関わるクラスの使い方は「PHPで天気予報を求める(その3)」を参照。include_path が通ったディレクトリに配置すること。

解説:表示列数など

0039: //天気予報の表示列数
0040: define('COLUMNS', 7);

天気予報は最大 7 日分を取得できるが、横表示の日数を COLUMNS に設定している。
7 なら 1行で、ここを 4 にすると 4 日分と 3 日分の 2行表示となる。

解説:都道府県情報を読み込む

PHPで天気予報を求める(その3)
週間予報のページは、左図の「各府県の週間天気予報」を選択し、各都道府県の週間天気予報が表示される。
まず、このプルダウン部分を Web スクレイピングし、都道府県名と表示URL を取得する。

0459: /**
0460:  * 都道府県テーブルを読み込む
0461:  * @param bool $force TRUE:強制読込/FALSE:読込済ならスキップ(デフォルト)
0462:  * @return bool TRUE:変換成功/FALSE:失敗
0463: */
0464: function jma_readPref($force=FALSE) {
0465:     //読込済ならスキップ
0466:     if (! $force && isset($this->jma_tablePref[47]))    return TRUE;
0467: 
0468:     $items = array();
0469:     if (! $this->__jma_readPref($items))  return FALSE;
0470: 
0471:     $cnt = 1;
0472:     $flag = 0;
0473:     foreach ($items as $key=>$arr) {
0474:         if ($flag == 0) {
0475:             //北海道(複数)
0476:             if ($arr['name'] != '青森県') {
0477:                 $this->jma_tablePref[$cnt]['name']  = '北海道';
0478:                 $this->jma_tablePref[$cnt]['url'][] = $arr['url'];
0479:             //青森県
0480:             } else {
0481:                 $flag = 1;
0482:                 $cnt++;
0483:                 $this->jma_tablePref[$cnt]['name']  = $arr['name'];
0484:                 $this->jma_tablePref[$cnt]['url'][] = $arr['url'];
0485:                 $cnt++;
0486:             }
0487:             //沖縄県
0488:         } else if ($flag == 2) {
0489:             $this->jma_tablePref[$cnt]['name']  = '沖縄県';
0490:             $this->jma_tablePref[$cnt]['url'][] = $arr['url'];
0491:             $cnt++;
0492:         } else {
0493:             //鹿児島県
0494:             if ($arr['name'] == '鹿児島県') {
0495:                 $this->jma_tablePref[$cnt]['name']  = $arr['name'];
0496:                 $this->jma_tablePref[$cnt]['url'][] = $arr['url'];
0497:                 $flag = 2;
0498:                 $cnt++;
0499:             //秋田県~宮崎県
0500:             } else {
0501:                 $this->jma_tablePref[$cnt]['name']  = $arr['name'];
0502:                 $this->jma_tablePref[$cnt]['url'][] = $arr['url'];
0503:                 $cnt++;
0504:             }
0505:         }
0506:     }
0507: 
0508:     return TRUE;
0509: }

北海道と沖縄県が複数のページに分割されていることから、都道府県名 1 つに対し、複数の URL をぶら下げる連想配列 jma_tablePref へ読み込む。

解説:週間天気予報を読み込む

PHPで天気予報を求める(その3)
各都市の週間天気予報のページにジャンプすると、ご覧のように、1 つの URL に、複数の都市の週間天気予報が表示される。
日付は最上段の 1行のみであること、都市名は最高/最低気温の行に現れることに留意し、正規表現を使って Web スクレイピングを行う。結果は、連想配列 jma_WeeklyWeather へ格納することにする。

0511: /**
0512:  * 指定したURLから週間天気予報を読み込む
0513:  * @param int    $num   都道府県番号(1:北海道~)
0514:  * @param int    $start 開始都市番号
0515:  * @param string $url   URL
0516:  * @return int 読み込んだ最後の都市番号/(-1):読込失敗
0517: */
0518: function __jma_readWeeklyWeather($num$start$url) {
0519:     static $week_name = array('', '', '', '', '', '', '');
0520: 
0521:     //気象庁週間天気予報にアクセス
0522:     $infp = fopen($url, 'r');
0523:     if ($infp == FALSE) {
0524:         $this->error = TRUE;
0525:         $this->errmsg = '気象庁サイト ' . $url . ' にアクセスできない.';
0526:         return (-1);
0527:     }
0528: 
0529:     //スクレイピング
0530:     $pat01 = '/<th\s+class\=\"weekday\"\s+colspan\=\"2\">/ui';
0531:     $pat02 = '/<th\s+class\=\"[^\"]+\">([0-9]+)</ui';
0532:     $pat11 = '/<input\s+class\=\"linkbtn\"\s+type\=\"button\"\s+title\=\"府県天気予報へ\"/uii';
0533:     $pat12 = '/<td\s+class\=\"for\"\s+nowrap>([^\>]+)<br><img\s+src\=\"([^\"]+)\"\s+align\=\"middle\"/ui';
0534:     $pat21 = '/<td\s+colspan\=\"2\"\s+class\=\"normal\">/ui';
0535:     $pat22 = '/<td\s+class\=\"for\"><font\s+class\=\"pop\">([^\>]+)<\/font><\/td>/ui';
0536:     $pat31 = '/<th\s+class\=\"cityname\"\s+rowspan\=\"2\">([^\>]+)<\/th>/ui';
0537:     $pat41 = '/<td\s+class\=\"for\"\s+nowrap><font\s+class\=\"maxtemp\">([^\<]+)</ui';
0538:     $pat42 = '/最低\(℃\)/ui';
0539:     $pat43 = '/<td\s+class\=\"for\"\s+nowrap><font\s+class\=\"mintemp\">([^\<]+)</ui';
0540:     $pat44 = '/<td\s+class\=\"for\">/<\/td>/ui';
0541:     $pat51 = '/<caption\s+style\=\"text\-align\:left\;\">([0-9]+)月([0-9]+)日[0-9]+時.+の週間天気予報<\/caption>/ui';
0542: 
0543:     $incode = '';
0544:     $encode = strtolower($this->Internal_encode);
0545:     $flag = 0;
0546:     $cnt = $start;
0547:     $dd = 0;
0548:     while (! feof($infp)) {
0549:         $str = fgets($infp);
0550:         if (($incode != '') && ($incode != $encode)) {
0551:             $str = mb_convert_encoding($str$encode$incode);
0552:         }
0553:         switch ($flag) {
0554:             //日付開始
0555:             case 0:
0556:                 //月日取得
0557:                 if (preg_match($pat51$str$arr) > 0) {
0558:                     $month = (int)$arr[1];
0559:                     $day   = (int)$arr[2];
0560:                     if ($month > (int)date('n')) {       //年越し
0561:                         $year = (int)date('Y') - 1;
0562:                     } else {
0563:                         $year = (int)date('Y');
0564:                     }
0565:                     $dt = new DateTime(sprintf('%04d-%02d-%02d', $year$month$day));
0566:                 }
0567:                 if (preg_match($pat01$str) > 0)   $flag = 1;
0568:                 break;
0569:             //日付の解釈
0570:             case 1:
0571:                 if (preg_match($pat02$str$arr) > 0) {
0572: /**
0573:                     if ($dd == 0) {
0574:                         $day   = (int)date('j');
0575:                         $month = (int)date('n');
0576:                         $year  = (int)date('Y');
0577:                         //開始日は1日だが、内蔵時計が1日でない場合(タイムラグ補正)
0578:                         if (($day != 1) && ($arr[1] == 1)) {
0579:                             $month++;
0580:                             if ($month > 12) {
0581:                                 $year++;
0582:                                 $month = 1;
0583:                             }
0584:                         } else {
0585:                             $day = $arr[1];
0586:                         }
0587:                         $dt = new DateTime(sprintf('%04d-%02d-%02d', $year$month$day));
0588:                     } else {
0589:                         date_add($dtdate_interval_create_from_date_string('1 days'));
0590:                     }
0591: **/
0592:                     //月の補正
0593:                     if ($dd == 0) {
0594:                         if ((int)$arr[1] < $day) {
0595:                             date_add($dtdate_interval_create_from_date_string('1 month'));
0596:                         } else if ((int)$arr[1] > $day) {
0597:                             date_add($dtdate_interval_create_from_date_string('-1 month'));
0598:                         }
0599:                     }
0600:                     //$dtを年月日に分解
0601:                     $this->jma_WeeklyWeather[$num][$cnt][$dd]['year'] = date_format($dt, 'Y');
0602:                     $this->jma_WeeklyWeather[$num][$cnt][$dd]['month'] = date_format($dt, 'n');
0603:                     $this->jma_WeeklyWeather[$num][$cnt][$dd]['day'] = date_format($dt, 'j');
0604:                     $this->jma_WeeklyWeather[$num][$cnt][$dd]['week'] = $week_name[date_format($dt, 'w')];
0605:                     $dd++;
0606:                     date_add($dtdate_interval_create_from_date_string('1 days'));
0607:                 } else if (preg_match($pat11$str) > 0) {
0608:                     $flag = 2;
0609:                     $dd = 0;
0610:                 }
0611:                 break;
0612:             //天候の解釈
0613:             case 2:
0614:                 if (preg_match($pat12$str$arr) > 0) {
0615:                     $this->jma_WeeklyWeather[$num][$cnt][$dd]['weather'] = isset($arr[1]) ? $arr[1] : '--';
0616:                     $this->jma_WeeklyWeather[$num][$cnt][$dd]['image'] = isset($arr[2]) ? ($this->jma_WeeklyURL . $arr[2]) : '';
0617:                     $dd++;
0618:                 } else if (preg_match($pat21$str) > 0) {
0619:                     //2番目以降の年の日付代入
0620:                     if ($cnt > 1) {
0621:                         for ($i = 0; $i < $dd$i++) {
0622:                             $this->jma_WeeklyWeather[$num][$cnt][$i]['year'] = $this->jma_WeeklyWeather[$num][$cnt - 1][$i]['year'];
0623:                             $this->jma_WeeklyWeather[$num][$cnt][$i]['month'] = $this->jma_WeeklyWeather[$num][$cnt - 1][$i]['month'];
0624:                             $this->jma_WeeklyWeather[$num][$cnt][$i]['day'] = $this->jma_WeeklyWeather[$num][$cnt - 1][$i]['day'];
0625:                             $this->jma_WeeklyWeather[$num][$cnt][$i]['week'] = $this->jma_WeeklyWeather[$num][$cnt - 1][$i]['week'];
0626:                         }
0627:                     }
0628:                     $flag = 3;
0629:                     $dd = 0;
0630:                 }
0631:                 $this->jma_WeeklyWeather[$num][$cnt][$dd]['rainy'] = '';
0632:                 break;
0633:             //降水確率の解釈
0634:             case 3:
0635:                 if (preg_match($pat22$str$arr) > 0) {
0636:                     $arr2 = preg_split('/\//ui', $arr[1]);
0637:                     $n = 0;
0638:                     $i = 0;
0639:                     foreach ($arr2 as $val) {
0640:                         if (is_numeric($val)) {
0641:                             $n += $val;
0642:                             $i++;
0643:                         }
0644:                     }
0645:                     $n = (int)($n / $i);
0646:                     $this->jma_WeeklyWeather[$num][$cnt][$dd]['rainy'] = $n;
0647:                     $dd++;
0648:                 //都市名+ID
0649:                 } else if (preg_match($pat31$str$arr) > 0) {
0650:                     if (preg_match('/\/([0-9]+)\.html$/ui', $url$arr3) == 0) {
0651:                         $pwt->error = TRUE;
0652:                         $pwt->errmsg = '気象庁サイトの解析に失敗.';
0653:                         return (-1);
0654:                     }
0655:                     //id = URLの数字(4桁)+連番(2桁)
0656:                     $id = sprintf('%04d%02d', $arr3[1]$cnt - $start);
0657:                     foreach ($this->jma_WeeklyWeather[$num][$cnt] as $dd=>$arr2) {
0658:                         $this->jma_WeeklyWeather[$num][$cnt][$dd]['city'] = $arr[1];
0659:                         $this->jma_WeeklyWeather[$num][$cnt][$dd]['id'] = $id;
0660:                     }
0661:                     $flag = 4;
0662:                     $dd = 0;
0663:                 }
0664:                 $this->jma_WeeklyWeather[$num][$cnt][$dd]['temp_max'] = '';
0665:                 $this->jma_WeeklyWeather[$num][$cnt][$dd]['temp_min'] = '';
0666:                 break;
0667:             //最高気温の解釈
0668:             case 4:
0669:                 if (preg_match($pat41$str$arr) > 0) {
0670:                     $this->jma_WeeklyWeather[$num][$cnt][$dd]['temp_max'] = $arr[1];
0671:                     $dd++;
0672:                 } else if (preg_match($pat42$str) > 0) {
0673:                     $flag = 5;
0674:                     $dd = 0;
0675:                 }
0676:                 break;
0677:             //最低気温の解釈
0678:             case 5:
0679:                 if (preg_match($pat43$str$arr) > 0) {
0680:                     $this->jma_WeeklyWeather[$num][$cnt][$dd]['temp_min'] = $arr[1];
0681:                     $dd++;
0682:                 } else if (preg_match($pat44$str$arr) > 0) {
0683:                     $this->jma_WeeklyWeather[$num][$cnt][$dd]['temp_min'] = '';
0684:                     $dd++;
0685:                 //次の地域
0686:                 } else if (preg_match($pat11$str) > 0) {
0687:                     $flag = 2;
0688:                     $cnt++;
0689:                     $dd = 0;
0690:                 }
0691:                 break;
0692:             default:
0693:                 break;
0694:         }
0695:     }
0696:     fclose($infp);
0697: 
0698:     return $cnt + 1;
0699: }

0701: /**
0702:  * 指定した都道府県の週間天気予報を読み込む
0703:  * @param int  $num   都道府県番号(1:北海道~)
0704:  * @param bool $force TRUE:強制読込/FALSE:読込済ならスキップ(デフォルト)
0705:  * @return bool TRUE:読込成功/FALSE:失敗
0706: */
0707: function jma_readWeeklyWeather($num$force=FALSE) {
0708:     //読込済ならスキップ
0709:     if (! $force && isset($this->jma_WeeklyWeather))  return TRUE;
0710: 
0711:     //読み込みURL
0712:     if (! isset($this->jma_tablePref[$num]['url'])) {
0713:         $this->error = TRUE;
0714:         $this->errmsg = '都道府県テーブルが無い';
0715:         return FALSE;
0716:     }
0717: 
0718:     $cnt = 1;
0719:     foreach ($this->jma_tablePref[$num]['url'] as $url) {
0720:         $cnt = $this->__jma_readWeeklyWeather($num$cnt$url);
0721:         if ($cnt < 0)   return FALSE;
0722:     }
0723: 
0724:     return TRUE;
0725: }

解説:週間天気予報表を作成する

Web スクレイピングが終わったら、それを出力用の表形式に加工するのがユーザー関数 makeWeeklyWeather である。

0142: /**
0143:  * 週間天気予報表を作成する
0144:  * @param array $items 週間天気予報情報を格納した配列
0145:  * @param int $start   作成開始オフセット(0~6)
0146:  * @param int $goal    作成終了オフセット(0~6)
0147:  * @return int 配列に格納した情報件数/0=失敗
0148: */
0149: function makeWeeklyWeather($items$start$goal) {
0150:     if ($start < 0 || $start > 6)   return FALSE;
0151:     if ($goal  < 0 || $goal  > 6)   return FALSE;
0152:     if ($start > $goal)             return FALSE;
0153: 
0154: $outstr =<<< EOT
0155: <table style="border:solid 1px #000000; border-collapse:collapse; margin-top:10px;">
0156: <caption>{$items[0]['city']}の天気予報</caption>
0157: 
0158: EOT;
0159: 
0160:     $i = $start;
0161:     while ($i <= $goal) {
0162:         //日付の行
0163:         $j = 0;
0164:         while ($j < COLUMNS) {
0165:             if ($j == 0)    $outstr .= "<tr>\n";
0166:             if (($i <= $goal) && isset($items[$i]['week'])) {
0167:                 $mmddww = sprintf("%02d/%02d(%s)", $items[$i]['month'], $items[$i]['day'], $items[$i]['week']);
0168: $outstr .=<<< EOT
0169: <td style="border:solid 1px #000000; border-collapse: collapse; padding:4px; white-space:nowrap; text-align:center;">{$mmddww}</td>
0170: 
0171: EOT;
0172:             } else {
0173:                 $outstr .= "<td>&nbsp;</td>\n";
0174:             }
0175:             $i++;
0176:             $j++;
0177:             if ($j == COLUMNS)  $outstr .= "</tr>\n";
0178:         }
0179:         //天気予報の行
0180:         $i -= COLUMNS;
0181:         $j = 0;
0182:         while ($j < COLUMNS) {
0183:             if ($j == 0)    $outstr .= "<tr>\n";
0184:             if ($i <= $goal) {
0185:                 $mmddww = sprintf("%02d/%02d(%s)", $items[$i]['month'], $items[$i]['day'], $items[$i]['week']);
0186: $outstr .=<<< EOT
0187: <td style="border:solid 1px #000000; border-collapse: collapse; padding:4px; white-space:nowrap; text-align:center;">
0188: {$items[$i]['weather']}<br />
0189: <img src="{$items[$i]['image']}" /><br />
0190: {$items[$i]['rainy']}%<br />
0191: {$items[$i]['temp_max']}℃/{$items[$i]['temp_min']}℃
0192: </td>
0193: 
0194: EOT;
0195:             } else {
0196:                 $outstr .= "<td>&nbsp;</td>\n";
0197:             }
0198:             $i++;
0199:             $j++;
0200:             if ($j == COLUMNS)  $outstr .= "</tr>\n";
0201:         }
0202:     }
0203:     $outstr .= "</table>\n";
0204: 
0205:     return $outstr;
0206: }

解説:出力処理

0304: //気象庁週間天気予報の読み込み
0305: $pwt->jma_setEncode(INTERNAL_ENCODING);
0306: $pwt->jma_readPref();
0307: 
0308: //都道府県が選択されたら読み込み
0309: if ($pref > 0)  $pwt->jma_readWeeklyWeather($pref);
0310: 
0311: //エラーチェック
0312: if ($pwt->error) {
0313: $HtmlBody2 =<<< EOT
0314: <p style="color:red;">エラー:{$pwt->errmsg}</p>
0315: 
0316: EOT;
0317: //天気予報表を作成
0318: else {
0319:     $ww = $pwt->jma_getWeeklyWeather();
0320:     $res = (($pref == 0) || ($city == 0)) ? '' : makeWeeklyWeather($ww[$pref][$city], 0, 6);
0321: }
0322: 
0323: $HtmlBody = makeCommonBody($pwt$pref$city$res);
0324: 
0325: //表示処理
0326: if ($id == 0) {
0327:     echo $HtmlHeader;
0328:     echo $HtmlBody;
0329:     echo $HtmlFooter;
0330: else {
0331:     echo mb_convert_encoding($res$outencINTERNAL_ENCODING);
0332: }
0333: 
0334: //オブジェクト解放
0335: $pwt = NULL;

コマンドラインで

jmaWeeklyWeather.php?id=1&pref=13&city=1


のように指定することで、ある地点の天気予報のみを表示させることが出来る。つまり、このスクリプトをホームページやブログの一部として組み込むことで、今日から 1週間分の天気予報を表示するガジェットになる。

また、親となるホームページやブログの文字コードセットにあわせ、charset 変数で出力文字コードを変更できるようにした。たとえば

jmaWeeklyWeather.php?id=1&pref=13&city=1&charset=SJIS


とすると、東京の週間天気予報をシフト JIS で出力することができる。

活用例

《全国の天気》今日・明日の天気と全国概況一覧 & 週間予報」(みんなの知識 ちょっと便利帳)では、このサンプル・プログラムを活用し、都市名を検索しやすいページを提供している。
また、サイドバーにコンパクトな形で天気予報を掲示している。
ご活用いただき、ありがとうございます。

質疑応答

【質問】

https://www.benricho.org/weather_japan/ のサイトように特定の地域(例えば東京都・東京)の天気のみを表示させるにはどのように記述すればよいのですか?


【回答】

URL パラメータの pref(都道府県)と city(都市)に整数を設定して呼び出してください。たとえば東京都・東京であれば、下記のようにして呼び出します。
 jmaWeeklyWeather.php?pref=13&city=1
また、プログラムに直接値を代入したいのであれば、メイン・プログラムの初期化にある $pref, $city に直接値を代入してください。



【質問】

pahooWeather.php の 583~589行目(// 月の補正 の部分)を下記のように修正したところ月日は一致したのですが、今日の天気(当日の天気)が出てきません。
date_add($dt, date_interval_create_from_date_string('-1 month'));
           ↓
date_add($dt, date_interval_create_from_date_string('1 day'));


【回答】

"pahooWeather.php" version 4.18 では、当該行はコメントアウトしており機能していません。ご確認ください。



【質問】

サイト内に天気予報と今日は何の日(https://www.pahoo.org/e-soul/webtech/php05/php05-16-01.shtm)とを同時に表示さたところ PHP で競合が起こりエラーが発生してしまいました。これらを解決する良い方法があれば教えてください。


【回答】

「競合」というのは、具体的にどのようなことが起きているのか、表示されるメッセージなどをお知らせください。



【質問】

サイト内に天気予報と今日は何の日を同時に表示させた際のエラーは次のようなものでした。
Cannot redeclare ~ (previously declared in ~)in ~


【回答】

サイト内で、"jmaWeeklyWeather.php" と "DayToday.php" をマージするか、include していませんか。この 2 つプログラムは別々に配置することを想定しています。

参考サイト

(この項おわり)
header