PHPでNHK政治意識月例調査をグラフ表示

(1/1)
NHK選挙WEB(2020年3月までは「NHK政治意識月例調査」)に、内閣支持率や政党支持率が公開されている。PHPを用いて、これらの数値データを取り出し、グラフに表示するプログラムを作る。応答速度を向上するため、一度読み込んだデータはXMLファイルとしてローカルに保存しておく。

(2024年3月24日)2023年(令和5年)8月以降のデータに対応
(2023年5月17日)政党支持率に「日本維新の会」「国民民主党」「れいわ新選組」を追加

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

NHK政治意識月例調査をグラフ表示

目次

サンプル・プログラム

圧縮ファイルの内容
viewPolls.phpサンプル・プログラム本体。
polls.xml内閣支持率・政党支持率のデータ・ファイル。
圧縮ファイルの内容
viewPolls.phpサンプル・プログラム本体。
viewPolls.php 更新履歴
バージョン 更新日 内容
3.3.0 2024/03/24 2023年8月以降のデータに対応
3.2.0 2023/05/17 plotParty()に3政党を追加
3.1 2021/05/08 POLLS_FILE へのセーブ/ロード
3.0 2021/05/08 2020年4月以降に対応,PHP8対応
2.0 2018/05/15 描画期間を選択できるようにした

解説:準備

  21: //jqPlotのあるフォルダ
  22: define('JQPLOT', '../../../../common/jqplot/');
  23: 
  24: //描画期間(年)
  25: define('PERIOD', 7);
  26: 
  27: //最古データ(年)
  28: define('OLDYEAR', 1998);
  29: 
  30: //表示幅(ピクセル)
  31: define('WIDTH', 600);
  32: 
  33: //表示高さ(ピクセル)
  34: define('HEIGHT', 500);
  35: 
  36: //保存用データファイル名
  37: define('POLLS_FILE', './polls.xml');

今回は折れ線グラフを描くのに、jQueryプラグイン「jqPlot」を利用する。
公式サイト「jqPlot Charts and Graphs for jQuery」から圧縮ファイルをダウンロードし、適当な場所に解凍しておく。その場所を定数 JQPLOT に定義する。

また、描画期間(当年から過去何年分遡るか)を定数 PERIOD に定義しておく。
なお、スクレイピングしたデータはXML形式ファイルとして定数 POLLS_FILE で示すファイルにセーブし、次に描画するときは、このファイルからロードできるようにした。

