Last active
April 19, 2025 21:19
-
-
Save p32929/7a2375cf2eb3d2986a741d7dc293a4c8 to your computer and use it in GitHub Desktop.
Playwright utility functions
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// v0.0.17 | |
import path from "path"; | |
import fs from 'fs'; | |
import { Page, BrowserType, BrowserContext, chromium, firefox } from "playwright"; | |
class ChromeConstants { | |
static SHOULD_CRASH_AFTER_URL_RETRY = true | |
static dbPath = "./data/database.json" | |
static defaultChromeTimeout = 1000 * 60 * 5 | |
static defaultMaxWaitMs = 1000 * 5 | |
static defaultMinWaitMs = 1000 | |
static defaultShortWait = 2500 | |
static defaultPageCreateWait = 2000 | |
static defaultDownloadWaitMs = 1000 * 10 | |
static defaultButtonClickTimeout = 1000 * 15 | |
static defaultButtonClickDelay = 500 | |
static defaultUploadWaitMs = 1000 * 30 | |
static maxGotoRetries = 5 | |
} | |
type BrowserTypes = "chrome" | "firefox" | |
interface IBrowserOptions { | |
mode: "sessioned" | "private", | |
sessionPath: string, | |
timeout: number, | |
browser: BrowserTypes, | |
headless: boolean, | |
/* | |
In order to mute browser completely, use this: | |
https://addons.mozilla.org/en-US/firefox/addon/mute-sites-by-default/ | |
https://chrome.google.com/webstore/detail/clever-mute/eadinjjkfelcokdlmoechclnmmmjnpdh | |
*/ | |
/* | |
In order to mute block images/videos/etc completely, use this: | |
https://addons.mozilla.org/en-US/firefox/addon/image-video-block/ | |
https://chromewebstore.google.com/detail/block-imagevideo/njclihbmkjiklhnhpmajjjkahhnbnpca | |
*/ | |
} | |
const defaultValues: IBrowserOptions = { | |
mode: "sessioned", | |
sessionPath: `./data/sessions/`, | |
timeout: ChromeConstants.defaultChromeTimeout, | |
browser: "firefox", | |
headless: false, | |
} | |
let originalViewport = null | |
const getRandomInt = (min: number = 0, max: number = Number.MAX_VALUE) => { | |
const int = Math.floor(Math.random() * (max - min + 1) + min) | |
return int | |
} | |
const delay = (ms) => new Promise((res) => setTimeout(res, ms)); | |
// | |
export class Chrome { | |
private options: IBrowserOptions = defaultValues | |
private page: Page = null | |
private context: BrowserContext = null | |
private isInitting = false | |
private openedPages: number = 0 | |
private tryingToOpenPages: number = 0 | |
constructor(options: Partial<IBrowserOptions> = defaultValues) { | |
this.options = { | |
...defaultValues, | |
...options, | |
} | |
} | |
private getBrowser(): BrowserType<{}> { | |
console.log(`chrome.ts :: Chrome :: getBrowser :: `) | |
if (this.options.browser === 'chrome') { | |
return chromium | |
} | |
else if (this.options.browser === 'firefox') { | |
return firefox | |
} | |
} | |
async getNewPage() { | |
console.log(`chrome.ts :: Chrome :: getNewPage :: this.openedPages -> ${this.openedPages} , this.context.pages().length -> ${this?.context?.pages().length} `) | |
while (this.isInitting) { | |
console.log(`chrome.ts :: Chrome :: getNewPage :: this.isInitting -> ${this.isInitting} `) | |
await delay(ChromeConstants.defaultShortWait) | |
} | |
console.log(`chrome.ts :: Chrome :: getNewPage :: this.isInitting -> ${this.isInitting} `) | |
if (this.isInitting === false && this.context === null) { | |
this.isInitting = true | |
if (this.options.mode == "sessioned") { | |
this.context = await this.getBrowser().launchPersistentContext( | |
this.options.sessionPath, { | |
headless: this.options.headless, | |
timeout: this.options.timeout, | |
ignoreHTTPSErrors: true, | |
}) | |
this.context.setDefaultNavigationTimeout(this.options.timeout) | |
this.context.setDefaultTimeout(this.options.timeout) | |
} | |
else if (this.options.mode == "private") { | |
const browser = await this.getBrowser().launch({ | |
headless: this.options.headless, | |
timeout: this.options.timeout, | |
}); | |
this.context = await browser.newContext({ | |
ignoreHTTPSErrors: true, | |
}) | |
this.context.setDefaultNavigationTimeout(this.options.timeout) | |
this.context.setDefaultTimeout(this.options.timeout) | |
} | |
await this.context.addInitScript("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})") | |
this.isInitting = false | |
} | |
console.log(`chrome.ts :: Chrome :: getNewPage-1 :: this.tryingToOpenPages -> ${this.tryingToOpenPages} , this.openedPages -> ${this.openedPages} `) | |
while (this.tryingToOpenPages !== this.openedPages) { | |
await delay(ChromeConstants.defaultPageCreateWait) | |
console.log(`chrome.ts :: Chrome :: getNewPage-1 :: this.tryingToOpenPages -> ${this.tryingToOpenPages} , this.openedPages -> ${this.openedPages} `) | |
} | |
this.tryingToOpenPages++ | |
this.page = await this.context.newPage(); | |
this.openedPages++ | |
return this.page | |
} | |
async destroy() { | |
try { | |
const pages = this.context.pages() | |
for (var i = 0; i < pages.length; i++) { | |
await pages[i].close() | |
} | |
await this.context.close() | |
} | |
catch (e) { | |
// | |
} | |
} | |
// ############################# | |
// ############################# | |
// ############################# | |
static async downloadFile( | |
page: Page, | |
url: string, | |
filePath: string, | |
waitTimeout: number = ChromeConstants.defaultDownloadWaitMs | |
): Promise<boolean> { | |
console.log(`⏬ downloadFile: fetching ${url} → ${filePath}`); | |
try { | |
// 1. Fetch via Playwright’s request context (uses same cookies/auth as the page). | |
const response = await page.request.get(url, { timeout: waitTimeout }); | |
// 2. Check HTTP status | |
if (!response.ok()) { | |
console.error( | |
`‼️ downloadFile failed: ${response.status()} ${response.statusText()}` | |
); | |
return false; | |
} | |
// 3. Read body as Buffer | |
const buffer = await response.body(); | |
// 4. Ensure directory exists | |
const dir = path.dirname(filePath); | |
await fs.promises.mkdir(dir, { recursive: true }); | |
// 5. Write to disk | |
await fs.promises.writeFile(filePath, buffer); | |
console.log(`✅ downloadFile success: saved to ${filePath}`); | |
return true; | |
} catch (err) { | |
console.error(`❌ downloadFile error:`, err); | |
return false; | |
} | |
} | |
static async downloadFileByButtonClick(page: Page, buttonSelector: string, filePath: string): Promise<boolean> { | |
console.log(`chrome.ts :: Chrome :: downloadFileByButtonClick :: buttonSelector -> ${buttonSelector} , filePath -> ${filePath} `) | |
return new Promise(async (resolve) => { | |
try { | |
const downloadPromise = page.waitForEvent('download'); | |
await page.click(buttonSelector) | |
const download = await downloadPromise; | |
await download.saveAs(filePath); | |
await Chrome.waitForTimeout(page, { | |
maxTimeout: ChromeConstants.defaultDownloadWaitMs, | |
}) | |
resolve(true) | |
} catch (e) { | |
resolve(false) | |
} | |
}) | |
} | |
static async uploadFiles(page: Page, uploadButtonSelector: string, fileLocations: string | string[], wait: number = ChromeConstants.defaultUploadWaitMs) { | |
console.log(`chrome.ts :: Chrome :: uploadFiles :: uploadButtonSelector -> ${uploadButtonSelector} , fileLocations -> ${fileLocations} `) | |
const [fileChooser] = await Promise.all([ | |
page.waitForEvent('filechooser'), | |
await Chrome.waitForTimeout(page), | |
page.click(uploadButtonSelector), | |
]); | |
await fileChooser.setFiles(fileLocations) | |
await Chrome.waitForTimeout(page, { | |
maxTimeout: wait, | |
}) | |
} | |
static async uploadFilesForced(page: Page, uploadButtonSelector: string, fileLocations: string | string[]) { | |
console.log(`chrome.ts :: Chrome :: uploadFiles :: uploadButtonSelector -> ${uploadButtonSelector} , fileLocations -> ${fileLocations} `) | |
const [fileChooser] = await Promise.all([ | |
page.waitForEvent('filechooser'), | |
Chrome.waitForTimeout(page), | |
page.click(uploadButtonSelector), | |
]); | |
// await fileChooser.setFiles(fileLocations) | |
for (var i = 0; i < fileLocations.length; i++) { | |
await fileChooser.setFiles(fileLocations[i]) | |
// await page.waitForTimeout(500) | |
await Chrome.waitForTimeout(page) | |
} | |
await Chrome.waitForTimeout(page, { | |
maxTimeout: ChromeConstants.defaultUploadWaitMs, | |
}) | |
} | |
static async getCurrentHeightWidth(page: Page): Promise<{ | |
height: number; | |
width: number; | |
}> { | |
console.log(`chrome.ts :: Chrome :: getCurrentHeightWidth :: `) | |
const obj = await page.evaluate(() => { | |
return { | |
height: window.outerHeight, | |
width: window.outerWidth, | |
} | |
}) | |
return obj | |
} | |
static async copyTextToClipboard(page: Page, text: string) { | |
console.log(`chrome.ts :: Chrome :: copyTextToClipboard :: text -> ${text} `) | |
await page.evaluate((text) => { | |
navigator.clipboard.writeText(text) | |
}, text) | |
await Chrome.waitForTimeout(page) | |
} | |
static async gotoForce(page: Page, url: string, disableDownloads: boolean = false) { | |
const retryCount = ChromeConstants.maxGotoRetries; | |
let openingUrl = ""; // Declare openingUrl here | |
let downloadDetected = false; // Flag to detect if a download was triggered | |
try { | |
const currentLocation = await page.evaluate(() => window.location.href); | |
if (currentLocation === url) { | |
await Chrome.waitForTimeout(page); | |
return; | |
} | |
if (disableDownloads) { | |
// Listen for download events and cancel them | |
page.on('download', async (download) => { | |
console.log(`Download detected and canceled: ${download.suggestedFilename()}`); | |
await download.cancel(); // Cancel the download | |
downloadDetected = true; // Set the flag to true | |
}); | |
} | |
const tryUrl = async (): Promise<boolean> => { | |
try { | |
const response = await page.goto(url, { | |
timeout: 90 * 1000, | |
waitUntil: 'load', | |
}); | |
const status = response.status(); | |
console.log(`Chrome.ts :: Chrome :: tryUrl :: status -> ${status}`); | |
// Check if the page contains a specific timeout error message | |
if (await page.$('text="The connection has timed out"')) { | |
console.log(`Timeout error detected on the page: ${url}`); | |
return false; | |
} | |
await Chrome.waitForTimeout(page); | |
return true; | |
} catch (e) { | |
console.log(`chrome.ts :: Chrome :: tryUrl :: e -> ${e}`); | |
return false; | |
} | |
}; | |
openingUrl = url; | |
for (let i = 0; i < retryCount; i++) { | |
if (downloadDetected) { | |
console.log(`chrome.ts :: Chrome :: gotoForce= :: Download detected, skipping URL -> ${url}`); | |
break; | |
} | |
const opened = await tryUrl(); | |
console.log(`chrome.ts :: Chrome :: gotoForce= :: url -> ${url} , opened -> ${opened} , i -> ${i}`); | |
if (opened) { | |
openingUrl = ""; | |
break; | |
} else { | |
console.log(`chrome.ts :: Chrome :: gotoForce= :: Retrying... :: url -> ${url} , opened -> ${opened} , i -> ${i}`); | |
await Chrome.waitForTimeout(page); | |
if (i === retryCount - 1) { | |
console.log('Max retries reached. Issue persists.'); | |
} | |
} | |
} | |
if (!downloadDetected) { | |
console.log(`chrome.ts :: Chrome :: gotoForce= :: url -> ${url} , openingUrl -> ${openingUrl} :: Success...`); | |
} | |
openingUrl = ""; | |
} catch (e) { | |
console.log(`chrome.ts :: Chrome :: gotoForce= :: e -> ${e}`); | |
console.log(`chrome.ts :: Chrome :: gotoForce= :: url -> ${url} , openingUrl -> ${openingUrl} :: Failed...`); | |
openingUrl = ""; | |
} | |
}; | |
static async scrollDown(page: Page, nTimes: number = 10, wait: number = ChromeConstants.defaultMaxWaitMs) { | |
console.log(`chrome.ts :: Chrome :: scrollDown :: nTimes -> ${nTimes} , wait -> ${wait} `) | |
for (var i = 0; i < nTimes; i++) { | |
await page.evaluate(() => { | |
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }) | |
}) | |
await page.waitForTimeout(wait) | |
} | |
} | |
static async getCurrentPageUrl(page: Page) { | |
const currentLocation = await page.evaluate(() => { | |
return window.location.href | |
}) | |
console.log(`chrome.ts :: Chrome :: getCurrentPageUrl :: currentLocation :: ${currentLocation}`) | |
return currentLocation | |
} | |
static async setIphoneViewport(page: Page) { | |
console.log(`chrome.ts :: Chrome :: setIphoneViewport :: `) | |
originalViewport = page.viewportSize() | |
await page.setViewportSize({ | |
width: 390, | |
height: 844, | |
}) | |
await page.reload() | |
await page.waitForTimeout(100) | |
} | |
static async resetViewport(page: Page) { | |
try { | |
await page.setViewportSize(originalViewport) | |
} | |
catch (e) { | |
// | |
} | |
await page.reload() | |
await page.waitForTimeout(ChromeConstants.defaultMaxWaitMs) | |
} | |
// static async tryClick(page: Page, selector: string, options?: { | |
// forceClick?: boolean, | |
// }) { | |
// console.log(`chrome.ts :: Chrome :: tryClick :: selector -> ${selector} , forceClick -> ${options?.forceClick} `) | |
// try { | |
// const element = await page.$(selector) | |
// await element.click({ | |
// timeout: Constants.defaultButtonClickTimeout, | |
// delay: Constants.defaultButtonClickDelay, | |
// trial: true | |
// }) | |
// await Chrome.waitForTimeout(page) | |
// await element.click({ | |
// timeout: Constants.defaultButtonClickTimeout, | |
// delay: Constants.defaultButtonClickDelay, | |
// force: options?.forceClick, | |
// }) | |
// await Chrome.waitForTimeout(page) | |
// console.log(`chrome.ts :: Chrome :: tryClick :: Success`) | |
// return true | |
// } | |
// catch (e) { | |
// console.log(`chrome.ts :: Chrome :: tryClick :: Failed`, e) | |
// return false | |
// } | |
// } | |
static async tryClick(page: Page, selector: string, options?: { | |
forceClick?: boolean, | |
}) { | |
console.log(`chrome.ts :: Chrome :: tryClick :: selector -> ${selector} , forceClick -> ${options?.forceClick} `) | |
let isClicked = false | |
try { | |
await page.waitForSelector(selector, { | |
timeout: ChromeConstants.defaultMaxWaitMs, | |
}) | |
console.log(`Found selected ` + selector) | |
} | |
catch (e) { | |
console.log(`Chrome.ts :: Chrome :: e -> `, e) | |
} | |
try { | |
const element = await page.$(selector) | |
await element.click({ | |
timeout: ChromeConstants.defaultButtonClickTimeout, | |
delay: ChromeConstants.defaultButtonClickDelay, | |
force: options?.forceClick, | |
}) | |
await Chrome.waitForTimeout(page) | |
isClicked = true | |
} | |
catch (e) { | |
console.log(`Chrome.ts :: Chrome :: e -> `, e) | |
} | |
return isClicked | |
} | |
static async tryClickElement(page: Page, element: any, options?: { | |
forceClick?: boolean, | |
}) { | |
try { | |
await element.click({ | |
timeout: ChromeConstants.defaultButtonClickTimeout, | |
delay: ChromeConstants.defaultButtonClickDelay, | |
trial: true | |
}) | |
await Chrome.waitForTimeout(page) | |
await element.click({ | |
timeout: ChromeConstants.defaultButtonClickTimeout, | |
delay: ChromeConstants.defaultButtonClickDelay, | |
force: options?.forceClick, | |
}) | |
await Chrome.waitForTimeout(page) | |
console.log(`chrome.ts :: Chrome :: tryClick :: Success`) | |
return true | |
} | |
catch (e) { | |
console.log(`chrome.ts :: Chrome :: tryClick :: Failed`, e) | |
return false | |
} | |
} | |
static async waitForTimeout(page: Page, options?: { | |
minTimeout?: number, | |
maxTimeout?: number, | |
}) { | |
const min = options?.minTimeout ?? ChromeConstants.defaultMinWaitMs | |
const max = options?.maxTimeout ?? ChromeConstants.defaultMaxWaitMs | |
const timeoutt = getRandomInt(min, max) | |
console.log(`chrome.ts :: Chrome :: waitForTimeout :: timeoutt -> ${timeoutt} `) | |
await page.waitForTimeout(timeoutt) | |
} | |
static async forceTypeText(page: Page, selector: string, text: string, append: boolean = false): Promise<boolean> { | |
console.log(`chrome.ts :: Chrome :: forceTypeText :: selector -> ${selector}, text -> ${text}`); | |
try { | |
// Wait for the element to be available | |
await page.waitForSelector(selector, { timeout: ChromeConstants.defaultMaxWaitMs }); | |
// First, get the current text length to know how many backspaces we need | |
const textLength = await page.evaluate((sel) => { | |
// Find the element | |
let el; | |
if (sel.startsWith('//')) { | |
el = document.evaluate(sel, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; | |
} else { | |
el = document.querySelector(sel); | |
} | |
if (!el) return 0; | |
// Find the contenteditable element | |
const contentEditable = el.querySelector('[contenteditable="true"]') || | |
el.querySelector('.public-DraftEditor-content') || | |
el; | |
// Get the text content | |
const content = contentEditable.textContent || ''; | |
return content.length; | |
}, selector); | |
console.log(`chrome.ts :: Chrome :: forceTypeText :: Current text length: ${textLength}`); | |
// Click to focus the element | |
await page.click(selector, { force: true }); | |
await Chrome.waitForTimeout(page, { maxTimeout: 500 }); | |
// Press backspace for each character | |
if (textLength > 0 && !append) { | |
// Select all text first (more reliable than individual backspaces) | |
await page.keyboard.down('Control'); | |
await page.keyboard.press('a'); | |
await page.keyboard.up('Control'); | |
await page.waitForTimeout(100); | |
// Delete the selected text | |
await page.keyboard.press('Backspace'); | |
await page.waitForTimeout(100); | |
} | |
// Type the new text with a small delay between characters | |
// This helps ensure the text is entered correctly even if focus changes | |
for (let i = 0; i < text.length; i++) { | |
await page.keyboard.type(text[i], { delay: 10 }); | |
// Add a small pause every few characters to reduce the chance of missed keystrokes | |
if (i % 5 === 0 && i > 0) { | |
await page.waitForTimeout(50); | |
} | |
} | |
// Press Tab to ensure the text is committed | |
await page.keyboard.press('Tab'); | |
console.log(`chrome.ts :: Chrome :: forceTypeText :: Successfully wrote text using keyboard events`); | |
await Chrome.waitForTimeout(page); | |
return true; | |
} catch (e) { | |
console.log(`chrome.ts :: Chrome :: forceTypeText :: Keyboard events approach failed -> `, e); | |
} | |
// Fallback: Try to use a combination of browser-side events and minimal Playwright interaction | |
try { | |
console.log(`chrome.ts :: Chrome :: forceTypeText :: Trying fallback approach`); | |
// First, prepare the element in the browser context | |
const prepared = await page.evaluate((sel) => { | |
// Find the element | |
let el; | |
if (sel.startsWith('//')) { | |
el = document.evaluate(sel, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; | |
} else { | |
el = document.querySelector(sel); | |
} | |
if (!el) return false; | |
// Find the contenteditable element | |
const contentEditable = el.querySelector('[contenteditable="true"]') || | |
el.querySelector('.public-DraftEditor-content') || | |
el; | |
// Focus and click the element | |
contentEditable.focus(); | |
contentEditable.click(); | |
// Select all text | |
const range = document.createRange(); | |
range.selectNodeContents(contentEditable); | |
const selection = window.getSelection(); | |
selection.removeAllRanges(); | |
selection.addRange(range); | |
return true; | |
}, selector); | |
if (!prepared) { | |
console.log(`chrome.ts :: Chrome :: forceTypeText :: Failed to prepare element`); | |
return false; | |
} | |
// Now use Playwright to delete selected text and type new text | |
await page.keyboard.press('Backspace'); | |
await page.waitForTimeout(100); | |
// Type the text with a delay | |
await page.keyboard.type(text, { delay: 20 }); | |
console.log(`chrome.ts :: Chrome :: forceTypeText :: Successfully wrote text using fallback approach`); | |
await Chrome.waitForTimeout(page); | |
return true; | |
} catch (e) { | |
console.log(`chrome.ts :: Chrome :: forceTypeText :: Fallback approach failed -> `, e); | |
} | |
console.log(`chrome.ts :: Chrome :: forceTypeText :: All methods failed for selector: ${selector}`); | |
return false; | |
} | |
static async forceTypeText2(page: Page, selector: string, text: string, append: boolean = false): Promise<boolean> { | |
try { | |
// Wait for the element to be available and click to focus it | |
await page.waitForSelector(selector); | |
await page.click(selector); | |
await page.waitForTimeout(100); // Allow focus to settle | |
// If not appending, clear the input by selecting all text and deleting it | |
if (!append) { | |
await page.keyboard.down('Control'); | |
await page.keyboard.press('a'); | |
await page.keyboard.up('Control'); | |
await page.waitForTimeout(100); | |
await page.keyboard.press('Backspace'); | |
await page.waitForTimeout(100); | |
} | |
// Type the new text with a small delay between keystrokes | |
await page.keyboard.type(text, { delay: 20 }); | |
// Optionally, press Tab to commit the change | |
await page.keyboard.press('Tab'); | |
return true; | |
} catch (error) { | |
console.error('forceTypeText error:', error); | |
return false; | |
} | |
} | |
} |
Author
p32929
commented
Aug 20, 2024
•
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment