「SEOプラグインを入れておけば、構造化データも出る」と考えがちです。

ところが、実際にページのソースを開くと、構造化データが最低限しか出ていない、あるいは1つも出ていないことがあります。筆者のサイトでも、プラグイン任せにしていた時期は、トップページのWebSiteが2項目だけで、記事ページには構造化データがありませんでした。

この記事では、structured-data.php を1つ用意して、構造化データ(JSON-LD)をプラグインなしで出力する方法を、コードつきで解説します。あわせて、同じ記事ページに「プラグインなし/自前実装/Yoast SEO/All in One SEO」を適用して、出力を比べました。


出力する内容

出力する内容は、主に次の4つ(企業サイトなら5つ)です。

# 出力 出すページ
① canonical 全ページ
② WebSite(企業サイトなら Organization も) トップページ
③ 記事(Article など) 投稿ページ
④ パンくず(BreadcrumbList) トップ・404・検索結果以外

自前実装するメリット

  • プラグインの場合、設定やオプション画面がやや複雑。
  • プラグインの場合、住所・電話番号・対応エリア・事業内容 等の追加の組織情報、IndexNow、ソーシャルメディアの外観 等、一部機能は有料。
  • 自前実装の場合、shortcodes.php 等、任意の場所で一元管理、サイト間で同じ仕組みを使い回せます。
  • 自前実装の場合、パンくずリストの出力形式等のカスタマイズが容易。
  • 出力する内容を自分で決めたい・余計な出力を増やしたくない。

どちらの場合も、プラグインと自前実装を同時に動かさないでください。canonical や構造化データが二重に出力されます。


同じ記事ページで、4パターンのソースを比べた

同じ記事ページ(技術記事)に対して、次の4つの状態でソースを確認しました。

  • SEOなし: 構造化データの出力をすべてOFFにした状態
  • 自前実装: この記事のコード
  • Yoast SEO(v28.6)
  • All in One SEO(5.0.2.1)

プラグインの設定は初期値に近い状態です。設定を変えれば、出力も変わります。

SEOなし 自前実装 Yoast SEO All in One SEO
JSON-LD 0件 2ブロック 1ブロック(@graph) 1ブロック(@graph)
記事の型 ― 選べる(TechArticle など) Article BlogPosting
含まれるデータ ― 記事・パンくず 記事・ページ・パンくず・WebSite・著者 記事・ページ・パンくず・組織・著者・WebSite
パンくず ― トップ > 一覧 > 記事名 ホーム > 記事名 Home > カテゴリー > 記事名
canonical なし あり あり あり
meta description 1本 1本 1本 2本

気づいた違い

  • プラグインは、情報量が多い: 記事・ページ・サイト・著者を、@id で結びつけた1つのグラフにまとめます。自前実装では、ここまで関連づけていません。
  • 題名に <head> を含む記事で、差が出た: 自前実装の headline は <head> と正しく出ました。Yoast SEO と All in One SEO の headline は、&lt;head&gt; という文字列のままでした。All in One SEO は、WebPage の name から <head> の部分が消えていました。
  • パンくずは、プラグインごとに違う: Yoast SEO は、一覧ページを挟まない2段でした。All in One SEO は、「Home」と英語で出力し、カテゴリーを経由します。サイトに表示しているパンくずと同じにはなりません。
  • meta description が重複する場合がある: All in One SEO では、サイトが元から出力している description とあわせて、2本になっていました。併用するプラグインの出力は、ソースで必ず確認します。

プラグインが悪いわけではありません。出力は、サイトの表示と合っているか、自分で確かめる必要がある、ということです。


実装の全体像

ファイルは3つです。

ファイル 役割
functions.php 共通の関数(JSONの出力・パンくずの項目作り)を置く
structured-data.php ①〜④を出力する(header.php から読み込む)
breadcrumb.php 画面のパンくず(functions.php の関数を使う)

ポイントは、関数は functions.php に置き、テンプレートには書かないことです。テンプレートに関数を書くと、複数回読み込まれたときに「Cannot redeclare」、読み込まれなかったときに「undefined function」という致命的エラーになります。


1. functions.php:共通の関数

