fix(instagram): stop depending on web_profile_info for business accounts (#2238)

* fix(instagram): resolve business-account user ids without web_profile_info

* fix(instagram): harden business account fallback

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
This commit is contained in:
Bo Liu
2026-08-08 17:55:48 +09:00
committed by GitHub
parent 05cfbee66e
commit 073c214507
10 changed files with 520 additions and 108 deletions
+39
View File
@@ -0,0 +1,39 @@
/**
* In-page snippet that resolves `username` to a numeric user id in `userId`.
*
* `web_profile_info` answers HTTP 400 for business and professional accounts,
* so the commands that need an id fall back to feed-by-username. Its root
* `user.pk` is the profile owner; `items[0].user.pk` can be a pinned collab
* author. Callers must already have `username` and `opts` in scope.
*/
export function buildResolveInstagramUserIdJs() {
return `
function normalizeInstagramUserId(value, label) {
const id = typeof value === 'number' ? String(value) : (typeof value === 'string' ? value.trim() : '');
if (!/^\\d+$/.test(id)) throw new Error(label);
return id;
}
async function readInstagramJson(response, label) {
try {
return await response.json();
} catch {
throw new Error(label + ' returned invalid JSON');
}
}
function throwInstagramHttpError(response, label, username) {
if (response.status === 404) throw new Error('User not found: ' + username);
if (response.status === 401 || response.status === 403) {
throw new Error('HTTP ' + response.status + ' - make sure you are logged in to Instagram');
}
throw new Error(label + ' failed: HTTP ' + response.status);
}
const r1 = await fetch('https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), opts);
if (r1.status === 404) throw new Error('User not found: ' + username);
if (!r1.ok && r1.status !== 400) throwInstagramHttpError(r1, 'Instagram web_profile_info', username);
let userId = r1.ok ? normalizeInstagramUserId((await readInstagramJson(r1, 'Instagram web_profile_info'))?.data?.user?.id, 'Instagram web_profile_info returned no valid user id for: ' + username) : '';
if (!userId) {
const r1b = await fetch('https://www.instagram.com/api/v1/feed/user/' + encodeURIComponent(username) + '/username/?count=1', opts);
if (!r1b.ok) throwInstagramHttpError(r1b, 'Instagram feed-by-username', username);
userId = normalizeInstagramUserId((await readInstagramJson(r1b, 'Instagram feed-by-username'))?.user?.pk, 'Instagram feed returned no valid profile owner for: ' + username);
}`;
}
+31 -10
View File
@@ -22,25 +22,46 @@ cli({
const username = \${{ args.username | json }};
const commentText = \${{ args.text | json }};
const idx = \${{ args.index }} - 1;
if (!Number.isInteger(idx) || idx < 0) throw new Error('index must be a positive integer');
const headers = { 'X-IG-App-ID': '936619743392459' };
const opts = { credentials: 'include', headers };
async function readInstagramJson(response, label) {
try {
return await response.json();
} catch {
throw new Error(label + ' returned invalid JSON');
}
}
function getPostFromFeed(feed, label) {
if (!feed || typeof feed !== 'object' || !Array.isArray(feed.items)) {
throw new Error(label + ' returned malformed items payload');
}
if (idx >= feed.items.length) throw new Error('Post index ' + (idx + 1) + ' not found');
const post = feed.items[idx];
const pkRaw = post?.pk ?? post?.id;
const pk = typeof pkRaw === 'number' ? String(pkRaw) : (typeof pkRaw === 'string' ? pkRaw.trim() : '');
if (!/^\\d+$/.test(pk)) throw new Error(label + ' returned malformed post row');
return { pk };
}
function assertOkStatus(payload, label) {
if (!payload || typeof payload !== 'object' || payload.status !== 'ok') {
throw new Error(label + ' returned no success evidence');
}
}
const r1 = await fetch('https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), opts);
if (!r1.ok) throw new Error('User not found: ' + username);
const userId = (await r1.json())?.data?.user?.id;
const r2 = await fetch('https://www.instagram.com/api/v1/feed/user/' + userId + '/?count=' + (idx + 1), opts);
const posts = (await r2.json())?.items || [];
if (idx >= posts.length) throw new Error('Post index ' + (idx + 1) + ' not found');
const pk = posts[idx].pk;
// web_profile_info answers HTTP 400 for business accounts; feed-by-username needs no user id. See #2234.
const r1 = await fetch('https://www.instagram.com/api/v1/feed/user/' + encodeURIComponent(username) + '/username/?count=' + (idx + 1), opts);
if (!r1.ok) throw new Error(r1.status === 404 ? 'User not found: ' + username : 'HTTP ' + r1.status + ' - make sure you are logged in to Instagram');
const { pk } = getPostFromFeed(await readInstagramJson(r1, 'Instagram feed-by-username'), 'Instagram feed-by-username');
const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || '';
const r3 = await fetch('https://www.instagram.com/api/v1/web/comments/' + pk + '/add/', {
const r2 = await fetch('https://www.instagram.com/api/v1/web/comments/' + pk + '/add/', {
method: 'POST', credentials: 'include',
headers: { ...headers, 'X-CSRFToken': csrf, 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'comment_text=' + encodeURIComponent(commentText),
});
if (!r3.ok) throw new Error('Failed to comment: HTTP ' + r3.status);
if (!r2.ok) throw new Error('Failed to comment: HTTP ' + r2.status);
assertOkStatus(await readInstagramJson(r2, 'Instagram comment'), 'Instagram comment');
return [{ status: 'Commented', user: username, text: commentText }];
})()
` },
+13 -8
View File
@@ -1,4 +1,5 @@
import { cli } from '@jackwener/opencli/registry';
import { buildResolveInstagramUserIdJs } from './_shared/user-id.js';
cli({
site: 'instagram',
name: 'follow',
@@ -21,12 +22,7 @@ cli({
const headers = { 'X-IG-App-ID': '936619743392459' };
const opts = { credentials: 'include', headers };
// Get user ID
const r1 = await fetch('https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), opts);
if (!r1.ok) throw new Error('User not found: ' + username);
const d1 = await r1.json();
const userId = d1?.data?.user?.id;
if (!userId) throw new Error('User not found: ' + username);
${buildResolveInstagramUserIdJs()}
const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || '';
const r2 = await fetch('https://www.instagram.com/api/v1/friendships/create/' + userId + '/', {
@@ -35,8 +31,17 @@ cli({
headers: { ...headers, 'X-CSRFToken': csrf, 'Content-Type': 'application/x-www-form-urlencoded' },
});
if (!r2.ok) throw new Error('Failed to follow: HTTP ' + r2.status);
const d2 = await r2.json();
const status = d2?.friendship_status?.following ? 'Following' : d2?.friendship_status?.outgoing_request ? 'Request sent' : 'Followed';
let d2;
try {
d2 = await r2.json();
} catch {
throw new Error('Instagram follow returned invalid JSON');
}
if (!d2 || typeof d2 !== 'object' || d2.status !== 'ok' || !d2.friendship_status || typeof d2.friendship_status !== 'object') {
throw new Error('Instagram follow returned no success evidence');
}
const status = d2.friendship_status.following ? 'Following' : d2.friendship_status.outgoing_request ? 'Request sent' : '';
if (!status) throw new Error('Instagram follow returned no success evidence');
return [{ status, username }];
})()
` },
+24 -15
View File
@@ -1,4 +1,5 @@
import { cli } from '@jackwener/opencli/registry';
import { buildResolveInstagramUserIdJs } from './_shared/user-id.js';
cli({
site: 'instagram',
name: 'followers',
@@ -15,17 +16,11 @@ cli({
{ evaluate: `(async () => {
const username = \${{ args.username | json }};
const limit = \${{ args.limit }};
if (!Number.isInteger(limit) || limit < 1) throw new Error('limit must be a positive integer');
const headers = { 'X-IG-App-ID': '936619743392459' };
const opts = { credentials: 'include', headers };
const r1 = await fetch(
'https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username),
opts
);
if (!r1.ok) throw new Error('HTTP ' + r1.status + ' - make sure you are logged in to Instagram');
const d1 = await r1.json();
const userId = d1?.data?.user?.id;
if (!userId) throw new Error('User not found: ' + username);
${buildResolveInstagramUserIdJs()}
const r2 = await fetch(
'https://www.instagram.com/api/v1/friendships/' + userId + '/followers/?count=' + limit,
@@ -33,13 +28,27 @@ cli({
);
if (!r2.ok) throw new Error('Failed to fetch followers: HTTP ' + r2.status);
const d2 = await r2.json();
return (d2?.users || []).slice(0, limit).map((u, i) => ({
rank: i + 1,
username: u.username || '',
name: u.full_name || '',
verified: u.is_verified ? 'Yes' : 'No',
private: u.is_private ? 'Yes' : 'No',
}));
if (!d2 || typeof d2 !== 'object' || !Array.isArray(d2.users)) {
throw new Error('Instagram followers returned malformed users payload');
}
return d2.users.slice(0, limit).map((u, i) => {
if (!u || typeof u !== 'object') {
throw new Error('Instagram followers returned malformed user row');
}
const pkRaw = u.pk ?? u.pk_id ?? u.id;
const pk = typeof pkRaw === 'number' ? String(pkRaw) : (typeof pkRaw === 'string' ? pkRaw.trim() : '');
const usernameValue = typeof u.username === 'string' ? u.username.trim() : '';
if (!/^\\d+$/.test(pk) || !usernameValue) {
throw new Error('Instagram followers returned malformed user row');
}
return {
rank: i + 1,
username: usernameValue,
name: typeof u.full_name === 'string' ? u.full_name : '',
verified: u.is_verified ? 'Yes' : 'No',
private: u.is_private ? 'Yes' : 'No',
};
});
})()
` },
],
+5 -10
View File
@@ -1,4 +1,5 @@
import { cli } from '@jackwener/opencli/registry';
import { buildResolveInstagramUserIdJs } from './_shared/user-id.js';
cli({
site: 'instagram',
name: 'following',
@@ -19,14 +20,7 @@ cli({
const headers = { 'X-IG-App-ID': '936619743392459' };
const opts = { credentials: 'include', headers };
const r1 = await fetch(
'https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username),
opts
);
if (!r1.ok) throw new Error('HTTP ' + r1.status + ' - make sure you are logged in to Instagram');
const d1 = await r1.json();
const userId = d1?.data?.user?.id;
if (!userId) throw new Error('User not found: ' + username);
${buildResolveInstagramUserIdJs()}
const PAGE_SIZE = 50;
const results = [];
@@ -50,9 +44,10 @@ cli({
if (!u || typeof u !== 'object') {
throw new Error('Instagram following returned malformed user row');
}
const pk = String(u.pk ?? u.pk_id ?? u.id ?? '');
const pkRaw = u.pk ?? u.pk_id ?? u.id;
const pk = typeof pkRaw === 'number' ? String(pkRaw) : (typeof pkRaw === 'string' ? pkRaw.trim() : '');
const usernameValue = typeof u.username === 'string' ? u.username.trim() : '';
if (!pk || !usernameValue) {
if (!/^\\d+$/.test(pk) || !usernameValue) {
throw new Error('Instagram following returned malformed user row');
}
if (!pk || seen.has(pk)) continue;
+269 -19
View File
@@ -1,15 +1,39 @@
import { describe, expect, it, vi } from 'vitest';
import './follow.js';
import './followers.js';
import './following.js';
import './profile.js';
import './save.js';
import './unfollow.js';
import { getRegistry } from '@jackwener/opencli/registry';
/**
* Extract the evaluate JS source from the following command pipeline
* so we can test the pagination logic in-process via eval().
* Run any instagram command evaluate script with a mock fetch, substituting
* the given template args. Stubs document.cookie for the write commands.
*/
function getFollowingEvaluateJs() {
const cmd = getRegistry().get('instagram/following');
const evalStep = cmd.pipeline.find((s) => s.evaluate);
return evalStep.evaluate;
async function runCommandEvaluate(commandName, fetchFn, replacements) {
const cmd = getRegistry().get('instagram/' + commandName);
let js = cmd.pipeline.find((s) => s.evaluate).evaluate;
for (const [placeholder, value] of Object.entries(replacements)) {
js = js.split(placeholder).join(value);
}
const originalFetch = globalThis.fetch;
const hadDocument = 'document' in globalThis;
const originalDocument = globalThis.document;
globalThis.fetch = fetchFn;
if (!hadDocument) {
globalThis.document = { cookie: 'csrftoken=testtoken' };
}
try {
return await eval(js);
} finally {
globalThis.fetch = originalFetch;
if (hadDocument) {
globalThis.document = originalDocument;
} else {
delete globalThis.document;
}
}
}
/**
@@ -17,19 +41,10 @@ function getFollowingEvaluateJs() {
* Returns the resolved value.
*/
async function runFollowingEvaluate(fetchFn, args = { username: 'testuser', limit: 20 }) {
const jsTemplate = getFollowingEvaluateJs();
// Replace the template placeholders with actual values
const js = jsTemplate
.replace('${{ args.username | json }}', JSON.stringify(args.username))
.replace('${{ args.limit }}', String(args.limit));
const originalFetch = globalThis.fetch;
globalThis.fetch = fetchFn;
try {
return await eval(js);
} finally {
globalThis.fetch = originalFetch;
}
return runCommandEvaluate('following', fetchFn, {
'${{ args.username | json }}': JSON.stringify(args.username),
'${{ args.limit }}': String(args.limit),
});
}
/**
@@ -379,3 +394,238 @@ describe('instagram/following pagination', () => {
).rejects.toThrow('Instagram following returned malformed has_more flag');
});
});
/**
* web_profile_info is gated (HTTP 400) for business/professional accounts.
* These tests pin the fallback contract: resolve ids and posts through
* feed-by-username instead, and never mistake the gate for a missing user.
*/
describe('instagram business-account endpoint fallback', () => {
const gatedResponse = { ok: false, status: 400 };
const feedResponse = {
ok: true,
json: () => Promise.resolve({
user: { pk: '4213518589' },
items: [{ pk: 'post_1', user: { pk: '41793412' }, caption: { text: 'pinned collab' } }],
}),
};
it('follow resolves the owner id from the feed root, not the first item author', async () => {
const fetchFn = vi.fn()
.mockResolvedValueOnce(gatedResponse)
.mockResolvedValueOnce(feedResponse)
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ status: 'ok', friendship_status: { following: true } }),
});
const result = await runCommandEvaluate('follow', fetchFn, {
'${{ args.username | json }}': JSON.stringify('bizaccount'),
});
expect(result).toEqual([{ status: 'Following', username: 'bizaccount' }]);
expect(fetchFn.mock.calls[1][0]).toContain('/api/v1/feed/user/bizaccount/username/');
expect(fetchFn.mock.calls[2][0]).toContain('/api/v1/friendships/create/4213518589/');
});
it('follow reports user not found on 404 without trying the fallback', async () => {
const fetchFn = vi.fn().mockResolvedValueOnce({ ok: false, status: 404 });
await expect(runCommandEvaluate('follow', fetchFn, {
'${{ args.username | json }}': JSON.stringify('ghostuser'),
})).rejects.toThrow('User not found: ghostuser');
expect(fetchFn).toHaveBeenCalledTimes(1);
});
it('follow does not treat auth failures as business-account fallback', async () => {
const fetchFn = vi.fn().mockResolvedValueOnce({ ok: false, status: 401 });
await expect(runCommandEvaluate('follow', fetchFn, {
'${{ args.username | json }}': JSON.stringify('privateuser'),
})).rejects.toThrow('HTTP 401 - make sure you are logged in to Instagram');
expect(fetchFn).toHaveBeenCalledTimes(1);
});
it('follow does not hide web_profile_info server failures behind the fallback', async () => {
const fetchFn = vi.fn().mockResolvedValueOnce({ ok: false, status: 503 });
await expect(runCommandEvaluate('follow', fetchFn, {
'${{ args.username | json }}': JSON.stringify('flakyuser'),
})).rejects.toThrow('Instagram web_profile_info failed: HTTP 503');
expect(fetchFn).toHaveBeenCalledTimes(1);
});
it('follow reports feed drift instead of a missing user when the owner id is absent', async () => {
const fetchFn = vi.fn()
.mockResolvedValueOnce(gatedResponse)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ items: [] }) });
await expect(runCommandEvaluate('follow', fetchFn, {
'${{ args.username | json }}': JSON.stringify('bizaccount'),
})).rejects.toThrow('Instagram feed returned no valid profile owner for: bizaccount');
});
it('follow rejects non-numeric feed owner ids instead of constructing a friendship URL', async () => {
const fetchFn = vi.fn()
.mockResolvedValueOnce(gatedResponse)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ user: { pk: { bad: true } } }) });
await expect(runCommandEvaluate('follow', fetchFn, {
'${{ args.username | json }}': JSON.stringify('bizaccount'),
})).rejects.toThrow('Instagram feed returned no valid profile owner for: bizaccount');
expect(fetchFn).toHaveBeenCalledTimes(2);
});
it('follow requires explicit friendship success evidence from the POST response', async () => {
const fetchFn = vi.fn()
.mockResolvedValueOnce(gatedResponse)
.mockResolvedValueOnce(feedResponse)
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ status: 'ok', friendship_status: { following: false, outgoing_request: false } }),
});
await expect(runCommandEvaluate('follow', fetchFn, {
'${{ args.username | json }}': JSON.stringify('bizaccount'),
})).rejects.toThrow('Instagram follow returned no success evidence');
});
it('unfollow requires a parseable ok response from the POST', async () => {
const fetchFn = vi.fn()
.mockResolvedValueOnce(gatedResponse)
.mockResolvedValueOnce(feedResponse)
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ status: 'fail' }),
});
await expect(runCommandEvaluate('unfollow', fetchFn, {
'${{ args.username | json }}': JSON.stringify('bizaccount'),
})).rejects.toThrow('Instagram unfollow returned no success evidence');
});
it('profile falls back to users info and maps the mobile field names', async () => {
const fetchFn = vi.fn()
.mockResolvedValueOnce(gatedResponse)
.mockResolvedValueOnce(feedResponse)
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
user: {
username: 'bizaccount',
full_name: 'Biz Account',
biography: 'line one\nline two',
follower_count: 136386446,
following_count: 510,
media_count: 1493,
is_verified: true,
},
}),
});
const result = await runCommandEvaluate('profile', fetchFn, {
'${{ args.username | json }}': JSON.stringify('bizaccount'),
});
expect(result).toEqual([{
username: 'bizaccount',
name: 'Biz Account',
bio: 'line one line two',
followers: 136386446,
following: 510,
posts: 1493,
verified: 'Yes',
}]);
expect(fetchFn.mock.calls[2][0]).toContain('/api/v1/users/4213518589/info/');
});
it('profile keeps the single-request path for ungated accounts', async () => {
const fetchFn = vi.fn().mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
data: {
user: {
username: 'personal',
full_name: 'Personal User',
biography: '',
edge_followed_by: { count: 10 },
edge_follow: { count: 20 },
edge_owner_to_timeline_media: { count: 30 },
is_verified: false,
},
},
}),
});
const result = await runCommandEvaluate('profile', fetchFn, {
'${{ args.username | json }}': JSON.stringify('personal'),
});
expect(result[0]).toMatchObject({ username: 'personal', followers: 10, following: 20, posts: 30 });
expect(fetchFn).toHaveBeenCalledTimes(1);
});
it('following paginates after resolving the id through the fallback', async () => {
const fetchFn = vi.fn()
.mockResolvedValueOnce(gatedResponse)
.mockResolvedValueOnce(feedResponse)
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ users: [{ pk: 1, pk_id: '1', username: 'a', full_name: 'A' }], next_max_id: null }),
});
const result = await runCommandEvaluate('following', fetchFn, {
'${{ args.username | json }}': JSON.stringify('bizaccount'),
'${{ args.limit }}': '5',
});
expect(result).toHaveLength(1);
expect(fetchFn.mock.calls[2][0]).toContain('/api/v1/friendships/4213518589/following/');
});
it('followers rejects malformed user rows instead of emitting blank usernames', async () => {
const fetchFn = vi.fn()
.mockResolvedValueOnce(gatedResponse)
.mockResolvedValueOnce(feedResponse)
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ users: [{ pk: { bad: true }, username: 'bad' }] }),
});
await expect(runCommandEvaluate('followers', fetchFn, {
'${{ args.username | json }}': JSON.stringify('bizaccount'),
'${{ args.limit }}': '5',
})).rejects.toThrow('Instagram followers returned malformed user row');
});
it('save typed-fails malformed feed item payloads before POSTing', async () => {
const fetchFn = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ user: { pk: '4213518589' }, items: [{ caption: { text: 'missing pk' } }] }),
});
await expect(runCommandEvaluate('save', fetchFn, {
'${{ args.username | json }}': JSON.stringify('bizaccount'),
'${{ args.index }}': '1',
})).rejects.toThrow('Instagram feed-by-username returned malformed post row');
expect(fetchFn).toHaveBeenCalledTimes(1);
});
it('save requires ok status from the POST response', async () => {
const fetchFn = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ user: { pk: '4213518589' }, items: [{ pk: '98765', caption: { text: 'post' } }] }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ status: 'fail' }),
});
await expect(runCommandEvaluate('save', fetchFn, {
'${{ args.username | json }}': JSON.stringify('bizaccount'),
'${{ args.index }}': '1',
})).rejects.toThrow('Instagram save returned no success evidence');
});
});
+64 -19
View File
@@ -13,27 +13,72 @@ cli({
{ navigate: 'https://www.instagram.com' },
{ evaluate: `(async () => {
const username = \${{ args.username | json }};
const res = await fetch(
'https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username),
{
credentials: 'include',
headers: { 'X-IG-App-ID': '936619743392459' }
const opts = { credentials: 'include', headers: { 'X-IG-App-ID': '936619743392459' } };
function normalizeInstagramUserId(value, label) {
const id = typeof value === 'number' ? String(value) : (typeof value === 'string' ? value.trim() : '');
if (!/^\\d+$/.test(id)) throw new Error(label);
return id;
}
async function readInstagramJson(response, label) {
try {
return await response.json();
} catch {
throw new Error(label + ' returned invalid JSON');
}
}
function throwInstagramHttpError(response, label) {
if (response.status === 404) throw new Error('User not found: ' + username);
if (response.status === 401 || response.status === 403) {
throw new Error('HTTP ' + response.status + ' - make sure you are logged in to Instagram');
}
throw new Error(label + ' failed: HTTP ' + response.status);
}
function mapProfileUser(u, countFields) {
if (!u || typeof u !== 'object' || typeof u.username !== 'string' || !u.username.trim()) {
throw new Error('Instagram profile returned malformed user payload for: ' + username);
}
return {
username: u.username,
name: typeof u.full_name === 'string' ? u.full_name : '',
bio: (typeof u.biography === 'string' ? u.biography : '').replace(/\\n/g, ' ').substring(0, 120),
followers: countFields.followers(u),
following: countFields.following(u),
posts: countFields.posts(u),
verified: u.is_verified ? 'Yes' : 'No',
};
}
const r1 = await fetch(
'https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username),
opts
);
if (!res.ok) throw new Error('HTTP ' + res.status + ' - make sure you are logged in to Instagram');
const data = await res.json();
const u = data?.data?.user;
if (!u) throw new Error('User not found: ' + username);
return [{
username: u.username,
name: u.full_name || '',
bio: (u.biography || '').replace(/\\n/g, ' ').substring(0, 120),
followers: u.edge_followed_by?.count ?? 0,
following: u.edge_follow?.count ?? 0,
posts: u.edge_owner_to_timeline_media?.count ?? 0,
verified: u.is_verified ? 'Yes' : 'No',
url: 'https://www.instagram.com/' + u.username,
}];
if (r1.status === 404) throw new Error('User not found: ' + username);
if (!r1.ok && r1.status !== 400) throwInstagramHttpError(r1, 'Instagram web_profile_info');
if (r1.ok) {
const data = await readInstagramJson(r1, 'Instagram web_profile_info');
const u = data?.data?.user;
return [mapProfileUser(u, {
followers: (user) => user.edge_followed_by?.count ?? 0,
following: (user) => user.edge_follow?.count ?? 0,
posts: (user) => user.edge_owner_to_timeline_media?.count ?? 0,
})];
}
// web_profile_info answers HTTP 400 for business/professional accounts.
// Resolve the id via feed-by-username (its root user.pk is the profile
// owner), then read the full profile from users/<id>/info/.
const r2 = await fetch(
'https://www.instagram.com/api/v1/feed/user/' + encodeURIComponent(username) + '/username/?count=1',
opts
);
if (!r2.ok) throwInstagramHttpError(r2, 'Instagram feed-by-username');
const pk = normalizeInstagramUserId((await readInstagramJson(r2, 'Instagram feed-by-username'))?.user?.pk, 'Instagram feed returned no valid profile owner for: ' + username);
const r3 = await fetch('https://www.instagram.com/api/v1/users/' + pk + '/info/', opts);
if (!r3.ok) throwInstagramHttpError(r3, 'Instagram users info');
const u = (await readInstagramJson(r3, 'Instagram users info'))?.user;
return [mapProfileUser(u, {
followers: (user) => user.follower_count ?? 0,
following: (user) => user.following_count ?? 0,
posts: (user) => user.media_count ?? 0,
})];
})()
` },
],
+32 -11
View File
@@ -20,25 +20,46 @@ cli({
{ evaluate: `(async () => {
const username = \${{ args.username | json }};
const idx = \${{ args.index }} - 1;
if (!Number.isInteger(idx) || idx < 0) throw new Error('index must be a positive integer');
const headers = { 'X-IG-App-ID': '936619743392459' };
const opts = { credentials: 'include', headers };
async function readInstagramJson(response, label) {
try {
return await response.json();
} catch {
throw new Error(label + ' returned invalid JSON');
}
}
function getPostFromFeed(feed, label) {
if (!feed || typeof feed !== 'object' || !Array.isArray(feed.items)) {
throw new Error(label + ' returned malformed items payload');
}
if (idx >= feed.items.length) throw new Error('Post index ' + (idx + 1) + ' not found');
const post = feed.items[idx];
const pkRaw = post?.pk ?? post?.id;
const pk = typeof pkRaw === 'number' ? String(pkRaw) : (typeof pkRaw === 'string' ? pkRaw.trim() : '');
if (!/^\\d+$/.test(pk)) throw new Error(label + ' returned malformed post row');
const caption = typeof post?.caption?.text === 'string' ? post.caption.text.substring(0, 60) : '';
return { pk, caption };
}
function assertOkStatus(payload, label) {
if (!payload || typeof payload !== 'object' || payload.status !== 'ok') {
throw new Error(label + ' returned no success evidence');
}
}
const r1 = await fetch('https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), opts);
if (!r1.ok) throw new Error('User not found: ' + username);
const userId = (await r1.json())?.data?.user?.id;
const r2 = await fetch('https://www.instagram.com/api/v1/feed/user/' + userId + '/?count=' + (idx + 1), opts);
const posts = (await r2.json())?.items || [];
if (idx >= posts.length) throw new Error('Post index ' + (idx + 1) + ' not found');
const pk = posts[idx].pk;
const caption = (posts[idx].caption?.text || '').substring(0, 60);
// web_profile_info answers HTTP 400 for business accounts; feed-by-username needs no user id. See #2234.
const r1 = await fetch('https://www.instagram.com/api/v1/feed/user/' + encodeURIComponent(username) + '/username/?count=' + (idx + 1), opts);
if (!r1.ok) throw new Error(r1.status === 404 ? 'User not found: ' + username : 'HTTP ' + r1.status + ' - make sure you are logged in to Instagram');
const { pk, caption } = getPostFromFeed(await readInstagramJson(r1, 'Instagram feed-by-username'), 'Instagram feed-by-username');
const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || '';
const r3 = await fetch('https://www.instagram.com/api/v1/web/save/' + pk + '/save/', {
const r2 = await fetch('https://www.instagram.com/api/v1/web/save/' + pk + '/save/', {
method: 'POST', credentials: 'include',
headers: { ...headers, 'X-CSRFToken': csrf, 'Content-Type': 'application/x-www-form-urlencoded' },
});
if (!r3.ok) throw new Error('Failed to save: HTTP ' + r3.status);
if (!r2.ok) throw new Error('Failed to save: HTTP ' + r2.status);
assertOkStatus(await readInstagramJson(r2, 'Instagram save'), 'Instagram save');
return [{ status: 'Saved', user: username, post: caption || '(post #' + (idx+1) + ')' }];
})()
` },
+11 -5
View File
@@ -1,4 +1,5 @@
import { cli } from '@jackwener/opencli/registry';
import { buildResolveInstagramUserIdJs } from './_shared/user-id.js';
cli({
site: 'instagram',
name: 'unfollow',
@@ -21,11 +22,7 @@ cli({
const headers = { 'X-IG-App-ID': '936619743392459' };
const opts = { credentials: 'include', headers };
const r1 = await fetch('https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), opts);
if (!r1.ok) throw new Error('User not found: ' + username);
const d1 = await r1.json();
const userId = d1?.data?.user?.id;
if (!userId) throw new Error('User not found: ' + username);
${buildResolveInstagramUserIdJs()}
const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || '';
const r2 = await fetch('https://www.instagram.com/api/v1/friendships/destroy/' + userId + '/', {
@@ -34,6 +31,15 @@ cli({
headers: { ...headers, 'X-CSRFToken': csrf, 'Content-Type': 'application/x-www-form-urlencoded' },
});
if (!r2.ok) throw new Error('Failed to unfollow: HTTP ' + r2.status);
let d2;
try {
d2 = await r2.json();
} catch {
throw new Error('Instagram unfollow returned invalid JSON');
}
if (!d2 || typeof d2 !== 'object' || d2.status !== 'ok') {
throw new Error('Instagram unfollow returned no success evidence');
}
return [{ status: 'Unfollowed', username }];
})()
` },
+32 -11
View File
@@ -20,25 +20,46 @@ cli({
{ evaluate: `(async () => {
const username = \${{ args.username | json }};
const idx = \${{ args.index }} - 1;
if (!Number.isInteger(idx) || idx < 0) throw new Error('index must be a positive integer');
const headers = { 'X-IG-App-ID': '936619743392459' };
const opts = { credentials: 'include', headers };
async function readInstagramJson(response, label) {
try {
return await response.json();
} catch {
throw new Error(label + ' returned invalid JSON');
}
}
function getPostFromFeed(feed, label) {
if (!feed || typeof feed !== 'object' || !Array.isArray(feed.items)) {
throw new Error(label + ' returned malformed items payload');
}
if (idx >= feed.items.length) throw new Error('Post index ' + (idx + 1) + ' not found');
const post = feed.items[idx];
const pkRaw = post?.pk ?? post?.id;
const pk = typeof pkRaw === 'number' ? String(pkRaw) : (typeof pkRaw === 'string' ? pkRaw.trim() : '');
if (!/^\\d+$/.test(pk)) throw new Error(label + ' returned malformed post row');
const caption = typeof post?.caption?.text === 'string' ? post.caption.text.substring(0, 60) : '';
return { pk, caption };
}
function assertOkStatus(payload, label) {
if (!payload || typeof payload !== 'object' || payload.status !== 'ok') {
throw new Error(label + ' returned no success evidence');
}
}
const r1 = await fetch('https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), opts);
if (!r1.ok) throw new Error('User not found: ' + username);
const userId = (await r1.json())?.data?.user?.id;
const r2 = await fetch('https://www.instagram.com/api/v1/feed/user/' + userId + '/?count=' + (idx + 1), opts);
const posts = (await r2.json())?.items || [];
if (idx >= posts.length) throw new Error('Post index ' + (idx + 1) + ' not found');
const pk = posts[idx].pk;
const caption = (posts[idx].caption?.text || '').substring(0, 60);
// web_profile_info answers HTTP 400 for business accounts; feed-by-username needs no user id. See #2234.
const r1 = await fetch('https://www.instagram.com/api/v1/feed/user/' + encodeURIComponent(username) + '/username/?count=' + (idx + 1), opts);
if (!r1.ok) throw new Error(r1.status === 404 ? 'User not found: ' + username : 'HTTP ' + r1.status + ' - make sure you are logged in to Instagram');
const { pk, caption } = getPostFromFeed(await readInstagramJson(r1, 'Instagram feed-by-username'), 'Instagram feed-by-username');
const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || '';
const r3 = await fetch('https://www.instagram.com/api/v1/web/save/' + pk + '/unsave/', {
const r2 = await fetch('https://www.instagram.com/api/v1/web/save/' + pk + '/unsave/', {
method: 'POST', credentials: 'include',
headers: { ...headers, 'X-CSRFToken': csrf, 'Content-Type': 'application/x-www-form-urlencoded' },
});
if (!r3.ok) throw new Error('Failed to unsave: HTTP ' + r3.status);
if (!r2.ok) throw new Error('Failed to unsave: HTTP ' + r2.status);
assertOkStatus(await readInstagramJson(r2, 'Instagram unsave'), 'Instagram unsave');
return [{ status: 'Unsaved', user: username, post: caption || '(post #' + (idx+1) + ')' }];
})()
` },