commit 6793fbfc6d9fcc3f3a1d25c26f088d798d8354ec Author: Alex Cube Date: Sun Oct 25 17:04:36 2026 +0300 SEOJak 1.0.0: SEO plugin for WordPress Co-Authored-By: Claude Opus 5.5 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e8f79d8 --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# SEOJak + +Лёгкий SEO-плагин для WordPress. Написан для [lexium.ru](https://www.lexium.ru/) на замену +All in One SEO: те же возможности бесплатной версии, без рекламы и лишних таблиц. + +- Заголовок, описание и «не индексировать» для каждой записи и страницы — блок «SEO» в + редакторе со счётчиками символов и предпросмотром сниппета. +- ``, meta description, robots, canonical, prev/next. +- Open Graph и Twitter Card; картинка — превью записи, иначе картинка по умолчанию. +- Schema.org одним JSON-LD: WebSite, Person, WebPage, BlogPosting, BreadcrumbList. +- Карта сайта — встроенная в WordPress (`/wp-sitemap.xml`): только записи, страницы и + открытые для индексации рубрики, без noindex-записей. Старые `/sitemap.xml` и + `post-sitemap.xml` перенаправляются на неё. +- IndexNow: после публикации и обновления адрес сообщается Яндексу (не чаще раза в + 10 минут на запись, только на production). +- Перенос из All in One SEO: при активации забирает настройки главной, автора, соцсетей и + данные записей из таблицы `aioseo_posts`. Повторно: `wp seojak import-aioseo`. + +Пока All in One SEO активен, SEOJak на сайте ничего не выводит — можно ставить рядом и +переключаться без пустого окна. + +Настройки: «Настройки → SEOJak». Данные записей лежат в postmeta +`_seojak_title`, `_seojak_description`, `_seojak_noindex` и доступны через REST API. + +Требования: WordPress 6.4+, PHP 7.4+. + +## Деплой на lexium.ru + +Рабочая копия для сайта — `www/wp-content/plugins/seojak/` в репозитории lexium.ru. +Правка здесь → копия туда → коммит и сборка по правилам lexium.ru. diff --git a/assets/icon-128x128.png b/assets/icon-128x128.png new file mode 100644 index 0000000..a1d7d49 Binary files /dev/null and b/assets/icon-128x128.png differ diff --git a/assets/icon-256x256.png b/assets/icon-256x256.png new file mode 100644 index 0000000..47337f1 Binary files /dev/null and b/assets/icon-256x256.png differ diff --git a/assets/icon.svg b/assets/icon.svg new file mode 100644 index 0000000..74e80bb --- /dev/null +++ b/assets/icon.svg @@ -0,0 +1,17 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"> + <defs> + <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"> + <stop offset="0" stop-color="#1f3b73"/> + <stop offset="1" stop-color="#0f1e3d"/> + </linearGradient> + </defs> + <rect width="256" height="256" rx="48" fill="url(#bg)"/> + <!-- линза --> + <circle cx="112" cy="104" r="58" fill="#ffffff" fill-opacity=".08" stroke="#ffffff" stroke-width="18"/> + <!-- столбики «позиций» внутри линзы --> + <rect x="82" y="112" width="14" height="26" rx="4" fill="#7fd1ff"/> + <rect x="105" y="96" width="14" height="42" rx="4" fill="#7fd1ff"/> + <rect x="128" y="78" width="14" height="60" rx="4" fill="#ffc94d"/> + <!-- ручка лупы загибается в букву J --> + <path d="M154 146 L188 180 L188 206 A26 26 0 0 1 136 206" fill="none" stroke="#ffc94d" stroke-width="22" stroke-linecap="round" stroke-linejoin="round"/> +</svg> diff --git a/seojak.php b/seojak.php new file mode 100644 index 0000000..94b3597 --- /dev/null +++ b/seojak.php @@ -0,0 +1,630 @@ +<?php +/** + * Plugin Name: SEOJak + * Description: SEO без лишнего: заголовок и описание записи, robots, canonical, Open Graph, Schema.org, карта сайта и IndexNow для Яндекса. + * Version: 1.0.0 + * Author: Алексей Якубов + * Author URI: https://www.lexium.ru/about-me/ + * License: GPL-2.0-or-later + * Requires at least: 6.4 + * Requires PHP: 7.4 + * Text Domain: seojak + */ + +defined( 'ABSPATH' ) || exit; + +const SEOJAK_VERSION = '1.0.0'; +const SEOJAK_OPTION = 'seojak'; + +/* ===================== Настройки ===================== */ + +function seojak_defaults(): array { + return array( + 'home_title' => get_bloginfo( 'name' ), + 'home_description' => get_bloginfo( 'description' ), + 'separator' => '|', + 'person_name' => '', + 'person_image' => '', + 'default_image' => '', + 'twitter_site' => '', + 'same_as' => '', + 'noindex_category' => 1, + 'noindex_tag' => 1, + 'noindex_author' => 1, + 'noindex_date' => 1, + 'indexnow' => 1, + 'indexnow_key' => '', + ); +} + +function seojak_opt( string $key ) { + static $opts = null; + if ( null === $opts ) { + $opts = wp_parse_args( (array) get_option( SEOJAK_OPTION, array() ), seojak_defaults() ); + } + return $opts[ $key ] ?? null; +} + +/* ===================== Данные записи ===================== */ + +const SEOJAK_META = array( + '_seojak_title' => 'string', + '_seojak_description' => 'string', + '_seojak_noindex' => 'boolean', +); + +add_action( 'init', function () { + foreach ( array( 'post', 'page' ) as $type ) { + foreach ( SEOJAK_META as $key => $kind ) { + register_post_meta( $type, $key, array( + 'type' => $kind, + 'single' => true, + 'show_in_rest' => true, + 'auth_callback' => function ( $allowed, $meta_key, $post_id ) { + return current_user_can( 'edit_post', $post_id ); + }, + ) ); + } + } +} ); + +function seojak_post_description( WP_Post $post ): string { + $custom = trim( (string) get_post_meta( $post->ID, '_seojak_description', true ) ); + if ( '' !== $custom ) { + return $custom; + } + $text = '' !== trim( $post->post_excerpt ) ? $post->post_excerpt : strip_shortcodes( $post->post_content ); + if ( function_exists( 'trim_text' ) ) { + return trim_text( $text, 160 ); + } + return wp_trim_words( wp_strip_all_tags( $text ), 25, '…' ); +} + +/* ===================== Что за страница сейчас ===================== */ + +/** Всё, что нужно для <head> текущей страницы. */ +function seojak_context(): array { + static $ctx = null; + if ( null !== $ctx ) { + return $ctx; + } + $sep = seojak_opt( 'separator' ); + $site = get_bloginfo( 'name' ); + $ctx = array( + 'title' => '', + 'description' => '', + 'url' => '', + 'type' => 'website', + 'image' => seojak_opt( 'default_image' ), + 'noindex' => false, + 'post' => null, + ); + + if ( is_front_page() || is_home() ) { + $ctx['title'] = seojak_opt( 'home_title' ); + $ctx['description'] = seojak_opt( 'home_description' ); + $ctx['url'] = home_url( '/' ); + } elseif ( is_singular() ) { + $post = get_queried_object(); + $custom = trim( (string) get_post_meta( $post->ID, '_seojak_title', true ) ); + $ctx['post'] = $post; + $ctx['title'] = '' !== $custom ? $custom : single_post_title( '', false ); + $ctx['description'] = seojak_post_description( $post ); + $ctx['url'] = get_permalink( $post ); + $ctx['type'] = 'article'; + $ctx['noindex'] = (bool) get_post_meta( $post->ID, '_seojak_noindex', true ); + if ( has_post_thumbnail( $post ) ) { + $ctx['image'] = get_the_post_thumbnail_url( $post, 'large' ); + } + } elseif ( is_category() || is_tag() || is_tax() ) { + $term = get_queried_object(); + $ctx['name'] = $term->name; + $ctx['description'] = wp_strip_all_tags( term_description( $term ) ); + $ctx['url'] = get_term_link( $term ); + $ctx['noindex'] = ( is_category() && seojak_opt( 'noindex_category' ) ) || ( is_tag() && seojak_opt( 'noindex_tag' ) ); + } elseif ( is_author() ) { + $ctx['name'] = get_the_author_meta( 'display_name', get_queried_object_id() ); + $ctx['url'] = get_author_posts_url( get_queried_object_id() ); + $ctx['noindex'] = (bool) seojak_opt( 'noindex_author' ); + } elseif ( is_date() ) { + $ctx['name'] = wp_strip_all_tags( get_the_archive_title() ); + $ctx['noindex'] = (bool) seojak_opt( 'noindex_date' ); + $ctx['url'] = is_year() ? get_year_link( get_query_var( 'year' ) ) : ( is_month() ? get_month_link( get_query_var( 'year' ), get_query_var( 'monthnum' ) ) : get_day_link( get_query_var( 'year' ), get_query_var( 'monthnum' ), get_query_var( 'day' ) ) ); + } elseif ( is_search() ) { + $ctx['name'] = get_search_query(); + $ctx['noindex'] = true; + } + if ( isset( $ctx['name'] ) ) { + $ctx['title'] = $ctx['name'] . " $sep $site"; + } + + // Вторая и дальше страницы списков: свой адрес, без индексации, ссылки по ним — да. + $paged = (int) get_query_var( 'paged' ); + if ( $paged > 1 && '' !== $ctx['title'] ) { + $ctx['noindex'] = true; + if ( $ctx['url'] ) { + $ctx['url'] = get_pagenum_link( $paged ); + } + $ctx['title'] .= " $sep Страница $paged"; + } + + if ( ! $ctx['image'] && seojak_opt( 'person_image' ) ) { + $ctx['image'] = seojak_opt( 'person_image' ); + } + return $ctx; +} + +/* ===================== <head> ===================== */ + +// Пока активен All in One SEO, фронтенд не трогаем: два плагина — два набора тегов. +function seojak_front_enabled(): bool { + return ! defined( 'AIOSEO_VERSION' ) && ! is_admin() && ! is_feed(); +} + +add_filter( 'pre_get_document_title', function ( $title ) { + if ( ! seojak_front_enabled() ) { + return $title; + } + $t = seojak_context()['title']; + return '' !== $t ? $t : $title; +}, 20 ); + +add_filter( 'wp_robots', function ( array $robots ) { + if ( seojak_front_enabled() && seojak_context()['noindex'] ) { + $robots['noindex'] = true; + $robots['follow'] = true; + } + return $robots; +} ); + +add_action( 'wp', function () { + if ( seojak_front_enabled() ) { + remove_action( 'wp_head', 'rel_canonical' ); // свой canonical — для всех страниц, а не только записей + } +} ); + +add_action( 'wp_head', function () { + if ( ! seojak_front_enabled() || is_404() ) { + return; + } + $c = seojak_context(); + $tags = array(); + + if ( $c['description'] ) { + $tags[] = array( 'name', 'description', $c['description'] ); + } + if ( $c['url'] && ! $c['noindex'] ) { + echo '<link rel="canonical" href="' . esc_url( $c['url'] ) . "\" />\n"; + } + foreach ( array( 'prev' => get_previous_posts_page_link(), 'next' => get_next_posts_page_link() ) as $rel => $link ) { + if ( ! is_singular() && $link && ( 'prev' === $rel ? get_query_var( 'paged' ) > 1 : max( 1, get_query_var( 'paged' ) ) < $GLOBALS['wp_query']->max_num_pages ) ) { + echo '<link rel="' . $rel . '" href="' . esc_url( $link ) . "\" />\n"; + } + } + + $tags[] = array( 'property', 'og:locale', get_locale() ); + $tags[] = array( 'property', 'og:site_name', get_bloginfo( 'name' ) ); + $tags[] = array( 'property', 'og:type', $c['type'] ); + $tags[] = array( 'property', 'og:title', $c['title'] ); + if ( $c['description'] ) { + $tags[] = array( 'property', 'og:description', $c['description'] ); + } + if ( $c['url'] ) { + $tags[] = array( 'property', 'og:url', $c['url'] ); + } + if ( $c['image'] ) { + $tags[] = array( 'property', 'og:image', $c['image'] ); + } + if ( $c['post'] && 'article' === $c['type'] ) { + $tags[] = array( 'property', 'article:published_time', get_post_time( 'c', true, $c['post'] ) ); + $tags[] = array( 'property', 'article:modified_time', get_post_modified_time( 'c', true, $c['post'] ) ); + } + $tags[] = array( 'name', 'twitter:card', $c['image'] ? 'summary_large_image' : 'summary' ); + if ( seojak_opt( 'twitter_site' ) ) { + $tags[] = array( 'name', 'twitter:site', seojak_opt( 'twitter_site' ) ); + } + $tags[] = array( 'name', 'twitter:title', $c['title'] ); + if ( $c['description'] ) { + $tags[] = array( 'name', 'twitter:description', $c['description'] ); + } + if ( $c['image'] ) { + $tags[] = array( 'name', 'twitter:image', $c['image'] ); + } + + echo "<!-- SEOJak -->\n"; + foreach ( $tags as list( $attr, $name, $content ) ) { + printf( "<meta %s=\"%s\" content=\"%s\" />\n", $attr, esc_attr( $name ), esc_attr( $content ) ); + } + echo '<script type="application/ld+json">' . wp_json_encode( seojak_schema( $c ), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ) . "</script>\n"; + echo "<!-- /SEOJak -->\n"; +}, 1 ); + +/** Разметка Schema.org: сайт, автор, страница, статья, хлебные крошки. */ +function seojak_schema( array $c ): array { + $home = home_url( '/' ); + $person = array( + '@type' => 'Person', + '@id' => $home . '#person', + 'name' => seojak_opt( 'person_name' ) ?: get_bloginfo( 'name' ), + 'url' => $home, + ); + if ( seojak_opt( 'person_image' ) ) { + $person['image'] = seojak_opt( 'person_image' ); + } + $same = array_values( array_filter( array_map( 'trim', explode( "\n", (string) seojak_opt( 'same_as' ) ) ) ) ); + if ( $same ) { + $person['sameAs'] = $same; + } + $graph = array( + array( + '@type' => 'WebSite', + '@id' => $home . '#website', + 'url' => $home, + 'name' => get_bloginfo( 'name' ), + 'inLanguage' => get_bloginfo( 'language' ), + 'publisher' => array( '@id' => $home . '#person' ), + ), + $person, + ); + if ( ! $c['url'] ) { + return array( '@context' => 'https://schema.org', '@graph' => $graph ); + } + + $page = array( + '@type' => 'WebPage', + '@id' => $c['url'] . '#webpage', + 'url' => $c['url'], + 'name' => $c['title'], + 'isPartOf' => array( '@id' => $home . '#website' ), + 'inLanguage' => get_bloginfo( 'language' ), + ); + if ( $c['description'] ) { + $page['description'] = $c['description']; + } + + $crumbs = array( array( 'Главная', $home ) ); + if ( $c['post'] && 'post' === $c['post']->post_type ) { + $cat = get_the_category( $c['post']->ID ); + if ( $cat ) { + foreach ( array_reverse( get_ancestors( $cat[0]->term_id, 'category' ) ) as $parent ) { + $crumbs[] = array( get_cat_name( $parent ), get_category_link( $parent ) ); + } + $crumbs[] = array( $cat[0]->name, get_category_link( $cat[0] ) ); + } + } + if ( ! is_front_page() && ! is_home() ) { + $crumbs[] = array( $c['post'] ? get_the_title( $c['post'] ) : ( $c['name'] ?? $c['title'] ), $c['url'] ); + $items = array(); + foreach ( $crumbs as $i => list( $name, $url ) ) { + $items[] = array( '@type' => 'ListItem', 'position' => $i + 1, 'name' => wp_strip_all_tags( $name ), 'item' => $url ); + } + $graph[] = array( '@type' => 'BreadcrumbList', '@id' => $c['url'] . '#breadcrumbs', 'itemListElement' => $items ); + $page['breadcrumb'] = array( '@id' => $c['url'] . '#breadcrumbs' ); + } + $graph[] = $page; + + if ( $c['post'] && 'post' === $c['post']->post_type ) { + $article = array( + '@type' => 'BlogPosting', + '@id' => $c['url'] . '#article', + 'headline' => wp_strip_all_tags( get_the_title( $c['post'] ) ), + 'datePublished' => get_post_time( 'c', true, $c['post'] ), + 'dateModified' => get_post_modified_time( 'c', true, $c['post'] ), + 'author' => array( '@id' => $home . '#person' ), + 'publisher' => array( '@id' => $home . '#person' ), + 'mainEntityOfPage' => array( '@id' => $c['url'] . '#webpage' ), + 'inLanguage' => get_bloginfo( 'language' ), + ); + if ( $c['image'] ) { + $article['image'] = $c['image']; + } + if ( $c['description'] ) { + $article['description'] = $c['description']; + } + $graph[] = $article; + } + return array( '@context' => 'https://schema.org', '@graph' => $graph ); +} + +/* ===================== Карта сайта (встроенная в WordPress) ===================== */ + +add_filter( 'wp_sitemaps_post_types', function ( $types ) { + return array_intersect_key( $types, array_flip( array( 'post', 'page' ) ) ); +} ); +// Рубрики — в карту, только если открыты для индексации; метки не нужны. +add_filter( 'wp_sitemaps_taxonomies', function ( $taxonomies ) { + return seojak_opt( 'noindex_category' ) ? array() : array_intersect_key( $taxonomies, array( 'category' => 1 ) ); +} ); +add_filter( 'wp_sitemaps_add_provider', function ( $provider, $name ) { + return 'users' === $name ? false : $provider; +}, 10, 2 ); +add_filter( 'wp_sitemaps_posts_query_args', function ( $args ) { + $args['meta_query'] = array( + 'relation' => 'OR', + array( 'key' => '_seojak_noindex', 'compare' => 'NOT EXISTS' ), + array( 'key' => '_seojak_noindex', 'value' => '1', 'compare' => '!=' ), + ); + return $args; +} ); + +// Старый адрес карты сайта и ключ IndexNow. +add_action( 'template_redirect', function () { + if ( defined( 'AIOSEO_VERSION' ) ) { + return; + } + $path = trim( (string) wp_parse_url( $_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH ), '/' ); + if ( in_array( $path, array( 'sitemap.xml', 'sitemap_index.xml', 'post-sitemap.xml', 'page-sitemap.xml' ), true ) ) { + wp_safe_redirect( home_url( '/wp-sitemap.xml' ), 301 ); + exit; + } + $key = seojak_indexnow_key(); + if ( $key && $path === $key . '.txt' ) { + status_header( 200 ); + header( 'Content-Type: text/plain; charset=utf-8' ); + echo $key; // phpcs:ignore WordPress.Security.EscapeOutput -- ключ из [a-f0-9] + exit; + } +}, 0 ); + +/* ===================== IndexNow ===================== */ + +function seojak_indexnow_key(): string { + if ( ! seojak_opt( 'indexnow' ) ) { + return ''; + } + $key = (string) seojak_opt( 'indexnow_key' ); + if ( ! preg_match( '/^[a-f0-9]{32}$/', $key ) ) { + $key = md5( wp_generate_password( 32, true, true ) ); + $opts = (array) get_option( SEOJAK_OPTION, array() ); + $opts['indexnow_key'] = $key; + update_option( SEOJAK_OPTION, $opts ); + } + return $key; +} + +/** Сообщить Яндексу (и всем участникам IndexNow), что адрес изменился. */ +function seojak_indexnow_ping( string $url ) { + $key = seojak_indexnow_key(); + if ( ! $key || 'production' !== wp_get_environment_type() && ! defined( 'SEOJAK_INDEXNOW_FORCE' ) ) { + return; + } + $response = wp_remote_post( 'https://yandex.com/indexnow', array( + 'timeout' => 5, + 'headers' => array( 'Content-Type' => 'application/json; charset=utf-8' ), + 'body' => wp_json_encode( array( + 'host' => wp_parse_url( home_url(), PHP_URL_HOST ), + 'key' => $key, + 'keyLocation' => home_url( "/$key.txt" ), + 'urlList' => array( $url ), + ) ), + ) ); + $code = is_wp_error( $response ) ? $response->get_error_message() : wp_remote_retrieve_response_code( $response ); + update_option( 'seojak_indexnow_last', array( 'time' => time(), 'url' => $url, 'result' => $code ), false ); +} + +add_action( 'transition_post_status', function ( $new, $old, $post ) { + if ( 'publish' !== $new || ! in_array( $post->post_type, array( 'post', 'page' ), true ) || wp_is_post_revision( $post ) ) { + return; + } + // Не чаще раза в 10 минут на запись: «Обновить» жмут по нескольку раз подряд. + if ( get_transient( 'seojak_in_' . $post->ID ) ) { + return; + } + set_transient( 'seojak_in_' . $post->ID, 1, 10 * MINUTE_IN_SECONDS ); + // После сохранения, чтобы адрес был уже окончательным. + add_action( 'shutdown', function () use ( $post ) { + if ( ! get_post_meta( $post->ID, '_seojak_noindex', true ) ) { + seojak_indexnow_ping( get_permalink( $post->ID ) ); + } + } ); +}, 10, 3 ); + +/* ===================== Блок в редакторе записи ===================== */ + +add_action( 'add_meta_boxes', function () { + add_meta_box( 'seojak', 'SEO', 'seojak_meta_box', array( 'post', 'page' ), 'normal', 'high' ); +} ); + +function seojak_meta_box( WP_Post $post ) { + wp_nonce_field( 'seojak_save', 'seojak_nonce' ); + $title = get_post_meta( $post->ID, '_seojak_title', true ); + $desc = get_post_meta( $post->ID, '_seojak_description', true ); + $noindex = get_post_meta( $post->ID, '_seojak_noindex', true ); + $auto = seojak_post_description( get_post( $post->ID ) ); + ?> + <style> + #seojak label { display: block; font-weight: 600; margin: 10px 0 4px; } + #seojak input[type=text], #seojak textarea { width: 100%; } + #seojak .seojak-count { color: #646970; font-weight: 400; } + #seojak .seojak-count.is-long { color: #b32d2e; } + #seojak .seojak-snippet { margin-top: 12px; padding: 10px 12px; border: 1px solid #dcdcde; border-radius: 4px; max-width: 600px; font-family: arial, sans-serif; } + #seojak .seojak-snippet b { display: block; color: #1a0dab; font-size: 18px; font-weight: 400; } + #seojak .seojak-snippet i { display: block; color: #006621; font-style: normal; font-size: 13px; } + #seojak .seojak-snippet span { color: #4d5156; font-size: 13px; } + </style> + <label for="seojak-f-title">Заголовок для поисковиков <span class="seojak-count" data-for="seojak-f-title" data-max="60"></span></label> + <input type="text" id="seojak-f-title" name="seojak_title" value="<?php echo esc_attr( $title ); ?>" placeholder="<?php echo esc_attr( $post->post_title ); ?>"> + <label for="seojak-f-desc">Описание <span class="seojak-count" data-for="seojak-f-desc" data-max="160"></span></label> + <textarea id="seojak-f-desc" name="seojak_description" rows="3" placeholder="<?php echo esc_attr( $auto ); ?>"><?php echo esc_textarea( $desc ); ?></textarea> + <label><input type="checkbox" name="seojak_noindex" value="1" <?php checked( $noindex ); ?>> Не индексировать эту страницу</label> + <div class="seojak-snippet"><b></b><i><?php echo esc_html( get_permalink( $post ) ); ?></i><span></span></div> + <script> + ( function () { + var box = document.getElementById( 'seojak' ), t = box.querySelector( '#seojak-f-title' ), d = box.querySelector( '#seojak-f-desc' ), + post = document.getElementById( 'title' ); + function val( el ) { return el.value.trim() || el.placeholder; } + function update() { + box.querySelector( '.seojak-snippet b' ).textContent = t.value.trim() || ( post && post.value ) || t.placeholder; + box.querySelector( '.seojak-snippet span' ).textContent = val( d ); + box.querySelectorAll( '.seojak-count' ).forEach( function ( c ) { + var el = document.getElementById( c.dataset.for ), n = ( el.value.trim() || ( el === t && post ? post.value : el.placeholder ) ).length; + c.textContent = n + ' из ~' + c.dataset.max; + c.classList.toggle( 'is-long', n > +c.dataset.max ); + } ); + } + [ t, d, post ].forEach( function ( el ) { el && el.addEventListener( 'input', update ); } ); + update(); + }() ); + </script> + <?php +} + +add_action( 'save_post', function ( $post_id ) { + if ( ! isset( $_POST['seojak_nonce'] ) || ! wp_verify_nonce( $_POST['seojak_nonce'], 'seojak_save' ) + || ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) || ! current_user_can( 'edit_post', $post_id ) ) { + return; + } + $fields = array( + '_seojak_title' => sanitize_text_field( wp_unslash( $_POST['seojak_title'] ?? '' ) ), + '_seojak_description' => sanitize_textarea_field( wp_unslash( $_POST['seojak_description'] ?? '' ) ), + '_seojak_noindex' => empty( $_POST['seojak_noindex'] ) ? '' : '1', + ); + foreach ( $fields as $key => $value ) { + if ( '' === $value ) { + delete_post_meta( $post_id, $key ); + } else { + update_post_meta( $post_id, $key, $value ); + } + } +} ); + +/* ===================== Страница настроек ===================== */ + +add_action( 'admin_menu', function () { + add_options_page( 'SEOJak', 'SEOJak', 'manage_options', 'seojak', 'seojak_settings_page' ); +} ); + +add_action( 'admin_init', function () { + register_setting( 'seojak', SEOJAK_OPTION, array( + 'sanitize_callback' => function ( $in ) { + $out = array(); + $prev = (array) get_option( SEOJAK_OPTION, array() ); + foreach ( seojak_defaults() as $key => $default ) { + if ( is_int( $default ) ) { + $out[ $key ] = empty( $in[ $key ] ) ? 0 : 1; + } elseif ( in_array( $key, array( 'person_image', 'default_image' ), true ) ) { + $out[ $key ] = esc_url_raw( $in[ $key ] ?? '' ); + } elseif ( in_array( $key, array( 'home_description', 'same_as' ), true ) ) { + $out[ $key ] = sanitize_textarea_field( $in[ $key ] ?? '' ); + } else { + $out[ $key ] = sanitize_text_field( $in[ $key ] ?? '' ); + } + } + $out['indexnow_key'] = $prev['indexnow_key'] ?? ''; + return $out; + }, + ) ); +} ); + +function seojak_settings_page() { + $o = wp_parse_args( (array) get_option( SEOJAK_OPTION, array() ), seojak_defaults() ); + $name = SEOJAK_OPTION; + $text = array( + 'home_title' => 'Заголовок главной', + 'separator' => 'Разделитель в заголовках', + 'person_name' => 'Автор (Schema.org)', + 'person_image' => 'Фото или логотип автора, URL', + 'default_image' => 'Картинка для соцсетей по умолчанию, URL', + 'twitter_site' => 'Twitter/X, @имя', + ); + $checks = array( + 'noindex_category' => 'Не индексировать рубрики', + 'noindex_tag' => 'Не индексировать метки', + 'noindex_author' => 'Не индексировать страницы авторов', + 'noindex_date' => 'Не индексировать архивы по датам', + 'indexnow' => 'Сообщать Яндексу о новых и обновлённых записях (IndexNow)', + ); + $last = get_option( 'seojak_indexnow_last' ); + ?> + <div class="wrap"> + <h1><img src="<?php echo esc_url( plugins_url( 'assets/icon.svg', __FILE__ ) ); ?>" alt="" width="32" height="32" style="vertical-align: middle; margin-right: 8px;">SEOJak</h1> + <form method="post" action="options.php"> + <?php settings_fields( 'seojak' ); ?> + <table class="form-table" role="presentation"> + <?php foreach ( $text as $key => $label ) : ?> + <tr><th><label for="sj-<?php echo esc_attr( $key ); ?>"><?php echo esc_html( $label ); ?></label></th> + <td><input type="text" class="regular-text" id="sj-<?php echo esc_attr( $key ); ?>" name="<?php echo esc_attr( "{$name}[{$key}]" ); ?>" value="<?php echo esc_attr( $o[ $key ] ); ?>"></td></tr> + <?php if ( 'home_title' === $key ) : ?> + <tr><th><label for="sj-home_description">Описание главной</label></th> + <td><textarea class="large-text" rows="3" id="sj-home_description" name="<?php echo esc_attr( "{$name}[home_description]" ); ?>"><?php echo esc_textarea( $o['home_description'] ); ?></textarea></td></tr> + <?php endif; ?> + <?php endforeach; ?> + <tr><th><label for="sj-same_as">Профили автора в соцсетях</label></th> + <td><textarea class="large-text" rows="3" id="sj-same_as" name="<?php echo esc_attr( "{$name}[same_as]" ); ?>" placeholder="По одному адресу в строке"><?php echo esc_textarea( $o['same_as'] ); ?></textarea></td></tr> + <tr><th>Индексация</th><td> + <?php foreach ( $checks as $key => $label ) : ?> + <label><input type="checkbox" name="<?php echo esc_attr( "{$name}[{$key}]" ); ?>" value="1" <?php checked( $o[ $key ] ); ?>> <?php echo esc_html( $label ); ?></label><br> + <?php endforeach; ?> + <?php if ( $last ) : ?> + <p class="description">Последний IndexNow: <?php echo esc_html( wp_date( 'd.m.Y H:i', $last['time'] ) . ' — ' . $last['url'] . ' — ответ ' . $last['result'] ); ?></p> + <?php endif; ?> + </td></tr> + </table> + <?php submit_button(); ?> + </form> + <p>Карта сайта: <a href="<?php echo esc_url( home_url( '/wp-sitemap.xml' ) ); ?>"><?php echo esc_html( home_url( '/wp-sitemap.xml' ) ); ?></a></p> + </div> + <?php +} + +/* ===================== Перенос из All in One SEO ===================== */ + +/** + * Копирует заголовки, описания и noindex из таблицы aioseo_posts в поля SEOJak. + * Заполненные поля SEOJak не перезаписывает. Возвращает число перенесённых записей. + */ +function seojak_import_aioseo(): int { + global $wpdb; + $table = $wpdb->prefix . 'aioseo_posts'; + if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) !== $table ) { + return 0; + } + $rows = $wpdb->get_results( "SELECT post_id, title, description, robots_noindex, robots_default FROM `$table` WHERE (title <> '' AND title IS NOT NULL) OR (description <> '' AND description IS NOT NULL) OR (robots_noindex = 1 AND robots_default = 0)" ); // phpcs:ignore WordPress.DB.PreparedSQL -- имя таблицы с префиксом + $count = 0; + foreach ( $rows as $r ) { + if ( ! get_post( $r->post_id ) ) { + continue; + } + $done = false; + foreach ( array( '_seojak_title' => trim( (string) $r->title ), '_seojak_description' => trim( (string) $r->description ) ) as $key => $value ) { + if ( '' !== $value && '' === (string) get_post_meta( $r->post_id, $key, true ) ) { + update_post_meta( $r->post_id, $key, $value ); + $done = true; + } + } + if ( 1 === (int) $r->robots_noindex && 0 === (int) $r->robots_default ) { + update_post_meta( $r->post_id, '_seojak_noindex', '1' ); + $done = true; + } + $count += (int) $done; + } + return $count; +} + +register_activation_hook( __FILE__, function () { + if ( ! get_option( SEOJAK_OPTION ) ) { + $opts = seojak_defaults(); + // Настройки главной и соцсетей — из All in One SEO, если он стоял. + $aio = json_decode( (string) get_option( 'aioseo_options' ), true ); + if ( is_array( $aio ) ) { + $g = $aio['searchAppearance']['global'] ?? array(); + $s = $aio['searchAppearance']['global']['schema'] ?? array(); + $opts['home_title'] = $g['siteTitle'] ?? $opts['home_title']; + $opts['home_description'] = $g['metaDescription'] ?? $opts['home_description']; + $opts['person_name'] = $s['personName'] ?? ''; + $opts['person_image'] = $s['personLogo'] ?? ''; + $opts['default_image'] = $aio['social']['facebook']['general']['defaultImagePosts'] ?? ''; + $tw = $aio['social']['profiles']['urls']['twitterUrl'] ?? ''; + $opts['twitter_site'] = $tw ? '@' . basename( untrailingslashit( $tw ) ) : ''; + $opts['same_as'] = implode( "\n", array_filter( array_map( 'strval', $aio['social']['profiles']['urls'] ?? array() ) ) ); + } + add_option( SEOJAK_OPTION, $opts ); + } + seojak_import_aioseo(); +} ); + +if ( defined( 'WP_CLI' ) && WP_CLI ) { + WP_CLI::add_command( 'seojak import-aioseo', function () { + WP_CLI::success( 'Перенесено записей: ' . seojak_import_aioseo() ); + } ); +}