circleci commited on
Commit
825affe
·
1 Parent(s): 10569a6

deploy 41a98f4

Browse files
backend/package.json CHANGED
@@ -13,9 +13,9 @@
13
  "test:lite": "npx tsc && node --test tests/lite/*.lite.test.js",
14
  "test:ci": "./scripts/ci-test.sh",
15
  "test:ci:keep": "./scripts/ci-test.sh --keep",
16
- "test:single": "NODE_OPTIONS='--import ./scripts/termux-shim.js --max-old-space-size=512' npx vitest run",
17
  "verify:chunked": "VITEST_INCLUDE_MANUAL=1 SMOKE_TEST=1 npx vitest run tests/manual/chunked_fetcher_smoke.test.ts --no-coverage",
18
- "test:live": "VITEST_INCLUDE_MANUAL=1 LIVE_TEST=1 npx vitest run tests/manual/facebook_live.test.ts tests/manual/instagram_live.test.ts --no-coverage",
19
  "bench:convert": "node scripts/bench-convert.js",
20
  "lint": "bash ../scripts/lint-changed.sh",
21
  "lint:all": "eslint .",
 
13
  "test:lite": "npx tsc && node --test tests/lite/*.lite.test.js",
14
  "test:ci": "./scripts/ci-test.sh",
15
  "test:ci:keep": "./scripts/ci-test.sh --keep",
16
+ "test:single": "NODE_ENV=test NODE_OPTIONS='--import ./scripts/termux-shim.js --max-old-space-size=512' npx vitest run",
17
  "verify:chunked": "VITEST_INCLUDE_MANUAL=1 SMOKE_TEST=1 npx vitest run tests/manual/chunked_fetcher_smoke.test.ts --no-coverage",
18
+ "test:live": "VITEST_INCLUDE_MANUAL=1 LIVE_TEST=1 npx vitest run tests/manual/facebook_live.test.ts tests/manual/instagram_live.test.ts tests/manual/tiktok_live.test.ts tests/manual/x_live.test.ts tests/manual/soundcloud_live.test.ts --no-coverage",
19
  "bench:convert": "node scripts/bench-convert.js",
20
  "lint": "bash ../scripts/lint-changed.sh",
21
  "lint:all": "eslint .",
