This commit is contained in:
NAME
2026-05-11 08:02:34 +00:00
parent c72a38201a
commit b5cf2502be
13 changed files with 534 additions and 35 deletions
+3 -1
View File
@@ -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],
+23 -2
View File
@@ -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();
+3 -9
View File
@@ -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')
+1 -1
View File
@@ -77,7 +77,7 @@ export class XPosterRouterService {
},
],
proxy: '',
userAgent: ''
userAgent: process.env.BROWSER_USER_AGENT || '',
},
};
@@ -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;
}
+7
View File
@@ -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"
}
+88
View File
@@ -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<Page> {
return this.context.newPage();
}
/** Set cookie auth_token, ct0, kdt vào context */
private async restoreSession() {
const authToken = this.config.get<string>('X_COOKIE_AUTH_TOKEN');
const ct0 = this.config.get<string>('X_COOKIE_CT0');
const kdt = this.config.get<string>('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();
}
}
+21
View File
@@ -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
}
+26
View File
@@ -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;
}
}
+16
View File
@@ -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 {}
+200
View File
@@ -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<string>(); // 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<FollowOneResult> {
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<FollowBatchResult> {
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<boolean> {
return page.locator(selector).first().isVisible().catch(() => false);
}
private async isTextPresent(page: Page, regex: RegExp): Promise<boolean> {
const text = await page.locator('body').innerText().catch(() => '');
return regex.test(text);
}
}