This commit is contained in:
hugo
2026-03-15 15:46:07 -04:00
commit f778221f3c
41 changed files with 6362 additions and 0 deletions

10
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/

8
.idea/Emusks.iml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

8
.idea/emusks.iml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/emusks.iml" filepath="$PROJECT_DIR$/.idea/emusks.iml" />
</modules>
</component>
</project>

19
.idea/php.xml generated Normal file
View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MessDetectorOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PHPCSFixerOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PHPCodeSnifferOptionsConfiguration">
<option name="highlightLevel" value="WARNING" />
<option name="transferred" value="true" />
</component>
<component name="PhpStanOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PsalmOptionsConfiguration">
<option name="transferred" value="true" />
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

1581
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "twitter-worker",
"version": "1.0.0",
"type": "module",
"description": "Microservice Twitter API avec Emusks",
"main": "src/app.js",
"scripts": {
"start": "node src/app.js",
"dev": "node --watch src/app.js",
"test": "curl -X POST http://localhost:3000/api/twitter/call -H 'Authorization: Bearer test123' -H 'Content-Type: application/json' -d '{\"auth_token\":\"xxx\",\"domain\":\"twitter\",\"method\":\"verify\",\"args\":[]}'"
},
"dependencies": {
"axios": "^1.6.0",
"cycletls": "^2.0.5",
"express": "^4.18.2",
"js-yaml": "^4.1.0",
"x-client-transaction-id": "^0.1.9"
},
"devDependencies": {
"nodemon": "^3.0.1"
}
}

255
src/app.js Normal file
View File

@@ -0,0 +1,255 @@
import express from 'express';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import yaml from 'js-yaml';
import Emusks from './emusks-local/index.js';
// --- GESTION DES CHEMINS (ES Modules) ---
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// --- LECTURE DE LA CONFIGURATION (YAML ou JSON) ---
let config = {};
const configPaths = [
path.join(__dirname, 'config.yml'),
path.join(__dirname, 'config.yaml'),
path.join(__dirname, '../config.yml'),
path.join(process.cwd(), 'config.yml')
];
for (const configPath of configPaths) {
try {
if (fs.existsSync(configPath)) {
const fileContents = fs.readFileSync(configPath, 'utf8');
config = yaml.load(fileContents);
console.log(`✅ Configuration chargée depuis : ${configPath}`);
break;
}
} catch (e) {
console.warn(`⚠️ Erreur lecture config ${configPath}: ${e.message}`);
}
}
if (Object.keys(config).length === 0) {
console.warn("⚠️ Aucun fichier de configuration trouvé, utilisation des variables d'environnement.");
}
// --- CONFIGURATION ---
const API_SECRET = config.api_secret || process.env.API_SECRET || 'secret_par_defaut';
const PORT = config.port || process.env.PORT || 3000;
const NODE_ENV = process.env.NODE_ENV || 'development';
console.log(`🔧 Environment: ${NODE_ENV}`);
console.log(`🔐 API Secret configured: ${API_SECRET ? 'Yes' : 'No'}`);
console.log(`🚀 Port: ${PORT}`);
// --- INITIALISATION EXPRESS ---
const app = express();
// Middleware pour parser le JSON (avec limite augmentée si besoin)
app.use(express.json({ limit: '10mb' }));
// Logging des requêtes (seulement en dev)
if (NODE_ENV === 'development') {
app.use((req, res, next) => {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.path}`);
next();
});
}
// --- ENDPOINT DE SANTÉ (Health Check) ---
app.get('/health', (req, res) => {
res.json({
status: 'ok',
service: 'twitter-worker',
timestamp: new Date().toISOString(),
environment: NODE_ENV
});
});
// --- ENDPOINT RACINE ---
app.get('/', (req, res) => {
res.json({
service: 'Twitter Worker API',
version: '1.0.0',
endpoints: {
health: 'GET /health',
call: 'POST /api/twitter/call'
}
});
});
// --- MIDDLEWARE DE SÉCURITÉ ---
const authMiddleware = (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader) {
return res.status(401).json({
error: 'Non autorisé. Header Authorization manquant.',
required: 'Bearer <API_SECRET>'
});
}
const [scheme, token] = authHeader.split(' ');
if (scheme !== 'Bearer' || token !== API_SECRET) {
console.warn(`⚠️ Tentative d'accès non autorisé depuis ${req.ip}`);
return res.status(401).json({
error: 'Non autorisé. Clé API invalide.'
});
}
next();
};
// Appliquer l'authentification uniquement sur les routes API
app.use('/api', authMiddleware);
// --- ENDPOINT PROXY DYNAMIQUE ---
app.post('/api/twitter/call', async (req, res) => {
const { auth_token, proxy, domain, method, args } = req.body;
// Validation des paramètres requis
if (!auth_token) {
return res.status(400).json({
error: "auth_token requis.",
hint: "Le token d'authentification Twitter est obligatoire."
});
}
if (!domain || !method) {
return res.status(400).json({
error: "domain et method requis.",
hint: "Exemple: domain='twitter', method='search'"
});
}
console.log(`📡 Requête reçue: ${domain}.${method}()`);
let client = null;
try {
// Initialisation du client Emusks
client = new Emusks();
// Connexion avec les paramètres fournis
await client.login({
auth_token,
proxy,
client: "web"
});
// Vérification que la méthode existe
if (!client[domain] || typeof client[domain][method] !== 'function') {
return res.status(400).json({
error: `La méthode client.${domain}.${method}() n'existe pas.`,
available_domains: Object.keys(client).filter(k => typeof client[k] === 'object')
});
}
// Exécution de la méthode
const startTime = Date.now();
const result = await client[domain][method](...(args || []));
const duration = Date.now() - startTime;
console.log(`✅ Succès: ${domain}.${method}() en ${duration}ms`);
res.json({
success: true,
data: result,
meta: {
duration_ms: duration,
timestamp: new Date().toISOString()
}
});
} catch (error) {
const duration = Date.now() - (startTime || Date.now());
console.error(`❌ [ERREUR] Action: ${domain}.${method}`);
console.error(` Message: ${error.message}`);
console.error(` Duration: ${duration}ms`);
if (NODE_ENV === 'development') {
console.error(error.stack);
}
res.status(500).json({
success: false,
error: error.message || "Erreur interne du serveur",
details: error.response?.data || error.response?.body || null,
meta: {
duration_ms: duration,
timestamp: new Date().toISOString()
}
});
} finally {
// Nettoyage si nécessaire
if (client && typeof client.logout === 'function') {
// optionnel: await client.logout();
}
}
});
// --- GESTION DES ERREURS 404 ---
app.use((req, res) => {
res.status(404).json({
error: 'Endpoint non trouvé',
path: req.path,
method: req.method,
available_endpoints: ['GET /', 'GET /health', 'POST /api/twitter/call']
});
});
// --- GESTION DES ERREURS GLOBALES ---
app.use((err, req, res, next) => {
console.error('🔥 Erreur globale:', err.message);
res.status(500).json({
success: false,
error: 'Erreur interne du serveur',
message: NODE_ENV === 'development' ? err.message : undefined
});
});
// --- DÉMARRAGE DU SERVEUR ---
const server = app.listen(PORT, () => {
console.log('');
console.log('╔════════════════════════════════════════════╗');
console.log('║ 🐦 Twitter Worker API Démarrée ║');
console.log('╠════════════════════════════════════════════╣');
console.log(`║ 🌍 URL: http://localhost:${PORT}`);
console.log(`║ 🔐 Auth: Bearer ${API_SECRET.substring(0, 8)}... ║`);
console.log(`║ 🚀 Env: ${NODE_ENV.padEnd(28)}`);
console.log('╚════════════════════════════════════════════╝');
console.log('');
});
// --- GESTION DES SIGNAUX POUR ARRÊT PROPRE ---
process.on('SIGTERM', () => {
console.log('📶 Signal SIGTERM reçu, fermeture en cours...');
server.close(() => {
console.log('✅ Serveur fermé proprement');
process.exit(0);
});
});
process.on('SIGINT', () => {
console.log('📶 Signal SIGINT reçu (Ctrl+C), fermeture en cours...');
server.close(() => {
console.log('✅ Serveur fermé proprement');
process.exit(0);
});
});
// Gestion des erreurs non capturées
process.on('uncaughtException', (err) => {
console.error('💥 Exception non capturée:', err);
process.exit(1);
});
process.on('unhandledRejection', (reason, promise) => {
console.error('💥 Rejection non gérée:', reason);
process.exit(1);
});

3
src/config.yml Normal file
View File

@@ -0,0 +1,3 @@
api_secret: test123
port: 3000
debug: true

View File

@@ -0,0 +1,62 @@
const chrome_fingerprint = {
"user-agent":
"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",
ja3: "771,4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53,35-5-27-16-0-10-13-23-45-65037-17613-18-65281-51-43-11,4588-29-23-24,0",
ja4r: "t13d1516h2_002f,0035,009c,009d,1301,1302,1303,c013,c014,c02b,c02c,c02f,c030,cca8,cca9_0005,000a,000b,000d,0012,0017,001b,0023,002b,002d,0033,44cd,fe0d,ff01_0403,0804,0401,0503,0805,0501,0806,0601",
};
const android_fingerprint = {
ja3: "771,4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53,23-0-16-11-17613-27-43-35-13-65037-51-18-65281-45-5-10-41,4588-29-23-24,0",
ja4r: "t13d1517h2_002f,0035,009c,009d,1301,1302,1303,c013,c014,c02b,c02c,c02f,c030,cca8,cca9_0005,000a,000b,000d,0012,0017,001b,0023,0029,002b,002d,0033,44cd,fe0d,ff01_0403,0804,0401,0503,0805,0501,0806,0601",
userAgent: "TwitterAndroid/10.93.0-release.00 (Android 14; Google Pixel 7 Pro)",
};
const iphone_fingerprint = {
ja3: "771,4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53,23-0-16-11-17613-27-43-35-13-65037-51-18-65281-45-5-10-41,4588-29-23-24,0",
ja4r: "t13d1517h2_002f,0035,009c,009d,1301,1302,1303,c013,c014,c02b,c02c,c02f,c030,cca8,cca9_0005,000a,000b,000d,0012,0017,001b,0023,0029,002b,002d,0033,44cd,fe0d,ff01_0403,0804,0401,0503,0805,0501,0806,0601",
userAgent: "Twitter-iPhone/10.93.0-release.00 (iPhone; iOS 16.5.1; Scale/3.00)",
};
export default {
android: {
// beware! this might get your account locked when tweeting
bearer:
"AAAAAAAAAAAAAAAAAAAAAFXzAwAAAAAAMHCxpeSDG1gLNLghVe8d74hl6k4%3DRUMF4xAQLsbeBhTSRrCiQpJtxoGWeyHrDb5te2jpGskWDFW82F",
fingerprints: android_fingerprint,
},
iphone: {
bearer:
"AAAAAAAAAAAAAAAAAAAAAAj4AQAAAAAAPraK64zCZ9CSzdLesbE7LB%2Bw4uE%3DVJQREvQNCZJNiz3rHO7lOXlkVOQkzzdsgu6wWgcazdMUaGoUGm",
fingerprints: iphone_fingerprint,
},
ipad: {
bearer:
"AAAAAAAAAAAAAAAAAAAAAGHtAgAAAAAA%2Bx7ILXNILCqkSGIzy6faIHZ9s3Q%3DQy97w6SIrzE7lQwPJEYQBsArEE2fC25caFwRBvAGi456G09vGR",
fingerprints: iphone_fingerprint,
},
mac: {
bearer:
"AAAAAAAAAAAAAAAAAAAAAIWCCAAAAAAA2C25AxqI%2BYCS7pdfJKRH8Xh19zA%3D8vpDZzPHaEJhd20MKVWp3UR38YoPpuTX7UD2cVYo3YNikubuxd",
fingerprints: iphone_fingerprint,
},
old: {
// doesn't seem to work
bearer:
"AAAAAAAAAAAAAAAAAAAAAPYXBAAAAAAACLXUNDekMxqa8h%2F40K4moUkGsoc%3DTYfbDKbT3jJPCEVnMYqilB28NHfOPqkca3qaAxGfsyKCs0wRbw",
fingerprints: chrome_fingerprint,
},
web: {
bearer:
"AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA",
fingerprints: chrome_fingerprint,
},
tweetdeck: {
// requires premium
bearer:
"AAAAAAAAAAAAAAAAAAAAAFQODgEAAAAAVHTp76lzh3rFzcHbmHVvQxYYpTw%3DckAlMINMjmCwxUcaXbAN4XqJVdgMJaHqNOFgPMK0zN1qLqLQCF",
fingerprints: chrome_fingerprint,
},
};

View File

@@ -0,0 +1,26 @@
import initCycleTLS from "cycletls";
let cycleTLS;
initCycleTLS().then((c) => {
cycleTLS = c;
});
export default async function getCycleTLS() {
if (!cycleTLS) {
await new Promise((r) => {
const c = setInterval(() => {
if (cycleTLS) {
clearInterval(c);
r();
}
}, 10);
});
}
return cycleTLS;
}
process.on("exit", () => {
if (cycleTLS) cycleTLS.exit();
});

399
src/emusks-local/flow.js Normal file
View File

