Skip to content

Commit 0fc791b

Browse files
committed
feat: implement experimental xp-forwarded-for support
1 parent cc1be9f commit 0fc791b

File tree

4 files changed

+158
-15
lines changed

4 files changed

+158
-15
lines changed

src/auth-user.ts

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Check } from '@sinclair/typebox/value';
99
import * as OTPAuth from 'otpauth';
1010
import { FetchParameters } from './api-types';
1111
import debug from 'debug';
12+
import { generateXPFFHeader } from './xpff';
1213

1314
const log = debug('twitter-scraper:auth-user');
1415

@@ -310,26 +311,30 @@ export class TwitterUserAuth extends TwitterGuestAuth {
310311
}
311312
}
312313

313-
async installCsrfToken(headers: Headers): Promise<void> {
314-
const cookies = await this.getCookies();
315-
const xCsrfToken = cookies.find((cookie) => cookie.key === 'ct0');
316-
if (xCsrfToken) {
317-
headers.set('x-csrf-token', xCsrfToken.value);
318-
}
319-
}
320-
321314
async installTo(headers: Headers): Promise<void> {
322315
headers.set('authorization', `Bearer ${this.bearerToken}`);
323-
const cookie = await this.getCookieString();
324-
headers.set('cookie', cookie);
325-
if (this.guestToken) {
326-
headers.set('x-guest-token', this.guestToken);
327-
}
328316
headers.set(
329317
'user-agent',
330318
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36',
331319
);
320+
321+
if (this.guestToken) {
322+
// Guest token is optional for authenticated users
323+
headers.set('x-guest-token', this.guestToken);
324+
}
325+
332326
await this.installCsrfToken(headers);
327+
328+
if (this.options?.experimental?.xpff) {
329+
const guestId = await this.guestId();
330+
if (guestId != null) {
331+
const xpffHeader = await generateXPFFHeader(guestId);
332+
headers.set('x-xp-forwarded-for', xpffHeader);
333+
}
334+
}
335+
336+
const cookie = await this.getCookieString();
337+
headers.set('cookie', cookie);
333338
}
334339

