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