@@ -0,0 +1,399 @@
import getCycleTLS from "./cycletls.js";
import clients from "./clients.js";
const BASE_URL = "https://api.x.com/1.1/onboarding/task.json";
const GUEST_ACTIVATE_URL = "https://api.x.com/1.1/guest/activate.json";
const USER_AGENT =
"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";
const SUBTASK_VERSIONS = {
action_list: 2,
alert_dialog: 1,
app_download_cta: 1,
check_logged_in_account: 2,
choice_selection: 3,
contacts_live_sync_permission_prompt: 0,
cta: 7,
email_verification: 2,
end_flow: 1,
enter_date: 1,
enter_email: 2,
enter_password: 5,
enter_phone: 2,
enter_recaptcha: 1,
enter_text: 5,
generic_urt: 3,
in_app_notification: 1,
interest_picker: 3,
js_instrumentation: 1,
menu_dialog: 1,
notifications_permission_prompt: 2,
open_account: 2,
open_home_timeline: 1,
open_link: 1,
phone_verification: 4,
privacy_options: 1,
security_key: 3,
select_avatar: 4,
select_banner: 2,
settings_list: 7,
show_code: 1,
sign_up: 2,
sign_up_review: 4,
tweet_selection_urt: 1,
update_users: 1,
upload_media: 1,
user_recommendations_list: 4,
user_recommendations_urt: 1,
wait_spinner: 3,
web_modal: 1,
};
const MAX_FLOW_STEPS = 20;
class CookieSession {
constructor(cycleTLS, proxy) {
this.cycleTLS = cycleTLS;
this.cookies = {};
this.proxy = proxy;
}
getCookieString() {
return Object.entries(this.cookies)
.map(([k, v]) => `${k}=${v}`)
.join("; ");
}
parseCookies(cookieValue) {
if (!cookieValue) return;
const cookies = Array.isArray(cookieValue) ? cookieValue : [cookieValue];
for (const cookie of cookies) {
const parts = cookie.split(";")[0].split("=");
if (parts.length >= 2) {
this.cookies[parts[0].trim()] = parts.slice(1).join("=").trim();
}
}
}
async post(url, options = {}) {
const headers = options.headers || {};
const cookieString = this.getCookieString();
if (cookieString) {
headers["Cookie"] = cookieString;
}
const response = await this.cycleTLS(
url,
{
body: options.json ? JSON.stringify(options.json) : options.body,
ja3: clients.web.fingerprints.ja3,
ja4r: clients.web.fingerprints.ja4r,
userAgent: USER_AGENT,
headers,
proxy: this.proxy || undefined,
},
"post",
);
const setCookie = response.headers?.["Set-Cookie"] || response.headers?.["set-cookie"];
if (setCookie) {
this.parseCookies(setCookie);
}
return response;
}
}
function getFlowHeaders(guestToken) {
const headers = {
Authorization: `Bearer ${clients.tweetdeck.bearer}`,
"Content-Type": "application/json",
Accept: "*/*",
"Accept-Language": "en-US",
"X-Twitter-Client-Language": "en-US",
Origin: "https://x.com",
Referer: "https://x.com/",
};
if (guestToken) {
headers["X-Guest-Token"] = guestToken;
}
return headers;
}
async function makeRequest(session, headers, flowToken, subtaskData) {
const payload = {
flow_token: flowToken,
subtask_inputs: Array.isArray(subtaskData) ? subtaskData : [subtaskData],
};
const response = await session.post(BASE_URL, { json: payload, headers });
if (response.status !== 200) {
const errorBody =
typeof response.body === "string"
? response.body
: JSON.stringify(response.body || response.data);
throw new Error(`Flow request failed: ${response.status} - ${errorBody}`);
}
const data =
typeof response.body === "string" ? JSON.parse(response.body) : response.body || response.data;
const newFlowToken = data.flow_token;
if (!newFlowToken) {
throw new Error("Failed to get flow token from response");
}
return [newFlowToken, data];
}
async function getGuestToken(session) {
const response = await session.post(GUEST_ACTIVATE_URL, {
headers: { Authorization: `Bearer ${clients.tweetdeck.bearer}` },
});
if (response.status !== 200) {
throw new Error("Failed to obtain guest token");
}
const data =
typeof response.body === "string" ? JSON.parse(response.body) : response.body || response.data;
const guestToken = data.guest_token;
if (!guestToken) {
throw new Error("Failed to obtain guest token");
}
return guestToken;
}
async function initFlow(session, guestToken) {
const headers = getFlowHeaders(guestToken);
const payload = {
input_flow_data: {
flow_context: {
debug_overrides: {},
start_location: { location: "manual_link" },
},
subtask_versions: SUBTASK_VERSIONS,
},
};
const response = await session.post(`${BASE_URL}?flow_name=login`, {
json: payload,
headers,
});
if (response.status !== 200) {
throw new Error("Failed to initialize login flow");
}
const data =
typeof response.body === "string" ? JSON.parse(response.body) : response.body || response.data;
const flowToken = data.flow_token;
if (!flowToken) {
throw new Error("Failed to get initial flow token");
}
return [flowToken, headers, data];
}
async function submitJsInstrumentation(session, flowToken, headers) {
return await makeRequest(session, headers, flowToken, {
subtask_id: "LoginJsInstrumentationSubtask",
js_instrumentation: {
response:
'{"rf":{"a4fc506d24bb4843c48a1966940c2796bf4fb7617a2d515ad3297b7df6b459b6":121,"bff66e16f1d7ea28c04653dc32479cf416a9c8b67c80cb8ad533b2a44fee82a3":-1,"ac4008077a7e6ca03210159dbe2134dea72a616f03832178314bb9931645e4f7":-22,"c3a8a81a9b2706c6fec42c771da65a9597c537b8e4d9b39e8e58de9fe31ff239":-12},"s":"ZHYaDA9iXRxOl2J3AZ9cc23iJx-Fg5E82KIBA_fgeZFugZGYzRtf8Bl3EUeeYgsK30gLFD2jTQx9fAMsnYCw0j8ahEy4Pb5siM5zD6n7YgOeWmFFaXoTwaGY4H0o-jQnZi5yWZRAnFi4lVuCVouNz_xd2BO2sobCO7QuyOsOxQn2CWx7bjD8vPAzT5BS1mICqUWyjZDjLnRZJU6cSQG5YFIHEPBa8Kj-v1JFgkdAfAMIdVvP7C80HWoOqYivQR7IBuOAI4xCeLQEdxlGeT-JYStlP9dcU5St7jI6ExyMeQnRicOcxXLXsan8i5Joautk2M8dAJFByzBaG4wtrPhQ3QAAAZEi-_t7"}',
link: "next_link",
},
});
}
async function submitUsername(session, flowToken, headers, username) {
const [newFlowToken, data] = await makeRequest(session, headers, flowToken, {
subtask_id: "LoginEnterUserIdentifierSSO",
settings_list: {
setting_responses: [
{
key: "user_identifier",
response_data: { text_data: { result: username } },
},
],
link: "next_link",
},
});
if (data.subtasks?.[0]?.cta?.primary_text?.text) {
throw new Error(`Login denied: ${data.subtasks[0].cta.primary_text.text}`);
}
return [newFlowToken, data];
}
async function submitPassword(session, flowToken, headers, password) {
return await makeRequest(session, headers, flowToken, {
subtask_id: "LoginEnterPassword",
enter_password: { password, link: "next_link" },
});
}
async function submitAlternateIdentifier(session, flowToken, headers, text) {
return await makeRequest(session, headers, flowToken, {
subtask_id: "LoginEnterAlternateIdentifierSubtask",
enter_text: { text: text.trim(), link: "next_link" },
});
}
async function submit2FA(session, flowToken, headers, code) {
return await makeRequest(session, headers, flowToken, {
subtask_id: "LoginTwoFactorAuthChallenge",
enter_text: { text: code.trim(), link: "next_link" },
});
}
async function submitLoginAcid(session, flowToken, headers, code) {
return await makeRequest(session, headers, flowToken, {
subtask_id: "LoginAcid",
enter_text: { text: code.trim(), link: "next_link" },
});
}
async function submitAccountDuplicationCheck(session, flowToken, headers) {
return await makeRequest(session, headers, flowToken, {
subtask_id: "AccountDuplicationCheck",
check_logged_in_account: { link: "AccountDuplicationCheck_false" },
});
}
function getSubtaskIds(data) {
if (!data?.subtasks) return [];
return data.subtasks.map((s) => s.subtask_id);
}
function isLoginComplete(data) {
if (!data?.subtasks) return false;
for (const subtask of data.subtasks) {
if (subtask.open_account || subtask.subtask_id === "LoginSuccessSubtask") {
return true;
}
}
return false;
}
function extractUserId(cookies) {
const twid = (cookies.twid || "").replace(/"/g, "");
for (const prefix of ["u=", "u%3D"]) {
if (twid.includes(prefix)) {
return twid.split(prefix)[1].split("&")[0].replace(/"/g, "");
}
}
return null;
}
async function resolve(staticValue, onRequest, type) {
if (staticValue) return staticValue;
if (onRequest) {
const value = await onRequest(type);
if (value != null && value !== "") return value;
}
throw new Error(
`the login flow is asking for "${type}" but no value was provided. either pass it directly or handle it in onRequest.`,
);
}
export default async function flowLogin(opts) {
const { username, password, email, phone, onRequest, proxy } = opts;
if (!username) throw new Error("username is required for flow login");
if (!password) throw new Error("password is required for flow login");
const cycleTLS = await getCycleTLS();
const session = new CookieSession(cycleTLS, proxy);
const guestToken = await getGuestToken(session);
let [flowToken, headers, data] = await initFlow(session, guestToken);
headers["X-Guest-Token"] = guestToken;
let step = 0;
while (!isLoginComplete(data) && step < MAX_FLOW_STEPS) {
step++;
const subtaskIds = getSubtaskIds(data);
if (subtaskIds.length === 0) {
throw new Error("Login flow returned no subtasks and login is not complete");
}
const current = subtaskIds[0];
switch (current) {
case "LoginJsInstrumentationSubtask":
[flowToken, data] = await submitJsInstrumentation(session, flowToken, headers);
break;
case "LoginEnterUserIdentifierSSO":
[flowToken, data] = await submitUsername(session, flowToken, headers, username);
break;
case "LoginEnterPassword":
[flowToken, data] = await submitPassword(session, flowToken, headers, password);
break;
case "LoginEnterAlternateIdentifierSubtask": {
const value = await resolve(email || phone, onRequest, "alternate_identifier");
[flowToken, data] = await submitAlternateIdentifier(session, flowToken, headers, value);
break;
}
case "LoginTwoFactorAuthChallenge": {
const code = await resolve(null, onRequest, "two_factor_code");
[flowToken, data] = await submit2FA(session, flowToken, headers, code);
break;
}
case "LoginAcid": {
const code = await resolve(null, onRequest, "email_code");
[flowToken, data] = await submitLoginAcid(session, flowToken, headers, code);
break;
}
case "AccountDuplicationCheck":
[flowToken, data] = await submitAccountDuplicationCheck(session, flowToken, headers);
break;
case "LoginSuccessSubtask":
break;
case "DenyLoginSubtask":
throw new Error("Login was denied by Twitter. Your account may be locked or suspended.");
default:
throw new Error(
`Unhandled login subtask: "${current}". All subtasks: [${subtaskIds.join(", ")}]`,
);
}
}
if (!isLoginComplete(data)) {
throw new Error(
`Login flow did not complete after ${MAX_FLOW_STEPS} steps. ` +
`Last subtasks: [${getSubtaskIds(data).join(", ")}]`,
);
}
const cookies = { ...session.cookies };
const authToken = cookies.auth_token || null;
const csrfToken = cookies.ct0 || null;
const userId = extractUserId(cookies);
if (!authToken) {
throw new Error("Login flow completed but no auth_token was found in cookies");
}
return { authToken, csrfToken, userId, cookies };
}

148
src/emusks-local/graphql.js Normal file
View File

@@ -0,0 +1,148 @@
import { ClientTransaction, handleXMigration } from "x-client-transaction-id";
import getCycleTLS from "./cycletls.js";
import graphqlApi from "./static/graphql.js";
const GRAPHQL_ENDPOINTS = {
main: {
base: "https://api.x.com/graphql",
referrer: "https://x.com/",
secFetchSite: "same-site",
},
main_twitter: {
base: "https://api.twitter.com/graphql",
referrer: "https://twitter.com/",
secFetchSite: "same-site",
},
web: {
base: "https://x.com/i/api/graphql",
referrer: "https://x.com/",
secFetchSite: "same-origin",
},
web_twitter: {
base: "https://twitter.com/i/api/graphql",
referrer: "https://twitter.com/",
secFetchSite: "same-origin",
},
tweetdeck: {
base: "https://pro.x.com/i/api/graphql",
referrer: "https://pro.x.com/",
secFetchSite: "same-origin",
},
tweetdeck_twitter: {
base: "https://pro.twitter.com/i/api/graphql",
referrer: "https://pro.twitter.com/",
secFetchSite: "same-origin",
},
};
export { GRAPHQL_ENDPOINTS };
export default async function graphql(queryName, { variables, fieldToggles, body, headers } = {}) {
const entry = graphqlApi[queryName];
if (!entry) {
throw new Error(`graphql query ${queryName} not found`);
}
const [method, , features, queryId] = entry;
const isPost = method.toLowerCase() === "post";
const endpointName = this.graphqlEndpoint || "web";
const endpoint = GRAPHQL_ENDPOINTS[endpointName];
if (!endpoint) {
throw new Error(
`unknown graphql endpoint "${endpointName}", expected: ${Object.keys(GRAPHQL_ENDPOINTS).join(", ")}`,
);
}
let finalUrl = `${endpoint.base}/${queryId}/${queryName}`;
let requestBody;
if (isPost) {
requestBody = {
...body,
variables: { ...variables, ...body?.variables },
queryId,
};
if (features) requestBody.features = features;
if (fieldToggles && Object.keys(fieldToggles).length) {
requestBody.fieldToggles = fieldToggles;
}
} else {
if (variables && Object.keys(variables).length) {
const separator = finalUrl.includes("?") ? "&" : "?";
finalUrl = `${finalUrl}${separator}variables=${encodeURIComponent(JSON.stringify(variables))}`;
}
if (features) {
const separator = finalUrl.includes("?") ? "&" : "?";
finalUrl = `${finalUrl}${separator}features=${encodeURIComponent(JSON.stringify(features))}`;
}
if (fieldToggles && Object.keys(fieldToggles).length) {
const separator = finalUrl.includes("?") ? "&" : "?";
finalUrl = `${finalUrl}${separator}fieldToggles=${encodeURIComponent(JSON.stringify(fieldToggles))}`;
}
}
const url = new URL(finalUrl);
const pathname = url.pathname;
const useTransactionIds =
this.transactionIds !== undefined ? this.transactionIds : endpointName === "web";
const requestHeaders = {
accept: "*/*",
"accept-language": "en-US,en;q=0.9",
authorization: `Bearer ${this.auth.client.bearer}`,
"content-type": "application/json",
"x-csrf-token": this.auth.csrfToken,
"x-twitter-active-user": "yes",
"x-twitter-auth-type": "OAuth2Session",
"x-twitter-client-language": "en",
priority: "u=1, i",
"sec-ch-ua": '"Not(A:Brand";v="8", "Chromium";v="144"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"macOS"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": endpoint.secFetchSite,
"sec-gpc": "1",
cookie:
this.auth.client.headers.cookie + (this.elevatedCookies ? `; ${this.elevatedCookies}` : ""),
...headers,
};
if (useTransactionIds) {
if (!this.auth.generateTransactionId) {
const document = await handleXMigration();
const transaction = new ClientTransaction(document);
await transaction.initialize();
this.auth.generateTransactionId = transaction.generateTransactionId.bind(transaction);
}
requestHeaders["x-client-transaction-id"] = await this.auth.generateTransactionId(
method.toUpperCase(),
pathname,
);
}
const cycleTLS = await getCycleTLS();
const res = await (
await cycleTLS(
finalUrl,
{
headers: requestHeaders,
userAgent: this.auth.client.fingerprints.userAgent,
ja3: this.auth.client.fingerprints.ja3,
ja4r: this.auth.client.fingerprints.ja4r,
body: isPost ? JSON.stringify(requestBody) : undefined,
proxy: this.proxy || undefined,
referrer: endpoint.referrer,
},
method,
)
).json();
if (res?.errors?.[0]) {
throw new Error(res.errors.map((err) => err.message).join(", "));
}
return res;
}

View File

@@ -0,0 +1,269 @@
import parseUser from "../parsers/user.js";
export async function settings() {
const res = await this.v1_1("get:account/settings", {});
return await res.json();
}
export async function updateSettings(params = {}) {
const res = await this.v1_1("post:account/settings", {
body: JSON.stringify(params),
});
return await res.json();
}
export async function verifyPassword(password) {
const res = await this.v1_1("account/verify_password", {
body: `password=${encodeURIComponent(password)}`,
headers: { "content-type": "application/x-www-form-urlencoded" },
});
return await res.json();
}
export async function changePassword(currentPassword, newPassword) {
const res = await this.v1_1("account/change_password", {
body: JSON.stringify({
current_password: currentPassword,
password: newPassword,
password_confirmation: newPassword,
}),
});
return await res.json();
}
export async function deactivate() {
const res = await this.v1_1("account/deactivate", {});
return await res.json();
}
export async function logout() {
const res = await this.v1_1("account/logout", {});
return await res.json();
}
export async function rateLimitStatus(params = {}) {
const res = await this.v1_1("application/rate_limit_status", { params });
return await res.json();
}
export async function viewer() {
const res = await this.graphql("Viewer", {
variables: { withCommunitiesMemberships: true },
fieldToggles: { withAuxiliaryUserLabels: false },
});
const user = res?.data?.viewer?.user_results?.result;
return user ? parseUser(user) : res;
}
export async function sessions() {
return await this.graphql("UserSessionsList", {
variables: {},
});
}
export async function preferences() {
return await this.graphql("UserPreferences", {
variables: {},
});
}
export async function claims() {
return await this.graphql("GetUserClaims", {
variables: {},
});
}
export async function phoneState() {
return await this.graphql("ProfileUserPhoneState", {
variables: {},
});
}
export async function passwordStrength(password) {
const res = await this.v1_1("account/password_strength", {
body: JSON.stringify({ password }),
});
return await res.json();
}
export async function resendConfirmationEmail() {
const res = await this.v1_1("account/resend_confirmation_email", {});
return await res.json();
}
export async function emailPhoneInfo(params = {}) {
const res = await this.v1_1("users/email_phone_info", { params });
return await res.json();
}
export async function emailAvailable(email) {
const res = await this.v1_1("users/email_available", {
params: { email },
});
return await res.json();
}
export async function phoneAvailable(phone) {
const res = await this.v1_1("users/phone_number_available", {
params: { phone_number: phone },
});
return await res.json();
}
export async function usernameAvailable(username) {
return await this.graphql("GetUsernameAvailabilityAndSuggestions", {
body: { variables: { username } },
});
}
export async function backupCode() {
const res = await this.v1_1("get:account/backup_code", {});
return await res.json();
}
export async function generateBackupCode() {
const res = await this.v1_1("post:account/backup_code", {});
return await res.json();
}
export async function disable2FA() {
const res = await this.v1_1("account/login_verification_enrollment", {});
return await res.json();
}
export async function remove2FAMethod(methodId) {
const res = await this.v1_1("account/login_verification/remove_method", {
body: JSON.stringify({ method_id: methodId }),
});
return await res.json();
}
export async function tempPassword() {
const res = await this.v1_1("account/login_verification/temporary_password", {});
return await res.json();
}
export async function renameSecurityKey(methodId, name) {
const res = await this.v1_1("account/login_verification/rename_security_key_method", {
body: JSON.stringify({ method_id: methodId, name }),
});
return await res.json();
}
export async function connectedApps() {
const res = await this.v1_1("oauth/list", {});
return await res.json();
}
export async function revokeApp(token) {
const res = await this.v1_1("oauth/revoke", {
body: JSON.stringify({ token }),
});
return await res.json();
}
export async function deleteSSOConnection(connectionId) {
const res = await this.v1_1("sso/delete_connection", {
body: JSON.stringify({ connection_id: connectionId }),
});
return await res.json();
}
export async function personalizationInterests() {
const res = await this.v1_1("account/personalization/twitter_interests", {});
return await res.json();
}
export async function emailYourData() {
const res = await this.v1_1("account/personalization/email_your_data", {});
return await res.json();
}
export async function multiList() {
const res = await this.v1_1("account/multi/list", {});
return await res.json();
}
export async function enableVerifiedPhoneLabel() {
return await this.graphql("EnableVerifiedPhoneLabel", {
body: { variables: {} },
});
}
export async function disableVerifiedPhoneLabel() {
return await this.graphql("DisableVerifiedPhoneLabel", {
body: { variables: {} },
});
}
export async function dataSaverMode() {
return await this.graphql("DataSaverMode", {
variables: {},
});
}
export async function setDataSaver(dataSaverMode) {
return await this.graphql("WriteDataSaverPreferences", {
body: { variables: { dataSaverMode } },
});
}
export async function mutedKeywords() {
const res = await this.v1_1("mutes/keywords/list", {});
return await res.json();
}
export async function deleteMutedKeyword(keywordId) {
const res = await this.v1_1("mutes/keywords/destroy", {
body: JSON.stringify({ ids: keywordId }),
});
return await res.json();
}
export async function updateMutedKeyword(params = {}) {
const res = await this.v1_1("mutes/keywords/update", {
body: JSON.stringify(params),
});
return await res.json();
}
export async function advancedFilters() {
const res = await this.v1_1("get:mutes/advanced_filters", {});
return await res.json();
}
export async function updateAdvancedFilters(params = {}) {
const res = await this.v1_1("post:mutes/advanced_filters", {
body: JSON.stringify(params),
});
return await res.json();
}
export async function helpSettings() {
const res = await this.v1_1("help/settings", {});
return await res.json();
}
export async function emailNotificationSettings(params = {}) {
return await this.graphql("WriteEmailNotificationSettings", {
body: { variables: { ...params } },
});
}
export async function viewerEmailSettings() {
return await this.graphql("ViewerEmailSettings", {
variables: {},
});
}
export async function accountLabel() {
return await this.graphql("UserAccountLabel", {
variables: {},
});
}
export async function disableAccountLabel() {
return await this.graphql("DisableUserAccountLabel", {
body: { variables: {} },
});
}

View File

@@ -0,0 +1,118 @@
import parseTimeline from "../parsers/timeline.js";
export async function create(tweetId) {
return await this.graphql("CreateBookmark", {
body: { variables: { tweet_id: tweetId } },
});
}
export async function remove(tweetId) {
return await this.graphql("DeleteBookmark", {
body: { variables: { tweet_id: tweetId } },
});
}
export async function deleteAll() {
return await this.graphql("BookmarksAllDelete", {
body: { variables: {} },
});
}
export async function get(opts = {}) {
const raw = await this.graphql("Bookmarks", {
variables: {
count: opts.count || 20,
cursor: opts.cursor,
includePromotedContent: false,
...opts.variables,
},
fieldToggles: {
withArticlePlainText: false,
withArticleRichContentState: false,
withAuxiliaryUserLabels: false,
},
});
return parseTimeline(raw);
}
export async function search(query, opts = {}) {
const raw = await this.graphql("BookmarkSearchTimeline", {
variables: {
search_query: query,
count: opts.count || 20,
cursor: opts.cursor,
includePromotedContent: false,
...opts.variables,
},
});
return parseTimeline(raw);
}
export async function folders() {
return await this.graphql("BookmarkFoldersSlice", {
variables: {},
});
}
export async function createFolder(name) {
return await this.graphql("createBookmarkFolder", {
body: { variables: { bookmark_collection_name: name } },
});
}
export async function deleteFolder(folderId) {
return await this.graphql("DeleteBookmarkFolder", {
body: { variables: { bookmark_collection_id: folderId } },
});
}
export async function editFolder(folderId, name) {
return await this.graphql("EditBookmarkFolder", {
body: {
variables: {
bookmark_collection_id: folderId,
bookmark_collection_name: name,
},
},
});
}
export async function addToFolder(tweetId, folderId) {
return await this.graphql("bookmarkTweetToFolder", {
body: {
variables: {
tweet_id: tweetId,
bookmark_collection_id: folderId,
},
},
});
}
export async function removeFromFolder(tweetId, folderId) {
return await this.graphql("RemoveTweetFromBookmarkFolder", {
body: {
variables: {
tweet_id: tweetId,
bookmark_collection_id: folderId,
},
},
});
}
export async function folderTimeline(folderId, opts = {}) {
const raw = await this.graphql("BookmarkFolderTimeline", {
variables: {
bookmark_collection_id: folderId,
count: opts.count || 20,
cursor: opts.cursor,
includePromotedContent: false,
...opts.variables,
},
fieldToggles: {
withArticlePlainText: false,
withArticleRichContentState: false,
withAuxiliaryUserLabels: false,
},
});
return parseTimeline(raw);
}

View File

@@ -0,0 +1,269 @@
export async function create(name, opts = {}) {
return await this.graphql("CreateCommunity", {
body: {
variables: {
name,
description: opts.description || "",
...(opts.rules ? { rules: opts.rules } : {}),
...opts.variables,
},
},
});
}
export async function get(communityId) {
return await this.graphql("CommunityByRestId", {
variables: { communityId },
});
}
export async function join(communityId) {
return await this.graphql("JoinCommunity", {
body: { variables: { communityId } },
});
}
export async function leave(communityId) {
return await this.graphql("LeaveCommunity", {
body: { variables: { communityId } },
});
}
export async function requestJoin(communityId, opts = {}) {
return await this.graphql("RequestToJoinCommunity", {
body: {
variables: {
communityId,
...(opts.answer ? { answer: opts.answer } : {}),
},
},
});
}
export async function timeline(communityId, opts = {}) {
return await this.graphql("CommunityTweetsTimeline", {
variables: {
communityId,
count: opts.count || 20,
cursor: opts.cursor,
rankingMode: opts.rankingMode || "Recency",
withCommunity: true,
...opts.variables,
},
fieldToggles: {
withArticlePlainText: false,
withArticleRichContentState: false,
withAuxiliaryUserLabels: false,
},
});
}
export async function media(communityId, opts = {}) {
return await this.graphql("CommunityMediaTimeline", {
variables: {
communityId,
count: opts.count || 20,
cursor: opts.cursor,
withCommunity: true,
...opts.variables,
},
fieldToggles: {
withArticlePlainText: false,
withArticleRichContentState: false,
withAuxiliaryUserLabels: false,
},
});
}
export async function about(communityId, opts = {}) {
return await this.graphql("CommunityAboutTimeline", {
variables: {
communityId,
count: opts.count || 20,
cursor: opts.cursor,
withCommunity: true,
...opts.variables,
},
});
}
export async function hashtags(communityId, opts = {}) {
return await this.graphql("CommunityHashtagsTimeline", {
variables: {
communityId,
count: opts.count || 20,
cursor: opts.cursor,
...opts.variables,
},
});
}
export async function editName(communityId, name) {
return await this.graphql("CommunityEditName", {
body: { variables: { communityId, name } },
});
}
export async function editPurpose(communityId, purpose) {
return await this.graphql("CommunityEditPurpose", {
body: { variables: { communityId, purpose } },
});
}
export async function editBanner(communityId, mediaId) {
return await this.graphql("CommunityEditBannerMedia", {
body: { variables: { communityId, mediaId } },
});
}
export async function removeBanner(communityId) {
return await this.graphql("CommunityRemoveBannerMedia", {
body: { variables: { communityId } },
});
}
export async function createRule(communityId, name, opts = {}) {
return await this.graphql("CommunityCreateRule", {
body: {
variables: {
communityId,
name,
description: opts.description || "",
},
},
});
}
export async function editRule(communityId, ruleId, name, opts = {}) {
return await this.graphql("CommunityEditRule", {
body: {
variables: {
communityId,
ruleId,
name,
...(opts.description !== undefined ? { description: opts.description } : {}),
},
},
});
}
export async function removeRule(communityId, ruleId) {
return await this.graphql("CommunityRemoveRule", {
body: { variables: { communityId, ruleId } },
});
}
export async function reorderRules(communityId, ruleIds) {
return await this.graphql("CommunityReorderRules", {
body: { variables: { communityId, ruleIds } },
});
}
export async function editQuestion(communityId, question) {
return await this.graphql("CommunityEditQuestion", {
body: { variables: { communityId, question } },
});
}
export async function updateRole(communityId, userId, role) {
return await this.graphql("CommunityUpdateRole", {
body: { variables: { communityId, userId, role } },
});
}
export async function invite(communityId, userId) {
return await this.graphql("CommunityUserInvite", {
body: { variables: { communityId, userId } },
});
}
export async function keepTweet(communityId, tweetId) {
return await this.graphql("CommunityModerationKeepTweet", {
body: { variables: { communityId, tweetId } },
});
}
export async function moderationCases(communityId, opts = {}) {
return await this.graphql("CommunityModerationTweetCasesSlice", {
variables: {
communityId,
count: opts.count || 20,
cursor: opts.cursor,
...opts.variables,
},
});
}
export async function moderationLog(communityId, opts = {}) {
return await this.graphql("CommunityTweetModerationLogSlice", {
variables: {
communityId,
count: opts.count || 20,
cursor: opts.cursor,
...opts.variables,
},
});
}
export async function explore(opts = {}) {
return await this.graphql("CommunitiesExploreTimeline", {
variables: {
count: opts.count || 20,
cursor: opts.cursor,
...opts.variables,
},
});
}
export async function discover(opts = {}) {
return await this.graphql("CommunitiesMainDiscoveryModule", {
variables: {
count: opts.count || 20,
cursor: opts.cursor,
...opts.variables,
},
});
}
export async function ranked(opts = {}) {
return await this.graphql("CommunitiesRankedTimeline", {
variables: {
count: opts.count || 20,
cursor: opts.cursor,
...opts.variables,
},
});
}
export async function memberships(userId, opts = {}) {
return await this.graphql("CommunitiesMembershipsTimeline", {
variables: {
userId,
count: opts.count || 20,
cursor: opts.cursor,
...opts.variables,
},
});
}
export async function memberSearch(communityId, query, opts = {}) {
return await this.graphql("CommunityMemberRelationshipTypeahead", {
variables: {
communityId,
query,
count: opts.count || 20,
...opts.variables,
},
});
}
export async function userSearch(communityId, query, opts = {}) {
return await this.graphql("CommunityUserRelationshipTypeahead", {
variables: {
communityId,
query,
count: opts.count || 20,
...opts.variables,
},
});
}

View File

@@ -0,0 +1,129 @@
export async function inbox(params = {}) {
const res = await this.v1_1("dm/inbox_initial_state", { params });
return await res.json();
}
export async function conversation(conversationId, params = {}) {
const res = await this.v1_1("dm/conversation", {
params: { id: conversationId, ...params },
});
return await res.json();
}
export async function search(query, opts = {}) {
return await this.graphql("DmAllSearchSlice", {
variables: {
query,
count: opts.count || 20,
cursor: opts.cursor,
...opts.variables,
},
});
}
export async function searchGroups(query, opts = {}) {
return await this.graphql("DmGroupSearchSlice", {
variables: {
query,
count: opts.count || 20,
cursor: opts.cursor,
...opts.variables,
},
});
}
export async function searchPeople(query, opts = {}) {
return await this.graphql("DmPeopleSearchSlice", {
variables: {
query,
count: opts.count || 20,
cursor: opts.cursor,
...opts.variables,
},
});
}
export async function block(userId) {
return await this.graphql("dmBlockUser", {
body: { variables: { user_id: userId } },
});
}
export async function unblock(userId) {
return await this.graphql("dmUnblockUser", {
body: { variables: { user_id: userId } },
});
}
export async function deleteConversations(conversationIds) {
const ids = Array.isArray(conversationIds) ? conversationIds : [conversationIds];
const res = await this.v1_1("dm/conversation/bulk_delete", {
body: JSON.stringify({ conversation_ids: ids }),
});
return await res.json();
}
export async function updateLastSeen(eventId) {
const res = await this.v1_1("dm/update_last_seen_event_id", {
body: JSON.stringify({ last_seen_event_id: eventId, trusted_last_seen_event_id: eventId }),
});
return await res.json();
}
export async function muted(opts = {}) {
return await this.graphql("DmMutedTimeline", {
variables: {
count: opts.count || 20,
cursor: opts.cursor,
...opts.variables,
},
});
}
export async function edit(messageId, conversationId, text) {
const res = await this.v1_1("dm/edit", {
body: JSON.stringify({
message_id: messageId,
conversation_id: conversationId,
text,
}),
});
return await res.json();
}
export async function permissions(params = {}) {
const res = await this.v1_1("dm/permissions", { params });
return await res.json();
}
export async function nsfwFilter(enabled) {
return await this.graphql("DmNsfwMediaFilterUpdate", {
body: { variables: { enabled } },
});
}
export async function updateRelationship(userId, action) {
const res = await this.v1_1("dm/user/update_relationship_state", {
body: JSON.stringify({ user_id: userId, action }),
});
return await res.json();
}
export async function reportSpam(conversationId, messageId) {
const res = await this.v1_1("direct_messages/report_spam", {
body: JSON.stringify({ conversation_id: conversationId, message_id: messageId }),
});
return await res.json();
}
export async function report(conversationId, messageId) {
const res = await this.v1_1("dm/report", {
body: JSON.stringify({ conversation_id: conversationId, message_id: messageId }),
});
return await res.json();
}
export async function userUpdates(params = {}) {
const res = await this.v1_1("dm/user_updates", { params });
return await res.json();
}

View File

@@ -0,0 +1,51 @@
import * as account from "./account.js";
import * as bookmarks from "./bookmarks.js";
import * as communities from "./communities.js";
import * as dms from "./dms.js";
import * as lists from "./lists.js";
import * as media from "./media.js";
import * as notifications from "./notifications.js";
import * as search from "./search.js";
import * as spaces from "./spaces.js";
import * as syndication from "./syndication.js";
import * as timelines from "./timelines.js";
import * as topics from "./topics.js";
import * as trends from "./trends.js";
import * as tweets from "./tweets.js";
import * as users from "./users.js";
function namespace(proto, name, methods) {
Object.defineProperty(proto, name, {
get() {
const bound = {};
for (const [k, fn] of Object.entries(methods)) {
if (typeof fn === "function") bound[k] = fn.bind(this);
}
Object.defineProperty(this, name, {
value: bound,
writable: true,
configurable: true,
});
return bound;
},
configurable: true,
});
}
export default function initHelpers(proto) {
namespace(proto, "tweets", tweets);
namespace(proto, "users", users);
namespace(proto, "timelines", timelines);
namespace(proto, "bookmarks", bookmarks);
namespace(proto, "dms", dms);
namespace(proto, "lists", lists);
namespace(proto, "communities", communities);
namespace(proto, "search", search);
namespace(proto, "spaces", spaces);
namespace(proto, "account", account);
namespace(proto, "notifications", notifications);
namespace(proto, "trends", trends);
namespace(proto, "topics", topics);
namespace(proto, "media", media);
namespace(proto, "syndication", syndication);
}

View File

@@ -0,0 +1,227 @@
export async function create(name, opts = {}) {
return await this.graphql("CreateList", {
body: {
variables: {
isPrivate: opts.private || false,
name,
description: opts.description || "",
},
},
});
}
export async function remove(listId) {
return await this.graphql("DeleteList", {
body: { variables: { listId } },
});
}
export async function update(listId, opts = {}) {
return await this.graphql("UpdateList", {
body: {
variables: {
listId,
...(opts.name !== undefined ? { name: opts.name } : {}),
...(opts.description !== undefined ? { description: opts.description } : {}),
...(opts.private !== undefined ? { isPrivate: opts.private } : {}),
},
},
});
}
export async function get(listId) {
return await this.graphql("ListByRestId", {
variables: { listId },
});
}
export async function getBySlug(slug, opts = {}) {
return await this.graphql("ListBySlug", {
variables: { slug, listOwnerScreenName: opts.ownerScreenName || "" },
});
}
export async function addMember(listId, userId) {
return await this.graphql("ListAddMember", {
body: { variables: { listId, userId } },
});
}
export async function removeMember(listId, userId) {
return await this.graphql("ListRemoveMember", {
body: { variables: { listId, userId } },
});
}
export async function members(listId, opts = {}) {
return await this.graphql("ListMembers", {
variables: {
listId,
count: opts.count || 20,
cursor: opts.cursor,
...opts.variables,
},
});
}
export async function subscribers(listId, opts = {}) {
return await this.graphql("ListSubscribers", {
variables: {
listId,
count: opts.count || 20,
cursor: opts.cursor,
...opts.variables,
},
});
}
export async function subscribe(listId) {
return await this.graphql("ListSubscribe", {
body: { variables: { listId } },
});
}
export async function unsubscribe(listId) {
return await this.graphql("ListUnsubscribe", {
body: { variables: { listId } },
});
}
export async function mute(listId) {
return await this.graphql("MuteList", {
body: { variables: { listId } },
});
}
export async function unmute(listId) {
return await this.graphql("UnmuteList", {
body: { variables: { listId } },
});
}
export async function timeline(listId, opts = {}) {
return await this.graphql("ListLatestTweetsTimeline", {
variables: {
listId,
count: opts.count || 20,
cursor: opts.cursor,
...opts.variables,
},
fieldToggles: {
withArticlePlainText: false,
withArticleRichContentState: false,
withAuxiliaryUserLabels: false,
},
});
}
export async function ranked(listId, opts = {}) {
return await this.graphql("ListRankedTweetsTimeline", {
variables: {
listId,
count: opts.count || 20,
cursor: opts.cursor,
...opts.variables,
},
fieldToggles: {
withArticlePlainText: false,
withArticleRichContentState: false,
withAuxiliaryUserLabels: false,
},
});
}
export async function search(listId, query, opts = {}) {
return await this.graphql("ListSearchTimeline", {
variables: {
listId,
search_query: query,
count: opts.count || 20,
cursor: opts.cursor,
...opts.variables,
},
});
}
export async function ownerships(userId, opts = {}) {
return await this.graphql("ListOwnerships", {
variables: {
userId,
count: opts.count || 20,
cursor: opts.cursor,
isCreator: true,
...opts.variables,
},
});
}
export async function listMemberships(userId, opts = {}) {
return await this.graphql("ListMemberships", {
variables: {
userId,
count: opts.count || 20,
cursor: opts.cursor,
...opts.variables,
},
});
}
export async function discover(opts = {}) {
return await this.graphql("ListsDiscovery", {
variables: {
count: opts.count || 20,
cursor: opts.cursor,
...opts.variables,
},
});
}
export async function combined(opts = {}) {
return await this.graphql("CombinedLists", {
variables: {
count: opts.count || 20,
cursor: opts.cursor,
...opts.variables,
},
});
}
export async function manage(opts = {}) {
return await this.graphql("ListsManagementPageTimeline", {
variables: {
count: opts.count || 20,
cursor: opts.cursor,
...opts.variables,
},
});
}
export async function editBanner(listId, mediaId) {
return await this.graphql("EditListBanner", {
body: { variables: { listId, mediaId } },
});
}
export async function deleteBanner(listId) {
return await this.graphql("DeleteListBanner", {
body: { variables: { listId } },
});
}
export async function pinTimeline(timelineId) {
return await this.graphql("PinTimeline", {
body: { variables: { timeline_id: timelineId } },
});
}
export async function unpinTimeline(timelineId) {
return await this.graphql("UnpinTimeline", {
body: { variables: { timeline_id: timelineId } },
});
}
export async function pinned() {
return await this.graphql("PinnedTimelines", {
variables: {},
});
}

View File

@@ -0,0 +1,289 @@
import { readFile } from "fs/promises";
import { extname } from "path";
import getCycleTLS from "../cycletls.js";
const UPLOAD_URL = "https://upload.twitter.com/i/media/upload.json";
const CHUNK_SIZE = 4 * 1024 * 1024; // 4 MB
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB
const MAX_GIF_SIZE = 15 * 1024 * 1024; // 15 MB
const MAX_VIDEO_SIZE = 512 * 1024 * 1024; // 512 MB
const MIME_BY_EXT = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".mp4": "video/mp4",
".mov": "video/quicktime",
".webm": "video/webm",
".avi": "video/x-msvideo",
};
function detectMediaType(buf) {
if (buf[0] === 0xff && buf[1] === 0xd8) return "image/jpeg";
if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) return "image/png";
if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) return "image/gif";
if (
buf[0] === 0x52 &&
buf[1] === 0x49 &&
buf[2] === 0x46 &&
buf[3] === 0x46 &&
buf[8] === 0x57 &&
buf[9] === 0x45 &&
buf[10] === 0x42 &&
buf[11] === 0x50
)
return "image/webp";
if (buf.length > 7 && buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70)
return "video/mp4";
if (buf[0] === 0x1a && buf[1] === 0x45 && buf[2] === 0xdf && buf[3] === 0xa3) return "video/webm";
return null;
}
function getMediaCategory(mediaType, uploadType) {
if (mediaType === "image/gif") return `${uploadType}_gif`;
if (mediaType.startsWith("video/")) return `${uploadType}_video`;
if (mediaType.startsWith("image/")) return `${uploadType}_image`;
throw new Error(`unsupported media type: ${mediaType}`);
}
function checkMediaSize(category, size) {
const fmt = (x) => `${(x / 1e6).toFixed(2)} MB`;
if (category.includes("image") && !category.includes("gif") && size > MAX_IMAGE_SIZE)
throw new Error(`cannot upload ${fmt(size)} image \u2014 max is ${fmt(MAX_IMAGE_SIZE)}`);
if (category.includes("gif") && size > MAX_GIF_SIZE)
throw new Error(`cannot upload ${fmt(size)} gif \u2014 max is ${fmt(MAX_GIF_SIZE)}`);
if (category.includes("video") && size > MAX_VIDEO_SIZE)
throw new Error(`cannot upload ${fmt(size)} video \u2014 max is ${fmt(MAX_VIDEO_SIZE)}`);
}
async function makeUploadRequest(instance, method, params, body, extraHeaders = {}) {
const cycleTLS = await getCycleTLS();
const url = `${UPLOAD_URL}?${new URLSearchParams(params).toString()}`;
const headers = {
accept: "*/*",
"accept-language": "en-US,en;q=0.9",
authorization: `Bearer ${instance.auth.client.bearer}`,
"x-csrf-token": instance.auth.csrfToken,
"x-twitter-active-user": "yes",
"x-twitter-auth-type": "OAuth2Session",
"x-twitter-client-language": "en",
priority: "u=1, i",
"sec-ch-ua": "Not(A:Brand;v=8, Chromium;v=144",
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": "macOS",
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-site",
"sec-gpc": "1",
cookie:
instance.auth.client.headers.cookie +
(instance.elevatedCookies ? `; ${instance.elevatedCookies}` : ""),
...extraHeaders,
};
return await cycleTLS(
url,
{
headers,
userAgent:
instance.auth.client.fingerprints?.userAgent ||
instance.auth.client.fingerprints?.["user-agent"],
ja3: instance.auth.client.fingerprints?.ja3,
ja4r: instance.auth.client.fingerprints?.ja4r,
body: body || undefined,
proxy: instance.proxy || undefined,
referrer: "https://x.com/",
},
method,
);
}
export async function create(source, opts = {}) {
if (!this.auth) throw new Error("you must be logged in to upload media");
let buf;
let mediaType = opts.mediaType;
if (typeof source === "string") {
buf = await readFile(source);
if (!mediaType) mediaType = MIME_BY_EXT[extname(source).toLowerCase()];
} else if (typeof Blob !== "undefined" && source instanceof Blob) {
buf = Buffer.from(await source.arrayBuffer());
if (!mediaType) mediaType = source.type || undefined;
} else if (source instanceof ArrayBuffer || source instanceof SharedArrayBuffer) {
buf = Buffer.from(source);
} else if (Buffer.isBuffer(source) || source instanceof Uint8Array) {
buf = Buffer.from(source);
} else {
throw new Error(
"source must be a file path (string), Buffer, Uint8Array, ArrayBuffer, or Blob",
);
}
if (!mediaType) mediaType = detectMediaType(buf);
if (!mediaType)
throw new Error("could not detect media type \u2014 pass opts.mediaType (e.g. 'image/png')");
const totalBytes = buf.length;
const uploadType = opts.type === "dm" ? "dm" : "tweet";
const category = getMediaCategory(mediaType, uploadType);
checkMediaSize(category, totalBytes);
const initRes = await makeUploadRequest(this, "post", {
command: "INIT",
media_type: mediaType,
total_bytes: totalBytes.toString(),
media_category: category,
});
const initData = await initRes.json();
if (!initData?.media_id_string) {
throw new Error(`upload INIT failed: ${JSON.stringify(initData)}`);
}
const mediaId = initData.media_id_string;
let segmentIndex = 0;
for (let offset = 0; offset < totalBytes; offset += CHUNK_SIZE) {
const chunk = buf.slice(offset, Math.min(offset + CHUNK_SIZE, totalBytes));
const base64 = chunk.toString("base64");
await makeUploadRequest(
this,
"post",
{
command: "APPEND",
media_id: mediaId,
segment_index: segmentIndex.toString(),
},
`media_data=${encodeURIComponent(base64)}`,
{ "content-type": "application/x-www-form-urlencoded" },
);
segmentIndex++;
}
const finalizeRes = await makeUploadRequest(this, "post", {
command: "FINALIZE",
media_id: mediaId,
allow_async: "true",
});
const finalizeData = await finalizeRes.json();
if (finalizeData?.error) {
throw new Error(`upload FINALIZE failed: ${JSON.stringify(finalizeData)}`);
}
let processingInfo = finalizeData?.processing_info;
while (processingInfo) {
if (processingInfo.error) {
throw new Error(`media processing error: ${JSON.stringify(processingInfo.error)}`);
}
if (processingInfo.state === "succeeded") break;
if (processingInfo.state === "failed") {
throw new Error(`media processing failed: ${JSON.stringify(processingInfo)}`);
}
const wait = (processingInfo.check_after_secs || 2) * 1000;
await new Promise((r) => setTimeout(r, wait));
const statusRes = await makeUploadRequest(this, "get", {
command: "STATUS",
media_id: mediaId,
});
const statusData = await statusRes.json();
processingInfo = statusData?.processing_info;
}
if (opts.alt_text) {
await this.v1_1("media/metadata/create", {
body: JSON.stringify({
media_id: mediaId,
alt_text: { text: opts.alt_text },
}),
});
}
return { media_id: mediaId, ...finalizeData };
}
export async function createFromUrl(url, opts = {}) {
if (!this.auth) throw new Error("you must be logged in to upload media");
const mediaType = opts.mediaType || "image/gif";
const uploadType = opts.type === "dm" ? "dm" : "tweet";
const category = opts.mediaCategory || getMediaCategory(mediaType, uploadType);
const initRes = await makeUploadRequest(this, "post", {
command: "INIT",
source_url: url,
media_type: mediaType,
media_category: category,
});
const initData = await initRes.json();
if (!initData?.media_id_string) {
throw new Error(`upload INIT failed: ${JSON.stringify(initData)}`);
}
const mediaId = initData.media_id_string;
let processingInfo = initData?.processing_info;
while (processingInfo) {
if (processingInfo.error) {
throw new Error(`media processing error: ${JSON.stringify(processingInfo.error)}`);
}
if (processingInfo.state === "succeeded") break;
if (processingInfo.state === "failed") {
throw new Error(`media processing failed: ${JSON.stringify(processingInfo)}`);
}
const wait = (processingInfo.check_after_secs || 2) * 1000;
await new Promise((r) => setTimeout(r, wait));
const statusRes = await makeUploadRequest(this, "get", {
command: "STATUS",
media_id: mediaId,
});
const statusData = await statusRes.json();
processingInfo = statusData?.processing_info;
}
const metadataBody = { media_id: mediaId };
const altText = opts.altText || opts.alt_text;
if (altText) metadataBody.alt_text = { text: altText };
if (opts.origin) metadataBody.found_media_origin = opts.origin;
if (altText || opts.origin) {
await this.v1_1("media/metadata/create", {
body: JSON.stringify(metadataBody),
});
}
return { media_id: mediaId, ...initData };
}
export async function createMetadata(mediaId, altText, opts = {}) {
const res = await this.v1_1("media/metadata/create", {
body: JSON.stringify({
media_id: mediaId,
alt_text: { text: altText },
...opts,
}),
});
return await res.json();
}
export async function createSubtitles(mediaId, subtitles) {
const res = await this.v1_1("media/subtitles/create", {
body: JSON.stringify({
media_id: mediaId,
media_category: "tweet_video",
subtitle_info: {
subtitles: Array.isArray(subtitles) ? subtitles : [subtitles],
},
}),
});
return await res.json();
}

