Quartet / Quintet

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()

出力ディレクトリを毎回作り直す作りなら起きませんが、 差分で書き足す作りだと必ず踏みます。

まとめ

この運用そのものを配っています

4 人格版 Quartet は MIT で無料公開しています。UI 設計人格・レビュー基準・ Issue 単位の並列実行スクリプト・実践ガイド 10 章を足した Quintet は有料版です。

無料版を見る 製品ページ