UNPKG

@alleyway/add-tradingview-alerts-tool

Version:
305 lines (233 loc) 11.2 kB
import * as csv from 'fast-csv'; import {createReadStream, existsSync, readFileSync} from "fs" import YAML from "yaml" import {addAlert, configureInterval, isEnvEnabled, waitForTimeout, isXpathVisible} from "./index.js"; import { checkForInvalidSymbol, launchBrowser, login, minimizeFooterChartPanel, navigateToSymbol } from "./service/tv-page-actions.js"; import { ISingleAlertSettings, SoundName, SoundDuration, soundNames, isSoundName, isSoundDuration, soundDurations } from "./interfaces.js"; import log, {logLogInfo} from "./service/log.js" import kleur from "kleur"; import {logBaseDelay, styleOverride} from "./service/common-service.js"; import {InvalidSymbolError} from "./classes.js"; import {Browser, Page} from "puppeteer"; const readFilePromise = (filename: string) => { return new Promise<any[]>((resolve, reject) => { const rows = [] if (!existsSync(filename)) reject(`File specified in config was not found: ${kleur.yellow(filename)}`) try { const readStream = createReadStream(filename); readStream // .pipe(stripBomStream()) // was an error using this package something about module resolution .pipe(csv.parse({ headers: (headerArray) => headerArray.map((header) => header.trim()) })) .on('data', (row) => rows.push(row)) .on('end', (rowCount) => { log.info(`Parsed ${rowCount} rows`) resolve(rows) }).on('error', (e) => { reject(`Unable to read csv: ${e.message}`) }) } catch (e) { reject(e.message) } }) } export const addAlertsMain = async (configFileName) => { const headless = isEnvEnabled(process.env.HEADLESS) logLogInfo() logBaseDelay() if (!existsSync(configFileName)) { log.error(`Unable to find config file: ${configFileName}`) process.exit(1) } log.info("Using config file: ", kleur.yellow(configFileName)) log.info("Press Ctrl-C to stop this script") const configString = readFileSync(configFileName, {encoding: "utf-8"}) const config = YAML.parse(configString) if (config.tradingview.chartUrl === "https://www.tradingview.com/chart/XXXXXXXX/") { log.fatal("oops! Looks like you need to set your chartUrl in the config file!") process.exit(1) } let blackListRows = [] if (config.files.exclude) { try { log.info(`${kleur.gray("Reading blacklist file: ")}${kleur.cyan(config.files.exclude)}`) blackListRows = await readFilePromise(config.files.exclude) if (blackListRows.length > 0) { if (!blackListRows[0].symbol) { log.error(`Invalid csv file format(${config.files.exclude}), first line must have at the following header: ${kleur.blue("symbol")}`) process.exit(1) } } } catch (e) { log.fatal(`Unable to open file specified in config: ${config.files.exclude}`) process.exit(1) } } let symbolRows = [] try { log.info(`${kleur.gray("Reading input file: ")}${kleur.cyan(config.files.input)}`) symbolRows = await readFilePromise(config.files.input) } catch (e) { log.fatal(`Unable to open file specified in config: ${kleur.yellow(config.files.input)}`) process.exit(1) } const firstRow = symbolRows[0] if (!firstRow.symbol) { log.error(`Invalid input csv file format, first line should have at least the following headers(no spaces!): ${kleur.blue("symbol,instrument,quote_asset")}`) process.exit(1) } const {alert: alertConfig} = config if (alertConfig.actions.playSound && alertConfig.actions.playSound.enabled) { if (!isSoundName( alertConfig.actions.playSound.name)) { log.error(`Configuration error. Invalid sound name: ${kleur.yellow(alertConfig.actions.playSound.name)}`) throw new Error(`Sound must be one of: ${soundNames.join(" , ")}`) } if (!isSoundDuration( alertConfig.actions.playSound.duration)) { log.error(`Configuration error. Invalid sound duration: ${kleur.yellow(alertConfig.actions.playSound.duration)}`) throw new Error(`Sound Duration must be one of: ${soundDurations.join(" , ")}`) } } const browser: Browser = await launchBrowser(headless, `${config.tradingview.chartUrl}#signin`) let page: Page; let accessDenied; if (headless) { page = await browser.newPage(); log.debug(`Go to ${config.tradingview.chartUrl} and wait until domloaded`) const pageResponse = await page.goto(config.tradingview.chartUrl + "#signin", { waitUntil: 'domcontentloaded' }); await waitForTimeout(8, "let page load and see if access is denied") /* istanbul ignore next */ await page.addStyleTag({content: styleOverride}) accessDenied = pageResponse.status() === 403 } else { page = (await browser.pages())[0]; await waitForTimeout(5, "let page load and see if access is denied") await page.addStyleTag({content: styleOverride}) /* istanbul ignore next */ accessDenied = await page.evaluate(() => { return document.title.includes("Denied"); }); } const isShowingSignIn = await isXpathVisible(page, `//div[@data-dialog-name='sign-in']`) if (accessDenied || isShowingSignIn) { if (config.tradingview.username && config.tradingview.password) { await login(page, config.tradingview.username, config.tradingview.password, config.tradingview.backupCode) } else { log.warn("You'll need to sign into TradingView in this browser (one time only)\n...after signing in, press ctrl-c to kill this script, then run it again") await waitForTimeout(1000000) await browser.close() process.exit(1) } } await waitForTimeout(3, "wait a little longer for page to load") const isBlacklisted = (symbol: string) => { for (const row of blackListRows) { if (symbol.toLowerCase().includes(row.symbol.toLowerCase())) { return true } } return false } await minimizeFooterChartPanel(page) // otherwise pine script editor might capture focus for (const row of symbolRows) { try { const makeReplacements = (value) => { if (value) { let val = String(value) // sometimes YAML config parameters are numbers for (const column of Object.keys(row)) { val = val.replace(new RegExp(`{{${column}}}`, "g"), row[column]) } const matches = val.match(/\{\{.*?\}\}/g) if (matches) { for (const match of matches) { log.warn(`No key in .csv matches '${match}' - but might be using TradingView token-replacement`) } } return val } else { return null } } if (isBlacklisted(row.symbol)) { log.warn(`Not adding blacklisted symbol: `, kleur.yellow(row.symbol)) continue } const replacedIntervals = makeReplacements(config.tradingview?.interval) const parsedIntervals = replacedIntervals.toString().split(",") || ["none"]; for (const currentInterval of parsedIntervals) { log.info(`Adding symbol: ${kleur.magenta(row.symbol)} | Instrument: ${kleur.magenta(row.instrument || row.base)} Quote Asset: ${kleur.magenta(row.quote_asset || row.quote)}`) if (currentInterval !== "none") { await configureInterval(currentInterval.trim(), page) await waitForTimeout(3, "after changing the interval") } await waitForTimeout(2, "let things settle from processing last alert") await navigateToSymbol(page, row.symbol) await waitForTimeout(3, "after navigating to ticker") await checkForInvalidSymbol(page, row.symbol) const singleAlertSettings: ISingleAlertSettings = { name: makeReplacements(row.alert_name || row.name || alertConfig.name), // TODO: deprecate "name" one day message: makeReplacements(alertConfig.message)?.trimRight(), condition: { primaryLeft: makeReplacements(alertConfig.condition.primaryLeft), primaryRight: makeReplacements(alertConfig.condition.primaryRight), secondary: makeReplacements(alertConfig.condition.secondary), tertiaryLeft: makeReplacements(alertConfig.condition.tertiaryLeft), tertiaryRight: makeReplacements(alertConfig.condition.tertiaryRight), quaternaryLeft: makeReplacements(alertConfig.condition.quaternaryLeft), quaternaryRight: makeReplacements(alertConfig.condition.quaternaryRight), }, option: makeReplacements(alertConfig.option), expireInterval: alertConfig.expireInterval, expireOpenEnded: alertConfig.expireOpenEnded } if (alertConfig.actions) { singleAlertSettings.actions = { notifyOnApp: alertConfig.actions.notifyOnApp, showPopup: alertConfig.actions.showPopup, sendEmail: alertConfig.actions.sendEmail, } if (alertConfig.actions.webhook) { singleAlertSettings.actions.webhook = { enabled: alertConfig.actions.webhook.enabled, url: makeReplacements(alertConfig.actions.webhook.url) } } if (alertConfig.actions.playSound) { singleAlertSettings.actions.playSound = { enabled: alertConfig.actions.playSound.enabled, name: alertConfig.actions.playSound.name, duration: alertConfig.actions.playSound.duration } } } await page.addStyleTag({content: styleOverride}) await addAlert(page, singleAlertSettings) } } catch (e) { log.error(e.message) if (e instanceof InvalidSymbolError) { e.symbol = row.symbol await browser.close() throw e } } } await waitForTimeout(5, "waiting a little before closing") await browser.close() }