開発mdx-playground

家電のまとめ記事を出すにあたって、商品ごとに「画像+Amazonのボタン」を1枚にまとめたカードが欲しくなった。記事側で型番を並べるだけで、見た目の揃ったカードが並ぶようにしたい。

成果物の記事(家電まとめ)の中身ではなく、このカードをどう作ったかを残しておく。とくに後半の画像集めは、bot検出と通信ハングと抽出ロジックのバグが順番に立ちはだかって、半分泥仕事になった。同じ轍を踏まないための記録。

まず「リンクだけ」を切り分ける

最初に手を付けたのはリンク生成。ここで一度立ち止まって調べたのが効いた。

「Amazonアソシエイトは売上実績がないと使えない」と思い込んでいたが、実際に止められるのは PA-API(商品情報や画像を自動取得するAPI)の方だけだった。アフィリエイトリンクそのものは、ASIN(商品の識別子)さえ分かれば自分で組み立てられる。/dp/{ASIN}?tag={アソシエイトID} の形に文字列を組むだけ。売上ゼロでも作れる。

この切り分けが分かった瞬間、設計が軽くなった。画像はあとで人力で集めるとして、リンクは純粋関数1本で済む。

// asin > url > keyword > トップページ の優先順位で組み立てる純粋関数
export const buildAmazonLink = (input: AmazonLinkInput): string => {
  const tag = (input.tag ?? AMAZON_AFFILIATE_TAG).trim()
  const asin = input.asin?.trim()
  if (asin) {
    return `https://www.amazon.co.jp/dp/${encodeURIComponent(asin)}?tag=${encodeURIComponent(tag)}`
  }
  // asin が無ければ url、それも無ければ keyword の検索リンクへフォールバック
  // ...
}

ASINが分かっていれば /dp/ の確実なリンク、分からなければ型番での検索リンクにフォールバックする。入力に副作用はなく、引数だけ見て文字列を返す。テストを17件書いて全部通し、カバレッジは100%まで埋めた。空白だけのASINがちゃんとフォールバックするか、既存のtagが上書きされるか、といった境界も拾った。

カードはMDCコンポーネントにする

リンクが固まったら、記事から呼ぶカードを ApplianceCard.vue として用意した。content/ の記事から ::appliance-card で呼べるMDCコンポーネントにして、型番や価格、推しポイントをfrontmatter風に渡す。画像URLが空ならプレースホルダーのSVGを出す作りにして、画像が揃う前から記事を組めるようにした。バッジやボタンはサイトのマゼンタ基調に合わせた。

::appliance-card
---
name: "パナソニック ドラム式洗濯乾燥機 NA-LX129E"
maker: "Panasonic"
asin: "B0FQHYQHVV"
image: "https://m.media-amazon.com/images/I/71TVER6YszL._AC_UL320_.jpg"
badge: "本命"
---
::

バグ1: カードに行番号と「####」が混入した

dev環境でカードを表示したら、カードの上に 7, 8, 9, 10 という数字と「####」が浮いていた。

原因はすぐに見当がついた。このサイトの記事本文は DocPage.vue がコードエディタ風の体裁を作っていて、h1h6ppretable のすべてに counter-increment で連番を振り、::before で左に行番号を出している。カードの中で見出しに h4、説明に p を使っていたせいで、その要素がグローバルのカウンターに巻き込まれた。「####」はマークダウン由来の見出しテキストが素通りしたもの。

直し方は、カード内の要素を h4/p から div に置き換えること。見た目はクラスセレクタ(.ac-name など)で当てているので、タグを div にしても崩れない。グローバルカウンターの対象セレクタから外れた瞬間、行番号も「####」も消えた。装飾のためのグローバルセレクタは、こういう「外側で生きるコンポーネント」を平気で巻き込む、という教訓になった。

バグ2の連鎖: 画像集めが泥仕事になる

ここからが本番。カードに入れる商品画像を集める段で、失敗が4つ続いた。PA-APIは使えないので、自分のログイン済みChromeを動かせる agent-browser でAmazonの商品ページを開き、og:image や商品画像のURLを抜く方針で進めた。

ハングで時間を溶かした。 最初、ページを開いたあとに「通信が落ち着くまで待つ」設定(networkidle待ち)を入れていた。ところがAmazonは広告まわりの通信が鳴り止まないので、待ちが永久に返ってこない。画面の前で「これ終わったの?」と聞かれて、初めて固まっているのに気づいた。待ちの条件を外し、ページが描けたら即座にDOMから抜く方式に変えて、ようやく前に進んだ。

bot検出に止められた。 商品を連続で開いていたら、商品ページの代わりに「ショッピングを続けてください」のクッション画面が挟まるようになった。これは「続ける」ボタンをクリックで突き抜けて回避した。アクセスの間隔も少し空けた。

抽出が空振りした(真因は別だった)。 三菱の白物家電だけ、何度やっても画像が NONE で返ってきた。最初はまたbot検出かと疑ったが、検索結果は51件ちゃんと返っていた。落ち着いて中身を見たら、抽出ロジックが最初にヒットした要素を拾っていて、それが検索ヘッダーの「結果」という商品ですらない文字列だった。商品画像を持っている要素を選ぶように抽出条件を直したら、あっさり取れた。bot検出だと早合点していたが、犯人は自分のコードのタイミングバグだった。

「普通にDevToolsを使えば」で寄り道した。 途中で「agent-browserでなく、Chrome DevToolsを直接使ってよ」と言われて、temp profile とデフォルトプロファイルの両方で接続を試した。が、どちらも繋がらない。Chrome 136以降、ログイン済みのデフォルトプロファイルではリモートデバッグが無効化されている(Cookie窃取対策)という制約に阻まれた。結局この日はagent-browserで進めきった。

上位機種は「誤画像を入れない」で割り切る

最後にもう一段。三菱の冷蔵庫とエアコンの上位機種は、Amazonの検索トップが本体ではなかった。互換フィルターや一段下の廉価機が先頭に並んでしまう。ここで「それっぽい画像」を入れると、本体と違うものを載せることになる。誤った画像を出すくらいなら空けておく方がいいと判断して、この3機種はプレースホルダーのまま残した。

結果、18枚中15枚に正しい画像が入り、三菱の上位3機種だけプレースホルダー。間違った画像を載せた枚数はゼロに保てた。

学び: バージョンを確認せず言い切らない

DevToolsで詰まった件は、勘違いを正す形でドキュメントに残した。「ログイン済みChromeにDevToolsを繋ぐのは仕様上不可能」と書きかけたが、これは誤りだった。Chrome 144以降なら chrome://inspect の許可接続でログイン済みセッションに繋げる。実際このPCは148で、条件を満たしている。

バージョンごとに挙動が変わる話を、確認せずに断定すると後で自分が引っかかる。Chrome 136でデフォルトプロファイルが封じられたこと、144で許可接続が復活したこと、Windowsで既存Chromeにアタッチして即終了する罠まで、CLAUDE.mdなど3箇所に正確な形で書き残した。

リンクは純粋関数とテストできれいに片付き、画像は泥臭く手で集める。きれいに片付く部分と、泥仕事で詰める部分は最初から分けて構えておくのがちょうどよかった。