@viral_vector/ig-mcp 0.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.
- package/README.md +123 -0
- package/dist/bridge/protocol.d.ts +84 -0
- package/dist/bridge/protocol.js +139 -0
- package/dist/bridge/server.d.ts +12 -0
- package/dist/bridge/server.js +82 -0
- package/dist/bridge/sessionRegistry.d.ts +50 -0
- package/dist/bridge/sessionRegistry.js +175 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +114 -0
- package/dist/mcp/server.d.ts +3 -0
- package/dist/mcp/server.js +10 -0
- package/dist/mcp/tools.d.ts +36 -0
- package/dist/mcp/tools.js +1466 -0
- package/package.json +38 -0
|
@@ -0,0 +1,1466 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { BRIDGE_ERROR_CODES, CAPABILITIES } from '../bridge/protocol.js';
|
|
3
|
+
const MEDIA_SOURCE_VALUES = ['posts', 'reels', 'stories', 'highlights'];
|
|
4
|
+
const REFRESH_DATA_VALUES = ['profile', 'followers', 'following', 'media'];
|
|
5
|
+
const AUDIENCE_KIND_VALUES = ['followers', 'following'];
|
|
6
|
+
const LIBRARY_INCLUDE_VALUES = ['summary', 'accounts', 'hashtags'];
|
|
7
|
+
const ACCOUNT_INCLUDE_VALUES = [
|
|
8
|
+
'profile_snapshot',
|
|
9
|
+
'engagement_snapshot',
|
|
10
|
+
'audience_summary',
|
|
11
|
+
'media_summary',
|
|
12
|
+
'recent_media',
|
|
13
|
+
'exports',
|
|
14
|
+
'actions'
|
|
15
|
+
];
|
|
16
|
+
const HASHTAG_INCLUDE_VALUES = [
|
|
17
|
+
'snapshot',
|
|
18
|
+
'media_summary',
|
|
19
|
+
'recent_media',
|
|
20
|
+
'creators',
|
|
21
|
+
'related',
|
|
22
|
+
'exports',
|
|
23
|
+
'actions'
|
|
24
|
+
];
|
|
25
|
+
const MEDIA_INCLUDE_VALUES = ['download_status'];
|
|
26
|
+
const OPERATION_KIND_VALUES = [
|
|
27
|
+
'account_track',
|
|
28
|
+
'account_update_role',
|
|
29
|
+
'account_untrack',
|
|
30
|
+
'account_refresh',
|
|
31
|
+
'hashtag_track',
|
|
32
|
+
'hashtag_untrack',
|
|
33
|
+
'hashtag_refresh',
|
|
34
|
+
'media_comments_refresh',
|
|
35
|
+
'media_download',
|
|
36
|
+
'export_create'
|
|
37
|
+
];
|
|
38
|
+
const OPERATION_STATUS_VALUES = ['queued', 'running', 'completed', 'failed', 'cancelled'];
|
|
39
|
+
const baseStatusSchema = z.object({
|
|
40
|
+
connected: z.boolean(),
|
|
41
|
+
clientId: z.string().nullable().optional(),
|
|
42
|
+
extensionVersion: z.string().nullable().optional(),
|
|
43
|
+
connectedAt: z.string().nullable().optional(),
|
|
44
|
+
lastSeenAt: z.string().nullable().optional()
|
|
45
|
+
});
|
|
46
|
+
const includeSchema = z.array(z.enum(['exports', 'actions'])).optional();
|
|
47
|
+
const readOnlyAnnotations = {
|
|
48
|
+
readOnlyHint: true,
|
|
49
|
+
destructiveHint: false,
|
|
50
|
+
idempotentHint: true,
|
|
51
|
+
openWorldHint: false
|
|
52
|
+
};
|
|
53
|
+
const liveReadOnlyAnnotations = {
|
|
54
|
+
readOnlyHint: true,
|
|
55
|
+
destructiveHint: false,
|
|
56
|
+
idempotentHint: true,
|
|
57
|
+
openWorldHint: true
|
|
58
|
+
};
|
|
59
|
+
const operationAnnotations = {
|
|
60
|
+
readOnlyHint: false,
|
|
61
|
+
destructiveHint: false,
|
|
62
|
+
idempotentHint: false,
|
|
63
|
+
openWorldHint: true
|
|
64
|
+
};
|
|
65
|
+
export const V2_TOOL_DEFINITIONS = [
|
|
66
|
+
{
|
|
67
|
+
name: 'vv_status',
|
|
68
|
+
title: 'Viral Vector Status',
|
|
69
|
+
description: 'Returns local bridge and Instagram session status for the ViralVector Instagram library.',
|
|
70
|
+
inputSchema: {},
|
|
71
|
+
annotations: readOnlyAnnotations,
|
|
72
|
+
handler: async (_input, registry) => {
|
|
73
|
+
const connection = registry.getStatus();
|
|
74
|
+
const session = await requestCapability(registry, CAPABILITIES.SOCIAL_SESSION_STATUS, {});
|
|
75
|
+
if (!session.ok) {
|
|
76
|
+
return errorToolResponse(session);
|
|
77
|
+
}
|
|
78
|
+
return jsonToolResponse({
|
|
79
|
+
bridge: summarizeConnection(connection),
|
|
80
|
+
session: session.data
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: 'vv_library_query',
|
|
86
|
+
title: 'Viral Vector Library Query',
|
|
87
|
+
description: 'Queries locally saved Instagram accounts and hashtags, with optional library summary.',
|
|
88
|
+
inputSchema: {
|
|
89
|
+
query: z.string().optional(),
|
|
90
|
+
include: z.array(z.enum(LIBRARY_INCLUDE_VALUES)).optional(),
|
|
91
|
+
limit: z.number().int().nonnegative().optional(),
|
|
92
|
+
offset: z.number().int().nonnegative().optional()
|
|
93
|
+
},
|
|
94
|
+
annotations: readOnlyAnnotations,
|
|
95
|
+
handler: async (input, registry) => {
|
|
96
|
+
const response = await requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_LIST_ACCOUNTS, {});
|
|
97
|
+
if (!response.ok) {
|
|
98
|
+
return errorToolResponse(response);
|
|
99
|
+
}
|
|
100
|
+
const value = asObject(response.data);
|
|
101
|
+
const query = stringOrNull(input.query)?.toLowerCase() ?? '';
|
|
102
|
+
const include = asStringArray(input.include);
|
|
103
|
+
const includeAccounts = include.length === 0 || include.includes('accounts');
|
|
104
|
+
const includeHashtags = include.length === 0 || include.includes('hashtags');
|
|
105
|
+
const includeSummary = include.length === 0 || include.includes('summary');
|
|
106
|
+
const accounts = includeAccounts
|
|
107
|
+
? paginate(asArray(value.accounts).filter((item) => !query || matchesSearch(item, query)).map(summarizeAccount), input)
|
|
108
|
+
: [];
|
|
109
|
+
const hashtags = includeHashtags
|
|
110
|
+
? paginate(asArray(value.hashtags).filter((item) => !query || matchesSearch(item, query)).map(summarizeHashtag), input)
|
|
111
|
+
: [];
|
|
112
|
+
return jsonToolResponse(queryEnvelope(input, {
|
|
113
|
+
results: {
|
|
114
|
+
accounts,
|
|
115
|
+
hashtags
|
|
116
|
+
},
|
|
117
|
+
included: {
|
|
118
|
+
summary: includeSummary ? {
|
|
119
|
+
accounts: asArray(value.accounts).length,
|
|
120
|
+
hashtags: asArray(value.hashtags).length,
|
|
121
|
+
profileSnapshots: asArray(value.profileSnapshots).length,
|
|
122
|
+
hashtagSnapshots: asArray(value.hashtagSnapshots).length
|
|
123
|
+
} : undefined
|
|
124
|
+
}
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: 'vv_account_query',
|
|
130
|
+
title: 'Viral Vector Account Query',
|
|
131
|
+
description: 'Queries saved Instagram accounts or returns account context with optional snapshots, summaries, recent media, exports, and actions.',
|
|
132
|
+
inputSchema: {
|
|
133
|
+
username: z.string().min(1).optional(),
|
|
134
|
+
query: z.string().optional(),
|
|
135
|
+
role: z.enum(['my_account', 'competitor', 'benchmark', 'creator', 'uncategorized']).optional(),
|
|
136
|
+
include: z.array(z.enum(ACCOUNT_INCLUDE_VALUES)).optional(),
|
|
137
|
+
mediaLimit: z.number().int().nonnegative().optional(),
|
|
138
|
+
limit: z.number().int().nonnegative().optional(),
|
|
139
|
+
offset: z.number().int().nonnegative().optional()
|
|
140
|
+
},
|
|
141
|
+
annotations: readOnlyAnnotations,
|
|
142
|
+
handler: async (input, registry) => {
|
|
143
|
+
if (typeof input.username === 'string' && input.username.trim()) {
|
|
144
|
+
const detail = await requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_GET_ACCOUNT_DETAIL, {
|
|
145
|
+
username: input.username,
|
|
146
|
+
postsLimit: input.mediaLimit
|
|
147
|
+
});
|
|
148
|
+
if (!detail.ok) {
|
|
149
|
+
return errorToolResponse(detail);
|
|
150
|
+
}
|
|
151
|
+
const exportsResult = includesV2(input, 'exports')
|
|
152
|
+
? await requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_LIST_EXPORTS, {
|
|
153
|
+
targetType: 'account',
|
|
154
|
+
targetHandle: input.username
|
|
155
|
+
})
|
|
156
|
+
: null;
|
|
157
|
+
if (exportsResult && !exportsResult.ok) {
|
|
158
|
+
return errorToolResponse(exportsResult);
|
|
159
|
+
}
|
|
160
|
+
return jsonToolResponse(queryEnvelope(input, {
|
|
161
|
+
item: buildAccountQueryItem(asObject(detail.data), input, exportsResult?.data)
|
|
162
|
+
}));
|
|
163
|
+
}
|
|
164
|
+
const response = await requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_LIST_ACCOUNTS, {});
|
|
165
|
+
if (!response.ok) {
|
|
166
|
+
return errorToolResponse(response);
|
|
167
|
+
}
|
|
168
|
+
const query = stringOrNull(input.query)?.toLowerCase() ?? '';
|
|
169
|
+
const role = typeof input.role === 'string' ? input.role : null;
|
|
170
|
+
const accounts = asArray(asObject(response.data).accounts)
|
|
171
|
+
.map(asObject)
|
|
172
|
+
.filter((item) => !role || String(item.targetRole ?? 'uncategorized') === role)
|
|
173
|
+
.filter((item) => !query || matchesSearch(item, query))
|
|
174
|
+
.map(summarizeAccount);
|
|
175
|
+
return jsonToolResponse(queryEnvelope(input, {
|
|
176
|
+
results: {
|
|
177
|
+
accounts: paginate(accounts, input)
|
|
178
|
+
}
|
|
179
|
+
}));
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
name: 'vv_account_audience_query',
|
|
184
|
+
title: 'Viral Vector Account Audience Query',
|
|
185
|
+
description: 'Queries saved followers or following records for an Instagram account.',
|
|
186
|
+
inputSchema: {
|
|
187
|
+
username: z.string().min(1),
|
|
188
|
+
kind: z.enum(AUDIENCE_KIND_VALUES),
|
|
189
|
+
query: z.string().optional(),
|
|
190
|
+
privacy: z.enum(['public', 'private', 'unknown']).optional(),
|
|
191
|
+
verification: z.enum(['verified', 'unverified', 'unknown']).optional(),
|
|
192
|
+
limit: z.number().int().nonnegative().optional(),
|
|
193
|
+
offset: z.number().int().nonnegative().optional()
|
|
194
|
+
},
|
|
195
|
+
annotations: readOnlyAnnotations,
|
|
196
|
+
handler: async (input, registry) => queryAudience(input, registry)
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
name: 'vv_hashtag_query',
|
|
200
|
+
title: 'Viral Vector Hashtag Query',
|
|
201
|
+
description: 'Queries saved Instagram hashtags or returns hashtag context with optional snapshot, media, creators, related hashtags, exports, and actions.',
|
|
202
|
+
inputSchema: {
|
|
203
|
+
handle: z.string().min(1).optional(),
|
|
204
|
+
query: z.string().optional(),
|
|
205
|
+
include: z.array(z.enum(HASHTAG_INCLUDE_VALUES)).optional(),
|
|
206
|
+
mediaLimit: z.number().int().nonnegative().optional(),
|
|
207
|
+
limit: z.number().int().nonnegative().optional(),
|
|
208
|
+
offset: z.number().int().nonnegative().optional()
|
|
209
|
+
},
|
|
210
|
+
annotations: readOnlyAnnotations,
|
|
211
|
+
handler: async (input, registry) => {
|
|
212
|
+
if (typeof input.handle === 'string' && input.handle.trim()) {
|
|
213
|
+
const detail = await requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_GET_HASHTAG_DETAIL, {
|
|
214
|
+
handle: input.handle,
|
|
215
|
+
postsLimit: input.mediaLimit
|
|
216
|
+
});
|
|
217
|
+
if (!detail.ok) {
|
|
218
|
+
return errorToolResponse(detail);
|
|
219
|
+
}
|
|
220
|
+
const exportsResult = includesV2(input, 'exports')
|
|
221
|
+
? await requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_LIST_EXPORTS, {
|
|
222
|
+
targetType: 'hashtag',
|
|
223
|
+
targetHandle: input.handle
|
|
224
|
+
})
|
|
225
|
+
: null;
|
|
226
|
+
if (exportsResult && !exportsResult.ok) {
|
|
227
|
+
return errorToolResponse(exportsResult);
|
|
228
|
+
}
|
|
229
|
+
return jsonToolResponse(queryEnvelope(input, {
|
|
230
|
+
item: buildHashtagQueryItem(asObject(detail.data), input, exportsResult?.data)
|
|
231
|
+
}));
|
|
232
|
+
}
|
|
233
|
+
const response = await requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_LIST_ACCOUNTS, {});
|
|
234
|
+
if (!response.ok) {
|
|
235
|
+
return errorToolResponse(response);
|
|
236
|
+
}
|
|
237
|
+
const query = stringOrNull(input.query)?.toLowerCase() ?? '';
|
|
238
|
+
const hashtags = asArray(asObject(response.data).hashtags)
|
|
239
|
+
.filter((item) => !query || matchesSearch(item, query))
|
|
240
|
+
.map(summarizeHashtag);
|
|
241
|
+
return jsonToolResponse(queryEnvelope(input, {
|
|
242
|
+
results: {
|
|
243
|
+
hashtags: paginate(hashtags, input)
|
|
244
|
+
}
|
|
245
|
+
}));
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
name: 'vv_media_query',
|
|
250
|
+
title: 'Viral Vector Media Query',
|
|
251
|
+
description: 'Queries saved account or hashtag media, or returns a single media item by media id or shortcode.',
|
|
252
|
+
inputSchema: {
|
|
253
|
+
account: z.string().min(1).optional(),
|
|
254
|
+
hashtag: z.string().min(1).optional(),
|
|
255
|
+
mediaId: z.string().min(1).optional(),
|
|
256
|
+
shortcode: z.string().min(1).optional(),
|
|
257
|
+
sources: z.array(z.enum(MEDIA_SOURCE_VALUES)).optional(),
|
|
258
|
+
include: z.array(z.enum(MEDIA_INCLUDE_VALUES)).optional(),
|
|
259
|
+
limit: z.number().int().nonnegative().optional(),
|
|
260
|
+
offset: z.number().int().nonnegative().optional()
|
|
261
|
+
},
|
|
262
|
+
annotations: readOnlyAnnotations,
|
|
263
|
+
handler: async (input, registry) => queryMedia(input, registry)
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
name: 'vv_comments_query',
|
|
267
|
+
title: 'Viral Vector Comments Query',
|
|
268
|
+
description: 'Queries saved comments for a media item by Instagram shortcode.',
|
|
269
|
+
inputSchema: {
|
|
270
|
+
shortcode: z.string().min(1),
|
|
271
|
+
limit: z.number().int().nonnegative().optional(),
|
|
272
|
+
offset: z.number().int().nonnegative().optional()
|
|
273
|
+
},
|
|
274
|
+
annotations: readOnlyAnnotations,
|
|
275
|
+
handler: async (input, registry) => {
|
|
276
|
+
const response = await requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_GET_POST_DETAIL, {
|
|
277
|
+
shortcode: input.shortcode,
|
|
278
|
+
commentsLimit: input.limit
|
|
279
|
+
});
|
|
280
|
+
if (!response.ok) {
|
|
281
|
+
return errorToolResponse(response);
|
|
282
|
+
}
|
|
283
|
+
const value = asObject(response.data);
|
|
284
|
+
return jsonToolResponse(queryEnvelope(input, {
|
|
285
|
+
item: summarizeMediaFromPost(value.post),
|
|
286
|
+
results: {
|
|
287
|
+
comments: paginate(asArray(value.comments), input)
|
|
288
|
+
}
|
|
289
|
+
}));
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
name: 'vv_operation_run',
|
|
294
|
+
title: 'Viral Vector Operation Run',
|
|
295
|
+
description: 'Runs or dry-runs local library mutations, refreshes, downloads, and exports. Untrack operations delete local tracked targets only; they do not delete Instagram content.',
|
|
296
|
+
inputSchema: {
|
|
297
|
+
kind: z.enum(OPERATION_KIND_VALUES),
|
|
298
|
+
input: z.record(z.string(), z.unknown()),
|
|
299
|
+
dryRun: z.boolean().optional(),
|
|
300
|
+
idempotencyKey: z.string().optional()
|
|
301
|
+
},
|
|
302
|
+
annotations: operationAnnotations,
|
|
303
|
+
handler: async (input, registry) => runOperation(input, registry)
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
name: 'vv_operation_query',
|
|
307
|
+
title: 'Viral Vector Operation Query',
|
|
308
|
+
description: 'Queries collection operation status and recent operation history.',
|
|
309
|
+
inputSchema: {
|
|
310
|
+
operationId: z.string().min(1).optional(),
|
|
311
|
+
kind: z.string().optional(),
|
|
312
|
+
status: z.enum(OPERATION_STATUS_VALUES).optional(),
|
|
313
|
+
account: z.string().min(1).optional(),
|
|
314
|
+
hashtag: z.string().min(1).optional(),
|
|
315
|
+
shortcode: z.string().min(1).optional(),
|
|
316
|
+
limit: z.number().int().nonnegative().optional(),
|
|
317
|
+
offset: z.number().int().nonnegative().optional()
|
|
318
|
+
},
|
|
319
|
+
annotations: readOnlyAnnotations,
|
|
320
|
+
handler: async (input, registry) => queryOperations(input, registry)
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
name: 'ig_search',
|
|
324
|
+
title: 'Instagram Live Search',
|
|
325
|
+
description: 'Temporarily searches Instagram live candidates for accounts or hashtags. Results are not saved or tracked; use vv_operation_run to persist a chosen candidate.',
|
|
326
|
+
inputSchema: {
|
|
327
|
+
query: z.string().min(1),
|
|
328
|
+
targets: z.array(z.enum(['accounts', 'hashtags'])).min(1).optional(),
|
|
329
|
+
limit: z.number().int().min(1).max(20).optional()
|
|
330
|
+
},
|
|
331
|
+
annotations: liveReadOnlyAnnotations,
|
|
332
|
+
handler: async (input, registry) => searchInstagram(input, registry)
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
name: 'ig_lookup',
|
|
336
|
+
title: 'Instagram Live Lookup',
|
|
337
|
+
description: 'Temporarily looks up one Instagram account or hashtag with optional recent media and related hashtag context. Results are not saved or tracked.',
|
|
338
|
+
inputSchema: {
|
|
339
|
+
type: z.enum(['account', 'hashtag']),
|
|
340
|
+
username: z.string().min(1).optional(),
|
|
341
|
+
hashtag: z.string().min(1).optional(),
|
|
342
|
+
include: z.array(z.enum(['recent_media', 'related'])).optional(),
|
|
343
|
+
mediaLimit: z.number().int().min(1).max(20).optional()
|
|
344
|
+
},
|
|
345
|
+
annotations: liveReadOnlyAnnotations,
|
|
346
|
+
handler: async (input, registry) => lookupInstagram(input, registry)
|
|
347
|
+
}
|
|
348
|
+
];
|
|
349
|
+
export const MCP_TOOL_DEFINITIONS = V2_TOOL_DEFINITIONS;
|
|
350
|
+
export function registerMcpTools(server, registry) {
|
|
351
|
+
for (const definition of MCP_TOOL_DEFINITIONS) {
|
|
352
|
+
server.registerTool(definition.name, {
|
|
353
|
+
title: definition.title,
|
|
354
|
+
description: definition.description,
|
|
355
|
+
inputSchema: definition.inputSchema,
|
|
356
|
+
annotations: definition.annotations
|
|
357
|
+
}, async (input) => definition.handler(input, registry));
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
async function requestCapability(registry, capability, input) {
|
|
361
|
+
return registry.request(capability, input);
|
|
362
|
+
}
|
|
363
|
+
function queryEnvelope(input, payload) {
|
|
364
|
+
return {
|
|
365
|
+
query: sanitizeQueryInput(input),
|
|
366
|
+
...payload,
|
|
367
|
+
page: buildPage(input)
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
function sanitizeQueryInput(input) {
|
|
371
|
+
return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
|
|
372
|
+
}
|
|
373
|
+
function buildPage(input) {
|
|
374
|
+
return {
|
|
375
|
+
limit: numberOrNull(input.limit),
|
|
376
|
+
offset: numberOrNull(input.offset) ?? 0
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
function paginate(items, input) {
|
|
380
|
+
const offset = Math.max(0, Math.trunc(numberOrNull(input.offset) ?? 0));
|
|
381
|
+
const limit = numberOrNull(input.limit);
|
|
382
|
+
return typeof limit === 'number'
|
|
383
|
+
? items.slice(offset, offset + Math.max(0, Math.trunc(limit)))
|
|
384
|
+
: items.slice(offset);
|
|
385
|
+
}
|
|
386
|
+
function includesV2(input, value) {
|
|
387
|
+
return Array.isArray(input.include) && input.include.includes(value);
|
|
388
|
+
}
|
|
389
|
+
function includeAny(input, values) {
|
|
390
|
+
return values.some((value) => includesV2(input, value));
|
|
391
|
+
}
|
|
392
|
+
function buildAccountQueryItem(detail, input, exportsData) {
|
|
393
|
+
const item = {
|
|
394
|
+
account: summarizeAccount(detail.account)
|
|
395
|
+
};
|
|
396
|
+
const included = {};
|
|
397
|
+
if (includesV2(input, 'profile_snapshot')) {
|
|
398
|
+
included.profileSnapshot = detail.profileSnapshot ?? null;
|
|
399
|
+
}
|
|
400
|
+
if (includesV2(input, 'engagement_snapshot')) {
|
|
401
|
+
included.engagementSnapshot = detail.engagementSnapshot ?? null;
|
|
402
|
+
}
|
|
403
|
+
if (includesV2(input, 'audience_summary')) {
|
|
404
|
+
included.audience = buildAccountAudience(detail);
|
|
405
|
+
}
|
|
406
|
+
if (includesV2(input, 'media_summary')) {
|
|
407
|
+
included.mediaSummary = buildAccountMediaSummary(detail);
|
|
408
|
+
}
|
|
409
|
+
if (includesV2(input, 'recent_media')) {
|
|
410
|
+
included.recentMedia = paginate(buildAccountMediaViews(detail), {
|
|
411
|
+
limit: input.mediaLimit ?? input.limit,
|
|
412
|
+
offset: input.offset
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
if (exportsData !== undefined) {
|
|
416
|
+
included.exports = asArray(asObject(exportsData).exports).map(summarizeExport);
|
|
417
|
+
}
|
|
418
|
+
if (includesV2(input, 'actions')) {
|
|
419
|
+
included.actions = buildActions(asArray(detail.tasks));
|
|
420
|
+
}
|
|
421
|
+
if (Object.keys(included).length > 0) {
|
|
422
|
+
item.included = included;
|
|
423
|
+
}
|
|
424
|
+
return item;
|
|
425
|
+
}
|
|
426
|
+
function buildHashtagQueryItem(detail, input, exportsData) {
|
|
427
|
+
const item = {
|
|
428
|
+
hashtag: summarizeHashtag(detail.hashtag ?? { handle: input.handle })
|
|
429
|
+
};
|
|
430
|
+
const included = {};
|
|
431
|
+
if (includesV2(input, 'snapshot')) {
|
|
432
|
+
included.snapshot = detail.snapshot ?? null;
|
|
433
|
+
}
|
|
434
|
+
if (includesV2(input, 'media_summary')) {
|
|
435
|
+
included.mediaSummary = {
|
|
436
|
+
count: buildHashtagMediaViews(detail).length
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
if (includesV2(input, 'recent_media')) {
|
|
440
|
+
included.recentMedia = paginate(buildHashtagMediaViews(detail), {
|
|
441
|
+
limit: input.mediaLimit ?? input.limit,
|
|
442
|
+
offset: input.offset
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
if (includesV2(input, 'creators')) {
|
|
446
|
+
included.creators = detail.creators ?? [];
|
|
447
|
+
}
|
|
448
|
+
if (includesV2(input, 'related')) {
|
|
449
|
+
included.related = detail.relatedHashtags ?? [];
|
|
450
|
+
}
|
|
451
|
+
if (exportsData !== undefined) {
|
|
452
|
+
included.exports = asArray(asObject(exportsData).exports).map(summarizeExport);
|
|
453
|
+
}
|
|
454
|
+
if (includesV2(input, 'actions')) {
|
|
455
|
+
included.actions = buildActions(asArray(detail.tasks));
|
|
456
|
+
}
|
|
457
|
+
if (Object.keys(included).length > 0) {
|
|
458
|
+
item.included = included;
|
|
459
|
+
}
|
|
460
|
+
return item;
|
|
461
|
+
}
|
|
462
|
+
async function queryAudience(input, registry) {
|
|
463
|
+
const detail = await requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_GET_ACCOUNT_DETAIL, {
|
|
464
|
+
username: input.username,
|
|
465
|
+
postsLimit: 0
|
|
466
|
+
});
|
|
467
|
+
if (!detail.ok) {
|
|
468
|
+
return errorToolResponse(detail);
|
|
469
|
+
}
|
|
470
|
+
const task = findLatestTaskForAudience(asArray(asObject(detail.data).tasks), String(input.kind));
|
|
471
|
+
if (!task) {
|
|
472
|
+
return jsonToolResponse(queryEnvelope(input, {
|
|
473
|
+
results: {
|
|
474
|
+
audience: []
|
|
475
|
+
},
|
|
476
|
+
included: {
|
|
477
|
+
stats: null
|
|
478
|
+
}
|
|
479
|
+
}));
|
|
480
|
+
}
|
|
481
|
+
const response = await requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_QUERY_AUDIENCE_MEMBERS, {
|
|
482
|
+
taskId: task.id,
|
|
483
|
+
query: input.query,
|
|
484
|
+
privacy: input.privacy,
|
|
485
|
+
verification: input.verification,
|
|
486
|
+
limit: input.limit,
|
|
487
|
+
offset: input.offset
|
|
488
|
+
});
|
|
489
|
+
if (!response.ok) {
|
|
490
|
+
return errorToolResponse(response);
|
|
491
|
+
}
|
|
492
|
+
return jsonToolResponse(queryEnvelope(input, {
|
|
493
|
+
results: {
|
|
494
|
+
audience: asObject(response.data).records ?? []
|
|
495
|
+
},
|
|
496
|
+
included: {
|
|
497
|
+
stats: asObject(response.data).stats ?? null
|
|
498
|
+
}
|
|
499
|
+
}));
|
|
500
|
+
}
|
|
501
|
+
async function queryMedia(input, registry) {
|
|
502
|
+
const hasAccount = typeof input.account === 'string' && input.account.trim();
|
|
503
|
+
const hasHashtag = typeof input.hashtag === 'string' && input.hashtag.trim();
|
|
504
|
+
if (hasAccount && hasHashtag) {
|
|
505
|
+
return validationError('vv_media_query accepts either account or hashtag, not both.');
|
|
506
|
+
}
|
|
507
|
+
if (!hasAccount && !hasHashtag) {
|
|
508
|
+
return validationError('vv_media_query requires account or hashtag.');
|
|
509
|
+
}
|
|
510
|
+
const response = hasAccount
|
|
511
|
+
? await requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_GET_ACCOUNT_DETAIL, {
|
|
512
|
+
username: input.account,
|
|
513
|
+
postsLimit: input.limit ?? 100
|
|
514
|
+
})
|
|
515
|
+
: await requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_GET_HASHTAG_DETAIL, {
|
|
516
|
+
handle: input.hashtag,
|
|
517
|
+
postsLimit: input.limit ?? 100
|
|
518
|
+
});
|
|
519
|
+
if (!response.ok) {
|
|
520
|
+
return errorToolResponse(response);
|
|
521
|
+
}
|
|
522
|
+
const media = hasAccount
|
|
523
|
+
? filterMediaViews(buildAccountMediaViews(asObject(response.data)), asStringArray(input.sources))
|
|
524
|
+
: buildHashtagMediaViews(asObject(response.data));
|
|
525
|
+
const selected = findMediaView(media, input.mediaId, input.shortcode);
|
|
526
|
+
if (typeof input.mediaId === 'string' || typeof input.shortcode === 'string') {
|
|
527
|
+
return jsonToolResponse(queryEnvelope(input, { item: selected }));
|
|
528
|
+
}
|
|
529
|
+
return jsonToolResponse(queryEnvelope(input, {
|
|
530
|
+
results: {
|
|
531
|
+
media: paginate(media, input)
|
|
532
|
+
}
|
|
533
|
+
}));
|
|
534
|
+
}
|
|
535
|
+
async function runOperation(input, registry) {
|
|
536
|
+
const kind = String(input.kind ?? '');
|
|
537
|
+
const operationInput = asObject(input.input);
|
|
538
|
+
const normalizedInput = normalizeOperationInput(kind, operationInput);
|
|
539
|
+
if ('error' in normalizedInput) {
|
|
540
|
+
return validationError(String(normalizedInput.error));
|
|
541
|
+
}
|
|
542
|
+
const effects = describeOperationEffects(kind, normalizedInput.value);
|
|
543
|
+
if (input.dryRun === true) {
|
|
544
|
+
return jsonToolResponse({
|
|
545
|
+
dryRun: true,
|
|
546
|
+
kind,
|
|
547
|
+
normalizedInput: normalizedInput.value,
|
|
548
|
+
effects,
|
|
549
|
+
warnings: []
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
const result = await executeOperation(kind, normalizedInput.value, registry);
|
|
553
|
+
if (!result.ok) {
|
|
554
|
+
return errorToolResponse(result);
|
|
555
|
+
}
|
|
556
|
+
return jsonToolResponse({
|
|
557
|
+
dryRun: false,
|
|
558
|
+
kind,
|
|
559
|
+
normalizedInput: normalizedInput.value,
|
|
560
|
+
effects,
|
|
561
|
+
...buildOperationRunPayload(kind, normalizedInput.value, result.data)
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
async function queryOperations(input, registry) {
|
|
565
|
+
const target = buildOperationQueryTarget(input);
|
|
566
|
+
const response = await requestCapability(registry, CAPABILITIES.SOCIAL_COLLECTION_LIST_TASKS, {
|
|
567
|
+
operationId: input.operationId,
|
|
568
|
+
targetHandle: target?.handle,
|
|
569
|
+
targetType: target?.type,
|
|
570
|
+
limit: input.limit
|
|
571
|
+
});
|
|
572
|
+
if (!response.ok) {
|
|
573
|
+
return errorToolResponse(response);
|
|
574
|
+
}
|
|
575
|
+
const tasks = asArray(asObject(response.data).tasks)
|
|
576
|
+
.map(asObject)
|
|
577
|
+
.filter((task) => !input.status || normalizeActionStatus(task.status) === input.status)
|
|
578
|
+
.filter((task) => !input.kind || operationKindFromCapability(String(task.capability ?? '')) === input.kind);
|
|
579
|
+
const operations = paginate(buildActions(tasks), input);
|
|
580
|
+
if (typeof input.operationId === 'string' && input.operationId.trim()) {
|
|
581
|
+
return jsonToolResponse(queryEnvelope(input, {
|
|
582
|
+
operation: operations[0] ?? null
|
|
583
|
+
}));
|
|
584
|
+
}
|
|
585
|
+
return jsonToolResponse(queryEnvelope(input, {
|
|
586
|
+
operations
|
|
587
|
+
}));
|
|
588
|
+
}
|
|
589
|
+
function normalizeOperationInput(kind, input) {
|
|
590
|
+
switch (kind) {
|
|
591
|
+
case 'account_track':
|
|
592
|
+
return requiredObject({
|
|
593
|
+
username: normalizeRequiredString(input.username, 'username'),
|
|
594
|
+
role: input.role
|
|
595
|
+
});
|
|
596
|
+
case 'account_update_role':
|
|
597
|
+
return requiredObject({
|
|
598
|
+
username: normalizeRequiredString(input.username, 'username'),
|
|
599
|
+
role: normalizeRequiredString(input.role, 'role')
|
|
600
|
+
});
|
|
601
|
+
case 'account_untrack':
|
|
602
|
+
return requiredObject({
|
|
603
|
+
username: normalizeRequiredString(input.username, 'username')
|
|
604
|
+
});
|
|
605
|
+
case 'account_refresh':
|
|
606
|
+
return requiredObject({
|
|
607
|
+
username: normalizeRequiredString(input.username, 'username'),
|
|
608
|
+
data: Array.isArray(input.data) && input.data.length > 0 ? input.data : ['profile', 'media'],
|
|
609
|
+
limit: input.limit,
|
|
610
|
+
mediaSources: input.mediaSources
|
|
611
|
+
});
|
|
612
|
+
case 'hashtag_track':
|
|
613
|
+
case 'hashtag_untrack':
|
|
614
|
+
return requiredObject({
|
|
615
|
+
handle: normalizeRequiredString(input.handle, 'handle')
|
|
616
|
+
});
|
|
617
|
+
case 'hashtag_refresh':
|
|
618
|
+
return requiredObject({
|
|
619
|
+
handle: normalizeRequiredString(input.handle, 'handle'),
|
|
620
|
+
limit: input.limit
|
|
621
|
+
});
|
|
622
|
+
case 'media_comments_refresh':
|
|
623
|
+
return requiredObject({
|
|
624
|
+
shortcode: normalizeRequiredString(input.shortcode, 'shortcode'),
|
|
625
|
+
commentsLimit: input.commentsLimit ?? input.limit
|
|
626
|
+
});
|
|
627
|
+
case 'media_download':
|
|
628
|
+
return requiredObject({
|
|
629
|
+
mediaItemIds: Array.isArray(input.mediaItemIds) && input.mediaItemIds.length > 0 ? input.mediaItemIds : null,
|
|
630
|
+
action: input.action ?? 'queue'
|
|
631
|
+
});
|
|
632
|
+
case 'export_create':
|
|
633
|
+
return requiredObject({
|
|
634
|
+
target: asObject(input.target),
|
|
635
|
+
username: input.username,
|
|
636
|
+
handle: input.handle,
|
|
637
|
+
dataset: input.dataset ?? input.data,
|
|
638
|
+
limit: input.limit,
|
|
639
|
+
mediaSources: input.mediaSources,
|
|
640
|
+
commentsLimit: input.commentsLimit,
|
|
641
|
+
includeComments: input.includeComments,
|
|
642
|
+
includeMediaUrls: input.includeMediaUrls
|
|
643
|
+
});
|
|
644
|
+
default:
|
|
645
|
+
return { error: `Unsupported operation kind: ${kind}` };
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
function requiredObject(value) {
|
|
649
|
+
const invalid = Object.entries(value).find(([, field]) => field === null);
|
|
650
|
+
if (invalid) {
|
|
651
|
+
return { error: `vv_operation_run input requires ${invalid[0]}.` };
|
|
652
|
+
}
|
|
653
|
+
return { value };
|
|
654
|
+
}
|
|
655
|
+
async function searchInstagram(input, registry) {
|
|
656
|
+
const targets = asStringArray(input.targets);
|
|
657
|
+
const requestedTargets = targets.length > 0 ? targets : ['accounts', 'hashtags'];
|
|
658
|
+
const libraryPromise = requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_LIST_ACCOUNTS, {});
|
|
659
|
+
const accountsPromise = requestedTargets.includes('accounts')
|
|
660
|
+
? requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_SEARCH_ACCOUNTS, { query: input.query, limit: input.limit })
|
|
661
|
+
: null;
|
|
662
|
+
const hashtagsPromise = requestedTargets.includes('hashtags')
|
|
663
|
+
? requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_SEARCH_HASHTAGS, { query: input.query, limit: input.limit })
|
|
664
|
+
: null;
|
|
665
|
+
const [library, accountsResult, hashtagsResult] = await Promise.all([libraryPromise, accountsPromise, hashtagsPromise]);
|
|
666
|
+
if (!library.ok) {
|
|
667
|
+
return errorToolResponse(library);
|
|
668
|
+
}
|
|
669
|
+
for (const result of [accountsResult, hashtagsResult]) {
|
|
670
|
+
if (result && !result.ok) {
|
|
671
|
+
return errorToolResponse(result);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
const libraryData = asObject(library.data);
|
|
675
|
+
const localAccounts = new Map(asArray(libraryData.accounts).map((item) => {
|
|
676
|
+
const account = asObject(item);
|
|
677
|
+
return [normalizeUsername(account.username), account];
|
|
678
|
+
}));
|
|
679
|
+
const localHashtags = new Map(asArray(libraryData.hashtags).map((item) => {
|
|
680
|
+
const hashtag = asObject(item);
|
|
681
|
+
return [normalizeHashtag(hashtag.handle), hashtag];
|
|
682
|
+
}));
|
|
683
|
+
const accounts = accountsResult?.ok
|
|
684
|
+
? asArray(asObject(accountsResult.data).results).map((item) => {
|
|
685
|
+
const candidate = asObject(item);
|
|
686
|
+
const local = localAccounts.get(normalizeUsername(candidate.username));
|
|
687
|
+
return { ...candidate, inLibrary: Boolean(local), isTracked: Boolean(local), targetRole: local?.targetRole ?? null };
|
|
688
|
+
})
|
|
689
|
+
: [];
|
|
690
|
+
const hashtags = hashtagsResult?.ok
|
|
691
|
+
? asArray(asObject(hashtagsResult.data).results).map((item) => {
|
|
692
|
+
const candidate = asObject(item);
|
|
693
|
+
const local = localHashtags.get(normalizeHashtag(candidate.handle));
|
|
694
|
+
return { ...candidate, inLibrary: Boolean(local), isTracked: Boolean(local) };
|
|
695
|
+
})
|
|
696
|
+
: [];
|
|
697
|
+
return jsonToolResponse({
|
|
698
|
+
query: stringOrNull(input.query) ?? '',
|
|
699
|
+
results: { accounts, hashtags }
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
async function lookupInstagram(input, registry) {
|
|
703
|
+
const type = input.type;
|
|
704
|
+
const include = asStringArray(input.include);
|
|
705
|
+
if (type === 'account' && include.includes('related')) {
|
|
706
|
+
return validationError('ig_lookup account include supports only recent_media.');
|
|
707
|
+
}
|
|
708
|
+
const capability = type === 'account'
|
|
709
|
+
? CAPABILITIES.SOCIAL_INSTAGRAM_LOOKUP_ACCOUNT
|
|
710
|
+
: CAPABILITIES.SOCIAL_INSTAGRAM_LOOKUP_HASHTAG;
|
|
711
|
+
const identifier = type === 'account'
|
|
712
|
+
? normalizeRequiredString(input.username, 'username')
|
|
713
|
+
: normalizeRequiredString(input.hashtag, 'hashtag');
|
|
714
|
+
if (!identifier) {
|
|
715
|
+
return validationError(`ig_lookup type ${String(type)} requires ${type === 'account' ? 'username' : 'hashtag'}.`);
|
|
716
|
+
}
|
|
717
|
+
const [live, library] = await Promise.all([
|
|
718
|
+
requestCapability(registry, capability, {
|
|
719
|
+
...(type === 'account' ? { username: identifier } : { hashtag: identifier }),
|
|
720
|
+
include,
|
|
721
|
+
mediaLimit: input.mediaLimit
|
|
722
|
+
}),
|
|
723
|
+
requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_LIST_ACCOUNTS, {})
|
|
724
|
+
]);
|
|
725
|
+
if (!live.ok) {
|
|
726
|
+
return errorToolResponse(live);
|
|
727
|
+
}
|
|
728
|
+
if (!library.ok) {
|
|
729
|
+
return errorToolResponse(library);
|
|
730
|
+
}
|
|
731
|
+
const data = asObject(live.data);
|
|
732
|
+
const libraryData = asObject(library.data);
|
|
733
|
+
if (type === 'account') {
|
|
734
|
+
const local = asArray(libraryData.accounts).map(asObject)
|
|
735
|
+
.find((account) => normalizeUsername(account.username) === normalizeUsername(identifier));
|
|
736
|
+
return jsonToolResponse({
|
|
737
|
+
item: {
|
|
738
|
+
type: 'account',
|
|
739
|
+
profile: data.profile ?? null,
|
|
740
|
+
inLibrary: Boolean(local),
|
|
741
|
+
isTracked: Boolean(local),
|
|
742
|
+
targetRole: local?.targetRole ?? null
|
|
743
|
+
},
|
|
744
|
+
included: asObject(data.included)
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
const local = asArray(libraryData.hashtags).map(asObject)
|
|
748
|
+
.find((hashtag) => normalizeHashtag(hashtag.handle) === normalizeHashtag(identifier));
|
|
749
|
+
return jsonToolResponse({
|
|
750
|
+
item: {
|
|
751
|
+
type: 'hashtag',
|
|
752
|
+
hashtag: data.hashtag ?? `#${normalizeHashtag(identifier)}`,
|
|
753
|
+
snapshot: data.snapshot ?? null,
|
|
754
|
+
inLibrary: Boolean(local),
|
|
755
|
+
isTracked: Boolean(local)
|
|
756
|
+
},
|
|
757
|
+
included: asObject(data.included)
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
function normalizeUsername(value) {
|
|
761
|
+
return typeof value === 'string' ? value.trim().replace(/^@/, '').toLowerCase() : '';
|
|
762
|
+
}
|
|
763
|
+
function normalizeHashtag(value) {
|
|
764
|
+
return typeof value === 'string' ? value.trim().replace(/^#+/, '').toLowerCase() : '';
|
|
765
|
+
}
|
|
766
|
+
function normalizeRequiredString(value, name) {
|
|
767
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
768
|
+
}
|
|
769
|
+
function describeOperationEffects(kind, input) {
|
|
770
|
+
switch (kind) {
|
|
771
|
+
case 'account_track':
|
|
772
|
+
return [`Track @${input.username} in the local Instagram library.`];
|
|
773
|
+
case 'account_update_role':
|
|
774
|
+
return [`Update local role metadata for @${input.username}.`];
|
|
775
|
+
case 'account_untrack':
|
|
776
|
+
return [`Delete @${input.username} from local tracked accounts. Instagram content is not deleted.`];
|
|
777
|
+
case 'account_refresh':
|
|
778
|
+
return asStringArray(input.data).map((dataKind) => `Queue ${dataKind} refresh for @${input.username}.`);
|
|
779
|
+
case 'hashtag_track':
|
|
780
|
+
return [`Track #${normalizeHashtagHandle(String(input.handle))} in the local Instagram library.`];
|
|
781
|
+
case 'hashtag_untrack':
|
|
782
|
+
return [`Delete #${normalizeHashtagHandle(String(input.handle))} from local tracked hashtags. Instagram content is not deleted.`];
|
|
783
|
+
case 'hashtag_refresh':
|
|
784
|
+
return [`Queue snapshot and media refresh for #${normalizeHashtagHandle(String(input.handle))}.`];
|
|
785
|
+
case 'media_comments_refresh':
|
|
786
|
+
return [`Queue comments refresh for media shortcode ${input.shortcode}.`];
|
|
787
|
+
case 'media_download':
|
|
788
|
+
return [`${String(input.action ?? 'queue')} local media downloads for ${asArray(input.mediaItemIds).length} media items.`];
|
|
789
|
+
case 'export_create':
|
|
790
|
+
return ['Create a local CSV export from saved Instagram library data.'];
|
|
791
|
+
default:
|
|
792
|
+
return [];
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
async function executeOperation(kind, input, registry) {
|
|
796
|
+
switch (kind) {
|
|
797
|
+
case 'account_track':
|
|
798
|
+
return requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_UPSERT_ACCOUNT_TARGET, {
|
|
799
|
+
username: input.username,
|
|
800
|
+
role: input.role
|
|
801
|
+
});
|
|
802
|
+
case 'account_update_role':
|
|
803
|
+
return requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_UPDATE_ACCOUNT_ROLE, {
|
|
804
|
+
username: input.username,
|
|
805
|
+
role: input.role
|
|
806
|
+
});
|
|
807
|
+
case 'account_untrack':
|
|
808
|
+
return requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_DELETE_ACCOUNT, {
|
|
809
|
+
username: input.username
|
|
810
|
+
});
|
|
811
|
+
case 'account_refresh': {
|
|
812
|
+
const dataKinds = asStringArray(input.data);
|
|
813
|
+
const results = [];
|
|
814
|
+
for (const dataKind of dataKinds) {
|
|
815
|
+
const response = await requestCapability(registry, CAPABILITIES.SOCIAL_COLLECTION_START, mapAccountRefreshInput(String(input.username), dataKind, input));
|
|
816
|
+
if (!response.ok) {
|
|
817
|
+
return response;
|
|
818
|
+
}
|
|
819
|
+
results.push(response.data);
|
|
820
|
+
}
|
|
821
|
+
return {
|
|
822
|
+
ok: true,
|
|
823
|
+
requestId: 'operation-account-refresh',
|
|
824
|
+
capability: CAPABILITIES.SOCIAL_COLLECTION_START,
|
|
825
|
+
data: { results }
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
case 'hashtag_track':
|
|
829
|
+
return requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_UPSERT_HASHTAG_TARGET, {
|
|
830
|
+
handle: input.handle
|
|
831
|
+
});
|
|
832
|
+
case 'hashtag_untrack':
|
|
833
|
+
return requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_DELETE_HASHTAG, {
|
|
834
|
+
handle: input.handle
|
|
835
|
+
});
|
|
836
|
+
case 'hashtag_refresh':
|
|
837
|
+
return requestCapability(registry, CAPABILITIES.SOCIAL_COLLECTION_START, {
|
|
838
|
+
capability: 'instagram.hashtag.snapshot.collect',
|
|
839
|
+
target: {
|
|
840
|
+
type: 'hashtag',
|
|
841
|
+
handle: normalizeHashtagHandle(String(input.handle))
|
|
842
|
+
},
|
|
843
|
+
options: {
|
|
844
|
+
limit: input.limit
|
|
845
|
+
}
|
|
846
|
+
});
|
|
847
|
+
case 'media_comments_refresh':
|
|
848
|
+
return requestCapability(registry, CAPABILITIES.SOCIAL_COLLECTION_START, {
|
|
849
|
+
capability: 'instagram.post.comments.collect',
|
|
850
|
+
target: {
|
|
851
|
+
type: 'post',
|
|
852
|
+
handle: input.shortcode
|
|
853
|
+
},
|
|
854
|
+
options: {
|
|
855
|
+
limit: input.commentsLimit
|
|
856
|
+
}
|
|
857
|
+
});
|
|
858
|
+
case 'media_download':
|
|
859
|
+
return requestCapability(registry, CAPABILITIES.SOCIAL_MEDIA_DOWNLOAD, {
|
|
860
|
+
action: input.action ?? 'queue',
|
|
861
|
+
mediaItemIds: input.mediaItemIds
|
|
862
|
+
});
|
|
863
|
+
case 'export_create':
|
|
864
|
+
return executeExportOperation(input, registry);
|
|
865
|
+
default:
|
|
866
|
+
return {
|
|
867
|
+
ok: false,
|
|
868
|
+
requestId: null,
|
|
869
|
+
capability: null,
|
|
870
|
+
error: {
|
|
871
|
+
code: BRIDGE_ERROR_CODES.UNSUPPORTED_CAPABILITY,
|
|
872
|
+
message: `Unsupported operation kind: ${kind}`
|
|
873
|
+
}
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
async function executeExportOperation(input, registry) {
|
|
878
|
+
const target = asObject(input.target);
|
|
879
|
+
const username = stringOrNull(input.username) ?? (target.type === 'account' ? stringOrNull(target.handle) : null);
|
|
880
|
+
const handle = stringOrNull(input.handle) ?? (target.type === 'hashtag' ? stringOrNull(target.handle) : null);
|
|
881
|
+
if (username) {
|
|
882
|
+
const mapped = await mapAccountExportInput({
|
|
883
|
+
username,
|
|
884
|
+
data: input.dataset ?? 'media',
|
|
885
|
+
limit: input.limit,
|
|
886
|
+
mediaSources: input.mediaSources,
|
|
887
|
+
commentsLimit: input.commentsLimit
|
|
888
|
+
}, registry);
|
|
889
|
+
if ('error' in mapped && mapped.error) {
|
|
890
|
+
return mapped.error;
|
|
891
|
+
}
|
|
892
|
+
return requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_EXPORT_CSV, mapped.value);
|
|
893
|
+
}
|
|
894
|
+
if (handle) {
|
|
895
|
+
return requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_EXPORT_CSV, {
|
|
896
|
+
sourceType: 'hashtag_posts',
|
|
897
|
+
handle: normalizeHashtagHandle(handle),
|
|
898
|
+
limit: input.limit,
|
|
899
|
+
includeComments: input.includeComments === true,
|
|
900
|
+
includeMediaUrls: input.includeMediaUrls === true
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
return {
|
|
904
|
+
ok: false,
|
|
905
|
+
requestId: null,
|
|
906
|
+
capability: CAPABILITIES.SOCIAL_CONTEXT_EXPORT_CSV,
|
|
907
|
+
error: {
|
|
908
|
+
code: BRIDGE_ERROR_CODES.UNSUPPORTED_CAPABILITY,
|
|
909
|
+
message: 'export_create requires username, handle, or target.'
|
|
910
|
+
}
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
function buildOperationRunPayload(kind, input, data) {
|
|
914
|
+
if (kind === 'account_refresh') {
|
|
915
|
+
const results = asArray(asObject(data).results);
|
|
916
|
+
return {
|
|
917
|
+
operationId: stringOrNull(asObject(results[0]).operationId) ?? stringOrNull(asObject(results[0]).taskId),
|
|
918
|
+
status: 'running',
|
|
919
|
+
actions: results.map((result) => buildActionFromCollectionRun('refresh', { type: 'account', handle: String(input.username ?? '') }, result))
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
if (kind === 'hashtag_refresh' || kind === 'media_comments_refresh') {
|
|
923
|
+
const target = kind === 'hashtag_refresh'
|
|
924
|
+
? { type: 'hashtag', handle: normalizeHashtagHandle(String(input.handle ?? '')) }
|
|
925
|
+
: { type: 'post', handle: String(input.shortcode ?? '') };
|
|
926
|
+
return {
|
|
927
|
+
operationId: stringOrNull(asObject(data).operationId) ?? stringOrNull(asObject(data).taskId),
|
|
928
|
+
status: 'running',
|
|
929
|
+
actions: [buildActionFromCollectionRun('refresh', target, data)]
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
if (kind === 'media_download') {
|
|
933
|
+
return {
|
|
934
|
+
status: 'running',
|
|
935
|
+
download: data
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
if (kind === 'export_create') {
|
|
939
|
+
return {
|
|
940
|
+
status: 'completed',
|
|
941
|
+
file: summarizeExportPayload(data)
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
return {
|
|
945
|
+
status: 'completed',
|
|
946
|
+
result: data
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
function buildOperationQueryTarget(input) {
|
|
950
|
+
if (typeof input.account === 'string' && input.account.trim()) {
|
|
951
|
+
return { type: 'account', handle: input.account };
|
|
952
|
+
}
|
|
953
|
+
if (typeof input.hashtag === 'string' && input.hashtag.trim()) {
|
|
954
|
+
return { type: 'hashtag', handle: normalizeHashtagHandle(input.hashtag) };
|
|
955
|
+
}
|
|
956
|
+
if (typeof input.shortcode === 'string' && input.shortcode.trim()) {
|
|
957
|
+
return { type: 'post', handle: input.shortcode };
|
|
958
|
+
}
|
|
959
|
+
return null;
|
|
960
|
+
}
|
|
961
|
+
function operationKindFromCapability(capability) {
|
|
962
|
+
if (capability === 'instagram.hashtag.snapshot.collect') {
|
|
963
|
+
return 'hashtag_refresh';
|
|
964
|
+
}
|
|
965
|
+
if (capability === 'instagram.post.comments.collect') {
|
|
966
|
+
return 'media_comments_refresh';
|
|
967
|
+
}
|
|
968
|
+
if (capability.includes('instagram.account') || capability.includes('instagram.audience') || capability.includes('instagram.posts')) {
|
|
969
|
+
return 'account_refresh';
|
|
970
|
+
}
|
|
971
|
+
return capability;
|
|
972
|
+
}
|
|
973
|
+
function validationError(message) {
|
|
974
|
+
return {
|
|
975
|
+
isError: true,
|
|
976
|
+
content: [{
|
|
977
|
+
type: 'text',
|
|
978
|
+
text: JSON.stringify({
|
|
979
|
+
error: {
|
|
980
|
+
code: BRIDGE_ERROR_CODES.VALIDATION_FAILED,
|
|
981
|
+
message
|
|
982
|
+
}
|
|
983
|
+
}, null, 2)
|
|
984
|
+
}]
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
function buildAccountProfile(detail) {
|
|
988
|
+
return {
|
|
989
|
+
account: summarizeAccount(detail.account),
|
|
990
|
+
profileSnapshot: detail.profileSnapshot ?? null,
|
|
991
|
+
engagementSnapshot: detail.engagementSnapshot ?? null
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
function buildAccountAudience(detail) {
|
|
995
|
+
const tasks = asArray(detail.tasks);
|
|
996
|
+
return {
|
|
997
|
+
followers: summarizeLatestAudienceTask(tasks, 'instagram.audience.followers.collect'),
|
|
998
|
+
following: summarizeLatestAudienceTask(tasks, 'instagram.audience.following.collect')
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
function buildAccountMediaSummary(detail) {
|
|
1002
|
+
const media = buildAccountMediaViews(detail);
|
|
1003
|
+
return {
|
|
1004
|
+
count: media.length,
|
|
1005
|
+
posts: media.filter((item) => item.source === 'post' || item.source === 'carousel').length,
|
|
1006
|
+
reels: media.filter((item) => item.source === 'reel').length,
|
|
1007
|
+
stories: media.filter((item) => item.source === 'story').length,
|
|
1008
|
+
highlights: media.filter((item) => item.source === 'highlight').length
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
function summarizeLatestAudienceTask(tasks, capability) {
|
|
1012
|
+
const task = tasks
|
|
1013
|
+
.map(asObject)
|
|
1014
|
+
.filter((item) => item.capability === capability)
|
|
1015
|
+
.sort(compareUpdatedAt)[0];
|
|
1016
|
+
if (!task) {
|
|
1017
|
+
return null;
|
|
1018
|
+
}
|
|
1019
|
+
return {
|
|
1020
|
+
status: normalizeActionStatus(task.status),
|
|
1021
|
+
updatedAt: stringOrNull(task.updatedAt),
|
|
1022
|
+
totalSaved: numberOrNull(task.totalSaved),
|
|
1023
|
+
totalDiscovered: numberOrNull(task.totalDiscovered)
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
function buildActions(tasks) {
|
|
1027
|
+
return tasks
|
|
1028
|
+
.map(asObject)
|
|
1029
|
+
.sort(compareUpdatedAt)
|
|
1030
|
+
.map((task) => ({
|
|
1031
|
+
id: stringOrNull(task.operationId) ?? String(task.id ?? ''),
|
|
1032
|
+
type: inferActionTypeFromCapability(String(task.capability ?? '')),
|
|
1033
|
+
status: normalizeActionStatus(task.status),
|
|
1034
|
+
target: asObject(task.target),
|
|
1035
|
+
capability: task.capability ?? null,
|
|
1036
|
+
updatedAt: stringOrNull(task.updatedAt),
|
|
1037
|
+
message: stringOrNull(task.failureReason) ?? stringOrNull(task.recommendedAction)
|
|
1038
|
+
}));
|
|
1039
|
+
}
|
|
1040
|
+
function buildAccountMediaViews(detail) {
|
|
1041
|
+
const mediaItems = asArray(detail.mediaItems).map((item) => fromMediaItem(asObject(item), asArray(detail.downloadTasks)));
|
|
1042
|
+
const seen = new Set(mediaItems.map((item) => item.shortcode ?? item.id));
|
|
1043
|
+
const legacyPosts = asArray(detail.posts)
|
|
1044
|
+
.map((item) => fromPost(asObject(item), true))
|
|
1045
|
+
.filter((item) => !seen.has(item.shortcode ?? item.id));
|
|
1046
|
+
return [...mediaItems, ...legacyPosts].sort(compareUpdatedAtDesc);
|
|
1047
|
+
}
|
|
1048
|
+
function buildHashtagMediaViews(detail) {
|
|
1049
|
+
return asArray(detail.posts).map((item) => fromPost(asObject(item), false)).sort(compareUpdatedAtDesc);
|
|
1050
|
+
}
|
|
1051
|
+
function findMediaView(media, mediaId, shortcode) {
|
|
1052
|
+
const mediaIdValue = typeof mediaId === 'string' ? mediaId : null;
|
|
1053
|
+
const shortcodeValue = typeof shortcode === 'string' ? shortcode : null;
|
|
1054
|
+
return media.find((item) => (mediaIdValue && item.id === mediaIdValue) ||
|
|
1055
|
+
(shortcodeValue && item.shortcode === shortcodeValue)) ?? null;
|
|
1056
|
+
}
|
|
1057
|
+
function filterMediaViews(media, sources) {
|
|
1058
|
+
if (!sources.length) {
|
|
1059
|
+
return media;
|
|
1060
|
+
}
|
|
1061
|
+
const normalized = new Set(sources.map((value) => normalizeMediaSourceFilter(value)));
|
|
1062
|
+
return media.filter((item) => normalized.has(item.source));
|
|
1063
|
+
}
|
|
1064
|
+
function normalizeMediaSourceFilter(value) {
|
|
1065
|
+
if (value === 'stories') {
|
|
1066
|
+
return 'story';
|
|
1067
|
+
}
|
|
1068
|
+
if (value === 'highlights') {
|
|
1069
|
+
return 'highlight';
|
|
1070
|
+
}
|
|
1071
|
+
if (value === 'reels') {
|
|
1072
|
+
return 'reel';
|
|
1073
|
+
}
|
|
1074
|
+
return 'post';
|
|
1075
|
+
}
|
|
1076
|
+
function fromMediaItem(item, downloadTasks) {
|
|
1077
|
+
const tasks = groupDownloadTasks(downloadTasks).get(String(item.id ?? '')) ?? [];
|
|
1078
|
+
const assets = asArray(item.assets).map((asset) => {
|
|
1079
|
+
const value = asObject(asset);
|
|
1080
|
+
return {
|
|
1081
|
+
type: String(value.type ?? 'image'),
|
|
1082
|
+
availability: String(value.availability ?? 'unknown')
|
|
1083
|
+
};
|
|
1084
|
+
});
|
|
1085
|
+
return {
|
|
1086
|
+
id: String(item.id ?? ''),
|
|
1087
|
+
source: normalizeMediaSource(String(item.sourceKind ?? 'post')),
|
|
1088
|
+
shortcode: stringOrNull(item.shortcode),
|
|
1089
|
+
url: stringOrNull(item.url),
|
|
1090
|
+
caption: String(item.caption ?? ''),
|
|
1091
|
+
authorUsername: stringOrNull(item.username),
|
|
1092
|
+
metrics: {
|
|
1093
|
+
likes: numberOrNull(asObject(item.metrics).likeCount),
|
|
1094
|
+
comments: numberOrNull(asObject(item.metrics).commentCount),
|
|
1095
|
+
views: numberOrNull(asObject(item.metrics).viewCount) ?? numberOrNull(asObject(item.metrics).playCount)
|
|
1096
|
+
},
|
|
1097
|
+
assets,
|
|
1098
|
+
commentsAvailable: typeof item.shortcode === 'string' && item.shortcode.length > 0,
|
|
1099
|
+
downloadStatus: summarizeDownloadStatus(tasks, assets.length),
|
|
1100
|
+
collectedAt: String(item.collectedAt ?? ''),
|
|
1101
|
+
updatedAt: String(item.updatedAt ?? '')
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
function fromPost(item, includeDownloadMetadata) {
|
|
1105
|
+
const source = inferPostSource(item);
|
|
1106
|
+
const hasAnyAsset = Boolean(item.thumbnailUrl) || Boolean(item.videoUrl);
|
|
1107
|
+
return {
|
|
1108
|
+
id: String(item.id ?? ''),
|
|
1109
|
+
source,
|
|
1110
|
+
shortcode: stringOrNull(item.shortcode),
|
|
1111
|
+
url: stringOrNull(item.url),
|
|
1112
|
+
caption: String(item.caption ?? ''),
|
|
1113
|
+
authorUsername: stringOrNull(item.username),
|
|
1114
|
+
metrics: {
|
|
1115
|
+
likes: numberOrNull(item.likeCount),
|
|
1116
|
+
comments: numberOrNull(item.commentCount),
|
|
1117
|
+
views: numberOrNull(item.viewCount)
|
|
1118
|
+
},
|
|
1119
|
+
assets: [{
|
|
1120
|
+
type: String(item.mediaType ?? 'image'),
|
|
1121
|
+
availability: hasAnyAsset ? 'available' : 'metadata_only'
|
|
1122
|
+
}],
|
|
1123
|
+
commentsAvailable: typeof item.shortcode === 'string' && item.shortcode.length > 0,
|
|
1124
|
+
downloadStatus: includeDownloadMetadata ? (hasAnyAsset ? 'metadata_only' : 'none') : 'metadata_only',
|
|
1125
|
+
collectedAt: String(item.collectedAt ?? ''),
|
|
1126
|
+
updatedAt: String(item.updatedAt ?? '')
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
function summarizeMediaFromPost(post) {
|
|
1130
|
+
return post ? fromPost(asObject(post), false) : null;
|
|
1131
|
+
}
|
|
1132
|
+
function inferPostSource(item) {
|
|
1133
|
+
const url = String(item.url ?? '');
|
|
1134
|
+
if (url.includes('/reel/')) {
|
|
1135
|
+
return 'reel';
|
|
1136
|
+
}
|
|
1137
|
+
return item.mediaType === 'carousel' ? 'carousel' : 'post';
|
|
1138
|
+
}
|
|
1139
|
+
function normalizeMediaSource(value) {
|
|
1140
|
+
if (value === 'reel') {
|
|
1141
|
+
return 'reel';
|
|
1142
|
+
}
|
|
1143
|
+
if (value === 'carousel') {
|
|
1144
|
+
return 'carousel';
|
|
1145
|
+
}
|
|
1146
|
+
if (value === 'story') {
|
|
1147
|
+
return 'story';
|
|
1148
|
+
}
|
|
1149
|
+
if (value === 'highlight_story') {
|
|
1150
|
+
return 'highlight';
|
|
1151
|
+
}
|
|
1152
|
+
return 'post';
|
|
1153
|
+
}
|
|
1154
|
+
function groupDownloadTasks(tasks) {
|
|
1155
|
+
const grouped = new Map();
|
|
1156
|
+
for (const task of tasks.map(asObject)) {
|
|
1157
|
+
const mediaItemId = String(task.mediaItemId ?? '');
|
|
1158
|
+
if (!mediaItemId) {
|
|
1159
|
+
continue;
|
|
1160
|
+
}
|
|
1161
|
+
const existing = grouped.get(mediaItemId) ?? [];
|
|
1162
|
+
existing.push(task);
|
|
1163
|
+
grouped.set(mediaItemId, existing);
|
|
1164
|
+
}
|
|
1165
|
+
return grouped;
|
|
1166
|
+
}
|
|
1167
|
+
function summarizeDownloadStatus(tasks, assetCount) {
|
|
1168
|
+
if (assetCount === 0) {
|
|
1169
|
+
return 'metadata_only';
|
|
1170
|
+
}
|
|
1171
|
+
if (tasks.length === 0) {
|
|
1172
|
+
return 'none';
|
|
1173
|
+
}
|
|
1174
|
+
const statuses = tasks.map((task) => String(task.status ?? ''));
|
|
1175
|
+
const completed = statuses.filter((status) => status === 'completed').length;
|
|
1176
|
+
if (completed === assetCount && assetCount > 0) {
|
|
1177
|
+
return 'completed';
|
|
1178
|
+
}
|
|
1179
|
+
if (statuses.some((status) => status === 'running')) {
|
|
1180
|
+
return 'running';
|
|
1181
|
+
}
|
|
1182
|
+
if (statuses.some((status) => status === 'queued')) {
|
|
1183
|
+
return 'queued';
|
|
1184
|
+
}
|
|
1185
|
+
if (completed > 0) {
|
|
1186
|
+
return 'partial';
|
|
1187
|
+
}
|
|
1188
|
+
if (statuses.some((status) => status === 'failed' || status === 'cancelled' || status === 'stale_url')) {
|
|
1189
|
+
return 'failed';
|
|
1190
|
+
}
|
|
1191
|
+
return 'none';
|
|
1192
|
+
}
|
|
1193
|
+
async function mapAccountExportInput(input, registry) {
|
|
1194
|
+
const username = String(input.username ?? '');
|
|
1195
|
+
const data = String(input.data ?? '');
|
|
1196
|
+
if (data === 'media') {
|
|
1197
|
+
return {
|
|
1198
|
+
value: {
|
|
1199
|
+
sourceType: 'account_media',
|
|
1200
|
+
username,
|
|
1201
|
+
limit: input.limit,
|
|
1202
|
+
mediaSources: mapMediaSources(asStringArray(input.mediaSources))
|
|
1203
|
+
}
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1206
|
+
if (data === 'media_comments') {
|
|
1207
|
+
return {
|
|
1208
|
+
value: {
|
|
1209
|
+
sourceType: 'account_comments',
|
|
1210
|
+
username,
|
|
1211
|
+
mediaLimit: input.limit,
|
|
1212
|
+
mediaSources: mapMediaSources(asStringArray(input.mediaSources)),
|
|
1213
|
+
commentsLimit: input.commentsLimit
|
|
1214
|
+
}
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
const detail = await requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_GET_ACCOUNT_DETAIL, {
|
|
1218
|
+
username,
|
|
1219
|
+
postsLimit: 0
|
|
1220
|
+
});
|
|
1221
|
+
if (!detail.ok) {
|
|
1222
|
+
return { error: detail };
|
|
1223
|
+
}
|
|
1224
|
+
const task = findLatestTaskForAudience(asArray(asObject(detail.data).tasks), data);
|
|
1225
|
+
if (!task) {
|
|
1226
|
+
return {
|
|
1227
|
+
error: {
|
|
1228
|
+
ok: false,
|
|
1229
|
+
requestId: null,
|
|
1230
|
+
capability: CAPABILITIES.SOCIAL_CONTEXT_EXPORT_CSV,
|
|
1231
|
+
error: {
|
|
1232
|
+
code: BRIDGE_ERROR_CODES.UNSUPPORTED_CAPABILITY,
|
|
1233
|
+
message: `No saved ${data} data is available for @${username}.`
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
};
|
|
1237
|
+
}
|
|
1238
|
+
return {
|
|
1239
|
+
value: {
|
|
1240
|
+
taskId: task.id,
|
|
1241
|
+
limit: input.limit
|
|
1242
|
+
}
|
|
1243
|
+
};
|
|
1244
|
+
}
|
|
1245
|
+
function mapMediaSources(values) {
|
|
1246
|
+
const normalized = values.filter((value) => (value === 'posts' || value === 'reels' || value === 'stories' || value === 'highlights'));
|
|
1247
|
+
return normalized.length > 0 ? normalized : undefined;
|
|
1248
|
+
}
|
|
1249
|
+
function mapAccountRefreshInput(username, dataKind, input) {
|
|
1250
|
+
if (dataKind === 'profile') {
|
|
1251
|
+
return {
|
|
1252
|
+
capability: 'instagram.account.profile.snapshot',
|
|
1253
|
+
target: { type: 'account', handle: username },
|
|
1254
|
+
options: {
|
|
1255
|
+
limit: input.limit
|
|
1256
|
+
}
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
if (dataKind === 'followers') {
|
|
1260
|
+
return {
|
|
1261
|
+
capability: 'instagram.audience.followers.collect',
|
|
1262
|
+
target: { type: 'account', handle: username },
|
|
1263
|
+
options: {
|
|
1264
|
+
limit: input.limit
|
|
1265
|
+
}
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1268
|
+
if (dataKind === 'following') {
|
|
1269
|
+
return {
|
|
1270
|
+
capability: 'instagram.audience.following.collect',
|
|
1271
|
+
target: { type: 'account', handle: username },
|
|
1272
|
+
options: {
|
|
1273
|
+
limit: input.limit
|
|
1274
|
+
}
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1277
|
+
return {
|
|
1278
|
+
capability: 'instagram.posts.recent.collect',
|
|
1279
|
+
target: { type: 'account', handle: username },
|
|
1280
|
+
options: {
|
|
1281
|
+
limit: input.limit,
|
|
1282
|
+
mediaSources: mapMediaSources(asStringArray(input.mediaSources))
|
|
1283
|
+
}
|
|
1284
|
+
};
|
|
1285
|
+
}
|
|
1286
|
+
function findLatestTaskForAudience(tasks, kind) {
|
|
1287
|
+
const capability = kind === 'following'
|
|
1288
|
+
? 'instagram.audience.following.collect'
|
|
1289
|
+
: 'instagram.audience.followers.collect';
|
|
1290
|
+
return tasks
|
|
1291
|
+
.map(asObject)
|
|
1292
|
+
.filter((task) => task.capability === capability)
|
|
1293
|
+
.sort(compareUpdatedAt)[0] ?? null;
|
|
1294
|
+
}
|
|
1295
|
+
function buildActionFromCollectionRun(actionType, target, data) {
|
|
1296
|
+
const value = asObject(data);
|
|
1297
|
+
return {
|
|
1298
|
+
...buildActionEnvelope(actionType, 'running', target),
|
|
1299
|
+
actionId: stringOrNull(value.taskId),
|
|
1300
|
+
result: value
|
|
1301
|
+
};
|
|
1302
|
+
}
|
|
1303
|
+
function buildActionEnvelope(type, status, target) {
|
|
1304
|
+
return {
|
|
1305
|
+
type,
|
|
1306
|
+
status,
|
|
1307
|
+
target
|
|
1308
|
+
};
|
|
1309
|
+
}
|
|
1310
|
+
function summarizeAccount(account) {
|
|
1311
|
+
if (!account || typeof account !== 'object') {
|
|
1312
|
+
return null;
|
|
1313
|
+
}
|
|
1314
|
+
const value = asObject(account);
|
|
1315
|
+
return {
|
|
1316
|
+
id: stringOrNull(value.id),
|
|
1317
|
+
username: stringOrNull(value.username),
|
|
1318
|
+
displayName: stringOrNull(value.displayName),
|
|
1319
|
+
role: stringOrNull(value.targetRole) ?? 'uncategorized',
|
|
1320
|
+
profileUrl: stringOrNull(value.profileUrl),
|
|
1321
|
+
avatarUrl: stringOrNull(value.avatarUrl),
|
|
1322
|
+
updatedAt: stringOrNull(value.updatedAt),
|
|
1323
|
+
lastObservedAt: stringOrNull(value.lastObservedAt)
|
|
1324
|
+
};
|
|
1325
|
+
}
|
|
1326
|
+
function summarizeHashtag(hashtag) {
|
|
1327
|
+
if (!hashtag || typeof hashtag !== 'object') {
|
|
1328
|
+
return null;
|
|
1329
|
+
}
|
|
1330
|
+
const value = asObject(hashtag);
|
|
1331
|
+
return {
|
|
1332
|
+
id: stringOrNull(value.id),
|
|
1333
|
+
handle: stringOrNull(value.handle),
|
|
1334
|
+
displayName: stringOrNull(value.displayName),
|
|
1335
|
+
profileUrl: stringOrNull(value.profileUrl),
|
|
1336
|
+
updatedAt: stringOrNull(value.updatedAt),
|
|
1337
|
+
lastObservedAt: stringOrNull(value.lastObservedAt)
|
|
1338
|
+
};
|
|
1339
|
+
}
|
|
1340
|
+
function summarizeExport(contextExport) {
|
|
1341
|
+
const value = asObject(contextExport);
|
|
1342
|
+
return {
|
|
1343
|
+
id: stringOrNull(value.id),
|
|
1344
|
+
filename: stringOrNull(value.filename),
|
|
1345
|
+
dataType: stringOrNull(value.dataType),
|
|
1346
|
+
target: value.target ?? null,
|
|
1347
|
+
createdAt: stringOrNull(value.createdAt),
|
|
1348
|
+
recordCount: numberOrNull(value.recordCount),
|
|
1349
|
+
format: stringOrNull(value.format)
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
function summarizeExportPayload(data) {
|
|
1353
|
+
const value = asObject(data);
|
|
1354
|
+
return {
|
|
1355
|
+
id: stringOrNull(value.exportId),
|
|
1356
|
+
name: stringOrNull(value.filename),
|
|
1357
|
+
format: stringOrNull(value.format),
|
|
1358
|
+
recordCount: numberOrNull(value.recordCount),
|
|
1359
|
+
content: stringOrNull(value.content),
|
|
1360
|
+
csv: stringOrNull(value.csv)
|
|
1361
|
+
};
|
|
1362
|
+
}
|
|
1363
|
+
function summarizeConnection(status) {
|
|
1364
|
+
return {
|
|
1365
|
+
connected: status.connected,
|
|
1366
|
+
clientId: status.clientId,
|
|
1367
|
+
extensionVersion: status.extensionVersion,
|
|
1368
|
+
connectedAt: status.connectedAt,
|
|
1369
|
+
lastSeenAt: status.lastSeenAt,
|
|
1370
|
+
pendingRequests: status.pendingRequests
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1373
|
+
function inferActionTypeFromCapability(capability) {
|
|
1374
|
+
if (capability.includes('.export') || capability.includes('comments.collect') || capability.includes('snapshot.collect')) {
|
|
1375
|
+
return 'export';
|
|
1376
|
+
}
|
|
1377
|
+
if (capability.includes('.download')) {
|
|
1378
|
+
return 'download';
|
|
1379
|
+
}
|
|
1380
|
+
if (capability.includes('.upsert') || capability.includes('.delete') || capability.includes('.update')) {
|
|
1381
|
+
return 'manage';
|
|
1382
|
+
}
|
|
1383
|
+
return 'refresh';
|
|
1384
|
+
}
|
|
1385
|
+
function normalizeActionStatus(value) {
|
|
1386
|
+
if (value === 'queued' || value === 'running' || value === 'completed' || value === 'failed' || value === 'cancelled') {
|
|
1387
|
+
return value;
|
|
1388
|
+
}
|
|
1389
|
+
return 'failed';
|
|
1390
|
+
}
|
|
1391
|
+
function shouldInclude(input, value) {
|
|
1392
|
+
return Array.isArray(input.include) && input.include.includes(value);
|
|
1393
|
+
}
|
|
1394
|
+
function bridgeResultToToolResponse(result) {
|
|
1395
|
+
if (result.ok) {
|
|
1396
|
+
return jsonToolResponse(result.data);
|
|
1397
|
+
}
|
|
1398
|
+
return errorToolResponse(result);
|
|
1399
|
+
}
|
|
1400
|
+
function errorToolResponse(result) {
|
|
1401
|
+
if (result.ok) {
|
|
1402
|
+
return jsonToolResponse(result.data);
|
|
1403
|
+
}
|
|
1404
|
+
return {
|
|
1405
|
+
isError: true,
|
|
1406
|
+
content: [{
|
|
1407
|
+
type: 'text',
|
|
1408
|
+
text: JSON.stringify({
|
|
1409
|
+
error: {
|
|
1410
|
+
...result.error,
|
|
1411
|
+
retryAfter: extractRetryAfter(result.error.message)
|
|
1412
|
+
},
|
|
1413
|
+
requestId: result.requestId,
|
|
1414
|
+
capability: result.capability
|
|
1415
|
+
}, null, 2)
|
|
1416
|
+
}]
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
function extractRetryAfter(message) {
|
|
1420
|
+
const match = /Retry after (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z)/.exec(message);
|
|
1421
|
+
return match?.[1];
|
|
1422
|
+
}
|
|
1423
|
+
function jsonToolResponse(value) {
|
|
1424
|
+
return {
|
|
1425
|
+
content: [{
|
|
1426
|
+
type: 'text',
|
|
1427
|
+
text: JSON.stringify(value, null, 2)
|
|
1428
|
+
}]
|
|
1429
|
+
};
|
|
1430
|
+
}
|
|
1431
|
+
function asObject(value) {
|
|
1432
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
1433
|
+
? value
|
|
1434
|
+
: {};
|
|
1435
|
+
}
|
|
1436
|
+
function asArray(value) {
|
|
1437
|
+
return Array.isArray(value) ? value : [];
|
|
1438
|
+
}
|
|
1439
|
+
function asStringArray(value) {
|
|
1440
|
+
return Array.isArray(value)
|
|
1441
|
+
? value.filter((item) => typeof item === 'string')
|
|
1442
|
+
: [];
|
|
1443
|
+
}
|
|
1444
|
+
function stringOrNull(value) {
|
|
1445
|
+
return typeof value === 'string' && value.trim() ? value : null;
|
|
1446
|
+
}
|
|
1447
|
+
function numberOrNull(value) {
|
|
1448
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
1449
|
+
}
|
|
1450
|
+
function matchesSearch(value, query) {
|
|
1451
|
+
const object = asObject(value);
|
|
1452
|
+
return [
|
|
1453
|
+
object.username,
|
|
1454
|
+
object.displayName,
|
|
1455
|
+
object.handle
|
|
1456
|
+
].some((field) => typeof field === 'string' && field.toLowerCase().includes(query));
|
|
1457
|
+
}
|
|
1458
|
+
function compareUpdatedAt(left, right) {
|
|
1459
|
+
return String(right.updatedAt ?? '').localeCompare(String(left.updatedAt ?? ''));
|
|
1460
|
+
}
|
|
1461
|
+
function compareUpdatedAtDesc(left, right) {
|
|
1462
|
+
return String(right.updatedAt || right.collectedAt).localeCompare(String(left.updatedAt || left.collectedAt));
|
|
1463
|
+
}
|
|
1464
|
+
function normalizeHashtagHandle(value) {
|
|
1465
|
+
return value.trim().replace(/^#+/, '').trim().toLowerCase();
|
|
1466
|
+
}
|