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 CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/xnotif)](https://www.npmjs.com/package/xnotif)
4
4
  [![CI](https://github.com/yutakobayashidev/xnotif/actions/workflows/ci.yml/badge.svg)](https://github.com/yutakobayashidev/xnotif/actions/workflows/ci.yml)
5
+ [![DeepWiki](https://img.shields.io/badge/DeepWiki-yutakobayashidev%2Fxnotif-blue.svg?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAyCAYAAAAnWDnqAAAAAXNSR0IArs4c6QAAA05JREFUaEPtmUtyEzEQhtWTQyQLHNak2AB7ZnyXZMEjXMGeK/AIi+QuHrMnbChYY7MIh8g01fJoopFb0uhhEqqcbWTp06/uv1saEDv4O3n3dV60RfP947Mm9/SQc0ICFQgzfc4CYZoTPAswgSJCCUJUnAAoRHOAUOcATwbmVLWdGoH//PB8mnKqScAhsD0kYP3j/Yt5LPQe2KvcXmGvRHcDnpxfL2zOYJ1mFwrryWTz0advv1Ut4CJgf5uhDuDj5eUcAUoahrdY/56ebRWeraTjMt/00Sh3UDtjgHtQNHwcRGOC98BJEAEymycmYcWwOprTgcB6VZ5JK5TAJ+fXGLBm3FDAmn6oPPjR4rKCAoJCal2eAiQp2x0vxTPB3ALO2CRkwmDy5WohzBDwSEFKRwPbknEggCPB/imwrycgxX2NzoMCHhPkDwqYMr9tRcP5qNrMZHkVnOjRMWwLCcr8ohBVb1OMjxLwGCvjTikrsBOiA6fNyCrm8V1rP93iVPpwaE+gO0SsWmPiXB+jikdf6SizrT5qKasx5j8ABbHpFTx+vFXp9EnYQmLx02h1QTTrl6eDqxLnGjporxl3NL3agEvXdT0WmEost648sQOYAeJS9Q7bfUVoMGnjo4AZdUMQku50McDcMWcBPvr0SzbTAFDfvJqwLzgxwATnCgnp4wDl6Aa+Ax283gghmj+vj7feE2KBBRMW3FzOpLOADl0Isb5587h/U4gGvkt5v60Z1VLG8BhYjbzRwyQZemwAd6cCR5/XFWLYZRIMpX39AR0tjaGGiGzLVyhse5C9RKC6ai42ppWPKiBagOvaYk8lO7DajerabOZP46Lby5wKjw1HCRx7p9sVMOWGzb/vA1hwiWc6jm3MvQDTogQkiqIhJV0nBQBTU+3okKCFDy9WwferkHjtxib7t3xIUQtHxnIwtx4mpg26/HfwVNVDb4oI9RHmx5WGelRVlrtiw43zboCLaxv46AZeB3IlTkwouebTr1y2NjSpHz68WNFjHvupy3q8TFn3Hos2IAk4Ju5dCo8B3wP7VPr/FGaKiG+T+v+TQqIrOqMTL1VdWV1DdmcbO8KXBz6esmYWYKPwDL5b5FA1a0hwapHiom0r/cKaoqr+27/XcrS5UwSMbQAAAABJRU5ErkJggg==)](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 | Required | Description |
92
- | --------- | ------------------------------------- | -------- | ---------------------- |
93
- | `cookies` | `{ auth_token: string; ct0: string }` | Yes | Session cookies |
94
- | `state` | `ClientState` | No | Restore previous state |
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
@@ -48,6 +48,7 @@ interface NotificationClientOptions {
48
48
  [key: string]: string;
49
49
  };
50
50
  state?: ClientState;
51
+ filter?: (notification: TwitterNotification) => boolean;
51
52
  }
52
53
  //#endregion
53
54
  //#region src/client.d.ts
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 API_HEADER_NAMES = [
278
- "Accept",
279
- "x-twitter-client-language",
280
- "Priority",
281
- "Referer",
282
- "Sec-Fetch-Dest",
283
- "Sec-Ch-Ua-Platform",
284
- "Sec-Fetch-Mode",
285
- "x-csrf-token",
286
- "x-client-uuid",
287
- "x-guest-token",
288
- "Sec-Ch-Ua",
289
- "x-twitter-active-user",
290
- "user-agent",
291
- "Accept-Language",
292
- "Sec-Fetch-Site",
293
- "x-twitter-auth-type",
294
- "Sec-Ch-Ua-Mobile",
295
- "Accept-Encoding"
296
- ];
297
- var NotificationApi = class extends BaseAPI {
298
- constructor(config) {
299
- super(config);
300
- }
301
- async post(path, body, initOverride) {
302
- const headers = {};
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 makeInitOverride(client, path) {
323
- return client.initOverrides({
324
- "@method": "POST",
325
- "@path": path
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 res = await api.post(path, { push_device_info: {
332
- os_version: "Web/Chrome",
333
- udid: "Web/Chrome",
334
- env: 3,
335
- locale: "en",
336
- protocol_version: 1,
337
- token: subscription.endpoint,
338
- encryption_key1: subscription.p256dh,
339
- encryption_key2: subscription.auth
340
- } }, makeInitOverride(client, path));
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.0-beta.1",
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",