同じ記事を Zenn・Qiita・dev.to に出していると、そのうち「これ重複コンテンツ扱いされていないか」が気になります。自分は気になった勢いで「外部の投稿を一時的に消す」手順書まで書きました。
実行前に、本文がどれくらい一致しているのかを数えました。11記事・22ファイルで、散文の一致は393行中3行。うち2行は水平線の --- でした。手順書は不要になり、作業は canonical を付けられるプラットフォームに付けるだけになりました。
測り方と、canonical を指定できる/できないプラットフォームの一覧をまとめます。
一致率は行単位で数えて、種別で割る
やることは3つだけです。
- frontmatter を落とす
- 空白を正規化して空行を捨てる
- コードフェンスの内側かどうかで行に種別を付ける
3番目が結論を左右します。付けないと「一致率48%」という数字だけが出て、その48%が何なのか分かりません。
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;
}
// 行を [正規化テキスト, 種別] に分解する
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 origin = new Set(classify(bodyOf(originFile)).map(([l]) => l));
const ext = classify(bodyOf(externalFile));
const matched = ext.filter(([l]) => origin.has(l));
const count = (k) => matched.filter(([, t]) => t === k).length;
console.log({
ext: ext.length,
matched: matched.length,
code: count("code"),
table: count("table"),
heading: count("heading"),
prose: count("prose"),
});
inFence のトグルを省いて行頭の記号だけで分類すると、シェルの # コメントや YAML・SQL の | を含む行が散文に化けます。信じたい数字がそこなので、ここは省けません。
空行を落とすのも先に決めておきます。空行は必ず一致するため、残すと分母も分子も膨らみます。
結果: 一致795行のうち762行はコードだった
自分の11記事(EN / JA を別ファイルとして22ペア)に通した結果です。
| 指標 | 値 |
|---|---|
| 外部記事の総行数 | 1,658 |
| 原本と一致した行 | 795(47.9%) |
| うちコードフェンス内 | 762 |
| うち表 | 13 |
| うち見出し | 17 |
| うち散文 | 3 |
| 外部記事の散文行 | 393 |
| 散文の一致率 | 0.8% |
一致した散文3行を印字したら、2行は水平線の ---、1行は英語の箇条書きが1行でした。
一致率が高い記事ほどコードの割合が高い、という関係になります。手順やコードを丁寧に載せた記事ほど数字が跳ねるので、比率だけ見ると読み違えます。コードが一致するのは同じ実装を説明しているからで、外部向けに書き換えたらどちらかが嘘になります。
書き下ろしで運用しているなら、まずこの数字を出してから対応を決めるのがおすすめです。
canonical を指定できるプラットフォーム、できないプラットフォーム
原本を宣言する手段は揃っていません。API の受け口を見ると早いです。
| プラットフォーム | canonical | 指定場所 |
|---|---|---|
| dev.to | 可 | 記事作成 API の article.canonical_url
|
| Hashnode | 可 |
publishPost mutation の originalArticleURL
|
| Medium | 可 | 投稿 API の canonicalUrl、または Import from URL |
| Qiita | 不可 | items API に該当パラメータが無い |
| Zenn | 不可 | 記事 frontmatter に該当項目が無い |
| note | 不可 | エディタに設定箇所が無い |
dev.to はForem API の記事オブジェクトに canonical_url があるので、ペイロードに1行足すだけです。
body: JSON.stringify({
article: {
title,
body_markdown: body,
published: false,
tags,
canonical_url: "https://example.com/blog/<slug>/",
},
}),
Qiita は API v2 の items 作成が title / body / tags / private / tweet / organization_url_name で、外部 URL を原本として宣言する項目はありません。Zenn の記事 frontmatter も title / emoji / type / topics / published / publication_name で同じです。
ハマりどころ: Hashnode の下書きには canonical を渡せない
originalArticleURL は publishPost の入力にはありますが、createDraft の入力にはありません。下書きで作って管理画面から公開する運用だと、canonical が付かないまま公開されます。
const input = publish
? { title, contentMarkdown, publicationId, tags, originalArticleURL: canonical }
: { title, contentMarkdown, publicationId, tags }; // draft には入れられない
dev.to も、canonical は投稿時のペイロードで確定します。すでに投稿済みの記事には後から PUT /articles/{id} で入れるか、エディタで設定し直す必要があります。
「一時的に消す」が選べないプラットフォームがある
canonical を設定できないプラットフォームでは、一度消したあとに戻す方法が「もう一度投稿する」しかありません。記事 URL は変わり、それまでの閲覧数も被リンクも切れます。可逆に見えて実際は削除です。
外部を消す判断をする前に、まず一致率を測る。30行のスクリプトで済んで、結果次第でその後の作業が全部消えます。
実測に至った経緯(AdSense の審査結果を検索側の重複判定と混同していた話)、チャネル別の閲覧数を並べた撤回の判断材料、記事ごとの一致率の内訳は Aulvem 本家にまとめました → Aulvem|クロス投稿は重複コンテンツか — 消す前に一致率を測ったら散文の一致は3行だった