摘要

结构化数据 (Schema) 是 SEO 优化中至关重要的一环。想让搜索引擎更“懂”你的文章、产品、FAQ,最好的方式就是在页面头部输出 JSON-LD 格式的 Schema 标记。本文将手把手带你设计一个灵活的 Schema 模块:从插件检测、配置档管理,到 wp_head 精准输出,并结合代码详解每一步的实现思路与核心细节。


一、为什么要在插件中内置 Schema 支持?

很多人会把 Schema 交给 Yoast、Rank Math 这类 SEO 插件去处理,但有时我们会有更特殊的诉求:

  • 你正在开发一个内容自动推送插件,需要根据桌面端生成的模板输出复杂的 Schema 图(例如同时包含 ArticleFAQPageProduct)。
  • 你希望兼容多种 SEO 插件,在 Yoast 输出基础 Schema 的同时,由插件额外补充 FAQ 等片段,避免重复输出。
  • 你需要为多站点、多模板的场景提供统一的 Schema 管理,让推送端只需指定一个 profileId

接下来,我们就以 autoGeo Connector 的设计为例,看看如何构建这样一套 Schema 体系。

二、整体架构设计

我们的 Schema 模块由三个核心类组成:

职责
AutoGeo_Schema_Profiles 定义内置的 Schema 配置档(template profiles),提供可用的 ID 列表和拼装逻辑
AutoGeo_Schema 负责实际的 Schema 组装、消毒、存入 post_meta,并通过 wp_head 输出到前端
AutoGeo_Seo_Adapter 检测当前启用的 SEO 插件,决定如何与现有 Schema 共存(避免重复输出 Article)

工作流程如下:

  1. 文章发布时,根据桌面端传入的 schemaProfile 或完整的 jsonLd 数据,调用 AutoGeo_Schema::apply() 将 Schema 存入 _autogeo_json_ld 自定义字段。
  2. 前端请求文章时,挂载在 wp_head 上的 render_head() 函数读取该字段,输出 <script type="application/ld+json"> 代码。
  3. 如果已安装 Yoast 等插件,我们只输出非冲突的类型(如只输出 FAQPage),避免一张页面出现两个 Article

三、核心代码实现

3.1 配置档管理:AutoGeo_Schema_Profiles

配置档的作用是允许推送端只传一个字符串 ID,插件就能自动拼装出对应的 JSON-LD 图。我们内置了三个配置文件:

class AutoGeo_Schema_Profiles {

    public static function list_ids(): array {
        return ['article_faq_v1', 'enterprise_product_v1', 'news_article_v1'];
    }

    public static function build( string $profile_id, WP_Post $post, array $seo, array $geo ): array {
        switch ( $profile_id ) {
            case 'article_faq_v1':
                return self::build_article_faq( $post, $seo, $geo );
            case 'enterprise_product_v1':
                return self::build_enterprise_product( $post, $seo, $geo );
            case 'news_article_v1':
                return self::build_news_article( $post, $seo, $geo );
            default:
                return [];
        }
    }

    private static function build_article_faq( WP_Post $post, array $seo, array $geo ): array {
        $graph = [];

        // Article
        $graph[] = [
            '@type'            => 'Article',
            'headline'         => $seo['metaTitle'] ?? get_the_title( $post ),
            'description'      => $seo['metaDescription'] ?? '',
            'datePublished'    => get_the_date( 'c', $post ),
            'dateModified'     => get_the_modified_date( 'c', $post ),
            'author'           => [
                '@type' => 'Person',
                'name'  => get_the_author_meta( 'display_name', $post->post_author ),
            ],
        ];

        // FAQPage
        if ( ! empty( $geo['faq'] ) ) {
            $questions = [];
            foreach ( $geo['faq'] as $faq ) {
                $questions[] = [
                    '@type'          => 'Question',
                    'name'           => $faq['question'],
                    'acceptedAnswer' => [
                        '@type' => 'Answer',
                        'text'  => $faq['answer'],
                    ],
                ];
            }
            $graph[] = [
                '@type'          => 'FAQPage',
                'mainEntity'     => $questions,
            ];
        }

        return $graph;
    }
    // 其他配置档类似...
}

这样,桌面端只需在 payload 中设置 "schemaProfile": "article_faq_v1",就能获得一套标准的 Article + FAQPage 结构。

3.2 核心 Schema 处理类:AutoGeo_Schema

这个类负责两项主要工作:存储输出

class AutoGeo_Schema {

    /**
     * 将 Schema 存储到文章元数据
     */
    public static function apply( int $post_id, string $title, string $excerpt, array $seo, array $geo ): void {
        $schemas = [];

        // 情况 1:桌面端已经传了完整 JSON-LD
        if ( ! empty( $geo['jsonLd'] ) && is_array( $geo['jsonLd'] ) ) {
            $schemas = self::sanitize_json_ld_graph( $geo['jsonLd'] );
        }
        // 情况 2:根据配置档 ID 构建
        elseif ( ! empty( $geo['schemaProfile'] ) ) {
            $profile_id = sanitize_text_field( $geo['schemaProfile'] );
            $post       = get_post( $post_id );
            if ( $post && in_array( $profile_id, AutoGeo_Schema_Profiles::list_ids(), true ) ) {
                $schemas = AutoGeo_Schema_Profiles::build( $profile_id, $post, $seo, $geo );
            }
        }

        if ( ! empty( $schemas ) ) {
            update_post_meta( $post_id, '_autogeo_json_ld', wp_json_encode( $schemas, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ) );
        }
    }

