diff --git a/.github/workflows/pages-deploy.yml b/.github/workflows/pages-deploy.yml
new file mode 100644
index 0000000..eaee50f
--- /dev/null
+++ b/.github/workflows/pages-deploy.yml
@@ -0,0 +1,73 @@
+name: Deploy GitHub Pages
+
+# main への push で Pages を自動デプロイする。
+#
+# 配信物は 2 系統:
+# - 編集用: 章フォルダ・shared/・sitemap.xml 等(リポをそのまま)
+# - 完全版: `python build.py` で生成した dist/<章名>.html
+#
+# 「Deploy from a branch」から「GitHub Actions」へ切り替える必要がある
+# (Settings → Pages → Source)。切替後は本ワークフローが唯一のデプロイ経路。
+#
+# PR では build-only ジョブだけ走らせ、build.py の破損を merge 前に検出する。
+# 詳細: Issue #33
+
+on:
+ push:
+ branches: ["main"]
+ pull_request:
+ branches: ["main"]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+# 同時デプロイは 1 件まで。in-progress を中断しないことで、進行中の deploy を守る。
+concurrency:
+ group: pages
+ cancel-in-progress: false
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Build standalone HTML
+ run: python3 build.py
+
+ - name: Stage site
+ # _site/ に編集用(リポ全体)+ 完全版(dist/)の両方を詰める。
+ # .git/.github/dist/_site は site 配信から除外。
+ run: |
+ mkdir -p _site
+ rsync -a --delete \
+ --exclude '.git' \
+ --exclude '.github' \
+ --exclude 'dist' \
+ --exclude '_site' \
+ ./ _site/
+ mkdir -p _site/dist
+ cp dist/*.html _site/dist/
+
+ - name: Upload Pages artifact
+ # PR では artifact をアップロードしない(deploy しないため)。
+ if: github.event_name != 'pull_request'
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: _site
+
+ deploy:
+ # PR では deploy しない。main への push / workflow_dispatch のみ。
+ if: github.event_name != 'pull_request'
+ needs: build
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/README.md b/README.md
index 51008ad..98f41fb 100644
--- a/README.md
+++ b/README.md
@@ -63,15 +63,36 @@ cd lectures
---
-## 完全版(1 ファイル)をビルドする
+## 完全版(1 ファイル)を入手する
-各章を 1 つの HTML にまとめた「完全版」を生成できます。`shared/` の CSS・JS と画像をすべて埋め込むので、**リポジトリもネット接続もなしで開けます**。メール添付・USB 配布・オフライン閲覧に。
+各章を 1 つの HTML にまとめた「完全版」が用意されています。`shared/` の CSS・JS と画像をすべて埋め込むので、**リポジトリもネット接続もなしで開けます**。メール添付・USB 配布・オフライン閲覧に。
+
+### 方法 1: ダウンロードする(推奨)
+
+CI が自動更新する常に最新版を Pages から取得できます。
+
+```
+https://co-lect.github.io/lectures/dist/00-about.html
+https://co-lect.github.io/lectures/dist/01-claude-code-intro.html
+https://co-lect.github.io/lectures/dist/02-setup.html
+https://co-lect.github.io/lectures/dist/03-claude-md.html
+```
+
+ブラウザで開いて右クリック →「名前を付けて保存」で `.html` 1 ファイルが手元に残ります。
+
+### 方法 2: 自分でビルドする
```bash
python build.py
```
-`dist/00-about.html` などが生成されます(`dist/` は git 管理外のビルド成果物)。正本は各章の `index.html`(`shared/` 参照版)のままなので、`shared/` や本文を変更したら `python build.py` で作り直します。依存は Python 3 標準ライブラリのみ。
+`dist/00-about.html` などが生成されます(`dist/` は git 管理外のビルド成果物)。依存は Python 3 標準ライブラリのみ。
+
+### 注意
+
+- `dist/` は **ビルドの度に上書きされる**。直接編集しないこと
+- 配布前は **必ず `python build.py` を実行**するか、Pages の最新版を取得する
+- `dist/` は git 管理外(`.gitignore` 設定済み)。`git add -f` で強制追加しないこと
> Web フォント(Google Fonts)だけは埋め込みません。完全オフラインではシステムフォントにフォールバックします(レイアウトは崩れません)。
diff --git a/build.py b/build.py
index fe2d1f8..ebf0a2e 100644
--- a/build.py
+++ b/build.py
@@ -8,6 +8,12 @@
正本は各章の index.html(shared/ 参照版)のまま。dist/ はビルド成果物で
git 管理外。内容や shared/ を変更したら `python build.py` で再生成する。
+SEO / 重複コンテンツ対策:
+ 完全版 HTML には `` と
+ `` を自動挿入する。
+ Pages 上で 2 系統の URL(章フォルダ / dist/)が同一コンテンツを返すため、
+ 検索エンジンには編集用 URL を正本として扱わせる。
+
既知の制約:
theme.css は Google Fonts を @import している。完全オフラインでは
Web フォントが読めず、システムフォントにフォールバックする
@@ -27,6 +33,9 @@
ROOT = Path(__file__).resolve().parent
DIST = ROOT / "dist"
+# Pages 上の編集用 URL のベース。完全版 HTML の canonical に使う。
+CANONICAL_BASE = "https://co-lect.github.io/lectures"
+
# 外部 / data URI は埋め込み対象外(そのまま残す)
_SKIP_PREFIXES = ("http://", "https://", "data:", "//")
@@ -97,6 +106,23 @@ def img_repl(m: re.Match) -> str:
return html
+def inject_seo_meta(html: str, chapter_name: str) -> str:
+ """完全版 HTML に canonical と robots=noindex を 直前に注入する。
+
+ 編集用 URL(章フォルダ)が正本であることを検索エンジンに伝え、
+ 完全版 URL(dist/<章名>.html)の重複 indexing を避ける。
+ """
+ canonical = f"{CANONICAL_BASE}/{chapter_name}/"
+ tags = (
+ f'\n'
+ f'\n'
+ )
+ if "" in html:
+ return html.replace("", tags + "", 1)
+ # が無いケースは想定外。head 解析より、検出して失敗させる方が安全
+ raise RuntimeError(f"{chapter_name}: が見つからず canonical を挿入できません")
+
+
def main() -> int:
# Windows の既定コンソール(cp932)でも日本語を出せるようにする
try:
@@ -116,6 +142,7 @@ def main() -> int:
for ch in chapters:
html = (ch / "index.html").read_text(encoding="utf-8-sig")
built = inline(html, ch)
+ built = inject_seo_meta(built, ch.name)
# 未解決のローカル参照(../ や _assets/)が残っていないか検証
leftover = sorted(set(re.findall(