Update
This commit is contained in:
+3
-1
@@ -59,4 +59,6 @@ dist
|
|||||||
.idea
|
.idea
|
||||||
dist.zip
|
dist.zip
|
||||||
|
|
||||||
data/*
|
data/*
|
||||||
|
x_profile_data
|
||||||
|
x_profile_data/*
|
||||||
@@ -198,20 +198,6 @@ export class XBrowserService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return isLoggedIn;
|
return isLoggedIn;
|
||||||
}
|
}
|
||||||
|
|
||||||
// async actVerifyCookie(): Promise<any> {
|
|
||||||
// this.logger.debug('==> actVerifyCookie');
|
|
||||||
// // const isAlive = await this.cookieSvc.verifyCookie();
|
|
||||||
// const isAlive = await this.verifyCookie();
|
|
||||||
// if (!isAlive) {
|
|
||||||
// await this.xCacheService.setStateXCookiesIsDie();
|
|
||||||
// await this.notifyService.sendUrgentMessageToTele('❌Cookie đã hết hạn vui lòng cập nhập để sử dụng.')
|
|
||||||
// }
|
|
||||||
// await this.xCacheService.setStateXCookiesIsSillALive();
|
|
||||||
// await this.notifyService.sendMessageToTele('✅Verify cookie pass')
|
|
||||||
//
|
|
||||||
// return isAlive;
|
|
||||||
// }
|
|
||||||
|
|
||||||
async likeTweet(tweetUrl: string) {
|
async likeTweet(tweetUrl: string) {
|
||||||
let page: Page | null = null;
|
let page: Page | null = null;
|
||||||
|
|
||||||
@@ -595,8 +581,6 @@ export class XBrowserService implements OnModuleInit, OnModuleDestroy {
|
|||||||
await page?.close().catch(() => null);
|
await page?.close().catch(() => null);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async cleanupStaleContexts() {
|
private async cleanupStaleContexts() {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {XCookieService} from "./x-cookie.service";
|
|||||||
import {XApiService} from "./x-api.service";
|
import {XApiService} from "./x-api.service";
|
||||||
import {XCacheService} from "../x-cache/x-cache.service";
|
import {XCacheService} from "../x-cache/x-cache.service";
|
||||||
import {XBrowserService} from "./x-browser.service";
|
import {XBrowserService} from "./x-browser.service";
|
||||||
|
import {XPwService} from "./x.pw.service";
|
||||||
|
|
||||||
@Controller('')
|
@Controller('')
|
||||||
export class XPosterController {
|
export class XPosterController {
|
||||||
@@ -14,10 +15,25 @@ export class XPosterController {
|
|||||||
private readonly xCookieService: XCookieService,
|
private readonly xCookieService: XCookieService,
|
||||||
private readonly xBrowserService: XBrowserService,
|
private readonly xBrowserService: XBrowserService,
|
||||||
private readonly xCacheService: XCacheService,
|
private readonly xCacheService: XCacheService,
|
||||||
private readonly xApiService: XApiService
|
private readonly xApiService: XApiService,
|
||||||
|
private readonly xPwService: XPwService,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('x_view')
|
||||||
|
async twitterViewPage(@Query('url') url: string,) {
|
||||||
|
const targetUrl = url || 'https://x.com';
|
||||||
|
|
||||||
|
// Gọi dịch vụ chạy profile
|
||||||
|
const title = await this.xPwService.runWithProfile(targetUrl);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Đã chạy xong kịch bản cho trang web`,
|
||||||
|
pageTitle: title,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@Get('tw_callback')
|
@Get('tw_callback')
|
||||||
async twitterAuthCallback(
|
async twitterAuthCallback(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {XPosterRouterService} from "./x-poster.router.service";
|
|||||||
import {XCookieService} from "./x-cookie.service";
|
import {XCookieService} from "./x-cookie.service";
|
||||||
import {XCacheService} from "../x-cache/x-cache.service";
|
import {XCacheService} from "../x-cache/x-cache.service";
|
||||||
import {NotifyService} from "../notify.service";
|
import {NotifyService} from "../notify.service";
|
||||||
|
import {XPwService} from "./x.pw.service";
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
@@ -18,6 +19,7 @@ import {NotifyService} from "../notify.service";
|
|||||||
XCacheService,
|
XCacheService,
|
||||||
XPosterRouterService,
|
XPosterRouterService,
|
||||||
NotifyService,
|
NotifyService,
|
||||||
|
XPwService
|
||||||
],
|
],
|
||||||
controllers: [XPosterController],
|
controllers: [XPosterController],
|
||||||
exports: [XApiService, XBrowserService, XPosterRouterService],
|
exports: [XApiService, XBrowserService, XPosterRouterService],
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {XCookieService} from "./x-cookie.service";
|
|||||||
import {NotifyService} from "../notify.service";
|
import {NotifyService} from "../notify.service";
|
||||||
import {getAccount} from "./utils/x-headers.util";
|
import {getAccount} from "./utils/x-headers.util";
|
||||||
import {XCacheService} from "../x-cache/x-cache.service";
|
import {XCacheService} from "../x-cache/x-cache.service";
|
||||||
|
import {XPwService} from "./x.pw.service";
|
||||||
|
|
||||||
export enum SUPPORT_SOCIAL_PROVIDERS {
|
export enum SUPPORT_SOCIAL_PROVIDERS {
|
||||||
FB = 'fb',
|
FB = 'fb',
|
||||||
@@ -51,6 +52,7 @@ export class XPosterRouterService {
|
|||||||
private readonly apiSvc: XApiService,
|
private readonly apiSvc: XApiService,
|
||||||
private readonly cookieSvc: XCookieService,
|
private readonly cookieSvc: XCookieService,
|
||||||
private readonly browserSvc: XBrowserService,
|
private readonly browserSvc: XBrowserService,
|
||||||
|
private readonly browserProfileSvc: XPwService,
|
||||||
private readonly notifyService: NotifyService,
|
private readonly notifyService: NotifyService,
|
||||||
private readonly xCacheService: XCacheService,
|
private readonly xCacheService: XCacheService,
|
||||||
) {
|
) {
|
||||||
@@ -63,7 +65,7 @@ export class XPosterRouterService {
|
|||||||
async verifyCookie(): Promise<any> {
|
async verifyCookie(): Promise<any> {
|
||||||
this.logger.debug('==> Verify Cookie');
|
this.logger.debug('==> Verify Cookie');
|
||||||
// const isAlive = await this.cookieSvc.verifyCookie();
|
// const isAlive = await this.cookieSvc.verifyCookie();
|
||||||
const isAlive = await this.browserSvc.verifyCookie();
|
const isAlive = await this.browserProfileSvc.verifyCookie();
|
||||||
if (!isAlive) {
|
if (!isAlive) {
|
||||||
await this.xCacheService.setStateXCookiesIsDie();
|
await this.xCacheService.setStateXCookiesIsDie();
|
||||||
await this.notifyService.sendUrgentMessageToTele('❌Cookie đã hết hạn vui lòng cập nhập để sử dụng.')
|
await this.notifyService.sendUrgentMessageToTele('❌Cookie đã hết hạn vui lòng cập nhập để sử dụng.')
|
||||||
@@ -301,7 +303,8 @@ export class XPosterRouterService {
|
|||||||
// return await this.cookieSvc.createTweet(account.cookie, text);
|
// return await this.cookieSvc.createTweet(account.cookie, text);
|
||||||
}
|
}
|
||||||
if (method === 'browser' && account.browser) {
|
if (method === 'browser' && account.browser) {
|
||||||
return await this.browserSvc.postTweet(account.browser, text);
|
// return await this.browserSvc.postTweet(account.browser, text);
|
||||||
|
return await this.browserProfileSvc.postTweet(account.browser, text);
|
||||||
}
|
}
|
||||||
return {success: false, error: `Method ${method} not configured`};
|
return {success: false, error: `Method ${method} not configured`};
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|||||||
@@ -0,0 +1,511 @@
|
|||||||
|
import {HttpException, Injectable, Logger, OnModuleDestroy} from "@nestjs/common";
|
||||||
|
import {BrowserContext, chromium, Page} from "playwright";
|
||||||
|
import * as path from 'path';
|
||||||
|
import {_toNumber, rand} from "../helper";
|
||||||
|
import {getAccount} from "./utils/x-headers.util";
|
||||||
|
import {BrowserAccount, BrowserTweetResult} from "./x-browser.service";
|
||||||
|
import {XCacheService} from "../x-cache/x-cache.service";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class XPwService implements OnModuleDestroy {
|
||||||
|
private context: BrowserContext | null = null;
|
||||||
|
private readonly logger = new Logger("XPwService");
|
||||||
|
constructor(
|
||||||
|
private readonly xCacheService: XCacheService,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
async runWithProfile(targetUrl?: string): Promise<Page> {
|
||||||
|
// 1. Định nghĩa đường dẫn lưu trữ Profile (nằm trong thư mục dự án)
|
||||||
|
const profilePath = path.resolve(__dirname, `../../x_profile_data/${process.env.X_USERNAME}`);
|
||||||
|
|
||||||
|
// 2. Cấu hình các tham số ẩn danh (Antidetect)
|
||||||
|
const antidetectArgs = [
|
||||||
|
'--disable-blink-features=AutomationControlled',
|
||||||
|
// '--no-sandbox',
|
||||||
|
'--disable-infobars',
|
||||||
|
'--start-maximized',
|
||||||
|
'--no-default-browser-check',
|
||||||
|
// '--disable-setuid-sandbox'
|
||||||
|
`--user-agent=${process.env.BROWSER_USER_AGENT}`,
|
||||||
|
|
||||||
|
];
|
||||||
|
|
||||||
|
// 3. Khởi chạy Persistent Context nếu chưa được khởi tạo
|
||||||
|
if (!this.context || this.context.isClosed()) {
|
||||||
|
this.context = await chromium.launchPersistentContext(profilePath, {
|
||||||
|
// channel: 'chrome', // Sử dụng Google Chrome thật
|
||||||
|
headless: false, // Để False để xem giao diện và đăng nhập lần đầu
|
||||||
|
args: antidetectArgs,
|
||||||
|
ignoreDefaultArgs: ['--enable-automation', '--disable-extensions'],
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// THÊM DÒNG NÀY ĐỂ ÉP CHROME BẬT LẠI SANDBOX
|
||||||
|
// ==========================================
|
||||||
|
chromiumSandbox: true,
|
||||||
|
viewport: {
|
||||||
|
width: _toNumber(process.env.BROWSER_WP_WIDTH || 1479),
|
||||||
|
height: _toNumber(process.env.BROWSER_WP_HEIGHT || 795),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Lấy ra Tab (Page) đầu tiên có sẵn
|
||||||
|
const pages = this.context.pages();
|
||||||
|
const page: Page = pages.length > 0 ? pages[0] : await this.context.newPage();
|
||||||
|
|
||||||
|
// 5. Giả lập User-Agent giống người dùng thật
|
||||||
|
await page.setExtraHTTPHeaders({
|
||||||
|
'User-Agent': `${process.env.BROWSER_USER_AGENT}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return page;
|
||||||
|
// // 6. Điều hướng tới trang web mục tiêu
|
||||||
|
// console.log(`Đang truy cập: ${targetUrl}`);
|
||||||
|
// await page.goto(targetUrl, {waitUntil: 'domcontentloaded'});
|
||||||
|
//
|
||||||
|
// // Lấy tiêu đề trang để kiểm tra xem đã chạy thành công chưa
|
||||||
|
// const pageTitle = await page.title();
|
||||||
|
//
|
||||||
|
// // Lưu ý: Không đóng context ở đây nếu bạn muốn giữ phiên chạy tiếp theo nhanh hơn.
|
||||||
|
// // Nếu muốn đóng ngay sau khi xong việc, dùng: await this.context.close(); this.context = null;
|
||||||
|
//
|
||||||
|
// return pageTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async newPage(): Promise<Page> {
|
||||||
|
const account = getAccount();
|
||||||
|
return this.getPage(account.browser)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPage(account: BrowserAccount): Promise<Page> {
|
||||||
|
return this.runWithProfile()
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyCookie(sendNotiWhenAlive = false): Promise<boolean> {
|
||||||
|
const page = await this.newPage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.goto('https://x.com/', {
|
||||||
|
waitUntil: 'domcontentloaded',
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(2000 + (Math.random() + Math.random()) * 3000);
|
||||||
|
await page.mouse.wheel(0, rand(300, 500));
|
||||||
|
return await this.isCookieLive(page, sendNotiWhenAlive);
|
||||||
|
|
||||||
|
} catch (er) {
|
||||||
|
this.logger.error(`Browser verify cookie fail: ${er.message}`);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
await page?.close().catch(() => null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async isCookieLive(page: Page, sendNotiWhenAlive = false): Promise<boolean> {
|
||||||
|
this.logger.debug('isCookieLive?');
|
||||||
|
if (page.url().includes('/home')) {
|
||||||
|
this.logger.log('Cookies live');
|
||||||
|
await this.xCacheService.setStateXCookiesIsSillALive();
|
||||||
|
if (sendNotiWhenAlive) {
|
||||||
|
// await this.notifyService.sendMessageToTele('✅Verify cookie pass')
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
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)'}`);
|
||||||
|
// if (isLoggedIn) {
|
||||||
|
// await this.xCacheService.setStateXCookiesIsSillALive();
|
||||||
|
// if (sendNotiWhenAlive) {
|
||||||
|
// await this.notifyService.sendMessageToTele('✅Verify cookie pass')
|
||||||
|
// }
|
||||||
|
// } else {
|
||||||
|
// await this.xCacheService.setStateXCookiesIsDie();
|
||||||
|
// await this.notifyService.sendUrgentMessageToTele('❌Cookie đã hết hạn vui lòng cập nhập để sử dụng.')
|
||||||
|
// }
|
||||||
|
return isLoggedIn;
|
||||||
|
}
|
||||||
|
|
||||||
|
async likeTweet(tweetUrl: string) {
|
||||||
|
let page: Page | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
page = await this.newPage();
|
||||||
|
|
||||||
|
await page.goto(tweetUrl, {
|
||||||
|
waitUntil: 'domcontentloaded',
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await this.actLikeTweet(page);
|
||||||
|
} catch (e) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
async actLikeTweet(page: Page, isCloseAfterEnd = false) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.logger.debug('actLikeTweet:');
|
||||||
|
await page.waitForTimeout(2000 + (Math.random() + Math.random()) * 3000);
|
||||||
|
|
||||||
|
// 1. Cuộn xuống 1000 pixel
|
||||||
|
await page.mouse.wheel(0, rand(300, 500));
|
||||||
|
await page.waitForTimeout(rand(2000, 4000));
|
||||||
|
await page.mouse.wheel(0, rand(300, 500));
|
||||||
|
this.logger.debug('actLikeTweet:Đã cuộn xuống');
|
||||||
|
|
||||||
|
// Nghỉ 2 giây để quan sát
|
||||||
|
await page.waitForTimeout(rand(2000, 4000));
|
||||||
|
|
||||||
|
// 2. Cuộn ngược lên lại 500 pixel
|
||||||
|
await page.mouse.wheel(0, -1 * 1000);
|
||||||
|
this.logger.debug('actLikeTweet:Đã cuộn lên');
|
||||||
|
await page.waitForTimeout(rand(500, 1500));
|
||||||
|
//like
|
||||||
|
this.logger.debug('actLikeTweet:Bắt đầu nhấn like');
|
||||||
|
|
||||||
|
// Sử dụng selector cụ thể cho bài viết chính (thường có vai trò là article)
|
||||||
|
const mainTweetLike = page
|
||||||
|
.locator('article[data-testid="tweet"]').first()
|
||||||
|
.locator('button[data-testid="like"]');
|
||||||
|
await mainTweetLike.click();
|
||||||
|
|
||||||
|
this.logger.debug('actLikeTweet:Đã like xong');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.debug(e);
|
||||||
|
this.logger.error('actLikeTweet: Error:' + e.message);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
if (isCloseAfterEnd) {
|
||||||
|
page.close().catch(() => null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
async postTweet(
|
||||||
|
account: BrowserAccount,
|
||||||
|
text: string,
|
||||||
|
): Promise<BrowserTweetResult> {
|
||||||
|
let page: Page | null = null;
|
||||||
|
try {
|
||||||
|
page = await this.newPage();
|
||||||
|
// Intercept để lấy tweet id từ response
|
||||||
|
let capturedTweetId: string | undefined;
|
||||||
|
// page.on('response', async (resp) => {
|
||||||
|
// if (resp.url().includes('/CreateTweet')) {
|
||||||
|
// try {
|
||||||
|
// const json = await resp.json();
|
||||||
|
// capturedTweetId =
|
||||||
|
// json?.data?.create_tweet?.tweet_results?.result?.rest_id;
|
||||||
|
// } catch {
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
// await page.keyboard.press('Mở trang ...');
|
||||||
|
await page.goto('https://x.com/home', {
|
||||||
|
waitUntil: 'domcontentloaded',
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(2000 + (Math.random() + Math.random()) * 3000);
|
||||||
|
await page.mouse.wheel(0, rand(300, 500));
|
||||||
|
await page.waitForTimeout(rand(1000, 4000));
|
||||||
|
|
||||||
|
// Detect login/challenge screen
|
||||||
|
if (page.url().includes('/login') || page.url().includes('/flow')) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Redirected to login',
|
||||||
|
needsRelogin: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.isCookieLive(page, false);
|
||||||
|
|
||||||
|
await page.mouse.wheel(200, rand(300, 800));
|
||||||
|
await page.waitForTimeout(rand(2000, 5000));
|
||||||
|
await page.evaluate(() => window.scrollTo({top: 0, behavior: 'smooth'}));
|
||||||
|
await page.waitForTimeout(rand(1000, 2000));
|
||||||
|
// page.mouse.move()
|
||||||
|
// Mở composer
|
||||||
|
// const composer = page.locator('a[href="/compose/post"]').first();
|
||||||
|
// await page.waitForTimeout(2000 + (Math.random()+Math.random()) * 3000);
|
||||||
|
// await composer.click();
|
||||||
|
this.logger.debug('Bắt đầu nhập tweet ...');
|
||||||
|
|
||||||
|
const textarea = page.locator('div[data-testid="tweetTextarea_0"]');
|
||||||
|
await page.waitForTimeout(2000 + (Math.random() + Math.random()) * 3000);
|
||||||
|
// nhập content (fallback nếu type fail)
|
||||||
|
try {
|
||||||
|
await textarea.fill(''); // clear
|
||||||
|
await textarea.type(text, {delay: 50 + Math.random() * 200});
|
||||||
|
} catch {
|
||||||
|
await textarea.fill(text);
|
||||||
|
}
|
||||||
|
this.logger.debug(' Nhập tweet xong ...');
|
||||||
|
await page.waitForTimeout(2000 + (Math.random() + Math.random()) * 3000);
|
||||||
|
await page.waitForTimeout(5000);
|
||||||
|
|
||||||
|
// Chờ nút enable
|
||||||
|
// const postBtn = page.locator('button[data-testid="tweetButtonInline"]');
|
||||||
|
// await postBtn.waitFor({state: 'visible', timeout: 5_000});
|
||||||
|
// await postBtn.click();
|
||||||
|
// await page.locator('button[data-testid="tweetButtonInline"]').click({ force: true });
|
||||||
|
const btn = page.locator('button[data-testid="tweetButtonInline"]');
|
||||||
|
const btnBox = await btn.boundingBox();
|
||||||
|
this.logger.debug(btnBox);
|
||||||
|
this.logger.debug('Nhấn Control+Enter ...');
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
// await page.mouse.click(btnBox?.x + btnBox.width / 2, btnBox.y + btnBox.height / 2);
|
||||||
|
await page.keyboard.press('Control+Enter');
|
||||||
|
this.logger.debug('Nhấn Control+Enter done ...');
|
||||||
|
await page.waitForTimeout(10000);
|
||||||
|
|
||||||
|
// Chờ request CreateTweet hoàn tất
|
||||||
|
// await page.waitForResponse(
|
||||||
|
// (r) => r.url().includes('/CreateTweet') && r.status() === 200,
|
||||||
|
// {timeout: 15_000},
|
||||||
|
// );
|
||||||
|
|
||||||
|
return {success: true, tweetId: capturedTweetId};
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`Browser post failed: ${err.message}`);
|
||||||
|
// console.error(err);
|
||||||
|
return {success: false, error: err.message};
|
||||||
|
} finally {
|
||||||
|
await page?.close().catch(() => null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async postQuote(
|
||||||
|
account: BrowserAccount,
|
||||||
|
tweetUrl: string,
|
||||||
|
quoteText: string,
|
||||||
|
) {
|
||||||
|
const page = await this.getPage(account);
|
||||||
|
try {
|
||||||
|
// ===== SAFE GOTO =====
|
||||||
|
try {
|
||||||
|
await page.goto(tweetUrl, {waitUntil: 'domcontentloaded', timeout: 30000});
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.debug('❌ Load fail');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.waitForTimeout(rand(2000, 4000));
|
||||||
|
|
||||||
|
await this.isCookieLive(page, false);
|
||||||
|
|
||||||
|
// ===== CHECK LOGIN =====
|
||||||
|
if (await page.locator('input[name="text"]').count()) {
|
||||||
|
this.logger.error('❌ Cookie die → bị redirect login');
|
||||||
|
|
||||||
|
throw new HttpException('❌ Không thấy nút retweet (tweet private?)', 500);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== SCROLL (giống người thật) =====
|
||||||
|
await page.mouse.wheel(0, rand(300, 800));
|
||||||
|
await page.waitForTimeout(rand(1000, 5000));
|
||||||
|
await page.mouse.wheel(0, rand(300, 800));
|
||||||
|
await page.waitForTimeout(rand(4000, 8000));
|
||||||
|
await page.mouse.wheel(0, -2000);
|
||||||
|
await page.waitForTimeout(rand(1000, 2000));
|
||||||
|
|
||||||
|
await this.actLikeTweet(page);
|
||||||
|
|
||||||
|
// ===== CLICK RETWEET =====
|
||||||
|
let retweetBtn = page.locator('[data-testid="retweet"]');
|
||||||
|
|
||||||
|
if (!(await retweetBtn.count())) {
|
||||||
|
this.logger.error('❌ Không thấy nút retweet (tweet private?)');
|
||||||
|
throw new HttpException('❌ Không thấy nút retweet (tweet private?)', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
await retweetBtn.first().click();
|
||||||
|
|
||||||
|
await page.waitForTimeout(rand(1000, 2000));
|
||||||
|
try {
|
||||||
|
await page.locator('a[href="/compose/post"]').click({timeout: 2000});
|
||||||
|
} catch {
|
||||||
|
this.logger.debug('fallback → click by text');
|
||||||
|
await page.locator('a[role="menuitem"]')
|
||||||
|
.filter({hasText: /Quote|Trích dẫn/i})
|
||||||
|
.click();
|
||||||
|
}
|
||||||
|
// // ===== CLICK QUOTE =====
|
||||||
|
// let quoteBtn = page.locator('[data-testid="retweetWithComment"]');
|
||||||
|
//
|
||||||
|
// if (!(await quoteBtn.count())) {
|
||||||
|
// this.logger.debug('❌ Không thấy nút quote');
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// await quoteBtn.first().click();
|
||||||
|
|
||||||
|
await page.waitForTimeout(rand(2000, 3000));
|
||||||
|
|
||||||
|
// ===== TYPE LIKE HUMAN =====
|
||||||
|
// const content = pick(quoteText);
|
||||||
|
const content = quoteText;
|
||||||
|
|
||||||
|
const box = page.locator('div[role="textbox"]').first();
|
||||||
|
// chọn đúng textbox đang visible
|
||||||
|
// const box = page.locator('div[role="textbox"]:visible').first();
|
||||||
|
|
||||||
|
if (!(await box.count())) {
|
||||||
|
this.logger.error('❌ Không thấy textbox');
|
||||||
|
throw new HttpException('❌ Không thấy textbox', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// đợi nó xuất hiện thật sự
|
||||||
|
await box.waitFor({state: 'visible', timeout: 7000});
|
||||||
|
|
||||||
|
// scroll nhẹ vào view (tránh bị offscreen)
|
||||||
|
// await box.scrollIntoViewIfNeeded();
|
||||||
|
|
||||||
|
// focus trước khi gõ
|
||||||
|
this.logger.debug('focus trước khi gõ')
|
||||||
|
await box.click({delay: rand(50, 150)});
|
||||||
|
|
||||||
|
|
||||||
|
for (let char of content) {
|
||||||
|
await box.type(char, {delay: rand(50, 120)});
|
||||||
|
}
|
||||||
|
this.logger.debug('gõ quote xong ...')
|
||||||
|
await page.waitForTimeout(rand(1000, 2000));
|
||||||
|
|
||||||
|
// ===== POST =====
|
||||||
|
let postBtn = page.locator('[data-testid="tweetButton"]');
|
||||||
|
this.logger.debug('count ...')
|
||||||
|
|
||||||
|
if ((await postBtn.count())) {
|
||||||
|
this.logger.debug('click nút quote ...')
|
||||||
|
await postBtn.click({timeout: 7000}).catch(async (e) => {
|
||||||
|
this.logger.debug('❌ Nut click khong duoc, thử dùng bàn phím Control+Enter');
|
||||||
|
await page.keyboard.press('Control+Enter');
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(rand(6000, 10000));
|
||||||
|
|
||||||
|
this.logger.debug('✅ Quoted thành công');
|
||||||
|
} else {
|
||||||
|
this.logger.debug('❌ Không thấy nút post, gọi Ctr + Enter');
|
||||||
|
await page.keyboard.press('Control+Enter');
|
||||||
|
await page.waitForTimeout(rand(4000, 6000));
|
||||||
|
this.logger.debug('✅ Quoted thành công');
|
||||||
|
}
|
||||||
|
return {success: true, error: ''};
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`Browser post quote failed: ${err.message}`);
|
||||||
|
// console.error(err);
|
||||||
|
return {success: false, error: `Browser post quote failed: ${err.message}`};
|
||||||
|
} finally {
|
||||||
|
await page?.close().catch(() => null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async postReply(account: BrowserAccount, tweetUrl, content) {
|
||||||
|
if (!content) {
|
||||||
|
this.logger.debug(`Nội dung trả lời không có`);
|
||||||
|
throw new Error('Nội dung trả lời không có');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const page = await this.getPage(account);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// limit X
|
||||||
|
// content = content.slice(0, 280);
|
||||||
|
|
||||||
|
// vào tweet
|
||||||
|
// ===== SAFE GOTO =====
|
||||||
|
try {
|
||||||
|
this.logger.debug(`Mo trang web tweetUrl`);
|
||||||
|
await page.goto(tweetUrl, {waitUntil: 'domcontentloaded', timeout: 30000});
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.debug('❌ Load fail');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// đợi UI ổn
|
||||||
|
this.logger.debug(`đợi UI ổn...`)
|
||||||
|
await page.waitForSelector('article', {timeout: 7000});
|
||||||
|
|
||||||
|
await this.isCookieLive(page, false);
|
||||||
|
|
||||||
|
// scroll nhẹ
|
||||||
|
this.logger.debug(`scroll nhẹ ...`)
|
||||||
|
await page.mouse.wheel(0, 300);
|
||||||
|
await page.mouse.wheel(300, 900);
|
||||||
|
await page.waitForTimeout(rand(2000, 8000));
|
||||||
|
await page.mouse.wheel(0, -2000);
|
||||||
|
|
||||||
|
|
||||||
|
await this.actLikeTweet(page);
|
||||||
|
|
||||||
|
// lấy textbox visible
|
||||||
|
const box = page.locator('div[role="textbox"]:visible').first();
|
||||||
|
|
||||||
|
await box.waitFor({state: 'visible', timeout: 7000});
|
||||||
|
|
||||||
|
// focus
|
||||||
|
this.logger.debug(`box focus ...`)
|
||||||
|
await box.click();
|
||||||
|
|
||||||
|
// nhập content (fallback nếu type fail)
|
||||||
|
try {
|
||||||
|
await box.fill(''); // clear
|
||||||
|
await box.type(content, {delay: 30 + Math.random() * 150});
|
||||||
|
} catch {
|
||||||
|
await box.fill(content);
|
||||||
|
}
|
||||||
|
this.logger.debug(`nhập nội dung xong ...`)
|
||||||
|
await page.waitForTimeout(800 + Math.random() * 1200);
|
||||||
|
|
||||||
|
// nút reply
|
||||||
|
const btn = page.locator('[data-testid="tweetButtonInline"]:visible');
|
||||||
|
|
||||||
|
if (!(await btn.count())) {
|
||||||
|
this.logger.debug('❌ Không thấy nút reply');
|
||||||
|
throw new Error('Không thấy nút reply');
|
||||||
|
// return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await btn.click();
|
||||||
|
this.logger.debug(`Đã nhấn nút gửi ...`)
|
||||||
|
await page.waitForTimeout(10000);
|
||||||
|
|
||||||
|
this.logger.debug('✅ Reply OK');
|
||||||
|
return {success: true, error: ''};
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Browser reply failed: ${err.message}`);
|
||||||
|
// console.error(err);
|
||||||
|
return {success: false, error: `Browser reply failed: ${err.message}`};
|
||||||
|
} finally {
|
||||||
|
//await page?.close().catch(() => null);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Tự động đóng trình duyệt giải phóng RAM khi NestJS tắt ứng dụng
|
||||||
|
async onModuleDestroy() {
|
||||||
|
if (this.context) {
|
||||||
|
await this.context.close();
|
||||||
|
this.context = null;
|
||||||
|
console.log('Playwright Browser Context đã đóng an toàn.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user