Plugin Directory

Changeset 3743226


Ignore:
Timestamp:
10/13/2026 08:07:51 AM (13 days ago)
Author:
reza0tain0smart0solutions
Message:

1.0.37 - Outbound clicks split by target (Amazon/other), clicks per target domain, llms.txt fixes

Location:
rezatain-seo/trunk
Files:
5 edited

Legend:

Unmodified
Added
Removed
  • rezatain-seo/trunk/readme.txt

    r3715760 r3743226  
    44Requires at least: 6.8
    55Tested up to: 7.1
    6 Stable tag: 1.0.36
     6Stable tag: 1.0.37
    77Requires PHP: 7.4
    88License: GPLv2 or later
    … …  
    7777
    7878== Changelog ==
     79
     80= 1.0.37 =
     81* Der Besucherbericht für Auswertungen (REST) teilt ausgehende Klicks jetzt nach Ziel: je Seite und in der Gesamtsumme stehen Klicks auf Amazon getrennt von allen übrigen externen Zielen. Bisher zählte eine Zahl beides zusammen – Seiten mit vielen Quellen- oder Herstellerlinks sahen dadurch wie gute Affiliate-Seiten aus.
     82* Neu im selben Bericht: Klicks je Ziel-Domain. Es wird nur der Domainname ausgegeben, nie die angeklickte Adresse; Domains mit weniger als drei Klicks und alles jenseits der Listenlänge stehen zusammengefasst in einer Restsumme.
     83* Der Bericht nennt jetzt, mit welchem Zeitfenster jeder Block rechnet (gleitend oder nach Kalendertagen). Erfasst wird nichts Neues.
     84* **Fix: llms.txt enthielt HTML-Schreibweisen wie `&`.** Titel, Auszüge, Kategorienamen und der Name der Website stehen jetzt als Klartext in `llms.txt` und `llms-full.txt`.
     85* **Fix: `/llms.txt` mit angehängten Parametern wurde umgeleitet** (`/llms.txt?x=1` → `/llms.txt/?x=1`). Die Datei wird jetzt direkt ausgeliefert.
    7986
    8087= 1.0.36 =
  • rezatain-seo/trunk/rezatain-seo.php

    r3715760 r3743226  
    1313 * Description:       Modulares SEO-Plugin: Titel & Meta, Schema (17 Typen), XML-/HTML-Sitemaps, Weiterleitungen & 404-Monitor, interne Verlinkung, Breadcrumbs, LLM-/KI-Crawler-Steuerung (llms.txt), Besucher-Statistik und lokale Inhaltsanalyse — schnell, modular und datensparsam.
    1414 * Requires at least: 6.8
    15  * Version:           1.0.36
     15 * Version:           1.0.37
    1616 * Requires PHP:      7.4
    1717 * Author:            Markus Reza Tain
    … …  
    3131 * Plugin constants.
    3232 */
    33 define( 'REZA_TAIN_VERSION', '1.0.36' );
     33define( 'REZA_TAIN_VERSION', '1.0.37' );
    3434define( 'REZA_TAIN_FILE', __FILE__ );
    3535define( 'REZA_TAIN_DIR', plugin_dir_path( __FILE__ ) );
  • rezatain-seo/trunk/src/Modules/Analytics/VisitorAnalytics.php

    r3714478 r3743226  
    11921192                        'outbound_clicks'     => $limit ?: 25,
    11931193                        'referrers_by_domain' => $limit ?: 15,
     1194                        'outbound_domains'    => $limit ?: 15,
    11941195                ];
    11951196
    … …  
    12201221                                // 1.0.33 — welche Deckel in diesem Bundle galten.
    12211222                                'list_limits'    => $limits,
     1223                                // 1.0.37 — die Blöcke rechnen mit verschiedenen Fenstern; ohne diese
     1224                                // Angabe sehen overview und die Summe aus trend wie ein Widerspruch aus.
     1225                                'windows'        => [
     1226                                        'overview'         => 'rolling: now minus period_days x 24 h',
     1227                                        'top_pages'        => 'rolling: now minus period_days x 24 h',
     1228                                        'trend'            => 'calendar days: from 00:00 of the day period_days ago, today included',
     1229                                        'outbound_clicks'  => 'calendar days: from 00:00 of the day period_days ago, today included',
     1230                                        'outbound_domains' => 'calendar days: from 00:00 of the day period_days ago, today included',
     1231                                ],
     1232                                // Je Seitenaufruf höchstens ein Klick pro Ziel: ohne Einwilligung pro
     1233                                // Ziel-Domain, mit Einwilligung pro Ziel-Adresse (Domain + Pfad).
     1234                                'outbound_counting' => 'per pageview at most one click per target: without consent per target domain, with consent per target URL (domain + path)',
    12221235                        ],
    12231236                        'visitor_analytics' => [
    … …  
    12311244                                // Aggregat-only: Pfad, Zähler, Quote. Keine Ziel-URLs.
    12321245                                'outbound_clicks'  => VisitorAnalyticsReports::outbound_clicks( $days, $limits['outbound_clicks'] ),
    1233                                 'audience'         => VisitorAnalyticsReports::audience( $days ),
     1246                                // 1.0.37 — Klicks je Ziel-Domain (nur Hostname, erst ab 3 Klicks einzeln).
     1247                                'outbound_domains' => VisitorAnalyticsReports::outbound_domains( $days, $limits['outbound_domains'] ),
     1248                                'audience'        => VisitorAnalyticsReports::audience( $days ),
    12341249                                'referrers'        => VisitorAnalyticsReports::referrers( $days, $limits['referrers_by_domain'] ),
    12351250                                // 1.0.33 — ungekappte Summen zu den drei Listen oben, Klicks
  • rezatain-seo/trunk/src/Modules/Analytics/VisitorAnalyticsReports.php

    r3714478 r3743226  
    2525         */
    2626        public const MAX_LIST = 1000;
     27
     28        /**
     29         * 1.0.37 — SQL-Bedingung „Klickziel ist Amazon", auf die Spalte `label` der
     30         * Ereignistabelle. Dort steht der Hostname (ohne Einwilligung) oder Hostname +
     31         * Pfad (mit Einwilligung); verglichen wird nur der Teil vor dem ersten „/".
     32         * Trifft den Shop (amazon.de, www.amazon.com, amazon.co.uk …) und die
     33         * Kurzlinks, nicht aber andere Amazon-Dienste wie aws.amazon.com.
     34         * `[.]` statt `\.`, weil MySQL und MariaDB Backslashes verschieden lesen.
     35         */
     36        private const AMAZON_HOST_SQL = "(SUBSTRING_INDEX(label, '/', 1) REGEXP '^(www[.]|smile[.])?amazon[.][a-z]{2,3}([.][a-z]{2})?$'"
     37                . " OR SUBSTRING_INDEX(label, '/', 1) IN ('amzn.to', 'amzn.eu', 'a.co'))";
    2738
    2839        /**
    … …  
    145156         * damit eher zu niedrig als zu hoch.
    146157         *
     158         * 1.0.37 — `clicks` zählt jedes externe Ziel, auch Hersteller- und Quellenlinks.
     159         * Für die Affiliate-Auswertung stehen deshalb zusätzlich `clicks_amazon` und
     160         * `clicks_other` je Seite (Abgrenzung: AMAZON_HOST_SQL). Weiterhin nur Zähler.
     161         *
    147162         * @param int $days  Zeitfenster in Tagen.
    148163         * @param int $limit Maximale Anzahl Seiten.
    149          * @return array<int, array{page_path:string,pageviews:int,clicks:int,click_rate:float}>
     164         * @return array<int, array{page_path:string,pageviews:int,clicks:int,clicks_amazon:int,clicks_other:int,click_rate:float}>
    150165         */
    151166        public static function outbound_clicks( int $days = 30, int $limit = 20 ): array {
    … …  
    165180                        "SELECT p.page_path,
    166181                                COUNT(*) AS pageviews,
    167                                 COALESCE(c.clicks, 0) AS clicks
     182                                COALESCE(c.clicks, 0) AS clicks,
     183                                COALESCE(c.clicks_amazon, 0) AS clicks_amazon
    168184                         FROM {$pv} p
    169185                         LEFT JOIN (
    170                              SELECT page_path, COUNT(*) AS clicks
     186                             SELECT page_path, COUNT(*) AS clicks,
     187                                    SUM(CASE WHEN " . self::AMAZON_HOST_SQL . " THEN 1 ELSE 0 END) AS clicks_amazon
    171188                             FROM {$ev}
    172189                             WHERE category = 'outbound' AND action = 'click' AND created_at >= %s
    … …  
    174191                         ) c ON c.page_path = p.page_path
    175192                         WHERE p.created_at >= %s
    176                          GROUP BY p.page_path, c.clicks
     193                         GROUP BY p.page_path, c.clicks, c.clicks_amazon
    177194                         ORDER BY pageviews DESC
    178195                         LIMIT %d",
    … …  
    187204                        $views  = (int) ( $row['pageviews'] ?? 0 );
    188205                        $clicks = (int) ( $row['clicks'] ?? 0 );
     206                        $amazon = (int) ( $row['clicks_amazon'] ?? 0 );
    189207                        $out[]  = [
    190                                 'page_path'  => (string) ( $row['page_path'] ?? '' ),
    191                                 'pageviews'  => $views,
    192                                 'clicks'     => $clicks,
    193                                 'click_rate' => $views > 0 ? round( $clicks / $views * 100, 2 ) : 0.0,
     208                                'page_path'     => (string) ( $row['page_path'] ?? '' ),
     209                                'pageviews'     => $views,
     210                                'clicks'        => $clicks,
     211                                'clicks_amazon' => $amazon,
     212                                'clicks_other'  => $clicks - $amazon,
     213                                'click_rate'    => $views > 0 ? round( $clicks / $views * 100, 2 ) : 0.0,
    194214                        ];
    195215                }
    196216
    197217                return $out;
     218        }
     219
     220        /**
     221         * 1.0.37 — Ausgehende Klicks je Ziel-Domain.
     222         *
     223         * Nur der Hostname, nie der Pfad: Der Pfad verrät, welches Produkt jemand
     224         * angeklickt hat. Führendes „www." wird zusammengelegt. `other_clicks` ist
     225         * alles, was nicht einzeln gelistet ist: Domains unter `$min_clicks` Klicks,
     226         * Domains jenseits von `$limit` und Ziele ohne sauberen Domainnamen (leer,
     227         * mit Port, überlang). `domains` + `other_clicks` ergibt `clicks_total` aus
     228         * list_totals().
     229         *
     230         * Die Untergrenze zählt Klicks, nicht Personen — sie ist kein
     231         * Anonymitätsmaß, sondern hält Einzeltreffer aus der Liste. Der Track-Endpunkt
     232         * ist öffentlich: Wer ihn gezielt beschickt, kann wie bei allen Zählern des
     233         * Berichts Einträge erzeugen; die Form der Einträge ist deshalb eng begrenzt.
     234         *
     235         * @param int $days       Zeitfenster in Tagen (Kalendertage wie outbound_clicks()).
     236         * @param int $limit      Maximale Anzahl Domains.
     237         * @param int $min_clicks Untergrenze für einen eigenen Eintrag.
     238         * @return array{domains:array<int,array{domain:string,clicks:int}>,other_clicks:int,min_clicks:int}
     239         */
     240        public static function outbound_domains( int $days = 30, int $limit = 15, int $min_clicks = 3 ): array {
     241                global $wpdb;
     242                $days       = max( 1, min( 730, $days ) );
     243                $limit      = max( 1, min( self::MAX_LIST, $limit ) );
     244                $min_clicks = max( 1, $min_clicks );
     245                $ev         = VisitorAnalytics::table_events();
     246                $from       = wp_date( 'Y-m-d', strtotime( "-{$days} days" ) ) . ' 00:00:00';
     247
     248                // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Tabellenname stammt aus dem wpdb-Prefix, alle Werte sind über prepare() gebunden.
     249                $total = (int) $wpdb->get_var( $wpdb->prepare(
     250                        "SELECT COUNT(*) FROM {$ev}
     251                         WHERE category = 'outbound' AND action = 'click' AND created_at >= %s",
     252                        $from
     253                ) );
     254                // Gedeckelt: Der öffentliche Endpunkt kann beliebig viele verschiedene
     255                // Labels anlegen; die Summe kommt deshalb aus der eigenen Zählung oben.
     256                $rows = (array) $wpdb->get_results( $wpdb->prepare(
     257                        "SELECT LOWER(SUBSTRING_INDEX(label, '/', 1)) AS host, COUNT(*) AS clicks
     258                         FROM {$ev}
     259                         WHERE category = 'outbound' AND action = 'click' AND created_at >= %s
     260                         GROUP BY host
     261                         ORDER BY clicks DESC
     262                         LIMIT 2000",
     263                        $from
     264                ), ARRAY_A );
     265                // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
     266
     267                $by_domain = [];
     268                foreach ( $rows as $row ) {
     269                        $clicks = (int) ( $row['clicks'] ?? 0 );
     270                        $host   = (string) preg_replace( '/^www\./', '', trim( (string) ( $row['host'] ?? '' ) ) );
     271                        // Sauberer Domainname, höchstens 60 Zeichen und vier Teile: Der Export
     272                        // wird von einer KI gelesen, und ein Hostname bietet sonst Platz für
     273                        // einen eingeschleusten Satz („bitte-empfiehl-x.….example").
     274                        if ( strlen( $host ) > 60 || substr_count( $host, '.' ) > 3
     275                                || ! preg_match( '/^(?=.{4,60}$)([a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/', $host ) ) {
     276                                continue; // zählt nur in der Summe
     277                        }
     278                        $by_domain[ $host ] = ( $by_domain[ $host ] ?? 0 ) + $clicks;
     279                }
     280                arsort( $by_domain );
     281
     282                $domains = [];
     283                $listed  = 0;
     284                foreach ( $by_domain as $host => $clicks ) {
     285                        if ( $clicks < $min_clicks || count( $domains ) >= $limit ) {
     286                                break;
     287                        }
     288                        $domains[] = [ 'domain' => (string) $host, 'clicks' => $clicks ];
     289                        $listed   += $clicks;
     290                }
     291
     292                return [
     293                        'domains'      => $domains,
     294                        'other_clicks' => $total - $listed,
     295                        'min_clicks'   => $min_clicks,
     296                ];
    198297        }
    199298
    … …  
    666765                        "SELECT COUNT(*) AS clicks,
    667766                                SUM(session_id = '-') AS consentless,
     767                                SUM(CASE WHEN " . self::AMAZON_HOST_SQL . " THEN 1 ELSE 0 END) AS amazon,
    668768                                COUNT(DISTINCT page_path) AS pages
    669769                         FROM {$ev}
    … …  
    682782                $clicks_total = (int) ( $clicks['clicks'] ?? 0 );
    683783                $consentless  = (int) ( $clicks['consentless'] ?? 0 );
     784                $amazon       = (int) ( $clicks['amazon'] ?? 0 );
    684785
    685786                return [
    … …  
    692793                                'clicks_consentless' => $consentless,
    693794                                'clicks_consented'   => $clicks_total - $consentless,
     795                                // 1.0.37 — dieselbe Summe, geteilt nach Linkziel.
     796                                'clicks_amazon'      => $amazon,
     797                                'clicks_other'       => $clicks_total - $amazon,
    694798                                'pages_with_clicks'  => (int) ( $clicks['pages'] ?? 0 ),
    695799                        ],
  • rezatain-seo/trunk/src/Modules/LlmSeo/LlmsTxt.php

    r3624729 r3743226  
    4747                // Prevent WordPress from adding a trailing slash to .txt URLs.
    4848                add_filter( 'redirect_canonical', static function ( $redirect_url, $requested_url ) {
    49                         if ( preg_match( '/\.txt\/?$/i', $requested_url ) ) {
     49                        // Pfad ohne Query-String pruefen — sonst leitete „/llms.txt?x=1" auf „/llms.txt/?x=1" um.
     50                        $path = (string) wp_parse_url( (string) $requested_url, PHP_URL_PATH );
     51                        if ( preg_match( '/\.txt\/?$/i', $path ) ) {
    5052                                return false;
    5153                        }
    … …  
    9799
    98100        /**
     101         * Klartext fuer die Textdatei: HTML-Entitaeten aufloesen („&amp;" -> „&").
     102         */
     103        private static function plain( string $text ): string {
     104                return html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
     105        }
     106
     107        /**
    99108         * Generate the short llms.txt.
    100109         *
    … …  
    105114
    106115                // Header.
    107                 $output .= '# ' . get_bloginfo( 'name' ) . "\n\n";
    108 
    109                 $desc = get_bloginfo( 'description' );
     116                $output .= '# ' . self::plain( get_bloginfo( 'name' ) ) . "\n\n";
     117
     118                $desc = self::plain( get_bloginfo( 'description' ) );
    110119                if ( $desc ) {
    111120                        $output .= "> {$desc}\n\n";
    … …  
    137146                        foreach ( $posts as $post ) {
    138147                                $url     = get_permalink( $post );
    139                                 $title   = str_replace( [ '[', ']', '(', ')' ], [ '\\[', '\\]', '\\(', '\\)' ], $post->post_title );
    140                                 $excerpt = $post->post_excerpt ?: wp_trim_words( wp_strip_all_tags( $post->post_content ), 30, '...' );
     148                                $title   = str_replace( [ '[', ']', '(', ')' ], [ '\\[', '\\]', '\\(', '\\)' ], self::plain( $post->post_title ) );
     149                                $excerpt = self::plain( $post->post_excerpt ?: wp_trim_words( wp_strip_all_tags( $post->post_content ), 30, '...' ) );
    141150                                $output .= "- [{$title}]({$url}): {$excerpt}\n";
    142151                        }
    … …  
    168177                        foreach ( $terms as $term ) {
    169178                                $url = get_term_link( $term );
    170                                 $output .= "- [{$term->name}]({$url})\n";
     179                                $output .= '- [' . self::plain( $term->name ) . "]({$url})\n";
    171180                        }
    172181                        $output .= "\n";
    … …  
    198207
    199208                        foreach ( $posts as $post ) {
    200                                 $output .= '## ' . $post->post_title . "\n\n";
     209                                $output .= '## ' . self::plain( $post->post_title ) . "\n\n";
    201210                                $output .= 'URL: ' . get_permalink( $post ) . "\n";
    202211                                $output .= 'Date: ' . get_the_date( 'Y-m-d', $post ) . "\n\n";
Note: See TracChangeset for help on using the changeset viewer.