<?php
/**
 * 構造化データ用の関数(functions.php に追記する)
 * ※ テンプレートファイル(structured-data.php など)には書かない。
 *   テンプレートに書くと「二重定義」や「未定義」の致命的エラーの原因になる。
 */

// 文字列を JSON 用に整える(タグ除去 → HTMLエンティティを元の文字に戻す → 前後の空白を除去)
if (!function_exists('sd_clean')) {
    function sd_clean($s)
    {
        return trim(html_entity_decode(wp_strip_all_tags((string) $s), ENT_QUOTES, 'UTF-8'));
    }
}

// JSON-LD を出力する(配列 → JSON。< と > は \u003C \u003E にして </script> 事故を防ぐ)
if (!function_exists('sd_json')) {
    function sd_json($data)
    {
        echo '<script type="application/ld+json">' . "\n"
            . wp_json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_PRETTY_PRINT)
            . "\n</script>\n";
    }
}

// パンくずの項目を作る(画面の breadcrumb.php と構造化データの両方から呼ぶ)
// 戻り値:[['name' => 名前, 'url' => URL(現在ページなどは空でもよい)], ...]
if (!function_exists('sd_breadcrumb_items')) {
    function sd_breadcrumb_items()
    {
        // ▼サイトごとの設定:投稿ページの親にあたる「投稿一覧ページ」
        $postlist_name = '投稿一覧';
        $postlist_url  = home_url('/postlist/');

        $items = [['name' => 'トップ', 'url' => home_url('/')]];

        if (is_single() && get_post_type() === 'post') {
            // 投稿:トップ > 投稿一覧 > 記事名
            $items[] = ['name' => $postlist_name, 'url' => $postlist_url];
            $items[] = ['name' => sd_clean(get_the_title()), 'url' => get_permalink()];
        } elseif (is_page()) {
            // 固定ページ:トップ > (先祖ページ) > 現在ページ
            foreach (array_reverse(get_post_ancestors(get_the_ID())) as $ancestor_id) {
                $items[] = ['name' => sd_clean(get_the_title($ancestor_id)), 'url' => get_permalink($ancestor_id)];
            }
            $items[] = ['name' => sd_clean(get_the_title()), 'url' => get_permalink()];
        } elseif (is_singular()) {
            // カスタム投稿:トップ > 投稿タイプの一覧 > 記事名
            $type    = get_post_type_object(get_post_type());
            $archive = get_post_type_archive_link(get_post_type());
            if ($type && $archive) {
                $items[] = ['name' => $type->labels->name, 'url' => $archive];
            }
            $items[] = ['name' => sd_clean(get_the_title()), 'url' => get_permalink()];
        } elseif (is_home() && get_option('page_for_posts')) {
            // 「投稿ページ」に指定した固定ページ:トップ > そのページ名
            $items[] = ['name' => sd_clean(get_the_title(get_option('page_for_posts'))), 'url' => ''];
        } elseif (is_category() || is_tag() || is_tax()) {
            // カテゴリー・タグ:トップ > 項目名
            $link    = get_term_link(get_queried_object());
            $items[] = ['name' => sd_clean(single_term_title('', false)), 'url' => is_wp_error($link) ? '' : $link];
        } elseif (is_archive()) {
            // 日付・投稿者などのアーカイブ:トップ > アーカイブ
            $items[] = ['name' => 'アーカイブ', 'url' => ''];
        }
        // 検索結果・404 などは「トップ」だけ(出力側で2項目未満は出さない)
        return $items;
    }
}

ここで気をつけたこと

  • JSONを手組みしない: "name": "<?php echo esc_js($title); ?>" のような書き方は、題名に ' や & があると不正なJSONになります(esc_js() は ' を \' に変えるため)。配列を作って wp_json_encode() で出力します。
  • JSON_HEX_TAG を付ける: JSON_UNESCAPED_SLASHES を指定すると、題名に </script> が含まれたとき、JSON-LDがそこで終わってしまいます。JSON_HEX_TAG で < と > を \u003C と \u003E に変えて、防ぎます。技術ブログでは起こり得ます。
  • HTMLエンティティを元の文字に戻す: 題名は &amp; や &lt; の形で保存されていることがあります。そのままJSONにすると、&lt;head&gt; という文字列として出力されます(Yoast SEO と All in One SEO がそうなっていました)。

