private-publish.js
1 import * as crypto from 'node:crypto'; 2 import * as fs from 'node:fs'; 3 import * as os from 'node:os'; 4 import * as path from 'node:path'; 5 import { spawnSync } from 'node:child_process'; 6 import { CommandExecutionError } from '@jackwener/opencli/errors'; 7 import { instagramPrivateApiFetch } from './protocol-capture.js'; 8 import { buildReadInstagramRuntimeInfoJs, } from './runtime-info.js'; 9 export { buildReadInstagramRuntimeInfoJs, extractInstagramRuntimeInfo, resolveInstagramRuntimeInfo, } from './runtime-info.js'; 10 const INSTAGRAM_MIN_FEED_ASPECT_RATIO = 4 / 5; 11 const INSTAGRAM_MAX_FEED_ASPECT_RATIO = 1.91; 12 const INSTAGRAM_MIN_STORY_ASPECT_RATIO = 9 / 16; 13 const INSTAGRAM_MAX_STORY_ASPECT_RATIO = 3 / 4; 14 const INSTAGRAM_PRIVATE_PAD_COLOR = 'FFFFFF'; 15 const INSTAGRAM_HOME_URL = 'https://www.instagram.com/'; 16 const INSTAGRAM_PRIVATE_CAPTURE_PATTERN = '/api/v1/|/graphql/'; 17 const INSTAGRAM_PRIVATE_CONFIG_RETRY_BUDGET = 2; 18 const INSTAGRAM_PRIVATE_UPLOAD_RETRY_BUDGET = 2; 19 const INSTAGRAM_PRIVATE_SIDECAR_TRANSCODE_ATTEMPTS = 20; 20 const INSTAGRAM_PRIVATE_SIDECAR_TRANSCODE_WAIT_MS = 2000; 21 const INSTAGRAM_MAX_STORY_VIDEO_DURATION_MS = 15_000; 22 const INSTAGRAM_STORY_SIG_KEY = '19ce5f445dbfd9d29c59dc2a78c616a7fc090a8e018b9267bc4240a30244c53b'; 23 const INSTAGRAM_STORY_SIG_KEY_VERSION = '4'; 24 const INSTAGRAM_STORY_DEVICE = { 25 manufacturer: 'samsung', 26 model: 'SM-G930F', 27 android_version: 24, 28 android_release: '7.0', 29 }; 30 export function derivePrivateApiContextFromCapture(entries) { 31 for (let index = entries.length - 1; index >= 0; index -= 1) { 32 const headers = entries[index]?.requestHeaders ?? {}; 33 const context = { 34 asbdId: String(headers['X-ASBD-ID'] || ''), 35 csrfToken: String(headers['X-CSRFToken'] || ''), 36 igAppId: String(headers['X-IG-App-ID'] || ''), 37 igWwwClaim: String(headers['X-IG-WWW-Claim'] || ''), 38 instagramAjax: String(headers['X-Instagram-AJAX'] || ''), 39 webSessionId: String(headers['X-Web-Session-ID'] || ''), 40 }; 41 if (context.asbdId 42 && context.csrfToken 43 && context.igAppId 44 && context.igWwwClaim 45 && context.instagramAjax 46 && context.webSessionId) { 47 return context; 48 } 49 } 50 return null; 51 } 52 function derivePartialPrivateApiContextFromCapture(entries) { 53 const context = {}; 54 for (let index = entries.length - 1; index >= 0; index -= 1) { 55 const headers = entries[index]?.requestHeaders ?? {}; 56 if (!context.asbdId && headers['X-ASBD-ID']) 57 context.asbdId = String(headers['X-ASBD-ID']); 58 if (!context.csrfToken && headers['X-CSRFToken']) 59 context.csrfToken = String(headers['X-CSRFToken']); 60 if (!context.igAppId && headers['X-IG-App-ID']) 61 context.igAppId = String(headers['X-IG-App-ID']); 62 if (!context.igWwwClaim && headers['X-IG-WWW-Claim']) 63 context.igWwwClaim = String(headers['X-IG-WWW-Claim']); 64 if (!context.instagramAjax && headers['X-Instagram-AJAX']) 65 context.instagramAjax = String(headers['X-Instagram-AJAX']); 66 if (!context.webSessionId && headers['X-Web-Session-ID']) 67 context.webSessionId = String(headers['X-Web-Session-ID']); 68 } 69 return context; 70 } 71 export function deriveInstagramJazoest(value) { 72 if (!value) 73 return ''; 74 const sum = Array.from(value).reduce((total, char) => total + char.charCodeAt(0), 0); 75 return `2${sum}`; 76 } 77 function sleep(ms) { 78 return new Promise((resolve) => setTimeout(resolve, ms)); 79 } 80 function isTransientPrivateFetchError(error) { 81 const message = error instanceof Error ? error.message : String(error); 82 return /fetch failed|network|socket hang up|econnreset|etimedout/i.test(message); 83 } 84 function getCookieValue(cookies, name) { 85 return cookies.find((cookie) => cookie.name === name)?.value || ''; 86 } 87 export async function resolveInstagramPrivatePublishConfig(page) { 88 let lastError; 89 for (let attempt = 0; attempt < INSTAGRAM_PRIVATE_CONFIG_RETRY_BUDGET; attempt += 1) { 90 try { 91 if (typeof page.startNetworkCapture === 'function') { 92 await page.startNetworkCapture(INSTAGRAM_PRIVATE_CAPTURE_PATTERN); 93 } 94 await page.goto(`${INSTAGRAM_HOME_URL}?__opencli_private_probe=${Date.now()}`); 95 await page.wait({ time: 2 }); 96 const [cookies, runtime, entries] = await Promise.all([ 97 page.getCookies({ domain: 'instagram.com' }), 98 page.evaluate(buildReadInstagramRuntimeInfoJs()), 99 typeof page.readNetworkCapture === 'function' 100 ? page.readNetworkCapture() 101 : Promise.resolve([]), 102 ]); 103 const captureEntries = (Array.isArray(entries) ? entries : []); 104 const capturedContext = derivePrivateApiContextFromCapture(captureEntries) 105 ?? derivePartialPrivateApiContextFromCapture(captureEntries); 106 const csrfToken = runtime?.csrfToken || getCookieValue(cookies, 'csrftoken') || capturedContext.csrfToken || ''; 107 const igAppId = runtime?.appId || capturedContext.igAppId || ''; 108 const instagramAjax = runtime?.instagramAjax || capturedContext.instagramAjax || ''; 109 if (!csrfToken) { 110 throw new CommandExecutionError('Instagram private route could not derive CSRF token from browser session'); 111 } 112 if (!igAppId) { 113 throw new CommandExecutionError('Instagram private route could not derive X-IG-App-ID from instagram runtime'); 114 } 115 if (!instagramAjax) { 116 throw new CommandExecutionError('Instagram private route could not derive X-Instagram-AJAX from instagram runtime'); 117 } 118 const asbdId = capturedContext.asbdId || ''; 119 const igWwwClaim = capturedContext.igWwwClaim || ''; 120 const webSessionId = capturedContext.webSessionId || ''; 121 return { 122 apiContext: { 123 asbdId, 124 csrfToken, 125 igAppId, 126 igWwwClaim, 127 instagramAjax, 128 webSessionId, 129 }, 130 jazoest: deriveInstagramJazoest(csrfToken), 131 }; 132 } 133 catch (error) { 134 lastError = error; 135 if (!isTransientPrivateFetchError(error) || attempt >= INSTAGRAM_PRIVATE_CONFIG_RETRY_BUDGET - 1) { 136 throw error; 137 } 138 } 139 } 140 throw lastError instanceof Error ? lastError : new Error(String(lastError)); 141 } 142 export function buildConfigureBody(input) { 143 const body = new URLSearchParams(); 144 body.set('archive_only', 'false'); 145 body.set('caption', input.caption); 146 body.set('clips_share_preview_to_feed', '1'); 147 body.set('disable_comments', '0'); 148 body.set('disable_oa_reuse', 'false'); 149 body.set('igtv_share_preview_to_feed', '1'); 150 body.set('is_meta_only_post', '0'); 151 body.set('is_unified_video', '1'); 152 body.set('like_and_view_counts_disabled', '0'); 153 body.set('media_share_flow', 'creation_flow'); 154 body.set('share_to_facebook', ''); 155 body.set('share_to_fb_destination_type', 'USER'); 156 body.set('source_type', 'library'); 157 body.set('upload_id', input.uploadId); 158 body.set('video_subtitles_enabled', '0'); 159 body.set('jazoest', input.jazoest); 160 return body.toString(); 161 } 162 export function buildConfigureSidecarPayload(input) { 163 return { 164 archive_only: false, 165 caption: input.caption, 166 children_metadata: input.uploadIds.map((uploadId) => ({ upload_id: uploadId })), 167 client_sidecar_id: input.clientSidecarId, 168 disable_comments: '0', 169 is_meta_only_post: false, 170 is_open_to_public_submission: false, 171 like_and_view_counts_disabled: 0, 172 media_share_flow: 'creation_flow', 173 share_to_facebook: '', 174 share_to_fb_destination_type: 'USER', 175 source_type: 'library', 176 jazoest: input.jazoest, 177 }; 178 } 179 function inferMimeType(filePath) { 180 const ext = path.extname(filePath).toLowerCase(); 181 switch (ext) { 182 case '.png': 183 return 'image/png'; 184 case '.webp': 185 return 'image/webp'; 186 case '.jpg': 187 case '.jpeg': 188 default: 189 return 'image/jpeg'; 190 } 191 } 192 function readPngDimensions(bytes) { 193 if (bytes.length < 24) 194 return null; 195 if (bytes.subarray(0, 8).toString('hex').toUpperCase() !== '89504E470D0A1A0A') 196 return null; 197 if (bytes.subarray(12, 16).toString('ascii') !== 'IHDR') 198 return null; 199 return { 200 width: bytes.readUInt32BE(16), 201 height: bytes.readUInt32BE(20), 202 }; 203 } 204 function readJpegDimensions(bytes) { 205 if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) 206 return null; 207 let offset = 2; 208 while (offset + 9 < bytes.length) { 209 if (bytes[offset] !== 0xff) { 210 offset += 1; 211 continue; 212 } 213 const marker = bytes[offset + 1]; 214 offset += 2; 215 if (marker === 0xd8 || marker === 0xd9) 216 continue; 217 if (offset + 2 > bytes.length) 218 break; 219 const segmentLength = bytes.readUInt16BE(offset); 220 if (segmentLength < 2 || offset + segmentLength > bytes.length) 221 break; 222 const isStartOfFrame = marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker); 223 if (isStartOfFrame && segmentLength >= 7) { 224 return { 225 height: bytes.readUInt16BE(offset + 3), 226 width: bytes.readUInt16BE(offset + 5), 227 }; 228 } 229 offset += segmentLength; 230 } 231 return null; 232 } 233 function readWebpDimensions(bytes) { 234 if (bytes.length < 30) 235 return null; 236 if (bytes.subarray(0, 4).toString('ascii') !== 'RIFF' || bytes.subarray(8, 12).toString('ascii') !== 'WEBP') { 237 return null; 238 } 239 const chunkType = bytes.subarray(12, 16).toString('ascii'); 240 if (chunkType === 'VP8X' && bytes.length >= 30) { 241 return { 242 width: 1 + bytes.readUIntLE(24, 3), 243 height: 1 + bytes.readUIntLE(27, 3), 244 }; 245 } 246 if (chunkType === 'VP8 ' && bytes.length >= 30) { 247 return { 248 width: bytes.readUInt16LE(26) & 0x3fff, 249 height: bytes.readUInt16LE(28) & 0x3fff, 250 }; 251 } 252 if (chunkType === 'VP8L' && bytes.length >= 25) { 253 const bits = bytes.readUInt32LE(21); 254 return { 255 width: (bits & 0x3fff) + 1, 256 height: ((bits >> 14) & 0x3fff) + 1, 257 }; 258 } 259 return null; 260 } 261 function readImageDimensions(filePath, bytes) { 262 const ext = path.extname(filePath).toLowerCase(); 263 const dimensions = ext === '.png' 264 ? readPngDimensions(bytes) 265 : ext === '.webp' 266 ? readWebpDimensions(bytes) 267 : readJpegDimensions(bytes); 268 if (!dimensions) { 269 throw new CommandExecutionError(`Failed to read image dimensions for ${filePath}`); 270 } 271 return dimensions; 272 } 273 export function readImageAsset(filePath) { 274 const bytes = fs.readFileSync(filePath); 275 const { width, height } = readImageDimensions(filePath, bytes); 276 return { 277 filePath, 278 fileName: path.basename(filePath), 279 mimeType: inferMimeType(filePath), 280 width, 281 height, 282 byteLength: bytes.length, 283 bytes, 284 }; 285 } 286 export function isInstagramFeedAspectRatioAllowed(width, height) { 287 const ratio = width / Math.max(height, 1); 288 return ratio >= INSTAGRAM_MIN_FEED_ASPECT_RATIO - 0.001 289 && ratio <= INSTAGRAM_MAX_FEED_ASPECT_RATIO + 0.001; 290 } 291 export function getInstagramFeedNormalizedDimensions(width, height) { 292 const ratio = width / Math.max(height, 1); 293 if (ratio < INSTAGRAM_MIN_FEED_ASPECT_RATIO) { 294 return { 295 width: Math.ceil(height * INSTAGRAM_MIN_FEED_ASPECT_RATIO), 296 height, 297 }; 298 } 299 if (ratio > INSTAGRAM_MAX_FEED_ASPECT_RATIO) { 300 return { 301 width, 302 height: Math.ceil(width / INSTAGRAM_MAX_FEED_ASPECT_RATIO), 303 }; 304 } 305 return null; 306 } 307 export function isInstagramStoryAspectRatioAllowed(width, height) { 308 const ratio = width / Math.max(height, 1); 309 return ratio >= INSTAGRAM_MIN_STORY_ASPECT_RATIO - 0.001 310 && ratio <= INSTAGRAM_MAX_STORY_ASPECT_RATIO + 0.001; 311 } 312 export function getInstagramStoryNormalizedDimensions(width, height) { 313 const ratio = width / Math.max(height, 1); 314 if (ratio < INSTAGRAM_MIN_STORY_ASPECT_RATIO) { 315 return { 316 width: Math.ceil(height * INSTAGRAM_MIN_STORY_ASPECT_RATIO), 317 height, 318 }; 319 } 320 if (ratio > INSTAGRAM_MAX_STORY_ASPECT_RATIO) { 321 return { 322 width, 323 height: Math.ceil(width / INSTAGRAM_MAX_STORY_ASPECT_RATIO), 324 }; 325 } 326 return null; 327 } 328 function buildPrivateNormalizedImagePath(filePath) { 329 const parsed = path.parse(filePath); 330 return path.join(os.tmpdir(), `opencli-instagram-private-${parsed.name}-${crypto.randomUUID()}${parsed.ext || '.png'}`); 331 } 332 export function prepareImageAssetForPrivateUpload(filePath) { 333 const asset = readImageAsset(filePath); 334 const normalizedDimensions = getInstagramFeedNormalizedDimensions(asset.width, asset.height); 335 if (!normalizedDimensions) { 336 return asset; 337 } 338 if (process.platform !== 'darwin') { 339 throw new CommandExecutionError(`Instagram private publish does not support auto-normalizing ${asset.fileName} on ${process.platform}`, `Use images within ${INSTAGRAM_MIN_FEED_ASPECT_RATIO.toFixed(2)}-${INSTAGRAM_MAX_FEED_ASPECT_RATIO.toFixed(2)} aspect ratio, or use the UI route`); 340 } 341 const outputPath = buildPrivateNormalizedImagePath(filePath); 342 const result = spawnSync('sips', [ 343 '--padToHeightWidth', 344 String(normalizedDimensions.height), 345 String(normalizedDimensions.width), 346 '--padColor', 347 INSTAGRAM_PRIVATE_PAD_COLOR, 348 filePath, 349 '--out', 350 outputPath, 351 ], { 352 encoding: 'utf8', 353 }); 354 if (result.error || result.status !== 0 || !fs.existsSync(outputPath)) { 355 const detail = [result.error?.message, result.stderr, result.stdout] 356 .map((value) => String(value || '').trim()) 357 .filter(Boolean) 358 .join(' '); 359 throw new CommandExecutionError(`Instagram private publish failed to normalize ${asset.fileName}`, detail || 'sips padToHeightWidth failed'); 360 } 361 return { 362 ...readImageAsset(outputPath), 363 cleanupPath: outputPath, 364 }; 365 } 366 export function prepareImageAssetForPrivateStoryUpload(filePath) { 367 const asset = readImageAsset(filePath); 368 const normalizedDimensions = getInstagramStoryNormalizedDimensions(asset.width, asset.height); 369 if (!normalizedDimensions) { 370 return asset; 371 } 372 if (process.platform !== 'darwin') { 373 throw new CommandExecutionError(`Instagram private story publish does not support auto-normalizing ${asset.fileName} on ${process.platform}`, `Use images within ${INSTAGRAM_MIN_STORY_ASPECT_RATIO.toFixed(2)}-${INSTAGRAM_MAX_STORY_ASPECT_RATIO.toFixed(2)} aspect ratio, or use the UI route`); 374 } 375 const outputPath = buildPrivateNormalizedImagePath(filePath); 376 const result = spawnSync('sips', [ 377 '--padToHeightWidth', 378 String(normalizedDimensions.height), 379 String(normalizedDimensions.width), 380 '--padColor', 381 INSTAGRAM_PRIVATE_PAD_COLOR, 382 filePath, 383 '--out', 384 outputPath, 385 ], { 386 encoding: 'utf8', 387 }); 388 if (result.error || result.status !== 0 || !fs.existsSync(outputPath)) { 389 const detail = [result.error?.message, result.stderr, result.stdout] 390 .map((value) => String(value || '').trim()) 391 .filter(Boolean) 392 .join(' '); 393 throw new CommandExecutionError(`Instagram private story publish failed to normalize ${asset.fileName}`, detail || 'sips padToHeightWidth failed'); 394 } 395 return { 396 ...readImageAsset(outputPath), 397 cleanupPath: outputPath, 398 }; 399 } 400 function runSwiftJsonScript(script, args, stage) { 401 const scriptPath = path.join(os.tmpdir(), `opencli-instagram-${crypto.randomUUID()}.swift`); 402 fs.writeFileSync(scriptPath, script); 403 try { 404 const result = spawnSync('swift', [scriptPath, ...args], { 405 encoding: 'utf8', 406 }); 407 if (result.error || result.status !== 0) { 408 const detail = [result.error?.message, result.stderr, result.stdout] 409 .map((value) => String(value || '').trim()) 410 .filter(Boolean) 411 .join(' '); 412 throw new CommandExecutionError(`Instagram private publish failed to ${stage}`, detail || 'swift helper failed'); 413 } 414 return JSON.parse(String(result.stdout || '{}')); 415 } 416 catch (error) { 417 if (error instanceof CommandExecutionError) 418 throw error; 419 throw new CommandExecutionError(`Instagram private publish failed to ${stage}`, error instanceof Error ? error.message : String(error)); 420 } 421 finally { 422 fs.rmSync(scriptPath, { force: true }); 423 } 424 } 425 function readVideoMetadata(filePath) { 426 if (process.platform !== 'darwin') { 427 throw new CommandExecutionError(`Instagram private mixed-media publish does not support reading video metadata on ${process.platform}`, 'Use macOS for private mixed-media publishing, or rely on the UI fallback'); 428 } 429 const metadata = runSwiftJsonScript(` 430 import AVFoundation 431 import Foundation 432 433 let path = CommandLine.arguments[1] 434 let url = URL(fileURLWithPath: path) 435 let asset = AVURLAsset(url: url) 436 guard let track = asset.tracks(withMediaType: .video).first else { 437 fputs("{\\"error\\":\\"missing-video-track\\"}", stderr) 438 exit(1) 439 } 440 let transformed = track.naturalSize.applying(track.preferredTransform) 441 let width = Int(abs(transformed.width.rounded())) 442 let height = Int(abs(transformed.height.rounded())) 443 let durationMs = Int((CMTimeGetSeconds(asset.duration) * 1000.0).rounded()) 444 let payload: [String: Int] = [ 445 "width": width, 446 "height": height, 447 "durationMs": durationMs, 448 ] 449 let data = try JSONSerialization.data(withJSONObject: payload, options: []) 450 FileHandle.standardOutput.write(data) 451 `, [filePath], 'read video metadata'); 452 if (!metadata.width || !metadata.height || !metadata.durationMs) { 453 throw new CommandExecutionError(`Instagram private publish failed to read video metadata for ${filePath}`); 454 } 455 return { 456 width: metadata.width, 457 height: metadata.height, 458 durationMs: metadata.durationMs, 459 }; 460 } 461 function buildPrivateVideoCoverPath(filePath) { 462 const parsed = path.parse(filePath); 463 return path.join(os.tmpdir(), `opencli-instagram-private-video-cover-${parsed.name}-${crypto.randomUUID()}.jpg`); 464 } 465 function buildPrivateStoryVideoPath(filePath) { 466 const parsed = path.parse(filePath); 467 return path.join(os.tmpdir(), `opencli-instagram-story-video-${parsed.name}-${crypto.randomUUID()}${parsed.ext || '.mp4'}`); 468 } 469 function generateVideoCoverImage(filePath) { 470 if (process.platform !== 'darwin') { 471 throw new CommandExecutionError(`Instagram private mixed-media publish does not support generating video covers on ${process.platform}`, 'Use macOS for private mixed-media publishing, or rely on the UI fallback'); 472 } 473 const outputPath = buildPrivateVideoCoverPath(filePath); 474 runSwiftJsonScript(` 475 import AVFoundation 476 import AppKit 477 import Foundation 478 479 let inputPath = CommandLine.arguments[1] 480 let outputPath = CommandLine.arguments[2] 481 let asset = AVURLAsset(url: URL(fileURLWithPath: inputPath)) 482 let generator = AVAssetImageGenerator(asset: asset) 483 generator.appliesPreferredTrackTransform = true 484 let image = try generator.copyCGImage(at: CMTime(seconds: 0, preferredTimescale: 600), actualTime: nil) 485 let rep = NSBitmapImageRep(cgImage: image) 486 guard let data = rep.representation(using: .jpeg, properties: [.compressionFactor: 0.9]) else { 487 fputs("{\\"error\\":\\"jpeg-encode-failed\\"}", stderr) 488 exit(1) 489 } 490 try data.write(to: URL(fileURLWithPath: outputPath)) 491 let payload = ["ok": true] 492 let json = try JSONSerialization.data(withJSONObject: payload, options: []) 493 FileHandle.standardOutput.write(json) 494 `, [filePath, outputPath], 'generate video cover'); 495 return { 496 ...readImageAsset(outputPath), 497 cleanupPath: outputPath, 498 }; 499 } 500 export function readVideoAsset(filePath) { 501 const bytes = fs.readFileSync(filePath); 502 const metadata = readVideoMetadata(filePath); 503 const coverImage = generateVideoCoverImage(filePath); 504 return { 505 filePath, 506 fileName: path.basename(filePath), 507 mimeType: 'video/mp4', 508 width: metadata.width, 509 height: metadata.height, 510 durationMs: metadata.durationMs, 511 byteLength: bytes.length, 512 bytes, 513 coverImage, 514 cleanupPaths: coverImage.cleanupPath ? [coverImage.cleanupPath] : [], 515 }; 516 } 517 function trimVideoForInstagramStory(filePath, maxDurationMs) { 518 if (process.platform !== 'darwin') { 519 throw new CommandExecutionError(`Instagram private story publish does not support trimming long videos on ${process.platform}`, 'Use macOS for private story video publishing, or trim the video to 15 seconds first'); 520 } 521 const outputPath = buildPrivateStoryVideoPath(filePath); 522 runSwiftJsonScript(` 523 import AVFoundation 524 import Foundation 525 526 let inputPath = CommandLine.arguments[1] 527 let outputPath = CommandLine.arguments[2] 528 let durationMs = Int(CommandLine.arguments[3]) ?? 15000 529 let asset = AVURLAsset(url: URL(fileURLWithPath: inputPath)) 530 guard let exportSession = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else { 531 fputs("{\\"error\\":\\"missing-export-session\\"}", stderr) 532 exit(1) 533 } 534 exportSession.outputURL = URL(fileURLWithPath: outputPath) 535 exportSession.outputFileType = .mp4 536 exportSession.shouldOptimizeForNetworkUse = true 537 exportSession.timeRange = CMTimeRange( 538 start: .zero, 539 duration: CMTime(seconds: Double(durationMs) / 1000.0, preferredTimescale: 600) 540 ) 541 let semaphore = DispatchSemaphore(value: 0) 542 exportSession.exportAsynchronously { 543 semaphore.signal() 544 } 545 semaphore.wait() 546 if exportSession.status != .completed { 547 let message = exportSession.error?.localizedDescription ?? "export-failed" 548 fputs(message, stderr) 549 exit(1) 550 } 551 let payload = ["ok": true] 552 let json = try JSONSerialization.data(withJSONObject: payload, options: []) 553 FileHandle.standardOutput.write(json) 554 `, [filePath, outputPath, String(maxDurationMs)], 'trim story video'); 555 return outputPath; 556 } 557 function prepareVideoAssetForPrivateStoryUpload(filePath) { 558 const asset = readVideoAsset(filePath); 559 if (asset.durationMs <= INSTAGRAM_MAX_STORY_VIDEO_DURATION_MS) { 560 return asset; 561 } 562 const trimmedPath = trimVideoForInstagramStory(filePath, INSTAGRAM_MAX_STORY_VIDEO_DURATION_MS); 563 const trimmedAsset = readVideoAsset(trimmedPath); 564 return { 565 ...trimmedAsset, 566 cleanupPaths: [ 567 ...(trimmedAsset.cleanupPaths || []), 568 trimmedPath, 569 ], 570 }; 571 } 572 function toUnixSeconds(now) { 573 const value = now(); 574 return value > 10_000_000_000 ? Math.floor(value / 1000) : Math.floor(value); 575 } 576 export function buildConfigureToStoryPhotoPayload(input) { 577 const now = input.now ?? (() => Date.now()); 578 const timestamp = toUnixSeconds(now); 579 return { 580 source_type: '4', 581 upload_id: input.uploadId, 582 story_media_creation_date: String(timestamp - 17), 583 client_shared_at: String(timestamp - 5), 584 client_timestamp: String(timestamp), 585 configure_mode: 1, 586 edits: { 587 crop_original_size: [input.width, input.height], 588 crop_center: [0, 0], 589 crop_zoom: 1.3333334, 590 }, 591 extra: { 592 source_width: input.width, 593 source_height: input.height, 594 }, 595 jazoest: input.jazoest, 596 }; 597 } 598 export function buildConfigureToStoryVideoPayload(input) { 599 const now = input.now ?? (() => Date.now()); 600 const timestamp = toUnixSeconds(now); 601 const durationSeconds = Number((input.durationMs / 1000).toFixed(3)); 602 return { 603 source_type: '4', 604 upload_id: input.uploadId, 605 story_media_creation_date: String(timestamp - 17), 606 client_shared_at: String(timestamp - 5), 607 client_timestamp: String(timestamp), 608 configure_mode: 1, 609 poster_frame_index: 0, 610 length: durationSeconds, 611 audio_muted: false, 612 filter_type: '0', 613 video_result: 'deprecated', 614 extra: { 615 source_width: input.width, 616 source_height: input.height, 617 }, 618 jazoest: input.jazoest, 619 }; 620 } 621 function buildFormEncodedBodyFromPayload(payload) { 622 const body = new URLSearchParams(); 623 for (const [key, value] of Object.entries(payload)) { 624 if (value === undefined || value === null) 625 continue; 626 if (typeof value === 'object') { 627 body.set(key, JSON.stringify(value)); 628 continue; 629 } 630 body.set(key, String(value)); 631 } 632 return body.toString(); 633 } 634 function buildSignedBody(payload) { 635 const jsonPayload = JSON.stringify(payload); 636 const signature = crypto 637 .createHmac('sha256', INSTAGRAM_STORY_SIG_KEY) 638 .update(jsonPayload) 639 .digest('hex'); 640 const body = new URLSearchParams(); 641 body.set('ig_sig_key_version', INSTAGRAM_STORY_SIG_KEY_VERSION); 642 body.set('signed_body', `${signature}.${jsonPayload}`); 643 return body.toString(); 644 } 645 function buildPrivateApiHeaders(context) { 646 return Object.fromEntries(Object.entries({ 647 'X-ASBD-ID': context.asbdId, 648 'X-CSRFToken': context.csrfToken, 649 'X-IG-App-ID': context.igAppId, 650 'X-IG-WWW-Claim': context.igWwwClaim, 651 'X-Instagram-AJAX': context.instagramAjax, 652 'X-Web-Session-ID': context.webSessionId, 653 }).filter(([, value]) => !!value)); 654 } 655 function buildRuploadHeaders(asset, uploadId, context) { 656 return { 657 ...buildPrivateApiHeaders(context), 658 'Accept': '*/*', 659 'Content-Type': asset.mimeType, 660 'Offset': '0', 661 'X-Entity-Length': String(asset.byteLength), 662 'X-Entity-Name': `fb_uploader_${uploadId}`, 663 'X-Entity-Type': asset.mimeType, 664 'X-Instagram-Rupload-Params': JSON.stringify({ 665 media_type: 1, 666 upload_id: uploadId, 667 upload_media_height: asset.height, 668 upload_media_width: asset.width, 669 }), 670 }; 671 } 672 function buildVideoEditParams(asset) { 673 const cropSize = Math.min(asset.width, asset.height); 674 const trimEndSeconds = Number((asset.durationMs / 1000).toFixed(3)); 675 return { 676 crop_height: cropSize, 677 crop_width: cropSize, 678 crop_x1: Math.max(0, Math.floor((asset.width - cropSize) / 2)), 679 crop_y1: Math.max(0, Math.floor((asset.height - cropSize) / 2)), 680 mute: false, 681 trim_end: trimEndSeconds, 682 trim_start: 0, 683 }; 684 } 685 function buildVideoRuploadHeaders(asset, uploadId, context) { 686 return { 687 ...buildPrivateApiHeaders(context), 688 'Accept': '*/*', 689 'Offset': '0', 690 'X-Entity-Length': String(asset.byteLength), 691 'X-Entity-Name': `fb_uploader_${uploadId}`, 692 'X-Instagram-Rupload-Params': JSON.stringify({ 693 'client-passthrough': '1', 694 'is_unified_video': '0', 695 'is_sidecar': '1', 696 'media_type': 2, 697 'for_album': false, 698 'video_format': '', 699 'upload_id': uploadId, 700 'upload_media_duration_ms': asset.durationMs, 701 'upload_media_height': asset.height, 702 'upload_media_width': asset.width, 703 'video_transform': null, 704 'video_edit_params': buildVideoEditParams(asset), 705 }), 706 }; 707 } 708 function buildStoryVideoRuploadHeaders(asset, uploadId, context) { 709 return { 710 ...buildPrivateApiHeaders(context), 711 'Accept': '*/*', 712 'Offset': '0', 713 'X-Entity-Length': String(asset.byteLength), 714 'X-Entity-Name': `fb_uploader_${uploadId}`, 715 'X-Instagram-Rupload-Params': JSON.stringify({ 716 'client-passthrough': '1', 717 'media_type': 2, 718 'upload_id': uploadId, 719 'upload_media_duration_ms': asset.durationMs, 720 'upload_media_height': asset.height, 721 'upload_media_width': asset.width, 722 'video_transform': null, 723 'video_edit_params': buildVideoEditParams(asset), 724 }), 725 }; 726 } 727 function buildVideoCoverRuploadHeaders(asset, uploadId, context) { 728 return { 729 ...buildPrivateApiHeaders(context), 730 'Accept': '*/*', 731 'Content-Type': asset.coverImage.mimeType, 732 'Offset': '0', 733 'X-Entity-Length': String(asset.coverImage.byteLength), 734 'X-Entity-Name': `fb_uploader_${uploadId}`, 735 'X-Entity-Type': asset.coverImage.mimeType, 736 'X-Instagram-Rupload-Params': JSON.stringify({ 737 media_type: 2, 738 upload_id: uploadId, 739 upload_media_height: asset.height, 740 upload_media_width: asset.width, 741 }), 742 }; 743 } 744 async function parseJsonResponse(response, stage) { 745 const text = await response.text(); 746 let data; 747 try { 748 data = text ? JSON.parse(text) : {}; 749 } 750 catch { 751 throw new CommandExecutionError(`Instagram private publish ${stage} returned invalid JSON`); 752 } 753 if (!response.ok) { 754 const detail = text ? ` ${text.slice(0, 500)}` : ''; 755 throw new CommandExecutionError(`Instagram private publish ${stage} failed: ${response.status}${detail}`); 756 } 757 return data; 758 } 759 async function fetchPrivateUploadWithRetry(fetcher, url, init) { 760 let lastError; 761 for (let attempt = 0; attempt < INSTAGRAM_PRIVATE_UPLOAD_RETRY_BUDGET; attempt += 1) { 762 try { 763 return await fetcher(url, init); 764 } 765 catch (error) { 766 lastError = error; 767 if (!isTransientPrivateFetchError(error) || attempt >= INSTAGRAM_PRIVATE_UPLOAD_RETRY_BUDGET - 1) { 768 throw error; 769 } 770 } 771 } 772 throw lastError instanceof Error ? lastError : new Error(String(lastError)); 773 } 774 async function prepareInstagramMediaAsset(item) { 775 if (item.type === 'video') { 776 return { 777 type: 'video', 778 asset: readVideoAsset(item.filePath), 779 }; 780 } 781 return { 782 type: 'image', 783 asset: prepareImageAssetForPrivateUpload(item.filePath), 784 }; 785 } 786 function cleanupPreparedMediaAssets(assets) { 787 for (const prepared of assets) { 788 if (prepared.type === 'image') { 789 if (prepared.asset.cleanupPath) { 790 fs.rmSync(prepared.asset.cleanupPath, { force: true }); 791 } 792 continue; 793 } 794 for (const cleanupPath of prepared.asset.cleanupPaths || []) { 795 fs.rmSync(cleanupPath, { force: true }); 796 } 797 if (prepared.asset.coverImage.cleanupPath) { 798 fs.rmSync(prepared.asset.coverImage.cleanupPath, { force: true }); 799 } 800 } 801 } 802 async function uploadPreparedMediaAsset(fetcher, prepared, uploadId, context, mode = 'feed') { 803 if (prepared.type === 'image') { 804 const response = await fetchPrivateUploadWithRetry(fetcher, `https://i.instagram.com/rupload_igphoto/fb_uploader_${uploadId}`, { 805 method: 'POST', 806 headers: buildRuploadHeaders(prepared.asset, uploadId, context), 807 body: prepared.asset.bytes, 808 }); 809 const json = await parseJsonResponse(response, 'upload'); 810 if (String(json?.status || '') !== 'ok') { 811 throw new CommandExecutionError(`Instagram private publish upload failed for ${prepared.asset.fileName}`); 812 } 813 return; 814 } 815 const videoResponse = await fetchPrivateUploadWithRetry(fetcher, `https://i.instagram.com/rupload_igvideo/fb_uploader_${uploadId}`, { 816 method: 'POST', 817 headers: mode === 'story' 818 ? buildStoryVideoRuploadHeaders(prepared.asset, uploadId, context) 819 : buildVideoRuploadHeaders(prepared.asset, uploadId, context), 820 body: prepared.asset.bytes, 821 }); 822 const videoJson = await parseJsonResponse(videoResponse, 'video upload'); 823 if (String(videoJson?.status || '') !== 'ok') { 824 throw new CommandExecutionError(`Instagram private publish video upload failed for ${prepared.asset.fileName}`); 825 } 826 const coverResponse = await fetchPrivateUploadWithRetry(fetcher, `https://i.instagram.com/rupload_igphoto/fb_uploader_${uploadId}`, { 827 method: 'POST', 828 headers: buildVideoCoverRuploadHeaders(prepared.asset, uploadId, context), 829 body: prepared.asset.coverImage.bytes, 830 }); 831 const coverJson = await parseJsonResponse(coverResponse, 'video cover upload'); 832 if (String(coverJson?.status || '') !== 'ok') { 833 throw new CommandExecutionError(`Instagram private publish video cover upload failed for ${prepared.asset.fileName}`); 834 } 835 } 836 async function publishSidecarWithRetry(input) { 837 const waitMs = input.waitMs ?? sleep; 838 const requestInit = { 839 method: 'POST', 840 headers: { 841 ...buildPrivateApiHeaders(input.apiContext), 842 'Content-Type': 'application/json', 843 }, 844 body: JSON.stringify(input.payload), 845 }; 846 for (let attempt = 0; attempt < INSTAGRAM_PRIVATE_SIDECAR_TRANSCODE_ATTEMPTS; attempt += 1) { 847 const response = await input.fetcher('https://www.instagram.com/api/v1/media/configure_sidecar/', requestInit); 848 const text = await response.text(); 849 let json = {}; 850 try { 851 json = text ? JSON.parse(text) : {}; 852 } 853 catch { 854 throw new CommandExecutionError('Instagram private publish configure_sidecar returned invalid JSON'); 855 } 856 if (!response.ok) { 857 const detail = text ? ` ${text.slice(0, 500)}` : ''; 858 throw new CommandExecutionError(`Instagram private publish configure_sidecar failed: ${response.status}${detail}`); 859 } 860 const message = String(json?.message || ''); 861 if (response.status === 202 862 || /transcode not finished yet/i.test(message)) { 863 if (attempt >= INSTAGRAM_PRIVATE_SIDECAR_TRANSCODE_ATTEMPTS - 1) { 864 throw new CommandExecutionError('Instagram private publish configure_sidecar timed out waiting for video transcode', text.slice(0, 500)); 865 } 866 await waitMs(INSTAGRAM_PRIVATE_SIDECAR_TRANSCODE_WAIT_MS); 867 continue; 868 } 869 if (String(json?.status || '').toLowerCase() === 'fail') { 870 throw new CommandExecutionError('Instagram private publish configure_sidecar failed', message || text.slice(0, 500)); 871 } 872 return { code: json?.media?.code }; 873 } 874 throw new CommandExecutionError('Instagram private publish configure_sidecar failed'); 875 } 876 export async function publishMediaViaPrivateApi(input) { 877 const now = input.now ?? (() => Date.now()); 878 const clientSidecarId = String(now()); 879 const uploadIds = input.mediaItems.length > 1 880 ? input.mediaItems.map((_, index) => String(now() + index + 1)) 881 : [String(now())]; 882 const fetcher = input.fetcher ?? ((url, init) => instagramPrivateApiFetch(input.page, url, init)); 883 const prepareMediaAsset = input.prepareMediaAsset ?? prepareInstagramMediaAsset; 884 const assets = await Promise.all(input.mediaItems.map((item) => prepareMediaAsset(item))); 885 try { 886 for (let index = 0; index < assets.length; index += 1) { 887 const asset = assets[index]; 888 const uploadId = uploadIds[index]; 889 await uploadPreparedMediaAsset(fetcher, asset, uploadId, input.apiContext); 890 } 891 if (uploadIds.length === 1) { 892 if (assets[0]?.type !== 'image') { 893 throw new CommandExecutionError('Instagram private publish only supports single-video uploads through instagram reel'); 894 } 895 const response = await fetcher('https://www.instagram.com/api/v1/media/configure/', { 896 method: 'POST', 897 headers: { 898 ...buildPrivateApiHeaders(input.apiContext), 899 'Content-Type': 'application/x-www-form-urlencoded', 900 }, 901 body: buildConfigureBody({ 902 uploadId: uploadIds[0], 903 caption: input.caption, 904 jazoest: input.jazoest, 905 }), 906 }); 907 const json = await parseJsonResponse(response, 'configure'); 908 return { code: json?.media?.code, uploadIds }; 909 } 910 const result = await publishSidecarWithRetry({ 911 fetcher, 912 payload: buildConfigureSidecarPayload({ 913 uploadIds, 914 caption: input.caption, 915 clientSidecarId, 916 jazoest: input.jazoest, 917 }), 918 apiContext: input.apiContext, 919 waitMs: input.waitMs, 920 }); 921 return { code: result.code, uploadIds }; 922 } 923 finally { 924 cleanupPreparedMediaAssets(assets); 925 } 926 } 927 export async function publishImagesViaPrivateApi(input) { 928 return publishMediaViaPrivateApi({ 929 page: input.page, 930 mediaItems: input.imagePaths.map((filePath) => ({ type: 'image', filePath })), 931 caption: input.caption, 932 apiContext: input.apiContext, 933 jazoest: input.jazoest, 934 now: input.now, 935 fetcher: input.fetcher, 936 waitMs: input.waitMs, 937 prepareMediaAsset: input.prepareAsset 938 ? async (item) => ({ 939 type: 'image', 940 asset: await input.prepareAsset(item.filePath), 941 }) 942 : undefined, 943 }); 944 } 945 export async function publishStoryViaPrivateApi(input) { 946 const now = input.now ?? (() => Date.now()); 947 const uploadId = String(now()); 948 const fetcher = input.fetcher ?? ((url, init) => instagramPrivateApiFetch(input.page, url, init)); 949 const prepareMediaAsset = input.prepareMediaAsset ?? (async (item) => item.type === 'video' 950 ? { type: 'video', asset: prepareVideoAssetForPrivateStoryUpload(item.filePath) } 951 : { type: 'image', asset: prepareImageAssetForPrivateStoryUpload(item.filePath) }); 952 const prepared = await prepareMediaAsset(input.mediaItem); 953 const currentUserId = input.currentUserId 954 || ('getCookies' in input.page 955 ? String((await (input.page.getCookies?.({ domain: 'instagram.com' }) ?? Promise.resolve([]))) 956 .find((cookie) => cookie.name === 'ds_user_id')?.value || '') 957 : ''); 958 if (!currentUserId) { 959 throw new CommandExecutionError('Instagram story publish could not derive current user id from browser session'); 960 } 961 const signedPayloadBase = { 962 _csrftoken: input.apiContext.csrfToken, 963 _uid: currentUserId, 964 _uuid: crypto.randomUUID(), 965 device: INSTAGRAM_STORY_DEVICE, 966 }; 967 const buildSignedStoryPhotoBody = (width, height) => buildSignedBody({ 968 ...buildConfigureToStoryPhotoPayload({ 969 uploadId, 970 width, 971 height, 972 now, 973 jazoest: input.jazoest, 974 }), 975 ...signedPayloadBase, 976 }); 977 const buildSignedStoryVideoBody = (width, height, durationMs) => buildSignedBody({ 978 ...buildConfigureToStoryVideoPayload({ 979 uploadId, 980 width, 981 height, 982 durationMs, 983 now, 984 jazoest: input.jazoest, 985 }), 986 ...signedPayloadBase, 987 }); 988 try { 989 await uploadPreparedMediaAsset(fetcher, prepared, uploadId, input.apiContext, 'story'); 990 if (prepared.type === 'image') { 991 const response = await fetcher('https://i.instagram.com/api/v1/media/configure_to_story/', { 992 method: 'POST', 993 headers: { 994 ...buildPrivateApiHeaders(input.apiContext), 995 'Content-Type': 'application/x-www-form-urlencoded', 996 }, 997 body: buildSignedStoryPhotoBody(prepared.asset.width, prepared.asset.height), 998 }); 999 const json = await parseJsonResponse(response, 'configure_to_story'); 1000 return { 1001 mediaPk: String(json?.media?.pk || json?.media?.id || '').split('_')[0] || undefined, 1002 uploadId, 1003 }; 1004 } 1005 await parseJsonResponse(await fetcher('https://i.instagram.com/api/v1/media/configure_to_story/', { 1006 method: 'POST', 1007 headers: { 1008 ...buildPrivateApiHeaders(input.apiContext), 1009 'Content-Type': 'application/x-www-form-urlencoded', 1010 }, 1011 body: buildSignedStoryPhotoBody(prepared.asset.width, prepared.asset.height), 1012 }), 'configure_to_story cover'); 1013 const response = await fetcher('https://i.instagram.com/api/v1/media/configure_to_story/?video=1', { 1014 method: 'POST', 1015 headers: { 1016 ...buildPrivateApiHeaders(input.apiContext), 1017 'Content-Type': 'application/x-www-form-urlencoded', 1018 }, 1019 body: buildSignedStoryVideoBody(prepared.asset.width, prepared.asset.height, prepared.asset.durationMs), 1020 }); 1021 const json = await parseJsonResponse(response, 'configure_to_story'); 1022 return { 1023 mediaPk: String(json?.media?.pk || json?.media?.id || '').split('_')[0] || undefined, 1024 uploadId, 1025 }; 1026 } 1027 finally { 1028 cleanupPreparedMediaAssets([prepared]); 1029 } 1030 }