Skip to content

Commit a7ff46b

Browse files
committed
add: source Twitter
1 parent 191634e commit a7ff46b

2 files changed

Lines changed: 250 additions & 1 deletion

File tree

config.default.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,10 @@ export default {
265265
enabled: true
266266
},
267267
tumblr: {
268-
enabled: true
268+
enabled: true
269+
},
270+
twitter: {
271+
enabled: true
269272
},
270273
lastfm: {
271274
enabled: true

src/sources/twitter.js

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
import { PassThrough } from 'node:stream'
2+
import { encodeTrack, http1makeRequest, logger } from '../utils.js'
3+
4+
const AUTH = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA'
5+
6+
export default class TwitterSource {
7+
constructor(nodelink) {
8+
this.nodelink = nodelink
9+
this.config = nodelink.options
10+
this.patterns = [
11+
/https?:\/\/(?:(?:www|m(?:obile)?)\.)?(?:twitter|x)\.com\/(?:[^/]+)\/status\/(\d+)/i
12+
]
13+
this.priority = 70
14+
this.guestToken = null
15+
this.tokenExpiry = 0
16+
}
17+
18+
async setup() {
19+
await this._refreshGuestToken()
20+
logger('info', 'Sources', 'Loaded Twitter (X) source.')
21+
return true
22+
}
23+
24+
async _refreshGuestToken() {
25+
try {
26+
const { body, statusCode } = await http1makeRequest('https://api.twitter.com/1.1/guest/activate.json', {
27+
method: 'POST',
28+
headers: {
29+
Authorization: `Bearer ${AUTH}`,
30+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
31+
}
32+
})
33+
34+
if (statusCode === 200 && body.guest_token) {
35+
this.guestToken = body.guest_token
36+
this.tokenExpiry = Date.now() + 10800000
37+
return true
38+
}
39+
} catch (e) {
40+
logger('error', 'Twitter', `Guest token activation failed: ${e.message}`)
41+
}
42+
return false
43+
}
44+
45+
_generateSyndicationToken(id) {
46+
return ((Number(id) / 1e15) * Math.PI).toString(36).replace(/[0.]/g, '')
47+
}
48+
49+
async _callGraphQL(operation, variables, features) {
50+
if (!this.guestToken || Date.now() > this.tokenExpiry) {
51+
await this._refreshGuestToken()
52+
}
53+
54+
const url = `https://twitter.com/i/api/graphql/${operation}?variables=${encodeURIComponent(JSON.stringify(variables))}&features=${encodeURIComponent(JSON.stringify(features))}`
55+
56+
return await http1makeRequest(url, {
57+
headers: {
58+
Authorization: `Bearer ${AUTH}`,
59+
'x-guest-token': this.guestToken,
60+
'x-twitter-active-user': 'yes',
61+
'x-twitter-client-language': 'en',
62+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
63+
'Referer': 'https://twitter.com/'
64+
}
65+
})
66+
}
67+
68+
async resolve(url) {
69+
const match = url.match(this.patterns[0])
70+
if (!match) return { loadType: 'empty', data: {} }
71+
72+
const id = match[1]
73+
try {
74+
const features = {
75+
creator_subscriptions_tweet_preview_api_enabled: true,
76+
responsive_web_graphql_timeline_navigation_enabled: true,
77+
longform_notetweets_inline_media_enabled: true,
78+
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true
79+
}
80+
81+
const { body, statusCode } = await this._callGraphQL('2ICDjqPd81tulZcYrtpTuQ/TweetResultByRestId', {
82+
tweetId: id,
83+
withCommunity: false,
84+
includePromotedContent: false,
85+
withVoice: true
86+
}, features)
87+
88+
let result = body?.data?.tweetResult?.result
89+
if (result?.__typename === 'TweetWithVisibilityResults') result = result.tweet
90+
const legacy = result?.legacy
91+
92+
if (legacy) {
93+
const media = legacy.extended_entities?.media?.find(m => m.type === 'video' || m.type === 'animated_gif')
94+
if (media) return this._buildTrackResponse(id, legacy, media, result, url)
95+
}
96+
97+
const syndToken = this._generateSyndicationToken(id)
98+
const syndRes = await http1makeRequest(`https://cdn.syndication.twimg.com/tweet-result?id=${id}&token=${syndToken}&lang=en`, {
99+
headers: { 'User-Agent': 'Googlebot' }
100+
})
101+
102+
if (syndRes.statusCode === 200 && (syndRes.body.video || syndRes.body.mediaDetails)) {
103+
const media = syndRes.body.video || syndRes.body.mediaDetails[0]
104+
return this._buildTrackResponse(id, syndRes.body, media, null, url, true)
105+
}
106+
107+
} catch (e) {
108+
logger('error', 'Twitter', `Resolution failed for ${id}: ${e.message}`)
109+
}
110+
111+
return { loadType: 'empty', data: {} }
112+
}
113+
114+
_buildTrackResponse(id, legacy, media, result, url, isSyndication = false) {
115+
const variants = isSyndication ? media.variants : media.video_info.variants
116+
const bestVariant = variants
117+
.filter(v => (v.content_type || v.type) === 'video/mp4')
118+
.map(v => {
119+
if (v.bitrate) return v
120+
const match = (v.url || v.src).match(/\/(\d+)x(\d+)\//)
121+
if (match) v.bitrate = parseInt(match[1]) * parseInt(match[2])
122+
return v
123+
})
124+
.sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0))[0] ||
125+
variants.find(v => (v.content_type || v.type) === 'application/x-mpegURL')
126+
127+
if (!bestVariant) return { loadType: 'empty', data: {} }
128+
129+
const directUrl = isSyndication ? bestVariant.src : bestVariant.url
130+
const isHLS = directUrl.includes('.m3u8')
131+
132+
const trackInfo = {
133+
identifier: id,
134+
isSeekable: !isHLS,
135+
author: isSyndication ? legacy.user.name : (result?.core?.user_results?.result?.legacy?.name || 'Twitter User'),
136+
length: isSyndication ? (media.durationMs || 0) : (media.video_info?.duration_millis || 0),
137+
isStream: isHLS,
138+
position: 0,
139+
title: (isSyndication ? legacy.text : legacy.full_text)?.split('https://t.co')[0].trim() || 'Twitter Content',
140+
uri: url,
141+
artworkUrl: isSyndication ? media.poster : (media.media_url_https || null),
142+
isrc: null,
143+
sourceName: 'twitter'
144+
}
145+
146+
return {
147+
loadType: 'track',
148+
data: {
149+
encoded: encodeTrack(trackInfo),
150+
info: trackInfo,
151+
pluginInfo: { directUrl, isHLS }
152+
}
153+
}
154+
}
155+
156+
async search(query) {
157+
try {
158+
const features = { responsive_web_graphql_timeline_navigation_enabled: true }
159+
const { body } = await this._callGraphQL('gk_S_vsh_PyInisUnZun6Q/SearchTimeline', {
160+
rawQuery: `${query} filter:videos`,
161+
count: 10,
162+
querySource: 'typed_query',
163+
product: 'Latest'
164+
}, features)
165+
166+
const instructions = body?.data?.search_by_raw_query?.search_timeline?.timeline?.instructions || []
167+
const entries = instructions.find(i => i.type === 'TimelineAddEntries')?.entries || []
168+
169+
const results = entries
170+
.map(e => {
171+
const result = e.content?.itemContent?.tweet_results?.result
172+
const legacy = result?.legacy || result?.tweet?.legacy
173+
if (!legacy) return null
174+
const media = legacy.extended_entities?.media?.find(m => m.type === 'video' || m.type === 'animated_gif')
175+
if (!media) return null
176+
return this._buildTrackResponse(legacy.id_str, legacy, media, result, `https://twitter.com/i/status/${legacy.id_str}`).data
177+
})
178+
.filter(Boolean)
179+
180+
return results.length ? { loadType: 'search', data: results } : { loadType: 'empty', data: {} }
181+
} catch (e) {
182+
logger('error', 'Twitter', `Search failed: ${e.message}`)
183+
return { loadType: 'empty', data: {} }
184+
}
185+
}
186+
187+
async getTrackUrl(decodedTrack) {
188+
if (decodedTrack.pluginInfo?.directUrl) {
189+
return {
190+
url: decodedTrack.pluginInfo.directUrl,
191+
protocol: decodedTrack.pluginInfo.isHLS ? 'hls' : 'https',
192+
format: decodedTrack.pluginInfo.isHLS ? 'm3u8' : 'mp4'
193+
}
194+
}
195+
196+
const res = await this.resolve(decodedTrack.uri)
197+
if (res.loadType === 'track') {
198+
return {
199+
url: res.data.pluginInfo.directUrl,
200+
protocol: res.data.pluginInfo.isHLS ? 'hls' : 'https',
201+
format: res.data.pluginInfo.isHLS ? 'm3u8' : 'mp4'
202+
}
203+
}
204+
205+
throw new Error('Failed to extract Twitter media URL')
206+
}
207+
208+
async loadStream(decodedTrack, url) {
209+
try {
210+
const options = {
211+
method: 'GET',
212+
streamOnly: true,
213+
headers: {
214+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
215+
'Referer': 'https://twitter.com/'
216+
}
217+
}
218+
219+
const response = await http1makeRequest(url, options)
220+
if (response.error || !response.stream) throw response.error || new Error('Failed to get stream')
221+
222+
const stream = new PassThrough()
223+
224+
response.stream.on('data', (chunk) => {
225+
if (!stream.destroyed) stream.write(chunk)
226+
})
227+
228+
response.stream.on('end', () => {
229+
if (!stream.destroyed) {
230+
stream.emit('finishBuffering')
231+
stream.end()
232+
}
233+
})
234+
235+
response.stream.on('error', (err) => {
236+
logger('error', 'Twitter', `External stream error: ${err.message}`)
237+
if (!stream.destroyed) stream.destroy(err)
238+
})
239+
240+
return { stream, type: decodedTrack.pluginInfo?.isHLS ? 'application/x-mpegURL' : 'video/mp4' }
241+
} catch (e) {
242+
logger('error', 'Twitter', `Failed to load stream: ${e.message}`)
243+
return { exception: { message: e.message, severity: 'fault' } }
244+
}
245+
}
246+
}

0 commit comments

Comments
 (0)