Skip to content

Fetch profile avatars via GraphQL profile query - #3

Open
fajarmf10 wants to merge 1 commit into
masterfrom
codex/avatar-graphql-profile-query-clean
Open

Fetch profile avatars via GraphQL profile query#3
fajarmf10 wants to merge 1 commit into
masterfrom
codex/avatar-graphql-profile-query-clean

Conversation

@fajarmf10

Copy link
Copy Markdown
Owner

Summary

  • Resolve profile user IDs before fetching avatars
  • Fetch profile avatars via Instagram GraphQL doc 9539110062771438
  • Keep existing avatar API routes as fallbacks and update profile tests

Tests

  • pnpm exec tsc --noEmit
  • pnpm exec vitest run src/content/profile.test.ts
  • pnpm test

@coderabbitai

coderabbitai Bot commented Apr 30, 2026

Copy link
Copy Markdown
📝 Walkthrough

Instagram Avatar Fetching - Now Using GraphQL (PR #3)

What's This PR About?

This pull request changes how the extension gets profile pictures (avatars) from Instagram. Instead of relying solely on Instagram's REST APIs, it now uses Instagram's GraphQL system as the primary method, with the old REST endpoints as fallbacks. Think of it like switching from asking a receptionist for info (REST) to querying a database directly (GraphQL) - it's more efficient and gives you better quality results!

The Big Picture: A Two-Step Dance 💃

Before getting a profile picture, the code now does something clever:

Step 1: Extract the User ID

When you visit a profile, Instagram's HTML page contains a hidden user ID. The code now:

  • Fetches the Instagram profile page (like https://www.instagram.com/username/)
  • Searches the HTML for a piece of data that looks like "user_id":"123456"
  • Caches this ID so it doesn't have to extract it again next time

Step 2: Fetch Avatar via GraphQL

With the user ID in hand, the code:

  • Sends a request to Instagram's GraphQL endpoint (/graphql/query/?doc_id=9539110062771438)
  • Includes the user ID as a variable
  • Gets back detailed profile data with multiple avatar image options
  • Picks the highest quality image available

What Actually Changed in the Code

New Concepts Added to profile.ts (~253 lines added, 20 removed)

1. Smart Username Handling

normalizeUsername() - Makes usernames lowercase and trimmed
usernameMatches() - Compares usernames case-insensitively

This ensures "jkt48.lana.a" and "JKT48.LANA.A" are treated as the same person.

2. User ID Extraction Helpers

  • getCachedProfileUserId() - Check if we already know this user's ID
  • cacheProfileUserId() - Save a user ID for later use
  • getProfileUserIdFromApiData() - Extract user IDs from API responses (handles multiple ID field names: id, pk, pk_id)
  • findUserIdByUsername() - Recursively search through nested API data to find an ID by matching the username

3. Multiple Fallback Methods for Getting User ID
The code tries several different approaches in order (like a backup plan):

  • getUserIdFromProfilePage() - Scrape the HTML page
  • getUserIdFromInstagramGraphQL() - Query Instagram's search system via GraphQL
  • getUserIdFromWebProfile() - Use Instagram's web API
  • getUserIdFromTopSearch() - Try the "top search" endpoint
  • getUserIdFromInstagramSearch() - Use Instagram's mobile search API

Each method has try/catch, so if one fails, it automatically tries the next one!

4. GraphQL-Specific Avatar Fetching

  • fetchProfileAvatarUrlFromGraphQL() - The star of the show! It:
    1. Gets the user ID (using the methods above)
    2. Calls Instagram's GraphQL endpoint with a special doc ID
    3. Extracts the best quality avatar from the response
    4. Caches it for future use

5. Helper Functions for Making Requests

  • fetchProfileAvatarData() - Makes requests with proper headers (X-IG-App-ID, credentials: 'include')
  • fetchInstagramJson() - Generic function for making JSON requests to Instagram
  • getCookieValue() - Reads values from browser cookies (needed for authentication)

6. Avatar Caching Improvements

  • New cache key: profile_user_id_by_username - Stores user IDs
  • Improved avatar caching with two levels: high-quality and fallback

Updated Tests in profile.test.ts (+32 lines added, 23 removed)

Test 1: "Fetches and caches HD avatars from the profile GraphQL query"
This test verifies the complete flow:

  1. ✅ Fetch the profile page → extract user ID
  2. ✅ Call the GraphQL endpoint with that user ID
  3. ✅ Cache both the user ID and the avatar URL
  4. ✅ Verify the correct headers are sent (including X-IG-App-ID)

The test mocks two fetch calls:

  • First call to https://www.instagram.com/jkt48.lana.a/ returns HTML with "user_id":"123456"
  • Second call to GraphQL endpoint returns high-quality avatar data

Test 2: "Checks the profile GraphQL endpoint before falling back to a low profile avatar URL"
This test ensures:

  • The GraphQL method is tried first
  • If it fails or the cached avatar is low-resolution, fallback to older REST API methods
  • The system is resilient and doesn't break if one method fails

Why This Matters

Quality ⬆️ - GraphQL gives more metadata about avatars, allowing better selection of high-resolution versions

Reliability 🛡️ - Multiple fallback methods mean the extension keeps working even if Instagram changes one API

Efficiency 🚀 - Caching user IDs means fewer requests to Instagram's servers

Cleaner Code - The GraphQL approach is more organized than multiple REST endpoint calls

Constants You'll See

New constants added (like magical recipe ingredients):

  • PROFILE_AVATAR_GRAPHQL_DOC_ID = '9539110062771438' - The GraphQL query identifier
  • PROFILE_USER_ID_CACHE_KEY = 'profile_user_id_by_username' - Where user IDs are stored
  • SEARCH_GRAPHQL_DOC_ID = '9153895011291216' - For search-based ID lookup
  • App IDs for different Instagram services (WEB_PROFILE_APP_ID, SEARCH_APP_ID)

How It All Fits Together

When you visit a profile and the extension needs the avatar:

1. Check if avatar is cached and high-quality? → Use it! ✅
2. No cached avatar? Try GraphQL method:
   a. Is user ID cached? → Use it
   b. No cached ID? Try multiple endpoints to get it
   c. Got ID? Query GraphQL for avatar
   d. Got avatar? Cache it and return ✅
3. GraphQL failed? Fall back to old REST APIs:
   a. Try web profile API
   b. Try feed user API
   c. Try user info API
   d. Return whatever high-quality image found
4. Still nothing? Return the fallback avatar from the page

The Test Validation

The test mock shows exactly what Instagram expects:

  • Requests include credentials: 'include' (sends cookies for authentication)
  • Custom header X-IG-App-ID with the app identifier
  • GraphQL request includes serialized variables with the user ID

The tests confirm the extension correctly handles both success and fallback scenarios, making sure users always get an avatar even if things go wrong.

Walkthrough

The changes implement a multi-step Instagram user ID resolution system with fallback methods and introduce GraphQL-based avatar fetching. A new caching layer stores resolved user IDs, while updated tests validate the refactored network flow and response handling across multiple endpoints.

Changes

Cohort / File(s) Summary
Test Updates
src/content/profile.test.ts
Avatar resolution tests refactored to model two-step network flow: fetch profile page HTML to extract user ID, then fetch profile data via GraphQL. Assertions updated for new fetch ordering, URLs, request headers (credentials, X-IG-App-ID), and cache validation for profile_user_id_by_username.
Core Implementation
src/content/profile.ts
Introduces username-normalized user ID caching and multi-step resolution pipeline with fallbacks (web profile, HTML scrape, GraphQL query, search endpoints). Adds helpers for cookie-bearing requests, CSRF token reading, and recursive response object traversal. Extends avatar resolution with GraphQL queries and refactors avatar fetching into shared fetchProfileAvatarData helper.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client/Extension
    participant Cache as Local Storage
    participant IGWeb as Instagram Web
    participant GraphQL as GraphQL Endpoint
    participant Search as Search API

    Client->>Cache: Check if user_id cached?
    alt User ID cached
        Cache-->>Client: Return user_id
    else User ID not cached
        Client->>IGWeb: Fetch profile page HTML<br/>(https://instagram.com/username/)
        IGWeb-->>Client: HTML + user_id in response
        alt user_id extracted
            Client->>Cache: Store user_id by username
            Cache-->>Client: ✓ Cached
        else Extraction fails
            Client->>GraphQL: Query via GraphQL doc_id
            GraphQL-->>Client: Profile data with user_id
            alt Still no user_id
                Client->>Search: Try search endpoints<br/>(top search, Instagram search)
                Search-->>Client: user_id from search results
            end
            Client->>Cache: Store resolved user_id
        end
    end

    Client->>Cache: Check avatar cache?
    alt Avatar cached
        Cache-->>Client: Return cached avatar
    else Avatar not cached
        Client->>GraphQL: Fetch avatar variants<br/>(GraphQL query with user_id)
        GraphQL-->>Client: HD avatar URL
        Client->>Cache: Store avatar URL
        Cache-->>Client: ✓ Cached
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

The implementation introduces significant new logic across multiple fallback pathways, adds several new helper functions for cookie handling and recursive object traversal, integrates GraphQL queries alongside existing REST endpoints, and requires careful validation of the caching layer and error handling flow.

Poem

🎭 A user ID quest through fallback's delight,
GraphQL queries dancing in the night,
Cache remembers what once was sought,
Avatar resolution: a lesson in robustness taught! 📸

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: fetching profile avatars via GraphQL instead of other methods, which matches the primary objective of the PR.
Description check ✅ Passed The description is directly related to the changeset, outlining the three key changes: user ID resolution, GraphQL-based avatar fetching, and fallback routes, with testing details.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/avatar-graphql-profile-query-clean

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 0/1 reviews remaining, refill in 60 minutes.

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/content/profile.ts (1)

340-347: ⚡ Quick win

Let this fallback inspect more than one TopSearch hit.

Line 342 hard-codes count=1, but findUserIdByUsername() is already written to walk a result list and find an exact username match. If the real account is ranked second or third, this fallback fails even though the endpoint returned enough data to succeed. Asking for a small batch like 10 keeps the logic the same and makes this fallback actually useful.

🔧 Small tweak
 async function getUserIdFromTopSearch(username: string) {
     const data = await fetchInstagramJson(
-        `https://www.instagram.com/web/search/topsearch/?context=blended&query=${encodeURIComponent(username)}&rank_token=0.3953592318270893&count=1`
+        `https://www.instagram.com/web/search/topsearch/?context=blended&query=${encodeURIComponent(username)}&rank_token=0.3953592318270893&count=10`
     );
     const userId = findUserIdByUsername(data, username);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/content/profile.ts` around lines 340 - 347, getUserIdFromTopSearch
currently requests only one TopSearch hit (count=1) so findUserIdByUsername
can't inspect lower-ranked matches; update the query string passed to
fetchInstagramJson in getUserIdFromTopSearch to request a small batch (e.g.,
count=10) so findUserIdByUsername can scan multiple results and find exact
username matches; leave the rest of the function logic (fetchInstagramJson call
and subsequent findUserIdByUsername usage and error handling) unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/content/profile.ts`:
- Around line 390-418: fetchProfileAvatarUrlFromGraphQL currently lets a low-res
GraphQL URL short-circuit fetchProfileAvatarUrl; change the flow so
fetchProfileAvatarUrlFromGraphQL returns an object/structure (or two values)
indicating highResolutionUrl vs lowResolutionCandidate (use
getHighResolutionProfileAvatarUrlFromApiData and
getProfileAvatarUrlFromApiData), and update fetchProfileAvatarUrl to only return
early when a confirmed HD URL is found; if only a low-res candidate is returned,
keep it as a fallback candidate (do not cache/return it yet) and continue trying
the legacy endpoints (/api/v1/users/web_profile_info,
/api/v1/feed/user/.../username/, /api/v1/users/{id}/info) to look for
profile_pic_url_hd or hd_profile_pic_url_info, then choose the best available
URL and call cacheProfileAvatarUrl once for the final chosen URL; add a
regression test that simulates GraphQL returning only low-res and a legacy
endpoint returning HD to ensure legacy HD wins.

---

Nitpick comments:
In `@src/content/profile.ts`:
- Around line 340-347: getUserIdFromTopSearch currently requests only one
TopSearch hit (count=1) so findUserIdByUsername can't inspect lower-ranked
matches; update the query string passed to fetchInstagramJson in
getUserIdFromTopSearch to request a small batch (e.g., count=10) so
findUserIdByUsername can scan multiple results and find exact username matches;
leave the rest of the function logic (fetchInstagramJson call and subsequent
findUserIdByUsername usage and error handling) unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c8de9525-8331-460a-aa50-39fd41556dec

📥 Commits

Reviewing files that changed from the base of the PR and between 2be6b1f and 8ab6acd.

📒 Files selected for processing (2)
  • src/content/profile.test.ts
  • src/content/profile.ts
📜 Review details
🔇 Additional comments (1)
src/content/profile.test.ts (1)

202-306: Nice coverage for the new two-step avatar flow.

These tests lock down the important behavior that changed here: the profile-page fetch happens first, the GraphQL request happens second, credentials: 'include' is preserved, X-IG-App-ID is sent, and the new profile_user_id_by_username cache is written. That gives this refactor a pretty solid safety net.

Comment thread src/content/profile.ts
Comment on lines +390 to +418
async function fetchProfileAvatarUrlFromGraphQL(username: string) {
const userId = await getUserId(username);
const variables = encodeURIComponent(JSON.stringify({
id: userId,
render_surface: 'PROFILE',
}));
const generatedUrl = `https://www.instagram.com/graphql/query/?doc_id=${PROFILE_AVATAR_GRAPHQL_DOC_ID}&variables=${variables}`;
const data = await fetchProfileAvatarData(generatedUrl);
const highResolutionUrl = getHighResolutionProfileAvatarUrlFromApiData(data);
const fallbackUrl = getProfileAvatarUrlFromApiData(data);

if (highResolutionUrl) {
await cacheProfileAvatarUrl(username, highResolutionUrl);
return highResolutionUrl;
}

if (fallbackUrl) {
await cacheProfileAvatarUrl(username, fallbackUrl, 'fallback');
return fallbackUrl;
}
}

async function fetchProfileAvatarUrl(username: string) {
const appId = findAppId() || '936619743392459';
try {
const graphQLUrl = await fetchProfileAvatarUrlFromGraphQL(username);
if (graphQLUrl) return graphQLUrl;
} catch (error) {
console.log(`Failed to fetch profile avatar from GraphQL profile query: ${error}`);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don’t let a low-res GraphQL result skip the older HD fallback routes.

Right now fetchProfileAvatarUrlFromGraphQL() returns fallbackUrl when GraphQL only gives you profile_pic_url, and fetchProfileAvatarUrl() treats any returned value as success. So a low-resolution GraphQL avatar stops the flow before /api/v1/users/web_profile_info/, /api/v1/feed/user/.../username/, or /api/v1/users/{id}/info/ get a chance to return profile_pic_url_hd or hd_profile_pic_url_info. That’s a real behavior change from “GraphQL first, older routes as fallbacks” to “GraphQL wins even when it’s only low-res.”

A safer pattern is: return early only for a confirmed HD GraphQL hit, otherwise keep the low-res GraphQL URL as a candidate and continue through the legacy endpoints. I’d also add a regression test for “GraphQL returns only low-res, REST fallback returns HD”.

🔧 Possible shape for the fix
-async function fetchProfileAvatarUrlFromGraphQL(username: string) {
+async function fetchProfileAvatarUrlFromGraphQL(username: string) {
     const userId = await getUserId(username);
     const variables = encodeURIComponent(JSON.stringify({
         id: userId,
         render_surface: 'PROFILE',
     }));
     const generatedUrl = `https://www.instagram.com/graphql/query/?doc_id=${PROFILE_AVATAR_GRAPHQL_DOC_ID}&variables=${variables}`;
     const data = await fetchProfileAvatarData(generatedUrl);
     const highResolutionUrl = getHighResolutionProfileAvatarUrlFromApiData(data);
     const fallbackUrl = getProfileAvatarUrlFromApiData(data);

     if (highResolutionUrl) {
         await cacheProfileAvatarUrl(username, highResolutionUrl);
-        return highResolutionUrl;
+        return { quality: 'high' as const, url: highResolutionUrl };
     }

-    if (fallbackUrl) {
-        await cacheProfileAvatarUrl(username, fallbackUrl, 'fallback');
-        return fallbackUrl;
-    }
+    return fallbackUrl
+        ? { quality: 'fallback' as const, url: fallbackUrl }
+        : undefined;
 }

 async function fetchProfileAvatarUrl(username: string) {
+    let fallbackUrl: string | undefined;
+
     try {
-        const graphQLUrl = await fetchProfileAvatarUrlFromGraphQL(username);
-        if (graphQLUrl) return graphQLUrl;
+        const graphQLAvatar = await fetchProfileAvatarUrlFromGraphQL(username);
+        if (graphQLAvatar?.quality === 'high') return graphQLAvatar.url;
+        fallbackUrl = graphQLAvatar?.url;
     } catch (error) {
         console.log(`Failed to fetch profile avatar from GraphQL profile query: ${error}`);
     }

     const endpoints = [
         `https://www.instagram.com/api/v1/users/web_profile_info/?username=${encodeURIComponent(username)}`,
         `https://www.instagram.com/api/v1/feed/user/${encodeURIComponent(username)}/username/`,
     ];
     const seenEndpoints = new Set<string>();
-    let fallbackUrl: string | undefined;

     for (let i = 0; i < endpoints.length; i++) {
         const endpoint = endpoints[i];
         ...
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/content/profile.ts` around lines 390 - 418,
fetchProfileAvatarUrlFromGraphQL currently lets a low-res GraphQL URL
short-circuit fetchProfileAvatarUrl; change the flow so
fetchProfileAvatarUrlFromGraphQL returns an object/structure (or two values)
indicating highResolutionUrl vs lowResolutionCandidate (use
getHighResolutionProfileAvatarUrlFromApiData and
getProfileAvatarUrlFromApiData), and update fetchProfileAvatarUrl to only return
early when a confirmed HD URL is found; if only a low-res candidate is returned,
keep it as a fallback candidate (do not cache/return it yet) and continue trying
the legacy endpoints (/api/v1/users/web_profile_info,
/api/v1/feed/user/.../username/, /api/v1/users/{id}/info) to look for
profile_pic_url_hd or hd_profile_pic_url_info, then choose the best available
URL and call cacheProfileAvatarUrl once for the final chosen URL; add a
regression test that simulates GraphQL returning only low-res and a legacy
endpoint returning HD to ensure legacy HD wins.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant