@eryxenx/insta-chat 2.0.11 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,31 @@
1
+ /*!
2
+ * insta-chat - Unofficial Instagram Chat API
3
+ * Copyright (c) 2026 EryXenX (https://github.com/EryXenX)
4
+ * Licensed under the Apache License, Version 2.0. See LICENSE and NOTICE.
5
+ * Original author credit must be retained in all copies and derivatives.
6
+ */
7
+
8
+ // fca.getUserID(idOrUsername, callback) -> the numeric user id (pk) as a string.
9
+ //
10
+ // Resolves an @handle to its id, or passes a numeric id through. Answers from
11
+ // the inbox-learned index first (free), then the logout-safe profile resolver.
12
+ // Returns null when the handle cannot be resolved.
13
+ module.exports = function (client) {
14
+ return async function getUserID(idOrUsername, callback) {
15
+ const cb = typeof callback === "function" ? callback : null;
16
+ const raw = String(idOrUsername == null ? "" : idOrUsername).trim().replace(/^@/, "");
17
+ if (/^\d+$/.test(raw)) {
18
+ if (cb) cb(null, raw);
19
+ return raw;
20
+ }
21
+ try {
22
+ const profile = await client.getProfile(raw).catch(() => null);
23
+ const id = profile?.id || null;
24
+ if (cb) cb(null, id);
25
+ return id;
26
+ } catch (error) {
27
+ if (cb) cb(error, null);
28
+ return null;
29
+ }
30
+ };
31
+ };
@@ -0,0 +1,62 @@
1
+ /*!
2
+ * insta-chat - Unofficial Instagram Chat API
3
+ * Copyright (c) 2026 EryXenX (https://github.com/EryXenX)
4
+ * Licensed under the Apache License, Version 2.0. See LICENSE and NOTICE.
5
+ * Original author credit must be retained in all copies and derivatives.
6
+ */
7
+
8
+ // fca.getUserInfoV2(id | id[], callback) -> { <id>: <full profile> }.
9
+ //
10
+ // V1 getUserInfo returns the fca-shaped subset (name/firstName/username/
11
+ // profileUrl/thumbSrc/gender). V2 returns the *complete* profile Instagram
12
+ // exposes: id, username, full name, biography, follower/following/media counts,
13
+ // verified/private/business flags, the profile picture (square + HD), external
14
+ // url, category and business contact details.
15
+ //
16
+ // It is logout-safe: every source it reads (the users already learned from the
17
+ // inbox/thread views, web_profile_info, the account's own page) is failure
18
+ // tolerant, so a rate-limited (429) or login-redirected (302) profile endpoint
19
+ // can never be mistaken for an expired session.
20
+ module.exports = function (client) {
21
+ return async function getUserInfoV2(ids, callback) {
22
+ const cb = typeof callback === "function" ? callback : null;
23
+ const list = Array.isArray(ids) ? ids : [ids];
24
+ try {
25
+ const out = {};
26
+ for (const id of list) {
27
+ const key = String(id);
28
+ const profile = await client.getProfile(key.replace(/^@/, "")).catch(() => null);
29
+ out[key] = profile
30
+ ? {
31
+ id: profile.id,
32
+ userID: profile.id,
33
+ username: profile.username,
34
+ name: profile.fullName,
35
+ firstName: (profile.fullName || profile.username || "").split(" ")[0] || null,
36
+ biography: profile.biography,
37
+ profileUrl: profile.profilePicUrl,
38
+ thumbSrc: profile.profilePicUrl,
39
+ profilePicUrl: profile.profilePicUrl,
40
+ profilePicUrlHd: profile.profilePicUrlHd,
41
+ followerCount: profile.followerCount,
42
+ followingCount: profile.followingCount,
43
+ mediaCount: profile.mediaCount,
44
+ isVerified: profile.isVerified,
45
+ isPrivate: profile.isPrivate,
46
+ isBusinessAccount: profile.isBusinessAccount,
47
+ isProfessionalAccount: profile.isProfessionalAccount,
48
+ externalUrl: profile.externalUrl,
49
+ category: profile.category,
50
+ fbid: profile.fbid,
51
+ gender: null
52
+ }
53
+ : null;
54
+ }
55
+ if (cb) cb(null, out);
56
+ return out;
57
+ } catch (error) {
58
+ if (cb) cb(error, null);
59
+ return {};
60
+ }
61
+ };
62
+ };
@@ -0,0 +1,26 @@
1
+ /*!
2
+ * insta-chat - Unofficial Instagram Chat API
3
+ * Copyright (c) 2026 EryXenX (https://github.com/EryXenX)
4
+ * Licensed under the Apache License, Version 2.0. See LICENSE and NOTICE.
5
+ * Original author credit must be retained in all copies and derivatives.
6
+ */
7
+
8
+ // fca.getUserProfile(idOrUsername, callback) -> the full normalized profile
9
+ // object (see src/profileParse.js): id, username, fullName, biography, counts,
10
+ // flags, pictures, externalUrl, category and contact details.
11
+ //
12
+ // Thin FCA-shaped wrapper over the native getUserProfile(); returns null (never
13
+ // throws) when nothing resolves, so a caller can fall back safely.
14
+ module.exports = function (client) {
15
+ return async function getUserProfile(idOrUsername, callback) {
16
+ const cb = typeof callback === "function" ? callback : null;
17
+ try {
18
+ const profile = await client.getProfile(String(idOrUsername).replace(/^@/, ""));
19
+ if (cb) cb(null, profile);
20
+ return profile;
21
+ } catch (error) {
22
+ if (cb) cb(error, null);
23
+ return null;
24
+ }
25
+ };
26
+ };
package/src/api.js CHANGED
@@ -47,11 +47,46 @@ class InstaAPI {
47
47
  this.reaction = new ReactionMethods(this.http);
48
48
  this.typing = new TypingMethods(this.http);
49
49
  this.thread = new ThreadMethods(this.http);
50
- this.user = new UserMethods(this.http);
50
+ this.user = new UserMethods(this.http, () => ({ id: this.userID, username: this.username }), (key) => this._learnedUser(key));
51
51
  this.inbox = new InboxMethods(this.http, () => this.userID, {
52
- minPollIntervalMs: options.minPollIntervalMs
52
+ minPollIntervalMs: options.minPollIntervalMs,
53
+ identity: this.identity,
54
+ realtime: options.realtime
53
55
  });
54
56
  this.media = new MediaMethods(this.http, this.identity);
57
+
58
+ // pk / @handle -> user record, learned from inbox/thread introspection. The
59
+ // profile REST paths rate-limit (429) or 302 to login for a web session, so
60
+ // a lookup for a user the bot has already seen answers from here for free.
61
+ this._userIndex = new Map();
62
+ this._learnInboxUsers();
63
+ }
64
+
65
+ _learnInboxUsers() {
66
+ const original = this.inbox.getInbox.bind(this.inbox);
67
+ this.inbox.getInbox = async (...args) => {
68
+ const result = await original(...args);
69
+ for (const t of result?.threads || []) {
70
+ for (const u of t.users || []) this._rememberUser(u);
71
+ if (t.viewer) this._rememberUser(t.viewer);
72
+ }
73
+ return result;
74
+ };
75
+ }
76
+
77
+ _rememberUser(u) {
78
+ if (!u || typeof u !== "object") return;
79
+ const keys = [u.id, u.pk, u.interop_messaging_user_fbid, u.username];
80
+ for (const key of keys) {
81
+ if (key) this._userIndex.set(String(key).toLowerCase(), u);
82
+ }
83
+ }
84
+
85
+ // The user record for a pk, interop fbid or @handle, from data already seen.
86
+ // Returns null when the user has not been observed yet.
87
+ _learnedUser(key) {
88
+ if (key == null) return null;
89
+ return this._userIndex.get(String(key).replace(/^@/, "").toLowerCase()) || null;
55
90
  }
