ts-trainingpeaks 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/browser.ts ADDED
@@ -0,0 +1,558 @@
1
+ /**
2
+ * Browser automation for TrainingPeaks login
3
+ * Uses Chrome DevTools Protocol (CDP) over WebSocket
4
+ * Zero dependencies - uses only Bun native APIs
5
+ */
6
+
7
+ export interface BrowserOptions {
8
+ executablePath?: string
9
+ headless?: boolean
10
+ timeout?: number
11
+ userAgent?: string
12
+ viewportWidth?: number
13
+ viewportHeight?: number
14
+ /** Use a clean/isolated browser profile (no shared sessions) */
15
+ incognito?: boolean
16
+ /** Custom user data directory */
17
+ userDataDir?: string
18
+ }
19
+
20
+ interface CDPMessage {
21
+ id: number
22
+ method?: string
23
+ params?: Record<string, unknown>
24
+ result?: unknown
25
+ error?: { code: number, message: string }
26
+ }
27
+
28
+ interface CDPCookie {
29
+ name: string
30
+ value: string
31
+ domain: string
32
+ path: string
33
+ expires: number
34
+ size: number
35
+ httpOnly: boolean
36
+ secure: boolean
37
+ session: boolean
38
+ sameSite?: string
39
+ }
40
+
41
+ export class Browser {
42
+ private process: ReturnType<typeof Bun.spawn> | null = null
43
+ private ws: WebSocket | null = null
44
+ private messageId = 0
45
+ private pendingMessages = new Map<number, { resolve: (value: unknown) => void, reject: (error: Error) => void }>()
46
+ private options: Required<BrowserOptions>
47
+ private debuggerUrl: string | null = null
48
+ private eventListeners = new Map<string, ((_params: unknown) => void)[]>()
49
+
50
+ private userDataDir: string | null = null
51
+
52
+ constructor(options: BrowserOptions = {}) {
53
+ this.options = {
54
+ executablePath: options.executablePath || this.findChrome(),
55
+ headless: options.headless ?? false, // Non-headless for login with reCAPTCHA
56
+ timeout: options.timeout ?? 60000,
57
+ userAgent: options.userAgent || 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
58
+ viewportWidth: options.viewportWidth ?? 1280,
59
+ viewportHeight: options.viewportHeight ?? 800,
60
+ incognito: options.incognito ?? true, // Default to isolated profile
61
+ userDataDir: options.userDataDir,
62
+ } as Required<BrowserOptions>
63
+ }
64
+
65
+ private findChrome(): string {
66
+ const paths = [
67
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
68
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
69
+ '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
70
+ '/Applications/Arc.app/Contents/MacOS/Arc',
71
+ '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
72
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
73
+ '/usr/bin/google-chrome',
74
+ '/usr/bin/chromium',
75
+ '/usr/bin/chromium-browser',
76
+ '/usr/bin/google-chrome-stable',
77
+ '/snap/bin/chromium',
78
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
79
+ 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
80
+ ]
81
+
82
+ for (const path of paths) {
83
+ try {
84
+ const result = Bun.spawnSync(['test', '-x', path])
85
+ if (result.exitCode === 0) return path
86
+ }
87
+ catch {
88
+ continue
89
+ }
90
+ }
91
+
92
+ throw new Error('Chrome/Chromium not found. Please install Chrome or specify executablePath.')
93
+ }
94
+
95
+ async launch(): Promise<void> {
96
+ // Create temp user data dir for isolated profile
97
+ if ((this.options as BrowserOptions).incognito) {
98
+ this.userDataDir = `/tmp/tp-chrome-${Date.now()}-${Math.random().toString(36).slice(2)}`
99
+ // Create the directory
100
+ const mkdirProc = Bun.spawn(['mkdir', '-p', this.userDataDir], { stdout: 'ignore', stderr: 'ignore' })
101
+ await mkdirProc.exited
102
+ }
103
+
104
+ const args = [
105
+ this.options.headless ? '--headless=new' : '',
106
+ '--disable-gpu',
107
+ '--disable-dev-shm-usage',
108
+ '--disable-setuid-sandbox',
109
+ '--no-sandbox',
110
+ '--remote-debugging-port=0',
111
+ `--window-size=${this.options.viewportWidth},${this.options.viewportHeight}`,
112
+ '--disable-background-networking',
113
+ '--disable-background-timer-throttling',
114
+ '--disable-backgrounding-occluded-windows',
115
+ '--disable-breakpad',
116
+ '--disable-component-extensions-with-background-pages',
117
+ '--disable-component-update',
118
+ '--disable-default-apps',
119
+ '--disable-extensions',
120
+ '--disable-features=TranslateUI',
121
+ '--disable-hang-monitor',
122
+ '--disable-ipc-flooding-protection',
123
+ '--disable-popup-blocking',
124
+ '--disable-prompt-on-repost',
125
+ '--disable-renderer-backgrounding',
126
+ '--disable-sync',
127
+ '--enable-features=NetworkService,NetworkServiceInProcess',
128
+ '--force-color-profile=srgb',
129
+ '--metrics-recording-only',
130
+ '--no-first-run',
131
+ '--password-store=basic',
132
+ '--use-mock-keychain',
133
+ // Use isolated profile
134
+ this.userDataDir ? `--user-data-dir=${this.userDataDir}` : '',
135
+ // Run in incognito mode for true isolation
136
+ (this.options as BrowserOptions).incognito ? '--incognito' : '',
137
+ ].filter(Boolean)
138
+
139
+ this.process = Bun.spawn([this.options.executablePath, ...args], {
140
+ stdout: 'pipe',
141
+ stderr: 'pipe',
142
+ })
143
+
144
+ const stderr = this.process.stderr as ReadableStream<Uint8Array>
145
+ const reader = stderr.getReader()
146
+ const decoder = new TextDecoder()
147
+ let output = ''
148
+ const startTime = Date.now()
149
+
150
+ while (Date.now() - startTime < this.options.timeout) {
151
+ const { value, done } = await reader.read()
152
+ if (done) break
153
+
154
+ output += decoder.decode(value)
155
+ const match = output.match(/DevTools listening on (ws:\/\/[^\s]+)/)
156
+ if (match) {
157
+ this.debuggerUrl = match[1]
158
+ break
159
+ }
160
+ }
161
+
162
+ reader.releaseLock()
163
+
164
+ if (!this.debuggerUrl) {
165
+ await this.close()
166
+ throw new Error('Failed to get Chrome DevTools URL')
167
+ }
168
+
169
+ // Get page target
170
+ const baseHttpUrl = this.debuggerUrl.replace('ws://', 'http://').split('/devtools/')[0]
171
+ const listUrl = `${baseHttpUrl}/json/list`
172
+ await new Promise(r => setTimeout(r, 1000))
173
+
174
+ const response = await fetch(listUrl)
175
+ const responseText = await response.text()
176
+
177
+ let targets: Array<{ type: string, webSocketDebuggerUrl: string }>
178
+ try {
179
+ targets = JSON.parse(responseText)
180
+ }
181
+ catch {
182
+ throw new Error(`Failed to parse browser targets: ${responseText.slice(0, 200)}`)
183
+ }
184
+
185
+ const pageTarget = targets.find(t => t.type === 'page')
186
+ if (!pageTarget) {
187
+ const newPageUrl = `${baseHttpUrl}/json/new?about:blank`
188
+ const newPageResponse = await fetch(newPageUrl, { method: 'PUT' })
189
+ const newPageText = await newPageResponse.text()
190
+ let newPage: { webSocketDebuggerUrl: string }
191
+ try {
192
+ newPage = JSON.parse(newPageText)
193
+ }
194
+ catch {
195
+ throw new Error(`Failed to create new page: ${newPageText.slice(0, 200)}`)
196
+ }
197
+ this.debuggerUrl = newPage.webSocketDebuggerUrl
198
+ }
199
+ else {
200
+ this.debuggerUrl = pageTarget.webSocketDebuggerUrl
201
+ }
202
+
203
+ await this.connect()
204
+
205
+ // Enable necessary domains
206
+ await this.send('Network.enable')
207
+ await this.send('Page.enable')
208
+ await this.send('Runtime.enable')
209
+
210
+ // Set user agent
211
+ await this.send('Network.setUserAgentOverride', {
212
+ userAgent: this.options.userAgent,
213
+ })
214
+
215
+ // Set viewport
216
+ await this.send('Emulation.setDeviceMetricsOverride', {
217
+ width: this.options.viewportWidth,
218
+ height: this.options.viewportHeight,
219
+ deviceScaleFactor: 1,
220
+ mobile: false,
221
+ })
222
+ }
223
+
224
+ private async connect(): Promise<void> {
225
+ return new Promise((resolve, reject) => {
226
+ if (!this.debuggerUrl) {
227
+ reject(new Error('No debugger URL'))
228
+ return
229
+ }
230
+
231
+ this.ws = new WebSocket(this.debuggerUrl)
232
+
233
+ this.ws.onopen = () => resolve()
234
+ this.ws.onerror = (e) => reject(new Error(`WebSocket error: ${e}`))
235
+ this.ws.onclose = () => { this.ws = null }
236
+
237
+ this.ws.onmessage = (event) => {
238
+ try {
239
+ const message: CDPMessage = JSON.parse(String(event.data))
240
+
241
+ // Handle responses
242
+ if (message.id !== undefined) {
243
+ const pending = this.pendingMessages.get(message.id)
244
+ if (pending) {
245
+ this.pendingMessages.delete(message.id)
246
+ if (message.error) {
247
+ pending.reject(new Error(message.error.message))
248
+ }
249
+ else {
250
+ pending.resolve(message.result)
251
+ }
252
+ }
253
+ }
254
+
255
+ // Handle events
256
+ if (message.method) {
257
+ const listeners = this.eventListeners.get(message.method)
258
+ if (listeners) {
259
+ for (const listener of listeners) {
260
+ listener(message.params)
261
+ }
262
+ }
263
+ }
264
+ }
265
+ catch {
266
+ // Ignore parse errors
267
+ }
268
+ }
269
+ })
270
+ }
271
+
272
+ private async send(method: string, params: Record<string, unknown> = {}): Promise<unknown> {
273
+ if (!this.ws) throw new Error('Not connected to browser')
274
+
275
+ const id = ++this.messageId
276
+
277
+ return new Promise((resolve, reject) => {
278
+ const timeout = setTimeout(() => {
279
+ this.pendingMessages.delete(id)
280
+ reject(new Error(`CDP command timed out: ${method}`))
281
+ }, this.options.timeout)
282
+
283
+ this.pendingMessages.set(id, {
284
+ resolve: (value) => {
285
+ clearTimeout(timeout)
286
+ resolve(value)
287
+ },
288
+ reject: (error) => {
289
+ clearTimeout(timeout)
290
+ reject(error)
291
+ },
292
+ })
293
+
294
+ this.ws!.send(JSON.stringify({ id, method, params }))
295
+ })
296
+ }
297
+
298
+ on(event: string, callback: (_params: unknown) => void): void {
299
+ const listeners = this.eventListeners.get(event) || []
300
+ listeners.push(callback)
301
+ this.eventListeners.set(event, listeners)
302
+ }
303
+
304
+ async goto(url: string): Promise<string> {
305
+ await this.send('Page.navigate', { url })
306
+ await new Promise(r => setTimeout(r, 2000))
307
+
308
+ const result = await this.send('Runtime.evaluate', {
309
+ expression: 'document.documentElement.outerHTML',
310
+ returnByValue: true,
311
+ }) as { result: { value: string } }
312
+
313
+ return result.result.value
314
+ }
315
+
316
+ async waitForNavigation(timeout = 30000): Promise<void> {
317
+ return new Promise((resolve, reject) => {
318
+ const timer = setTimeout(() => {
319
+ reject(new Error('Navigation timeout'))
320
+ }, timeout)
321
+
322
+ const handler = () => {
323
+ clearTimeout(timer)
324
+ this.eventListeners.delete('Page.loadEventFired')
325
+ resolve()
326
+ }
327
+
328
+ this.on('Page.loadEventFired', handler)
329
+ })
330
+ }
331
+
332
+ async waitForUrl(urlPattern: string | RegExp, timeout = 60000): Promise<string> {
333
+ const startTime = Date.now()
334
+
335
+ while (Date.now() - startTime < timeout) {
336
+ const result = await this.send('Runtime.evaluate', {
337
+ expression: 'window.location.href',
338
+ returnByValue: true,
339
+ }) as { result: { value: string } }
340
+
341
+ const currentUrl = result.result.value
342
+
343
+ if (typeof urlPattern === 'string') {
344
+ if (currentUrl.includes(urlPattern)) return currentUrl
345
+ }
346
+ else {
347
+ if (urlPattern.test(currentUrl)) return currentUrl
348
+ }
349
+
350
+ await new Promise(r => setTimeout(r, 500))
351
+ }
352
+
353
+ throw new Error(`Timeout waiting for URL pattern: ${urlPattern}`)
354
+ }
355
+
356
+ async type(selector: string, text: string): Promise<void> {
357
+ const escapedSelector = selector.replace(/'/g, "\\'")
358
+
359
+ // Set value directly via JavaScript for reliability
360
+ const result = await this.send('Runtime.evaluate', {
361
+ expression: `
362
+ (function() {
363
+ const el = document.querySelector('${escapedSelector}');
364
+ if (!el) return { success: false, error: 'Element not found' };
365
+
366
+ // Focus the element
367
+ el.focus();
368
+
369
+ // Clear and set value
370
+ el.value = '${text.replace(/'/g, "\\'")}';
371
+
372
+ // Trigger events
373
+ el.dispatchEvent(new Event('input', { bubbles: true }));
374
+ el.dispatchEvent(new Event('change', { bubbles: true }));
375
+ el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }));
376
+
377
+ return { success: true, value: el.value };
378
+ })()
379
+ `,
380
+ returnByValue: true,
381
+ }) as { result: { value: { success: boolean, error?: string, value?: string } } }
382
+
383
+ if (!result.result.value?.success) {
384
+ throw new Error(`Failed to type into ${selector}: ${result.result.value?.error || 'unknown error'}`)
385
+ }
386
+ }
387
+
388
+ async click(selector: string): Promise<void> {
389
+ const escapedSelector = selector.replace(/'/g, "\\'")
390
+
391
+ // Scroll element into view and get position
392
+ const result = await this.send('Runtime.evaluate', {
393
+ expression: `
394
+ (function() {
395
+ const el = document.querySelector('${escapedSelector}');
396
+ if (!el) return null;
397
+ el.scrollIntoView({ behavior: 'instant', block: 'center' });
398
+ const rect = el.getBoundingClientRect();
399
+ return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
400
+ })()
401
+ `,
402
+ returnByValue: true,
403
+ }) as { result: { value: { x: number, y: number } | null } }
404
+
405
+ if (!result.result.value) {
406
+ throw new Error(`Element not found: ${selector}`)
407
+ }
408
+
409
+ const { x, y } = result.result.value
410
+
411
+ // Move mouse first
412
+ await this.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y })
413
+ await new Promise(r => setTimeout(r, 50))
414
+
415
+ // Click
416
+ await this.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 })
417
+ await new Promise(r => setTimeout(r, 50))
418
+ await this.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 })
419
+ }
420
+
421
+ async submitForm(formSelector?: string): Promise<void> {
422
+ const selector = formSelector ? formSelector.replace(/'/g, "\\'") : 'form'
423
+ await this.send('Runtime.evaluate', {
424
+ expression: `document.querySelector('${selector}')?.submit()`,
425
+ })
426
+ }
427
+
428
+ /**
429
+ * Click element using JavaScript click() - more reliable for buttons
430
+ */
431
+ async jsClick(selector: string): Promise<void> {
432
+ const escapedSelector = selector.replace(/'/g, "\\'")
433
+ await this.send('Runtime.evaluate', {
434
+ expression: `
435
+ const el = document.querySelector('${escapedSelector}');
436
+ if (el) {
437
+ el.scrollIntoView({ behavior: 'instant', block: 'center' });
438
+ el.click();
439
+ }
440
+ `,
441
+ })
442
+ }
443
+
444
+ async evaluate<T>(expression: string): Promise<T> {
445
+ const result = await this.send('Runtime.evaluate', {
446
+ expression,
447
+ returnByValue: true,
448
+ awaitPromise: true,
449
+ }) as { result: { value: T }, exceptionDetails?: { exception?: { description: string } } }
450
+
451
+ if (result.exceptionDetails) {
452
+ throw new Error(result.exceptionDetails.exception?.description || 'Evaluation failed')
453
+ }
454
+
455
+ return result.result.value
456
+ }
457
+
458
+ async getCookies(urls?: string[]): Promise<CDPCookie[]> {
459
+ const result = await this.send('Network.getCookies', urls ? { urls } : {}) as { cookies: CDPCookie[] }
460
+ return result.cookies
461
+ }
462
+
463
+ async setCookies(cookies: Array<{ name: string, value: string, domain: string, path?: string }>): Promise<void> {
464
+ await this.send('Network.setCookies', {
465
+ cookies: cookies.map(c => ({
466
+ name: c.name,
467
+ value: c.value,
468
+ domain: c.domain,
469
+ path: c.path || '/',
470
+ })),
471
+ })
472
+ }
473
+
474
+ async clearCookies(): Promise<void> {
475
+ await this.send('Network.clearBrowserCookies')
476
+ await this.send('Network.clearBrowserCache')
477
+ }
478
+
479
+ async clearAllStorage(): Promise<void> {
480
+ await this.send('Network.clearBrowserCookies')
481
+ await this.send('Network.clearBrowserCache')
482
+ // Clear storage for the origin
483
+ await this.send('Storage.clearDataForOrigin', {
484
+ origin: 'https://trainingpeaks.com',
485
+ storageTypes: 'all',
486
+ })
487
+ await this.send('Storage.clearDataForOrigin', {
488
+ origin: 'https://home.trainingpeaks.com',
489
+ storageTypes: 'all',
490
+ })
491
+ await this.send('Storage.clearDataForOrigin', {
492
+ origin: 'https://app.trainingpeaks.com',
493
+ storageTypes: 'all',
494
+ })
495
+ }
496
+
497
+ /**
498
+ * Enable request interception to log API calls
499
+ */
500
+ async enableRequestLogging(urlFilter?: RegExp): Promise<string[]> {
501
+ const requests: string[] = []
502
+
503
+ this.on('Network.requestWillBeSent', (eventParams: unknown) => {
504
+ const p = eventParams as { request: { url: string, method: string } }
505
+ const url = p.request.url
506
+ if (!urlFilter || urlFilter.test(url)) {
507
+ requests.push(`${p.request.method} ${url}`)
508
+ }
509
+ })
510
+
511
+ return requests
512
+ }
513
+
514
+ async screenshot(path?: string): Promise<string> {
515
+ const result = await this.send('Page.captureScreenshot', { format: 'png' }) as { data: string }
516
+
517
+ if (path) {
518
+ await Bun.write(path, Buffer.from(result.data, 'base64'))
519
+ }
520
+
521
+ return result.data
522
+ }
523
+
524
+ async close(): Promise<void> {
525
+ for (const [id, pending] of this.pendingMessages) {
526
+ pending.reject(new Error('Browser closed'))
527
+ this.pendingMessages.delete(id)
528
+ }
529
+
530
+ if (this.ws) {
531
+ try {
532
+ await this.send('Browser.close')
533
+ }
534
+ catch {
535
+ // Ignore
536
+ }
537
+ this.ws.close()
538
+ this.ws = null
539
+ }
540
+
541
+ if (this.process) {
542
+ this.process.kill()
543
+ this.process = null
544
+ }
545
+
546
+ // Clean up temp user data dir
547
+ if (this.userDataDir) {
548
+ try {
549
+ const proc = Bun.spawn(['rm', '-rf', this.userDataDir], { stdout: 'ignore', stderr: 'ignore' })
550
+ await proc.exited
551
+ }
552
+ catch {
553
+ // Ignore cleanup errors
554
+ }
555
+ this.userDataDir = null
556
+ }
557
+ }
558
+ }