Plugin Directory

Changeset 3743355


Ignore:
Timestamp:
10/13/2026 09:15:13 AM (12 days ago)
Author:
redkirin
Message:

1.3.0: in-app browser classification, wasted spend estimate, form submission verification, thank-you page gating, evidence CSV export, alert emails

Location:
redkirin-ad-click-verification
Files:
4 added
26 edited
1 copied

Legend:

Unmodified
Added
Removed
  • redkirin-ad-click-verification/tags/1.3.0/assets/css/admin.css

    r3743276 r3743355  
    7878tr.rkacv-dimmed th, tr.rkacv-dimmed td { opacity: .45; transition: opacity .15s; }
    7979tr.rkacv-dimmed input, tr.rkacv-dimmed textarea, tr.rkacv-dimmed select { pointer-events: none; }
     80.rkacv-toolbar-right { display: flex; gap: 6px; align-items: center; }
  • redkirin-ad-click-verification/tags/1.3.0/assets/js/rkacv.js

    r3743325 r3743355  
    271271                        debug('human verified', r);
    272272                        dlPush({ event: 'rkacv_human_verified', rkacv_human: true });
    273                         if (cfg.verifiedPV) {
     273                        var thanks = onThanksPage();
     274                        if (thanks && !recentFormSubmit()) {
     275                                // サンクスページに直接到達(フォーム送信を経ていない): 検証済みページビューを出さない。
     276                                debug('thanks page reached without a verified form submit');
     277                                sendLog('blocked', 'thanks', String(w.location.pathname), r, 'no_form_submit');
     278                        } else if (cfg.verifiedPV) {
     279                                if (thanks) {
     280                                        dlPush({ event: 'rkacv_thanks_view', rkacv_page_path: String(w.location.pathname), rkacv_form_verified: true });
     281                                        sendLog('verified', 'thanks', String(w.location.pathname), r);
     282                                }
    274283                                // page_view の代わりにキーイベントにできる「検証済みページビュー」。
    275284                                dlPush({
    … …  
    492501                        }
    493502                } catch (e) { /* noop */ }
     503        }
     504
     505        /* ------------------------------------------------------------------ */
     506        /* フォーム送信の保護                                                   */
     507        /* ------------------------------------------------------------------ */
     508
     509        function formExcluded(form) {
     510                var sels = cfg.formExclude || [];
     511                for (var i = 0; i < sels.length; i++) {
     512                        try {
     513                                if (form.matches(sels[i]) || form.closest(sels[i])) {
     514                                        return true;
     515                                }
     516                        } catch (e) { /* 不正なセレクタは無視 */ }
     517                }
     518                return false;
     519        }
     520
     521        function setHidden(form, name, value) {
     522                var el = form.querySelector('input[name="' + name + '"]');
     523                if (!el) {
     524                        el = d.createElement('input');
     525                        el.type = 'hidden';
     526                        el.name = name;
     527                        form.appendChild(el);
     528                }
     529                el.value = value;
     530        }
     531
     532        function markFormVerified() {
     533                try {
     534                        w.sessionStorage.setItem('rkacv_form_ok', JSON.stringify({ t: Date.now(), path: w.location.pathname }));
     535                } catch (e) { /* noop */ }
     536        }
     537
     538        function onSubmit(e) {
     539                if (!cfg.forms) {
     540                        return;
     541                }
     542                var form = e.target;
     543                if (!form || form.tagName !== 'FORM' || formExcluded(form)) {
     544                        return;
     545                }
     546                var r = evaluate(e, form);
     547                var verdict = r.ok ? 'human' : (r.hard ? 'bot' : 'undetermined');
     548                var reasons = r.reasons.concat(r.notes || []).join(',');
     549
     550                // 判定結果を隠しフィールドで同送(CF7 / WPForms はサーバー側で bot を迷惑メール扱い、他は CRM 側で判別可能)。
     551                setHidden(form, 'rkacv_verdict', verdict);
     552                setHidden(form, 'rkacv_reasons', reasons);
     553                setHidden(form, 'rkacv_dwell', String(r.dwell));
     554                setHidden(form, 'rkacv_signals', String(r.signals));
     555                setHidden(form, 'rkacv_token', cfg.logToken || '');
     556
     557                var label = '<form' + (form.id ? '#' + form.id : '') + (form.getAttribute('action') ? ' ' + String(form.getAttribute('action')).slice(0, 80) : '') + '>';
     558
     559                if (r.ok) {
     560                        state.lastVerifiedAt = now();
     561                        markFormVerified();
     562                        dlPush({ event: 'rkacv_form_submit', rkacv_form_id: form.id || '', rkacv_form_action: String(form.getAttribute('action') || '').slice(0, 200), rkacv_dwell_ms: r.dwell, rkacv_signals: r.signals });
     563                        sendLog('verified', 'form', label, r);
     564                        return;
     565                }
     566                sendLog('blocked', 'form', label, r);
     567                debug('form submit unverified', verdict, r.reasons);
     568
     569                if (r.hard && cfg.formBlockHard) {
     570                        // bot 兆候が明確なら送信そのものを止める(人間の可能性が残る場合は通す)。
     571                        e.preventDefault();
     572                        e.stopImmediatePropagation();
     573                        e.stopPropagation();
     574                }
     575        }
     576
     577        /* ------------------------------------------------------------------ */
     578        /* サンクスページの到達検証                                             */
     579        /* ------------------------------------------------------------------ */
     580
     581        function onThanksPage() {
     582                var paths = cfg.thanksPaths || [];
     583                var cur = w.location.pathname.replace(/\/+$/, '') + '/';
     584                for (var i = 0; i < paths.length; i++) {
     585                        var p = String(paths[i]).replace(/\/+$/, '') + '/';
     586                        if (cur === p || cur.indexOf(p) === 0) {
     587                                return true;
     588                        }
     589                }
     590                return false;
     591        }
     592
     593        function recentFormSubmit() {
     594                try {
     595                        var raw = w.sessionStorage.getItem('rkacv_form_ok');
     596                        if (!raw) {
     597                                return false;
     598                        }
     599                        var rec = JSON.parse(raw);
     600                        return rec && Date.now() - rec.t < (cfg.thanksWindow || 1800000);
     601                } catch (e) {
     602                        return false;
     603                }
    494604        }
    495605
    … …  
    841951
    842952        w.addEventListener('click', onClick, true);
     953        w.addEventListener('submit', onSubmit, true);
    843954        checkIp();
    844955        trackAdVisit();
  • redkirin-ad-click-verification/tags/1.3.0/includes/class-rkacv-ad-visits.php

    r3743244 r3743355  
    6161                        network VARCHAR(20) NOT NULL DEFAULT '',
    6262                        source VARCHAR(40) NOT NULL DEFAULT '',
     63                        app VARCHAR(40) NOT NULL DEFAULT '',
    6364                        extra VARCHAR(1000) NOT NULL DEFAULT '',
    6465                        repeat_ip TINYINT UNSIGNED NOT NULL DEFAULT 0,
    … …  
    7778
    7879        /**
     80         * UA から流入アプリ(アプリ内ブラウザー)を判定。通常のブラウザーは空文字。
     81         *
     82         * @param string $ua UA。
     83         * @return string
     84         */
     85        public static function app_from_ua( $ua ) {
     86                $ua = (string) $ua;
     87                $map = array(
     88                        '/YJApp-(IOS|ANDROID)|jp\.co\.yahoo\./i' => 'Yahoo! JAPAN',
     89                        '/\bLine\/|\bLIFF\b/'                    => 'LINE',
     90                        '/FBAN|FBAV|FB_IAB/'                         => 'Facebook',
     91                        '/Instagram/'                               => 'Instagram',
     92                        '/TwitterAndroid|Twitter for iPhone/'       => 'X',
     93                        '/\bGSA\//'                                => 'Google app',
     94                        '/SmartNews/i'                              => 'SmartNews',
     95                        '/Gunosy/i'                                 => 'Gunosy',
     96                        '/NewsPicks/i'                              => 'NewsPicks',
     97                        '/TikTok|musical_ly|BytedanceWebview/i'     => 'TikTok',
     98                        '/Pinterest/'                               => 'Pinterest',
     99                        '/MicroMessenger/'                          => 'WeChat',
     100                        '/Snapchat/'                                => 'Snapchat',
     101                        '/\bDuckDuckGo\//'                         => 'DuckDuckGo',
     102                        '/Mercari/i'                                => 'Mercari',
     103                        '/\bwv\)/'                                 => 'Android WebView (other)',
     104                );
     105                foreach ( $map as $re => $label ) {
     106                        if ( preg_match( $re, $ua ) ) {
     107                                return $label;
     108                        }
     109                }
     110                // iOS: Safari 本体には "Safari/" が付く。無い場合はアプリ内ブラウザー。
     111                if ( false !== strpos( $ua, 'iPhone' ) || false !== strpos( $ua, 'iPad' ) ) {
     112                        if ( false === strpos( $ua, 'Safari/' ) && false === strpos( $ua, 'CriOS' ) && false === strpos( $ua, 'FxiOS' ) && false === strpos( $ua, 'EdgiOS' ) ) {
     113                                return 'iOS in-app (other)';
     114                        }
     115                }
     116                return '';
     117        }
     118
     119        /**
    79120         * クエリ文字列の連想配列を列値に変換。
    80121         *
    … …  
    155196                        'network'      => substr( sanitize_text_field( $row['network'] ), 0, 20 ),
    156197                        'source'       => substr( sanitize_text_field( $row['source'] ), 0, 40 ),
     198                        'app'          => substr( self::app_from_ua( $row['user_agent'] ), 0, 40 ),
    157199                        'extra'        => substr( (string) $row['extra'], 0, 1000 ),
    158200                        'repeat_ip'    => $repeat_ip,
    … …  
    268310        public static function breakdown( $column, $days, $limit = 20 ) {
    269311                global $wpdb;
    270                 if ( ! in_array( $column, array( 'campaign_id', 'placement', 'device', 'source', 'network', 'adgroup_id' ), true ) ) {
     312                if ( ! in_array( $column, array( 'campaign_id', 'placement', 'device', 'source', 'network', 'adgroup_id', 'app' ), true ) ) {
    271313                        return array();
    272314                }
    … …  
    368410
    369411        /**
     412         * 証跡エクスポート用: 期間内の bot(任意で保留も)着地。
     413         *
     414         * @param int  $days               日数。
     415         * @param bool $include_undetermined 保留も含める。
     416         * @return array
     417         */
     418        public static function evidence_rows( $days, $include_undetermined = false ) {
     419                global $wpdb;
     420                $table = self::table();
     421                $where = $include_undetermined ? "verdict IN ('bot','undetermined')" : "verdict = 'bot'";
     422                // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
     423                return $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE created_at >= %s AND {$where} ORDER BY id ASC LIMIT 5000", self::since( $days ) ), ARRAY_A );
     424        }
     425
     426        /**
    370427         * URL から gclid 系の値を取り出す(クリックログとの突合用)。
    371428         *
  • redkirin-ad-click-verification/tags/1.3.0/includes/class-rkacv-admin.php

    r3743325 r3743355  
    1919                add_action( 'admin_post_rkacv_add_cidr', array( __CLASS__, 'handle_add_cidr' ) );
    2020                add_action( 'admin_post_rkacv_clear_visits', array( __CLASS__, 'handle_clear_visits' ) );
     21                add_action( 'admin_post_rkacv_export', array( __CLASS__, 'handle_export' ) );
    2122                add_action( 'admin_enqueue_scripts', array( __CLASS__, 'assets' ) );
    2223                add_filter( 'plugin_action_links_' . plugin_basename( RKACV_PLUGIN_FILE ), array( __CLASS__, 'action_links' ) );
    … …  
    7677                RKACV_Ad_Visits::truncate();
    7778                wp_safe_redirect( add_query_arg( array( 'page' => self::PAGE, 'tab' => 'ads', 'rkacv_msg' => 'visits_cleared' ), admin_url( 'options-general.php' ) ) );
     79                exit;
     80        }
     81
     82        /**
     83         * 証跡 CSV(無効クリックの申し立て用)。
     84         */
     85        public static function handle_export() {
     86                if ( ! current_user_can( 'manage_options' ) ) {
     87                        wp_die( esc_html__( 'You do not have permission to do this.', 'redkirin-ad-click-verification' ) );
     88                }
     89                check_admin_referer( 'rkacv_export' );
     90                $days = isset( $_GET['days'] ) ? (int) $_GET['days'] : 7;
     91                if ( ! in_array( $days, array( 7, 30, 90 ), true ) ) {
     92                        $days = 7;
     93                }
     94                $with_und = ! empty( $_GET['undetermined'] );
     95
     96                nocache_headers();
     97                header( 'Content-Type: text/csv; charset=UTF-8' );
     98                header( 'Content-Disposition: attachment; filename="ad-click-verification-evidence-' . gmdate( 'Ymd' ) . '.csv"' );
     99                $out = fopen( 'php://output', 'w' ); // phpcs:ignore WordPress.WP.AlternativeFunctions
     100                fwrite( $out, "\xEF\xBB\xBF" ); // phpcs:ignore WordPress.WP.AlternativeFunctions
     101                fputcsv( $out, array( 'datetime', 'type', 'verdict_or_reasons', 'click_id', 'campaign_id', 'placement', 'app', 'device', 'ip_anonymised', 'user_agent', 'page_url', 'dwell_ms', 'signals', 'link_url' ) );
     102                foreach ( RKACV_Ad_Visits::evidence_rows( $days, $with_und ) as $r ) {
     103                        fputcsv( $out, array( get_date_from_gmt( $r['created_at'], 'Y-m-d H:i:s' ), 'landing', $r['verdict'] . ( $r['reasons'] ? ' (' . $r['reasons'] . ')' : '' ), $r['gclid'], $r['campaign_id'], $r['placement'], $r['app'], $r['device'], $r['ip'], $r['user_agent'], $r['page_url'], $r['dwell_ms'], $r['signals'], '' ) );
     104                }
     105                foreach ( RKACV_Log::evidence_rows( $days ) as $r ) {
     106                        fputcsv( $out, array( get_date_from_gmt( $r['created_at'], 'Y-m-d H:i:s' ), 'click', $r['reasons'], $r['click_id'], '', '', RKACV_Ad_Visits::app_from_ua( $r['user_agent'] ), '', $r['ip'], $r['user_agent'], $r['page_url'], $r['dwell_ms'], $r['signals'], $r['link_url'] ) );
     107                }
     108                fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions
    78109                exit;
    79110        }
    … …  
    317348
    318349        /** 基本設定タブの項目。 */
    319         const BASIC_KEYS = array( 'scope', 'verified_pageview', 'bot_tag_block', 'anonymize_ip', 'datalayer_event', 'debug', 'dedupe_daily_limit', 'dedupe_enabled', 'dedupe_window_min', 'dwell_signal_ms', 'enabled', 'gtag_direct', 'guard_datalayer', 'guarded_events', 'intercept_clicks', 'link_patterns', 'log_enabled', 'log_retention_days', 'log_verified', 'min_dwell_ms', 'min_signals' );
     350        const BASIC_KEYS = array( 'scope', 'verified_pageview', 'bot_tag_block', 'form_enabled', 'form_block_hard', 'form_exclude', 'thanks_paths', 'thanks_window_min', 'anonymize_ip', 'datalayer_event', 'debug', 'dedupe_daily_limit', 'dedupe_enabled', 'dedupe_window_min', 'dwell_signal_ms', 'enabled', 'gtag_direct', 'guard_datalayer', 'guarded_events', 'intercept_clicks', 'link_patterns', 'log_enabled', 'log_retention_days', 'log_verified', 'min_dwell_ms', 'min_signals' );
    320351
    321352        /** 高度な設定タブの項目。 */
    322         const ADVANCED_KEYS = array( 'ad_capture_params', 'ad_report_delay_ms', 'ad_retention_days', 'ad_tracking', 'ad_trigger_params', 'bot_ua_patterns', 'deny_cidrs', 'deny_enabled', 'deny_ua', 'deny_ua_patterns', 'deny_webdriver', 'extra_cidrs', 'ip_check' );
     353        const ADVANCED_KEYS = array( 'cpc_default', 'cpc_by_campaign', 'currency_symbol', 'alert_enabled', 'alert_email', 'alert_bot_share', 'alert_min_landings', 'alert_repeat_clicks', 'alert_digest', 'ad_capture_params', 'ad_report_delay_ms', 'ad_retention_days', 'ad_tracking', 'ad_trigger_params', 'bot_ua_patterns', 'deny_cidrs', 'deny_enabled', 'deny_ua', 'deny_ua_patterns', 'deny_webdriver', 'extra_cidrs', 'ip_check' );
    323354
    324355        private static function render_settings( $section = 'basic' ) {
    … …  
    347378                                self::field_number( $s, 'ad_report_delay_ms', __( 'Verdict delay (ms)', 'redkirin-ad-click-verification' ), __( 'How long to wait after the page starts loading before the landing is scored and sent. If the visitor leaves earlier, it is sent at that moment. Recommended 10000.', 'redkirin-ad-click-verification' ), 0, 120000, 'ad_tracking log_enabled' );
    348379                                self::field_number( $s, 'ad_retention_days', __( 'Ad landing retention (days)', 'redkirin-ad-click-verification' ), '', 1, 365, 'ad_tracking log_enabled' );
     380                                ?>
     381                        </table>
     382
     383                        <h2><?php esc_html_e( 'Wasted spend estimate', 'redkirin-ad-click-verification' ); ?></h2>
     384                        <table class="form-table" role="presentation">
     385                                <?php
     386                                self::field_number( $s, 'cpc_default', __( 'Average CPC', 'redkirin-ad-click-verification' ), __( 'Average cost per click used for "bot landings x CPC" in the Ad traffic report and the dashboard. 0 hides the estimate.', 'redkirin-ad-click-verification' ), 0, 1000000 );
     387                                self::field_textarea( $s, 'cpc_by_campaign', __( 'CPC per campaign', 'redkirin-ad-click-verification' ), __( 'One line per campaign as <code>campaign_id=cpc</code>, e.g. <code>21505995745=180</code>. Campaigns not listed use the average CPC.', 'redkirin-ad-click-verification' ), 4 );
     388                                self::field_text( $s, 'currency_symbol', __( 'Currency symbol', 'redkirin-ad-click-verification' ) );
     389                                ?>
     390                        </table>
     391
     392                        <h2><?php esc_html_e( 'Alerts', 'redkirin-ad-click-verification' ); ?></h2>
     393                        <table class="form-table" role="presentation">
     394                                <?php
     395                                self::field_checkbox( $s, 'alert_enabled', __( 'Send alert emails', 'redkirin-ad-click-verification' ), __( 'Checks the last 24 hours once an hour (WP-Cron) and emails when the bot share of ad landings or repeated conversions from one click ID exceed the thresholds. Each condition is reported at most once per 24 hours.', 'redkirin-ad-click-verification' ) );
     396                                self::field_text( $s, 'alert_email', __( 'Recipient', 'redkirin-ad-click-verification' ), __( 'Leave empty to use the site admin email.', 'redkirin-ad-click-verification' ), 'alert_enabled' );
     397                                self::field_number( $s, 'alert_bot_share', __( 'Bot share threshold (%)', 'redkirin-ad-click-verification' ), '', 1, 100, 'alert_enabled' );
     398                                self::field_number( $s, 'alert_min_landings', __( 'Minimum landings for the share alert', 'redkirin-ad-click-verification' ), '', 1, 100000, 'alert_enabled' );
     399                                self::field_number( $s, 'alert_repeat_clicks', __( 'Repeated conversions per click ID', 'redkirin-ad-click-verification' ), '', 2, 1000, 'alert_enabled' );
     400                                self::field_checkbox( $s, 'alert_digest', __( 'Daily digest', 'redkirin-ad-click-verification' ), __( 'Once a day: blocked / verified clicks, ad landings and the wasted spend estimate.', 'redkirin-ad-click-verification' ), 'alert_enabled' );
    349401                                ?>
    350402                        </table>
    … …  
    406458                                self::field_number( $s, 'dedupe_window_min', __( 'Window (minutes)', 'redkirin-ad-click-verification' ), __( 'Within this window, the same link type is measured only once per click ID / session. 0 disables the window rule.', 'redkirin-ad-click-verification' ), 0, 1440, 'dedupe_enabled' );
    407459                                self::field_number( $s, 'dedupe_daily_limit', __( 'Daily limit per click ID', 'redkirin-ad-click-verification' ), __( 'Maximum verified clicks per click ID in 24 hours, checked against the log via the REST API. 0 disables the limit.', 'redkirin-ad-click-verification' ), 0, 100, 'dedupe_enabled' );
     460                                ?>
     461                        </table>
     462
     463                        <h2><?php esc_html_e( 'Form submissions and thank-you pages', 'redkirin-ad-click-verification' ); ?></h2>
     464                        <table class="form-table" role="presentation">
     465                                <?php
     466                                self::field_checkbox( $s, 'form_enabled', __( 'Verify form submissions', 'redkirin-ad-click-verification' ), __( 'Applies the same human verification to form submissions. Every submission gets hidden fields <code>rkacv_verdict</code> (human / undetermined / bot), <code>rkacv_reasons</code>, <code>rkacv_dwell</code> and <code>rkacv_signals</code>, so your CRM or form plugin can filter them. Verified submissions push <code>rkacv_form_submit</code> to the dataLayer; use it as the form key event.', 'redkirin-ad-click-verification' ) );
     467                                self::field_checkbox( $s, 'form_block_hard', __( 'Block submissions with definite bot signals', 'redkirin-ad-click-verification' ), __( 'Stops the submission in the browser when webdriver, a bot user agent, automation globals or a synthetic submit event are present. Contact Form 7 marks such submissions as spam and WPForms rejects them on the server as well. Submissions that merely lack dwell time or interaction are never blocked.', 'redkirin-ad-click-verification' ), 'form_enabled' );
     468                                self::field_textarea( $s, 'form_exclude', __( 'Forms to ignore', 'redkirin-ad-click-verification' ), __( 'CSS selectors, one per line. Matching forms (or forms inside a matching element) are not verified.', 'redkirin-ad-click-verification' ), 4, 'form_enabled' );
     469                                self::field_textarea( $s, 'thanks_paths', __( 'Thank-you page paths', 'redkirin-ad-click-verification' ), __( 'One path per line, e.g. <code>/thanks/</code> or <code>/contact/complete/</code>. On these pages the verified page view event is pushed only if a verified form was submitted in this browser session within the window below, and <code>rkacv_thanks_view</code> is pushed as well. Direct visits and crawlers hitting the thank-you URL are logged and not counted. Leave empty to disable.', 'redkirin-ad-click-verification' ), 3, 'form_enabled' );
     470                                self::field_number( $s, 'thanks_window_min', __( 'Thank-you window (minutes)', 'redkirin-ad-click-verification' ), '', 1, 1440, 'form_enabled' );
    408471                                ?>
    409472                        </table>
    … …  
    626689                        'repeat_limit'      => __( 'Repeat: daily limit per click ID', 'redkirin-ad-click-verification' ),
    627690                        'key_on_touch'      => __( 'Note: keyboard on touch device', 'redkirin-ad-click-verification' ),
     691                        'no_form_submit'    => __( 'Thank-you page without a verified form submission', 'redkirin-ad-click-verification' ),
    628692                        'deny_ua'           => __( 'Denied: user agent', 'redkirin-ad-click-verification' ),
    629693                        'deny_ip'           => __( 'Denied: IP range', 'redkirin-ad-click-verification' ),
    … …  
    646710        }
    647711
    648         private static function breakdown_table( $title, $rows, $cv_by_key = array(), $key_label = '' ) {
     712        private static function breakdown_table( $title, $rows, $cv_by_key = array(), $key_label = '', $waste_by_key = null, $currency = '' ) {
    649713                if ( empty( $rows ) ) {
    650714                        return;
    … …  
    662726                                        <th><?php esc_html_e( 'Bot share', 'redkirin-ad-click-verification' ); ?></th>
    663727                                        <?php if ( $cv_by_key ) : ?><th class="rkacv-num"><?php esc_html_e( 'Verified clicks', 'redkirin-ad-click-verification' ); ?></th><?php endif; ?>
     728                                        <?php if ( null !== $waste_by_key ) : ?><th class="rkacv-num"><?php esc_html_e( 'Est. waste', 'redkirin-ad-click-verification' ); ?></th><?php endif; ?>
    664729                                </tr>
    665730                        </thead>
    … …  
    680745                                        </td>
    681746                                        <?php if ( $cv_by_key ) : ?><td class="rkacv-num"><?php echo esc_html( number_format_i18n( isset( $cv_by_key[ $r['key'] ] ) ? $cv_by_key[ $r['key'] ] : 0 ) ); ?></td><?php endif; ?>
     747                                        <?php if ( null !== $waste_by_key ) : ?><td class="rkacv-num"><?php echo esc_html( $currency . number_format_i18n( isset( $waste_by_key[ $r['key'] ] ) ? $waste_by_key[ $r['key'] ] : 0 ) ); ?></td><?php endif; ?>
    682748                                </tr>
    683749                        <?php endforeach; ?>
    … …  
    705771                $by_dev = RKACV_Ad_Visits::breakdown( 'device', $days );
    706772                $by_src = RKACV_Ad_Visits::breakdown( 'source', $days );
     773                $by_app = RKACV_Ad_Visits::breakdown( 'app', $days );
     774                $cpc    = RKACV_Settings::cpc_table( $s );
     775                $has_cpc = $cpc['default'] > 0 || ! empty( $cpc['by'] );
     776                $waste_by_cmp = array();
     777                $waste_total  = 0;
     778                if ( $has_cpc ) {
     779                        foreach ( $by_cmp as $c ) {
     780                                $rate = isset( $cpc['by'][ $c['key'] ] ) ? $cpc['by'][ $c['key'] ] : $cpc['default'];
     781                                $waste_by_cmp[ $c['key'] ] = $c['bot'] * $rate;
     782                                $waste_total              += $c['bot'] * $rate;
     783                        }
     784                }
     785                $export_url = wp_nonce_url( admin_url( 'admin-post.php?action=rkacv_export&days=' . $days ), 'rkacv_export' );
    707786                $ips    = RKACV_Ad_Visits::suspicious_ips( $days, 2 );
    708787                $recent = RKACV_Ad_Visits::recent( 50 );
    … …  
    726805                                <?php endforeach; ?>
    727806                        </ul>
     807                        <div class="rkacv-toolbar-right">
     808                                <a class="button" href="<?php echo esc_url( $export_url ); ?>"><?php esc_html_e( 'Export evidence CSV', 'redkirin-ad-click-verification' ); ?></a>
     809                                <a class="button" href="<?php echo esc_url( $export_url . '&undetermined=1' ); ?>" title="<?php esc_attr_e( 'Includes undetermined landings as well as bot signals', 'redkirin-ad-click-verification' ); ?>"><?php esc_html_e( 'Export incl. undetermined', 'redkirin-ad-click-verification' ); ?></a>
    728810                        <form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" onsubmit="return confirm('<?php echo esc_js( __( 'Delete all ad landing records?', 'redkirin-ad-click-verification' ) ); ?>');">
    729811                                <input type="hidden" name="action" value="rkacv_clear_visits">
    … …  
    731813                                <button class="button"><?php esc_html_e( 'Clear records', 'redkirin-ad-click-verification' ); ?></button>
    732814                        </form>
     815                        </div>
    733816                </div>
     817                <p class="description"><?php esc_html_e( 'The evidence CSV (bot landings and blocked clicks with time, click ID, campaign, app, user agent and reasons) can be attached to the Google Ads invalid click claim form.', 'redkirin-ad-click-verification' ); ?></p>
    734818
    735819                <div class="rkacv-cards">
     820                        <?php if ( $has_cpc ) : ?>
     821                        <div class="rkacv-card"><div class="rkacv-card-label"><?php esc_html_e( 'Estimated wasted spend', 'redkirin-ad-click-verification' ); ?></div><div class="rkacv-card-value rkacv-blocked"><?php echo esc_html( $s['currency_symbol'] . number_format_i18n( $waste_total ) ); ?></div><div class="rkacv-card-sub"><?php esc_html_e( 'bot landings x CPC', 'redkirin-ad-click-verification' ); ?></div></div>
     822                        <?php endif; ?>
    736823                        <div class="rkacv-card"><div class="rkacv-card-label"><?php esc_html_e( 'Ad landings', 'redkirin-ad-click-verification' ); ?></div><div class="rkacv-card-value"><?php echo esc_html( number_format_i18n( $sum['total'] ) ); ?></div></div>
    737824                        <div class="rkacv-card"><div class="rkacv-card-label"><?php esc_html_e( 'Bot signals', 'redkirin-ad-click-verification' ); ?></div><div class="rkacv-card-value rkacv-blocked"><?php echo esc_html( number_format_i18n( $sum['bot'] ) ); ?> <small><?php echo esc_html( self::pct( $sum['bot'], $sum['total'] ) ); ?>%</small></div></div>
    … …  
    747834
    748835                <?php
    749                 self::breakdown_table( __( 'By campaign', 'redkirin-ad-click-verification' ), $by_cmp, $cv_by_cmp, __( 'Campaign ID', 'redkirin-ad-click-verification' ) );
     836                self::breakdown_table( __( 'By campaign', 'redkirin-ad-click-verification' ), $by_cmp, $cv_by_cmp, __( 'Campaign ID', 'redkirin-ad-click-verification' ), $has_cpc ? $waste_by_cmp : null, $s['currency_symbol'] );
     837                self::breakdown_table( __( 'By app (in-app browser)', 'redkirin-ad-click-verification' ), $by_app, array(), __( 'App', 'redkirin-ad-click-verification' ) );
    750838                self::breakdown_table( __( 'By placement', 'redkirin-ad-click-verification' ), $by_pl, array(), __( 'Placement', 'redkirin-ad-click-verification' ) );
    751839                self::breakdown_table( __( 'By device', 'redkirin-ad-click-verification' ), $by_dev, array(), __( 'Device', 'redkirin-ad-click-verification' ) );
    … …  
    806894                                                <th><?php esc_html_e( 'Campaign ID', 'redkirin-ad-click-verification' ); ?></th>
    807895                                                <th><?php esc_html_e( 'Placement', 'redkirin-ad-click-verification' ); ?></th>
     896                                                <th><?php esc_html_e( 'App', 'redkirin-ad-click-verification' ); ?></th>
    808897                                                <th><?php esc_html_e( 'Reason', 'redkirin-ad-click-verification' ); ?></th>
    809898                                                <th><?php esc_html_e( 'Dwell / signals', 'redkirin-ad-click-verification' ); ?></th>
    … …  
    828917                                                <td class="rkacv-ellipsis"><?php echo esc_html( $r['campaign_id'] ); ?></td>
    829918                                                <td class="rkacv-ellipsis" title="<?php echo esc_attr( $r['placement'] ); ?>"><?php echo esc_html( $r['placement'] ); ?></td>
     919                                                <td class="rkacv-ellipsis"><?php echo esc_html( isset( $r['app'] ) ? $r['app'] : '' ); ?></td>
    830920                                                <td><?php echo esc_html( self::reasons_label( $r['reasons'] ) ); ?></td>
    831921                                                <td class="rkacv-nowrap"><?php echo esc_html( $r['dwell_ms'] ); ?>ms / <?php echo esc_html( $r['signals'] ); ?></td>
  • redkirin-ad-click-verification/tags/1.3.0/includes/class-rkacv-dashboard.php

    r3743244 r3743355  
    8787                                        $bot_pct = round( $ads['bot'] / $ads['total'] * 100 );
    8888                                        echo esc_html( sprintf( /* translators: 1: landings, 2: bot percentage, 3: undetermined count */ __( 'Ad landings: %1$d, %2$d%% with bot signals, %3$d undetermined', 'redkirin-ad-click-verification' ), $ads['total'], $bot_pct, $ads['undetermined'] ) );
     89                                        $cpc = RKACV_Settings::cpc_table( $settings );
     90                                        if ( $cpc['default'] > 0 || $cpc['by'] ) {
     91                                                $waste = 0;
     92                                                foreach ( RKACV_Ad_Visits::breakdown( 'campaign_id', self::DAYS, 100 ) as $c ) {
     93                                                        $waste += $c['bot'] * ( isset( $cpc['by'][ $c['key'] ] ) ? $cpc['by'][ $c['key'] ] : $cpc['default'] );
     94                                                }
     95                                                echo ' · ' . esc_html( sprintf( /* translators: 1: currency, 2: amount */ __( 'est. wasted spend %1$s%2$s', 'redkirin-ad-click-verification' ), $settings['currency_symbol'], number_format_i18n( $waste ) ) );
     96                                        }
    8997                                        ?>
    9098                                        <a href="<?php echo esc_url( admin_url( 'options-general.php?page=' . RKACV_Admin::PAGE . '&tab=ads' ) ); ?>"><?php esc_html_e( 'Report', 'redkirin-ad-click-verification' ); ?></a>
  • redkirin-ad-click-verification/tags/1.3.0/includes/class-rkacv-frontend.php

    r3743325 r3743355  
    5050                        'debug'         => ! empty( $settings['debug'] ),
    5151                        'denyWebdriver' => ! empty( $settings['deny_enabled'] ) && ! empty( $settings['deny_webdriver'] ),
     52                        'forms'         => ! empty( $settings['form_enabled'] ),
     53                        'formBlockHard' => ! empty( $settings['form_block_hard'] ),
     54                        'formExclude'   => RKACV_Settings::lines( $settings['form_exclude'] ),
     55                        'thanksPaths'   => RKACV_Settings::lines( $settings['thanks_paths'] ),
     56                        'thanksWindow'  => (int) $settings['thanks_window_min'] * 60 * 1000,
    5257                        'verifiedPV'    => ! empty( $settings['verified_pageview'] ),
    5358                        'botTagBlock'   => ! empty( $settings['bot_tag_block'] ),
  • redkirin-ad-click-verification/tags/1.3.0/includes/class-rkacv-log.php

    r3743265 r3743355  
    1010class RKACV_Log {
    1111
    12         const DB_VERSION = '4';
     12        const DB_VERSION = '5';
    1313
    1414        /** 保存する最大行数。超えた分は古い行から削除する(リングバッファ)。 */
    … …  
    103103                }
    104104                return $ok;
     105        }
     106
     107        /**
     108         * 証跡エクスポート用: 期間内の遮断クリック。
     109         *
     110         * @param int $days 日数。
     111         * @return array
     112         */
     113        public static function evidence_rows( $days ) {
     114                global $wpdb;
     115                $table = self::table();
     116                $since = gmdate( 'Y-m-d H:i:s', time() - (int) $days * DAY_IN_SECONDS );
     117                // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
     118                return $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE created_at >= %s AND status = 'blocked' ORDER BY id ASC LIMIT 5000", $since ), ARRAY_A );
    105119        }
    106120
  • redkirin-ad-click-verification/tags/1.3.0/includes/class-rkacv-settings.php

    r3743325 r3743355  
    104104                        'dedupe_window_min'    => 30,
    105105                        'dedupe_daily_limit'   => 3,
     106                        // 無駄費用の推定(bot 着地 × CPC)。
     107                        'cpc_default'          => 0,
     108                        'cpc_by_campaign'      => '',
     109                        'currency_symbol'      => '¥',
     110                        // フォーム送信の保護。
     111                        'form_enabled'         => 1,
     112                        'form_block_hard'      => 1,
     113                        'form_exclude'         => "form[role=search]\n.search-form\n#loginform\n#adminbarsearch",
     114                        // サンクスページの到達検証(空なら無効)。
     115                        'thanks_paths'         => '',
     116                        'thanks_window_min'    => 30,
     117                        // 異常検知アラート。
     118                        'alert_enabled'        => 0,
     119                        'alert_email'          => '',
     120                        'alert_bot_share'      => 30,
     121                        'alert_min_landings'   => 20,
     122                        'alert_repeat_clicks'  => 5,
     123                        'alert_digest'         => 0,
    106124                        // 広告着地の記録(アドフラウド可視化)。
    107125                        'ad_tracking'          => 1,
    … …  
    182200                $out = array();
    183201
    184                 foreach ( array( 'enabled', 'intercept_clicks', 'guard_datalayer', 'gtag_direct', 'ip_check', 'log_enabled', 'log_verified', 'anonymize_ip', 'debug', 'deny_enabled', 'deny_cidrs', 'deny_ua', 'deny_webdriver', 'ad_tracking', 'dedupe_enabled', 'verified_pageview', 'bot_tag_block' ) as $k ) {
     202                foreach ( array( 'enabled', 'intercept_clicks', 'guard_datalayer', 'gtag_direct', 'ip_check', 'log_enabled', 'log_verified', 'anonymize_ip', 'debug', 'deny_enabled', 'deny_cidrs', 'deny_ua', 'deny_webdriver', 'ad_tracking', 'dedupe_enabled', 'verified_pageview', 'bot_tag_block', 'form_enabled', 'form_block_hard', 'alert_enabled', 'alert_digest' ) as $k ) {
    185203                        $out[ $k ] = empty( $input[ $k ] ) ? 0 : 1;
    186204                }
    … …  
    190208                $out['dwell_signal_ms']    = isset( $input['dwell_signal_ms'] ) ? max( 0, min( 600000, (int) $input['dwell_signal_ms'] ) ) : $d['dwell_signal_ms'];
    191209                $out['log_retention_days'] = isset( $input['log_retention_days'] ) ? max( 1, min( 365, (int) $input['log_retention_days'] ) ) : $d['log_retention_days'];
     210                $out['cpc_default']        = isset( $input['cpc_default'] ) ? max( 0, (float) $input['cpc_default'] ) : $d['cpc_default'];
     211                $out['thanks_window_min']  = isset( $input['thanks_window_min'] ) ? max( 1, min( 1440, (int) $input['thanks_window_min'] ) ) : $d['thanks_window_min'];
     212                $out['alert_bot_share']    = isset( $input['alert_bot_share'] ) ? max( 1, min( 100, (int) $input['alert_bot_share'] ) ) : $d['alert_bot_share'];
     213                $out['alert_min_landings'] = isset( $input['alert_min_landings'] ) ? max( 1, min( 100000, (int) $input['alert_min_landings'] ) ) : $d['alert_min_landings'];
     214                $out['alert_repeat_clicks'] = isset( $input['alert_repeat_clicks'] ) ? max( 2, min( 1000, (int) $input['alert_repeat_clicks'] ) ) : $d['alert_repeat_clicks'];
     215                $out['currency_symbol']    = isset( $input['currency_symbol'] ) ? substr( sanitize_text_field( $input['currency_symbol'] ), 0, 5 ) : $d['currency_symbol'];
     216                $out['alert_email']        = isset( $input['alert_email'] ) ? sanitize_text_field( $input['alert_email'] ) : $d['alert_email'];
    192217                $out['dedupe_window_min']  = isset( $input['dedupe_window_min'] ) ? max( 0, min( 1440, (int) $input['dedupe_window_min'] ) ) : $d['dedupe_window_min'];
    193218                $out['dedupe_daily_limit'] = isset( $input['dedupe_daily_limit'] ) ? max( 0, min( 100, (int) $input['dedupe_daily_limit'] ) ) : $d['dedupe_daily_limit'];
    … …  
    195220                $out['ad_retention_days']  = isset( $input['ad_retention_days'] ) ? max( 1, min( 365, (int) $input['ad_retention_days'] ) ) : $d['ad_retention_days'];
    196221
    197                 foreach ( array( 'link_patterns', 'guarded_events', 'bot_ua_patterns', 'extra_cidrs', 'deny_ua_patterns', 'ad_trigger_params', 'ad_capture_params' ) as $k ) {
     222                foreach ( array( 'link_patterns', 'guarded_events', 'bot_ua_patterns', 'extra_cidrs', 'deny_ua_patterns', 'ad_trigger_params', 'ad_capture_params', 'cpc_by_campaign', 'form_exclude', 'thanks_paths' ) as $k ) {
    198223                        $out[ $k ] = isset( $input[ $k ] ) ? self::sanitize_lines( $input[ $k ] ) : $d[ $k ];
    199224                }
    … …  
    240265
    241266        /**
     267         * キャンペーン ID ごとの CPC("id=cpc" 行)とデフォルト CPC。
     268         *
     269         * @param array $settings 設定。
     270         * @return array{default:float,by:array<string,float>}
     271         */
     272        public static function cpc_table( $settings ) {
     273                $by = array();
     274                foreach ( self::lines( $settings['cpc_by_campaign'] ) as $line ) {
     275                        if ( preg_match( '/^\s*([^=\s]+)\s*=\s*([0-9.]+)\s*$/', $line, $m ) ) {
     276                                $by[ $m[1] ] = (float) $m[2];
     277                        }
     278                }
     279                return array(
     280                        'default' => (float) $settings['cpc_default'],
     281                        'by'      => $by,
     282                );
     283        }
     284
     285        /**
    242286         * 改行区切りテキストの整形。
    243287         *
  • redkirin-ad-click-verification/tags/1.3.0/languages/redkirin-ad-click-verification-ja.po

    r3743325 r3743355  
    22msgid ""
    33msgstr ""
    4 "Project-Id-Version: Redkirin Ad Click Verification 1.2.0\n"
     4"Project-Id-Version: Redkirin Ad Click Verification 1.3.0\n"
    55"POT-Creation-Date: 2026-10-12 00:08+0000\n"
    66"PO-Revision-Date: 2026-10-12 00:08+0000\n"
    … …  
    683683msgstr "別案として、page_view のキーイベントを残したまま GA4 設定タグに例外トリガー「rkacv_bot 等しい true」を追加する方法もあります。ただし読込時点で分かる bot にしか効きません。"
    684684
     685msgid "Wasted spend estimate"
     686msgstr "無駄費用の推定"
     687
     688msgid "Average CPC"
     689msgstr "平均 CPC"
     690
     691msgid "Average cost per click used for \"bot landings x CPC\" in the Ad traffic report and the dashboard. 0 hides the estimate."
     692msgstr "広告トラフィックのレポートとダッシュボードで「bot 着地 × CPC」の計算に使う平均クリック単価。0 で非表示。"
     693
     694msgid "CPC per campaign"
     695msgstr "キャンペーン別 CPC"
     696
     697msgid "One line per campaign as <code>campaign_id=cpc</code>, e.g. <code>21505995745=180</code>. Campaigns not listed use the average CPC."
     698msgstr "1 行 1 キャンペーンで <code>キャンペーンID=CPC</code> の形式(例: <code>21505995745=180</code>)。未記載のキャンペーンは平均 CPC を使います。"
     699
     700msgid "Currency symbol"
     701msgstr "通貨記号"
     702
     703msgid "Alerts"
     704msgstr "アラート"
     705
     706msgid "Send alert emails"
     707msgstr "アラートメールを送る"
     708
     709msgid "Checks the last 24 hours once an hour (WP-Cron) and emails when the bot share of ad landings or repeated conversions from one click ID exceed the thresholds. Each condition is reported at most once per 24 hours."
     710msgstr "1 時間ごと(WP-Cron)に直近 24 時間を確認し、広告着地の bot 率や同一クリック ID からの反復コンバージョンがしきい値を超えたらメールします。同じ条件の通知は 24 時間に 1 回までです。"
     711
     712msgid "Recipient"
     713msgstr "宛先"
     714
     715msgid "Leave empty to use the site admin email."
     716msgstr "空ならサイトの管理者メールアドレスを使います。"
     717
     718msgid "Bot share threshold (%)"
     719msgstr "bot 率のしきい値 (%)"
     720
     721msgid "Minimum landings for the share alert"
     722msgstr "bot 率アラートに必要な最低着地数"
     723
     724msgid "Repeated conversions per click ID"
     725msgstr "クリック ID ごとの反復コンバージョン数"
     726
     727msgid "Daily digest"
     728msgstr "日次ダイジェスト"
     729
     730msgid "Once a day: blocked / verified clicks, ad landings and the wasted spend estimate."
     731msgstr "1 日 1 回、遮断 / 検証済みクリック、広告着地、無駄費用の推定を送ります。"
     732
     733msgid "Form submissions and thank-you pages"
     734msgstr "フォーム送信とサンクスページ"
     735
     736msgid "Verify form submissions"
     737msgstr "フォーム送信を検証する"
     738
     739msgid "Applies the same human verification to form submissions. Every submission gets hidden fields <code>rkacv_verdict</code> (human / undetermined / bot), <code>rkacv_reasons</code>, <code>rkacv_dwell</code> and <code>rkacv_signals</code>, so your CRM or form plugin can filter them. Verified submissions push <code>rkacv_form_submit</code> to the dataLayer; use it as the form key event."
     740msgstr "フォーム送信にも同じ人間判定を適用します。すべての送信に隠しフィールド <code>rkacv_verdict</code>(human / undetermined / bot)、<code>rkacv_reasons</code>、<code>rkacv_dwell</code>、<code>rkacv_signals</code> が付くので、CRM やフォームプラグイン側で判別できます。検証済みの送信は <code>rkacv_form_submit</code> を dataLayer に push します。フォームのキーイベントにはこれを使ってください。"
     741
     742msgid "Block submissions with definite bot signals"
     743msgstr "bot 兆候が明確な送信を止める"
     744
     745msgid "Stops the submission in the browser when webdriver, a bot user agent, automation globals or a synthetic submit event are present. Contact Form 7 marks such submissions as spam and WPForms rejects them on the server as well. Submissions that merely lack dwell time or interaction are never blocked."
     746msgstr "webdriver・bot UA・自動化ツールの痕跡・合成 submit イベントがある場合、ブラウザー側で送信を止めます。Contact Form 7 では迷惑メール扱い、WPForms ではサーバー側でもエラーにします。滞在時間や操作が足りないだけの送信は止めません。"
     747
     748msgid "Forms to ignore"
     749msgstr "対象外にするフォーム"
     750
     751msgid "CSS selectors, one per line. Matching forms (or forms inside a matching element) are not verified."
     752msgstr "CSS セレクタを 1 行 1 件。一致するフォーム(または一致する要素内のフォーム)は検証しません。"
     753
     754msgid "Thank-you page paths"
     755msgstr "サンクスページのパス"
     756
     757msgid "One path per line, e.g. <code>/thanks/</code> or <code>/contact/complete/</code>. On these pages the verified page view event is pushed only if a verified form was submitted in this browser session within the window below, and <code>rkacv_thanks_view</code> is pushed as well. Direct visits and crawlers hitting the thank-you URL are logged and not counted. Leave empty to disable."
     758msgstr "1 行 1 パス(例: <code>/thanks/</code>、<code>/contact/complete/</code>)。これらのページでは、同じブラウザーセッションで下の時間内に検証済みのフォーム送信があった場合だけ検証済みページビューを push し、あわせて <code>rkacv_thanks_view</code> も push します。URL 直打ちやクローラーの到達はログに残し計測しません。空で無効。"
     759
     760msgid "Thank-you window (minutes)"
     761msgstr "サンクスページの有効時間(分)"
     762
     763msgid "Thank-you page without a verified form submission"
     764msgstr "検証済みフォーム送信のないサンクスページ到達"
     765
     766msgid "Est. waste"
     767msgstr "推定無駄費用"
     768
     769msgid "Export evidence CSV"
     770msgstr "証跡 CSV を出力"
     771
     772msgid "Includes undetermined landings as well as bot signals"
     773msgstr "bot 兆候ありに加えて保留の着地も含めます"
     774
     775msgid "Export incl. undetermined"
     776msgstr "保留を含めて出力"
     777
     778msgid "The evidence CSV (bot landings and blocked clicks with time, click ID, campaign, app, user agent and reasons) can be attached to the Google Ads invalid click claim form."
     779msgstr "証跡 CSV(bot 着地と遮断クリック: 日時、クリック ID、キャンペーン、アプリ、UA、理由)は Google 広告の無効なクリックの申し立てフォームに添付できます。"
     780
     781msgid "Estimated wasted spend"
     782msgstr "推定無駄費用"
     783
     784msgid "bot landings x CPC"
     785msgstr "bot 着地 × CPC"
     786
     787msgid "By app (in-app browser)"
     788msgstr "流入アプリ別(アプリ内ブラウザー)"
     789
     790msgid "App"
     791msgstr "アプリ"
     792
     793msgid "Bot share of ad landings in the last 24 hours: %1$d%% (%2$d landings, %3$d with bot signals)."
     794msgstr "直近 24 時間の広告着地の bot 率: %1$d%%(着地 %2$d 件、bot 兆候あり %3$d 件)。"
     795
     796msgid "Campaign %1$s: %2$d bot / %3$d landings"
     797msgstr "キャンペーン %1$s: bot %2$d / 着地 %3$d"
     798
     799msgid "Repeated conversions from one ad click ID %1$s: %2$d verified, %3$d repeat-blocked, %4$d IPs in the last 24 hours."
     800msgstr "1 つの広告クリック ID %1$s から反復コンバージョン: 直近 24 時間で検証済み %2$d 件、反復遮断 %3$d 件、IP 数 %4$d。"
     801
     802msgid "Ad Click Verification alert"
     803msgstr "Ad Click Verification アラート"
     804
     805msgid "Clicks in the last 24 hours: %1$d blocked (%2$d with bot signals), %3$d verified, %4$d page requests denied."
     806msgstr "直近 24 時間のクリック: 遮断 %1$d 件(bot 兆候あり %2$d 件)、検証済み %3$d 件、表示拒否 %4$d 件。"
     807
     808msgid "Ad landings: %1$d (%2$d bot signals, %3$d undetermined, %4$d human)."
     809msgstr "広告着地: %1$d 件(bot 兆候あり %2$d、保留 %3$d、人間 %4$d)。"
     810
     811msgid "Estimated wasted spend (bot landings x CPC): %1$s%2$s"
     812msgstr "推定無駄費用(bot 着地 × CPC): %1$s%2$s"
     813
     814msgid "Ad Click Verification daily digest"
     815msgstr "Ad Click Verification 日次ダイジェスト"
     816
     817msgid "est. wasted spend %1$s%2$s"
     818msgstr "推定無駄費用 %1$s%2$s"
     819
     820msgid "The submission could not be verified. Please try again."
     821msgstr "送信を確認できませんでした。もう一度お試しください。"
     822
  • redkirin-ad-click-verification/tags/1.3.0/readme.txt

    r3743325 r3743355  
    55Tested up to: 7.1
    66Requires PHP: 7.4
    7 Stable tag: 1.2.0
     7Stable tag: 1.3.0
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    2121* **Click interception** – unverified clicks are stopped at the `window` capture phase, before Google Tag Manager click triggers or inline `onclick` handlers see them. The navigation itself (dialing, opening LINE, following the link) is performed by the plugin, so visitors are never blocked.
    2222* **dataLayer guard** – `gtm.linkClick` / `gtm.click` and any event names you specify are dropped from `dataLayer.push` while the visitor is unverified.
     23* **Form submission verification** – the same human verification applies to form submissions. Hidden fields carry the verdict for your CRM, verified submissions push <code>rkacv_form_submit</code>, submissions with definite bot signals are stopped (Contact Form 7 marks them as spam, WPForms rejects them), and thank-you pages only count when a verified form was submitted in the same session.
     24* **In-app browser detection** – landings are classified by app (Yahoo! JAPAN, LINE, Facebook, Instagram, X, Google app, TikTok, generic WebView, ...), so you can see which apps bot traffic comes from even without ValueTrack placements.
     25* **Wasted spend estimate** – enter an average CPC (or per campaign) and the report shows bot landings x CPC per campaign and in total.
     26* **Evidence export** – CSV of bot landings and blocked clicks for the Google Ads invalid click claim form.
     27* **Alerts** – hourly check with email when the bot share of ad landings or repeated conversions from one click ID exceed your thresholds; optional daily digest.
    2328* **Page view protection** – obvious bots (webdriver, bot user agents, automation globals) get no tags at all: every dataLayer push is dropped, so GTM and gtag.js send nothing, not even page_view. Once a visitor is verified, a <code>rkacv_verified_pageview</code> event is pushed that you can use as a key event instead of page_view (thank-you pages, landing pages).
    2429* **Verified events** – verified clicks push `rkacv_verified_click` to the dataLayer (with `rkacv_link_type`, `rkacv_link_url`, `rkacv_link_text`, `rkacv_element_id`), and can optionally call `gtag('event', ...)` directly.
    … …  
    9499== Changelog ==
    95100
     101= 1.3.0 =
     102* In-app browser classification for ad landings (new "By app" breakdown and column); evidence CSV export for Google Ads invalid click claims; wasted spend estimate (bot landings x CPC, per campaign CPC overrides) in the report, the dashboard and the digest.
     103* Form submission verification: hidden verdict fields, rkacv_form_submit event for verified submissions, blocking of submissions with definite bot signals (browser, Contact Form 7 spam, WPForms error), configurable excluded forms.
     104* Thank-you page gating: verified page view / rkacv_thanks_view only after a verified form submission in the same session; direct hits are logged.
     105* Alert emails (hourly check: bot share, repeated conversions per click ID) and an optional daily digest.
     106
    96107= 1.2.0 =
    97108* New "Fire no tags for obvious bots" (default on): when webdriver, a bot user agent or automation globals are present at load, every dataLayer push is dropped so GTM / gtag.js send nothing, including page_view.
  • redkirin-ad-click-verification/tags/1.3.0/redkirin-ad-click-verification.php

    r3743325 r3743355  
    33 * Plugin Name: Redkirin Ad Click Verification
    44 * Description: Stops bots from inflating GA4 events and Google Ads conversions by verifying that clicks on phone, LINE, email and other tracked links come from a real person.
    5  * Version:     1.2.0
     5 * Version:     1.3.0
    66 * Author:      Redkirin
    77 * Author URI:  https://redkirin.co.jp/
    … …  
    1717}
    1818
    19 define( 'RKACV_VERSION', '1.2.0' );
     19define( 'RKACV_VERSION', '1.3.0' );
    2020define( 'RKACV_PLUGIN_FILE', __FILE__ );
    2121define( 'RKACV_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
    … …  
    3030require_once RKACV_PLUGIN_DIR . 'includes/class-rkacv-frontend.php';
    3131require_once RKACV_PLUGIN_DIR . 'includes/class-rkacv-deny.php';
     32require_once RKACV_PLUGIN_DIR . 'includes/class-rkacv-forms.php';
     33require_once RKACV_PLUGIN_DIR . 'includes/class-rkacv-alerts.php';
    3234require_once RKACV_PLUGIN_DIR . 'includes/class-rkacv-admin.php';
    3335require_once RKACV_PLUGIN_DIR . 'includes/class-rkacv-dashboard.php';
    … …  
    4345                wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'rkacv_daily_maintenance' );
    4446        }
     47        RKACV_Alerts::schedule();
    4548
    4649        $settings = RKACV_Settings::get();
    … …  
    5659function rkacv_deactivate() {
    5760        wp_clear_scheduled_hook( 'rkacv_daily_maintenance' );
     61        RKACV_Alerts::unschedule();
    5862}
    5963register_deactivation_hook( __FILE__, 'rkacv_deactivate' );
    … …  
    9094                RKACV_Frontend::init();
    9195                RKACV_Deny::init();
     96                RKACV_Forms::init();
     97                RKACV_Alerts::init();
     98                RKACV_Alerts::schedule();
    9299                if ( is_admin() ) {
    93100                        RKACV_Admin::init();
  • redkirin-ad-click-verification/tags/1.3.0/uninstall.php

    r3743244 r3743355  
    2727delete_transient( 'rkacv_ip_refresh_lock' );
    2828delete_transient( 'rkacv_rl_global' );
     29wp_clear_scheduled_hook( 'rkacv_hourly_check' );
     30delete_transient( 'rkacv_alert_share' );
  • redkirin-ad-click-verification/trunk/assets/css/admin.css

    r3743276 r3743355  
    7878tr.rkacv-dimmed th, tr.rkacv-dimmed td { opacity: .45; transition: opacity .15s; }
    7979tr.rkacv-dimmed input, tr.rkacv-dimmed textarea, tr.rkacv-dimmed select { pointer-events: none; }
     80.rkacv-toolbar-right { display: flex; gap: 6px; align-items: center; }
  • redkirin-ad-click-verification/trunk/assets/js/rkacv.js

    r3743325 r3743355  
    271271                        debug('human verified', r);
    272272                        dlPush({ event: 'rkacv_human_verified', rkacv_human: true });
    273                         if (cfg.verifiedPV) {
     273                        var thanks = onThanksPage();
     274                        if (thanks && !recentFormSubmit()) {
     275                                // サンクスページに直接到達(フォーム送信を経ていない): 検証済みページビューを出さない。
     276                                debug('thanks page reached without a verified form submit');
     277                                sendLog('blocked', 'thanks', String(w.location.pathname), r, 'no_form_submit');
     278                        } else if (cfg.verifiedPV) {
     279                                if (thanks) {
     280                                        dlPush({ event: 'rkacv_thanks_view', rkacv_page_path: String(w.location.pathname), rkacv_form_verified: true });
     281                                        sendLog('verified', 'thanks', String(w.location.pathname), r);
     282                                }
    274283                                // page_view の代わりにキーイベントにできる「検証済みページビュー」。
    275284                                dlPush({
    … …  
    492501                        }
    493502                } catch (e) { /* noop */ }
     503        }
     504
     505        /* ------------------------------------------------------------------ */
     506        /* フォーム送信の保護                                                   */
     507        /* ------------------------------------------------------------------ */
     508
     509        function formExcluded(form) {
     510                var sels = cfg.formExclude || [];
     511                for (var i = 0; i < sels.length; i++) {
     512                        try {
     513                                if (form.matches(sels[i]) || form.closest(sels[i])) {
     514                                        return true;
     515                                }
     516                        } catch (e) { /* 不正なセレクタは無視 */ }
     517                }
     518                return false;
     519        }
     520
     521        function setHidden(form, name, value) {
     522                var el = form.querySelector('input[name="' + name + '"]');
     523                if (!el) {
     524                        el = d.createElement('input');
     525                        el.type = 'hidden';
     526                        el.name = name;
     527                        form.appendChild(el);
     528                }
     529                el.value = value;
     530        }
     531
     532        function markFormVerified() {
     533                try {
     534                        w.sessionStorage.setItem('rkacv_form_ok', JSON.stringify({ t: Date.now(), path: w.location.pathname }));
     535                } catch (e) { /* noop */ }
     536        }
     537
     538        function onSubmit(e) {
     539                if (!cfg.forms) {
     540                        return;
     541                }
     542                var form = e.target;
     543                if (!form || form.tagName !== 'FORM' || formExcluded(form)) {
     544                        return;
     545                }
     546                var r = evaluate(e, form);
     547                var verdict = r.ok ? 'human' : (r.hard ? 'bot' : 'undetermined');
     548                var reasons = r.reasons.concat(r.notes || []).join(',');
     549
     550                // 判定結果を隠しフィールドで同送(CF7 / WPForms はサーバー側で bot を迷惑メール扱い、他は CRM 側で判別可能)。
     551                setHidden(form, 'rkacv_verdict', verdict);
     552                setHidden(form, 'rkacv_reasons', reasons);
     553                setHidden(form, 'rkacv_dwell', String(r.dwell));
     554                setHidden(form, 'rkacv_signals', String(r.signals));
     555                setHidden(form, 'rkacv_token', cfg.logToken || '');
     556
     557                var label = '<form' + (form.id ? '#' + form.id : '') + (form.getAttribute('action') ? ' ' + String(form.getAttribute('action')).slice(0, 80) : '') + '>';
     558
     559                if (r.ok) {
     560                        state.lastVerifiedAt = now();
     561                        markFormVerified();
     562                        dlPush({ event: 'rkacv_form_submit', rkacv_form_id: form.id || '', rkacv_form_action: String(form.getAttribute('action') || '').slice(0, 200), rkacv_dwell_ms: r.dwell, rkacv_signals: r.signals });
     563                        sendLog('verified', 'form', label, r);
     564                        return;
     565                }
     566                sendLog('blocked', 'form', label, r);
     567                debug('form submit unverified', verdict, r.reasons);
     568
     569                if (r.hard && cfg.formBlockHard) {
     570                        // bot 兆候が明確なら送信そのものを止める(人間の可能性が残る場合は通す)。
     571                        e.preventDefault();
     572                        e.stopImmediatePropagation();
     573                        e.stopPropagation();
     574                }
     575        }
     576
     577        /* ------------------------------------------------------------------ */
     578        /* サンクスページの到達検証                                             */
     579        /* ------------------------------------------------------------------ */
     580
     581        function onThanksPage() {
     582                var paths = cfg.thanksPaths || [];
     583                var cur = w.location.pathname.replace(/\/+$/, '') + '/';
     584                for (var i = 0; i < paths.length; i++) {
     585                        var p = String(paths[i]).replace(/\/+$/, '') + '/';
     586                        if (cur === p || cur.indexOf(p) === 0) {
     587                                return true;
     588                        }
     589                }
     590                return false;
     591        }
     592
     593        function recentFormSubmit() {
     594                try {
     595                        var raw = w.sessionStorage.getItem('rkacv_form_ok');
     596                        if (!raw) {
     597                                return false;
     598                        }
     599                        var rec = JSON.parse(raw);
     600                        return rec && Date.now() - rec.t < (cfg.thanksWindow || 1800000);
     601                } catch (e) {
     602                        return false;
     603                }
    494604        }
    495605
    … …  
    841951
    842952        w.addEventListener('click', onClick, true);
     953        w.addEventListener('submit', onSubmit, true);
    843954        checkIp();
    844955        trackAdVisit();
  • redkirin-ad-click-verification/trunk/includes/class-rkacv-ad-visits.php

    r3743244 r3743355  
    6161                        network VARCHAR(20) NOT NULL DEFAULT '',
    6262                        source VARCHAR(40) NOT NULL DEFAULT '',
     63                        app VARCHAR(40) NOT NULL DEFAULT '',
    6364                        extra VARCHAR(1000) NOT NULL DEFAULT '',
    6465                        repeat_ip TINYINT UNSIGNED NOT NULL DEFAULT 0,
    … …  
    7778
    7879        /**
     80         * UA から流入アプリ(アプリ内ブラウザー)を判定。通常のブラウザーは空文字。
     81         *
     82         * @param string $ua UA。
     83         * @return string
     84         */
     85        public static function app_from_ua( $ua ) {
     86                $ua = (string) $ua;
     87                $map = array(
     88                        '/YJApp-(IOS|ANDROID)|jp\.co\.yahoo\./i' => 'Yahoo! JAPAN',
     89                        '/\bLine\/|\bLIFF\b/'                    => 'LINE',
     90                        '/FBAN|FBAV|FB_IAB/'                         => 'Facebook',
     91                        '/Instagram/'                               => 'Instagram',
     92                        '/TwitterAndroid|Twitter for iPhone/'       => 'X',
     93                        '/\bGSA\//'                                => 'Google app',
     94                        '/SmartNews/i'                              => 'SmartNews',
     95                        '/Gunosy/i'                                 => 'Gunosy',
     96                        '/NewsPicks/i'                              => 'NewsPicks',
     97                        '/TikTok|musical_ly|BytedanceWebview/i'     => 'TikTok',
     98                        '/Pinterest/'                               => 'Pinterest',
     99                        '/MicroMessenger/'                          => 'WeChat',
     100                        '/Snapchat/'                                => 'Snapchat',
     101                        '/\bDuckDuckGo\//'                         => 'DuckDuckGo',
     102                        '/Mercari/i'                                => 'Mercari',
     103                        '/\bwv\)/'                                 => 'Android WebView (other)',
     104                );
     105                foreach ( $map as $re => $label ) {
     106                        if ( preg_match( $re, $ua ) ) {
     107                                return $label;
     108                        }
     109                }
     110                // iOS: Safari 本体には "Safari/" が付く。無い場合はアプリ内ブラウザー。
     111                if ( false !== strpos( $ua, 'iPhone' ) || false !== strpos( $ua, 'iPad' ) ) {
     112                        if ( false === strpos( $ua, 'Safari/' ) && false === strpos( $ua, 'CriOS' ) && false === strpos( $ua, 'FxiOS' ) && false === strpos( $ua, 'EdgiOS' ) ) {
     113                                return 'iOS in-app (other)';
     114                        }
     115                }
     116                return '';
     117        }
     118
     119        /**
    79120         * クエリ文字列の連想配列を列値に変換。
    80121         *
    … …  
    155196                        'network'      => substr( sanitize_text_field( $row['network'] ), 0, 20 ),
    156197                        'source'       => substr( sanitize_text_field( $row['source'] ), 0, 40 ),
     198                        'app'          => substr( self::app_from_ua( $row['user_agent'] ), 0, 40 ),
    157199                        'extra'        => substr( (string) $row['extra'], 0, 1000 ),
    158200                        'repeat_ip'    => $repeat_ip,
    … …  
    268310        public static function breakdown( $column, $days, $limit = 20 ) {
    269311                global $wpdb;
    270                 if ( ! in_array( $column, array( 'campaign_id', 'placement', 'device', 'source', 'network', 'adgroup_id' ), true ) ) {
     312                if ( ! in_array( $column, array( 'campaign_id', 'placement', 'device', 'source', 'network', 'adgroup_id', 'app' ), true ) ) {
    271313                        return array();
    272314                }
    … …  
    368410
    369411        /**
     412         * 証跡エクスポート用: 期間内の bot(任意で保留も)着地。
     413         *
     414         * @param int  $days               日数。
     415         * @param bool $include_undetermined 保留も含める。
     416         * @return array
     417         */
     418        public static function evidence_rows( $days, $include_undetermined = false ) {
     419                global $wpdb;
     420                $table = self::table();
     421                $where = $include_undetermined ? "verdict IN ('bot','undetermined')" : "verdict = 'bot'";
     422                // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
     423                return $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE created_at >= %s AND {$where} ORDER BY id ASC LIMIT 5000", self::since( $days ) ), ARRAY_A );
     424        }
     425
     426        /**
    370427         * URL から gclid 系の値を取り出す(クリックログとの突合用)。
    371428         *
  • redkirin-ad-click-verification/trunk/includes/class-rkacv-admin.php

    r3743325 r3743355  
    1919                add_action( 'admin_post_rkacv_add_cidr', array( __CLASS__, 'handle_add_cidr' ) );
    2020                add_action( 'admin_post_rkacv_clear_visits', array( __CLASS__, 'handle_clear_visits' ) );
     21                add_action( 'admin_post_rkacv_export', array( __CLASS__, 'handle_export' ) );
    2122                add_action( 'admin_enqueue_scripts', array( __CLASS__, 'assets' ) );
    2223                add_filter( 'plugin_action_links_' . plugin_basename( RKACV_PLUGIN_FILE ), array( __CLASS__, 'action_links' ) );
    … …  
    7677                RKACV_Ad_Visits::truncate();
    7778                wp_safe_redirect( add_query_arg( array( 'page' => self::PAGE, 'tab' => 'ads', 'rkacv_msg' => 'visits_cleared' ), admin_url( 'options-general.php' ) ) );
     79                exit;
     80        }
     81
     82        /**
     83         * 証跡 CSV(無効クリックの申し立て用)。
     84         */
     85        public static function handle_export() {
     86                if ( ! current_user_can( 'manage_options' ) ) {
     87                        wp_die( esc_html__( 'You do not have permission to do this.', 'redkirin-ad-click-verification' ) );
     88                }
     89                check_admin_referer( 'rkacv_export' );
     90                $days = isset( $_GET['days'] ) ? (int) $_GET['days'] : 7;
     91                if ( ! in_array( $days, array( 7, 30, 90 ), true ) ) {
     92                        $days = 7;
     93                }
     94                $with_und = ! empty( $_GET['undetermined'] );
     95
     96                nocache_headers();
     97                header( 'Content-Type: text/csv; charset=UTF-8' );
     98                header( 'Content-Disposition: attachment; filename="ad-click-verification-evidence-' . gmdate( 'Ymd' ) . '.csv"' );
     99                $out = fopen( 'php://output', 'w' ); // phpcs:ignore WordPress.WP.AlternativeFunctions
     100                fwrite( $out, "\xEF\xBB\xBF" ); // phpcs:ignore WordPress.WP.AlternativeFunctions
     101                fputcsv( $out, array( 'datetime', 'type', 'verdict_or_reasons', 'click_id', 'campaign_id', 'placement', 'app', 'device', 'ip_anonymised', 'user_agent', 'page_url', 'dwell_ms', 'signals', 'link_url' ) );
     102                foreach ( RKACV_Ad_Visits::evidence_rows( $days, $with_und ) as $r ) {
     103                        fputcsv( $out, array( get_date_from_gmt( $r['created_at'], 'Y-m-d H:i:s' ), 'landing', $r['verdict'] . ( $r['reasons'] ? ' (' . $r['reasons'] . ')' : '' ), $r['gclid'], $r['campaign_id'], $r['placement'], $r['app'], $r['device'], $r['ip'], $r['user_agent'], $r['page_url'], $r['dwell_ms'], $r['signals'], '' ) );
     104                }
     105                foreach ( RKACV_Log::evidence_rows( $days ) as $r ) {
     106                        fputcsv( $out, array( get_date_from_gmt( $r['created_at'], 'Y-m-d H:i:s' ), 'click', $r['reasons'], $r['click_id'], '', '', RKACV_Ad_Visits::app_from_ua( $r['user_agent'] ), '', $r['ip'], $r['user_agent'], $r['page_url'], $r['dwell_ms'], $r['signals'], $r['link_url'] ) );
     107                }
     108                fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions
    78109                exit;
    79110        }
    … …  
    317348
    318349        /** 基本設定タブの項目。 */
    319         const BASIC_KEYS = array( 'scope', 'verified_pageview', 'bot_tag_block', 'anonymize_ip', 'datalayer_event', 'debug', 'dedupe_daily_limit', 'dedupe_enabled', 'dedupe_window_min', 'dwell_signal_ms', 'enabled', 'gtag_direct', 'guard_datalayer', 'guarded_events', 'intercept_clicks', 'link_patterns', 'log_enabled', 'log_retention_days', 'log_verified', 'min_dwell_ms', 'min_signals' );
     350        const BASIC_KEYS = array( 'scope', 'verified_pageview', 'bot_tag_block', 'form_enabled', 'form_block_hard', 'form_exclude', 'thanks_paths', 'thanks_window_min', 'anonymize_ip', 'datalayer_event', 'debug', 'dedupe_daily_limit', 'dedupe_enabled', 'dedupe_window_min', 'dwell_signal_ms', 'enabled', 'gtag_direct', 'guard_datalayer', 'guarded_events', 'intercept_clicks', 'link_patterns', 'log_enabled', 'log_retention_days', 'log_verified', 'min_dwell_ms', 'min_signals' );
    320351
    321352        /** 高度な設定タブの項目。 */
    322         const ADVANCED_KEYS = array( 'ad_capture_params', 'ad_report_delay_ms', 'ad_retention_days', 'ad_tracking', 'ad_trigger_params', 'bot_ua_patterns', 'deny_cidrs', 'deny_enabled', 'deny_ua', 'deny_ua_patterns', 'deny_webdriver', 'extra_cidrs', 'ip_check' );
     353        const ADVANCED_KEYS = array( 'cpc_default', 'cpc_by_campaign', 'currency_symbol', 'alert_enabled', 'alert_email', 'alert_bot_share', 'alert_min_landings', 'alert_repeat_clicks', 'alert_digest', 'ad_capture_params', 'ad_report_delay_ms', 'ad_retention_days', 'ad_tracking', 'ad_trigger_params', 'bot_ua_patterns', 'deny_cidrs', 'deny_enabled', 'deny_ua', 'deny_ua_patterns', 'deny_webdriver', 'extra_cidrs', 'ip_check' );
    323354
    324355        private static function render_settings( $section = 'basic' ) {
    … …  
    347378                                self::field_number( $s, 'ad_report_delay_ms', __( 'Verdict delay (ms)', 'redkirin-ad-click-verification' ), __( 'How long to wait after the page starts loading before the landing is scored and sent. If the visitor leaves earlier, it is sent at that moment. Recommended 10000.', 'redkirin-ad-click-verification' ), 0, 120000, 'ad_tracking log_enabled' );
    348379                                self::field_number( $s, 'ad_retention_days', __( 'Ad landing retention (days)', 'redkirin-ad-click-verification' ), '', 1, 365, 'ad_tracking log_enabled' );
     380                                ?>
     381                        </table>
     382
     383                        <h2><?php esc_html_e( 'Wasted spend estimate', 'redkirin-ad-click-verification' ); ?></h2>
     384                        <table class="form-table" role="presentation">
     385                                <?php
     386                                self::field_number( $s, 'cpc_default', __( 'Average CPC', 'redkirin-ad-click-verification' ), __( 'Average cost per click used for "bot landings x CPC" in the Ad traffic report and the dashboard. 0 hides the estimate.', 'redkirin-ad-click-verification' ), 0, 1000000 );
     387                                self::field_textarea( $s, 'cpc_by_campaign', __( 'CPC per campaign', 'redkirin-ad-click-verification' ), __( 'One line per campaign as <code>campaign_id=cpc</code>, e.g. <code>21505995745=180</code>. Campaigns not listed use the average CPC.', 'redkirin-ad-click-verification' ), 4 );
     388                                self::field_text( $s, 'currency_symbol', __( 'Currency symbol', 'redkirin-ad-click-verification' ) );
     389                                ?>
     390                        </table>
     391
     392                        <h2><?php esc_html_e( 'Alerts', 'redkirin-ad-click-verification' ); ?></h2>
     393                        <table class="form-table" role="presentation">
     394                                <?php
     395                                self::field_checkbox( $s, 'alert_enabled', __( 'Send alert emails', 'redkirin-ad-click-verification' ), __( 'Checks the last 24 hours once an hour (WP-Cron) and emails when the bot share of ad landings or repeated conversions from one click ID exceed the thresholds. Each condition is reported at most once per 24 hours.', 'redkirin-ad-click-verification' ) );
     396                                self::field_text( $s, 'alert_email', __( 'Recipient', 'redkirin-ad-click-verification' ), __( 'Leave empty to use the site admin email.', 'redkirin-ad-click-verification' ), 'alert_enabled' );
     397                                self::field_number( $s, 'alert_bot_share', __( 'Bot share threshold (%)', 'redkirin-ad-click-verification' ), '', 1, 100, 'alert_enabled' );
     398                                self::field_number( $s, 'alert_min_landings', __( 'Minimum landings for the share alert', 'redkirin-ad-click-verification' ), '', 1, 100000, 'alert_enabled' );
     399                                self::field_number( $s, 'alert_repeat_clicks', __( 'Repeated conversions per click ID', 'redkirin-ad-click-verification' ), '', 2, 1000, 'alert_enabled' );
     400                                self::field_checkbox( $s, 'alert_digest', __( 'Daily digest', 'redkirin-ad-click-verification' ), __( 'Once a day: blocked / verified clicks, ad landings and the wasted spend estimate.', 'redkirin-ad-click-verification' ), 'alert_enabled' );
    349401                                ?>
    350402                        </table>
    … …  
    406458                                self::field_number( $s, 'dedupe_window_min', __( 'Window (minutes)', 'redkirin-ad-click-verification' ), __( 'Within this window, the same link type is measured only once per click ID / session. 0 disables the window rule.', 'redkirin-ad-click-verification' ), 0, 1440, 'dedupe_enabled' );
    407459                                self::field_number( $s, 'dedupe_daily_limit', __( 'Daily limit per click ID', 'redkirin-ad-click-verification' ), __( 'Maximum verified clicks per click ID in 24 hours, checked against the log via the REST API. 0 disables the limit.', 'redkirin-ad-click-verification' ), 0, 100, 'dedupe_enabled' );
     460                                ?>
     461                        </table>
     462
     463                        <h2><?php esc_html_e( 'Form submissions and thank-you pages', 'redkirin-ad-click-verification' ); ?></h2>
     464                        <table class="form-table" role="presentation">
     465                                <?php
     466                                self::field_checkbox( $s, 'form_enabled', __( 'Verify form submissions', 'redkirin-ad-click-verification' ), __( 'Applies the same human verification to form submissions. Every submission gets hidden fields <code>rkacv_verdict</code> (human / undetermined / bot), <code>rkacv_reasons</code>, <code>rkacv_dwell</code> and <code>rkacv_signals</code>, so your CRM or form plugin can filter them. Verified submissions push <code>rkacv_form_submit</code> to the dataLayer; use it as the form key event.', 'redkirin-ad-click-verification' ) );
     467                                self::field_checkbox( $s, 'form_block_hard', __( 'Block submissions with definite bot signals', 'redkirin-ad-click-verification' ), __( 'Stops the submission in the browser when webdriver, a bot user agent, automation globals or a synthetic submit event are present. Contact Form 7 marks such submissions as spam and WPForms rejects them on the server as well. Submissions that merely lack dwell time or interaction are never blocked.', 'redkirin-ad-click-verification' ), 'form_enabled' );
     468                                self::field_textarea( $s, 'form_exclude', __( 'Forms to ignore', 'redkirin-ad-click-verification' ), __( 'CSS selectors, one per line. Matching forms (or forms inside a matching element) are not verified.', 'redkirin-ad-click-verification' ), 4, 'form_enabled' );
     469                                self::field_textarea( $s, 'thanks_paths', __( 'Thank-you page paths', 'redkirin-ad-click-verification' ), __( 'One path per line, e.g. <code>/thanks/</code> or <code>/contact/complete/</code>. On these pages the verified page view event is pushed only if a verified form was submitted in this browser session within the window below, and <code>rkacv_thanks_view</code> is pushed as well. Direct visits and crawlers hitting the thank-you URL are logged and not counted. Leave empty to disable.', 'redkirin-ad-click-verification' ), 3, 'form_enabled' );
     470                                self::field_number( $s, 'thanks_window_min', __( 'Thank-you window (minutes)', 'redkirin-ad-click-verification' ), '', 1, 1440, 'form_enabled' );
    408471                                ?>
    409472                        </table>
    … …  
    626689                        'repeat_limit'      => __( 'Repeat: daily limit per click ID', 'redkirin-ad-click-verification' ),
    627690                        'key_on_touch'      => __( 'Note: keyboard on touch device', 'redkirin-ad-click-verification' ),
     691                        'no_form_submit'    => __( 'Thank-you page without a verified form submission', 'redkirin-ad-click-verification' ),
    628692                        'deny_ua'           => __( 'Denied: user agent', 'redkirin-ad-click-verification' ),
    629693                        'deny_ip'           => __( 'Denied: IP range', 'redkirin-ad-click-verification' ),
    … …  
    646710        }
    647711
    648         private static function breakdown_table( $title, $rows, $cv_by_key = array(), $key_label = '' ) {
     712        private static function breakdown_table( $title, $rows, $cv_by_key = array(), $key_label = '', $waste_by_key = null, $currency = '' ) {
    649713                if ( empty( $rows ) ) {
    650714                        return;
    … …  
    662726                                        <th><?php esc_html_e( 'Bot share', 'redkirin-ad-click-verification' ); ?></th>
    663727                                        <?php if ( $cv_by_key ) : ?><th class="rkacv-num"><?php esc_html_e( 'Verified clicks', 'redkirin-ad-click-verification' ); ?></th><?php endif; ?>
     728                                        <?php if ( null !== $waste_by_key ) : ?><th class="rkacv-num"><?php esc_html_e( 'Est. waste', 'redkirin-ad-click-verification' ); ?></th><?php endif; ?>
    664729                                </tr>
    665730                        </thead>
    … …  
    680745                                        </td>
    681746                                        <?php if ( $cv_by_key ) : ?><td class="rkacv-num"><?php echo esc_html( number_format_i18n( isset( $cv_by_key[ $r['key'] ] ) ? $cv_by_key[ $r['key'] ] : 0 ) ); ?></td><?php endif; ?>
     747                                        <?php if ( null !== $waste_by_key ) : ?><td class="rkacv-num"><?php echo esc_html( $currency . number_format_i18n( isset( $waste_by_key[ $r['key'] ] ) ? $waste_by_key[ $r['key'] ] : 0 ) ); ?></td><?php endif; ?>
    682748                                </tr>
    683749                        <?php endforeach; ?>
    … …  
    705771                $by_dev = RKACV_Ad_Visits::breakdown( 'device', $days );
    706772                $by_src = RKACV_Ad_Visits::breakdown( 'source', $days );
     773                $by_app = RKACV_Ad_Visits::breakdown( 'app', $days );
     774                $cpc    = RKACV_Settings::cpc_table( $s );
     775                $has_cpc = $cpc['default'] > 0 || ! empty( $cpc['by'] );
     776                $waste_by_cmp = array();
     777                $waste_total  = 0;
     778                if ( $has_cpc ) {
     779                        foreach ( $by_cmp as $c ) {
     780                                $rate = isset( $cpc['by'][ $c['key'] ] ) ? $cpc['by'][ $c['key'] ] : $cpc['default'];
     781                                $waste_by_cmp[ $c['key'] ] = $c['bot'] * $rate;
     782                                $waste_total              += $c['bot'] * $rate;
     783                        }
     784                }
     785                $export_url = wp_nonce_url( admin_url( 'admin-post.php?action=rkacv_export&days=' . $days ), 'rkacv_export' );
    707786                $ips    = RKACV_Ad_Visits::suspicious_ips( $days, 2 );
    708787                $recent = RKACV_Ad_Visits::recent( 50 );
    … …  
    726805                                <?php endforeach; ?>
    727806                        </ul>
     807                        <div class="rkacv-toolbar-right">
     808                                <a class="button" href="<?php echo esc_url( $export_url ); ?>"><?php esc_html_e( 'Export evidence CSV', 'redkirin-ad-click-verification' ); ?></a>
     809                                <a class="button" href="<?php echo esc_url( $export_url . '&undetermined=1' ); ?>" title="<?php esc_attr_e( 'Includes undetermined landings as well as bot signals', 'redkirin-ad-click-verification' ); ?>"><?php esc_html_e( 'Export incl. undetermined', 'redkirin-ad-click-verification' ); ?></a>
    728810                        <form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" onsubmit="return confirm('<?php echo esc_js( __( 'Delete all ad landing records?', 'redkirin-ad-click-verification' ) ); ?>');">
    729811                                <input type="hidden" name="action" value="rkacv_clear_visits">
    … …  
    731813                                <button class="button"><?php esc_html_e( 'Clear records', 'redkirin-ad-click-verification' ); ?></button>
    732814                        </form>
     815                        </div>
    733816                </div>
     817                <p class="description"><?php esc_html_e( 'The evidence CSV (bot landings and blocked clicks with time, click ID, campaign, app, user agent and reasons) can be attached to the Google Ads invalid click claim form.', 'redkirin-ad-click-verification' ); ?></p>
    734818
    735819                <div class="rkacv-cards">
     820                        <?php if ( $has_cpc ) : ?>
     821                        <div class="rkacv-card"><div class="rkacv-card-label"><?php esc_html_e( 'Estimated wasted spend', 'redkirin-ad-click-verification' ); ?></div><div class="rkacv-card-value rkacv-blocked"><?php echo esc_html( $s['currency_symbol'] . number_format_i18n( $waste_total ) ); ?></div><div class="rkacv-card-sub"><?php esc_html_e( 'bot landings x CPC', 'redkirin-ad-click-verification' ); ?></div></div>
     822                        <?php endif; ?>
    736823                        <div class="rkacv-card"><div class="rkacv-card-label"><?php esc_html_e( 'Ad landings', 'redkirin-ad-click-verification' ); ?></div><div class="rkacv-card-value"><?php echo esc_html( number_format_i18n( $sum['total'] ) ); ?></div></div>
    737824                        <div class="rkacv-card"><div class="rkacv-card-label"><?php esc_html_e( 'Bot signals', 'redkirin-ad-click-verification' ); ?></div><div class="rkacv-card-value rkacv-blocked"><?php echo esc_html( number_format_i18n( $sum['bot'] ) ); ?> <small><?php echo esc_html( self::pct( $sum['bot'], $sum['total'] ) ); ?>%</small></div></div>
    … …  
    747834
    748835                <?php
    749                 self::breakdown_table( __( 'By campaign', 'redkirin-ad-click-verification' ), $by_cmp, $cv_by_cmp, __( 'Campaign ID', 'redkirin-ad-click-verification' ) );
     836                self::breakdown_table( __( 'By campaign', 'redkirin-ad-click-verification' ), $by_cmp, $cv_by_cmp, __( 'Campaign ID', 'redkirin-ad-click-verification' ), $has_cpc ? $waste_by_cmp : null, $s['currency_symbol'] );
     837                self::breakdown_table( __( 'By app (in-app browser)', 'redkirin-ad-click-verification' ), $by_app, array(), __( 'App', 'redkirin-ad-click-verification' ) );
    750838                self::breakdown_table( __( 'By placement', 'redkirin-ad-click-verification' ), $by_pl, array(), __( 'Placement', 'redkirin-ad-click-verification' ) );
    751839                self::breakdown_table( __( 'By device', 'redkirin-ad-click-verification' ), $by_dev, array(), __( 'Device', 'redkirin-ad-click-verification' ) );
    … …  
    806894                                                <th><?php esc_html_e( 'Campaign ID', 'redkirin-ad-click-verification' ); ?></th>
    807895                                                <th><?php esc_html_e( 'Placement', 'redkirin-ad-click-verification' ); ?></th>
     896                                                <th><?php esc_html_e( 'App', 'redkirin-ad-click-verification' ); ?></th>
    808897                                                <th><?php esc_html_e( 'Reason', 'redkirin-ad-click-verification' ); ?></th>
    809898                                                <th><?php esc_html_e( 'Dwell / signals', 'redkirin-ad-click-verification' ); ?></th>
    … …  
    828917                                                <td class="rkacv-ellipsis"><?php echo esc_html( $r['campaign_id'] ); ?></td>
    829918                                                <td class="rkacv-ellipsis" title="<?php echo esc_attr( $r['placement'] ); ?>"><?php echo esc_html( $r['placement'] ); ?></td>
     919                                                <td class="rkacv-ellipsis"><?php echo esc_html( isset( $r['app'] ) ? $r['app'] : '' ); ?></td>
    830920                                                <td><?php echo esc_html( self::reasons_label( $r['reasons'] ) ); ?></td>
    831921                                                <td class="rkacv-nowrap"><?php echo esc_html( $r['dwell_ms'] ); ?>ms / <?php echo esc_html( $r['signals'] ); ?></td>
  • redkirin-ad-click-verification/trunk/includes/class-rkacv-dashboard.php

    r3743244 r3743355  
    8787                                        $bot_pct = round( $ads['bot'] / $ads['total'] * 100 );
    8888                                        echo esc_html( sprintf( /* translators: 1: landings, 2: bot percentage, 3: undetermined count */ __( 'Ad landings: %1$d, %2$d%% with bot signals, %3$d undetermined', 'redkirin-ad-click-verification' ), $ads['total'], $bot_pct, $ads['undetermined'] ) );
     89                                        $cpc = RKACV_Settings::cpc_table( $settings );
     90                                        if ( $cpc['default'] > 0 || $cpc['by'] ) {
     91                                                $waste = 0;
     92                                                foreach ( RKACV_Ad_Visits::breakdown( 'campaign_id', self::DAYS, 100 ) as $c ) {
     93                                                        $waste += $c['bot'] * ( isset( $cpc['by'][ $c['key'] ] ) ? $cpc['by'][ $c['key'] ] : $cpc['default'] );
     94                                                }
     95                                                echo ' · ' . esc_html( sprintf( /* translators: 1: currency, 2: amount */ __( 'est. wasted spend %1$s%2$s', 'redkirin-ad-click-verification' ), $settings['currency_symbol'], number_format_i18n( $waste ) ) );
     96                                        }
    8997                                        ?>
    9098                                        <a href="<?php echo esc_url( admin_url( 'options-general.php?page=' . RKACV_Admin::PAGE . '&tab=ads' ) ); ?>"><?php esc_html_e( 'Report', 'redkirin-ad-click-verification' ); ?></a>
  • redkirin-ad-click-verification/trunk/includes/class-rkacv-frontend.php

    r3743325 r3743355  
    5050                        'debug'         => ! empty( $settings['debug'] ),
    5151                        'denyWebdriver' => ! empty( $settings['deny_enabled'] ) && ! empty( $settings['deny_webdriver'] ),
     52                        'forms'         => ! empty( $settings['form_enabled'] ),
     53                        'formBlockHard' => ! empty( $settings['form_block_hard'] ),
     54                        'formExclude'   => RKACV_Settings::lines( $settings['form_exclude'] ),
     55                        'thanksPaths'   => RKACV_Settings::lines( $settings['thanks_paths'] ),
     56                        'thanksWindow'  => (int) $settings['thanks_window_min'] * 60 * 1000,
    5257                        'verifiedPV'    => ! empty( $settings['verified_pageview'] ),
    5358                        'botTagBlock'   => ! empty( $settings['bot_tag_block'] ),
  • redkirin-ad-click-verification/trunk/includes/class-rkacv-log.php

    r3743265 r3743355  
    1010class RKACV_Log {
    1111
    12         const DB_VERSION = '4';
     12        const DB_VERSION = '5';
    1313
    1414        /** 保存する最大行数。超えた分は古い行から削除する(リングバッファ)。 */
    … …  
    103103                }
    104104                return $ok;
     105        }
     106
     107        /**
     108         * 証跡エクスポート用: 期間内の遮断クリック。
     109         *
     110         * @param int $days 日数。
     111         * @return array
     112         */
     113        public static function evidence_rows( $days ) {
     114                global $wpdb;
     115                $table = self::table();
     116                $since = gmdate( 'Y-m-d H:i:s', time() - (int) $days * DAY_IN_SECONDS );
     117                // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
     118                return $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE created_at >= %s AND status = 'blocked' ORDER BY id ASC LIMIT 5000", $since ), ARRAY_A );
    105119        }
    106120
  • redkirin-ad-click-verification/trunk/includes/class-rkacv-settings.php

    r3743325 r3743355  
    104104                        'dedupe_window_min'    => 30,
    105105                        'dedupe_daily_limit'   => 3,
     106                        // 無駄費用の推定(bot 着地 × CPC)。
     107                        'cpc_default'          => 0,
     108                        'cpc_by_campaign'      => '',
     109                        'currency_symbol'      => '¥',
     110                        // フォーム送信の保護。
     111                        'form_enabled'         => 1,
     112                        'form_block_hard'      => 1,
     113                        'form_exclude'         => "form[role=search]\n.search-form\n#loginform\n#adminbarsearch",
     114                        // サンクスページの到達検証(空なら無効)。
     115                        'thanks_paths'         => '',
     116                        'thanks_window_min'    => 30,
     117                        // 異常検知アラート。
     118                        'alert_enabled'        => 0,
     119                        'alert_email'          => '',
     120                        'alert_bot_share'      => 30,
     121                        'alert_min_landings'   => 20,
     122                        'alert_repeat_clicks'  => 5,
     123                        'alert_digest'         => 0,
    106124                        // 広告着地の記録(アドフラウド可視化)。
    107125                        'ad_tracking'          => 1,
    … …  
    182200                $out = array();
    183201
    184                 foreach ( array( 'enabled', 'intercept_clicks', 'guard_datalayer', 'gtag_direct', 'ip_check', 'log_enabled', 'log_verified', 'anonymize_ip', 'debug', 'deny_enabled', 'deny_cidrs', 'deny_ua', 'deny_webdriver', 'ad_tracking', 'dedupe_enabled', 'verified_pageview', 'bot_tag_block' ) as $k ) {
     202                foreach ( array( 'enabled', 'intercept_clicks', 'guard_datalayer', 'gtag_direct', 'ip_check', 'log_enabled', 'log_verified', 'anonymize_ip', 'debug', 'deny_enabled', 'deny_cidrs', 'deny_ua', 'deny_webdriver', 'ad_tracking', 'dedupe_enabled', 'verified_pageview', 'bot_tag_block', 'form_enabled', 'form_block_hard', 'alert_enabled', 'alert_digest' ) as $k ) {
    185203                        $out[ $k ] = empty( $input[ $k ] ) ? 0 : 1;
    186204                }
    … …  
    190208                $out['dwell_signal_ms']    = isset( $input['dwell_signal_ms'] ) ? max( 0, min( 600000, (int) $input['dwell_signal_ms'] ) ) : $d['dwell_signal_ms'];
    191209                $out['log_retention_days'] = isset( $input['log_retention_days'] ) ? max( 1, min( 365, (int) $input['log_retention_days'] ) ) : $d['log_retention_days'];
     210                $out['cpc_default']        = isset( $input['cpc_default'] ) ? max( 0, (float) $input['cpc_default'] ) : $d['cpc_default'];
     211                $out['thanks_window_min']  = isset( $input['thanks_window_min'] ) ? max( 1, min( 1440, (int) $input['thanks_window_min'] ) ) : $d['thanks_window_min'];
     212                $out['alert_bot_share']    = isset( $input['alert_bot_share'] ) ? max( 1, min( 100, (int) $input['alert_bot_share'] ) ) : $d['alert_bot_share'];
     213                $out['alert_min_landings'] = isset( $input['alert_min_landings'] ) ? max( 1, min( 100000, (int) $input['alert_min_landings'] ) ) : $d['alert_min_landings'];
     214                $out['alert_repeat_clicks'] = isset( $input['alert_repeat_clicks'] ) ? max( 2, min( 1000, (int) $input['alert_repeat_clicks'] ) ) : $d['alert_repeat_clicks'];
     215                $out['currency_symbol']    = isset( $input['currency_symbol'] ) ? substr( sanitize_text_field( $input['currency_symbol'] ), 0, 5 ) : $d['currency_symbol'];
     216                $out['alert_email']        = isset( $input['alert_email'] ) ? sanitize_text_field( $input['alert_email'] ) : $d['alert_email'];
    192217                $out['dedupe_window_min']  = isset( $input['dedupe_window_min'] ) ? max( 0, min( 1440, (int) $input['dedupe_window_min'] ) ) : $d['dedupe_window_min'];
    193218                $out['dedupe_daily_limit'] = isset( $input['dedupe_daily_limit'] ) ? max( 0, min( 100, (int) $input['dedupe_daily_limit'] ) ) : $d['dedupe_daily_limit'];
    … …  
    195220                $out['ad_retention_days']  = isset( $input['ad_retention_days'] ) ? max( 1, min( 365, (int) $input['ad_retention_days'] ) ) : $d['ad_retention_days'];
    196221
    197                 foreach ( array( 'link_patterns', 'guarded_events', 'bot_ua_patterns', 'extra_cidrs', 'deny_ua_patterns', 'ad_trigger_params', 'ad_capture_params' ) as $k ) {
     222                foreach ( array( 'link_patterns', 'guarded_events', 'bot_ua_patterns', 'extra_cidrs', 'deny_ua_patterns', 'ad_trigger_params', 'ad_capture_params', 'cpc_by_campaign', 'form_exclude', 'thanks_paths' ) as $k ) {
    198223                        $out[ $k ] = isset( $input[ $k ] ) ? self::sanitize_lines( $input[ $k ] ) : $d[ $k ];
    199224                }
    … …  
    240265
    241266        /**
     267         * キャンペーン ID ごとの CPC("id=cpc" 行)とデフォルト CPC。
     268         *
     269         * @param array $settings 設定。
     270         * @return array{default:float,by:array<string,float>}
     271         */
     272        public static function cpc_table( $settings ) {
     273                $by = array();
     274                foreach ( self::lines( $settings['cpc_by_campaign'] ) as $line ) {
     275                        if ( preg_match( '/^\s*([^=\s]+)\s*=\s*([0-9.]+)\s*$/', $line, $m ) ) {
     276                                $by[ $m[1] ] = (float) $m[2];
     277                        }
     278                }
     279                return array(
     280                        'default' => (float) $settings['cpc_default'],
     281                        'by'      => $by,
     282                );
     283        }
     284
     285        /**
    242286         * 改行区切りテキストの整形。
    243287         *
  • redkirin-ad-click-verification/trunk/languages/redkirin-ad-click-verification-ja.po

    r3743325 r3743355  
    22msgid ""
    33msgstr ""
    4 "Project-Id-Version: Redkirin Ad Click Verification 1.2.0\n"
     4"Project-Id-Version: Redkirin Ad Click Verification 1.3.0\n"
    55"POT-Creation-Date: 2026-10-12 00:08+0000\n"
    66"PO-Revision-Date: 2026-10-12 00:08+0000\n"
    … …  
    683683msgstr "別案として、page_view のキーイベントを残したまま GA4 設定タグに例外トリガー「rkacv_bot 等しい true」を追加する方法もあります。ただし読込時点で分かる bot にしか効きません。"
    684684
     685msgid "Wasted spend estimate"
     686msgstr "無駄費用の推定"
     687
     688msgid "Average CPC"
     689msgstr "平均 CPC"
     690
     691msgid "Average cost per click used for \"bot landings x CPC\" in the Ad traffic report and the dashboard. 0 hides the estimate."
     692msgstr "広告トラフィックのレポートとダッシュボードで「bot 着地 × CPC」の計算に使う平均クリック単価。0 で非表示。"
     693
     694msgid "CPC per campaign"
     695msgstr "キャンペーン別 CPC"
     696
     697msgid "One line per campaign as <code>campaign_id=cpc</code>, e.g. <code>21505995745=180</code>. Campaigns not listed use the average CPC."
     698msgstr "1 行 1 キャンペーンで <code>キャンペーンID=CPC</code> の形式(例: <code>21505995745=180</code>)。未記載のキャンペーンは平均 CPC を使います。"
     699
     700msgid "Currency symbol"
     701msgstr "通貨記号"
     702
     703msgid "Alerts"
     704msgstr "アラート"
     705
     706msgid "Send alert emails"
     707msgstr "アラートメールを送る"
     708
     709msgid "Checks the last 24 hours once an hour (WP-Cron) and emails when the bot share of ad landings or repeated conversions from one click ID exceed the thresholds. Each condition is reported at most once per 24 hours."
     710msgstr "1 時間ごと(WP-Cron)に直近 24 時間を確認し、広告着地の bot 率や同一クリック ID からの反復コンバージョンがしきい値を超えたらメールします。同じ条件の通知は 24 時間に 1 回までです。"
     711
     712msgid "Recipient"
     713msgstr "宛先"
     714
     715msgid "Leave empty to use the site admin email."
     716msgstr "空ならサイトの管理者メールアドレスを使います。"
     717
     718msgid "Bot share threshold (%)"
     719msgstr "bot 率のしきい値 (%)"
     720
     721msgid "Minimum landings for the share alert"
     722msgstr "bot 率アラートに必要な最低着地数"
     723
     724msgid "Repeated conversions per click ID"
     725msgstr "クリック ID ごとの反復コンバージョン数"
     726
     727msgid "Daily digest"
     728msgstr "日次ダイジェスト"
     729
     730msgid "Once a day: blocked / verified clicks, ad landings and the wasted spend estimate."
     731msgstr "1 日 1 回、遮断 / 検証済みクリック、広告着地、無駄費用の推定を送ります。"
     732
     733msgid "Form submissions and thank-you pages"
     734msgstr "フォーム送信とサンクスページ"
     735
     736msgid "Verify form submissions"
     737msgstr "フォーム送信を検証する"
     738
     739msgid "Applies the same human verification to form submissions. Every submission gets hidden fields <code>rkacv_verdict</code> (human / undetermined / bot), <code>rkacv_reasons</code>, <code>rkacv_dwell</code> and <code>rkacv_signals</code>, so your CRM or form plugin can filter them. Verified submissions push <code>rkacv_form_submit</code> to the dataLayer; use it as the form key event."
     740msgstr "フォーム送信にも同じ人間判定を適用します。すべての送信に隠しフィールド <code>rkacv_verdict</code>(human / undetermined / bot)、<code>rkacv_reasons</code>、<code>rkacv_dwell</code>、<code>rkacv_signals</code> が付くので、CRM やフォームプラグイン側で判別できます。検証済みの送信は <code>rkacv_form_submit</code> を dataLayer に push します。フォームのキーイベントにはこれを使ってください。"
     741
     742msgid "Block submissions with definite bot signals"
     743msgstr "bot 兆候が明確な送信を止める"
     744
     745msgid "Stops the submission in the browser when webdriver, a bot user agent, automation globals or a synthetic submit event are present. Contact Form 7 marks such submissions as spam and WPForms rejects them on the server as well. Submissions that merely lack dwell time or interaction are never blocked."
     746msgstr "webdriver・bot UA・自動化ツールの痕跡・合成 submit イベントがある場合、ブラウザー側で送信を止めます。Contact Form 7 では迷惑メール扱い、WPForms ではサーバー側でもエラーにします。滞在時間や操作が足りないだけの送信は止めません。"
     747
     748msgid "Forms to ignore"
     749msgstr "対象外にするフォーム"
     750
     751msgid "CSS selectors, one per line. Matching forms (or forms inside a matching element) are not verified."
     752msgstr "CSS セレクタを 1 行 1 件。一致するフォーム(または一致する要素内のフォーム)は検証しません。"
     753
     754msgid "Thank-you page paths"
     755msgstr "サンクスページのパス"
     756
     757msgid "One path per line, e.g. <code>/thanks/</code> or <code>/contact/complete/</code>. On these pages the verified page view event is pushed only if a verified form was submitted in this browser session within the window below, and <code>rkacv_thanks_view</code> is pushed as well. Direct visits and crawlers hitting the thank-you URL are logged and not counted. Leave empty to disable."
     758msgstr "1 行 1 パス(例: <code>/thanks/</code>、<code>/contact/complete/</code>)。これらのページでは、同じブラウザーセッションで下の時間内に検証済みのフォーム送信があった場合だけ検証済みページビューを push し、あわせて <code>rkacv_thanks_view</code> も push します。URL 直打ちやクローラーの到達はログに残し計測しません。空で無効。"
     759
     760msgid "Thank-you window (minutes)"
     761msgstr "サンクスページの有効時間(分)"
     762
     763msgid "Thank-you page without a verified form submission"
     764msgstr "検証済みフォーム送信のないサンクスページ到達"
     765
     766msgid "Est. waste"
     767msgstr "推定無駄費用"
     768
     769msgid "Export evidence CSV"
     770msgstr "証跡 CSV を出力"
     771
     772msgid "Includes undetermined landings as well as bot signals"
     773msgstr "bot 兆候ありに加えて保留の着地も含めます"
     774
     775msgid "Export incl. undetermined"
     776msgstr "保留を含めて出力"
     777
     778msgid "The evidence CSV (bot landings and blocked clicks with time, click ID, campaign, app, user agent and reasons) can be attached to the Google Ads invalid click claim form."
     779msgstr "証跡 CSV(bot 着地と遮断クリック: 日時、クリック ID、キャンペーン、アプリ、UA、理由)は Google 広告の無効なクリックの申し立てフォームに添付できます。"
     780
     781msgid "Estimated wasted spend"
     782msgstr "推定無駄費用"
     783
     784msgid "bot landings x CPC"
     785msgstr "bot 着地 × CPC"
     786
     787msgid "By app (in-app browser)"
     788msgstr "流入アプリ別(アプリ内ブラウザー)"
     789
     790msgid "App"
     791msgstr "アプリ"
     792
     793msgid "Bot share of ad landings in the last 24 hours: %1$d%% (%2$d landings, %3$d with bot signals)."
     794msgstr "直近 24 時間の広告着地の bot 率: %1$d%%(着地 %2$d 件、bot 兆候あり %3$d 件)。"
     795
     796msgid "Campaign %1$s: %2$d bot / %3$d landings"
     797msgstr "キャンペーン %1$s: bot %2$d / 着地 %3$d"
     798
     799msgid "Repeated conversions from one ad click ID %1$s: %2$d verified, %3$d repeat-blocked, %4$d IPs in the last 24 hours."
     800msgstr "1 つの広告クリック ID %1$s から反復コンバージョン: 直近 24 時間で検証済み %2$d 件、反復遮断 %3$d 件、IP 数 %4$d。"
     801
     802msgid "Ad Click Verification alert"
     803msgstr "Ad Click Verification アラート"
     804
     805msgid "Clicks in the last 24 hours: %1$d blocked (%2$d with bot signals), %3$d verified, %4$d page requests denied."
     806msgstr "直近 24 時間のクリック: 遮断 %1$d 件(bot 兆候あり %2$d 件)、検証済み %3$d 件、表示拒否 %4$d 件。"
     807
     808msgid "Ad landings: %1$d (%2$d bot signals, %3$d undetermined, %4$d human)."
     809msgstr "広告着地: %1$d 件(bot 兆候あり %2$d、保留 %3$d、人間 %4$d)。"
     810
     811msgid "Estimated wasted spend (bot landings x CPC): %1$s%2$s"
     812msgstr "推定無駄費用(bot 着地 × CPC): %1$s%2$s"
     813
     814msgid "Ad Click Verification daily digest"
     815msgstr "Ad Click Verification 日次ダイジェスト"
     816
     817msgid "est. wasted spend %1$s%2$s"
     818msgstr "推定無駄費用 %1$s%2$s"
     819
     820msgid "The submission could not be verified. Please try again."
     821msgstr "送信を確認できませんでした。もう一度お試しください。"
     822
  • redkirin-ad-click-verification/trunk/readme.txt

    r3743325 r3743355  
    55Tested up to: 7.1
    66Requires PHP: 7.4
    7 Stable tag: 1.2.0
     7Stable tag: 1.3.0
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    2121* **Click interception** – unverified clicks are stopped at the `window` capture phase, before Google Tag Manager click triggers or inline `onclick` handlers see them. The navigation itself (dialing, opening LINE, following the link) is performed by the plugin, so visitors are never blocked.
    2222* **dataLayer guard** – `gtm.linkClick` / `gtm.click` and any event names you specify are dropped from `dataLayer.push` while the visitor is unverified.
     23* **Form submission verification** – the same human verification applies to form submissions. Hidden fields carry the verdict for your CRM, verified submissions push <code>rkacv_form_submit</code>, submissions with definite bot signals are stopped (Contact Form 7 marks them as spam, WPForms rejects them), and thank-you pages only count when a verified form was submitted in the same session.
     24* **In-app browser detection** – landings are classified by app (Yahoo! JAPAN, LINE, Facebook, Instagram, X, Google app, TikTok, generic WebView, ...), so you can see which apps bot traffic comes from even without ValueTrack placements.
     25* **Wasted spend estimate** – enter an average CPC (or per campaign) and the report shows bot landings x CPC per campaign and in total.
     26* **Evidence export** – CSV of bot landings and blocked clicks for the Google Ads invalid click claim form.
     27* **Alerts** – hourly check with email when the bot share of ad landings or repeated conversions from one click ID exceed your thresholds; optional daily digest.
    2328* **Page view protection** – obvious bots (webdriver, bot user agents, automation globals) get no tags at all: every dataLayer push is dropped, so GTM and gtag.js send nothing, not even page_view. Once a visitor is verified, a <code>rkacv_verified_pageview</code> event is pushed that you can use as a key event instead of page_view (thank-you pages, landing pages).
    2429* **Verified events** – verified clicks push `rkacv_verified_click` to the dataLayer (with `rkacv_link_type`, `rkacv_link_url`, `rkacv_link_text`, `rkacv_element_id`), and can optionally call `gtag('event', ...)` directly.
    … …  
    9499== Changelog ==
    95100
     101= 1.3.0 =
     102* In-app browser classification for ad landings (new "By app" breakdown and column); evidence CSV export for Google Ads invalid click claims; wasted spend estimate (bot landings x CPC, per campaign CPC overrides) in the report, the dashboard and the digest.
     103* Form submission verification: hidden verdict fields, rkacv_form_submit event for verified submissions, blocking of submissions with definite bot signals (browser, Contact Form 7 spam, WPForms error), configurable excluded forms.
     104* Thank-you page gating: verified page view / rkacv_thanks_view only after a verified form submission in the same session; direct hits are logged.
     105* Alert emails (hourly check: bot share, repeated conversions per click ID) and an optional daily digest.
     106
    96107= 1.2.0 =
    97108* New "Fire no tags for obvious bots" (default on): when webdriver, a bot user agent or automation globals are present at load, every dataLayer push is dropped so GTM / gtag.js send nothing, including page_view.
  • redkirin-ad-click-verification/trunk/redkirin-ad-click-verification.php

    r3743325 r3743355  
    33 * Plugin Name: Redkirin Ad Click Verification
    44 * Description: Stops bots from inflating GA4 events and Google Ads conversions by verifying that clicks on phone, LINE, email and other tracked links come from a real person.
    5  * Version:     1.2.0
     5 * Version:     1.3.0
    66 * Author:      Redkirin
    77 * Author URI:  https://redkirin.co.jp/
    … …  
    1717}
    1818
    19 define( 'RKACV_VERSION', '1.2.0' );
     19define( 'RKACV_VERSION', '1.3.0' );
    2020define( 'RKACV_PLUGIN_FILE', __FILE__ );
    2121define( 'RKACV_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
    … …  
    3030require_once RKACV_PLUGIN_DIR . 'includes/class-rkacv-frontend.php';
    3131require_once RKACV_PLUGIN_DIR . 'includes/class-rkacv-deny.php';
     32require_once RKACV_PLUGIN_DIR . 'includes/class-rkacv-forms.php';
     33require_once RKACV_PLUGIN_DIR . 'includes/class-rkacv-alerts.php';
    3234require_once RKACV_PLUGIN_DIR . 'includes/class-rkacv-admin.php';
    3335require_once RKACV_PLUGIN_DIR . 'includes/class-rkacv-dashboard.php';
    … …  
    4345                wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'rkacv_daily_maintenance' );
    4446        }
     47        RKACV_Alerts::schedule();
    4548
    4649        $settings = RKACV_Settings::get();
    … …  
    5659function rkacv_deactivate() {
    5760        wp_clear_scheduled_hook( 'rkacv_daily_maintenance' );
     61        RKACV_Alerts::unschedule();
    5862}
    5963register_deactivation_hook( __FILE__, 'rkacv_deactivate' );
    … …  
    9094                RKACV_Frontend::init();
    9195                RKACV_Deny::init();
     96                RKACV_Forms::init();
     97                RKACV_Alerts::init();
     98                RKACV_Alerts::schedule();
    9299                if ( is_admin() ) {
    93100                        RKACV_Admin::init();
  • redkirin-ad-click-verification/trunk/uninstall.php

    r3743244 r3743355  
    2727delete_transient( 'rkacv_ip_refresh_lock' );
    2828delete_transient( 'rkacv_rl_global' );
     29wp_clear_scheduled_hook( 'rkacv_hourly_check' );
     30delete_transient( 'rkacv_alert_share' );
Note: See TracChangeset for help on using the changeset viewer.