feat: test ig tg size limit

This commit is contained in:
dangered wolf
2026-07-27 02:13:52 -07:00
parent 1338b65ac1
commit 982effb8fd
6 changed files with 315 additions and 38 deletions
@@ -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(
+10
View File
@@ -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: {
+68
View File
@@ -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',