106 lines
3.4 KiB
TypeScript
106 lines
3.4 KiB
TypeScript
|
|
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()
|
||
|
|
}
|
||
|
|
}
|