385 lines
14 KiB
PHP
385 lines
14 KiB
PHP
<?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";
|
||
}
|
||
}
|