← back to Eur Recrawl
login.mjs
129 lines
// login.mjs — authenticated trade-portal login for the EUR- recrawl (TK-10068).
// Opens a REAL Chrome window (headful), waits for Cloudflare to clear, fills creds
// from the secrets master (values never leave this process), submits, waits for a
// logged-in state, and saves the session for the batch crawler to reuse.
//
// node login.mjs osborne # Osborne & Little + Nina Campbell
// node login.mjs dg # Designers Guild + Christian Lacroix
//
// Output: .auth/<portal>/ (persistent context, gitignored) + .auth/<portal>-state.json
import { chromium } from 'playwright';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
function loadSecrets(file, keys) {
const out = {};
if (!fs.existsSync(file)) return out;
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
const m = line.match(/^([A-Z0-9_]+)\s*=\s*(.*)$/);
if (m && keys.includes(m[1])) out[m[1]] = m[2].trim().replace(/^["']|["']$/g, '');
}
return out;
}
const SECRETS = path.join(os.homedir(), 'Projects/secrets-manager/.env');
const s = loadSecrets(SECRETS, [
'OSBORNE_USERNAME', 'OSBORNE_PASSWORD',
'DESIGNERSGUILD_PORTAL_USER', 'DESIGNERSGUILD_PORTAL_PASS', 'DESIGNERSGUILD_LOGIN_URL',
]);
const PORTALS = {
osborne: {
name: 'Osborne & Little (+ Nina Campbell)',
loginUrl: 'https://tradenew.osborneandlittle.com/',
user: s.OSBORNE_USERNAME, pass: s.OSBORNE_PASSWORD,
loggedInHint: /log\s?out|sign\s?out|my account|dashboard|basket|trade price/i,
},
dg: {
name: 'Designers Guild (+ Christian Lacroix)',
loginUrl: s.DESIGNERSGUILD_LOGIN_URL || 'https://www.designersguild.com/en-us/login/l102?t=1',
user: s.DESIGNERSGUILD_PORTAL_USER, pass: s.DESIGNERSGUILD_PORTAL_PASS,
loggedInHint: /log\s?out|sign\s?out|my account|dashboard|trade|net price|order history/i,
},
};
const which = (process.argv[2] || 'osborne').toLowerCase();
const cfg = PORTALS[which];
if (!cfg) { console.error(`Unknown portal "${which}". Use: osborne | dg`); process.exit(1); }
if (!cfg.user || !cfg.pass) { console.error(`Missing creds for ${which} in secrets master.`); process.exit(2); }
const log = (m) => console.log(' ' + m);
const authDir = path.join(process.cwd(), '.auth', which);
fs.mkdirSync(authDir, { recursive: true });
console.log(`\n▶ ${cfg.name}\n login: ${cfg.loginUrl}`);
const ctx = await chromium.launchPersistentContext(authDir, {
headless: false, channel: 'chrome',
viewport: { width: 1440, height: 900 },
args: ['--disable-blink-features=AutomationControlled'],
});
const page = ctx.pages()[0] || await ctx.newPage();
try { await page.goto(cfg.loginUrl, { waitUntil: 'domcontentloaded', timeout: 60000 }); }
catch (e) { log(`nav warning: ${e.message}`); }
await page.waitForTimeout(1500);
log(`loaded: "${(await page.title().catch(() => '')).slice(0, 80)}" @ ${page.url()}`);
// Let Cloudflare clear + the form render. Fields may be hidden behind tabs (DG), so wait
// for the form to be ATTACHED (present in DOM), not visible.
try {
await page.waitForSelector('#trade-login-email, #login-email, input[type=email], input[type=password]', { timeout: 60000, state: 'attached' });
log('login form present');
} catch { log('login form not detected in 60s — a Cloudflare/captcha challenge may be showing; solve it in the window if so.'); }
await page.waitForTimeout(1500);
async function fillCreds() {
if (which === 'dg') {
// Designers Guild: use the TRADE LOGIN tab (gives net/trade pricing).
const tab = await page.$('#signin-trade-tab-anchor');
if (tab) { await tab.click().catch(() => {}); await page.waitForTimeout(1200); log('opened Trade Login tab'); }
const radio = await page.$('#trade-already-authenticated');
if (radio) { await radio.check().catch(() => radio.click().catch(() => {})); }
const em = await page.$('#trade-login-email');
const pw = await page.$('#trade-login-password');
if (em) { await em.fill('').catch(() => {}); await em.type(cfg.user, { delay: 25 }); log('trade email filled'); } else log('trade email field not found');
if (pw) { await pw.fill('').catch(() => {}); await pw.type(cfg.pass, { delay: 25 }); log('trade password filled'); } else log('trade password field not found');
// reCAPTCHA may sit between fill and submit — pass invisibly or wait for Steve to click it.
const rc = page.frames().some((f) => /recaptcha/.test(f.url()));
if (rc) log('reCAPTCHA present — if it shows a challenge, click it in the window; then I detect login.');
const signin = await page.$('#trade-login-password ~ button[type=submit], button:has-text("Sign in")');
if (signin) { await signin.click().catch(() => {}); log('trade Sign in clicked'); } else log('trade Sign in button not found');
return;
}
// generic (osborne + others)
const u = await page.$('input[type=email], input[autocomplete="username"], input[name*=user i], input[name*=email i], input[id*=user i], input[id*=email i]');
if (u) { await u.fill('').catch(() => {}); await u.type(cfg.user, { delay: 25 }); log('username filled'); } else log('username field not found');
if (!(await page.$('input[type=password]'))) {
const nxt = await page.$('button:has-text("Continue"), button:has-text("Next")');
if (nxt) { await nxt.click().catch(() => {}); await page.waitForTimeout(1500); }
}
const p = await page.$('input[type=password]');
if (p) { await p.fill('').catch(() => {}); await p.type(cfg.pass, { delay: 25 }); log('password filled'); } else log('password field not found');
const btn = await page.$('button[type=submit], input[type=submit], button:has-text("Log in"), button:has-text("Login"), button:has-text("Sign in")');
if (btn) { await btn.click().catch(() => {}); log('submit clicked'); } else { await page.keyboard.press('Enter').catch(() => {}); log('submitted via Enter'); }
}
await fillCreds().catch((e) => log('fill error: ' + e.message));
log('waiting for logged-in state (up to 6 min; complete any challenge by hand)...');
let ok = false;
const deadline = Date.now() + 6 * 60 * 1000;
while (Date.now() < deadline) {
const body = await page.evaluate(() => (document.body ? document.body.innerText : '')).catch(() => '');
if (cfg.loggedInHint.test(body) && !/incorrect|invalid (e-?mail|password|login)|try again/i.test(body)) { ok = true; break; }
await page.waitForTimeout(4000);
}
await ctx.storageState({ path: path.join(process.cwd(), '.auth', `${which}-state.json`) }).catch(() => {});
if (ok) {
log(`✅ logged in @ ${page.url()} — session saved → .auth/${which}-state.json`);
} else {
log(`⚠ no logged-in signal in 6 min. Session cookies persisted in .auth/${which}/ anyway; re-run to resume.`);
}
// Keep the window open briefly so state settles / Steve can eyeball, then close.
await page.waitForTimeout(4000);
await ctx.close();
log('done.\n');