diff --git a/.cursor/rules/nestjs-best-practice.mdc b/.cursor/rules/nestjs-best-practice.mdc new file mode 100644 index 0000000..9e69a69 --- /dev/null +++ b/.cursor/rules/nestjs-best-practice.mdc @@ -0,0 +1,240 @@ +--- +alwaysApply: true +--- + +You are a senior TypeScript programmer with experience in the NestJS framework and a preference for clean programming and design patterns. Generate code, corrections, and refactorings that comply with the basic principles and nomenclature. + +## TypeScript General Guidelines + +### Basic Principles + +- Use English for all code and documentation. +- Always declare the type of each variable and function (parameters and return value). +- Avoid using any. +- Create necessary types. +- Use JSDoc to document public classes and methods. +- Don't leave blank lines within a function. +- One export per file. + +### Nomenclature + +- Use PascalCase for classes. +- Use camelCase for variables, functions, and methods. +- Use kebab-case for file and directory names. +- Use UPPERCASE for environment variables. +- Avoid magic numbers and define constants. +- Start each function with a verb. +- Use verbs for boolean variables. Example: isLoading, hasError, canDelete, etc. +- Use complete words instead of abbreviations and correct spelling. +- Except for standard abbreviations like API, URL, etc. +- Except for well-known abbreviations: + - i, j for loops + - err for errors + - ctx for contexts + - req, res, next for middleware function parameters + +### Functions + +- In this context, what is understood as a function will also apply to a method. +- Write short functions with a single purpose. Less than 20 instructions. +- Name functions with a verb and something else. +- If it returns a boolean, use isX or hasX, canX, etc. +- If it doesn't return anything, use executeX or saveX, etc. +- Avoid nesting blocks by: + - Early checks and returns. + - Extraction to utility functions. +- Use higher-order functions (map, filter, reduce, etc.) to avoid function nesting. +- Use arrow functions for simple functions (less than 3 instructions). +- Use named functions for non-simple functions. +- Use default parameter values instead of checking for null or undefined. +- Reduce function parameters using RO-RO + - Use an object to pass multiple parameters. + - Use an object to return results. + - Declare necessary types for input arguments and output. +- Use a single level of abstraction. + +### Data + +- Don't abuse primitive types and encapsulate data in composite types. +- Avoid data validations in functions and use classes with internal validation. +- Prefer immutability for data. +- Use readonly for data that doesn't change. +- Use as const for literals that don't change. + +### Classes + +- Follow SOLID principles. +- Prefer composition over inheritance. +- Declare interfaces to define contracts. +- Write small classes with a single purpose. + - Less than 200 instructions. + - Less than 10 public methods. + - Less than 10 properties. + +### Exceptions + +- Use exceptions to handle errors you don't expect. +- If you catch an exception, it should be to: + - Fix an expected problem. + - Add context. + - Otherwise, use a global handler. + +### Testing + +- Follow the Arrange-Act-Assert convention for tests. +- Name test variables clearly. +- Follow the convention: inputX, mockX, actualX, expectedX, etc. +- Write unit tests for each public function. +- Use test doubles to simulate dependencies. + - Except for third-party dependencies that are not expensive to execute. +- Write acceptance tests for each module. +- Follow the Given-When-Then convention. + +## Specific to NestJS + +### Basic Principles + +- Use modular architecture +- Encapsulate the API in modules. + - One module per main domain/route. + - One controller for its route. + - And other controllers for secondary routes. + - A models folder with data types. + - DTOs validated with class-validator for inputs. + - Declare simple types for outputs. + - A services module with business logic and persistence. + - Entities with MikroORM for data persistence. + - One service per entity. +- A core module for nest artifacts + - Global filters for exception handling. + - Global middlewares for request management. + - Guards for permission management. + - Interceptors for request management. +- A shared module for services shared between modules. + - Utilities + - Shared business logic + +### Testing + +- Use the standard Jest framework for testing. +- Write tests for each controller and service. +- Write end to end tests for each api module. +- Add a admin/test method to each controller as a smoke test. + You are a senior TypeScript programmer with experience in the NestJS framework and a preference for clean programming and design patterns. Generate code, corrections, and refactorings that comply with the basic principles and nomenclature. + +## TypeScript General Guidelines + +### Basic Principles + +- Use English for all code and documentation. +- Always declare the type of each variable and function (parameters and return value). +- Avoid using any. +- Create necessary types. +- Use JSDoc to document public classes and methods. +- Don't leave blank lines within a function. +- One export per file. + +### Nomenclature + +- Use PascalCase for classes. +- Use camelCase for variables, functions, and methods. +- Use kebab-case for file and directory names. +- Use UPPERCASE for environment variables. +- Avoid magic numbers and define constants. +- Start each function with a verb. +- Use verbs for boolean variables. Example: isLoading, hasError, canDelete, etc. +- Use complete words instead of abbreviations and correct spelling. +- Except for standard abbreviations like API, URL, etc. +- Except for well-known abbreviations: + - i, j for loops + - err for errors + - ctx for contexts + - req, res, next for middleware function parameters + +### Functions + +- In this context, what is understood as a function will also apply to a method. +- Write short functions with a single purpose. Less than 20 instructions. +- Name functions with a verb and something else. +- If it returns a boolean, use isX or hasX, canX, etc. +- If it doesn't return anything, use executeX or saveX, etc. +- Avoid nesting blocks by: + - Early checks and returns. + - Extraction to utility functions. +- Use higher-order functions (map, filter, reduce, etc.) to avoid function nesting. +- Use arrow functions for simple functions (less than 3 instructions). +- Use named functions for non-simple functions. +- Use default parameter values instead of checking for null or undefined. +- Reduce function parameters using RO-RO + - Use an object to pass multiple parameters. + - Use an object to return results. + - Declare necessary types for input arguments and output. +- Use a single level of abstraction. + +### Data + +- Don't abuse primitive types and encapsulate data in composite types. +- Avoid data validations in functions and use classes with internal validation. +- Prefer immutability for data. +- Use readonly for data that doesn't change. +- Use as const for literals that don't change. + +### Classes + +- Follow SOLID principles. +- Prefer composition over inheritance. +- Declare interfaces to define contracts. +- Write small classes with a single purpose. + - Less than 200 instructions. + - Less than 10 public methods. + - Less than 10 properties. + +### Exceptions + +- Use exceptions to handle errors you don't expect. +- If you catch an exception, it should be to: + - Fix an expected problem. + - Add context. + - Otherwise, use a global handler. + +### Testing + +- Follow the Arrange-Act-Assert convention for tests. +- Name test variables clearly. +- Follow the convention: inputX, mockX, actualX, expectedX, etc. +- Write unit tests for each public function. +- Use test doubles to simulate dependencies. + - Except for third-party dependencies that are not expensive to execute. +- Write acceptance tests for each module. +- Follow the Given-When-Then convention. + +## Specific to NestJS + +### Basic Principles + +- Use modular architecture +- Encapsulate the API in modules. + - One module per main domain/route. + - One controller for its route. + - And other controllers for secondary routes. + - A models folder with data types. + - DTOs validated with class-validator for inputs. + - Declare simple types for outputs. + - A services module with business logic and persistence. + - Entities with MikroORM for data persistence. + - One service per entity. +- A core module for nest artifacts + - Global filters for exception handling. + - Global middlewares for request management. + - Guards for permission management. + - Interceptors for request management. +- A shared module for services shared between modules. + - Utilities + - Shared business logic + +### Testing + +- Use the standard Jest framework for testing. +- Write tests for each controller and service. +- Write end to end tests for each api module. +- Add a admin/test method to each controller as a smoke test. diff --git a/package-lock.json b/package-lock.json index 346afa7..f179081 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "0.0.1", "license": "UNLICENSED", "dependencies": { + "@mozilla/readability": "^0.6.0", + "@nestjs/axios": "^4.0.0", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.0.1", @@ -18,14 +20,23 @@ "@nestjs/swagger": "^11.2.0", "@nestjs/throttler": "^6.4.0", "@prisma/client": "^6.10.1", + "axios": "^1.10.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", "cookie-parser": "^1.4.7", + "dompurify": "^3.2.6", + "es-toolkit": "^1.39.6", "helmet": "^8.1.0", + "jsdom": "^26.1.0", "passport": "^0.7.0", "passport-google-oauth20": "^2.0.0", + "puppeteer-core": "^24.11.2", + "puppeteer-extra": "^3.3.6", + "puppeteer-extra-plugin-adblocker": "^2.13.6", + "puppeteer-extra-plugin-stealth": "^2.11.2", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "sanitize-html": "^2.17.0", "swagger-ui-express": "^5.0.1" }, "devDependencies": { @@ -40,6 +51,7 @@ "@types/cookie-parser": "^1.4.9", "@types/express": "^5.0.0", "@types/jest": "^29.5.14", + "@types/jsdom": "^21.1.7", "@types/node": "^22.10.7", "@types/passport-google-oauth20": "^2.0.16", "@types/supertest": "^6.0.2", @@ -219,11 +231,27 @@ "tslib": "^2.1.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", @@ -385,7 +413,6 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -735,6 +762,64 @@ "dev": true, "license": "MIT" }, + "node_modules/@cliqz/adblocker": { + "version": "1.34.0", + "resolved": "https://registry.npmjs.org/@cliqz/adblocker/-/adblocker-1.34.0.tgz", + "integrity": "sha512-d7TeUl5t+TOMJe7/CRYtf+x6hbd8N25DtH7guQTIjjr3AFVortxiAIgNejGvVqy0by4eNByw+oVil15oqxz2Eg==", + "deprecated": "This project has been renamed to @ghostery/adblocker. Install using @ghostery/adblocker instead", + "dependencies": { + "@cliqz/adblocker-content": "^1.34.0", + "@cliqz/adblocker-extended-selectors": "^1.34.0", + "@remusao/guess-url-type": "^1.3.0", + "@remusao/small": "^1.2.1", + "@remusao/smaz": "^1.9.1", + "@types/chrome": "^0.0.278", + "@types/firefox-webext-browser": "^120.0.0", + "tldts-experimental": "^6.0.14" + } + }, + "node_modules/@cliqz/adblocker-content": { + "version": "1.34.0", + "resolved": "https://registry.npmjs.org/@cliqz/adblocker-content/-/adblocker-content-1.34.0.tgz", + "integrity": "sha512-5LcV8UZv49RWwtpom9ve4TxJIFKd+bjT59tS/2Z2c22Qxx5CW1ncO/T+ybzk31z422XplQfd0ZE6gMGGKs3EMg==", + "deprecated": "This project has been renamed to @ghostery/adblocker-content. Install using @ghostery/adblocker-content instead", + "dependencies": { + "@cliqz/adblocker-extended-selectors": "^1.34.0" + } + }, + "node_modules/@cliqz/adblocker-extended-selectors": { + "version": "1.34.0", + "resolved": "https://registry.npmjs.org/@cliqz/adblocker-extended-selectors/-/adblocker-extended-selectors-1.34.0.tgz", + "integrity": "sha512-lNrgdUPpsBWHjrwXy2+Z5nX/Gy5YAvNwFMLqkeMdjzrybwPIalJJN2e+YtkS1I6mVmOMNppF5cv692OAVoI74g==", + "deprecated": "This project has been renamed to @ghostery/adblocker-extended-selectors. Install using @ghostery/adblocker-extended-selectors instead" + }, + "node_modules/@cliqz/adblocker-puppeteer": { + "version": "1.23.8", + "resolved": "https://registry.npmjs.org/@cliqz/adblocker-puppeteer/-/adblocker-puppeteer-1.23.8.tgz", + "integrity": "sha512-Ca1/DBqQXsOpKTFVAHX6OpLTSEupXmUkUWHj6iXhLLleC7RPISN5B0b801VDmaGRqoC5zKRxn0vYbIfpgCWVug==", + "deprecated": "This project has been renamed to @ghostery/adblocker-puppeteer. Install using @ghostery/adblocker-puppeteer instead", + "dependencies": { + "@cliqz/adblocker": "^1.23.8", + "@cliqz/adblocker-content": "^1.23.8", + "tldts-experimental": "^5.6.21" + }, + "peerDependencies": { + "puppeteer": ">5" + } + }, + "node_modules/@cliqz/adblocker/node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==" + }, + "node_modules/@cliqz/adblocker/node_modules/tldts-experimental": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-experimental/-/tldts-experimental-6.1.86.tgz", + "integrity": "sha512-X3N3+SrwSajvANDyIBFa6tf/nO0VoqaXvvINSnQkZMGbzNlD+9G7Xb24Mtk3ZBVZJRGY7UynAJJL8kRVt6Z46Q==", + "dependencies": { + "tldts-core": "^6.1.86" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -770,6 +855,111 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz", + "integrity": "sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.10.tgz", + "integrity": "sha512-TiJ5Ajr6WRd1r8HSiwJvZBiJOqtH86aHpUjq5aEKWHiII2Qfjqd/HCWKPOW8EP4vcspXbHnXrwIDlu5savQipg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/color-helpers": "^5.0.2", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", @@ -1989,6 +2179,14 @@ "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.15.1.tgz", "integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==" }, + "node_modules/@mozilla/readability": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@mozilla/readability/-/readability-0.6.0.tgz", + "integrity": "sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@napi-rs/nice": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.0.1.tgz", @@ -2294,6 +2492,16 @@ "node": ">= 10" } }, + "node_modules/@nestjs/axios": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-4.0.0.tgz", + "integrity": "sha512-1cB+Jyltu/uUPNQrpUimRHEQHrnQrpLzVj6dU3dgn6iDDDdahr10TgHFGTmw5VuJ9GzKZsCLDL78VSwJAs/9JQ==", + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "axios": "^1.3.1", + "rxjs": "^7.0.0" + } + }, "node_modules/@nestjs/cli": { "version": "11.0.7", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-11.0.7.tgz", @@ -2998,6 +3206,63 @@ "@prisma/debug": "6.10.1" } }, + "node_modules/@puppeteer/browsers": { + "version": "2.10.5", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.5.tgz", + "integrity": "sha512-eifa0o+i8dERnngJwKrfp3dEq7ia5XFyoqB17S4gK8GhsQE4/P8nxOfQSE0zQHxzzLo/cmF+7+ywEQ7wK7Fb+w==", + "dependencies": { + "debug": "^4.4.1", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "semver": "^7.7.2", + "tar-fs": "^3.0.8", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@remusao/guess-url-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@remusao/guess-url-type/-/guess-url-type-1.3.0.tgz", + "integrity": "sha512-SNSJGxH5ckvxb3EUHj4DqlAm/bxNxNv2kx/AESZva/9VfcBokwKNS+C4D1lQdWIDM1R3d3UG+xmVzlkNG8CPTQ==" + }, + "node_modules/@remusao/small": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@remusao/small/-/small-1.3.0.tgz", + "integrity": "sha512-bydAhJI+ywmg5xMUcbqoR8KahetcfkFywEZpsyFZ8EBofilvWxbXnMSe4vnjDI1Y+SWxnNhR4AL/2BAXkf4b8A==" + }, + "node_modules/@remusao/smaz": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@remusao/smaz/-/smaz-1.10.0.tgz", + "integrity": "sha512-GQzCxmmMpLkyZwcwNgz8TpuBEWl0RUQa8IcvKiYlPxuyYKqyqPkCr0hlHI15ckn3kDUPS68VmTVgyPnLNrdVmg==", + "dependencies": { + "@remusao/smaz-compress": "^1.10.0", + "@remusao/smaz-decompress": "^1.10.0" + } + }, + "node_modules/@remusao/smaz-compress": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@remusao/smaz-compress/-/smaz-compress-1.10.0.tgz", + "integrity": "sha512-E/lC8OSU+3bQrUl64vlLyPzIxo7dxF2RvNBe9KzcM4ax43J/d+YMinmMztHyCIHqRbz7rBCtkp3c0KfeIbHmEg==", + "dependencies": { + "@remusao/trie": "^1.5.0" + } + }, + "node_modules/@remusao/smaz-decompress": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@remusao/smaz-decompress/-/smaz-decompress-1.10.0.tgz", + "integrity": "sha512-aA5ImUH480Pcs5/cOgToKmFnzi7osSNG6ft+7DdmQTaQEEst3nLq3JLlBEk+gwidURymjbx6DYs60LHaZ415VQ==" + }, + "node_modules/@remusao/trie": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@remusao/trie/-/trie-1.5.0.tgz", + "integrity": "sha512-UX+3utJKgwCsg6sUozjxd38gNMVRXrY4TNX9VvCdSrlZBS1nZjRPi98ON3QjRAdf6KCguJFyQARRsulTeqQiPg==" + }, "node_modules/@scarf/scarf": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", @@ -3385,6 +3650,11 @@ "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", "license": "MIT" }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==" + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -3469,6 +3739,15 @@ "@types/node": "*" } }, + "node_modules/@types/chrome": { + "version": "0.0.278", + "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.278.tgz", + "integrity": "sha512-PDIJodOu7o54PpSOYLybPW/MDZBCjM1TKgf31I3Q/qaEbNpIH09rOM3tSEH3N7Q+FAqb1933LhF8ksUPYeQLNg==", + "dependencies": { + "@types/filesystem": "*", + "@types/har-format": "*" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -3495,6 +3774,14 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -3549,6 +3836,24 @@ "@types/send": "*" } }, + "node_modules/@types/filesystem": { + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz", + "integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==", + "dependencies": { + "@types/filewriter": "*" + } + }, + "node_modules/@types/filewriter": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz", + "integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==" + }, + "node_modules/@types/firefox-webext-browser": { + "version": "120.0.4", + "resolved": "https://registry.npmjs.org/@types/firefox-webext-browser/-/firefox-webext-browser-120.0.4.tgz", + "integrity": "sha512-lBrpf08xhiZBigrtdQfUaqX1UauwZ+skbFiL8u2Tdra/rklkKadYmIzTwkNZSWtuZ7OKpFqbE2HHfDoFqvZf6w==" + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -3559,6 +3864,11 @@ "@types/node": "*" } }, + "node_modules/@types/har-format": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz", + "integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==" + }, "node_modules/@types/http-cache-semantics": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", @@ -3611,6 +3921,17 @@ "pretty-format": "^29.0.0" } }, + "node_modules/@types/jsdom": { + "version": "21.1.7", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", + "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", + "dev": true, + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -3640,6 +3961,11 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==" + }, "node_modules/@types/node": { "version": "22.15.33", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.33.tgz", @@ -3761,6 +4087,18 @@ "@types/superagent": "^8.1.0" } }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true + }, + "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==", + "optional": true + }, "node_modules/@types/validator": { "version": "13.15.2", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.2.tgz", @@ -3783,6 +4121,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.35.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.0.tgz", @@ -4824,6 +5171,14 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -4936,7 +5291,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -5025,6 +5379,14 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/arr-union": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", + "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/array-timsort": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", @@ -5039,6 +5401,17 @@ "dev": true, "license": "MIT" }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -5050,14 +5423,22 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, "license": "MIT" }, + "node_modules/axios": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz", + "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/b4a": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", "integrity": "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==", - "dev": true, "license": "Apache-2.0" }, "node_modules/babel-jest": { @@ -5190,17 +5571,76 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, "license": "MIT" }, "node_modules/bare-events": { "version": "2.5.4", "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.4.tgz", "integrity": "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==", - "dev": true, "license": "Apache-2.0", "optional": true }, + "node_modules/bare-fs": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.1.5.tgz", + "integrity": "sha512-1zccWBMypln0jEE05LzZt+V/8y8AQsQQqxtklqaIyg5nu6OAYFhZxPXinJTSG+kU5qyNmeLgcn9AW7eHiCHVLA==", + "optional": true, + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.1.tgz", + "integrity": "sha512-uaIjxokhFidJP+bmmvKSgiMzj2sV5GPHaZVAIktcxcpCyBFFWO+YlikVAdhmUo2vYFvFhOXIAlldqV29L8126g==", + "optional": true, + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "optional": true, + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.6.5.tgz", + "integrity": "sha512-jSmxKJNJmHySi6hC42zlZnq00rga4jjxcgNZjY9N5WlOe/iOoGRtdwGsHzQv2RlH2KOYMwGUXhf2zXd32BA9RA==", + "optional": true, + "dependencies": { + "streamx": "^2.21.0" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -5231,6 +5671,14 @@ "node": ">=6.0.0" } }, + "node_modules/basic-ftp": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz", + "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/bin-version": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/bin-version/-/bin-version-6.0.0.tgz", @@ -5302,7 +5750,6 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -5407,7 +5854,6 @@ "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, "license": "MIT", "engines": { "node": "*" @@ -5506,7 +5952,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -5603,6 +6048,18 @@ "node": ">=6.0" } }, + "node_modules/chromium-bidi": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-5.1.0.tgz", + "integrity": "sha512-9MSRhWRVoRPDG0TgzkHrshFSJJNZzfY5UFqUMuksg7zL1yoZIZ3jLB0YAgHclbiAxPI86pBnwDX1tbzoiV8aFw==", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -5697,7 +6154,6 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -5712,7 +6168,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5722,7 +6177,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -5735,7 +6189,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -5759,6 +6212,32 @@ "node": ">=0.8" } }, + "node_modules/clone-deep": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-0.2.4.tgz", + "integrity": "sha512-we+NuQo2DHhSl+DP6jlUiAhyAjBQrYnpOk15rN6c6JSPScjiCLh8IbSU+VTcph6YS3o7mASE8a0+gbZ7ChLpgg==", + "dependencies": { + "for-own": "^0.1.3", + "is-plain-object": "^2.0.1", + "kind-of": "^3.0.2", + "lazy-cache": "^1.0.3", + "shallow-clone": "^0.1.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/clone-deep/node_modules/kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", + "dependencies": { + "is-buffer": "^1.1.5" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -5781,7 +6260,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -5794,14 +6272,12 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "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==", - "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -5851,7 +6327,6 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, "license": "MIT" }, "node_modules/concat-stream": { @@ -6040,16 +6515,79 @@ "node": ">= 8" } }, - "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "license": "MIT", + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", "dependencies": { - "ms": "^2.1.3" + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" }, "engines": { - "node": ">=6.0" + "node": ">=18" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "engines": { + "node": ">= 14" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" }, "peerDependenciesMeta": { "supports-color": { @@ -6057,6 +6595,11 @@ } } }, + "node_modules/decimal.js": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz", + "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==" + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -6112,7 +6655,6 @@ "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" @@ -6141,11 +6683,23 @@ "node": ">=10" } }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -6170,6 +6724,11 @@ "node": ">=8" } }, + "node_modules/devtools-protocol": { + "version": "0.0.1464554", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1464554.tgz", + "integrity": "sha512-CAoP3lYfwAGQTaAXYvA6JZR0fjGUb7qec1qf4mToyoH2TZgUFeIqYcjh6f9jNuhHfuZiEdH+PONHYrLhRQX6aw==" + }, "node_modules/dezalgo": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", @@ -6201,6 +6760,76 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "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==", + "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/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?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" + } + ] + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/dompurify": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz", + "integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "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==", + "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/dotenv": { "version": "16.4.7", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", @@ -6327,7 +6956,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/encodeurl": { @@ -6339,6 +6967,14 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.2", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz", @@ -6353,11 +6989,30 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dev": true, "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -6404,7 +7059,6 @@ "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==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -6416,11 +7070,19 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.39.6", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.6.tgz", + "integrity": "sha512-uiVjnLem6kkfXumlwUEWEKnwUN5QbSEB0DHy2rNJt0nkYcob5K0TXJ7oJRzhAcvx+SRmz4TahKyN5V9cly/IPA==", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -6436,7 +7098,6 @@ "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" @@ -6445,6 +7106,35 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/escodegen/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/eslint": { "version": "9.29.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.29.0.tgz", @@ -6605,7 +7295,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, "license": "BSD-2-Clause", "bin": { "esparse": "bin/esparse.js", @@ -6645,7 +7334,6 @@ "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" @@ -6655,7 +7343,6 @@ "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" @@ -6846,6 +7533,48 @@ "node": ">=0.10.0" } }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extract-zip/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/extract-zip/node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6864,7 +7593,6 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -6954,6 +7682,14 @@ "bser": "2.1.1" } }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/fflate": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", @@ -7137,6 +7873,44 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-in": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/for-own": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/for-own/-/for-own-0.1.5.tgz", + "integrity": "sha512-SKmowqGTJoPzLO1T0BBJpkfp3EMacCMOuH40hOUbrbzElVktk4DioXVM99QkLCyKoiuOmyjgcWMpVz2xjE7LZw==", + "dependencies": { + "for-in": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -7186,7 +7960,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", - "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -7213,7 +7986,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -7223,7 +7995,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -7272,7 +8043,6 @@ "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -7294,7 +8064,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, "license": "ISC" }, "node_modules/fsevents": { @@ -7335,7 +8104,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -7401,6 +8169,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-uri": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.4.tgz", + "integrity": "sha512-E1b1lFFLvLgak2whF2xDBcOy6NLVGZBqqjJjsIhvopKfWWEi64pLVTWWehV8KlLerZkfNTA95sTe2OdJKm1OzQ==", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/glob": { "version": "11.0.1", "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.1.tgz", @@ -7516,7 +8297,6 @@ "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/graphemer": { @@ -7562,7 +8342,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -7594,6 +8373,17 @@ "node": ">=18.0.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -7601,6 +8391,35 @@ "dev": true, "license": "MIT" }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -7633,6 +8452,18 @@ "node": ">= 0.8" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/http2-wrapper": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz", @@ -7647,6 +8478,18 @@ "node": ">=10.19.0" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -7703,7 +8546,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -7751,7 +8593,6 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -7774,6 +8615,23 @@ "kind-of": "^6.0.2" } }, + "node_modules/ip-address": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", + "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ip-address/node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==" + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -7787,9 +8645,13 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, "license": "MIT" }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -7806,6 +8668,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -7820,7 +8690,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7879,9 +8748,25 @@ "node": ">=0.10.0" } }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==" + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, @@ -7918,6 +8803,14 @@ "dev": true, "license": "ISC" }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -8718,7 +9611,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -8733,6 +9625,80 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==" + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "engines": { + "node": ">=12" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -8757,7 +9723,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, "license": "MIT" }, "node_modules/json-schema-traverse": { @@ -8798,7 +9763,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "dev": true, "license": "MIT", "dependencies": { "universalify": "^2.0.0" @@ -8877,6 +9841,14 @@ "node": ">=6" } }, + "node_modules/lazy-cache": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz", + "integrity": "sha512-RE2g0b5VGZsOCFOCgP7omTRYFqydmZkBwl5oNnQ1lDYC57uyO9KqNnNVxT7COSHTxrRCWVcAVOcbjk+tvh/rgQ==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -8910,7 +9882,6 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, "license": "MIT" }, "node_modules/load-esm": { @@ -9127,6 +10098,30 @@ "node": ">= 4.0.0" } }, + "node_modules/merge-deep": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/merge-deep/-/merge-deep-3.0.3.tgz", + "integrity": "sha512-qtmzAS6t6grwEkNrunqTBdn0qKwFgNWvlxUbAV8es9M7Ot1EbyApytCnvE0jALPa46ZpKDUo527kKiaWplmlFA==", + "dependencies": { + "arr-union": "^3.1.0", + "clone-deep": "^0.2.4", + "kind-of": "^3.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/merge-deep/node_modules/kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", + "dependencies": { + "is-buffer": "^1.1.5" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/merge-descriptors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", @@ -9254,7 +10249,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -9282,6 +10276,31 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==" + }, + "node_modules/mixin-object": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mixin-object/-/mixin-object-2.0.1.tgz", + "integrity": "sha512-ALGF1Jt9ouehcaXaHhn6t1yGWRqGaHkPFndtFVHfZXOvkIZ/yoGaSi0AHVTafb3ZBGg4dr/bDwnaEKqCXzchMA==", + "dependencies": { + "for-in": "^0.1.3", + "is-extendable": "^0.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mixin-object/node_modules/for-in": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-0.1.8.tgz", + "integrity": "sha512-F0to7vbBSHP8E3l6dCjxNOLuSFAACIxFy3UehTUlG7svlXi37HHsDkyVcHo0Pq8QwrE+pXvWSVX3ZT1T9wAZ9g==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/mkdirp": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", @@ -9371,6 +10390,23 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "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", @@ -9394,6 +10430,14 @@ "dev": true, "license": "MIT" }, + "node_modules/netmask": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", + "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/node-abort-controller": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", @@ -9411,6 +10455,25 @@ "lodash": "^4.17.21" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -9461,6 +10524,11 @@ "node": ">=8" } }, + "node_modules/nwsapi": { + "version": "2.2.20", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.20.tgz", + "integrity": "sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==" + }, "node_modules/oauth": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", @@ -9652,6 +10720,36 @@ "node": ">=6" } }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -9663,7 +10761,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, "license": "MIT", "dependencies": { "callsites": "^3.0.0" @@ -9676,7 +10773,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.0.0", @@ -9691,6 +10787,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse-srcset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", + "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -9772,7 +10884,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9864,14 +10975,12 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true, "license": "MIT" }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -9986,6 +11095,33 @@ "node": ">=4" } }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "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" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -10053,67 +11189,338 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/prisma": { - "version": "6.10.1", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.10.1.tgz", - "integrity": "sha512-khhlC/G49E4+uyA3T3H5PRBut486HD2bDqE2+rvkU0pwk9IAqGFacLFUyIx9Uw+W2eCtf6XGwsp+/strUwMNPw==", - "devOptional": true, - "hasInstallScript": true, - "license": "Apache-2.0", + "node_modules/prisma": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.10.1.tgz", + "integrity": "sha512-khhlC/G49E4+uyA3T3H5PRBut486HD2bDqE2+rvkU0pwk9IAqGFacLFUyIx9Uw+W2eCtf6XGwsp+/strUwMNPw==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "6.10.1", + "@prisma/engines": "6.10.1" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/puppeteer": { + "version": "24.11.2", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.11.2.tgz", + "integrity": "sha512-HopdRZWHa5zk0HSwd8hU+GlahQ3fmesTAqMIDHVY9HasCvppcYuHYXyjml0nlm+nbwVCqAQWV+dSmiNCrZGTGQ==", + "hasInstallScript": true, + "peer": true, + "dependencies": { + "@puppeteer/browsers": "2.10.5", + "chromium-bidi": "5.1.0", + "cosmiconfig": "^9.0.0", + "devtools-protocol": "0.0.1464554", + "puppeteer-core": "24.11.2", + "typed-query-selector": "^2.12.0" + }, + "bin": { + "puppeteer": "lib/cjs/puppeteer/node/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/puppeteer-core": { + "version": "24.11.2", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.11.2.tgz", + "integrity": "sha512-c49WifNb8hix+gQH17TldmD6TC/Md2HBaTJLHexIUq4sZvo2pyHY/Pp25qFQjibksBu/SJRYUY7JsoaepNbiRA==", + "dependencies": { + "@puppeteer/browsers": "2.10.5", + "chromium-bidi": "5.1.0", + "debug": "^4.4.1", + "devtools-protocol": "0.0.1464554", + "typed-query-selector": "^2.12.0", + "ws": "^8.18.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/puppeteer-extra": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/puppeteer-extra/-/puppeteer-extra-3.3.6.tgz", + "integrity": "sha512-rsLBE/6mMxAjlLd06LuGacrukP2bqbzKCLzV1vrhHFavqQE/taQ2UXv3H5P0Ls7nsrASa+6x3bDbXHpqMwq+7A==", + "dependencies": { + "@types/debug": "^4.1.0", + "debug": "^4.1.1", + "deepmerge": "^4.2.2" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "@types/puppeteer": "*", + "puppeteer": "*", + "puppeteer-core": "*" + }, + "peerDependenciesMeta": { + "@types/puppeteer": { + "optional": true + }, + "puppeteer": { + "optional": true + }, + "puppeteer-core": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin/-/puppeteer-extra-plugin-3.2.3.tgz", + "integrity": "sha512-6RNy0e6pH8vaS3akPIKGg28xcryKscczt4wIl0ePciZENGE2yoaQJNd17UiEbdmh5/6WW6dPcfRWT9lxBwCi2Q==", + "dependencies": { + "@types/debug": "^4.1.0", + "debug": "^4.1.1", + "merge-deep": "^3.0.1" + }, + "engines": { + "node": ">=9.11.2" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-adblocker": { + "version": "2.13.6", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-adblocker/-/puppeteer-extra-plugin-adblocker-2.13.6.tgz", + "integrity": "sha512-AftgnUZ1rg2RPe9RpX6rkYAxEohwp3iFeGIyjsAuTaIiw4VLZqOb1LSY8/S60vAxpeat60fbCajxoUetmLy4Dw==", + "dependencies": { + "@cliqz/adblocker-puppeteer": "1.23.8", + "debug": "^4.1.1", + "node-fetch": "^2.6.0", + "puppeteer-extra-plugin": "^3.2.3" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "puppeteer": "*", + "puppeteer-core": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "puppeteer": { + "optional": true + }, + "puppeteer-core": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-stealth": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-stealth/-/puppeteer-extra-plugin-stealth-2.11.2.tgz", + "integrity": "sha512-bUemM5XmTj9i2ZerBzsk2AN5is0wHMNE6K0hXBzBXOzP5m5G3Wl0RHhiqKeHToe/uIH8AoZiGhc1tCkLZQPKTQ==", "dependencies": { - "@prisma/config": "6.10.1", - "@prisma/engines": "6.10.1" - }, - "bin": { - "prisma": "build/index.js" + "debug": "^4.1.1", + "puppeteer-extra-plugin": "^3.2.3", + "puppeteer-extra-plugin-user-preferences": "^2.4.1" }, "engines": { - "node": ">=18.18" + "node": ">=8" }, "peerDependencies": { - "typescript": ">=5.1.0" + "playwright-extra": "*", + "puppeteer-extra": "*" }, "peerDependenciesMeta": { - "typescript": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { "optional": true } } }, - "node_modules/prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", - "dev": true, - "license": "MIT", + "node_modules/puppeteer-extra-plugin-user-data-dir": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-user-data-dir/-/puppeteer-extra-plugin-user-data-dir-2.4.1.tgz", + "integrity": "sha512-kH1GnCcqEDoBXO7epAse4TBPJh9tEpVEK/vkedKfjOVOhZAvLkHGc9swMs5ChrJbRnf8Hdpug6TJlEuimXNQ+g==", "dependencies": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" + "debug": "^4.1.1", + "fs-extra": "^10.0.0", + "puppeteer-extra-plugin": "^3.2.3", + "rimraf": "^3.0.2" }, "engines": { - "node": ">= 6" + "node": ">=8" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } } }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", + "node_modules/puppeteer-extra-plugin-user-preferences": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-user-preferences/-/puppeteer-extra-plugin-user-preferences-2.4.1.tgz", + "integrity": "sha512-i1oAZxRbc1bk8MZufKCruCEC3CCafO9RKMkkodZltI4OqibLFXF3tj6HZ4LZ9C5vCXZjYcDWazgtY69mnmrQ9A==", "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" + "debug": "^4.1.1", + "deepmerge": "^4.2.2", + "puppeteer-extra-plugin": "^3.2.3", + "puppeteer-extra-plugin-user-data-dir": "^2.4.1" }, "engines": { - "node": ">= 0.10" + "node": ">=8" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } } }, - "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", + "node_modules/puppeteer/node_modules/cosmiconfig": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", + "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "peer": true, + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, "engines": { - "node": ">=6" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/pure-rand": { @@ -10271,7 +11678,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -10342,7 +11748,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -10406,6 +11811,41 @@ "node": ">=0.10.0" } }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -10422,6 +11862,11 @@ "node": ">= 18" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -10481,6 +11926,38 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sanitize-html": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.0.tgz", + "integrity": "sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==", + "dependencies": { + "deepmerge": "^4.2.2", + "escape-string-regexp": "^4.0.0", + "htmlparser2": "^8.0.0", + "is-plain-object": "^5.0.0", + "parse-srcset": "^1.0.2", + "postcss": "^8.3.11" + } + }, + "node_modules/sanitize-html/node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/schema-utils": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", @@ -10618,6 +12095,39 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/shallow-clone": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-0.1.2.tgz", + "integrity": "sha512-J1zdXCky5GmNnuauESROVu31MQSnLoYvlyEn6j2Ztk6Q5EHFIhxkMhYcv6vuDzl2XEzoRr856QwzMgWM/TmZgw==", + "dependencies": { + "is-extendable": "^0.1.1", + "kind-of": "^2.0.1", + "lazy-cache": "^0.2.3", + "mixin-object": "^2.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shallow-clone/node_modules/kind-of": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-2.0.1.tgz", + "integrity": "sha512-0u8i1NZ/mg0b+W3MGGw5I7+6Eib2nx72S/QvXa0hYjEkjTknYmEYQJwGu3mLC0BrhtJjtQafTkyRUQ75Kx0LVg==", + "dependencies": { + "is-buffer": "^1.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shallow-clone/node_modules/lazy-cache": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-0.2.7.tgz", + "integrity": "sha512-gkX52wvU/R8DVMMt78ATVPFMJqfW8FPz1GZ1sVHBVQHmu/WvhIWE4cE1GBzhJNFicDeYhnwp6Rl35BcAIM3YOQ==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -10743,6 +12253,41 @@ "node": ">=8" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.5.tgz", + "integrity": "sha512-iF+tNDQla22geJdTyJB1wM/qrX9DMRwWrciEPwWLPRWAUEM8sQiyxgckLxWT1f7+9VabJS0jTGGr4QgBuvi6Ww==", + "dependencies": { + "ip-address": "^9.0.5", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/sort-keys": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-1.1.2.tgz", @@ -10779,6 +12324,14 @@ "node": ">= 8" } }, + "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==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-support": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", @@ -10851,7 +12404,6 @@ "version": "2.22.1", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.1.tgz", "integrity": "sha512-znKXEBxfatz2GBNK02kRnCXjV+AA4kjZIUxeWSr3UGirZMJfTE9uiwKHobnbgxWyL/JWro8tTq+vOqAK1/qbSA==", - "dev": true, "license": "MIT", "dependencies": { "fast-fifo": "^1.3.2", @@ -10911,7 +12463,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -10965,7 +12516,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -10975,7 +12525,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -11177,6 +12726,11 @@ "node": ">=0.10" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==" + }, "node_modules/synckit": { "version": "0.11.8", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.8.tgz", @@ -11203,11 +12757,23 @@ "node": ">=6" } }, + "node_modules/tar-fs": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.0.tgz", + "integrity": "sha512-5Mty5y/sOF1YWj1J6GiBodjlDc05CUR8PKXrsnFAiSG0xA+GHeWLovaZPYUDXkH/1iKRf2+M5+OrRgzC7O9b7w==", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, "node_modules/tar-stream": { "version": "3.1.7", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", - "dev": true, "license": "MIT", "dependencies": { "b4a": "^1.6.4", @@ -11423,7 +12989,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "b4a": "^1.6.4" @@ -11436,6 +13001,35 @@ "dev": true, "license": "MIT" }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "5.7.112", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-5.7.112.tgz", + "integrity": "sha512-mutrEUgG2sp0e/MIAnv9TbSLR0IPbvmAImpzqul5O/HJ2XM1/I1sajchQ/fbj0fPdA31IiuWde8EUhfwyldY1Q==" + }, + "node_modules/tldts-experimental": { + "version": "5.7.112", + "resolved": "https://registry.npmjs.org/tldts-experimental/-/tldts-experimental-5.7.112.tgz", + "integrity": "sha512-Nq5qWN4OiLziAOOOEoSME7cZI4Hz8Srt+9q6cl8mZ5EAhCfmeE6l7K5XjuIKN+pySuGUvthE5aPiD185YU1/lg==", + "dependencies": { + "tldts-core": "^5.7.112" + } + }, + "node_modules/tldts/node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==" + }, "node_modules/tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -11495,6 +13089,22 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -11746,6 +13356,11 @@ "node": ">= 0.6" } }, + "node_modules/typed-query-selector": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", + "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==" + }, "node_modules/typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", @@ -11840,7 +13455,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 10.0.0" @@ -11950,6 +13564,17 @@ "node": ">= 0.8" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -11997,6 +13622,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, "node_modules/webpack": { "version": "5.99.9", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.9.tgz", @@ -12197,6 +13827,34 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -12330,6 +13988,39 @@ "dev": true, "license": "ISC" }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -12343,7 +14034,6 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -12360,7 +14050,6 @@ "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -12379,7 +14068,6 @@ "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -12434,6 +14122,14 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.25.72", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.72.tgz", + "integrity": "sha512-Cl+fe4dNL4XumOBNBsr0lHfA80PQiZXHI4xEMTEr8gt6aGz92t3lBA32e71j9+JeF/VAYvdfBnuwJs+BMx/BrA==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 22492b8..691c0d8 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,8 @@ "prisma:studio": "prisma studio" }, "dependencies": { + "@mozilla/readability": "^0.6.0", + "@nestjs/axios": "^4.0.0", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.0.1", @@ -36,14 +38,23 @@ "@nestjs/swagger": "^11.2.0", "@nestjs/throttler": "^6.4.0", "@prisma/client": "^6.10.1", + "axios": "^1.10.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", "cookie-parser": "^1.4.7", + "dompurify": "^3.2.6", + "es-toolkit": "^1.39.6", "helmet": "^8.1.0", + "jsdom": "^26.1.0", "passport": "^0.7.0", "passport-google-oauth20": "^2.0.0", + "puppeteer-core": "^24.11.2", + "puppeteer-extra": "^3.3.6", + "puppeteer-extra-plugin-adblocker": "^2.13.6", + "puppeteer-extra-plugin-stealth": "^2.11.2", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "sanitize-html": "^2.17.0", "swagger-ui-express": "^5.0.1" }, "devDependencies": { @@ -58,6 +69,7 @@ "@types/cookie-parser": "^1.4.9", "@types/express": "^5.0.0", "@types/jest": "^29.5.14", + "@types/jsdom": "^21.1.7", "@types/node": "^22.10.7", "@types/passport-google-oauth20": "^2.0.16", "@types/supertest": "^6.0.2", diff --git a/prisma/migrations/20250704113432_add_article_model/migration.sql b/prisma/migrations/20250704113432_add_article_model/migration.sql new file mode 100644 index 0000000..ffc719b --- /dev/null +++ b/prisma/migrations/20250704113432_add_article_model/migration.sql @@ -0,0 +1,40 @@ +-- CreateTable +CREATE TABLE "Article" ( + "id" TEXT NOT NULL, + "url" TEXT NOT NULL, + "finalUrl" TEXT NOT NULL, + "title" TEXT, + "content" TEXT, + "contentType" TEXT, + "summary" TEXT, + "author" TEXT, + "publishedAt" TIMESTAMP(3), + "wordCount" INTEGER, + "readingTime" INTEGER, + "tags" TEXT[], + "isBookmarked" BOOLEAN NOT NULL DEFAULT false, + "isArchived" BOOLEAN NOT NULL DEFAULT false, + "userId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Article_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Article_userId_idx" ON "Article"("userId"); + +-- CreateIndex +CREATE INDEX "Article_userId_isBookmarked_idx" ON "Article"("userId", "isBookmarked"); + +-- CreateIndex +CREATE INDEX "Article_userId_isArchived_idx" ON "Article"("userId", "isArchived"); + +-- CreateIndex +CREATE INDEX "Article_createdAt_idx" ON "Article"("createdAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "Article_url_userId_key" ON "Article"("url", "userId"); + +-- AddForeignKey +ALTER TABLE "Article" ADD CONSTRAINT "Article_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20250704142951_add_id_user_id_unique_constraint/migration.sql b/prisma/migrations/20250704142951_add_id_user_id_unique_constraint/migration.sql new file mode 100644 index 0000000..fd685c1 --- /dev/null +++ b/prisma/migrations/20250704142951_add_id_user_id_unique_constraint/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - A unique constraint covering the columns `[id,userId]` on the table `Article` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateIndex +CREATE UNIQUE INDEX "Article_id_userId_key" ON "Article"("id", "userId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d73c8b2..6f4f5d4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -24,6 +24,7 @@ model User { updatedAt DateTime @updatedAt refreshTokens RefreshToken[] + articles Article[] @@unique([provider, providerId]) } @@ -42,3 +43,32 @@ model RefreshToken { @@index([userId]) @@index([token]) } + +model Article { + id String @id @default(uuid()) + url String // ์›๋ณธ URL + finalUrl String // ์ตœ์ข… URL (๋ฆฌ๋””๋ ‰์…˜ ํ›„) + title String? // ์ถ”์ถœ๋œ ์ œ๋ชฉ + content String? // ์Šคํฌ๋ž˜ํ•‘๋œ ์ฝ˜ํ…์ธ  (HTML) + contentType String? // MIME ํƒ€์ž… + summary String? // ์š”์•ฝ (์„ ํƒ์ ) + author String? // ์ €์ž (์„ ํƒ์ ) + publishedAt DateTime? // ๋ฐœํ–‰์ผ (์„ ํƒ์ ) + wordCount Int? // ๋‹จ์–ด ์ˆ˜ + readingTime Int? // ์˜ˆ์ƒ ์ฝ๊ธฐ ์‹œ๊ฐ„ (๋ถ„) + tags String[] // ํƒœ๊ทธ ๋ฐฐ์—ด + isBookmarked Boolean @default(false) // ๋ถ๋งˆํฌ ์—ฌ๋ถ€ + isArchived Boolean @default(false) // ์•„์นด์ด๋ธŒ ์—ฌ๋ถ€ + userId String // ์‚ฌ์šฉ์ž ID + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([url, userId]) // ์‚ฌ์šฉ์ž๋ณ„ URL ์ค‘๋ณต ๋ฐฉ์ง€ + @@unique([id, userId]) // id์™€ userId ๋ณตํ•ฉ ๊ณ ์œ  ์ œ์•ฝ์กฐ๊ฑด ์ถ”๊ฐ€ + @@index([userId]) + @@index([userId, isBookmarked]) + @@index([userId, isArchived]) + @@index([createdAt]) +} diff --git a/src/app.module.ts b/src/app.module.ts index 2bc3ee9..25af7ee 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -12,7 +12,12 @@ import throttlerConfig from 'src/config/throttler.config'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { AuthModule } from 'src/modules/auth/auth.module'; import { DatabaseModule } from 'src/database/database.module'; +import { ScraperModule } from 'src/modules/scraper/scraper.module'; +import { ArticleModule } from 'src/modules/article/article.module'; import { HealthController } from 'src/health.controller'; +import { PreHandlerModule } from './modules/pre-handler/pre-handler.module'; +import { SecurityModule } from './modules/security/security.module'; +import { UserModule } from './modules/user/user.module'; @Module({ imports: [ @@ -34,6 +39,11 @@ import { HealthController } from 'src/health.controller'; DatabaseModule, // Modules AuthModule, + ScraperModule, + PreHandlerModule, + ArticleModule, + SecurityModule, + UserModule, ], controllers: [HealthController], providers: [ diff --git a/src/common/guards/__tests__/jwt.guard.spec.ts b/src/common/guards/__tests__/jwt.guard.spec.ts index 47726c9..0eec995 100644 --- a/src/common/guards/__tests__/jwt.guard.spec.ts +++ b/src/common/guards/__tests__/jwt.guard.spec.ts @@ -1,65 +1,138 @@ -import { JwtAuthGuard } from '../jwt.guard'; +import { Test, TestingModule } from '@nestjs/testing'; import { ExecutionContext, UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { JwtAuthGuard } from '../jwt.guard'; +import { AuthService } from 'src/modules/auth/services/auth.service'; import { TokenType } from 'src/types'; describe('JwtAuthGuard', () => { let guard: JwtAuthGuard; - let jwtService: any; - let authService: any; - let context: Partial; + let jwtService: jest.Mocked; + let authService: jest.Mocked; + let context: ExecutionContext; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + JwtAuthGuard, + { + provide: JwtService, + useValue: { + verify: jest.fn(), + }, + }, + { + provide: AuthService, + useValue: { + validateUser: jest.fn(), + }, + }, + ], + }).compile(); + + guard = module.get(JwtAuthGuard); + jwtService = module.get(JwtService); + authService = module.get(AuthService); - beforeEach(() => { - jwtService = { verify: jest.fn() }; - authService = { validateUser: jest.fn() }; - guard = new JwtAuthGuard(jwtService, authService); context = { switchToHttp: jest.fn().mockReturnValue({ - getRequest: jest.fn().mockReturnValue({ headers: { authorization: 'Bearer token' } }), + getRequest: jest.fn().mockReturnValue({ + headers: { authorization: 'Bearer valid-token' }, + }), }), - } as any; + } as unknown as ExecutionContext; + }); + + afterEach(() => { + jest.clearAllMocks(); }); it('JwtAuthGuard ์ธ์Šคํ„ด์Šค๊ฐ€ ์ •์˜๋˜์–ด์•ผ ํ•œ๋‹ค', () => { expect(guard).toBeDefined(); }); - it('Authorization ํ—ค๋”๊ฐ€ ์—†์œผ๋ฉด UnauthorizedException์„ ๋˜์ง„๋‹ค', async () => { - (context.switchToHttp as jest.Mock).mockReturnValueOnce({ getRequest: () => ({ headers: {} }) }); - await expect(guard.canActivate(context as ExecutionContext)).rejects.toThrow(UnauthorizedException); - }); + describe('canActivate', () => { + it('Authorization ํ—ค๋”๊ฐ€ ์—†์œผ๋ฉด UnauthorizedException์„ ๋˜์ง„๋‹ค', async () => { + const contextWithoutAuth = { + switchToHttp: jest.fn().mockReturnValue({ + getRequest: jest.fn().mockReturnValue({ headers: {} }), + }), + } as unknown as ExecutionContext; + await expect(guard.canActivate(contextWithoutAuth)).rejects.toThrow(UnauthorizedException); + }); - it('Bearer ํƒ€์ž…์ด ์•„๋‹Œ Authorization์ด๋ฉด UnauthorizedException์„ ๋˜์ง„๋‹ค', async () => { - (context.switchToHttp as jest.Mock).mockReturnValueOnce({ - getRequest: () => ({ headers: { authorization: 'Basic token' } }), + it('Bearer ํƒ€์ž…์ด ์•„๋‹Œ Authorization์ด๋ฉด UnauthorizedException์„ ๋˜์ง„๋‹ค', async () => { + const contextWithBasicAuth = { + switchToHttp: jest.fn().mockReturnValue({ + getRequest: jest.fn().mockReturnValue({ + headers: { authorization: 'Basic token' }, + }), + }), + } as unknown as ExecutionContext; + await expect(guard.canActivate(contextWithBasicAuth)).rejects.toThrow(UnauthorizedException); }); - await expect(guard.canActivate(context as ExecutionContext)).rejects.toThrow(UnauthorizedException); - }); - it('jwtService.verify๊ฐ€ ์˜ˆ์™ธ๋ฅผ ๋˜์ง€๋ฉด UnauthorizedException์„ ๋˜์ง„๋‹ค', async () => { - jwtService.verify = jest.fn(() => { - throw new Error('fail'); + it('jwtService.verify๊ฐ€ ์˜ˆ์™ธ๋ฅผ ๋˜์ง€๋ฉด UnauthorizedException์„ ๋˜์ง„๋‹ค', async () => { + jwtService.verify.mockImplementation(() => { + throw new Error('JWT verification failed'); + }); + await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); + await expect(guard.canActivate(context)).rejects.toThrow('Invalid access token'); }); - await expect(guard.canActivate(context as ExecutionContext)).rejects.toThrow('Invalid access token'); - }); - it('ํ† ํฐ ํƒ€์ž…์ด ACCESS๊ฐ€ ์•„๋‹ˆ๋ฉด UnauthorizedException์„ ๋˜์ง„๋‹ค', async () => { - jwtService.verify = jest.fn(() => ({ type: TokenType.REFRESH, email: 'a' })); - await expect(guard.canActivate(context as ExecutionContext)).rejects.toThrow('Invalid token type'); - }); + it('ํ† ํฐ ํƒ€์ž…์ด ACCESS๊ฐ€ ์•„๋‹ˆ๋ฉด UnauthorizedException์„ ๋˜์ง„๋‹ค', async () => { + jwtService.verify.mockReturnValue({ + type: TokenType.REFRESH, + email: 'test@example.com', + sub: '1', + }); + await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); + await expect(guard.canActivate(context)).rejects.toThrow('Invalid token type'); + }); - it('validateUser๊ฐ€ null์„ ๋ฐ˜ํ™˜ํ•˜๋ฉด UnauthorizedException์„ ๋˜์ง„๋‹ค', async () => { - jwtService.verify = jest.fn(() => ({ type: TokenType.ACCESS, email: 'a' })); - authService.validateUser = jest.fn().mockResolvedValue(null); - await expect(guard.canActivate(context as ExecutionContext)).rejects.toThrow('Invalid access token'); - }); + it('validateUser๊ฐ€ null์„ ๋ฐ˜ํ™˜ํ•˜๋ฉด UnauthorizedException์„ ๋˜์ง„๋‹ค', async () => { + jwtService.verify.mockReturnValue({ + type: TokenType.ACCESS, + email: 'test@example.com', + sub: '1', + }); + authService.validateUser.mockRejectedValue(new UnauthorizedException('Invalid access token')); + await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); + await expect(guard.canActivate(context)).rejects.toThrow('Invalid access token'); + }); - it('์ •์ƒ์ ์ธ ํ† ํฐ์ด๋ฉด true๋ฅผ ๋ฐ˜ํ™˜ํ•˜๊ณ  request.user์— ์œ ์ €๋ฅผ ํ• ๋‹นํ•œ๋‹ค', async () => { - jwtService.verify = jest.fn(() => ({ type: TokenType.ACCESS, email: 'a' })); - authService.validateUser = jest.fn().mockResolvedValue({ id: 1 }); - const req = { headers: { authorization: 'Bearer token' }, user: undefined }; - (context.switchToHttp as jest.Mock).mockReturnValueOnce({ getRequest: () => req }); - const result = await guard.canActivate(context as ExecutionContext); - expect(result).toBe(true); - expect(req.user).toEqual({ id: 1 }); + it('์ •์ƒ์ ์ธ ํ† ํฐ์ด๋ฉด true๋ฅผ ๋ฐ˜ํ™˜ํ•˜๊ณ  request.user์— ์œ ์ €๋ฅผ ํ• ๋‹นํ•œ๋‹ค', async () => { + const mockUser = { + id: '1', + email: 'test@example.com', + name: null, + provider: 'google', + providerId: 'gid', + createdAt: new Date(), + updatedAt: new Date(), + }; + const mockRequest = { + headers: { authorization: 'Bearer valid-token' }, + user: undefined, + }; + jwtService.verify.mockReturnValue({ + type: TokenType.ACCESS, + email: 'test@example.com', + sub: '1', + }); + authService.validateUser.mockResolvedValue(mockUser); + const contextWithRequest = { + switchToHttp: jest.fn().mockReturnValue({ + getRequest: jest.fn().mockReturnValue(mockRequest), + }), + } as unknown as ExecutionContext; + const result = await guard.canActivate(contextWithRequest); + expect(result).toBe(true); + expect(mockRequest.user).toEqual(mockUser); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(jwtService.verify).toHaveBeenCalledWith('valid-token'); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(authService.validateUser).toHaveBeenCalledWith('test@example.com'); + }); }); }); diff --git a/src/common/guards/jwt.guard.ts b/src/common/guards/jwt.guard.ts index ed759c9..1a2209d 100644 --- a/src/common/guards/jwt.guard.ts +++ b/src/common/guards/jwt.guard.ts @@ -1,50 +1,51 @@ -import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; +import { CanActivate, ExecutionContext, Injectable, UnauthorizedException, Logger } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; -import { Request } from 'express'; import { AuthService } from 'src/modules/auth/services/auth.service'; -import { JwtPayload, TokenType } from 'src/types'; - -interface AuthenticatedRequest extends Request { - user?: any; -} +import { AuthRequest, JwtPayload, TokenType } from 'src/types'; @Injectable() export class JwtAuthGuard implements CanActivate { + private readonly logger = new Logger(JwtAuthGuard.name); + constructor( private readonly jwtService: JwtService, private readonly authService: AuthService, ) {} async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); + const request = context.switchToHttp().getRequest(); - // Authorization ํ—ค๋”์—์„œ Bearer ํ† ํฐ ์ถ”์ถœ const authHeader = request.headers['authorization']; + if (!authHeader || !authHeader.startsWith('Bearer ')) { - throw new UnauthorizedException('No access token provided'); + throw new UnauthorizedException('Bearer token required'); } + const accessToken = authHeader.split(' ')[1]; + if (!accessToken) { + throw new UnauthorizedException('No access token provided'); + } + try { - // Access Token ๊ฒ€์ฆ const payload = this.jwtService.verify(accessToken); if (payload.type !== TokenType.ACCESS) { throw new UnauthorizedException('Invalid token type'); } - // ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ request์— ์ถ”๊ฐ€ const user = await this.authService.validateUser(payload.email); if (!user) { throw new UnauthorizedException('Invalid access token'); } - request.user = user; + request.user = user; return true; } catch (err) { if (err instanceof UnauthorizedException) { throw err; } + this.logger.error(`JWT verification failed: ${(err as Error).message}`); throw new UnauthorizedException('Invalid access token'); } } diff --git a/src/common/interceptors/__tests__/logging.interceptor.spec.ts b/src/common/interceptors/__tests__/logging.interceptor.spec.ts index a37fa3b..33669f8 100644 --- a/src/common/interceptors/__tests__/logging.interceptor.spec.ts +++ b/src/common/interceptors/__tests__/logging.interceptor.spec.ts @@ -1,50 +1,45 @@ -import { LoggingInterceptor } from '../logging.interceptor'; +import { Test, TestingModule } from '@nestjs/testing'; import { ExecutionContext, CallHandler } from '@nestjs/common'; import { of, throwError } from 'rxjs'; +import { LoggingInterceptor } from '../logging.interceptor'; +import { Request } from 'express'; describe('LoggingInterceptor', () => { let interceptor: LoggingInterceptor; - let mockContext: Partial; - let mockCallHandler: Partial; - let loggerSpy: any; - - beforeAll(() => { - // process.hrtime.bigint mock (jest ํ™˜๊ฒฝ์—์„œ ์—†์„ ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ) - if (!process.hrtime || typeof process.hrtime.bigint !== 'function') { - (process as any).hrtime = { bigint: jest.fn(() => BigInt(Date.now() * 1_000_000)) }; - } - }); + let context: ExecutionContext; + let callHandler: CallHandler; + let mockRequest: Partial; + let mockResponse: any; - afterAll(() => { - // ํ…Œ์ŠคํŠธ ์ข…๋ฃŒ ํ›„ mock ์ œ๊ฑฐ - if ((process as any).hrtime && (process as any).hrtime.bigint) { - delete (process as any).hrtime; - } - }); + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [LoggingInterceptor], + }).compile(); + + interceptor = module.get(LoggingInterceptor); + + mockRequest = { + method: 'GET', + url: '/test', + ip: '127.0.0.1', + connection: { remoteAddress: '127.0.0.2', destroySoon: () => {} } as unknown as import('net').Socket, + socket: { remoteAddress: '127.0.0.3', destroySoon: () => {} } as unknown as import('net').Socket, + }; - beforeEach(() => { - interceptor = new LoggingInterceptor(); - mockContext = { + mockResponse = { + statusCode: 200, + }; + + context = { switchToHttp: jest.fn().mockReturnValue({ - getRequest: jest.fn().mockReturnValue({ - method: 'GET', - url: '/test', - ip: '127.0.0.1', - connection: { remoteAddress: '127.0.0.2' }, - socket: { remoteAddress: '127.0.0.3' }, - }), - getResponse: jest.fn().mockReturnValue({ statusCode: 200 }), + getRequest: jest.fn().mockReturnValue(mockRequest), + getResponse: jest.fn().mockReturnValue(mockResponse), }), - } as any; - mockCallHandler = { + } as unknown as ExecutionContext; + + callHandler = { handle: jest.fn().mockReturnValue(of('response')), }; - // logger ๋ฉ”์„œ๋“œ spy - loggerSpy = { - log: jest.spyOn(interceptor['logger'], 'log').mockImplementation(() => {}), - warn: jest.spyOn(interceptor['logger'], 'warn').mockImplementation(() => {}), - error: jest.spyOn(interceptor['logger'], 'error').mockImplementation(() => {}), - }; }); afterEach(() => { @@ -55,68 +50,81 @@ describe('LoggingInterceptor', () => { expect(interceptor).toBeDefined(); }); - it('์ •์ƒ ์‘๋‹ต ์‹œ log๊ฐ€ ํ˜ธ์ถœ๋œ๋‹ค', (done) => { - interceptor.intercept(mockContext as ExecutionContext, mockCallHandler as CallHandler).subscribe(() => { - setImmediate(() => { - expect(loggerSpy.log).toHaveBeenCalledWith('GET /test - 127.0.0.1'); - expect(loggerSpy.log).toHaveBeenCalledWith('GET /test - 200'); - done(); + describe('intercept', () => { + it('์ •์ƒ ์‘๋‹ต ์‹œ ๋กœ๊ทธ๊ฐ€ ๊ธฐ๋ก๋œ๋‹ค', (done) => { + const logSpy = jest.spyOn(interceptor['logger'], 'log').mockImplementation(() => {}); + interceptor.intercept(context, callHandler).subscribe({ + next: () => { + expect(logSpy).toHaveBeenCalledWith('GET /test - 127.0.0.1'); + expect(logSpy).toHaveBeenCalledWith(expect.stringMatching(/^GET \/test - 200 - [\d.]+ms$/)); + logSpy.mockRestore(); + done(); + }, }); }); - }); - it('statusCode๊ฐ€ 400 ์ด์ƒ์ด๋ฉด warn์ด ํ˜ธ์ถœ๋œ๋‹ค', (done) => { - mockContext.switchToHttp = jest.fn().mockReturnValue({ - getRequest: jest.fn().mockReturnValue({ method: 'POST', url: '/fail', ip: '1.1.1.1' }), - getResponse: jest.fn().mockReturnValue({ statusCode: 404 }), - }); - interceptor.intercept(mockContext as ExecutionContext, mockCallHandler as CallHandler).subscribe(() => { - setImmediate(() => { - expect(loggerSpy.warn).toHaveBeenCalled(); - done(); + it('statusCode๊ฐ€ 400 ์ด์ƒ์ด๋ฉด warn์ด ํ˜ธ์ถœ๋œ๋‹ค', (done) => { + mockResponse.statusCode = 404; + const warnSpy = jest.spyOn(interceptor['logger'], 'warn').mockImplementation(() => {}); + interceptor.intercept(context, callHandler).subscribe({ + next: () => { + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + done(); + }, }); }); - }); - it('responseTime์ด 1000ms ์ดˆ๊ณผ๋ฉด warn์ด ํ˜ธ์ถœ๋œ๋‹ค', (done) => { - // getNow๋ฅผ spyOnํ•˜์—ฌ 1001ms ์ฐจ์ด๊ฐ€ ๋‚˜๋„๋ก mock - const getNowSpy = jest.spyOn(interceptor as any, 'getNow'); - getNowSpy.mockReturnValueOnce(0).mockReturnValueOnce(1001); - mockContext.switchToHttp = jest.fn().mockReturnValue({ - getRequest: jest.fn().mockReturnValue({ - method: 'GET', - url: '/slow', - ip: '1.1.1.1', - connection: { remoteAddress: '1.1.1.2' }, - socket: { remoteAddress: '1.1.1.3' }, - }), - getResponse: jest.fn().mockReturnValue({ statusCode: 200 }), - }); - interceptor.intercept(mockContext as ExecutionContext, mockCallHandler as CallHandler).subscribe(() => { - setImmediate(() => { - expect(loggerSpy.warn).toHaveBeenCalled(); - getNowSpy.mockRestore(); - done(); + it('responseTime์ด 1000ms ์ดˆ๊ณผ๋ฉด warn์ด ํ˜ธ์ถœ๋œ๋‹ค', (done) => { + const warnSpy = jest.spyOn(interceptor['logger'], 'warn').mockImplementation(() => {}); + const getNowSpy = jest.spyOn(interceptor as any, 'getNow'); + getNowSpy.mockReturnValueOnce(0).mockReturnValueOnce(1001); + interceptor.intercept(context, callHandler).subscribe({ + next: () => { + expect(warnSpy).toHaveBeenCalled(); + getNowSpy.mockRestore(); + warnSpy.mockRestore(); + done(); + }, }); }); - }); - it('์—๋Ÿฌ ๋ฐœ์ƒ ์‹œ error๊ฐ€ ํ˜ธ์ถœ๋œ๋‹ค', (done) => { - mockCallHandler.handle = jest.fn().mockReturnValue(throwError(() => new Error('fail'))); - interceptor.intercept(mockContext as ExecutionContext, mockCallHandler as CallHandler).subscribe({ - error: () => { - setImmediate(() => { - expect(loggerSpy.error).toHaveBeenCalled(); + it('์—๋Ÿฌ ๋ฐœ์ƒ ์‹œ error๊ฐ€ ํ˜ธ์ถœ๋œ๋‹ค', (done) => { + const errorSpy = jest.spyOn(interceptor['logger'], 'error').mockImplementation(() => {}); + callHandler.handle = jest.fn().mockReturnValue(throwError(() => new Error('Test error'))); + interceptor.intercept(context, callHandler).subscribe({ + error: () => { + expect(errorSpy).toHaveBeenCalled(); + errorSpy.mockRestore(); done(); - }); - }, + }, + }); }); }); - it('getClientIp๊ฐ€ ip, connection, socket, unknown ๋ชจ๋‘ ์ •์ƒ ๋ฐ˜ํ™˜', () => { - expect(interceptor['getClientIp']({ ip: '1.2.3.4' } as any)).toBe('1.2.3.4'); - expect(interceptor['getClientIp']({ connection: { remoteAddress: '2.2.2.2' } } as any)).toBe('2.2.2.2'); - expect(interceptor['getClientIp']({ socket: { remoteAddress: '3.3.3.3' } } as any)).toBe('3.3.3.3'); - expect(interceptor['getClientIp']({} as any)).toBe('unknown'); + describe('getClientIp', () => { + it('ip๊ฐ€ ์žˆ์œผ๋ฉด ip๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค', () => { + const request = { ip: '1.2.3.4' } as Request; + const result = interceptor['getClientIp'](request); + expect(result).toBe('1.2.3.4'); + }); + + it('connection.remoteAddress๊ฐ€ ์žˆ์œผ๋ฉด connection.remoteAddress๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค', () => { + const request = { connection: { remoteAddress: '2.2.2.2' } } as unknown as Request; + const result = interceptor['getClientIp'](request); + expect(result).toBe('2.2.2.2'); + }); + + it('socket.remoteAddress๊ฐ€ ์žˆ์œผ๋ฉด socket.remoteAddress๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค', () => { + const request = { socket: { remoteAddress: '3.3.3.3' } } as unknown as Request; + const result = interceptor['getClientIp'](request); + expect(result).toBe('3.3.3.3'); + }); + + it('IP ์ •๋ณด๊ฐ€ ์—†์œผ๋ฉด unknown์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค', () => { + const request = {} as Request; + const result = interceptor['getClientIp'](request); + expect(result).toBe('unknown'); + }); }); }); diff --git a/src/common/interceptors/logging.interceptor.ts b/src/common/interceptors/logging.interceptor.ts index 96a7266..89833d7 100644 --- a/src/common/interceptors/logging.interceptor.ts +++ b/src/common/interceptors/logging.interceptor.ts @@ -5,7 +5,6 @@ import { Request, Response } from 'express'; @Injectable() export class LoggingInterceptor implements NestInterceptor { private readonly logger = new Logger(LoggingInterceptor.name); - private readonly logFormat = '[%s] %s - %d - %dms'; private getNow(): number { if (typeof process.hrtime?.bigint === 'function') { @@ -21,29 +20,29 @@ export class LoggingInterceptor implements NestInterceptor { const startTime = this.getNow(); const clientIp = this.getClientIp(req); - // ์š”์ฒญ ์‹œ์ž‘ ๋กœ๊ทธ (๋น„๋™๊ธฐ) - setImmediate(() => { - this.logger.log(`${method} ${url} - ${clientIp}`); - }); + // ์š”์ฒญ ์‹œ์ž‘ ๋กœ๊ทธ + this.logger.log(`${method} ${url} - ${clientIp}`); return next.handle().pipe( tap(() => { - setImmediate(() => { - const endTime = this.getNow(); - const responseTime = endTime - startTime; - const statusCode = res.statusCode; - if (statusCode >= 400 || responseTime > 1000) { - this.logger.warn(this.logFormat, method, url, statusCode, responseTime); - } else { - this.logger.log(`${method} ${url} - ${statusCode}`); - } - }); + const endTime = this.getNow(); + const responseTime = endTime - startTime; + const statusCode = res.statusCode; + const logMessage = `${method} ${url} - ${statusCode} - ${responseTime.toFixed(2)}ms`; + + if (statusCode >= 400 || responseTime > 1000) { + this.logger.warn(logMessage); + } else { + this.logger.log(logMessage); + } }), catchError((err: unknown) => { const endTime = this.getNow(); const responseTime = endTime - startTime; const statusCode = res.statusCode || 500; - this.logger.error(this.logFormat, method, url, statusCode, responseTime); + const logMessage = `${method} ${url} - ${statusCode} - ${responseTime.toFixed(2)}ms`; + + this.logger.error(logMessage); if (err instanceof Error) { this.logger.error(`${method} ${url} - ${err.message}`); } diff --git a/src/config/__tests__/app.config.spec.ts b/src/config/__tests__/app.config.spec.ts index 8791137..44e00ac 100644 --- a/src/config/__tests__/app.config.spec.ts +++ b/src/config/__tests__/app.config.spec.ts @@ -1,9 +1,27 @@ import appConfig from '../app.config'; describe('appConfig', () => { - it('should return default values if env is not set', () => { + it('ํ™˜๊ฒฝ๋ณ€์ˆ˜๊ฐ€ ์„ค์ •๋˜์ง€ ์•Š์•˜์„ ๋•Œ ๊ธฐ๋ณธ๊ฐ’์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค', () => { + const originalEnv = process.env; + process.env = {}; const config = appConfig(); expect(config.BASE_URL).toContain('localhost'); expect(config.CLIENT_URL).toContain('localhost'); + process.env = originalEnv; + }); + + it('ํ™˜๊ฒฝ๋ณ€์ˆ˜๊ฐ€ ์„ค์ •๋˜์—ˆ์„ ๋•Œ ํ•ด๋‹น ๊ฐ’์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค', () => { + const originalEnv = process.env; + const expectedBaseUrl = 'https://api.example.com'; + const expectedClientUrl = 'https://app.example.com'; + process.env = { + ...originalEnv, + BASE_URL: expectedBaseUrl, + CLIENT_URL: expectedClientUrl, + }; + const config = appConfig(); + expect(config.BASE_URL).toBe(expectedBaseUrl); + expect(config.CLIENT_URL).toBe(expectedClientUrl); + process.env = originalEnv; }); }); diff --git a/src/config/__tests__/auth.config.spec.ts b/src/config/__tests__/auth.config.spec.ts index ae60438..c04a23d 100644 --- a/src/config/__tests__/auth.config.spec.ts +++ b/src/config/__tests__/auth.config.spec.ts @@ -1,10 +1,31 @@ import authConfig from '../auth.config'; describe('authConfig', () => { - it('should return default JWT values if env is not set', () => { + it('ํ™˜๊ฒฝ๋ณ€์ˆ˜๊ฐ€ ์„ค์ •๋˜์ง€ ์•Š์•˜์„ ๋•Œ ๊ธฐ๋ณธ JWT ๊ฐ’์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค', () => { + const originalEnv = process.env; + process.env = {}; const config = authConfig(); expect(config.JWT_SECRET).toBeDefined(); expect(config.JWT_REFRESH_EXPIRES_IN).toBe('7d'); expect(config.JWT_ACCESS_EXPIRES_IN).toBe('15m'); + process.env = originalEnv; + }); + + it('ํ™˜๊ฒฝ๋ณ€์ˆ˜๊ฐ€ ์„ค์ •๋˜์—ˆ์„ ๋•Œ ํ•ด๋‹น ๊ฐ’์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค', () => { + const originalEnv = process.env; + const expectedSecret = 'custom-secret'; + const expectedRefreshExpires = '30d'; + const expectedAccessExpires = '1h'; + process.env = { + ...originalEnv, + JWT_SECRET: expectedSecret, + JWT_REFRESH_EXPIRES_IN: expectedRefreshExpires, + JWT_ACCESS_EXPIRES_IN: expectedAccessExpires, + }; + const config = authConfig(); + expect(config.JWT_SECRET).toBe(expectedSecret); + expect(config.JWT_REFRESH_EXPIRES_IN).toBe(expectedRefreshExpires); + expect(config.JWT_ACCESS_EXPIRES_IN).toBe(expectedAccessExpires); + process.env = originalEnv; }); }); diff --git a/src/config/__tests__/database.config.spec.ts b/src/config/__tests__/database.config.spec.ts index 2c4c01d..48ec379 100644 --- a/src/config/__tests__/database.config.spec.ts +++ b/src/config/__tests__/database.config.spec.ts @@ -1,9 +1,27 @@ import databaseConfig from '../database.config'; describe('databaseConfig', () => { - it('should return empty string if env is not set', () => { + it('ํ™˜๊ฒฝ๋ณ€์ˆ˜๊ฐ€ ์„ค์ •๋˜์ง€ ์•Š์•˜์„ ๋•Œ ๋นˆ ๋ฌธ์ž์—ด์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค', () => { + const originalEnv = process.env; + process.env = {}; const config = databaseConfig(); expect(config.DATABASE_URL).toBe(''); expect(config.DIRECT_URL).toBe(''); + process.env = originalEnv; + }); + + it('ํ™˜๊ฒฝ๋ณ€์ˆ˜๊ฐ€ ์„ค์ •๋˜์—ˆ์„ ๋•Œ ํ•ด๋‹น ๊ฐ’์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค', () => { + const originalEnv = process.env; + const expectedDatabaseUrl = 'postgresql://user:pass@localhost:5432/db'; + const expectedDirectUrl = 'postgresql://user:pass@localhost:5432/db'; + process.env = { + ...originalEnv, + DATABASE_URL: expectedDatabaseUrl, + DIRECT_URL: expectedDirectUrl, + }; + const config = databaseConfig(); + expect(config.DATABASE_URL).toBe(expectedDatabaseUrl); + expect(config.DIRECT_URL).toBe(expectedDirectUrl); + process.env = originalEnv; }); }); diff --git a/src/config/app.config.ts b/src/config/app.config.ts index bf5118d..f7836b7 100644 --- a/src/config/app.config.ts +++ b/src/config/app.config.ts @@ -3,4 +3,7 @@ import { registerAs } from '@nestjs/config'; export default registerAs('app', () => ({ BASE_URL: process.env.BASE_URL || `http://localhost:${process.env.PORT || 4000}`, CLIENT_URL: process.env.CLIENT_URL || 'http://localhost:3000', + BROWSER_PATH: process.env.BROWSER_PATH || '/usr/bin/chromium-browser', + LOG_LEVEL: process.env.LOG_LEVEL || 'info', + SUPPRESS_BROWSER_LOGS: process.env.SUPPRESS_BROWSER_LOGS === 'true', })); diff --git a/src/database/__tests__/database.module.spec.ts b/src/database/__tests__/database.module.spec.ts index 52c5f6d..7f56091 100644 --- a/src/database/__tests__/database.module.spec.ts +++ b/src/database/__tests__/database.module.spec.ts @@ -5,14 +5,26 @@ import { PrismaService } from '../prisma.service'; describe('DatabaseModule', () => { let module: TestingModule; - beforeAll(async () => { + beforeEach(async () => { module = await Test.createTestingModule({ imports: [DatabaseModule], }).compile(); }); - it('should provide PrismaService', () => { + afterEach(async () => { + await module.close(); + }); + + it('PrismaService๋ฅผ ์ œ๊ณตํ•œ๋‹ค', () => { const prisma = module.get(PrismaService); expect(prisma).toBeDefined(); + expect(typeof prisma).toBe('object'); + expect(prisma).toHaveProperty('$connect'); + }); + + it('PrismaService๊ฐ€ ์‹ฑ๊ธ€ํ†ค์œผ๋กœ ์ œ๊ณต๋œ๋‹ค', () => { + const prisma1 = module.get(PrismaService); + const prisma2 = module.get(PrismaService); + expect(prisma1).toBe(prisma2); }); }); diff --git a/src/database/__tests__/prisma.service.spec.ts b/src/database/__tests__/prisma.service.spec.ts index c96fafa..5a8482c 100644 --- a/src/database/__tests__/prisma.service.spec.ts +++ b/src/database/__tests__/prisma.service.spec.ts @@ -1,15 +1,36 @@ +import { Test, TestingModule } from '@nestjs/testing'; import { PrismaService } from '../prisma.service'; describe('PrismaService', () => { let service: PrismaService; + let module: TestingModule; - beforeEach(() => { - service = new PrismaService(); + beforeEach(async () => { + module = await Test.createTestingModule({ + providers: [PrismaService], + }).compile(); + + service = module.get(PrismaService); + }); + + afterEach(async () => { + await module.close(); }); - it('should be defined', () => { + it('PrismaService ์ธ์Šคํ„ด์Šค๊ฐ€ ์ •์˜๋˜์–ด์•ผ ํ•œ๋‹ค', () => { expect(service).toBeDefined(); }); - // ํ•„์š”์‹œ PrismaService์˜ ๋ฉ”์„œ๋“œ์— ๋Œ€ํ•œ mock ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€ ๊ฐ€๋Šฅ + it('PrismaService๊ฐ€ PrismaClient๋ฅผ ์ƒ์†ํ•œ๋‹ค', () => { + expect(service).toHaveProperty('$connect'); + expect(service).toHaveProperty('$disconnect'); + expect(service).toHaveProperty('$on'); + }); + + it('onModuleInit์—์„œ $connect๋ฅผ ํ˜ธ์ถœํ•œ๋‹ค', async () => { + const connectSpy = jest.spyOn(service, '$connect').mockResolvedValue(undefined); + await service.onModuleInit(); + expect(connectSpy).toHaveBeenCalled(); + connectSpy.mockRestore(); + }); }); diff --git a/src/main.ts b/src/main.ts index c96513a..0e6404f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,12 +2,16 @@ import { NestFactory } from '@nestjs/core'; import * as cookieParser from 'cookie-parser'; import { AppModule } from './app.module'; import { LoggingInterceptor } from 'src/common/interceptors/logging.interceptor'; -import { ValidationPipe } from '@nestjs/common'; +import { ValidationPipe, Logger } from '@nestjs/common'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import helmet from 'helmet'; async function bootstrap() { - const app = await NestFactory.create(AppModule); + // ๋กœ๊ทธ ๋ ˆ๋ฒจ ์„ค์ • (๋‹จ์ผ ๊ฐ’) + const logLevel = (process.env.LOG_LEVEL || 'log') as 'log' | 'error' | 'warn' | 'debug' | 'verbose' | 'fatal'; + const app = await NestFactory.create(AppModule, { + logger: [logLevel], + }); // Helmet ๋ณด์•ˆ ๋ฏธ๋“ค์›จ์–ด ์ถ”๊ฐ€ app.use(helmet()); @@ -44,19 +48,27 @@ async function bootstrap() { type: 'http', scheme: 'bearer', bearerFormat: 'JWT', - description: 'Input your JWT token', + description: 'Enter JWT token (without "Bearer" prefix)', name: 'Authorization', in: 'header', }, 'access-token', ) + .addSecurityRequirements('access-token') .build(); const document = SwaggerModule.createDocument(app, config); - SwaggerModule.setup('api', app, document); + SwaggerModule.setup('api', app, document, { + swaggerOptions: { + persistAuthorization: true, // ๋ธŒ๋ผ์šฐ์ € ์ƒˆ๋กœ๊ณ ์นจ ์‹œ์—๋„ ํ† ํฐ ์œ ์ง€ + }, + }); } + const logger = new Logger('Bootstrap'); + logger.log(`Application starting with log level: ${logLevel}`); + await app.listen(process.env.PORT ?? 4000); } -bootstrap(); +void bootstrap(); diff --git a/src/modules/article/article.controller.ts b/src/modules/article/article.controller.ts new file mode 100644 index 0000000..ecfbbec --- /dev/null +++ b/src/modules/article/article.controller.ts @@ -0,0 +1,311 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + UseGuards, + Request, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBody, ApiQuery, ApiBearerAuth } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../../common/guards/jwt.guard'; +import { ArticleService } from './services/article.service'; +import { CreateArticleInput } from './dto/create-article.input'; +import { UpdateArticleInput } from './dto/update-article.input'; +import { ListArticlesInput } from './dto/list-articles.input'; +import { ArticleOutput } from './dto/article.output'; +import { PaginatedArticlesOutput } from './dto/paginated-articles.output'; +import { AuthRequest } from 'src/types'; + +@ApiTags('articles') +@Controller('articles') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth() +export class ArticleController { + constructor(private readonly articleService: ArticleService) {} + + /** + * ์ƒˆ๋กœ์šด Article์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. + */ + @Post() + @ApiOperation({ + summary: 'Article ์ƒ์„ฑ', + description: '์ƒˆ๋กœ์šด Article์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.', + }) + @ApiBody({ type: CreateArticleInput }) + @ApiResponse({ + status: 201, + description: 'Article ์ƒ์„ฑ ์„ฑ๊ณต', + type: ArticleOutput, + }) + @ApiResponse({ + status: 400, + description: '์ž˜๋ชป๋œ ์š”์ฒญ ๋˜๋Š” ์ค‘๋ณต๋œ URL', + }) + @ApiResponse({ + status: 401, + description: '์ธ์ฆ ์‹คํŒจ', + }) + async createArticle(@Request() req: AuthRequest, @Body() input: CreateArticleInput): Promise { + return this.articleService.createArticle(req.user.id, input); + } + + /** + * ์‚ฌ์šฉ์ž์˜ Article ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. + */ + @Get() + @ApiOperation({ + summary: 'Article ๋ชฉ๋ก ์กฐํšŒ', + description: '์‚ฌ์šฉ์ž์˜ Article ๋ชฉ๋ก์„ ํŽ˜์ด์ง€๋„ค์ด์…˜๊ณผ ํ•จ๊ป˜ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.', + }) + @ApiQuery({ name: 'page', required: false, type: Number, description: 'ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ' }) + @ApiQuery({ name: 'limit', required: false, type: Number, description: 'ํŽ˜์ด์ง€๋‹น ํ•ญ๋ชฉ ์ˆ˜' }) + @ApiQuery({ name: 'search', required: false, type: String, description: '๊ฒ€์ƒ‰ ํ‚ค์›Œ๋“œ' }) + @ApiQuery({ name: 'tags', required: false, type: [String], description: 'ํƒœ๊ทธ ํ•„ํ„ฐ' }) + @ApiQuery({ name: 'isBookmarked', required: false, type: Boolean, description: '๋ถ๋งˆํฌ ํ•„ํ„ฐ' }) + @ApiQuery({ name: 'isArchived', required: false, type: Boolean, description: '์•„์นด์ด๋ธŒ ํ•„ํ„ฐ' }) + @ApiQuery({ name: 'sortBy', required: false, type: String, description: '์ •๋ ฌ ๊ธฐ์ค€' }) + @ApiQuery({ name: 'sortOrder', required: false, type: String, description: '์ •๋ ฌ ์ˆœ์„œ' }) + @ApiResponse({ + status: 200, + description: 'Article ๋ชฉ๋ก ์กฐํšŒ ์„ฑ๊ณต', + type: PaginatedArticlesOutput, + }) + @ApiResponse({ + status: 401, + description: '์ธ์ฆ ์‹คํŒจ', + }) + async getArticles( + @Request() req: AuthRequest, + @Query() query: ListArticlesInput, + ): Promise { + return this.articleService.getArticles(req.user.id, query); + } + + /** + * ํŠน์ • Article์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. + */ + @Get(':id') + @ApiOperation({ + summary: 'Article ์กฐํšŒ', + description: 'ํŠน์ • Article์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.', + }) + @ApiResponse({ + status: 200, + description: 'Article ์กฐํšŒ ์„ฑ๊ณต', + type: ArticleOutput, + }) + @ApiResponse({ + status: 401, + description: '์ธ์ฆ ์‹คํŒจ', + }) + @ApiResponse({ + status: 404, + description: 'Article์„ ์ฐพ์„ ์ˆ˜ ์—†์Œ', + }) + async getArticle(@Request() req: AuthRequest, @Param('id') id: string): Promise { + return this.articleService.getArticle(req.user.id, id); + } + + /** + * Article์„ ์—…๋ฐ์ดํŠธํ•ฉ๋‹ˆ๋‹ค. + */ + @Put(':id') + @ApiOperation({ + summary: 'Article ์—…๋ฐ์ดํŠธ', + description: 'Article์˜ ์ •๋ณด๋ฅผ ์—…๋ฐ์ดํŠธํ•ฉ๋‹ˆ๋‹ค.', + }) + @ApiBody({ type: UpdateArticleInput }) + @ApiResponse({ + status: 200, + description: 'Article ์—…๋ฐ์ดํŠธ ์„ฑ๊ณต', + type: ArticleOutput, + }) + @ApiResponse({ + status: 401, + description: '์ธ์ฆ ์‹คํŒจ', + }) + @ApiResponse({ + status: 404, + description: 'Article์„ ์ฐพ์„ ์ˆ˜ ์—†์Œ', + }) + async updateArticle( + @Request() req: AuthRequest, + @Param('id') id: string, + @Body() input: UpdateArticleInput, + ): Promise { + return this.articleService.updateArticle(req.user.id, id, input); + } + + /** + * Article์„ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค. + */ + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ + summary: 'Article ์‚ญ์ œ', + description: 'Article์„ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค.', + }) + @ApiResponse({ + status: 204, + description: 'Article ์‚ญ์ œ ์„ฑ๊ณต', + }) + @ApiResponse({ + status: 401, + description: '์ธ์ฆ ์‹คํŒจ', + }) + @ApiResponse({ + status: 404, + description: 'Article์„ ์ฐพ์„ ์ˆ˜ ์—†์Œ', + }) + async deleteArticle(@Request() req: AuthRequest, @Param('id') id: string): Promise { + return this.articleService.deleteArticle(req.user.id, id); + } + + /** + * Article ๋ถ๋งˆํฌ ์ƒํƒœ๋ฅผ ํ† ๊ธ€ํ•ฉ๋‹ˆ๋‹ค. + */ + @Post(':id/bookmark') + @ApiOperation({ + summary: 'Article ๋ถ๋งˆํฌ ํ† ๊ธ€', + description: 'Article์˜ ๋ถ๋งˆํฌ ์ƒํƒœ๋ฅผ ํ† ๊ธ€ํ•ฉ๋‹ˆ๋‹ค.', + }) + @ApiResponse({ + status: 200, + description: '๋ถ๋งˆํฌ ์ƒํƒœ ๋ณ€๊ฒฝ ์„ฑ๊ณต', + type: ArticleOutput, + }) + @ApiResponse({ + status: 401, + description: '์ธ์ฆ ์‹คํŒจ', + }) + @ApiResponse({ + status: 404, + description: 'Article์„ ์ฐพ์„ ์ˆ˜ ์—†์Œ', + }) + async toggleBookmark(@Request() req: AuthRequest, @Param('id') id: string): Promise { + return this.articleService.toggleBookmark(req.user.id, id); + } + + /** + * Article ์•„์นด์ด๋ธŒ ์ƒํƒœ๋ฅผ ํ† ๊ธ€ํ•ฉ๋‹ˆ๋‹ค. + */ + @Post(':id/archive') + @ApiOperation({ + summary: 'Article ์•„์นด์ด๋ธŒ ํ† ๊ธ€', + description: 'Article์˜ ์•„์นด์ด๋ธŒ ์ƒํƒœ๋ฅผ ํ† ๊ธ€ํ•ฉ๋‹ˆ๋‹ค.', + }) + @ApiResponse({ + status: 200, + description: '์•„์นด์ด๋ธŒ ์ƒํƒœ ๋ณ€๊ฒฝ ์„ฑ๊ณต', + type: ArticleOutput, + }) + @ApiResponse({ + status: 401, + description: '์ธ์ฆ ์‹คํŒจ', + }) + @ApiResponse({ + status: 404, + description: 'Article์„ ์ฐพ์„ ์ˆ˜ ์—†์Œ', + }) + async toggleArchive(@Request() req: AuthRequest, @Param('id') id: string): Promise { + return this.articleService.toggleArchive(req.user.id, id); + } + + /** + * ์‚ฌ์šฉ์ž์˜ Article ํ†ต๊ณ„๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. + */ + @Get('stats/overview') + @ApiOperation({ + summary: 'Article ํ†ต๊ณ„ ์กฐํšŒ', + description: '์‚ฌ์šฉ์ž์˜ Article ํ†ต๊ณ„๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.', + }) + @ApiResponse({ + status: 200, + description: 'ํ†ต๊ณ„ ์กฐํšŒ ์„ฑ๊ณต', + schema: { + type: 'object', + properties: { + total: { type: 'number', description: '์ด Article ์ˆ˜' }, + bookmarked: { type: 'number', description: '๋ถ๋งˆํฌ๋œ Article ์ˆ˜' }, + archived: { type: 'number', description: '์•„์นด์ด๋ธŒ๋œ Article ์ˆ˜' }, + recent: { type: 'number', description: '์ตœ๊ทผ 7์ผ๊ฐ„ ์ถ”๊ฐ€๋œ Article ์ˆ˜' }, + }, + }, + }) + @ApiResponse({ + status: 401, + description: '์ธ์ฆ ์‹คํŒจ', + }) + async getArticleStats(@Request() req: AuthRequest): Promise<{ + total: number; + bookmarked: number; + archived: number; + recent: number; + }> { + return this.articleService.getArticleStats(req.user.id); + } + + /** + * ์‚ฌ์šฉ์ž์˜ ๋ชจ๋“  ํƒœ๊ทธ๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. + */ + @Get('tags/all') + @ApiOperation({ + summary: '์‚ฌ์šฉ์ž ํƒœ๊ทธ ๋ชฉ๋ก ์กฐํšŒ', + description: '์‚ฌ์šฉ์ž๊ฐ€ ์‚ฌ์šฉํ•œ ๋ชจ๋“  ํƒœ๊ทธ๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.', + }) + @ApiResponse({ + status: 200, + description: 'ํƒœ๊ทธ ๋ชฉ๋ก ์กฐํšŒ ์„ฑ๊ณต', + schema: { + type: 'array', + items: { type: 'string' }, + }, + }) + @ApiResponse({ + status: 401, + description: '์ธ์ฆ ์‹คํŒจ', + }) + async getUserTags(@Request() req: AuthRequest): Promise { + return this.articleService.getUserTags(req.user.id); + } + + /** + * URL์ด ์ด๋ฏธ ์ €์žฅ๋˜์–ด ์žˆ๋Š”์ง€ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค. + */ + @Get('check-url') + @ApiOperation({ + summary: 'URL ์ค‘๋ณต ํ™•์ธ', + description: 'URL์ด ์ด๋ฏธ ์ €์žฅ๋˜์–ด ์žˆ๋Š”์ง€ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.', + }) + @ApiQuery({ + name: 'url', + required: true, + type: String, + description: 'ํ™•์ธํ•  URL', + }) + @ApiResponse({ + status: 200, + description: 'URL ํ™•์ธ ์„ฑ๊ณต', + schema: { + type: 'object', + properties: { + exists: { type: 'boolean', description: 'URL ์กด์žฌ ์—ฌ๋ถ€' }, + url: { type: 'string', description: 'ํ™•์ธํ•œ URL' }, + }, + }, + }) + @ApiResponse({ + status: 401, + description: '์ธ์ฆ ์‹คํŒจ', + }) + async checkUrl(@Request() req: AuthRequest, @Query('url') url: string): Promise<{ exists: boolean; url: string }> { + const exists = await this.articleService.isUrlAlreadySaved(req.user.id, url); + return { exists, url }; + } +} diff --git a/src/modules/article/article.module.ts b/src/modules/article/article.module.ts new file mode 100644 index 0000000..ad22d3e --- /dev/null +++ b/src/modules/article/article.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { DatabaseModule } from '../../database/database.module'; +import { ArticleController } from './article.controller'; +import { ArticleService } from './services/article.service'; +import { ArticleRepository } from './repositories/article.repository'; + +/** + * Article ๋ชจ๋“ˆ + * ์Šคํฌ๋ž˜ํ•‘๋œ ์ฝ˜ํ…์ธ  ์ €์žฅ ๋ฐ ๊ด€๋ฆฌ ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. + */ +@Module({ + imports: [DatabaseModule], + controllers: [ArticleController], + providers: [ArticleService, ArticleRepository], + exports: [ArticleService], +}) +export class ArticleModule {} diff --git a/src/modules/article/dto/article.output.ts b/src/modules/article/dto/article.output.ts new file mode 100644 index 0000000..7339fda --- /dev/null +++ b/src/modules/article/dto/article.output.ts @@ -0,0 +1,109 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +/** + * Article ์ถœ๋ ฅ DTO + */ +export class ArticleOutput { + @ApiProperty({ + description: 'Article ID', + example: 'uuid-string', + }) + id!: string; + + @ApiProperty({ + description: '์›๋ณธ URL', + example: 'https://example.com/article', + }) + url!: string; + + @ApiProperty({ + description: '์ตœ์ข… URL (๋ฆฌ๋””๋ ‰์…˜ ํ›„)', + example: 'https://example.com/article', + }) + finalUrl!: string; + + @ApiPropertyOptional({ + description: '์ถ”์ถœ๋œ ์ œ๋ชฉ', + example: 'Amazing Article Title', + }) + title?: string; + + @ApiPropertyOptional({ + description: '์Šคํฌ๋ž˜ํ•‘๋œ ์ฝ˜ํ…์ธ  (HTML)', + example: '
Article content...
', + }) + content?: string; + + @ApiPropertyOptional({ + description: 'MIME ํƒ€์ž…', + example: 'text/html', + }) + contentType?: string; + + @ApiPropertyOptional({ + description: '์š”์•ฝ', + example: 'This article discusses...', + }) + summary?: string; + + @ApiPropertyOptional({ + description: '์ €์ž', + example: 'John Doe', + }) + author?: string; + + @ApiPropertyOptional({ + description: '๋ฐœํ–‰์ผ', + example: '2024-01-01T00:00:00Z', + }) + publishedAt?: Date; + + @ApiPropertyOptional({ + description: '๋‹จ์–ด ์ˆ˜', + example: 1500, + }) + wordCount?: number; + + @ApiPropertyOptional({ + description: '์˜ˆ์ƒ ์ฝ๊ธฐ ์‹œ๊ฐ„ (๋ถ„)', + example: 7, + }) + readingTime?: number; + + @ApiPropertyOptional({ + description: 'ํƒœ๊ทธ ๋ฐฐ์—ด', + example: ['tech', 'ai', 'programming'], + type: [String], + }) + tags?: string[]; + + @ApiProperty({ + description: '๋ถ๋งˆํฌ ์—ฌ๋ถ€', + example: false, + }) + isBookmarked!: boolean; + + @ApiProperty({ + description: '์•„์นด์ด๋ธŒ ์—ฌ๋ถ€', + example: false, + }) + isArchived!: boolean; + + @ApiProperty({ + description: '์‚ฌ์šฉ์ž ID', + example: 'user-uuid', + }) + userId!: string; + + @ApiProperty({ + description: '์ƒ์„ฑ์ผ', + example: '2024-01-01T00:00:00Z', + }) + createdAt!: Date; + + @ApiProperty({ + description: '์—…๋ฐ์ดํŠธ์ผ', + example: '2024-01-01T00:00:00Z', + }) + updatedAt!: Date; +} diff --git a/src/modules/article/dto/create-article.input.ts b/src/modules/article/dto/create-article.input.ts new file mode 100644 index 0000000..4d58111 --- /dev/null +++ b/src/modules/article/dto/create-article.input.ts @@ -0,0 +1,119 @@ +import { IsNotEmpty, IsOptional, IsString, IsUrl, IsArray, IsBoolean, IsInt, IsDateString, Min } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +/** + * Article ์ƒ์„ฑ์„ ์œ„ํ•œ ์ž…๋ ฅ DTO + */ +export class CreateArticleInput { + @ApiProperty({ + description: '์›๋ณธ URL', + example: 'https://example.com/article', + }) + @IsString() + @IsNotEmpty() + @IsUrl({ require_protocol: true }) + url!: string; + + @ApiProperty({ + description: '์ตœ์ข… URL (๋ฆฌ๋””๋ ‰์…˜ ํ›„)', + example: 'https://example.com/article', + }) + @IsString() + @IsNotEmpty() + @IsUrl({ require_protocol: true }) + finalUrl!: string; + + @ApiPropertyOptional({ + description: '์ถ”์ถœ๋œ ์ œ๋ชฉ', + example: 'Amazing Article Title', + }) + @IsOptional() + @IsString() + title?: string; + + @ApiPropertyOptional({ + description: '์Šคํฌ๋ž˜ํ•‘๋œ ์ฝ˜ํ…์ธ  (HTML)', + example: '
Article content...
', + }) + @IsOptional() + @IsString() + content?: string; + + @ApiPropertyOptional({ + description: 'MIME ํƒ€์ž…', + example: 'text/html', + }) + @IsOptional() + @IsString() + contentType?: string; + + @ApiPropertyOptional({ + description: '์š”์•ฝ', + example: 'This article discusses...', + }) + @IsOptional() + @IsString() + summary?: string; + + @ApiPropertyOptional({ + description: '์ €์ž', + example: 'John Doe', + }) + @IsOptional() + @IsString() + author?: string; + + @ApiPropertyOptional({ + description: '๋ฐœํ–‰์ผ', + example: '2024-01-01T00:00:00Z', + }) + @IsOptional() + @IsDateString() + publishedAt?: string; + + @ApiPropertyOptional({ + description: '๋‹จ์–ด ์ˆ˜', + example: 1500, + }) + @IsOptional() + @IsInt() + @Min(0) + wordCount?: number; + + @ApiPropertyOptional({ + description: '์˜ˆ์ƒ ์ฝ๊ธฐ ์‹œ๊ฐ„ (๋ถ„)', + example: 7, + }) + @IsOptional() + @IsInt() + @Min(0) + readingTime?: number; + + @ApiPropertyOptional({ + description: 'ํƒœ๊ทธ ๋ฐฐ์—ด', + example: ['tech', 'ai', 'programming'], + type: [String], + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @ApiPropertyOptional({ + description: '๋ถ๋งˆํฌ ์—ฌ๋ถ€', + example: false, + default: false, + }) + @IsOptional() + @IsBoolean() + isBookmarked?: boolean; + + @ApiPropertyOptional({ + description: '์•„์นด์ด๋ธŒ ์—ฌ๋ถ€', + example: false, + default: false, + }) + @IsOptional() + @IsBoolean() + isArchived?: boolean; +} diff --git a/src/modules/article/dto/list-articles.input.ts b/src/modules/article/dto/list-articles.input.ts new file mode 100644 index 0000000..7e5e7e4 --- /dev/null +++ b/src/modules/article/dto/list-articles.input.ts @@ -0,0 +1,85 @@ +import { IsOptional, IsString, IsInt, IsBoolean, IsArray, IsIn, Min, Max } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; + +/** + * Article ๋ชฉ๋ก ์กฐํšŒ๋ฅผ ์œ„ํ•œ ์ž…๋ ฅ DTO + */ +export class ListArticlesInput { + @ApiPropertyOptional({ + description: 'ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ (1๋ถ€ํ„ฐ ์‹œ์ž‘)', + example: 1, + default: 1, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ + description: 'ํŽ˜์ด์ง€๋‹น ํ•ญ๋ชฉ ์ˆ˜', + example: 20, + default: 20, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 20; + + @ApiPropertyOptional({ + description: '๊ฒ€์ƒ‰ ํ‚ค์›Œ๋“œ (์ œ๋ชฉ, ๋‚ด์šฉ์—์„œ ๊ฒ€์ƒ‰)', + example: 'javascript', + }) + @IsOptional() + @IsString() + search?: string; + + @ApiPropertyOptional({ + description: 'ํƒœ๊ทธ ํ•„ํ„ฐ', + example: ['tech', 'programming'], + type: [String], + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @ApiPropertyOptional({ + description: '๋ถ๋งˆํฌ๋œ ํ•ญ๋ชฉ๋งŒ ์กฐํšŒ', + example: true, + }) + @IsOptional() + @IsBoolean() + isBookmarked?: boolean; + + @ApiPropertyOptional({ + description: '์•„์นด์ด๋ธŒ๋œ ํ•ญ๋ชฉ๋งŒ ์กฐํšŒ', + example: false, + }) + @IsOptional() + @IsBoolean() + isArchived?: boolean; + + @ApiPropertyOptional({ + description: '์ •๋ ฌ ๊ธฐ์ค€', + example: 'createdAt', + enum: ['createdAt', 'updatedAt', 'title', 'publishedAt'], + }) + @IsOptional() + @IsString() + @IsIn(['createdAt', 'updatedAt', 'title', 'publishedAt']) + sortBy?: string = 'createdAt'; + + @ApiPropertyOptional({ + description: '์ •๋ ฌ ์ˆœ์„œ', + example: 'desc', + enum: ['asc', 'desc'], + }) + @IsOptional() + @IsString() + @IsIn(['asc', 'desc']) + sortOrder?: string = 'desc'; +} diff --git a/src/modules/article/dto/paginated-articles.output.ts b/src/modules/article/dto/paginated-articles.output.ts new file mode 100644 index 0000000..7fffabc --- /dev/null +++ b/src/modules/article/dto/paginated-articles.output.ts @@ -0,0 +1,49 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ArticleOutput } from './article.output'; + +/** + * ํŽ˜์ด์ง€๋„ค์ด์…˜๋œ Article ๋ชฉ๋ก ์ถœ๋ ฅ DTO + */ +export class PaginatedArticlesOutput { + @ApiProperty({ + description: 'Article ๋ชฉ๋ก', + type: [ArticleOutput], + }) + articles!: ArticleOutput[]; + + @ApiProperty({ + description: '์ด ํ•ญ๋ชฉ ์ˆ˜', + example: 150, + }) + total!: number; + + @ApiProperty({ + description: 'ํ˜„์žฌ ํŽ˜์ด์ง€', + example: 1, + }) + page!: number; + + @ApiProperty({ + description: 'ํŽ˜์ด์ง€๋‹น ํ•ญ๋ชฉ ์ˆ˜', + example: 20, + }) + limit!: number; + + @ApiProperty({ + description: '์ด ํŽ˜์ด์ง€ ์ˆ˜', + example: 8, + }) + totalPages!: number; + + @ApiProperty({ + description: '๋‹ค์Œ ํŽ˜์ด์ง€ ์กด์žฌ ์—ฌ๋ถ€', + example: true, + }) + hasNext!: boolean; + + @ApiProperty({ + description: '์ด์ „ ํŽ˜์ด์ง€ ์กด์žฌ ์—ฌ๋ถ€', + example: false, + }) + hasPrev!: boolean; +} diff --git a/src/modules/article/dto/save-scraped-content.input.ts b/src/modules/article/dto/save-scraped-content.input.ts new file mode 100644 index 0000000..b875084 --- /dev/null +++ b/src/modules/article/dto/save-scraped-content.input.ts @@ -0,0 +1,44 @@ +import { IsNotEmpty, IsOptional, IsString, IsUrl, IsArray, IsBoolean } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +/** + * ์Šคํฌ๋ž˜ํ•‘๋œ ์ฝ˜ํ…์ธ ๋ฅผ ์ €์žฅํ•˜๊ธฐ ์œ„ํ•œ ์ž…๋ ฅ DTO + */ +export class SaveScrapedContentInput { + @ApiProperty({ + description: '์Šคํฌ๋ž˜ํ•‘ํ•  URL', + example: 'https://example.com/article', + }) + @IsString() + @IsNotEmpty() + @IsUrl({ require_protocol: true }) + url!: string; + + @ApiPropertyOptional({ + description: 'ํƒœ๊ทธ ๋ฐฐ์—ด', + example: ['tech', 'ai', 'programming'], + type: [String], + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @ApiPropertyOptional({ + description: '๋ถ๋งˆํฌ ์—ฌ๋ถ€', + example: false, + default: false, + }) + @IsOptional() + @IsBoolean() + isBookmarked?: boolean; + + @ApiPropertyOptional({ + description: '์•„์นด์ด๋ธŒ ์—ฌ๋ถ€', + example: false, + default: false, + }) + @IsOptional() + @IsBoolean() + isArchived?: boolean; +} diff --git a/src/modules/article/dto/update-article.input.ts b/src/modules/article/dto/update-article.input.ts new file mode 100644 index 0000000..cb4f200 --- /dev/null +++ b/src/modules/article/dto/update-article.input.ts @@ -0,0 +1,99 @@ +import { IsOptional, IsString, IsArray, IsBoolean, IsInt, IsDateString, Min } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +/** + * Article ์—…๋ฐ์ดํŠธ๋ฅผ ์œ„ํ•œ ์ž…๋ ฅ DTO + */ +export class UpdateArticleInput { + @ApiPropertyOptional({ + description: '์ถ”์ถœ๋œ ์ œ๋ชฉ', + example: 'Updated Article Title', + }) + @IsOptional() + @IsString() + title?: string; + + @ApiPropertyOptional({ + description: '์Šคํฌ๋ž˜ํ•‘๋œ ์ฝ˜ํ…์ธ  (HTML)', + example: '
Updated content...
', + }) + @IsOptional() + @IsString() + content?: string; + + @ApiPropertyOptional({ + description: 'MIME ํƒ€์ž…', + example: 'text/html', + }) + @IsOptional() + @IsString() + contentType?: string; + + @ApiPropertyOptional({ + description: '์š”์•ฝ', + example: 'Updated summary...', + }) + @IsOptional() + @IsString() + summary?: string; + + @ApiPropertyOptional({ + description: '์ €์ž', + example: 'Jane Doe', + }) + @IsOptional() + @IsString() + author?: string; + + @ApiPropertyOptional({ + description: '๋ฐœํ–‰์ผ', + example: '2024-01-01T00:00:00Z', + }) + @IsOptional() + @IsDateString() + publishedAt?: string; + + @ApiPropertyOptional({ + description: '๋‹จ์–ด ์ˆ˜', + example: 1500, + }) + @IsOptional() + @IsInt() + @Min(0) + wordCount?: number; + + @ApiPropertyOptional({ + description: '์˜ˆ์ƒ ์ฝ๊ธฐ ์‹œ๊ฐ„ (๋ถ„)', + example: 7, + }) + @IsOptional() + @IsInt() + @Min(0) + readingTime?: number; + + @ApiPropertyOptional({ + description: 'ํƒœ๊ทธ ๋ฐฐ์—ด', + example: ['tech', 'ai', 'programming'], + type: [String], + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @ApiPropertyOptional({ + description: '๋ถ๋งˆํฌ ์—ฌ๋ถ€', + example: true, + }) + @IsOptional() + @IsBoolean() + isBookmarked?: boolean; + + @ApiPropertyOptional({ + description: '์•„์นด์ด๋ธŒ ์—ฌ๋ถ€', + example: false, + }) + @IsOptional() + @IsBoolean() + isArchived?: boolean; +} diff --git a/src/modules/article/repositories/article.repository.ts b/src/modules/article/repositories/article.repository.ts new file mode 100644 index 0000000..26ad69d --- /dev/null +++ b/src/modules/article/repositories/article.repository.ts @@ -0,0 +1,324 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../../../database/prisma.service'; +import { Article, Prisma } from '@prisma/client'; +import { CreateArticleInput } from '../dto/create-article.input'; +import { UpdateArticleInput } from '../dto/update-article.input'; +import { ListArticlesInput } from '../dto/list-articles.input'; + +/** + * Article ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ž‘์—…์„ ๋‹ด๋‹นํ•˜๋Š” Repository + */ +@Injectable() +export class ArticleRepository { + private readonly logger = new Logger(ArticleRepository.name); + + constructor(private readonly prisma: PrismaService) {} + + /** + * ์ƒˆ๋กœ์šด Article์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. + */ + async createArticle(userId: string, input: CreateArticleInput): Promise
{ + const data: Prisma.ArticleCreateInput = { + url: input.url, + finalUrl: input.finalUrl, + title: input.title, + content: input.content, + contentType: input.contentType, + summary: input.summary, + author: input.author, + publishedAt: input.publishedAt ? new Date(input.publishedAt) : undefined, + wordCount: input.wordCount, + readingTime: input.readingTime, + tags: input.tags || [], + isBookmarked: input.isBookmarked || false, + isArchived: input.isArchived || false, + user: { + connect: { id: userId }, + }, + }; + + return this.prisma.article.create({ + data, + include: { + user: { + select: { + id: true, + email: true, + name: true, + }, + }, + }, + }); + } + + /** + * URL๊ณผ ์‚ฌ์šฉ์ž ID๋กœ ๊ธฐ์กด Article์„ ์ฐพ์Šต๋‹ˆ๋‹ค. + */ + async findByUrlAndUserId(url: string, userId: string): Promise
{ + return this.prisma.article.findUnique({ + where: { + url_userId: { + url, + userId, + }, + }, + }); + } + + /** + * ID๋กœ Article์„ ์ฐพ์Šต๋‹ˆ๋‹ค. + */ + async findById(id: string, userId: string): Promise
{ + return this.prisma.article.findFirst({ + where: { + id, + userId, + }, + include: { + user: { + select: { + id: true, + email: true, + name: true, + }, + }, + }, + }); + } + + /** + * Article์„ ์—…๋ฐ์ดํŠธํ•ฉ๋‹ˆ๋‹ค. + */ + async updateArticle(id: string, userId: string, input: UpdateArticleInput): Promise
{ + const data: Prisma.ArticleUpdateInput = { + title: input.title, + content: input.content, + contentType: input.contentType, + summary: input.summary, + author: input.author, + publishedAt: input.publishedAt ? new Date(input.publishedAt) : undefined, + wordCount: input.wordCount, + readingTime: input.readingTime, + tags: input.tags, + isBookmarked: input.isBookmarked, + isArchived: input.isArchived, + }; + + // undefined ๊ฐ’๋“ค์„ ์ œ๊ฑฐ + Object.keys(data).forEach((key) => { + if (data[key as keyof typeof data] === undefined) { + delete data[key as keyof typeof data]; + } + }); + + try { + return await this.prisma.article.update({ + where: { + id_userId: { + id, + userId, + }, + }, + data, + include: { + user: { + select: { + id: true, + email: true, + name: true, + }, + }, + }, + }); + } catch (error) { + // Article์ด ์กด์žฌํ•˜์ง€ ์•Š๊ฑฐ๋‚˜ ์‚ฌ์šฉ์ž๊ฐ€ ์†Œ์œ ํ•˜์ง€ ์•Š์€ ๊ฒฝ์šฐ + this.logger.warn(`Failed to update article ${id} for user ${userId}: ${(error as Error).message}`); + return null; + } + } + + /** + * Article์„ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค. + */ + async deleteArticle(id: string, userId: string): Promise { + try { + await this.prisma.article.delete({ + where: { + id_userId: { + id, + userId, + }, + }, + }); + return true; + } catch (error) { + this.logger.warn(`Failed to delete article ${id} for user ${userId}: ${(error as Error).message}`); + return false; + } + } + + /** + * ์‚ฌ์šฉ์ž์˜ Article ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. + */ + async findArticlesByUserId( + userId: string, + input: ListArticlesInput, + ): Promise<{ + articles: Article[]; + total: number; + }> { + const { + page = 1, + limit = 20, + search, + tags, + isBookmarked, + isArchived, + sortBy = 'createdAt', + sortOrder = 'desc', + } = input; + + const skip = (page - 1) * limit; + + // ๊ฒ€์ƒ‰ ์กฐ๊ฑด ๊ตฌ์„ฑ + const where: Prisma.ArticleWhereInput = { + userId, + ...(search && { + OR: [ + { title: { contains: search, mode: 'insensitive' } }, + { content: { contains: search, mode: 'insensitive' } }, + { summary: { contains: search, mode: 'insensitive' } }, + ], + }), + ...(tags && + tags.length > 0 && { + tags: { + hasSome: tags, + }, + }), + ...(isBookmarked !== undefined && { isBookmarked }), + ...(isArchived !== undefined && { isArchived }), + }; + + // ์ •๋ ฌ ์กฐ๊ฑด ๊ตฌ์„ฑ - ํƒ€์ž… ์•ˆ์ „์„ฑ ๋ณด์žฅ + const orderBy: Prisma.ArticleOrderByWithRelationInput = { + [sortBy as keyof Prisma.ArticleOrderByWithRelationInput]: sortOrder as Prisma.SortOrder, + }; + + const [articles, total] = await Promise.all([ + this.prisma.article.findMany({ + where, + orderBy, + skip, + take: limit, + include: { + user: { + select: { + id: true, + email: true, + name: true, + }, + }, + }, + }), + this.prisma.article.count({ where }), + ]); + + return { articles, total }; + } + + /** + * ์‚ฌ์šฉ์ž์˜ Article ํ†ต๊ณ„๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. + */ + async getArticleStats(userId: string): Promise<{ + total: number; + bookmarked: number; + archived: number; + recent: number; + }> { + const [total, bookmarked, archived, recent] = await Promise.all([ + this.prisma.article.count({ where: { userId } }), + this.prisma.article.count({ where: { userId, isBookmarked: true } }), + this.prisma.article.count({ where: { userId, isArchived: true } }), + this.prisma.article.count({ + where: { + userId, + createdAt: { + gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), // ์ตœ๊ทผ 7์ผ + }, + }, + }), + ]); + + return { total, bookmarked, archived, recent }; + } + + /** + * ์‚ฌ์šฉ์ž์˜ ๋ชจ๋“  ํƒœ๊ทธ๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. + */ + async getUserTags(userId: string): Promise { + const articles = await this.prisma.article.findMany({ + where: { userId }, + select: { tags: true }, + }); + + const allTags = articles.flatMap((article) => article.tags); + return Array.from(new Set(allTags)).sort(); + } + + /** + * URL๊ณผ ์‚ฌ์šฉ์ž ID๋กœ ๊ธฐ์กด Article์„ ์—…๋ฐ์ดํŠธํ•˜๊ฑฐ๋‚˜ ์ƒˆ๋กœ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. + */ + async upsertArticle(userId: string, input: CreateArticleInput): Promise
{ + const data: Prisma.ArticleCreateInput = { + url: input.url, + finalUrl: input.finalUrl, + title: input.title, + content: input.content, + contentType: input.contentType, + summary: input.summary, + author: input.author, + publishedAt: input.publishedAt ? new Date(input.publishedAt) : undefined, + wordCount: input.wordCount, + readingTime: input.readingTime, + tags: input.tags || [], + isBookmarked: input.isBookmarked || false, + isArchived: input.isArchived || false, + user: { + connect: { id: userId }, + }, + }; + + return this.prisma.article.upsert({ + where: { + url_userId: { + url: input.url, + userId, + }, + }, + create: data, + update: { + finalUrl: input.finalUrl, + title: input.title, + content: input.content, + contentType: input.contentType, + summary: input.summary, + author: input.author, + publishedAt: input.publishedAt ? new Date(input.publishedAt) : undefined, + wordCount: input.wordCount, + readingTime: input.readingTime, + tags: input.tags || [], + // ๋ถ๋งˆํฌ์™€ ์•„์นด์ด๋ธŒ ์ƒํƒœ๋Š” ์—…๋ฐ์ดํŠธํ•˜์ง€ ์•Š์Œ (์‚ฌ์šฉ์ž๊ฐ€ ์„ค์ •ํ•œ ๊ฐ’ ์œ ์ง€) + }, + include: { + user: { + select: { + id: true, + email: true, + name: true, + }, + }, + }, + }); + } +} diff --git a/src/modules/article/services/article.service.ts b/src/modules/article/services/article.service.ts new file mode 100644 index 0000000..bdfceac --- /dev/null +++ b/src/modules/article/services/article.service.ts @@ -0,0 +1,258 @@ +import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; +import { Article } from '@prisma/client'; +import * as DOMPurify from 'dompurify'; +import { JSDOM } from 'jsdom'; +import { ArticleRepository } from '../repositories/article.repository'; +import { CreateArticleInput } from '../dto/create-article.input'; +import { UpdateArticleInput } from '../dto/update-article.input'; +import { ListArticlesInput } from '../dto/list-articles.input'; +import { ArticleOutput } from '../dto/article.output'; +import { PaginatedArticlesOutput } from '../dto/paginated-articles.output'; +import { SaveScrapedContentInput } from '../dto/save-scraped-content.input'; +import { ScrapedContentOutput } from '../../scraper/dto/scraped-content.output'; + +/** + * Article ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์„ ๋‹ด๋‹นํ•˜๋Š” Service + */ +@Injectable() +export class ArticleService { + private readonly logger = new Logger(ArticleService.name); + + constructor(private readonly articleRepository: ArticleRepository) {} + + /** + * ์ƒˆ๋กœ์šด Article์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. + */ + async createArticle(userId: string, input: CreateArticleInput): Promise { + try { + // ์ค‘๋ณต ์ฒดํฌ + const existing = await this.articleRepository.findByUrlAndUserId(input.url, userId); + if (existing) { + throw new BadRequestException('์ด๋ฏธ ์ €์žฅ๋œ URL์ž…๋‹ˆ๋‹ค.'); + } + + const article = await this.articleRepository.createArticle(userId, input); + return this.mapToOutput(article); + } catch (error) { + this.logger.error(`Failed to create article for user ${userId}: ${(error as Error).message}`); + throw error; + } + } + + /** + * ์Šคํฌ๋ž˜ํ•‘๋œ ์ฝ˜ํ…์ธ ๋ฅผ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค. + */ + async saveScrapedContent( + userId: string, + scrapedContent: ScrapedContentOutput, + options: Omit = {}, + ): Promise { + try { + // (B-3) XSS ๋ฐฉ์–ด: DB ์ €์žฅ ์ „ HTML ์‚ด๊ท  + const sanitizedContent = this.sanitizeHtml(scrapedContent.content); + + // ๋‹จ์–ด ์ˆ˜์™€ ์ฝ๊ธฐ ์‹œ๊ฐ„ ๊ณ„์‚ฐ + const wordCount = this.calculateWordCount(sanitizedContent); + const readingTime = this.calculateReadingTime(wordCount); + + const articleData: CreateArticleInput = { + url: scrapedContent.finalUrl, // ์›๋ณธ URL ๋Œ€์‹  ์ตœ์ข… URL ์‚ฌ์šฉ + finalUrl: scrapedContent.finalUrl, + title: scrapedContent.title, + content: sanitizedContent, // ์‚ด๊ท ๋œ ์ฝ˜ํ…์ธ  ์ €์žฅ + contentType: scrapedContent.contentType, + wordCount, + readingTime, + tags: options.tags || [], + isBookmarked: options.isBookmarked || false, + isArchived: options.isArchived || false, + }; + + // upsert๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์ค‘๋ณต ์ฒ˜๋ฆฌ + const article = await this.articleRepository.upsertArticle(userId, articleData); + return this.mapToOutput(article); + } catch (error) { + this.logger.error(`Failed to save scraped content for user ${userId}: ${(error as Error).message}`); + throw error; + } + } + + /** + * Article์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. + */ + async getArticle(userId: string, articleId: string): Promise { + const article = await this.articleRepository.findById(articleId, userId); + if (!article) { + throw new NotFoundException('Article์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'); + } + return this.mapToOutput(article); + } + + /** + * Article์„ ์—…๋ฐ์ดํŠธํ•ฉ๋‹ˆ๋‹ค. + */ + async updateArticle(userId: string, articleId: string, input: UpdateArticleInput): Promise { + const article = await this.articleRepository.updateArticle(articleId, userId, input); + if (!article) { + throw new NotFoundException('Article์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'); + } + return this.mapToOutput(article); + } + + /** + * Article์„ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค. + */ + async deleteArticle(userId: string, articleId: string): Promise { + const success = await this.articleRepository.deleteArticle(articleId, userId); + if (!success) { + throw new NotFoundException('Article์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'); + } + } + + /** + * ์‚ฌ์šฉ์ž์˜ Article ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. + */ + async getArticles(userId: string, input: ListArticlesInput): Promise { + const { articles, total } = await this.articleRepository.findArticlesByUserId(userId, input); + + const page = input.page || 1; + const limit = input.limit || 20; + const totalPages = Math.ceil(total / limit); + + return { + articles: articles.map((article) => this.mapToOutput(article)), + total, + page, + limit, + totalPages, + hasNext: page < totalPages, + hasPrev: page > 1, + }; + } + + /** + * ์‚ฌ์šฉ์ž์˜ Article ํ†ต๊ณ„๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. + */ + async getArticleStats(userId: string): Promise<{ + total: number; + bookmarked: number; + archived: number; + recent: number; + }> { + return this.articleRepository.getArticleStats(userId); + } + + /** + * ์‚ฌ์šฉ์ž์˜ ๋ชจ๋“  ํƒœ๊ทธ๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. + */ + async getUserTags(userId: string): Promise { + return this.articleRepository.getUserTags(userId); + } + + /** + * Article ๋ถ๋งˆํฌ ์ƒํƒœ๋ฅผ ํ† ๊ธ€ํ•ฉ๋‹ˆ๋‹ค. + */ + async toggleBookmark(userId: string, articleId: string): Promise { + const article = await this.articleRepository.findById(articleId, userId); + if (!article) { + throw new NotFoundException('Article์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'); + } + + const updated = await this.articleRepository.updateArticle(articleId, userId, { + isBookmarked: !article.isBookmarked, + }); + + return this.mapToOutput(updated!); + } + + /** + * Article ์•„์นด์ด๋ธŒ ์ƒํƒœ๋ฅผ ํ† ๊ธ€ํ•ฉ๋‹ˆ๋‹ค. + */ + async toggleArchive(userId: string, articleId: string): Promise { + const article = await this.articleRepository.findById(articleId, userId); + if (!article) { + throw new NotFoundException('Article์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'); + } + + const updated = await this.articleRepository.updateArticle(articleId, userId, { + isArchived: !article.isArchived, + }); + + return this.mapToOutput(updated!); + } + + /** + * URL์ด ์ด๋ฏธ ์ €์žฅ๋˜์–ด ์žˆ๋Š”์ง€ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค. + */ + async isUrlAlreadySaved(userId: string, url: string): Promise { + const article = await this.articleRepository.findByUrlAndUserId(url, userId); + return !!article; + } + + // ==================== PRIVATE HELPERS ==================== + + /** + * Sanitize HTML content to prevent XSS attacks. + * @param html The HTML content to sanitize. + * @returns The sanitized HTML content. + */ + private sanitizeHtml(html?: string): string | undefined { + if (!html) { + return undefined; + } + // JSDOM์„ ์‚ฌ์šฉํ•˜์—ฌ ์„œ๋ฒ„ ์‚ฌ์ด๋“œ์—์„œ DOM ํ™˜๊ฒฝ์„ ๋งŒ๋“ญ๋‹ˆ๋‹ค. + const window = new JSDOM('').window; + const purify = DOMPurify(window); + // ๊ธฐ๋ณธ ์„ค์ •์œผ๋กœ HTML์„ ์‚ด๊ท ํ•ฉ๋‹ˆ๋‹ค. + return purify.sanitize(html); + } + + /** + * Article ์—”ํ‹ฐํ‹ฐ๋ฅผ ArticleOutput DTO๋กœ ๋ณ€ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + */ + private mapToOutput(article: Article & { user?: { id: string; email: string; name: string } }): ArticleOutput { + return { + id: article.id, + url: article.url, + finalUrl: article.finalUrl, + title: article.title ?? undefined, + content: article.content ?? undefined, + contentType: article.contentType ?? undefined, + summary: article.summary ?? undefined, + author: article.author ?? undefined, + publishedAt: article.publishedAt ?? undefined, + wordCount: article.wordCount ?? undefined, + readingTime: article.readingTime ?? undefined, + tags: article.tags, + isBookmarked: article.isBookmarked, + isArchived: article.isArchived, + userId: article.userId, + createdAt: article.createdAt, + updatedAt: article.updatedAt, + }; + } + + /** + * ์ฝ˜ํ…์ธ ์˜ ๋‹จ์–ด ์ˆ˜๋ฅผ ๊ณ„์‚ฐํ•ฉ๋‹ˆ๋‹ค. + */ + private calculateWordCount(content?: string): number { + if (!content) return 0; + + // HTML ํƒœ๊ทธ ์ œ๊ฑฐ + const textContent = content.replace(/<[^>]*>/g, ''); + + // ๋‹จ์–ด ์ˆ˜ ๊ณ„์‚ฐ (๊ณต๋ฐฑ ๊ธฐ์ค€) + const words = textContent.trim().split(/\s+/); + return words.length > 0 && words[0] !== '' ? words.length : 0; + } + + /** + * ์˜ˆ์ƒ ์ฝ๊ธฐ ์‹œ๊ฐ„์„ ๊ณ„์‚ฐํ•ฉ๋‹ˆ๋‹ค (๋ถ„ ๋‹จ์œ„). + */ + private calculateReadingTime(wordCount: number): number { + // ํ‰๊ท  ์ฝ๊ธฐ ์†๋„: 200-250 ๋‹จ์–ด/๋ถ„ + const wordsPerMinute = 225; + const minutes = Math.ceil(wordCount / wordsPerMinute); + return Math.max(1, minutes); // ์ตœ์†Œ 1๋ถ„ + } +} diff --git a/src/modules/auth/__tests__/services/auth.service.spec.ts b/src/modules/auth/__tests__/services/auth.service.spec.ts index 33ad301..16b4bbf 100644 --- a/src/modules/auth/__tests__/services/auth.service.spec.ts +++ b/src/modules/auth/__tests__/services/auth.service.spec.ts @@ -55,8 +55,8 @@ describe('AuthService', () => { it('์ด๋ฉ”์ผ์ด undefined/null์ด๋ฉด NotFoundException์„ ๋˜์ง„๋‹ค', async () => { userService.getUserByEmail.mockResolvedValue(null); - await expect(service.validateUser(undefined as any)).rejects.toThrow(NotFoundException); - await expect(service.validateUser(null as any)).rejects.toThrow(NotFoundException); + await expect(service.validateUser(undefined as unknown as string)).rejects.toThrow(NotFoundException); + await expect(service.validateUser(null as unknown as string)).rejects.toThrow(NotFoundException); }); }); }); diff --git a/src/modules/auth/__tests__/services/token.service.spec.ts b/src/modules/auth/__tests__/services/token.service.spec.ts index 7ef4466..f44dceb 100644 --- a/src/modules/auth/__tests__/services/token.service.spec.ts +++ b/src/modules/auth/__tests__/services/token.service.spec.ts @@ -44,7 +44,9 @@ describe('TokenService', () => { deleteMany: jest.fn(), create: jest.fn(), prisma: { - $transaction: jest.fn((callback: (tx: any) => unknown) => callback(refreshTokenRepository)), + $transaction: jest.fn((callback: (tx: unknown) => Promise) => + callback(refreshTokenRepository), + ), }, }; @@ -119,9 +121,11 @@ describe('TokenService', () => { describe('generateTokenPair', () => { it('์•ก์„ธ์Šค ํ† ํฐ๊ณผ ๋ฆฌํ”„๋ ˆ์‹œ ํ† ํฐ ์Œ์„ ์ƒ์„ฑํ•˜๊ณ  ์ €์žฅํ•œ๋‹ค', async () => { jwtService.sign.mockReturnValueOnce(mockAccessToken).mockReturnValueOnce(mockRefreshToken); - refreshTokenRepository.prisma.$transaction.mockImplementation(async (cb: any) => { - await cb(refreshTokenRepository); - }); + refreshTokenRepository.prisma.$transaction.mockImplementation( + async (cb: (tx: unknown) => Promise) => { + await cb(refreshTokenRepository); + }, + ); refreshTokenRepository.deleteMany.mockResolvedValue(undefined); refreshTokenRepository.create.mockResolvedValue(undefined); @@ -149,9 +153,11 @@ describe('TokenService', () => { refreshTokenRepository.findFirst.mockResolvedValue({ id: '1' }); userService.getUserByEmail.mockResolvedValue(mockUser); jwtService.sign.mockReturnValueOnce('new-access-token').mockReturnValueOnce('new-refresh-token'); - refreshTokenRepository.prisma.$transaction.mockImplementation(async (cb: any) => { - await cb(refreshTokenRepository); - }); + refreshTokenRepository.prisma.$transaction.mockImplementation( + async (cb: (tx: unknown) => Promise) => { + await cb(refreshTokenRepository); + }, + ); refreshTokenRepository.deleteMany.mockResolvedValue(undefined); refreshTokenRepository.create.mockResolvedValue(undefined); @@ -206,16 +212,18 @@ describe('TokenService', () => { }); it('refreshToken์ด undefined/null์ด๋ฉด UnauthorizedException์„ ๋˜์ง„๋‹ค', async () => { - await expect(service.refreshTokens(undefined as any)).rejects.toThrow(UnauthorizedException); - await expect(service.refreshTokens(null as any)).rejects.toThrow(UnauthorizedException); + await expect(service.refreshTokens(undefined as unknown as string)).rejects.toThrow(UnauthorizedException); + await expect(service.refreshTokens(null as unknown as string)).rejects.toThrow(UnauthorizedException); }); }); describe('saveRefreshToken', () => { it('๋ฆฌํ”„๋ ˆ์‹œ ํ† ํฐ์„ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์ €์žฅํ•œ๋‹ค', async () => { - refreshTokenRepository.prisma.$transaction.mockImplementation(async (cb: any) => { - await cb(refreshTokenRepository); - }); + refreshTokenRepository.prisma.$transaction.mockImplementation( + async (cb: (tx: unknown) => Promise) => { + await cb(refreshTokenRepository); + }, + ); refreshTokenRepository.deleteMany.mockResolvedValue(undefined); refreshTokenRepository.create.mockResolvedValue(undefined); @@ -230,7 +238,7 @@ describe('TokenService', () => { { token: mockRefreshToken, isValid: true, - expiresAt: expect.any(Date) as Date, + expiresAt: expect.any(Date), user: { connect: { id: mockUser.id } }, }, refreshTokenRepository, diff --git a/src/modules/auth/repositories/__tests__/refresh-token.repository.spec.ts b/src/modules/auth/repositories/__tests__/refresh-token.repository.spec.ts index 4e6d416..61ad304 100644 --- a/src/modules/auth/repositories/__tests__/refresh-token.repository.spec.ts +++ b/src/modules/auth/repositories/__tests__/refresh-token.repository.spec.ts @@ -1,13 +1,10 @@ import { RefreshTokenRepository } from '../refresh-token.repository'; import { PrismaService } from 'src/database/prisma.service'; -import { UserFactory } from '../../../../../test/factories/user.factory'; describe('RefreshTokenRepository', () => { let repo: RefreshTokenRepository; let prisma: PrismaService; - let mockUser: any; - beforeEach(() => { prisma = { refreshToken: { @@ -17,7 +14,6 @@ describe('RefreshTokenRepository', () => { }, } as any; repo = new RefreshTokenRepository(prisma); - mockUser = UserFactory.create(); }); it('should be defined', () => { @@ -26,16 +22,19 @@ describe('RefreshTokenRepository', () => { it('should call findFirst', async () => { await repo.findFirst({ where: {} }); - expect(prisma.refreshToken.findFirst).toBeCalled(); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(prisma.refreshToken.findFirst).toHaveBeenCalled(); }); it('should call deleteMany', async () => { await repo.deleteMany({ where: {} }); - expect(prisma.refreshToken.deleteMany).toBeCalled(); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(prisma.refreshToken.deleteMany).toHaveBeenCalled(); }); it('should call create', async () => { await repo.create({ token: 't', expiresAt: new Date(), user: { connect: { id: '1' } } }); - expect(prisma.refreshToken.create).toBeCalled(); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(prisma.refreshToken.create).toHaveBeenCalled(); }); }); diff --git a/src/modules/auth/services/token.service.ts b/src/modules/auth/services/token.service.ts index 70c44bd..1e00ae8 100644 --- a/src/modules/auth/services/token.service.ts +++ b/src/modules/auth/services/token.service.ts @@ -104,9 +104,8 @@ export class TokenService { await this.removeRefreshToken(refreshToken); return await this.generateTokenPair(user); - } catch (error: unknown) { - // ๋””๋ฒ„๊น…์„ ์œ„ํ•œ ๋กœ๊ทธ๋งŒ ๋‚จ๊ธฐ๊ณ , ํด๋ผ์ด์–ธํŠธ์—๋Š” ์ผ๊ด€๋œ ๋ฉ”์‹œ์ง€ ๋ฐ˜ํ™˜ - this.logger.debug('Token refresh failed', error); + } catch (error) { + this.logger.warn(`Token refresh failed: ${(error as Error).message}`); throw new UnauthorizedException('Invalid refresh token'); } } @@ -124,16 +123,13 @@ export class TokenService { tx, ); }); - this.logger.debug(`RefreshToken ์ €์žฅ: ${userId}`); } async removeRefreshToken(token: string): Promise { await this.refreshTokenRepository.deleteMany({ where: { token } }); - this.logger.debug(`RefreshToken ์‚ญ์ œ: ${token}`); } async logout(userId: string): Promise { await this.refreshTokenRepository.deleteMany({ where: { userId } }); - this.logger.debug(`๋ชจ๋“  RefreshToken ์‚ญ์ œ(๋กœ๊ทธ์•„์›ƒ): ${userId}`); } } diff --git a/src/modules/auth/strategies/__tests__/google.strategy.spec.ts b/src/modules/auth/strategies/__tests__/google.strategy.spec.ts index e0cb605..d51f180 100644 --- a/src/modules/auth/strategies/__tests__/google.strategy.spec.ts +++ b/src/modules/auth/strategies/__tests__/google.strategy.spec.ts @@ -1,18 +1,28 @@ import { GoogleStrategy } from '../google.strategy'; import { OAuthService } from '../../services/oauth.service'; +import { ConfigType } from '@nestjs/config'; +import authConfig from 'src/config/auth.config'; +import { GoogleProfile } from 'src/types'; describe('GoogleStrategy', () => { let strategy: GoogleStrategy; - let oauthService: OAuthService; - let config: any; + let oauthService: jest.Mocked; + let config: ConfigType; beforeEach(() => { - oauthService = { handleGoogleLogin: jest.fn() } as any; + oauthService = { + handleGoogleLogin: jest.fn(), + } as unknown as jest.Mocked; + config = { GOOGLE_CLIENT_ID: 'id', GOOGLE_CLIENT_SECRET: 'secret', GOOGLE_CALLBACK_URL: 'url', - }; + JWT_SECRET: 'secret', + JWT_REFRESH_EXPIRES_IN: '7d', + JWT_ACCESS_EXPIRES_IN: '15m', + } as unknown as ConfigType; + strategy = new GoogleStrategy(config, oauthService); }); @@ -22,6 +32,6 @@ describe('GoogleStrategy', () => { it('should throw if user not found', async () => { (oauthService.handleGoogleLogin as jest.Mock).mockResolvedValue(null); - await expect(strategy.validate('a', 'b', {} as any)).rejects.toThrow(); + await expect(strategy.validate('a', 'b', {} as unknown as GoogleProfile)).rejects.toThrow(); }); }); diff --git a/src/modules/pre-handler/base/abstract-content-handler.ts b/src/modules/pre-handler/base/abstract-content-handler.ts new file mode 100644 index 0000000..f306fc6 --- /dev/null +++ b/src/modules/pre-handler/base/abstract-content-handler.ts @@ -0,0 +1,184 @@ +/** + * ์ถ”์ƒ ํ•ธ๋“ค๋Ÿฌ ๋ฒ ์ด์Šค ํด๋ž˜์Šค (ํ…œํ”Œ๋ฆฟ ๋ฉ”์„œ๋“œ ํŒจํ„ด) + * - SOLID ์›์น™ ๋ฐ ํ•จ์ˆ˜ํ˜• ํ”„๋กœ๊ทธ๋ž˜๋ฐ ๊ธฐ๋ฐ˜ + */ +import { Logger } from '@nestjs/common'; +import { JSDOM } from 'jsdom'; +import { IContentHandler } from '../interfaces/content-handler.interface'; +import { PreHandleResult } from '../dto/pre-handle-result.dto'; +import { + HttpRequestConfig, + DomConfig, + ContentCleaningConfig, + TitleExtractionConfig, + ContentExtractionResult, +} from '../types/content-extraction.types'; +import { fetchHtml, createDom, extractTitle, findContentElement, Result, Option } from '../utils/functional-utils'; +import { createContentCleaningPipeline } from '../utils/content-cleaning-pipeline'; + +/** + * ์ฝ˜ํ…์ธ  ํ•ธ๋“ค๋Ÿฌ์˜ ๊ณตํ†ต ์ถ”์ƒ ํด๋ž˜์Šค + * - ํ…œํ”Œ๋ฆฟ ๋ฉ”์„œ๋“œ ํŒจํ„ด ๊ธฐ๋ฐ˜ + * - ๊ณตํ†ต ๋กœ์ง์„ ์ถ”์ƒํ™”ํ•˜๊ณ , ๊ตฌ์ฒด ๊ตฌํ˜„์€ ํ•˜์œ„ ํด๋ž˜์Šค์— ์œ„์ž„ + */ +export abstract class AbstractContentHandler implements IContentHandler { + protected readonly logger = new Logger(this.constructor.name); + + /** + * ํ•ธ๋“ค๋Ÿฌ๊ฐ€ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ๋Š” URL์ธ์ง€ ํ™•์ธ + * @param url ๊ฒ€์‚ฌํ•  URL + */ + public abstract canHandle(url: URL): boolean; + + /** + * ํ•ธ๋“ค๋Ÿฌ ์ด๋ฆ„ (๋กœ๊น…์šฉ) + */ + protected abstract get handlerName(): string; + + /** + * HTTP ์š”์ฒญ ์„ค์ • + */ + protected abstract get httpConfig(): HttpRequestConfig; + + /** + * DOM ์ƒ์„ฑ ์„ค์ • + */ + protected abstract get domConfig(): DomConfig; + + /** + * ์ฝ˜ํ…์ธ  ์ •์ œ ์„ค์ • + */ + protected abstract get cleaningConfig(): ContentCleaningConfig; + + /** + * ์ œ๋ชฉ ์ถ”์ถœ ์„ค์ • + */ + protected abstract get titleConfig(): TitleExtractionConfig; + + /** + * ์ฝ˜ํ…์ธ  ์„ ํƒ์ž๋“ค + */ + protected abstract get contentSelectors(): readonly string[]; + + /** + * ํ…œํ”Œ๋ฆฟ ๋ฉ”์„œ๋“œ: ํ•ธ๋“ค๋ง ํ”„๋กœ์„ธ์Šค + * @param url ์ฒ˜๋ฆฌํ•  URL + * @returns ์ถ”์ถœ ๊ฒฐ๊ณผ ๋˜๋Š” null + */ + public async handle(url: URL): Promise { + try { + const processedUrl = this.preProcessUrl(url); + this.logger.debug(`${this.handlerName} ์ฝ˜ํ…์ธ  ์ถ”์ถœ ์‹œ์ž‘: ${processedUrl.href}`); + + const result = await this.extractContent(processedUrl); + + if (!result.success) { + this.logger.debug(`${this.handlerName} ์ฝ˜ํ…์ธ  ์ถ”์ถœ ์‹คํŒจ: ${result.error.message}`); + return null; + } + + const { title } = result.data; + let content = result.data.content; + + if (!content) { + this.logger.debug(`${this.handlerName} ์ฝ˜ํ…์ธ  ์—†์Œ: ${processedUrl.href}`); + // ์ฝ˜ํ…์ธ ๊ฐ€ ์—†์–ด๋„ ์ œ๋ชฉ์ด๋ผ๋„ ์žˆ์œผ๋ฉด ๋ฐ˜ํ™˜ํ•ด์ค€๋‹ค. + return title ? { url: processedUrl.href, title, contentType: 'text/html' } : null; + } + + // ํ›„์ฒ˜๋ฆฌ ํ›… ํ˜ธ์ถœ + content = this.postProcess(content, processedUrl); + + this.logger.log(`${this.handlerName} ์ฝ˜ํ…์ธ  ์ถ”์ถœ ์„ฑ๊ณต: ${content.length} ๊ธ€์ž`); + return { + url: processedUrl.href, + title, + content, + contentType: 'text/html', + }; + } catch (error) { + this.logger.warn(`${this.handlerName} ํ•ธ๋“ค๋Ÿฌ ์ฒ˜๋ฆฌ ์‹คํŒจ ${url.href}: ${(error as Error).message}`); + return null; + } + } + + /** + * ์ฝ˜ํ…์ธ  ์ถ”์ถœ ์ „ URL์„ ๊ฐ€๊ณตํ•˜๊ธฐ ์œ„ํ•œ ํ›… ๋ฉ”์„œ๋“œ. + * ์ž์‹ ํด๋ž˜์Šค์—์„œ ํ•„์š”์— ๋”ฐ๋ผ ์˜ค๋ฒ„๋ผ์ด๋“œํ•˜์—ฌ URL์„ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + * @param url ์›๋ณธ URL + * @returns ๊ฐ€๊ณต๋œ URL + */ + protected preProcessUrl(url: URL): URL { + return url; + } + + /** + * ์ฝ˜ํ…์ธ  ์ถ”์ถœ ํ›„ ํ›„์ฒ˜๋ฆฌ๋ฅผ ์œ„ํ•œ ํ›… ๋ฉ”์„œ๋“œ. + * ์ž์‹ ํด๋ž˜์Šค์—์„œ ํ•„์š”์— ๋”ฐ๋ผ ์˜ค๋ฒ„๋ผ์ด๋“œํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + * @param content ์ถ”์ถœ๋œ HTML ์ฝ˜ํ…์ธ  + * @param url ๊ฐ€๊ณต๋œ URL + * @returns ํ›„์ฒ˜๋ฆฌ๋œ HTML ์ฝ˜ํ…์ธ  + */ + protected postProcess(content: string, _url: URL): string { + // ๊ธฐ๋ณธ์ ์œผ๋กœ๋Š” ์•„๋ฌด ์ž‘์—…๋„ ํ•˜์ง€ ์•Š์Œ + return content; + } + + /** + * ์ฝ˜ํ…์ธ  ์ถ”์ถœ (ํ•จ์ˆ˜ํ˜• ํ”„๋กœ๊ทธ๋ž˜๋ฐ) + * @param url ์ฒ˜๋ฆฌํ•  URL + * @returns ์ถ”์ถœ ๊ฒฐ๊ณผ Result + */ + protected async extractContent(url: URL): Promise> { + const htmlResult = await fetchHtml(url.href, this.httpConfig); + if (!htmlResult.success) { + return { success: false, error: htmlResult.error }; + } + const domResult = createDom(htmlResult.data, this.domConfig); + if (!domResult.success) { + return { success: false, error: domResult.error }; + } + return { success: true, data: await this.processDom(domResult.data, url.href) }; + } + + /** + * DOM ์ฒ˜๋ฆฌ ๋ฐ ์ฝ˜ํ…์ธ  ์ •์ œ + * @param dom JSDOM ์ธ์Šคํ„ด์Šค + * @param url ๊ธฐ์ค€ URL + * @returns ์ถ”์ถœ ๊ฒฐ๊ณผ + */ + private async processDom(dom: JSDOM, url: string): Promise { + const document = dom.window.document; + // ๋™์  ์ฝ˜ํ…์ธ  ๋Œ€๊ธฐ (waitForDynamicContent๊ฐ€ ์žˆ์œผ๋ฉด ์•ˆ์ „ํ•˜๊ฒŒ ํ˜ธ์ถœ) + const maybeWithWait = this as unknown as { waitForDynamicContent?: (doc: Document) => Promise }; + if (typeof maybeWithWait.waitForDynamicContent === 'function') { + await maybeWithWait.waitForDynamicContent(document); + } + // ์ œ๋ชฉ ์ถ”์ถœ + const titleOption: Option = extractTitle( + document, + this.titleConfig.selectors, + this.titleConfig.patterns, + ); + const title: string | undefined = titleOption == null ? undefined : titleOption; + // ์ฝ˜ํ…์ธ  ์š”์†Œ ์ฐพ๊ธฐ (minTextLength 80, logger ์ „๋‹ฌ) + const contentElement = findContentElement(document, this.contentSelectors, 80, this.logger); + if (!contentElement) { + this.logger.debug(`${this.handlerName} ๋ณธ๋ฌธ ์š”์†Œ๋ฅผ ์ฐพ์ง€ ๋ชปํ•ด null ๋ฐ˜ํ™˜`); + return { title, contentType: 'text/html', url }; + } + // ์ฝ˜ํ…์ธ  ์ •์ œ + const cleaningPipeline = createContentCleaningPipeline(this.cleaningConfig); + const cleanedElement = cleaningPipeline(contentElement, { + baseUrl: url, + config: this.cleaningConfig, + logger: this.logger, + }); + return { + title, + content: cleanedElement.outerHTML, + contentType: 'text/html', + url, + }; + } +} diff --git a/src/modules/pre-handler/dto/pre-handle-result.dto.ts b/src/modules/pre-handler/dto/pre-handle-result.dto.ts new file mode 100644 index 0000000..feb60a5 --- /dev/null +++ b/src/modules/pre-handler/dto/pre-handle-result.dto.ts @@ -0,0 +1,34 @@ +/** + * DTO for the result of a pre-handling process. + * It encapsulates the data extracted by a content handler. + */ +export class PreHandleResult { + /** + * The final URL after potential redirects or modifications by a handler. + */ + url: string; + + /** + * The extracted title of the content, if available. + * @optional + */ + title?: string; + + /** + * The extracted main content, typically in HTML format. + * @optional + */ + content?: string; + + /** + * The MIME type of the content (e.g., 'text/html', 'application/pdf'). + * @optional + */ + contentType?: string; + + /** + * The name of the handler that successfully processed the URL. + * @optional + */ + handlerUsed?: string; +} diff --git a/src/modules/pre-handler/factories/handler-factory.ts b/src/modules/pre-handler/factories/handler-factory.ts new file mode 100644 index 0000000..f818488 --- /dev/null +++ b/src/modules/pre-handler/factories/handler-factory.ts @@ -0,0 +1,45 @@ +/** + * ํ•ธ๋“ค๋Ÿฌ ํŒฉํ† ๋ฆฌ + * - ๋„๋ฉ”์ธ๋ณ„ ํ•ธ๋“ค๋Ÿฌ๋ฅผ DI ๋ฐ›์•„ URL์— ๋”ฐ๋ผ ์ ์ ˆํ•œ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๋ฐ˜ํ™˜ + * - getAllHandlers()๋กœ ์ „์ฒด ํ•ธ๋“ค๋Ÿฌ ๋ฐฐ์—ด ๋ฐ˜ํ™˜ + */ +import { Inject, Injectable } from '@nestjs/common'; +import { IContentHandler, CONTENT_HANDLER_TOKEN } from '../interfaces/content-handler.interface'; + +/** + * ํ•ธ๋“ค๋Ÿฌ ํŒฉํ† ๋ฆฌ ํด๋ž˜์Šค + * + * @description + * NestJS์˜ Custom Provider์™€ Injection Token์„ ์‚ฌ์šฉํ•˜์—ฌ ๋ชจ๋“  ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๋™์ ์œผ๋กœ ์ฃผ์ž…๋ฐ›์Šต๋‹ˆ๋‹ค. + * ์ด๋ฅผ ํ†ตํ•ด ์ƒˆ๋กœ์šด ํ•ธ๋“ค๋Ÿฌ๊ฐ€ ์ถ”๊ฐ€๋˜์–ด๋„ ํŒฉํ† ๋ฆฌ ์ฝ”๋“œ๋ฅผ ์ˆ˜์ •ํ•  ํ•„์š”๊ฐ€ ์—†์œผ๋ฏ€๋กœ + * OCP(๊ฐœ๋ฐฉ-ํ์‡„ ์›์น™)๋ฅผ ์ค€์ˆ˜ํ•ฉ๋‹ˆ๋‹ค. ํ•ธ๋“ค๋Ÿฌ์˜ ์‹คํ–‰ ์ˆœ์„œ๋Š” pre-handler.module.ts์—์„œ ๊ด€๋ฆฌ๋ฉ๋‹ˆ๋‹ค. + */ +@Injectable() +export class HandlerFactory { + constructor( + @Inject(CONTENT_HANDLER_TOKEN) + private readonly handlerChain: IContentHandler[], + ) {} + + /** + * URL์— ์ ํ•ฉํ•œ ํ•ธ๋“ค๋Ÿฌ ๋ฐ˜ํ™˜ (์šฐ์„ ์ˆœ์œ„ ์ˆœํšŒ) + * @param url URL ๊ฐ์ฒด + * @returns IContentHandler + */ + public createHandler(url: URL): IContentHandler { + for (const handler of this.handlerChain) { + if (handler.canHandle(url)) { + return handler; + } + } + // ์ด๋ก ์ƒ ๋„๋‹ฌ ๋ถˆ๊ฐ€ (readability๊ฐ€ ํ•ญ์ƒ true) + throw new Error('No suitable handler found for this URL'); + } + + /** + * ์ „์ฒด ํ•ธ๋“ค๋Ÿฌ ๋ฐฐ์—ด ๋ฐ˜ํ™˜ + */ + public getAllHandlers(): IContentHandler[] { + return this.handlerChain; + } +} diff --git a/src/modules/pre-handler/handlers/disquiet.handler.ts b/src/modules/pre-handler/handlers/disquiet.handler.ts new file mode 100644 index 0000000..d552d98 --- /dev/null +++ b/src/modules/pre-handler/handlers/disquiet.handler.ts @@ -0,0 +1,361 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { AbstractContentHandler } from '../base/abstract-content-handler'; +import { + DomConfig, + HttpRequestConfig, + TitleExtractionConfig, + ContentCleaningConfig, + ContentExtractionResult, +} from '../types/content-extraction.types'; +import { JSDOM } from 'jsdom'; +import { fetchHtml, createDom, extractTitle } from '../utils/functional-utils'; +import { createContentCleaningPipeline } from '../utils/content-cleaning-pipeline'; +import { Result } from '../utils/functional-utils'; + +/** + * Disquiet.io ์‚ฌ์ดํŠธ ์ „์šฉ ํ•ธ๋“ค๋Ÿฌ + * + * Disquiet.io๋Š” ๋กœ๊ทธ์ธ ์œ ๋„ ํŒ์—…์ด ๋‚˜ํƒ€๋‚˜๋Š” ๋ฌธ์ œ๊ฐ€ ์žˆ์–ด์„œ, + * ํŠน๋ณ„ํ•œ ์ฒ˜๋ฆฌ๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. + * + * ์ฃผ์š” ํŠน์ง•: + * - ๋กœ๊ทธ์ธ ํŒ์—… ์ œ๊ฑฐ + * - ๋™์  ์ฝ˜ํ…์ธ  ๋กœ๋”ฉ ๋Œ€๊ธฐ + * - ํŠน์ • CSS ์„ ํƒ์ž๋กœ ์ฝ˜ํ…์ธ  ์ถ”์ถœ + */ +@Injectable() +export class DisquietHandler extends AbstractContentHandler { + protected readonly logger = new Logger(DisquietHandler.name); + + /** + * ํ•ธ๋“ค๋Ÿฌ๊ฐ€ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ๋Š” URL์ธ์ง€ ํ™•์ธ + * @param url ๊ฒ€์‚ฌํ•  URL + * @returns true if can handle + */ + public canHandle(url: URL): boolean { + const result = url.hostname.endsWith('disquiet.io'); + this.logger.debug(`DisquietHandler canHandle: ${url.hostname} -> ${result}`); + return result; + } + + /** + * ํ•ธ๋“ค๋Ÿฌ ์ด๋ฆ„ + */ + protected get handlerName(): string { + return 'DisquietHandler'; + } + + /** + * HTTP ์š”์ฒญ ์„ค์ • + */ + protected get httpConfig(): HttpRequestConfig { + return { + userAgent: + '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', + timeout: 30000, + headers: { + Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', + 'Accept-Language': 'ko-KR,ko;q=0.9,en;q=0.8', + 'Accept-Encoding': 'gzip, deflate, br', + DNT: '1', + Connection: 'keep-alive', + 'Upgrade-Insecure-Requests': '1', + }, + redirect: 'follow', + }; + } + + /** + * DOM ์ƒ์„ฑ ์„ค์ • (์Šคํฌ๋ฆฝํŠธ ํ™œ์„ฑํ™”๋กœ ๋™์  ์ฝ˜ํ…์ธ  ์ฒ˜๋ฆฌ) + */ + protected get domConfig(): DomConfig { + return { + userAgent: this.httpConfig.userAgent, + resources: 'usable', + runScripts: 'dangerously', // ๋™์  ์ฝ˜ํ…์ธ ๋ฅผ ์œ„ํ•ด ์Šคํฌ๋ฆฝํŠธ ์‹คํ–‰ + pretendToBeVisual: true, + }; + } + + /** + * ์ฝ˜ํ…์ธ  ์ •์ œ ์„ค์ • + */ + protected get cleaningConfig(): ContentCleaningConfig { + return { + removeUnwantedElements: true, + cleanupStyles: false, + cleanupLinks: false, + cleanupImages: false, + cleanupText: false, + refineTitle: false, + }; + } + + /** + * ์ œ๋ชฉ ์ถ”์ถœ ์„ค์ • + */ + protected get titleConfig(): TitleExtractionConfig { + return { + selectors: [ + 'h1', + '.post-title', + '.article-title', + '[data-testid="post-title"]', + 'header h1', + 'header .title', + 'meta[property="og:title"]', + '.title.detail-page', + '.title-wrapper .title', + // Disquiet.io ์ „์šฉ ์„ ํƒ์ž + '[data-testid="makerlog-title"]', + '.makerlog-title', + '.post-header h1', + '.post-header .title', + ], + patterns: [], + siteSpecificPatterns: {}, + }; + } + + /** + * ์ฝ˜ํ…์ธ  ์„ ํƒ์ž (Disquiet.io ์ „์šฉ์œผ๋กœ ์ตœ์ ํ™”) + */ + protected get contentSelectors(): readonly string[] { + return [ + // Disquiet.io ์ „์šฉ ์„ ํƒ์ž (์šฐ์„ ์ˆœ์œ„ ๋†’์Œ) + '[data-testid="makerlog-content"]', + '[data-testid="post-content"]', + '.makerlog-content', + '.maker-log-detail', + + // ์ดํ•˜ ๊ธฐ์กด ์„ ํƒ์ž + '.post-content', + '.article-content', + '.content-wrapper', + '.content-area', + '.post-body', + '.article-body', + '.makerlog-body', + '.post-detail', + '.article-detail', + '.detail-content', + '.main-content', + '.content', + 'article', + 'main', + '.container', + '#content', + '.body', + '.markdown-body', + '.reader-content', + '.entry-content', + '.blog-post', + '.post', + '.detail', + '.detail-page', + // Disquiet.io ํŠน์ • ํด๋ž˜์Šค + '.sc-keuYuY', + '.sc-keuYuY.detail-page', + '.title.detail-page', + // ์ถ”๊ฐ€ Disquiet.io ์„ ํƒ์ž + '[class*="makerlog"]', + '[class*="post-"]', + '[class*="article-"]', + '[class*="content"]', + '[class*="body"]', + ]; + } + + /** + * ๋กœ๊ทธ์ธ ํŒ์—… ๋“ฑ ๋ถˆํ•„์š”ํ•œ ์š”์†Œ ์ œ๊ฑฐ (ํ›„์ฒ˜๋ฆฌ) + * @param dom JSDOM ์ธ์Šคํ„ด์Šค + */ + protected postProcessDom(dom: Document): void { + // Disquiet.io ์ „์šฉ ์ œ๊ฑฐ ์š”์†Œ (๋” ๊ตฌ์ฒด์ ์œผ๋กœ) + const removeSelectors = [ + // ๋กœ๊ทธ์ธ/์ธ์ฆ ๊ด€๋ จ + '[data-testid="login-modal"]', + '[data-testid="auth-modal"]', + '.login-modal', + '.auth-modal', + '.modal', + '.popup', + '.Dialog', + '.modal-backdrop', + '.overlay', + '.Dialog-overlay', + '.backdrop', + // ๋„ค๋น„๊ฒŒ์ด์…˜/ํ—ค๋”/ํ‘ธํ„ฐ + '.header', + '.footer', + '.nav', + '.navigation', + '.menu', + '.sidebar', + // ๊ด‘๊ณ /ํ”„๋กœ๋ชจ์…˜ + '.ad', + '.advertisement', + '.promo', + '.sponsor', + '.banner', + // ์†Œ์…œ/๊ณต์œ  + '.share', + '.social', + '.comment', + '.comments', + // ๊ธฐํƒ€ UI ์š”์†Œ + '.button', + '.actions', + '.toolbar', + '.widget', + '.tool', + '.search', + '.breadcrumb', + '.pagination', + '.page-nav', + // ๋ฉ”ํƒ€ ์ •๋ณด (์ œ๋ชฉ์€ ์œ ์ง€) + '.meta', + '.info', + '.date', + '.time', + '.author', + '.tag', + '.category', + '.label', + '.count', + '.view', + '.like', + '.dislike', + '.vote', + '.star', + '.rating', + // ๋ฏธ๋””์–ด (ํ…์ŠคํŠธ ์ฝ˜ํ…์ธ ์— ์ง‘์ค‘) + '.icon', + '.svg', + '.img', + '.figure', + '.caption', + '.gallery', + '.media', + '.video', + '.audio', + // ๊ด€๋ จ ์ฝ˜ํ…์ธ  + '.related', + '.related-posts', + '.related-articles', + '.recommend', + '.suggest', + '.popular', + '.trending', + '.recent', + // ๊ธฐ์ˆ ์  ์š”์†Œ + 'script', + 'style', + 'noscript', + 'iframe', + // ๊ธฐํƒ€ ๋ถˆํ•„์š” ์š”์†Œ + '.subscribe', + '.newsletter', + '.cookie', + '.consent', + '.file', + '.download', + '.attachment', + '.external', + '.internal', + '.link', + ]; + + // ์š”์†Œ ์ œ๊ฑฐ (์ฝ˜ํ…์ธ  ๋ณด์กด) + removeSelectors.forEach((selector) => { + dom.querySelectorAll(selector).forEach((el) => { + // ์ฝ˜ํ…์ธ ๊ฐ€ ํฌํ•จ๋œ ์š”์†Œ๋Š” ์ œ๊ฑฐํ•˜์ง€ ์•Š์Œ + const textContent = el.textContent?.trim(); + if (textContent && textContent.length > 50) { + this.logger.debug(`์ฝ˜ํ…์ธ  ๋ณด์กด: ${selector} (${textContent.length}๊ธ€์ž)`); + return; + } + el.remove(); + }); + }); + + this.logger.log('Disquiet.io ์ „์šฉ ์š”์†Œ ์ œ๊ฑฐ ์™„๋ฃŒ (์ฝ˜ํ…์ธ  ๋ณด์กด)'); + } + + /** + * ๋™์  ์ฝ˜ํ…์ธ  ๋กœ๋”ฉ์„ ์œ„ํ•œ ๋Œ€๊ธฐ ์‹œ๊ฐ„ ์ถ”๊ฐ€ + * @param dom JSDOM ์ธ์Šคํ„ด์Šค + */ + protected async waitForDynamicContent(dom: Document): Promise { + // Disquiet.io๋Š” ๋™์  ์ฝ˜ํ…์ธ  ๋กœ๋”ฉ์ด ํ•„์š”ํ•  ์ˆ˜ ์žˆ์Œ + await new Promise((resolve) => setTimeout(resolve, 2000)); + // ์ฝ˜ํ…์ธ ๊ฐ€ ๋กœ๋“œ๋˜์—ˆ๋Š”์ง€ ํ™•์ธ + const contentSelectors = this.contentSelectors; + for (const selector of contentSelectors) { + const elements = dom.querySelectorAll(selector); + for (const element of elements) { + const textContent = element.textContent?.trim(); + if (textContent && textContent.length > 100) { + this.logger.debug(`๋™์  ์ฝ˜ํ…์ธ  ํ™•์ธ: ${selector} (${textContent.length}๊ธ€์ž)`); + return; + } + } + } + } + + /** + * ์—ฌ๋Ÿฌ div(๋ณธ๋ฌธ ํŒŒํŽธ)๋ฅผ ๋ชจ๋‘ ํ•ฉ์ณ์„œ ๋ฐ˜ํ™˜ํ•˜๋Š” extractContent ์˜ค๋ฒ„๋ผ์ด๋“œ + */ + protected override async extractContent(url: URL): Promise> { + try { + const htmlResult = await fetchHtml(url.href, this.httpConfig); + if (!htmlResult.success) { + return { success: false, error: htmlResult.error }; + } + const domResult = createDom(htmlResult.data, this.domConfig); + if (!domResult.success) { + return { success: false, error: domResult.error }; + } + const dom: JSDOM = domResult.data; + const document = dom.window.document; + + // waitForDynamicContent๊ฐ€ ์กด์žฌํ•˜๋Š”์ง€ ํƒ€์ž… ๊ฐ€๋“œ๋กœ ์•ˆ์ „ํ•˜๊ฒŒ ํ˜ธ์ถœ + if ( + typeof (this as unknown as { waitForDynamicContent?: (doc: Document) => Promise }) + .waitForDynamicContent === 'function' + ) { + await ( + this as unknown as { waitForDynamicContent: (doc: Document) => Promise } + ).waitForDynamicContent(document); + } + // ์ œ๋ชฉ ์ถ”์ถœ + const titleOption = extractTitle(document, this.titleConfig.selectors, this.titleConfig.patterns); + const title: string | undefined = titleOption == null ? undefined : titleOption; + // ์—ฌ๋Ÿฌ div๋ฅผ ๋ชจ๋‘ ํ•ฉ์ณ์„œ ๋ณธ๋ฌธ์œผ๋กœ ์‚ฌ์šฉ + const elements = Array.from(document.querySelectorAll(this.contentSelectors.join(','))); + const content = elements.map((el) => el.innerHTML).join('\n'); + if (!content || content.trim().length < 10) { + this.logger.debug(`${this.handlerName} ๋ณธ๋ฌธ ์š”์†Œ๋ฅผ ์ฐพ์ง€ ๋ชปํ•ด body๋กœ fallback`); + return { success: true, data: { title, contentType: 'text/html', url: url.href } }; + } + // ์ฝ˜ํ…์ธ  ์ •์ œ + const cleaningPipeline = createContentCleaningPipeline(this.cleaningConfig); + const fakeElement = document.createElement('div'); + fakeElement.innerHTML = content; + const cleanedElement = cleaningPipeline(fakeElement, { + baseUrl: url.href, + config: this.cleaningConfig, + logger: this.logger, + }); + const result: ContentExtractionResult = { + title, + content: cleanedElement.outerHTML, + contentType: 'text/html', + url: url.href, + }; + return { success: true, data: result }; + } catch (error) { + return { success: false, error: error instanceof Error ? error : new Error(String(error)) }; + } + } +} diff --git a/src/modules/pre-handler/handlers/domain-specific.handler.ts b/src/modules/pre-handler/handlers/domain-specific.handler.ts new file mode 100644 index 0000000..15bbdab --- /dev/null +++ b/src/modules/pre-handler/handlers/domain-specific.handler.ts @@ -0,0 +1,492 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { IContentHandler } from '../interfaces/content-handler.interface'; +import { PreHandleResult } from '../dto/pre-handle-result.dto'; + +/** + * A map of domain names to their URL transformation functions. + * This allows for easy extension to support new domains. + * Note: Social media domains are handled by SocialMediaHandler, + * and news sites are handled by NewsSiteHandler. + */ +const DOMAIN_TRANSFORMATIONS: Record URL> = { + // Publishing platforms + 'substack.com': (url) => { + // Substack provides a clean AMP version by setting the search query. + const newUrl = new URL(url.href); + newUrl.search = '?format=amp'; + return newUrl; + }, + 'medium.com': (url) => { + // Clean up Medium URLs by removing tracking parameters + const newUrl = new URL(url.href); + newUrl.searchParams.delete('source'); + newUrl.searchParams.delete('gi'); + newUrl.searchParams.delete('sk'); + return newUrl; + }, + + // Developer platforms (non-social aspects) + 'github.com': (url) => { + // GitHub: For markdown files, get the raw content + if (url.pathname.includes('/blob/') && url.pathname.endsWith('.md')) { + return new URL(url.href.replace('/blob/', '/raw/')); + } + return url; + }, + 'gitlab.com': (url) => { + // GitLab: For markdown files, get the raw content + if (url.pathname.includes('/blob/') && url.pathname.endsWith('.md')) { + return new URL(url.href.replace('/blob/', '/raw/')); + } + return url; + }, + + // Knowledge platforms + 'wikipedia.org': (url) => { + // Wikipedia: Use mobile version for cleaner layout + const newUrl = new URL(url.href); + newUrl.hostname = newUrl.hostname.replace('en.wikipedia.org', 'm.wikipedia.org'); + return newUrl; + }, + + // Korean platforms + 'blog.naver.com': (url) => { + // Naver Blog: Use mobile version for better content extraction + // Convert https://blog.naver.com/username/postid to https://m.blog.naver.com/username/postid + const newUrl = new URL(url.href); + newUrl.hostname = 'm.blog.naver.com'; + return newUrl; + }, + 'cafe.naver.com': (url) => { + // Naver Cafe: Use mobile version + const newUrl = new URL(url.href); + newUrl.hostname = 'm.cafe.naver.com'; + return newUrl; + }, + 'post.naver.com': (url) => { + // Naver Post: Use mobile version + const newUrl = new URL(url.href); + newUrl.hostname = 'm.post.naver.com'; + return newUrl; + }, + 'stackoverflow.com': (url) => { + // Stack Overflow: Keep original, it's usually accessible + return url; + }, + + // Other platforms + 'notion.so': (url) => { + // Notion: Keep original, usually accessible + return url; + }, + 'hackernews.com': (url) => { + // Hacker News: Keep original + return url; + }, + 'news.ycombinator.com': (url) => { + // Y Combinator Hacker News: Keep original + return url; + }, + 'patreon.com': (url) => { + // Patreon: Keep original for posts + return url; + }, + 'ko-fi.com': (url) => { + // Ko-fi: Keep original + return url; + }, + 'buymeacoffee.com': (url) => { + // Buy Me a Coffee: Keep original + return url; + }, + 'gumroad.com': (url) => { + // Gumroad: Keep original + return url; + }, + 'itch.io': (url) => { + // Itch.io: Keep original + return url; + }, + 'deviantart.com': (url) => { + // DeviantArt: Keep original + return url; + }, + 'artstation.com': (url) => { + // ArtStation: Keep original + return url; + }, + 'behance.net': (url) => { + // Behance: Keep original + return url; + }, + 'dribbble.com': (url) => { + // Dribbble: Keep original + return url; + }, + 'figma.com': (url) => { + // Figma: Keep original for public files + return url; + }, + 'canva.com': (url) => { + // Canva: Keep original for public designs + return url; + }, + 'unsplash.com': (url) => { + // Unsplash: Keep original + return url; + }, + 'pexels.com': (url) => { + // Pexels: Keep original + return url; + }, + 'pixabay.com': (url) => { + // Pixabay: Keep original + return url; + }, + 'shutterstock.com': (url) => { + // Shutterstock: Keep original + return url; + }, + 'gettyimages.com': (url) => { + // Getty Images: Keep original + return url; + }, + 'imgur.com': (url) => { + // Imgur: Keep original + return url; + }, + 'flickr.com': (url) => { + // Flickr: Keep original + return url; + }, + 'photobucket.com': (url) => { + // Photobucket: Keep original + return url; + }, + 'dropbox.com': (url) => { + // Dropbox: For shared files, keep original + return url; + }, + 'drive.google.com': (url) => { + // Google Drive: For shared files, keep original + return url; + }, + 'onedrive.live.com': (url) => { + // OneDrive: For shared files, keep original + return url; + }, + 'box.com': (url) => { + // Box: For shared files, keep original + return url; + }, + 'wetransfer.com': (url) => { + // WeTransfer: Keep original + return url; + }, + 'sendspace.com': (url) => { + // SendSpace: Keep original + return url; + }, + 'mediafire.com': (url) => { + // MediaFire: Keep original + return url; + }, + 'mega.nz': (url) => { + // Mega: Keep original + return url; + }, + 'archive.org': (url) => { + // Internet Archive: Keep original + return url; + }, + 'web.archive.org': (url) => { + // Wayback Machine: Keep original + return url; + }, + 'scholar.google.com': (url) => { + // Google Scholar: Keep original + return url; + }, + 'researchgate.net': (url) => { + // ResearchGate: Keep original + return url; + }, + 'academia.edu': (url) => { + // Academia.edu: Keep original + return url; + }, + 'jstor.org': (url) => { + // JSTOR: Keep original + return url; + }, + 'pubmed.ncbi.nlm.nih.gov': (url) => { + // PubMed: Keep original + return url; + }, + 'arxiv.org': (url) => { + // arXiv: Keep original + return url; + }, + 'biorxiv.org': (url) => { + // bioRxiv: Keep original + return url; + }, + 'medrxiv.org': (url) => { + // medRxiv: Keep original + return url; + }, + 'ssrn.com': (url) => { + // SSRN: Keep original + return url; + }, + 'doi.org': (url) => { + // DOI: Keep original + return url; + }, + 'orcid.org': (url) => { + // ORCID: Keep original + return url; + }, + 'goodreads.com': (url) => { + // Goodreads: Keep original + return url; + }, + 'bookdepository.com': (url) => { + // Book Depository: Keep original + return url; + }, + 'amazon.com': (url) => { + // Amazon: For book/product pages, keep original + return url; + }, + 'amazon.co.uk': (url) => { + // Amazon UK: Keep original + return url; + }, + 'amazon.de': (url) => { + // Amazon Germany: Keep original + return url; + }, + 'amazon.fr': (url) => { + // Amazon France: Keep original + return url; + }, + 'amazon.es': (url) => { + // Amazon Spain: Keep original + return url; + }, + 'amazon.it': (url) => { + // Amazon Italy: Keep original + return url; + }, + 'amazon.ca': (url) => { + // Amazon Canada: Keep original + return url; + }, + 'amazon.com.au': (url) => { + // Amazon Australia: Keep original + return url; + }, + 'amazon.co.jp': (url) => { + // Amazon Japan: Keep original + return url; + }, + 'ebay.com': (url) => { + // eBay: Keep original + return url; + }, + 'etsy.com': (url) => { + // Etsy: Keep original + return url; + }, + 'aliexpress.com': (url) => { + // AliExpress: Keep original + return url; + }, + 'alibaba.com': (url) => { + // Alibaba: Keep original + return url; + }, + 'shopify.com': (url) => { + // Shopify stores: Keep original + return url; + }, + 'squarespace.com': (url) => { + // Squarespace sites: Keep original + return url; + }, + 'wix.com': (url) => { + // Wix sites: Keep original + return url; + }, + 'wordpress.com': (url) => { + // WordPress.com sites: Keep original + return url; + }, + 'blogger.com': (url) => { + // Blogger: Keep original + return url; + }, + 'blogspot.com': (url) => { + // Blogspot: Keep original + return url; + }, + 'tumblr.com': (url) => { + // Tumblr: Keep original + return url; + }, + 'ghost.org': (url) => { + // Ghost blogs: Keep original + return url; + }, +}; + +/** + * A content handler that transforms URLs for specific domains to improve content extraction. + * This handler focuses on general domain transformations, excluding social media and news sites + * which are handled by specialized handlers. + */ +@Injectable() +export class DomainSpecificHandler implements IContentHandler { + private readonly logger = new Logger(DomainSpecificHandler.name); + + /** + * Determines if the handler can process the content from the given URL. + * @param url - The URL to be checked. + * @returns `true` if the handler can process the URL, `false` otherwise. + */ + public canHandle(url: URL): boolean { + return Object.keys(DOMAIN_TRANSFORMATIONS).some((domain) => url.hostname.endsWith(domain)); + } + + /** + * Processes the content from the URL by transforming it to a more accessible version. + * @param url - The URL of the content to handle. + * @returns A `PreHandleResult` with the new URL, or `null` on failure. + */ + public async handle(url: URL): Promise { + const domain = Object.keys(DOMAIN_TRANSFORMATIONS).find((d) => url.hostname.endsWith(d)); + + if (!domain) { + return null; + } + + try { + const transform = DOMAIN_TRANSFORMATIONS[domain]; + const newUrl = transform(url); + + // Extract title and content from the original URL for specific domains + let title: string | undefined; + let content: string | undefined; + const contentType = 'text/html'; // Default content type + + if (domain === 'substack.com') { + title = this.extractSubstackTitle(url); + } else if (domain === 'github.com') { + title = this.extractGitHubTitle(url); + } else if (domain === 'stackoverflow.com') { + title = this.extractStackOverflowTitle(url); + } else if (domain === 'wikipedia.org') { + title = this.extractWikipediaTitle(url); + } + + return { + url: newUrl.href, + title, + content, + contentType, + }; + } catch (error) { + this.logger.warn(`DomainSpecificHandler failed for ${url.href}: ${(error as Error).message}`); + return null; + } + } + + /** + * Extracts title from Substack URL. + * @param url - The Substack URL. + * @returns The extracted title or undefined. + */ + private extractSubstackTitle(url: URL): string | undefined { + const pathParts = url.pathname.split('/').filter((part) => part.length > 0); + + if (pathParts.length > 0 && pathParts[0] === 'p') { + // Substack post URL: /p/article-title + const articleSlug = pathParts[1]; + if (articleSlug) { + return articleSlug + .split('-') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + } + } + + return undefined; + } + + /** + * Extracts title from GitHub URL. + * @param url - The GitHub URL. + * @returns The extracted title or undefined. + */ + private extractGitHubTitle(url: URL): string | undefined { + const pathParts = url.pathname.split('/').filter((part) => part.length > 0); + + if (pathParts.length >= 2) { + const owner = pathParts[0]; + const repo = pathParts[1]; + + if (pathParts.length >= 4 && pathParts[2] === 'blob') { + // File URL: /owner/repo/blob/branch/path/to/file.md + const fileName = pathParts[pathParts.length - 1]; + return `${owner}/${repo}: ${fileName}`; + } else { + // Repository URL: /owner/repo + return `${owner}/${repo}`; + } + } + + return undefined; + } + + /** + * Extracts title from Stack Overflow URL. + * @param url - The Stack Overflow URL. + * @returns The extracted title or undefined. + */ + private extractStackOverflowTitle(url: URL): string | undefined { + const pathParts = url.pathname.split('/').filter((part) => part.length > 0); + + if (pathParts.length >= 3 && pathParts[0] === 'questions') { + // Stack Overflow question URL: /questions/123456/question-title + const titleSlug = pathParts[2]; + if (titleSlug) { + return titleSlug + .split('-') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + } + } + + return undefined; + } + + /** + * Extracts title from Wikipedia URL. + * @param url - The Wikipedia URL. + * @returns The extracted title or undefined. + */ + private extractWikipediaTitle(url: URL): string | undefined { + const pathParts = url.pathname.split('/').filter((part) => part.length > 0); + + if (pathParts.length >= 2 && pathParts[0] === 'wiki') { + // Wikipedia article URL: /wiki/Article_Title + const articleTitle = pathParts[1]; + if (articleTitle) { + return decodeURIComponent(articleTitle.replace(/_/g, ' ')); + } + } + + return undefined; + } +} diff --git a/src/modules/pre-handler/handlers/maily.handler.ts b/src/modules/pre-handler/handlers/maily.handler.ts new file mode 100644 index 0000000..ab1d3fd --- /dev/null +++ b/src/modules/pre-handler/handlers/maily.handler.ts @@ -0,0 +1,145 @@ +/** + * ๋ฉ”์ผ๋ฆฌ(Maily) ๋‰ด์Šค๋ ˆํ„ฐ ํ”Œ๋žซํผ์„ ์œ„ํ•œ ๋ฆฌํŒฉํ† ๋ง๋œ ์ฝ˜ํ…์ธ  ํ•ธ๋“ค๋Ÿฌ + * - AbstractContentHandler ๊ธฐ๋ฐ˜ + * - SOLID ์›์น™ ๋ฐ ํ•จ์ˆ˜ํ˜• ํ”„๋กœ๊ทธ๋ž˜๋ฐ ์ ์šฉ + */ +import { Injectable } from '@nestjs/common'; +import { AbstractContentHandler } from '../base/abstract-content-handler'; +import { + HttpRequestConfig, + DomConfig, + ContentCleaningConfig, + TitleExtractionConfig, +} from '../types/content-extraction.types'; + +/** + * ๋ฉ”์ผ๋ฆฌ ๋‰ด์Šค๋ ˆํ„ฐ ํ•ธ๋“ค๋Ÿฌ + */ +@Injectable() +export class MailyHandler extends AbstractContentHandler { + /** + * ๋ฉ”์ผ๋ฆฌ ๋„๋ฉ”์ธ ์ฒ˜๋ฆฌ ์—ฌ๋ถ€ + * @param url ๊ฒ€์‚ฌํ•  URL + */ + public canHandle(url: URL): boolean { + return url.hostname.endsWith('maily.so'); + } + + /** + * ํ•ธ๋“ค๋Ÿฌ ์ด๋ฆ„ + */ + protected get handlerName(): string { + return '๋ฉ”์ผ๋ฆฌ ํ•ธ๋“ค๋Ÿฌ'; + } + + /** + * HTTP ์š”์ฒญ ์„ค์ • + */ + protected get httpConfig(): HttpRequestConfig { + return { + userAgent: + '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', + timeout: 20000, + headers: { + Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', + 'Accept-Language': 'ko-KR,ko;q=0.9,en;q=0.8', + 'Accept-Encoding': 'gzip, deflate, br', + Connection: 'keep-alive', + 'Upgrade-Insecure-Requests': '1', + 'Sec-Fetch-Dest': 'document', + 'Sec-Fetch-Mode': 'navigate', + 'Sec-Fetch-Site': 'none', + 'Cache-Control': 'no-cache', + 'X-Requested-With': 'XMLHttpRequest', + Origin: 'https://maily.so', + Referer: 'https://maily.so/', + }, + redirect: 'follow', + }; + } + + /** + * DOM ์ƒ์„ฑ ์„ค์ • + */ + protected get domConfig(): DomConfig { + return { + userAgent: this.httpConfig.userAgent, + resources: 'usable', + runScripts: 'outside-only', + pretendToBeVisual: true, + }; + } + + /** + * ์ฝ˜ํ…์ธ  ์ •์ œ ์„ค์ • + */ + protected get cleaningConfig(): ContentCleaningConfig { + return { + removeUnwantedElements: true, + cleanupStyles: true, + cleanupLinks: true, + cleanupImages: true, + cleanupText: true, + refineTitle: true, + }; + } + + /** + * ์ œ๋ชฉ ์ถ”์ถœ ์„ค์ • + */ + protected get titleConfig(): TitleExtractionConfig { + return { + selectors: [ + 'meta[property="og:title"]', + 'meta[name="twitter:title"]', + 'title', + 'h1', + '.newsletter-title', + '.post-title', + '.article-title', + '[class*="title"]', + '[class*="headline"]', + '[data-testid="post-title"]', + '[data-testid="article-title"]', + '[class*="maily-title"]', + '[class*="letter-title"]', + 'article h1', + 'main h1', + ], + patterns: [ + /\s*-\s*๋ฉ”์ผ๋ฆฌ$/, + /\s*\|\s*Maily$/, + /\s*::.*$/, + /\s*ยท\s*๋ฉ”์ผ๋ฆฌ$/, + /\s*๋‰ด์Šค๋ ˆํ„ฐ๋ฅผ ์‰ฝ๊ฒŒ, ๋ฉ”์ผ๋ฆฌ๋กœ ์‹œ์ž‘ํ•˜์„ธ์š”$/, + ], + siteSpecificPatterns: { + 'maily.so': [/\s*-\s*๋ฉ”์ผ๋ฆฌ$/, /\s*\|\s*Maily$/, /\s*๋‰ด์Šค๋ ˆํ„ฐ๋ฅผ ์‰ฝ๊ฒŒ, ๋ฉ”์ผ๋ฆฌ๋กœ ์‹œ์ž‘ํ•˜์„ธ์š”$/], + }, + }; + } + + /** + * ๋ณธ๋ฌธ ์ฝ˜ํ…์ธ  ์ถ”์ถœ์šฉ ์…€๋ ‰ํ„ฐ + */ + protected get contentSelectors(): readonly string[] { + return [ + 'article', + '[class*="content"]', + '[class*="newsletter"]', + '[class*="post"]', + '[class*="body"]', + '[class*="letter"]', + '[data-testid="post-content"]', + '[data-testid="article-content"]', + 'main', + '.container', + '#content', + '[class*="maily-content"]', + '[class*="letter-content"]', + '[class*="newsletter-content"]', + '.post-content', + '.article-content', + ]; + } +} diff --git a/src/modules/pre-handler/handlers/medium.handler.ts b/src/modules/pre-handler/handlers/medium.handler.ts new file mode 100644 index 0000000..490b5bb --- /dev/null +++ b/src/modules/pre-handler/handlers/medium.handler.ts @@ -0,0 +1,90 @@ +import { Injectable } from '@nestjs/common'; +import { AbstractContentHandler } from '../base/abstract-content-handler'; +import { + HttpRequestConfig, + DomConfig, + ContentCleaningConfig, + TitleExtractionConfig, +} from '../types/content-extraction.types'; +import { JSDOM } from 'jsdom'; +import { postProcessDom } from '../utils/content-cleaning-pipeline'; + +/** + * Medium ์ „์šฉ ํ•ธ๋“ค๋Ÿฌ + * - SOLID ์›์น™ ๊ธฐ๋ฐ˜, AbstractContentHandler ์ƒ์† + */ +@Injectable() +export class MediumHandler extends AbstractContentHandler { + public canHandle(url: URL): boolean { + return url.hostname.endsWith('medium.com'); + } + + protected get handlerName(): string { + return 'Medium ํ•ธ๋“ค๋Ÿฌ'; + } + + protected get httpConfig(): HttpRequestConfig { + return { + userAgent: + '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', + timeout: 15000, + headers: { + Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.5', + 'Accept-Encoding': 'gzip, deflate', + Connection: 'keep-alive', + 'Cache-Control': 'no-cache', + }, + redirect: 'follow', + }; + } + + protected get domConfig(): DomConfig { + return { + userAgent: this.httpConfig.userAgent, + resources: 'usable', + runScripts: 'outside-only', + pretendToBeVisual: true, + }; + } + + protected get cleaningConfig(): ContentCleaningConfig { + return { + removeUnwantedElements: true, + cleanupStyles: true, + cleanupLinks: true, + cleanupImages: true, + cleanupText: false, + refineTitle: true, + }; + } + + protected get titleConfig(): TitleExtractionConfig { + return { + selectors: ['meta[property="og:title"]', 'meta[name="title"]', 'title', 'h1'], + patterns: [], + siteSpecificPatterns: {}, + }; + } + + protected get contentSelectors(): readonly string[] { + return ['article', '.section-content', '.postArticle-content', '.meteredContent', '.main-content']; + } + + /** + * Medium ์ฝ˜ํ…์ธ ์— ํŠนํ™”๋œ ํ›„์ฒ˜๋ฆฌ ๋กœ์ง์„ ์ ์šฉํ•ฉ๋‹ˆ๋‹ค. + * @param content ์ถ”์ถœ๋œ HTML ์ฝ˜ํ…์ธ  + * @returns ํ›„์ฒ˜๋ฆฌ๋œ HTML ์ฝ˜ํ…์ธ  + */ + protected override postProcess(content: string): string { + if (!content) { + return content; + } + + const dom = new JSDOM(content); + const document = dom.window.document; + postProcessDom(document); + + return document.body?.outerHTML ?? content; + } +} diff --git a/src/modules/pre-handler/handlers/naver-blog.handler.ts b/src/modules/pre-handler/handlers/naver-blog.handler.ts new file mode 100644 index 0000000..7d0ee97 --- /dev/null +++ b/src/modules/pre-handler/handlers/naver-blog.handler.ts @@ -0,0 +1,105 @@ +import { Injectable } from '@nestjs/common'; +import { AbstractContentHandler } from '../base/abstract-content-handler'; +import { + HttpRequestConfig, + DomConfig, + ContentCleaningConfig, + TitleExtractionConfig, +} from '../types/content-extraction.types'; +import { JSDOM } from 'jsdom'; +import { postProcessDom } from '../utils/content-cleaning-pipeline'; + +/** + * ๋„ค์ด๋ฒ„ ๋ธ”๋กœ๊ทธ ์ „์šฉ ํ•ธ๋“ค๋Ÿฌ + * - SOLID ์›์น™ ๊ธฐ๋ฐ˜, AbstractContentHandler ์ƒ์† + */ +@Injectable() +export class NaverBlogHandler extends AbstractContentHandler { + public canHandle(url: URL): boolean { + return url.hostname.endsWith('blog.naver.com'); + } + + protected get handlerName(): string { + return '๋„ค์ด๋ฒ„๋ธ”๋กœ๊ทธ ํ•ธ๋“ค๋Ÿฌ'; + } + + protected get httpConfig(): HttpRequestConfig { + return { + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Mobile/15E148 Safari/604.1', + timeout: 15000, + headers: { + Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'ko-KR,ko;q=0.9,en;q=0.8', + 'Accept-Encoding': 'gzip, deflate', + Connection: 'keep-alive', + Referer: 'https://blog.naver.com/', + }, + redirect: 'follow', + }; + } + + protected get domConfig(): DomConfig { + return { + userAgent: this.httpConfig.userAgent, + resources: 'usable', + runScripts: 'outside-only', + pretendToBeVisual: true, + }; + } + + protected get cleaningConfig(): ContentCleaningConfig { + return { + removeUnwantedElements: true, + cleanupStyles: true, + cleanupLinks: true, + cleanupImages: true, + cleanupText: false, + refineTitle: true, + }; + } + + protected get titleConfig(): TitleExtractionConfig { + return { + selectors: [ + 'meta[property="og:title"]', + 'meta[name="title"]', + 'title', + '.se-title-text', + '.pcol1 .title', + '.blog-title', + ], + patterns: [], + siteSpecificPatterns: {}, + }; + } + + protected get contentSelectors(): readonly string[] { + return [ + '#postViewArea', + '.se-main-container', + '.post-view', + '.se_component_wrap', + '.se_textView', + '.blog2_container', + '.se_content', + '.view', + '.post', + ]; + } + + /** + * ๋„ค์ด๋ฒ„ ๋ธ”๋กœ๊ทธ ์ฝ˜ํ…์ธ ์— ํŠนํ™”๋œ ํ›„์ฒ˜๋ฆฌ ๋กœ์ง์„ ์ ์šฉํ•ฉ๋‹ˆ๋‹ค. + * @param content ์ถ”์ถœ๋œ HTML ์ฝ˜ํ…์ธ  + * @returns ํ›„์ฒ˜๋ฆฌ๋œ HTML ์ฝ˜ํ…์ธ  + */ + protected override postProcess(content: string): string { + if (!content) { + return content; + } + const dom = new JSDOM(content); + const document = dom.window.document; + postProcessDom(document, { baseUrl: 'https://blog.naver.com' }); + return document.body?.outerHTML ?? content; + } +} diff --git a/src/modules/pre-handler/handlers/news-site.handler.ts b/src/modules/pre-handler/handlers/news-site.handler.ts new file mode 100644 index 0000000..dd5b067 --- /dev/null +++ b/src/modules/pre-handler/handlers/news-site.handler.ts @@ -0,0 +1,341 @@ +/** + * ๋‰ด์Šค ์‚ฌ์ดํŠธ์šฉ ๋ฆฌํŒฉํ† ๋ง๋œ ์ฝ˜ํ…์ธ  ํ•ธ๋“ค๋Ÿฌ + * - AbstractContentHandler ๊ธฐ๋ฐ˜ + * - SOLID ์›์น™ ๋ฐ ํ•จ์ˆ˜ํ˜• ํ”„๋กœ๊ทธ๋ž˜๋ฐ ์ ์šฉ + */ +import { Injectable, Logger } from '@nestjs/common'; +import { AbstractContentHandler } from '../base/abstract-content-handler'; +import { + HttpRequestConfig, + DomConfig, + ContentCleaningConfig, + TitleExtractionConfig, +} from '../types/content-extraction.types'; + +/** + * News site transformations. + * Each news site has specific URL patterns and optimal access methods. + */ +const NEWS_SITE_TRANSFORMATIONS: Record URL> = { + 'nytimes.com': (url) => { + // New York Times: Use print version to bypass paywall + const newUrl = new URL(url.href); + newUrl.searchParams.set('print', '1'); + return newUrl; + }, + 'wsj.com': (url) => { + // Wall Street Journal: Use print version + const newUrl = new URL(url.href); + newUrl.searchParams.set('print', '1'); + return newUrl; + }, + 'washingtonpost.com': (url) => { + // Washington Post: Use print version + const newUrl = new URL(url.href); + newUrl.searchParams.set('print', '1'); + return newUrl; + }, + 'ft.com': (url) => { + // Financial Times: Use print version + const newUrl = new URL(url.href); + newUrl.searchParams.set('print', '1'); + return newUrl; + }, + 'bloomberg.com': (url) => { + // Bloomberg: Use print version + const newUrl = new URL(url.href); + newUrl.searchParams.set('print', '1'); + return newUrl; + }, + 'economist.com': (url) => { + // The Economist: Use print version + const newUrl = new URL(url.href); + newUrl.searchParams.set('print', '1'); + return newUrl; + }, + 'cnn.com': (url) => { + // CNN: Use mobile version for cleaner layout + const newUrl = new URL(url.href); + newUrl.hostname = 'lite.cnn.com'; + return newUrl; + }, + 'bbc.com': (url) => { + // BBC: Use mobile version + const newUrl = new URL(url.href); + newUrl.hostname = 'm.bbc.com'; + return newUrl; + }, + 'bbc.co.uk': (url) => { + // BBC UK: Use mobile version + const newUrl = new URL(url.href); + newUrl.hostname = 'm.bbc.co.uk'; + return newUrl; + }, + 'reuters.com': (url) => { + // Reuters: Keep original, usually accessible + return url; + }, + 'apnews.com': (url) => { + // Associated Press: Keep original, usually accessible + return url; + }, + 'theguardian.com': (url) => { + // The Guardian: Keep original, no paywall + return url; + }, + 'npr.org': (url) => { + // NPR: Keep original, usually accessible + return url; + }, + 'politico.com': (url) => { + // Politico: Use print version for better readability + const newUrl = new URL(url.href); + newUrl.searchParams.set('print', '1'); + return newUrl; + }, + 'axios.com': (url) => { + // Axios: Keep original, usually accessible + return url; + }, + 'vox.com': (url) => { + // Vox: Keep original, usually accessible + return url; + }, + 'buzzfeed.com': (url) => { + // BuzzFeed: Keep original + return url; + }, + 'huffpost.com': (url) => { + // HuffPost: Keep original + return url; + }, + 'usatoday.com': (url) => { + // USA Today: Use print version + const newUrl = new URL(url.href); + newUrl.searchParams.set('print', '1'); + return newUrl; + }, + 'latimes.com': (url) => { + // LA Times: Use print version + const newUrl = new URL(url.href); + newUrl.searchParams.set('print', '1'); + return newUrl; + }, + 'chicagotribune.com': (url) => { + // Chicago Tribune: Use print version + const newUrl = new URL(url.href); + newUrl.searchParams.set('print', '1'); + return newUrl; + }, + 'time.com': (url) => { + // Time Magazine: Keep original + return url; + }, + 'newsweek.com': (url) => { + // Newsweek: Keep original + return url; + }, + 'theatlantic.com': (url) => { + // The Atlantic: Use print version + const newUrl = new URL(url.href); + newUrl.searchParams.set('print', '1'); + return newUrl; + }, + 'newyorker.com': (url) => { + // The New Yorker: Use print version + const newUrl = new URL(url.href); + newUrl.searchParams.set('print', '1'); + return newUrl; + }, + 'forbes.com': (url) => { + // Forbes: Keep original but remove tracking + const newUrl = new URL(url.href); + newUrl.searchParams.delete('sh'); + return newUrl; + }, + 'techcrunch.com': (url) => { + // TechCrunch: Keep original + return url; + }, + 'engadget.com': (url) => { + // Engadget: Keep original + return url; + }, + 'theverge.com': (url) => { + // The Verge: Keep original + return url; + }, + 'wired.com': (url) => { + // Wired: Use print version + const newUrl = new URL(url.href); + newUrl.searchParams.set('print', '1'); + return newUrl; + }, + 'arstechnica.com': (url) => { + // Ars Technica: Keep original, usually accessible + return url; + }, + 'espn.com': (url) => { + // ESPN: Use mobile version + const newUrl = new URL(url.href); + newUrl.hostname = 'm.espn.com'; + return newUrl; + }, + 'cbssports.com': (url) => { + // CBS Sports: Use mobile version + const newUrl = new URL(url.href); + newUrl.hostname = 'm.cbssports.com'; + return newUrl; + }, + 'nfl.com': (url) => { + // NFL: Use mobile version + const newUrl = new URL(url.href); + newUrl.hostname = 'm.nfl.com'; + return newUrl; + }, + 'nba.com': (url) => { + // NBA: Use mobile version + const newUrl = new URL(url.href); + newUrl.hostname = 'm.nba.com'; + return newUrl; + }, +}; + +/** + * ๋‰ด์Šค ์‚ฌ์ดํŠธ ํ•ธ๋“ค๋Ÿฌ + */ +@Injectable() +export class NewsSiteHandler extends AbstractContentHandler { + protected readonly logger = new Logger(NewsSiteHandler.name); + + /** + * ๋‰ด์Šค ์‚ฌ์ดํŠธ ์ฒ˜๋ฆฌ ์—ฌ๋ถ€ + * @param url ๊ฒ€์‚ฌํ•  URL + */ + public canHandle(url: URL): boolean { + return Object.keys(NEWS_SITE_TRANSFORMATIONS).some((domain) => url.hostname.endsWith(domain)); + } + + /** + * ํ•ธ๋“ค๋Ÿฌ ์ด๋ฆ„ + */ + protected get handlerName(): string { + return '๋‰ด์Šค์‚ฌ์ดํŠธ ํ•ธ๋“ค๋Ÿฌ'; + } + + /** + * HTTP ์š”์ฒญ ์„ค์ • (๋‰ด์Šค์‚ฌ์ดํŠธ๋Š” ํ‘œ์ค€ ์„ค์ •) + */ + protected get httpConfig(): HttpRequestConfig { + return { + userAgent: '', + timeout: 10000, + headers: {}, + redirect: 'follow', + }; + } + + /** + * DOM ์ƒ์„ฑ ์„ค์ • (ํ‘œ์ค€) + */ + protected get domConfig(): DomConfig { + return { + userAgent: '', + resources: 'usable', + runScripts: 'outside-only', + pretendToBeVisual: true, + }; + } + + /** + * ์ฝ˜ํ…์ธ  ์ •์ œ ์„ค์ • (๋‰ด์Šค์‚ฌ์ดํŠธ์šฉ) + */ + protected get cleaningConfig(): ContentCleaningConfig { + return { + removeUnwantedElements: true, + cleanupStyles: true, + cleanupLinks: true, + cleanupImages: true, + cleanupText: true, + refineTitle: true, + }; + } + + /** + * ์ œ๋ชฉ ์ถ”์ถœ ์„ค์ • (๋‰ด์Šค์‚ฌ์ดํŠธ์šฉ) + */ + protected get titleConfig(): TitleExtractionConfig { + return { + selectors: ['meta[property="og:title"]', 'title', 'h1'], + patterns: [/[-_][^\s]+/g], + siteSpecificPatterns: {}, + }; + } + + /** + * ๋ณธ๋ฌธ ์ฝ˜ํ…์ธ  ์ถ”์ถœ์šฉ ์…€๋ ‰ํ„ฐ (๋‰ด์Šค์‚ฌ์ดํŠธ์šฉ) + */ + protected get contentSelectors(): readonly string[] { + return ['article', 'main', '.article-body', '.content', '#article-body']; + } + + /** + * ๋‰ด์Šค์‚ฌ์ดํŠธ๋Š” URL ๋ณ€ํ™˜ ํ›„ ํ‘œ์ค€ ์ถ”์ถœ ํ”„๋กœ์„ธ์Šค ์‚ฌ์šฉ + * @param url ์›๋ณธ URL + * @returns ๊ฐ€๊ณต๋œ URL + */ + protected override preProcessUrl(url: URL): URL { + const domain = Object.keys(NEWS_SITE_TRANSFORMATIONS).find((d) => url.hostname.endsWith(d)); + if (domain) { + return NEWS_SITE_TRANSFORMATIONS[domain](url); + } + return url; + } + + /** + * Gets a human-readable site name from domain. + * @param domain - The domain name. + * @returns The site name. + */ + private getSiteName(domain: string): string { + const siteNames: Record = { + 'nytimes.com': 'New York Times', + 'wsj.com': 'Wall Street Journal', + 'washingtonpost.com': 'Washington Post', + 'ft.com': 'Financial Times', + 'bloomberg.com': 'Bloomberg', + 'economist.com': 'The Economist', + 'cnn.com': 'CNN', + 'bbc.com': 'BBC', + 'bbc.co.uk': 'BBC', + 'reuters.com': 'Reuters', + 'apnews.com': 'Associated Press', + 'theguardian.com': 'The Guardian', + 'npr.org': 'NPR', + 'politico.com': 'Politico', + 'axios.com': 'Axios', + 'vox.com': 'Vox', + 'buzzfeed.com': 'BuzzFeed', + 'huffpost.com': 'HuffPost', + 'usatoday.com': 'USA Today', + 'latimes.com': 'LA Times', + 'chicagotribune.com': 'Chicago Tribune', + 'time.com': 'Time', + 'newsweek.com': 'Newsweek', + 'theatlantic.com': 'The Atlantic', + 'newyorker.com': 'The New Yorker', + 'forbes.com': 'Forbes', + 'techcrunch.com': 'TechCrunch', + 'engadget.com': 'Engadget', + 'theverge.com': 'The Verge', + 'wired.com': 'Wired', + 'arstechnica.com': 'Ars Technica', + 'espn.com': 'ESPN', + 'cbssports.com': 'CBS Sports', + 'nfl.com': 'NFL', + 'nba.com': 'NBA', + }; + + return siteNames[domain] || domain; + } +} diff --git a/src/modules/pre-handler/handlers/pdf.handler.ts b/src/modules/pre-handler/handlers/pdf.handler.ts new file mode 100644 index 0000000..0336a1b --- /dev/null +++ b/src/modules/pre-handler/handlers/pdf.handler.ts @@ -0,0 +1,121 @@ +/** + * PDF ํŒŒ์ผ์„ ์œ„ํ•œ ๋ฆฌํŒฉํ† ๋ง๋œ ์ฝ˜ํ…์ธ  ํ•ธ๋“ค๋Ÿฌ + * - AbstractContentHandler ๊ธฐ๋ฐ˜ + * - SOLID ์›์น™ ๋ฐ ํ•จ์ˆ˜ํ˜• ํ”„๋กœ๊ทธ๋ž˜๋ฐ ์ ์šฉ + */ +import { Injectable, Logger } from '@nestjs/common'; +import { AbstractContentHandler } from '../base/abstract-content-handler'; +import { + HttpRequestConfig, + DomConfig, + ContentCleaningConfig, + TitleExtractionConfig, +} from '../types/content-extraction.types'; +import { PreHandleResult } from '../dto/pre-handle-result.dto'; +import { extractTitleFromPath } from '../utils/functional-utils'; + +/** + * PDF ํŒŒ์ผ ํ•ธ๋“ค๋Ÿฌ + */ +@Injectable() +export class PdfHandler extends AbstractContentHandler { + protected readonly logger = new Logger(PdfHandler.name); + + /** + * PDF ํŒŒ์ผ ์ฒ˜๋ฆฌ ์—ฌ๋ถ€ + * @param url ๊ฒ€์‚ฌํ•  URL + */ + public canHandle(url: URL): boolean { + if (url.pathname.toLowerCase().endsWith('.pdf')) { + return true; + } + const pdfPatterns = [/\/pdf\//i, /\.pdf$/i, /\/download.*\.pdf/i, /\/files.*\.pdf/i, /\/documents.*\.pdf/i]; + return pdfPatterns.some((pattern) => pattern.test(url.pathname)); + } + + /** + * ํ•ธ๋“ค๋Ÿฌ ์ด๋ฆ„ + */ + protected get handlerName(): string { + return 'PDF ํ•ธ๋“ค๋Ÿฌ'; + } + + /** + * HTTP ์š”์ฒญ ์„ค์ • (PDF๋Š” ๋ณ„๋„ ์š”์ฒญ ๋ถˆํ•„์š”) + */ + protected get httpConfig(): HttpRequestConfig { + return { + userAgent: '', + timeout: 0, + headers: {}, + redirect: 'follow', + }; + } + + /** + * DOM ์ƒ์„ฑ ์„ค์ • (PDF๋Š” ์‚ฌ์šฉํ•˜์ง€ ์•Š์Œ) + */ + protected get domConfig(): DomConfig { + return { + userAgent: '', + resources: 'usable', + runScripts: 'outside-only', + pretendToBeVisual: false, + }; + } + + /** + * ์ฝ˜ํ…์ธ  ์ •์ œ ์„ค์ • (PDF๋Š” ์ •์ œ ๋ถˆํ•„์š”) + */ + protected get cleaningConfig(): ContentCleaningConfig { + return { + removeUnwantedElements: false, + cleanupStyles: false, + cleanupLinks: false, + cleanupImages: false, + cleanupText: false, + refineTitle: true, + }; + } + + /** + * ์ œ๋ชฉ ์ถ”์ถœ ์„ค์ • (ํŒŒ์ผ๋ช… ๊ธฐ๋ฐ˜) + */ + protected get titleConfig(): TitleExtractionConfig { + return { + selectors: [], + patterns: [/\.pdf$/i, /[-_]/g], + siteSpecificPatterns: {}, + }; + } + + /** + * ๋ณธ๋ฌธ ์ฝ˜ํ…์ธ  ์ถ”์ถœ์šฉ ์…€๋ ‰ํ„ฐ (์‚ฌ์šฉํ•˜์ง€ ์•Š์Œ) + */ + protected get contentSelectors(): readonly string[] { + return []; + } + + /** + * PDF๋Š” ๋ณ„๋„ ๋ณธ๋ฌธ ์ถ”์ถœ ์—†์ด ํƒ€์ž… ๋งˆํ‚น๋งŒ ์ˆ˜ํ–‰ + * @param url ์ฒ˜๋ฆฌํ•  URL + * @returns PreHandleResult ๋˜๋Š” null + */ + public async handle(url: URL): Promise { + try { + let title: string | undefined; + const filename = url.pathname.split('/').pop(); + if (filename && filename.includes('.pdf')) { + title = extractTitleFromPath(filename, { removeExtension: true }); + } + return { + url: url.href, + title, + contentType: 'application/pdf', + }; + } catch (error) { + this.logger.warn(`PdfHandler failed for ${url.href}: ${(error as Error).message}`); + return null; + } + } +} diff --git a/src/modules/pre-handler/handlers/readability.handler.ts b/src/modules/pre-handler/handlers/readability.handler.ts new file mode 100644 index 0000000..b9acf8d --- /dev/null +++ b/src/modules/pre-handler/handlers/readability.handler.ts @@ -0,0 +1,310 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { JSDOM } from 'jsdom'; +import { Readability } from '@mozilla/readability'; +import { AbstractContentHandler } from '../base/abstract-content-handler'; +import { + HttpRequestConfig, + DomConfig, + ContentCleaningConfig, + TitleExtractionConfig, +} from '../types/content-extraction.types'; +import { PreHandleResult } from '../dto/pre-handle-result.dto'; + +/** + * Readability ๊ธฐ๋ฐ˜ ๋ฆฌํŒฉํ† ๋ง๋œ ์ฝ˜ํ…์ธ  ํ•ธ๋“ค๋Ÿฌ + * - AbstractContentHandler ๊ธฐ๋ฐ˜ + * - SOLID ์›์น™ ๋ฐ ํ•จ์ˆ˜ํ˜• ํ”„๋กœ๊ทธ๋ž˜๋ฐ ์ ์šฉ + */ +@Injectable() +export class ReadabilityHandler extends AbstractContentHandler { + protected readonly logger = new Logger(ReadabilityHandler.name); + private readonly USER_AGENT = 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)'; + + /** + * Readability๋Š” ๋ชจ๋“  http/https URL์„ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ์Œ + * @param url ๊ฒ€์‚ฌํ•  URL + */ + public canHandle(url: URL): boolean { + return ['http:', 'https:'].includes(url.protocol); + } + + /** + * ํ•ธ๋“ค๋Ÿฌ ์ด๋ฆ„ + */ + protected get handlerName(): string { + return 'Readability ํ•ธ๋“ค๋Ÿฌ'; + } + + /** + * HTTP ์š”์ฒญ ์„ค์ • (ํ‘œ์ค€) + */ + protected get httpConfig(): HttpRequestConfig { + return { + userAgent: this.USER_AGENT, + timeout: 10000, + headers: {}, + redirect: 'follow', + }; + } + + /** + * DOM ์ƒ์„ฑ ์„ค์ • (์Šคํฌ๋ฆฝํŠธ ํ™œ์„ฑํ™”) + */ + protected get domConfig(): DomConfig { + return { + userAgent: this.httpConfig.userAgent, + resources: 'usable', + runScripts: 'dangerously', + pretendToBeVisual: true, + }; + } + + /** + * ์ฝ˜ํ…์ธ  ์ •์ œ ์„ค์ • (Readability๋Š” ์ž์ฒด ์ •์ œ) + */ + protected get cleaningConfig(): ContentCleaningConfig { + return { + removeUnwantedElements: false, + cleanupStyles: false, + cleanupLinks: false, + cleanupImages: false, + cleanupText: false, + refineTitle: true, + }; + } + + /** + * ์ œ๋ชฉ ์ถ”์ถœ ์„ค์ • (Readability ๊ฒฐ๊ณผ ๊ธฐ๋ฐ˜) + */ + protected get titleConfig(): TitleExtractionConfig { + return { + selectors: [], + patterns: [], + siteSpecificPatterns: {}, + }; + } + + /** + * ๋ณธ๋ฌธ ์ฝ˜ํ…์ธ  ์ถ”์ถœ์šฉ ์…€๋ ‰ํ„ฐ (Readability๋Š” ์‚ฌ์šฉํ•˜์ง€ ์•Š์Œ) + */ + protected get contentSelectors(): readonly string[] { + return []; + } + + /** + * Fetches the webpage, parses it with JSDOM, and extracts the article content. + * @param url - The URL to handle. + * @returns A `PreHandleResult` with the extracted article, or `null` on failure. + */ + public async handle(url: URL): Promise { + try { + // 1์ฐจ ์‹œ๋„: ์Šคํฌ๋ฆฝํŠธ ํ™œ์„ฑํ™” DOM + let dom = await this.createDOMWithScripts(url.href); + let article = this.extractContentFromDOM(dom); + // ์‹คํŒจ ์‹œ: ์Šคํฌ๋ฆฝํŠธ ๋น„ํ™œ์„ฑํ™” DOM ์žฌ์‹œ๋„ + if (!article?.content) { + this.logger.debug(`First attempt failed, trying without scripts for ${url.href}`); + dom = await this.createDOMWithoutScripts(url.href); + article = this.extractContentFromDOM(dom); + } + if (!article?.content) { + this.logger.debug(`No readable content found for ${url.href}`); + return null; + } + this.logger.log(`Successfully extracted readable content: ${article.content.length} chars`); + return { + url: url.href, + title: article.title ?? undefined, + content: article.content ?? undefined, + contentType: 'text/html', + }; + } catch (error) { + this.logger.warn(`ReadabilityHandler failed for ${url.href}: ${(error as Error).message}`); + return null; + } + } + + /** + * ์Šคํฌ๋ฆฝํŠธ ํ™œ์„ฑํ™” JSDOM ์ƒ์„ฑ + */ + private async createDOMWithScripts(url: string): Promise { + const dom = await JSDOM.fromURL(url, { + userAgent: this.httpConfig.userAgent, + resources: 'usable', + runScripts: 'dangerously', + pretendToBeVisual: true, + }); + this.removeAllScripts(dom.window.document); + return dom; + } + + /** + * ์Šคํฌ๋ฆฝํŠธ ๋น„ํ™œ์„ฑํ™” JSDOM ์ƒ์„ฑ + */ + private async createDOMWithoutScripts(url: string): Promise { + const dom = await JSDOM.fromURL(url, { + userAgent: this.httpConfig.userAgent, + resources: 'usable', + runScripts: 'outside-only', + pretendToBeVisual: true, + }); + this.removeAllScripts(dom.window.document); + return dom; + } + + /** + * DOM์—์„œ ๋ชจ๋“