56
91
 
57
92
  _loadSessionParams(file) {
@@ -122,6 +157,17 @@ class InstaAPI {
122
157
  } catch (_) {
123
158
  // GraphQL tokens are only needed for sending; polling still works without them.
124
159
  }
160
+ // EP.CURRENT_USER 302s to the login page for a web session, so the handle is
161
+ // usually still unknown here. The home shell Instagram server-renders embeds
162
+ // the viewer's own profile, which fills it in without another rate-limited call.
163
+ if (!this.username && this.userID) {
164
+ try {
165
+ const selfProfile = await this.user.getProfile(this.userID);
166
+ if (selfProfile?.username) this.username = selfProfile.username;
167
+ } catch (_) {
168
+ // A missing handle is cosmetic; the id from the cookie is enough.
169
+ }
170
+ }
125
171
  return { userID: this.userID, username: this.username };
126
172
  }
127
173
 
@@ -209,6 +255,21 @@ class InstaAPI {
209
255
  return this.user.search(query, limit);
210
256
  }
211
257
 
258
+ // Full profile (id or @handle) merged from every source a web session can
259
+ // read. Logout-safe: never throws, returns null when nothing resolves.
260
+ getUserProfile(idOrUsername, opts) {
261
+ return this.user.getProfile(idOrUsername, opts);
262
+ }
263
+
264
+ // The best available profile-picture URL. `opts.size` resizes via the CDN.
265
+ async getProfilePicture(idOrUsername, opts = {}) {
266
+ const profile = await this.user.getProfile(idOrUsername);
267
+ const url = profile?.profilePicUrlHd || profile?.profilePicUrl || null;
268
+ if (!url) return null;
269
+ const { profilePictureAtSize } = require("./profileParse");
270
+ return opts && opts.size ? profilePictureAtSize(url, opts.size) : url;
271
+ }
272
+
212
273
  sendPhoto(threadID, filePath, options) {
213
274
  return this.media.sendPhoto(threadID, filePath, options);
214
275
  }