    /**
     * 前端输出(挂载到 wp_head)
     */
    public static function render_head(): void {
        if ( ! is_singular() ) {
            return;
        }

        $post_id = get_the_ID();
        $json_ld = get_post_meta( $post_id, '_autogeo_json_ld', true );

        if ( empty( $json_ld ) ) {
            return;
        }

        // 兼容 Yoast / Rank Math:如果它们已经输出了 Article,我们就只输出剩余类型
        $schemas = json_decode( $json_ld, true );
        if ( ! is_array( $schemas ) ) {
            return;
        }

        // 检测 SEO 插件并过滤已输出的类型
        $seo_plugin = AutoGeo_Seo_Adapter::detect_plugin();
        if ( in_array( $seo_plugin, [ 'yoast', 'rankmath' ] ) ) {
            $schemas = self::filter_overlapping_types( $schemas, [ 'Article', 'WebPage' ] );
        }

        if ( empty( $schemas ) ) {
            return;
        }

        // 包装成 @graph
        $output = [
            '@context' => 'https://schema.org',
            '@graph'   => $schemas,
        ];

        printf(
            '<script type="application/ld+json" class="autogeo-schema">%s</script>' . "\n",
            wp_json_encode( $output, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES )
        );
    }

    /**
     * 过滤掉指定类型的 Schema 节点
     */
    private static function filter_overlapping_types( array $schemas, array $exclude_types ): array {
        return array_filter( $schemas, function( $node ) use ( $exclude_types ) {
            $type = $node['@type'] ?? '';
            return ! in_array( $type, $exclude_types, true );
        } );
    }

    /**
     * 简单的消毒:确保每个节点都有 @type,并递归清理危险标签
     */
    private static function sanitize_json_ld_graph( array $graph ): array {
        $clean = [];
        foreach ( $graph as $node ) {
            if ( empty( $node['@type'] ) ) {
                continue;
            }
            $clean[] = self::recursive_sanitize( $node );
        }
        return $clean;
    }

    private static function recursive_sanitize( array $data ): array {
        $safe = [];
        foreach ( $data as $key => $value ) {
            if ( is_string( $value ) ) {
                $safe[ $key ] = wp_kses( $value, [] );
            } elseif ( is_array( $value ) ) {
                $safe[ $key ] = self::recursive_sanitize( $value );
            } else {
                $safe[ $key ] = $value;
            }
        }
        return $safe;
    }
}

// 注册 wp_head 钩子
add_action( 'wp_head', [ 'AutoGeo_Schema', 'render_head' ], 20 );

3.3 与 SEO 插件的智能共存

为了避免重复输出 Article 等基础类型,我们通过 AutoGeo_Seo_Adapter 检测当前 SEO 插件:

class AutoGeo_Seo_Adapter {
    public static function detect_plugin(): string {
        if ( defined( 'WPSEO_VERSION' ) ) {
            return 'yoast';
        }
        if ( class_exists( 'RankMath' ) ) {
            return 'rankmath';
        }
        if ( defined( 'AIOSEO_PLUGIN_FILE' ) ) {
            return 'aioseo';
        }
        // ... 其他
        return 'native';
    }
}

当检测到 Yoast 或 Rank Math 时,render_head() 会过滤掉 ArticleWebPage 节点,只输出它们不提供的类型(如 FAQPageProduct)。这样就实现了“补充输出”,而非“重复输出”。

四、前端输出效果

最终在页面源代码中,你会看到类似这样的脚本:

<script type="application/ld+json" class="autogeo-schema">
{
  "@context": "https://schema.org",
  "@graph": [
    {
      "@type": "FAQPage",
      "mainEntity": [
        {
          "@type": "Question",
          "name": "如何安装插件?",
          "acceptedAnswer": {
            "@type": "Answer",
            "text": "上传至 wp-content/plugins 并启用即可。"
          }
        }
      ]
    }
  ]
}
</script>

这正是搜索引擎爬虫最喜爱的结构化数据格式。

五、进阶:支持远程推送模板

autoGeo Connector 的实际应用中,桌面端可以直接传递完整的 jsonLd 数组,插件仅做消毒和存储。这使得 Schema 的生成完全由桌面端的 AI 或模板引擎控制,极大增强了灵活性。

// 桌面端 POST /publish 的部分 payload
{
  "geo": {
    "jsonLd": [
      {
        "@type": "Article",
        "headline": "...",
        ...
      },
      {
        "@type": "FAQPage",
        "mainEntity": [...]
      }
    ]
  }
}

插件端的 apply() 方法会优先检测 jsonLd 字段,若存在则跳过内置配置档的拼装。这样既保留了自动化的便利,又提供了完全自定义的能力。

六、总结

通过上面这套设计,我们实现了一个既轻量又灵活的 WordPress Schema 模块:

  • 配置档机制:让常见结构可以“开箱即用”。
  • 消毒与安全:任何外部 JSON 数据都经过 wp_kses 和类型校验。
  • 智能共存:自动检测 Yoast / Rank Math,避免重复标记。
  • 可扩展:新增 Schema 类型只需在 AutoGeo_Schema_Profiles 中添加一个配置档 ID 和对应构建方法。

无论你是在开发自己的插件,还是在为站群自动化建设赋能,这套 Schema 方案都能让你事半功倍。希望本文的代码和思路能为你提供实用的参考。如果有更好的实现方式,欢迎在评论区交流!

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