View File

@@ -0,0 +1,48 @@
export async function timeline(opts = {}) {
return await this.graphql("NotificationsTimeline", {
variables: {
timeline_type: opts.timeline_type || "All",
count: opts.count || 20,
cursor: opts.cursor,
includePromotedContent: false,
...opts.variables,
},
fieldToggles: {
withArticlePlainText: false,
withArticleRichContentState: false,
withAuxiliaryUserLabels: false,
},
});
}
export async function enableWebNotifications() {
return await this.graphql("EnableLoggedOutWebNotifications", {
body: { variables: {} },
});
}
export async function saveSettings(params = {}) {
const res = await this.v1_1("notifications/settings/save", {
body: JSON.stringify(params),
});
return await res.json();
}
export async function loginSettings(params = {}) {
const res = await this.v1_1("notifications/settings/login", {
body: JSON.stringify(params),
});
return await res.json();
}
export async function checkin(params = {}) {
const res = await this.v1_1("notifications/settings/checkin", {
body: JSON.stringify(params),
});
return await res.json();
}
export async function badge(params = {}) {
const res = await this.v2("badge_count/badge_count", { params });
return await res.json();
}

View File

@@ -0,0 +1,142 @@
import parseTimeline from "../parsers/timeline.js";
export async function tweets(query, opts = {}) {
const raw = await this.graphql("SearchTimeline", {
variables: {
rawQuery: query,
count: opts.count || 20,
cursor: opts.cursor,
querySource: opts.querySource || "typed_query",
product: opts.product || "Top",
...opts.variables,
},
fieldToggles: {
withArticlePlainText: false,
withArticleRichContentState: false,
withAuxiliaryUserLabels: false,
},
});
return parseTimeline(raw);
}
export async function users(query, opts = {}) {
const raw = await this.graphql("SearchTimeline", {
variables: {
rawQuery: query,
count: opts.count || 20,
cursor: opts.cursor,
querySource: opts.querySource || "typed_query",
product: "People",
...opts.variables,
},
});
return parseTimeline(raw);
}
export async function media(query, opts = {}) {
const raw = await this.graphql("SearchTimeline", {
variables: {
rawQuery: query,
count: opts.count || 20,
cursor: opts.cursor,
querySource: opts.querySource || "typed_query",
product: "Media",
...opts.variables,
},
});
return parseTimeline(raw);
}
export async function latest(query, opts = {}) {
const raw = await this.graphql("SearchTimeline", {
variables: {
rawQuery: query,
count: opts.count || 20,
cursor: opts.cursor,
querySource: opts.querySource || "typed_query",
product: "Latest",
...opts.variables,
},
});
return parseTimeline(raw);
}
export async function lists(query, opts = {}) {
const raw = await this.graphql("SearchTimeline", {
variables: {
rawQuery: query,
count: opts.count || 20,
cursor: opts.cursor,
querySource: opts.querySource || "typed_query",
product: "Lists",
...opts.variables,
},
});
return parseTimeline(raw);
}
export async function typeahead(query, params = {}) {
const res = await this.v1_1("search/typeahead", {
params: {
q: query,
src: params.src || "search_box",
result_type: params.result_type || "events,users,topics,lists",
...params,
},
});
return await res.json();
}
export async function adaptive(query, params = {}) {
const res = await this.v2("search/adaptive", {
params: {
q: query,
count: params.count || 20,
query_source: params.query_source || "typed_query",
pc: params.pc || 1,
spelling_corrections: params.spelling_corrections || 1,
include_ext_edit_control: true,
...params,
},
});
return await res.json();
}
export async function communities(query, opts = {}) {
const raw = await this.graphql("GlobalCommunitiesPostSearchTimeline", {
variables: {
rawQuery: query,
count: opts.count || 20,
cursor: opts.cursor,
...opts.variables,
},
});
return parseTimeline(raw);
}
export async function gifs(query, params = {}) {
const res = await this.v1_1("foundmedia/search", {
params: {
q: query,
...(params.cursor ? { cursor: params.cursor } : {}),
...params,
},
});
const json = await res.json();
return {
items: json.data?.items || [],
cursor: json.cursor?.next || null,
};
}
export async function communitiesLatest(query, opts = {}) {
const raw = await this.graphql("GlobalCommunitiesLatestPostSearchTimeline", {
variables: {
rawQuery: query,
count: opts.count || 20,
cursor: opts.cursor,
...opts.variables,
},
});
return parseTimeline(raw);
}

