emusks 2.3.7 → 2.3.9
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 +9 -1
- package/package.json +4 -3
- package/src/castle-profile.js +18 -0
- package/src/castle.js +72 -17
- package/src/flow-jetfuel.js +125 -50
- package/src/helpers/account.js +2 -1
- package/src/helpers/tweets.js +2 -1
- package/src/helpers/users.js +4 -2
- package/src/index.js +41 -17
- package/src/parsers/jetfuel-messages.js +121 -0
- package/src/parsers/tweet.js +4 -0
- package/src/static/graphql.js +1 -1
- package/src/static/v1.1.js +1 -1
- package/src/static/v2.js +1 -1
- package/src/vendor/castle-sdk.min.js +6 -3
- package/src/scripts/castle-mint-test.js +0 -8
- package/src/scripts/login-test.js +0 -28
- package/src/scripts/verify-session.js +0 -11
package/README.md
CHANGED
|
@@ -5,4 +5,12 @@ Log in and interact with the unofficial X API using any client identity - web, A
|
|
|
5
5
|
|
|
6
6
|
officially dmca'd by twitter™ 🏆 • includes a few leaked ads bearers
|
|
7
7
|
|
|
8
|
-
[Learn more →](https://emusks.tiago.zip) [
|
|
8
|
+
[Learn more →](https://emusks.tiago.zip) [Source](https://code.lgbt/tiago/emusks)
|
|
9
|
+
|
|
10
|
+
## browser-free password login
|
|
11
|
+
|
|
12
|
+
password login can use a private Castle signal profile to generate fresh request tokens locally. emusks loads `.emusks/castle-profile.json` from the working directory, or the file selected by `EMUSKS_CASTLE_PROFILE`. an explicit `castleProfile` login option accepts a path or profile object; `false` disables profile loading.
|
|
13
|
+
|
|
14
|
+
the profile contains saved browser measurements, not a pre-generated request token. six request-specific fields are generated by the SDK for each local instance. the profile's user agent and client hints are retained through login and the authenticated session. profiles are bound to the bundled SDK's SHA-256 hash; a changed SDK produces an error before credential submission.
|
|
15
|
+
|
|
16
|
+
keep the profile private and exclude `.emusks/` from version control. profiles are not included in the published package. token generation and login do not launch or contact a browser.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "emusks",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.9",
|
|
4
4
|
"description": "Reverse-engineered Twitter API client. Log in and interact with the unofficial X API using any client identity - web, Android, iOS, or TweetDeck",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"client",
|
|
@@ -12,12 +12,12 @@
|
|
|
12
12
|
],
|
|
13
13
|
"homepage": "https://emusks.tiago.zip",
|
|
14
14
|
"bugs": {
|
|
15
|
-
"url": "https://
|
|
15
|
+
"url": "https://code.lgbt/tiago/emusks/issues"
|
|
16
16
|
},
|
|
17
17
|
"license": "AGPL-3.0-only",
|
|
18
18
|
"repository": {
|
|
19
19
|
"type": "git",
|
|
20
|
-
"url": "https://
|
|
20
|
+
"url": "https://code.lgbt/tiago/emusks"
|
|
21
21
|
},
|
|
22
22
|
"type": "module",
|
|
23
23
|
"main": "src/index.js",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
},
|
|
27
27
|
"files": [
|
|
28
28
|
"src",
|
|
29
|
+
"!src/scripts",
|
|
29
30
|
"README.md"
|
|
30
31
|
],
|
|
31
32
|
"dependencies": {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
export function loadCastleProfile(value) {
|
|
5
|
+
if (value === false) return null;
|
|
6
|
+
const path = value ?? process.env.EMUSKS_CASTLE_PROFILE ?? resolve(".emusks/castle-profile.json");
|
|
7
|
+
if (value == null && !process.env.EMUSKS_CASTLE_PROFILE && !existsSync(path)) return null;
|
|
8
|
+
const profile = typeof path === "string" ? JSON.parse(readFileSync(path, "utf8")) : path;
|
|
9
|
+
if (profile?.format !== 1 || !/^[a-f0-9]{64}$/.test(profile.sdkSha256 ?? "") || typeof profile.userAgent !== "string" || !Array.isArray(profile.signals) || profile.signals.length !== 749) {
|
|
10
|
+
throw new Error("invalid Castle profile");
|
|
11
|
+
}
|
|
12
|
+
const clientHints = {};
|
|
13
|
+
for (const name of ["sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform"]) {
|
|
14
|
+
if (typeof profile.clientHints?.[name] !== "string") throw new Error(`Castle profile is missing ${name}`);
|
|
15
|
+
clientHints[name] = profile.clientHints[name];
|
|
16
|
+
}
|
|
17
|
+
return { ...profile, clientHints };
|
|
18
|
+
}
|
package/src/castle.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { loadCastleProfile } from "./castle-profile.js";
|
|
1
3
|
import { readFileSync } from "node:fs";
|
|
2
4
|
import { dirname, join } from "node:path";
|
|
3
5
|
import { fileURLToPath } from "node:url";
|
|
@@ -7,7 +9,7 @@ import { parseHTML } from "linkedom";
|
|
|
7
9
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
8
10
|
|
|
9
11
|
export const DEFAULT_CASTLE_PK = "pk_AvRa79bHyJSYSQHnRpcVtzyxetSvFerx";
|
|
10
|
-
export const CASTLE_MODULE_ID = "
|
|
12
|
+
export const CASTLE_MODULE_ID = "855881";
|
|
11
13
|
|
|
12
14
|
const DEFAULT_UA =
|
|
13
15
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36";
|
|
@@ -42,36 +44,53 @@ function mimeTypeArray() {
|
|
|
42
44
|
return arr;
|
|
43
45
|
}
|
|
44
46
|
|
|
45
|
-
function buildSandbox(userAgent) {
|
|
46
|
-
const { document } = parseHTML(
|
|
47
|
-
"<!DOCTYPE html><html><head><title>X</title></head><body></body></html>",
|
|
47
|
+
function buildSandbox(userAgent, cookies, html) {
|
|
48
|
+
const { document, window: domWindow } = parseHTML(
|
|
49
|
+
html ?? "<!DOCTYPE html><html><head><title>X</title></head><body></body></html>",
|
|
48
50
|
);
|
|
49
51
|
const define = (prop, value) => {
|
|
50
52
|
try {
|
|
51
53
|
Object.defineProperty(document, prop, { get: () => value, configurable: true });
|
|
52
54
|
} catch {}
|
|
53
55
|
};
|
|
54
|
-
|
|
56
|
+
Object.defineProperty(document, "cookie", {
|
|
57
|
+
configurable: true,
|
|
58
|
+
get: () => Object.entries(cookies).map(([name, value]) => `${name}=${value}`).join("; "),
|
|
59
|
+
set: (cookie) => {
|
|
60
|
+
const [pair, ...attributes] = String(cookie).split(";");
|
|
61
|
+
const separator = pair.indexOf("=");
|
|
62
|
+
if (separator < 1) return;
|
|
63
|
+
const name = pair.slice(0, separator).trim();
|
|
64
|
+
const attrs = Object.fromEntries(attributes.map((attribute) => {
|
|
65
|
+
const [key, ...parts] = attribute.trim().split("=");
|
|
66
|
+
return [key.toLowerCase(), parts.join("=")];
|
|
67
|
+
}));
|
|
68
|
+
const maxAge = /^-?\d+$/.test(attrs["max-age"] ?? "") ? Number(attrs["max-age"]) : null;
|
|
69
|
+
const expired = maxAge !== null ? maxAge <= 0 : Date.parse(attrs.expires) <= Date.now();
|
|
70
|
+
if (expired) delete cookies[name];
|
|
71
|
+
else cookies[name] = pair.slice(separator + 1).trim();
|
|
72
|
+
},
|
|
73
|
+
});
|
|
55
74
|
define("readyState", "complete");
|
|
56
75
|
define("referrer", "https://x.com/");
|
|
57
76
|
define("visibilityState", "visible");
|
|
58
77
|
define("hidden", false);
|
|
78
|
+
Object.defineProperty(document, "scripts", { get: () => document.getElementsByTagName("script"), configurable: true });
|
|
59
79
|
document.hasFocus = () => true;
|
|
60
80
|
|
|
61
81
|
const s = {};
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
s
|
|
66
|
-
s.top = s;
|
|
67
|
-
s.parent = s;
|
|
68
|
-
s.frames = s;
|
|
82
|
+
for (const name of ["Element", "HTMLElement", "Node", "Document", "DocumentFragment", "Event", "CustomEvent", "EventTarget", "MutationObserver", "HTMLCanvasElement", "HTMLIFrameElement", "HTMLImageElement"]) {
|
|
83
|
+
if (domWindow[name]) s[name] = domWindow[name];
|
|
84
|
+
}
|
|
85
|
+
Object.assign(s, { Request, Response, Headers, AbortController, AbortSignal, URL, URLSearchParams, Blob, CompressionStream, DecompressionStream, ReadableStream, WritableStream, TransformStream });
|
|
69
86
|
s.name = "";
|
|
70
87
|
s.closed = false;
|
|
71
88
|
s.origin = "https://x.com";
|
|
72
89
|
s.isSecureContext = true;
|
|
73
90
|
|
|
74
91
|
s.document = document;
|
|
92
|
+
const context2d = document.createElement("canvas").getContext("2d");
|
|
93
|
+
if (context2d) s.CanvasRenderingContext2D = context2d.constructor;
|
|
75
94
|
s.navigator = {
|
|
76
95
|
userAgent,
|
|
77
96
|
appVersion: userAgent.replace("Mozilla/", ""),
|
|
@@ -129,12 +148,12 @@ function buildSandbox(userAgent) {
|
|
|
129
148
|
orientation: { type: "landscape-primary", angle: 0 },
|
|
130
149
|
};
|
|
131
150
|
s.location = {
|
|
132
|
-
href: "https://x.com/
|
|
151
|
+
href: "https://x.com/",
|
|
133
152
|
protocol: "https:",
|
|
134
153
|
host: "x.com",
|
|
135
154
|
hostname: "x.com",
|
|
136
155
|
origin: "https://x.com",
|
|
137
|
-
pathname: "/
|
|
156
|
+
pathname: "/",
|
|
138
157
|
search: "",
|
|
139
158
|
hash: "",
|
|
140
159
|
};
|
|
@@ -208,6 +227,22 @@ function buildSandbox(userAgent) {
|
|
|
208
227
|
addEventListener() {}
|
|
209
228
|
};
|
|
210
229
|
s.getComputedStyle = () => ({ getPropertyValue: () => "" });
|
|
230
|
+
const createElement = document.createElement.bind(document);
|
|
231
|
+
document.createElement = (name, ...args) => {
|
|
232
|
+
const element = createElement(name, ...args);
|
|
233
|
+
if (String(name).toLowerCase() !== "iframe") return element;
|
|
234
|
+
const frameDocument = parseHTML("<!doctype html><html><head></head><body></body></html>").document;
|
|
235
|
+
const frame = vm.createContext({ document: frameDocument, navigator: s.navigator, performance: s.performance });
|
|
236
|
+
const frameWindow = vm.runInContext("self = window = globalThis", frame);
|
|
237
|
+
frameWindow.parent = document.defaultView;
|
|
238
|
+
frameWindow.top = document.defaultView;
|
|
239
|
+
Object.defineProperty(frameDocument, "defaultView", { get: () => frameWindow, configurable: true });
|
|
240
|
+
Object.defineProperties(element, {
|
|
241
|
+
contentWindow: { get: () => frameWindow },
|
|
242
|
+
contentDocument: { get: () => frameDocument },
|
|
243
|
+
});
|
|
244
|
+
return element;
|
|
245
|
+
};
|
|
211
246
|
return s;
|
|
212
247
|
}
|
|
213
248
|
|
|
@@ -217,20 +252,40 @@ export function createCastleMinter(opts = {}) {
|
|
|
217
252
|
userAgent = DEFAULT_UA,
|
|
218
253
|
sdkPath = join(HERE, "vendor", "castle-sdk.min.js"),
|
|
219
254
|
sdkSource,
|
|
255
|
+
cookies = {},
|
|
256
|
+
html,
|
|
257
|
+
profile: profileOption,
|
|
220
258
|
} = opts;
|
|
221
259
|
|
|
222
|
-
|
|
223
|
-
const
|
|
260
|
+
let src = sdkSource ?? readFileSync(sdkPath, "utf8");
|
|
261
|
+
const profile = loadCastleProfile(profileOption);
|
|
262
|
+
if (profile) {
|
|
263
|
+
if (createHash("sha256").update(src).digest("hex") !== profile.sdkSha256 || !src.includes(",$=[],")) {
|
|
264
|
+
throw new Error("Castle profile does not match the bundled SDK");
|
|
265
|
+
}
|
|
266
|
+
const signals = Object.fromEntries(profile.signals.flatMap((value, index) => [4, 116, 342, 356, 465, 550].includes(index) ? [] : [[index, value]]));
|
|
267
|
+
src = src.replace(",$=[],", `,$=new Proxy(Object.assign([],${JSON.stringify(signals)}),{set(target,key,value){const profile=${JSON.stringify(signals)};target[key]=Object.hasOwn(profile,key)?profile[key]:value;return true;}}),`);
|
|
268
|
+
}
|
|
269
|
+
const sandbox = buildSandbox(profile?.userAgent ?? userAgent, cookies, html);
|
|
224
270
|
|
|
225
271
|
let factory = null;
|
|
226
272
|
sandbox.webpackChunk_twitter_responsive_web = {
|
|
227
273
|
push: (entry) => {
|
|
228
274
|
const modules = entry?.[1];
|
|
229
|
-
if (modules
|
|
275
|
+
if (modules?.[CASTLE_MODULE_ID]) factory = modules[CASTLE_MODULE_ID];
|
|
230
276
|
},
|
|
231
277
|
};
|
|
232
278
|
|
|
233
279
|
const ctx = vm.createContext(sandbox);
|
|
280
|
+
vm.runInContext("self = window = global = top = parent = frames = globalThis", ctx);
|
|
281
|
+
vm.runInContext(`
|
|
282
|
+
Object.defineProperties(document, {
|
|
283
|
+
defaultView: { get: () => window, configurable: true },
|
|
284
|
+
location: { get: () => location, configurable: true },
|
|
285
|
+
URL: { get: () => location.href, configurable: true },
|
|
286
|
+
documentURI: { get: () => location.href, configurable: true },
|
|
287
|
+
});
|
|
288
|
+
`, ctx);
|
|
234
289
|
vm.runInContext(src, ctx, { filename: "castle-sdk.min.js" });
|
|
235
290
|
if (!factory) throw new Error(`castle sdk module ${CASTLE_MODULE_ID} not found (X may have rotated the chunk)`);
|
|
236
291
|
|
package/src/flow-jetfuel.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { loadCastleProfile } from "./castle-profile.js";
|
|
2
|
+
import { createCastleMinter } from "./castle.js";
|
|
3
3
|
import clients from "./clients.js";
|
|
4
4
|
import getCycleTLS from "./cycletls.js";
|
|
5
|
-
import {
|
|
5
|
+
import { decodeJetfuelMessages } from "./parsers/jetfuel-messages.js";
|
|
6
6
|
|
|
7
7
|
const GUEST_ACTIVATE_URL = "https://api.x.com/1.1/guest/activate.json";
|
|
8
|
-
const JFAPI_BASE = "https://x.com
|
|
8
|
+
const JFAPI_BASE = "https://jf.x.com";
|
|
9
9
|
const SURFACE = "onboarding/web";
|
|
10
10
|
const UA =
|
|
11
11
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36";
|
|
@@ -13,11 +13,12 @@ const UA =
|
|
|
13
13
|
const MAX_STEPS = 15;
|
|
14
14
|
|
|
15
15
|
class CookieSession {
|
|
16
|
-
constructor(cycleTLS, proxy, timeout) {
|
|
16
|
+
constructor(cycleTLS, proxy, timeout, profile) {
|
|
17
17
|
this.cycleTLS = cycleTLS;
|
|
18
18
|
this.cookies = {};
|
|
19
19
|
this.proxy = proxy;
|
|
20
20
|
this.timeout = timeout;
|
|
21
|
+
this.profile = profile;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
cookieString() {
|
|
@@ -30,13 +31,24 @@ class CookieSession {
|
|
|
30
31
|
const setCookie = res.headers?.["set-cookie"] || res.headers?.["Set-Cookie"];
|
|
31
32
|
if (!setCookie) return;
|
|
32
33
|
for (const cookie of Array.isArray(setCookie) ? setCookie : [setCookie]) {
|
|
33
|
-
const [
|
|
34
|
-
|
|
34
|
+
const [pair, ...attributes] = cookie.split(";");
|
|
35
|
+
const separator = pair.indexOf("=");
|
|
36
|
+
if (separator < 1) continue;
|
|
37
|
+
const key = pair.slice(0, separator).trim();
|
|
38
|
+
const attrs = Object.fromEntries(attributes.map((attribute) => {
|
|
39
|
+
const [name, ...value] = attribute.trim().split("=");
|
|
40
|
+
return [name.toLowerCase(), value.join("=")];
|
|
41
|
+
}));
|
|
42
|
+
const maxAge = /^-?\d+$/.test(attrs["max-age"] ?? "") ? Number(attrs["max-age"]) : null;
|
|
43
|
+
const expired = maxAge !== null ? maxAge <= 0 : Date.parse(attrs.expires) <= Date.now();
|
|
44
|
+
if (expired) delete this.cookies[key];
|
|
45
|
+
else this.cookies[key] = pair.slice(separator + 1).trim();
|
|
35
46
|
}
|
|
36
47
|
}
|
|
37
48
|
|
|
38
49
|
async request(url, method, { headers = {}, body, responseType } = {}) {
|
|
39
|
-
const h = { ...headers };
|
|
50
|
+
const h = { ...this.profile?.clientHints, ...headers };
|
|
51
|
+
if (this.cookies.ct0) h["x-csrf-token"] = this.cookies.ct0;
|
|
40
52
|
const cookieStr = this.cookieString();
|
|
41
53
|
if (cookieStr) h.Cookie = cookieStr;
|
|
42
54
|
|
|
@@ -44,9 +56,8 @@ class CookieSession {
|
|
|
44
56
|
url,
|
|
45
57
|
{
|
|
46
58
|
body,
|
|
47
|
-
ja3: clients.web.fingerprints.ja3,
|
|
48
|
-
|
|
49
|
-
userAgent: UA,
|
|
59
|
+
ja3: clients.web.fingerprints.ja3.replace(/^771,/, "772,").replace("4588-", ""),
|
|
60
|
+
userAgent: this.profile?.userAgent ?? UA,
|
|
50
61
|
headers: h,
|
|
51
62
|
proxy: this.proxy || undefined,
|
|
52
63
|
responseType,
|
|
@@ -59,7 +70,7 @@ class CookieSession {
|
|
|
59
70
|
}
|
|
60
71
|
}
|
|
61
72
|
|
|
62
|
-
function jfHeaders(guestToken,
|
|
73
|
+
function jfHeaders(guestToken, theme) {
|
|
63
74
|
const headers = {
|
|
64
75
|
authorization: `Bearer ${clients.web.bearer}`,
|
|
65
76
|
"x-guest-token": guestToken,
|
|
@@ -71,9 +82,8 @@ function jfHeaders(guestToken, tid, theme) {
|
|
|
71
82
|
accept: "*/*",
|
|
72
83
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
|
73
84
|
Origin: "https://x.com",
|
|
74
|
-
Referer: "https://x.com/
|
|
85
|
+
Referer: "https://x.com/",
|
|
75
86
|
};
|
|
76
|
-
if (tid) headers["x-client-transaction-id"] = tid;
|
|
77
87
|
return headers;
|
|
78
88
|
}
|
|
79
89
|
|
|
@@ -95,12 +105,18 @@ async function toBuffer(res) {
|
|
|
95
105
|
return Buffer.from(String(body ?? ""), "utf8");
|
|
96
106
|
}
|
|
97
107
|
|
|
98
|
-
function
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
108
|
+
function decodeResponse(buffer) {
|
|
109
|
+
const { messages, strings } = decodeJetfuelMessages(buffer);
|
|
110
|
+
const forms = [];
|
|
111
|
+
for (const [tag, message] of messages) {
|
|
112
|
+
if (tag !== 0) continue;
|
|
113
|
+
for (const [type, value] of message.props) {
|
|
114
|
+
if (type === 30) {
|
|
115
|
+
forms.push({ url: value[0], fields: Object.fromEntries(value[1]), errors: Object.fromEntries(value[2]) });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
103
118
|
}
|
|
119
|
+
return { strings, forms };
|
|
104
120
|
}
|
|
105
121
|
|
|
106
122
|
function extractActionIds(strings) {
|
|
@@ -130,10 +146,10 @@ function extractUserId(cookies) {
|
|
|
130
146
|
return null;
|
|
131
147
|
}
|
|
132
148
|
|
|
133
|
-
async function resolve(value, onRequest, type) {
|
|
149
|
+
async function resolve(value, onRequest, type, context) {
|
|
134
150
|
if (value != null && value !== "") return value;
|
|
135
151
|
if (onRequest) {
|
|
136
|
-
const v = await onRequest(type);
|
|
152
|
+
const v = await onRequest(type, context);
|
|
137
153
|
if (v != null && v !== "") return v;
|
|
138
154
|
}
|
|
139
155
|
throw new Error(
|
|
@@ -148,7 +164,8 @@ export default async function flowLoginJetfuel(opts) {
|
|
|
148
164
|
email,
|
|
149
165
|
phone,
|
|
150
166
|
onRequest,
|
|
151
|
-
getCastleToken
|
|
167
|
+
getCastleToken,
|
|
168
|
+
castleProfile,
|
|
152
169
|
proxy,
|
|
153
170
|
timeout,
|
|
154
171
|
theme = "light",
|
|
@@ -161,57 +178,63 @@ export default async function flowLoginJetfuel(opts) {
|
|
|
161
178
|
if (!password) throw new Error("password is required for login");
|
|
162
179
|
|
|
163
180
|
const cycleTLS = await getCycleTLS();
|
|
164
|
-
const
|
|
181
|
+
const profile = loadCastleProfile(castleProfile);
|
|
182
|
+
const session = new CookieSession(cycleTLS, proxy, timeout, profile);
|
|
165
183
|
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
184
|
+
const home = await session.request("https://x.com/", "get", {
|
|
185
|
+
headers: { accept: "text/html", "accept-language": "en-US,en;q=0.9" },
|
|
186
|
+
});
|
|
187
|
+
if (home.status !== 200) throw new Error(`login bootstrap failed: ${home.status}`);
|
|
188
|
+
const homeHtml = (await toBuffer(home)).toString("utf8");
|
|
189
|
+
const mint = getCastleToken ?? createCastleMinter({ userAgent: UA, cookies: session.cookies, html: homeHtml, profile: profile ?? false });
|
|
170
190
|
|
|
171
|
-
const guestToken =
|
|
172
|
-
|
|
191
|
+
const guestToken = homeHtml.match(/document\.cookie\s*=\s*["']gt=(\d+);/)?.[1]
|
|
192
|
+
?? session.cookies.gt
|
|
193
|
+
?? await getGuestToken(session);
|
|
194
|
+
session.cookies.gt = guestToken;
|
|
195
|
+
log("guest token obtained");
|
|
173
196
|
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
headers: jfHeaders(guestToken, await tid("GET", landingPath), theme),
|
|
197
|
+
const landing = await session.request(`${JFAPI_BASE}/${SURFACE}/landing`, "get", {
|
|
198
|
+
headers: jfHeaders(guestToken, theme),
|
|
177
199
|
responseType: "arraybuffer",
|
|
178
200
|
});
|
|
179
201
|
if (landing.status !== 200) throw new Error(`landing failed: ${landing.status}`);
|
|
180
|
-
let
|
|
202
|
+
let response = decodeResponse(await toBuffer(landing));
|
|
203
|
+
let { strings } = response;
|
|
181
204
|
log("landing actions", extractActionIds(strings));
|
|
182
205
|
|
|
183
206
|
const post = async (action, fields) => {
|
|
184
|
-
const
|
|
185
|
-
const castle = await getCastleToken(action);
|
|
207
|
+
const castle = await mint(action);
|
|
186
208
|
const form = new URLSearchParams({ ...fields, $castle_token: castle }).toString();
|
|
187
|
-
const headers = jfHeaders(guestToken,
|
|
209
|
+
const headers = jfHeaders(guestToken, theme);
|
|
188
210
|
headers["content-type"] = "application/x-www-form-urlencoded";
|
|
189
|
-
if (session.cookies.ct0) headers["x-csrf-token"] = session.cookies.ct0;
|
|
190
211
|
const res = await session.request(`${JFAPI_BASE}/${SURFACE}/actions/${action}`, "post", {
|
|
191
212
|
headers,
|
|
192
213
|
body: form,
|
|
193
214
|
responseType: "arraybuffer",
|
|
194
215
|
});
|
|
195
216
|
const buffer = await toBuffer(res);
|
|
196
|
-
const responseStrings = decodeStrings(buffer);
|
|
197
|
-
log(`POST ${action} -> ${res.status}`, extractActionIds(responseStrings));
|
|
198
217
|
if (res.status !== 200) {
|
|
199
|
-
throw new Error(`action ${action} failed: ${res.status}
|
|
218
|
+
throw new Error(`action ${action} failed: ${res.status}`);
|
|
200
219
|
}
|
|
220
|
+
const result = decodeResponse(buffer);
|
|
221
|
+
const responseStrings = result.strings;
|
|
222
|
+
log(`POST ${action} -> ${res.status}`, extractActionIds(responseStrings));
|
|
201
223
|
const genericError = responseStrings.find((s) => /something went wrong/i.test(s));
|
|
202
224
|
if (genericError && responseStrings.length < 6) {
|
|
203
225
|
throw new Error(
|
|
204
|
-
`action ${action} rejected ("${genericError}")
|
|
226
|
+
`action ${action} rejected ("${genericError}")`,
|
|
205
227
|
);
|
|
206
228
|
}
|
|
207
|
-
const serverError =
|
|
229
|
+
const serverError = result.forms.flatMap((form) => Object.values(form.errors)).find(Boolean)
|
|
230
|
+
?? extractServerError(responseStrings);
|
|
208
231
|
if (serverError) {
|
|
209
232
|
if (/temporarily limited|rate.?limit|try again later/i.test(serverError)) {
|
|
210
|
-
throw new Error(`login rate-limited by X: "${serverError.trim()}"
|
|
233
|
+
throw new Error(`login rate-limited by X: "${serverError.trim()}"`);
|
|
211
234
|
}
|
|
212
235
|
throw new Error(`action ${action} error: "${serverError.trim()}"`);
|
|
213
236
|
}
|
|
214
|
-
return
|
|
237
|
+
return result;
|
|
215
238
|
};
|
|
216
239
|
|
|
217
240
|
const sessionTokenFrom = (arr) => {
|
|
@@ -223,7 +246,8 @@ export default async function flowLoginJetfuel(opts) {
|
|
|
223
246
|
let sessionToken = null;
|
|
224
247
|
const withSession = (fields) => (sessionToken ? { session_token: sessionToken, ...fields } : fields);
|
|
225
248
|
|
|
226
|
-
|
|
249
|
+
response = await post("begin_login", { username_or_email: identifier });
|
|
250
|
+
strings = response.strings;
|
|
227
251
|
sessionToken = sessionTokenFrom(strings) || sessionToken;
|
|
228
252
|
|
|
229
253
|
let step = 0;
|
|
@@ -239,6 +263,52 @@ export default async function flowLoginJetfuel(opts) {
|
|
|
239
263
|
throw new Error("login denied: account locked or suspended");
|
|
240
264
|
}
|
|
241
265
|
|
|
266
|
+
const passwordSwitch = response.forms.find((form) => form.fields.force_password);
|
|
267
|
+
if (passwordSwitch) {
|
|
268
|
+
const knowledgeCheck = response.forms.find((form) => form.url === "/onboarding/web/actions/finish_knowledge_check");
|
|
269
|
+
if (knowledgeCheck && onRequest) {
|
|
270
|
+
const kc = knowledgeCheck.fields;
|
|
271
|
+
const alternate = await onRequest("alternate_identifier", { challengeType: kc.challenge_type ?? null, accountFound: kc.account_found ?? null, accountFoundType: kc.account_found_type ?? null });
|
|
272
|
+
if (alternate != null && alternate !== "") {
|
|
273
|
+
response = await post("finish_knowledge_check", { ...knowledgeCheck.fields, challenge_response: alternate });
|
|
274
|
+
strings = response.strings;
|
|
275
|
+
sessionToken = sessionTokenFrom(strings) || sessionToken;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
response = await post("begin_login", passwordSwitch.fields);
|
|
280
|
+
strings = response.strings;
|
|
281
|
+
sessionToken = sessionTokenFrom(strings) || sessionToken;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const form = response.forms.find((entry) => /\/onboarding\/web\/actions\/login_enter_password$/.test(entry.url))
|
|
286
|
+
?? response.forms.find((entry) => /\/onboarding\/web\/actions\/(?:finish_login|finish_knowledge_check|login_)/.test(entry.url));
|
|
287
|
+
if (form) {
|
|
288
|
+
const action = form.url.match(/^\/onboarding\/web\/actions\/([a-z0-9_]+)$/)?.[1];
|
|
289
|
+
if (!action) throw new Error("invalid login action URL");
|
|
290
|
+
const fields = { ...form.fields };
|
|
291
|
+
if (Object.hasOwn(fields, "password")) fields.password = password;
|
|
292
|
+
else {
|
|
293
|
+
const field = ["challenge_response", "code", "text"].find((key) => Object.hasOwn(fields, key));
|
|
294
|
+
if (!field) throw new Error(`unsupported login form: ${action}`);
|
|
295
|
+
const type = action === "finish_knowledge_check" || /username|phone/i.test(fields.challenge_type ?? "") ? "alternate_identifier"
|
|
296
|
+
: has("two_factor") || has("authenticator") || has("authentication code") ? "two_factor_code" : "email_code";
|
|
297
|
+
let alternate = null;
|
|
298
|
+
if (type === "alternate_identifier") {
|
|
299
|
+
const challenge = fields.challenge_type?.toLowerCase();
|
|
300
|
+
if (challenge === "username" && !identifier.includes("@") && !/^\+?[\d\s-]+$/.test(identifier)) alternate = identifier;
|
|
301
|
+
if (challenge === "email") alternate = email || (identifier.includes("@") ? identifier : null);
|
|
302
|
+
if (challenge === "phone") alternate = phone;
|
|
303
|
+
}
|
|
304
|
+
fields[field] = await resolve(alternate, onRequest, type, { challengeType: fields.challenge_type ?? null, accountFound: fields.account_found ?? null, accountFoundType: fields.account_found_type ?? null });
|
|
305
|
+
}
|
|
306
|
+
response = await post(action, fields);
|
|
307
|
+
strings = response.strings;
|
|
308
|
+
sessionToken = sessionTokenFrom(strings) || sessionToken;
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
|
|
242
312
|
const wantsPasswordEntry = has("enter your password") || has("login_enter_password");
|
|
243
313
|
const wantsTwoFactor = has("two_factor") || has("2fa") || has("authentication code");
|
|
244
314
|
const wantsEmailCode = has("acid") || has("verifycode") || has("verification code") ||
|
|
@@ -247,31 +317,35 @@ export default async function flowLoginJetfuel(opts) {
|
|
|
247
317
|
|
|
248
318
|
if (wantsTwoFactor) {
|
|
249
319
|
const code = await resolve(null, onRequest, "two_factor_code");
|
|
250
|
-
|
|
320
|
+
response = await post("login_enter_two_factor", withSession({ text: code }));
|
|
321
|
+
strings = response.strings;
|
|
251
322
|
sessionToken = sessionTokenFrom(strings) || sessionToken;
|
|
252
323
|
continue;
|
|
253
324
|
}
|
|
254
325
|
if (wantsEmailCode) {
|
|
255
326
|
const code = await resolve(null, onRequest, "email_code");
|
|
256
|
-
|
|
327
|
+
response = await post("login_acid", withSession({ text: code }));
|
|
328
|
+
strings = response.strings;
|
|
257
329
|
sessionToken = sessionTokenFrom(strings) || sessionToken;
|
|
258
330
|
continue;
|
|
259
331
|
}
|
|
260
332
|
if (wantsAlternate) {
|
|
261
333
|
const value = await resolve(email || phone, onRequest, "alternate_identifier");
|
|
262
|
-
|
|
334
|
+
response = await post("login_enter_alternate_identifier", withSession({ text: value }));
|
|
335
|
+
strings = response.strings;
|
|
263
336
|
sessionToken = sessionTokenFrom(strings) || sessionToken;
|
|
264
337
|
continue;
|
|
265
338
|
}
|
|
266
339
|
if (wantsPasswordEntry || has("password")) {
|
|
267
|
-
|
|
340
|
+
response = await post("login_enter_password", withSession({ password }));
|
|
341
|
+
strings = response.strings;
|
|
268
342
|
sessionToken = sessionTokenFrom(strings) || sessionToken;
|
|
269
343
|
continue;
|
|
270
344
|
}
|
|
271
345
|
|
|
272
346
|
throw new Error(
|
|
273
347
|
`unhandled jetfuel login step. decoded actions: [${extractActionIds(strings).join(", ")}]. ` +
|
|
274
|
-
`
|
|
348
|
+
`no supported password or verification form was returned`,
|
|
275
349
|
);
|
|
276
350
|
}
|
|
277
351
|
|
|
@@ -284,5 +358,6 @@ export default async function flowLoginJetfuel(opts) {
|
|
|
284
358
|
csrfToken: session.cookies.ct0 || null,
|
|
285
359
|
userId: extractUserId(session.cookies),
|
|
286
360
|
cookies: { ...session.cookies },
|
|
361
|
+
clientProfile: profile ? { userAgent: profile.userAgent, clientHints: profile.clientHints } : null,
|
|
287
362
|
};
|
|
288
363
|
}
|
package/src/helpers/account.js
CHANGED
|
@@ -52,7 +52,8 @@ export async function viewer() {
|
|
|
52
52
|
fieldToggles: { withAuxiliaryUserLabels: false },
|
|
53
53
|
});
|
|
54
54
|
const user = res?.data?.viewer?.user_results?.result;
|
|
55
|
-
|
|
55
|
+
if (!user) throw new Error("couldn't get viewer, response: " + JSON.stringify(res));
|
|
56
|
+
return parseUser(user);
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
export async function sessions() {
|
package/src/helpers/tweets.js
CHANGED
|
@@ -241,7 +241,8 @@ export async function get(tweetId) {
|
|
|
241
241
|
},
|
|
242
242
|
});
|
|
243
243
|
const tweet = res?.data?.tweetResult?.result;
|
|
244
|
-
|
|
244
|
+
if (!tweet) throw new Error("couldn't get tweet, response: " + JSON.stringify(res));
|
|
245
|
+
return parseTweet(tweet);
|
|
245
246
|
}
|
|
246
247
|
|
|
247
248
|
export async function getMany(tweetIds) {
|
package/src/helpers/users.js
CHANGED
|
@@ -7,7 +7,8 @@ export async function get(userId) {
|
|
|
7
7
|
fieldToggles: { withAuxiliaryUserLabels: false },
|
|
8
8
|
});
|
|
9
9
|
const user = res?.data?.user?.result;
|
|
10
|
-
|
|
10
|
+
if (!user) throw new Error("couldn't get user, response: " + JSON.stringify(res));
|
|
11
|
+
return parseUser(user);
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
export async function getByUsername(username) {
|
|
@@ -16,7 +17,8 @@ export async function getByUsername(username) {
|
|
|
16
17
|
fieldToggles: { withAuxiliaryUserLabels: false },
|
|
17
18
|
});
|
|
18
19
|
const user = res?.data?.user?.result;
|
|
19
|
-
|
|
20
|
+
if (!user) throw new Error("couldn't get user, response: " + JSON.stringify(res));
|
|
21
|
+
return parseUser(user);
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
export async function getMany(userIds) {
|