backend/scripts/termux-shim.js CHANGED
@@ -31,5 +31,7 @@ Module.prototype.require = function (name, ...args) {
31
 
32
  console.log('[env] termux bypass active');
33
 
34
- // load main app
35
- await import('../dist/backend/src/app.js');
 
 
 
31
 
32
  console.log('[env] termux bypass active');
33
 
34
+ // skip server boot under tests
35
+ if (process.env.NODE_ENV !== 'test' && !process.env.VITEST) {
36
+ await import('../dist/backend/src/app.js');
37
+ }
backend/scripts/test-sequential.sh CHANGED
@@ -30,7 +30,7 @@ for i in "${!FILES[@]}"; do
30
  IDX=$((i + 1))
31
  printf "[%d/%d] %s ... " "$IDX" "$TOTAL" "$FILE"
32
 
33
- if NODE_OPTIONS='--import ./scripts/termux-shim.js --max-old-space-size=384' \
34
  npx vitest run "$FILE" --reporter=dot --no-coverage > /tmp/vitest-out.log 2>&1; then
35
  echo "PASS"
36
  PASS=$((PASS + 1))
 
30
  IDX=$((i + 1))
31
  printf "[%d/%d] %s ... " "$IDX" "$TOTAL" "$FILE"
32
 
33
+ if NODE_ENV=test NODE_OPTIONS='--import ./scripts/termux-shim.js --max-old-space-size=384' \
34
  npx vitest run "$FILE" --reporter=dot --no-coverage > /tmp/vitest-out.log 2>&1; then
35
  echo "PASS"
36
  PASS=$((PASS + 1))
backend/src/services/extractors/instagram/constants.ts ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // required by instagram private api
2
+ export const IG_APP_ID = '936619743392459';
3
+
4
+ // graphql doc id for post query
5
+ export const POST_DOC_ID = '8845758582119845';
6
+
7
+ export const DESKTOP_UA =
8
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
9
+
10
+ // android app ua for mobile api
11
+ export const MOBILE_UA =
12
+ 'Instagram 275.0.0.27.98 Android (33/13; 280dpi; 720x1423; Xiaomi; Redmi 7; onclite; qcom; en_US; 458229237)';
13
+
14
+ // app-id unlocks logged-out web json
15
+ export const WEB_HEADERS: Record<string, string> = {
16
+ 'User-Agent': DESKTOP_UA,
17
+ 'x-ig-app-id': IG_APP_ID,
18
+ 'Accept-Language': 'en-US,en;q=0.9',
19
+ 'Sec-Fetch-Site': 'same-origin',
20
+ };
21
+
22
+ export const MOBILE_HEADERS: Record<string, string> = {
23
+ 'User-Agent': MOBILE_UA,
24
+ 'x-ig-app-id': IG_APP_ID,
25
+ 'x-ig-app-locale': 'en_US',
26
+ 'x-ig-device-locale': 'en_US',
27
+ 'x-ig-mapped-locale': 'en_US',
28
+ 'Accept-Language': 'en-US',
29
+ 'x-fb-http-engine': 'Liger',
30
+ };
31
+
32
+ export const EMBED_HEADERS: Record<string, string> = {
33
+ 'User-Agent': DESKTOP_UA,
34
+ Accept:
35
+ 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
36
+ 'Accept-Language': 'en-GB,en;q=0.9',
37
+ };
backend/src/services/extractors/instagram/fetcher.ts CHANGED
@@ -1,61 +1,117 @@
1
  import { secureFetch } from '../../../utils/network/security.util.js';
 
 
 
 
 
 
 
2
 
3
- export async function fetchJson(
4
- url: string,
5
- options: { cookie?: string } = {}
6
- ): Promise<unknown> {
 
 
 
 
 
 
 
 
7
  const cookie = typeof options.cookie === 'string' ? options.cookie : null;
8
- const response = await secureFetch(url, {
9
- headers: {
10
- 'User-Agent':
11
- 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Mobile/15E148 Safari/604.1',
12
- Accept: '*/*',
13
- 'Accept-Language': 'en-US,en;q=0.5',
14
- 'X-Requested-With': 'XMLHttpRequest',
15
- ...(cookie && { Cookie: cookie }),
16
- },
17
- signal: AbortSignal.timeout(10000),
 
 
18
  });
 
 
 
 
 
 
 
 
 
 
 
 
19
 
 
 
 
 
 
 
 
20
  if (!response.ok) return null;
21
- return await response.json();
 
22
  }
23
 
24
- export async function fetchEmbed(
25
- url: string,
26
- options: { cookie?: string } = {}
27
- ): Promise<string | null> {
28
- const cookie = typeof options.cookie === 'string' ? options.cookie : null;
29
- const response = await secureFetch(`${url}embed/captioned/`, {
 
 
 
 
 
 
30
  headers: {
31
- 'User-Agent':
32
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
33
- ...(cookie && { Cookie: cookie }),
34
  },
35
- signal: AbortSignal.timeout(10000),
 
36
  });
 
 
 
 
 
 
37
 
 
 
 
 
 
 
 
 
 
 
38
  if (!response.ok) return null;
39
  return await response.text();
40
  }
41
 
42
  export async function fetchFileSize(url: string): Promise<number | undefined> {
43
  try {
44
- const headResponse = await secureFetch(url, {
45
  method: 'HEAD',
46
- headers: {
47
- 'User-Agent':
48
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
49
- },
50
  signal: AbortSignal.timeout(5000),
51
  });
52
- if (headResponse.ok) {
53
- const contentLengthHeader = headResponse.headers.get('content-length');
54
- if (contentLengthHeader) return parseInt(contentLengthHeader, 10);
55
  }
56
  } catch (error: unknown) {
57
- const errorObj = error as Error;
58
- console.debug(`[Instagram] Size fetch failed: ${errorObj.message}`);
59
  }
60
  return undefined;
61
  }
 
1
  import { secureFetch } from '../../../utils/network/security.util.js';
2
+ import { ExtractorOptions } from '../../../types/index.js';
3
+ import {
4
+ WEB_HEADERS,
5
+ MOBILE_HEADERS,
6
+ EMBED_HEADERS,
7
+ POST_DOC_ID,
8
+ } from './constants.js';
9
 
10
+ const TIMEOUT_MS = 10000;
11
+
12
+ // pull shortcode from any post/reel/tv url
13
+ export function extractShortcode(url: string): string | null {
14
+ const match = url.match(/\/(?:reels?|p|tv)\/([A-Za-z0-9_-]+)/u);
15
+ return match ? match[1] : null;
16
+ }
17
+
18
+ function withCookie(
19
+ headers: Record<string, string>,
20
+ options: ExtractorOptions
21
+ ): Record<string, string> {
22
  const cookie = typeof options.cookie === 'string' ? options.cookie : null;
23
+ return cookie ? { ...headers, Cookie: cookie } : headers;
24
+ }
25
+
26
+ // oembed maps shortcode to numeric media id
27
+ async function fetchMediaId(
28
+ shortcode: string,
29
+ options: ExtractorOptions
30
+ ): Promise<string | null> {
31
+ const oembedUrl = `https://i.instagram.com/api/v1/oembed/?url=https://www.instagram.com/p/${shortcode}/`;
32
+ const response = await secureFetch(oembedUrl, {
33
+ headers: withCookie(MOBILE_HEADERS, options),
34
+ signal: AbortSignal.timeout(TIMEOUT_MS),
35
  });
36
+ if (!response.ok) return null;
37
+ const data = (await response.json()) as { media_id?: string };
38
+ return data?.media_id ?? null;
39
+ }
40
+
41
+ // primary path, mobile private api
42
+ export async function fetchMobileItem(
43
+ shortcode: string,
44
+ options: ExtractorOptions
45
+ ): Promise<unknown> {
46
+ const mediaId = await fetchMediaId(shortcode, options);
47
+ if (!mediaId) return null;
48
 
49
+ const response = await secureFetch(
50
+ `https://i.instagram.com/api/v1/media/${mediaId}/info/`,
51
+ {
52
+ headers: withCookie(MOBILE_HEADERS, options),
53
+ signal: AbortSignal.timeout(TIMEOUT_MS),
54
+ }
55
+ );
56
  if (!response.ok) return null;
57
+ const data = (await response.json()) as { items?: unknown[] };
58
+ return data?.items?.[0] ?? null;
59
  }
60
 
61
+ // secondary path, web graphql
62
+ export async function fetchGraphqlMedia(
63
+ shortcode: string,
64
+ options: ExtractorOptions
65
+ ): Promise<unknown> {
66
+ const body = new URLSearchParams({
67
+ doc_id: POST_DOC_ID,
68
+ variables: JSON.stringify({ shortcode }),
69
+ });
70
+
71
+ const response = await secureFetch('https://www.instagram.com/graphql/query', {
72
+ method: 'POST',
73
  headers: {
74
+ ...withCookie(WEB_HEADERS, options),
75
+ 'Content-Type': 'application/x-www-form-urlencoded',
76
+ 'X-FB-Friendly-Name': 'PolarisPostActionLoadPostQueryQuery',
77
  },
78
+ body: body.toString(),
79
+ signal: AbortSignal.timeout(TIMEOUT_MS),
80
  });
81
+ if (!response.ok) return null;
82
+ const json = (await response.json()) as {
83
+ data?: { xdt_shortcode_media?: unknown; shortcode_media?: unknown };
84
+ };
85
+ return json?.data?.xdt_shortcode_media ?? json?.data?.shortcode_media ?? null;
86
+ }
87
 
88
+ // last resort, captioned embed html
89
+ export async function fetchEmbedHtml(
90
+ url: string,
91
+ options: ExtractorOptions
92
+ ): Promise<string | null> {
93
+ const base = url.split('?')[0].replace(/\/?$/u, '/');
94
+ const response = await secureFetch(`${base}embed/captioned/`, {
95
+ headers: withCookie(EMBED_HEADERS, options),
96
+ signal: AbortSignal.timeout(TIMEOUT_MS),
97
+ });
98
  if (!response.ok) return null;
99
  return await response.text();
100
  }
101
 
102
  export async function fetchFileSize(url: string): Promise<number | undefined> {
103
  try {
104
+ const response = await secureFetch(url, {
105
  method: 'HEAD',
106
+ headers: { 'User-Agent': WEB_HEADERS['User-Agent'] },
 
 
 
107
  signal: AbortSignal.timeout(5000),
108
  });
109
+ if (response.ok) {
110
+ const len = response.headers.get('content-length');
111
+ if (len) return parseInt(len, 10);
112
  }
113
  } catch (error: unknown) {
114
+ console.debug(`[Instagram] Size fetch failed: ${(error as Error).message}`);
 
115
  }
116
  return undefined;
117
  }
backend/src/services/extractors/instagram/index.ts CHANGED
@@ -1,45 +1,69 @@
1
  import { getQuantumStream } from '../../../utils/network/proxy.util.js';
2
  import { VideoInfo, Format, ExtractorOptions } from '../../../types/index.js';
 
3
  import { Readable } from 'node:stream';
4
- import { fetchJson, fetchEmbed, fetchFileSize } from './fetcher.js';
5
- import { parseJson, parseEmbed } from './parser.js';
 
 
 
 
 
 
 
6
  import { normalizeVideoInfo } from './normalizer.js';
7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  export async function getInfo(
9
  url: string,
10
  options: ExtractorOptions = {}
11
  ): Promise<VideoInfo | null> {
12
  try {
13
- // try JSON
14
- let rawData = null;
15
- const jsonUrl = url.includes('?')
16
- ? `${url.split('?')[0]}?__a=1&__d=dis`
17
- : `${url}?__a=1&__d=dis`;
18
- const jsonData = await fetchJson(jsonUrl, options);
19
- if (jsonData) {
20
- rawData = parseJson(jsonData);
21
- }
22
-
23
- if (!rawData) {
24
- const embedHtml = await fetchEmbed(url, options);
25
- if (embedHtml) {
26
- rawData = parseEmbed(embedHtml);
27
- }
28
- }
29
-
30
- if (!rawData) return null;
31
 
32
- const videoInfo = normalizeVideoInfo(url, rawData);
33
  if (!videoInfo) return null;
34
 
35
- // fetch size
36
- for (let index = 0; index < videoInfo.formats.length; index += 2) {
37
- const batch = videoInfo.formats.slice(index, index + 2);
38
  await Promise.all(
39
- batch.map(async (formatItem: Format) => {
40
- if (formatItem.url) {
41
- const size = await fetchFileSize(formatItem.url);
42
- if (size) formatItem.filesize = size;
43
  }
44
  })
45
  );
@@ -66,10 +90,10 @@ export function getStream(
66
 
67
  return Promise.resolve(
68
  getQuantumStream(targetFormat.url, {
69
- 'User-Agent':
70
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
71
  Referer: 'https://www.instagram.com/',
72
  Accept: '*/*',
 
73
  })
74
  );
75
  }
 
1
  import { getQuantumStream } from '../../../utils/network/proxy.util.js';
2
  import { VideoInfo, Format, ExtractorOptions } from '../../../types/index.js';
3
+ import { IgParsed } from './types.js';
4
  import { Readable } from 'node:stream';
5
+ import { DESKTOP_UA } from './constants.js';
6
+ import {
7
+ extractShortcode,
8
+ fetchMobileItem,
9
+ fetchGraphqlMedia,
10
+ fetchEmbedHtml,
11
+ fetchFileSize,
12
+ } from './fetcher.js';
13
+ import { parseMobileItem, parseGraphqlMedia, parseEmbed } from './parser.js';
14
  import { normalizeVideoInfo } from './normalizer.js';
15
 
16
+ async function resolveParsed(
17
+ url: string,
18
+ options: ExtractorOptions
19
+ ): Promise<IgParsed | null> {
20
+ const shortcode = extractShortcode(url);
21
+
22
+ // ordered cascade, first with media wins
23
+ const resolvers: Array<() => Promise<IgParsed | null>> = [];
24
+ if (shortcode) {
25
+ resolvers.push(async () =>
26
+ parseMobileItem(await fetchMobileItem(shortcode, options))
27
+ );
28
+ resolvers.push(async () =>
29
+ parseGraphqlMedia(await fetchGraphqlMedia(shortcode, options))
30
+ );
31
+ }
32
+ resolvers.push(async () => {
33
+ const html = await fetchEmbedHtml(url, options);
34
+ return html ? parseEmbed(html) : null;
35
+ });
36
+
37
+ for (const resolve of resolvers) {
38
+ try {
39
+ const parsed = await resolve();
40
+ if (parsed && parsed.media.length > 0) return parsed;
41
+ } catch (error: unknown) {
42
+ console.debug(`[JS-IG] path failed: ${(error as Error).message}`);
43
+ }
44
+ }
45
+ return null;
46
+ }
47
+
48
  export async function getInfo(
49
  url: string,
50
  options: ExtractorOptions = {}
51
  ): Promise<VideoInfo | null> {
52
  try {
53
+ const parsed = await resolveParsed(url, options);
54
+ if (!parsed) return null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
 
56
+ const videoInfo = normalizeVideoInfo(url, parsed);
57
  if (!videoInfo) return null;
58
 
59
+ // sizes are optional, fetch in small batches
60
+ for (let index = 0; index < videoInfo.formats.length; index += 3) {
61
+ const batch = videoInfo.formats.slice(index, index + 3);
62
  await Promise.all(
63
+ batch.map(async (format: Format) => {
64
+ if (format.url) {
65
+ const size = await fetchFileSize(format.url);
66
+ if (size) format.filesize = size;
67
  }
68
  })
69
  );
 
90
 
91
  return Promise.resolve(
92
  getQuantumStream(targetFormat.url, {
93
+ 'User-Agent': DESKTOP_UA,
 
94
  Referer: 'https://www.instagram.com/',
95
  Accept: '*/*',
96
+ 'Accept-Language': 'en-US,en;q=0.9',
97
  })
98
  );
99
  }
backend/src/services/extractors/instagram/normalizer.ts CHANGED
@@ -1,32 +1,56 @@
1
- import { VideoInfo } from '../../../types/index.js';
2
  import { normalizeTitle, normalizeArtist } from '../../social.service.js';
 
3
 
4
- /* eslint-disable @typescript-eslint/no-explicit-any */
5
- export function normalizeVideoInfo(
6
- url: string,
7
- parsedData: any
8
- ): VideoInfo | null {
9
- if (!parsedData) return null;
10
 
11
- const rawFormats = (parsedData.formats || []) as any[];
12
- const formats = rawFormats.map((formatItem) => {
13
  return {
14
- formatId: formatItem.id || `ig_${Math.random().toString(36).slice(2, 7)}`,
15
- url: formatItem.video_url || formatItem.display_url,
16
  extension: 'mp4',
17
- resolution: formatItem.width
18
- ? `${formatItem.width}x${formatItem.height}`
19
- : 'Source',
20
- width: formatItem.width,
21
- height: formatItem.height,
22
- filesize: formatItem.filesize,
23
- acodec: 'aac',
24
  vcodec: 'h264',
25
- isAudio: true,
26
- isVideo: true,
27
  isMuxed: true,
 
 
28
  };
29
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
  const info: VideoInfo = {
32
  type: 'video',
@@ -34,15 +58,21 @@ export function normalizeVideoInfo(
34
  title: parsedData.title || 'Instagram Video',
35
  uploader: parsedData.uploader || 'Instagram User',
36
  webpageUrl: url,
 
37
  formats,
38
  extractorKey: 'instagram',
39
  isJsInfo: true,
40
  fromBrain: false,
41
  isPartial: false,
42
  isIsrcMatch: false,
43
- isFullData: false,
44
  };
45
 
 
 
 
 
 
46
  info.title = normalizeTitle(info as unknown as Record<string, unknown>);
47
  info.uploader = normalizeArtist(info as unknown as Record<string, unknown>);
48
 
 
1
+ import { VideoInfo, Format } from '../../../types/index.js';
2
  import { normalizeTitle, normalizeArtist } from '../../social.service.js';
3
+ import { IgParsed, IgMedia } from './types.js';
4
 
5
+ function toFormat(media: IgMedia, index: number, total: number): Format {
6
+ const dims =
7
+ media.width && media.height ? `${media.width}x${media.height}` : undefined;
8
+ // label carousel children for the picker
9
+ const prefix = total > 1 ? `item${index + 1}_` : '';
 
10
 
11
+ if (media.isVideo) {
 
12
  return {
13
+ formatId: `${prefix}hd`,
14
+ url: media.url,
15
  extension: 'mp4',
16
+ resolution: dims ?? 'Source',
17
+ quality: total > 1 ? `Item ${index + 1}` : 'HD',
18
+ width: media.width,
19
+ height: media.height,
 
 
 
20
  vcodec: 'h264',
21
+ acodec: 'aac',
 
22
  isMuxed: true,
23
+ isVideo: true,
24
+ isAudio: true,
25
  };
26
+ }
27
+
28
+ return {
29
+ formatId: `${prefix}photo`,
30
+ url: media.url,
31
+ extension: 'jpg',
32
+ resolution: dims ?? 'Photo',
33
+ quality: total > 1 ? `Item ${index + 1}` : 'Photo',
34
+ width: media.width,
35
+ height: media.height,
36
+ vcodec: 'none',
37
+ isMuxed: false,
38
+ isVideo: false,
39
+ isAudio: false,
40
+ };
41
+ }
42
+
43
+ export function normalizeVideoInfo(
44
+ url: string,
45
+ parsedData: IgParsed | null
46
+ ): VideoInfo | null {
47
+ if (!parsedData) return null;
48
+
49
+ const total = parsedData.media.length;
50
+ const formats: Format[] = parsedData.media.map((media, index) =>
51
+ toFormat(media, index, total)
52
+ );
53
+ if (formats.length === 0) return null;
54
 
55
  const info: VideoInfo = {
56
  type: 'video',
 
58
  title: parsedData.title || 'Instagram Video',
59
  uploader: parsedData.uploader || 'Instagram User',
60
  webpageUrl: url,
61
+ thumbnail: parsedData.thumbnail,
62
  formats,
63
  extractorKey: 'instagram',
64
  isJsInfo: true,
65
  fromBrain: false,
66
  isPartial: false,
67
  isIsrcMatch: false,
68
+ isFullData: true,
69
  };
70
 
71
+ // make caption authoritative over og:title
72
+ if (parsedData.title) {
73
+ info.metascraper = { title: parsedData.title };
74
+ }
75
+
76
  info.title = normalizeTitle(info as unknown as Record<string, unknown>);
77
  info.uploader = normalizeArtist(info as unknown as Record<string, unknown>);
78
 
backend/src/services/extractors/instagram/parser.ts CHANGED
@@ -1,70 +1,137 @@
1
- import { load, CheerioAPI } from 'cheerio';
2
  import { decode } from '../facebook/utils.js';
 
3
 
4
  /* eslint-disable @typescript-eslint/no-explicit-any */
5
- export function parseJson(jsonData: any): unknown {
6
- const items = (jsonData?.items || []) as any[];
7
- if (items.length === 0) return null;
8
 
9
- const item = items[0];
10
- const videoVersions = (item.video_versions || []) as any[];
11
- const user = item.user;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
  return {
14
- id: item.pk || item.id,
15
  title: item.caption?.text || 'Instagram Post',
16
- uploader: user?.full_name || user?.username,
17
  thumbnail: item.image_versions2?.candidates?.[0]?.url,
18
- formats: videoVersions.map((version) => ({
19
- id: version.id,
20
- video_url: version.url,
21
- width: version.width,
22
- height: version.height,
23
- })),
24
  };
25
  }
26
 
27
- const _extractThumbnailFromCheerio = (cheerioInstance: CheerioAPI) => {
28
- return (
29
- cheerioInstance('meta[property="og:image"]').attr('content') ||
30
- cheerioInstance('img.EmbeddedMediaImage').attr('src')
31
- );
32
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
- const _extractVideoUrlFromHtml = (html: string) => {
35
  const videoMatch = html.match(/"video_url"\s*:\s*"([^"]+)"/u);
36
- return videoMatch ? decode(videoMatch[1]) : null;
37
- };
38
-
39
- export function parseEmbed(html: string): unknown {
40
- const cheerioInstance = load(html);
41
- const title =
42
- cheerioInstance('meta[property="og:title"]').attr('content') ||
43
- cheerioInstance('.Caption').text() ||
44
- 'Instagram Video';
45
-
46
- const uploader =
47
- cheerioInstance('.Username').text() ||
48
- cheerioInstance('.UsernameText').text() ||
49
- 'Instagram User';
50
-
51
- const thumbnail = _extractThumbnailFromCheerio(cheerioInstance);
52
- const videoUrl = _extractVideoUrlFromHtml(html);
53
-
54
- const formats = videoUrl
55
- ? [
56
- {
57
- id: 'hd',
58
- video_url: videoUrl,
59
- },
60
- ]
61
- : [];
62
 
 
63
  return {
64
  id: null,
65
- title,
66
- uploader,
67
- thumbnail,
68
- formats,
 
 
 
69
  };
70
  }
 
1
+ import { load } from 'cheerio';
2
  import { decode } from '../facebook/utils.js';
3
+ import { IgParsed, IgMedia } from './types.js';
4
 
5
  /* eslint-disable @typescript-eslint/no-explicit-any */
 
 
 
6
 
7
+ // highest pixel-count rendition wins
8
+ function bestVideo(versions: any[]): any {
9
+ return versions.reduce((prev, next) =>
10
+ (prev.width ?? 0) * (prev.height ?? 0) <
11
+ (next.width ?? 0) * (next.height ?? 0)
12
+ ? next
13
+ : prev
14
+ );
15
+ }
16
+
17
+ function mediaFromMobile(node: any): IgMedia | null {
18
+ const videos = node?.video_versions;
19
+ if (Array.isArray(videos) && videos.length > 0) {
20
+ const best = bestVideo(videos);
21
+ return {
22
+ url: best.url,
23
+ isVideo: true,
24
+ width: best.width,
25
+ height: best.height,
26
+ };
27
+ }
28
+ const candidate = node?.image_versions2?.candidates?.[0];
29
+ if (candidate?.url) {
30
+ return {
31
+ url: candidate.url,
32
+ isVideo: false,
33
+ width: candidate.width,
34
+ height: candidate.height,
35
+ };
36
+ }
37
+ return null;
38
+ }
39
+
40
+ function mediaFromGql(node: any): IgMedia | null {
41
+ if (node?.video_url) {
42
+ return {
43
+ url: node.video_url,
44
+ isVideo: true,
45
+ width: node.dimensions?.width,
46
+ height: node.dimensions?.height,
47
+ };
48
+ }
49
+ if (node?.display_url) {
50
+ return {
51
+ url: node.display_url,
52
+ isVideo: false,
53
+ width: node.dimensions?.width,
54
+ height: node.dimensions?.height,
55
+ };
56
+ }
57
+ return null;
58
+ }
59
+
60
+ // mobile api items[0], single or carousel
61
+ export function parseMobileItem(item: any): IgParsed | null {
62
+ if (!item) return null;
63
+
64
+ const carousel = item.carousel_media;
65
+ const media: IgMedia[] = Array.isArray(carousel)
66
+ ? (carousel.map(mediaFromMobile).filter(Boolean) as IgMedia[])
67
+ : ([mediaFromMobile(item)].filter(Boolean) as IgMedia[]);
68
+
69
+ if (media.length === 0) return null;
70
 
71
  return {
72
+ id: item.code || item.pk || item.id || null,
73
  title: item.caption?.text || 'Instagram Post',
74
+ uploader: item.user?.full_name || item.user?.username || 'Instagram User',
75
  thumbnail: item.image_versions2?.candidates?.[0]?.url,
76
+ media,
 
 
 
 
 
77
  };
78
  }
79
 
80
+ // graphql shortcode_media, single or sidecar
81
+ export function parseGraphqlMedia(node: any): IgParsed | null {
82
+ if (!node) return null;
83
+
84
+ const sidecar = node.edge_sidecar_to_children?.edges;
85
+ const media: IgMedia[] = Array.isArray(sidecar)
86
+ ? (sidecar.map((edge: any) => mediaFromGql(edge?.node)).filter(Boolean) as IgMedia[])
87
+ : ([mediaFromGql(node)].filter(Boolean) as IgMedia[]);
88
+
89
+ if (media.length === 0) return null;
90
+
91
+ return {
92
+ id: node.shortcode || node.id || null,
93
+ title: node.edge_media_to_caption?.edges?.[0]?.node?.text || 'Instagram Post',
94
+ uploader: node.owner?.full_name || node.owner?.username || 'Instagram User',
95
+ thumbnail: node.display_url,
96
+ media,
97
+ };
98
+ }
99
+
100
+ // embed bundles a graphql node in contextJSON
101
+ function extractEmbedContext(html: string): any {
102
+ try {
103
+ const initMatch = html.match(/"init",\[\],\[(.*?)\]\],/u);
104
+ if (!initMatch) return null;
105
+ const init = JSON.parse(initMatch[1]);
106
+ if (!init?.contextJSON) return null;
107
+ const ctx = JSON.parse(init.contextJSON);
108
+ return ctx?.gql_data?.shortcode_media || ctx?.gql_data?.xdt_shortcode_media || null;
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+
114
+ export function parseEmbed(html: string): IgParsed | null {
115
+ const ctx = extractEmbedContext(html);
116
+ if (ctx) {
117
+ const structured = parseGraphqlMedia(ctx);
118
+ if (structured) return structured;
119
+ }
120
 
121
+ // degraded regex path for older embeds
122
  const videoMatch = html.match(/"video_url"\s*:\s*"([^"]+)"/u);
123
+ const url = videoMatch ? decode(videoMatch[1]) : null;
124
+ if (!url) return null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
 
126
+ const page = load(html);
127
  return {
128
  id: null,
129
+ title:
130
+ page('meta[property="og:title"]').attr('content') ||
131
+ page('.Caption').text() ||
132
+ 'Instagram Video',
133
+ uploader: page('.Username').text() || 'Instagram User',
134
+ thumbnail: page('meta[property="og:image"]').attr('content'),
135
+ media: [{ url, isVideo: true }],
136
  };
137
  }
backend/src/services/extractors/instagram/types.ts ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // one resolved media (carousel child or single)
2
+ export interface IgMedia {
3
+ url: string;
4
+ isVideo: boolean;
5
+ width?: number;
6
+ height?: number;
7
+ }
8
+
9
+ // normalized shape across all fetch paths
10
+ export interface IgParsed {
11
+ id: string | null;
12
+ title: string;
13
+ uploader: string;
14
+ thumbnail?: string;
15
+ media: IgMedia[];
16
+ }
backend/tests/extractors/instagram_extractor.test.ts CHANGED
@@ -3,6 +3,9 @@ import {
3
  getInfo,
4
  getStream,
5
  } from '../../src/services/extractors/instagram/index.js';
 
 
 
6
  import { Readable } from 'node:stream';
7
  import { z } from 'zod';
8
  import { CaseSchema } from '../utils/schema.js';
@@ -10,6 +13,8 @@ import { assertOutcome } from '../utils/assert.js';
10
  import rawCases from '../fixtures/extractors/instagram.json';
11
 
12
  const testCases = z.array(CaseSchema).parse(rawCases);
 
 
13
 
14
  describe('Instagram JS Extractor (Data-Driven)', () => {
15
  beforeEach(() => {
@@ -19,10 +24,13 @@ describe('Instagram JS Extractor (Data-Driven)', () => {
19
  it.each(testCases)('should extract metadata for $name', async (testCase) => {
20
  const info = await getInfo(testCase.url);
21
  assertOutcome(info, testCase.expected);
22
-
23
- if (testCase.expected.status === 'ok' && testCase.expected.type === 'video') {
24
- expect(info?.formats?.length).toBeGreaterThan(0);
25
- expect(info?.formats[0].url).toContain('test.mp4');
 
 
 
26
  }
27
  });
28
 
@@ -39,3 +47,116 @@ describe('Instagram JS Extractor (Data-Driven)', () => {
39
  stream.destroy();
40
  });
41
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  getInfo,
4
  getStream,
5
  } from '../../src/services/extractors/instagram/index.js';
6
+ import { IG_APP_ID } from '../../src/services/extractors/instagram/constants.js';
7
+ import { server } from '../setup.js';
8
+ import { http, HttpResponse } from 'msw';
9
  import { Readable } from 'node:stream';
10
  import { z } from 'zod';
11
  import { CaseSchema } from '../utils/schema.js';
 
13
  import rawCases from '../fixtures/extractors/instagram.json';
14
 
15
  const testCases = z.array(CaseSchema).parse(rawCases);
16
+ const REEL = 'https://www.instagram.com/reel/DFQe23tOWKz/';
17
+ const MEDIA_INFO = 'https://i.instagram.com/api/v1/media/:mediaId/info/';
18
 
19
  describe('Instagram JS Extractor (Data-Driven)', () => {
20
  beforeEach(() => {
 
24
  it.each(testCases)('should extract metadata for $name', async (testCase) => {
25
  const info = await getInfo(testCase.url);
26
  assertOutcome(info, testCase.expected);
27
+
28
+ if (
29
+ testCase.expected.status === 'ok' &&
30
+ testCase.expected.type === 'video'
31
+ ) {
32
+ expect(info?.formats?.length).toBeGreaterThan(0);
33
+ expect(info?.formats[0].url).toContain('test.mp4');
34
  }
35
  });
36
 
 
47
  stream.destroy();
48
  });
49
  });
50
+
51
+ describe('Instagram Gold Standard (2026)', () => {
52
+ it('sends the mandatory X-IG-App-ID header', async () => {
53
+ let sentAppId: string | null = null;
54
+ server.use(
55
+ http.get('https://i.instagram.com/api/v1/oembed/', ({ request }) => {
56
+ sentAppId = request.headers.get('x-ig-app-id');
57
+ return HttpResponse.json({ media_id: '123456_789' });
58
+ })
59
+ );
60
+
61
+ await getInfo(REEL);
62
+ expect(sentAppId).toBe(IG_APP_ID);
63
+ expect(sentAppId).toBe('936619743392459');
64
+ });
65
+
66
+ it('selects the highest-quality video version', async () => {
67
+ const info = await getInfo(REEL);
68
+ // mock serves sd 480x854 + hd 1080x1920
69
+ expect(info?.formats[0].resolution).toBe('1080x1920');
70
+ expect(info?.formats[0].url).toContain('test.mp4');
71
+ expect(info?.formats[0].url).not.toContain('test_sd');
72
+ });
73
+
74
+ it('exposes each carousel child as a format', async () => {
75
+ server.use(
76
+ http.get(MEDIA_INFO, () =>
77
+ HttpResponse.json({
78
+ items: [
79
+ {
80
+ code: 'CAROUSEL1',
81
+ caption: { text: 'Carousel Post' },
82
+ user: { username: 'test_user' },
83
+ carousel_media: [
84
+ {
85
+ video_versions: [
86
+ {
87
+ url: 'https://scontent.cdninstagram.com/v/c1.mp4',
88
+ width: 1080,
89
+ height: 1080,
90
+ },
91
+ ],
92
+ },
93
+ {
94
+ image_versions2: {
95
+ candidates: [
96
+ {
97
+ url: 'https://scontent.cdninstagram.com/c2.jpg',
98
+ width: 1080,
99
+ height: 1080,
100
+ },
101
+ ],
102
+ },
103
+ },
104
+ ],
105
+ },
106
+ ],
107
+ })
108
+ )
109
+ );
110
+
111
+ const info = await getInfo(REEL);
112
+ expect(info?.formats).toHaveLength(2);
113
+ expect(info?.formats[0].formatId).toBe('item1_hd');
114
+ expect(info?.formats[1].formatId).toBe('item2_photo');
115
+ expect(info?.formats[1].extension).toBe('jpg');
116
+ });
117
+
118
+ it('handles image-only posts as photo formats', async () => {
119
+ server.use(
120
+ http.get(MEDIA_INFO, () =>
121
+ HttpResponse.json({
122
+ items: [
123
+ {
124
+ code: 'PHOTO1',
125
+ caption: { text: 'Photo Post' },
126
+ user: { username: 'test_user' },
127
+ image_versions2: {
128
+ candidates: [
129
+ {
130
+ url: 'https://scontent.cdninstagram.com/photo.jpg',
131
+ width: 1080,
132
+ height: 1350,
133
+ },
134
+ ],
135
+ },
136
+ },
137
+ ],
138
+ })
139
+ )
140
+ );
141
+
142
+ const info = await getInfo(REEL);
143
+ expect(info?.formats[0].extension).toBe('jpg');
144
+ expect(info?.formats[0].vcodec).toBe('none');
145
+ expect(info?.formats[0].isVideo).toBe(false);
146
+ });
147
+
148
+ it('falls back to graphql when the mobile api fails', async () => {
149
+ server.use(
150
+ http.get(
151
+ 'https://i.instagram.com/api/v1/oembed/',
152
+ () => new HttpResponse(null, { status: 404 })
153
+ ),
154
+ http.get(MEDIA_INFO, () => new HttpResponse(null, { status: 404 }))
155
+ );
156
+
157
+ const info = await getInfo(REEL);
158
+ expect(info).not.toBeNull();
159
+ expect(info?.formats?.length).toBeGreaterThan(0);
160
+ expect(info?.formats[0].url).toContain('test.mp4');
161
+ });
162
+ });
backend/tests/fixtures/live-extractor-urls.json CHANGED
@@ -8,12 +8,12 @@
8
  "note": "public reel; IG may need cookies even on a residential IP"
9
  },
10
  "x": {
11
- "url": "https://x.com/farmskins/status/2044702637853905192",
12
  "note": "public tweet with video"
13
  },
14
  "tiktok": {
15
- "url": "https://www.tiktok.com/@scout2015/video/6718335390845095173",
16
- "note": "tiktok oEmbed example video; long-lived"
17
  },
18
  "soundcloud": {
19
  "url": "https://soundcloud.com/forss/flickermood",
 
8
  "note": "public reel; IG may need cookies even on a residential IP"
9
  },
10
  "x": {
11
+ "url": "https://x.com/earthshakerph/status/2063795469898444995",
12
  "note": "public tweet with video"
13
  },
14
  "tiktok": {
15
+ "url": "https://www.tiktok.com/@gablyricsssss/video/7636385053227879700",
16
+ "note": "public tiktok video"
17
  },
18
  "soundcloud": {
19
  "url": "https://soundcloud.com/forss/flickermood",
backend/tests/manual/soundcloud_live.test.ts ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect } from 'vitest';
2
+ import { readFileSync } from 'node:fs';
3
+ import { soundcloud } from '../../src/services/extractors/index.js';
4
+
5
+ // live test, real soundcloud fetch
6
+ // run on residential ip not datacenter
7
+ const RUN = process.env.LIVE_TEST === '1';
8
+ const ldescribe = RUN ? describe : describe.skip;
9
+
10
+ interface LiveUrlEntry {
11
+ url: string;
12
+ note?: string;
13
+ }
14
+
15
+ // volatile urls live in one editable json
16
+ const liveUrls = JSON.parse(
17
+ readFileSync(
18
+ new URL('../fixtures/live-extractor-urls.json', import.meta.url),
19
+ 'utf8'
20
+ )
21
+ ) as Record<string, LiveUrlEntry>;
22
+
23
+ const SC_URL = process.env.SOUNDCLOUD_LIVE_URL || liveUrls.soundcloud?.url;
24
+
25
+ ldescribe('soundcloud extractor (live)', () => {
26
+ it('resolves a real soundcloud track to playable formats', async () => {
27
+ expect(SC_URL, 'no soundcloud url in fixtures').toBeTruthy();
28
+ const info = await soundcloud.getInfo(SC_URL, {});
29
+
30
+ expect(info, 'extractor returned null — likely broken').toBeTruthy();
31
+ expect(info?.title, 'no title resolved').toBeTruthy();
32
+ // canary for soundcloud api changes
33
+ expect(
34
+ info?.formats?.length ?? 0,
35
+ 'no formats — soundcloud likely changed, extractor needs a fix'
36
+ ).toBeGreaterThan(0);
37
+
38
+ console.log(
39
+ `[live] soundcloud OK: "${info?.title}" — ${info?.formats?.length} format(s)`
40
+ );
41
+ }, 60000);
42
+ });
backend/tests/manual/tiktok_live.test.ts ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import { readFileSync } from 'node:fs';
3
+ import {
4
+ getGlobalDispatcher,
5
+ setGlobalDispatcher,
6
+ ProxyAgent,
7
+ } from 'undici';
8
+ import type { Dispatcher } from 'undici';
9
+ import { tiktok } from '../../src/services/extractors/index.js';
10
+
11
+ // live test, real tiktok fetch
12
+ // run on residential ip not datacenter
13
+ const RUN = process.env.LIVE_TEST === '1';
14
+ const ldescribe = RUN ? describe : describe.skip;
15
+
16
+ // tiktok captchas direct ips; proxy unblocks
17
+ const PROXY = process.env.LIVE_PROXY;
18
+ const TT_ENABLED = Boolean(PROXY) || process.env.TIKTOK_LIVE === '1';
19
+
20
+ interface LiveUrlEntry {
21
+ url: string;
22
+ note?: string;
23
+ }
24
+
25
+ // volatile urls live in one editable json
26
+ const liveUrls = JSON.parse(
27
+ readFileSync(
28
+ new URL('../fixtures/live-extractor-urls.json', import.meta.url),
29
+ 'utf8'
30
+ )
31
+ ) as Record<string, LiveUrlEntry>;
32
+
33
+ const TT_URL = process.env.TIKTOK_LIVE_URL || liveUrls.tiktok?.url;
34
+
35
+ ldescribe('tiktok extractor (live)', () => {
36
+ let original: Dispatcher | undefined;
37
+
38
+ // route extractor fetches through residential proxy
39
+ beforeAll(() => {
40
+ if (PROXY) {
41
+ original = getGlobalDispatcher();
42
+ setGlobalDispatcher(new ProxyAgent(PROXY));
43
+ }
44
+ });
45
+
46
+ // restore so proxy never leaks to siblings
47
+ afterAll(() => {
48
+ if (original) setGlobalDispatcher(original);
49
+ });
50
+
51
+ it(
52
+ 'resolves a real tiktok video (needs LIVE_PROXY; direct ip gets captcha)',
53
+ async (ctx) => {
54
+ if (!TT_ENABLED) {
55
+ ctx.skip();
56
+ return;
57
+ }
58
+ expect(TT_URL, 'no tiktok url in fixtures').toBeTruthy();
59
+ const info = await tiktok.getInfo(TT_URL, {});
60
+
61
+ expect(
62
+ info,
63
+ 'null — tiktok served captcha (ip flagged, try a proxy)'
64
+ ).toBeTruthy();
65
+ expect(info?.title, 'no title resolved').toBeTruthy();
66
+ // canary for tiktok page changes
67
+ expect(
68
+ info?.formats?.length ?? 0,
69
+ 'no formats — tiktok changed or captcha'
70
+ ).toBeGreaterThan(0);
71
+
72
+ console.log(
73
+ `[live] tiktok OK${PROXY ? ' (via proxy)' : ''}: "${info?.title}" — ${info?.formats?.length} format(s)`
74
+ );
75
+ },
76
+ 60000
77
+ );
78
+ });
backend/tests/manual/x_live.test.ts ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect } from 'vitest';
2
+ import { readFileSync } from 'node:fs';
3
+ import { x } from '../../src/services/extractors/index.js';
4
+
5
+ // live test, real x fetch
6
+ // run on residential ip not datacenter
7
+ const RUN = process.env.LIVE_TEST === '1';
8
+ const ldescribe = RUN ? describe : describe.skip;
9
+
10
+ interface LiveUrlEntry {
11
+ url: string;
12
+ note?: string;
13
+ }
14
+
15
+ // volatile urls live in one editable json
16
+ const liveUrls = JSON.parse(
17
+ readFileSync(
18
+ new URL('../fixtures/live-extractor-urls.json', import.meta.url),
19
+ 'utf8'
20
+ )
21
+ ) as Record<string, LiveUrlEntry>;
22
+
23
+ const X_URL = process.env.X_LIVE_URL || liveUrls.x?.url;
24
+
25
+ ldescribe('x extractor (live)', () => {
26
+ it('resolves a real x video to playable formats', async () => {
27
+ expect(X_URL, 'no x url in fixtures').toBeTruthy();
28
+ const info = await x.getInfo(X_URL, {});
29
+
30
+ expect(info, 'extractor returned null — likely broken').toBeTruthy();
31
+ expect(info?.title, 'no title resolved').toBeTruthy();
32
+ // canary for x page changes
33
+ expect(
34
+ info?.formats?.length ?? 0,
35
+ 'no formats — x likely changed, extractor needs a fix'
36
+ ).toBeGreaterThan(0);
37
+
38
+ console.log(
39
+ `[live] x OK: "${info?.title}" — ${info?.formats?.length} format(s)`
40
+ );
41
+ }, 60000);
42
+ });
backend/tests/setup.ts CHANGED
@@ -453,41 +453,78 @@ export const handlers = [
453
  });
454
  return new HttpResponse('OK');
455
  }),
456
- http.get('https://www.instagram.com/reel/DFQe23tOWKz/', (req) => {
457
- const url = new URL(req.request.url);
458
- if (url.searchParams.get('__a') === '1') {
459
- return HttpResponse.json({
460
- items: [
461
- {
462
- pk: '123456',
463
- id: '123456',
464
- caption: { text: 'Test Title #awesome' },
465
- user: { full_name: 'Test User', username: 'test_user' },
466
- image_versions2: { candidates: [{ url: 'https://thumb.jpg' }] },
467
- video_versions: [
 
 
 
468
  {
469
- id: 'hd',
470
- url: 'https://scontent.cdninstagram.com/v/test.mp4',
471
  width: 1080,
472
  height: 1920,
473
  },
474
  ],
475
  },
476
- ],
477
- });
478
- }
479
- return new HttpResponse(
480
- '<html><body><script>window.__additionalDataLoaded(\'feed\', { "shortcode_media": { "video_url": "https://scontent.cdninstagram.com/v/test.mp4", "display_url": "https://thumb.jpg", "owner": { "username": "test_user" }, "edge_media_to_caption": { "edges": [{ "node": { "text": "Test Title #awesome" } }] } } });</script></body></html>',
481
- { headers: { 'Content-Type': 'text/html' } }
482
- );
 
 
 
 
 
 
 
 
 
 
483
  }),
484
- http.get('https://api.instagram.com/oembed', () => {
 
485
  return HttpResponse.json({
486
- title: 'OEmbed Title',
487
- author_name: 'OEmbed Author',
488
- thumbnail_url: 'https://thumb.jpg',
 
 
 
 
 
 
 
 
 
 
489
  });
490
  }),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
491
  ];
492
 
493
  export const server = setupServer(...handlers);
 
453
  });
454
  return new HttpResponse('OK');
455
  }),
456
+ // oembed maps shortcode -> media id
457
+ http.get('https://i.instagram.com/api/v1/oembed/', () => {
458
+ return HttpResponse.json({ media_id: '123456_789' });
459
+ }),
460
+ // mobile private api, primary path
461
+ http.get('https://i.instagram.com/api/v1/media/:mediaId/info/', () => {
462
+ return HttpResponse.json({
463
+ items: [
464
+ {
465
+ code: 'DFQe23tOWKz',
466
+ pk: '123456',
467
+ caption: { text: 'Test Title #awesome' },
468
+ user: { full_name: 'Test User', username: 'test_user' },
469
+ image_versions2: {
470
+ candidates: [
471
  {
472
+ url: 'https://scontent.cdninstagram.com/thumb.jpg',
 
473
  width: 1080,
474
  height: 1920,
475
  },
476
  ],
477
  },
478
+ video_versions: [
479
+ {
480
+ id: 'sd',
481
+ url: 'https://scontent.cdninstagram.com/v/test_sd.mp4',
482
+ width: 480,
483
+ height: 854,
484
+ },
485
+ {
486
+ id: 'hd',
487
+ url: 'https://scontent.cdninstagram.com/v/test.mp4',
488
+ width: 1080,
489
+ height: 1920,
490
+ },
491
+ ],
492
+ },
493
+ ],
494
+ });
495
  }),
496
+ // web graphql, secondary path
497
+ http.post('https://www.instagram.com/graphql/query', () => {
498
  return HttpResponse.json({
499
+ data: {
500
+ xdt_shortcode_media: {
501
+ shortcode: 'DFQe23tOWKz',
502
+ video_url: 'https://scontent.cdninstagram.com/v/test.mp4',
503
+ display_url: 'https://scontent.cdninstagram.com/thumb.jpg',
504
+ is_video: true,
505
+ dimensions: { width: 1080, height: 1920 },
506
+ owner: { username: 'test_user', full_name: 'Test User' },
507
+ edge_media_to_caption: {
508
+ edges: [{ node: { text: 'Test Title #awesome' } }],
509
+ },
510
+ },
511
+ },
512
  });
513
  }),
514
+ // captioned embed, last resort
515
+ http.get(
516
+ 'https://www.instagram.com/reel/DFQe23tOWKz/embed/captioned/',
517
+ () => {
518
+ return new HttpResponse(
519
+ '<html><head><meta property="og:title" content="Test Title #awesome"></head><body><script>{"video_url":"https://scontent.cdninstagram.com/v/test.mp4"}</script></body></html>',
520
+ { headers: { 'Content-Type': 'text/html' } }
521
+ );
522
+ }
523
+ ),
524
+ // size probe for cdn assets
525
+ http.head('https://scontent.cdninstagram.com/*', () => {
526
+ return new HttpResponse(null, { headers: { 'content-length': '1048576' } });
527
+ }),
528
  ];
529
 
530
  export const server = setupServer(...handlers);
backend/vitest.config.js CHANGED
@@ -8,6 +8,8 @@ export default defineConfig({
8
  test: {
9
  globals: true,
10
  environment: 'node',
 
 
11
  setupFiles: ['./tests/setup.ts'],
12
  testTimeout: 60000,
13
  reporters: ['default', 'junit'],
 
8
  test: {
9
  globals: true,
10
  environment: 'node',
11
+ // vitest4 stopped auto-setting this; guards app boot
12
+ env: { NODE_ENV: 'test' },
13
  setupFiles: ['./tests/setup.ts'],
14
  testTimeout: 60000,
15
  reporters: ['default', 'junit'],