Skip to content

Commit

Permalink
more edits to Tweet class
Browse files Browse the repository at this point in the history
  • Loading branch information
Owen3H committed Jan 24, 2025
1 parent be3035d commit c314c50
Show file tree
Hide file tree
Showing 3 changed files with 79 additions and 64 deletions.
111 changes: 64 additions & 47 deletions src/classes/tweet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,70 @@ import { isNumeric, sendReq } from "../util.js"
import User from "./user.js"
import { FetchError } from "./errors.js"

// Grabbed from react-tweet and edited a bit for clarity :P
function tokenFromID(id: string) {
return ((Number(id) / 1e15) * Math.PI)
.toString(36) // Base 36 (a-z, 0-9)
.replace(/(0+|\.)/g, '') // Strip trailing zeros.
}

const SYNDICATION_TWEET_URL = 'https://cdn.syndication.twimg.com/tweet-result'

export default class Tweet {
/**
* Fetches a tweet by its ID by calling to the Syndication API's tweet embed endpoint.\
* The JSON object is immediately returned regardless of contents, please handle this accordingly!
*
* **NOTE**: This method should only be used if you need the raw object for whatever reason.\
* In most cases, it is suggested to use `Tweet.get()` instead which checks for tweet existence.
* @param id The ID of the tweet to fetch - must represent a valid ID.
* @returns The tweet object ({@link RawTweet}) from the JSON response.
* @see {@link SYNDICATION_TWEET_URL}
*/
static async fetch(id: string | number): Promise<RawTweet> {
try {
id = id.toString()
if (id.length > 40) {
throw new Error("Tweet ID too long! Must be less than 40 characters.")
}

if (!isNumeric(id)) {
throw new Error(`Tweet ID must be a number!`)
}

const url = new URL(SYNDICATION_TWEET_URL)
url.searchParams.set("id", id)
url.searchParams.set("token", tokenFromID(id))
url.searchParams.set("dnt", "1") // Send Do-Not-Track signal.

return sendReq(url.toString()).then((res: any) => res.json())
}
catch (e: unknown) {
const err = e instanceof Error ? e.message : e.toString()
throw new FetchError(`Error fetching Tweet with ID: ${id}\n${err}`)
}
}

/**
* Gets a tweet by its ID or `null` if it does not exist. Returned object is parsed as a {@link TweetEmbed}.
* @param id The ID of the tweet to fetch - must represent a valid ID.
*/
static async get(id: string | number) {
const tweet = await this.fetch(id)
if (!tweet || Object.keys(tweet).length == 0) {
return null
}

return new TweetEmbed(tweet)
}

// Bring back these docs when we can no longer get token from ID.
// * **A TOKEN IS REQUIRED!** You can find this token by doing the following:
// * 1. Opening **Inspect Element** -> **Network Requests**.
// * 2. Heading to [this link](https://platform.twitter.com/embed/Tweet.html?dnt=false&id=1877062812003885543) while logged in.
// * 3. Find the `tweet-result` request and copy the value of the `token` key under the **Payload** tab.
}

class TweetEmbed {
readonly conversationCount?: number
readonly createdAt: string
Expand Down Expand Up @@ -41,53 +105,6 @@ class TweetEmbed {
}
}

// Grabbed from react-tweet and edited a bit :P
function tokenFromID(id: string) {
if (!isNumeric(id)) {
throw new Error(`Could not generate token from non-numeric id: ${id}`)
}

return ((Number(id) / 1e15) * Math.PI)
.toString(36) // Base 36 (a-z, 0-9)
.replace(/(0+|\.)/g, '') // Strip trailing zeros.
}

const SYNDICATION_TWEET_URL = 'https://cdn.syndication.twimg.com/tweet-result?'

export default class Tweet {
static async #fetchTweet(id: string) {
try {
const token = tokenFromID(id)
const url = `${SYNDICATION_TWEET_URL}id=${id}&token=${token}&dnt=1`

const data = await sendReq(url).then((res: any) => res.json())
return data as RawTweet
}
catch (e: unknown) {
const errPrefix = `An error occurred fetching Tweet '${id}'`
const err = e instanceof Error ? e.message : e.toString()
throw new FetchError(`${errPrefix}\n${err}`)
}
}