View File

@@ -0,0 +1,53 @@
export async function get(spaceId) {
return await this.graphql("AudioSpaceById", {
variables: {
id: spaceId,
isMetatagsQuery: false,
withReplays: true,
withListeners: true,
},
});
}
export async function search(query, opts = {}) {
return await this.graphql("AudioSpaceSearch", {
variables: {
query,
count: opts.count || 20,
cursor: opts.cursor,
...opts.variables,
},
});
}
export async function browseTopics(opts = {}) {
return await this.graphql("BrowseSpaceTopics", {
variables: {
...opts.variables,
},
});
}
export async function subscribe(spaceId) {
return await this.graphql("SubscribeToScheduledSpace", {
body: { variables: { space_id: spaceId } },
});
}
export async function unsubscribe(spaceId) {
return await this.graphql("UnsubscribeFromScheduledSpace", {
body: { variables: { space_id: spaceId } },
});
}
export async function addSharing(spaceId) {
return await this.graphql("AudioSpaceAddSharing", {
body: { variables: { space_id: spaceId } },
});
}
export async function deleteSharing(spaceId) {
return await this.graphql("AudioSpaceDeleteSharing", {
body: { variables: { space_id: spaceId } },
});
}

View File

@@ -0,0 +1,31 @@
import getCycleTLS from "../cycletls.js";
export async function getTweet(tweetId) {
const token = ((Number(tweetId) / 1e15) * Math.PI).toString(36).replace(/(0+|\.)/g, "");
const features = "tfw_timeline_list:;tfw_tweet_edit_backend:on;tfw_refsrc_session:on;tfw_fosnr_soft_interventions_enabled:on;tfw_mixed_media_15897:treatment;tfw_experiments_cookie_expiration:1209600;tfw_show_birdwatch_pivots_enabled:on;tfw_duplicate_scribes_to_settings:on;tfw_use_profile_image_shape_enabled:on;tfw_video_hls_dynamic_manifests_15082:true_bitrate;tfw_tweet_edit_frontend:on";
const url = `https://cdn.syndication.twimg.com/tweet-result?id=${tweetId}&lang=en&token=${token}&features=${encodeURIComponent(features)}`;
try {
const cycleTLS = await getCycleTLS();
const res = await cycleTLS(url, {
headers: {
accept: "application/json, text/plain, */*",
},
userAgent: this.auth?.client?.fingerprints?.userAgent,
ja3: this.auth?.client?.fingerprints?.ja3,
ja4r: this.auth?.client?.fingerprints?.ja4r,
proxy: this.proxy || undefined,
timeout: 3000,
}, "GET");
if (!res || res.status !== 200) return null;
return await res.json();
} catch (e) {
console.warn(e);
return null;
}
}

