Compare commits

...

12 Commits

Author SHA1 Message Date
jackwener 34624adb18 refactor(instagram): remove UI fallback from post, simplify reel
- Remove ~1200 lines of UI automation from post.ts (executeUiInstagramPost
  and all its dependencies: composer, upload, caption, retry logic)
- post.ts now uses Private API exclusively (like story.ts/note.ts)
- Remove process.env.VITEST coupling in reel.ts; always use passed-in page
- Replace hardcoded /tmp debug paths with os.tmpdir() in reel.ts and
  protocol-capture.ts
- Update post.test.ts: remove UI automation tests, keep Private API and
  JS builder function tests (used by reel.ts)
2026-04-04 16:14:32 +08:00
jackwener 12fe89d12c fix(instagram): use JSON.stringify for constants in note evaluate string
Replace template literal interpolation of Node-side constants with
JSON.stringify() for consistency with codebase evaluate patterns.
Use bracket notation for dynamic property access instead of template
interpolation into a property chain.
2026-04-04 15:36:13 +08:00
Ray 4546a3d3e3 Merge upstream main into instagram-post 2026-04-03 17:45:06 +08:00
Ray c8c773197e Add Instagram note publishing command 2026-04-03 17:23:08 +08:00
Ray 6e950a4201 Add Instagram story posting command 2026-04-03 14:59:26 +08:00
Ray f54401488a Unify Instagram post media input 2026-04-03 11:02:42 +08:00
Ray e22a312f50 Add Instagram mixed-media carousel posting 2026-04-03 09:54:46 +08:00
Ray d50cfe345f Add Instagram reel posting command 2026-04-03 00:55:26 +08:00
Ray c7d8678c11 Retry transient Instagram private setup failures 2026-04-02 23:11:49 +08:00
Ray 6aad5522f1 Add dynamic Instagram posting routes 2026-04-02 22:59:20 +08:00
Ray e8c27d8da5 Refine Instagram post flow 2026-04-02 15:35:45 +08:00
Ray a8fbb637cb Add draft Instagram posting flow 2026-04-02 01:44:09 +08:00
26 changed files with 6971 additions and 671 deletions
+1046 -658
View File
File diff suppressed because it is too large Load Diff
+144 -8
View File
@@ -117,6 +117,8 @@ type AutomationSession = {
windowId: number;
idleTimer: ReturnType<typeof setTimeout> | null;
idleDeadlineAt: number;
owned: boolean;
preferredTabId: number | null;
};
const automationSessions = new Map<string, AutomationSession>();
@@ -134,6 +136,11 @@ function resetWindowIdleTimer(workspace: string): void {
session.idleTimer = setTimeout(async () => {
const current = automationSessions.get(workspace);
if (!current) return;
if (!current.owned) {
console.log(`[opencli] Borrowed workspace ${workspace} detached from window ${current.windowId} (idle timeout)`);
automationSessions.delete(workspace);
return;
}
try {
await chrome.windows.remove(current.windowId);
console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout)`);
@@ -177,6 +184,8 @@ async function getAutomationWindow(workspace: string, initialUrl?: string): Prom
windowId: win.id!,
idleTimer: null,
idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT,
owned: true,
preferredTabId: null,
};
automationSessions.set(workspace, session);
console.log(`[opencli] Created automation window ${session.windowId} (${workspace}, start=${startUrl})`);
@@ -279,6 +288,14 @@ async function handleCommand(cmd: Command): Promise<Result> {
return await handleSessions(cmd);
case 'set-file-input':
return await handleSetFileInput(cmd, workspace);
case 'insert-text':
return await handleInsertText(cmd, workspace);
case 'bind-current':
return await handleBindCurrent(cmd, workspace);
case 'network-capture-start':
return await handleNetworkCaptureStart(cmd, workspace);
case 'network-capture-read':
return await handleNetworkCaptureRead(cmd, workspace);
default:
return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` };
}
@@ -326,7 +343,31 @@ function isTargetUrl(currentUrl: string | undefined, targetUrl: string): boolean
return normalizeUrlForComparison(currentUrl) === normalizeUrlForComparison(targetUrl);
}
function setWorkspaceSession(workspace: string, session: Pick<AutomationSession, 'windowId'>): void {
function matchesDomain(url: string | undefined, domain: string): boolean {
if (!url) return false;
try {
const parsed = new URL(url);
return parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`);
} catch {
return false;
}
}
function matchesBindCriteria(tab: chrome.tabs.Tab, cmd: Command): boolean {
if (!tab.id || !isDebuggableUrl(tab.url)) return false;
if (cmd.matchDomain && !matchesDomain(tab.url, cmd.matchDomain)) return false;
if (cmd.matchPathPrefix) {
try {
const parsed = new URL(tab.url!);
if (!parsed.pathname.startsWith(cmd.matchPathPrefix)) return false;
} catch {
return false;
}
}
return true;
}
function setWorkspaceSession(workspace: string, session: Omit<AutomationSession, 'idleTimer' | 'idleDeadlineAt'>): void {
const existing = automationSessions.get(workspace);
if (existing?.idleTimer) clearTimeout(existing.idleTimer);
automationSessions.set(workspace, {
@@ -348,9 +389,11 @@ async function resolveTab(tabId: number | undefined, workspace: string, initialU
try {
const tab = await chrome.tabs.get(tabId);
const session = automationSessions.get(workspace);
const matchesSession = session ? tab.windowId === session.windowId : false;
const matchesSession = session
? (session.preferredTabId !== null ? session.preferredTabId === tabId : tab.windowId === session.windowId)
: false;
if (isDebuggableUrl(tab.url) && matchesSession) return { tabId, tab };
if (session && !matchesSession && isDebuggableUrl(tab.url)) {
if (session && !matchesSession && session.preferredTabId === null && isDebuggableUrl(tab.url)) {
// Tab drifted to another window but content is still valid.
// Try to move it back instead of abandoning it.
console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId}, moving back to ${session.windowId}`);
@@ -371,6 +414,16 @@ async function resolveTab(tabId: number | undefined, workspace: string, initialU
}
}
const existingSession = automationSessions.get(workspace);
if (existingSession?.preferredTabId !== null) {
try {
const preferredTab = await chrome.tabs.get(existingSession.preferredTabId);
if (isDebuggableUrl(preferredTab.url)) return { tabId: preferredTab.id!, tab: preferredTab };
} catch {
automationSessions.delete(workspace);
}
}
// Get (or create) the automation window
const windowId = await getAutomationWindow(workspace, initialUrl);
@@ -408,6 +461,14 @@ async function resolveTabId(tabId: number | undefined, workspace: string, initia
async function listAutomationTabs(workspace: string): Promise<chrome.tabs.Tab[]> {
const session = automationSessions.get(workspace);
if (!session) return [];
if (session.preferredTabId !== null) {
try {
return [await chrome.tabs.get(session.preferredTabId)];
} catch {
automationSessions.delete(workspace);
return [];
}
}
try {
return await chrome.tabs.query({ windowId: session.windowId });
} catch {
@@ -681,10 +742,12 @@ async function handleCdp(cmd: Command, workspace: string): Promise<Result> {
async function handleCloseWindow(cmd: Command, workspace: string): Promise<Result> {
const session = automationSessions.get(workspace);
if (session) {
try {
await chrome.windows.remove(session.windowId);
} catch {
// Window may already be closed
if (session.owned) {
try {
await chrome.windows.remove(session.windowId);
} catch {
// Window may already be closed
}
}
if (session.idleTimer) clearTimeout(session.idleTimer);
automationSessions.delete(workspace);
@@ -705,6 +768,39 @@ async function handleSetFileInput(cmd: Command, workspace: string): Promise<Resu
}
}
async function handleInsertText(cmd: Command, workspace: string): Promise<Result> {
if (typeof cmd.text !== 'string') {
return { id: cmd.id, ok: false, error: 'Missing text payload' };
}
const tabId = await resolveTabId(cmd.tabId, workspace);
try {
await executor.insertText(tabId, cmd.text);
return { id: cmd.id, ok: true, data: { inserted: true } };
} catch (err) {
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
async function handleNetworkCaptureStart(cmd: Command, workspace: string): Promise<Result> {
const tabId = await resolveTabId(cmd.tabId, workspace);
try {
await executor.startNetworkCapture(tabId, cmd.pattern);
return { id: cmd.id, ok: true, data: { started: true } };
} catch (err) {
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
async function handleNetworkCaptureRead(cmd: Command, workspace: string): Promise<Result> {
const tabId = await resolveTabId(cmd.tabId, workspace);
try {
const data = await executor.readNetworkCapture(tabId);
return { id: cmd.id, ok: true, data };
} catch (err) {
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
async function handleSessions(cmd: Command): Promise<Result> {
const now = Date.now();
const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({
@@ -716,11 +812,49 @@ async function handleSessions(cmd: Command): Promise<Result> {
return { id: cmd.id, ok: true, data };
}
async function handleBindCurrent(cmd: Command, workspace: string): Promise<Result> {
const activeTabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
const fallbackTabs = await chrome.tabs.query({ lastFocusedWindow: true });
const allTabs = await chrome.tabs.query({});
const boundTab = activeTabs.find((tab) => matchesBindCriteria(tab, cmd))
?? fallbackTabs.find((tab) => matchesBindCriteria(tab, cmd))
?? allTabs.find((tab) => matchesBindCriteria(tab, cmd));
if (!boundTab?.id) {
return {
id: cmd.id,
ok: false,
error: cmd.matchDomain || cmd.matchPathPrefix
? `No visible tab matching ${cmd.matchDomain ?? 'domain'}${cmd.matchPathPrefix ? ` ${cmd.matchPathPrefix}` : ''}`
: 'No active debuggable tab found',
};
}
setWorkspaceSession(workspace, {
windowId: boundTab.windowId,
owned: false,
preferredTabId: boundTab.id,
});
resetWindowIdleTimer(workspace);
console.log(`[opencli] Workspace ${workspace} explicitly bound to tab ${boundTab.id} (${boundTab.url})`);
return {
id: cmd.id,
ok: true,
data: {
tabId: boundTab.id,
windowId: boundTab.windowId,
url: boundTab.url,
title: boundTab.title,
workspace,
},
};
}
export const __test__ = {
handleNavigate,
isTargetUrl,
handleTabs,
handleSessions,
handleBindCurrent,
resolveTabId,
resetWindowIdleTimer,
getSession: (workspace: string = 'default') => automationSessions.get(workspace) ?? null,
@@ -734,9 +868,11 @@ export const __test__ = {
}
setWorkspaceSession(workspace, {
windowId,
owned: true,
preferredTabId: null,
});
},
setSession: (workspace: string, session: { windowId: number }) => {
setSession: (workspace: string, session: { windowId: number; owned: boolean; preferredTabId: number | null }) => {
setWorkspaceSession(workspace, session);
},
};
+178 -1
View File
@@ -8,6 +8,27 @@
const attached = new Set<number>();
type NetworkCaptureEntry = {
kind: 'cdp';
url: string;
method: string;
requestHeaders?: Record<string, string>;
requestBodyKind?: string;
requestBodyPreview?: string;
responseStatus?: number;
responseContentType?: string;
responseHeaders?: Record<string, string>;
responsePreview?: string;
timestamp: number;
};
type NetworkCaptureState = {
patterns: string[];
entries: NetworkCaptureEntry[];
requestToIndex: Map<string, number>;
};
const networkCaptures = new Map<number, NetworkCaptureState>();
/** Check if a URL can be attached via CDP — only allow http(s) and blank pages. */
function isDebuggableUrl(url?: string): boolean {
if (!url) return true; // empty/undefined = tab still loading, allow it
@@ -241,18 +262,100 @@ export async function setFileInputFiles(
});
}
export async function insertText(
tabId: number,
text: string,
): Promise<void> {
await ensureAttached(tabId);
await chrome.debugger.sendCommand({ tabId }, 'Input.insertText', { text });
}
function normalizeCapturePatterns(pattern?: string): string[] {
return String(pattern || '')
.split('|')
.map((part) => part.trim())
.filter(Boolean);
}
function shouldCaptureUrl(url: string | undefined, patterns: string[]): boolean {
if (!url) return false;
if (!patterns.length) return true;
return patterns.some((pattern) => url.includes(pattern));
}
function normalizeHeaders(headers: unknown): Record<string, string> {
if (!headers || typeof headers !== 'object') return {};
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {
out[String(key)] = String(value);
}
return out;
}
function getOrCreateNetworkCaptureEntry(tabId: number, requestId: string, fallback?: {
url?: string;
method?: string;
requestHeaders?: Record<string, string>;
}): NetworkCaptureEntry | null {
const state = networkCaptures.get(tabId);
if (!state) return null;
const existingIndex = state.requestToIndex.get(requestId);
if (existingIndex !== undefined) {
return state.entries[existingIndex] || null;
}
const url = fallback?.url || '';
if (!shouldCaptureUrl(url, state.patterns)) return null;
const entry: NetworkCaptureEntry = {
kind: 'cdp',
url,
method: fallback?.method || 'GET',
requestHeaders: fallback?.requestHeaders || {},
timestamp: Date.now(),
};
state.entries.push(entry);
state.requestToIndex.set(requestId, state.entries.length - 1);
return entry;
}
export async function startNetworkCapture(
tabId: number,
pattern?: string,
): Promise<void> {
await ensureAttached(tabId);
await chrome.debugger.sendCommand({ tabId }, 'Network.enable');
networkCaptures.set(tabId, {
patterns: normalizeCapturePatterns(pattern),
entries: [],
requestToIndex: new Map(),
});
}
export async function readNetworkCapture(tabId: number): Promise<NetworkCaptureEntry[]> {
const state = networkCaptures.get(tabId);
if (!state) return [];
const entries = state.entries.slice();
state.entries = [];
state.requestToIndex.clear();
return entries;
}
export async function detach(tabId: number): Promise<void> {
if (!attached.has(tabId)) return;
attached.delete(tabId);
networkCaptures.delete(tabId);
try { await chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
}
export function registerListeners(): void {
chrome.tabs.onRemoved.addListener((tabId) => {
attached.delete(tabId);
networkCaptures.delete(tabId);
});
chrome.debugger.onDetach.addListener((source) => {
if (source.tabId) attached.delete(source.tabId);
if (source.tabId) {
attached.delete(source.tabId);
networkCaptures.delete(source.tabId);
}
});
// Invalidate attached cache when tab URL changes to non-debuggable
chrome.tabs.onUpdated.addListener(async (tabId, info) => {
@@ -260,4 +363,78 @@ export function registerListeners(): void {
await detach(tabId);
}
});
chrome.debugger.onEvent.addListener(async (source, method, params) => {
const tabId = source.tabId;
if (!tabId) return;
const state = networkCaptures.get(tabId);
if (!state) return;
if (method === 'Network.requestWillBeSent') {
const requestId = String(params?.requestId || '');
const request = params?.request as {
url?: string;
method?: string;
headers?: Record<string, unknown>;
postData?: string;
hasPostData?: boolean;
} | undefined;
const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, {
url: request?.url,
method: request?.method,
requestHeaders: normalizeHeaders(request?.headers),
});
if (!entry) return;
entry.requestBodyKind = request?.hasPostData ? 'string' : 'empty';
entry.requestBodyPreview = String(request?.postData || '').slice(0, 4000);
try {
const postData = await chrome.debugger.sendCommand({ tabId }, 'Network.getRequestPostData', { requestId }) as { postData?: string };
if (postData?.postData) {
entry.requestBodyKind = 'string';
entry.requestBodyPreview = postData.postData.slice(0, 4000);
}
} catch {
// Optional; some requests do not expose postData.
}
return;
}
if (method === 'Network.responseReceived') {
const requestId = String(params?.requestId || '');
const response = params?.response as {
url?: string;
mimeType?: string;
status?: number;
headers?: Record<string, unknown>;
} | undefined;
const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, {
url: response?.url,
});
if (!entry) return;
entry.responseStatus = response?.status;
entry.responseContentType = response?.mimeType || '';
entry.responseHeaders = normalizeHeaders(response?.headers);
return;
}
if (method === 'Network.loadingFinished') {
const requestId = String(params?.requestId || '');
const stateEntryIndex = state.requestToIndex.get(requestId);
if (stateEntryIndex === undefined) return;
const entry = state.entries[stateEntryIndex];
if (!entry) return;
try {
const body = await chrome.debugger.sendCommand({ tabId }, 'Network.getResponseBody', { requestId }) as {
body?: string;
base64Encoded?: boolean;
};
if (typeof body?.body === 'string') {
entry.responsePreview = body.base64Encoded
? `base64:${body.body.slice(0, 4000)}`
: body.body.slice(0, 4000);
}
} catch {
// Optional; bodies are unavailable for some requests (e.g. uploads).
}
}
});
}
+22 -1
View File
@@ -5,7 +5,20 @@
* Everything else is just JS code sent via 'exec'.
*/
export type Action = 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'cdp';
export type Action =
| 'exec'
| 'navigate'
| 'tabs'
| 'cookies'
| 'screenshot'
| 'close-window'
| 'sessions'
| 'set-file-input'
| 'insert-text'
| 'bind-current'
| 'network-capture-start'
| 'network-capture-read'
| 'cdp';
export interface Command {
/** Unique request ID */
@@ -26,6 +39,10 @@ export interface Command {
index?: number;
/** Cookie domain filter */
domain?: string;
/** Optional hostname/domain to require for current-tab binding */
matchDomain?: string;
/** Optional pathname prefix to require for current-tab binding */
matchPathPrefix?: string;
/** Screenshot format: png (default) or jpeg */
format?: 'png' | 'jpeg';
/** JPEG quality (0-100), only for jpeg format */
@@ -36,6 +53,10 @@ export interface Command {
files?: string[];
/** CSS selector for file input element (set-file-input action) */
selector?: string;
/** Raw text payload for insert-text action */
text?: string;
/** URL substring filter pattern for network capture actions */
pattern?: string;
/** CDP method name for 'cdp' action (e.g. 'Accessibility.getFullAXTree') */
cdpMethod?: string;
/** CDP method params for 'cdp' action */
+11 -1
View File
@@ -21,7 +21,7 @@ function generateId(): string {
export interface DaemonCommand {
id: string;
action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'cdp';
action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'insert-text' | 'bind-current' | 'network-capture-start' | 'network-capture-read' | 'cdp';
tabId?: number;
code?: string;
workspace?: string;
@@ -29,6 +29,8 @@ export interface DaemonCommand {
op?: string;
index?: number;
domain?: string;
matchDomain?: string;
matchPathPrefix?: string;
format?: 'png' | 'jpeg';
quality?: number;
fullPage?: boolean;
@@ -37,6 +39,10 @@ export interface DaemonCommand {
files?: string[];
/** CSS selector for file input element (set-file-input action) */
selector?: string;
/** Raw text payload for insert-text action */
text?: string;
/** URL substring filter pattern for network capture */
pattern?: string;
cdpMethod?: string;
cdpParams?: Record<string, unknown>;
}
@@ -163,3 +169,7 @@ export async function listSessions(): Promise<BrowserSessionInfo[]> {
const result = await sendCommand('sessions');
return Array.isArray(result) ? result : [];
}
export async function bindCurrentTab(workspace: string, opts: { matchDomain?: string; matchPathPrefix?: string } = {}): Promise<unknown> {
return sendCommand('bind-current', { workspace, ...opts });
}
+26 -1
View File
@@ -120,6 +120,9 @@ export class Page extends BasePage {
await sendCommand('close-window', { ...this._wsOpt() });
} catch {
// Window may already be closed or daemon may be down
} finally {
this._tabId = undefined;
this._lastUrl = null;
}
}
@@ -151,6 +154,19 @@ export class Page extends BasePage {
return base64;
}
async startNetworkCapture(pattern: string = ''): Promise<void> {
await sendCommand('network-capture-start', {
pattern,
...this._cmdOpts(),
});
}
async readNetworkCapture(): Promise<unknown[]> {
const result = await sendCommand('network-capture-read', {
...this._cmdOpts(),
});
return Array.isArray(result) ? result : [];
}
/**
* Set local file paths on a file input element via CDP DOM.setFileInputFiles.
* Chrome reads the files directly from the local filesystem, avoiding the
@@ -167,6 +183,16 @@ export class Page extends BasePage {
}
}
async insertText(text: string): Promise<void> {
const result = await sendCommand('insert-text', {
text,
...this._cmdOpts(),
}) as { inserted?: boolean };
if (!result?.inserted) {
throw new Error('insertText returned no inserted flag — command may not be supported by the extension');
}
}
async cdp(method: string, params: Record<string, unknown> = {}): Promise<unknown> {
return sendCommand('cdp', {
cdpMethod: method,
@@ -287,4 +313,3 @@ export class Page extends BasePage {
});
}
}
+2
View File
@@ -33,6 +33,7 @@ export interface ManifestEntry {
type?: string;
default?: unknown;
required?: boolean;
valueRequired?: boolean;
positional?: boolean;
help?: string;
choices?: string[];
@@ -62,6 +63,7 @@ function toManifestArgs(args: CliCommand['args']): ManifestEntry['args'] {
type: arg.type ?? 'str',
default: arg.default,
required: !!arg.required,
valueRequired: !!arg.valueRequired || undefined,
positional: arg.positional || undefined,
help: arg.help ?? '',
choices: arg.choices,
@@ -0,0 +1,827 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterAll, describe, expect, it } from 'vitest';
import type { InstagramProtocolCaptureEntry } from './protocol-capture.js';
import {
buildConfigureBody,
buildConfigureSidecarPayload,
buildConfigureToStoryPhotoPayload,
buildConfigureToStoryVideoPayload,
deriveInstagramJazoest,
derivePrivateApiContextFromCapture,
extractInstagramRuntimeInfo,
getInstagramFeedNormalizedDimensions,
getInstagramStoryNormalizedDimensions,
isInstagramFeedAspectRatioAllowed,
isInstagramStoryAspectRatioAllowed,
publishStoryViaPrivateApi,
publishMediaViaPrivateApi,
publishImagesViaPrivateApi,
readImageAsset,
resolveInstagramPrivatePublishConfig,
} from './private-publish.js';
const tempDirs: string[] = [];
function createTempFile(name: string, bytes: Buffer): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-instagram-private-'));
tempDirs.push(dir);
const filePath = path.join(dir, name);
fs.writeFileSync(filePath, bytes);
return filePath;
}
afterAll(() => {
for (const dir of tempDirs) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe('instagram private publish helpers', () => {
it('derives the private API context from captured instagram request headers', () => {
const entries: InstagramProtocolCaptureEntry[] = [
{
kind: 'cdp' as never,
url: 'https://www.instagram.com/api/v1/feed/timeline/',
method: 'GET',
requestHeaders: {
'X-ASBD-ID': '359341',
'X-CSRFToken': 'csrf-token',
'X-IG-App-ID': '936619743392459',
'X-IG-WWW-Claim': 'hmac.claim',
'X-Instagram-AJAX': '1036517563',
'X-Web-Session-ID': 'abc:def:ghi',
},
timestamp: Date.now(),
},
];
expect(derivePrivateApiContextFromCapture(entries)).toEqual({
asbdId: '359341',
csrfToken: 'csrf-token',
igAppId: '936619743392459',
igWwwClaim: 'hmac.claim',
instagramAjax: '1036517563',
webSessionId: 'abc:def:ghi',
});
});
it('derives jazoest from the csrf token', () => {
expect(deriveInstagramJazoest('SJ_btbvfkpAVFKCN_tJstW')).toBe('22047');
});
it('extracts app id, rollout hash, and csrf token from instagram html', () => {
const html = `
<html>
<head>
<script type="application/json">
{"csrf_token":"csrf-from-html","rollout_hash":"1036523242","X-IG-App-ID":"936619743392459"}
</script>
</head>
</html>
`;
expect(extractInstagramRuntimeInfo(html)).toEqual({
appId: '936619743392459',
csrfToken: 'csrf-from-html',
instagramAjax: '1036523242',
});
});
it('resolves private publish config from capture, runtime html, and cookies', async () => {
const entries: InstagramProtocolCaptureEntry[] = [
{
kind: 'cdp' as never,
url: 'https://www.instagram.com/api/v1/feed/timeline/',
method: 'GET',
requestHeaders: {
'X-ASBD-ID': '359341',
'X-IG-WWW-Claim': 'hmac.claim',
'X-Web-Session-ID': 'abc:def:ghi',
},
timestamp: Date.now(),
},
];
const page = {
goto: async () => undefined,
wait: async () => undefined,
getCookies: async () => [{ name: 'csrftoken', value: 'csrf-cookie', domain: 'instagram.com' }],
startNetworkCapture: async () => undefined,
readNetworkCapture: async () => entries,
evaluate: async () => ({
appId: '936619743392459',
csrfToken: 'csrf-from-html',
instagramAjax: '1036523242',
}),
} as any;
await expect(resolveInstagramPrivatePublishConfig(page)).resolves.toEqual({
apiContext: {
asbdId: '359341',
csrfToken: 'csrf-from-html',
igAppId: '936619743392459',
igWwwClaim: 'hmac.claim',
instagramAjax: '1036523242',
webSessionId: 'abc:def:ghi',
},
jazoest: deriveInstagramJazoest('csrf-from-html'),
});
});
it('retries transient private publish config resolution failures and then succeeds', async () => {
const entries: InstagramProtocolCaptureEntry[] = [
{
kind: 'cdp' as never,
url: 'https://www.instagram.com/api/v1/feed/timeline/',
method: 'GET',
requestHeaders: {
'X-ASBD-ID': '359341',
'X-IG-WWW-Claim': 'hmac.claim',
'X-Web-Session-ID': 'abc:def:ghi',
},
timestamp: Date.now(),
},
];
let evaluateAttempts = 0;
const page = {
goto: async () => undefined,
wait: async () => undefined,
getCookies: async () => [{ name: 'csrftoken', value: 'csrf-cookie', domain: 'instagram.com' }],
startNetworkCapture: async () => undefined,
readNetworkCapture: async () => entries,
evaluate: async () => {
evaluateAttempts += 1;
if (evaluateAttempts === 1) {
throw new TypeError('fetch failed');
}
return {
appId: '936619743392459',
csrfToken: 'csrf-from-html',
instagramAjax: '1036523242',
};
},
} as any;
await expect(resolveInstagramPrivatePublishConfig(page)).resolves.toEqual({
apiContext: {
asbdId: '359341',
csrfToken: 'csrf-from-html',
igAppId: '936619743392459',
igWwwClaim: 'hmac.claim',
instagramAjax: '1036523242',
webSessionId: 'abc:def:ghi',
},
jazoest: deriveInstagramJazoest('csrf-from-html'),
});
expect(evaluateAttempts).toBe(2);
});
it('builds the single-image configure form body', () => {
expect(buildConfigureBody({
uploadId: '1775134280303',
caption: 'hello private route',
jazoest: '22047',
})).toBe(
'archive_only=false&caption=hello+private+route&clips_share_preview_to_feed=1'
+ '&disable_comments=0&disable_oa_reuse=false&igtv_share_preview_to_feed=1'
+ '&is_meta_only_post=0&is_unified_video=1&like_and_view_counts_disabled=0'
+ '&media_share_flow=creation_flow&share_to_facebook=&share_to_fb_destination_type=USER'
+ '&source_type=library&upload_id=1775134280303&video_subtitles_enabled=0&jazoest=22047'
);
});
it('builds the carousel configure_sidecar JSON payload', () => {
expect(buildConfigureSidecarPayload({
uploadIds: ['1', '3', '2'],
caption: 'hello carousel',
clientSidecarId: '1775134574348',
jazoest: '22047',
})).toEqual({
archive_only: false,
caption: 'hello carousel',
children_metadata: [
{ upload_id: '1' },
{ upload_id: '3' },
{ upload_id: '2' },
],
client_sidecar_id: '1775134574348',
disable_comments: '0',
is_meta_only_post: false,
is_open_to_public_submission: false,
like_and_view_counts_disabled: 0,
media_share_flow: 'creation_flow',
share_to_facebook: '',
share_to_fb_destination_type: 'USER',
source_type: 'library',
jazoest: '22047',
});
});
it('reads png and jpeg image assets with mime type and dimensions', () => {
const png = createTempFile('sample.png', Buffer.from(
'89504E470D0A1A0A0000000D49484452000000030000000508060000008D6F26E50000000049454E44AE426082',
'hex',
));
const jpeg = createTempFile('sample.jpg', Buffer.from(
'FFD8FFE000104A46494600010100000100010000FFC00011080004000603012200021101031101FFD9',
'hex',
));
expect(readImageAsset(png)).toMatchObject({
mimeType: 'image/png',
width: 3,
height: 5,
});
expect(readImageAsset(jpeg)).toMatchObject({
mimeType: 'image/jpeg',
width: 6,
height: 4,
});
});
it('computes feed-safe aspect-ratio normalization targets', () => {
expect(isInstagramFeedAspectRatioAllowed(1080, 1350)).toBe(true);
expect(isInstagramFeedAspectRatioAllowed(1179, 2556)).toBe(false);
expect(getInstagramFeedNormalizedDimensions(1179, 2556)).toEqual({
width: 2045,
height: 2556,
});
expect(getInstagramFeedNormalizedDimensions(2120, 1140)).toBeNull();
});
it('computes story-safe aspect-ratio normalization targets', () => {
expect(isInstagramStoryAspectRatioAllowed(1080, 1920)).toBe(true);
expect(isInstagramStoryAspectRatioAllowed(1080, 1080)).toBe(false);
expect(getInstagramStoryNormalizedDimensions(1080, 1080)).toEqual({
width: 1080,
height: 1440,
});
});
it('builds the single-photo configure_to_story payload', () => {
expect(buildConfigureToStoryPhotoPayload({
uploadId: '1775134280303',
width: 1080,
height: 1920,
now: () => 1_775_134_280_303,
jazoest: '22047',
})).toMatchObject({
source_type: '4',
upload_id: '1775134280303',
configure_mode: 1,
edits: {
crop_original_size: [1080, 1920],
crop_center: [0, 0],
crop_zoom: 1.3333334,
},
extra: {
source_width: 1080,
source_height: 1920,
},
jazoest: '22047',
});
});
it('builds the single-video configure_to_story payload', () => {
expect(buildConfigureToStoryVideoPayload({
uploadId: '1775134280303',
width: 1080,
height: 1920,
durationMs: 12500,
now: () => 1_775_134_280_303,
jazoest: '22047',
})).toMatchObject({
source_type: '4',
upload_id: '1775134280303',
configure_mode: 1,
poster_frame_index: 0,
length: 12.5,
extra: {
source_width: 1080,
source_height: 1920,
},
jazoest: '22047',
});
});
it('publishes a single image through rupload + configure', async () => {
const jpeg = createTempFile('private-single.jpg', Buffer.from(
'FFD8FFE000104A46494600010100000100010000FFC00011080004000603012200021101031101FFD9',
'hex',
));
const calls: Array<{ url: string; init?: { method?: string; headers?: Record<string, string>; body?: unknown } }> = [];
const fetcher = async (url: string | URL, init?: { method?: string; headers?: Record<string, string>; body?: unknown }) => {
calls.push({ url: String(url), init });
if (String(url).includes('/rupload_igphoto/')) {
return new Response('{"upload_id":"111","status":"ok"}', { status: 200 });
}
return new Response('{"media":{"code":"ABC123"}}', { status: 200 });
};
const response = await publishImagesViaPrivateApi({
page: {} as never,
imagePaths: [jpeg],
caption: 'private single',
apiContext: {
asbdId: '359341',
csrfToken: 'csrf-token',
igAppId: '936619743392459',
igWwwClaim: 'hmac.claim',
instagramAjax: '1036517563',
webSessionId: 'abc:def:ghi',
},
jazoest: '22047',
now: () => 111,
fetcher,
});
expect(calls).toHaveLength(2);
expect(calls[0]?.url).toContain('https://i.instagram.com/rupload_igphoto/fb_uploader_111');
expect(calls[0]?.init?.headers).toMatchObject({
'Content-Type': 'image/jpeg',
'X-Entity-Length': String(fs.statSync(jpeg).size),
'X-Entity-Name': 'fb_uploader_111',
'X-IG-App-ID': '936619743392459',
});
expect(calls[1]?.url).toBe('https://www.instagram.com/api/v1/media/configure/');
expect(String(calls[1]?.init?.body || '')).toContain('upload_id=111');
expect(response).toEqual({ code: 'ABC123', uploadIds: ['111'] });
});
it('publishes a single image story through rupload + configure_to_story', async () => {
const jpeg = createTempFile('private-story.jpg', Buffer.from(
'FFD8FFE000104A46494600010100000100010000FFC00011080010000903012200021101031101FFD9',
'hex',
));
const calls: Array<{ url: string; init?: { method?: string; headers?: Record<string, string>; body?: unknown } }> = [];
const fetcher = async (url: string | URL, init?: { method?: string; headers?: Record<string, string>; body?: unknown }) => {
calls.push({ url: String(url), init });
if (String(url).includes('/rupload_igphoto/')) {
return new Response('{"upload_id":"111","status":"ok"}', { status: 200 });
}
return new Response('{"media":{"pk":"1234567890"}}', { status: 200 });
};
const response = await publishStoryViaPrivateApi({
page: {} as never,
mediaItem: { type: 'image', filePath: jpeg },
content: '',
currentUserId: '61236465677',
apiContext: {
asbdId: '359341',
csrfToken: 'csrf-token',
igAppId: '936619743392459',
igWwwClaim: 'hmac.claim',
instagramAjax: '1036517563',
webSessionId: 'abc:def:ghi',
},
jazoest: '22047',
now: () => 111,
fetcher,
prepareMediaAsset: async () => ({
type: 'image',
asset: {
filePath: jpeg,
fileName: path.basename(jpeg),
mimeType: 'image/jpeg',
width: 1080,
height: 1920,
byteLength: fs.statSync(jpeg).size,
bytes: fs.readFileSync(jpeg),
},
}),
});
expect(calls).toHaveLength(2);
expect(calls[0]?.url).toContain('/rupload_igphoto/fb_uploader_111');
expect(calls[1]?.url).toBe('https://i.instagram.com/api/v1/media/configure_to_story/');
expect(String(calls[1]?.init?.body || '')).toContain('signed_body=');
expect(response).toEqual({ mediaPk: '1234567890', uploadId: '111' });
});
it('publishes a single video story through rupload + cover + configure_to_story?video=1', async () => {
const video = createTempFile('private-story.mp4', Buffer.from('story-video'));
const coverBytes = Buffer.from(
'FFD8FFE000104A46494600010100000100010000FFC00011080010000903012200021101031101FFD9',
'hex',
);
const calls: Array<{ url: string; init?: { method?: string; headers?: Record<string, string>; body?: unknown } }> = [];
const fetcher = async (url: string | URL, init?: { method?: string; headers?: Record<string, string>; body?: unknown }) => {
calls.push({ url: String(url), init });
if (String(url).includes('/rupload_igvideo/')) {
return new Response('{"upload_id":"222","status":"ok"}', { status: 200 });
}
if (String(url).includes('/rupload_igphoto/')) {
return new Response('{"upload_id":"222","status":"ok"}', { status: 200 });
}
return new Response('{"media":{"pk":"9988776655"}}', { status: 200 });
};
const response = await publishStoryViaPrivateApi({
page: {} as never,
mediaItem: { type: 'video', filePath: video },
content: '',
currentUserId: '61236465677',
apiContext: {
asbdId: '359341',
csrfToken: 'csrf-token',
igAppId: '936619743392459',
igWwwClaim: 'hmac.claim',
instagramAjax: '1036517563',
webSessionId: 'abc:def:ghi',
},
jazoest: '22047',
now: () => 222,
fetcher,
prepareMediaAsset: async () => ({
type: 'video',
asset: {
filePath: video,
fileName: path.basename(video),
mimeType: 'video/mp4',
width: 1080,
height: 1920,
durationMs: 12500,
byteLength: fs.statSync(video).size,
bytes: fs.readFileSync(video),
coverImage: {
filePath: '/tmp/cover.jpg',
fileName: 'cover.jpg',
mimeType: 'image/jpeg',
width: 1080,
height: 1920,
byteLength: coverBytes.length,
bytes: coverBytes,
},
},
}),
});
expect(calls).toHaveLength(4);
expect(calls[0]?.url).toContain('/rupload_igvideo/fb_uploader_222');
expect(calls[1]?.url).toContain('/rupload_igphoto/fb_uploader_222');
expect(calls[2]?.url).toBe('https://i.instagram.com/api/v1/media/configure_to_story/');
expect(calls[3]?.url).toBe('https://i.instagram.com/api/v1/media/configure_to_story/?video=1');
expect(String(calls[2]?.init?.body || '')).toContain('signed_body=');
expect(String(calls[3]?.init?.body || '')).toContain('signed_body=');
expect(response).toEqual({ mediaPk: '9988776655', uploadId: '222' });
});
it('publishes a carousel through rupload + configure_sidecar', async () => {
const first = createTempFile('private-carousel-1.jpg', Buffer.from(
'FFD8FFE000104A46494600010100000100010000FFC00011080004000603012200021101031101FFD9',
'hex',
));
const second = createTempFile('private-carousel-2.png', Buffer.from(
'89504E470D0A1A0A0000000D49484452000000030000000508060000008D6F26E50000000049454E44AE426082',
'hex',
));
const calls: Array<{ url: string; init?: { method?: string; headers?: Record<string, string>; body?: unknown } }> = [];
let uploadCounter = 0;
const fetcher = async (url: string | URL, init?: { method?: string; headers?: Record<string, string>; body?: unknown }) => {
calls.push({ url: String(url), init });
if (String(url).includes('/rupload_igphoto/')) {
uploadCounter += 1;
return new Response(JSON.stringify({ upload_id: String(200 + uploadCounter), status: 'ok' }), { status: 200 });
}
return new Response('{"media":{"code":"SIDE123"}}', { status: 200 });
};
const response = await publishImagesViaPrivateApi({
page: {} as never,
imagePaths: [first, second],
caption: 'private carousel',
apiContext: {
asbdId: '359341',
csrfToken: 'csrf-token',
igAppId: '936619743392459',
igWwwClaim: 'hmac.claim',
instagramAjax: '1036517563',
webSessionId: 'abc:def:ghi',
},
jazoest: '22047',
now: () => 200,
fetcher,
prepareAsset: async (filePath) => readImageAsset(filePath),
});
expect(calls).toHaveLength(3);
expect(calls[2]?.url).toBe('https://www.instagram.com/api/v1/media/configure_sidecar/');
expect(JSON.parse(String(calls[2]?.init?.body || '{}'))).toMatchObject({
caption: 'private carousel',
client_sidecar_id: '200',
children_metadata: [{ upload_id: '201' }, { upload_id: '202' }],
});
expect(response).toEqual({ code: 'SIDE123', uploadIds: ['201', '202'] });
});
it('uses prepared assets when private carousel upload needs aspect-ratio normalization', async () => {
const first = createTempFile('private-carousel-normalize-1.jpg', Buffer.from(
'FFD8FFE000104A46494600010100000100010000FFC00011080004000603012200021101031101FFD9',
'hex',
));
const second = createTempFile('private-carousel-normalize-2.png', Buffer.from(
'89504E470D0A1A0A0000000D49484452000000030000000508060000008D6F26E50000000049454E44AE426082',
'hex',
));
const calls: Array<{ url: string; init?: { method?: string; headers?: Record<string, string>; body?: unknown } }> = [];
let uploadCounter = 0;
const fetcher = async (url: string | URL, init?: { method?: string; headers?: Record<string, string>; body?: unknown }) => {
calls.push({ url: String(url), init });
if (String(url).includes('/rupload_igphoto/')) {
uploadCounter += 1;
return new Response(JSON.stringify({ upload_id: String(400 + uploadCounter), status: 'ok' }), { status: 200 });
}
return new Response('{"media":{"code":"SIDEPAD"}}', { status: 200 });
};
const preparedBytes = Buffer.from(
'89504E470D0A1A0A0000000D49484452000007FD000009FC08060000008D6F26E50000000049454E44AE426082',
'hex',
);
const response = await publishImagesViaPrivateApi({
page: {} as never,
imagePaths: [first, second],
caption: 'private carousel normalized',
apiContext: {
asbdId: '359341',
csrfToken: 'csrf-token',
igAppId: '936619743392459',
igWwwClaim: 'hmac.claim',
instagramAjax: '1036517563',
webSessionId: 'abc:def:ghi',
},
jazoest: '22047',
now: () => 400,
fetcher,
prepareAsset: async (filePath) => {
if (filePath === second) {
return {
filePath: '/tmp/normalized.png',
fileName: 'normalized.png',
mimeType: 'image/png',
width: 2045,
height: 2556,
byteLength: preparedBytes.length,
bytes: preparedBytes,
cleanupPath: '/tmp/normalized.png',
};
}
return readImageAsset(filePath);
},
});
const secondUploadHeaders = calls[1]?.init?.headers ?? {};
expect(JSON.parse(String(secondUploadHeaders['X-Instagram-Rupload-Params'] || '{}'))).toMatchObject({
upload_media_width: 2045,
upload_media_height: 2556,
});
expect(response).toEqual({ code: 'SIDEPAD', uploadIds: ['401', '402'] });
});
it('includes the response body when configure_sidecar returns a 400', async () => {
const first = createTempFile('private-carousel-error-1.jpg', Buffer.from(
'FFD8FFE000104A46494600010100000100010000FFC00011080004000603012200021101031101FFD9',
'hex',
));
const second = createTempFile('private-carousel-error-2.png', Buffer.from(
'89504E470D0A1A0A0000000D49484452000000030000000508060000008D6F26E50000000049454E44AE426082',
'hex',
));
let uploadCounter = 0;
const fetcher = async (url: string | URL) => {
if (String(url).includes('/rupload_igphoto/')) {
uploadCounter += 1;
return new Response(JSON.stringify({ upload_id: String(300 + uploadCounter), status: 'ok' }), { status: 200 });
}
return new Response('{"message":"children_metadata invalid"}', { status: 400 });
};
await expect(publishImagesViaPrivateApi({
page: {} as never,
imagePaths: [first, second],
caption: 'private carousel',
apiContext: {
asbdId: '359341',
csrfToken: 'csrf-token',
igAppId: '936619743392459',
igWwwClaim: 'hmac.claim',
instagramAjax: '1036517563',
webSessionId: 'abc:def:ghi',
},
jazoest: '22047',
now: () => 300,
fetcher,
prepareAsset: async (filePath) => readImageAsset(filePath),
})).rejects.toThrow('children_metadata invalid');
});
it('retries transient rupload fetch failures and still completes the carousel publish', async () => {
const first = createTempFile('private-carousel-retry-1.jpg', Buffer.from(
'FFD8FFE000104A46494600010100000100010000FFC00011080004000603012200021101031101FFD9',
'hex',
));
const second = createTempFile('private-carousel-retry-2.png', Buffer.from(
'89504E470D0A1A0A0000000D49484452000000030000000508060000008D6F26E50000000049454E44AE426082',
'hex',
));
const calls: string[] = [];
let firstUploadAttempts = 0;
let uploadCounter = 0;
const fetcher = async (url: string | URL) => {
const value = String(url);
calls.push(value);
if (value.includes('/rupload_igphoto/')) {
firstUploadAttempts += value.includes('fb_uploader_501') ? 1 : 0;
if (value.includes('fb_uploader_501') && firstUploadAttempts === 1) {
throw new TypeError('fetch failed');
}
uploadCounter += 1;
return new Response(JSON.stringify({ upload_id: String(500 + uploadCounter), status: 'ok' }), { status: 200 });
}
return new Response('{"media":{"code":"SIDERETRY"}}', { status: 200 });
};
const response = await publishImagesViaPrivateApi({
page: {} as never,
imagePaths: [first, second],
caption: 'private carousel retry',
apiContext: {
asbdId: '359341',
csrfToken: 'csrf-token',
igAppId: '936619743392459',
igWwwClaim: 'hmac.claim',
instagramAjax: '1036517563',
webSessionId: 'abc:def:ghi',
},
jazoest: '22047',
now: () => 500,
fetcher,
prepareAsset: async (filePath) => readImageAsset(filePath),
});
expect(calls.filter((url) => url.includes('fb_uploader_501'))).toHaveLength(2);
expect(response).toEqual({ code: 'SIDERETRY', uploadIds: ['501', '502'] });
});
it('does not retry transient configure_sidecar fetch failures to avoid duplicate posts', async () => {
const first = createTempFile('private-carousel-no-retry-1.jpg', Buffer.from(
'FFD8FFE000104A46494600010100000100010000FFC00011080004000603012200021101031101FFD9',
'hex',
));
const second = createTempFile('private-carousel-no-retry-2.png', Buffer.from(
'89504E470D0A1A0A0000000D49484452000000030000000508060000008D6F26E50000000049454E44AE426082',
'hex',
));
const calls: string[] = [];
let uploadCounter = 0;
const fetcher = async (url: string | URL) => {
const value = String(url);
calls.push(value);
if (value.includes('/rupload_igphoto/')) {
uploadCounter += 1;
return new Response(JSON.stringify({ upload_id: String(600 + uploadCounter), status: 'ok' }), { status: 200 });
}
throw new TypeError('fetch failed');
};
await expect(publishImagesViaPrivateApi({
page: {} as never,
imagePaths: [first, second],
caption: 'private no retry configure',
apiContext: {
asbdId: '359341',
csrfToken: 'csrf-token',
igAppId: '936619743392459',
igWwwClaim: 'hmac.claim',
instagramAjax: '1036517563',
webSessionId: 'abc:def:ghi',
},
jazoest: '22047',
now: () => 600,
fetcher,
prepareAsset: async (filePath) => readImageAsset(filePath),
})).rejects.toThrow('fetch failed');
expect(calls.filter((url) => url.includes('configure_sidecar'))).toHaveLength(1);
});
it('publishes a mixed image/video carousel and polls configure_sidecar until transcoding finishes', async () => {
const image = createTempFile('mixed-private-image.jpg', Buffer.from(
'FFD8FFE000104A46494600010100000100010000FFC00011080004000603012200021101031101FFD9',
'hex',
));
const video = createTempFile('mixed-private-video.mp4', Buffer.from('video-binary'));
const coverBytes = Buffer.from(
'FFD8FFE000104A46494600010100000100010000FFC00011080168028003012200021101031101FFD9',
'hex',
);
const calls: Array<{ url: string; init?: { method?: string; headers?: Record<string, string>; body?: unknown } }> = [];
let configureAttempts = 0;
const fetcher = async (url: string | URL, init?: { method?: string; headers?: Record<string, string>; body?: unknown }) => {
const value = String(url);
calls.push({ url: value, init });
if (value.includes('/rupload_igphoto/') && value.includes('fb_uploader_701')) {
return new Response('{"upload_id":"701","status":"ok"}', { status: 200 });
}
if (value.includes('/rupload_igvideo/') && value.includes('fb_uploader_702')) {
return new Response('{"media_id":17944674009157009,"status":"ok"}', { status: 200 });
}
if (value.includes('/rupload_igphoto/') && value.includes('fb_uploader_702')) {
return new Response('{"upload_id":"702","status":"ok"}', { status: 200 });
}
configureAttempts += 1;
if (configureAttempts === 1) {
return new Response('{"message":"Transcode not finished yet.","status":"fail"}', { status: 202 });
}
return new Response('{"status":"ok","media":{"code":"MIXEDSIDE123"}}', { status: 200 });
};
const response = await publishMediaViaPrivateApi({
page: {} as never,
mediaItems: [
{ type: 'image', filePath: image },
{ type: 'video', filePath: video },
],
caption: 'mixed private carousel',
apiContext: {
asbdId: '359341',
csrfToken: 'csrf-token',
igAppId: '936619743392459',
igWwwClaim: 'hmac.claim',
instagramAjax: '1036517563',
webSessionId: 'abc:def:ghi',
},
jazoest: '22047',
now: () => 700,
fetcher,
prepareMediaAsset: async (item) => {
if (item.type === 'image') {
return {
type: 'image' as const,
asset: readImageAsset(item.filePath),
};
}
return {
type: 'video' as const,
asset: {
filePath: item.filePath,
fileName: 'mixed-private-video.mp4',
mimeType: 'video/mp4',
width: 640,
height: 360,
durationMs: 28245,
byteLength: 12,
bytes: Buffer.from('video-binary'),
coverImage: {
filePath: '/tmp/mixed-private-cover.jpg',
fileName: 'mixed-private-cover.jpg',
mimeType: 'image/jpeg',
width: 640,
height: 360,
byteLength: coverBytes.length,
bytes: coverBytes,
},
},
};
},
waitMs: async () => undefined,
});
expect(calls).toHaveLength(5);
expect(calls[0]?.url).toContain('/rupload_igphoto/fb_uploader_701');
expect(calls[1]?.url).toContain('/rupload_igvideo/fb_uploader_702');
expect(calls[2]?.url).toContain('/rupload_igphoto/fb_uploader_702');
expect(JSON.parse(String(calls[1]?.init?.headers?.['X-Instagram-Rupload-Params'] || '{}'))).toMatchObject({
media_type: 2,
upload_id: '702',
upload_media_width: 640,
upload_media_height: 360,
upload_media_duration_ms: 28245,
video_edit_params: {
crop_width: 360,
crop_height: 360,
crop_x1: 140,
crop_y1: 0,
trim_start: 0,
trim_end: 28.245,
mute: false,
},
});
expect(JSON.parse(String(calls[2]?.init?.headers?.['X-Instagram-Rupload-Params'] || '{}'))).toMatchObject({
media_type: 2,
upload_id: '702',
upload_media_width: 640,
upload_media_height: 360,
});
expect(JSON.parse(String(calls[3]?.init?.body || '{}'))).toMatchObject({
caption: 'mixed private carousel',
client_sidecar_id: '700',
children_metadata: [{ upload_id: '701' }, { upload_id: '702' }],
});
expect(response).toEqual({ code: 'MIXEDSIDE123', uploadIds: ['701', '702'] });
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,152 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
const TRACE_OUTPUT_PATH = path.join(os.tmpdir(), 'instagram_post_protocol_trace.json');
import type { BrowserCookie, IPage } from '../../../types.js';
import {
buildInstallInstagramProtocolCaptureJs,
buildReadInstagramProtocolCaptureJs,
dumpInstagramProtocolCaptureIfEnabled,
instagramPrivateApiFetch,
installInstagramProtocolCapture,
readInstagramProtocolCapture,
} from './protocol-capture.js';
describe('instagram protocol capture helpers', () => {
afterEach(() => {
vi.restoreAllMocks();
delete process.env.OPENCLI_INSTAGRAM_CAPTURE;
try { fs.rmSync(TRACE_OUTPUT_PATH, { force: true }); } catch {}
});
it('installs the protocol capture patch in page context', async () => {
const evaluate = vi.fn().mockResolvedValue({ ok: true });
const page = { evaluate } as unknown as IPage;
await installInstagramProtocolCapture(page);
expect(evaluate).toHaveBeenCalledTimes(1);
expect(String(evaluate.mock.calls[0]?.[0] || '')).toContain('__opencli_ig_protocol_capture');
expect(String(evaluate.mock.calls[0]?.[0] || '')).toContain('/media/configure_sidecar/');
});
it('prefers native page network capture when available', async () => {
const startNetworkCapture = vi.fn().mockResolvedValue(undefined);
const evaluate = vi.fn();
const page = { startNetworkCapture, evaluate } as unknown as IPage;
await installInstagramProtocolCapture(page);
expect(startNetworkCapture).toHaveBeenCalledTimes(1);
expect(evaluate).not.toHaveBeenCalled();
});
it('reads and normalizes captured protocol entries', async () => {
const evaluate = vi.fn().mockResolvedValue({
data: [{ kind: 'fetch', url: 'https://www.instagram.com/api/v1/media/configure/' }],
errors: ['ignored'],
});
const page = { evaluate } as unknown as IPage;
const result = await readInstagramProtocolCapture(page);
expect(String(evaluate.mock.calls[0]?.[0] || '')).toContain('__opencli_ig_protocol_capture');
expect(result).toEqual({
data: [{ kind: 'fetch', url: 'https://www.instagram.com/api/v1/media/configure/' }],
errors: ['ignored'],
});
});
it('prefers native page network capture reads when available', async () => {
const readNetworkCapture = vi.fn().mockResolvedValue([
{ kind: 'cdp', url: 'https://www.instagram.com/rupload_igphoto/test', method: 'POST' },
]);
const evaluate = vi.fn();
const page = { readNetworkCapture, evaluate } as unknown as IPage;
const result = await readInstagramProtocolCapture(page);
expect(readNetworkCapture).toHaveBeenCalledTimes(1);
expect(evaluate).not.toHaveBeenCalled();
expect(result).toEqual({
data: [{ kind: 'cdp', url: 'https://www.instagram.com/rupload_igphoto/test', method: 'POST' }],
errors: [],
});
});
it('dumps protocol traces to /tmp only when capture env is enabled', async () => {
process.env.OPENCLI_INSTAGRAM_CAPTURE = '1';
const page = {
evaluate: vi.fn().mockResolvedValue({
data: [{ kind: 'fetch', url: 'https://www.instagram.com/rupload_igphoto/test' }],
errors: [],
}),
} as unknown as IPage;
await dumpInstagramProtocolCaptureIfEnabled(page);
const raw = fs.readFileSync(TRACE_OUTPUT_PATH, 'utf8');
expect(raw).toContain('rupload_igphoto');
});
it('does not dump protocol traces when capture env is disabled', async () => {
const page = {
evaluate: vi.fn(),
} as unknown as IPage;
await dumpInstagramProtocolCaptureIfEnabled(page);
expect(page.evaluate).not.toHaveBeenCalled();
expect(fs.existsSync(TRACE_OUTPUT_PATH)).toBe(false);
});
});
describe('instagram private api fetch', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('uses browser cookies to build instagram private api requests', async () => {
const getCookies = vi.fn()
.mockResolvedValueOnce([{ name: 'sessionid', value: 'sess', domain: '.instagram.com' } satisfies BrowserCookie])
.mockResolvedValueOnce([
{ name: 'csrftoken', value: 'csrf', domain: '.instagram.com' } satisfies BrowserCookie,
{ name: 'sessionid', value: 'sess', domain: '.instagram.com' } satisfies BrowserCookie,
]);
const evaluate = vi.fn().mockResolvedValue({
appId: 'dynamic-app-id',
csrfToken: 'csrf',
instagramAjax: 'dynamic-rollout',
});
const page = { getCookies, evaluate } as unknown as IPage;
const fetchMock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
await instagramPrivateApiFetch(page, 'https://www.instagram.com/api/v1/media/configure/', {
method: 'POST',
body: 'caption=test',
});
expect(fetchMock).toHaveBeenCalledWith(
'https://www.instagram.com/api/v1/media/configure/',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'X-CSRFToken': 'csrf',
'X-IG-App-ID': 'dynamic-app-id',
'Cookie': expect.stringContaining('sessionid=sess'),
}),
body: 'caption=test',
}),
);
});
it('exposes stable browser-side JS builders', () => {
expect(buildInstallInstagramProtocolCaptureJs()).toContain('/rupload_igphoto/');
expect(buildReadInstagramProtocolCaptureJs()).toContain('__opencli_ig_protocol_capture');
});
});
@@ -0,0 +1,323 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import type { BrowserCookie, IPage } from '../../../types.js';
import { resolveInstagramRuntimeInfo } from './runtime-info.js';
const DEFAULT_CAPTURE_VAR = '__opencli_ig_protocol_capture';
const DEFAULT_CAPTURE_ERRORS_VAR = '__opencli_ig_protocol_capture_errors';
const TRACE_OUTPUT_PATH = path.join(os.tmpdir(), 'instagram_post_protocol_trace.json');
const INSTAGRAM_PROTOCOL_CAPTURE_PATTERN = [
'/rupload_igphoto/',
'/rupload_igvideo/',
'/api/v1/',
'/media/configure/',
'/media/configure_sidecar/',
'/media/configure_to_story/',
'/api/graphql/',
].join('|');
export interface InstagramProtocolCaptureEntry {
kind: 'fetch' | 'xhr';
url: string;
method: string;
requestHeaders?: Record<string, string>;
requestBodyKind?: string;
requestBodyPreview?: string;
responseStatus?: number;
responseContentType?: string;
responsePreview?: string;
timestamp: number;
}
export function buildInstallInstagramProtocolCaptureJs(
captureVar: string = DEFAULT_CAPTURE_VAR,
captureErrorsVar: string = DEFAULT_CAPTURE_ERRORS_VAR,
): string {
return `
(() => {
const CAPTURE_VAR = ${JSON.stringify(captureVar)};
const CAPTURE_ERRORS_VAR = ${JSON.stringify(captureErrorsVar)};
const PATCH_GUARD = CAPTURE_VAR + '_patched';
const FILTERS = [
'/rupload_igphoto/',
'/rupload_igvideo/',
'/api/v1/',
'/media/configure/',
'/media/configure_sidecar/',
'/media/configure_to_story/',
'/api/graphql/',
];
const shouldCapture = (url) => {
const value = String(url || '');
return FILTERS.some((filter) => value.includes(filter));
};
const normalizeHeaders = (headersLike) => {
const out = {};
try {
if (!headersLike) return out;
if (headersLike instanceof Headers) {
headersLike.forEach((value, key) => { out[key] = value; });
return out;
}
if (Array.isArray(headersLike)) {
for (const pair of headersLike) {
if (Array.isArray(pair) && pair.length >= 2) out[String(pair[0])] = String(pair[1]);
}
return out;
}
if (typeof headersLike === 'object') {
for (const [key, value] of Object.entries(headersLike)) out[key] = String(value);
}
} catch {}
return out;
};
const summarizeBody = async (body) => {
if (body == null) return { kind: 'empty', preview: '' };
try {
if (typeof body === 'string') {
return { kind: 'string', preview: body.slice(0, 1000) };
}
if (body instanceof URLSearchParams) {
return { kind: 'urlencoded', preview: body.toString().slice(0, 1000) };
}
if (body instanceof FormData) {
const parts = [];
for (const [key, value] of body.entries()) {
if (value instanceof File) {
parts.push(key + '=File(' + value.name + ',' + value.type + ',' + value.size + ')');
} else {
parts.push(key + '=' + String(value));
}
}
return { kind: 'formdata', preview: parts.join('&').slice(0, 2000) };
}
if (body instanceof Blob) {
return { kind: 'blob', preview: 'Blob(' + body.type + ',' + body.size + ')' };
}
if (body instanceof ArrayBuffer) {
return { kind: 'arraybuffer', preview: 'ArrayBuffer(' + body.byteLength + ')' };
}
if (ArrayBuffer.isView(body)) {
return { kind: 'typed-array', preview: body.constructor.name + '(' + body.byteLength + ')' };
}
return { kind: typeof body, preview: String(body).slice(0, 1000) };
} catch (error) {
return { kind: 'unknown', preview: 'body-preview-error:' + String(error) };
}
};
const capture = async (kind, url, method, headers, body, response) => {
if (!shouldCapture(url)) return;
try {
const bodyInfo = await summarizeBody(body);
const contentType = response?.headers?.get?.('content-type') || '';
let responsePreview = '';
try {
if (response && typeof response.clone === 'function') {
const clone = response.clone();
responsePreview = (await clone.text()).slice(0, 4000);
}
} catch (error) {
responsePreview = 'response-preview-error:' + String(error);
}
window[CAPTURE_VAR].push({
kind,
url: String(url || ''),
method: String(method || 'GET').toUpperCase(),
requestHeaders: normalizeHeaders(headers),
requestBodyKind: bodyInfo.kind,
requestBodyPreview: bodyInfo.preview,
responseStatus: response?.status,
responseContentType: contentType,
responsePreview,
timestamp: Date.now(),
});
} catch (error) {
window[CAPTURE_ERRORS_VAR].push(String(error));
}
};
if (!Array.isArray(window[CAPTURE_VAR])) window[CAPTURE_VAR] = [];
if (!Array.isArray(window[CAPTURE_ERRORS_VAR])) window[CAPTURE_ERRORS_VAR] = [];
if (window[PATCH_GUARD]) return { ok: true };
const origFetch = window.fetch;
window.fetch = async function(...args) {
const input = args[0];
const init = args[1] || {};
const url = typeof input === 'string'
? input
: input instanceof Request
? input.url
: String(input || '');
const method = init.method || (input instanceof Request ? input.method : 'GET');
const headers = init.headers || (input instanceof Request ? input.headers : undefined);
const body = init.body || (input instanceof Request ? input.body : undefined);
const response = await origFetch.apply(this, args);
capture('fetch', url, method, headers, body, response);
return response;
};
const origOpen = XMLHttpRequest.prototype.open;
const origSend = XMLHttpRequest.prototype.send;
const origSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
XMLHttpRequest.prototype.open = function(method, url) {
this.__opencli_method = method;
this.__opencli_url = url;
this.__opencli_headers = {};
return origOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.setRequestHeader = function(name, value) {
try {
this.__opencli_headers = this.__opencli_headers || {};
this.__opencli_headers[String(name)] = String(value);
} catch {}
return origSetRequestHeader.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function(body) {
this.addEventListener('load', () => {
if (!shouldCapture(this.__opencli_url)) return;
try {
window[CAPTURE_VAR].push({
kind: 'xhr',
url: String(this.__opencli_url || ''),
method: String(this.__opencli_method || 'GET').toUpperCase(),
requestHeaders: this.__opencli_headers || {},
requestBodyKind: body == null ? 'empty' : (body instanceof FormData ? 'formdata' : typeof body),
requestBodyPreview: body == null ? '' : (body instanceof FormData ? '[formdata]' : String(body).slice(0, 2000)),
responseStatus: this.status,
responseContentType: this.getResponseHeader('content-type') || '',
responsePreview: String(this.responseText || '').slice(0, 4000),
timestamp: Date.now(),
});
} catch (error) {
window[CAPTURE_ERRORS_VAR].push(String(error));
}
});
return origSend.apply(this, arguments);
};
window[PATCH_GUARD] = true;
return { ok: true };
})()
`;
}
export function buildReadInstagramProtocolCaptureJs(
captureVar: string = DEFAULT_CAPTURE_VAR,
captureErrorsVar: string = DEFAULT_CAPTURE_ERRORS_VAR,
): string {
return `
(() => {
const data = Array.isArray(window[${JSON.stringify(captureVar)}]) ? window[${JSON.stringify(captureVar)}] : [];
const errors = Array.isArray(window[${JSON.stringify(captureErrorsVar)}]) ? window[${JSON.stringify(captureErrorsVar)}] : [];
window[${JSON.stringify(captureVar)}] = [];
window[${JSON.stringify(captureErrorsVar)}] = [];
return { data, errors };
})()
`;
}
export async function installInstagramProtocolCapture(page: IPage): Promise<void> {
if (typeof page.startNetworkCapture === 'function') {
try {
await page.startNetworkCapture(INSTAGRAM_PROTOCOL_CAPTURE_PATTERN);
return;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!message.includes('Unknown action') && !message.includes('network-capture')) {
throw error;
}
}
}
await page.evaluate(buildInstallInstagramProtocolCaptureJs());
}
export async function readInstagramProtocolCapture(page: IPage): Promise<{
data: InstagramProtocolCaptureEntry[];
errors: string[];
}> {
if (typeof page.readNetworkCapture === 'function') {
try {
const data = await page.readNetworkCapture();
return {
data: Array.isArray(data) ? data as InstagramProtocolCaptureEntry[] : [],
errors: [],
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!message.includes('Unknown action') && !message.includes('network-capture')) {
throw error;
}
}
}
const result = await page.evaluate(buildReadInstagramProtocolCaptureJs()) as {
data?: InstagramProtocolCaptureEntry[];
errors?: string[];
};
return {
data: Array.isArray(result?.data) ? result.data : [],
errors: Array.isArray(result?.errors) ? result.errors : [],
};
}
export async function dumpInstagramProtocolCaptureIfEnabled(page: IPage): Promise<void> {
if (process.env.OPENCLI_INSTAGRAM_CAPTURE !== '1') return;
const payload = await readInstagramProtocolCapture(page);
fs.writeFileSync(TRACE_OUTPUT_PATH, JSON.stringify(payload, null, 2));
}
function buildCookieHeader(cookies: BrowserCookie[]): string {
return cookies
.filter((cookie) => cookie?.name && cookie?.value)
.map((cookie) => `${cookie.name}=${cookie.value}`)
.join('; ');
}
export async function instagramPrivateApiFetch(
page: IPage,
input: string | URL,
init: {
method?: 'GET' | 'POST';
headers?: Record<string, string>;
body?: unknown;
} = {},
): Promise<Response> {
const url = String(input);
const [urlCookies, domainCookies] = await Promise.all([
page.getCookies({ url }),
page.getCookies({ domain: 'instagram.com' }),
]);
const merged = new Map<string, BrowserCookie>();
for (const cookie of domainCookies) merged.set(cookie.name, cookie);
for (const cookie of urlCookies) merged.set(cookie.name, cookie);
const cookieHeader = buildCookieHeader(Array.from(merged.values()));
const csrf = merged.get('csrftoken')?.value || '';
const initHeaders = init.headers ?? {};
const requestedAppIdHeader = Object.entries(initHeaders).find(([key]) => key.toLowerCase() === 'x-ig-app-id')?.[1] || '';
const runtimeInfo = requestedAppIdHeader ? null : await resolveInstagramRuntimeInfo(page);
const appId = requestedAppIdHeader || runtimeInfo?.appId || '';
const hasContentType = Object.keys(init.headers ?? {}).some((key) => key.toLowerCase() === 'content-type');
return fetch(url, {
method: init.method ?? 'GET',
headers: {
'Accept': 'application/json, text/plain, */*',
'X-CSRFToken': csrf,
'X-Requested-With': 'XMLHttpRequest',
'Origin': 'https://www.instagram.com',
'Referer': 'https://www.instagram.com/',
...(appId ? { 'X-IG-App-ID': appId } : {}),
...(cookieHeader ? { 'Cookie': cookieHeader } : {}),
...(typeof init.body === 'string' && !hasContentType ? { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' } : {}),
...initHeaders,
},
...(init.body !== undefined ? { body: init.body as BodyInit } : {}),
});
}
@@ -0,0 +1,91 @@
import type { BrowserCookie, IPage } from '../../../types.js';
export interface InstagramRuntimeInfo {
appId: string;
csrfToken: string;
instagramAjax: string;
}
function pickMatch(input: string, patterns: RegExp[]): string {
for (const pattern of patterns) {
const match = input.match(pattern);
if (!match) continue;
for (let index = 1; index < match.length; index += 1) {
if (match[index]) return match[index]!;
}
return match[0] || '';
}
return '';
}
export function extractInstagramRuntimeInfo(html: string): InstagramRuntimeInfo {
return {
appId: pickMatch(html, [
/"X-IG-App-ID":"(\d+)"/,
/"appId":"(\d+)"/,
/"app_id":"(\d+)"/,
/"instagramWebAppId":"(\d+)"/,
]),
csrfToken: pickMatch(html, [
/"csrf_token":"([^"]+)"/,
/"csrfToken":"([^"]+)"/,
]),
instagramAjax: pickMatch(html, [
/"rollout_hash":"([^"]+)"/,
/"X-Instagram-AJAX":"([^"]+)"/,
/"Instagram-AJAX":"([^"]+)"/,
]),
};
}
export function buildReadInstagramRuntimeInfoJs(): string {
return `
(() => {
const html = document.documentElement?.outerHTML || '';
const pick = (patterns) => {
for (const pattern of patterns) {
const match = html.match(new RegExp(pattern, 'i'));
if (!match) continue;
for (let index = 1; index < match.length; index += 1) {
if (match[index]) return match[index];
}
return match[0] || '';
}
return '';
};
return {
appId: pick([
'"X-IG-App-ID":"(\\\\d+)"',
'"appId":"(\\\\d+)"',
'"app_id":"(\\\\d+)"',
'"instagramWebAppId":"(\\\\d+)"',
]),
csrfToken: pick([
'"csrf_token":"([^"]+)"',
'"csrfToken":"([^"]+)"',
]),
instagramAjax: pick([
'"rollout_hash":"([^"]+)"',
'"X-Instagram-AJAX":"([^"]+)"',
'"Instagram-AJAX":"([^"]+)"',
]),
};
})()
`;
}
function getCookieValue(cookies: BrowserCookie[], name: string): string {
return cookies.find((cookie) => cookie.name === name)?.value || '';
}
export async function resolveInstagramRuntimeInfo(page: IPage): Promise<InstagramRuntimeInfo> {
const [runtime, cookies] = await Promise.all([
page.evaluate(buildReadInstagramRuntimeInfoJs()) as Promise<InstagramRuntimeInfo>,
page.getCookies({ domain: 'instagram.com' }),
]);
return {
appId: runtime?.appId || '',
csrfToken: runtime?.csrfToken || getCookieValue(cookies, 'csrftoken') || '',
instagramAjax: runtime?.instagramAjax || '',
};
}
+96
View File
@@ -0,0 +1,96 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ArgumentError } from '../../errors.js';
import { getRegistry } from '../../registry.js';
import type { IPage } from '../../types.js';
import './note.js';
function createPageMock(): IPage {
return {
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue(undefined),
getCookies: vi.fn().mockResolvedValue([]),
snapshot: vi.fn().mockResolvedValue(undefined),
click: vi.fn().mockResolvedValue(undefined),
typeText: vi.fn().mockResolvedValue(undefined),
pressKey: vi.fn().mockResolvedValue(undefined),
scrollTo: vi.fn().mockResolvedValue(undefined),
getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
wait: vi.fn().mockResolvedValue(undefined),
tabs: vi.fn().mockResolvedValue([]),
closeTab: vi.fn().mockResolvedValue(undefined),
newTab: vi.fn().mockResolvedValue(undefined),
selectTab: vi.fn().mockResolvedValue(undefined),
networkRequests: vi.fn().mockResolvedValue([]),
consoleMessages: vi.fn().mockResolvedValue([]),
scroll: vi.fn().mockResolvedValue(undefined),
autoScroll: vi.fn().mockResolvedValue(undefined),
installInterceptor: vi.fn().mockResolvedValue(undefined),
getInterceptedRequests: vi.fn().mockResolvedValue([]),
waitForCapture: vi.fn().mockResolvedValue(undefined),
screenshot: vi.fn().mockResolvedValue(''),
setFileInput: vi.fn().mockResolvedValue(undefined),
insertText: vi.fn().mockResolvedValue(undefined),
getCurrentUrl: vi.fn().mockResolvedValue(null),
};
}
describe('instagram note registration', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('registers the note command with a required positional content arg', () => {
const cmd = getRegistry().get('instagram/note');
expect(cmd).toBeDefined();
expect(cmd?.browser).toBe(true);
expect(cmd?.args.some((arg) => arg.name === 'content' && arg.positional && arg.required)).toBe(true);
});
it('rejects missing note content before browser work', async () => {
const page = createPageMock();
const cmd = getRegistry().get('instagram/note');
await expect(cmd!.func!(page, {})).rejects.toThrow(ArgumentError);
expect(page.goto).not.toHaveBeenCalled();
});
it('rejects blank note content before browser work', async () => {
const page = createPageMock();
const cmd = getRegistry().get('instagram/note');
await expect(cmd!.func!(page, { content: ' ' })).rejects.toThrow(ArgumentError);
expect(page.goto).not.toHaveBeenCalled();
});
it('rejects note content longer than 60 characters before browser work', async () => {
const page = createPageMock();
const cmd = getRegistry().get('instagram/note');
await expect(cmd!.func!(page, { content: 'x'.repeat(61) })).rejects.toThrow(ArgumentError);
expect(page.goto).not.toHaveBeenCalled();
});
it('publishes a note through the web inbox mutation', async () => {
const page = createPageMock();
const cmd = getRegistry().get('instagram/note');
vi.mocked(page.evaluate).mockResolvedValue({
ok: true,
noteId: '17849203563031468',
});
const rows = await cmd!.func!(page, { content: 'hello note' }) as Array<Record<string, string>>;
expect(page.goto).toHaveBeenCalledWith('https://www.instagram.com/direct/inbox/');
expect(page.evaluate).toHaveBeenCalledTimes(1);
expect(rows).toEqual([{
status: '✅ Posted',
detail: 'Instagram note published successfully',
noteId: '17849203563031468',
}]);
});
});
+254
View File
@@ -0,0 +1,254 @@
import { ArgumentError, CommandExecutionError } from '../../errors.js';
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
type InstagramNoteSuccessRow = {
status: string;
detail: string;
noteId: string;
};
type BrowserNoteResult = {
ok?: boolean;
stage?: string;
status?: number;
text?: string;
noteId?: string;
};
const INSTAGRAM_INBOX_URL = 'https://www.instagram.com/direct/inbox/';
const INSTAGRAM_NOTE_DOC_ID = '25155183657506484';
const INSTAGRAM_NOTE_MUTATION_NAME = 'usePolarisCreateInboxTrayItemSubmitMutation';
const INSTAGRAM_NOTE_ROOT_FIELD = 'xdt_create_inbox_tray_item';
function requirePage(page: IPage | null): IPage {
if (!page) throw new CommandExecutionError('Browser session required for instagram note');
return page;
}
function validateInstagramNoteArgs(kwargs: Record<string, unknown>): void {
if (kwargs.content === undefined) {
throw new ArgumentError(
'Argument "content" is required.',
'Provide a note text, for example: opencli instagram note "hello"',
);
}
}
function normalizeInstagramNoteContent(kwargs: Record<string, unknown>): string {
const content = String(kwargs.content ?? '').trim();
if (!content) {
throw new ArgumentError(
'Instagram note content cannot be empty.',
'Provide a non-empty note text, for example: opencli instagram note "hello"',
);
}
if (Array.from(content).length > 60) {
throw new ArgumentError(
'Instagram note content must be 60 characters or fewer.',
'Shorten the note text and try again.',
);
}
return content;
}
function buildNoteSuccessResult(noteId: string): InstagramNoteSuccessRow[] {
return [{
status: '✅ Posted',
detail: 'Instagram note published successfully',
noteId,
}];
}
function buildPublishInstagramNoteJs(content: string): string {
return `
(async () => {
const input = ${JSON.stringify({ content })};
const html = document.documentElement?.outerHTML || '';
const scripts = Array.from(document.scripts || [])
.map((script) => script.textContent || '')
.join('\\n');
const source = html + '\\n' + scripts;
const pick = (patterns) => {
for (const pattern of patterns) {
const match = source.match(pattern);
if (!match) continue;
for (let index = 1; index < match.length; index += 1) {
if (match[index]) return match[index];
}
return match[0] || '';
}
return '';
};
const readCookie = (name) => {
const prefix = name + '=';
const part = document.cookie
.split('; ')
.find((cookie) => cookie.startsWith(prefix));
return part ? decodeURIComponent(part.slice(prefix.length)) : '';
};
const actorId = pick([
/"actorID":"(\\d+)"/,
/"actor_id":"(\\d+)"/,
/"viewerId":"(\\d+)"/,
]);
const fbDtsg = pick([
/(NAF[a-zA-Z0-9:_-]{20,})/,
/(NAf[a-zA-Z0-9:_-]{20,})/,
]);
const lsd = pick([
/"LSD",\\[\\],\\{"token":"([^"]+)"\\}/,
/"lsd":"([^"]+)"/,
]);
const appId = pick([
/"X-IG-App-ID":"(\\d+)"/,
/"instagramWebAppId":"(\\d+)"/,
/"appId":"(\\d+)"/,
]);
const asbdId = pick([
/"X-ASBD-ID":"(\\d+)"/,
/"asbd_id":"(\\d+)"/,
]);
const spinR = pick([/"__spin_r":(\\d+)/]);
const spinB = pick([/"__spin_b":"([^"]+)"/]);
const spinT = pick([/"__spin_t":(\\d+)/]);
const csrfToken = readCookie('csrftoken') || pick([
/"csrf_token":"([^"]+)"/,
/"csrfToken":"([^"]+)"/,
]);
const jazoest = fbDtsg
? '2' + Array.from(fbDtsg).reduce((total, char) => total + char.charCodeAt(0), 0)
: '';
if (!actorId || !fbDtsg || !lsd || !appId || !csrfToken || !spinR || !spinB || !spinT || !jazoest) {
return {
ok: false,
stage: 'config',
text: JSON.stringify({
actorId: Boolean(actorId),
fbDtsg: Boolean(fbDtsg),
lsd: Boolean(lsd),
appId: Boolean(appId),
csrfToken: Boolean(csrfToken),
spinR: Boolean(spinR),
spinB: Boolean(spinB),
spinT: Boolean(spinT),
jazoest: Boolean(jazoest),
}),
};
}
const variables = {
input: {
actor_id: actorId,
client_mutation_id: '1',
additional_params: {
note_create_params: {
note_style: 0,
text: input.content,
},
},
audience: 0,
inbox_tray_item_type: 'note',
},
};
const body = new URLSearchParams();
body.set('av', actorId);
body.set('__user', '0');
body.set('__a', '1');
body.set('__req', '1');
body.set('__hs', '');
body.set('dpr', String(window.devicePixelRatio || 1));
body.set('__ccg', 'UNKNOWN');
body.set('__rev', spinR);
body.set('__s', '');
body.set('__hsi', '');
body.set('__dyn', '');
body.set('__csr', '');
body.set('__comet_req', '7');
body.set('fb_dtsg', fbDtsg);
body.set('jazoest', jazoest);
body.set('lsd', lsd);
body.set('__spin_r', spinR);
body.set('__spin_b', spinB);
body.set('__spin_t', spinT);
body.set('fb_api_caller_class', 'RelayModern');
body.set('fb_api_req_friendly_name', ${JSON.stringify(INSTAGRAM_NOTE_MUTATION_NAME)});
body.set('variables', JSON.stringify(variables));
body.set('server_timestamps', 'true');
body.set('doc_id', ${JSON.stringify(INSTAGRAM_NOTE_DOC_ID)});
const headers = {
Accept: '*/*',
'Content-Type': 'application/x-www-form-urlencoded',
'X-ASBD-ID': asbdId || undefined,
'X-CSRFToken': csrfToken,
'X-FB-Friendly-Name': ${JSON.stringify(INSTAGRAM_NOTE_MUTATION_NAME)},
'X-FB-LSD': lsd,
'X-IG-App-ID': appId,
'X-Root-Field-Name': ${JSON.stringify(INSTAGRAM_NOTE_ROOT_FIELD)},
};
const response = await fetch('/graphql/query', {
method: 'POST',
credentials: 'include',
headers,
body: body.toString(),
});
const text = await response.text();
const normalizedText = text.replace(/^for \\(;;\\);?/, '').trim();
let data = null;
try {
data = JSON.parse(normalizedText);
} catch {}
const rootField = ${JSON.stringify(INSTAGRAM_NOTE_ROOT_FIELD)};
const note = data?.data?.[rootField]?.inbox_tray_item;
const noteId = String(note?.inbox_tray_item_id || note?.id || '');
if (response.ok && noteId) {
return {
ok: true,
stage: 'publish',
noteId,
text: String(note?.note_dict?.text || input.content || ''),
};
}
return {
ok: false,
stage: 'publish',
status: response.status,
text: normalizedText || text,
};
})()
`;
}
cli({
site: 'instagram',
name: 'note',
description: 'Publish a text Instagram note',
domain: 'www.instagram.com',
strategy: Strategy.UI,
browser: true,
timeoutSeconds: 120,
args: [
{ name: 'content', positional: true, required: true, help: 'Note text (max 60 characters)' },
],
columns: ['status', 'detail', 'noteId'],
validateArgs: validateInstagramNoteArgs,
func: async (page: IPage | null, kwargs) => {
const browserPage = requirePage(page);
const content = normalizeInstagramNoteContent(kwargs as Record<string, unknown>);
await browserPage.goto(INSTAGRAM_INBOX_URL);
await browserPage.wait({ time: 2 });
const result = await browserPage.evaluate(buildPublishInstagramNoteJs(content)) as BrowserNoteResult;
if (!result?.ok) {
throw new CommandExecutionError(
`Instagram note publish failed at ${String(result?.stage || 'unknown')}: ${String(result?.text || 'unknown error')}`,
);
}
return buildNoteSuccessResult(String(result.noteId || ''));
},
});
+567
View File
@@ -0,0 +1,567 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CommandExecutionError } from '../../errors.js';
import { getRegistry } from '../../registry.js';
import type { IPage } from '../../types.js';
import * as privatePublish from './_shared/private-publish.js';
import { buildClickActionJs, buildEnsureComposerOpenJs, buildInspectUploadStageJs, buildPublishStatusProbeJs } from './post.js';
import './post.js';
const tempDirs: string[] = [];
function createTempImage(name = 'demo.jpg', bytes = Buffer.from([0xff, 0xd8, 0xff, 0xd9])): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-instagram-post-'));
tempDirs.push(dir);
const filePath = path.join(dir, name);
fs.writeFileSync(filePath, bytes);
return filePath;
}
function createTempVideo(name = 'demo.mp4', bytes = Buffer.from('video')): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-instagram-post-video-'));
tempDirs.push(dir);
const filePath = path.join(dir, name);
fs.writeFileSync(filePath, bytes);
return filePath;
}
function createPageMock(evaluateResults: unknown[], overrides: Partial<IPage> = {}): IPage {
const evaluate = vi.fn();
for (const result of evaluateResults) {
evaluate.mockResolvedValueOnce(result);
}
return {
goto: vi.fn().mockResolvedValue(undefined),
evaluate,
getCookies: vi.fn().mockResolvedValue([]),
snapshot: vi.fn().mockResolvedValue(undefined),
click: vi.fn().mockResolvedValue(undefined),
typeText: vi.fn().mockResolvedValue(undefined),
pressKey: vi.fn().mockResolvedValue(undefined),
scrollTo: vi.fn().mockResolvedValue(undefined),
getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
wait: vi.fn().mockResolvedValue(undefined),
tabs: vi.fn().mockResolvedValue([]),
closeTab: vi.fn().mockResolvedValue(undefined),
newTab: vi.fn().mockResolvedValue(undefined),
selectTab: vi.fn().mockResolvedValue(undefined),
networkRequests: vi.fn().mockResolvedValue([]),
consoleMessages: vi.fn().mockResolvedValue([]),
scroll: vi.fn().mockResolvedValue(undefined),
autoScroll: vi.fn().mockResolvedValue(undefined),
installInterceptor: vi.fn().mockResolvedValue(undefined),
getInterceptedRequests: vi.fn().mockResolvedValue([]),
waitForCapture: vi.fn().mockResolvedValue(undefined),
screenshot: vi.fn().mockResolvedValue(''),
setFileInput: vi.fn().mockResolvedValue(undefined),
insertText: undefined,
getCurrentUrl: vi.fn().mockResolvedValue(null),
...overrides,
};
}
afterAll(() => {
for (const dir of tempDirs) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe('instagram auth detection', () => {
it('does not treat generic homepage text containing "log in" as an auth failure', () => {
const globalState = globalThis as typeof globalThis & {
document?: unknown;
window?: unknown;
};
const originalDocument = globalState.document;
const originalWindow = globalState.window;
globalState.document = {
body: { innerText: 'Suggested for you Log in to see more content' },
querySelector: () => null,
querySelectorAll: () => [],
} as unknown as Document;
globalState.window = { location: { pathname: '/' } } as unknown as Window & typeof globalThis;
try {
expect(eval(buildEnsureComposerOpenJs()) as { ok: boolean; reason?: string }).toEqual({ ok: true });
} finally {
globalState.document = originalDocument;
globalState.window = originalWindow;
}
});
});
describe('instagram publish status detection', () => {
it('does not treat unrelated page text as share failure while the sharing dialog is still visible', () => {
const globalState = globalThis as typeof globalThis & {
document?: unknown;
window?: unknown;
HTMLElement?: unknown;
};
class MockHTMLElement {}
const visibleDialog = new MockHTMLElement() as MockHTMLElement & {
textContent: string;
querySelector: () => null;
getBoundingClientRect: () => { width: number; height: number };
};
visibleDialog.textContent = 'Sharing';
visibleDialog.querySelector = () => null;
visibleDialog.getBoundingClientRect = () => ({ width: 100, height: 100 });
const originalDocument = globalState.document;
const originalWindow = globalState.window;
const originalHTMLElement = globalState.HTMLElement;
globalState.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement;
globalState.document = {
querySelectorAll: (selector: string) => selector === '[role="dialog"]' ? [visibleDialog] : [],
} as unknown as Document;
globalState.window = {
location: { href: 'https://www.instagram.com/' },
getComputedStyle: () => ({ display: 'block', visibility: 'visible' }),
} as unknown as Window & typeof globalThis;
try {
expect(eval(buildPublishStatusProbeJs()) as { failed?: boolean; settled?: boolean; ok?: boolean }).toEqual({
ok: false,
failed: false,
settled: false,
url: '',
});
} finally {
globalState.document = originalDocument;
globalState.window = originalWindow;
globalState.HTMLElement = originalHTMLElement;
}
});
it('does not treat a stale visible error dialog as share failure while sharing is still in progress', () => {
const globalState = globalThis as typeof globalThis & {
document?: unknown;
window?: unknown;
HTMLElement?: unknown;
};
class MockHTMLElement {}
const sharingDialog = new MockHTMLElement() as MockHTMLElement & {
textContent: string;
querySelector: () => null;
getBoundingClientRect: () => { width: number; height: number };
};
sharingDialog.textContent = 'Sharing';
sharingDialog.querySelector = () => null;
sharingDialog.getBoundingClientRect = () => ({ width: 100, height: 100 });
const staleErrorDialog = new MockHTMLElement() as MockHTMLElement & {
textContent: string;
querySelector: () => null;
getBoundingClientRect: () => { width: number; height: number };
};
staleErrorDialog.textContent = 'Something went wrong. Please try again. Try again';
staleErrorDialog.querySelector = () => null;
staleErrorDialog.getBoundingClientRect = () => ({ width: 100, height: 100 });
const originalDocument = globalState.document;
const originalWindow = globalState.window;
const originalHTMLElement = globalState.HTMLElement;
globalState.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement;
globalState.document = {
querySelectorAll: (selector: string) => selector === '[role="dialog"]' ? [sharingDialog, staleErrorDialog] : [],
} as unknown as Document;
globalState.window = {
location: { href: 'https://www.instagram.com/' },
getComputedStyle: () => ({ display: 'block', visibility: 'visible' }),
} as unknown as Window & typeof globalThis;
try {
expect(eval(buildPublishStatusProbeJs()) as { failed?: boolean; settled?: boolean; ok?: boolean }).toEqual({
ok: false,
failed: false,
settled: false,
url: '',
});
} finally {
globalState.document = originalDocument;
globalState.window = originalWindow;
globalState.HTMLElement = originalHTMLElement;
}
});
it('prefers explicit post-shared success over stale visible error text', () => {
const globalState = globalThis as typeof globalThis & {
document?: unknown;
window?: unknown;
HTMLElement?: unknown;
};
class MockHTMLElement {}
const sharedDialog = new MockHTMLElement() as MockHTMLElement & {
textContent: string;
querySelector: () => null;
getBoundingClientRect: () => { width: number; height: number };
};
sharedDialog.textContent = 'Post shared Your post has been shared.';
sharedDialog.querySelector = () => null;
sharedDialog.getBoundingClientRect = () => ({ width: 100, height: 100 });
const staleErrorDialog = new MockHTMLElement() as MockHTMLElement & {
textContent: string;
querySelector: () => null;
getBoundingClientRect: () => { width: number; height: number };
};
staleErrorDialog.textContent = 'Something went wrong. Please try again. Try again';
staleErrorDialog.querySelector = () => null;
staleErrorDialog.getBoundingClientRect = () => ({ width: 100, height: 100 });
const originalDocument = globalState.document;
const originalWindow = globalState.window;
const originalHTMLElement = globalState.HTMLElement;
globalState.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement;
globalState.document = {
querySelectorAll: (selector: string) => selector === '[role="dialog"]' ? [sharedDialog, staleErrorDialog] : [],
} as unknown as Document;
globalState.window = {
location: { href: 'https://www.instagram.com/' },
getComputedStyle: () => ({ display: 'block', visibility: 'visible' }),
} as unknown as Window & typeof globalThis;
try {
expect(eval(buildPublishStatusProbeJs()) as { failed?: boolean; settled?: boolean; ok?: boolean }).toEqual({
ok: true,
failed: false,
settled: false,
url: '',
});
} finally {
globalState.document = originalDocument;
globalState.window = originalWindow;
globalState.HTMLElement = originalHTMLElement;
}
});
});
describe('instagram click action detection', () => {
it('matches aria-label-only Next buttons in the media dialog', () => {
const globalState = globalThis as typeof globalThis & {
document?: unknown;
window?: unknown;
HTMLElement?: unknown;
};
class MockHTMLElement {
textContent = '';
ariaLabel = '';
clicked = false;
querySelectorAll = (_selector: string) => [] as unknown[];
querySelector = (_selector: string) => null as unknown;
getAttribute(name: string): string | null {
if (name === 'aria-label') return this.ariaLabel || null;
return null;
}
getBoundingClientRect() {
return { width: 100, height: 40 };
}
click() {
this.clicked = true;
}
}
const nextButton = new MockHTMLElement();
nextButton.ariaLabel = 'Next';
const dialog = new MockHTMLElement();
dialog.textContent = 'Crop Back Select crop Open media gallery';
dialog.querySelector = (selector: string) => selector === 'input[type="file"]' ? {} as Element : null;
dialog.querySelectorAll = (selector: string) => selector === 'button, div[role="button"]' ? [nextButton] : [];
const body = new MockHTMLElement();
const originalDocument = globalState.document;
const originalWindow = globalState.window;
const originalHTMLElement = globalState.HTMLElement;
globalState.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement;
globalState.document = {
body,
querySelectorAll: (selector: string) => selector === '[role="dialog"]' ? [dialog] : [],
} as unknown as Document;
globalState.window = {
getComputedStyle: () => ({ display: 'block', visibility: 'visible' }),
} as unknown as Window & typeof globalThis;
try {
expect(eval(buildClickActionJs(['Next', '下一步'], 'media')) as { ok: boolean; label?: string }).toEqual({
ok: true,
label: 'Next',
});
expect(nextButton.clicked).toBe(true);
} finally {
globalState.document = originalDocument;
globalState.window = originalWindow;
globalState.HTMLElement = originalHTMLElement;
}
});
it('does not click a body-level Next button when media scope has no matching dialog controls', () => {
const globalState = globalThis as typeof globalThis & {
document?: unknown;
window?: unknown;
HTMLElement?: unknown;
};
class MockHTMLElement {
textContent = '';
ariaLabel = '';
clicked = false;
children: unknown[] = [];
querySelectorAll = (_selector: string) => this.children;
querySelector = (_selector: string) => null as unknown;
getAttribute(name: string): string | null {
if (name === 'aria-label') return this.ariaLabel || null;
return null;
}
getBoundingClientRect() {
return { width: 100, height: 40 };
}
click() {
this.clicked = true;
}
}
const bodyNext = new MockHTMLElement();
bodyNext.ariaLabel = 'Next';
const errorDialog = new MockHTMLElement();
errorDialog.textContent = 'Something went wrong Try again';
errorDialog.children = [];
const body = new MockHTMLElement();
body.children = [bodyNext];
const originalDocument = globalState.document;
const originalWindow = globalState.window;
const originalHTMLElement = globalState.HTMLElement;
globalState.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement;
globalState.document = {
body,
querySelectorAll: (selector: string) => selector === '[role="dialog"]' ? [errorDialog] : [],
} as unknown as Document;
globalState.window = {
getComputedStyle: () => ({ display: 'block', visibility: 'visible' }),
} as unknown as Window & typeof globalThis;
try {
expect(eval(buildClickActionJs(['Next', '下一步'], 'media')) as { ok: boolean }).toEqual({ ok: false });
expect(bodyNext.clicked).toBe(false);
} finally {
globalState.document = originalDocument;
globalState.window = originalWindow;
globalState.HTMLElement = originalHTMLElement;
}
});
});
describe('instagram upload stage detection', () => {
it('does not treat a body-level Next button as upload preview when the visible dialog is an error', () => {
const globalState = globalThis as typeof globalThis & {
document?: unknown;
window?: unknown;
HTMLElement?: unknown;
};
class MockHTMLElement {
textContent = '';
ariaLabel = '';
children: unknown[] = [];
querySelectorAll = (_selector: string) => this.children;
querySelector = (_selector: string) => null as unknown;
getAttribute(name: string): string | null {
if (name === 'aria-label') return this.ariaLabel || null;
return null;
}
getBoundingClientRect() {
return { width: 100, height: 40 };
}
}
const bodyNext = new MockHTMLElement();
bodyNext.ariaLabel = 'Next';
const errorDialog = new MockHTMLElement();
errorDialog.textContent = 'Something went wrong. Please try again. Try again';
const body = new MockHTMLElement();
body.children = [bodyNext];
const originalDocument = globalState.document;
const originalWindow = globalState.window;
const originalHTMLElement = globalState.HTMLElement;
globalState.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement;
globalState.document = {
body,
querySelectorAll: (selector: string) => {
if (selector === '[role="dialog"]') return [errorDialog];
return [];
},
} as unknown as Document;
globalState.window = {
getComputedStyle: () => ({ display: 'block', visibility: 'visible' }),
} as unknown as Window & typeof globalThis;
try {
expect(eval(buildInspectUploadStageJs()) as { state: string; detail: string }).toEqual({
state: 'failed',
detail: 'Something went wrong. Please try again. Try again',
});
} finally {
globalState.document = originalDocument;
globalState.window = originalWindow;
globalState.HTMLElement = originalHTMLElement;
}
});
});
describe('instagram post registration', () => {
beforeEach(() => {
vi.spyOn(privatePublish, 'resolveInstagramPrivatePublishConfig').mockResolvedValue({
apiContext: {
asbdId: '',
csrfToken: 'csrf-token',
igAppId: '936619743392459',
igWwwClaim: '',
instagramAjax: '1036523242',
webSessionId: '',
},
jazoest: '22047',
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('registers the post command with a required-value media arg', () => {
const cmd = getRegistry().get('instagram/post');
expect(cmd).toBeDefined();
expect(cmd?.browser).toBe(true);
expect(cmd?.timeoutSeconds).toBe(300);
expect(cmd?.args.some((arg) => arg.name === 'media' && !arg.required && arg.valueRequired)).toBe(true);
expect(cmd?.args.some((arg) => arg.name === 'content' && !arg.required && arg.positional)).toBe(true);
});
it('publishes via private API and returns the post URL', async () => {
const imagePath = createTempImage('private-default.jpg');
const privateSpy = vi.spyOn(privatePublish, 'publishImagesViaPrivateApi').mockResolvedValueOnce({
code: 'PRIVATEDEFAULT123',
uploadIds: ['111'],
});
const page = createPageMock([], {
evaluate: vi.fn(async () => ({ ok: true })),
getCookies: vi.fn().mockResolvedValue([{ name: 'csrftoken', value: 'csrf-token', domain: 'instagram.com' }]),
});
const cmd = getRegistry().get('instagram/post');
const result = await cmd!.func!(page, { media: imagePath, content: 'private default' });
expect(privateSpy).toHaveBeenCalledTimes(1);
expect(page.setFileInput).not.toHaveBeenCalled();
expect(result).toEqual([
{
status: '✅ Posted',
detail: 'Single image post shared successfully',
url: 'https://www.instagram.com/p/PRIVATEDEFAULT123/',
},
]);
privateSpy.mockRestore();
});
it('publishes mixed-media posts via private API and preserves input order', async () => {
const imagePath = createTempImage('mixed-default.jpg');
const videoPath = createTempVideo('mixed-default.mp4');
const privateSpy = vi.spyOn(privatePublish, 'publishMediaViaPrivateApi').mockResolvedValueOnce({
code: 'MIXEDPRIVATE123',
uploadIds: ['111', '222'],
});
const page = createPageMock([], {
evaluate: vi.fn(async () => ({ ok: true })),
getCookies: vi.fn().mockResolvedValue([{ name: 'csrftoken', value: 'csrf-token', domain: 'instagram.com' }]),
});
const cmd = getRegistry().get('instagram/post');
const result = await cmd!.func!(page, {
media: `${imagePath},${videoPath}`,
content: 'mixed private default',
});
expect(privateSpy).toHaveBeenCalledWith(expect.objectContaining({
mediaItems: [
{ type: 'image', filePath: imagePath },
{ type: 'video', filePath: videoPath },
],
caption: 'mixed private default',
}));
expect(page.setFileInput).not.toHaveBeenCalled();
expect(result).toEqual([
{
status: '✅ Posted',
detail: '2-item mixed-media carousel post shared successfully',
url: 'https://www.instagram.com/p/MIXEDPRIVATE123/',
},
]);
privateSpy.mockRestore();
});
it('rejects missing --media before browser work', async () => {
const page = createPageMock([]);
const cmd = getRegistry().get('instagram/post');
await expect(cmd!.func!(page, {
content: 'missing media',
})).rejects.toThrow('Argument "media" is required.');
});
it('rejects empty or invalid --media inputs', async () => {
const imagePath = createTempImage('invalid-media-image.jpg');
const page = createPageMock([]);
const cmd = getRegistry().get('instagram/post');
await expect(cmd!.func!(page, {
media: '',
})).rejects.toThrow('Argument "media" is required.');
await expect(cmd!.func!(page, {
media: `${imagePath},/tmp/does-not-exist.mp4`,
})).rejects.toThrow('Media file not found');
});
it('propagates private API errors directly', async () => {
const imagePath = createTempImage('private-fail.jpg');
vi.spyOn(privatePublish, 'publishImagesViaPrivateApi').mockRejectedValueOnce(
new CommandExecutionError('Instagram private publish configure failed: 400'),
);
const page = createPageMock([], {
evaluate: vi.fn(async () => ({ ok: true })),
getCookies: vi.fn().mockResolvedValue([{ name: 'csrftoken', value: 'csrf-token', domain: 'instagram.com' }]),
});
const cmd = getRegistry().get('instagram/post');
await expect(cmd!.func!(page, {
media: imagePath,
content: 'should fail',
})).rejects.toThrow('Instagram private publish configure failed: 400');
});
});
+455
View File
@@ -0,0 +1,455 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { cli, Strategy } from '../../registry.js';
import { ArgumentError, CommandExecutionError } from '../../errors.js';
import type { IPage } from '../../types.js';
import {
publishMediaViaPrivateApi,
publishImagesViaPrivateApi,
resolveInstagramPrivatePublishConfig,
} from './_shared/private-publish.js';
import { resolveInstagramRuntimeInfo } from './_shared/runtime-info.js';
const INSTAGRAM_HOME_URL = 'https://www.instagram.com/';
const SUPPORTED_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp']);
const SUPPORTED_VIDEO_EXTENSIONS = new Set(['.mp4']);
const MAX_MEDIA_ITEMS = 10;
type InstagramSuccessRow = {
status: string;
detail: string;
url: string;
};
type InstagramPostMediaItem = {
type: 'image' | 'video';
filePath: string;
};
function requirePage(page: IPage | null): IPage {
if (!page) throw new CommandExecutionError('Browser session required for instagram post');
return page;
}
export function buildEnsureComposerOpenJs(): string {
return `
(() => {
const path = window.location?.pathname || '';
const onLoginRoute = /\\/accounts\\/login\\/?/.test(path);
const hasLoginField = !!document.querySelector('input[name="username"], input[name="password"]');
const hasLoginButton = Array.from(document.querySelectorAll('button, div[role="button"]')).some((el) => {
const text = (el.textContent || '').replace(/\\s+/g, ' ').trim().toLowerCase();
return text === 'log in' || text === 'login' || text === '登录';
});
if (onLoginRoute || (hasLoginField && hasLoginButton)) {
return { ok: false, reason: 'auth' };
}
const alreadyOpen = document.querySelector('input[type="file"]');
if (alreadyOpen) return { ok: true };
const labels = ['Create', 'New post', 'Post', '创建', '新帖子'];
const nodes = Array.from(document.querySelectorAll('a, button, div[role="button"], svg[aria-label], [aria-label]'));
for (const node of nodes) {
const text = ((node.textContent || '') + ' ' + (node.getAttribute?.('aria-label') || '')).trim();
if (labels.some((label) => text.toLowerCase().includes(label.toLowerCase()))) {
const clickable = node.closest('a, button, div[role="button"]') || node;
if (clickable instanceof HTMLElement) {
clickable.click();
return { ok: true };
}
}
}
return { ok: true };
})()
`;
}
export function buildPublishStatusProbeJs(): string {
return `
(() => {
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& rect.width > 0
&& rect.height > 0;
};
const dialogs = Array.from(document.querySelectorAll('[role="dialog"]')).filter((el) => isVisible(el));
const dialogText = dialogs
.map((el) => (el.textContent || '').replace(/\\s+/g, ' ').trim())
.join(' ');
const url = window.location.href;
const visibleText = dialogText.toLowerCase();
const sharingVisible = /sharing/.test(visibleText);
const shared = /post shared|your post has been shared|已分享|已发布/.test(visibleText)
|| /\\/p\\//.test(url);
const failed = !shared && !sharingVisible && (
/couldn['']t be shared|could not be shared|failed to share|share failed|无法分享|分享失败/.test(visibleText)
|| (/something went wrong/.test(visibleText) && /try again/.test(visibleText))
);
const composerOpen = dialogs.some((dialog) =>
!!dialog.querySelector('textarea, [contenteditable="true"], input[type="file"]')
|| /write a caption|add location|advanced settings|select from computer|crop|filters|adjustments|sharing/.test((dialog.textContent || '').toLowerCase())
);
const settled = !shared && !composerOpen && !/sharing/.test(visibleText);
return { ok: shared, failed, settled, url: /\\/p\\//.test(url) ? url : '' };
})()
`;
}
export function buildInspectUploadStageJs(): string {
return `
(() => {
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& rect.width > 0
&& rect.height > 0;
};
const dialogs = Array.from(document.querySelectorAll('[role="dialog"]')).filter((el) => isVisible(el));
const visibleTexts = dialogs.map((el) => (el.textContent || '').replace(/\\s+/g, ' ').trim());
const dialogText = visibleTexts.join(' ');
const combined = dialogText.toLowerCase();
const hasVisibleButtonInDialogs = (labels) => {
return dialogs.some((dialog) =>
Array.from(dialog.querySelectorAll('button, div[role="button"]')).some((el) => {
const text = (el.textContent || '').replace(/\\s+/g, ' ').trim();
const aria = (el.getAttribute?.('aria-label') || '').replace(/\\s+/g, ' ').trim();
return isVisible(el) && (labels.includes(text) || labels.includes(aria));
})
);
};
const hasCaption = dialogs.some((dialog) => !!dialog.querySelector('textarea, [contenteditable="true"]'));
const hasPicker = hasVisibleButtonInDialogs(['Select from computer', '从电脑中选择']);
const hasNext = hasVisibleButtonInDialogs(['Next', '下一步']);
const hasPreviewUi = hasCaption
|| (!hasPicker && hasNext)
|| /crop|select crop|select zoom|open media gallery|filters|adjustments|裁剪|缩放|滤镜|调整/.test(combined);
const failed = /something went wrong|please try again|couldn['']t upload|could not upload|upload failed|try again|出错|失败/.test(combined);
if (hasPreviewUi) return { state: 'preview', detail: dialogText || '' };
if (failed) return { state: 'failed', detail: dialogText || 'Something went wrong' };
return { state: 'pending', detail: dialogText || '' };
})()
`;
}
export function buildClickActionJs(labels: string[], scope: 'any' | 'media' | 'caption' = 'any'): string {
return `
((labels, scope) => {
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& rect.width > 0
&& rect.height > 0;
};
const matchesScope = (dialog) => {
if (!(dialog instanceof HTMLElement) || !isVisible(dialog)) return false;
const text = (dialog.textContent || '').replace(/\\s+/g, ' ').trim().toLowerCase();
if (scope === 'caption') {
return !!dialog.querySelector('textarea, [contenteditable="true"]')
|| text.includes('write a caption')
|| text.includes('add location')
|| text.includes('add collaborators')
|| text.includes('accessibility')
|| text.includes('advanced settings');
}
if (scope === 'media') {
return !!dialog.querySelector('input[type="file"]')
|| text.includes('select from computer')
|| text.includes('crop')
|| text.includes('filters')
|| text.includes('adjustments')
|| text.includes('open media gallery')
|| text.includes('select crop')
|| text.includes('select zoom');
}
return true;
};
const containers = scope !== 'any'
? Array.from(document.querySelectorAll('[role="dialog"]')).filter(matchesScope)
: [document.body];
for (const container of containers) {
const nodes = Array.from(container.querySelectorAll('button, div[role="button"]'));
for (const node of nodes) {
const text = (node.textContent || '').replace(/\\s+/g, ' ').trim();
const aria = (node.getAttribute?.('aria-label') || '').replace(/\\s+/g, ' ').trim();
if (!text && !aria) continue;
if (!labels.includes(text) && !labels.includes(aria)) continue;
if (node instanceof HTMLElement && isVisible(node) && node.getAttribute('aria-disabled') !== 'true') {
node.click();
return { ok: true, label: text || aria };
}
}
}
return { ok: false };
})(${JSON.stringify(labels)}, ${JSON.stringify(scope)})
`;
}
function validateMixedMediaItems(inputs: string[]): InstagramPostMediaItem[] {
if (!inputs.length) {
throw new ArgumentError(
'Argument "media" is required.',
'Provide --media /path/to/file.jpg or --media /path/a.jpg,/path/b.mp4',
);
}
if (inputs.length > MAX_MEDIA_ITEMS) {
throw new ArgumentError(`Too many media items: ${inputs.length}`, `Instagram carousel posts support at most ${MAX_MEDIA_ITEMS} items`);
}
const items = inputs.map((input) => {
const resolved = path.resolve(String(input || '').trim());
if (!resolved) {
throw new ArgumentError('Media path cannot be empty');
}
if (!fs.existsSync(resolved)) {
throw new ArgumentError(`Media file not found: ${resolved}`);
}
const ext = path.extname(resolved).toLowerCase();
if (SUPPORTED_IMAGE_EXTENSIONS.has(ext)) {
return { type: 'image' as const, filePath: resolved };
}
if (SUPPORTED_VIDEO_EXTENSIONS.has(ext)) {
return { type: 'video' as const, filePath: resolved };
}
throw new ArgumentError(`Unsupported media format: ${ext}`, 'Supported formats: images (.jpg, .jpeg, .png, .webp) and videos (.mp4)');
});
return items;
}
function normalizePostMediaItems(kwargs: Record<string, unknown>): InstagramPostMediaItem[] {
const media = String(kwargs.media ?? '').trim();
return validateMixedMediaItems(media.split(',').map((part) => part.trim()).filter(Boolean));
}
function validateInstagramPostArgs(kwargs: Record<string, unknown>): void {
const media = kwargs.media;
if (media === undefined) {
throw new ArgumentError(
'Argument "media" is required.',
'Provide --media /path/to/file.jpg or --media /path/a.jpg,/path/b.mp4',
);
}
}
function describePostDetail(mediaItems: InstagramPostMediaItem[]): string {
if (mediaItems.every((item) => item.type === 'image')) {
return mediaItems.length === 1
? 'Single image post shared successfully'
: `${mediaItems.length}-image carousel post shared successfully`;
}
return mediaItems.length === 1
? 'Single mixed-media post shared successfully'
: `${mediaItems.length}-item mixed-media carousel post shared successfully`;
}
function buildInstagramSuccessResult(mediaItems: InstagramPostMediaItem[], url: string): InstagramSuccessRow[] {
return [{
status: '✅ Posted',
detail: describePostDetail(mediaItems),
url,
}];
}
async function resolveCurrentUserId(page: IPage): Promise<string> {
const cookies = await page.getCookies({ domain: 'instagram.com' });
return cookies.find((cookie) => cookie.name === 'ds_user_id')?.value || '';
}
async function resolveProfileUrl(page: IPage, currentUserId = ''): Promise<string> {
if (currentUserId) {
const runtimeInfo = await resolveInstagramRuntimeInfo(page);
const apiResult = await page.evaluate(`
(async () => {
const userId = ${JSON.stringify(currentUserId)};
const appId = ${JSON.stringify(runtimeInfo.appId || '')};
try {
const res = await fetch(
'https://www.instagram.com/api/v1/users/' + encodeURIComponent(userId) + '/info/',
{
credentials: 'include',
headers: appId ? { 'X-IG-App-ID': appId } : {},
},
);
if (!res.ok) return { ok: false };
const data = await res.json();
const username = data?.user?.username || '';
return { ok: !!username, username };
} catch {
return { ok: false };
}
})()
`) as { ok?: boolean; username?: string };
if (apiResult?.ok && apiResult.username) {
return new URL(`/${apiResult.username}/`, INSTAGRAM_HOME_URL).toString();
}
}
const result = await page.evaluate(`
(() => {
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& rect.width > 0
&& rect.height > 0;
};
const anchors = Array.from(document.querySelectorAll('a[href]'))
.filter((el) => el instanceof HTMLAnchorElement && isVisible(el))
.map((el) => ({
href: el.getAttribute('href') || '',
text: (el.textContent || '').replace(/\\s+/g, ' ').trim().toLowerCase(),
aria: (el.getAttribute('aria-label') || '').replace(/\\s+/g, ' ').trim().toLowerCase(),
}))
.filter((el) => /^\\/[^/?#]+\\/$/.test(el.href));
const explicitProfile = anchors.find((el) => el.text === 'profile' || el.aria === 'profile')?.href || '';
const path = explicitProfile;
return { ok: !!path, path };
})()
`) as { ok?: boolean; path?: string };
if (!result?.ok || !result.path) return '';
return new URL(result.path, INSTAGRAM_HOME_URL).toString();
}
async function collectVisibleProfilePostPaths(page: IPage): Promise<string[]> {
const result = await page.evaluate(`
(() => {
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& rect.width > 0
&& rect.height > 0;
};
const hrefs = Array.from(document.querySelectorAll('a[href*="/p/"]'))
.filter((el) => el instanceof HTMLAnchorElement && isVisible(el))
.map((el) => el.getAttribute('href') || '')
.filter((href) => /^\\/(?:[^/?#]+\\/)?p\\/[^/?#]+\\/?$/.test(href))
.filter((href, index, arr) => arr.indexOf(href) === index);
return { ok: hrefs.length > 0, hrefs };
})()
`) as { ok?: boolean; hrefs?: string[] };
return Array.isArray(result?.hrefs) ? result.hrefs.filter(Boolean) : [];
}
async function captureExistingProfilePostPaths(page: IPage): Promise<Set<string>> {
const currentUserId = await resolveCurrentUserId(page);
if (!currentUserId) return new Set();
const profileUrl = await resolveProfileUrl(page, currentUserId);
if (!profileUrl) return new Set();
try {
await page.goto(profileUrl);
await page.wait({ time: 3 });
return new Set(await collectVisibleProfilePostPaths(page));
} catch {
return new Set();
}
}
async function resolveLatestPostUrl(page: IPage, existingPostPaths: ReadonlySet<string>): Promise<string> {
const currentUrl = await page.getCurrentUrl?.();
if (currentUrl && /\/p\//.test(currentUrl)) return currentUrl;
const currentUserId = await resolveCurrentUserId(page);
const profileUrl = await resolveProfileUrl(page, currentUserId);
if (!profileUrl) return '';
await page.goto(profileUrl);
await page.wait({ time: 4 });
for (let attempt = 0; attempt < 8; attempt++) {
const hrefs = await collectVisibleProfilePostPaths(page);
const href = hrefs.find((candidate) => !existingPostPaths.has(candidate)) || '';
if (href) {
return new URL(href, INSTAGRAM_HOME_URL).toString();
}
if (attempt < 7) await page.wait({ time: 1 });
}
return '';
}
async function executePrivateInstagramPost(input: {
page: IPage;
mediaItems: InstagramPostMediaItem[];
content: string;
existingPostPaths: Set<string>;
}): Promise<InstagramSuccessRow[]> {
const privateConfig = await resolveInstagramPrivatePublishConfig(input.page);
const privateResult = input.mediaItems.every((item) => item.type === 'image')
? await publishImagesViaPrivateApi({
page: input.page,
imagePaths: input.mediaItems.map((item) => item.filePath),
caption: input.content,
apiContext: privateConfig.apiContext,
jazoest: privateConfig.jazoest,
})
: await publishMediaViaPrivateApi({
page: input.page,
mediaItems: input.mediaItems,
caption: input.content,
apiContext: privateConfig.apiContext,
jazoest: privateConfig.jazoest,
});
const url = privateResult.code
? new URL(`/p/${privateResult.code}/`, INSTAGRAM_HOME_URL).toString()
: await resolveLatestPostUrl(input.page, input.existingPostPaths);
return buildInstagramSuccessResult(input.mediaItems, url);
}
cli({
site: 'instagram',
name: 'post',
description: 'Post an Instagram feed image or mixed-media carousel',
domain: 'www.instagram.com',
strategy: Strategy.UI,
browser: true,
timeoutSeconds: 300,
args: [
{ name: 'media', required: false, valueRequired: true, help: `Comma-separated media paths (images/videos, up to ${MAX_MEDIA_ITEMS})` },
{ name: 'content', positional: true, required: false, help: 'Caption text' },
],
columns: ['status', 'detail', 'url'],
validateArgs: validateInstagramPostArgs,
func: async (page: IPage | null, kwargs) => {
const browserPage = requirePage(page);
const mediaItems = normalizePostMediaItems(kwargs as Record<string, unknown>);
const content = String(kwargs.content ?? '').trim();
const existingPostPaths = await captureExistingProfilePostPaths(browserPage);
return executePrivateInstagramPost({
page: browserPage,
mediaItems,
content,
existingPostPaths,
});
},
});
+191
View File
@@ -0,0 +1,191 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ArgumentError } from '../../errors.js';
import { getRegistry } from '../../registry.js';
import type { IPage } from '../../types.js';
import './reel.js';
const tempDirs: string[] = [];
function createTempVideo(name = 'demo.mp4', bytes = Buffer.from('video')): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-instagram-reel-'));
tempDirs.push(dir);
const filePath = path.join(dir, name);
fs.writeFileSync(filePath, bytes);
return filePath;
}
function createPageMock(evaluateResults: unknown[], overrides: Partial<IPage> = {}): IPage {
const evaluate = vi.fn();
for (const result of evaluateResults) {
evaluate.mockResolvedValueOnce(result);
}
return {
goto: vi.fn().mockResolvedValue(undefined),
evaluate,
getCookies: vi.fn().mockResolvedValue([]),
snapshot: vi.fn().mockResolvedValue(undefined),
click: vi.fn().mockResolvedValue(undefined),
typeText: vi.fn().mockResolvedValue(undefined),
pressKey: vi.fn().mockResolvedValue(undefined),
scrollTo: vi.fn().mockResolvedValue(undefined),
getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
wait: vi.fn().mockResolvedValue(undefined),
tabs: vi.fn().mockResolvedValue([]),
closeTab: vi.fn().mockResolvedValue(undefined),
newTab: vi.fn().mockResolvedValue(undefined),
selectTab: vi.fn().mockResolvedValue(undefined),
networkRequests: vi.fn().mockResolvedValue([]),
consoleMessages: vi.fn().mockResolvedValue([]),
scroll: vi.fn().mockResolvedValue(undefined),
autoScroll: vi.fn().mockResolvedValue(undefined),
installInterceptor: vi.fn().mockResolvedValue(undefined),
getInterceptedRequests: vi.fn().mockResolvedValue([]),
waitForCapture: vi.fn().mockResolvedValue(undefined),
screenshot: vi.fn().mockResolvedValue(''),
setFileInput: vi.fn().mockResolvedValue(undefined),
insertText: vi.fn().mockResolvedValue(undefined),
getCurrentUrl: vi.fn().mockResolvedValue(null),
...overrides,
};
}
afterAll(() => {
for (const dir of tempDirs) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe('instagram reel registration', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('registers the reel command with a required-value video arg', () => {
const cmd = getRegistry().get('instagram/reel');
expect(cmd).toBeDefined();
expect(cmd?.browser).toBe(true);
expect(cmd?.args.some((arg) => arg.name === 'video' && !arg.required && arg.valueRequired)).toBe(true);
expect(cmd?.args.some((arg) => arg.name === 'content' && arg.positional && !arg.required)).toBe(true);
});
it('rejects missing --video before browser work', async () => {
const page = createPageMock([]);
const cmd = getRegistry().get('instagram/reel');
await expect(cmd!.func!(page, { content: 'hello reel' })).rejects.toThrow(ArgumentError);
expect(page.goto).not.toHaveBeenCalled();
});
it('rejects unsupported video formats', async () => {
const videoPath = createTempVideo('demo.mov');
const page = createPageMock([]);
const cmd = getRegistry().get('instagram/reel');
await expect(cmd!.func!(page, { video: videoPath })).rejects.toThrow('Unsupported video format');
expect(page.goto).not.toHaveBeenCalled();
});
it('uploads a reel video without caption and shares it', async () => {
const videoPath = createTempVideo();
const page = createPageMock([
{ ok: false }, // dismiss residual dialogs
{ ok: true }, // ensure composer open
{ ok: true }, // composer upload input ready
{ ok: true, selectors: ['[data-opencli-reel-upload-index="0"]', '[data-opencli-reel-upload-index="1"]'] }, // resolve upload selector
{ count: 1 }, // file bound to input
{ state: 'preview', detail: 'Crop Back Next' }, // preview detected
{ ok: true, label: 'OK' }, // dismiss reels nux
{ ok: true, label: 'Next' }, // move from crop to edit
{ state: 'edit' }, // edit stage
{ ok: true, label: 'Next' }, // move from edit to composer
{ state: 'composer' }, // composer stage
{ ok: true, label: 'Share' }, // share
{ ok: true, url: 'https://www.instagram.com/reel/REEL123/' }, // success
]);
const cmd = getRegistry().get('instagram/reel');
const result = await cmd!.func!(page, { video: videoPath });
expect(page.setFileInput).toHaveBeenCalledWith([videoPath], '[data-opencli-reel-upload-index="0"]');
expect(page.insertText).not.toHaveBeenCalled();
expect(result).toEqual([
{
status: '✅ Posted',
detail: 'Single reel shared successfully',
url: 'https://www.instagram.com/reel/REEL123/',
},
]);
});
it('copies query-style local video filenames to a safe temp upload path before setFileInput', async () => {
const videoPath = createTempVideo('demo.mp4?sign=abc&t=123video.MP4');
const page = createPageMock([
{ ok: false },
{ ok: true },
{ ok: true },
{ ok: true, selectors: ['[data-opencli-reel-upload-index="0"]'] },
{ count: 1 },
{ state: 'preview', detail: 'Crop Back Next' },
{ ok: true, label: 'OK' },
{ ok: true, label: 'Next' },
{ state: 'edit' },
{ ok: true, label: 'Next' },
{ state: 'composer' },
{ ok: true, label: 'Share' },
{ ok: true, url: 'https://www.instagram.com/reel/REELSAFE123/' },
]);
const cmd = getRegistry().get('instagram/reel');
await cmd!.func!(page, { video: videoPath });
const uploadPaths = (page.setFileInput as any).mock.calls[0]?.[0] ?? [];
expect(uploadPaths).toHaveLength(1);
expect(uploadPaths[0]).not.toBe(videoPath);
expect(String(uploadPaths[0])).toContain('opencli-instagram-video-real');
expect(String(uploadPaths[0]).toLowerCase()).toContain('.mp4');
});
it('uploads a reel video with caption and shares it', async () => {
const videoPath = createTempVideo('captioned.mp4');
const page = createPageMock([
{ ok: false }, // dismiss residual dialogs
{ ok: true }, // ensure composer open
{ ok: true }, // composer upload input ready
{ ok: true, selectors: ['[data-opencli-reel-upload-index="0"]'] }, // resolve upload selector
{ count: 1 }, // file bound to input
{ state: 'preview', detail: 'Crop Back Next' }, // preview detected
{ ok: true, label: 'OK' }, // dismiss reels nux
{ ok: true, label: 'Next' }, // move from crop to edit
{ state: 'edit' }, // edit stage
{ ok: true, label: 'Next' }, // move from edit to composer
{ state: 'composer' }, // composer stage
{ ok: true }, // focus caption editor
{ ok: true }, // post-insert event dispatch
{ ok: true }, // caption matches
{ ok: true, label: 'Share' }, // share
{ ok: true, url: 'https://www.instagram.com/reel/REEL456/' }, // success
]);
const cmd = getRegistry().get('instagram/reel');
const result = await cmd!.func!(page, { video: videoPath, content: 'hello reel' });
expect(page.insertText).toHaveBeenCalledWith('hello reel');
expect(result).toEqual([
{
status: '✅ Posted',
detail: 'Single reel shared successfully',
url: 'https://www.instagram.com/reel/REEL456/',
},
]);
});
});
+873
View File
@@ -0,0 +1,873 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { cli, Strategy } from '../../registry.js';
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '../../errors.js';
import type { BrowserCookie, IPage } from '../../types.js';
import {
buildClickActionJs,
buildEnsureComposerOpenJs,
buildInspectUploadStageJs,
} from './post.js';
import { resolveInstagramRuntimeInfo } from './_shared/runtime-info.js';
const INSTAGRAM_HOME_URL = 'https://www.instagram.com/';
const SUPPORTED_VIDEO_EXTENSIONS = new Set(['.mp4']);
const INSTAGRAM_REEL_TIMEOUT_SECONDS = 600;
type InstagramReelSuccessRow = {
status: string;
detail: string;
url: string;
};
type ReelStageState = {
state: 'crop' | 'edit' | 'composer' | 'failed' | 'pending';
detail?: string;
};
type PreparedVideoUpload = {
originalPath: string;
uploadPath: string;
cleanupPath?: string;
};
function requirePage(page: IPage | null): IPage {
if (!page) throw new CommandExecutionError('Browser session required for instagram reel');
return page;
}
async function gotoInstagramHome(page: IPage, forceReload = false): Promise<void> {
if (forceReload) {
await page.goto(`${INSTAGRAM_HOME_URL}?__opencli_reset=${Date.now()}`);
await page.wait({ time: 1 });
}
await page.goto(INSTAGRAM_HOME_URL);
}
function validateVideoPath(input: unknown): string {
const resolved = path.resolve(String(input || '').trim());
if (!resolved) {
throw new ArgumentError('Video path cannot be empty');
}
if (!fs.existsSync(resolved)) {
throw new ArgumentError(`Video file not found: ${resolved}`);
}
const ext = path.extname(resolved).toLowerCase();
if (!SUPPORTED_VIDEO_EXTENSIONS.has(ext)) {
throw new ArgumentError(`Unsupported video format: ${ext}`, 'Supported formats: .mp4');
}
return resolved;
}
function validateInstagramReelArgs(kwargs: Record<string, unknown>): void {
if (kwargs.video === undefined) {
throw new ArgumentError(
'Argument "video" is required.',
'Provide --video /path/to/file.mp4',
);
}
}
function buildInstagramReelSuccessResult(url: string): InstagramReelSuccessRow[] {
return [{
status: '✅ Posted',
detail: 'Single reel shared successfully',
url,
}];
}
function isRecoverableReelSessionError(error: unknown): boolean {
if (!(error instanceof CommandExecutionError)) return false;
return error.message === 'Instagram reel upload input not found'
|| error.message === 'Instagram reel preview did not appear after upload'
|| error.message === 'Instagram reel upload failed';
}
function buildSafeTempVideoPath(filePath: string): string {
const ext = path.extname(filePath).toLowerCase() || '.mp4';
return path.join(os.tmpdir(), `opencli-instagram-video-real${ext}`);
}
function prepareVideoUpload(filePath: string): PreparedVideoUpload {
const baseName = path.basename(filePath);
if (/^[a-zA-Z0-9._-]+$/.test(baseName)) {
return { originalPath: filePath, uploadPath: filePath };
}
const uploadPath = buildSafeTempVideoPath(filePath);
fs.copyFileSync(filePath, uploadPath);
return {
originalPath: filePath,
uploadPath,
cleanupPath: uploadPath,
};
}
async function ensureComposerOpen(page: IPage): Promise<void> {
const result = await page.evaluate(buildEnsureComposerOpenJs()) as { ok?: boolean; reason?: string };
if (!result?.ok) {
if (result?.reason === 'auth') {
throw new AuthRequiredError('www.instagram.com', 'Instagram login required before posting a reel');
}
throw new CommandExecutionError('Failed to open Instagram reel composer');
}
for (let attempt = 0; attempt < 12; attempt += 1) {
const ready = await page.evaluate(`
(() => {
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& rect.width > 0
&& rect.height > 0;
};
const inputs = Array.from(document.querySelectorAll('input[type="file"]'))
.filter((el) => el instanceof HTMLInputElement)
.filter((el) => {
const dialog = el.closest('[role="dialog"]');
return dialog instanceof HTMLElement && isVisible(dialog);
});
return { ok: inputs.length > 0 };
})()
`) as { ok?: boolean };
if (ready?.ok) return;
if (attempt < 11) await page.wait({ time: 0.5 });
}
throw new CommandExecutionError('Instagram reel upload input not found', 'Open the new-post composer in a logged-in browser session and retry');
}
async function dismissResidualDialogs(page: IPage): Promise<void> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const result = await page.evaluate(`
(() => {
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& rect.width > 0
&& rect.height > 0;
};
const dialogs = Array.from(document.querySelectorAll('[role="dialog"]'))
.filter((el) => el instanceof HTMLElement && isVisible(el));
for (const dialog of dialogs) {
const text = (dialog.textContent || '').replace(/\\s+/g, ' ').trim().toLowerCase();
if (!text) continue;
if (
text.includes('post shared')
|| text.includes('your post has been shared')
|| text.includes('your reel has been shared')
|| text.includes('video posts are now reels')
|| text.includes('something went wrong')
|| text.includes('sharing')
|| text.includes('create new post')
|| text.includes('new reel')
|| text.includes('crop')
|| text.includes('edit')
) {
const close = dialog.querySelector('[aria-label="Close"], button[aria-label="Close"], div[role="button"][aria-label="Close"]');
if (close instanceof HTMLElement && isVisible(close)) {
close.click();
return { ok: true };
}
}
}
return { ok: false };
})()
`) as { ok?: boolean };
if (!result?.ok) return;
await page.wait({ time: 0.5 });
}
}
async function resolveUploadSelectors(page: IPage): Promise<string[]> {
const result = await page.evaluate(`
(() => {
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& rect.width > 0
&& rect.height > 0;
};
const dialogs = Array.from(document.querySelectorAll('[role="dialog"]'))
.filter((el) => el instanceof HTMLElement && isVisible(el));
const roots = dialogs.length ? dialogs : [document.body];
const selectors = [];
let index = 0;
for (const root of roots) {
const inputs = Array.from(root.querySelectorAll('input[type="file"]'));
for (const input of inputs) {
if (!(input instanceof HTMLInputElement)) continue;
if (input.disabled) continue;
const accept = (input.getAttribute('accept') || '').toLowerCase();
if (accept && !accept.includes('video') && !accept.includes('.mp4')) continue;
input.setAttribute('data-opencli-reel-upload-index', String(index));
selectors.push('[data-opencli-reel-upload-index="' + index + '"]');
index += 1;
}
}
return { ok: selectors.length > 0, selectors };
})()
`) as { ok?: boolean; selectors?: string[] };
if (!result?.ok || !Array.isArray(result.selectors) || result.selectors.length === 0) {
throw new CommandExecutionError(
'Instagram reel upload input not found',
'Open the new-post composer in a logged-in browser session and retry',
);
}
return result.selectors;
}
async function uploadVideo(page: IPage, videoPath: string, selector: string): Promise<void> {
if (!page.setFileInput) {
throw new CommandExecutionError(
'Instagram reel upload requires Browser Bridge file upload support',
'Use Browser Bridge or another browser mode that supports setFileInput',
);
}
await page.setFileInput([videoPath], selector);
}
async function readSelectedFileCount(page: IPage, selector: string): Promise<number | null> {
const result = await page.evaluate(`
(() => {
const input = document.querySelector(${JSON.stringify(selector)});
if (!(input instanceof HTMLInputElement)) return { count: null };
return { count: input.files?.length || 0 };
})()
`) as { count?: number | null };
if (result?.count === null || result?.count === undefined) return null;
return Number(result.count);
}
async function waitForVideoPreview(page: IPage, maxWaitSeconds = 20): Promise<void> {
let lastDetail = '';
for (let attempt = 0; attempt < maxWaitSeconds * 2; attempt += 1) {
const result = await page.evaluate(buildInspectUploadStageJs()) as { state?: string; detail?: string };
lastDetail = String(result?.detail || '').trim();
if (result?.state === 'preview') return;
if (result?.state === 'failed') {
throw new CommandExecutionError(
'Instagram reel upload failed',
result.detail ? `Instagram rejected the reel upload: ${result.detail}` : 'Instagram rejected the reel upload before the preview stage',
);
}
if (attempt < maxWaitSeconds * 2 - 1) await page.wait({ time: 0.5 });
}
const debugPath = path.join(os.tmpdir(), 'instagram_reel_preview_debug.png');
await page.screenshot({ path: debugPath });
throw new CommandExecutionError(
'Instagram reel preview did not appear after upload',
lastDetail
? `Inspect ${debugPath}. Last visible dialog text: ${lastDetail}`
: `Inspect ${debugPath} for the upload state`,
);
}
async function clickAction(page: IPage, labels: string[], scope: 'any' | 'media' | 'caption' = 'any'): Promise<string> {
const result = await page.evaluate(buildClickActionJs(labels, scope)) as { ok?: boolean; label?: string };
if (!result?.ok) {
throw new CommandExecutionError(`Instagram action button not found: ${labels.join(' / ')}`);
}
return result.label || labels[0] || '';
}
async function clickActionMaybe(page: IPage, labels: string[], scope: 'any' | 'media' | 'caption' = 'any'): Promise<boolean> {
const result = await page.evaluate(buildClickActionJs(labels, scope)) as { ok?: boolean };
return !!result?.ok;
}
function buildInspectReelStageJs(): string {
return `
(() => {
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& rect.width > 0
&& rect.height > 0;
};
const dialogs = Array.from(document.querySelectorAll('[role="dialog"]')).filter((el) => isVisible(el));
const text = dialogs.map((el) => (el.textContent || '').replace(/\\s+/g, ' ').trim()).join(' ');
const lower = text.toLowerCase();
const hasVisibleButton = (labels) => dialogs.some((dialog) =>
Array.from(dialog.querySelectorAll('button, div[role="button"]')).some((el) => {
const value = (el.textContent || '').replace(/\\s+/g, ' ').trim().toLowerCase();
return isVisible(el) && labels.includes(value);
})
);
if (/something went wrong|please try again|share failed|couldn[']t be shared|could not be shared|失败|出错/.test(lower)) {
return { state: 'failed', detail: text };
}
if (/new reel|write a caption|add location|tag people/.test(lower) && hasVisibleButton(['share'])) {
return { state: 'composer', detail: text };
}
if (/edit|cover photo|trim|video has no audio/.test(lower) && hasVisibleButton(['next'])) {
return { state: 'edit', detail: text };
}
if (/crop|select crop|open media gallery/.test(lower) && hasVisibleButton(['next'])) {
return { state: 'crop', detail: text };
}
return { state: 'pending', detail: text };
})()
`;
}
async function waitForReelStage(page: IPage, expected: ReelStageState['state'], maxWaitSeconds = 20): Promise<void> {
for (let attempt = 0; attempt < maxWaitSeconds * 2; attempt += 1) {
const result = await page.evaluate(buildInspectReelStageJs()) as ReelStageState;
if (result?.state === expected) return;
if (result?.state === 'failed') {
throw new CommandExecutionError(
'Instagram reel editor did not appear',
result.detail ? `Instagram reel flow failed: ${result.detail}` : 'Instagram reel flow failed before the next editor stage',
);
}
if (attempt < maxWaitSeconds * 2 - 1) await page.wait({ time: 0.5 });
}
throw new CommandExecutionError(`Instagram reel ${expected} editor did not appear`);
}
async function focusCaptionEditor(page: IPage): Promise<boolean> {
const result = await page.evaluate(`
(() => {
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& rect.width > 0
&& rect.height > 0;
};
const dialogs = Array.from(document.querySelectorAll('[role="dialog"]')).filter((el) => isVisible(el));
for (const dialog of dialogs) {
const textarea = dialog.querySelector('[aria-label="Write a caption..."], textarea');
if (textarea instanceof HTMLTextAreaElement && isVisible(textarea)) {
textarea.focus();
textarea.select();
return { ok: true, kind: 'textarea' };
}
const editor = dialog.querySelector('[aria-label="Write a caption..."][contenteditable="true"]')
|| dialog.querySelector('[contenteditable="true"]');
if (editor instanceof HTMLElement && isVisible(editor)) {
const lexical = editor.__lexicalEditor;
try {
if (lexical && typeof lexical.getEditorState === 'function' && typeof lexical.parseEditorState === 'function') {
const emptyState = {
root: {
children: [{
children: [],
direction: null,
format: '',
indent: 0,
textFormat: 0,
textStyle: '',
type: 'paragraph',
version: 1,
}],
direction: null,
format: '',
indent: 0,
type: 'root',
version: 1,
},
};
const nextState = lexical.parseEditorState(JSON.stringify(emptyState));
try {
lexical.setEditorState(nextState, { tag: 'history-merge', discrete: true });
} catch {
lexical.setEditorState(nextState);
}
} else {
editor.textContent = '';
}
} catch {
editor.textContent = '';
}
editor.focus();
const selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
const range = document.createRange();
range.selectNodeContents(editor);
range.collapse(false);
selection.addRange(range);
}
return { ok: true, kind: 'contenteditable' };
}
}
return { ok: false };
})()
`) as { ok?: boolean };
return !!result?.ok;
}
async function captionMatches(page: IPage, content: string): Promise<boolean> {
const result = await page.evaluate(`
(() => {
const target = ${JSON.stringify(content.trim())}.replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim();
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& rect.width > 0
&& rect.height > 0;
};
const readLexicalText = (node) => {
if (!node || typeof node !== 'object') return '';
if (node.type === 'text' && typeof node.text === 'string') return node.text;
if (!Array.isArray(node.children)) return '';
if (node.type === 'root') return node.children.map((child) => readLexicalText(child)).join('\\n');
if (node.type === 'paragraph') return node.children.map((child) => readLexicalText(child)).join('');
return node.children.map((child) => readLexicalText(child)).join('');
};
const dialogs = Array.from(document.querySelectorAll('[role="dialog"]')).filter((el) => isVisible(el));
for (const dialog of dialogs) {
const textarea = dialog.querySelector('[aria-label="Write a caption..."], textarea');
if (textarea instanceof HTMLTextAreaElement && isVisible(textarea)) {
if (textarea.value.replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim() === target) {
return { ok: true };
}
continue;
}
const editor = dialog.querySelector('[aria-label="Write a caption..."][contenteditable="true"]')
|| dialog.querySelector('[contenteditable="true"]');
if (!(editor instanceof HTMLElement) || !isVisible(editor)) continue;
const lexical = editor.__lexicalEditor;
if (lexical && typeof lexical.getEditorState === 'function') {
const currentState = lexical.getEditorState();
const pendingState = lexical._pendingEditorState;
const current = currentState && typeof currentState.toJSON === 'function' ? currentState.toJSON() : null;
const pending = pendingState && typeof pendingState.toJSON === 'function' ? pendingState.toJSON() : null;
const currentText = readLexicalText(current && current.root).replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim();
const pendingText = readLexicalText(pending && pending.root).replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim();
if (currentText === target || pendingText === target) {
return { ok: true };
}
}
const value = (editor.textContent || '').replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim();
if (value === target) {
return { ok: true };
}
}
return { ok: false };
})()
`) as { ok?: boolean };
return !!result?.ok;
}
async function fillCaption(page: IPage, content: string): Promise<void> {
const focused = await focusCaptionEditor(page);
if (!focused) {
throw new CommandExecutionError('Instagram reel caption editor did not appear');
}
if (page.insertText) {
try {
await page.insertText(content);
await page.wait({ time: 0.3 });
await page.evaluate(`
(() => {
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& rect.width > 0
&& rect.height > 0;
};
const dialogs = Array.from(document.querySelectorAll('[role="dialog"]')).filter((el) => isVisible(el));
for (const dialog of dialogs) {
const textarea = dialog.querySelector('[aria-label="Write a caption..."], textarea');
if (textarea instanceof HTMLTextAreaElement && isVisible(textarea)) {
textarea.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true, inputType: 'insertText' }));
textarea.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
textarea.blur();
return { ok: true };
}
const editor = dialog.querySelector('[aria-label="Write a caption..."][contenteditable="true"]')
|| dialog.querySelector('[contenteditable="true"]');
if (!(editor instanceof HTMLElement) || !isVisible(editor)) continue;
try {
editor.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true, inputType: 'insertText' }));
} catch {
editor.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
}
editor.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
editor.blur();
return { ok: true };
}
return { ok: false };
})()
`);
return;
} catch {
// Fall back to browser-side editor manipulation below.
}
}
await page.evaluate(`
((content) => {
const createParagraph = (text) => ({
children: text
? [{ detail: 0, format: 0, mode: 'normal', style: '', text, type: 'text', version: 1 }]
: [],
direction: null,
format: '',
indent: 0,
textFormat: 0,
textStyle: '',
type: 'paragraph',
version: 1,
});
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& rect.width > 0
&& rect.height > 0;
};
const dialogs = Array.from(document.querySelectorAll('[role="dialog"]')).filter((el) => isVisible(el));
for (const dialog of dialogs) {
const textarea = dialog.querySelector('[aria-label="Write a caption..."], textarea');
if (textarea instanceof HTMLTextAreaElement && isVisible(textarea)) {
textarea.focus();
const dt = new DataTransfer();
dt.setData('text/plain', content);
textarea.dispatchEvent(new ClipboardEvent('paste', {
clipboardData: dt,
bubbles: true,
cancelable: true,
}));
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
setter?.call(textarea, content);
textarea.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
textarea.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
textarea.blur();
return { ok: true, mode: 'textarea' };
}
const editor = dialog.querySelector('[aria-label="Write a caption..."][contenteditable="true"]')
|| dialog.querySelector('[contenteditable="true"]');
if (!(editor instanceof HTMLElement) || !isVisible(editor)) continue;
editor.focus();
const lexical = editor.__lexicalEditor;
if (lexical && typeof lexical.getEditorState === 'function' && typeof lexical.parseEditorState === 'function') {
const currentState = lexical.getEditorState && lexical.getEditorState();
const base = currentState && typeof currentState.toJSON === 'function' ? currentState.toJSON() : {};
const lines = String(content).split(/\\r?\\n/);
const paragraphs = lines.map((line) => createParagraph(line));
base.root = {
children: paragraphs.length ? paragraphs : [createParagraph('')],
direction: null,
format: '',
indent: 0,
type: 'root',
version: 1,
};
const nextState = lexical.parseEditorState(JSON.stringify(base));
try {
lexical.setEditorState(nextState, { tag: 'history-merge', discrete: true });
} catch {
lexical.setEditorState(nextState);
}
editor.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
editor.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
editor.blur();
return { ok: true, mode: 'lexical' };
}
const selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
const range = document.createRange();
range.selectNodeContents(editor);
selection.addRange(range);
}
const dt = new DataTransfer();
dt.setData('text/plain', content);
editor.dispatchEvent(new ClipboardEvent('paste', {
clipboardData: dt,
bubbles: true,
cancelable: true,
}));
editor.blur();
return { ok: true, mode: 'contenteditable' };
}
return { ok: false };
})(${JSON.stringify(content)})
`);
}
async function ensureCaptionFilled(page: IPage, content: string): Promise<void> {
for (let attempt = 0; attempt < 6; attempt += 1) {
if (await captionMatches(page, content)) return;
if (attempt < 5) await page.wait({ time: 0.5 });
}
throw new CommandExecutionError('Instagram reel caption did not stick before sharing');
}
function buildReelPublishStatusProbeJs(): string {
return `
(() => {
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& rect.width > 0
&& rect.height > 0;
};
const dialogs = Array.from(document.querySelectorAll('[role="dialog"]')).filter((el) => isVisible(el));
const dialogText = dialogs.map((el) => (el.textContent || '').replace(/\\s+/g, ' ').trim()).join(' ');
const lower = dialogText.toLowerCase();
const url = window.location.href;
const sharingVisible = /sharing/.test(lower);
const shared = /your reel has been shared|reel shared|已分享|已发布/.test(lower) || /\\/reel\\//.test(url);
const failed = !shared && !sharingVisible && (
/couldn[']t be shared|could not be shared|share failed|无法分享|分享失败/.test(lower)
|| (/something went wrong/.test(lower) && /try again/.test(lower))
);
const composerOpen = dialogs.some((dialog) =>
!!dialog.querySelector('textarea, [contenteditable="true"], input[type="file"]')
|| /new reel|cover photo|trim|select from computer|crop|sharing/.test((dialog.textContent || '').toLowerCase())
);
const settled = !shared && !composerOpen && !sharingVisible;
return { ok: shared, failed, settled, url: /\\/reel\\//.test(url) ? url : '' };
})()
`;
}
async function waitForPublishSuccess(page: IPage): Promise<string> {
let settledStreak = 0;
for (let attempt = 0; attempt < 120; attempt += 1) {
const result = await page.evaluate(buildReelPublishStatusProbeJs()) as { ok?: boolean; failed?: boolean; settled?: boolean; url?: string };
if (result?.failed) {
throw new CommandExecutionError('Instagram reel share failed');
}
if (result?.ok) {
return result.url || '';
}
if (result?.settled) {
settledStreak += 1;
if (settledStreak >= 3) return '';
} else {
settledStreak = 0;
}
if (attempt < 119) await page.wait({ time: 1 });
}
throw new CommandExecutionError('Instagram reel share confirmation did not appear');
}
async function resolveCurrentUserId(page: IPage): Promise<string> {
const cookies = await page.getCookies({ domain: 'instagram.com' });
return cookies.find((cookie: BrowserCookie) => cookie.name === 'ds_user_id')?.value || '';
}
async function resolveProfileUrl(page: IPage, currentUserId = ''): Promise<string> {
if (currentUserId) {
const runtimeInfo = await resolveInstagramRuntimeInfo(page);
const apiResult = await page.evaluate(`
(async () => {
const userId = ${JSON.stringify(currentUserId)};
const appId = ${JSON.stringify(runtimeInfo.appId || '')};
try {
const res = await fetch(
'https://www.instagram.com/api/v1/users/' + encodeURIComponent(userId) + '/info/',
{
credentials: 'include',
headers: appId ? { 'X-IG-App-ID': appId } : {},
},
);
if (!res.ok) return { ok: false };
const data = await res.json();
const username = data?.user?.username || '';
return { ok: !!username, username };
} catch {
return { ok: false };
}
})()
`) as { ok?: boolean; username?: string };
if (apiResult?.ok && apiResult.username) {
return new URL(`/${apiResult.username}/`, INSTAGRAM_HOME_URL).toString();
}
}
return '';
}
async function collectVisibleProfileMediaPaths(page: IPage): Promise<string[]> {
const result = await page.evaluate(`
(() => {
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& rect.width > 0
&& rect.height > 0;
};
const hrefs = Array.from(document.querySelectorAll('a[href*="/reel/"], a[href*="/p/"]'))
.filter((el) => el instanceof HTMLAnchorElement && isVisible(el))
.map((el) => el.getAttribute('href') || '')
.filter((href) => /^\\/(?:[^/?#]+\\/)?(?:reel|p)\\/[^/?#]+\\/?$/.test(href))
.filter((href, index, arr) => arr.indexOf(href) === index);
return { hrefs };
})()
`) as { hrefs?: string[] };
return Array.isArray(result?.hrefs) ? result.hrefs.filter(Boolean) : [];
}
async function captureExistingProfileMediaPaths(page: IPage): Promise<Set<string>> {
const currentUserId = await resolveCurrentUserId(page);
if (!currentUserId) return new Set();
const profileUrl = await resolveProfileUrl(page, currentUserId);
if (!profileUrl) return new Set();
try {
await page.goto(profileUrl);
await page.wait({ time: 3 });
return new Set(await collectVisibleProfileMediaPaths(page));
} catch {
return new Set();
}
}
async function resolveLatestReelUrl(page: IPage, existingPaths: ReadonlySet<string>): Promise<string> {
const currentUrl = await page.getCurrentUrl?.();
if (currentUrl && /\/reel\//.test(currentUrl)) return currentUrl;
const currentUserId = await resolveCurrentUserId(page);
const profileUrl = await resolveProfileUrl(page, currentUserId);
if (!profileUrl) return '';
await page.goto(profileUrl);
await page.wait({ time: 4 });
for (let attempt = 0; attempt < 8; attempt += 1) {
const hrefs = await collectVisibleProfileMediaPaths(page);
const href = hrefs.find((candidate) => candidate.includes('/reel/') && !existingPaths.has(candidate))
|| hrefs.find((candidate) => !existingPaths.has(candidate))
|| '';
if (href) {
return new URL(href, INSTAGRAM_HOME_URL).toString();
}
if (attempt < 7) await page.wait({ time: 1 });
}
return '';
}
cli({
site: 'instagram',
name: 'reel',
description: 'Post an Instagram reel video',
domain: 'www.instagram.com',
strategy: Strategy.UI,
browser: true,
timeoutSeconds: INSTAGRAM_REEL_TIMEOUT_SECONDS,
args: [
{ name: 'video', required: false, valueRequired: true, help: 'Path to a single .mp4 video file' },
{ name: 'content', positional: true, required: false, help: 'Caption text' },
],
columns: ['status', 'detail', 'url'],
validateArgs: validateInstagramReelArgs,
func: async (page: IPage | null, kwargs) => {
const browserPage = requirePage(page);
const videoPath = validateVideoPath(kwargs.video);
const content = String(kwargs.content ?? '').trim();
const preparedUpload = prepareVideoUpload(videoPath);
const run = async (
activePage: IPage,
existingMediaPaths: ReadonlySet<string> = new Set(),
): Promise<InstagramReelSuccessRow[]> => {
if (typeof activePage.startNetworkCapture === 'function') {
await activePage.startNetworkCapture('/rupload_igvideo/|/api/v1/|/reel/|/clips/|/media/|/configure|/upload');
}
await gotoInstagramHome(activePage, true);
await activePage.wait({ time: 2 });
await dismissResidualDialogs(activePage);
await ensureComposerOpen(activePage);
await activePage.wait({ time: 2 });
const selectors = await resolveUploadSelectors(activePage);
let uploaded = false;
let uploadError: unknown;
for (const selector of selectors) {
try {
await uploadVideo(activePage, preparedUpload.uploadPath, selector);
const selectedFileCount = await readSelectedFileCount(activePage, selector);
if (selectedFileCount === 0) {
throw new CommandExecutionError('Instagram reel upload failed', 'The selected reel input never received the video file');
}
await waitForVideoPreview(activePage, 10);
uploaded = true;
break;
} catch (error) {
uploadError = error;
}
}
if (!uploaded) {
throw uploadError instanceof Error
? uploadError
: new CommandExecutionError('Instagram reel preview did not appear after upload');
}
await clickActionMaybe(activePage, ['OK'], 'any');
await clickAction(activePage, ['Next', '下一步'], 'media');
await waitForReelStage(activePage, 'edit', 20);
await clickAction(activePage, ['Next', '下一步'], 'media');
await waitForReelStage(activePage, 'composer', 20);
if (content) {
await fillCaption(activePage, content);
await ensureCaptionFilled(activePage, content);
}
await clickAction(activePage, ['Share', '分享'], 'caption');
const sharedUrl = await waitForPublishSuccess(activePage);
const url = sharedUrl || await resolveLatestReelUrl(activePage, existingMediaPaths);
return buildInstagramReelSuccessResult(url);
};
try {
const existingMediaPaths = await captureExistingProfileMediaPaths(browserPage);
try {
return await run(browserPage, existingMediaPaths);
} catch (error) {
if (!isRecoverableReelSessionError(error)) throw error;
return await run(browserPage, existingMediaPaths);
}
} finally {
if (preparedUpload.cleanupPath) {
fs.rmSync(preparedUpload.cleanupPath, { force: true });
}
}
},
});
+191
View File
@@ -0,0 +1,191 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ArgumentError } from '../../errors.js';
import { getRegistry } from '../../registry.js';
import type { IPage } from '../../types.js';
import * as privatePublish from './_shared/private-publish.js';
import './story.js';
const tempDirs: string[] = [];
function createTempFile(name: string, bytes = Buffer.from('story-media')): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-instagram-story-'));
tempDirs.push(dir);
const filePath = path.join(dir, name);
fs.writeFileSync(filePath, bytes);
return filePath;
}
function createPageMock(evaluateResults: unknown[] = [], overrides: Partial<IPage> = {}): IPage {
const evaluate = vi.fn();
for (const result of evaluateResults) {
evaluate.mockResolvedValueOnce(result);
}
return {
goto: vi.fn().mockResolvedValue(undefined),
evaluate,
getCookies: vi.fn().mockResolvedValue([]),
snapshot: vi.fn().mockResolvedValue(undefined),
click: vi.fn().mockResolvedValue(undefined),
typeText: vi.fn().mockResolvedValue(undefined),
pressKey: vi.fn().mockResolvedValue(undefined),
scrollTo: vi.fn().mockResolvedValue(undefined),
getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
wait: vi.fn().mockResolvedValue(undefined),
tabs: vi.fn().mockResolvedValue([]),
closeTab: vi.fn().mockResolvedValue(undefined),
newTab: vi.fn().mockResolvedValue(undefined),
selectTab: vi.fn().mockResolvedValue(undefined),
networkRequests: vi.fn().mockResolvedValue([]),
consoleMessages: vi.fn().mockResolvedValue([]),
scroll: vi.fn().mockResolvedValue(undefined),
autoScroll: vi.fn().mockResolvedValue(undefined),
installInterceptor: vi.fn().mockResolvedValue(undefined),
getInterceptedRequests: vi.fn().mockResolvedValue([]),
waitForCapture: vi.fn().mockResolvedValue(undefined),
screenshot: vi.fn().mockResolvedValue(''),
setFileInput: vi.fn().mockResolvedValue(undefined),
insertText: vi.fn().mockResolvedValue(undefined),
getCurrentUrl: vi.fn().mockResolvedValue(null),
...overrides,
};
}
afterAll(() => {
for (const dir of tempDirs) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe('instagram story registration', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('registers the story command with a required-value media arg', () => {
const cmd = getRegistry().get('instagram/story');
expect(cmd).toBeDefined();
expect(cmd?.browser).toBe(true);
expect(cmd?.args.some((arg) => arg.name === 'media' && !arg.required && arg.valueRequired)).toBe(true);
expect(cmd?.args.some((arg) => arg.name === 'content')).toBe(false);
});
it('rejects missing --media before browser work', async () => {
const page = createPageMock();
const cmd = getRegistry().get('instagram/story');
await expect(cmd!.func!(page, {})).rejects.toThrow(ArgumentError);
expect(page.goto).not.toHaveBeenCalled();
});
it('rejects multiple media inputs for a single story', async () => {
const first = createTempFile('one.jpg');
const second = createTempFile('two.mp4');
const page = createPageMock();
const cmd = getRegistry().get('instagram/story');
await expect(cmd!.func!(page, { media: `${first},${second}` })).rejects.toThrow('single media');
expect(page.goto).not.toHaveBeenCalled();
});
it('rejects unsupported story formats', async () => {
const filePath = createTempFile('story.mov');
const page = createPageMock();
const cmd = getRegistry().get('instagram/story');
await expect(cmd!.func!(page, { media: filePath })).rejects.toThrow('Unsupported story media format');
expect(page.goto).not.toHaveBeenCalled();
});
it('publishes a single image story through the private route', async () => {
const imagePath = createTempFile('story.jpg');
const page = createPageMock([
{ appId: '936619743392459', csrfToken: '', instagramAjax: 'ajax' },
{ ok: true, username: 'tsezi_ray' },
], {
getCookies: vi.fn().mockResolvedValue([{ name: 'ds_user_id', value: '123', domain: 'instagram.com' }]),
});
const cmd = getRegistry().get('instagram/story');
vi.spyOn(privatePublish, 'resolveInstagramPrivatePublishConfig').mockResolvedValue({
apiContext: {
asbdId: '359341',
csrfToken: 'csrf-token',
igAppId: '936619743392459',
igWwwClaim: 'claim',
instagramAjax: 'ajax',
webSessionId: 'session',
},
jazoest: '22047',
});
vi.spyOn(privatePublish, 'publishStoryViaPrivateApi').mockResolvedValue({
mediaPk: '1234567890',
uploadId: '1234567890',
});
const result = await cmd!.func!(page, { media: imagePath });
expect(privatePublish.publishStoryViaPrivateApi).toHaveBeenCalledWith(expect.objectContaining({
page,
mediaItem: { type: 'image', filePath: imagePath },
content: '',
}));
expect(result).toEqual([
{
status: '✅ Posted',
detail: 'Single story shared successfully',
url: 'https://www.instagram.com/stories/tsezi_ray/1234567890/',
},
]);
});
it('publishes a single video story through the private route', async () => {
const videoPath = createTempFile('story.mp4');
const page = createPageMock([
{ appId: '936619743392459', csrfToken: '', instagramAjax: 'ajax' },
{ ok: true, username: 'tsezi_ray' },
], {
getCookies: vi.fn().mockResolvedValue([{ name: 'ds_user_id', value: '123', domain: 'instagram.com' }]),
});
const cmd = getRegistry().get('instagram/story');
vi.spyOn(privatePublish, 'resolveInstagramPrivatePublishConfig').mockResolvedValue({
apiContext: {
asbdId: '359341',
csrfToken: 'csrf-token',
igAppId: '936619743392459',
igWwwClaim: 'claim',
instagramAjax: 'ajax',
webSessionId: 'session',
},
jazoest: '22047',
});
vi.spyOn(privatePublish, 'publishStoryViaPrivateApi').mockResolvedValue({
mediaPk: '9988776655',
uploadId: '9988776655',
});
const result = await cmd!.func!(page, { media: videoPath });
expect(privatePublish.publishStoryViaPrivateApi).toHaveBeenCalledWith(expect.objectContaining({
page,
mediaItem: { type: 'video', filePath: videoPath },
content: '',
}));
expect(result).toEqual([
{
status: '✅ Posted',
detail: 'Single video story shared successfully',
url: 'https://www.instagram.com/stories/tsezi_ray/9988776655/',
},
]);
});
});
+151
View File
@@ -0,0 +1,151 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { ArgumentError, CommandExecutionError } from '../../errors.js';
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
import {
publishStoryViaPrivateApi,
resolveInstagramPrivatePublishConfig,
} from './_shared/private-publish.js';
import { resolveInstagramRuntimeInfo } from './_shared/runtime-info.js';
const INSTAGRAM_HOME_URL = 'https://www.instagram.com/';
const SUPPORTED_STORY_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp']);
const SUPPORTED_STORY_VIDEO_EXTENSIONS = new Set(['.mp4']);
type InstagramStoryMediaItem = {
type: 'image' | 'video';
filePath: string;
};
type InstagramStorySuccessRow = {
status: string;
detail: string;
url: string;
};
function requirePage(page: IPage | null): IPage {
if (!page) throw new CommandExecutionError('Browser session required for instagram story');
return page;
}
function validateInstagramStoryArgs(kwargs: Record<string, unknown>): void {
if (kwargs.media === undefined) {
throw new ArgumentError(
'Argument "media" is required.',
'Provide --media /path/to/file.jpg or --media /path/to/file.mp4',
);
}
}
function normalizeStoryMediaItem(kwargs: Record<string, unknown>): InstagramStoryMediaItem {
const raw = String(kwargs.media ?? '').trim();
const parts = raw.split(',').map((part) => part.trim()).filter(Boolean);
if (parts.length === 0) {
throw new ArgumentError(
'Argument "media" is required.',
'Provide --media /path/to/file.jpg or --media /path/to/file.mp4',
);
}
if (parts.length > 1) {
throw new ArgumentError(
'Instagram story currently supports a single media item.',
'Provide one image or one video path with --media',
);
}
const resolved = path.resolve(parts[0]!);
if (!fs.existsSync(resolved)) {
throw new ArgumentError(`Story media file not found: ${resolved}`);
}
const ext = path.extname(resolved).toLowerCase();
if (SUPPORTED_STORY_IMAGE_EXTENSIONS.has(ext)) {
return { type: 'image', filePath: resolved };
}
if (SUPPORTED_STORY_VIDEO_EXTENSIONS.has(ext)) {
return { type: 'video', filePath: resolved };
}
throw new ArgumentError(
`Unsupported story media format: ${ext}`,
'Supported formats: images (.jpg, .jpeg, .png, .webp) and videos (.mp4)',
);
}
async function resolveCurrentUserId(page: IPage): Promise<string> {
const cookies = await page.getCookies({ domain: 'instagram.com' });
return cookies.find((cookie) => cookie.name === 'ds_user_id')?.value || '';
}
async function resolveCurrentUsername(page: IPage, currentUserId = ''): Promise<string> {
if (!currentUserId) return '';
const runtimeInfo = await resolveInstagramRuntimeInfo(page);
const apiResult = await page.evaluate(`
(async () => {
const userId = ${JSON.stringify(currentUserId)};
const appId = ${JSON.stringify(runtimeInfo.appId || '')};
try {
const res = await fetch(
'https://www.instagram.com/api/v1/users/' + encodeURIComponent(userId) + '/info/',
{
credentials: 'include',
headers: appId ? { 'X-IG-App-ID': appId } : {},
},
);
if (!res.ok) return { ok: false };
const data = await res.json();
const username = data?.user?.username || '';
return { ok: !!username, username };
} catch {
return { ok: false };
}
})()
`) as { ok?: boolean; username?: string };
return apiResult?.ok && apiResult.username ? apiResult.username : '';
}
function buildStorySuccessResult(mediaItem: InstagramStoryMediaItem, url: string): InstagramStorySuccessRow[] {
return [{
status: '✅ Posted',
detail: mediaItem.type === 'video'
? 'Single video story shared successfully'
: 'Single story shared successfully',
url,
}];
}
cli({
site: 'instagram',
name: 'story',
description: 'Post a single Instagram story image or video',
domain: 'www.instagram.com',
strategy: Strategy.UI,
browser: true,
timeoutSeconds: 300,
args: [
{ name: 'media', required: false, valueRequired: true, help: 'Path to a single story image or video file' },
],
columns: ['status', 'detail', 'url'],
validateArgs: validateInstagramStoryArgs,
func: async (page: IPage | null, kwargs) => {
const browserPage = requirePage(page);
const mediaItem = normalizeStoryMediaItem(kwargs as Record<string, unknown>);
const currentUserId = await resolveCurrentUserId(browserPage);
const privateConfig = await resolveInstagramPrivatePublishConfig(browserPage);
const storyResult = await publishStoryViaPrivateApi({
page: browserPage,
mediaItem,
content: '',
apiContext: privateConfig.apiContext,
jazoest: privateConfig.jazoest,
currentUserId,
});
const username = await resolveCurrentUsername(browserPage, currentUserId);
const mediaPk = storyResult.mediaPk || storyResult.uploadId;
const url = username && mediaPk
? new URL(`/stories/${username}/${mediaPk}/`, INSTAGRAM_HOME_URL).toString()
: '';
return buildStorySuccessResult(mediaItem, url);
},
});
+51
View File
@@ -125,6 +125,57 @@ describe('commanderAdapter boolean alias support', () => {
});
});
describe('commanderAdapter value-required optional options', () => {
const cmd: CliCommand = {
site: 'instagram',
name: 'post',
description: 'Post to Instagram',
browser: true,
args: [
{ name: 'image', valueRequired: true, help: 'Single image path' },
{ name: 'images', valueRequired: true, help: 'Comma-separated image paths' },
{ name: 'content', positional: true, required: false, help: 'Caption text' },
],
validateArgs: (kwargs) => {
if (!kwargs.image && !kwargs.images) {
throw new Error('media required');
}
},
func: vi.fn(),
};
beforeEach(() => {
mockExecuteCommand.mockReset();
mockExecuteCommand.mockResolvedValue([]);
mockRenderOutput.mockReset();
delete process.env.OPENCLI_VERBOSE;
process.exitCode = undefined;
});
it('requires a value when --image is present', async () => {
const program = new Command();
program.exitOverride();
const siteCmd = program.command('instagram');
registerCommandToProgram(siteCmd, cmd);
await expect(
program.parseAsync(['node', 'opencli', 'instagram', 'post', '--image']),
).rejects.toMatchObject({ code: 'commander.optionMissingArgument' });
expect(mockExecuteCommand).not.toHaveBeenCalled();
});
it('runs validateArgs before executeCommand so missing media does not dispatch the browser command', async () => {
const program = new Command();
const siteCmd = program.command('instagram');
registerCommandToProgram(siteCmd, cmd);
await program.parseAsync(['node', 'opencli', 'instagram', 'post', 'caption only']);
expect(mockExecuteCommand).not.toHaveBeenCalled();
expect(process.exitCode).toBeDefined();
});
});
describe('commanderAdapter command aliases', () => {
const cmd: CliCommand = {
site: 'notebooklm',
+3 -1
View File
@@ -62,7 +62,8 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
subCmd.argument(bracket, arg.help ?? '');
positionalArgs.push(arg);
} else {
const flag = arg.required ? `--${arg.name} <value>` : `--${arg.name} [value]`;
const expectsValue = arg.required || arg.valueRequired;
const flag = expectsValue ? `--${arg.name} <value>` : `--${arg.name} [value]`;
if (arg.required) subCmd.requiredOption(flag, arg.help ?? '');
else if (arg.default != null) subCmd.option(flag, arg.help ?? '', String(arg.default));
else subCmd.option(flag, arg.help ?? '');
@@ -93,6 +94,7 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
const v = optionsRecord[arg.name] ?? optionsRecord[camelName];
if (v !== undefined) kwargs[arg.name] = normalizeArgValue(arg.type, v, arg.name);
}
cmd.validateArgs?.(kwargs);
const verbose = optionsRecord.verbose === true;
let format = typeof optionsRecord.format === 'string' ? optionsRecord.format : 'table';
+1
View File
@@ -139,6 +139,7 @@ export async function executeCommand(
let kwargs: CommandArgs;
try {
kwargs = coerceAndValidateArgs(cmd.args, rawKwargs);
cmd.validateArgs?.(kwargs);
} catch (err) {
if (err instanceof ArgumentError) throw err;
throw new ArgumentError(getErrorMessage(err));
+2
View File
@@ -17,6 +17,7 @@ export interface Arg {
type?: string;
default?: unknown;
required?: boolean;
valueRequired?: boolean;
positional?: boolean;
help?: string;
choices?: string[];
@@ -47,6 +48,7 @@ export interface CliCommand {
source?: string;
footerExtra?: (kwargs: CommandArgs) => string | undefined;
requiredEnv?: RequiredEnv[];
validateArgs?: (kwargs: CommandArgs) => void;
/** Deprecation note shown in help / execution warnings. */
deprecated?: boolean | string;
/** Preferred replacement command, if any. */
+2
View File
@@ -14,6 +14,7 @@ export type SerializedArg = {
name: string;
type: string;
required: boolean;
valueRequired: boolean;
positional: boolean;
choices: string[];
default: unknown;
@@ -26,6 +27,7 @@ export function serializeArg(a: Arg): SerializedArg {
name: a.name,
type: a.type ?? 'string',
required: !!a.required,
valueRequired: !!a.valueRequired,
positional: !!a.positional,
choices: a.choices ?? [],
default: a.default ?? null,
+9
View File
@@ -56,6 +56,8 @@ export interface IPage {
getFormState(): Promise<any>;
wait(options: number | WaitOptions): Promise<void>;
tabs(): Promise<any>;
closeTab?(index?: number): Promise<void>;
newTab?(): Promise<void>;
selectTab(index: number): Promise<void>;
networkRequests(includeStatic?: boolean): Promise<any>;
consoleMessages(level?: string): Promise<any>;
@@ -65,11 +67,18 @@ export interface IPage {
getInterceptedRequests(): Promise<any[]>;
waitForCapture(timeout?: number): Promise<void>;
screenshot(options?: ScreenshotOptions): Promise<string>;
startNetworkCapture?(pattern?: string): Promise<void>;
readNetworkCapture?(): Promise<unknown[]>;
/**
* Set local file paths on a file input element via CDP DOM.setFileInputFiles.
* Chrome reads the files directly — no base64 encoding or payload size limits.
*/
setFileInput?(files: string[], selector?: string): Promise<void>;
/**
* Insert text via native CDP Input.insertText into the currently focused element.
* Useful for rich editors that ignore synthetic DOM value/text mutations.
*/
insertText?(text: string): Promise<void>;
closeWindow?(): Promise<void>;
/** Returns the current page URL, or null if unavailable. */
getCurrentUrl?(): Promise<string | null>;