← back to Fromental Internal
Fromental internal viewer: Shopify products.json scraper + local Ollama enricher + basic-auth catalog viewer (:10071)
886861faa272aae5cb55ade9fb621d5c50ec3e72 · 2026-07-09 09:10:33 -0700 · Steve Abrams
Files touched
A .gitignoreA package-lock.jsonA package.jsonA public/index.htmlA scripts/enrich-fromental.jsA scripts/scrape-fromental.jsA server.js
Diff
commit 886861faa272aae5cb55ade9fb621d5c50ec3e72
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Jul 9 09:10:33 2026 -0700
Fromental internal viewer: Shopify products.json scraper + local Ollama enricher + basic-auth catalog viewer (:10071)
---
.gitignore | 10 +
package-lock.json | 1184 +++++++++++++++++++++++++++++++++++++++++++
package.json | 17 +
public/index.html | 285 +++++++++++
scripts/enrich-fromental.js | 128 +++++
scripts/scrape-fromental.js | 155 ++++++
server.js | 134 +++++
7 files changed, 1913 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1457bc2
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+data/*.log
+data/enrich.log
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..ea5d347
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1184 @@
+{
+ "name": "fromental-internal",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "fromental-internal",
+ "version": "1.0.0",
+ "dependencies": {
+ "compression": "^1.8.1",
+ "express": "^5.2.1",
+ "pg": "^8.11.5"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+ "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "^3.0.0",
+ "negotiator": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/accepts/node_modules/negotiator": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+ "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/body-parser": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
+ "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "^3.1.2",
+ "content-type": "^2.0.0",
+ "debug": "^4.4.3",
+ "http-errors": "^2.0.1",
+ "iconv-lite": "^0.7.2",
+ "on-finished": "^2.4.1",
+ "qs": "^6.15.2",
+ "raw-body": "^3.0.2",
+ "type-is": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/body-parser/node_modules/content-type": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
+ "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/body-parser/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/body-parser/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/compressible": {
+ "version": "2.0.18",
+ "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
+ "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": ">= 1.43.0 < 2"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/compression": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
+ "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "3.1.2",
+ "compressible": "~2.0.18",
+ "debug": "2.6.9",
+ "negotiator": "~0.6.4",
+ "on-headers": "~1.1.0",
+ "safe-buffer": "5.2.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
+ "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+ "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.6.0"
+ }
+ },
+ "node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
+ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "^2.0.0",
+ "body-parser": "^2.2.1",
+ "content-disposition": "^1.0.0",
+ "content-type": "^1.0.5",
+ "cookie": "^0.7.1",
+ "cookie-signature": "^1.2.1",
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "finalhandler": "^2.1.0",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "merge-descriptors": "^2.0.0",
+ "mime-types": "^3.0.0",
+ "on-finished": "^2.4.1",
+ "once": "^1.4.0",
+ "parseurl": "^1.3.3",
+ "proxy-addr": "^2.0.7",
+ "qs": "^6.14.0",
+ "range-parser": "^1.2.1",
+ "router": "^2.2.0",
+ "send": "^1.1.0",
+ "serve-static": "^2.2.0",
+ "statuses": "^2.0.1",
+ "type-is": "^2.0.1",
+ "vary": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/express/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/finalhandler": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
+ "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "on-finished": "^2.4.1",
+ "parseurl": "^1.3.3",
+ "statuses": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/finalhandler/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/finalhandler/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+ "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.7.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
+ "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/is-promise": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+ "license": "MIT"
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
+ "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+ "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.4",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
+ "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/on-headers": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
+ "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "8.4.2",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
+ "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/pg": {
+ "version": "8.22.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
+ "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.0",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.15.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
+ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
+ "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.15.3",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
+ "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "es-define-property": "^1.0.1",
+ "side-channel": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
+ "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
+ "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.7.0",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/router": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+ "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "is-promise": "^4.0.0",
+ "parseurl": "^1.3.3",
+ "path-to-regexp": "^8.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/router/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/router/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.1",
+ "mime-types": "^3.0.2",
+ "ms": "^2.1.3",
+ "on-finished": "^2.4.1",
+ "range-parser": "^1.2.1",
+ "statuses": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/send/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/serve-static": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
+ "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "parseurl": "^1.3.3",
+ "send": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
+ "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
+ "license": "MIT",
+ "dependencies": {
+ "content-type": "^2.0.0",
+ "media-typer": "^1.1.0",
+ "mime-types": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/type-is/node_modules/content-type": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
+ "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..6998a72
--- /dev/null
+++ b/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "fromental-internal",
+ "version": "1.0.0",
+ "private": true,
+ "description": "Internal (basic-auth) catalog viewer + scraper for the Fromental line over dw_unified.fromental_catalog",
+ "main": "server.js",
+ "scripts": {
+ "start": "node server.js",
+ "scrape": "node scripts/scrape-fromental.js",
+ "enrich": "node scripts/enrich-fromental.js"
+ },
+ "dependencies": {
+ "compression": "^1.8.1",
+ "express": "^5.2.1",
+ "pg": "^8.11.5"
+ }
+}
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..527bb38
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,285 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1" />
+<title>Fromental — Designer Wallcoverings (internal)</title>
+<style>
+ :root{
+ --bg:#faf9f7; --ink:#1a1a1a; --mut:#7a756e; --line:#e2ded7; --acc:#0f7d74;
+ --card:#fff; --cols:6; --fs:1;
+ }
+ *{box-sizing:border-box}
+ body{margin:0;font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;background:var(--bg);color:var(--ink);font-size:15px;line-height:1.4}
+ a{color:inherit}
+ header.top{position:sticky;top:0;z-index:20;background:rgba(250,249,247,.96);backdrop-filter:blur(6px);border-bottom:1px solid var(--line);padding:12px 18px;display:flex;align-items:center;gap:16px;flex-wrap:wrap}
+ .brand{font-weight:700;letter-spacing:.02em}
+ .brand .sub{color:var(--mut);font-weight:400;font-size:12px;margin-left:8px}
+ .count{color:var(--mut);font-size:12px}
+ .ctrls{margin-left:auto;display:flex;align-items:center;gap:14px;flex-wrap:wrap}
+ .ctrls label{font-size:12px;color:var(--mut);display:flex;align-items:center;gap:6px}
+ select,input[type=search]{font:inherit;font-size:13px;padding:5px 8px;border:1px solid var(--line);border-radius:3px;background:#fff}
+ input[type=range]{width:120px;accent-color:var(--acc)}
+ .refresh{font-size:11px;color:var(--mut);border:1px solid var(--line);border-radius:20px;padding:3px 10px}
+
+ .wrap{display:grid;grid-template-columns:260px 1fr;gap:0;align-items:start}
+ aside{position:sticky;top:57px;max-height:calc(100vh - 57px);overflow:auto;border-right:1px solid var(--line);padding:14px;background:#fff}
+ aside .search{width:100%;margin-bottom:10px}
+ .clearbtn{width:100%;text-align:left;font-size:12px;color:var(--acc);background:none;border:none;cursor:pointer;padding:6px 2px;margin-bottom:6px}
+ details.facet{border-bottom:1px solid var(--line)}
+ details.facet>summary{cursor:pointer;list-style:none;padding:8px 2px;font-size:12px;font-weight:600;letter-spacing:.03em;text-transform:uppercase;color:#4a453f;display:flex;justify-content:space-between;align-items:center}
+ details.facet>summary::-webkit-details-marker{display:none}
+ details.facet>summary .badge{background:var(--acc);color:#fff;border-radius:10px;font-size:10px;padding:1px 7px;font-weight:600;text-transform:none;letter-spacing:0}
+ details.facet[data-active="1"]>summary{color:var(--acc)}
+ .facet .rows{padding:2px 0 8px}
+ .facet .row{display:flex;justify-content:space-between;gap:8px;font-size:12.5px;padding:3px 6px;cursor:pointer;border-radius:3px;color:#3a352f}
+ .facet .row:hover{background:#f2efe9}
+ .facet .row.sel{background:var(--acc);color:#fff}
+ .facet .row .n{color:var(--mut);font-variant-numeric:tabular-nums}
+ .facet .row.sel .n{color:#dff3f0}
+ .cardfields{padding:8px 2px 2px}
+ .cardfields .cf{display:flex;align-items:center;gap:7px;font-size:12.5px;padding:3px 4px;color:#3a352f}
+
+ main{padding:14px}
+ .grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:8px}
+ .card{border:1px solid var(--line);border-radius:2px;background:var(--card);overflow:hidden;cursor:pointer;position:relative;transition:border-color .12s}
+ .card:hover{border-color:var(--acc)}
+ .card .imgwrap{aspect-ratio:1/1;background:#efece6;overflow:hidden}
+ .card img{width:100%;height:100%;object-fit:cover;display:block}
+ .card .b{padding:calc(7px*var(--fs)) calc(8px*var(--fs))}
+ .card .b .ttl{font-size:calc(12.5px*var(--fs));font-weight:600;line-height:1.25;margin-bottom:calc(2px*var(--fs))}
+ .card .b .col{font-size:calc(11px*var(--fs));color:var(--mut)}
+ .card .chips{display:flex;flex-wrap:wrap;gap:4px;margin-top:calc(5px*var(--fs))}
+ .chip{font-size:calc(9.5px*var(--fs));border:1px solid var(--line);border-radius:2px;padding:1px 5px;color:#5a554e;background:#fbfaf8}
+ .chip.q{border-color:var(--acc);color:var(--acc)}
+ .chip.p{border-color:#b58a2e;color:#8a6820}
+ .card .when{display:flex;align-items:center;gap:4px;font-size:calc(9.5px*var(--fs));color:var(--mut);margin-top:calc(5px*var(--fs))}
+ .dot{width:9px;height:9px;border-radius:50%;border:1px solid rgba(0,0,0,.15);flex:0 0 auto}
+ /* image-only mode */
+ body.imgonly .grid .card .b{display:none}
+
+ /* PDP drawer */
+ .drawer{position:fixed;inset:0;background:rgba(20,18,16,.5);display:none;z-index:40;align-items:stretch;justify-content:flex-end}
+ .drawer.open{display:flex}
+ .drawer .panel{width:min(560px,94vw);background:#fff;height:100%;overflow:auto;box-shadow:-8px 0 30px rgba(0,0,0,.2)}
+ .drawer .panel .hero{width:100%;aspect-ratio:1/1;object-fit:cover;background:#efece6;display:block}
+ .drawer .pad{padding:18px}
+ .drawer h2{margin:0 0 2px;font-size:20px}
+ .drawer .sub{color:var(--mut);margin-bottom:12px}
+ .drawer .row2{display:flex;justify-content:space-between;border-bottom:1px solid var(--line);padding:7px 0;font-size:13px}
+ .drawer .row2 .k{color:var(--mut)}
+ .drawer .desc{margin:12px 0;line-height:1.55;font-size:13.5px}
+ .drawer .cta{display:flex;gap:8px;margin:14px 0 4px}
+ .drawer .cta button{flex:1;font:inherit;font-size:13px;padding:10px;border:1px solid var(--acc);background:var(--acc);color:#fff;border-radius:3px;cursor:pointer}
+ .drawer .cta button.alt{background:#fff;color:var(--acc)}
+ .drawer .gal{display:grid;grid-template-columns:repeat(4,1fr);gap:6px;margin-top:10px}
+ .drawer .gal img{width:100%;aspect-ratio:1/1;object-fit:cover;border:1px solid var(--line);border-radius:2px}
+ .close{position:absolute;top:10px;right:14px;font-size:22px;color:#fff;cursor:pointer;text-shadow:0 1px 3px rgba(0,0,0,.6)}
+</style>
+</head>
+<body>
+<header class="top">
+ <div class="brand">Fromental <span class="sub">Designer Wallcoverings · internal line viewer</span></div>
+ <span class="count" id="count">…</span>
+ <div class="ctrls">
+ <label>Sort
+ <select id="sort">
+ <option value="newest">Newest</option>
+ <option value="color">Color</option>
+ <option value="style">Style</option>
+ <option value="type">Type</option>
+ <option value="sku">SKU A→Z</option>
+ <option value="title">Title A→Z</option>
+ <option value="light">Light→Dark</option>
+ <option value="dark">Dark→Light</option>
+ <option value="price_asc">Price ↑</option>
+ <option value="price_desc">Price ↓</option>
+ </select>
+ </label>
+ <label>Density <input type="range" id="density" min="3" max="20" value="6" /></label>
+ <span class="refresh" id="refresh">—</span>
+ </div>
+</header>
+
+<div class="wrap">
+ <aside>
+ <input class="search" type="search" id="search" placeholder="Search pattern, color, SKU…" list="patterns" />
+ <datalist id="patterns"></datalist>
+ <button class="clearbtn" id="clear">✕ Clear filters</button>
+ <div id="facets"></div>
+ <details class="facet" open>
+ <summary>Card fields</summary>
+ <div class="cardfields" id="cardfields">
+ <label class="cf"><input type="checkbox" data-f="ttl" checked> Title</label>
+ <label class="cf"><input type="checkbox" data-f="col" checked> Color</label>
+ <label class="cf"><input type="checkbox" data-f="chips" checked> Chips (type / price)</label>
+ <label class="cf"><input type="checkbox" data-f="when" checked> Created date</label>
+ </div>
+ </details>
+ </aside>
+
+ <main>
+ <div class="grid" id="grid"></div>
+ </main>
+</div>
+
+<div class="drawer" id="drawer"><div class="panel" id="panel"></div></div>
+
+<script>
+const ORIGIN = location.origin;
+const state = { all: [], facets: {}, filters: {}, q: '', sort: 'newest' };
+const FACET_LABELS = { type:'Type', collection:'Collection', color:'Color', material:'Material', width:'Width', style:'Style', tag:'Tag', pricing:'Pricing' };
+
+function lum(hex){ if(!hex||hex[0]!=='#'||hex.length!==7) return null; const r=parseInt(hex.slice(1,3),16),g=parseInt(hex.slice(3,5),16),b=parseInt(hex.slice(5,7),16); return .2126*r+.7152*g+.0722*b; }
+function hue(hex){ if(!hex||hex[0]!=='#'||hex.length!==7) return 999; let r=parseInt(hex.slice(1,3),16)/255,g=parseInt(hex.slice(3,5),16)/255,b=parseInt(hex.slice(5,7),16)/255; const mx=Math.max(r,g,b),mn=Math.min(r,g,b),d=mx-mn; if(d===0)return 999; let h; if(mx===r)h=((g-b)/d)%6; else if(mx===g)h=(b-r)/d+2; else h=(r-g)/d+4; h*=60; if(h<0)h+=360; return h; }
+function fmtDate(d){ if(!d) return ''; try{ return new Date(d).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); }catch{ return ''; } }
+
+function matches(p){
+ if(state.q){ const q=state.q.toLowerCase(); const hay=[p.pattern,p.color,p.sku,p.type,p.collection].filter(Boolean).join(' ').toLowerCase(); if(!hay.includes(q)) return false; }
+ for(const [field,val] of Object.entries(state.filters)){
+ if(val==null) continue;
+ if(field==='pricing'){ const isQuote=p.quote; if((val==='Quote-only')!==isQuote) return false; continue; }
+ if(field==='style'){ if(!(p.styles||[]).includes(val)) return false; continue; }
+ if(field==='tag'){ if(!(p.aiTags||[]).includes(val)) return false; continue; }
+ const map={type:'type',collection:'collection',color:'color',material:'material',width:'width'};
+ if(p[map[field]]!==val) return false;
+ }
+ return true;
+}
+function sortRows(rows){
+ const s=state.sort;
+ const by={
+ newest:(a,b)=> new Date(b.createdAt||0)-new Date(a.createdAt||0),
+ color:(a,b)=> (hue(a.hex)-hue(b.hex))||0,
+ style:(a,b)=> ((a.styles&&a.styles[0])||'zz').localeCompare((b.styles&&b.styles[0])||'zz'),
+ type:(a,b)=> (a.type||'').localeCompare(b.type||''),
+ sku:(a,b)=> (a.sku||'').localeCompare(b.sku||''),
+ title:(a,b)=> (a.pattern||'').localeCompare(b.pattern||''),
+ light:(a,b)=> (lum(b.hex)??-1)-(lum(a.hex)??-1),
+ dark:(a,b)=> (lum(a.hex)??999)-(lum(b.hex)??999),
+ price_asc:(a,b)=> (a.price??1e9)-(b.price??1e9),
+ price_desc:(a,b)=> (b.price??-1)-(a.price??-1),
+ };
+ return rows.slice().sort(by[s]||by.newest);
+}
+
+function render(){
+ const rows=sortRows(state.all.filter(matches));
+ document.getElementById('count').textContent=`${rows.length} of ${state.all.length}`;
+ const g=document.getElementById('grid');
+ g.innerHTML=rows.map(p=>{
+ const chips=[];
+ if(p.type) chips.push(`<span class="chip">${esc(p.type)}</span>`);
+ chips.push(p.quote?`<span class="chip q">Quote</span>`:(p.price!=null?`<span class="chip p">$${p.price.toLocaleString()}</span>`:''));
+ const dot=p.hex?`<span class="dot" style="background:${p.hex}"></span>`:'';
+ return `<div class="card" data-sku="${esc(p.sku)}">
+ <div class="imgwrap">${p.image?`<img loading="lazy" src="${esc(p.image)}" alt="${esc(p.pattern||'')}">`:''}</div>
+ <div class="b">
+ <div class="ttl f-ttl">${esc(p.pattern||p.sku)}</div>
+ ${p.color?`<div class="col f-col">${esc(p.color)}</div>`:''}
+ <div class="chips f-chips">${dot}${chips.join('')}</div>
+ ${p.createdAt?`<div class="when f-when" title="${esc(p.createdAt)}">🕓 ${fmtDate(p.createdAt)}</div>`:''}
+ </div>
+ </div>`;
+ }).join('');
+ applyCardFields();
+}
+function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); }
+
+// facets
+function renderFacets(){
+ const host=document.getElementById('facets');
+ host.innerHTML=Object.entries(state.facets).map(([field,rows])=>{
+ if(!rows||!rows.length) return '';
+ const active=state.filters[field];
+ const rowsHtml=rows.map(([v,n])=>`<div class="row ${active===v?'sel':''}" data-field="${field}" data-val="${esc(v)}"><span>${esc(v)}</span><span class="n">${n}</span></div>`).join('');
+ return `<details class="facet" data-active="${active?1:0}">
+ <summary>${FACET_LABELS[field]||field} ${active?`<span class="badge">${esc(active)}</span>`:''}</summary>
+ <div class="rows">${rowsHtml}</div>
+ </details>`;
+ }).join('');
+}
+
+// events
+document.addEventListener('click',(e)=>{
+ const row=e.target.closest('.facet .row');
+ if(row){ const f=row.dataset.field,v=row.dataset.val; state.filters[f]=(state.filters[f]===v)?null:v; renderFacets(); render(); return; }
+ const card=e.target.closest('.card'); if(card){ openDrawer(card.dataset.sku); }
+});
+document.getElementById('clear').addEventListener('click',()=>{ state.filters={}; state.q=''; document.getElementById('search').value=''; renderFacets(); render(); });
+document.getElementById('search').addEventListener('input',(e)=>{ state.q=e.target.value.trim(); render(); });
+document.getElementById('sort').addEventListener('change',(e)=>{ state.sort=e.target.value; localStorage.setItem('fr_sort',state.sort); render(); });
+document.getElementById('density').addEventListener('input',(e)=>setDensity(+e.target.value));
+function setDensity(cols){
+ document.documentElement.style.setProperty('--cols',cols);
+ const fs=Math.max(.5,Math.min(1.2, cols<=6 ? 1+(6-cols)*.06 : 1-(cols-6)*.042));
+ document.documentElement.style.setProperty('--fs',fs);
+ localStorage.setItem('fr_density',cols); document.getElementById('density').value=cols;
+}
+document.querySelectorAll('#cardfields input').forEach(cb=>cb.addEventListener('change',()=>{ saveCardFields(); applyCardFields(); }));
+function saveCardFields(){ const o={}; document.querySelectorAll('#cardfields input').forEach(cb=>o[cb.dataset.f]=cb.checked); localStorage.setItem('fr_cf',JSON.stringify(o)); }
+function applyCardFields(){
+ const o={}; document.querySelectorAll('#cardfields input').forEach(cb=>o[cb.dataset.f]=cb.checked);
+ document.querySelectorAll('.f-ttl').forEach(el=>el.style.display=o.ttl?'':'none');
+ document.querySelectorAll('.f-col').forEach(el=>el.style.display=o.col?'':'none');
+ document.querySelectorAll('.f-chips').forEach(el=>el.style.display=o.chips?'':'none');
+ document.querySelectorAll('.f-when').forEach(el=>el.style.display=o.when?'':'none');
+ const allOff=!o.ttl&&!o.col&&!o.chips&&!o.when;
+ document.body.classList.toggle('imgonly',allOff);
+}
+
+// drawer / PDP
+async function openDrawer(sku){
+ const d=document.getElementById('drawer'), panel=document.getElementById('panel');
+ d.classList.add('open'); panel.innerHTML='<div class="pad">Loading…</div>';
+ const p=await fetch(ORIGIN+'/api/product/'+encodeURIComponent(sku)).then(r=>r.json()).catch(()=>null);
+ if(!p){ panel.innerHTML='<div class="pad">Not found.</div>'; return; }
+ const gal=(p.gallery||[]).slice(0,8).map(g=>`<img src="${esc(g)}">`).join('');
+ const rowsSpec=[
+ ['SKU',p.sku],['Color',p.color],['Type',p.type],['Collection',p.collection],
+ ['Material',p.material],['Width',p.width],['Vendor',p.vendor],
+ ['Price', p.quote?'Quote-only (made to order)':(p.price!=null?'$'+p.price.toLocaleString():'—')],
+ ['Styles',(p.styles||[]).join(', ')],
+ ].filter(r=>r[1]).map(r=>`<div class="row2"><span class="k">${r[0]}</span><span>${esc(r[1])}</span></div>`).join('');
+ panel.innerHTML=`
+ <div style="position:relative"><span class="close" onclick="document.getElementById('drawer').classList.remove('open')">×</span>
+ ${p.image?`<img class="hero" src="${esc(p.image)}">`:''}</div>
+ <div class="pad">
+ <h2>${esc(p.pattern||p.sku)}</h2>
+ <div class="sub">${esc(p.color||'')} · Fromental</div>
+ <div class="cta">
+ <button>Request Quote</button>
+ <button class="alt">Order Memo Sample</button>
+ </div>
+ ${rowsSpec}
+ ${p.description?`<div class="desc">${esc(p.description)}</div>`:''}
+ ${gal?`<div class="gal">${gal}</div>`:''}
+ </div>`;
+}
+document.getElementById('drawer').addEventListener('click',(e)=>{ if(e.target.id==='drawer') e.target.classList.remove('open'); });
+
+// meta refresh pill
+async function tickMeta(){
+ try{ const m=await fetch(ORIGIN+'/api/meta').then(r=>r.json());
+ document.getElementById('refresh').textContent=`↻ ${m.count} · ${m.enriched} enriched`;
+ }catch{}
+}
+
+async function boot(){
+ // restore prefs
+ setDensity(+(localStorage.getItem('fr_density')||6));
+ state.sort=localStorage.getItem('fr_sort')||'newest'; document.getElementById('sort').value=state.sort;
+ const cf=localStorage.getItem('fr_cf'); if(cf){ try{ const o=JSON.parse(cf); document.querySelectorAll('#cardfields input').forEach(cb=>{ if(o[cb.dataset.f]===false) cb.checked=false; }); }catch{} }
+
+ const [prod,fac]=await Promise.all([
+ fetch(ORIGIN+'/api/products').then(r=>r.json()),
+ fetch(ORIGIN+'/api/facets').then(r=>r.json()),
+ ]);
+ state.all=prod.products; state.facets=fac;
+ document.getElementById('patterns').innerHTML=[...new Set(state.all.map(p=>p.pattern).filter(Boolean))].slice(0,400).map(v=>`<option value="${esc(v)}">`).join('');
+ renderFacets(); render(); tickMeta(); setInterval(tickMeta,60000);
+}
+boot();
+</script>
+</body>
+</html>
diff --git a/scripts/enrich-fromental.js b/scripts/enrich-fromental.js
new file mode 100644
index 0000000..0454349
--- /dev/null
+++ b/scripts/enrich-fromental.js
@@ -0,0 +1,128 @@
+#!/usr/bin/env node
+/**
+ * enrich-fromental.js — LOCAL ($0) Ollama enrichment of fromental_catalog.
+ *
+ * Steve HARD RULE: all design/image enrichment runs on LOCAL models, never paid
+ * Gemini/Claude-vision/Replicate. This uses Mac1 Ollama:
+ * - qwen2.5vl:7b -> vision: colors (hex+name), color_hex, styles, tags,
+ * and a clean description when body_html is thin.
+ *
+ * Resumable: skips rows where ai_accepted_at IS NOT NULL. Batches politely.
+ * node scripts/enrich-fromental.js [limit]
+ * LIMIT env or argv[2] caps rows this run (default 100). Pass "all" for全部.
+ *
+ * Cost: $0 (local Ollama, unmetered).
+ */
+const { Client } = require('pg');
+
+const DB = process.env.DATABASE_URL || 'postgresql://localhost/dw_unified';
+const OLLAMA = process.env.OLLAMA_URL || 'http://192.168.1.133:11434';
+const VISION_MODEL = process.env.VISION_MODEL || 'qwen2.5vl:7b';
+const argLimit = process.argv[2] || process.env.LIMIT || '100';
+const LIMIT = argLimit === 'all' ? 1000000 : parseInt(argLimit, 10);
+
+async function fetchImageB64(url) {
+ // Fromental images can be huge; request a Shopify-resized variant to keep it small.
+ let u = url;
+ if (/cdn\.shopify\.com/.test(u) && !/[?&]width=/.test(u)) {
+ u += (u.includes('?') ? '&' : '?') + 'width=600';
+ }
+ const res = await fetch(u, { headers: { 'User-Agent': 'DW-enrich/1.0' } });
+ if (!res.ok) throw new Error(`img HTTP ${res.status}`);
+ const buf = Buffer.from(await res.arrayBuffer());
+ return buf.toString('base64');
+}
+
+async function ollamaVision(prompt, imgB64) {
+ const res = await fetch(`${OLLAMA}/api/generate`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ model: VISION_MODEL,
+ prompt,
+ images: [imgB64],
+ stream: false,
+ format: 'json',
+ options: { temperature: 0.2 },
+ }),
+ });
+ if (!res.ok) throw new Error(`ollama HTTP ${res.status}`);
+ const j = await res.json();
+ return j.response;
+}
+
+function safeParse(s) {
+ if (!s) return null;
+ try { return JSON.parse(s); } catch {}
+ const m = s.match(/\{[\s\S]*\}/);
+ if (m) { try { return JSON.parse(m[0]); } catch {} }
+ return null;
+}
+
+const PROMPT = (row) => `You are a luxury interior-design cataloguer describing a Fromental hand-painted / embroidered wallcovering or artwork image.
+Pattern: "${row.pattern_name || ''}" Colorway: "${row.color_name || ''}" Type: "${row.product_type || ''}".
+Look at the image and return STRICT JSON with these keys only:
+{
+ "colors": [{"name":"warm ivory","hex":"#efe7d8"}, ...], // 2-5 dominant colors, real hex you SEE
+ "dominant_hex": "#rrggbb", // single most-dominant hex
+ "styles": ["Chinoiserie","Scenic", ...], // 1-4 interior/design styles
+ "tags": ["hand-painted","floral","birds", ...], // 3-8 lowercase descriptive tags
+ "description": "..." // 1-2 elegant sentences, editorial tone, NO price, NO brand claims beyond Fromental
+}
+Only output the JSON object.`;
+
+async function main() {
+ const client = new Client({ connectionString: DB });
+ await client.connect();
+
+ const { rows } = await client.query(
+ `SELECT id, mfr_sku, pattern_name, color_name, product_type, image_url, description
+ FROM fromental_catalog
+ WHERE ai_accepted_at IS NULL AND image_url IS NOT NULL
+ ORDER BY (description IS NULL) DESC, id
+ LIMIT $1`,
+ [LIMIT]
+ );
+ console.log(`Enriching ${rows.length} rows (model ${VISION_MODEL}, LOCAL $0)...`);
+
+ let ok = 0, fail = 0;
+ for (const row of rows) {
+ try {
+ const b64 = await fetchImageB64(row.image_url);
+ const raw = await ollamaVision(PROMPT(row), b64);
+ const j = safeParse(raw);
+ if (!j) { throw new Error('unparseable model output'); }
+
+ const colors = Array.isArray(j.colors) ? j.colors : [];
+ const domHex = typeof j.dominant_hex === 'string' && /^#[0-9a-fA-F]{6}$/.test(j.dominant_hex) ? j.dominant_hex : null;
+ const styles = Array.isArray(j.styles) ? j.styles : [];
+ const tags = Array.isArray(j.tags) ? j.tags : [];
+ const aiDesc = typeof j.description === 'string' ? j.description.trim() : null;
+ // only fill ai_description into description if the scraped one is thin
+ const useDesc = (!row.description || row.description.length < 40) ? aiDesc : null;
+
+ await client.query(
+ `UPDATE fromental_catalog SET
+ ai_colors=$2, color_hex=$3, dominant_color_hex=$3, ai_styles=$4, ai_tags=$5,
+ ai_description=$6,
+ description=COALESCE($7, description),
+ ai_accepted_at=NOW(), updated_at=NOW()
+ WHERE id=$1`,
+ [
+ row.id, JSON.stringify(colors), domHex, JSON.stringify(styles),
+ JSON.stringify(tags), aiDesc, useDesc,
+ ]
+ );
+ ok++;
+ if (ok % 10 === 0) process.stdout.write(` ${ok} enriched...\n`);
+ } catch (e) {
+ fail++;
+ process.stderr.write(` FAIL ${row.mfr_sku}: ${e.message}\n`);
+ }
+ await new Promise((r) => setTimeout(r, 150)); // polite pacing
+ }
+ console.log(`Done. enriched=${ok} failed=${fail}. Cost: $0 (local Ollama).`);
+ await client.end();
+}
+
+main().catch((e) => { console.error(e); process.exit(1); });
diff --git a/scripts/scrape-fromental.js b/scripts/scrape-fromental.js
new file mode 100644
index 0000000..addbb04
--- /dev/null
+++ b/scripts/scrape-fromental.js
@@ -0,0 +1,155 @@
+#!/usr/bin/env node
+/**
+ * scrape-fromental.js — feed-first ($0) Fromental catalog scraper.
+ *
+ * Fromental (https://www.fromental.co.uk) is a CONFIRMED Shopify store, so we hit
+ * products.json?limit=250&page=N until a page returns 0 products. No auth, no
+ * proxy, no captcha, no paid API. Cost: $0 (local).
+ *
+ * Captures EVERYTHING per product:
+ * title -> pattern_name, handle, product_type, body_html -> description,
+ * image srcs -> image_url + gallery_images, variants (price/sku/options),
+ * tags, vendor, options.
+ *
+ * Pricing: QUOTE-ONLY line. Most bespoke items are $0/blank (made-to-order);
+ * some Artwork / Shop / Accessory items carry real prices. We capture whatever
+ * price exists (max variant price), else leave NULL and mark quote_only=true.
+ *
+ * mfr_sku key resolution (idempotent upsert key):
+ * 1. first non-empty variant sku
+ * 2. else "handle:<handle>"
+ * 3. else "shopify:<id>"
+ *
+ * Usage: node scripts/scrape-fromental.js
+ */
+const { Client } = require('pg');
+
+const BASE = 'https://www.fromental.co.uk';
+const DB = process.env.DATABASE_URL || 'postgresql://localhost/dw_unified';
+
+async function fetchPage(page) {
+ const url = `${BASE}/products.json?limit=250&page=${page}`;
+ const res = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh) DW-catalog-sync' } });
+ if (!res.ok) throw new Error(`HTTP ${res.status} on ${url}`);
+ const j = await res.json();
+ return j.products || [];
+}
+
+function stripHtml(html) {
+ if (!html) return null;
+ return String(html)
+ .replace(/<[^>]+>/g, ' ')
+ .replace(/ /g, ' ')
+ .replace(/&/g, '&')
+ .replace(/'|’|‘/g, "'")
+ .replace(/"|“|”/g, '"')
+ .replace(/é/g, 'e')
+ .replace(/\s{2,}/g, ' ')
+ .trim() || null;
+}
+
+// "Myeoh - Minhwa" -> pattern "Myeoh", color/variant "Minhwa"
+function splitTitle(title) {
+ if (!title) return { pattern: null, color: null };
+ const parts = title.split(/\s+[-–—]\s+/);
+ if (parts.length >= 2) return { pattern: parts[0].trim(), color: parts.slice(1).join(' - ').trim() };
+ return { pattern: title.trim(), color: null };
+}
+
+// Fromental SKU pattern e.g. AW-YH01-02-OS-A ; collection ~ the alpha prefix family
+function inferCollection(product, sku) {
+ // prefer a "collection" style tag if present
+ const tags = product.tags || [];
+ const t = (Array.isArray(tags) ? tags : String(tags).split(',')).map((s) => s.trim()).filter(Boolean);
+ const col = t.find((x) => /collection|series/i.test(x));
+ if (col) return col.replace(/collection[:\s-]*/i, '').trim();
+ return null;
+}
+
+function pickPrice(variants) {
+ const prices = (variants || [])
+ .map((v) => parseFloat(v.price))
+ .filter((n) => Number.isFinite(n) && n > 0);
+ if (!prices.length) return { price: null, quote: true };
+ return { price: Math.max(...prices), quote: false };
+}
+
+function resolveMfrSku(product) {
+ const v = (product.variants || []).find((x) => x.sku && String(x.sku).trim());
+ if (v) return String(v.sku).trim();
+ if (product.handle) return `handle:${product.handle}`;
+ return `shopify:${product.id}`;
+}
+
+async function main() {
+ const client = new Client({ connectionString: DB });
+ await client.connect();
+
+ let page = 1, all = [];
+ while (true) {
+ const products = await fetchPage(page);
+ if (!products.length) break;
+ all = all.concat(products);
+ process.stdout.write(` page ${page}: ${products.length} (total ${all.length})\n`);
+ page++;
+ if (page > 100) break; // safety
+ await new Promise((r) => setTimeout(r, 300)); // polite
+ }
+ console.log(`Fetched ${all.length} products across ${page - 1} pages.`);
+
+ let up = 0;
+ for (const p of all) {
+ const mfr_sku = resolveMfrSku(p);
+ const { pattern, color } = splitTitle(p.title);
+ const images = (p.images || []).map((im) => im.src).filter(Boolean);
+ const { price, quote } = pickPrice(p.variants);
+ const cmp = (p.variants || [])
+ .map((v) => parseFloat(v.compare_at_price))
+ .filter((n) => Number.isFinite(n) && n > 0);
+ const compareAt = cmp.length ? Math.max(...cmp) : null;
+ const tags = Array.isArray(p.tags) ? p.tags : String(p.tags || '').split(',').map((s) => s.trim()).filter(Boolean);
+ const collection = inferCollection(p, mfr_sku);
+ const productUrl = `${BASE}/products/${p.handle}`;
+
+ await client.query(
+ `INSERT INTO fromental_catalog
+ (mfr_sku, shopify_id, handle, pattern_name, color_name, collection,
+ image_url, product_url, product_type, description, tags, vendor,
+ gallery_images, variants, options, price, compare_at_price, quote_only,
+ published_at, vendor_created_at, last_scraped, updated_at)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,NOW(),NOW())
+ ON CONFLICT (mfr_sku) DO UPDATE SET
+ shopify_id=EXCLUDED.shopify_id, handle=EXCLUDED.handle,
+ pattern_name=EXCLUDED.pattern_name, color_name=EXCLUDED.color_name,
+ collection=EXCLUDED.collection, image_url=EXCLUDED.image_url,
+ product_url=EXCLUDED.product_url, product_type=EXCLUDED.product_type,
+ description=EXCLUDED.description, tags=EXCLUDED.tags, vendor=EXCLUDED.vendor,
+ gallery_images=EXCLUDED.gallery_images, variants=EXCLUDED.variants,
+ options=EXCLUDED.options, price=EXCLUDED.price,
+ compare_at_price=EXCLUDED.compare_at_price, quote_only=EXCLUDED.quote_only,
+ published_at=EXCLUDED.published_at, vendor_created_at=EXCLUDED.vendor_created_at,
+ last_scraped=NOW(), updated_at=NOW()`,
+ [
+ mfr_sku, p.id, p.handle, pattern, color, collection,
+ images[0] || null, productUrl, p.product_type || null,
+ stripHtml(p.body_html), tags, p.vendor || 'Fromental',
+ images, JSON.stringify(p.variants || []), JSON.stringify(p.options || []),
+ price, compareAt, quote,
+ p.published_at ? new Date(p.published_at) : null,
+ p.created_at ? new Date(p.created_at) : null,
+ ]
+ );
+ up++;
+ }
+ console.log(`Upserted ${up} rows into fromental_catalog.`);
+ const { rows } = await client.query(
+ `SELECT count(*) total,
+ count(*) FILTER (WHERE price IS NOT NULL) priced,
+ count(*) FILTER (WHERE quote_only) quote
+ FROM fromental_catalog`
+ );
+ console.log('Table state:', rows[0]);
+ await client.end();
+}
+
+main().catch((e) => { console.error(e); process.exit(1); });
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..8d07da8
--- /dev/null
+++ b/server.js
@@ -0,0 +1,134 @@
+/* Fromental — INTERNAL line viewer (basic-auth, read-only over dw_unified.fromental_catalog).
+ Built to Steve's internal-line-viewer standing spec (astek-landing :9944 is the reference):
+ left panel with search + per-field collapsible tab tables (values+counts, click-to-filter),
+ card-field visibility toggles down to image-only, type scale tied to the density slider,
+ created-date chips, location.origin fetches, sort + density persisted in localStorage.
+
+ Reads fromental_catalog LIVE (refreshes an in-memory snapshot every REFRESH_INTERVAL_SEC).
+ Fromental is CUSTOMER-FACING (not private-label) but this viewer is an internal curation
+ surface — quote-only, sample-order only, no Shopify links. */
+const express = require('express');
+const path = require('path');
+const { Client } = require('pg');
+
+const PORT = process.env.PORT || 10071;
+const DB = process.env.DATABASE_URL || 'postgresql://localhost/dw_unified';
+const REFRESH_INTERVAL_SEC = Number(process.env.REFRESH_INTERVAL_SEC || 900);
+const [AUTH_USER, AUTH_PASS] = (process.env.BASIC_AUTH || 'admin:DW2024!').split(':');
+
+const app = express();
+app.use(require('compression')());
+
+let SNAP = { products: [], facets: {}, count: 0 };
+let LAST_REFRESH = new Date().toISOString();
+
+function titleCase(s) {
+ return String(s || '').replace(/\w\S*/g, (t) => t.charAt(0).toUpperCase() + t.slice(1).toLowerCase());
+}
+
+async function load() {
+ const client = new Client({ connectionString: DB });
+ await client.connect();
+ const { rows } = await client.query(`
+ SELECT id, mfr_sku, handle, pattern_name, color_name, collection, product_type,
+ material, composition, width, roll_length, image_url, gallery_images,
+ product_url, description, tags, vendor, price, compare_at_price, quote_only,
+ color_hex, dominant_color_hex, ai_colors, ai_styles, ai_tags, ai_description,
+ ai_accepted_at, crawled_at, vendor_created_at, updated_at
+ FROM fromental_catalog
+ ORDER BY (vendor_created_at IS NULL), vendor_created_at DESC NULLS LAST, id DESC`);
+ await client.end();
+
+ const products = rows.map((r) => ({
+ id: r.id,
+ sku: r.mfr_sku,
+ handle: r.handle,
+ pattern: r.pattern_name,
+ color: r.color_name,
+ collection: r.collection,
+ type: r.product_type,
+ material: r.material,
+ composition: r.composition,
+ width: r.width,
+ length: r.roll_length,
+ image: r.image_url,
+ gallery: r.gallery_images || [],
+ productUrl: r.product_url,
+ description: r.description || r.ai_description || null,
+ tags: r.tags || [],
+ vendor: (r.vendor || 'Fromental').replace(/\s*Design$/i, ''),
+ price: r.price != null ? Number(r.price) : null,
+ quote: !!r.quote_only && r.price == null,
+ hex: r.dominant_color_hex || r.color_hex || null,
+ styles: r.ai_styles || [],
+ aiTags: r.ai_tags || [],
+ enriched: !!r.ai_accepted_at,
+ createdAt: r.vendor_created_at || r.crawled_at,
+ }));
+
+ // Facets — one entry per filterable field: [value, count], sorted by count desc.
+ const facet = (keyFn) => {
+ const m = new Map();
+ for (const p of products) {
+ const vals = keyFn(p);
+ for (const v of (Array.isArray(vals) ? vals : [vals])) {
+ if (v == null || v === '') continue;
+ m.set(v, (m.get(v) || 0) + 1);
+ }
+ }
+ return [...m.entries()].sort((a, b) => b[1] - a[1]);
+ };
+ const facets = {
+ type: facet((p) => p.type),
+ collection: facet((p) => p.collection),
+ color: facet((p) => p.color),
+ material: facet((p) => p.material),
+ width: facet((p) => p.width),
+ style: facet((p) => p.styles),
+ tag: facet((p) => p.aiTags),
+ pricing: facet((p) => (p.quote ? 'Quote-only' : 'Priced')),
+ };
+
+ SNAP = { products, facets, count: products.length };
+ LAST_REFRESH = new Date().toISOString();
+ console.log(`[fromental] loaded ${products.length} products (${products.filter((p) => p.enriched).length} enriched)`);
+}
+
+// health probe BEFORE auth (deploy smoke + uptime monitors)
+app.get('/healthz', (_req, res) => res.json({ ok: true, products: SNAP.count, lastRefresh: LAST_REFRESH }));
+
+app.use((req, res, next) => {
+ const [scheme, b64] = (req.headers.authorization || '').split(' ');
+ if (scheme === 'Basic' && b64) {
+ const [u, p] = Buffer.from(b64, 'base64').toString('utf8').split(':');
+ if (u === AUTH_USER && p === AUTH_PASS) return next();
+ }
+ res.set('WWW-Authenticate', 'Basic realm="Fromental (Designer Wallcoverings internal)"');
+ return res.status(401).send('Authentication required.');
+});
+
+app.use(express.json({ limit: '2mb' }));
+
+app.get('/api/meta', (_req, res) => res.json({
+ lastRefresh: LAST_REFRESH, intervalSec: REFRESH_INTERVAL_SEC,
+ count: SNAP.count, enriched: SNAP.products.filter((p) => p.enriched).length,
+ now: new Date().toISOString(),
+}));
+// light list — drop heavy fields the grid never renders
+app.get('/api/products', (_req, res) => res.json({
+ count: SNAP.count,
+ products: SNAP.products.map(({ gallery, description, ...p }) => p),
+}));
+app.get('/api/facets', (_req, res) => res.json(SNAP.facets));
+app.get('/api/product/:sku', (req, res) => {
+ const p = SNAP.products.find((x) => x.sku === req.params.sku);
+ if (!p) return res.status(404).json({ error: 'not found' });
+ res.json(p);
+});
+
+app.use(express.static(path.join(__dirname, 'public')));
+
+load().then(() => {
+ setInterval(() => load().catch((e) => console.error('[fromental] reload failed:', e.message)), REFRESH_INTERVAL_SEC * 1000);
+ app.listen(PORT, () => console.log(`Fromental internal viewer → http://127.0.0.1:${PORT} (basic-auth ${AUTH_USER})`));
+}).catch((e) => { console.error('initial load failed:', e); process.exit(1); });
(oldest)
·
back to Fromental Internal
·
Strip product count from unauthenticated /healthz a8b60f7 →