335340
private async initLogin(): Promise<FlowTokenResult> {

src/auth.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,17 @@ import {
1010
} from './rate-limit';
1111
import { AuthenticationError } from './errors';
1212
import debug from 'debug';
13+
import { generateXPFFHeader } from './xpff';
1314

1415
const log = debug('twitter-scraper:auth');
1516

1617
export interface TwitterAuthOptions {
1718
fetch: typeof fetch;
1819
transform: Partial<FetchTransformOptions>;
1920
rateLimitStrategy: RateLimitStrategy;
21+
experimental: {
22+
xpff?: boolean;
23+
};
2024
}
2125

2226
export interface TwitterAuth {
@@ -33,6 +37,9 @@ export interface TwitterAuth {
3337
*/
3438
cookieJar(): CookieJar;
3539

40+
/**
41+
* Returns the current cookies.
42+
*/
3643
getCookies(): Promise<Cookie[]>;
3744

3845
/**
@@ -187,13 +194,25 @@ export class TwitterGuestAuth implements TwitterAuth {
187194
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36',
188195
);
189196

197+
await this.installCsrfToken(headers);
198+
199+
if (this.options?.experimental?.xpff) {
200+
const guestId = await this.guestId();
201+
if (guestId != null) {
202+
const xpffHeader = await generateXPFFHeader(guestId);
203+
headers.set('x-xp-forwarded-for', xpffHeader);
204+
}
205+
}
206+
207+
headers.set('cookie', await this.getCookieString());
208+
}
209+
210+
async installCsrfToken(headers: Headers): Promise<void> {
190211
const cookies = await this.getCookies();
191212
const xCsrfToken = cookies.find((cookie) => cookie.key === 'ct0');
192213
if (xCsrfToken) {
193214
headers.set('x-csrf-token', xCsrfToken.value);
194215
}
195-
196-
headers.set('cookie', await this.getCookieString());
197216
}
198217

199218
protected async setCookie(key: string, value: string): Promise<void> {
@@ -238,6 +257,12 @@ export class TwitterGuestAuth implements TwitterAuth {
238257
: 'https://x.com';
239258
}
240259

260+
protected async guestId(): Promise<string | null> {
261+
const cookies = await this.getCookies();
262+
const guestIdCookie = cookies.find((cookie) => cookie.key === 'guest_id');
263+
return guestIdCookie ? guestIdCookie.value : null;
264+
}
265+
241266
/**
242267
* Updates the authentication state with a new guest token from the Twitter API.
243268
*/

src/scraper.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,16 @@ export interface ScraperOptions {
6767
* A handling strategy for rate limits (HTTP 429).
6868
*/
6969
rateLimitStrategy: RateLimitStrategy;
70+
71+
/**
72+
* Experimental features that may be added, changed, or removed at any time. Use with caution.
73+
*/
74+
experimental: {
75+
/**
76+
* Enables the generation of the `x-xp-forwarded-for` header on requests. This may resolve some errors.
77+
*/
78+
xpff: boolean;
79+
};
7080
}
7181

7282
/**
@@ -595,6 +605,9 @@ export class Scraper {
595605
fetch: this.options?.fetch,
596606
transform: this.options?.transform,
597607
rateLimitStrategy: this.options?.rateLimitStrategy,
608+
experimental: {
609+
xpff: this.options?.experimental?.xpff,
610+
},
598611
};
599612
}
600613

src/xpff.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import debug from 'debug';
2+
3+
const log = debug('twitter-scraper:xpff');
4+
5+
let isoCrypto: Crypto | null = null;
6+
7+
function getCrypto(): Crypto {
8+
if (isoCrypto != null) {
9+
return isoCrypto;
10+
}
11+
12+
// In Node.js, the global `crypto` object is only available from v19.0.0 onwards.
13+
// For earlier versions, we need to import the 'crypto' module.
14+
if (typeof crypto === 'undefined') {
15+
log('Global crypto is undefined, importing from crypto module...');
16+
// eslint-disable-next-line @typescript-eslint/no-var-requires
17+
const { webcrypto } = require('crypto');
18+
isoCrypto = webcrypto;
19+
return webcrypto;
20+
}
21+
isoCrypto = crypto;
22+
return crypto;
23+
}
24+
25+
async function sha256(message: string): Promise<Uint8Array> {
26+
const msgBuffer = new TextEncoder().encode(message);
27+
const hashBuffer = await getCrypto().subtle.digest('SHA-256', msgBuffer);
28+
return new Uint8Array(hashBuffer);
29+
}
30+
31+
// https://stackoverflow.com/a/40031979
32+
function buf2hex(buffer: ArrayBuffer): string {
33+
return [...new Uint8Array(buffer)]
34+
.map((x) => x.toString(16).padStart(2, '0'))
35+
.join('');
36+
}
37+
38+
// Adapted from https://github.com/dsekz/twitter-x-xp-forwarded-for-header
39+
export class XPFFHeaderGenerator {
40+
constructor(private readonly seed: string) {}
41+
42+
private async deriveKey(guestId: string): Promise<Uint8Array> {
43+
const combined = `${this.seed}${guestId}`;
44+
const result = await sha256(combined);
45+
return result;
46+
}
47+
48+
async generateHeader(plaintext: string, guestId: string): Promise<string> {
49+
log(`Generating XPFF key for guest ID: ${guestId}`);
50+
const key = await this.deriveKey(guestId);
51+
const nonce = getCrypto().getRandomValues(new Uint8Array(12));
52+
const cipher = await getCrypto().subtle.importKey(
53+
'raw',
54+
key,
55+
{ name: 'AES-GCM' },
56+
false,
57+
['encrypt'],
58+
);
59+
const encrypted = await getCrypto().subtle.encrypt(
60+
{
61+
name: 'AES-GCM',
62+
iv: nonce,
63+
},
64+
cipher,
65+
new TextEncoder().encode(plaintext),
66+
);
67+
68+
// Combine nonce and encrypted data
69+
const combined = new Uint8Array(nonce.length + encrypted.byteLength);
70+
combined.set(nonce);
71+
combined.set(new Uint8Array(encrypted), nonce.length);
72+
const result = buf2hex(combined);
73+
74+
log(`XPFF header generated for guest ID ${guestId}: ${result}`);
75+
76+
return result;
77+
}
78+
}
79+
80+
const xpffBaseKey =
81+
'0e6be1f1e21ffc33590b888fd4dc81b19713e570e805d4e5df80a493c9571a05';
82+
83+
function xpffPlain(): string {
84+
const timestamp = Date.now();
85+
return JSON.stringify({
86+
navigator_properties: {
87+
hasBeenActive: 'true',
88+
userAgent:
89+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36',
90+
webdriver: 'false',
91+
},
92+
created_at: timestamp,
93+
});
94+
}
95+
96+
export async function generateXPFFHeader(guestId: string): Promise<string> {
97+
const generator = new XPFFHeaderGenerator(xpffBaseKey);
98+
const plaintext = xpffPlain();
99+
return generator.generateHeader(plaintext, guestId);
100+
}

0 commit comments

Comments
 (0)