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.

// ==UserScript==
// @name         AliExpress Product Link Fixer
// @namespace    http://tampermonkey.net/
// @version      2.3
// @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 (carefully)
            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.3] 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.3] Error reconnecting observer:", e);
            }
        } else { console.warn("[AliFix v2.3] 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 avoids unwrapping anchors that appear to be structural components (containing complex nested divs)
     * or specific excluded navigation elements.
     */
    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;

            // --- Exclusion Checks ---
            // 1. 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; } // Skip anchors without href
                const url = new URL(a.href, location.origin);

                // Only consider anchors without productIds for unwrapping
                if (!url.searchParams.has('productIds')) {

                    // --- Structural Check: Avoid unwrapping complex/structural anchors ---
                    let hasDirectDivChild = false;
                    for (let i = 0; i < a.children.length; i++) {
                        if (a.children[i].tagName === 'DIV') {
                            hasDirectDivChild = true;
                            break;
                        }
                    }

                    if (hasDirectDivChild) {
                        // This anchor contains a direct DIV child. It might be a structural element. Let's NOT unwrap it.
                         if (a && a.dataset) a.dataset.alifixDone = "1";
                         return; // Skip unwrapping
                    }
                    // --- End of Structural Check ---

                    // If we pass the checks, proceed to unwrap
                     if (a && a.dataset) a.dataset.alifixDone = "1"; // Mark before unwrapping
                     unwrapAnchor(a); // Remove the anchor, keep children

                }
                // Anchors with productIds are handled by rewriteAnchorsWithProductIds.

            } catch (e) {
                console.error("[AliFix v2.3] Error processing anchor for potential 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; // Skip already processed

            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) {
                    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;
                        }
                         if (a.dataset) a.dataset.alifixDone = "1"; // Mark as successfully processed
                    } else {
                        console.warn("[AliFix v2.3] Invalid PID format found:", pidParam, "in anchor:", a.href);
                        if (a.dataset) a.dataset.alifixDone = "1"; // Mark as processed even if PID format was invalid
                    }
                } else {
                     // Mark anchors without productIds as done here if they weren't unwrapped in phase 1
                     if (a.dataset) a.dataset.alifixDone = "1";
                }
            } catch (e) {
                console.error("[AliFix v2.3] 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, identified more robustly).
     * 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
            '.g6_cy[data-product-ids]:not([data-alifix-done]), ' +                // Specific class often used with data-product-ids
            // --- Robust Selectors for Bundle Deals Containers ---
            '#root div[mod-name*="waterfall"] div[id][class*="productContainer"]:not([data-alifix-done]), ' + // Waterfall view
            '#root div[mod-name*="goods-slider"] div[id][class*="productContainer"]:not([data-alifix-done]), ' + // Slider view
            // --- End Robust Selectors ---
            'div.jc_bt.jc_jl[data-product-ids]:not([data-alifix-done])'          // AliExpress Business section pattern
        );

        productIndicators.forEach(element => {
            let pid;
            let isBundleDealsDiv = false; // Flag if it's a Bundle Deals div needing wrapping

            if (!element || !element.dataset || element.dataset.alifixDone) return;

            // Determine the source of the Product ID
            if (element.dataset.productIds) {
                pid = element.dataset.productIds.split(':')[0]; // Handle potential extra chars
            } else if (element.tagName === 'DIV' && element.id && /^\d+$/.test(element.id)) {
                 // Check if it matches our robust selectors for Bundle Deals divs
                 if (element.matches('#root div[mod-name*="waterfall"] div[id][class*="productContainer"], #root div[mod-name*="goods-slider"] div[id][class*="productContainer"]')) {
                     pid = element.id;
                     isBundleDealsDiv = true; // Mark it for wrapping
                 } else {
                     // It's a div with a numeric ID, but doesn't match the Bundle Deals patterns we target
                     if (element.dataset) element.dataset.alifixDone = "1";
                     return;
                 }
            } else { if (element.dataset) element.dataset.alifixDone = "1"; return; } // No valid PID source found

            // Validate the extracted/found Product ID
            if (!pid || !/^\d+$/.test(pid)) {
                console.warn("[AliFix v2.3] Invalid PID found for element:", pid, element);
                if (element.dataset) 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 correctly wrapped by a *direct* parent anchor
            const parentAnchor = element.parentNode;
             if (parentAnchor && parentAnchor.tagName === 'A') {
                  if (parentAnchor.href === targetHref) {
                      if (!parentAnchor.dataset.alifixDone) parentAnchor.dataset.alifixDone = "1";
                      return; // Already handled
                  } else if (!parentAnchor.dataset.alifixDone) {
                       parentAnchor.href = targetHref;
                       parentAnchor.dataset.alifixDone = "1";
                       return; // Handled
                  } else { return; } // Already marked done
             }

            // 2. For elements identified by `data-product-ids`, check for an *inner* anchor to fix.
            //    Skip this check if the element itself is an anchor OR if it's a BundleDealsDiv (which we intend to wrap).
            if (!isBundleDealsDiv && element.tagName !== 'A') {
                const existingInnerAnchor = element.querySelector('a:not([data-alifix-link-added]):not([id*="/^\\d+$/"])');
                if (existingInnerAnchor && !existingInnerAnchor.dataset.alifixDone) {
                     if (existingInnerAnchor.href !== targetHref) {
                        existingInnerAnchor.href = targetHref;
                     }
                     existingInnerAnchor.dataset.alifixDone = "1";
                     return; // Handled
                }
            }

            // --- Create a new wrapper link if no suitable parent/inner link was found/fixed ---
            if (!element.parentNode) {
                console.warn(`[AliFix v2.3] Element for PID ${pid} lost its parent before wrapping.`);
                return;
            }

            const link = document.createElement('a');
            link.href = targetHref;
            link.dataset.alifixDone = "1";
            link.dataset.alifixLinkAdded = "1";
            link.style.display = 'block';
            link.style.color = 'inherit';
            link.style.textDecoration = 'none';
            link.style.cursor = 'pointer';

            link.addEventListener('click', handleProductClick, true);

            try {
                 element.parentNode.insertBefore(link, element);
                 link.appendChild(element);
                 // Disable pointer events on the original div to override AE's JS listeners
                 element.style.setProperty('pointer-events', 'none', 'important');
            } catch (e) { console.error(`[AliFix v2.3] Error wrapping element PID ${pid}:`, e, element); }
        });
    }


    /**
     * Custom click handler attached ONLY to newly created wrapper anchors.
     * Prevents the anchor's default navigation and stops the event from propagating.
     * 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) {
            event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); 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') || !href.includes('/item/')) {
            console.warn("[AliFix v2.3] Click handler stopped non-navigable/invalid link:", href, "Target:", event.target);
            setTimeout(() => { isHandlingClick = false; }, 50);
            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) {
             if (typeof GM_openInTab === 'function') {
                   try {
                        GM_openInTab(href, { active: isMiddleClick, insert: true });
                   } catch (gmErr) {
                       console.error("[AliFix v2.3] Error using GM_openInTab:", gmErr, "Falling back to window.open.");
                       window.open(href, '_blank'); // Fallback on GM error
                   }
             } else {
                  console.warn("[AliFix v2.3] GM_openInTab not available/granted, using window.open.");
                  window.open(href, '_blank'); // Fallback if GM function doesn't exist
             }
        } else if (event.button === 0) { // Standard left click
            window.location.href = href; // Navigate in the current tab
        }

        // Reset the re-entry guard after a short delay
        setTimeout(() => {
            isHandlingClick = false;
        }, 50);
        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 {
            while (wrapper.firstChild) {
                parent.insertBefore(wrapper.firstChild, wrapper);
            }
            if (wrapper.parentNode === parent) {
                 parent.removeChild(wrapper);
            }
        } catch (e) { console.error("[AliFix v2.3] 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, 150);
     }

    // Create and start the MutationObserver to watch for dynamically loaded content
    observer = new MutationObserver(scheduleFixLinks); // Use the debounced scheduler

    // Observe the body initially. If body isn't available yet, wait.
    function startObserver() {
        if (document.body) {
             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.3] Initialized and observing.");
        } else {
            console.warn("[AliFix v2.3] Document body not ready, retrying observer start...");
            setTimeout(startObserver, 100);
        }
    }
    startObserver();

})();