解説:スクレイピング

 354: /**
 355:  * 内閣支持率を解析する
 356:  * @param   int $infp 解析中のURL
 357:  * @param   array $items 解析結果を格納する配列
 358:  * @return  bool TRUE/FALSE
 359: */
 360: function analyseCabinet($infp, &$items) {
 361:     $pat1 = "/<h[0-9]+[^>]*>内閣支持率<\/h[0-9]+>/u";
 362:     $pat2 = "/<tr[^>]*>/u";
 363:     $pat3 = "/<t[hd][^>]*>(<[^>]+>)?([0-9\-\.]+)/u";
 364:     $pat31 = "/<td[^>]*>(.+)/u";
 365:     $pat4 = "/<t[hd][^>]*>支持(<[^>]+>)?する/u";
 366:     $pat5 = "/<t[hd][^>]*>(<[^>]+>)?支持(<[^>]+>)?しない/u";
 367:     $pat6 = "/<t[hd][^>]*>内閣/u";
 368:     $pat61 = "/<\/table>/u";
 369:     $pat7 = "/<td colspan\=\"([0-9]+)\"[^>]*>([^<]+)/u";
 370:     $pat8 = "/<td[^>]*>([^<]+)/u";
 371:     $pat9 = "/<h[0-9]+[^>]*>.*政党支持率<\/h[0-9]+>/u";
 372:     $flag = 0;
 373:     while (! feof($infp)) {
 374:         $instr = trim(fgets($infp));
 375:         if (($flag == 0&& (preg_match($pat1, $instr> 0)) {
 376:             $flag = 1;
 377:         } else if (($flag == 1&& (preg_match($pat2, $instr> 0)) {
 378:             $flag = 2;
 379:             $i = 0;
 380:         //月
 381:         } else if ($flag == 2) {
 382:             if (preg_match($pat4, $instr> 0) {
 383:                 $flag = 3;
 384:                 $i = 0;
 385:             } else if (preg_match($pat3, $instr, $arr> 0) {
 386:                 $items[$i]['month']   = $arr[2];
 387:                 $items[$i]['yes']     = '-';
 388:                 $items[$i]['no']      = '-';
 389:                 $items[$i]['cabinet'] = '-';
 390:                 $i++;
 391:             }
 392:         //支持する
 393:         } else if ($flag == 3) {
 394:             if (preg_match($pat5, $instr> 0) {
 395:                 $flag = 4;
 396:                 $i = 0;
 397:             } else if (preg_match($pat31, $instr, $arr> 0) {
 398:                 $items[$i]['yes'] = strip_tags($arr[1]);
 399:                 $i++;
 400:             }
 401:         //支持しない
 402:         } else if ($flag == 4) {
 403:             if (preg_match($pat31, $instr, $arr> 0) {
 404:                 $items[$i]['no'] = strip_tags($arr[1]);
 405:                 $i++;
 406:             } else if (preg_match($pat6, $instr> 0) {
 407:                 $flag = 5;
 408:                 $i = 0;
 409:             } else if (preg_match($pat61, $instr> 0) {
 410:                 $flag = 5;
 411:                 $i = 0;
 412:             }
 413:         //内閣
 414:         } else if ($flag == 5) {
 415:             if (preg_match($pat7, $instr, $arr> 0) {
 416:                 $n = $i + $arr[1];
 417:                 while ($i < $n) {
 418:                     $items[$i]['cabinet'] = $arr[2];
 419:                     $i++;
 420:                 }
 421:             } else if (preg_match($pat8, $instr, $arr> 0) {
 422:                 $items[$i]['cabinet'] = $arr[1];
 423:                 $i++;
 424:             } else if (preg_match($pat9, $instr> 0) {
 425:                 $flag = 6;
 426:             }
 427:         } else if ($flag == 6) {
 428:             break;
 429:         }
 430:     }
 431: 
 432:     return TRUE;
 433: }

 538: /**
 539:  * 政党支持率を解析する
 540:  * @param   int   $infp  解析中のURL
 541:  * @param   array $items 解析結果を格納する配列
 542:  * @return  bool TRUE/FALSE
 543: */
 544: function analyseParty($infp, &$items) {
 545:     $pat1 = "/<tr[^>]*>/u";
 546:     $pat2 = "/<t[hd][^>]*>(<[^>]+>)?([0-9\-\.]+)/u";
 547:     $pat3 = "/<\/tr>/u";
 548:     $pat4 = "/<t[hd][^>]*>([^<]+)/u";
 549:     $pat5 = "/<\/table>/u";
 550:     $flag = 0;
 551:     while (! feof($infp)) {
 552:         $instr = trim(fgets($infp));
 553:         if (($flag == 0&& (preg_match($pat1, $instr> 0)) {
 554:             $flag = 1;
 555:             $i = 0;
 556:         //月
 557:         } else if ($flag == 1) {
 558:             if (preg_match($pat2, $instr, $arr> 0) {
 559:                 $items[$i]['month'] = $arr[2];
 560:                 $i++;
 561:             } else if (preg_match($pat3, $instr> 0) {
 562:                 $flag = 2;
 563:                 $i = 0;
 564:             }
 565:         //政党支持率
 566:         } else if ($flag == 2) {
 567:             if (preg_match($pat2, $instr, $arr> 0) {
 568:                 $items[$i][$party] = $arr[2];
 569:                 $i++;
 570:             } else if (preg_match($pat4, $instr, $arr> 0) {
 571:                 $party = $arr[1];
 572:                 $i = 0;
 573:             } else if (preg_match($pat5, $instr> 0) {
 574:                 $flag = 3;
 575:             }
 576:         } else if ($flag == 3) {
 577:             break;
 578:         }
 579:     }
 580:     return TRUE;
 581: }

NHK政治意識月例調査をスクレイピングする処理は、ユーザー関数 analyseCabinet および analyseParty で行う。1行ずつ読み込み、正規表現を使ってデータを分離していく。
スクレイピングしたデータは、配列に格納する。

 435: /**
 436:  * 内閣支持率・政党支持率を解析する【2020年1月以降】
 437:  * @param   int   $infp  解析中のURL
 438:  * @param   array $items 内閣支持率の解析結果を格納する配列
 439:  * @param   array $items 政党支持率の解析結果を格納する配列
 440:  * @return  bool TRUE/FALSE
 441: */
 442: function analyse2020($infp, &$items1, &$items2) {
 443:     //プロットする政党名
 444:     static $table = array(
 445:     '', '自民党', '立憲民主党', '日本維新の会', '公明党', '共産党', '国民民主党', 'れいわ新選組', '社民党', 'みんなでつくる党', '参政党', '特に支持している政党はない', '支持政党なし');
 446: 
 447:     $pat01 = "/<i\s+class\=\"far\s+fa\-calendar\-alt\"><\/i>[0-9]+年([0-9]+)月<span>/ui";
 448:     $pat11 = "/<h3>内閣支持([0-9]+)[%\%]、不支持([0-9]+)[%\%]/ui";
 449:     $pat12 = "/(\p{Han}+内閣)を「支持する/ui";
 450:     $pat21 = "/<h2><span>政党支持率<\/span>/ui";
 451:     $pat31 = "/<td>([^<]+)<\/td>/ui";
 452:     $pat32 = "/<td\s+class\=\"right\">([0-9\.]+)<\/td>/ui";
 453:     //2023年8月以降
 454:     $pat22 = "/各党の支持率は/ui";
 455:     $pat33 = '/「([^」]+)」が([0-9\.]+)\%/ui';
 456: 
 457:     $flag = FALSE;
 458:     while (! feof($infp)) {
 459:         $instr1 = trim(fgets($infp));
 460:         $instr2 = strip_tags($instr1);
 461:         //月
 462:         if (preg_match($pat01, $instr1, $arr> 0) {
 463:             $month = (int)$arr[1];
 464:         //内閣支持率・不支持率
 465:         } else if (preg_match($pat11, $instr1, $arr> 0) {
 466:             $items1[$month - 1]['month'] = (int)$month;
 467:             $items1[$month - 1]['yes']   = (int)$arr[1];
 468:             $items1[$month - 1]['no']    = (int)$arr[2];
 469:         //内閣名
 470:         } else if (preg_match($pat12, $instr2, $arr> 0) {
 471:             $items1[$month - 1]['cabinet'] = (string)$arr[1];
 472:         //政党支持率の解析開始
 473:         } else if (preg_match($pat21, $instr1, $arr> 0) {
 474:             $flag = TRUE;
 475:             $items2[$month - 1]['month'] = (int)$month;
 476:         //政党名
 477:         } else if ($flag && (preg_match($pat31, $instr1, $arr> 0)) {
 478:             $party = (string)$arr[1];
 479:             if ($party == '特に支持している政党はない') {
 480:                 $party = '支持政党なし';
 481:             }
 482:         //支持率
 483:         } else if ($flag && (preg_match($pat32, $instr1, $arr> 0)) {
 484:             $items2[$month - 1][$party] = (string)$arr[1];
 485:         //政党名と支持率 2023年8月以降
 486:         } else if (preg_match($pat22, $instr1, $arr> 0) {
 487:             if (preg_match_all($pat33, $instr1, $arr, PREG_PATTERN_ORDER> 0) {
 488:                 foreach ($arr[1as $key=>$party) {
 489:                     if ($party == '特に支持している政党はない') {
 490:                         $party = '支持政党なし';
 491:                     }
 492:                     if (array_search($party, $table!FALSE) {
 493:                         $items2[$month - 1][$party] = (string)$arr[2][$key];
 494:                     }
 495:                 }
 496:             }
 497:         }
 498:     }
 499:     return TRUE;
 500: }

2020年(令和2年)4月以降は、NHK選挙WEBに移動し、ページのレイアウトが大きく変わったので、別のユーザー関数 analyse2020 を用意した。スクレイピングしたデータは、同様に配列に格納する。

解説:XMLへの格納

 502: /**
 503:  * 内閣支持率をXMLオブジェクトに追加/更新する
 504:  * @param   array  $items 解析結果を格納した配列
 505:  * @param   int    $year  西暦年
 506:  * @param   object $xml   XMLオブジェクト
 507:  * @return  bool TRUE/FALSE
 508: */
 509: function addCabinet($items, $year, &$xml) {
 510:     //西暦年の頭出し
 511:     $flag = FALSE;
 512:     foreach ($xml->research as $elem1) {
 513:         if ($elem1->year == $year) {
 514:             break;
 515:         }
 516:     }
 517:     //月次処理
 518:     foreach ($items as $item) {
 519:         foreach ($elem1->article as $elem2) {
 520:             if ($elem2->month == $item['month']) {
 521:                 $flag = TRUE;
 522:                 break;
 523:             }
 524:         }
 525:         if (! $flag) {
 526:             $elem2 = $elem1->addChild('article');
 527:             $elem2->addChild('month', $item['month']);
 528:         }
 529:         if (! isset($elem2->cabinet)) {
 530:             $elem2->addChild('cabinet');
 531:             $elem2->cabinet->addChild('name', $item['cabinet']);
 532:             $elem2->cabinet->addChild('yes', $item['yes']);
 533:             $elem2->cabinet->addChild('no', $item['no']);
 534:         }
 535:     }
 536: }

 583: /**
 584:  * 政党支持率をXMLオブジェクトに追加/更新する
 585:  * @param   array  $items 解析結果を格納した配列
 586:  * @param   int    $year  西暦年
 587:  * @param   object $xml   XMLオブジェクト
 588:  * @return  bool TRUE/FALSE
 589: */
 590: function addParty($items, $year, &$xml) {
 591:     //西暦年の頭出し
 592:     $flag = FALSE;
 593:     foreach ($xml->research as $elem1) {
 594:         if ($elem1->year == $year) {
 595:             break;
 596:         }
 597:     }
 598: 
 599:     //月次処理
 600:     foreach ($items as $item) {
 601:         foreach ($elem1->article as $elem2) {
 602:             if (isset($item['month']) && ($elem2->month == $item['month'])) {
 603:                 $flag = TRUE;
 604:                 break;
 605:             }
 606:         }
 607:         if (! $flag && isset($item['month'])) {
 608:             $elem2 = $elem1->addChild('article');
 609:             $elem2->addChild('month', $item['month']);
 610:         }
 611: 
 612:         foreach ($item as $key=>$val) {
 613:             if ($key == 'month')    continue;
 614:             $flag = FALSE;
 615:             foreach ($elem2->party as $elem3) {
 616:                 if (isset($elem3->name&& ($elem3->name == $key)) {
 617:                     $flag = TRUE;
 618:                     break;
 619:                 }
 620:             }
 621:             if (! $flag) {
 622:                 $elem4 = $elem2->addChild('party');
 623:                 $elem4->addChild('name', $key);
 624:                 $elem4->addChild('yes', $val);
 625:             }
 626:         }
 627:     }
 628: }

配列に格納したデータを、XMLオブジェクトへ展開していく処理は、ユーザー関数 addCabinet および addParty で行う。

解説:XMLファイルへのセーブとロード

 317: /**
 318:  * データ・ファイルを書き込む
 319:  * @param   string $fname 出力ファイル名
 320:  * @param   object XMLオブジェクト
 321:  * @return  bool TRUE/FALSE
 322: */
 323: function writeDataFile($fname, $xml) {
 324:     $str = $xml->asXML();
 325:     $str = cleanUpXML($str);
 326:     $outfp = fopen($fname, 'w');
 327:     fwrite($outfp, $str);
 328: 
 329:     return fclose($outfp);
 330: }

 290: /**
 291:  * データ・ファイルを読み込む
 292:  * @param   string $fname 入力ファイル名
 293:  * @return  object XMLオブジェクト/FALSE:読み込み失敗
 294: */
 295: function readDataFile($fname) {
 296:     $xmlstr =<<< EOT
 297: <?xml version="1.0" encoding="utf-8" ?>
 298: <polls>
 299: </polls>
 300: 
 301: EOT;
 302:     if (! isphp5over()) return FALSE;       //PHP5以上でないと動作しない
 303: 
 304:     //ファイル無し
 305:     if (! file_exists($fname)) {
 306:         $xml = new SimpleXMLElement($xmlstr);
 307: 
 308:     //ファイル有り
 309:     } else {
 310:         $xml = simplexml_load_file($fname);
 311:         if ($xml == FALSE)      return FALSE;
 312:     }
 313: 
 314:     return $xml;
 315: }

XMLオブジェクトをXMLファイルへセーブするユーザー関数は writeDataFile、ロードするユーザー関数は readDataFile である。

解説:jqPlotスクリプト

 746: /**
 747:  * jqPlot用のスクリプト:内閣支持率
 748:  * @param   object $xml XMLオブジェクト
 749:  * @param   int    $start  開始年
 750:  * @param   int    $finish 終了年
 751:  * @return  string スクリプト
 752: */
 753: function plotCabinet($xml, $start, $finish) {
 754:     //系列の生成
 755:     $series1 = '';      //内閣支持率
 756:     $series2 = '';      //内閣府支持率
 757:     $old = '';
 758:     foreach ($xml as $research) {
 759:         //描画範囲内かどうか
 760:         $year = $research->year;
 761:         if (($year < $start|| ($year > $finish))   continue;
 762:         //描画データ作成
 763:         foreach ($research as $article) {
 764:             $month = $article->month;
 765:             if ($month >1 && $month <12) {
 766:                 $name = '';
 767:                 if ($old != (string)$article->cabinet->name) {
 768:                     $name = (string)$article->cabinet->name;
 769:                     $old  = (string)$article->cabinet->name;
 770:                 }
 771:                 $yes = $article->cabinet->yes;
 772:                 $no  = $article->cabinet->no;
 773:                 if ($month > 0 && $yes > 0) {
 774:                     $series1 .sprintf("['%04d-%02d-01',%d,'<span style=\"background-color:#FFFFFF;\">%s</span>'],", $year, $month, $yes, $name);
 775:                     $series2 .sprintf("['%04d-%02d-01',%d,''],", $year, $month, $no);
 776:                 }
 777:             }
 778:         }
 779:     }
 780: 
 781:     //X軸の最小値
 782:     $xmin = sprintf('%04d-01-01', $start);
 783:     //X軸の最大値
 784:     if (date('Y') == $finish) {
 785:         $xmax = date('Y-m-01', strtotime('next month'));
 786:      } else {
 787:         $xmax = sprintf('%04d-12-31', $finish);
 788:     }
 789: 
 790:     //グラフ描画
 791:     $js =<<< EOT
 792: jQuery(function() {
 793:     jQuery.jqplot('jqPlot_polls',
 794:     [
 795:         [ {$series1} ],
 796:         [ {$series2} ]
 797:     ],
 798:     {
 799:         //系列
 800:         series: [
 801:             { label: '支持率',   color: '#88CC44' },
 802:             { label: '不支持率', color: '#CC4488'  }
 803:         ],
 804:         legend: {
 805:             show: true,
 806:             placement: 'inside',
 807:             location: 'sw',
 808:             renderer: $.jqplot.EnhancedLegendRenderer,
 809:             rendererOptions: { numberRows: 1 }
 810:         },
 811:         seriesDefaults: {
 812:             showLine: true,
 813:             rendererOptions: { smooth: false },
 814:             markerOptions: { size: 0 },
 815:             pointLabels: {
 816:                 show :true,
 817:                 escapeHTML: false,
 818:                 location: 'ne'
 819:             }
 820:         },
 821:         //軸
 822:         axes: {
 823:             xaxis: {
 824:                 renderer:$.jqplot.DateAxisRenderer,
 825:                 min: '{$xmin}',
 826:                 max: '{$xmax}',
 827:                 tickOptions: { formatString: '%Y/%#m' },
 828:                 label: '年月',
 829:             },
 830:             yaxis: {
 831:                 label: '支持率(%)'
 832:             }
 833:         },
 834:         //ハイライター
 835:         highlighter: {
 836:             show: true,
 837:             showMarker: true,
 838:             tooltipLocation: 'sw',
 839:             fadeTooltip: false,
 840:             bringSeriesToFront: true,
 841:             tooltipAxes: 'xy',
 842:             formatString: '%s<br />(不)支持率%s%'
 843:         }
 844:     }
 845:     );
 846: });
 847: 
 848: EOT;
 849: 
 850:     return $js;
 851: }

 853: /**
 854:  * jqPlot用のスクリプト:政党支持率
 855:  * @param   object $xml XMLオブジェクト
 856:  * @param   int $start  開始年
 857:  * @param   int $finish 終了年
 858:  * @return  string スクリプト
 859: */
 860: function plotParty($xml, $start, $finish) {
 861:     //プロットする政党名
 862:     static $table = array(
 863: '自民党', '公明党', '共産党', '民進党', '立憲民主党', '民主党', '日本維新の会', '国民民主党', 'れいわ新選組', '支持政党なし');
 864: 
 865:     //系列の生成
 866:     $str2 = '';
 867:     foreach ($table as $key=>$val) {
 868:         $str2 ."\t\t\t{ label: '{$val}' },\n";
 869:         $series[] = '';
 870:     }
 871: 
 872:     foreach ($xml as $research) {
 873:         $year = (int)$research->year;
 874:         //描画範囲内かどうか
 875:         if (($year < $start|| ($year > $finish))   continue;
 876:         //描画データ作成
 877:         foreach ($research as $article) {
 878:             $month = (int)$article->month;
 879:             foreach ($article as $party) {
 880:                 foreach ($table as $key=>$val) {
 881:                     if ($val == (string)$party->name) {
 882:                         if (preg_match('/[0-9\.]+/', $party->yes> 0 && $year > 0 && $month >1 && $month <12) {
 883:                             $series[$key.sprintf("['%04d-%02d-01',%4.1f],", $year, $month, (double)$party->yes);
 884:                         }
 885:                     }
 886:                 }
 887:             }
 888:         }
 889:     }
 890:     $str1 = '';
 891:     foreach ($series as $val) {
 892:         $str1 ."\t\t[ " . $val . "],\n";
 893:     }
 894: 
 895:     //X軸の最小値
 896:     $xmin = sprintf('%04d-01-01', $start);
 897:     if (date('Y') == $finish) {
 898:         $xmax = date('Y-m-01', strtotime('next month'));
 899:      } else {
 900:         $xmax = sprintf('%04d-12-31', $finish);
 901:     }
 902: 
 903:     //グラフ描画
 904:     $js =<<< EOT
 905: jQuery(function() {
 906:     jQuery.jqplot('jqPlot_polls',
 907:     [
 908: {$str1}
 909:     ],
 910:     {
 911:         //系列
 912:         series: [
 913: {$str2}
 914:         ],
 915:         legend: {
 916:             show: true,
 917:             placement: 'inside',
 918:             location: 'sw',
 919:             renderer: $.jqplot.EnhancedLegendRenderer,
 920:             rendererOptions: { numberRows: 1 }
 921:         },
 922:         seriesDefaults: {
 923:             showLine: true,
 924:             rendererOptions: { smooth: false },
 925:             markerOptions: { size: 0 },
 926:         },
 927:         //軸
 928:         axes: {
 929:             xaxis: {
 930:                 renderer: $.jqplot.DateAxisRenderer,
 931:                 min: '{$xmin}',
 932:                 max: '{$xmax}',
 933:                 tickOptions: { formatString: '%Y/%#m' },
 934:                 label: '年月',
 935:             },
 936:             yaxis: {
 937:                 label: '支持率(%)'
 938:             }
 939:         },
 940:         //ハイライター
 941:         highlighter: {
 942:             show: true,
 943:             showMarker: true,
 944:             tooltipLocation: 'sw',
 945:             fadeTooltip: false,
 946:             bringSeriesToFront: true,
 947:             tooltipAxes: 'xy',
 948:             formatString: '%s<br />支持率%s%'
 949:         }
 950:     }
 951:     );
 952: });
 953: 
 954: EOT;
 955: 
 956:     return $js;
 957: }

XMLオブジェクトに格納したデータを、jqPlotスクリプトへ展開していく処理は、ユーザー関数 plotCabinet および plotParty で行う。

ここでは、jqPlotプラグインの「jqplot.dateAxisRenderer.js」(時間軸を扱う)、「jqplot.highlighter.js」(ツールチップなどハイライト表示を行う)、「jqplot.pointLabels.js」(データポイントラベルを表示する)の3つを使っている。
jqPlotの使い方は、「jqPlot - jQuery プラグイン」(アルファシス)が詳しい。

参考サイト

(この項おわり)
header