@@ -256,6 +317,59 @@ class InstaAPI {
256
317
  stopListening() {
257
318
  this.inbox.stopListening();
258
319
  }
320
+
321
+ // ------------------------------------------------------------- realtime
322
+ // A standalone MQTT (realtime) connection, for callers that want raw push
323
+ // events (typing/seen/presence) or to drive their own dispatch, rather than
324
+ // the inbox callback listen() wires up. listen({ realtime: true }) uses the
325
+ // same RealtimeClient under the hood.
326
+ _realtimeContext() {
327
+ return {
328
+ sessionid: this.http.getCookie("sessionid"),
329
+ userId: this.userID,
330
+ deviceId: this.identity.deviceId,
331
+ uuid: this.identity.uuid
332
+ };
333
+ }
334
+
335
+ realtimeClient(options = {}) {
336
+ const { RealtimeClient } = require("./realtime/client");
337
+ return new RealtimeClient(this._realtimeContext(), options);
338
+ }
339
+
340
+ async realtimeConnect(options = {}) {
341
+ if (!this._realtime) this._realtime = this.realtimeClient(options);
342
+ await this._realtime.connect();
343
+ return this._realtime;
344
+ }
345
+
346
+ realtimeOn(event, handler) {
347
+ if (!this._realtime) this._realtime = this.realtimeClient();
348
+ this._realtime.on(event, handler);
349
+ return this;
350
+ }
351
+
352
+ // Subscribes the connected realtime client to the Direct message-sync stream,
353
+ // reading the cursor (seq_id/snapshot_at_ms) from the inbox query.
354
+ async realtimeSubscribe() {
355
+ if (!this._realtime) throw new Error("realtimeSubscribe: call realtimeConnect() first");
356
+ const state = await this.inbox.fetchRealtimeState();
357
+ if (!state) throw new Error("realtimeSubscribe: Instagram did not return a realtime sync cursor");
358
+ await this._realtime.irisSubscribe(state.seqId, state.snapshotAtMs);
359
+ return state;
360
+ }
361
+
362
+ async realtimePing() {
363
+ if (!this._realtime) throw new Error("realtimePing: call realtimeConnect() first");
364
+ return this._realtime.ping();
365
+ }
366
+
367
+ realtimeDisconnect() {
368
+ if (this._realtime) {
369
+ this._realtime.disconnect();
370
+ this._realtime = null;
371
+ }
372
+ }
259
373
  }
