目次13

AdSense の審査に落ちた。表示されるのは「有用性の低いコンテンツ」という区分だけで、どのページが引っかかったのかは書いていない。自分の運用で心当たりを探すしかなく、Zenn / Qiita / dev.to に同じ記事を出していることに行き着いた。外部の投稿済み5本を一時的に消して canonical を整える、という手順書まで書いた。

実行する前に、本当に本文が重複しているのかを数えた。11記事・22ファイルで、散文の一致は393行中3行。うち2行は水平線の --- だった。手順書は捨てて、消さずに canonical を付ける側に切り替えた。

一致率は行単位で測ってから判断する

クロス投稿を重複と疑ったら、消す前に本文の一致を数える。書き下ろしで運用しているなら、一致するのはコードと表と見出しで、散文はほとんど残らない。この内訳を見ないまま「一致率48%」だけを見ると、実態と逆の結論が出る。

対策も、消すのではなく canonical を置ける場所に置くほうに寄せる。dev.to / Hashnode / Medium は API で原本 URL を渡せる。Qiita / Zenn / note には渡す場所が無いので、この3つについては「原本を主張する」という選択肢が最初から存在しない。

前提 — ハブ&スポークで書き下ろしている

Aulvem は自サイトをハブ、外部プラットフォームをスポークとして運用している。同じ題材を扱うが、翻訳でもコピーでもなく、読者像と粒度を変えて書き下ろす。

場所役割
ハブaulvem.com設計背景・運用知見・関連記事への結節点
スポークZenn / QiitaJA 本文(body.ja.md)を共有
スポークdev.to / HashnodeEN 本文(body.en.md)を共有

測定対象は 2026-05-17 から 2026-08-08 までの11記事、EN / JA を別ファイルとして数えて22ペア。外部側の本文は frontmatter を持たない素の Markdown で、本体側は MDX なので、比較の前にどちらも frontmatter を落とす。

一致を数えるスクリプトは30行で書ける

行を正規化して集合に入れ、外部側の各行が本体側に含まれるかを見るだけでいい。判断に効くのは、その一致を種別に分けるところ。

import fs from "node:fs";

const norm = (s) => s.replace(/\s+/g, " ").trim();

function bodyOf(file) {
  let t = fs.readFileSync(file, "utf8");
  if (t.startsWith("---")) t = t.slice(t.indexOf("\n---", 3) + 4); // frontmatter を落とす
  return t;
}

