bool. */ function citami_gsc_fetch() { $key = citami_get_api_key(); if ($key === '') { return array('ok' => false, 'error' => 'no_key'); } $res = wp_remote_get(citami_get_endpoint() . '/api/plugin/gsc', array( 'timeout' => 30, '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)) { return array('ok' => false, 'error' => 'HTTP ' . $code); } $data['ok'] = true; $data['cached_at'] = time(); return $data; } /** Cached panel (6 h). $force refreshes. Failures keep the last good copy. */ function citami_gsc_data($force = false) { $cached = get_transient(CITAMI_GSC_TRANSIENT); if (!$force && is_array($cached)) { return $cached; } $fresh = citami_gsc_fetch(); if (!empty($fresh['ok'])) { set_transient(CITAMI_GSC_TRANSIENT, $fresh, CITAMI_GSC_TTL); return $fresh; } if (is_array($cached)) { $cached['stale_error'] = isset($fresh['error']) ? (string) $fresh['error'] : ''; return $cached; } // Remember the failure briefly so a broken key doesn't hit the platform on every admin page. $fresh['cached_at'] = time(); set_transient(CITAMI_GSC_TRANSIENT, $fresh, 10 * MINUTE_IN_SECONDS); return $fresh; } function citami_gsc_page_key($url) { $p = wp_parse_url((string) $url); $host = isset($p['host']) ? preg_replace('/^www\./', '', strtolower($p['host'])) : ''; $path = isset($p['path']) && $p['path'] !== '' ? rtrim($p['path'], '/') : ''; if ($path === '') { $path = '/'; } return $host . $path . (isset($p['query']) ? '?' . $p['query'] : ''); } /** Page rows keyed by normalised URL (from the cached panel). */ function citami_gsc_pages_map() { $d = citami_gsc_data(); $map = array(); if (empty($d['ok']) || empty($d['connected']) || empty($d['pages']) || !is_array($d['pages'])) { return $map; } foreach ($d['pages'] as $row) { if (is_array($row) && !empty($row['page'])) { $map[citami_gsc_page_key($row['page'])] = $row; } } return $map; } function citami_gsc_for_post($post_id) { $map = citami_gsc_pages_map(); if (empty($map)) { return null; } $key = citami_gsc_page_key(get_permalink($post_id)); return isset($map[$key]) ? $map[$key] : null; } function citami_gsc_fmt_delta($n) { $n = (int) $n; if ($n === 0) { return '±0'; } return '' . ($n > 0 ? '+' : '−') . number_format_i18n(abs($n)) . ''; } function citami_gsc_fmt_pct($ctr) { return number_format_i18n(((float) $ctr) * 100, 1) . '%'; } /* ---------------------------------------------------------------- */ /* 2.43 — OPPORTUNITIES: three things to do, computed from the panel */ /* query × page rows (platform ≥ ACT3). Deterministic, no model. */ /* ---------------------------------------------------------------- */ define('CITAMI_GSC_CTR_MIN_IMPR', 100); define('CITAMI_GSC_CTR_MAX', 0.02); define('CITAMI_GSC_POS_MAX', 10); define('CITAMI_GSC_Q_MIN_IMPR', 30); define('CITAMI_GSC_OPP_MAX', 8); /** Post id for a Search Console page URL (www / trailing slash tolerant), 0 when unknown. */ function citami_gsc_post_for_url($url) { $id = (int) url_to_postid((string) $url); if ($id > 0) { return $id; } $alt = preg_match('#/$#', (string) $url) ? rtrim((string) $url, '/') : (string) $url . '/'; return (int) url_to_postid($alt); } function citami_gsc_is_question($q) { return (bool) preg_match('/^(come|cosa|cos\'|quanto|quanta|quanti|quante|perch[eé]|quando|dove|chi|quale|quali|conviene|meglio|si può|si puo|what|how|why|when|where|which|who|can|should|is|are|does|do)\b/iu', trim((string) $q)); } /** mb-safe ucfirst. */ function citami_gsc_ucfirst($s) { $s = trim((string) $s); return $s === '' ? '' : mb_strtoupper(mb_substr($s, 0, 1)) . mb_substr($s, 1); } function citami_gsc_cut($text, $max) { $text = trim(preg_replace('/\s+/', ' ', (string) $text)); if (mb_strlen($text) <= $max) { return $text; } $cut = mb_substr($text, 0, $max); $sp = mb_strrpos($cut, ' '); return rtrim($sp > $max * 0.6 ? mb_substr($cut, 0, $sp) : $cut, " ,;:.-") ; } /** A title that carries the query, built from what the page already says. */ function citami_gsc_draft_title($query, $current_title) { $q = citami_gsc_ucfirst($query); $cur = trim((string) $current_title); if ($cur !== '' && mb_stripos($cur, trim((string) $query)) !== false) { return citami_gsc_cut($cur, 60); } $joined = $cur !== '' ? $q . ': ' . $cur : $q; if (mb_strlen($joined) <= 60) { return $joined; } $brand = trim((string) get_bloginfo('name')); $alt = $brand !== '' ? $q . ' | ' . $brand : $q; return citami_gsc_cut(mb_strlen($alt) <= 60 ? $alt : $q, 60); } /** A description that opens with the query and continues with the page's own excerpt. */ function citami_gsc_draft_description($query, $current_desc, $post) { $q = trim((string) $query); $cur = trim((string) $current_desc); if ($cur !== '' && mb_stripos($cur, $q) !== false) { return citami_gsc_cut($cur, 155); } $base = $cur !== '' ? $cur : (function_exists('citami_seo_post_excerpt') && is_object($post) ? citami_seo_post_excerpt($post) : ''); $lead = citami_gsc_ucfirst($q); // 2.64 — whole sentences when they fit, so the draft does not stop mid-phrase. $out = $lead; foreach (preg_split('/(?<=[.!?])\s+/u', trim((string) $base)) as $sentence) { $next = $out === $lead ? $lead . ': ' . $sentence : $out . ' ' . $sentence; if (mb_strlen($next) > 155) break; $out = $next; } if ($out === $lead && $base !== '') { return citami_gsc_cut($lead . ': ' . $base, 155); } return $out; } /** FAQ answer draft: the page's TL;DR, else its first sentences. Marked for review in the notice. */ function citami_gsc_faq_answer_draft($post_id) { $tldr = function_exists('citami_content_tldr') ? citami_content_tldr($post_id) : ''; if ($tldr !== '') { return $tldr; } $post = get_post((int) $post_id); if (!$post) { return ''; } $html = preg_replace('//s', ' ', citami_post_content_html($post)); $html = preg_replace('#]*>#i', ' ', (string) $html); $plain = html_entity_decode(wp_strip_all_tags((string) $html), ENT_QUOTES | ENT_HTML5, 'UTF-8'); $plain = trim(preg_replace('/\s+/', ' ', (string) $plain)); if (preg_match('/^(.{40,300}?[.!?])(\s|$)/u', $plain, $m)) { return trim($m[1]); } return citami_gsc_cut($plain, 300); } /** * The three lists the tab acts on: * ctr — one query per page: position ≤ 10, ≥ 100 impressions, CTR < 2 % * losing — pages that lost ≥ 20 % of their clicks (prev ≥ 20), with their top queries * questions — question queries with ≥ 30 impressions whose page has no such FAQ yet */ function citami_gsc_opportunities(array $d, $limit = CITAMI_GSC_OPP_MAX) { $limit = (int) $limit; $out = array('ctr' => array(), 'losing' => array(), 'questions' => array(), 'has_pairs' => false, 'totals' => array('ctr' => 0, 'losing' => 0, 'questions' => 0)); if (empty($d['ok']) || empty($d['connected'])) { return $out; } $pairs = isset($d['pairs']) && is_array($d['pairs']) ? array_filter($d['pairs'], 'is_array') : array(); $out['has_pairs'] = !empty($pairs); $post_cache = array(); $post_of = function ($url) use (&$post_cache) { $k = citami_gsc_page_key($url); if (!isset($post_cache[$k])) { $post_cache[$k] = citami_gsc_post_for_url($url); } return $post_cache[$k]; }; // Queries per page (top by impressions). $by_page = array(); foreach ($pairs as $r) { if (empty($r['page']) || empty($r['query'])) continue; $by_page[citami_gsc_page_key($r['page'])][] = $r; } foreach ($by_page as $k => $rows) { usort($rows, function ($a, $b) { return (int) $b['impressions'] <=> (int) $a['impressions']; }); $by_page[$k] = $rows; } // ctr $seen = array(); foreach ($pairs as $r) { if ((int) $r['impressions'] < CITAMI_GSC_CTR_MIN_IMPR || (float) $r['position'] > CITAMI_GSC_POS_MAX || (float) $r['ctr'] >= CITAMI_GSC_CTR_MAX) continue; $pid = $post_of($r['page']); if ($pid <= 0 || isset($seen[$pid])) continue; $post = get_post($pid); if (!$post) continue; $seen[$pid] = true; $out['ctr'][] = array( 'post_id' => $pid, 'post_title' => (string) $post->post_title, 'query' => (string) $r['query'], 'impressions' => (int) $r['impressions'], 'ctr' => (float) $r['ctr'], 'position' => (float) $r['position'], ); } // 2.60 — every match is counted ("8 di N"); the top ones by impressions are shown. usort($out['ctr'], function ($a, $b) { return $b['impressions'] <=> $a['impressions']; }); $out['totals']['ctr'] = count($out['ctr']); if ($limit > 0) { $out['ctr'] = array_slice($out['ctr'], 0, $limit); } foreach ($out['ctr'] as $i => $row) { $post = get_post($row['post_id']); $fields = function_exists('citami_seo_get_post_fields') ? citami_seo_get_post_fields($row['post_id']) : array('title' => '', 'description' => ''); $cur_title = $fields['title'] !== '' ? $fields['title'] : (string) $post->post_title; $out['ctr'][$i] += array( 'title' => $cur_title, 'description' => (string) $fields['description'], 'draft_title' => citami_gsc_draft_title($row['query'], $cur_title), 'draft_description' => citami_gsc_draft_description($row['query'], $fields['description'], $post), ); } // losing foreach ((array) (isset($d['pages']) ? $d['pages'] : array()) as $pg) { if (!is_array($pg) || empty($pg['page'])) continue; $delta = (int) $pg['clicksDelta']; $prev = (int) $pg['clicks'] - $delta; if ($delta >= 0 || $prev < 20 || (-$delta) / max(1, $prev) < 0.2) continue; $pid = $post_of($pg['page']); if ($pid <= 0) continue; $k = citami_gsc_page_key($pg['page']); $out['losing'][] = array( 'post_id' => $pid, 'post_title' => get_the_title($pid), 'page' => (string) $pg['page'], 'clicks' => (int) $pg['clicks'], 'previous' => $prev, 'delta' => $delta, 'queries' => array_map(function ($r) { return (string) $r['query']; }, array_slice(isset($by_page[$k]) ? $by_page[$k] : array(), 0, 3)), ); } usort($out['losing'], function ($a, $b) { return $a['delta'] <=> $b['delta']; }); // most clicks lost first $out['totals']['losing'] = count($out['losing']); if ($limit > 0) { $out['losing'] = array_slice($out['losing'], 0, $limit); } // questions $seen_q = array(); foreach ($pairs as $r) { if ((int) $r['impressions'] < CITAMI_GSC_Q_MIN_IMPR || !citami_gsc_is_question($r['query'])) continue; $pid = $post_of($r['page']); if ($pid <= 0) continue; $key = $pid . '|' . mb_strtolower(trim((string) $r['query'])); if (isset($seen_q[$key])) continue; $seen_q[$key] = true; $has = false; foreach (function_exists('citami_content_faq') ? citami_content_faq($pid) : array() as $f) { if (mb_strtolower(rtrim(trim($f['q']), '?')) === mb_strtolower(rtrim(trim((string) $r['query']), '?'))) { $has = true; break; } } if ($has) continue; $out['questions'][] = array('post_id' => $pid, 'post_title' => get_the_title($pid), 'query' => (string) $r['query'], 'impressions' => (int) $r['impressions'], 'position' => (float) $r['position']); } usort($out['questions'], function ($a, $b) { return $b['impressions'] <=> $a['impressions']; }); $out['totals']['questions'] = count($out['questions']); if ($limit > 0) { $out['questions'] = array_slice($out['questions'], 0, $limit); } return $out; } /** Saves a title/description drafted from a query. */ function citami_gsc_apply_meta($post_id, $title, $description, $query = '') { $post_id = (int) $post_id; if (!get_post($post_id) || !function_exists('citami_seo_save_post_fields')) { return array('ok' => false, 'message' => citami_t('Pagina non trovata.', 'Page not found.')); } $title = citami_gsc_cut(sanitize_text_field($title), 70); $description = citami_gsc_cut(sanitize_text_field($description), 170); if ($title === '' && $description === '') { return array('ok' => false, 'message' => citami_t('Niente da salvare.', 'Nothing to save.')); } $fields = array(); if ($title !== '') $fields['title'] = $title; if ($description !== '') $fields['description'] = $description; citami_seo_save_post_fields($post_id, $fields); if (function_exists('citami_indexnow_enqueue_post')) citami_indexnow_enqueue_post($post_id, true); if (function_exists('citami_activity_log')) citami_activity_log('meta', sprintf(citami_t('Title e description riscritti per "%1$s" (query: %2$s)', 'Title and description rewritten for "%1$s" (query: %2$s)'), get_the_title($post_id), $query), 1, array('post' => $post_id)); return array('ok' => true, 'message' => sprintf(citami_t('Salvati title e description di "%s". Google li rilegge alla prossima scansione; IndexNow avvisato.', 'Title and description of "%s" saved. Google re-reads them on the next crawl; IndexNow notified.'), get_the_title($post_id))); } define('CITAMI_GSC_AI_BULK_MAX', 3); /** 2.64 — asks the platform AI for title + description (+ H1) for each post; one notice for all. */ function citami_gsc_meta_ai(array $post_ids) { if (empty($post_ids) || !function_exists('citami_pages_ai_request')) { return array('ok' => false, 'message' => citami_t('Nessuna pagina da riscrivere.', 'No page to rewrite.')); } if (function_exists('set_time_limit')) @set_time_limit(30 + 30 * count($post_ids)); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged,Squiz.PHP.DiscouragedFunctions.Discouraged -- several pages rewritten in one request; some hosts forbid raising the limit $done = 0; $errors = array(); foreach ($post_ids as $pid) { $r = citami_pages_ai_request((int) $pid, 'meta'); if (!empty($r['ok'])) { $done++; continue; } $errors[] = '"' . get_the_title((int) $pid) . '": ' . $r['message']; if (!empty($r['upgrade'])) { return array('ok' => $done > 0, 'upgrade' => $r['upgrade'], 'message' => $r['message']); } } if ($done === 0) { return array('ok' => false, 'message' => implode(' ', $errors)); } $msg = $done === 1 ? citami_t('Proposta AI pronta: la trovi nei campi della riga, con il perché. Non è ancora salvata: controlla e premi "Usa la bozza".', 'AI proposal ready: it is in the row\'s fields, with the reason. Not saved yet: check it and press "Use the draft".') : sprintf(citami_t('%d proposte AI pronte nei campi delle righe. Non sono ancora salvate: controllale e premi "Usa la bozza" su quelle che ti vanno bene.', '%d AI proposals ready in the rows\' fields. Not saved yet: check them and press "Use the draft" on the ones you like.'), $done); return array('ok' => true, 'message' => $msg . ($errors ? ' ' . implode(' ', $errors) : '')); } /** The stored AI proposal for a post, or null. */ function citami_gsc_ai_draft($post_id) { if (!defined('CITAMI_META_PAGE_DRAFT')) { return null; } $d = get_post_meta((int) $post_id, CITAMI_META_PAGE_DRAFT, true); return is_array($d) && !empty($d['title']) ? $d : null; } /** Adds a question from Search Console to the page's FAQ with a draft answer. */ function citami_gsc_apply_faq($post_id, $question, $answer = null) { $post_id = (int) $post_id; $question = trim(sanitize_text_field($question)); if (!get_post($post_id) || $question === '' || !function_exists('citami_content_save')) { return array('ok' => false, 'message' => citami_t('Pagina non trovata.', 'Page not found.')); } $q = citami_gsc_ucfirst(rtrim($question, '?')) . '?'; // 2.59 — the answer the owner read (and maybe edited) in the row; the draft only as fallback. $answer = $answer !== null ? trim(sanitize_textarea_field((string) $answer)) : citami_gsc_faq_answer_draft($post_id); if ($answer === '') { return array('ok' => false, 'message' => citami_t('La pagina non ha testo da cui abbozzare una risposta: scrivila nel box SEO.', 'The page has no text to draft an answer from: write it in the SEO box.')); } $faq = citami_content_faq($post_id); foreach ($faq as $f) { if (mb_strtolower($f['q']) === mb_strtolower($q)) { return array('ok' => false, 'message' => citami_t('Questa domanda è già nelle FAQ della pagina.', 'This question is already in the page\'s FAQ.')); } } $faq[] = array('q' => $q, 'a' => $answer); citami_content_save($post_id, function_exists('citami_content_tldr') ? citami_content_tldr($post_id) : '', $faq); if (function_exists('citami_activity_log')) citami_activity_log('faq', sprintf(citami_t('FAQ aggiunta a "%1$s": %2$s', 'FAQ added to "%1$s": %2$s'), get_the_title($post_id), $q), 1, array('post' => $post_id)); return array('ok' => true, 'message' => sprintf(citami_t('FAQ aggiunta a "%1$s": "%2$s". È nella pagina e nello schema FAQPage.', 'FAQ added to "%1$s": "%2$s". It is on the page and in the FAQPage schema.'), get_the_title($post_id), $q)); } function citami_gsc_form_open($do, $extra = '', $id = '', $class = 'citami-inline-form') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display only: come back to the uncapped lists $all = !empty($_GET['gsc_all']) ? '' : ''; return '
' . wp_nonce_field('citami_gsc', '_citami_gsc_nonce', true, false) . '' . $all . $extra; } /** "8 di 23 · Mostra tutte" next to a capped list; "Mostra meno" once uncapped. */ function citami_gsc_cap_html($shown, $total, $anchor) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display only $all = !empty($_GET['gsc_all']); if ($all && $total > CITAMI_GSC_OPP_MAX) { return '' . esc_html(sprintf(citami_t('tutte e %d', 'all %d'), (int) $total)) . ' · ' . esc_html(citami_t('Mostra meno', 'Show fewer')) . ''; } if ($total <= $shown) { return ''; } return '' . esc_html(sprintf(citami_t('%1$d di %2$d', '%1$d of %2$d'), (int) $shown, (int) $total)) . ' · ' . esc_html(citami_t('Mostra tutte', 'Show all')) . ''; } /** Questions tracked in the last action, read once: their rows get the dashboard link. */ function citami_gsc_tracked_key() { return 'citami_gsc_tracked_' . (int) get_current_user_id(); } /** $fresh reads (and clears) the transient; otherwise the copy read at the top of this render. */ function citami_gsc_tracked_take($fresh = false) { static $taken = null; if ($fresh || $taken === null) { $v = get_transient(citami_gsc_tracked_key()); if (is_array($v)) { delete_transient(citami_gsc_tracked_key()); } $taken = is_array($v) ? $v + array('queries' => array(), 'dash' => '') : array('queries' => array(), 'dash' => ''); citami_gsc_tracked_row_attr(true); } return $taken; } function citami_gsc_dashboard_queries_url() { return citami_lang_url((function_exists('citami_brand_site') ? citami_brand_site() : citami_get_endpoint()) . '/dashboard/query'); } /** Row attributes for a just-tracked question: the first one is the anchor the redirect lands on. */ function citami_gsc_tracked_row_attr($reset = false) { static $anchored = false; if ($reset) { $anchored = false; return ''; } $id = $anchored ? '' : ' id="citami-gsc-tracked"'; $anchored = true; return $id . ' class="is-focus"'; } /** Prints the tracked-row attributes (fixed literals, nothing from the request). */ function citami_gsc_tracked_row_attr_print() { $attr = citami_gsc_tracked_row_attr(); echo $attr === ' id="citami-gsc-tracked" class="is-focus"' ? ' id="citami-gsc-tracked" class="is-focus"' : ' class="is-focus"'; } /** The "tracked" mark for a row whose question was just sent; '' otherwise. */ function citami_gsc_tracked_mark($query) { $t = citami_gsc_tracked_take(); $k = mb_strtolower(trim(preg_replace('/\s+/', ' ', (string) $query))); if ($k === '' || !in_array($k, (array) $t['queries'], true)) { return ''; } return '' . esc_html(citami_t('Tracciata', 'Tracked')) . ' · ' . esc_html(citami_t('Vedi nella dashboard', 'See it in the dashboard')) . ''; } /* ---------------------------------------------------------------- */ /* Admin tab + refresh */ /* ---------------------------------------------------------------- */ /** * 2.51 — TRACK: a Google query becomes a question tracked on the AI * engines (POST /api/plugin/track-query, same catalogue and budget as the * dashboard form). Idempotent: an identical question is reused. */ function citami_gsc_track_query($query, $type = 'keyword') { $query = trim(preg_replace('/\s+/', ' ', sanitize_text_field($query))); if (mb_strlen($query) < 5) { return array('ok' => false, 'message' => citami_t('Domanda troppo corta.', 'Question too short.')); } if (citami_get_api_key() === '') { return array('ok' => false, 'message' => citami_t('Collega prima il sito alla piattaforma.', 'Connect the site to the platform first.')); } $res = wp_remote_post(citami_get_endpoint() . '/api/plugin/track-query', array( 'timeout' => 20, '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('query' => $query, 'type' => $type === 'conversational' ? 'conversational' : 'keyword')), )); if (is_wp_error($res)) { return array('ok' => false, 'message' => sprintf(citami_t('Richiesta non riuscita (%s).', 'Request failed (%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 === 402) { return array('ok' => false, 'upgrade' => 'questions', 'message' => citami_t('Hai usato tutte le domande del piano per questo sito: liberane una nella dashboard o passa a un piano superiore.', 'You have used every tracked question of the plan for this site: free one in the dashboard or move to a higher plan.')); } if ($code < 200 || $code >= 300 || !is_array($data)) { return array('ok' => false, 'message' => sprintf(citami_t('Richiesta non riuscita (HTTP %d).', 'Request failed (HTTP %d).'), $code)); } $dash = citami_gsc_dashboard_queries_url(); $msg = !empty($data['existed']) ? sprintf(citami_t('"%s" era già tra le domande tracciate.', '"%s" was already a tracked question.'), $query) : sprintf(citami_t('"%s" è tra le domande tracciate: dal prossimo audit vedi chi la cita.', '"%s" is now a tracked question: from the next audit you see who cites it.'), $query); if (isset($data['usage']['current'], $data['usage']['limit'])) { $msg .= ' ' . sprintf(citami_t('Domande usate: %1$d di %2$d.', 'Questions used: %1$d of %2$d.'), (int) $data['usage']['current'], (int) $data['usage']['limit']); } // The dashboard link sits on the question's row (a notice prints plain text only). $msg .= ' ' . citami_t('Il link alla dashboard è accanto alla domanda.', 'The dashboard link is next to the question.'); return array('ok' => true, 'message' => $msg, 'existed' => !empty($data['existed']), 'query' => $query, 'dashboard' => $dash); } /** * 2.56 — BULK-Q: a pasted list (one question per line, from a document, * an email or Excel) → clean, unique questions. Same rules as the * dashboard form; the platform re-checks them. */ function citami_track_parse_lines($text) { $r = citami_track_parse_report($text); return $r['kept']; } /** * 2.60 — the same cleaning, with what was left out and why: duplicates * (named), lines too short or too long, lines past the 200 cap. */ function citami_track_parse_report($text) { $out = array(); $seen = array(); $dups = array(); $invalid = array(); $over_cap = 0; foreach (preg_split('/\r\n|\r|\n/', (string) $text) as $line) { $q = preg_replace('/\t.*$/s', '', $line); // Excel paste: first column $q = trim(preg_replace('/\s+/u', ' ', $q)); for ($i = 0; $i < 2; $i++) { $q = trim(preg_replace('/^(?:\d{1,3}[.)]\s+|[-*•·–—>]\s*|\[[ xX]?\]\s+)/u', '', $q)); } $q = trim(preg_replace('/[;,]+$/', '', $q)); $q = trim(preg_replace('/^["\'“”«»‘’`]+|["\'“”«»‘’`]+$/u', '', $q)); $len = mb_strlen($q); if ($len < 5 || $len > 200) { if (trim($line) !== '') $invalid[] = $len > 200 ? mb_substr($q, 0, 40) . '…' : $q; continue; } $key = mb_strtolower($q); if (isset($seen[$key])) { $dups[] = $q; continue; } if (count($out) >= 200) { $over_cap++; continue; } $seen[$key] = true; $out[] = $q; } return array('kept' => $out, 'duplicates' => $dups, 'invalid' => $invalid, 'over_cap' => $over_cap); } /** «a», «b», «c» e altre 2 */ function citami_track_name_list(array $items, $max = 5) { $shown = array_map(function ($q) { return '«' . $q . '»'; }, array_slice($items, 0, $max)); $more = count($items) - count($shown); return implode(', ', $shown) . ($more > 0 ? ' ' . sprintf(citami_t('e altre %d', 'and %d more'), $more) : ''); } /** * 2.56 — BULK-Q: many questions tracked in one request (same endpoint * with `queries`). Questions beyond the plan's free slots are not saved * and are named in the message. */ function citami_gsc_track_queries(array $queries, $type = 'keyword') { $parsed = citami_track_parse_report(implode("\n", array_map('strval', $queries))); $queries = $parsed['kept']; if (empty($queries)) { return array('ok' => false, 'message' => citami_t('Nessuna domanda valida: scrivine una per riga, da 5 a 200 caratteri.', 'No valid question: write one per line, 5 to 200 characters.')); } if (citami_get_api_key() === '') { return array('ok' => false, 'message' => citami_t('Collega prima il sito alla piattaforma.', 'Connect the site to the platform first.')); } $res = wp_remote_post(citami_get_endpoint() . '/api/plugin/track-query', array( 'timeout' => 30, '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('queries' => $queries, 'type' => $type === 'conversational' ? 'conversational' : 'keyword')), )); if (is_wp_error($res)) { return array('ok' => false, 'message' => sprintf(citami_t('Richiesta non riuscita (%s).', 'Request failed (%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 === 402) { return array('ok' => false, 'upgrade' => 'questions', 'message' => citami_t('Hai usato tutte le domande del piano per questo sito: liberane qualcuna nella dashboard o passa a un piano superiore.', 'You have used every tracked question of the plan for this site: free some in the dashboard or move to a higher plan.')); } if ($code < 200 || $code >= 300 || !is_array($data)) { return array('ok' => false, 'message' => sprintf(citami_t('Richiesta non riuscita (HTTP %d).', 'Request failed (HTTP %d).'), $code)); } $saved = (int) ($data['created'] ?? 0) + (int) ($data['reactivated'] ?? 0); $over = isset($data['over_quota']) && is_array($data['over_quota']) ? array_values(array_map('strval', $data['over_quota'])) : array(); $parts = array(sprintf(citami_t('Domande aggiunte: %d.', 'Questions added: %d.'), $saved)); if (!empty($data['existing'])) $parts[] = sprintf(citami_t('Già tracciate: %d.', 'Already tracked: %d.'), (int) $data['existing']); if ($over) $parts[] = sprintf(citami_t('Non aggiunte per mancanza di slot: %d (restano nel riquadro).', 'Not added, no slots left: %d (kept in the box).'), count($over)); if ($parsed['duplicates']) $parts[] = sprintf(citami_t('Doppioni tolti: %1$d (%2$s).', 'Duplicates removed: %1$d (%2$s).'), count($parsed['duplicates']), citami_track_name_list($parsed['duplicates'])); if ($parsed['invalid']) $parts[] = sprintf(citami_t('Scartate perché più corte di 5 o più lunghe di 200 caratteri: %1$d (%2$s).', 'Dropped for being shorter than 5 or longer than 200 characters: %1$d (%2$s).'), count($parsed['invalid']), citami_track_name_list($parsed['invalid'])); if ($parsed['over_cap'] > 0) $parts[] = sprintf(citami_t('Oltre le 200 per volta: %d non inviate, incollale in un secondo invio.', 'Past 200 at a time: %d not sent, paste them in a second round.'), $parsed['over_cap']); if (isset($data['usage']['current'], $data['usage']['limit'])) { $parts[] = sprintf(citami_t('Domande usate: %1$d di %2$d.', 'Questions used: %1$d of %2$d.'), (int) $data['usage']['current'], (int) $data['usage']['limit']); } $parts[] = citami_t('Le misuriamo dal prossimo audit.', 'They are measured from the next audit.'); $parts[] = citami_t('Il link alla dashboard è nel riquadro "Traccia domande".', 'The dashboard link is in the "Track questions" box.'); return array( 'ok' => true, 'message' => implode(' ', $parts), 'saved' => $saved, 'over_quota' => $over, 'existing' => (int) ($data['existing'] ?? 0), 'duplicates' => $parsed['duplicates'], 'invalid' => $parsed['invalid'], 'over_cap' => $parsed['over_cap'], 'sent' => $queries, 'dashboard' => citami_gsc_dashboard_queries_url(), ); } function citami_handle_gsc_action() { if (!current_user_can('manage_options')) { wp_die('Permesso negato.', '', array('response' => 403)); } if (!wp_verify_nonce(isset($_POST['_citami_gsc_nonce']) ? sanitize_text_field(wp_unslash($_POST['_citami_gsc_nonce'])) : '', 'citami_gsc')) { wp_die('Nonce non valido.', '', array('response' => 403)); } $do = isset($_POST['do']) ? sanitize_key((string) $_POST['do']) : 'refresh'; $tracked = array(); $dash = ''; $anchor = ''; if ($do === 'meta') { $pid = absint(wp_unslash($_POST['post_id'] ?? 0)); $r = citami_gsc_apply_meta($pid, sanitize_text_field(wp_unslash($_POST['title'] ?? '')), sanitize_text_field(wp_unslash($_POST['description'] ?? '')), sanitize_text_field(sanitize_text_field(wp_unslash($_POST['query'] ?? '')))); if (!empty($r['ok']) && defined('CITAMI_META_PAGE_DRAFT')) { // 2.64 — the H1 comes from the stored AI proposal, only when the box was ticked. $ai = get_post_meta($pid, CITAMI_META_PAGE_DRAFT, true); if (!empty($_POST['apply_h1']) && is_array($ai) && !empty($ai['h1']) && function_exists('citami_pages_apply_h1')) { $h = citami_pages_apply_h1($pid, $ai['h1']); $r['message'] .= ' ' . $h['message']; } delete_post_meta($pid, CITAMI_META_PAGE_DRAFT); } $notice = array('type' => !empty($r['ok']) ? 'success' : 'error', 'message' => $r['message']); $anchor = 'citami-gsc-ctr'; } elseif ($do === 'meta_ai' || $do === 'meta_ai_bulk') { // 2.64 — title, description (and H1) written by the platform AI from the page's queries. $ids = $do === 'meta_ai' ? array(absint(wp_unslash($_POST['post_id'] ?? 0))) : array_map('absint', (array) wp_unslash($_POST['post_ids'] ?? array())); $r = citami_gsc_meta_ai(array_slice(array_values(array_unique(array_filter($ids))), 0, CITAMI_GSC_AI_BULK_MAX)); $notice = array('type' => $r['ok'] ? 'success' : 'error', 'message' => $r['message'], 'upgrade' => isset($r['upgrade']) ? $r['upgrade'] : ''); $anchor = 'citami-gsc-ctr'; } elseif ($do === 'faq') { $r = citami_gsc_apply_faq(absint(wp_unslash($_POST['post_id'] ?? 0)), sanitize_text_field(wp_unslash($_POST['query'] ?? '')), isset($_POST['answer']) ? sanitize_textarea_field(wp_unslash($_POST['answer'])) : null); $notice = array('type' => !empty($r['ok']) ? 'success' : 'error', 'message' => $r['message']); } elseif ($do === 'track') { $r = citami_gsc_track_query(sanitize_text_field(wp_unslash($_POST['query'] ?? '')), sanitize_key(wp_unslash($_POST['type'] ?? 'keyword'))); $notice = array('type' => !empty($r['ok']) ? 'success' : 'error', 'message' => $r['message'], 'upgrade' => isset($r['upgrade']) ? $r['upgrade'] : ''); if (!empty($r['ok'])) { $tracked = array(mb_strtolower((string) $r['query'])); $dash = (string) $r['dashboard']; $anchor = 'citami-gsc-tracked'; } } elseif ($do === 'track_bulk') { $type = sanitize_key(wp_unslash($_POST['type'] ?? 'keyword')); if (isset($_POST['queries_list']) && is_array($_POST['queries_list'])) { $list = array_map('sanitize_text_field', wp_unslash($_POST['queries_list'])); } else { $list = preg_split('/\r\n|\r|\n/', sanitize_textarea_field(wp_unslash($_POST['queries'] ?? ''))); } $r = citami_gsc_track_queries((array) $list, $type); $notice = array('type' => empty($r['ok']) ? 'error' : (!empty($r['over_quota']) ? 'warning' : 'success'), 'message' => $r['message'], 'upgrade' => !empty($r['upgrade']) || !empty($r['over_quota']) ? 'questions' : ''); // Questions left out (no slots) or a list that failed stay in the box. $keep = !empty($r['ok']) ? (array) ($r['over_quota'] ?? array()) : (isset($_POST['queries']) ? (array) $list : array()); if ($keep) set_transient('citami_track_bulk_draft_' . get_current_user_id(), implode("\n", $keep), 600); if (!empty($r['ok'])) { $over_keys = array_map('mb_strtolower', (array) $r['over_quota']); $tracked = array_values(array_diff(array_map('mb_strtolower', (array) $r['sent']), $over_keys)); $dash = (string) $r['dashboard']; $anchor = isset($_POST['queries_list']) ? 'citami-gsc-questions' : 'citami-track-bulk-card'; } } elseif ($do === 'indexnow') { $url = get_permalink(absint(wp_unslash($_POST['post_id'] ?? 0))); $r = $url && function_exists('citami_indexnow_submit_now') ? citami_indexnow_submit_now(array($url)) : array('added' => 0); $notice = array('type' => $r['added'] > 0 ? 'success' : 'warning', 'message' => $r['added'] > 0 ? citami_t('URL inviato a IndexNow.', 'URL sent to IndexNow.') : citami_t('Niente inviato: IndexNow spento o URL già in coda.', 'Nothing sent: IndexNow off or URL already queued.')); } else { $d = citami_gsc_data(true); $notice = !empty($d['ok']) && empty($d['stale_error']) ? array('type' => 'success', 'message' => citami_t('Dati Search Console aggiornati.', 'Search Console data refreshed.')) : array('type' => 'error', 'message' => sprintf(citami_t('Aggiornamento non riuscito (%s).', 'Refresh failed (%s).'), isset($d['stale_error']) && $d['stale_error'] !== '' ? $d['stale_error'] : (isset($d['error']) ? $d['error'] : '?'))); } if (!empty($tracked)) { set_transient(citami_gsc_tracked_key(), array('queries' => $tracked, 'dash' => $dash), 10 * MINUTE_IN_SECONDS); } set_transient('citami_action_notice', $notice, 30); wp_safe_redirect(citami_admin_url('gsc', !empty($_POST['gsc_all']) ? array('gsc_all' => 1) : array()) . (!empty($anchor) ? '#' . $anchor : '')); exit; } add_action('admin_post_citami_gsc_action', 'citami_handle_gsc_action'); function citami_render_gsc_kpis(array $d) { $t = isset($d['totals']) && is_array($d['totals']) ? $d['totals'] : array('clicks' => 0, 'impressions' => 0, 'ctr' => 0, 'position' => 0); $p = isset($d['previous']) && is_array($d['previous']) ? $d['previous'] : array('clicks' => 0, 'impressions' => 0, 'ctr' => 0, 'position' => 0); $tiles = array( array(citami_t('Clic', 'Clicks'), number_format_i18n((int) $t['clicks']), citami_gsc_fmt_delta((int) $t['clicks'] - (int) $p['clicks'])), array(citami_t('Impressioni', 'Impressions'), number_format_i18n((int) $t['impressions']), citami_gsc_fmt_delta((int) $t['impressions'] - (int) $p['impressions'])), array('CTR', citami_gsc_fmt_pct($t['ctr']), '' . esc_html(citami_t('prima', 'before')) . ' ' . esc_html(citami_gsc_fmt_pct($p['ctr'])) . ''), array(citami_t('Posizione media', 'Average position'), number_format_i18n((float) $t['position'], 1), '' . esc_html(citami_t('prima', 'before')) . ' ' . esc_html(number_format_i18n((float) $p['position'], 1)) . ''), ); echo '
'; foreach ($tiles as $tile) { echo wp_kses('
' . esc_html($tile[0]) . '
' . esc_html($tile[1]) . '
' . $tile[2] . '
', citami_kses_allowed()); } echo '
'; } function citami_render_gsc_opportunities(array $d) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display only: lift the caps $o = citami_gsc_opportunities($d, !empty($_GET['gsc_all']) ? 0 : CITAMI_GSC_OPP_MAX); $busy_track = citami_t('Invio alla piattaforma…', 'Sending to the platform…'); $fmt_pct = 'citami_gsc_fmt_pct'; ?>

1): $hidden = ''; foreach ($todo as $tid) $hidden .= ''; echo wp_kses(citami_gsc_form_open('meta_ai_bulk', $hidden, '', 'citami-inline-form citami-gsc-ai-bulk'), citami_kses_allowed()); ?>

$r): $fid = 'citami-gsc-meta-' . (int) $i; $mark = citami_gsc_tracked_mark($r['query']); $ai = citami_gsc_ai_draft($r['post_id']); if ($ai) { $r['draft_title'] = $ai['title']; $r['draft_description'] = $ai['description']; } ?> >


', $fid, 'citami-draft-form'), citami_kses_allowed()); ?> ' . esc_html(citami_t('nessuna: Google ne sceglie una dal testo', 'none: Google picks one from the text')) . ''; ?>


', $fid . '-ai'), citami_kses_allowed()); ?>
', $fid . '-t'), citami_kses_allowed()); ?>' . wp_kses($mark, citami_kses_allowed()) : ''; ?>

1): $all = ''; foreach ($o['questions'] as $r) $all .= ''; $nq = count($o['questions']); $confirm = sprintf(citami_t('Tracciare queste %1$d domande nelle AI? Usano fino a %1$d slot del piano (quelle già tracciate non ne consumano altri).', 'Track these %1$d questions on the AI engines? They use up to %1$d slots of the plan (already tracked ones use none).'), $nq) . "\n\n" . citami_track_name_list(array_map(function ($r) { return (string) $r['query']; }, $o['questions']), 10); echo wp_kses(citami_gsc_form_open('track_bulk', $all, '', 'citami-inline-form citami-track-all'), citami_kses_allowed()); ?>

>
ΔCTR
Δ
0 ? '' . esc_html($label) . '' : esc_html($label); ?>
25): ?>

·
' . esc_html(citami_t('Search Console non collegata o senza dati.', 'Search Console not connected or without data.')) . ' ' . esc_html(citami_t('Dettagli', 'Details')) . '

'; return; } echo '
'; citami_render_gsc_kpis($d); echo '
'; echo '

' . esc_html(citami_t('Domande e pagine →', 'Queries and pages →')) . '

'; } add_action('wp_dashboard_setup', function () { if (citami_get_api_key() === '' || !current_user_can('edit_posts')) { return; } wp_add_dashboard_widget('citami_gsc', citami_brand_name() . ' · Search Console (28 ' . citami_t('giorni', 'days') . ')', 'citami_gsc_render_widget'); }); function citami_gsc_add_column($columns) { if (is_array($columns) && !empty(citami_gsc_pages_map())) { $columns['citami_gsc'] = citami_t('Clic 28g', 'Clicks 28d'); } return $columns; } add_filter('manage_posts_columns', 'citami_gsc_add_column'); add_filter('manage_pages_columns', 'citami_gsc_add_column'); function citami_gsc_render_column($column, $post_id) { if ($column !== 'citami_gsc') { return; } $row = citami_gsc_for_post($post_id); if (!$row) { echo '–'; return; } echo wp_kses('' . (int) $row['clicks'] . ' ' . citami_gsc_fmt_delta($row['clicksDelta']), citami_kses_allowed()); } add_action('manage_posts_custom_column', 'citami_gsc_render_column', 10, 2); add_action('manage_pages_custom_column', 'citami_gsc_render_column', 10, 2); add_action('citami_seo_meta_box_essentials', function ($post) { $row = citami_gsc_for_post($post->ID); if (!$row) { return; } echo wp_kses('

Search Console (28 ' . esc_html(citami_t('giorni', 'days')) . '): ' . (int) $row['clicks'] . ' ' . esc_html(citami_t('clic', 'clicks')) . ' ' . citami_gsc_fmt_delta($row['clicksDelta']) . ' · ' . (int) $row['impressions'] . ' ' . esc_html(citami_t('impressioni', 'impressions')) . ' · ' . esc_html(citami_t('posizione', 'position')) . ' ' . esc_html(number_format_i18n((float) $row['position'], 1)) . '

', citami_kses_allowed()); // 2.43 — the queries this page is found for: the context for an update. $queries = citami_gsc_queries_for_post($post->ID, 5); if (!empty($queries)) { echo wp_kses('

' . esc_html(citami_t('Cercano:', 'They search:')) . ' ' . implode(' · ', array_map(function ($q) { return '' . esc_html($q['query']) . ' ' . (int) $q['impressions'] . ''; }, $queries)) . '

', citami_kses_allowed()); // 2.64 — title, description and H1 written by the AI from these searches, into the fields below. if (citami_get_api_key() !== '') { echo wp_kses('

', citami_kses_allowed()); } } }, 5); /** 2.64 — REST for the editor: { post_id } → AI title, description, H1 (stored as the page's AI proposal). */ function citami_rest_meta_ai(WP_REST_Request $request) { $body = $request->get_json_params(); $post_id = is_array($body) && isset($body['post_id']) ? (int) $body['post_id'] : 0; if ($post_id <= 0 || !get_post($post_id)) { return new WP_Error('citami_meta_ai_post', 'post_id required', array('status' => 400)); } if (!current_user_can('edit_post', $post_id)) { return new WP_Error('citami_meta_ai_forbidden', 'forbidden', array('status' => 403)); } $r = function_exists('citami_pages_ai_request') ? citami_pages_ai_request($post_id, 'meta') : array('ok' => false, 'message' => ''); $d = !empty($r['ok']) ? citami_gsc_ai_draft($post_id) : null; return rest_ensure_response(array( 'ok' => (bool) $d, 'message' => $d ? citami_t('Proposta pronta nei campi qui sotto. Salva la pagina per tenerla.', 'Proposal ready in the fields below. Save the page to keep it.') : (string) $r['message'], 'draft' => $d ? array('title' => $d['title'], 'description' => $d['description'], 'h1' => isset($d['h1']) ? $d['h1'] : '', 'why' => isset($d['why']) ? $d['why'] : '') : null, )); } add_action('rest_api_init', function () { register_rest_route('citami/v1', '/meta-ai', array( 'methods' => 'POST', 'callback' => 'citami_rest_meta_ai', 'permission_callback' => function () { return current_user_can('edit_posts'); }, )); }); /** 2.64 — once the page is saved with the proposed title, the proposal is used up. */ add_action('citami_seo_save_extra', function ($post_id, $in) { $d = citami_gsc_ai_draft($post_id); if ($d && is_array($in) && isset($in['title']) && trim((string) $in['title']) === $d['title']) { delete_post_meta((int) $post_id, CITAMI_META_PAGE_DRAFT); } }, 10, 2); /** * 2.64 — editor: the button calls /meta-ai, fills the SEO title and * description fields (saved with the page) and offers the H1, which goes * into the editor's own title field (classic or block) — nothing is saved * until the page is. */ function citami_gsc_meta_ai_inline_js() { $cfg = array( 'rest' => rest_url('citami/v1/meta-ai'), 'nonce' => wp_create_nonce('wp_rest'), 't' => array( 'busy' => citami_t('Scrivo… 10-20 secondi', 'Writing… 10-20 seconds'), 'h1' => citami_t('Titolo della pagina (H1) proposto:', 'Proposed page title (H1):'), 'useH1' => citami_t('Usa come titolo della pagina', 'Use as page title'), 'h1Done' => citami_t('Titolo della pagina aggiornato nell\'editor: salva per tenerlo.', 'Page title updated in the editor: save to keep it.'), 'error' => citami_t('Non riuscito: riprova tra poco.', 'Did not work: try again shortly.'), ), ); $js = <<<'JS' (function(C){ function esc(s){return String(s==null?"":s).replace(/[&<>"']/g,function(c){return {"&":"&","<":"<",">":">","\"":""","'":"'"}[c];});} function setVal(id,v){var el=document.getElementById(id);if(!el)return;el.value=v;el.dispatchEvent(new Event("input",{bubbles:true}));el.dispatchEvent(new Event("change",{bubbles:true}));} function setTitle(v){try{if(window.wp&&wp.data&&wp.data.dispatch("core/editor")){wp.data.dispatch("core/editor").editPost({title:v});return true;}}catch(e){}var t=document.getElementById("title");if(t){t.value=v;var l=document.getElementById("title-prompt-text");if(l)l.className="screen-reader-text";return true;}return false;} document.addEventListener("click",function(e){ var b=e.target.closest?e.target.closest('[data-citami="meta-ai"]'):null; if(b){e.preventDefault();var out=document.getElementById("citami-meta-ai");var label=b.innerHTML;b.disabled=true;b.textContent=C.t.busy; fetch(C.rest,{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json","X-WP-Nonce":C.nonce},body:JSON.stringify({post_id:+b.getAttribute("data-post")})}) .then(function(r){return r.json();}).then(function(r){b.disabled=false;b.innerHTML=label;if(!out)return;out.hidden=false; if(!r||!r.ok||!r.draft){out.innerHTML='

'+esc(r&&r.message?r.message:C.t.error)+"

";return;} setVal("citami_seo_title",r.draft.title);setVal("citami_seo_description",r.draft.description); var h='

'+esc(r.message)+(r.draft.why?" "+esc(r.draft.why):"")+"

"; if(r.draft.h1){h+='

'+esc(C.t.h1)+" "+esc(r.draft.h1)+' '+esc(C.t.useH1)+"

";} out.innerHTML=h;}) .catch(function(){b.disabled=false;b.innerHTML=label;if(out){out.hidden=false;out.innerHTML='

'+esc(C.t.error)+"

";}}); return;} var u=e.target.closest?e.target.closest('[data-citami="meta-ai-h1"]'):null; if(u){e.preventDefault();if(setTitle(u.getAttribute("data-h1"))){u.outerHTML=""+esc(C.t.h1Done)+"";}} }); })(CITAMI_CFG); JS; return str_replace('CITAMI_CFG', wp_json_encode($cfg), $js); } /** Top queries of a post from the query × page rows (impressions desc). */ function citami_gsc_queries_for_post($post_id, $limit = 5) { $d = citami_gsc_data(); if (empty($d['ok']) || empty($d['pairs']) || !is_array($d['pairs'])) { return array(); } $key = citami_gsc_page_key(get_permalink((int) $post_id)); $rows = array(); foreach ($d['pairs'] as $r) { if (is_array($r) && !empty($r['page']) && citami_gsc_page_key($r['page']) === $key) { $rows[] = $r; } } usort($rows, function ($a, $b) { return (int) $b['impressions'] <=> (int) $a['impressions']; }); return array_slice($rows, 0, $limit); } // A changed API key invalidates the cached panel. add_action('updated_option', function ($option) { if ($option === 'citami_api_key') { delete_transient(CITAMI_GSC_TRANSIENT); } });