[object Object]

← back to Eur Recrawl

Cycle 4 ledger + loop HOLD-FOR-STEVE on price basis (5/5 DTD SHIP+HOLD)

7a59e04b372784828d0598b9cdf71c23ee2c8bfe · 2026-07-30 18:00:30 -0700 · Steve

Files touched

Diff

commit 7a59e04b372784828d0598b9cdf71c23ee2c8bfe
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jul 30 18:00:30 2026 -0700

    Cycle 4 ledger + loop HOLD-FOR-STEVE on price basis (5/5 DTD SHIP+HOLD)
---
 login.mjs             | 124 ++++++++++++++++++++++++++++++++++++++++++++++++++
 package.json          |  14 ++++++
 yoloforever-ledger.md |   8 ++++
 3 files changed, 146 insertions(+)

diff --git a/login.mjs b/login.mjs
new file mode 100644
index 0000000..4c8bcb4
--- /dev/null
+++ b/login.mjs
@@ -0,0 +1,124 @@
+// 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.
+//
+//   node login.mjs osborne   # covers Osborne & Little + Nina Campbell
+//   node login.mjs dg        # covers 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;
+  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/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);
+}
+
+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`);
+
+const ctx = await chromium.launchPersistentContext(authDir, {
+  headless: false,
+  channel: 'chrome',            // real Google Chrome → passes Cloudflare naturally
+  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.`);
+}
+
+// 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; }
+  }
+  // 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(() => {});
+}
+await page.waitForTimeout(2500);
+await tryFill().catch(() => {});
+
+// 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)...');
+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; }
+  await page.waitForTimeout(4000);
+}
+
+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()}`);
+} 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.`);
+}
+
+await ctx.close();
+console.log('  Browser closed.\n');
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..a464725
--- /dev/null
+++ b/package.json
@@ -0,0 +1,14 @@
+{
+  "name": "eur-recrawl",
+  "version": "0.1.0",
+  "private": true,
+  "type": "module",
+  "description": "Authenticated trade-portal recrawl to source EUR- costs, then price the line (TK-10068).",
+  "scripts": {
+    "login:osborne": "node login.mjs osborne",
+    "login:dg": "node login.mjs dg"
+  },
+  "dependencies": {
+    "playwright": "^1.48.0"
+  }
+}
diff --git a/yoloforever-ledger.md b/yoloforever-ledger.md
new file mode 100644
index 0000000..9ef52ba
--- /dev/null
+++ b/yoloforever-ledger.md
@@ -0,0 +1,8 @@
+# EUR- price-or-discontinue — yoloforever ledger
+
+## Cycle 4 — 2026-07-30
+- OBJECTIVE (new, Steve): price out or discontinue all EUR- patterns.
+- LANDED (safe/local): live-verified EUR- universe (2,099 active, sample-only $4.25, no cost); price-vs-discontinue segmentation (only ~378 priceable from stale catalogs); asked Steve -> direction = RECRAWL trade portals for logged-in price, DON'T discontinue, source cost first. Built ~/Projects/eur-recrawl scaffold: targets.csv (2,099 + mfr_code keys), PLAN.md; mapped creds (Osborne->O&L+Nina, DesignersGuild->DG+Lacroix); feasibility probe (DG=Cloudflare-walled, needs real browser).
+- CODY GATE: FIX-FIRST, all verified: (1) CRITICAL price basis unknown (catalog trade $240>retail $173 backwards); (2) 729/2099 share mfr_code (colorway ambiguity); (3) normalize recovers 0 (crawl needed); (4) CF-Playwright unproven.
+- FINAL DTD: 5/5 SHIP + HOLD-FOR-STEVE. Cost ~$0.007 panel + $0 local.
+- LOOP STATE: PAUSED / HOLD-FOR-STEVE on the price basis. Cannot build the crawler's pricing step until Steve pins what the logged-in number means + DW's charge formula. Fastest unblock = authorize ONE gated authenticated probe (pull one known Osborne product's logged-in price) to settle it empirically.

← 8563be9 Cody gate (verified): price-basis unknown (trade>retail back  ·  back to Eur Recrawl  ·  Osborne portal probe: reachable, clean login form (no CF), b 1a1804f →