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:
@@ -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
@@ -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 }];
|
||||
})()
|
||||
` },
|
||||
|
||||
@@ -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
@@ -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',
|
||||
};
|
||||
});
|
||||
})()
|
||||
` },
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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) + ')' }];
|
||||
})()
|
||||
` },
|
||||
|
||||
@@ -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
@@ -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) + ')' }];
|
||||
})()
|
||||
` },
|
||||
|
||||
Reference in New Issue
Block a user