/ clis / instagram / _shared / private-publish.js
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  }