Instantly share code, notes, and snippets.
Last active
November 6, 2023 15:55
-
Star
(0)
0
You must be signed in to star a gist -
Fork
(0)
0
You must be signed in to fork a gist
-
Save sp00n/341b0a56accbfaba29e8b13b255038e0 to your computer and use it in GitHub Desktop.
RCZ Bikeshop Newsletter Parser Userscript
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name RCZ Bikeshop Newsletter Parser | |
// @namespace net.sp00n.rcz | |
// @match https://go.mail-coach.com/t/* | |
// @grant none | |
// @version 1.03 | |
// @author sp00n | |
// @description Parses the links for a rczbikeshop.com newsletter and tries to list the products in a readable manner | |
// @downloadURL https://gist.github.com/sp00n/341b0a56accbfaba29e8b13b255038e0 | |
// ==/UserScript== | |
// -------------------------- Some debug settings -------------------------- | |
window.isDebug = false; | |
const Debugger = function(globalState, myClass) { | |
this.debug = {}; | |
const isGlobal = !!(myClass.toString() === "[object Window]" && myClass.window); | |
if ( globalState && myClass.isDebug ) { | |
for ( const m in console ) { | |
if ( typeof console[m] == "function" ) { | |
if ( isGlobal ) { | |
this.debug[m] = console[m].bind(window.console); | |
} | |
else { | |
this.debug[m] = console[m].bind(window.console, myClass.toString() + ": "); | |
} | |
} | |
} | |
} | |
else { | |
for ( const m in console ) { | |
if ( typeof console[m] == "function" ) { | |
this.debug[m] = function(){}; | |
} | |
} | |
} | |
return this.debug; | |
}; | |
const debug = Debugger(isDebug, this); | |
// -------------------------- The styles for the parsed entries -------------------------- | |
const offerTableStyles = ` | |
#offerTableContainer, | |
#offerTableContainer > * { | |
box-sizing: border-box; | |
} | |
#offerTableContainer { | |
width: 100%; | |
background-color: #000000; | |
padding: 8px 8px 80px 8px; | |
} | |
#offerTable { | |
border: 1px solid #444; | |
margin: 0 auto; | |
font-family: sans-serif; | |
border-collapse: collapse; | |
background-color: #555; | |
color: #CCC; | |
} | |
#offerTable th, | |
#offerTable td { | |
border: 1px solid #444; | |
padding: 2px 8px; | |
} | |
#offerTable th { | |
font-size: x-large; | |
} | |
#offerTable td { | |
height: 1.8em; | |
} | |
/* Striped colors */ | |
#offerTable tbody tr:nth-child(even) { | |
background-color: #151515; | |
} | |
#offerTable tbody tr:nth-child(odd) { | |
background-color: #262626; | |
} | |
#offerTable tbody tr:hover { | |
background-color: #333; | |
} | |
#offerTable a { | |
text-decoration: none; | |
color: #EEEEEE; | |
} | |
#offerTable del { | |
font-size: x-small; | |
display: block; | |
text-decoration: none; | |
font-weight: normal; | |
color: #999; | |
} | |
#offerTable del.percentage { | |
font-size: small; | |
} | |
#offerTable em { | |
font-style: normal; | |
} | |
#offerTable em.cc { | |
color: #BBBB00; | |
} | |
#offerTable em.maxUnits { | |
color: #00FFFF; | |
} | |
/* The name/link of the offer */ | |
#offerTable tr td:nth-child(1) { | |
padding-left: 8px; | |
} | |
#offerTable tr td:nth-child(1) > div { | |
display: flex | |
} | |
#offerTable tr td:nth-child(1) > div *:nth-child(1) { | |
width: 100%; | |
} | |
#offerTable tr td:nth-child(1) > div *:nth-child(2) { | |
flex: 1; | |
width: 2em; | |
font-size: small; | |
filter: grayscale(0.65); | |
} | |
/* The price */ | |
#offerTable tr td:nth-child(2) { | |
font-weight: bold; | |
text-align: center; | |
} | |
/* The code */ | |
#offerTable tr td:nth-child(3) { | |
font-weight: bold; | |
text-align: center; | |
font-size: 1.1em; | |
color: #AA0000; | |
} | |
/* The validity date */ | |
#offerTable tr td:nth-child(4) { | |
text-align: right; | |
} | |
/* Notes */ | |
#offerTable tr td:nth-child(5) { | |
text-align: right; | |
} | |
`; | |
const styleSheet = document.createElement("style"); | |
styleSheet.textContent = offerTableStyles; | |
document.head.appendChild(styleSheet); | |
// -------------------------- Try to translate some french expressions -------------------------- | |
const TRANSLATIONS = new Map([ | |
// Composite words first | |
["Adaptateur de frein", "Disc Brake Adapter"], | |
["Paire de roues", "Wheelset"], | |
["Paire de freins à disc", "Pair Disc Brakes"], | |
["Paire de freins disc", "Pair Disc Brakes"], | |
["Paire Freins à Disc", "Pair Disc Brakes"], | |
["Paire de freins", "Pair Disc Brakes"], | |
["Frein à Disc", "Disc Brake"], | |
["Frein à Disque", "Disc Brake"], | |
["Groupe complet", "Full Groupset"], | |
["Tige de Selle Télescopique", "Dropper Seatpost"], | |
["Tige de Selle Dropper", "Dropper Seatpost"], | |
["Tige de Selle", "Seatpost"], | |
["Paire de Pédales", "Pair Pedals"], | |
["Paire de Pédale", "Pair Pedals"], | |
["Boitier de Pédalier", "Bottom Bracket"], | |
["Jeu de direction", "Headset"], | |
["Paire de Chaussures", "Shoes"], | |
["VELO COMPLET", "COMPLETE BIKE"], | |
["VTT COMPLE", "COMPLETE BIKE"], | |
["Cintre Droit", "Flat Handlebar"], | |
["Blocage de roue", "Wheel Axle"], // Theoreticall this is just an ordinary axle, not necessarily a quick release one | |
["Couvre Chaussures", "Shoe Covers"], | |
["Paire de Gants", "Gloves"], | |
["Fonds de Jante", "Rim Tape"], | |
["Fourche", "Fork"], | |
["Disque", "Disc"], | |
["Amortisseur", "Rear Shock"], | |
["Dérailleur", "Derailleur"], | |
["Pédalier", "Chainset"], | |
["Paire", "Pair"], | |
["Cadre", "Frameset"], | |
["Potence", "Stem"], | |
["Pontence", "Stem"], // Spelling error? | |
["Couronne", "Chainring"], | |
["Porte-Bidon", "Bottle Cage"], | |
["Chaine", "Chain"], | |
["Moyeux", "Hubs"], | |
["Moyeu", "Hub"], | |
["Etrier", "Brake Caliper"], | |
["Cintre", "Handlebar"], | |
["Roue", "Wheel"], | |
["Casque", "Helmet"], | |
["Pneu", "Tyre"], | |
["Manivelle", "Crank Arm"], | |
["Groupe", "Groupset"], | |
["Frein", "Disc Brake"], | |
["Jante", "Rim"], | |
["Chaussettes", "Socks"], | |
["Hydraulique", "Hydraulic"], | |
["Taille", "Size"], | |
["Selle", "Saddle"], // Unfortunately also replaces Selle Italia with Saddle Italia (we're fixing this below) | |
["Carbone", "Carbon"], | |
["Blanc", "White"], | |
["Bleu", "Blue"], | |
["Gris", "Gray"], | |
["Jaune", "Yellow"], | |
["Marron", "Brown"], | |
["Noir", "Black"], | |
["Rose", "Pink"], | |
["Rouge", "Red"], | |
["Vert", "Green"], | |
["Violet", "Purple"], | |
["ARRIERE", "REAR"], | |
["Arrière", "REAR"], | |
["AVANT", "FRONT"], | |
["GAUCHE", "LEFT"], | |
["DROITE", "RIGHT"], | |
// Special | |
["\\s{2,}", " "], // Multiple spaces into one | |
["Saddle\\s?Italia", "SELLE ITALIA"], // Fix for Selle Italia | |
]); | |
// -------------------------- Parse the HTML -------------------------- | |
// This holds all the offers | |
const allOffers = []; | |
// The original newsletter table | |
const originalNewsletterTable = document.querySelector("table"); | |
// Create our new table | |
let offerTableContainer = document.createElement("div"); | |
offerTableContainer.setAttribute("id", "offerTableContainer"); | |
offerTableContainer.innerHTML = ` | |
<table id="offerTable"> | |
<thead> | |
<tr> | |
<th>Offer</th> | |
<th>Price</th> | |
<th>Code</th> | |
<th>Valid until</th> | |
<th>Notes</th> | |
</tr> | |
</thead> | |
<tbody> | |
</tbody> | |
</table> | |
`; | |
const offerTableBody = offerTableContainer.querySelector("#offerTable tbody"); | |
// Get all the section links so that we can all the offers | |
const sectionLinks = originalNewsletterTable.querySelectorAll("a[name]"); | |
// <a> | |
// <div> Headline | |
// <div> Content | |
// <p> <span> <a> Link to item (or only the overview page, depends) | |
// <p> <span> <span> <strong> The coupon code | |
// <p> <span> <span> <strong> <u> Until when the offer stands | |
sectionLinks.forEach(sectionLink => { | |
const contentDiv = sectionLink.nextElementSibling.nextElementSibling; | |
debug.log("sectionLink:", sectionLink); | |
debug.group("Current contentDiv:", contentDiv); | |
// Get all the links | |
let offerLinks = [...contentDiv.querySelectorAll("a")].filter(entry => !!entry.innerText); | |
debug.log("offerLinks:", offerLinks); | |
let creditCard = false; | |
let creditCardEle = null; | |
let creditCardMatches = null; | |
let maxUnits = false; | |
let maxUnitsEle = null; | |
let maxUnitsMatches = null; | |
let percentage = null; | |
let percentageEle = null; | |
let percentageMatches = null; | |
let offerCodeEle = null; | |
let offerCode = ""; | |
// Some codes only work with a credit card! | |
// The line also sometimes contains the number of available units | |
// Paiement par cb uniquement | |
// PAIEMENT PAR CB uniquement =22ex | |
// Venda privada PAIEMENT CB ONLY : 3ex | |
// *Payment by credit card only | |
// Sometimes the credit card only text is directly after the section anchor | |
if ( (creditCardMatches = sectionLink.nextElementSibling.innerText.match(/cb uniquement|CB ONLY|credit card only/i)) !== null ) { | |
creditCard = true; | |
} | |
if ( (maxUnitsMatches = sectionLink.nextElementSibling.innerText.match(/(\d+)\s*ex/i)) !== null ) { | |
maxUnits = parseInt(maxUnitsMatches[1]); | |
} | |
// Check if there's a percentage value in one of the elements before the offerLinks | |
// Sometimes there's no price provided, only a percentage off | |
// Sometimes the percentage is directly after the section anchor | |
if ( (percentageMatches = sectionLink.nextElementSibling.innerText.match(/\d+\s?%/i)) !== null ) { | |
// There should be only one match | |
if ( percentageMatches && percentageMatches[0] ) { | |
percentage = percentageMatches[0]; | |
debug.log("Found a percentage:", percentage); | |
} | |
} | |
if ( offerLinks[0] ) { | |
// Sometimes the credit card only text is before the links themselves | |
if ( !creditCard ) { | |
creditCardEle = offerLinks[0].closest("p"); | |
while ( (creditCardEle = creditCardEle.previousElementSibling) !== null ) { | |
if ( !creditCardEle?.innerText || creditCardEle?.innerText?.trim() == "" ) { | |
continue; // No text found | |
} | |
creditCardMatches = creditCardEle.innerText.match(/cb uniquement|CB ONLY|credit card only/i); | |
// There should be only one match | |
if ( creditCardMatches && creditCardMatches[0] ) { | |
creditCard = true; | |
break; // Break the loop, we have found the text | |
} | |
} | |
} | |
// The limited stock may also be in before the product links | |
if ( !maxUnits ) { | |
maxUnitsEle = offerLinks[0].closest("p"); | |
while ( (maxUnitsEle = maxUnitsEle.previousElementSibling) !== null ) { | |
if ( !maxUnitsEle?.innerText || maxUnitsEle?.innerText?.trim() == "" ) { | |
continue; // No text found | |
} | |
maxUnitsMatches = maxUnitsEle.innerText.match(/(\d+)\s*ex/i); | |
// There should be two matches | |
if ( maxUnitsMatches && maxUnitsMatches[1] ) { | |
maxUnits = parseInt(maxUnitsMatches[1]); | |
break; // Break the loop, we have found the text | |
} | |
} | |
} | |
// Sometimes the percentage is before the links themselves | |
if ( !percentage ) { | |
percentageEle = offerLinks[0].closest("p"); | |
while ( (percentageEle = percentageEle.previousElementSibling) !== null ) { | |
if ( !percentageEle?.innerText || percentageEle?.innerText?.trim() == "" ) { | |
continue; // No text found | |
} | |
percentageMatches = percentageEle.innerText.match(/\d+\s?%/i); | |
// There should be only one match | |
if ( percentageMatches && percentageMatches[0] ) { | |
percentage = percentageMatches[0]; | |
debug.log("Found a percentage:", percentage); | |
break; // Break the loop, we have found our code | |
} | |
} | |
} | |
// Get the offer code, but there may not be one | |
debug.log("Trying to get the offer code"); | |
offerCodeEle = offerLinks[offerLinks.length - 1].closest("p"); | |
while ( (offerCodeEle = offerCodeEle.nextElementSibling) !== null ) { | |
debug.log("current offerCodeEle:", offerCodeEle, offerCodeEle?.innerText); | |
if ( !offerCodeEle?.innerText || offerCodeEle?.innerText?.trim() == "" ) { | |
continue; // No text found | |
} | |
if ( offerCodeEle?.innerText?.length < 10 ) { | |
continue; // The text was too short | |
} | |
// Get all the text inside <strong> and check for their validity | |
// Actually sometimes they seem to forget to use a <strong> tag, so just search all the text | |
// Try to find a word that starts with "RCZ", these are the codes | |
let offerCodeMatches = offerCodeEle.innerText.match(/\bRCZ\S+\b/ig); | |
// There should be only one match | |
if ( offerCodeMatches && offerCodeMatches[0] ) { | |
offerCode = offerCodeMatches[0]; | |
// There may be a missing space after the code. Take only uppercase letters and numbers | |
offerCode = offerCode.replace(/[^A-Z0-9]/g, ""); | |
break; // Break the loop, we have found our code | |
} | |
} | |
debug.log("offerCode:", offerCode); | |
} | |
// Get the offer validity date | |
let offerDateOriginal = ""; | |
let offerDateParsed = ""; | |
// If there's no offer code, there's probably also no offer date | |
if ( offerCode.length > 0 ) { | |
debug.info("Trying to get the validity date"); | |
let currentNode = offerCodeEle.closest("p"); | |
while ( (currentNode = currentNode.nextElementSibling) !== null ) { | |
debug.log("currentNode for date:", currentNode); | |
offerDateOriginal = currentNode?.closest("p")?.innerText; | |
debug.log("current offerDateOriginal:", offerDateOriginal); | |
if ( !offerDateOriginal || offerDateOriginal.length == 0 ) { | |
continue; // No test found at all, continue with next element | |
} | |
// Offer available until thursday 28th September 2023 at midnight (CET) | |
// Offres valables jusqu'au vendredi 29 septembre 2023 à minuit (Heure Luxembourg) | |
// ("Offer available until thursday 28th September 2023 at midnight (CET)").match(/(\w+)\s+(\d+)[a-z]{0,2}\s+(\w+)\s+(\d{4})/i) | |
// ("Offres valables jusqu'au vendredi 29 septembre 2023 à minuit (Heure Luxembourg)").match(/(\w+)\s+(\d+)[a-z]{0,2}\s+(\w+)\s+(\d{4})/i) | |
let dateMatches = offerDateOriginal.match(/(\w+)\s+(\d+)[a-z]{0,2}\s*(\w+)\s+(\d{4})/i); | |
if ( !dateMatches || !dateMatches[4] ) { | |
continue; // No date text found, continue with next element | |
} | |
const frenchMonthNames = ["janvier", "février", "mars", "avril", "mai", "juin", "juillet", "aout", "septembre", "octobre", "novembre", "décembre"]; | |
const spanishMonthNames = ["enero", "febrero", "marzo", "abril", "mayo", "junio", "julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre"]; | |
const italienMonthNames = ["gennaio", "febbraio", "marzo", "aprile", "maggio", "giugno", "luglio", "agosto", "settembre", "ottobre", "novembre", "dicembre"]; | |
const germanMonthNames = ["januar", "februar", "märz", "april", "mai", "juni", "juli", "august", "september", "oktober", "november", "dezember"]; | |
const englishMonthNames = ["january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december"]; | |
debug.log("dateMatches[3]:", dateMatches[3]); | |
let monthName = dateMatches[3].toLowerCase(); | |
// Check for month names in other languages or spelling errors | |
if ( frenchMonthNames.indexOf(monthName) > -1 ) { | |
monthName = englishMonthNames[frenchMonthNames.indexOf(monthName)]; | |
} | |
else if ( spanishMonthNames.indexOf(monthName) > -1 ) { | |
monthName = englishMonthNames[spanishMonthNames.indexOf(monthName)]; | |
} | |
else if ( italienMonthNames.indexOf(monthName) > -1 ) { | |
monthName = englishMonthNames[italienMonthNames.indexOf(monthName)]; | |
} | |
else if ( germanMonthNames.indexOf(monthName) > -1 ) { | |
monthName = englishMonthNames[germanMonthNames.indexOf(monthName)]; | |
} | |
else if ( monthName == "semptembre" ) { | |
monthName = englishMonthNames[8]; | |
} | |
debug.log("monthName:", monthName); | |
let monthIndex = englishMonthNames.indexOf(monthName.toLowerCase()); | |
debug.log("monthIndex:", monthIndex); | |
let offerDateString = `${dateMatches[4]}-${monthIndex+1}-${dateMatches[2]}`; | |
debug.log("offerDateString:", offerDateString); | |
let offerDateObj = new Date(offerDateString); | |
debug.log("offerDateObj:", offerDateObj); | |
offerDateParsed = offerDateObj.toLocaleDateString("de-DE", { weekday: "long", year: "numeric", month: "long", day: "numeric" }); | |
break; // Finished | |
} | |
} | |
// Build the new offer entry | |
offerLinks.forEach(link => { | |
debug.group(link); | |
// Ignore if text is too short | |
if ( link.innerText.length < 15 ) { | |
debug.groupEnd(); | |
return; | |
} | |
let newEntry = {}; | |
let oldLinkText = link.closest("p").innerText; | |
let newLinkText = oldLinkText.split("=")[0].trim(); | |
debug.groupCollapsed("Translation Regex"); | |
// Translate the text | |
for ( let [french, english] of TRANSLATIONS ) { | |
let regex = new RegExp(`${french}`, "ig"); | |
debug.log("regex", regex); | |
debug.log("english", english); | |
newLinkText = newLinkText.replace(regex, english); | |
} | |
debug.groupEnd(); | |
newEntry.anchor = sectionLink.getAttribute("name"); | |
newEntry.name = newLinkText; | |
newEntry.link = link.getAttribute("href"); | |
newEntry.code = offerCode; | |
debug.log("oldLinkText:", oldLinkText); | |
let priceTextEle = document.createDocumentFragment();; | |
let oldPriceEle = document.createElement("del"); | |
let hasPrice = false; | |
// The prices are separated with a "=" (so far) | |
if ( oldLinkText.indexOf("=") > -1 ) { | |
let priceText = oldLinkText.split("=")[1].trim(); | |
debug.log("priceText:", priceText); | |
// au lieu de | |
// anstatt | |
let priceMatches = priceText.match(/(\d{0,9}[\.,]?\d{0,2})e{0,3} [a-z ]+ (\d+[\.,]?\d{0,2})e{0,3}/i); | |
debug.log("priceMatches:", priceMatches); | |
if ( priceMatches && priceMatches.length > 2 ) { | |
oldPriceEle.append(document.createTextNode(`(${(priceMatches?.[2]?.replace(",", ".") || "")}€)`)); | |
priceTextEle.append(document.createTextNode(`${(priceMatches?.[1]?.replace(",", ".") || "")}€`)); | |
priceTextEle.append(document.createElement("br")); | |
priceTextEle.append(oldPriceEle); | |
hasPrice = true; | |
} | |
} | |
// No prices were found | |
if ( !hasPrice ) { | |
// But maybe we have a percentage | |
if ( percentage ) { | |
oldPriceEle.append(document.createTextNode(`-${percentage}`)); | |
oldPriceEle.classList.add("percentage"); | |
priceTextEle.append(oldPriceEle); | |
} | |
else { | |
priceTextEle.append(document.createTextNode("")); | |
} | |
} | |
newEntry.price = priceTextEle; | |
newEntry.date = offerDateParsed; | |
let notes = ""; | |
// Credit card only / limited stock? | |
if ( creditCard ) { | |
notes = `<em class="cc">💳 Credit Card</em>`; | |
} | |
if ( maxUnits ) { | |
if ( notes.length > 0 ) { | |
notes += `<br>`; | |
} | |
notes += `<small><em class="maxUnits">${maxUnits}</em> unit${maxUnits > 1 ? "s" : ""} available</small>`; | |
} | |
newEntry.notes = notes; | |
debug.groupEnd(); | |
allOffers.push(newEntry); | |
}); | |
debug.groupEnd(); | |
}); | |
// Sort the offers by name | |
allOffers.sort((a, b) => { | |
const nameA = a.name.toUpperCase(); // ignore upper and lowercase | |
const nameB = b.name.toUpperCase(); // ignore upper and lowercase | |
if ( nameA < nameB ) { | |
return -1; | |
} | |
if ( nameA > nameB ) { | |
return 1; | |
} | |
// names can be equal | |
return 0; | |
}); | |
// Insert the entries into the table | |
allOffers.forEach(entry => { | |
let tr = document.createElement("tr"); | |
let td = document.createElement("td"); | |
let linkTd = td.cloneNode(); | |
let priceTd = td.cloneNode(); | |
let offerCodeTd = td.cloneNode(); | |
let offerDateTd = td.cloneNode(); | |
let notesTd = td.cloneNode(); | |
let linksDiv = document.createElement("div"); | |
let productDiv = document.createElement("div"); | |
let productLink = document.createElement("a"); | |
productLink.setAttribute("href", entry.link); | |
productLink.setAttribute("target", "_blank"); | |
productLink.innerText = entry.name; | |
let anchorLink = document.createElement("a"); | |
anchorLink.setAttribute("href", `#${entry.anchor}`); | |
anchorLink.setAttribute("title", "Go to original entry"); | |
anchorLink.classList.add("goto"); | |
anchorLink.innerText = "⤵️"; | |
productDiv.append(productLink); | |
linksDiv.append(productDiv); | |
linksDiv.append(anchorLink); | |
linkTd.append(linksDiv); | |
priceTd.append(entry.price); | |
offerCodeTd.append(document.createTextNode(entry.code)); | |
offerDateTd.append(document.createTextNode(entry.date)); | |
notesTd.innerHTML = entry.notes; | |
tr.append(linkTd); | |
tr.append(priceTd); | |
tr.append(offerCodeTd); | |
tr.append(offerDateTd); | |
tr.append(notesTd); | |
offerTableBody.append(tr); | |
}); | |
document.body.prepend(offerTableContainer); | |
// -------------------------- Event listeners -------------------------- | |
offerTableBody.addEventListener("click", (event) => { | |
if ( !event.target.classList.contains("goto") ) { | |
return; | |
} | |
const href = event.target.closest("a").previousElementSibling.querySelector("a").getAttribute("href"); | |
if ( !href ) { | |
return; | |
} | |
const originalLink = originalNewsletterTable.querySelector(`a[href="${href}"]`); | |
if ( !originalLink ) { | |
return; | |
} | |
event.preventDefault(); | |
//const name = originalLink.innerText; | |
originalLink.scrollIntoView(); | |
window.location.hash = `#${href}`; | |
}); | |
window.addEventListener("hashchange", (event) => { | |
if ( window.location.hash == "" ) { | |
window.scrollTo({top: 0}); | |
} | |
else { | |
const href = window.location.hash; | |
const originalLink = originalNewsletterTable.querySelector(`a[href="${href}"]`); | |
if ( !originalLink ) { | |
return; | |
} | |
originalLink.scrollIntoView(); | |
} | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment