自社サービスの紹介ページ(LP)を Astro で作っています。AI の検索(ChatGPT の検索・Copilot・Perplexity など)と検索エンジンに、サービスの事実を正しく読んでもらうために、次の3つを用意しました。
- 画面に出す「よくある質問」
- 構造化データ(schema.org の JSON-LD。FAQPage など)
-
/llms.txt(AI 向けの短い案内。提案されている形式で、使われ方はまだ限られます)
この3つを別々に書くと、いつか必ず食い違います。たとえば、料金の言い方を画面だけ直して、JSON-LD を直し忘れるような食い違いです。Google の構造化データのガイドラインでも、FAQ のマークアップは「ページで見えている内容」と同じでなければいけません。
そこで、1つのデータから3つを作り、ビルドした結果を機械で照らし合わせるようにしました。
なお、Google の FAQ のリッチリザルトは、今は政府や医療など一部のサイトにしか出ません。それでも、検索エンジンや AI が問いと答えの組を読み取る手がかりになると考えて入れています。
全体の形
src/config/faq.ts ← 問いと答えの元(直すのはここだけ)
├ src/components/Faq.astro … 画面(details / summary)
├ src/lib/structuredData.ts … JSON-LD(FAQPage ほか)
└ src/pages/llms.txt.ts … /llms.txt
1. 問いと答えを TS のモジュールに置く
import { brand } from './brand'
export interface FaqItem {
q: string
a: string
/** 答えの続きに置くリンク(a → リンクの文字 → after の順で、1つの文になる) */
link?: { text: string; href: string; after: string }
}
export const faqGroups: { title: string; items: FaqItem[] }[] = [
{
title: 'はじめに',
items: [
{
q: '始めるのに、何が要りますか?',
a: 'パソコンとブラウザ、Google アカウントかメールアドレスです。…マイクと接続は、',
link: { text: '自己診断のページ', href: `${brand.appUrl}diag`, after: 'で確かめられます。' },
},
// …
],
},
]
/** 答えを1つの文にする(構造化データ用。リンクは文字だけを残す) */
export function faqAnswerText(f: FaqItem): string {
return f.link ? `${f.a}${f.link.text}${f.link.after}` : f.a
}
答えの途中にリンクを置きたいことがあるので、link を別に持たせています。リンクの出し方は、出す先ごとに変えます。
- 画面:
<a> - JSON-LD: 文字だけ
- llms.txt: Markdown のリンク
サービス名・料金・無料期間の日数も、文に直接書かず設定(brand.ts)から差し込みます。値を変えれば、3つとも一緒に変わります。
2. 画面:コンポーネントは出すだけ
---
import { faqGroups as groups } from '../config/faq'
---
{groups.map((g) => (
<div>
<h3>{g.title}</h3>
{g.items.map((f) => (
<details>
<summary>{f.q}</summary>
<p>
{f.a}
{f.link && (<><a href={f.link.href}>{f.link.text}</a>{f.link.after}</>)}
</p>
</details>
))}
</div>
))}
details の中身は、閉じていても HTML に入っています。JavaScript を動かさないクローラでも読めます。
3. 構造化データ:同じデータから FAQPage を作る
import { faqAnswerText, faqGroups } from '../config/faq'
export function faqPageLd() {
return {
'@type': 'FAQPage',
'@id': `${home}#faq`,
url: home,
inLanguage: 'ja',
mainEntity: faqGroups
.flatMap((g) => g.items)
.map((f) => ({ '@type': 'Question', name: f.q, acceptedAnswer: { '@type': 'Answer', text: faqAnswerText(f) } })),
}
}
トップページでは、次の4つを @graph でまとめて出しています。
- サイト(WebSite)
- 運営会社(Organization)
- アプリ(SoftwareApplication)
- FAQPage
---
const { jsonLd } = Astro.props
// 文の中の `</script>` で閉じられないように、< を逃がしてから埋め込む
const jsonLdText = jsonLd ? JSON.stringify(jsonLd).replace(/</g, '\\u003c') : ''
---
<head>
{jsonLd && <script type="application/ld+json" is:inline set:html={jsonLdText} />}
</head>
構造化データでは、次のことも決めておきました。
- 人の名前と住所は載せない: 特定商取引法に基づく表記のページにだけ書きます。
- 評価やレビュー(aggregateRating)は載せない: まだ無いので、作りません。
-
料金の申し込み(offers)は、有料の受付を始めてから出す: 今は申し込めない額を「売っている」と読ませないためです。設定の
paidOpenが true になったら、自動で出ます。 - 記事の書き手(author)を、記事の末尾の文と同じ会社にする: 末尾には「この記事は○○が書いています」と出しています。はじめは製品名の Organization にしていて、ここが食い違っていました。
4. llms.txt:同じデータから Markdown を作る
import type { APIRoute } from 'astro'
import { brand, siteHref } from '../config/brand'
import { faqGroups, type FaqItem } from '../config/faq'
/** 答えを1つの段落にする(リンクは Markdown の形・絶対の URL) */
const answer = (f: FaqItem) => (f.link ? `${f.a}[${f.link.text}](${siteHref(f.link.href)})${f.link.after}` : f.a)
export const GET: APIRoute = async () => {
const lines = [
`# ${brand.name}`,
'',
`> ${brand.description}`,
'',
'## よくある質問',
'',
...faqGroups.flatMap((g) => g.items).flatMap((f) => [`### ${f.q}`, '', answer(f), '']),
]
return new Response(lines.join('\n'), { headers: { 'Content-Type': 'text/plain; charset=utf-8' } })
}
#stack のようなページ内のリンクは、llms.txt の中では意味がありません。なので、絶対の URL に直しています。
5. ビルドした結果を照らし合わせる
作り方を1つにしても、テンプレートの書き間違いで食い違うことはあります。そこで、ビルドした dist/ から JSON-LD を取り出し、画面の文と照らし合わせるスクリプトを置きました(Python)。
import html, json, re
def text(fragment):
t = re.sub(r'<[^>]+>', '', fragment)
return re.sub(r'\s+', '', html.unescape(t))
doc = open('dist/index.html', encoding='utf-8').read()
ld = json.loads(re.search(r'<script type="application/ld\+json"[^>]*>(.*?)</script>', doc, re.S).group(1))
faq = next(n for n in ld['@graph'] if n['@type'] == 'FAQPage')['mainEntity']
items = re.findall(r'<details[^>]*>(.*?)</details>', doc, re.S)
assert len(faq) == len(items)
for q, d in zip(faq, items):
assert re.sub(r'\s+', '', q['name']) == text(re.search(r'<summary[^>]*>(.*?)</summary>', d, re.S).group(1))
assert re.sub(r'\s+', '', q['acceptedAnswer']['text']) == text(re.search(r'<p[^>]*>(.*?)</p>', d, re.S).group(1))
空白を除いて、一字一句同じかを見ています。ほかに、次の3つも見ています。
- 記事の構造化データの書き手が、記事の末尾の文の会社と同じか。
- llms.txt に、同じ数・同じ順で問いが並ぶか。
- 人の名前・住所・評価・offers が入っていないか。
照合のスクリプトが本当に効いているかは、画面の文を1字変えた写しで NG になるのを見て確かめました。通るだけのテストは、壊したら落ちるのを見るまで信用しません。
6. ついでに:AI のクローラを入口で止めていないか
構造化データや llms.txt を用意しても、クローラが読めなければ意味がありません。
Cloudflare には、AI のクローラを止める設定があります(ボットの設定の「AI のボットをブロック」や、AI Crawl Control など)。私たちは、API の GET /zones/{zone_id}/bot_management で状態を読み、次のようになっているのを確かめました。
{ "ai_bots_protection": "disabled", "fight_mode": false, "is_robots_txt_managed": false }
あわせて、Cloudflare の記録(GraphQL の httpRequestsAdaptiveGroups)で、実際に来たクローラを数えました。公開から2日ほどで、次の7つが来て、どれも 200 で読めていました。
- GPTBot・OAI-SearchBot(OpenAI)
- ClaudeBot(Anthropic)
- PerplexityBot
- bingbot
- Googlebot
- Applebot
注意が1つあります。無料のプランでは、1回に取れるのが1日分までです。取る行数の上限(私たちは1,000行)を決めたまま User-Agent で絞らずに数えると、少ないクローラが上限の外に落ちて見落とします(最初はそれで「GPTBot は来ていない」と読み違えました)。userAgent_like で絞ってから数えるのがおすすめです。
まとめ
- 画面・構造化データ・llms.txt を、1つのデータから作る。
- ビルドした結果を、画面の文と機械で照らし合わせる。壊して落ちるのも見る。
- 人の名前・まだ無い評価・まだ売っていない料金は載せない。
- クローラが本当に読めているかも確かめる。
このやり方は、ブラウザで使う声の仮想オフィス「Hotori」の紹介ページ(https://hotoriplace.com/ )で使っています。この記事は、Hotori を運営するインサイドセールス株式会社が書いています。