Skip to content

Instantly share code, notes, and snippets.

@sp00n
Last active November 6, 2023 15:55
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save sp00n/341b0a56accbfaba29e8b13b255038e0 to your computer and use it in GitHub Desktop.
Save sp00n/341b0a56accbfaba29e8b13b255038e0 to your computer and use it in GitHub Desktop.
RCZ Bikeshop Newsletter Parser Userscript
// ==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