1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Amazonの「令和最新版」を検索結果から消すChrome拡張を作ってストア公開するまで

1
Posted at

はじめに

Amazon.co.jp で買い物をしていると、こういう商品名をよく見かけます。

【令和最新版】★超高品質★ モバイルバッテリー 大容量 25000mAh 急速充電 ...

Amazon には「商品名は ブランド名 + 商品名 + 仕様・型番・カラー・サイズ の順で書く」という出品規約があります。冒頭の「令和最新版」も「★超高品質★」も、本来そこに来るべきものではありません。規約を守っていない出品は、その他の点でも信用しにくい——そう考えて、こういう出品を検索結果から非表示にする Chrome 拡張機能を作りました。

この記事では、設計上の判断と、Chrome ウェブストアに公開するまでに踏んだ地雷をまとめます。

作ったもの

  • Amazon.co.jp の検索結果ページで、商品名規約に沿わない出品を非表示にする
  • 非表示ではなく注意ラベルを付けるだけのモードも選べる
  • Amazon のページ上に折りたたみ可能な操作パネルを常時表示し、その場で条件を変えて再検索できる
  • カテゴリ(化粧品・ブランド品・家電など)を選ぶと、そのジャンル特有の禁止ワードも判定対象に加わる
  • 権限は storage のみ。外部サーバーとの通信はゼロ

設計の核心:「禁止ワード」ではなく「商品名規約」で判定する

最初は素朴に、禁止ワードのブラックリストを作ろうとしました。「スーパーコピー」「レプリカ」「令和最新版」……。

しかしこの方式には限界があります。宣伝文句は無限に湧いてくるので、リストがいたちごっこになる。かといって「最強」「高品質」のような一般語を足すと、正規品まで巻き込んで誤検知が増える。

そこで判定の軸を変えました。ブラックリストで殴るのではなく、Amazon の商品名規約に沿っているかどうかを見る。規約が「ブランド名で始まれ」と言っているなら、ブランド名が来るべき位置に何が来ているかだけを見ればいい。

function titleViolatesPolicy(title) {
  const t = String(title || "").trim();
  if (!t) return { violated: false, reason: "" };

  // 1. 先頭が装飾記号(★ ◆ ※ など)
  for (const s of TITLE_POLICY.leadingSymbols) {
    if (t.startsWith(s)) return { violated: true, reason: `先頭に「${s}」` };
  }

  // 1'. 先頭が絵文字・矢印・各種シンボル
  if (/^[←-⇿⌀-⏿①-➿⬀-⯿\u{1F000}-\u{1FAFF}]/u.test(t)) {
    return { violated: true, reason: "先頭が絵文字・記号" };
  }

  // 2. 先頭カッコ【】の中身が宣伝的
  const bracket = t.match(/^\s*[\[((《〈「『]([^\]))》〉」』]{1,30})[\]))》〉」』]/);
  if (bracket) {
    const inner = bracket[1];
    const promoInBracket =
      new RegExp(TITLE_POLICY.promoBracketPattern, "i").test(inner) ||
      TITLE_POLICY.leadingPromoPhrases.some((p) => inner.includes(p));
    if (promoInBracket) {
      return { violated: true, reason: `先頭カッコ「${inner}」が宣伝文句` };
    }
  }

  // 3. カッコの後ろが年号アピール・宣伝フレーズ
  const head = (bracket ? t.slice(bracket[0].length) : t).trim();
  if (/^(20[0-9]{2}|令和\s*[0-9]?|平成\s*[0-9]{1,2})\s*年?\s*(最新|版|モデル|新)?/.test(head)) {
    return { violated: true, reason: "先頭が年号・最新アピール" };
  }
  for (const p of TITLE_POLICY.leadingPromoPhrases) {
    if (head.startsWith(p)) return { violated: true, reason: `先頭が「${p}」` };
  }

  return { violated: false, reason: "" };
}

ポイントは 商品名の「先頭」しか見ていないことです。

タイトル全体を対象にすると、正規品でも仕様欄に「高性能」などと書いてあるだけで弾いてしまいます。先頭に限定することで、「規約の順序を守っていない」という一点に絞って判定でき、誤検知を大きく減らせました。

また、先頭カッコの判定では**「正規」「保証」「代理店」を意図的に除外**しています。これらは正規品の表記でも普通に使われるためです。ブラックリスト方式では、こういう「入れてはいけない語」の見極めが延々と必要になります。


つまずき① Amazon の -キーワード 除外が使えない

