feat(notification): add price to links (#1209)

fix(store): label selection ordering and pricing
fix(nvidia): deprecation notice removed outside of usa
fix(amazon): maxPrice selector

Resolves #1188
Resolves #673
Resolves #1187
This commit is contained in:
Jef LeCompte
2020-12-05 23:22:58 -05:00
committed by GitHub
parent f7b32e8ac5
commit 15ec12b0a3
17 changed files with 157 additions and 112 deletions
+50 -17
View File
@@ -395,6 +395,21 @@
"kuler": "^2.0.0" "kuler": "^2.0.0"
} }
}, },
"@discordjs/collection": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-0.1.6.tgz",
"integrity": "sha512-utRNxnd9kSS2qhyivo9lMlt5qgAUasH2gb7BEOn6p0efFh24gjGomHzWKMAPn2hEReOPQZCJaRKoURwRotKucQ=="
},
"@discordjs/form-data": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@discordjs/form-data/-/form-data-3.0.1.tgz",
"integrity": "sha512-ZfFsbgEXW71Rw/6EtBdrP5VxBJy4dthyC0tpQKGKmYFImlmmrykO14Za+BiIVduwjte0jXEBlhSKf0MWbFp9Eg==",
"requires": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
}
},
"@eslint/eslintrc": { "@eslint/eslintrc": {
"version": "0.2.1", "version": "0.2.1",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.2.1.tgz", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.2.1.tgz",
@@ -1224,6 +1239,14 @@
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
"dev": true "dev": true
}, },
"abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"requires": {
"event-target-shim": "^5.0.0"
}
},
"acorn": { "acorn": {
"version": "8.0.4", "version": "8.0.4",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.0.4.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.0.4.tgz",
@@ -2747,24 +2770,25 @@
"path-type": "^4.0.0" "path-type": "^4.0.0"
} }
}, },
"discord-webhook-node": { "discord.js": {
"version": "1.1.8", "version": "12.5.1",
"resolved": "https://registry.npmjs.org/discord-webhook-node/-/discord-webhook-node-1.1.8.tgz", "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-12.5.1.tgz",
"integrity": "sha512-3u0rrwywwYGc6HrgYirN/9gkBYqmdpvReyQjapoXARAHi0P0fIyf3W5tS5i3U3cc7e44E+e7dIHYUeec7yWaug==", "integrity": "sha512-VwZkVaUAIOB9mKdca0I5MefPMTQJTNg0qdgi1huF3iwsFwJ0L5s/Y69AQe+iPmjuV6j9rtKoG0Ta0n9vgEIL6w==",
"requires": { "requires": {
"form-data": "^3.0.0", "@discordjs/collection": "^0.1.6",
"node-fetch": "^2.6.0" "@discordjs/form-data": "^3.0.1",
"abort-controller": "^3.0.0",
"node-fetch": "^2.6.1",
"prism-media": "^1.2.2",
"setimmediate": "^1.0.5",
"tweetnacl": "^1.0.3",
"ws": "^7.3.1"
}, },
"dependencies": { "dependencies": {
"form-data": { "tweetnacl": {
"version": "3.0.0", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.0.tgz", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
"integrity": "sha512-CKMFDglpbMi6PyN+brwB9Q/GOw0eAnsrEZDgcsH5Krhz5Od/haKHAX0NmQfha2zPPz0JpWzA7GJHGSnvCRLWsg==", "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="
"requires": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
}
} }
} }
}, },
@@ -3712,6 +3736,11 @@
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
"dev": true "dev": true
}, },
"event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="
},
"eventemitter3": { "eventemitter3": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz",
@@ -8781,6 +8810,11 @@
"fast-diff": "^1.1.2" "fast-diff": "^1.1.2"
} }
}, },
"prism-media": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/prism-media/-/prism-media-1.2.3.tgz",
"integrity": "sha512-fSrR66n0l6roW9Rx4rSLMyTPTjRTiXy5RVqDOurACQ6si1rKHHKDU5gwBJoCsIV0R3o9gi+K50akl/qyw1C74A=="
},
"process": { "process": {
"version": "0.11.10", "version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
@@ -9751,8 +9785,7 @@
"setimmediate": { "setimmediate": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=", "integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU="
"dev": true
}, },
"sha.js": { "sha.js": {
"version": "2.4.11", "version": "2.4.11",
+1 -1
View File
@@ -29,7 +29,7 @@
"@slack/web-api": "^5.14.0", "@slack/web-api": "^5.14.0",
"chalk": "^4.1.0", "chalk": "^4.1.0",
"cheerio": "^1.0.0-rc.3", "cheerio": "^1.0.0-rc.3",
"discord-webhook-node": "^1.1.8", "discord.js": "^12.5.1",
"dotenv": "^8.2.0", "dotenv": "^8.2.0",
"messaging-api-telegram": "^1.0.1", "messaging-api-telegram": "^1.0.1",
"mqtt": "^4.2.6", "mqtt": "^4.2.6",
+1
View File
@@ -5,6 +5,7 @@ const link: Link = {
brand: 'test:brand', brand: 'test:brand',
cartUrl: 'https://www.example.com/cartUrl', cartUrl: 'https://www.example.com/cartUrl',
model: 'test:model', model: 'test:model',
price: 100,
series: 'test:series', series: 'test:series',
url: 'https://www.example.com/url' url: 'https://www.example.com/url'
}; };
+1 -1
View File
@@ -180,7 +180,7 @@ const notifications = {
desktop: process.env.DESKTOP_NOTIFICATIONS === 'true', desktop: process.env.DESKTOP_NOTIFICATIONS === 'true',
discord: { discord: {
notifyGroup: envOrArray(process.env.DISCORD_NOTIFY_GROUP), notifyGroup: envOrArray(process.env.DISCORD_NOTIFY_GROUP),
webHookUrl: envOrArray(process.env.DISCORD_WEB_HOOK) webhooks: envOrArray(process.env.DISCORD_WEB_HOOK)
}, },
email: { email: {
password: envOrString(process.env.EMAIL_PASSWORD), password: envOrString(process.env.EMAIL_PASSWORD),
+4 -7
View File
@@ -118,11 +118,9 @@ export const Print = {
return ` ${buildProductString(link, store)} :: IN STOCK, WAITING`; return ` ${buildProductString(link, store)} :: IN STOCK, WAITING`;
}, },
// eslint-disable-next-line max-params
maxPrice( maxPrice(
link: Link, link: Link,
store: Store, store: Store,
price: number,
maxPrice: number, maxPrice: number,
color?: boolean color?: boolean
): string { ): string {
@@ -131,14 +129,13 @@ export const Print = {
'✖ ' + '✖ ' +
buildProductString(link, store, true) + buildProductString(link, store, true) +
' :: ' + ' :: ' +
chalk.yellow(`PRICE ${price} EXCEEDS LIMIT ${maxPrice}`) chalk.yellow(`PRICE ${link.price ?? ''} EXCEEDS LIMIT ${maxPrice}`)
); );
} }
return `${buildProductString( return `${buildProductString(link, store)} :: PRICE ${
link, link.price ?? ''
store } EXCEEDS LIMIT ${maxPrice}`;
)} :: PRICE ${price} EXCEEDS LIMIT ${maxPrice}`;
}, },
message( message(
message: string, message: string,
+42 -20
View File
@@ -1,41 +1,63 @@
import {Link, Store} from '../store/model'; import {Link, Store} from '../store/model';
import {MessageBuilder, Webhook} from 'discord-webhook-node'; import Discord from 'discord.js';
import {config} from '../config'; import {config} from '../config';
import {logger} from '../logger'; import {logger} from '../logger';
const discord = config.notifications.discord; const discord = config.notifications.discord;
const hooks = discord.webHookUrl; const {notifyGroup, webhooks} = discord;
const notifyGroup = discord.notifyGroup;
function getIdAndToken(webhook: string) {
const match = /.*\/webhooks\/(\d+)\/(.+)/.exec(webhook);
if (!match) {
throw new Error('could not get discord webhook');
}
return {
id: match[1],
token: match[2]
};
}
export function sendDiscordMessage(link: Link, store: Store) { export function sendDiscordMessage(link: Link, store: Store) {
if (discord.webHookUrl.length > 0) { if (webhooks.length > 0) {
logger.debug('↗ sending discord message'); logger.debug('↗ sending discord message');
(async () => { (async () => {
try { try {
const embed = new MessageBuilder(); const embed = new Discord.MessageEmbed()
embed.setTitle('Stock Notification'); .setTitle('_**Stock alert!**_')
if (link.cartUrl) .setDescription(
embed.addField('Add To Cart Link', link.cartUrl, true); '> provided by [streetmerchant](https://github.com/jef/streetmerchant) with :heart:'
embed.addField('Product Page', link.url, true); )
.setThumbnail(
'https://raw.githubusercontent.com/jef/streetmerchant/main/media/streetmerchant-square.png'
)
.setColor('#52b788')
.setTimestamp();
embed.addField('Store', store.name, true); embed.addField('Store', store.name, true);
if (link.price) embed.addField('Price', `$${link.price}`, true);
embed.addField('Product Page', link.url);
if (link.cartUrl) embed.addField('Add to Cart', link.cartUrl);
embed.addField('Brand', link.brand, true); embed.addField('Brand', link.brand, true);
embed.addField('Series', link.series, true);
embed.addField('Model', link.model, true); embed.addField('Model', link.model, true);
embed.addField('Series', link.series, true);
if (notifyGroup) {
embed.setText(notifyGroup.join(' '));
}
embed.setColor(0x76b900);
embed.setTimestamp();
const promises = []; const promises = [];
for (const hook of hooks) { for (const webhook of webhooks) {
promises.push(new Webhook(hook).send(embed)); const {id, token} = getIdAndToken(webhook);
const client = new Discord.WebhookClient(id, token);
promises.push({
client,
message: client.send(notifyGroup.join(' '), {
embeds: [embed],
username: 'streetmerchant'
})
});
} }
await Promise.all(promises); (await Promise.all(promises)).forEach(({client}) => client.destroy());
logger.info('✔ discord message sent'); logger.info('✔ discord message sent');
} catch (error: unknown) { } catch (error: unknown) {
+8 -13
View File
@@ -116,27 +116,22 @@ export function includesLabels(
); );
} }
export async function cardPrice( export async function getPrice(
page: Page, page: Page,
query: Pricing, query: Pricing,
max: number,
options: Selector options: Selector
): Promise<number | null> { ): Promise<number | null> {
if (!max || max === -1) {
return null;
}
const selector = {...options, selector: query.container}; const selector = {...options, selector: query.container};
const cardPrice = await extractPageContents(page, selector); const priceString = await extractPageContents(page, selector);
if (cardPrice) { if (priceString) {
const priceSeperator = query.euroFormat ? /\./g : /,/g; const priceSeparator = query.euroFormat ? /\./g : /,/g;
const cardpriceNumber = Number.parseFloat( const price = Number.parseFloat(
cardPrice.replace(priceSeperator, '').match(/\d+/g)!.join('.') priceString.replace(priceSeparator, '').match(/\d+/g)!.join('.')
); );
logger.debug(`Raw card price: ${cardPrice} | Limit: ${max}`); logger.debug('received price', price);
return cardpriceNumber > max ? cardpriceNumber : null; return price;
} }
return null; return null;
+38 -41
View File
@@ -1,7 +1,7 @@
import {Browser, Page, PageEventObj, Request, Response} from 'puppeteer'; import {Browser, Page, PageEventObj, Request, Response} from 'puppeteer';
import {Link, Store, getStores} from './model'; import {Link, Store, getStores} from './model';
import {Print, logger} from '../logger'; import {Print, logger} from '../logger';
import {Selector, cardPrice, pageIncludesLabels} from './includes-labels'; import {Selector, getPrice, pageIncludesLabels} from './includes-labels';
import { import {
closePage, closePage,
delay, delay,
@@ -303,6 +303,43 @@ async function lookupCardInStock(store: Store, page: Page, link: Link) {
type: 'textContent' type: 'textContent'
}; };
if (store.labels.captcha) {
if (await pageIncludesLabels(page, store.labels.captcha, baseOptions)) {
logger.warn(Print.captcha(link, store, true));
await delay(getSleepTime(store));
return false;
}
}
if (store.labels.bannedSeller) {
if (
await pageIncludesLabels(page, store.labels.bannedSeller, baseOptions)
) {
logger.warn(Print.bannedSeller(link, store, true));
return false;
}
}
if (store.labels.maxPrice) {
const maxPrice = config.store.maxPrice.series[link.series];
link.price = await getPrice(page, store.labels.maxPrice, baseOptions);
if (link.price && link.price > maxPrice && maxPrice > 0) {
logger.info(Print.maxPrice(link, store, maxPrice, true));
return false;
}
}
// Fixme: currently causing issues
// Do API inventory validation in realtime (no cache) if available
// if (
// store.realTimeInventoryLookup !== undefined &&
// link.itemNumber !== undefined
// ) {
// return store.realTimeInventoryLookup(link.itemNumber);
// }
if (store.labels.inStock) { if (store.labels.inStock) {
const options = { const options = {
...baseOptions, ...baseOptions,
@@ -336,46 +373,6 @@ async function lookupCardInStock(store: Store, page: Page, link: Link) {
} }
} }
if (store.labels.bannedSeller) {
if (
await pageIncludesLabels(page, store.labels.bannedSeller, baseOptions)
) {
logger.warn(Print.bannedSeller(link, store, true));
return false;
}
}
if (store.labels.maxPrice) {
const price = await cardPrice(
page,
store.labels.maxPrice,
config.store.maxPrice.series[link.series],
baseOptions
);
const maxPrice = config.store.maxPrice.series[link.series];
if (price) {
logger.info(Print.maxPrice(link, store, price, maxPrice, true));
return false;
}
}
if (store.labels.captcha) {
if (await pageIncludesLabels(page, store.labels.captcha, baseOptions)) {
logger.warn(Print.captcha(link, store, true));
await delay(getSleepTime(store));
return false;
}
}
// Fixme: currently causing issues
// Do API inventory validation in realtime (no cache) if available
// if (
// store.realTimeInventoryLookup !== undefined &&
// link.itemNumber !== undefined
// ) {
// return store.realTimeInventoryLookup(link.itemNumber);
// }
return true; return true;
} }
+1 -2
View File
@@ -11,8 +11,7 @@ export const AmazonCa: Store = {
text: ['add to cart'] text: ['add to cart']
}, },
maxPrice: { maxPrice: {
container: 'span[class*="PriceString"]', container: '#priceblock_ourprice'
euroFormat: false
} }
}, },
links: [ links: [
+1 -2
View File
@@ -12,8 +12,7 @@ export const AmazonEs: Store = {
text: ['añadir a la cesta'] text: ['añadir a la cesta']
}, },
maxPrice: { maxPrice: {
container: 'span[class*="PriceString"]', container: '#priceblock_ourprice'
euroFormat: true
}, },
outOfStock: [ outOfStock: [
{ {
+1 -1
View File
@@ -12,7 +12,7 @@ export const AmazonFr: Store = {
text: ['ajouter au panier'] text: ['ajouter au panier']
}, },
maxPrice: { maxPrice: {
container: 'span[class*="PriceString"]', container: '#priceblock_ourprice',
euroFormat: true euroFormat: true
}, },
outOfStock: [ outOfStock: [
+1 -1
View File
@@ -12,7 +12,7 @@ export const AmazonIt: Store = {
text: ['Aggiungi al carrello'] text: ['Aggiungi al carrello']
}, },
maxPrice: { maxPrice: {
container: 'span[class*="PriceString"]' container: '#priceblock_ourprice'
} }
}, },
links: [ links: [
+1 -1
View File
@@ -16,7 +16,7 @@ export const AmazonNl: Store = {
] ]
}, },
maxPrice: { maxPrice: {
container: 'span[class*="PriceString"]', container: '#priceblock_ourprice',
euroFormat: true euroFormat: true
}, },
outOfStock: [ outOfStock: [
+1 -1
View File
@@ -14,7 +14,7 @@ export const AmazonUk: Store = {
text: ['in stock'] text: ['in stock']
}, },
maxPrice: { maxPrice: {
container: 'span[class*="PriceString"]' container: '#priceblock_ourprice'
}, },
outOfStock: [ outOfStock: [
{ {
+3 -3
View File
@@ -18,17 +18,17 @@ export const Amazon: Store = {
} }
], ],
maxPrice: { maxPrice: {
container: '#price_inside_buybox' container: '#priceblock_ourprice'
} }
}, },
links: [ links: [
{ {
brand: 'test:brand', brand: 'test:brand',
cartUrl: cartUrl:
'https://www.amazon.com/gp/aws/cart/add.html?ASIN.1=B07TDN1SC5&Quantity.1=1', 'https://www.amazon.com/gp/aws/cart/add.html?ASIN.1=B083248S3B&Quantity.1=1',
model: 'test:model', model: 'test:model',
series: 'test:series', series: 'test:series',
url: 'https://www.amazon.com/dp/B07TDN1SC5' url: 'https://www.amazon.com/dp/B083248S3B'
}, },
{ {
brand: 'asus', brand: 'asus',
+2 -1
View File
@@ -232,7 +232,8 @@ function warnIfStoreDeprecated(store: Store) {
switch (store.name) { switch (store.name) {
case 'nvidia': case 'nvidia':
case 'nvidia-api': case 'nvidia-api':
logger.warn(`${store.name} is deprecated in favor of bestbuy`); if (config.store.country === 'usa')
logger.warn(`${store.name} is deprecated in favor of bestbuy`);
break; break;
case 'evga': case 'evga':
logger.warn( logger.warn(
+1
View File
@@ -137,6 +137,7 @@ export type Link = {
labels?: Labels; labels?: Labels;
model: Model; model: Model;
openCartAction?: (browser: Browser) => Promise<string>; openCartAction?: (browser: Browser) => Promise<string>;
price?: number | null;
series: Series; series: Series;
screenshot?: string; screenshot?: string;
url: string; url: string;