├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .github ├── FUNDING.yml └── ISSUE_TEMPLATE │ └── bug_report.md ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── LICENSE.md ├── README.md ├── config ├── README.md ├── config.json └── example.env ├── logs └── README.md ├── package-lock.json ├── package.json ├── server ├── index.html └── index.js └── src ├── item.js ├── main.js ├── store.js ├── stores ├── amazon.js ├── antonline.js ├── argos.js ├── bestbuy.js ├── currys.js ├── ebuyer.js ├── gamestop.js ├── microcenter.js ├── newegg.js ├── target.js ├── tesco.js └── walmart.js └── utils ├── fetch.js ├── interval-value.js ├── log.js └── notification ├── alerts.js ├── desktop.js ├── email.js ├── sms-aws.js ├── sms-email.js ├── sms-twilio.js └── webhook.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = tab 6 | indent_size = 4 7 | trim_trailing_whitespace = true 8 | 9 | [*.md] 10 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # Ignore this files and folders when using ESLint 2 | .github/ 3 | logs/ 4 | node_modules/ 5 | package-lock.json 6 | README.md -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true, 5 | "node": true 6 | }, 7 | "extends": ["eslint:recommended", "plugin:prettier/recommended"], 8 | "plugins": ["prettier", "unicorn"], 9 | "parserOptions": { 10 | "ecmaVersion": 12, 11 | "sourceType": "module" 12 | }, 13 | "rules": { 14 | "prettier/prettier": [ 15 | "error", 16 | {}, 17 | { 18 | "usePrettierrc": true, 19 | "endOfLine": "auto", 20 | } 21 | ], 22 | "no-multiple-empty-lines": ["error", { "max": 3, "maxEOF": 1, "maxBOF": 1 }], // Doesn't work. Overridden by Prettier 23 | "no-nested-ternary": "off", // Off to avoid conflicting with unicorn 24 | "no-unused-vars": "warn", 25 | "unicorn/better-regex": "error", 26 | "unicorn/catch-error-name": "error", 27 | "unicorn/consistent-destructuring": "error", 28 | "unicorn/error-message": "error", 29 | "unicorn/filename-case": "error", 30 | "unicorn/no-array-for-each": "error", 31 | "unicorn/no-array-push-push": "error", 32 | "unicorn/no-console-spaces": "error", 33 | "unicorn/no-for-loop": "error", 34 | "unicorn/no-instanceof-array": "error", 35 | "unicorn/no-keyword-prefix": "off", 36 | "unicorn/no-lonely-if": "error", 37 | "unicorn/no-new-array": "error", 38 | "unicorn/no-new-buffer": "error", 39 | "unicorn/no-null": "error", 40 | "unicorn/no-object-as-default-parameter": "error", 41 | "unicorn/no-process-exit": "error", 42 | "unicorn/no-this-assignment": "error", 43 | "unicorn/no-unsafe-regex": "off", 44 | "unicorn/no-zero-fractions": "error", 45 | "unicorn/prefer-array-find": "error", 46 | "unicorn/prefer-array-flat-map": "error", 47 | "unicorn/prefer-array-index-of": "error", 48 | "unicorn/prefer-array-some": "error", 49 | "unicorn/prefer-date-now": "error", 50 | "unicorn/prefer-default-parameters": "error", 51 | "unicorn/prefer-dom-node-append": "error", 52 | "unicorn/prefer-dom-node-dataset": "error", 53 | "unicorn/prefer-dom-node-remove": "error", 54 | "unicorn/prefer-dom-node-text-content": "error", 55 | "unicorn/prefer-includes": "error", 56 | "unicorn/prefer-negative-index": "error", 57 | "unicorn/prefer-optional-catch-binding": "error", 58 | "unicorn/prefer-regexp-test": "error", 59 | "unicorn/prefer-set-has": "error", 60 | "unicorn/prefer-string-slice": "error", 61 | "unicorn/prefer-string-starts-ends-with": "error", 62 | "unicorn/prefer-string-trim-start-end": "error", 63 | "unicorn/prefer-ternary": "off", 64 | "unicorn/prefer-type-error": "error", 65 | "unicorn/throw-new-error": "error", 66 | "unicorn/prevent-abbreviations": "error" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [Prince25] 2 | custom: ['https://www.buymeacoffee.com/PrinceSingh', 'https://www.paypal.com/donate/?business=3Y9NEYR4TURT8&item_name=Making+software+and+hacking+the+world%21+%E2%99%A5¤cy_code=USD'] 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a bug report to help me improve the bot 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Logs and Screenshots** 14 | Please upload any related logs from the `logs` folder. 15 | If available, upload the html file related to the bug from the `log` folder. 16 | If applicable, upload `config.json` from the `config` folder. 17 | If applicable, add screenshot of the console to help explain your problem. 18 | 19 | **Platform Information** 20 | - OS: [e.g. Windows 10] 21 | - Node version `node -v`: [e.g. v14.15.2] 22 | 23 | **Additional context** 24 | Add any other context about the problem here. 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | # config/config.json 14 | config/proxies.txt 15 | pids 16 | *.pid 17 | *.seed 18 | *.pid.lock 19 | .DS_Store 20 | 21 | # Directory for instrumented libs generated by jscoverage/JSCover 22 | lib-cov 23 | 24 | # Coverage directory used by tools like istanbul 25 | coverage 26 | *.lcov 27 | 28 | # nyc test coverage 29 | .nyc_output 30 | 31 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 32 | .grunt 33 | 34 | # Bower dependency directory (https://bower.io/) 35 | bower_components 36 | 37 | # node-waf configuration 38 | .lock-wscript 39 | 40 | # Compiled binary addons (https://nodejs.org/api/addons.html) 41 | build/Release 42 | 43 | # Dependency directories 44 | node_modules/ 45 | jspm_packages/ 46 | 47 | # TypeScript v1 declaration files 48 | typings/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Microbundle cache 60 | .rpt2_cache/ 61 | .rts2_cache_cjs/ 62 | .rts2_cache_es/ 63 | .rts2_cache_umd/ 64 | 65 | # Optional REPL history 66 | .node_repl_history 67 | 68 | # Output of 'npm pack' 69 | *.tgz 70 | 71 | # Yarn Integrity file 72 | .yarn-integrity 73 | 74 | # dotenv environment variables file 75 | config/.env 76 | .env 77 | .env.test 78 | 79 | # parcel-bundler cache (https://parceljs.org/) 80 | .cache 81 | 82 | # Next.js build output 83 | .next 84 | 85 | # Nuxt.js build / generate output 86 | .nuxt 87 | dist 88 | 89 | # Gatsby files 90 | .cache/ 91 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 92 | # https://nextjs.org/blog/next-9-1#public-directory-support 93 | # public 94 | 95 | # vuepress build output 96 | .vuepress/dist 97 | 98 | # Serverless directories 99 | .serverless/ 100 | 101 | # FuseBox cache 102 | .fusebox/ 103 | 104 | # DynamoDB Local files 105 | .dynamodb/ 106 | 107 | # TernJS port file 108 | .tern-port 109 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore this files and folders when using Prettier 2 | .github/ 3 | logs/ 4 | node_modules/ 5 | package-lock.json 6 | README.md -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "bracketSpacing": true, 4 | "useTabs": true, 5 | "tabWidth": 4, 6 | "printWidth": 100 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Prabhjot Singh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Urgent: Developers Wanted! 2 | Since I've been working multiple part-time jobs, I haven't had time to maintain or add new features. That is why I'm looking for developers who would like to volunteer to contribute to this project. Whether you're a JavaScript expert or you're intrigued by the idea of programming, please contact me on Discord `Prince#0584` or email `prince112592@yahoo.com`. 3 |

4 | 5 | # StockAlertBot 6 | Faced with the scenario of scalpers using bots to hog up all the inventory of popular holiday toys and sell them at ridiculously high markup prices, I decided to put up a fight so we can get our hands on things we ~~want~~ need to survive the #Coronavirus quarantine(s). 7 | ###### Of course, this is only half the battle. For full writeup on scoring items, look [here](https://github.com/Prince25/StockAlertBot/wiki/Beating-Scalpers).



8 | 9 |

10 | Donate, buy me a Pizza or PayPal me if you'd like to see this project expanded and support me. :)

11 | Paypal me

12 | Buy Me A Coffee 13 | Paypal me 14 |

15 | 16 | ### How does it work? 17 | Enter links of the products you want tracked and how often you want the program to monitor them. When an item becomes available, you will be alerted through variety of notifications. 18 |
19 | 20 | ### Features 21 | * Add links and change settings **easily** through a browser page 22 | * Complete control over check intervals 23 | * Ability to freely set monitor frequency, **no limits** 24 | * Ability to space out checks if multiple items per store 25 | * Ability to override default interval and define frequency for each store 26 | * Proxy Support 27 | * Automatically opens a product's URL upon restock 28 | * Notifications 29 | * Desktop 30 | * Email 31 | * SMS / Text 32 | * Terminal 33 | * Webhooks: Discord, IFTTT, and Slack 34 |
35 | 36 | ### What stores/websites are supported? 37 | Currently, the following stores are supported: 38 | * AntOnline 39 | * Amazon, with ability to track stock by a particular merchant 40 | * Argos (UK. Does not currently work with proxies)\ 41 | For PS5, use the following links. Disc: `https://www.argos.co.uk/product/8349000`, Digital: `https://www.argos.co.uk/product/8349024` 42 | * Best Buy, including open-box and packages (Does not currently work with proxies) 43 | * Currys (UK) 44 | * Ebuyer (UK) 45 | * Gamestop (Does not currently work with proxies) 46 | * Microcenter 47 | * Newegg, including combo deals 48 | * Target, including local stock 49 | * Tesco (UK. Does not currently work with proxies) 50 | * Walmart (No third party sellers) 51 | 52 | 53 | Console Screenshot 54 |

55 | 56 | 57 | ## Prerequisites 58 | 0. A Terminal: ([cmd](https://en.wikipedia.org/wiki/Cmd.exe) (Windows), [Terminal](https://en.wikipedia.org/wiki/Terminal_(macOS)) (macOS), or [Console](https://en.wikipedia.org/wiki/Linux_console) (Linux)) 59 | 1. Install [Node.js](https://nodejs.org/en/), either LTS or Current. 60 | 2. Clone or [download](https://github.com/Prince25/StockAlertBot/archive/main.zip) this repository 61 | `git clone https://github.com/Prince25/StockAlertBot.git` 62 | 3. Go to root directory 63 | `cd StockAlertBot` 64 | 4. Install npm packages via a terminal 65 | `npm install` 66 |

