Skip to content

Commit 7e29647

Browse files
committed
fix(instagram): harden api session validation
1 parent f085d9b commit 7e29647

5 files changed

Lines changed: 179 additions & 17 deletions

File tree

Binary file not shown.

connector-index.json

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,43 @@
458458
"iconKey": "instagram",
459459
"defaultScope": "instagram.profile"
460460
}
461+
},
462+
{
463+
"connectorId": "instagram-api-playwright",
464+
"company": "meta",
465+
"version": "2.0.2",
466+
"name": "Instagram (API)",
467+
"status": "experimental",
468+
"description": "Exports your Instagram profile, posts, followers, following, and ad interests using API-first network replay (no DOM scraping for data).",
469+
"sourceFiles": {
470+
"script": "meta/instagram-api-playwright.js",
471+
"metadata": "meta/instagram-api-playwright.json"
472+
},
473+
"publishedAt": "2026-04-15T00:00:00Z",
474+
"gitRef": "fix/instagram-api-compat",
475+
"pageApiVersion": 1,
476+
"manifestSha256": "sha256:8c67a41a9c5bf4e6bcb3c5e4f6923bc9022f1e26a5ce2959d7308bb23a180798",
477+
"scriptSha256": "sha256:f40b9b2568e9b69a3adbb2c9e006d6c22d1dad9a706b71c1a9deacae1c82665a",
478+
"artifactSha256": "sha256:ddee2b1d8a3f503a03066f9d637959c8d9eeb4d8a8aa6dcf964242175c8e4556",
479+
"artifactPath": "artifacts/instagram-api-playwright/instagram-api-playwright-2.0.2.tgz",
480+
"artifactUrl": "https://raw.githubusercontent.com/vana-com/data-connectors/fix/instagram-api-compat/artifacts/instagram-api-playwright/instagram-api-playwright-2.0.2.tgz",
481+
"scopes": [
482+
"instagram.profile",
483+
"instagram.posts",
484+
"instagram.followers",
485+
"instagram.following",
486+
"instagram.ads"
487+
],
488+
"consumerMetadata": {
489+
"sourceId": "instagram",
490+
"displayName": "Instagram (API)",
491+
"brandDomain": "instagram.com",
492+
"aliases": [
493+
"meta"
494+
],
495+
"iconKey": "instagram",
496+
"defaultScope": "instagram.profile"
497+
}
461498
}
462499
],
463500
"instagram-playwright": [

connectors/meta/instagram-api-playwright.js

Lines changed: 140 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
const IG_APP_ID = '936619743392459';
1818
const PLATFORM = 'instagram';
19-
const VERSION = '2.0.1-api-playwright';
19+
const VERSION = '2.0.2-api-playwright';
2020
const CANONICAL_SCOPES = [
2121
'instagram.profile',
2222
'instagram.posts',
@@ -27,11 +27,14 @@ const CANONICAL_SCOPES = [
2727
const POSTS_PAGE_SIZE = 12;
2828
const FRIENDSHIP_PAGE_SIZE = 50;
2929
const REQUEST_DELAY_MS = 800;
30+
const AUTH_SETTLE_DELAY_MS = 2500;
31+
const RATE_LIMIT_BACKOFF_MS = [3000, 7000, 15000];
3032
const MAX_POSTS_PAGES = 50;
3133
const MAX_FRIENDSHIP_PAGES = 2000;
3234
const DISCOVERY_TIMEOUT_MS = 20000;
3335
const DISCOVERY_POLL_MS = 250;
3436
const MAX_LOGIN_ATTEMPTS = 3;
37+
const MAX_RATE_LIMIT_RETRIES = RATE_LIMIT_BACKOFF_MS.length;
3538

3639
const readOptionalProcessEnv = (key) => {
3740
if (typeof process === 'undefined' || !process?.env) {
@@ -67,6 +70,9 @@ const makeFatalRunError = (errorClass, reason, phase = 'collect') => {
6770

6871
const inferErrorClass = (message, fallback = 'runtime_error') => {
6972
const text = String(message || '').toLowerCase();
73+
if (text.includes('429') || text.includes('rate limit')) {
74+
return 'rate_limited';
75+
}
7076
if (
7177
text.includes('auth') ||
7278
text.includes('login') ||
@@ -156,7 +162,28 @@ const setAuthState = async (state) => {
156162

157163
// ─── In-page fetch helper ────────────────────────────────────
158164

159-
const fetchApi = async (url, options) => {
165+
const normalizeRetryAfterMs = (value) => {
166+
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
167+
return value;
168+
}
169+
if (typeof value !== 'string') {
170+
return null;
171+
}
172+
173+
const trimmed = value.trim();
174+
if (/^\d+$/.test(trimmed)) {
175+
return Number(trimmed) * 1000;
176+
}
177+
178+
const parsed = Date.parse(trimmed);
179+
if (!Number.isFinite(parsed)) {
180+
return null;
181+
}
182+
183+
return Math.max(parsed - Date.now(), 0);
184+
};
185+
186+
const fetchApiOnce = async (url, options) => {
160187
const opts = options || {};
161188
const urlStr = JSON.stringify(url);
162189
const requestSpec = {
@@ -179,7 +206,11 @@ const fetchApi = async (url, options) => {
179206
if (spec.body !== null && spec.body !== undefined) init.body = spec.body;
180207
const r = await fetch(${urlStr}, init);
181208
if (!r.ok) {
182-
return { _error: 'http ' + r.status + ' ' + r.statusText };
209+
return {
210+
_error: 'http ' + r.status + ' ' + r.statusText,
211+
_status: r.status,
212+
_retryAfter: r.headers.get('retry-after'),
213+
};
183214
}
184215
if (spec.asText) {
185216
return { _ok: true, text: await r.text() };
@@ -195,6 +226,40 @@ const fetchApi = async (url, options) => {
195226
}
196227
};
197228

229+
const isRateLimitError = (result) => {
230+
if (!result?._error) {
231+
return false;
232+
}
233+
234+
if (result._status === 429) {
235+
return true;
236+
}
237+
238+
return /429|rate limit|too many requests/i.test(String(result._error));
239+
};
240+
241+
const fetchApi = async (url, options) => {
242+
const opts = options || {};
243+
244+
for (let attempt = 0; attempt <= MAX_RATE_LIMIT_RETRIES; attempt++) {
245+
const result = await fetchApiOnce(url, opts);
246+
if (!isRateLimitError(result) || attempt === MAX_RATE_LIMIT_RETRIES) {
247+
return result;
248+
}
249+
250+
const retryAfterMs = normalizeRetryAfterMs(result._retryAfter);
251+
const backoffMs =
252+
retryAfterMs && retryAfterMs > 0
253+
? retryAfterMs
254+
: RATE_LIMIT_BACKOFF_MS[Math.min(attempt, RATE_LIMIT_BACKOFF_MS.length - 1)];
255+
256+
await page.setData('status', `Instagram rate limited; retrying in ${Math.ceil(backoffMs / 1000)}s...`);
257+
await page.sleep(backoffMs);
258+
}
259+
260+
return { _error: 'http 429 Too Many Requests', _status: 429 };
261+
};
262+
198263
// ─── Login (API-based) ───────────────────────────────────────
199264
// We POST credentials directly to /api/v1/web/accounts/login/ajax/ instead of
200265
// performing a DOM form fill (no `input.value = ...` style automation). The
@@ -256,6 +321,52 @@ const buildAuthState = async (stage, extras = {}) => {
256321
};
257322
};
258323

324+
const readSessionEvidence = async () => {
325+
const [authUi, dsUserId, webInfo] = await Promise.all([
326+
readAuthUiSnapshot(),
327+
readDsUserId(),
328+
fetchWebInfo(),
329+
]);
330+
331+
let hasLoggedInChrome = false;
332+
try {
333+
hasLoggedInChrome = await page.evaluate(`
334+
(() =>
335+
Boolean(document.querySelector('svg[aria-label="Home"], a[href="/direct/inbox/"]'))
336+
)()
337+
`);
338+
} catch (error) {
339+
hasLoggedInChrome = false;
340+
}
341+
342+
return {
343+
authUi: authUi || {},
344+
dsUserId: dsUserId || null,
345+
webInfo: webInfo || null,
346+
hasLoggedInChrome,
347+
};
348+
};
349+
350+
const sessionLooksAuthenticated = (evidence) => {
351+
if (!evidence) {
352+
return false;
353+
}
354+
355+
if (!evidence.dsUserId) {
356+
return false;
357+
}
358+
359+
if (evidence.authUi?.stillOnLoginForm) {
360+
return false;
361+
}
362+
363+
if (!evidence.webInfo?.username) {
364+
return false;
365+
}
366+
367+
return evidence.hasLoggedInChrome || evidence.authUi?.currentUrl === 'https://www.instagram.com/';
368+
};
369+
259370
const readCsrfToken = async () => {
260371
try {
261372
return await page.evaluate(`
@@ -674,45 +785,58 @@ const performLogin = async () => {
674785
};
675786

676787
const checkLoginStatus = async () => {
677-
const info = await fetchWebInfo();
678-
return !!(info && info.username);
788+
const evidence = await readSessionEvidence();
789+
return sessionLooksAuthenticated(evidence);
679790
};
680791

681792
const ensureLoggedIn = async () => {
682793
await setAuthState(await buildAuthState('checking_login'));
683794
await page.setData('status', 'Checking login status...');
684795
await safeGoto('https://www.instagram.com/');
685-
await page.sleep(2000);
796+
await page.sleep(AUTH_SETTLE_DELAY_MS);
797+
await dismissInterstitials();
686798

687-
let info = await fetchWebInfo();
688-
if (info && info.username) {
799+
let evidence = await readSessionEvidence();
800+
if (sessionLooksAuthenticated(evidence)) {
689801
await setAuthState(
690802
await buildAuthState('authenticated', {
691803
restoredSession: true,
804+
dsUserId: evidence.dsUserId,
692805
}),
693806
);
694807
await page.setData('status', 'Session restored');
695-
return info;
808+
return evidence.webInfo;
696809
}
697810

698811
await setAuthState(await buildAuthState('login_required'));
699812
await page.setData('status', 'Logging in...');
700813
await performLogin();
701814

702815
for (let attempt = 0; attempt < 3; attempt++) {
703-
info = await fetchWebInfo();
704-
if (info && info.username) {
816+
await safeGoto('https://www.instagram.com/');
817+
await page.sleep(AUTH_SETTLE_DELAY_MS);
818+
await dismissInterstitials();
819+
evidence = await readSessionEvidence();
820+
if (sessionLooksAuthenticated(evidence)) {
705821
await setAuthState(
706822
await buildAuthState('authenticated', {
707823
restoredSession: false,
824+
dsUserId: evidence.dsUserId,
708825
}),
709826
);
710827
await page.setData('status', 'Login successful');
711-
return info;
828+
return evidence.webInfo;
712829
}
713830
await page.sleep(1500);
714831
}
715832

833+
await setAuthState(
834+
await buildAuthState('login_api_did_not_establish_session', {
835+
dsUserId: evidence?.dsUserId || null,
836+
hasLoggedInChrome: evidence?.hasLoggedInChrome || false,
837+
}),
838+
);
839+
716840
await setAuthState(await buildAuthState('manual_verification_required'));
717841
const { headed } = await page.showBrowser('https://www.instagram.com/accounts/login/');
718842
if (!headed) {
@@ -727,17 +851,18 @@ const ensureLoggedIn = async () => {
727851
await page.goHeadless();
728852
await dismissInterstitials();
729853

730-
info = await fetchWebInfo();
731-
if (!info || !info.username) {
854+
evidence = await readSessionEvidence();
855+
if (!sessionLooksAuthenticated(evidence)) {
732856
await setAuthState(await buildAuthState('auth_failed_after_fallback'));
733857
throw new Error('Instagram login failed after headed fallback');
734858
}
735859
await setAuthState(
736860
await buildAuthState('authenticated', {
737861
completedManualFallback: true,
862+
dsUserId: evidence.dsUserId,
738863
}),
739864
);
740-
return info;
865+
return evidence.webInfo;
741866
};
742867

743868
// ─── Profile collector ───────────────────────────────────────

connectors/meta/instagram-api-playwright.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"manifest_version": "1.0",
33
"connector_id": "instagram-api-playwright",
44
"source_id": "instagram",
5-
"version": "2.0.1",
5+
"version": "2.0.2",
66
"name": "Instagram (API)",
77
"company": "Meta",
88
"description": "Exports your Instagram profile, posts, followers, following, and ad interests using API-first network replay (no DOM scraping for data).",

registry.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@
8181
{
8282
"id": "instagram-api-playwright",
8383
"company": "meta",
84-
"version": "2.0.1",
84+
"version": "2.0.2",
8585
"name": "Instagram (API)",
8686
"status": "experimental",
8787
"description": "Exports your Instagram profile, posts, followers, following, and ad interests using API-first network replay (no DOM scraping for data).",

0 commit comments

Comments
 (0)