← 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');