mirror of
https://github.com/FxEmbed/FxEmbed.git
synced 2026-10-02 01:54:54 +08:00
feat: test ig tg size limit
This commit is contained in:
@@ -52,7 +52,7 @@ export async function constructInstagramConversation(
|
||||
(typeof owner?.profile_image_uri === 'string' && owner.profile_image_uri) ||
|
||||
null
|
||||
};
|
||||
const status = instagramNodeToStatus(item, fb);
|
||||
const status = instagramNodeToStatus(item, fb, { userAgent: options.userAgent });
|
||||
if (!status) {
|
||||
return {
|
||||
ok: true,
|
||||
|
||||
@@ -248,6 +248,33 @@ export function parseDashPresentationDurationSec(manifest: string | null | undef
|
||||
return Number.isFinite(total) && total > 0 ? total : Number.NaN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort map of height → bandwidth (bits/sec) from Instagram DASH Representations.
|
||||
* Attribute order varies; we only keep the highest bandwidth seen for each height.
|
||||
*/
|
||||
export function parseDashBandwidthByHeight(
|
||||
manifest: string | null | undefined
|
||||
): Map<number, number> {
|
||||
const out = new Map<number, number>();
|
||||
if (!manifest || typeof manifest !== 'string') return out;
|
||||
const re = /<Representation\b([^>]*)>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(manifest))) {
|
||||
const attrs = match[1] ?? '';
|
||||
const bw = attrs.match(/\bbandwidth=["'](\d+)["']/i);
|
||||
const h = attrs.match(/\bheight=["'](\d+)["']/i);
|
||||
if (!bw?.[1] || !h?.[1]) continue;
|
||||
const bandwidth = Number(bw[1]);
|
||||
const height = Number(h[1]);
|
||||
if (!Number.isFinite(bandwidth) || bandwidth <= 0 || !Number.isFinite(height) || height <= 0) {
|
||||
continue;
|
||||
}
|
||||
const prev = out.get(height) ?? 0;
|
||||
if (bandwidth > prev) out.set(height, bandwidth);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull product media from a Polaris GraphQL JSON response
|
||||
* (`data.xig_polaris_media.if_not_gated_logged_out`).
|
||||
|
||||
@@ -23,7 +23,7 @@ export async function constructInstagramPost(
|
||||
(typeof owner?.profile_image_uri === 'string' && owner.profile_image_uri) ||
|
||||
null
|
||||
};
|
||||
const status = instagramNodeToStatus(item, fb);
|
||||
const status = instagramNodeToStatus(item, fb, { userAgent });
|
||||
if (!status) {
|
||||
return { code: 404, status: null, thread: null, author: null };
|
||||
}
|
||||
|
||||
@@ -3,9 +3,33 @@ import type {
|
||||
APISubstatus,
|
||||
APIUser,
|
||||
APIVideo,
|
||||
APIVideoFormat,
|
||||
APIPhoto
|
||||
} from '../../types/api-schemas.js';
|
||||
import { parseDashPresentationDurationSec } from './extractors.js';
|
||||
import {
|
||||
parseDashBandwidthByHeight,
|
||||
parseDashPresentationDurationSec
|
||||
} from './extractors.js';
|
||||
|
||||
/** Telegram refuses bot videos over ~20 MiB; prefer under that when we can estimate. */
|
||||
const TELEGRAM_MAX_BYTES = 20 * 1024 * 1024;
|
||||
/** Soft cap used with estimates so overshoots still tend to fit Telegram. */
|
||||
const TELEGRAM_SOFT_MAX_BYTES = 18 * 1024 * 1024;
|
||||
const ESTIMATE_FPS = 30;
|
||||
/** Bits/pixel/frame — slightly high on purpose so we overestimate size for Telegram. */
|
||||
const ESTIMATE_BPP = 0.14;
|
||||
|
||||
export type InstagramStatusOptions = {
|
||||
userAgent?: string;
|
||||
};
|
||||
|
||||
type IgVideoVersion = {
|
||||
url?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
type?: number;
|
||||
bandwidth?: number | null;
|
||||
};
|
||||
|
||||
function pickInt(...vals: unknown[]): number {
|
||||
for (const v of vals) {
|
||||
@@ -51,26 +75,140 @@ function displayImageUrl(node: Record<string, unknown>): string {
|
||||
);
|
||||
}
|
||||
|
||||
function pickBestVideoVersion(
|
||||
versions: { url?: string; width?: number; height?: number; type?: number }[] | undefined
|
||||
): { url: string; width: number; height: number } | null {
|
||||
/**
|
||||
* Estimate progressive MP4 size when Instagram omits byte lengths.
|
||||
* Prefer DASH/API bandwidth (bits/sec) when available; otherwise resolution × duration.
|
||||
*/
|
||||
export function estimateInstagramVideoBytes(
|
||||
width: number,
|
||||
height: number,
|
||||
durationSec: number,
|
||||
bitrateBps?: number
|
||||
): number | undefined {
|
||||
if (durationSec > 0 && bitrateBps && bitrateBps > 0) {
|
||||
return Math.round((bitrateBps * durationSec) / 8);
|
||||
}
|
||||
if (durationSec > 0 && width > 0 && height > 0) {
|
||||
const bitrate = width * height * ESTIMATE_FPS * ESTIMATE_BPP;
|
||||
return Math.round((bitrate * durationSec) / 8);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function selectInstagramVideoVariant(
|
||||
versions: IgVideoVersion[] | undefined,
|
||||
opts: {
|
||||
durationSec: number;
|
||||
fallbackWidth: number;
|
||||
fallbackHeight: number;
|
||||
dashBandwidthByHeight?: Map<number, number>;
|
||||
preferTelegramSafe?: boolean;
|
||||
}
|
||||
): {
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
type?: number;
|
||||
estimatedBytes?: number;
|
||||
bitrate?: number;
|
||||
formats: APIVideoFormat[];
|
||||
} | null {
|
||||
if (!versions?.length) return null;
|
||||
// Prefer entries with dimensions; otherwise first URL (polaris often omits width/height).
|
||||
let best: { url: string; width: number; height: number } | null = null;
|
||||
|
||||
type Candidate = {
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
type: number;
|
||||
bitrate?: number;
|
||||
estimatedBytes?: number;
|
||||
};
|
||||
|
||||
const candidates: Candidate[] = [];
|
||||
for (const v of versions) {
|
||||
if (typeof v?.url !== 'string' || !v.url) continue;
|
||||
const w = pickInt(v.width);
|
||||
const h = pickInt(v.height);
|
||||
const candidate = { url: v.url, width: w, height: h };
|
||||
if (!best) {
|
||||
best = candidate;
|
||||
continue;
|
||||
}
|
||||
const bestArea = best.width * best.height;
|
||||
const area = w * h;
|
||||
if (area > bestArea) best = candidate;
|
||||
const width = pickInt(v.width, opts.fallbackWidth);
|
||||
const height = pickInt(v.height, opts.fallbackHeight);
|
||||
const type = pickInt(v.type);
|
||||
const fromDash = height > 0 ? opts.dashBandwidthByHeight?.get(height) : undefined;
|
||||
const bitrate =
|
||||
(typeof v.bandwidth === 'number' && v.bandwidth > 0 ? v.bandwidth : undefined) ?? fromDash;
|
||||
const estimatedBytes = estimateInstagramVideoBytes(
|
||||
width || opts.fallbackWidth,
|
||||
height || opts.fallbackHeight,
|
||||
opts.durationSec,
|
||||
bitrate
|
||||
);
|
||||
candidates.push({
|
||||
url: v.url,
|
||||
width,
|
||||
height,
|
||||
type,
|
||||
bitrate,
|
||||
estimatedBytes
|
||||
});
|
||||
}
|
||||
return best;
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
const byQuality = (a: Candidate, b: Candidate) => {
|
||||
const areaDiff = b.width * b.height - a.width * a.height;
|
||||
if (areaDiff !== 0) return areaDiff;
|
||||
// Same resolution: higher type is often the better encode (102/103 > 101).
|
||||
return b.type - a.type;
|
||||
};
|
||||
|
||||
let selected: Candidate;
|
||||
if (opts.preferTelegramSafe) {
|
||||
const fitting = candidates
|
||||
.filter(c => !c.estimatedBytes || c.estimatedBytes <= TELEGRAM_SOFT_MAX_BYTES)
|
||||
.sort((a, b) => {
|
||||
const q = byQuality(a, b);
|
||||
if (q !== 0) return q;
|
||||
// Prefer smaller estimate when quality ties.
|
||||
return (a.estimatedBytes ?? 0) - (b.estimatedBytes ?? 0);
|
||||
});
|
||||
if (fitting.length > 0) {
|
||||
selected = fitting[0];
|
||||
} else {
|
||||
// Nothing estimated under the soft cap — pick the smallest estimate / lowest type.
|
||||
selected = [...candidates].sort((a, b) => {
|
||||
if (a.estimatedBytes && b.estimatedBytes) {
|
||||
return a.estimatedBytes - b.estimatedBytes;
|
||||
}
|
||||
if (a.estimatedBytes) return -1;
|
||||
if (b.estimatedBytes) return 1;
|
||||
const areaDiff = a.width * a.height - b.width * b.height;
|
||||
if (areaDiff !== 0) return areaDiff;
|
||||
return a.type - b.type;
|
||||
})[0];
|
||||
if (selected.estimatedBytes && selected.estimatedBytes > TELEGRAM_MAX_BYTES) {
|
||||
console.log(
|
||||
`[instagram] Telegram video estimate ${Math.round(selected.estimatedBytes / (1024 * 1024))}MB exceeds 20MB; no smaller variant available`
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
selected = [...candidates].sort(byQuality)[0];
|
||||
}
|
||||
|
||||
const formats: APIVideoFormat[] = candidates.map(c => ({
|
||||
url: c.url,
|
||||
width: c.width || undefined,
|
||||
height: c.height || undefined,
|
||||
bitrate: c.bitrate,
|
||||
size: c.estimatedBytes,
|
||||
container: 'mp4' as const
|
||||
}));
|
||||
|
||||
return {
|
||||
url: selected.url,
|
||||
width: selected.width,
|
||||
height: selected.height,
|
||||
type: selected.type || undefined,
|
||||
estimatedBytes: selected.estimatedBytes,
|
||||
bitrate: selected.bitrate,
|
||||
formats
|
||||
};
|
||||
}
|
||||
|
||||
function videoDurationSec(node: Record<string, unknown>): number {
|
||||
@@ -208,7 +346,9 @@ function buildVideo(
|
||||
w: number,
|
||||
h: number,
|
||||
durationSec: number,
|
||||
thumb: string | null | undefined
|
||||
thumb: string | null | undefined,
|
||||
formats?: APIVideoFormat[],
|
||||
estimatedBytes?: number
|
||||
): APIVideo {
|
||||
return {
|
||||
type: 'video',
|
||||
@@ -216,7 +356,10 @@ function buildVideo(
|
||||
width: w || 1,
|
||||
height: h || 1,
|
||||
duration: durationSec > 0 ? durationSec : 0,
|
||||
formats: [{ url, width: w || undefined, height: h || undefined }],
|
||||
filesize: estimatedBytes,
|
||||
formats: formats?.length
|
||||
? formats
|
||||
: [{ url, width: w || undefined, height: h || undefined, size: estimatedBytes }],
|
||||
thumbnail_url: thumb ?? null
|
||||
};
|
||||
}
|
||||
@@ -241,13 +384,15 @@ function mediaPkFromNode(node: Record<string, unknown>): string | undefined {
|
||||
*/
|
||||
export function instagramNodeToStatus(
|
||||
node: Record<string, unknown>,
|
||||
ownerFallback: { id: string; username: string; fullName?: string; pic?: string | null }
|
||||
ownerFallback: { id: string; username: string; fullName?: string; pic?: string | null },
|
||||
options?: InstagramStatusOptions
|
||||
): APIInstagramStatus | null {
|
||||
const shortcode =
|
||||
(typeof node.shortcode === 'string' && node.shortcode) ||
|
||||
(typeof node.code === 'string' && node.code) ||
|
||||
'';
|
||||
if (!shortcode) return null;
|
||||
const preferTelegramSafe = Boolean(options?.userAgent?.toLowerCase().includes('telegram'));
|
||||
const owner =
|
||||
(node.user as Record<string, unknown> | undefined) ??
|
||||
(node.owner as Record<string, unknown> | undefined) ??
|
||||
@@ -286,17 +431,30 @@ export function instagramNodeToStatus(
|
||||
if (!slide || typeof slide !== 'object') continue;
|
||||
const s = slide as Record<string, unknown>;
|
||||
if (isVideoNode(s)) {
|
||||
const best = pickBestVideoVersion(
|
||||
s.video_versions as { url?: string; width?: number; height?: number; type?: number }[]
|
||||
const durationSec = videoDurationSec(s);
|
||||
const dashBw = parseDashBandwidthByHeight(
|
||||
typeof s.video_dash_manifest === 'string' ? s.video_dash_manifest : undefined
|
||||
);
|
||||
const url = pickString(s.video_url, best?.url);
|
||||
const selected = selectInstagramVideoVariant(
|
||||
s.video_versions as IgVideoVersion[] | undefined,
|
||||
{
|
||||
durationSec,
|
||||
fallbackWidth: pickInt(s.original_width, w),
|
||||
fallbackHeight: pickInt(s.original_height, h),
|
||||
dashBandwidthByHeight: dashBw,
|
||||
preferTelegramSafe
|
||||
}
|
||||
);
|
||||
const url = pickString(s.video_url, selected?.url);
|
||||
if (url) {
|
||||
const v = buildVideo(
|
||||
url,
|
||||
pickInt(s.original_width, best?.width, w),
|
||||
pickInt(s.original_height, best?.height, h),
|
||||
videoDurationSec(s),
|
||||
displayImageUrl(s) || undefined
|
||||
pickInt(s.original_width, selected?.width, w),
|
||||
pickInt(s.original_height, selected?.height, h),
|
||||
durationSec,
|
||||
displayImageUrl(s) || undefined,
|
||||
selected?.formats,
|
||||
selected?.estimatedBytes
|
||||
);
|
||||
videos.push(v);
|
||||
all.push(v);
|
||||
@@ -311,18 +469,31 @@ export function instagramNodeToStatus(
|
||||
}
|
||||
}
|
||||
} else if (isVideoNode(node)) {
|
||||
const best = pickBestVideoVersion(
|
||||
node.video_versions as { url?: string; width?: number; height?: number; type?: number }[]
|
||||
const durationSec = videoDurationSec(node);
|
||||
const dashBw = parseDashBandwidthByHeight(
|
||||
typeof node.video_dash_manifest === 'string' ? node.video_dash_manifest : undefined
|
||||
);
|
||||
const url = pickString(node.video_url, best?.url);
|
||||
const selected = selectInstagramVideoVariant(
|
||||
node.video_versions as IgVideoVersion[] | undefined,
|
||||
{
|
||||
durationSec,
|
||||
fallbackWidth: w,
|
||||
fallbackHeight: h,
|
||||
dashBandwidthByHeight: dashBw,
|
||||
preferTelegramSafe
|
||||
}
|
||||
);
|
||||
const url = pickString(node.video_url, selected?.url);
|
||||
if (url) {
|
||||
const thumb = displayImageUrl(node) || undefined;
|
||||
const v = buildVideo(
|
||||
url,
|
||||
pickInt(best?.width, w),
|
||||
pickInt(best?.height, h),
|
||||
videoDurationSec(node),
|
||||
thumb
|
||||
pickInt(selected?.width, w),
|
||||
pickInt(selected?.height, h),
|
||||
durationSec,
|
||||
thumb,
|
||||
selected?.formats,
|
||||
selected?.estimatedBytes
|
||||
);
|
||||
// When polaris omits version dimensions, fall back to original_* / image candidate size.
|
||||
if ((v.width <= 1 || v.height <= 1) && (w > 0 || h > 0)) {
|
||||
@@ -378,12 +549,13 @@ export function instagramNodeToStatus(
|
||||
|
||||
export function edgeNodeToStatus(
|
||||
edge: unknown,
|
||||
ownerFallback: { id: string; username: string; fullName?: string; pic?: string | null }
|
||||
ownerFallback: { id: string; username: string; fullName?: string; pic?: string | null },
|
||||
options?: InstagramStatusOptions
|
||||
): APIInstagramStatus | null {
|
||||
if (!edge || typeof edge !== 'object') return null;
|
||||
const n = (edge as { node?: unknown }).node;
|
||||
if (!n || typeof n !== 'object') return null;
|
||||
return instagramNodeToStatus(n as Record<string, unknown>, ownerFallback);
|
||||
return instagramNodeToStatus(n as Record<string, unknown>, ownerFallback, options);
|
||||
}
|
||||
|
||||
export function commentRecordToSubstatus(
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
extractPostMediaItem,
|
||||
extractShortcodeWebInfo,
|
||||
hasUsefulRelayData,
|
||||
parseDashBandwidthByHeight,
|
||||
parseDashPresentationDurationSec
|
||||
} from '@fxembed/atmosphere/providers/instagram/extractors';
|
||||
|
||||
@@ -60,6 +61,15 @@ describe('instagram extractors', () => {
|
||||
).toBeCloseTo(5.016, 3);
|
||||
});
|
||||
|
||||
it('parses DASH representation bandwidth by height', () => {
|
||||
const map = parseDashBandwidthByHeight(
|
||||
'<MPD><Representation id="1" bandwidth="5000000" width="720" height="1280"/>' +
|
||||
'<Representation height="640" width="360" bandwidth="400000"/></MPD>'
|
||||
);
|
||||
expect(map.get(1280)).toBe(5_000_000);
|
||||
expect(map.get(640)).toBe(400_000);
|
||||
});
|
||||
|
||||
it('extracts polaris product from GraphQL JSON', () => {
|
||||
const bundle = extractPolarisProductFromGraphqlJson({
|
||||
data: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
commentRecordToSubstatus,
|
||||
estimateInstagramVideoBytes,
|
||||
fullUserFromWebProfile,
|
||||
instagramNodeToStatus,
|
||||
mapCommentEdges
|
||||
@@ -77,6 +78,73 @@ describe('instagram processor', () => {
|
||||
expect(s!.media_pk).toBe('2913440072144448240');
|
||||
});
|
||||
|
||||
it('for Telegram prefers a lower-res variant when high-res estimate exceeds ~20MB', () => {
|
||||
const node: Record<string, unknown> = {
|
||||
pk: '1',
|
||||
code: 'LongReel123',
|
||||
media_type: 2,
|
||||
product_type: 'clips',
|
||||
taken_at: 1661529231,
|
||||
video_duration: 90,
|
||||
original_width: 1080,
|
||||
original_height: 1920,
|
||||
caption: { text: 'long' },
|
||||
user: { pk: '1', username: 'demo' },
|
||||
display_uri: 'https://cdn.example/thumb.jpg',
|
||||
video_versions: [
|
||||
{ url: 'https://cdn.example/1080.mp4', type: 103, width: 1080, height: 1920 },
|
||||
{ url: 'https://cdn.example/360.mp4', type: 101, width: 360, height: 640 }
|
||||
]
|
||||
};
|
||||
const high = estimateInstagramVideoBytes(1080, 1920, 90)!;
|
||||
const low = estimateInstagramVideoBytes(360, 640, 90)!;
|
||||
expect(high).toBeGreaterThan(20 * 1024 * 1024);
|
||||
expect(low).toBeLessThan(18 * 1024 * 1024);
|
||||
|
||||
const forDiscord = instagramNodeToStatus(node, ownerFb, {
|
||||
userAgent: 'Discordbot/2.0'
|
||||
});
|
||||
expect(forDiscord!.media?.videos?.[0]?.url).toBe('https://cdn.example/1080.mp4');
|
||||
|
||||
const forTelegram = instagramNodeToStatus(node, ownerFb, {
|
||||
userAgent: 'TelegramBot (like TwitterBot)'
|
||||
});
|
||||
expect(forTelegram!.media?.videos?.[0]?.url).toBe('https://cdn.example/360.mp4');
|
||||
expect(forTelegram!.media?.videos?.[0]?.filesize).toBe(low);
|
||||
expect(forTelegram!.media?.videos?.[0]?.formats?.length).toBe(2);
|
||||
});
|
||||
|
||||
it('uses DASH bandwidth when estimating Telegram-safe variants', () => {
|
||||
const node: Record<string, unknown> = {
|
||||
pk: '2',
|
||||
code: 'DashReel',
|
||||
media_type: 2,
|
||||
product_type: 'clips',
|
||||
taken_at: 1661529231,
|
||||
video_duration: 60,
|
||||
original_width: 720,
|
||||
original_height: 1280,
|
||||
caption: { text: 'dash' },
|
||||
user: { pk: '1', username: 'demo' },
|
||||
display_uri: 'https://cdn.example/thumb.jpg',
|
||||
video_versions: [
|
||||
{ url: 'https://cdn.example/hi.mp4', type: 103, width: 720, height: 1280 },
|
||||
{ url: 'https://cdn.example/lo.mp4', type: 101, width: 360, height: 640 }
|
||||
],
|
||||
video_dash_manifest:
|
||||
'<MPD mediaPresentationDuration="PT0H1M0S">' +
|
||||
'<Representation bandwidth="5000000" width="720" height="1280"/>' +
|
||||
'<Representation bandwidth="400000" width="360" height="640"/>' +
|
||||
'</MPD>'
|
||||
};
|
||||
const forTelegram = instagramNodeToStatus(node, ownerFb, {
|
||||
userAgent: 'TelegramBot'
|
||||
});
|
||||
expect(forTelegram!.media?.videos?.[0]?.url).toBe('https://cdn.example/lo.mp4');
|
||||
// 400000 bps * 60s / 8 = 3_000_000 bytes
|
||||
expect(forTelegram!.media?.videos?.[0]?.filesize).toBe(3_000_000);
|
||||
});
|
||||
|
||||
it('maps polaris carousel with video slides', () => {
|
||||
const node: Record<string, unknown> = {
|
||||
pk: '1455917559229915856',
|
||||
|
||||
Reference in New Issue
Block a user