View File

@@ -0,0 +1,82 @@
import parseTimeline from "../parsers/timeline.js";
export async function home(opts = {}) {
const raw = await this.graphql("HomeTimeline", {
variables: {
count: opts.count || 20,
cursor: opts.cursor,
includePromotedContent: true,
latestControlAvailable: true,
requestContext: "launch",
withCommunity: true,
...opts.variables,
},
fieldToggles: {
withArticlePlainText: false,
withArticleRichContentState: false,
withAuxiliaryUserLabels: false,
},
});
return parseTimeline(raw);
}
export async function homeLatest(opts = {}) {
const raw = await this.graphql("HomeLatestTimeline", {
variables: {
count: opts.count || 20,
cursor: opts.cursor,
includePromotedContent: true,
latestControlAvailable: true,
requestContext: "launch",
withCommunity: true,
...opts.variables,
},
fieldToggles: {
withArticlePlainText: false,
withArticleRichContentState: false,
withAuxiliaryUserLabels: false,
},
});
return parseTimeline(raw);
}
export async function connect(opts = {}) {
const raw = await this.graphql("ConnectTabTimeline", {
variables: {
count: opts.count || 20,
cursor: opts.cursor,
context: "{}",
...opts.variables,
},
});
return parseTimeline(raw);
}
export async function moderated(opts = {}) {
const raw = await this.graphql("ModeratedTimeline", {
variables: {
count: opts.count || 20,
cursor: opts.cursor,
includePromotedContent: false,
...opts.variables,
},
fieldToggles: {
withArticlePlainText: false,
withArticleRichContentState: false,
withAuxiliaryUserLabels: false,
},
});
return parseTimeline(raw);
}
export async function creatorSubscriptions(opts = {}) {
const raw = await this.graphql("CreatorSubscriptionsTimeline", {
variables: {
count: opts.count || 20,
cursor: opts.cursor,
includePromotedContent: false,
...opts.variables,
},
});
return parseTimeline(raw);
}

View File

@@ -0,0 +1,97 @@
export async function follow(topicId) {
return await this.graphql("TopicFollow", {
body: { variables: { topic_id: topicId } },
});
}
export async function unfollow(topicId) {
return await this.graphql("TopicUnfollow", {
body: { variables: { topic_id: topicId } },
});
}
export async function notInterested(topicId) {
return await this.graphql("TopicNotInterested", {
body: { variables: { topic_id: topicId } },
});
}
export async function undoNotInterested(topicId) {
return await this.graphql("TopicUndoNotInterested", {
body: { variables: { topic_id: topicId } },
});
}
export async function get(topicId) {
return await this.graphql("TopicByRestId", {
variables: { topicId },
});
}
export async function landingPage(topicId, opts = {}) {
return await this.graphql("TopicLandingPage", {
variables: {
topicId,
count: opts.count || 20,
cursor: opts.cursor,
...opts.variables,
},
fieldToggles: {
withArticlePlainText: false,
withArticleRichContentState: false,
withAuxiliaryUserLabels: false,
},
});
}
export async function toFollow(opts = {}) {
return await this.graphql("TopicToFollowSidebar", {
variables: {
count: opts.count || 20,
cursor: opts.cursor,
...opts.variables,
},
});
}
export async function manage(opts = {}) {
return await this.graphql("TopicsManagementPage", {
variables: {
count: opts.count || 20,
cursor: opts.cursor,
...opts.variables,
},
});
}
export async function picker(opts = {}) {
return await this.graphql("TopicsPickerPage", {
variables: {
count: opts.count || 20,
cursor: opts.cursor,
...opts.variables,
},
});
}
export async function pickerById(topicId, opts = {}) {
return await this.graphql("TopicsPickerPageById", {
variables: {
topicId,
count: opts.count || 20,
cursor: opts.cursor,
...opts.variables,
},
});
}
export async function viewing(userId, opts = {}) {
return await this.graphql("ViewingOtherUsersTopicsPage", {
variables: {
userId,
count: opts.count || 20,
cursor: opts.cursor,
...opts.variables,
},
});
}

View File