2. structured-data.php:本体

次の順に、①〜④を出力します。先頭に、サイトごとの設定をまとめます。

<?php
/**
 * structured-data.php
 * header.php の wp_head() の直後で読み込む(読み込み方は記事を参照)
 */

// ▼サイトごとの設定 -------------------------------------------------
$sd = [
    'alt_name'      => '',          // サイト名の略称(WebSite の alternateName。空なら出さない)
    'article_type'  => 'Article',   // 記事の型:Article / NewsArticle / BlogPosting / TechArticle
    'author_type'   => 'Person',    // 著者:Person(投稿者名) / Organization(サイト名義)
    'default_image' => '',          // アイキャッチが無い記事の代替画像(http から始まる完全なURL。不要なら空)
];
// ------------------------------------------------------------------

$sd_site_name = sd_clean(get_bloginfo('name'));
$sd_home_url  = home_url('/');

① canonical

// ① canonical(WordPress標準の出力はやめて、ここで一元管理する → remove_action は記事を参照)
$sd_canonical = '';
if (is_front_page()) {
    $sd_canonical = $sd_home_url;
} elseif (is_singular()) {
    $sd_canonical = get_permalink();
} elseif (is_category() || is_tag() || is_tax()) {
    $sd_term_link = get_term_link(get_queried_object());
    if (!is_wp_error($sd_term_link)) {
        $sd_canonical = $sd_term_link;
    }
}
if ($sd_canonical) {
    echo '<link rel="canonical" href="' . esc_url($sd_canonical) . '">' . "\n";
}

WordPressは、標準でも投稿・固定ページの canonical を出力します。ここで一元管理するなら、functions.php に次の1行を足して、標準の出力を止めます。

remove_action('wp_head', 'rel_canonical');

プラグインが canonical を出している場合は、どちらか一方にしてください。

② WebSite(トップページ)

// ② WebSite(トップページのみ)
if (is_front_page()) {
    $sd_website = ['@context' => 'https://schema.org', '@type' => 'WebSite', 'name' => $sd_site_name];
    $sd_alt     = sd_clean($sd['alt_name']);
    if ($sd_alt !== '' && $sd_alt !== $sd_site_name) {
        $sd_website['alternateName'] = $sd_alt;
    }
    $sd_website['url']        = $sd_home_url;
    $sd_website['inLanguage'] = 'ja';
    sd_json($sd_website);
}

alternateName は、サイト名の略称です。「株式会社○○」と探されるサイトでも、実際には「○○」と略して探されることが多いため、略称を伝えておきます。

SearchAction は、もう入れなくてよい: 旧版では、WebSite に SearchAction(サイト内検索)を入れて、サイトリンク検索ボックスを狙っていました。Googleは、2024年11月に、サイトリンク検索ボックスを廃止しました。入れていても害はありませんが、検索結果への効果はありません。

③ 記事(投稿ページ)

