Skip to content

Commit 2d59237

Browse files
Mr-Tech-13Copilot
andauthored
feat: Better Web GUI with bot controls (#3250)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 6272f6c commit 2d59237

File tree

17 files changed

+1857
-373
lines changed

17 files changed

+1857
-373
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ First and foremost, this service _will not_ automatically buy for you.
1414

1515
- **Checks stock continuously** -- runs 24/7, 365, looking for the items you want.
1616
- **Ready for checkout** -- ability to add to cart when available and even opens the browser for you.
17+
- **Live dashboard** -- optional web interface with a matrix view of selected stores and series, filter controls, dotenv editing, and restart control.
1718
- **Notifications galore** -- when you're not by your computer, worry free with notifications to most platforms and devices when an item comes in stock.
1819

1920
## Quick start
@@ -25,4 +26,6 @@ git clone https://github.com/jef/streetmerchant.git
2526
cd streetmerchant && npm i && npm run start
2627
```
2728

29+
To enable the web dashboard, set `WEB_PORT` in `dotenv` and open `http://localhost:<WEB_PORT>` while streetmerchant is running.
30+
2831
For more information and customization, visit [jef.buzz/streetmerchant/getting-started](https://jef.buzz/streetmerchant/getting-started).

docs/getting-started.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,26 @@ You do not need any computer skills, smarts, or anything of that nature. You are
2525

2626
At any point you want the program to stop, use ++ctrl+c++.
2727

28+
## Using the web dashboard
29+
30+
If you want to manage streetmerchant from the browser while it is running, set `WEB_PORT` in your `dotenv` file.
31+
32+
```shell
33+
WEB_PORT=8080
34+
```
35+
36+
After you start the app, open `http://localhost:8080`.
37+
38+
The dashboard includes:
39+
40+
- A live matrix with selected stores across the top and selected series on the left.
41+
- Store, series, and model menus that update the running configuration.
42+
- A settings editor for the active `dotenv` file.
43+
- A restart button to reload the bot without closing the dashboard.
44+
45+
???+ note
46+
Filter changes made in the dashboard are persisted back to the active `dotenv` file. Some settings still require a restart before they fully take effect.
47+
2848
???+ tip
2949
Community based help can also be found on the [wiki](https://github.com/jef/streetmerchant/wiki). Feel free to check that out if you're having problems running. If you're still having problems running, you're probably not the first. Make some searches through the [GitHub issues](https://github.com/jef/streetmerchant/issues) before making one.
3050

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ First and foremost, this service _will not_ automatically buy for you.
66

77
- **Checks stock continuously** -- runs 24/7, 365, looking for the items you want.
88
- **Ready for checkout** -- ability to add to cart when available and even opens the browser for you.
9+
- **Live dashboard** -- optional web interface with a matrix view of selected stores and series, filter controls, dotenv editing, and restart control.
910
- **Notifications galore** -- when you're not by your computer, worry free with notifications to most platforms and devices when an item comes in stock.
1011

1112
## Getting started

docs/reference/application.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
| `HEADLESS` | Puppeteer to run headless or not. Debugging related, default: `true` |
88
| `INCOGNITO` | Puppeteer to run incognito or not. Debugging related, default: `false` |
99
| `IN_STOCK_WAIT_TIME` | Time to wait between requests to the same link if it has that card in stock. In seconds, default: `0` |
10+
| `LOOKUP_THREADS` | Maximum number of product links checked concurrently per store loop, default: `1` |
1011
| `LOG_LEVEL` | [Logging levels](https://github.com/winstonjs/winston#logging-levels). Debugging related, default: `info` |
1112
| `LOW_BANDWIDTH` | Blocks images/fonts to reduce traffic. Disables ad blocker, default: `false` |
1213
| `NVIDIA_ADD_TO_CART_ATTEMPTS` | Maximum number of attempts add an item to card in the Nvidia storefront, default: `10` |
@@ -20,15 +21,17 @@
2021
| `PROXY_PROTOCOL` | Protocol of proxy server, such as `socks5`, default: `http` |
2122
| `PROXY_ADDRESS` | IP Address or fqdn of proxy server |
2223
| `PROXY_PORT` | TCP Port number on which the proxy is listening for connections, default: `80` |
24+
| `RANDOMIZE_LOOKUP_ORDER` | Shuffle store and product lookup order on each pass, default: `false` |
2325
| `RESTART_TIME` | Restarts chrome after defined milliseconds. `0` for never, default: `0` |
2426
| `SCREENSHOT` | Capture screenshot of page if a card is found, default: `true` |
2527
| `SCREENSHOT_DIR` | The directory for saving the screenshots, default: `screenshots` |
2628
| `USER_AGENT` | Custom user agent used for requests |
27-
| `WEB_PORT` | Starts a webserver to be able to control the bot while it is running. Setting this value starts this service. |
29+
| `WEB_PORT` | Starts a webserver to control the bot while it is running. The dashboard includes a live matrix view, filter menus, dotenv editing, and a restart control. |
2830

2931
???+ info
3032
There is more information on proxy settings in the [Proxy documentation](proxy.md).
3133

3234
???+ tip
3335
- You can also have a list of proxies that are rotated while searching stores. Proxies can be read from a file named `STORENAME.proxies` in the format of `socks5://username:password@ip`; one per line.
3436
- Data usage is [known to be high](https://github.com/jef/streetmerchant/issues?q=is%3Aissue+sort%3Aupdated-desc+bandwidth). This is expected as the program scrapes many websites in parallel 24/7. To help reduce this, use `LOW_BANDWIDTH="true"`. We are looking into other solutions as well, but is low priority.
37+
- Increasing `LOOKUP_THREADS` can improve throughput, but it also increases request pressure and rate-limit risk.

docs/reference/filter.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@
5454
???+ note
5555
For `MAX_PRICE_SERIES_*` variables: Use whole numbers only (no currency symbol is required). Avoid using any commas or decimal points. Example: `1234`. Merchandise found above this price will be skipped.
5656

57+
???+ info
58+
When `WEB_PORT` is enabled, the web dashboard can update `STORES`, `SHOW_ONLY_SERIES`, and `SHOW_ONLY_MODELS` from the browser. Those changes are also written back to the active `dotenv` file.
59+
5760
## Supported stores
5861

5962
Used with the `STORES` variable.
@@ -122,7 +125,7 @@ Used with the `STORES` variable.
122125
| Drako | IT | `drako` |
123126
| DustinHome | NO | `dustinhome-no` |
124127
| eBuyer | UK | `ebuyer` |
125-
| El Corte Inglés | ES | `elcorteingles` |
128+
| El Corte Ingles | ES | `elcorteingles` |
126129
| Eletronicamente | ES | `eletronicamente` |
127130
| Elkjop | NO | `elkjop` |
128131
| ePrice | IT | `eprice` |

dotenv-example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ IN_STOCK_WAIT_TIME=
7474
INCOGNITO=
7575
LOG_LEVEL=
7676
LOW_BANDWIDTH=
77+
LOOKUP_THREADS=
78+
RANDOMIZE_LOOKUP_ORDER=
7779
MAX_PRICE_SERIES_3060=
7880
MAX_PRICE_SERIES_3060TI=
7981
MAX_PRICE_SERIES_3070=

src/config.ts

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,36 @@ import {existsSync, readFileSync} from 'fs';
33
import path from 'path';
44
import {banner} from './banner';
55

6-
if (process.env.npm_config_conf) {
7-
if (
8-
existsSync(path.resolve(__dirname, '../../' + process.env.npm_config_conf))
9-
) {
10-
dotenv.config({
11-
path: path.resolve(__dirname, '../../' + process.env.npm_config_conf),
12-
});
13-
} else {
14-
dotenv.config({path: path.resolve(__dirname, '../../.env')});
6+
export function getActiveConfigPath() {
7+
if (process.env.npm_config_conf) {
8+
const configuredPath = path.resolve(
9+
__dirname,
10+
'../../' + process.env.npm_config_conf
11+
);
12+
if (existsSync(configuredPath)) {
13+
return configuredPath;
14+
}
15+
16+
return path.resolve(__dirname, '../../.env');
1517
}
16-
} else if (existsSync(path.resolve(__dirname, '../../dotenv'))) {
17-
dotenv.config({path: path.resolve(__dirname, '../../dotenv')});
18-
} else if (existsSync(path.resolve(__dirname, '../dotenv'))) {
19-
dotenv.config({path: path.resolve(__dirname, '../dotenv')});
20-
} else {
21-
dotenv.config({path: path.resolve(__dirname, '../../.env')});
18+
19+
const rootDotenv = path.resolve(__dirname, '../../dotenv');
20+
if (existsSync(rootDotenv)) {
21+
return rootDotenv;
22+
}
23+
24+
const buildDotenv = path.resolve(__dirname, '../dotenv');
25+
if (existsSync(buildDotenv)) {
26+
return buildDotenv;
27+
}
28+
29+
return path.resolve(__dirname, '../../.env');
2230
}
2331

32+
export const activeConfigPath = getActiveConfigPath();
33+
34+
dotenv.config({path: activeConfigPath});
35+
2436
console.info(
2537
banner.render(
2638
envOrBoolean(process.env.ASCII_BANNER, false),
@@ -414,6 +426,8 @@ const nvidia = {
414426
const page = {
415427
height: 1080,
416428
inStockWaitTime: envOrNumber(process.env.IN_STOCK_WAIT_TIME),
429+
lookupThreads: envOrNumber(process.env.LOOKUP_THREADS, 1),
430+
randomizeLookupOrder: envOrBoolean(process.env.RANDOMIZE_LOOKUP_ORDER, false),
417431
screenshot: envOrBoolean(process.env.SCREENSHOT),
418432
screenshotDir: envOrString(process.env.SCREENSHOT_DIR, 'screenshots'),
419433
timeout: envOrNumber(process.env.PAGE_TIMEOUT, 30000),

src/index.ts

Lines changed: 51 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,31 +6,46 @@ import {getSleepTime} from './util';
66
import {logger} from './logger';
77
import {storeList} from './store/model';
88
import {tryLookupAndLoop} from './store';
9+
import {setRestartBotHandler} from './runtime-control';
910

1011
let browser: Browser | undefined;
12+
let runGeneration = 0;
13+
14+
function shuffleArray<T>(items: T[]): T[] {
15+
const shuffled = [...items];
16+
for (let i = shuffled.length - 1; i > 0; i--) {
17+
const j = Math.floor(Math.random() * (i + 1));
18+
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
19+
}
20+
21+
return shuffled;
22+
}
1123

1224
async function sleep(ms: number) {
1325
return new Promise(resolve => setTimeout(resolve, ms));
1426
}
1527

16-
/**
17-
* Schedules a restart of the bot
18-
*/
19-
async function restartMain() {
28+
async function scheduleRestart(generation: number) {
2029
if (config.restartTime > 0) {
2130
await sleep(config.restartTime);
22-
await stop();
23-
loopMain();
31+
if (generation !== runGeneration) {
32+
return;
33+
}
34+
35+
await restartBot();
2436
}
2537
}
2638

27-
/**
28-
* Starts the bot.
29-
*/
30-
async function main() {
39+
async function startBot(startApiServer: boolean) {
40+
const generation = ++runGeneration;
3141
browser = await launchBrowser();
42+
void scheduleRestart(generation);
43+
44+
const stores = config.page.randomizeLookupOrder
45+
? shuffleArray([...storeList.values()])
46+
: [...storeList.values()];
3247

33-
for (const store of storeList.values()) {
48+
for (const store of stores) {
3449
logger.debug('store links', {meta: {links: store.links}});
3550
if (store.setupAction !== undefined) {
3651
store.setupAction(browser);
@@ -39,35 +54,43 @@ async function main() {
3954
setTimeout(tryLookupAndLoop, getSleepTime(store), browser, store);
4055
}
4156

42-
await startAPIServer();
57+
if (startApiServer) {
58+
await startAPIServer();
59+
}
4360
}
4461

45-
async function stop() {
46-
await stopAPIServer();
62+
async function stopBot() {
63+
runGeneration++;
4764

4865
if (browser) {
49-
// Use temporary swap variable to avoid any race condition
5066
const browserTemporary = browser;
5167
browser = undefined;
5268
await browserTemporary.close();
5369
}
5470
}
5571

72+
async function stop() {
73+
await stopAPIServer();
74+
await stopBot();
75+
}
76+
77+
export async function restartBot() {
78+
logger.info('Restarting streetmerchant bot');
79+
await stopBot();
80+
await startBot(false);
81+
}
82+
5683
async function stopAndExit() {
5784
await stop();
5885
Process.exit(0);
5986
}
6087

61-
/**
62-
* Will continually run until user interferes.
63-
*/
6488
async function loopMain() {
6589
try {
66-
restartMain();
67-
await main();
90+
await startBot(true);
6891
} catch (error: unknown) {
6992
logger.error(
70-
' something bad happened, resetting streetmerchant in 5 seconds',
93+
'✖ something bad happened, resetting streetmerchant in 5 seconds',
7194
error
7295
);
7396
setTimeout(loopMain, 5000);
@@ -77,15 +100,11 @@ async function loopMain() {
77100
export async function launchBrowser(): Promise<Browser> {
78101
const args: string[] = [];
79102

80-
// Skip Chromium Linux Sandbox
81-
// https://github.com/puppeteer/puppeteer/blob/main/docs/troubleshooting.md#setting-up-chrome-linux-sandbox
82103
if (config.browser.isTrusted) {
83104
args.push('--no-sandbox');
84105
args.push('--disable-setuid-sandbox');
85106
}
86107

87-
// https://github.com/puppeteer/puppeteer/blob/main/docs/troubleshooting.md#tips
88-
// https://stackoverflow.com/questions/48230901/docker-alpine-with-node-js-and-chromium-headless-puppeter-failed-to-launch-c
89108
if (config.docker) {
90109
args.push('--disable-dev-shm-usage');
91110
args.push('--no-sandbox');
@@ -95,19 +114,18 @@ export async function launchBrowser(): Promise<Browser> {
95114
config.browser.open = false;
96115
}
97116

98-
// Add the address of the proxy server if defined
99117
if (config.proxy.address) {
100118
args.push(
101119
`--proxy-server=${config.proxy.protocol}://${config.proxy.address}:${config.proxy.port}`
102120
);
103121
}
104122

105123
if (args.length > 0) {
106-
logger.info(' puppeteer config: ', args);
124+
logger.info('ℹ puppeteer config: ', args);
107125
}
108126

109-
await stop();
110-
const browser = await Puppeteer.launch({
127+
await stopBot();
128+
const launchedBrowser = await Puppeteer.launch({
111129
args,
112130
defaultViewport: {
113131
height: config.page.height,
@@ -116,11 +134,13 @@ export async function launchBrowser(): Promise<Browser> {
116134
headless: config.browser.isHeadless,
117135
});
118136

119-
config.browser.userAgent = await browser.userAgent();
137+
config.browser.userAgent = await launchedBrowser.userAgent();
120138

121-
return browser;
139+
return launchedBrowser;
122140
}
123141

142+
setRestartBotHandler(restartBot);
143+
124144
void loopMain();
125145

126146
process.on('SIGINT', stopAndExit);

src/runtime-control.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
let restartBotHandler: (() => Promise<void>) | undefined;
2+
3+
export function setRestartBotHandler(handler: () => Promise<void>) {
4+
restartBotHandler = handler;
5+
}
6+
7+
export async function restartBot() {
8+
if (!restartBotHandler) {
9+
throw new Error('Restart handler has not been initialized');
10+
}
11+
12+
await restartBotHandler();
13+
}

0 commit comments

Comments
 (0)