Spaces:
Paused
Paused
circleci commited on
Commit ·
825affe
1
Parent(s): 10569a6
deploy 41a98f4
Browse files- backend/package.json +2 -2
- backend/scripts/termux-shim.js +4 -2
- backend/scripts/test-sequential.sh +1 -1
- backend/src/services/extractors/instagram/constants.ts +37 -0
- backend/src/services/extractors/instagram/fetcher.ts +91 -35
- backend/src/services/extractors/instagram/index.ts +54 -30
- backend/src/services/extractors/instagram/normalizer.ts +52 -22
- backend/src/services/extractors/instagram/parser.ts +119 -52
- backend/src/services/extractors/instagram/types.ts +16 -0
- backend/tests/extractors/instagram_extractor.test.ts +125 -4
- backend/tests/fixtures/live-extractor-urls.json +3 -3
- backend/tests/manual/soundcloud_live.test.ts +42 -0
- backend/tests/manual/tiktok_live.test.ts +78 -0
- backend/tests/manual/x_live.test.ts +42 -0
- backend/tests/setup.ts +62 -25
- backend/vitest.config.js +2 -0
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 |
-
//
|
| 35 |
-
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
const cookie = typeof options.cookie === 'string' ? options.cookie : null;
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
| 18 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
if (!response.ok) return null;
|
| 21 |
-
|
|
|
|
| 22 |
}
|
| 23 |
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
headers: {
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
},
|
| 35 |
-
|
|
|
|
| 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
|
| 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 (
|
| 53 |
-
const
|
| 54 |
-
if (
|
| 55 |
}
|
| 56 |
} catch (error: unknown) {
|
| 57 |
-
|
| 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 {
|
| 5 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 14 |
-
|
| 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,
|
| 33 |
if (!videoInfo) return null;
|
| 34 |
|
| 35 |
-
// fetch
|
| 36 |
-
for (let index = 0; index < videoInfo.formats.length; index +=
|
| 37 |
-
const batch = videoInfo.formats.slice(index, index +
|
| 38 |
await Promise.all(
|
| 39 |
-
batch.map(async (
|
| 40 |
-
if (
|
| 41 |
-
const size = await fetchFileSize(
|
| 42 |
-
if (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 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
if (!parsedData) return null;
|
| 10 |
|
| 11 |
-
|
| 12 |
-
const formats = rawFormats.map((formatItem) => {
|
| 13 |
return {
|
| 14 |
-
formatId:
|
| 15 |
-
url:
|
| 16 |
extension: 'mp4',
|
| 17 |
-
resolution:
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
height: formatItem.height,
|
| 22 |
-
filesize: formatItem.filesize,
|
| 23 |
-
acodec: 'aac',
|
| 24 |
vcodec: 'h264',
|
| 25 |
-
|
| 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:
|
| 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
|
| 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 |
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 19 |
-
id: version.id,
|
| 20 |
-
video_url: version.url,
|
| 21 |
-
width: version.width,
|
| 22 |
-
height: version.height,
|
| 23 |
-
})),
|
| 24 |
};
|
| 25 |
}
|
| 26 |
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
-
|
| 35 |
const videoMatch = html.match(/"video_url"\s*:\s*"([^"]+)"/u);
|
| 36 |
-
|
| 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 |
-
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
| 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 (
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
| 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/
|
| 12 |
"note": "public tweet with video"
|
| 13 |
},
|
| 14 |
"tiktok": {
|
| 15 |
-
"url": "https://www.tiktok.com/@
|
| 16 |
-
"note": "tiktok
|
| 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 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
|
|
|
|
|
|
|
|
|
| 468 |
{
|
| 469 |
-
|
| 470 |
-
url: 'https://scontent.cdninstagram.com/v/test.mp4',
|
| 471 |
width: 1080,
|
| 472 |
height: 1920,
|
| 473 |
},
|
| 474 |
],
|
| 475 |
},
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 483 |
}),
|
| 484 |
-
|
|
|
|
| 485 |
return HttpResponse.json({
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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'],
|