scenri 0.9.1 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/serve.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createUpdateChecker, classify, isReleaseTriplet, findNpm, stageVersion } from './chunk-FYQ5BAFA.js';
2
2
  import { shouldAdoptRunning, anotherScenriLines, portBusyLines } from './chunk-CMLJGPYC.js';
3
- import { SchemaTooNewError, createCore, BUDGET_EXHAUSTED, SpendCapError, budgetSize, EDIT_REFERENCE_ROLE_DIRECTIVE, REFERENCE_ROLE_DIRECTIVE, ratioLabel, searchTerms, termMatches, SCHEMA_VERSION, ASPECT_TOLERANCE } from './chunk-OJAG3FRX.js';
3
+ import { SchemaTooNewError, createCore, BUDGET_EXHAUSTED, SpendCapError, budgetSize, EDIT_REFERENCE_ROLE_DIRECTIVE, REFERENCE_ROLE_DIRECTIVE, ratioLabel, searchTerms, termMatches, SCHEMA_VERSION, ASPECT_TOLERANCE } from './chunk-Y5L3KLX5.js';
4
4
  import { detectInstallKind } from './chunk-PAIAXRAC.js';
5
5
  export { detectInstallKind } from './chunk-PAIAXRAC.js';
6
6
  import { readMeta, repoSlug } from './chunk-Y3ZPBPLP.js';
@@ -12,7 +12,7 @@ import { homedir, networkInterfaces, tmpdir } from 'os';
12
12
  import { randomBytes, randomUUID, timingSafeEqual, createHash } from 'crypto';
13
13
  import { readFile, copyFile, stat, readdir, mkdtemp, rm, access, rename, unlink, writeFile } from 'fs/promises';
14
14
  import { spawn } from 'child_process';
15
- import sharp20 from 'sharp';
15
+ import sharp21 from 'sharp';
16
16
  import Fastify from 'fastify';
17
17
  import fastifyStatic from '@fastify/static';
18
18
  import fastifyMultipart from '@fastify/multipart';
@@ -20,6 +20,8 @@ import JSZip from 'jszip';
20
20
  import { Ajv2020 } from 'ajv/dist/2020.js';
21
21
  import addFormats from 'ajv-formats';
22
22
  import * as cheerio from 'cheerio';
23
+ import { lookup } from 'dns/promises';
24
+ import { isIP } from 'net';
23
25
  import { parse } from 'node-html-parser';
24
26
  import pixelmatch from 'pixelmatch';
25
27
  import { PNG } from 'pngjs';
@@ -687,6 +689,108 @@ function resolveCodex(platform, spawnImpl, timeoutMs = WHERE_TIMEOUT_MS) {
687
689
  });
688
690
  }
689
691
 
692
+ // ../engines/codex/src/childEnv.ts
693
+ function buildChildEnv(parent, drop) {
694
+ if (drop.length === 0) return { ...parent };
695
+ const unwanted = new Set(drop.map((name) => name.toUpperCase()));
696
+ const child = {};
697
+ for (const [name, value] of Object.entries(parent)) {
698
+ if (unwanted.has(name.toUpperCase())) continue;
699
+ child[name] = value;
700
+ }
701
+ return child;
702
+ }
703
+
704
+ // ../engines/codex/src/classify.ts
705
+ var CONFLICT_ENV_KEYS = ["CODEX_API_KEY", "CODEX_ACCESS_TOKEN", "OPENAI_API_KEY"];
706
+ function presentConflictKeys(env, ignored = []) {
707
+ const dropped = new Set(ignored.map((n) => n.toUpperCase()));
708
+ const present = [];
709
+ for (const name of CONFLICT_ENV_KEYS) {
710
+ if (dropped.has(name)) continue;
711
+ const value = env[name];
712
+ if (typeof value === "string" && value.trim() !== "") present.push(name);
713
+ }
714
+ return present;
715
+ }
716
+ function listKeys(keys) {
717
+ if (keys.length === 0) return "";
718
+ if (keys.length === 1) return keys[0];
719
+ return `${keys.slice(0, -1).join(", ")} and ${keys[keys.length - 1]}`;
720
+ }
721
+ function conflictReason(keys) {
722
+ return `Codex is signed in, but ${listKeys(keys)} in this computer's environment is overriding that sign-in and OpenAI rejected it.`;
723
+ }
724
+ var NOT_FOUND = /failed to spawn codex|\bENOENT\b|command not found|is not recognized as an internal/i;
725
+ var TOO_OLD = /requires a newer version of Codex|is too old/i;
726
+ var USAGE_LIMIT = /usage limit/i;
727
+ var SIGNED_OUT = /not logged in|login required|run .{0,3}codex login|no credentials found/i;
728
+ var UNAUTHORIZED = /\b401\b|unauthorized/i;
729
+ var BAD_KEY = /incorrect api key provided|invalid_api_key|invalid api key/i;
730
+ var NETWORK = /ENOTFOUND|ECONNREFUSED|ECONNRESET|EAI_AGAIN|getaddrinfo|error sending request|dns error|tls handshake/i;
731
+ var NO_OUTPUT = /produced no output for \d+s/i;
732
+ var TIMED_OUT = /timed out after/i;
733
+ var ALREADY_CONFLICT = /environment is overriding that sign-in/i;
734
+ function classifyCodexFailure(input) {
735
+ const text = input.text ?? "";
736
+ const conflictKeys = presentConflictKeys(input.env, input.ignored);
737
+ const at = (code, reason) => ({ code, conflictKeys, reason });
738
+ if (ALREADY_CONFLICT.test(text)) return at("CODEX_AUTH_CONFLICT", conflictReason(conflictKeys));
739
+ if (NOT_FOUND.test(text)) return at("CODEX_NOT_FOUND", "Codex CLI is not installed on this computer.");
740
+ if (TOO_OLD.test(text)) return at("CODEX_VERSION_TOO_OLD", "Codex CLI on this computer is too old.");
741
+ if (USAGE_LIMIT.test(text)) return at("CODEX_USAGE_LIMIT", "Your Codex plan's usage limit is used up.");
742
+ if (SIGNED_OUT.test(text)) return at("CODEX_NOT_AUTHENTICATED", "Codex CLI is signed out on this computer.");
743
+ if (UNAUTHORIZED.test(text)) {
744
+ if (BAD_KEY.test(text) && conflictKeys.length > 0) return at("CODEX_AUTH_CONFLICT", conflictReason(conflictKeys));
745
+ return at("CODEX_401", "OpenAI did not accept the Codex sign-in on this computer.");
746
+ }
747
+ if (NETWORK.test(text)) return at("NETWORK_FAILURE", "Codex could not reach OpenAI.");
748
+ if (NO_OUTPUT.test(text)) return at("CODEX_NO_OUTPUT", "Codex never started answering.");
749
+ if (TIMED_OUT.test(text)) return at("CODEX_TIMEOUT", "Codex ran out of time.");
750
+ if (text.trim() !== "") return at("CODEX_EXECUTION_FAILED", "Codex stopped with an error.");
751
+ return at("UNKNOWN", "Codex stopped for a reason it did not give.");
752
+ }
753
+
754
+ // ../engines/codex/src/connect.ts
755
+ var CONNECT_PROMPT = "Reply with the single word READY. Do not use any tools. Do not generate an image. Do not read or write any files.";
756
+ var CONNECT_TIMEOUT_MS = 45e3;
757
+ var CONNECT_TTL_MS = 10 * 6e4;
758
+ function connectFingerprint(p) {
759
+ return [p.command, p.version ?? "unknown", [...p.present].sort().join("+"), [...p.ignored].sort().join("+")].join(
760
+ "|"
761
+ );
762
+ }
763
+ function outcomeFor(code) {
764
+ switch (code) {
765
+ case "CODEX_AUTH_CONFLICT":
766
+ case "CODEX_401":
767
+ case "CODEX_NOT_AUTHENTICATED":
768
+ case "CODEX_NOT_FOUND":
769
+ case "CODEX_VERSION_TOO_OLD":
770
+ return "refused";
771
+ default:
772
+ return "unproven";
773
+ }
774
+ }
775
+ function availabilityFrom(shallow, conn) {
776
+ if (!shallow.ok) return shallow;
777
+ if (!conn || conn.outcome !== "refused" || !conn.failure) return shallow;
778
+ const { code, reason } = conn.failure;
779
+ switch (code) {
780
+ case "CODEX_AUTH_CONFLICT":
781
+ return { ok: false, reason, code: "env-conflict" };
782
+ case "CODEX_401":
783
+ case "CODEX_NOT_AUTHENTICATED":
784
+ return { ok: false, reason, code: "not-authenticated" };
785
+ case "CODEX_VERSION_TOO_OLD":
786
+ return { ok: false, reason, code: "update-needed" };
787
+ case "CODEX_NOT_FOUND":
788
+ return { ok: false, reason, code: "not-installed" };
789
+ default:
790
+ return shallow;
791
+ }
792
+ }
793
+
690
794
  // ../engines/codex/src/run.ts
691
795
  var NOT_INSTALLED_REASON = "Codex CLI is not installed on this computer";
692
796
  var NOT_AUTHENTICATED_REASON = "Codex CLI is installed but not signed in";
@@ -740,6 +844,11 @@ function codexFailureDetail(stderr, stdout) {
740
844
  const marked = body.startsWith("ERROR:") ? body : errorAt >= 0 ? body.slice(errorAt + 1) : "";
741
845
  return tailOf(marked) || tailOf(body) || tailOf(stdout.trim()) || tailOf(stderr.trim());
742
846
  }