当初は検索クエリ側で除外しようとしました。Google と同じように、-令和最新版 のようにマイナス検索すればいいだろうと。

これが動きません。 amazon.co.jp はこの構文を安定して解釈せず、検索結果が 0 件になることがある。除外語を増やすほど再現性が悪くなります。

そこで方針転換し、検索結果ページ側で該当要素を display:none にする方式にしました。

function renderFlagged() {
  const hide = settings.excludeSuspicious && !revealed;
  for (const { item, hits, reason } of flagged) {
    item.classList.toggle("asf-hidden", hide);
    if (!hide) labelItem(item, hits, reason);
  }
  updatePanelStatus();
}

結果的にこちらの方が優れていました。「N件を非表示にしました / すべて表示」というトグルを出せるからです。検索クエリで除外すると、何がどれだけ消えたのかユーザーには分かりません。表示側で隠せば、いつでも元に戻せる。

「フィルタは可逆であるべき」という点で、制約から入った選択が結果的に正解になった例でした。


つまずき② Amazon の DOM は変わる

Amazon の検索結果の HTML は、クラス名も属性も頻繁に変わります。単一のセレクタに依存すると、ある日突然フィルタが無言で効かなくなる。

対策として、セレクタを候補の配列にして順に試す構造にしました。

const RESULT_ITEM_SELECTORS = [
  'div[data-component-type="s-search-result"]',
  'div.s-result-item[data-asin]:not([data-asin=""])',
  '.s-main-slot [data-asin]:not([data-asin=""])',
  '[data-asin]:not([data-asin=""])',   // 最後の砦
];

const TITLE_SELECTORS = [
  "h2 a span",
  "h2 span",
  '[data-cy="title-recipe"] span',
  "a.a-link-normal span.a-text-normal",
  "h2",
];

function allMatches(root, selectors) {
  for (const sel of selectors) {
    try {
      const els = root.querySelectorAll(sel);
      if (els.length) return { selector: sel, elements: Array.from(els) };
    } catch (_) {
      /* 無効なセレクタは無視して次へ */
    }
  }
  return { selector: null, elements: [] };
}

上から順に「具体的だが壊れやすいもの」→「曖昧だが壊れにくいもの」と並べています。[data-asin] は Amazon の商品識別子なので、そう簡単には消えません。

さらに、検索結果は遅延読み込みされるため MutationObserver で監視し、連続発火を防ぐためにデバウンスをかけています。

observer = new MutationObserver(() => {
  clearTimeout(rescanTimer);
  rescanTimer = setTimeout(scan, 200);
});
observer.observe(target, { childList: true, subtree: true });

加えて、DOM 構造が変わって商品要素が1つも見つからなかった場合でも例外で停止せず、5秒後にコンソールへ警告を1回出すだけにしています。フィルタが効かなくなっても、パネルからの検索機能自体は生き残るようにするためです。


権限は最小限に

Chrome ウェブストアの審査は、要求する権限が多いほど厳しくなります。この拡張の manifest.json はこれだけです。

{
  "manifest_version": 3,
  "name": "Amazon Search Filter",
  "version": "1.3.0",
  "permissions": ["storage"],
  "content_scripts": [
    {
      "matches": ["https://www.amazon.co.jp/*"],
      "js": ["dictionary.js", "content.js"],
      "css": ["content.css"],
      "run_at": "document_idle"
    }
  ]
}

host_permissions は宣言していません。tabs も使っていません(新しいタブを開くのは chrome.tabs.create ではなくポップアップからのリンクで足りる場面が多い)。

判定に使う辞書はすべてパッケージに同梱し、外部から取得しません。結果として外部サーバーとの通信がゼロになり、プライバシーポリシーも「何も収集しない」と書ける状態になりました。これは審査対応がかなり楽になります。


収益化:Amazon アソシエイトを「あえて使わなかった」

Amazon 関連の拡張機能なら、検索 URL に自分のアソシエイトタグを付ければ収益化できます。実装は 1 行です。

if (ASSOCIATE_TAG) url.searchParams.set("tag", ASSOCIATE_TAG);

しかし今回は無効化したまま公開しましたASSOCIATE_TAG = "")。理由は次の通りです。

  • Amazon アソシエイトの規約は、ソフトウェア・ブラウザ拡張機能・ツールバーからのリンク生成やタグ付与を制限・禁止する場合がある
  • ユーザーが明示的に指定していない検索に自動でタグを差し込む挙動は「リンクの改変・注入」とみなされ、アカウント停止の対象になりうる
  • Chrome ウェブストアのポリシーも、アフィリエイトコードの付与には「明確な開示」と「ユーザーにとっての利益」を要求する

