[object Object]

← back to Eur Recrawl

Osborne portal probe: reachable, clean login form (no CF), but stored creds (acct 1433803) rejected -> 'Login Failed'. Stopped after 1 attempt (account-lock risk). Blocked on current creds.

1a1804f59f0f6c1157cd8046a55e03933a14ab7a · 2026-07-30 18:05:28 -0700 · Steve

Files touched

Diff

commit 1a1804f59f0f6c1157cd8046a55e03933a14ab7a
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jul 30 18:05:28 2026 -0700

    Osborne portal probe: reachable, clean login form (no CF), but stored creds (acct 1433803) rejected -> 'Login Failed'. Stopped after 1 attempt (account-lock risk). Blocked on current creds.
---
 .gitignore        |   1 +
 login.mjs         | 105 ++++++++++++++++++++++++------------------------------
 package-lock.json |  59 ++++++++++++++++++++++++++++++
 package.json      |   2 +-
 probe-dg.mjs      |  47 ++++++++++++++++++++++++
 probe.mjs         |  85 +++++++++++++++++++++++++++++++++++++++++++
 6 files changed, 240 insertions(+), 59 deletions(-)

diff --git a/.gitignore b/.gitignore
index 15affcf..37e7465 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,4 @@ tmp/
 __pycache__/
 playwright-report/
 .auth/
+probe-*.png
diff --git a/login.mjs b/login.mjs
index 4c8bcb4..ccf67ee 100644
--- a/login.mjs
+++ b/login.mjs
@@ -1,21 +1,18 @@
 // login.mjs — authenticated trade-portal login for the EUR- recrawl (TK-10068).
-// Opens a REAL Chrome window (headful) so Cloudflare/2FA can be passed as a real
-// browser — DW logging into its OWN supplier accounts (legitimate access, never evasion).
-// Auto-fills creds from the secrets master (values never leave this process), then WAITS
-// for a logged-in state (auto or via Steve's hands) and saves the session for the crawler.
+// 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   # covers Osborne & Little + Nina Campbell
-//   node login.mjs dg        # covers Designers Guild + Christian Lacroix
+//   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
-// The batch crawler (owned by the eur-recrawl session) reuses .auth/<portal>-state.json.
 
 import { chromium } from 'playwright';
 import fs from 'node:fs';
 import path from 'node:path';
 import os from 'node:os';
 