67 | 68 | 69 | ## Usage 70 | There are only two steps to use this program: 1) enter information and 2) launch the program. 71 | 72 | 1. You can now enter information using two ways: via a browser (recommended) or a text editor. 73 | #### Via Browser 74 |
75 | Expand 76 | 77 | 1. At the root directory, run on the terminal: 78 | `npm run settings`\ 79 | A browser window should open up. If it doesn't and the console says `Server started!`, go to: `http://localhost:3250/` in your browser. 80 | 2. Enter the links of the items you want to track in the URLs tab. 81 | 3. Go to Settings tab and change to your heart's content. 82 | - Set how often you want to check the stores for given URLs and how much to space out the checks between items. It's not recommended to set it to 0 as you may be flagged as a bot. If you have 3 items (call them A, B, C) for Amazon and 2 items for Walmart (call them D, E) at 10 second interval spaced out at 2 seconds, for example, Items A and D will be checked first. 2 seconds later, items B and E will be checked. 2 seconds later, item C will be checked. The checks will start again in 8 seconds for Walmart and 10 seconds for Amazon. 83 | - If you want to use Proxies, turn it on and create a file called `proxies.txt` in the `config` folder and fill it with one proxy per line. See [proxies](#Proxies). 84 | - If you have Amazon link(s), you will see an option to pick a region. Select a region if you want to only monitor items sold by Amazon and not third party sellers. If you want to use a particular seller or if your region is not in the list, select `Custom` and provide the merchant ID. See [Feedback and Support](#Feedback-and-Support) if you'd like to request a region. 85 | - If you have Target link(s), you will see additional options to put zip code and API Key. Only change the key if you get API key errors. Refer to the instructions in the following [section](#Via-Text-Editor). 86 | 4. Configure notification options in Optional tab. 87 | - If you want notifications sent to [Discord](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks), [IFTTT](https://maker.ifttt.com/), or [Slack](https://api.slack.com/messaging/webhooks), expand WEBHOOKS and enter the webhook URL(s) there. 88 | - If you want notification sent via SMS/Text, expand SMS and choose a method: Amazon Web Services, Email, or Twilio. See [SMS](#SMS). 89 | - If you want notifications sent to Email, turn on email and enter your service provider information. Some providers (Yahoo, AOL, AT&T) cause problems. Refer to this [section](#Email). 90 | 5. Once you're happy with the settings, click `Save Settings`.\ 91 | `config.json` and `.env` files in the `config` directory should now reflect your settings.\ 92 | You can use `CTRL + C` or `CMD + C` to stop the program. 93 |
94 |
95 | 96 | #### Via Text Editor 97 |
98 | Expand 99 | 100 | Open and edit `config.json` in the `config` directory 101 | 1. Add urls of products in the `URLS` array 102 | 2. Change the `INTERVAL` to suit your desires.\ 103 | You can override this default interval for any store by changing `STORE_INTERVALS` in the following manner:\ 104 | ` 105 | "STORE_INTERVALS": { 106 | "newegg": { 107 | "unit": "seconds", 108 | "value": 10 109 | } 110 | }, 111 | `\ 112 | **WARNING:** Having the interval too low might have negative consquences such as this program being detected as a bot or blocking your IP from accessing the website entirely. See [proxies](#Proxies). Setting `TIME_BETWEEN_CHECKS` might help prevent this. See step 3a from the other [method](#Via-Browser) for a detailed explanation and an example. 113 | 3. Set `OPEN_URL` to false if you don't want the application to automatically open urls when item is in stock 114 | 4. Set `DESKTOP` to false if you want to disable desktop and audible warnings 115 | 5. Optional Settings. 116 | 1. **If** you're planning to track Amazon item(s), you can also set a merchant ID in `AMAZON_MERCHANT_ID` to only get prices from a ceratin merchant. The other [method](#Via-Browser) allows you to select pre-configured IDs items only sold by Amazon depending on the region. 117 | 2. **If** you're planning to track Target item(s), enter your zip code in `TARGET_ZIP_CODE`.\ 118 | **NOTE:** If you encounter an error relating to API Key, you need to get this key yourself: 119 | 1. Go to target.com with the DevTools (Chrome) or Developer Tools (Firefox) open (Google or ask if you're unsure how) 120 | 2. On the console, you should see GET requests as you load the page.\ 121 | In DevTools, you have to click the gear and check "Log XMLHttpRequests" to see them 122 | 3. Click on any of the urls that has the string "key=" and copy the whole key 123 | 4. Paste it to `TARGET_KEY` 124 | 3. **If** you want to send alerts to webhook URL(s) like [Discord](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks), [IFTTT](https://ifttt.com/maker_webhooks/), or [Slack](https://api.slack.com/messaging/webhooks), add them to `WEBHOOK_URLS` array. 125 | 4. **If** you want to use Proxies, change `PROXIES` to `true` and create a file called `proxies.txt` in the `config` directory and fill it with one proxy per line. See [proxies](#Proxies). 126 | 5. **If** you want to send alerts to email, change `EMAIL` to `true`. Make a copy of `example.env` in the `config` directory and rename it to `.env`. Inside `.env`, type out one of the service providers (`EMAIL_SERVICE`) listed in [Email](#Email), your email (`EMAIL_FROM`) and password (`EMAIL_PASS`) and the email you want alerts sent to (`EMAIL_TO`). All without quotes. 127 | 6. **If** you want to send alerts to SMS, change `SMS_METHOD` to either "Email", "Amazon Web Services", or "Twilio". Then change the associated values in `.env`. See [SMS](#SMS). 128 |
129 |
130 | 131 | 2. Execute and continue about your day: 132 | `npm start` OR `node --experimental-modules main.js`\ 133 | You can use `CTRL + C` or `CMD + C` to stop the program. 134 | 135 | 3. Consider [donating](https://www.paypal.com/donate?business=3Y9NEYR4TURT8&item_name=Making+software+and+hacking+the+world%21+%E2%99%A5¤cy_code=USD) or buying me a [Pizza](https://buymeacoff.ee/PrinceSingh) or [PayPal](https://paypal.me/PrinceSingh25) me :smile: 136 |
137 | 138 | 139 | ### Email 140 |
141 | Expand for important information 142 | 143 | Supported email providers: 144 | ``` 145 | Gmail, Yahoo, iCloud, Hotmail, Outlook365, QQ, 126, 163, 1und1, AOL, DebugMail, DynectEmail, 146 | FastMail, GandiMail, Godaddy, GodaddyAsia, GodaddyEurope, hot.ee, mail.ee, Mail.ru, Maildev, Mailgun, Mailjet, 147 | Mailosaur, Mandrill, Naver, OpenMailBox, Postmark, QQex, SendCloud, SendGrid, SendinBlue, SendPulse, SES, 148 | SES-US-EAST-1, SES-US-WEST-2, SES-EU-WEST-1, Sparkpost, Yandex, Zoho, qiye.aliyun 149 | ``` 150 | 151 | **NOTE:** If you receive the error: `535 5.7.0 (#AUTH005) Too many bad auth attempts`, most likely you are using Yahoo for the email server or an email server managed by Yahoo, such as AOL or AT&T. Yahoo has implemented an option that by default, does not let 3rd party products access the email servers. To resolve, go to https://login.yahoo.com/account/security and then enable the option to allow apps that use less secure sign in. Use the password generated by "Generate app password". If you are using AOL, do the same thing, from https://login.aol.com/account/security. 152 |
153 |
154 | 155 | ### SMS 156 |
157 | Expand for important information 158 | 159 | SMS / Text support is available via Amazon Web Services, Email, or Twilio. This, however, requires some setup on your part. Read below regarding setup for each method: 160 | 161 | - **[Amazon Web Services](https://aws.amazon.com/sns)**\ 162 | First, read pricing information [here](https://aws.amazon.com/sns/faqs/#SMS_pricing). First 100 SMS are free for each month as long as you send them to a United States destination. For this method, you will need: 163 | - Region 164 | - Access Key 165 | - Secret Access Key 166 | - Phone Number 167 | 168 | Region is Amazon server you want to send from. It's probably best to choose one closest to you. More information [here](https://docs.aws.amazon.com/sns/latest/dg/sns-supported-regions-countries.html).\ 169 | Access Key and Secret Access Key can be obtained following instructions in this [tutorial](https://medium.com/codephilics/how-to-send-a-sms-using-amazon-simple-notification-service-sns-46208d82abcc).\ 170 | Phone number is the number to send SMS to. You will need to include country code and area code. Country code information can be found [here](https://countrycode.org/). 171 | 172 | - **Email**\ 173 | **FREE** but limited. Uses email to send text via phone carrier's [SMS gateway](https://en.wikipedia.org/wiki/SMS_gateway#Email_clients). Mostly the same setup as [Email](#Email).\ 174 | Currently supported carriers: Alltel, AT&T, Boost Mobil, Cricket Wireless, EE, FirstNet, Google Project Fi, MetroPCS, O2, Republic Wireless, Sprint, Straight Talk, T-Mobile, Ting, U.S. Cellular, Verizon Wireless, Virgin Mobile, Vodafone.\ 175 | If you'd like to request a carrier, please refer to [Feedback and Support](#Feedback-and-Support) and provide your carrier's SMS gateway if possible. 176 | 177 | - **[Twilio](https://www.twilio.com/sms)**\ 178 | First, read pricing information [here](https://www.twilio.com/sms/pricing). You get some free starting balance with which you can buy a Twilio phone number. For this method, you will need: 179 | - Twilio Account SID 180 | - Twilio Auth Token 181 | - Twilio Phone Number 182 | - Phone Number 183 | 184 | The first three can easily be obtained from the [Twilio console](https://www.twilio.com/console) after you make a Twilio account.\ 185 | Phone number is the number to send SMS to. You will need to include country code and area code. Country code information can be found [here](https://countrycode.org/). 186 |
187 |
188 | 189 | 190 | ### Proxies 191 |
192 | Expand for important information 193 | 194 | If you plan to use low interval rates OR track several items from one store, it is highly recommended that you use proxies such as ones from [Webshare](https://www.webshare.io/) in the format `ip:port` for IP-based authentication or `username:password@ip:port`.
\ 195 | **NOTE:** The following stores do not currently work with proxies due to them blocking some connections/headers which results in inconsistent connection: Argos, Best Buy, Gamestop and Tesco. Thus I thought it'd be best to take off proxy support for now until further research is done or an alternative way is found. 196 |
197 |
198 | 199 | 200 | ## Screenshots 201 | Screenshot of URLs 202 | Screenshot of Settings 203 | Screenshot of Optional 204 | 205 | 206 | ## Feedback and Support 207 | To ensure this program continues to work, please report bugs by creating an [issue](https://github.com/Prince25/StockAlertBot/issues).\ 208 | To ask questions, give feedback or suggestions among other things, create a new [discussion](https://github.com/Prince25/StockAlertBot/discussions).\ 209 | To contribute code, programming questions/guidance, gaming sessions, and more, add me on Discord: Prince#0584\ 210 | To provide monetary support, [donate](https://www.paypal.com/donate?business=3Y9NEYR4TURT8&item_name=Making+software+and+hacking+the+world%21+%E2%99%A5¤cy_code=USD) or buy me a [Pizza](https://buymeacoff.ee/PrinceSingh) or [PayPal](https://paypal.me/PrinceSingh25) me 211 |

212 | 213 | 214 | ## Timeline 215 | v4.0: Complete code overhaul! Better control over intervals, new stores and notifications! (see [Features](#Features)) 216 | 217 | v3.0: SMS / Text notification support :iphone: !! (see [SMS](#SMS)) 218 | 219 | v2.0: :email: E-mail notification support and an user interface :computer: (see [screenshots](#Screenshots)) 220 | 221 | v1.0: Official release! New name and webhook notification support. 222 |

223 | 224 | 225 | ## Things to work on 226 | * Add more stores 227 | * Newegg search pages 228 | * Best Buy preorder alerts 229 | * ~~B&H Photo Video~~ (no longer supported) 230 | * ~~Ebuyer~~ 231 | * ~~Walmart~~ 232 | * ~~Gamestop~~ 233 | * ~~Currys~~ 234 | * ~~Newegg Combos~~ 235 | * ~~Newegg~~ 236 | * ~~AntOnline~~ 237 | * ~~Target~~ 238 | * ~~Tesco~~ 239 | * ~~Argos~~ 240 | * Add tests 241 | * Documentation page 242 | * Twitter notifications 243 | * ~~Add delay between items from the same store~~ 244 | * ~~More OOP!!~~ 245 | * ~~Add way to track notification status independent of items in a store~~ 246 | * ~~Fix~~ Find Bugs 247 | * ~~Fix notifications relying on `OPEN_URL`~~ 248 | * ~~Add Ability to use certain Amazon Merchants~~ 249 | * ~~Add Email and SMS Notifications~~ 250 | * ~~Add Proxies~~ 251 | * ~~Add GUI - Make it easier to use~~ 252 | * ~~Initially create seperation between intervals for Amazon items~~ 253 | * ~~Add a way to have independent delay timers for Amazon~~ 254 | * ~~Open product page when in stock~~ 255 | * ~~Add webhookURL to enable posting messages to Slack and Discord~~ 256 |

257 | 258 | 259 | ## Main Technologies 260 | - [Node.js](https://nodejs.org/) with [Express.js](https://expressjs.com/) 261 | - [Vue.js](https://vuejs.org/) powered by [Vuetify](https://vuetifyjs.com/) and [Material Design Icons](https://materialdesignicons.com/) 262 | - [ESLint](https://eslint.org/) with [Prettier](https://prettier.io/) and [Unicorn](https://github.com/sindresorhus/eslint-plugin-unicorn) plugin 263 | - [AWS SDK](https://aws.amazon.com/sdk-for-javascript/) 264 | - [Axios](https://github.com/axios/axios) 265 | - [Chalk](https://github.com/chalk/chalk) 266 | - [Cheerio](https://github.com/cheeriojs/cheerio) 267 | - [Dotenv](https://github.com/motdotla/dotenv) 268 | - [Moment](https://momentjs.com/) 269 | - [Node Fetch](https://github.com/node-fetch/node-fetch) 270 | - [Node Notifier](https://github.com/mikaelbr/node-notifier) 271 | - [Nodemailer](https://nodemailer.com/) 272 | - [Twilio](https://github.com/twilio/twilio-node) 273 |

274 | 275 | 276 | ## License 277 | See the [LICENSE](LICENSE.md) file for license rights and limitations (MIT). 278 | -------------------------------------------------------------------------------- /config/README.md: -------------------------------------------------------------------------------- 1 | The following files go here 2 | * .env 3 | * config.json 4 | * example.env 5 | * proxies.txt -------------------------------------------------------------------------------- /config/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "URLS": [], 3 | "INTERVAL": { 4 | "unit": "seconds", 5 | "value": 10 6 | }, 7 | "TIME_BETWEEN_CHECKS": { 8 | "unit": "seconds", 9 | "value": 3 10 | }, 11 | "STORE_INTERVALS": {}, 12 | "OPEN_URL": true, 13 | "DESKTOP": true, 14 | "AMAZON_MERCHANT_ID": "None", 15 | "TARGET_ZIP_CODE": "90024", 16 | "TARGET_KEY": "9f36aeafbe60771e321a7cc95a78140772ab3e96", 17 | "WEBHOOK_URLS": [], 18 | "PROXIES": false, 19 | "EMAIL": false, 20 | "SMS_METHOD": "None", 21 | "SUPPORTED_STORES_DOMAINS": [ 22 | "amazon", 23 | "antonline", 24 | "argos", 25 | "bestbuy", 26 | "currys", 27 | "ebuyer", 28 | "gamestop", 29 | "microcenter", 30 | "newegg", 31 | "target", 32 | "tesco", 33 | "walmart" 34 | ], 35 | "SUPPORTED_PROXY_DOMAINS": [ 36 | "amazon", 37 | "antonline", 38 | "currys", 39 | "ebuyer", 40 | "microcenter", 41 | "newegg", 42 | "target", 43 | "walmart" 44 | ], 45 | "SUPPORTED_WEBHOOK_DOMAINS": ["discord", "slack", "ifttt"] 46 | } 47 | -------------------------------------------------------------------------------- /config/example.env: -------------------------------------------------------------------------------- 1 | # ** Configuration for alerts ** # 2 | ################################## 3 | 4 | EMAIL_SERVICE = 5 | EMAIL_FROM = 6 | EMAIL_PASS = 7 | EMAIL_TO = 8 | SMS_AWS_REGION = 9 | SMS_AWS_ACCESS_KEY = 10 | SMS_AWS_SECRET_ACCESS = 11 | SMS_AWS_PHONE_NUMBER = 12 | SMS_EMAIL_SERVICE = 13 | SMS_EMAIL_FROM = 14 | SMS_EMAIL_PASS = 15 | SMS_EMAIL_PHONE_CARRIER = 16 | SMS_EMAIL_PHONE_NUMBER = 17 | SMS_TWILIO_ACCOUNT_SID = 18 | SMS_TWILIO_AUTH_TOKEN = 19 | SMS_TWILIO_FROM_NUMBER = 20 | SMS_TWILIO_TO_NUMBER = -------------------------------------------------------------------------------- /logs/README.md: -------------------------------------------------------------------------------- 1 | Directory for errors and other logs -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stock-alert-bot", 3 | "version": "4.0.0", 4 | "description": "Get alerts when items come in stock", 5 | "type": "module", 6 | "main": "main.js", 7 | "scripts": { 8 | "start": "node src/main.js", 9 | "settings": "node server/index.js", 10 | "eslint": "eslint", 11 | "eslintFix": "eslint --fix", 12 | "eslintFixAll": "eslint --fix .", 13 | "prettier": "prettier --write", 14 | "prettierAll": "prettier --write ." 15 | }, 16 | "dependencies": { 17 | "@aws-sdk/client-sns": "^3.738.0", 18 | "axios": "^1.7.9", 19 | "chalk": "^4.1.2", 20 | "cheerio": "^1.0.0", 21 | "cors": "^2.8.5", 22 | "dotenv": "^8.6.0", 23 | "envfile": "^6.22.0", 24 | "express": "^4.21.2", 25 | "https-proxy-agent": "^5.0.1", 26 | "moment": "^2.30.1", 27 | "node-fetch": "^2.7.0", 28 | "node-notifier": "^9.0.1", 29 | "nodemailer": "^6.10.0", 30 | "open": "^7.4.2", 31 | "random-seed": "^0.3.0", 32 | "random-useragent": "^0.5.0", 33 | "tmp": "^0.2.3", 34 | "twilio": "^5.4.3" 35 | }, 36 | "repository": { 37 | "type": "git", 38 | "url": "git+https://github.com/Prince25/StockAlertBot.git" 39 | }, 40 | "author": "Prabhjot Singh", 41 | "license": "MIT", 42 | "bugs": { 43 | "url": "https://github.com/Prince25/StockAlertBot/issues" 44 | }, 45 | "homepage": "https://github.com/Prince25/StockAlertBot#readme", 46 | "devDependencies": { 47 | "eslint": "^7.32.0", 48 | "eslint-config-prettier": "^8.10.0", 49 | "eslint-plugin-prettier": "^3.4.1", 50 | "eslint-plugin-unicorn": "^29.0.0", 51 | "prettier": "^2.8.8" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /server/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | StockAlertBot Settings 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 23 | 27 | 31 | 35 | 36 | 37 | 38 | 48 | 49 |
50 | 51 | 52 | 53 |

StockAlertBot SETTINGS

54 | 55 | 56 | 57 | {{ tab }} 58 | 59 | 60 | 61 | 62 | 67 | 68 | 69 | 70 | 71 | 72 | 86 | 87 | 88 | 89 | 90 | 91 | 116 | 117 | 118 | 119 | 120 | 121 |

{{key}}:{{settings}}

122 |

{{key}}:{{settings}}

123 |

has_amazon: {{has_amazon}}

124 |

has_target: {{has_target}}

125 |

stores: {{[...stores]}}

126 |
127 |
128 | 129 | 130 | 131 | 132 |
133 | 139 | Save Settings 140 | mdi-content-save 141 | 142 |

143 |
144 |
145 |
146 |
147 |
148 | 149 | 1773 | 1774 | 1775 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | import { toConsole } from "../src/utils/log.js"; 2 | 3 | toConsole("setup", "Setting up server..."); 4 | 5 | // Support require 6 | import { createRequire } from "module"; 7 | const require = createRequire(import.meta.url); 8 | 9 | // Attempt to read .env file 10 | // If it doesn't exist, create an .env file with example.env information 11 | toConsole("setup", "Looking for .env file..."); 12 | const fs = require("fs"); 13 | var firstRun = true; 14 | function readEnvironmentFile(firstRun) { 15 | let environmentFile = ""; 16 | try { 17 | environmentFile = fs.readFileSync("config/.env", { encoding: "utf8", flag: "r" }); 18 | if (environmentFile == "") throw new Error(".env file empty!"); 19 | if (firstRun) toConsole("info", ".env file found! Attempting to read..."); 20 | } catch { 21 | if (firstRun) toConsole("info", ".env file not found! Creating a new one..."); 22 | environmentFile = fs.readFileSync("config/example.env", { encoding: "utf8", flag: "r" }); 23 | fs.writeFileSync("config/.env", environmentFile); 24 | } 25 | return environmentFile; 26 | } 27 | readEnvironmentFile(firstRun); 28 | firstRun = false; 29 | 30 | // Import stuff 31 | toConsole("setup", "Importing important stuff..."); 32 | const { parse, stringify } = require("envfile"); 33 | var open = require("open"); 34 | var express = require("express"); 35 | var path = require("path"); 36 | var cors = require("cors"); 37 | var app = express(); 38 | 39 | // Setup express with CORS on port 3250 40 | toConsole("setup", "Starting server..."); 41 | app.use(cors()); 42 | app.options("*", cors()); 43 | app.listen(3250, "0.0.0.0", listening); 44 | 45 | function listening() { 46 | toConsole("setup", "Server started!"); 47 | } 48 | 49 | app.use(express.static("public")); 50 | app.use(express.urlencoded({ extended: false })); 51 | app.use(express.json()); 52 | 53 | /* 54 | Setup routes 55 | */ 56 | toConsole("setup", "Setting up routes..."); 57 | 58 | // index.html: https://localhost:3250/ 59 | app.get("/", getPage); 60 | function getPage(request, response) { 61 | response.sendFile(path.join(path.resolve() + "/server/index.html")); 62 | } 63 | 64 | // GET .env: https://localhost:3250/env 65 | app.get("/env", getEnvironment); 66 | function getEnvironment(request, response) { 67 | let environmentFile = readEnvironmentFile(firstRun); 68 | response.send(parse(environmentFile)); 69 | } 70 | 71 | // POST .env: https://localhost:3250/env 72 | app.post("/env", postEnvironment); 73 | function postEnvironment(request, response) { 74 | toConsole("info", "Settings received! Saving to .env..."); 75 | let environmentSettings = stringify(request.body); 76 | 77 | fs.writeFile("config/.env", environmentSettings, "utf8", function (error) { 78 | if (error) { 79 | response.status(400).send({ error: "Error writing .env" }); 80 | } else { 81 | response.send({ message: "Successfully saved .env" }); 82 | } 83 | }); 84 | } 85 | 86 | // GET config.json: https://localhost:3250/config 87 | app.get("/config", getSettings); 88 | function getSettings(request, response) { 89 | response.sendFile(path.join(path.resolve() + "/config/config.json")); 90 | } 91 | 92 | // POST config.json: https://localhost:3250/config 93 | app.post("/config", postSettings); 94 | function postSettings(request, response) { 95 | toConsole("info", "Settings received! Saving to config.json..."); 96 | let settings = JSON.stringify(request.body, undefined, 4); 97 | 98 | fs.writeFile("config/config.json", settings, "utf8", function (error) { 99 | if (error) { 100 | response.status(400).send({ error: "Error writing config.json" }); 101 | } else { 102 | response.send({ message: "Successfully saved config.json" }); 103 | } 104 | }); 105 | } 106 | 107 | toConsole("info", "Opening settings page on http://localhost:3250/..."); 108 | open("http://localhost:3250/"); 109 | -------------------------------------------------------------------------------- /src/item.js: -------------------------------------------------------------------------------- 1 | import { toFile } from "./utils/log.js"; 2 | import { fetchPage } from "./utils/fetch.js"; 3 | 4 | export default class Item { 5 | constructor(url) { 6 | this.url = url; 7 | this.notificationSent = false; 8 | this.shouldSendNotification = true; 9 | this.html = undefined; 10 | this.info = { 11 | title: undefined, 12 | inventory: undefined, 13 | image: undefined, 14 | }; 15 | } 16 | 17 | /* 18 | Fetches the item page and assigns the html to this.html 19 | Returns a promise of true if successful, false otherwise 20 | */ 21 | async getPage(store, use_proxies, badProxies) { 22 | const response = await fetchPage(this.url, store, use_proxies, badProxies); 23 | switch (response.status) { 24 | case "ok": 25 | this.html = response.html; 26 | return { 27 | status: "ok", 28 | }; 29 | 30 | case "retry": 31 | this.html = response.html; 32 | return { 33 | status: "retry", 34 | bad_proxies: response.badProxies, 35 | }; 36 | 37 | case "error": 38 | this.html = response.html; 39 | toFile(store, response.error, this); 40 | return { 41 | status: "error", 42 | }; 43 | } 44 | } 45 | 46 | /* 47 | Extract item information based on the passed callback function and assigns it to this.info 48 | Returns true if successful, false otherwise 49 | */ 50 | async extractInformation(store, storeFunction) { 51 | const info = await storeFunction(this.html); 52 | if (info.title && info.image && typeof info.inventory == "boolean") { 53 | // Change notification status to false once item goes out of stock 54 | if (this.notificationSent && !info.inventory) this.notificationSent = false; 55 | 56 | this.shouldSendNotification = !this.info.inventory && info.inventory; // Check change in item stock 57 | this.info = info; 58 | return true; 59 | } else if (info.error) { 60 | toFile(store, info.error, this); 61 | return false; 62 | } else { 63 | toFile(store, "Unable to get information", Object.assign(this, info)); 64 | return false; 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import chalk from "chalk"; 3 | import Item from "./item.js"; 4 | import Store from "./store.js"; 5 | import * as dotenv from "dotenv"; 6 | import * as log from "./utils/log.js"; 7 | 8 | log.toConsole("info", chalk.yellow.bold("Thank you for using Stock Alert Bot!")); 9 | log.toConsole("info", chalk.red("https://github.com/Prince25/StockAlertBot\n")); 10 | log.toConsole("setup", "Starting setup..."); 11 | 12 | log.toConsole("setup", "Importing necessary files..."); 13 | /* 14 | Import store functions and assign them 15 | * Important: Edit this when adding new stores 16 | */ 17 | import amazonFunction from "./stores/amazon.js"; 18 | import antonlineFunction from "./stores/antonline.js"; 19 | import argosFunction from "./stores/argos.js"; 20 | import bestbuyFunction from "./stores/bestbuy.js"; 21 | import currysFunction from "./stores/currys.js"; 22 | import ebuyerFunction from "./stores/ebuyer.js"; 23 | import gamestopFunction from "./stores/gamestop.js"; 24 | import microcenterFunction from "./stores/microcenter.js"; 25 | import neweggFunction from "./stores/newegg.js"; 26 | import targetFunction from "./stores/target.js"; 27 | import tescoFunction from "./stores/tesco.js"; 28 | import walmartFunction from "./stores/walmart.js"; 29 | 30 | const storeFunctionMap = { 31 | amazon: amazonFunction, 32 | antonline: antonlineFunction, 33 | argos: argosFunction, 34 | bestbuy: bestbuyFunction, 35 | currys: currysFunction, 36 | ebuyer: ebuyerFunction, 37 | gamestop: gamestopFunction, 38 | microcenter: microcenterFunction, 39 | newegg: neweggFunction, 40 | target: targetFunction, 41 | tesco: tescoFunction, 42 | walmart: walmartFunction, 43 | }; 44 | 45 | // Read config.json 46 | export const { 47 | URLS, 48 | SUPPORTED_STORES_DOMAINS, 49 | INTERVAL, 50 | TIME_BETWEEN_CHECKS, 51 | STORE_INTERVALS, 52 | OPEN_URL, 53 | DESKTOP, 54 | AMAZON_MERCHANT_ID, 55 | TARGET_ZIP_CODE, 56 | TARGET_KEY, 57 | WEBHOOK_URLS, 58 | SUPPORTED_WEBHOOK_DOMAINS, 59 | PROXIES, 60 | SUPPORTED_PROXY_DOMAINS, 61 | EMAIL, 62 | SMS_METHOD, // "None", "Amazon Web Services", "Email", "Twilio" 63 | } = JSON.parse(fs.readFileSync("config/config.json", "UTF-8")); 64 | 65 | // Check for URLs 66 | if (URLS.length == 0) { 67 | log.toConsole("error", "No URLs provided. Exiting."); 68 | } 69 | 70 | // Read proxies.txt 71 | export const PROXY_LIST = (() => 72 | PROXIES 73 | ? fs 74 | .readFileSync("config/proxies.txt", "UTF-8") 75 | .split(/\r?\n/) 76 | .filter((proxy) => proxy != "") 77 | : [])(); 78 | 79 | let shouldTerminate = false; 80 | // Check if webhooks are supported 81 | if (WEBHOOK_URLS.length > 0) { 82 | log.toConsole("setup", "Checking webhooks..."); 83 | for (const url of WEBHOOK_URLS) { 84 | if (!SUPPORTED_WEBHOOK_DOMAINS.some((webhookDomain) => url.includes(webhookDomain))) { 85 | shouldTerminate = true; 86 | log.toConsole("error", "Webhook not supported: " + chalk.blue.bold(url)); 87 | } 88 | } 89 | } 90 | 91 | // Check if .env file is valid 92 | export let environment_config; 93 | if (EMAIL || SMS_METHOD !== "None") { 94 | log.toConsole("setup", "Checking .env file..."); 95 | if (!fs.existsSync("config/.env")) { 96 | log.toConsole( 97 | "error", 98 | chalk.yellow("config/.env") + 99 | " file not found. Make sure to rename example.env file to .env and edit it, or use browser to change settings." 100 | ); 101 | shouldTerminate = true; 102 | } else { 103 | dotenv.config({ 104 | path: "config/.env", 105 | }); 106 | } 107 | 108 | environment_config = { 109 | email: { 110 | service: process.env.EMAIL_SERVICE, 111 | from: process.env.EMAIL_FROM, 112 | pass: process.env.EMAIL_PASS, 113 | to: process.env.EMAIL_TO, 114 | }, 115 | 116 | sms_aws: { 117 | region: process.env.SMS_AWS_REGION, 118 | key: process.env.SMS_AWS_ACCESS_KEY, 119 | secret: process.env.SMS_AWS_SECRET_ACCESS, 120 | phone: process.env.SMS_AWS_PHONE_NUMBER, 121 | }, 122 | 123 | sms_email: { 124 | service: process.env.SMS_EMAIL_SERVICE, 125 | from: process.env.SMS_EMAIL_FROM, 126 | pass: process.env.SMS_EMAIL_PASS, 127 | carrier: process.env.SMS_EMAIL_PHONE_CARRIER, 128 | number: process.env.SMS_EMAIL_PHONE_NUMBER, 129 | }, 130 | 131 | sms_twilio: { 132 | sid: process.env.SMS_TWILIO_ACCOUNT_SID, 133 | auth: process.env.SMS_TWILIO_AUTH_TOKEN, 134 | from: process.env.SMS_TWILIO_FROM_NUMBER, 135 | to: process.env.SMS_TWILIO_TO_NUMBER, 136 | }, 137 | }; 138 | 139 | if ( 140 | EMAIL && 141 | (environment_config.email.service == "" || 142 | environment_config.email.from == "" || 143 | environment_config.email.pass == "" || 144 | environment_config.email.to == "") 145 | ) { 146 | log.toConsole("error", "Email information not provided in " + chalk.yellow("config/.env")); 147 | shouldTerminate = true; 148 | } 149 | 150 | if (SMS_METHOD !== "None") { 151 | switch (SMS_METHOD) { 152 | case "Amazon Web Services": 153 | if ( 154 | environment_config.sms_aws.region == "" || 155 | environment_config.sms_aws.key == "" || 156 | environment_config.sms_aws.secret == "" || 157 | environment_config.sms_aws.phone == "" 158 | ) { 159 | log.toConsole( 160 | "error", 161 | "Amazon Web Services is chosen as SMS method but information is not provided in " + 162 | chalk.yellow("config/.env") 163 | ); 164 | shouldTerminate = true; 165 | } 166 | break; 167 | 168 | case "Email": 169 | if ( 170 | environment_config.sms_email.number.length == 0 || 171 | !environment_config.sms_email.service || 172 | !environment_config.sms_email.from || 173 | !environment_config.sms_email.pass || 174 | !environment_config.sms_email.carrier 175 | ) { 176 | log.toConsole( 177 | "error", 178 | "Email is chosen as SMS method but information is not provided in " + 179 | chalk.yellow("config/.env") 180 | ); 181 | shouldTerminate = true; 182 | } 183 | break; 184 | 185 | case "Twilio": 186 | if ( 187 | environment_config.sms_twilio.sid == "" || 188 | environment_config.sms_twilio.auth == "" || 189 | environment_config.sms_twilio.from == "" || 190 | environment_config.sms_twilio.to == "" 191 | ) { 192 | log.toConsole( 193 | "error", 194 | "Twilio is chosen as SMS method but information is not provided in " + 195 | chalk.yellow("config/.env") 196 | ); 197 | shouldTerminate = true; 198 | } 199 | break; 200 | } 201 | } 202 | } 203 | 204 | /* 205 | Get Domain name from an URL 206 | https://www.FOO.BAR.com/... -> FOO 207 | */ 208 | function getDomainName(url) { 209 | let hostName = new URL(url).hostname; 210 | let host = hostName.split("."); 211 | return host[1]; 212 | } 213 | 214 | /* 215 | Return a map of stores to their item URLs 216 | */ 217 | function getStoreURLMap() { 218 | const storeUrlMap = {}; 219 | for (const url of URLS) { 220 | const storeName = getDomainName(url); 221 | if ({}.propertyIsEnumerable.call(storeUrlMap, storeName)) { 222 | // If store already in map 223 | storeUrlMap[storeName].push(url); // ... add url to array 224 | } else { 225 | storeUrlMap[storeName] = [url]; // Otherwise, create new array 226 | } 227 | } 228 | return storeUrlMap; 229 | } 230 | 231 | /* 232 | Main Function 233 | Creates instances of Store 234 | */ 235 | function main() { 236 | if (shouldTerminate) return; 237 | 238 | // Create instances of stores and add items 239 | const storeUrlMap = getStoreURLMap(); 240 | const storeFunctions = []; 241 | for (const store of Object.keys(storeUrlMap)) { 242 | if (SUPPORTED_STORES_DOMAINS.includes(store)) { 243 | const storeFunction = new Store(store, storeFunctionMap[store]); 244 | for (const url of storeUrlMap[store]) storeFunction.addItem(new Item(url)); 245 | storeFunctions.push(storeFunction); 246 | } else { 247 | // If store is not supported 248 | log.toConsole("error", chalk.cyan.bold(store) + " is currently unsupported!"); 249 | } 250 | } 251 | 252 | for (const store of storeFunctions) { 253 | store.startMonitor(); 254 | } 255 | } 256 | 257 | main(); 258 | -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | import { toConsole } from "./utils/log.js"; 3 | import getMs from "./utils/interval-value.js"; 4 | import sendAlerts from "./utils/notification/alerts.js"; 5 | import { INTERVAL, STORE_INTERVALS, SUPPORTED_PROXY_DOMAINS, TIME_BETWEEN_CHECKS } from "./main.js"; 6 | 7 | const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); 8 | 9 | export default class Store { 10 | constructor(name, storeFunction) { 11 | this.name = name; 12 | this.items = []; 13 | this.bad_proxies = new Set(); 14 | this.supports_proxies = SUPPORTED_PROXY_DOMAINS.includes(name); 15 | this.store_function = storeFunction; 16 | this.interval = getMs(STORE_INTERVALS[name] ? STORE_INTERVALS[name] : INTERVAL); 17 | this.delay = getMs(TIME_BETWEEN_CHECKS); 18 | } 19 | 20 | /* 21 | Adds an Item to the array 22 | */ 23 | addItem(item) { 24 | this.items.push(item); 25 | } 26 | 27 | /* 28 | Starts checking status of items 29 | */ 30 | startMonitor() { 31 | if (this.items.length == 0) { 32 | toConsole("error", "Cannot start montior: no items added!"); 33 | return; 34 | } 35 | 36 | toConsole("setup", "Starting monitor for: " + chalk.cyan.bold(this.name.toUpperCase())); 37 | this.monitorItems(); 38 | } 39 | 40 | /* 41 | Recursively checks all items 42 | */ 43 | async monitorItems() { 44 | const length = this.items.length; 45 | for (const [index, item] of this.items.entries()) { 46 | if (item.info.title) 47 | toConsole( 48 | "check", 49 | "Checking " + 50 | chalk.magenta.bold(item.info.title) + 51 | " at " + 52 | chalk.cyan.bold(this.name.toUpperCase()) 53 | ); 54 | else toConsole("check", "Checking url: " + chalk.magenta(item.url)); 55 | 56 | // Get Item Page 57 | const response = await item.getPage(this.name, this.supports_proxies, this.bad_proxies); 58 | if (response.status == "retry") { 59 | this.bad_proxies = response.bad_proxies; 60 | } else if (response.status == "error") { 61 | if (index != length - 1) await sleep(this.delay); 62 | continue; 63 | } 64 | 65 | // Extract item information from the page 66 | if (!(await item.extractInformation(this.name, this.store_function))) { 67 | if (index != length - 1) await sleep(this.delay); 68 | continue; 69 | } else { 70 | // Send notifications about the item 71 | if (item.info.inventory && item.notificationSent) { 72 | toConsole( 73 | "info", 74 | chalk.magenta.bold(item.info.title) + 75 | " is still in stock at " + 76 | chalk.cyan.bold(this.name.toUpperCase()) 77 | ); 78 | } 79 | if (item.shouldSendNotification && !item.notificationSent) { 80 | sendAlerts(item.url, item.info.title, item.info.image, this.name); 81 | toConsole( 82 | "stock", 83 | chalk.magenta.bold(item.info.title) + 84 | " is in stock at " + 85 | chalk.cyan.bold(this.name.toUpperCase()) + 86 | "!!" 87 | ); 88 | item.notificationSent = true; 89 | } 90 | } 91 | 92 | if (index != length - 1) await sleep(this.delay); 93 | } 94 | 95 | toConsole( 96 | "info", 97 | "Waiting " + 98 | chalk.yellow.bold( 99 | STORE_INTERVALS[this.name] 100 | ? STORE_INTERVALS[this.name].value + " " + STORE_INTERVALS[this.name].unit 101 | : INTERVAL.value + " " + INTERVAL.unit 102 | ) + 103 | " to check " + 104 | chalk.cyan.bold(this.name.toUpperCase()) + 105 | " again" 106 | ); 107 | 108 | setTimeout(this.monitorItems.bind(this), this.interval); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/stores/amazon.js: -------------------------------------------------------------------------------- 1 | import * as cheerio from 'cheerio'; 2 | 3 | export default function amazon(html) { 4 | try { 5 | const TITLE_SELECTOR = "#productTitle"; 6 | const IMAGE_SELECTOR = "#landingImage"; 7 | const IMAGE_BOOK_SELECTOR = "#img-canvas > img"; 8 | const INVENTORY_SELECTOR = "#add-to-cart-button"; 9 | 10 | const $ = cheerio.load(html); 11 | const title = $(TITLE_SELECTOR).text()?.trim(); 12 | let image = $(IMAGE_SELECTOR).attr("data-old-hires"); 13 | let inventory = $(INVENTORY_SELECTOR).attr("value"); 14 | 15 | if (!image) { 16 | image = $(IMAGE_SELECTOR).attr("src"); 17 | if (!image) { 18 | image = $(IMAGE_BOOK_SELECTOR).attr("src"); 19 | } 20 | } 21 | 22 | if (inventory != undefined) { 23 | inventory = true; 24 | } else if (inventory == undefined) { 25 | inventory = false; 26 | } 27 | 28 | return { title, image, inventory }; 29 | } catch (error) { 30 | return { error }; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/stores/antonline.js: -------------------------------------------------------------------------------- 1 | import * as cheerio from 'cheerio'; 2 | 3 | export default function antonline(html) { 4 | try { 5 | const TITLE_SELECTOR = ".title"; 6 | const IMAGE_SELECTOR = "ul.uk-slideshow > li > img"; 7 | const INVENTORY_SELECTOR = "button.uk-button:first"; 8 | 9 | const $ = cheerio.load(html); 10 | const title = $(TITLE_SELECTOR).text()?.trim(); 11 | const image = $(IMAGE_SELECTOR).attr("src"); 12 | let inventory = $(INVENTORY_SELECTOR).text()?.trim(); 13 | 14 | if (inventory == "Add to Cart") { 15 | inventory = true; 16 | } else if (html.includes("OUT OF STOCK ")) { 17 | inventory = false; 18 | } 19 | 20 | return { title, image, inventory }; 21 | } catch (error) { 22 | return { error }; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/stores/argos.js: -------------------------------------------------------------------------------- 1 | import * as cheerio from 'cheerio'; 2 | 3 | export default function argos(html) { 4 | // Hard code PS5 special case 5 | if (html.includes("Sorry, PlayStation®5 is currently unavailable.")) { 6 | return { 7 | title: "PlayStation 5", 8 | image: 9 | "https://media.4rgos.it/s/Argos/8349000_R_SET?w=270&h=270&qlt=75&fmt.jpeg.interlaced=true", 10 | inventory: false, 11 | }; 12 | } 13 | 14 | try { 15 | const TITLE_SELECTOR = "span[data-test='product-title']:first"; 16 | const IMAGE_SELECTOR = 17 | "div.MediaGallerystyles__ImageWrapper-sc-1jwueuh-2.eDqOMU > picture > img"; 18 | const INVENTORY_SELECTOR = "button[data-test='add-to-trolley-button-button']:first"; 19 | 20 | const $ = cheerio.load(html); 21 | const title = $(TITLE_SELECTOR).text()?.trim(); 22 | const image = "https:" + $(IMAGE_SELECTOR).attr("src"); 23 | let inventory = $(INVENTORY_SELECTOR).text()?.trim(); 24 | 25 | if (inventory == "Add to trolley") { 26 | inventory = true; 27 | } else if (html.includes("Currently unavailable")) { 28 | inventory = false; 29 | } 30 | 31 | return { title, image, inventory }; 32 | } catch (error) { 33 | return { error }; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/stores/bestbuy.js: -------------------------------------------------------------------------------- 1 | import * as cheerio from 'cheerio'; 2 | 3 | /* 4 | Checks Best Buy in the following order: 5 | US Normal -> US Package -> US Open box -> Canada 6 | */ 7 | export default function bestbuy(html) { 8 | try { 9 | const TITLE_SELECTOR = "div.sku-title:first"; 10 | const TITLE_SELECTOR_CANADA = "div.x-product-detail-page > h1:first"; 11 | 12 | const IMAGE_SELECTOR = "img.primary-image:first"; 13 | const IMAGE_SELECTOR_US_PACKAGE = ".picture-wrapper > img:first"; 14 | const IMAGE_SELECTOR_CANADA = "img[alt='product image']"; 15 | 16 | const INVENTORY_SELECTOR_US = "button.add-to-cart-button:first"; 17 | const INVENTORY_SELECTOR_US_OPEN_BOX = "span.open-box-option__label"; 18 | const INVENTORY_SELECTOR_CANADA = "button.addToCartButton:first"; 19 | 20 | const CANADA_BACKUP_SELECTOR = "body > script:first"; 21 | 22 | const $ = cheerio.load(html); 23 | 24 | // Check US Normal Products 25 | let title = $(TITLE_SELECTOR).text()?.trim(); 26 | let image = $(IMAGE_SELECTOR).attr("src"); 27 | let inventory = $(INVENTORY_SELECTOR_US).text()?.trim(); 28 | if (inventory == "Add to Cart") { 29 | inventory = true; 30 | } else { 31 | inventory = false; 32 | } 33 | 34 | // Check US Package Products 35 | if (!image && $(IMAGE_SELECTOR_US_PACKAGE).length) { 36 | image = $(IMAGE_SELECTOR_US_PACKAGE).attr("src"); 37 | } 38 | 39 | // Check US Normal Open Box 40 | if (!inventory && $(INVENTORY_SELECTOR_US_OPEN_BOX).length) { 41 | title += " - Open Box"; 42 | inventory = true; 43 | } 44 | 45 | // Check Best Buy Canada Products 46 | if (!title || !image) { 47 | title = $(TITLE_SELECTOR_CANADA).text()?.trim(); 48 | image = $(IMAGE_SELECTOR_CANADA).attr("src"); 49 | let inventory_button = $(INVENTORY_SELECTOR_CANADA); 50 | if (inventory_button.length) { 51 | inventory = inventory_button.attr("disabled") ? false : true; 52 | } else { 53 | inventory = false; 54 | } 55 | 56 | // Backup method 57 | if (!title || !image || !inventory) { 58 | let script = $(CANADA_BACKUP_SELECTOR).html(); 59 | if (script.length > 0) { 60 | script = script.slice( 61 | script.indexOf("window.__INITIAL_STATE__ = {") + 27, 62 | script.indexOf("};") + 1 63 | ); 64 | script = script.length > 0 ? JSON.parse(script) : undefined; 65 | if (script) { 66 | let productInfo = script.product; 67 | title = productInfo.product.name; 68 | image = productInfo.product.productImage; 69 | if (productInfo.availability.shipping.purchasable) inventory = true; 70 | else inventory = false; 71 | } 72 | } 73 | } 74 | } 75 | 76 | return { title, image, inventory }; 77 | } catch (error) { 78 | return { error }; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/stores/currys.js: -------------------------------------------------------------------------------- 1 | import * as cheerio from 'cheerio'; 2 | 3 | export default function currys(html) { 4 | try { 5 | const TITLE_SELECTOR = ".prd-name"; 6 | const IMAGE_SELECTOR = "meta[property='og:image']"; 7 | const INVENTORY_SELECTOR = "div[data-component='add-to-basket-button-wrapper']:first"; 8 | 9 | const $ = cheerio.load(html); 10 | const title = $(TITLE_SELECTOR).text()?.trim(); 11 | const image = $(IMAGE_SELECTOR).attr("content"); 12 | let inventory = $(INVENTORY_SELECTOR).attr("data-button-label"); 13 | 14 | if (inventory) { 15 | inventory = inventory?.trim(); 16 | inventory = inventory == "Add to basket"; 17 | } else if (html.includes("Out of stock")) { 18 | inventory = false; 19 | } 20 | 21 | return { title, image, inventory }; 22 | } catch (error) { 23 | return { error }; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/stores/ebuyer.js: -------------------------------------------------------------------------------- 1 | import * as cheerio from 'cheerio'; 2 | 3 | export default function ebuyer(html) { 4 | try { 5 | const TITLE_SELECTOR = ".product-hero__title"; 6 | const IMAGE_SELECTOR = "div.image-gallery__hero > a > img"; 7 | const INVENTORY_SELECTOR = ".js-add-to-basket-main:first"; 8 | 9 | const $ = cheerio.load(html); 10 | let title = $(TITLE_SELECTOR).text()?.trim(); 11 | const image = $(IMAGE_SELECTOR).attr("src"); 12 | let inventory = $(INVENTORY_SELECTOR).attr("value"); 13 | 14 | if (inventory == "Add to Basket") { 15 | inventory = true; 16 | } else if (inventory == "Pre-order") { 17 | // Check for preorder 18 | title += " - Preorder"; 19 | inventory = true; 20 | } else { 21 | inventory = false; 22 | } 23 | 24 | return { title, image, inventory }; 25 | } catch (error) { 26 | return { error }; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/stores/gamestop.js: -------------------------------------------------------------------------------- 1 | import * as cheerio from 'cheerio'; 2 | 3 | export default function gamestop(html) { 4 | try { 5 | const TITLE_SELECTOR = "h2.product-name:first"; 6 | const IMAGE_SELECTOR = "img.product-main-image:first"; 7 | const IMAGE_BACKUP_SELECTOR = "div.product-image-carousel > picture > source:first"; 8 | const PRODUCT_INFO_SELECTOR = "button.add-to-cart.btn.btn-primary:first"; 9 | 10 | const $ = cheerio.load(html); 11 | const json = $(PRODUCT_INFO_SELECTOR).attr("data-gtmdata"); 12 | const product_info = json ? JSON.parse(json) : undefined; 13 | let title = $(TITLE_SELECTOR).text()?.trim(); 14 | let image = $(IMAGE_SELECTOR).attr("data-src"); 15 | let inventory = product_info?.productInfo?.availability; 16 | 17 | // Backup method 18 | if (!title) { 19 | title = product_info?.productInfo?.name; 20 | } 21 | if (!image) { 22 | image = "https://media.gamestop.com/i/gamestop/" + product_info?.productInfo?.productID; 23 | image = !image ? $(IMAGE_BACKUP_SELECTOR).attr("srcset") : image; 24 | } 25 | inventory = inventory == "Available"; 26 | 27 | return { title, image, inventory }; 28 | } catch (error) { 29 | return { error }; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/stores/microcenter.js: -------------------------------------------------------------------------------- 1 | import * as cheerio from 'cheerio'; 2 | 3 | export default function microcenter(html) { 4 | try { 5 | const TITLE_SELECTOR = "meta[property='og:title']"; 6 | const IMAGE_SELECTOR = ".productImageZoom"; 7 | 8 | const $ = cheerio.load(html); 9 | const title = $(TITLE_SELECTOR).attr("content")?.replace(" - Micro Center", ""); 10 | const image = $(IMAGE_SELECTOR).attr("src"); 11 | 12 | let inventory = undefined; 13 | 14 | if (html.includes("NEW IN STOCK") || html.includes("Open Box:")) { 15 | inventory = true; 16 | } else if (!html.includes("NEW IN STOCK") || !html.includes("Open Box:")) { 17 | inventory = false; 18 | } 19 | 20 | return { title, image, inventory }; 21 | } catch (error) { 22 | return { error }; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/stores/newegg.js: -------------------------------------------------------------------------------- 1 | import * as cheerio from 'cheerio'; 2 | 3 | export default function newegg(html) { 4 | try { 5 | const TITLE_SELECTOR = "h1.product-title:first"; 6 | const IMAGE_SELECTOR = "img.product-view-img-original:first"; 7 | const INVENTORY_SELECTOR = "button.btn.btn-primary.btn-wide:first"; 8 | const TITLE_COMBO_SELECTOR = "title"; 9 | const IMAGE_COMBO_SELECTOR = "img#mainSlide_0:first"; 10 | const INVENTORY_COMBO_SELECTOR = "a.atnPrimary:first"; 11 | 12 | const $ = cheerio.load(html); 13 | let title = $(TITLE_SELECTOR).text()?.trim(); 14 | let image = $(IMAGE_SELECTOR).attr("src"); 15 | let inventory = $(INVENTORY_SELECTOR).text()?.trim().toLowerCase(); 16 | 17 | // Combo Deals 18 | if (!title) { 19 | title = $(TITLE_COMBO_SELECTOR).text()?.trim(); 20 | image = "https:" + $(IMAGE_COMBO_SELECTOR).attr("src"); 21 | inventory = $(INVENTORY_COMBO_SELECTOR) 22 | .text() 23 | ?.trim() 24 | ?.toLowerCase() 25 | ?.replace(" ►", ""); 26 | } 27 | 28 | return { title, image, inventory: inventory == "add to cart" }; 29 | } catch (error) { 30 | return { error }; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/stores/target.js: -------------------------------------------------------------------------------- 1 | import * as cheerio from 'cheerio'; 2 | import { fetchPage } from "../utils/fetch.js"; 3 | import { TARGET_KEY, TARGET_ZIP_CODE } from "../main.js"; 4 | 5 | export default async function target(html) { 6 | try { 7 | const $ = cheerio.load(html); 8 | 9 | // Extract product data and api key JSON inside a script 10 | const scriptContent = $("script").toArray() 11 | .map(el => $(el).html()) 12 | .find(content => content?.includes("'__TGT_DATA__'")); 13 | 14 | if (!scriptContent) process.exit(1); 15 | 16 | const extractJSON = (key) => { 17 | const match = scriptContent.match(new RegExp(`'${key}':\\s*\\{[^}]*value:\\s*deepFreeze\\(JSON\\.parse\\("(.+?)"\\)\\),`, 's')); 18 | return match ? JSON.parse(match[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\').replace(/\\[ntr]/g, '')) : null; 19 | }; 20 | 21 | const tgtData = extractJSON('__TGT_DATA__'); 22 | const configData = extractJSON('__CONFIG__'); 23 | 24 | const findProductEntry = (obj) => { // Find product entry in the JSON 25 | if (typeof obj !== 'object' || !obj) return null; 26 | if (obj.__typename === 'Product') return obj; 27 | return Object.values(obj).map(findProductEntry).find(Boolean); 28 | }; 29 | 30 | const product = findProductEntry(tgtData) || {}; 31 | const title = product?.item?.product_description?.title; 32 | const image = product?.item?.enrichment?.images?.primary_image_url; 33 | const product_id = product?.tcin; 34 | const api_key = configData?.defaultServicesApiKey || configData?.services?.redsky?.apiKey || TARGET_KEY; 35 | 36 | 37 | // Get location ID from zip code 38 | let jsonResponse = await fetchPage( 39 | "https://api.target.com/location_fulfillment_aggregations/v1/preferred_stores?" + 40 | "zipcode=" + TARGET_ZIP_CODE + 41 | "&key=" + api_key, 42 | "target", 43 | true, 44 | new Set(), 45 | false, 46 | true 47 | ); 48 | 49 | const location_id = jsonResponse 50 | ? jsonResponse?.preferred_stores[0]?.location_id 51 | : undefined; 52 | 53 | // Get fulfillment status 54 | jsonResponse = await fetchPage( 55 | "https://redsky.target.com/redsky_aggregations/v1/web/product_fulfillment_v1?" + 56 | "is_bot=false" + 57 | "&channel=WEB" + 58 | "&key=" + api_key + 59 | "&tcin=" + product_id + 60 | "&zip=" + TARGET_ZIP_CODE + 61 | "&store_id=" + location_id + 62 | "&scheduled_delivery_store_id=" + location_id, 63 | "target", 64 | true, 65 | new Set(), 66 | false, 67 | true 68 | ); 69 | jsonResponse = jsonResponse ? jsonResponse.data?.product?.fulfillment : undefined; 70 | 71 | let in_store = false; 72 | if (jsonResponse && jsonResponse.store_options && jsonResponse.store_options.length > 0) 73 | in_store = jsonResponse.store_options.some((store) => { 74 | if (store.order_pickup || store.in_store_only || store.ship_to_store) 75 | return ( 76 | store.order_pickup?.availability_status == "IN_STOCK" || 77 | store.in_store_only?.availability_status == "IN_STOCK" || 78 | store.ship_to_store?.availability_status == "IN_STOCK" 79 | ); 80 | }); 81 | 82 | let shipping = false; 83 | if (jsonResponse && jsonResponse.shipping_options) 84 | shipping = jsonResponse.shipping_options.availability_status == "IN_STOCK"; 85 | 86 | let delivery = false; 87 | if (jsonResponse && jsonResponse.scheduled_delivery) 88 | delivery = jsonResponse.scheduled_delivery.availability_status == "IN_STOCK"; 89 | 90 | const inventory = in_store || shipping || delivery; 91 | 92 | return { title, image, inventory }; 93 | } catch (error) { 94 | return { error }; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/stores/tesco.js: -------------------------------------------------------------------------------- 1 | import * as cheerio from 'cheerio'; 2 | 3 | export default function tesco(html) { 4 | try { 5 | const TITLE_SELECTOR = "h1.product-details-tile__title"; 6 | const IMAGE_SELECTOR = "img.product-image.product-image-visible"; 7 | const INVENTORY_SELECTOR = "button.add-control.button-secondary"; 8 | 9 | const $ = cheerio.load(html); 10 | const title = $(TITLE_SELECTOR).text()?.trim(); 11 | const image = $(IMAGE_SELECTOR).attr("src"); 12 | let inventory = $(INVENTORY_SELECTOR).text()?.trim(); 13 | 14 | inventory = inventory.includes("to basket"); 15 | 16 | return { title, image, inventory }; 17 | } catch (error) { 18 | return { error }; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/stores/walmart.js: -------------------------------------------------------------------------------- 1 | import * as cheerio from 'cheerio'; 2 | 3 | export default function walmart(html) { 4 | try { 5 | const SCRIPT_SELECTOR = "#__NEXT_DATA__"; 6 | const TITLE_SELECTOR = '.lh-copy[itemprop = "name"]'; 7 | const IMAGE_SELECTOR = ".self-center > img"; 8 | const IMAGE_SELECTOR_BACKUP = 'li[data-slide="0"] img'; 9 | const INVENTORY_SELECTOR = 10 | 'div[data-testid="add-to-cart-section"] span[style="visibility:visible"]'; 11 | const SELLER_SELECTOR = 'a[data-testid="seller-name-link"]'; 12 | 13 | const $ = cheerio.load(html); 14 | let title, image, seller, inventory; 15 | 16 | // Script method 17 | const script = $(SCRIPT_SELECTOR).html()?.trim(); 18 | const product_info = script 19 | ? JSON.parse(script).props?.pageProps?.initialData?.data?.product 20 | : undefined; 21 | if (product_info) { 22 | title = product_info.name; 23 | image = product_info.imageInfo?.thumbnailUrl; 24 | seller = product_info?.sellerName; 25 | inventory = product_info.availabilityStatus == "IN_STOCK" && seller == "Walmart.com"; 26 | } 27 | 28 | // HTML method 29 | else { 30 | title = $(TITLE_SELECTOR).text()?.trim(); 31 | image = $(IMAGE_SELECTOR).attr("src"); 32 | if (!image) { 33 | image = $(IMAGE_SELECTOR_BACKUP).attr("src"); 34 | } 35 | seller = $(SELLER_SELECTOR).text()?.trim(); 36 | inventory = $(INVENTORY_SELECTOR).text()?.trim(); 37 | inventory = inventory == "Add to cart" && seller == ""; 38 | } 39 | 40 | return { title, image, inventory }; 41 | } catch (error) { 42 | return { error }; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/utils/fetch.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import fetch from "node-fetch"; 3 | import rand from "random-seed"; 4 | import ua from "random-useragent"; 5 | import { toConsole } from "./log.js"; 6 | import HttpsProxyAgent from "https-proxy-agent"; 7 | import { AMAZON_MERCHANT_ID, PROXIES, PROXY_LIST } from "../main.js"; 8 | 9 | const random = rand.create(); 10 | const PROXY_BLOCKING_MESSAGES = [ 11 | "Are you a human?", 12 | "Help us keep your account safe by clicking on the checkbox below", 13 | "we just need to make sure you're not a robot", 14 | "discuss automated access to Amazon", // Amazon 15 | "detected unusual traffic from this device", // Newegg 16 | ]; 17 | 18 | /* 19 | Get a random user agent 20 | */ 21 | export function getUserAgent() { 22 | return ua.getRandom(function (ua) { 23 | return ua.deviceType != "mobile" && parseFloat(ua.browserVersion) >= 40; 24 | }); 25 | } 26 | 27 | /* 28 | Get a random proxy from the list 29 | */ 30 | export function getProxy(badProxies) { 31 | let proxy; 32 | if (PROXY_LIST.length == badProxies.size) { 33 | badProxies = new Set(); 34 | toConsole("info", "All proxies used. Resetting bad proxy list."); 35 | } 36 | do { 37 | proxy = "http://" + PROXY_LIST[random(PROXY_LIST.length)]; 38 | } while (badProxies.has(proxy)); 39 | return proxy; 40 | } 41 | 42 | /* 43 | Fetches the item page and returns html in a promise 44 | Returns false if not successful 45 | */ 46 | export function fetchPage(url, store, use_proxies, badProxies, retry = false, getJSON = false) { 47 | const headers = { 48 | "user-agent": getUserAgent(), 49 | pragma: "no-cache", 50 | "cache-control": "no-cache", 51 | accept: 52 | "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", 53 | "accept-language": "en-US,en;q=0.9", 54 | "upgrade-insecure-requests": 1, 55 | }, 56 | options = { 57 | headers, 58 | timeout: 15000, 59 | }; 60 | 61 | let proxy = undefined, 62 | agent = undefined; 63 | 64 | if (use_proxies && PROXIES) { 65 | proxy = getProxy(badProxies); 66 | agent = new HttpsProxyAgent(proxy); 67 | Object.assign(options, { agent }); 68 | } else if (!use_proxies && PROXIES) { 69 | toConsole( 70 | "error", 71 | `Proxies are turned on but ${store} does not currently support proxies. Your IP will be used!!` 72 | ); 73 | } 74 | if (!use_proxies) return fetchPageViaAxios(url); 75 | 76 | // Update URL for Amazon if a particular merchant is selected 77 | if (store == "amazon" && AMAZON_MERCHANT_ID !== "None") { 78 | url = url + "?m=" + AMAZON_MERCHANT_ID; 79 | } 80 | 81 | // For Target 82 | if (getJSON) { 83 | return fetchJSON(url, options); 84 | } 85 | 86 | let sourceHTML = undefined; 87 | return fetch(url, options) 88 | .then(async (response) => { 89 | if (response && response.ok) { 90 | return response.text(); 91 | } else if (store == "antonline" && response && response.status == "404") { 92 | // Hard code Ant online status code for out of stock items 93 | return response.text(); 94 | } else if (store == "currys" && response && response.status == "503") { 95 | // Hard code Currys showing high traffic page 96 | sourceHTML = response.text(); 97 | if (sourceHTML.includes("getting waaaay more traffic than usual")) { 98 | toConsole( 99 | "alert", 100 | "High traffic redirect page at Currys! This may mean a hot item might be getting restocked." 101 | ); 102 | } else { 103 | throw new Error(response.status + " - " + response.statusText); 104 | } 105 | } else { 106 | sourceHTML = await response.text(); 107 | throw new Error(response.status + " - " + response.statusText); 108 | } 109 | }) 110 | .then((html) => { 111 | // If request was blocked.. 112 | if (PROXY_BLOCKING_MESSAGES.some((message) => html.includes(message))) { 113 | // ..via proxy, add it to bad list and retry 114 | if (use_proxies && PROXIES) { 115 | toConsole("info", `Proxy, ${proxy}, was blocked! Retrying...`); 116 | badProxies.add(proxy); 117 | return fetchPage(url, store, use_proxies, badProxies, true); 118 | } 119 | // Otherwise, Raise error 120 | else { 121 | sourceHTML = html; 122 | throw new Error(`Request to ${store} was blocked!`); 123 | } 124 | } 125 | 126 | return retry 127 | ? { 128 | status: "retry", 129 | html, 130 | badProxies, 131 | } 132 | : { 133 | status: "ok", 134 | html, 135 | }; 136 | }) 137 | .catch(async (error) => { 138 | toConsole("error", "Error getting page for url: " + url + ". "); 139 | return { 140 | status: "error", 141 | error, 142 | html: sourceHTML, 143 | }; 144 | }); 145 | } 146 | 147 | /* 148 | Fetches a JSON page and returns it 149 | */ 150 | export function fetchJSON(url, options) { 151 | let sourceHTML = undefined; 152 | return fetch(url, options) 153 | .then(async (response) => { 154 | if (response && response.ok) { 155 | return response.json(); 156 | } else { 157 | sourceHTML = await response.text(); 158 | throw new Error(response.status + " - " + response.statusText); 159 | } 160 | }) 161 | .then((json) => json) 162 | .catch(async (error) => { 163 | toConsole("error", "Error getting page for url: " + url + ". "); 164 | return { 165 | status: "error", 166 | error, 167 | html: sourceHTML, 168 | }; 169 | }); 170 | } 171 | 172 | /* 173 | Uses Axios to fetch the item page and returns html in a promise 174 | Returns false if not successful 175 | */ 176 | function fetchPageViaAxios(url) { 177 | let sourceHTML = undefined; 178 | return axios 179 | .get(url) 180 | .then(async (response) => { 181 | if (response && response.status == 200) { 182 | return { 183 | status: "ok", 184 | html: response.data, 185 | }; 186 | } else { 187 | sourceHTML = await response.text(); 188 | throw new Error(response.status + " - " + response.statusText); 189 | } 190 | }) 191 | .catch((error) => { 192 | toConsole("error", "Error getting page for url: " + url + ". "); 193 | return { 194 | status: "error", 195 | error, 196 | html: sourceHTML, 197 | }; 198 | }); 199 | } 200 | -------------------------------------------------------------------------------- /src/utils/interval-value.js: -------------------------------------------------------------------------------- 1 | /* 2 | Returns milliseconds from given interval units and value 3 | */ 4 | export default function getMSFromInterval(interval) { 5 | switch (interval.unit) { 6 | case "milliseconds": 7 | return interval.value; 8 | 9 | case "seconds": 10 | return interval.value * 1000; 11 | 12 | case "minutes": 13 | return interval.value * 1000 * 60; 14 | 15 | case "hours": 16 | return interval.value * 1000 * 60 * 60; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/log.js: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import util from "util"; 3 | import chalk from "chalk"; // https://www.npmjs.com/package/chalk 4 | import moment from "moment"; 5 | 6 | const DIRECTORY = "logs/"; 7 | const MOMENT_FORMAT = "hh:mm:ss: a"; 8 | 9 | /* 10 | Writes a message to console for one of the following types: 11 | error, info, setup, stock 12 | */ 13 | export function toConsole(type, message) { 14 | const time = () => moment().format(MOMENT_FORMAT); 15 | switch (type.toLowerCase()) { 16 | case "alert": 17 | console.error( 18 | "[" + 19 | chalk.yellow.bold(type.toUpperCase()) + 20 | "]" + 21 | "\t" + 22 | chalk.gray.italic(time()) + 23 | ": " + 24 | message 25 | ); 26 | break; 27 | 28 | case "check": 29 | console.error( 30 | "[" + 31 | chalk.magentaBright.bold(type.toUpperCase()) + 32 | "]" + 33 | "\t" + 34 | chalk.gray.italic(time()) + 35 | ": " + 36 | message 37 | ); 38 | break; 39 | 40 | case "error": 41 | console.error( 42 | "[" + 43 | chalk.red.bold(type.toUpperCase()) + 44 | "]" + 45 | "\t" + 46 | chalk.gray.italic(time()) + 47 | ": " + 48 | message 49 | ); 50 | break; 51 | 52 | case "info": 53 | console.info( 54 | "[" + 55 | chalk.cyan.bold(type.toUpperCase()) + 56 | "]" + 57 | "\t" + 58 | chalk.gray.italic(time()) + 59 | ": " + 60 | message 61 | ); 62 | break; 63 | 64 | case "setup": 65 | console.info( 66 | "[" + 67 | chalk.bold(type.toUpperCase()) + 68 | "]" + 69 | "\t" + 70 | chalk.gray.italic(time()) + 71 | ": " + 72 | message 73 | ); 74 | break; 75 | 76 | case "stock": 77 | console.info( 78 | "[" + 79 | chalk.green.bold(type.toUpperCase()) + 80 | "]" + 81 | "\t" + 82 | chalk.gray.italic(time()) + 83 | ": " + 84 | message 85 | ); 86 | break; 87 | } 88 | } 89 | 90 | /* 91 | Writes a message (preferably an error) to log file 92 | */ 93 | export function toFile(store, error, item = undefined) { 94 | const itemClone = Object.assign({}, item); 95 | delete itemClone.html; 96 | fs.writeFile( 97 | DIRECTORY + store + ".log", 98 | util.inspect(error) + 99 | (item ? "\n" + "ITEM_INFO: " + JSON.stringify(itemClone, undefined, 4) : ""), 100 | function (error) { 101 | if (error) toConsole("error", chalk.red("File write error: ") + error); 102 | } 103 | ); 104 | 105 | let message = 106 | "Error occured for " + 107 | chalk.cyan.bold(store.toUpperCase()) + 108 | (item && item.title 109 | ? " while checking the item, " + chalk.magenta.bold(item.title) 110 | : item 111 | ? " while checking url: " + chalk.magenta(item.url) 112 | : ""); 113 | ".\n\t\t Writing error information to " + chalk.yellow(DIRECTORY + store + ".log."); 114 | 115 | if (item && item.html) { 116 | message += 117 | "\n\t\t Writing HTML to " + chalk.yellow(DIRECTORY + store + "ErrorPage.html."); 118 | 119 | fs.writeFile(DIRECTORY + store + "ErrorPage.html", item.html, function (error) { 120 | if (error) toConsole("error", chalk.red("File write error: ") + error); 121 | }); 122 | } 123 | message += 124 | "\n\t\t This is usually not a problem but if this error appears frequently, please report the error with the log and html files to GitHub."; 125 | 126 | toConsole("error", message); 127 | } 128 | -------------------------------------------------------------------------------- /src/utils/notification/alerts.js: -------------------------------------------------------------------------------- 1 | import open from "open"; 2 | import sendAlertToEmail from "./email.js"; 3 | import sendDesktopAlert from "./desktop.js"; 4 | import sendAlertToWebhooks from "./webhook.js"; 5 | import sendAlertToSMSViaAWS from "./sms-aws.js"; 6 | import sendAlertToSMSViaEmail from "./sms-email.js"; 7 | import sendAlertToSMSViaTwilio from "./sms-twilio.js"; 8 | import { 9 | DESKTOP, 10 | EMAIL, 11 | environment_config, 12 | OPEN_URL, 13 | SMS_METHOD, 14 | WEBHOOK_URLS, 15 | } from "../../main.js"; 16 | 17 | let openedDonateLink = false; 18 | export default function sendAlerts(product_url, title, image, store) { 19 | if (DESKTOP) sendDesktopAlert(product_url, title, image, store); 20 | if (EMAIL) sendAlertToEmail(environment_config.email, product_url, title, image, store); 21 | if (OPEN_URL) { 22 | if (!openedDonateLink) { 23 | open( 24 | "https://www.paypal.com/donate/?business=3Y9NEYR4TURT8&item_name=Making+software+and+hacking+the+world%21+%E2%99%A5¤cy_code=USD" 25 | ); 26 | openedDonateLink = true; 27 | setTimeout(() => (openedDonateLink = false), 30 * 60 * 1000); 28 | } 29 | open(product_url); 30 | } 31 | if (SMS_METHOD == "Amazon Web Services") 32 | sendAlertToSMSViaAWS(environment_config.sms_aws, product_url, title, store); 33 | if (SMS_METHOD == "Email") 34 | sendAlertToSMSViaEmail(environment_config.sms_email, product_url, title, image, store); 35 | if (SMS_METHOD == "Twilio") 36 | sendAlertToSMSViaTwilio(environment_config.sms_twilio, product_url, title, store); 37 | if (WEBHOOK_URLS.length > 0) 38 | sendAlertToWebhooks(WEBHOOK_URLS, product_url, title, image, store); 39 | } 40 | -------------------------------------------------------------------------------- /src/utils/notification/desktop.js: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import tmp from "tmp"; 3 | import open from "open"; 4 | import axios from "axios"; 5 | import * as log from "../log.js"; 6 | import notifier from "node-notifier"; 7 | 8 | // Triggers if `wait: true` and user clicks notification 9 | let url; 10 | notifier.on("click", () => { 11 | open(url); 12 | }); 13 | tmp.setGracefulCleanup(); 14 | 15 | export default function sendDesktopAlert(product_url, title, image, store) { 16 | try { 17 | log.toConsole("alert", "Sending notification to Desktop!"); 18 | 19 | // Create a temporary file 20 | tmp.file( 21 | { prefix: store, postfix: ".jpg" }, 22 | async function (error, path, fd, cleanupCallback) { 23 | if (error) throw error; 24 | 25 | // Download image to the tmp file for Desktop Thumbnail 26 | const downloadImage = async () => { 27 | const photoWriter = fs.createWriteStream(path); 28 | const response = await axios 29 | .get(image, { responseType: "stream" }) 30 | .catch(function (error) { 31 | log.toFile(store, error); 32 | }); 33 | 34 | if (response && response.status == 200 && response.data) { 35 | response.data.pipe(photoWriter); 36 | return new Promise((resolve, reject) => { 37 | photoWriter.on("finish", resolve); 38 | photoWriter.on("error", reject); 39 | }); 40 | } 41 | }; 42 | 43 | await downloadImage(); 44 | url = product_url; 45 | notifier.notify( 46 | { 47 | // Send Desktop Notification 48 | title: "***** In Stock at " + store + " *****", 49 | message: title, 50 | subtitle: "Stock Alert Bot", 51 | icon: path, 52 | contentImage: image, 53 | open: product_url, 54 | sound: true, // Only Notification Center or Windows Toasters 55 | wait: true, // Wait with callback 56 | }, 57 | function () { 58 | cleanupCallback(); // Delete tmp file 59 | } 60 | ); 61 | } 62 | ); 63 | } catch (error) { 64 | log.toFile(store, error); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/utils/notification/email.js: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | import moment from "moment"; 3 | import * as log from "../log.js"; 4 | import nodemailer from "nodemailer"; 5 | 6 | export default async function sendAlertToEmail(email, product_url, title, image, store) { 7 | var transporter = nodemailer.createTransport({ 8 | service: email.service, 9 | auth: { 10 | user: email.from, 11 | pass: email.pass, 12 | }, 13 | }); 14 | 15 | var mailOptions = { 16 | from: `"StockAlertBot" <${email.from}>`, 17 | to: email.to, 18 | subject: "***** In Stock at " + store + " *****", 19 | text: `${title} \n\n${product_url} \n\nStockAlertBot | ${moment().format( 20 | "MMM Do YYYY - h:mm:ss A" 21 | )}\nhttps://github.com/Prince25/StockAlertBot`, 22 | attachments: [ 23 | { 24 | filename: "Product.jpg", 25 | path: image, 26 | }, 27 | ], 28 | }; 29 | 30 | transporter.sendMail(mailOptions, function (error, info) { 31 | if (error) { 32 | log.toConsole("error", "Error sending Email notification: " + error); 33 | log.toFile("Email", error); 34 | } else { 35 | log.toConsole( 36 | "alert", 37 | "Email notification sent to " + chalk.yellow.bold(info.accepted[0]) + "!" 38 | ); 39 | } 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /src/utils/notification/sms-aws.js: -------------------------------------------------------------------------------- 1 | import { SNS } from "@aws-sdk/client-sns"; 2 | import moment from "moment"; 3 | import * as log from "../log.js"; 4 | 5 | export default async function sendAlertToSMSViaAWS(service, product_url, title, store) { 6 | log.toConsole("alert", "Sending SMS notification via AWS!"); 7 | 8 | var sns = new SNS({ 9 | region: service.region, 10 | credentials: { 11 | accessKeyId: service.key, 12 | secretAccessKey: service.secret, 13 | }, 14 | }); 15 | 16 | var parameters = { 17 | Message: `${title} in stock at ${store}! \n\n${product_url} \n\nStockAlertBot | ${moment().format( 18 | "MMM Do YYYY - h:mm:ss A" 19 | )}\nhttps://github.com/Prince25/StockAlertBot`, 20 | MessageStructure: "string", 21 | PhoneNumber: "+" + service.phone, 22 | }; 23 | 24 | sns.publish(parameters, (error) => { 25 | if (error) { 26 | log.toConsole( 27 | "error", 28 | "Error sending SMS notification via AWS: " + error + "\n" + error.stack 29 | ); 30 | log.toFile("sms-aws", error); 31 | } 32 | }); 33 | } 34 | -------------------------------------------------------------------------------- /src/utils/notification/sms-email.js: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | import moment from "moment"; 3 | import * as log from "../log.js"; 4 | import nodemailer from "nodemailer"; 5 | 6 | export default async function sendAlertToSMSViaEmail(sms_email, product_url, title, image, store) { 7 | var transporter = nodemailer.createTransport({ 8 | service: sms_email.service, 9 | auth: { 10 | user: sms_email.from, 11 | pass: sms_email.pass, 12 | }, 13 | }); 14 | 15 | var mailOptions = { 16 | from: `"StockAlertBot" <${sms_email.from}>`, 17 | to: sms_email.number + "@" + sms_email.carrier, 18 | subject: "***** In Stock at " + store + " *****", 19 | text: `${title} \n\n${product_url} \n\nStockAlertBot | ${moment().format( 20 | "MMM Do YYYY - h:mm:ss A" 21 | )}\nhttps://github.com/Prince25/StockAlertBot`, 22 | attachments: [ 23 | { 24 | filename: "Product.jpg", 25 | path: image, 26 | }, 27 | ], 28 | }; 29 | 30 | transporter.sendMail(mailOptions, (error, info) => { 31 | if (error) { 32 | log.toConsole("error", "Error sending SMS via Email notification: " + error); 33 | log.toFile("sms-email", error); 34 | } else { 35 | log.toConsole( 36 | "alert", 37 | "SMS via Email notification sent to " + chalk.yellow.bold(info.accepted[0]) + "!" 38 | ); 39 | } 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /src/utils/notification/sms-twilio.js: -------------------------------------------------------------------------------- 1 | import Twilio from "twilio"; 2 | import moment from "moment"; 3 | import * as log from "../log.js"; 4 | 5 | export default async function sendAlertToSMSViaTwilio(twilio, product_url, title, store) { 6 | log.toConsole("alert", "Sending SMS notification via Twilio!"); 7 | 8 | try { 9 | var client = new Twilio(twilio.sid, twilio.auth); 10 | client.messages.create({ 11 | from: "+" + twilio.from, 12 | to: "+" + twilio.to, 13 | body: `***** In Stock at ${store} ***** \n\n${title} \n\n${product_url} \n\nStockAlertBot | ${moment().format( 14 | "MMM Do YYYY - h:mm:ss A" 15 | )}\nhttps://github.com/Prince25/StockAlertBot`, 16 | }); 17 | } catch (error) { 18 | log.toConsole("error", "Error sending SMS notification via Twilio: " + error); 19 | log.toFile("sms-twilio", error); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/notification/webhook.js: -------------------------------------------------------------------------------- 1 | import moment from "moment"; 2 | import fetch from "node-fetch"; 3 | import * as log from "../log.js"; 4 | 5 | export default async function sendAlertToWebhooks(WEBHOOK_URLS, product_url, title, image, store) { 6 | for (const url of WEBHOOK_URLS) { 7 | // Notify Discord 8 | if (url.includes("discord")) { 9 | log.toConsole("alert", "Sending notification to Discord!"); 10 | fetch(url, { 11 | method: "POST", 12 | headers: { 13 | "Content-type": "application/json", 14 | }, 15 | body: JSON.stringify({ 16 | username: "StockAlertBot", 17 | embeds: [ 18 | { 19 | title: title, 20 | url: product_url, 21 | color: "15736093", 22 | footer: { 23 | text: `StockAlertBot | ${moment().format( 24 | "MMMM Do YYYY - h:mm:ss A" 25 | )}\nhttps://github.com/Prince25/StockAlertBot`, 26 | }, 27 | thumbnail: { 28 | url: image, 29 | }, 30 | fields: [ 31 | { 32 | name: "Store", 33 | value: store, 34 | inline: true, 35 | }, 36 | { 37 | name: "Status", 38 | value: "In Stock", 39 | inline: true, 40 | }, 41 | { 42 | name: "Product Page", 43 | value: product_url, 44 | }, 45 | ], 46 | }, 47 | ], 48 | }), 49 | }).catch((error) => { 50 | log.toConsole("error", "Error sending notification to Discord: " + error); 51 | log.toFile("Discord", error); 52 | }); 53 | 54 | // Notify Slack 55 | } else if (url.includes("slack")) { 56 | log.toConsole("alert", "Sending notification to Slack!"); 57 | fetch(url, { 58 | method: "POST", 59 | body: JSON.stringify({ 60 | attachments: [ 61 | { 62 | title: title, 63 | title_link: product_url, 64 | color: "#36a64f", 65 | fields: [ 66 | { 67 | title: "Store", 68 | value: store, 69 | short: true, 70 | }, 71 | { 72 | title: "Status", 73 | value: "In Stock", 74 | short: true, 75 | }, 76 | { 77 | title: "Product Page", 78 | value: product_url, 79 | short: false, 80 | }, 81 | ], 82 | thumb_url: image, 83 | footer: `StockAlertBot | ${moment().format( 84 | "MMMM Do YYYY - h:mm:ss A" 85 | )}\nhttps://github.com/Prince25/StockAlertBot`, 86 | }, 87 | ], 88 | }), 89 | }).catch((error) => { 90 | log.toConsole("error", "Error sending notification to Slack: " + error); 91 | log.toFile("Slack", error); 92 | }); 93 | 94 | // Notify IFTTT 95 | } else if (url.includes("ifttt")) { 96 | log.toConsole("alert", "Sending notification to IFTTT!"); 97 | fetch(url, { 98 | method: "POST", 99 | url: url, 100 | headers: { 101 | "Content-type": "application/json", 102 | }, 103 | body: JSON.stringify({ 104 | value1: title, 105 | value2: product_url, 106 | value3: image, 107 | }), 108 | }).catch((error) => { 109 | log.toConsole("error", "Error sending notification to IFTTT: " + error); 110 | log.toFile("IFTTT", error); 111 | }); 112 | } 113 | } 114 | } 115 | --------------------------------------------------------------------------------