diff --git a/package.json b/package.json index a581e1a..fb0491a 100644 --- a/package.json +++ b/package.json @@ -27,8 +27,10 @@ "@nestjs/config": "^4.0.4", "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.1", + "@nestjs/swagger": "^11.4.2", "@twitter-api-v2/plugin-token-refresher": "^1.0.0", "axios": "^1.16.0", + "class-transformer": "^0.5.1", "class-validator": "^0.15.1", "cookie": "^1.1.1", "https-proxy-agent": "^9.0.0", @@ -37,6 +39,7 @@ "playwright-extra": "^4.3.6", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "swagger-ui-express": "^5.0.1", "twitter-api-v2": "^1.29.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d4fafc..16a46e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,25 +16,31 @@ importers: version: 5.1.6(keyv@5.6.0) '@nestjs/cache-manager': specifier: ^3.1.2 - version: 3.1.2(@nestjs/common@11.1.19(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(cache-manager@7.2.8)(keyv@5.6.0)(rxjs@7.8.2) + version: 3.1.2(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(cache-manager@7.2.8)(keyv@5.6.0)(rxjs@7.8.2) '@nestjs/common': specifier: ^11.0.1 - version: 11.1.19(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/config': specifier: ^4.0.4 - version: 4.0.4(@nestjs/common@11.1.19(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) + version: 4.0.4(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) '@nestjs/core': specifier: ^11.0.1 - version: 11.1.19(@nestjs/common@11.1.19(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/platform-express': specifier: ^11.0.1 - version: 11.1.19(@nestjs/common@11.1.19(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19) + version: 11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19) + '@nestjs/swagger': + specifier: ^11.4.2 + version: 11.4.2(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2) '@twitter-api-v2/plugin-token-refresher': specifier: ^1.0.0 version: 1.0.0(twitter-api-v2@1.29.0) axios: specifier: ^1.16.0 version: 1.16.0 + class-transformer: + specifier: ^0.5.1 + version: 0.5.1 class-validator: specifier: ^0.15.1 version: 0.15.1 @@ -59,6 +65,9 @@ importers: rxjs: specifier: ^7.8.1 version: 7.8.2 + swagger-ui-express: + specifier: ^5.0.1 + version: 5.0.1(express@5.2.1) twitter-api-v2: specifier: ^1.29.0 version: 1.29.0 @@ -77,7 +86,7 @@ importers: version: 11.1.0(chokidar@4.0.3)(prettier@3.8.3)(typescript@5.9.3) '@nestjs/testing': specifier: ^11.0.1 - version: 11.1.19(@nestjs/common@11.1.19(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(@nestjs/platform-express@11.1.19) + version: 11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(@nestjs/platform-express@11.1.19) '@types/express': specifier: ^5.0.0 version: 5.0.6 @@ -800,6 +809,9 @@ packages: resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} + '@microsoft/tsdoc@0.16.0': + resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -862,6 +874,19 @@ packages: '@nestjs/websockets': optional: true + '@nestjs/mapped-types@2.1.1': + resolution: {integrity: sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + class-transformer: ^0.4.0 || ^0.5.0 + class-validator: ^0.13.0 || ^0.14.0 || ^0.15.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + '@nestjs/platform-express@11.1.19': resolution: {integrity: sha512-Vpdv8jyCQdThfoTx+UTn+DRYr6H6X02YUqcpZ3qP6G3ZUwtVp7eS+hoQPGd4UuCnlnFG8Wqr2J9bGEzQdi1rIg==} peerDependencies: @@ -877,6 +902,23 @@ packages: prettier: optional: true + '@nestjs/swagger@11.4.2': + resolution: {integrity: sha512-aBihEogDMj/bLEcaqhkvyX/ZVWUw/bmnhKzR0zwUoyGJikvZyaq7rOPYl/H7Lxkkr3c90SJxyuv1AX2UT1WKlw==} + peerDependencies: + '@fastify/static': ^8.0.0 || ^9.0.0 + '@nestjs/common': ^11.0.1 + '@nestjs/core': ^11.0.1 + class-transformer: '*' + class-validator: '*' + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + '@fastify/static': + optional: true + class-transformer: + optional: true + class-validator: + optional: true + '@nestjs/testing@11.1.19': resolution: {integrity: sha512-/UFNWXvPEdu4v4DlC5oWLbGKmD27LehLK06b8oLzs6D6lf4vAQTdST8LRAXBadyMUQnVEQWMuBo3CtAVtlfXtQ==} peerDependencies: @@ -925,6 +967,9 @@ packages: '@opentelemetry/api': optional: true + '@scarf/scarf@1.4.0': + resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + '@sinclair/typebox@0.34.49': resolution: {integrity: sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==} @@ -1704,6 +1749,9 @@ packages: cjs-module-lexer@2.2.0: resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + class-transformer@0.5.1: + resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==} + class-validator@0.15.1: resolution: {integrity: sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==} @@ -3149,6 +3197,18 @@ packages: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} + swagger-ui-dist@5.32.4: + resolution: {integrity: sha512-0AADFFQNJzExEN49SrD/34Nn9cxNxVLiydYl2MBwSZFPVXNkVwC/EFAjoezGGqE8oDegiDC+p47t8lKObCinMQ==} + + swagger-ui-dist@5.32.5: + resolution: {integrity: sha512-7/FQfWe9A4qoyYFdAwy0chD0uDYidDp/ZT9VQ9LZlgD4AnnHJk8/+ytAA1HkJYOPySmK6helPDdJQMlcumt7HA==} + + swagger-ui-express@5.0.1: + resolution: {integrity: sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==} + engines: {node: '>= v0.10.32'} + peerDependencies: + express: '>=4.0.0 || >=5.0.0-beta' + symbol-observable@4.0.0: resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} engines: {node: '>=0.10'} @@ -4517,6 +4577,8 @@ snapshots: '@lukeed/csprng@1.1.0': {} + '@microsoft/tsdoc@0.16.0': {} + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.10.0 @@ -4524,10 +4586,10 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true - '@nestjs/cache-manager@3.1.2(@nestjs/common@11.1.19(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(cache-manager@7.2.8)(keyv@5.6.0)(rxjs@7.8.2)': + '@nestjs/cache-manager@3.1.2(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(cache-manager@7.2.8)(keyv@5.6.0)(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.19(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.19(@nestjs/common@11.1.19(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2) cache-manager: 7.2.8 keyv: 5.6.0 rxjs: 7.8.2 @@ -4559,7 +4621,7 @@ snapshots: - uglify-js - webpack-cli - '@nestjs/common@11.1.19(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: file-type: 21.3.4 iterare: 1.2.1 @@ -4569,21 +4631,22 @@ snapshots: tslib: 2.8.1 uid: 2.0.2 optionalDependencies: + class-transformer: 0.5.1 class-validator: 0.15.1 transitivePeerDependencies: - supports-color - '@nestjs/config@4.0.4(@nestjs/common@11.1.19(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': + '@nestjs/config@4.0.4(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.19(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) dotenv: 17.4.1 dotenv-expand: 12.0.3 lodash: 4.18.1 rxjs: 7.8.2 - '@nestjs/core@11.1.19(@nestjs/common@11.1.19(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/core@11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.19(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nuxt/opencollective': 0.4.1 fast-safe-stringify: 2.1.1 iterare: 1.2.1 @@ -4593,12 +4656,20 @@ snapshots: tslib: 2.8.1 uid: 2.0.2 optionalDependencies: - '@nestjs/platform-express': 11.1.19(@nestjs/common@11.1.19(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19) + '@nestjs/platform-express': 11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19) - '@nestjs/platform-express@11.1.19(@nestjs/common@11.1.19(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)': + '@nestjs/mapped-types@2.1.1(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)': dependencies: - '@nestjs/common': 11.1.19(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.19(@nestjs/common@11.1.19(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.15.1 + + '@nestjs/platform-express@11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)': + dependencies: + '@nestjs/common': 11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2) cors: 2.8.6 express: 5.2.1 multer: 2.1.1 @@ -4620,13 +4691,28 @@ snapshots: transitivePeerDependencies: - chokidar - '@nestjs/testing@11.1.19(@nestjs/common@11.1.19(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(@nestjs/platform-express@11.1.19)': + '@nestjs/swagger@11.4.2(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)': dependencies: - '@nestjs/common': 11.1.19(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.19(@nestjs/common@11.1.19(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@microsoft/tsdoc': 0.16.0 + '@nestjs/common': 11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': 2.1.1(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2) + js-yaml: 4.1.1 + lodash: 4.18.1 + path-to-regexp: 8.4.2 + reflect-metadata: 0.2.2 + swagger-ui-dist: 5.32.4 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.15.1 + + '@nestjs/testing@11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(@nestjs/platform-express@11.1.19)': + dependencies: + '@nestjs/common': 11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 optionalDependencies: - '@nestjs/platform-express': 11.1.19(@nestjs/common@11.1.19(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19) + '@nestjs/platform-express': 11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19) '@noble/hashes@1.8.0': {} @@ -4649,6 +4735,8 @@ snapshots: dependencies: cluster-key-slot: 1.1.2 + '@scarf/scarf@1.4.0': {} + '@sinclair/typebox@0.34.49': {} '@sinonjs/commons@3.0.1': @@ -5588,6 +5676,8 @@ snapshots: cjs-module-lexer@2.2.0: {} + class-transformer@0.5.1: {} + class-validator@0.15.1: dependencies: '@types/validator': 13.15.10 @@ -7167,6 +7257,19 @@ snapshots: dependencies: has-flag: 4.0.0 + swagger-ui-dist@5.32.4: + dependencies: + '@scarf/scarf': 1.4.0 + + swagger-ui-dist@5.32.5: + dependencies: + '@scarf/scarf': 1.4.0 + + swagger-ui-express@5.0.1(express@5.2.1): + dependencies: + express: 5.2.1 + swagger-ui-dist: 5.32.5 + symbol-observable@4.0.0: {} synckit@0.11.12: diff --git a/src/app.module.ts b/src/app.module.ts index 86ac191..b43e3d7 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -6,6 +6,7 @@ import {XPosterModule} from "./x-poster/x-poster.module"; import {ConfigModule} from "@nestjs/config"; import {CacheModule} from "@nestjs/cache-manager"; import KeyvRedis from "@keyv/redis"; +import {XbotFollowModule} from "./xbot-follow/xbot-follow.module"; @Module({ imports: [ @@ -24,7 +25,8 @@ import KeyvRedis from "@keyv/redis"; }), }), SqsModule, - XPosterModule + XPosterModule, + XbotFollowModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/main.ts b/src/main.ts index 724f942..af47f96 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,12 +1,33 @@ import {NestFactory} from '@nestjs/core'; import {AppModule} from './app.module'; import {SqsPosterWorker} from "./sqs-module/sqs.poster.worker"; +import {DocumentBuilder, SwaggerModule} from "@nestjs/swagger"; async function bootstrap() { const app = await NestFactory.create(AppModule); - const port = process.env.PORT || 3000; - await app.listen(port, () => console.log(`Listening on port ${port}`)); + // Cấu hình Swagger + const config = new DocumentBuilder() + .setTitle('X Poster API') + .setDescription('Mô tả chi tiết về các endpoint API') + .setVersion('1.0') + .addTag('users') // Phân nhóm API (tùy chọn) + .build(); + + const document = SwaggerModule.createDocument(app, config); + // Thiết lập đường dẫn truy cập tài liệu (ví dụ: http://localhost:3000/api) + SwaggerModule.setup('api', app, document); + + const port = process.env.PORT || 3003; + await app.listen(port, () => + + console.log(` + 🔥 X-Poster is running! + 📡 API: http://localhost:${port} + 📖 Swagger: http://localhost:${port}/docs + `) + ); + await app.get(SqsPosterWorker).start(); diff --git a/src/x-poster/x-browser.service.ts b/src/x-poster/x-browser.service.ts index 4bdb7d1..199ba9f 100644 --- a/src/x-poster/x-browser.service.ts +++ b/src/x-poster/x-browser.service.ts @@ -1,12 +1,6 @@ // src/modules/x-browser/x-browser.service.ts -import { - Injectable, - Logger, - OnModuleInit, - OnModuleDestroy, HttpException, -} from '@nestjs/common'; -import {chromium, Browser, BrowserContext, Page} from 'playwright'; -import {buildXCookies} from "./utils/x-headers.util"; +import {HttpException, Injectable, Logger, OnModuleDestroy, OnModuleInit,} from '@nestjs/common'; +import {Browser, BrowserContext, chromium, Page} from 'playwright'; import {rand} from "../helper"; export interface BrowserAccount { @@ -91,7 +85,7 @@ export class XBrowserService implements OnModuleInit, OnModuleDestroy { 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', viewport: {width: 1366, height: 768}, - locale: 'en-US', + locale: process.env.BROWSER_LOCALE || 'en-US', proxy: account.proxy ? {server: account.proxy} : undefined, }); console.log('getOrCreateContext:5') diff --git a/src/x-poster/x-poster.router.service.ts b/src/x-poster/x-poster.router.service.ts index 9691e4d..91bff0e 100644 --- a/src/x-poster/x-poster.router.service.ts +++ b/src/x-poster/x-poster.router.service.ts @@ -77,7 +77,7 @@ export class XPosterRouterService { }, ], proxy: '', - userAgent: '' + userAgent: process.env.BROWSER_USER_AGENT || '', }, }; diff --git a/src/xbot-follow/dto/follow-followers.dto.ts b/src/xbot-follow/dto/follow-followers.dto.ts new file mode 100644 index 0000000..1f20260 --- /dev/null +++ b/src/xbot-follow/dto/follow-followers.dto.ts @@ -0,0 +1,18 @@ +import { IsString, IsOptional, IsInt, Min, Max } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class FollowFollowersDto { + @IsString() + targetUsername: string; // follow những ai đang follow người này + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 10; + + @IsOptional() + @Type(() => Boolean) + skipVerified?: boolean = false; +} \ No newline at end of file diff --git a/src/xbot-follow/dto/follow-one.dto.ts b/src/xbot-follow/dto/follow-one.dto.ts new file mode 100644 index 0000000..eea4075 --- /dev/null +++ b/src/xbot-follow/dto/follow-one.dto.ts @@ -0,0 +1,7 @@ +import { IsString, Length } from 'class-validator'; + +export class FollowOneDto { + @IsString() + @Length(1, 50) + username: string; // không có @, ví dụ: "elonmusk" +} \ No newline at end of file diff --git a/src/xbot-follow/playwright-x.service.ts b/src/xbot-follow/playwright-x.service.ts new file mode 100644 index 0000000..5bbe1df --- /dev/null +++ b/src/xbot-follow/playwright-x.service.ts @@ -0,0 +1,88 @@ +import {Injectable, Logger, OnModuleDestroy, OnModuleInit} from '@nestjs/common'; +import {ConfigService} from '@nestjs/config'; +import {Browser, BrowserContext, chromium, Page} from 'playwright'; + +@Injectable() +export class PlaywrightXService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(PlaywrightXService.name); + private browser: Browser; + private context: BrowserContext; + + constructor(private readonly config: ConfigService) { + } + + async onModuleInit() { + // Launch với stealth args để giảm bị detect + this.browser = await chromium.launch({ + headless: false, // Để true khi đã test ổn định + args: [ + '--disable-blink-features=AutomationControlled', + '--disable-features=IsolateOrigins,site-per-process', + '--disable-infobars', + '--no-sandbox', + '--window-size=1366,768', + ], + }); + + this.context = await this.browser.newContext({ + viewport: {width: 1366, height: 768}, + userAgent: process.env.BROWSER_USER_AGENT || + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' + + '(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + locale: process.env.BROWSER_LOCALE || 'en-US', + permissions: ['notifications'], + }); + + // Pre-warm: set cookie rồi vào X kiểm tra login + await this.restoreSession(); + } + + async onModuleDestroy() { + await this.context?.close(); + await this.browser?.close(); + } + + /** Tạo page mới từ context đã login */ + async newPage(): Promise { + return this.context.newPage(); + } + + /** Set cookie auth_token, ct0, kdt vào context */ + private async restoreSession() { + const authToken = this.config.get('X_COOKIE_AUTH_TOKEN'); + const ct0 = this.config.get('X_COOKIE_CT0'); + const kdt = this.config.get('X_COOKIE_KDT') || ''; + + if (!authToken || !ct0) { + this.logger.warn('🚨 Thiếu TWITTER_AUTH_TOKEN hoặc CT0 trong .env'); + return; + } + + await this.context.addCookies([ + { + name: 'auth_token', + value: authToken, + domain: '.x.com', + path: '/', + httpOnly: true, + secure: true, + sameSite: 'None' + }, + {name: 'ct0', value: ct0, domain: '.x.com', path: '/', secure: true, sameSite: 'Lax'}, + {name: 'kdt', value: kdt, domain: '.x.com', path: '/', secure: true, sameSite: 'Lax'}, + ]); + + const page = await this.context.newPage(); + await page.goto('https://x.com/home', {waitUntil: 'domcontentloaded', timeout: 30000}); + await page.waitForTimeout(3000); + + const isLoggedIn = await page + .locator('[data-testid="SideNav_AccountSwitcher_Button"], [data-testid="AppTabBar_Home_Link"]') + .first() + .isVisible() + .catch(() => false); + + this.logger.log(`🔐 Session restore: ${isLoggedIn ? 'LOGGED IN' : 'GUEST (cookie có thể expired)'}`); + await page.close(); + } +} \ No newline at end of file diff --git a/src/xbot-follow/types.ts b/src/xbot-follow/types.ts new file mode 100644 index 0000000..4cfaee9 --- /dev/null +++ b/src/xbot-follow/types.ts @@ -0,0 +1,21 @@ +export interface FollowOneResult { + username: string; + success: boolean; + alreadyFollowing: boolean; + error?: string; +} + +export interface FollowBatchResult { + targetSource: string; + totalScanned: number; + followed: string[]; // username đã follow thành công + skipped: Array<{ username: string; reason: string }>; + failed: Array<{ username: string; error: string }>; +} + +export interface FollowFollowersOptions { + limit?: number; // mặc định 10 + skipVerified?: boolean; // bỏ qua tick xanh (nếu detect được) + skipIfBioEmpty?: boolean; // chưa implement trong script cơ bản, để mở rộng + delayRange?: [number, number]; // [min, max] ms giữa các lần follow +} \ No newline at end of file diff --git a/src/xbot-follow/xbot-follow.controller.ts b/src/xbot-follow/xbot-follow.controller.ts new file mode 100644 index 0000000..20d3296 --- /dev/null +++ b/src/xbot-follow/xbot-follow.controller.ts @@ -0,0 +1,26 @@ +import {Body, Controller, Get, Param, Post} from '@nestjs/common'; +import {FollowFollowersDto} from './dto/follow-followers.dto'; +import {XbotFollowService} from "./xbot-follow.service"; + +@Controller('x-auto') +export class XbotFollowController { + constructor(private readonly followService: XbotFollowService) { + } + + /** GET /x-auto/follow { "username": "billgates" } */ + @Get('follow/:username') + async followOne(@Param('username') username: string) { + const res = await this.followService.followOne(username); + return res; + } + + /** POST /x-auto/follow/followers-of { "targetUsername": "elonmusk", "limit": 5 } */ + @Post('follow/followers-of') + async followFollowers(@Body() dto: FollowFollowersDto) { + const res = await this.followService.followFollowersOf(dto.targetUsername, { + limit: dto.limit, + skipVerified: dto.skipVerified, + }); + return res; + } +} \ No newline at end of file diff --git a/src/xbot-follow/xbot-follow.module.ts b/src/xbot-follow/xbot-follow.module.ts new file mode 100644 index 0000000..b5b6cb6 --- /dev/null +++ b/src/xbot-follow/xbot-follow.module.ts @@ -0,0 +1,16 @@ +import {Global, Module} from "@nestjs/common"; +import {XbotFollowController} from "./xbot-follow.controller"; +import {XbotFollowService} from "./xbot-follow.service"; +import {PlaywrightXService} from "./playwright-x.service"; + +@Global() +@Module({ + imports: [], + providers: [ + PlaywrightXService, + XbotFollowService + ], + controllers: [XbotFollowController], + exports: [XbotFollowService], +}) +export class XbotFollowModule {} \ No newline at end of file diff --git a/src/xbot-follow/xbot-follow.service.ts b/src/xbot-follow/xbot-follow.service.ts new file mode 100644 index 0000000..1946b66 --- /dev/null +++ b/src/xbot-follow/xbot-follow.service.ts @@ -0,0 +1,200 @@ +import {Injectable, Logger} from '@nestjs/common'; +import {Page} from 'playwright'; +import {PlaywrightXService} from './playwright-x.service'; +import {FollowBatchResult, FollowFollowersOptions, FollowOneResult} from './types'; + +@Injectable() +export class XbotFollowService { + private readonly logger = new Logger(XbotFollowService.name); + private readonly sessionFollowed = new Set(); // cache tránh follow lại trong cùng phiên + + constructor( + private readonly pwService: PlaywrightXService, + // private readonly config: ConfigService, + ) {} + + // =================== 1. FOLLOW TRỰC TIẾP =================== + + async followOne(username: string, page?: Page): Promise { + const p = page ?? (await this.pwService.newPage()); + const shouldClose = !page; + + const target = username.replace(/^@/, '').toLowerCase(); + this.logger.log(`➡️ Đang follow @${target}...`); + + try { + await p.goto(`https://x.com/${target}`, { + waitUntil: 'domcontentloaded', + timeout: 30000, + }); + await p.waitForLoadState('networkidle'); + await this.humanDelay(1500, 3000); + + // Check account tồn tại + if (await this.isTextPresent(p, /doesn\'t exist|Coundn.t be found/)) { + return { username: target, success: false, alreadyFollowing: false, error: 'Account not found' }; + } + + // Nếu đã follow rồi -> nút sẽ là "Following" (unfollowButton) + if (await this.isVisible(p, 'button[data-testid="unfollowButton"]')) { + this.logger.log(` → Already following @${target}`); + return { username: target, success: true, alreadyFollowing: true }; + } + + // Click Follow + const followBtn = p.locator('button[data-testid="followButton"]:not([disabled])').first(); + if (await followBtn.isVisible().catch(() => false)) { + await followBtn.scrollIntoViewIfNeeded(); + await this.humanDelay(400, 900); + await followBtn.click(); + + await this.humanDelay(1200, 2500); + + // Verify chuyển sang Following + const success = await this.isVisible(p, 'button[data-testid="unfollowButton"]'); + if (success) this.sessionFollowed.add(target); + + return { + username: target, + success, + alreadyFollowing: false, + error: success ? undefined : 'Clicked but state did not change', + }; + } + + return { username: target, success: false, alreadyFollowing: false, error: 'Follow button not found' }; + } catch (err: any) { + this.logger.error(` ❌ Lỗi follow @${target}: ${err.message}`); + return { username: target, success: false, alreadyFollowing: false, error: err.message }; + } finally { + if (shouldClose) await p.close(); + } + } + + // =================== 2. FOLLOW FOLLOWERS CỦA USER CHỈ ĐỊNH =================== + + async followFollowersOf( + targetUsername: string, + options: FollowFollowersOptions = {}, + ): Promise { + const { limit = 5, delayRange = [2000, 5000] } = options; + const source = targetUsername.replace(/^@/, '').toLowerCase(); + + const result: FollowBatchResult = { + targetSource: source, + totalScanned: 0, + followed: [], + skipped: [], + failed: [], + }; + + const page = await this.pwService.newPage(); + this.logger.log(`🔍 Mở danh sách followers của @${source} (target: ${limit})`); + + try { + await page.goto(`https://x.com/${source}/followers`, { + waitUntil: 'domcontentloaded', + timeout: 30000, + }); + await page.waitForLoadState('networkidle'); + await this.humanDelay(2500, 4000); + + let lastHeight = -1; + let stagnant = 0; + + while (result.followed.length < limit && stagnant < 4) { + // Lấy các cell user đang hiển thị + const cells = await page.locator('div[data-testid="cellInnerDiv"]').all(); + + for (const cell of cells) { + result.totalScanned++; + + // Lấy username từ link đầu tiên trong cell + const link = cell.locator('a[href^="/"][role="link"]').first(); + const href = await link.getAttribute('href').catch(() => null); + if (!href) continue; + + const user = href.replace('/', '').split('?')[0].toLowerCase(); + if (!user || user === source) continue; // Bỏ qua chính chủ + if (this.sessionFollowed.has(user)) { + result.skipped.push({ username: user, reason: 'already-in-session' }); + continue; + } + + // Đã follow chưa? + const isAlreadyFollowing = await cell + .locator('button[data-testid="unfollowButton"]') + .isVisible() + .catch(() => false); + + if (isAlreadyFollowing) { + result.skipped.push({ username: user, reason: 'already-following' }); + continue; + } + + // Click follow trong cell + const btn = cell.locator('button[data-testid="followButton"]:not([disabled])').first(); + if (await btn.isVisible().catch(() => false)) { + await btn.scrollIntoViewIfNeeded(); + await this.humanDelay(500, 1200); + await btn.click(); + await this.humanDelay(delayRange[0], delayRange[1]); + + // Verify + const ok = await cell + .locator('button[data-testid="unfollowButton"]') + .isVisible() + .catch(() => false); + + if (ok) { + result.followed.push(user); + this.sessionFollowed.add(user); + this.logger.log(` ✅ ${result.followed.length}/${limit} | @${user}`); + } else { + result.failed.push({ username: user, error: 'State unchanged after click' }); + } + } + + if (result.followed.length >= limit) break; + } + + // Scroll để load thêm + await page.evaluate(() => window.scrollBy(0, Math.floor(500 + Math.random() * 500))); + await this.humanDelay(1500, 3000); + + const currentHeight = await page.evaluate(() => document.body.scrollHeight); + if (currentHeight === lastHeight) { + stagnant++; + } else { + stagnant = 0; + lastHeight = currentHeight; + } + } + } catch (err: any) { + this.logger.error(`❌ Batch follow lỗi: ${err.message}`); + } finally { + await page.close(); + } + + this.logger.log( + `🏁 Kết quả: Followed ${result.followed.length}, Skipped ${result.skipped.length}, Failed ${result.failed.length}`, + ); + return result; + } + + // =================== HELPERS =================== + + private async humanDelay(min: number, max: number) { + const ms = Math.floor(min + Math.random() * (max - min)); + await new Promise((r) => setTimeout(r, ms)); + } + + private async isVisible(page: Page, selector: string): Promise { + return page.locator(selector).first().isVisible().catch(() => false); + } + + private async isTextPresent(page: Page, regex: RegExp): Promise { + const text = await page.locator('body').innerText().catch(() => ''); + return regex.test(text); + } +} \ No newline at end of file