背景
商品リンクからアソシエイトタグが抜けても、リンク自体は普通に開く。
表示も崩れないし、404にもならない。だから壊れていることに気づけない。
気づけるのは「クリックはあるのに成果が0のまま」という、数か月遅れの間接的な形になる。
私が運用している自動更新サイト群では、公開前ゲートにこの検査を入れている。
今日その検査を古い記事群にかけたところ、blog.hashito.biz の記事から17本のタグ抜けリンクが出てきた。
2019〜2021年に手で書いた記事で、Amazonの商品ページからURLをそのままコピーしたものだった。
中には他人のタグ(tag=jpgo-22)が付いたままのURLもあった。検索結果経由でコピーすると混ざる。
この記事では、その検出と修正をどう機械化したかを書く。
題材はAmazonだが、「配信されるHTMLを見て、リンクに必要なパラメータが付いているかを検査する」という形は他でも使える。
なぜ「配信されるHTML」を見るのか
検査対象はソース(Markdownやテンプレート)ではなく、ビルド後に配信されるHTMLにする。
理由は、ソースが正しくても出力が壊れることがあるからだ。
- テンプレートやプラグインがURLを書き換える(リダイレクタを挟む、
rel を付ける、& をエスケープする)
- 一部のリンクだけコンポーネント経由で、残りは地の文にベタ書き
- 古い記事の生成物だけが古い形式のまま残る
ソースを見る検査は「書いた内容」を保証するが、収益動線が生きているかどうかを決めるのは読者に届くHTMLのほうだ。
もう1点、href 属性の中だけを見るのも意識的な選択にしている。
地の文に説明としてURL文字列を書いている箇所まで拾うと、直す必要のないものが検出結果に混ざる。
検出結果にノイズが混ざると、そのうち誰も見なくなる。
実際に試す
前提は Node.js 20 以上(String.prototype.matchAll と URL を使う。追加の依存はない)。
公開ディレクトリを引数に渡すと、href 属性中の amazon.co.jp リンクを全部拾い、指定タグが付いていないものを報告する。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
|
import { readdirSync, readFileSync, statSync } from 'node:fs'; import { join, extname } from 'node:path';
const root = process.argv[2] ?? 'public'; const tag = process.argv[3] ?? 'your-tag-22';
function* htmlFiles(dir) { for (const name of readdirSync(dir)) { const p = join(dir, name); if (statSync(p).isDirectory()) yield* htmlFiles(p); else if (extname(p) === '.html') yield p; } }
const HREF = /href=["']([^"']*amazon\.co\.jp[^"']*)["']/gi;
const bad = []; let total = 0;
for (const file of htmlFiles(root)) { const html = readFileSync(file, 'utf8'); for (const m of html.matchAll(HREF)) { total++; const url = m[1].replace(/&/g, '&'); const params = new URL(url, 'https://www.amazon.co.jp').searchParams; if (params.get('tag') !== tag) bad.push({ file, url, tag: params.get('tag') }); } }
console.log(`amazon.co.jp リンク ${total}本 / タグ不備 ${bad.length}本`); for (const b of bad.slice(0, 10)) { console.log(` ${b.tag === null ? 'タグ無し' : `他人のタグ(${b.tag})`}: ${b.url.slice(0, 70)}`); } process.exit(bad.length === 0 ? 0 : 1);
|
実行すると次のように出る(このブログの修正前の状態で実測した出力)。
1 2 3 4 5
| $ node check-affiliate-tag.mjs public post03f-22 amazon.co.jp リンク 17本 / タグ不備 17本 タグ無し: https://www.amazon.co.jp/FC-%E5%B7%A5%E5%A0%B4-R3%E9%96%8B%E7%99%BA%E3 タグ無し: https://www.amazon.co.jp/gp/product/B013QV28CW/ref=ppx_yo_dt_b_asin_ti タグ無し: https://www.amazon.co.jp/gp/product/B00P9BVKOK/ref=ppx_yo_dt_b_asin_ti
|
& を戻してから URL に渡しているのがポイントで、これを飛ばすと
?ie=UTF8&tag=... のようなHTML属性表記でパラメータ名が amp;tag になり、
タグが付いているのに「タグ無し」と判定される。
検査の誤検知は、放置よりたちが悪い(本物の違反まで無視されるようになる)。
process.exit で終了コードを返しているので、そのままデプロイ前ゲートに置ける。
1
| npm run build && node check-affiliate-tag.mjs public post03f-22 && firebase deploy --only hosting
|
見つけたものをASINで正規化する
検出できたら次は修正だが、商品ページからコピーしたURLは検索経由のパラメータで長い。
1
| https://www.amazon.co.jp/gp/product/B013QV28CW/ref=ppx_yo_dt_b_asin_title_o08_s00?ie=UTF8&psc=1
|
この ref= 以降はコピー元のセッションに紐づく履歴で、他人が踏んでも意味がない。
必要なのは ASIN(10文字の商品ID) だけなので、そこだけ抜いて短い正規形に直す。
/dp/<ASIN> と /gp/product/<ASIN> の両方が同じ商品を指すので、どちらの形も同じ扱いにする。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| import { readFileSync, writeFileSync } from 'node:fs';
const TAG = 'your-tag-22'; const AMAZON = /https:\/\/www\.amazon\.co\.jp\/[^\s"')\]<>]*/g; const ASIN = /\/(?:dp|gp\/product)\/([A-Z0-9]{10})/;
export function canonicalize(text) { return text.replace(AMAZON, (url) => { const m = ASIN.exec(url); if (m) return `https://www.amazon.co.jp/dp/${m[1]}/?tag=${TAG}`; if (url.includes(`tag=${TAG}`)) return url; return url + (url.includes('?') ? '&' : '?') + `tag=${TAG}`; }); }
for (const file of process.argv.slice(2)) { const before = readFileSync(file, 'utf8'); const after = canonicalize(before); if (before !== after) { writeFileSync(file, after); console.log('rewrote', file); } }
|
動作を確かめる(node --test で通る最小の確認)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| import { test } from 'node:test'; import assert from 'node:assert/strict'; import { canonicalize } from './canonicalize-amazon.mjs';
test('gp/product 形式をASINの正規形に直す', () => { const src = 'https://www.amazon.co.jp/gp/product/B013QV28CW/ref=ppx_yo?ie=UTF8&psc=1'; assert.equal(canonicalize(src), 'https://www.amazon.co.jp/dp/B013QV28CW/?tag=your-tag-22'); });
test('他人のタグ付きURLも自分のタグの正規形になる', () => { const src = 'https://www.amazon.co.jp/dp/4779305918/ref=asc_df?tag=jpgo-22&psc=1'; assert.equal(canonicalize(src), 'https://www.amazon.co.jp/dp/4779305918/?tag=your-tag-22'); });
test('ASINが無い検索URLにはタグを足すだけ', () => { const src = 'https://www.amazon.co.jp/s?k=arduino'; assert.equal(canonicalize(src), 'https://www.amazon.co.jp/s?k=arduino&tag=your-tag-22'); });
|
1 2 3 4 5 6 7
| $ node --test ok 1 - gp/product 形式をASINの正規形に直す ok 2 - 他人のタグ付きURLも自分のタグの正規形になる ok 3 - ASINが無い検索URLにはタグを足すだけ
|
ASINを持たない検索URL(/s?k=...)にはパラメータを足すだけにしている。
検索結果へのリンクを商品ページに勝手に化けさせると、記事の文脈と合わなくなるためだ。
「直せるものだけ直し、判断が要るものは形を変えない」を境界にしておくと、一括置換を安心して回せる。
なお正規形の ?tag= は消えやすい。リンクを貼り直すたびに落ちるので、
検査を残しておくことのほうが、一度きれいにすることより効く。
運用で分かったこと
- 修正は10ファイル・17リンクで済んだが、気づくまでに数年かかっていた。壊れても何も起きない不具合はこうなる
- 検出を「表示の壊れ」ではなく「終了コード」にすると、人が見ていない時間帯の自動デプロイでも止まる
- ソースではなく配信物を見る検査は、ビルドやテーマの変更にも巻き込まれずに済む
同じ考え方で商品リンクを機械生成しているサイトの実装は
ガジェットレビュー(gadget.autoarticles.net)にまとめてある。
こちらは記事生成の時点でリンクを組み立てるので、そもそもタグが抜けない形にしてある。
既存の手書き記事が多い場合は、この記事の検査を先に入れるほうが早い。