mirror of
https://codeberg.org/SafeTwitch/safetwitch-backend.git
synced 2025-01-05 04:10:06 -05:00
641 lines
No EOL
19 KiB
TypeScript
641 lines
No EOL
19 KiB
TypeScript
import { LooseObject } from "../../../types/looseTypes"
|
|
import { StreamerData, StreamData, Social, Badge } from "../../../types/scraping/Streamer"
|
|
import { Category, Tag } from "../../../types/scraping/Category"
|
|
import { CategoryData, CategoryMinifiedStream } from "../../../types/scraping/CategoryData"
|
|
import { SearchResult } from "../../../types/scraping/Search"
|
|
import { bttvEmotesHandler, ffzEmotesHandler } from "./emoteHandler"
|
|
|
|
const base64 = (data: String) => {
|
|
return Buffer.from(data).toString('base64url')
|
|
}
|
|
|
|
/**
|
|
* Class that interacts with the Twitch api
|
|
*/
|
|
export class TwitchAPI {
|
|
public readonly twitchUrl = 'https://gql.twitch.tv/gql'
|
|
public headers = {
|
|
"Client-Id": "kimne78kx3ncx6brgo4mv6wki5h1ko"
|
|
}
|
|
|
|
constructor() { }
|
|
/**
|
|
* Gets information about a streamer, like socials, about, and more.
|
|
* @see StreamerData
|
|
* @param streamerName The username of the streamer
|
|
* @returns Promise<StreamerData>
|
|
*/
|
|
public getStreamerInfo = async (streamerName: string) => {
|
|
const payload = [
|
|
{
|
|
"operationName": "ChannelRoot_AboutPanel",
|
|
"variables": {
|
|
"channelLogin": streamerName,
|
|
"skipSchedule": false
|
|
},
|
|
"extensions": {
|
|
"persistedQuery": {
|
|
"version": 1,
|
|
"sha256Hash": "6089531acef6c09ece01b440c41978f4c8dc60cb4fa0124c9a9d3f896709b6c6"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"operationName": "StreamMetadata",
|
|
"variables": {
|
|
"channelLogin": streamerName
|
|
},
|
|
"extensions": {
|
|
"persistedQuery": {
|
|
"version": 1,
|
|
"sha256Hash": "a647c2a13599e5991e175155f798ca7f1ecddde73f7f341f39009c14dbf59962"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"operationName": "StreamTagsTrackingChannel",
|
|
"variables": {
|
|
"channel": streamerName
|
|
},
|
|
"extensions": {
|
|
"persistedQuery": {
|
|
"version": 1,
|
|
"sha256Hash": "6aa3851aaaf88c320d514eb173563d430b28ed70fdaaf7eeef6ed4b812f48608"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"operationName": "VideoPreviewOverlay",
|
|
"variables": {
|
|
"login": streamerName
|
|
},
|
|
"extensions": {
|
|
"persistedQuery": {
|
|
"version": 1,
|
|
"sha256Hash": "9515480dee68a77e667cb19de634739d33f243572b007e98e67184b1a5d8369f"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"operationName": "UseViewCount",
|
|
"variables": {
|
|
"channelLogin": streamerName
|
|
},
|
|
"extensions": {
|
|
"persistedQuery": {
|
|
"version": 1,
|
|
"sha256Hash": "00b11c9c428f79ae228f30080a06ffd8226a1f068d6f52fbc057cbde66e994c2"
|
|
}
|
|
}
|
|
},
|
|
]
|
|
|
|
const res = await fetch(this.twitchUrl, {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
headers: this.headers
|
|
})
|
|
|
|
const data = await res.json()
|
|
const rawStreamerData = data[0].data
|
|
|
|
|
|
// get socials
|
|
const socials: LooseObject[] = []
|
|
if (rawStreamerData.user.channel && rawStreamerData.user.channel.socialMedias) {
|
|
for (let social of rawStreamerData.user.channel.socialMedias) {
|
|
socials.push({
|
|
type: social.name,
|
|
name: social.title,
|
|
link: social.url
|
|
})
|
|
}
|
|
}
|
|
|
|
// check if is liver
|
|
const rawStreamData = data[1].data.user.stream
|
|
let parsedStream: StreamData | null;
|
|
if (!rawStreamData) {
|
|
parsedStream = null
|
|
} else {
|
|
const tags: string[] = []
|
|
for (let tagData of data[2].data.user.stream.freeformTags) {
|
|
tags.push(tagData.name)
|
|
}
|
|
|
|
const previewUrl = `${process.env.URL}/proxy/img/${base64(data[3].data.user.stream.previewImageURL)}`
|
|
|
|
parsedStream = {
|
|
title: data[1].data.user.lastBroadcast.title,
|
|
topic: rawStreamData.game.name,
|
|
startedAt: new Date(rawStreamData.createdAt).valueOf(),
|
|
tags,
|
|
viewers: Number(data[4].data.user.stream.viewersCount),
|
|
preview: previewUrl
|
|
}
|
|
}
|
|
|
|
const abbreviatedFollowers = Intl.NumberFormat('en-US', {
|
|
notation: "compact",
|
|
maximumFractionDigits: 1
|
|
}).format(rawStreamerData.user.followers.totalCount)
|
|
|
|
const streamerData: StreamerData = {
|
|
username: rawStreamerData.user.displayName,
|
|
about: rawStreamerData.user.description,
|
|
pfp: `${process.env.URL}/proxy/img/${base64(rawStreamerData.user.profileImageURL)}`,
|
|
followers: rawStreamerData.user.followers.totalCount,
|
|
socials: socials as Social[],
|
|
isLive: (!!parsedStream),
|
|
isPartner: rawStreamerData.user.isPartner,
|
|
colorHex: '#' + rawStreamerData.user.primaryColorHex,
|
|
id: Number(rawStreamerData.user.id),
|
|
stream: parsedStream
|
|
}
|
|
|
|
return Promise.resolve(streamerData)
|
|
}
|
|
|
|
/**
|
|
* Gets the current viewers of a stream
|
|
* @param streamerName The username of the streamer
|
|
* @returns Promise<number>
|
|
*/
|
|
public getViewers = async (streamerName: string) => {
|
|
const payload = [
|
|
{
|
|
"operationName": "UseViewCount",
|
|
"variables": {
|
|
"channelLogin": streamerName
|
|
},
|
|
"extensions": {
|
|
"persistedQuery": {
|
|
"version": 1,
|
|
"sha256Hash": "00b11c9c428f79ae228f30080a06ffd8226a1f068d6f52fbc057cbde66e994c2"
|
|
}
|
|
}
|
|
},
|
|
]
|
|
|
|
const res = await fetch(this.twitchUrl, {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
headers: this.headers
|
|
})
|
|
|
|
const rawData = await res.json()
|
|
|
|
if (!rawData[0].data.user.stream)
|
|
return Promise.reject(new Error(`Streamer ${streamerName} is not live`))
|
|
|
|
return Promise.resolve(rawData[0].data.user.stream.viewersCount)
|
|
}
|
|
|
|
public isLive = async (streamerName: string) => {
|
|
const payload = [
|
|
{
|
|
"operationName": "UseViewCount",
|
|
"variables": {
|
|
"channelLogin": streamerName
|
|
},
|
|
"extensions": {
|
|
"persistedQuery": {
|
|
"version": 1,
|
|
"sha256Hash": "00b11c9c428f79ae228f30080a06ffd8226a1f068d6f52fbc057cbde66e994c2"
|
|
}
|
|
}
|
|
},
|
|
]
|
|
|
|
const res = await fetch(this.twitchUrl, {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
headers: this.headers
|
|
})
|
|
|
|
const rawData = await res.json()
|
|
|
|
if (!rawData[0].data.user.stream)
|
|
return Promise.resolve(false)
|
|
|
|
return Promise.resolve(true)
|
|
}
|
|
|
|
/**
|
|
* Gets the stream playlist file from twitch
|
|
* @param streamerName The username of the streamer
|
|
* @returns Promise<boolean>
|
|
*/
|
|
public getStream = async (streamerName: string) => {
|
|
const isLive = await this.isLive(streamerName)
|
|
if (!isLive) return Promise.reject(new Error(`Streamer ${streamerName} is not live`))
|
|
|
|
// Get token
|
|
const payload = {
|
|
"operationName": "PlaybackAccessToken_Template",
|
|
"query": "query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: \"web\", playerBackend: \"mediaplayer\", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: \"web\", playerBackend: \"mediaplayer\", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}",
|
|
"variables": {
|
|
"isLive": true,
|
|
"login": streamerName,
|
|
"isVod": false,
|
|
"vodID": "",
|
|
"playerType": "site"
|
|
}
|
|
}
|
|
|
|
var res = await fetch(this.twitchUrl, {
|
|
headers: this.headers,
|
|
body: JSON.stringify(payload),
|
|
method: 'POST'
|
|
})
|
|
|
|
const data = await res.json()
|
|
|
|
const token = data.data.streamPlaybackAccessToken.value
|
|
const signature = data.data.streamPlaybackAccessToken.signature
|
|
|
|
const playlistUrl = `https://usher.ttvnw.net/api/channel/hls/${streamerName.toLowerCase()}.m3u8`
|
|
const params = `?sig=${signature}&token=${token}`
|
|
var res = await fetch(playlistUrl + params, {
|
|
headers: this.headers
|
|
})
|
|
|
|
return await res.text()
|
|
}
|
|
|
|
|
|
/**
|
|
* Gets the homepage discovery tab of twitch
|
|
* @param limit Maximum categories to get
|
|
* @param cursor The current page you're at (for pagination)
|
|
|
|
*/
|
|
public getDirectory = async (limit: number, cursor?: string) => {
|
|
const payload: any[] = [
|
|
{
|
|
"operationName": "BrowsePage_AllDirectories",
|
|
"variables": {
|
|
"limit": limit,
|
|
"options": {
|
|
"recommendationsContext": {
|
|
"platform": "web"
|
|
},
|
|
"requestID": "JIRA-VXP-2397",
|
|
"sort": "RELEVANCE",
|
|
"tags": []
|
|
}
|
|
},
|
|
"extensions": {
|
|
"persistedQuery": {
|
|
"version": 1,
|
|
"sha256Hash": "1d1914ca3cbfaa607ecd5595b2e305e96acf987c8f25328f7713b25f604c4668"
|
|
}
|
|
}
|
|
}
|
|
]
|
|
|
|
if (cursor)
|
|
payload[0].variables.cursor = cursor
|
|
|
|
const res = await fetch(this.twitchUrl, {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
headers: this.headers
|
|
})
|
|
const data = await res.json()
|
|
const categories = data[0].data.directoriesWithTags.edges
|
|
let formattedCategories: Category[] = []
|
|
|
|
for (let category of categories) {
|
|
let tags: Tag[] = []
|
|
for (let tag of category.node.tags) {
|
|
tags.push(tag.localizedName)
|
|
}
|
|
|
|
formattedCategories.push({
|
|
name: category.node.name,
|
|
displayName: category.node.displayName,
|
|
viewers: category.node.viewersCount,
|
|
tags: tags,
|
|
createdAt: category.node.originalReleaseDate,
|
|
cursor: category.cursor,
|
|
image: `${process.env.URL}/proxy/img/${base64(category.node.avatarURL)}`
|
|
})
|
|
}
|
|
|
|
return formattedCategories
|
|
}
|
|
|
|
public getDirectoryGame = async (name: string, streamLimit: number, cursor?: string) => {
|
|
const payload: any[] = [
|
|
{
|
|
"operationName": "DirectoryPage_Game",
|
|
"variables": {
|
|
"imageWidth": 50,
|
|
"name": name,
|
|
"options": {
|
|
"sort": "RELEVANCE",
|
|
"recommendationsContext": {
|
|
"platform": "web"
|
|
},
|
|
"requestID": "JIRA-VXP-2397",
|
|
"freeformTags": null,
|
|
"tags": []
|
|
},
|
|
"sortTypeIsRecency": false,
|
|
"limit": streamLimit
|
|
},
|
|
"extensions": {
|
|
"persistedQuery": {
|
|
"version": 1,
|
|
"sha256Hash": "df4bb6cc45055237bfaf3ead608bbafb79815c7100b6ee126719fac3762ddf8b"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"operationName": "Directory_DirectoryBanner",
|
|
"variables": {
|
|
"name": name
|
|
},
|
|
"extensions": {
|
|
"persistedQuery": {
|
|
"version": 1,
|
|
"sha256Hash": "2670fbecd8fbea0211c56528d6eff5752ef9d6c73cd5238d395784b46335ded4"
|
|
}
|
|
}
|
|
},
|
|
]
|
|
|
|
if (cursor)
|
|
payload[0].variables.cursor = cursor
|
|
|
|
const res = await fetch(this.twitchUrl, {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
headers: this.headers
|
|
})
|
|
const data = await res.json()
|
|
|
|
if (!data[0].data.game) return null;
|
|
|
|
|
|
let streams = []
|
|
if (data[0].data.game.streams)
|
|
streams = data[0].data.game.streams.edges
|
|
let formatedStreams: CategoryMinifiedStream[] = []
|
|
|
|
for (let stream of streams) {
|
|
let tags = []
|
|
for (let tag of stream.node.freeformTags) {
|
|
tags.push(tag.name)
|
|
}
|
|
|
|
formatedStreams.push({
|
|
title: stream.node.title,
|
|
viewers: stream.node.viewersCount,
|
|
preview: `${process.env.URL}/proxy/img/${base64(stream.node.previewImageURL)}`,
|
|
tags,
|
|
cursor: stream.cursor,
|
|
streamer: {
|
|
name: stream.node.broadcaster.displayName,
|
|
pfp: `${process.env.URL}/proxy/img/${base64(stream.node.broadcaster.profileImageURL)}`,
|
|
colorHex: stream.node.broadcaster.primaryColorHex
|
|
}
|
|
})
|
|
}
|
|
|
|
const rawGameData = data[1].data.game;
|
|
let tags: Tag[] = []
|
|
for (let tag of rawGameData.tags) {
|
|
tags.push(tag.tagName)
|
|
}
|
|
|
|
const formatedGameData: CategoryData = {
|
|
name: rawGameData.name,
|
|
cover: `${process.env.URL}/proxy/img/${base64(rawGameData.avatarURL)}`,
|
|
description: rawGameData.description,
|
|
viewers: rawGameData.viewersCount,
|
|
followers: rawGameData.followersCount,
|
|
tags,
|
|
streams: formatedStreams
|
|
}
|
|
|
|
|
|
return formatedGameData
|
|
}
|
|
|
|
public getTwitchBadges = async () => {
|
|
const payload = [
|
|
{
|
|
"operationName": "ChannelPointsPredictionBadges",
|
|
"variables": {},
|
|
"extensions": {
|
|
"persistedQuery": {
|
|
"sha256Hash": "36995b30b22c31d1cd0aa329987ac9b5368bb7e6e1ab1df42808bdaa80a6dbf9",
|
|
"version": 1
|
|
}
|
|
},
|
|
}
|
|
]
|
|
|
|
const res = await fetch(this.twitchUrl, {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
headers: this.headers
|
|
})
|
|
const data = await res.json()
|
|
|
|
if (!data[0].data.badges) return null;
|
|
let formatedBadges: Badge[] = []
|
|
|
|
for (let badge of data[0].data.badges) {
|
|
let formatedBadge: Badge = {
|
|
id: Buffer.from(badge.id, 'base64').toString(),
|
|
setId: badge.setID,
|
|
title: badge.title,
|
|
version: badge.version,
|
|
images: {
|
|
image1x: `${process.env.URL}/proxy/img/${base64(badge.image1x)}`,
|
|
image2x: `${process.env.URL}/proxy/img/${base64(badge.image2x)}`,
|
|
image4x: `${process.env.URL}/proxy/img/${base64(badge.image4x)}`,
|
|
}
|
|
}
|
|
|
|
formatedBadges.push(formatedBadge)
|
|
}
|
|
|
|
return formatedBadges
|
|
}
|
|
|
|
public getStreamerBadges = async (streamerName: string) => {
|
|
const payload = {
|
|
"extensions": {
|
|
"persistedQuery": {
|
|
"sha256Hash": "86f43113c04606e6476e39dcd432dee47c994d77a83e54b732e11d4935f0cd08",
|
|
"version": 1
|
|
}
|
|
},
|
|
"operationName": "ChatList_Badges",
|
|
"variables": {
|
|
"channelLogin": streamerName
|
|
}
|
|
}
|
|
|
|
const res = await fetch(this.twitchUrl, {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
headers: this.headers
|
|
})
|
|
const data = await res.json()
|
|
|
|
if (!data.data.user.broadcastBadges) return null;
|
|
let formatedBadges: Badge[] = []
|
|
|
|
for (let badge of data.data.user.broadcastBadges) {
|
|
let formatedBadge: Badge = {
|
|
id: Buffer.from(badge.id, 'base64').toString(),
|
|
setId: badge.setID,
|
|
title: badge.title,
|
|
version: badge.version,
|
|
images: {
|
|
image1x: `${process.env.URL}/proxy/img/${base64(badge.image1x)}`,
|
|
image2x: `${process.env.URL}/proxy/img/${base64(badge.image2x)}`,
|
|
image4x: `${process.env.URL}/proxy/img/${base64(badge.image4x)}`,
|
|
}
|
|
}
|
|
|
|
formatedBadges.push(formatedBadge)
|
|
}
|
|
|
|
return formatedBadges
|
|
}
|
|
|
|
public getSearchResult = async (query: string) => {
|
|
const payload = {
|
|
"operationName": "SearchResultsPage_SearchResults",
|
|
"variables": {
|
|
"query": query,
|
|
"options": null,
|
|
"requestID": "75948144-d051-4203-8511-57f3ee9b809a"
|
|
},
|
|
"extensions": {
|
|
"persistedQuery": {
|
|
"version": 1,
|
|
"sha256Hash": "6ea6e6f66006485e41dbe3ebd69d5674c5b22896ce7b595d7fce6411a3790138"
|
|
}
|
|
}
|
|
}
|
|
|
|
const res = await fetch(this.twitchUrl, {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
headers: this.headers
|
|
})
|
|
const data = await res.json()
|
|
const resultsData = data.data.searchFor
|
|
|
|
const formattedStreamers: StreamerData[] = resultsData.channels.edges.map((data: any) => {
|
|
return {
|
|
username: data.item.login,
|
|
followers: data.item.followers.totalCount,
|
|
isLive: !(data.item.stream === null),
|
|
about: data.item.description,
|
|
pfp: `${process.env.URL}/proxy/img/${base64(data.item.profileImageURL)}`,
|
|
isPartner: null,
|
|
colorHex: '#fff',
|
|
id: Number(data.item.channel.id)
|
|
}
|
|
})
|
|
|
|
const foundCategories: Category[] = []
|
|
for (let category of resultsData.games.edges) {
|
|
let tags = category.item.tags.map((data: { tagName: string }) => {
|
|
return data.tagName
|
|
})
|
|
|
|
foundCategories.push({
|
|
name: category.item.name,
|
|
displayName: category.item.displayName,
|
|
viewers: category.item.viewersCount,
|
|
tags,
|
|
image: category.item.boxArtURL,
|
|
})
|
|
}
|
|
|
|
const foundRelatedLiveChannels: StreamData[] = await Promise.all(resultsData.relatedLiveChannels.edges.map(async (data: any) => {
|
|
return await this.getStreamerInfo(data.item.stream.broadcaster.login)
|
|
}))
|
|
|
|
const foundChannelsWithTag: StreamData[] = resultsData.channelsWithTag.edges.map((data: any) => {
|
|
return {
|
|
username: data.item.login,
|
|
followers: data.item.followers.totalCount,
|
|
isLive: !(data.item.stream === null),
|
|
about: data.item.description,
|
|
pfp: `${process.env.URL}/proxy/img/${base64(data.item.profileImageURL)}`,
|
|
isPartner: null,
|
|
colorHex: '#fff',
|
|
id: Number(data.item.channel.id)
|
|
}
|
|
})
|
|
|
|
const finalData: SearchResult = {
|
|
channels: formattedStreamers,
|
|
categories: foundCategories,
|
|
relatedChannels: foundRelatedLiveChannels,
|
|
channelsWithTag: foundChannelsWithTag
|
|
}
|
|
|
|
return finalData
|
|
}
|
|
|
|
public getStreamerId = async (channelName: string) => {
|
|
const payload = {
|
|
"operationName": "ChannelRoot_AboutPanel",
|
|
"variables": {
|
|
"channelLogin": channelName,
|
|
"skipSchedule": true
|
|
},
|
|
"extensions": {
|
|
"persistedQuery": {
|
|
"version": 1,
|
|
"sha256Hash": "6089531acef6c09ece01b440c41978f4c8dc60cb4fa0124c9a9d3f896709b6c6"
|
|
}
|
|
}
|
|
}
|
|
|
|
const res = await fetch(this.twitchUrl, {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
headers: this.headers
|
|
})
|
|
const data = await res.json()
|
|
|
|
if (!data.data.user) {
|
|
return Promise.reject(new Error(`Steamer ${channelName} is offline.`))
|
|
}
|
|
|
|
return Number(data.data.user.id)
|
|
}
|
|
|
|
public getEmotes = async (channelName: string) => {
|
|
let id = await this.getStreamerId(channelName)
|
|
const ffzUrl = 'https://api.frankerfacez.com/v1/room/id/' + id
|
|
const bttvGlobalUrl = 'https://api.betterttv.net/3/cached/users/twitch/121059319'
|
|
const bttvChannelUrl = 'https://api.betterttv.net/3/cached/users/twitch/' + id
|
|
|
|
let res = await fetch(ffzUrl)
|
|
let data = await res.json()
|
|
const ffzEmotes = ffzEmotesHandler(data)
|
|
|
|
res = await fetch(bttvGlobalUrl)
|
|
data = await res.json()
|
|
const bttvGlobalEmotes = bttvEmotesHandler([...data.channelEmotes, ...data.sharedEmotes])
|
|
|
|
res = await fetch(bttvChannelUrl)
|
|
data = await res.json()
|
|
const bttvChannelEmotes = bttvEmotesHandler(data.channelEmotes)
|
|
|
|
return [ ...ffzEmotes, ...bttvGlobalEmotes, ...bttvChannelEmotes]
|
|
}
|
|
} |