/**
* Gets a {@link RawTweet} by its ID and returns a {@link TweetEmbed}.
*
* @param id The ID of the tweet to fetch.
* @param token Token required to fetch this tweet.
*/
static async get(id: string | number) {
const tweet = await this.#fetchTweet(id.toString())
return new TweetEmbed(tweet)
}

// Bring back when we can no longer get token from ID.
// * **A TOKEN IS REQUIRED!** You can find this token by doing the following:
// * 1. Opening **Inspect Element** -> **Network Requests**.
// * 2. Heading to [this link](https://platform.twitter.com/embed/Tweet.html?dnt=false&id=1877062812003885543) while logged in.
// * 3. Find the `tweet-result` request and copy the value of the `token` key under the **Payload** tab.
}

export {
Tweet,
TweetEmbed
Expand Down
30 changes: 13 additions & 17 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const headers = (cookie?: string) => {
return obj
}

const buildCookieString = (cookies: TwitterCookies) => {
export const buildCookieString = (cookies: TwitterCookies) => {
const obj = {
...cookies,
dnt: 1,
Expand All @@ -37,13 +37,14 @@ const buildCookieString = (cookies: TwitterCookies) => {
* response body or an {@link HttpError} including the status code.
* @internal
*/
async function sendReq(url: string, cookie?: string) {
export async function sendReq(url: string, cookie?: string) {
const res = await request(url, { headers: headers(cookie) })
if (!res) throw new FetchError(`Received null/undefined fetching '${url}'`)

const code = res.statusCode
if (code !== 200 && code !== 304)
if (code !== 200 && code !== 304) {
throw new HttpError(`Server responded with an error!\nStatus code: ${code}`, code)
}

// When running outside of Node, built-in fetch is used - therefore,
// fallback to original response since `body` won't be defined.
Expand All @@ -55,7 +56,7 @@ async function sendReq(url: string, cookie?: string) {
* using Puppeteer to navigate to the API endpoint.
* @internal
*/
async function getPuppeteerContent(config: PuppeteerConfig & {
export async function getPuppeteerContent(config: PuppeteerConfig & {
url: string,
cookie?: string
}) {
Expand All @@ -64,15 +65,17 @@ async function getPuppeteerContent(config: PuppeteerConfig & {

try {
if (!page) {
if (browser) page = await browser.newPage()
else throw new ConfigError('Failed to use Puppeteer! Either `page` or `browser` need to be specified.')
if (!browser) throw new ConfigError('Failed to use Puppeteer! Either `page` or `browser` need to be specified.')
page = await browser.newPage()
}

if (hasProp(page, 'setBypassCSP'))
if (hasProp(page, 'setBypassCSP')) {
await page.setBypassCSP(true)
}

if (hasProp(page, 'setExtraHTTPHeaders'))
if (hasProp(page, 'setExtraHTTPHeaders')) {
await page.setExtraHTTPHeaders(headers(cookie))
}

await page.goto(url, { waitUntil: 'load' })
return await page.content()
Expand All @@ -88,7 +91,7 @@ async function getPuppeteerContent(config: PuppeteerConfig & {
* response from the inputted timeline HTML string.
* @internal
*/
const extractTimelineData = (html: string) => {
export const extractTimelineData = (html: string) => {
const scriptId = `__NEXT_DATA__`
const regex = new RegExp(`<script id="${scriptId}" type="application\/json">([^>]*)<\/script>`)

Expand All @@ -104,11 +107,4 @@ const extractTimelineData = (html: string) => {
}
}

const isNumeric = (string) => Number.isFinite(+string)

export {
sendReq, buildCookieString,
getPuppeteerContent,
extractTimelineData,
isNumeric
}
export const isNumeric = (str: string) => Number.isFinite(+str)
2 changes: 2 additions & 0 deletions tests/tweet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ it('single tweet can be retrieved successfully', async () => {
expect(tweet.id).toBeDefined()
expect(tweet.createdAt).toBeDefined()
expect(tweet.text).toBeDefined()

//console.log(tweet)
})

// describe('Tweet get', () => {
Expand Down

0 comments on commit c314c50

Please sign in to comment.