---
title: "pixelstomotors.com の全記事を AI が丸ごと読めるように ― 記事に「AIで読む」ボタンを置いた"
slug: full-text-pages-and-read-with-ai-button
date: 2026-11-01
day: 421
meta_description: "pixelstomotors.com の全記事に、コードとログを含む全文を Markdown 1枚で読める全文ページを足し、記事に「AIで読む」ボタンを置いた。AI の引用に効くのは全文ページのほう。"
tags: [サイト改修, AI引用, llms.txt, Markdown, ChatGPT, Claude, Perplexity, Gemini, Cloudflare Workers, Astro, アクセス解析]
ai_consult: true
series: "ひとり研究所の裏側"
---

**pixelstomotors.com の全記事（公開時点で424本）に、コードと実行ログを含む本文を Markdown 1枚で読める全文ページを足し、記事ページには ChatGPT・Claude・Perplexity・Gemini を選んで記事を読ませる「AIで読む」ボタンを置きました。** AI の回答で引用される数を増やすのに効くのは全文ページのほうで、ボタンは読み手が自分の環境で再現するのを手伝う入口だと判断して、2つを組にして入れています。

これで変わることは3つあります。

- AI が読める量：これまで、コードとログまで1枚で読める全文があったのは、長いコードを別ファイルに分けた67本（Markdown 64本・HTML 3本）だけでした。今は全記事にあり、住所は記事の住所の末尾に `.md` を付けるだけで分かります
- 読み手の手間：これまでは、記事の住所をコピーして AI を開き、質問を自分で書く必要がありました。今は、ボタンを押して AI を選ぶと、要点と「自分の環境で再現するときに確かめること」を聞く質問文が入った状態で開きます（前の手間の時間は測っていません）
- 数えられること：ボタンが押された数を AI ごとに数え、さらにボタン経由で AI が全文ページを実際に読みに来た数も、押した AI ごとに分けて数えます。名乗りでは見分けられない Gemini の分も数えられます

背景には、このサイトが人より先に AI に読まれ始めていることがあります。前に計測の記録を読んだとき、人がページを見た回数が5日で130回なのに対し、ChatGPT が利用者に頼まれて記事を読みに来た回数は48回ありました（LOG411）。人が調べものを AI に聞くことが増えたので、AI の答えの中でこのサイトが引用される数を増やしたい、というのが私の考えです。ただ、記事の多くは長いコードやログを含み、AI がページを読んでも、別ファイルに分けたコードまではたどらないことがありました。

最初は「AIで読む」ボタンの案だけでした。作る前に効き目を分析すると、ボタンだけではサイト側の直接のメリットは小さく、引用を増やすのに効くのは、AI が丸ごと読める全文ページのほうだと分かりました。そこで両方を入れています。

| やったこと | なぜ | 結果 |
|---|---|---|
| 「AIで読む」ボタンの効き目を、作る前に分析した | 置けるかどうかと、置く意味があるかを先に知りたかった | 読み手の AI を自動で選ぶことはできない。ボタンは読み手の再現の手伝いで、引用に効くのは全文ページ |
| 全記事に全文ページ（`/record/{slug}.md`）を足した | AI が分けたコードまで含めて1枚で読めるようにする | 424本。人が見る記事ページは変えず、検索では記事ページを正とする印を付けた |
| 記事のタイトルの下に「AIで読む」を置いた | 読み手が自分の AI で、記事を自分の環境に合わせて読めるようにする | ChatGPT・Claude・Perplexity は質問文入りで開く。Gemini とほかの AI はコピー |
| 押した数と、ボタン経由で AI が読みに来た数を数えるようにした | 効いたかどうかを、あとで数字で確かめる | 全文ページの住所に押した AI の印を付け、AI ごとに読みに来た数を分けて出す |

【画像】LOG421_01_ai_read_pc.png パソコンで見た記事ページ。タイトルの下に「この記事の素材」と「AIで読む」の灰色の帯が並び、「AIで読む」を開くと ChatGPT・Claude・Perplexity・Gemini で開く行と、質問文をコピーする行が出る


## 作業ログ ― 「AIで読む」ボタンの効き目を、作る前に分析した

このサイトは、記事を作るときも、公開するときも、ほとんどの作業を Claude（Anthropic の AI）と一緒に進めています。今回も、私の質問から始まりました。

> サイトの記事内に、記事内容をAIに読み込ませるボタン（名称は提案ください）などを置き、記事をそのまま自分の利用しているAIを選択して、もしくは自動AI選択といったことは可能ですか？またそのボタンの有効性についても分析して教えてください。

### 読み手が使っている AI を自動で選ぶことはできない