@@ -0,0 +1,84 @@
export async function available() {
const res = await this.v1_1("trends/available", {});
return await res.json();
}
export async function history(opts = {}) {
return await this.graphql("TrendHistory", {
variables: {
count: opts.count || 20,
cursor: opts.cursor,
...opts.variables,
},
});
}
export async function relevantUsers(trendName, opts = {}) {
return await this.graphql("TrendRelevantUsers", {
variables: {
trend_name: trendName,
...opts.variables,
},
});
}
export async function explore(opts = {}) {
return await this.graphql("ExplorePage", {
variables: {
count: opts.count || 20,
cursor: opts.cursor,
...opts.variables,
},
fieldToggles: {
withArticlePlainText: false,
withArticleRichContentState: false,
withAuxiliaryUserLabels: false,
},
});
}
export async function exploreSidebar(opts = {}) {
return await this.graphql("ExploreSidebar", {
variables: {
count: opts.count || 20,
cursor: opts.cursor,
...opts.variables,
},
});
}
export async function report(trendId) {
return await this.graphql("ReportTrend", {
body: { variables: { trend_id: trendId } },
});
}
export async function save(trendId) {
return await this.graphql("SaveTrend", {
body: { variables: { trend_id: trendId } },
});
}
export async function action(trendId, action) {
return await this.graphql("ActionTrend", {
body: { variables: { trend_id: trendId, action } },
});
}
export async function getById(trendId) {
return await this.graphql("AiTrendByRestId", {
variables: { trendId },
});
}
export async function exploreSettings() {
const res = await this.v2("guide/get_explore_settings", {});
return await res.json();
}
export async function setExploreSettings(params = {}) {
const res = await this.v2("guide/set_explore_settings", {
body: JSON.stringify(params),
});
return await res.json();
}

View File

@@ -0,0 +1,408 @@
import getCycleTLS from "../cycletls.js";
import parseTweet from "../parsers/tweet.js";
async function createPollCard(instance, poll) {
if (!poll.choices || poll.choices.length < 2)
throw new Error("a poll must have at least 2 choices");
if (poll.choices.length > 4) throw new Error("a poll must not have more than 4 choices");
const hasImages = poll.choices.some((c) => typeof c === "object" && c.image);
const allImages = poll.choices.every((c) => typeof c === "object" && c.image);
if (hasImages && !allImages)
throw new Error("either all poll choices must have images, or none of them");
const labels = poll.choices.map((c) => (typeof c === "object" ? c.label : c));
for (const label of labels) {
if (typeof label !== "string" || label.length === 0)
throw new Error("each poll choice must have a non-empty label");
if (label.length > 25) throw new Error(`poll choice "${label}" exceeds the 25-character limit`);
}
const duration = poll.duration_minutes ?? 1440;
if (duration < 5 || duration > 10080)
throw new Error("poll duration must be between 5 and 10 080 minutes (7 days)");
const cycleTLS = await getCycleTLS();
const cardObj = {
"twitter:api:api:endpoint": "1",
"twitter:long:duration_minutes": duration,
};
if (hasImages) {
cardObj["twitter:card"] = "poll_choice_images";
for (let i = 0; i < poll.choices.length; i++) {
cardObj[`twitter:string:choice${i + 1}_label`] = labels[i];
cardObj[`twitter:image:choice${i + 1}_image:src:id`] = `mis://${poll.choices[i].image}`;
}
} else {
cardObj["twitter:card"] = `poll${poll.choices.length}choice_text_only`;
for (let i = 0; i < labels.length; i++) {
cardObj[`twitter:string:choice${i + 1}_label`] = labels[i];
}
}
const cardData = JSON.stringify(cardObj);
const res = await cycleTLS(
"https://caps.x.com/v2/cards/create.json",
{
headers: {
accept: "*/*",
"accept-language": "en-US,en;q=0.9",
authorization: `Bearer ${instance.auth.client.bearer}`,
"content-type": "application/x-www-form-urlencoded",
"x-csrf-token": instance.auth.csrfToken,
"x-twitter-active-user": "yes",
"x-twitter-auth-type": "OAuth2Session",
"x-twitter-client-language": "en",
priority: "u=1, i",
"sec-ch-ua": "Not(A:Brand;v=8, Chromium;v=144",
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": "macOS",
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-site",
"sec-gpc": "1",
cookie:
instance.auth.client.headers.cookie +
(instance.elevatedCookies ? `; ${instance.elevatedCookies}` : ""),
},
body: `card_data=${encodeURIComponent(cardData)}`,
userAgent:
instance.auth.client.fingerprints?.userAgent ||
instance.auth.client.fingerprints?.["user-agent"],
ja3: instance.auth.client.fingerprints?.ja3,
ja4r: instance.auth.client.fingerprints?.ja4r,
proxy: instance.proxy || undefined,
referrer: "https://x.com/",
},
"post",
);
const data = await res.json();
if (!data?.card_uri) {
throw new Error(`failed to create poll card: ${JSON.stringify(data)}`);
}
return data.card_uri;
}
export async function create(text, opts = {}) {
let cardUri = opts.cardUri;
if (opts.poll) {
if (cardUri) throw new Error("a tweet can\u0027t have both a poll and a cardUri");
cardUri = await createPollCard(this, opts.poll);
}
const mediaIds = opts.mediaIds ? [...opts.mediaIds] : [];
if (opts.gif) {
const gif = opts.gif;
const gifUrl = gif.original_image?.url || gif.url;
if (!gifUrl) throw new Error("gif must have a url or original_image.url");
const provider = gif.found_media_origin?.provider || gif.provider;
const gifId = gif.found_media_origin?.id || gif.id;
const altText = gif.alt_text || gif.altText;
const uploaded = await this.media.createFromUrl(gifUrl, {
origin: provider && gifId ? { provider, id: gifId } : undefined,
altText,
});
mediaIds.push(uploaded.media_id);
}
const res = await this.graphql("CreateTweet", {
body: {
variables: {
tweet_text: text,
dark_request: false,
card_uri: cardUri || undefined,
media: {
media_entities: mediaIds.map((id) => ({
media_id: id,
tagged_users: [],
})),
possibly_sensitive: opts.sensitive || false,
},
semantic_annotation_ids: [],
...(opts.replyTo
? {
reply: {
in_reply_to_tweet_id: opts.replyTo,
exclude_reply_user_ids: [],
},
}
: {}),
...(opts.quoteTweetId
? { attachment_url: `https://x.com/i/status/${opts.quoteTweetId}` }
: {}),
...(opts.conversationControl
? { conversation_control: { mode: opts.conversationControl } }
: {}),
...opts.variables,
},
},
});
const tweet = res?.data?.create_tweet?.tweet_results?.result;
return tweet ? parseTweet(tweet) : res;
}
export async function createNote(text, opts = {}) {
const res = await this.graphql("CreateNoteTweet", {
body: {
variables: {
tweet_text: text,
dark_request: false,
media: {
media_entities:
opts.mediaIds?.map((id) => ({
media_id: id,
tagged_users: [],
})) || [],
possibly_sensitive: opts.sensitive || false,
},
semantic_annotation_ids: [],
richtext_options: { richtext_tags: opts.richtext_tags || [] },
...(opts.replyTo
? {
reply: {
in_reply_to_tweet_id: opts.replyTo,
exclude_reply_user_ids: [],
},
}
: {}),
...opts.variables,
},
},
});
const tweet = res?.data?.notetweet_create?.tweet_results?.result;
return tweet ? parseTweet(tweet) : res;
}
export async function remove(tweetId) {
return await this.graphql("DeleteTweet", {
body: { variables: { tweet_id: tweetId, dark_request: false } },
});
}
export async function like(tweetId) {
return await this.graphql("FavoriteTweet", {
body: { variables: { tweet_id: tweetId } },
});
}
export async function unlike(tweetId) {
return await this.graphql("UnfavoriteTweet", {
body: { variables: { tweet_id: tweetId } },
});
}
export async function retweet(tweetId) {
return await this.graphql("CreateRetweet", {
body: { variables: { tweet_id: tweetId, dark_request: false } },
});
}
export async function unretweet(tweetId) {
return await this.graphql("DeleteRetweet", {
body: { variables: { source_tweet_id: tweetId, dark_request: false } },
});
}
export async function pin(tweetId) {
return await this.graphql("PinTweet", {
body: { variables: { tweet_id: tweetId } },
});
}
export async function unpin(tweetId) {
return await this.graphql("UnpinTweet", {
body: { variables: { tweet_id: tweetId } },
});
}
export async function get(tweetId) {
const res = await this.graphql("TweetResultByRestId", {
variables: {
tweetId,
withCommunity: false,
includePromotedContent: false,
withVoice: false,
},
fieldToggles: {
withArticlePlainText: false,
withArticleRichContentState: false,
withAuxiliaryUserLabels: false,
},
});
const tweet = res?.data?.tweetResult?.result;
return tweet ? parseTweet(tweet) : res;
}
export async function getMany(tweetIds) {
const res = await this.graphql("TweetResultsByRestIds", {
variables: {
tweetIds,
withCommunity: false,
includePromotedContent: false,
withVoice: false,
},
fieldToggles: {
withArticlePlainText: false,
withArticleRichContentState: false,
withAuxiliaryUserLabels: false,
},
});
const results = res?.data?.tweetResult || [];
return Array.isArray(results) ? results.map((r) => (r?.result ? parseTweet(r.result) : r)) : res;
}
export async function detail(tweetId, opts = {}) {
return await this.graphql("TweetDetail", {
variables: {
focalTweetId: tweetId,
with_rux_injections: false,
rankingMode: "Relevance",
includePromotedContent: true,
withCommunity: true,
withQuickPromoteEligibilityTweetFields: true,
withBirdwatchNotes: true,
withVoice: true,
...opts.variables,
},
fieldToggles: {
withArticlePlainText: false,
withArticleRichContentState: false,
withAuxiliaryUserLabels: false,
},
});
}
export async function editHistory(tweetId) {
return await this.graphql("TweetEditHistory", {
variables: { tweetId, withQuickPromoteEligibilityTweetFields: true },
});
}
export async function retweeters(tweetId, opts = {}) {
return await this.graphql("Retweeters", {
variables: {
tweetId,
count: opts.count || 20,
cursor: opts.cursor,
includePromotedContent: false,
},
});
}
export async function highlight(tweetId) {
return await this.graphql("CreateHighlight", {
body: { variables: { tweet_id: tweetId } },
});
}
export async function unhighlight(tweetId) {
return await this.graphql("DeleteHighlight", {
body: { variables: { tweet_id: tweetId } },
});
}
export async function schedule(text, scheduledAt, opts = {}) {
return await this.graphql("CreateScheduledTweet", {
body: {
variables: {
post_tweet_request: {
status: text,
...(opts.mediaIds ? { media_ids: opts.mediaIds } : {}),
...(opts.replyTo ? { in_reply_to_status_id: opts.replyTo } : {}),
auto_populate_reply_metadata: true,
},
execute_at: Math.floor(new Date(scheduledAt).getTime() / 1000),
},
},
});
}
export async function deleteScheduled(scheduledTweetId) {
return await this.graphql("DeleteScheduledTweet", {
body: { variables: { scheduled_tweet_id: scheduledTweetId } },
});
}
export async function getScheduled() {
return await this.graphql("FetchScheduledTweets", {
variables: {},
});
}
export async function moderate(tweetId) {
return await this.graphql("ModerateTweet", {
body: { variables: { tweet_id: tweetId } },
});
}
export async function unmoderate(tweetId) {
return await this.graphql("UnmoderateTweet", {
body: { variables: { tweet_id: tweetId } },
});
}
export async function pinReply(tweetId) {
return await this.graphql("PinReply", {
body: { variables: { tweet_id: tweetId } },
});
}
export async function unpinReply(tweetId) {
return await this.graphql("UnpinReply", {
body: { variables: { tweet_id: tweetId } },
});
}
export async function setConversationControl(tweetId, mode) {
return await this.graphql("ConversationControlChange", {
body: { variables: { tweet_id: tweetId, mode } },
});
}
export async function removeConversationControl(tweetId) {
return await this.graphql("ConversationControlDelete", {
body: { variables: { tweet_id: tweetId } },
});
}
export async function unmention(tweetId) {
return await this.graphql("UnmentionUserFromConversation", {
body: { variables: { tweet_id: tweetId } },
});
}
export async function createThread(items) {
if (!Array.isArray(items) || items.length < 2)
throw new Error("a thread must have at least 2 tweets");
const tweets = [];
let lastId = null;
for (const item of items) {
const opts = typeof item === "string" ? {} : { ...item };
const text = typeof item === "string" ? item : item.text;
if (lastId) opts.replyTo = lastId;
const tweet = await create.call(this, text, opts);
tweets.push(tweet);
lastId = tweet.id;
}
return tweets;
}
export async function similar(tweetId) {
return await this.graphql("SimilarPosts", {
variables: { tweet_id: tweetId },
});
}

View File