-// Read ONLY the keys we need from the secrets master; never print values.
 function loadSecrets(file, keys) {
   const out = {};
   if (!fs.existsSync(file)) return out;
@@ -36,89 +33,81 @@ const PORTALS = {
   osborne: {
     name: 'Osborne & Little (+ Nina Campbell)',
     loginUrl: 'https://tradenew.osborneandlittle.com/',
-    user: s.OSBORNE_USERNAME,
-    pass: s.OSBORNE_PASSWORD,
+    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/i,
+    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 — expected keys present? (values not shown)`);
-  process.exit(2);
-}
+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}`);
-console.log(`  login: ${cfg.loginUrl}`);
-console.log(`  A real Chrome window is opening. If a Cloudflare / 2FA / captcha challenge`);
-console.log(`  appears, just complete it by hand — the script waits and then saves the session.\n`);
+console.log(`\n▶ ${cfg.name}\n  login: ${cfg.loginUrl}`);
 
 const ctx = await chromium.launchPersistentContext(authDir, {
-  headless: false,
-  channel: 'chrome',            // real Google Chrome → passes Cloudflare naturally
+  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) {
-  console.log(`  (nav warning: ${e.message}) — leaving window open for manual login.`);
-}
+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()}`);
 
-// Best-effort auto-fill; silently skip if the fields aren't present (Cloudflare/custom form).
-async function tryFill() {
-  const userSel = ['input[type=email]', 'input[name*=user i]', 'input[name*=email i]', 'input[id*=user i]', 'input[id*=email i]'];
-  const passSel = ['input[type=password]', 'input[name*=pass i]', 'input[id*=pass i]'];
-  for (const u of userSel) {
-    const el = await page.$(u);
-    if (el) { await el.fill(cfg.user).catch(() => {}); break; }
-  }
-  for (const p of passSel) {
-    const el = await page.$(p);
-    if (el) { await el.fill(cfg.pass).catch(() => {}); break; }
+// Let Cloudflare clear + the form render (real Chrome passes the challenge naturally).
+let hasPass = false;
+try { await page.waitForSelector('input[type=password]', { timeout: 90000, state: 'visible' }); hasPass = true; }
+catch { log('no password field after 90s — a Cloudflare/captcha challenge may be showing; solve it in the window if so.'); }
+
+async function fillCreds() {
+  const userSel = 'input[type=email], input[autocomplete="username"], input[name*=user i], input[name*=email i], input[id*=user i], input[id*=email i]';
+  const u = await page.$(userSel);
+  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); }
   }
-  // try to submit
-  const btn = await page.$('button[type=submit], input[type=submit], button:has-text("Log in"), button:has-text("Sign in")');
-  if (btn) await btn.click().catch(() => {});
+  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"), 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 page.waitForTimeout(2500);
-await tryFill().catch(() => {});
+await fillCreds().catch((e) => log('fill error: ' + e.message));
 
-// Wait (up to 6 min) for a logged-in signal — auto or human-completed.
-console.log('  Waiting for logged-in state (up to 6 min)...');
+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)) { ok = true; break; }
+  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) {
-  await ctx.storageState({ path: path.join(process.cwd(), '.auth', `${which}-state.json`) });
-  console.log(`\n✅ Logged in. Session saved → .auth/${which}-state.json (reused by the crawler).`);
-  console.log(`   Current URL: ${page.url()}`);
+  log(`✅ logged in @ ${page.url()} — session saved → .auth/${which}-state.json`);
 } else {
-  // Save whatever state exists anyway (persistent context keeps cookies on disk).
-  await ctx.storageState({ path: path.join(process.cwd(), '.auth', `${which}-state.json`) }).catch(() => {});
-  console.log(`\n⚠ Did not auto-detect a logged-in state within 6 min.`);
-  console.log(`   If you did log in, the session is still persisted in .auth/${which}/ (cookies on disk).`);
-  console.log(`   Re-run to resume; the crawler can also just reuse the persistent context.`);
+  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();
-console.log('  Browser closed.\n');
+log('done.\n');
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..1e4ea11
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,59 @@
+{
+  "name": "eur-recrawl",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "eur-recrawl",
+      "version": "0.1.0",
+      "dependencies": {
+        "playwright": "^1.61.0"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+      "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/playwright": {
+      "version": "1.61.0",
+      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
+      "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "playwright-core": "1.61.0"
+      },
+      "bin": {
+        "playwright": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "fsevents": "2.3.2"
+      }
+    },
+    "node_modules/playwright-core": {
+      "version": "1.61.0",
+      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
+      "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
+      "license": "Apache-2.0",
+      "bin": {
+        "playwright-core": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    }
+  }
+}
diff --git a/package.json b/package.json
index a464725..520f02b 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,6 @@
     "login:dg": "node login.mjs dg"
   },
   "dependencies": {
-    "playwright": "^1.48.0"
+    "playwright": "^1.61.0"
   }
 }
diff --git a/probe-dg.mjs b/probe-dg.mjs
new file mode 100644
index 0000000..0dbf36a
--- /dev/null
+++ b/probe-dg.mjs
@@ -0,0 +1,47 @@
+// probe-dg.mjs <MFR_CODE> — reuse the authed DG session (.auth/dg) to find where a
+// pattern's LOGGED-IN price renders. Read-only recon for the eur-recrawl pricing crawl.
+//   node probe-dg.mjs PDG674
+// Prints: the product URL it lands on + every price-looking string on the page, so we
+// can confirm whether a trade/net price is exposed to DW's logged-in account.
+
+import { chromium } from 'playwright';
+import path from 'node:path';
+
+const mfr = (process.argv[2] || '').trim();
+if (!mfr) { console.error('usage: node probe-dg.mjs <MFR_CODE>'); process.exit(1); }
+
+const base = mfr.split(/[\/\-]/)[0]; // PDG674/07 -> PDG674 (search on the base pattern)
+const authDir = path.join(process.cwd(), '.auth', 'dg');
+const ctx = await chromium.launchPersistentContext(authDir, { headless: false, channel: 'chrome', viewport: { width: 1440, height: 900 } });
+const page = ctx.pages()[0] || await ctx.newPage();
+
+const tries = [
+  `https://www.designersguild.com/en-us/search?q=${encodeURIComponent(base)}`,
+  `https://www.designersguild.com/en-us/search?q=${encodeURIComponent(mfr)}`,
+];
+for (const url of tries) {
+  try {
+    await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 });
+    await page.waitForTimeout(2500);
+    console.log(`\n[search] ${url}\n  title: ${(await page.title().catch(() => '')).slice(0, 80)} @ ${page.url()}`);
+    // click first product result if present
+    const link = await page.$(`a[href*="/product"], a[href*="/p/"], .product a, [class*=product] a`);
+    if (link) {
+      const href = await link.getAttribute('href').catch(() => null);
+      if (href) { await page.goto(new URL(href, page.url()).href, { waitUntil: 'domcontentloaded', timeout: 45000 }).catch(() => {}); await page.waitForTimeout(2500); }
+    }
+    const info = await page.evaluate(() => {
+      const txt = document.body ? document.body.innerText : '';
+      const prices = [...txt.matchAll(/(?:£|\$|€|USD|GBP)\s?\d[\d.,]*/g)].map((m) => m[0]).slice(0, 25);
+      const codeSeen = /PDG|P\d{3}/i.test(txt);
+      return { url: location.href, title: document.title, prices, codeSeen };
+    });
+    console.log(`  landed: ${info.title.slice(0, 80)} @ ${info.url}`);
+    console.log(`  pattern-code visible on page: ${info.codeSeen}`);
+    console.log(`  price strings: ${info.prices.length ? info.prices.join(' | ') : '(none found)'}`);
+    if (info.prices.length) break;
+  } catch (e) { console.log(`  probe error: ${e.message}`); }
+}
+await page.waitForTimeout(3000);
+await ctx.close();
+console.log('\nprobe done.');
diff --git a/probe.mjs b/probe.mjs
new file mode 100644
index 0000000..c2e38a6
--- /dev/null
+++ b/probe.mjs
@@ -0,0 +1,85 @@
+// Osborne trade-portal price probe — GATED (Steve authorized 2026-07-30).
+// Self-sources OSBORNE_USERNAME/PASSWORD from the secrets .env so credentials
+// never appear in a tool call or transcript. Logs in with a real browser,
+// looks up ONE known product (W7900-01 Lodhi), captures the logged-in price,
+// and screenshots each step. Read-only against the vendor's own account.
+import { chromium } from 'playwright';
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+
+function envVal(key) {
+  const p = path.join(os.homedir(), 'Projects/secrets-manager/.env');
+  for (const line of fs.readFileSync(p, 'utf8').split('\n')) {
+    if (line.startsWith(key + '=')) return line.slice(key.length + 1).trim().replace(/^"|"$/g, '');
+  }
+  return null;
+}
+
+const USER = envVal('OSBORNE_USERNAME');
+const PASS = envVal('OSBORNE_PASSWORD');
+const OUT = path.dirname(new URL(import.meta.url).pathname);
+const shot = (n) => path.join(OUT, `probe-${n}.png`);
+const STAGE = process.env.STAGE || 'recon';
+
+const log = (...a) => console.log(...a);
+
+const browser = await chromium.launch({ headless: true });
+const ctx = await browser.newContext({
+  userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36',
+  viewport: { width: 1440, height: 900 },
+});
+const page = await ctx.newPage();
+
+try {
+  log('creds present:', !!USER, !!PASS);
+  await page.goto('https://tradenew.osborneandlittle.com/', { waitUntil: 'networkidle', timeout: 60000 });
+  await page.waitForTimeout(2500);
+  await page.screenshot({ path: shot('01-landing'), fullPage: true });
+  log('title:', await page.title(), '| url:', page.url());
+
+  // dump interactive elements so we can see the login form shape
+  const els = await page.evaluate(() => {
+    const out = [];
+    for (const e of document.querySelectorAll('input,button,a[href],select')) {
+      out.push({
+        tag: e.tagName, type: e.type || '', name: e.name || '', id: e.id || '',
+        ph: e.placeholder || '', text: (e.innerText || e.value || '').slice(0, 40),
+      });
+    }
+    return out;
+  });
+  log('ELEMENTS:', JSON.stringify(els.slice(0, 40), null, 1));
+
+  if (STAGE === 'recon') {
+    log('recon only — stopping before login');
+  }
+
+  if (STAGE === 'login' || STAGE === 'probe') {
+    await page.fill('#username', USER);
+    await page.fill('#password', PASS);
+    await page.click('button:has-text("Login"), input[type=submit], #btnLogin');
+    await page.waitForLoadState('networkidle', { timeout: 60000 }).catch(() => {});
+    await page.waitForTimeout(3000);
+    await page.screenshot({ path: shot('02-postlogin'), fullPage: true });
+    log('post-login url:', page.url(), '| title:', await page.title());
+
+    // find a search box + dump nav so we can locate the product-lookup path
+    const nav = await page.evaluate(() => {
+      const inputs = [...document.querySelectorAll('input[type=text],input[type=search]')]
+        .map(e => ({ name: e.name, id: e.id, ph: e.placeholder }));
+      const links = [...document.querySelectorAll('a[href]')]
+        .map(a => ({ t: (a.innerText || '').trim().slice(0, 24), href: a.getAttribute('href') }))
+        .filter(l => l.t).slice(0, 30);
+      return { inputs, links, bodyText: document.body.innerText.slice(0, 300) };
+    });
+    log('SEARCH INPUTS:', JSON.stringify(nav.inputs));
+    log('NAV LINKS:', JSON.stringify(nav.links, null, 1));
+    log('BODY:', nav.bodyText);
+  }
+} catch (e) {
+  log('ERROR:', e.message);
+  try { await page.screenshot({ path: shot('error'), fullPage: true }); } catch {}
+} finally {
+  await browser.close();
+}

← 7a59e04 Cycle 4 ledger + loop HOLD-FOR-STEVE on price basis (5/5 DTD  ·  back to Eur Recrawl  ·  auto-save: 2026-07-30T18:20:08 (5 files) — login.mjs probe-d 9834b9e →