ボタンそのものは置けます。ChatGPT・Claude・[Perplexity](https://www.perplexity.ai/)（検索しながら答える AI）は、住所の後ろに質問文を付けて開くと、入力欄にその文が入った状態で始まります。記事の本文を丸ごと住所に入れると長すぎるので、記事の住所を渡して「このページを読んで」と頼む形にします。

できないのは、読み手がどの AI にログインしているかを調べて、自動で選ぶことです。ブラウザは、別のサイトのログインの状態を、ほかのサイトに見せない作りになっています。そこで、最初に一度選んでもらい、その選択を見た人のブラウザに覚えて、次から一番上に出す形にしました。自動選択に一番近い形です。

名前は3案を出し、短くて何が起きるか分かる「AIで読む」にしました。

| 案 | 向き・不向き |
|---|---|
| AIで読む（採用） | 短く、何が起きるか分かる |
| この記事をAIに聞く | 質問できると伝わる。少し長い |
| AIと一緒に再現する | 主指標（再現可能な環境数）に一番近い。押す前の期待が大きすぎる |

### ボタンが直接効くのは読み手の側で、引用には効かない

効き目を、サイトの側と読み手の側に分けて考えました。

| | 効くこと | 効かないこと |
|---|---|---|
| 読み手 | 長いコード・ログを、自分の機体やパソコンに合わせて AI に読み解かせる入口になる | ― |
| サイト | 押された数と、どの AI が選ばれたかは数えられる | AI の回答で引用される数はほとんど増えない。押すのは、すでに記事を開いている人だから。押したあと AI の中で何を聞いたか、再現できたかも見えない |

**AI の回答で引用されるかは、ChatGPT などが検索したときにこのサイトを見つけ、中身を読めるかで決まります。** ボタンはそこに関わりません。引用に効くのは、AI が読みに来たときに、分けたコードまで含めて1枚で読めるページのほうです。

分析を見て、私はこう返しました。

> このボタンによって直接的なメリットは特にないという感じ？

> AIが引用するのを増やすのは一つの目的なので、ボタン追加、全文ページ追加をしてみよう

そこで、全文ページを本体に、ボタンをその入口にして、両方を入れることにしました。


## 作業ログ ― 全記事に全文ページ（/record/{slug}.md）を足した

### 全文ページは、記事ページとは別に足す1枚の写し

全文ページは、[Markdown](https://daringfireball.net/projects/markdown/)（見出しや表を記号で書く、飾りのない文字だけの書き方）で書いた、記事1本の写しです。住所は、記事ページの住所の末尾に `.md` を付けたものです。

- 記事ページ：https://pixelstomotors.com/record/DesFKiCf
- 全文ページ：https://pixelstomotors.com/record/DesFKiCf.md

人が見るのは今までどおり記事ページで、見た目もアドレスも変えていません。全文ページの頭には、題名・LOG 番号・シリーズ・公開日・記事ページの住所・要約を置き、「引用するときは記事のページの住所を使ってください」と書きました。AI が全文ページを読んで答えるときも、記事ページを指してもらうためです。

- ねらい：AI が記事を読みに来たとき、分けたコードやログまで含めて1回で読めるようにする
- 変わったこと：全文があったのは分けた記事の67本だけだったのが、全記事（公開時点で424本）になった
- 見込める効果：AI の答えの中で、コードやログの中身まで引いて答えられる記事が増える。実際に読まれたかは、AI の巡回の記録の「よく読まれたページ」（`.md` の住所）で確かめる

### 分けた記事は分ける前の全文を、Studio から移した記事は HTML から直した

記事は、作った時期によって3つの形で入っています。全文ページの作り方は、形ごとに変えました。

| 記事の形 | 本数 | 全文ページの作り方 |
|---|---|---|
| 長いコード・ログを別ファイルに分けた記事 | 67本（Markdown 64本・HTML 3本） | 分ける前の全文（`LOG{N}_full.md`・`LOG{N}_full.html`）を使う |
| Claude が Markdown で書いた記事（分けていないもの） | 残りの Markdown の記事 | 記事の Markdown をそのまま使う |
| Studio（前に使っていたサイトの作成サービス）から移した記事 | 341本の元データのうち、Markdown の記事と重ならないもの | 本文の HTML を [turndown](https://github.com/mixmark-io/turndown)（HTML を Markdown に直す部品）で直す |

記事の Markdown には、このサイトだけの印（`【画像】ファイル名`・`【図解】ファイル名` など）があります。サイトはこれを画像や図に置き換えて表示しますが、全文ページではただの文字になってしまいます。そこで、画像は Markdown の画像の書き方に、図解と動画はリンクに直し、どれも `https://pixelstomotors.com` から始まる住所にしました。ページの外で読んでも、どこに何があるかが分かるようにするためです。

全文ページの大きさは、まん中の値で約22KB でした。一番大きいのは、配膳ロボットのシミュレーションを18回回した記録で、約8.3MB あります。実行ログを全量で載せているためです。AI によっては、大きなページを途中までしか読まないことがあります。

### 検索では記事ページを正にする（canonical）

同じ中身のページが2つあると、検索エンジンは重複として扱い、どちらを検索結果に出すかを自分で選びます。全文ページが検索結果に出てしまうと、人が飾りのない文字だけのページに着いてしまいます。

そこで、全文ページを返すときに「正しいページは記事ページのほう」という印（canonical）を付けました。印は、ページの中ではなく、サイトがページを返すときの見出し（HTTP の Link ヘッダー）に書いています。Markdown のページには、HTML のように印を書く場所がないためです。ページを配っている Cloudflare Workers（ページを返す前に小さなプログラムを動かせる仕組み）で付けました。

```
$ curl -sI "https://pixelstomotors.com/record/DesFKiCf.md?p2m-notrack=1" | grep -i "HTTP\|content-type\|^link"
HTTP/1.1 200 Connection Established
HTTP/2 200 
content-type: text/markdown; charset=utf-8
link: <https://pixelstomotors.com/record/DesFKiCf>; rel="canonical"
```

### AI への案内（llms.txt）と、記事ページの目印を全記事に

AI が全文ページを見つけられるように、2か所に置き場所を書きました。

- [llms.txt](https://llmstxt.org/)（AI がサイトを読むときの案内のファイル）：全記事の行に「全文の Markdown」の住所を足した。前は分けた記事にだけ書いていた
- 記事ページの head（ページの見えない部分）：`<link rel="alternate" type="text/markdown">` で、すべての記事から全文ページを指すようにした。前は分けた記事にだけあり、指す先も `/files/LOG{N}/LOG{N}_full.md` だった


## 作業ログ ― 記事のタイトルの下に「AIで読む」を置いた

### 灰色の帯で、押すと AI の一覧が開く

場所は、タイトルのすぐ下にしました。読み手がコードを読み始めて困ったときより、最初に目に入る所のほうが見つけやすいためです。形は、すぐ上にある「この記事の素材」と同じ灰色の帯にそろえました。

押すと、次の5つが開きます。

| 行 | 押すと |
|---|---|
| ChatGPTで開く | 質問文が入力欄に入った状態で ChatGPT が開く |
| Claudeで開く | 同じく Claude が開く |
| Perplexityで開く | 同じく Perplexity が開く |
| Geminiで開く | 質問文をコピーしてから Gemini を開く（貼り付けてもらう） |
| 質問文をコピー（ほかの AI で使う） | 質問文をコピーする |

Gemini には、調べた範囲では、住所に質問文を付けて入力欄に入れる公式の方法がありません。そのため、コピーしてから開く2段階にしました。コピーできなかったときは、質問文をその場に出して、選んでコピーできるようにしています。

【画像】LOG421_02_ai_read_phone.png スマホで見た記事ページ。タイトルの下に「この記事の素材」と「AIで読む」の帯が縦に並ぶ

### 質問文は、要点と「自分の環境で再現するときに確かめること」

入力欄に入れる質問文は、次の形です。記事ページと全文ページの住所を両方入れ、全文のほうにコードと実行ログがあることを書いています。

```
この記事を読んで、質問に答えてください。
記事: https://pixelstomotors.com/record/DesFKiCf
全文（コード・実行ログを含む）: https://pixelstomotors.com/record/DesFKiCf.md?via=chatgpt

まず、何をして何が分かったのかを短くまとめてください。そのあと、私が同じことを自分の環境で試すときに、確かめることを教えてください。
```

2つ目の問いを「自分の環境で再現するときに確かめること」にしたのは、このサイトの主指標が「再現可能な環境数」（他人が同じ手順で同じ結果に届く研究の数）だからです。記事を要約して終わりにせず、読み手が自分の機体やパソコンで試すところまでつなげたいと考えました。末尾の `?via=chatgpt` は、どの AI から来たかの印です（次の節）。

### 前回選んだ AI を一番上に出す

選んだ AI を、見た人のブラウザに覚えます（`p2m-site-ai`）。次に開いたとき、その AI を一番上に移し、「前回」の印を付けます。覚えられない環境（プライベートブラウズなど）では、いつもの並びのまま動きます。

### スマホとパソコンで確かめた

公開前に、手元でサイトを作って、幅 1280px と 390px の画面で開き、帯の形・一覧の開き方・コピーの動き・前回の並び替えを確かめました。公開後、私がスマホとパソコンで開いて確かめています（上の2枚の画面写真）。

スマホでは、一度押したあとに指を乗せたときの色が残り、「AIで読む」の帯だけが少し濃く見えることがあります。これは iPhone の表示のくせなので、直さないことにしました。

> 一度押したあとに指を乗せたときの色（ホバーの色）が残ることがあり、それが出ているので気にしないで。

ChatGPT・Claude をスマホで開いたときに、アプリのほうへ質問文が渡るかは、まだ確かめていません。アプリが入っていると、住所の後ろの質問文がアプリに渡らないことがあります。


## 作業ログ ― 押した数と、ボタン経由で AI が読みに来た数を数える

### 押した数は、サイトの計測の「押したもの」の表に出る

このサイトには、ページのどこが押されたかを数える仕組みがあります（`/api/ev-stats`。LOG411 で説明）。「AIで読む」の帯に `data-ev-area="ai"` を付け、場所「ai」として数えるようにしました。表には次の名前で出ます。

| 表に出る名前 | 何を押したか |
|---|---|
| btn:AIで読む:開く | 「AIで読む」を開いた |
| AIで読む chatgpt.com | ChatGPTで開く |
| AIで読む claude.ai | Claudeで開く |
| AIで読む www.perplexity.ai | Perplexityで開く |
| AIで読む gemini.google.com | Geminiで開く |
| btn:AIで読む:コピー | 質問文をコピー |

開いた数と AI を選んだ数を比べると、開いたけれど選ばずにやめた人の割合も分かります。

### 全文ページの住所に印を付けて、ボタン経由の読み取りを分けた

押した数だけでは、そのあと AI が本当に記事を読んだかは分かりません。このサイトには、AI や検索エンジンの巡回プログラム（クローラー）が、どのページを読みに来たかを残す仕組みもあります（`/api/cr-stats`）。ChatGPT・Claude・Perplexity は、利用者に頼まれて読むとき `ChatGPT-User`・`Claude-User`・`Perplexity-User` と名乗るので、どの AI が来たかが分かります。

ただ、この記録だけでは、ボタン経由で来たのか、AI が自分で見つけて来たのかを分けられません。Gemini は名乗りの一覧に入っていないので、数にも入りません。

そこで、質問文に入れる全文ページの住所に、押した AI の印を付けました。

| 押したもの | 質問文の全文ページの住所 |
|---|---|
| ChatGPTで開く | `/record/{slug}.md?via=chatgpt` |
| Claudeで開く | `/record/{slug}.md?via=claude` |
| Perplexityで開く | `/record/{slug}.md?via=perplexity` |
| Geminiで開く | `/record/{slug}.md?via=gemini` |
| 質問文をコピー | `/record/{slug}.md?via=copy` |

サイトは、この印が付いた全文ページへの訪問を、名乗りが一覧にない読み手（Gemini や、人のブラウザなど）も「その他」として残します。数は `/api/cr-stats` の「AIで読む経由」の表に、押した AI ごとに「読みに来た回数・記事の数・読みに来たのはだれか」と、週ごとの内訳で出ます。

【図解】LOG421_read_with_ai_flow.html

- ねらい：ボタンを押したあと、AI が本当に記事を読んだかを、押した AI ごとに知る
- 変わったこと：名乗りで見分けられる AI だけだった記録に、ボタン経由の印が付いた。Gemini の分も「その他」として数えられる
- 見込める効果：押した数とボタン経由で読みに来た数を並べると、どの AI だと読ませるところまで届いているかが分かる。押された数は、公開後の計測で確かめる

公開後、印が付いた住所を確かめのために1回開いたので、「その他」に1件入っています（`via=chatgpt`）。

印は、ボタンで開いたときだけ付きます。AI が自分で見つけて全文ページを読みに来たときは印がないので、今までどおり名乗りで数えます。

### 記録の仕組みを、作った部品で確かめた

公開の前に、サイトのプログラム（`worker/index.js`）を手元で動かし、作り物の訪問を4回送って、記録に入るものと入らないものを確かめました。名乗りのない Gemini（`via=gemini`）は「その他」、ChatGPT（`via=chatgpt`）は `ChatGPT-User` として残り、印のない人の訪問と、決まっていない印（`via=evil`）は残りませんでした。

```
<https://pixelstomotors.com/record/x>; rel="canonical" text/markdown; charset=utf-8
[
  { b: 'その他', p: '/record/x.md', s: '200', ok: '?', v: 'gemini' },
  {
    b: 'ChatGPT-User',
    p: '/record/x.md',
    s: '200',
    ok: '?',
    v: 'chatgpt'
  }
]
```

集計（`/api/cr-stats?format=json`）の「AIで読む経由」の部分は、次のように出ました。

```
    visits: 1,
    records: 1,
    bots: 'ChatGPT-User 1',
    last: '2026-11-01'
  }
] [ { week: '2026-10-26', total: 2, via: 'gemini 1、chatgpt 1' } ]
```


## 作業ログ ― 使ったコード（全文）

### src/lib/fulltext.ts（全文ページの中身を作る）

`````ts
// 記事の全文ページ（/record/{slug}.md。2026-11-01）
// AI が記事を丸ごと読めるように、1本の記事を飾りのない Markdown 1枚にする。
// - 長いコード・ログを外に分けた記事（tools/split_files.py）は、分ける前の全文（public/files/LOG{N}/LOG{N}_full.md・.html）を使う
// - Studio から移した記事（本文が HTML）は Markdown に直す（turndown）
// - 画像・図解・動画・このサイトの住所は、ページの外でも開けるよう https://pixelstomotors.com から始まる住所にする
// 人が見るのは今の記事ページのまま。全文ページは worker/index.js が canonical（正しいページは記事ページ）を付けて返す
import { existsSync, readFileSync } from 'node:fs';
import TurndownService from 'turndown';
import { SITE, SITE_NAME } from './seo';
import { ymd } from './format';

export const fullTextPath = (slug: string) => `/record/${slug}.md`;

type Meta = { title: string; slug: string; day: string; series: string; seriesName: string; published: string; description: string; lang: 'ja' | 'en' };

const absUrl = (u: string) => (/^https?:|^mailto:|^#/.test(u) ? u : u.startsWith('/') ? SITE + u : u);

// 記事の Markdown の印（【画像】など。src/lib/remark-markers.mjs）を、普通の Markdown に直す
function markersToMarkdown(md: string, title: string): string {
  const media = (f: string) => `${SITE}/media/${encodeURIComponent(f)}`;
  return md.split('\n').map((line) => {
    const t = line.trim();
    let m = t.match(/^【画像】\s*(\S+)(?:\s+(.+))?$/);
    if (m) return `![${(m[2] || title).trim()}](${media(m[1])})`;
    m = t.match(/^【動画】\s*(\S+\.(?:mp4|webm|mov))(?:\s+(.+))?$/);
    if (m) return `[動画: ${(m[2] || m[1]).trim()}](${media(m[1])})`;
    m = t.match(/^【図解】\s*(\S+\.html)$/);
    if (m) return `[図解: ${m[1]}](${SITE}/embeds/${encodeURIComponent(m[1])})`;
    m = t.match(/^【バナー】\s*(\S+)$/);
    if (m) return `[${m[1] === 'gpu-buy-or-rent' ? 'ロボットAIの学習用GPU、買うか借りるか' : m[1] === 'serving-robot-one-less-staff' ? '配膳ロボットの導入前シミュレーション' : m[1]}](${SITE}/${m[1]})`;
    // 分けたファイルの印は、全文には本来残らない。残っていたらファイルへのリンクにする
    m = t.match(/^【ファイル】\s*(LOG\d+\/\S+)\s+(\S+)\s+(\S+)$/);
    if (m) return `[ファイル: ${m[1]}（${m[2]}・${m[3]}）](${SITE}/files/${m[1]})`;
    m = t.match(/^【ファイル一式】\s*(LOG\d+)$/);
    if (m) return `[ファイル一式: ${SITE}/files/${m[1]}/index.json](${SITE}/files/${m[1]}/index.json)`;
    return line;
  }).join('\n')
    // このサイトの中の住所（](/record/… ）を、外でも開ける住所に
    .replace(/\]\((\/[^)\s]*)\)/g, (_, p) => `](${SITE}${p})`);
}

const stripFrontmatter = (md: string) => md.replace(/^---\n[\s\S]*?\n---\n+/, '');

let td: TurndownService | null = null;
function htmlToMarkdown(html: string): string {
  if (!td) {
    td = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced', bulletListMarker: '-', emDelimiter: '*' });
    td.remove(['script', 'style', 'noscript']);
    // 図解・YouTube の枠は、中身のページへのリンクにする
    td.addRule('iframe', {
      filter: 'iframe',
      replacement: (_c, node: any) => {
        const src = node.getAttribute('src') || '';
        if (!src) return '';
        const yt = /youtube(?:-nocookie)?\.com\/embed\/([\w-]+)/.exec(src);
        return yt ? `\n\n[動画（YouTube）](https://www.youtube.com/watch?v=${yt[1]})\n\n` : `\n\n[図解](${absUrl(src)})\n\n`;
      },
    });
    td.addRule('video', {
      filter: 'video',
      replacement: (_c, node: any) => {
        const src = (node.getAttribute('src') || node.querySelector?.('source')?.getAttribute('src') || '').replace(/#.*$/, '');
        return src ? `\n\n[動画](${absUrl(src)})\n\n` : '';
      },
    });
    // コードの言語（class="language-python"）を残す
    td.addRule('pre', {
      filter: (node: any) => node.nodeName === 'PRE',
      replacement: (_c, node: any) => {
        const code = node.querySelector?.('code');
        const lang = /language-([\w+-]+)/.exec((code || node).getAttribute?.('class') || '')?.[1] || '';
        const text = (code || node).textContent || '';
        const fence = text.includes('```') ? '````' : '```';
        return `\n\n${fence}${lang}\n${text.replace(/\n$/, '')}\n${fence}\n\n`;
      },
    });
  }
  return td.turndown(html.replace(/<!--TOC-->/g, ''))
    .replace(/\]\((\/[^)\s]*)\)/g, (_, p) => `](${SITE}${p})`)
    .replace(/\n{3,}/g, '\n\n');
}

