emusks 2.3.4 → 2.3.6
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/package.json +1 -1
- package/src/castle.js +261 -0
- package/src/flow-jetfuel.js +288 -0
- package/src/flow.js +2 -2
- package/src/helpers/index.js +2 -0
- package/src/helpers/report.js +100 -0
- package/src/helpers/tweets.js +4 -0
- package/src/helpers/users.js +4 -0
- package/src/index.js +7 -1
- package/src/scripts/castle-mint-test.js +8 -0
- package/src/scripts/login-test.js +28 -0
- package/src/scripts/verify-session.js +11 -0
- package/src/static/graphql.js +1 -1
- package/src/static/v1.1.js +1 -1
- package/src/vendor/castle-sdk.min.js +3 -0
package/package.json
CHANGED
package/src/castle.js
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import vm from "node:vm";
|
|
5
|
+
import { parseHTML } from "linkedom";
|
|
6
|
+
|
|
7
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_CASTLE_PK = "pk_AvRa79bHyJSYSQHnRpcVtzyxetSvFerx";
|
|
10
|
+
export const CASTLE_MODULE_ID = "84197";
|
|
11
|
+
|
|
12
|
+
const DEFAULT_UA =
|
|
13
|
+
"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";
|
|
14
|
+
|
|
15
|
+
function pluginArray() {
|
|
16
|
+
const names = [
|
|
17
|
+
"PDF Viewer",
|
|
18
|
+
"Chrome PDF Viewer",
|
|
19
|
+
"Chromium PDF Viewer",
|
|
20
|
+
"Microsoft Edge PDF Viewer",
|
|
21
|
+
"WebKit built-in PDF",
|
|
22
|
+
];
|
|
23
|
+
const arr = names.map((name) => ({
|
|
24
|
+
name,
|
|
25
|
+
description: "Portable Document Format",
|
|
26
|
+
filename: "internal-pdf-viewer",
|
|
27
|
+
length: 2,
|
|
28
|
+
}));
|
|
29
|
+
arr.item = (i) => arr[i] || null;
|
|
30
|
+
arr.namedItem = (n) => arr.find((p) => p.name === n) || null;
|
|
31
|
+
arr.refresh = () => {};
|
|
32
|
+
return arr;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function mimeTypeArray() {
|
|
36
|
+
const arr = [
|
|
37
|
+
{ type: "application/pdf", suffixes: "pdf", description: "Portable Document Format" },
|
|
38
|
+
{ type: "text/pdf", suffixes: "pdf", description: "Portable Document Format" },
|
|
39
|
+
];
|
|
40
|
+
arr.item = (i) => arr[i] || null;
|
|
41
|
+
arr.namedItem = (n) => arr.find((m) => m.type === n) || null;
|
|
42
|
+
return arr;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function buildSandbox(userAgent) {
|
|
46
|
+
const { document } = parseHTML(
|
|
47
|
+
"<!DOCTYPE html><html><head><title>X</title></head><body></body></html>",
|
|
48
|
+
);
|
|
49
|
+
const define = (prop, value) => {
|
|
50
|
+
try {
|
|
51
|
+
Object.defineProperty(document, prop, { get: () => value, configurable: true });
|
|
52
|
+
} catch {}
|
|
53
|
+
};
|
|
54
|
+
define("cookie", "");
|
|
55
|
+
define("readyState", "complete");
|
|
56
|
+
define("referrer", "https://x.com/");
|
|
57
|
+
define("visibilityState", "visible");
|
|
58
|
+
define("hidden", false);
|
|
59
|
+
document.hasFocus = () => true;
|
|
60
|
+
|
|
61
|
+
const s = {};
|
|
62
|
+
s.globalThis = s;
|
|
63
|
+
s.self = s;
|
|
64
|
+
s.window = s;
|
|
65
|
+
s.global = s;
|
|
66
|
+
s.top = s;
|
|
67
|
+
s.parent = s;
|
|
68
|
+
s.frames = s;
|
|
69
|
+
s.name = "";
|
|
70
|
+
s.closed = false;
|
|
71
|
+
s.origin = "https://x.com";
|
|
72
|
+
s.isSecureContext = true;
|
|
73
|
+
|
|
74
|
+
s.document = document;
|
|
75
|
+
s.navigator = {
|
|
76
|
+
userAgent,
|
|
77
|
+
appVersion: userAgent.replace("Mozilla/", ""),
|
|
78
|
+
appName: "Netscape",
|
|
79
|
+
appCodeName: "Mozilla",
|
|
80
|
+
platform: "MacIntel",
|
|
81
|
+
vendor: "Google Inc.",
|
|
82
|
+
vendorSub: "",
|
|
83
|
+
product: "Gecko",
|
|
84
|
+
productSub: "20030107",
|
|
85
|
+
language: "en-US",
|
|
86
|
+
languages: ["en-US", "en"],
|
|
87
|
+
hardwareConcurrency: 10,
|
|
88
|
+
deviceMemory: 8,
|
|
89
|
+
maxTouchPoints: 0,
|
|
90
|
+
cookieEnabled: true,
|
|
91
|
+
onLine: true,
|
|
92
|
+
doNotTrack: null,
|
|
93
|
+
webdriver: false,
|
|
94
|
+
pdfViewerEnabled: true,
|
|
95
|
+
plugins: pluginArray(),
|
|
96
|
+
mimeTypes: mimeTypeArray(),
|
|
97
|
+
userAgentData: {
|
|
98
|
+
brands: [
|
|
99
|
+
{ brand: "Not(A:Brand", version: "8" },
|
|
100
|
+
{ brand: "Chromium", version: "144" },
|
|
101
|
+
{ brand: "Google Chrome", version: "144" },
|
|
102
|
+
],
|
|
103
|
+
mobile: false,
|
|
104
|
+
platform: "macOS",
|
|
105
|
+
getHighEntropyValues: async () => ({
|
|
106
|
+
platform: "macOS",
|
|
107
|
+
platformVersion: "15.0.0",
|
|
108
|
+
architecture: "arm",
|
|
109
|
+
model: "",
|
|
110
|
+
uaFullVersion: "144.0.0.0",
|
|
111
|
+
bitness: "64",
|
|
112
|
+
}),
|
|
113
|
+
},
|
|
114
|
+
permissions: { query: async () => ({ state: "prompt" }) },
|
|
115
|
+
mediaDevices: { enumerateDevices: async () => [] },
|
|
116
|
+
connection: { effectiveType: "4g", rtt: 50, downlink: 10, saveData: false },
|
|
117
|
+
sendBeacon: () => true,
|
|
118
|
+
javaEnabled: () => false,
|
|
119
|
+
};
|
|
120
|
+
s.screen = {
|
|
121
|
+
width: 1512,
|
|
122
|
+
height: 982,
|
|
123
|
+
availWidth: 1512,
|
|
124
|
+
availHeight: 944,
|
|
125
|
+
colorDepth: 30,
|
|
126
|
+
pixelDepth: 30,
|
|
127
|
+
availLeft: 0,
|
|
128
|
+
availTop: 0,
|
|
129
|
+
orientation: { type: "landscape-primary", angle: 0 },
|
|
130
|
+
};
|
|
131
|
+
s.location = {
|
|
132
|
+
href: "https://x.com/i/flow/login",
|
|
133
|
+
protocol: "https:",
|
|
134
|
+
host: "x.com",
|
|
135
|
+
hostname: "x.com",
|
|
136
|
+
origin: "https://x.com",
|
|
137
|
+
pathname: "/i/flow/login",
|
|
138
|
+
search: "",
|
|
139
|
+
hash: "",
|
|
140
|
+
};
|
|
141
|
+
s.history = { length: 2, state: null, pushState: () => {}, replaceState: () => {} };
|
|
142
|
+
|
|
143
|
+
const store = {};
|
|
144
|
+
s.localStorage = {
|
|
145
|
+
getItem: (k) => (k in store ? store[k] : null),
|
|
146
|
+
setItem: (k, v) => {
|
|
147
|
+
store[k] = String(v);
|
|
148
|
+
},
|
|
149
|
+
removeItem: (k) => {
|
|
150
|
+
delete store[k];
|
|
151
|
+
},
|
|
152
|
+
clear: () => {
|
|
153
|
+
for (const k in store) delete store[k];
|
|
154
|
+
},
|
|
155
|
+
key: (i) => Object.keys(store)[i] ?? null,
|
|
156
|
+
get length() {
|
|
157
|
+
return Object.keys(store).length;
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
s.sessionStorage = { getItem: () => null, setItem: () => {}, removeItem: () => {}, clear: () => {} };
|
|
161
|
+
|
|
162
|
+
s.innerWidth = 1512;
|
|
163
|
+
s.innerHeight = 850;
|
|
164
|
+
s.outerWidth = 1512;
|
|
165
|
+
s.outerHeight = 944;
|
|
166
|
+
s.screenX = 0;
|
|
167
|
+
s.screenY = 0;
|
|
168
|
+
s.pageXOffset = 0;
|
|
169
|
+
s.pageYOffset = 0;
|
|
170
|
+
s.devicePixelRatio = 2;
|
|
171
|
+
s.chrome = { app: { isInstalled: false }, runtime: {}, csi: () => ({}), loadTimes: () => ({}) };
|
|
172
|
+
s.Intl = Intl;
|
|
173
|
+
s.matchMedia = (q) => ({
|
|
174
|
+
matches: false,
|
|
175
|
+
media: q,
|
|
176
|
+
addEventListener: () => {},
|
|
177
|
+
removeEventListener: () => {},
|
|
178
|
+
addListener: () => {},
|
|
179
|
+
removeListener: () => {},
|
|
180
|
+
});
|
|
181
|
+
s.addEventListener = () => {};
|
|
182
|
+
s.removeEventListener = () => {};
|
|
183
|
+
s.dispatchEvent = () => true;
|
|
184
|
+
s.requestAnimationFrame = (cb) => setTimeout(() => cb(performance.now()), 0);
|
|
185
|
+
s.cancelAnimationFrame = (id) => clearTimeout(id);
|
|
186
|
+
s.requestIdleCallback = (cb) => setTimeout(() => cb({ timeRemaining: () => 50, didTimeout: false }), 0);
|
|
187
|
+
s.cancelIdleCallback = (id) => clearTimeout(id);
|
|
188
|
+
s.setTimeout = setTimeout;
|
|
189
|
+
s.clearTimeout = clearTimeout;
|
|
190
|
+
s.setInterval = setInterval;
|
|
191
|
+
s.clearInterval = clearInterval;
|
|
192
|
+
s.setImmediate = setImmediate;
|
|
193
|
+
s.queueMicrotask = queueMicrotask;
|
|
194
|
+
s.performance = performance;
|
|
195
|
+
s.crypto = globalThis.crypto;
|
|
196
|
+
s.btoa = (str) => Buffer.from(str, "binary").toString("base64");
|
|
197
|
+
s.atob = (str) => Buffer.from(str, "base64").toString("binary");
|
|
198
|
+
s.TextEncoder = TextEncoder;
|
|
199
|
+
s.TextDecoder = TextDecoder;
|
|
200
|
+
s.console = { log: () => {}, warn: () => {}, error: () => {}, info: () => {}, debug: () => {} };
|
|
201
|
+
s.Image = class {
|
|
202
|
+
set src(_v) {}
|
|
203
|
+
};
|
|
204
|
+
s.XMLHttpRequest = class {
|
|
205
|
+
open() {}
|
|
206
|
+
setRequestHeader() {}
|
|
207
|
+
send() {}
|
|
208
|
+
addEventListener() {}
|
|
209
|
+
};
|
|
210
|
+
s.getComputedStyle = () => ({ getPropertyValue: () => "" });
|
|
211
|
+
return s;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function createCastleMinter(opts = {}) {
|
|
215
|
+
const {
|
|
216
|
+
pk = DEFAULT_CASTLE_PK,
|
|
217
|
+
userAgent = DEFAULT_UA,
|
|
218
|
+
sdkPath = join(HERE, "vendor", "castle-sdk.min.js"),
|
|
219
|
+
sdkSource,
|
|
220
|
+
} = opts;
|
|
221
|
+
|
|
222
|
+
const src = sdkSource ?? readFileSync(sdkPath, "utf8");
|
|
223
|
+
const sandbox = buildSandbox(userAgent);
|
|
224
|
+
|
|
225
|
+
let factory = null;
|
|
226
|
+
sandbox.webpackChunk_twitter_responsive_web = {
|
|
227
|
+
push: (entry) => {
|
|
228
|
+
const modules = entry?.[1];
|
|
229
|
+
if (modules && modules[CASTLE_MODULE_ID]) factory = modules[CASTLE_MODULE_ID];
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const ctx = vm.createContext(sandbox);
|
|
234
|
+
vm.runInContext(src, ctx, { filename: "castle-sdk.min.js" });
|
|
235
|
+
if (!factory) throw new Error(`castle sdk module ${CASTLE_MODULE_ID} not found (X may have rotated the chunk)`);
|
|
236
|
+
|
|
237
|
+
const moduleObj = { exports: {} };
|
|
238
|
+
factory(moduleObj, moduleObj.exports);
|
|
239
|
+
const api = moduleObj.exports;
|
|
240
|
+
if (typeof api.configure !== "function" || typeof api.createRequestToken !== "function") {
|
|
241
|
+
throw new Error("castle sdk did not expose configure/createRequestToken (chunk changed)");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const configured = api.configure({ pk });
|
|
245
|
+
|
|
246
|
+
return async function mint() {
|
|
247
|
+
const instance = await Promise.resolve(configured);
|
|
248
|
+
const token = instance?.createRequestToken
|
|
249
|
+
? await instance.createRequestToken()
|
|
250
|
+
: await api.createRequestToken();
|
|
251
|
+
if (!token) throw new Error("castle createRequestToken returned empty");
|
|
252
|
+
return token;
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
let sharedMinter = null;
|
|
257
|
+
|
|
258
|
+
export async function mintCastleToken(opts = {}) {
|
|
259
|
+
if (!sharedMinter || opts.fresh) sharedMinter = createCastleMinter(opts);
|
|
260
|
+
return sharedMinter();
|
|
261
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { ClientTransaction, handleXMigration } from "x-client-transaction-id";
|
|
2
|
+
import { mintCastleToken } from "./castle.js";
|
|
3
|
+
import clients from "./clients.js";
|
|
4
|
+
import getCycleTLS from "./cycletls.js";
|
|
5
|
+
import { decodeJetfuel } from "./parsers/jetfuel.js";
|
|
6
|
+
|
|
7
|
+
const GUEST_ACTIVATE_URL = "https://api.x.com/1.1/guest/activate.json";
|
|
8
|
+
const JFAPI_BASE = "https://x.com/i/jfapi";
|
|
9
|
+
const SURFACE = "onboarding/web";
|
|
10
|
+
const UA =
|
|
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";
|
|
12
|
+
|
|
13
|
+
const MAX_STEPS = 15;
|
|
14
|
+
|
|
15
|
+
class CookieSession {
|
|
16
|
+
constructor(cycleTLS, proxy, timeout) {
|
|
17
|
+
this.cycleTLS = cycleTLS;
|
|
18
|
+
this.cookies = {};
|
|
19
|
+
this.proxy = proxy;
|
|
20
|
+
this.timeout = timeout;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
cookieString() {
|
|
24
|
+
return Object.entries(this.cookies)
|
|
25
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
26
|
+
.join("; ");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
eat(res) {
|
|
30
|
+
const setCookie = res.headers?.["set-cookie"] || res.headers?.["Set-Cookie"];
|
|
31
|
+
if (!setCookie) return;
|
|
32
|
+
for (const cookie of Array.isArray(setCookie) ? setCookie : [setCookie]) {
|
|
33
|
+
const [key, ...rest] = cookie.split(";")[0].split("=");
|
|
34
|
+
if (rest.length) this.cookies[key.trim()] = rest.join("=").trim();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async request(url, method, { headers = {}, body, responseType } = {}) {
|
|
39
|
+
const h = { ...headers };
|
|
40
|
+
const cookieStr = this.cookieString();
|
|
41
|
+
if (cookieStr) h.Cookie = cookieStr;
|
|
42
|
+
|
|
43
|
+
const res = await this.cycleTLS(
|
|
44
|
+
url,
|
|
45
|
+
{
|
|
46
|
+
body,
|
|
47
|
+
ja3: clients.web.fingerprints.ja3,
|
|
48
|
+
ja4r: clients.web.fingerprints.ja4r,
|
|
49
|
+
userAgent: UA,
|
|
50
|
+
headers: h,
|
|
51
|
+
proxy: this.proxy || undefined,
|
|
52
|
+
responseType,
|
|
53
|
+
timeout: this.timeout,
|
|
54
|
+
},
|
|
55
|
+
method,
|
|
56
|
+
);
|
|
57
|
+
this.eat(res);
|
|
58
|
+
return res;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function jfHeaders(guestToken, tid, theme) {
|
|
63
|
+
const headers = {
|
|
64
|
+
authorization: `Bearer ${clients.web.bearer}`,
|
|
65
|
+
"x-guest-token": guestToken,
|
|
66
|
+
"x-jf-v": "JP-5",
|
|
67
|
+
"x-jf-client-theme": theme,
|
|
68
|
+
"x-twitter-active-user": "yes",
|
|
69
|
+
"x-twitter-client-language": "en",
|
|
70
|
+
"accept-language": "en",
|
|
71
|
+
accept: "*/*",
|
|
72
|
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
|
73
|
+
Origin: "https://x.com",
|
|
74
|
+
Referer: "https://x.com/i/flow/login",
|
|
75
|
+
};
|
|
76
|
+
if (tid) headers["x-client-transaction-id"] = tid;
|
|
77
|
+
return headers;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function getGuestToken(session) {
|
|
81
|
+
const res = await session.request(GUEST_ACTIVATE_URL, "post", {
|
|
82
|
+
headers: { Authorization: `Bearer ${clients.web.bearer}` },
|
|
83
|
+
});
|
|
84
|
+
let body = res.body ?? res.data;
|
|
85
|
+
if (typeof body === "string") body = JSON.parse(body);
|
|
86
|
+
if (!body?.guest_token) throw new Error("failed to obtain guest token");
|
|
87
|
+
return body.guest_token;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function toBuffer(res) {
|
|
91
|
+
if (typeof res.arrayBuffer === "function") return Buffer.from(await res.arrayBuffer());
|
|
92
|
+
const body = res.body ?? res.data;
|
|
93
|
+
if (Buffer.isBuffer(body)) return body;
|
|
94
|
+
if (body && typeof body === "object" && body.data) return Buffer.from(body.data);
|
|
95
|
+
return Buffer.from(String(body ?? ""), "utf8");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function decodeStrings(buffer) {
|
|
99
|
+
try {
|
|
100
|
+
return decodeJetfuel(buffer).strings || [];
|
|
101
|
+
} catch {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function extractActionIds(strings) {
|
|
107
|
+
const ids = new Set();
|
|
108
|
+
for (const s of strings) {
|
|
109
|
+
const m = String(s).match(/onboarding\/web\/actions\/([a-z0-9_]+)/i);
|
|
110
|
+
if (m) ids.add(m[1]);
|
|
111
|
+
}
|
|
112
|
+
return [...ids];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function extractServerError(strings) {
|
|
116
|
+
const idx = strings.findIndex((s) => /^errors?$/i.test(String(s).trim()));
|
|
117
|
+
if (idx === -1) return null;
|
|
118
|
+
for (let i = idx + 1; i < strings.length; i++) {
|
|
119
|
+
const s = String(strings[i]).trim();
|
|
120
|
+
if (s.length > 8 && /[a-z]\s[a-z]/i.test(s) && !/^error$/i.test(s)) return s;
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function extractUserId(cookies) {
|
|
126
|
+
const twid = (cookies.twid || "").replace(/"/g, "");
|
|
127
|
+
for (const prefix of ["u=", "u%3D"]) {
|
|
128
|
+
if (twid.includes(prefix)) return twid.split(prefix)[1].split("&")[0].replace(/"/g, "");
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function resolve(value, onRequest, type) {
|
|
134
|
+
if (value != null && value !== "") return value;
|
|
135
|
+
if (onRequest) {
|
|
136
|
+
const v = await onRequest(type);
|
|
137
|
+
if (v != null && v !== "") return v;
|
|
138
|
+
}
|
|
139
|
+
throw new Error(
|
|
140
|
+
`the login flow needs "${type}" but none was provided. pass it directly or handle it in onRequest.`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export default async function flowLoginJetfuel(opts) {
|
|
145
|
+
const {
|
|
146
|
+
username,
|
|
147
|
+
password,
|
|
148
|
+
email,
|
|
149
|
+
phone,
|
|
150
|
+
onRequest,
|
|
151
|
+
getCastleToken = () => mintCastleToken(),
|
|
152
|
+
proxy,
|
|
153
|
+
timeout,
|
|
154
|
+
theme = "light",
|
|
155
|
+
debug,
|
|
156
|
+
} = opts;
|
|
157
|
+
const log = debug ? (...a) => console.error("[jf-flow]", ...a) : () => {};
|
|
158
|
+
|
|
159
|
+
const identifier = username || email;
|
|
160
|
+
if (!identifier) throw new Error("username or email is required for login");
|
|
161
|
+
if (!password) throw new Error("password is required for login");
|
|
162
|
+
|
|
163
|
+
const cycleTLS = await getCycleTLS();
|
|
164
|
+
const session = new CookieSession(cycleTLS, proxy, timeout);
|
|
165
|
+
|
|
166
|
+
const migrationDoc = await handleXMigration();
|
|
167
|
+
const transaction = new ClientTransaction(migrationDoc);
|
|
168
|
+
await transaction.initialize();
|
|
169
|
+
const tid = (method, path) => transaction.generateTransactionId(method, path);
|
|
170
|
+
|
|
171
|
+
const guestToken = await getGuestToken(session);
|
|
172
|
+
log("guest token", guestToken);
|
|
173
|
+
|
|
174
|
+
const landingPath = `/i/jfapi/${SURFACE}?mode=login`;
|
|
175
|
+
const landing = await session.request(`${JFAPI_BASE}/${SURFACE}?mode=login`, "get", {
|
|
176
|
+
headers: jfHeaders(guestToken, await tid("GET", landingPath), theme),
|
|
177
|
+
responseType: "arraybuffer",
|
|
178
|
+
});
|
|
179
|
+
if (landing.status !== 200) throw new Error(`landing failed: ${landing.status}`);
|
|
180
|
+
let strings = decodeStrings(await toBuffer(landing));
|
|
181
|
+
log("landing actions", extractActionIds(strings));
|
|
182
|
+
|
|
183
|
+
const post = async (action, fields) => {
|
|
184
|
+
const path = `/i/jfapi/${SURFACE}/actions/${action}`;
|
|
185
|
+
const castle = await getCastleToken(action);
|
|
186
|
+
const form = new URLSearchParams({ ...fields, $castle_token: castle }).toString();
|
|
187
|
+
const headers = jfHeaders(guestToken, await tid("POST", path), theme);
|
|
188
|
+
headers["content-type"] = "application/x-www-form-urlencoded";
|
|
189
|
+
if (session.cookies.ct0) headers["x-csrf-token"] = session.cookies.ct0;
|
|
190
|
+
const res = await session.request(`${JFAPI_BASE}/${SURFACE}/actions/${action}`, "post", {
|
|
191
|
+
headers,
|
|
192
|
+
body: form,
|
|
193
|
+
responseType: "arraybuffer",
|
|
194
|
+
});
|
|
195
|
+
const buffer = await toBuffer(res);
|
|
196
|
+
const responseStrings = decodeStrings(buffer);
|
|
197
|
+
log(`POST ${action} -> ${res.status}`, extractActionIds(responseStrings));
|
|
198
|
+
if (res.status !== 200) {
|
|
199
|
+
throw new Error(`action ${action} failed: ${res.status} - ${buffer.toString("utf8").slice(0, 200)}`);
|
|
200
|
+
}
|
|
201
|
+
const genericError = responseStrings.find((s) => /something went wrong/i.test(s));
|
|
202
|
+
if (genericError && responseStrings.length < 6) {
|
|
203
|
+
throw new Error(
|
|
204
|
+
`action ${action} rejected ("${genericError}") - usually an invalid or missing $castle_token.`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
const serverError = extractServerError(responseStrings);
|
|
208
|
+
if (serverError) {
|
|
209
|
+
if (/temporarily limited|rate.?limit|try again later/i.test(serverError)) {
|
|
210
|
+
throw new Error(`login rate-limited by X: "${serverError.trim()}" (back off this account/IP)`);
|
|
211
|
+
}
|
|
212
|
+
throw new Error(`action ${action} error: "${serverError.trim()}"`);
|
|
213
|
+
}
|
|
214
|
+
return responseStrings;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const sessionTokenFrom = (arr) => {
|
|
218
|
+
const i = arr.findIndex((s) => /^session_token$/i.test(String(s).trim()));
|
|
219
|
+
if (i === -1) return null;
|
|
220
|
+
const v = String(arr[i + 1] || "").trim();
|
|
221
|
+
return /^[0-9a-f-]{20,}$/i.test(v) ? v : null;
|
|
222
|
+
};
|
|
223
|
+
let sessionToken = null;
|
|
224
|
+
const withSession = (fields) => (sessionToken ? { session_token: sessionToken, ...fields } : fields);
|
|
225
|
+
|
|
226
|
+
strings = await post("begin_login", { username_or_email: identifier });
|
|
227
|
+
sessionToken = sessionTokenFrom(strings) || sessionToken;
|
|
228
|
+
|
|
229
|
+
let step = 0;
|
|
230
|
+
while (!session.cookies.auth_token && step < MAX_STEPS) {
|
|
231
|
+
step++;
|
|
232
|
+
const lower = strings.map((s) => String(s).toLowerCase());
|
|
233
|
+
const has = (needle) => lower.some((s) => s.includes(needle));
|
|
234
|
+
|
|
235
|
+
if (has("incorrect") || has("wrong password")) {
|
|
236
|
+
throw new Error("login denied: the password was incorrect");
|
|
237
|
+
}
|
|
238
|
+
if (has("suspend") || has("locked") || has("denied")) {
|
|
239
|
+
throw new Error("login denied: account locked or suspended");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const wantsPasswordEntry = has("enter your password") || has("login_enter_password");
|
|
243
|
+
const wantsTwoFactor = has("two_factor") || has("2fa") || has("authentication code");
|
|
244
|
+
const wantsEmailCode = has("acid") || has("verifycode") || has("verification code") ||
|
|
245
|
+
(has("verification") && has("code"));
|
|
246
|
+
const wantsAlternate = has("alternate") || has("login_enter_alternate_identifier");
|
|
247
|
+
|
|
248
|
+
if (wantsTwoFactor) {
|
|
249
|
+
const code = await resolve(null, onRequest, "two_factor_code");
|
|
250
|
+
strings = await post("login_enter_two_factor", withSession({ text: code }));
|
|
251
|
+
sessionToken = sessionTokenFrom(strings) || sessionToken;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (wantsEmailCode) {
|
|
255
|
+
const code = await resolve(null, onRequest, "email_code");
|
|
256
|
+
strings = await post("login_acid", withSession({ text: code }));
|
|
257
|
+
sessionToken = sessionTokenFrom(strings) || sessionToken;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (wantsAlternate) {
|
|
261
|
+
const value = await resolve(email || phone, onRequest, "alternate_identifier");
|
|
262
|
+
strings = await post("login_enter_alternate_identifier", withSession({ text: value }));
|
|
263
|
+
sessionToken = sessionTokenFrom(strings) || sessionToken;
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (wantsPasswordEntry || has("password")) {
|
|
267
|
+
strings = await post("login_enter_password", withSession({ password }));
|
|
268
|
+
sessionToken = sessionTokenFrom(strings) || sessionToken;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
throw new Error(
|
|
273
|
+
`unhandled jetfuel login step. decoded actions: [${extractActionIds(strings).join(", ")}]. ` +
|
|
274
|
+
`strings sample: ${JSON.stringify(strings.slice(0, 20))}`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (!session.cookies.auth_token) {
|
|
279
|
+
throw new Error(`login did not complete after ${step} steps`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
authToken: session.cookies.auth_token,
|
|
284
|
+
csrfToken: session.cookies.ct0 || null,
|
|
285
|
+
userId: extractUserId(session.cookies),
|
|
286
|
+
cookies: { ...session.cookies },
|
|
287
|
+
};
|
|
288
|
+
}
|
package/src/flow.js
CHANGED
|
@@ -135,7 +135,7 @@ class CookieSession {
|
|
|
135
135
|
|
|
136
136
|
function getFlowHeaders(guestToken) {
|
|
137
137
|
const headers = {
|
|
138
|
-
Authorization: `Bearer ${clients.
|
|
138
|
+
Authorization: `Bearer ${clients.web.bearer}`,
|
|
139
139
|
"Content-Type": "application/json",
|
|
140
140
|
Accept: "*/*",
|
|
141
141
|
"Accept-Language": "en-US",
|
|
@@ -178,7 +178,7 @@ async function makeRequest(session, headers, flowToken, subtaskData) {
|
|
|
178
178
|
|
|
179
179
|
async function getGuestToken(session) {
|
|
180
180
|
const response = await session.post(GUEST_ACTIVATE_URL, {
|
|
181
|
-
headers: { Authorization: `Bearer ${clients.
|
|
181
|
+
headers: { Authorization: `Bearer ${clients.web.bearer}` },
|
|
182
182
|
});
|
|
183
183
|
|
|
184
184
|
if (response.status !== 200) {
|
package/src/helpers/index.js
CHANGED
|
@@ -12,6 +12,7 @@ import * as lists from "./lists.js";
|
|
|
12
12
|
import * as media from "./media.js";
|
|
13
13
|
import * as notes from "./notes.js";
|
|
14
14
|
import * as notifications from "./notifications.js";
|
|
15
|
+
import * as report from "./report.js";
|
|
15
16
|
import * as search from "./search.js";
|
|
16
17
|
import * as spaces from "./spaces.js";
|
|
17
18
|
import * as syndication from "./syndication.js";
|
|
@@ -52,6 +53,7 @@ export default function initHelpers(proto) {
|
|
|
52
53
|
namespace(proto, "spaces", spaces);
|
|
53
54
|
namespace(proto, "account", account);
|
|
54
55
|
namespace(proto, "notifications", notifications);
|
|
56
|
+
namespace(proto, "report", report);
|
|
55
57
|
namespace(proto, "trends", trends);
|
|
56
58
|
namespace(proto, "topics", topics);
|
|
57
59
|
namespace(proto, "media", media);
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
export const REPORT_REASONS = {
|
|
2
|
+
hate: "HateOrAbuseSimpleOption",
|
|
3
|
+
abuse: "HateOrAbuseSimpleOption",
|
|
4
|
+
harassment: "HateOrAbuseSimpleOption",
|
|
5
|
+
violent_speech: "ViolentSpeechSimpleOption",
|
|
6
|
+
child_safety: "ChildSafetySimpleOption",
|
|
7
|
+
private: "PrivateContentSimpleOption",
|
|
8
|
+
nonconsensual: "PrivateContentSimpleOption",
|
|
9
|
+
illegal: "IRBSimpleOption",
|
|
10
|
+
regulated: "IRBSimpleOption",
|
|
11
|
+
spam: "SpamSimpleOption",
|
|
12
|
+
self_harm: "SuicideSelfHarmSimpleOption",
|
|
13
|
+
suicide: "SuicideSelfHarmSimpleOption",
|
|
14
|
+
adult: "AdultContentSimpleOption",
|
|
15
|
+
nsfw: "AdultContentSimpleOption",
|
|
16
|
+
violent_media: "ViolentMediaSimpleOption",
|
|
17
|
+
graphic: "ViolentMediaSimpleOption",
|
|
18
|
+
impersonation: "ImpersonationSimpleOption",
|
|
19
|
+
terrorism: "TerrorismSimpleOption",
|
|
20
|
+
extremism: "TerrorismSimpleOption",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function resolveReason(reason) {
|
|
24
|
+
if (typeof reason !== "string" || !reason) throw new Error("a report reason is required");
|
|
25
|
+
if (reason.endsWith("SimpleOption")) return reason;
|
|
26
|
+
const mapped = REPORT_REASONS[reason.toLowerCase().replace(/[\s-]+/g, "_")];
|
|
27
|
+
if (!mapped) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`unknown report reason "${reason}". use a raw *SimpleOption id or one of: ${Object.keys(REPORT_REASONS).join(", ")}`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
return mapped;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function submitReport(instance, { variant, startLocation, reason }) {
|
|
36
|
+
const init = await instance.v1_1("report/flow", {
|
|
37
|
+
params: { flow_name: "report-flow" },
|
|
38
|
+
headers: { "content-type": "application/json" },
|
|
39
|
+
body: JSON.stringify({
|
|
40
|
+
input_flow_data: {
|
|
41
|
+
requested_variant: JSON.stringify(variant),
|
|
42
|
+
flow_context: { start_location: { location: startLocation } },
|
|
43
|
+
},
|
|
44
|
+
}),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const { flow_token } = await init.json();
|
|
48
|
+
if (!flow_token) throw new Error("failed to start report flow");
|
|
49
|
+
|
|
50
|
+
const res = await instance.v1_1("report/flow", {
|
|
51
|
+
headers: { "content-type": "application/json" },
|
|
52
|
+
body: JSON.stringify({
|
|
53
|
+
flow_token,
|
|
54
|
+
subtask_inputs: [
|
|
55
|
+
{
|
|
56
|
+
subtask_id: "single-selection",
|
|
57
|
+
choice_selection: { link: "next_link", selected_choices: [reason] },
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
}),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return await res.json();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function tweet(tweetId, reason, opts = {}) {
|
|
67
|
+
if (!tweetId) throw new Error("tweetId is required");
|
|
68
|
+
return await submitReport(this, {
|
|
69
|
+
startLocation: opts.startLocation || "home",
|
|
70
|
+
reason: resolveReason(reason),
|
|
71
|
+
variant: {
|
|
72
|
+
client_app_id: "3033300",
|
|
73
|
+
client_location: "home:home:",
|
|
74
|
+
client_referer: "/home",
|
|
75
|
+
is_media: false,
|
|
76
|
+
is_promoted: false,
|
|
77
|
+
reported_tweet_id: tweetId,
|
|
78
|
+
source: "reporttweet",
|
|
79
|
+
...opts.variant,
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function user(userId, reason, opts = {}) {
|
|
85
|
+
if (!userId) throw new Error("userId is required");
|
|
86
|
+
return await submitReport(this, {
|
|
87
|
+
startLocation: opts.startLocation || "profile",
|
|
88
|
+
reason: resolveReason(reason),
|
|
89
|
+
variant: {
|
|
90
|
+
client_app_id: "3033300",
|
|
91
|
+
client_location: "profile:header:",
|
|
92
|
+
client_referer: opts.username ? `/${opts.username}` : "/",
|
|
93
|
+
is_media: false,
|
|
94
|
+
is_promoted: false,
|
|
95
|
+
reported_user_id: userId,
|
|
96
|
+
source: "reportprofile",
|
|
97
|
+
...opts.variant,
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
}
|