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.
- package/README.md +291 -0
- package/index.js +707 -0
- 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
|
+
}
|