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.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();

})();