「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は、<head>という文字列のままでした。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エンティティを元の文字に戻す: 題名は
&や<の形で保存されていることがあります。そのままJSONにすると、<head>という文字列として出力されます(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 を付ける |
題名に < という文字が出る |
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を忘れない - パンくずは、画面と構造化データで同じ関数を使う
- プラグインと自前実装は、同時に動かさない
まずは、自分のサイトのソースを開いて、何が出力されているかを確認してみてください。