Move 288 types from global AKSRT_* + inc/classes manual requires into
namespace AKSRT\{Domain} under src/AKSRT/, wire composer.json psr-4,
and slim bootstrap to helpers/modules/hooks only. Split multi-class
widget/gateway files, update admin contract audit paths, and document
the layout in ADR/README.
406 lines
15 KiB
PHP
406 lines
15 KiB
PHP
<?php
|
||
/**
|
||
* @package AKSRT
|
||
*/
|
||
|
||
namespace AKSRT\Core;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
class SEOHead {
|
||
|
||
private static $instance = null;
|
||
|
||
public static function get_instance(): self {
|
||
if (null === self::$instance) {
|
||
self::$instance = new self();
|
||
}
|
||
return self::$instance;
|
||
}
|
||
|
||
public function init(): void {
|
||
// seo_enabled 主开关:关闭时不挂载任何 wp_head 输出
|
||
if (class_exists('\AKSRT\Settings\Settings')) {
|
||
$seo = \AKSRT\Settings\Settings::get_seo_settings();
|
||
if (empty($seo['seo_enabled'])) return;
|
||
}
|
||
// 单一出口:所有 SEO/OG/JSON-LD 输出集中在 priority 2。
|
||
add_action('wp_head', [$this, 'render'], 2);
|
||
// robots:dl token 页面 noindex(独立、最早输出)。
|
||
add_action('wp_head', [$this, 'render_robots'], 1);
|
||
}
|
||
|
||
/**
|
||
* 站点级 SEO 配置(带 per-request memo)。
|
||
*
|
||
* @return array<string,mixed>
|
||
*/
|
||
private function config(): array {
|
||
static $cache = null;
|
||
if ($cache !== null) {
|
||
return $cache;
|
||
}
|
||
|
||
if (class_exists('\AKSRT\Settings\Settings')) {
|
||
$s = \AKSRT\Settings\Settings::get_all();
|
||
} else {
|
||
$s = [];
|
||
}
|
||
|
||
$cache = [
|
||
'connector' => trim((string) ($s['seo_connector'] ?? '-')) ?: '-',
|
||
'site_title' => trim((string) ($s['seo_title'] ?? '')),
|
||
'description' => trim((string) ($s['seo_description'] ?? ($s['site_description'] ?? ''))),
|
||
'keywords' => trim((string) ($s['seo_keywords'] ?? '')),
|
||
'canonical' => !isset($s['canonical_tag']) || !empty($s['canonical_tag']),
|
||
'og_enabled' => empty($s['disable_og_meta']),
|
||
'jsonld' => !isset($s['seo_jsonld']) || !empty($s['seo_jsonld']),
|
||
'jsonld_single' => !isset($s['seo_jsonld_single']) || !empty($s['seo_jsonld_single']),
|
||
'jsonld_page' => !isset($s['seo_jsonld_page']) || !empty($s['seo_jsonld_page']),
|
||
'default_image' => trim((string) ($s['seo_image_default'] ?? '')),
|
||
'always_default' => !empty($s['seo_image_always_default']),
|
||
];
|
||
|
||
return $cache;
|
||
}
|
||
|
||
/**
|
||
* 解析当前请求的规范 SEO 负载。
|
||
*
|
||
* @return array{title:string,description:string,keywords:string,canonical:string,image:string,og_type:string,object:?\WP_Post}
|
||
*/
|
||
private function resolve_payload(): array {
|
||
$cfg = $this->config();
|
||
$site_name = get_bloginfo('name');
|
||
|
||
$payload = [
|
||
'title' => wp_get_document_title(),
|
||
'description' => $cfg['description'],
|
||
'keywords' => $cfg['keywords'],
|
||
'canonical' => home_url(isset($_SERVER['REQUEST_URI']) ? wp_unslash($_SERVER['REQUEST_URI']) : '/'),
|
||
'image' => $cfg['default_image'],
|
||
'og_type' => 'website',
|
||
'object' => null,
|
||
];
|
||
|
||
if (is_singular()) {
|
||
$post = get_queried_object();
|
||
if ($post instanceof \WP_Post) {
|
||
$payload['object'] = $post;
|
||
$payload['canonical'] = (string) get_permalink($post);
|
||
$payload['og_type'] = ($post->post_type === 'post') ? 'article' : 'website';
|
||
|
||
// 单篇 SEO 覆盖:per-post meta → 文章字段 → 站点默认。
|
||
$meta = $this->post_seo_meta($post->ID);
|
||
|
||
$payload['title'] = $meta['title'] !== '' ? $meta['title'] : wp_strip_all_tags(get_the_title($post));
|
||
if ($meta['description'] !== '') {
|
||
$payload['description'] = $meta['description'];
|
||
} else {
|
||
$excerpt = has_excerpt($post)
|
||
? wp_strip_all_tags(get_the_excerpt($post))
|
||
: wp_trim_words(wp_strip_all_tags((string) $post->post_content), 40, '…');
|
||
$payload['description'] = trim((string) $excerpt);
|
||
}
|
||
if ($meta['keywords'] !== '') {
|
||
$payload['keywords'] = $meta['keywords'];
|
||
} elseif ($payload['keywords'] === '') {
|
||
$payload['keywords'] = $this->terms_to_keywords($post);
|
||
}
|
||
|
||
$payload['image'] = $this->resolve_image($post, $cfg);
|
||
}
|
||
} elseif (is_home() || is_front_page()) {
|
||
if ($cfg['site_title'] !== '') {
|
||
$payload['title'] = $cfg['site_title'];
|
||
}
|
||
$payload['canonical'] = home_url('/');
|
||
} elseif (is_category() || is_tag() || is_tax()) {
|
||
$term = get_queried_object();
|
||
if ($term instanceof \WP_Term) {
|
||
$payload['canonical'] = (string) get_term_link($term);
|
||
$meta = $this->term_seo_meta((int) $term->term_id);
|
||
if ($meta['title'] !== '') {
|
||
$payload['title'] = $meta['title'];
|
||
}
|
||
if ($meta['description'] !== '') {
|
||
$payload['description'] = $meta['description'];
|
||
} else {
|
||
$term_desc = trim(wp_strip_all_tags((string) term_description($term)));
|
||
if ($term_desc !== '') {
|
||
$payload['description'] = $term_desc;
|
||
}
|
||
}
|
||
if ($meta['keywords'] !== '') {
|
||
$payload['keywords'] = $meta['keywords'];
|
||
}
|
||
}
|
||
}
|
||
|
||
if ($cfg['always_default'] && $cfg['default_image'] !== '') {
|
||
$payload['image'] = $cfg['default_image'];
|
||
}
|
||
|
||
/**
|
||
* 允许扩展(如 AI SEO)在最终输出前调整 head 负载。
|
||
*
|
||
* @param array $payload 规范 SEO 负载。
|
||
*/
|
||
return apply_filters('aksrt_seo_head_payload', $payload);
|
||
}
|
||
|
||
/**
|
||
* 读取单篇 SEO 覆盖(per-post meta)。
|
||
*
|
||
* @param int $post_id Post ID.
|
||
* @return array{title:string,description:string,keywords:string}
|
||
*/
|
||
private function post_seo_meta(int $post_id): array {
|
||
$get = static function (string $key) use ($post_id): string {
|
||
return trim((string) get_post_meta($post_id, $key, true));
|
||
};
|
||
|
||
return [
|
||
'title' => $get('_aksrt_seo_title'),
|
||
'description' => $get('_aksrt_seo_description'),
|
||
'keywords' => $get('_aksrt_seo_keywords'),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 读取 term SEO 覆盖。
|
||
*
|
||
* @return array{title:string,description:string,keywords:string}
|
||
*/
|
||
private function term_seo_meta(int $term_id): array {
|
||
$get = static function (string $key) use ($term_id): string {
|
||
return trim((string) get_term_meta($term_id, $key, true));
|
||
};
|
||
|
||
return [
|
||
'title' => $get('_aksrt_seo_title'),
|
||
'description' => $get('_aksrt_seo_description'),
|
||
'keywords' => $get('_aksrt_seo_keywords'),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 由分类/标签生成关键词回退。
|
||
*/
|
||
private function terms_to_keywords(\WP_Post $post): string {
|
||
$names = [];
|
||
foreach ((array) get_the_category($post->ID) as $cat) {
|
||
if ($cat instanceof \WP_Term) {
|
||
$names[] = $cat->name;
|
||
}
|
||
}
|
||
$tags = get_the_terms($post, 'post_tag');
|
||
if (is_array($tags)) {
|
||
foreach ($tags as $tag) {
|
||
$names[] = $tag->name;
|
||
}
|
||
}
|
||
|
||
return implode(',', array_slice(array_values(array_unique(array_filter($names))), 0, 10));
|
||
}
|
||
|
||
/**
|
||
* 解析单篇缩略图:特色图 → 内容首图 → 站点默认 → logo。
|
||
*/
|
||
private function resolve_image(\WP_Post $post, array $cfg): string {
|
||
if (has_post_thumbnail($post)) {
|
||
$image = (string) get_the_post_thumbnail_url($post, 'large');
|
||
if ($image !== '') {
|
||
return $image;
|
||
}
|
||
}
|
||
if (function_exists('aksrt_get_first_content_image_url')) {
|
||
$image = (string) aksrt_get_first_content_image_url($post->ID);
|
||
if ($image !== '') {
|
||
return $image;
|
||
}
|
||
}
|
||
if ($cfg['default_image'] !== '') {
|
||
return $cfg['default_image'];
|
||
}
|
||
if (class_exists('\AKSRT\Settings\Settings')) {
|
||
return (string) \AKSRT\Settings\Settings::logo_url();
|
||
}
|
||
return '';
|
||
}
|
||
|
||
/**
|
||
* dl token 页面 noindex。
|
||
*/
|
||
public function render_robots(): void {
|
||
if (function_exists('get_query_var') && get_query_var('aksrt_dl_token')) {
|
||
echo '<meta name="robots" content="noindex,nofollow">' . "\n";
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 输出 head(favicon + canonical + description/keywords + OG/Twitter + JSON-LD)。
|
||
*/
|
||
public function render(): void {
|
||
$cfg = $this->config();
|
||
$payload = $this->resolve_payload();
|
||
|
||
echo "\n<!-- AKSRT SEO -->\n";
|
||
|
||
// Favicon(无站点图标时回退)。
|
||
if (function_exists('aksrt_get_favicon_url')) {
|
||
$favicon = (string) aksrt_get_favicon_url();
|
||
if ($favicon !== '' && !has_site_icon()) {
|
||
printf('<link rel="icon" href="%s">' . "\n", esc_url($favicon));
|
||
}
|
||
}
|
||
|
||
// Canonical。
|
||
if ($cfg['canonical'] && !is_404() && $payload['canonical'] !== '') {
|
||
printf('<link rel="canonical" href="%s">' . "\n", esc_url($payload['canonical']));
|
||
}
|
||
|
||
// Description / Keywords。
|
||
if ($payload['description'] !== '') {
|
||
printf('<meta name="description" content="%s">' . "\n", esc_attr($payload['description']));
|
||
}
|
||
if ($payload['keywords'] !== '') {
|
||
printf('<meta name="keywords" content="%s">' . "\n", esc_attr($payload['keywords']));
|
||
}
|
||
|
||
// OpenGraph + Twitter。
|
||
if ($cfg['og_enabled']) {
|
||
$this->render_open_graph($payload);
|
||
}
|
||
|
||
// JSON-LD。
|
||
if ($cfg['jsonld']) {
|
||
$this->render_jsonld($payload, $cfg);
|
||
}
|
||
|
||
echo "<!-- /AKSRT SEO -->\n";
|
||
}
|
||
|
||
/**
|
||
* 输出 OpenGraph + Twitter Card。
|
||
*/
|
||
private function render_open_graph(array $payload): void {
|
||
$site_name = get_bloginfo('name');
|
||
$locale = str_replace('-', '_', get_locale());
|
||
|
||
$og = [
|
||
['property' => 'og:type', 'content' => $payload['og_type']],
|
||
['property' => 'og:locale', 'content' => $locale],
|
||
['property' => 'og:site_name', 'content' => $site_name],
|
||
['property' => 'og:title', 'content' => $payload['title']],
|
||
['property' => 'og:url', 'content' => $payload['canonical']],
|
||
];
|
||
if ($payload['description'] !== '') {
|
||
$og[] = ['property' => 'og:description', 'content' => $payload['description']];
|
||
}
|
||
if ($payload['image'] !== '') {
|
||
$og[] = ['property' => 'og:image', 'content' => $payload['image']];
|
||
}
|
||
|
||
if ($payload['og_type'] === 'article' && $payload['object'] instanceof \WP_Post) {
|
||
$post = $payload['object'];
|
||
$published = get_the_date('c', $post);
|
||
$modified = get_the_modified_date('c', $post);
|
||
$author = get_the_author_meta('display_name', (int) $post->post_author);
|
||
if ($published) $og[] = ['property' => 'article:published_time', 'content' => $published];
|
||
if ($modified) $og[] = ['property' => 'article:modified_time', 'content' => $modified];
|
||
if ($author) $og[] = ['property' => 'article:author', 'content' => $author];
|
||
|
||
$cats = get_the_category($post->ID);
|
||
if (!empty($cats)) {
|
||
$og[] = ['property' => 'article:section', 'content' => $cats[0]->name];
|
||
}
|
||
$tags = get_the_terms($post, 'post_tag');
|
||
if (is_array($tags)) {
|
||
foreach ($tags as $tag) {
|
||
$og[] = ['property' => 'article:tag', 'content' => $tag->name];
|
||
}
|
||
}
|
||
}
|
||
|
||
foreach ($og as $tag) {
|
||
printf('<meta property="%s" content="%s">' . "\n", esc_attr($tag['property']), esc_attr($tag['content']));
|
||
}
|
||
|
||
$twitter = [
|
||
['name' => 'twitter:card', 'content' => $payload['image'] !== '' ? 'summary_large_image' : 'summary'],
|
||
['name' => 'twitter:title', 'content' => $payload['title']],
|
||
];
|
||
if ($payload['description'] !== '') {
|
||
$twitter[] = ['name' => 'twitter:description', 'content' => $payload['description']];
|
||
}
|
||
if ($payload['image'] !== '') {
|
||
$twitter[] = ['name' => 'twitter:image', 'content' => $payload['image']];
|
||
}
|
||
foreach ($twitter as $tag) {
|
||
printf('<meta name="%s" content="%s">' . "\n", esc_attr($tag['name']), esc_attr($tag['content']));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 输出 JSON-LD 结构化数据。
|
||
*/
|
||
private function render_jsonld(array $payload, array $cfg): void {
|
||
$graph = [];
|
||
|
||
// WebSite(全站)。
|
||
$graph[] = [
|
||
'@type' => 'WebSite',
|
||
'@id' => home_url('/#website'),
|
||
'url' => home_url('/'),
|
||
'name' => get_bloginfo('name'),
|
||
'inLanguage' => get_locale(),
|
||
];
|
||
|
||
if (is_singular('post') && $cfg['jsonld_single'] && $payload['object'] instanceof \WP_Post) {
|
||
$post = $payload['object'];
|
||
$node = [
|
||
'@type' => 'Article',
|
||
'headline' => $payload['title'],
|
||
'url' => $payload['canonical'],
|
||
'datePublished' => get_the_date('c', $post),
|
||
'dateModified' => get_the_modified_date('c', $post),
|
||
'author' => [
|
||
'@type' => 'Person',
|
||
'name' => get_the_author_meta('display_name', (int) $post->post_author),
|
||
],
|
||
'publisher' => [
|
||
'@type' => 'Organization',
|
||
'name' => get_bloginfo('name'),
|
||
],
|
||
];
|
||
if ($payload['description'] !== '') {
|
||
$node['description'] = $payload['description'];
|
||
}
|
||
if ($payload['image'] !== '') {
|
||
$node['image'] = $payload['image'];
|
||
}
|
||
$graph[] = $node;
|
||
} elseif (is_page() && $cfg['jsonld_page'] && $payload['object'] instanceof \WP_Post) {
|
||
$graph[] = [
|
||
'@type' => 'WebPage',
|
||
'url' => $payload['canonical'],
|
||
'name' => $payload['title'],
|
||
];
|
||
}
|
||
|
||
if (empty($graph)) {
|
||
return;
|
||
}
|
||
|
||
$doc = [
|
||
'@context' => 'https://schema.org',
|
||
'@graph' => $graph,
|
||
];
|
||
|
||
echo '<script type="application/ld+json">'
|
||
. wp_json_encode($doc, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||
. '</script>' . "\n";
|
||
}
|
||
}
|