Greasy Fork

Greasy Fork is available in English.

AliExpress Product Link Fixer

Enhance your AliExpress shopping experience by converting marketing links into direct product links, ensuring each product is easily accessible with a single click.

目前为 2025-04-29 提交的版本,查看 最新版本

// ==UserScript==
// @name         AliExpress Product Link Fixer
// @namespace    http://tampermonkey.net/
// @version      2.2
// @license      MIT
// @description  Enhance your AliExpress shopping experience by converting marketing links into direct product links, ensuring each product is easily accessible with a single click.
// @author       NewsGuyTor
// @match        https://*.aliexpress.com/*
// @icon         https://www.aliexpress.com/favicon.ico
// @grant        GM_openInTab
// ==/UserScript==

(function() {
    'use strict';

    // --- Global Variables ---
    let observer;               // MutationObserver instance to watch for page changes
    let debounceTimer;          // Timer ID for debouncing MutationObserver callbacks
    let isHandlingClick = false; // Flag to prevent click handler re-entry issues

    // --- Core Functions ---

    /**
     * Schedules the main link fixing logic to run after a short delay.
     * This prevents the function from running excessively on rapid DOM changes.
     */
    function scheduleFixLinks() {
        clearTimeout(debounceTimer);
        debounceTimer = setTimeout(fixLinks, 250); // Wait 250ms after the last mutation
    }

    /**
     * Main function orchestrating the different link fixing strategies.
     * Temporarily disconnects the observer to avoid infinite loops while modifying the DOM.
     */
    function fixLinks() {
        if (observer) observer.disconnect(); // Pause observation during modification

        try {
            // Apply fixes in a specific order
            removeMarketingAnchors();        // Phase 1: Clean up wrapper links without direct product IDs
            rewriteAnchorsWithProductIds();  // Phase 2: Correct links that *do* contain product IDs
            fixOrCreateLinksForDataProducts(); // Phase 3: Ensure product elements (divs/etc.) are properly linked
        } catch (err) {
            console.error("[AliExpress Product Link Fixer v2.2] Error in fixLinks():", err);
        }
        // Resume observation after modifications are done
        if (observer) {
            try {
                 observer.observe(document.body, { childList: true, subtree: true });
            } catch (e) { // Handle edge case where the page unloads rapidly
                 if (e.name !== 'NotFoundError') console.error("[AliFix v2.2] Error reconnecting observer:", e);
            }
        } else { console.warn("[AliFix v2.2] Observer not ready."); }
    }

    /**
     * Phase 1: Removes wrapper anchor tags (`<a>`) that point to marketing URLs
     * (containing '/gcp/' or '/ssr/') *without* a specific 'productIds' parameter.
     * It unwraps the anchor, keeping its child elements in place.
     * It specifically excludes certain header navigation links.
     */
    function removeMarketingAnchors() {
        // Select potential marketing anchors not yet processed by this script
        const anchors = document.querySelectorAll('a[href*="/gcp/"]:not([data-alifix-done]), a[href*="/ssr/"]:not([data-alifix-done])');

        anchors.forEach(a => {
            // Skip if already marked as processed
            if (a.dataset.alifixDone) return;

            // Exclude specific header navigation links (e.g., Bundle Deals, Choice tabs)
            const headerNavContainer = a.closest('div.an_ar.an_at[data-tabs="true"]');
            if (headerNavContainer) {
                a.dataset.alifixDone = "1"; // Mark as processed to prevent other functions touching it
                return; // Do not unwrap header links
            }

            // Process other potential marketing links
            try {
                if (!a.href) { if (a && a.dataset) a.dataset.alifixDone = "1"; return; }
                const url = new URL(a.href, location.origin);

                // If the URL has '/gcp/' or '/ssr/' but NO 'productIds', unwrap it
                if (!url.searchParams.has('productIds')) {
                     if (a && a.dataset) a.dataset.alifixDone = "1"; // Mark before unwrapping
                     unwrapAnchor(a); // Remove the anchor, keep children
                }
            } catch (e) {
                console.error("[AliFix v2.2] Error processing anchor for removal:", a.href, e);
                 if (a && a.dataset) a.dataset.alifixDone = "1"; // Mark as done on error
            }
        });
    }

    /**
     * Phase 2: Rewrites anchor tags that point to marketing URLs ('/gcp/' or '/ssr/')
     * but *do* contain a 'productIds' parameter. It changes the href to point
     * directly to the standard '/item/...' product page URL.
     */
    function rewriteAnchorsWithProductIds() {
        // Select relevant anchors not yet processed
        const anchors = document.querySelectorAll('a[href*="/gcp/"]:not([data-alifix-done]), a[href*="/ssr/"]:not([data-alifix-done])');

        anchors.forEach(a => {
            if (a.dataset.alifixDone) return;

            try {
                if (!a.href) { if (a && a.dataset) a.dataset.alifixDone = "1"; return; }
                const url = new URL(a.href, location.origin);
                const pidParam = url.searchParams.get('productIds');

                if (pidParam) {
                    // Extract the numeric ID (sometimes includes extra chars like ':0')
                    const actualPid = pidParam.split(':')[0];
                    if (actualPid && /^\d+$/.test(actualPid)) {
                        const newHref = `https://${url.host}/item/${actualPid}.html`;
                        if (a.href !== newHref) { a.href = newHref; }
                         a.dataset.alifixDone = "1"; // Mark as successfully processed
                    } else { a.dataset.alifixDone = "1"; } // Mark as processed even if PID format was invalid
                } else {
                     a.dataset.alifixDone = "1"; // Mark anchors without productIds as done here too
                }
            } catch (e) {
                console.error("[AliFix v2.2] Error processing anchor for rewrite:", a.href, e);
                 if (a && a.dataset) a.dataset.alifixDone = "1"; // Mark done on error
            }
        });
    }

    /**
     * Phase 3: Ensures that elements representing products have a functional, direct link.
     * Targets elements identified either by 'data-product-ids' attribute or specific
     * div structures (like those on Bundle Deals pages with numeric IDs).
     * If a correct link doesn't exist, it creates a wrapper `<a>` tag.
     * Applies CSS `pointer-events: none` to the original inner element when wrapping,
     * to prevent interference from its original JS click handlers.
     * Attaches a custom click handler to newly created links to manage navigation reliably.
     */
    function fixOrCreateLinksForDataProducts() {
        // Select potential product elements using various known patterns, excluding already processed ones
        const productIndicators = document.querySelectorAll(
            '[data-product-ids]:not([data-alifix-done]), ' +                 // Common pattern
            '#root div[id].productContainer:not([data-alifix-done]), ' +     // Bundle Deals slider container pattern
            '#root div[id].product_a12766ed:not([data-alifix-done]), ' +     // Bundle Deals waterfall container pattern
            '#root div[id].product_6a40c3cf:not([data-alifix-done])'          // Bundle Deals slider item pattern
        );

        productIndicators.forEach(element => {
            let pid;
            let isDivById = false; // Track if PID comes from element ID
            if (!element || !element.dataset || element.dataset.alifixDone) return;

            // Determine the source of the Product ID
            if (element.dataset.productIds) {
                pid = element.dataset.productIds;
            } else if (element.tagName === 'DIV' && element.id && /^\d+$/.test(element.id)) {
                const parentAnchor = element.parentNode;
                // Only proceed if it looks like it needs wrapping
                if ((element.getAttribute('href') === "" || !element.hasAttribute('href')) && !(parentAnchor && parentAnchor.tagName === 'A')) {
                     pid = element.id;
                     isDivById = true;
                } else { element.dataset.alifixDone = "1"; return; }
            } else { if (element.dataset) element.dataset.alifixDone = "1"; return; }

            // Validate the extracted/found Product ID
            if (!pid || !/^\d+$/.test(pid)) { element.dataset.alifixDone = "1"; return; }

            // Mark the element as processed EARLY to prevent potential infinite loops
            element.dataset.alifixDone = "1";
            const targetHref = `https://${location.host}/item/${pid}.html`;

            // --- Check if linking is already handled ---

            // 1. Check if already correctly wrapped by a parent anchor
            const parentAnchor = element.parentNode;
             if (parentAnchor && parentAnchor.tagName === 'A') {
                  if (parentAnchor.href === targetHref) {
                      if (!parentAnchor.dataset.alifixDone) parentAnchor.dataset.alifixDone = "1"; return;
                  } else if (!parentAnchor.dataset.alifixDone) {
                       parentAnchor.href = targetHref; parentAnchor.dataset.alifixDone = "1"; return;
                  } else { return; }
             }

            // 2. For `data-product-ids` elements, check for an inner anchor to fix
            if (!isDivById) {
                const existingInnerAnchor = element.querySelector('a[href]');
                if (existingInnerAnchor && !existingInnerAnchor.dataset.alifixDone && !(/^\d+$/.test(existingInnerAnchor.id))) {
                     if (existingInnerAnchor.href !== targetHref) { existingInnerAnchor.href = targetHref; }
                     existingInnerAnchor.dataset.alifixDone = "1"; return;
                }
            }

            // --- Create a new wrapper link if no suitable parent/inner link was found/fixed ---
            const link = document.createElement('a');
            link.href = targetHref;
            link.dataset.alifixDone = "1"; // Mark the new link itself as processed
            link.style.display = 'block';  // Ensure proper layout wrapping
            link.style.color = 'inherit';  // Prevent default blue link color on content
            link.style.textDecoration = 'none'; // Prevent underline

            // Attach our custom click handler to manage navigation and prevent JS conflicts
            link.addEventListener('click', handleProductClick, true); // Use 'click' event, capture phase

            // Perform the DOM manipulation (wrapping)
            try {
                if (element.parentNode) { // Ensure element is still in the DOM
                     element.parentNode.insertBefore(link, element); // Insert link before element
                     link.appendChild(element);                      // Move element inside link
                     // Disable pointer events on the original element to prevent its JS handlers firing
                     element.style.pointerEvents = 'none';
                } else { console.warn(`[AliFix v2.2] Element for PID ${pid} lost its parent before wrapping.`); }
            } catch (e) { console.error(`[AliFix v2.2] Error wrapping element PID ${pid}:`, e); }
        });
    }

    /**
     * Custom click handler attached ONLY to newly created wrapper anchors.
     * Prevents the anchor's default navigation and stops the event from propagating
     * to potentially conflicting JS listeners on the original wrapped element.
     * Handles opening in new tab (Ctrl/Cmd/Middle-click) or same tab manually.
     * Uses a guard flag (`isHandlingClick`) to prevent issues from rapid/double clicks.
     */
    function handleProductClick(event) {
        // Prevent re-entry if handler is already running
        if (isHandlingClick) return false;
        isHandlingClick = true;

        // Immediately stop the default action (navigation) and prevent event propagation
        event.preventDefault();
        event.stopPropagation();
        event.stopImmediatePropagation(); // Stop other listeners on this same element too

        const link = event.currentTarget; // The anchor element we attached the listener to
        const href = link.href;

        // Double-check the link is valid before navigating
        if (!href || !href.startsWith('http')) {
            console.warn("[AliFix v2.2] Click handler stopped non-navigable event.", event.target, href);
            setTimeout(() => { isHandlingClick = false; }, 10); // Reset flag after a short delay
            return false; // Ensure no fallback default action occurs
        }

        // Determine if a new tab is requested (Middle mouse, Ctrl+click, Cmd+click)
        const isMiddleClick = event.button === 1;
        const isCtrlClick = event.ctrlKey;
        const isMetaClick = event.metaKey; // Cmd on Mac
        const openInNewTab = isMiddleClick || isCtrlClick || isMetaClick;

        // Manually perform the navigation
        if (openInNewTab) {
             // Use GM_openInTab for better userscript integration if available/granted
             if (typeof GM_openInTab === 'function') {
                   // Open in background if Ctrl/Cmd was used, otherwise activate (for middle click)
                   GM_openInTab(href, { active: isMiddleClick, insert: true });
             } else {
                  console.warn("[AliFix v2.2] GM_openInTab not available/granted, using window.open.");
                  window.open(href, '_blank'); // Fallback
             }
        } else if (event.button === 0) { // Standard left click
            window.location.href = href; // Navigate in the current tab
        }

        // Reset the re-entry guard after a very short delay
        setTimeout(() => { isHandlingClick = false; }, 10);
        return false; // Standard practice to return false from handlers that prevent default
    }

    /**
     * Helper function to remove a wrapper element (typically an anchor)
     * while keeping its child nodes in the same position in the DOM.
     * @param {HTMLElement} wrapper - The element to remove.
     */
    function unwrapAnchor(wrapper) {
        const parent = wrapper.parentNode;
        if (!parent || !wrapper) return; // Safety check
        try {
            // Move all children out from wrapper to before the wrapper
            while (wrapper.firstChild) {
                parent.insertBefore(wrapper.firstChild, wrapper);
            }
            // Remove the now-empty wrapper if it's still attached to the original parent
            if (wrapper.parentNode === parent) {
                 parent.removeChild(wrapper);
            }
        } catch (e) { console.error("[AliFix v2.2] Error unwrapping element:", wrapper, e); }
    }

    // --- Initialization and Observation ---

     // Run the fixes once initially after the DOM is ready or loaded
     if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', fixLinks);
     } else {
        // DOM already loaded, run after a short delay to allow page JS to potentially settle
        setTimeout(fixLinks, 100);
     }

    // Create and start the MutationObserver to watch for dynamically loaded content
    observer = new MutationObserver(scheduleFixLinks); // Use the debounced scheduler
    observer.observe(document.body, {
        childList: true,  // Watch for addition/removal of nodes
        subtree: true     // Watch descendants as well
    });

    console.log("[AliExpress Product Link Fixer v2.2] Initialized.");

})();