From 822f688c4127b7232b81ab05156e32ec4c1bb368 Mon Sep 17 00:00:00 2001 From: chenkel-data Date: Wed, 18 Mar 2026 14:59:21 +0100 Subject: [PATCH] feat: add tests and github action for CI --- .github/workflows/ci.yml | 34 + .prettierrc.json | 7 + NOTICE | 2 +- README.md | 2 +- eslint.config.js | 24 + package-lock.json | 2230 +++++++++++++++++++++++++- package.json | 27 +- src/db/database.js | 288 +++- src/middleware/errorHandler.js | 4 +- src/providers/immoscout24/index.js | 160 +- src/providers/kleinanzeigen/index.js | 32 +- src/providers/registry.js | 1 - src/routes/configs.js | 224 +-- src/routes/listings.js | 379 +++-- src/routes/scraper.js | 245 +-- src/scrapers/engine.js | 59 +- src/server.js | 22 +- src/services/scraperService.js | 67 +- src/utils.js | 36 +- tests/immoscout24.test.js | 223 +++ tests/kleinanzeigen.test.js | 152 ++ tests/utils.test.js | 206 +++ vitest.config.js | 8 + 23 files changed, 3815 insertions(+), 617 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .prettierrc.json create mode 100644 eslint.config.js create mode 100644 tests/immoscout24.test.js create mode 100644 tests/kleinanzeigen.test.js create mode 100644 tests/utils.test.js create mode 100644 vitest.config.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f6be8af --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main] + +jobs: + lint-and-test: + name: Lint & Unit Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci --ignore-scripts + + - name: Format check (Prettier) + run: npm run format:check + + - name: Lint (ESLint) + run: npm run lint + + - name: Unit tests (Vitest) + run: npm test diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..044758e --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "singleQuote": true, + "semi": true, + "trailingComma": "all", + "printWidth": 100, + "arrowParens": "always" +} diff --git a/NOTICE b/NOTICE index 0dcce68..eeb64d2 100644 --- a/NOTICE +++ b/NOTICE @@ -1,4 +1,4 @@ -ImmoPilot +Immo-Pilot Copyright 2026 Christopher Henkel This product is licensed under the Apache License, Version 2.0. diff --git a/README.md b/README.md index 8b184b0..ea58cd6 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Currently supported: **ImmobilienScout24** & **Kleinanzeigen** – more provider - 🚫 **Blacklist** – per click or globally via keywords - ❀️ **Favorites** – persisted even if the agent is deleted - πŸ”„ **Scraping** – manual, on startup, or via cron; with pagination and duplicate filtering -- 🧩 **Provider system** – currently: Kleinanzeigen; more planned +- 🧩 **Provider system** – currently: **ImmobilienScout24** & **Kleinanzeigen**; more planned - πŸ—„οΈ **Local** – SQLite ## Quickstart diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..35bfa4f --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,24 @@ +import js from '@eslint/js'; +import globals from 'globals'; +import prettierConfig from 'eslint-config-prettier'; + +export default [ + js.configs.recommended, + prettierConfig, + { + languageOptions: { + globals: { ...globals.node }, + ecmaVersion: 2022, + sourceType: 'module', + }, + rules: { + 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }], + 'no-console': 'off', + // Empty catch blocks are used intentionally throughout (fire-and-forget patterns) + 'no-empty': ['error', { allowEmptyCatch: true }], + }, + }, + { + ignores: ['client/', 'public/', 'node_modules/', 'data/', 'reports/'], + }, +]; diff --git a/package-lock.json b/package-lock.json index 8c9414e..c6c111a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,10 +17,733 @@ "node-cron": "^3.0.3", "playwright": "^1.42.1" }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "eslint": "^10.0.3", + "eslint-config-prettier": "^10.1.8", + "globals": "^17.4.0", + "prettier": "^3.8.1", + "vitest": "^4.1.0" + }, "engines": { "node": ">=22.5.0" } }, + "node_modules/@emnapi/core": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", + "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", + "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.3", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz", + "integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.3", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-array/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@eslint/config-array/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz", + "integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.1.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz", + "integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz", + "integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz", + "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.1.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@oxc-project/runtime": { + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz", + "integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz", + "integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz", + "integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz", + "integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz", + "integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz", + "integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz", + "integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz", + "integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz", + "integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz", + "integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz", + "integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz", + "integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz", + "integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz", + "integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", + "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "chai": "^6.2.2", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", + "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.0", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", + "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", + "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.0", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", + "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.0", + "@vitest/utils": "4.1.0", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", + "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", + "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.0", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -43,12 +766,73 @@ "node": ">= 0.6" } }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/body-parser": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", @@ -73,6 +857,19 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -111,6 +908,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/compressible": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", @@ -162,6 +969,13 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -194,6 +1008,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -203,6 +1032,13 @@ "ms": "2.0.0" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -222,6 +1058,16 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -269,35 +1115,259 @@ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "license": "MIT", "engines": { - "node": ">= 0.4" + "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-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "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/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.3.tgz", + "integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.3", + "@eslint/config-helpers": "^0.5.2", + "@eslint/core": "^1.1.1", + "@eslint/plugin-kit": "^0.6.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.1.1", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/eslint/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" } }, - "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", + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", "engines": { - "node": ">= 0.4" + "node": ">=4.0" } }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, "license": "MIT", "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" + "@types/estree": "^1.0.0" } }, - "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/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } }, "node_modules/etag": { "version": "1.8.1", @@ -308,6 +1378,16 @@ "node": ">= 0.6" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", @@ -354,6 +1434,58 @@ "url": "https://opencollective.com/express" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -372,6 +1504,44 @@ "node": ">= 0.8" } }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", + "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", + "dev": true, + "license": "ISC" + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -450,6 +1620,32 @@ "node": ">= 0.4" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz", + "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -518,6 +1714,26 @@ "node": ">=0.10.0" } }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -533,6 +1749,368 @@ "node": ">= 0.10" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -611,12 +2189,54 @@ "node": ">= 0.6" } }, + "node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "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/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/negotiator": { "version": "0.6.4", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", @@ -659,6 +2279,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -680,6 +2311,56 @@ "node": ">= 0.8" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -689,12 +2370,60 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-to-regexp": { "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/playwright": { "version": "1.58.2", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", @@ -725,6 +2454,61 @@ "node": ">=18" } }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -738,6 +2522,16 @@ "node": ">= 0.10" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.14.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", @@ -777,6 +2571,40 @@ "node": ">= 0.8" } }, + "node_modules/rolldown": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz", + "integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.115.0", + "@rolldown/pluginutils": "1.0.0-rc.9" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.9", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", + "@rolldown/binding-darwin-x64": "1.0.0-rc.9", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -854,6 +2682,29 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -926,6 +2777,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -935,6 +2810,57 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -944,6 +2870,27 @@ "node": ">=0.6" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -966,6 +2913,16 @@ "node": ">= 0.8" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -992,6 +2949,239 @@ "engines": { "node": ">= 0.8" } + }, + "node_modules/vite": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz", + "integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@oxc-project/runtime": "0.115.0", + "lightningcss": "^1.32.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.9", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.0.0-alpha.31", + "esbuild": "^0.27.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitest": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", + "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.0", + "@vitest/mocker": "4.1.0", + "@vitest/pretty-format": "4.1.0", + "@vitest/runner": "4.1.0", + "@vitest/snapshot": "4.1.0", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.0", + "@vitest/browser-preview": "4.1.0", + "@vitest/browser-webdriverio": "4.1.0", + "@vitest/ui": "4.1.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index 0655dfd..985d7da 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,21 @@ { "name": "immo-pilot", "version": "1.0.0", - "description": "ImmoPilot - die Wohnungssuche-App", + "description": "Immo-Pilot - die Wohnungssuche-App", "type": "module", "main": "src/server.js", "scripts": { - "start": "node src/server.js", - "dev": "node --watch src/server.js", - "dev:client": "cd client && npm run dev", - "build:client": "cd client && npm run build", - "db": "node scripts/query-db.js", - "postinstall": "playwright install chromium" + "start": "node src/server.js", + "dev": "node --watch src/server.js", + "dev:client": "cd client && npm run dev", + "build:client": "cd client && npm run build", + "db": "node scripts/query-db.js", + "test": "vitest run", + "test:watch": "vitest", + "lint": "eslint src/ tests/", + "format": "prettier --write src/ tests/", + "format:check": "prettier --check src/ tests/", + "postinstall": "playwright install chromium" }, "dependencies": { "compression": "^1.7.4", @@ -23,5 +28,13 @@ "license": "Apache-2.0", "engines": { "node": ">=22.5.0" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "eslint": "^10.0.3", + "eslint-config-prettier": "^10.1.8", + "globals": "^17.4.0", + "prettier": "^3.8.1", + "vitest": "^4.1.0" } } diff --git a/src/db/database.js b/src/db/database.js index 495dc42..1a80a58 100644 --- a/src/db/database.js +++ b/src/db/database.js @@ -9,8 +9,8 @@ import fs from 'fs'; import { normalizeAvailableFrom } from '../utils.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const DB_DIR = path.join(__dirname, '..', '..', 'data'); -const DB_PATH = path.join(DB_DIR, 'listings.db'); +const DB_DIR = path.join(__dirname, '..', '..', 'data'); +const DB_PATH = path.join(DB_DIR, 'listings.db'); if (!fs.existsSync(DB_DIR)) fs.mkdirSync(DB_DIR, { recursive: true }); @@ -72,15 +72,19 @@ db.exec(` // ── Migrations for existing DBs ─────────────────────────────────────────── -const safeAlter = (sql) => { try { db.exec(sql); } catch (_) {} }; +const safeAlter = (sql) => { + try { + db.exec(sql); + } catch (_) {} +}; // listings table additions safeAlter('ALTER TABLE listings ADD COLUMN rooms TEXT'); safeAlter('ALTER TABLE listings ADD COLUMN publisher TEXT'); safeAlter('ALTER TABLE listings ADD COLUMN listed_at TEXT'); safeAlter('ALTER TABLE listings ADD COLUMN images TEXT'); -safeAlter('ALTER TABLE listings ADD COLUMN provider TEXT DEFAULT \'kleinanzeigen\''); -safeAlter('ALTER TABLE listings ADD COLUMN listing_type TEXT DEFAULT \'miete\''); +safeAlter("ALTER TABLE listings ADD COLUMN provider TEXT DEFAULT 'kleinanzeigen'"); +safeAlter("ALTER TABLE listings ADD COLUMN listing_type TEXT DEFAULT 'miete'"); safeAlter('ALTER TABLE listings ADD COLUMN search_config_id INTEGER'); safeAlter('ALTER TABLE listings ADD COLUMN is_blacklisted INTEGER DEFAULT 0'); @@ -90,7 +94,7 @@ safeAlter('ALTER TABLE scrape_runs ADD COLUMN listing_type TEXT'); safeAlter('ALTER TABLE scrape_runs ADD COLUMN search_config_id INTEGER'); // search_configs table additions -safeAlter('ALTER TABLE search_configs ADD COLUMN name TEXT DEFAULT \'\''); +safeAlter("ALTER TABLE search_configs ADD COLUMN name TEXT DEFAULT ''"); safeAlter('ALTER TABLE search_configs DROP COLUMN city'); safeAlter('ALTER TABLE search_configs DROP COLUMN radius'); @@ -104,28 +108,36 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_listings_link ON listings(link)'); // Migration: set search_config_id = NULL for orphaned listings (deleted agents) { - const result = db.prepare('UPDATE listings SET search_config_id = NULL WHERE search_config_id IS NOT NULL AND search_config_id NOT IN (SELECT id FROM search_configs)').run(); - if (result.changes > 0) console.log(`[db] ${result.changes} orphaned listing(s) cleaned up (deleted agents).`); + const result = db + .prepare( + 'UPDATE listings SET search_config_id = NULL WHERE search_config_id IS NOT NULL AND search_config_id NOT IN (SELECT id FROM search_configs)', + ) + .run(); + if (result.changes > 0) + console.log(`[db] ${result.changes} orphaned listing(s) cleaned up (deleted agents).`); } // Upgrade image URLs of all existing listings to full resolution (one-time migration) { const rows = db.prepare('SELECT id, image FROM listings WHERE image IS NOT NULL').all(); - const upgrade = (u) => u - .replace('/thumbs/images/', '/images/') - .replace(/s-l\d+\./, 's-l1600.'); + const upgrade = (u) => u.replace('/thumbs/images/', '/images/').replace(/s-l\d+\./, 's-l1600.'); const stmt = db.prepare('UPDATE listings SET image = ? WHERE id = ?'); let count = 0; for (const row of rows) { const upgraded = upgrade(row.image); - if (upgraded !== row.image) { stmt.run(upgraded, row.id); count++; } + if (upgraded !== row.image) { + stmt.run(upgraded, row.id); + count++; + } } if (count > 0) console.log(`[db] ${count} image URL(s) upgraded to s-l1600.`); } // Normalize available_from values to ISO where possible { - const rows = db.prepare('SELECT id, available_from FROM listings WHERE available_from IS NOT NULL').all(); + const rows = db + .prepare('SELECT id, available_from FROM listings WHERE available_from IS NOT NULL') + .all(); const stmt = db.prepare('UPDATE listings SET available_from = ? WHERE id = ?'); let count = 0; for (const row of rows) { @@ -140,11 +152,21 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_listings_link ON listings(link)'); // ── Search Configs ────────────────────────────────────────────────────────── -export function createSearchConfig({ provider = 'kleinanzeigen', listingType = 'miete', maxPages = 10, extraParams = {}, name = '' }) { - const res = db.prepare(` +export function createSearchConfig({ + provider = 'kleinanzeigen', + listingType = 'miete', + maxPages = 10, + extraParams = {}, + name = '', +}) { + const res = db + .prepare( + ` INSERT INTO search_configs (provider, listing_type, max_pages, extra_params, name) VALUES (?, ?, ?, ?, ?) - `).run(provider, listingType, maxPages, JSON.stringify(extraParams), name); + `, + ) + .run(provider, listingType, maxPages, JSON.stringify(extraParams), name); return getSearchConfigById(Number(res.lastInsertRowid)); } @@ -166,8 +188,17 @@ export function updateSearchConfig(id, data) { const fields = []; const params = []; for (const [k, v] of Object.entries(data)) { - const col = k === 'listingType' ? 'listing_type' : k === 'maxPages' ? 'max_pages' : k === 'extraParams' ? 'extra_params' : k; - if (['provider', 'listing_type', 'max_pages', 'extra_params', 'enabled', 'name'].includes(col)) { + const col = + k === 'listingType' + ? 'listing_type' + : k === 'maxPages' + ? 'max_pages' + : k === 'extraParams' + ? 'extra_params' + : k; + if ( + ['provider', 'listing_type', 'max_pages', 'extra_params', 'enabled', 'name'].includes(col) + ) { fields.push(`${col} = ?`); params.push(col === 'extra_params' ? JSON.stringify(v) : v); } @@ -180,7 +211,9 @@ export function updateSearchConfig(id, data) { export function deleteSearchConfig(id) { // Detach favorited & blacklisted listings – they survive agent deletion - db.prepare('UPDATE listings SET search_config_id = NULL WHERE search_config_id = ? AND (is_favorite = 1 OR is_blacklisted = 1)').run(id); + db.prepare( + 'UPDATE listings SET search_config_id = NULL WHERE search_config_id = ? AND (is_favorite = 1 OR is_blacklisted = 1)', + ).run(id); // Delete remaining listings for this agent db.prepare('DELETE FROM listings WHERE search_config_id = ?').run(id); // Delete scrape run history for this agent @@ -194,8 +227,13 @@ export function deleteSearchConfig(id) { export function blacklistListing(listingId) { const listing = getListingById(listingId); if (!listing) throw new Error('Listing nicht gefunden'); - db.prepare('INSERT OR IGNORE INTO blacklist (listing_id, url) VALUES (?, ?)').run(listingId, listing.link); - db.prepare("UPDATE listings SET is_blacklisted = 1, is_favorite = 0, favorited_at = NULL, blacklisted_at = datetime('now') WHERE id = ?").run(listingId); + db.prepare('INSERT OR IGNORE INTO blacklist (listing_id, url) VALUES (?, ?)').run( + listingId, + listing.link, + ); + db.prepare( + "UPDATE listings SET is_blacklisted = 1, is_favorite = 0, favorited_at = NULL, blacklisted_at = datetime('now') WHERE id = ?", + ).run(listingId); return { ok: true, wasFavorite: listing.is_favorite === 1 }; } @@ -216,11 +254,15 @@ export function getBlacklistCount() { } export function clearAllFavorites() { - db.prepare('UPDATE listings SET is_favorite = 0, favorited_at = NULL WHERE is_favorite = 1').run(); + db.prepare( + 'UPDATE listings SET is_favorite = 0, favorited_at = NULL WHERE is_favorite = 1', + ).run(); } export function clearFavoritesByConfig(searchConfigId) { - db.prepare('UPDATE listings SET is_favorite = 0, favorited_at = NULL WHERE search_config_id = ? AND is_favorite = 1').run(searchConfigId); + db.prepare( + 'UPDATE listings SET is_favorite = 0, favorited_at = NULL WHERE search_config_id = ? AND is_favorite = 1', + ).run(searchConfigId); } export function clearAllBlacklist() { @@ -229,21 +271,33 @@ export function clearAllBlacklist() { } export function clearBlacklistByConfig(searchConfigId) { - db.prepare(` + db.prepare( + ` DELETE FROM blacklist WHERE listing_id IN ( SELECT id FROM listings WHERE search_config_id = ? ) - `).run(searchConfigId); - db.prepare('UPDATE listings SET is_blacklisted = 0 WHERE search_config_id = ? AND is_blacklisted = 1').run(searchConfigId); + `, + ).run(searchConfigId); + db.prepare( + 'UPDATE listings SET is_blacklisted = 0 WHERE search_config_id = ? AND is_blacklisted = 1', + ).run(searchConfigId); } // ── Stats ──────────────────────────────────────────────────────────────────── export function getStats() { - const total = db.prepare('SELECT COUNT(*) as cnt FROM listings WHERE is_blacklisted = 0').get()?.cnt ?? 0; - const unseen = db.prepare('SELECT COUNT(*) as cnt FROM listings WHERE is_blacklisted = 0 AND is_seen = 0').get()?.cnt ?? 0; - const favorites = db.prepare('SELECT COUNT(*) as cnt FROM listings WHERE is_favorite = 1 AND is_blacklisted = 0').get()?.cnt ?? 0; - const blacklisted = db.prepare('SELECT COUNT(*) as cnt FROM listings WHERE is_blacklisted = 1').get()?.cnt ?? 0; + const total = + db.prepare('SELECT COUNT(*) as cnt FROM listings WHERE is_blacklisted = 0').get()?.cnt ?? 0; + const unseen = + db + .prepare('SELECT COUNT(*) as cnt FROM listings WHERE is_blacklisted = 0 AND is_seen = 0') + .get()?.cnt ?? 0; + const favorites = + db + .prepare('SELECT COUNT(*) as cnt FROM listings WHERE is_favorite = 1 AND is_blacklisted = 0') + .get()?.cnt ?? 0; + const blacklisted = + db.prepare('SELECT COUNT(*) as cnt FROM listings WHERE is_blacklisted = 1').get()?.cnt ?? 0; return { total, unseen, favorites, blacklisted }; } @@ -261,28 +315,41 @@ export function upsertListing(l) { // or is an unpinned orphan (no favorite, no blacklist entry). // Listings from other agents (favorited/blacklisted by another agent) remain // untouched – no cross-agent carry-over. - let carrySeen = 0, carryFav = 0, carryFirstSeen = null; + let carrySeen = 0, + carryFav = 0, + carryFirstSeen = null; if (l.link) { const existingByLink = db.prepare('SELECT * FROM listings WHERE link = ? LIMIT 1').get(l.link); if (existingByLink && existingByLink.id !== l.id) { - const isSameAgent = existingByLink.search_config_id === l.search_config_id; - const isSafeOrphan = existingByLink.search_config_id == null - && !existingByLink.is_favorite - && !existingByLink.is_blacklisted; + const isSameAgent = existingByLink.search_config_id === l.search_config_id; + const isSafeOrphan = + existingByLink.search_config_id == null && + !existingByLink.is_favorite && + !existingByLink.is_blacklisted; if (isSameAgent || isSafeOrphan) { // Same agent or unpinned orphan β†’ carry over flags, replace old entry carrySeen = existingByLink.is_seen ?? 0; - carryFav = existingByLink.is_favorite ?? 0; + carryFav = existingByLink.is_favorite ?? 0; carryFirstSeen = existingByLink.first_seen ?? null; - try { db.prepare('UPDATE blacklist SET listing_id = ? WHERE listing_id = ?').run(l.id, existingByLink.id); } catch {} - try { db.prepare('UPDATE blacklist SET listing_id = ? WHERE url = ?').run(l.id, l.link); } catch {} - try { db.prepare('DELETE FROM listings WHERE id = ?').run(existingByLink.id); } catch {} + try { + db.prepare('UPDATE blacklist SET listing_id = ? WHERE listing_id = ?').run( + l.id, + existingByLink.id, + ); + } catch {} + try { + db.prepare('UPDATE blacklist SET listing_id = ? WHERE url = ?').run(l.id, l.link); + } catch {} + try { + db.prepare('DELETE FROM listings WHERE id = ?').run(existingByLink.id); + } catch {} } // Otherwise (different agent with a pinned entry): leave the old entry untouched, // the new listing will be inserted normally for the current agent. } } - db.prepare(` + db.prepare( + ` INSERT INTO listings (id, source, provider, listing_type, search_config_id, title, price, size, rooms, address, description, publisher, link, image, is_blacklisted, listed_at, available_from, first_seen, last_seen) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET @@ -297,17 +364,35 @@ export function upsertListing(l) { available_from = COALESCE(excluded.available_from, available_from), last_seen = excluded.last_seen, search_config_id = COALESCE(search_config_id, excluded.search_config_id) - `).run( - l.id, l.source ?? l.provider ?? 'kleinanzeigen', l.provider ?? 'kleinanzeigen', l.listing_type ?? 'miete', + `, + ).run( + l.id, + l.source ?? l.provider ?? 'kleinanzeigen', + l.provider ?? 'kleinanzeigen', + l.listing_type ?? 'miete', l.search_config_id ?? null, - l.title, l.price ?? null, l.size ?? null, l.rooms ?? null, - l.address ?? null, l.description ?? null, l.publisher ?? null, l.link, l.image ?? null, - l.is_blacklisted ?? 0, l.listed_at ?? null, l.available_from ?? null, (carryFirstSeen && (!l.first_seen || carryFirstSeen < l.first_seen)) ? carryFirstSeen : l.first_seen, l.last_seen + l.title, + l.price ?? null, + l.size ?? null, + l.rooms ?? null, + l.address ?? null, + l.description ?? null, + l.publisher ?? null, + l.link, + l.image ?? null, + l.is_blacklisted ?? 0, + l.listed_at ?? null, + l.available_from ?? null, + carryFirstSeen && (!l.first_seen || carryFirstSeen < l.first_seen) + ? carryFirstSeen + : l.first_seen, + l.last_seen, ); if (carrySeen || carryFav) { - db.prepare('UPDATE listings SET is_seen = COALESCE(?, is_seen), is_favorite = COALESCE(?, is_favorite) WHERE id = ?') - .run(carrySeen ? 1 : null, carryFav ? 1 : null, l.id); + db.prepare( + 'UPDATE listings SET is_seen = COALESCE(?, is_seen), is_favorite = COALESCE(?, is_favorite) WHERE id = ?', + ).run(carrySeen ? 1 : null, carryFav ? 1 : null, l.id); } } @@ -332,39 +417,54 @@ export function toggleFavorite(id) { if (!listing) return null; if (listing.is_blacklisted) { db.prepare('DELETE FROM blacklist WHERE listing_id = ?').run(id); - db.prepare("UPDATE listings SET is_favorite = 1, is_blacklisted = 0, favorited_at = datetime('now') WHERE id = ?").run(id); + db.prepare( + "UPDATE listings SET is_favorite = 1, is_blacklisted = 0, favorited_at = datetime('now') WHERE id = ?", + ).run(id); } else if (listing.is_favorite) { db.prepare('UPDATE listings SET is_favorite = 0, favorited_at = NULL WHERE id = ?').run(id); } else { - db.prepare("UPDATE listings SET is_favorite = 1, favorited_at = datetime('now') WHERE id = ?").run(id); + db.prepare( + "UPDATE listings SET is_favorite = 1, favorited_at = datetime('now') WHERE id = ?", + ).run(id); } return getListingById(id); } export function getListings({ - onlyUnseen = false, - onlyFavorites = false, - listingType = null, - provider = null, - searchConfigId = null, - hideBlacklisted = true, - showBlacklisted = false, - includeBlacklisted = false, - blacklistKeywords = [], + onlyUnseen = false, + onlyFavorites = false, + listingType = null, + provider = null, + searchConfigId = null, + hideBlacklisted = true, + showBlacklisted = false, + includeBlacklisted = false, + blacklistKeywords = [], } = {}) { const conditions = []; - const params = []; + const params = []; - if (onlyUnseen) conditions.push('is_seen = 0'); - if (onlyFavorites) conditions.push('is_favorite = 1'); - if (showBlacklisted) conditions.push('is_blacklisted = 1'); + if (onlyUnseen) conditions.push('is_seen = 0'); + if (onlyFavorites) conditions.push('is_favorite = 1'); + if (showBlacklisted) conditions.push('is_blacklisted = 1'); else if (!includeBlacklisted && hideBlacklisted) conditions.push('is_blacklisted = 0'); - if (listingType) { conditions.push('listing_type = ?'); params.push(listingType); } - if (provider) { conditions.push('provider = ?'); params.push(provider); } - if (searchConfigId) { conditions.push('search_config_id = ?'); params.push(searchConfigId); } + if (listingType) { + conditions.push('listing_type = ?'); + params.push(listingType); + } + if (provider) { + conditions.push('provider = ?'); + params.push(provider); + } + if (searchConfigId) { + conditions.push('search_config_id = ?'); + params.push(searchConfigId); + } for (const term of blacklistKeywords) { - conditions.push("(LOWER(COALESCE(title,'')) NOT LIKE LOWER(?) AND LOWER(COALESCE(description,'')) NOT LIKE LOWER(?) AND LOWER(COALESCE(publisher,'')) NOT LIKE LOWER(?))"); + conditions.push( + "(LOWER(COALESCE(title,'')) NOT LIKE LOWER(?) AND LOWER(COALESCE(description,'')) NOT LIKE LOWER(?) AND LOWER(COALESCE(publisher,'')) NOT LIKE LOWER(?))", + ); params.push(`%${term}%`, `%${term}%`, `%${term}%`); } const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; @@ -387,22 +487,38 @@ export function purgeListingsKeepPinned() { db.exec('VACUUM'); } -export function purgeListingsByConfig(searchConfigId) { - db.prepare('DELETE FROM listings WHERE search_config_id = ? AND is_favorite = 0 AND is_blacklisted = 0').run(searchConfigId); +export function purgeListingsByConfig(searchConfigId) { + db.prepare( + 'DELETE FROM listings WHERE search_config_id = ? AND is_favorite = 0 AND is_blacklisted = 0', + ).run(searchConfigId); } export function getExistingIds(provider, listingType, searchConfigId = null) { const conditions = []; const params = []; - if (provider) { conditions.push('provider = ?'); params.push(provider); } - if (listingType) { conditions.push('listing_type = ?'); params.push(listingType); } - if (searchConfigId) { conditions.push('search_config_id = ?'); params.push(searchConfigId); } + if (provider) { + conditions.push('provider = ?'); + params.push(provider); + } + if (listingType) { + conditions.push('listing_type = ?'); + params.push(listingType); + } + if (searchConfigId) { + conditions.push('search_config_id = ?'); + params.push(searchConfigId); + } const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; - return db.prepare(`SELECT id FROM listings ${where}`).all(...params).map(r => r.id); + return db + .prepare(`SELECT id FROM listings ${where}`) + .all(...params) + .map((r) => r.id); } export function getStatsPerConfig() { - return db.prepare(` + return db + .prepare( + ` SELECT search_config_id, SUM(CASE WHEN is_blacklisted = 0 THEN 1 ELSE 0 END) as total, @@ -411,37 +527,49 @@ export function getStatsPerConfig() { SUM(CASE WHEN is_blacklisted = 1 THEN 1 ELSE 0 END) as blacklisted FROM listings GROUP BY search_config_id - `).all(); + `, + ) + .all(); } // Stats for detached listings (search_config_id IS NULL) – favorites of deleted agents export function getOrphanStats() { - const row = db.prepare(` + const row = db + .prepare( + ` SELECT COUNT(*) as total, SUM(CASE WHEN is_seen = 0 THEN 1 ELSE 0 END) as unseen, SUM(CASE WHEN is_favorite = 1 THEN 1 ELSE 0 END) as favorites FROM listings WHERE search_config_id IS NULL AND is_blacklisted = 0 - `).get(); + `, + ) + .get(); return { total: row?.total ?? 0, unseen: row?.unseen ?? 0, favorites: row?.favorites ?? 0 }; } // ── Scrape-Runs ─────────────────────────────────────────────────────────────── export function startScrapeRun(source, startedAt, { provider, listingType, searchConfigId } = {}) { - const res = db.prepare(` + const res = db + .prepare( + ` INSERT INTO scrape_runs (source, started_at, status, provider, listing_type, search_config_id) VALUES (?, ?, 'running', ?, ?, ?) - `).run(source, startedAt, provider ?? source, listingType ?? null, searchConfigId ?? null); + `, + ) + .run(source, startedAt, provider ?? source, listingType ?? null, searchConfigId ?? null); return Number(res.lastInsertRowid); } export function finishScrapeRun(runId, { endedAt, status, newCount, totalCount, error }) { - db.prepare(` + db.prepare( + ` UPDATE scrape_runs SET ended_at = ?, status = ?, new_count = ?, total_count = ?, error = ? WHERE id = ? - `).run(endedAt, status, newCount, totalCount, error ?? null, runId); + `, + ).run(endedAt, status, newCount, totalCount, error ?? null, runId); } export function getRecentRuns(limit = 20) { diff --git a/src/middleware/errorHandler.js b/src/middleware/errorHandler.js index f81f695..a0de89a 100644 --- a/src/middleware/errorHandler.js +++ b/src/middleware/errorHandler.js @@ -19,7 +19,7 @@ export function notFoundHandler(req, res, next) { /** * Global error handler. */ -export function errorHandler(err, req, res, next) { +export function errorHandler(err, req, res, _next) { const status = err.status || err.statusCode || 500; const message = err.message || 'Internal Server Error'; @@ -51,7 +51,7 @@ export function errorHandler(err, req, res, next) { */ export function requestLogger(req, res, next) { const start = Date.now(); - + res.on('finish', () => { const duration = Date.now() - start; const logLevel = res.statusCode >= 400 ? 'ERROR' : 'INFO'; diff --git a/src/providers/immoscout24/index.js b/src/providers/immoscout24/index.js index 04ebdd2..2d2ad7b 100644 --- a/src/providers/immoscout24/index.js +++ b/src/providers/immoscout24/index.js @@ -12,10 +12,17 @@ * - shorttermaccommodation β†’ Wohnen auf Zeit */ -import { buildHash, LISTING_PATTERNS, parsePublishedDate, pickByPattern, sleep } from '../../utils.js'; +import { + buildHash, + LISTING_PATTERNS, + parsePublishedDate, + pickByPattern, + sleep, +} from '../../utils.js'; // Set LOG_RAW_VS_PARSED=1 to enable per-listing debug output. -const LOG_RAW_VS_PARSED = process.env.LOG_RAW_VS_PARSED === '1' || process.env.LOG_RAW_VS_PARSED === 'true'; +const LOG_RAW_VS_PARSED = + process.env.LOG_RAW_VS_PARSED === '1' || process.env.LOG_RAW_VS_PARSED === 'true'; // ── Error Class ───────────────────────────────────────────────────────────── @@ -30,10 +37,10 @@ class Is24ProviderError extends Error { // ── Property Types ────────────────────────────────────────────────────────── const PROPERTY_KINDS = [ - { key: 'apartmentrent', label: 'Wohnung mieten' }, - { key: 'apartmentbuy', label: 'Wohnung kaufen' }, - { key: 'houserent', label: 'Haus mieten' }, - { key: 'housebuy', label: 'Haus kaufen' }, + { key: 'apartmentrent', label: 'Wohnung mieten' }, + { key: 'apartmentbuy', label: 'Wohnung kaufen' }, + { key: 'houserent', label: 'Haus mieten' }, + { key: 'housebuy', label: 'Haus kaufen' }, { key: 'shorttermaccommodation', label: 'Wohnen auf Zeit' }, ]; @@ -42,10 +49,10 @@ const KNOWN_PROPERTY_KEYS = new Set(PROPERTY_KINDS.map((k) => k.key)); // ── Path Routing ──────────────────────────────────────────────────────────── const PATH_ROUTES = [ - { suffix: 'wohnung-mieten', kind: 'apartmentrent' }, - { suffix: 'wohnung-kaufen', kind: 'apartmentbuy' }, - { suffix: 'haus-mieten', kind: 'houserent' }, - { suffix: 'haus-kaufen', kind: 'housebuy' }, + { suffix: 'wohnung-mieten', kind: 'apartmentrent' }, + { suffix: 'wohnung-kaufen', kind: 'apartmentbuy' }, + { suffix: 'haus-mieten', kind: 'houserent' }, + { suffix: 'haus-kaufen', kind: 'housebuy' }, { suffix: 'wohnen-auf-zeit', kind: 'shorttermaccommodation' }, ]; @@ -56,15 +63,29 @@ function findRoute(suffix) { // ── Parameter Processing ──────────────────────────────────────────────────── const PASSTHROUGH_KEYS = new Set([ - 'price', 'pricetype', 'numberofrooms', 'livingspace', - 'geocoordinates', 'geocodes', 'sorting', 'fulltext', - 'apartmenttypes', 'floor', 'newbuilding', 'equipment', - 'petsallowedtypes', 'constructionyear', 'energyefficiencyclasses', - 'exclusioncriteria', 'heatingtypes', 'haspromotion', 'startrentaldate', + 'price', + 'pricetype', + 'numberofrooms', + 'livingspace', + 'geocoordinates', + 'geocodes', + 'sorting', + 'fulltext', + 'apartmenttypes', + 'floor', + 'newbuilding', + 'equipment', + 'petsallowedtypes', + 'constructionyear', + 'energyefficiencyclasses', + 'exclusioncriteria', + 'heatingtypes', + 'haspromotion', + 'startrentaldate', ]); // Sort codes: Web UI uses numeric IDs, the API uses named identifiers -const SORT_LABELS = { '1': 'standard', '2': '-firstactivation' }; +const SORT_LABELS = { 1: 'standard', 2: '-firstactivation' }; function resolveSortCode(code) { return SORT_LABELS[code] ?? code; @@ -74,15 +95,15 @@ function resolveSortCode(code) { /** * Extracts geo information from the path segments of an IS24 URL. - * @param {string[]} segments + * @param {string[]} segments * @returns {{ isRadius: boolean, path: string, segments: string[] }} */ function extractGeoInfo(segments) { - const cleaned = segments.filter((s) => s.toLowerCase() !== 'suche'); + const cleaned = segments.filter((s) => s.toLowerCase() !== 'suche'); const isRadius = cleaned.includes('radius'); return { isRadius, - path: '/' + cleaned.join('/'), + path: '/' + cleaned.join('/'), segments: cleaned, }; } @@ -122,13 +143,13 @@ function parseSearchFromUrl(webUrl) { if (!route) { throw new Is24ProviderError( 'Spezielle Filter werden nicht unterstΓΌtzt. ' + - 'Bitte verwende eine Standard-Suche (Wohnung/Haus mieten oder kaufen, Wohnen auf Zeit).', + 'Bitte verwende eine Standard-Suche (Wohnung/Haus mieten oder kaufen, Wohnen auf Zeit).', ); } return { - type: route.kind, - geo: extractGeoInfo(parts.slice(0, -1)), + type: route.kind, + geo: extractGeoInfo(parts.slice(0, -1)), params: extractParams(parsed.searchParams), }; } @@ -141,7 +162,7 @@ function parseSearchFromUrl(webUrl) { */ function buildApiEndpoint(search) { const fields = { - searchType: search.geo.isRadius ? 'radius' : 'region', + searchType: search.geo.isRadius ? 'radius' : 'region', realestatetype: search.type, }; @@ -188,7 +209,7 @@ export function validateUrl(url) { const REQUEST_HEADERS = { 'User-Agent': 'ImmoScout_28.3_34.0_._', - 'Accept': 'application/json', + Accept: 'application/json', }; async function requestPage(baseEndpoint, pageIdx, log = console.log) { @@ -196,48 +217,49 @@ async function requestPage(baseEndpoint, pageIdx, log = console.log) { log(`[immoscout24] Seite ${pageIdx}: ${endpoint}`); const response = await fetch(endpoint, { - method: 'POST', + method: 'POST', headers: REQUEST_HEADERS, }); if (!response.ok) { - const userMessage = response.status >= 400 && response.status < 500 - ? `Diese Such-URL wird nicht unterstΓΌtzt. Bitte verwende eine Standard-Suche ohne Kartenausschnitt oder spezielle Filter.` - : `API nicht erreichbar (HTTP ${response.status}). Bitte spΓ€ter erneut versuchen.`; + const userMessage = + response.status >= 400 && response.status < 500 + ? `Diese Such-URL wird nicht unterstΓΌtzt. Bitte verwende eine Standard-Suche ohne Kartenausschnitt oder spezielle Filter.` + : `API nicht erreichbar (HTTP ${response.status}). Bitte spΓ€ter erneut versuchen.`; throw new Is24ProviderError(userMessage, { page: pageIdx, status: response.status }); } return response.json(); } -function transformResultItem(raw) { +export function transformResultItem(raw) { if (!raw?.id) return null; - const attrs = raw.attributes ?? []; + const attrs = raw.attributes ?? []; const attrValues = attrs.map((a) => String(a.value ?? '')); - const link = `https://www.immobilienscout24.de/expose/${raw.id}`; + const link = `https://www.immobilienscout24.de/expose/${raw.id}`; return { - id: buildHash('immoscout24', String(raw.id)), - title: raw.title ?? '', - price: pickByPattern(attrValues, 'price'), - size: pickByPattern(attrValues, 'size'), - rooms: pickByPattern(attrValues, 'rooms'), + id: buildHash('immoscout24', String(raw.id)), + title: raw.title ?? '', + price: pickByPattern(attrValues, 'price'), + size: pickByPattern(attrValues, 'size'), + rooms: pickByPattern(attrValues, 'rooms'), availableFrom: pickByPattern(attrValues, 'date'), - address: raw.address?.line ?? null, + address: raw.address?.line ?? null, description: null, - publisher: raw.isPrivate ? 'Privat' : 'Makler', + publisher: raw.isPrivate ? 'Privat' : 'Makler', link, - image: raw.titlePicture?.full ?? raw.titlePicture?.preview ?? null, - listedAt: parsePublishedDate(raw.published), - lat: raw.address?.lat ?? null, - lon: raw.address?.lon ?? null, + image: raw.titlePicture?.full ?? raw.titlePicture?.preview ?? null, + listedAt: parsePublishedDate(raw.published), + lat: raw.address?.lat ?? null, + lon: raw.address?.lon ?? null, }; } export function logRawVsParsed(raw, parsed, log = console.log) { - const attrs = raw.attributes ?? []; - const sep = '─'.repeat(60); + const attrs = raw.attributes ?? []; + const sep = '─'.repeat(60); const hasWarn = attrs.some((a) => { const v = String(a.value ?? ''); return !Object.values(LISTING_PATTERNS).some((re) => re.test(v)); @@ -245,13 +267,15 @@ export function logRawVsParsed(raw, parsed, log = console.log) { log(`[immoscout24] β”Œ ${sep}`); log(`[immoscout24] β”‚ ${raw.title || '(kein Titel)'}`); - log(`[immoscout24] β”‚ Expose-ID : ${raw.id} β†’ https://www.immobilienscout24.de/expose/${raw.id}`); + log( + `[immoscout24] β”‚ Expose-ID : ${raw.id} β†’ https://www.immobilienscout24.de/expose/${raw.id}`, + ); log(`[immoscout24] β”‚ Anbieter : ${raw.isPrivate ? 'Privat' : 'Makler'}`); log(`[immoscout24] β”‚ Adresse : ${raw.address?.line ?? '–'}`); log(`[immoscout24] β”‚ VerΓΆff. : ${raw.published ?? '–'}`); log(`[immoscout24] β”‚ ── Rohe Attribute β†’ Erkennung ──────────────────────────`); for (let i = 0; i < attrs.length; i++) { - const val = String(attrs[i]?.value ?? ''); + const val = String(attrs[i]?.value ?? ''); const matched = Object.entries(LISTING_PATTERNS) .filter(([, re]) => re.test(val)) .map(([k]) => k); @@ -260,7 +284,7 @@ export function logRawVsParsed(raw, parsed, log = console.log) { } log(`[immoscout24] β”‚ ── Parsed ───────────────────────────────────────────────`); log(`[immoscout24] β”‚ Preis : ${parsed.price ?? '–'}`); - log(`[immoscout24] β”‚ Grâße : ${parsed.size ?? '–'}`); + log(`[immoscout24] β”‚ Grâße : ${parsed.size ?? '–'}`); log(`[immoscout24] β”‚ Zimmer : ${parsed.rooms ?? (hasWarn ? '– (⚠ fehlend)' : '–')}`); log(`[immoscout24] β”‚ Einzug : ${parsed.availableFrom ?? '–'}`); log(`[immoscout24] β”” ${sep}`); @@ -272,11 +296,13 @@ function collectListingsFromResponse(payload) { .filter((entry) => entry.type === 'EXPOSE_RESULT') .map((entry) => entry.item) .filter(Boolean); - const listings = rawItems.map((raw) => { - const parsed = transformResultItem(raw); - if (parsed && LOG_RAW_VS_PARSED) logRawVsParsed(raw, parsed); - return parsed; - }).filter(Boolean); + const listings = rawItems + .map((raw) => { + const parsed = transformResultItem(raw); + if (parsed && LOG_RAW_VS_PARSED) logRawVsParsed(raw, parsed); + return parsed; + }) + .filter(Boolean); return { listings, rawItems }; } @@ -305,11 +331,7 @@ export async function scrape(inputUrl, maxPages = 10, opts = {}) { * @returns {Promise<{ mobileUrl: string, hitCount: number|string, pageCount: number, targetPages: number, pages: Array<{ pageNum: number, listings: object[] }> }>} */ export async function scrapePages(inputUrl, maxPages = 10, opts = {}) { - const { - signal, - onProgress, - log = console.log, - } = opts; + const { signal, onProgress, log = console.log } = opts; // Detect if already an API URL const isApiEndpoint = inputUrl.includes('api.mobile.immobilienscout24.de'); @@ -330,9 +352,9 @@ export async function scrapePages(inputUrl, maxPages = 10, opts = {}) { } // Fetch first page (contains paging metadata) - const firstPage = await requestPage(apiBase, 1, log); - const pageCount = firstPage.numberOfPages ?? 1; - const hitCount = firstPage.totalResults ?? '?'; + const firstPage = await requestPage(apiBase, 1, log); + const pageCount = firstPage.numberOfPages ?? 1; + const hitCount = firstPage.totalResults ?? '?'; const targetPages = Math.min(maxPages, pageCount); const pages = []; @@ -367,7 +389,7 @@ export async function scrapePages(inputUrl, maxPages = 10, opts = {}) { // ── Provider Exports ──────────────────────────────────────────────────────── -export const id = 'immoscout24'; +export const id = 'immoscout24'; export const name = 'ImmobilienScout24'; export const listingTypes = PROPERTY_KINDS.map(({ key, label }) => ({ id: key, label })); @@ -379,19 +401,23 @@ export function inferListingTypeFromUrl(url) { // Web URL: resolve suffix via routing table try { - const parsed = new URL(url); - const parts = parsed.pathname.split('/').filter(Boolean); - const tail = parts[parts.length - 1]; - const route = findRoute(tail); + const parsed = new URL(url); + const parts = parsed.pathname.split('/').filter(Boolean); + const tail = parts[parts.length - 1]; + const route = findRoute(tail); if (route) return route.kind; - } catch { /* ignore */ } + } catch { + /* ignore */ + } // Mobile API URL: read realestatetype parameter try { const parsed = new URL(url); const rt = parsed.searchParams.get('realestatetype'); if (rt && KNOWN_PROPERTY_KEYS.has(rt)) return rt; - } catch { /* ignore */ } + } catch { + /* ignore */ + } return 'apartmentrent'; } diff --git a/src/providers/kleinanzeigen/index.js b/src/providers/kleinanzeigen/index.js index 5865f25..2da903a 100644 --- a/src/providers/kleinanzeigen/index.js +++ b/src/providers/kleinanzeigen/index.js @@ -43,21 +43,19 @@ function buildPageUrl(baseUrl, pageNum) { /** Replaces thumbnail path and low resolution with a larger image */ function upscaleImageUrl(url) { - return (url || '') - .replace('/thumbs/images/', '/images/') - .replace(/s-l\d+\./, 's-l640.'); + return (url || '').replace('/thumbs/images/', '/images/').replace(/s-l\d+\./, 's-l640.'); } function parseListing(raw) { - const link = `https://www.kleinanzeigen.de${raw.link}`; + const link = `https://www.kleinanzeigen.de${raw.link}`; const tagParts = (raw.tags ?? '').split('Β·').map((s) => s.trim()); return { ...raw, - id: buildHash(raw.id, link), + id: buildHash(raw.id, link), link, - size: pickByPattern(tagParts, 'size'), - rooms: pickByPattern(tagParts, 'rooms'), - image: upscaleImageUrl(raw.image), + size: pickByPattern(tagParts, 'size'), + rooms: pickByPattern(tagParts, 'rooms'), + image: upscaleImageUrl(raw.image), listedAt: parsePublishedDate(raw.listedAt), }; } @@ -65,16 +63,16 @@ function parseListing(raw) { // ── Field Extraction ───────────────────────────────────────────────────────────── const FIELD_EXTRACTORS = { - id: { attr: 'data-adid', scope: '.aditem' }, - price: { text: '.aditem-main--middle--price-shipping--price' }, - tags: { text: '.aditem-main--middle--tags' }, - title: { text: '.aditem-main .text-module-begin a' }, - link: { attr: 'href', scope: '.aditem-main .text-module-begin a' }, + id: { attr: 'data-adid', scope: '.aditem' }, + price: { text: '.aditem-main--middle--price-shipping--price' }, + tags: { text: '.aditem-main--middle--tags' }, + title: { text: '.aditem-main .text-module-begin a' }, + link: { attr: 'href', scope: '.aditem-main .text-module-begin a' }, description: { text: '.aditem-main .aditem-main--middle--description' }, - address: { text: '.aditem-main--top--left' }, - listedAt: { text: '.aditem-main--top--right' }, - image: { attr: 'src', scope: 'img' }, - publisher: { text: '.aditem-main--bottom' }, + address: { text: '.aditem-main--top--left' }, + listedAt: { text: '.aditem-main--top--right' }, + image: { attr: 'src', scope: 'img' }, + publisher: { text: '.aditem-main--bottom' }, }; // ── Crawl Config Builder ─────────────────────────────────────────────────────── diff --git a/src/providers/registry.js b/src/providers/registry.js index e2eb106..7d57e4a 100644 --- a/src/providers/registry.js +++ b/src/providers/registry.js @@ -22,7 +22,6 @@ function register(provider) { register(kleinanzeigen); register(immoscout24); - /** * Returns a provider by its ID. */ diff --git a/src/routes/configs.js b/src/routes/configs.js index ac2ea39..cc26a34 100644 --- a/src/routes/configs.js +++ b/src/routes/configs.js @@ -18,100 +18,66 @@ const router = Router(); // ── Providers ────────────────────────────────────────────────────────────────── // GET /api/providers – list all available providers with listing types -router.get('/providers', asyncHandler(async (_req, res) => { - res.json(getAllProviders()); -})); +router.get( + '/providers', + asyncHandler(async (_req, res) => { + res.json(getAllProviders()); + }), +); // POST /api/configs/infer-url – infers provider and listing type from a URL -router.post('/infer-url', asyncHandler(async (req, res) => { - const { url } = req.body; +router.post( + '/infer-url', + asyncHandler(async (req, res) => { + const { url } = req.body; if (!url) return res.status(400).json({ error: 'URL fehlt' }); - const result = inferFromUrl(url); - if (!result) return res.json({ detected: false }); - const provider = getProvider(result.providerId); - const typeInfo = provider?.listingTypes?.find(t => t.id === result.listingTypeId); - return res.json({ - detected: true, - providerId: result.providerId, - listingTypeId: result.listingTypeId, - listingTypeLabel: typeInfo?.label || result.listingTypeId, - }); -})); + const result = inferFromUrl(url); + if (!result) return res.json({ detected: false }); + const provider = getProvider(result.providerId); + const typeInfo = provider?.listingTypes?.find((t) => t.id === result.listingTypeId); + return res.json({ + detected: true, + providerId: result.providerId, + listingTypeId: result.listingTypeId, + listingTypeLabel: typeInfo?.label || result.listingTypeId, + }); + }), +); // ── Search Configs ───────────────────────────────────────────────────────────── // GET /api/configs – all search configurations -router.get('/', asyncHandler(async (_req, res) => { - const configs = getAllSearchConfigs(); +router.get( + '/', + asyncHandler(async (_req, res) => { + const configs = getAllSearchConfigs(); // Compute the current scrape URL for each config - const enriched = configs.map(cfg => { - let scrapeUrl = ''; - try { - const extraParams = JSON.parse(cfg.extra_params || '{}'); - scrapeUrl = extraParams.directUrl || ''; - } catch {} - return { ...cfg, scrape_url: scrapeUrl }; - }); - res.json(enriched); -})); + const enriched = configs.map((cfg) => { + let scrapeUrl = ''; + try { + const extraParams = JSON.parse(cfg.extra_params || '{}'); + scrapeUrl = extraParams.directUrl || ''; + } catch {} + return { ...cfg, scrape_url: scrapeUrl }; + }); + res.json(enriched); + }), +); // POST /api/configs – create a new search configuration -router.post('/', asyncHandler(async (req, res) => { - const directUrl = req.body.directUrl || ''; - const name = req.body.name?.trim() || ''; - - if (!name) { - return res.status(400).json({ error: 'Name ist erforderlich.' }); - } - if (!directUrl) { - return res.status(400).json({ error: 'Scrape-URL ist erforderlich.' }); - } - - // Validate IS24 URL against supported search categories - if (directUrl.includes('immobilienscout24.de')) { - try { - validateIs24Url(directUrl); - } catch (e) { - return res.status(400).json({ error: e.message }); +router.post( + '/', + asyncHandler(async (req, res) => { + const directUrl = req.body.directUrl || ''; + const name = req.body.name?.trim() || ''; + + if (!name) { + return res.status(400).json({ error: 'Name ist erforderlich.' }); } - } - - const extraParams = { directUrl }; - console.log(`[config] New config: ${name}`); - - // Auto-detect listing type & provider from URL - let providerId = req.body.provider || 'kleinanzeigen'; - let listingType = req.body.listingType ?? null; - if (!listingType) { - const inferred = inferFromUrl(directUrl); - if (inferred) { - listingType = inferred.listingTypeId; - providerId = inferred.providerId; - console.log(`[config] Inferred from URL: provider=${providerId}, type=${listingType}`); - } else { - listingType = 'miete'; // safe fallback + if (!directUrl) { + return res.status(400).json({ error: 'Scrape-URL ist erforderlich.' }); } - } - const config = createSearchConfig({ - provider: providerId, - listingType, - maxPages: Number(req.body.maxPages) || 10, - extraParams, - name, - }); - - res.status(201).json({ ...config, scrape_url: directUrl }); -})); - -// PATCH /api/configs/:id – update a search configuration -router.patch('/:id', asyncHandler(async (req, res) => { - const id = Number(req.params.id); - const data = {}; - - if (req.body.directUrl !== undefined) { - const directUrl = req.body.directUrl; - data.extraParams = { directUrl }; // Validate IS24 URL against supported search categories if (directUrl.includes('immobilienscout24.de')) { try { @@ -121,36 +87,88 @@ router.patch('/:id', asyncHandler(async (req, res) => { } } - // Auto-infer listing type from new URL if not explicitly supplied - if (req.body.listing_type === undefined && req.body.listingType === undefined) { + const extraParams = { directUrl }; + console.log(`[config] New config: ${name}`); + + // Auto-detect listing type & provider from URL + let providerId = req.body.provider || 'kleinanzeigen'; + let listingType = req.body.listingType ?? null; + if (!listingType) { const inferred = inferFromUrl(directUrl); if (inferred) { - data.listingType = inferred.listingTypeId; - data.provider = inferred.providerId; + listingType = inferred.listingTypeId; + providerId = inferred.providerId; + console.log(`[config] Inferred from URL: provider=${providerId}, type=${listingType}`); + } else { + listingType = 'miete'; // safe fallback } } - } - if (req.body.maxPages !== undefined) data.maxPages = Number(req.body.maxPages); - if (req.body.enabled !== undefined) data.enabled = req.body.enabled ? 1 : 0; - if (req.body.name !== undefined) data.name = req.body.name; - if (req.body.listingType !== undefined) data.listingType = req.body.listingType; - if (req.body.provider !== undefined) data.provider = req.body.provider; - const updated = updateSearchConfig(id, data); + const config = createSearchConfig({ + provider: providerId, + listingType, + maxPages: Number(req.body.maxPages) || 10, + extraParams, + name, + }); + + res.status(201).json({ ...config, scrape_url: directUrl }); + }), +); - let scrapeUrl = ''; - try { - const extraParams = JSON.parse(updated.extra_params || '{}'); - scrapeUrl = extraParams.directUrl || ''; - } catch {} +// PATCH /api/configs/:id – update a search configuration +router.patch( + '/:id', + asyncHandler(async (req, res) => { + const id = Number(req.params.id); + const data = {}; + + if (req.body.directUrl !== undefined) { + const directUrl = req.body.directUrl; + data.extraParams = { directUrl }; + // Validate IS24 URL against supported search categories + if (directUrl.includes('immobilienscout24.de')) { + try { + validateIs24Url(directUrl); + } catch (e) { + return res.status(400).json({ error: e.message }); + } + } + + // Auto-infer listing type from new URL if not explicitly supplied + if (req.body.listing_type === undefined && req.body.listingType === undefined) { + const inferred = inferFromUrl(directUrl); + if (inferred) { + data.listingType = inferred.listingTypeId; + data.provider = inferred.providerId; + } + } + } + if (req.body.maxPages !== undefined) data.maxPages = Number(req.body.maxPages); + if (req.body.enabled !== undefined) data.enabled = req.body.enabled ? 1 : 0; + if (req.body.name !== undefined) data.name = req.body.name; + if (req.body.listingType !== undefined) data.listingType = req.body.listingType; + if (req.body.provider !== undefined) data.provider = req.body.provider; + + const updated = updateSearchConfig(id, data); + + let scrapeUrl = ''; + try { + const extraParams = JSON.parse(updated.extra_params || '{}'); + scrapeUrl = extraParams.directUrl || ''; + } catch {} - res.json({ ...updated, scrape_url: scrapeUrl }); -})); + res.json({ ...updated, scrape_url: scrapeUrl }); + }), +); // DELETE /api/configs/:id – delete a search configuration -router.delete('/:id', asyncHandler(async (req, res) => { - deleteSearchConfig(Number(req.params.id)); - res.json({ ok: true }); -})); +router.delete( + '/:id', + asyncHandler(async (req, res) => { + deleteSearchConfig(Number(req.params.id)); + res.json({ ok: true }); + }), +); export default router; diff --git a/src/routes/listings.js b/src/routes/listings.js index 51ef0c1..2d7d101 100644 --- a/src/routes/listings.js +++ b/src/routes/listings.js @@ -32,8 +32,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const CONFIG_PATH = path.join(__dirname, '..', '..', 'config', 'default.json'); function readConfig() { - try { return JSON.parse(readFileSync(CONFIG_PATH, 'utf8')); } - catch { return { blacklistKeywords: [] }; } + try { + return JSON.parse(readFileSync(CONFIG_PATH, 'utf8')); + } catch { + return { blacklistKeywords: [] }; + } } function countStats(rows) { @@ -47,135 +50,176 @@ function countStats(rows) { const router = Router(); // DELETE /api/listings/clear-favorites – resets all favorites -router.delete('/clear-favorites', asyncHandler(async (_req, res) => { - clearAllFavorites(); - res.json({ ok: true }); -})); +router.delete( + '/clear-favorites', + asyncHandler(async (_req, res) => { + clearAllFavorites(); + res.json({ ok: true }); + }), +); // DELETE /api/listings/clear-favorites/:configId – resets favorites for a single agent -router.delete('/clear-favorites/:configId', asyncHandler(async (req, res) => { - clearFavoritesByConfig(Number(req.params.configId)); - res.json({ ok: true }); -})); +router.delete( + '/clear-favorites/:configId', + asyncHandler(async (req, res) => { + clearFavoritesByConfig(Number(req.params.configId)); + res.json({ ok: true }); + }), +); // DELETE /api/listings/clear-blacklist – clears the entire blacklist -router.delete('/clear-blacklist', asyncHandler(async (_req, res) => { - clearAllBlacklist(); - res.json({ ok: true }); -})); +router.delete( + '/clear-blacklist', + asyncHandler(async (_req, res) => { + clearAllBlacklist(); + res.json({ ok: true }); + }), +); // DELETE /api/listings/clear-blacklist/:configId – clears the blacklist for a single agent -router.delete('/clear-blacklist/:configId', asyncHandler(async (req, res) => { - clearBlacklistByConfig(Number(req.params.configId)); - res.json({ ok: true }); -})); +router.delete( + '/clear-blacklist/:configId', + asyncHandler(async (req, res) => { + clearBlacklistByConfig(Number(req.params.configId)); + res.json({ ok: true }); + }), +); // DELETE /api/listings/reset – deletes all listings (favorites & blacklist are retained) -router.delete('/reset', asyncHandler(async (_req, res) => { - purgeListingsKeepPinned(); - res.json({ ok: true }); -})); +router.delete( + '/reset', + asyncHandler(async (_req, res) => { + purgeListingsKeepPinned(); + res.json({ ok: true }); + }), +); // DELETE /api/listings/reset/:configId – deletes listings for a single search agent -router.delete('/reset/:configId', asyncHandler(async (req, res) => { - purgeListingsByConfig(Number(req.params.configId)); - res.json({ ok: true }); -})); +router.delete( + '/reset/:configId', + asyncHandler(async (req, res) => { + purgeListingsByConfig(Number(req.params.configId)); + res.json({ ok: true }); + }), +); // GET /api/listings/runs -router.get('/runs', asyncHandler(async (_req, res) => { - res.json(getRecentRuns(50)); -})); +router.get( + '/runs', + asyncHandler(async (_req, res) => { + res.json(getRecentRuns(50)); + }), +); // PATCH /api/listings/seen-all -router.patch('/seen-all', asyncHandler(async (_req, res) => { - markAllSeen(); - res.json({ ok: true }); -})); +router.patch( + '/seen-all', + asyncHandler(async (_req, res) => { + markAllSeen(); + res.json({ ok: true }); + }), +); // PATCH /api/listings/:id/unseen -router.patch('/:id/unseen', asyncHandler(async (req, res) => { - markUnseen(req.params.id); - res.json({ ok: true }); -})); +router.patch( + '/:id/unseen', + asyncHandler(async (req, res) => { + markUnseen(req.params.id); + res.json({ ok: true }); + }), +); // GET /api/listings/stats -router.get('/stats', asyncHandler(async (_req, res) => { - const cfg = readConfig(); - const blacklistKeywords = cfg.blacklistKeywords ?? []; - - const visibleRows = getListings({ - hideBlacklisted: true, - showBlacklisted: false, - blacklistKeywords, - }); - const blacklistedRows = getListings({ - hideBlacklisted: false, - showBlacklisted: true, - blacklistKeywords, - }); - - res.json({ - ...countStats(visibleRows), - blacklisted: blacklistedRows.length, - }); -})); - -// GET /api/listings/stats/per-config -router.get('/stats/per-config', asyncHandler(async (_req, res) => { - const cfg = readConfig(); - const blacklistKeywords = cfg.blacklistKeywords ?? []; +router.get( + '/stats', + asyncHandler(async (_req, res) => { + const cfg = readConfig(); + const blacklistKeywords = cfg.blacklistKeywords ?? []; - const perConfig = getAllSearchConfigs().map((searchConfig) => { const visibleRows = getListings({ - searchConfigId: searchConfig.id, hideBlacklisted: true, showBlacklisted: false, blacklistKeywords, }); const blacklistedRows = getListings({ - searchConfigId: searchConfig.id, hideBlacklisted: false, showBlacklisted: true, blacklistKeywords, }); - const visibleStats = countStats(visibleRows); - return { - search_config_id: searchConfig.id, - total: visibleStats.total, - unseen: visibleStats.unseen, - favorites: visibleStats.favorites, + res.json({ + ...countStats(visibleRows), blacklisted: blacklistedRows.length, - }; - }); - const orphans = getOrphanStats(); - res.json({ perConfig, orphans }); -})); + }); + }), +); + +// GET /api/listings/stats/per-config +router.get( + '/stats/per-config', + asyncHandler(async (_req, res) => { + const cfg = readConfig(); + const blacklistKeywords = cfg.blacklistKeywords ?? []; + + const perConfig = getAllSearchConfigs().map((searchConfig) => { + const visibleRows = getListings({ + searchConfigId: searchConfig.id, + hideBlacklisted: true, + showBlacklisted: false, + blacklistKeywords, + }); + const blacklistedRows = getListings({ + searchConfigId: searchConfig.id, + hideBlacklisted: false, + showBlacklisted: true, + blacklistKeywords, + }); + const visibleStats = countStats(visibleRows); + + return { + search_config_id: searchConfig.id, + total: visibleStats.total, + unseen: visibleStats.unseen, + favorites: visibleStats.favorites, + blacklisted: blacklistedRows.length, + }; + }); + const orphans = getOrphanStats(); + res.json({ perConfig, orphans }); + }), +); // GET /api/listings -router.get('/', asyncHandler(async (req, res) => { - const onlyUnseen = req.query.unseen === 'true'; - const onlyFavorites = req.query.favorites === 'true'; - const listingType = req.query.type || null; - const provider = req.query.provider || null; - const searchConfigId = req.query.search_config_id ? Number(req.query.search_config_id) : null; - const showBlacklisted = req.query.blacklisted === 'true'; - const includeBlacklisted = req.query.include_blacklisted === 'true'; - const hideBlacklisted = !showBlacklisted; - - const cfg = readConfig(); - const blacklistKeywords = cfg.blacklistKeywords ?? []; - - const listings = getListings({ - onlyUnseen, onlyFavorites, - listingType, provider, searchConfigId, - hideBlacklisted, showBlacklisted, includeBlacklisted, - blacklistKeywords, - }); - - res.json(listings); -})); +router.get( + '/', + asyncHandler(async (req, res) => { + const onlyUnseen = req.query.unseen === 'true'; + const onlyFavorites = req.query.favorites === 'true'; + const listingType = req.query.type || null; + const provider = req.query.provider || null; + const searchConfigId = req.query.search_config_id ? Number(req.query.search_config_id) : null; + const showBlacklisted = req.query.blacklisted === 'true'; + const includeBlacklisted = req.query.include_blacklisted === 'true'; + const hideBlacklisted = !showBlacklisted; + + const cfg = readConfig(); + const blacklistKeywords = cfg.blacklistKeywords ?? []; + + const listings = getListings({ + onlyUnseen, + onlyFavorites, + listingType, + provider, + searchConfigId, + hideBlacklisted, + showBlacklisted, + includeBlacklisted, + blacklistKeywords, + }); + + res.json(listings); + }), +); // ── Image Fetching ────────────────────────────────────────────────────────── @@ -190,9 +234,10 @@ async function fetchImagesForListing(listing) { try { const response = await fetch(listing.link, { headers: { - 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36', + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36', 'Accept-Language': 'de-DE,de;q=0.9', - 'Accept': 'text/html', + Accept: 'text/html', }, signal: AbortSignal.timeout(8000), }); @@ -203,25 +248,31 @@ async function fetchImagesForListing(listing) { const dataImgSrc = [...html.matchAll(/data-imgsrc="([^"]+\/images\/.+?)"/g)]; for (const m of dataImgSrc) urls.add(m[1]); - const galleryImgs = [...html.matchAll(/class="galleryimage-element[^"]*"[^>]*>[\s\S]*?]+src="([^"]+)"/g)]; + const galleryImgs = [ + ...html.matchAll(/class="galleryimage-element[^"]*"[^>]*>[\s\S]*?]+src="([^"]+)"/g), + ]; for (const m of galleryImgs) urls.add(m[1]); - const jsonLdMatch = html.match(/