9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
1498 lines
62 KiB
JavaScript
1498 lines
62 KiB
JavaScript
import * as fs from 'node:fs';
|
||
import * as path from 'node:path';
|
||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||
import { installInstagramProtocolCapture, readInstagramProtocolCapture, } from './_shared/protocol-capture.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;
|
||
const INSTAGRAM_PROTOCOL_TRACE_OUTPUT_PATH = '/tmp/instagram_post_protocol_trace.json';
|
||
async function gotoInstagramHome(page, forceReload = false) {
|
||
if (forceReload) {
|
||
await page.goto(`${INSTAGRAM_HOME_URL}?__opencli_reset=${Date.now()}`);
|
||
await page.wait({ time: 1 });
|
||
}
|
||
await page.goto(INSTAGRAM_HOME_URL);
|
||
}
|
||
export function buildEnsureComposerOpenJs() {
|
||
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() {
|
||
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 : '' };
|
||
})()
|
||
`;
|
||
}
|
||
function requirePage(page) {
|
||
if (!page)
|
||
throw new CommandExecutionError('Browser session required for instagram post');
|
||
return page;
|
||
}
|
||
function validateMixedMediaItems(inputs) {
|
||
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', filePath: resolved };
|
||
}
|
||
if (SUPPORTED_VIDEO_EXTENSIONS.has(ext)) {
|
||
return { type: 'video', filePath: resolved };
|
||
}
|
||
throw new ArgumentError(`Unsupported media format: ${ext}`, 'Supported formats: images (.jpg, .jpeg, .png, .webp) and videos (.mp4)');
|
||
});
|
||
return items;
|
||
}
|
||
function normalizePostMediaItems(kwargs) {
|
||
const media = String(kwargs.media ?? '').trim();
|
||
return validateMixedMediaItems(media.split(',').map((part) => part.trim()).filter(Boolean));
|
||
}
|
||
function validateInstagramPostArgs(kwargs) {
|
||
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 isSafePrivateRouteFallbackError(error) {
|
||
if (!(error instanceof CommandExecutionError))
|
||
return false;
|
||
return error.message.startsWith('Instagram private publish')
|
||
|| error.message.startsWith('Instagram private route');
|
||
}
|
||
function buildInstagramSuccessResult(mediaItems, url) {
|
||
return [{
|
||
status: '✅ Posted',
|
||
detail: describePostDetail(mediaItems),
|
||
url,
|
||
}];
|
||
}
|
||
function buildFallbackHint(privateError, uiError) {
|
||
const privateMessage = privateError instanceof Error ? privateError.message : String(privateError);
|
||
const uiMessage = uiError instanceof Error ? uiError.message : String(uiError);
|
||
return `Private route failed first: ${privateMessage}. UI fallback then failed: ${uiMessage}`;
|
||
}
|
||
async function executePrivateInstagramPost(input) {
|
||
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);
|
||
}
|
||
async function executeUiInstagramPost(input) {
|
||
let lastError;
|
||
let lastSpecificCommandError = null;
|
||
for (let attempt = 0; attempt < input.commandAttemptBudget; attempt++) {
|
||
let shareClicked = false;
|
||
try {
|
||
await gotoInstagramHome(input.page, input.forceFreshStart || attempt > 0);
|
||
await input.installProtocolCapture();
|
||
await input.page.wait({ time: 2 });
|
||
await dismissResidualDialogs(input.page);
|
||
await ensureComposerOpen(input.page);
|
||
const uploadSelectors = await resolveUploadSelectors(input.page, input.mediaItems);
|
||
if (input.preUploadDelaySeconds > 0) {
|
||
await input.page.wait({ time: input.preUploadDelaySeconds });
|
||
}
|
||
let uploaded = false;
|
||
let uploadFailure = null;
|
||
for (const selector of uploadSelectors) {
|
||
let activeSelector = selector;
|
||
for (let uploadAttempt = 0; uploadAttempt < input.uploadAttemptBudget; uploadAttempt++) {
|
||
await uploadMedia(input.page, input.mediaItems, activeSelector);
|
||
const uploadState = await waitForPreviewMaybe(input.page, input.previewProbeWindowSeconds);
|
||
if (uploadState.state === 'preview') {
|
||
uploaded = true;
|
||
break;
|
||
}
|
||
if (uploadState.state === 'failed') {
|
||
uploadFailure = makeUploadFailure(uploadState.detail);
|
||
for (let inlineRetry = 0; inlineRetry < input.inlineUploadRetryBudget; inlineRetry++) {
|
||
const clickedRetry = await clickVisibleUploadRetry(input.page);
|
||
if (!clickedRetry)
|
||
break;
|
||
await input.page.wait({ time: 3 });
|
||
const retriedState = await waitForPreviewMaybe(input.page, Math.max(3, Math.floor(input.previewProbeWindowSeconds / 2)));
|
||
if (retriedState.state === 'preview') {
|
||
uploaded = true;
|
||
break;
|
||
}
|
||
if (retriedState.state !== 'failed')
|
||
break;
|
||
}
|
||
if (uploaded)
|
||
break;
|
||
await dismissUploadErrorDialog(input.page);
|
||
await dismissResidualDialogs(input.page);
|
||
if (uploadAttempt < input.uploadAttemptBudget - 1) {
|
||
try {
|
||
await input.drainProtocolCapture();
|
||
await gotoInstagramHome(input.page, true);
|
||
await input.installProtocolCapture();
|
||
await input.page.wait({ time: 2 });
|
||
await dismissResidualDialogs(input.page);
|
||
await ensureComposerOpen(input.page);
|
||
activeSelector = await resolveFreshUploadSelector(input.page, activeSelector, input.mediaItems);
|
||
if (input.preUploadDelaySeconds > 0) {
|
||
await input.page.wait({ time: input.preUploadDelaySeconds });
|
||
}
|
||
}
|
||
catch {
|
||
throw uploadFailure;
|
||
}
|
||
await input.page.wait({ time: 1.5 });
|
||
continue;
|
||
}
|
||
break;
|
||
}
|
||
break;
|
||
}
|
||
if (uploaded)
|
||
break;
|
||
}
|
||
if (!uploaded) {
|
||
if (uploadFailure)
|
||
throw uploadFailure;
|
||
await waitForPreview(input.page, input.finalPreviewWaitSeconds);
|
||
}
|
||
try {
|
||
await advanceToCaptionEditor(input.page);
|
||
}
|
||
catch (error) {
|
||
await rethrowUploadFailureIfPresent(input.page, error);
|
||
}
|
||
if (input.content) {
|
||
await fillCaption(input.page, input.content);
|
||
await ensureCaptionFilled(input.page, input.content);
|
||
}
|
||
if (input.preShareDelaySeconds > 0) {
|
||
await input.page.wait({ time: input.preShareDelaySeconds });
|
||
}
|
||
await clickAction(input.page, ['Share', '分享'], 'caption');
|
||
shareClicked = true;
|
||
let url = '';
|
||
try {
|
||
url = await waitForPublishSuccess(input.page);
|
||
}
|
||
catch (error) {
|
||
if (error instanceof CommandExecutionError
|
||
&& error.message === 'Instagram post share failed'
|
||
&& await clickVisibleShareRetry(input.page)) {
|
||
await input.page.wait({ time: Math.max(2, input.preShareDelaySeconds) });
|
||
url = await waitForPublishSuccess(input.page);
|
||
}
|
||
else {
|
||
throw error;
|
||
}
|
||
}
|
||
await input.drainProtocolCapture();
|
||
if (!url) {
|
||
url = await resolveLatestPostUrl(input.page, input.existingPostPaths);
|
||
}
|
||
return buildInstagramSuccessResult(input.mediaItems, url);
|
||
}
|
||
catch (error) {
|
||
lastError = error;
|
||
if (error instanceof CommandExecutionError && error.message !== 'Failed to open Instagram post composer') {
|
||
lastSpecificCommandError = error;
|
||
}
|
||
if (error instanceof AuthRequiredError)
|
||
throw error;
|
||
if (shareClicked) {
|
||
throw error;
|
||
}
|
||
if (!(error instanceof CommandExecutionError) || attempt === input.commandAttemptBudget - 1) {
|
||
if (error instanceof CommandExecutionError && error.message === 'Failed to open Instagram post composer' && lastSpecificCommandError) {
|
||
throw lastSpecificCommandError;
|
||
}
|
||
throw error;
|
||
}
|
||
let resetWindow = false;
|
||
if (input.mediaItems.length >= 10 && input.page.closeWindow) {
|
||
try {
|
||
await input.drainProtocolCapture();
|
||
await input.page.closeWindow();
|
||
resetWindow = true;
|
||
}
|
||
catch {
|
||
// Best-effort: a fresh automation window is safer than reusing a polluted one.
|
||
}
|
||
}
|
||
if (!resetWindow) {
|
||
await dismissResidualDialogs(input.page);
|
||
await input.page.wait({ time: 1 });
|
||
}
|
||
}
|
||
}
|
||
throw lastError instanceof Error ? lastError : new CommandExecutionError('Instagram post failed');
|
||
}
|
||
async function ensureComposerOpen(page) {
|
||
const result = await page.evaluate(buildEnsureComposerOpenJs());
|
||
if (!result?.ok) {
|
||
if (result?.reason === 'auth')
|
||
throw new AuthRequiredError('www.instagram.com', 'Instagram login required before posting');
|
||
throw new CommandExecutionError('Failed to open Instagram post composer');
|
||
}
|
||
}
|
||
async function dismissResidualDialogs(page) {
|
||
for (let attempt = 0; attempt < 4; attempt++) {
|
||
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('something went wrong')
|
||
|| text.includes('sharing')
|
||
|| text.includes('create new post')
|
||
|| 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 };
|
||
}
|
||
const closeByText = Array.from(dialog.querySelectorAll('button, div[role="button"]')).find((el) => {
|
||
const buttonText = (el.textContent || '').replace(/\\s+/g, ' ').trim().toLowerCase();
|
||
return isVisible(el) && (buttonText === 'close' || buttonText === 'cancel' || buttonText === '取消');
|
||
});
|
||
if (closeByText instanceof HTMLElement) {
|
||
closeByText.click();
|
||
return { ok: true };
|
||
}
|
||
}
|
||
}
|
||
|
||
return { ok: false };
|
||
})()
|
||
`);
|
||
if (!result?.ok)
|
||
return;
|
||
await page.wait({ time: 0.5 });
|
||
}
|
||
}
|
||
async function findUploadSelectors(page, mediaItems) {
|
||
const includesVideo = mediaItems.some((item) => item.type === 'video');
|
||
const result = await page.evaluate(`
|
||
((includesVideo) => {
|
||
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 hasButtonText = (root, labels) => {
|
||
if (!root || !(root instanceof Element)) return false;
|
||
return Array.from(root.querySelectorAll('button, div[role="button"], span'))
|
||
.some((el) => {
|
||
const text = (el.textContent || '').replace(/\\s+/g, ' ').trim().toLowerCase();
|
||
return labels.some((label) => text === label.toLowerCase());
|
||
});
|
||
};
|
||
|
||
const inputs = Array.from(document.querySelectorAll('input[type="file"]'));
|
||
const candidates = inputs.filter((el) => {
|
||
if (!(el instanceof HTMLInputElement)) return false;
|
||
if (el.disabled) return false;
|
||
const accept = (el.getAttribute('accept') || '').toLowerCase();
|
||
if (!accept) return true;
|
||
if (includesVideo) return true;
|
||
return accept.includes('image') || accept.includes('.jpg') || accept.includes('.jpeg') || accept.includes('.png') || accept.includes('.webp');
|
||
});
|
||
|
||
const dialogInputs = candidates.filter((el) => {
|
||
const dialog = el.closest('[role="dialog"]');
|
||
return hasButtonText(dialog, ['Select from computer', '从电脑中选择']);
|
||
});
|
||
|
||
const visibleDialogInputs = dialogInputs.filter((el) => {
|
||
const dialog = el.closest('[role="dialog"]');
|
||
return dialog instanceof HTMLElement && isVisible(dialog);
|
||
});
|
||
|
||
const pickerInputs = candidates.filter((el) => {
|
||
return hasButtonText(el.parentElement, ['Select from computer', '从电脑中选择']);
|
||
});
|
||
|
||
const primary = visibleDialogInputs.length
|
||
? [visibleDialogInputs[visibleDialogInputs.length - 1]]
|
||
: dialogInputs.length
|
||
? [dialogInputs[dialogInputs.length - 1]]
|
||
: [];
|
||
const ordered = [...primary, ...pickerInputs, ...candidates]
|
||
.filter((el, index, arr) => arr.indexOf(el) === index);
|
||
if (!ordered.length) return { ok: false };
|
||
|
||
document.querySelectorAll('[data-opencli-ig-upload-index]').forEach((el) => el.removeAttribute('data-opencli-ig-upload-index'));
|
||
const selectors = ordered.map((input, index) => {
|
||
input.setAttribute('data-opencli-ig-upload-index', String(index));
|
||
return '[data-opencli-ig-upload-index="' + index + '"]';
|
||
});
|
||
return { ok: true, selectors };
|
||
})(${JSON.stringify(includesVideo)})
|
||
`);
|
||
if (!result?.ok || !result.selectors?.length) {
|
||
throw new CommandExecutionError('Instagram upload input not found', 'Open the new-post composer in a logged-in browser session and retry');
|
||
}
|
||
return result.selectors;
|
||
}
|
||
async function resolveUploadSelectors(page, mediaItems) {
|
||
try {
|
||
return await findUploadSelectors(page, mediaItems);
|
||
}
|
||
catch (error) {
|
||
if (!(error instanceof CommandExecutionError) || !error.message.includes('upload input not found')) {
|
||
throw error;
|
||
}
|
||
await ensureComposerOpen(page);
|
||
await page.wait({ time: 1.5 });
|
||
try {
|
||
return await findUploadSelectors(page, mediaItems);
|
||
}
|
||
catch (retryError) {
|
||
if (!(retryError instanceof CommandExecutionError) || !retryError.message.includes('upload input not found')) {
|
||
throw retryError;
|
||
}
|
||
await gotoInstagramHome(page, true);
|
||
await page.wait({ time: 2 });
|
||
await dismissResidualDialogs(page);
|
||
await ensureComposerOpen(page);
|
||
await page.wait({ time: 2 });
|
||
return findUploadSelectors(page, mediaItems);
|
||
}
|
||
}
|
||
}
|
||
function extractSelectorIndex(selector) {
|
||
const match = selector.match(/data-opencli-ig-upload-index="(\d+)"/);
|
||
if (!match)
|
||
return null;
|
||
const index = Number.parseInt(match[1] || '', 10);
|
||
return Number.isNaN(index) ? null : index;
|
||
}
|
||
async function resolveFreshUploadSelector(page, previousSelector, mediaItems) {
|
||
const selectors = await resolveUploadSelectors(page, mediaItems);
|
||
const index = extractSelectorIndex(previousSelector);
|
||
if (index !== null && selectors[index])
|
||
return selectors[index];
|
||
return selectors[0] || previousSelector;
|
||
}
|
||
async function injectImageViaBrowser(page, imagePaths, selector) {
|
||
const images = imagePaths.map((imagePath) => {
|
||
const ext = path.extname(imagePath).toLowerCase();
|
||
const mimeType = ext === '.png'
|
||
? 'image/png'
|
||
: ext === '.webp'
|
||
? 'image/webp'
|
||
: 'image/jpeg';
|
||
return {
|
||
name: path.basename(imagePath),
|
||
type: mimeType,
|
||
base64: fs.readFileSync(imagePath).toString('base64'),
|
||
};
|
||
});
|
||
const chunkKey = `__opencliInstagramUpload_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||
const chunkSize = 256 * 1024;
|
||
await page.evaluate(`
|
||
(() => {
|
||
window[${JSON.stringify(chunkKey)}] = [];
|
||
return { ok: true };
|
||
})()
|
||
`);
|
||
const payload = JSON.stringify(images);
|
||
for (let offset = 0; offset < payload.length; offset += chunkSize) {
|
||
const chunk = payload.slice(offset, offset + chunkSize);
|
||
await page.evaluate(`
|
||
(() => {
|
||
const key = ${JSON.stringify(chunkKey)};
|
||
const chunk = ${JSON.stringify(chunk)};
|
||
const parts = Array.isArray(window[key]) ? window[key] : [];
|
||
parts.push(chunk);
|
||
window[key] = parts;
|
||
return { ok: true, count: parts.length };
|
||
})()
|
||
`);
|
||
}
|
||
const result = await page.evaluate(`
|
||
(() => {
|
||
const selector = ${JSON.stringify(selector)};
|
||
const key = ${JSON.stringify(chunkKey)};
|
||
const payload = JSON.parse(Array.isArray(window[key]) ? window[key].join('') : '[]');
|
||
|
||
const cleanup = () => { try { delete window[key]; } catch {} };
|
||
const input = document.querySelector(selector);
|
||
if (!(input instanceof HTMLInputElement)) {
|
||
cleanup();
|
||
return { ok: false, error: 'File input not found for fallback injection' };
|
||
}
|
||
|
||
try {
|
||
const dt = new DataTransfer();
|
||
for (const img of payload) {
|
||
const binary = atob(img.base64);
|
||
const bytes = new Uint8Array(binary.length);
|
||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||
const blob = new Blob([bytes], { type: img.type });
|
||
const file = new File([blob], img.name, { type: img.type });
|
||
dt.items.add(file);
|
||
}
|
||
Object.defineProperty(input, 'files', { value: dt.files, configurable: true });
|
||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||
cleanup();
|
||
return { ok: true, count: dt.files.length };
|
||
} catch (error) {
|
||
cleanup();
|
||
return { ok: false, error: String(error) };
|
||
}
|
||
})()
|
||
`);
|
||
if (!result?.ok) {
|
||
throw new CommandExecutionError(result?.error || 'Instagram fallback file injection failed');
|
||
}
|
||
}
|
||
async function dispatchUploadEvents(page, selector) {
|
||
await page.evaluate(`
|
||
(() => {
|
||
const input = document.querySelector(${JSON.stringify(selector)});
|
||
if (!(input instanceof HTMLInputElement)) return { ok: false };
|
||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||
return { ok: true };
|
||
})()
|
||
`);
|
||
}
|
||
export function buildInspectUploadStageJs() {
|
||
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 || '' };
|
||
})()
|
||
`;
|
||
}
|
||
async function inspectUploadStage(page) {
|
||
const result = await page.evaluate(buildInspectUploadStageJs());
|
||
if (result?.state)
|
||
return result;
|
||
if (result?.ok === true)
|
||
return { state: 'preview', detail: result.detail };
|
||
return { state: 'pending', detail: result?.detail };
|
||
}
|
||
function makeUploadFailure(detail) {
|
||
return new CommandExecutionError('Instagram image upload failed', detail ? `Instagram rejected the upload: ${detail}` : 'Instagram rejected the upload before the preview stage');
|
||
}
|
||
async function uploadMedia(page, mediaItems, selector) {
|
||
const mediaPaths = mediaItems.map((item) => item.filePath);
|
||
if (!page.setFileInput) {
|
||
throw new CommandExecutionError('Instagram posting requires Browser Bridge file upload support', 'Use Browser Bridge or another browser mode that supports setFileInput');
|
||
}
|
||
let activeSelector = selector;
|
||
for (let attempt = 0; attempt < 2; attempt++) {
|
||
try {
|
||
await page.setFileInput(mediaPaths, activeSelector);
|
||
await dispatchUploadEvents(page, activeSelector);
|
||
return;
|
||
}
|
||
catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
const staleSelector = message.includes('No element found matching selector')
|
||
|| message.includes('Could not find node with given id')
|
||
|| message.includes('No node with given id found');
|
||
if (staleSelector && attempt === 0) {
|
||
activeSelector = await resolveFreshUploadSelector(page, activeSelector, mediaItems);
|
||
continue;
|
||
}
|
||
if (!message.includes('Unknown action') && !message.includes('set-file-input') && !message.includes('not supported')) {
|
||
throw error;
|
||
}
|
||
if (mediaItems.some((item) => item.type === 'video')) {
|
||
throw new CommandExecutionError('Instagram mixed-media posting requires Browser Bridge file upload support', 'Use Browser Bridge or another browser mode that supports setFileInput for image/video uploads');
|
||
}
|
||
await injectImageViaBrowser(page, mediaPaths, activeSelector);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
function describePostDetail(mediaItems) {
|
||
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 getCommandAttemptBudget(mediaItems) {
|
||
if (mediaItems.length >= 10)
|
||
return 6;
|
||
if (mediaItems.length >= 5)
|
||
return 4;
|
||
return 3;
|
||
}
|
||
function getPreUploadDelaySeconds(mediaItems) {
|
||
if (mediaItems.length >= 10)
|
||
return 3;
|
||
if (mediaItems.length >= 5)
|
||
return 1.5;
|
||
return 0;
|
||
}
|
||
function getUploadAttemptBudget(mediaItems) {
|
||
if (mediaItems.length >= 10)
|
||
return 3;
|
||
if (mediaItems.length >= 5)
|
||
return 3;
|
||
return 2;
|
||
}
|
||
function getPreviewProbeWindowSeconds(mediaItems) {
|
||
if (mediaItems.length >= 10)
|
||
return 6;
|
||
if (mediaItems.length >= 5)
|
||
return 6;
|
||
return 4;
|
||
}
|
||
function getFinalPreviewWaitSeconds(mediaItems) {
|
||
if (mediaItems.length >= 10)
|
||
return 12;
|
||
if (mediaItems.length >= 5)
|
||
return 16;
|
||
return 12;
|
||
}
|
||
function getPreShareDelaySeconds(mediaItems) {
|
||
if (mediaItems.length >= 10)
|
||
return 4;
|
||
if (mediaItems.length >= 5)
|
||
return 3;
|
||
return 0;
|
||
}
|
||
function getInlineUploadRetryBudget(mediaItems) {
|
||
if (mediaItems.length >= 10)
|
||
return 3;
|
||
if (mediaItems.length >= 5)
|
||
return 2;
|
||
return 1;
|
||
}
|
||
async function dismissUploadErrorDialog(page) {
|
||
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 text = (dialog.textContent || '').replace(/\\s+/g, ' ').trim().toLowerCase();
|
||
if (!text.includes('something went wrong') && !text.includes('try again') && !text.includes('失败') && !text.includes('出错')) continue;
|
||
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 };
|
||
})()
|
||
`);
|
||
return !!result?.ok;
|
||
}
|
||
async function clickVisibleUploadRetry(page) {
|
||
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 text = (dialog.textContent || '').replace(/\\s+/g, ' ').trim().toLowerCase();
|
||
if (!text.includes('something went wrong') && !text.includes('try again') && !text.includes('失败') && !text.includes('出错')) continue;
|
||
const retry = Array.from(dialog.querySelectorAll('button, div[role="button"]')).find((el) => {
|
||
const label = ((el.textContent || '') + ' ' + (el.getAttribute?.('aria-label') || ''))
|
||
.replace(/\\s+/g, ' ')
|
||
.trim()
|
||
.toLowerCase();
|
||
return isVisible(el) && (
|
||
label === 'try again'
|
||
|| label === 'retry'
|
||
|| label === '再试一次'
|
||
|| label === '重试'
|
||
);
|
||
});
|
||
if (retry instanceof HTMLElement) {
|
||
retry.click();
|
||
return { ok: true };
|
||
}
|
||
}
|
||
return { ok: false };
|
||
})()
|
||
`);
|
||
return !!result?.ok;
|
||
}
|
||
async function waitForPreview(page, maxWaitSeconds = 12) {
|
||
const attempts = Math.max(1, Math.ceil(maxWaitSeconds));
|
||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||
const state = await inspectUploadStage(page);
|
||
if (state.state === 'preview')
|
||
return;
|
||
if (state.state === 'failed') {
|
||
await page.screenshot({ path: '/tmp/instagram_post_preview_debug.png' });
|
||
throw makeUploadFailure('Inspect /tmp/instagram_post_preview_debug.png. ' + (state.detail || ''));
|
||
}
|
||
if (attempt < attempts - 1)
|
||
await page.wait({ time: 1 });
|
||
}
|
||
await page.screenshot({ path: '/tmp/instagram_post_preview_debug.png' });
|
||
throw new CommandExecutionError('Instagram image preview did not appear after upload', 'The selected file input may not match the active composer; inspect /tmp/instagram_post_preview_debug.png');
|
||
}
|
||
async function waitForPreviewMaybe(page, maxWaitSeconds = 4) {
|
||
const attempts = Math.max(1, Math.ceil(maxWaitSeconds * 2));
|
||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||
const state = await inspectUploadStage(page);
|
||
if (state.state !== 'pending')
|
||
return state;
|
||
if (attempt < attempts - 1)
|
||
await page.wait({ time: 0.5 });
|
||
}
|
||
return { state: 'pending' };
|
||
}
|
||
export function buildClickActionJs(labels, scope = 'any') {
|
||
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)})
|
||
`;
|
||
}
|
||
async function clickAction(page, labels, scope = 'any') {
|
||
const result = await page.evaluate(buildClickActionJs(labels, scope));
|
||
if (!result?.ok) {
|
||
throw new CommandExecutionError(`Instagram action button not found: ${labels.join(' / ')}`);
|
||
}
|
||
return result.label || labels[0];
|
||
}
|
||
async function clickVisibleShareRetry(page) {
|
||
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 text = (dialog.textContent || '').replace(/\\s+/g, ' ').trim().toLowerCase();
|
||
if (!text.includes('post couldn') && !text.includes('could not be shared') && !text.includes('share failed')) continue;
|
||
|
||
const retry = Array.from(dialog.querySelectorAll('button, div[role="button"]')).find((el) => {
|
||
const label = ((el.textContent || '') + ' ' + (el.getAttribute?.('aria-label') || ''))
|
||
.replace(/\\s+/g, ' ')
|
||
.trim()
|
||
.toLowerCase();
|
||
return isVisible(el) && (
|
||
label === 'try again'
|
||
|| label === 'retry'
|
||
|| label === '再试一次'
|
||
|| label === '重试'
|
||
);
|
||
});
|
||
|
||
if (retry instanceof HTMLElement) {
|
||
retry.click();
|
||
return { ok: true };
|
||
}
|
||
}
|
||
|
||
return { ok: false };
|
||
})()
|
||
`);
|
||
return !!result?.ok;
|
||
}
|
||
async function hasCaptionEditor(page) {
|
||
const result = await page.evaluate(`
|
||
(() => {
|
||
const editable = document.querySelector('textarea, [contenteditable="true"]');
|
||
return { ok: !!editable };
|
||
})()
|
||
`);
|
||
return !!result?.ok;
|
||
}
|
||
async function isCaptionStage(page) {
|
||
const result = await page.evaluate(`
|
||
(() => {
|
||
const editable = document.querySelector('textarea, [contenteditable="true"]');
|
||
const dialogText = Array.from(document.querySelectorAll('[role="dialog"]'))
|
||
.map((el) => (el.textContent || '').replace(/\\s+/g, ' ').trim().toLowerCase())
|
||
.join(' ');
|
||
return {
|
||
ok: !!editable
|
||
|| dialogText.includes('write a caption')
|
||
|| dialogText.includes('add location')
|
||
|| dialogText.includes('add collaborators')
|
||
|| dialogText.includes('advanced settings'),
|
||
};
|
||
})()
|
||
`);
|
||
return !!result?.ok;
|
||
}
|
||
async function advanceToCaptionEditor(page) {
|
||
for (let attempt = 0; attempt < 3; attempt++) {
|
||
if (await isCaptionStage(page)) {
|
||
return;
|
||
}
|
||
try {
|
||
await clickAction(page, ['Next', '下一步'], 'media');
|
||
}
|
||
catch (error) {
|
||
if (error instanceof CommandExecutionError) {
|
||
await page.wait({ time: 1.5 });
|
||
if (await isCaptionStage(page)) {
|
||
return;
|
||
}
|
||
const uploadState = await inspectUploadStage(page);
|
||
if (uploadState.state === 'failed') {
|
||
throw makeUploadFailure(uploadState.detail);
|
||
}
|
||
if (attempt < 2) {
|
||
continue;
|
||
}
|
||
}
|
||
throw error;
|
||
}
|
||
await page.wait({ time: 1.5 });
|
||
if (await hasCaptionEditor(page)) {
|
||
return;
|
||
}
|
||
const uploadState = await inspectUploadStage(page);
|
||
if (uploadState.state === 'failed') {
|
||
throw makeUploadFailure(uploadState.detail);
|
||
}
|
||
}
|
||
await page.screenshot({ path: '/tmp/instagram_post_caption_debug.png' });
|
||
throw new CommandExecutionError('Instagram caption editor did not appear', 'Instagram may have changed the publish flow; inspect /tmp/instagram_post_caption_debug.png');
|
||
}
|
||
async function waitForCaptionEditor(page) {
|
||
if (!(await hasCaptionEditor(page))) {
|
||
await page.screenshot({ path: '/tmp/instagram_post_caption_debug.png' });
|
||
throw new CommandExecutionError('Instagram caption editor did not appear', 'Instagram may have changed the publish flow; inspect /tmp/instagram_post_caption_debug.png');
|
||
}
|
||
}
|
||
async function rethrowUploadFailureIfPresent(page, originalError) {
|
||
const uploadState = await inspectUploadStage(page);
|
||
if (uploadState.state === 'failed') {
|
||
throw makeUploadFailure(uploadState.detail);
|
||
}
|
||
throw originalError;
|
||
}
|
||
async function focusCaptionEditorForNativeInsert(page) {
|
||
const result = await page.evaluate(`
|
||
(() => {
|
||
const textarea = document.querySelector('[aria-label="Write a caption..."], textarea');
|
||
if (textarea instanceof HTMLTextAreaElement) {
|
||
textarea.focus();
|
||
textarea.select();
|
||
return { ok: true, kind: 'textarea' };
|
||
}
|
||
|
||
const editor = document.querySelector('[aria-label="Write a caption..."][contenteditable="true"]')
|
||
|| document.querySelector('[contenteditable="true"]');
|
||
if (!(editor instanceof HTMLElement)) return { ok: false };
|
||
|
||
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 !!result?.ok;
|
||
}
|
||
async function fillCaption(page, content) {
|
||
if (page.insertText && await focusCaptionEditorForNativeInsert(page)) {
|
||
try {
|
||
await page.insertText(content);
|
||
await page.wait({ time: 0.3 });
|
||
await page.evaluate(`
|
||
(() => {
|
||
const textarea = document.querySelector('[aria-label="Write a caption..."], textarea');
|
||
if (textarea instanceof HTMLTextAreaElement) {
|
||
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 = document.querySelector('[aria-label="Write a caption..."][contenteditable="true"]')
|
||
|| document.querySelector('[contenteditable="true"]');
|
||
if (!(editor instanceof HTMLElement)) return { ok: false };
|
||
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;
|
||
}
|
||
catch {
|
||
// Fall back to browser-side editor manipulation below.
|
||
}
|
||
}
|
||
const result = 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 textarea = document.querySelector('[aria-label="Write a caption..."], textarea');
|
||
if (textarea instanceof HTMLTextAreaElement) {
|
||
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 }));
|
||
return { ok: true, mode: 'textarea' };
|
||
}
|
||
|
||
const editor = document.querySelector('[aria-label="Write a caption..."][contenteditable="true"]')
|
||
|| document.querySelector('[contenteditable="true"]');
|
||
if (editor instanceof HTMLElement) {
|
||
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 }));
|
||
const nextCurrentState = lexical.getEditorState && lexical.getEditorState();
|
||
const pendingState = lexical._pendingEditorState;
|
||
return {
|
||
ok: true,
|
||
mode: 'lexical',
|
||
value: editor.textContent || '',
|
||
current: nextCurrentState && typeof nextCurrentState.toJSON === 'function' ? nextCurrentState.toJSON() : null,
|
||
pending: pendingState && typeof pendingState.toJSON === 'function' ? pendingState.toJSON() : null,
|
||
};
|
||
}
|
||
|
||
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,
|
||
}));
|
||
return { ok: true, mode: 'contenteditable', value: editor.textContent || '' };
|
||
}
|
||
|
||
return { ok: false };
|
||
})(${JSON.stringify(content)})
|
||
`);
|
||
if (!result?.ok) {
|
||
throw new CommandExecutionError('Failed to fill Instagram caption');
|
||
}
|
||
}
|
||
async function captionMatches(page, content) {
|
||
const result = await page.evaluate(`
|
||
((content) => {
|
||
const normalized = content.trim();
|
||
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 textarea = document.querySelector('[aria-label="Write a caption..."], textarea');
|
||
if (textarea instanceof HTMLTextAreaElement) {
|
||
return { ok: textarea.value.trim() === normalized };
|
||
}
|
||
|
||
const editor = document.querySelector('[aria-label="Write a caption..."][contenteditable="true"]')
|
||
|| document.querySelector('[contenteditable="true"]');
|
||
if (editor instanceof HTMLElement) {
|
||
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).trim();
|
||
const pendingText = readLexicalText(pending && pending.root).trim();
|
||
if (currentText === normalized || pendingText === normalized) {
|
||
return { ok: true, currentText, pendingText };
|
||
}
|
||
}
|
||
|
||
const text = (editor.textContent || '').replace(/\\u00a0/g, ' ').trim();
|
||
if (text === normalized) return { ok: true };
|
||
|
||
const counters = Array.from(document.querySelectorAll('div, span'))
|
||
.map((el) => (el.textContent || '').replace(/\\s+/g, ' ').trim())
|
||
.filter(Boolean);
|
||
const counter = counters.find((value) => /\\d+\\s*\\/\\s*2,?200/.test(value));
|
||
if (counter) {
|
||
const match = counter.match(/(\\d+)\\s*\\/\\s*2,?200/);
|
||
if (match && Number(match[1]) >= normalized.length) return { ok: true };
|
||
}
|
||
|
||
return { ok: false, text, counter: counter || '' };
|
||
}
|
||
|
||
return { ok: false };
|
||
})(${JSON.stringify(content)})
|
||
`);
|
||
return !!result?.ok;
|
||
}
|
||
async function ensureCaptionFilled(page, content) {
|
||
for (let attempt = 0; attempt < 6; attempt++) {
|
||
if (await captionMatches(page, content)) {
|
||
return;
|
||
}
|
||
if (attempt < 5) {
|
||
await page.wait({ time: 0.5 });
|
||
}
|
||
}
|
||
await page.screenshot({ path: '/tmp/instagram_post_caption_fill_debug.png' });
|
||
throw new CommandExecutionError('Instagram caption did not stick before sharing', 'Inspect /tmp/instagram_post_caption_fill_debug.png for the caption editor state');
|
||
}
|
||
async function waitForPublishSuccess(page) {
|
||
let settledStreak = 0;
|
||
for (let attempt = 0; attempt < 90; attempt++) {
|
||
const result = await page.evaluate(buildPublishStatusProbeJs());
|
||
if (result?.failed) {
|
||
await page.screenshot({ path: '/tmp/instagram_post_share_debug.png' });
|
||
throw new CommandExecutionError('Instagram post share failed', 'Inspect /tmp/instagram_post_share_debug.png for the share failure state');
|
||
}
|
||
if (result?.ok) {
|
||
return result.url || '';
|
||
}
|
||
if (result?.settled) {
|
||
settledStreak += 1;
|
||
if (settledStreak >= 3)
|
||
return '';
|
||
}
|
||
else {
|
||
settledStreak = 0;
|
||
}
|
||
if (attempt < 89) {
|
||
await page.wait({ time: 1 });
|
||
}
|
||
}
|
||
await page.screenshot({ path: '/tmp/instagram_post_share_debug.png' });
|
||
throw new CommandExecutionError('Instagram post share confirmation did not appear', 'Inspect /tmp/instagram_post_share_debug.png for the final publish state');
|
||
}
|
||
async function resolveCurrentUserId(page) {
|
||
const cookies = await page.getCookies({ domain: 'instagram.com' });
|
||
return cookies.find((cookie) => cookie.name === 'ds_user_id')?.value || '';
|
||
}
|
||
async function resolveProfileUrl(page, currentUserId = '') {
|
||
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 };
|
||
}
|
||
})()
|
||
`);
|
||
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 };
|
||
})()
|
||
`);
|
||
if (!result?.ok || !result.path)
|
||
return '';
|
||
return new URL(result.path, INSTAGRAM_HOME_URL).toString();
|
||
}
|
||
async function collectVisibleProfilePostPaths(page) {
|
||
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 };
|
||
})()
|
||
`);
|
||
return Array.isArray(result?.hrefs) ? result.hrefs.filter(Boolean) : [];
|
||
}
|
||
async function captureExistingProfilePostPaths(page) {
|
||
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, existingPostPaths) {
|
||
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 '';
|
||
}
|
||
cli({
|
||
site: 'instagram',
|
||
name: 'post',
|
||
access: 'write',
|
||
description: 'Post an Instagram feed image or mixed-media carousel',
|
||
domain: 'www.instagram.com',
|
||
strategy: Strategy.UI,
|
||
browser: true,
|
||
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' },
|
||
{ name: 'timeout', type: 'int', required: false, default: 300, help: 'Max seconds for the overall command (default: 300)' },
|
||
],
|
||
columns: ['status', 'detail', 'url'],
|
||
validateArgs: validateInstagramPostArgs,
|
||
func: async (page, kwargs) => {
|
||
const browserPage = requirePage(page);
|
||
const mediaItems = normalizePostMediaItems(kwargs);
|
||
const content = String(kwargs.content ?? '').trim();
|
||
const existingPostPaths = await captureExistingProfilePostPaths(browserPage);
|
||
const commandAttemptBudget = getCommandAttemptBudget(mediaItems);
|
||
const preUploadDelaySeconds = getPreUploadDelaySeconds(mediaItems);
|
||
const uploadAttemptBudget = getUploadAttemptBudget(mediaItems);
|
||
const previewProbeWindowSeconds = getPreviewProbeWindowSeconds(mediaItems);
|
||
const finalPreviewWaitSeconds = getFinalPreviewWaitSeconds(mediaItems);
|
||
const preShareDelaySeconds = getPreShareDelaySeconds(mediaItems);
|
||
const inlineUploadRetryBudget = getInlineUploadRetryBudget(mediaItems);
|
||
const protocolCaptureEnabled = process.env.OPENCLI_INSTAGRAM_CAPTURE === '1';
|
||
const protocolCaptureData = [];
|
||
const protocolCaptureErrors = [];
|
||
const installProtocolCapture = async () => {
|
||
if (!protocolCaptureEnabled)
|
||
return;
|
||
await installInstagramProtocolCapture(browserPage);
|
||
};
|
||
const drainProtocolCapture = async () => {
|
||
if (!protocolCaptureEnabled)
|
||
return;
|
||
const payload = await readInstagramProtocolCapture(browserPage);
|
||
if (payload.data.length)
|
||
protocolCaptureData.push(...payload.data);
|
||
if (payload.errors.length)
|
||
protocolCaptureErrors.push(...payload.errors);
|
||
};
|
||
try {
|
||
try {
|
||
return await executePrivateInstagramPost({
|
||
page: browserPage,
|
||
mediaItems,
|
||
content,
|
||
existingPostPaths,
|
||
});
|
||
}
|
||
catch (error) {
|
||
if (error instanceof AuthRequiredError || !isSafePrivateRouteFallbackError(error)) {
|
||
throw error;
|
||
}
|
||
try {
|
||
return await executeUiInstagramPost({
|
||
page: browserPage,
|
||
mediaItems,
|
||
content,
|
||
existingPostPaths,
|
||
commandAttemptBudget,
|
||
preUploadDelaySeconds,
|
||
uploadAttemptBudget,
|
||
previewProbeWindowSeconds,
|
||
finalPreviewWaitSeconds,
|
||
preShareDelaySeconds,
|
||
inlineUploadRetryBudget,
|
||
installProtocolCapture,
|
||
drainProtocolCapture,
|
||
forceFreshStart: true,
|
||
});
|
||
}
|
||
catch (uiError) {
|
||
if (uiError instanceof AuthRequiredError)
|
||
throw uiError;
|
||
if (uiError instanceof CommandExecutionError) {
|
||
throw new CommandExecutionError(uiError.message, buildFallbackHint(error, uiError));
|
||
}
|
||
throw uiError;
|
||
}
|
||
}
|
||
}
|
||
finally {
|
||
if (protocolCaptureEnabled) {
|
||
try {
|
||
await drainProtocolCapture();
|
||
}
|
||
catch {
|
||
// Best-effort: capture export should not hide the main command result.
|
||
}
|
||
fs.writeFileSync(INSTAGRAM_PROTOCOL_TRACE_OUTPUT_PATH, JSON.stringify({
|
||
data: protocolCaptureData,
|
||
errors: protocolCaptureErrors,
|
||
}, null, 2));
|
||
}
|
||
}
|
||
},
|
||
});
|