Files
wordpress-theme-aksrt/inc/classes/class-aksrt-seo-head.php
T

385 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* AKSRT SEO Head — 单一 head 输出装配器
*
* Phase 7 收口:站点 SEO、单篇 SEO、OpenGraph/Twitter、JSON-LD、canonical、
* favicon、robots 全部从这里一次性装配输出,替代此前散落在
* inc/helpers/aksrt-template-tags.php 的多个 wp_head 闭包。
*
* 所有取值只经过 AKSRT_Settings + 模板 helper,不直接 raw get_option()。
* 唯一 head 来源,便于追踪。详见 seo-output-matrix.md。
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_SEO_Head {
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')) {
$seo = AKSRT_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')) {
$s = AKSRT_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'] = in_array($post->post_type, ['post', 'aksrt_resource'], true) ? '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);
$term_desc = trim(wp_strip_all_tags((string) term_description($term)));
if ($term_desc !== '') {
$payload['description'] = $term_desc;
}
}
}
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'),
];
}
/**
* 由分类/标签生成关键词回退。
*/
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')) {
return (string) AKSRT_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";
}
}