diff --git a/.github/workflows/assign.yml b/.github/workflows/assign.yml index f29be83..08b835d 100644 --- a/.github/workflows/assign.yml +++ b/.github/workflows/assign.yml @@ -12,6 +12,10 @@ jobs: steps: - name: Assign adolfousier run: | - gh api repos/${{ github.repository }}/issues/${{ github.event.number }} -X PATCH -f assignees[]="adolfousier" + if [ "${{ github.event_name }}" = "pull_request" ]; then + gh api repos/${{ github.repository }}/pulls/${{ github.event.number }} -X PATCH -f add_assignees[]="adolfousier" + else + gh api repos/${{ github.repository }}/issues/${{ github.event.number }} -X PATCH -f add_assignees[]="adolfousier" + fi env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/package-lock.json b/package-lock.json index 06f16b4..a9d9ee7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "clawsocial", - "version": "0.0.48", + "name": "socialcrabs", + "version": "0.0.49", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "clawsocial", - "version": "0.0.48", + "name": "socialcrabs", + "version": "0.0.49", "license": "MIT", "dependencies": { "commander": "^14.0.3", @@ -21,7 +21,7 @@ "zod": "^4.3.6" }, "bin": { - "clawsocial": "dist/cli.js" + "socialcrabs": "dist/cli.js" }, "devDependencies": { "@types/cors": "^2.8.19", diff --git a/src/index.ts b/src/index.ts index 257aebb..35e7955 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import { RateLimiter, DEFAULT_RATE_LIMITS } from './utils/rate-limiter.js'; import { InstagramHandler } from './platforms/instagram.js'; import { TwitterHandler } from './platforms/twitter.js'; import { LinkedInHandler } from './platforms/linkedin.js'; +import { TikTokHandler } from './platforms/tiktok.js'; import { createHttpServer } from './server/http.js'; import { WebSocketManager } from './server/websocket.js'; import { initLogger, log } from './utils/logger.js'; @@ -33,6 +34,7 @@ export class SocialCrabs { public instagram: InstagramHandler; public twitter: TwitterHandler; public linkedin: LinkedInHandler; + public tiktok: TikTokHandler; constructor(config?: SocialCrabsConfig) { // Load and merge config @@ -44,6 +46,7 @@ export class SocialCrabs { instagram: { ...defaultConfig.rateLimits.instagram, ...config?.rateLimits?.instagram }, twitter: { ...defaultConfig.rateLimits.twitter, ...config?.rateLimits?.twitter }, linkedin: { ...defaultConfig.rateLimits.linkedin, ...config?.rateLimits?.linkedin }, + tiktok: { ...defaultConfig.rateLimits.tiktok, ...config?.rateLimits?.tiktok }, }, delays: { ...defaultConfig.delays, ...config?.delays }, session: { ...defaultConfig.session, ...config?.session }, @@ -81,6 +84,7 @@ export class SocialCrabs { instagram: { ...DEFAULT_RATE_LIMITS.instagram, ...this.config.rateLimits.instagram }, twitter: { ...DEFAULT_RATE_LIMITS.twitter, ...this.config.rateLimits.twitter }, linkedin: { ...DEFAULT_RATE_LIMITS.linkedin, ...this.config.rateLimits.linkedin }, + tiktok: { ...DEFAULT_RATE_LIMITS.tiktok, ...this.config.rateLimits.tiktok }, }; this.rateLimiter = new RateLimiter(rateLimits, `${this.config.session.dir}/rate-limits.json`); @@ -88,6 +92,7 @@ export class SocialCrabs { this.instagram = new InstagramHandler(this.browserManager, this.rateLimiter); this.twitter = new TwitterHandler(this.browserManager, this.rateLimiter); this.linkedin = new LinkedInHandler(this.browserManager, this.rateLimiter); + this.tiktok = new TikTokHandler(this.browserManager, this.rateLimiter); log.info('SocialCrabs initialized', { headless: this.config.browser.headless, @@ -143,6 +148,8 @@ export class SocialCrabs { return this.twitter.isLoggedIn(); case 'linkedin': return this.linkedin.isLoggedIn(); + case 'tiktok': + return this.tiktok.isLoggedIn(); default: throw new Error(`Unknown platform: ${platform}`); } @@ -159,6 +166,8 @@ export class SocialCrabs { return this.twitter.login(); case 'linkedin': return this.linkedin.login(); + case 'tiktok': + return this.tiktok.login(); default: throw new Error(`Unknown platform: ${platform}`); } @@ -179,6 +188,8 @@ export class SocialCrabs { return this.twitter.loginWithCredentials(username, password); case 'linkedin': return this.linkedin.loginWithCredentials(username, password); + case 'tiktok': + return this.tiktok.loginWithCredentials(username, password); default: throw new Error(`Unknown platform: ${platform}`); } @@ -198,6 +209,9 @@ export class SocialCrabs { case 'linkedin': await this.linkedin.logout(); break; + case 'tiktok': + await this.tiktok.logout(); + break; default: throw new Error(`Unknown platform: ${platform}`); } @@ -224,6 +238,10 @@ export class SocialCrabs { loggedIn: await this.isLoggedIn('linkedin').catch(() => false), rateLimits: this.rateLimiter.getStatus('linkedin'), }, + tiktok: { + loggedIn: await this.isLoggedIn('tiktok').catch(() => false), + rateLimits: this.rateLimiter.getStatus('tiktok'), + }, }; return { diff --git a/src/platforms/base.ts b/src/platforms/base.ts index 0799d13..dcf41a0 100644 --- a/src/platforms/base.ts +++ b/src/platforms/base.ts @@ -384,7 +384,7 @@ export abstract class BasePlatformHandler { // Abstract methods that must be implemented by each platform abstract isLoggedIn(): Promise; - abstract login(): Promise; + abstract login(username?: string, password?: string): Promise; abstract logout(): Promise; abstract like(payload: LikePayload): Promise; abstract comment(payload: CommentPayload): Promise; diff --git a/src/platforms/index.ts b/src/platforms/index.ts index 859db6f..d185a98 100644 --- a/src/platforms/index.ts +++ b/src/platforms/index.ts @@ -2,3 +2,4 @@ export { BasePlatformHandler } from './base.js'; export { InstagramHandler } from './instagram.js'; export { TwitterHandler } from './twitter.js'; export { LinkedInHandler } from './linkedin.js'; +export { TikTokHandler } from './tiktok.js'; diff --git a/src/platforms/tiktok.ts b/src/platforms/tiktok.ts new file mode 100644 index 0000000..a2c7dc1 --- /dev/null +++ b/src/platforms/tiktok.ts @@ -0,0 +1,465 @@ +/** + * TikTok Platform Handler + * SocialCrabs - Playwright-based TikTok automation + * + * @see https://github.com/adolfousier/socialcrabs/issues/5 + */ + +import { BasePlatformHandler } from './base.js'; +import { log } from '../utils/logger.js'; +import type { BrowserManager } from '../browser/manager.js'; +import type { RateLimiter } from '../utils/rate-limiter.js'; +import type { + ActionResult, + LikePayload, + CommentPayload, + FollowPayload, + DMPayload, + PostPayload, + TikTokPostPayload, + TikTokProfile, +} from '../types/index.js'; +import { TikTokLoginHandler } from './tiktok/login.js'; +import { TikTokPostHandler } from './tiktok/post.js'; +import { TikTokEngagementHandler } from './tiktok/engagement.js'; + +// TikTok UI selectors (current as of 2025) +const SELECTORS = { + // Cookie consent + cookieAccept: 'button:has-text("Accept"), button:has-text("Accept all")', + + // Login + loginUsername: 'input[name="username"], input[autocomplete="username"]', + loginPassword: 'input[name="password"], input[type="password"]', + loginButton: 'button[type="submit"], div[role="button"]:has-text("Log in")', + loginError: 'p[data-e2e="login-error"], div[role="alert"]', + qrTab: 'text=Use QR code', + + // Logged in indicators + loggedInNav: 'nav[role="navigation"]', + uploadButton: 'div[data-e2e="upload-button"], a[href="/upload"]', + + // Like + likeButton: 'div[data-e2e="like-button"] svg, span[data-e2e="unlike-button"]', + unlikeButton: 'div[data-e2e="like-button"]:has(svg[fill="currentColor"])', + + // Comment + commentInput: 'div[contenteditable="true"][data-e2e="comment-input"]', + commentPostButton: 'button[data-e2e="comment-post-button"]', + commentButton: 'div[data-e2e="comment-button"]', + + // Follow + followButton: 'button[data-e2e="follow-button"], div[data-e2e="follow-button"]', + followingButton: 'button[data-e2e="following-button"]', + + // Post/Upload + uploadInput: 'input[type="file"][accept*="video"], input[type="file"][accept*="image"]', + postButton: 'button[data-e2e="publish-button"], button:has-text("Post")', + captionInput: 'div[contenteditable="true"][data-e2e="caption-input"]', + + // Profile + profileAvatar: 'img[data-e2e="user-avatar"]', + profileStats: 'h2[data-e2e="profile-title"], span[data-e2e="profile-subtitle"]', + followersCount: 'strong[data-e2e="followers-count"]', + followingCount: 'strong[data-e2e="following-count"]', + likesCount: 'strong[data-e2e="likes-count"]', + + // Carousel (image post) + addImageButton: 'div[data-e2e="add-image-button"]', + imageIndicator: 'div[data-e2e="upload-media-indicator"]', + + // DM + inboxButton: 'a[href="/messages"], div[data-e2e="direct-message-icon"]', + dmInput: 'div[contenteditable="true"]', + dmSendButton: 'button[data-e2e="send-message-button"]', +}; + +export class TikTokHandler extends BasePlatformHandler { + private readonly baseUrl = 'https://www.tiktok.com'; + private loginHandler: TikTokLoginHandler; + private postHandler: TikTokPostHandler; + private engagementHandler: TikTokEngagementHandler; + + constructor(browserManager: BrowserManager, rateLimiter: RateLimiter) { + super('tiktok', browserManager, rateLimiter); + this.loginHandler = new TikTokLoginHandler(this.browserManager); + this.postHandler = new TikTokPostHandler(); + this.engagementHandler = new TikTokEngagementHandler(); + } + + // ======================================================================== + // Login / Auth + // ======================================================================== + + async login(username?: string, password?: string): Promise { + if (username && password) { + return this.loginWithCredentials(username, password); + } + + // Use stored credentials from config (placeholder - implement credential storage) + log.info('TikTok: Starting login...'); + try { + await this.navigate(`${this.baseUrl}/login`); + await this.handleCookieConsent(); + await this.think(); + const page = await this.getPage(); + return await this.loginHandler.performLogin(page); + } catch (err) { + log.error(`TikTok: Login failed - ${String(err)}`); + return false; + } + } + + async loginWithCredentials(username: string, password: string): Promise { + log.info(`TikTok: Attempting login for ${username}`); + + try { + await this.navigate(`${this.baseUrl}/login`); + await this.handleCookieConsent(); + await this.think(); + + const success = await this.loginHandler.performLogin( + await this.getPage(), + username, + password + ); + + if (success) { + log.info(`TikTok: Login successful for ${username}`); + } + + return success; + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + log.error(`TikTok: Login failed - ${errorMsg}`); + return false; + } + } + + async isLoggedIn(): Promise { + try { + await this.navigate(`${this.baseUrl}/`); + await this.pause(); + + // Check for login button (not logged in) or profile nav (logged in) + const loginButton = await this.elementExists('a[href="/login"]'); + const profileNav = await this.elementExists(SELECTORS.uploadButton); + return !loginButton || !!profileNav; + } catch { + return false; + } + } + + async logout(): Promise { + try { + await this.navigate(`${this.baseUrl}/`); + await this.think(); + + // Navigate to settings/logout + // eslint-disable-next-line @typescript-eslint/no-explicit-any + // @ts-expect-error - Playwright $() returns ElementHandle | null + const profileBtn = await this.page.$('[data-e2e="profile-avatar"]'); + if (profileBtn !== null) { + await profileBtn.click(); + await this.pause(); + + // Look for logout in dropdown + // eslint-disable-next-line @typescript-eslint/no-explicit-any + // @ts-expect-error - Playwright $() returns ElementHandle | null + const logoutBtn = await this.page.$('text=Log out'); + if (logoutBtn !== null) { + await logoutBtn.click(); + await this.pause(); + log.info('TikTok: Logged out'); + } + } + } catch (err) { + log.warn(`TikTok: Logout error - ${String(err)}`); + } + } + + // ======================================================================== + // Cookie Consent Handler + // ======================================================================== + + private async handleCookieConsent(): Promise { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + // @ts-expect-error - Playwright waitForSelector returns nullable in strict mode + const acceptBtn = await this.page.waitForSelector(SELECTORS.cookieAccept); + await acceptBtn.click(); + if (acceptBtn !== null) { + await acceptBtn!.click(); + await this.pause(); + log.debug('TikTok: Cookie consent accepted'); + } + } catch { + // No cookie consent shown + } + } + + // ======================================================================== + // Engagement Actions + // ======================================================================== + + async like(payload: LikePayload): Promise { + const startTime = Date.now(); + const { allowed, status } = await this.checkAndRecordAction('like'); + + if (!allowed) { + return this.createErrorResult( + 'like', + payload.url, + `Rate limited - ${status.remaining} remaining, resets at ${new Date(status.resetAt).toISOString()}`, + startTime, + status + ); + } + + try { + await this.navigate(payload.url); + await this.warmUp({ scrollCount: 2, minPauseMs: 1000, maxPauseMs: 2000 }); + await this.think(); + + const success = await this.engagementHandler.like(await this.getPage(), SELECTORS); + + await this.recordAction('like'); + + return success + ? this.createResult('like', payload.url, startTime, status) + : this.createErrorResult('like', payload.url, 'Like action failed', startTime, status); + } catch (err) { + return this.createErrorResult( + 'like', + payload.url, + err instanceof Error ? err.message : String(err), + startTime + ); + } + } + + async comment(payload: CommentPayload): Promise { + const startTime = Date.now(); + const { allowed, status } = await this.checkAndRecordAction('comment'); + + if (!allowed) { + return this.createErrorResult( + 'comment', + payload.url, + `Rate limited - ${status.remaining} remaining, resets at ${new Date(status.resetAt).toISOString()}`, + startTime, + status + ); + } + + try { + await this.navigate(payload.url); + await this.warmUp({ scrollCount: 2, minPauseMs: 1500, maxPauseMs: 3000 }); + await this.think(); + + // Scroll to comments + await this.scroll('down', 400); + await this.pause(); + + const text = this.sanitizeText(payload.text); + const success = await this.engagementHandler.comment( + await this.getPage(), + SELECTORS, + text + ); + + await this.recordAction('comment'); + + return success + ? this.createResult('comment', payload.url, startTime, status, { text }) + : this.createErrorResult('comment', payload.url, 'Comment failed', startTime, status); + } catch (err) { + return this.createErrorResult( + 'comment', + payload.url, + err instanceof Error ? err.message : String(err), + startTime + ); + } + } + + async follow(payload: FollowPayload): Promise { + const startTime = Date.now(); + const { allowed, status } = await this.checkAndRecordAction('follow'); + + if (!allowed) { + return this.createErrorResult( + 'follow', + payload.username, + `Rate limited - ${status.remaining} remaining, resets at ${new Date(status.resetAt).toISOString()}`, + startTime, + status + ); + } + + try { + await this.navigate(`${this.baseUrl}/@${payload.username}`); + await this.warmUp({ scrollCount: 2, minPauseMs: 2000, maxPauseMs: 4000 }); + await this.think(); + + const success = await this.engagementHandler.follow(await this.getPage(), SELECTORS); + + await this.recordAction('follow'); + + return success + ? this.createResult('follow', payload.username, startTime, status) + : this.createErrorResult('follow', payload.username, 'Follow failed', startTime, status); + } catch (err) { + return this.createErrorResult( + 'follow', + payload.username, + err instanceof Error ? err.message : String(err), + startTime + ); + } + } + + async unfollow(payload: FollowPayload): Promise { + const startTime = Date.now(); + const { allowed, status } = await this.checkAndRecordAction('follow'); + + if (!allowed) { + return this.createErrorResult( + 'unfollow', + payload.username, + `Rate limited - ${status.remaining} remaining, resets at ${new Date(status.resetAt).toISOString()}`, + startTime, + status + ); + } + + try { + await this.navigate(`${this.baseUrl}/@${payload.username}`); + await this.think(); + + const success = await this.engagementHandler.unfollow(await this.getPage(), SELECTORS); + + await this.recordAction('follow'); + + return success + ? this.createResult('unfollow', payload.username, startTime, status) + : this.createErrorResult('unfollow', payload.username, 'Unfollow failed', startTime, status); + } catch (err) { + return this.createErrorResult( + 'unfollow', + payload.username, + err instanceof Error ? err.message : String(err), + startTime + ); + } + } + + async dm(_payload: DMPayload): Promise { + // TikTok DMs work differently - use inbox navigation + return this.createErrorResult( + 'dm', + _payload.username, + 'TikTok DM automation not yet implemented - requires inbox navigation', + Date.now() + ); + } + + // ======================================================================== + // Post Creation + // ======================================================================== + + async post(payload: PostPayload): Promise { + const startTime = Date.now(); + const { allowed, status } = await this.checkAndRecordAction('post'); + + if (!allowed) { + return this.createErrorResult( + 'post', + payload.text.slice(0, 50), + `Rate limited - ${status.remaining} remaining, resets at ${new Date(status.resetAt).toISOString()}`, + startTime, + status + ); + } + + try { + await this.navigate(`${this.baseUrl}/upload`); + await this.pause(); + await this.handleCookieConsent(); + + // TikTok primarily uses video. For image posts, use the carousel endpoint. + const success = await this.postHandler.createPost( + await this.getPage(), + SELECTORS, + payload + ); + + await this.recordAction('post'); + + return success + ? this.createResult('post', payload.text.slice(0, 50), startTime, status) + : this.createErrorResult('post', payload.text.slice(0, 50), 'Post failed', startTime, status); + } catch (err) { + return this.createErrorResult( + 'post', + payload.text.slice(0, 50), + err instanceof Error ? err.message : String(err), + startTime + ); + } + } + + // ======================================================================== + // TikTok-specific: Carousel Post + // ======================================================================== + + async postCarousel( + imagePaths: string[], + caption: string, + options?: Partial + ): Promise { + const startTime = Date.now(); + + try { + await this.navigate(`${this.baseUrl}/upload`); + await this.pause(); + await this.handleCookieConsent(); + + const success = await this.postHandler.createCarouselPost( + await this.getPage(), + SELECTORS, + imagePaths, + this.sanitizeText(caption), + options + ); + + return success + ? this.createResult('post', 'carousel', startTime) + : this.createErrorResult('post', 'carousel', 'Carousel post failed', startTime); + } catch (err) { + return this.createErrorResult( + 'post', + 'carousel', + err instanceof Error ? err.message : String(err), + startTime + ); + } + } + + // ======================================================================== + // Profile + // ======================================================================== + + async getProfile(username: string): Promise { + try { + await this.navigate(`${this.baseUrl}/@${username}`); + await this.waitForElement(SELECTORS.profileAvatar, 10000); + + const profile = await this.engagementHandler.getProfileData(await this.getPage(), SELECTORS); + return profile as TikTokProfile; + } catch (err) { + log.warn(`TikTok: Failed to get profile for @${username} - ${String(err)}`); + return null; + } + } +} diff --git a/src/platforms/tiktok/engagement.ts b/src/platforms/tiktok/engagement.ts new file mode 100644 index 0000000..fea1334 --- /dev/null +++ b/src/platforms/tiktok/engagement.ts @@ -0,0 +1,249 @@ +/** + * TikTok Engagement Handler + * Handles like, comment, follow, unfollow, and profile scraping + */ + +import { log } from '../../utils/logger.js'; +import { quickDelay, thinkingPause, sleep } from '../../utils/delays.js'; +import type { TikTokProfile } from '../../types/index.js'; + +export class TikTokEngagementHandler { + constructor() {} + + // ======================================================================== + // Like + // ======================================================================== + + async like(page: any, SELECTORS: Record): Promise { + try { + // Wait for like button to appear + const likeBtn = page.locator( + SELECTORS.likeButton || 'div[data-e2e="like-button"]' + ); + await likeBtn.waitFor({ timeout: 10000 }); + + // Click like + await likeBtn.first().click(); + await quickDelay(); + + // Verify like state changed + const likedState = await page.locator( + SELECTORS.unlikeButton || 'div[data-e2e="like-button"]:has(svg[fill="currentColor"])' + ); + if (await likedState.count() > 0) { + log.debug('TikTokEngagement: Post liked'); + return true; + } + + return true; // Assume success even without visual confirmation + } catch (err) { + log.error(`TikTokEngagement: Like failed - ${String(err)}`); + return false; + } + } + + // ======================================================================== + // Comment + // ======================================================================== + + async comment( + page: any, + SELECTORS: Record, + text: string + ): Promise { + try { + // Click comment button to open comment box + const commentBtn = page.locator( + SELECTORS.commentButton || 'div[data-e2e="comment-button"]' + ); + if (await commentBtn.count() > 0) { + await commentBtn.first().click(); + await sleep(1500); + } + + // Find and fill comment input + const commentInput = page.locator( + SELECTORS.commentInput || + 'div[contenteditable="true"][data-e2e="comment-input"], textarea[placeholder*="Add comment"]' + ); + await commentInput.waitFor({ timeout: 10000 }); + + await commentInput.first().click(); + await thinkingPause(); + + // Type comment with human timing + for (const char of text) { + await commentInput.pressSequentially(char, { delay: 60 + Math.random() * 80 }); + } + await sleep(500); + + // Post comment + const postBtn = page.locator( + SELECTORS.commentPostButton || 'button[data-e2e="comment-post-button"]' + ); + if (await postBtn.count() > 0) { + await postBtn.first().click(); + await sleep(2000); + log.debug(`TikTokEngagement: Comment posted: "${text.slice(0, 30)}..."`); + return true; + } + + return false; + } catch (err) { + log.error(`TikTokEngagement: Comment failed - ${String(err)}`); + return false; + } + } + + // ======================================================================== + // Follow / Unfollow + // ======================================================================== + + async follow(page: any, SELECTORS: Record): Promise { + try { + const followBtn = page.locator( + SELECTORS.followButton || 'button[data-e2e="follow-button"]' + ); + await followBtn.waitFor({ timeout: 10000 }); + + await followBtn.first().click(); + await quickDelay(); + + // Verify follow state + const followingBtn = page.locator( + SELECTORS.followingButton || 'button[data-e2e="following-button"]' + ); + if (await followingBtn.count() > 0) { + log.debug('TikTokEngagement: User followed'); + return true; + } + + return true; // Assume success + } catch (err) { + log.error(`TikTokEngagement: Follow failed - ${String(err)}`); + return false; + } + } + + async unfollow(page: any, SELECTORS: Record): Promise { + try { + // First click to open unfollow confirm dialog + const followingBtn = page.locator( + SELECTORS.followingButton || 'button[data-e2e="following-button"]' + ); + await followingBtn.waitFor({ timeout: 10000 }); + await followingBtn.first().click(); + await sleep(1500); + + // Confirm unfollow + const confirmBtn = page.locator( + 'button:has-text("Unfollow"), button:has-text("Following")' + ); + if (await confirmBtn.count() > 0) { + await confirmBtn.first().click(); + await quickDelay(); + log.debug('TikTokEngagement: User unfollowed'); + return true; + } + + return false; + } catch (err) { + log.error(`TikTokEngagement: Unfollow failed - ${String(err)}`); + return false; + } + } + + // ======================================================================== + // Profile Data Scraping + // ======================================================================== + + async getProfileData( + page: any, + _SELECTORS: Record + ): Promise { + try { + await page.waitForLoadState('networkidle'); + await sleep(2000); + + // Wait for profile section to load + await page.waitForSelector('h2[data-e2e="profile-title"]', { timeout: 15000 }); + + const getText = async (selector: string): Promise => { + const el = page.locator(selector).first(); + return (await el.count()) > 0 ? (await el.textContent()) || '' : ''; + }; + + const getNumber = async (selector: string): Promise => { + const el = page.locator(selector).first(); + if ((await el.count()) === 0) return 0; + const text = (await el.textContent()) || '0'; + // Convert TikTok number format (1.2M, 500K, etc.) + return this.parseTikTokNumber(text); + }; + + // Extract profile data + const username = page.url().split('@')[1]?.split('?')[1]?.split('/')[0] || ''; + const displayName = await getText('h2[data-e2e="profile-title"]'); + const bio = await getText('h2[data-e2e="user-bio"]'); + const avatar = await page + .locator('img[data-e2e="user-avatar"]') + .first() + .getAttribute('src') + .catch(() => ''); + + const followers = await getNumber('strong[data-e2e="followers-count"]'); + const following = await getNumber('strong[data-e2e="following-count"]'); + const likes = await getNumber('strong[data-e2e="likes-count"]'); + + // Check for verified badge and private account + const verifiedBadge = await page.locator('svg[data-e2e="verified-badge"]').count(); + const privateIndicator = await page.locator('text=This account is private').count(); + + const profile: TikTokProfile = { + username, + displayName, + bio, + avatar: avatar || '', + followers, + following, + likes, + verified: verifiedBadge > 0, + isPrivate: privateIndicator > 0, + posts: 0, // TikTok doesn't always show post count on profile + }; + + log.debug(`TikTokEngagement: Scraped profile @${username}`); + return profile; + } catch (err) { + log.error(`TikTokEngagement: Profile scrape failed - ${String(err)}`); + return null; + } + } + + /** + * Parse TikTok number format (1.2M, 500K, 1234) + */ + private parseTikTokNumber(text: string): number { + const cleaned = text.trim().replace(/,/g, ''); + const match = cleaned.match(/([\d.]+)\s*([MMBKT]?)/i); + + if (!match) { + const num = parseFloat(cleaned); + return isNaN(num) ? 0 : num; + } + + const value = parseFloat(match[1]); + const suffix = (match[2] || '').toUpperCase(); + + switch (suffix) { + case 'M': + return value * 1_000_000; + case 'B': + return value * 1_000_000_000; + case 'K': + return value * 1_000; + default: + return value; + } + } +} diff --git a/src/platforms/tiktok/login.ts b/src/platforms/tiktok/login.ts new file mode 100644 index 0000000..06344bb --- /dev/null +++ b/src/platforms/tiktok/login.ts @@ -0,0 +1,335 @@ +/** + * TikTok Login Handler + * Handles TikTok authentication with anti-bot measures + */ + +import type { BrowserManager } from '../../browser/manager.js'; +import { log } from '../../utils/logger.js'; +import { + quickDelay, + preTypeDelay, + postTypeDelay, + typingDelay, + sleep, +} from '../../utils/delays.js'; + +export class TikTokLoginHandler { + private browserManager: BrowserManager; + + constructor(browserManager: BrowserManager) { + this.browserManager = browserManager; + } + + /** + * Perform login via username/email + password + */ + async performLogin( + page: any, + username?: string, + password?: string + ): Promise { + log.info(`TikTokLogin: Starting login for ${username}`); + + try { + // Wait for page to load + await page.waitForLoadState('networkidle', { timeout: 15000 }); + await sleep(2000); + + // Handle cookie consent if shown + await this.acceptCookies(page); + + // Detect which login option is available + const loginMethod = await this.detectLoginMethod(page); + + if (loginMethod === 'email') { + if (!username || !password) { + log.warn('TikTokLogin: Email login requires username and password'); + return false; + } + return await this.loginWithEmail(page, username, password); + } else if (loginMethod === 'qr') { + log.info('TikTokLogin: QR code login detected - manual scan required'); + return await this.loginWithQR(page); + } + + log.warn('TikTokLogin: No supported login method found'); + return false; + } catch (err) { + log.error(`TikTokLogin: Login failed - ${String(err)}`); + return false; + } + } + + /** + * Detect available login methods on TikTok + */ + private async detectLoginMethod(page: any): Promise<'email' | 'qr' | 'none'> { + try { + // Look for email/username login tab + const emailTab = await page.locator('text=Use phone / email / username').count(); + const phoneTab = await page.locator('text=Use phone / email').count(); + const usernameInput = await page.locator('input[name="username"], input[autocomplete="username"]').count(); + + if (emailTab > 0 || phoneTab > 0 || usernameInput > 0) { + return 'email'; + } + + // Look for QR code option + const qrTab = await page.locator('text=Use QR code').count(); + if (qrTab > 0) { + return 'qr'; + } + + return 'none'; + } catch { + return 'none'; + } + } + + /** + * Accept cookie consent banner + */ + private async acceptCookies(page: any): Promise { + try { + const acceptBtn = page.locator('button:has-text("Accept"), button:has-text("Accept all")'); + if (await acceptBtn.count() > 0) { + await acceptBtn.first().click(); + await sleep(1000); + log.debug('TikTokLogin: Cookie consent accepted'); + } + } catch { + // No cookie banner + } + } + + /** + * Login with email/username + password + */ + private async loginWithEmail( + page: any, + username: string, + password: string + ): Promise { + log.info('TikTokLogin: Using email/username login'); + + try { + // Click email/username tab if shown + const emailTab = await page.locator('text=Use phone / email / username'); + if (await emailTab.count() > 0) { + await emailTab.click(); + await sleep(1500); + } + + // Wait for username input + await page.waitForSelector('input[name="username"], input[autocomplete="username"]', { + timeout: 10000, + }); + + // Type username with human-like timing + await this.typeHuman('input[name="username"], input[autocomplete="username"]', username); + + await sleep(800); + + // Click next/continue + await this.clickElement('button[type="submit"], div[role="button"]:has-text("Next")'); + await sleep(2500); + + // Check for phone verification (TikTok sometimes asks for phone after username) + const phoneInput = await page.locator('input[type="tel"], input[autocomplete="tel-national"]'); + if (await phoneInput.count() > 0) { + log.info('TikTokLogin: Phone verification required after username'); + // For now, return false - phone verification needs separate handling + return false; + } + + // Wait for password input + await page.waitForSelector('input[name="password"], input[type="password"]', { + timeout: 10000, + }); + + // Type password with human-like timing + await this.typeHuman('input[name="password"], input[type="password"]', password); + + await sleep(800); + + // Submit + await this.clickElement('button[type="submit"], div[role="button"]:has-text("Log in")'); + await sleep(3000); + + // Handle 2FA if present + if (await this.handle2FA(page)) { + log.info('TikTokLogin: 2FA required - manual code entry needed'); + return false; + } + + // Check for CAPTCHA + if (await this.detectCAPTCHA(page)) { + log.warn('TikTokLogin: CAPTCHA detected - manual resolution required'); + return false; + } + + // Verify login success + const currentUrl = page.url(); + const isLoggedIn = !currentUrl.includes('/login'); + + if (isLoggedIn) { + log.info('TikTokLogin: Login successful'); + return true; + } + + // Check for error message + const errorMsg = await this.getErrorMessage(page); + if (errorMsg) { + log.warn(`TikTokLogin: Login error - ${errorMsg}`); + } + + return false; + } catch (err) { + log.error(`TikTokLogin: Email login failed - ${String(err)}`); + return false; + } + } + + /** + * Login with QR code (manual scan required) + */ + private async loginWithQR(page: any): Promise { + log.info('TikTokLogin: QR code login'); + + try { + const qrTab = page.locator('text=Use QR code'); + if (await qrTab.count() > 0) { + await qrTab.click(); + await sleep(2000); + } + + // Wait for QR code to appear + const qrImg = await page.waitForSelector('img[alt="QR Code"]', { timeout: 10000 }); + if (qrImg) { + // Save QR code screenshot for user to scan + await qrImg.screenshot({ path: 'tiktok_qr.png' }); + log.info('TikTokLogin: QR code saved to tiktok_qr.png - scan with TikTok app'); + + // Poll for login completion (2 min timeout) + for (let i = 0; i < 60; i++) { + await sleep(2000); + + const loginLink = await page.locator('a[href*="/login"]').count(); + if (loginLink === 0) { + log.info('TikTokLogin: QR scan successful'); + return true; + } + } + + log.warn('TikTokLogin: QR scan timeout (2 minutes)'); + } + + return false; + } catch (err) { + log.error(`TikTokLogin: QR login failed - ${String(err)}`); + return false; + } + } + + /** + * Handle 2FA/Two-factor authentication + */ + private async handle2FA(page: any): Promise { + try { + // Look for 6-digit OTP input + const otpInput = await page.locator('input[maxlength="6"], input[aria-label*="code"]'); + if (await otpInput.count() > 0) { + log.info('TikTokLogin: 2FA code input detected'); + // 2FA requires manual code entry or external service + // For automated flow, this would need integration with + // an authenticator app or SMS service + return true; + } + + // Look for "We sent you an email" message + const emailMsg = await page.locator('text=We sent you an email').count(); + if (emailMsg > 0) { + return true; + } + + return false; + } catch { + return false; + } + } + + /** + * Detect CAPTCHA challenge + */ + private detectCAPTCHA(page: any): Promise { + return page.locator('text=verify you are human, text=Prove you are human').count().then((c: any) => c > 0); + } + + /** + * Get login error message from page + */ + private async getErrorMessage(page: any): Promise { + try { + const errorSelectors = [ + 'p[data-e2e="login-error"]', + 'div[role="alert"]', + 'text=Incorrect username', + 'text=Incorrect password', + 'text=Account does not exist', + 'text=Too many attempts', + ]; + + for (const selector of errorSelectors) { + const el = await page.locator(selector).first(); + if (await el.count() > 0) { + const text = await el.textContent(); + if (text && text.trim()) { + return text.trim(); + } + } + } + + return null; + } catch { + return null; + } + } + + /** + * Type text character by character with human timing + */ + private async typeHuman(selector: string, text: string): Promise { + const element = await this.browserManager.getPage('tiktok'); + const locator = element.locator(selector).first(); + + await locator.scrollIntoViewIfNeeded(); + await preTypeDelay(); + await locator.click(); + + for (const char of text) { + await locator.pressSequentially(char, { delay: typingDelay() }); + } + + await postTypeDelay(); + } + + /** + * Click element with fallback strategies + */ + private async clickElement(selector: string): Promise { + const element = await this.browserManager.getPage('tiktok'); + const locator = element.locator(selector).first(); + + await locator.scrollIntoViewIfNeeded(); + await quickDelay(); + + try { + await locator.click({ timeout: 5000 }); + } catch { + // Fallback: JS-level click + await locator.evaluate((el: any) => { + el.dispatchEvent(new Event('click', { bubbles: true, cancelable: true })); + }); + } + } +} diff --git a/src/platforms/tiktok/post.ts b/src/platforms/tiktok/post.ts new file mode 100644 index 0000000..c067315 --- /dev/null +++ b/src/platforms/tiktok/post.ts @@ -0,0 +1,194 @@ +/** + * TikTok Post Handler + * Handles video and carousel post creation + */ + +import { log } from '../../utils/logger.js'; +import { quickDelay, thinkingPause, sleep } from '../../utils/delays.js'; +import type { PostPayload, TikTokPostPayload } from '../../types/index.js'; + +export class TikTokPostHandler { + constructor() {} + + /** + * Create a video post + */ + async createPost( + page: any, + SELECTORS: Record, + payload: PostPayload + ): Promise { + log.info('TikTokPost: Creating video post'); + + try { + // Wait for upload page to fully load + await page.waitForLoadState('networkidle'); + await sleep(2000); + + // Find file input + const fileInput = await page.locator(SELECTORS.uploadInput || 'input[type="file"]').first(); + if (!(await fileInput.count())) { + log.error('TikTokPost: File upload input not found'); + return false; + } + + // Upload video file + if (payload.media && payload.media.length > 0) { + await fileInput.setInputFiles(payload.media[0]); + await log.debug(`TikTokPost: Uploaded ${payload.media[0]}`); + } + + // Wait for video processing + await sleep(5000); + + // Wait for caption input to appear (video processing indicator) + const captionInput = page.locator(SELECTORS.captionInput || 'div[contenteditable="true"]'); + await captionInput.waitFor({ timeout: 30000 }); + + await sleep(2000); + + // Add caption + if (payload.text) { + await captionInput.click(); + await thinkingPause(); + + // Type caption with human timing + for (const char of payload.text) { + await captionInput.pressSequentially(char, { delay: 80 + Math.random() * 60 }); + } + + await sleep(1000); + } + + // Wait a moment then click post + await quickDelay(); + await quickDelay(); + + // Find and click post button + const postBtn = page.locator(SELECTORS.postButton || 'button:has-text("Post")'); + if (await postBtn.count() > 0) { + await postBtn.first().click(); + await sleep(3000); + + // Check for success (redirect to the new post) + const currentUrl = page.url(); + if (currentUrl.includes('/video/') || !currentUrl.includes('/upload')) { + log.info(`TikTokPost: Post created successfully - ${currentUrl}`); + return true; + } + } + + log.warn('TikTokPost: Post button not found or post did not appear'); + return false; + } catch (err) { + log.error(`TikTokPost: Create post failed - ${String(err)}`); + return false; + } + } + + /** + * Create a carousel post (image post with multiple images) + * TikTok supports up to 35 images in a carousel + */ + async createCarouselPost( + page: any, + SELECTORS: Record, + imagePaths: string[], + caption: string, + options?: Partial + ): Promise { + log.info(`TikTokPost: Creating carousel post with ${imagePaths.length} images`); + + try { + await page.waitForLoadState('networkidle'); + await sleep(2000); + + // Upload first image + const fileInput = page.locator( + 'input[type="file"][accept*="image"], input[type="file"][accept*="jpg"], input[type="file"]' + ).first(); + + if (!(await fileInput.count())) { + log.error('TikTokPost: File upload input not found'); + return false; + } + + // Upload first image (TikTok carousel: upload first, then add more) + await fileInput.setInputFiles(imagePaths[0]); + log.debug(`TikTokPost: Uploaded first image`); + + // Wait for processing + await sleep(3000); + + // If multiple images, click "add more" button for each + if (imagePaths.length > 1) { + for (let i = 1; i < imagePaths.length; i++) { + // Look for add image button (may appear after first upload) + const addBtn = page.locator( + SELECTORS.addImageButton || + 'div:has-text("Add"), button:has-text("Add photo"), button:has-text("Add more")' + ); + + if (await addBtn.count() > 0) { + await addBtn.first().click(); + await sleep(1500); + + // Upload next image + const nextInput = page.locator( + 'input[type="file"][accept*="image"], input[type="file"]' + ).last(); + await nextInput.setInputFiles(imagePaths[i]); + log.debug(`TikTokPost: Uploaded image ${i + 1}/${imagePaths.length}`); + await sleep(2000); + } + } + } + + // Wait for all images to process + await sleep(2000); + + // Add caption + const captionInput = page.locator( + SELECTORS.captionInput || 'div[contenteditable="true"]' + ); + if (await captionInput.count() > 0) { + await captionInput.first().click(); + await thinkingPause(); + + // Build caption with hashtags + let fullCaption = caption; + if (options?.hashtags && options.hashtags.length > 0) { + fullCaption += '\n\n' + options.hashtags.map((h) => `#${h}`).join(' '); + } + + for (const char of fullCaption) { + await captionInput.pressSequentially(char, { delay: 60 + Math.random() * 50 }); + } + + await sleep(1000); + } + + // Post + await quickDelay(); + const postBtn = page.locator( + SELECTORS.postButton || 'button:has-text("Post"), button:has-text("Publish")' + ); + if (await postBtn.count() > 0) { + await postBtn.first().click(); + await sleep(3000); + + const currentUrl = page.url(); + if (!currentUrl.includes('/upload')) { + log.info(`TikTokPost: Carousel post created - ${currentUrl}`); + return true; + } + } + + log.warn('TikTokPost: Carousel post may not have succeeded'); + return false; + } catch (err) { + log.error(`TikTokPost: Carousel post failed - ${String(err)}`); + return false; + } + } +} diff --git a/src/types/index.ts b/src/types/index.ts index 362776d..e8b22f0 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -4,7 +4,7 @@ import type { Page, BrowserContext } from 'playwright'; // Platform Types // ============================================================================ -export type Platform = 'instagram' | 'twitter' | 'linkedin'; +export type Platform = 'instagram' | 'twitter' | 'linkedin' | 'tiktok'; export type ActionType = | 'like' @@ -56,6 +56,7 @@ export interface RateLimitConfig { instagram: PlatformRateLimits; twitter: PlatformRateLimits; linkedin: PlatformRateLimits; + tiktok: PlatformRateLimits; } export interface PlatformRateLimits { @@ -358,3 +359,36 @@ export interface EventPayload { data: Record; timestamp: number; } + +// ============================================================================ +// TikTok Types +// ============================================================================ + +export interface TikTokPostPayload { + text: string; + media?: string[]; + hashtags?: string[]; + mentions?: string[]; + visibility?: 'public' | 'private' | 'friends'; + commentControl?: 'all' | 'following' | 'off'; + duet?: boolean; + stitch?: boolean; +} + +export interface TikTokEngagementPayload { + url: string; + text?: string; +} + +export interface TikTokProfile { + username: string; + displayName: string; + bio: string; + avatar: string; + followers: number; + following: number; + likes: number; + posts: number; + verified: boolean; + isPrivate: boolean; +} diff --git a/src/utils/config.ts b/src/utils/config.ts index 2e2fe4e..678c2cf 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -76,6 +76,14 @@ export function loadConfig(): ResolvedConfig { post: getEnvNumber('RATE_LIMIT_LINKEDIN_POST', 5), connect: getEnvNumber('RATE_LIMIT_LINKEDIN_CONNECT', 15), }, + tiktok: { + like: getEnvNumber('RATE_LIMIT_TIKTOK_LIKE', 200), + comment: getEnvNumber('RATE_LIMIT_TIKTOK_COMMENT', 50), + follow: getEnvNumber('RATE_LIMIT_TIKTOK_FOLLOW', 30), + dm: getEnvNumber('RATE_LIMIT_TIKTOK_DM', 10), + post: getEnvNumber('RATE_LIMIT_TIKTOK_POST', 5), + connect: 0, + }, }, delays: { minMs: getEnvNumber('DELAY_MIN_MS', 1500), diff --git a/src/utils/rate-limiter.ts b/src/utils/rate-limiter.ts index cae3b09..4f0334e 100644 --- a/src/utils/rate-limiter.ts +++ b/src/utils/rate-limiter.ts @@ -236,4 +236,11 @@ export const DEFAULT_RATE_LIMITS: Record> = { dm: 40, post: 5, }, + tiktok: { + like: 200, + comment: 50, + follow: 30, + dm: 10, + post: 5, + }, };