stock.dev
AstroブログにJSON-LDと検索計測を後付けした
se

AstroブログにJSON-LDと検索計測を後付けした

·10 min read

Astro + microCMS + Cloudflare Pagesで作った個人ブログ(息子の闘病記録)にテクニカルSEOの基礎をひと通り入れたので、その手順と内容を残しておく。

公開URL: https://west-blog.pages.dev/


状況

スタート時点の状態

  • Lighthouse Mobile: Performance / Accessibility / Best Practices / SEOすべて100
  • @astrojs/sitemapでsitemap-index.xml自動生成済み
  • public/robots.txtにSitemap指定あり
  • SEO.astroでtitle / description / canonical / OGP / Twitter Cardはすべて出力
  • 記事詳細では本文140字をdescriptionに自動抽出、og:imageは記事のアイキャッチを動的に使う
  • RSSフィード(/rss.xml) + @astrojs/rss実装済み

今回の実装

  1. 計測基盤が無い (Google Search ConsoleもAnalyticsも未導入で、表示回数0なのかインデックス未済なのか判別不能)
  2. JSON-LD構造化データが未実装 (Googleが記事として認識するための補助情報が無い)

一旦おいておく問題...

  • ドメインが*.pages.dev (Cloudflare共有ドメイン。検索評価上のハンデになりやすい)→個人記録としての気軽さを優先する。

採用方針

その前提で、計測基盤と構造化データだけは入れる方針にした。

項目 方針
アクセス解析 Cloudflare Web Analytics (Cookie不要・プライバシー配慮)
カスタムドメイン 取得しない (west-blog.pages.devのまま)
og画像動的生成 今回スコープ外 (アイキャッチが無い記事は固定のog-default.pngのまま)
JSON-LD 全ページにWebSite、記事詳細にBlogPosting + BreadcrumbListを追加
検索順位計測 Google Search Console + Bing Webmaster Tools

JSON-LD構造化データの追加

SEO.astroに3種類のスキーマを出力する処理を追加した。

  • 全ページ: WebSite
  • ogType === 'article'の記事詳細のみ: BlogPosting + BreadcrumbList

publisherは記事用とWebSite用で再利用する。authorもOrganization扱いで「ちいさな日々」を入れている(個人名は出さない方針)。

---
import { SITE } from '../consts';
 
interface Props {
  title?: string;
  description?: string;
  ogImage?: string;
  ogType?: 'website' | 'article';
  publishedAt?: string;
  updatedAt?: string;
}
 
const {
  title,
  description = SITE.description,
  ogImage = SITE.defaultOgImage,
  ogType = 'website',
  publishedAt,
  updatedAt,
} = Astro.props;
 
const fullTitle = title ? `${title} | ${SITE.title}` : SITE.title;
const canonical = new URL(Astro.url.pathname, SITE.url).toString();
const ogImageUrl = ogImage?.startsWith('http')
  ? ogImage
  : new URL(ogImage, SITE.url).toString();
 
const publisher = {
  '@type': 'Organization' as const,
  name: SITE.title,
  url: SITE.url,
  logo: {
    '@type': 'ImageObject' as const,
    url: new URL(SITE.defaultOgImage, SITE.url).toString(),
  },
};
 
const websiteSchema = {
  '@context': 'https://schema.org',
  '@type': 'WebSite',
  name: SITE.title,
  alternateName: SITE.titleEn,
  url: SITE.url,
  description: SITE.description,
  inLanguage: 'ja',
  publisher,
};
 
const blogPostingSchema =
  ogType === 'article' && title && publishedAt
    ? {
        '@context': 'https://schema.org',
        '@type': 'BlogPosting',
        headline: title,
        description,
        image: ogImageUrl,
        datePublished: publishedAt,
        dateModified: updatedAt ?? publishedAt,
        author: {
          '@type': 'Organization',
          name: SITE.authorName,
          url: SITE.url,
        },
        publisher,
        mainEntityOfPage: {
          '@type': 'WebPage',
          '@id': canonical,
        },
        inLanguage: 'ja',
      }
    : null;
 
const breadcrumbSchema =
  ogType === 'article' && title
    ? {
        '@context': 'https://schema.org',
        '@type': 'BreadcrumbList',
        itemListElement: [
          {
            '@type': 'ListItem',
            position: 1,
            name: SITE.title,
            item: SITE.url,
          },
          {
            '@type': 'ListItem',
            position: 2,
            name: title,
            item: canonical,
          },
        ],
      }
    : null;
---
 
<title>{fullTitle}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
<!-- 既存のOGP/Twitter Card出力は省略 -->
 
<script type="application/ld+json" is:inline set:html={JSON.stringify(websiteSchema)} />
{blogPostingSchema && (
  <script type="application/ld+json" is:inline set:html={JSON.stringify(blogPostingSchema)} />
)}
{breadcrumbSchema && (
  <script type="application/ld+json" is:inline set:html={JSON.stringify(breadcrumbSchema)} />
)}

is:inlineの指定

<script type="application/ld+json" set:html={...} />is:inlineなしで書くと、Astroのcheckが次のヒントを出した。

Add the is:inline directive explicitly to silence this hint.

Astroの<script>はデフォルトでバンドル対象になるが、JSON-LDのスクリプトはバンドルされない生のテキストとしてHTMLに残ってほしい。is:inlineを付けるとAstroが処理せずそのまま出力する。


Search Console認証スロット

GoogleとBingの所有権確認をHTMLメタタグ方式で行う。トークンはsrc/consts.tsに置き、BaseLayout.astroで条件付き出力する。空文字なら出力されない仕様にしておく(ローカル運用時にトークンを気にしなくていい)。

// src/consts.ts (抜粋)
export const SITE = {
  title: 'ちいさな日々',
  // ...
  googleSiteVerification: '',  // GSCで取得したcontent値を貼る
  bingSiteVerification: '',    // Bing Webmaster Toolsで取得したcontent値を貼る
  cloudflareAnalyticsToken: '', // Cloudflare Web Analyticsのtoken
  authorName: 'ちいさな日々',
} as const;
<!-- src/layouts/BaseLayout.astro (抜粋) -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
{SITE.googleSiteVerification && (
  <meta name="google-site-verification" content={SITE.googleSiteVerification} />
)}
{SITE.bingSiteVerification && (
  <meta name="msvalidate.01" content={SITE.bingSiteVerification} />
)}

Google Search Consoleの登録手順

  1. https://search.google.com/search-console/ でプロパティを追加(URLプレフィックス型)
  2. 「HTMLタグで確認」を選び、<meta name="google-site-verification" content="..." />のcontent値だけコピー
  3. consts.tsgoogleSiteVerificationに貼り付け
  4. コミットしてpush → Cloudflare Pagesが自動デプロイ
  5. GSCに戻って「確認」ボタン
  6. サイトマップ → 新しいサイトマップの追加 → sitemap-index.xmlを送信

Bing Webmaster Toolsの登録手順

Bingには「Google Search Consoleからインポート」というショートカットがある。ただし今回これを試したら「we didn't find any sites from GSC」と表示されて失敗した。原因はGSC側のプロパティ同期が間に合っていない(またはBing側のログインアカウントとGSCのアカウントが食い違っている)。

手動追加に切り替えた。

  1. https://www.bing.com/webmasters/ で「Add a site manually」を選ぶ
  2. URLにhttps://west-blog.pages.dev/を入力
  3. 所有権確認の方法で「HTML Meta Tag」を選ぶ
  4. 表示される<meta name="msvalidate.01" content="..." />のcontent値をコピー
  5. consts.tsbingSiteVerificationに貼り付け
  6. コミットしてpush
  7. Bingに戻って「Verify」 → サイトマップ送信

Cloudflare Web Analytics

Cookieを使わないアクセス解析。手動セットアップフローでした。

Web Analyticsを開始する
  ① ホスト名を設定 → west-blog.pages.dev を入力
  ② インストール     → ビーコンスニペットを表示
  ③ 分析を取得

②で出てきたスニペットは次の形だった。

<!-- Cloudflare Web Analytics -->
<script defer src='https://static.cloudflareinsights.com/beacon.min.js'
        data-cf-beacon='{"token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}'></script>
<!-- End Cloudflare Web Analytics -->

これもconsts.tscloudflareAnalyticsTokenに値だけ置いて、BaseLayout.astroで条件付き出力にした。

{SITE.cloudflareAnalyticsToken && (
  <script
    defer
    src="https://static.cloudflareinsights.com/beacon.min.js"
    data-cf-beacon={`{"token": "${SITE.cloudflareAnalyticsToken}"}`}
    is:inline
  ></script>
)}

ここでも<script>is:inlineを付ける。これがないとAstroがビルド時にバンドル対象としてしまい、外部スクリプトをそのままdefer読み込みする挙動から外れてしまう。


ビルド検証

ローカルで以下を確認した。

npx astro check
# Result (25 files):
# - 0 errors
# - 0 warnings
# - 0 hints
 
npm run build

ビルド後のdist/index.html(トップページ)にはWebSiteスキーマだけが入る。

<script type="application/ld+json">
{"@context":"https://schema.org","@type":"WebSite","name":"ちいさな日々",
 "alternateName":"CHIISANA HIBI","url":"https://west-blog.pages.dev",
 "description":"...","inLanguage":"ja",
 "publisher":{"@type":"Organization","name":"ちいさな日々",
   "url":"https://west-blog.pages.dev",
   "logo":{"@type":"ImageObject","url":"https://west-blog.pages.dev/og-default.png"}}}
</script>

記事詳細(dist/articles/<slug>/index.html)にはWebSite + BlogPosting + BreadcrumbListの3つが入る。それぞれ独立した<script type="application/ld+json">タグとして並ぶ。

consts.tsの各トークンを空文字にした状態でビルドしても、Search Console認証メタタグもCloudflare beaconも一切出力されないことを併せて確認した。

JSON-LDの内容はGoogle公式のリッチリザルトテストに貼ってBlogPostingとして認識されるかをチェックできる。


今後の運用

検索順位は数日では変わらないのでデータが溜まる順に観測していく。えいえいおー! 独自ドメインにしないのがどれほど影響してしまうのか... 個人的なブログなので試してみよう。

  • 1週間後: GSCの「ページ」タブでインデックス済ページ数を確認。0のままなら、主要記事をURL検査ツールから手動で「インデックス登録をリクエスト」(1日10件程度の制限)
  • 2週間後: GSC「検索パフォーマンス」で表示回数・クエリ・平均掲載順位を確認
  • 1ヶ月後: Cloudflare Web Analyticsで実アクセス傾向(国・デバイス・リファラ)を確認
  • 中長期: GSCの「表示はされているがクリックされていないクエリ」を起点に、記事のタイトル・descriptionを見直す

参考リンク: