thread-master/backend/worker/threads-resolve-media.ts

106 lines
3.4 KiB
TypeScript
Raw Normal View History

import { chromium, type BrowserContextOptions } from 'playwright'
import {
isNumericMediaId,
mediaIdFromThreadObject,
postShortcode,
shortcodeFromPermalink,
} from './threads-media-id'
const USER_AGENT =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
function walkJson(data: unknown, visit: (obj: Record<string, unknown>) => void): void {
if (!data || typeof data !== 'object') return
if (Array.isArray(data)) {
for (const item of data) walkJson(item, visit)
return
}
const obj = data as Record<string, unknown>
visit(obj)
for (const value of Object.values(obj)) {
if (value && typeof value === 'object') walkJson(value, visit)
}
}
function findMediaIdForShortcode(data: unknown, shortcode: string): string | undefined {
const want = shortcode.trim().toLowerCase()
if (!want) return undefined
let found: string | undefined
walkJson(data, (obj) => {
if (found) return
const code = postShortcode(obj)
if (!code || code.toLowerCase() !== want) return
const mediaId = mediaIdFromThreadObject(obj)
if (mediaId) found = mediaId
})
return found
}
/** Open a Threads permalink and extract the numeric media id (pk) for API replies. */
export async function resolveMediaIdFromPermalink(
permalink: string,
storageState?: string,
): Promise<string | undefined> {
const link = permalink.trim()
const shortcode = shortcodeFromPermalink(link)
if (!link || !shortcode) return undefined
const browser = await chromium.launch({
headless: process.env.PLAYWRIGHT_HEADLESS !== 'false',
args: ['--disable-blink-features=AutomationControlled', '--no-first-run', '--no-default-browser-check'],
})
try {
const contextOptions: BrowserContextOptions = {
userAgent: USER_AGENT,
viewport: { width: 1280, height: 900 },
locale: 'zh-TW',
timezoneId: 'Asia/Taipei',
}
const trimmedState = storageState?.trim() ?? ''
if (trimmedState) {
contextOptions.storageState = JSON.parse(trimmedState) as BrowserContextOptions['storageState']
}
const context = await browser.newContext(contextOptions)
const page = await context.newPage()
const payloads: unknown[] = []
page.on('response', async (response) => {
const url = response.url()
if (!url.includes('graphql') && !url.includes('threads') && !url.includes('instagram')) return
try {
const contentType = response.headers()['content-type'] ?? ''
if (!contentType.includes('json')) return
payloads.push(await response.json())
} catch {
// ignore
}
})
await page.goto(link, { waitUntil: 'domcontentloaded', timeout: 45_000 })
await page.waitForTimeout(1500)
for (const payload of payloads) {
const mediaId = findMediaIdForShortcode(payload, shortcode)
if (isNumericMediaId(mediaId)) return mediaId
}
const scripts = await page.locator('script[type="application/json"][data-sjs]').all()
for (const script of scripts) {
try {
const raw = await script.textContent()
if (!raw) continue
const json = JSON.parse(raw)
const mediaId = findMediaIdForShortcode(json, shortcode)
if (isNumericMediaId(mediaId)) return mediaId
} catch {
// skip malformed script
}
}
return undefined
} finally {
await browser.close()
}
}