JSON-LDをMicrodataから生成するJavaScript「md2ld.js」

ウェブサイトの制作・運営に携わり、JSON-LD で苦労している人も多いかと思います。今回は、そういった方々の救世主になるかもしれない、JSON-LD を Microdata から生成する JavaScript「md2ld.js」を紹介します。

JSON-LD で苦労する理由

JSON-LD は構造化データの1つで Google が推奨している方法です。ウェブページの本体とも言える HTML は文章を構造化するための記述言語です。「あれは見出し」「これは段落」「それは箇条書き」といったように、文字列が文章構造的に何なのかを明らかにします。対して、構造化データは「あれはウェブサイトの名称」「これはウェブページの名称」「それは人の名前」といったように、文字列が具体的に何なのかを明らかにします。

Microdata も構造化データの1つで、2025年現在、HTML の仕様書である HTML Standard にも記載されています。Microdata は HTML の各要素に専用属性 (itemscope, itemtype, itemprop 等) を埋め込むことで構造を明らかにします。

<body>
  <div itemscope itemtype="https://schema.org/WebSite">
    <header>
      <p itemprop="name">ウェブサイトの名称</p>
    </header>
    <main itemscope itemtype="https://schema.org/WebPage">
      <h1 itemprop="name">ウェブページの名称</h1>
      <p itemprop="author" itemscope itemtype="https://schema.org/Person">著者: <span itemprop="name">ウェブページの著者名</span></p>
      …
    </main>
    <footer>
      <p>&copy; <time itemprop="copyrightYear">2025</time> <span itemprop="copyrightHolder" itemscope itemtype="https://schema.org/Person"><span itemprop="name">ウェブサイトの著作権所有者名</span></span>.</p>
    </footer>
  </div>
</body>

Microdata の場合、ウェブページに表示する内容自体を構造化しますが、JSON-LD は表示する内容とは別に用意する必要があるため、手間がかかります。

<head>
  …
  <script type="application/ld+json">
  {
    "@context": "https://schema.org",
    "@graph": [
      {
        "@type": "WebSite",
        "name": "ウェブサイトの名称",
        "copyrightYear": "2025",
        "copyrightHolder": { "@id": "https://example.com/#WebSiteCopyrightHolder" }
      },
      {
        "@id": "https://example.com/#WebSiteCopyrightHolder",
        "@type": "Person",
        "name": "ウェブサイトの著作権所有者名"
      },
      {
        "@type": "WebPage",
        "name": "ウェブページの名称",
        "author": { "@id": "https://example.com/#WebPageAuthor" }
      },
      {
        "@id": "https://example.com/#WebPageAuthor",
        "@type": "Person",
        "name": "ウェブページの著者名"
      }
    ]
  }
  </script>
</head>

また、ユーザーが見ている情報 (HTMLコード) と、サーチエンジンが取得した情報 (JSON-LD) の整合性がとれていない状態も発生しやすく、最悪、スパム判定されるリスクもあります。

そのような事態を避けるため、フォームに入力された情報から、HTMLコード と JSON-LD を一括生成するのが一般的です。高いシェアを誇る WordPress のテーマやプラグインで採用されているのもこの方法です。

しかし、この方法は、構造化データごとにフォームを用意したり、HTMLコード と JSON-LD の出力を最適化しなければならないという欠点があります。2025年現在、Google検索 がサポートする構造化データは27種類もあります。

  • 記事
  • パンくずリスト
  • カルーセル
  • コースリスト
  • データセット
  • ディスカッション フォーラム
  • 教育向け Q&A
  • 雇用主の総合評価
  • イベント
  • よくある質問
  • 画像メタデータ
  • 求人情報
  • ローカル ビジネス
  • 数学の解法
  • 映画
  • 組織
  • 練習問題
  • 商品
  • プロフィール ページ
  • Q&A
  • レシピ
  • クチコミ抜粋
  • ソフトウェア アプリ
  • 読み上げ
  • 定期購入とペイウォール コンテンツ
  • 民泊
  • 動画

また、アップデートにより必須項目や推奨項目が変わったり、新たに構造化データが追加される可能性もあるため、それら全てに対応するというのは困難を極めます。

JSON-LD を自動生成させるという方法

JSON-LD を自動生成させる方法を考えた場合、HTMLコード を元に生成するのがベストだと思います。この方法であれば HTMLコード と JSON-LD の整合性がとれ、部分的にテンプレート化したり、局所的に構造化データを手動で埋め込むといったこともできます。

しかし、この方法は HTMLコード のどの部分を構造化データにするのかを明確にする必要があります。そこで目を付けたのが Microdata です。

Microdata は先にも述べた通り、HTML Standard にも記載される正式な仕様で、JSON-LD との互換性もあります。また、副次的な効果としてウェブページ上の情報を最適化できる側面もあります。このような経緯から、Microdata から JSON-LD を生成する JavaScript「md2ld.js」を制作しました。

余談: 情報の最適化とは

Microdata は型 (itemtype) を持つスコープ (itemscope) で構成されており、正しい位置に適切な情報を配置することが求められる場合があります。例えば、WebPage という型には breadcrumb という専用のプロパティがあるため「パンくずリスト」はウェブページの情報の一部として配置するのがベストだと言えます。

<div itemscope itemtype="https://schema.org/WebPage">
  …
  <ol itemprop="breadcrumb" itemscope itemtype="https://schema.org/BreadcrumbList">
    <li itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
      <a href="https://example.com/" itemprop="item"><span itemprop="name">ホーム</span></a>
      <meta itemprop="position" content="1" />
    </li>
    <li itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
      <a href="https://example.com/about/" itemprop="item"><span itemprop="name">このサイトについて</span></a>
      <meta itemprop="position" content="2" />
    </li>
    <li itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
      <a href="https://example.com/about/profile/" itemprop="item"><span itemprop="name">運営者情報</span></a>
      <meta itemprop="position" content="3" />
    </li>
  </ol>
  …
</div>

md2ld.js (Microdata to Linked Data) という選択肢

ということで Microdata から JSON-LD を生成する JavaScript の紹介です。md2ld.js (Microdata to Linked Data) と名付けた次のコードをコピペして md2ld.js を作成し、head要素内 で読み込んでください。それだけでOKです。

手作業で作った HTMLファイル から WordPress まで、幅広く使えるようになっています。応用すれば Node.js などにも転用できます。ちなみに、itemref属性 は使用しても反映されませんので、あしからず。

/**
 * md2ld.js (Microdata to Linked Data)
 * 
 * Microdata から Linked Data (JSON-LD) を生成し head要素 に script[type="application/ld+json"] を追加します
 * 
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the GNU General Public License
 * as published by the Free Software Foundation; either version 2
 * of the License, or (at your option) any later version.
 * 
 * @package md2ld.js
 * @since 1.0.0
 * @author Shimotsuki
 * @copyright Copyright (C) 2025 Shimotsuki
 * @license http://www.gnu.org/licenses/gpl-2.0.html GPL v2 or later
 * @link https://shimotsuki.wwwxyz.jp/
 */
