Beerizer Systembolaget export

Adds an Systembolaget export button to the top of the Beerizer.com cart.

  1. // ==UserScript==
  2. // @name Beerizer Systembolaget export
  3. // @namespace https://github.com/Row/beerizer-export-systembolaget
  4. // @version 0.8
  5. // @description Adds an Systembolaget export button to the top of the Beerizer.com cart.
  6. // The export result can be verifed in the Systembolaget.se cart.
  7. // @author Row
  8. // @match https://beerizer.com/*
  9. // @match https://www.systembolaget.se/*
  10. // @grant GM.setValue
  11. // @grant GM.getValue
  12. // @run-at document-body
  13. // ==/UserScript==
  14.  
  15. const STATE_KEY = 'STATE_KEY';
  16. const STATE_UNDEF = null;
  17. const STATE_INIT = 'INIT';
  18. const STATE_PENDING = 'PENDING';
  19. const STATE_DONE = 'DONE';
  20. const STATE_ERROR = 'ERROR';
  21. const STATE_CANCEL = 'CANCEL';
  22. const INITIAL_STATE = {
  23. state: STATE_UNDEF,
  24. index: 0,
  25. beers: [],
  26. };
  27.  
  28. const PROGRESS_ID = 'beerizer-progress';
  29.  
  30. const makeTag = tag => parent => parent.appendChild(document.createElement(tag));
  31.  
  32. const a = makeTag('a');
  33. const button = makeTag('button');
  34. const div = makeTag('div');
  35. const table = makeTag('table');
  36. const td = makeTag('td');
  37. const tr = makeTag('tr');
  38. const aLink = (parent, { href, title }) => {
  39. let el;
  40. if (href) {
  41. el = a(parent);
  42. el.setAttribute('href', href);
  43. } else {
  44. el = parent;
  45. }
  46. el.innerText = title || 'Unknown';
  47. return el;
  48. };
  49. const tdr = parent => {
  50. const t = td(parent);
  51. t.style.padding = '0.3em';
  52. return t;
  53. };
  54.  
  55. const getElementByXpath = (xpath) =>
  56. document.evaluate(
  57. xpath,
  58. document,
  59. null,
  60. XPathResult.FIRST_ORDERED_NODE_TYPE,
  61. null,
  62. ).singleNodeValue;
  63.  
  64. const waitForElement = (xpath, timeout = 5000, interval = 100, shouldInverse = false) => {
  65. const start = (new Date()).getTime();
  66. return new Promise((resolve, reject) => {
  67. const tryElement = () => {
  68. const element = getElementByXpath(xpath);
  69. if ((!!element) !== shouldInverse) {
  70. resolve(element);
  71. return;
  72. }
  73. if (((new Date()).getTime() - start) > timeout) {
  74. reject(xpath);
  75. }
  76. window.setTimeout(tryElement, interval);
  77. };
  78. tryElement();
  79. });
  80. };
  81.  
  82. // Systembolaget related
  83. const URL_START = 'https://www.systembolaget.se';
  84. const URL_CART = 'https://www.systembolaget.se/varukorg';
  85.  
  86. const XPATH_CART_INS = '//h1[./span[text()="Varukorg"] or text()="Varukorg"]';
  87. const XPATH_CART = `//div[
  88. text()="Varukorgen är tom."
  89. or (starts-with(text(), "Du har ") and contains(text(), "varor i korgen"))]`;
  90. const XPATH_CONFIRM_AGE = '//button[text()="Jag har fyllt 20 år"]';
  91. const XPATH_CONFIRM_COOKIE = '//button[text()="Slå på och acceptera alla kakor"]';
  92. const XPATH_ADD_TO_CART_BTN = '//button[text()="Lägg i varukorg"]';
  93. const XPATH_VERIFY_ADD = '//button[text()="Tillagd"]';
  94. const XPATH_MODAL = '//button[@id="initialTgmFocus"]';
  95. const XPATH_BEER_TITLE = '//h1[./p]';
  96. const XPATH_SHIP_METHOD = '//div[text()="Välj leveranssätt "]';
  97.  
  98. const cancelExport = async (state) => {
  99. const { index, beers } = state;
  100. for (let i = index; i < beers.length; i += 1) {
  101. beers[i].state = STATE_CANCEL;
  102. beers[i].error = 'cancelled';
  103. }
  104. doneSystemBolaget({ ...state, index: beers.length - 1 });
  105. };
  106.  
  107. const renderProgress = (state) => {
  108. const overlay = div(document.body);
  109. overlay.id = PROGRESS_ID;
  110. overlay.style.cssText = `
  111. align-items: center;
  112. background: #FFF;
  113. display: flex;
  114. flex-flow: column;
  115. height: 100vh;
  116. justify-content: center;
  117. left: 0;
  118. position: fixed;
  119. top: 0;
  120. transition: height 0.3s;
  121. width: 100vw;
  122. z-index: 1337;
  123. `;
  124. const done = state.beers.filter(({ state }) => state !== STATE_INIT).length;
  125. const total = state.beers.length;
  126. const percent = (done / total) * 100;
  127. const bar = div(overlay);
  128. bar.style.cssText = `
  129. margin: 0 20em;
  130. background: lightgrey;
  131. `;
  132. const progress = div(bar);
  133. progress.style.cssText = `
  134. background: #fbd533;
  135. color: #fff;
  136. overflow: visible;
  137. padding: 1em;
  138. text-align: right;
  139. text-shadow: rgb(95 92 92) 1px 1px 2px;
  140. white-space: nowrap;
  141. width: ${percent}%;
  142. `;
  143. progress.innerText = `EXPORTING BEER ${done} OF ${total}`;
  144. const cancelButton = button(overlay);
  145. cancelButton.innerText = 'Cancel export';
  146. cancelButton.style.cssText = `
  147. background: white;
  148. border: 1px solid red;
  149. color: red;
  150. cursor: pointer;
  151. font-size: 0.7rem;
  152. margin-top: 1rem;
  153. `;
  154. cancelButton.addEventListener('click', () => cancelExport(state));
  155. };
  156.  
  157. const renderResult = async (state) => {
  158. const div = document.createElement('div');
  159. await waitForElement(XPATH_CART);
  160. const insertElement = await waitForElement(XPATH_CART_INS);
  161. insertElement.after(div);
  162. div.innerHTML = `<h2>Beerizer exported ${state.beers.length} beers</h2>`;
  163. const exportTable = table(div);
  164. state.beers.map(({
  165. beerizerHref,
  166. beerizerTitle,
  167. error,
  168. state,
  169. systemBolagetHref,
  170. systemBolagetTitle,
  171. }, index) => {
  172. const row = tr(exportTable);
  173. tdr(row).innerText = index + 1;
  174. aLink(tdr(row), {
  175. href: beerizerHref,
  176. title: beerizerTitle,
  177. });
  178. tdr(row).innerText = '➜';
  179. aLink(tdr(row), {
  180. href: systemBolagetHref,
  181. title: systemBolagetTitle,
  182. });
  183. tdr(row).innerText = state === STATE_DONE ? '✅' : '⚠️';
  184. tdr(row).innerText = error ? error : state;
  185. });
  186. };
  187.  
  188. const doneSystemBolaget = async (state) => {
  189. GM.setValue(STATE_KEY, { ...state, state: STATE_DONE });
  190. window.location.href = URL_CART;
  191. };
  192.  
  193. const initSystemBolaget = async (state) => {
  194. if (state.beers.length === 0) {
  195. await doneSystemBolaget(state);
  196. } else {
  197. try {
  198. const btn = await waitForElement(XPATH_CONFIRM_AGE, 2000);
  199. btn.click();
  200. } catch (e) {
  201. console.log('tried to accept age');
  202. }
  203. try {
  204. const btn = await waitForElement(XPATH_CONFIRM_COOKIE, 2000);
  205. btn.click();
  206. } catch (e) {
  207. console.log('tried to accept cookie');
  208. }
  209. await GM.setValue(STATE_KEY, { ...state, state: STATE_PENDING });
  210. window.location.href = state.beers[0].href;
  211. }
  212. };
  213.  
  214. const addBeerSystembolaget = async (state) => {
  215. const { index, beers } = state;
  216. const beer = state.beers[index];
  217. beer.systemBolagetHref = window.location.href;
  218. try {
  219. const beerHeader = await waitForElement(XPATH_BEER_TITLE);
  220. if (!beerHeader) {
  221. throw Error('Beer not found?');
  222. }
  223. beer.systemBolagetTitle = beerHeader.innerText;
  224. const cartBtn = await waitForElement(XPATH_ADD_TO_CART_BTN);
  225. cartBtn.click();
  226. try {
  227. await waitForElement(XPATH_VERIFY_ADD, 2000, 100);
  228. } catch (e) {
  229. if (!getElementByXpath(XPATH_SHIP_METHOD)) throw e;
  230. const progress = document.getElementById(PROGRESS_ID);
  231. progress.style.height = '100px';
  232. await waitForElement(XPATH_MODAL, 1000 * 120, 100, true);
  233. const cartBtn = await waitForElement(XPATH_ADD_TO_CART_BTN);
  234. cartBtn.click();
  235. await waitForElement(XPATH_VERIFY_ADD, 2000, 100);
  236. progress.style.height = '100vh';
  237. }
  238. beer.state = STATE_DONE;
  239. } catch (error) {
  240. beer.state = STATE_ERROR;
  241. beer.error = error.message;
  242. }
  243. const nextIndex = index + 1;
  244. if (beers.length <= nextIndex) {
  245. await doneSystemBolaget(state);
  246. } else {
  247. await GM.setValue(STATE_KEY, { ...state, index: nextIndex });
  248. window.location.href = beers[nextIndex].href;
  249. }
  250. };
  251.  
  252. const handleSystembolaget = async () => {
  253. const state = await GM.getValue(STATE_KEY, INITIAL_STATE);
  254. if (state.beers.length > 0 && state.state !== STATE_DONE) {
  255. renderProgress(state);
  256. }
  257. if (state.state === STATE_INIT) {
  258. await initSystemBolaget(state);
  259. } else if (state.state === STATE_PENDING) {
  260. window.addEventListener('load', () => {
  261. addBeerSystembolaget(state);
  262. });
  263. }
  264. if (window.location.pathname === '/varukorg/') {
  265. renderResult(state);
  266. }
  267. };
  268.  
  269.  
  270. // Beerizer parts
  271. const XPATH_CART_MENU_BUTTON = '//a[@class="cart-link" and ./span[text()="Share"]]';
  272. const SELECT_OPEN_CART_BUTTON = 'div.cart-wrapper.collapsed>div.summary';
  273. const SELECT_SB_REF_LINKS = 'a[title="To Systembolaget"]';
  274. const SELECT_TITLE = '.CartProductTable td.name>a';
  275.  
  276. const exportCart = async () => {
  277. const titles = [...document.querySelectorAll(SELECT_TITLE)].map(l => ({
  278. beerizerHref: l.href,
  279. beerizerTitle: l.innerText,
  280. }));
  281. const hrefs = [...document.querySelectorAll(SELECT_SB_REF_LINKS)].map(l => l.href);
  282. const beers = [...new Set(hrefs)].map((href, i) => ({
  283. ...titles[i],
  284. href,
  285. state: STATE_INIT,
  286. }));
  287. const state = {
  288. ...INITIAL_STATE,
  289. state: STATE_INIT,
  290. beers,
  291. };
  292. await GM.setValue(STATE_KEY, state);
  293. const w = window.open('', 'systembolaget');
  294. w.location = URL_START;
  295. };
  296.  
  297. const renderButton = () => {
  298. const cl = getElementByXpath(XPATH_CART_MENU_BUTTON);
  299. if (!cl) return;
  300. const e = cl.cloneNode(2);
  301. cl.after(e);
  302. e.querySelector('span').innerText = 'Export Systembolaget';
  303. e.addEventListener('click', exportCart);
  304. };
  305.  
  306. const handleBeerizer = () => {
  307. const btnEl = document.querySelector(SELECT_OPEN_CART_BUTTON);
  308. btnEl.addEventListener('click', () => {
  309. window.setTimeout(renderButton, 100);
  310. });
  311. };
  312.  
  313. // initialize
  314. (() => {
  315. 'use strict';
  316. const hostname = window.location.hostname;
  317. if (hostname.includes('beerizer')) {
  318. window.addEventListener('load', () => {
  319. handleBeerizer();
  320. });
  321. }
  322. if (hostname.includes('systembolaget')) {
  323. handleSystembolaget();
  324. }
  325. })();