847
+ var MODEL_NEEDS_NEWER_CODEX = /The '([^']+)' model requires a newer version of Codex/;
848
+ var BAD_KEY_401 = /incorrect api key provided|invalid_api_key|invalid api key/i;
849
+ function tooOldForModel(version, model) {
850
+ return `Codex CLI ${version ?? "on this computer"} is too old for the model it is set to, ${model}.`;
851
+ }
743
852
  function killTree(child, platform, spawnImpl) {
744
853
  if (platform === "win32" && child.pid) {
745
854
  try {
@@ -777,16 +886,23 @@ function createRunner(opts = {}) {
777
886
  const probeTtlMs = opts.probeTtlMs ?? PROBE_TTL_MS;
778
887
  const firstOutputMs = opts.firstOutputMs ?? FIRST_OUTPUT_TIMEOUT_MS;
779
888
  const platform = opts.platform ?? process.platform;
889
+ const connectTtlMs = opts.connectTtlMs ?? CONNECT_TTL_MS;
890
+ const connectTimeoutMs = opts.connectTimeoutMs ?? CONNECT_TIMEOUT_MS;
891
+ const parentEnv = opts.env ?? process.env;
892
+ const ignoreEnvKeys = opts.ignoreEnvKeys ?? (() => []);
780
893
  const killCodex = (child) => killTree(child, platform, spawnImpl);
781
894
  let resolved = null;
782
895
  async function resolution() {
783
896
  resolved ??= await resolveCodex(platform, spawnImpl);
784
897
  return resolved;
785
898
  }
899
+ let knownVersion = null;
900
+ let tooOldFor = null;
786
901
  const winArg = (a) => `"${a.replace(/[\r\n]+/g, " ").replace(/"/g, "'").replace(/%/g, " percent ")}"`;
787
902
  const spawnCodex = (exe, args, stdinOpen) => {
788
903
  const stdio = [stdinOpen ? "pipe" : "ignore", "pipe", "pipe"];
789
- return exe.direct ? spawnImpl(exe.command, args, { stdio, ...platform !== "win32" ? { detached: true } : {} }) : spawnImpl([exe.command, ...args.map(winArg)].join(" "), [], { stdio, shell: true });
904
+ const env = buildChildEnv(parentEnv, ignoreEnvKeys());
905
+ return exe.direct ? spawnImpl(exe.command, args, { stdio, env, ...platform !== "win32" ? { detached: true } : {} }) : spawnImpl([exe.command, ...args.map(winArg)].join(" "), [], { stdio, env, shell: true });
790
906
  };
791
907
  async function run2(args, signal, io) {
792
908
  const exe = await resolution();
@@ -890,6 +1006,27 @@ function createRunner(opts = {}) {
890
1006
  );
891
1007
  return;
892
1008
  }
1009
+ const newer = MODEL_NEEDS_NEWER_CODEX.exec(stderr);
1010
+ if (newer) {
1011
+ const model = newer[1];
1012
+ tooOldFor = { version: knownVersion, model };
1013
+ invalidateProbe();
1014
+ finish(
1015
+ `exit-${code ?? "unknown"}`,
1016
+ () => reject(new Error(`${tooOldForModel(knownVersion, model)} Update Codex CLI, then run this again.`))
1017
+ );
1018
+ return;
1019
+ }
1020
+ const conflicting = presentConflictKeys(parentEnv, ignoreEnvKeys());
1021
+ if (conflicting.length > 0 && /\b401\b|unauthorized/i.test(stderr) && BAD_KEY_401.test(stderr)) {
1022
+ noteConnection("refused", {
1023
+ code: "CODEX_AUTH_CONFLICT",
1024
+ conflictKeys: conflicting,
1025
+ reason: conflictReason(conflicting)
1026
+ });
1027
+ finish(`exit-${code ?? "unknown"}`, () => reject(new Error(conflictReason(conflicting))));
1028
+ return;
1029
+ }
893
1030
  const snippet2 = codexFailureDetail(stderr, stdout);
894
1031
  finish(
895
1032
  `exit-${code ?? "unknown"}`,
@@ -949,6 +1086,23 @@ function createRunner(opts = {}) {
949
1086
  return avail;
950
1087
  }
951
1088
  let cached = null;
1089
+ let connCache = null;
1090
+ let connectInFlight = null;
1091
+ function fingerprintFor(exe, version) {
1092
+ const ignored = ignoreEnvKeys();
1093
+ return connectFingerprint({
1094
+ command: exe.command,
1095
+ version,
1096
+ present: presentConflictKeys(parentEnv, ignored),
1097
+ ignored
1098
+ });
1099
+ }
1100
+ function freshConnection(exe, version) {
1101
+ if (!connCache || connectTtlMs <= 0) return null;
1102
+ if (Date.now() - connCache.at >= connectTtlMs) return null;
1103
+ if (connCache.fingerprint !== fingerprintFor(exe, version)) return null;
1104
+ return connCache;
1105
+ }
952
1106
  async function probe() {
953
1107
  if (process.env.SCENRI_NO_CODEX === "1") {
954
1108
  return { ok: false, reason: NOT_INSTALLED_REASON, code: "not-installed" };
@@ -961,41 +1115,128 @@ function createRunner(opts = {}) {
961
1115
  return value;
962
1116
  }
963
1117
  async function probeUncached() {
1118
+ const { avail, exe, version } = await probeLadder();
1119
+ return availabilityFrom(avail, freshConnection(exe, version));
1120
+ }
1121
+ async function probeLadder() {
964
1122
  resolved = await resolveCodex(platform, spawnImpl);
965
1123
  const exe = resolved;
966
1124
  const ver = await probeSpawn(exe, ["--version"]);
967
1125
  if (ver.outcome === "timeout") {
968
- return verdict({ ok: false, reason: UNVERIFIED_REASON, code: "unverified" }, exe, null);
1126
+ return {
1127
+ avail: verdict({ ok: false, reason: UNVERIFIED_REASON, code: "unverified" }, exe, null),
1128
+ exe,
1129
+ version: null
1130
+ };
969
1131
  }
970
1132
  if (ver.outcome !== "ok") {
971
- return verdict({ ok: false, reason: NOT_INSTALLED_REASON, code: "not-installed" }, exe, null);
1133
+ return {
1134
+ avail: verdict({ ok: false, reason: NOT_INSTALLED_REASON, code: "not-installed" }, exe, null),
1135
+ exe,
1136
+ version: null
1137
+ };
972
1138
  }
973
1139
  const version = parseCodexVersion(ver.stdout);
1140
+ knownVersion = version;
974
1141
  if (version && !versionAtLeast(version, MIN_CODEX_VERSION)) {
975
- return verdict(
976
- {
977
- ok: false,
978
- reason: `Codex CLI ${version} is too old. Scenri needs ${MIN_CODEX_VERSION} or newer.`,
979
- code: "update-needed"
980
- },
1142
+ return {
1143
+ avail: verdict(
1144
+ {
1145
+ ok: false,
1146
+ reason: `Codex CLI ${version} is too old. Scenri needs ${MIN_CODEX_VERSION} or newer.`,
1147
+ code: "update-needed"
1148
+ },
1149
+ exe,
1150
+ version
1151
+ ),
981
1152
  exe,
982
1153
  version
983
- );
1154
+ };
1155
+ }
1156
+ if (tooOldFor) {
1157
+ if (version === tooOldFor.version) {
1158
+ return {
1159
+ avail: verdict(
1160
+ { ok: false, reason: tooOldForModel(version, tooOldFor.model), code: "update-needed" },
1161
+ exe,
1162
+ version
1163
+ ),
1164
+ exe,
1165
+ version
1166
+ };
1167
+ }
1168
+ tooOldFor = null;
984
1169
  }
985
1170
  const login = await probeSpawn(exe, ["login", "status"]);
986
1171
  if (login.outcome === "ok") {
987
- return verdict({ ok: true }, exe, version);
1172
+ return { avail: verdict({ ok: true }, exe, version), exe, version };
988
1173
  }
989
1174
  if (login.outcome === "nonzero") {
990
- return verdict({ ok: false, reason: NOT_AUTHENTICATED_REASON, code: "not-authenticated" }, exe, version);
1175
+ return {
1176
+ avail: verdict({ ok: false, reason: NOT_AUTHENTICATED_REASON, code: "not-authenticated" }, exe, version),
1177
+ exe,
1178
+ version
1179
+ };
1180
+ }
1181
+ return { avail: verdict({ ok: false, reason: UNVERIFIED_REASON, code: "unverified" }, exe, version), exe, version };
1182
+ }
1183
+ async function connect(o = {}) {
1184
+ if (connectInFlight) return connectInFlight;
1185
+ const pending = (async () => {
1186
+ const { avail, exe, version } = await probeLadder();
1187
+ const fingerprint = fingerprintFor(exe, version);
1188
+ if (!o.force) {
1189
+ const fresh = freshConnection(exe, version);
1190
+ if (fresh) return fresh;
1191
+ }
1192
+ if (!avail.ok) {
1193
+ return { outcome: "unproven", at: Date.now(), fingerprint };
1194
+ }
1195
+ let result;
1196
+ try {
1197
+ await withWorkDir(
1198
+ (dir) => run2(execArgs(dir), void 0, {
1199
+ stdin: CONNECT_PROMPT,
1200
+ timeoutMs: connectTimeoutMs,
1201
+ label: "connect"
1202
+ })
1203
+ );
1204
+ result = { outcome: "proven", at: Date.now(), fingerprint };
1205
+ } catch (err) {
1206
+ const failure = classifyCodexFailure({
1207
+ text: err instanceof Error ? err.message : String(err),
1208
+ env: parentEnv,
1209
+ ignored: ignoreEnvKeys()
1210
+ });
1211
+ result = { outcome: outcomeFor(failure.code), failure, at: Date.now(), fingerprint };
1212
+ }
1213
+ connCache = result;
1214
+ cached = null;
1215
+ return result;
1216
+ })();
1217
+ connectInFlight = pending;
1218
+ try {
1219
+ return await pending;
1220
+ } finally {
1221
+ connectInFlight = null;
991
1222
  }
992
- return verdict({ ok: false, reason: UNVERIFIED_REASON, code: "unverified" }, exe, version);
1223
+ }
1224
+ function noteConnection(outcome, failure) {
1225
+ if (outcome === "unproven") return;
1226
+ const exe = resolved;
1227
+ if (!exe) return;
1228
+ connCache = { outcome, failure, at: Date.now(), fingerprint: fingerprintFor(exe, knownVersion) };
1229
+ cached = null;
993
1230
  }
994
1231
  function invalidateProbe() {
995
1232
  cached = null;
996
1233
  resolved = null;
997
1234
  }
998
- return { run: run2, withWorkDir, probe, invalidateProbe };
1235
+ function invalidateConnection() {
1236
+ connCache = null;
1237
+ cached = null;
1238
+ }
1239
+ return { run: run2, withWorkDir, probe, invalidateProbe, connect, invalidateConnection, noteConnection };
999
1240
  }
1000
1241
  var OUT_FILE = "analysis.json";
1001
1242
  function createCodexAnalyzer(opts = {}) {
@@ -1154,6 +1395,7 @@ function pick(v, allowed, max) {
1154
1395
  var INSTALL_COMMAND = "npm install -g @openai/codex";
1155
1396
  var INSTALL_DOCS_URL = "https://developers.openai.com/codex/cli";
1156
1397
  var INSTALL_COMMAND_SUDO = "sudo npm install -g @openai/codex";
1398
+ var INSTALL_COMMAND_WINDOWS = 'powershell -ExecutionPolicy ByPass -c "irm https://chatgpt.com/codex/install.ps1 | iex"';
1157
1399
  var DEFAULT_INSTALL_TIMEOUT_MS = 18e4;
1158
1400
  function stateFrom(avail) {
1159
1401
  if (avail.ok) return "ready";
@@ -1161,6 +1403,7 @@ function stateFrom(avail) {
1161
1403
  case "not-authenticated":
1162
1404
  case "update-needed":
1163
1405
  case "unverified":
1406
+ case "env-conflict":
1164
1407
  return avail.code;
1165
1408
  default:
1166
1409
  return "not-installed";
@@ -1171,6 +1414,7 @@ function createCodexSetup(opts = {}) {
1171
1414
  const platform = opts.platform ?? process.platform;
1172
1415
  const runner = opts.runner ?? createRunner(opts);
1173
1416
  const installTimeoutMs = opts.installTimeoutMs ?? DEFAULT_INSTALL_TIMEOUT_MS;
1417
+ const ignoreEnvKeys = opts.ignoreEnvKeys ?? (() => []);
1174
1418
  function run2(cmd, args, timeoutMs) {
1175
1419
  return new Promise((resolve) => {
1176
1420
  let settled = false;
@@ -1202,11 +1446,22 @@ function createCodexSetup(opts = {}) {
1202
1446
  });
1203
1447
  }
1204
1448
  return {
1205
- async status() {
1449
+ async status(o = {}) {
1206
1450
  runner.invalidateProbe();
1451
+ if (o.force) runner.invalidateConnection();
1452
+ const conn = await runner.connect();
1207
1453
  const avail = await runner.probe();
1454
+ const state = stateFrom(avail);
1208
1455
  const setupPlatform = platform === "win32" ? "windows" : platform === "darwin" ? "mac" : "linux";
1209
- return { state: stateFrom(avail), reason: avail.reason, platform: setupPlatform };
1456
+ return {
1457
+ state,
1458
+ reason: avail.reason,
1459
+ platform: setupPlatform,
1460
+ conflictKeys: state === "env-conflict" ? conn.failure?.conflictKeys ?? [] : [],
1461
+ ignoredKeys: [...ignoreEnvKeys()].filter(
1462
+ (k) => CONFLICT_ENV_KEYS.includes(k)
1463
+ )
1464
+ };
1210
1465
  },
1211
1466
  async install() {
1212
1467
  const res = await run2("npm", ["install", "-g", "@openai/codex"], installTimeoutMs);
@@ -1223,6 +1478,14 @@ function createCodexSetup(opts = {}) {
1223
1478
  }
1224
1479
  return { ok: true };
1225
1480
  }
1481
+ if (platform === "win32" && /EPERM|EACCES|EBUSY|permission denied|operation not permitted/i.test(res.stderr)) {
1482
+ return {
1483
+ ok: false,
1484
+ fallbackCommand: INSTALL_COMMAND_WINDOWS,
1485
+ docsUrl: INSTALL_DOCS_URL,
1486
+ detail: "npm could not write its files. This is almost never about administrator rights: close Codex and any terminal window using it, then try again. If it keeps failing, the command below is OpenAI\u2019s own installer, which does not go through npm."
1487
+ };
1488
+ }
1226
1489
  if (platform !== "win32" && /EACCES|permission denied/i.test(res.stderr)) {
1227
1490
  return {
1228
1491
  ok: false,
@@ -1424,6 +1687,7 @@ function createCodexEngine(opts) {
1424
1687
  fatal = err;
1425
1688
  inner.abort();
1426
1689
  runner.invalidateProbe();
1690
+ noteFromError(err);
1427
1691
  }
1428
1692
  }
1429
1693
  }
@@ -1486,7 +1750,10 @@ function createCodexEngine(opts) {
1486
1750
  try {
1487
1751
  await runCodex(args, signal, { stdin: promptText, label: `edit refs=${editRefs.length}` });
1488
1752
  } catch (err) {
1489
- if (isFatalSetupError(err)) runner.invalidateProbe();
1753
+ if (isFatalSetupError(err)) {
1754
+ runner.invalidateProbe();
1755
+ noteFromError(err);
1756
+ }
1490
1757
  throw err;
1491
1758
  }
1492
1759
  const images = await collectImages(dir, before);
@@ -1495,10 +1762,17 @@ function createCodexEngine(opts) {
1495
1762
  }
1496
1763
  };
1497
1764
  function isFatalSetupError(err) {
1498
- return /failed to spawn|ENOENT|not logged in|login required|401|unauthorized/i.test(
1765
+ return /failed to spawn|ENOENT|not logged in|login required|401|unauthorized|is too old|environment is overriding/i.test(
1499
1766
  String(err?.message ?? err)
1500
1767
  );
1501
1768
  }
1769
+ function noteFromError(err) {
1770
+ const failure = classifyCodexFailure({
1771
+ text: String(err?.message ?? err),
1772
+ env: process.env
1773
+ });
1774
+ runner.noteConnection(outcomeFor(failure.code), failure);
1775
+ }
1502
1776
  function buildPrompt2(req, index, roles) {
1503
1777
  const variation = req.variations?.[index] ?? "";
1504
1778
  const roleDirective = REFERENCE_ROLE_DIRECTIVE;
@@ -1538,9 +1812,15 @@ function createCodexEngine(opts) {
1538
1812
  function keyGetter(core, settingKey, envVar) {
1539
1813
  return () => core.store.getSetting(settingKey) || process.env[envVar] || null;
1540
1814
  }
1815
+ var IGNORE_ENV_KEYS_SETTING = "codex.ignore_env_keys";
1816
+ function ignoreEnvKeysGetter(core) {
1817
+ return () => (core.store.getSetting(IGNORE_ENV_KEYS_SETTING) ?? "").split(",").map((name) => name.trim().toUpperCase()).filter(
1818
+ (name) => CONFLICT_ENV_KEYS.includes(name)
1819
+ );
1820
+ }
1541
1821
  function createEngineRegistry(core, extra = []) {
1542
1822
  const saveImage = (buf) => core.images.save(buf);
1543
- const codexRunner = createRunner();
1823
+ const codexRunner = createRunner({ ignoreEnvKeys: ignoreEnvKeysGetter(core) });
1544
1824
  const adapters = [
1545
1825
  createOpenRouterEngine({ getKey: keyGetter(core, "openrouter_api_key", "OPENROUTER_API_KEY"), saveImage }),
1546
1826
  createReplicateEngine({ getKey: keyGetter(core, "replicate_api_token", "REPLICATE_API_TOKEN"), saveImage }),
@@ -1599,7 +1879,7 @@ function createDemoEngine(saveImage, opts = {}) {
1599
1879
  <text x="24" y="${h - 48}" font-family="Helvetica, Arial" font-size="${Math.max(14, Math.round(w / 42))}" fill="#ffffff" opacity="0.92">${esc(label)}</text>
1600
1880
  <text x="24" y="${h - 22}" font-family="Helvetica, Arial" font-size="12" fill="#ffffff" opacity="0.6">Scenri demo engine</text>
1601
1881
  </svg>`;
1602
- return sharp20(Buffer.from(svg)).png().toBuffer();
1882
+ return sharp21(Buffer.from(svg)).png().toBuffer();
1603
1883
  }
1604
1884
  return {
1605
1885
  capabilities() {
@@ -1790,7 +2070,7 @@ var resolvedRefs = /* @__PURE__ */ new Map();
1790
2070
  async function refHash(core, path) {
1791
2071
  const hit = resolvedRefs.get(path);
1792
2072
  if (hit && core.images.has(hit)) return hit;
1793
- const hash = core.images.save(await sharp20(readFileSync(path)).png().toBuffer());
2073
+ const hash = core.images.save(await sharp21(readFileSync(path)).png().toBuffer());
1794
2074
  resolvedRefs.set(path, hash);
1795
2075
  return hash;
1796
2076
  }
@@ -1894,7 +2174,7 @@ var resolvedRefs2 = /* @__PURE__ */ new Map();
1894
2174
  async function refHash2(core, path) {
1895
2175
  const hit = resolvedRefs2.get(path);
1896
2176
  if (hit && core.images.has(hit)) return hit;
1897
- const hash = core.images.save(await sharp20(readFileSync(path)).png().toBuffer());
2177
+ const hash = core.images.save(await sharp21(readFileSync(path)).png().toBuffer());
1898
2178
  resolvedRefs2.set(path, hash);
1899
2179
  return hash;
1900
2180
  }
@@ -2048,7 +2328,7 @@ function createThumbStore(core, opts = {}) {
2048
2328
  const pathFor = (key, w) => join(dir, `${key}-w${w}.webp`);
2049
2329
  const inflight = /* @__PURE__ */ new Map();
2050
2330
  const failed = /* @__PURE__ */ new Set();
2051
- const concurrency = Math.max(1, opts.concurrency ?? 2);
2331
+ const concurrency = Math.max(1, opts.concurrency ?? 4);
2052
2332
  let active = 0;
2053
2333
  const waiting = [];
2054
2334
  const acquire = () => new Promise((resolve) => {
@@ -2067,7 +2347,7 @@ function createThumbStore(core, opts = {}) {
2067
2347
  const tmp = `${final}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`;
2068
2348
  await acquire();
2069
2349
  try {
2070
- await sharp20(source).resize({ width: w, withoutEnlargement: true }).webp({ quality: QUALITY[w], effort: 4 }).toFile(tmp);
2350
+ await sharp21(source).resize({ width: w, withoutEnlargement: true }).webp({ quality: QUALITY[w], effort: 4 }).toFile(tmp);
2071
2351
  await rename(tmp, final);
2072
2352
  return final;
2073
2353
  } catch {
@@ -2157,7 +2437,7 @@ var assetHash = (ref) => {
2157
2437
  };
2158
2438
  var LOGO_ROLES = ["primary", "mark", "wordmark", "monochrome", "alternate"];
2159
2439
  var LOGO_BACKGROUNDS = ["light", "dark", "any"];
2160
- var toPng = (buf) => sharp20(buf).rotate().png().toBuffer();
2440
+ var toPng = (buf) => sharp21(buf).rotate().png().toBuffer();
2161
2441
  var COST_PROBE = {
2162
2442
  prompt: "",
2163
2443
  brand: { brand: {}, assetPaths: {} },
@@ -2170,11 +2450,11 @@ var MARK_MIN_EDGE = 1024;
2170
2450
  var MARK_TINY_EDGE = 256;
2171
2451
  var MARK_WARN_EDGE = 512;
2172
2452
  var toMarkPng = async (buf) => {
2173
- const out = await sharp20(buf, { density: 384 }).rotate().resize({ width: MARK_MAX_EDGE, height: MARK_MAX_EDGE, fit: "inside", withoutEnlargement: true }).png().toBuffer();
2174
- const meta = await sharp20(out).metadata();
2453
+ const out = await sharp21(buf, { density: 384 }).rotate().resize({ width: MARK_MAX_EDGE, height: MARK_MAX_EDGE, fit: "inside", withoutEnlargement: true }).png().toBuffer();
2454
+ const meta = await sharp21(out).metadata();
2175
2455
  const edge = Math.max(meta.width ?? 0, meta.height ?? 0);
2176
2456
  if (edge >= MARK_TINY_EDGE && edge < MARK_MIN_EDGE) {
2177
- return sharp20(out).resize({ width: MARK_MIN_EDGE, height: MARK_MIN_EDGE, fit: "inside", kernel: "lanczos3" }).png().toBuffer();
2457
+ return sharp21(out).resize({ width: MARK_MIN_EDGE, height: MARK_MIN_EDGE, fit: "inside", kernel: "lanczos3" }).png().toBuffer();
2178
2458
  }
2179
2459
  return out;
2180
2460
  };
@@ -2185,9 +2465,9 @@ async function capReferenceEdge(core, path, maxEdge) {
2185
2465
  if (hit) return hit;
2186
2466
  let out = path;
2187
2467
  try {
2188
- const meta = await sharp20(path).metadata();
2468
+ const meta = await sharp21(path).metadata();
2189
2469
  if ((meta.width ?? 0) > maxEdge || (meta.height ?? 0) > maxEdge) {
2190
- const buf = await sharp20(path).resize({ width: maxEdge, height: maxEdge, fit: "inside", withoutEnlargement: true }).png().toBuffer();
2470
+ const buf = await sharp21(path).resize({ width: maxEdge, height: maxEdge, fit: "inside", withoutEnlargement: true }).png().toBuffer();
2191
2471
  out = core.images.pathFor(core.images.save(buf));
2192
2472
  }
2193
2473
  } catch {
@@ -3463,13 +3743,122 @@ function validateBrand(json) {
3463
3743
  errors: valid ? [] : (compiled.errors ?? []).map((e) => `${e.instancePath || "/"} ${e.message}`)
3464
3744
  };
3465
3745
  }
3466
- var HEX_RE = /#[0-9a-fA-F]{6}\b/g;
3467
- var FETCH_TIMEOUT_MS = 15e3;
3746
+
3747
+ // ../brand-spec/src/colorParse.ts
3748
+ var CLAMP = (n) => Math.min(255, Math.max(0, Math.round(n)));
3749
+ var hex2 = (n) => CLAMP(n).toString(16).padStart(2, "0");
3750
+ var rgbHex = (r, g, b) => `#${hex2(r)}${hex2(g)}${hex2(b)}`;
3751
+ var MIN_ALPHA = 0.35;
3752
+ var NUM = String.raw`[-+]?[\d.]+%?`;
3753
+ var RGB_RE = new RegExp(String.raw`^rgba?\(\s*(${NUM})[\s,]+(${NUM})[\s,]+(${NUM})(?:[\s,/]+(${NUM}))?\s*\)$`, "i");
3754
+ var HSL_RE = new RegExp(
3755
+ String.raw`^hsla?\(\s*(${NUM}|[-+]?[\d.]+deg)[\s,]+(${NUM})[\s,]+(${NUM})(?:[\s,/]+(${NUM}))?\s*\)$`,
3756
+ "i"
3757
+ );
3758
+ var OKLCH_RE = new RegExp(
3759
+ String.raw`^oklch\(\s*(${NUM})[\s,]+(${NUM})[\s,]+([-+]?[\d.]+)(?:deg)?(?:[\s,/]+(${NUM}))?\s*\)$`,
3760
+ "i"
3761
+ );
3762
+ function scalar(token, full) {
3763
+ const t = token.trim();
3764
+ if (t.endsWith("%")) return Number.parseFloat(t) / 100 * full;
3765
+ return Number.parseFloat(t);
3766
+ }
3767
+ function alphaOk(token) {
3768
+ if (token == null) return true;
3769
+ const a = token.trim().endsWith("%") ? Number.parseFloat(token) / 100 : Number.parseFloat(token);
3770
+ return !Number.isNaN(a) && a >= MIN_ALPHA;
3771
+ }
3772
+ function toHex(value) {
3773
+ const v = value.trim().toLowerCase();
3774
+ const hex = /^#([0-9a-f]{3,8})$/.exec(v);
3775
+ if (hex) {
3776
+ const d = hex[1];
3777
+ if (d.length === 3 || d.length === 4) {
3778
+ if (d.length === 4 && !alphaOk(String(Number.parseInt(d[3] + d[3], 16) / 255))) return null;
3779
+ return `#${d[0]}${d[0]}${d[1]}${d[1]}${d[2]}${d[2]}`;
3780
+ }
3781
+ if (d.length === 6) return `#${d}`;
3782
+ if (d.length === 8) {
3783
+ if (!alphaOk(String(Number.parseInt(d.slice(6, 8), 16) / 255))) return null;
3784
+ return `#${d.slice(0, 6)}`;
3785
+ }
3786
+ return null;
3787
+ }
3788
+ const rgb = RGB_RE.exec(v);
3789
+ if (rgb) {
3790
+ if (!alphaOk(rgb[4])) return null;
3791
+ return rgbHex(scalar(rgb[1], 255), scalar(rgb[2], 255), scalar(rgb[3], 255));
3792
+ }
3793
+ const hsl = HSL_RE.exec(v);
3794
+ if (hsl) {
3795
+ if (!alphaOk(hsl[4])) return null;
3796
+ return hslHex(Number.parseFloat(hsl[1]), scalar(hsl[2], 1), scalar(hsl[3], 1));
3797
+ }
3798
+ const oklch = OKLCH_RE.exec(v);
3799
+ if (oklch) {
3800
+ if (!alphaOk(oklch[4])) return null;
3801
+ return oklchHex(scalar(oklch[1], 1), Number.parseFloat(oklch[2]), Number.parseFloat(oklch[3]));
3802
+ }
3803
+ return null;
3804
+ }
3805
+ function hslHex(h, s, l) {
3806
+ const c = (1 - Math.abs(2 * l - 1)) * s;
3807
+ const hp = (h % 360 + 360) % 360 / 60;
3808
+ const x = c * (1 - Math.abs(hp % 2 - 1));
3809
+ const [r, g, b] = hp < 1 ? [c, x, 0] : hp < 2 ? [x, c, 0] : hp < 3 ? [0, c, x] : hp < 4 ? [0, x, c] : hp < 5 ? [x, 0, c] : [c, 0, x];
3810
+ const m = l - c / 2;
3811
+ return rgbHex((r + m) * 255, (g + m) * 255, (b + m) * 255);
3812
+ }
3813
+ function oklchHex(L, C, hDeg) {
3814
+ const h = hDeg * Math.PI / 180;
3815
+ const a = C * Math.cos(h);
3816
+ const bb = C * Math.sin(h);
3817
+ const l_ = L + 0.3963377774 * a + 0.2158037573 * bb;
3818
+ const m_ = L - 0.1055613458 * a - 0.0638541728 * bb;
3819
+ const s_ = L - 0.0894841775 * a - 1.291485548 * bb;
3820
+ const l = l_ ** 3;
3821
+ const m = m_ ** 3;
3822
+ const s = s_ ** 3;
3823
+ const lr = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
3824
+ const lg = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
3825
+ const lb = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s;
3826
+ const gamma = (u) => u <= 31308e-7 ? 12.92 * u : 1.055 * Math.abs(u) ** (1 / 2.4) - 0.055;
3827
+ return rgbHex(gamma(lr) * 255, gamma(lg) * 255, gamma(lb) * 255);
3828
+ }
3829
+ var DECL_RE = /(--[\w-]+|[\w-]+)\s*:\s*([^;{}]*?(?:#[0-9a-fA-F]{3,8}|rgba?\([^)]*\)|hsla?\([^)]*\)|oklch\([^)]*\))[^;{}]*)/g;
3830
+ var TOKEN_RE = /#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\)|oklch\([^)]*\)/g;
3831
+ function extractColors(css) {
3832
+ const out = [];
3833
+ const blocks = css.split("}");
3834
+ for (const block of blocks) {
3835
+ const brace = block.lastIndexOf("{");
3836
+ const context = brace >= 0 ? block.slice(Math.max(0, brace - 200), brace) : "";
3837
+ const body = brace >= 0 ? block.slice(brace + 1) : block;
3838
+ DECL_RE.lastIndex = 0;
3839
+ let decl = DECL_RE.exec(body);
3840
+ while (decl !== null) {
3841
+ const property = decl[1];
3842
+ TOKEN_RE.lastIndex = 0;
3843
+ let token = TOKEN_RE.exec(decl[2]);
3844
+ while (token !== null) {
3845
+ const hex = toHex(token[0]);
3846
+ if (hex) out.push({ hex: hex.toLowerCase(), property, context });
3847
+ token = TOKEN_RE.exec(decl[2]);
3848
+ }
3849
+ decl = DECL_RE.exec(body);
3850
+ }
3851
+ }
3852
+ return out;
3853
+ }
3854
+
3855
+ // ../brand-spec/src/colors.ts
3468
3856
  function hexToHsl(hex) {
3469
- const r = parseInt(hex.slice(1, 3), 16) / 255;
3470
- const g = parseInt(hex.slice(3, 5), 16) / 255;
3471
- const b = parseInt(hex.slice(5, 7), 16) / 255;
3472
- const max = Math.max(r, g, b), min = Math.min(r, g, b);
3857
+ const r = Number.parseInt(hex.slice(1, 3), 16) / 255;
3858
+ const g = Number.parseInt(hex.slice(3, 5), 16) / 255;
3859
+ const b = Number.parseInt(hex.slice(5, 7), 16) / 255;
3860
+ const max = Math.max(r, g, b);
3861
+ const min = Math.min(r, g, b);
3473
3862
  const l = (max + min) / 2;
3474
3863
  if (max === min) return { h: 0, s: 0, l };
3475
3864
  const d = max - min;
@@ -3480,15 +3869,111 @@ function hexToHsl(hex) {
3480
3869
  else h = ((r - g) / d + 4) / 6;
3481
3870
  return { h: h * 360, s, l };
3482
3871
  }
3483
- function pickPalette(colors) {
3872
+ var BRANDISH_VAR = /--(?:[\w-]*)(brand|primary|accent|secondary|theme|main)/i;
3873
+ var FRAMEWORK_VAR = /^--(tw-|[\w-]*-(?:50|\d{3})$)/i;
3874
+ var UTILITY_CLASS = /\.(bg|text|border|fill|stroke|ring|from|via|to|shadow|outline|divide|accent|caret|decoration|placeholder)-[a-z]+-\d{2,3}\b/i;
3875
+ var INTERACTIVE = /(btn|button|cta|primary|brand|badge|header|nav\b|link|hero)/i;
3876
+ function weightOf(hit) {
3877
+ if (FRAMEWORK_VAR.test(hit.property) || UTILITY_CLASS.test(hit.context)) return 0;
3878
+ if (hit.property.startsWith("--")) return BRANDISH_VAR.test(hit.property) ? 6 : 2;
3879
+ if (INTERACTIVE.test(hit.context) || INTERACTIVE.test(hit.property)) return 3;
3880
+ return 1;
3881
+ }
3882
+ function liveClassTokens($) {
3883
+ const tokens = /* @__PURE__ */ new Set();
3884
+ for (const sel of ["html", "body"]) {
3885
+ for (const cls of ($(sel).first().attr("class") ?? "").split(/\s+/)) {
3886
+ if (cls) tokens.add(cls.toLowerCase());
3887
+ }
3888
+ }
3889
+ return [...tokens];
3890
+ }
3891
+ var familyOf = (cls) => cls.split("-")[0];
3892
+ function selectorIsLive(selector, live) {
3893
+ const sel = selector.toLowerCase();
3894
+ if (sel.includes(":root")) return true;
3895
+ const families = new Set(live.map(familyOf));
3896
+ return sel.split(",").some((branch) => {
3897
+ const positive = branch.replace(/:not\([^)]*\)/g, "").match(/\.[a-z0-9_-]+/g);
3898
+ if (!positive) return true;
3899
+ const leading = positive[0].slice(1);
3900
+ if (live.includes(leading)) return true;
3901
+ return !families.has(familyOf(leading));
3902
+ });
3903
+ }
3904
+ function roleOfProperty(property) {
3905
+ if (!property.startsWith("--")) return null;
3906
+ const name = property.slice(2).toLowerCase().replace(/^(brand|color|colour|theme|sc|c|ui)-/, "");
3907
+ const base = /^(primary|secondary|accent|dark|light)(?:-(\d+|dark|light|hover|active|soft|muted|contrast|foreground|fg|bg))?$/.exec(
3908
+ name
3909
+ );
3910
+ if (!base) return null;
3911
+ return { role: base[1], variant: Boolean(base[2]) };
3912
+ }
3913
+ function declaredPalette(hits, live) {
3914
+ const best = /* @__PURE__ */ new Map();
3915
+ for (const hit of hits) {
3916
+ const named = roleOfProperty(hit.property);
3917
+ if (!named || FRAMEWORK_VAR.test(hit.property) || !selectorIsLive(hit.context, live)) continue;
3918
+ const classes = hit.context.toLowerCase().match(/\.[a-z0-9_-]+/g) ?? [];
3919
+ const onLiveRoot = classes.some((c) => live.includes(c.slice(1)));
3920
+ const rank = (onLiveRoot ? 4 : classes.length > 0 ? 2 : 0) + (named.variant ? 0 : 1);
3921
+ const seen = best.get(named.role);
3922
+ if (seen && seen.rank > rank) continue;
3923
+ best.set(named.role, { hex: hit.hex, rank });
3924
+ }
3925
+ return {
3926
+ primary: best.get("primary")?.hex,
3927
+ secondary: best.get("secondary")?.hex,
3928
+ accent: best.get("accent")?.hex,
3929
+ dark: best.get("dark")?.hex,
3930
+ light: best.get("light")?.hex
3931
+ };
3932
+ }
3933
+ function collectColors(sources, live = []) {
3484
3934
  const counts = /* @__PURE__ */ new Map();
3485
- for (const c of colors) counts.set(c.toLowerCase(), (counts.get(c.toLowerCase()) ?? 0) + 1);
3486
- const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([c]) => c);
3935
+ for (const { css, weightScale = 1 } of sources) {
3936
+ for (const hit of extractColors(css)) {
3937
+ if (live.length > 0 && !selectorIsLive(hit.context, live)) continue;
3938
+ const weight = weightOf(hit) * weightScale;
3939
+ if (weight <= 0) continue;
3940
+ counts.set(hit.hex, (counts.get(hit.hex) ?? 0) + weight);
3941
+ }
3942
+ }
3943
+ return [...counts.entries()].map(([hex, weight]) => ({ hex, weight }));
3944
+ }
3945
+ function dedupeNearby(hits) {
3946
+ const buckets = /* @__PURE__ */ new Map();
3947
+ for (const hit of [...hits].sort((a, b) => b.weight - a.weight)) {
3948
+ const { h, s, l } = hexToHsl(hit.hex);
3949
+ const key = isNeutral(hit.hex) ? `grey:${Math.round(l * 8)}` : `${Math.round(h / 12)}:${Math.round(s * 8)}:${Math.round(l * 8)}`;
3950
+ const seen = buckets.get(key);
3951
+ if (seen) seen.weight += hit.weight;
3952
+ else buckets.set(key, { ...hit });
3953
+ }
3954
+ return [...buckets.values()].sort((a, b) => b.weight - a.weight);
3955
+ }
3956
+ function dropSingletons(hits) {
3957
+ if (hits.length < 12) return [...hits];
3958
+ const kept = hits.filter((h) => h.weight > 1);
3959
+ return kept.length > 0 ? kept : [...hits];
3960
+ }
3961
+ function chromaOf(hex) {
3962
+ const r = Number.parseInt(hex.slice(1, 3), 16) / 255;
3963
+ const g = Number.parseInt(hex.slice(3, 5), 16) / 255;
3964
+ const b = Number.parseInt(hex.slice(5, 7), 16) / 255;
3965
+ return Math.max(r, g, b) - Math.min(r, g, b);
3966
+ }
3967
+ function isNeutral(hex) {
3968
+ const { l } = hexToHsl(hex);
3969
+ return chromaOf(hex) < 0.1 || l < 0.06 || l > 0.96;
3970
+ }
3971
+ function pickPalette(hits) {
3972
+ const sorted = [...hits].sort((a, b) => b.weight - a.weight).map((h) => h.hex);
3487
3973
  const saturated = [];
3488
3974
  const neutrals = [];
3489
3975
  for (const c of sorted) {
3490
- const { s, l } = hexToHsl(c);
3491
- if (s < 0.12 || l < 0.06 || l > 0.96) neutrals.push(c);
3976
+ if (isNeutral(c)) neutrals.push(c);
3492
3977
  else saturated.push(c);
3493
3978
  }
3494
3979
  return {
@@ -3498,91 +3983,650 @@ function pickPalette(colors) {
3498
3983
  neutrals: neutrals.slice(0, 2)
3499
3984
  };
3500
3985
  }
3501
- async function buildFromUrl(url, opts = {}) {
3502
- const fetchImpl = opts.fetchImpl ?? fetch;
3503
- const warnings = [];
3504
- const origin = new URL(url);
3505
- const controller = new AbortController();
3506
- const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
3507
- let res;
3986
+ function paletteFrom(sources, live = []) {
3987
+ const hits = sources.flatMap((s) => extractColors(s.css));
3988
+ const declared = declaredPalette(hits, live);
3989
+ const counted = pickPalette(dropSingletons(dedupeNearby(collectColors(sources, live))));
3990
+ const taken = /* @__PURE__ */ new Set();
3991
+ const take = (hex) => {
3992
+ if (!hex || taken.has(hex)) return void 0;
3993
+ taken.add(hex);
3994
+ return hex;
3995
+ };
3996
+ const primary = take(declared.primary) ?? take(counted.primary);
3997
+ const secondary = take(declared.secondary) ?? take(counted.secondary);
3998
+ const named = Boolean(declared.primary || declared.secondary);
3999
+ const accent = (named ? [declared.accent, declared.dark, declared.light] : counted.accent).map(take).filter((h) => Boolean(h)).slice(0, 2);
4000
+ const neutrals = counted.neutrals.filter((h) => !taken.has(h)).slice(0, 2);
4001
+ return { primary, secondary, accent, neutrals };
4002
+ }
4003
+
4004
+ // ../brand-spec/src/logoCandidates.ts
4005
+ var BASE = {
4006
+ "json-ld": 170,
4007
+ manifest: 85,
4008
+ "apple-touch-icon": 80,
4009
+ "header-img": 75,
4010
+ "header-svg": 60,
4011
+ "link-icon": 45,
4012
+ "og-image": 10
4013
+ };
4014
+ var LOGOISH = /(^|[^a-z])(logo|wordmark|brandmark|lockup|masthead)([^a-z]|$)/i;
4015
+ var NOT_OURS = /(client|customer|partner|sponsor|press|award|integrat|collab|affiliat|featured-in|trusted-by|logos?-(wall|grid|cloud|strip)|testimonial|marquee|as-seen)/i;
4016
+ var SOCIAL = /(facebook|instagram|twitter|linkedin|youtube|tiktok|pinterest|threads|whatsapp|x-logo|social)/i;
4017
+ var FLAGGISH = /(\bflags?\b|locale|country|region|currency|\blang(uage)?\b)/i;
4018
+ var TRACKER = /(pixel|1x1|spacer|blank|beacon|analytics|doubleclick|facebook\.com\/tr)/i;
4019
+ var PHOTOISH = /(hero|banner|cover|photo|screenshot|slide|\bbg\b|background)/i;
4020
+ var DARKISH = /(dark|inverse|inverted|white|light-on)/i;
4021
+ var HEADER_SCOPE = 'header, nav, [role="banner"], .site-header, .navbar, .masthead, .topbar';
4022
+ var IMG_HORIZON = 40;
4023
+ function absolute(href, base) {
4024
+ if (!href) return null;
4025
+ const raw = href.trim();
4026
+ if (raw === "") return null;
4027
+ if (raw.startsWith("data:"))
4028
+ return raw.length <= 262144 && /^data:image\/(svg\+xml|png|jpeg|webp)/i.test(raw) ? raw : null;
3508
4029
  try {
3509
- res = await fetchImpl(url, {
3510
- redirect: "follow",
3511
- headers: { "user-agent": "scenri/0.1 (+https://scenri.co)" },
3512
- signal: controller.signal
4030
+ const u = new URL(raw, base);
4031
+ return u.protocol === "http:" || u.protocol === "https:" ? u.toString() : null;
4032
+ } catch {
4033
+ return null;
4034
+ }
4035
+ }
4036
+ function largestFromSrcset(srcset) {
4037
+ const entries = srcset.split(",").map((part) => part.trim()).filter(Boolean).map((part) => {
4038
+ const [url, descriptor = ""] = part.split(/\s+/);
4039
+ const w = /^(\d+)w$/.exec(descriptor);
4040
+ const x = /^([\d.]+)x$/.exec(descriptor);
4041
+ return { url, rank: w ? Number(w[1]) : x ? Number(x[1]) * 1e3 : 1 };
4042
+ });
4043
+ if (entries.length === 0) return null;
4044
+ return entries.sort((a, b) => b.rank - a.rank)[0].url;
4045
+ }
4046
+ function declaredEdgeOf(sizes, w, h) {
4047
+ const fromSizes = sizes ? Math.max(...(sizes.match(/\d+/g) ?? ["0"]).map(Number)) : 0;
4048
+ const fromAttrs = Math.max(Number(0) || 0, Number(0) || 0);
4049
+ const edge = Math.max(fromSizes, fromAttrs);
4050
+ return edge > 0 ? edge : null;
4051
+ }
4052
+ function layoutTerm(w, h, why) {
4053
+ const width = Number(w ?? 0) || 0;
4054
+ const height = Number(h ?? 0) || 0;
4055
+ if (width > 0 && height > 0 && width <= 32 && height <= 32) {
4056
+ why.push("icon-shaped");
4057
+ return -20;
4058
+ }
4059
+ return 0;
4060
+ }
4061
+ function sizeTerm(edge, why) {
4062
+ if (edge === null) return 0;
4063
+ if (edge >= 512) {
4064
+ why.push("large");
4065
+ return 15;
4066
+ }
4067
+ if (edge >= 256) {
4068
+ why.push("big enough");
4069
+ return 8;
4070
+ }
4071
+ if (edge <= 64) {
4072
+ why.push("favicon-sized");
4073
+ return -25;
4074
+ }
4075
+ return 0;
4076
+ }
4077
+ function logoCandidates($, base, manifest) {
4078
+ const out = [];
4079
+ const host = base.hostname.replace(/^www\./, "").split(".")[0];
4080
+ const push = (c) => {
4081
+ if (c.score <= 0) return;
4082
+ if (c.url && out.some((o) => o.url === c.url)) return;
4083
+ out.push(c);
4084
+ };
4085
+ for (const node of $('script[type="application/ld+json"]').toArray()) {
4086
+ for (const href of jsonLdLogos($(node).text())) {
4087
+ const url = absolute(href, base);
4088
+ if (url)
4089
+ push({
4090
+ url,
4091
+ source: "json-ld",
4092
+ score: BASE["json-ld"],
4093
+ declaredEdge: null,
4094
+ role: "primary",
4095
+ why: ["declared as the organisation logo"]
4096
+ });
4097
+ }
4098
+ }
4099
+ for (const icon of manifest?.icons ?? []) {
4100
+ const url = absolute(icon.src, base);
4101
+ const edge = declaredEdgeOf(icon.sizes);
4102
+ if (!url || (edge ?? 0) < 192) continue;
4103
+ const why = ["from the web app manifest"];
4104
+ push({
4105
+ url,
4106
+ source: "manifest",
4107
+ score: BASE.manifest + sizeTerm(edge, why),
4108
+ declaredEdge: edge,
4109
+ role: "primary",
4110
+ why
3513
4111
  });
3514
- } catch (err) {
3515
- if (err instanceof Error && err.name === "AbortError") {
3516
- throw new Error(`Timed out fetching ${url}`);
4112
+ }
4113
+ for (const el of $('link[rel="apple-touch-icon"], link[rel="apple-touch-icon-precomposed"]').toArray()) {
4114
+ const url = absolute($(el).attr("href"), base);
4115
+ const edge = declaredEdgeOf($(el).attr("sizes"));
4116
+ const why = ["a touch icon"];
4117
+ if (url)
4118
+ push({
4119
+ url,
4120
+ source: "apple-touch-icon",
4121
+ score: BASE["apple-touch-icon"] + sizeTerm(edge, why),
4122
+ declaredEdge: edge,
4123
+ role: "primary",
4124
+ why
4125
+ });
4126
+ }
4127
+ const imgs = $("img").toArray();
4128
+ imgs.forEach((el, index) => {
4129
+ const node = $(el);
4130
+ const inHeader = node.closest(HEADER_SCOPE).length > 0;
4131
+ if (!inHeader && index > IMG_HORIZON) return;
4132
+ const src = node.attr("src") ?? node.attr("data-src") ?? node.attr("data-lazy-src") ?? largestFromSrcset(node.attr("srcset") ?? node.attr("data-srcset") ?? "") ?? void 0;
4133
+ const url = absolute(src, base);
4134
+ if (!url) return;
4135
+ const text = [url, node.attr("alt") ?? "", node.attr("class") ?? "", node.attr("id") ?? ""].join(" ");
4136
+ const ancestry = node.parents().slice(0, 6).map((_i, p) => `${$(p).attr("class") ?? ""} ${$(p).attr("id") ?? ""}`).get().join(" ");
4137
+ const why = [];
4138
+ let score = inHeader ? BASE["header-img"] : BASE["header-img"] - 20;
4139
+ if (inHeader) why.push("in the header");
4140
+ if (index <= 2) {
4141
+ why.push("first image on the page");
4142
+ score += 20;
4143
+ } else if (index <= 6) {
4144
+ score += 6;
4145
+ }
4146
+ score += layoutTerm(node.attr("width"), node.attr("height"), why);
4147
+ score += semanticTerms(text, ancestry, host, why);
4148
+ if (node.parent().children("img").length >= 3) {
4149
+ why.push("one of a row of logos");
4150
+ score -= 50;
4151
+ }
4152
+ push({
4153
+ url,
4154
+ source: "header-img",
4155
+ score,
4156
+ declaredEdge: null,
4157
+ role: "primary",
4158
+ ...DARKISH.test(text) ? { background: "dark" } : {},
4159
+ why
4160
+ });
4161
+ });
4162
+ for (const el of $(`${HEADER_SCOPE}`).find("svg").toArray().slice(0, 4)) {
4163
+ const node = $(el);
4164
+ const text = [node.attr("class") ?? "", node.attr("id") ?? "", node.attr("aria-label") ?? ""].join(" ");
4165
+ const why = ["drawn in the header"];
4166
+ const score = BASE["header-svg"] + semanticTerms(text, "", host, why);
4167
+ const markup = $.html(el);
4168
+ if (markup) push({ url: null, svg: markup, source: "header-svg", score, declaredEdge: null, role: "primary", why });
4169
+ }
4170
+ for (const el of $('link[rel~="icon"]').toArray()) {
4171
+ const url = absolute($(el).attr("href"), base);
4172
+ const edge = declaredEdgeOf($(el).attr("sizes"));
4173
+ const why = ["the site icon"];
4174
+ if (url)
4175
+ push({
4176
+ url,
4177
+ source: "link-icon",
4178
+ score: BASE["link-icon"] + sizeTerm(edge, why),
4179
+ declaredEdge: edge,
4180
+ role: "primary",
4181
+ why
4182
+ });
4183
+ }
4184
+ const og = absolute($('meta[property="og:image"]').attr("content"), base);
4185
+ if (og)
4186
+ push({
4187
+ url: og,
4188
+ source: "og-image",
4189
+ score: BASE["og-image"],
4190
+ declaredEdge: null,
4191
+ role: "alternate",
4192
+ why: ["the social share image"]
4193
+ });
4194
+ return out.sort((a, b) => b.score - a.score);
4195
+ }
4196
+ function pathOf(text) {
4197
+ return text.replace(/https?:\/\/[^/\s]+/gi, "");
4198
+ }
4199
+ function semanticTerms(text, ancestry, host, why) {
4200
+ let score = 0;
4201
+ if (LOGOISH.test(text)) {
4202
+ why.push("called a logo");
4203
+ score += 40;
4204
+ }
4205
+ if (host.length >= 3 && pathOf(text).toLowerCase().includes(host.toLowerCase())) {
4206
+ why.push("named after the site");
4207
+ score += 6;
4208
+ }
4209
+ if (/\.svg(\?|$)/i.test(text)) score += 8;
4210
+ if (/\.ico(\?|$)/i.test(text)) score -= 15;
4211
+ if (/\.jpe?g(\?|$)/i.test(text)) score -= 10;
4212
+ if (NOT_OURS.test(ancestry) || NOT_OURS.test(text)) {
4213
+ why.push("inside a section of other companies");
4214
+ score -= 40;
4215
+ }
4216
+ if (SOCIAL.test(text)) {
4217
+ why.push("a social icon");
4218
+ score -= 60;
4219
+ }
4220
+ if (FLAGGISH.test(text)) {
4221
+ why.push("a flag or a region switcher");
4222
+ score -= 60;
4223
+ }
4224
+ if (TRACKER.test(text)) {
4225
+ why.push("a tracking pixel");
4226
+ score -= 80;
4227
+ }
4228
+ if (PHOTOISH.test(text)) {
4229
+ why.push("photography");
4230
+ score -= 30;
4231
+ }
4232
+ if (DARKISH.test(text)) score -= 15;
4233
+ return score;
4234
+ }
4235
+ function jsonLdLogos(text) {
4236
+ let data;
4237
+ try {
4238
+ data = JSON.parse(text);
4239
+ } catch {
4240
+ return [];
4241
+ }
4242
+ const found = [];
4243
+ const walk = (node, depth) => {
4244
+ if (depth > 6 || node === null || typeof node !== "object") return;
4245
+ if (Array.isArray(node)) {
4246
+ for (const item of node) walk(item, depth + 1);
4247
+ return;
3517
4248
  }
3518
- throw new Error(`Could not reach that site: ${url}`);
3519
- } finally {
3520
- clearTimeout(timeoutId);
4249
+ const obj = node;
4250
+ const logo = obj.logo;
4251
+ if (typeof logo === "string") found.push(logo);
4252
+ else if (logo && typeof logo === "object") {
4253
+ const url = logo.url;
4254
+ if (typeof url === "string") found.push(url);
4255
+ }
4256
+ for (const value of Object.values(obj)) walk(value, depth + 1);
4257
+ };
4258
+ walk(data, 0);
4259
+ return found;
4260
+ }
4261
+ function svgAsMark(markup, longEdge = 1024) {
4262
+ if (/<script|<foreignObject|(?:xlink:)?href\s*=\s*["']https?:/i.test(markup)) return null;
4263
+ const viewBox = /viewBox\s*=\s*["']\s*[-\d.]+[\s,]+[-\d.]+[\s,]+([\d.]+)[\s,]+([\d.]+)\s*["']/i.exec(markup);
4264
+ const width = /\bwidth\s*=\s*["']([\d.]+)/i.exec(markup);
4265
+ const height = /\bheight\s*=\s*["']([\d.]+)/i.exec(markup);
4266
+ const w = viewBox ? Number(viewBox[1]) : Number(width?.[1] ?? 0);
4267
+ const h = viewBox ? Number(viewBox[2]) : Number(height?.[1] ?? 0);
4268
+ if (!(w > 0 && h > 0)) return null;
4269
+ const scale = longEdge / Math.max(w, h);
4270
+ let out = markup.replace(/\s\bwidth\s*=\s*["'][^"']*["']/i, "").replace(/\s\bheight\s*=\s*["'][^"']*["']/i, "").replace(/<svg\b/i, `<svg width="${Math.round(w * scale)}" height="${Math.round(h * scale)}"`);
4271
+ if (!/xmlns\s*=/.test(out)) out = out.replace(/<svg\b/i, '<svg xmlns="http://www.w3.org/2000/svg"');
4272
+ return out;
4273
+ }
4274
+
4275
+ // ../brand-spec/src/scrapeError.ts
4276
+ var ScrapeError = class extends Error {
4277
+ constructor(code, message, statusCode = 502) {
4278
+ super(message);
4279
+ this.code = code;
4280
+ this.statusCode = statusCode;
4281
+ }
4282
+ code;
4283
+ statusCode;
4284
+ name = "ScrapeError";
4285
+ };
4286
+ function urlRefusal(reason, message) {
4287
+ return new ScrapeError(`url_${reason}`, message, 400);
4288
+ }
4289
+
4290
+ // ../brand-spec/src/safeFetch.ts
4291
+ var DEFAULTS = {
4292
+ budgetMs: 2e4,
4293
+ requestMs: 8e3,
4294
+ maxRedirects: 3,
4295
+ maxHtmlBytes: 3e6,
4296
+ maxCssBytes: 1e6,
4297
+ maxAssetBytes: 5e6
4298
+ };
4299
+ function isPrivateAddress(ip) {
4300
+ const kind = isIP(ip);
4301
+ if (kind === 4) return isPrivateV4(ip);
4302
+ if (kind === 6) return isPrivateV6(ip.toLowerCase());
4303
+ return true;
4304
+ }
4305
+ function isPrivateV4(ip) {
4306
+ const p = ip.split(".").map(Number);
4307
+ if (p.length !== 4 || p.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return true;
4308
+ const [a, b] = p;
4309
+ if (a === 0 || a === 10 || a === 127) return true;
4310
+ if (a === 100 && b >= 64 && b <= 127) return true;
4311
+ if (a === 169 && b === 254) return true;
4312
+ if (a === 172 && b >= 16 && b <= 31) return true;
4313
+ if (a === 192 && b === 168) return true;
4314
+ if (a === 192 && b === 0) return true;
4315
+ if (a === 192 && b === 88) return true;
4316
+ if (a === 198 && (b === 18 || b === 19 || b === 51)) return true;
4317
+ if (a === 203 && b === 0) return true;
4318
+ if (a >= 224) return true;
4319
+ return false;
4320
+ }
4321
+ function isPrivateV6(ip) {
4322
+ const bare = ip.replace(/^\[|\]$/g, "");
4323
+ if (bare === "::" || bare === "::1") return true;
4324
+ const mapped = /^(?:::ffff:|64:ff9b::)(\d{1,3}(?:\.\d{1,3}){3})$/.exec(bare);
4325
+ if (mapped) return isPrivateV4(mapped[1]);
4326
+ const head = bare.split(":")[0];
4327
+ if (/^f[cd]/.test(head)) return true;
4328
+ if (/^fe[89ab]/.test(head)) return true;
4329
+ if (/^ff/.test(head)) return true;
4330
+ if (head === "2002") return true;
4331
+ return false;
4332
+ }
4333
+ var PRIVATE_SUFFIX = /(^|\.)(local|internal|localhost|home\.arpa)$/i;
4334
+ function assertPublicHost(host, addresses, allow) {
4335
+ if (allow) return;
4336
+ const bare = host.replace(/^\[|\]$/g, "");
4337
+ if (isIP(bare)) {
4338
+ if (isPrivateAddress(bare)) throw blocked(host);
4339
+ return;
4340
+ }
4341
+ if (!bare.includes(".") || PRIVATE_SUFFIX.test(bare)) throw blocked(host);
4342
+ if (addresses.length === 0) throw new ScrapeError("unreachable", `Could not reach ${host}.`);
4343
+ if (addresses.some((a) => isPrivateAddress(a))) throw blocked(host);
4344
+ }
4345
+ var blocked = (host) => new ScrapeError("blocked_host", `${host} points at this computer or a private network, not a public website.`, 400);
4346
+ var LIMIT = {
4347
+ html: "maxHtmlBytes",
4348
+ css: "maxCssBytes",
4349
+ asset: "maxAssetBytes"
4350
+ };
4351
+ var TRUNCATES = { html: true, css: true, asset: false };
4352
+ function createGuardedFetch(opts = {}) {
4353
+ const o = { ...DEFAULTS, ...opts };
4354
+ const fetchImpl = opts.fetchImpl ?? fetch;
4355
+ const resolve = opts.lookup ?? (async (host) => {
4356
+ if (isIP(host.replace(/^\[|\]$/g, ""))) return [host.replace(/^\[|\]$/g, "")];
4357
+ const all = await lookup(host, { all: true });
4358
+ return all.map((a) => a.address);
4359
+ });
4360
+ const deadline = Date.now() + o.budgetMs;
4361
+ const remaining = () => Math.max(0, deadline - Date.now());
4362
+ async function once(url, as) {
4363
+ let current = url;
4364
+ let hops = 0;
4365
+ let attempts = 0;
4366
+ for (; ; ) {
4367
+ const u = new URL(current);
4368
+ if (u.protocol !== "http:" && u.protocol !== "https:")
4369
+ throw new ScrapeError(
4370
+ "url_scheme",
4371
+ "Scenri reads web addresses only, the ones that start with http or https.",
4372
+ 400
4373
+ );
4374
+ let addresses = [];
4375
+ if (!o.allowPrivateHosts) {
4376
+ try {
4377
+ addresses = await resolve(u.hostname);
4378
+ } catch {
4379
+ throw new ScrapeError("unreachable", `Could not reach ${u.hostname}.`);
4380
+ }
4381
+ }
4382
+ assertPublicHost(u.hostname, addresses, Boolean(o.allowPrivateHosts));
4383
+ const left = remaining();
4384
+ if (left <= 0) throw new ScrapeError("timeout", "Reading that site took too long.");
4385
+ const signal = AbortSignal.any([AbortSignal.timeout(Math.min(o.requestMs, left))]);
4386
+ let res;
4387
+ try {
4388
+ res = await fetchImpl(current, {
4389
+ redirect: "manual",
4390
+ headers: { "user-agent": USER_AGENT, accept: ACCEPT[as] },
4391
+ signal
4392
+ });
4393
+ } catch (err) {
4394
+ if (err instanceof ScrapeError) throw err;
4395
+ const aborted = err instanceof Error && (err.name === "AbortError" || err.name === "TimeoutError");
4396
+ throw aborted ? new ScrapeError("timeout", `${u.hostname} took too long to answer.`) : new ScrapeError("unreachable", `Could not reach ${u.hostname}.`);
4397
+ }
4398
+ if (res.status >= 300 && res.status < 400) {
4399
+ const location = res.headers.get("location");
4400
+ await res.body?.cancel().catch(() => {
4401
+ });
4402
+ if (!location) throw new ScrapeError("http_status", `${u.hostname} answered ${res.status} with nowhere to go.`);
4403
+ if (++hops > o.maxRedirects)
4404
+ throw new ScrapeError(
4405
+ "too_many_redirects",
4406
+ `${new URL(url).hostname} kept redirecting, so nothing was read.`
4407
+ );
4408
+ current = new URL(location, current).toString();
4409
+ continue;
4410
+ }
4411
+ if (!res.ok) {
4412
+ if (RETRYABLE.has(res.status) && attempts < 1 && remaining() > 2e3) {
4413
+ await res.body?.cancel().catch(() => {
4414
+ });
4415
+ await sleep3(700);
4416
+ attempts++;
4417
+ continue;
4418
+ }
4419
+ throw new ScrapeError("http_status", statusSentence(u.hostname, res.status));
4420
+ }
4421
+ const contentType = (res.headers.get("content-type") ?? "").toLowerCase();
4422
+ const cap2 = o[LIMIT[as]];
4423
+ const { bytes, truncated } = await readBounded(res, cap2, TRUNCATES[as]);
4424
+ assertKind(as, contentType, bytes, u.hostname);
4425
+ return {
4426
+ finalUrl: res.url || current,
4427
+ status: res.status,
4428
+ contentType,
4429
+ bytes,
4430
+ text: as === "asset" ? "" : decode(bytes, contentType),
4431
+ truncated
4432
+ };
4433
+ }
4434
+ }
4435
+ const guarded = ((url, as) => once(url, as));
4436
+ guarded.remaining = remaining;
4437
+ return guarded;
4438
+ }
4439
+ var USER_AGENT = "scenri/0.1 (+https://scenri.co)";
4440
+ var sleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
4441
+ var RETRYABLE = /* @__PURE__ */ new Set([403, 408, 425, 429, 500, 502, 503, 504]);
4442
+ function statusSentence(host, status) {
4443
+ if (status === 401 || status === 403)
4444
+ return `${host} would not let Scenri read it. Some sites block anything that is not a person in a browser; you can still add the logo and colours by hand.`;
4445
+ if (status === 404) return `There is no page at that address on ${host}.`;
4446
+ if (status === 429) return `${host} asked Scenri to slow down. Try again in a minute.`;
4447
+ if (status >= 500) return `${host} had trouble answering. Try again in a moment.`;
4448
+ return `${host} answered ${status}, so there was nothing to read.`;
4449
+ }
4450
+ var ACCEPT = {
4451
+ html: "text/html,application/xhtml+xml",
4452
+ css: "text/css,*/*;q=0.1",
4453
+ asset: "image/*"
4454
+ };
4455
+ async function readBounded(res, cap2, truncate) {
4456
+ const declared = Number(res.headers.get("content-length") ?? "0");
4457
+ if (!truncate && declared > cap2) {
4458
+ await res.body?.cancel().catch(() => {
4459
+ });
4460
+ throw new ScrapeError("too_large", "That file is too large to read.");
4461
+ }
4462
+ const reader = res.body?.getReader();
4463
+ if (!reader) return { bytes: Buffer.from(await res.arrayBuffer()), truncated: false };
4464
+ const chunks = [];
4465
+ let total = 0;
4466
+ for (; ; ) {
4467
+ const { done, value } = await reader.read();
4468
+ if (done) break;
4469
+ chunks.push(Buffer.from(value));
4470
+ total += value.byteLength;
4471
+ if (total >= cap2) {
4472
+ await reader.cancel().catch(() => {
4473
+ });
4474
+ if (!truncate) throw new ScrapeError("too_large", "That file is too large to read.");
4475
+ return { bytes: Buffer.concat(chunks).subarray(0, cap2), truncated: true };
4476
+ }
4477
+ }
4478
+ return { bytes: Buffer.concat(chunks), truncated: false };
4479
+ }
4480
+ var IMAGE_MAGIC = [
4481
+ ["png", (b) => b.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))],
4482
+ ["jpeg", (b) => b[0] === 255 && b[1] === 216],
4483
+ ["gif", (b) => b.subarray(0, 3).toString("latin1") === "GIF"],
4484
+ ["webp", (b) => b.subarray(0, 4).toString("latin1") === "RIFF" && b.subarray(8, 12).toString("latin1") === "WEBP"],
4485
+ ["ico", (b) => b[0] === 0 && b[1] === 0 && (b[2] === 1 || b[2] === 2)],
4486
+ ["svg", (b) => /<svg[\s>]/i.test(b.subarray(0, 1024).toString("utf8"))]
4487
+ ];
4488
+ function assertKind(as, contentType, bytes, host) {
4489
+ if (as === "html") {
4490
+ const htmlish = /text\/html|application\/xhtml/.test(contentType);
4491
+ const vague = contentType === "" || /^text\/plain|^application\/octet-stream/.test(contentType);
4492
+ const looksLikeMarkup = vague && bytes.subarray(0, 512).toString("utf8").trimStart().startsWith("<");
4493
+ if (!htmlish && !looksLikeMarkup)
4494
+ throw new ScrapeError("not_a_page", `${host} answered with a file, not a web page.`);
4495
+ return;
4496
+ }
4497
+ if (as === "asset") {
4498
+ if (contentType.startsWith("image/")) return;
4499
+ const vague = contentType === "" || /^application\/octet-stream/.test(contentType);
4500
+ if (vague || IMAGE_MAGIC.some(([, test]) => test(bytes))) return;
4501
+ throw new ScrapeError("not_a_page", "That logo was not an image.");
4502
+ }
4503
+ }
4504
+ function decode(bytes, contentType) {
4505
+ const declared = /charset=([\w-]+)/i.exec(contentType)?.[1];
4506
+ const sniffed = declared ?? /charset=["']?([\w-]+)/i.exec(bytes.subarray(0, 1024).toString("latin1"))?.[1];
4507
+ const label = (sniffed ?? "utf-8").toLowerCase();
4508
+ if (label === "utf-8" || label === "utf8") return bytes.toString("utf8");
4509
+ try {
4510
+ return new TextDecoder(label, { fatal: false }).decode(bytes);
4511
+ } catch {
4512
+ return bytes.toString("utf8");
4513
+ }
4514
+ }
4515
+
4516
+ // ../brand-spec/src/siteUrl.ts
4517
+ var INVISIBLE = /[​-‍⁠­᠎‎‏‪-‮⁦-⁩]/g;
4518
+ var SPACES = /[\t\n\r\f\v   -    ]/g;
4519
+ var SCHEMED = /^[a-z][a-z0-9+.-]*:\/\//i;
4520
+ var OPAQUE = /^(javascript|data|mailto|tel|sms|file|about|blob|chrome|chrome-extension|view-source):/i;
4521
+ var HTTPISH = /^https?:\/\//i;
4522
+ var IP_LITERAL = /^(\d{1,3}(\.\d{1,3}){3}|\[[0-9a-f:]+\])$/i;
4523
+ var EXAMPLE = "acme.com";
4524
+ var MESSAGES = {
4525
+ empty: `Paste a website address, like ${EXAMPLE}.`,
4526
+ scheme: `Scenri reads web addresses only, the ones that start with http or https. Try ${EXAMPLE}.`,
4527
+ space: "A web address cannot contain a space.",
4528
+ host: `That does not look like a website address. Try ${EXAMPLE}.`,
4529
+ unparseable: `That does not look like a website address. Try ${EXAMPLE}.`
4530
+ };
4531
+ var no = (reason, suggestion) => ({
4532
+ ok: false,
4533
+ reason,
4534
+ message: suggestion && reason === "space" ? `${MESSAGES.space} Did you mean ${suggestion}?` : MESSAGES[reason],
4535
+ ...suggestion ? { suggestion } : {}
4536
+ });
4537
+ function unwrap(value) {
4538
+ const pairs = [
4539
+ ["<", ">"],
4540
+ ['"', '"'],
4541
+ ["'", "'"]
4542
+ ];
4543
+ for (const [open, close] of pairs) {
4544
+ if (value.length >= 2 && value.startsWith(open) && value.endsWith(close)) return value.slice(1, -1).trim();
4545
+ }
4546
+ return value;
4547
+ }
4548
+ function normalizeSiteUrl(input) {
4549
+ const cleaned = unwrap(
4550
+ String(input ?? "").replace(INVISIBLE, "").replace(SPACES, " ").trim()
4551
+ );
4552
+ if (cleaned === "") return no("empty");
4553
+ const addedScheme = !SCHEMED.test(cleaned) && !OPAQUE.test(cleaned);
4554
+ if (!addedScheme && !HTTPISH.test(cleaned)) return no("scheme");
4555
+ const candidate = addedScheme ? `https://${cleaned}` : cleaned;
4556
+ const authority = candidate.slice(candidate.indexOf("://") + 3).split(/[/?#]/)[0];
4557
+ if (authority.includes(" ")) {
4558
+ const squeezed = normalizeSiteUrl(cleaned.replace(/ /g, ""));
4559
+ return no("space", squeezed.ok ? squeezed.url.replace(/^https?:\/\//, "").replace(/\/$/, "") : void 0);
4560
+ }
4561
+ let u;
4562
+ try {
4563
+ u = new URL(candidate);
4564
+ } catch {
4565
+ return no("unparseable");
3521
4566
  }
3522
- if (!res.ok) throw new Error(`Could not fetch ${url}: HTTP ${res.status}`);
3523
- const html = await res.text();
3524
- const $ = cheerio.load(html);
3525
- const name = $('meta[property="og:site_name"]').attr("content")?.trim() || $("title").first().text().trim().split(/\s+[|–—-]\s+/)[0] || origin.hostname.replace(/^www\./, "");
4567
+ u.username = "";
4568
+ u.password = "";
4569
+ u.hash = "";
4570
+ const host = u.hostname.replace(/\.$/, "");
4571
+ u.hostname = host;
4572
+ if (!host.includes(".") && !IP_LITERAL.test(host) && host !== "localhost") return no("host");
4573
+ return { ok: true, url: u.toString(), host, addedScheme };
4574
+ }
4575
+
4576
+ // ../brand-spec/src/buildFromUrl.ts
4577
+ var MAX_PORTRAIT_RATIO = 2.5;
4578
+ var CONFIDENT_SCORE = 80;
4579
+ var ICON_SOURCES = /* @__PURE__ */ new Set(["json-ld", "manifest", "apple-touch-icon", "link-icon"]);
4580
+ var LOGO_TRIES = 3;
4581
+ var MAX_SHEETS = 6;
4582
+ var SHEET_RANK = /(root|variables|tokens|colou?rs|palette|theme|main|app|style|tailwind|global|site|brand|base)/i;
4583
+ var SHEET_SKIP = /(font|icon|fontawesome|bootstrap-icons|swiper|slick|slider|lightbox|print)/i;
4584
+ async function buildFromUrl(url, opts = {}) {
4585
+ const warnings = [];
4586
+ const normalized = normalizeSiteUrl(url);
4587
+ if (!normalized.ok) throw urlRefusal(normalized.reason, normalized.message);
4588
+ const get = createGuardedFetch({
4589
+ ...opts.guard,
4590
+ ...opts.fetchImpl ? { fetchImpl: opts.fetchImpl, allowPrivateHosts: true } : {}
4591
+ });
4592
+ const page = await get(normalized.url, "html");
4593
+ const origin = new URL(page.finalUrl);
4594
+ const $ = cheerio.load(page.text);
4595
+ const base = baseOf($, origin);
4596
+ const named = pickName($, origin);
3526
4597
  const tagline = $('meta[name="description"]').attr("content")?.trim() || $('meta[property="og:description"]').attr("content")?.trim();
3527
- const colorSources = [];
3528
- const themeColor = $('meta[name="theme-color"]').attr("content");
3529
- if (themeColor && /^#[0-9a-fA-F]{6}$/.test(themeColor.trim())) {
3530
- for (let i = 0; i < 5; i++) colorSources.push(themeColor.trim());
3531
- }
3532
- const styleText = $("[style]").map((_, el) => $(el).attr("style")).get().join("\n") + "\n" + $("style").map((_, el) => $(el).text()).get().join("\n");
3533
- colorSources.push(...styleText.match(HEX_RE) ?? []);
3534
- const cssHref = $('link[rel="stylesheet"]').first().attr("href");
3535
- if (cssHref) {
4598
+ const sources = [];
4599
+ const themeColor = $('meta[name="theme-color"]').attr("content")?.trim();
4600
+ const tileColor = $('meta[name="msapplication-TileColor"]').attr("content")?.trim();
4601
+ for (const [value, scale] of [
4602
+ [themeColor, 2],
4603
+ [tileColor, 1]
4604
+ ]) {
4605
+ if (value) sources.push({ css: `:root{--brand-theme:${value}}`, weightScale: scale });
4606
+ }
4607
+ sources.push({ css: inlineCss($) });
4608
+ for (const href of sheetHrefs($, base)) {
4609
+ if (get.remaining() <= 0) break;
3536
4610
  try {
3537
- const cssUrl = new URL(cssHref, origin).toString();
3538
- const cssRes = await fetchImpl(cssUrl, { redirect: "follow" });
3539
- if (cssRes.ok) colorSources.push(...(await cssRes.text()).match(HEX_RE) ?? []);
4611
+ const sheet = await get(href, "css");
4612
+ sources.push({ css: sheet.text });
3540
4613
  } catch {
3541
4614
  warnings.push("Stylesheet fetch failed; palette from inline styles only.");
3542
4615
  }
3543
4616
  }
3544
- const palette = pickPalette(colorSources);
4617
+ const palette = paletteFrom(sources, liveClassTokens($));
3545
4618
  if (!palette.primary) warnings.push("No confident palette found. Set colors manually.");
3546
- let logoRef;
3547
- let logoFromOg = false;
3548
- let logoTiny = false;
3549
- let iconHref = $('link[rel="apple-touch-icon"]').attr("href") || $('link[rel~="icon"]').attr("href");
3550
- if (!iconHref) {
3551
- iconHref = $('meta[property="og:image"]').attr("content");
3552
- logoFromOg = Boolean(iconHref);
3553
- }
3554
- if (iconHref && opts.saveAsset) {
3555
- try {
3556
- const iconRes = await fetchImpl(new URL(iconHref, origin).toString(), { redirect: "follow" });
3557
- if (iconRes.ok) {
3558
- const buf = Buffer.from(await iconRes.arrayBuffer());
3559
- if (buf.length > 0) {
3560
- logoRef = await opts.saveAsset(buf, "logo");
3561
- if (!logoFromOg && opts.probeLongEdge) {
3562
- const edge = await opts.probeLongEdge(buf).catch(() => null);
3563
- if (edge !== null && edge < 256) {
3564
- logoTiny = true;
3565
- warnings.push(
3566
- `The site icon is favicon-sized (${edge}px), so it was saved as an alternate mark, not the logo. Upload your real logo in Settings.`
3567
- );
3568
- }
3569
- }
3570
- }
3571
- }
3572
- } catch {
3573
- warnings.push("Logo download failed.");
3574
- }
3575
- }
3576
- if (!logoRef) warnings.push("No logo captured. Add one manually.");
3577
- else if (logoFromOg)
3578
- warnings.push(
3579
- "No site icon was found; the social share image was saved as an alternate mark. Check it before treating it as the logo."
3580
- );
4619
+ const colorCount = [palette.primary, palette.secondary, ...palette.accent, ...palette.neutrals].filter(
4620
+ Boolean
4621
+ ).length;
4622
+ const manifest = await readManifest($, base, get);
4623
+ const candidates = logoCandidates($, base, manifest);
4624
+ const picked = await downloadMark(candidates, get, opts, warnings);
3581
4625
  const brand = {
3582
4626
  specVersion: "0.1",
3583
4627
  meta: {
3584
- name,
3585
- slug: name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || origin.hostname,
4628
+ name: named.value,
4629
+ slug: named.value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || origin.hostname,
3586
4630
  ...tagline ? { tagline } : {},
3587
4631
  website: origin.origin,
3588
4632
  createdWith: opts.createdWith ?? "scenri",
@@ -3596,20 +4640,202 @@ async function buildFromUrl(url, opts = {}) {
3596
4640
  ...palette.neutrals.length ? { neutrals: palette.neutrals.map((hex) => ({ hex })) } : {}
3597
4641
  }
3598
4642
  } : {},
3599
- ...logoRef ? { logos: [{ role: logoFromOg || logoTiny ? "alternate" : "primary", file: logoRef }] } : {}
4643
+ ...picked.ref ? {
4644
+ logos: [
4645
+ {
4646
+ role: picked.role,
4647
+ file: picked.ref,
4648
+ ...picked.background ? { background: picked.background } : {}
4649
+ }
4650
+ ]
4651
+ } : {}
4652
+ };
4653
+ return {
4654
+ brand,
4655
+ warnings,
4656
+ report: {
4657
+ url: page.finalUrl,
4658
+ host: origin.hostname,
4659
+ name: named,
4660
+ tagline: tagline ?? null,
4661
+ logo: {
4662
+ status: picked.ref ? picked.role : "none",
4663
+ source: picked.source,
4664
+ ...picked.score != null ? { score: picked.score } : {},
4665
+ ...picked.note ? { note: picked.note } : {}
4666
+ },
4667
+ colors: { count: colorCount }
4668
+ }
3600
4669
  };
3601
- return { brand, warnings };
3602
- }
3603
-
3604
- // ../brand-spec/src/mergeScrape.ts
3605
- var str2 = (v) => typeof v === "string" ? v.trim() : "";
3606
- var arr = (v) => Array.isArray(v) ? v : [];
3607
- var hashOf = (ref) => {
3608
- const s = String(ref ?? "");
3609
- return s.startsWith("asset:") ? s.slice(6) : s;
3610
- };
3611
- function scrapedHexes(palette) {
3612
- return [palette?.primary, palette?.secondary, ...arr(palette?.accent), ...arr(palette?.neutrals)].filter((c) => typeof c?.hex === "string").map((c) => ({ hex: String(c.hex) }));
4670
+ }
4671
+ function baseOf($, origin) {
4672
+ const declared = $("base[href]").attr("href");
4673
+ if (!declared) return origin;
4674
+ try {
4675
+ return new URL(declared, origin);
4676
+ } catch {
4677
+ return origin;
4678
+ }
4679
+ }
4680
+ function pickName($, origin) {
4681
+ const ld = jsonLdName($);
4682
+ if (ld) return { value: ld, source: "json-ld" };
4683
+ const og = $('meta[property="og:site_name"]').attr("content")?.trim();
4684
+ if (og) return { value: og, source: "og:site_name" };
4685
+ const title = nameFromTitle($("title").first().text());
4686
+ if (title) return { value: title, source: "title" };
4687
+ return { value: origin.hostname.replace(/^www\./, ""), source: "hostname" };
4688
+ }
4689
+ function nameFromTitle(raw) {
4690
+ const parts = raw.trim().split(/\s+[|\u00b7\u2022\u2013\u2014]\s+|\s+-\s+/).map((p) => p.trim()).filter(Boolean);
4691
+ if (parts.length < 2) return parts[0] ?? "";
4692
+ const first = parts[0];
4693
+ const last = parts[parts.length - 1];
4694
+ const words = (v) => v.split(/\s+/).length;
4695
+ if (/^(home|homepage|index|welcome|start|main)$/i.test(first)) return last;
4696
+ if (words(last) <= 4 && words(first) > words(last)) return last;
4697
+ return first;
4698
+ }
4699
+ function jsonLdName($) {
4700
+ for (const node of $('script[type="application/ld+json"]').toArray()) {
4701
+ try {
4702
+ const data = JSON.parse($(node).text());
4703
+ const found = findOrgName(data, 0);
4704
+ if (found) return found;
4705
+ } catch {
4706
+ }
4707
+ }
4708
+ return null;
4709
+ }
4710
+ function findOrgName(node, depth) {
4711
+ if (depth > 6 || node === null || typeof node !== "object") return null;
4712
+ if (Array.isArray(node)) {
4713
+ for (const item of node) {
4714
+ const found = findOrgName(item, depth + 1);
4715
+ if (found) return found;
4716
+ }
4717
+ return null;
4718
+ }
4719
+ const obj = node;
4720
+ const type = String(obj["@type"] ?? "");
4721
+ if (/Organization|Corporation|LocalBusiness/i.test(type) && typeof obj.name === "string" && obj.name.trim())
4722
+ return obj.name.trim();
4723
+ for (const value of Object.values(obj)) {
4724
+ const found = findOrgName(value, depth + 1);
4725
+ if (found) return found;
4726
+ }
4727
+ return null;
4728
+ }
4729
+ function inlineCss($) {
4730
+ const attrs = $("[style]").map((_, el) => `x{${$(el).attr("style")}}`).get().join("\n");
4731
+ const blocks = $("style").map((_, el) => $(el).text()).get().join("\n");
4732
+ return `${attrs}
4733
+ ${blocks}`;
4734
+ }
4735
+ function sheetHrefs($, base) {
4736
+ const hrefs = [];
4737
+ for (const el of $('link[rel="stylesheet"]').toArray()) {
4738
+ const href = $(el).attr("href");
4739
+ if (!href || SHEET_SKIP.test(href)) continue;
4740
+ try {
4741
+ hrefs.push(new URL(href, base).toString());
4742
+ } catch {
4743
+ }
4744
+ }
4745
+ return hrefs.sort((a, b) => Number(SHEET_RANK.test(b)) - Number(SHEET_RANK.test(a))).slice(0, MAX_SHEETS);
4746
+ }
4747
+ async function readManifest($, base, get) {
4748
+ const href = $('link[rel="manifest"]').attr("href");
4749
+ if (!href || get.remaining() <= 0) return void 0;
4750
+ try {
4751
+ const res = await get(new URL(href, base).toString(), "css");
4752
+ return JSON.parse(res.text);
4753
+ } catch {
4754
+ return void 0;
4755
+ }
4756
+ }
4757
+ async function downloadMark(candidates, get, opts, warnings) {
4758
+ if (!opts.saveAsset || candidates.length === 0) {
4759
+ if (candidates.length === 0) warnings.push("No logo captured. Add one manually.");
4760
+ return { role: "primary", source: null };
4761
+ }
4762
+ let fallback = null;
4763
+ let tried = 0;
4764
+ let failed = false;
4765
+ for (const candidate of candidates) {
4766
+ if (tried >= LOGO_TRIES || get.remaining() <= 0) break;
4767
+ let buf = null;
4768
+ if (candidate.svg) {
4769
+ const sized = svgAsMark(candidate.svg);
4770
+ if (!sized) continue;
4771
+ buf = Buffer.from(sized, "utf8");
4772
+ } else if (candidate.url?.startsWith("data:")) {
4773
+ const comma = candidate.url.indexOf(",");
4774
+ const body = candidate.url.slice(comma + 1);
4775
+ buf = candidate.url.slice(0, comma).includes(";base64") ? Buffer.from(body, "base64") : Buffer.from(decodeURIComponent(body), "utf8");
4776
+ } else if (candidate.url) {
4777
+ tried++;
4778
+ try {
4779
+ buf = (await get(candidate.url, "asset")).bytes;
4780
+ } catch {
4781
+ failed = true;
4782
+ continue;
4783
+ }
4784
+ }
4785
+ if (!buf || buf.length === 0) continue;
4786
+ let ref;
4787
+ try {
4788
+ ref = await opts.saveAsset(buf, "logo");
4789
+ } catch {
4790
+ failed = true;
4791
+ continue;
4792
+ }
4793
+ const shape = opts.inspectMark ? await opts.inspectMark(buf).catch(() => null) : null;
4794
+ if (shape?.blank) continue;
4795
+ const edge = candidate.svg ? null : shape?.longEdge ?? null;
4796
+ const tiny = edge !== null && edge < 256;
4797
+ const tall = shape?.width != null && shape?.height != null && shape.width > 0 && shape.height / shape.width > MAX_PORTRAIT_RATIO;
4798
+ const declaredIcon = ICON_SOURCES.has(candidate.source);
4799
+ const unmeasured = !candidate.svg && shape === null;
4800
+ const bigEnough = edge !== null ? declaredIcon && edge >= 512 : unmeasured;
4801
+ const unsure = candidate.score < CONFIDENT_SCORE && !bigEnough;
4802
+ const role = candidate.role === "alternate" || tiny || tall || unsure ? "alternate" : "primary";
4803
+ const picked = {
4804
+ ref,
4805
+ role,
4806
+ source: candidate.source,
4807
+ score: candidate.score,
4808
+ ...candidate.background ? { background: candidate.background } : {}
4809
+ };
4810
+ if (role === "primary") return picked;
4811
+ if (tiny)
4812
+ picked.note = `The site icon is favicon-sized (${edge}px), so it was saved as an alternate mark, not the logo. Upload your real logo in Settings.`;
4813
+ else if (tall)
4814
+ picked.note = "The only image we could find is much taller than it is wide, so it was saved as an alternate mark rather than the logo. Upload your real logo in Settings.";
4815
+ else if (unsure)
4816
+ picked.note = "We are not confident this is your logo, so it was saved as an alternate mark. Check it, or upload your real logo in Settings.";
4817
+ else if (candidate.source === "og-image")
4818
+ picked.note = "No site icon was found; the social share image was saved as an alternate mark. Check it before treating it as the logo.";
4819
+ fallback ??= picked;
4820
+ }
4821
+ if (fallback) {
4822
+ if (fallback.note) warnings.push(fallback.note);
4823
+ return fallback;
4824
+ }
4825
+ if (failed) warnings.push("Logo download failed.");
4826
+ warnings.push("No logo captured. Add one manually.");
4827
+ return { role: "primary", source: null };
4828
+ }
4829
+
4830
+ // ../brand-spec/src/mergeScrape.ts
4831
+ var str2 = (v) => typeof v === "string" ? v.trim() : "";
4832
+ var arr = (v) => Array.isArray(v) ? v : [];
4833
+ var hashOf = (ref) => {
4834
+ const s = String(ref ?? "");
4835
+ return s.startsWith("asset:") ? s.slice(6) : s;
4836
+ };
4837
+ function scrapedHexes(palette) {
4838
+ return [palette?.primary, palette?.secondary, ...arr(palette?.accent), ...arr(palette?.neutrals)].filter((c) => typeof c?.hex === "string").map((c) => ({ hex: String(c.hex) }));
3613
4839
  }
3614
4840
  var hasPalette = (palette) => scrapedHexes(palette).length > 0;
3615
4841
  function mergeScrape(existing, scraped) {
@@ -3643,6 +4869,23 @@ function mergeScrape(existing, scraped) {
3643
4869
  }
3644
4870
  return { brand, suggestions: { palette: suggestions } };
3645
4871
  }
4872
+ async function inspectMark(buf, toPng2) {
4873
+ const png = await toPng2(buf);
4874
+ const meta = await sharp21(png).metadata();
4875
+ let blank = false;
4876
+ try {
4877
+ const flat = await sharp21(png).flatten({ background: "#ffffff" }).toBuffer();
4878
+ const stats = await sharp21(flat).stats();
4879
+ blank = stats.channels.slice(0, 3).every((c) => c.min > 200);
4880
+ } catch {
4881
+ }
4882
+ return {
4883
+ longEdge: Math.max(meta.width ?? 0, meta.height ?? 0) || null,
4884
+ width: meta.width ?? null,
4885
+ height: meta.height ?? null,
4886
+ blank
4887
+ };
4888
+ }
3646
4889
 
3647
4890
  // ../catalog/src/url.ts
3648
4891
  function normalizeStoreUrl(input) {
@@ -3689,6 +4932,28 @@ function upgradeImageUrl(url) {
3689
4932
  return url;
3690
4933
  }
3691
4934
  }
4935
+ var LOCALE_SEGMENT = /^\/[a-z]{2}(?:-[A-Za-z]{2})?\//;
4936
+ function preferCanonicalLocale(urls) {
4937
+ const canonical = /* @__PURE__ */ new Set();
4938
+ for (const u of urls) {
4939
+ try {
4940
+ const parsed = new URL(u);
4941
+ if (!LOCALE_SEGMENT.test(parsed.pathname)) canonical.add(`${parsed.origin}${parsed.pathname}`);
4942
+ } catch {
4943
+ }
4944
+ }
4945
+ if (!canonical.size) return urls;
4946
+ return urls.filter((u) => {
4947
+ try {
4948
+ const parsed = new URL(u);
4949
+ if (!LOCALE_SEGMENT.test(parsed.pathname)) return true;
4950
+ const stripped = parsed.pathname.replace(LOCALE_SEGMENT, "/");
4951
+ return !canonical.has(`${parsed.origin}${stripped}`);
4952
+ } catch {
4953
+ return true;
4954
+ }
4955
+ });
4956
+ }
3692
4957
 
3693
4958
  // ../catalog/src/normalize.ts
3694
4959
  function cleanText(s) {
@@ -3796,10 +5061,48 @@ function dedupeProducts(products) {
3796
5061
  }
3797
5062
  return [...byKey.values()];
3798
5063
  }
3799
-
3800
- // ../catalog/src/http/fetch.ts
3801
- var USER_AGENT = "scenri-catalog/0.1 (+https://scenri.co)";
3802
- function sleep3(ms) {
5064
+ var ALLOW_PRIVATE = process.env.SCENRI_SCRAPE_ALLOW_PRIVATE === "1";
5065
+ async function assertReachable(url, real) {
5066
+ const u = new URL(url);
5067
+ if (u.protocol !== "http:" && u.protocol !== "https:") {
5068
+ throw new Error(`Scenri reads http and https addresses only, not ${u.protocol.replace(":", "")}`);
5069
+ }
5070
+ if (ALLOW_PRIVATE) return;
5071
+ const host = u.hostname.replace(/^\[|\]$/g, "");
5072
+ if (isIP(host)) {
5073
+ assertPublicHost(u.hostname, [host], false);
5074
+ return;
5075
+ }
5076
+ if (!real) return;
5077
+ let addresses;
5078
+ try {
5079
+ addresses = (await lookup(host, { all: true })).map((a) => a.address);
5080
+ } catch {
5081
+ return;
5082
+ }
5083
+ assertPublicHost(u.hostname, addresses, false);
5084
+ }
5085
+ var USER_AGENT2 = "scenri-catalog/0.1 (+https://scenri.co)";
5086
+ async function readBounded2(res, maxBytes) {
5087
+ if (!maxBytes || !res.body) return res.text();
5088
+ const reader = res.body.getReader();
5089
+ const decoder = new TextDecoder();
5090
+ let out = "";
5091
+ let seen = 0;
5092
+ try {
5093
+ while (seen < maxBytes) {
5094
+ const { done, value } = await reader.read();
5095
+ if (done) break;
5096
+ seen += value.byteLength;
5097
+ out += decoder.decode(value, { stream: true });
5098
+ }
5099
+ } finally {
5100
+ await reader.cancel().catch(() => {
5101
+ });
5102
+ }
5103
+ return out + decoder.decode();
5104
+ }
5105
+ function sleep4(ms) {
3803
5106
  return new Promise((r) => setTimeout(r, ms));
3804
5107
  }
3805
5108
  async function httpGet(url, opts = {}) {
@@ -3813,17 +5116,34 @@ async function httpGet(url, opts = {}) {
3813
5116
  opts.signal?.addEventListener("abort", onAbort, { once: true });
3814
5117
  const timer = setTimeout(() => ctrl.abort(), timeoutMs);
3815
5118
  try {
3816
- const res = await fetchImpl(url, {
3817
- redirect: "follow",
3818
- signal: ctrl.signal,
3819
- headers: {
3820
- "user-agent": USER_AGENT,
3821
- accept: opts.accept ?? "application/json, text/html, application/xml, text/xml, */*;q=0.8",
3822
- ...opts.headers ?? {}
5119
+ let current = url;
5120
+ let res;
5121
+ for (let hop = 0; ; hop++) {
5122
+ await assertReachable(current, !opts.fetchImpl);
5123
+ res = await fetchImpl(current, {
5124
+ redirect: "manual",
5125
+ signal: ctrl.signal,
5126
+ headers: {
5127
+ "user-agent": USER_AGENT2,
5128
+ accept: opts.accept ?? "application/json, text/html, application/xml, text/xml, */*;q=0.8",
5129
+ ...opts.headers ?? {}
5130
+ }
5131
+ });
5132
+ if (res.status < 300 || res.status >= 400) break;
5133
+ const location = res.headers.get("location");
5134
+ await res.body?.cancel().catch(() => {
5135
+ });
5136
+ if (!location || hop >= 5) break;
5137
+ current = new URL(location, current).toString();
5138
+ }
5139
+ if (res.url !== current) {
5140
+ try {
5141
+ Object.defineProperty(res, "url", { value: current, configurable: true });
5142
+ } catch {
3823
5143
  }
3824
- });
5144
+ }
3825
5145
  if ((res.status === 429 || res.status >= 500) && attempt < retries) {
3826
- await sleep3(400 * 2 ** attempt);
5146
+ await sleep4(400 * 2 ** attempt);
3827
5147
  continue;
3828
5148
  }
3829
5149
  return res;
@@ -3831,7 +5151,7 @@ async function httpGet(url, opts = {}) {
3831
5151
  lastErr = err;
3832
5152
  if (opts.signal?.aborted) throw err;
3833
5153
  if (attempt < retries) {
3834
- await sleep3(400 * 2 ** attempt);
5154
+ await sleep4(400 * 2 ** attempt);
3835
5155
  continue;
3836
5156
  }
3837
5157
  throw err;
@@ -3844,7 +5164,7 @@ async function httpGet(url, opts = {}) {
3844
5164
  }
3845
5165
  async function httpText(url, opts = {}) {
3846
5166
  const res = await httpGet(url, opts);
3847
- const text = await res.text();
5167
+ const text = await readBounded2(res, opts.maxBytes);
3848
5168
  return { ok: res.ok, status: res.status, text, url: res.url || url };
3849
5169
  }
3850
5170
  async function httpJson(url, opts = {}) {
@@ -3874,6 +5194,273 @@ async function mapPool(items, concurrency, fn, signal) {
3874
5194
  await Promise.all(workers);
3875
5195
  return results;
3876
5196
  }
5197
+ function loadHtml(html) {
5198
+ return parse(html);
5199
+ }
5200
+ function attr(el, name) {
5201
+ return el?.getAttribute(name) ?? void 0;
5202
+ }
5203
+ function textOf(el) {
5204
+ return (el?.text ?? "").replace(/\s+/g, " ").trim();
5205
+ }
5206
+
5207
+ // ../catalog/src/adapters/productPage.ts
5208
+ function stableKey(url) {
5209
+ return createHash("sha256").update(url).digest("hex").slice(0, 16);
5210
+ }
5211
+ var typesOf = (obj) => {
5212
+ const t = obj["@type"];
5213
+ const list2 = Array.isArray(t) ? t : t ? [t] : [];
5214
+ return list2.map((x) => String(x).toLowerCase());
5215
+ };
5216
+ function walkJsonLd(node, groups, singles) {
5217
+ if (!node) return;
5218
+ if (Array.isArray(node)) {
5219
+ for (const n of node) walkJsonLd(n, groups, singles);
5220
+ return;
5221
+ }
5222
+ if (typeof node !== "object") return;
5223
+ const obj = node;
5224
+ const types = typesOf(obj);
5225
+ if (types.includes("productgroup")) groups.push(obj);
5226
+ else if (types.includes("product")) singles.push(obj);
5227
+ if (obj["@graph"]) walkJsonLd(obj["@graph"], groups, singles);
5228
+ for (const v of Object.values(obj)) {
5229
+ if (v && typeof v === "object") walkJsonLd(v, groups, singles);
5230
+ }
5231
+ }
5232
+ var asArray = (v) => Array.isArray(v) ? v : v == null ? [] : [v];
5233
+ function imagesOf(n, pageUrl) {
5234
+ return [].concat(n.image ?? []).flat().map((img) => typeof img === "string" ? img : img?.url ?? img?.contentUrl).filter(Boolean).map((u, i) => ({ url: absolutize(pageUrl, String(u)), position: i, width: null, height: null, alt: null })).filter((img) => img.url);
5235
+ }
5236
+ var offerOf = (n) => Array.isArray(n?.offers) ? n.offers[0] : n?.offers;
5237
+ function variesBy(group) {
5238
+ const named = asArray(group.variesBy).map((v) => String(v).split("/").pop() ?? "").filter(Boolean);
5239
+ return named.length ? named : ["size", "color"];
5240
+ }
5241
+ function variantsOf(group, keys) {
5242
+ return asArray(group.hasVariant).filter((v) => v && typeof v === "object").map((v, i) => {
5243
+ const offer = offerOf(v);
5244
+ const options = Object.fromEntries(
5245
+ keys.map((k) => [k, v[k]]).filter(([, val]) => val != null && val !== "")
5246
+ );
5247
+ const base = String(v.sku || v.mpn || v.gtin || `${group.productGroupID ?? group.name ?? "group"}:${i}`);
5248
+ const suffix = Object.values(options).join("/");
5249
+ return {
5250
+ externalKey: suffix ? `${base}:${suffix}` : base,
5251
+ title: [v.name, ...Object.values(options)].filter(Boolean).join(" "),
5252
+ sku: v.sku ? String(v.sku) : null,
5253
+ price: offer?.price != null ? Number(offer.price) : null,
5254
+ compareAtPrice: null,
5255
+ currency: offer?.priceCurrency ?? null,
5256
+ available: offer?.availability ? /instock/i.test(String(offer.availability)) : null,
5257
+ options
5258
+ };
5259
+ });
5260
+ }
5261
+ function fromGroup(group, pageUrl) {
5262
+ const keys = variesBy(group);
5263
+ const variants = variantsOf(group, keys);
5264
+ const url = absolutize(pageUrl, String(group.url ?? group["@id"] ?? pageUrl)) ?? pageUrl;
5265
+ const images = imagesOf(group, pageUrl);
5266
+ const fallback = images.length ? images : imagesOf(asArray(group.hasVariant)[0] ?? {}, pageUrl);
5267
+ return normalizeProduct({
5268
+ externalKey: String(group.productGroupID || group.sku || stableKey(url)),
5269
+ title: String(group.name ?? "Product"),
5270
+ descriptionHtml: group.description ? String(group.description) : null,
5271
+ url,
5272
+ vendor: group.brand?.name ?? (typeof group.brand === "string" ? group.brand : null),
5273
+ productType: group.category ? String(group.category) : null,
5274
+ category: group.category ? String(group.category) : null,
5275
+ price: variants.find((v) => v.price != null)?.price ?? null,
5276
+ compareAtPrice: null,
5277
+ currency: variants.find((v) => v.currency)?.currency ?? null,
5278
+ available: variants.some((v) => v.available) || null,
5279
+ tags: [],
5280
+ variants,
5281
+ images: fallback,
5282
+ raw: group
5283
+ });
5284
+ }
5285
+ function fromSingle(n, pageUrl) {
5286
+ const offers = offerOf(n);
5287
+ const url = String(n.url ?? n["@id"] ?? pageUrl);
5288
+ return normalizeProduct({
5289
+ externalKey: String(n.sku || n.productID || n.mpn || stableKey(url)),
5290
+ title: String(n.name ?? "Product"),
5291
+ descriptionHtml: n.description ? String(n.description) : null,
5292
+ url: absolutize(pageUrl, url) ?? pageUrl,
5293
+ vendor: n.brand?.name ?? (typeof n.brand === "string" ? n.brand : null),
5294
+ productType: n.category ? String(n.category) : null,
5295
+ category: n.category ? String(n.category) : null,
5296
+ price: offers?.price != null ? Number(offers.price) : null,
5297
+ compareAtPrice: null,
5298
+ currency: offers?.priceCurrency ?? null,
5299
+ available: offers?.availability ? /instock/i.test(String(offers.availability)) : null,
5300
+ tags: [],
5301
+ variants: [],
5302
+ images: imagesOf(n, pageUrl),
5303
+ raw: n
5304
+ });
5305
+ }
5306
+ function extractJsonLdProducts(html, pageUrl, doc) {
5307
+ const root = doc ?? loadHtml(html);
5308
+ const groups = [];
5309
+ const singles = [];
5310
+ for (const el of root.querySelectorAll('script[type="application/ld+json"]')) {
5311
+ const raw = el.innerHTML;
5312
+ if (!raw) continue;
5313
+ try {
5314
+ walkJsonLd(JSON.parse(raw), groups, singles);
5315
+ } catch {
5316
+ }
5317
+ }
5318
+ const claimed = /* @__PURE__ */ new Set();
5319
+ for (const g of groups) for (const v of asArray(g.hasVariant)) if (v && typeof v === "object") claimed.add(v);
5320
+ return [
5321
+ ...groups.map((g) => fromGroup(g, pageUrl)),
5322
+ ...singles.filter((n) => !claimed.has(n)).map((n) => fromSingle(n, pageUrl))
5323
+ ];
5324
+ }
5325
+ var PRODUCT_MARKERS = [
5326
+ 'meta[property="og:type"][content="product"]',
5327
+ 'meta[property="product:price:amount"]',
5328
+ 'meta[property="og:price:amount"]',
5329
+ '[itemtype*="schema.org/Product"]',
5330
+ '[itemprop="price"]',
5331
+ '[itemprop="offers"]',
5332
+ 'form[action*="/cart/add"]',
5333
+ '[name="add"]'
5334
+ ];
5335
+ var BUY_WORDS = /add to (cart|bag|basket)|buy now|add to my bag/i;
5336
+ var BUILD_ASSET = /\/_next\/static\/|\/static\/media\/|\/assets\/(icons|flags|ui)\//i;
5337
+ var NOT_A_PACKSHOT = /logo|icon|sprite|pixel|avatar|\bflags?\b|\/flags?\/|locale|country|currency|badge|payment|social/i;
5338
+ function looksLikeProduct(html, doc) {
5339
+ const $ = doc ?? loadHtml(html);
5340
+ for (const sel of PRODUCT_MARKERS) {
5341
+ try {
5342
+ if ($.querySelector(sel)) return true;
5343
+ } catch {
5344
+ }
5345
+ }
5346
+ for (const el of $.querySelectorAll('button, input[type="submit"], a')) {
5347
+ const words = `${textOf(el)} ${attr(el, "value") ?? ""} ${attr(el, "aria-label") ?? ""}`;
5348
+ if (BUY_WORDS.test(words)) return true;
5349
+ }
5350
+ return false;
5351
+ }
5352
+ function galleryImages($, pageUrl, cap2 = 12) {
5353
+ const images = [];
5354
+ const og = attr($.querySelector('meta[property="og:image"]'), "content");
5355
+ if (og) {
5356
+ const abs = absolutize(pageUrl, og);
5357
+ if (abs) images.push({ url: abs, position: 0, width: null, height: null, alt: null });
5358
+ }
5359
+ for (const el of $.querySelectorAll("img[src]")) {
5360
+ if (images.length >= cap2) break;
5361
+ const src = attr(el, "src") || attr(el, "data-src");
5362
+ const abs = src ? absolutize(pageUrl, src) : null;
5363
+ if (!abs || NOT_A_PACKSHOT.test(abs) || BUILD_ASSET.test(abs)) continue;
5364
+ if (images.some((x) => x.url === abs)) continue;
5365
+ images.push({ url: abs, position: images.length, width: null, height: null, alt: attr(el, "alt") ?? null });
5366
+ }
5367
+ return images;
5368
+ }
5369
+ function parseProductHtml(html, pageUrl, doc) {
5370
+ const $ = doc ?? loadHtml(html);
5371
+ if (!looksLikeProduct(html, $)) return null;
5372
+ const title = attr($.querySelector('meta[property="og:title"]'), "content") || textOf($.querySelector("h1")) || textOf($.querySelector("title"));
5373
+ if (!title) return null;
5374
+ const desc = attr($.querySelector('meta[property="og:description"]'), "content") || attr($.querySelector('meta[name="description"]'), "content") || null;
5375
+ const images = galleryImages($, pageUrl);
5376
+ const canonical = attr($.querySelector('link[rel="canonical"]'), "href");
5377
+ const url = canonical ? absolutize(pageUrl, canonical) ?? pageUrl : pageUrl;
5378
+ return normalizeProduct({
5379
+ externalKey: stableKey(url),
5380
+ title,
5381
+ descriptionHtml: desc,
5382
+ url,
5383
+ images,
5384
+ variants: [],
5385
+ tags: [],
5386
+ raw: { source: "html" }
5387
+ });
5388
+ }
5389
+ function sellsSomething(p) {
5390
+ const n = p.raw ?? {};
5391
+ return Boolean(n.offers || n.sku || n.gtin || n.mpn || n.hasVariant || p.price != null);
5392
+ }
5393
+ function withGallery(product, doc, pageUrl) {
5394
+ const have = new Set((product.images ?? []).map((i) => i.url));
5395
+ const extra = galleryImages(doc, pageUrl).filter((i) => !have.has(i.url));
5396
+ if (!extra.length) return product;
5397
+ const images = [...product.images ?? []];
5398
+ for (const img of extra) {
5399
+ if (images.length >= 12) break;
5400
+ images.push({ ...img, position: images.length });
5401
+ }
5402
+ return { ...product, images };
5403
+ }
5404
+ function productsFromPage(html, url) {
5405
+ const doc = loadHtml(html);
5406
+ const declared = looksLikeProduct(html, doc);
5407
+ const fromLd = extractJsonLdProducts(html, url, doc);
5408
+ const selling = declared ? fromLd : fromLd.filter(sellsSomething);
5409
+ if (selling.length === 1) return [withGallery(selling[0], doc, url)];
5410
+ if (selling.length) return selling;
5411
+ if (!declared) return [];
5412
+ const one = parseProductHtml(html, url, doc);
5413
+ return one ? [one] : [];
5414
+ }
5415
+ var sleep5 = (ms) => new Promise((r) => setTimeout(r, ms));
5416
+ async function fetchProductPages(ctx, urls, opts = {}) {
5417
+ const out = [];
5418
+ const seen = /* @__PURE__ */ new Set();
5419
+ let kept = 0;
5420
+ const want = opts.want ?? Number.POSITIVE_INFINITY;
5421
+ const ceiling = opts.maxPages ?? (Number.isFinite(want) ? want * 3 : urls.length);
5422
+ const take = urls.slice(0, ceiling);
5423
+ let bytes = 0;
5424
+ await mapPool(
5425
+ take,
5426
+ opts.delayMs ? 1 : opts.concurrency ?? 5,
5427
+ async (u) => {
5428
+ if (kept >= want) return;
5429
+ if (opts.deadline != null && Date.now() > opts.deadline) return;
5430
+ if (opts.maxTotalBytes != null && bytes >= opts.maxTotalBytes) return;
5431
+ try {
5432
+ if (opts.delayMs) await sleep5(opts.delayMs);
5433
+ const { ok, text, url } = await httpText(u, {
5434
+ fetchImpl: ctx.fetchImpl,
5435
+ signal: ctx.signal,
5436
+ accept: "text/html",
5437
+ maxBytes: opts.maxBytes
5438
+ });
5439
+ bytes += text.length;
5440
+ if (opts.stats) {
5441
+ opts.stats.pages += 1;
5442
+ opts.stats.bytes = bytes;
5443
+ }
5444
+ if (!ok) {
5445
+ if (opts.stats) opts.stats.refused = (opts.stats.refused ?? 0) + 1;
5446
+ return;
5447
+ }
5448
+ for (const p of productsFromPage(text, url)) {
5449
+ if (seen.has(p.externalKey)) continue;
5450
+ seen.add(p.externalKey);
5451
+ kept++;
5452
+ if (opts.onEach) opts.onEach(p);
5453
+ else out.push(p);
5454
+ }
5455
+ opts.onProduct?.(kept);
5456
+ } catch {
5457
+ if (opts.stats) opts.stats.refused = (opts.stats.refused ?? 0) + 1;
5458
+ }
5459
+ },
5460
+ ctx.signal
5461
+ );
5462
+ return out;
5463
+ }
3877
5464
 
3878
5465
  // ../catalog/src/adapters/shopify.ts
3879
5466
  function mapShopifyProduct(base, p) {
@@ -3927,10 +5514,14 @@ function mapShopifyProduct(base, p) {
3927
5514
  async function fetchProductsJsonPage(ctx, page, limit = 250) {
3928
5515
  const origin = originOf(ctx.baseUrl);
3929
5516
  const url = `${origin}/products.json?limit=${limit}&page=${page}`;
3930
- const { ok, json } = await httpJson(url, { fetchImpl: ctx.fetchImpl, signal: ctx.signal });
3931
- if (!ok || !json?.products) return [];
3932
- return json.products;
5517
+ const { ok, status, json } = await httpJson(url, {
5518
+ fetchImpl: ctx.fetchImpl,
5519
+ signal: ctx.signal
5520
+ });
5521
+ if (ok && json?.products) return { products: json.products, blocked: false };
5522
+ return { products: [], blocked: status === 401 || status === 403 || status === 404 || status >= 500 };
3933
5523
  }
5524
+ var JSON_BLOCKED = "json-blocked";
3934
5525
  async function collectSitemapProductUrls(ctx) {
3935
5526
  const origin = originOf(ctx.baseUrl);
3936
5527
  const urls = /* @__PURE__ */ new Set();
@@ -3956,7 +5547,7 @@ async function collectSitemapProductUrls(ctx) {
3956
5547
  }
3957
5548
  }
3958
5549
  }
3959
- return [...urls];
5550
+ return preferCanonicalLocale([...urls]);
3960
5551
  }
3961
5552
  var shopifyAdapter = {
3962
5553
  platform: "shopify",
@@ -3988,6 +5579,7 @@ var shopifyAdapter = {
3988
5579
  },
3989
5580
  async discover(ctx) {
3990
5581
  const warnings = [];
5582
+ const hints = [];
3991
5583
  const keys = /* @__PURE__ */ new Set();
3992
5584
  const productUrls = /* @__PURE__ */ new Set();
3993
5585
  const origin = originOf(ctx.baseUrl);
@@ -3995,8 +5587,12 @@ var shopifyAdapter = {
3995
5587
  let emptyStreak = 0;
3996
5588
  while (emptyStreak < 1) {
3997
5589
  if (ctx.signal?.aborted) throw new Error("aborted");
3998
- const products = await fetchProductsJsonPage(ctx, page);
5590
+ const { products, blocked: blocked2 } = await fetchProductsJsonPage(ctx, page);
3999
5591
  ctx.onProgress?.({ stage: "discovering", discovered: keys.size, message: `Shopify page ${page}` });
5592
+ if (blocked2 && page === 1) {
5593
+ hints.push(JSON_BLOCKED);
5594
+ warnings.push("This store does not serve its product API, so the product pages were read instead");
5595
+ }
4000
5596
  if (!products.length) {
4001
5597
  emptyStreak++;
4002
5598
  break;
@@ -4025,17 +5621,23 @@ var shopifyAdapter = {
4025
5621
  productKeys: [...keys],
4026
5622
  productUrls: [...productUrls],
4027
5623
  estimatedTotal: keys.size || productUrls.size || null,
4028
- warnings
5624
+ // A store whose own product API answered but refused us leaves nothing
5625
+ // to read but the pages themselves, one request each. That is what
5626
+ // gymshark.com does, and it is the run worth batching.
5627
+ byPage: hints.includes(JSON_BLOCKED),
5628
+ warnings,
5629
+ hints
4029
5630
  };
4030
5631
  },
4031
5632
  async fetchAll(ctx, discovered) {
4032
5633
  const origin = originOf(ctx.baseUrl);
4033
5634
  const out = [];
4034
5635
  const seen = /* @__PURE__ */ new Set();
5636
+ const jsonBlocked = discovered.hints?.includes(JSON_BLOCKED) ?? false;
4035
5637
  let page = 1;
4036
- while (true) {
5638
+ while (!jsonBlocked) {
4037
5639
  if (ctx.signal?.aborted) throw new Error("aborted");
4038
- const products = await fetchProductsJsonPage(ctx, page);
5640
+ const { products } = await fetchProductsJsonPage(ctx, page);
4039
5641
  if (!products.length) break;
4040
5642
  for (const p of products) {
4041
5643
  const mapped = mapShopifyProduct(origin, p);
@@ -4052,48 +5654,56 @@ var shopifyAdapter = {
4052
5654
  page++;
4053
5655
  if (page > 1e4) break;
4054
5656
  }
4055
- const missingUrls = discovered.productUrls.filter((u) => {
4056
- const handle = /\/products\/([^/?#]+)/i.exec(u)?.[1];
4057
- return handle && !out.some((p) => p.handle === decodeURIComponent(handle));
5657
+ const handleOf = (u) => {
5658
+ const raw = /\/products\/([^/?#]+)/i.exec(u)?.[1];
5659
+ return raw ? decodeURIComponent(raw) : null;
5660
+ };
5661
+ const stillMissing = () => discovered.productUrls.filter((u) => {
5662
+ const handle = handleOf(u);
5663
+ return handle && !out.some((p) => p.handle === handle);
4058
5664
  });
4059
- if (missingUrls.length) {
4060
- await mapPool(
4061
- missingUrls,
4062
- 6,
4063
- async (u) => {
4064
- const handle = /\/products\/([^/?#]+)/i.exec(u)?.[1];
4065
- if (!handle) return;
4066
- const { ok, json } = await httpJson(`${origin}/products/${handle}.json`, {
4067
- fetchImpl: ctx.fetchImpl,
4068
- signal: ctx.signal
4069
- });
4070
- if (ok && json?.product) {
4071
- const mapped = mapShopifyProduct(origin, json.product);
4072
- if (!seen.has(mapped.externalKey)) {
4073
- seen.add(mapped.externalKey);
4074
- out.push(mapped);
4075
- ctx.onProgress?.({ stage: "fetching_products", fetched: out.length });
5665
+ if (!jsonBlocked) {
5666
+ const missingUrls = stillMissing();
5667
+ if (missingUrls.length) {
5668
+ await mapPool(
5669
+ missingUrls,
5670
+ 6,
5671
+ async (u) => {
5672
+ const handle = handleOf(u);
5673
+ if (!handle) return;
5674
+ const { ok, json } = await httpJson(`${origin}/products/${handle}.json`, {
5675
+ fetchImpl: ctx.fetchImpl,
5676
+ signal: ctx.signal
5677
+ });
5678
+ if (ok && json?.product) {
5679
+ const mapped = mapShopifyProduct(origin, json.product);
5680
+ if (!seen.has(mapped.externalKey)) {
5681
+ seen.add(mapped.externalKey);
5682
+ out.push(mapped);
5683
+ ctx.onProgress?.({ stage: "fetching_products", fetched: out.length });
5684
+ }
4076
5685
  }
4077
- }
4078
- },
4079
- ctx.signal
4080
- );
5686
+ },
5687
+ ctx.signal
5688
+ );
5689
+ }
5690
+ }
5691
+ const unread = stillMissing();
5692
+ if (unread.length) {
5693
+ for (const p of await fetchProductPages(ctx, unread, {
5694
+ concurrency: 4,
5695
+ onProduct: (fetched) => ctx.onProgress?.({ stage: "fetching_products", fetched: out.length + fetched })
5696
+ })) {
5697
+ if (seen.has(p.externalKey)) continue;
5698
+ seen.add(p.externalKey);
5699
+ out.push(p);
5700
+ }
4081
5701
  }
4082
5702
  return out;
4083
5703
  }
4084
5704
  };
4085
- function loadHtml(html) {
4086
- return parse(html);
4087
- }
4088
- function attr(el, name) {
4089
- return el?.getAttribute(name) ?? void 0;
4090
- }
4091
- function textOf(el) {
4092
- return (el?.text ?? "").replace(/\s+/g, " ").trim();
4093
- }
4094
- function stableKey(url) {
4095
- return createHash("sha256").update(url).digest("hex").slice(0, 16);
4096
- }
5705
+
5706
+ // ../catalog/src/adapters/generic.ts
4097
5707
  async function extractSitemapUrls(ctx, filter = () => true) {
4098
5708
  const origin = originOf(ctx.baseUrl);
4099
5709
  const out = /* @__PURE__ */ new Set();
@@ -4125,89 +5735,7 @@ async function extractSitemapUrls(ctx, filter = () => true) {
4125
5735
  }
4126
5736
  if (seen.size > 200) break;
4127
5737
  }
4128
- return [...out];
4129
- }
4130
- function walkJsonLd(node, out) {
4131
- if (!node) return;
4132
- if (Array.isArray(node)) {
4133
- for (const n of node) walkJsonLd(n, out);
4134
- return;
4135
- }
4136
- if (typeof node !== "object") return;
4137
- const obj = node;
4138
- const type = obj["@type"];
4139
- const types = Array.isArray(type) ? type : type ? [type] : [];
4140
- if (types.some((t) => String(t).toLowerCase() === "product")) out.push(obj);
4141
- if (obj["@graph"]) walkJsonLd(obj["@graph"], out);
4142
- for (const v of Object.values(obj)) {
4143
- if (v && typeof v === "object") walkJsonLd(v, out);
4144
- }
4145
- }
4146
- function extractJsonLdProducts(html, pageUrl) {
4147
- const root = loadHtml(html);
4148
- const nodes = [];
4149
- for (const el of root.querySelectorAll('script[type="application/ld+json"]')) {
4150
- const raw = el.innerHTML;
4151
- if (!raw) continue;
4152
- try {
4153
- walkJsonLd(JSON.parse(raw), nodes);
4154
- } catch {
4155
- }
4156
- }
4157
- return nodes.map((n) => {
4158
- const offers = Array.isArray(n.offers) ? n.offers[0] : n.offers;
4159
- const images = [].concat(n.image ?? []).flat().map((img) => typeof img === "string" ? img : img?.url ?? img?.contentUrl).filter(Boolean).map((u, i) => ({ url: absolutize(pageUrl, String(u)), position: i, width: null, height: null, alt: null })).filter((img) => img.url);
4160
- const url = String(n.url ?? n["@id"] ?? pageUrl);
4161
- return normalizeProduct({
4162
- externalKey: String(n.sku || n.productID || n.mpn || stableKey(url)),
4163
- title: String(n.name ?? "Product"),
4164
- descriptionHtml: n.description ? String(n.description) : null,
4165
- url: absolutize(pageUrl, url) ?? pageUrl,
4166
- vendor: n.brand?.name ?? (typeof n.brand === "string" ? n.brand : null),
4167
- productType: n.category ? String(n.category) : null,
4168
- category: n.category ? String(n.category) : null,
4169
- price: offers?.price != null ? Number(offers.price) : null,
4170
- compareAtPrice: null,
4171
- currency: offers?.priceCurrency ?? null,
4172
- available: offers?.availability ? /instock/i.test(String(offers.availability)) : null,
4173
- tags: [],
4174
- variants: [],
4175
- images,
4176
- raw: n
4177
- });
4178
- });
4179
- }
4180
- function parseProductHtml(html, pageUrl) {
4181
- const $ = loadHtml(html);
4182
- const title = attr($.querySelector('meta[property="og:title"]'), "content") || textOf($.querySelector("h1")) || textOf($.querySelector("title"));
4183
- if (!title) return null;
4184
- const desc = attr($.querySelector('meta[property="og:description"]'), "content") || attr($.querySelector('meta[name="description"]'), "content") || null;
4185
- const images = [];
4186
- const og = attr($.querySelector('meta[property="og:image"]'), "content");
4187
- if (og) {
4188
- const abs = absolutize(pageUrl, og);
4189
- if (abs) images.push({ url: abs, position: 0, width: null, height: null, alt: null });
4190
- }
4191
- for (const el of $.querySelectorAll("img[src]")) {
4192
- if (images.length >= 12) break;
4193
- const src = attr(el, "src") || attr(el, "data-src");
4194
- const abs = src ? absolutize(pageUrl, src) : null;
4195
- if (!abs || /logo|icon|sprite|pixel|avatar/i.test(abs)) continue;
4196
- if (images.some((x) => x.url === abs)) continue;
4197
- images.push({ url: abs, position: images.length, width: null, height: null, alt: attr(el, "alt") ?? null });
4198
- }
4199
- const canonical = attr($.querySelector('link[rel="canonical"]'), "href");
4200
- const url = canonical ? absolutize(pageUrl, canonical) ?? pageUrl : pageUrl;
4201
- return normalizeProduct({
4202
- externalKey: stableKey(url),
4203
- title,
4204
- descriptionHtml: desc,
4205
- url,
4206
- images,
4207
- variants: [],
4208
- tags: [],
4209
- raw: { source: "html" }
4210
- });
5738
+ return preferCanonicalLocale([...out]);
4211
5739
  }
4212
5740
  async function extractFeedUrls(ctx) {
4213
5741
  const origin = originOf(ctx.baseUrl);
@@ -4311,41 +5839,15 @@ var genericAdapter = {
4311
5839
  productKeys: [...productUrls].map(stableKey),
4312
5840
  productUrls: [...productUrls],
4313
5841
  estimatedTotal: productUrls.size || null,
5842
+ // Nothing here but addresses: every product is its own page fetch.
5843
+ byPage: true,
4314
5844
  warnings
4315
5845
  };
4316
5846
  },
4317
5847
  async fetchAll(ctx, discovered) {
4318
- const out = [];
4319
- const seen = /* @__PURE__ */ new Set();
4320
- await mapPool(
4321
- discovered.productUrls,
4322
- 5,
4323
- async (u) => {
4324
- try {
4325
- const { ok, text, url } = await httpText(u, {
4326
- fetchImpl: ctx.fetchImpl,
4327
- signal: ctx.signal,
4328
- accept: "text/html"
4329
- });
4330
- if (!ok) return;
4331
- const fromLd = extractJsonLdProducts(text, url);
4332
- const list2 = fromLd.length ? fromLd : [parseProductHtml(text, url)].filter(Boolean);
4333
- for (const p of list2) {
4334
- if (seen.has(p.externalKey)) continue;
4335
- seen.add(p.externalKey);
4336
- out.push(p);
4337
- }
4338
- ctx.onProgress?.({
4339
- stage: "fetching_products",
4340
- fetched: out.length,
4341
- discovered: discovered.productUrls.length
4342
- });
4343
- } catch {
4344
- }
4345
- },
4346
- ctx.signal
4347
- );
4348
- return out;
5848
+ return fetchProductPages(ctx, discovered.productUrls, {
5849
+ onProduct: (fetched) => ctx.onProgress?.({ stage: "fetching_products", fetched, discovered: discovered.productUrls.length })
5850
+ });
4349
5851
  }
4350
5852
  };
4351
5853
 
@@ -4453,6 +5955,9 @@ var woocommerceAdapter = {
4453
5955
  productKeys: [...keys],
4454
5956
  productUrls: [...productUrls],
4455
5957
  estimatedTotal: keys.size || productUrls.size || null,
5958
+ // The WooCommerce store API answers in pages of a hundred, so the whole
5959
+ // catalogue is a handful of requests and needs no batching of ours.
5960
+ byPage: false,
4456
5961
  warnings
4457
5962
  };
4458
5963
  },
@@ -4484,30 +5989,13 @@ var woocommerceAdapter = {
4484
5989
  }
4485
5990
  if (apiWorked && out.length) return out;
4486
5991
  const urls = discovered.productUrls.length ? discovered.productUrls : [...new Set(discovered.productKeys)];
4487
- await mapPool(
4488
- urls,
4489
- 5,
4490
- async (u) => {
4491
- try {
4492
- const {
4493
- ok,
4494
- text,
4495
- url: finalUrl
4496
- } = await httpText(u, { fetchImpl: ctx.fetchImpl, signal: ctx.signal, accept: "text/html" });
4497
- if (!ok) return;
4498
- const fromLd = extractJsonLdProducts(text, finalUrl);
4499
- const products = fromLd.length ? fromLd : [parseProductHtml(text, finalUrl)].filter(Boolean);
4500
- for (const p of products) {
4501
- if (seen.has(p.externalKey)) continue;
4502
- seen.add(p.externalKey);
4503
- out.push(p);
4504
- }
4505
- ctx.onProgress?.({ stage: "fetching_products", fetched: out.length });
4506
- } catch {
4507
- }
4508
- },
4509
- ctx.signal
4510
- );
5992
+ for (const p of await fetchProductPages(ctx, urls, {
5993
+ onProduct: (fetched) => ctx.onProgress?.({ stage: "fetching_products", fetched: out.length + fetched })
5994
+ })) {
5995
+ if (seen.has(p.externalKey)) continue;
5996
+ seen.add(p.externalKey);
5997
+ out.push(p);
5998
+ }
4511
5999
  return out;
4512
6000
  }
4513
6001
  };
@@ -4572,45 +6060,19 @@ var webflowAdapter = {
4572
6060
  }
4573
6061
  ctx.onProgress?.({ stage: "discovering", discovered: productUrls.size });
4574
6062
  if (!productUrls.size) warnings.push("No Webflow product URLs discovered");
4575
- return {
4576
- productKeys: [...productUrls],
4577
- productUrls: [...productUrls],
4578
- estimatedTotal: productUrls.size || null,
4579
- warnings
4580
- };
4581
- },
4582
- async fetchAll(ctx, discovered) {
4583
- const out = [];
4584
- const seen = /* @__PURE__ */ new Set();
4585
- await mapPool(
4586
- discovered.productUrls,
4587
- 5,
4588
- async (u) => {
4589
- try {
4590
- const { ok, text, url } = await httpText(u, {
4591
- fetchImpl: ctx.fetchImpl,
4592
- signal: ctx.signal,
4593
- accept: "text/html"
4594
- });
4595
- if (!ok) return;
4596
- const fromLd = extractJsonLdProducts(text, url);
4597
- const list2 = fromLd.length ? fromLd : [parseProductHtml(text, url)].filter(Boolean);
4598
- for (const p of list2) {
4599
- if (seen.has(p.externalKey)) continue;
4600
- seen.add(p.externalKey);
4601
- out.push(p);
4602
- }
4603
- ctx.onProgress?.({
4604
- stage: "fetching_products",
4605
- fetched: out.length,
4606
- discovered: discovered.productUrls.length
4607
- });
4608
- } catch {
4609
- }
4610
- },
4611
- ctx.signal
4612
- );
4613
- return out;
6063
+ return {
6064
+ productKeys: [...productUrls],
6065
+ productUrls: [...productUrls],
6066
+ estimatedTotal: productUrls.size || null,
6067
+ // Webflow has no catalogue API to ask: every product is its own page.
6068
+ byPage: true,
6069
+ warnings
6070
+ };
6071
+ },
6072
+ async fetchAll(ctx, discovered) {
6073
+ return fetchProductPages(ctx, discovered.productUrls, {
6074
+ onProduct: (fetched) => ctx.onProgress?.({ stage: "fetching_products", fetched, discovered: discovered.productUrls.length })
6075
+ });
4614
6076
  }
4615
6077
  };
4616
6078
 
@@ -4652,7 +6114,7 @@ function baseProgress(platform = "unknown") {
4652
6114
  warnings: []
4653
6115
  };
4654
6116
  }
4655
- async function runCatalogIngestion(opts) {
6117
+ async function discoverCatalog(opts) {
4656
6118
  const fetchImpl = opts.fetchImpl ?? fetch;
4657
6119
  const progress = baseProgress();
4658
6120
  const emit = (patch2) => {
@@ -4695,57 +6157,247 @@ async function runCatalogIngestion(opts) {
4695
6157
  warnings: [...progress.warnings, ...discovered.warnings],
4696
6158
  message: `Found ${discovered.estimatedTotal ?? discovered.productUrls.length} products`
4697
6159
  });
4698
- if (!(discovered.estimatedTotal ?? discovered.productUrls.length)) {
4699
- emit({
4700
- stage: "failed",
4701
- errors: [
4702
- ...progress.errors,
4703
- {
4704
- code: "empty_catalog",
4705
- message: detection.platform === "generic" ? "No public product catalog found. This store may be JavaScript-rendered or require authentication." : `No products discovered on this ${detection.platform} store.`
4706
- }
4707
- ]
4708
- });
4709
- return { baseUrl: detection.baseUrl, detection, products: [], progress };
6160
+ const estimatedTotal = discovered.estimatedTotal ?? discovered.productUrls.length;
6161
+ let empty = null;
6162
+ if (!estimatedTotal) {
6163
+ empty = {
6164
+ code: "empty_catalog",
6165
+ message: detection.platform === "generic" ? "No public product catalog found. This store may be JavaScript-rendered or require authentication." : `No products discovered on this ${detection.platform} store.`
6166
+ };
6167
+ emit({ stage: "failed", errors: [...progress.errors, empty] });
4710
6168
  }
4711
- emit({ stage: "fetching_products", message: "Fetching product details" });
4712
- let products = [];
6169
+ return {
6170
+ baseUrl: detection.baseUrl,
6171
+ detection,
6172
+ adapter,
6173
+ ctx: { ...ctx, baseUrl: detection.baseUrl },
6174
+ productUrls: discovered.productUrls,
6175
+ productKeys: discovered.productKeys,
6176
+ byPage: (discovered.byPage ?? false) && discovered.productUrls.length > 0,
6177
+ estimatedTotal,
6178
+ progress,
6179
+ empty,
6180
+ emit,
6181
+ async fetchAll() {
6182
+ emit({ stage: "fetching_products", message: "Fetching product details" });
6183
+ try {
6184
+ return dedupeProducts(await adapter.fetchAll({ ...ctx, baseUrl: detection.baseUrl }, discovered));
6185
+ } catch (err) {
6186
+ emit({
6187
+ stage: "partial",
6188
+ errors: [...progress.errors, { code: "fetch_failed", message: String(err?.message ?? err), retryable: true }]
6189
+ });
6190
+ return [];
6191
+ }
6192
+ }
6193
+ };
6194
+ }
6195
+
6196
+ // ../catalog/src/robots.ts
6197
+ var ALLOW_ALL = { rules: [], crawlDelayMs: 0 };
6198
+ function toRegExp(pattern) {
6199
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
6200
+ const anchored = escaped.endsWith("\\$") ? `${escaped.slice(0, -2)}$` : escaped;
6201
+ return new RegExp(`^${anchored}`);
6202
+ }
6203
+ function parseRobots(text, agent = USER_AGENT2) {
6204
+ const lines = text.split(/\r?\n/).map((l) => l.replace(/#.*$/, "").trim());
6205
+ const groups = [];
6206
+ let current = null;
6207
+ let expectingAgents = false;
6208
+ for (const line of lines) {
6209
+ const at = line.indexOf(":");
6210
+ if (at < 0) continue;
6211
+ const field = line.slice(0, at).trim().toLowerCase();
6212
+ const value = line.slice(at + 1).trim();
6213
+ if (field === "user-agent") {
6214
+ if (!current || !expectingAgents) {
6215
+ current = { agents: [], rules: [], delay: 0 };
6216
+ groups.push(current);
6217
+ expectingAgents = true;
6218
+ }
6219
+ current.agents.push(value.toLowerCase());
6220
+ continue;
6221
+ }
6222
+ if (!current) continue;
6223
+ expectingAgents = false;
6224
+ if (field === "disallow" && value) current.rules.push({ allow: false, pattern: value });
6225
+ else if (field === "allow" && value) current.rules.push({ allow: true, pattern: value });
6226
+ else if (field === "crawl-delay") {
6227
+ const n = Number(value);
6228
+ if (Number.isFinite(n) && n > 0) current.delay = n;
6229
+ }
6230
+ }
6231
+ const token = agent.toLowerCase();
6232
+ const mine = groups.filter((g) => g.agents.some((a) => a !== "*" && token.includes(a)));
6233
+ const chosen = mine.length ? mine : groups.filter((g) => g.agents.includes("*"));
6234
+ return {
6235
+ rules: chosen.flatMap((g) => g.rules),
6236
+ crawlDelayMs: Math.max(0, ...chosen.map((g) => g.delay)) * 1e3
6237
+ };
6238
+ }
6239
+ function isAllowed(robots, url) {
6240
+ let path;
4713
6241
  try {
4714
- products = await adapter.fetchAll({ ...ctx, baseUrl: detection.baseUrl }, discovered);
4715
- } catch (err) {
4716
- emit({
4717
- stage: "partial",
4718
- errors: [...progress.errors, { code: "fetch_failed", message: String(err?.message ?? err), retryable: true }]
4719
- });
6242
+ const parsed = new URL(url);
6243
+ path = `${parsed.pathname}${parsed.search}`;
6244
+ } catch {
6245
+ return true;
4720
6246
  }
4721
- products = dedupeProducts(products);
4722
- emit({
4723
- stage: products.length ? "fetching_products" : progress.stage,
4724
- fetched: products.length,
4725
- discovered: Math.max(progress.discovered, products.length),
4726
- message: `Fetched ${products.length} products`
4727
- });
4728
- if (!products.length) {
4729
- emit({
4730
- stage: "failed",
4731
- errors: [
4732
- ...progress.errors,
4733
- { code: "no_products_fetched", message: "Discovery found URLs but no product payloads could be parsed." }
4734
- ]
6247
+ let best = null;
6248
+ for (const rule of robots.rules) {
6249
+ if (!toRegExp(rule.pattern).test(path)) continue;
6250
+ const length = rule.pattern.replace(/[*$]/g, "").length;
6251
+ if (!best || length > best.length || length === best.length && rule.allow) {
6252
+ best = { allow: rule.allow, length };
6253
+ }
6254
+ }
6255
+ return best ? best.allow : true;
6256
+ }
6257
+ async function fetchRobots(ctx) {
6258
+ try {
6259
+ const { ok, text } = await httpText(`${originOf(ctx.baseUrl)}/robots.txt`, {
6260
+ fetchImpl: ctx.fetchImpl,
6261
+ signal: ctx.signal,
6262
+ retries: 0,
6263
+ accept: "text/plain",
6264
+ maxBytes: 512e3
4735
6265
  });
6266
+ if (!ok || !text) return ALLOW_ALL;
6267
+ return parseRobots(text);
6268
+ } catch {
6269
+ return ALLOW_ALL;
6270
+ }
6271
+ }
6272
+
6273
+ // ../catalog/src/candidates.ts
6274
+ var DEFAULT_SCAN_BUDGET = {
6275
+ maxPreviewPages: 24,
6276
+ maxBytesPerPage: 15e5,
6277
+ maxTotalBytes: 4e7,
6278
+ budgetMs: 25e3,
6279
+ concurrency: 4,
6280
+ /**
6281
+ * Time the preview is owed even when discovery has already spent the lot.
6282
+ *
6283
+ * Discovery on a large store can run long, and when it overran the whole
6284
+ * budget the preview read zero pages and the store was reported as one we
6285
+ * could not open - from a site that was answering every request with a 200.
6286
+ * Being slow to list a catalog is not the same as being shut.
6287
+ */
6288
+ previewFloorMs: 12e3
6289
+ };
6290
+ var COMMERCE_SIGNALS = /* @__PURE__ */ new Set([
6291
+ "products.json",
6292
+ "shopify-html",
6293
+ "wc-store-api",
6294
+ "woocommerce-html",
6295
+ "webflow-commerce"
6296
+ ]);
6297
+ function wwwVariant(baseUrl) {
6298
+ try {
6299
+ const url = new URL(baseUrl);
6300
+ const labels = url.hostname.split(".");
6301
+ if (labels.length !== 2 || /^\d+$/.test(labels[labels.length - 1])) return null;
6302
+ url.hostname = `www.${url.hostname}`;
6303
+ return url.origin;
6304
+ } catch {
6305
+ return null;
6306
+ }
6307
+ }
6308
+ async function scanForCandidates(opts) {
6309
+ const first = await scanOnce(opts, originOf(normalizeStoreUrl(opts.url)));
6310
+ if (first.verdict === "found") return first;
6311
+ const alternate = wwwVariant(first.baseUrl);
6312
+ if (!alternate) return first;
6313
+ const second = await scanOnce(opts, alternate);
6314
+ return second.verdict === "found" ? second : first;
6315
+ }
6316
+ async function scanOnce(opts, baseUrl) {
6317
+ const started = Date.now();
6318
+ const budget = { ...DEFAULT_SCAN_BUDGET, ...opts.budget };
6319
+ const deadline = started + budget.budgetMs;
6320
+ const fetchImpl = opts.fetchImpl ?? fetch;
6321
+ const ctx = {
6322
+ fetchImpl,
6323
+ baseUrl,
6324
+ signal: opts.signal,
6325
+ onProgress: opts.onProgress
6326
+ };
6327
+ const warnings = [];
6328
+ const stats = { pages: 0, bytes: 0 };
6329
+ const robots = await fetchRobots(ctx);
6330
+ if (robots.crawlDelayMs) warnings.push("This site asks readers to go slowly, so the preview is smaller");
6331
+ opts.onProgress?.({ stage: "discovering", message: "Looking for a shop" });
6332
+ const detection = await detectPlatform(ctx);
6333
+ const adapter = adapterFor(detection.platform);
6334
+ let discovered;
6335
+ try {
6336
+ discovered = await adapter.discover(ctx);
6337
+ } catch {
6338
+ return done("blocked", [], [], 0, "none", [...warnings, "The product list could not be read"]);
6339
+ }
6340
+ warnings.push(...discovered.warnings);
6341
+ const urls = discovered.productUrls.filter((u) => isAllowed(robots, u));
6342
+ const refused = discovered.productUrls.length - urls.length;
6343
+ if (refused > 0) warnings.push(`${refused} pages are disallowed by this site's robots.txt`);
6344
+ const fromApi = detection.platform !== "generic" && !discovered.hints?.includes("json-blocked");
6345
+ const countSource = urls.length === 0 ? "none" : fromApi ? "api" : "sitemap";
6346
+ if (urls.length === 0) {
6347
+ const commerce = detection.signals.some((sig) => COMMERCE_SIGNALS.has(sig));
6348
+ return done(commerce ? "likely" : "none", [], [], 0, countSource, warnings);
6349
+ }
6350
+ opts.onProgress?.({ stage: "discovering", discovered: urls.length, message: "Reading a few products" });
6351
+ const previewDeadline = Math.max(deadline, Date.now() + budget.previewFloorMs);
6352
+ const read = dedupeProducts(
6353
+ await fetchProductPages(ctx, urls, {
6354
+ want: budget.maxPreviewPages,
6355
+ stats,
6356
+ concurrency: budget.concurrency,
6357
+ maxBytes: budget.maxBytesPerPage,
6358
+ maxTotalBytes: budget.maxTotalBytes,
6359
+ delayMs: robots.crawlDelayMs,
6360
+ deadline: previewDeadline,
6361
+ onProduct: (fetched) => opts.onProgress?.({ stage: "fetching_products", fetched, discovered: urls.length })
6362
+ })
6363
+ );
6364
+ const preview = read.slice(0, budget.maxPreviewPages);
6365
+ const verdict = preview.length ? "found" : "blocked";
6366
+ return done(verdict, preview, urls, urls.length, countSource, warnings);
6367
+ function done(verdict2, candidates, candidateUrls, count, source, notes) {
6368
+ return {
6369
+ baseUrl,
6370
+ platform: detection?.platform ?? "unknown",
6371
+ signals: detection?.signals ?? [],
6372
+ verdict: verdict2,
6373
+ count,
6374
+ countSource: source,
6375
+ candidates,
6376
+ candidateUrls,
6377
+ truncated: count > candidates.length,
6378
+ warnings: notes,
6379
+ spent: { pages: stats.pages, bytes: stats.bytes, ms: Date.now() - started }
6380
+ };
4736
6381
  }
4737
- return { baseUrl: detection.baseUrl, detection, products, progress };
4738
6382
  }
4739
6383
 
4740
6384
  // src/catalogImport.ts
4741
6385
  var running = /* @__PURE__ */ new Map();
4742
6386
  function cancelCatalogImport(jobId) {
4743
- const ctrl = running.get(jobId);
4744
- if (!ctrl) return false;
4745
- ctrl.abort();
6387
+ const job = running.get(jobId);
6388
+ if (!job) return false;
6389
+ job.ctrl.abort();
4746
6390
  return true;
4747
6391
  }
4748
- function startCatalogImport(deps, brandId, url) {
6392
+ async function settleCatalogImports(timeoutMs = 5e3) {
6393
+ if (!running.size) return;
6394
+ for (const { ctrl } of running.values()) ctrl.abort();
6395
+ await Promise.race([
6396
+ Promise.allSettled([...running.values()].map((j) => j.done)),
6397
+ new Promise((r) => setTimeout(r, timeoutMs))
6398
+ ]);
6399
+ }
6400
+ function startCatalogImport(deps, brandId, url, opts = {}) {
4749
6401
  const { core } = deps;
4750
6402
  if (!core.store.getBrand(brandId)) throw Object.assign(new Error("brand not found"), { statusCode: 404 });
4751
6403
  let normalized;
@@ -4756,57 +6408,185 @@ function startCatalogImport(deps, brandId, url) {
4756
6408
  }
4757
6409
  const job = core.catalog.createJob({ brandId, url: normalized });
4758
6410
  const ctrl = new AbortController();
4759
- running.set(job.id, ctrl);
4760
- void runJob(deps, job.id, brandId, normalized, ctrl.signal).finally(() => {
6411
+ const done = runJob(deps, job.id, brandId, normalized, ctrl.signal, opts.only).finally(() => {
4761
6412
  running.delete(job.id);
4762
6413
  });
6414
+ void done.catch(() => {
6415
+ });
6416
+ running.set(job.id, { ctrl, done });
4763
6417
  return { jobId: job.id };
4764
6418
  }
4765
- async function runJob(deps, jobId, brandId, url, signal) {
6419
+ function progressWriter(patch2) {
6420
+ let lastStage = "";
6421
+ let lastMessage = null;
6422
+ return (p) => {
6423
+ const reported = p.stage === "queued" ? "discovering" : p.stage;
6424
+ const stage = TERMINAL.has(reported) ? "fetching_products" : reported;
6425
+ const message = p.message ?? null;
6426
+ const notable = stage !== lastStage || message !== lastMessage || p.fetched % 10 === 0;
6427
+ if (!notable) return;
6428
+ lastStage = stage;
6429
+ lastMessage = message;
6430
+ patch2({
6431
+ stage,
6432
+ platform: p.platform,
6433
+ discovered: p.discovered,
6434
+ fetched: p.fetched,
6435
+ warnings: p.warnings,
6436
+ errors: p.errors,
6437
+ message
6438
+ });
6439
+ };
6440
+ }
6441
+ async function runJob(deps, jobId, brandId, url, signal, only) {
4766
6442
  const { core, fetchImpl } = deps;
4767
6443
  const patch2 = (p) => core.catalog.updateJob(jobId, p);
4768
6444
  try {
4769
- patch2({ stage: "discovering", message: "Detecting store platform" });
4770
- const result = await runCatalogIngestion({
4771
- url,
4772
- fetchImpl,
4773
- signal,
4774
- onProgress: (p) => {
6445
+ const tally = { fetched: 0, upserted: 0, imagesDone: 0, imagesTotal: 0, errors: [], seenKeys: [] };
6446
+ const ctx = { fetchImpl: fetchImpl ?? fetch, baseUrl: url, signal };
6447
+ let urls = [];
6448
+ let bulk = null;
6449
+ let baseUrl;
6450
+ let platform;
6451
+ let discovered;
6452
+ let warnings = [];
6453
+ const sweep = !only?.length;
6454
+ if (only?.length) {
6455
+ patch2({ stage: "fetching_products", message: `Importing ${only.length} products`, discovered: only.length });
6456
+ const detection = await detectPlatform(ctx);
6457
+ urls = only;
6458
+ baseUrl = url;
6459
+ platform = detection.platform;
6460
+ discovered = only.length;
6461
+ } else {
6462
+ patch2({ stage: "discovering", message: "Detecting store platform" });
6463
+ const found = await discoverCatalog({ url, fetchImpl, signal, onProgress: progressWriter(patch2) });
6464
+ baseUrl = found.baseUrl;
6465
+ platform = found.detection.platform;
6466
+ discovered = found.estimatedTotal;
6467
+ warnings = found.progress.warnings;
6468
+ if (found.empty || !found.estimatedTotal) {
6469
+ const source = core.catalog.upsertSource(brandId, found.baseUrl, platform);
6470
+ patch2({ sourceId: source.id, platform });
4775
6471
  patch2({
4776
- stage: p.stage === "queued" ? "discovering" : p.stage,
4777
- platform: p.platform,
4778
- discovered: p.discovered,
4779
- fetched: p.fetched,
4780
- warnings: p.warnings,
4781
- errors: p.errors,
4782
- message: p.message ?? null
6472
+ stage: "no_catalog",
6473
+ errors: [],
6474
+ warnings: found.progress.warnings,
6475
+ message: "No shop found on this site",
6476
+ finished: true
4783
6477
  });
6478
+ core.catalog.setSourceStatus(source.id, "empty", true);
6479
+ return;
4784
6480
  }
4785
- });
6481
+ if (!found.byPage) {
6482
+ const products = await found.fetchAll();
6483
+ if (signal.aborted) {
6484
+ patch2({ stage: "cancelled", errors: [], message: "Stopped before anything was saved", finished: true });
6485
+ return;
6486
+ }
6487
+ if (!products.length) {
6488
+ const source = core.catalog.upsertSource(brandId, baseUrl, platform);
6489
+ patch2({ sourceId: source.id, platform });
6490
+ patch2({
6491
+ stage: "failed",
6492
+ errors: found.progress.errors,
6493
+ warnings: found.progress.warnings,
6494
+ message: found.progress.errors[0]?.message ?? "No products imported",
6495
+ finished: true
6496
+ });
6497
+ core.catalog.setSourceStatus(source.id, "failed", true);
6498
+ return;
6499
+ }
6500
+ bulk = products;
6501
+ discovered = found.progress.discovered;
6502
+ warnings = found.progress.warnings;
6503
+ }
6504
+ if (!bulk) {
6505
+ urls = found.productUrls;
6506
+ patch2({ stage: "fetching_products", discovered, message: `Reading ${discovered.toLocaleString()} products` });
6507
+ }
6508
+ }
6509
+ const run2 = beginWrite(deps, jobId, brandId, baseUrl, platform, tally, discovered);
6510
+ const pictures = drainPictures(deps, jobId, brandId, tally, signal);
6511
+ let pictureErrors;
6512
+ const stats = { pages: 0, bytes: 0, refused: 0 };
6513
+ try {
6514
+ if (bulk) for (const p of bulk) run2.write(p);
6515
+ else
6516
+ await fetchProductPages(ctx, urls, {
6517
+ concurrency: IMPORT_CONCURRENCY,
6518
+ maxBytes: 15e5,
6519
+ onEach: run2.write,
6520
+ stats
6521
+ });
6522
+ if (!signal.aborted && tally.upserted) {
6523
+ patch2({
6524
+ stage: "processing_assets",
6525
+ message: `Downloading ${tally.imagesTotal.toLocaleString()} pictures`,
6526
+ upserted: tally.upserted,
6527
+ fetched: tally.fetched
6528
+ });
6529
+ }
6530
+ } finally {
6531
+ pictures.stop();
6532
+ pictureErrors = await pictures.done;
6533
+ }
4786
6534
  if (signal.aborted) {
4787
- patch2({ stage: "failed", message: "Import cancelled", finished: true });
6535
+ patch2({
6536
+ stage: "cancelled",
6537
+ errors: [],
6538
+ message: tally.upserted ? `Stopped after saving ${tally.upserted.toLocaleString()} products` : "Stopped before anything was saved",
6539
+ finished: true
6540
+ });
6541
+ core.catalog.setSourceStatus(run2.sourceId, "partial", true);
4788
6542
  return;
4789
6543
  }
4790
- const source = core.catalog.upsertSource(brandId, result.baseUrl, result.detection.platform);
4791
- patch2({ sourceId: source.id, platform: result.detection.platform });
4792
- core.catalog.setSourceStatus(source.id, "importing");
4793
- if (!result.products.length) {
4794
- const stage = result.progress.stage === "failed" ? "failed" : "failed";
6544
+ if (!tally.upserted) {
6545
+ const message = only?.length ? "None of the chosen products could be read" : `Found pages on this ${platform} store but could not read a product from any of them. The store may be blocking automated readers.`;
4795
6546
  patch2({
4796
- stage,
4797
- errors: result.progress.errors,
4798
- warnings: result.progress.warnings,
4799
- message: result.progress.errors[0]?.message ?? "No products imported",
6547
+ stage: "failed",
6548
+ message,
6549
+ errors: [{ code: "no_products_fetched", message }],
4800
6550
  finished: true
4801
6551
  });
4802
- core.catalog.setSourceStatus(source.id, "failed", true);
6552
+ core.catalog.setSourceStatus(run2.sourceId, "failed", true);
6553
+ return;
6554
+ }
6555
+ run2.finish({
6556
+ sweep,
6557
+ warnings,
6558
+ errors: pictureErrors,
6559
+ refused: stats.refused,
6560
+ // Every address was read. A bulk API hands the catalogue over whole, so
6561
+ // there is nothing to cover.
6562
+ covered: bulk ? true : stats.pages >= urls.length
6563
+ });
6564
+ } catch (err) {
6565
+ if (signal.aborted) {
6566
+ patch2({ stage: "cancelled", errors: [], message: "Stopped before anything was saved", finished: true });
4803
6567
  return;
4804
6568
  }
4805
- patch2({ stage: "fetching_products", fetched: result.products.length, message: "Saving products" });
4806
- let upserted = 0;
4807
- const seenKeys = [];
4808
- for (const p of result.products) {
4809
- if (signal.aborted) break;
6569
+ patch2({
6570
+ stage: "failed",
6571
+ message: String(err?.message ?? err),
6572
+ errors: [{ code: "import_failed", message: String(err?.message ?? err) }],
6573
+ finished: true
6574
+ });
6575
+ }
6576
+ }
6577
+ var IMAGES_PER_PRODUCT = 3;
6578
+ var TERMINAL = /* @__PURE__ */ new Set(["completed", "partial", "no_catalog", "cancelled", "failed"]);
6579
+ var IMAGE_CONCURRENCY = 4;
6580
+ var sleep6 = (ms) => new Promise((r) => setTimeout(r, ms));
6581
+ function beginWrite(deps, jobId, brandId, baseUrl, platform, tally, discovered) {
6582
+ const { core } = deps;
6583
+ const patch2 = (p) => core.catalog.updateJob(jobId, p);
6584
+ const source = core.catalog.upsertSource(brandId, baseUrl, platform);
6585
+ patch2({ sourceId: source.id, platform });
6586
+ core.catalog.setSourceStatus(source.id, "importing");
6587
+ return {
6588
+ sourceId: source.id,
6589
+ write(p) {
4810
6590
  core.catalog.upsertProduct({
4811
6591
  sourceId: source.id,
4812
6592
  brandId,
@@ -4823,7 +6603,9 @@ async function runJob(deps, jobId, brandId, url, signal) {
4823
6603
  compareAtPrice: p.compareAtPrice,
4824
6604
  currency: p.currency,
4825
6605
  available: p.available,
4826
- raw: p.raw,
6606
+ // `raw` is the whole crawled payload, 14.2 KB a product on gymshark
6607
+ // and 32 MB across its catalog, written to sqlite and read by nothing.
6608
+ raw: null,
4827
6609
  variants: p.variants,
4828
6610
  images: (p.images ?? []).map((img) => ({
4829
6611
  sourceUrl: img.url,
@@ -4832,93 +6614,111 @@ async function runJob(deps, jobId, brandId, url, signal) {
4832
6614
  height: img.height,
4833
6615
  alt: img.alt
4834
6616
  })),
4835
- collections: (p.collections ?? []).map((c) => ({
4836
- externalKey: c,
4837
- title: c
4838
- }))
6617
+ collections: (p.collections ?? []).map((c) => ({ externalKey: c, title: c }))
6618
+ });
6619
+ tally.seenKeys.push(p.externalKey);
6620
+ tally.upserted++;
6621
+ tally.fetched++;
6622
+ patch2({ upserted: tally.upserted, fetched: tally.fetched });
6623
+ },
6624
+ finish({
6625
+ sweep,
6626
+ warnings,
6627
+ errors,
6628
+ refused = 0,
6629
+ covered = true
6630
+ }) {
6631
+ if (sweep) core.catalog.markMissingUnavailable(source.id, tally.seenKeys);
6632
+ tally.errors = errors;
6633
+ const partial = !covered || refused > 0;
6634
+ const shut = refused > 0 && refused >= Math.max(20, tally.upserted * 0.25);
6635
+ const message = shut ? `The store stopped answering after ${tally.upserted.toLocaleString()} of ${discovered.toLocaleString()} products. Try again later.` : partial ? `Imported ${tally.upserted.toLocaleString()} products with ${(errors.length + refused).toLocaleString()} issue${errors.length + refused === 1 ? "" : "s"}` : `Imported ${tally.upserted.toLocaleString()} products`;
6636
+ patch2({
6637
+ stage: partial ? "partial" : "completed",
6638
+ upserted: tally.upserted,
6639
+ fetched: tally.fetched,
6640
+ imagesDone: tally.imagesDone,
6641
+ imagesTotal: tally.imagesTotal,
6642
+ errors: shut ? [...errors, { code: "store_refused", message: `${refused.toLocaleString()} pages were refused` }] : errors,
6643
+ warnings,
6644
+ message,
6645
+ finished: true
4839
6646
  });
4840
- seenKeys.push(p.externalKey);
4841
- upserted++;
4842
- if (upserted % 10 === 0) patch2({ upserted, fetched: result.products.length });
6647
+ core.catalog.setSourceStatus(source.id, partial ? "partial" : "ready", true);
4843
6648
  }
4844
- patch2({ upserted, fetched: result.products.length });
4845
- core.catalog.markMissingUnavailable(source.id, seenKeys);
4846
- const pending = core.catalog.listImagesNeedingAssets(brandId, 5e4);
4847
- patch2({
4848
- stage: "processing_assets",
4849
- imagesTotal: pending.length,
4850
- imagesDone: 0,
4851
- message: `Downloading ${pending.length} images`
4852
- });
4853
- const errors = [...core.catalog.getJob(jobId)?.errors ?? []];
4854
- let imagesDone = 0;
4855
- await mapPool(
4856
- pending,
4857
- 6,
4858
- async (img) => {
4859
- if (signal.aborted) return;
4860
- try {
4861
- const res = await httpGet(img.sourceUrl, { fetchImpl, signal, timeoutMs: 4e4, retries: 2 });
4862
- if (!res.ok) {
4863
- errors.push({ code: "image_http", message: `HTTP ${res.status}`, url: img.sourceUrl });
4864
- return;
4865
- }
4866
- const buf = Buffer.from(await res.arrayBuffer());
4867
- if (!buf.length) {
4868
- errors.push({ code: "image_empty", message: "Empty image", url: img.sourceUrl });
4869
- return;
4870
- }
4871
- const png = await sharp20(buf).rotate().png().toBuffer();
4872
- const meta = await sharp20(png).metadata();
4873
- const hash = core.images.save(png);
4874
- core.catalog.setImageAsset(img.productId, img.sourceUrl, `asset:${hash}`, {
4875
- width: meta.width,
4876
- height: meta.height
4877
- });
4878
- } catch (err) {
6649
+ };
6650
+ }
6651
+ var IMPORT_CONCURRENCY = 4;
6652
+ var PICTURE_ROUND = 60;
6653
+ function drainPictures(deps, jobId, brandId, tally, signal, imagesPerProduct = IMAGES_PER_PRODUCT) {
6654
+ const { core, fetchImpl } = deps;
6655
+ const patch2 = (p) => core.catalog.updateJob(jobId, p);
6656
+ const errors = [...core.catalog.getJob(jobId)?.errors ?? []];
6657
+ let stopped = false;
6658
+ const done = (async () => {
6659
+ while (!signal.aborted) {
6660
+ const round = core.catalog.listImagesNeedingAssets(brandId, PICTURE_ROUND).filter((img) => img.position < imagesPerProduct);
6661
+ if (!round.length) {
6662
+ if (stopped) break;
6663
+ await sleep6(150);
6664
+ continue;
6665
+ }
6666
+ tally.imagesTotal += round.length;
6667
+ await mapPool(
6668
+ round,
6669
+ IMAGE_CONCURRENCY,
6670
+ async (img) => {
4879
6671
  if (signal.aborted) return;
4880
- errors.push({
4881
- code: "image_failed",
4882
- message: String(err?.message ?? err),
4883
- url: img.sourceUrl,
4884
- retryable: true
4885
- });
4886
- } finally {
4887
- imagesDone++;
4888
- if (imagesDone % 5 === 0 || imagesDone === pending.length) {
4889
- patch2({ imagesDone, imagesTotal: pending.length, errors });
6672
+ try {
6673
+ const res = await httpGet(img.sourceUrl, { fetchImpl, signal, timeoutMs: 4e4, retries: 2 });
6674
+ if (!res.ok) {
6675
+ errors.push({ code: "image_http", message: `HTTP ${res.status}`, url: img.sourceUrl });
6676
+ return;
6677
+ }
6678
+ const buf = Buffer.from(await res.arrayBuffer());
6679
+ if (!buf.length) {
6680
+ errors.push({ code: "image_empty", message: "Empty image", url: img.sourceUrl });
6681
+ return;
6682
+ }
6683
+ const probe = await sharp21(buf).metadata();
6684
+ const turned = (probe.orientation ?? 1) > 1;
6685
+ const keep = turned ? await sharp21(buf).rotate().toBuffer() : buf;
6686
+ const meta = turned ? await sharp21(keep).metadata() : probe;
6687
+ const hash = core.images.save(keep, meta.format ?? probe.format ?? "png");
6688
+ core.catalog.setImageAsset(img.productId, img.sourceUrl, `asset:${hash}`, {
6689
+ width: meta.width,
6690
+ height: meta.height
6691
+ });
6692
+ } catch (err) {
6693
+ if (signal.aborted) return;
6694
+ errors.push({
6695
+ code: "image_failed",
6696
+ message: String(err?.message ?? err),
6697
+ url: img.sourceUrl,
6698
+ retryable: true
6699
+ });
6700
+ } finally {
6701
+ tally.imagesDone++;
6702
+ patch2({ imagesDone: tally.imagesDone, imagesTotal: tally.imagesTotal, errors });
4890
6703
  }
4891
- }
4892
- },
4893
- signal
4894
- );
4895
- if (signal.aborted) {
4896
- patch2({ stage: "failed", message: "Import cancelled", errors, finished: true });
4897
- core.catalog.setSourceStatus(source.id, "failed", true);
4898
- return;
6704
+ },
6705
+ signal
6706
+ );
6707
+ const stuck = core.catalog.listImagesNeedingAssets(brandId, PICTURE_ROUND).filter((img) => img.position < imagesPerProduct);
6708
+ if (stuck.length && stuck[0]?.id === round[0]?.id) {
6709
+ errors.push({ code: "images_stalled", message: "Some pictures could not be downloaded" });
6710
+ break;
6711
+ }
4899
6712
  }
4900
- const stillMissing = core.catalog.listImagesNeedingAssets(brandId, 1).length;
4901
- const productCount = result.products.length;
4902
- const partial = !!errors.length || stillMissing > 0 || result.progress.discovered > 0 && productCount < result.progress.discovered * 0.9;
4903
- patch2({
4904
- stage: partial ? "partial" : "completed",
4905
- upserted,
4906
- imagesDone,
4907
- imagesTotal: pending.length,
4908
- errors,
4909
- warnings: result.progress.warnings,
4910
- message: partial ? `Imported ${upserted} products with ${errors.length} issue${errors.length === 1 ? "" : "s"}` : `Imported ${upserted} products`,
4911
- finished: true
4912
- });
4913
- core.catalog.setSourceStatus(source.id, partial ? "partial" : "ready", true);
4914
- } catch (err) {
4915
- patch2({
4916
- stage: "failed",
4917
- message: String(err?.message ?? err),
4918
- errors: [{ code: "import_failed", message: String(err?.message ?? err) }],
4919
- finished: true
4920
- });
4921
- }
6713
+ patch2({ imagesDone: tally.imagesDone, imagesTotal: tally.imagesTotal, errors });
6714
+ return errors;
6715
+ })();
6716
+ return {
6717
+ stop() {
6718
+ stopped = true;
6719
+ },
6720
+ done
6721
+ };
4922
6722
  }
4923
6723
  function resolveLibraryProduct(core, brandId, productId) {
4924
6724
  const brand = core.store.getBrand(brandId);
@@ -5346,7 +7146,7 @@ async function generateStudioSet(deps, job, who, sourcePaths, signal) {
5346
7146
  return { hashes: kept.map((f) => byAngle.get(f.angle)), angles: kept.map((f) => f.angle) };
5347
7147
  }
5348
7148
  async function edgeBarGeometry(buf) {
5349
- const { data, info } = await sharp20(buf).greyscale().raw().toBuffer({ resolveWithObject: true });
7149
+ const { data, info } = await sharp21(buf).greyscale().raw().toBuffer({ resolveWithObject: true });
5350
7150
  const W = info.width;
5351
7151
  const H = info.height;
5352
7152
  const scan = (len, cross, at) => {
@@ -5400,7 +7200,7 @@ async function trimEdgeBars(core, hash) {
5400
7200
  const width = g.right - g.left + 1;
5401
7201
  const height = g.bottom - g.top + 1;
5402
7202
  if (width < g.W * 0.6 || height < g.H * 0.6) return hash;
5403
- const png = await sharp20(buf).extract({ left: g.left, top: g.top, width, height }).png().toBuffer();
7203
+ const png = await sharp21(buf).extract({ left: g.left, top: g.top, width, height }).png().toBuffer();
5404
7204
  return core.images.save(png);
5405
7205
  } catch {
5406
7206
  return hash;
@@ -5471,7 +7271,7 @@ async function identityCrop(core, hash) {
5471
7271
  if (!out) return void 0;
5472
7272
  try {
5473
7273
  const height = Math.min(IDENTITY_TARGET_HEIGHT, Math.round(nativeHeight * IDENTITY_MAX_UPSCALE)) || IDENTITY_TARGET_HEIGHT;
5474
- const png = await sharp20(core.images.read(out)).resize({ height, fit: "inside", kernel: "lanczos3", withoutEnlargement: false }).png().toBuffer();
7274
+ const png = await sharp21(core.images.read(out)).resize({ height, fit: "inside", kernel: "lanczos3", withoutEnlargement: false }).png().toBuffer();
5475
7275
  const scaled = core.images.save(png);
5476
7276
  identityCrops.set(hash, scaled);
5477
7277
  return scaled;
@@ -5500,12 +7300,12 @@ async function brandJsonWithIdentityCrops(core, json, characterIds) {
5500
7300
  return changed ? { ...json, characters } : json;
5501
7301
  }
5502
7302
  async function figureBox(buf) {
5503
- const meta = await sharp20(buf).metadata();
7303
+ const meta = await sharp21(buf).metadata();
5504
7304
  const W = meta.width ?? 0;
5505
7305
  const H = meta.height ?? 0;
5506
7306
  if (!W || !H) return null;
5507
7307
  for (const threshold of FIGURE_TRIM_THRESHOLDS) {
5508
- const { info } = await sharp20(buf).trim({ threshold }).toBuffer({ resolveWithObject: true });
7308
+ const { info } = await sharp21(buf).trim({ threshold }).toBuffer({ resolveWithObject: true });
5509
7309
  const left = Math.abs(info.trimOffsetLeft ?? 0);
5510
7310
  const top = Math.abs(info.trimOffsetTop ?? 0);
5511
7311
  const width = info.width ?? 0;
@@ -5538,13 +7338,13 @@ async function smartCover(core, hash, box) {
5538
7338
  if (!hash || !core.images.has(hash)) return void 0;
5539
7339
  try {
5540
7340
  const buf = core.images.read(hash);
5541
- const meta = await sharp20(buf).metadata();
7341
+ const meta = await sharp21(buf).metadata();
5542
7342
  const w = meta.width ?? 0;
5543
7343
  const h = meta.height ?? 0;
5544
7344
  if (!w || !h) return void 0;
5545
7345
  const raw = box(w, h);
5546
7346
  const target = { width: Math.max(1, raw.width), height: Math.max(1, raw.height) };
5547
- const png = await sharp20(buf).resize(target.width, target.height, { fit: "cover", position: "attention" }).png().toBuffer();
7347
+ const png = await sharp21(buf).resize(target.width, target.height, { fit: "cover", position: "attention" }).png().toBuffer();
5548
7348
  return core.images.save(png);
5549
7349
  } catch {
5550
7350
  return void 0;
@@ -5553,11 +7353,11 @@ async function smartCover(core, hash, box) {
5553
7353
  async function crop(core, hash, region, cap2) {
5554
7354
  if (!hash || !core.images.has(hash)) return void 0;
5555
7355
  try {
5556
- const meta = await sharp20(core.images.read(hash)).metadata();
7356
+ const meta = await sharp21(core.images.read(hash)).metadata();
5557
7357
  const w = meta.width ?? 0;
5558
7358
  const h = meta.height ?? 0;
5559
7359
  if (!w || !h) return void 0;
5560
- let pipeline = sharp20(core.images.read(hash)).extract(region(w, h));
7360
+ let pipeline = sharp21(core.images.read(hash)).extract(region(w, h));
5561
7361
  if (cap2) pipeline = pipeline.resize(cap2, cap2, { fit: "inside", withoutEnlargement: true });
5562
7362
  const png = await pipeline.png().toBuffer();
5563
7363
  return core.images.save(png);
@@ -5904,7 +7704,7 @@ var fromLab = (l, a, bb) => {
5904
7704
  return [clamp(R), clamp(G), clamp(B)];
5905
7705
  };
5906
7706
  var rawAt = async (png, edge) => {
5907
- let img = sharp20(png);
7707
+ let img = sharp21(png);
5908
7708
  if (edge) img = img.resize(edge, edge, { fit: "fill" });
5909
7709
  const { data, info } = await img.removeAlpha().raw().toBuffer({ resolveWithObject: true });
5910
7710
  return { data, width: info.width, height: info.height };
@@ -5967,7 +7767,7 @@ async function gradeComposite(originalPng, modelInputPng, modelOutputPng) {
5967
7767
  if (residual > GRADE_GATE_MEAN_DELTA) return null;
5968
7768
  const full = await rawAt(originalPng);
5969
7769
  applyAffine(full, T);
5970
- const image = await sharp20(full.data, {
7770
+ const image = await sharp21(full.data, {
5971
7771
  raw: { width: full.width, height: full.height, channels: 3 }
5972
7772
  }).png().toBuffer();
5973
7773
  return { image, residual };
@@ -6141,7 +7941,7 @@ function fitExpandToBudget(plan, source, pixelBudget) {
6141
7941
  }
6142
7942
  async function attentionCropOrigin(srcBuf, source, plan) {
6143
7943
  try {
6144
- const { info } = await sharp20(srcBuf).resize(plan.width, plan.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
7944
+ const { info } = await sharp21(srcBuf).resize(plan.width, plan.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
6145
7945
  const attnLeft = typeof info.cropOffsetLeft === "number" ? Math.abs(info.cropOffsetLeft) : plan.left;
6146
7946
  const attnTop = typeof info.cropOffsetTop === "number" ? Math.abs(info.cropOffsetTop) : plan.top;
6147
7947
  const left = Math.round((attnLeft + plan.left) / 2);
@@ -6294,23 +8094,23 @@ function relax(grid, seam, fixedSweeps) {
6294
8094
 
6295
8095
  // src/expand.ts
6296
8096
  async function expandCanvas(source, plan) {
6297
- const bed = await sharp20(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
6298
- return sharp20(bed).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
8097
+ const bed = await sharp21(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
8098
+ return sharp21(bed).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
6299
8099
  }
6300
8100
  async function compositeExpand(engineImage, source, plan) {
6301
- const meta = await sharp20(engineImage).metadata();
8101
+ const meta = await sharp21(engineImage).metadata();
6302
8102
  const want = plan.width / plan.height;
6303
8103
  const got = meta.width && meta.height ? meta.width / meta.height : 0;
6304
8104
  const sameOrientation = got > 0 && got >= 1 === want >= 1;
6305
8105
  const aligned = sameOrientation;
6306
8106
  const exact = meta.width === plan.width && meta.height === plan.height;
6307
- const surround = aligned ? exact ? engineImage : await sharp20(engineImage).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).toBuffer() : await expandCanvasBedOnly(source, plan);
8107
+ const surround = aligned ? exact ? engineImage : await sharp21(engineImage).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).toBuffer() : await expandCanvasBedOnly(source, plan);
6308
8108
  const matched = aligned ? await matchMarginsToSeam(surround, source, plan) : surround;
6309
- const image = await sharp20(matched).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
8109
+ const image = await sharp21(matched).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
6310
8110
  return { image, aligned };
6311
8111
  }
6312
8112
  async function matchMarginsToSeam(surround, source, plan) {
6313
- const src = await sharp20(source).metadata();
8113
+ const src = await sharp21(source).metadata();
6314
8114
  if (!src.width || !src.height) return surround;
6315
8115
  const SW = src.width;
6316
8116
  const SH = src.height;
@@ -6357,8 +8157,8 @@ var MAX_CORRECTION = 60;
6357
8157
  async function reconcile(surround, source, side, axis) {
6358
8158
  const { margin } = side;
6359
8159
  if (margin.width < 1 || margin.height < 1) return surround;
6360
- const marginRaw = await sharp20(surround).extract(margin).removeAlpha().raw().toBuffer();
6361
- const edgeRaw = await sharp20(source).extract(side.srcEdge).removeAlpha().raw().toBuffer();
8160
+ const marginRaw = await sharp21(surround).extract(margin).removeAlpha().raw().toBuffer();
8161
+ const edgeRaw = await sharp21(source).extract(side.srcEdge).removeAlpha().raw().toBuffer();
6362
8162
  const W = margin.width;
6363
8163
  const H = margin.height;
6364
8164
  const along = axis === "width" ? H : W;
@@ -6412,11 +8212,11 @@ async function reconcile(surround, source, side, axis) {
6412
8212
  }
6413
8213
  }
6414
8214
  }
6415
- const patch2 = await sharp20(corrected, { raw: { width: W, height: H, channels: 3 } }).png().toBuffer();
6416
- return sharp20(surround).composite([{ input: patch2, left: margin.left, top: margin.top }]).toBuffer();
8215
+ const patch2 = await sharp21(corrected, { raw: { width: W, height: H, channels: 3 } }).png().toBuffer();
8216
+ return sharp21(surround).composite([{ input: patch2, left: margin.left, top: margin.top }]).toBuffer();
6417
8217
  }
6418
8218
  async function expandCanvasBedOnly(source, plan) {
6419
- return sharp20(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
8219
+ return sharp21(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
6420
8220
  }
6421
8221
  function medianOf(rgb, channel, from, to) {
6422
8222
  const n = to - from;
@@ -6427,17 +8227,17 @@ function medianOf(rgb, channel, from, to) {
6427
8227
  return n % 2 ? values[(n - 1) / 2] : (values[n / 2 - 1] + values[n / 2]) / 2;
6428
8228
  }
6429
8229
  async function reframeExpand(engineImage, plan) {
6430
- const meta = await sharp20(engineImage).metadata();
8230
+ const meta = await sharp21(engineImage).metadata();
6431
8231
  if (!(meta.width && meta.height)) return null;
6432
8232
  const want = plan.width / plan.height;
6433
8233
  const got = meta.width / meta.height;
6434
8234
  if (got >= 1 !== want >= 1) return null;
6435
8235
  if (meta.width === plan.width && meta.height === plan.height) return engineImage;
6436
8236
  const straight = Math.abs(got - want) / want <= 0.02;
6437
- return sharp20(engineImage).resize(plan.width, plan.height, { fit: straight ? "fill" : "cover", position: "centre" }).png().toBuffer();
8237
+ return sharp21(engineImage).resize(plan.width, plan.height, { fit: straight ? "fill" : "cover", position: "centre" }).png().toBuffer();
6438
8238
  }
6439
8239
  async function seamScore(image, plan, source) {
6440
- const { data, info } = await sharp20(image).removeAlpha().greyscale().raw().toBuffer({ resolveWithObject: true });
8240
+ const { data, info } = await sharp21(image).removeAlpha().greyscale().raw().toBuffer({ resolveWithObject: true });
6441
8241
  const W = info.width;
6442
8242
  const H = info.height;
6443
8243
  const horizontal = plan.axis === "width";
@@ -6470,7 +8270,7 @@ var SEAM_VISIBLE = 2.2;
6470
8270
  var OFFSET = 4;
6471
8271
  var RESIDUAL_VISIBLE = 15;
6472
8272
  async function seamResidual(image, plan, source) {
6473
- const { data, info } = await sharp20(image).removeAlpha().raw().toBuffer({ resolveWithObject: true });
8273
+ const { data, info } = await sharp21(image).removeAlpha().raw().toBuffer({ resolveWithObject: true });
6474
8274
  const W = info.width;
6475
8275
  const H = info.height;
6476
8276
  const ch = info.channels;
@@ -6505,7 +8305,7 @@ var MAX_SHARE = 0.8;
6505
8305
  async function subjectFraction(src, source, axis) {
6506
8306
  try {
6507
8307
  const window = axis === "width" ? { width: Math.max(8, Math.round(source.width * 0.5)), height: source.height } : { width: source.width, height: Math.max(8, Math.round(source.height * 0.5)) };
6508
- const { info } = await sharp20(src).resize(window.width, window.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
8308
+ const { info } = await sharp21(src).resize(window.width, window.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
6509
8309
  const offset = axis === "width" ? Math.abs(typeof info.cropOffsetLeft === "number" ? info.cropOffsetLeft : 0) : Math.abs(typeof info.cropOffsetTop === "number" ? info.cropOffsetTop : 0);
6510
8310
  const span = axis === "width" ? source.width : source.height;
6511
8311
  const extent = axis === "width" ? window.width : window.height;
@@ -6528,14 +8328,14 @@ function placeExpand(plan, source, fraction) {
6528
8328
  }
6529
8329
  var NEUTRAL = { r: 128, g: 128, b: 128 };
6530
8330
  async function conditioningCanvas(source, plan, fill = "edge") {
6531
- const meta = await sharp20(source).metadata();
8331
+ const meta = await sharp21(source).metadata();
6532
8332
  const sw = meta.width ?? 0;
6533
8333
  const sh = meta.height ?? 0;
6534
8334
  if (!(sw > 0 && sh > 0)) throw new Error("conditioningCanvas: source has no dimensions");
6535
8335
  const layers = [];
6536
8336
  if (fill === "edge") layers.push(...await edgeMargins(source, plan, { width: sw, height: sh }));
6537
8337
  layers.push({ input: source, left: plan.left, top: plan.top });
6538
- const canvas = sharp20({
8338
+ const canvas = sharp21({
6539
8339
  create: {
6540
8340
  width: plan.width,
6541
8341
  height: plan.height,
@@ -6547,7 +8347,7 @@ async function conditioningCanvas(source, plan, fill = "edge") {
6547
8347
  }
6548
8348
  async function edgeMargins(source, plan, size) {
6549
8349
  const out = [];
6550
- const strip = async (extract, width, height) => sharp20(source).extract(extract).resize(width, height, { fit: "fill" }).png().toBuffer();
8350
+ const strip = async (extract, width, height) => sharp21(source).extract(extract).resize(width, height, { fit: "fill" }).png().toBuffer();
6551
8351
  if (plan.axis === "width") {
6552
8352
  const before = plan.left;
6553
8353
  const after = plan.width - plan.left - size.width;
@@ -6638,12 +8438,12 @@ async function resolveOutpaintRoute(all, shot) {
6638
8438
  return { engine: shot, method: "reframe", crossed: false };
6639
8439
  }
6640
8440
  async function driftDiff(a, b) {
6641
- const metaA = await sharp20(a).metadata();
6642
- const metaB = await sharp20(b).metadata();
8441
+ const metaA = await sharp21(a).metadata();
8442
+ const metaB = await sharp21(b).metadata();
6643
8443
  const width = Math.min(metaA.width ?? 1, metaB.width ?? 1, 1024);
6644
8444
  const height = Math.min(metaA.height ?? 1, metaB.height ?? 1, 1024);
6645
8445
  const [rawA, rawB] = await Promise.all(
6646
- [a, b].map((buf) => sharp20(buf).resize(width, height, { fit: "cover" }).ensureAlpha().raw().toBuffer())
8446
+ [a, b].map((buf) => sharp21(buf).resize(width, height, { fit: "cover" }).ensureAlpha().raw().toBuffer())
6647
8447
  );
6648
8448
  const out = new PNG({ width, height });
6649
8449
  const changed = pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, diffColor: [255, 64, 64] });
@@ -6655,11 +8455,11 @@ async function driftDiff(a, b) {
6655
8455
  };
6656
8456
  }
6657
8457
  async function changeMask(a, b, cap2 = 1024) {
6658
- const metaA = await sharp20(a).metadata();
8458
+ const metaA = await sharp21(a).metadata();
6659
8459
  const width = Math.min(metaA.width ?? 1, cap2);
6660
8460
  const height = Math.min(metaA.height ?? 1, cap2);
6661
8461
  const [rawA, rawB] = await Promise.all(
6662
- [a, b].map((buf) => sharp20(buf).resize(width, height, { fit: "fill" }).ensureAlpha().raw().toBuffer())
8462
+ [a, b].map((buf) => sharp21(buf).resize(width, height, { fit: "fill" }).ensureAlpha().raw().toBuffer())
6663
8463
  );
6664
8464
  const out = new PNG({ width, height });
6665
8465
  pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, includeAA: true, diffMask: true });
@@ -6708,8 +8508,8 @@ function dilationFor(longEdge) {
6708
8508
  // src/localEdit.ts
6709
8509
  async function preserveOutsideChange(source, edited) {
6710
8510
  try {
6711
- const srcMeta = await sharp20(source).metadata();
6712
- const outMeta = await sharp20(edited).metadata();
8511
+ const srcMeta = await sharp21(source).metadata();
8512
+ const outMeta = await sharp21(edited).metadata();
6713
8513
  if (!srcMeta.width || !srcMeta.height || !outMeta.width || !outMeta.height)
6714
8514
  return { image: edited, outcome: "error", changed: 0 };
6715
8515
  const sameShape = Math.abs(outMeta.width / outMeta.height - srcMeta.width / srcMeta.height) / (srcMeta.width / srcMeta.height) <= 0.01;
@@ -6719,15 +8519,15 @@ async function preserveOutsideChange(source, edited) {
6719
8519
  if (outcome !== "composited") return { image: edited, outcome, changed: shape.changed };
6720
8520
  const r = dilationFor(Math.max(shape.width, shape.height));
6721
8521
  const rawShape = { raw: { width: shape.width, height: shape.height, channels: 1 } };
6722
- const spread = await sharp20(shape.mask, rawShape).blur(r).toColourspace("b-w").raw().toBuffer();
6723
- const dilated = await sharp20(spread, rawShape).threshold(8).toColourspace("b-w").raw().toBuffer();
6724
- const feathered = await sharp20(dilated, rawShape).blur(Math.max(2, r / 3)).toColourspace("b-w").raw().toBuffer();
6725
- const grown = await sharp20(feathered, rawShape).resize(srcMeta.width, srcMeta.height, { fit: "fill", kernel: "cubic" }).toColourspace("b-w").raw().toBuffer();
6726
- const editedRgb = await sharp20(edited).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).removeAlpha().raw().toBuffer();
6727
- const masked = await sharp20(editedRgb, {
8522
+ const spread = await sharp21(shape.mask, rawShape).blur(r).toColourspace("b-w").raw().toBuffer();
8523
+ const dilated = await sharp21(spread, rawShape).threshold(8).toColourspace("b-w").raw().toBuffer();
8524
+ const feathered = await sharp21(dilated, rawShape).blur(Math.max(2, r / 3)).toColourspace("b-w").raw().toBuffer();
8525
+ const grown = await sharp21(feathered, rawShape).resize(srcMeta.width, srcMeta.height, { fit: "fill", kernel: "cubic" }).toColourspace("b-w").raw().toBuffer();
8526
+ const editedRgb = await sharp21(edited).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).removeAlpha().raw().toBuffer();
8527
+ const masked = await sharp21(editedRgb, {
6728
8528
  raw: { width: srcMeta.width, height: srcMeta.height, channels: 3 }
6729
8529
  }).joinChannel(grown, { raw: { width: srcMeta.width, height: srcMeta.height, channels: 1 } }).png().toBuffer();
6730
- const image = await sharp20(source).removeAlpha().composite([{ input: masked }]).png().toBuffer();
8530
+ const image = await sharp21(source).removeAlpha().composite([{ input: masked }]).png().toBuffer();
6731
8531
  return { image, outcome: "composited", changed: shape.changed };
6732
8532
  } catch {
6733
8533
  return { image: edited, outcome: "error", changed: 0 };
@@ -6765,7 +8565,7 @@ function registerLogoRoutes(app, deps) {
6765
8565
  const v = validateBrand(json);
6766
8566
  if (!v.valid) return reply.status(400).send({ error: "brand became invalid", details: v.errors });
6767
8567
  const row = core.store.updateBrand(brand.id, json);
6768
- const meta = await sharp20(core.images.read(part.hash)).metadata().catch(() => null);
8568
+ const meta = await sharp21(core.images.read(part.hash)).metadata().catch(() => null);
6769
8569
  const logoEdge = meta ? Math.max(meta.width ?? 0, meta.height ?? 0) || null : null;
6770
8570
  return { ...row, logoHash: part.hash, logoEdge };
6771
8571
  });
@@ -6813,6 +8613,38 @@ function registerLogoRoutes(app, deps) {
6813
8613
  return core.store.updateBrand(brand.id, json);
6814
8614
  });
6815
8615
  }
8616
+ var scans = /* @__PURE__ */ new Map();
8617
+ var controllers = /* @__PURE__ */ new Map();
8618
+ var KEEP = 32;
8619
+ function remember(state) {
8620
+ scans.set(state.id, state);
8621
+ while (scans.size > KEEP) {
8622
+ const oldest = scans.keys().next().value;
8623
+ if (oldest === void 0) break;
8624
+ scans.delete(oldest);
8625
+ controllers.delete(oldest);
8626
+ }
8627
+ }
8628
+ function getScan(scanId) {
8629
+ return scans.get(scanId);
8630
+ }
8631
+ function startCatalogScan(deps, brandId, url) {
8632
+ const { core, fetchImpl } = deps;
8633
+ if (!core.store.getBrand(brandId)) throw Object.assign(new Error("brand not found"), { statusCode: 404 });
8634
+ const id = randomUUID();
8635
+ const ctrl = new AbortController();
8636
+ const state = { id, brandId, url, status: "running", startedAt: Date.now() };
8637
+ remember(state);
8638
+ controllers.set(id, ctrl);
8639
+ void scanForCandidates({ url, fetchImpl, signal: ctrl.signal }).then((result) => {
8640
+ state.result = result;
8641
+ state.status = "done";
8642
+ }).catch((err) => {
8643
+ state.status = "error";
8644
+ state.error = err instanceof Error ? err.message : String(err);
8645
+ }).finally(() => controllers.delete(id));
8646
+ return { scanId: id };
8647
+ }
6816
8648
 
6817
8649
  // src/routes/catalogImport.ts
6818
8650
  function registerCatalogImportRoutes(app, deps) {
@@ -6820,23 +8652,104 @@ function registerCatalogImportRoutes(app, deps) {
6820
8652
  app.get("/api/brands/:id/products-library", async (req, reply) => {
6821
8653
  const brand = core.store.getBrand(req.params.id);
6822
8654
  if (!brand) return reply.status(404).send({ error: "brand not found" });
6823
- const products = core.catalog.listLibraryProducts(brand.id, brand.json);
8655
+ const products = core.catalog.listLibraryIndex(brand.id, brand.json);
6824
8656
  const source = core.catalog.getSourceForBrand(brand.id);
6825
8657
  return { products, source };
6826
8658
  });
8659
+ app.get("/api/brands/:id/products-library/:productId", async (req, reply) => {
8660
+ const brand = core.store.getBrand(req.params.id);
8661
+ if (!brand) return reply.status(404).send({ error: "brand not found" });
8662
+ const product = core.catalog.libraryProduct(brand.id, brand.json, String(req.params.productId));
8663
+ if (!product) return reply.status(404).send({ error: "product not found" });
8664
+ return { product };
8665
+ });
6827
8666
  app.get("/api/brands/:id/catalog/source", async (req, reply) => {
6828
8667
  const brand = core.store.getBrand(req.params.id);
6829
8668
  if (!brand) return reply.status(404).send({ error: "brand not found" });
6830
8669
  return { source: core.catalog.getSourceForBrand(brand.id) };
6831
8670
  });
8671
+ app.post("/api/brands/:id/catalog/scan", async (req, reply) => {
8672
+ const brandId = req.params.id;
8673
+ const brand = core.store.getBrand(brandId);
8674
+ if (!brand) return reply.status(404).send({ error: "brand not found" });
8675
+ const url = String(req.body?.url ?? brand.json?.meta?.website ?? "");
8676
+ if (!url.trim()) return reply.status(400).send({ error: "url required" });
8677
+ try {
8678
+ return startCatalogScan({ core, fetchImpl }, brandId, url);
8679
+ } catch (err) {
8680
+ return reply.status(err.statusCode ?? 500).send({ error: err.message ?? "scan failed" });
8681
+ }
8682
+ });
8683
+ app.get("/api/brands/:id/catalog/scans/:scanId", async (req, reply) => {
8684
+ const brandId = req.params.id;
8685
+ const scan = getScan(req.params.scanId);
8686
+ if (!scan || scan.brandId !== brandId) return reply.status(404).send({ error: "scan not found" });
8687
+ return scan;
8688
+ });
8689
+ app.post("/api/brands/:id/catalog/details", async (req, reply) => {
8690
+ const brand = core.store.getBrand(req.params.id);
8691
+ if (!brand) return reply.status(404).send({ error: "brand not found" });
8692
+ const asked = req.body?.urls;
8693
+ if (!Array.isArray(asked) || asked.some((u) => typeof u !== "string")) {
8694
+ return reply.status(400).send({ error: "urls must be a list of product addresses" });
8695
+ }
8696
+ const urls = asked.slice(0, 48);
8697
+ if (!urls.length) return { products: [] };
8698
+ const site = String(brand.json?.meta?.website ?? "");
8699
+ let origin;
8700
+ try {
8701
+ origin = new URL(site.startsWith("http") ? site : `https://${site}`).origin;
8702
+ } catch {
8703
+ return reply.status(400).send({ error: "this brand has no website to read products from" });
8704
+ }
8705
+ const sameSite = urls.filter((u) => {
8706
+ try {
8707
+ return new URL(u).origin === origin;
8708
+ } catch {
8709
+ return false;
8710
+ }
8711
+ });
8712
+ if (!sameSite.length) {
8713
+ return reply.status(400).send({ error: "none of those products belong to this site" });
8714
+ }
8715
+ const products = await fetchProductPages({ fetchImpl: fetchImpl ?? fetch, baseUrl: origin }, sameSite, {
8716
+ concurrency: 4,
8717
+ maxBytes: 15e5
8718
+ });
8719
+ return { products };
8720
+ });
6832
8721
  app.post("/api/brands/:id/catalog/import", async (req, reply) => {
6833
8722
  const brandId = req.params.id;
6834
8723
  const brand = core.store.getBrand(brandId);
6835
8724
  if (!brand) return reply.status(404).send({ error: "brand not found" });
6836
8725
  const url = String(req.body?.url ?? brand.json?.meta?.website ?? "");
6837
8726
  if (!url.trim()) return reply.status(400).send({ error: "url required" });
8727
+ const asked = req.body?.urls;
8728
+ let only;
8729
+ if (asked != null) {
8730
+ if (!Array.isArray(asked) || asked.some((u) => typeof u !== "string")) {
8731
+ return reply.status(400).send({ error: "urls must be a list of product addresses" });
8732
+ }
8733
+ let origin;
8734
+ try {
8735
+ origin = new URL(url.startsWith("http") ? url : `https://${url}`).origin;
8736
+ } catch {
8737
+ return reply.status(400).send({ error: "url required" });
8738
+ }
8739
+ only = asked.filter((u) => {
8740
+ try {
8741
+ return new URL(u).origin === origin;
8742
+ } catch {
8743
+ return false;
8744
+ }
8745
+ });
8746
+ if (!only.length) return reply.status(400).send({ error: "none of those products belong to this site" });
8747
+ if (only.length > 2e3) {
8748
+ return reply.status(400).send({ error: "That is nearly the whole catalogue. Import everything instead." });
8749
+ }
8750
+ }
6838
8751
  try {
6839
- return startCatalogImport({ core, fetchImpl }, brandId, url);
8752
+ return startCatalogImport({ core, fetchImpl }, brandId, url, { only });
6840
8753
  } catch (err) {
6841
8754
  return reply.status(err.statusCode ?? 500).send({ error: err.message ?? "import failed" });
6842
8755
  }
@@ -6887,7 +8800,7 @@ async function vibrantColor(input) {
6887
8800
  let data;
6888
8801
  let channels;
6889
8802
  try {
6890
- const out = await sharp20(input).resize(48, 48, { fit: "inside" }).raw().toBuffer({ resolveWithObject: true });
8803
+ const out = await sharp21(input).resize(48, 48, { fit: "inside" }).raw().toBuffer({ resolveWithObject: true });
6891
8804
  data = out.data;
6892
8805
  channels = out.info.channels;
6893
8806
  } catch {
@@ -6910,13 +8823,13 @@ async function vibrantColor(input) {
6910
8823
  const best = buckets.reduce((a, b) => b.score > a.score ? b : a, buckets[0]);
6911
8824
  if (best.score <= 0) {
6912
8825
  try {
6913
- const { dominant } = await sharp20(input).stats();
6914
- return toHex(dominant.r, dominant.g, dominant.b);
8826
+ const { dominant } = await sharp21(input).stats();
8827
+ return toHex2(dominant.r, dominant.g, dominant.b);
6915
8828
  } catch {
6916
8829
  return null;
6917
8830
  }
6918
8831
  }
6919
- return toHex(best.r / best.score, best.g / best.score, best.b / best.score);
8832
+ return toHex2(best.r / best.score, best.g / best.score, best.b / best.score);
6920
8833
  }
6921
8834
  function rgbToHsl(r, g, b) {
6922
8835
  const rn = r / 255, gn = g / 255, bn = b / 255;
@@ -6931,7 +8844,7 @@ function rgbToHsl(r, g, b) {
6931
8844
  else h = ((rn - gn) / d + 4) * 60;
6932
8845
  return { h, s, l };
6933
8846
  }
6934
- var toHex = (r, g, b) => "#" + [r, g, b].map(
8847
+ var toHex2 = (r, g, b) => "#" + [r, g, b].map(
6935
8848
  (v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, "0")
6936
8849
  ).join("");
6937
8850
 
@@ -7454,7 +9367,7 @@ function registerProjectRoutes(app, deps) {
7454
9367
  const brand = core.store.getBrand(req.params.id);
7455
9368
  if (!brand) return reply.status(404).send({ error: "brand not found" });
7456
9369
  const limit = Math.min(Number(req.query.limit) || 60, 200);
7457
- return { nodes: core.store.recentActivity(brand.id, limit), jobs: core.catalog.listJobs(brand.id) };
9370
+ return { nodes: core.store.recentActivity(brand.id, limit), jobs: core.catalog.listRecentJobs(brand.id) };
7458
9371
  });
7459
9372
  app.get("/api/brands/:id/workspace", async (req, reply) => {
7460
9373
  const brand = core.store.getBrand(req.params.id);
@@ -7563,7 +9476,10 @@ function registerProjectRoutes(app, deps) {
7563
9476
  function registerCodexSetupRoutes(app, deps) {
7564
9477
  const codexSetup = deps.codexSetup ?? createCodexSetup({ runner: deps.codexRunner });
7565
9478
  let codexSetupBusy = null;
7566
- app.get("/api/engines/codex/status", async () => codexSetup.status());
9479
+ app.get("/api/engines/codex/status", async (req) => {
9480
+ const force = req.query?.force === "1";
9481
+ return codexSetup.status({ force });
9482
+ });
7567
9483
  app.post("/api/engines/codex/install", async (_req, reply) => {
7568
9484
  if (codexSetupBusy) return reply.status(409).send({ error: `already running: ${codexSetupBusy}` });
7569
9485
  codexSetupBusy = "install";
@@ -7586,6 +9502,35 @@ function registerCodexSetupRoutes(app, deps) {
7586
9502
  codexSetupBusy = null;
7587
9503
  }
7588
9504
  });
9505
+ app.post("/api/engines/codex/repair-env", async (req, reply) => {
9506
+ const repair = deps.envRepair;
9507
+ if (!repair) return reply.status(501).send({ error: "not available on this server" });
9508
+ const asked = req.body?.keys;
9509
+ if (!Array.isArray(asked) || asked.length === 0) return reply.status(400).send({ error: "keys required" });
9510
+ const keys = asked.map((k) => String(k).trim().toUpperCase());
9511
+ const unknown = keys.filter((k) => !CONFLICT_ENV_KEYS.includes(k));
9512
+ if (unknown.length > 0) return reply.status(400).send({ error: `not a Codex credential: ${unknown.join(", ")}` });
9513
+ if (codexSetupBusy) return reply.status(409).send({ error: `already running: ${codexSetupBusy}` });
9514
+ codexSetupBusy = "repair";
9515
+ try {
9516
+ repair.set([.../* @__PURE__ */ new Set([...repair.get(), ...keys])]);
9517
+ return { ok: true, ...await codexSetup.status({ force: true }) };
9518
+ } finally {
9519
+ codexSetupBusy = null;
9520
+ }
9521
+ });
9522
+ app.post("/api/engines/codex/restore-env", async (_req, reply) => {
9523
+ const repair = deps.envRepair;
9524
+ if (!repair) return reply.status(501).send({ error: "not available on this server" });
9525
+ if (codexSetupBusy) return reply.status(409).send({ error: `already running: ${codexSetupBusy}` });
9526
+ codexSetupBusy = "repair";
9527
+ try {
9528
+ repair.set([]);
9529
+ return { ok: true, ...await codexSetup.status({ force: true }) };
9530
+ } finally {
9531
+ codexSetupBusy = null;
9532
+ }
9533
+ });
7589
9534
  }
7590
9535
  var slug = (v, fallback) => {
7591
9536
  const s = String(v ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
@@ -7766,8 +9711,8 @@ function registerImageRoutes(app, deps) {
7766
9711
  if (!part) return reply.status(400).send({ error: "multipart file field required" });
7767
9712
  const buf = await part.toBuffer();
7768
9713
  if (buf.length === 0) return reply.status(400).send({ error: "empty file" });
7769
- const fmt = (await sharp20(buf).metadata().catch(() => null))?.format;
7770
- const png = fmt === "svg" ? await toMarkPng(buf) : await sharp20(buf).rotate().png().toBuffer();
9714
+ const fmt = (await sharp21(buf).metadata().catch(() => null))?.format;
9715
+ const png = fmt === "svg" ? await toMarkPng(buf) : await sharp21(buf).rotate().png().toBuffer();
7771
9716
  return { hash: core.images.save(png) };
7772
9717
  });
7773
9718
  app.post("/api/diff", async (req, reply) => {
@@ -7789,6 +9734,39 @@ function registerImageRoutes(app, deps) {
7789
9734
 
7790
9735
  // src/release/notes.data.ts
7791
9736
  var RELEASES = [
9737
+ {
9738
+ version: "0.9.3",
9739
+ date: "2026-09-14",
9740
+ title: "Paste a website, get the brand and its products.",
9741
+ sections: [
9742
+ {
9743
+ heading: "Brand",
9744
+ body: "A website address is enough to start a brand. Scenri reads the logo, the wordmark and the palette a site actually declares, and says plainly what it found and what it could not."
9745
+ },
9746
+ {
9747
+ heading: "Products",
9748
+ body: "The same address brings in the store. Pick the products you want or take the whole catalogue, and watch them arrive: each one appears in your library as it lands, with its picture, newest first. A large store no longer runs out of memory partway through, and a shop that stops answering says so instead of looking empty."
9749
+ },
9750
+ {
9751
+ heading: "Create",
9752
+ body: "The insert menus reach everything. Typing $, @, / or # opens a shortlist that pages through the rest of that catalogue, with a count so nothing is quietly cut off, and the box stays where the caret is."
9753
+ },
9754
+ {
9755
+ heading: "Fixes",
9756
+ body: "A first run on Windows no longer stops at the desktop icon question, and never asks for an administrator it does not need. Codex reports itself ready only once a real run has authenticated."
9757
+ }
9758
+ ]
9759
+ },
9760
+ {
9761
+ version: "0.9.2",
9762
+ date: "2026-09-07",
9763
+ sections: [
9764
+ {
9765
+ heading: "Fixes",
9766
+ body: "If the Codex app has switched to a model your Codex CLI is too old for, a shot now stops with a plain sentence naming the version and the model, and the setup dialog opens on the update step rather than a green check. Update the CLI once and generation resumes."
9767
+ }
9768
+ ]
9769
+ },
7792
9770
  {
7793
9771
  version: "0.9.1",
7794
9772
  date: "2026-09-07",
@@ -8749,15 +10727,15 @@ function registerDesktopRoutes(app, deps) {
8749
10727
  record: null
8750
10728
  };
8751
10729
  }
8752
- const { installDeps } = await import('./cli-FYWM4HBN.js');
8753
- const { desktopStatus } = await import('./install-4W6AARK6.js');
10730
+ const { installDeps } = await import('./cli-SX46UQO6.js');
10731
+ const { desktopStatus } = await import('./install-VXJPLYWM.js');
8754
10732
  return desktopStatus(installDeps(runtime.entry));
8755
10733
  });
8756
10734
  const install = deps.installImpl ?? (async () => {
8757
10735
  if (!runtime.entry) {
8758
10736
  return { ok: false, reason: "unsupported", message: "Desktop shortcuts are not available on this system yet." };
8759
10737
  }
8760
- const { addToDesktop } = await import('./cli-FYWM4HBN.js');
10738
+ const { addToDesktop } = await import('./cli-SX46UQO6.js');
8761
10739
  return addToDesktop(runtime.entry);
8762
10740
  });
8763
10741
  app.get("/api/desktop", async () => {
@@ -8822,6 +10800,7 @@ function buildServer(opts) {
8822
10800
  registerAccessGuard(app, opts.access);
8823
10801
  const reserved = /* @__PURE__ */ new Map();
8824
10802
  const runningGenerations = /* @__PURE__ */ new Map();
10803
+ core.catalog.reconcileInterruptedJobs();
8825
10804
  const thumbs = createThumbStore(core);
8826
10805
  const { scenes } = loadScenes(opts.templatesDir);
8827
10806
  const resolveScene = sceneResolver(scenes);
@@ -8831,8 +10810,11 @@ function buildServer(opts) {
8831
10810
  const e = err;
8832
10811
  const status = err instanceof SpendCapError ? 402 : e.statusCode ?? 500;
8833
10812
  const leaksPath = typeof e.code === "string" && /^(ENOENT|EACCES|EPERM|EISDIR|ENOTDIR)$/.test(e.code);
8834
- reply.status(status).send({ error: leaksPath ? "unexpected error" : e.message ?? "unexpected error" });
10813
+ const rawRuntime = !e.statusCode && (err instanceof TypeError || err instanceof RangeError);
10814
+ if (rawRuntime) console.error("unexpected error:", err);
10815
+ reply.status(status).send({ error: leaksPath || rawRuntime ? "unexpected error" : e.message ?? "unexpected error" });
8835
10816
  });
10817
+ const scrapeGuard = () => ({ allowPrivateHosts: process.env.SCENRI_SCRAPE_ALLOW_PRIVATE === "1" });
8836
10818
  app.get("/api/brands", async () => core.store.listBrands());
8837
10819
  app.post("/api/brands", async (req, reply) => {
8838
10820
  const json = req.body?.brand;
@@ -8841,10 +10823,12 @@ function buildServer(opts) {
8841
10823
  return core.store.createBrand(json);
8842
10824
  });
8843
10825
  app.post("/api/brands/from-url", async (req, reply) => {
8844
- const url = String(req.body?.url ?? "");
8845
- if (!/^https?:\/\//.test(url)) return reply.status(400).send({ error: "url must be http(s)" });
8846
- const { brand, warnings } = await buildFromUrl(url, {
10826
+ const asked = normalizeSiteUrl(req.body?.url);
10827
+ if (!asked.ok) return reply.status(400).send({ error: asked.message });
10828
+ const url = asked.url;
10829
+ const { brand, warnings, report } = await buildFromUrl(url, {
8847
10830
  fetchImpl: opts.fetchImpl,
10831
+ guard: scrapeGuard(),
8848
10832
  // The store names every blob `<hash>.png` and /api/images/:hash always
8849
10833
  // serves image/png, so an un-normalized .ico or .svg here is a file lying
8850
10834
  // about its own format — broken in the marks grid, and mislabelled to any
@@ -8852,14 +10836,11 @@ function buildServer(opts) {
8852
10836
  saveAsset: async (buf) => `asset:${core.images.save(await toMarkPng(buf))}`,
8853
10837
  // Measured as stored (post-toMarkPng), so the scrape judges the same
8854
10838
  // pixels the compiler will one day attach.
8855
- probeLongEdge: async (buf) => {
8856
- const m = await sharp20(await toMarkPng(buf)).metadata();
8857
- return Math.max(m.width ?? 0, m.height ?? 0) || null;
8858
- },
10839
+ inspectMark: (buf) => inspectMark(buf, toMarkPng),
8859
10840
  createdWith: `${meta.name}/${meta.version}`
8860
10841
  });
8861
10842
  const row = core.store.createBrand(brand);
8862
- return { ...row, warnings };
10843
+ return { ...row, warnings, report };
8863
10844
  });
8864
10845
  app.put("/api/brands/:id", async (req, reply) => {
8865
10846
  const json = req.body?.brand;
@@ -8936,15 +10917,14 @@ function buildServer(opts) {
8936
10917
  app.post("/api/brands/:id/refresh-from-url", async (req, reply) => {
8937
10918
  const brand = core.store.getBrand(req.params.id);
8938
10919
  if (!brand) return reply.status(404).send({ error: "brand not found" });
8939
- const url = String(req.body?.url ?? brand.json?.meta?.website ?? "");
8940
- if (!/^https?:\/\//.test(url)) return reply.status(400).send({ error: "url must be http(s)" });
10920
+ const asked = normalizeSiteUrl(req.body?.url ?? brand.json?.meta?.website);
10921
+ if (!asked.ok) return reply.status(400).send({ error: asked.message });
10922
+ const url = asked.url;
8941
10923
  const { brand: scraped, warnings } = await buildFromUrl(url, {
8942
10924
  fetchImpl: opts.fetchImpl,
10925
+ guard: scrapeGuard(),
8943
10926
  saveAsset: async (buf) => `asset:${core.images.save(await toMarkPng(buf))}`,
8944
- probeLongEdge: async (buf) => {
8945
- const m = await sharp20(await toMarkPng(buf)).metadata();
8946
- return Math.max(m.width ?? 0, m.height ?? 0) || null;
8947
- },
10927
+ inspectMark: (buf) => inspectMark(buf, toMarkPng),
8948
10928
  createdWith: `${meta.name}/${meta.version}`
8949
10929
  });
8950
10930
  const { brand: merged, suggestions } = mergeScrape(brand.json, scraped);
@@ -9252,7 +11232,14 @@ function buildServer(opts) {
9252
11232
  ],
9253
11233
  engineNames: () => engines.all().map((e) => ({ id: e.capabilities().id, name: e.capabilities().displayName }))
9254
11234
  });
9255
- registerCodexSetupRoutes(app, { codexSetup: opts.codexSetup, codexRunner: engines.codexRunner });
11235
+ registerCodexSetupRoutes(app, {
11236
+ codexSetup: opts.codexSetup,
11237
+ codexRunner: engines.codexRunner,
11238
+ envRepair: {
11239
+ get: () => [...ignoreEnvKeysGetter(core)()],
11240
+ set: (keys) => core.store.setSetting(IGNORE_ENV_KEYS_SETTING, keys.join(","))
11241
+ }
11242
+ });
9256
11243
  app.get("/api/engines", async () => {
9257
11244
  const list2 = [];
9258
11245
  for (const e of engines.all()) {
@@ -9315,11 +11302,11 @@ function buildServer(opts) {
9315
11302
  const out = [];
9316
11303
  for (const h of images) {
9317
11304
  const buf = core.images.read(h);
9318
- const meta2 = await sharp20(buf).metadata().catch(() => null);
11305
+ const meta2 = await sharp21(buf).metadata().catch(() => null);
9319
11306
  if (!meta2?.width || !meta2.height) throw new Error("engine returned an undecodable image");
9320
11307
  const oriented = (meta2.orientation ?? 1) !== 1;
9321
11308
  out.push(
9322
- buf.subarray(0, 8).equals(PNG_SIG) && !oriented ? h : core.images.save(await sharp20(buf).rotate().png().toBuffer())
11309
+ buf.subarray(0, 8).equals(PNG_SIG) && !oriented ? h : core.images.save(await sharp21(buf).rotate().png().toBuffer())
9323
11310
  );
9324
11311
  }
9325
11312
  return out;
@@ -9331,7 +11318,7 @@ function buildServer(opts) {
9331
11318
  const out = [];
9332
11319
  for (const h of images) {
9333
11320
  const buf = core.images.read(h);
9334
- const meta2 = await sharp20(buf).metadata();
11321
+ const meta2 = await sharp21(buf).metadata();
9335
11322
  if (!meta2.width || !meta2.height) {
9336
11323
  out.push(h);
9337
11324
  continue;
@@ -9344,7 +11331,7 @@ function buildServer(opts) {
9344
11331
  }
9345
11332
  const w = got > target ? Math.round(meta2.height * target) : meta2.width;
9346
11333
  const hpx = got > target ? meta2.height : Math.round(meta2.width / target);
9347
- const cropped = await sharp20(buf).resize(w, hpx, { fit: "cover", position: "attention" }).png().toBuffer();
11334
+ const cropped = await sharp21(buf).resize(w, hpx, { fit: "cover", position: "attention" }).png().toBuffer();
9348
11335
  app.log.info(
9349
11336
  { nodeId, got: `${meta2.width}x${meta2.height}`, want: `${w}x${hpx}` },
9350
11337
  "canvas: cropped a drifted frame to the asked ratio"
@@ -9363,7 +11350,7 @@ function buildServer(opts) {
9363
11350
  async function assertAspect(images, expect) {
9364
11351
  const want = expect.width / expect.height;
9365
11352
  for (const h of images) {
9366
- const meta2 = await sharp20(core.images.read(h)).metadata();
11353
+ const meta2 = await sharp21(core.images.read(h)).metadata();
9367
11354
  if (!meta2.width || !meta2.height) continue;
9368
11355
  const got = meta2.width / meta2.height;
9369
11356
  if (Math.abs(got - want) / want > ASPECT_TOLERANCE)
@@ -9394,7 +11381,7 @@ function buildServer(opts) {
9394
11381
  if (post) own = await post(own);
9395
11382
  if (expect) await assertAspect(own, expect);
9396
11383
  try {
9397
- const meta2 = await sharp20(core.images.read(own[0])).metadata();
11384
+ const meta2 = await sharp21(core.images.read(own[0])).metadata();
9398
11385
  const node = core.store.getNode(id);
9399
11386
  if (node && meta2.width && meta2.height) {
9400
11387
  const brief = node.brief ?? {};
@@ -9503,7 +11490,7 @@ function buildServer(opts) {
9503
11490
  crop: window
9504
11491
  });
9505
11492
  const work2 = async () => ({
9506
- images: [core.images.save(await sharp20(args.srcBuf).extract(window).png().toBuffer())],
11493
+ images: [core.images.save(await sharp21(args.srcBuf).extract(window).png().toBuffer())],
9507
11494
  costUsd: 0
9508
11495
  });
9509
11496
  void runNode([node2.id], null, 0, work2, { width: plan2.width, height: plan2.height }).catch(
@@ -9528,7 +11515,7 @@ function buildServer(opts) {
9528
11515
  if (!srcHash || !core.images.has(String(srcHash)))
9529
11516
  return reply.status(400).send({ error: "edit needs a parent node with an image (sourceImage)" });
9530
11517
  const srcBuf = core.images.read(String(srcHash));
9531
- const srcMeta = await sharp20(srcBuf).metadata();
11518
+ const srcMeta = await sharp21(srcBuf).metadata();
9532
11519
  if (!srcMeta.width || !srcMeta.height) return reply.status(400).send({ error: "source image unreadable" });
9533
11520
  return runCropNode({
9534
11521
  parentId: cropParentId,
@@ -9743,7 +11730,7 @@ function buildServer(opts) {
9743
11730
  );
9744
11731
  }
9745
11732
  const srcBuf = core.images.read(String(srcHash));
9746
- const srcMeta = await sharp20(srcBuf).metadata();
11733
+ const srcMeta = await sharp21(srcBuf).metadata();
9747
11734
  if (srcMeta.width && srcMeta.height) expectShape = { width: srcMeta.width, height: srcMeta.height };
9748
11735
  const parentFormat = parent?.brief?.tokens?.find((t) => t?.t === "format");
9749
11736
  const parentNominal = parentFormat && Number(parentFormat.w) > 0 && Number(parentFormat.h) > 0 ? { width: Number(parentFormat.w), height: Number(parentFormat.h) } : null;
@@ -9777,7 +11764,7 @@ function buildServer(opts) {
9777
11764
  } else if (decision.op === "extend") {
9778
11765
  if (decision.assist) {
9779
11766
  expandAssist = { width: decision.assist.width, height: decision.assist.height };
9780
- workBuf = await sharp20(srcBuf).extract(decision.assist).png().toBuffer();
11767
+ workBuf = await sharp21(srcBuf).extract(decision.assist).png().toBuffer();
9781
11768
  workSize = { width: decision.assist.width, height: decision.assist.height };
9782
11769
  }
9783
11770
  expandPlan = planExpand(workSize, targetRatio);
@@ -9798,7 +11785,7 @@ function buildServer(opts) {
9798
11785
  const fit = fitExpandToBudget(expandPlan, workSize, runEngine.capabilities().editPixelBudget);
9799
11786
  if (fit.scale < 1) {
9800
11787
  expandPlan = fit.plan;
9801
- workBuf = await sharp20(workBuf).resize(fit.source.width, fit.source.height, { fit: "fill", kernel: "lanczos3" }).png().toBuffer();
11788
+ workBuf = await sharp21(workBuf).resize(fit.source.width, fit.source.height, { fit: "fill", kernel: "lanczos3" }).png().toBuffer();
9802
11789
  workSize = fit.source;
9803
11790
  extraWarnings.push(
9804
11791
  `${runEngine.capabilities().displayName} draws about ${((runEngine.capabilities().editPixelBudget ?? 0) / 1e6).toFixed(1)} megapixels, so this shape continues as a ${fit.plan.width}x${fit.plan.height} frame with the photograph riding inside it at ${fit.source.width}x${fit.source.height}. Nothing is upscaled; the stored size is the size the engine truly drew.`
@@ -9821,7 +11808,7 @@ function buildServer(opts) {
9821
11808
  if (editPixelBudget && stepped && (stepped.width !== srcMeta.width || stepped.height !== srcMeta.height)) {
9822
11809
  sentSize = stepped;
9823
11810
  budgetSourceHash = core.images.save(
9824
- await sharp20(srcBuf).resize(sentSize.width, sentSize.height, { fit: "fill", kernel: "lanczos3" }).png().toBuffer()
11811
+ await sharp21(srcBuf).resize(sentSize.width, sentSize.height, { fit: "fill", kernel: "lanczos3" }).png().toBuffer()
9825
11812
  );
9826
11813
  if (!gradeOnlyAsk)
9827
11814
  extraWarnings.push(
@@ -9961,11 +11948,11 @@ function buildServer(opts) {
9961
11948
  const original = editedFrom ? core.images.read(editedFrom) : null;
9962
11949
  const localScope = kind === "edit" && !plan && editScope === "local" && original;
9963
11950
  const enforceEditCanvas = async (images) => {
9964
- const srcMeta = await sharp20(original).metadata();
11951
+ const srcMeta = await sharp21(original).metadata();
9965
11952
  if (!srcMeta.width || !srcMeta.height) return images;
9966
11953
  const out = [];
9967
11954
  for (const h of images) {
9968
- const meta2 = await sharp20(core.images.read(h)).metadata();
11955
+ const meta2 = await sharp21(core.images.read(h)).metadata();
9969
11956
  const got = { width: meta2.width ?? 0, height: meta2.height ?? 0 };
9970
11957
  const verdict = judgeEditSize({ width: srcMeta.width, height: srcMeta.height }, got, {
9971
11958
  pixelBudget: runEngine.capabilities().editPixelBudget
@@ -9995,7 +11982,7 @@ function buildServer(opts) {
9995
11982
  );
9996
11983
  out.push(
9997
11984
  core.images.save(
9998
- await sharp20(core.images.read(h)).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).png().toBuffer()
11985
+ await sharp21(core.images.read(h)).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).png().toBuffer()
9999
11986
  )
10000
11987
  );
10001
11988
  try {
@@ -10017,7 +12004,7 @@ function buildServer(opts) {
10017
12004
  const out = [];
10018
12005
  for (const h of images) {
10019
12006
  const answer = core.images.read(h);
10020
- const got = await sharp20(answer).metadata();
12007
+ const got = await sharp21(answer).metadata();
10021
12008
  if (got.width !== plan.width || got.height !== plan.height)
10022
12009
  app.log.info(
10023
12010
  { nodeId: node.id, got: `${got.width}x${got.height}`, want: `${plan.width}x${plan.height}` },
@@ -10178,6 +12165,7 @@ function buildServer(opts) {
10178
12165
  while (runningGenerations.size > 0 && Date.now() < deadline) {
10179
12166
  await new Promise((r) => setTimeout(r, 25));
10180
12167
  }
12168
+ await settleCatalogImports();
10181
12169
  await thumbs.settle();
10182
12170
  await app.close();
10183
12171
  core.close();
@@ -10350,9 +12338,9 @@ async function run() {
10350
12338
  }
10351
12339
  }
10352
12340
  const ownEntry = fileURLToPath(import.meta.url);
10353
- const { addToDesktop, installDeps } = await import('./cli-FYWM4HBN.js');
10354
- const { refreshLauncher } = await import('./refresh-FKQABJOC.js');
10355
- const { askOnTerminal, offerDesktop, shouldOfferDesktop } = await import('./offer-IVTNHIDJ.js');
12341
+ const { addToDesktop, installDeps } = await import('./cli-SX46UQO6.js');
12342
+ const { refreshLauncher } = await import('./refresh-5HBDLLLB.js');
12343
+ const { askOnTerminal, offerDesktop, shouldOfferDesktop } = await import('./offer-W6NJXMWT.js');
10356
12344
  const { launcherInstalled } = await import('./paths-6ZXRZUHY.js');
10357
12345
  const meta = readMeta();
10358
12346
  void refreshLauncher({ ...installDeps(ownEntry), ownEntry, installKind, pkg: meta.name }).then((r) => {
@@ -10374,7 +12362,7 @@ async function run() {
10374
12362
  add: () => addToDesktop(ownEntry),
10375
12363
  decline: () => core.store.setSetting("desktop.prompt", "declined"),
10376
12364
  say: (line) => console.log(line)
10377
- });
12365
+ }).catch(() => console.log(" Could not ask about the desktop icon. Scenri is running anyway."));
10378
12366
  console.log("");
10379
12367
  }
10380
12368
  }
@@ -10384,8 +12372,8 @@ async function verify() {
10384
12372
  const db = new Database(":memory:");
10385
12373
  db.pragma("user_version");
10386
12374
  db.close();
10387
- const { default: sharp21 } = await import('sharp');
10388
- await sharp21({ create: { width: 1, height: 1, channels: 3, background: "#000" } }).png().toBuffer();
12375
+ const { default: sharp22 } = await import('sharp');
12376
+ await sharp22({ create: { width: 1, height: 1, channels: 3, background: "#000" } }).png().toBuffer();
10389
12377
  console.log(JSON.stringify({ ok: true, version: readMeta().version }));
10390
12378
  } catch (err) {
10391
12379
  console.log(JSON.stringify({ ok: false, error: String(err?.message ?? err) }));