new class {

  /**
   * @type {number} ユニークID 生成のためのカウンター
   */
  idCounter = 1;

  constructor() {
    const jsonLd = this.generate();

    if ( jsonLd ) {
      const script = document.createElement( 'script' );
      script.type = 'application/ld+json';
      script.textContent = jsonLd;
      document.head.append( script );

      // Microdata と JSON-LD がバッティングしないよう Microdata を除去
      document.querySelectorAll( '[itemscope], [itemtype], [itemid], [itemref], [itemprop]' ).forEach( ( element ) => {
        element.removeAttribute( 'itemscope' );
        element.removeAttribute( 'itemtype' );
        element.removeAttribute( 'itemid' );
        element.removeAttribute( 'itemref' );
        element.removeAttribute( 'itemprop' );
        element.removeAttribute( 'data-md-id' );
      } );
      document.querySelectorAll( 'body [itemprop]:is(link, meta)' ).forEach( ( element ) => {
        element.remove();
      } );
    }
  }

  /**
   * Microdata を DOM から抽出し JSON-LD文字列 を生成
   * 
   * @returns {string|null}
   */
  generate() {
    const final_graph = {};

    // 1. すべてのトップレベルエンティティを処理し final_graph を構築
    document.querySelectorAll( '[itemscope][itemtype]:not([itemprop])' ).forEach( ( entity_node ) => {
      this.processEntity( entity_node, final_graph );
    } );

    if ( 0 === Object.keys( final_graph ).length ) {
      return null;
    }

    // 2. final_graph を JSON-LD形式 に変換
    const jsonLdObject = {
      "@context": "https://schema.org",
      "@graph": this.buildJsonLdGraph( final_graph )
    };

    return JSON.stringify( jsonLdObject, null, 2 );
  }

  /**
   * エンティティを処理し final_graph に追加
   * ネストされたエンティティの処理にも使用
   * 
   * @param   {Element} entity_node - itemscope を持つノード
   * @param   {object}  final_graph - 収集された全エンティティのマップ
   * @returns {string}              - エンティティID
   */
  processEntity( entity_node, final_graph ) {
    const entity_id = this.getEntityId( entity_node );

    // 既に処理済みの場合はスキップ
    if ( final_graph[ entity_id ] ) {
      return entity_id;
    }

    final_graph[ entity_id ] = {
      'id': entity_id,
      'type': entity_node.getAttribute( 'itemtype' ),
      // ネストされたエンティティも再帰的に処理
      'properties': this.collectProperties( entity_node, final_graph ),
    };

    return entity_id;
  }

  /**
   * エンティティの ID を取得または生成
   * 
   * @param   {Element} node - itemscope を持つノード
   * @returns {string}       - エンティティID
   */
  getEntityId( node ) {
    if ( node.hasAttribute( 'itemid' ) ) {
      return node.getAttribute( 'itemid' );
    }

    // ユニークID を生成
    let id = node.dataset.mdId;
    if ( ! id ) {
      id = 'md' + String( this.idCounter++ ).padStart( 2, '0' ) + Math.random().toString( 36 ).substr( 2, 8 );
      node.dataset.mdId = id;
    }
    return id;
  }

  /**
   * エンティティの全プロパティを収集
   * 
   * @param   {Element} entity_node - itemscope を持つノード
   * @param   {object}  final_graph - 収集された全エンティティのマップ
   * @returns {object}              - プロパティを格納したオブジェクト
   */
  collectProperties( entity_node, final_graph ) {
    const properties = {};

    // entity_node の子ノードから走査を開始
    this.recursivelyCollectProperties( entity_node.children, entity_node, final_graph, properties );

    return properties;
  }

  /**
   * エンティティノードの子孫を再帰的に走査し、プロパティを収集するヘルパー関数
   * 
   * @param {HTMLCollection} current_nodes - 現在処理中のノードのコレクション
   * @param {Element}        entity_root   - 収集対象のエンティティのルートノード
   * @param {object}         final_graph   - 全エンティティのマップ
   * @param {object}         properties    - 収集したプロパティの格納先
   */
  recursivelyCollectProperties( current_nodes, entity_root, final_graph, properties ) {
    for ( const node of current_nodes ) {
      if ( node.nodeType !== Node.ELEMENT_NODE ) {
        continue;
      }

      const has_itemprop = node.hasAttribute( 'itemprop' );
      const has_itemscope = node.hasAttribute( 'itemscope' );

      // 1. itemprop を持つ要素
      if ( has_itemprop ) {
        // A. ネストされたエンティティのルートの場合 (itemprop + itemscope)
        if ( has_itemscope ) {
          const prop_names = node.getAttribute( 'itemprop' ).split( /\s+/ ).filter( name => name );
          const prop_value_data = this.getPropertyValue( node, final_graph );

          if ( null !== prop_value_data ) {
            prop_names.forEach( ( name ) => {
              properties[ name ] = properties[ name ] || [];
              properties[ name ].push( prop_value_data );
            } );
          }

          continue;
        }

        // B. 純粋なプロパティの場合 (itemprop)
        else {
          const prop_names = node.getAttribute( 'itemprop' ).split( /\s+/ ).filter( name => name );
          const prop_value_data = this.getPropertyValue( node, final_graph );

          if ( null !== prop_value_data ) {
            prop_names.forEach( ( name ) => {
              properties[ name ] = properties[ name ] || [];
              properties[ name ].push( prop_value_data );
            } );
          }

          // 子孫に itemprop がないか探す
          this.recursivelyCollectProperties( node.children, entity_root, final_graph, properties );

          continue;
        }
      }

      // 2. itemprop を持たず itemscope を持つ要素
      if ( has_itemscope ) {
        continue;
      }

      // 3. どちらの属性も持たない一般的な要素
      // 子孫に itemprop がないか探す
      this.recursivelyCollectProperties( node.children, entity_root, final_graph, properties );
    } // for
  }

  /**
   * Microdata のルールに従いプロパティの値を確定
   * 
   * @param   {Element} node        - itemprop を持つノード
   * @param   {object}  final_graph - 全エンティティのマップ
   * @returns {object|null}
   */
  getPropertyValue( node, final_graph ) {
    const tag = node.tagName.toLowerCase();

    // 1. itemscope がある場合
    if ( node.hasAttribute( 'itemscope' ) ) {
      // ネストされたエンティティを再帰的に処理し ID を取得
      const nested_entity_id = this.processEntity( node, final_graph );
      return {
        'value': nested_entity_id,
        'is_reference': true
      };
    }

    // 2. 属性から値を取得
    let value = null;
    if ( 'meta' === tag ) {
      value = node.getAttribute( 'content' );
    } else if ( 'link' === tag || 'a' === tag ) {
      value = node.getAttribute( 'href' );
    } else if ( 'source' === tag || 'img' === tag || 'iframe' === tag || 'embed' === tag || 'video' === tag || 'audio' === tag || 'track' === tag ) {
      value = node.getAttribute( 'src' );
    } else if ( 'data' === tag || 'meter' === tag ) {
      value = node.getAttribute( 'value' );
    } else if ( 'time' === tag ) {
      value = node.getAttribute( 'datetime' );
    }
    if ( null !== value ) {
      return {
        'value': value,
        'is_reference': false
      };
    }

    // 3. テキストコンテンツを取得
    const text_content = this.collectTextContent( node );
    if ( text_content ) {
      return {
        'value': text_content,
        'is_reference': false
      };
    }

    return null;
  }

  /**
   * Microdata のテキストコンテンツ結合ルールに従いノードのテキストコンテンツを収集
   * 
   * @param   {Element} node - itemprop を持つノード
   * @returns {string}       - テキストコンテンツ
   */
  collectTextContent( node ) {
    let parts = [];

    for ( const child_node of node.childNodes ) {
      if ( child_node.nodeType === Node.TEXT_NODE ) {
        const trimmed = child_node.textContent.trim();
        if ( 0 < trimmed.length ) {
          parts.push( trimmed );
        }
        continue;
      }

      if ( child_node.nodeType === Node.ELEMENT_NODE ) {
        if ( 'img' === child_node.tagName.toLowerCase() ) {
          const alt = child_node.getAttribute( 'alt' ) || '';
          if ( alt ) {
            parts.push( alt.trim() );
          }
          continue;
        }

        if ( child_node.hasAttribute( 'itemprop' ) ) {
          continue;
        }

        const inner_text = this.collectTextContent( child_node );
        if ( inner_text ) {
          parts.push( inner_text );
        }
      }
    } // for

    return parts.join( ' ' ).replace( /\s+/g, ' ' ).trim();
  }

  /**
   * final_graph をJSON-LD形式 に変換
   * 
   * @param   {object} final_graph - 全エンティティのマップ
   * @returns {array}
   */
  buildJsonLdGraph( final_graph ) {
    let transformed_graph = [];

    for ( const item_id in final_graph ) {
      const entity = final_graph[ item_id ];
      let new_entity = {};

      if ( entity.id ) {
        new_entity[ '@id' ] = entity.id;
      }

      if ( entity.type ) {
        if ( entity.type.startsWith( 'https://schema.org/' ) ) {
          new_entity[ '@type' ] = entity.type.substring( 'https://schema.org/'.length );
        } else {
          new_entity[ '@type' ] = entity.type;
        }
      }

      if ( entity.properties ) {
        for ( const prop_key in entity.properties ) {
          const values = entity.properties[ prop_key ];
          let prop_values = [];

          values.forEach( item => {
            if ( undefined === item.value || null === item.value ) {
              return;
            }

            if ( item.is_reference ) {
              prop_values.push( { '@id': item.value } );
            } else {
              prop_values.push( item.value );
            }
          } );

          if ( 1 === prop_values.length ) {
            new_entity[ prop_key ] = prop_values[ 0 ];
          } else if ( 1 < prop_values.length ) {
            new_entity[ prop_key ] = prop_values;
          }
        } // for
      }

      transformed_graph.push( new_entity );
    } // for

    return transformed_graph;
  }

}

script要素 には defer属性 を忘れずにつけてください。

<head>
  …
  <script src="md2ld.js" defer></script>
  …
</head>

md2ld.js によって HTMLコード内 の Microdata から JSON-LD を生成され、head要素 の終了タグ前に出力されます。あとは Microdata によるマークアップを頑張るのみです!

実は、Microdata によるマークアップが一番の鬼門だったりして。もし不具合等を見つけてしまった場合は、こっそり教えてください。

関連記事