2026-09-05 · seo · githubactions · typescript · 設計 · 個人開発
同じ記事を3か所に出すと検索評価が割れる。canonical をどこに寄せるか
技術記事を Zenn と dev.to と自分のサイトに出しています。同じ内容です。 これをそのままやると、Google から見て同じ記事が3つある状態になります。
3つのうちどれを検索結果に出すかは、Google が勝手に決めます。 決めさせると、たいてい望まない結果になります。
何が問題なのか
「重複コンテンツはペナルティを受ける」とよく言われますが、正確ではありません。 Google は重複を見つけるとどれか1つを選んで、残りを結果から落とすだけです。 罰ではなく、選別です。
問題は、選ばれる先をこちらが決めていない点にあります。
自分のサイトが選ばれた場合を考えます。ドメインの評価がゼロの新しいサイトです。 Zenn や dev.to のほうが、検索順位では確実に上に来ます。 弱いほうが正典として選ばれると、そもそも誰にも表示されません。
逆に強いほうが選ばれた場合も、放っておくと自分のサイトのページが 「重複」として扱われ、そこに集まるはずだった評価が宙に浮きます。
canonical で「これが原本です」と宣言する
<link rel="canonical"> は、そのページの原本がどこにあるかを示すタグです。
<link rel="canonical" href="https://zenn.dev/user/articles/slug">
自分のページに、別ドメインの URL を指す canonical を書けます。 意味は「このページは複製です。評価は向こうに集めてください」です。
弱いドメイン側から、強いドメイン側へ寄せるのが基本です。 自分のサイトのページに、Zenn や dev.to の URL を書きます。
一見もったいなく見えますが、逆です。3つに割れた評価が1つに集まるほうが、 検索結果に出る確率は上がります。出ないページを3つ持っていても意味がない。
実際にどう管理するか
問題は、canonical 先が「記事を公開した後」にしか決まらないことです。 Zenn の URL は公開してみないと確定しません。
そこで、記事の frontmatter には書かず、別のファイルに持たせました。
{
"ja": {
"multi-persona-workflow": "https://zenn.dev/user/articles/multi-persona-workflow"
},
"en": {
"multi-persona-workflow": "https://dev.to/user/some-slug-with-hash"
}
}
frontmatter に書かなかったのには理由があります。その frontmatter は Zenn と dev.to にもそのまま渡るからです。 知らないキーで壊れる可能性がある場所に、 自分の都合のキーを足したくありませんでした。
サイト生成側は、この表を見て canonical を出し分けます。
# 他所が正典の記事は、自分を原本だと主張しない
self.canonical_override = CANONICAL.get(lang, {}).get(self.slug)
@property
def canonical(self) -> str:
return self.canonical_override or BASE_URL + self.url_path
sitemap からも外す
canonical を向けるだけでは足りませんでした。
sitemap.xml は「このページを登録してほしい」という申告です。 canonical で「原本は別にある」と言いながら sitemap で登録を求めるのは、 矛盾したことを言っています。
# 正典が他所にある記事は sitemap に載せない。
# 自分で「原本ではない」と言っているものを登録させても、評価が割れるだけ
urls += [(BASE_URL + p.url_path, p.date) for p in all_posts
if p.canonical_override is None]
まだどこにも公開していない記事は、自分のサイトが原本です。 その状態では sitemap に載ります。他所に出した時点で外れます。 公開の進み方に合わせて、自動でそうなる形にしました。
先に自分のサイトに出す理由
順番の話を1つ。
プラットフォーム側の準備が整うのを待っていると、記事は書けているのに どこにも出ていない期間ができます。検索のインデックスは数週間かかるので、 待つほど後ろにずれます。
なので、書けた時点でまず自分のサイトに出します。この時点では自分が原本です。 プラットフォーム側に出せるようになったら、canonical をそちらへ向け直す。 これはデータの1行を書き換えるだけで済むようにしてあります。
改名したら古いページを消す
もう1つ、生成器に穴がありました。
記事の slug を変えたとき、新しいページは生成されますが、 古いページがそのまま残ります。 消える理由がないからです。
結果、内容の同じページが2つ、両方とも生きた URL として残ります。 自分で重複を作っていることになります。
# 生成対象に無い .html は消す
for lang, d in (("ja", SITE / "articles"), ("en", SITE / "en" / "articles")):
keep = {f"{q.slug}.html" for q in posts if q.lang == lang} | {"index.html"}
for f in d.glob("*.html"):
if f.name not in keep:
f.unlink()
出力ディレクトリを毎回作り直す作りなら起きませんが、 差分で書き足す作りだと必ず踏みます。
まとめ
- 重複はペナルティではなく選別。どれが選ばれるかを自分で決めていないことが問題
- canonical は弱いドメインから強いドメインへ寄せる。 出ないページを3つ持つより、出るページを1つ持つほうがよい
- canonical 先は公開後に決まるので、frontmatter ではなく別ファイルで持つ。 frontmatter は他のプラットフォームにもそのまま渡るので汚さない
- canonical を向けたページは sitemap からも外す。言っていることを一致させる
- まず自分のサイトに出す。インデックスは数週間かかるので、待つほど損をする
- slug を変えたら古い生成物を消す。差分で書き足す生成器は必ずここを踏む