@@ -0,0 +1,319 @@
import parseTimeline from "../parsers/timeline.js";
import parseUser from "../parsers/user.js";
export async function get(userId) {
const res = await this.graphql("UserByRestId", {
variables: { userId, withSafetyModeUserFields: true },
fieldToggles: { withAuxiliaryUserLabels: false },
});
const user = res?.data?.user?.result;
return user ? parseUser(user) : res;
}
export async function getByUsername(username) {
const res = await this.graphql("UserByScreenName", {
variables: { screen_name: username, withSafetyModeUserFields: true },
fieldToggles: { withAuxiliaryUserLabels: false },
});
const user = res?.data?.user?.result;
return user ? parseUser(user) : res;
}
export async function getMany(userIds) {
const res = await this.graphql("UsersByRestIds", {
variables: { userIds, withSafetyModeUserFields: true },
fieldToggles: { withAuxiliaryUserLabels: false },
});
const users = res?.data?.users || [];
return Array.isArray(users)
? users.map((u) => (u?.result ? parseUser(u.result) : u))
: res;
}
export async function getManyByUsername(screenNames) {
const res = await this.graphql("UsersByScreenNames", {
variables: { screen_names: screenNames, withSafetyModeUserFields: true },
fieldToggles: { withAuxiliaryUserLabels: false },
});
const users = res?.data?.users || [];
return Array.isArray(users)
? users.map((u) => (u?.result ? parseUser(u.result) : u))
: res;
}
export async function tweets(userId, opts = {}) {
const raw = await this.graphql("UserTweets", {
variables: {
userId,
count: opts.count || 20,
cursor: opts.cursor,
includePromotedContent: true,
withQuickPromoteEligibilityTweetFields: true,
withVoice: true,
withV2Timeline: true,
...opts.variables,
},
fieldToggles: {
withArticlePlainText: false,
withArticleRichContentState: false,
withAuxiliaryUserLabels: false,
},
});
return parseTimeline(raw);
}
export async function replies(userId, opts = {}) {
const raw = await this.graphql("UserTweetsAndReplies", {
variables: {
userId,
count: opts.count || 20,
cursor: opts.cursor,
includePromotedContent: true,
withCommunity: true,
withVoice: true,
withV2Timeline: true,
...opts.variables,
},
fieldToggles: {
withArticlePlainText: false,
withArticleRichContentState: false,
withAuxiliaryUserLabels: false,
},
});
return parseTimeline(raw);
}
export async function userMedia(userId, opts = {}) {
const raw = await this.graphql("UserMedia", {
variables: {
userId,
count: opts.count || 20,
cursor: opts.cursor,
includePromotedContent: false,
withClientEventToken: false,
withBirdwatchNotes: false,
withVoice: true,
withV2Timeline: true,
...opts.variables,
},
fieldToggles: {
withArticlePlainText: false,
withArticleRichContentState: false,
withAuxiliaryUserLabels: false,
},
});
return parseTimeline(raw);
}
export async function highlights(userId, opts = {}) {
const raw = await this.graphql("UserHighlightsTweets", {
variables: {
userId,
count: opts.count || 20,
cursor: opts.cursor,
includePromotedContent: true,
withVoice: true,
...opts.variables,
},
fieldToggles: {
withArticlePlainText: false,
withArticleRichContentState: false,
withAuxiliaryUserLabels: false,
},
});
return parseTimeline(raw);
}
export async function followers(userId, opts = {}) {
const raw = await this.graphql("Followers", {
variables: {
userId,
count: opts.count || 20,
cursor: opts.cursor,
includePromotedContent: false,
},
});
return parseTimeline(raw);
}
export async function following(userId, opts = {}) {
const raw = await this.graphql("Following", {
variables: {
userId,
count: opts.count || 20,
cursor: opts.cursor,
includePromotedContent: false,
},
});
return parseTimeline(raw);
}
export async function verifiedFollowers(userId, opts = {}) {
const raw = await this.graphql("BlueVerifiedFollowers", {
variables: {
userId,
count: opts.count || 20,
cursor: opts.cursor,
includePromotedContent: false,
},
});
return parseTimeline(raw);
}
export async function followersYouKnow(userId, opts = {}) {
const raw = await this.graphql("FollowersYouKnow", {
variables: {
userId,
count: opts.count || 20,
cursor: opts.cursor,
includePromotedContent: false,
},
});
return parseTimeline(raw);
}
export async function follow(userId) {
const res = await this.v1_1("friendships/create", {
body: JSON.stringify({ user_id: userId }),
});
const json = await res.json();
return json ? parseUser(json) : res;
}
export async function unfollow(userId) {
const res = await this.v1_1("friendships/destroy", {
body: JSON.stringify({ user_id: userId }),
});
const json = await res.json();
return json ? parseUser(json) : res;
}
export async function block(userId) {
const res = await this.v1_1("blocks/create", {
body: JSON.stringify({ user_id: userId }),
});
const json = await res.json();
return json ? parseUser(json) : res;
}
export async function unblock(userId) {
const res = await this.v1_1("blocks/destroy", {
body: JSON.stringify({ user_id: userId }),
});
const json = await res.json();
return json ? parseUser(json) : res;
}
export async function mute(userId) {
const res = await this.v1_1("mutes/users/create", {
body: JSON.stringify({ user_id: userId }),
});
const json = await res.json();
return json ? parseUser(json) : res;
}
export async function unmute(userId) {
const res = await this.v1_1("mutes/users/destroy", {
body: JSON.stringify({ user_id: userId }),
});
const json = await res.json();
return json ? parseUser(json) : res;
}
export async function removeFollower(userId) {
return await this.graphql("RemoveFollower", {
body: { variables: { target_user_id: userId } },
});
}
export async function blocked(opts = {}) {
const raw = await this.graphql("BlockedAccountsAll", {
variables: {
count: opts.count || 20,
cursor: opts.cursor,
includePromotedContent: false,
},
});
return parseTimeline(raw);
}
export async function muted(opts = {}) {
const raw = await this.graphql("MutedAccounts", {
variables: {
count: opts.count || 20,
cursor: opts.cursor,
includePromotedContent: false,
},
});
return parseTimeline(raw);
}
export async function lookup(params = {}) {
const res = await this.v1_1("users/lookup", { params });
return await res.json();
}
export async function updateProfile(params = {}) {
const res = await this.v1_1("account/update_profile", {
body: JSON.stringify(params),
});
const json = await res.json();
return json ? parseUser(json) : res;
}
export async function updateProfileImage(imageData) {
const res = await this.v1_1("account/update_profile_image", {
body: JSON.stringify({ image: imageData }),
});
return await res.json();
}
export async function updateProfileBanner(bannerData) {
const res = await this.v1_1("account/update_profile_banner", {
body: JSON.stringify({ banner: bannerData }),
});
return await res.json();
}
export async function removeProfileBanner() {
const res = await this.v1_1("account/remove_profile_banner", {});
return await res.json();
}
export async function subscriptions(userId, opts = {}) {
const raw = await this.graphql("UserCreatorSubscriptions", {
variables: {
userId,
count: opts.count || 20,
cursor: opts.cursor,
},
});
return parseTimeline(raw);
}
export async function subscribers(userId, opts = {}) {
const raw = await this.graphql("UserCreatorSubscribers", {
variables: {
userId,
count: opts.count || 20,
cursor: opts.cursor,
},
});
return parseTimeline(raw);
}
export async function superFollowers(opts = {}) {
const raw = await this.graphql("SuperFollowers", {
variables: {
count: opts.count || 20,
cursor: opts.cursor,
includePromotedContent: false,
},
});
return parseTimeline(raw);
}
export async function recommendations(params = {}) {
const res = await this.v1_1("users/recommendations", { params });
return await res.json();
}

196
src/emusks-local/index.js Normal file
View File

@@ -0,0 +1,196 @@
import { ClientTransaction, handleXMigration } from "x-client-transaction-id";
import clients from "./clients.js";
import getCycleTLS from "./cycletls.js";
import flowLogin from "./flow.js";
import graphql, { GRAPHQL_ENDPOINTS } from "./graphql.js";
import initHelpers from "./helpers/index.js";
import parseUser from "./parsers/user.js";
import v1_1 from "./v1.1.js";
import v2 from "./v2.js";
export default class Emusks {
auth = null;
elevatedCookies = null;
graphqlEndpoint = "web";
transactionIds = undefined;
async elevate(password) {
if (!this.auth) throw new Error("must be logged in before calling elevate");
const res = await this.v1_1("account/verify_password", {
body: `password=${encodeURIComponent(password)}`,
headers: {
"content-type": "application/x-www-form-urlencoded",
},
});
const setCookies = res.headers["Set-Cookie"] || [];
const json = await res.json();
if (json.status !== "ok") {
throw new Error("invalid password");
}
const cookieParts = [];
for (const setCookie of setCookies) {
const cookiePair = setCookie.split(";")[0];
if (cookiePair) {
cookieParts.push(cookiePair);
}
}
this.elevatedCookies = cookieParts.join("; ");
return json;
}
async login(p) {
if (typeof p === "string") {
if (p.length > 50 || p.length < 20) {
throw new Error("invalid auth token length!");
}
p = { auth_token: p };
}
if (p.type === "password") {
if (!p.username) throw new Error("username is required for password login");
if (!p.password) throw new Error("password is required for password login");
const flowResult = await flowLogin({
username: p.username,
password: p.password,
email: p.email,
phone: p.phone,
onRequest: p.onRequest,
proxy: p.proxy,
});
p = {
auth_token: flowResult.authToken,
client: p.client,
proxy: p.proxy,
};
}
if (!p.client) p.client = "web";
if (!p.auth_token) throw new Error("auth_token is required!");
if (typeof p.client === "string") p.client = clients[p.client];
if (!p.client) throw new Error("invalid client!");
if (p.proxy) this.proxy = p.proxy;
if (p.endpoint) {
if (!GRAPHQL_ENDPOINTS[p.endpoint]) {
throw new Error(
`unknown graphql endpoint "${p.endpoint}", expected: ${Object.keys(GRAPHQL_ENDPOINTS).join(", ")}`,
);
}
this.graphqlEndpoint = p.endpoint;
}
if (p.transactionIds !== undefined) {
this.transactionIds = p.transactionIds;
}
if (!p.client.bearer) {
throw new Error("client is missing bearer token!");
}
if (!p.client.userAgent || !p.client.fingerprints) {
p.client.userAgent =
p.client.userAgent ||
"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";
p.client.fingerprints = p.client.fingerprints || {
ja3: "771,4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53,35-5-27-16-0-10-13-23-45-65037-17613-18-65281-51-43-11,4588-29-23-24,0",
ja4r: "t13d1516h2_002f,0035,009c,009d,1301,1302,1303,c013,c014,c02b,c02c,c02f,c030,cca8,cca9_0005,000a,000b,000d,0012,0017,001b,0023,002b,002d,0033,44cd,fe0d,ff01_0403,0804,0401,0503,0805,0501,0806,0601",
};
}
p.client.headers = {
"accept-language": "en-US,en;q=0.9",
priority: "u=1, i",
"sec-ch-ua": '"Not(A:Brand";v="8", "Chromium";v="144"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"macOS"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-site",
"sec-gpc": "1",
};
const cycleTLS = await getCycleTLS();
const res = await cycleTLS("https://x.com/", {
headers: {
accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
cookie: `auth_token=${p.auth_token};`,
...p.client.headers,
},
userAgent: p.client.fingerprints.userAgent,
ja3: p.client.fingerprints.ja3,
ja4r: p.client.fingerprints.ja4r,
proxy: p.proxy || undefined,
referrer: "https://x.com/",
});
const setCookies = res.headers["Set-Cookie"] || [];
const csrfToken = setCookies
.find((c) => c?.startsWith?.("ct0="))
?.split?.(";")?.[0]
?.split?.("=")?.[1];
if (!csrfToken) {
throw new Error("[emusks] failed to log in");
}
this.auth = p;
this.auth.csrfToken = csrfToken;
const cookieParts = [`auth_token=${p.auth_token}`];
for (const setCookie of setCookies) {
const cookiePair = setCookie.split(";")[0];
if (cookiePair && !cookiePair.startsWith("auth_token=")) {
cookieParts.push(cookiePair);
}
}
this.auth.client.headers.cookie = cookieParts.join("; ");
const needsTransactionIds =
this.transactionIds !== undefined
? this.transactionIds
: this.graphqlEndpoint === "web" || this.graphqlEndpoint === "web_twitter";
if (needsTransactionIds) {
const document = await handleXMigration();
const transaction = new ClientTransaction(document);
await transaction.initialize();
this.auth.generateTransactionId = transaction.generateTransactionId.bind(transaction);
}
const responseText = await res.text();
const initialStateMatch = responseText.match(/window\.__INITIAL_STATE__\s*=\s*({.*?});/s);
if (!initialStateMatch) {
console.warn("[emusks] failed to extract initial state from response");
return;
}
const initialState = JSON.parse(initialStateMatch[1]);
const usersEntities = initialState?.entities?.users?.entities;
const initialStateUser = usersEntities && Object.values(usersEntities)[0];
if (!initialStateUser) {
console.warn("[emusks] failed to extract user from initial state");
return;
}
this.user = parseUser(initialStateUser);
this.settings = initialState?.settings?.remote?.settings;
return this.user;
}
}
Emusks.prototype.graphql = graphql;
Emusks.prototype.v1_1 = v1_1;
Emusks.prototype.v2 = v2;
initHelpers(Emusks.prototype);

504
src/emusks-local/package-lock.json generated Normal file
View File

@@ -0,0 +1,504 @@
{
"name": "emusks",
"version": "2.0.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "emusks",
"version": "2.0.12",
"license": "AGPL-3.0-only",
"dependencies": {
"cycletls": "^2.0.5",
"x-client-transaction-id": "^0.1.9"
}
},
"node_modules/@types/node": {
"version": "20.19.37",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC"
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/cssom": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz",
"integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==",
"license": "MIT"
},
"node_modules/cycletls": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/cycletls/-/cycletls-2.0.5.tgz",
"integrity": "sha512-Nud94JBPZu1JTXWJ1GpIoQNR1xxU84WYt7G0dxzwpUe1xAAs6YbihvlQhIhPv9xljjonINJfWPFaeb8Ex1wLhQ==",
"license": "GPL3",
"dependencies": {
"@types/node": "^20.14.0",
"form-data": "^4.0.0",
"ws": "^8.17.0"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/html-escaper": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
"integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",
"license": "MIT"
},
"node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"entities": "^7.0.1"
}
},
"node_modules/htmlparser2/node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/linkedom": {
"version": "0.18.12",
"resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.12.tgz",
"integrity": "sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q==",
"license": "ISC",
"dependencies": {
"css-select": "^5.1.0",
"cssom": "^0.5.0",
"html-escaper": "^3.0.3",
"htmlparser2": "^10.0.0",
"uhyphen": "^0.2.0"
},
"engines": {
"node": ">=16"
},
"peerDependencies": {
"canvas": ">= 2"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0"
},
"funding": {
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/uhyphen": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",
"integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",
"license": "ISC"
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"license": "MIT"
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/x-client-transaction-id": {
"version": "0.1.9",
"resolved": "https://registry.npmjs.org/x-client-transaction-id/-/x-client-transaction-id-0.1.9.tgz",
"integrity": "sha512-CES4zgkJ0wbfFWm0qgdKphthyb+L7lVHymgOY15v6ivcWSx5p9lp5kzAed+BuqJSP7bS0GbQyJ16ONkRthgsUw==",
"license": "MIT",
"dependencies": {
"linkedom": "^0.18.9"
}
}
}
}

View File

@@ -0,0 +1,31 @@
{
"name": "emusks",
"version": "2.0.12",
"description": "Reverse-engineered Twitter API client. Log in and interact with the unofficial X API using any client identity — web, Android, iOS, or TweetDeck",
"keywords": [
"client",
"reverse-engineering",
"twitter",
"twitter-api",
"x",
"x-api"
],
"homepage": "https://github.com/tiagozip/emusks",
"bugs": {
"url": "https://github.com/tiagozip/emusks/issues"
},
"license": "AGPL-3.0-only",
"repository": {
"type": "git",
"url": "https://github.com/tiagozip/emusks.git"
},
"type": "module",
"main": "src/index.js",
"exports": {
".": "./src/index.js"
},
"dependencies": {
"cycletls": "^2.0.5",
"x-client-transaction-id": "^0.1.9"
}
}

View File