260
374
 
261
375
  module.exports = InstaAPI;
@@ -319,6 +319,29 @@ class InstaClient {
319
319
  return this._igPictureForUser(key, /^\d+$/.test(key) ? "id" : "username");
320
320
  }
321
321
 
322
+ // Full profile (id or @handle) from the native resolver. Merges the record
323
+ // learned from the inbox/thread views with web_profile_info and the account's
324
+ // own page, so it answers even when the profile REST paths rate-limit (429) or
325
+ // 302 to the login page. Never throws; returns null when nothing resolves.
326
+ async getProfile(idOrUsername, opts) {
327
+ if (typeof this.api.getUserProfile !== "function") return null;
328
+ const profile = await this.api.getUserProfile(idOrUsername, opts).catch(() => null);
329
+ if (profile) this._rememberProfile(profile);
330
+ return profile;
331
+ }
332
+
333
+ // Seeds the FCA caches (picture + user index) from a normalized profile so
334
+ // later getUserInfo / graph-bridge lookups are a cache hit.
335
+ _rememberProfile(profile) {
336
+ if (!profile) return;
337
+ const url = profile.profilePicUrlHd || profile.profilePicUrl;
338
+ for (const key of [profile.id, profile.pk, profile.username, profile.fbid]) {
339
+ if (!key) continue;
340
+ if (url) this._picCache.set(String(key), url);
341
+ if (profile.username) this._picCache.set(`name:${String(key)}`, profile.username);
342
+ }
343
+ }
344
+
322
345
  // Frozen commands (pp.js) call the fca name `getPictureUrl(id, type)` and
323
346
  // guard with `typeof api.getPictureUrl === "function"`; without it the call
324
347
  // is skipped and the avatar is blank. `type` mirrors fca's optional
@@ -41,19 +41,31 @@ function createFcaClient(client) {
41
41
  }),
42
42
  users: compact({
43
43
  getInfo: bindOptionalMethod(client, "getUserInfo"),
44
+ getInfoV2: bindOptionalMethod(client, "getUserInfoV2"),
45
+ getProfile: bindOptionalMethod(client, "getUserProfile"),
46
+ getProfilePicture: bindOptionalMethod(client, "getProfilePictureV2"),
47
+ getBio: bindOptionalMethod(client, "getProfileBio"),
48
+ getFriendship: bindOptionalMethod(client, "getUserFriendship"),
49
+ getID: bindOptionalMethod(client, "getUserID"),
44
50
  getUID: bindOptionalMethod(client, "getUID"),
45
51
  getByUsername: bindOptionalMethod(client, "getUserByUsername"),
46
52
  getPictureUrl: bindOptionalMethod(client, "getProfilePictureUrl")
47
53
  }),
48
54
  account: compact({
49
55
  getCurrentUserID: bindOptionalMethod(client, "getCurrentUserID"),
56
+ getSelfInfo: bindOptionalMethod(client, "getSelfInfo"),
50
57
  getAppState: bindOptionalMethod(client, "getAppState"),
51
58
  getCookies: bindOptionalMethod(client, "getCookies"),
52
59
  setOptions: bindOptionalMethod(client, "setOptions")
53
60
  }),
54
61
  realtime: compact({
55
62
  listen: bindOptionalMethod(client, "listen"),
56
- stop: bindOptionalMethod(client, "stopListening")
63
+ stop: bindOptionalMethod(client, "stopListening"),
64
+ connect: bindOptionalMethod(client, "realtimeConnect"),
65
+ on: bindOptionalMethod(client, "realtimeOn"),
66
+ subscribe: bindOptionalMethod(client, "realtimeSubscribe"),
67
+ ping: bindOptionalMethod(client, "realtimePing"),
68
+ disconnect: bindOptionalMethod(client, "realtimeDisconnect")
57
69
  }),
