diff --git a/.env-example b/.env-example index 51ae3cab75..604f6c6ce8 100644 --- a/.env-example +++ b/.env-example @@ -13,6 +13,8 @@ IN_STOCK_WAIT_TIME="" LOG_LEVEL="" LOW_BANDWIDTH="" MICROCENTER_LOCATION="" +NVIDIA_ADD_TO_CART_ATTEMPTS="" +NVIDIA_SESSION_TTL="" OPEN_BROWSER="" PAGE_TIMEOUT="" PHONE_NUMBER="" diff --git a/README.md b/README.md index 5e57643c07..d372fb4255 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,8 @@ Here is a list of variables that you can use to customize your newly copied `.en | `LOG_LEVEL` | [Logging levels](https://github.com/winstonjs/winston#logging-levels) | Debugging related, default: `info` | | `LOW_BANDWIDTH` | Blocks images/fonts to reduce traffic | Disables ad blocker, default: `false` | | `MICROCENTER_LOCATION` | Specific MicroCenter location to search | Default : `web` | +| `NVIDIA_ADD_TO_CART_ATTEMPTS` | The maximum number of times the `nvidia-api` add to cart feature will be attempted before failing | Default: `10` | +| `NVIDIA_SESSION_TTL` | The time in seconds to keep the cart active while using `nvidia-api` | Default: `60000` | | `OPEN_BROWSER` | Toggle for whether or not the browser should open when item is found | Default: `true` | | `PAGE_TIMEOUT` | Navigation Timeout in milliseconds | `0` for infinite, default: `30000` | | `PHONE_NUMBER` | 10 digit phone number | E.g.: `1234567890`, email configuration required | diff --git a/src/config.ts b/src/config.ts index 81a747f67c..873c6c4cf3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -113,6 +113,11 @@ const notifications = { } }; +const nvidia = { + addToCardAttempts: envOrNumber(process.env.NVIDIA_ADD_TO_CART_ATTEMPTS, 10), + sessionTtl: envOrNumber(process.env.NVIDIA_SESSION_TTL, 60000) +}; + const page = { height: 1080, inStockWaitTime: envOrNumber(process.env.IN_STOCK_WAIT_TIME), @@ -135,6 +140,7 @@ export const Config = { browser, logLevel, notifications, + nvidia, page, store }; diff --git a/src/store/model/helpers/nvidia-cart.ts b/src/store/model/helpers/nvidia-cart.ts new file mode 100644 index 0000000000..da2a9ea505 --- /dev/null +++ b/src/store/model/helpers/nvidia-cart.ts @@ -0,0 +1,174 @@ +import {NvidiaRegionInfo, regionInfos} from '../nvidia-api'; +import {usingPage, usingResponse} from '../../../util'; +import {Browser} from 'puppeteer'; +import {Config} from '../../../config'; +import {Logger} from '../../../logger'; +import open from 'open'; + +interface NvidiaSessionTokenJSON { + session_token: string; +} + +interface NvidiaAddToCardJSON { + location: string; +} + +export class NvidiaCart { + protected readonly browser: Browser; + protected isKeepAlive = false; + protected sessionToken: string | null = null; + + public constructor(browser: Browser) { + this.browser = browser; + } + + public keepAlive() { + if (this.isKeepAlive) { + return; + } + + const callback = async () => { + if (!this.isKeepAlive) { + return; + } + + await this.refreshSessionToken(); + + setTimeout(callback, Config.nvidia.sessionTtl); + }; + + this.isKeepAlive = true; + + void callback(); + } + + public get fallbackCartUrl(): string { + return `https://www.nvidia.com/${this.regionInfo.siteLocale}/geforce/`; + } + + public get regionInfo(): NvidiaRegionInfo { + const country = Config.store.country; + const regionInfo = regionInfos.get(country); + if (!regionInfo) { + throw new Error(`Unknown country ${country}`); + } + + return regionInfo; + } + + public get sessionUrl(): string { + return `https://store.nvidia.com/store/nvidia/SessionToken?format=json&locale=${this.regionInfo.drLocale}`; + } + + public async addToCard(productId: number, name: string): Promise { + let cartUrl: string | undefined; + Logger.info(`🚀🚀🚀 [nvidia] ${name}, starting auto add to cart 🚀🚀🚀`); + try { + Logger.info(`🚀🚀🚀 [nvidia] ${name}, adding to cart 🚀🚀🚀`); + let lastError: Error | string | undefined; + + /* eslint-disable no-await-in-loop */ + for (let i = 0; i < Config.nvidia.addToCardAttempts; i++) { + try { + cartUrl = await this.addToCartAndGetLocationRedirect(productId); + + break; + } catch (error) { + Logger.error(`✖ [nvidia] ${name} could not automatically add to cart, attempt ${i + 1} of ${Config.nvidia.addToCardAttempts}`, error); + Logger.debug(error); + + lastError = error; + } + } + /* eslint-enable no-await-in-loop */ + + if (!cartUrl) { + // eslint-disable-next-line @typescript-eslint/no-throw-literal + throw lastError; + } + + Logger.info(`🚀🚀🚀 [nvidia] ${name}, opening checkout page 🚀🚀🚀`); + Logger.info(cartUrl); + + await open(cartUrl); + } catch (error) { + Logger.error(`✖ [nvidia] ${name} could not automatically add to cart, opening page`); + Logger.debug(error); + + cartUrl = this.fallbackCartUrl; + + await open(cartUrl); + } + + return cartUrl; + } + + public async getSessionToken(): Promise { + if (!this.sessionToken) { + await this.refreshSessionToken(); + } + + if (!this.sessionToken) { + throw new Error('Failed to create the session_token'); + } + + return this.sessionToken; + } + + public async refreshSessionToken(): Promise { + Logger.debug('ℹ [nvidia] refreshing session token'); + try { + const result = await usingResponse(this.browser, this.sessionUrl, async response => { + return response?.json() as NvidiaSessionTokenJSON | undefined; + }); + if (typeof result !== 'object' || result === null || !('session_token' in result)) { + throw new Error('malformed response'); + } + + this.sessionToken = result.session_token; + Logger.debug(`ℹ [nvidia] session_token=${result.session_token}`); + } catch (error) { + const message: string = typeof error === 'object' ? error.message : error; + Logger.error(`✖ [nvidia] ${message}`); + } + } + + protected async addToCartAndGetLocationRedirect(productId: number): Promise { + const url = 'https://api-prod.nvidia.com/direct-sales-shop/DR/add-to-cart'; + const sessionToken = await this.getSessionToken(); + + Logger.info(`ℹ [nvidia] session_token=${sessionToken}`); + + const locationData = await usingPage(this.browser, async page => { + page.removeAllListeners('request'); + + await page.setRequestInterception(true); + + page.on('request', interceptedRequest => { + void interceptedRequest.continue({ + headers: { + ...interceptedRequest.headers(), + 'content-type': 'application/json', + nvidia_shop_id: sessionToken + }, + method: 'POST', + postData: JSON.stringify({ + products: [ + {productId, quantity: 1} + ] + }) + }); + }); + + const response = await page.goto(url, {waitUntil: 'networkidle0'}); + + if (response === null) { + throw new Error('NvidiaAddToCartUnavailable'); + } + + return response.json() as Promise; + }); + + return locationData.location; + } +} diff --git a/src/store/model/helpers/nvidia.ts b/src/store/model/helpers/nvidia.ts index adfedd7ad1..f5b5b7e008 100644 --- a/src/store/model/helpers/nvidia.ts +++ b/src/store/model/helpers/nvidia.ts @@ -1,9 +1,8 @@ -import {Browser, Page, Response} from 'puppeteer'; import {NvidiaRegionInfo, regionInfos} from '../nvidia-api'; +import {Browser} from 'puppeteer'; import {Config} from '../../../config'; import {Link} from '../store'; -import {Logger} from '../../../logger'; -import open from 'open'; +import {NvidiaCart} from './nvidia-cart'; import {timestampUrlParameter} from '../../timestamp-url-parameter'; function getRegionInfo(): NvidiaRegionInfo { @@ -25,94 +24,23 @@ function nvidiaStockUrl(id: number, drLocale: string, currency: string): string timestampUrlParameter().slice(1); } -interface NvidiaSessionTokenJSON { - session_token: string; -} - -interface NvidiaAddToCardJSON { - location: string; -} - -function nvidiaSessionUrl(drLocale: string): string { - return `https://store.nvidia.com/store/nvidia/SessionToken?format=json&locale=${drLocale}` + - timestampUrlParameter(); -} - -async function addToCartAndGetLocationRedirect(page: Page, sessionToken: string, productId: number): Promise { - const url = 'https://api-prod.nvidia.com/direct-sales-shop/DR/add-to-cart'; - - page.removeAllListeners('request'); +let cart: NvidiaCart; - await page.setRequestInterception(true); - - page.on('request', interceptedRequest => { - void interceptedRequest.continue({ - headers: { - ...interceptedRequest.headers(), - 'content-type': 'application/json', - nvidia_shop_id: sessionToken - }, - method: 'POST', - postData: JSON.stringify({ - products: [ - {productId, quantity: 1} - ] - }) - }); - }); - - const response = await page.goto(url, {waitUntil: 'networkidle0'}); - if (response === null) { - throw new Error('NvidiaAddToCartUnavailable'); - } - - const locationData = await response.json() as NvidiaAddToCardJSON; - - return locationData.location; -} - -function fallbackCartUrl(nvidiaLocale: string): string { - return `https://www.nvidia.com/${nvidiaLocale}/shop/geforce?${timestampUrlParameter()}`; -} - -export function generateOpenCartAction(id: number, drLocale: string, cardName: string) { +export function generateSetupAction() { return async (browser: Browser) => { - const page = await browser.newPage(); - - Logger.info(`🚀🚀🚀 [nvidia] ${cardName}, starting auto add to cart 🚀🚀🚀`); - - let response: Response | null; - let cartUrl: string; - try { - Logger.info(`🚀🚀🚀 [nvidia] ${cardName}, getting access token 🚀🚀🚀`); - - response = await page.goto(nvidiaSessionUrl(drLocale), {waitUntil: 'networkidle0'}); - if (response === null) { - throw new Error('NvidiaAccessTokenUnavailable'); - } - - const data = await response.json() as NvidiaSessionTokenJSON; - const sessionToken = data.session_token; + cart = new NvidiaCart(browser); - Logger.info(`🚀🚀🚀 [nvidia] ${cardName}, adding to cart 🚀🚀🚀`); - - cartUrl = await addToCartAndGetLocationRedirect(page, sessionToken, id); - - Logger.info(`🚀🚀🚀 [nvidia] ${cardName}, opening checkout page 🚀🚀🚀`); - Logger.info(cartUrl); - - await open(cartUrl); - } catch (error) { - Logger.debug(error); - Logger.error(`✖ [nvidia] ${cardName} could not automatically add to cart, opening page`, error); - - cartUrl = fallbackCartUrl(drLocale); - await open(cartUrl); + if (Config.browser.open) { + cart.keepAlive(); } + }; +} - await page.close(); +export function generateOpenCartAction(id: number, cardName: string) { + return async () => { + const url = await cart.addToCard(id, cardName); - return cartUrl; + return url; }; } @@ -125,7 +53,7 @@ export function generateLinks(): Link[] { links.push({ brand: 'test:brand', model: 'test:model', - openCartAction: generateOpenCartAction(fe2060SuperId, drLocale, 'TEST CARD debug'), + openCartAction: generateOpenCartAction(fe2060SuperId, 'TEST CARD debug'), series: 'test:series', url: nvidiaStockUrl(fe2060SuperId, drLocale, currency) }); @@ -135,7 +63,7 @@ export function generateLinks(): Link[] { links.push({ brand: 'nvidia', model: 'founders edition', - openCartAction: generateOpenCartAction(fe3080Id, drLocale, 'nvidia founders edition 3080'), + openCartAction: generateOpenCartAction(fe3080Id, 'nvidia founders edition 3080'), series: '3080', url: nvidiaStockUrl(fe3080Id, drLocale, currency) }); @@ -145,7 +73,7 @@ export function generateLinks(): Link[] { links.push({ brand: 'nvidia', model: 'founders edition', - openCartAction: generateOpenCartAction(fe3090Id, drLocale, 'nvidia founders edition 3090'), + openCartAction: generateOpenCartAction(fe3090Id, 'nvidia founders edition 3090'), series: '3090', url: nvidiaStockUrl(fe3090Id, drLocale, currency) }); diff --git a/src/store/model/nvidia-api.ts b/src/store/model/nvidia-api.ts index 6c56206f59..0e5b6fa5ce 100644 --- a/src/store/model/nvidia-api.ts +++ b/src/store/model/nvidia-api.ts @@ -1,5 +1,5 @@ +import {generateLinks, generateSetupAction} from './helpers/nvidia'; import {Store} from './store'; -import {generateLinks} from './helpers/nvidia'; // Region/country set by config file, silently ignores null / missing values and defaults to usa @@ -9,29 +9,30 @@ export interface NvidiaRegionInfo { fe3080Id: number | null; fe3090Id: number | null; fe2060SuperId: number | null; + siteLocale: string; } export const regionInfos = new Map([ - ['austria', {currency: 'EUR', drLocale: 'de_de', fe2060SuperId: 5394902900, fe3080Id: 5440853700, fe3090Id: 5444941400}], - ['belgium', {currency: 'EUR', drLocale: 'fr_fr', fe2060SuperId: 5394902700, fe3080Id: 5438795700, fe3090Id: 5438795600}], - ['canada', {currency: 'CAD', drLocale: 'en_us', fe2060SuperId: 5379432500, fe3080Id: 5438481700, fe3090Id: 5438481600}], - ['czechia', {currency: 'CZK', drLocale: 'en_gb', fe2060SuperId: 5394902800, fe3080Id: 5438793800, fe3090Id: 5438793600}], - ['denmark', {currency: 'EUR', drLocale: 'en_gb', fe2060SuperId: 5394903100, fe3080Id: 5438793300, fe3090Id: 5438793500}], - ['finland', {currency: 'EUR', drLocale: 'en_gb', fe2060SuperId: 5394903100, fe3080Id: 5438793300, fe3090Id: 5438793500}], - ['france', {currency: 'EUR', drLocale: 'fr_fr', fe2060SuperId: 5394903200, fe3080Id: 5438795200, fe3090Id: 5438761500}], - ['germany', {currency: 'EUR', drLocale: 'de_de', fe2060SuperId: 5394902900, fe3080Id: 5438792300, fe3090Id: 5438761400}], - ['great_britain', {currency: 'GBP', drLocale: 'en_gb', fe2060SuperId: 5394903300, fe3080Id: 5438792800, fe3090Id: 5438792700}], - ['ireland', {currency: 'GBP', drLocale: 'en_gb', fe2060SuperId: 5394903300, fe3080Id: 5438792800, fe3090Id: 5438792700}], - ['italy', {currency: 'EUR', drLocale: 'it_it', fe2060SuperId: 5394903400, fe3080Id: 5438796200, fe3090Id: 5438796100}], - ['luxembourg', {currency: 'EUR', drLocale: 'fr_fr', fe2060SuperId: 5394902700, fe3080Id: 5438795700, fe3090Id: 5438795600}], - ['netherlands', {currency: 'EUR', drLocale: 'nl_nl', fe2060SuperId: 5394903500, fe3080Id: 5438796700, fe3090Id: 5438796600}], - ['norway', {currency: 'EUR', drLocale: 'nb_no', fe2060SuperId: 5394903600, fe3080Id: 5438797200, fe3090Id: 5438797100}], - ['poland', {currency: 'PLN', drLocale: 'pl_pl', fe2060SuperId: 5394903700, fe3080Id: 5438797700, fe3090Id: 5438797600}], - ['portugal', {currency: 'EUR', drLocale: 'en_gb', fe2060SuperId: null, fe3080Id: 5438794300, fe3090Id: null}], - ['russia', {currency: 'RUB', drLocale: 'ru_ru', fe2060SuperId: null, fe3080Id: null, fe3090Id: null}], - ['spain', {currency: 'EUR', drLocale: 'es_es', fe2060SuperId: 5394903000, fe3080Id: 5438794800, fe3090Id: 5438794700}], - ['sweden', {currency: 'SEK', drLocale: 'sv_se', fe2060SuperId: 5394903900, fe3080Id: 5438798100, fe3090Id: 5438761600}], - ['usa', {currency: 'USD', drLocale: 'en_us', fe2060SuperId: 5379432500, fe3080Id: 5438481700, fe3090Id: 5438481600}] + ['austria', {currency: 'EUR', drLocale: 'de_de', fe2060SuperId: 5394902900, fe3080Id: 5440853700, fe3090Id: 5444941400, siteLocale: 'de-at'}], + ['belgium', {currency: 'EUR', drLocale: 'fr_fr', fe2060SuperId: 5394902700, fe3080Id: 5438795700, fe3090Id: 5438795600, siteLocale: 'fr-be'}], + ['canada', {currency: 'CAD', drLocale: 'en_us', fe2060SuperId: 5379432500, fe3080Id: 5438481700, fe3090Id: 5438481600, siteLocale: 'en-us'}], + ['czechia', {currency: 'CZK', drLocale: 'en_gb', fe2060SuperId: 5394902800, fe3080Id: 5438793800, fe3090Id: 5438793600, siteLocale: 'cs-cz'}], + ['denmark', {currency: 'EUR', drLocale: 'en_gb', fe2060SuperId: 5394903100, fe3080Id: 5438793300, fe3090Id: 5438793500, siteLocale: 'da-dk'}], + ['finland', {currency: 'EUR', drLocale: 'en_gb', fe2060SuperId: 5394903100, fe3080Id: 5438793300, fe3090Id: 5438793500, siteLocale: 'da-dk'}], + ['france', {currency: 'EUR', drLocale: 'fr_fr', fe2060SuperId: 5394903200, fe3080Id: 5438795200, fe3090Id: 5438761500, siteLocale: 'fr-fr'}], + ['germany', {currency: 'EUR', drLocale: 'de_de', fe2060SuperId: 5394902900, fe3080Id: 5438792300, fe3090Id: 5438761400, siteLocale: 'de-de'}], + ['great_britain', {currency: 'GBP', drLocale: 'en_gb', fe2060SuperId: 5394903300, fe3080Id: 5438792800, fe3090Id: 5438792700, siteLocale: 'en-gb'}], + ['ireland', {currency: 'GBP', drLocale: 'en_gb', fe2060SuperId: 5394903300, fe3080Id: 5438792800, fe3090Id: 5438792700, siteLocale: 'en-gb'}], + ['italy', {currency: 'EUR', drLocale: 'it_it', fe2060SuperId: 5394903400, fe3080Id: 5438796200, fe3090Id: 5438796100, siteLocale: 'it-it'}], + ['luxembourg', {currency: 'EUR', drLocale: 'fr_fr', fe2060SuperId: 5394902700, fe3080Id: 5438795700, fe3090Id: 5438795600, siteLocale: 'fr-be'}], + ['netherlands', {currency: 'EUR', drLocale: 'nl_nl', fe2060SuperId: 5394903500, fe3080Id: 5438796700, fe3090Id: 5438796600, siteLocale: 'nl-nl'}], + ['norway', {currency: 'EUR', drLocale: 'nb_no', fe2060SuperId: 5394903600, fe3080Id: 5438797200, fe3090Id: 5438797100, siteLocale: 'nb-no'}], + ['poland', {currency: 'PLN', drLocale: 'pl_pl', fe2060SuperId: 5394903700, fe3080Id: 5438797700, fe3090Id: 5438797600, siteLocale: 'pl-pl'}], + ['portugal', {currency: 'EUR', drLocale: 'en_gb', fe2060SuperId: null, fe3080Id: 5438794300, fe3090Id: null, siteLocale: 'en-gb'}], + ['russia', {currency: 'RUB', drLocale: 'ru_ru', fe2060SuperId: null, fe3080Id: null, fe3090Id: null, siteLocale: 'ru-ru'}], + ['spain', {currency: 'EUR', drLocale: 'es_es', fe2060SuperId: 5394903000, fe3080Id: 5438794800, fe3090Id: 5438794700, siteLocale: 'es-es'}], + ['sweden', {currency: 'SEK', drLocale: 'sv_se', fe2060SuperId: 5394903900, fe3080Id: 5438798100, fe3090Id: 5438761600, siteLocale: 'sv-se'}], + ['usa', {currency: 'USD', drLocale: 'en_us', fe2060SuperId: 5379432500, fe3080Id: 5438481700, fe3090Id: 5438481600, siteLocale: 'en-us'}] ]); export const NvidiaApi: Store = { @@ -42,5 +43,6 @@ export const NvidiaApi: Store = { } }, links: generateLinks(), - name: 'nvidia-api' + name: 'nvidia-api', + setupAction: generateSetupAction() };