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
828 lines
34 KiB
JavaScript
828 lines
34 KiB
JavaScript
import * as fs from 'node:fs';
|
||
import * as os from 'node:os';
|
||
import * as path from 'node:path';
|
||
import { Page as BrowserPage } from '@jackwener/opencli/browser/page';
|
||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||
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;
|
||
function requirePage(page) {
|
||
if (!page)
|
||
throw new CommandExecutionError('Browser session required for instagram reel');
|
||
return page;
|
||
}
|
||
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);
|
||
}
|
||
function validateVideoPath(input) {
|
||
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) {
|
||
if (kwargs.video === undefined) {
|
||
throw new ArgumentError('Argument "video" is required.', 'Provide --video /path/to/file.mp4');
|
||
}
|
||
}
|
||
function buildInstagramReelSuccessResult(url) {
|
||
return [{
|
||
status: '✅ Posted',
|
||
detail: 'Single reel shared successfully',
|
||
url,
|
||
}];
|
||
}
|
||
function isRecoverableReelSessionError(error) {
|
||
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) {
|
||
const ext = path.extname(filePath).toLowerCase() || '.mp4';
|
||
return path.join(os.tmpdir(), `opencli-instagram-video-real${ext}`);
|
||
}
|
||
function prepareVideoUpload(filePath) {
|
||
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) {
|
||
const result = await page.evaluate(buildEnsureComposerOpenJs());
|
||
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 };
|
||
})()
|
||
`);
|
||
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) {
|
||
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 };
|
||
})()
|
||
`);
|
||
if (!result?.ok)
|
||
return;
|
||
await page.wait({ time: 0.5 });
|
||
}
|
||
}
|
||
async function resolveUploadSelectors(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) => 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 };
|
||
})()
|
||
`);
|
||
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, videoPath, selector) {
|
||
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, selector) {
|
||
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 };
|
||
})()
|
||
`);
|
||
if (result?.count === null || result?.count === undefined)
|
||
return null;
|
||
return Number(result.count);
|
||
}
|
||
async function waitForVideoPreview(page, maxWaitSeconds = 20) {
|
||
let lastDetail = '';
|
||
for (let attempt = 0; attempt < maxWaitSeconds * 2; attempt += 1) {
|
||
const result = await page.evaluate(buildInspectUploadStageJs());
|
||
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 });
|
||
}
|
||
await page.screenshot({ path: '/tmp/instagram_reel_preview_debug.png' });
|
||
throw new CommandExecutionError('Instagram reel preview did not appear after upload', lastDetail
|
||
? `Inspect /tmp/instagram_reel_preview_debug.png. Last visible dialog text: ${lastDetail}`
|
||
: 'Inspect /tmp/instagram_reel_preview_debug.png for the upload state');
|
||
}
|
||
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 clickActionMaybe(page, labels, scope = 'any') {
|
||
const result = await page.evaluate(buildClickActionJs(labels, scope));
|
||
return !!result?.ok;
|
||
}
|
||
function buildInspectReelStageJs() {
|
||
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, expected, maxWaitSeconds = 20) {
|
||
for (let attempt = 0; attempt < maxWaitSeconds * 2; attempt += 1) {
|
||
const result = await page.evaluate(buildInspectReelStageJs());
|
||
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) {
|
||
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 };
|
||
})()
|
||
`);
|
||
return !!result?.ok;
|
||
}
|
||
async function captionMatches(page, content) {
|
||
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 };
|
||
})()
|
||
`);
|
||
return !!result?.ok;
|
||
}
|
||
async function fillCaption(page, content) {
|
||
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, content) {
|
||
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() {
|
||
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) {
|
||
let settledStreak = 0;
|
||
for (let attempt = 0; attempt < 120; attempt += 1) {
|
||
const result = await page.evaluate(buildReelPublishStatusProbeJs());
|
||
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) {
|
||
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();
|
||
}
|
||
}
|
||
return '';
|
||
}
|
||
async function collectVisibleProfileMediaPaths(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*="/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 };
|
||
})()
|
||
`);
|
||
return Array.isArray(result?.hrefs) ? result.hrefs.filter(Boolean) : [];
|
||
}
|
||
async function captureExistingProfileMediaPaths(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 collectVisibleProfileMediaPaths(page));
|
||
}
|
||
catch {
|
||
return new Set();
|
||
}
|
||
}
|
||
async function resolveLatestReelUrl(page, existingPaths) {
|
||
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',
|
||
access: 'write',
|
||
description: 'Post an Instagram reel video',
|
||
domain: 'www.instagram.com',
|
||
strategy: Strategy.UI,
|
||
browser: true,
|
||
args: [
|
||
{ name: 'video', required: false, valueRequired: true, help: 'Path to a single .mp4 video file' },
|
||
{ name: 'content', positional: true, required: false, help: 'Caption text' },
|
||
{ name: 'timeout', type: 'int', required: false, default: INSTAGRAM_REEL_TIMEOUT_SECONDS, help: `Max seconds for the overall command (default: ${INSTAGRAM_REEL_TIMEOUT_SECONDS})` },
|
||
],
|
||
columns: ['status', 'detail', 'url'],
|
||
validateArgs: validateInstagramReelArgs,
|
||
func: async (page, kwargs) => {
|
||
const browserPage = requirePage(page);
|
||
const videoPath = validateVideoPath(kwargs.video);
|
||
const content = String(kwargs.content ?? '').trim();
|
||
const preparedUpload = prepareVideoUpload(videoPath);
|
||
const run = async (activePage, existingMediaPaths = new Set()) => {
|
||
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;
|
||
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 {
|
||
if (!process.env.VITEST) {
|
||
const runIsolated = async () => {
|
||
const isolatedPage = new BrowserPage(`site:instagram-reel-${Date.now()}`);
|
||
try {
|
||
return await run(isolatedPage, new Set());
|
||
}
|
||
finally {
|
||
await isolatedPage.closeWindow?.();
|
||
}
|
||
};
|
||
try {
|
||
return await runIsolated();
|
||
}
|
||
catch (error) {
|
||
if (!isRecoverableReelSessionError(error))
|
||
throw error;
|
||
return await runIsolated();
|
||
}
|
||
}
|
||
const existingMediaPaths = await captureExistingProfileMediaPaths(browserPage);
|
||
return await run(browserPage, existingMediaPaths);
|
||
}
|
||
finally {
|
||
if (preparedUpload.cleanupPath) {
|
||
fs.rmSync(preparedUpload.cleanupPath, { force: true });
|
||
}
|
||
}
|
||
},
|
||
});
|