9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
195 lines
7.8 KiB
JavaScript
195 lines
7.8 KiB
JavaScript
/**
|
|
* Twitter `/i/timeline` device-follow notification stream, i.e. the
|
|
* curated tweet list aggregated under a bell-icon "new posts from
|
|
* @userA and N others" notification. Direct GET /i/timeline redirects
|
|
* to /home; the data is only reachable via the legacy v1.1 REST
|
|
* endpoint `/i/api/2/notifications/device_follow.json`.
|
|
*
|
|
* Endpoint discovery and field-mapping originally proposed by @traddo
|
|
* in issue #1628.
|
|
*/
|
|
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
|
|
import { describeTwitterApiError } from './shared.js';
|
|
|
|
const DEVICE_FOLLOW_PATH = '/i/api/2/notifications/device_follow.json';
|
|
const MAX_LIMIT = 200;
|
|
|
|
function parseLimit(value) {
|
|
if (value === undefined || value === null || value === '') return 20;
|
|
const limit = Number(value);
|
|
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {
|
|
throw new ArgumentError(`--limit must be an integer between 1 and ${MAX_LIMIT}`);
|
|
}
|
|
return limit;
|
|
}
|
|
|
|
function buildDeviceFollowUrl(count) {
|
|
const params = new URLSearchParams({
|
|
include_profile_interstitial_type: '1',
|
|
include_blocking: '1',
|
|
include_blocked_by: '1',
|
|
include_followed_by: '1',
|
|
include_want_retweets: '1',
|
|
include_mute_edge: '1',
|
|
include_can_dm: '1',
|
|
include_can_media_tag: '1',
|
|
include_ext_has_nft_avatar: '1',
|
|
include_ext_is_blue_verified: '1',
|
|
include_ext_verified_type: '1',
|
|
skip_status: '1',
|
|
cards_platform: 'Web-12',
|
|
include_cards: '1',
|
|
include_ext_alt_text: 'true',
|
|
include_quote_count: 'true',
|
|
include_reply_count: '1',
|
|
tweet_mode: 'extended',
|
|
include_ext_views: 'true',
|
|
count: String(count),
|
|
});
|
|
return `${DEVICE_FOLLOW_PATH}?${params.toString()}`;
|
|
}
|
|
|
|
function extractEntries(timeline) {
|
|
if (!timeline || !Array.isArray(timeline.instructions)) return null;
|
|
const out = [];
|
|
for (const inst of timeline.instructions) {
|
|
const entries = inst?.addEntries?.entries;
|
|
if (Array.isArray(entries)) out.push(...entries);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function joinEntryToTweet(entry, tweets, users) {
|
|
const tweetId = entry?.content?.item?.content?.tweet?.id;
|
|
if (!tweetId) return null;
|
|
const tw = tweets?.[tweetId];
|
|
if (!tw) return null;
|
|
const user = users?.[tw.user_id_str] || null;
|
|
if (typeof user?.screen_name !== 'string' || !user.screen_name) return null;
|
|
return { tweetId, tweet: tw, user };
|
|
}
|
|
|
|
function shapeRow({ tweetId, tweet, user }) {
|
|
const screenName = user.screen_name;
|
|
return {
|
|
id: tweetId,
|
|
author: screenName,
|
|
text: tweet?.full_text || tweet?.text || '',
|
|
likes: tweet?.favorite_count ?? 0,
|
|
retweets: tweet?.retweet_count ?? 0,
|
|
replies: tweet?.reply_count ?? 0,
|
|
// The legacy v1.1 endpoint does not return view counts even with
|
|
// include_ext_views=true; surface null rather than a 0 sentinel
|
|
// that would lie about real engagement (typed-errors §3).
|
|
views: null,
|
|
created_at: tweet?.created_at || '',
|
|
url: `https://x.com/${screenName}/status/${tweetId}`,
|
|
};
|
|
}
|
|
|
|
function parseDeviceFollow(payload, seen) {
|
|
if (!payload?.globalObjects || typeof payload.globalObjects !== 'object') return null;
|
|
const tweets = payload?.globalObjects?.tweets || {};
|
|
const users = payload?.globalObjects?.users || {};
|
|
if (typeof tweets !== 'object' || typeof users !== 'object') return null;
|
|
const entries = extractEntries(payload?.timeline);
|
|
if (!entries) return null;
|
|
const rows = [];
|
|
let unmatchedTweetEntries = 0;
|
|
let malformedEntries = 0;
|
|
for (const entry of entries) {
|
|
const hasTweetEntry = Boolean(entry?.content?.item?.content?.tweet?.id);
|
|
if (!hasTweetEntry) {
|
|
malformedEntries++;
|
|
continue;
|
|
}
|
|
const joined = joinEntryToTweet(entry, tweets, users);
|
|
if (!joined) {
|
|
unmatchedTweetEntries++;
|
|
continue;
|
|
}
|
|
if (seen.has(joined.tweetId)) continue;
|
|
seen.add(joined.tweetId);
|
|
rows.push(shapeRow(joined));
|
|
}
|
|
return { rows, entryCount: entries.length, unmatchedTweetEntries, malformedEntries };
|
|
}
|
|
|
|
cli({
|
|
site: 'twitter',
|
|
name: 'device-follow',
|
|
access: 'read',
|
|
description: 'Read the /i/timeline device-follow notification stream (tweets aggregated under a bell-icon "new posts from @userA and N others" notification)',
|
|
domain: 'x.com',
|
|
strategy: Strategy.COOKIE,
|
|
browser: true,
|
|
args: [
|
|
{ name: 'limit', type: 'int', default: 20, help: `Maximum number of tweets to return (1-${MAX_LIMIT}, default 20)` },
|
|
{ name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank by weighted engagement and return the top N. Default 0 keeps upstream ordering.' },
|
|
],
|
|
columns: ['id', 'author', 'text', 'likes', 'retweets', 'replies', 'views', 'created_at', 'url'],
|
|
func: async (page, kwargs) => {
|
|
const limit = parseLimit(kwargs.limit);
|
|
const cookies = await page.getCookies({ url: 'https://x.com' });
|
|
const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
|
|
if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
|
|
const apiUrl = buildDeviceFollowUrl(limit);
|
|
const headers = JSON.stringify({
|
|
Authorization: `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
|
|
'X-Csrf-Token': ct0,
|
|
'X-Twitter-Auth-Type': 'OAuth2Session',
|
|
'X-Twitter-Active-User': 'yes',
|
|
});
|
|
const data = await page.evaluate(`async () => {
|
|
try {
|
|
const r = await fetch("${apiUrl}", { method: "GET", headers: ${headers}, credentials: 'include' });
|
|
if (!r.ok) return { error: r.status };
|
|
try {
|
|
return await r.json();
|
|
} catch (e) {
|
|
return { errorKind: 'non_json', detail: String(e && e.message || e) };
|
|
}
|
|
} catch (e) {
|
|
return { errorKind: 'exception', detail: String(e && e.message || e) };
|
|
}
|
|
}`);
|
|
if (data?.errorKind === 'non_json') {
|
|
throw new CommandExecutionError(`Twitter device-follow returned non-JSON response: ${data.detail || 'unknown parse error'}`);
|
|
}
|
|
if (data?.errorKind === 'exception') {
|
|
throw new CommandExecutionError(`Twitter device-follow fetch failed: ${data.detail || 'unknown error'}`);
|
|
}
|
|
if (data?.error) {
|
|
if (data.error === 401 || data.error === 403) {
|
|
throw new AuthRequiredError('x.com', `Twitter device-follow returned HTTP ${data.error}`);
|
|
}
|
|
throw new CommandExecutionError(describeTwitterApiError('device_follow', data.error));
|
|
}
|
|
const parsed = parseDeviceFollow(data, new Set());
|
|
if (!parsed) {
|
|
throw new CommandExecutionError('Twitter device-follow response was missing the expected timeline/globalObjects shape.');
|
|
}
|
|
if (parsed.malformedEntries > 0 || parsed.unmatchedTweetEntries > 0) {
|
|
throw new CommandExecutionError('Twitter device-follow entries could not be joined to tweet/user objects.');
|
|
}
|
|
if (parsed.rows.length === 0) {
|
|
throw new EmptyResultError('twitter device-follow', 'No device-follow notification tweets found.');
|
|
}
|
|
const rows = parsed.rows;
|
|
const trimmed = rows.slice(0, limit);
|
|
return applyTopByEngagement(trimmed, kwargs['top-by-engagement']);
|
|
},
|
|
});
|
|
|
|
export const __test__ = {
|
|
buildDeviceFollowUrl,
|
|
extractEntries,
|
|
joinEntryToTweet,
|
|
shapeRow,
|
|
parseDeviceFollow,
|
|
parseLimit,
|
|
};
|