@@ -0,0 +1,105 @@
import parseTweet from "./tweet.js";
import parseUser from "./user.js";
function findInstructions(obj) {
if (!obj || typeof obj !== "object") return null;
if (Array.isArray(obj.instructions)) {
return obj.instructions;
}
for (const key of Object.keys(obj)) {
const val = obj[key];
if (val && typeof val === "object") {
const found = findInstructions(val);
if (found) return found;
}
}
return null;
}
function extractTweetFromEntry(entry) {
const tweetResult =
entry?.content?.itemContent?.tweet_results?.result ||
entry?.item?.itemContent?.tweet_results?.result;
if (tweetResult) {
return parseTweet(tweetResult);
}
return null;
}
function extractUserFromEntry(entry) {
const userResult =
entry?.content?.itemContent?.user_results?.result ||
entry?.item?.itemContent?.user_results?.result;
if (userResult) {
return parseUser(userResult);
}
return null;
}
export default function parseTimeline(raw) {
const instructions = findInstructions(raw?.data || raw);
if (!instructions) {
return { tweets: [], users: [], nextCursor: null, previousCursor: null, raw };
}
const tweets = [];
const users = [];
let nextCursor = null;
let previousCursor = null;
for (const instruction of instructions) {
if (instruction.type === "TimelineAddEntries" || instruction.entries) {
for (const entry of instruction.entries || []) {
processEntry(entry, tweets, users);
if (entry.entryId?.startsWith("cursor-bottom") || entry.entryId?.includes("cursor-bottom")) {
nextCursor =
entry.content?.value || entry.content?.itemContent?.value || null;
}
if (entry.entryId?.startsWith("cursor-top") || entry.entryId?.includes("cursor-top")) {
previousCursor =
entry.content?.value || entry.content?.itemContent?.value || null;
}
}
}
if (instruction.type === "TimelineReplaceEntry" && instruction.entry) {
processEntry(instruction.entry, tweets, users);
if (instruction.entry.entryId?.includes("cursor-bottom")) {
nextCursor =
instruction.entry.content?.value ||
instruction.entry.content?.itemContent?.value ||
null;
}
}
}
return { tweets, users, nextCursor, previousCursor, raw };
}
function processEntry(entry, tweets, users) {
const tweet = extractTweetFromEntry(entry);
if (tweet) {
tweets.push(tweet);
return;
}
const user = extractUserFromEntry(entry);
if (user) {
users.push(user);
return;
}
if (entry.content?.items) {
for (const item of entry.content.items) {
const t = extractTweetFromEntry(item);
if (t) tweets.push(t);
const u = extractUserFromEntry(item);
if (u) users.push(u);
}
}
}

View File

@@ -0,0 +1,82 @@
import parseUser from "./user.js";
export default function parseTweet(tweet) {
const get = (obj, path, fallback = undefined) =>
path
.split(".")
.reduce((o, k) => (o && o[k] !== undefined ? o[k] : fallback), obj);
const data = tweet.data?.tweet || tweet;
const legacy = data.legacy || data;
const core = data.core || {};
const views = data.views || {};
return {
id: tweet.rest_id || legacy.id_str || tweet.id || tweet.id_str,
text: legacy.full_text || legacy.text || tweet.text,
created_at: legacy.created_at || tweet.created_at,
conversation_id: legacy.conversation_id_str || tweet.conversation_id_str,
in_reply_to_status_id:
legacy.in_reply_to_status_id_str || tweet.in_reply_to_status_id_str,
in_reply_to_user_id:
legacy.in_reply_to_user_id_str || tweet.in_reply_to_user_id_str,
in_reply_to_screen_name:
legacy.in_reply_to_screen_name || tweet.in_reply_to_screen_name,
user: (() => {
const raw =
get(core, "user_results.result") ||
get(data, "core.user_results.result") ||
data.user;
return raw ? parseUser(raw) : null;
})(),
stats: {
retweets: legacy.retweet_count || tweet.retweet_count || 0,
likes: legacy.favorite_count || tweet.favorite_count || 0,
replies: legacy.reply_count || tweet.reply_count || 0,
quotes: legacy.quote_count || tweet.quote_count || 0,
bookmarks: legacy.bookmark_count || tweet.bookmark_count || 0,
views: get(views, "count") || 0,
},
engagement: {
retweeted: legacy.retweeted || tweet.retweeted || false,
liked: legacy.favorited || tweet.favorited || false,
bookmarked: legacy.bookmarked || tweet.bookmarked || false,
},
media:
tweet.extended_entities?.media ||
legacy.extended_entities?.media ||
legacy.entities?.media ||
tweet.entities?.media ||
[],
urls: legacy.entities?.urls || tweet.entities?.urls || [],
hashtags: legacy.entities?.hashtags || tweet.entities?.hashtags || [],
user_mentions:
legacy.entities?.user_mentions || tweet.entities?.user_mentions || [],
source: legacy.source || tweet.source,
lang: legacy.lang || tweet.lang,
quoting: get(tweet, "quoted_status_result.result")
? parseTweet(get(tweet, "quoted_status_result.result"))
: null,
edit_control: data.edit_control || {},
card: data.card || null,
unmention_data: data.unmention_data || {},
misc: {
display_text_range: legacy.display_text_range || tweet.display_text_range,
is_translatable: tweet.is_translatable || false,
possibly_sensitive:
legacy.possibly_sensitive || tweet.possibly_sensitive || false,
withheld_copyright:
legacy.withheld_copyright || tweet.withheld_copyright || false,
withheld_in_countries:
legacy.withheld_in_countries || tweet.withheld_in_countries || [],
},
};
}

View File

@@ -0,0 +1,142 @@
export default function parseUser(user) {
if (typeof user?.stats?.followers?.count === "number") {
// we can safely assume this has already been parsed
return user;
}
const get = (obj, path, fallback = undefined) =>
path
.split(".")
.reduce((o, k) => (o && o[k] !== undefined ? o[k] : fallback), obj);
const data = user.data?.user || user;
const legacy = data.legacy || data;
const core = data.core || {};
const locationObj = data.location || {};
const verification = data.verification || {};
const parodyLabel =
data.parody_commentary_fan_label || legacy.parody_commentary_fan_label;
const highlightedLabel =
get(data, "affiliates_highlighted_label.label") || data.highlightedLabel;
return {
id: data.rest_id || legacy.id_str || data.id || data.id_str,
name: core.name || legacy.name || data.name,
username:
core.screen_name ||
legacy.screen_name ||
data.screen_name ||
data.username,
description: legacy.description || data.description,
banner:
legacy.profile_banner_url ||
legacy.profile_background_image_url ||
legacy.profile_background_image_url_https ||
data.profile_banner_url ||
data.profile_background_image_url ||
data.profile_background_image_url_https,
url: legacy.url || data.url,
location: locationObj.location || legacy.location || data.location || null,
protected: get(
data,
"privacy.protected",
legacy.protected || data.protected
),
created_at: get(core, "created_at", legacy.created_at || data.created_at),
backgroundColor:
legacy.profile_background_color || data.profile_background_color,
profile_picture: {
url:
get(data, "avatar.image_url") ||
legacy.profile_image_url_https ||
data.profile_image_url_https ||
"https://abs.twimg.com/sticky/default_profile_images/default_profile_normal.png",
shape:
data.profile_image_shape ||
data.ext_profile_image_shape ||
legacy.profile_image_shape ||
"Circle",
},
stats: {
followers: {
count: legacy.followers_count || data.followers_count,
fast_followers:
legacy.fast_followers_count || data.fast_followers_count || 0,
normal_followers:
legacy.normal_followers_count || data.normal_followers_count,
},
following: legacy.friends_count || data.friends_count,
subscriptions_count:
data.creator_subscriptions_count ||
legacy.creator_subscriptions_count ||
0,
likes: legacy.favourites_count || data.favourites_count,
listed: legacy.listed_count || data.listed_count,
media: legacy.media_count || data.media_count,
posts: legacy.statuses_count || data.statuses_count,
},
verification: {
verified: get(verification, "verified", legacy.verified || data.verified),
premium_verified:
data.is_blue_verified ||
data.ext_is_blue_verified ||
data.premium_verified ||
data.premium_verified_type ||
false,
},
pinned_tweets:
legacy.pinned_tweet_ids_str || data.pinned_tweet_ids_str || [],
birthdate: data.birthdate || {},
misc: {
default_profile: legacy.default_profile || data.default_profile,
default_profile_image:
legacy.default_profile_image || data.default_profile_image || false,
entities: legacy.entities || data.entities,
has_custom_timelines:
legacy.has_custom_timelines || data.has_custom_timelines,
is_translator: legacy.is_translator || data.is_translator || false,
translator_type: legacy.translator_type || data.translator_type,
is_profile_translatable: data.is_profile_translatable,
needs_phone_verification: data.needs_phone_verification,
possibly_sensitive: legacy.possibly_sensitive || data.possibly_sensitive,
profile_interstitial_type:
legacy.profile_interstitial_type || data.profile_interstitial_type,
want_retweets: legacy.want_retweets || data.want_retweets,
withheld_in_countries:
legacy.withheld_in_countries || data.withheld_in_countries || [],
tipjar_settings: data.tipjar_settings || {},
can_dm: get(data, "dm_permissions.can_dm", data.can_dm),
can_media_tag: get(
data,
"media_permissions.can_media_tag",
data.can_media_tag
),
blocked_by: get(
data,
"relationship_perspectives.followed_by",
data.blocked_by
),
blocking: data.blocking,
following: get(
data,
"relationship_perspectives.following",
data.following
),
muting: data.muting,
has_graduated_access: data.has_graduated_access,
geo_enabled: data.geo_enabled,
advertising: {
service_levels: data.advertiser_account_service_levels || [],
account_type: data.advertiser_account_type || "none",
},
},
labels: {
parody_commentary_fan_label: parodyLabel || "None",
highlightedLabel: highlightedLabel,
},
};
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
export default {"badge_count/badge_count":["get","https://x.com/i/api/2/badge_count/badge_count.json"],"guide/get_explore_settings":["get","https://x.com/i/api/2/guide/get_explore_settings.json"],"guide/set_explore_settings":["post","https://x.com/i/api/2/guide/set_explore_settings.json"],"guide/explore_locations_with_auto_complete":["get","https://x.com/i/api/2/guide/explore_locations_with_auto_complete.json"],"timeline/reactive":["get","https://x.com/i/api/2/timeline/reactive.json"],"moments/list_user_moments":["get","https://x.com/i/api/2/moments/list_user_moments.json"],"guide":["get","https://x.com/i/api/2/guide.json"],"guide/topic":["get","https://x.com/i/api/2/guide/topic.json"],"onboarding/fetch_user_recommendations_urt":["get","https://x.com/i/api/2/onboarding/fetch_user_recommendations_urt.json"],"people_discovery/modules_urt":["get","https://x.com/i/api/2/people_discovery/modules_urt.json"],"search/adaptive":["get","https://x.com/i/api/2/search/adaptive.json"],"timeline/fixture":["get","https://x.com/i/api/2/timeline/fixture.json"]};

64
src/emusks-local/v1.1.js Normal file
View File

@@ -0,0 +1,64 @@
import getCycleTLS from "./cycletls.js";
import v1_1Api from "./static/v1.1.js";
export default async function v1_1(queryName, { params, body, headers } = {}) {
let entry = v1_1Api[queryName];
if (!entry) {
throw new Error(`v1.1 endpoint ${queryName} not found`);
}
const [method, baseUrl] = entry;
let finalUrl = baseUrl;
if (params && Object.keys(params).length) {
const searchParams = new URLSearchParams(params);
const separator = baseUrl.includes("?") ? "&" : "?";
finalUrl = `${baseUrl}${separator}${searchParams.toString()}`;
}
const url = new URL(finalUrl);
const pathname = url.pathname;
const requestHeaders = {
accept: "*/*",
"accept-language": "en-US,en;q=0.9",
authorization: `Bearer ${this.auth.client.bearer}`,
"content-type": "application/json",
"x-csrf-token": this.auth.csrfToken,
"x-twitter-active-user": "yes",
"x-twitter-auth-type": "OAuth2Session",
"x-twitter-client-language": "en",
"x-client-transaction-id": await this.auth.generateTransactionId(
method.toUpperCase(),
pathname,
),
priority: "u=1, i",
"sec-ch-ua": '"Not(A:Brand";v="8", "Chromium";v="144"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"macOS"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin",
"sec-gpc": "1",
cookie:
this.auth.client.headers.cookie + (this.elevatedCookies ? `; ${this.elevatedCookies}` : ""),
...headers,
};
const cycleTLS = await getCycleTLS();
return await cycleTLS(
finalUrl,
{
headers: requestHeaders,
userAgent: this.auth.client.fingerprints.userAgent,
ja3: this.auth.client.fingerprints.ja3,
ja4r: this.auth.client.fingerprints.ja4r,
body: body || undefined,
proxy: this.proxy || undefined,
referrer: "https://x.com/",
},
method,
);
}

64
src/emusks-local/v2.js Normal file
View File

@@ -0,0 +1,64 @@
import getCycleTLS from "./cycletls.js";
import v2Api from "./static/v2.js";
export default async function v2(queryName, { params, body, headers } = {}) {
let entry = v2Api[queryName];
if (!entry) {
throw new Error(`v2 endpoint ${queryName} not found`);
}
const [method, baseUrl] = entry;
let finalUrl = baseUrl;
if (params && Object.keys(params).length) {
const searchParams = new URLSearchParams(params);
const separator = baseUrl.includes("?") ? "&" : "?";
finalUrl = `${baseUrl}${separator}${searchParams.toString()}`;
}
const url = new URL(finalUrl);
const pathname = url.pathname;
const requestHeaders = {
accept: "*/*",
"accept-language": "en-US,en;q=0.9",
authorization: `Bearer ${this.auth.client.bearer}`,
"content-type": "application/json",
"x-csrf-token": this.auth.csrfToken,
"x-twitter-active-user": "yes",
"x-twitter-auth-type": "OAuth2Session",
"x-twitter-client-language": "en",
"x-client-transaction-id": await this.auth.generateTransactionId(
method.toUpperCase(),
pathname,
),
priority: "u=1, i",
"sec-ch-ua": '"Not(A:Brand";v="8", "Chromium";v="144"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"macOS"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin",
"sec-gpc": "1",
cookie:
this.auth.client.headers.cookie + (this.elevatedCookies ? `; ${this.elevatedCookies}` : ""),
...headers,
};
const cycleTLS = await getCycleTLS();
return await cycleTLS(
finalUrl,
{
headers: requestHeaders,
userAgent: this.auth.client.fingerprints.userAgent,
ja3: this.auth.client.fingerprints.ja3,
ja4r: this.auth.client.fingerprints.ja4r,
body: body || undefined,
proxy: this.proxy || undefined,
referrer: "https://x.com/",
},
method,
);
}