diff --git a/.env-example b/.env-example index 72fc72b77f..55f60d5ae5 100644 --- a/.env-example +++ b/.env-example @@ -70,3 +70,4 @@ TWITTER_CONSUMER_KEY= TWITTER_CONSUMER_SECRET= TWITTER_TWEET_TAGS= USER_AGENT= +WEB_PORT= diff --git a/README.md b/README.md index 8fa81f64be..8937b0aed3 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,7 @@ Here is a list of variables that you can use to customize your newly copied `.en | `TWITCH_REFRESH_TOKEN` | Twitch refresh token | | | `TWITCH_CHANNEL` | Twitch channel | | | `USER_AGENT` | Custom User-Agents headers for HTTP requests | Newline separated, e.g.: `USER_AGENT_STRING1 \n USER_AGENT_STRING2` | | Default: `Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.108 Safari/537.36` | +| `WEB_PORT` | Starts a webserver to be able to control the bot while it is running; optional | Default: disabled | > :point_right: If you have multi-factor authentication (MFA), you will need to create an [app password](https://myaccount.google.com/apppasswords) and use this instead of your Gmail password. diff --git a/src/config.ts b/src/config.ts index fc035009bc..fcc1400eab 100644 --- a/src/config.ts +++ b/src/config.ts @@ -271,3 +271,10 @@ export const config = { proxy, store }; + +export function setConfig(newConfig: any) { + const writeConfig = config as any; + for (const key of Object.keys(newConfig)) { + writeConfig[key] = newConfig[key]; + } +} diff --git a/src/index.ts b/src/index.ts index dcd1ba191d..6c377fad31 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ -import {Stores} from './store/model'; +import {startAPIServer, stopAPIServer} from './web'; +import {Browser} from 'puppeteer'; import {adBlocker} from './adblocker'; import {config} from './config'; import {getSleepTime} from './util'; @@ -6,6 +7,7 @@ import {logger} from './logger'; import puppeteer from 'puppeteer-extra'; import resourceBlock from 'puppeteer-extra-plugin-block-resources'; import stealthPlugin from 'puppeteer-extra-plugin-stealth'; +import {storeList} from './store/model'; import {tryLookupAndLoop} from './store'; puppeteer.use(stealthPlugin()); @@ -17,15 +19,12 @@ if (config.browser.lowBandwidth) { puppeteer.use(adBlocker); } +let browser: Browser | undefined; + /** * Starts the bot. */ async function main() { - if (Stores.length === 0) { - logger.error('✖ no stores selected', Stores); - return; - } - const args: string[] = []; // Skip Chromium Linux Sandbox @@ -45,7 +44,9 @@ async function main() { args.push(`--proxy-server=http://${config.proxy.address}:${config.proxy.port}`); } - const browser = await puppeteer.launch({ + await stop(); + + browser = await puppeteer.launch({ args, defaultViewport: { height: config.page.height, @@ -54,7 +55,7 @@ async function main() { headless: config.browser.isHeadless }); - for (const store of Stores) { + for (const store of storeList.values()) { logger.debug('store links', {meta: {links: store.links}}); if (store.setupAction !== undefined) { store.setupAction(browser); @@ -62,14 +63,41 @@ async function main() { setTimeout(tryLookupAndLoop, getSleepTime(store), browser, store); } + + await startAPIServer(); +} + +async function stop() { + await stopAPIServer(); + + if (browser) { + // Use temporary swap variable to avoid any race condition + const browserTemporary = browser; + browser = undefined; + await browserTemporary.close(); + } +} + +async function stopAndExit() { + await stop(); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(0); } /** * Will continually run until user interferes. */ -try { - void main(); -} catch (error) { - logger.error('✖ something bad happened, resetting nvidia-snatcher', error); - void main(); +async function loopMain() { + try { + await main(); + } catch (error) { + logger.error('✖ something bad happened, resetting nvidia-snatcher in 5 seconds', error); + setTimeout(loopMain, 5000); + } } + +void loopMain(); + +process.on('SIGINT', stopAndExit); +process.on('SIGQUIT', stopAndExit); +process.on('SIGTERM', stopAndExit); diff --git a/src/store/lookup.ts b/src/store/lookup.ts index 81dbb779a5..8625d06bd1 100644 --- a/src/store/lookup.ts +++ b/src/store/lookup.ts @@ -24,6 +24,10 @@ const linkBuilderLastRunTimes: Record = {}; * @param store Vendor of graphics cards. */ async function lookup(browser: Browser, store: Store) { + if (config.store.stores.length > 0 && !config.store.stores.find(foundStore => foundStore.name === store.name)) { + return; + } + /* eslint-disable no-await-in-loop */ for (const link of store.links) { if (!filterStoreLink(link)) { @@ -192,6 +196,11 @@ async function lookupCardInStock(store: Store, page: Page, link: Link) { } export async function tryLookupAndLoop(browser: Browser, store: Store) { + if (!browser.isConnected()) { + logger.debug(`[${store.name}] Ending this loop as browser is disposed...`); + return; + } + if (store.linksBuilder) { const lastRunTime = linkBuilderLastRunTimes[store.name] ?? -1; const ttl = store.linksBuilder.ttl ?? Number.MAX_SAFE_INTEGER; diff --git a/src/store/model/index.ts b/src/store/model/index.ts index 31d549fda2..f24653f90c 100644 --- a/src/store/model/index.ts +++ b/src/store/model/index.ts @@ -42,13 +42,10 @@ import {ProshopDE} from './proshop-de'; import {ProshopDK} from './proshop-dk'; import {Saturn} from './saturn'; import {Scan} from './scan'; -import {Store} from './store'; import {Very} from './very'; import {Zotac} from './zotac'; -import {config} from '../../config'; -import {logger} from '../../logger'; -const masterList = new Map([ +export const storeList = new Map([ [Adorama.name, Adorama], [Alternate.name, Alternate], [AlternateNL.name, AlternateNL], @@ -97,33 +94,27 @@ const masterList = new Map([ [Zotac.name, Zotac] ]); -const list = new Map(); - -for (const storeData of config.store.stores) { - if (masterList.has(storeData.name)) { - list.set(storeData.name, {...masterList.get(storeData.name), ...storeData}); - } else { - const logString = `No store named ${storeData.name}, skipping.`; - logger.warn(logString); +const brands = new Set(); +const series = new Set(); +const models = new Set(); +for (const store of storeList.values()) { + for (const link of store.links) { + brands.add(link.brand); + series.add(link.series); + models.add(link.model); } } -logger.info(`ℹ selected stores: ${Array.from(list.keys()).join(', ')}`); - -if (config.store.showOnlyBrands.length > 0) { - logger.info(`ℹ selected brands: ${config.store.showOnlyBrands.join(', ')}`); +export function getAllBrands() { + return Array.from(brands); } -if (config.store.showOnlyModels.length > 0) { - logger.info(`ℹ selected models: ${config.store.showOnlyModels.map(entry => { - return entry.series ? entry.name + ' (' + entry.series + ')' : entry.name; - }).join(', ')}`); +export function getAllSeries() { + return Array.from(series); } -if (config.store.showOnlySeries.length > 0) { - logger.info(`ℹ selected series: ${config.store.showOnlySeries.join(', ')}`); +export function getAllModels() { + return Array.from(models); } -export const Stores = Array.from(list.values()) as Store[]; - export * from './store'; diff --git a/src/web/index.ts b/src/web/index.ts new file mode 100644 index 0000000000..4cd67c946d --- /dev/null +++ b/src/web/index.ts @@ -0,0 +1,169 @@ +import {IncomingMessage, Server, ServerResponse, createServer} from 'http'; +import {config, setConfig} from '../config'; +import {createReadStream, readdir} from 'fs'; +import {getAllBrands, getAllModels, getAllSeries, storeList} from '../store/model'; +import {join, normalize} from 'path'; + +const approot = join(__dirname, '../../'); +const webroot = join(approot, './web'); + +const contentTypeMap: { [key: string]: string } = { + css: 'text/css', + htm: 'text/html', + html: 'text/html', + jpeg: 'image/jpeg', + jpg: 'image/jpeg', + js: 'application/javascript', + json: 'application/json', + png: 'image/png', + txt: 'text/plain' +}; + +function sendFile(response: ServerResponse, path: string, relativeTo: string = webroot) { + path = normalize(`./${path}`); + + const fsPath = join(relativeTo, path); + try { + const stream = createReadStream(fsPath); + stream.on('error', error => { + sendError(response, error.message); + }); + + const pathSplit = path.split('.'); + const ext = pathSplit[pathSplit.length - 1].toLowerCase(); + + response.setHeader('Content-Type', contentTypeMap[ext] ?? contentTypeMap.txt); + + stream.on('end', () => response.end()); + stream.pipe(response); + } catch (error) { + sendError(response, error); + } +} + +function sendError(response: ServerResponse, data: string, statusCode = 500) { + response.statusCode = statusCode; + response.setHeader('Content-Type', contentTypeMap.txt); + response.write(data); + response.end(); +} + +function sendJSON(response: ServerResponse, data: any) { + response.setHeader('Content-Type', contentTypeMap.json); + response.write(JSON.stringify(data)); + response.end(); +} + +function sendConfig(response: ServerResponse) { + sendJSON(response, config); +} + +function handleAPI(request: IncomingMessage, response: ServerResponse, urlComponents: string[]) { + if (urlComponents.length < 2) { + sendError(response, 'No API route specified', 400); + return; + } + + switch (urlComponents[1]) { + case 'config': + if (request.method === 'PUT') { + const data: string[] = []; + request.on('data', chunk => { + data.push(chunk); + }); + request.on('end', () => { + // We ignore errors, client just sent wrong data... + try { + setConfig(JSON.parse(data.join(''))); + } catch { } + + sendConfig(response); + }); + return; + } + + sendConfig(response); + return; + case 'stores': + sendJSON(response, Array.from(storeList.keys())); + return; + case 'brands': + sendJSON(response, getAllBrands()); + return; + case 'series': + sendJSON(response, getAllSeries()); + return; + case 'models': + sendJSON(response, getAllModels()); + return; + case 'screenshots': + if (urlComponents.length >= 3) { + const timeStamp = urlComponents[2]; + if (/\D/.test(timeStamp)) { + sendError(response, 'Invalid screenshot timestamp', 400); + return; + } + + sendFile(response, `../success-${timeStamp}.png`); + return; + } + + readdir(approot, (error, files) => { + if (error) { + sendError(response, error.message); + return; + } + + const result = []; + for (const file of files) { + const match = /^success-(\d+)\.png$/.exec(file); + if (!match) { + continue; + } + + result.push(match[1]); + } + + sendJSON(response, result); + }); + return; + default: + sendError(response, 'No API route found for path', 400); + } +} + +function requestListener(request: IncomingMessage, response: ServerResponse) { + const url = request.url!; + const urlComponents = url.slice(1).split('/'); // Remove the leading / + + switch (urlComponents[0]) { + case 'api': + handleAPI(request, response, urlComponents); + break; + default: + sendFile(response, url === '/' ? '/index.html' : url); + break; + } +} + +let server: Server | undefined; + +export async function startAPIServer() { + await stopAPIServer(); + if (process.env.WEB_PORT) { + server = createServer(requestListener); + server.listen(Number(process.env.WEB_PORT)); + } +} + +export async function stopAPIServer() { + return new Promise(resolve => { + if (server) { + server.close(resolve); + server = undefined; + return; + } + + resolve(); + }); +} diff --git a/tsconfig.json b/tsconfig.json index 15390b1625..5714cba949 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { - "target": "es5", + "target": "es2019", "module": "commonjs", - "lib": ["es6", "dom"], + "lib": ["dom", "es2019", "es2020.bigint", "es2020.string", "es2020.symbol.wellknown"], "allowJs": true, "outDir": "build", "rootDir": "src", @@ -12,6 +12,6 @@ "resolveJsonModule": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "sourceMap": true + "sourceMap": true } } diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000000..d40f94ddcb --- /dev/null +++ b/web/index.html @@ -0,0 +1,149 @@ + + + + nvidia-snatcher control + + + + + + + + + + + + + + + + +
StoresBrandsSeriesModels
+ +
+ + +



+ Screenshots (Refresh) +
+ +