// ==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 // @downloadURL none // ==/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 (``) 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 `` 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."); })();