Amazon.co.jp 商品链接清理器 🔗🧹

将 Amazon.co.jp 商品链接规范化为 /dp/ASIN,移除跟踪参数;可保留允许参数(如 tag)。适配 SPA、History API 与链接点击。

// ==UserScript==
// @name         Amazon.co.jp 商品URLクリーナー 🔗🧹
// @name:ja      Amazon.co.jp 商品URLクリーナー 🔗🧹
// @name:en      Amazon.co.jp Product URL Cleaner 🔗🧹
// @name:zh-CN   Amazon.co.jp 商品链接清理器 🔗🧹
// @name:zh-TW   Amazon.co.jp 商品連結清理器 🔗🧹
// @name:ko      Amazon.co.jp 상품 URL 클리너 🔗🧹
// @name:fr      Nettoyeur d’URL produit Amazon.co.jp 🔗🧹
// @name:es      Limpiador de URL de productos de Amazon.co.jp 🔗🧹
// @name:de      Amazon.co.jp Produkt-URL-Reiniger 🔗🧹
// @name:pt-BR   Limpador de URL de produtos da Amazon.co.jp 🔗🧹
// @name:ru      Очистка URL товаров Amazon.co.jp 🔗🧹
// @version      1.0.0
// @description         Amazon.co.jpの商品URLを /dp/ASIN に正規化。トラッキングを削除し、必要なら許可したクエリ(例: tag)のみ保持。SPA遷移・履歴API・anchorクリックもフックして常にクリーンなURLを維持。
// @description:ja      Amazon.co.jpの商品URLを /dp/ASIN に正規化。トラッキングを削除し、必要なら許可したクエリ(例: tag)のみ保持。SPA遷移・履歴API・anchorクリックもフックして常にクリーンなURLを維持。
// @description:en      Canonicalize Amazon.co.jp product URLs to /dp/ASIN. Remove tracking and optionally keep allowed params (e.g., tag). Hooks SPA navigation, History API, and anchor clicks to keep URLs clean.
// @description:zh-CN   将 Amazon.co.jp 商品链接规范化为 /dp/ASIN,移除跟踪参数;可保留允许参数(如 tag)。适配 SPA、History API 与链接点击。
// @description:zh-TW   將 Amazon.co.jp 商品連結正規化為 /dp/ASIN,移除追蹤參數;可保留允許的參數(如 tag)。支援 SPA、History API 與連結點擊。
// @description:ko      Amazon.co.jp 상품 URL을 /dp/ASIN 으로 정규화하고 트래킹 파라미터를 제거합니다. 필요 시 허용된 파라미터(tag 등)만 유지. SPA/History API/앵커 클릭 대응.
// @description:fr      Canonise les URL de produits Amazon.co.jp en /dp/ASIN, supprime le tracking et peut garder certains paramètres (ex.: tag). Compatible navigation SPA et History API.
// @description:es      Canonicaliza las URL de productos de Amazon.co.jp a /dp/ASIN. Elimina el tracking y puede conservar parámetros permitidos (p. ej., tag). Funciona con SPA/History API.
// @description:de      Kanonisiert Amazon.co.jp-Produkt-URLs zu /dp/ASIN, entfernt Tracking und kann erlaubte Parameter (z. B. tag) beibehalten. Funktioniert mit SPA/History API.
// @description:pt-BR   Canoniza URLs de produtos da Amazon.co.jp para /dp/ASIN. Remove tracking e pode manter parâmetros permitidos (ex.: tag). Suporta SPA/History API.
// @description:ru      Приводит ссылки товаров Amazon.co.jp к /dp/ASIN, удаляет трекинг; при необходимости сохраняет разрешённые параметры (например, tag). Поддержка SPA/History API.
// @namespace    https://github.com/koyasi777/amazon-jp-url-cleaner
// @author       koyasi777
// @license      MIT
// @homepageURL  https://github.com/koyasi777/amazon-jp-url-cleaner
// @supportURL   https://github.com/koyasi777/amazon-jp-url-cleaner/issues
// @icon         https://www.amazon.co.jp/favicon.ico
// @match        https://www.amazon.co.jp/dp/*
// @match        https://www.amazon.co.jp/*/dp/*
// @match        https://www.amazon.co.jp/-/*/dp/*
// @match        https://www.amazon.co.jp/gp/product/*
// @match        https://www.amazon.co.jp/-/*/gp/product/*
// @match        https://www.amazon.co.jp/gp/aw/d/*
// @match        https://www.amazon.co.jp/-/*/gp/aw/d/*
// @grant        none
// @run-at       document-start
// @noframes
// ==/UserScript==

(function () {
  'use strict';

  // 許可するクエリキー(例: アフィリエイトなら ['tag'])。完全にクリーンにするなら [] のままでOK。
  // 例:
  //  - アフィリエイト: ['tag']
  //  - 言語維持:       ['language']   // 例: ?language=en_US
  //  - 両方:           ['tag','language']
  const KEEP_KEYS = [];

  // --- helpers ---------------------------------------------------------------
  function toURL(input) {
    const s = String(input);
    if (typeof URL.canParse === 'function' && !URL.canParse(s, location.href)) {
      throw new TypeError('Unparsable URL');
    }
    return input instanceof URL ? input : new URL(s, location.href);
  }

  // --- Canonicalizer ---------------------------------------------------------
  function canonicalize(input) {
    let url;
    try {
      url = toURL(input);
    } catch {
      // URL 構築に失敗した場合はそのまま返す
      return String(input);
    }

    // ★ amazon.co.jp 以外のホストは触らない(外部リンク安全弁)
    if (!/\.amazon\.co\.jp$/i.test(url.hostname)) return url.href;

    // dp / gp/product / gp/aw/d の何れかから ASIN を抽出
    const m = url.pathname.match(/\/(?:dp|gp\/product|gp\/aw\/d)\/([A-Z0-9]{10})(?:[/?]|$)/i);
    if (!m) return url.href; // 対象外は触らない

    const asin = m[1].toUpperCase();

    // 言語プレフィックス(/-/en/ や /-/es/ など)があれば保持
    const langPrefixMatch = url.pathname.match(/^\/-\/[^/]+\//);
    const prefix = langPrefixMatch ? langPrefixMatch[0].slice(0, -1) : ''; // '/-/en'

    // 許可されたクエリだけ残す(既定では全削除、重複値も維持)
    const kept = new URLSearchParams();
    if (KEEP_KEYS.length) {
      const allow = new Set(KEEP_KEYS.map(k => k.toLowerCase()));
      for (const [k, v] of url.searchParams) {
        if (allow.has(k.toLowerCase())) kept.append(k, v);
      }
    }
    const qs = kept.toString();

    // フラグメント(#...)は常に保持
    return `${url.origin}${prefix}/dp/${asin}${qs ? `?${qs}` : ''}${url.hash}`;
  }

  function normalizeHere() {
    const target = canonicalize(location.href);
    if (target !== location.href) {
      try {
        // 既存の history.state / document.title を保持したまま URL だけ置換
        history.replaceState(history.state, document.title, target);
      } catch {
        // 後方互換フォールバック
        history.replaceState(null, '', target);
      }
      return true;
    }
    return false;
  }

  // --- 1) 初期正規化(超早期) -----------------------------------------------
  normalizeHere();

  // --- 2) history API を正しくフック(URLを明示的に差し替えて呼ぶ) ----------
  (function hookHistory() {
    const _push = history.pushState;
    const _replace = history.replaceState;

    history.pushState = function (state, title, url) {
      if (url !== undefined && url !== null) {
        try { url = canonicalize(url); } catch {}
        return _push.call(this, state, title, url);
      }
      // 第3引数未指定の場合は2引数で呼ぶ
      return _push.call(this, state, title);
    };

    history.replaceState = function (state, title, url) {
      if (url !== undefined && url !== null) {
        try { url = canonicalize(url); } catch {}
        return _replace.call(this, state, title, url);
      } else {
        // url 未指定でも現在URLを正規化
        const target = canonicalize(location.href);
        if (target !== location.href) {
          return _replace.call(this, state, title, target);
        }
        // 第3引数未指定の素通し
        return _replace.call(this, state, title);
      }
    };

    window.addEventListener('popstate', normalizeHere, { capture: true });
  })();

  // --- 3) location.assign/replace のフック(フルナビゲーション経路も矯正) ----
  (function hookLocation() {
    // 同一URLなら何もしない(無駄なリロード回避)
    function safeCallAssignReplace(fn, urlLike) {
      try {
        const c = canonicalize(urlLike);
        if (c === location.href) return; // no-op
        return fn.call(this, c);
      } catch {
        return fn.call(this, urlLike);
      }
    }

    try {
      const L = Location.prototype;
      // assign
      try {
        const descA = Object.getOwnPropertyDescriptor(L, 'assign');
        if (!descA || descA.writable) {
          const _assign = L.assign;
          L.assign = function (url) {
            return safeCallAssignReplace.call(this, _assign, url);
          };
        }
      } catch {}
      // replace
      try {
        const descR = Object.getOwnPropertyDescriptor(L, 'replace');
        if (!descR || descR.writable) {
          const _replace = L.replace;
          L.replace = function (url) {
            return safeCallAssignReplace.call(this, _replace, url);
          };
        }
      } catch {}
    } catch {
      // prototype を触れない環境向けフォールバック(インスタンス側)
      try {
        const loc = window.location;
        const _assign2 = loc.assign.bind(loc);
        const _replace2 = loc.replace.bind(loc);
        loc.assign = (url) => safeCallAssignReplace.call(loc, _assign2, url);
        loc.replace = (url) => safeCallAssignReplace.call(loc, _replace2, url);
      } catch {}
    }
  })();

  // --- 3.5) アンカークリックの事前正規化(遷移前に href をクリーン化) -------
  (function preNormalizeAnchorClicks() {
    document.addEventListener('click', (e) => {
      if (e.defaultPrevented) return;
      if (e.button !== 0) return; // 左クリックのみ
      if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; // 修飾キー除外

      const a = e.target && e.target.closest && e.target.closest('a[href]');
      if (!a) return;

      // ★ ユーザ意図尊重: download / 新規タブ / rel=external は改変しない
      if (a.hasAttribute('download')) return;
      if (a.target === '_blank') return;
      if (/\bexternal\b/i.test(a.rel || '')) return;

      try {
        const u = new URL(a.href, location.href);
        // ★ http(s) 以外は触らない
        if (!/^https?:$/i.test(u.protocol)) return;
        // ★ amazon.co.jp 以外は触らない
        if (!/\.amazon\.co\.jp$/i.test(u.hostname)) return;

        const c = canonicalize(u);
        if (c !== a.href) a.href = c; // 置換して標準ナビゲーションに任せる
      } catch {}
    }, { capture: true });
  })();

  // --- 4) 軽量ウォッチドッグ(短時間だけ監視して最後の付け直しも即矯正) -----
  (function watchdog() {
    let ticks = 0, stable = 0;
    const id = setInterval(() => {
      const changed = normalizeHere();
      stable = changed ? 0 : (stable + 1);
      // 連続8回「変化なし」 or 約10秒経過で自動終了(250ms間隔)
      if (stable >= 8 || (++ticks > 40 && !changed)) clearInterval(id);
    }, 250);

    // ページ表示タイミング/完全読込でも念のため
    document.addEventListener('DOMContentLoaded', normalizeHere, { once: true });
    window.addEventListener('load', normalizeHere, { once: true });
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'visible') normalizeHere();
    });
  })();

})();