// 行を [正規化テキスト, 種別] に分解する。コードフェンスの内側は全て code 扱い
function classify(text) {
  const out = [];
  let inFence = false;
  for (const raw of text.split("\n")) {
    const line = norm(raw);
    if (/^```/.test(line)) { inFence = !inFence; out.push([line, "code"]); continue; }
    if (!line) continue;
    if (inFence) { out.push([line, "code"]); continue; }
    if (/^#{1,6}\s/.test(line)) { out.push([line, "heading"]); continue; }
    if (/^\|/.test(line)) { out.push([line, "table"]); continue; }
    out.push([line, "prose"]);
  }
  return out;
}

const hp = new Set(classify(bodyOf(hpFile)).map(([l]) => l));
const ext = classify(bodyOf(extFile));
const matched = ext.filter(([l]) => hp.has(l));

フェンスの開閉をトグルで追うのは、コードブロックの中に # で始まるコメント行や | で始まる行が普通に出てくるから。フェンスを見ずに行頭の記号だけで分類すると、コードがまるごと散文に化ける。

空行を落とすのも先に決めておく。空行は必ず一致するので、残したまま比率を出すと分母も分子も水増しされる。

47.9% の一致は、762行のコードだった

22ペアを通した結果が次の数字になる。

指標
外部記事の総行数1,658
本体と一致した行795(47.9%)
うちコードブロック内762
うち表の行13
うち見出し17
うち散文3
外部記事の散文行393
散文の一致率0.8%

一致した散文3行の中身を出すと、2行は水平線の ---、残る1行は Zod スキーマの記事の英語箇条書きが1行だった。実質的に、散文は1行も重なっていない。

記事単位で見ても構造は同じで、一致率が高い記事ほどコードの比率が高い。

記事一致率コード散文
sitemap-lastmod (ja)65.4%700
idempotent-notification-outbox (ja)62.5%390
sheets-xlsx-excel-silent-breakage (ja)67.8%530
aulvem-blog-architecture (ja)4.4%30

コードが一致するのは、同じ実装を説明しているからで、ここを変えたら片方が嘘になる。CREATE UNIQUE INDEX uq_outbox_dedup を外部向けに書き換える理由は無い。逆に言えば、コードの共有だけを根拠に「重複サイト」と自己診断するのは、測っていないのと変わらない。

canonical を置ける場所と、置けない場所

原本を主張する手段は、プラットフォームごとに揃っていない。API の実装を見るのがいちばん早い。

flowchart LR
  Devto["dev.to<br/>canonical_url"] --> HP["aulvem.com<br/>(原本)"]
  Hashnode["Hashnode<br/>originalArticleURL"] --> HP
  Medium["Medium<br/>canonicalUrl"] --> HP
  Qiita["Qiita<br/>指定不可"] -.-> HP
  Zenn["Zenn<br/>指定不可"] -.-> HP
  Note["note<br/>指定不可"] -.-> HP
プラットフォームcanonical指定する場所
dev.to記事作成 API の article.canonical_url
HashnodepublishPost mutation の originalArticleURL
Medium投稿 API の canonicalUrl、または Import from URL
Qiita不可items API のパラメータに該当項目が無い
Zenn不可記事 frontmatter に該当項目が無い
note不可エディタに設定箇所が無い

Qiita の API v2 ドキュメントで items の POST に渡せるのは title / body / tags / private / tweet / organization_url_name などで、外部 URL を原本として宣言するフィールドは無い。Zenn は記事の frontmatter が title / emoji / type / topics / published / publication_name で、同じく無い。

dev.to は Forem API の記事オブジェクトに canonical_url があるので、投稿ペイロードに1行足すだけで済む。

body: JSON.stringify({
  article: {
    title: fm.title,
    body_markdown: body,
    published: fm.published ?? false,
    tags,
    canonical_url: fm.canonical_url,   // ← ここ
  },
}),

Hashnode は少し引っかかる場所がある。originalArticleURLpublishPost の入力にはあるが、createDraft の入力には無い。下書きとして作ってから管理画面で公開する運用にしていると、canonical が渡らないまま公開されることになる。

const input = publish
  ? { title, contentMarkdown, publicationId, tags, originalArticleURL: fm.canonical_url }
  : { title, contentMarkdown, publicationId, tags };   // draft 側には入れられない

自分の運用では、外部ドラフトの dev.yaml 11本すべてに canonical_url: https://aulvem.com/blog/<slug>/ を入れた。ただし dev.to は投稿時のペイロードで値が確定するので、すでに投稿済みの5本には反映されない。既存記事は PUT /articles/{id} で更新するか、エディタ側で設定し直す必要がある。ここは今も残っている宿題。

「一時的に消す」は工程として成立しない

canonical を設定できないプラットフォームでは、消したあとに元へ戻す方法が「もう一度投稿する」しかない。記事 URL は変わり、それまでの PV も被リンクも切れる。可逆に見える操作が、実際には削除になる。

数字を見ると、判断はもっとはっきりする。

チャネル実績
Qiita 11記事の累計 PV2,240
dev.to 累計 PV309
Zenn 累計表示182
aulvem.com の検索クリック(28日)7(表示 1,335)

外部を消すのは、読者が来ている側を止めて、来ていない側を守る取引になる。AdSense の想定収益は月に数百円という規模で、この取引は割に合わない。8月8日に公開した記事は Qiita で8日間に196PV、平均滞在221秒だった。同じ記事に自サイトへ来た読者は、8日で10ページビュー。

混同していた2つのこと

診断を撤回した理由は2つある。

ひとつは、AdSense の審査と検索の重複ハンドリングを同じものとして扱っていたこと。AdSense はプログラム ポリシーに基づくサイト単位の審査で、検索側の重複 URL の統合は canonical や hreflang を見た正規化の処理。片方の結果からもう片方の原因を逆算しても、根拠にはならない。

もうひとつは、「Qiita の canonical が Qiita 自身を指している」ことをペナルティの証拠として読んだこと。canonical を指定できないプラットフォームは、自分自身を指す自己参照 canonical を出す。それがどのサイトでも既定の挙動である以上、そこから何かを推定することはできない。

この2つを混同していた間に、2021年のレビュー記事17本を一時 draft 化していた。これも巻き戻した。自分で書いた原稿を隠すのは、独自性を疑われている状況では逆向きの操作になる。

まとめ

クロス投稿を疑ったときにまずやることは、外部記事を消すことではなく、行単位で数えることだった。30行のスクリプトで済むし、結果が「散文は393行中3行」と出れば、そこから先の作業は全部不要になる。

canonical については、置ける場所には置く、置けない場所は諦める、で運用が回る。Qiita と Zenn と note に原本を主張する手段が無いのは自分ではどうにもならないが、少なくとも dev.to と Hashnode と Medium には渡せる。渡す先を間違えなければ、外部に出す判断そのものを止める理由にはならない。

測る前に手を動かしかけたことのほうが、今回の反省点として残っている。同じ「壊れていることに気づける形にしておく」という話は、Zod スキーマで運用ルール違反をビルドで落とす記事と、.xlsx が静かに壊れる記事にも書いた。

よくある質問

クロス投稿した記事が重複コンテンツになっているか、どう確かめる?

本文を行単位で正規化して集合に入れ、外部記事の各行が本体記事に含まれるかを数えます。そのときコードフェンスの内側をコードとして分けておくのが要点で、分けないと「一致率48%」のような数字だけが出て判断できません。Aulvem の11記事22ファイルでは一致795行のうち762行がコードブロック内で、散文の一致は393行中3行、しかも2行は水平線の --- でした。

外部プラットフォームで canonical はどこまで指定できる?

dev.to は API の article.canonical_url、Hashnode は publishPostoriginalArticleURL、Medium は投稿 API の canonicalUrl で指定できます。Qiita は items API のパラメータに該当する項目が無く、Zenn の記事 frontmatter にも note のエディタにも設定箇所がありません。指定できない側は、原本を主張する手段が最初から無いという前提で運用することになります。

重複を疑ったら外部記事を一時的に消せばいい?

canonical を設定できないプラットフォームでは、消したあとに戻す手段が「もう一度投稿する」しかなく、URL が変わって PV も被リンクも切れます。Aulvem の場合、外部の累計は Qiita 2,240PV / dev.to 309PV / Zenn 182表示で、自サイトの検索クリックは28日で7でした。読者がいる側を消して、いない側を守る取引になります。

AdSense の審査落ちと Google 検索の重複判定は同じもの?

別系統です。AdSense はプログラム ポリシーに基づくサイト単位の審査で、検索側の重複 URL の統合は canonical や hreflang を見た正規化の処理です。片方の結果からもう片方の原因を推定すると、今回のように誤った対策に工数を使います。