All Articles
Zenn12 min2026-03

構造化データ18種の設計と実装 — Schema.orgを「エンティティ認識装置」として使う

構造化データはリッチスニペットのためだけのものではない。18種のスキーマが@idで繋がり「グラフ」になる設計。

構造化データ18種の設計と実装 — Schema.orgを「エンティティ認識装置」として使う

構造化データの誤解を解く

構造化データの話をすると、ほぼ確実に「リッチスニペットのためでしょ?」と返ってくる。

違う。

リッチスニペットは副産物にすぎない。構造化データの本質は AIにエンティティを認識させる装置 だ。

Google、ChatGPT、Perplexity——あらゆるAIがWebページを読み取るとき、HTMLのテキストからエンティティ(人物・組織・サービス等の「実体」)を抽出しようとする。だがHTMLのテキストだけでは曖昧さが残る。「ゆめスタ」が会社名なのかサービス名なのか、「飯田思遠」がどの組織のどの役職の人物なのか、テキストだけでは 推測 するしかない。

構造化データはその推測を 確定 に変える。

ただし、1つのスキーマだけでは「点」だ。Organization を1つ置いただけでは、Googleに「組織があるらしい」と伝わるだけ。18種のスキーマが @id で相互参照し合って初めて、サイト全体が ナレッジグラフ になる。

Organization ←→ Person ←→ Article
     ↕              ↕
  Service      WebSite
     ↕
 LocalBusiness

点ではなくグラフ。これが設計の出発点になる。


18種の全体マップ

自分のサイト(yumesuta.com)では989行・18種のスキーマを1ファイルで管理している。「18種」という数は恣意的に決めたわけではない。このサイトの事業構造——組織、2人のコアメンバー、3つのサービス、月刊誌、記事群、高卒採用FAQ——を過不足なくグラフとして表現するために、結果的に18種が必要になった。

少なすぎると表現力が足りない。多すぎると管理が破綻する。

グループ分け

グループスキーマ目的
組織系Organization, WebSite, LocalBusiness組織の存在と所在を確定させる
人物系Person(2種)STAR紹介用とコアメンバー用の使い分け
コンテンツ系Article, Periodical, PublicationIssue, HowTo, FAQPage(2種)コンテンツの種別と著者を明示する
サービス系Service(3種)汎用/採用HP/動画サービスの個別定義
データ系Dataset, JobPosting統計データと求人情報の構造化
イベント系EducationalEvent教育イベントの対象者を明示する
ナビゲーション系BreadcrumbListサイト階層の構造化

Person を2種類に分けた理由

ここが設計上の重要な判断ポイントだった。サイトにはSTARインタビュー(外部の人物紹介)とコアメンバー(自社の人物)がいる。両者に求められるスキーマの粒度がまるで違う。

STAR用の generatePersonSchemaworksFor と基本情報を出力するシンプルなもの。コアメンバー用の generateCorePersonSchema@idsameAsknowsAbouthasOccupationcreator まで含む拡張版だ。

// STAR用: 外部人物のシンプルな紹介
export const generatePersonSchema = (person: {
  name: string
  nameEn: string
  jobTitle?: string
  organization: string
  description: string
  image: string
  url: string
  catchphrase: string[]
  region?: string
  keywords?: string[]
  websiteUrl?: string
}) => ({
  "@context": "https://schema.org",
  "@type": "Person",
  "name": person.name,
  "alternateName": person.nameEn,
  "jobTitle": person.jobTitle,
  "worksFor": {
    "@type": "Organization",
    "name": person.organization,
  },
  "description": person.description,
  "image": `https://yumesuta.com${person.image}`,
  "url": `https://yumesuta.com${person.url}`,
  // オプショナルフィールドはスプレッド演算子で条件付き展開
  ...(person.region && {
    "workLocation": {
      "@type": "Place",
      "name": person.region,
    },
  }),
  ...(person.keywords && person.keywords.length > 0 && {
    "knowsAbout": person.keywords,
  }),
})
// コアメンバー用: @id・sameAs・creator まで含む拡張版
export const generateCorePersonSchema = (person: {
  id: string
  name: string
  alternateName?: readonly string[]
  nameEn: string
  jobTitle: string
  description: string
  image: string
  knowsAbout: readonly string[]
  sameAs: readonly string[]
  occupation: readonly string[]
  creator?: readonly {
    readonly type: string
    readonly name: string
    readonly url?: string
  }[]
}) => ({
  "@context": "https://schema.org",
  "@type": "Person",
  "@id": `https://yumesuta.com/#${person.id}`,
  "name": person.name,
  ...(person.alternateName && person.alternateName.length > 0 && {
    "alternateName": person.alternateName
  }),
  "jobTitle": person.jobTitle,
  "worksFor": {
    "@type": "Organization",
    "@id": "https://yumesuta.com/#organization",
    "name": "株式会社ゆめスタ"
  },
  "description": person.description,
  "image": `https://yumesuta.com${person.image}`,
  "url": "https://yumesuta.com/company",
  "knowsAbout": person.knowsAbout,
  "sameAs": person.sameAs,
  "hasOccupation": person.occupation.map(occ => ({
    "@type": "Occupation",
    "name": occ
  })),
  ...(person.creator && person.creator.length > 0 && {
    "creator": person.creator.map(work => ({
      "@type": work.type,
      "name": work.name,
      ...(work.url && { "url": work.url })
    }))
  })
})

