emusks 2.3.6 → 2.3.8
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 +122 -48
- package/src/helpers/media.js +2 -2
- package/src/index.js +41 -17
- package/src/parsers/jetfuel-messages.js +121 -0
- 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.8",
|
|
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) {
|
|
@@ -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,51 @@ 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 alternate = await onRequest("alternate_identifier");
|
|
271
|
+
if (alternate != null && alternate !== "") {
|
|
272
|
+
response = await post("finish_knowledge_check", { ...knowledgeCheck.fields, challenge_response: alternate });
|
|
273
|
+
strings = response.strings;
|
|
274
|
+
sessionToken = sessionTokenFrom(strings) || sessionToken;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
response = await post("begin_login", passwordSwitch.fields);
|
|
279
|
+
strings = response.strings;
|
|
280
|
+
sessionToken = sessionTokenFrom(strings) || sessionToken;
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const form = response.forms.find((entry) => /\/onboarding\/web\/actions\/login_enter_password$/.test(entry.url))
|
|
285
|
+
?? response.forms.find((entry) => /\/onboarding\/web\/actions\/(?:finish_login|finish_knowledge_check|login_)/.test(entry.url));
|
|
286
|
+
if (form) {
|
|
287
|
+
const action = form.url.match(/^\/onboarding\/web\/actions\/([a-z0-9_]+)$/)?.[1];
|
|
288
|
+
if (!action) throw new Error("invalid login action URL");
|
|
289
|
+
const fields = { ...form.fields };
|
|
290
|
+
if (Object.hasOwn(fields, "password")) fields.password = password;
|
|
291
|
+
else {
|
|
292
|
+
const field = ["challenge_response", "code", "text"].find((key) => Object.hasOwn(fields, key));
|
|
293
|
+
if (!field) throw new Error(`unsupported login form: ${action}`);
|
|
294
|
+
const type = action === "finish_knowledge_check" || /username|phone/i.test(fields.challenge_type ?? "") ? "alternate_identifier"
|
|
295
|
+
: has("two_factor") || has("authenticator") || has("authentication code") ? "two_factor_code" : "email_code";
|
|
296
|
+
let alternate = null;
|
|
297
|
+
if (type === "alternate_identifier") {
|
|
298
|
+
const challenge = fields.challenge_type?.toLowerCase();
|
|
299
|
+
if (challenge === "username" && !identifier.includes("@") && !/^\+?[\d\s-]+$/.test(identifier)) alternate = identifier;
|
|
300
|
+
if (challenge === "email") alternate = email || (identifier.includes("@") ? identifier : null);
|
|
301
|
+
if (challenge === "phone") alternate = phone;
|
|
302
|
+
}
|
|
303
|
+
fields[field] = await resolve(alternate, onRequest, type);
|
|
304
|
+
}
|
|
305
|
+
response = await post(action, fields);
|
|
306
|
+
strings = response.strings;
|
|
307
|
+
sessionToken = sessionTokenFrom(strings) || sessionToken;
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
|
|
242
311
|
const wantsPasswordEntry = has("enter your password") || has("login_enter_password");
|
|
243
312
|
const wantsTwoFactor = has("two_factor") || has("2fa") || has("authentication code");
|
|
244
313
|
const wantsEmailCode = has("acid") || has("verifycode") || has("verification code") ||
|
|
@@ -247,31 +316,35 @@ export default async function flowLoginJetfuel(opts) {
|
|
|
247
316
|
|
|
248
317
|
if (wantsTwoFactor) {
|
|
249
318
|
const code = await resolve(null, onRequest, "two_factor_code");
|
|
250
|
-
|
|
319
|
+
response = await post("login_enter_two_factor", withSession({ text: code }));
|
|
320
|
+
strings = response.strings;
|
|
251
321
|
sessionToken = sessionTokenFrom(strings) || sessionToken;
|
|
252
322
|
continue;
|
|
253
323
|
}
|
|
254
324
|
if (wantsEmailCode) {
|
|
255
325
|
const code = await resolve(null, onRequest, "email_code");
|
|
256
|
-
|
|
326
|
+
response = await post("login_acid", withSession({ text: code }));
|
|
327
|
+
strings = response.strings;
|
|
257
328
|
sessionToken = sessionTokenFrom(strings) || sessionToken;
|
|
258
329
|
continue;
|
|
259
330
|
}
|
|
260
331
|
if (wantsAlternate) {
|
|
261
332
|
const value = await resolve(email || phone, onRequest, "alternate_identifier");
|
|
262
|
-
|
|
333
|
+
response = await post("login_enter_alternate_identifier", withSession({ text: value }));
|
|
334
|
+
strings = response.strings;
|
|
263
335
|
sessionToken = sessionTokenFrom(strings) || sessionToken;
|
|
264
336
|
continue;
|
|
265
337
|
}
|
|
266
338
|
if (wantsPasswordEntry || has("password")) {
|
|
267
|
-
|
|
339
|
+
response = await post("login_enter_password", withSession({ password }));
|
|
340
|
+
strings = response.strings;
|
|
268
341
|
sessionToken = sessionTokenFrom(strings) || sessionToken;
|
|
269
342
|
continue;
|
|
270
343
|
}
|
|
271
344
|
|
|
272
345
|
throw new Error(
|
|
273
346
|
`unhandled jetfuel login step. decoded actions: [${extractActionIds(strings).join(", ")}]. ` +
|
|
274
|
-
`
|
|
347
|
+
`no supported password or verification form was returned`,
|
|
275
348
|
);
|
|
276
349
|
}
|
|
277
350
|
|
|
@@ -284,5 +357,6 @@ export default async function flowLoginJetfuel(opts) {
|
|
|
284
357
|
csrfToken: session.cookies.ct0 || null,
|
|
285
358
|
userId: extractUserId(session.cookies),
|
|
286
359
|
cookies: { ...session.cookies },
|
|
360
|
+
clientProfile: profile ? { userAgent: profile.userAgent, clientHints: profile.clientHints } : null,
|
|
287
361
|
};
|
|
288
362
|
}
|
package/src/helpers/media.js
CHANGED
|
@@ -219,7 +219,7 @@ export async function create(source, opts = {}) {
|
|
|
219
219
|
});
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
-
return { media_id: mediaId
|
|
222
|
+
return { ...finalizeData, media_id: mediaId };
|
|
223
223
|
}
|
|
224
224
|
|
|
225
225
|
export async function createFromUrl(url, opts = {}) {
|
|
@@ -274,7 +274,7 @@ export async function createFromUrl(url, opts = {}) {
|
|
|
274
274
|
});
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
-
return { media_id: mediaId
|
|
277
|
+
return { ...initData, media_id: mediaId };
|
|
278
278
|
}
|
|
279
279
|
|
|
280
280
|
export async function createMetadata(mediaId, altText, opts = {}) {
|