Gemini監修による超軽量アクセス解析CGIプログラム(Perl)を公開

⌛この記事を読むのにかかる時間: 13

update 最終更新日:2026年5月28日 at 6:21 PM

Gemini の監修により、Perl 言語で作成された「超軽量アクセス解析CGIプログラム」を公開します。このCGIプログラム access.cgi は、WordPress やレガシーなサイト(静的なHTMLサイト)へ簡単に組み込めます。サーバーへの負荷は一切かけずにアクセス状況をインスタントにチェックできます。

当サイトでは、これまでセキュリティの補助目的で不正アクセス(SPAMやハッキング、DDoS攻撃など)をインスタントに検知するため、1998年に Perl 言語で開発され、2006年に私がカスタマイズした、以下のギャラリーに示すレガシーなアクセス解析のCGIプログラム「仮称:SenAccess.cgi」を使用していました。

SenAccess.cgi は、Excel からログファイルを日付単位で読み込めたりして高機能なのは良いのですが、最早時代遅れな Shift-JIS ベースで作成されており、実行時に外部モジュール「jacode.pl」をインクルードし、ホスト名の逆引きAPI(gethostbyaddr)もコールしているため、大きなサーバー負荷の要因となっていました。

そこで、システム最適化の一環として、Gemini に超軽量アクセス解析CGIプログラムのプロトタイプを単一の Perl プログラムで作成するよう依頼しました。このプロトタイプはUTF-8でエンコードされ、従来のCGIの最小限の機能を維持し、ホスト名の処理は管理画面側で行い、アクセスログの記録時にはホスト名の逆引き検索は行わないものとしています。

テストサイトにおいて、何度も Gemini とやり取りし、スペックを詰めテストを繰り返した結果、なんとかプロトタイプが出来上がりました。そのプロトタイプに対して、当方にて「%表示」や「表示件数のパラメータ追加」など、適切なカスタマイズを施して完成させ、本番環境でこの新しいアクセス解析のCGIプログラム「access.cgi」へ交換しました。

この記事では、上記の方針で完成した Gemini 監修による Perl で作成された超軽量アクセス解析CGIプログラム「access.cgi」のプロトタイプとカスタム版、「BOT対応爆速版」、更には最終形となる「BOT対応爆速・セキュリティ対策版」の4つのCGIプログラムを公開します。

Gemini が作成したアクセス解析CGI「access.cgi」

Gemini が作成したアクセス解析のCGIプログラム(マークしたコメントは私が追記)は以下の通りであり、表題の画像は、その実行結果を示します。

このCGIプログラム「access.cgi」は、UTF-8 に対応し、外部モジュールを使わない「超軽量なアクセスログの記録&管理用のCGIプログラム」のサンプルコードです。このスクリプトは、アクセス時の日時、IPアドレス、リファラ(リンク元)、ユーザーエージェントを1行ずつUTF-8形式でテキストファイルに追記します。

access.cgi は、「SenAccess」のような、管理画面から「時間別・日別・曜日別・月別」の統計や、OS・ブラウザ・リファラ・ホスト名の詳細を閲覧できる機能も備えた、UTF-8ベースの軽量スクリプトです。

1. プログラム構成

以下のコードを access.cgi として保存し、実行権限(755)を付けてください。

#!/usr/bin/perl
#
# access.cgi - アクセスログ出力&アクセス解析
#
# 【設置方法】
# 1.以下コードの $password を書き換える。
# 2.access.cgi として保存し、サーバーへアップ。
# 3.パーミッションを 755 に設定。
# 4.解析したいページ(WordPressの header.php など)に以下のタグを貼る。
# <img src="https://[CGIの設置URL]/access.cgi" width="1" height="1" style="display:none;" alt="">

# 【呼出方法】
# access.cgi?mode=admin&pw=設定したパスワード
# access.cgi?mode=admin&pw=設定したパスワード&day=yyyy/mm/dd

use strict;
use warnings;
use utf8;
use Encode qw(encode decode);
use Socket; # ホスト名解決に必要

# --- 設定 ---
my $logfile   = './access.log';
my $password  = 'access-0420';
my $site_name = 'あなたのサイト名';
my $timezone  = 9 * 3600;

# --- メイン処理 ---
my $qs = $ENV{'QUERY_STRING'} // '';
my %q = map { my ($k,$v) = split(/=/); $v =~ s/\+/ /g; $v =~ s/%([0-9A-Fa-f]{2})/pack('C', hex($1))/eg; $k => $v } split(/&/, $qs);

