Appearance
Authentication
If the site requires login, the script needs to be authenticated. But you don't want to store passwords, extract tokens, or manage credentials.
Problem: The Script Needs to Be Logged In
Extracting cookies, replaying tokens, or storing passwords is fragile and insecure.
Solution: open a browser window and let the human sign in. No credential extraction, no token management. The browser handles it:
javascript
import { chromium } from "playwright";
import { existsSync } from "fs";
const browser = await chromium.launch({
headless: false,
channel: "chrome",
args: [
"--disable-blink-features=AutomationControlled",
],
});
const context = existsSync(SESSION_PATH) ?
await browser.newContext({
storageState: SESSION_PATH,
}) :
await browser.newContext();Once signed in, page.evaluate(() => fetch(...)) inherits session cookies, CSRF tokens, and all headers automatically. No need to set authorization headers — the browser context handles everything.
Problem: The Site Blocks the Automated Browser
Bot detection services (Cloudflare, Turnstile, CAPTCHA, etc.) fingerprint automated browsers and present challenge pages instead of the actual site.
Solution: use real Chrome with anti-detection flags, and let the human solve challenges that still appear. Playwright can launch the system Chrome installation instead of its bundled Chromium. Disable the webdriver flag that detection scripts check:
javascript
const browser = await chromium.launch({
headless: false,
channel: "chrome",
args: [
"--disable-blink-features=AutomationControlled",
],
});
const page = await browser.newPage();
await page.addInitScript(() => {
Object.defineProperty(navigator, "webdriver", {
get: () => false,
});
});If a challenge still appears, the browser window is visible (headless: false). The script waits for the human to solve it:
javascript
let attempts = 0;
while (attempts < 120) {
await page.waitForTimeout(2000);
const text = await page.evaluate(
() => document.body.innerText,
).catch(() => "");
const isChallenge =
text.includes("security verification") ||
text.includes("Verify you are human");
if (!isChallenge) break;
attempts++;
}Problem: The Human Has to Sign In Every Time
The script runs frequently. Signing in on every run defeats the purpose of automation.
Solution: save and reload the Playwright storage state. After authentication, persist cookies and local storage to disk:
javascript
import { mkdirSync } from "fs";
import { dirname } from "path";
mkdirSync(dirname(SESSION_PATH), { recursive: true });
await context.storageState({ path: SESSION_PATH });On the next run, pass storageState to browser.newContext() (shown above) to restore the session without signing in.
Problem: Saved Sessions Could Leak
The session file grants full account access. If it ends up in a git commit or a repo copy, the account is compromised.
Solution: store the session file outside the repo. Never rely on gitignore for credential security — a force-add or repo copy would leak it. Use ~/.config/<app>/ following XDG conventions:
javascript
import { join } from "path";
const CONFIG_DIR = join(
process.env.HOME,
".config",
"my-app",
);
const SESSION_PATH = join(
CONFIG_DIR,
"playwright-session.json",
);Problem: Sessions Expire
The saved session will eventually become invalid. The script needs to detect this and recover.
Solution: poll a lightweight API endpoint at startup. If the session is dead, the browser window is already open — the human signs in again, and the new session is saved automatically:
javascript
const ok = await page.evaluate(async () => {
try {
const res = await fetch("/api/me");
return res.ok;
} catch { return false; }
}).catch(() => false);
if (!ok) {
console.log("Session expired. Sign in in the browser...");
// Wait for the human, then save the new session
}