function header(m: Meta): string {
  const url = `${SITE}/record/${m.slug}`;
  const en = m.lang === 'en';
  return [
    `# ${m.title}`,
    '',
    en
      ? `- Record: ${m.day} (${m.seriesName})\n- Published: ${ymd(m.published)}\n- Page: ${url}\n- Site: ${SITE_NAME} (ADAPT DESIGN / Eiichi Shimazu) ${SITE}/`
      : `- 記録: ${m.day}（${m.seriesName}）\n- 公開: ${ymd(m.published)}\n- 記事のページ: ${url}\n- サイト: ${SITE_NAME}（ADAPT DESIGN / 島津 栄一） ${SITE}/`,
    m.description ? (en ? `- Summary: ${m.description}` : `- 要約: ${m.description}`) : '',
    '',
    en
      ? '> This is the full text of the article for AI and text readers, including code and logs. Please cite the article page above.'
      : '> AI が読むための記事の全文です（コード・実行ログを含む）。引用するときは上の「記事のページ」の住所を使ってください。',
    '',
    '---',
    '',
  ].filter((l, i, a) => !(l === '' && a[i - 1] === '')).join('\n') + '\n';
}

// 分けた記事の全文ファイル（public/files/LOG{N}/index.json の article_full）
function splitFull(day: string): { body: string; type: 'md' | 'html' } | null {
  const idx = `public/files/${day}/index.json`;
  if (!existsSync(idx)) return null;
  try {
    const j = JSON.parse(readFileSync(idx, 'utf-8'));
    const type = j.article_full_type === 'text/html' ? 'html' : 'md';
    const p = type === 'html' ? `public/files/${day}/${day}_full.html` : `public/files/${day}/${day}_full.md`;
    return existsSync(p) ? { body: readFileSync(p, 'utf-8'), type } : null;
  } catch { return null; }
}

export function markdownRecord(m: Meta, src: { kind: 'md'; body: string } | { kind: 'studio'; html: string }): string {
  const split = splitFull(m.day);
  let body: string;
  if (split?.type === 'md') body = markersToMarkdown(stripFrontmatter(split.body), m.title);
  else if (split?.type === 'html') body = htmlToMarkdown(split.body);
  else if (src.kind === 'md') body = markersToMarkdown(src.body, m.title);
  else body = htmlToMarkdown(src.html);
  return header(m) + body.trim() + '\n';
}
`````

### src/pages/record/[slug].md.ts（全文ページの住所を作る）

```ts
// 記事の全文ページ（/record/{slug}.md。2026-11-01）。中身の作り方は src/lib/fulltext.ts
import { getCollection } from 'astro:content';
import { SERIES_PAGES } from '../../lib/records';
import { mediaHtml } from '../../lib/media';
import { markdownRecord } from '../../lib/fulltext';

const seriesNameOf = (s: string) => {
  const key = s === 'Consideration' ? '考察記事' : s;
  return Object.values(SERIES_PAGES).find((c) => c.series === key)?.title.split(' | ').pop() || s;
};

export async function getStaticPaths() {
  const md = await getCollection('records');
  const seen = new Set(md.map((e) => e.data.slug));
  const studio = (Object.values(import.meta.glob('../../data/records/*.json', { eager: true, import: 'default' })) as any[])
    .filter((r) => !seen.has(r.slug));
  return [
    ...studio.map((r) => ({ params: { slug: r.slug }, props: { kind: 'studio', rec: r } })),
    ...md.map((e) => ({ params: { slug: e.data.slug }, props: { kind: 'md', entry: e } })),
  ];
}

export async function GET({ props }: { props: any }) {
  let text: string;
  if (props.kind === 'studio') {
    const r = props.rec;
    text = markdownRecord(
      { title: r.title, slug: r.slug, day: r.day, series: r.series, seriesName: seriesNameOf(r.series), published: r.published,
        description: r.description || '', lang: r.lang === 'EN' || /-en$/.test(r.slug) ? 'en' : 'ja' },
      { kind: 'studio', html: mediaHtml(r.body) },
    );
  } else {
    const d = props.entry.data;
    text = markdownRecord(
      { title: d.title, slug: d.slug, day: `LOG${d.day}`, series: d.series, seriesName: seriesNameOf(d.series), published: d.date.toISOString(),
        description: d.meta_description || '', lang: /-en$/.test(d.slug) ? 'en' : 'ja' },
      { kind: 'md', body: props.entry.body || '' },
    );
  }
  return new Response(text, { headers: { 'Content-Type': 'text/markdown; charset=utf-8' } });
}
```

### src/pages/record/[slug].astro（記事ページ。「AIで読む」の帯・質問文・前回の並び替え）

```astro
---
import Base from '../../layouts/Base.astro';
import { ymd } from '../../lib/format';
import { mediaHtml } from '../../lib/media';
import { fixBody, textOf, abs, AUTHOR, PUBLISHER, SITE, SITE_NAME } from '../../lib/seo';
import { SERIES_PAGES, allCards, withDeepDiveThumb } from '../../lib/records';
import RecordCard from '../../components/RecordCard.astro';
import { getCollection, render } from 'astro:content';
import { existsSync, readFileSync } from 'node:fs';
import aiConsult from '../../data/aiConsult.json';
import { linkifyHtml, emphasizeHtml, linkLogsHtml } from '../../lib/links.mjs';
import { roomyTables } from '../../lib/tables.mjs';
import { fullTextPath } from '../../lib/fulltext';
import emphasis from '../../data/emphasis.json';

// 記事ページは2種類から作る
// - Studio から移した記事：CMS の本文 HTML（src/data/records/。tools/import_all.py で作る）
// - これから Claude が書く記事：Markdown（src/content/records/）
export async function getStaticPaths() {
  const studio = Object.values(import.meta.glob('../../data/records/*.json', { eager: true, import: 'default' })) as any[];
  const md = await getCollection('records');
  return [
    ...studio.map((r) => ({ params: { slug: r.slug }, props: { kind: 'studio', rec: r } })),
    ...md.map((e) => ({ params: { slug: e.data.slug }, props: { kind: 'md', entry: e } })),
  ];
}
const { kind, entry, rec } = Astro.props;
const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;');
const tocOf = (items) => items.length ? `<nav class="toc"><ul>${items.map((t) => `<li><a href="#${t.id}">${esc(t.text)}</a></li>`).join('')}</ul></nav>` : '';

let r, body = '', Content = null, toc = '';
if (kind === 'studio') {
  r = rec;
  body = fixBody(mediaHtml(rec.body.replace('<!--TOC-->', tocOf(rec.toc))), rec.title);
} else {
  const d = entry.data;
  r = { title: d.title, description: d.meta_description || '', slug: d.slug, day: `LOG${d.day}`, series: d.series,
        published: d.date.toISOString(), cover: d.cover ?? null, thumb: d.cover ? null : `/thumbs/LOG${d.day}.html`, tags: d.tags || [] };
  const rendered = await render(entry);
  Content = rendered.Content;
  toc = tocOf(rendered.headings.filter((h) => h.depth === 2).map((h) => ({ id: h.slug, text: h.text })));
  // 目次は最初の見出しの直前に置く（冒頭の書き出しを先に読んでもらうため。article_rules.md「冒頭の書き出し」）
  const html = entry.rendered?.html;
  if (html) {
    const at = toc ? html.search(/<h2[\s>]/) : -1;
    body = !toc ? html : at >= 0 ? html.slice(0, at) + toc + html.slice(at) : toc + html;
  }
}
// 本文の URL・サイトの住所をリンクにする（中の住所は同じタブ、外のサイトは新しいタブ。src/lib/links.mjs）
// 記事のいちばん大事な一文を太字にする（src/data/emphasis.json）
// 本文の記事番号（LOG334 など）を、その記事へのリンクにする（同じ記事へは最初の1か所だけ・自分へはつながない）。
// 古い呼び方の DAY200 は LOG200 に直す。LOG343（DAY から LOG への呼び方の変更を書いた記事）は DAY を例として書いているので直さない
const LOGS = Object.fromEntries((await allCards()).map((c) => [c.day, c.slug]));
const isEnglish = /-en$/.test(r.slug);
body = linkifyHtml(linkLogsHtml(emphasizeHtml(body, (emphasis as Record<string, string[]>)[r.slug] || []), LOGS, r.slug, isEnglish, r.slug !== 'studio-shutdown-cloudflare-redirect', r.slug === 'day39'));
// 表を窮屈にしない（本文より広い表は横スクロール・短いセルは折り返さない・長いセルは最小の幅を持たせる。src/lib/tables.mjs）
body = roomyTables(body);

// 検索エンジン向けの情報
// 考察記事は、問いを文字で組んだサムネイルを記事の上と SNS の画像にも使う（Gemini の絵より優先）
r = withDeepDiveThumb(r);
// SNS・検索結果に出す画像：表紙の画像 → サムネイルを書き出した画像（public/og/。tools/render_og.py）→ サイト共通の画像
const ogFromThumb = r.thumb ? r.thumb.replace(/^\/thumbs\//, '/og/').replace(/\.html$/, '.jpg') : null;
const ogImage = r.cover || (ogFromThumb && existsSync(`public${ogFromThumb}`) ? ogFromThumb : undefined);
const isEn = rec?.lang === 'EN' || /-en$/.test(r.slug);
const description = r.description || textOf(kind === 'studio' ? rec.body : entry.body, 120);
const allSlugs = new Set([
  ...Object.values(import.meta.glob('../../data/records/*.json', { eager: true, import: 'default' })).map((x: any) => x.slug),
  ...(await getCollection('records')).map((e) => e.data.slug),
]);
const pair = isEn ? r.slug.replace(/-en$/, '') : `${r.slug}-en`;
const alternates = allSlugs.has(pair) ? [{ lang: isEn ? 'ja' : 'en', path: `/record/${pair}` }] : [];
// 英語の考察記事（Consideration）は、考察記事のシリーズページ（series5）につなぐ
const seriesKey = r.series === 'Consideration' ? '考察記事' : r.series;
const seriesPage = Object.entries(SERIES_PAGES).find(([, c]) => c.series === seriesKey);
const seriesName = seriesPage ? seriesPage[1].title.split(' | ').pop() : r.series;

// 同じシリーズの記事（一覧と同じく LOG 番号の大きい順）。前後の記事と、この記事の近くの6本
const sameSeries = (await allCards()).filter((c) => c.series === r.series);
const pos = sameSeries.findIndex((c) => c.slug === r.slug);
const newer = pos > 0 ? sameSeries[pos - 1] : null;
const older = pos >= 0 && pos < sameSeries.length - 1 ? sameSeries[pos + 1] : null;
const others = sameSeries.filter((c) => c.slug !== r.slug);
const start = Math.max(0, Math.min((pos < 0 ? 0 : pos) - 3, others.length - 6));
const nearby = others.slice(start, start + 6);
// AI活用の記事（研究を回すために作った仕組みの記録）の末尾に、相談の案内を出す。
// Markdown の記事は先頭の項目 ai_consult: true、Studio から移した記事は src/data/aiConsult.json の slug で決める
const showConsult = kind === 'md' ? entry.data.ai_consult === true : (aiConsult as string[]).includes(r.slug);
const url = `${SITE}/record/${r.slug}`;
// 研究の記録の末尾から、相談のページ（/consult）へ（MASTER v2.1・2026-10-28）。AI活用の相談の案内が出る記事には出さない
const WORK_NOTE = {
  'フィジカルAI研究室': 'この記録の学習環境を、自社でも立ち上げたい方へ。借りて測るところから、一緒に手を動かして進めます。',
  '導入シミュレーション': '自店や提案先の店で、ロボットを入れる前に確かめたい方へ。店の形と人数から、一緒に手を動かして試算します。',
};
const workNote = !showConsult && !isEnglish ? WORK_NOTE[r.series] : null;
// 長いコード・ログ・データを外に出した記事（LOG364 から。tools/split_files.py）は、
// 全文（Markdown。Studio から移した記事は HTML）を head に、ファイルの一覧を構造化データ（hasPart）に出して、AI がたどれるようにする
const filesIndexPath = `public/files/${r.day}/index.json`;
const filesIndex = existsSync(filesIndexPath) ? JSON.parse(readFileSync(filesIndexPath, 'utf-8')) : null;
// 記事の素材（分けたファイル）への入口（2026-10-28）。本文の「ファイル一式」の枠に id="files" を付け、タイトルの下から飛べるようにする
if (filesIndex) body = body.replace('<div class="filebox fb-all">', '<div class="filebox fb-all" id="files">');
const filesCount = filesIndex && body.includes('id="files"') ? filesIndex.files.length : 0;
// 古い記録から今の話への道（2026-10-28）。検索から古い記録に着いた人が、そこで止まらないように。
// 公開から60日より前の記録に出す。同じシリーズに60日以内の記録があればその最新へ（「{シリーズ名}の最新」）、なければサイト全体の最新の記録へ（「いま進めている記録」）
const DAY_MS = 864e5, OLD_DAYS = 60;
const fresh = (c) => Date.now() - new Date(c.published).getTime() < OLD_DAYS * DAY_MS;
const isOld = !isEnglish && Date.now() - new Date(r.published).getTime() >= OLD_DAYS * DAY_MS;
const seriesNow = isOld ? (await allCards()).find((c) => c.series === r.series && c.slug !== r.slug && !/-en$/.test(c.slug) && fresh(c)) : null;
const siteNow = isOld && !seriesNow ? (await allCards()).find((c) => !/-en$/.test(c.slug) && c.slug !== r.slug) : null;
const nowCard = seriesNow || siteNow;
// AIで読む（2026-11-01）：読み手が使っている AI を開き、この記事の全文（/record/{slug}.md。src/lib/fulltext.ts）を読ませる質問を入れた状態にする。
// ChatGPT・Claude・Perplexity は住所の後ろの q= で入力欄に入る。Gemini は入れる方法がないので、質問文をコピーしてから開く。
// 前回選んだ AI を見た人のブラウザに覚え（p2m-site-ai）、次から一番上に出す。押した数は /api/ev-stats（場所 ai）で見る
const mdUrl = `${SITE}${fullTextPath(r.slug)}`;
// 全文ページの住所に ?via=（どの AI から来たか）を付ける。AI が読みに来たとき、ボタン経由だと worker/index.js が分けて数える（/api/cr-stats の「AIで読む経由」）
const aiPromptFor = (via) => {
  const md = `${mdUrl}?via=${via}`;
  return isEnglish
    ? `Please read this article and answer my questions.\nArticle: ${url}\nFull text (including code and logs): ${md}\n\nFirst, briefly summarize what was done and what was found. Then tell me what I should check to reproduce it in my own environment.`
    : `この記事を読んで、質問に答えてください。\n記事: ${url}\n全文（コード・実行ログを含む）: ${md}\n\nまず、何をして何が分かったのかを短くまとめてください。そのあと、私が同じことを自分の環境で試すときに、確かめることを教えてください。`;
};
const aiQ = (via) => encodeURIComponent(aiPromptFor(via));
const AI_TARGETS = [
  { id: 'chatgpt', name: 'ChatGPT', href: `https://chatgpt.com/?q=${aiQ('chatgpt')}` },
  { id: 'claude', name: 'Claude', href: `https://claude.ai/new?q=${aiQ('claude')}` },
  { id: 'perplexity', name: 'Perplexity', href: `https://www.perplexity.ai/search?q=${aiQ('perplexity')}` },
  { id: 'gemini', name: 'Gemini', href: 'https://gemini.google.com/app', copy: aiPromptFor('gemini') },
];
const aiCopyPrompt = aiPromptFor('copy');
const ym = (iso) => { const d = new Date(new Date(iso).getTime() + 9 * 3600e3); return `${d.getUTCFullYear()}年${d.getUTCMonth() + 1}月`; };
const codeLang = { csharp: 'C#', json: 'JSON', yaml: 'YAML', markdown: 'Markdown', python: 'Python', text: 'Text' };
const jsonLd = [
  {
    '@context': 'https://schema.org', '@type': 'BlogPosting',
    headline: r.title, description, url, mainEntityOfPage: url,
    inLanguage: isEn ? 'en' : 'ja',
    datePublished: r.published, dateModified: rec?.updated || r.published,
    image: ogImage ? [abs(ogImage)] : undefined,
    articleSection: r.series, keywords: r.tags?.length ? r.tags.join(', ') : undefined,
    author: AUTHOR, publisher: PUBLISHER,
    isPartOf: { '@type': 'Blog', name: SITE_NAME, url: SITE + '/' },
    hasPart: filesIndex ? [
      { '@type': 'DigitalDocument', name: `${r.day} 記事の全文（${filesIndex.article_full_type === 'text/html' ? 'HTML' : 'Markdown'}）`, encodingFormat: filesIndex.article_full_type || 'text/markdown', url: abs(filesIndex.article_full || filesIndex.article_full_markdown) },
      ...filesIndex.files.map((f) => f.lang === 'csharp' || f.lang === 'yaml' || /\.(cs|prefab|asset|mat)\.txt$/.test(f.file)
        ? { '@type': 'SoftwareSourceCode', name: f.subheading || f.heading || f.file, programmingLanguage: codeLang[f.lang] || f.lang, url: abs(f.url) }
        : { '@type': 'Dataset', name: [f.heading, f.subheading].filter(Boolean).join(' / ') || f.file, url: abs(f.url) }),
    ] : undefined,
  },
  {
    '@context': 'https://schema.org', '@type': 'BreadcrumbList',
    itemListElement: [
      { '@type': 'ListItem', position: 1, name: SITE_NAME, item: SITE + '/' },
      ...(seriesPage ? [{ '@type': 'ListItem', position: 2, name: seriesPage[1].title, item: `${SITE}/${seriesPage[0]}` }] : []),
      { '@type': 'ListItem', position: seriesPage ? 3 : 2, name: r.title, item: url },
    ],
  },
];
---
<Base title={`${r.title} | ${SITE_NAME}`} description={description} ogImage={ogImage} path={`/record/${r.slug}`}
  lang={isEn ? 'en' : 'ja'} ogType="article" published={r.published} modified={rec?.updated || r.published}
  alternates={alternates} jsonLd={jsonLd}>
  <link slot="head" rel="alternate" type="text/markdown" title={isEnglish ? 'Full text (including code and logs)' : '記事の全文（コードとログを含む）'} href={fullTextPath(r.slug)} />
  <article class="rec">
    <nav class="crumbs" aria-label="現在地">
      <a href="/">{SITE_NAME}</a><span aria-hidden="true">›</span>{seriesPage ? <a href={`/${seriesPage[0]}`}>{seriesName}</a> : <span>{seriesName}</span>}
    </nav>
    {r.thumb ? <div class="hero"><iframe src={r.thumb} sandbox="allow-scripts" title={r.title} scrolling="no" tabindex="-1"></iframe></div> : r.cover && <img class="hero-img" src={r.cover} alt={r.title} width="1920" height="1080" />}
    <div class="meta"><span class="day">{r.day}</span>{seriesPage ? <a class="tag" href={`/${seriesPage[0]}`}>{r.series}</a> : <span class="tag">{r.series}</span>}<span class="date">{ymd(r.published)}</span></div>
    <h1 class="title">{r.title}</h1>
    {nowCard && (
      <a class="now-bar" href={`/record/${nowCard.slug}`} data-ev="今の続き" data-ev-area="now">
        <span class="nb-k">{ym(r.published)}の記録です。{seriesNow ? `${seriesName}の最新` : 'いま進めている記録'}</span>
        <span class="nb-t">{nowCard.day}　{nowCard.title}</span><i class="ms" aria-hidden="true">arrow_forward</i>
      </a>
    )}
    {filesCount > 0 && (
      <a class="files-jump" href="#files" data-ev="素材" data-ev-area="files">この記事の素材（コード・ログ・データ {filesCount}ファイル・まとめてダウンロードあり）<i class="ms" aria-hidden="true">arrow_downward</i></a>
    )}
    <details class="ai-read" data-ev-area="ai">
      <summary data-ev="AIで読む:開く">
        <span class="ar-t">{isEnglish ? 'Read with AI' : 'AIで読む'}</span>
        <span class="ar-s">{isEnglish ? 'Ask ChatGPT, Claude and others about this article' : 'ChatGPT・Claude などで、この記事に質問できます'}</span>
        <i class="ms" aria-hidden="true">expand_more</i>
      </summary>
      <ul class="ar-list">
        {AI_TARGETS.map((a) => (
          <li data-ai={a.id}>
            <a href={a.href} target="_blank" rel="noopener" data-ev="AIで読む" data-copy={a.copy || undefined}>
              {isEnglish ? `Open in ${a.name}` : `${a.name}で開く`}{a.copy && <span class="ar-n">{isEnglish ? '(copies the question; paste it)' : '（質問文をコピーして開きます。貼り付けてください）'}</span>}<i class="ms" aria-hidden="true">open_in_new</i>
            </a>
          </li>
        ))}
        <li data-ai="copy">
          <button type="button" data-ev="AIで読む:コピー" data-copy={aiCopyPrompt}>{isEnglish ? 'Copy the question (for other AI)' : '質問文をコピー（ほかの AI で使う）'}<i class="ms" aria-hidden="true">content_copy</i></button>
        </li>
      </ul>
      <p class="ar-msg" role="status" aria-live="polite"></p>
      <textarea class="ar-prompt" hidden readonly></textarea>
    </details>
    {kind === 'studio'
      ? <div class="richText" set:html={body} />
      : body ? <div class="richText" set:html={body} /> : <div class="richText"><Fragment set:html={toc} /><Content /></div>}
    {showConsult && (
      <aside class="consult" aria-label="ご相談について">
        <p>この記事の仕組みは、フィジカルAIの研究をひとりで回すために作ったものです。同じような仕組みを仕事に取り入れたい方は、メッセージからご相談ください。</p>
        <a class="btn-dark" href="/message?topic=ai">メッセージで相談する<i class="ms">arrow_forward</i></a>
      </aside>
    )}
    {workNote && (
      <aside class="consult" aria-label="仕事でのご相談について" data-ev-area="consult">
        <p>{workNote}</p>
        <a class="btn-dark" href="/consult" data-ev="記録の末尾:相談について">一緒に進め方を見る<i class="ms">arrow_forward</i></a>
      </aside>
    )}
    {(newer || older) && (
      <nav class="pager" aria-label="同じシリーズの前後の記事">
        {older ? <a class="pg pg-prev" href={`/record/${older.slug}`}><span class="pg-k">← 前の記事　{older.day}</span><span class="pg-t">{older.title}</span></a> : <span />}
        {newer ? <a class="pg pg-next" href={`/record/${newer.slug}`}><span class="pg-k">次の記事　{newer.day} →</span><span class="pg-t">{newer.title}</span></a> : <span />}
      </nav>
    )}
  </article>
  {nearby.length > 0 && (
    <section class="related">
      <div class="panel">
        <h2 class="sec-h">{seriesName}の記録 | Series</h2>
        <div class="grid3">{nearby.map((c) => <RecordCard item={c} />)}</div>
        {seriesPage && <div class="rel-more"><a class="btn-dark" href={`/${seriesPage[0]}`}>{seriesName}の記録をすべて見る（{sameSeries.length}本）<i class="ms">arrow_forward</i></a></div>}
      </div>
    </section>
  )}
  <script is:inline>
    // AIで読む：前回選んだ AI を一番上に出す・Gemini とコピーは質問文をコピーする（覚えられない環境でも動く）
    (() => {
      const box = document.querySelector('.ai-read');
      if (!box) return;
      const KEY = 'p2m-site-ai';
      const list = box.querySelector('.ar-list'), msg = box.querySelector('.ar-msg');
      const en = document.documentElement.lang === 'en';
      try {
        const last = localStorage.getItem(KEY);
        const li = last && list.querySelector(`li[data-ai="${last}"]`);
        if (li && li !== list.firstElementChild) list.prepend(li);
        if (li) li.classList.add('ar-last');
      } catch { /* 覚えられない環境ではいつもの並び */ }
      const copy = async (prompt) => {
        try { await navigator.clipboard.writeText(prompt); return true; } catch { return false; }
      };
      box.addEventListener('click', async (e) => {
        const li = e.target.closest && e.target.closest('li[data-ai]');
        if (!li) return;
        try { localStorage.setItem(KEY, li.dataset.ai); } catch { /* 覚えられなくても開く */ }
        const a = e.target.closest('[data-copy]');
        if (!a) return;
        const ok = await copy(a.dataset.copy);
        msg.textContent = ok
          ? (en ? 'Copied the question. Paste it into the AI.' : '質問文をコピーしました。AI の入力欄に貼り付けてください。')
          : (en ? 'Could not copy. Please copy the question below.' : 'コピーできませんでした。下の質問文を選んでコピーしてください。');
        if (!ok) { const t = box.querySelector('.ar-prompt'); t.value = a.dataset.copy; t.hidden = false; t.select(); }
      });
    })();
  </script>
  {filesIndex && (
    <script is:inline>
      // 長いコード・ログの枠：押したときだけ全文を読み込んで、先頭の数行の所に出す（LOG364 から）
      document.addEventListener('click', async (e) => {
        const b = e.target.closest && e.target.closest('.filebox .fb-open');
        if (!b) return;
        const box = b.closest('.filebox'), code = box.querySelector('.fb-pre code');
        if (box.dataset.full === '1') { box.classList.toggle('fb-closed'); b.textContent = box.classList.contains('fb-closed') ? '全文をここに表示' : 'たたむ'; return; }
        b.disabled = true; b.textContent = '読み込み中…';
        try {
          const res = await fetch(box.dataset.src);
          code.textContent = await res.text();
          box.dataset.full = '1'; box.classList.add('fb-full'); b.textContent = 'たたむ';
        } catch (err) { b.textContent = '読み込めませんでした（別のタブで開いてください）'; }
        b.disabled = false;
      });
    </script>
  )}
</Base>

<style is:global>
  .rec { width: 620px; max-width: calc(100% - 48px); margin: 0 auto; padding: 104px 0 64px; }
  .rec .hero { aspect-ratio: 16 / 9; position: relative; border-radius: 4px; overflow: hidden; background: #0d0d0d; }
  .rec .hero iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; pointer-events: none; }
  .rec .hero-img { width: 100%; height: auto; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 4px; display: block; }
  .rec .crumbs { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; margin: 0 0 16px; font: 400 13px/20px var(--sans); color: var(--ink-3); }
  .rec .crumbs a { color: var(--ink-2); text-decoration: underline; text-underline-offset: 2px; }
  .rec .crumbs a:hover { color: var(--ink); }
  .rec a.tag:hover { background: #e4e4e4; color: var(--ink-2); }
  .pager { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 64px 0 0; padding-top: 32px; border-top: 1px solid #ddd; }
  .pg { display: flex; flex-direction: column; gap: 8px; padding: 16px; border-radius: 8px; background: #fff; box-shadow: 0 8px 16px rgba(0,0,0,.08); }
  .pg:hover .pg-t { text-decoration: underline; }
  .pg-next .pg-k { text-align: right; }
  .pg-k { font: 400 12px/18px var(--sans); color: var(--ink-3); }
  .pg-t { font: 600 14px/1.6 var(--sans); color: var(--ink); overflow-wrap: anywhere; }
  .rel-more { display: flex; justify-content: center; margin-top: 40px; }
  .rel-more .btn-dark { height: auto; min-height: 40px; padding: 8px 20px; white-space: normal; text-align: center; }
  @media (max-width: 599px) { .pager { grid-template-columns: 1fr; } .pg-next .pg-k { text-align: left; } }
  .consult { margin: 56px 0 0; padding: 24px; border-radius: 8px; background: #f0f0f0; }
  .consult p { margin: 0 0 16px; font: 400 16px/28px var(--sans); color: var(--ink); }
  .consult .btn-dark { height: auto; min-height: 40px; padding: 8px 20px; white-space: normal; }
  .rec .meta { display: flex; align-items: center; gap: 12px; margin-top: 16px; }
  .rec .day { font: 400 28px/28px var(--sans); }
  .rec .title { margin: 12px 0 40px; font: 600 28px/42px var(--sans); overflow-wrap: anywhere; }
  .rec .title:has(+ .now-bar), .rec .title:has(+ .files-jump) { margin-bottom: 24px; }
  .now-bar { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 2px 12px; margin: 0 0 24px; padding: 12px 16px; border-radius: 4px; background: var(--ink); color: #fff; text-decoration: none; }
  .now-bar .nb-k { grid-column: 1; font: 400 13px/20px var(--sans); color: rgba(255,255,255,.72); }
  .now-bar .nb-t { grid-column: 1; font: 600 15px/24px var(--sans); overflow-wrap: anywhere; }
  .now-bar .ms { grid-column: 2; grid-row: 1 / span 2; font-size: 20px; transition: transform .2s ease; }
  .now-bar:hover .ms { transform: translateX(3px); }
  .files-jump { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 0 0 24px; padding: 12px 16px; border-radius: 4px; background: #f0f0f0; color: var(--ink); font: 600 14px/22px var(--sans); text-decoration: none; }
  .files-jump .ms { font-size: 20px; flex: none; }
  .files-jump:hover { background: #e6e6e6; }
  #files { scroll-margin-top: 88px; }
  /* AIで読む（2026-11-01）。形は「この記事の素材」と同じ灰色の帯。押すと AI の一覧が開く */
  .rec .title:has(+ .ai-read) { margin-bottom: 24px; }
  .ai-read { margin: 0 0 32px; border-radius: 4px; background: #f0f0f0; color: var(--ink); }
  .ai-read summary { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 0 12px; padding: 12px 16px; cursor: pointer; list-style: none; border-radius: 4px; }
  .ai-read summary::-webkit-details-marker { display: none; }
  .ai-read summary:hover { background: #e6e6e6; }
  .ai-read .ar-t { grid-column: 1; font: 600 14px/22px var(--sans); }
  .ai-read .ar-s { grid-column: 1; font: 400 13px/20px var(--sans); color: var(--ink-2); }
  .ai-read summary .ms { grid-column: 2; grid-row: 1 / span 2; font-size: 20px; transition: transform .2s ease; }
  .ai-read[open] summary .ms { transform: rotate(180deg); }
  .ai-read .ar-list { list-style: none; margin: 0; padding: 4px 8px 8px; display: grid; gap: 4px; }
  .ai-read .ar-list a, .ai-read .ar-list button { display: flex; align-items: center; gap: 8px; width: 100%; padding: 10px 12px; border: 0; border-radius: 4px; background: #fff; color: var(--ink); font: 600 14px/22px var(--sans); text-align: left; text-decoration: none; cursor: pointer; }
  .ai-read .ar-list a:hover, .ai-read .ar-list button:hover { background: var(--ink); color: #fff; }
  .ai-read .ar-list .ms { font-size: 18px; flex: none; margin-left: auto; }
  .ai-read .ar-n { font: 400 12px/18px var(--sans); color: inherit; opacity: .7; }
  .ai-read .ar-last a::before { content: "前回"; flex: none; padding: 0 6px; border: 1px solid currentColor; border-radius: 4px; font: 400 11px/18px var(--sans); }
  html[lang="en"] .ai-read .ar-last a::before { content: "Last used"; }
  .ai-read .ar-msg { margin: 0; padding: 0 16px; font: 400 13px/20px var(--sans); color: var(--ink-2); }
  .ai-read .ar-msg:not(:empty) { padding-bottom: 12px; }
  .ai-read .ar-prompt { display: block; width: calc(100% - 32px); margin: 0 16px 12px; min-height: 120px; font: 400 13px/20px var(--sans); }
  .ai-read .ar-prompt[hidden] { display: none; }

  .richText { color: var(--ink); font-family: var(--sans); }
  .richText p { font-size: 18px; line-height: 36px; margin: 8px 0 12px; overflow-wrap: anywhere; }
  .richText h2 { font-size: 24px; line-height: 36px; font-weight: 600; margin: 40px 0 16px; }
  .richText h3 { font-size: 20px; line-height: 30px; font-weight: 600; margin: 36px 0 16px; padding-left: 12px; border-left: 2px solid var(--ink); }
  .richText h4 { font-size: 18px; line-height: 30px; font-weight: 600; margin: 24px 0 8px; }
  .richText h5 { font-size: 14px; line-height: 1.8; font-weight: 400; color: #555; margin: 48px 0 0; }
  .richText hr { border: 0; border-top: 1px solid #ddd; margin: 10px 0; }
  .richText strong { font-weight: 600; }
  .richText a { color: inherit; text-decoration: underline; }
  .richText pre { background: #eee; border-radius: 4px; padding: 25px 35px; margin: 4px 0; overflow-x: auto;
    font: 400 16px/24px var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; }
  .richText pre code { font: inherit; }
  .richText :not(pre) > code { background: #eee; padding: 0 5px; margin: 2px; border: 1px solid rgba(0,0,0,.1); border-radius: 6px; font: 400 18px/36px var(--sans); overflow-wrap: anywhere; }
  .richText blockquote { margin: 10px 0; padding: 10px 15px; border-left: 3px solid rgba(0,0,0,.15); color: #333; }
  .richText blockquote p { font: 400 16px/28.8px 'Noto Sans JP', sans-serif; margin: 10px 0; }
  .richText figure { margin: 16px 0; }
  .richText figure img { width: 100%; height: auto; }
  .richText figcaption:empty { display: none; }
  .richText ul, .richText ol { padding-left: 1.5em; }
  .richText li { font-size: 18px; line-height: 36px; margin: 10px 0; }
  /* 表は本文の幅に押し込まず、はみ出す分は横にスクロールする（縦に1文字ずつ折り返して読めなくなっていた。LOG355） */
  .richText .table { margin: 10px 0; overflow-x: auto; -webkit-overflow-scrolling: touch; }
  .richText table { border-collapse: collapse; width: auto; min-width: 100%; font-size: 14px; }
  /* 表は見出しの行を少し濃く、線をはっきり、本文の行は白に（ページの地色と見分けられるように） */
  .richText th, .richText td { border: 1px solid #d2d2d2; vertical-align: top; text-align: left; }
  .richText th { background: #e9e9e9; padding: 24px; font: 600 16px/24px var(--sans); }
  .richText td { background: #fff; padding: 16px; font: 400 18px/27px var(--sans); }
  .richText th p, .richText td p { margin: 0; font: inherit; }
  .richText .embed { margin: 16px 0; }
  .richText .embed iframe { display: block; width: 100%; height: 150px; border: 0; }
  .richText .video { margin: 16px 0; }
  .richText .video video { display: block; width: 100%; height: auto; }
  .richText .yt { aspect-ratio: 16 / 9; margin: 16px 0; }
  /* 長いコード・ログの枠（LOG364 から。tools/split_files.py） */
  /* 記事の中のバナー（【バナー】gpu-buy-or-rent。src/lib/remark-markers.mjs）。色と背景の写真はトップのカード（GuideCards）と同じ、大きさは記事用に小さく */
  .richText a.art-bn { position: relative; isolation: isolate; overflow: hidden; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 12px 20px;
    margin: 20px 0 28px; padding: 18px 20px; border-radius: 8px; box-shadow: 0 8px 16px rgba(0,0,0,.08); background: #0b0c0e; color: #fff; text-decoration: none; overflow-wrap: normal; word-break: normal; }
  .richText a.art-bn::before { content: ""; position: absolute; inset: 0; z-index: -2; background: url(/site/gbr/reverse.webp) right center / cover no-repeat; transition: transform .6s ease; }
  .richText a.art-bn::after { content: ""; position: absolute; inset: 0; z-index: -1; background: linear-gradient(90deg, rgba(11,12,14,.95) 0%, rgba(11,12,14,.86) 55%, rgba(11,12,14,.55) 100%); }
  .richText a.art-bn:hover::before { transform: scale(1.03); }
  .richText .art-bn .ab-figs { display: grid; gap: 2px; font-family: 'Manrope', 'Inter', 'Noto Sans JP', sans-serif; font-variant-numeric: tabular-nums; line-height: 1.1; text-align: right; }
  .richText .art-bn .ab-buy { font-size: 13px; font-weight: 800; color: #8d939d; text-decoration: line-through; text-decoration-thickness: 2px; }
  .richText .art-bn .ab-rent { font-size: 22px; font-weight: 800; color: #f2600c; letter-spacing: -.01em; }
  .richText .art-bn .ab-text { display: grid; gap: 2px; min-width: 0; word-break: auto-phrase; }
  .richText .art-bn .ab-label { font: 700 11px/16px var(--sans); color: #b9bec7; letter-spacing: .08em; }
  .richText .art-bn .ab-title { font: 900 15px/1.5 var(--sans); color: #fff; }
  .richText .art-bn .ab-sub { font: 400 12px/1.6 var(--sans); color: #c8ccd4; }
  .richText .art-bn .ab-go { display: inline-grid; place-items: center; width: 36px; height: 36px; border-radius: 4px; background: #f2600c; color: #fff; font-size: 18px; transition: background .15s, color .15s; }
  .richText a.art-bn:hover .ab-go { background: #fff; color: #121418; }
  /* 配膳ロボットの導入前シミュレーション：トップのカードと同じ明るい面と店の立体の絵（左に小さく） */
  .richText a.art-bn-light { background: #eceef1; color: #121418; box-shadow: 0 8px 16px rgba(0,0,0,.08); }
  .richText a.art-bn-light::before, .richText a.art-bn-light::after { content: none; }
  .richText .art-bn-light .ab-img { display: block; width: 112px; overflow: hidden; }
  .richText .art-bn-light .ab-img img { display: block; width: 100%; height: auto; transition: transform .6s ease; }
  .richText a.art-bn-light:hover .ab-img img { transform: scale(1.04); }
  .richText .art-bn-light .ab-label { color: #5c616b; }
  .richText .art-bn-light .ab-title { color: #121418; }
  .richText .art-bn-light .ab-sub { color: #4a4f58; }
  .richText a.art-bn-light:hover .ab-go { background: #121418; color: #fff; }
  @media (max-width: 479px) {
    .richText a.art-bn { grid-template-columns: minmax(0, 1fr) auto; }
    .richText a.art-bn::after { background: rgba(11,12,14,.84); }
    .richText .art-bn .ab-figs { grid-column: 1 / -1; grid-auto-flow: column; justify-content: start; align-items: baseline; gap: 10px; text-align: left; }
    .richText .art-bn-light .ab-img { grid-column: 1 / -1; width: 140px; }
  }
  .richText .filebox { margin: 8px 0 16px; border: 1px solid #d2d2d2; border-radius: 4px; background: #fff; }
  .richText .fb-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; padding: 12px 16px; border-bottom: 1px solid #e4e4e4; }
  .richText .fb-name { font: 600 14px/20px var(--mono); overflow-wrap: anywhere; text-decoration: none; border-bottom: 1px solid currentColor; }
  .richText .fb-meta { font: 400 13px/20px var(--sans); color: #666; }
  .richText .filebox .fb-pre { margin: 0; border-radius: 0; padding: 12px 16px; font-size: 13px; line-height: 20px; max-height: 180px; overflow: hidden; position: relative; }
  .richText .filebox.fb-full .fb-pre { max-height: 70vh; overflow: auto; }
  .richText .filebox.fb-full.fb-closed .fb-pre { max-height: 180px; overflow: hidden; }
  .richText .fb-act { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center; padding: 10px 16px; border-top: 1px solid #e4e4e4; font: 400 14px/20px var(--sans); }
  .richText .fb-open { font: 600 14px/20px var(--sans); padding: 6px 12px; border: 1px solid var(--ink); border-radius: 4px; background: var(--ink); color: #fff; cursor: pointer; }
  .richText .fb-open:disabled { opacity: .6; cursor: default; }
  .richText .fb-all { padding: 8px 16px; background: #f6f6f6; }
  .richText .fb-all p { font-size: 16px; line-height: 28px; margin: 8px 0; }
  .richText .fb-all li { font-size: 16px; line-height: 28px; margin: 4px 0; }
  .richText .fb-all .fb-lh { font: 600 16px/28px var(--sans); margin: 16px 0 4px; }
  .richText .fb-list .fb-li { font-size: 14px; line-height: 22px; margin: 2px 0; overflow-wrap: anywhere; }
  .richText .yt iframe { width: 100%; height: 100%; border: 0; }

  .toc { background: #f0f0f0; border-radius: 4px; padding: 20px 20px 8px 8px; margin: 0 0 24px; }
  .toc ul { list-style: none; margin: 0; padding: 0; }
  .toc li { position: relative; padding-left: 32px; margin: 0 0 16px; font: 400 16px/32px var(--sans); color: var(--ink-2); }
  .toc a { text-decoration: none; color: inherit; }

  /* 本文より広いもの（表・画像）が画面の幅を押し広げないようにする */
  .richText { max-width: 100%; min-width: 0; }
  .richText a, .richText code, .richText li, .richText td, .richText th, .richText blockquote, .richText h2, .richText h3 { overflow-wrap: anywhere; word-break: break-word; }
  /* 表の中は、英数字の語（ファイル名・数値）を途中で折らない。見出しの行は日本語も途中で折らない */
  .richText td, .richText td code, .richText td a, .richText th, .richText th code { overflow-wrap: normal; word-break: normal; }
  .richText .nw, .richText .nw * { white-space: nowrap; }
  .richText .cw { min-width: 9em; }
  .richText pre code { white-space: pre-wrap; }
  .richText img { max-width: 100%; height: auto; }
  @media (max-width: 767px) {
    .rec { max-width: calc(100% - 48px); padding-top: 72px; }
    .rec .title { font-size: 24px; line-height: 36px; }
  }
</style>
```

### 1つ目の公開の差分（worker・llms.txt・package.json）

```diff
commit 227e893c08509aef41a3e5dc64c9b8ee30dcefa5
Author: PixelstoMotors <studio@adaptdesign.jp>
Date:   Sun Nov 1 09:57:54 2026 +0900

    記事の全文ページ（/record/{slug}.md）と「AIで読む」ボタンを足す
    
    - すべての記事に、AI が丸ごと読める Markdown の全文ページを作る（分けたコード・ログも含む）
    - 全文ページは記事ページを canonical にして返す。head の alternate と llms.txt を全記事でこのページに
    - 記事のタイトルの下に「AIで読む」：ChatGPT・Claude・Perplexity を質問文入りで開く、Gemini とほかの AI はコピー
    - 押した数は /api/ev-stats の場所 ai で見る
    
    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01LevEXUdawHCPzCgXi4FEM9

diff --git a/package.json b/package.json
index c695f91..d4ef6f8 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,7 @@
     "@astrojs/sitemap": "3.7.4",
     "astro": "5.13.5",
     "mdast-util-to-string": "4.0.0",
+    "turndown": "7.2.0",
     "unist-util-visit": "5.1.0"
   }
 }
diff --git a/src/pages/llms.txt.ts b/src/pages/llms.txt.ts
index f9ce6b3..c4ab6f9 100644
--- a/src/pages/llms.txt.ts
+++ b/src/pages/llms.txt.ts
@@ -1,19 +1,10 @@
 // /llms.txt：AI がこのサイトを読むときの案内（2026-10-25）。https://llmstxt.org の形に合わせる。
 // 主要な AI が公式に使うとは言っていないので効果ははっきりしないが、作るのは軽いので置く。
 // ビルドのたびに、シリーズ（src/lib/records.ts の SERIES_PAGES）と記録の一覧から作り直す。
-// 長いコード・ログを外に分けた記事は、全文の Markdown（public/files/LOG{N}/index.json の article_full_markdown）も添える
-import fs from 'node:fs';
-import path from 'node:path';
+// すべての記録に、全文の Markdown（/record/{slug}.md。src/lib/fulltext.ts。2026-11-01）を添える
 import { allCards, SERIES_PAGES } from '../lib/records';
 import { SITE } from '../lib/seo';
-
-const fullMarkdown = (day: string): string | null => {
-  try {
-    const j = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'public/files', day, 'index.json'), 'utf8'));
-    const u = j.article_full_markdown || (j.article_full_type === 'text/markdown' ? j.article_full : null);
-    return u ? (u.startsWith('http') ? u : SITE + u) : null;
-  } catch { return null; }
-};
+import { fullTextPath } from '../lib/fulltext';
 
 export async function GET() {
   const cards = await allCards();
@@ -23,7 +14,7 @@ export async function GET() {
     '',
     '> ADAPT DESIGN（島津 栄一 / Eiichi Shimazu）による、フィジカルAI・ロボットの研究記録のサイト。机の上のロボットアームでお手本のデータを集め、AI に学習させ、実機で動かして確かめる環境を自宅で組み、誰でも同じ手順で始められる形で公開している。サービスロボット（配膳・搬送・清掃など）の導入前シミュレーションも行っている。',
     '',
-    '- 記録（LOG）は作業の過程をそのまま残したもので、コード・実行ログ・数値を全量で載せている。長いコードとログは別ファイルに分け、記事の全文の Markdown を添えている',
+    '- 記録（LOG）は作業の過程をそのまま残したもので、コード・実行ログ・数値を全量で載せている。長いコードとログは別ファイルに分けている。どの記録にも、コード・ログを含む全文の Markdown（記事の住所の末尾に .md）がある',
     '- 試算の前提の数字は、公開事例と観察から置いた仮の値。記録が新しいほど前提が更新されている（公開済みの記録は直さない）',
     '- 言語は日本語',
     '',
@@ -45,10 +36,7 @@ export async function GET() {
     '',
     '## 記録（新しい順）',
     '',
-    ...cards.map((c) => {
-      const md = fullMarkdown(c.day);
-      return `- [${c.day} ${c.title}](${SITE}/record/${c.slug}): ${seriesName(c.series)}${md ? `（全文の Markdown: ${md}）` : ''}`;
-    }),
+    ...cards.map((c) => `- [${c.day} ${c.title}](${SITE}/record/${c.slug}): ${seriesName(c.series)}（全文の Markdown: ${SITE}${fullTextPath(c.slug)}）`),
     '',
     '## Optional',
     '',
diff --git a/worker/index.js b/worker/index.js
index b4375c2..79d077a 100644
--- a/worker/index.js
+++ b/worker/index.js
@@ -38,7 +38,14 @@ export default {
       } catch { /* 数え損ねても転送はする */ }
       return secured(Response.redirect(to.toString(), 301));
     }
-    const res = await env.ASSETS.fetch(request);
+    let res = await env.ASSETS.fetch(request);
+    // 記事の全文ページ（/record/{slug}.md。2026-11-01）は、検索では記事ページを正とする（canonical）。文字コードも付ける
+    const full = res.ok && url.pathname.match(/^\/record\/([^/]+)\.md$/);
+    if (full) {
+      res = new Response(res.body, res);
+      res.headers.set('Content-Type', 'text/markdown; charset=utf-8');
+      res.headers.set('Link', `<https://pixelstomotors.com/record/${full[1]}>; rel="canonical"`);
+    }
     // 検索エンジン・AI のクローラーが読みに来たか（記録の失敗でページを止めない）
     try {
       const bot = crawlerOf(request, url);
@@ -332,7 +339,7 @@ h1{font-size:20px}h2{font-size:16px;margin-top:28px}table{border-collapse:collap
 // 1回のページ表示 = 1件。中身は持たず、付箋（customMetadata）に書く（一覧のときに中身を読まずに数えられる）。
 // 同じ表示が戻ってきて送り直したときは n が増える。数えるときは v（その表示だけの番号）ごとに n が最大の1件を使う。
 // IP アドレス・ブラウザの種類・Cookie は残さない。v はブラウザに保存されない、その表示だけの番号
-const EV_AREAS = new Set(['hakobu', 'header', 'footer', 'crumbs', 'toc', 'consult', 'pager', 'related', 'body', 'main', 'sim', 'now', 'files', 'other']);
+const EV_AREAS = new Set(['hakobu', 'header', 'footer', 'crumbs', 'toc', 'consult', 'pager', 'related', 'body', 'main', 'sim', 'now', 'files', 'ai', 'other']);
 const EV_DEPTHS = new Set([0, 25, 50, 75, 100]);
 const evInt = (x, max) => { const n = Math.floor(Number(x)); return Number.isFinite(n) && n >= 0 ? Math.min(n, max) : 0; };
 // 自動で巡回するプログラムの名乗り（検索エンジン・AI のクローラー・SNS のプレビュー・自動のブラウザ）。名乗りそのものは保存しない
```

### 2つ目の公開の差分（worker。ボタン経由の読み取りを数える）

```diff
commit 7f4920e070fc1d6cff050ce128363a5cf7d54bcf
Author: PixelstoMotors <studio@adaptdesign.jp>
Date:   Sun Nov 1 10:10:28 2026 +0900

    「AIで読む」経由で AI が全文ページを読みに来た数を、押した AI ごとに数える
    
    - 質問文の全文ページの住所に ?via=（chatgpt・claude・perplexity・gemini・copy）を付ける
    - worker がその訪問を、名乗りが一覧にない読み手（Gemini など）も「その他」として残す
    - /api/cr-stats に「AIで読む経由」の表（押した AI ごと・週ごと）
    
    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01LevEXUdawHCPzCgXi4FEM9

diff --git a/worker/index.js b/worker/index.js
index 79d077a..5d47d18 100644
--- a/worker/index.js
+++ b/worker/index.js
@@ -47,9 +47,11 @@ export default {
       res.headers.set('Link', `<https://pixelstomotors.com/record/${full[1]}>; rel="canonical"`);
     }
     // 検索エンジン・AI のクローラーが読みに来たか（記録の失敗でページを止めない）
+    // 記事の「AIで読む」から来た全文ページ（?via=chatgpt など）は、名乗りが一覧にない読み手（Gemini など）も「その他」として残す（2026-11-01）
     try {
-      const bot = crawlerOf(request, url);
-      if (bot) ctx.waitUntil(crRecord(request, env, url, bot, res.status).catch(() => {}));
+      const via = /^\/record\/[^/]+\.md$/.test(url.pathname) && AI_VIA.has(url.searchParams.get('via') || '') ? url.searchParams.get('via') : '';
+      const bot = crawlerOf(request, url) || (via ? 'その他' : null);
+      if (bot) ctx.waitUntil(crRecord(request, env, url, bot, res.status, via).catch(() => {}));
     } catch { /* 数え損ねてもページは返す */ }
     return res;
   },
@@ -300,6 +302,15 @@ async function hkStats(request, env, url) {
   const byDay = new Map(), byWeek = new Map(), byItem = new Map(), byW = new Map();
   for (const r of rows) { add(byDay, r.day, r.e); add(byWeek, weekOf(r.day), r.e); add(byItem, r.id, r.e); add(byW, r.w, r.e); }
   const KIND_JA = { n: '機械の印なし', b: '機械', o: '印なし（10/28より前）' };
+  // 「AIで読む」経由（?via=）。押した AI ごとに、実際に読みに来たのはだれか（2026-11-01）
+  const viaMap = new Map();
+  for (const r of rows) {
+    if (!r.v) continue;
+    const e = viaMap.get(r.v) || { via: r.v, visits: 0, records: new Set(), bots: new Map(), last: '' };
+    e.visits++; e.records.add(r.p); count(e.bots, r.b); if (r.day > e.last) e.last = r.day; viaMap.set(r.v, e);
+  }
+  const viaWeeks = new Map();
+  for (const r of rows) { if (!r.v) continue; const w = viaWeeks.get(r.week) || new Map(); count(w, r.v); viaWeeks.set(r.week, w); }
   const data = {
     total: rows.length,
     who: who || null,
@@ -484,6 +495,15 @@ async function evStats(request, env, url) {
     }
   }
   const top = (map, n, key) => [...map].sort((a, b) => b[1] - a[1]).slice(0, n).map(([k, v]) => ({ [key]: k, count: v }));
+  // 「AIで読む」経由（?via=）。押した AI ごとに、実際に読みに来たのはだれか（2026-11-01）
+  const viaMap = new Map();
+  for (const r of rows) {
+    if (!r.v) continue;
+    const e = viaMap.get(r.v) || { via: r.v, visits: 0, records: new Set(), bots: new Map(), last: '' };
+    e.visits++; e.records.add(r.p); count(e.bots, r.b); if (r.day > e.last) e.last = r.day; viaMap.set(r.v, e);
+  }
+  const viaWeeks = new Map();
+  for (const r of rows) { if (!r.v) continue; const w = viaWeeks.get(r.week) || new Map(); count(w, r.v); viaWeeks.set(r.week, w); }
   const data = {
     total: rows.length, counted: counted.length, truncated, since: since || null, week: week || null, who: who || null,
     byWeek,
@@ -538,6 +558,8 @@ const CRAWLERS = [
   ['cohere-ai', 'Cohere'], ['Diffbot', 'Diffbot'], ['YouBot', 'You.com'], ['YandexBot', 'Yandex（検索）'], ['Baiduspider', 'Baidu（検索）'], ['Yeti', 'Naver（検索）'],
 ];
 // 数えるのはページ・テキストだけ（画像・CSS・JS は wrangler.jsonc の run_worker_first で外している）
+// 「AIで読む」の質問文で、全文ページの住所に付けている印（src/pages/record/[slug].astro の AI_TARGETS）
+const AI_VIA = new Set(['chatgpt', 'claude', 'perplexity', 'gemini', 'copy']);
 const CR_PAGE = /(\/|\/[^/.]*|\.html|\.md|\.txt|\.xml|\.json)$/i;
 function crawlerOf(request, url) {
   if (request.method !== 'GET' && request.method !== 'HEAD') return null;
@@ -547,7 +569,7 @@ function crawlerOf(request, url) {
   for (const [name] of CRAWLERS) if (ua.includes(name.toLowerCase())) return name;
   return null;
 }
-async function crRecord(request, env, url, bot, status) {
+async function crRecord(request, env, url, bot, status, via = '') {
   let ok = '?';
   try {
     const cf = request.cf || {};
@@ -556,7 +578,7 @@ async function crRecord(request, env, url, bot, status) {
   } catch { /* 判定がなければ ? のまま */ }
   const at = new Date();
   const day = new Date(at.getTime() + 9 * 3600e3).toISOString().slice(0, 10);
-  const meta = { b: bot, p: url.pathname.slice(0, 160), s: String(status), ok };
+  const meta = { b: bot, p: url.pathname.slice(0, 160), s: String(status), ok, ...(via ? { v: via } : {}) };
   await env.MESSAGES.put(`cr/${day}/${at.getTime()}-${Math.random().toString(36).slice(2, 8)}`, '', { customMetadata: meta });
 }
 async function crStats(request, env, url) {
@@ -577,7 +599,7 @@ async function crStats(request, env, url) {
     cursor = r.truncated ? r.cursor : undefined;
     if (++pages >= 40 && cursor) { truncated = true; break; }
   } while (cursor);
-  const label = Object.fromEntries(CRAWLERS);
+  const label = { ...Object.fromEntries(CRAWLERS), その他: 'その他（「AIで読む」経由で、名乗りが一覧にない読み手）' };
   const count = (map, k) => map.set(k, (map.get(k) || 0) + 1);
   // クローラーごと（全期間）
   const bots = new Map();
@@ -604,6 +626,15 @@ async function crStats(request, env, url) {
   const scope = only ? rows.filter((r) => r.b === only) : rows;
   const paths = new Map(), pathBots = new Map();
   for (const r of scope) { count(paths, r.p); const s = pathBots.get(r.p) || new Set(); s.add(r.b); pathBots.set(r.p, s); }
+  // 「AIで読む」経由（?via=）。押した AI ごとに、実際に読みに来たのはだれか（2026-11-01）
+  const viaMap = new Map();
+  for (const r of rows) {
+    if (!r.v) continue;
+    const e = viaMap.get(r.v) || { via: r.v, visits: 0, records: new Set(), bots: new Map(), last: '' };
+    e.visits++; e.records.add(r.p); count(e.bots, r.b); if (r.day > e.last) e.last = r.day; viaMap.set(r.v, e);
+  }
+  const viaWeeks = new Map();
+  for (const r of rows) { if (!r.v) continue; const w = viaWeeks.get(r.week) || new Map(); count(w, r.v); viaWeeks.set(r.week, w); }
   const data = {
     total: rows.length, truncated, since: since || null, bot: only || null,
     byBot: [...bots.values()].sort((a, b) => b.visits - a.visits).map((b) => ({
@@ -611,6 +642,8 @@ async function crStats(request, env, url) {
     })),
     byWeek: [...weeks].sort((a, b) => b[0].localeCompare(a[0])).map(([week, m]) => ({ week, total: [...m.values()].reduce((x, y) => x + y, 0), bots: [...m].sort((a, b) => b[1] - a[1]).map(([b, n]) => `${b} ${n}`).join('、') })),
     byPage: [...paths].sort((a, b) => b[1] - a[1]).slice(0, 100).map(([path, n]) => ({ path, visits: n, bots: [...(pathBots.get(path) || [])].join('、') })),
+    byVia: [...viaMap.values()].sort((a, b) => b.visits - a.visits).map((e) => ({ via: e.via, visits: e.visits, records: e.records.size, bots: [...e.bots].sort((a, b) => b[1] - a[1]).map(([b, n]) => `${b} ${n}`).join('、'), last: e.last })),
+    byViaWeek: [...viaWeeks].sort((a, b) => b[0].localeCompare(a[0])).map(([week, m]) => ({ week, total: [...m.values()].reduce((x, y) => x + y, 0), via: [...m].sort((a, b) => b[1] - a[1]).map(([v, n]) => `${v} ${n}`).join('、') })),
     errors: [...errMap.values()].sort((a, b) => b.visits - a.visits).slice(0, 100).map((e) => ({ path: e.path, status: e.status, visits: e.visits, bots: [...e.bots].join('、'), last: e.last })),
   };
   if (url.searchParams.get('format') === 'json') {
@@ -626,6 +659,8 @@ h1{font-size:20px}h2{font-size:16px;margin-top:28px}.wrap{overflow-x:auto}table{
 <h1>クローラーの訪問の記録</h1><p>検索エンジン・AI のクローラーが、どのページを読みに来たか（2026-10-25 から）。名乗りで見分けているので、「Cloudflare が確かめた」が 0 のものは偽物が混ざりうる。日付は日本時間、週は月曜はじまり。?since=日付 で期間を、?bot=名前（例 GPTBot）でページごとの表をそのクローラーだけに絞る。${truncated ? ' 記録が多すぎて一部だけ数えた。' : ''}</p>
 <h2>クローラーごと</h2>${table([['bot', '名前'], ['who', 'だれの・何のため'], ['visits', '読んだ回数'], ['pages', 'ページの数'], ['records', '記録（LOG）の数'], ['verified', 'Cloudflare が確かめた'], ['unverified', '確かめられなかった'], ['errors', '見つからない等（400以上）'], ['last', '最後に来た日']], data.byBot)}
 <h2>週ごと</h2>${table([['week', '週（月曜）'], ['total', '合計'], ['bots', '内訳']], data.byWeek)}
+<h2>「AIで読む」経由（記事のボタンから、全文ページを読みに来た回数）</h2><p>押した AI（via）ごと。copy は「質問文をコピー」。「その他」は名乗りが一覧にない読み手（Gemini・人のブラウザなど）。押した回数は /api/ev-stats の場所 ai。</p>${table([['via', '押した AI'], ['visits', '読みに来た回数'], ['records', '記事の数'], ['bots', '読みに来たのはだれか'], ['last', '最後の日']], data.byVia)}
+${table([['week', '週（月曜）'], ['total', '合計'], ['via', '内訳']], data.byViaWeek)}
 <h2>よく読まれたページ${only ? `（${esc(only)} だけ）` : ''}</h2>${table([['path', 'ページ'], ['visits', '回数'], ['bots', '読みに来たクローラー']], data.byPage)}
 <h2>返せなかったページ（400以上）</h2>${table([['path', 'ページ'], ['status', '返した結果'], ['visits', '回数'], ['bots', 'クローラー'], ['last', '最後の日']], data.errors)}
 <p>記録の件数：${data.total}</p></body></html>`;
```

### 確かめに使ったスクリプト（worker の記録を作り物の訪問で確かめる）

```js
crypto.subtle.timingSafeEqual = () => true;
import w from '/tmp/claude-0/-home-claude/d61a015b-0dbb-5beb-969e-ce8ba4954c3b/scratchpad/w/index.js';
const puts = [], waits = [];
const env = { ASSETS: { fetch: async () => new Response('# t', { status: 200, headers: { 'content-type': 'text/markdown' } }) },
  MESSAGES: { put: async (k, v, o) => puts.push(o.customMetadata), list: async () => ({ objects: puts.map((m, i) => ({ key: `cr/2026-11-01/${i}`, customMetadata: m })), truncated: false }) },
  STATS_KEY: 'k' };
const ctx = { waitUntil: (p) => waits.push(p) };
const go = async (u, ua) => { const r = await w.fetch(new Request(u, { headers: { 'user-agent': ua } }), env, ctx); await Promise.all(waits); return r; };
let r = await go('https://pixelstomotors.com/record/x.md?via=gemini', 'Mozilla/5.0 Chrome');
console.log(r.headers.get('link'), r.headers.get('content-type'));
await go('https://pixelstomotors.com/record/x.md?via=chatgpt', 'Mozilla/5.0 ChatGPT-User/1.0');
await go('https://pixelstomotors.com/record/x.md', 'Mozilla/5.0 Chrome');
await go('https://pixelstomotors.com/record/x.md?via=evil', 'Mozilla/5.0 Chrome');
console.log(puts);
const s = await w.fetch(new Request('https://pixelstomotors.com/api/cr-stats?key=k&format=json'), env, ctx);
const j = await s.json(); console.log(j.byVia, j.byViaWeek);
```


## 次は、押された数と、ボタン経由で AI が読みに来た数を並べて見る

公開した時点では、まだ数がありません。押された数（`/api/ev-stats` の場所 ai）と、ボタン経由で AI が全文ページを読みに来た数（`/api/cr-stats` の「AIで読む経由」）を並べ、どの AI だと記事を読ませるところまで届いているかを見ます。あわせて、ボタンとは関係なく、AI が自分で全文ページを読みに来る数が増えるかも見ます。引用を増やすという目的に効いているかは、こちらの数で確かめます。
