commit 3799ad59f8b93db564f398b5451f3129c00bfd82 Author: Nasyarobby Putra Date: Mon Aug 3 07:35:37 2026 +0700 Add initial project structure with Docker support and chapter downloading functionality - Created .dockerignore and .gitignore files to exclude unnecessary files from Docker builds. - Added Dockerfile for multi-stage build setup, including dependencies and production environment configuration. - Introduced docker-compose.yml for service orchestration, including FlareSolverr and the main application. - Implemented core functionality for downloading manga chapters, including utilities for handling file paths and chapter metadata. - Added ESLint configuration for code quality and consistency. - Included JSDoc configuration for documentation generation. - Established initial package.json and package-lock.json with necessary dependencies for the project. diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..4974227 Binary files /dev/null and b/.DS_Store differ diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..39ff020 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +node_modules +.git +docs +data +public +.env +.env.* +*.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3308461 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +docs/ +data/ +public/* +!public/.gitkeep diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c0a018a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,44 @@ +# syntax=docker/dockerfile:1 + +FROM node:22-bookworm-slim AS web-build +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3 make g++ \ + && rm -rf /var/lib/apt/lists/* + +COPY package.json package-lock.json ./ +RUN npm ci + +COPY web ./web +COPY src ./src +RUN npm run build:web + +FROM node:22-bookworm-slim AS runtime +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3 make g++ \ + && rm -rf /var/lib/apt/lists/* + +ENV NODE_ENV=production +ENV DATA_DIR=/app/data +ENV PORT=3000 +ENV WATCH_CRON="0 * * * *" +ENV WATCH_TITLE_INTERVAL_HOURS="24" + +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev \ + && apt-get purge -y python3 make g++ \ + && apt-get autoremove -y \ + && rm -rf /var/lib/apt/lists/* + +COPY src ./src +COPY --from=web-build /app/public ./public + +RUN mkdir -p /app/data + +EXPOSE 3000 +VOLUME ["/app/data"] + +CMD ["node", "src/server/index.js"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..823c391 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +services: + flaresolverr: + image: ghcr.io/flaresolverr/flaresolverr:latest + environment: + LOG_LEVEL: info + ports: + - "8191:8191" + restart: unless-stopped + + cxyz: + build: . + ports: + - "3000:3000" + environment: + PORT: "3000" + DATA_DIR: /app/data + # Wake hourly; sync at most one due title per wake. + WATCH_CRON: "0 * * * *" + # Each title is eligible again after this many hours (daily). + WATCH_TITLE_INTERVAL_HOURS: "24" + FLARESOLVERR_URL: http://flaresolverr:8191/v1 + volumes: + - ./data:/app/data + depends_on: + - flaresolverr + restart: unless-stopped diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..60e9470 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,18 @@ +import js from "@eslint/js"; +import globals from "globals"; +import jsdoc from "eslint-plugin-jsdoc"; + +export default [ + { + ignores: ["node_modules/**", "docs/**", "public/**", "web/**", "data/**"], + }, + js.configs.recommended, + jsdoc.configs["flat/recommended"], + { + files: ["**/*.js"], + languageOptions: { + ecmaVersion: 2024, + globals: globals.node, + }, + }, +]; diff --git a/jsdoc.conf.json b/jsdoc.conf.json new file mode 100644 index 0000000..4daad72 --- /dev/null +++ b/jsdoc.conf.json @@ -0,0 +1,11 @@ +{ + "source": { + "include": ["./src"], + "includePattern": "\\.js$", + "excludePattern": "(node_modules/|docs)" + }, + "opts": { + "destination": "./docs", + "recurse": true + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..4fe2aaa --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3961 @@ +{ + "name": "cxyz", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cxyz", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@fastify/static": "^10.1.2", + "@picocss/pico": "^2.1.1", + "adm-zip": "^0.5.17", + "axios": "^1.16.1", + "better-sqlite3": "^13.0.2", + "fastify": "^5.11.0", + "node-cron": "^4.6.0", + "node-html-parser": "^7.1.0" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@sveltejs/vite-plugin-svelte": "^7.2.0", + "eslint": "^10.4.0", + "eslint-plugin-jsdoc": "^62.9.0", + "globals": "^17.6.0", + "jsdoc": "^4.0.5", + "svelte": "^5.56.8", + "vite": "^8.2.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", + "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "2.0.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", + "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", + "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@es-joy/jsdoccomment": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.86.0.tgz", + "integrity": "sha512-ukZmRQ81WiTpDWO6D/cTBM7XbrNtutHKvAVnZN/8pldAwLoJArGOvkNyxPTBGsPjsoaQBJxlH+tE2TNA/92Qgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.8", + "@typescript-eslint/types": "^8.58.0", + "comment-parser": "1.4.6", + "esquery": "^1.7.0", + "jsdoc-type-pratt-parser": "~7.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@es-joy/resolve.exports": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@es-joy/resolve.exports/-/resolve.exports-1.2.0.tgz", + "integrity": "sha512-Q9hjxWI5xBM+qW2enxfe8wDKdFWMfd0Z29k5ZJnuBqD/CasY5Zryj09aCA6owbGATWz+39p5uIdaHXpopOcG8g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "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.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "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.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@fastify/accept-negotiator": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", + "integrity": "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/ajv-compiler/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@fastify/ajv-compiler/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.0.tgz", + "integrity": "sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^7.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.2.tgz", + "integrity": "sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@fastify/send": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz", + "integrity": "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "escape-html": "~1.0.3", + "fast-decode-uri-component": "^1.0.1", + "http-errors": "^2.0.0", + "mime": "^3" + } + }, + "node_modules/@fastify/static": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-10.1.2.tgz", + "integrity": "sha512-G/g18cG9tLutT/OVyN1AIsHIl9L1UwmJ+S3dkyhVpplIx0nEMicd7RGQ+uJLyhKKF4a3tTcQydccn3Mop1fX+Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/accept-negotiator": "^2.0.0", + "@fastify/error": "^4.0.0", + "@fastify/send": "^4.0.0", + "content-disposition": "^2.0.1", + "fastify-plugin": "^6.0.0", + "fastq": "^1.17.1", + "glob": "^13.0.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.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/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "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/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jsdoc/salty": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@jsdoc/salty/-/salty-0.2.12.tgz", + "integrity": "sha512-TuB0x50EoAvEX/UEWITd8Mkn3WhiTjSvbTMCLj0BhsQEl5iUzjXdA0bETEVpTk+5TGTLR6QktI9H4hLviVeaAQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "lodash": "^4.18.1" + }, + "engines": { + "node": ">=v12.0.0" + } + }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", + "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", + "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@picocss/pico": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@picocss/pico/-/pico-2.1.1.tgz", + "integrity": "sha512-kIDugA7Ps4U+2BHxiNHmvgPIQDWPDU4IeU6TNRdvXQM1uZX+FibqDQT2xUOnnO2yq/LUHcwnGlu1hvf4KfXnMg==", + "license": "MIT" + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz", + "integrity": "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==", + "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.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.1.tgz", + "integrity": "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==", + "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.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.1.tgz", + "integrity": "sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==", + "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.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.1.tgz", + "integrity": "sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==", + "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.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.1.tgz", + "integrity": "sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==", + "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.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.1.tgz", + "integrity": "sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==", + "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.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.1.tgz", + "integrity": "sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==", + "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.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.1.tgz", + "integrity": "sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==", + "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.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.1.tgz", + "integrity": "sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==", + "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.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.1.tgz", + "integrity": "sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==", + "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.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.1.tgz", + "integrity": "sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==", + "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.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.1.tgz", + "integrity": "sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==", + "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.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.1.tgz", + "integrity": "sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "2.0.0-alpha.3", + "@emnapi/runtime": "2.0.0-alpha.3", + "@napi-rs/wasm-runtime": "^1.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.1.tgz", + "integrity": "sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==", + "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.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.1.tgz", + "integrity": "sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==", + "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.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/base62": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/base62/-/base62-1.0.0.tgz", + "integrity": "sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz", + "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.2.0.tgz", + "integrity": "sha512-1SpkuMSRLfugrVX+IrKfE1RUegzo8AQzKQ6qQPfVzbcWi5IhuTPaKb5ZrLpucleFznkc4/RTeSPoRnGWFxX+EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "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.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "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/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz", + "integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "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", + "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/adm-zip": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz", + "integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "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/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/are-docs-informative": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/are-docs-informative/-/are-docs-informative-0.0.2.tgz", + "integrity": "sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.3.0.tgz", + "integrity": "sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/axios": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/better-sqlite3": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.2.tgz", + "integrity": "sha512-jW6oufeDhXZaiX9Lw5A+oerVClx4iFrI6uDj1zu7SqUAjak9vbJvA0NEcKLNxHiQHb6kYCoFzzXYV0YOauhV3g==", + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/catharsis": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/catharsis/-/catharsis-0.9.0.tgz", + "integrity": "sha512-prMTQVpcns/tzFgFVkVp6ak6RykZyWb3gu8ckUpd6YkTlacOd3DXGJjIpD4Q6zJirizvaiAjSSHlOsA+6sNh2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash": "^4.17.15" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/comment-parser": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.6.tgz", + "integrity": "sha512-ObxuY6vnbWTN6Od72xfwN9DbzC7Y2vv8u1Soi9ahRKL37gb6y1qk6/dgjs+3JWuXJHWvsg3BXIwzd/rkmAwavg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/content-disposition": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", + "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "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/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/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/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/devalue": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.0.tgz", + "integrity": "sha512-RWrqdArjvPbsATEhOPUo6Wndc/iWnkWKlhIrdlF3zMMYo/c3CVtoaVAyLtWxz5h8nSlkHzxnzV2uLydPXmtF+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.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/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "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.4.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz", + "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.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.2.0", + "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-plugin-jsdoc": { + "version": "62.9.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-62.9.0.tgz", + "integrity": "sha512-PY7/X4jrVgoIDncUmITlUqK546Ltmx/Pd4Hdsu4CvSjryQZJI2mEV4vrdMufyTetMiZ5taNSqvK//BTgVUlNkA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@es-joy/jsdoccomment": "~0.86.0", + "@es-joy/resolve.exports": "1.2.0", + "are-docs-informative": "^0.0.2", + "comment-parser": "1.4.6", + "debug": "^4.4.3", + "escape-string-regexp": "^4.0.0", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "html-entities": "^2.6.0", + "object-deep-merge": "^2.0.0", + "parse-imports-exports": "^0.2.4", + "semver": "^7.7.4", + "spdx-expression-parse": "^4.0.0", + "to-valid-identifier": "^1.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.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/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "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/esrap": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.0.tgz", + "integrity": "sha512-GQ/7RN8uOtEfNpzZzBMTzW9JBcX42oaSVtPzdF+6cEL8pqIL094iUpr9jzYGn4O4P/1S60dJ6izyT8F4LYARng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "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/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": ">=4.0" + } + }, + "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/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "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==", + "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-json-stringify": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz", + "integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-json-stringify/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/fast-json-stringify/node_modules/ajv/node_modules/fast-uri": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-json-stringify/node_modules/fast-uri": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.2.tgz", + "integrity": "sha512-TyGmBcbDTZXcb2cj5MV89DrF42DKvb3y5DDUNh95iO+IMeAzMkVSxK1PZRrRIpc9yg8U2GhGdbofNa0LS/a4Bw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-json-stringify/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "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/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.11.0.tgz", + "integrity": "sha512-Y/Ecx1yt0hYzrQR+QVLbxaUN8wCX+MQzMevh29r5RRvlOIArmi4+WAXXaGl5Hw5gBr2wduZpZaT3gRCnXoyVgA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.6.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz", + "integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "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/find-my-way": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.7.0.tgz", + "integrity": "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "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.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "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/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "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", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "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", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", + "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", + "license": "MIT", + "engines": { + "node": ">= 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/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "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/js2xmlparser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/js2xmlparser/-/js2xmlparser-4.0.2.tgz", + "integrity": "sha512-6n4D8gLlLf1n5mNLQPRfViYzu9RATblzPEtm1SthMX1Pjao0r9YI9nw7ZIfRxQMERS87mcswrg+r/OYrPRX6jA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "xmlcreate": "^2.0.4" + } + }, + "node_modules/jsdoc": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/jsdoc/-/jsdoc-4.0.5.tgz", + "integrity": "sha512-P4C6MWP9yIlMiK8nwoZvxN84vb6MsnXcHuy7XzVOvQoCizWX5JFCBsWIIWKXBltpoRZXddUOVQmCTOZt9yDj9g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@babel/parser": "^7.20.15", + "@jsdoc/salty": "^0.2.1", + "@types/markdown-it": "^14.1.1", + "bluebird": "^3.7.2", + "catharsis": "^0.9.0", + "escape-string-regexp": "^2.0.0", + "js2xmlparser": "^4.0.2", + "klaw": "^3.0.0", + "markdown-it": "^14.1.0", + "markdown-it-anchor": "^8.6.7", + "marked": "^4.0.10", + "mkdirp": "^1.0.4", + "requizzle": "^0.2.3", + "strip-json-comments": "^3.1.0", + "underscore": "~1.13.2" + }, + "bin": { + "jsdoc": "jsdoc.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/jsdoc-type-pratt-parser": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-7.2.0.tgz", + "integrity": "sha512-dh140MMgjyg3JhJZY/+iEzW+NO5xR2gpbDFKHqotCmexElVntw7GjWjt511+C/Ef02RU5TKYrJo/Xlzk+OLaTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/jsdoc/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "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/klaw": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/klaw/-/klaw-3.0.0.tgz", + "integrity": "sha512-0Fo5oir+O9jnXu5EefYbVK+mHMBeEVEy2cmctR1O1NECcCkPRreJKrS6Qt/j3KC2C148Dfo9i3pCmCMsdqGr0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.9" + } + }, + "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/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "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.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "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.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "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.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "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.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "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.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "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.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "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.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "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.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "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.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "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.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "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.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "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/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "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/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "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/markdown-it": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdown-it-anchor": { + "version": "8.6.7", + "resolved": "https://registry.npmjs.org/markdown-it-anchor/-/markdown-it-anchor-8.6.7.tgz", + "integrity": "sha512-FlCHFwNnutLgVTflOYHPW2pPcl2AACqVzExlkGQNsi4CJgqOHN7YTgDd4LuhgN1BFO3TS0vLAruV1Td6dwWPJA==", + "dev": true, + "license": "Unlicense", + "peerDependencies": { + "@types/markdown-it": "*", + "markdown-it": "*" + } + }, + "node_modules/marked": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", + "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "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/node-addon-api": { + "version": "8.9.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.1.tgz", + "integrity": "sha512-4eUQWVPCUUUiBjLnHS3cXWeC6ryoPUc0U3rP7IuzapoGbzMqd/r6KKO0clr0b+snQhsrueFEhCZDdK+LK7hxKg==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-cron": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.6.0.tgz", + "integrity": "sha512-Si/bzYiKRHOB8/a99T2+SDGN582ONDMSTlJr5oCkT6GtnqPjZ2s10eoQRYkW9ZHwjVxONL+W8Fb+qR0AHMQsdg==", + "license": "ISC", + "engines": { + "node": ">=20" + } + }, + "node_modules/node-html-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-7.1.0.tgz", + "integrity": "sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ==", + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "he": "1.2.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-deep-merge": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/object-deep-merge/-/object-deep-merge-2.0.0.tgz", + "integrity": "sha512-3DC3UMpeffLTHiuXSy/UG4NOIYTLlY9u3V82+djSCLYClWobZiS4ivYzpIUWrRY/nfsJ8cWsKyG3QfyLePmhvg==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "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/parse-imports-exports": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/parse-imports-exports/-/parse-imports-exports-0.2.4.tgz", + "integrity": "sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-statements": "1.0.11" + } + }, + "node_modules/parse-statements": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/parse-statements/-/parse-statements-1.0.11.tgz", + "integrity": "sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==", + "dev": true, + "license": "MIT" + }, + "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-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "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.16", + "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/process-warning": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", + "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=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/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/requizzle/-/requizzle-0.2.4.tgz", + "integrity": "sha512-JRrFk1D4OQ4SqovXOgdav+K8EAhSB/LJZqCz8tbX0KObcdeM15Ss59ozWMBWmmINMagCwmqn4ZNryUGpBsl6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21" + } + }, + "node_modules/reserved-identifiers": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/reserved-identifiers/-/reserved-identifiers-1.2.0.tgz", + "integrity": "sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz", + "integrity": "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.142.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.1", + "@rolldown/binding-darwin-arm64": "1.2.1", + "@rolldown/binding-darwin-x64": "1.2.1", + "@rolldown/binding-freebsd-x64": "1.2.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.1", + "@rolldown/binding-linux-arm64-gnu": "1.2.1", + "@rolldown/binding-linux-arm64-musl": "1.2.1", + "@rolldown/binding-linux-ppc64-gnu": "1.2.1", + "@rolldown/binding-linux-s390x-gnu": "1.2.1", + "@rolldown/binding-linux-x64-gnu": "1.2.1", + "@rolldown/binding-linux-x64-musl": "1.2.1", + "@rolldown/binding-openharmony-arm64": "1.2.1", + "@rolldown/binding-wasm32-wasi": "1.2.1", + "@rolldown/binding-win32-arm64-msvc": "1.2.1", + "@rolldown/binding-win32-x64-msvc": "1.2.1" + } + }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/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/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "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/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", + "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/svelte": { + "version": "5.56.8", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.8.tgz", + "integrity": "sha512-PY8LOw7xP6c8IOiVqdo0sbbZVYhXRSfklOQLAUyGBKqjTX0wx/z4l/9J+PmBpmlLnxzEb1NqltxQ5/wZme/Cmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/to-valid-identifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/to-valid-identifier/-/to-valid-identifier-1.0.0.tgz", + "integrity": "sha512-41wJyvKep3yT2tyPqX/4blcfybknGB4D+oETKLs7Q76UiPqRpUJK3hr1nxelyYO0PHKVzJwlu0aCeEAsGI6rpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/base62": "^1.0.0", + "reserved-identifiers": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/toad-cache": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", + "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/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/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "dev": true, + "license": "MIT" + }, + "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/vite": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.23", + "rolldown": "~1.2.0", + "tinyglobby": "^0.2.17" + }, + "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.4.0", + "esbuild": "^0.27.0 || ^0.28.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/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "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/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/xmlcreate": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/xmlcreate/-/xmlcreate-2.0.4.tgz", + "integrity": "sha512-nquOebG4sngPmGPICTS5EnxqhKbCmz5Ox5hsszI2T6U5qdrJizBc+0ilYSEjTSzU0yZcmvppztXe/5Al5fUwdg==", + "dev": true, + "license": "Apache-2.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" + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ccde853 --- /dev/null +++ b/package.json @@ -0,0 +1,45 @@ +{ + "name": "cxyz", + "version": "1.0.0", + "description": "", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "web": "node src/server/index.js", + "dev:web": "vite --config web/vite.config.mjs", + "build:web": "vite build --config web/vite.config.mjs", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "docs": "jsdoc -c jsdoc.conf.json", + "mangaread:fetch": "node src/mangareadFetchChapters.js", + "manhwasusu:fetch": "node src/manhwasusu/fetchChapters.cli.js", + "manhwasusu:chapter-cbz": "node src/manhwasusu/downloadChapterCbz.js", + "manhwasusu:chapters-cbz": "node src/manhwasusu/downloadChaptersQueue.js", + "mangaread:chapter-cbz": "node src/downloadChapterCbz.js", + "mangaread:chapters-cbz": "node src/downloadChaptersQueue.js", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@fastify/static": "^10.1.2", + "@picocss/pico": "^2.1.1", + "adm-zip": "^0.5.17", + "axios": "^1.16.1", + "better-sqlite3": "^13.0.2", + "fastify": "^5.11.0", + "node-cron": "^4.6.0", + "node-html-parser": "^7.1.0" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@sveltejs/vite-plugin-svelte": "^7.2.0", + "eslint": "^10.4.0", + "eslint-plugin-jsdoc": "^62.9.0", + "globals": "^17.6.0", + "jsdoc": "^4.0.5", + "svelte": "^5.56.8", + "vite": "^8.2.0" + } +} diff --git a/src/downloadChapterCbz.js b/src/downloadChapterCbz.js new file mode 100644 index 0000000..87ceb9d --- /dev/null +++ b/src/downloadChapterCbz.js @@ -0,0 +1,160 @@ +const fs = require("fs/promises"); +const path = require("path"); +const os = require("os"); +const AdmZip = require("adm-zip"); + +const { downloadWpMangaChapterImages, extractWpMangaComicTitle, fetchPageHtml, mangaSlugFromUrl } = + require("./mangareadFetchChapters.js"); + +/** + * @param {string} chapterPageUrl - Full chapter URL. + * @returns {string} Suggested base name without extension. + */ +function stemForCbzFilename(chapterPageUrl) { + try { + const { pathname } = new URL(chapterPageUrl); + const segs = pathname.replace(/\/+$/, "").split("/").filter(Boolean); + const mangaIdx = segs.indexOf("manga"); + if (mangaIdx !== -1 && segs.length >= mangaIdx + 3) { + return `${segs[mangaIdx + 1]}-${segs[mangaIdx + 2]}`; + } + if (segs.length >= 2) { + return segs.slice(-2).join("-"); + } + return segs.at(-1) || "chapter"; + } catch { + return "chapter"; + } +} + +/** + * @param {string} stem - File base name. + * @returns {string} Sanitized segment. + */ +function sanitizeFsStem(stem) { + const cleaned = stem.replace(/[^a-z0-9._-]+/gi, "_").replace(/^_+|_+$/g, ""); + return cleaned.length > 0 ? cleaned.slice(0, 180) : "chapter"; +} + +/** + * @param {string} name - Human-readable comic title. + * @returns {string} Filesystem-safe directory name. + */ +function sanitizeComicDirName(name) { + const collapsed = name.trim().replace(/\s+/g, " "); + const cleaned = [...collapsed] + .filter((ch) => { + const code = ch.charCodeAt(0); + if (code < 32 || code === 127) { + return false; + } + return !/[<>:"/\\|?*]/.test(ch); + }) + .join("") + .replace(/\.+$/g, "") + .trim(); + return cleaned.length > 0 ? cleaned.slice(0, 180) : "comic"; +} + +/** + * @param {string|undefined} cliOut - Second CLI argument, if any. + * @param {string} defaultStem - Used when `cliOut` is a directory or omitted. + * @param {string} [comicTitle] - When set, default output goes under this subdirectory. + * @returns {string} Absolute path ending in `.cbz`. + */ +function resolveCliOutputPath(cliOut, defaultStem, comicTitle) { + const stem = sanitizeFsStem(defaultStem); + const baseDir = + typeof comicTitle === "string" && comicTitle.trim().length > 0 + ? path.join(process.cwd(), "data", "comics", sanitizeComicDirName(comicTitle)) + : path.join(process.cwd(), "data", "comics"); + if (cliOut === undefined || cliOut.trim() === "") { + return path.join(baseDir, `${stem}.cbz`); + } + const trimmed = cliOut.trim(); + const abs = path.resolve(trimmed); + if (abs.toLowerCase().endsWith(".cbz")) { + return abs; + } + return path.join(abs, `${stem}.cbz`); +} + +/** + * Downloads chapter images to a temp folder, packs them into a `.cbz` zip, then deletes temp files. + * @param {string} chapterUrl - Chapter reader URL. + * @param {string} outputCbzPath - Absolute `.cbz` path to write. + * @returns {Promise<{ imageCount: number, outputPath: string }>} Summary of written pages and archive path. + */ +async function chapterUrlToCbz(chapterUrl, outputCbzPath) { + const absOut = outputCbzPath.toLowerCase().endsWith(".cbz") + ? path.resolve(outputCbzPath) + : `${path.resolve(outputCbzPath)}.cbz`; + + const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cxyz-chapter-")); + + try { + const saved = await downloadWpMangaChapterImages(chapterUrl, tmpRoot); + + if (saved.length === 0) { + throw new Error("No chapter images were found (try another URL or chapter)."); + } + + await fs.mkdir(path.dirname(absOut), { recursive: true }); + + const zip = new AdmZip(); + for (const { filePath } of saved) { + zip.addLocalFile(filePath, "", path.basename(filePath)); + } + zip.writeZip(absOut); + + return { imageCount: saved.length, outputPath: absOut }; + } finally { + await fs.rm(tmpRoot, { recursive: true, force: true }).catch(() => {}); + } +} + +/** + * CLI: `node src/downloadChapterCbz.js [path/to/file.cbz|path/to/dir/]` + * @returns {Promise} + */ +async function main() { + const chapterUrl = process.argv[2]; + if (!chapterUrl || chapterUrl.startsWith("-")) { + console.error( + "Usage: node src/downloadChapterCbz.js [output.cbz|outputDirectory]", + ); + process.exitCode = 1; + return; + } + + const outArg = process.argv[3]; + const stem = stemForCbzFilename(chapterUrl); + let comicTitle = mangaSlugFromUrl(chapterUrl); + try { + const html = await fetchPageHtml(chapterUrl); + comicTitle = extractWpMangaComicTitle(html) || comicTitle; + } catch { + /* keep slug fallback */ + } + const target = resolveCliOutputPath(outArg, stem, comicTitle); + + try { + const { imageCount, outputPath } = await chapterUrlToCbz(chapterUrl, target); + console.log(`Wrote ${String(imageCount)} page(s) → ${outputPath}`); + } catch (err) { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + } +} + +module.exports = { + chapterUrlToCbz, + sanitizeComicDirName, + sanitizeFsStem, + stemForCbzFilename, + resolveCliOutputPath, +}; + +if (require.main === module) { + main(); +} diff --git a/src/downloadChaptersQueue.js b/src/downloadChaptersQueue.js new file mode 100644 index 0000000..e29e400 --- /dev/null +++ b/src/downloadChaptersQueue.js @@ -0,0 +1,426 @@ +const path = require("path"); + +const { fetchWpMangaChapterLinks } = require("./mangareadFetchChapters.js"); +const { chapterUrlToCbz, sanitizeComicDirName, stemForCbzFilename } = require("./downloadChapterCbz.js"); + +/** + * @typedef {object} ParsedCli + * @property {boolean} [help] - When set from `--help`, print usage and exit. + * @property {string|undefined} listingUrl - Page containing `li.wp-manga-chapter` links. + * @property {boolean} listOnly - Print numbered chapters and exit. + * @property {boolean} allChapters - Queue every chapter in list order. + * @property {string|undefined} selectSpec - Comma-separated **list indices** on the printed menu (oldest first: row 1 ≈ Chapter 1), e.g. `1,3,5-7`. + * @property {string|undefined} numbersSpec - Comma-separated **chapter numbers** from titles, e.g. `22,23,24` or `22-24`. + * @property {string} outDir - Directory for `.cbz` files. + */ + +/** + * Parses CLI arguments after `node .../downloadChaptersQueue.js`. + * @param {string[]} argv - `process.argv`. + * @returns {ParsedCli} Parsed options. + */ +function parseCli(argv) { + /** @type {ParsedCli} */ + const result = { + help: false, + listingUrl: undefined, + listOnly: false, + allChapters: false, + selectSpec: undefined, + numbersSpec: undefined, + outDir: path.join(process.cwd(), "data", "comics"), + }; + + for (let i = 2; i < argv.length; i += 1) { + const a = argv[i]; + if (a === "--help" || a === "-h") { + result.help = true; + } else if (a === "--list-only" || a === "-l") { + result.listOnly = true; + } else if (a === "--all") { + result.allChapters = true; + } else if (a === "--select" || a === "-s") { + const next = argv[i + 1]; + if (next === undefined || next.startsWith("-")) { + throw new Error("Missing value for --select"); + } + result.selectSpec = next; + i += 1; + } else if (a === "--numbers" || a === "-n") { + const next = argv[i + 1]; + if (next === undefined || next.startsWith("-")) { + throw new Error("Missing value for --numbers"); + } + result.numbersSpec = next; + i += 1; + } else if (a === "--out" || a === "-o") { + const next = argv[i + 1]; + if (next === undefined || next.startsWith("-")) { + throw new Error("Missing value for --out"); + } + result.outDir = path.resolve(next); + i += 1; + } else if (!a.startsWith("-")) { + if (result.listingUrl) { + throw new Error(`Unexpected extra argument: ${a}`); + } + result.listingUrl = a; + } else { + throw new Error(`Unknown option: ${a}`); + } + } + + return result; +} + +/** + * WP‑Manga lists chapters **newest first** in the HTML. We reverse so the numbered + * menu and `--select` indices match reading order (line 1 ≈ Chapter 1). + * @param {import("./mangareadFetchChapters.js").ChapterLink[]} chapters - Fetched in site order. + * @returns {import("./mangareadFetchChapters.js").ChapterLink[]} New array, oldest chapter first. + */ +function chaptersInReadingOrder(chapters) { + return [...chapters].reverse(); +} + +/** + * Builds sorted unique 1-based indices from a spec like `1`, `1,3`, `5-10,2`. + * @param {string} spec - Selection string. + * @param {number} count - Number of chapters (max index). + * @returns {number[]} Ascending unique indices in [1, count]. + */ +function parseChapterSelection(spec, count) { + if (count <= 0) { + throw new Error("Chapter list is empty; cannot select indices."); + } + + const trimmed = spec.trim(); + if (trimmed.length === 0) { + throw new Error("--select value is empty."); + } + + /** @type {Set} */ + const seen = new Set(); + + for (const rawPart of trimmed.split(",")) { + const part = rawPart.trim(); + if (part.length === 0) { + continue; + } + + if (/^\d+$/.test(part)) { + const n = Number.parseInt(part, 10); + if (n < 1 || n > count) { + throw new Error( + `Index ${String(n)} is out of range (valid: 1–${String(count)}).`, + ); + } + seen.add(n); + continue; + } + + const rangeMatch = /^(\d+)\s*-\s*(\d+)$/.exec(part); + if (rangeMatch) { + const lo = Number.parseInt(rangeMatch[1], 10); + const hi = Number.parseInt(rangeMatch[2], 10); + const a = Math.min(lo, hi); + const b = Math.max(lo, hi); + for (let i = a; i <= b; i += 1) { + if (i < 1 || i > count) { + throw new Error( + `Range ${part} includes invalid index ${String(i)} (valid: 1–${String(count)}).`, + ); + } + seen.add(i); + } + continue; + } + + throw new Error(`Invalid --select segment: "${part}"`); + } + + if (seen.size === 0) { + throw new Error("No indices parsed from --select."); + } + + return [...seen].sort((x, y) => x - y); +} + +/** + * Parses "Chapter 22" / "chapter 160.5" style titles from WP‑Manga lists. + * @param {string} title - Anchor text. + * @returns {string|null} Numeric suffix or null. + */ +function chapterNumberFromWpTitle(title) { + const m = /^Chapter\s+([\d.]+)\s*$/iu.exec(title.trim().replace(/\s+/g, " ")); + return m ? m[1] : null; +} + +/** + * Expands `"22,23,24"` or `"22-24"` into ordered chapter-number strings. + * Integer ranges only (22-24 → 22,23,24). Use explicit `160.5` for decimals. + * @param {string} spec - User `--numbers` argument. + * @returns {string[]} Ordered unique chapter numbers to resolve. + */ +function parseMangaChapterNumbersSpec(spec) { + const trimmed = spec.trim(); + if (trimmed.length === 0) { + throw new Error("--numbers value is empty."); + } + + /** @type {string[]} */ + const ordered = []; + /** @type {Set} */ + const seen = new Set(); + + for (const rawPart of trimmed.split(",")) { + const part = rawPart.trim(); + if (part.length === 0) { + continue; + } + + const rangeMatch = /^(\d+)\s*-\s*(\d+)$/.exec(part); + if (rangeMatch) { + const lo = Number.parseInt(rangeMatch[1], 10); + const hi = Number.parseInt(rangeMatch[2], 10); + const a = Math.min(lo, hi); + const b = Math.max(lo, hi); + for (let n = a; n <= b; n += 1) { + const k = String(n); + if (!seen.has(k)) { + seen.add(k); + ordered.push(k); + } + } + continue; + } + + if (/^\d+(\.\d+)?$/.test(part)) { + if (!seen.has(part)) { + seen.add(part); + ordered.push(part); + } + continue; + } + + throw new Error(`Invalid --numbers segment: "${part}"`); + } + + if (ordered.length === 0) { + throw new Error("No chapter numbers parsed from --numbers."); + } + + return ordered; +} + +/** + * Resolves chapter rows by numeric title (e.g. Chapter 22). + * @param {string[]} orderedNums - From {@link parseMangaChapterNumbersSpec}. + * @param {import("./mangareadFetchChapters.js").ChapterLink[]} chapters - Full list. + * @returns {Array<{ url: string, title: string, pickLabel: string, chapterNumber: string }>} Queue rows with URLs and labels. + */ +function queueItemsByChapterNumbers(orderedNums, chapters) { + /** @type {Map} */ + const first = new Map(); + for (const ch of chapters) { + const num = chapterNumberFromWpTitle(ch.title); + if (num !== null && !first.has(num)) { + first.set(num, ch); + } + } + + return orderedNums.map((num) => { + const ch = first.get(num); + if (!ch) { + throw new Error( + `No chapter titled "Chapter ${num}" in the listing.` + + " Use --list-only and check spelling (decimals like 160.5 must be listed explicitly).", + ); + } + return { + url: ch.url, + title: ch.title, + pickLabel: `Ch.${num}`, + chapterNumber: num, + }; + }); +} + +/** + * Prints numbered chapters for manual `--select` choice. + * @param {import("./mangareadFetchChapters.js").ChapterLink[]} chapters - Chapter rows. + * @returns {void} + */ +function printChapterMenu(chapters) { + const digits = String(chapters.length).length; + for (let i = 0; i < chapters.length; i += 1) { + const n = i + 1; + const pad = String(n).padStart(digits, " "); + const title = chapters[i].title.replace(/\s+/g, " ").trim(); + console.log(`${pad}. ${title}`); + console.log(` ${chapters[i].url}`); + } +} + +/** + * @param {number[]} indices1 - Sorted unique 1-based indices. + * @param {import("./mangareadFetchChapters.js").ChapterLink[]} chapters - Full list. + * @returns {Array<{ url: string, title: string, index: number, pickLabel: string }>} One item per requested index. + */ +function chaptersByIndices(indices1, chapters) { + return indices1.map((idx) => ({ + index: idx, + title: chapters[idx - 1].title, + url: chapters[idx - 1].url, + pickLabel: `#${String(idx)}`, + })); +} + +/** + * Fetches chapter list, queues selected chapters, downloads one `.cbz` at a time. + * @returns {Promise} + */ +async function main() { + let parsed; + try { + parsed = parseCli(process.argv); + } catch (err) { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + return; + } + + if (parsed.help) { + console.log(`Usage: + node src/downloadChaptersQueue.js [--list-only|-l] + node src/downloadChaptersQueue.js --numbers|-n [--out|-o ] + node src/downloadChaptersQueue.js --select|-s [--out|-o ] + node src/downloadChaptersQueue.js --all [--out|-o ] + +Any chapter URL for that manga usually works as (same HTML includes the chapter list). + +--numbers spec: chapter numbers from link titles ("Chapter 22"), e.g. 22,23,24 or 22-24 + Decimals (e.g. 160.5) must be listed explicitly; integer ranges expand to whole numbers only. + +--select spec: 1-based row numbers on the **printed list** (oldest first; row 1 is Chapter 1), + e.g. 1,3,5-10. This is the menu index, not a substring of the title (use --numbers for that). + +Chapters are processed in list order for --select/--all, or in the order given for --numbers. +Default --out: data/comics// (under current working directory).`); + return; + } + + if (!parsed.listingUrl) { + console.error( + "Usage: node src/downloadChaptersQueue.js [options]\nTry --help for details.", + ); + process.exitCode = 1; + return; + } + + let chapters; + let comicTitle; + try { + ({ chapters, comicTitle } = await fetchWpMangaChapterLinks(parsed.listingUrl)); + chapters = chaptersInReadingOrder(chapters); + } catch (err) { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + return; + } + + console.log(`Found ${String(chapters.length)} chapter link(s) on listing page.`); + printChapterMenu(chapters); + + if (parsed.listOnly) { + return; + } + + const wantsAll = parsed.allChapters; + const wantsSelect = Boolean(parsed.selectSpec); + const wantsNumbers = Boolean(parsed.numbersSpec); + const pickModes = [wantsAll, wantsSelect, wantsNumbers].filter(Boolean).length; + if (pickModes === 0) { + console.error( + "Choose: --numbers , --select , or --all (or use --list-only to exit after listing).", + ); + process.exitCode = 1; + return; + } + if (pickModes > 1) { + console.error("Use only one of --all, --select, or --numbers."); + process.exitCode = 1; + return; + } + + /** @type {Array<{ url: string, title: string, pickLabel: string }>} */ + let queue; + try { + if (wantsAll) { + const indices = Array.from({ length: chapters.length }, (_, i) => i + 1); + queue = chaptersByIndices(indices, chapters); + } else if (wantsSelect) { + const indices = parseChapterSelection( + /** @type {string} */ (parsed.selectSpec), + chapters.length, + ); + queue = chaptersByIndices(indices, chapters); + } else { + const orderedNums = parseMangaChapterNumbersSpec( + /** @type {string} */ (parsed.numbersSpec), + ); + queue = queueItemsByChapterNumbers(orderedNums, chapters); + } + } catch (err) { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + return; + } + const outDir = path.join(parsed.outDir, sanitizeComicDirName(comicTitle)); + console.log(`Comic: ${comicTitle}`); + console.log(`Output directory: ${outDir}`); + + let failedCount = 0; + let done = 0; + for (const item of queue) { + done += 1; + const stem = stemForCbzFilename(item.url); + const targetPath = path.join(outDir, `${stem}.cbz`); + console.log( + `\n[${String(done)}/${String(queue.length)}] ${item.pickLabel} ${item.title}`, + ); + console.log(` → ${item.url}`); + try { + const { imageCount, outputPath } = await chapterUrlToCbz(item.url, targetPath); + console.log(` ✓ ${String(imageCount)} page(s) → ${outputPath}`); + } catch (err) { + console.error(` ✗ ${err instanceof Error ? err.message : err}`); + failedCount += 1; + } + } + + if (failedCount > 0) { + console.error( + `\n${String(failedCount)} chapter(s) failed (${String(queue.length - failedCount)} succeeded).`, + ); + process.exitCode = 1; + } +} + +module.exports = { + chapterNumberFromWpTitle, + chaptersByIndices, + chaptersInReadingOrder, + parseChapterSelection, + parseCli, + parseMangaChapterNumbersSpec, + printChapterMenu, + queueItemsByChapterNumbers, +}; + +if (require.main === module) { + main().catch((err) => { + console.error(err); + process.exitCode = 1; + }); +} diff --git a/src/flaresolverr.js b/src/flaresolverr.js new file mode 100644 index 0000000..685d871 --- /dev/null +++ b/src/flaresolverr.js @@ -0,0 +1,112 @@ +const axios = require("axios"); + +/** + * Turn upstream scrape/HTTP failures into a short UI-friendly message. + * @param {unknown} err - Caught error (often Axios). + * @param {string} [siteLabel] - e.g. "ManhwaSusu". + * @returns {string} + */ +function formatUpstreamError(err, siteLabel) { + const label = siteLabel || "Source site"; + const status = err && typeof err === "object" && "response" in err + ? /** @type {{ response?: { status?: number } }} */ (err).response?.status + : undefined; + const code = + err && typeof err === "object" && "code" in err + ? /** @type {{ code?: string }} */ (err).code + : undefined; + const raw = err instanceof Error ? err.message : String(err); + + if (status === 403) { + return ( + `${label} blocked the request (HTTP 403), usually Cloudflare. ` + + `Set FLARESOLVERR_URL (e.g. http://flaresolverr:8191/v1) and retry.` + ); + } + if (status === 404) { + return `${label} returned 404 — check the series URL.`; + } + if (status === 429) { + return `${label} rate-limited the request (HTTP 429). Try again later.`; + } + if (status && status >= 500) { + return `${label} is unavailable (HTTP ${String(status)}).`; + } + if (code === "ECONNABORTED" || /timeout/iu.test(raw)) { + return `${label} request timed out.`; + } + if (code === "ENOTFOUND" || code === "ECONNREFUSED") { + return `Could not reach ${label}: ${raw}`; + } + if (/status code 403/iu.test(raw)) { + return ( + `${label} blocked the request (HTTP 403), usually Cloudflare. ` + + `Set FLARESOLVERR_URL (e.g. http://flaresolverr:8191/v1) and retry.` + ); + } + return raw; +} + +/** + * Fetch HTML, optionally via FlareSolverr when `FLARESOLVERR_URL` is set. + * @param {string} pageUrl - Absolute page URL. + * @param {import("axios").AxiosRequestConfig} [directConfig] - Used for direct axios GET. + * @returns {Promise} HTML body. + */ +async function fetchHtmlWithOptionalFlareSolverr(pageUrl, directConfig = {}) { + const flareBase = (process.env.FLARESOLVERR_URL || "").trim().replace(/\/+$/u, ""); + if (flareBase) { + const endpoint = flareBase.endsWith("/v1") ? flareBase : `${flareBase}/v1`; + const { data } = await axios.post( + endpoint, + { + cmd: "request.get", + url: pageUrl, + maxTimeout: 60_000, + }, + { + timeout: 90_000, + headers: { "Content-Type": "application/json" }, + validateStatus: (s) => s >= 200 && s < 300, + }, + ); + + if (!data || data.status !== "ok" || !data.solution) { + const msg = + data && typeof data.message === "string" + ? data.message + : "FlareSolverr request failed"; + throw new Error(msg); + } + + const status = Number(data.solution.status) || 0; + if (status < 200 || status >= 300) { + const err = new Error(`Request failed with status code ${String(status)}`); + /** @type {any} */ (err).response = { status }; + throw err; + } + + return String(data.solution.response || ""); + } + + const { data, status } = await axios.get(pageUrl, { + responseType: "text", + timeout: 30_000, + validateStatus: (s) => s >= 200 && s < 300, + ...directConfig, + headers: { + ...(directConfig.headers || {}), + }, + }); + + if (status !== 200) { + throw new Error(`Unexpected HTTP status ${String(status)}`); + } + + return typeof data === "string" ? data : String(data); +} + +module.exports = { + fetchHtmlWithOptionalFlareSolverr, + formatUpstreamError, +}; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..b921f0a --- /dev/null +++ b/src/index.js @@ -0,0 +1,19 @@ +const axios = require("axios"); +const { parse } = require("node-html-parser"); + +/** + * Fetches HTML from a URL and returns the parsed document root. + * @async + * @param {string} url - Absolute URL to fetch. + * @returns {Promise} Parsed HTML root (node-html-parser tree). + */ +async function fetchAndParseHtml(url) { + const { data } = await axios.get(url, { + responseType: "text", + validateStatus: (status) => status >= 200 && status < 300, + timeout: 15_000, + }); + return parse(data); +} + +module.exports = { fetchAndParseHtml }; diff --git a/src/mangareadFetchChapters.js b/src/mangareadFetchChapters.js new file mode 100644 index 0000000..d63554a --- /dev/null +++ b/src/mangareadFetchChapters.js @@ -0,0 +1,426 @@ +const fs = require("fs/promises"); +const path = require("path"); +const axios = require("axios"); +const { parse } = require("node-html-parser"); + +const { fetchHtmlWithOptionalFlareSolverr } = require("./flaresolverr.js"); + +const PAGE_URL = + "https://www.mangaread.org/manga/the-chaebeols-youngest-son/chapter-560/"; +const OUTPUT_HTML = path.join(__dirname, "..", "data", "mangaread-chapter-560.html"); + +/** @type {import("axios").AxiosRequestConfig} */ +const DEFAULT_GET_HTML = { + responseType: "text", + timeout: 30_000, + headers: { + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + }, + validateStatus: (s) => s >= 200 && s < 300, +}; + +/** + * Madara / WP‑Manga often serves desktop HTML as manga info (`manga-page`) and the reader + * (`reading-manga` + `img.wp-manga-chapter-img`) only when the client looks like mobile. + * @type {import("axios").AxiosRequestConfig} + */ +const DEFAULT_GET_HTML_READER_MOBILE = { + ...DEFAULT_GET_HTML, + headers: { + ...DEFAULT_GET_HTML.headers, + "User-Agent": + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 " + + "(KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1", + }, +}; + +/** + * @typedef {object} ChapterLink + * @property {string} url - Absolute or relative chapter URL from the anchor `href`. + * @property {string} title - Visible chapter label (trimmed). + */ + +/** + * Returns every `a` inside `li.wp-manga-chapter` with URL and title. + * @param {string} html - Raw HTML document. + * @returns {ChapterLink[]} Extracted links in document order. + */ +function extractWpMangaChapters(html) { + const root = parse(html); + const items = root.querySelectorAll("li.wp-manga-chapter"); + + /** @type {ChapterLink[]} */ + const chapters = []; + + for (const li of items) { + const a = li.querySelector("a"); + if (!a) { + continue; + } + + const href = a.getAttribute("href"); + const title = (a.textContent || "").trim().replace(/\s+/g, " "); + if (href) { + chapters.push({ url: href.trim(), title }); + } + } + + return chapters; +} + +/** + * @param {string} html - Listing or chapter page HTML. + * @returns {string|null} Series title when found in markup. + */ +function extractWpMangaComicTitle(html) { + const root = parse(html); + + const og = root.querySelector('meta[property="og:title"]'); + if (og) { + const t = (og.getAttribute("content") || "").trim(); + if (t.length > 0) { + return t.replace(/\s+/g, " "); + } + } + + const h1 = root.querySelector(".profile-manga .post-title h1"); + if (h1) { + const t = (h1.textContent || "").trim().replace(/\s+/g, " "); + if (t.length > 0) { + return t; + } + } + + const titleEl = root.querySelector("title"); + if (titleEl) { + const m = /^Read\s+(.+?)\s+-\s+manga/iu.exec((titleEl.textContent || "").trim()); + if (m) { + return m[1].trim().replace(/\s+/g, " "); + } + } + + return null; +} + +/** + * @param {string} pageUrl - Manga or chapter URL. + * @returns {string} Slug segment from `/manga/{slug}/`, or `"comic"`. + */ +function mangaSlugFromUrl(pageUrl) { + try { + const { pathname } = new URL(pageUrl); + const parts = pathname.split("/").filter(Boolean); + const mi = parts.indexOf("manga"); + if (mi !== -1 && parts[mi + 1] !== undefined) { + return parts[mi + 1]; + } + } catch { + /* invalid URL */ + } + return "comic"; +} + +/** + * Derives `https://host/manga/{slug}/` from a listing or chapter URL. + * @param {string} pageUrl - Any URL under a manga (chapter or series page). + * @returns {string} Normalized manga base URL with trailing slash. + */ +function mangaBaseUrlFromListingUrl(pageUrl) { + const u = new URL(pageUrl); + const parts = u.pathname.split("/").filter(Boolean); + const mi = parts.indexOf("manga"); + if (mi === -1 || parts[mi + 1] === undefined) { + throw new Error( + 'Could not find /manga/{slug}/ in URL; use a manga or chapter link from this site.', + ); + } + const slug = parts[mi + 1]; + return new URL(`/manga/${slug}/`, u.origin).href; +} + +/** + * WP‑Manga / Madara fills `#manga-chapters-holder` via POST to `ajax/chapters/` when + * the initial GET does not include chapter rows. + * @param {string} mangaBaseUrl - Output of {@link mangaBaseUrlFromListingUrl}. + * @param {string} refererUrl - Original page URL (for Referer). + * @returns {Promise} HTML fragment containing `li.wp-manga-chapter` items. + */ +async function fetchMangaChapterListAjaxHtml(mangaBaseUrl, refererUrl) { + const base = mangaBaseUrl.endsWith("/") ? mangaBaseUrl : `${mangaBaseUrl}/`; + const ajaxUrl = new URL("ajax/chapters/", base).href; + const origin = new URL(refererUrl).origin; + + const { data, status } = await axios.post(ajaxUrl, "", { + ...DEFAULT_GET_HTML, + timeout: 45_000, + headers: { + ...DEFAULT_GET_HTML.headers, + Accept: "*/*", + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + Referer: refererUrl, + Origin: origin, + "X-Requested-With": "XMLHttpRequest", + }, + validateStatus: (s) => s >= 200 && s < 300, + }); + + if (status !== 200) { + throw new Error(`ajax/chapters/ returned HTTP ${String(status)}`); + } + + return typeof data === "string" ? data : String(data); +} + +/** + * Fetches HTML for {@link pageUrl}. + * @param {string} pageUrl - URL to load. + * @param {object} [options] - Request shape. + * @param {"desktop"|"reader"} [options.variant] - `reader` requests mobile reader markup when the theme separates info vs reader. + * @returns {Promise} Response body as text. + */ +async function fetchPageHtml(pageUrl, options = {}) { + /** @type {import("axios").AxiosRequestConfig} */ + const cfg = + options.variant === "reader" ? DEFAULT_GET_HTML_READER_MOBILE : DEFAULT_GET_HTML; + + return fetchHtmlWithOptionalFlareSolverr(pageUrl, cfg); +} + +/** + * Loads a page and extracts each chapter anchor URL and title under `li.wp-manga-chapter`. + * If the first response has no such rows (common on series-only URLs), POSTs to `ajax/chapters/`. + * @param {string} pageUrl - Any page containing that markup (e.g. Mangaread chapter URL). + * @returns {Promise<{ chapters: ChapterLink[], comicTitle: string }>} Chapter rows and series title. + */ +async function fetchWpMangaChapterLinks(pageUrl) { + const html = await fetchPageHtml(pageUrl); + let chapters = extractWpMangaChapters(html); + + if (chapters.length === 0) { + const base = mangaBaseUrlFromListingUrl(pageUrl); + const ajaxHtml = await fetchMangaChapterListAjaxHtml(base, pageUrl); + chapters = extractWpMangaChapters(ajaxHtml); + } + + const comicTitle = + extractWpMangaComicTitle(html) || mangaSlugFromUrl(pageUrl); + + return { chapters, comicTitle }; +} + +/** + * @typedef {object} SavedChapterImage + * @property {string} imageUrl - Absolute URL that was downloaded. + * @property {string} filePath - Absolute path of the saved file. + */ + +/** + * @param {import("node-html-parser").HTMLElement} img - Image element. + * @returns {string|null} First usable image URL attribute, or null. + */ +function pickChapterImageSrc(img) { + /** @type {(string|null)[]} */ + const attrs = [ + img.getAttribute("data-src"), + img.getAttribute("data-lazy-src"), + img.getAttribute("data-original"), + img.getAttribute("data-full-url"), + img.getAttribute("src"), + ]; + + for (const raw of attrs) { + if (raw && typeof raw === "string") { + const t = raw.trim(); + if (t.length > 0 && !t.startsWith("data:")) { + return t; + } + } + } + + return null; +} + +/** + * @param {string} pageUrl - Chapter or site URL (for folder name). + * @returns {string} Filesystem-safe single path segment. + */ +function slugFromUrlForDir(pageUrl) { + try { + const { pathname } = new URL(pageUrl); + const parts = pathname.split("/").filter(Boolean); + const last = parts[parts.length - 1] || "chapter"; + const safe = last.replace(/[^a-z0-9._-]+/gi, "_").slice(0, 96); + return safe.length > 0 ? safe : "chapter"; + } catch { + return "chapter"; + } +} + +/** + * @param {string} contentType - `Content-Type` header value. + * @returns {string} File extension including dot, or empty string. + */ +function extensionFromContentType(contentType) { + if (!contentType || typeof contentType !== "string") { + return ""; + } + const ct = contentType.split(";")[0].trim().toLowerCase(); + /** @type {Record} */ + const map = { + "image/jpeg": ".jpg", + "image/jpg": ".jpg", + "image/pjpeg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", + "image/gif": ".gif", + "image/avif": ".avif", + "image/svg+xml": ".svg", + }; + return map[ct] || ""; +} + +/** + * @param {string} imageUrl - Absolute image URL. + * @returns {string} Lowercase extension with dot, or empty string. + */ +function extensionFromImageUrl(imageUrl) { + try { + const p = new URL(imageUrl).pathname; + const ext = path.extname(p).toLowerCase(); + if (ext.length > 0 && ext.length <= 8 && /^\.[a-z0-9]+$/u.test(ext)) { + return ext; + } + } catch { + /* invalid URL */ + } + return ""; +} + +/** + * Collects image URLs from `img.wp-manga-chapter-img` in document order. + * @param {string} html - Document HTML. + * @param {string} baseHref - URL used to resolve relative `src` values. + * @returns {string[]} Absolute image URLs (deduped sequential). + */ +function collectWpMangaChapterImageUrls(html, baseHref) { + const root = parse(html); + const imgs = root.querySelectorAll("img.wp-manga-chapter-img"); + const baseUrl = new URL(baseHref); + + /** @type {string[]} */ + const urls = []; + + for (const img of imgs) { + const raw = pickChapterImageSrc(img); + if (!raw) { + continue; + } + try { + urls.push(new URL(raw, baseUrl).href); + } catch { + /* skip bad href */ + } + } + + return urls; +} + +/** + * Fetches a chapter page, selects `img.wp-manga-chapter-img`, downloads each image to disk. + * Uses a mobile UA first (Madara reader HTML), then falls back to desktop if no images appear. + * + * Pure client-side lazy sites may still return an empty list. + * @param {string} chapterPageUrl - Reader/chapter page URL. + * @param {string} [outDir] - Directory for saved files (default: `data/chapter-images/`). + * @returns {Promise} One record per image, in DOM order. + */ +async function downloadWpMangaChapterImages(chapterPageUrl, outDir) { + let urls = collectWpMangaChapterImageUrls( + await fetchPageHtml(chapterPageUrl, { variant: "reader" }), + chapterPageUrl, + ); + + if (urls.length === 0) { + urls = collectWpMangaChapterImageUrls(await fetchPageHtml(chapterPageUrl), chapterPageUrl); + } + + const targetDir = + typeof outDir === "string" && outDir.trim().length > 0 + ? path.resolve(outDir.trim()) + : path.join(__dirname, "..", "data", "chapter-images", slugFromUrlForDir(chapterPageUrl)); + + await fs.mkdir(targetDir, { recursive: true }); + + /** @type {import("axios").AxiosRequestConfig} */ + const imageRequest = { + responseType: "arraybuffer", + timeout: 120_000, + maxContentLength: 50 * 1024 * 1024, + maxBodyLength: 50 * 1024 * 1024, + headers: { + ...DEFAULT_GET_HTML.headers, + Accept: "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", + Referer: chapterPageUrl, + }, + validateStatus: (s) => s >= 200 && s < 300, + }; + + /** @type {SavedChapterImage[]} */ + const saved = []; + let index = 0; + + for (const imageUrl of urls) { + index += 1; + const { data, headers } = await axios.get(imageUrl, imageRequest); + const ct = headers["content-type"]; + let ext = extensionFromImageUrl(imageUrl); + if (ext.length === 0) { + ext = extensionFromContentType(typeof ct === "string" ? ct : "") || ".bin"; + } + const filename = `page-${String(index).padStart(4, "0")}${ext}`; + const filePath = path.join(targetDir, filename); + await fs.writeFile(filePath, Buffer.from(data)); + saved.push({ imageUrl, filePath }); + } + + return saved; +} + +/** + * Fetches the page, writes HTML to disk, parses chapter rows. + * @returns {Promise} + */ +async function main() { + const html = await fetchPageHtml(PAGE_URL); + + await fs.mkdir(path.dirname(OUTPUT_HTML), { recursive: true }); + await fs.writeFile(OUTPUT_HTML, html, "utf8"); + + const chapters = extractWpMangaChapters(html); + + console.log(`Saved HTML: ${OUTPUT_HTML}`); + console.log(`Found ${String(chapters.length)} li.wp-manga-chapter link(s):`); + console.log(JSON.stringify(chapters, null, 2)); +} + +module.exports = { + collectWpMangaChapterImageUrls, + downloadWpMangaChapterImages, + extractWpMangaChapters, + extractWpMangaComicTitle, + fetchMangaChapterListAjaxHtml, + fetchPageHtml, + fetchWpMangaChapterLinks, + mangaBaseUrlFromListingUrl, + mangaSlugFromUrl, +}; + +if (require.main === module) { + main().catch((err) => { + console.error(err); + process.exitCode = 1; + }); +} diff --git a/src/manhwasusu/chapters.js b/src/manhwasusu/chapters.js new file mode 100644 index 0000000..8ddd0db --- /dev/null +++ b/src/manhwasusu/chapters.js @@ -0,0 +1,180 @@ +const { parse } = require("node-html-parser"); + +const { CHAPTER_PATHNAME_PATTERN, MANHWASUSU_HOSTS } = require("./constants.js"); +const { fetchManhwaSusuHtml } = require("./io.js"); + +/** + * @typedef {object} ChapterLink + * @property {string} url - Absolute chapter URL. + * @property {string} title - Chapter label (trimmed). + */ + +/** + * @param {string} pathname - URL pathname. + * @returns {boolean} Whether this path is a ManhwaSusu chapter reader URL. + */ +function isChapterPathname(pathname) { + const p = pathname.replace(/\/+$/u, ""); + return CHAPTER_PATHNAME_PATTERN.test(p); +} + +/** + * @param {string} url - Any http(s) URL. + * @returns {boolean} True when hostname matches known ManhwaSusu hosts (hint only). + */ +function isLikelyManhwaSusuSite(url) { + try { + const { hostname } = new URL(url); + return MANHWASUSU_HOSTS.some((h) => h === hostname.toLowerCase()); + } catch { + return false; + } +} + +/** + * Skip duplicate “First / Last chapter” buttons; the full list still contains the same targets. + * @param {import("node-html-parser").HTMLElement} anchor - ``. + * @returns {boolean} True when the link text is only “First chapter” or “Last chapter”. + */ +function isFirstLastNavOnlyLink(anchor) { + const t = (anchor.textContent || "").trim().replace(/\s+/gu, " "); + return /^first chapter$/iu.test(t) || /^last chapter$/iu.test(t); +} + +/** + * Parses chapter anchors from a series / listing HTML document. + * @param {string} html - Raw HTML. + * @param {string} pageUrl - Base URL for resolving relative `href`s. + * @returns {ChapterLink[]} Unique links in **document order** (site lists newest first). + */ +function extractChapterLinksFromListingHtml(html, pageUrl) { + const root = parse(html); + const base = new URL(pageUrl); + + /** @type {Map} */ + const byUrl = new Map(); + + for (const a of root.querySelectorAll("a[href]")) { + const href = (a.getAttribute("href") || "").trim(); + if (href.length === 0 || href.startsWith("#")) { + continue; + } + + let abs; + try { + abs = new URL(href, base).href; + } catch { + continue; + } + + const { pathname } = new URL(abs); + if (!isChapterPathname(pathname)) { + continue; + } + + if (isFirstLastNavOnlyLink(a)) { + continue; + } + + if (byUrl.has(abs)) { + continue; + } + + let title = ""; + const pEl = a.querySelector("p"); + if (pEl) { + title = (pEl.textContent || "").trim().replace(/\s+/gu, " "); + } + if (title.length === 0) { + title = (a.textContent || "").trim().replace(/\s+/gu, " "); + } + title = title + .replace(/\s+\d+\s*(min|hour|day|week|mth|month|year)s?\s+ago$/iu, "") + .trim(); + + const fallback = pathname.split("/").filter(Boolean).pop() || "chapter"; + byUrl.set(abs, { url: abs, title: title.length > 0 ? title : fallback }); + } + + return [...byUrl.values()]; +} + +/** + * @param {string} html - Series listing HTML. + * @returns {string|null} Comic title when found in markup. + */ +function extractComicTitleFromListingHtml(html) { + const root = parse(html); + + const stripSiteSuffix = (raw) => + raw.replace(/\s*-\s*ManhwaSusu\s*$/iu, "").trim().replace(/\s+/gu, " "); + + const og = root.querySelector('meta[property="og:title"]'); + if (og) { + const t = stripSiteSuffix(og.getAttribute("content") || ""); + if (t.length > 0) { + return t; + } + } + + const titleEl = root.querySelector("title"); + if (titleEl) { + const t = stripSiteSuffix(titleEl.textContent || ""); + if (t.length > 0) { + return t; + } + } + + for (const h1 of root.querySelectorAll("h1")) { + const t = (h1.textContent || "").trim().replace(/\s+/gu, " "); + if ( + t.length > 0 && + !/^ManhwaSusu$/iu.test(t) && + !/chapter\s*list/iu.test(t) + ) { + return t; + } + } + + return null; +} + +/** + * @param {string} pageUrl - Series or chapter URL under `/read/{slug}/`. + * @returns {string} Series slug segment, or `"comic"`. + */ +function seriesSlugFromListingUrl(pageUrl) { + try { + const { pathname } = new URL(pageUrl); + const parts = pathname.split("/").filter(Boolean); + const readIdx = parts.indexOf("read"); + if (readIdx !== -1 && parts[readIdx + 1] !== undefined) { + return parts[readIdx + 1]; + } + } catch { + /* invalid URL */ + } + return "comic"; +} + +/** + * @param {string} listingPageUrl - e.g. `https://manhwasusu.com/read/{slug}/` + * @returns {Promise<{ chapters: ChapterLink[], comicTitle: string }>} Chapter rows and series title. + */ +async function fetchChapterLinks(listingPageUrl) { + const html = await fetchManhwaSusuHtml(listingPageUrl); + const chapters = extractChapterLinksFromListingHtml(html, listingPageUrl); + const comicTitle = + extractComicTitleFromListingHtml(html) || seriesSlugFromListingUrl(listingPageUrl); + return { chapters, comicTitle }; +} + +module.exports = { + extractChapterLinksFromListingHtml, + extractComicTitleFromListingHtml, + fetchChapterLinks, + isChapterPathname, + isFirstLastNavOnlyLink, + isLikelyManhwaSusuSite, + seriesSlugFromListingUrl, +}; diff --git a/src/manhwasusu/constants.js b/src/manhwasusu/constants.js new file mode 100644 index 0000000..deb0f14 --- /dev/null +++ b/src/manhwasusu/constants.js @@ -0,0 +1,55 @@ +/** + * ManhwaSusu-specific URLs and DOM selectors. Adjust here if routes or markup change. + * @see https://manhwasusu.com/read/{slug}/ — series + chapter index + */ + +/** Hostnames that this adapter is intended for (for friendly checks only). */ +const MANHWASUSU_HOSTS = /** @type {const} */ (["manhwasusu.com", "www.manhwasusu.com"]); + +/** Canonical site origin used as Referer for CDN panel downloads. */ +const MANHWASUSU_ORIGIN = "https://manhwasusu.com/"; + +/** Default sample listing used by the fetch CLI when no URL is overridden. */ +const DEFAULT_LISTING_URL = "https://manhwasusu.com/read/switch-on-season-2/"; + +/** Path shape: `/read/{seriesSlug}/chapter-{id}/` (SSR uses trailing slash optional). */ +const CHAPTER_PATHNAME_PATTERN = /^\/read\/[^/]+\/chapter-[^/]+$/iu; + +/** + * Reader lazily fills `src` from `data-src`; class `imgku` wraps panel images on chapter pages. + * @type {readonly [string, ...string[]]} + */ +const READER_IMG_SELECTORS = ["img.imgku"]; + +/** + * Browser-like headers for CDN image GETs (captured from manhwasusu.com reader → manhwature.com). + * Referer must be the site origin; chapter URLs are often rejected by the CDN. + * @type {Readonly>} + */ +const CDN_IMAGE_HEADERS = { + Accept: "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9,id;q=0.8", + "Cache-Control": "no-cache", + Pragma: "no-cache", + "Sec-CH-UA": + '"Chromium";v="146", "Not-A.Brand";v="24", "Microsoft Edge";v="146"', + "Sec-CH-UA-Mobile": "?0", + "Sec-CH-UA-Platform": '"Windows"', + "Sec-Fetch-Dest": "image", + "Sec-Fetch-Mode": "no-cors", + "Sec-Fetch-Site": "cross-site", + "Sec-Fetch-Storage-Access": "active", + Referer: MANHWASUSU_ORIGIN, + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0", +}; + +module.exports = { + CDN_IMAGE_HEADERS, + CHAPTER_PATHNAME_PATTERN, + DEFAULT_LISTING_URL, + MANHWASUSU_HOSTS, + MANHWASUSU_ORIGIN, + READER_IMG_SELECTORS, +}; diff --git a/src/manhwasusu/downloadChapterCbz.js b/src/manhwasusu/downloadChapterCbz.js new file mode 100644 index 0000000..8721db5 --- /dev/null +++ b/src/manhwasusu/downloadChapterCbz.js @@ -0,0 +1,169 @@ +const fs = require("fs/promises"); +const path = require("path"); +const os = require("os"); +const AdmZip = require("adm-zip"); + +const { downloadChapterImages } = require("./images.js"); +const { extractComicTitleFromListingHtml, seriesSlugFromListingUrl } = require("./chapters.js"); +const { fetchManhwaSusuHtml } = require("./io.js"); + +/** + * `/read/my-series/chapter-12/` → `my-series-chapter-12`. + * @param {string} chapterPageUrl - Full chapter URL. + * @returns {string} Suggested `.cbz` stem. + */ +function stemForManhwaSusuChapterCbz(chapterPageUrl) { + try { + const { pathname } = new URL(chapterPageUrl); + const segs = pathname.replace(/\/+$/u, "").split("/").filter(Boolean); + const readIdx = segs.indexOf("read"); + if (readIdx !== -1 && segs.length >= readIdx + 3) { + const slug = segs[readIdx + 1]; + const chapSeg = segs[readIdx + 2]; + return `${slug}-${chapSeg}`; + } + if (segs.length >= 2) { + return segs.slice(-2).join("-"); + } + return segs.at(-1) || "chapter"; + } catch { + return "chapter"; + } +} + +/** + * @param {string} stem - File base segment. + * @returns {string} Sanitized stem. + */ +function sanitizeFsStem(stem) { + const cleaned = stem.replace(/[^a-z0-9._-]+/giu, "_").replace(/^_+|_+$/gu, ""); + return cleaned.length > 0 ? cleaned.slice(0, 180) : "chapter"; +} + +/** + * @param {string} name - Human-readable comic title. + * @returns {string} Filesystem-safe directory name. + */ +function sanitizeComicDirName(name) { + const collapsed = name.trim().replace(/\s+/gu, " "); + const cleaned = [...collapsed] + .filter((ch) => { + const code = ch.charCodeAt(0); + if (code < 32 || code === 127) { + return false; + } + return !/[<>:"/\\|?*]/.test(ch); + }) + .join("") + .replace(/\.+$/g, "") + .trim(); + return cleaned.length > 0 ? cleaned.slice(0, 180) : "comic"; +} + +/** + * @param {string|undefined} cliOut - Positional output path/dir. + * @param {string} defaultStem - When `cliOut` is omitted or directory-like. + * @param {string} [comicTitle] - When set, default output goes under this subdirectory. + * @returns {string} Absolute `.cbz` path. + */ +function resolveCliOutputPath(cliOut, defaultStem, comicTitle) { + const stem = sanitizeFsStem(defaultStem); + const baseDir = + typeof comicTitle === "string" && comicTitle.trim().length > 0 + ? path.join( + process.cwd(), + "data", + "manhwasusu", + "comics", + sanitizeComicDirName(comicTitle), + ) + : path.join(process.cwd(), "data", "manhwasusu", "comics"); + if (cliOut === undefined || cliOut.trim() === "") { + return path.join(baseDir, `${stem}.cbz`); + } + const trimmed = cliOut.trim(); + const abs = path.resolve(trimmed); + if (abs.toLowerCase().endsWith(".cbz")) { + return abs; + } + return path.join(abs, `${stem}.cbz`); +} + +/** + * Downloads ManhwaSusu chapter panels to temp storage, packs `.cbz`, removes temp folder. + * @param {string} chapterUrl - Chapter reader URL. + * @param {string} outputCbzPath - Destination `.cbz`. + * @returns {Promise<{ imageCount: number, outputPath: string }>} Page count and written archive path. + */ +async function chapterUrlToCbz(chapterUrl, outputCbzPath) { + const absOut = outputCbzPath.toLowerCase().endsWith(".cbz") + ? path.resolve(outputCbzPath) + : `${path.resolve(outputCbzPath)}.cbz`; + + const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cxyz-manhwasusu-chapter-")); + + try { + const saved = await downloadChapterImages(chapterUrl, tmpRoot); + + if (saved.length === 0) { + throw new Error("No chapter images were found for this ManhwaSusu URL."); + } + + await fs.mkdir(path.dirname(absOut), { recursive: true }); + + const zip = new AdmZip(); + for (const { filePath } of saved) { + zip.addLocalFile(filePath, "", path.basename(filePath)); + } + zip.writeZip(absOut); + + return { imageCount: saved.length, outputPath: absOut }; + } finally { + await fs.rm(tmpRoot, { recursive: true, force: true }).catch(() => {}); + } +} + +/** + * CLI: `node src/manhwasusu/downloadChapterCbz.js [.cbz|dir]` + * @returns {Promise} + */ +async function main() { + const chapterUrl = process.argv[2]; + if (!chapterUrl || chapterUrl.startsWith("-")) { + console.error( + "Usage: node src/manhwasusu/downloadChapterCbz.js [output.cbz|outputDirectory]", + ); + process.exitCode = 1; + return; + } + + const stem = stemForManhwaSusuChapterCbz(chapterUrl); + let comicTitle = seriesSlugFromListingUrl(chapterUrl); + try { + const html = await fetchManhwaSusuHtml(chapterUrl); + comicTitle = extractComicTitleFromListingHtml(html) || comicTitle; + } catch { + /* keep slug fallback */ + } + const target = resolveCliOutputPath(process.argv[3], stem, comicTitle); + + try { + const { imageCount, outputPath } = await chapterUrlToCbz(chapterUrl, target); + console.log(`Wrote ${String(imageCount)} page(s) → ${outputPath}`); + } catch (err) { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + } +} + +module.exports = { + chapterUrlToCbz, + resolveCliOutputPath, + sanitizeComicDirName, + sanitizeFsStem, + stemForManhwaSusuChapterCbz, +}; + +if (require.main === module) { + main(); +} diff --git a/src/manhwasusu/downloadChaptersQueue.js b/src/manhwasusu/downloadChaptersQueue.js new file mode 100644 index 0000000..1fdd26b --- /dev/null +++ b/src/manhwasusu/downloadChaptersQueue.js @@ -0,0 +1,426 @@ +const path = require("path"); + +const { fetchChapterLinks, isLikelyManhwaSusuSite } = require("./chapters.js"); +const { stemForManhwaSusuChapterCbz, chapterUrlToCbz, sanitizeComicDirName } = + require("./downloadChapterCbz.js"); + +/** + * @typedef {object} ParsedCli + * @property {boolean} [help] - Print usage and exit when true. + * @property {string|undefined} listingUrl - Series listing URL (required unless help). + * @property {boolean} listOnly - Only print the chapter menu. + * @property {boolean} allChapters - Queue every chapter after reversal to reading order. + * @property {string|undefined} selectSpec - `--select` string (1-based menu indices). + * @property {string|undefined} numbersSpec - `--numbers` string (chapter numbers from titles). + * @property {string} outDir - Output directory for `.cbz` files. + */ + +/** + * @param {string[]} argv - `process.argv`. + * @returns {ParsedCli} Parsed flags and positional URL. + */ +function parseCli(argv) { + /** @type {ParsedCli} */ + const result = { + help: false, + listingUrl: undefined, + listOnly: false, + allChapters: false, + selectSpec: undefined, + numbersSpec: undefined, + outDir: path.join(process.cwd(), "data", "manhwasusu", "comics"), + }; + + for (let i = 2; i < argv.length; i += 1) { + const a = argv[i]; + if (a === "--help" || a === "-h") { + result.help = true; + } else if (a === "--list-only" || a === "-l") { + result.listOnly = true; + } else if (a === "--all") { + result.allChapters = true; + } else if (a === "--select" || a === "-s") { + const next = argv[i + 1]; + if (next === undefined || next.startsWith("-")) { + throw new Error("Missing value for --select"); + } + result.selectSpec = next; + i += 1; + } else if (a === "--numbers" || a === "-n") { + const next = argv[i + 1]; + if (next === undefined || next.startsWith("-")) { + throw new Error("Missing value for --numbers"); + } + result.numbersSpec = next; + i += 1; + } else if (a === "--out" || a === "-o") { + const next = argv[i + 1]; + if (next === undefined || next.startsWith("-")) { + throw new Error("Missing value for --out"); + } + result.outDir = path.resolve(next); + i += 1; + } else if (!a.startsWith("-")) { + if (result.listingUrl) { + throw new Error(`Unexpected extra argument: ${a}`); + } + result.listingUrl = a; + } else { + throw new Error(`Unknown option: ${a}`); + } + } + + return result; +} + +/** + * Listing HTML is newest-first; reverse so `--select` line 1 is Chapter 1. + * @param {import("./chapters.js").ChapterLink[]} chapters - Site order. + * @returns {import("./chapters.js").ChapterLink[]} New array, oldest chapter first. + */ +function chaptersInReadingOrder(chapters) { + return [...chapters].reverse(); +} + +/** + * @param {string} spec - `--select` value. + * @param {number} count - Chapter count. + * @returns {number[]} Sorted unique 1-based row indices into the reversed list. + */ +function parseChapterSelection(spec, count) { + if (count <= 0) { + throw new Error("Chapter list is empty; cannot select indices."); + } + + const trimmed = spec.trim(); + if (trimmed.length === 0) { + throw new Error("--select value is empty."); + } + + /** @type {Set} */ + const seen = new Set(); + + for (const rawPart of trimmed.split(",")) { + const part = rawPart.trim(); + if (part.length === 0) { + continue; + } + + if (/^\d+$/u.test(part)) { + const n = Number.parseInt(part, 10); + if (n < 1 || n > count) { + throw new Error( + `Index ${String(n)} is out of range (valid: 1–${String(count)}).`, + ); + } + seen.add(n); + continue; + } + + const rangeMatch = /^(\d+)\s*-\s*(\d+)$/u.exec(part); + if (rangeMatch) { + const lo = Number.parseInt(rangeMatch[1], 10); + const hi = Number.parseInt(rangeMatch[2], 10); + const a = Math.min(lo, hi); + const b = Math.max(lo, hi); + for (let j = a; j <= b; j += 1) { + if (j < 1 || j > count) { + throw new Error( + `Range ${part} includes invalid index ${String(j)} (valid: 1–${String(count)}).`, + ); + } + seen.add(j); + } + continue; + } + + throw new Error(`Invalid --select segment: "${part}"`); + } + + if (seen.size === 0) { + throw new Error("No indices parsed from --select."); + } + + return [...seen].sort((x, y) => x - y); +} + +/** + * @param {string} title - e.g. `Chapter 22`. + * @returns {string|null} Chapter number string from title, or null. + */ +function chapterNumberFromListingTitle(title) { + const m = /^Chapter\s+([\d.]+)\s*$/iu.exec(title.trim().replace(/\s+/gu, " ")); + return m ? m[1] : null; +} + +/** + * @param {string} spec - `--numbers` argument. + * @returns {string[]} Ordered unique chapter number strings as entered. + */ +function parseChapterNumbersSpec(spec) { + const trimmed = spec.trim(); + if (trimmed.length === 0) { + throw new Error("--numbers value is empty."); + } + + /** @type {string[]} */ + const ordered = []; + /** @type {Set} */ + const seen = new Set(); + + for (const rawPart of trimmed.split(",")) { + const part = rawPart.trim(); + if (part.length === 0) { + continue; + } + + const rangeMatch = /^(\d+)\s*-\s*(\d+)$/u.exec(part); + if (rangeMatch) { + const lo = Number.parseInt(rangeMatch[1], 10); + const hi = Number.parseInt(rangeMatch[2], 10); + const a = Math.min(lo, hi); + const b = Math.max(lo, hi); + for (let n = a; n <= b; n += 1) { + const k = String(n); + if (!seen.has(k)) { + seen.add(k); + ordered.push(k); + } + } + continue; + } + + if (/^\d+(\.\d+)?$/u.test(part)) { + if (!seen.has(part)) { + seen.add(part); + ordered.push(part); + } + continue; + } + + throw new Error(`Invalid --numbers segment: "${part}"`); + } + + if (ordered.length === 0) { + throw new Error("No chapter numbers parsed from --numbers."); + } + + return ordered; +} + +/** + * @param {string[]} orderedNums - Chapter numbers requested. + * @param {import("./chapters.js").ChapterLink[]} chapters - Ordered oldest→newest after reverse. + * @returns {Array<{ url: string, title: string, pickLabel: string, chapterNumber: string }>} + * Rows to download. + */ +function queueItemsByChapterNumbers(orderedNums, chapters) { + /** @type {Map} */ + const first = new Map(); + for (const ch of chapters) { + const num = chapterNumberFromListingTitle(ch.title); + if (num !== null && !first.has(num)) { + first.set(num, ch); + } + } + + return orderedNums.map((num) => { + const ch = first.get(num); + if (!ch) { + throw new Error( + `No chapter titled "Chapter ${num}" in the ManhwaSusu listing. ` + + "Use --list-only and check titles.", + ); + } + return { + url: ch.url, + title: ch.title, + pickLabel: `Ch.${num}`, + chapterNumber: num, + }; + }); +} + +/** + * @param {import("./chapters.js").ChapterLink[]} chapters - Oldest-first list. + * @returns {void} + */ +function printChapterMenu(chapters) { + const digits = String(chapters.length).length; + for (let i = 0; i < chapters.length; i += 1) { + const n = i + 1; + const pad = String(n).padStart(digits, " "); + const title = chapters[i].title.replace(/\s+/gu, " ").trim(); + console.log(`${pad}. ${title}`); + console.log(` ${chapters[i].url}`); + } +} + +/** + * @param {number[]} indices1 - Sorted 1-based indices. + * @param {import("./chapters.js").ChapterLink[]} chapters - Oldest-first. + */ +/** + * @param {number[]} indices1 - Sorted 1-based indices. + * @param {import("./chapters.js").ChapterLink[]} chapters - Oldest-first chapter list. + * @returns {Array<{ index: number, title: string, url: string, pickLabel: string }>} Queue rows. + */ +function chaptersByIndices(indices1, chapters) { + return indices1.map((idx) => ({ + index: idx, + title: chapters[idx - 1].title, + url: chapters[idx - 1].url, + pickLabel: `#${String(idx)}`, + })); +} + +/** + * CLI entry: fetch listing, optionally build `.cbz` archives. + * @returns {Promise} + */ +async function main() { + let parsed; + try { + parsed = parseCli(process.argv); + } catch (err) { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + return; + } + + if (parsed.help) { + console.log(`ManhwaSusu chapter queue (dedicated adapter; URLs like /read/{slug}/): + +Usage: + node src/manhwasusu/downloadChaptersQueue.js [--list-only|-l] + node src/manhwasusu/downloadChaptersQueue.js --numbers|-n [--out|-o ] + node src/manhwasusu/downloadChaptersQueue.js --select|-s [--out|-o ] + node src/manhwasusu/downloadChaptersQueue.js --all [--out|-o ] + +Listing URL: series page ending in /read/{slug}/ (or chapter URL — site embeds chapter list markup). + +Default --out: data/manhwasusu/comics// + +--numbers: titles like "Chapter 22", e.g. 22,23,24 or 22-24 +--select: 1-based rows on printed list after reversal (line 1 = Chapter 1) +`); + return; + } + + if (!parsed.listingUrl) { + console.error( + "Usage: node src/manhwasusu/downloadChaptersQueue.js [options]", + ); + process.exitCode = 1; + return; + } + + if (!isLikelyManhwaSusuSite(parsed.listingUrl)) { + console.warn( + "Warning: URL hostname does not look like ManhwaSusu; parsers may fail if structure differs.", + ); + } + + let chapters; + let comicTitle; + try { + ({ chapters, comicTitle } = await fetchChapterLinks(parsed.listingUrl)); + chapters = chaptersInReadingOrder(chapters); + } catch (err) { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + return; + } + + console.log(`Found ${String(chapters.length)} ManhwaSusu chapter link(s).`); + printChapterMenu(chapters); + + if (parsed.listOnly) { + return; + } + + const wantsAll = parsed.allChapters; + const wantsSelect = Boolean(parsed.selectSpec); + const wantsNumbers = Boolean(parsed.numbersSpec); + const pickModes = [wantsAll, wantsSelect, wantsNumbers].filter(Boolean).length; + if (pickModes === 0) { + console.error("Choose --numbers, --select, or --all (or --list-only)."); + process.exitCode = 1; + return; + } + if (pickModes > 1) { + console.error("Use only one of --all, --select, or --numbers."); + process.exitCode = 1; + return; + } + + /** @type {Array<{ url: string, title: string, pickLabel: string }>} */ + let queue; + try { + if (wantsAll) { + const indices = Array.from({ length: chapters.length }, (_, i) => i + 1); + queue = chaptersByIndices(indices, chapters); + } else if (wantsSelect) { + const indices = parseChapterSelection( + /** @type {string} */ (parsed.selectSpec), + chapters.length, + ); + queue = chaptersByIndices(indices, chapters); + } else { + const orderedNums = parseChapterNumbersSpec( + /** @type {string} */ (parsed.numbersSpec), + ); + queue = queueItemsByChapterNumbers(orderedNums, chapters); + } + } catch (err) { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + return; + } + + const outDir = path.join(parsed.outDir, sanitizeComicDirName(comicTitle)); + console.log(`Comic: ${comicTitle}`); + console.log(`Output directory: ${outDir}`); + + let failedCount = 0; + let done = 0; + for (const item of queue) { + done += 1; + const stem = stemForManhwaSusuChapterCbz(item.url); + const targetPath = path.join(outDir, `${stem}.cbz`); + console.log(`\n[${String(done)}/${String(queue.length)}] ${item.pickLabel} ${item.title}`); + console.log(` → ${item.url}`); + try { + const { imageCount, outputPath } = await chapterUrlToCbz(item.url, targetPath); + console.log(` ✓ ${String(imageCount)} page(s) → ${outputPath}`); + } catch (err) { + console.error(` ✗ ${err instanceof Error ? err.message : err}`); + failedCount += 1; + } + } + + if (failedCount > 0) { + console.error( + `\n${String(failedCount)} chapter(s) failed (${String(queue.length - failedCount)} succeeded).`, + ); + process.exitCode = 1; + } +} + +module.exports = { + chapterNumberFromListingTitle, + chaptersByIndices, + chaptersInReadingOrder, + parseChapterNumbersSpec, + parseChapterSelection, + parseCli, + printChapterMenu, + queueItemsByChapterNumbers, +}; + +if (require.main === module) { + main().catch((err) => { + console.error(err); + process.exitCode = 1; + }); +} diff --git a/src/manhwasusu/fetchChapters.cli.js b/src/manhwasusu/fetchChapters.cli.js new file mode 100644 index 0000000..00b8a06 --- /dev/null +++ b/src/manhwasusu/fetchChapters.cli.js @@ -0,0 +1,45 @@ +const fs = require("fs/promises"); +const path = require("path"); + +const { DEFAULT_LISTING_URL } = require("./constants.js"); +const { extractChapterLinksFromListingHtml } = require("./chapters.js"); +const { fetchManhwaSusuHtml } = require("./io.js"); + +const OUTPUT_HTML = path.join( + __dirname, + "..", + "..", + "data", + "manhwasusu", + "debug-listing.html", +); + +/** + * CLI: snapshot listing HTML + print chapter JSON. Optional `MANHWASUSU_LISTING_URL` env overrides default. + * @returns {Promise} + */ +async function main() { + const pageUrl = process.env.MANHWASUSU_LISTING_URL || DEFAULT_LISTING_URL; + const html = await fetchManhwaSusuHtml(pageUrl); + + await fs.mkdir(path.dirname(OUTPUT_HTML), { recursive: true }); + await fs.writeFile(OUTPUT_HTML, html, "utf8"); + + const chapters = extractChapterLinksFromListingHtml(html, pageUrl); + + console.log(`Source: ${pageUrl}`); + console.log(`Saved HTML: ${OUTPUT_HTML}`); + console.log( + `Found ${String(chapters.length)} chapter link(s) (newest-first on page; reverse for reading order):`, + ); + console.log(JSON.stringify(chapters, null, 2)); +} + +if (require.main === module) { + main().catch((err) => { + console.error(err); + process.exitCode = 1; + }); +} + +module.exports = { main }; diff --git a/src/manhwasusu/images.js b/src/manhwasusu/images.js new file mode 100644 index 0000000..788109a --- /dev/null +++ b/src/manhwasusu/images.js @@ -0,0 +1,100 @@ +const fs = require("fs/promises"); +const path = require("path"); +const axios = require("axios"); +const { parse } = require("node-html-parser"); + +const { READER_IMG_SELECTORS } = require("./constants.js"); +const { + extensionFromContentType, + extensionFromImageUrl, + fetchManhwaSusuHtml, + imageGetConfig, + pickReaderImgSrc, + slugFromChapterUrl, +} = require("./io.js"); + +/** + * Collects panel URLs from chapter HTML (lazy `data-src` on reader images). + * @param {string} html - Chapter document. + * @param {string} baseHref - Used to resolve relative attributes. + * @returns {string[]} Absolute image URLs in DOM order. + */ +function collectChapterImageUrls(html, baseHref) { + const root = parse(html); + const baseUrl = new URL(baseHref); + const selector = READER_IMG_SELECTORS.join(", "); + + /** @type {string[]} */ + const urls = []; + + for (const img of root.querySelectorAll(selector)) { + const raw = pickReaderImgSrc(img); + if (!raw) { + continue; + } + try { + urls.push(new URL(raw, baseUrl).href); + } catch { + /* skip bad href */ + } + } + + return urls; +} + +/** + * @typedef {object} SavedChapterImage + * @property {string} imageUrl - Remote URL that was downloaded. + * @property {string} filePath - Path of the saved file. + */ + +/** + * Downloads reader panels into `data/manhwasusu/chapters/{slug}/` by default. + * @param {string} chapterPageUrl - Full `/read/{slug}/chapter-N/` URL. + * @param {string} [outDir] - Override output directory. + * @returns {Promise} One record per saved panel file. + */ +async function downloadChapterImages(chapterPageUrl, outDir) { + const html = await fetchManhwaSusuHtml(chapterPageUrl); + const urls = collectChapterImageUrls(html, chapterPageUrl); + + const targetDir = + typeof outDir === "string" && outDir.trim().length > 0 + ? path.resolve(outDir.trim()) + : path.join( + path.join(__dirname, "..", ".."), + "data", + "manhwasusu", + "chapters", + slugFromChapterUrl(chapterPageUrl), + ); + + await fs.mkdir(targetDir, { recursive: true }); + + const cfg = imageGetConfig(chapterPageUrl); + + /** @type {SavedChapterImage[]} */ + const saved = []; + let index = 0; + + for (const imageUrl of urls) { + index += 1; + const { data, headers } = await axios.get(imageUrl, cfg); + const ct = headers["content-type"]; + let ext = extensionFromImageUrl(imageUrl); + if (ext.length === 0) { + ext = extensionFromContentType(typeof ct === "string" ? ct : "") || ".bin"; + } + const filename = `page-${String(index).padStart(4, "0")}${ext}`; + const filePath = path.join(targetDir, filename); + await fs.writeFile(filePath, Buffer.from(data)); + saved.push({ imageUrl, filePath }); + } + + return saved; +} + +module.exports = { + collectChapterImageUrls, + downloadChapterImages, +}; diff --git a/src/manhwasusu/index.js b/src/manhwasusu/index.js new file mode 100644 index 0000000..13bf1bb --- /dev/null +++ b/src/manhwasusu/index.js @@ -0,0 +1,88 @@ +/** + * ManhwaSusu adapter: HTTP, listing/chapter parsing, image download, and CBZ CLIs live under this folder. + * Tweak {@link ./constants.js} when routes or reader markup change. + */ + +const { + CDN_IMAGE_HEADERS, + CHAPTER_PATHNAME_PATTERN, + DEFAULT_LISTING_URL, + MANHWASUSU_HOSTS, + MANHWASUSU_ORIGIN, + READER_IMG_SELECTORS, +} = require("./constants.js"); + +const { + extractChapterLinksFromListingHtml, + extractComicTitleFromListingHtml, + fetchChapterLinks, + isChapterPathname, + isFirstLastNavOnlyLink, + isLikelyManhwaSusuSite, + seriesSlugFromListingUrl, +} = require("./chapters.js"); + +const { + collectChapterImageUrls, + downloadChapterImages, +} = require("./images.js"); + +const { + DEFAULT_GET_HTML, + extensionFromContentType, + extensionFromImageUrl, + fetchManhwaSusuHtml, + imageGetConfig, + pickReaderImgSrc, + slugFromChapterUrl, +} = require("./io.js"); + +const { + chapterUrlToCbz, + resolveCliOutputPath, + sanitizeComicDirName, + sanitizeFsStem, + stemForManhwaSusuChapterCbz, +} = require("./downloadChapterCbz.js"); + +module.exports = { + CDN_IMAGE_HEADERS, + CHAPTER_PATHNAME_PATTERN, + DEFAULT_GET_HTML, + DEFAULT_LISTING_URL, + MANHWASUSU_HOSTS, + MANHWASUSU_ORIGIN, + READER_IMG_SELECTORS, + chapterUrlToCbz, + collectChapterImageUrls, + /** @deprecated Use {@link collectChapterImageUrls} */ + collectManhwaSusuChapterImageUrls: collectChapterImageUrls, + downloadChapterImages, + /** @deprecated Use {@link downloadChapterImages} */ + downloadManhwaSusuChapterImages: downloadChapterImages, + extensionFromContentType, + extensionFromImageUrl, + extractChapterLinksFromListingHtml, + extractComicTitleFromListingHtml, + /** @deprecated Use {@link extractChapterLinksFromListingHtml} */ + extractManhwaSusuChapters: extractChapterLinksFromListingHtml, + fetchChapterLinks, + fetchManhwaSusuHtml, + /** @deprecated Use {@link fetchChapterLinks} */ + fetchManhwaSusuChapterLinks: fetchChapterLinks, + imageGetConfig, + isChapterPathname, + isFirstLastNavOnlyLink, + /** @deprecated Use {@link isFirstLastNavOnlyLink} */ + isFirstLastChapterNavLink: isFirstLastNavOnlyLink, + isLikelyManhwaSusuSite, + /** @deprecated Use {@link isChapterPathname} */ + isManhwaSusuChapterPath: isChapterPathname, + pickReaderImgSrc, + resolveCliOutputPath, + sanitizeComicDirName, + sanitizeFsStem, + seriesSlugFromListingUrl, + slugFromChapterUrl, + stemForManhwaSusuChapterCbz, +}; diff --git a/src/manhwasusu/io.js b/src/manhwasusu/io.js new file mode 100644 index 0000000..fa3cb09 --- /dev/null +++ b/src/manhwasusu/io.js @@ -0,0 +1,141 @@ +const path = require("path"); +const axios = require("axios"); + +const { fetchHtmlWithOptionalFlareSolverr } = require("../flaresolverr.js"); +const { CDN_IMAGE_HEADERS, MANHWASUSU_ORIGIN } = require("./constants.js"); + +/** @type {import("axios").AxiosRequestConfig} */ +const DEFAULT_GET_HTML = { + responseType: "text", + timeout: 30_000, + headers: { + Accept: + "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9,id;q=0.8", + Referer: MANHWASUSU_ORIGIN, + "User-Agent": CDN_IMAGE_HEADERS["User-Agent"], + }, + validateStatus: (s) => s >= 200 && s < 300, +}; + +/** + * @param {string} pageUrl - URL to load. + * @returns {Promise} Response body text. + */ +async function fetchManhwaSusuHtml(pageUrl) { + return fetchHtmlWithOptionalFlareSolverr(pageUrl, DEFAULT_GET_HTML); +} + +/** + * Chooses a usable Lazy-load / CDN attribute from a reader `` (same order as many WP‑style lazy themes). + * @param {import("node-html-parser").HTMLElement} img - Image element. + * @returns {string|null} First non-placeholder image URL, or null. + */ +function pickReaderImgSrc(img) { + /** @type {(string|null)[]} */ + const attrs = [ + img.getAttribute("data-src"), + img.getAttribute("data-lazy-src"), + img.getAttribute("data-original"), + img.getAttribute("data-full-url"), + img.getAttribute("src"), + ]; + + for (const raw of attrs) { + if (raw && typeof raw === "string") { + const t = raw.trim(); + if ( + t.length > 0 && + !t.startsWith("data:") && + t !== "/readerarea.svg" && + !t.endsWith("readerarea.svg") + ) { + return t; + } + } + } + + return null; +} + +/** + * @param {string} contentType - `Content-Type` header value. + * @returns {string} File extension including dot, or empty string. + */ +function extensionFromContentType(contentType) { + if (!contentType || typeof contentType !== "string") { + return ""; + } + const ct = contentType.split(";")[0].trim().toLowerCase(); + /** @type {Record} */ + const map = { + "image/jpeg": ".jpg", + "image/jpg": ".jpg", + "image/pjpeg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", + "image/gif": ".gif", + "image/avif": ".avif", + "image/svg+xml": ".svg", + }; + return map[ct] || ""; +} + +/** + * @param {string} imageUrl - Absolute image URL. + * @returns {string} Lowercase extension with dot, or empty string. + */ +function extensionFromImageUrl(imageUrl) { + try { + const p = new URL(imageUrl).pathname; + const ext = path.extname(p).toLowerCase(); + if (ext.length > 0 && ext.length <= 8 && /^\.[a-z0-9]+$/u.test(ext)) { + return ext; + } + } catch { + /* invalid URL */ + } + return ""; +} + +/** + * @param {string} pageUrl - Chapter URL (directory name fragment). + * @returns {string} Single filesystem-safe segment. + */ +function slugFromChapterUrl(pageUrl) { + try { + const { pathname } = new URL(pageUrl); + const parts = pathname.split("/").filter(Boolean); + const last = parts[parts.length - 1] || "chapter"; + const safe = last.replace(/[^a-z0-9._-]+/giu, "_").slice(0, 96); + return safe.length > 0 ? safe : "chapter"; + } catch { + return "chapter"; + } +} + +/** + * Axios config for CDN panel bytes (browser-like headers from manhwasusu reader). + * @param {string} [_referrerChapterUrl] - Kept for call-site compatibility; CDN expects site-origin Referer. + * @returns {import("axios").AxiosRequestConfig} GET config for binary image responses. + */ +function imageGetConfig(_referrerChapterUrl) { + return { + responseType: "arraybuffer", + timeout: 120_000, + maxContentLength: 50 * 1024 * 1024, + maxBodyLength: 50 * 1024 * 1024, + headers: { ...CDN_IMAGE_HEADERS }, + validateStatus: (s) => s >= 200 && s < 300, + }; +} + +module.exports = { + DEFAULT_GET_HTML, + extensionFromContentType, + extensionFromImageUrl, + fetchManhwaSusuHtml, + imageGetConfig, + pickReaderImgSrc, + slugFromChapterUrl, +}; diff --git a/src/manhwasusuFetchChapters.js b/src/manhwasusuFetchChapters.js new file mode 100644 index 0000000..a2b4c4c --- /dev/null +++ b/src/manhwasusuFetchChapters.js @@ -0,0 +1,5 @@ +/** + * Re-export for older imports. Prefer `require("./manhwasusu")` or `require("./manhwasusu/index.js")`. + * @see {@link ./manhwasusu/index.js} + */ +module.exports = require("./manhwasusu/index.js"); diff --git a/src/server/db.js b/src/server/db.js new file mode 100644 index 0000000..256b843 --- /dev/null +++ b/src/server/db.js @@ -0,0 +1,473 @@ +const fs = require("fs"); +const path = require("path"); +const Database = require("better-sqlite3"); + +/** + * @param {string} dataDir - Absolute data directory (SQLite + CBZ roots live under this). + * @returns {import("better-sqlite3").Database} Opened database with schema applied. + */ +function openDatabase(dataDir) { + fs.mkdirSync(dataDir, { recursive: true }); + const dbPath = path.join(dataDir, "app.db"); + const db = new Database(dbPath); + db.pragma("journal_mode = WAL"); + db.pragma("foreign_keys = ON"); + migrate(db); + return db; +} + +/** + * @param {import("better-sqlite3").Database} db - Database handle. + */ +function migrate(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS titles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL CHECK (source IN ('mangaread', 'manhwasusu')), + url TEXT NOT NULL UNIQUE, + slug TEXT NOT NULL, + title TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_checked_at TEXT + ); + + CREATE TABLE IF NOT EXISTS chapters ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title_id INTEGER NOT NULL REFERENCES titles(id) ON DELETE CASCADE, + chapter_key TEXT NOT NULL, + chapter_number TEXT, + label TEXT NOT NULL, + url TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'downloading', 'downloaded', 'failed')), + cbz_path TEXT, + downloaded_at TEXT, + error TEXT, + UNIQUE (title_id, chapter_key) + ); + + CREATE TABLE IF NOT EXISTS jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL CHECK (type IN ('watch_sync', 'redownload', 'download')), + title_id INTEGER REFERENCES titles(id) ON DELETE SET NULL, + chapter_id INTEGER REFERENCES chapters(id) ON DELETE SET NULL, + status TEXT NOT NULL DEFAULT 'queued' + CHECK (status IN ('queued', 'running', 'done', 'failed')), + message TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + finished_at TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_chapters_title_id ON chapters(title_id); + CREATE INDEX IF NOT EXISTS idx_chapters_status ON chapters(status); + CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status); + `); + + // Drop active jobs left behind after a title was removed (ON DELETE SET NULL). + db.prepare( + ` + DELETE FROM jobs + WHERE status IN ('queued', 'running') + AND (title_id IS NULL OR title_id NOT IN (SELECT id FROM titles)) + `, + ).run(); +} + +/** + * @param {import("better-sqlite3").Database} db + * @returns {object[]} + */ +function listTitlesWithCounts(db) { + return db + .prepare( + ` + SELECT + t.*, + COUNT(c.id) AS total, + SUM(CASE WHEN c.status = 'downloaded' THEN 1 ELSE 0 END) AS downloaded, + SUM(CASE WHEN c.status = 'pending' THEN 1 ELSE 0 END) AS pending, + SUM(CASE WHEN c.status = 'downloading' THEN 1 ELSE 0 END) AS downloading, + SUM(CASE WHEN c.status = 'failed' THEN 1 ELSE 0 END) AS failed + FROM titles t + LEFT JOIN chapters c ON c.title_id = t.id + GROUP BY t.id + ORDER BY t.id DESC + `, + ) + .all() + .map(normalizeTitleRow); +} + +/** + * @param {object} row + * @returns {object} + */ +function normalizeTitleRow(row) { + return { + ...row, + enabled: Boolean(row.enabled), + total: Number(row.total) || 0, + downloaded: Number(row.downloaded) || 0, + pending: Number(row.pending) || 0, + downloading: Number(row.downloading) || 0, + failed: Number(row.failed) || 0, + }; +} + +/** + * @param {import("better-sqlite3").Database} db + * @param {number} id + * @returns {object|undefined} + */ +function getTitle(db, id) { + const row = db + .prepare( + ` + SELECT + t.*, + COUNT(c.id) AS total, + SUM(CASE WHEN c.status = 'downloaded' THEN 1 ELSE 0 END) AS downloaded, + SUM(CASE WHEN c.status = 'pending' THEN 1 ELSE 0 END) AS pending, + SUM(CASE WHEN c.status = 'downloading' THEN 1 ELSE 0 END) AS downloading, + SUM(CASE WHEN c.status = 'failed' THEN 1 ELSE 0 END) AS failed + FROM titles t + LEFT JOIN chapters c ON c.title_id = t.id + WHERE t.id = ? + GROUP BY t.id + `, + ) + .get(id); + return row ? normalizeTitleRow(row) : undefined; +} + +/** + * @param {import("better-sqlite3").Database} db + * @param {number} titleId + * @returns {object[]} + */ +function listChapters(db, titleId) { + return db + .prepare( + ` + SELECT * FROM chapters + WHERE title_id = ? + ORDER BY + CASE WHEN chapter_number GLOB '[0-9]*' THEN CAST(chapter_number AS REAL) ELSE 1e18 END, + id + `, + ) + .all(titleId); +} + +/** + * @param {import("better-sqlite3").Database} db + * @param {{ source: string, url: string, slug: string, title: string }} data + * @returns {object} + */ +function insertTitle(db, data) { + const info = db + .prepare( + ` + INSERT INTO titles (source, url, slug, title, enabled) + VALUES (@source, @url, @slug, @title, 1) + `, + ) + .run(data); + return getTitle(db, Number(info.lastInsertRowid)); +} + +/** + * @param {import("better-sqlite3").Database} db + * @param {number} id + * @param {{ enabled?: boolean, title?: string }} patch + * @returns {object|undefined} + */ +function updateTitle(db, id, patch) { + const current = db.prepare("SELECT * FROM titles WHERE id = ?").get(id); + if (!current) { + return undefined; + } + const enabled = + patch.enabled === undefined ? current.enabled : patch.enabled ? 1 : 0; + const title = patch.title === undefined ? current.title : patch.title; + db.prepare("UPDATE titles SET enabled = ?, title = ? WHERE id = ?").run( + enabled, + title, + id, + ); + return getTitle(db, id); +} + +/** + * @param {import("better-sqlite3").Database} db + * @param {number} id + * @returns {boolean} + */ +function deleteTitle(db, id) { + const info = db.prepare("DELETE FROM titles WHERE id = ?").run(id); + return info.changes > 0; +} + +/** + * Upsert a chapter row; does not overwrite downloading/downloaded unless forcePending. + * @param {import("better-sqlite3").Database} db + * @param {object} chapter + * @returns {{ id: number, inserted: boolean }} + */ +function upsertChapter(db, chapter) { + const existing = db + .prepare( + "SELECT id, status, cbz_path FROM chapters WHERE title_id = ? AND chapter_key = ?", + ) + .get(chapter.title_id, chapter.chapter_key); + + if (existing) { + db.prepare( + ` + UPDATE chapters + SET label = @label, url = @url, chapter_number = @chapter_number, + cbz_path = COALESCE(@cbz_path, cbz_path) + WHERE id = @id + `, + ).run({ + id: existing.id, + label: chapter.label, + url: chapter.url, + chapter_number: chapter.chapter_number, + cbz_path: chapter.cbz_path ?? null, + }); + return { id: existing.id, inserted: false }; + } + + const info = db + .prepare( + ` + INSERT INTO chapters ( + title_id, chapter_key, chapter_number, label, url, status, cbz_path + ) VALUES ( + @title_id, @chapter_key, @chapter_number, @label, @url, @status, @cbz_path + ) + `, + ) + .run(chapter); + return { id: Number(info.lastInsertRowid), inserted: true }; +} + +/** + * @param {import("better-sqlite3").Database} db + * @param {number} id + * @returns {object|undefined} + */ +function getChapter(db, id) { + return db.prepare("SELECT * FROM chapters WHERE id = ?").get(id); +} + +/** + * @param {import("better-sqlite3").Database} db + * @param {number} id + * @param {object} fields + */ +function updateChapter(db, id, fields) { + const allowed = [ + "status", + "cbz_path", + "downloaded_at", + "error", + "label", + "url", + ]; + const sets = []; + const values = {}; + for (const key of allowed) { + if (Object.prototype.hasOwnProperty.call(fields, key)) { + sets.push(`${key} = @${key}`); + values[key] = fields[key]; + } + } + if (sets.length === 0) { + return; + } + values.id = id; + db.prepare(`UPDATE chapters SET ${sets.join(", ")} WHERE id = @id`).run(values); +} + +/** + * @param {import("better-sqlite3").Database} db + * @param {{ type: string, title_id?: number|null, chapter_id?: number|null, message?: string|null }} data + * @returns {number} Job id + */ +function createJob(db, data) { + const info = db + .prepare( + ` + INSERT INTO jobs (type, title_id, chapter_id, status, message) + VALUES (@type, @title_id, @chapter_id, 'queued', @message) + `, + ) + .run({ + type: data.type, + title_id: data.title_id ?? null, + chapter_id: data.chapter_id ?? null, + message: data.message ?? null, + }); + return Number(info.lastInsertRowid); +} + +/** + * @param {import("better-sqlite3").Database} db + * @param {number} id + * @param {object} fields + */ +function updateJob(db, id, fields) { + const allowed = ["status", "message", "finished_at"]; + const sets = []; + const values = {}; + for (const key of allowed) { + if (Object.prototype.hasOwnProperty.call(fields, key)) { + sets.push(`${key} = @${key}`); + values[key] = fields[key]; + } + } + if (sets.length === 0) { + return; + } + values.id = id; + db.prepare(`UPDATE jobs SET ${sets.join(", ")} WHERE id = @id`).run(values); +} + +/** + * @param {import("better-sqlite3").Database} db + * @param {{ active?: boolean, limit?: number }} opts + * @returns {object[]} + */ +function listJobs(db, opts = {}) { + const limit = opts.limit ?? 50; + if (opts.active) { + return db + .prepare( + ` + SELECT j.*, t.title AS title_name, c.label AS chapter_label + FROM jobs j + INNER JOIN titles t ON t.id = j.title_id + LEFT JOIN chapters c ON c.id = j.chapter_id + WHERE j.status IN ('queued', 'running') + ORDER BY + CASE j.status WHEN 'running' THEN 0 ELSE 1 END, + j.id ASC + LIMIT ? + `, + ) + .all(limit); + } + return db + .prepare( + ` + SELECT j.*, t.title AS title_name, c.label AS chapter_label + FROM jobs j + LEFT JOIN titles t ON t.id = j.title_id + LEFT JOIN chapters c ON c.id = j.chapter_id + ORDER BY j.id DESC + LIMIT ? + `, + ) + .all(limit); +} + +/** + * @param {import("better-sqlite3").Database} db + * @returns {object[]} + */ +function listEnabledTitles(db) { + return db + .prepare("SELECT * FROM titles WHERE enabled = 1 ORDER BY id ASC") + .all(); +} + +/** + * @param {import("better-sqlite3").Database} db + * @param {number} titleId + */ +function touchTitleChecked(db, titleId) { + db.prepare( + "UPDATE titles SET last_checked_at = datetime('now') WHERE id = ?", + ).run(titleId); +} + +/** + * Chapters that need a download (pending/failed, or downloaded but file missing). + * @param {import("better-sqlite3").Database} db + * @param {number} titleId + * @returns {object[]} + */ +function listChaptersNeedingDownload(db, titleId) { + return db + .prepare( + ` + SELECT * FROM chapters + WHERE title_id = ? + AND status IN ('pending', 'failed') + ORDER BY id ASC + `, + ) + .all(titleId); +} + +/** + * @param {import("better-sqlite3").Database} db - Database handle. + * @param {string} url - Exact listing URL. + * @returns {object|undefined} Title row when found. + */ +function findTitleByUrl(db, url) { + return db.prepare("SELECT * FROM titles WHERE url = ?").get(url); +} + +/** + * Pick the next enabled title due for a watch sync (never-checked first, then oldest). + * @param {import("better-sqlite3").Database} db - Database handle. + * @param {number} minAgeHours - Hours since last check before a title is due again. + * @returns {object|undefined} Title row, or undefined when nothing is due. + */ +function pickNextTitleDue(db, minAgeHours) { + const hours = Math.max(1, Math.floor(Number(minAgeHours) || 24)); + const modifier = `-${hours} hours`; + return db + .prepare( + ` + SELECT * + FROM titles + WHERE enabled = 1 + AND ( + last_checked_at IS NULL + OR last_checked_at <= datetime('now', '${modifier}') + ) + ORDER BY + CASE WHEN last_checked_at IS NULL THEN 0 ELSE 1 END, + last_checked_at ASC, + id ASC + LIMIT 1 + `, + ) + .get(); +} + +module.exports = { + openDatabase, + listTitlesWithCounts, + getTitle, + listChapters, + insertTitle, + updateTitle, + deleteTitle, + upsertChapter, + getChapter, + updateChapter, + createJob, + updateJob, + listJobs, + listEnabledTitles, + touchTitleChecked, + listChaptersNeedingDownload, + findTitleByUrl, + pickNextTitleDue, +}; diff --git a/src/server/index.js b/src/server/index.js new file mode 100644 index 0000000..707d140 --- /dev/null +++ b/src/server/index.js @@ -0,0 +1,92 @@ +const path = require("path"); +const fs = require("fs"); +const Fastify = require("fastify"); +const fastifyStatic = require("@fastify/static"); + +const { openDatabase } = require("./db.js"); +const { DownloadQueue } = require("./queue.js"); +const { startWatcher } = require("./watcher.js"); +const routesPlugin = require("./routes.js"); + +const PORT = Number(process.env.PORT) || 3000; +const DATA_DIR = path.resolve(process.env.DATA_DIR || path.join(process.cwd(), "data")); +/** How often the watcher wakes (default: every hour). */ +const WATCH_CRON = process.env.WATCH_CRON || "0 * * * *"; +/** Only sync a title if it has not been checked for this many hours (default: daily). */ +const WATCH_TITLE_INTERVAL_HOURS = Number(process.env.WATCH_TITLE_INTERVAL_HOURS) || 24; +const PUBLIC_DIR = path.join(process.cwd(), "public"); + +/** + * @returns {Promise} + */ +async function main() { + fs.mkdirSync(DATA_DIR, { recursive: true }); + fs.mkdirSync(PUBLIC_DIR, { recursive: true }); + + const db = openDatabase(DATA_DIR); + const queue = new DownloadQueue(db); + const app = Fastify({ logger: true }); + + // Allow POST/PATCH with Content-Type: application/json and an empty body + app.addContentTypeParser( + "application/json", + { parseAs: "string" }, + (request, body, done) => { + if (body === "" || body == null) { + done(null, {}); + return; + } + try { + done(null, JSON.parse(body)); + } catch (err) { + done(err instanceof Error ? err : new Error(String(err)), undefined); + } + }, + ); + + await app.register(routesPlugin, { db, queue, dataDir: DATA_DIR }); + + await app.register(fastifyStatic, { + root: PUBLIC_DIR, + prefix: "/", + wildcard: false, + }); + + // SPA fallback for non-API routes + app.setNotFoundHandler((request, reply) => { + if (request.url.startsWith("/api/")) { + return reply.code(404).send({ error: "Not found" }); + } + const indexPath = path.join(PUBLIC_DIR, "index.html"); + if (fs.existsSync(indexPath)) { + return reply.type("text/html").send(fs.readFileSync(indexPath)); + } + return reply + .code(503) + .type("text/plain") + .send("Frontend not built. Run: npm run build:web"); + }); + + const watcher = startWatcher(db, queue, DATA_DIR, WATCH_CRON, { + titleIntervalHours: WATCH_TITLE_INTERVAL_HOURS, + }); + + const shutdown = async () => { + watcher.stop(); + await app.close(); + db.close(); + process.exit(0); + }; + process.on("SIGINT", () => void shutdown()); + process.on("SIGTERM", () => void shutdown()); + + await app.listen({ port: PORT, host: "0.0.0.0" }); + app.log.info(`Data dir: ${DATA_DIR}`); + app.log.info(`Watch cron: ${WATCH_CRON}`); + app.log.info(`Watch title interval: ${String(WATCH_TITLE_INTERVAL_HOURS)}h (one title per tick)`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/server/queue.js b/src/server/queue.js new file mode 100644 index 0000000..2e34d5d --- /dev/null +++ b/src/server/queue.js @@ -0,0 +1,359 @@ +const dbApi = require("./db.js"); +const { + downloadChapter, + cbzExists, + removeCbzIfExists, +} = require("./sources.js"); + +/** + * Serial in-process download queue. + */ +class DownloadQueue { + /** + * @param {import("better-sqlite3").Database} db + * @param {{ onChange?: () => void }} [opts] + */ + constructor(db, opts = {}) { + this.db = db; + this.onChange = opts.onChange; + /** @type {number[]} */ + this.pendingJobIds = []; + this.running = false; + /** @type {Set} Chapters cancelled while a download may still be in flight. */ + this.cancelledChapterIds = new Set(); + } + + /** + * Enqueue a chapter download job. + * @param {number} chapterId + * @param {{ type?: 'download'|'redownload', force?: boolean }} [opts] + * @returns {number|null} Job id, or null if skipped (already queued/running for same chapter) + */ + enqueueChapter(chapterId, opts = {}) { + const type = opts.type || (opts.force ? "redownload" : "download"); + const chapter = dbApi.getChapter(this.db, chapterId); + if (!chapter) { + throw new Error(`Chapter ${String(chapterId)} not found.`); + } + + const active = dbApi + .listJobs(this.db, { active: true, limit: 500 }) + .find((j) => j.chapter_id === chapterId); + if (active) { + return active.id; + } + + if (opts.force) { + removeCbzIfExists(chapter.cbz_path); + dbApi.updateChapter(this.db, chapterId, { + status: "pending", + error: null, + downloaded_at: null, + }); + } + + this.cancelledChapterIds.delete(chapterId); + + const jobId = dbApi.createJob(this.db, { + type, + title_id: chapter.title_id, + chapter_id: chapterId, + message: `Queued ${chapter.label}`, + }); + this.pendingJobIds.push(jobId); + this.#notify(); + void this.#pump(); + return jobId; + } + + /** + * Enqueue all chapters that need downloading for a title. + * @param {number} titleId + * @param {{ alsoMissingFiles?: boolean }} [opts] + * @returns {number} Number of jobs enqueued + */ + enqueueMissingForTitle(titleId, opts = {}) { + const chapters = dbApi.listChapters(this.db, titleId); + let count = 0; + for (const ch of chapters) { + const missingFile = + Boolean(opts.alsoMissingFiles) && + ch.status === "downloaded" && + !cbzExists(ch.cbz_path); + const needs = + ch.status === "pending" || + ch.status === "failed" || + missingFile; + if (!needs) { + continue; + } + if (missingFile) { + dbApi.updateChapter(this.db, ch.id, { status: "pending", error: "CBZ missing on disk" }); + } + this.enqueueChapter(ch.id, { type: "download" }); + count += 1; + } + return count; + } + + /** + * Drop all waiting download jobs. The in-flight job (if any) is left to finish. + * @returns {{ cleared: number }} + */ + clear() { + this.pendingJobIds = []; + const info = this.db + .prepare( + ` + UPDATE jobs + SET status = 'failed', + message = 'Cleared from queue', + finished_at = datetime('now') + WHERE status = 'queued' + `, + ) + .run(); + this.#notify(); + return { cleared: Number(info.changes) || 0 }; + } + + /** + * Cancel a stuck/queued/downloading chapter: drop its jobs and reset status to pending. + * If a download is mid-flight, ignore its success when it finishes. + * @param {number} chapterId - Chapter row id. + * @returns {{ ok: boolean, chapter: object|undefined }} + */ + cancelChapter(chapterId) { + const chapter = dbApi.getChapter(this.db, chapterId); + if (!chapter) { + return { ok: false, chapter: undefined }; + } + + this.cancelledChapterIds.add(chapterId); + + this.pendingJobIds = this.pendingJobIds.filter((jobId) => { + const job = this.db.prepare("SELECT chapter_id FROM jobs WHERE id = ?").get(jobId); + return !job || job.chapter_id !== chapterId; + }); + + this.db + .prepare( + ` + UPDATE jobs + SET status = 'failed', + message = 'Cancelled by user', + finished_at = datetime('now') + WHERE chapter_id = ? + AND status IN ('queued', 'running') + `, + ) + .run(chapterId); + + dbApi.updateChapter(this.db, chapterId, { + status: "pending", + error: "Cancelled by user", + downloaded_at: null, + }); + + this.#notify(); + return { ok: true, chapter: dbApi.getChapter(this.db, chapterId) }; + } + + /** + * Remove a watched title: cancel its jobs/queue entries, then delete the row. + * @param {number} titleId - Title id to remove. + * @returns {boolean} True when the title existed and was deleted. + */ + removeTitle(titleId) { + const title = this.db.prepare("SELECT id FROM titles WHERE id = ?").get(titleId); + if (!title) { + return false; + } + + const chapters = dbApi.listChapters(this.db, titleId); + for (const ch of chapters) { + this.cancelledChapterIds.add(ch.id); + } + + this.pendingJobIds = this.pendingJobIds.filter((jobId) => { + const job = this.db.prepare("SELECT title_id FROM jobs WHERE id = ?").get(jobId); + return !job || job.title_id !== titleId; + }); + + this.db + .prepare( + ` + UPDATE jobs + SET status = 'failed', + message = 'Title removed', + finished_at = datetime('now') + WHERE title_id = ? + AND status IN ('queued', 'running') + `, + ) + .run(titleId); + + this.db.prepare("DELETE FROM jobs WHERE title_id = ?").run(titleId); + dbApi.deleteTitle(this.db, titleId); + this.#notify(); + return true; + } + + async #pump() { + if (this.running) { + return; + } + this.running = true; + try { + while (this.pendingJobIds.length > 0) { + const jobId = this.pendingJobIds.shift(); + if (jobId === undefined) { + break; + } + await this.#runJob(jobId); + } + } finally { + this.running = false; + this.#notify(); + } + } + + /** + * @param {number} jobId + */ + async #runJob(jobId) { + const job = this.db.prepare("SELECT * FROM jobs WHERE id = ?").get(jobId); + if (!job || job.status !== "queued") { + return; + } + + const chapter = job.chapter_id + ? dbApi.getChapter(this.db, job.chapter_id) + : null; + if (!chapter) { + dbApi.updateJob(this.db, jobId, { + status: "failed", + message: "Chapter missing", + finished_at: new Date().toISOString(), + }); + this.#notify(); + return; + } + + const title = dbApi.getTitle(this.db, chapter.title_id); + if (!title) { + dbApi.updateJob(this.db, jobId, { + status: "failed", + message: "Title missing", + finished_at: new Date().toISOString(), + }); + this.#notify(); + return; + } + + // Skip if already present (unless redownload) + if ( + job.type !== "redownload" && + chapter.status === "downloaded" && + cbzExists(chapter.cbz_path) + ) { + dbApi.updateJob(this.db, jobId, { + status: "done", + message: "Already downloaded", + finished_at: new Date().toISOString(), + }); + this.#notify(); + return; + } + + dbApi.updateJob(this.db, jobId, { + status: "running", + message: `Downloading ${chapter.label}`, + }); + dbApi.updateChapter(this.db, chapter.id, { + status: "downloading", + error: null, + }); + this.#notify(); + + const cbzPath = chapter.cbz_path; + if (!cbzPath) { + dbApi.updateChapter(this.db, chapter.id, { + status: "failed", + error: "No cbz_path set", + }); + dbApi.updateJob(this.db, jobId, { + status: "failed", + message: "No cbz_path set", + finished_at: new Date().toISOString(), + }); + this.#notify(); + return; + } + + try { + const { imageCount, outputPath } = await downloadChapter( + title.source, + chapter.url, + cbzPath, + ); + + if (this.cancelledChapterIds.has(chapter.id)) { + this.cancelledChapterIds.delete(chapter.id); + dbApi.updateJob(this.db, jobId, { + status: "failed", + message: "Cancelled by user", + finished_at: new Date().toISOString(), + }); + // Keep chapter as pending if cancel already reset it; otherwise force pending. + const latest = dbApi.getChapter(this.db, chapter.id); + if (latest && latest.status === "downloading") { + dbApi.updateChapter(this.db, chapter.id, { + status: "pending", + error: "Cancelled by user", + }); + } + this.#notify(); + return; + } + + dbApi.updateChapter(this.db, chapter.id, { + status: "downloaded", + cbz_path: outputPath, + downloaded_at: new Date().toISOString(), + error: null, + }); + dbApi.updateJob(this.db, jobId, { + status: "done", + message: `${chapter.label}: ${String(imageCount)} page(s)`, + finished_at: new Date().toISOString(), + }); + } catch (err) { + if (this.cancelledChapterIds.has(chapter.id)) { + this.cancelledChapterIds.delete(chapter.id); + } + const msg = err instanceof Error ? err.message : String(err); + const latest = dbApi.getChapter(this.db, chapter.id); + if (!latest || latest.status === "downloading") { + dbApi.updateChapter(this.db, chapter.id, { + status: "failed", + error: msg, + }); + } + dbApi.updateJob(this.db, jobId, { + status: "failed", + message: msg, + finished_at: new Date().toISOString(), + }); + } + this.#notify(); + } + + #notify() { + if (typeof this.onChange === "function") { + this.onChange(); + } + } +} + +module.exports = { DownloadQueue }; diff --git a/src/server/routes.js b/src/server/routes.js new file mode 100644 index 0000000..399c278 --- /dev/null +++ b/src/server/routes.js @@ -0,0 +1,226 @@ +const dbApi = require("./db.js"); +const { + detectSource, + normalizeListingUrl, + fetchSeriesChapters, + resolveCbzPath, + cbzExists, +} = require("./sources.js"); +const { syncTitle } = require("./watcher.js"); +const { formatUpstreamError } = require("../flaresolverr.js"); + +/** + * @param {import("fastify").FastifyInstance} fastify - Fastify app. + * @param {{ db: import("better-sqlite3").Database, queue: import("./queue.js").DownloadQueue, dataDir: string }} opts - Shared deps. + */ +async function routesPlugin(fastify, opts) { + const { db, queue, dataDir } = opts; + + fastify.get("/api/titles", async () => { + return { titles: dbApi.listTitlesWithCounts(db) }; + }); + + fastify.get("/api/titles/:id", async (request, reply) => { + const id = Number(request.params.id); + const title = dbApi.getTitle(db, id); + if (!title) { + return reply.code(404).send({ error: "Title not found" }); + } + return { title }; + }); + + fastify.post("/api/titles", async (request, reply) => { + const body = request.body || {}; + const url = typeof body.url === "string" ? body.url.trim() : ""; + if (!url) { + return reply.code(400).send({ error: "url is required" }); + } + + let source; + let listingUrl; + try { + source = detectSource(url); + listingUrl = normalizeListingUrl(url, source); + } catch (err) { + return reply + .code(400) + .send({ error: err instanceof Error ? err.message : String(err) }); + } + + const siteLabel = source === "manhwasusu" ? "ManhwaSusu" : "Mangaread"; + + const existing = dbApi.findTitleByUrl(db, listingUrl); + if (existing) { + try { + const result = await syncTitle(db, queue, existing.id, dataDir, { + enqueue: true, + }); + return { + title: dbApi.getTitle(db, existing.id), + created: false, + ...result, + }; + } catch (err) { + return reply.code(502).send({ + error: formatUpstreamError(err, siteLabel), + }); + } + } + + let series; + try { + series = await fetchSeriesChapters(listingUrl, source); + } catch (err) { + return reply.code(502).send({ + error: formatUpstreamError(err, siteLabel), + }); + } + + const title = dbApi.insertTitle(db, { + source: series.source, + url: series.listingUrl, + slug: series.slug, + title: series.comicTitle, + }); + + for (const ch of series.chapters) { + const cbzPath = resolveCbzPath( + series.source, + series.comicTitle, + ch.url, + dataDir, + ); + const exists = cbzExists(cbzPath); + dbApi.upsertChapter(db, { + title_id: title.id, + chapter_key: ch.chapter_key, + chapter_number: ch.chapter_number, + label: ch.title, + url: ch.url, + status: exists ? "downloaded" : "pending", + cbz_path: cbzPath, + }); + if (exists) { + const row = db + .prepare( + "SELECT id FROM chapters WHERE title_id = ? AND chapter_key = ?", + ) + .get(title.id, ch.chapter_key); + if (row) { + dbApi.updateChapter(db, row.id, { + status: "downloaded", + downloaded_at: new Date().toISOString(), + error: null, + }); + } + } + } + + dbApi.touchTitleChecked(db, title.id); + const enqueued = queue.enqueueMissingForTitle(title.id, { + alsoMissingFiles: true, + }); + + return { + title: dbApi.getTitle(db, title.id), + created: true, + added: series.chapters.length, + enqueued, + total: series.chapters.length, + }; + }); + + fastify.patch("/api/titles/:id", async (request, reply) => { + const id = Number(request.params.id); + const body = request.body || {}; + const patch = {}; + if (typeof body.enabled === "boolean") { + patch.enabled = body.enabled; + } + if (typeof body.title === "string" && body.title.trim()) { + patch.title = body.title.trim(); + } + const title = dbApi.updateTitle(db, id, patch); + if (!title) { + return reply.code(404).send({ error: "Title not found" }); + } + return { title }; + }); + + fastify.delete("/api/titles/:id", async (request, reply) => { + const id = Number(request.params.id); + const ok = queue.removeTitle(id); + if (!ok) { + return reply.code(404).send({ error: "Title not found" }); + } + return { + ok: true, + jobs: dbApi.listJobs(db, { active: true, limit: 20 }), + }; + }); + + fastify.get("/api/titles/:id/chapters", async (request, reply) => { + const id = Number(request.params.id); + const title = dbApi.getTitle(db, id); + if (!title) { + return reply.code(404).send({ error: "Title not found" }); + } + return { chapters: dbApi.listChapters(db, id) }; + }); + + fastify.post("/api/titles/:id/sync", async (request, reply) => { + const id = Number(request.params.id); + const title = dbApi.getTitle(db, id); + if (!title) { + return reply.code(404).send({ error: "Title not found" }); + } + const siteLabel = title.source === "manhwasusu" ? "ManhwaSusu" : "Mangaread"; + try { + const result = await syncTitle(db, queue, id, dataDir, { enqueue: true }); + return { title: dbApi.getTitle(db, id), ...result }; + } catch (err) { + return reply.code(502).send({ + error: formatUpstreamError(err, siteLabel), + }); + } + }); + + fastify.post("/api/chapters/:id/redownload", async (request, reply) => { + const id = Number(request.params.id); + const chapter = dbApi.getChapter(db, id); + if (!chapter) { + return reply.code(404).send({ error: "Chapter not found" }); + } + const jobId = queue.enqueueChapter(id, { force: true, type: "redownload" }); + return { ok: true, jobId, chapter: dbApi.getChapter(db, id) }; + }); + + fastify.post("/api/chapters/:id/cancel", async (request, reply) => { + const id = Number(request.params.id); + const result = queue.cancelChapter(id); + if (!result.ok) { + return reply.code(404).send({ error: "Chapter not found" }); + } + return { ok: true, chapter: result.chapter }; + }); + + fastify.get("/api/jobs", async (request) => { + const active = + request.query.active === "1" || + request.query.active === "true" || + request.query.active === true; + const limit = request.query.limit ? Number(request.query.limit) : 50; + return { jobs: dbApi.listJobs(db, { active, limit }) }; + }); + + fastify.post("/api/jobs/clear", async () => { + const result = queue.clear(); + return { + ok: true, + cleared: result.cleared, + jobs: dbApi.listJobs(db, { active: true, limit: 20 }), + }; + }); +} + +module.exports = routesPlugin; diff --git a/src/server/sources.js b/src/server/sources.js new file mode 100644 index 0000000..dd1b352 --- /dev/null +++ b/src/server/sources.js @@ -0,0 +1,258 @@ +const fs = require("fs"); +const path = require("path"); + +const { + fetchWpMangaChapterLinks, + mangaSlugFromUrl, +} = require("../mangareadFetchChapters.js"); +const { + chapterUrlToCbz: mangareadChapterUrlToCbz, + sanitizeComicDirName: mangareadSanitizeDir, + sanitizeFsStem: mangareadSanitizeStem, + stemForCbzFilename: mangareadStem, +} = require("../downloadChapterCbz.js"); +const { chaptersInReadingOrder: mangareadReadingOrder } = require("../downloadChaptersQueue.js"); +const { chapterNumberFromWpTitle } = require("../downloadChaptersQueue.js"); + +const manhwa = require("../manhwasusu/index.js"); +const { + chaptersInReadingOrder: manhwaReadingOrder, +} = require("../manhwasusu/downloadChaptersQueue.js"); + +/** + * @typedef {'mangaread'|'manhwasusu'} SourceId + */ + +/** + * Detect source from a series listing URL. + * @param {string} rawUrl + * @returns {SourceId} + */ +function detectSource(rawUrl) { + let parsed; + try { + parsed = new URL(rawUrl); + } catch { + throw new Error("Invalid URL."); + } + + const host = parsed.hostname.toLowerCase(); + const pathname = parsed.pathname.replace(/\/+$/u, ""); + + if ( + host.includes("manhwasusu") || + manhwa.isLikelyManhwaSusuSite(rawUrl) || + /^\/read\/[^/]+/iu.test(pathname) + ) { + if (manhwa.isLikelyManhwaSusuSite(rawUrl) || host.includes("manhwasusu")) { + return "manhwasusu"; + } + } + + if ( + host.includes("mangaread") || + /^\/manga\/[^/]+/iu.test(pathname) + ) { + return "mangaread"; + } + + // Heuristic fallbacks by path shape + if (/^\/read\/[^/]+/iu.test(pathname)) { + return "manhwasusu"; + } + if (/^\/manga\/[^/]+/iu.test(pathname)) { + return "mangaread"; + } + + throw new Error( + "Unsupported URL. Use a Mangaread `/manga/{slug}/` or ManhwaSusu `/read/{slug}/` series page.", + ); +} + +/** + * Normalize listing URL (strip chapter segment if pasted a chapter URL). + * @param {string} rawUrl + * @param {SourceId} source + * @returns {string} + */ +function normalizeListingUrl(rawUrl, source) { + const u = new URL(rawUrl); + const segs = u.pathname.replace(/\/+$/u, "").split("/").filter(Boolean); + + if (source === "mangaread") { + const mangaIdx = segs.indexOf("manga"); + if (mangaIdx !== -1 && segs[mangaIdx + 1]) { + u.pathname = `/manga/${segs[mangaIdx + 1]}/`; + u.search = ""; + u.hash = ""; + return u.toString(); + } + } + + if (source === "manhwasusu") { + const readIdx = segs.indexOf("read"); + if (readIdx !== -1 && segs[readIdx + 1]) { + u.pathname = `/read/${segs[readIdx + 1]}/`; + u.search = ""; + u.hash = ""; + return u.toString(); + } + } + + if (!u.pathname.endsWith("/")) { + u.pathname = `${u.pathname}/`; + } + return u.toString(); +} + +/** + * @param {string} chapterUrl + * @returns {string} e.g. chapter-22 + */ +function chapterKeyFromUrl(chapterUrl) { + try { + const segs = new URL(chapterUrl).pathname.replace(/\/+$/u, "").split("/").filter(Boolean); + return segs.at(-1) || "chapter"; + } catch { + return "chapter"; + } +} + +/** + * @param {string} title + * @param {SourceId} source + * @returns {string|null} + */ +function chapterNumberFromTitle(title, source) { + if (source === "mangaread") { + return chapterNumberFromWpTitle(title); + } + const m = /^Chapter\s+([\d.]+)/iu.exec(title.trim().replace(/\s+/gu, " ")); + return m ? m[1] : null; +} + +/** + * @param {SourceId} source - Site adapter id. + * @param {string} dataDir - Absolute data root. + * @returns {string} Comics output directory for the source. + */ +function comicsRoot(source, dataDir) { + if (source === "manhwasusu") { + return path.join(dataDir, "manhwasusu", "comics"); + } + return path.join(dataDir, "comics"); +} + +/** + * @param {SourceId} source + * @param {string} comicTitle + * @param {string} chapterUrl + * @param {string} dataDir + * @returns {string} Absolute CBZ path + */ +function resolveCbzPath(source, comicTitle, chapterUrl, dataDir) { + const dirName = + source === "manhwasusu" + ? manhwa.sanitizeComicDirName(comicTitle) + : mangareadSanitizeDir(comicTitle); + const stem = + source === "manhwasusu" + ? manhwa.sanitizeFsStem(manhwa.stemForManhwaSusuChapterCbz(chapterUrl)) + : mangareadSanitizeStem(mangareadStem(chapterUrl)); + return path.join(comicsRoot(source, dataDir), dirName, `${stem}.cbz`); +} + +/** + * Fetch chapter list for a series listing URL. + * @param {string} listingUrl + * @param {SourceId} [sourceHint] + * @returns {Promise<{ source: SourceId, listingUrl: string, slug: string, comicTitle: string, chapters: Array<{ url: string, title: string, chapter_key: string, chapter_number: string|null }> }>} + */ +async function fetchSeriesChapters(listingUrl, sourceHint) { + const source = sourceHint || detectSource(listingUrl); + const normalized = normalizeListingUrl(listingUrl, source); + + if (source === "manhwasusu") { + const { chapters, comicTitle } = await manhwa.fetchChapterLinks(normalized); + const ordered = manhwaReadingOrder(chapters); + const slug = manhwa.seriesSlugFromListingUrl(normalized); + return { + source, + listingUrl: normalized, + slug, + comicTitle, + chapters: ordered.map((ch) => ({ + url: ch.url, + title: ch.title, + chapter_key: chapterKeyFromUrl(ch.url), + chapter_number: chapterNumberFromTitle(ch.title, source), + })), + }; + } + + const { chapters, comicTitle } = await fetchWpMangaChapterLinks(normalized); + const ordered = mangareadReadingOrder(chapters); + const slug = mangaSlugFromUrl(normalized); + return { + source, + listingUrl: normalized, + slug, + comicTitle, + chapters: ordered.map((ch) => ({ + url: ch.url, + title: ch.title, + chapter_key: chapterKeyFromUrl(ch.url), + chapter_number: chapterNumberFromTitle(ch.title, source), + })), + }; +} + +/** + * Download one chapter to CBZ. + * @param {SourceId} source + * @param {string} chapterUrl + * @param {string} outputCbzPath + * @returns {Promise<{ imageCount: number, outputPath: string }>} + */ +async function downloadChapter(source, chapterUrl, outputCbzPath) { + if (source === "manhwasusu") { + return manhwa.chapterUrlToCbz(chapterUrl, outputCbzPath); + } + return mangareadChapterUrlToCbz(chapterUrl, outputCbzPath); +} + +/** + * @param {string|null|undefined} cbzPath + * @returns {boolean} + */ +function cbzExists(cbzPath) { + if (!cbzPath) { + return false; + } + try { + return fs.existsSync(cbzPath) && fs.statSync(cbzPath).isFile(); + } catch { + return false; + } +} + +/** + * @param {string|null|undefined} cbzPath + */ +function removeCbzIfExists(cbzPath) { + if (cbzPath && fs.existsSync(cbzPath)) { + fs.unlinkSync(cbzPath); + } +} + +module.exports = { + detectSource, + normalizeListingUrl, + chapterKeyFromUrl, + resolveCbzPath, + fetchSeriesChapters, + downloadChapter, + cbzExists, + removeCbzIfExists, + comicsRoot, +}; diff --git a/src/server/watcher.js b/src/server/watcher.js new file mode 100644 index 0000000..0c33d09 --- /dev/null +++ b/src/server/watcher.js @@ -0,0 +1,172 @@ +const cron = require("node-cron"); +const dbApi = require("./db.js"); +const { + fetchSeriesChapters, + resolveCbzPath, + cbzExists, +} = require("./sources.js"); + +/** + * Sync a title's chapter list from the source site and enqueue missing downloads. + * @param {import("better-sqlite3").Database} db - Database handle. + * @param {import("./queue.js").DownloadQueue} queue - Download queue. + * @param {number} titleId - Title to sync. + * @param {string} dataDir - Absolute data root for CBZ paths. + * @param {{ enqueue?: boolean }} [opts] - Options; `enqueue` defaults to true. + * @returns {Promise<{ added: number, enqueued: number, total: number }>} Sync summary. + */ +async function syncTitle(db, queue, titleId, dataDir, opts = {}) { + const title = dbApi.getTitle(db, titleId); + if (!title) { + throw new Error(`Title ${String(titleId)} not found.`); + } + + const syncJobId = dbApi.createJob(db, { + type: "watch_sync", + title_id: titleId, + message: `Syncing ${title.title}`, + }); + dbApi.updateJob(db, syncJobId, { status: "running" }); + + try { + const series = await fetchSeriesChapters(title.url, title.source); + let added = 0; + + for (const ch of series.chapters) { + const cbzPath = resolveCbzPath( + title.source, + series.comicTitle || title.title, + ch.url, + dataDir, + ); + const exists = cbzExists(cbzPath); + const { inserted } = dbApi.upsertChapter(db, { + title_id: titleId, + chapter_key: ch.chapter_key, + chapter_number: ch.chapter_number, + label: ch.title, + url: ch.url, + status: exists ? "downloaded" : "pending", + cbz_path: cbzPath, + }); + if (inserted) { + added += 1; + } + + // Heal statuses without interrupting an in-flight download + const row = db + .prepare( + "SELECT id, status, cbz_path FROM chapters WHERE title_id = ? AND chapter_key = ?", + ) + .get(titleId, ch.chapter_key); + if (row && row.status === "downloading") { + continue; + } + if (row && row.status === "downloaded" && !cbzExists(row.cbz_path)) { + dbApi.updateChapter(db, row.id, { + status: "pending", + error: "CBZ missing on disk", + }); + } + if (row && exists && row.status !== "downloaded") { + dbApi.updateChapter(db, row.id, { + status: "downloaded", + cbz_path: cbzPath, + downloaded_at: new Date().toISOString(), + error: null, + }); + } + } + + // Refresh comic title if site provides a better one + if (series.comicTitle && series.comicTitle !== title.title) { + dbApi.updateTitle(db, titleId, { title: series.comicTitle }); + } + + dbApi.touchTitleChecked(db, titleId); + + let enqueued = 0; + if (opts.enqueue !== false) { + enqueued = queue.enqueueMissingForTitle(titleId, { alsoMissingFiles: true }); + } + + dbApi.updateJob(db, syncJobId, { + status: "done", + message: `Sync complete: +${String(added)} chapter(s), ${String(enqueued)} queued`, + finished_at: new Date().toISOString(), + }); + + return { + added, + enqueued, + total: series.chapters.length, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + dbApi.updateJob(db, syncJobId, { + status: "failed", + message: msg, + finished_at: new Date().toISOString(), + }); + throw err; + } +} + +/** + * Watches titles on a schedule. Each tick syncs at most one due title + * (oldest `last_checked_at` first), so titles are staggered instead of + * all hitting the source sites at once. Default cadence is hourly wake + + * 24h per-title cooldown → each title roughly once per day, unlimited count. + * @param {import("better-sqlite3").Database} db - Database handle. + * @param {import("./queue.js").DownloadQueue} queue - Download queue. + * @param {string} dataDir - Absolute data root. + * @param {string} cronExpr - node-cron expression. + * @param {{ titleIntervalHours?: number }} [opts] - Per-title cooldown in hours. + * @returns {{ stop: () => void }} Handle to stop the cron task. + */ +function startWatcher(db, queue, dataDir, cronExpr, opts = {}) { + if (!cron.validate(cronExpr)) { + throw new Error(`Invalid WATCH_CRON expression: ${cronExpr}`); + } + + const titleIntervalHours = + Number(opts.titleIntervalHours) > 0 ? Number(opts.titleIntervalHours) : 24; + + let busy = false; + + const task = cron.schedule(cronExpr, () => { + if (busy) { + console.warn("[watcher] previous sync still running; skipping this tick"); + return; + } + busy = true; + void (async () => { + try { + const title = dbApi.pickNextTitleDue(db, titleIntervalHours); + if (!title) { + console.info("[watcher] no titles due this tick"); + return; + } + console.info( + `[watcher] syncing 1 title: ${title.title} (${title.source})`, + ); + await syncTitle(db, queue, title.id, dataDir, { enqueue: true }); + } catch (err) { + console.error( + "[watcher] sync failed:", + err instanceof Error ? err.message : err, + ); + } finally { + busy = false; + } + })(); + }); + + return { + stop() { + task.stop(); + }, + }; +} + +module.exports = { syncTitle, startWatcher }; diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..30d8ea4 --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + Watch & Download + + +
+ + + diff --git a/web/src/App.svelte b/web/src/App.svelte new file mode 100644 index 0000000..122b018 --- /dev/null +++ b/web/src/App.svelte @@ -0,0 +1,378 @@ + + +
+
+

