release: v1.2.0
This commit is contained in:
parent
af7302e9b4
commit
d98c328869
15 changed files with 1776 additions and 25 deletions
51
README.de.md
51
README.de.md
|
|
@ -3,8 +3,8 @@
|
|||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
🇬🇧 [English version → README.md](README.md)
|
||||
|
||||
|
|
@ -65,6 +65,7 @@ brezngeo/
|
|||
│ ├── editor-meta.js # Meta Editor Box: Live-Zähler, KI-Regen-Button
|
||||
│ ├── geo-editor.js # GEO Block Editor: Generieren / Löschen Button
|
||||
│ ├── geo-frontend.css # Minimales Stylesheet für .brezngeo-geo auf dem Frontend
|
||||
│ ├── keyword-analysis.js # Keyword-Analyse: AJAX-Checks, Ergebnis-Rendering, KI-Handler
|
||||
│ ├── link-suggest.js # Interne Link-Vorschläge: Trigger, UI, Apply (Gutenberg + Classic)
|
||||
│ └── seo-widget.js # SEO Analyse Widget: Live-Auswertung im Editor
|
||||
├── includes/
|
||||
|
|
@ -74,6 +75,8 @@ brezngeo/
|
|||
│ │ ├── BulkPage.php # Bulk Generator Admin-Seite
|
||||
│ │ ├── GeoEditorBox.php # GEO Block Meta-Box im Post-Editor
|
||||
│ │ ├── GeoPage.php # GEO Block Einstellungsseite
|
||||
│ │ ├── KeywordMetaBox.php # Keyword-Analyse Meta-Box im Post-Editor
|
||||
│ │ ├── KeywordPage.php # Keyword-Analyse Einstellungsseite
|
||||
│ │ ├── LinkAnalysis.php # AJAX-Handler für Link-Analyse Dashboard
|
||||
│ │ ├── LinkSuggestPage.php # Einstellungsseite für interne Link-Vorschläge
|
||||
│ │ ├── MetaEditorBox.php # Meta Description Meta-Box im Post-Editor
|
||||
|
|
@ -90,6 +93,7 @@ brezngeo/
|
|||
│ │ ├── GeoBlock.php # GEO Quick Overview Block (Frontend-Ausgabe)
|
||||
│ │ ├── LlmsTxt.php # /llms.txt Endpunkt mit ETag/Cache
|
||||
│ │ ├── LinkSuggest.php # Interne Link-Vorschläge: Matching-Engine + AJAX-Handler + Meta-Box
|
||||
│ │ ├── KeywordAnalysis.php # Keyword-Analyse Engine: 10 SEO-Checks
|
||||
│ │ ├── MetaGenerator.php # Kernlogik: KI-Aufruf, Speichern, Bulk, AJAX
|
||||
│ │ ├── RobotsTxt.php # robots.txt Bot-Blocking via WP-Filter
|
||||
│ │ └── SchemaEnhancer.php # JSON-LD Schema.org Ausgabe in wp_head
|
||||
|
|
@ -97,6 +101,7 @@ brezngeo/
|
|||
│ │ ├── BulkQueue.php # Mutex-Lock für Bulk-Prozesse (Transient-basiert)
|
||||
│ │ ├── FallbackMeta.php # Meta-Extraktion aus Post-Content ohne KI
|
||||
│ │ ├── KeyVault.php # API-Key Verschleierung vor dem Schreiben in die DB
|
||||
│ │ ├── KeywordVariants.php # Locale-basierte Keyword-Varianten (EN/DE)
|
||||
│ │ └── TokenEstimator.php # Grobe Token-Schätzung für Kostenvorschau im Bulk
|
||||
│ └── Providers/
|
||||
│ ├── ProviderInterface.php # Interface: getId, getName, getModels, testConnection, generateText
|
||||
|
|
@ -201,6 +206,36 @@ Batch-Verarbeitung aller veröffentlichten Beiträge ohne Meta-Beschreibung. Lä
|
|||
|
||||
---
|
||||
|
||||
### Keyword-Analyse
|
||||
|
||||
Meta-Box im Post-Editor, die die Keyword-Nutzung über zehn On-Page-SEO-Checks analysiert:
|
||||
|
||||
| Check | Was geprüft wird |
|
||||
|---|---|
|
||||
| Titel | Keyword im Beitragstitel vorhanden |
|
||||
| Überschriften | Keyword in mindestens einer H2–H6 |
|
||||
| Dichte | Keyword-Dichte im Zielbereich (Standard 0,5 %–2,5 %) |
|
||||
| Bild-Alt | Mindestens ein Bild hat das Keyword im `alt`-Attribut |
|
||||
| Meta Description | Keyword in der Meta-Beschreibung |
|
||||
| Slug | Keyword in der URL |
|
||||
| Erster Absatz | Keyword im ersten Absatz |
|
||||
| Letzter Absatz | Keyword im letzten Absatz |
|
||||
| Bild-Titel/Caption | Keyword in mindestens einem Bild-`title` oder `<figcaption>` |
|
||||
| Excerpt | Keyword im Beitragsauszug |
|
||||
|
||||
**Primär- + Sekundär-Keywords:** Das Hauptkeyword wird mit strengeren Schwellenwerten geprüft, Sekundär-Keywords mit gelockerten Minimums.
|
||||
|
||||
**Update-Modi:** `live` (debounced beim Tippen), `manual` (Button-Klick) oder `save` (beim Speichern).
|
||||
|
||||
**Locale-basierte Varianten:** `KeywordVariants` generiert Compound-Formen (Leerzeichen ↔ Bindestrich), Trailing-s und sprachspezifische Suffixe (EN: -es, -ing, -ed; DE: -er, -en, -e).
|
||||
|
||||
**Optionale KI-Features** (bei konfiguriertem API-Key):
|
||||
- **Suggest** — KI-generierte Keyword-Vorschläge basierend auf dem Artikelinhalt
|
||||
- **Optimize** — Content-Optimierungstipps für das aktuelle Keyword
|
||||
- **Semantic** — verwandte semantische Keywords für breitere Themenabdeckung
|
||||
|
||||
---
|
||||
|
||||
### Crawler Log
|
||||
|
||||
Loggt Besuche bekannter KI-Bots in der Tabelle `{prefix}brezngeo_crawler_log` (bot_name, ip_hash SHA-256, url, visited_at). Einträge älter als 90 Tage werden automatisch bereinigt. Dashboard zeigt 30-Tage-Zusammenfassung.
|
||||
|
|
@ -219,6 +254,7 @@ Loggt Besuche bekannter KI-Bots in der Tabelle `{prefix}brezngeo_crawler_log` (b
|
|||
| `brezngeo_geo_settings` | GEO Block: Modus, Position, Labels, CSS, Prompt, Farbschema |
|
||||
| `brezngeo_robots_settings` | robots.txt: blockierte Bots |
|
||||
| `brezngeo_llms_settings` | llms.txt: Titel, Beschreibung, Featured-Links, Footer, Seitenanzahl |
|
||||
| `brezngeo_keyword_settings` | Keyword-Analyse: Update-Modus, Zieldichte, Min. Vorkommen, Post-Types, Debounce |
|
||||
| `brezngeo_usage_stats` | Akkumulierte Token-Nutzung: `tokens_in`, `tokens_out`, `count` |
|
||||
| `brezngeo_first_activated` | Unix-Timestamp der Erstaktivierung (für Welcome Notice) |
|
||||
|
||||
|
|
@ -232,6 +268,9 @@ Loggt Besuche bekannter KI-Bots in der Tabelle `{prefix}brezngeo_crawler_log` (b
|
|||
| `_bre_geo_summary` | GEO Block Summary |
|
||||
| `_bre_geo_bullets` | GEO Block Key Points (JSON-Array) |
|
||||
| `_bre_geo_faq` | GEO Block FAQ (JSON-Array) |
|
||||
| `_brezngeo_keyword_main` | Primäres Keyword |
|
||||
| `_brezngeo_keyword_secondary` | Sekundäre Keywords (kommagetrennt) |
|
||||
| `_brezngeo_keyword_results` | Gecachte Analyse-Ergebnisse (JSON) |
|
||||
|
||||
### Transients
|
||||
|
||||
|
|
@ -243,7 +282,7 @@ Loggt Besuche bekannter KI-Bots in der Tabelle `{prefix}brezngeo_crawler_log` (b
|
|||
| `brezngeo_meta_stats` | 5 Minuten | Dashboard Meta-Coverage-Abfrage |
|
||||
| `brezngeo_crawler_summary` | 5 Minuten | Dashboard Crawler-Zusammenfassung (letzte 30 Tage) |
|
||||
|
||||
> **Uninstall:** `uninstall.php` löscht `brezngeo_settings` und `_bre_meta_description` für alle Posts. Die übrigen Option-Keys und die `brezngeo_crawler_log`-Tabelle müssen manuell gelöscht werden.
|
||||
> **Uninstall:** `uninstall.php` löscht `brezngeo_settings`, `brezngeo_keyword_settings` und die Post-Meta-Keys `_brezngeo_meta_description`, `_brezngeo_keyword_main`, `_brezngeo_keyword_secondary`, `_brezngeo_keyword_results` für alle Posts. Die übrigen Option-Keys und die `brezngeo_crawler_log`-Tabelle müssen manuell gelöscht werden.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -343,6 +382,10 @@ Alle Endpunkte erfordern `manage_options` (kein `nopriv`).
|
|||
| `brezngeo_bulk_stats` | `MetaGenerator::ajaxBulkStats` | Fortschritt abrufen |
|
||||
| `brezngeo_bulk_release` | `MetaGenerator::ajaxBulkRelease` | Mutex-Lock manuell freigeben |
|
||||
| `brezngeo_bulk_status` | `MetaGenerator::ajaxBulkStatus` | Lock-Status prüfen |
|
||||
| `brezngeo_keyword_analyze` | `KeywordMetaBox::ajax_analyze` | Keyword-Analyse für einen Post ausführen |
|
||||
| `brezngeo_keyword_ai_suggest` | `KeywordMetaBox::ajax_ai_suggest` | KI-Keyword-Vorschläge |
|
||||
| `brezngeo_keyword_ai_optimize` | `KeywordMetaBox::ajax_ai_optimize` | KI-Content-Optimierungstipps |
|
||||
| `brezngeo_keyword_ai_semantic` | `KeywordMetaBox::ajax_ai_semantic` | KI-semantische Keyword-Analyse |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -378,7 +421,7 @@ Kein JavaScript-Build-Step. Alle Assets unter `assets/` sind direkte JS/CSS-Date
|
|||
| Caching | WordPress Transients |
|
||||
| Frontend | Vanilla JS + jQuery (WordPress-integriert), kein Build-Step |
|
||||
| I18n | `.pot`-File, Text-Domain `brezngeo` |
|
||||
| Tests | PHPUnit (102 Tests, 216 Assertions) |
|
||||
| Tests | PHPUnit (158 Tests, 301 Assertions) |
|
||||
| Coding Standard | WordPress PHPCS |
|
||||
| Lizenz | GPL-2.0-or-later |
|
||||
|
||||
|
|
|
|||
53
README.md
53
README.md
|
|
@ -3,8 +3,8 @@
|
|||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
🇩🇪 [Deutsche Version → README.de.md](README.de.md)
|
||||
|
||||
|
|
@ -65,6 +65,7 @@ brezngeo/
|
|||
│ ├── editor-meta.js # Meta editor box: live counter, AI regen button
|
||||
│ ├── geo-editor.js # GEO block editor: generate / clear button
|
||||
│ ├── geo-frontend.css # Minimal stylesheet for .brezngeo-geo on frontend
|
||||
│ ├── keyword-analysis.js # Keyword analysis: AJAX checks, result rendering, AI handlers
|
||||
│ ├── link-suggest.js # Internal link suggestions: trigger, UI, apply (Gutenberg + Classic)
|
||||
│ └── seo-widget.js # SEO analysis widget: live evaluation in editor
|
||||
├── includes/
|
||||
|
|
@ -74,6 +75,8 @@ brezngeo/
|
|||
│ │ ├── BulkPage.php # Bulk generator admin page
|
||||
│ │ ├── GeoEditorBox.php # GEO block meta box in post editor
|
||||
│ │ ├── GeoPage.php # GEO block settings page
|
||||
│ │ ├── KeywordMetaBox.php # Keyword analysis meta box in post editor
|
||||
│ │ ├── KeywordPage.php # Keyword analysis settings page
|
||||
│ │ ├── LinkAnalysis.php # AJAX handler for link analysis dashboard
|
||||
│ │ ├── LinkSuggestPage.php # Internal link suggestions settings page
|
||||
│ │ ├── MetaEditorBox.php # Meta description meta box in post editor
|
||||
|
|
@ -90,6 +93,7 @@ brezngeo/
|
|||
│ │ ├── GeoBlock.php # GEO Quick Overview block (frontend output)
|
||||
│ │ ├── LlmsTxt.php # /llms.txt endpoint with ETag/cache
|
||||
│ │ ├── LinkSuggest.php # Internal link suggestions: matching engine + AJAX handler + meta box
|
||||
│ │ ├── KeywordAnalysis.php # Keyword analysis engine: 10 SEO checks
|
||||
│ │ ├── MetaGenerator.php # Core logic: AI call, save, bulk, AJAX
|
||||
│ │ ├── RobotsTxt.php # robots.txt bot blocking via WP filter
|
||||
│ │ └── SchemaEnhancer.php # JSON-LD Schema.org output in wp_head
|
||||
|
|
@ -97,6 +101,7 @@ brezngeo/
|
|||
│ │ ├── BulkQueue.php # Mutex lock for bulk processes (transient-based)
|
||||
│ │ ├── FallbackMeta.php # Meta extraction from post content without AI
|
||||
│ │ ├── KeyVault.php # API key obfuscation before writing to DB
|
||||
│ │ ├── KeywordVariants.php # Locale-aware keyword variant generation (EN/DE)
|
||||
│ │ └── TokenEstimator.php # Rough token estimate for cost preview in bulk
|
||||
│ └── Providers/
|
||||
│ ├── ProviderInterface.php # Interface: getId, getName, getModels, testConnection, generateText
|
||||
|
|
@ -265,6 +270,36 @@ Entries older than 90 days are automatically cleaned up via weekly cron (`brezng
|
|||
|
||||
---
|
||||
|
||||
### Keyword Analysis
|
||||
|
||||
Meta box in the post editor that analyzes keyword usage across ten on-page SEO checks:
|
||||
|
||||
| Check | What it evaluates |
|
||||
|---|---|
|
||||
| Title | Keyword present in the post title |
|
||||
| Headings | Keyword appears in at least one H2–H6 |
|
||||
| Density | Keyword density within target range (default 0.5 %–2.5 %) |
|
||||
| Image Alt | At least one image has the keyword in its `alt` attribute |
|
||||
| Meta Description | Keyword present in the meta description |
|
||||
| Slug | Keyword present in the URL slug |
|
||||
| First Paragraph | Keyword appears in the opening paragraph |
|
||||
| Last Paragraph | Keyword appears in the closing paragraph |
|
||||
| Image Title/Caption | Keyword in at least one image `title` or `<figcaption>` |
|
||||
| Excerpt | Keyword present in the post excerpt |
|
||||
|
||||
**Primary + secondary keywords:** The primary keyword is evaluated against all ten checks with stricter thresholds. Secondary keywords support multiple entries and use relaxed minimums.
|
||||
|
||||
**Update modes:** `live` (debounced while typing), `manual` (button click), or `save` (on post save).
|
||||
|
||||
**Locale-aware variants:** `KeywordVariants` generates compound forms (space ↔ hyphen), trailing-s, and language-specific suffixes (EN: -es, -ing, -ed; DE: -er, -en, -e) to match keyword variations automatically.
|
||||
|
||||
**Optional AI features** (when an API key is configured):
|
||||
- **Suggest** — AI-generated keyword suggestions based on post content
|
||||
- **Optimize** — content optimization tips for the current keyword
|
||||
- **Semantic** — related semantic keywords for broader topic coverage
|
||||
|
||||
---
|
||||
|
||||
### Meta Editor Box
|
||||
|
||||
Meta box in the post editor (Classic and Block Editor):
|
||||
|
|
@ -310,6 +345,7 @@ Results are cached for 1 hour in the transient cache (`brezngeo_link_analysis`).
|
|||
| `brezngeo_geo_settings` | GEO block: mode, position, labels, CSS, prompt, color scheme |
|
||||
| `brezngeo_robots_settings` | robots.txt: blocked bots |
|
||||
| `brezngeo_llms_settings` | llms.txt: title, description, featured links, footer, page count |
|
||||
| `brezngeo_keyword_settings` | Keyword analysis: update mode, target density, min occurrences, post types, debounce |
|
||||
| `brezngeo_usage_stats` | Accumulated token usage: `tokens_in`, `tokens_out`, `count` |
|
||||
| `brezngeo_first_activated` | Unix timestamp of first activation (used by welcome notice) |
|
||||
|
||||
|
|
@ -323,6 +359,9 @@ Results are cached for 1 hour in the transient cache (`brezngeo_link_analysis`).
|
|||
| `_bre_geo_summary` | GEO block summary |
|
||||
| `_bre_geo_bullets` | GEO block key points (JSON array) |
|
||||
| `_bre_geo_faq` | GEO block FAQ (JSON array) |
|
||||
| `_brezngeo_keyword_main` | Primary keyword |
|
||||
| `_brezngeo_keyword_secondary` | Secondary keywords (comma-separated) |
|
||||
| `_brezngeo_keyword_results` | Cached analysis results (JSON) |
|
||||
|
||||
### Custom Database Table
|
||||
|
||||
|
|
@ -343,8 +382,8 @@ Results are cached for 1 hour in the transient cache (`brezngeo_link_analysis`).
|
|||
### Uninstall cleanup
|
||||
|
||||
`uninstall.php` removes on plugin deletion:
|
||||
- Option `brezngeo_settings`
|
||||
- Post meta `_bre_meta_description` for all posts
|
||||
- Options `brezngeo_settings`, `brezngeo_keyword_settings`
|
||||
- Post meta `_brezngeo_meta_description`, `_brezngeo_keyword_main`, `_brezngeo_keyword_secondary`, `_brezngeo_keyword_results` for all posts
|
||||
|
||||
> Note: The remaining option keys and the `brezngeo_crawler_log` table are not automatically removed. For full cleanup, delete these manually.
|
||||
|
||||
|
|
@ -497,6 +536,10 @@ All endpoints are exclusively accessible to logged-in users with `manage_options
|
|||
| `brezngeo_bulk_stats` | `MetaGenerator::ajaxBulkStats` | Retrieve progress and stats of running bulk |
|
||||
| `brezngeo_bulk_release` | `MetaGenerator::ajaxBulkRelease` | Manually release bulk mutex lock |
|
||||
| `brezngeo_bulk_status` | `MetaGenerator::ajaxBulkStatus` | Check bulk lock status |
|
||||
| `brezngeo_keyword_analyze` | `KeywordMetaBox::ajax_analyze` | Run keyword analysis for a post |
|
||||
| `brezngeo_keyword_ai_suggest` | `KeywordMetaBox::ajax_ai_suggest` | AI keyword suggestions |
|
||||
| `brezngeo_keyword_ai_optimize` | `KeywordMetaBox::ajax_ai_optimize` | AI content optimization tips |
|
||||
| `brezngeo_keyword_ai_semantic` | `KeywordMetaBox::ajax_ai_semantic` | AI semantic keyword analysis |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -533,7 +576,7 @@ The plugin has no JavaScript build step. All assets under `assets/` are direct J
|
|||
| Caching | WordPress transients (llms.txt, link analysis, bulk lock) |
|
||||
| Frontend | Vanilla JS + jQuery (WordPress-bundled), no build step |
|
||||
| i18n | `.pot` file, text domain `brezngeo` |
|
||||
| Tests | PHPUnit (102 tests, 216 assertions) |
|
||||
| Tests | PHPUnit (158 tests, 301 assertions) |
|
||||
| Coding standard | WordPress PHPCS |
|
||||
| License | GPL-2.0-or-later |
|
||||
|
||||
|
|
|
|||
327
brezngeo/assets/keyword-analysis.js
Normal file
327
brezngeo/assets/keyword-analysis.js
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
/* global jQuery, wp, brezngeoKeyword, tinyMCE, ajaxurl */
|
||||
( function ( $ ) {
|
||||
'use strict';
|
||||
|
||||
if ( typeof brezngeoKeyword === 'undefined' ) { return; }
|
||||
|
||||
var cfg = brezngeoKeyword;
|
||||
var i18n = cfg.i18n;
|
||||
var isRunning = false;
|
||||
var debounceTimer = null;
|
||||
|
||||
var $box = $( '#brezngeo-keyword-box' );
|
||||
var $main = $( '#brezngeo-keyword-main' );
|
||||
var $secList = $( '#brezngeo-keyword-secondary-list' );
|
||||
var $addSec = $( '#brezngeo-keyword-add-secondary' );
|
||||
var $analyze = $( '#brezngeo-keyword-analyze' );
|
||||
var $status = $( '#brezngeo-keyword-status' );
|
||||
var $results = $( '#brezngeo-keyword-results' );
|
||||
var $aiActions = $( '#brezngeo-keyword-ai-actions' );
|
||||
var $aiResults = $( '#brezngeo-keyword-ai-results' );
|
||||
|
||||
if ( ! $box.length ) { return; }
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────── */
|
||||
|
||||
function getContent() {
|
||||
if ( window.wp && wp.data && wp.data.select( 'core/editor' ) ) {
|
||||
try {
|
||||
return wp.data.select( 'core/editor' ).getEditedPostContent();
|
||||
} catch ( e ) { /* fall through */ }
|
||||
}
|
||||
if ( typeof tinyMCE !== 'undefined' && tinyMCE.activeEditor && ! tinyMCE.activeEditor.isHidden() ) {
|
||||
return tinyMCE.activeEditor.getContent();
|
||||
}
|
||||
return $( '#content' ).val() || '';
|
||||
}
|
||||
|
||||
function setStatus( msg, isError ) {
|
||||
$status.text( msg ).css( 'color', isError ? '#dc3232' : '#46b450' );
|
||||
if ( msg ) {
|
||||
setTimeout( function () { $status.text( '' ); }, 5000 );
|
||||
}
|
||||
}
|
||||
|
||||
function getSecondaryKeywords() {
|
||||
var keywords = [];
|
||||
$secList.find( 'input[name="brezngeo_keyword_secondary[]"]' ).each( function () {
|
||||
var val = $.trim( $( this ).val() );
|
||||
if ( val ) { keywords.push( val ); }
|
||||
} );
|
||||
return keywords;
|
||||
}
|
||||
|
||||
/* ── Secondary keyword repeater ──────────────────────── */
|
||||
|
||||
$addSec.on( 'click', function () {
|
||||
var $row = $( '<div class="brezngeo-keyword-secondary-row" style="display:flex;gap:6px;margin-bottom:4px;">' +
|
||||
'<input type="text" name="brezngeo_keyword_secondary[]" style="flex:1;box-sizing:border-box;">' +
|
||||
'<button type="button" class="button brezngeo-keyword-remove-secondary">×</button>' +
|
||||
'</div>' );
|
||||
$secList.append( $row );
|
||||
$row.find( 'input' ).focus();
|
||||
} );
|
||||
|
||||
$secList.on( 'click', '.brezngeo-keyword-remove-secondary', function () {
|
||||
$( this ).closest( '.brezngeo-keyword-secondary-row' ).remove();
|
||||
} );
|
||||
|
||||
/* ── Status icons ────────────────────────────────────── */
|
||||
|
||||
function statusIcon( status ) {
|
||||
switch ( status ) {
|
||||
case 'pass': return '<span style="color:#46b450;">✅</span>';
|
||||
case 'warn': return '<span style="color:#ffb900;">⚠️</span>';
|
||||
case 'fail': return '<span style="color:#dc3232;">❌</span>';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Render results ──────────────────────────────────── */
|
||||
|
||||
function renderResults( data ) {
|
||||
var html = '';
|
||||
|
||||
// Main keyword results.
|
||||
if ( data.main && data.main.checks ) {
|
||||
html += '<h4 style="margin:0 0 8px;">' + escHtml( data.main.keyword ) + '</h4>';
|
||||
html += '<div style="margin-bottom:16px;">';
|
||||
$.each( data.main.checks, function ( i, check ) {
|
||||
html += '<div style="padding:3px 0;">' +
|
||||
statusIcon( check.status ) + ' ' +
|
||||
'<strong>' + escHtml( check.label ) + '</strong> — ' +
|
||||
escHtml( check.message ) +
|
||||
'</div>';
|
||||
} );
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
// Secondary keyword results.
|
||||
if ( data.secondary ) {
|
||||
$.each( data.secondary, function ( kw, checks ) {
|
||||
html += '<h4 style="margin:12px 0 6px;font-size:13px;color:#555;">' + escHtml( kw ) + '</h4>';
|
||||
$.each( checks, function ( i, check ) {
|
||||
html += '<div style="padding:2px 0;font-size:13px;">' +
|
||||
statusIcon( check.status ) + ' ' +
|
||||
'<strong>' + escHtml( check.label ) + '</strong> — ' +
|
||||
escHtml( check.message ) +
|
||||
'</div>';
|
||||
} );
|
||||
} );
|
||||
}
|
||||
|
||||
$results.html( html );
|
||||
|
||||
// Show AI action buttons after results are displayed.
|
||||
if ( $aiActions.length ) {
|
||||
$aiActions.show();
|
||||
}
|
||||
}
|
||||
|
||||
function escHtml( str ) {
|
||||
var div = document.createElement( 'div' );
|
||||
div.appendChild( document.createTextNode( str || '' ) );
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/* ── Core: run analysis ──────────────────────────────── */
|
||||
|
||||
function runAnalysis() {
|
||||
var keyword = $.trim( $main.val() );
|
||||
if ( ! keyword ) {
|
||||
setStatus( i18n.noKeyword, true );
|
||||
return;
|
||||
}
|
||||
if ( isRunning ) { return; }
|
||||
|
||||
var content = getContent();
|
||||
isRunning = true;
|
||||
$analyze.prop( 'disabled', true );
|
||||
setStatus( i18n.analyzing, false );
|
||||
|
||||
$.post( cfg.ajaxUrl, {
|
||||
action: 'brezngeo_keyword_analyze',
|
||||
nonce: cfg.nonce,
|
||||
post_id: cfg.postId,
|
||||
post_content: content,
|
||||
main_keyword: keyword,
|
||||
secondary_keywords: JSON.stringify( getSecondaryKeywords() ),
|
||||
} )
|
||||
.done( function ( res ) {
|
||||
if ( res && res.success ) {
|
||||
renderResults( res.data );
|
||||
setStatus( '', false );
|
||||
} else {
|
||||
setStatus( res.data || i18n.error, true );
|
||||
}
|
||||
} )
|
||||
.fail( function () {
|
||||
setStatus( i18n.error, true );
|
||||
} )
|
||||
.always( function () {
|
||||
isRunning = false;
|
||||
$analyze.prop( 'disabled', false );
|
||||
} );
|
||||
}
|
||||
|
||||
/* ── Triggers ────────────────────────────────────────── */
|
||||
|
||||
// Manual mode: button click.
|
||||
$analyze.on( 'click', runAnalysis );
|
||||
|
||||
// Live mode: debounced on editor changes.
|
||||
if ( cfg.updateMode === 'live' ) {
|
||||
function debouncedAnalysis() {
|
||||
clearTimeout( debounceTimer );
|
||||
debounceTimer = setTimeout( function () {
|
||||
if ( $.trim( $main.val() ) ) {
|
||||
runAnalysis();
|
||||
}
|
||||
}, cfg.debounceMs );
|
||||
}
|
||||
|
||||
// Gutenberg.
|
||||
if ( window.wp && wp.data && wp.data.subscribe ) {
|
||||
var lastContent = '';
|
||||
wp.data.subscribe( function () {
|
||||
var content = getContent();
|
||||
if ( content !== lastContent ) {
|
||||
lastContent = content;
|
||||
debouncedAnalysis();
|
||||
}
|
||||
} );
|
||||
}
|
||||
|
||||
// Classic editor.
|
||||
if ( typeof tinyMCE !== 'undefined' ) {
|
||||
$( document ).on( 'tinymce-editor-init', function ( evt, editor ) {
|
||||
editor.on( 'input', debouncedAnalysis );
|
||||
} );
|
||||
}
|
||||
$( '#content' ).on( 'input', debouncedAnalysis );
|
||||
$main.on( 'input', debouncedAnalysis );
|
||||
}
|
||||
|
||||
// On-save mode.
|
||||
if ( cfg.updateMode === 'save' ) {
|
||||
if ( window.wp && wp.data && wp.data.subscribe ) {
|
||||
var wasSaving = false;
|
||||
wp.data.subscribe( function () {
|
||||
var isSaving = wp.data.select( 'core/editor' ).isSavingPost();
|
||||
if ( wasSaving && ! isSaving ) {
|
||||
runAnalysis();
|
||||
}
|
||||
wasSaving = isSaving;
|
||||
} );
|
||||
}
|
||||
$( '#publish, #save-post' ).on( 'click', function () {
|
||||
setTimeout( runAnalysis, 500 );
|
||||
} );
|
||||
}
|
||||
|
||||
/* ── AI: Suggest keywords ────────────────────────────── */
|
||||
|
||||
$( '#brezngeo-keyword-ai-suggest' ).on( 'click', function () {
|
||||
var $btn = $( this );
|
||||
var content = getContent();
|
||||
$btn.prop( 'disabled', true );
|
||||
setStatus( i18n.suggesting, false );
|
||||
|
||||
$.post( cfg.ajaxUrl, {
|
||||
action: 'brezngeo_keyword_ai_suggest',
|
||||
nonce: cfg.nonce,
|
||||
post_id: cfg.postId,
|
||||
post_content: content,
|
||||
} )
|
||||
.done( function ( res ) {
|
||||
if ( res && res.success && res.data ) {
|
||||
if ( res.data.main ) {
|
||||
$main.val( res.data.main );
|
||||
}
|
||||
if ( res.data.secondary && res.data.secondary.length ) {
|
||||
$secList.empty();
|
||||
$.each( res.data.secondary, function ( i, kw ) {
|
||||
var $row = $( '<div class="brezngeo-keyword-secondary-row" style="display:flex;gap:6px;margin-bottom:4px;">' +
|
||||
'<input type="text" name="brezngeo_keyword_secondary[]" value="' + escHtml( kw ) + '" style="flex:1;box-sizing:border-box;">' +
|
||||
'<button type="button" class="button brezngeo-keyword-remove-secondary">×</button>' +
|
||||
'</div>' );
|
||||
$secList.append( $row );
|
||||
} );
|
||||
}
|
||||
setStatus( '', false );
|
||||
} else {
|
||||
setStatus( res.data || i18n.error, true );
|
||||
}
|
||||
} )
|
||||
.fail( function () { setStatus( i18n.error, true ); } )
|
||||
.always( function () { $btn.prop( 'disabled', false ); } );
|
||||
} );
|
||||
|
||||
/* ── AI: Optimization Tips ───────────────────────────── */
|
||||
|
||||
$( '#brezngeo-keyword-ai-optimize' ).on( 'click', function () {
|
||||
var $btn = $( this );
|
||||
var content = getContent();
|
||||
var keyword = $.trim( $main.val() );
|
||||
if ( ! keyword ) { setStatus( i18n.noKeyword, true ); return; }
|
||||
|
||||
$btn.prop( 'disabled', true );
|
||||
setStatus( i18n.optimizing, false );
|
||||
|
||||
$.post( cfg.ajaxUrl, {
|
||||
action: 'brezngeo_keyword_ai_optimize',
|
||||
nonce: cfg.nonce,
|
||||
post_id: cfg.postId,
|
||||
post_content: content,
|
||||
main_keyword: keyword,
|
||||
} )
|
||||
.done( function ( res ) {
|
||||
if ( res && res.success && res.data ) {
|
||||
var html = '<h4>' + escHtml( 'Optimization Tips' ) + '</h4><ul style="margin:6px 0 0 16px;">';
|
||||
$.each( res.data, function ( i, tip ) {
|
||||
html += '<li style="margin-bottom:4px;">' + escHtml( tip ) + '</li>';
|
||||
} );
|
||||
html += '</ul>';
|
||||
$aiResults.html( html );
|
||||
setStatus( '', false );
|
||||
} else {
|
||||
setStatus( res.data || i18n.error, true );
|
||||
}
|
||||
} )
|
||||
.fail( function () { setStatus( i18n.error, true ); } )
|
||||
.always( function () { $btn.prop( 'disabled', false ); } );
|
||||
} );
|
||||
|
||||
/* ── AI: Semantic Analysis ───────────────────────────── */
|
||||
|
||||
$( '#brezngeo-keyword-ai-semantic' ).on( 'click', function () {
|
||||
var $btn = $( this );
|
||||
var content = getContent();
|
||||
var keyword = $.trim( $main.val() );
|
||||
if ( ! keyword ) { setStatus( i18n.noKeyword, true ); return; }
|
||||
|
||||
$btn.prop( 'disabled', true );
|
||||
setStatus( i18n.semantic, false );
|
||||
|
||||
$.post( cfg.ajaxUrl, {
|
||||
action: 'brezngeo_keyword_ai_semantic',
|
||||
nonce: cfg.nonce,
|
||||
post_id: cfg.postId,
|
||||
post_content: content,
|
||||
main_keyword: keyword,
|
||||
} )
|
||||
.done( function ( res ) {
|
||||
if ( res && res.success && res.data ) {
|
||||
var html = '<h4>' + escHtml( 'Semantic Analysis' ) + '</h4>';
|
||||
html += '<div style="white-space:pre-wrap;font-size:13px;line-height:1.5;">' + escHtml( res.data ) + '</div>';
|
||||
$aiResults.html( html );
|
||||
setStatus( '', false );
|
||||
} else {
|
||||
setStatus( res.data || i18n.error, true );
|
||||
}
|
||||
} )
|
||||
.fail( function () { setStatus( i18n.error, true ); } )
|
||||
.always( function () { $btn.prop( 'disabled', false ); } );
|
||||
} );
|
||||
|
||||
} )( jQuery );
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
* Plugin Name: BreznGEO
|
||||
* Plugin URI: https://brezngeo.com/
|
||||
* Description: AI-powered meta descriptions, GEO structured data, and llms.txt for WordPress.
|
||||
* Version: 1.1.0
|
||||
* Version: 1.2.0
|
||||
* Requires at least: 6.0
|
||||
* Requires PHP: 8.0
|
||||
* Author: NoSchmarrn.dev
|
||||
|
|
@ -18,7 +18,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||
exit;
|
||||
}
|
||||
|
||||
define( 'BREZNGEO_VERSION', '1.1.0' );
|
||||
define( 'BREZNGEO_VERSION', '1.2.0' );
|
||||
define( 'BREZNGEO_FILE', __FILE__ );
|
||||
define( 'BREZNGEO_DIR', plugin_dir_path( __FILE__ ) );
|
||||
define( 'BREZNGEO_URL', plugin_dir_url( __FILE__ ) );
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ class AdminMenu {
|
|||
'meta' => false,
|
||||
'geo' => false,
|
||||
'links' => false,
|
||||
'keywords' => false,
|
||||
);
|
||||
$saved = get_option( self::OPTION_KEY_AI_FEATURES, array() );
|
||||
$saved = is_array( $saved ) ? $saved : array();
|
||||
|
|
@ -43,6 +44,7 @@ class AdminMenu {
|
|||
'meta' => ! empty( $input['meta'] ),
|
||||
'geo' => ! empty( $input['geo'] ),
|
||||
'links' => ! empty( $input['links'] ),
|
||||
'keywords' => ! empty( $input['keywords'] ),
|
||||
)
|
||||
);
|
||||
|
||||
|
|
@ -166,6 +168,15 @@ class AdminMenu {
|
|||
array( new LinkSuggestPage(), 'render' )
|
||||
);
|
||||
|
||||
add_submenu_page(
|
||||
'brezngeo',
|
||||
__( 'Keyword Analysis', 'brezngeo' ),
|
||||
__( 'Keyword Analysis', 'brezngeo' ),
|
||||
'manage_options',
|
||||
'brezngeo-keyword',
|
||||
array( new KeywordPage(), 'render' )
|
||||
);
|
||||
|
||||
add_submenu_page(
|
||||
'brezngeo',
|
||||
__( 'GEO Quick Overview', 'brezngeo' ),
|
||||
|
|
|
|||
433
brezngeo/includes/Admin/KeywordMetaBox.php
Normal file
433
brezngeo/includes/Admin/KeywordMetaBox.php
Normal file
|
|
@ -0,0 +1,433 @@
|
|||
<?php
|
||||
/**
|
||||
* Keyword analysis meta box for the post editor.
|
||||
*
|
||||
* @package BreznGEO
|
||||
*/
|
||||
|
||||
namespace BreznGEO\Admin;
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
use BreznGEO\Features\KeywordAnalysis;
|
||||
use BreznGEO\Helpers\KeywordVariants;
|
||||
use BreznGEO\Helpers\TokenEstimator;
|
||||
use BreznGEO\ProviderRegistry;
|
||||
|
||||
/**
|
||||
* Registers and renders the keyword analysis meta box with AJAX handlers.
|
||||
*/
|
||||
class KeywordMetaBox {
|
||||
|
||||
public const META_MAIN = '_brezngeo_keyword_main';
|
||||
public const META_SECONDARY = '_brezngeo_keyword_secondary';
|
||||
public const META_RESULTS = '_brezngeo_keyword_results';
|
||||
|
||||
/**
|
||||
* Register hooks for the keyword meta box.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register(): void {
|
||||
add_action( 'add_meta_boxes', array( $this, 'add_boxes' ) );
|
||||
add_action( 'save_post', array( $this, 'save' ), 10, 2 );
|
||||
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) );
|
||||
add_action( 'wp_ajax_brezngeo_keyword_analyze', array( $this, 'ajax_analyze' ) );
|
||||
add_action( 'wp_ajax_brezngeo_keyword_ai_suggest', array( $this, 'ajax_ai_suggest' ) );
|
||||
add_action( 'wp_ajax_brezngeo_keyword_ai_optimize', array( $this, 'ajax_ai_optimize' ) );
|
||||
add_action( 'wp_ajax_brezngeo_keyword_ai_semantic', array( $this, 'ajax_ai_semantic' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the keyword analysis meta box to configured post types.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function add_boxes(): void {
|
||||
$settings = self::get_settings();
|
||||
$post_types = $settings['post_types'] ?? array( 'post', 'page' );
|
||||
|
||||
foreach ( $post_types as $pt ) {
|
||||
add_meta_box(
|
||||
'brezngeo_keyword_box',
|
||||
__( 'Keyword Analysis (BreznGEO)', 'brezngeo' ),
|
||||
array( $this, 'render' ),
|
||||
$pt,
|
||||
'normal',
|
||||
'default'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the keyword analysis meta box.
|
||||
*
|
||||
* @param \WP_Post $post The current post object.
|
||||
* @return void
|
||||
*/
|
||||
public function render( \WP_Post $post ): void {
|
||||
$main_keyword = get_post_meta( $post->ID, self::META_MAIN, true );
|
||||
$secondary_json = get_post_meta( $post->ID, self::META_SECONDARY, true );
|
||||
$secondary = ! empty( $secondary_json ) ? json_decode( $secondary_json, true ) : array();
|
||||
$cached_results = get_post_meta( $post->ID, self::META_RESULTS, true );
|
||||
$settings = self::get_settings();
|
||||
$ai_features = AdminMenu::get_ai_features();
|
||||
$global_settings = SettingsPage::getSettings();
|
||||
$has_ai = ! empty( $global_settings['ai_enabled'] )
|
||||
&& ! empty( $global_settings['api_keys'][ $global_settings['provider'] ] ?? '' );
|
||||
$ai_keywords = $has_ai && ! empty( $ai_features['keywords'] );
|
||||
|
||||
wp_nonce_field( 'brezngeo_keyword_save_' . $post->ID, 'brezngeo_keyword_nonce' );
|
||||
|
||||
include BREZNGEO_DIR . 'includes/Admin/views/keyword-meta-box.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Save keyword meta data on post save.
|
||||
*
|
||||
* @param int $post_id The post ID.
|
||||
* @param \WP_Post $post The post object.
|
||||
* @return void
|
||||
*/
|
||||
public function save( int $post_id, \WP_Post $post ): void {
|
||||
if ( ! isset( $_POST['brezngeo_keyword_nonce'] ) ) {
|
||||
return;
|
||||
}
|
||||
if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['brezngeo_keyword_nonce'] ) ), 'brezngeo_keyword_save_' . $post_id ) ) {
|
||||
return;
|
||||
}
|
||||
if ( ! current_user_can( 'edit_post', $post_id ) ) {
|
||||
return;
|
||||
}
|
||||
if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$main = sanitize_text_field( wp_unslash( $_POST['brezngeo_keyword_main'] ?? '' ) );
|
||||
update_post_meta( $post_id, self::META_MAIN, $main );
|
||||
|
||||
$raw_secondary = isset( $_POST['brezngeo_keyword_secondary'] ) && is_array( $_POST['brezngeo_keyword_secondary'] )
|
||||
? array_map( 'sanitize_text_field', wp_unslash( $_POST['brezngeo_keyword_secondary'] ) )
|
||||
: array();
|
||||
$secondary = array_values(
|
||||
array_filter(
|
||||
$raw_secondary,
|
||||
function ( $kw ) {
|
||||
return '' !== trim( $kw );
|
||||
}
|
||||
)
|
||||
);
|
||||
update_post_meta( $post_id, self::META_SECONDARY, wp_json_encode( $secondary, JSON_UNESCAPED_UNICODE ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue scripts and styles for the keyword meta box.
|
||||
*
|
||||
* @param string $hook The current admin page hook.
|
||||
* @return void
|
||||
*/
|
||||
public function enqueue( string $hook ): void {
|
||||
if ( ! in_array( $hook, array( 'post.php', 'post-new.php' ), true ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$settings = self::get_settings();
|
||||
$ai_features = AdminMenu::get_ai_features();
|
||||
$global_settings = SettingsPage::getSettings();
|
||||
$has_ai = ! empty( $global_settings['ai_enabled'] )
|
||||
&& ! empty( $global_settings['api_keys'][ $global_settings['provider'] ] ?? '' );
|
||||
|
||||
wp_enqueue_script(
|
||||
'brezngeo-keyword-analysis',
|
||||
BREZNGEO_URL . 'assets/keyword-analysis.js',
|
||||
array( 'jquery' ),
|
||||
BREZNGEO_VERSION,
|
||||
true
|
||||
);
|
||||
wp_localize_script(
|
||||
'brezngeo-keyword-analysis',
|
||||
'brezngeoKeyword',
|
||||
array(
|
||||
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
|
||||
'nonce' => wp_create_nonce( 'brezngeo_admin' ),
|
||||
'postId' => get_the_ID(),
|
||||
'updateMode' => $settings['update_mode'] ?? 'manual',
|
||||
'debounceMs' => (int) ( $settings['live_debounce_ms'] ?? 800 ),
|
||||
'aiEnabled' => $has_ai && ! empty( $ai_features['keywords'] ),
|
||||
'i18n' => array(
|
||||
'analyzing' => __( 'Analyzing…', 'brezngeo' ),
|
||||
'error' => __( 'Analysis error.', 'brezngeo' ),
|
||||
'noKeyword' => __( 'Please enter a main keyword.', 'brezngeo' ),
|
||||
'suggesting' => __( 'Getting suggestions…', 'brezngeo' ),
|
||||
'optimizing' => __( 'Getting optimization tips…', 'brezngeo' ),
|
||||
'semantic' => __( 'Running semantic analysis…', 'brezngeo' ),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX handler for keyword analysis.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function ajax_analyze(): void {
|
||||
check_ajax_referer( 'brezngeo_admin', 'nonce' );
|
||||
if ( ! current_user_can( 'edit_posts' ) ) {
|
||||
wp_send_json_error( __( 'Insufficient permissions.', 'brezngeo' ) );
|
||||
}
|
||||
|
||||
$post_id = absint( wp_unslash( $_POST['post_id'] ?? 0 ) );
|
||||
$post_content = wp_kses_post( wp_unslash( $_POST['post_content'] ?? '' ) );
|
||||
$main_keyword = sanitize_text_field( wp_unslash( $_POST['main_keyword'] ?? '' ) );
|
||||
$raw_secondary = isset( $_POST['secondary_keywords'] ) ? sanitize_text_field( wp_unslash( $_POST['secondary_keywords'] ) ) : '';
|
||||
$secondary = ! empty( $raw_secondary ) ? json_decode( $raw_secondary, true ) : array();
|
||||
if ( ! is_array( $secondary ) ) {
|
||||
$secondary = array();
|
||||
}
|
||||
$secondary = array_map( 'sanitize_text_field', $secondary );
|
||||
|
||||
if ( '' === $main_keyword ) {
|
||||
wp_send_json_error( __( 'No keyword provided.', 'brezngeo' ) );
|
||||
}
|
||||
|
||||
$settings = self::get_settings();
|
||||
$locale = function_exists( 'get_locale' ) ? get_locale() : 'en_US';
|
||||
$data = KeywordAnalysis::extract_content_data( $post_content, $post_id );
|
||||
$thresholds = array(
|
||||
'target_density' => (float) ( $settings['target_density'] ?? 1.5 ),
|
||||
'density_margin' => 0.5,
|
||||
'min_occurrences' => (int) ( $settings['min_occurrences_primary'] ?? 3 ),
|
||||
'min_occurrences_secondary' => (int) ( $settings['min_occurrences_secondary'] ?? 1 ),
|
||||
);
|
||||
|
||||
$main_results = KeywordAnalysis::analyze( $main_keyword, $data, $thresholds, true, $locale );
|
||||
$secondary_results = array();
|
||||
foreach ( $secondary as $kw ) {
|
||||
$kw = trim( $kw );
|
||||
if ( '' === $kw ) {
|
||||
continue;
|
||||
}
|
||||
$secondary_results[ $kw ] = KeywordAnalysis::analyze( $kw, $data, $thresholds, false, $locale );
|
||||
}
|
||||
|
||||
$response = array(
|
||||
'main' => array(
|
||||
'keyword' => $main_keyword,
|
||||
'checks' => $main_results,
|
||||
),
|
||||
'secondary' => $secondary_results,
|
||||
);
|
||||
|
||||
// Cache results as post meta.
|
||||
if ( $post_id > 0 ) {
|
||||
update_post_meta( $post_id, self::META_RESULTS, wp_json_encode( $response, JSON_UNESCAPED_UNICODE ) );
|
||||
}
|
||||
|
||||
wp_send_json_success( $response );
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX handler for AI keyword suggestions.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function ajax_ai_suggest(): void {
|
||||
check_ajax_referer( 'brezngeo_admin', 'nonce' );
|
||||
if ( ! current_user_can( 'edit_posts' ) ) {
|
||||
wp_send_json_error( __( 'Insufficient permissions.', 'brezngeo' ) );
|
||||
}
|
||||
|
||||
$ai_features = AdminMenu::get_ai_features();
|
||||
if ( empty( $ai_features['keywords'] ) ) {
|
||||
wp_send_json_error( __( 'AI keyword features are not activated.', 'brezngeo' ) );
|
||||
}
|
||||
|
||||
$post_content = wp_kses_post( wp_unslash( $_POST['post_content'] ?? '' ) );
|
||||
$post_id = absint( wp_unslash( $_POST['post_id'] ?? 0 ) );
|
||||
$title = $post_id > 0 ? get_the_title( $post_id ) : '';
|
||||
$content = wp_strip_all_tags( $post_content );
|
||||
$content = TokenEstimator::truncate( $content, 2000 );
|
||||
$language = self::detect_language();
|
||||
|
||||
$prompt = "Analyze the following article and suggest the best SEO keywords.\n" .
|
||||
"Title: {$title}\n" .
|
||||
"Content: {$content}\n\n" .
|
||||
"Language: {$language}\n" .
|
||||
"Respond ONLY with valid JSON in this format: {\"main\": \"primary keyword\", \"secondary\": [\"keyword1\", \"keyword2\", \"keyword3\"]}\n" .
|
||||
'Suggest 1 main keyword and up to 3 secondary keywords. Keep them concise and relevant.';
|
||||
|
||||
$result = self::call_ai( $prompt );
|
||||
if ( null === $result ) {
|
||||
wp_send_json_error( __( 'AI generation failed. Check provider settings.', 'brezngeo' ) );
|
||||
}
|
||||
|
||||
$parsed = json_decode( $result, true );
|
||||
if ( ! is_array( $parsed ) || empty( $parsed['main'] ) ) {
|
||||
// Try to extract JSON from response.
|
||||
if ( preg_match( '/\{[^}]+\}/', $result, $json_match ) ) {
|
||||
$parsed = json_decode( $json_match[0], true );
|
||||
}
|
||||
}
|
||||
|
||||
if ( is_array( $parsed ) && ! empty( $parsed['main'] ) ) {
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'main' => sanitize_text_field( $parsed['main'] ),
|
||||
'secondary' => array_map( 'sanitize_text_field', (array) ( $parsed['secondary'] ?? array() ) ),
|
||||
)
|
||||
);
|
||||
} else {
|
||||
wp_send_json_error( __( 'Could not parse AI response.', 'brezngeo' ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX handler for AI optimization tips.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function ajax_ai_optimize(): void {
|
||||
check_ajax_referer( 'brezngeo_admin', 'nonce' );
|
||||
if ( ! current_user_can( 'edit_posts' ) ) {
|
||||
wp_send_json_error( __( 'Insufficient permissions.', 'brezngeo' ) );
|
||||
}
|
||||
|
||||
$ai_features = AdminMenu::get_ai_features();
|
||||
if ( empty( $ai_features['keywords'] ) ) {
|
||||
wp_send_json_error( __( 'AI keyword features are not activated.', 'brezngeo' ) );
|
||||
}
|
||||
|
||||
$post_content = wp_kses_post( wp_unslash( $_POST['post_content'] ?? '' ) );
|
||||
$main_keyword = sanitize_text_field( wp_unslash( $_POST['main_keyword'] ?? '' ) );
|
||||
$content = wp_strip_all_tags( $post_content );
|
||||
$content = TokenEstimator::truncate( $content, 2000 );
|
||||
$language = self::detect_language();
|
||||
|
||||
$prompt = "You are an SEO expert. The target keyword is: \"{$main_keyword}\".\n" .
|
||||
"Article content: {$content}\n\n" .
|
||||
"Language: {$language}\n" .
|
||||
"Provide 3-5 concrete, actionable optimization tips to improve this article's ranking for the target keyword.\n" .
|
||||
'Respond ONLY with a JSON array of strings, e.g.: ["Tip 1", "Tip 2", "Tip 3"]';
|
||||
|
||||
$result = self::call_ai( $prompt );
|
||||
if ( null === $result ) {
|
||||
wp_send_json_error( __( 'AI generation failed. Check provider settings.', 'brezngeo' ) );
|
||||
}
|
||||
|
||||
$parsed = json_decode( $result, true );
|
||||
if ( ! is_array( $parsed ) ) {
|
||||
if ( preg_match( '/\[.*\]/s', $result, $json_match ) ) {
|
||||
$parsed = json_decode( $json_match[0], true );
|
||||
}
|
||||
}
|
||||
|
||||
if ( is_array( $parsed ) && ! empty( $parsed ) ) {
|
||||
wp_send_json_success( array_map( 'sanitize_text_field', array_values( $parsed ) ) );
|
||||
} else {
|
||||
wp_send_json_error( __( 'Could not parse AI response.', 'brezngeo' ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX handler for AI semantic analysis.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function ajax_ai_semantic(): void {
|
||||
check_ajax_referer( 'brezngeo_admin', 'nonce' );
|
||||
if ( ! current_user_can( 'edit_posts' ) ) {
|
||||
wp_send_json_error( __( 'Insufficient permissions.', 'brezngeo' ) );
|
||||
}
|
||||
|
||||
$ai_features = AdminMenu::get_ai_features();
|
||||
if ( empty( $ai_features['keywords'] ) ) {
|
||||
wp_send_json_error( __( 'AI keyword features are not activated.', 'brezngeo' ) );
|
||||
}
|
||||
|
||||
$post_content = wp_kses_post( wp_unslash( $_POST['post_content'] ?? '' ) );
|
||||
$main_keyword = sanitize_text_field( wp_unslash( $_POST['main_keyword'] ?? '' ) );
|
||||
$content = wp_strip_all_tags( $post_content );
|
||||
$content = TokenEstimator::truncate( $content, 2000 );
|
||||
$language = self::detect_language();
|
||||
|
||||
$prompt = "You are an SEO expert. Perform a semantic analysis of the following article for the keyword: \"{$main_keyword}\".\n" .
|
||||
"Article content: {$content}\n\n" .
|
||||
"Language: {$language}\n" .
|
||||
"Analyze:\n" .
|
||||
"1. Topic coverage — which related subtopics are present?\n" .
|
||||
"2. Related terms — which relevant terms are present, which are missing?\n" .
|
||||
"3. Content gaps — what topics should be added?\n\n" .
|
||||
'Respond in plain text, structured with clear headings. Keep it concise (max 200 words). Respond in the article language.';
|
||||
|
||||
$result = self::call_ai( $prompt );
|
||||
if ( null === $result ) {
|
||||
wp_send_json_error( __( 'AI generation failed. Check provider settings.', 'brezngeo' ) );
|
||||
}
|
||||
|
||||
wp_send_json_success( sanitize_textarea_field( $result ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Call AI provider with a prompt.
|
||||
*
|
||||
* @param string $prompt The prompt to send.
|
||||
* @return string|null AI response text or null on failure.
|
||||
*/
|
||||
private static function call_ai( string $prompt ): ?string {
|
||||
$settings = SettingsPage::getSettings();
|
||||
$registry = ProviderRegistry::instance();
|
||||
$provider = $registry->get( $settings['provider'] );
|
||||
$api_key = $settings['api_keys'][ $settings['provider'] ] ?? '';
|
||||
|
||||
if ( ! $provider || empty( $api_key ) || empty( $settings['ai_enabled'] ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$model = $settings['models'][ $settings['provider'] ] ?? array_key_first( $provider->getModels() );
|
||||
|
||||
try {
|
||||
$result = $provider->generateText( $prompt, $api_key, $model, 500 );
|
||||
$tokens_in = TokenEstimator::estimate( $prompt );
|
||||
$tokens_out = TokenEstimator::estimate( $result );
|
||||
\BreznGEO\Features\MetaGenerator::record_usage( $tokens_in, $tokens_out );
|
||||
return $result;
|
||||
} catch ( \Exception $e ) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect language for AI prompts.
|
||||
*/
|
||||
private static function detect_language(): string {
|
||||
$locale = function_exists( 'get_locale' ) ? get_locale() : 'en_US';
|
||||
$locale_map = array(
|
||||
'de' => 'German',
|
||||
'en' => 'English',
|
||||
);
|
||||
$lang = mb_strtolower( mb_substr( $locale, 0, 2 ) );
|
||||
return $locale_map[ $lang ] ?? 'English';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get keyword analysis settings with defaults.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function get_settings(): array {
|
||||
$defaults = array(
|
||||
'update_mode' => 'manual',
|
||||
'target_density' => 1.5,
|
||||
'min_occurrences_primary' => 3,
|
||||
'min_occurrences_secondary' => 1,
|
||||
'post_types' => array( 'post', 'page' ),
|
||||
'live_debounce_ms' => 800,
|
||||
);
|
||||
$saved = get_option( 'brezngeo_keyword_settings', array() );
|
||||
$saved = is_array( $saved ) ? $saved : array();
|
||||
return array_merge( $defaults, $saved );
|
||||
}
|
||||
}
|
||||
103
brezngeo/includes/Admin/KeywordPage.php
Normal file
103
brezngeo/includes/Admin/KeywordPage.php
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
<?php
|
||||
/**
|
||||
* Keyword analysis settings page.
|
||||
*
|
||||
* @package BreznGEO
|
||||
*/
|
||||
|
||||
namespace BreznGEO\Admin;
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles registration and rendering of keyword analysis settings.
|
||||
*/
|
||||
class KeywordPage {
|
||||
|
||||
public const OPTION_KEY = 'brezngeo_keyword_settings';
|
||||
|
||||
/**
|
||||
* Register hooks for the keyword settings page.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register(): void {
|
||||
add_action( 'admin_init', array( $this, 'register_settings' ) );
|
||||
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the keyword settings with WordPress.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register_settings(): void {
|
||||
register_setting(
|
||||
'brezngeo_keyword',
|
||||
self::OPTION_KEY,
|
||||
array( 'sanitize_callback' => array( $this, 'sanitize' ) )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue assets for the keyword settings page.
|
||||
*
|
||||
* @param string $hook The current admin page hook.
|
||||
* @return void
|
||||
*/
|
||||
public function enqueue_assets( string $hook ): void {
|
||||
if ( 'brezngeo_page_brezngeo-keyword' !== $hook ) {
|
||||
return;
|
||||
}
|
||||
wp_enqueue_style( 'brezngeo-admin', BREZNGEO_URL . 'assets/admin.css', array(), BREZNGEO_VERSION );
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize keyword settings input.
|
||||
*
|
||||
* @param mixed $input Raw settings input.
|
||||
* @return array Sanitized settings.
|
||||
*/
|
||||
public function sanitize( mixed $input ): array {
|
||||
$input = is_array( $input ) ? $input : array();
|
||||
$clean = array();
|
||||
|
||||
$allowed_modes = array( 'live', 'manual', 'save' );
|
||||
$clean['update_mode'] = in_array( $input['update_mode'] ?? '', $allowed_modes, true )
|
||||
? $input['update_mode'] : 'manual';
|
||||
|
||||
$clean['target_density'] = max( 0.1, min( 5.0, (float) ( $input['target_density'] ?? 1.5 ) ) );
|
||||
$clean['min_occurrences_primary'] = max( 1, (int) ( $input['min_occurrences_primary'] ?? 3 ) );
|
||||
$clean['min_occurrences_secondary'] = max( 1, (int) ( $input['min_occurrences_secondary'] ?? 1 ) );
|
||||
$clean['live_debounce_ms'] = max( 300, min( 3000, (int) ( $input['live_debounce_ms'] ?? 800 ) ) );
|
||||
|
||||
$all_post_types = array_keys( get_post_types( array( 'public' => true ) ) );
|
||||
$clean['post_types'] = array_values(
|
||||
array_intersect(
|
||||
array_map( 'sanitize_key', (array) ( $input['post_types'] ?? array() ) ),
|
||||
$all_post_types
|
||||
)
|
||||
);
|
||||
if ( empty( $clean['post_types'] ) ) {
|
||||
$clean['post_types'] = array( 'post', 'page' );
|
||||
}
|
||||
|
||||
return $clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the keyword settings page.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function render(): void {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
return;
|
||||
}
|
||||
$settings = KeywordMetaBox::get_settings();
|
||||
$post_types = get_post_types( array( 'public' => true ), 'objects' );
|
||||
include BREZNGEO_DIR . 'includes/Admin/views/keyword-settings.php';
|
||||
}
|
||||
}
|
||||
|
|
@ -156,6 +156,18 @@
|
|||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:6px 0;">
|
||||
<label>
|
||||
<input type="checkbox" name="brezngeo_ai_features[keywords]" value="1"
|
||||
<?php checked( $ai_features['keywords'] ); ?>>
|
||||
<strong><?php esc_html_e( 'Keyword Analysis', 'brezngeo' ); ?></strong>
|
||||
</label>
|
||||
<p style="margin:2px 0 0 22px;color:#777;font-size:12px;">
|
||||
<?php esc_html_e( 'AI-powered keyword suggestions, optimization tips, and semantic analysis.', 'brezngeo' ); ?>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin-top:12px;">
|
||||
<?php submit_button( __( 'Save', 'brezngeo' ), 'secondary', 'submit', false ); ?>
|
||||
|
|
|
|||
68
brezngeo/includes/Admin/views/keyword-meta-box.php
Normal file
68
brezngeo/includes/Admin/views/keyword-meta-box.php
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
?>
|
||||
<div id="brezngeo-keyword-box"
|
||||
data-post-id="<?php echo esc_attr( $post->ID ); ?>"
|
||||
data-nonce="<?php echo esc_attr( wp_create_nonce( 'brezngeo_admin' ) ); ?>">
|
||||
|
||||
<p style="margin-bottom:4px;">
|
||||
<label for="brezngeo-keyword-main"><strong><?php esc_html_e( 'Main Keyword', 'brezngeo' ); ?></strong></label>
|
||||
</p>
|
||||
<input type="text" id="brezngeo-keyword-main" name="brezngeo_keyword_main"
|
||||
value="<?php echo esc_attr( $main_keyword ); ?>"
|
||||
style="width:100%;box-sizing:border-box;"
|
||||
placeholder="<?php esc_attr_e( 'e.g. Passau travel guide', 'brezngeo' ); ?>">
|
||||
|
||||
<p style="margin-bottom:4px;margin-top:12px;">
|
||||
<strong><?php esc_html_e( 'Secondary Keywords', 'brezngeo' ); ?></strong>
|
||||
</p>
|
||||
<div id="brezngeo-keyword-secondary-list">
|
||||
<?php if ( ! empty( $secondary ) ) : ?>
|
||||
<?php foreach ( $secondary as $kw ) : ?>
|
||||
<div class="brezngeo-keyword-secondary-row" style="display:flex;gap:6px;margin-bottom:4px;">
|
||||
<input type="text" name="brezngeo_keyword_secondary[]"
|
||||
value="<?php echo esc_attr( $kw ); ?>"
|
||||
style="flex:1;box-sizing:border-box;">
|
||||
<button type="button" class="button brezngeo-keyword-remove-secondary">×</button>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<p>
|
||||
<button type="button" class="button" id="brezngeo-keyword-add-secondary">
|
||||
+ <?php esc_html_e( 'Add Keyword', 'brezngeo' ); ?>
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<p style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap;">
|
||||
<button type="button" class="button button-primary" id="brezngeo-keyword-analyze">
|
||||
<?php esc_html_e( 'Analyze', 'brezngeo' ); ?>
|
||||
</button>
|
||||
<?php if ( $ai_keywords ) : ?>
|
||||
<button type="button" class="button" id="brezngeo-keyword-ai-suggest">
|
||||
<?php esc_html_e( 'Suggest Keywords', 'brezngeo' ); ?> ✨
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<span id="brezngeo-keyword-status" style="align-self:center;font-size:12px;"></span>
|
||||
</p>
|
||||
|
||||
<div id="brezngeo-keyword-results" style="margin-top:16px;">
|
||||
<?php if ( ! empty( $cached_results ) ) : ?>
|
||||
<p style="font-size:11px;color:#999;"><?php esc_html_e( 'Showing cached results. Click "Analyze" to refresh.', 'brezngeo' ); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if ( $ai_keywords ) : ?>
|
||||
<div id="brezngeo-keyword-ai-actions" style="margin-top:12px;display:none;">
|
||||
<button type="button" class="button" id="brezngeo-keyword-ai-optimize">
|
||||
<?php esc_html_e( 'Optimization Tips', 'brezngeo' ); ?> ✨
|
||||
</button>
|
||||
<button type="button" class="button" id="brezngeo-keyword-ai-semantic">
|
||||
<?php esc_html_e( 'Semantic Analysis', 'brezngeo' ); ?> ✨
|
||||
</button>
|
||||
</div>
|
||||
<div id="brezngeo-keyword-ai-results" style="margin-top:12px;"></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
95
brezngeo/includes/Admin/views/keyword-settings.php
Normal file
95
brezngeo/includes/Admin/views/keyword-settings.php
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
<?php
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
?>
|
||||
<div class="wrap brezngeo-settings">
|
||||
<h1><?php esc_html_e( 'Keyword Analysis', 'brezngeo' ); ?></h1>
|
||||
|
||||
<?php settings_errors( 'brezngeo_keyword' ); ?>
|
||||
|
||||
<form method="post" action="options.php">
|
||||
<?php settings_fields( 'brezngeo_keyword' ); ?>
|
||||
|
||||
<h2><?php esc_html_e( 'Analysis Settings', 'brezngeo' ); ?></h2>
|
||||
<table class="form-table">
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e( 'Update Mode', 'brezngeo' ); ?></th>
|
||||
<td>
|
||||
<select name="brezngeo_keyword_settings[update_mode]">
|
||||
<option value="manual" <?php selected( $settings['update_mode'], 'manual' ); ?>>
|
||||
<?php esc_html_e( 'Manual — click "Analyze" button', 'brezngeo' ); ?>
|
||||
</option>
|
||||
<option value="live" <?php selected( $settings['update_mode'], 'live' ); ?>>
|
||||
<?php esc_html_e( 'Live — auto-analyze while typing', 'brezngeo' ); ?>
|
||||
</option>
|
||||
<option value="save" <?php selected( $settings['update_mode'], 'save' ); ?>>
|
||||
<?php esc_html_e( 'On Save — analyze when post is saved', 'brezngeo' ); ?>
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e( 'Target Keyword Density (%)', 'brezngeo' ); ?></th>
|
||||
<td>
|
||||
<input type="number" name="brezngeo_keyword_settings[target_density]"
|
||||
value="<?php echo esc_attr( $settings['target_density'] ); ?>"
|
||||
min="0.1" max="5.0" step="0.1" style="width:80px;">
|
||||
<p class="description"><?php esc_html_e( 'Recommended: 1.0–2.0%. Pass range is ±0.5% around the target.', 'brezngeo' ); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e( 'Min. Occurrences (Primary)', 'brezngeo' ); ?></th>
|
||||
<td>
|
||||
<input type="number" name="brezngeo_keyword_settings[min_occurrences_primary]"
|
||||
value="<?php echo esc_attr( $settings['min_occurrences_primary'] ); ?>"
|
||||
min="1" max="50" style="width:80px;">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e( 'Min. Occurrences (Secondary)', 'brezngeo' ); ?></th>
|
||||
<td>
|
||||
<input type="number" name="brezngeo_keyword_settings[min_occurrences_secondary]"
|
||||
value="<?php echo esc_attr( $settings['min_occurrences_secondary'] ); ?>"
|
||||
min="1" max="50" style="width:80px;">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e( 'Live Mode Debounce (ms)', 'brezngeo' ); ?></th>
|
||||
<td>
|
||||
<input type="number" name="brezngeo_keyword_settings[live_debounce_ms]"
|
||||
value="<?php echo esc_attr( $settings['live_debounce_ms'] ); ?>"
|
||||
min="300" max="3000" step="100" style="width:100px;">
|
||||
<p class="description"><?php esc_html_e( 'Delay in milliseconds before live analysis triggers after typing stops.', 'brezngeo' ); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h2><?php esc_html_e( 'Post Types', 'brezngeo' ); ?></h2>
|
||||
<table class="form-table">
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e( 'Show keyword meta box on', 'brezngeo' ); ?></th>
|
||||
<td>
|
||||
<?php foreach ( $post_types as $pt ) : ?>
|
||||
<label style="display:block;margin-bottom:4px;">
|
||||
<input type="checkbox" name="brezngeo_keyword_settings[post_types][]"
|
||||
value="<?php echo esc_attr( $pt->name ); ?>"
|
||||
<?php checked( in_array( $pt->name, $settings['post_types'], true ) ); ?>>
|
||||
<?php echo esc_html( $pt->labels->name ); ?>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<?php submit_button(); ?>
|
||||
</form>
|
||||
|
||||
<p class="brezngeo-footer">
|
||||
BreznGEO <?php echo esc_html( BREZNGEO_VERSION ); ?> —
|
||||
<?php esc_html_e( 'developed by', 'brezngeo' ); ?>
|
||||
<a href="https://noschmarrn.dev" target="_blank" rel="noopener">noschmarrn.dev</a>
|
||||
<?php esc_html_e( 'for', 'brezngeo' ); ?>
|
||||
<a href="https://donau2space.de" target="_blank" rel="noopener">Donau2Space.de</a>
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -48,6 +48,10 @@ class Core {
|
|||
require_once BREZNGEO_DIR . 'includes/Admin/GeoEditorBox.php';
|
||||
require_once BREZNGEO_DIR . 'includes/Admin/SchemaMetaBox.php';
|
||||
require_once BREZNGEO_DIR . 'includes/Admin/SchemaPage.php';
|
||||
require_once BREZNGEO_DIR . 'includes/Helpers/KeywordVariants.php';
|
||||
require_once BREZNGEO_DIR . 'includes/Features/KeywordAnalysis.php';
|
||||
require_once BREZNGEO_DIR . 'includes/Admin/KeywordMetaBox.php';
|
||||
require_once BREZNGEO_DIR . 'includes/Admin/KeywordPage.php';
|
||||
}
|
||||
|
||||
private function register_hooks(): void {
|
||||
|
|
@ -80,6 +84,8 @@ class Core {
|
|||
( new Admin\GeoEditorBox() )->register();
|
||||
( new Admin\SchemaMetaBox() )->register();
|
||||
( new Admin\SchemaPage() )->register();
|
||||
( new Admin\KeywordMetaBox() )->register();
|
||||
( new Admin\KeywordPage() )->register();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
474
brezngeo/includes/Features/KeywordAnalysis.php
Normal file
474
brezngeo/includes/Features/KeywordAnalysis.php
Normal file
|
|
@ -0,0 +1,474 @@
|
|||
<?php
|
||||
/**
|
||||
* Keyword analysis checks for SEO optimization.
|
||||
*
|
||||
* @package BreznGEO
|
||||
*/
|
||||
|
||||
namespace BreznGEO\Features;
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
use BreznGEO\Helpers\KeywordVariants;
|
||||
|
||||
/**
|
||||
* Runs keyword presence and density checks against post content.
|
||||
*/
|
||||
class KeywordAnalysis {
|
||||
|
||||
/**
|
||||
* Extract structured content data from HTML and post context.
|
||||
*
|
||||
* @param string $html Post content HTML.
|
||||
* @param int $post_id Post ID for meta lookups.
|
||||
* @return array{title: string, headings: string[], paragraphs: string[], images: array, slug: string, excerpt: string, meta_description: string, word_count: int, plain_text: string}
|
||||
*/
|
||||
public static function extract_content_data( string $html, int $post_id = 0 ): array {
|
||||
$title = '';
|
||||
if ( $post_id > 0 ) {
|
||||
$title = get_the_title( $post_id );
|
||||
}
|
||||
|
||||
$headings = array();
|
||||
if ( preg_match_all( '/<h[2-6][^>]*>(.*?)<\/h[2-6]>/si', $html, $matches ) ) {
|
||||
$headings = array_map( 'wp_strip_all_tags', $matches[1] );
|
||||
}
|
||||
|
||||
$paragraphs = array();
|
||||
if ( preg_match_all( '/<p[^>]*>(.*?)<\/p>/si', $html, $matches ) ) {
|
||||
$paragraphs = array_map( 'wp_strip_all_tags', $matches[1] );
|
||||
$paragraphs = array_values(
|
||||
array_filter(
|
||||
$paragraphs,
|
||||
function ( $p ) {
|
||||
return '' !== trim( $p );
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$images = array();
|
||||
if ( preg_match_all( '/<img[^>]+>/si', $html, $img_matches ) ) {
|
||||
foreach ( $img_matches[0] as $img_tag ) {
|
||||
$alt = '';
|
||||
$img_title = '';
|
||||
$caption = '';
|
||||
if ( preg_match( '/alt=["\']([^"\']*)["\']/', $img_tag, $alt_m ) ) {
|
||||
$alt = $alt_m[1];
|
||||
}
|
||||
if ( preg_match( '/title=["\']([^"\']*)["\']/', $img_tag, $title_m ) ) {
|
||||
$img_title = $title_m[1];
|
||||
}
|
||||
$images[] = array(
|
||||
'alt' => $alt,
|
||||
'title' => $img_title,
|
||||
'caption' => $caption,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$slug = '';
|
||||
if ( $post_id > 0 ) {
|
||||
$permalink = get_permalink( $post_id );
|
||||
if ( $permalink ) {
|
||||
$path = wp_parse_url( $permalink, PHP_URL_PATH );
|
||||
$slug = $path ? trim( $path, '/' ) : '';
|
||||
}
|
||||
}
|
||||
|
||||
$excerpt = '';
|
||||
if ( $post_id > 0 && function_exists( 'get_the_excerpt' ) ) {
|
||||
$excerpt = get_the_excerpt( $post_id );
|
||||
}
|
||||
|
||||
$meta_description = '';
|
||||
if ( $post_id > 0 ) {
|
||||
$meta_description = get_post_meta( $post_id, '_brezngeo_meta_description', true );
|
||||
}
|
||||
|
||||
$plain_text = wp_strip_all_tags( $html );
|
||||
$word_count = str_word_count( $plain_text );
|
||||
|
||||
return array(
|
||||
'title' => $title,
|
||||
'headings' => $headings,
|
||||
'paragraphs' => $paragraphs,
|
||||
'images' => $images,
|
||||
'slug' => $slug,
|
||||
'excerpt' => $excerpt,
|
||||
'meta_description' => $meta_description,
|
||||
'word_count' => $word_count,
|
||||
'plain_text' => $plain_text,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all keyword checks.
|
||||
*
|
||||
* @param string $keyword The keyword to check.
|
||||
* @param array $data Content data from extract_content_data().
|
||||
* @param array $thresholds Settings: target_density, min_occurrences.
|
||||
* @param bool $is_primary Whether this is the primary keyword.
|
||||
* @param string $locale Locale for variant generation.
|
||||
* @return array[] Array of check results.
|
||||
*/
|
||||
public static function analyze( string $keyword, array $data, array $thresholds = array(), bool $is_primary = true, string $locale = '' ): array {
|
||||
$defaults = array(
|
||||
'target_density' => 1.5,
|
||||
'min_occurrences' => 3,
|
||||
'density_margin' => 0.5,
|
||||
);
|
||||
$thresholds = array_merge( $defaults, $thresholds );
|
||||
|
||||
if ( ! $is_primary ) {
|
||||
$thresholds['min_occurrences'] = $thresholds['min_occurrences_secondary'] ?? 1;
|
||||
}
|
||||
|
||||
$variants = KeywordVariants::generate( $keyword, $locale );
|
||||
|
||||
$checks = array(
|
||||
self::check_title( $keyword, $data, $variants ),
|
||||
self::check_headings( $keyword, $data, $variants ),
|
||||
self::check_density( $keyword, $data, $variants, $thresholds ),
|
||||
self::check_image_alts( $keyword, $data, $variants ),
|
||||
self::check_meta_description( $keyword, $data, $variants ),
|
||||
self::check_slug( $keyword, $data, $variants ),
|
||||
self::check_first_paragraph( $keyword, $data, $variants ),
|
||||
self::check_last_paragraph( $keyword, $data, $variants ),
|
||||
self::check_image_title_caption( $keyword, $data, $variants ),
|
||||
self::check_excerpt( $keyword, $data, $variants ),
|
||||
);
|
||||
|
||||
return $checks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 1: Keyword in title.
|
||||
*
|
||||
* @param string $keyword The keyword.
|
||||
* @param array $data Content data.
|
||||
* @param string[] $variants Keyword variants.
|
||||
* @return array Check result.
|
||||
*/
|
||||
public static function check_title( string $keyword, array $data, array $variants ): array {
|
||||
$found = KeywordVariants::keyword_present( $keyword, $data['title'] ?? '', $variants );
|
||||
return array(
|
||||
'id' => 'title',
|
||||
'label' => __( 'Title', 'brezngeo' ),
|
||||
'status' => $found ? 'pass' : 'fail',
|
||||
'message' => $found
|
||||
? __( 'Keyword found in title.', 'brezngeo' )
|
||||
: __( 'Keyword not found in title.', 'brezngeo' ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 2: Keyword in headings (H2-H6).
|
||||
*
|
||||
* @param string $keyword The keyword.
|
||||
* @param array $data Content data.
|
||||
* @param string[] $variants Keyword variants.
|
||||
* @return array Check result.
|
||||
*/
|
||||
public static function check_headings( string $keyword, array $data, array $variants ): array {
|
||||
$headings = $data['headings'] ?? array();
|
||||
$found = false;
|
||||
foreach ( $headings as $heading ) {
|
||||
if ( KeywordVariants::keyword_present( $keyword, $heading, $variants ) ) {
|
||||
$found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return array(
|
||||
'id' => 'headings',
|
||||
'label' => __( 'Headings', 'brezngeo' ),
|
||||
'status' => $found ? 'pass' : 'fail',
|
||||
'message' => $found
|
||||
? __( 'Keyword found in subheading.', 'brezngeo' )
|
||||
: __( 'Keyword not found in any H2-H6.', 'brezngeo' ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 3: Keyword density.
|
||||
*
|
||||
* @param string $keyword The keyword.
|
||||
* @param array $data Content data.
|
||||
* @param string[] $variants Keyword variants.
|
||||
* @param array $thresholds Density thresholds.
|
||||
* @return array Check result.
|
||||
*/
|
||||
public static function check_density( string $keyword, array $data, array $variants, array $thresholds ): array {
|
||||
$plain = $data['plain_text'] ?? '';
|
||||
$word_count = $data['word_count'] ?? 0;
|
||||
$count = KeywordVariants::count_occurrences( $plain, $variants );
|
||||
|
||||
if ( 0 === $word_count || 0 === $count ) {
|
||||
return array(
|
||||
'id' => 'density',
|
||||
'label' => __( 'Keyword Density', 'brezngeo' ),
|
||||
'status' => 'fail',
|
||||
'message' => __( 'Keyword not found in content.', 'brezngeo' ),
|
||||
'details' => array(
|
||||
'count' => $count,
|
||||
'density' => 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
$density = ( $count / $word_count ) * 100;
|
||||
$target = (float) $thresholds['target_density'];
|
||||
$margin = (float) $thresholds['density_margin'];
|
||||
$diff = abs( $density - $target );
|
||||
|
||||
if ( $diff <= $margin ) {
|
||||
$status = 'pass';
|
||||
} elseif ( $density > 0 ) {
|
||||
$status = 'warn';
|
||||
} else {
|
||||
$status = 'fail';
|
||||
}
|
||||
|
||||
return array(
|
||||
'id' => 'density',
|
||||
'label' => __( 'Keyword Density', 'brezngeo' ),
|
||||
'status' => $status,
|
||||
/* translators: 1: actual density percentage, 2: target density percentage */
|
||||
'message' => sprintf( __( '%1$.1f%% (target: %2$.1f%%)', 'brezngeo' ), $density, $target ),
|
||||
'details' => array(
|
||||
'count' => $count,
|
||||
'density' => round( $density, 2 ),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 4: Keyword in image alt texts.
|
||||
*
|
||||
* @param string $keyword The keyword.
|
||||
* @param array $data Content data.
|
||||
* @param string[] $variants Keyword variants.
|
||||
* @return array Check result.
|
||||
*/
|
||||
public static function check_image_alts( string $keyword, array $data, array $variants ): array {
|
||||
$images = $data['images'] ?? array();
|
||||
|
||||
if ( empty( $images ) ) {
|
||||
return array(
|
||||
'id' => 'image_alts',
|
||||
'label' => __( 'Image Alt Texts', 'brezngeo' ),
|
||||
'status' => 'warn',
|
||||
'message' => __( 'No images found.', 'brezngeo' ),
|
||||
);
|
||||
}
|
||||
|
||||
foreach ( $images as $img ) {
|
||||
if ( KeywordVariants::keyword_present( $keyword, $img['alt'] ?? '', $variants ) ) {
|
||||
return array(
|
||||
'id' => 'image_alts',
|
||||
'label' => __( 'Image Alt Texts', 'brezngeo' ),
|
||||
'status' => 'pass',
|
||||
'message' => __( 'Keyword found in image alt text.', 'brezngeo' ),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return array(
|
||||
'id' => 'image_alts',
|
||||
'label' => __( 'Image Alt Texts', 'brezngeo' ),
|
||||
'status' => 'fail',
|
||||
'message' => __( 'No image contains keyword in alt text.', 'brezngeo' ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 5: Keyword in meta description.
|
||||
*
|
||||
* @param string $keyword The keyword.
|
||||
* @param array $data Content data.
|
||||
* @param string[] $variants Keyword variants.
|
||||
* @return array Check result.
|
||||
*/
|
||||
public static function check_meta_description( string $keyword, array $data, array $variants ): array {
|
||||
$meta = $data['meta_description'] ?? '';
|
||||
|
||||
if ( '' === $meta ) {
|
||||
return array(
|
||||
'id' => 'meta_description',
|
||||
'label' => __( 'Meta Description', 'brezngeo' ),
|
||||
'status' => 'warn',
|
||||
'message' => __( 'Meta description is empty.', 'brezngeo' ),
|
||||
);
|
||||
}
|
||||
|
||||
$found = KeywordVariants::keyword_present( $keyword, $meta, $variants );
|
||||
return array(
|
||||
'id' => 'meta_description',
|
||||
'label' => __( 'Meta Description', 'brezngeo' ),
|
||||
'status' => $found ? 'pass' : 'fail',
|
||||
'message' => $found
|
||||
? __( 'Keyword found in meta description.', 'brezngeo' )
|
||||
: __( 'Keyword not found in meta description.', 'brezngeo' ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 6: Keyword in URL/slug.
|
||||
*
|
||||
* @param string $keyword The keyword.
|
||||
* @param array $data Content data.
|
||||
* @param string[] $variants Keyword variants.
|
||||
* @return array Check result.
|
||||
*/
|
||||
public static function check_slug( string $keyword, array $data, array $variants ): array {
|
||||
$slug = mb_strtolower( $data['slug'] ?? '' );
|
||||
$found = KeywordVariants::keyword_present( $keyword, $slug, $variants );
|
||||
|
||||
return array(
|
||||
'id' => 'slug',
|
||||
'label' => __( 'URL / Slug', 'brezngeo' ),
|
||||
'status' => $found ? 'pass' : 'fail',
|
||||
'message' => $found
|
||||
? __( 'Keyword found in URL.', 'brezngeo' )
|
||||
: __( 'Keyword not found in URL.', 'brezngeo' ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 7: Keyword in first paragraph.
|
||||
*
|
||||
* @param string $keyword The keyword.
|
||||
* @param array $data Content data.
|
||||
* @param string[] $variants Keyword variants.
|
||||
* @return array Check result.
|
||||
*/
|
||||
public static function check_first_paragraph( string $keyword, array $data, array $variants ): array {
|
||||
$paragraphs = $data['paragraphs'] ?? array();
|
||||
|
||||
if ( empty( $paragraphs ) ) {
|
||||
return array(
|
||||
'id' => 'first_paragraph',
|
||||
'label' => __( 'First Paragraph', 'brezngeo' ),
|
||||
'status' => 'fail',
|
||||
'message' => __( 'No paragraphs found.', 'brezngeo' ),
|
||||
);
|
||||
}
|
||||
|
||||
$found = KeywordVariants::keyword_present( $keyword, $paragraphs[0], $variants );
|
||||
return array(
|
||||
'id' => 'first_paragraph',
|
||||
'label' => __( 'First Paragraph', 'brezngeo' ),
|
||||
'status' => $found ? 'pass' : 'fail',
|
||||
'message' => $found
|
||||
? __( 'Keyword found in first paragraph.', 'brezngeo' )
|
||||
: __( 'Keyword not found in first paragraph.', 'brezngeo' ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 8: Keyword in last paragraph.
|
||||
*
|
||||
* @param string $keyword The keyword.
|
||||
* @param array $data Content data.
|
||||
* @param string[] $variants Keyword variants.
|
||||
* @return array Check result.
|
||||
*/
|
||||
public static function check_last_paragraph( string $keyword, array $data, array $variants ): array {
|
||||
$paragraphs = $data['paragraphs'] ?? array();
|
||||
|
||||
if ( empty( $paragraphs ) ) {
|
||||
return array(
|
||||
'id' => 'last_paragraph',
|
||||
'label' => __( 'Last Paragraph', 'brezngeo' ),
|
||||
'status' => 'fail',
|
||||
'message' => __( 'No paragraphs found.', 'brezngeo' ),
|
||||
);
|
||||
}
|
||||
|
||||
$last = end( $paragraphs );
|
||||
$found = KeywordVariants::keyword_present( $keyword, $last, $variants );
|
||||
return array(
|
||||
'id' => 'last_paragraph',
|
||||
'label' => __( 'Last Paragraph', 'brezngeo' ),
|
||||
'status' => $found ? 'pass' : 'fail',
|
||||
'message' => $found
|
||||
? __( 'Keyword found in last paragraph.', 'brezngeo' )
|
||||
: __( 'Keyword not found in last paragraph.', 'brezngeo' ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 9: Keyword in image title or caption.
|
||||
*
|
||||
* @param string $keyword The keyword.
|
||||
* @param array $data Content data.
|
||||
* @param string[] $variants Keyword variants.
|
||||
* @return array Check result.
|
||||
*/
|
||||
public static function check_image_title_caption( string $keyword, array $data, array $variants ): array {
|
||||
$images = $data['images'] ?? array();
|
||||
|
||||
if ( empty( $images ) ) {
|
||||
return array(
|
||||
'id' => 'image_title_caption',
|
||||
'label' => __( 'Image Title/Caption', 'brezngeo' ),
|
||||
'status' => 'warn',
|
||||
'message' => __( 'No images found.', 'brezngeo' ),
|
||||
);
|
||||
}
|
||||
|
||||
foreach ( $images as $img ) {
|
||||
$title_text = $img['title'] ?? '';
|
||||
$caption_text = $img['caption'] ?? '';
|
||||
if ( KeywordVariants::keyword_present( $keyword, $title_text, $variants )
|
||||
|| KeywordVariants::keyword_present( $keyword, $caption_text, $variants ) ) {
|
||||
return array(
|
||||
'id' => 'image_title_caption',
|
||||
'label' => __( 'Image Title/Caption', 'brezngeo' ),
|
||||
'status' => 'pass',
|
||||
'message' => __( 'Keyword found in image title or caption.', 'brezngeo' ),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return array(
|
||||
'id' => 'image_title_caption',
|
||||
'label' => __( 'Image Title/Caption', 'brezngeo' ),
|
||||
'status' => 'fail',
|
||||
'message' => __( 'Keyword not found in any image title or caption.', 'brezngeo' ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 10: Keyword in excerpt.
|
||||
*
|
||||
* @param string $keyword The keyword.
|
||||
* @param array $data Content data.
|
||||
* @param string[] $variants Keyword variants.
|
||||
* @return array Check result.
|
||||
*/
|
||||
public static function check_excerpt( string $keyword, array $data, array $variants ): array {
|
||||
$excerpt = $data['excerpt'] ?? '';
|
||||
|
||||
if ( '' === trim( $excerpt ) ) {
|
||||
return array(
|
||||
'id' => 'excerpt',
|
||||
'label' => __( 'Excerpt', 'brezngeo' ),
|
||||
'status' => 'warn',
|
||||
'message' => __( 'Excerpt is empty.', 'brezngeo' ),
|
||||
);
|
||||
}
|
||||
|
||||
$found = KeywordVariants::keyword_present( $keyword, $excerpt, $variants );
|
||||
return array(
|
||||
'id' => 'excerpt',
|
||||
'label' => __( 'Excerpt', 'brezngeo' ),
|
||||
'status' => $found ? 'pass' : 'fail',
|
||||
'message' => $found
|
||||
? __( 'Keyword found in excerpt.', 'brezngeo' )
|
||||
: __( 'Keyword not found in excerpt.', 'brezngeo' ),
|
||||
);
|
||||
}
|
||||
}
|
||||
115
brezngeo/includes/Helpers/KeywordVariants.php
Normal file
115
brezngeo/includes/Helpers/KeywordVariants.php
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
<?php
|
||||
/**
|
||||
* Keyword variant generation and matching.
|
||||
*
|
||||
* @package BreznGEO
|
||||
*/
|
||||
|
||||
namespace BreznGEO\Helpers;
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates locale-aware keyword variants and provides matching utilities.
|
||||
*/
|
||||
class KeywordVariants {
|
||||
|
||||
/**
|
||||
* Generate keyword variants based on locale.
|
||||
*
|
||||
* @param string $keyword The keyword to generate variants for.
|
||||
* @param string $locale WordPress locale (e.g. 'en_US', 'de_DE').
|
||||
* @return string[] Array of unique lowercase variants including the original.
|
||||
*/
|
||||
public static function generate( string $keyword, string $locale = '' ): array {
|
||||
$keyword = trim( $keyword );
|
||||
if ( '' === $keyword ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
if ( '' === $locale ) {
|
||||
$locale = function_exists( 'get_locale' ) ? get_locale() : 'en_US';
|
||||
}
|
||||
|
||||
$lower = mb_strtolower( $keyword );
|
||||
$variants = array( $lower );
|
||||
|
||||
// Universal: compound variants (space ↔ hyphen).
|
||||
if ( mb_strpos( $lower, ' ' ) !== false ) {
|
||||
$variants[] = str_replace( ' ', '-', $lower );
|
||||
} elseif ( mb_strpos( $lower, '-' ) !== false ) {
|
||||
$variants[] = str_replace( '-', ' ', $lower );
|
||||
}
|
||||
|
||||
// Universal: trailing-s.
|
||||
if ( mb_substr( $lower, -1 ) !== 's' ) {
|
||||
$variants[] = $lower . 's';
|
||||
}
|
||||
|
||||
$lang = mb_strtolower( mb_substr( $locale, 0, 2 ) );
|
||||
|
||||
$suffixes = self::get_suffixes( $lang );
|
||||
foreach ( $suffixes as $suffix ) {
|
||||
$variants[] = $lower . $suffix;
|
||||
}
|
||||
|
||||
return array_values( array_unique( $variants ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a keyword (or any of its variants) is present in text.
|
||||
*
|
||||
* @param string $keyword The keyword.
|
||||
* @param string $text The text to search in.
|
||||
* @param string[] $variants Pre-generated variants from generate().
|
||||
* @return bool
|
||||
*/
|
||||
public static function keyword_present( string $keyword, string $text, array $variants ): bool {
|
||||
$text_lower = mb_strtolower( $text );
|
||||
|
||||
foreach ( $variants as $variant ) {
|
||||
if ( mb_strpos( $text_lower, $variant ) !== false ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count occurrences of a keyword (including variants) in text.
|
||||
*
|
||||
* @param string $text The text to search in.
|
||||
* @param string[] $variants Pre-generated variants from generate().
|
||||
* @return int Total count across all variants (no double-counting of overlapping matches).
|
||||
*/
|
||||
public static function count_occurrences( string $text, array $variants ): int {
|
||||
$text_lower = mb_strtolower( $text );
|
||||
$count = 0;
|
||||
|
||||
foreach ( $variants as $variant ) {
|
||||
$count += mb_substr_count( $text_lower, $variant );
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get locale-specific suffixes.
|
||||
*
|
||||
* @param string $lang Two-letter language code.
|
||||
* @return string[]
|
||||
*/
|
||||
private static function get_suffixes( string $lang ): array {
|
||||
switch ( $lang ) {
|
||||
case 'en':
|
||||
return array( 'es', 'ing', 'ed' );
|
||||
case 'de':
|
||||
return array( 'er', 'en', 'e' );
|
||||
default:
|
||||
return array();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ Contributors: mifupadev
|
|||
Tags: seo, ai, meta description, schema, llms.txt
|
||||
Requires at least: 6.0
|
||||
Tested up to: 6.9
|
||||
Stable tag: 1.1.0
|
||||
Stable tag: 1.2.0
|
||||
Requires PHP: 8.0
|
||||
License: GPL-2.0-or-later
|
||||
License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
||||
|
|
@ -20,15 +20,16 @@ All AI features are optional. Without an API key, the plugin falls back to local
|
|||
|
||||
= Learn more =
|
||||
|
||||
* Website: https://brezngeo.com/
|
||||
* FAQ: https://brezngeo.com/faq.html
|
||||
* Live demo: https://brezngeo.com/demo.html
|
||||
* Website: <a href="https://brezngeo.com/">brezngeo.com</a>
|
||||
* FAQ: <a href="https://brezngeo.com/faq.html">brezngeo.com/faq</a>
|
||||
* Live demo: <a href="https://brezngeo.com/demo.html">brezngeo.com/demo</a>
|
||||
|
||||
= At a glance =
|
||||
|
||||
* Generates AI meta descriptions automatically on publish — falls back to clean local extraction without any API key
|
||||
* Adds a GEO Quick Overview block to each post: AI-generated summary, key bullet points, optional FAQ
|
||||
* Suggests internal links while writing — text-based matching works without AI; optional AI upgrade for semantic ranking
|
||||
* Analyzes keyword usage in real time — checks title, headings, density, images, and more with locale-aware variant matching
|
||||
* Bulk-generates descriptions for all existing posts that have none
|
||||
* Adds Schema.org JSON-LD structured data for search engines and AI retrieval systems
|
||||
* Serves `/llms.txt` — a machine-readable content index for AI discovery tools
|
||||
|
|
@ -98,6 +99,10 @@ Block individual AI training and data-harvesting bots directly from the WordPres
|
|||
|
||||
Automatically logs visits from known AI bots. Stores the bot name, a SHA-256-hashed IP address, and the requested URL. Entries older than 90 days are purged automatically. A 30-day summary is shown on the plugin dashboard.
|
||||
|
||||
= Keyword Analysis =
|
||||
|
||||
A post editor meta box that analyzes keyword usage in real time. Enter a primary keyword and optional secondary keywords — the plugin checks title, headings, keyword density, image alt text, meta description, URL slug, first and last paragraph, image titles and captions, and excerpt. Each check reports pass, warning, or fail status with actionable feedback. Three update modes: live (debounced while typing), manual (button click), or on save. Optional AI features (when an API key is configured): keyword suggestions, content optimization tips, and semantic keyword analysis. Supports locale-aware keyword variant matching for English and German. Configurable via a dedicated settings page (target density, minimum occurrences, post types, debounce interval).
|
||||
|
||||
= Post Editor Integration =
|
||||
|
||||
A "Meta Description" meta box in the post and page editor shows the current description, its source (AI / Fallback / Manual), a live character counter, and a "Regenerate with AI" button. A sidebar SEO widget displays word count, reading time, heading structure, and link counts with live warnings.
|
||||
|
|
@ -189,35 +194,44 @@ The following features may send data to the selected AI provider:
|
|||
* **Meta Descriptions** — post title and content excerpt are sent to generate a meta description. Triggered on publish, on update, or via the Bulk Generator.
|
||||
* **GEO Block** — post title and content are sent to generate a Quick Overview block (summary, key points, optional FAQ). Triggered on publish/update or manually from the post editor.
|
||||
* **Internal Link Suggestions (AI upgrade)** — up to 20 pre-scored candidate link pairs (post titles and URLs) are sent for semantic ranking. Triggered manually, on save, or on a timed interval — all configurable by the user.
|
||||
* **Keyword Analysis (AI upgrade)** — post content and keyword are sent for AI-powered keyword suggestions, content optimization tips, and semantic keyword analysis. Triggered manually from the post editor meta box.
|
||||
|
||||
No data is transmitted during normal page loads or to visitors.
|
||||
|
||||
= OpenAI =
|
||||
* Data sent: Post title and content excerpt (meta descriptions, GEO Block); candidate post titles and URLs (link suggestions).
|
||||
* Data sent: Post title and content excerpt (meta descriptions, GEO Block); candidate post titles and URLs (link suggestions); post content and keyword (keyword analysis).
|
||||
* API endpoint: `https://api.openai.com/v1/`
|
||||
* Privacy policy: https://openai.com/policies/privacy-policy/
|
||||
* Terms of use: https://openai.com/policies/terms-of-use/
|
||||
|
||||
= Anthropic Claude =
|
||||
* Data sent: Post title and content excerpt (meta descriptions, GEO Block); candidate post titles and URLs (link suggestions).
|
||||
* Data sent: Post title and content excerpt (meta descriptions, GEO Block); candidate post titles and URLs (link suggestions); post content and keyword (keyword analysis).
|
||||
* API endpoint: `https://api.anthropic.com/`
|
||||
* Privacy policy: https://www.anthropic.com/privacy
|
||||
* Terms of use: https://www.anthropic.com/legal/consumer-terms
|
||||
|
||||
= Google Gemini =
|
||||
* Data sent: Post title and content excerpt (meta descriptions, GEO Block); candidate post titles and URLs (link suggestions).
|
||||
* Data sent: Post title and content excerpt (meta descriptions, GEO Block); candidate post titles and URLs (link suggestions); post content and keyword (keyword analysis).
|
||||
* API endpoint: `https://generativelanguage.googleapis.com/`
|
||||
* Privacy policy: https://policies.google.com/privacy
|
||||
* Terms of use: https://ai.google.dev/gemini-api/terms?hl=en
|
||||
|
||||
= xAI Grok =
|
||||
* Data sent: Post title and content excerpt (meta descriptions, GEO Block); candidate post titles and URLs (link suggestions).
|
||||
* Data sent: Post title and content excerpt (meta descriptions, GEO Block); candidate post titles and URLs (link suggestions); post content and keyword (keyword analysis).
|
||||
* API endpoint: `https://api.x.ai/`
|
||||
* Privacy policy: https://x.ai/privacy-policy
|
||||
* Terms of use: https://x.ai/legal/terms-of-service
|
||||
|
||||
== Changelog ==
|
||||
|
||||
= 1.2.0 =
|
||||
* New: Keyword Analysis meta box in the post editor — checks keyword usage across title, headings, density, image alts, meta description, slug, first/last paragraph, image title/caption, and excerpt.
|
||||
* New: Primary and secondary keyword support with configurable minimum occurrences.
|
||||
* New: Three analysis update modes: live (debounced), manual, and on-save.
|
||||
* New: Locale-aware keyword variant matching for English and German (compound words, suffixes).
|
||||
* New: Optional AI-powered keyword suggestions, content optimization tips, and semantic keyword analysis.
|
||||
* New: Keyword Analysis settings page with target density, minimum occurrences, post type selection, and debounce configuration.
|
||||
|
||||
= 1.1.0 =
|
||||
* Fixed Google Gemini API terms URL that caused too many redirects during WordPress.org review.
|
||||
* Improved input sanitization in Schema.org meta box — uses `map_deep()` with `sanitize_textarea_field` instead of relying on downstream sanitization with phpcs suppression.
|
||||
|
|
@ -246,6 +260,9 @@ No data is transmitted during normal page loads or to visitors.
|
|||
|
||||
== Upgrade Notice ==
|
||||
|
||||
= 1.2.0 =
|
||||
Adds Keyword Analysis: real-time keyword checks in the post editor with optional AI-powered suggestions.
|
||||
|
||||
= 1.1.0 =
|
||||
Fixes WordPress.org review issues: corrected Google Gemini terms URL and improved inline input sanitization.
|
||||
|
||||
|
|
|
|||
|
|
@ -3,4 +3,8 @@ if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
|
|||
exit;
|
||||
}
|
||||
delete_option( 'brezngeo_settings' );
|
||||
delete_option( 'brezngeo_keyword_settings' );
|
||||
delete_post_meta_by_key( '_brezngeo_meta_description' );
|
||||
delete_post_meta_by_key( '_brezngeo_keyword_main' );
|
||||
delete_post_meta_by_key( '_brezngeo_keyword_secondary' );
|
||||
delete_post_meta_by_key( '_brezngeo_keyword_results' );
|
||||
|
|
|
|||
Loading…
Reference in a new issue