bool. */
function citami_pages_fetch() {
$key = citami_get_api_key();
if ($key === '') {
return array('ok' => false, 'error' => 'no_key');
}
$res = wp_remote_get(citami_get_endpoint() . '/api/plugin/page-insights', array(
'timeout' => 45,
'headers' => array(
'Authorization' => 'Bearer ' . $key,
'User-Agent' => 'CitamiConnector/' . CITAMI_PLUGIN_VERSION . '; ' . home_url('/'),
),
));
if (is_wp_error($res)) {
return array('ok' => false, 'error' => $res->get_error_message());
}
$code = (int) wp_remote_retrieve_response_code($res);
$data = json_decode((string) wp_remote_retrieve_body($res), true);
if ($code < 200 || $code >= 300 || !is_array($data) || !isset($data['pages'])) {
return array('ok' => false, 'error' => 'HTTP ' . $code);
}
$data['ok'] = true;
$data['cached_at'] = time();
return $data;
}
/** Cached insights (6 h). $force refreshes. Failures keep the last good copy. */
function citami_pages_data($force = false) {
$cached = get_transient(CITAMI_PAGES_TRANSIENT);
if (!$force && is_array($cached)) {
return $cached;
}
$fresh = citami_pages_fetch();
if (!empty($fresh['ok'])) {
set_transient(CITAMI_PAGES_TRANSIENT, $fresh, CITAMI_PAGES_TTL);
return $fresh;
}
if (is_array($cached)) {
$cached['stale_error'] = isset($fresh['error']) ? (string) $fresh['error'] : '';
return $cached;
}
$fresh['cached_at'] = time();
set_transient(CITAMI_PAGES_TRANSIENT, $fresh, 10 * MINUTE_IN_SECONDS);
return $fresh;
}
/** Page rows with a verdict to act on, mapped to posts, ordered by priority. */
function citami_pages_rows($limit = CITAMI_PAGES_LIST_MAX) {
$data = citami_pages_data();
if (empty($data['ok']) || empty($data['pages'])) {
return array();
}
$rows = array();
foreach ((array) $data['pages'] as $p) {
if (!is_array($p) || empty($p['verdict']['kind']) || $p['verdict']['kind'] === 'ok' || empty($p['url'])) {
continue;
}
$p['post_id'] = function_exists('citami_gsc_post_for_url') ? citami_gsc_post_for_url($p['url']) : (int) url_to_postid($p['url']);
$rows[] = $p;
if (count($rows) >= $limit) {
break;
}
}
return $rows;
}
function citami_pages_pct($ctr) {
return number_format_i18n(100 * (float) $ctr, 1) . '%';
}
/** The verdict in one plain sentence, with the numbers behind it. */
function citami_pages_verdict_text(array $p, array $curve = array()) {
$v = $p['verdict'];
$g = isset($p['gsc']) && is_array($p['gsc']) ? $p['gsc'] : array();
$ai = isset($p['ai']) && is_array($p['ai']) ? $p['ai'] : array();
switch ($v['kind']) {
case 'lost_clicks':
return sprintf(citami_t('Ha perso clic su Google: %1$d negli ultimi 28 giorni contro %2$d nei 28 prima.', 'Losing clicks on Google: %1$d in the last 28 days against %2$d in the 28 before.'), (int) $g['clicks'], (int) $g['clicksPrev']);
case 'low_ctr':
$bucket = citami_pages_bucket((float) $g['position']);
$typ = ($bucket !== '' && isset($curve[$bucket])) ? citami_pages_pct($curve[$bucket]) : '';
return $typ !== ''
? sprintf(citami_t('Google la mostra %1$s volte ma la cliccano solo il %2$s: le tue pagine in posizione simile arrivano al %3$s.', 'Google shows it %1$s times but only %2$s click: your pages at a similar position reach %3$s.'), number_format_i18n((int) $g['impressions']), citami_pages_pct($g['ctr']), $typ)
: sprintf(citami_t('Google la mostra %1$s volte ma la cliccano solo il %2$s.', 'Google shows it %1$s times but only %2$s click.'), number_format_i18n((int) $g['impressions']), citami_pages_pct($g['ctr']));
case 'read_not_cited':
$lost = isset($ai['lostQuestions']) ? (array) $ai['lostQuestions'] : array();
$s = sprintf(citami_t('Le AI non ti citano su %1$d domande a cui questa pagina dovrebbe rispondere, per esempio «%2$s».', 'The AI engines do not cite you on %1$d questions this page should answer, e.g. "%2$s".'), count($lost), isset($lost[0]) ? $lost[0] : '');
if (!empty($ai['answerReads'])) {
$s .= ' ' . sprintf(citami_t('Gli assistenti l\'hanno aperta %d volte per rispondere a qualcuno: la leggono ma non la citano.', 'Assistants opened it %d times to answer someone: they read it but do not cite it.'), (int) $ai['answerReads']);
}
return $s;
case 'cannibal':
$c = isset($v['competing']) && is_array($v['competing']) ? $v['competing'] : array('url' => '', 'query' => '');
return sprintf(citami_t('Compete con un\'altra tua pagina per «%1$s» («%2$s»). Google ne mostra una a scapito dell\'altra.', 'Competes with another of your pages for "%1$s" ("%2$s"). Google shows one at the expense of the other.'), $c['query'], citami_pages_title_for_url((string) $c['url']));
case 'near_top':
return sprintf(citami_t('In posizione %1$s con %2$s impressioni: a un passo dalla prima pagina.', 'At position %1$s with %2$s impressions: one step from page one.'), number_format_i18n((float) $g['position'], 1), number_format_i18n((int) $g['impressions']));
case 'not_read_by_ai':
return sprintf(citami_t('Google la mostra %s volte, ma nessun bot AI l\'ha aperta negli ultimi 30 giorni.', 'Google shows it %s times, but no AI bot opened it in the last 30 days.'), number_format_i18n((int) $g['impressions']));
}
return '';
}
function citami_pages_bucket($position) {
if ($position <= 0) return '';
if ($position <= 3) return '1-3';
if ($position <= 6) return '4-6';
if ($position <= 10) return '7-10';
if ($position <= 20) return '11-20';
return '';
}
function citami_pages_action_label($action) {
$labels = array(
'meta_draft' => citami_t('Riscrivi titolo e descrizione con AI', 'Rewrite title and description with AI'),
'refresh_brief' => citami_t('Prepara l\'aggiornamento con AI', 'Prepare the update with AI'),
'aeo_draft' => citami_t('Scrivi la risposta in apertura con AI', 'Write the opening answer with AI'),
'indexnow' => citami_t('Invia ai motori (IndexNow)', 'Send to the engines (IndexNow)'),
'links' => citami_t('Aggiungi link interni', 'Add internal links'),
'review' => citami_t('Confronta le due pagine', 'Compare the two pages'),
);
return isset($labels[$action]) ? $labels[$action] : '';
}
/* ---------------------------------------------------------------- */
/* Before / after */
/* ---------------------------------------------------------------- */
function citami_pages_actions_log() {
$log = get_option(CITAMI_PAGES_ACTIONS_OPT, array());
return is_array($log) ? $log : array();
}
/** Remembers an applied action with the page's numbers of that day. */
function citami_pages_log_action($post_id, $kind) {
$url = get_permalink((int) $post_id);
if (!$url) {
return;
}
$path = rtrim((string) wp_parse_url($url, PHP_URL_PATH), '/');
$path = $path === '' ? '/' : $path;
$before = null;
$data = get_transient(CITAMI_PAGES_TRANSIENT);
foreach ((array) (is_array($data) && isset($data['pages']) ? $data['pages'] : array()) as $p) {
if (is_array($p) && isset($p['path']) && $p['path'] === $path && !empty($p['gsc'])) {
$before = array('clicks' => (int) $p['gsc']['clicks'], 'impressions' => (int) $p['gsc']['impressions'], 'ctr' => (float) $p['gsc']['ctr'], 'position' => (float) $p['gsc']['position']);
break;
}
}
$log = citami_pages_actions_log();
$log[$path] = array('post' => (int) $post_id, 'kind' => (string) $kind, 'ts' => time(), 'before' => $before);
if (count($log) > 200) {
uasort($log, function ($a, $b) { return $b['ts'] - $a['ts']; });
$log = array_slice($log, 0, 200, true);
}
update_option(CITAMI_PAGES_ACTIONS_OPT, $log, false);
}
/** "Since Oct 3: CTR 1.2% → 2.8%, clicks 14 → 31" — or "measuring" in the first two weeks. */
function citami_pages_effect_text(array $p) {
$log = citami_pages_actions_log();
if (empty($p['path']) || !isset($log[$p['path']])) {
return '';
}
$e = $log[$p['path']];
$fmt = (string) get_option('date_format');
$when = date_i18n($fmt !== '' ? $fmt : 'j M Y', (int) $e['ts']);
if (time() - (int) $e['ts'] < CITAMI_PAGES_MEASURE_DAYS * DAY_IN_SECONDS || empty($e['before']) || empty($p['gsc'])) {
return sprintf(citami_t('Modifica fatta (%s): lo misuriamo, servono 2-4 settimane di dati Google.', 'Change made (%s): being measured, it takes 2-4 weeks of Google data.'), $when);
}
return sprintf(
citami_t('Dopo la modifica (%1$s): CTR %2$s → %3$s, clic %4$d → %5$d nei 28 giorni.', 'After the change (%1$s): CTR %2$s → %3$s, clicks %4$d → %5$d over 28 days.'),
$when,
citami_pages_pct($e['before']['ctr']),
citami_pages_pct($p['gsc']['ctr']),
(int) $e['before']['clicks'],
(int) $p['gsc']['clicks']
);
}
/* ---------------------------------------------------------------- */
/* AI drafts from the platform */
/* ---------------------------------------------------------------- */
function citami_pages_brand() {
if (function_exists('citami_truth_sheet')) {
$s = citami_truth_sheet();
if (is_array($s) && !empty($s['name'])) {
return (string) $s['name'];
}
}
return (string) get_bloginfo('name');
}
/** Calls /api/plugin/page-ai for one post; stores the draft. kind: meta | refresh. */
function citami_pages_ai_request($post_id, $kind) {
$post_id = (int) $post_id;
$kind = $kind === 'refresh' ? 'refresh' : 'meta';
if (!get_post($post_id)) {
return array('ok' => false, 'message' => citami_t('Pagina non trovata.', 'Page not found.'));
}
if (citami_get_api_key() === '') {
return array('ok' => false, 'message' => citami_t('Collega prima il sito alla piattaforma (Impostazioni → API key).', 'Connect the site to the platform first (Settings → API key).'));
}
$page = citami_aeo_draft_page_text($post_id);
if (mb_strlen($page['text']) < 80) {
return array('ok' => false, 'message' => citami_t('La pagina ha troppo poco testo salvato per lavorarci: aggiungi contenuto e salva.', 'The page has too little saved text to work from: add content and save.'));
}
$seo = function_exists('citami_seo_get_post_fields') ? citami_seo_get_post_fields($post_id) : array('title' => '', 'description' => '');
$res = wp_remote_post(citami_get_endpoint() . '/api/plugin/page-ai', array(
'timeout' => 60,
'headers' => array(
'Authorization' => 'Bearer ' . citami_get_api_key(),
'Content-Type' => 'application/json',
'User-Agent' => 'CitamiConnector/' . CITAMI_PLUGIN_VERSION . '; ' . home_url('/'),
),
'body' => wp_json_encode(array(
'kind' => $kind,
'url' => get_permalink($post_id),
'page_title' => $page['title'] !== '' ? $page['title'] : '(senza titolo)',
'current_title' => (string) $seo['title'],
'current_description' => (string) $seo['description'],
'brand' => citami_pages_brand(),
'text' => mb_substr($page['text'], 0, 10000),
'locale' => citami_lang(),
), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG),
));
if (is_wp_error($res)) {
return array('ok' => false, 'message' => sprintf(citami_t('Piattaforma non raggiungibile (%s).', 'Platform unreachable (%s).'), $res->get_error_message()));
}
$code = (int) wp_remote_retrieve_response_code($res);
$data = json_decode((string) wp_remote_retrieve_body($res), true);
if ($code === 429) {
return array('ok' => false, 'message' => citami_t('Troppe richieste in un minuto: riprova tra poco.', 'Too many requests in a minute: try again shortly.'));
}
if ($code < 200 || $code >= 300 || !is_array($data)) {
return array('ok' => false, 'message' => sprintf(citami_t('La piattaforma ha risposto con un errore (HTTP %d).', 'The platform answered with an error (HTTP %d).'), $code));
}
if (isset($data['reason']) && $data['reason'] === 'quota') {
return array('ok' => false, 'upgrade' => 'page_ai', 'message' => sprintf(citami_t('Riscritture AI finite per questo mese (%1$d su %2$d): riprendono il mese prossimo.', 'AI rewrites used up for this month (%1$d of %2$d): back next month.'), (int) $data['used'], (int) $data['quota']));
}
if (isset($data['reason']) && $data['reason'] === 'no_queries') {
return array('ok' => false, 'message' => citami_t('Search Console non ha ancora ricerche per questa pagina: senza quelle non c\'è su cosa riscrivere titolo e descrizione.', 'Search Console has no queries for this page yet: without them there is nothing to rewrite the title and description on.'));
}
if ($kind === 'meta') {
if (!empty($data['dropped']) || empty($data['title'])) {
return array('ok' => false, 'message' => citami_t('Bozza scartata: conteneva numeri o fatti non presenti nella pagina. Riprova o scrivi il titolo a mano.', 'Draft dropped: it carried numbers or facts not on the page. Try again or write the title by hand.'));
}
$draft = array(
'title' => mb_substr(sanitize_text_field((string) $data['title']), 0, 70),
'description' => mb_substr(sanitize_text_field((string) $data['description']), 0, 170),
'why' => mb_substr(sanitize_text_field((string) (isset($data['why']) ? $data['why'] : '')), 0, 240),
// 2.64 — the platform may also propose the page heading (H1 = the post title).
'h1' => mb_substr(sanitize_text_field((string) (isset($data['h1']) ? $data['h1'] : '')), 0, 80),
'query' => isset($data['queries'][0]['query']) ? sanitize_text_field((string) $data['queries'][0]['query']) : '',
'ts' => time(),
);
update_post_meta($post_id, CITAMI_META_PAGE_DRAFT, $draft);
return array('ok' => true, 'message' => citami_t('Titolo e descrizione proposti: leggili qui sotto e decidi se usarli.', 'Title and description proposed: read them below and decide whether to use them.'));
}
$sections = array();
foreach ((array) (isset($data['sections']) ? $data['sections'] : array()) as $s) {
if (is_array($s) && !empty($s['heading'])) {
$sections[] = array('heading' => sanitize_text_field((string) $s['heading']), 'why' => sanitize_text_field((string) (isset($s['why']) ? $s['why'] : '')));
}
}
$updates = array();
foreach ((array) (isset($data['updates']) ? $data['updates'] : array()) as $u) {
if (is_string($u) && trim($u) !== '') {
$updates[] = sanitize_text_field($u);
}
}
if (empty($sections) && empty($updates)) {
return array('ok' => false, 'message' => citami_t('Dai dati non è emerso un aggiornamento da proporre: la pagina potrebbe perdere clic per stagionalità.', 'The data did not point to an update worth proposing: the page may be losing clicks to seasonality.'));
}
update_post_meta($post_id, CITAMI_META_PAGE_BRIEF, array(
'summary' => sanitize_text_field((string) (isset($data['summary']) ? $data['summary'] : '')),
'sections' => array_slice($sections, 0, 5),
'updates' => array_slice($updates, 0, 5),
'ts' => time(),
));
return array('ok' => true, 'message' => citami_t('Scaletta dell\'aggiornamento pronta: la trovi qui sotto. Aprila accanto all\'editor mentre aggiorni la pagina.', 'Update brief ready: you find it below. Keep it open next to the editor while you update the page.'));
}
/* ---------------------------------------------------------------- */
/* Actions */
/* ---------------------------------------------------------------- */
/** The Pages tab scrolled to one card. */
function citami_pages_card_url($post_id) {
return citami_admin_url('pages', array('focus' => (int) $post_id)) . '#citami-page-' . (int) $post_id;
}
function citami_pages_action_url($do, $post_id = 0, $back = '') {
return wp_nonce_url(admin_url('admin-post.php?action=citami_pages_action&do=' . rawurlencode($do) . ($post_id ? '&post=' . (int) $post_id : '') . ($back === 'overview' ? '&back=overview' : '')), 'citami_pages', '_citami_pages_nonce');
}
/** 2.59 — pages with an AI short answer waiting for approval (newest first). */
function citami_pages_pending_aeo_drafts($limit = 10) {
if (!defined('CITAMI_META_AEO_DRAFT')) {
return array();
}
return array_map('intval', get_posts(array(
'post_type' => 'any',
'post_status' => array('publish', 'draft', 'pending', 'future', 'private'),
'meta_key' => CITAMI_META_AEO_DRAFT, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- pages with a draft only
'posts_per_page' => (int) $limit,
'orderby' => 'modified',
'fields' => 'ids',
)));
}
/** 2.59 — Overview: the drafts written from there, approved there. */
function citami_pages_render_overview_drafts() {
$ids = citami_pages_pending_aeo_drafts();
$rows = array();
foreach ($ids as $pid) {
$d = citami_aeo_draft_get($pid);
if ($d !== null) $rows[$pid] = $d;
}
if (empty($rows)) {
return '';
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display only: which draft to highlight
$focus = isset($_GET['focus']) ? (int) $_GET['focus'] : 0;
$notice = $focus ? get_transient(CITAMI_PAGES_CARD_NOTICE) : null;
ob_start();
?>
' . esc_html(citami_t('Le AI rispondono senza citarti', 'The AI engines answer without citing you')) . '
' . esc_html(citami_t('Le AI citano questa pagina', 'The AI engines cite this page')) . '
' . ($cur !== '' ? esc_html($cur) : '' . esc_html(citami_t('nessuna risposta in apertura', 'no opening answer')) . '') . '
' . esc_html($draft['tldr']) . '
' . esc_html(citami_t('Scritta solo dal testo della pagina. "Inserisci" la scrive nella pagina e la salva; le FAQ già presenti restano.', 'Written from the page text only. "Add to the page" writes it into the page and saves it; existing FAQ stay.')) . '
'; $h .= '
' . (int) mb_strlen($cur_d) . '' : '' . esc_html(citami_t('nessuna descrizione: Google ne inventa una', 'no description: Google makes one up')) . ''; ?>