Watch & Download

+
+ + {#if error} + + {/if} + +
+ + +
+ +
+
+

Activity

+ +
+ {#if jobs.length === 0} +

No active jobs.

+ {:else} +
    + {#each jobs as job (job.id)} +
  • +
    + {job.status} + — {job.message || job.type} + {#if job.title_name} + ({job.title_name}) + {/if} +
    + {#if job.chapter_id} + + {/if} +
  • + {/each} +
+ {/if} +
+ +
+

Titles

+ {#if titles.length === 0} +

No titles yet. Add a series URL above.

+ {:else} + {#each titles as title (title.id)} +
+
+
+ {title.title} + {title.source} + + {title.downloaded} / {title.total} downloaded + {#if title.downloading > 0} + · {title.downloading} downloading + {/if} + {#if title.failed > 0} + · {title.failed} failed + {/if} + +
+

+ {title.url} + {#if title.last_checked_at} + · last checked {title.last_checked_at} + {/if} + · watch {title.enabled ? "on" : "off"} +

+
+ + + + +
+
+ + {#if expanded[title.id]} + {#if loadingChapters[title.id] && !chaptersByTitle[title.id]} +

Loading chapters…

+ {:else} +
    + {#each chaptersByTitle[title.id] || [] as ch (ch.id)} +
  • +
    {ch.label}
    + {ch.status} + {#if ch.error} +
    {ch.error}
    + {/if} +
    + {#if ch.status === "downloading"} + + {/if} + +
    +
  • + {/each} +
+
+ + + + + + + + + + {#each chaptersByTitle[title.id] || [] as ch (ch.id)} + + + + + + {/each} + +
ChapterStatus
{ch.label} + {ch.status} + {#if ch.error} +
{ch.error}
+ {/if} +
+
+ {#if ch.status === "downloading"} + + {/if} + +
+
+
+ {/if} + {/if} +
+ {/each} + {/if} +
+
diff --git a/web/src/app.css b/web/src/app.css new file mode 100644 index 0000000..cb66f22 --- /dev/null +++ b/web/src/app.css @@ -0,0 +1,332 @@ +:root { + --pico-font-family: "Segoe UI", system-ui, sans-serif; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + overflow-x: clip; +} + +body { + margin: 0; + overflow-x: clip; + width: 100%; +} + +#app { + width: 100%; + min-width: 0; + max-width: 100%; +} + +/* Fluid layout — avoid Pico .container stepped max-widths (510/700/950px) */ +.app-shell { + display: block; + width: 100%; + max-width: min(960px, 100%); + margin-inline: auto; + padding-inline: max(1rem, env(safe-area-inset-left, 0px), env(safe-area-inset-right, 0px)); + padding-block: 1.25rem; + min-width: 0; +} + +h1 { + font-size: clamp(1.5rem, 5vw, 2rem); + line-height: 1.2; + overflow-wrap: anywhere; +} + +.muted { + color: var(--pico-muted-color); + font-size: 0.9rem; +} + +.url-line, +.url-line a { + overflow-wrap: anywhere; + word-break: break-word; +} + +.row-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; + margin-top: 0.75rem; +} + +.row-actions button { + width: 100%; + margin-bottom: 0; + white-space: normal; + line-height: 1.2; + padding-inline: 0.5rem; +} + +.status { + text-transform: uppercase; + font-size: 0.75rem; + letter-spacing: 0.04em; + font-weight: 600; +} + +.status-pending { + color: #a67c00; +} + +.status-downloading { + color: #0b6bcb; +} + +.status-downloaded { + color: #1b7f3a; +} + +.status-failed { + color: #c62828; +} + +.progress-text { + font-variant-numeric: tabular-nums; +} + +.error-banner { + color: #c62828; + overflow-wrap: anywhere; +} + +.jobs-strip { + margin-bottom: 1.5rem; +} + +.section-heading { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.section-heading h2 { + margin-bottom: 0; +} + +.section-heading button { + width: auto; + margin-bottom: 0; +} + +.jobs-strip ul { + margin-bottom: 0; + padding-inline-start: 0; + list-style: none; +} + +.jobs-strip li { + overflow-wrap: anywhere; + word-break: break-word; +} + +.job-list { + display: grid; + gap: 0.5rem; +} + +.job-row { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.5rem 0.75rem; + padding: 0.5rem 0.65rem; + border: 1px solid var(--pico-muted-border-color); + border-radius: var(--pico-border-radius); +} + +.job-info { + flex: 1 1 12rem; + min-width: 0; +} + +.job-cancel { + width: auto; + margin-bottom: 0; + flex: 0 0 auto; +} + +@media (max-width: 639px) { + .job-cancel { + width: 100%; + } +} + +.title-meta { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; +} + +.title-meta strong { + overflow-wrap: anywhere; + word-break: break-word; +} + +article { + min-width: 0; + max-width: 100%; + overflow: hidden; +} + +article header { + min-width: 0; +} + +section { + min-width: 0; + max-width: 100%; +} + +form.add-title { + display: grid; + gap: 0.75rem; + margin-bottom: 2rem; + min-width: 0; + max-width: 100%; +} + +form.add-title label { + min-width: 0; +} + +form.add-title input { + max-width: 100%; + min-width: 0; +} + +form.add-title button { + width: 100%; + margin-bottom: 0; +} + +.table-wrap { + width: 100%; + max-width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + margin-top: 0.75rem; +} + +.table-wrap table { + margin-bottom: 0; + min-width: 28rem; +} + +.table-wrap th, +.table-wrap td { + white-space: nowrap; +} + +.table-wrap td:first-child { + white-space: normal; + overflow-wrap: anywhere; + max-width: 12rem; +} + +.chapter-list { + display: grid; + gap: 0.75rem; + margin-top: 0.75rem; + list-style: none; + padding: 0; +} + +.chapter-card { + border: 1px solid var(--pico-muted-border-color); + border-radius: var(--pico-border-radius); + padding: 0.75rem; + min-width: 0; +} + +.chapter-card .chapter-label { + font-weight: 600; + overflow-wrap: anywhere; + margin-bottom: 0.35rem; +} + +.chapter-card .chapter-error { + margin-top: 0.25rem; + overflow-wrap: anywhere; +} + +.chapter-card button { + width: 100%; + margin-top: 0.5rem; + margin-bottom: 0; +} + +.chapter-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.chapter-actions button { + margin-top: 0; + margin-bottom: 0; +} + +@media (max-width: 639px) { + .chapter-actions button { + width: 100%; + } +} + +@media (min-width: 640px) { + .app-shell { + padding-inline: max(1.5rem, env(safe-area-inset-left, 0px), env(safe-area-inset-right, 0px)); + padding-block: 1.5rem; + } + + form.add-title { + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + } + + form.add-title button { + width: auto; + } + + .title-meta { + flex-direction: row; + flex-wrap: wrap; + gap: 0.75rem 1.25rem; + align-items: baseline; + } + + .row-actions { + display: flex; + flex-wrap: wrap; + grid-template-columns: unset; + } + + .row-actions button { + width: auto; + } + + .chapter-list { + display: none; + } +} + +@media (max-width: 639px) { + .table-wrap { + display: none; + } +} diff --git a/web/src/main.js b/web/src/main.js new file mode 100644 index 0000000..4d4af93 --- /dev/null +++ b/web/src/main.js @@ -0,0 +1,9 @@ +import "@picocss/pico/css/pico.min.css"; +import "./app.css"; +import { mount } from "svelte"; +import App from "./App.svelte"; + +const target = document.getElementById("app"); +if (target) { + mount(App, { target }); +} diff --git a/web/svelte.config.mjs b/web/svelte.config.mjs new file mode 100644 index 0000000..1d07739 --- /dev/null +++ b/web/svelte.config.mjs @@ -0,0 +1,8 @@ +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; + +/** @type {import('@sveltejs/vite-plugin-svelte').SvelteConfig} */ +const config = { + preprocess: vitePreprocess(), +}; + +export default config; diff --git a/web/vite.config.mjs b/web/vite.config.mjs new file mode 100644 index 0000000..d70cd6a --- /dev/null +++ b/web/vite.config.mjs @@ -0,0 +1,20 @@ +import { defineConfig } from "vite"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + root: __dirname, + plugins: [svelte()], + build: { + outDir: path.resolve(__dirname, "../public"), + emptyOutDir: true, + }, + server: { + proxy: { + "/api": "http://127.0.0.1:3000", + }, + }, +});