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/LICENSE.md +21 -0
- package/README.md +358 -0
- package/package.json +45 -0
- package/src/TrainingPeaks.ts +674 -0
- package/src/browser.ts +558 -0
- package/src/http.ts +265 -0
- package/src/index.ts +5 -0
- package/src/types.ts +319 -0
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
|
+
}
|