- Add TECHNICAL_ANALYSIS_BASICS.md with complete indicator explanations - Add TA_QUICK_REFERENCE.md for quick lookup - Enhance AI analysis prompts with TA principles integration - Improve JSON response structure with dedicated analysis sections - Add cross-layout consensus analysis for higher confidence signals - Include timeframe-specific risk assessment and position sizing - Add educational content for RSI, MACD, EMAs, Stochastic RSI, VWAP, OBV - Implement layout-specific analysis (AI vs DIY layouts) - Add momentum, trend, and volume analysis separation - Update README with TA documentation references - Create implementation summary and test files
595 lines
18 KiB
TypeScript
595 lines
18 KiB
TypeScript
import puppeteer, { Browser, Page } from 'puppeteer'
|
|
import { promises as fs } from 'fs'
|
|
import * as path from 'path'
|
|
|
|
export interface TradingViewCredentials {
|
|
email: string
|
|
password: string
|
|
}
|
|
|
|
// Environment variables fallback
|
|
const TRADINGVIEW_EMAIL = process.env.TRADINGVIEW_EMAIL
|
|
const TRADINGVIEW_PASSWORD = process.env.TRADINGVIEW_PASSWORD
|
|
|
|
// Utility function to replace Puppeteer's waitForTimeout
|
|
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
|
|
|
// Helper function to check if element is visible using Puppeteer APIs
|
|
async function isElementVisible(page: Page, selector: string, timeout: number = 1000): Promise<boolean> {
|
|
try {
|
|
await page.waitForSelector(selector, { timeout, visible: true })
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
export interface NavigationOptions {
|
|
symbol?: string // e.g., 'SOLUSD', 'BTCUSD'
|
|
timeframe?: string // e.g., '5', '15', '1H'
|
|
waitForChart?: boolean
|
|
}
|
|
|
|
// Session persistence configuration
|
|
const SESSION_DATA_DIR = path.join(process.cwd(), '.tradingview-session')
|
|
const COOKIES_FILE = path.join(SESSION_DATA_DIR, 'cookies.json')
|
|
const SESSION_STORAGE_FILE = path.join(SESSION_DATA_DIR, 'session-storage.json')
|
|
|
|
export class TradingViewAutomation {
|
|
private browser: Browser | null = null
|
|
private page: Page | null = null
|
|
private isAuthenticated: boolean = false
|
|
private static instance: TradingViewAutomation | null = null
|
|
private initPromise: Promise<void> | null = null
|
|
private operationLock: boolean = false
|
|
private lastRequestTime = 0
|
|
private requestCount = 0
|
|
|
|
private acquireOperationLock(): void {
|
|
if (this.operationLock) {
|
|
throw new Error('Another operation is already in progress. Please wait.')
|
|
}
|
|
this.operationLock = true
|
|
}
|
|
|
|
private releaseOperationLock(): void {
|
|
this.operationLock = false
|
|
}
|
|
|
|
// Singleton pattern
|
|
static getInstance(): TradingViewAutomation {
|
|
if (!TradingViewAutomation.instance) {
|
|
TradingViewAutomation.instance = new TradingViewAutomation()
|
|
}
|
|
return TradingViewAutomation.instance
|
|
}
|
|
|
|
async init(forceCleanup: boolean = false): Promise<void> {
|
|
this.acquireOperationLock()
|
|
try {
|
|
if (this.initPromise) {
|
|
console.log('🔄 Initialization already in progress, waiting...')
|
|
await this.initPromise
|
|
return
|
|
}
|
|
|
|
if (forceCleanup && this.browser) {
|
|
console.log('🧹 Force cleanup requested')
|
|
await this.forceCleanup()
|
|
}
|
|
|
|
if (this.browser) {
|
|
console.log('SUCCESS: Browser already initialized and connected')
|
|
return
|
|
}
|
|
|
|
this.initPromise = this._doInit()
|
|
try {
|
|
await this.initPromise
|
|
} finally {
|
|
this.initPromise = null
|
|
}
|
|
} finally {
|
|
this.releaseOperationLock()
|
|
}
|
|
}
|
|
|
|
private async _doInit(): Promise<void> {
|
|
console.log('🚀 Initializing TradingView automation with session persistence...')
|
|
|
|
// Ensure session directory exists
|
|
await fs.mkdir(SESSION_DATA_DIR, { recursive: true })
|
|
|
|
try {
|
|
this.browser = await puppeteer.launch({
|
|
headless: true,
|
|
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || process.env.CHROME_PATH || '/usr/bin/chromium',
|
|
timeout: 60000,
|
|
args: [
|
|
'--no-sandbox',
|
|
'--disable-setuid-sandbox',
|
|
'--disable-dev-shm-usage',
|
|
'--disable-accelerated-2d-canvas',
|
|
'--no-first-run',
|
|
'--no-zygote',
|
|
'--disable-gpu',
|
|
'--disable-web-security',
|
|
'--disable-features=VizDisplayCompositor',
|
|
'--disable-background-timer-throttling',
|
|
'--disable-backgrounding-occluded-windows',
|
|
'--disable-renderer-backgrounding',
|
|
'--disable-features=TranslateUI',
|
|
'--disable-ipc-flooding-protection',
|
|
'--disable-extensions',
|
|
'--disable-default-apps',
|
|
'--disable-sync',
|
|
'--window-size=1920,1080',
|
|
'--user-agent=Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
|
|
]
|
|
})
|
|
|
|
this.page = await this.browser.newPage()
|
|
|
|
// Set viewport
|
|
await this.page.setViewport({ width: 1920, height: 1080 })
|
|
|
|
// Load saved session if available
|
|
await this.loadSession()
|
|
|
|
console.log('✅ Browser initialized successfully')
|
|
} catch (error) {
|
|
console.error('❌ Failed to initialize browser:', error)
|
|
await this.forceCleanup()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async forceCleanup(): Promise<void> {
|
|
console.log('🧹 Force cleanup: Closing browser and resetting state...')
|
|
try {
|
|
if (this.browser) {
|
|
await this.browser.close()
|
|
}
|
|
} catch (e) {
|
|
console.log('WARNING: Error during browser cleanup:', e)
|
|
}
|
|
|
|
this.browser = null
|
|
this.page = null
|
|
this.isAuthenticated = false
|
|
console.log('✅ Cleanup completed')
|
|
}
|
|
|
|
private async loadSession(): Promise<void> {
|
|
if (!this.page) return
|
|
|
|
try {
|
|
// Load cookies
|
|
if (await fs.access(COOKIES_FILE).then(() => true).catch(() => false)) {
|
|
const cookiesData = await fs.readFile(COOKIES_FILE, 'utf8')
|
|
const cookies = JSON.parse(cookiesData)
|
|
await this.page.setCookie(...cookies)
|
|
console.log('✅ Loaded saved cookies')
|
|
}
|
|
} catch (e) {
|
|
console.log('WARNING: Could not load session:', e)
|
|
}
|
|
}
|
|
|
|
private async saveSession(): Promise<void> {
|
|
if (!this.page) return
|
|
|
|
try {
|
|
// Save cookies
|
|
const cookies = await this.page.cookies()
|
|
await fs.writeFile(COOKIES_FILE, JSON.stringify(cookies, null, 2))
|
|
console.log('✅ Session saved')
|
|
} catch (e) {
|
|
console.log('WARNING: Could not save session:', e)
|
|
}
|
|
}
|
|
|
|
async checkLoginStatus(): Promise<boolean> {
|
|
if (!this.page) throw new Error('Page not initialized')
|
|
|
|
console.log('CHECKING: Login status with 5 detection strategies...')
|
|
|
|
try {
|
|
// Strategy 1: Check for user account indicators (positive indicators)
|
|
console.log('CHECKING: Strategy 1: Checking for user account indicators...')
|
|
await this.takeDebugScreenshot('login_status_check')
|
|
|
|
const userIndicators = [
|
|
'.js-header-user-menu-button', // TradingView's main user button
|
|
'[data-name="header-user-menu"]',
|
|
'.tv-header__user-menu-button:not(.tv-header__user-menu-button--anonymous)',
|
|
'.tv-header__user-menu-wrap'
|
|
]
|
|
|
|
for (const selector of userIndicators) {
|
|
try {
|
|
const element = await this.page.$(selector)
|
|
if (element) {
|
|
const isVisible = await element.boundingBox()
|
|
if (isVisible) {
|
|
console.log('SUCCESS: Found user account element: ' + selector)
|
|
return true
|
|
}
|
|
}
|
|
} catch (e) {
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Strategy 2: Check for anonymous/sign-in indicators (negative indicators)
|
|
console.log('CHECKING: Strategy 2: Checking for anonymous/sign-in indicators...')
|
|
const anonymousIndicators = [
|
|
'.tv-header__user-menu-button--anonymous',
|
|
'[data-name="header-user-menu-sign-in"]',
|
|
'button:contains("Sign in")',
|
|
'a:contains("Sign in")'
|
|
]
|
|
|
|
for (const selector of anonymousIndicators) {
|
|
try {
|
|
const element = await this.page.$(selector)
|
|
if (element) {
|
|
const isVisible = await element.boundingBox()
|
|
if (isVisible) {
|
|
console.log('ERROR: Found anonymous indicator: ' + selector + ' - not logged in')
|
|
return false
|
|
}
|
|
}
|
|
} catch (e) {
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Strategy 3: Check URL patterns
|
|
console.log('CHECKING: Strategy 3: Checking URL patterns...')
|
|
const currentUrl = this.page.url()
|
|
if (currentUrl.includes('/signin') || currentUrl.includes('/login')) {
|
|
console.log('ERROR: On login page - not logged in')
|
|
return false
|
|
}
|
|
|
|
// Strategy 4: Check authentication cookies
|
|
console.log('CHECKING: Strategy 4: Checking authentication cookies...')
|
|
const cookies = await this.page.cookies()
|
|
const authCookies = cookies.filter(cookie =>
|
|
cookie.name.includes('auth') ||
|
|
cookie.name.includes('session') ||
|
|
cookie.name.includes('token')
|
|
)
|
|
if (authCookies.length === 0) {
|
|
console.log('WARNING: No authentication cookies found')
|
|
}
|
|
|
|
// Strategy 5: Check for personal content
|
|
console.log('CHECKING: Strategy 5: Checking for personal content...')
|
|
const personalContentSelectors = [
|
|
'[data-name="watchlist"]',
|
|
'.tv-header__watchlist',
|
|
'.js-backtesting-head'
|
|
]
|
|
|
|
for (const selector of personalContentSelectors) {
|
|
try {
|
|
const element = await this.page.$(selector)
|
|
if (element) {
|
|
console.log('SUCCESS: Found personal content: ' + selector)
|
|
return true
|
|
}
|
|
} catch (e) {
|
|
continue
|
|
}
|
|
}
|
|
|
|
// If we can't determine status clearly, assume not logged in to be safe
|
|
console.log('WARNING: Could not determine login status clearly, assuming not logged in')
|
|
return false
|
|
|
|
} catch (e) {
|
|
console.log('ERROR: Error checking login status:', e)
|
|
return false
|
|
}
|
|
}
|
|
|
|
async login(credentials?: TradingViewCredentials): Promise<boolean> {
|
|
if (!this.page) throw new Error('Page not initialized')
|
|
|
|
const email = credentials?.email || TRADINGVIEW_EMAIL
|
|
const password = credentials?.password || TRADINGVIEW_PASSWORD
|
|
|
|
if (!email || !password) {
|
|
throw new Error('TradingView credentials not provided')
|
|
}
|
|
|
|
try {
|
|
// Check if already logged in
|
|
const loggedIn = await this.checkLoginStatus()
|
|
if (loggedIn) {
|
|
console.log('SUCCESS: Already logged in, skipping login steps')
|
|
return true
|
|
}
|
|
|
|
console.log('🔐 Starting login process...')
|
|
|
|
// Navigate to login page
|
|
console.log('📄 Navigating to TradingView login page...')
|
|
await this.page.goto('https://www.tradingview.com/accounts/signin/', {
|
|
waitUntil: 'domcontentloaded',
|
|
timeout: 30000
|
|
})
|
|
|
|
await sleep(3000)
|
|
await this.takeDebugScreenshot('login_page_loaded')
|
|
|
|
// Wait for login form
|
|
console.log('⏳ Waiting for login form...')
|
|
await sleep(5000)
|
|
|
|
// Look for email login option
|
|
console.log('CHECKING: Looking for Email login option...')
|
|
|
|
const emailTriggers = [
|
|
'button[data-overflow-tooltip-text="Email"]',
|
|
'button:contains("Email")',
|
|
'button:contains("email")',
|
|
'[data-name="email"]'
|
|
]
|
|
|
|
let emailFormVisible = false
|
|
for (const trigger of emailTriggers) {
|
|
try {
|
|
const element = await this.page.$(trigger)
|
|
if (element) {
|
|
const isVisible = await element.boundingBox()
|
|
if (isVisible) {
|
|
console.log("TARGET: Found email trigger: " + trigger)
|
|
await element.click()
|
|
console.log('SUCCESS: Clicked email trigger')
|
|
await sleep(3000)
|
|
emailFormVisible = true
|
|
break
|
|
}
|
|
}
|
|
} catch (e) {
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Fill email
|
|
const emailInputSelectors = [
|
|
'input[type="email"]',
|
|
'input[name*="email"]',
|
|
'input[name="username"]',
|
|
'input[placeholder*="email" i]'
|
|
]
|
|
|
|
let emailInput = null
|
|
for (const selector of emailInputSelectors) {
|
|
try {
|
|
emailInput = await this.page.$(selector)
|
|
if (emailInput) {
|
|
const isVisible = await emailInput.boundingBox()
|
|
if (isVisible) {
|
|
console.log('SUCCESS: Found email input: ' + selector)
|
|
break
|
|
}
|
|
}
|
|
} catch (e) {
|
|
continue
|
|
}
|
|
}
|
|
|
|
if (!emailInput) {
|
|
throw new Error('Could not find email input field')
|
|
}
|
|
|
|
await emailInput.click()
|
|
await emailInput.type(email)
|
|
console.log('✅ Filled email field')
|
|
|
|
// Fill password
|
|
const passwordInputSelectors = [
|
|
'input[type="password"]',
|
|
'input[name*="password"]'
|
|
]
|
|
|
|
let passwordInput = null
|
|
for (const selector of passwordInputSelectors) {
|
|
try {
|
|
passwordInput = await this.page.$(selector)
|
|
if (passwordInput) {
|
|
const isVisible = await passwordInput.boundingBox()
|
|
if (isVisible) {
|
|
console.log('SUCCESS: Found password input: ' + selector)
|
|
break
|
|
}
|
|
}
|
|
} catch (e) {
|
|
continue
|
|
}
|
|
}
|
|
|
|
if (!passwordInput) {
|
|
throw new Error('Could not find password input field')
|
|
}
|
|
|
|
await passwordInput.click()
|
|
await passwordInput.type(password)
|
|
console.log('✅ Filled password field')
|
|
|
|
// Submit form
|
|
const submitSelectors = [
|
|
'button[type="submit"]',
|
|
'button:contains("Sign in")',
|
|
'button:contains("Log in")',
|
|
'button:contains("Login")'
|
|
]
|
|
|
|
let submitted = false
|
|
for (const selector of submitSelectors) {
|
|
try {
|
|
const button = await this.page.$(selector)
|
|
if (button) {
|
|
const isVisible = await button.boundingBox()
|
|
if (isVisible) {
|
|
console.log('SUCCESS: Found submit button: ' + selector)
|
|
await button.click()
|
|
submitted = true
|
|
break
|
|
}
|
|
}
|
|
} catch (e) {
|
|
continue
|
|
}
|
|
}
|
|
|
|
if (!submitted) {
|
|
// Try pressing Enter on password field
|
|
await passwordInput.press('Enter')
|
|
console.log('INFO: Pressed Enter on password field')
|
|
}
|
|
|
|
console.log('⏳ Waiting for login completion...')
|
|
await sleep(5000)
|
|
|
|
// Check for errors
|
|
const errorSelectors = [
|
|
'.tv-alert-dialog__text',
|
|
'.tv-dialog__error',
|
|
'[data-name="auth-error-message"]',
|
|
'.error-message'
|
|
]
|
|
|
|
for (const selector of errorSelectors) {
|
|
try {
|
|
const errorElement = await this.page.$(selector)
|
|
if (errorElement) {
|
|
const errorText = await this.page.evaluate(el => el.textContent, errorElement)
|
|
if (errorText && errorText.trim()) {
|
|
await this.takeDebugScreenshot('login_error')
|
|
throw new Error('Login failed: ' + errorText.trim())
|
|
}
|
|
}
|
|
} catch (e) {
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Verify login success
|
|
await sleep(3000)
|
|
const loginSuccess = await this.checkLoginStatus()
|
|
|
|
if (loginSuccess) {
|
|
console.log('✅ Login successful!')
|
|
this.isAuthenticated = true
|
|
await this.saveSession()
|
|
return true
|
|
} else {
|
|
await this.takeDebugScreenshot('login_verification_failed')
|
|
throw new Error('Login verification failed - still appears not logged in')
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('❌ Login failed:', error)
|
|
await this.takeDebugScreenshot('login_error')
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async takeDebugScreenshot(prefix: string = 'debug'): Promise<string> {
|
|
if (!this.page) throw new Error('Page not initialized')
|
|
|
|
try {
|
|
const timestamp = Date.now()
|
|
const filename = `${prefix}_${timestamp}.png`
|
|
const filepath = path.join(process.cwd(), 'screenshots', filename)
|
|
|
|
// Ensure screenshots directory exists
|
|
await fs.mkdir(path.dirname(filepath), { recursive: true })
|
|
|
|
await this.page.screenshot({
|
|
path: filepath as `${string}.png`,
|
|
fullPage: true,
|
|
type: 'png'
|
|
})
|
|
|
|
console.log(`📸 Screenshot saved: ${filename}`)
|
|
return filepath
|
|
} catch (error) {
|
|
console.error('Error taking screenshot:', error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async navigateToSymbol(symbol: string, timeframe?: string): Promise<boolean> {
|
|
if (!this.page) throw new Error('Page not initialized')
|
|
|
|
try {
|
|
console.log(`🎯 Navigating to symbol: ${symbol}`)
|
|
|
|
// Construct TradingView URL
|
|
const baseUrl = 'https://www.tradingview.com/chart/'
|
|
const params = new URLSearchParams()
|
|
params.set('symbol', symbol)
|
|
if (timeframe) {
|
|
params.set('interval', timeframe)
|
|
}
|
|
|
|
const url = `${baseUrl}?${params.toString()}`
|
|
console.log(`📍 Navigating to: ${url}`)
|
|
|
|
await this.page.goto(url, {
|
|
waitUntil: 'domcontentloaded',
|
|
timeout: 30000
|
|
})
|
|
|
|
// Wait for chart to load
|
|
await sleep(5000)
|
|
|
|
// Wait for chart container
|
|
await this.page.waitForSelector('.chart-container, #chart-container, [data-name="chart"]', {
|
|
timeout: 30000
|
|
})
|
|
|
|
console.log('✅ Chart loaded successfully')
|
|
return true
|
|
|
|
} catch (error) {
|
|
console.error('❌ Failed to navigate to symbol:', error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async takeScreenshot(options: { filename?: string, fullPage?: boolean } = {}): Promise<string> {
|
|
if (!this.page) throw new Error('Page not initialized')
|
|
|
|
try {
|
|
const timestamp = Date.now()
|
|
const filename = options.filename || `screenshot_${timestamp}.png`
|
|
const filepath = path.join(process.cwd(), 'screenshots', filename)
|
|
|
|
// Ensure screenshots directory exists
|
|
await fs.mkdir(path.dirname(filepath), { recursive: true })
|
|
|
|
await this.page.screenshot({
|
|
path: filepath as `${string}.png`,
|
|
fullPage: options.fullPage || false,
|
|
type: 'png'
|
|
})
|
|
|
|
console.log(`📸 Screenshot saved: ${filename}`)
|
|
return filepath
|
|
} catch (error) {
|
|
console.error('Error taking screenshot:', error)
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
// Export default instance
|
|
export default TradingViewAutomation.getInstance()
|