// ③ 記事(投稿ページのみ)
if (is_single() && get_post_type() === 'post') {
    // 設定値は許可リストで検証(綴り間違いは既定値に戻す)
    $sd_type = in_array($sd['article_type'], ['Article', 'NewsArticle', 'BlogPosting', 'TechArticle'], true)
        ? $sd['article_type'] : 'Article';

    if ($sd['author_type'] === 'Organization') {
        $sd_author = ['@type' => 'Organization', 'name' => $sd_site_name, 'url' => $sd_home_url];
    } else {
        $sd_author = ['@type' => 'Person', 'name' => sd_clean(get_the_author_meta('display_name', (int) get_post_field('post_author', get_the_ID())))];
    }

    $sd_publisher = ['@type' => 'Organization', 'name' => $sd_site_name, 'url' => $sd_home_url];
    if (get_site_icon_url()) {
        $sd_publisher['logo'] = ['@type' => 'ImageObject', 'url' => get_site_icon_url(512)];
    }

    $sd_article = [
        '@context'         => 'https://schema.org',
        '@type'            => $sd_type,
        'headline'         => sd_clean(get_the_title()),
        'url'              => get_permalink(),
        'mainEntityOfPage' => ['@type' => 'WebPage', '@id' => get_permalink()],
    ];
    if (has_excerpt()) {
        $sd_article['description'] = sd_clean(get_the_excerpt());
    }
    // 画像:アイキャッチ → 無ければ代替画像
    $sd_image = has_post_thumbnail() ? get_the_post_thumbnail_url(null, 'large')
        : (strpos($sd['default_image'], 'http') === 0 ? $sd['default_image'] : '');
    if ($sd_image) {
        $sd_article['image'] = $sd_image;
    }
    $sd_article['datePublished'] = get_the_date('c');
    $sd_article['dateModified']  = get_the_modified_date('c');
    $sd_article['author']        = $sd_author;
    $sd_article['publisher']     = $sd_publisher;
    $sd_article['inLanguage']    = 'ja';
    sd_json($sd_article);
}
  • 型: お知らせ・ニュースなら NewsArticle、技術記事なら TechArticle、個人ブログなら BlogPosting、迷ったら Article です。
  • 著者: 会社名義のお知らせは Organization にします。Person のままだと、投稿ユーザー名(admin など)がそのまま出ることがあります。
  • 画像: アイキャッチがない記事には、代替画像を出します。Googleのテストで「image がありません」という警告が出るのを防げます(任意項目です)。
  • 設定値の検証: in_array() で許可した値だけを通し、綴り間違いは既定値の Article に戻します。

④ パンくず

// ④ パンくず(トップ・404・検索結果以外)
if (!is_front_page() && !is_404() && !is_search()) {
    $sd_items = sd_breadcrumb_items();
    if (count($sd_items) > 1) {
        $sd_list = [];
        foreach ($sd_items as $i => $item) {
            $li = ['@type' => 'ListItem', 'position' => $i + 1, 'name' => $item['name']];
            if ($item['url'] !== '') {
                $li['item'] = $item['url'];   // 現在ページなど URL が無い項目は item を省略してよい
            }
            $sd_list[] = $li;
        }
        sd_json(['@context' => 'https://schema.org', '@type' => 'BreadcrumbList', 'itemListElement' => $sd_list]);
    }
}

②-2 企業サイトの場合:Organization を追加

会社情報は、トップページの②の if の中に追記します。

    // ②-2 Organization(企業サイトの場合。上の ② の if の中に追記)
    $sd_org = [
        '@context'   => 'https://schema.org',
        '@type'      => 'Corporation',                 // 複数事業の会社は Corporation(業種別の型は1事業しか表せない)
        '@id'        => $sd_home_url . '#organization',
        'name'       => '株式会社サンプル',
        'url'        => $sd_home_url,
        'address'    => [
            '@type'           => 'PostalAddress',
            'addressCountry'  => 'JP',
            'postalCode'      => '812-0000',
            'addressRegion'   => '福岡県',
            'addressLocality' => '福岡市博多区',          // 市区町村は番地と分ける
            'streetAddress'   => '住吉1丁目2番3号 サンプルビル8F',
        ],
        'telephone'  => '+81-92-000-0000',             // 国番号つき
        'areaServed' => [
            ['@type' => 'AdministrativeArea', 'name' => '福岡県'],
            ['@type' => 'AdministrativeArea', 'name' => '九州'],
        ],
        'knowsAbout' => ['ビルメンテナンス', '施設管理', '建築設備工事'],
        'parentOrganization' => ['@type' => 'Organization', 'name' => '株式会社親会社', 'url' => 'https://parent.example.com/'],
        // 'sameAs'  => ['https://x.com/example'],     // 自社の公式SNSなどだけ。親会社のURLは入れない
    ];
    if (get_site_icon_url()) {
        $sd_org['logo'] = ['@type' => 'ImageObject', 'url' => get_site_icon_url(512)];
    }
    sd_json($sd_org);
  • 型は Corporation: 複数の事業を持つ会社は、業種別の型(GeneralContractor など)だと、1つの事業しか表せません。
  • 市区町村は、番地と分ける: addressLocality と streetAddress を別にします。まとめると、テストで警告が出ます。
  • sameAs に親会社のURLを入れない: sameAs は「同じ存在であることを示すURL」です。親会社は別の法人なので、親子関係は parentOrganization で表します。

3. header.php から読み込む

