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('#?(p|h[1-6]|li|ul|ol|div|br|tr|td|th|table|blockquote|figure|figcaption|section|article)\b[^>]*>#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 '
' . 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 ''; 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)) . '
'+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+='";} 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); } });