数円の収益のためにアカウントを飛ばすリスクを取る価値はないと判断しました。

代わりに Buy Me a Coffee への任意の寄付リンクをポップアップとパネルに置いています。実装は「正しい URL を新しいタブで開く」だけで、API 連携も OAuth も不要です。

<a href="https://buymeacoffee.com/maylab" target="_blank" rel="noopener noreferrer">
  開発者を支援する(Buy Me a Coffee)
</a>

拡張機能側でやることはこれだけで、入金導線は Buy Me a Coffee 側で受取方法(銀行 / PayPal)を登録すれば完結します。

もしこの記事を読んでくださった方がいて、少しでも役に立ったと思っていただけたら、よければ寄付で応援していただけるとうれしいです!


Chrome ウェブストア公開でハマった7つのこと

ここからは実装ではなく手続きの話です。「あと1時間で公開できるだろう」と思っていたら、丸一日かかりました。

1. 登録料 $5 は初回のみ・アカウント単位・返金不可

掲載主にするアカウントは慎重に選ぶこと。あとから別アカウントへ移すのは面倒です。

2. EEA の「取引業者 / 非取引業者」申告

EU 消費者保護法に基づく申告が必須になっています。無料の拡張機能で、収益は外部サイトでの任意の寄付のみなら通常は非取引業者。取引業者を選ぶと氏名・住所・電話の一般公開が必要になります。有料機能を追加したら取引業者への変更が要ります。

3. host_permissions を書いていないのに「ホスト権限の理由」を求められる

これが一番混乱しました。manifest に host_permissions は一切書いていません。しかし content_scriptsmatcheshttps://www.amazon.co.jp/* を指定していると、Chrome ウェブストアはこれをホスト権限として扱います

「宣言していないので該当なし」では通らないので、コンテンツスクリプトが何のためにそのドメインへアクセスするのかを具体的に書く必要があります。

4. 「リモートコードの使用」の申告

eval() も外部スクリプト読み込みもしていなければ「いいえ」でよいのですが、判断に迷ったら自分のコードを検索して確認するのが確実です。

grep -nE "eval\(|new Function|<script[^>]*src=[\"']https?:|import\(" *.js *.html

なお、外部サイトへの <a href> リンクはリモートコードではありません(コードを取得・実行していないため)。

5. ストアのアイコンは manifest から自動反映されない

zip の icons/icon128.png は拡張機能のツールバー表示用で、ストア掲載ページのアイコンは別途手動アップロードが必要です。「アイコンが反映されない」と悩んだ原因はこれでした。

6. スクリーンショットは 1280×800 ちょうど

Retina ディスプレイなら、ブラウザを少しズームしてから撮ると 2560px 級で取得できます。それを 1280×800 へ一回だけ縮小するのが最も鮮明です。macOS なら sips だけで完結します。

sips -c 1814 2902 --cropOffset 0 0 shot.png   # 左上基準で16:10に切り出し
sips -z 800 1280 shot.png                      # 一回だけ縮小

--cropOffset を使うと左上基準で切り出せます。-c だけだと中央基準になり、左端の文字が欠けます。

7. 最後の関門:連絡先メールの「認証」

すべてのタブを埋めても送信できず、残ったエラーがこれでした。

* パブリッシャーの連絡先メールアドレスを確認する必要があります。[設定] ページで...

これはアイテム編集画面ではなくアカウント全体の「設定」ページの項目です。しかも入力して保存するだけでは不十分で、認証メールのリンクを踏むまで公開できません


まとめ

技術的に一番効いたのは、判定の軸を「禁止ワードのリスト」から「規約に沿っているか」へ変えたことでした。ブラックリストは対象が増え続けますが、「ブランド名で始まっているか」という基準なら対象は有限で、誤検知もコントロールできます。

そして、実装より公開手続きの方が時間を食いました。特に「宣言していない権限の理由を求められる」「アイコンは別アップロード」「メール認証は別画面」あたりは、事前に知っていれば数時間節約できたはずです。同じところで詰まる人の役に立てば幸いです。

辞書の精度(誤検知・見逃し)はまだ発展途上なので、実際の検索結果を見ながら調整を続ける予定です。


推奨タグ: Chrome拡張 JavaScript 個人開発 Amazon ChromeWebStore

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?