From 574f0944fa9d9b7b15fc1e3a4092549c4c06dfe1 Mon Sep 17 00:00:00 2001 From: NAME Date: Sat, 23 May 2026 16:11:12 +0000 Subject: [PATCH] Update --- .gitignore | 4 +- src/x-poster/x-browser.service.ts | 16 - src/x-poster/x-poster.controller.ts | 18 +- src/x-poster/x-poster.module.ts | 2 + src/x-poster/x-poster.router.service.ts | 7 +- src/x-poster/x.pw.service.ts | 511 ++++++++++++++++++++++++ 6 files changed, 538 insertions(+), 20 deletions(-) create mode 100644 src/x-poster/x.pw.service.ts diff --git a/.gitignore b/.gitignore index 16bf9c5..faf24e3 100644 --- a/.gitignore +++ b/.gitignore @@ -59,4 +59,6 @@ dist .idea dist.zip -data/* \ No newline at end of file +data/* +x_profile_data +x_profile_data/* \ No newline at end of file diff --git a/src/x-poster/x-browser.service.ts b/src/x-poster/x-browser.service.ts index 3b5508e..0c26a1b 100644 --- a/src/x-poster/x-browser.service.ts +++ b/src/x-poster/x-browser.service.ts @@ -198,20 +198,6 @@ export class XBrowserService implements OnModuleInit, OnModuleDestroy { return isLoggedIn; } - // async actVerifyCookie(): Promise { - // 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) { let page: Page | null = null; @@ -595,8 +581,6 @@ export class XBrowserService implements OnModuleInit, OnModuleDestroy { await page?.close().catch(() => null); } - - } private async cleanupStaleContexts() { diff --git a/src/x-poster/x-poster.controller.ts b/src/x-poster/x-poster.controller.ts index 28aa938..58d957d 100644 --- a/src/x-poster/x-poster.controller.ts +++ b/src/x-poster/x-poster.controller.ts @@ -6,6 +6,7 @@ import {XCookieService} from "./x-cookie.service"; import {XApiService} from "./x-api.service"; import {XCacheService} from "../x-cache/x-cache.service"; import {XBrowserService} from "./x-browser.service"; +import {XPwService} from "./x.pw.service"; @Controller('') export class XPosterController { @@ -14,10 +15,25 @@ export class XPosterController { private readonly xCookieService: XCookieService, private readonly xBrowserService: XBrowserService, 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') async twitterAuthCallback( diff --git a/src/x-poster/x-poster.module.ts b/src/x-poster/x-poster.module.ts index 1f900a8..32d924c 100644 --- a/src/x-poster/x-poster.module.ts +++ b/src/x-poster/x-poster.module.ts @@ -7,6 +7,7 @@ import {XPosterRouterService} from "./x-poster.router.service"; import {XCookieService} from "./x-cookie.service"; import {XCacheService} from "../x-cache/x-cache.service"; import {NotifyService} from "../notify.service"; +import {XPwService} from "./x.pw.service"; @Global() @Module({ @@ -18,6 +19,7 @@ import {NotifyService} from "../notify.service"; XCacheService, XPosterRouterService, NotifyService, + XPwService ], controllers: [XPosterController], exports: [XApiService, XBrowserService, XPosterRouterService], diff --git a/src/x-poster/x-poster.router.service.ts b/src/x-poster/x-poster.router.service.ts index 311cce3..fd056ca 100644 --- a/src/x-poster/x-poster.router.service.ts +++ b/src/x-poster/x-poster.router.service.ts @@ -7,6 +7,7 @@ import {XCookieService} from "./x-cookie.service"; import {NotifyService} from "../notify.service"; import {getAccount} from "./utils/x-headers.util"; import {XCacheService} from "../x-cache/x-cache.service"; +import {XPwService} from "./x.pw.service"; export enum SUPPORT_SOCIAL_PROVIDERS { FB = 'fb', @@ -51,6 +52,7 @@ export class XPosterRouterService { private readonly apiSvc: XApiService, private readonly cookieSvc: XCookieService, private readonly browserSvc: XBrowserService, + private readonly browserProfileSvc: XPwService, private readonly notifyService: NotifyService, private readonly xCacheService: XCacheService, ) { @@ -63,7 +65,7 @@ export class XPosterRouterService { async verifyCookie(): Promise { this.logger.debug('==> Verify Cookie'); // const isAlive = await this.cookieSvc.verifyCookie(); - const isAlive = await this.browserSvc.verifyCookie(); + const isAlive = await this.browserProfileSvc.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.') @@ -301,7 +303,8 @@ export class XPosterRouterService { // return await this.cookieSvc.createTweet(account.cookie, text); } 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`}; } catch (e: any) { diff --git a/src/x-poster/x.pw.service.ts b/src/x-poster/x.pw.service.ts new file mode 100644 index 0000000..64a0075 --- /dev/null +++ b/src/x-poster/x.pw.service.ts @@ -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 { + // 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 { + const account = getAccount(); + return this.getPage(account.browser) + } + + async getPage(account: BrowserAccount): Promise { + return this.runWithProfile() + } + + async verifyCookie(sendNotiWhenAlive = false): Promise { + 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 { + 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 { + 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.'); + } + } +} \ No newline at end of file