Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 34624adb18 | |||
| 12fe89d12c | |||
| 4546a3d3e3 | |||
| c8c773197e | |||
| 6e950a4201 | |||
| f54401488a | |||
| e22a312f50 | |||
| d50cfe345f | |||
| c7d8678c11 | |||
| 6aad5522f1 | |||
| e8c27d8da5 | |||
| a8fbb637cb |
Vendored
+1046
-658
File diff suppressed because it is too large
Load Diff
+144
-8
@@ -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
@@ -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).
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 || '',
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
}]);
|
||||
});
|
||||
});
|
||||
@@ -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 || ''));
|
||||
},
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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/',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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/',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user