xnotif 0.2.0-beta.1 → 0.2.1
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 +26 -4
- package/dist/index.d.mts +1 -0
- package/dist/index.mjs +79 -62
- package/package.json +2 -5
package/README.md
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/xnotif)
|
|
4
4
|
[](https://github.com/yutakobayashidev/xnotif/actions/workflows/ci.yml)
|
|
5
|
+
[](https://deepwiki.com/yutakobayashidev/xnotif)
|
|
5
6
|
|
|
6
7
|
Receive Twitter/X notifications in real-time. No API key, no scraping — just Web Push.
|
|
7
8
|
|
|
@@ -27,6 +28,13 @@ npm install xnotif
|
|
|
27
28
|
|
|
28
29
|
> Requires Node.js >= 22.0.0
|
|
29
30
|
|
|
31
|
+
## Why xnotif
|
|
32
|
+
|
|
33
|
+
- **Cookie exposure can be minimized** - Cookies are mainly used for one registration call to `POST /1.1/notifications/settings/login.json`. After registration, notifications are received through Mozilla Autopush WebSocket, so you are not continuously calling internal Twitter endpoints with cookies.
|
|
34
|
+
- **Lower ban-risk profile than polling/scraping** - xnotif avoids headless-browser automation and high-frequency private API polling. The runtime traffic pattern is mostly one registration plus push-stream consumption.
|
|
35
|
+
- **Avoid unnecessary re-registration** - If you persist `ClientState` from the `connected` event, restart with `state`, and the endpoint is unchanged, xnotif skips the registration call.
|
|
36
|
+
- **Simple operations** - No API key provisioning, no webhook server, and no request-signing stack. A single Node.js process can receive and process notifications.
|
|
37
|
+
|
|
30
38
|
## Notification Payload
|
|
31
39
|
|
|
32
40
|
Each `notification` event delivers a `TwitterNotification` object:
|
|
@@ -88,10 +96,24 @@ await client.start();
|
|
|
88
96
|
|
|
89
97
|
### `createClient(options)`
|
|
90
98
|
|
|
91
|
-
| Option | Type
|
|
92
|
-
| --------- |
|
|
93
|
-
| `cookies` | `{ auth_token: string; ct0: string }`
|
|
94
|
-
| `state` | `ClientState`
|
|
99
|
+
| Option | Type | Required | Description |
|
|
100
|
+
| --------- | ------------------------------------------------ | -------- | --------------------------------- |
|
|
101
|
+
| `cookies` | `{ auth_token: string; ct0: string }` | Yes | Session cookies |
|
|
102
|
+
| `state` | `ClientState` | No | Restore previous state |
|
|
103
|
+
| `filter` | `(notification: TwitterNotification) => boolean` | No | Predicate to filter notifications |
|
|
104
|
+
|
|
105
|
+
#### Filtering Notifications
|
|
106
|
+
|
|
107
|
+
Pass a `filter` function to receive only the notifications you care about:
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
const client = createClient({
|
|
111
|
+
cookies: { auth_token: "...", ct0: "..." },
|
|
112
|
+
filter: (n) => n.data?.type === "tweet",
|
|
113
|
+
});
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The predicate receives the decrypted `TwitterNotification` object. Return `true` to emit the notification, `false` to discard it silently. If the filter throws an exception, the notification is discarded and an `error` event is emitted.
|
|
95
117
|
|
|
96
118
|
### Events
|
|
97
119
|
|
package/dist/index.d.mts
CHANGED
package/dist/index.mjs
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import { EventEmitter } from "events";
|
|
2
|
-
import { TwitterOpenApi } from "twitter-openapi-typescript";
|
|
3
|
-
import { BaseAPI } from "twitter-openapi-typescript-generated";
|
|
4
2
|
|
|
5
3
|
//#region src/utils.ts
|
|
6
4
|
function base64urlToBuffer(b64url) {
|
|
@@ -274,70 +272,83 @@ var AutopushClient = class {
|
|
|
274
272
|
|
|
275
273
|
//#endregion
|
|
276
274
|
//#region src/twitter.ts
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
"
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
"
|
|
292
|
-
|
|
293
|
-
"
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
const apiKey = this.configuration.apiKey;
|
|
304
|
-
if (apiKey) for (const name of API_HEADER_NAMES) {
|
|
305
|
-
const value = await apiKey(name);
|
|
306
|
-
if (value) headers[name] = value;
|
|
307
|
-
}
|
|
308
|
-
const accessToken = this.configuration.accessToken;
|
|
309
|
-
if (accessToken) headers["Authorization"] = `Bearer ${await accessToken()}`;
|
|
310
|
-
headers["Content-Type"] = "application/json";
|
|
311
|
-
return this.request({
|
|
312
|
-
path,
|
|
313
|
-
method: "POST",
|
|
314
|
-
headers,
|
|
315
|
-
body
|
|
316
|
-
}, initOverride);
|
|
317
|
-
}
|
|
318
|
-
};
|
|
319
|
-
async function createClient$1(cookies) {
|
|
320
|
-
return new TwitterOpenApi().getClientFromCookies(cookies);
|
|
275
|
+
const BEARER_TOKEN = "AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA";
|
|
276
|
+
const HEADER_URL = "https://raw.githubusercontent.com/fa0311/latest-user-agent/refs/heads/main/header.json";
|
|
277
|
+
const PAIR_URL = "https://raw.githubusercontent.com/fa0311/x-client-transaction-pair-dict/refs/heads/main/pair.json";
|
|
278
|
+
async function generateTransactionId(method, path, key, animationKey) {
|
|
279
|
+
const DEFAULT_KEYWORD = "obfiowerehiring";
|
|
280
|
+
const ADDITIONAL_RANDOM_NUMBER = 3;
|
|
281
|
+
const timeNow = Math.floor((Date.now() - 1682924400 * 1e3) / 1e3);
|
|
282
|
+
const timeNowBytes = [
|
|
283
|
+
timeNow & 255,
|
|
284
|
+
timeNow >> 8 & 255,
|
|
285
|
+
timeNow >> 16 & 255,
|
|
286
|
+
timeNow >> 24 & 255
|
|
287
|
+
];
|
|
288
|
+
const data = `${method}!${path}!${timeNow}${DEFAULT_KEYWORD}${animationKey}`;
|
|
289
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(data));
|
|
290
|
+
const hashBytes = Array.from(new Uint8Array(hashBuffer));
|
|
291
|
+
const keyBytes = Array.from(Buffer.from(key, "base64"));
|
|
292
|
+
const randomNum = Math.floor(Math.random() * 256);
|
|
293
|
+
const bytesArr = [
|
|
294
|
+
...keyBytes,
|
|
295
|
+
...timeNowBytes,
|
|
296
|
+
...hashBytes.slice(0, 16),
|
|
297
|
+
ADDITIONAL_RANDOM_NUMBER
|
|
298
|
+
];
|
|
299
|
+
const out = new Uint8Array([randomNum, ...bytesArr.map((b) => b ^ randomNum)]);
|
|
300
|
+
return Buffer.from(out).toString("base64").replace(/=/g, "");
|
|
321
301
|
}
|
|
322
|
-
function
|
|
323
|
-
return
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
302
|
+
function encodeCookies(cookies) {
|
|
303
|
+
return Object.entries(cookies).map(([k, v]) => `${k}=${v}`).join("; ");
|
|
304
|
+
}
|
|
305
|
+
async function createClient$1(cookies) {
|
|
306
|
+
const [headerJson, pairs] = await Promise.all([fetch(HEADER_URL).then((r) => r.json()), fetch(PAIR_URL).then((r) => r.json())]);
|
|
307
|
+
const ignore = new Set(["host", "connection"]);
|
|
308
|
+
const headers = {
|
|
309
|
+
...Object.fromEntries(Object.entries(headerJson["chrome-fetch"]).filter(([k]) => !ignore.has(k))),
|
|
310
|
+
"accept-encoding": "identity",
|
|
311
|
+
pragma: "no-cache",
|
|
312
|
+
referer: "https://x.com",
|
|
313
|
+
priority: "u=1, i",
|
|
314
|
+
"x-twitter-client-language": "en",
|
|
315
|
+
"x-twitter-active-user": "yes",
|
|
316
|
+
authorization: `Bearer ${BEARER_TOKEN}`
|
|
317
|
+
};
|
|
318
|
+
if (cookies["ct0"]) {
|
|
319
|
+
headers["x-twitter-auth-type"] = "OAuth2Session";
|
|
320
|
+
headers["x-csrf-token"] = cookies["ct0"];
|
|
321
|
+
}
|
|
322
|
+
if (cookies["gt"]) headers["x-guest-token"] = cookies["gt"];
|
|
323
|
+
return {
|
|
324
|
+
headers,
|
|
325
|
+
cookies,
|
|
326
|
+
pairs
|
|
327
|
+
};
|
|
327
328
|
}
|
|
328
329
|
async function registerPush(client, subscription) {
|
|
329
|
-
const api = new NotificationApi(client.config);
|
|
330
330
|
const path = "/1.1/notifications/settings/login.json";
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
331
|
+
const pair = client.pairs[Math.floor(Math.random() * client.pairs.length)];
|
|
332
|
+
const tid = await generateTransactionId("POST", path, pair.verification, pair.animationKey);
|
|
333
|
+
const res = await fetch(`https://x.com/i/api${path}`, {
|
|
334
|
+
method: "POST",
|
|
335
|
+
headers: {
|
|
336
|
+
...client.headers,
|
|
337
|
+
cookie: encodeCookies(client.cookies),
|
|
338
|
+
"content-type": "application/json",
|
|
339
|
+
"x-client-transaction-id": tid
|
|
340
|
+
},
|
|
341
|
+
body: JSON.stringify({ push_device_info: {
|
|
342
|
+
os_version: "Web/Chrome",
|
|
343
|
+
udid: "Web/Chrome",
|
|
344
|
+
env: 3,
|
|
345
|
+
locale: "en",
|
|
346
|
+
protocol_version: 1,
|
|
347
|
+
token: subscription.endpoint,
|
|
348
|
+
encryption_key1: subscription.p256dh,
|
|
349
|
+
encryption_key2: subscription.auth
|
|
350
|
+
} })
|
|
351
|
+
});
|
|
341
352
|
if (!res.ok) {
|
|
342
353
|
const text = await res.text();
|
|
343
354
|
throw new Error(`login.json failed (${res.status}): ${text}`);
|
|
@@ -385,6 +396,12 @@ var NotificationClient = class extends EventEmitter {
|
|
|
385
396
|
const payload = base64urlToBuffer(msg.data);
|
|
386
397
|
const json = await decryptor.decrypt(msg.headers.crypto_key, msg.headers.encryption, payload);
|
|
387
398
|
const notification = JSON.parse(json);
|
|
399
|
+
if (this.options.filter) try {
|
|
400
|
+
if (!this.options.filter(notification)) return;
|
|
401
|
+
} catch (filterErr) {
|
|
402
|
+
this.emit("error", filterErr instanceof Error ? filterErr : new Error(String(filterErr)));
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
388
405
|
this.emit("notification", notification);
|
|
389
406
|
} catch (err) {
|
|
390
407
|
this.emit("error", err instanceof Error ? err : new Error(String(err)));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "xnotif",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Receive Twitter/X push notifications programmatically via Mozilla Autopush",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"autopush",
|
|
@@ -30,10 +30,7 @@
|
|
|
30
30
|
"import": "./dist/index.mjs"
|
|
31
31
|
}
|
|
32
32
|
},
|
|
33
|
-
"dependencies": {
|
|
34
|
-
"twitter-openapi-typescript": "^0.0.55",
|
|
35
|
-
"twitter-openapi-typescript-generated": "^0.0.38"
|
|
36
|
-
},
|
|
33
|
+
"dependencies": {},
|
|
37
34
|
"devDependencies": {
|
|
38
35
|
"@types/node": "^22.0.0",
|
|
39
36
|
"@vitest/coverage-v8": "^4.0.18",
|