1616
1717const IG_APP_ID = '936619743392459' ;
1818const PLATFORM = 'instagram' ;
19- const VERSION = '2.0.1 -api-playwright' ;
19+ const VERSION = '2.0.2 -api-playwright' ;
2020const CANONICAL_SCOPES = [
2121 'instagram.profile' ,
2222 'instagram.posts' ,
@@ -27,11 +27,14 @@ const CANONICAL_SCOPES = [
2727const POSTS_PAGE_SIZE = 12 ;
2828const FRIENDSHIP_PAGE_SIZE = 50 ;
2929const REQUEST_DELAY_MS = 800 ;
30+ const AUTH_SETTLE_DELAY_MS = 2500 ;
31+ const RATE_LIMIT_BACKOFF_MS = [ 3000 , 7000 , 15000 ] ;
3032const MAX_POSTS_PAGES = 50 ;
3133const MAX_FRIENDSHIP_PAGES = 2000 ;
3234const DISCOVERY_TIMEOUT_MS = 20000 ;
3335const DISCOVERY_POLL_MS = 250 ;
3436const MAX_LOGIN_ATTEMPTS = 3 ;
37+ const MAX_RATE_LIMIT_RETRIES = RATE_LIMIT_BACKOFF_MS . length ;
3538
3639const readOptionalProcessEnv = ( key ) => {
3740 if ( typeof process === 'undefined' || ! process ?. env ) {
@@ -67,6 +70,9 @@ const makeFatalRunError = (errorClass, reason, phase = 'collect') => {
6770
6871const 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 / 4 2 9 | r a t e l i m i t | t o o m a n y r e q u e s t s / 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+
259370const readCsrfToken = async ( ) => {
260371 try {
261372 return await page . evaluate ( `
@@ -674,45 +785,58 @@ const performLogin = async () => {
674785} ;
675786
676787const checkLoginStatus = async ( ) => {
677- const info = await fetchWebInfo ( ) ;
678- return ! ! ( info && info . username ) ;
788+ const evidence = await readSessionEvidence ( ) ;
789+ return sessionLooksAuthenticated ( evidence ) ;
679790} ;
680791
681792const 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 ───────────────────────────────────────
0 commit comments