// ==UserScript== // @name RunRepeat Review Summaries on Shoe Sites // @namespace https://github.com/sinazadeh/userscripts // @version 1.2.2 // @description Injects RunRepeat reviews onto product pages of major shoe brands. // @author TheSina // @match https://www.nike.com/* // @match https://www.adidas.com/* // @match https://www.newbalance.com/* // @match https://www.asics.com/* // @match https://www.brooksrunning.com/* // @match https://www.hoka.com/* // @match https://www.saucony.com/* // @match https://www.altrarunning.com/* // @match https://www.on.com/* // @grant GM_xmlhttpRequest // @connect runrepeat.com // @license MIT // @downloadURL https://raw.githubusercontent.com/sinazadeh/runrepeat/refs/heads/main/RunRepeat_Review_Summaries_on_Shoe_Sites.user.js // @updateURL https://raw.githubusercontent.com/sinazadeh/runrepeat/refs/heads/main/RunRepeat_Review_Summaries_on_Shoe_Sites.meta.js // ==/UserScript== /* jshint esversion: 11 */ (function () { "use strict"; let reviewData = null; let currentSlug = null; let currentConfig = null; let isFetching = false; let hasFailed = false; let lastUrl = location.href; let shoeDatabase = null; const siteConfigs = { "www.adidas.com": { brand: "adidas", getSlug: () => { const el = document.querySelector('h1[data-testid="product-title"]'); if (!el) return null; let productName = el.textContent .trim() .toLowerCase() .replace(/\s+/g, "-") .replace(/[^a-z0-9-]/g, ""); // Remove special characters productName = productName.replace( /-(training|golf|running|basketball)?-shoes$/, "" ); // Remove specific suffixes return `adidas-${productName}`; // Prepend the brand name to the slug }, injectionTarget: '[data-testid="buy-section"], .product-description', injectionMethod: "after", }, "www.brooksrunning.com": { brand: "brooks", getSlug: () => { const productName = document .querySelector("h1.m-buy-box-header__name") ?.textContent.trim() .toLowerCase() .replace(/\s+/g, "-") || null; return productName ? `brooks-${productName}` : null; }, injectionTarget: ".m-buy-box .js-pdp-add-cart-btn", injectionMethod: "after", }, "www.hoka.com": { brand: "hoka", getSlug: () => { const productName = document .querySelector('h1[data-qa="productName"]') ?.textContent.trim() .toLowerCase() .replace(/\s+/g, "-") || null; return productName ? `hoka-${productName}` : null; }, injectionTarget: "div.product-primary-attributes", injectionMethod: "after", }, "www.on.com": { brand: "on", getSlug: () => { const el = document.querySelector( 'h1[data-test-id="productNameTitle"]' ); if (!el) return null; const clone = el.cloneNode(true); clone.querySelectorAll("span").forEach((span) => span.remove()); const productName = clone.textContent .trim() .toLowerCase() .replace(/\s+/g, "-"); return `on-${productName}`; // Prepend the brand name to the slug }, injectionTarget: '[data-test-id="cartButton"]', injectionMethod: "after", }, "www.newbalance.com": { brand: "new-balance", getSlug: () => { const el = document.querySelector( "#productDetails h1, h1.product-name" ); if (!el) return null; let txt = el.textContent.trim(); txt = txt .replace(/(\d)(v\d+)/gi, "$1 $2") .replace(/([a-z])([A-Z])/g, "$1 $2"); const productName = txt.toLowerCase().replace(/\s+/g, "-"); return `new-balance-${productName}`; }, injectionTarget: ".prices-add-to-cart-actions", injectionMethod: "after", }, "www.asics.com": { brand: "asics", getSlug: () => { const productName = document .querySelector("h1.pdp-top__product-name__not-ot") ?.textContent.trim() .toLowerCase() .replace(/\s+/g, "-") || null; return productName ? `asics-${productName}` : null; }, injectionTarget: ".pdp-top__cta.product-add-to-cart", injectionMethod: "after", }, "www.nike.com": { brand: "nike", getSlug: () => { const el = document.querySelector("#pdp_product_title"); if (!el) return null; let productName = el.textContent .trim() .toLowerCase() .replace(/\s+/g, "-"); // The title on Nike.com might already include "Nike", let's remove it to avoid duplication. if (productName.startsWith("nike-")) { productName = productName.substring(5); } return `nike-${productName}`; }, injectionTarget: '[data-testid="atb-button"]', injectionMethod: "after", }, "www.saucony.com": { brand: "saucony", getSlug: () => { const el = document.querySelector("h1.product-name-v2"); if (!el) return null; let productName = el.textContent .trim() .toLowerCase() .replace(/^(?:men's|women's)\s/i, "") .replace(/\s+/g, "-"); return `saucony-${productName}`; }, injectionTarget: ".add-to-cart-container", injectionMethod: "after", }, "www.altrarunning.com": { brand: "altra", getSlug: () => { const titleElement = document.querySelector( "h1.b-product_details-name" ); if (!titleElement) return null; return titleElement.textContent .trim() .toLowerCase() .replace(/^(men's|women's)\s+/i, "") // Remove gender prefix .replace(/\s+/g, "-"); }, injectionTarget: ".b-product_actions", injectionMethod: "after", }, }; function generateRunRepeatURLs(slug, brand) { if (!slug) return []; const cleanSlug = slug.replace(/-shoes$/, ""); const baseSlug = slug.startsWith(`${brand}-`) ? cleanSlug : `${brand}-${cleanSlug}`; // Avoid double brand name return [ `https://runrepeat.com/${baseSlug}`, `https://runrepeat.com/${baseSlug}-shoes`, ]; } function fetchAndParseRunRepeat(url) { return new Promise((resolve) => { GM_xmlhttpRequest({ method: "GET", url, onload: (res) => { if (res.status !== 200) return resolve(null); const doc = new DOMParser().parseFromString( res.responseText, "text/html" ); if (!doc.querySelector("#product-intro")) return resolve(null); resolve({ ...parseRunRepeat(doc), url }); }, onerror: () => resolve(null), }); }); } function findMatchingShoe(brand, slug) { if (!shoeDatabase) return null; console.log("[RunRepeat] Searching for slug:", slug); // Normalize the slug by removing terms like 'shoes', 'running shoes', etc. let normalizedSlug = slug.replace( /-(shoes|running-shoes|training-shoes|basketball-shoes)$/i, "" ); // Additional normalization for 'new-balance' if (brand === "new-balance") { normalizedSlug = normalizedSlug.replace(/^fuel-cell-/, "fuelcell-"); } const match = shoeDatabase.find((shoe) => { return shoe.brand === brand && shoe.name === normalizedSlug; }); if (match) { console.log( "[RunRepeat] Found match in database for slug:", normalizedSlug ); } else { console.log( "[RunRepeat] No match found in database for slug:", normalizedSlug ); } return match; } async function findValidRunRepeatPage(slug, brand) { const urls = generateRunRepeatURLs(slug, brand); console.log("[RunRepeat] Trying to match URLs:", urls); const results = await Promise.all(urls.map(fetchAndParseRunRepeat)); const validPage = results.find(Boolean); if (validPage) { console.log( "[RunRepeat] Found valid RunRepeat page for URL:", validPage.url ); } else { console.log("[RunRepeat] No valid RunRepeat page found for URLs:", urls); } return validPage || null; } function parseRunRepeat(doc) { const q = (sel) => doc.querySelector(sel)?.textContent.trim() || ""; const scoreEl = doc.querySelector( "#audience_verdict #corescore .corescore-big__score" ); return { verdict: q("#product-intro .product-intro-verdict + div"), pros: [...doc.querySelectorAll("#the_good ul li")].map((li) => li.textContent.trim() ), cons: [...doc.querySelectorAll("#the_bad ul li")].map((li) => li.textContent.trim() ), audienceScore: parseInt(scoreEl?.textContent.trim() || "0", 10), scoreText: q("#audience_verdict .corescore-big__text"), awards: [ ...doc.querySelectorAll( "#product-intro ul.awards-list li, #audience_verdict ul.awards-list li" ), ].map((li) => li.textContent.replace(/\s+/g, " ").trim()), }; } function createRunRepeatSection(data) { const scoreColorMap = { superb: "#098040", great: "#098040", good: "#54cb62", decent: "#ffb717", bad: "#eb1c24", }; const scoreKey = (data.scoreText || "").replace("!", "").toLowerCase(); const scoreColor = scoreColorMap[scoreKey] || "#6c757d"; const section = document.createElement("div"); section.className = "runrepeat-section"; section.style.cssText = `border:1px solid #e0e0e0; border-radius:8px; padding:20px; margin:20px 0; background:#fdfdfd; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;`; section.innerHTML = `