U
This commit is contained in:
+3
-1
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user