58
70
  http: compact({
59
71
  get: bindOptionalMethod(client, "httpGet"),
package/src/constants.js CHANGED
@@ -26,7 +26,12 @@ const DOC_ID = {
26
26
  MARK_THREAD_READ: "27399783383056109",
27
27
  THREAD_DETAIL: "29619996517588618",
28
28
  INBOX: "27909866362025854",
29
- UNSEND_MESSAGE: "26948700068153789"
29
+ UNSEND_MESSAGE: "26948700068153789",
30
+ // Profile Relay operations. PolarisProfilePageContentQuery is the one the
31
+ // profile page itself issues; PolarisUserRootQuery is its fallback. Both take
32
+ // a `username` variable and return the full `data.user` profile.
33
+ PROFILE_PAGE_CONTENT: "7898261790222653",
34
+ PROFILE_USER_ROOT: "25317500992033585"
30
35
  };
31
36
 
32
37
  const EP = {
@@ -49,6 +54,10 @@ const EP = {
49
54
  USER_INFO: (userID) => `${BASE}/users/${userID}/info/`,
50
55
  USER_BY_USERNAME: (username) => `${BASE}/users/${username}/usernameinfo/`,
51
56
  USER_SEARCH: (query, count) => `${BASE}/users/search/?q=${encodeURIComponent(query)}&count=${count}`,
57
+ // The canonical profile JSON a logged-in web session reads. Unlike the
58
+ // /users/<id>/info/ and usernameinfo REST paths, it is not 302'd to the login
59
+ // page, and it carries follower/media counts, biography and the account flags.
60
+ WEB_PROFILE_INFO: (username) => `${BASE}/users/web_profile_info/?username=${encodeURIComponent(username)}`,
52
61
  TOPSEARCH: (query) => `${WEB}/web/search/topsearch/?query=${encodeURIComponent(query)}&context=blended`,
53
62
  RUPLOAD_PHOTO: (uploadName) => `https://i.instagram.com/rupload_igphoto/${uploadName}`,
54
63
  RUPLOAD_VIDEO: (uploadName) => `https://i.instagram.com/rupload_igvideo/${uploadName}`,
@@ -27,6 +27,10 @@ class InboxMethods {
27
27
  constructor(http, getUserID, options = {}) {
28
28
  this.http = http;
29
29
  this.getUserID = getUserID;
30
+ // The device identity (deviceId/uuid) the realtime MQTT connection needs.
31
+ this.identity = options.identity || {};
32
+ // { enabled, fallback } — realtime is opt-in via listen(); see listen().
33
+ this.realtimeOptions = options.realtime || null;
30
34
  // Lowest poll interval listen() will honour, in ms. Instagram has no DM
31
35
  // push, so reply latency is bounded by one poll cycle. The 15000 default is
32
36
  // the conservative safe floor for a personal account; operators on a
@@ -327,7 +331,82 @@ class InboxMethods {
327
331
  return out;
328
332
  }
329
333
 
330
- listen(callback, intervalMs = 20000) {
334
+ // Starts the realtime (MQTT) listener, falling back to the inbox poller when
335
+ // the broker is unreachable and options.realtime.fallback !== false. Realtime
336
+ // is push (sub-second); the poller is the always-works safety net.
337
+ listen(callback, intervalMs) {
338
+ const rtOpts = this.realtimeOptions;
339
+ if (rtOpts && rtOpts.enabled) {
340
+ const { RealtimeListener } = require("../realtime/listener");
341
+ const allowFallback = rtOpts.fallback !== false;
342
+ let stopped = false;
343
+ let stopPoll = null;
344
+ const listener = new RealtimeListener(
345
+ {
346
+ sessionid: this.http.getCookie("sessionid"),
347
+ userId: this.getUserID(),
348
+ deviceId: this.identity.deviceId,
349
+ uuid: this.identity.uuid,
350
+ userAgent: rtOpts.userAgent,
351
+ appVersion: rtOpts.appVersion
352
+ },
353
+ {
354
+ host: rtOpts.host,
355
+ transport: rtOpts.transport,
356
+ keepAliveMs: rtOpts.keepAliveMs,
357
+ seqId: rtOpts.seqId,
358
+ snapshotAtMs: rtOpts.snapshotAtMs,
359
+ getRealtimeState: rtOpts.getRealtimeState || (() => this.fetchRealtimeState()),
360
+ onReady: rtOpts.onReady,
361
+ onError: (err) => {
362
+ // Before the subscription is confirmed a failure means realtime is
363
+ // not usable; degrade to polling so the bot still gets events.
364
+ if (allowFallback && !listener._subscribed && !stopped) {
365
+ stopPoll = this._startPolling(callback, intervalMs);
366
+ } else {
367
+ callback(err, null);
368
+ }
369
+ }
370
+ }
371
+ );
372
+ listener.start(callback).catch((err) => {
373
+ if (allowFallback && !stopped) stopPoll = this._startPolling(callback, intervalMs);
374
+ else callback(err, null);
375
+ });
376
+ const stop = () => {
377
+ stopped = true;
378
+ listener.stop();
379
+ if (stopPoll) stopPoll();
380
+ };
381
+ this._realtimeListener = listener;
382
+ this._stopListen = stop;
383
+ return stop;
384
+ }
385
+ this._stopListen = this._startPolling(callback, intervalMs);
386
+ return this._stopListen;
387
+ }
388
+
389
+ // The iris (Direct message-sync) subscription cursor: seq_id + snapshot time.
390
+ // Read from the inbox query when it exposes them; otherwise the listener
391
+ // connects without a subscription (a live message still opens the stream).
392
+ async fetchRealtimeState() {
393
+ try {
394
+ const res = await this.http.postGraphQL(
395
+ "PolarisDirectInboxQuery",
396
+ DOC_ID.INBOX,
397
+ { ...INBOX_VARIABLES, device_id_for_iris_subscription: this.http.session?.__hsi || this.http.uuid() },
398
+ "https://www.instagram.com/direct/inbox/"
399
+ );
400
+ const box = res?.data?.get_slide_mailbox_for_iris_subscription || {};
401
+ const seqId = box.iris_inactive_subscription_uq_seq_id;
402
+ if (seqId == null) return null;
403
+ return { seqId: Number(seqId), snapshotAtMs: Date.now() };
404
+ } catch (_) {
405
+ return null;
406
+ }
407
+ }
408
+
409
+ _startPolling(callback, intervalMs = 20000) {
331
410
  const MIN_INTERVAL_MS = this.minPollIntervalMs;
332
411
  const BASE_INTERVAL_MS = Math.max(Number(intervalMs) || 20000, MIN_INTERVAL_MS);
333
412
  const BACKOFF_START_MS = 30000;
@@ -482,6 +561,8 @@ class InboxMethods {
482
561
  }
483
562
 
484
563
  stopListening() {
564
+ if (typeof this._stopListen === "function") this._stopListen();
565
+ this._stopListen = null;
485
566
  if (this._pollTimer) clearInterval(this._pollTimer);
486
567
  }
487
568
  }
@@ -5,16 +5,37 @@
5
5
  * Original author credit must be retained in all copies and derivatives.
6
6
  */
7
7
 
8
- const { EP } = require("../constants");
8
+ const { EP, WEB, DOC_ID } = require("../constants");
9
+ const { parseProfilePage, normalizeWebProfileInfo, normalizeGraphqlUser, normalizeInboxUser, normalize } = require("../profileParse");
10
+
11
+ // A small cache so a profile fetched once (from whichever source answered) is
12
+ // reused instead of re-hit, and a per-user failure is not retried in a tight
13
+ // loop. Instagram rate-limits profile reads aggressively for web sessions.
14
+ const CACHE_TTL_MS = 10 * 60 * 1000;
9
15
 
10
16
  class UserMethods {
11
- constructor(http) {
17
+ constructor(http, getSelf, getLearned) {
12
18
  this.http = http;
19
+ // Resolves the logged-in account ({ id, username }) so a profile lookup by
20
+ // the bot's own id can find its handle without a rate-limited REST call.
21
+ this.getSelf = typeof getSelf === "function" ? getSelf : null;
22
+ // Returns a raw user record already seen in the inbox/thread views for a pk,
23
+ // interop fbid or @handle — the offline source that survives rate-limiting.
24
+ this.getLearned = typeof getLearned === "function" ? getLearned : null;
25
+ this._profileCache = new Map(); // key -> { at, value }
13
26
  }
14
27
 
28
+ // The id-only lookup. /api/v1/users/<id>/info/ is a REST path Instagram 302s
29
+ // to the login page for a web session (the HTTP layer reads any 3xx as an
30
+ // expired session), so this is best-effort: callers that need a full profile
31
+ // should use getProfile().
15
32
  async getInfo(userID) {
16
- const res = await this.http.get(EP.USER_INFO(userID));
17
- return res?.user || null;
33
+ try {
34
+ const res = await this.http.get(EP.USER_INFO(userID));
35
+ return res?.user || null;
36
+ } catch (_) {
37
+ return null;
38
+ }
18
39
  }
19
40
 
20
41
  async getInfoByUsername(username) {
@@ -59,6 +80,161 @@ class UserMethods {
59
80
  return [];
60
81
  }
61
82
  }
83
+
84
+ // --------------------------------------------------------------- profiles
85
+ // Full profile for a user id or @handle. Tries every source a web session can
86
+ // actually read, in order, and merges whatever each returns so the result is
87
+ // as complete as Instagram allows:
88
+ //
89
+ // 1. the user record already learned from the inbox/thread views (free, and
90
+ // the only source that still answers once the profile REST paths 429)
91
+ // 2. web_profile_info REST (canonical: follower/media counts, verified, ...)
92
+ // 3. the logged-in account's own page HTML (bio, hd picture, account flags)
93
+ //
94
+ // Never throws: an id that cannot be resolved returns null, and a source that
95
+ // 302s/429s is simply skipped — a profile lookup must not surface as a dead
96
+ // session.
97
+ async getProfile(idOrUsername, opts = {}) {
98
+ const raw = String(idOrUsername == null ? "" : idOrUsername).trim().replace(/^@/, "");
99
+ if (!raw) return null;
100
+ const byId = /^\d+$/.test(raw);
101
+ const key = (byId ? "id:" : "user:") + raw.toLowerCase();
102
+
103
+ const cached = this._profileCache.get(key);
104
+ if (!opts.refresh && cached && Date.now() - cached.at < CACHE_TTL_MS) return cached.value;
105
+
106
+ let username = byId ? null : raw;
107
+ const parts = [];
108
+
109
+ // 0) Whatever the inbox/thread views already told us about this user.
110
+ if (this.getLearned) {
111
+ const learned = this.getLearned(raw);
112
+ if (learned) {
113
+ const norm = normalizeInboxUser(learned);
114
+ if (norm) parts.push(norm);
115
+ if (!username && learned.username) username = learned.username;
116
+ }
117
+ }
118
+
119
+ // 1) Canonical REST profile (username only). Also resolves an id -> username.
120
+ if (!username && byId) {
121
+ const self = this.getSelf && this.getSelf();
122
+ if (self && String(self.id) === raw && self.username) {
123
+ // The logged-in account answers offline (the cookie carries its handle).
124
+ username = self.username;
125
+ } else {
126
+ const u = await this.getInfo(raw).catch(() => null);
127
+ if (u?.username) username = u.username;
128
+ }
129
+ }
130
+ if (username) {
131
+ const rest = await this._webProfileInfo(username);
132
+ if (rest) parts.push(rest);
133
+ }
134
+
135
+ // 1b) Profile GraphQL (PolarisProfilePageContentQuery). Usually rate-limited
136
+ // (429) for a web session, but when it does answer it carries the richest
137
+ // data: biography, counts, external url and the account flags.
138
+ if (username) {
139
+ const gql = await this._profileFromGraphQL(username);
140
+ if (gql) parts.push(gql);
141
+ }
142
+
143
+ // 2) Profile pages Instagram server-renders: the account's own profile and
144
+ // the home/inbox shell both embed the *viewer's* full PolarisViewer blob, so
145
+ // the logged-in account is always resolvable even when REST 302s/429s.
146
+ const self = this.getSelf && this.getSelf();
147
+ const isSelf = !!(self && self.id && String(self.id) === raw);
148
+ if (username) {
149
+ const page = await this._profileFromPage(username);
150
+ if (page) parts.push(page);
151
+ }
152
+ if (isSelf && !parts.some((p) => p && p.username)) {
153
+ const shell = await this._viewerProfileFromShell();
154
+ if (shell) parts.push(shell);
155
+ }
156
+
157
+ if (!parts.length) return null;
158
+
159
+ const merged = this._mergeProfiles(parts, { id: byId ? raw : null, username });
160
+ const entry = { at: Date.now(), value: merged };
161
+ this._profileCache.set(key, entry);
162
+ if (merged.username) this._profileCache.set("user:" + merged.username.toLowerCase(), entry);
163
+ if (merged.id) this._profileCache.set("id:" + merged.id, entry);
164
+ return merged;
165
+ }
166
+
167
+ async _webProfileInfo(username) {
168
+ try {
169
+ const res = await this.http.get(EP.WEB_PROFILE_INFO(username));
170
+ const user = res?.data?.user || res?.user;
171
+ return normalizeWebProfileInfo(user);
172
+ } catch (_) {
173
+ return null;
174
+ }
175
+ }
176
+
177
+ async _profileFromPage(username) {
178
+ try {
179
+ const res = await this.http.client.get(`${WEB}/${encodeURIComponent(username)}/`, { maxRedirects: 5 });
180
+ return parseProfilePage(typeof res.data === "string" ? res.data : "", username);
181
+ } catch (_) {
182
+ return null;
183
+ }
184
+ }
185
+
186
+ // The viewer's own profile, read from the home shell Instagram server-renders
187
+ // for a logged-in session (it embeds the full PolarisViewer blob).
188
+ async _viewerProfileFromShell() {
189
+ try {
190
+ const res = await this.http.client.get(`${WEB}/`, { maxRedirects: 5 });
191
+ return parseProfilePage(typeof res.data === "string" ? res.data : "");
192
+ } catch (_) {
193
+ return null;
194
+ }
195
+ }
196
+
197
+ // Profile GraphQL. Best-effort: a 429 / "Rate limit exceeded" / rotated doc_id
198
+ // is swallowed so the caller simply falls through to the next source.
199
+ async _profileFromGraphQL(username) {
200
+ const referer = `${WEB}/${encodeURIComponent(username)}/`;
201
+ const docs = [
202
+ ["PolarisProfilePageContentQuery", DOC_ID.PROFILE_PAGE_CONTENT],
203
+ ["PolarisUserRootQuery", DOC_ID.PROFILE_USER_ROOT]
204
+ ];
205
+ for (const [name, docId] of docs) {
206
+ try {
207
+ const res = await this.http.postGraphQL(name, docId, { username }, referer);
208
+ if (Array.isArray(res?.errors) && res.errors.length) continue;
209
+ const user = res?.data?.user;
210
+ const norm = normalizeGraphqlUser(user);
211
+ if (norm && norm.username) return norm;
212
+ } catch (_) {
213
+ // try the next operation
214
+ }
215
+ }
216
+ return null;
217
+ }
218
+
219
+ // Later sources fill only the fields earlier ones left null, so the canonical
220
+ // REST profile wins and the HTML/learned data tops it up.
221
+ _mergeProfiles(parts, fallback) {
222
+ const out = normalize({ id: fallback.id, username: fallback.username });
223
+ for (const part of parts) {
224
+ for (const [k, v] of Object.entries(part)) {
225
+ if (k === "raw") continue;
226
+ const empty = out[k] === null || out[k] === undefined || out[k] === "";
227
+ if (empty && v !== null && v !== undefined && v !== "") out[k] = v;
228
+ }
229
+ for (const flag of ["isVerified", "isPrivate", "isBusinessAccount", "isProfessionalAccount", "hasProfilePic"]) {
230
+ if (part[flag] === true) out[flag] = true;
231
+ }
232
+ if (!out.raw && part.raw) out.raw = part.raw;
233
+ }
234
+ if (!out.id && fallback.id) out.id = String(fallback.id);
235
+ if (!out.pk) out.pk = out.id;
236
+ return out;
237
+ }
62
238
  }
63
239
 
64
240
  module.exports = UserMethods;