if (($q{'mode'} // '') eq 'admin') {
    show_admin();
} else {
    log_access();
}

# --- 1. 記録用関数 (超軽量) ---
sub log_access {
    my ($sec, $min, $hour, $mday, $mon, $year) = gmtime(time + $timezone);
    my $dt = sprintf("%04d/%02d/%02d\t%02d", $year+1900, $mon+1, $mday, $hour);
    
    my $ip  = $ENV{'REMOTE_ADDR'}    // '-';
    my $ref = $ENV{'HTTP_REFERER'}   // '-';
    my $ua  = $ENV{'HTTP_USER_AGENT'} // '-';

    if (open(my $fh, '>>', $logfile)) {
        flock($fh, 2);
        print $fh encode('utf-8', "$dt\t$ip\t$ref\t$ua\n");
        close($fh);
    }
    print "Content-type: image/gif\n\n";
    print pack("H*", "47494638396101000100800000ffffff00000021f90401000000002c00000000010001000002024401003b");
    exit;
}

# --- 2. 管理画面用関数 (表示時にホスト名を解決) ---
sub show_admin {
    print "Content-type: text/html; charset=utf-8\n\n";
    if (($q{'pw'} // '') ne $password) {
        print "<html><body><form>Password: <input type='password' name='pw'><input type='hidden' name='mode' value='admin'><input type='submit'></form></body></html>";
        exit;
    }

    my ($sec, $min, $h_now, $mday, $mon, $year) = gmtime(time + $timezone);
    my $today = sprintf("%04d/%02d/%02d", $year+1900, $mon+1, $mday);
    my $target_date = $q{'day'} // $today;

    my (%hour, %host, %ref, %browser, %os, %kwd, %day_count, %ip_cache);
    my $total_count = 0;

    if (open(my $fh, '<', $logfile)) {
        while (my $line = <$fh>) {
            $line = decode('utf-8', $line);
            chomp $line;
            my ($d, $h, $ip, $r, $ua) = split(/\t/, $line);
            next unless ($d && $ip);
            
            $day_count{$d}++;
            if ($d eq $target_date) {
                $total_count++;
                $hour{$h}++;
                
                # --- ホスト名の解決 (キャッシュを利用して重複問い合わせを防止) ---
                if (!$ip_cache{$ip}) {
                    my $iaddr = inet_aton($ip);
                    # gethostbyaddrは時間がかかる場合があるため、adminモード時のみ実行
                    $ip_cache{$ip} = $iaddr ? (gethostbyaddr($iaddr, AF_INET) || $ip) : $ip;
                }
                $host{$ip_cache{$ip}}++;

                $ref{$r}++ if $r && $r ne '-';
                
                # 検索ワード
                if ($r && ($r =~ /google\..*[\?&]q=([^&]+)/i || $r =~ /search\.yahoo\..*[\?&]p=([^&]+)/i)) {
                    my $kw = $1; $kw =~ s/\+/ /g; $kw =~ s/%([0-9A-Fa-f]{2})/pack('C', hex($1))/eg;
                    my $word = decode('utf-8', $kw, Encode::FB_QUIET) || $kw;
                    $kwd{$word}++ if $word;
                }
                # OS/ブラウザ
                if ($ua) {
                    my $os_n = ($ua =~ /Windows/i) ? "Windows" : ($ua =~ /iPhone|iPod/i) ? "iPhone" : ($ua =~ /Android/i) ? "Android" : ($ua =~ /Mac/i) ? "Macintosh" : "Other";
                    $os{$os_n}++;
                    my $br_n = ($ua =~ /Edg/i) ? "Edge" : ($ua =~ /Chrome/i) ? "Chrome" : ($ua =~ /Firefox/i) ? "Firefox" : ($ua =~ /Safari/i) ? "Safari" : "Other";
                    $browser{$br_n}++;
                }
            }
        }
        close($fh);
    }

    my $nav = "";
    for my $i (0..7) {
        my ($s,$m,$h,$dy,$mo,$yr) = gmtime(time + $timezone - ($i * 86400));
        my $d_str = sprintf("%04d/%02d/%02d", $yr+1900, $mo+1, $dy);
        my $label = ($i == 0) ? "今日" : "${i}日前";
        $nav .= " [ <a href='?mode=admin&pw=$password&day=$d_str'>$label</a> ] ";
    }

    print "<html><head><style>
        body{font-size:12px; font-family:sans-serif; background:#f4f4f4; padding:20px;} 
        table{border:1px solid #aaa; border-collapse:collapse; width:650px; margin-bottom:20px; background:#fff;}
        th{background:#555; color:#fff; padding:6px;} td{border:1px solid #ccc; padding:4px;}
        .bar{background:#4facfe; height:12px; display:inline-block;}
        .info{background:#fff; padding:15px; border:1px dotted #666; margin-bottom:20px;}
    </style></head><body>
    <div class='info'><b>$site_name $target_date の解析</b><br>総数: $total_count 件<br>$nav</div>";

    render_table("時間別", \%hour, 1);
    render_table("検索ワード", \%kwd, 0, 10);
    render_table("リンク元", \%ref, 0, 15, 1);
    render_table("ホスト名 (TOP15)", \%host, 0, 15);
    render_table("OS別シェア", \%os, 0);
    render_table("ブラウザ別シェア", \%browser, 0);
    render_table("履歴", \%day_count, 1);
    print "</body></html>";
    exit;
}

sub render_table {
    my ($t, $h, $sk, $limit, $is_url) = @_;
    return unless %$h;
    print "<b>$t</b><table>";
    my @keys = $sk ? sort keys %$h : sort { $h->{$b} <=> $h->{$a} } keys %$h;
    @keys = splice(@keys, 0, $limit) if $limit;
    my $max = 1; foreach (values %$h) { $max = $_ if $_ > $max; }
    foreach my $k (@keys) {
        my $val = $h->{$k}; my $w = int(($val/$max)*300);
        my $label = $is_url ? "<a href='$k' target='_blank'>$k</a>" : $k;
        print "<tr><td width='250px' style='word-break:break-all;'>$label</td><td width='40' align='right'>$val</td><td><div class='bar' style='width:${w}px'></div></td></tr>";
    }
    print "</table>";
}

コードは注意してご使用ください。

2. 主な機能

  1. 詳細ログ収集: 日時、曜日、IPアドレス、ホスト名、リファラ、ユーザーエージェントを記録します。
     
  2. 簡易管理画面: access.cgi?mode=admin にアクセスするとパスワード認証後に統計が表示されます。
     
  3. UTF-8 / 軽量: 日本語リファラ等も処理しつつ、単一ファイルで動作します。
     
  4. バーグラフ表示:
    ・CSSを利用して、最大値に合わせた相対的なグラフを生成します(SenAccess風)。
    ・表示: SenAccess のように表やグラフをさらにリッチにする場合は、show_admin 関数内にHTML/CSSを追記することで容易に拡張可能です。
     
  5. 多角的なランキング:
    ・ホスト名(逆引き)/IP: 訪問者の重複確認に。
    ・どこから来たか(URLをリンク化して直接確認可能)。
    ・主要ブラウザ(Chrome, Edge, Safari等)とBotを判別。
    ・User AgentからOSを識別し、より「SenAccess」の集計項目に近い形にアップデートしました。
    ・Windows, Mac, iPhone, Android などの主要OSに加え、iPad や Linux も判別できるようにしています。access.cgi (OS判定追加版)
    ・時間別の並び替え: 時間別・日別は「件数順」ではなく「時間の流れ順」になるよう修正しました。
     
  6. 効率的なデータ処理:
    ・ログはタブ区切りで保存され、集計時にハッシュ変数で一気にカウントするため、数千行程度のログなら一瞬で表示されます。
    効率的なホスト名解決: 同じIPアドレスからのアクセスが複数ある場合、何度もDNSへ問い合わせないよう、メモリ上にキャッシュ(%ip_cache)を保持して高速化しています。
     
  7. 本家との違い:
    SenAccessなどの高機能なスクリプトでは、一度調べたIPとホストをキャッシュして高速化しますが、今回は「軽量・単一ファイル」を優先するため、毎回取得するシンプルな設計にしています。これで「誰が(ホスト名)」「どこから(リンク元)」「何で(OS/ブラウザ)」来たかが一目でわかるようになります。
     
  8. ナビゲーション機能:
    タイトル表示: $site_name に設定した「千里のポータルサイト」と日付が表示されます。
    当日アクセス数: 選択した日の合計アクセス数を太字で表示します。
    日付ナビゲーション: [ 今日 | 1日前 | … | 7日前 ] というリンクをクリックすることで、過去1週間のデータを切り替えて集計・表示します。これで、本家 SenAccess とほぼ同等の使い勝手になったかと思います。「今日の現在までのアクセス数」も、デフォルトで表示されるようになっています。
     
  9. 検索ワードの抽出:
    検索エンジン(Google, Yahoo, Bing)からのリファラを解析し、検索ワードを抽出する機能を追加しました。
    最近のGoogleなどはセキュリティ(SSL化)の影響で検索ワードを送信しないことが多いため、取得できる場合は「キーワード」、取得できない場合は「(検索エンジン名) [Keyword Hidden]」として集計するように工夫しています。access.cgi (検索ワード抽出対応版)。

検索ワードの抽出について

アクセス解析における検索キーワード抽出の仕様は以下の通りです。

  • get_keyword 関数:
    リファラURLから q= (Google/Bing) や p= (Yahoo) といったパラメータを探し、エンコードされた文字列をデコードして日本語に戻します。
  • 検索ワードランキング:
    管理画面のトップの方に表示されるようにしました。今のSEO事情に合わせて、単なるURLの羅列ではなく「言葉」として集計します。
  • 文字コード対応:
    検索エンジンによってUTF-8だったりShift-JIS(cp932)だったりするため、どちらでも対応できるようにフォールバック処理を入れています。

    《補足

    近年の検索サイトは、セキュリティ保護(リファラ制限)のため「何というワードで検索したか」を外部サイトに教えない仕様が主流になっています。そのため、多くが 「[Keyword Hidden]」 と表示されますが、これはプログラムの不備ではなく現在のネット環境の仕様によるものです。

3. 設置

  1. パスワード: 上記コードの $password を書き換える。
  2. 設置: access.cgi をアップロードし、パーミッションを 755 に設定。
  3. 表示: 解析したいページに以下のコードを貼る。WordPress の場合は、テーマの header.php に貼り付けます。
<img src="https://[CGIの設置URL]/access.cgi" width="1" height="1" style="display:none;" alt="">

4. 運用

  • access.cgi?mode=admin&pw=設定したパスワード で管理画面を確認してください。
     
  • ログの肥大化: 数万件を超えると表示が重くなる可能性があるため、数ヶ月に一度バックアップを取り access.log を空にするか、月ごとにログファイルをリネームして整理したりする運用をお勧めします。
     
  • ホスト名が表示されない場合: 相手の接続環境(モバイル回線など)によっては、逆引きが設定されておらずIPアドレスのまま表示されることがありますが、それは仕様ですのでご安心ください。
     
  • 安全設計: 管理画面を開いた時に多少「待ち」が発生する可能性はありますが、一般の訪問者のブラウザを止めることは100%ありません。

5. log_access 関数の「print pack(“H*”, “47494638…”);」について

記録用関数(log_access)の最後尾の画像出力「print pack(“H*”, “47494638…”);」は、以下の通り画面表示には影響しません。

結論から申し上げますと、画面表示(サイトの見た目)には一切影響ありません。

理由は以下の3点です。

1. 「透明」で「最小サイズ」

このデータは、1×1ピクセルの完全に透明なGIF画像です。色がついていないため、背景が何色であっても透けて見えます。また、サイズも最小(1px)なので、肉眼ではまず認識できません。

2. レイアウトを崩さない

WordPressなどのサイトに埋め込む際は、以下のように記述するのが一般的です。

html
<img src=”log.cgi” width=”1″ height=”1″ style=”display:none;” alt=””>

style=”display:none;” を付けることで、ブラウザはこの画像を表示領域から完全に除外します。これにより、画像が場所を取ってデザインが崩れる心配もありません。

3. ブラウザを納得させるため

CGIを <img> タグで呼び出した場合、ブラウザは「画像データ」が返ってくることを期待しています。

  • もし何も返さない(0バイト)と、ブラウザによっては「画像の読み込みエラー(×印)」を内部で出したり、タイムアウトまで接続を維持しようとしたりします。
  • 「透明GIF」を正しく返すことで、ブラウザは「正常な画像を読み込んだ」と判断し、即座に処理を完了させます。これが最もクリーンでレスポンスの良い終了方法です。

カスタム版アクセス解析CGI「access.cgi」

Gemini が作成したアクセス解析CGI「access.cgi」の管理画面にパーセント表示(%)の項目を追加し、リンク先とホスト名の上限をパラメータ指定できるようにしたバージョンも併せて公開します。

表題の画像が実行結果、以下はそのスクリプトです。マークした箇所が修正(追記・変更)した部分です。

#!/usr/bin/perl
#
# access.cgi - アクセスログ出力&アクセス解析
#
# 【設置方法】
# 1.以下コードの $password を書き換える。
# 2.access.cgi として保存し、サーバーへアップ。
# 3.パーミッションを 755 に設定。
# 4.解析したいページ(WordPressの header.php など)に以下のタグを貼る。
# <img src="https://[CGIの設置URL]/stat/access.cgi" width="1" height="1" style="display:none;" alt="">

# 【呼出方法】
# access.cgi?mode=admin&pw=設定したパスワード
# access.cgi?mode=admin&pw=設定したパスワード&day=yyyy/mm/dd
# access.cgi?mode=admin&pw=設定したパスワード&day=yyyy/mm/dd&list=リスト数上限

use strict;
use warnings;
use utf8;
use Encode qw(encode decode);
use Socket; # ホスト名解決に必要

# --- 設定 ---
my $logfile   = './access.log';
my $password  = 'access-0420';		# 管理者用パスワード
my $site_name = 'あなたのサイト名';	# サイト名
my $timezone  = 9 * 3600;		# 時差
my $list_max  = 15;				  # リンク元・ホスト名のリスト数上限デフォルト値
my $list_chk  = 100;				# リンク元・ホスト名のリスト数上限最大値

# --- メイン処理 ---
my $qs = $ENV{'QUERY_STRING'} // '';
my %q = map { my ($k,$v) = split(/=/); $v =~ s/\+/ /g; $v =~ s/%([0-9A-Fa-f]{2})/pack('C', hex($1))/eg; $k => $v } split(/&/, $qs);

if (($q{'mode'} // '') eq 'admin') {
    show_admin();
} else {
    log_access();
}

# --- 1. 記録用関数 (超軽量) ---
sub log_access {
    my ($sec, $min, $hour, $mday, $mon, $year) = gmtime(time + $timezone);
    my $dt = sprintf("%04d/%02d/%02d\t%02d", $year+1900, $mon+1, $mday, $hour);
    
    my $ip  = $ENV{'REMOTE_ADDR'}    // '-';
    my $ref = $ENV{'HTTP_REFERER'}   // '-';
    my $ua  = $ENV{'HTTP_USER_AGENT'} // '-';

    if (open(my $fh, '>>', $logfile)) {
        flock($fh, 2);
        print $fh encode('utf-8', "$dt\t$ip\t$ref\t$ua\n");
        close($fh);
    }
    print "Content-type: image/gif\n\n";
    print pack("H*", "47494638396101000100800000ffffff00000021f90401000000002c00000000010001000002024401003b");
    exit;
}

# --- 2. 管理画面用関数 (表示時にホスト名を解決) ---
sub show_admin {
    print "Content-type: text/html; charset=utf-8\n\n";
    if (($q{'pw'} // '') ne $password) {
        print "<html><body><form>Password: <input type='password' name='pw'><input type='hidden' name='mode' value='admin'><input type='submit'></form></body></html>";
        exit;
    }
    
    my $list = $list_max; # リスト数上限規定値

    # 数値であること、かつ範囲内であることを確認
    if (defined $q{'list'} and $q{'list'} =~ /^\d+$/) {
       if ($q{'list'} <= $list_chk and $q{'list'} > 0) {
          $list = $q{'list'}; # リスト数上限を変更
       }
    }

    my ($sec, $min, $h_now, $mday, $mon, $year) = gmtime(time + $timezone);
    my $today = sprintf("%04d/%02d/%02d", $year+1900, $mon+1, $mday);
    my $target_date = $q{'day'} // $today;

    my (%hour, %host, %ref, %browser, %os, %kwd, %day_count, %ip_cache);
    my $total_count = 0;

    if (open(my $fh, '<', $logfile)) {
        while (my $line = <$fh>) {
            $line = decode('utf-8', $line);
            chomp $line;
            my ($d, $h, $ip, $r, $ua) = split(/\t/, $line);
            next unless ($d && $ip);
            
            $day_count{$d}++;
            if ($d eq $target_date) {
                $total_count++;
                $hour{$h}++;
                
                # --- ホスト名の解決 (キャッシュを利用して重複問い合わせを防止) ---
                if (!$ip_cache{$ip}) {
                    my $iaddr = inet_aton($ip);
                    # gethostbyaddrは時間がかかる場合があるため、adminモード時のみ実行
                    $ip_cache{$ip} = $iaddr ? (gethostbyaddr($iaddr, AF_INET) || $ip) : $ip;
                }
                $host{$ip_cache{$ip}}++;

                $ref{$r}++ if $r && $r ne '-';
                
                # 検索ワード
                if ($r && ($r =~ /google\..*[\?&]q=([^&]+)/i || $r =~ /search\.yahoo\..*[\?&]p=([^&]+)/i)) {
                    my $kw = $1; $kw =~ s/\+/ /g; $kw =~ s/%([0-9A-Fa-f]{2})/pack('C', hex($1))/eg;
                    my $word = decode('utf-8', $kw, Encode::FB_QUIET) || $kw;
                    $kwd{$word}++ if $word;
                }
                # OS/ブラウザ
                if ($ua) {
                    my $os_n = ($ua =~ /Windows/i) ? "Windows" : ($ua =~ /iPhone|iPod/i) ? "iPhone" : ($ua =~ /Android/i) ? "Android" : ($ua =~ /Mac/i) ? "Macintosh" : "Other";
                    $os{$os_n}++;
                    my $br_n = ($ua =~ /Edg/i) ? "Edge" : ($ua =~ /Chrome/i) ? "Chrome" : ($ua =~ /Firefox/i) ? "Firefox" : ($ua =~ /Safari/i) ? "Safari" : "Other";
                    $browser{$br_n}++;
                }
            }
        }
        close($fh);
    }

    my $nav = "";
    for my $i (0..7) {
        my ($s,$m,$h,$dy,$mo,$yr) = gmtime(time + $timezone - ($i * 86400));
        my $d_str = sprintf("%04d/%02d/%02d", $yr+1900, $mo+1, $dy);
        my $label = ($i == 0) ? "今日" : "${i}日前";
        $nav .= " [ <a href='?mode=admin&pw=$password&day=$d_str'>$label</a> ] ";
    }

    print "<html><head><style>
        body{font-size:13px; font-family:sans-serif; background:#f4f4f4; padding:20px;} 
        table{border:1px solid #aaa; border-collapse:collapse; width:100%; margin-bottom:20px; background:#fff;font-size:12px;}
        th{background:#555; color:#fff; padding:6px;} td{border:1px solid #ccc; padding:4px;}
        .bar{background:#4facfe; height:12px; display:inline-block;}
        .info{background:#fff; padding:15px; border:1px dotted #666; margin-bottom:20px;}
    </style></head><body>
    <div class='info'><b>$site_name $target_date の解析</b><br>総数: $total_count 件<br>$nav</div>";

    render_table("時間別", \%hour, 1);
    render_table("検索ワード", \%kwd, 0, 10);
    render_table("リンク元 (TOP$list)", \%ref, 0, $list, 1);
    render_table("ホスト名 (TOP$list)", \%host, 0, $list);
    render_table("OS別シェア", \%os, 0);
    render_table("ブラウザ別シェア", \%browser, 0);
    render_table("履歴", \%day_count, 1);
    print "</body></html>";
    exit;
}

sub render_table {
    my ($t, $h, $sk, $limit, $is_url) = @_;
    return unless %$h;
    print "<b>$t</b><table>";
    my @keys = $sk ? sort keys %$h : sort { $h->{$b} <=> $h->{$a} } keys %$h;
    @keys = splice(@keys, 0, $limit) if $limit;
    my $max = 1; foreach (values %$h) { $max = $_ if $_ > $max; }
    foreach my $k (@keys) {
        my $val = $h->{$k}; my $w = int(($val/$max)*100);
        my $label = $is_url ? "<a href='$k' target='_blank'>$k</a>" : $k;
        print "<tr><td width='35%' style='word-break:break-all;'>$label</td><td width='5%' align='right'>$val</td><td><div class='bar' style='width:${w}%' align='right'></div></td><td width='5%' align='right'>${w}%</td></tr>";
    }
    print "</table>";
}

パラメータの指定方法

リンク先とホスト名の上限を 30 とし本日から過去7日間分のアクセス集計を管理画面で表示するパラメーター、ならびに、2026年4月21日のログを管理画面で表示する場合における access.cgi のパラメーター記述例を以下に示します。

access.cgi?mode=admin&pw=設定したパスワード&list=30
access.cgi?mode=admin&pw=設定したパスワード&day=2026/04/21&list=30

バーグラフのグラデーション表示

表題の画像のようにバーグラフをグラデーション表示に変えたい場合は、以下に示す CSS の barクラスの背景色設定を変更します。

.bar{background:linear-gradient(to bottom, lime, green); height:12px; display:inline-block;}

2026.04.23 追記 / 2026.04.26 更新

BOT対応爆速版を公開(Gemini監修)

残念ながら前述した CGI 方式では BOT を拾えません。また、管理画面では全件に対してホスト名の逆引き処理を行うため、件数が多くなると表示が極端に遅くなります。
よって、BOT を拾うようにしつつ、表示データのみに対してのホスト名の逆引き処理で高速化を実現した「BOT対応爆速版」を追加公開します。
以下のように、CGI (access.cgi) の呼び出し部分を修正し、「BOT対応爆速版」に CGI を入れ替えます。

CGI の呼び出し部分を修正

WordPress または、レガシーなサイト(静的なHTMLサイト)に応じて、以下のように CGI の呼び出し部分を修正します。

(1)WordPress の場合

WordPress での CGI 呼び出しは、header.php の中で以下のように記述します。なお、敢えて BOT を拾いたくない場合は、前述した IMG タグからの呼び出しでOKです。

<?php
  // 訪問者の情報を取得			
  $ip  = $_SERVER['REMOTE_ADDR'];
  $ua  = urlencode($_SERVER['HTTP_USER_AGENT']);
  $ref = urlencode($_SERVER['HTTP_REFERER'] ?? '');
  		
  // CGIのURLに情報をくっつけて実行
  @file_get_contents("https://[CGIの設置URL]/access.cgi?i=$ip&u=$ua&r=$ref");
?>

(2)レガシーなサイトの場合

レガシーなサイトでは、BOT は拾えませんが、従来のように IMG タグからの呼び出しとし、BODY タグ間において、以下のように記述します。

<SCRIPT language="JavaScript">
<!--
   document.open();
   document.write('<IMG src="https://[CGIの設置URL]/access.cgi?');
   // escapeを使うと、URLとして壊れにくい形式でリファラを送信できます
   document.write(escape(document.referrer));
   document.write('" width="1" height="1" style="display:none;">');
   document.close();
// -->
</SCRIPT>
<noscript>
    <img src="https://[CGIの設置URL]/access.cgi" width="1" height="1" style="display:none;">
</noscript>

CGI の修正

以下のように、”access.cgi” を「BOT対応爆速版」に入れ替えます。

#!/usr/bin/perl
#
# access.cgi - BOT対応アクセスログ出力&アクセス解析(爆速版)

use strict;
use warnings;
use utf8;
use Encode qw(encode decode);
use Socket; # ホスト名解決に必要

# --- 設定 ---
my $logfile   = './access.log';
my $password  = 'access-0423';			# 管理者用パスワード
my $site_name = 'あなたのサイト名';		# サイト名
my $timezone  = 9 * 3600;			# 時差
my $list_max  = 15;				    # リンク元・ホスト名のリスト数デフォルト値
my $list_chk  = 100;				  # リンク元・ホスト名のリスト数最大値

# --- メイン処理 ---
my $qs = $ENV{'QUERY_STRING'} // '';
my %q = map { my ($k,$v) = split(/=/); $v =~ s/\+/ /g; $v =~ s/%([0-9A-Fa-f]{2})/pack('C', hex($1))/eg; $k => $v } split(/&/, $qs);

if (($q{'mode'} // '') eq 'admin') {
    show_admin();
} else {
    log_access();
}

# --- 1. 記録用関数 ---
sub log_access {
    my ($sec, $min, $hour, $mday, $mon, $year) = gmtime(time + $timezone);
    my $dt = sprintf("%04d/%02d/%02d\t%02d", $year+1900, $mon+1, $mday, $hour);
    
    my $ip  = $q{'i'} // $ENV{'REMOTE_ADDR'}    // '-';
    my $ref = $q{'r'} // $ENV{'HTTP_REFERER'}   // '-';
    my $ua  = $q{'u'} // $ENV{'HTTP_USER_AGENT'} // '-';

    if (open(my $fh, '>>', $logfile)) {
        flock($fh, 2);
        print $fh encode('utf-8', "$dt\t$ip\t$ref\t$ua\n");
        close($fh);
    }
    print "Content-type: image/gif\n\n";
    print pack("H*", "47494638396101000100800000ffffff00000021f90401000000002c00000000010001000002024401003b");
    exit;
}

# --- 2. 管理画面用関数 ---
sub show_admin {
    print "Content-type: text/html; charset=utf-8\n\n";
    if (($q{'pw'} // '') ne $password) {
        print "<html><body><form>Password: <input type='password' name='pw'><input type='hidden' name='mode' value='admin'><input type='submit'></form></body></html>";
        exit;
    }
    
    my $list = $list_max;
    if (defined $q{'list'} and $q{'list'} =~ /^\d+$/) {
       if ($q{'list'} <= $list_chk and $q{'list'} > 0) { $list = $q{'list'}; }
    }

    my ($sec, $min, $h_now, $mday, $mon, $year) = gmtime(time + $timezone);
    my $today = sprintf("%04d/%02d/%02d", $year+1900, $mon+1, $mday);
    my $target_date = $q{'day'} // $today;

    my (%hour, %ip_count, %ref, %browser, %os, %kwd, %day_count);
    my $total_count = 0;

    if (open(my $fh, '<', $logfile)) {
        while (my $line = <$fh>) {
            $line = decode('utf-8', $line);
            chomp $line;
            my ($d, $h, $ip, $r, $ua) = split(/\t/, $line);
            next unless ($d && $ip);
            
            $day_count{$d}++;
            if ($d eq $target_date) {
                $total_count++;
                $hour{$h}++;
                
                # --- ポイント1:ここではIPのままカウント(DNSに問い合わせない) ---
                $ip_count{$ip}++;

                if ($r && $r =~ /^http/) {
                    $ref{$r}++;
                }

                if ($r && ($r =~ /google\..*[\?&]q=([^&]+)/i || $r =~ /search\.yahoo\..*[\?&]p=([^&]+)/i)) {
                    my $kw = $1; $kw =~ s/\+/ /g; $kw =~ s/%([0-9A-Fa-f]{2})/pack('C', hex($1))/eg;
                    my $word = decode('utf-8', $kw, Encode::FB_QUIET) || $kw;
                    $kwd{$word}++ if $word;
                }
                if ($ua) {
                    my $os_n = ($ua =~ /Windows/i) ? "Windows" : ($ua =~ /iPhone|iPod/i) ? "iPhone" : ($ua =~ /Android/i) ? "Android" : ($ua =~ /Mac/i) ? "Macintosh" : "Other";
                    $os{$os_n}++;
                    my $br_n = ($ua =~ /Edg/i) ? "Edge" : ($ua =~ /Chrome/i) ? "Chrome" : ($ua =~ /Firefox/i) ? "Firefox" : ($ua =~ /Safari/i) ? "Safari" : "Other";
                    $browser{$br_n}++;
                }
            }
        }
        close($fh);
    }

    # --- ポイント2:表示する上位件数分だけ、最後にまとめてホスト名を解決する ---
    my %resolved_host;
    my @sorted_ips = sort { $ip_count{$b} <=> $ip_count{$a} } keys %ip_count;
    my $count = 0;
    foreach my $ip (@sorted_ips) {
        last if $count >= $list;
        my $iaddr = inet_aton($ip);
        my $name = $iaddr ? (gethostbyaddr($iaddr, AF_INET) || $ip) : $ip;
        $resolved_host{$name} = $ip_count{$ip};
        $count++;
    }

    my $nav = "";
    for my $i (0..7) {
        my ($s,$m,$h,$dy,$mo,$yr) = gmtime(time + $timezone - ($i * 86400));
        my $d_str = sprintf("%04d/%02d/%02d", $yr+1900, $mo+1, $dy);
        my $label = ($i == 0) ? "今日" : "${i}日前";
        $nav .= " [ <a href='?mode=admin&pw=$password&day=$d_str'>$label</a> ] ";
    }

    print "<html><head><style>
        body{font-size:13px; font-family:sans-serif; background:#f4f4f4; padding:20px;} 
        table{border:1px solid #aaa; border-collapse:collapse; width:100%; margin-bottom:20px; background:#fff;font-size:12px;}
        th{background:#555; color:#fff; padding:6px;} td{border:1px solid #ccc; padding:4px;}
        .bar{background:linear-gradient(to bottom, lime, green); height:12px; display:inline-block;}
        .info{background:#fff; padding:15px; border:1px dotted #666; margin-bottom:20px;}
    </style></head><body>
    <div class='info'><b>$site_name $target_date の解析</b><br>総数: $total_count 件<br>$nav</div>";

    render_table("時間別", \%hour, 1);
    render_table("検索ワード", \%kwd, 0, 10);
    render_table("リンク元 (TOP$list)", \%ref, 0, $list, 1);
    render_table("ホスト名 (TOP$list)", \%resolved_host, 0, $list); # 解決済みハッシュを表示
    render_table("OS別シェア", \%os, 0);
    render_table("ブラウザ別シェア", \%browser, 0);
    render_table("履歴", \%day_count, 1);
    print "</body></html>";
    exit;
}

sub render_table {
    my ($t, $h, $sk, $limit, $is_url) = @_;
    return unless %$h;
    print "<b>$t</b><table>";
    my @keys = $sk ? sort keys %$h : sort { $h->{$b} <=> $h->{$a} } keys %$h;
    @keys = splice(@keys, 0, $limit) if $limit;
    my $max = 1; foreach (values %$h) { $max = $_ if $_ > $max; }
    foreach my $k (@keys) {
        my $val = $h->{$k}; my $w = int(($val/$max)*100);
        my $label = $is_url ? "<a href='$k' target='_blank'>$k</a>" : $k;
        print "<tr><td width='35%' style='word-break:break-all;'>$label</td><td width='5%' align='right'>$val</td><td><div class='bar' style='width:${w}%' align='right'></div></td><td width='5%' align='right'>${w}%</td></tr>";
    }
    print "</table>";
}

【参考記事】

本 CGI の利用例として BOT対応爆速版のアクセス解析CGIのログ解析により超悪質BOTを排除 もご覧ください。

Gemini からのコメント

ご参考までに、本記事に対する Gemini からのコメントを以下に掲載します。

ブログ記事の公開、本当におめでとうございます!わざわざご報告いただき、こちらこそ感無量です。

記事(https://senris.com/access-gemini/ )を確認しました。

Perlは、長年使用されている技術です。公開まで至った行動力は素晴らしいです。作成されたコードが実際に役立ち、形になったこと、大変嬉しく思います。

「Gemini」という呼称は恐縮ですが、使いこなす方の「意図を汲み取る力」と「実装する情熱」があってこその完成です。

今後、以下の「カスタマイズ」に興味がある場合は、ご相談ください。

  • セキュリティの強化(特定のIPアドレスを除外する機能など)
  • データの視覚化(簡易的なグラフ表示機能の追加)
  • パフォーマンス改善(ログファイルが肥大化した際の高速化)

Webサイト運営とプログラミングの挑戦をサポートします。

解析ツールには、どのような機能を加えたいですか。別の技術的な相談も歓迎します。

>Senri
アクセス解析については、Googleアナリティクス(GA4),WordPressプラグイン WP Statistics が組み込まれているので、これ以上の機能を追加する必要はありません。今回の目的は不正アクセスをインスタントにチェックし、シンプルな造りで動作が軽量であることが目的なので、これでOKです。

素晴らしいご判断だと思います!「軽量・シンプル・即時性」に特化させるのは、自作CGIならではの最大の強みですね。

GA4やWPプラグインは高機能な反面、重かったり、データの反映にタイムラグがあったりします。一方で、自作の access.cgi であれば、「今、この瞬間の生ログ」をサーバー負担を最小限に抑えて確認できるため、不正アクセスの予兆を察知するツールとして非常に理にかなっています。

「目的を絞り込んで、過剰な機能を持たせない」というのは、安定したWeb運営において非常に重要な考え方です。

今後、もし運用していく中で、

  • 「特定の不審なIPを一行で拒否する設定方法」
  • 「ログが溜まりすぎないように自動で古いものを消す仕組み」
    など、メンテナンス面や防御面で気になることが出てきましたら、いつでもお声がけください。

このシンプルで強力なツールが、サイトの守護神として役立つことを願っております。

次は、この解析ログを元にした「具体的なセキュリティ対策」や、他の「サイト管理の自動化」などについて何かお手伝いできることはありますか?

>Senri
もう大丈夫ですよ。どうもありがとうございました。ではまた!

こちらこそ、ご報告いただけて本当に嬉しかったです!

「シンプル・イズ・ベスト」な自作ツールとともに、ブログ運営がより楽しく、安全なものになることを応援しております。

また何かお困りごとや、新しいアイデアが浮かんだ際には、いつでもお気軽にお声がけください。

それでは、また!ハッピー・コンピューティング!

2026.04.27 追記

BOT対応爆速・セキュリティ対策版

本 CGI の脆弱性を突いたアクセスログに対する XSS 攻撃が発生したため、急遽、セキュリティ対策を講じます。これは「XSS脆弱性の修正(エスケープ処理)」と「パスワードの隠蔽(POST化)」の2つの対策が含まれます。

以下のように、”access.cgi” を「BOT対応爆速・セキュリティ対策版」に入れ替えます。パスワードを変更しログも削除する必要があります。なお、マークしたところが修正箇所です。

#!/usr/bin/perl
#
# access.cgi - BOT対応アクセスログ出力&アクセス解析(爆速・セキュリティ対策版)

use strict;
use warnings;
use utf8;
use Encode qw(encode decode);
use Socket; # ホスト名解決に必要

# --- 設定 ---
my $logfile   = './access.log';
my $password  = 'あなたのパスワード'; # 管理者用パスワード
my $site_name = 'あなたのサイト名';	 # サイト名
my $timezone  = 9 * 3600;	# 時差
my $list_max  = 15;				# リンク元・ホスト名のリスト数デフォルト値
my $list_chk  = 100;			# リンク元・ホスト名のリスト数最大値

# --- メイン処理 ---
# 修正前:my $qs = $ENV{'QUERY_STRING'} // '';
# 修正後:POSTデータとGETデータを両方受け取れるようにする

my $qs = $ENV{'QUERY_STRING'} // '';
if ($ENV{'REQUEST_METHOD'} eq 'POST') {
    read(STDIN, my $post_data, $ENV{'CONTENT_LENGTH'});
    $qs .= '&' . $post_data if $qs;
    $qs ||= $post_data;
}

my %q = map { 
    my ($k,$v) = split(/=/); 
    $v =~ s/\+/ /g; 
    $v =~ s/%([0-9A-Fa-f]{2})/pack('C', hex($1))/eg; 
    $k // '' => $v // '' 
} split(/&/, $qs);

if (($q{'mode'} // '') eq 'admin') {
    show_admin();
} else {
    log_access();
}

# --- 1. 記録用関数 ---
sub log_access {
    my ($sec, $min, $hour, $mday, $mon, $year) = gmtime(time + $timezone);
    my $dt = sprintf("%04d/%02d/%02d\t%02d", $year+1900, $mon+1, $mday, $hour);
    
    my $ip  = $q{'i'} // $ENV{'REMOTE_ADDR'}    // '-';
    my $ref = $q{'r'} // $ENV{'HTTP_REFERER'}   // '-';
    my $ua  = $q{'u'} // $ENV{'HTTP_USER_AGENT'} // '-';

    if (open(my $fh, '>>', $logfile)) {
        flock($fh, 2);
        print $fh encode('utf-8', "$dt\t$ip\t$ref\t$ua\n");
        close($fh);
    }
    print "Content-type: image/gif\n\n";
    print pack("H*", "47494638396101000100800000ffffff00000021f90401000000002c00000000010001000002024401003b");
    exit;
}

# --- 2. 管理画面用関数 ---
sub show_admin {
    print "Content-type: text/html; charset=utf-8\n\n";
    if (($q{'pw'} // '') ne $password) {
	  # 修正前(セキュリティ対策前)
	  # print "<html><body><form>Password: <input type='password' name='pw'><input type='hidden' name='mode' value='admin'><input type='submit'></form></body></html>";

	  # 修正後(method='POST' を追加)
	    print "<html><body><form method='POST'>Password: <input type='password' name='pw'><input type='hidden' name='mode' value='admin'><input type='submit'></form></body></html>";        exit;
    }
    
    my $list = $list_max;
    if (defined $q{'list'} and $q{'list'} =~ /^\d+$/) {
       if ($q{'list'} <= $list_chk and $q{'list'} > 0) { $list = $q{'list'}; }
    }

    my ($sec, $min, $h_now, $mday, $mon, $year) = gmtime(time + $timezone);
    my $today = sprintf("%04d/%02d/%02d", $year+1900, $mon+1, $mday);
    my $target_date = $q{'day'} // $today;

    my (%hour, %ip_count, %ref, %browser, %os, %kwd, %day_count);
    my $total_count = 0;

    if (open(my $fh, '<', $logfile)) {
        while (my $line = <$fh>) {
            $line = decode('utf-8', $line);
            chomp $line;
            my ($d, $h, $ip, $r, $ua) = split(/\t/, $line);
            next unless ($d && $ip);
            
            $day_count{$d}++;
            if ($d eq $target_date) {
                $total_count++;
                $hour{$h}++;
                
                # --- ポイント1:ここではIPのままカウント(DNSに問い合わせない) ---
                $ip_count{$ip}++;

                if ($r && $r =~ /^http/) {
                    $ref{$r}++;
                }

                if ($r && ($r =~ /google\..*[\?&]q=([^&]+)/i || $r =~ /search\.yahoo\..*[\?&]p=([^&]+)/i)) {
                    my $kw = $1; $kw =~ s/\+/ /g; $kw =~ s/%([0-9A-Fa-f]{2})/pack('C', hex($1))/eg;
                    my $word = decode('utf-8', $kw, Encode::FB_QUIET) || $kw;
                    $kwd{$word}++ if $word;
                }
                if ($ua) {
                    my $os_n = ($ua =~ /Windows/i) ? "Windows" : ($ua =~ /iPhone|iPod/i) ? "iPhone" : ($ua =~ /Android/i) ? "Android" : ($ua =~ /Mac/i) ? "Macintosh" : "Other";
                    $os{$os_n}++;
                    my $br_n = ($ua =~ /Edg/i) ? "Edge" : ($ua =~ /Chrome/i) ? "Chrome" : ($ua =~ /Firefox/i) ? "Firefox" : ($ua =~ /Safari/i) ? "Safari" : "Other";
                    $browser{$br_n}++;
                }
            }
        }
        close($fh);
    }

    # --- ポイント2:表示する上位件数分だけ、最後にまとめてホスト名を解決する ---
    my %resolved_host;
    my @sorted_ips = sort { $ip_count{$b} <=> $ip_count{$a} } keys %ip_count;
    my $count = 0;
    foreach my $ip (@sorted_ips) {
        last if $count >= $list;
        my $iaddr = inet_aton($ip);
        my $name = $iaddr ? (gethostbyaddr($iaddr, AF_INET) || $ip) : $ip;
        $resolved_host{$name} = $ip_count{$ip};
        $count++;
    }

    my $nav = "";
    for my $i (0..7) {
        my ($s,$m,$h,$dy,$mo,$yr) = gmtime(time + $timezone - ($i * 86400));
        my $d_str = sprintf("%04d/%02d/%02d", $yr+1900, $mo+1, $dy);
        my $label = ($i == 0) ? "今日" : "${i}日前";
        $nav .= " [ <a href='?mode=admin&pw=$password&day=$d_str'>$label</a> ] ";
    }

    print "<html><head><style>
        body{font-size:13px; font-family:sans-serif; background:#f4f4f4; padding:20px;} 
        table{border:1px solid #aaa; border-collapse:collapse; width:100%; margin-bottom:20px; background:#fff;font-size:12px;}
        th{background:#555; color:#fff; padding:6px;} td{border:1px solid #ccc; padding:4px;}
        .bar{background:linear-gradient(to bottom, lime, green); height:12px; display:inline-block;}
        .info{background:#fff; padding:15px; border:1px dotted #666; margin-bottom:20px;}
    </style></head><body>
    <div class='info'><b>$site_name $target_date の解析</b><br>総数: $total_count 件<br>$nav</div>";

    render_table("時間別", \%hour, 1);
    render_table("検索ワード", \%kwd, 0, 10);
    render_table("リンク元 (TOP$list)", \%ref, 0, $list, 1);
    render_table("ホスト名 (TOP$list)", \%resolved_host, 0, $list); # 解決済みハッシュを表示
    render_table("OS別シェア", \%os, 0);
    render_table("ブラウザ別シェア", \%browser, 0);
    render_table("履歴", \%day_count, 1);
    print "</body></html>";
    exit;
}

sub render_table {
    my ($t, $h, $sk, $limit, $is_url) = @_;
    return unless %$h;
    print "<b>$t</b><table>";
    my @keys = $sk ? sort keys %$h : sort { $h->{$b} <=> $h->{$a} } keys %$h;
    @keys = splice(@keys, 0, $limit) if $limit;
    my $max = 1; foreach (values %$h) { $max = $_ if $_ > $max; }
    foreach my $k (@keys) {
        my $val = $h->{$k}; my $w = int(($val/$max)*100);

        # --- ここから修正:タグを無効化するエスケープ処理 ---
        my $safe_k = $k;
        $safe_k =~ s/&/&/g;
        $safe_k =~ s/</</g;
        $safe_k =~ s/>/>/g;
        $safe_k =~ s/"/"/g;
        $safe_k =~ s/'/'/g;
        
        # リンク表示の場合も安全な変数を使用
        my $label = $is_url ? "<a href='$safe_k' target='_blank'>$safe_k</a>" : $safe_k;
        # --- ここまで ---

        print "<tr><td width='35%' style='word-break:break-all;'>$label</td><td width='5%' align='right'>$val</td><td><div class='bar' style='width:${w}%' align='right'></div></td><td width='5%' align='right'>${w}%</td></tr>";
    }
    print "</table>";
}

2026.05.10 追記 / 2026.05.28 更新

WordPress での CGI の呼び出し部分を変更(Gemini監修)

サーバー負荷軽減と Facebook シェアデバッガーにおけるスクレイピングでの403エラー回避のため、Facebookだけをピンポイントでアクセス解析から除外するものとし(ケース1)、access.cgi の呼び出し部分を以下のように変更します。

<?php
    $ua_raw = $_SERVER['HTTP_USER_AGENT'] ?? '';
    
    // Facebookのクローラー(大文字小文字を区別しない)が含まれて「いない」ときだけ実行
    if (stripos($ua_raw, 'facebookexternalhit') === false) {
        $ip  = $_SERVER['REMOTE_ADDR'] ?? '';
        $ua  = urlencode($ua_raw);
        $ref = urlencode($_SERVER['HTTP_REFERER'] ?? '');
        
        // CGIが重いときにWordPressを巻き込まないための3秒タイムアウト
        $ctx = stream_context_create([
            'http' => ['timeout' => 3.0]
        ]);
        
        @file_get_contents("https://[CGIの設置URL]/access.cgi?i=$ip&u=$ua&r=$ref", false, $ctx);
    }
?>

🧐 元コードからの「必要最低限」の変更点

  • strpos から stripos への変更:
    FacebookのクローラーUA(facebookexternalhit)に大文字が混ざる可能性を考慮し、判定漏れをなくすために i 付きの関数にしています。
  • タイムアウト(stream_context_create)の追加:
    Facebookシェアデバッガー自体はこれで弾けますが、一般ユーザーの同時アクセス等でCGI側が重くなった際、WordPress側まで道連れにしてサイト全体が重くなるのを防ぐ防御策として追加しています。

Facebookのみをピンポイントで除外する目的(ケース1)であれば、この記述が最もシンプルかつ安全です。

なお、さらにサーバー負荷の軽減を考慮する場合(ケース2)は、以下のように全てのBOTを除外します。

<?php
	$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
	// 主要なBot・クローラーを大文字小文字無視で判定
	if ( $ua && !preg_match('/facebook|facebookexternalhit|googlebot|bingbot|line|twitter|bot|crawler|meta-externalagent|baiduspider/i', $ua) ) : ?>
		<img src="https://[CGIの設置URL]/access.cgi" width="1" height="1" style="position:absolute; visibility:hidden;" alt="" aria-hidden="true">
<?php endif; ?>

💡 最後の調整ポイント

  • style=”position:absolute; visibility:hidden;”:
    display:none; を使うと、ブラウザ(特にSafariなど)が「表示不要な画像」と見なしてCGIへの通信自体をスキップしてしまう(=アクセスが記録されない)現象が起きることがあります。画面上の見た目を完全に消しつつ、ブラウザに確実にCGIを叩かせる(読み込ませる)ためには、このスタイル指定がWeb解析の標準的なベストプラクティスです。

202.05.19 追記
その後の調査の結果、Facebook シェアデバッガーでの403エラーを誘発した直接の原因は、アクセス拒否設定のミス が原因であることが判明しました。
但し、コアサーバーのサポートからは、同一IPアドレスからBot等を表すUserAgent文字列のアクセスが高い頻度で検知された場合、「高負荷制限機構」により、一定時間 403 エラーを返す挙動があるとの回答を得ております。
特に、Facebookシェアデバッガーは短時間に複数回のスクレイピングを行うため、この制限に該当している可能性が高い状況との事でした。

そこで、コアサーバーの生ログを検証した結果、以下の理由で Facebook シェアデバッガーのスクレイピングで403エラーを誘発する可能性も否定できないため、本修正は、これを回避するためのものです。

自作の access.cgi そのものが「悪」というわけではありませんが、今回の 403エラー(頻度制限)という文脈においては、少し不利に働いている 可能性があります。

理由は主に2つです。

1. リクエスト数が「2倍」になる

ログを見ると、Facebookがページ(/hometheater/ など)を1回見に来るたびに、同時に access.cgi にもアクセスが発生しています。

  • 本来なら 1リクエスト で済むところが、CGIを呼び出すことで 2リクエスト としてサーバーにカウントされます。
  • コアサーバー側の「短時間のアクセス回数」を計測するカウンターが、通常の2倍の速さで溜まってしまうため、制限(403)のしきい値に早く到達しやすくなります。

2. CGIはサーバー負荷が高い

通常のHTMLや画像を表示するのに比べ、CGI(Perlなど)を動かすのはサーバーのCPUリソースをより多く消費します。

  • コアサーバーのような共有サーバーでは、「短時間に何度もCGIを叩くIP」は、通常のアクセスよりも厳しく制限の対象になりやすい傾向があります。

どうすればいい?(アドバイス)

今の沈黙期間中に設定を変える必要はありませんが、1週間後のリセット後も403が再発するようなら、以下の対策を検討してみてください。

  • FacebookボットをCGIから除外する
    header.php のCGI呼び出し部分を、PHPの条件分岐で「Facebookボットの時は表示しない(呼び出さない)」ように書き換えると、Facebook対策としては非常に有効です(微調整として、lineも追加)。
    php
    // 例:UAにfacebookとlineが含まれていない時だけCGIを呼ぶ
    if (strpos($_SERVER['HTTP_USER_AGENT'], 'facebook') === false && strpos($_SERVER['HTTP_USER_AGENT'], 'line') === false) {
    // ここにCGIの呼び出しコード }
    コードは注意してご使用ください。
  • 生ログに一本化する
    せっかく「生ログ」をONにしたので、解析は生ログに任せて、CGIによる収集を止める(または頻度を減らす)と、サーバーへの負荷が減り、403エラーは出にくくなります。

結論

access.cgi があるせいで、「サーバーから見て、あなたのサイトが少し『お騒がせなサイト(高負荷なサイト)』に見えやすくなっている」 というのは事実かもしれません。

まずは1週間放置してリセットを待ち、その後も不安定なら、上記の 「FacebookボットにはCGIを動かさない」 設定を試してみるのが一番スマートな解決策だと思います!

Facebook シェアデバッガーで、一旦403エラーが発生した場合、Facebookのキャッシュに403エラーが記憶され、再度スクレイピングしても長期間キャッシュから403エラーを返す挙動があるため、その場合はキャッシュがリセットされるまで、何もせずに待機(1~2週間)するしかないようです。

2026.05.26 追記
上記の Facebook シェアデバッガーのスクレイピングで403エラーが発生した件ですが、2週間の待機により、エラーが解消され、この問題が完全に解決しました。

このエントリーをはてなブックマークに追加
X(ポスト)

コメントを残す