← 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
M .gitignoreM login.mjsA package-lock.jsonM package.jsonA probe-dg.mjsA probe.mjs
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 →