wp_head() の直後に、1行追加します。

<?php wp_head(); ?>
<?php get_template_part('structured-data'); ?>
</head>

header.php を触れない場合は、functions.php から読み込みます。

add_action('wp_head', function () {
    get_template_part('structured-data');
}, 99);

テーマ直下の structured-data.php が読み込まれます(子テーマにも対応します)。

既存のプラグインの出力を止める

構造化データ・canonical・OGPを出力しているプラグイン(SEO系のプラグインや、拡張機能をまとめたプラグイン)を使っている場合は、そのプラグインの構造化データの出力をOFFにしてください。OFFにできない場合は、自前実装は使わず、プラグインの出力を使います。


4. 画面のパンくずと揃える

Googleは、構造化データの内容が、ページに表示している内容と一致していることを求めています。パンくずは、画面用と構造化データ用を別々に書くと、食い違いやすい部分です。

そこで、項目を作る関数 sd_breadcrumb_items() を1つにして、両方から呼びます。

<?php
// 画面のパンくず(構造化データと同じ sd_breadcrumb_items() から作る)
$bc_items = sd_breadcrumb_items();
$bc_last  = count($bc_items) - 1;
?>
<nav class="breadcrumb" aria-label="パンくずリスト">
  <ul>
    <?php foreach ($bc_items as $i => $item) : ?>
      <?php if ($i === $bc_last) : ?>
        <li><?php echo esc_html($item['name']); ?></li>
      <?php else : ?>
        <li><a href="<?php echo esc_url($item['url']); ?>"><?php echo esc_html($item['name']); ?></a></li>
      <?php endif; ?>
    <?php endforeach; ?>
  </ul>
</nav>

sd_breadcrumb_items() の中の $postlist_name と $postlist_url は、実在する一覧ページの名前とURLに合わせてください。一覧ページ自体のパンくずには、その固定ページのタイトルが出ます。投稿ページ側の名前と、一覧ページのタイトルを揃えておくと、サイト内で表記が統一されます。


5. 確認する方法

① ソースを見る

ページのソースを表示して、application/ld+json を検索します。次の点を確認します。

  • JSON-LD が、意図した数だけ出ているか
  • [ や ] で囲まれた、置き換わっていない文字が混ざっていないか
  • canonical と meta description が、1本ずつか

② リッチリザルトテスト

リッチリザルトテストにURLを入れます。記事ページでは、「記事」と「パンくずリスト」が検出されれば成功です。黄色い「重大ではない問題」は、任意項目の不足です。

テスト画面に表示されない種類(WebSite など)は、ソースで確認します。

③ Search Console

テストは、その場でページを読み込む検査です。Google検索への反映には、数日かかります。数日後に、Search Console の「拡張」で、パンくずリストなどにエラーがないか確認します。


ハマりどころ

症状 原因と対策
下層ページだけ、致命的エラーになる 関数を、テンプレートに書いている/functions.php に追加していない。トップページだけ確認して安心せず、固定ページ・記事・カテゴリーでも確認する
JSON-LDが途中で終わる 題名に </script> が含まれている。JSON_HEX_TAG を付ける
題名に &lt; という文字が出る HTMLエンティティを戻していない。html_entity_decode() を通す
画面と構造化データのパンくずが違う 別々に作っている。sd_breadcrumb_items() を共通で使う
canonical や description が2本出る プラグインと二重に出力している。どちらか一方にする
記事の型が、意図と違う article_type の綴り間違い。許可リストで Article に戻っている

本番に反映する前は、ファイルのバックアップを取り、wp-config.php で WP_DEBUG_LOG を有効にしておくと、エラーの原因をすぐ確認できます。


まとめ

  • 構造化データは、SEOプラグインを入れても、ソースを見ないと中身が分からない
  • プラグインなしでも、functions.php の関数と structured-data.php の1ファイルで、canonical・WebSite・記事・パンくずを出力できる
  • JSONは手組みせず、配列から wp_json_encode() で作る。JSON_HEX_TAG を忘れない
  • パンくずは、画面と構造化データで同じ関数を使う
  • プラグインと自前実装は、同時に動かさない

まずは、自分のサイトのソースを開いて、何が出力されているかを確認してみてください。