update
This commit is contained in:
@@ -145,7 +145,9 @@ export class SqsPosterWorker {
|
||||
return r
|
||||
} catch (e) {
|
||||
this.logger.error(e);
|
||||
await this.notifyService.sendMessageToTele(`Worker==> doReplyTweet error:${e.message}`)
|
||||
console.log("Mã lỗi:", e.code); // Ví dụ: 'ECONNABORTED' (timeout), 'ERR_NETWORK' (mất mạng)
|
||||
console.log("Thông báo:", e.message);
|
||||
await this.notifyService.sendMessageToTele(`Worker==> doReplyTweet error:${e.code} - ${e.message}`)
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -32,3 +32,42 @@ export function buildXCookies() {
|
||||
{name: "lang", value: "en"},
|
||||
];
|
||||
}
|
||||
|
||||
export function getAccount() {
|
||||
return {
|
||||
id: process.env.X_USERNAME!,
|
||||
api: {
|
||||
accessToken: '',
|
||||
accessSecret: '',
|
||||
appKey: process.env.TWITTER_CLIENT_ID + '',
|
||||
appSecret: process.env.TWITTER_CLIENT_SECRET!,
|
||||
},
|
||||
cookie: {
|
||||
authToken: process.env.X_COOKIE_AUTH_TOKEN!, // auth_token cookie
|
||||
ct0: process.env.X_COOKIE_CT0!, // ct0 cookie (CSRF token)
|
||||
kdt: process.env.X_COOKIE_KDT!, // ct0 cookie (CSRF token)
|
||||
proxy: '',
|
||||
},
|
||||
browser: {
|
||||
accountId: process.env.X_USERNAME!,
|
||||
cookies: [
|
||||
{
|
||||
name: 'auth_token',
|
||||
value: process.env.X_COOKIE_AUTH_TOKEN!,
|
||||
},
|
||||
{
|
||||
name: 'ct0',
|
||||
value: process.env.X_COOKIE_CT0!,
|
||||
},
|
||||
{
|
||||
name: 'kdt',
|
||||
value: process.env.X_COOKIE_KDT!,
|
||||
},
|
||||
],
|
||||
proxy: '',
|
||||
userAgent: process.env.BROWSER_USER_AGENT || '',
|
||||
headless: Number(process.env.BROWSER_IS_HEADLESS) === 1,
|
||||
},
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import {HttpException, Injectable, Logger, OnModuleDestroy, OnModuleInit,} from '@nestjs/common';
|
||||
import {Browser, BrowserContext, chromium, Page} from 'playwright';
|
||||
import {rand} from "../helper";
|
||||
import {getAccount} from "./utils/x-headers.util";
|
||||
|
||||
export interface BrowserAccount {
|
||||
accountId: string;
|
||||
@@ -13,6 +14,7 @@ export interface BrowserAccount {
|
||||
}>;
|
||||
proxy?: string;
|
||||
userAgent?: string;
|
||||
headless?: boolean;
|
||||
}
|
||||
|
||||
export interface BrowserTweetResult {
|
||||
@@ -38,13 +40,15 @@ export class XBrowserService implements OnModuleInit, OnModuleDestroy {
|
||||
setInterval(() => this.cleanupStaleContexts(), 60_000);
|
||||
}
|
||||
|
||||
private async ensureBrowser(): Promise<Browser> {
|
||||
private async ensureBrowser(headless = true): Promise<Browser> {
|
||||
if (this.browser && this.browser.isConnected()) return this.browser;
|
||||
this.logger.log('Launching Chromium...');
|
||||
this.browser = await chromium.launch({
|
||||
headless: true,
|
||||
headless,
|
||||
args: [
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--disable-features=IsolateOrigins,site-per-process',
|
||||
'--disable-infobars',
|
||||
'--no-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
],
|
||||
@@ -76,7 +80,7 @@ export class XBrowserService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
console.log('getOrCreateContext:3')
|
||||
|
||||
const browser = await this.ensureBrowser();
|
||||
const browser = await this.ensureBrowser(account.headless);
|
||||
console.log('getOrCreateContext:4')
|
||||
|
||||
const ctx = await browser.newContext({
|
||||
@@ -115,7 +119,12 @@ export class XBrowserService implements OnModuleInit, OnModuleDestroy {
|
||||
return ctx;
|
||||
}
|
||||
|
||||
private async getPage(account: BrowserAccount): Promise<Page> {
|
||||
async newPage(): Promise<Page> {
|
||||
const account = getAccount();
|
||||
return this.getPage(account.browser)
|
||||
}
|
||||
|
||||
async getPage(account: BrowserAccount): Promise<Page> {
|
||||
let ctx = await this.getOrCreateContext(account);
|
||||
console.log('Đã khởi tạo ctx')
|
||||
if (ctx.isClosed()) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {BrowserAccount, XBrowserService} from "./x-browser.service";
|
||||
import {XApiService} from "./x-api.service";
|
||||
import {XCookieService} from "./x-cookie.service";
|
||||
import {NotifyService} from "../notify.service";
|
||||
import {getAccount} from "./utils/x-headers.util";
|
||||
|
||||
export enum SUPPORT_SOCIAL_PROVIDERS {
|
||||
FB = 'fb',
|
||||
@@ -51,35 +52,7 @@ export class XPosterRouterService {
|
||||
private readonly browserSvc: XBrowserService,
|
||||
private readonly notifyService: NotifyService,
|
||||
) {
|
||||
this.X_UNIFIED_ACCOUNT = {
|
||||
id: process.env.X_USERNAME!,
|
||||
api: {
|
||||
accessToken: '',
|
||||
accessSecret: '',
|
||||
appKey: process.env.TWITTER_CLIENT_ID + '',
|
||||
appSecret: process.env.TWITTER_CLIENT_SECRET!,
|
||||
},
|
||||
cookie: {
|
||||
authToken: process.env.X_COOKIE_AUTH_TOKEN!, // auth_token cookie
|
||||
ct0: process.env.X_COOKIE_CT0!, // ct0 cookie (CSRF token)
|
||||
proxy: '',
|
||||
},
|
||||
browser: {
|
||||
accountId: process.env.X_USERNAME!,
|
||||
cookies: [
|
||||
{
|
||||
name: 'auth_token',
|
||||
value: process.env.X_COOKIE_AUTH_TOKEN!,
|
||||
},
|
||||
{
|
||||
name: 'ct0',
|
||||
value: process.env.X_COOKIE_CT0!,
|
||||
},
|
||||
],
|
||||
proxy: '',
|
||||
userAgent: process.env.BROWSER_USER_AGENT || '',
|
||||
},
|
||||
};
|
||||
this.X_UNIFIED_ACCOUNT = getAccount();
|
||||
|
||||
console.error(this.X_UNIFIED_ACCOUNT);
|
||||
|
||||
|
||||
@@ -1,88 +1,107 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
// 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();
|
||||
// }
|
||||
//
|
||||
// // async onModuleInit() {
|
||||
// // // Lazy launch – chỉ mở khi cần
|
||||
// // setInterval(() => this.cleanupStaleContexts(), 60_000);
|
||||
// // }
|
||||
//
|
||||
// private async ensureBrowser(): Promise<Browser> {
|
||||
// if (this.browser && this.browser.isConnected()) return this.browser;
|
||||
// this.logger.log('Launching Chromium...');
|
||||
// this.browser = await chromium.launch({
|
||||
// headless: true,
|
||||
// args: [
|
||||
// '--disable-blink-features=AutomationControlled',
|
||||
// '--no-sandbox',
|
||||
// '--disable-dev-shm-usage',
|
||||
// ],
|
||||
// });
|
||||
// return this.browser;
|
||||
// }
|
||||
//
|
||||
// /** 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();
|
||||
// }
|
||||
// }
|
||||
@@ -1,13 +1,11 @@
|
||||
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],
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import {Injectable, Logger} from '@nestjs/common';
|
||||
import {Page} from 'playwright';
|
||||
import {PlaywrightXService} from './playwright-x.service';
|
||||
import {FollowBatchResult, FollowFollowersOptions, FollowOneResult} from './types';
|
||||
import {XBrowserService} from "../x-poster/x-browser.service";
|
||||
import {rand} from "../helper";
|
||||
|
||||
@Injectable()
|
||||
export class XbotFollowService {
|
||||
@@ -9,9 +10,10 @@ export class XbotFollowService {
|
||||
private readonly sessionFollowed = new Set<string>(); // cache tránh follow lại trong cùng phiên
|
||||
|
||||
constructor(
|
||||
private readonly pwService: PlaywrightXService,
|
||||
private readonly pwService: XBrowserService,
|
||||
// private readonly config: ConfigService,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
// =================== 1. FOLLOW TRỰC TIẾP ===================
|
||||
|
||||
@@ -27,22 +29,29 @@ export class XbotFollowService {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 30000,
|
||||
});
|
||||
await p.waitForLoadState('networkidle');
|
||||
await p.waitForTimeout(1000 + (Math.random() + Math.random()) * 3000);
|
||||
await p.mouse.wheel(0, rand(300, 500));
|
||||
await p.waitForTimeout(rand(1000, 3000));
|
||||
|
||||
await this.humanDelay(1500, 3000);
|
||||
this.logger.log(`➡️ Check account tồn tại...`);
|
||||
|
||||
// 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' };
|
||||
return {username: target, success: false, alreadyFollowing: false, error: 'Account not found'};
|
||||
}
|
||||
|
||||
this.logger.log(`➡️ Nếu đã follow rồi -> nút sẽ là "Following" (unfollowButton)`);
|
||||
// Nếu đã follow rồi -> nút sẽ là "Following" (unfollowButton)
|
||||
if (await this.isVisible(p, 'button[data-testid="unfollowButton"]')) {
|
||||
if (await this.isVisible(p, 'button[data-testid$="-unfollow"]')) {
|
||||
this.logger.log(` → Already following @${target}`);
|
||||
return { username: target, success: true, alreadyFollowing: true };
|
||||
return {username: target, success: true, alreadyFollowing: true};
|
||||
}
|
||||
|
||||
// Click Follow
|
||||
const followBtn = p.locator('button[data-testid="followButton"]:not([disabled])').first();
|
||||
this.logger.log(`➡️Click follow`);
|
||||
|
||||
// Click Follow: data-testid kết thúc bằng "-follow"
|
||||
const followBtn = p.locator('button[data-testid$="-follow"]').first();
|
||||
if (await followBtn.isVisible().catch(() => false)) {
|
||||
await followBtn.scrollIntoViewIfNeeded();
|
||||
await this.humanDelay(400, 900);
|
||||
@@ -50,8 +59,8 @@ export class XbotFollowService {
|
||||
|
||||
await this.humanDelay(1200, 2500);
|
||||
|
||||
// Verify chuyển sang Following
|
||||
const success = await this.isVisible(p, 'button[data-testid="unfollowButton"]');
|
||||
// Verify chuyển sang unfollow
|
||||
const success = await this.isVisible(p, 'button[data-testid$="-unfollow"]');
|
||||
if (success) this.sessionFollowed.add(target);
|
||||
|
||||
return {
|
||||
@@ -62,10 +71,13 @@ export class XbotFollowService {
|
||||
};
|
||||
}
|
||||
|
||||
return { username: target, success: false, alreadyFollowing: false, error: 'Follow button not found' };
|
||||
|
||||
this.logger.error(`❌Follow button not found`);
|
||||
|
||||
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 };
|
||||
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();
|
||||
}
|
||||
@@ -77,7 +89,7 @@ export class XbotFollowService {
|
||||
targetUsername: string,
|
||||
options: FollowFollowersOptions = {},
|
||||
): Promise<FollowBatchResult> {
|
||||
const { limit = 5, delayRange = [2000, 5000] } = options;
|
||||
const {limit = 5, delayRange = [2000, 5000]} = options;
|
||||
const source = targetUsername.replace(/^@/, '').toLowerCase();
|
||||
|
||||
const result: FollowBatchResult = {
|
||||
@@ -117,7 +129,7 @@ export class XbotFollowService {
|
||||
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' });
|
||||
result.skipped.push({username: user, reason: 'already-in-session'});
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -128,7 +140,7 @@ export class XbotFollowService {
|
||||
.catch(() => false);
|
||||
|
||||
if (isAlreadyFollowing) {
|
||||
result.skipped.push({ username: user, reason: 'already-following' });
|
||||
result.skipped.push({username: user, reason: 'already-following'});
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -151,7 +163,7 @@ export class XbotFollowService {
|
||||
this.sessionFollowed.add(user);
|
||||
this.logger.log(` ✅ ${result.followed.length}/${limit} | @${user}`);
|
||||
} else {
|
||||
result.failed.push({ username: user, error: 'State unchanged after click' });
|
||||
result.failed.push({username: user, error: 'State unchanged after click'});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user