diff --git a/packages/instauto/src/index.ts b/packages/instauto/src/index.ts index 8ee722c..ce9473a 100644 --- a/packages/instauto/src/index.ts +++ b/packages/instauto/src/index.ts @@ -195,6 +195,104 @@ function isInstagramUser(value: unknown): value is InstagramUser { && typeof isVerified === 'boolean'; } +// Instagram's web client now loads profile data through GraphQL (e.g. PolarisProfilePageContentQuery), +// which uses a different shape (follower_count etc.) than the old web_profile_info endpoint. +// Normalize both shapes into InstagramUser +function normalizeInstagramUser(value: unknown): InstagramUser | undefined { + if (isInstagramUser(value)) return value; + if (!isRecord(value)) return undefined; + + const { + id, pk, username, follower_count: followerCount, following_count: followingCount, is_private: isPrivate, is_verified: isVerified, + is_business: isBusiness, is_business_account: isBusinessAccount, is_professional_account: isProfessionalAccount, account_type: accountType, + full_name: fullName, biography, profile_pic_url: profilePicUrl, hd_profile_pic_url_info: hdProfilePicUrlInfo, external_url: externalUrl, + category, category_name: categoryName, business_category_name: businessCategoryName, + } = value; + + const rawId = id ?? pk; + if (typeof rawId !== 'string' && typeof rawId !== 'number') return undefined; + if (typeof username !== 'string') return undefined; + if (typeof followerCount !== 'number' || typeof followingCount !== 'number') return undefined; + if (typeof isPrivate !== 'boolean' || typeof isVerified !== 'boolean') return undefined; + + const hdProfilePicUrl = isRecord(hdProfilePicUrlInfo) ? hdProfilePicUrlInfo['url'] : undefined; + const isBusinessAccountNormalized = isBusiness === true || isBusinessAccount === true; + + return { + id: String(rawId), + username, + edge_followed_by: { count: followerCount }, + edge_follow: { count: followingCount }, + is_private: isPrivate, + is_verified: isVerified, + is_business_account: isBusinessAccountNormalized, + // account_type 2 = business, 3 = creator + is_professional_account: isProfessionalAccount === true || isBusinessAccountNormalized || accountType === 2 || accountType === 3, + full_name: typeof fullName === 'string' ? fullName : '', + biography: typeof biography === 'string' ? biography : '', + profile_pic_url_hd: typeof hdProfilePicUrl === 'string' ? hdProfilePicUrl : (typeof profilePicUrl === 'string' ? profilePicUrl : ''), + external_url: typeof externalUrl === 'string' && externalUrl !== '' ? externalUrl : null, + business_category_name: typeof businessCategoryName === 'string' ? businessCategoryName : null, + category_name: typeof categoryName === 'string' ? categoryName : (typeof category === 'string' ? category : null), + }; +} + +// Recursively look for a `user` object (e.g. `data.user`) in a GraphQL response or relay preload cache. +// If `username` is given, only a user with that username is accepted. +function findUserInJson(value: unknown, username?: string, depth = 0): InstagramUser | undefined { + if (depth > 50) return undefined; + + if (typeof value === 'string') { + // the relay preload cache sometimes contains a stringified JSON response + if (value.length > 200000 || !value.trimStart().startsWith('{') || !value.includes('"user"')) return undefined; + try { + return findUserInJson(JSON.parse(value), username, depth + 1); + } catch { + return undefined; + } + } + + if (Array.isArray(value)) { + for (const item of value) { + const found = findUserInJson(item, username, depth + 1); + if (found) return found; + } + return undefined; + } + + if (!isRecord(value)) return undefined; + + if ('user' in value) { + const user = normalizeInstagramUser(value['user']); + if (user && (username == null || user.username == null || user.username.toLowerCase() === username.toLowerCase())) return user; + } + + for (const child of Object.values(value)) { + const found = findUserInJson(child, username, depth + 1); + if (found) return found; + } + return undefined; +} + +// Some GraphQL responses are streamed as multiple newline separated JSON objects +function parseJsonChunks(text: string): unknown[] { + try { + return [JSON.parse(text)]; + } catch { + const ret: unknown[] = []; + for (const line of text.split(/\r?\n/)) { + if (line.startsWith('{')) { + try { + ret.push(JSON.parse(line)); + } catch { + // ignore + } + } + } + return ret; + } +} + // NOTE duplicated inside puppeteer page function shuffleArray(arrayIn: T[]): T[] { const array = [...arrayIn]; @@ -266,6 +364,27 @@ function Instauto(db: JSONDBInstance, page: Page, options: InstautoOptions): Ins let myUsername = myUsernameIn; const userDataCache: Record = {}; + // Profile data captured from GraphQL responses made by Instagram's own web client, keyed by lowercase username + const interceptedUsers: Record = {}; + + page.on('response', (response) => { + (async () => { + try { + if (!/\/graphql\/query|\/api\/graphql|\/web_profile_info\//.test(response.url())) return; + if (!response.ok()) return; + const contentType = response.headers()['content-type'] ?? ''; + if (!contentType.includes('json')) return; + const text = await response.text(); + for (const json of parseJsonChunks(text)) { + const user = findUserInJson(json); + if (user?.username) interceptedUsers[user.username.toLowerCase()] = user; + } + } catch { + // response body may no longer be available (e.g. after navigation) - ignore + } + })(); + }); + assert(cookiesPath); assert(db); @@ -490,38 +609,20 @@ function Instauto(db: JSONDBInstance, page: Page, options: InstautoOptions): Ins } async function getUserDataFromPage(): Promise { + // Instagram embeds the (relay) preloaded GraphQL profile data in