STAR用には @id がない。なぜなら外部の人物はサイト内でエンティティとして一意に識別する必要がないから。逆にコアメンバーは @id がないと Organization との相互参照が成立しない。同じ Person 型でも用途が違えば設計が変わる。


@id 設計の実装

@id はスキーマ全体を結合する背骨だ。

命名規則

https://yumesuta.com/#organization           → 組織
https://yumesuta.com/#website                → サイト
https://yumesuta.com/#person-iida-shion      → 飯田思遠
https://yumesuta.com/#person-urushihata-tomoya → 漆畑智哉

URLフラグメント識別子(# 以降)を使っている。実際にこのURLにアクセスしてもページ内の特定要素にジャンプするだけで、独立したリソースは返さない。それでいい。@id は「アクセスできるURL」である必要はない。一意な識別子として機能すれば十分 だ。

Organization の実装

export const generateOrganizationSchema = () => ({
  "@context": "https://schema.org",
  "@type": ["Organization", "EmploymentAgency"],
  "@id": "https://yumesuta.com/#organization",
  "name": "株式会社ゆめスタ",
  "legalName": "株式会社ゆめスタ",
  "url": "https://yumesuta.com",
  "founder": {
    "@type": "Person",
    "@id": "https://yumesuta.com/#person-iida-shion",
    "name": "飯田思遠"
  },
  "employee": [
    {
      "@type": "Person",
      "@id": "https://yumesuta.com/#person-iida-shion",
      "name": "飯田思遠",
      "jobTitle": "代表取締役"
    },
    {
      "@type": "Person",
      "@id": "https://yumesuta.com/#person-urushihata-tomoya",
      "name": "漆畑智哉",
      "jobTitle": "技術・クリエイティブ統括"
    }
  ],
  // ... 以下省略
})

founderemployee の中で Person の @id を参照している。別のページで generateCorePersonSchema が出力する Person オブジェクトも同じ @id を持つ。AIはこの @id の一致を見て、「Organization の founder とこの Person は同一人物だ」と確定する。

相互参照チェーンの全体像

Organization (@id: /#organization)
  ├── founder → Person (@id: /#person-iida-shion)
  ├── employee[0] → Person (@id: /#person-iida-shion)
  └── employee[1] → Person (@id: /#person-urushihata-tomoya)

Person: 飯田思遠 (@id: /#person-iida-shion)
  └── worksFor → Organization (@id: /#organization)

Person: 漆畑智哉 (@id: /#person-urushihata-tomoya)
  ├── worksFor → Organization (@id: /#organization)
  └── creator → [yumesuta.com, ゆめマガ, アニリク]

Article (各記事)
  ├── author → Person (@id: /#person-iida-shion or /#person-urushihata-tomoya)
  └── publisher → Organization (@id: /#organization)

WebSite (@id: /#website)
  └── publisher → Organization (@id: /#organization)

Organization → Person → Article → Organization。循環参照しているように見えるが、これが正しい。ナレッジグラフは有向グラフであって木構造ではない。


業界特化型の設計判断

マルチタイプ指定

"@type": ["Organization", "EmploymentAgency"]
"@type": ["LocalBusiness", "EmploymentAgency"]

Schema.org は @type に配列を許容する。Organization だけでは「何かの組織」としか認識されない。EmploymentAgency を追加することで「採用支援を行う組織」であることをAIに伝える。

これは Google のドキュメントにも明記されている正当な手法だ。ただし無関係なタイプを混ぜると逆効果になる。OrganizationEmploymentAgency は継承関係にあるから成立する。

EducationalAudience で「高校生」を明示する

"audience": {
  "@type": "EducationalAudience",
  "educationalRole": "student",
  "description": "高校生(就職希望者)"
}

Audience ではなく EducationalAudience を使っている。教育機関向けのオーディエンスであることを Schema.org の型レベルで宣言する。description でさらに「高校生(就職希望者)」と絞り込む。

AIが「このサービスは誰向けか?」を判断するとき、テキストを読み解くより構造化データの audience フィールドを参照する方が確実で高速だ。

Periodical × PublicationIssue

// 雑誌全体の定義
export const generateMagazineSchema = () => ({
  "@type": "Periodical",
  "name": "ゆめマガ - 高校生向け就活情報誌",
  "isAccessibleForFree": true,
  // ...
})

// 個別号の定義
export const generateMagazineIssueSchema = (issue: {
  month: number
  year: number
  pdfUrl: string
  coverImage: string
}) => ({
  "@type": "PublicationIssue",
  "issueNumber": `${issue.year}年${issue.month}月号`,
  "datePublished": `${issue.year}-${String(issue.month).padStart(2, '0')}-01`,
  "isPartOf": {
    "@type": "Periodical",
    "name": "ゆめマガ"
  },
  // ...
})

月刊誌を構造化するとき、「雑誌」と「号」を分離する。Periodical が親、PublicationIssue が子。isPartOf で接続する。これにより「ゆめマガ3月号」が独立した文書ではなく、定期刊行物の一部であるとAIが理解できる。

Service を3種に分けた理由

汎用の generateServiceSchema、採用HP専用の generateRecruitmentHPServiceSchema、動画サービス専用の generatePRVideoServiceSchema

なぜ分けるか。サービスごとに serviceTypehasOfferCatalogareaServed が異なるから。1つの Service スキーマに全サービスを詰め込むと、AIは「この組織は何のサービスを提供しているのか」を正確に判断できない。各サービスが独立したスキーマを持つことで、「採用HP制作」と「動画制作」が別のサービスであることが構造的に明確になる。


TypeScript での型安全な実装

オプショナルフィールドのスプレッド演算子

構造化データには「あれば出力、なければ省略」というフィールドが頻出する。TypeScript ではスプレッド演算子で条件付き展開するのが最も簡潔だ。

// ❌ undefined が JSON に混入する
{
  "workLocation": person.region ? { "@type": "Place", "name": person.region } : undefined,
}

// ✅ フィールドごと消える
{
  ...(person.region && {
    "workLocation": {
      "@type": "Place",
      "name": person.region,
    },
  }),
}

undefined が JSON に含まれると JSON.stringify で消えはするが、型としては不正確だし、他のシリアライズ手段で問題を起こす可能性がある。スプレッド演算子ならオブジェクトにフィールド自体が存在しない状態を作れる。

as const による Person データ定義

export const urushihataPersonData = {
  id: "person-urushihata-tomoya",
  name: "漆畑智哉",
  alternateName: ["天ちゃん++", "Tenchan++"],
  nameEn: "Tomoya Urushihata",
  jobTitle: "技術・クリエイティブ統括",
  knowsAbout: [
    "SEO", "LLMO", "AI最適化", "構造化データ設計",
    // ... 20+ items
  ],
  sameAs: [
    "https://github.com/tenchan000517",
    "https://www.wikidata.org/entity/Q138767422",
    // ...
  ],
  occupation: [
    "SEO/LLMOアーキテクト",
    "Webエンジニア",
    "ブロックチェーンエンジニア",
    "デザイナー",
    "映像クリエイター"
  ],
  creator: [
    { type: "WebSite", name: "yumesuta.com", url: "https://yumesuta.com" },
    { type: "Periodical", name: "ゆめマガ(高校生向け就活情報誌)", url: "https://yumesuta.com/yumemaga" },
    { type: "SoftwareSourceCode", name: "漆畑式LLMO(4層LLMO設計アーキテクチャ)", url: "https://github.com/tenchan000517/urushihata-llmo" },
    // ...
  ]
} as const

as const でリテラル型に固定する。これにより urushihataPersonData.id の型は string ではなく "person-urushihata-tomoya" になる。IDのtypoをコンパイル時に検出できる。

989行を1ファイルで管理する理由

「18種もあるなら分割しろ」という意見はもっともに聞こえる。だが分割すると、@id の一貫性を目視で確認するのが困難になる。Organization の @id を変更したとき、Person ファイル、Article ファイル、WebSite ファイル……全部を修正する必要が出る。

989行は確かに長い。だが1ファイルに収まっているおかげで、@id の文字列を Cmd+F で検索すれば全参照箇所が一覧できる。@id の整合性は構造化データの生命線だ。整合性を保つためのコストが最小になる構造を選んだ。


検証方法

1. Google Rich Results Test

https://search.google.com/test/rich-results

ページURLを入力するとリッチリザルト対象のスキーマを検出してくれる。エラーと警告を分けて表示してくれるので、まずはエラーをゼロにする。警告は「推奨フィールドが足りない」程度のものが多いので、必要に応じて対応する。

2. Schema Markup Validator

https://validator.schema.org/

Google 固有のルールではなく、Schema.org の仕様に準拠しているかを検証する。Rich Results Test が「Googleにとって有用か」を見るのに対し、こちらは「仕様として正しいか」を見る。

3. AIに直接聞く

最も実践的な検証。ChatGPT や Perplexity に「yumesuta.com はどんな会社?」「漆畑智哉は誰?」と聞いて、構造化データの内容が反映されているか確認する。

構造化データを正しく実装していれば、AIの回答精度が上がる。逆に実装が不十分だと、AIはテキストから推測した曖昧な情報を返す。最終的な検証基準は「AIが正しく答えられるか」だ。


まとめ

構造化データは「Googleに怒られないために入れるもの」ではない。サイト上のエンティティ——組織、人物、サービス、コンテンツ——を機械可読なグラフとして定義し、AIのエンティティ解決を確定的にするための装置だ。

18種が多いか少ないかはサイトの規模と事業構造による。重要なのは数ではなく、@id による相互参照が矛盾なく成立していること。点をいくら増やしても意味はない。グラフとして繋がって初めて機能する。


実装コード

シリーズ記事

Schema.orgSEONext.jsTypeScriptStructured Data