x-twitter-bot 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +291 -0
  2. package/index.js +707 -0
  3. package/package.json +35 -0
package/README.md ADDED
@@ -0,0 +1,291 @@
1
+ # x-bot
2
+
3
+ Event-driven Twitter/X automation library for Node.js. Puppeteer + cookie-based auth.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install x-twitter-bot
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```js
14
+ const TwitterBot = require("x-twitter-bot");
15
+
16
+ const bot = new TwitterBot({
17
+ cookies: {
18
+ auth_token: "...",
19
+ ct0: "...",
20
+ twid: "...",
21
+ kdt: "...",
22
+ att: "...",
23
+ },
24
+ username: "your_username",
25
+ headless: true,
26
+ });
27
+
28
+ bot.on("browserLaunched", () => console.log("Browser launched"));
29
+
30
+ bot.on("ready", async () => {
31
+ // 1. Post a tweet
32
+ try {
33
+ const tweet = await bot.postTweet("Hello world! πŸ€–");
34
+ console.log(tweet);
35
+ } catch (err) {
36
+ console.error(err.message); // e.g. "Whoops! You already said that."
37
+ }
38
+
39
+ // 2. Tweet stats + initial visible replies (no scroll)
40
+ const stats = await bot.getTweetStats("TWEET_ID");
41
+ console.log(stats.likes, stats.views, stats.initialReplies);
42
+
43
+ // 3. Comments with auto-scroll (up to 20)
44
+ const comments = await bot.getTweetComments("TWEET_ID", 20);
45
+ console.log(comments.collected, comments.scrollBlocked);
46
+
47
+ // 4. Sub-replies β€” pass a comment's tweetId (works recursively)
48
+ const sub = await bot.getTweetComments(comments.comments[0].tweetId, 5);
49
+
50
+ // 5. Search & like
51
+ await bot.searchAndLike("nodejs", 3);
52
+
53
+ await bot.close();
54
+ });
55
+
56
+ bot.on("loginRequired", () => { console.error("Cookies expired!"); bot.close(); });
57
+ bot.on("tweetPosted", (d) => console.log("Posted:", d.text));
58
+ bot.on("tweetFailed", (d) => console.error("Failed:", d.error));
59
+ bot.on("error", (err) => { console.error(err.message); bot.close(); });
60
+ bot.on("closed", () => console.log("Closed"));
61
+
62
+ bot.init();
63
+ ```
64
+
65
+ See [example.js](example.js) for a full runnable example.
66
+
67
+ ---
68
+
69
+ ## Constructor
70
+
71
+ ```js
72
+ new TwitterBot(options)
73
+ ```
74
+
75
+ | Option | Type | Default | Description |
76
+ |---|---|---|---|
77
+ | `cookies` | `object` | **required** | Auth cookies (see below) |
78
+ | `username` | `string` | `""` | Twitter username β€” used for building tweet URLs |
79
+ | `headless` | `boolean` | `true` | Run browser in headless mode |
80
+ | `timeout` | `number` | `60000` | Navigation timeout (ms) |
81
+
82
+ ### Required Cookies
83
+
84
+ | Cookie | Description |
85
+ |---|---|
86
+ | `auth_token` | Session auth token |
87
+ | `ct0` | CSRF token |
88
+ | `twid` | Twitter user ID |
89
+ | `kdt` | Key derivation token |
90
+ | `att` | Access token |
91
+
92
+ Optional: `guest_id`
93
+
94
+ Get these from DevTools β†’ Application β†’ Cookies β†’ `https://x.com`.
95
+
96
+ ---
97
+
98
+ ## Events
99
+
100
+ | Event | Payload | Description |
101
+ |---|---|---|
102
+ | `browserLaunched` | – | Browser instance started |
103
+ | `ready` | – | Authenticated and ready to use |
104
+ | `loginRequired` | – | Cookies invalid/expired |
105
+ | `tweetPosted` | `{ text, timestamp }` | Tweet posted successfully |
106
+ | `tweetFailed` | `{ text, error }` | Tweet post failed |
107
+ | `error` | `Error` | Unrecoverable error during init |
108
+ | `closed` | – | Browser closed |
109
+
110
+ ### Flow
111
+
112
+ ```
113
+ bot.init()
114
+ β”‚
115
+ β”œβ”€ emit('browserLaunched')
116
+ β”‚
117
+ β”œβ”€ cookies valid? ──YES──→ emit('ready') ← call methods here
118
+ β”‚ └─NO──→ emit('loginRequired')
119
+ β”‚
120
+ └─ exception ────────────→ emit('error', err)
121
+ ```
122
+
123
+ ---
124
+
125
+ ## Methods
126
+
127
+ All methods require `ready` to have fired.
128
+
129
+ ---
130
+
131
+ ### `bot.init()`
132
+
133
+ Launches browser, injects cookies, navigates to `/home`, verifies authentication.
134
+
135
+ ```js
136
+ bot.init(); // triggers 'ready' or 'loginRequired'
137
+ ```
138
+
139
+ ---
140
+
141
+ ### `bot.postTweet(text)`
142
+
143
+ Posts a tweet (max 280 chars). After clicking post, checks for X error toasts (e.g. duplicate tweet warning) before returning. Handles "Leave site?" dialogs automatically. Emits `tweetPosted` on success, `tweetFailed` on failure.
144
+
145
+ ```js
146
+ const result = await bot.postTweet("Hello! πŸš€");
147
+ // { success: true, text: "Hello! πŸš€", timestamp: "2026-02-21T12:00:00.000Z" }
148
+ ```
149
+
150
+ Errors are thrown and also emitted via `tweetFailed`:
151
+ - `"Whoops! You already said that."` β€” duplicate tweet
152
+ - `"Tweet textarea not found"` β€” compose page failed to load
153
+ - `"Post button not found"` β€” UI issue
154
+
155
+ ---
156
+
157
+ ### `bot.getTweetStats(tweetId)`
158
+
159
+ Scrapes stats for a tweet **and** the initial visible replies already rendered on the page (no scrolling).
160
+
161
+ ```js
162
+ const stats = await bot.getTweetStats("1893023456789");
163
+ ```
164
+
165
+ **Response:**
166
+ ```js
167
+ {
168
+ id: "1893023456789",
169
+ url: "https://x.com/username/status/1893023456789",
170
+ text: "Tweet content here",
171
+ likes: 42,
172
+ replies: 7,
173
+ reposts: 3,
174
+ views: 1500,
175
+ bookmarks: 2,
176
+ initialReplies: [
177
+ {
178
+ tweetId: "1893024000000",
179
+ username: "John Doe",
180
+ handle: "@johndoe",
181
+ text: "Great tweet!",
182
+ time: "2026-02-21T10:00:00.000Z",
183
+ likes: 5,
184
+ replies: 1,
185
+ reposts: 0
186
+ }
187
+ ]
188
+ }
189
+ ```
190
+
191
+ Each item in `initialReplies` includes a `tweetId` you can use with `getTweetComments()`.
192
+
193
+ ---
194
+
195
+ ### `bot.getTweetComments(tweetId, count?)`
196
+
197
+ Scrapes comments with **automatic scrolling** until `count` is reached or scrolling is blocked.
198
+
199
+ | Param | Type | Default | Description |
200
+ |---|---|---|---|
201
+ | `tweetId` | `string` | required | Tweet ID |
202
+ | `count` | `number` | `20` | Max comments to collect |
203
+
204
+ - Caps `count` to the actual reply count shown on the page
205
+ - Stops and returns partial results if X blocks scrolling (rate limiting)
206
+
207
+ ```js
208
+ const data = await bot.getTweetComments("1893023456789", 10);
209
+ ```
210
+
211
+ **Response:**
212
+ ```js
213
+ {
214
+ id: "1893023456789",
215
+ url: "https://x.com/username/status/1893023456789",
216
+ requested: 10,
217
+ actualReplyCount: 47,
218
+ collected: 10,
219
+ scrollBlocked: false,
220
+ comments: [
221
+ {
222
+ tweetId: "1893024000000",
223
+ username: "John Doe",
224
+ handle: "@johndoe",
225
+ text: "Nice!",
226
+ time: "2026-02-21T10:00:00.000Z",
227
+ likes: 3,
228
+ replies: 0,
229
+ reposts: 1
230
+ }
231
+ ]
232
+ }
233
+ ```
234
+
235
+ #### Sub-replies
236
+
237
+ Every reply on X is itself a tweet. Pass any comment's `tweetId` back into `getTweetComments()` to fetch its replies:
238
+
239
+ ```js
240
+ const comments = await bot.getTweetComments("1893023456789", 10);
241
+ const subReplies = await bot.getTweetComments(comments.comments[0].tweetId, 5);
242
+ ```
243
+
244
+ Works recursively β€” you can traverse entire conversation threads.
245
+
246
+ ---
247
+
248
+ ### `bot.searchAndLike(query, count?)`
249
+
250
+ Searches for tweets matching a query and likes them. `count` defaults to `5`.
251
+
252
+ ```js
253
+ const result = await bot.searchAndLike("nodejs", 5);
254
+ // { query: "nodejs", liked: 5 }
255
+ ```
256
+
257
+ ---
258
+
259
+ ### `bot.close()`
260
+
261
+ Closes the browser. Emits `closed`.
262
+
263
+ ```js
264
+ await bot.close();
265
+ ```
266
+
267
+ ---
268
+
269
+ ## Project Structure
270
+
271
+ ```
272
+ x-bot/
273
+ β”œβ”€β”€ index.js ← TwitterBot class (library entry point)
274
+ β”œβ”€β”€ example.js ← Full usage example
275
+ β”œβ”€β”€ package.json
276
+ └── README.md
277
+ ```
278
+
279
+ ---
280
+
281
+ ## ⚠️ Disclaimer
282
+
283
+ > **This project is NOT affiliated with, endorsed by, or associated with X (formerly Twitter) in any way.**
284
+
285
+ - This is an **unofficial**, independently developed tool created strictly for **educational and research purposes**.
286
+ - Using this library may result in your X/Twitter account being **temporarily or permanently suspended**. Automated interactions violate the [X Terms of Service](https://twitter.com/en/tos) and [X Automation Rules](https://help.twitter.com/en/rules-and-policies/x-automation).
287
+ - The author(s) of this project **accept no responsibility** for any consequences arising from the use of this software, including but not limited to account bans, data loss, or legal action.
288
+ - **You use this software entirely at your own risk.** By using it, you acknowledge that you are solely responsible for any outcomes.
289
+ - This project is provided **"as is"** without warranty of any kind, express or implied.
290
+
291
+ **If you don't fully understand the risks, do not use this library.**
package/index.js ADDED
@@ -0,0 +1,707 @@
1
+ const puppeteer = require("puppeteer");
2
+ const { EventEmitter } = require("events");
3
+
4
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
5
+
6
+ const REQUIRED_COOKIES = ["auth_token", "ct0", "twid", "kdt", "att"];
7
+
8
+ /**
9
+ * Events:
10
+ * ready – Bot authenticated and ready to use
11
+ * loginRequired – Cookies are invalid/expired, new cookies needed
12
+ * browserLaunched – Browser instance started
13
+ * error – Unrecoverable error during init or operation
14
+ * tweetPosted – Tweet posted successfully β†’ { text, timestamp }
15
+ * tweetFailed – Tweet failed β†’ { text, error }
16
+ * closed – Browser closed
17
+ */
18
+ class TwitterBot extends EventEmitter {
19
+ /**
20
+ * @param {object} options
21
+ * @param {object} options.cookies – { auth_token, ct0, twid, kdt, att, guest_id? }
22
+ * @param {string} [options.username] – Twitter username (for building tweet URLs)
23
+ * @param {boolean} [options.headless=true]
24
+ * @param {number} [options.timeout=60000]
25
+ */
26
+ constructor(options = {}) {
27
+ super();
28
+
29
+ if (!options.cookies) throw new Error("cookies is required");
30
+
31
+ for (const name of REQUIRED_COOKIES) {
32
+ if (!options.cookies[name]) {
33
+ throw new Error(`Missing required cookie: ${name}`);
34
+ }
35
+ }
36
+
37
+ this.cookies = options.cookies;
38
+ this.username = options.username || "";
39
+ this.headless = options.headless !== undefined ? options.headless : true;
40
+ this.timeout = options.timeout || 60000;
41
+
42
+ this.browser = null;
43
+ this.page = null;
44
+ this.isReady = false;
45
+ }
46
+
47
+ // ═══════════════════════════════════════════════════════════════════════════
48
+ // INIT
49
+ // ═══════════════════════════════════════════════════════════════════════════
50
+
51
+ async init() {
52
+ if (this.isReady) return this;
53
+
54
+ try {
55
+ // ── Launch browser ────────────────────────────────────────────────
56
+ this.browser = await puppeteer.launch({
57
+ headless: this.headless ? "new" : false,
58
+ args: [
59
+ "--no-sandbox",
60
+ "--disable-setuid-sandbox",
61
+ "--disable-blink-features=AutomationControlled",
62
+ ],
63
+ defaultViewport: { width: 1280, height: 720 },
64
+ });
65
+
66
+ this.page = await this.browser.newPage();
67
+
68
+ await this.page.setUserAgent(
69
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
70
+ );
71
+
72
+ await this.page.evaluateOnNewDocument(() => {
73
+ Object.defineProperty(navigator, "webdriver", { get: () => undefined });
74
+ });
75
+
76
+ this.emit("browserLaunched");
77
+
78
+ // ── Set cookies & open x.com ──────────────────────────────────────
79
+ const cookieObjects = this._buildCookieObjects();
80
+ await this.page.setCookie(...cookieObjects);
81
+
82
+ await this.page.goto("https://x.com/home", {
83
+ waitUntil: "networkidle2",
84
+ timeout: this.timeout,
85
+ });
86
+
87
+ // ── Verify auth ──────────────────────────────────────────────────
88
+ await delay(1500);
89
+
90
+ const currentUrl = this.page.url();
91
+
92
+ if (currentUrl.includes("/login") || currentUrl.includes("/i/flow/login")) {
93
+ this.isReady = false;
94
+ this.emit("loginRequired");
95
+ return this;
96
+ }
97
+
98
+ // Double-check with DOM β€” look for logged-in sidebar
99
+ const hasProfile = await this._waitFor(
100
+ '[data-testid="AppTabBar_Profile_Link"], [data-testid="SideNav_AccountSwitcher_Button"]',
101
+ 5000
102
+ );
103
+
104
+ if (!hasProfile) {
105
+ const recheckUrl = this.page.url();
106
+ if (recheckUrl.includes("/login") || recheckUrl.includes("/i/flow/login")) {
107
+ this.isReady = false;
108
+ this.emit("loginRequired");
109
+ return this;
110
+ }
111
+ }
112
+
113
+ this.isReady = true;
114
+ this.emit("ready");
115
+ return this;
116
+ } catch (err) {
117
+ this.emit("error", err);
118
+ return this;
119
+ }
120
+ }
121
+
122
+ // ═══════════════════════════════════════════════════════════════════════════
123
+ // ACTIONS
124
+ // ═══════════════════════════════════════════════════════════════════════════
125
+
126
+ // ── Post a tweet ─────────────────────────────────────────────────────────
127
+
128
+ async postTweet(text) {
129
+ this._ensureReady();
130
+ if (!text) throw new Error("Tweet text is required");
131
+ if (text.length > 280) throw new Error("Tweet exceeds 280 characters");
132
+
133
+ try {
134
+ // Handle "Leave site?" / beforeunload dialogs automatically
135
+ this.page.once("dialog", async (dialog) => {
136
+ await dialog.accept();
137
+ });
138
+
139
+ await this.page.goto("https://x.com/compose/post", {
140
+ waitUntil: "networkidle2",
141
+ timeout: this.timeout,
142
+ });
143
+ await delay(1000);
144
+
145
+ const textarea = '[data-testid="tweetTextarea_0"]';
146
+ const found =
147
+ (await this._waitFor(textarea, 10000)) ||
148
+ (await this._waitFor('div[role="textbox"]', 5000));
149
+ if (!found) throw new Error("Tweet textarea not found");
150
+
151
+ await this.page.click(textarea);
152
+ await delay(200);
153
+ await this.page.type(textarea, text, { delay: 30 });
154
+ await delay(500);
155
+
156
+ const clicked = await this.page.evaluate(() => {
157
+ const btn =
158
+ document.querySelector('[data-testid="tweetButton"]') ||
159
+ document.querySelector('[data-testid="tweetButtonInline"]');
160
+ if (btn) {
161
+ btn.click();
162
+ return true;
163
+ }
164
+ return false;
165
+ });
166
+
167
+ if (!clicked) throw new Error("Post button not found");
168
+
169
+ // Wait for X to process
170
+ await delay(3000);
171
+
172
+ // Check for error toasts / warnings
173
+ const postStatus = await this.page.evaluate(() => {
174
+ // "Whoops! You already said that." or similar warning
175
+ const toast = document.querySelector('[role="status"]');
176
+ if (toast) {
177
+ const text = toast.innerText.trim();
178
+ if (text) return { error: text };
179
+ }
180
+ // Still on compose page β†’ something went wrong
181
+ if (window.location.href.includes("/compose")) {
182
+ return { error: "Still on compose page" };
183
+ }
184
+ return { ok: true };
185
+ });
186
+
187
+ if (postStatus.error) {
188
+ // Dismiss the compose page safely
189
+ await this._dismissCompose();
190
+ throw new Error(postStatus.error);
191
+ }
192
+
193
+ // Tweet sent β€” recover page for next calls
194
+ await this._recoverPage();
195
+
196
+ const result = { success: true, text, timestamp: new Date().toISOString() };
197
+ this.emit("tweetPosted", result);
198
+ return result;
199
+ } catch (err) {
200
+ try { await this._recoverPage(); } catch { /* ignore */ }
201
+ this.emit("tweetFailed", { text, error: err.message });
202
+ throw err;
203
+ }
204
+ }
205
+
206
+ // ── Get tweet stats + initial visible replies ─────────────────────────────
207
+
208
+ async getTweetStats(tweetId) {
209
+ this._ensureReady();
210
+ if (!tweetId) throw new Error("Tweet ID is required");
211
+
212
+ const url = this._tweetUrl(tweetId);
213
+
214
+ await this.page.goto(url, {
215
+ waitUntil: "networkidle2",
216
+ timeout: this.timeout,
217
+ });
218
+ await delay(1500);
219
+
220
+ const data = await this.page.evaluate(() => {
221
+ const r = {
222
+ text: "",
223
+ likes: 0,
224
+ replies: 0,
225
+ reposts: 0,
226
+ views: 0,
227
+ bookmarks: 0,
228
+ initialReplies: [],
229
+ };
230
+
231
+ // ── Main tweet stats ──────────────────────────────────────────
232
+ const tweetText = document.querySelector('[data-testid="tweetText"]');
233
+ if (tweetText) r.text = tweetText.innerText;
234
+
235
+ const parse = (testId) => {
236
+ const el =
237
+ document.querySelector(`[data-testid="${testId}"]`) ||
238
+ document.querySelector(`[data-testid="un${testId}"]`);
239
+ if (el) {
240
+ const m = (el.getAttribute("aria-label") || "").match(/(\d+)/);
241
+ if (m) return parseInt(m[1]);
242
+ }
243
+ return 0;
244
+ };
245
+
246
+ r.replies = parse("reply");
247
+ r.reposts = parse("retweet");
248
+
249
+ r.likes = (() => {
250
+ const el =
251
+ document.querySelector('[data-testid="like"]') ||
252
+ document.querySelector('[data-testid="unlike"]');
253
+ if (el) {
254
+ const m = (el.getAttribute("aria-label") || "").match(/(\d+)/);
255
+ if (m) return parseInt(m[1]);
256
+ }
257
+ return 0;
258
+ })();
259
+
260
+ r.bookmarks = (() => {
261
+ const el =
262
+ document.querySelector('[data-testid="bookmark"]') ||
263
+ document.querySelector('[data-testid="removeBookmark"]');
264
+ if (el) {
265
+ const m = (el.getAttribute("aria-label") || "").match(/(\d+)/);
266
+ if (m) return parseInt(m[1]);
267
+ }
268
+ return 0;
269
+ })();
270
+
271
+ const allLinks = document.querySelectorAll("a[aria-label]");
272
+ for (const el of allLinks) {
273
+ const label = el.getAttribute("aria-label") || "";
274
+ if (/view/i.test(label) || /gΓΆrΓΌntΓΌlenme/i.test(label)) {
275
+ const m = label.match(/([\d,.]+)/);
276
+ if (m) {
277
+ r.views = parseInt(m[1].replace(/[,.]/g, ""));
278
+ break;
279
+ }
280
+ }
281
+ }
282
+
283
+ // ── Initial visible replies (no scroll) ──────────────────────
284
+ const cells = document.querySelectorAll('[data-testid="cellInnerDiv"]');
285
+ let foundMainTweet = false;
286
+
287
+ for (const cell of cells) {
288
+ const article = cell.querySelector('article[data-testid="tweet"]');
289
+ if (!article) continue;
290
+
291
+ if (!foundMainTweet) {
292
+ foundMainTweet = true;
293
+ continue;
294
+ }
295
+
296
+ const reply = { tweetId: null, username: "", handle: "", text: "", time: "", likes: 0, replies: 0, reposts: 0 };
297
+
298
+ // Extract tweet ID from permalink
299
+ const permalink = article.querySelector('a[href*="/status/"] time')?.closest("a");
300
+ if (permalink) {
301
+ const match = permalink.getAttribute("href").match(/\/status\/(\d+)/);
302
+ if (match) reply.tweetId = match[1];
303
+ }
304
+
305
+ const userNameEl = article.querySelector('[data-testid="User-Name"]');
306
+ if (userNameEl) {
307
+ const spans = userNameEl.querySelectorAll("a");
308
+ if (spans[0]) {
309
+ const nameSpan = spans[0].querySelector("span span");
310
+ if (nameSpan) reply.username = nameSpan.innerText;
311
+ }
312
+ if (spans[1]) {
313
+ const handleSpan = spans[1].querySelector("span");
314
+ if (handleSpan) reply.handle = handleSpan.innerText;
315
+ }
316
+ }
317
+
318
+ const timeEl = article.querySelector("time");
319
+ if (timeEl) reply.time = timeEl.getAttribute("datetime") || timeEl.innerText;
320
+
321
+ const textEl = article.querySelector('[data-testid="tweetText"]');
322
+ if (textEl) reply.text = textEl.innerText;
323
+
324
+ const parseBtn = (testId) => {
325
+ const el =
326
+ article.querySelector(`[data-testid="${testId}"]`) ||
327
+ article.querySelector(`[data-testid="un${testId}"]`);
328
+ if (el) {
329
+ const m = (el.getAttribute("aria-label") || "").match(/(\d+)/);
330
+ if (m) return parseInt(m[1]);
331
+ }
332
+ return 0;
333
+ };
334
+
335
+ reply.replies = parseBtn("reply");
336
+ reply.reposts = parseBtn("retweet");
337
+ reply.likes = (() => {
338
+ const el =
339
+ article.querySelector('[data-testid="like"]') ||
340
+ article.querySelector('[data-testid="unlike"]');
341
+ if (el) {
342
+ const m = (el.getAttribute("aria-label") || "").match(/(\d+)/);
343
+ if (m) return parseInt(m[1]);
344
+ }
345
+ return 0;
346
+ })();
347
+
348
+ r.initialReplies.push(reply);
349
+ }
350
+
351
+ return r;
352
+ });
353
+
354
+ return { id: tweetId, url, ...data };
355
+ }
356
+
357
+ // ── Get tweet comments (with scroll & count limit) ────────────────────────
358
+
359
+ async getTweetComments(tweetId, count = 20) {
360
+ this._ensureReady();
361
+ if (!tweetId) throw new Error("Tweet ID is required");
362
+
363
+ const url = this._tweetUrl(tweetId);
364
+
365
+ if (!this.page.url().includes(`/status/${tweetId}`)) {
366
+ await this.page.goto(url, {
367
+ waitUntil: "networkidle2",
368
+ timeout: this.timeout,
369
+ });
370
+ }
371
+ await delay(2000);
372
+
373
+ // Get actual reply count from the page to cap requested count
374
+ const actualReplyCount = await this.page.evaluate(() => {
375
+ const replyBtn = document.querySelector('[data-testid="reply"]');
376
+ if (replyBtn) {
377
+ const m = (replyBtn.getAttribute("aria-label") || "").match(/(\d+)/);
378
+ if (m) return parseInt(m[1]);
379
+ }
380
+ return 0;
381
+ });
382
+
383
+ const targetCount = Math.min(count, actualReplyCount || count);
384
+
385
+ const collectedMap = new Map(); // tweetId β†’ comment (dedup)
386
+ let scrollBlocked = false;
387
+ let noNewDataRetries = 0;
388
+ const MAX_RETRIES = 5;
389
+
390
+ const scrapeVisibleComments = async () => {
391
+ return await this.page.evaluate(() => {
392
+ const results = [];
393
+ const cells = document.querySelectorAll('[data-testid="cellInnerDiv"]');
394
+ let foundMainTweet = false;
395
+
396
+ for (const cell of cells) {
397
+ const article = cell.querySelector('article[data-testid="tweet"]');
398
+ if (!article) continue;
399
+
400
+ if (!foundMainTweet) {
401
+ foundMainTweet = true;
402
+ continue;
403
+ }
404
+
405
+ const comment = {
406
+ tweetId: null,
407
+ username: "",
408
+ handle: "",
409
+ text: "",
410
+ time: "",
411
+ likes: 0,
412
+ replies: 0,
413
+ reposts: 0,
414
+ };
415
+
416
+ // Extract tweet ID from permalink
417
+ const permalink = article.querySelector('a[href*="/status/"] time')?.closest("a");
418
+ if (permalink) {
419
+ const match = permalink.getAttribute("href").match(/\/status\/(\d+)/);
420
+ if (match) comment.tweetId = match[1];
421
+ }
422
+
423
+ const userNameEl = article.querySelector('[data-testid="User-Name"]');
424
+ if (userNameEl) {
425
+ const spans = userNameEl.querySelectorAll("a");
426
+ if (spans[0]) {
427
+ const nameSpan = spans[0].querySelector("span span");
428
+ if (nameSpan) comment.username = nameSpan.innerText;
429
+ }
430
+ if (spans[1]) {
431
+ const handleSpan = spans[1].querySelector("span");
432
+ if (handleSpan) comment.handle = handleSpan.innerText;
433
+ }
434
+ }
435
+
436
+ const timeEl = article.querySelector("time");
437
+ if (timeEl) comment.time = timeEl.getAttribute("datetime") || timeEl.innerText;
438
+
439
+ const textEl = article.querySelector('[data-testid="tweetText"]');
440
+ if (textEl) comment.text = textEl.innerText;
441
+
442
+ const parseBtn = (testId) => {
443
+ const el =
444
+ article.querySelector(`[data-testid="${testId}"]`) ||
445
+ article.querySelector(`[data-testid="un${testId}"]`);
446
+ if (el) {
447
+ const m = (el.getAttribute("aria-label") || "").match(/(\d+)/);
448
+ if (m) return parseInt(m[1]);
449
+ }
450
+ return 0;
451
+ };
452
+
453
+ comment.replies = parseBtn("reply");
454
+ comment.reposts = parseBtn("retweet");
455
+ comment.likes = (() => {
456
+ const el =
457
+ article.querySelector('[data-testid="like"]') ||
458
+ article.querySelector('[data-testid="unlike"]');
459
+ if (el) {
460
+ const m = (el.getAttribute("aria-label") || "").match(/(\d+)/);
461
+ if (m) return parseInt(m[1]);
462
+ }
463
+ return 0;
464
+ })();
465
+
466
+ results.push(comment);
467
+ }
468
+
469
+ return results;
470
+ });
471
+ };
472
+
473
+ // ── Scroll loop ──────────────────────────────────────────────────
474
+ while (collectedMap.size < targetCount) {
475
+ const visible = await scrapeVisibleComments();
476
+
477
+ let newFound = 0;
478
+ for (const c of visible) {
479
+ const key = c.tweetId || `${c.handle}_${c.text}`;
480
+ if (!collectedMap.has(key)) {
481
+ collectedMap.set(key, c);
482
+ newFound++;
483
+ }
484
+ if (collectedMap.size >= targetCount) break;
485
+ }
486
+
487
+ if (collectedMap.size >= targetCount) break;
488
+
489
+ if (newFound === 0) {
490
+ noNewDataRetries++;
491
+ if (noNewDataRetries >= MAX_RETRIES) {
492
+ scrollBlocked = true;
493
+ break;
494
+ }
495
+ } else {
496
+ noNewDataRetries = 0;
497
+ }
498
+
499
+ // Scroll down
500
+ const prevHeight = await this.page.evaluate(() => document.body.scrollHeight);
501
+ await this.page.evaluate(() => window.scrollBy(0, 800));
502
+ await delay(1500);
503
+ const newHeight = await this.page.evaluate(() => document.body.scrollHeight);
504
+
505
+ // Detect if scroll is physically blocked (page height didn't change)
506
+ if (newHeight === prevHeight) {
507
+ noNewDataRetries++;
508
+ if (noNewDataRetries >= MAX_RETRIES) {
509
+ scrollBlocked = true;
510
+ break;
511
+ }
512
+ // Wait a bit longer before retrying
513
+ await delay(1000);
514
+ }
515
+ }
516
+
517
+ const comments = Array.from(collectedMap.values()).slice(0, targetCount);
518
+
519
+ return {
520
+ id: tweetId,
521
+ url,
522
+ requested: count,
523
+ actualReplyCount,
524
+ collected: comments.length,
525
+ scrollBlocked,
526
+ comments,
527
+ };
528
+ }
529
+
530
+ // ── Search & like tweets ─────────────────────────────────────────────────
531
+
532
+ async searchAndLike(query, count = 5) {
533
+ this._ensureReady();
534
+ if (!query) throw new Error("Search query is required");
535
+
536
+ await this.page.goto(
537
+ `https://x.com/search?q=${encodeURIComponent(query)}&src=typed_query&f=live`,
538
+ { waitUntil: "networkidle2", timeout: this.timeout }
539
+ );
540
+ await delay(1000);
541
+
542
+ const likeButtons = await this.page.$$('[data-testid="like"]');
543
+ const likesToDo = Math.min(count, likeButtons.length);
544
+ let liked = 0;
545
+
546
+ for (let i = 0; i < likesToDo; i++) {
547
+ try {
548
+ await likeButtons[i].click();
549
+ liked++;
550
+ await delay(1000 + Math.random() * 1000);
551
+ } catch {
552
+ /* skip */
553
+ }
554
+ }
555
+
556
+ return { query, liked };
557
+ }
558
+
559
+ // ═══════════════════════════════════════════════════════════════════════════
560
+ // LIFECYCLE
561
+ // ═══════════════════════════════════════════════════════════════════════════
562
+
563
+ async close() {
564
+ if (this.browser) {
565
+ await this.browser.close();
566
+ this.browser = null;
567
+ this.page = null;
568
+ this.isReady = false;
569
+ this.emit("closed");
570
+ }
571
+ }
572
+
573
+ // ═══════════════════════════════════════════════════════════════════════════
574
+ // INTERNALS
575
+ // ═══════════════════════════════════════════════════════════════════════════
576
+
577
+ _ensureReady() {
578
+ if (!this.isReady) {
579
+ throw new Error("Bot not ready. Call .init() and wait for 'ready' event.");
580
+ }
581
+ }
582
+
583
+ _tweetUrl(tweetId) {
584
+ if (this.username) {
585
+ return `https://x.com/${this.username}/status/${tweetId}`;
586
+ }
587
+ return `https://x.com/i/status/${tweetId}`;
588
+ }
589
+
590
+ async _waitFor(selector, timeout = 10000) {
591
+ try {
592
+ await this.page.waitForSelector(selector, { timeout });
593
+ return true;
594
+ } catch {
595
+ return false;
596
+ }
597
+ }
598
+
599
+ async _dismissCompose() {
600
+ // Try to close the compose modal/page without triggering beforeunload issues.
601
+ // Accept any "Leave site?" dialog that appears.
602
+ this.page.once("dialog", async (dialog) => {
603
+ await dialog.accept();
604
+ });
605
+
606
+ try {
607
+ // Try clicking the close button on the compose modal
608
+ const closed = await this.page.evaluate(() => {
609
+ const closeBtn = document.querySelector('[data-testid="app-bar-close"]');
610
+ if (closeBtn) { closeBtn.click(); return true; }
611
+ return false;
612
+ });
613
+ if (closed) {
614
+ await delay(500);
615
+ // There may be a "Discard" confirmation β€” click it
616
+ await this.page.evaluate(() => {
617
+ const btns = document.querySelectorAll('[role="button"]');
618
+ for (const btn of btns) {
619
+ const t = btn.innerText.toLowerCase();
620
+ if (t === "discard" || t === "at" || t === "vazgeΓ§") {
621
+ btn.click();
622
+ return;
623
+ }
624
+ }
625
+ });
626
+ await delay(500);
627
+ }
628
+ } catch { /* page may be dead, that's ok */ }
629
+ }
630
+
631
+ async _recoverPage() {
632
+ // Accept any "Leave site?" beforeunload dialogs
633
+ this.page.once("dialog", async (dialog) => {
634
+ await dialog.accept();
635
+ });
636
+
637
+ try {
638
+ // Quick check β€” if we can read the URL, the page is alive
639
+ this.page.url();
640
+ await this.page.goto("https://x.com/home", {
641
+ waitUntil: "domcontentloaded",
642
+ timeout: this.timeout,
643
+ });
644
+ } catch {
645
+ // Page is dead β€” create a new tab
646
+ const pages = await this.browser.pages();
647
+ let recovered = false;
648
+ for (const p of pages) {
649
+ try {
650
+ p.url();
651
+ this.page = p;
652
+ this.page.once("dialog", async (d) => await d.accept());
653
+ await this.page.goto("https://x.com/home", {
654
+ waitUntil: "domcontentloaded",
655
+ timeout: this.timeout,
656
+ });
657
+ recovered = true;
658
+ break;
659
+ } catch { /* dead page, skip */ }
660
+ }
661
+
662
+ if (!recovered) {
663
+ this.page = await this.browser.newPage();
664
+ await this.page.setUserAgent(
665
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
666
+ );
667
+ await this.page.evaluateOnNewDocument(() => {
668
+ Object.defineProperty(navigator, "webdriver", { get: () => undefined });
669
+ });
670
+ const cookieObjects = this._buildCookieObjects();
671
+ await this.page.setCookie(...cookieObjects);
672
+ await this.page.goto("https://x.com/home", {
673
+ waitUntil: "domcontentloaded",
674
+ timeout: this.timeout,
675
+ });
676
+ }
677
+ }
678
+ await delay(1000);
679
+ }
680
+
681
+ _buildCookieObjects() {
682
+ return Object.entries(this.cookies).map(([name, value]) => {
683
+ const base = {
684
+ name,
685
+ value,
686
+ domain: ".x.com",
687
+ path: "/",
688
+ expires: Math.floor(Date.now() / 1000) + 365 * 24 * 60 * 60,
689
+ secure: true,
690
+ sameSite: "None",
691
+ };
692
+
693
+ if (name === "auth_token" || name === "kdt" || name === "att") {
694
+ base.httpOnly = true;
695
+ } else {
696
+ base.httpOnly = false;
697
+ }
698
+
699
+ if (name === "ct0") base.sameSite = "Lax";
700
+ if (name === "kdt") base.sameSite = "Strict";
701
+
702
+ return base;
703
+ });
704
+ }
705
+ }
706
+
707
+ module.exports = TwitterBot;
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "x-twitter-bot",
3
+ "version": "1.0.0",
4
+ "description": "Twitter/X automation library powered by Puppeteer. Cookie-based auth, tweet, like, scrape stats & comments.",
5
+ "main": "index.js",
6
+ "files": [
7
+ "index.js",
8
+ "README.md",
9
+ "LICENSE"
10
+ ],
11
+ "scripts": {
12
+ "example": "node example.js"
13
+ },
14
+ "keywords": [
15
+ "twitter",
16
+ "x",
17
+ "bot",
18
+ "automation",
19
+ "puppeteer",
20
+ "scraper"
21
+ ],
22
+ "author": "alpersamur3",
23
+ "license": "MIT",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/alpersamur3/x-twitter-bot.git"
27
+ },
28
+ "homepage": "https://github.com/alpersamur3/x-twitter-bot#readme",
29
+ "dependencies": {
30
+ "puppeteer": "^21.11.0"
31
+ },
32
+ "engines": {
33
+ "node": ">=18.0.0"
34
+ }
35
+ }