'on'); $s = get_option(CITAMI_AEO_SETTINGS_OPT, array()); if (is_array($s) && isset($s['enabled']) && $s['enabled'] === 'off') { $d['enabled'] = 'off'; } return $d; } add_action('admin_init', function () { register_setting('citami_seo', CITAMI_AEO_SETTINGS_OPT, array( 'type' => 'array', 'sanitize_callback' => function ($v) { return array('enabled' => (is_array($v) && isset($v['enabled']) && $v['enabled'] === 'off') ? 'off' : 'on'); }, 'default' => array(), )); }); /* ---------------------------------------------------------------- */ /* Text analysis (pure, no WordPress state) */ /* ---------------------------------------------------------------- */ /** Block-editor comments and shortcodes out, tags kept. */ function citami_aeo_clean_html($html) { $html = preg_replace('//s', '', (string) $html); $html = preg_replace('/\[\/?[a-zA-Z0-9_\-]+[^\]]*\]/', '', $html); return $html; } function citami_aeo_plain($html) { // Block boundaries become spaces so "

" never glues two words. $html = preg_replace('#]*>#i', ' ', citami_aeo_clean_html($html)); $text = wp_strip_all_tags($html); $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); return trim(preg_replace('/\s+/u', ' ', $text)); } function citami_aeo_word_count($text) { return preg_match_all('/[\p{L}\p{N}][\p{L}\p{N}\'’\-]*/u', (string) $text); } /** Paragraph texts:

contents when present, blank-line blocks otherwise. */ function citami_aeo_paragraphs($html) { $html = citami_aeo_clean_html($html); $out = array(); if (preg_match_all('#]*>(.*?)

#is', $html, $m)) { foreach ($m[1] as $p) { $t = citami_aeo_plain($p); if ($t !== '') { $out[] = $t; } } return $out; } // Headings, lists and tables are not paragraphs. $html = preg_replace('#<(h[1-6]|ul|ol|table|blockquote|figure|pre)\b[^>]*>.*?#is', "\n\n", $html); foreach (preg_split('/\n\s*\n/', $html) as $chunk) { $t = citami_aeo_plain($chunk); if ($t !== '') { $out[] = $t; } } return $out; } /** [[level, text], ...] */ function citami_aeo_headings($html) { $out = array(); if (preg_match_all('#]*>(.*?)#is', citami_aeo_clean_html($html), $m, PREG_SET_ORDER)) { foreach ($m as $h) { $t = citami_aeo_plain($h[2]); if ($t !== '') { $out[] = array((int) $h[1], $t); } } } return $out; } /** Numbers an engine can quote: units, percentages, prices, years, decimals. */ function citami_aeo_fact_count($text) { $units = 'k?m|cm|mm|km|kg|g|mg|l|ml|h|min|sec|mq|m²|m2|kw|w|kwh|gb|mb|tb|%|€|\$|£|eur|usd|ore|ora|anni|anno|giorni|giorno|mesi|mese|settimane|settimana|minuti|secondi|persone|volte|years?|days?|months?|weeks?|hours?|minutes?|seconds?|people|times|pcs|pz'; $n = 0; $n += preg_match_all('/(? n, 'external' => n] for in the content. */ function citami_aeo_links($html, $home) { $out = array('internal' => 0, 'external' => 0); $home_host = strtolower((string) wp_parse_url((string) $home, PHP_URL_HOST)); if (!preg_match_all('/]*\bhref\s*=\s*["\']([^"\']+)["\']/i', citami_aeo_clean_html($html), $m)) { return $out; } foreach ($m[1] as $href) { $href = trim($href); if ($href === '' || $href[0] === '#' || preg_match('/^(mailto|tel|javascript):/i', $href)) { continue; } if (preg_match('#^https?://#i', $href)) { $host = strtolower((string) wp_parse_url($href, PHP_URL_HOST)); $host = preg_replace('/^www\./', '', $host); $mine = preg_replace('/^www\./', '', $home_host); if ($host === $mine) { $out['internal']++; } else { $out['external']++; } } else { $out['internal']++; } } return $out; } /** ['total' => n, 'missing_alt' => n] */ function citami_aeo_images($html) { $out = array('total' => 0, 'missing_alt' => 0); if (!preg_match_all('/]*>/i', citami_aeo_clean_html($html), $m)) { return $out; } foreach ($m[0] as $tag) { $out['total']++; if (!preg_match('/\balt\s*=\s*["\']([^"\']*)["\']/i', $tag, $a) || trim($a[1]) === '') { $out['missing_alt']++; } } return $out; } function citami_aeo_is_question($title) { $t = mb_strtolower(trim((string) $title)); if ($t === '') { return false; } if (mb_substr($t, -1) === '?') { return true; } $words = 'come|cosa|cos\'è|cos’è|quanto|quanta|quanti|quante|perché|perche|quando|dove|chi|quale|quali|conviene|meglio|differenza|guida|how|what|why|when|where|who|which|should|can|does|is|are|guide|vs|versus|best|difference'; return (bool) preg_match('/^(' . $words . ')\b/u', $t) || (bool) preg_match('/\b(vs|versus|differenza|difference)\b/u', $t); } /* ---------------------------------------------------------------- */ /* Checks */ /* ---------------------------------------------------------------- */ /** * Weights sum to 100. Areas: answer (35), structure (25), trust (25), * technical (15). warn earns half, na drops the weight from the base. */ function citami_aeo_weights() { return array( 'answer_first' => 14, 'title_query' => 8, 'description' => 7, 'faq' => 6, 'headings' => 8, 'paragraphs' => 6, 'lists_tables' => 5, 'length' => 6, 'facts' => 8, 'author' => 7, 'freshness' => 5, 'links' => 5, 'images_alt' => 4, 'schema' => 5, 'entity' => 3, 'indexable' => 3, ); } function citami_aeo_areas() { return array( 'answer' => array('label' => citami_t('Risposta', 'Answer'), 'checks' => array('answer_first', 'title_query', 'description', 'faq')), 'structure' => array('label' => citami_t('Struttura', 'Structure'), 'checks' => array('headings', 'paragraphs', 'lists_tables', 'length')), 'trust' => array('label' => citami_t('Fiducia', 'Trust'), 'checks' => array('facts', 'author', 'freshness', 'links')), 'technical' => array('label' => citami_t('Tecnico', 'Technical'), 'checks' => array('images_alt', 'schema', 'entity', 'indexable')), ); } /** Labels + hints, bilingual. Hints say what to do, never invent content. */ function citami_aeo_copy($id) { $c = array( 'answer_first' => array(citami_t('Risposta breve in cima', 'Short answer at the top'), citami_t('Scrivi 1–2 frasi che rispondono alla domanda della pagina nel campo «Risposta breve».', 'Write 1–2 sentences that answer the page\'s question in the "Short answer" field.')), 'title_query' => array(citami_t('Titolo con la domanda del cliente', 'Title with the customer\'s question'), citami_t('Metti nel titolo la domanda a cui risponde la pagina (Come…, Quanto costa…, X o Y), 4–14 parole.', 'Put the question the page answers in the title (How…, How much…, X or Y), 4–14 words.')), 'description' => array(citami_t('Descrizione per i risultati di ricerca', 'Search-result description'), citami_t('Scrivi 50–160 caratteri che anticipano la risposta nel campo «Descrizione».', 'Write 50–160 characters that preview the answer in the "Description" field.')), 'faq' => array(citami_t('Domande dei clienti con risposta', 'Customer questions with answers'), citami_t('Aggiungi 2–3 domande che i clienti fanno davvero, con una risposta breve.', 'Add 2–3 questions customers really ask, each with a short answer.')), 'headings' => array(citami_t('Sottotitoli ogni 250–350 parole', 'Subheadings every 250–350 words'), citami_t('Aggiungi un sottotitolo ogni 250–350 parole, meglio se è una domanda.', 'Add a subheading every 250–350 words, ideally phrased as a question.')), 'paragraphs' => array(citami_t('Paragrafi corti', 'Short paragraphs'), citami_t('Spezza i paragrafi sopra le 90 parole: i motori estraggono blocchi brevi.', 'Split paragraphs over 90 words: engines extract short blocks.')), 'lists_tables' => array(citami_t('Elenchi o tabelle', 'Lists or tables'), citami_t('Metti passaggi, confronti o prezzi in un elenco o in una tabella.', 'Put steps, comparisons or prices in a list or a table.')), 'length' => array(citami_t('Testo abbastanza lungo', 'Enough text'), citami_t('Sotto le 300 parole la pagina viene citata di rado: completa l\'argomento.', 'Under 300 words the page is rarely cited: cover the topic.')), 'facts' => array(citami_t('Numeri concreti', 'Concrete numbers'), citami_t('Aggiungi prezzi, tempi, quantità, anni: solo dati veri.', 'Add prices, times, quantities, years: real data only.')), 'author' => array(citami_t('Autore con nome e qualifica', 'Author with name and job title'), citami_t('Assegna un autore reale e compila la qualifica nel suo profilo (Utenti → Profilo).', 'Assign a real author and fill in the job title in their profile (Users → Profile).')), 'freshness' => array(citami_t('Aggiornata di recente', 'Recently updated'), citami_t('Rivedi il testo e salva: le pagine ferme da oltre un anno pesano meno.', 'Review the text and save: pages untouched for over a year carry less weight.')), 'links' => array(citami_t('Link ad altre pagine e a una fonte', 'Links to other pages and to a source'), citami_t('Collega almeno una pagina del sito e una fonte esterna affidabile.', 'Link at least one page of the site and one reliable external source.')), 'images_alt' => array(citami_t('Immagini descritte', 'Images described'), citami_t('Descrivi ogni immagine nel campo «testo alternativo».', 'Describe every image in its "alt text" field.')), 'schema' => array(citami_t('Dati strutturati attivi', 'Structured data on'), citami_t('Lascia attivi i dati strutturati con un tipo coerente (Article, WebPage, Product).', 'Keep structured data on with a coherent type (Article, WebPage, Product).')), 'entity' => array(citami_t('Nome dell\'azienda nel testo', 'Business name in the text'), citami_t('Scrivi il nome dell\'azienda nel testo e nella scheda Brand del plugin.', 'Write the business name in the text and in the plugin\'s Brand tab.')), 'indexable' => array(citami_t('Visibile ai motori', 'Visible to the engines'), citami_t('La pagina è noindex: i motori AI che usano un indice non la vedono.', 'The page is noindex: AI engines that use an index will not see it.')), ); return isset($c[$id]) ? $c[$id] : array($id, ''); } /** * Pure analysis. $in keys (all optional): * title, content (HTML), description, tldr, faq (array of [q,a]), * post_type, author_name, author_job, modified (mysql/ISO or ''), * robots_index, schema_off (bool), schema_type, entity_name, home_url. * Returns score, grade, blocked, checks[], areas[]. */ function citami_aeo_analyse(array $in) { $d = array('title' => '', 'content' => '', 'description' => '', 'tldr' => '', 'faq' => array(), 'post_type' => 'post', 'author_name' => '', 'author_job' => '', 'modified' => '', 'robots_index' => '', 'schema_off' => false, 'schema_type' => 'Article', 'entity_name' => '', 'home_url' => home_url('/')); $in = array_merge($d, $in); $is_post = $in['post_type'] === 'post'; $text = citami_aeo_plain($in['content']); $words = citami_aeo_word_count($text); $paras = citami_aeo_paragraphs($in['content']); $heads = citami_aeo_headings($in['content']); $r = array(); // Answer first: TL;DR, or a lead paragraph that already reads as an answer. $tldr = trim((string) $in['tldr']); $tldr_words = citami_aeo_word_count($tldr); $lead_words = !empty($paras) ? citami_aeo_word_count($paras[0]) : 0; if ($tldr_words >= 8 && $tldr_words <= 90) { $r['answer_first'] = array('pass', sprintf(citami_t('Risposta breve di %d parole', 'Short answer of %d words'), $tldr_words)); } elseif ($tldr_words > 90) { $r['answer_first'] = array('warn', citami_t('Risposta breve troppo lunga: tienila sotto le 90 parole', 'Short answer too long: keep it under 90 words')); } elseif ($lead_words >= 25 && $lead_words <= 80) { $r['answer_first'] = array('warn', citami_t('Primo paragrafo di misura giusta ma nessuna risposta breve dichiarata', 'Lead paragraph the right size but no stated short answer')); } else { $r['answer_first'] = array('fail', citami_t('Nessuna risposta breve in cima', 'No short answer at the top')); } // Title. $title = trim((string) $in['title']); $tw = citami_aeo_word_count($title); if ($tw === 0) { $r['title_query'] = array('fail', citami_t('Titolo mancante', 'Missing title')); } elseif ($tw < 3 || $tw > 18) { $r['title_query'] = array('fail', sprintf(citami_t('%d parole: troppo %s', '%d words: too %s'), $tw, $tw < 3 ? citami_t('corto', 'short') : citami_t('lungo', 'long'))); } elseif ($tw <= 14 && (citami_aeo_is_question($title) || preg_match('/\d/', $title))) { $r['title_query'] = array('pass', citami_t('Titolo che ripete una domanda o un dato', 'Title repeats a query or a figure')); } elseif ($tw >= 4 && $tw <= 14) { $r['title_query'] = array('warn', citami_t('Lunghezza giusta ma non suona come una domanda', 'Right length but does not read as a query')); } else { $r['title_query'] = array('warn', sprintf(citami_t('%d parole: punta a 4–14', '%d words: aim for 4–14'), $tw)); } // Meta description. $desc = trim((string) $in['description']); $dl = mb_strlen($desc); if ($dl === 0) { $r['description'] = array('warn', citami_t('Vuota: viene usato il riassunto automatico', 'Empty: the automatic excerpt is used')); } elseif ($dl < 50 || $dl > 160) { $r['description'] = array('warn', sprintf(citami_t('%d caratteri: punta a 50–160', '%d characters: aim for 50–160'), $dl)); } else { $r['description'] = array('pass', sprintf(citami_t('%d caratteri', '%d characters'), $dl)); } // FAQ — ours plus the ones the page already has (Yoast / Rank Math block, or a "FAQ" section in the text). $faq = 0; $seen_q = array(); $all_faq = (array) $in['faq']; if (function_exists('citami_content_existing_faq')) { $all_faq = array_merge($all_faq, citami_content_existing_faq((string) $in['content'])['items']); } foreach ($all_faq as $it) { if (!is_array($it) || trim((string) (isset($it['q']) ? $it['q'] : '')) === '' || trim((string) (isset($it['a']) ? $it['a'] : '')) === '') { continue; } $k = function_exists('citami_content_norm_q') ? citami_content_norm_q($it['q']) : mb_strtolower(trim((string) $it['q'])); if (isset($seen_q[$k])) continue; $seen_q[$k] = true; $faq++; } if ($faq >= 2) { $r['faq'] = array('pass', sprintf(citami_t('%d domande', '%d questions'), $faq)); } elseif ($faq === 1) { $r['faq'] = array('warn', citami_t('Una sola domanda', 'Only one question')); } else { $r['faq'] = array($is_post ? 'fail' : 'warn', citami_t('Nessuna domanda con risposta', 'No question with an answer')); } // Headings. $subs = 0; foreach ($heads as $h) { if ($h[0] >= 2 && $h[0] <= 3) { $subs++; } } if ($words < 200 && $subs === 0) { $r['headings'] = array('na', citami_t('Testo breve: non servono', 'Short text: not needed')); } elseif ($subs === 0) { $r['headings'] = array('fail', citami_t('Nessun sottotitolo', 'No subheading')); } elseif ($words / $subs > 400) { $r['headings'] = array('warn', sprintf(citami_t('%d sottotitoli per %d parole: pochi', '%d subheadings for %d words: too few'), $subs, $words)); } else { $r['headings'] = array('pass', sprintf(citami_t('%d sottotitoli', '%d subheadings'), $subs)); } // Paragraphs. if (empty($paras)) { $r['paragraphs'] = array($words === 0 ? 'fail' : 'warn', citami_t('Nessun paragrafo riconoscibile', 'No recognisable paragraph')); } else { $max = 0; $sum = 0; foreach ($paras as $p) { $n = citami_aeo_word_count($p); $sum += $n; $max = max($max, $n); } $avg = $sum / count($paras); if ($max > 250) { $r['paragraphs'] = array('fail', sprintf(citami_t('Un paragrafo di %d parole', 'A %d-word paragraph'), $max)); } elseif ($max > 160 || $avg > 90) { $r['paragraphs'] = array('warn', sprintf(citami_t('Media %d parole, massimo %d', 'Average %d words, longest %d'), (int) round($avg), $max)); } else { $r['paragraphs'] = array('pass', sprintf(citami_t('Media %d parole', 'Average %d words'), (int) round($avg))); } } // Lists / tables. $structures = preg_match_all('/<(ul|ol|table)\b/i', citami_aeo_clean_html($in['content'])); if ($structures > 0) { $r['lists_tables'] = array('pass', sprintf(citami_t('%d liste/tabelle', '%d lists/tables'), $structures)); } elseif ($words < 300) { $r['lists_tables'] = array('na', citami_t('Testo breve', 'Short text')); } else { $r['lists_tables'] = array('warn', citami_t('Solo prosa', 'Prose only')); } // Length. $min = $is_post ? array(300, 150) : array(150, 60); if ($words >= $min[0]) { $r['length'] = array('pass', sprintf(citami_t('%d parole', '%d words'), $words)); } elseif ($words >= $min[1]) { $r['length'] = array('warn', sprintf(citami_t('%d parole: punta a %d+', '%d words: aim for %d+'), $words, $min[0])); } else { $r['length'] = array('fail', sprintf(citami_t('%d parole', '%d words'), $words)); } // Facts. $facts = citami_aeo_fact_count($text . ' ' . $tldr); $needed = max(3, (int) ceil($words / 300)); if ($facts >= $needed) { $r['facts'] = array('pass', sprintf(citami_t('%d dati numerici', '%d numeric facts'), $facts)); } elseif ($facts >= 1) { $r['facts'] = array('warn', sprintf(citami_t('%d dati numerici: puntane almeno %d', '%d numeric facts: aim for at least %d'), $facts, $needed)); } else { $r['facts'] = array('fail', citami_t('Nessun numero verificabile', 'No verifiable number')); } // Author (posts only). if (!$is_post) { $r['author'] = array('na', citami_t('Non richiesto per le pagine', 'Not required for pages')); } elseif (trim((string) $in['author_name']) === '') { $r['author'] = array('fail', citami_t('Nessun autore', 'No author')); } elseif (trim((string) $in['author_job']) === '') { $r['author'] = array('warn', sprintf(citami_t('%s senza qualifica', '%s without job title'), $in['author_name'])); } else { $r['author'] = array('pass', $in['author_name'] . ' · ' . $in['author_job']); } // Freshness. $ts = $in['modified'] !== '' ? strtotime((string) $in['modified']) : false; if (!$ts) { $r['freshness'] = array('pass', citami_t('Nuovo', 'New')); } else { $months = (int) floor((time() - $ts) / (30 * DAY_IN_SECONDS)); if ($months < 12) { $r['freshness'] = array('pass', sprintf(citami_t('Aggiornato %d mesi fa', 'Updated %d months ago'), $months)); } elseif ($months < 24) { $r['freshness'] = array('warn', sprintf(citami_t('Aggiornato %d mesi fa', 'Updated %d months ago'), $months)); } else { $r['freshness'] = array('fail', sprintf(citami_t('Aggiornato %d mesi fa', 'Updated %d months ago'), $months)); } } // Links. $links = citami_aeo_links($in['content'], $in['home_url']); if ($links['internal'] > 0 && $links['external'] > 0) { $r['links'] = array('pass', sprintf(citami_t('%d interni, %d esterni', '%d internal, %d external'), $links['internal'], $links['external'])); } elseif ($links['internal'] > 0 || $links['external'] > 0) { $r['links'] = array('warn', $links['internal'] > 0 ? citami_t('Solo link interni', 'Internal links only') : citami_t('Solo link esterni', 'External links only')); } elseif ($words < 150) { $r['links'] = array('na', citami_t('Testo breve', 'Short text')); } else { $r['links'] = array('fail', citami_t('Nessun link', 'No links')); } // Images. $img = citami_aeo_images($in['content']); if ($img['total'] === 0) { $r['images_alt'] = array('na', citami_t('Nessuna immagine', 'No images')); } elseif ($img['missing_alt'] === 0) { $r['images_alt'] = array('pass', sprintf(citami_t('%d immagini con alt', '%d images with alt'), $img['total'])); } elseif ($img['missing_alt'] < $img['total']) { $r['images_alt'] = array('warn', sprintf(citami_t('%d su %d senza alt', '%d of %d without alt'), $img['missing_alt'], $img['total'])); } else { $r['images_alt'] = array('fail', citami_t('Nessuna immagine ha l\'alt', 'No image has alt text')); } // Schema. if (!empty($in['schema_off']) || (string) $in['schema_type'] === 'None') { $r['schema'] = array('fail', citami_t('Schema disattivato su questa pagina', 'Schema off on this page')); } else { $r['schema'] = array('pass', (string) $in['schema_type']); } // Entity. $entity = trim((string) $in['entity_name']); if ($entity === '') { $r['entity'] = array('warn', citami_t('Nome entità non configurato', 'Entity name not set')); } elseif (mb_stripos($text . ' ' . $title . ' ' . $tldr, $entity) !== false) { $r['entity'] = array('pass', $entity); } else { $r['entity'] = array('warn', sprintf(citami_t('"%s" non compare nel testo', '"%s" does not appear in the text'), $entity)); } // Indexable. $blocked = (string) $in['robots_index'] === 'noindex'; $r['indexable'] = $blocked ? array('fail', 'noindex') : array('pass', 'index'); // Score. $weights = citami_aeo_weights(); $earned = 0; $base = 0; $checks = array(); foreach ($weights as $id => $w) { $status = $r[$id][0]; if ($status !== 'na') { $base += $w; $earned += $status === 'pass' ? $w : ($status === 'warn' ? $w / 2 : 0); } $copy = citami_aeo_copy($id); $checks[] = array('id' => $id, 'status' => $status, 'weight' => $w, 'label' => $copy[0], 'detail' => $r[$id][1], 'hint' => $status === 'pass' || $status === 'na' ? '' : $copy[1]); } $score = $base > 0 ? (int) round($earned / $base * 100) : 0; $areas = array(); foreach (citami_aeo_areas() as $key => $a) { $ae = 0; $ab = 0; foreach ($a['checks'] as $id) { if ($r[$id][0] === 'na') { continue; } $ab += $weights[$id]; $ae += $r[$id][0] === 'pass' ? $weights[$id] : ($r[$id][0] === 'warn' ? $weights[$id] / 2 : 0); } $areas[] = array('id' => $key, 'label' => $a['label'], 'score' => $ab > 0 ? (int) round($ae / $ab * 100) : null, 'checks' => $a['checks']); } return array( 'score' => $score, 'grade' => $score >= 80 ? 'good' : ($score >= 55 ? 'ok' : 'poor'), 'blocked' => $blocked, 'words' => $words, 'checks' => $checks, 'areas' => $areas, ); } /* ---------------------------------------------------------------- */ /* Post → input */ /* ---------------------------------------------------------------- */ /** Builds the analysis input from a stored post, with optional live overrides. */ function citami_aeo_input_for_post($post, array $override = array()) { $pid = (int) $post->ID; $seo = citami_seo_get_post_fields($pid); $author_id = isset($post->post_author) ? (int) $post->post_author : 0; $in = array( 'title' => isset($post->post_title) ? (string) $post->post_title : '', 'content' => citami_post_content_html($post), 'description' => $seo['description'], 'tldr' => function_exists('citami_content_tldr') ? citami_content_tldr($pid) : '', 'faq' => function_exists('citami_content_faq') ? citami_content_faq($pid) : array(), 'post_type' => (string) $post->post_type, 'author_name' => $author_id > 0 ? (string) get_the_author_meta('display_name', $author_id) : '', 'author_job' => $author_id > 0 ? (string) get_user_meta($author_id, 'citami_job_title', true) : '', 'modified' => !empty($post->post_modified_gmt) && $post->post_modified_gmt !== '0000-00-00 00:00:00' ? $post->post_modified_gmt : (string) (isset($post->post_modified) ? $post->post_modified : ''), 'robots_index' => $seo['robots_index'], 'schema_off' => (string) get_post_meta($pid, CITAMI_META_SCHEMA_OFF, true) === '1', 'schema_type' => citami_schema_type_for_post($post), 'entity_name' => citami_schema_settings()['entity']['name'], 'home_url' => home_url('/'), ); foreach (array('title', 'content', 'description', 'tldr', 'robots_index') as $k) { if (array_key_exists($k, $override)) { $in[$k] = (string) $override[$k]; } } if (array_key_exists('faq', $override) && is_array($override['faq'])) { $in['faq'] = $override['faq']; } if (array_key_exists('schema_off', $override)) { $in['schema_off'] = !empty($override['schema_off']); } if (array_key_exists('schema_type', $override)) { $type = preg_replace('/[^A-Za-z]/', '', (string) $override['schema_type']); $in['schema_type'] = $type === '' ? citami_schema_type_for_post($post) : $type; } if (array_key_exists('modified', $override)) { $in['modified'] = (string) $override['modified']; } return $in; } function citami_aeo_score_post($post_id) { $post = get_post($post_id); if (!$post) { return null; } return citami_aeo_analyse(citami_aeo_input_for_post($post)); } /** save_post (after the SEO meta box at 10): store the number for the list. */ function citami_aeo_store_on_save($post_id) { if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) { return; } if (function_exists('wp_is_post_revision') && wp_is_post_revision($post_id)) { return; } $post = get_post($post_id); if (!$post || !in_array($post->post_type, citami_seo_post_types(), true)) { return; } $res = citami_aeo_analyse(citami_aeo_input_for_post($post, array('modified' => ''))); update_post_meta($post_id, CITAMI_META_AEO_SCORE, (string) $res['score']); update_post_meta($post_id, CITAMI_META_AEO_CHECKED, (string) time()); } add_action('save_post', 'citami_aeo_store_on_save', 20, 1); /** * 2.56.1 — scores the published pages that were never saved since the * plugin arrived (a fresh install read "over 0 scored pages"). Runs in * the admin at most once an hour, 25 pages at a time; the content-health * cache is dropped so the overview shows the new average right away. */ function citami_aeo_backfill($limit = 25) { if (!function_exists('get_posts') || !function_exists('citami_seo_post_types')) { return 0; } $ids = get_posts(array( 'post_type' => citami_seo_post_types(), 'post_status' => 'publish', 'numberposts' => (int) $limit, 'fields' => 'ids', 'no_found_rows' => true, 'meta_query' => array(array('key' => CITAMI_META_AEO_SCORE, 'compare' => 'NOT EXISTS')), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- capped at 25 ids, at most once an hour )); foreach ((array) $ids as $id) { citami_aeo_store_on_save((int) $id); } if (!empty($ids) && defined('CITAMI_HEALTH_TRANSIENT')) { delete_transient(CITAMI_HEALTH_TRANSIENT); } return count((array) $ids); } function citami_aeo_backfill_maybe() { if (!current_user_can('edit_posts') || get_transient('citami_aeo_backfill')) { return; } set_transient('citami_aeo_backfill', 1, HOUR_IN_SECONDS); citami_aeo_backfill(25); } add_action('admin_init', 'citami_aeo_backfill_maybe'); /* ---------------------------------------------------------------- */ /* REST: live score while editing (logged-in editors, cookie nonce) */ /* ---------------------------------------------------------------- */ function citami_rest_aeo_score(WP_REST_Request $request) { $body = $request->get_json_params(); if (!is_array($body)) { $body = array(); } $post_id = isset($body['post_id']) ? (int) $body['post_id'] : 0; $post = $post_id > 0 ? get_post($post_id) : null; if (!$post) { return new WP_Error('citami_aeo_post', 'post_id required', array('status' => 400)); } if (!current_user_can('edit_post', $post_id)) { return new WP_Error('citami_aeo_forbidden', 'forbidden', array('status' => 403)); } $override = array('modified' => ''); foreach (array('title', 'content', 'description', 'tldr', 'robots_index', 'schema_type') as $k) { if (isset($body[$k]) && is_string($body[$k])) { $override[$k] = $body[$k]; } } if (isset($body['schema_off'])) { $override['schema_off'] = !empty($body['schema_off']); } if (isset($body['faq']) && is_array($body['faq'])) { $faq = array(); foreach ($body['faq'] as $it) { if (is_array($it)) { $faq[] = array('q' => (string) (isset($it['q']) ? $it['q'] : ''), 'a' => (string) (isset($it['a']) ? $it['a'] : '')); } } $override['faq'] = $faq; } $res = citami_aeo_analyse(citami_aeo_input_for_post($post, $override)); // 2.49 — the sidebar shows the same to-do list as the box. $td = citami_aeo_todos_for_post($post, $res['checks']); $res['todos'] = array_values(array_map(function ($t) { return array( 'id' => $t['id'], 'kind' => $t['kind'], 'status' => $t['status'], 'weight' => $t['weight'], 'sentence' => $t['sentence'], 'short' => $t['short'], 'buttons' => array_values(array_map(function ($b) { return array( 'label' => $b['label'], 'primary' => !empty($b['primary']), 'draft' => isset($b['draft']) ? $b['draft'] : '', 'href' => isset($b['draft']) ? '' : (string) $b['href'], 'data' => $b['data'], ); }, $t['buttons'])), ); }, $td['todos'])); $res['pick'] = $td['pick']; $res['quota_level'] = $td['ctx']['quota_level']; $res['quota'] = $td['ctx']['quota']; return rest_ensure_response($res); } add_action('rest_api_init', function () { register_rest_route('citami/v1', '/aeo-score', array( 'methods' => 'POST', 'callback' => 'citami_rest_aeo_score', 'permission_callback' => function () { return current_user_can('edit_posts'); }, )); }); /* ---------------------------------------------------------------- */ /* Editor panel (top of the SEO meta box) + inline JS */ /* ---------------------------------------------------------------- */ function citami_aeo_grade_color($grade) { return $grade === 'good' ? '#15803d' : ($grade === 'ok' ? '#b45309' : '#b91c1c'); } define('CITAMI_EDITOR_NOTICE', 'citami_editor_notice_'); /** * 2.49 — What the box asks the author to do, per failing check, in plain * words and with the button that does it. 'kind' is 'action' when there * is a button (the box shows at most three of these) and 'text' when the * fix lives in the body text (shown as one line: "Nel testo: …"). * Returns array(kind, sentence, short, buttons[]); buttons are * array(label, href|null, data attributes). */ function citami_aeo_todo($id, $post, array $ctx) { $btn = function ($label, $href = null, array $data = array(), $primary = false) { return array('label' => $label, 'href' => $href, 'data' => $data, 'primary' => $primary); }; $draft_btn = function ($label, $kind) use ($post, $btn) { $b = $btn($label, citami_aeo_draft_url($post->ID, 'generate') . '&kind=' . $kind); $b['draft'] = $kind; return $b; }; $draft = !empty($ctx['draft_ok']) && function_exists('citami_aeo_draft_url'); $ctx += array('quota' => '', 'quota_level' => ''); switch ($id) { case 'answer_first': $b = array($btn(citami_t('Scrivila', 'Write it'), null, array('focus' => '#citami_tldr'), true)); if ($draft) $b[] = $draft_btn(citami_t('Falla scrivere alla piattaforma', 'Have the platform write it'), 'tldr'); return array('action', citami_t('Serve una risposta breve in cima: 1–2 frasi che rispondono alla domanda della pagina.', 'It needs a short answer at the top: 1–2 sentences that answer the page\'s question.'), '', $b); case 'faq': $b = array($btn(citami_t('Aggiungi una domanda', 'Add a question'), null, array('click' => '.citami-faq-add'), true)); if ($draft) $b[] = $draft_btn(citami_t('Falle scrivere alla piattaforma', 'Have the platform write them'), 'faq'); return array('action', citami_t('Servono 2–3 domande che i clienti fanno davvero, con una risposta breve.', 'It needs 2–3 questions customers really ask, each with a short answer.'), '', $b); case 'description': return array('action', citami_t('Serve la descrizione per i risultati di ricerca: 50–160 caratteri che anticipano la risposta.', 'It needs the search-result description: 50–160 characters that preview the answer.'), '', array($btn(citami_t('Scrivila', 'Write it'), null, array('focus' => '#citami_seo_description'), true))); case 'title_query': return array('action', citami_t('Il titolo non contiene la domanda del cliente (Come…, Quanto costa…, X o Y).', 'The title does not carry the customer\'s question (How…, How much…, X or Y).'), '', array($btn(citami_t('Cambia il titolo SEO', 'Change the SEO title'), null, array('focus' => '#citami_seo_title'), true))); case 'links': if (!empty($ctx['has_internal'])) { // Internal links are there: what is missing is the external source, and that is written in the text. return array('text', '', citami_t('un link a una fonte esterna affidabile', 'a link to a reliable external source'), array()); } if (empty($ctx['link_editable'])) { return array('text', '', citami_t('collega un\'altra pagina del sito', 'link another page of the site'), array()); } if (!empty($ctx['link'])) { $l = $ctx['link']; return array('action', citami_t('Non collega nessun\'altra pagina del sito.', 'It links no other page of the site.'), '', array($btn(sprintf(citami_t('Inserisci il link «%1$s» → %2$s', 'Insert the link "%1$s" → %2$s'), $l['anchor'], $l['title']), citami_links_editor_insert_url($post->ID, $l['id'], $l['anchor']), array(), true))); } return array('action', citami_t('Non collega nessun\'altra pagina del sito.', 'It links no other page of the site.'), '', array($btn(citami_t('Vedi le pagine da collegare', 'See the pages to link'), null, array('open' => '.citami-links')))); case 'indexable': return array('action', citami_t('La pagina è nascosta ai motori (noindex).', 'The page is hidden from the engines (noindex).'), '', array($btn(citami_t('Rendila visibile', 'Make it visible'), null, array('set' => 'citami_seo[robots_index]', 'value' => ''), true))); case 'schema': return array('action', citami_t('I dati strutturati sono spenti su questa pagina.', 'Structured data is off on this page.'), '', array($btn(citami_t('Riaccendili', 'Turn them back on'), null, array('uncheck' => 'citami_seo[schema_off]'), true))); case 'author': $url = function_exists('get_edit_user_link') && !empty($post->post_author) ? get_edit_user_link((int) $post->post_author) : admin_url('profile.php'); return array('action', citami_t('Manca un autore con nome e qualifica.', 'It lacks an author with a name and a job title.'), '', array($btn(citami_t('Apri il profilo dell\'autore', 'Open the author profile'), $url ? $url : admin_url('profile.php')))); case 'images_alt': return array('action', citami_t('Alcune immagini non hanno una descrizione (testo alternativo).', 'Some images have no description (alt text).'), '', array($btn(citami_t('Apri la libreria media', 'Open the media library'), admin_url('upload.php?mode=list')))); case 'headings': return array('text', '', citami_t('un sottotitolo ogni 250–350 parole', 'a subheading every 250–350 words'), array()); case 'paragraphs': return array('text', '', citami_t('paragrafi più corti', 'shorter paragraphs'), array()); case 'lists_tables': return array('text', '', citami_t('un elenco o una tabella', 'a list or a table'), array()); case 'length': return array('text', '', citami_t('più testo (almeno 300 parole)', 'more text (at least 300 words)'), array()); case 'facts': return array('text', '', citami_t('numeri concreti', 'concrete numbers'), array()); case 'freshness': return array('text', '', citami_t('rivedila e salva: è ferma da oltre un anno', 'review it and save: untouched for over a year'), array()); case 'entity': return array('text', '', citami_t('il nome dell\'azienda nel testo', 'the business name in the text'), array()); } return array('text', '', '', array()); } /** Picks what the box shows: up to three actions and up to three text items, heaviest failing checks first. */ function citami_aeo_pick_todos(array $checks, $max_actions = 3, $max_text = 3) { $open = array_filter($checks, function ($c) { return $c['status'] === 'fail' || $c['status'] === 'warn'; }); usort($open, function ($a, $b) { $sa = $a['status'] === 'fail' ? 1 : 0; $sb = $b['status'] === 'fail' ? 1 : 0; return ($b['weight'] * (1 + $sb)) <=> ($a['weight'] * (1 + $sa)); }); $actions = array(); $text = array(); foreach ($open as $c) { if ($c['kind'] === 'action' && count($actions) < $max_actions) $actions[] = $c['id']; elseif ($c['kind'] === 'text' && count($text) < $max_text) $text[] = $c['id']; } return array('actions' => $actions, 'text' => $text); } /** * The to-do list of a page from its checks: sentence, kind and buttons per * check, plus which ones the box shows. Shared by the meta box and the * REST endpoint the block-editor sidebar reads. */ function citami_aeo_todos_for_post($post, array $checks) { // Context for the buttons: platform drafts available? a link the plugin can write now? $ctx = array('draft_ok' => false, 'link_editable' => false, 'link' => null, 'quota' => '', 'quota_level' => ''); if (citami_get_api_key() !== '') { $u = function_exists('citami_usage') ? citami_usage() : null; $q = $u && isset($u['aeo_draft']) ? $u['aeo_draft'] : null; $ctx['draft_ok'] = !($q && $q['level'] === 'exhausted'); if ($q) { $ctx['quota_level'] = (string) $q['level']; $ctx['quota'] = $q['level'] === 'exhausted' ? sprintf(citami_t('Bozze AI finite per questo mese (%1$d su %2$d): riprendono il %3$s.', 'AI drafts used up for this month (%1$d of %2$d): back on %3$s.'), (int) $q['used'], (int) $q['quota'], wp_date('j F', (int) $u['resets_at'])) : sprintf(citami_t('%1$d di %2$d bozze usate questo mese.', '%1$d of %2$d drafts used this month.'), (int) $q['used'], (int) $q['quota']); } } $ctx['has_internal'] = false; if (function_exists('citami_post_content_html')) { $counts = citami_aeo_links(citami_post_content_html($post), home_url('/')); $ctx['has_internal'] = !empty($counts['internal']); } if (function_exists('citami_links_post_editable') && citami_links_post_editable($post->ID)) { $ctx['link_editable'] = true; $sugg = citami_links_suggest($post->ID, citami_post_content_html($post), 1); $ctx['link'] = !empty($sugg) ? $sugg[0] : null; } $todos = array(); foreach ($checks as $c) { list($kind, $sentence, $short, $buttons) = citami_aeo_todo($c['id'], $post, $ctx); $todos[$c['id']] = array('id' => $c['id'], 'status' => $c['status'], 'weight' => $c['weight'], 'kind' => $kind, 'sentence' => $sentence, 'short' => $short, 'buttons' => $buttons); } $pick = citami_aeo_pick_todos($todos); return array('todos' => $todos, 'pick' => $pick, 'ctx' => $ctx); } function citami_aeo_render_panel($post) { if (citami_aeo_settings()['enabled'] !== 'on') { return; } $res = citami_aeo_analyse(citami_aeo_input_for_post($post, array('modified' => ''))); $rest = function_exists('rest_url') ? rest_url('citami/v1/aeo-score') : ''; $nonce = wp_create_nonce('wp_rest'); $notice = get_transient(CITAMI_EDITOR_NOTICE . (int) $post->ID); if (is_array($notice)) { delete_transient(CITAMI_EDITOR_NOTICE . (int) $post->ID); } $td = citami_aeo_todos_for_post($post, $res['checks']); $todos = $td['todos']; $pick = $td['pick']; $ctx = $td['ctx']; $i18n = array( 'good' => citami_t('Facile da citare per le AI', 'Easy for the AI engines to cite'), 'ok' => citami_t('Qualcosa da sistemare', 'A few things to fix'), 'poor' => citami_t('Difficile da citare per le AI', 'Hard for the AI engines to cite'), 'blocked' => citami_t('noindex: i motori con indice non la vedono', 'noindex: index-based engines will not see it'), 'live' => citami_t('Si aggiorna mentre scrivi', 'Updates as you write'), 'error' => citami_t('Punteggio non aggiornato (errore di rete)', 'Score not updated (network error)'), 'none' => citami_t('Niente da fare: la pagina è a posto.', 'Nothing to do: the page is in good shape.'), 'intext' => citami_t('Nel testo:', 'In the text:'), ); ?>

  1. >
0; ?>

> '', 'upgrade' => 'aeo_draft')), citami_kses_allowed()); ?>

> >.

  • —

'#15803d', 'warn' => '#b45309', 'fail' => '#b91c1c', 'na' => '#9ca3af'); return isset($c[$status]) ? $c[$status] : $c['na']; } add_action('citami_seo_meta_box_top', 'citami_aeo_render_panel'); /* ---------------------------------------------------------------- */ /* Posts list: sortable AEO column */ /* ---------------------------------------------------------------- */ function citami_aeo_add_column($columns) { if (is_array($columns) && citami_aeo_settings()['enabled'] === 'on') { $columns['citami_aeo'] = 'AEO'; } return $columns; } add_filter('manage_posts_columns', 'citami_aeo_add_column'); add_filter('manage_pages_columns', 'citami_aeo_add_column'); function citami_aeo_render_column($column, $post_id) { if ($column !== 'citami_aeo') { return; } $stored = (string) get_post_meta($post_id, CITAMI_META_AEO_SCORE, true); if ($stored === '') { echo '–'; return; } $score = (int) $stored; $grade = $score >= 80 ? 'good' : ($score >= 55 ? 'ok' : 'poor'); echo '' . (int) $score . ''; } add_action('manage_posts_custom_column', 'citami_aeo_render_column', 10, 2); add_action('manage_pages_custom_column', 'citami_aeo_render_column', 10, 2); add_filter('manage_edit-post_sortable_columns', function ($cols) { $cols['citami_aeo'] = 'citami_aeo'; return $cols; }); add_filter('manage_edit-page_sortable_columns', function ($cols) { $cols['citami_aeo'] = 'citami_aeo'; return $cols; }); add_action('pre_get_posts', function ($query) { if (!is_admin() || !is_object($query) || !method_exists($query, 'get')) { return; } if ($query->get('orderby') === 'citami_aeo') { $query->set('meta_key', CITAMI_META_AEO_SCORE); $query->set('orderby', 'meta_value_num'); } }); /* ---------------------------------------------------------------- */ /* Settings row */ /* ---------------------------------------------------------------- */ add_action('citami_seo_settings_rows', function () { $s = citami_aeo_settings(); ?> citami_t('Facile da citare per le AI', 'Easy for the AI engines to cite'), 'ok' => citami_t('Qualcosa da sistemare', 'A few things to fix'), 'poor' => citami_t('Difficile da citare per le AI', 'Hard for the AI engines to cite'), 'todo' => citami_t('Cose da fare', 'Things to do'), 'none' => citami_t('Niente da fare: la pagina è a posto.', 'Nothing to do: the page is in good shape.'), 'intext' => citami_t('Nel testo:', 'In the text:'), 'details' => citami_t('Dettagli: tutti i controlli', 'Details: every check'), 'openBox' => citami_t('Il campo è nel box "SEO" sotto l\'articolo.', 'The field is in the "SEO" box under the article.'), 'words' => citami_t('parole', 'words'), 'blocked' => citami_t('noindex: non verrà citata', 'noindex: will not be cited'), 'live' => citami_t('Si aggiorna mentre scrivi.', 'Updates as you write.'), 'updating' => citami_t('Aggiorno…', 'Updating…'), 'error' => citami_t('Punteggio non disponibile: salva la pagina e riprova.', 'Score unavailable: save the page and try again.'), 'checks' => citami_t('Dettagli: tutti i controlli', 'Details: every check'), 'about' => citami_t('Cos\'è il punteggio AEO', 'What the AEO score is'), 'foot' => citami_t('Il punteggio misura quanto è facile, per un motore AI, estrarre e citare questa pagina. Non prevede il traffico.', 'The score measures how easy it is for an AI engine to extract and cite this page. It does not forecast traffic.'), 'ready' => citami_t('Pronta: risposta breve, struttura e dati ci sono.', 'Ready: short answer, structure and facts are there.'), // 2.45 — draft panel 'draft' => citami_t('Bozza dalla piattaforma', 'Draft from the platform'), 'draftIntro' => citami_t('Risposta breve e FAQ scritte solo dal testo salvato di questa pagina: niente numeri, nomi o promesse che non ci sono già. Tu decidi se usarle.', 'Short answer and FAQ written only from this page\'s saved text: no numbers, names or promises that are not already there. You decide whether to use them.'), 'draftGenerate' => citami_t('Scrivi risposta breve e 3 FAQ', 'Write a short answer and 3 FAQ'), 'draftSaveFirst'=> citami_t('Usa il testo salvato: salva la pagina prima, se l\'hai modificata.', 'Uses the saved text: save the page first if you changed it.'), 'draftWorking' => citami_t('La piattaforma sta leggendo la pagina…', 'The platform is reading the page…'), 'draftTldr' => citami_t('Risposta breve', 'Short answer'), 'draftDropped' => citami_t('Proposte scartate perché contenevano numeri o domande non presenti nella pagina: %d.', 'Proposals dropped because they carried numbers or questions not on the page: %d.'), 'draftUse' => citami_t('Usa la bozza', 'Use the draft'), 'draftDiscard' => citami_t('Scarta', 'Discard'), 'draftRetry' => citami_t('Riprova', 'Try again'), 'draftUseNote' => citami_t('"Usa la bozza" scrive risposta breve e FAQ nei campi del box SEO e salva subito; le FAQ già presenti restano.', '"Use the draft" writes the short answer and FAQ into the SEO box fields and saves right away; existing FAQ stay.'), 'draftNotConnected' => citami_t('Collega il sito alla piattaforma per farti scrivere una bozza.', 'Connect the site to the platform to get a draft written.'), 'draftError' => citami_t('Richiesta non riuscita: riprova tra poco.', 'Request failed: try again shortly.'), 'draftQuota' => citami_t('%1 di %2 bozze usate questo mese.', '%1 of %2 drafts used this month.'), 'draftQuotaOut' => citami_t('Bozze AI finite per questo mese (%1 su %2): riprendono con il nuovo mese.', 'AI drafts used up for this month (%1 of %2): back with the new month.'), ); } add_action('enqueue_block_editor_assets', function () { $post = isset($GLOBALS['post']) && is_object($GLOBALS['post']) ? $GLOBALS['post'] : null; if (!$post || !in_array((string) $post->post_type, citami_seo_post_types(), true) || !current_user_can('edit_post', $post->ID)) { return; } wp_enqueue_script('citami-editor', citami_asset_url('editor.js'), array('wp-plugins', 'wp-element', 'wp-data', 'wp-components', 'wp-editor'), CITAMI_PLUGIN_VERSION, true); wp_localize_script('citami-editor', 'citamiEditor', array( 'rest' => rest_url('citami/v1/aeo-score'), 'draftRest' => rest_url('citami/v1/aeo-draft'), 'nonce' => wp_create_nonce('wp_rest'), 'postId' => (int) $post->ID, 'brand' => citami_brand_name(), // 2.57 — a free account sees where to get more drafts when they run out. 'upgrade' => citami_is_free_account() ? array('url' => citami_upgrade_url('editor_quota'), 'label' => citami_t('Passa a un piano', 'See the plans'), 'line' => citami_upgrade_line('aeo_draft')) : null, 'i18n' => citami_aeo_editor_i18n(), )); wp_enqueue_style('citami-admin', citami_asset_url('admin.css'), array(), CITAMI_PLUGIN_VERSION); });