@hraness/ghostget 0.17.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (500) hide show
  1. package/CHANGELOG.md +458 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1359 -0
  4. package/bunfig.toml +2 -0
  5. package/dist/apple-photos-client.js +704 -0
  6. package/dist/beeper-client.js +4602 -0
  7. package/dist/client.js +1605 -0
  8. package/dist/index-26yq8q16.js +31 -0
  9. package/dist/index-9t13b4k7.js +508 -0
  10. package/dist/index-axwm9b3v.js +5 -0
  11. package/dist/index-gwk7rbyj.js +30 -0
  12. package/dist/index.js +701 -0
  13. package/dist/messaging.js +1130 -0
  14. package/dist/omni-client.js +1406 -0
  15. package/dist/whatsapp-client.js +355 -0
  16. package/docs/imessage-direct-provider.md +106 -0
  17. package/docs/rental-listings.md +94 -0
  18. package/package.json +600 -0
  19. package/skills/ghostget/SKILL.md +251 -0
  20. package/skills/ghostget/agents/openai.yaml +4 -0
  21. package/skills/ghostget/references/adapter-contract.md +181 -0
  22. package/skills/ghostget/references/apple-photos.md +115 -0
  23. package/skills/ghostget/references/article-drafts.md +235 -0
  24. package/skills/ghostget/references/code-owned-provider-scaffold.md +134 -0
  25. package/skills/ghostget/references/cross-posting-video.md +139 -0
  26. package/skills/ghostget/references/cross-posting.md +82 -0
  27. package/skills/ghostget/references/derivation.md +390 -0
  28. package/skills/ghostget/references/hraness-social-profile-stats.json +269 -0
  29. package/skills/ghostget/references/install.md +66 -0
  30. package/skills/ghostget/references/linkedin-adapter.md +442 -0
  31. package/skills/ghostget/references/messaging.md +168 -0
  32. package/skills/ghostget/references/meta-comet-contract.md +153 -0
  33. package/skills/ghostget/references/platform-patterns.md +122 -0
  34. package/skills/ghostget/references/provider-plugins.md +496 -0
  35. package/skills/ghostget/references/reddit-flair.md +78 -0
  36. package/skills/ghostget/references/safety-and-state.md +214 -0
  37. package/skills/ghostget/references/settlement-and-duplicate-cleanup.md +79 -0
  38. package/skills/ghostget/references/social-platform-routing.md +66 -0
  39. package/skills/ghostget/references/social-profile-stats.md +195 -0
  40. package/skills/ghostget/references/social-video-platform-routing.md +89 -0
  41. package/skills/ghostget/references/whatsapp-adapter.md +101 -0
  42. package/skills/ghostget/references/x-adapter.md +332 -0
  43. package/skills/ghostget/references/x-ai-disclosure.md +71 -0
  44. package/src/apple-photos-cli.ts +639 -0
  45. package/src/apple-photos-client-types.ts +168 -0
  46. package/src/apple-photos-client.ts +201 -0
  47. package/src/apple-photos-contact-evidence.ts +838 -0
  48. package/src/apple-photos-local-source.ts +1131 -0
  49. package/src/args.ts +2053 -0
  50. package/src/article-draft-document.ts +417 -0
  51. package/src/article-draft-embeds.ts +123 -0
  52. package/src/article-draft-images.ts +214 -0
  53. package/src/assets/adapter-template/wrench-adapter.json +40 -0
  54. package/src/assets/adapters/beeper/wrench-web-adapter.json +1500 -0
  55. package/src/assets/adapters/beeper/wrench-web-adapter.v1.0.0.json +129 -0
  56. package/src/assets/adapters/beeper/wrench-web-adapter.v1.1.0.json +207 -0
  57. package/src/assets/adapters/beeper/wrench-web-adapter.v2.0.0.json +1463 -0
  58. package/src/assets/adapters/beeper/wrench-web-adapter.v2.1.0.json +1463 -0
  59. package/src/assets/adapters/beeper/wrench-web-adapter.v2.2.0.json +1475 -0
  60. package/src/assets/adapters/beeper/wrench-web-adapter.v2.3.0.json +1488 -0
  61. package/src/assets/adapters/bluesky/wrench-web-adapter.json +684 -0
  62. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.0.0.json +581 -0
  63. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.1.0.json +581 -0
  64. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.2.0.json +581 -0
  65. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.3.0.json +615 -0
  66. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.4.0.json +684 -0
  67. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.5.0.json +684 -0
  68. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.6.0.json +684 -0
  69. package/src/assets/adapters/clasificados/wrench-web-adapter.json +54 -0
  70. package/src/assets/adapters/facebook/wrench-web-adapter.json +750 -0
  71. package/src/assets/adapters/facebook/wrench-web-adapter.v1.0.0.json +706 -0
  72. package/src/assets/adapters/facebook/wrench-web-adapter.v1.1.0.json +703 -0
  73. package/src/assets/adapters/facebook/wrench-web-adapter.v1.2.0.json +750 -0
  74. package/src/assets/adapters/facebook-group/wrench-web-adapter.json +682 -0
  75. package/src/assets/adapters/facebook-group/wrench-web-adapter.v1.0.0.json +583 -0
  76. package/src/assets/adapters/facebook-group/wrench-web-adapter.v1.1.0.json +682 -0
  77. package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.json +437 -0
  78. package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.v1.0.0.json +383 -0
  79. package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.v1.1.0.json +437 -0
  80. package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.v1.1.1.json +437 -0
  81. package/src/assets/adapters/facebook-page/wrench-web-adapter.json +880 -0
  82. package/src/assets/adapters/facebook-page/wrench-web-adapter.v1.0.0.json +740 -0
  83. package/src/assets/adapters/facebook-page/wrench-web-adapter.v1.1.0.json +880 -0
  84. package/src/assets/adapters/github/wrench-web-adapter.json +69 -0
  85. package/src/assets/adapters/github/wrench-web-adapter.v1.0.0.json +42 -0
  86. package/src/assets/adapters/github/wrench-web-adapter.v1.1.0.json +69 -0
  87. package/src/assets/adapters/gmail/wrench-adapter.json +161 -0
  88. package/src/assets/adapters/gmail/wrench-adapter.v1.2.0.json +157 -0
  89. package/src/assets/adapters/hacker-news/wrench-web-adapter.json +315 -0
  90. package/src/assets/adapters/hacker-news/wrench-web-adapter.v1.0.0.json +315 -0
  91. package/src/assets/adapters/imessage/wrench-web-adapter.json +236 -0
  92. package/src/assets/adapters/instagram/wrench-web-adapter.json +719 -0
  93. package/src/assets/adapters/instagram/wrench-web-adapter.v1.0.0.json +627 -0
  94. package/src/assets/adapters/instagram/wrench-web-adapter.v1.1.0.json +609 -0
  95. package/src/assets/adapters/instagram/wrench-web-adapter.v1.2.0.json +640 -0
  96. package/src/assets/adapters/instagram/wrench-web-adapter.v1.3.0.json +671 -0
  97. package/src/assets/adapters/instagram/wrench-web-adapter.v1.4.0.json +710 -0
  98. package/src/assets/adapters/instagram/wrench-web-adapter.v1.5.0.json +710 -0
  99. package/src/assets/adapters/instagram/wrench-web-adapter.v1.6.0.json +719 -0
  100. package/src/assets/adapters/instagram/wrench-web-adapter.v1.7.0.json +719 -0
  101. package/src/assets/adapters/linkedin/wrench-adapter.json +442 -0
  102. package/src/assets/adapters/linkedin/wrench-adapter.v0.4.0.json +124 -0
  103. package/src/assets/adapters/linkedin/wrench-adapter.v1.0.0.json +411 -0
  104. package/src/assets/adapters/linkedin/wrench-web-adapter.json +914 -0
  105. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.0.0.json +604 -0
  106. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.1.0.json +604 -0
  107. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.10.0.json +809 -0
  108. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.11.0.json +809 -0
  109. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.12.0.json +820 -0
  110. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.13.0.json +819 -0
  111. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.14.0.json +819 -0
  112. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.15.0.json +819 -0
  113. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.16.0.json +874 -0
  114. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.17.0.json +874 -0
  115. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.18.0.json +874 -0
  116. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.19.0.json +874 -0
  117. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.2.0.json +731 -0
  118. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.3.0.json +768 -0
  119. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.4.0.json +808 -0
  120. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.5.0.json +808 -0
  121. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.6.0.json +808 -0
  122. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.7.0.json +825 -0
  123. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.8.0.json +792 -0
  124. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.9.0.json +809 -0
  125. package/src/assets/adapters/reddit/wrench-web-adapter.json +855 -0
  126. package/src/assets/adapters/reddit/wrench-web-adapter.v1.0.0.json +577 -0
  127. package/src/assets/adapters/reddit/wrench-web-adapter.v1.1.0.json +604 -0
  128. package/src/assets/adapters/reddit/wrench-web-adapter.v1.10.0.json +712 -0
  129. package/src/assets/adapters/reddit/wrench-web-adapter.v1.11.0.json +712 -0
  130. package/src/assets/adapters/reddit/wrench-web-adapter.v1.12.0.json +712 -0
  131. package/src/assets/adapters/reddit/wrench-web-adapter.v1.2.0.json +653 -0
  132. package/src/assets/adapters/reddit/wrench-web-adapter.v1.3.0.json +712 -0
  133. package/src/assets/adapters/reddit/wrench-web-adapter.v1.4.0.json +713 -0
  134. package/src/assets/adapters/reddit/wrench-web-adapter.v1.5.0.json +712 -0
  135. package/src/assets/adapters/reddit/wrench-web-adapter.v1.6.0.json +713 -0
  136. package/src/assets/adapters/reddit/wrench-web-adapter.v1.7.0.json +713 -0
  137. package/src/assets/adapters/reddit/wrench-web-adapter.v1.8.0.json +713 -0
  138. package/src/assets/adapters/reddit/wrench-web-adapter.v1.9.0.json +713 -0
  139. package/src/assets/adapters/substack/wrench-web-adapter.json +936 -0
  140. package/src/assets/adapters/substack/wrench-web-adapter.v1.0.0.json +817 -0
  141. package/src/assets/adapters/substack/wrench-web-adapter.v1.1.0.json +812 -0
  142. package/src/assets/adapters/substack/wrench-web-adapter.v1.2.0.json +812 -0
  143. package/src/assets/adapters/substack/wrench-web-adapter.v1.3.0.json +902 -0
  144. package/src/assets/adapters/substack/wrench-web-adapter.v1.4.0.json +936 -0
  145. package/src/assets/adapters/substack/wrench-web-adapter.v1.5.0.json +936 -0
  146. package/src/assets/adapters/substack/wrench-web-adapter.v1.6.0.json +936 -0
  147. package/src/assets/adapters/threads/wrench-web-adapter.json +692 -0
  148. package/src/assets/adapters/threads/wrench-web-adapter.v1.0.0.json +629 -0
  149. package/src/assets/adapters/threads/wrench-web-adapter.v1.1.0.json +623 -0
  150. package/src/assets/adapters/threads/wrench-web-adapter.v1.2.0.json +622 -0
  151. package/src/assets/adapters/threads/wrench-web-adapter.v1.3.0.json +622 -0
  152. package/src/assets/adapters/threads/wrench-web-adapter.v1.4.0.json +622 -0
  153. package/src/assets/adapters/threads/wrench-web-adapter.v1.5.0.json +699 -0
  154. package/src/assets/adapters/threads/wrench-web-adapter.v1.6.0.json +693 -0
  155. package/src/assets/adapters/threads/wrench-web-adapter.v1.7.0.json +692 -0
  156. package/src/assets/adapters/tiktok/wrench-web-adapter.json +762 -0
  157. package/src/assets/adapters/tiktok/wrench-web-adapter.v1.0.0.json +681 -0
  158. package/src/assets/adapters/tiktok/wrench-web-adapter.v1.1.0.json +708 -0
  159. package/src/assets/adapters/tiktok/wrench-web-adapter.v1.2.0.json +762 -0
  160. package/src/assets/adapters/tiktok/wrench-web-adapter.v1.3.0.json +762 -0
  161. package/src/assets/adapters/twitch/wrench-web-adapter.json +42 -0
  162. package/src/assets/adapters/twitch/wrench-web-adapter.v1.0.0.json +42 -0
  163. package/src/assets/adapters/whatsapp/wrench-web-adapter.json +400 -0
  164. package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.0.0.json +355 -0
  165. package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.1.0.json +386 -0
  166. package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.2.0.json +386 -0
  167. package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.3.0.json +400 -0
  168. package/src/assets/adapters/x/wrench-adapter.json +749 -0
  169. package/src/assets/adapters/x/wrench-adapter.v1.0.0.json +700 -0
  170. package/src/assets/adapters/x/wrench-adapter.v1.1.0.json +707 -0
  171. package/src/assets/adapters/x/wrench-web-adapter.json +712 -0
  172. package/src/assets/adapters/x/wrench-web-adapter.v1.1.0.json +579 -0
  173. package/src/assets/adapters/x/wrench-web-adapter.v1.10.0.json +685 -0
  174. package/src/assets/adapters/x/wrench-web-adapter.v1.11.0.json +712 -0
  175. package/src/assets/adapters/x/wrench-web-adapter.v1.12.0.json +712 -0
  176. package/src/assets/adapters/x/wrench-web-adapter.v1.13.0.json +712 -0
  177. package/src/assets/adapters/x/wrench-web-adapter.v1.2.0.json +578 -0
  178. package/src/assets/adapters/x/wrench-web-adapter.v1.3.0.json +610 -0
  179. package/src/assets/adapters/x/wrench-web-adapter.v1.4.0.json +617 -0
  180. package/src/assets/adapters/x/wrench-web-adapter.v1.5.0.json +634 -0
  181. package/src/assets/adapters/x/wrench-web-adapter.v1.6.0.json +632 -0
  182. package/src/assets/adapters/x/wrench-web-adapter.v1.7.0.json +649 -0
  183. package/src/assets/adapters/x/wrench-web-adapter.v1.8.0.json +649 -0
  184. package/src/assets/adapters/x/wrench-web-adapter.v1.9.0.json +683 -0
  185. package/src/assets/adapters/youtube/wrench-web-adapter.json +561 -0
  186. package/src/assets/adapters/youtube/wrench-web-adapter.v1.0.0.json +465 -0
  187. package/src/assets/adapters/youtube/wrench-web-adapter.v1.1.0.json +519 -0
  188. package/src/assets/adapters/youtube/wrench-web-adapter.v1.2.0.json +561 -0
  189. package/src/assets/adapters/youtube/wrench-web-adapter.v1.3.0.json +561 -0
  190. package/src/assets/code-owned-provider-template/plugin.test.ts.template +54 -0
  191. package/src/assets/code-owned-provider-template/plugin.ts.template +102 -0
  192. package/src/assets/code-owned-provider-template/runtime.internal.test.ts.template +44 -0
  193. package/src/assets/code-owned-provider-template/runtime.ts.template +59 -0
  194. package/src/assets/code-owned-provider-template/wrench-adapter.json.template +33 -0
  195. package/src/assets/fonts/nebula-sans/LICENSE.txt +96 -0
  196. package/src/assets/fonts/nebula-sans/NebulaSans-Book.woff2 +0 -0
  197. package/src/assets/fonts/nebula-sans/PROVENANCE.md +14 -0
  198. package/src/assets.d.ts +4 -0
  199. package/src/auth.ts +1157 -0
  200. package/src/beeper-client-types.ts +169 -0
  201. package/src/beeper-client.ts +250 -0
  202. package/src/beeper-contact-interactions-cli.ts +180 -0
  203. package/src/beeper-contact-interactions.ts +1612 -0
  204. package/src/beeper-message-bundle-v1.ts +257 -0
  205. package/src/beeper-message-like-me-cli.ts +84 -0
  206. package/src/beeper-message-like-me-export.ts +2274 -0
  207. package/src/beeper-message-like-me-recovery.ts +931 -0
  208. package/src/beeper-message-like-me-source.ts +4034 -0
  209. package/src/browser-admission.ts +928 -0
  210. package/src/browser-snapshots.ts +260 -0
  211. package/src/browser.ts +3873 -0
  212. package/src/canonical-json.ts +60 -0
  213. package/src/catalog-cli.ts +638 -0
  214. package/src/cli.ts +208 -0
  215. package/src/client-types.ts +301 -0
  216. package/src/client.ts +2726 -0
  217. package/src/confirmed-write-failure.ts +44 -0
  218. package/src/confirmed-write-model.ts +72 -0
  219. package/src/confirmed-write-platform.ts +1055 -0
  220. package/src/confirmed-write-program.ts +224 -0
  221. package/src/confirmed-write-runtime.ts +16 -0
  222. package/src/cursor-token.ts +228 -0
  223. package/src/derivation-dnr-readiness.ts +367 -0
  224. package/src/derivation-file-chooser.ts +384 -0
  225. package/src/derivation-network-boundary.ts +835 -0
  226. package/src/derivation-network-guard.ts +782 -0
  227. package/src/derivation-network-proxy-helper.ts +440 -0
  228. package/src/derivation-network-proxy.ts +385 -0
  229. package/src/derive-command-helper.ts +693 -0
  230. package/src/derive-fixtures.ts +343 -0
  231. package/src/derive-review.ts +980 -0
  232. package/src/derive.ts +4754 -0
  233. package/src/fixtures/beeper-message-like-me-v1/accounts.ndjson +2 -0
  234. package/src/fixtures/beeper-message-like-me-v1/conversations.ndjson +1 -0
  235. package/src/fixtures/beeper-message-like-me-v1/manifest.json +1 -0
  236. package/src/fixtures/beeper-message-like-me-v1/messages.ndjson +2 -0
  237. package/src/fixtures/beeper-message-like-me-v1/participants.ndjson +3 -0
  238. package/src/fixtures/beeper-message-like-me-v1/reactions.ndjson +1 -0
  239. package/src/fixtures/beeper-message-like-me-v1/tombstones.ndjson +1 -0
  240. package/src/ghostget.ts +3672 -0
  241. package/src/gmail-capture.ts +1244 -0
  242. package/src/har-internal.ts +1242 -0
  243. package/src/har.ts +743 -0
  244. package/src/index.ts +218 -0
  245. package/src/invocation-read-platform.ts +55 -0
  246. package/src/invocation-read-program.ts +93 -0
  247. package/src/invocation-read-runtime.ts +11 -0
  248. package/src/linked-device-lifecycle-admission.ts +450 -0
  249. package/src/linked-device-lifecycle-journal.ts +1560 -0
  250. package/src/linked-device-lifecycle-runtime.ts +1602 -0
  251. package/src/local-cli-admission.ts +135 -0
  252. package/src/local-cli-contracts.ts +271 -0
  253. package/src/local-cli-execution.ts +98 -0
  254. package/src/local-cli-surface-contract.ts +1510 -0
  255. package/src/local-cli-tool-identity.ts +336 -0
  256. package/src/media/archive.ts +2325 -0
  257. package/src/media/args.ts +303 -0
  258. package/src/media/cli.ts +287 -0
  259. package/src/media/doctor.ts +475 -0
  260. package/src/media/ffmpeg.ts +1330 -0
  261. package/src/media/http-capture.ts +711 -0
  262. package/src/media/http-probe.ts +593 -0
  263. package/src/media/http.ts +387 -0
  264. package/src/media/index.ts +10 -0
  265. package/src/media/local-transcription.ts +613 -0
  266. package/src/media/lock.ts +321 -0
  267. package/src/media/manifest.ts +1712 -0
  268. package/src/media/metadata.ts +906 -0
  269. package/src/media/process.ts +723 -0
  270. package/src/media/revision.ts +166 -0
  271. package/src/media/runtime-closure.ts +1063 -0
  272. package/src/media/source-router.ts +187 -0
  273. package/src/media/transcriber-config.ts +1122 -0
  274. package/src/media/transcript-persistence-model.ts +15 -0
  275. package/src/media/transcript-persistence-platform.ts +89 -0
  276. package/src/media/transcript-persistence-program.ts +39 -0
  277. package/src/media/transcript-persistence-runtime.ts +24 -0
  278. package/src/media/transcript.ts +616 -0
  279. package/src/media/utf8-order.ts +30 -0
  280. package/src/media/whisper-cpp.ts +464 -0
  281. package/src/media/whisper-language.ts +58 -0
  282. package/src/media/yt-dlp.ts +439 -0
  283. package/src/message-like-me-agentic-messaging.ts +882 -0
  284. package/src/messaging-action-store.ts +793 -0
  285. package/src/messaging-runtime.ts +2136 -0
  286. package/src/messaging-store.ts +539 -0
  287. package/src/messaging-types.ts +1663 -0
  288. package/src/messaging.ts +334 -0
  289. package/src/model.ts +2671 -0
  290. package/src/oauth-google.ts +888 -0
  291. package/src/omni-client-types.ts +306 -0
  292. package/src/omni-client.ts +2225 -0
  293. package/src/omni-limits.ts +34 -0
  294. package/src/omni-model.ts +1614 -0
  295. package/src/omni-request.ts +379 -0
  296. package/src/omni-runtime.ts +1632 -0
  297. package/src/operation-deadline.ts +189 -0
  298. package/src/path-helper.ts +1656 -0
  299. package/src/pinned-https.ts +194 -0
  300. package/src/plan-assets.ts +832 -0
  301. package/src/platform-catalog.ts +1832 -0
  302. package/src/plugins/beeper-linked-device/plugin.ts +310 -0
  303. package/src/plugins/bluesky-web/plugin.ts +178 -0
  304. package/src/plugins/clasificados-web/plugin.ts +66 -0
  305. package/src/plugins/github-web/plugin.ts +80 -0
  306. package/src/plugins/gmail-official/plugin.ts +110 -0
  307. package/src/plugins/hacker-news-web/plugin.ts +52 -0
  308. package/src/plugins/imessage-direct/plugin.ts +195 -0
  309. package/src/plugins/imessage-direct/vendor/0001-fix-keep-AppleScript-send-payloads-out-of-child-argv.patch +1430 -0
  310. package/src/plugins/imessage-direct/vendor/0002-feat-rpc-add-exact-chat-lookup.patch +225 -0
  311. package/src/plugins/imessage-direct/vendor/provenance.json +80 -0
  312. package/src/plugins/linkedin-official/plugin.ts +84 -0
  313. package/src/plugins/linkedin-web/plugin.ts +412 -0
  314. package/src/plugins/meta-web/plugin.ts +317 -0
  315. package/src/plugins/reddit-web/flair.ts +492 -0
  316. package/src/plugins/reddit-web/plugin.ts +477 -0
  317. package/src/plugins/substack-web/plugin.ts +124 -0
  318. package/src/plugins/tiktok-web/plugin.ts +67 -0
  319. package/src/plugins/twitch-web/plugin.ts +54 -0
  320. package/src/plugins/whatsapp-linked-device/plugin.ts +105 -0
  321. package/src/plugins/x-official/plugin.ts +64 -0
  322. package/src/plugins/x-web/plugin.ts +488 -0
  323. package/src/plugins/youtube-web/plugin.ts +101 -0
  324. package/src/portable-run-recovery.ts +466 -0
  325. package/src/process-identity.ts +501 -0
  326. package/src/profile-clone-helper.ts +104 -0
  327. package/src/provider-catalog-views.ts +51 -0
  328. package/src/provider-context.ts +69 -0
  329. package/src/provider-contract-definitions-x.ts +45 -0
  330. package/src/provider-contract-definitions.ts +948 -0
  331. package/src/provider-contract-input-gmail.ts +176 -0
  332. package/src/provider-contract-input-linkedin.ts +77 -0
  333. package/src/provider-contract-input-x.ts +304 -0
  334. package/src/provider-contract-planning.ts +23 -0
  335. package/src/provider-contract-semantic-identity.ts +54 -0
  336. package/src/provider-contracts.ts +251 -0
  337. package/src/provider-http.ts +723 -0
  338. package/src/provider-plugin-auth.ts +36 -0
  339. package/src/provider-plugin-builtins.ts +229 -0
  340. package/src/provider-plugin-cleanup-barrier.ts +229 -0
  341. package/src/provider-plugin-cleanup-execution.ts +79 -0
  342. package/src/provider-plugin-cleanup-resource.ts +470 -0
  343. package/src/provider-plugin-contract-identity.ts +707 -0
  344. package/src/provider-plugin-host.ts +1972 -0
  345. package/src/provider-plugin-identifiers.ts +60 -0
  346. package/src/provider-plugin-invocation-lease.ts +889 -0
  347. package/src/provider-plugin-lifecycle-kernel.ts +566 -0
  348. package/src/provider-plugin-lifecycle.ts +1455 -0
  349. package/src/provider-plugin-module-analysis.ts +336 -0
  350. package/src/provider-plugin-package.ts +2530 -0
  351. package/src/provider-plugin-portable-authority.ts +106 -0
  352. package/src/provider-plugin-portable-catalog.ts +94 -0
  353. package/src/provider-plugin-portable-identity.ts +315 -0
  354. package/src/provider-plugin-portable-runtime.ts +2057 -0
  355. package/src/provider-plugin-protocol.ts +1625 -0
  356. package/src/provider-plugin-registry.ts +3342 -0
  357. package/src/provider-plugin-store.ts +2155 -0
  358. package/src/provider-plugin-test-fixture.ts +5 -0
  359. package/src/provider-plugin.ts +4907 -0
  360. package/src/provider-plugins.generated.ts +43 -0
  361. package/src/provider-plugins.ts +8 -0
  362. package/src/provider-subject.ts +41 -0
  363. package/src/provider.ts +315 -0
  364. package/src/providers/beeper-local-runtime.ts +5426 -0
  365. package/src/providers/beeper-local.ts +2739 -0
  366. package/src/providers/beeper-messaging.ts +462 -0
  367. package/src/providers/beeper-omni.ts +1159 -0
  368. package/src/providers/bluesky-web-runtime.ts +2895 -0
  369. package/src/providers/bluesky-web.ts +1614 -0
  370. package/src/providers/clasificados-web-runtime.ts +255 -0
  371. package/src/providers/clasificados-web.ts +503 -0
  372. package/src/providers/contact-projection.ts +301 -0
  373. package/src/providers/github-read-model.ts +94 -0
  374. package/src/providers/github-read-platform.ts +165 -0
  375. package/src/providers/github-read-program.ts +97 -0
  376. package/src/providers/github-web-runtime.ts +320 -0
  377. package/src/providers/github-web.ts +403 -0
  378. package/src/providers/gmail-api.ts +2834 -0
  379. package/src/providers/gmail-omni.ts +830 -0
  380. package/src/providers/gmail.ts +1130 -0
  381. package/src/providers/hacker-news-web-runtime.ts +220 -0
  382. package/src/providers/hacker-news-web.ts +809 -0
  383. package/src/providers/imessage-direct-install.ts +354 -0
  384. package/src/providers/imessage-direct-messaging.ts +532 -0
  385. package/src/providers/imessage-direct-omni.ts +400 -0
  386. package/src/providers/imessage-direct-runtime.ts +1498 -0
  387. package/src/providers/imessage-direct.ts +351 -0
  388. package/src/providers/instagram-video-foundations.ts +913 -0
  389. package/src/providers/instagram-web-profile-browser.ts +600 -0
  390. package/src/providers/iso-bmff.ts +338 -0
  391. package/src/providers/linkedin-company-platform.ts +42 -0
  392. package/src/providers/linkedin-company-program.ts +88 -0
  393. package/src/providers/linkedin-contact-failure.ts +84 -0
  394. package/src/providers/linkedin-contact-platform.ts +45 -0
  395. package/src/providers/linkedin-contact-program.ts +91 -0
  396. package/src/providers/linkedin-profile-activity-failure.ts +76 -0
  397. package/src/providers/linkedin-profile-activity-platform.ts +41 -0
  398. package/src/providers/linkedin-profile-activity-program.ts +67 -0
  399. package/src/providers/linkedin-read-failure.ts +52 -0
  400. package/src/providers/linkedin-self-platform.ts +53 -0
  401. package/src/providers/linkedin-self-program.ts +122 -0
  402. package/src/providers/linkedin-web-article-browser.ts +794 -0
  403. package/src/providers/linkedin-web-bootstrap.ts +146 -0
  404. package/src/providers/linkedin-web-contact.ts +830 -0
  405. package/src/providers/linkedin-web-feed-browser.ts +832 -0
  406. package/src/providers/linkedin-web-feed.ts +864 -0
  407. package/src/providers/linkedin-web-post-browser.ts +653 -0
  408. package/src/providers/linkedin-web-profile-browser.ts +683 -0
  409. package/src/providers/linkedin-web-runtime.ts +2388 -0
  410. package/src/providers/linkedin-web.ts +3305 -0
  411. package/src/providers/linkedin.ts +1885 -0
  412. package/src/providers/meta-bootstrap.ts +1043 -0
  413. package/src/providers/meta-facebook-group.ts +690 -0
  414. package/src/providers/meta-marketplace-relay.ts +747 -0
  415. package/src/providers/meta-omni.ts +373 -0
  416. package/src/providers/meta-relay-bundle.ts +1049 -0
  417. package/src/providers/meta-web-descriptors.ts +1740 -0
  418. package/src/providers/meta-web-runtime.ts +3388 -0
  419. package/src/providers/meta-web.ts +2931 -0
  420. package/src/providers/read-failure.ts +139 -0
  421. package/src/providers/reddit-omni.ts +462 -0
  422. package/src/providers/reddit-web-runtime.ts +1929 -0
  423. package/src/providers/reddit-web.ts +1611 -0
  424. package/src/providers/rental-listings.ts +460 -0
  425. package/src/providers/substack-omni.ts +406 -0
  426. package/src/providers/substack-video-mp4.ts +33 -0
  427. package/src/providers/substack-web-runtime.ts +2812 -0
  428. package/src/providers/substack-web.ts +1502 -0
  429. package/src/providers/tiktok-video-mp4.ts +33 -0
  430. package/src/providers/tiktok-web-runtime.ts +770 -0
  431. package/src/providers/tiktok-web.ts +2786 -0
  432. package/src/providers/twitch-web-runtime.ts +226 -0
  433. package/src/providers/twitch-web.ts +274 -0
  434. package/src/providers/whatsapp-account-identity.ts +55 -0
  435. package/src/providers/whatsapp-contact-projection-helper.ts +874 -0
  436. package/src/providers/whatsapp-contact-projection-protocol.ts +403 -0
  437. package/src/providers/whatsapp-interaction-projection-helper.ts +1452 -0
  438. package/src/providers/whatsapp-interaction-projection-protocol.ts +462 -0
  439. package/src/providers/whatsapp-message-export-projection-protocol.ts +532 -0
  440. package/src/providers/whatsapp-messaging.ts +132 -0
  441. package/src/providers/whatsapp-omni.ts +586 -0
  442. package/src/providers/whatsapp-web-runtime.ts +3108 -0
  443. package/src/providers/whatsapp-web.ts +1037 -0
  444. package/src/providers/x-image-provenance.ts +523 -0
  445. package/src/providers/x-made-with-ai.ts +106 -0
  446. package/src/providers/x-transaction-id.ts +413 -0
  447. package/src/providers/x-web-runtime.ts +3485 -0
  448. package/src/providers/x-web.ts +2332 -0
  449. package/src/providers/x.ts +1889 -0
  450. package/src/providers/youtube-web-runtime.ts +1400 -0
  451. package/src/providers/youtube-web.ts +1221 -0
  452. package/src/read-admission-runtime.ts +91 -0
  453. package/src/read-client.ts +521 -0
  454. package/src/read-effect-platform.ts +7 -0
  455. package/src/read-effect-runtime.ts +14 -0
  456. package/src/read-effect.ts +52 -0
  457. package/src/read-projection-admission.ts +866 -0
  458. package/src/read-projections.ts +3151 -0
  459. package/src/recovery.ts +1492 -0
  460. package/src/reviewed-template.ts +146 -0
  461. package/src/run-journal.ts +1272 -0
  462. package/src/runtime.ts +5116 -0
  463. package/src/scripts/generate-provider-plugin-catalog.ts +121 -0
  464. package/src/scripts/install-whatsapp-protocol.sh +232 -0
  465. package/src/scripts/recover-install-stages.ts +1491 -0
  466. package/src/scripts/resolve-state-home.ts +30 -0
  467. package/src/scripts/scaffold-web-provider.ts +973 -0
  468. package/src/scripts/sync-bundled-adapters.ts +695 -0
  469. package/src/session-secrets.ts +1183 -0
  470. package/src/state-helper.bunfig.toml +2 -0
  471. package/src/state-helper.ts +2914 -0
  472. package/src/storage.ts +3842 -0
  473. package/src/transport-policy.ts +2 -0
  474. package/src/usage.ts +182 -0
  475. package/src/version.ts +2 -0
  476. package/src/web-session-authentication-policy.ts +136 -0
  477. package/src/web-session-cleanup-admission.ts +2344 -0
  478. package/src/web-session-cleanup-join.ts +99 -0
  479. package/src/web-session-client.ts +996 -0
  480. package/src/web-session-contract-definitions.ts +820 -0
  481. package/src/web-session-contract-planning.ts +42 -0
  482. package/src/web-session-contracts.ts +228 -0
  483. package/src/web-session-cookies.ts +500 -0
  484. package/src/web-session-execution.ts +385 -0
  485. package/src/web-session-http.ts +373 -0
  486. package/src/web-session-read-errors.ts +19 -0
  487. package/src/web-session-read-runtime.ts +124 -0
  488. package/src/web-session-recovery.ts +818 -0
  489. package/src/web-session-sites.ts +58 -0
  490. package/src/web-session-template.ts +994 -0
  491. package/src/web-session.ts +93 -0
  492. package/src/whatsapp-client-binding.ts +21 -0
  493. package/src/whatsapp-client-types.ts +75 -0
  494. package/src/whatsapp-client.ts +395 -0
  495. package/src/whatsapp-export-coordinate.ts +17 -0
  496. package/src/whatsapp-message-bundle-v2.ts +195 -0
  497. package/src/whatsapp-message-like-me-cli.ts +195 -0
  498. package/src/whatsapp-message-like-me-export.ts +1072 -0
  499. package/src/whatsapp-message-like-me-source.ts +1012 -0
  500. package/tsconfig.json +67 -0
@@ -0,0 +1,3388 @@
1
+ import { constants } from "node:fs";
2
+ import { open } from "node:fs/promises";
3
+
4
+ import { acquireCookieRecords } from "@hraness/kb/clip/acquire";
5
+
6
+ import type { GhostgetAuth } from "../auth";
7
+ import {
8
+ browserCleanupBarrier,
9
+ PreservedBrowserArtifactsError,
10
+ type BrowserFileResolver,
11
+ } from "../browser";
12
+ import { canonicalJson, sha256 } from "../canonical-json";
13
+ import type { FileInputValue, OperationInput, WebSessionRecipe } from "../model";
14
+ import { OperationDeadlineError } from "../operation-deadline";
15
+ import { openCursorToken, sealCursorToken } from "../cursor-token";
16
+ import {
17
+ createWebSessionClient,
18
+ fetchPublicWebAsset,
19
+ webSessionAuthSubject,
20
+ webSessionCookie,
21
+ type WebSessionClient,
22
+ type WebSessionCookieRotationState,
23
+ type WebSessionNetworkDependencies,
24
+ } from "../web-session-client";
25
+ import { acquireWebSessionCookieRecords } from "../web-session-cookies";
26
+ import type {
27
+ WebSessionCleanupBarrierRegistrar,
28
+ WebSessionCleanupResourcePublisher,
29
+ WebSessionDispatchEvent,
30
+ WebSessionExecution,
31
+ WebSessionOperationDeadline,
32
+ WebSessionProviderAcceptedMutationTargetEvent,
33
+ WebSessionProviderBoundMutationTargetEvent,
34
+ } from "../web-session-execution";
35
+ import { startWebSessionCleanupTrackedOperation } from "../web-session-execution";
36
+ import {
37
+ createInstagramProfileBrowserTransport,
38
+ InstagramProfileBrowserFailure,
39
+ InstagramProfileBrowserResponseRejectedError,
40
+ type InstagramProfileBrowserTransport,
41
+ } from "./instagram-web-profile-browser";
42
+ import {
43
+ failedProviderRead,
44
+ ProviderReadResponseRejectedError,
45
+ ProviderReadTransportError,
46
+ } from "./read-failure";
47
+ import {
48
+ assertInstagramVideoConfigureIndeterminate,
49
+ assertInstagramVideoDeleteAcknowledgement,
50
+ assertInstagramVideoUploadAcknowledgement,
51
+ bindInstagramVideoMediaReadback,
52
+ instagramVideoAcceptedTargetIdentifier,
53
+ INSTAGRAM_VIDEO_CAPTURE_BLOCKERS,
54
+ materializeInstagramVideoPublishInput,
55
+ parseInstagramVideoAcceptedTargetIdentifier,
56
+ prepareInstagramAuthoredPostDeleteInput,
57
+ prepareInstagramVideoPublishInput,
58
+ parseInstagramVideoConfigureAccepted,
59
+ revalidateInstagramVideoPublishBindingForDispatch,
60
+ } from "./instagram-video-foundations";
61
+ export {
62
+ assertInstagramVideoConfigureIndeterminate,
63
+ assertInstagramVideoDeleteAcknowledgement,
64
+ assertInstagramVideoUploadAcknowledgement,
65
+ bindInstagramVideoMediaReadback,
66
+ instagramVideoAcceptedTargetIdentifier,
67
+ INSTAGRAM_VIDEO_CAPTURE_BLOCKERS,
68
+ materializeInstagramVideoPublishInput,
69
+ parseInstagramVideoAcceptedTargetIdentifier,
70
+ prepareInstagramAuthoredPostDeleteInput,
71
+ prepareInstagramVideoPublishInput,
72
+ parseInstagramVideoConfigureAccepted,
73
+ revalidateInstagramVideoPublishBindingForDispatch,
74
+ };
75
+ import {
76
+ META_WEB_OPERATIONS,
77
+ META_WEB_OPERATION_NAMES,
78
+ META_WEB_SITES,
79
+ isCanonicalMetaNumericId,
80
+ normalizeFacebookFeedHtml,
81
+ normalizeFacebookMarketplaceFeedJsonDocuments,
82
+ normalizeFacebookMarketplaceFeedHtml,
83
+ normalizeFacebookMarketplaceListingHtml,
84
+ normalizeInstagramComments,
85
+ normalizeInstagramContacts,
86
+ normalizeInstagramFeed,
87
+ normalizeInstagramInbox,
88
+ normalizeInstagramPost,
89
+ normalizeInstagramProfileStats,
90
+ normalizeThreadsFeedHtml,
91
+ normalizeThreadsPostHtml,
92
+ normalizeThreadsVideoPostHtml,
93
+ normalizeThreadsProfileStats,
94
+ normalizeThreadsRecentViewsAvailability,
95
+ parseFacebookViewerId,
96
+ parseMetaJsonDocuments,
97
+ parseMetaJsonScripts,
98
+ parseInstagramViewerId,
99
+ parseThreadsViewerId,
100
+ projectThreadsPublishVideo,
101
+ ThreadsAuthRepairRequiredError,
102
+ type FacebookMarketplaceFeed,
103
+ type MetaWebOperationContract,
104
+ type MetaWebSite,
105
+ type ThreadsVideoProjection,
106
+ } from "./meta-web";
107
+ import { isoBmffVideoDimensions } from "./iso-bmff";
108
+ import {
109
+ bootstrapMetaComet,
110
+ consumeMetaCometRequestProof,
111
+ materializeMetaCometRequestProof,
112
+ type MetaCometRequestFieldName,
113
+ } from "./meta-bootstrap";
114
+ import { normalizeFacebookGroupFeedHtml } from "./meta-facebook-group";
115
+ import {
116
+ FACEBOOK_MARKETPLACE_PAGINATION_DESCRIPTOR,
117
+ bindFacebookMarketplacePaginationCursor,
118
+ buildFacebookMarketplacePaginationRequest,
119
+ facebookMarketplacePaginationCursorExhausted,
120
+ facebookMarketplacePaginationInputHash,
121
+ reconstructFacebookMarketplacePaginationCursor,
122
+ type FacebookMarketplacePaginationCursor,
123
+ } from "./meta-marketplace-relay";
124
+ import {
125
+ extractMetaRelayBundleUrls,
126
+ resolveMetaRelayOperationRevision,
127
+ } from "./meta-relay-bundle";
128
+ import {
129
+ assertMetaRelayResponseBinding,
130
+ resolveMetaOperationDescriptor,
131
+ } from "./meta-web-descriptors";
132
+
133
+ const ORIGINS = Object.freeze({
134
+ instagram: "https://www.instagram.com",
135
+ threads: "https://www.threads.com",
136
+ facebook: "https://www.facebook.com",
137
+ "facebook-page": "https://www.facebook.com",
138
+ "facebook-group": "https://www.facebook.com",
139
+ "facebook-marketplace": "https://www.facebook.com",
140
+ } as const satisfies Readonly<Record<MetaWebSite, string>>);
141
+
142
+ const MAX_BOOTSTRAP_BYTES = 12 * 1024 * 1024;
143
+ const MAX_API_BYTES = 8 * 1024 * 1024;
144
+ const MAX_META_RELAY_ASSETS = 16;
145
+ const MAX_META_RELAY_ASSET_BYTES = 3 * 1024 * 1024;
146
+ const MAX_THREADS_IMAGE_BYTES = 20 * 1024 * 1024;
147
+ const MAX_THREADS_VIDEO_BYTES = 1024 * 1024 * 1024;
148
+ const MAX_THREADS_UPLOAD_ACKNOWLEDGEMENT_BYTES = 16 * 1024;
149
+ const MARKETPLACE_CURSOR_SCOPE = "facebook-marketplace-feed";
150
+ const THREADS_WEB_APP_ID = "238260118697367";
151
+ const THREADS_ASBD_ID = "359341";
152
+ const INSTAGRAM_UPLOAD_ORIGIN = "https://i.instagram.com";
153
+ const INSTAGRAM_WEB_APP_ID = "936619743392459";
154
+ const INSTAGRAM_ASBD_ID = "359341";
155
+
156
+ export type MetaWebRuntimeDependencies = Partial<WebSessionNetworkDependencies> & {
157
+ readonly createInstagramProfileBrowserTransport?:
158
+ typeof createInstagramProfileBrowserTransport;
159
+ readonly now?: () => number;
160
+ };
161
+
162
+ function metaWebSessionDependencies(
163
+ site: MetaWebSite,
164
+ auth: GhostgetAuth,
165
+ dependencies: MetaWebRuntimeDependencies | undefined,
166
+ ): Partial<WebSessionNetworkDependencies> | undefined {
167
+ if (site !== "threads" && site !== "instagram") return dependencies;
168
+ const fallback = dependencies?.acquireCookies ?? acquireCookieRecords;
169
+ return {
170
+ ...(dependencies?.fetch === undefined ? {} : { fetch: dependencies.fetch }),
171
+ acquireCookies: (selection, target) => acquireWebSessionCookieRecords(
172
+ auth,
173
+ target,
174
+ selection.timeoutMs,
175
+ fallback,
176
+ ),
177
+ };
178
+ }
179
+
180
+ function isMetaSite(value: string): value is MetaWebSite {
181
+ return (META_WEB_SITES as readonly string[]).includes(value);
182
+ }
183
+
184
+ function operationContract(site: MetaWebSite, action: string): MetaWebOperationContract | null {
185
+ if (!(META_WEB_OPERATION_NAMES[site] as readonly string[]).includes(action)) return null;
186
+ const siteContracts: Readonly<Record<string, MetaWebOperationContract>> = META_WEB_OPERATIONS[site];
187
+ return siteContracts[action] ?? null;
188
+ }
189
+
190
+ function integerInput(
191
+ input: OperationInput,
192
+ name: string,
193
+ fallback: number,
194
+ minimum: number,
195
+ maximum: number,
196
+ ): number {
197
+ const value = input[name] ?? fallback;
198
+ if (!Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum) {
199
+ throw new Error(`input.${name} must be an integer between ${minimum} and ${maximum}`);
200
+ }
201
+ return value as number;
202
+ }
203
+
204
+ function exactStringInput(
205
+ input: OperationInput,
206
+ name: string,
207
+ pattern: RegExp,
208
+ label: string,
209
+ ): string {
210
+ const value = input[name];
211
+ if (typeof value !== "string" || !pattern.test(value)) throw new Error(`input.${name} must be ${label}`);
212
+ return value;
213
+ }
214
+
215
+ function exactEnumInput(
216
+ input: OperationInput,
217
+ name: string,
218
+ allowed: readonly string[],
219
+ ): string {
220
+ const value = input[name];
221
+ if (typeof value !== "string" || !allowed.includes(value)) {
222
+ throw new Error(`input.${name} must be ${allowed.join(" or ")}`);
223
+ }
224
+ return value;
225
+ }
226
+
227
+ function optionalOpaqueCursor(input: OperationInput, name: string): string | undefined {
228
+ const value = input[name];
229
+ if (value === undefined) return undefined;
230
+ if (
231
+ typeof value !== "string"
232
+ || value.length < 1
233
+ || value.length > 8_192
234
+ || [...value].some((character) => {
235
+ const codePoint = character.codePointAt(0);
236
+ return codePoint !== undefined && (codePoint <= 31 || codePoint === 127);
237
+ })
238
+ ) throw new Error(`input.${name} must be an exact bounded opaque cursor`);
239
+ return value;
240
+ }
241
+
242
+ function requireExactInputKeys(
243
+ input: OperationInput,
244
+ allowed: readonly string[],
245
+ ): void {
246
+ const allowedKeys = new Set(allowed);
247
+ const unexpected = Object.keys(input).filter((name) => !allowedKeys.has(name));
248
+ if (unexpected.length > 0) {
249
+ throw new Error(`input contains unsupported keys: ${unexpected.join(", ")}`);
250
+ }
251
+ }
252
+
253
+ function htmlHeaders(origin: string): Readonly<Record<string, string>> {
254
+ return Object.freeze({
255
+ accept: "text/html,application/xhtml+xml",
256
+ referer: `${origin}/`,
257
+ });
258
+ }
259
+
260
+ function instagramHeaders(referer: string): Readonly<Record<string, string>> {
261
+ return Object.freeze({
262
+ accept: "application/json, text/plain, */*",
263
+ referer,
264
+ "x-ig-app-id": INSTAGRAM_WEB_APP_ID,
265
+ "x-requested-with": "XMLHttpRequest",
266
+ });
267
+ }
268
+
269
+ function isRecord(value: unknown): value is Record<string, unknown> {
270
+ return typeof value === "object" && value !== null && !Array.isArray(value);
271
+ }
272
+
273
+ function bootstrapConfig(
274
+ html: string,
275
+ moduleName: string,
276
+ ): Readonly<Record<string, unknown>> {
277
+ const stack: unknown[] = [...parseMetaJsonScripts(html)];
278
+ const matches: Record<string, unknown>[] = [];
279
+ let visited = 0;
280
+ while (stack.length > 0) {
281
+ const value = stack.pop();
282
+ visited += 1;
283
+ if (visited > 250_000) throw new Error("Meta bootstrap configuration exceeded its reviewed bound");
284
+ if (Array.isArray(value)) {
285
+ if (value[0] === moduleName && isRecord(value[2])) matches.push(value[2]);
286
+ for (const item of value) stack.push(item);
287
+ } else if (isRecord(value)) {
288
+ for (const item of Object.values(value)) stack.push(item);
289
+ }
290
+ }
291
+ if (matches.length !== 1) {
292
+ throw new Error(`Meta bootstrap must contain one exact ${moduleName} configuration`);
293
+ }
294
+ return matches[0]!;
295
+ }
296
+
297
+ type ThreadsRequestConfig = {
298
+ readonly bloksVersionId: string;
299
+ readonly sprinkleParameter: "jazoest";
300
+ readonly sprinkleVersion: number;
301
+ };
302
+
303
+ function threadsRequestConfig(html: string): ThreadsRequestConfig {
304
+ const bloks = bootstrapConfig(html, "WebBloksVersioningID");
305
+ const sprinkle = bootstrapConfig(html, "SprinkleConfig");
306
+ if (
307
+ typeof bloks.versioningID !== "string"
308
+ || !/^[a-f0-9]{64}$/u.test(bloks.versioningID)
309
+ ) throw new Error("Threads bootstrap Web Bloks version is invalid");
310
+ if (
311
+ sprinkle.param_name !== "jazoest"
312
+ || !Number.isSafeInteger(sprinkle.version)
313
+ || (sprinkle.version as number) < 1
314
+ || (sprinkle.version as number) > 9
315
+ || sprinkle.should_randomize !== false
316
+ ) throw new Error("Threads bootstrap request-sprinkle configuration is invalid");
317
+ return Object.freeze({
318
+ bloksVersionId: bloks.versioningID,
319
+ sprinkleParameter: "jazoest",
320
+ sprinkleVersion: sprinkle.version as number,
321
+ });
322
+ }
323
+
324
+ export type InstagramVideoRequestConfig = Readonly<{
325
+ rolloutHash: string;
326
+ }>;
327
+
328
+ function exactInstagramRolloutHash(value: unknown): string {
329
+ if (
330
+ typeof value !== "string"
331
+ || value.length < 1
332
+ || value.length > 256
333
+ || /[^\x21-\x7e]/u.test(value)
334
+ ) throw new Error("Instagram bootstrap rollout hash is invalid");
335
+ return value;
336
+ }
337
+
338
+ /**
339
+ * Parse the one request value already established in the authenticated
340
+ * Instagram bootstrap. This does not create a client or perform a request.
341
+ */
342
+ export function instagramVideoRequestConfig(
343
+ html: string,
344
+ ): InstagramVideoRequestConfig {
345
+ const pushInfo = bootstrapConfig(html, "InstagramWebPushInfo");
346
+ return Object.freeze({
347
+ rolloutHash: exactInstagramRolloutHash(pushInfo.rollout_hash),
348
+ });
349
+ }
350
+
351
+ /**
352
+ * Construct only the established header shape. The live executor deliberately
353
+ * does not call this while the mutation response envelopes remain uncaptured.
354
+ */
355
+ export function instagramVideoMutationHeaders(
356
+ csrfToken: string,
357
+ config: InstagramVideoRequestConfig,
358
+ contentType:
359
+ | "application/x-www-form-urlencoded;charset=UTF-8"
360
+ | "image/jpeg"
361
+ | "video/mp4",
362
+ ): Readonly<Record<string, string>> {
363
+ if (
364
+ csrfToken.length < 1
365
+ || csrfToken.length > 512
366
+ || /[\0\r\n]/u.test(csrfToken)
367
+ ) throw new Error("Instagram CSRF cookie is invalid");
368
+ const rolloutHash = exactInstagramRolloutHash(config.rolloutHash);
369
+ return Object.freeze({
370
+ accept: "*/*",
371
+ "content-type": contentType,
372
+ origin: ORIGINS.instagram,
373
+ referer: `${ORIGINS.instagram}/`,
374
+ "x-asbd-id": INSTAGRAM_ASBD_ID,
375
+ "x-csrftoken": csrfToken,
376
+ "x-ig-app-id": INSTAGRAM_WEB_APP_ID,
377
+ "x-instagram-ajax": rolloutHash,
378
+ });
379
+ }
380
+
381
+ function threadsSprinkleValue(csrfToken: string, version: number): string {
382
+ if (csrfToken.length < 1 || csrfToken.length > 512 || /[\0\r\n]/u.test(csrfToken)) {
383
+ throw new Error("Threads CSRF cookie is invalid");
384
+ }
385
+ let total = 0;
386
+ for (const character of csrfToken) total += character.charCodeAt(0);
387
+ return `${version}${total}`;
388
+ }
389
+
390
+ function threadsWebSessionId(seed: string): string {
391
+ const numeric = Number(seed);
392
+ if (!Number.isSafeInteger(numeric)) throw new Error("Threads upload ID is invalid");
393
+ const pageId = (numeric % (36 ** 6)).toString(36).padStart(6, "0");
394
+ return `::${pageId}`;
395
+ }
396
+
397
+ function threadsApiHeaders(
398
+ client: WebSessionClient,
399
+ config: ThreadsRequestConfig,
400
+ webSessionId: string,
401
+ contentType: "application/json" | "application/x-www-form-urlencoded;charset=UTF-8",
402
+ ): Readonly<Record<string, string>> {
403
+ return Object.freeze({
404
+ accept: "*/*",
405
+ "content-type": contentType,
406
+ origin: ORIGINS.threads,
407
+ referer: `${ORIGINS.threads}/`,
408
+ "x-asbd-id": THREADS_ASBD_ID,
409
+ "x-bloks-version-id": config.bloksVersionId,
410
+ "x-csrftoken": webSessionCookie(client.cookies, "csrftoken"),
411
+ "x-ig-app-id": THREADS_WEB_APP_ID,
412
+ "x-instagram-ajax": "0",
413
+ "x-web-session-id": webSessionId,
414
+ });
415
+ }
416
+
417
+ function fileInput(value: OperationInput[string], label = "input.attachment"): FileInputValue {
418
+ if (
419
+ !isRecord(value)
420
+ || value.kind !== "file"
421
+ || typeof value.reference !== "string"
422
+ || Object.keys(value).sort().join(",") !== "kind,reference"
423
+ ) throw new Error(`${label} must be one plan-bound file`);
424
+ return Object.freeze({ kind: "file", reference: value.reference });
425
+ }
426
+
427
+ type ThreadsImage = {
428
+ readonly bytes: Uint8Array;
429
+ readonly height: number;
430
+ readonly mediaType: "image/png";
431
+ readonly width: number;
432
+ };
433
+
434
+ type ThreadsVideo = {
435
+ readonly bytes: Uint8Array;
436
+ readonly height: number;
437
+ readonly mediaType: "video/mp4";
438
+ readonly width: number;
439
+ };
440
+
441
+ async function materializeThreadsImage(
442
+ attachment: FileInputValue,
443
+ fileResolver: BrowserFileResolver | undefined,
444
+ operationDeadline: WebSessionOperationDeadline | undefined,
445
+ ): Promise<ThreadsImage> {
446
+ if (fileResolver === undefined) {
447
+ throw new Error("Threads image upload requires the plan-bound file resolver");
448
+ }
449
+ const paths = operationDeadline === undefined
450
+ ? await fileResolver([attachment])
451
+ : await operationDeadline.run(
452
+ () => fileResolver([attachment]),
453
+ "authenticated web operation deadline",
454
+ );
455
+ operationDeadline?.throwIfUnavailable("authenticated web operation deadline");
456
+ if (paths.length !== 1 || typeof paths[0] !== "string") {
457
+ throw new Error("Threads file resolver did not return one exact path");
458
+ }
459
+ const noFollow = "O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0;
460
+ const handle = operationDeadline === undefined
461
+ ? await open(paths[0], constants.O_RDONLY | noFollow)
462
+ : await operationDeadline.run(
463
+ () => open(paths[0]!, constants.O_RDONLY | noFollow),
464
+ "authenticated web operation deadline",
465
+ );
466
+ try {
467
+ const before = operationDeadline === undefined
468
+ ? await handle.stat()
469
+ : await operationDeadline.run(
470
+ () => handle.stat(),
471
+ "authenticated web operation deadline",
472
+ );
473
+ if (!before.isFile() || before.size < 24 || before.size > MAX_THREADS_IMAGE_BYTES) {
474
+ throw new Error("Threads image must be a regular PNG no larger than 20 MiB");
475
+ }
476
+ const bytes = operationDeadline === undefined
477
+ ? await handle.readFile()
478
+ : await operationDeadline.run(
479
+ () => handle.readFile(),
480
+ "authenticated web operation deadline",
481
+ );
482
+ const after = operationDeadline === undefined
483
+ ? await handle.stat()
484
+ : await operationDeadline.run(
485
+ () => handle.stat(),
486
+ "authenticated web operation deadline",
487
+ );
488
+ if (
489
+ before.dev !== after.dev
490
+ || before.ino !== after.ino
491
+ || before.size !== after.size
492
+ || before.mtimeMs !== after.mtimeMs
493
+ || before.ctimeMs !== after.ctimeMs
494
+ || bytes.byteLength !== before.size
495
+ ) throw new Error("Threads image changed while it was materialized");
496
+ const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
497
+ if (
498
+ signature.some((value, index) => bytes[index] !== value)
499
+ || bytes.subarray(12, 16).toString("ascii") !== "IHDR"
500
+ ) throw new Error("Threads image must be a PNG fixture");
501
+ const width = bytes.readUInt32BE(16);
502
+ const height = bytes.readUInt32BE(20);
503
+ if (width < 1 || height < 1 || width > 20_000 || height > 20_000) {
504
+ throw new Error("Threads PNG dimensions are outside the reviewed bound");
505
+ }
506
+ return Object.freeze({
507
+ bytes: new Uint8Array(bytes),
508
+ height,
509
+ mediaType: "image/png",
510
+ width,
511
+ });
512
+ } finally {
513
+ await handle.close();
514
+ }
515
+ }
516
+
517
+ async function materializeThreadsVideo(
518
+ media: FileInputValue,
519
+ fileResolver: BrowserFileResolver | undefined,
520
+ operationDeadline: WebSessionOperationDeadline | undefined,
521
+ ): Promise<ThreadsVideo> {
522
+ if (fileResolver === undefined) {
523
+ throw new Error("Threads video upload requires the plan-bound file resolver");
524
+ }
525
+ const paths = operationDeadline === undefined
526
+ ? await fileResolver([media])
527
+ : await operationDeadline.run(
528
+ () => fileResolver([media]),
529
+ "authenticated web operation deadline",
530
+ );
531
+ operationDeadline?.throwIfUnavailable("authenticated web operation deadline");
532
+ if (paths.length !== 1 || typeof paths[0] !== "string") {
533
+ throw new Error("Threads file resolver did not return one exact path");
534
+ }
535
+ const noFollow = "O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0;
536
+ const handle = operationDeadline === undefined
537
+ ? await open(paths[0], constants.O_RDONLY | noFollow)
538
+ : await operationDeadline.run(
539
+ () => open(paths[0]!, constants.O_RDONLY | noFollow),
540
+ "authenticated web operation deadline",
541
+ );
542
+ try {
543
+ const before = operationDeadline === undefined
544
+ ? await handle.stat()
545
+ : await operationDeadline.run(
546
+ () => handle.stat(),
547
+ "authenticated web operation deadline",
548
+ );
549
+ if (!before.isFile() || before.size < 24 || before.size > MAX_THREADS_VIDEO_BYTES) {
550
+ throw new Error("Threads video must be a regular MP4 no larger than 1 GiB");
551
+ }
552
+ const bytes = operationDeadline === undefined
553
+ ? await handle.readFile()
554
+ : await operationDeadline.run(
555
+ () => handle.readFile(),
556
+ "authenticated web operation deadline",
557
+ );
558
+ const after = operationDeadline === undefined
559
+ ? await handle.stat()
560
+ : await operationDeadline.run(
561
+ () => handle.stat(),
562
+ "authenticated web operation deadline",
563
+ );
564
+ if (
565
+ before.dev !== after.dev
566
+ || before.ino !== after.ino
567
+ || before.size !== after.size
568
+ || before.mtimeMs !== after.mtimeMs
569
+ || before.ctimeMs !== after.ctimeMs
570
+ || bytes.byteLength !== before.size
571
+ ) throw new Error("Threads video changed while it was materialized");
572
+ const dimensions = isoBmffVideoDimensions(bytes, "Threads video");
573
+ return Object.freeze({
574
+ bytes: new Uint8Array(bytes),
575
+ height: dimensions.height,
576
+ mediaType: "video/mp4",
577
+ width: dimensions.width,
578
+ });
579
+ } finally {
580
+ await handle.close();
581
+ }
582
+ }
583
+
584
+ async function rootHtml(client: WebSessionClient, origin: string): Promise<string> {
585
+ return client.requestText({
586
+ url: new URL("/", origin),
587
+ headers: htmlHeaders(origin),
588
+ expectedContentTypes: ["text/html"],
589
+ maxBytes: MAX_BOOTSTRAP_BYTES,
590
+ });
591
+ }
592
+
593
+ async function metaHtmlPath(
594
+ client: WebSessionClient,
595
+ origin: string,
596
+ path: string,
597
+ refererPath = "/",
598
+ ): Promise<string> {
599
+ return client.requestText({
600
+ url: new URL(path, origin),
601
+ headers: Object.freeze({
602
+ accept: "text/html,application/xhtml+xml",
603
+ referer: new URL(refererPath, origin).href,
604
+ }),
605
+ expectedContentTypes: ["text/html"],
606
+ maxBytes: MAX_BOOTSTRAP_BYTES,
607
+ });
608
+ }
609
+
610
+ async function resolveCurrentMetaRelayOperation(
611
+ html: string,
612
+ friendlyName: string,
613
+ timeoutMs: number,
614
+ dependencies?: MetaWebRuntimeDependencies,
615
+ budget: {
616
+ readonly signal?: AbortSignal;
617
+ readonly operationDeadline?: WebSessionOperationDeadline;
618
+ } = {},
619
+ ): Promise<void> {
620
+ const urls = extractMetaRelayBundleUrls(html);
621
+ if (urls.length > MAX_META_RELAY_ASSETS) {
622
+ throw new Error("Meta Relay bootstrap exposed too many executable bundle assets");
623
+ }
624
+ const texts = await Promise.all(urls.map((url) =>
625
+ fetchPublicWebAsset(new URL(url), {
626
+ allowedOrigin: "https://static.xx.fbcdn.net",
627
+ contentTypes: [
628
+ "application/javascript",
629
+ "application/x-javascript",
630
+ "text/javascript",
631
+ ],
632
+ maxBytes: MAX_META_RELAY_ASSET_BYTES,
633
+ timeoutMs,
634
+ ...(budget.signal === undefined ? {} : { signal: budget.signal }),
635
+ ...(budget.operationDeadline === undefined
636
+ ? {}
637
+ : { operationDeadline: budget.operationDeadline }),
638
+ ...(dependencies === undefined ? {} : { dependencies }),
639
+ })));
640
+ const revision = resolveMetaRelayOperationRevision(texts, friendlyName);
641
+ resolveMetaOperationDescriptor([{
642
+ friendlyName: revision.friendlyName,
643
+ docId: revision.docId,
644
+ operationType: "query",
645
+ origin: "https://www.facebook.com",
646
+ method: "POST",
647
+ path: "/api/graphql/",
648
+ }], FACEBOOK_MARKETPLACE_PAGINATION_DESCRIPTOR);
649
+ }
650
+
651
+ async function executeFacebookMarketplaceContinuation(
652
+ client: WebSessionClient,
653
+ html: string,
654
+ viewerId: string,
655
+ cursor: FacebookMarketplacePaginationCursor,
656
+ limit: number,
657
+ recipe: WebSessionRecipe,
658
+ dependencies?: MetaWebRuntimeDependencies,
659
+ budget: {
660
+ readonly signal?: AbortSignal;
661
+ readonly operationDeadline?: WebSessionOperationDeadline;
662
+ } = {},
663
+ ): Promise<FacebookMarketplaceFeed> {
664
+ const bootstrap = bootstrapMetaComet(html, {
665
+ parseMetaJsonScripts,
666
+ expectedViewerId: viewerId,
667
+ expectedActingId: viewerId,
668
+ });
669
+ const request = buildFacebookMarketplacePaginationRequest(html, viewerId, cursor);
670
+ const friendlyName = request.parameters.find(
671
+ ({ name }) => name === "fb_api_req_friendly_name",
672
+ )?.value;
673
+ if (friendlyName === undefined) {
674
+ throw new Error("Marketplace Relay request omitted its reviewed friendly name");
675
+ }
676
+ await resolveCurrentMetaRelayOperation(
677
+ html,
678
+ friendlyName,
679
+ recipe.timeoutMs,
680
+ dependencies,
681
+ budget,
682
+ );
683
+
684
+ const form = new URLSearchParams();
685
+ form.set("__a", "1");
686
+ form.set("fb_api_caller_class", "RelayModern");
687
+ form.set("server_timestamps", "true");
688
+ for (const parameter of request.parameters) form.set(parameter.name, parameter.value);
689
+ const variablesText = form.get("variables");
690
+ if (variablesText === null) throw new Error("Marketplace Relay request omitted variables");
691
+ let variables: unknown;
692
+ try {
693
+ variables = JSON.parse(variablesText) as unknown;
694
+ } catch {
695
+ throw new Error("Marketplace Relay request variables were malformed");
696
+ }
697
+ if (
698
+ typeof variables !== "object"
699
+ || variables === null
700
+ || Array.isArray(variables)
701
+ || !Number.isSafeInteger((variables as Record<string, unknown>).scale)
702
+ ) throw new Error("Marketplace Relay request omitted its reviewed display scale");
703
+ form.set("dpr", String((variables as Record<string, unknown>).scale));
704
+
705
+ const writtenProofs: string[] = [];
706
+ consumeMetaCometRequestProof(
707
+ materializeMetaCometRequestProof(bootstrap, request),
708
+ request,
709
+ {
710
+ sink: "network-request",
711
+ write: (name: MetaCometRequestFieldName, value: string) => {
712
+ if (!request.proofFormFields.includes(name)) {
713
+ throw new Error("Marketplace Relay proof escaped its descriptor-owned sink");
714
+ }
715
+ writtenProofs.push(name);
716
+ form.set(name, value);
717
+ },
718
+ },
719
+ );
720
+ if (
721
+ writtenProofs.length !== request.proofFormFields.length
722
+ || request.proofFormFields.some((name) => !writtenProofs.includes(name))
723
+ ) throw new Error("Marketplace Relay request omitted a descriptor-owned proof");
724
+
725
+ const text = await client.requestText({
726
+ url: new URL(request.path, request.origin),
727
+ method: "POST",
728
+ headers: Object.freeze({
729
+ accept: "*/*",
730
+ "content-type": "application/x-www-form-urlencoded",
731
+ referer: "https://www.facebook.com/marketplace/",
732
+ }),
733
+ body: form.toString(),
734
+ expectedContentTypes: ["text/html"],
735
+ maxBytes: Math.min(recipe.maxOutputBytes, MAX_API_BYTES),
736
+ });
737
+ const documents = parseMetaJsonDocuments(text);
738
+ const firstDocument = documents[0];
739
+ if (firstDocument === undefined) {
740
+ throw new Error("Marketplace Relay response omitted its descriptor-bound root document");
741
+ }
742
+ const response = assertMetaRelayResponseBinding(
743
+ FACEBOOK_MARKETPLACE_PAGINATION_DESCRIPTOR,
744
+ firstDocument,
745
+ );
746
+ return normalizeFacebookMarketplaceFeedJsonDocuments(
747
+ documents,
748
+ cursor.cursor,
749
+ limit,
750
+ response.value,
751
+ );
752
+ }
753
+
754
+ function threadsUploadId(now: () => number): string {
755
+ const value = Math.trunc(now());
756
+ const uploadId = String(value);
757
+ if (!/^[1-9][0-9]{12}$/u.test(uploadId)) {
758
+ throw new Error("Threads upload clock did not produce one canonical 13-digit ID");
759
+ }
760
+ return uploadId;
761
+ }
762
+
763
+ export function instagramVideoUploadId(now: () => number): string {
764
+ const value = Math.trunc(now());
765
+ const uploadId = String(value);
766
+ if (!/^[1-9][0-9]{12}$/u.test(uploadId)) {
767
+ throw new Error("Instagram upload clock did not produce one canonical 13-digit ID");
768
+ }
769
+ return uploadId;
770
+ }
771
+
772
+ export type InstagramVideoUploadRequestShape = Readonly<{
773
+ headers: Readonly<Record<string, string>>;
774
+ method: "POST";
775
+ url: string;
776
+ }>;
777
+
778
+ /**
779
+ * Build the established cross-origin upload request shape without reading a
780
+ * file, acquiring credentials, opening a network client, or assuming an
781
+ * acknowledgement envelope.
782
+ */
783
+ export function instagramVideoUploadRequestShape(
784
+ snapshot: Pick<
785
+ ReturnType<typeof revalidateInstagramVideoPublishBindingForDispatch>,
786
+ | "byteLength"
787
+ | "durationMilliseconds"
788
+ | "height"
789
+ | "mediaType"
790
+ | "width"
791
+ >,
792
+ uploadId: string,
793
+ csrfToken: string,
794
+ config: InstagramVideoRequestConfig,
795
+ ): InstagramVideoUploadRequestShape {
796
+ if (
797
+ !/^[1-9][0-9]{12}$/u.test(uploadId)
798
+ || !Number.isSafeInteger(snapshot.byteLength)
799
+ || snapshot.byteLength < 24
800
+ || snapshot.byteLength > 128 * 1024 * 1024
801
+ || !Number.isSafeInteger(snapshot.durationMilliseconds)
802
+ || snapshot.durationMilliseconds < 1
803
+ || snapshot.durationMilliseconds > 86_400_000
804
+ || !Number.isSafeInteger(snapshot.height)
805
+ || snapshot.height < 1
806
+ || snapshot.height > 20_000
807
+ || !Number.isSafeInteger(snapshot.width)
808
+ || snapshot.width < 1
809
+ || snapshot.width > 20_000
810
+ || snapshot.mediaType !== "video/mp4"
811
+ ) throw new Error("Instagram upload request input is outside its reviewed bound");
812
+ const entityName = `fb_uploader_${uploadId}`;
813
+ return Object.freeze({
814
+ url: new URL(`/rupload_igvideo/${entityName}`, INSTAGRAM_UPLOAD_ORIGIN).href,
815
+ method: "POST",
816
+ headers: Object.freeze({
817
+ ...instagramVideoMutationHeaders(csrfToken, config, "video/mp4"),
818
+ offset: "0",
819
+ "x-entity-length": String(snapshot.byteLength),
820
+ "x-entity-name": entityName,
821
+ "x-instagram-rupload-params": JSON.stringify({
822
+ "client-passthrough": "1",
823
+ is_clips_video: "1",
824
+ for_album: false,
825
+ is_sidecar: "0",
826
+ media_type: 2,
827
+ upload_id: uploadId,
828
+ upload_media_duration_ms: snapshot.durationMilliseconds,
829
+ upload_media_height: snapshot.height,
830
+ upload_media_width: snapshot.width,
831
+ video_format: snapshot.mediaType,
832
+ video_transform: null,
833
+ }),
834
+ }),
835
+ });
836
+ }
837
+
838
+ export type InstagramVideoThumbnailUploadRequestShape = Readonly<{
839
+ headers: Readonly<Record<string, string>>;
840
+ method: "POST";
841
+ url: string;
842
+ }>;
843
+
844
+ /** Build the observed same-identity JPEG cover upload. */
845
+ export function instagramVideoThumbnailUploadRequestShape(
846
+ thumbnail: Pick<
847
+ ReturnType<typeof revalidateInstagramVideoPublishBindingForDispatch>,
848
+ | "thumbnailByteLength"
849
+ | "thumbnailHeight"
850
+ | "thumbnailMediaType"
851
+ | "thumbnailWidth"
852
+ >,
853
+ uploadId: string,
854
+ csrfToken: string,
855
+ config: InstagramVideoRequestConfig,
856
+ ): InstagramVideoThumbnailUploadRequestShape {
857
+ if (
858
+ !/^[1-9][0-9]{12}$/u.test(uploadId)
859
+ || !Number.isSafeInteger(thumbnail.thumbnailByteLength)
860
+ || thumbnail.thumbnailByteLength < 16
861
+ || thumbnail.thumbnailByteLength > 8 * 1024 * 1024
862
+ || !Number.isSafeInteger(thumbnail.thumbnailHeight)
863
+ || thumbnail.thumbnailHeight < 1
864
+ || thumbnail.thumbnailHeight > 20_000
865
+ || !Number.isSafeInteger(thumbnail.thumbnailWidth)
866
+ || thumbnail.thumbnailWidth < 1
867
+ || thumbnail.thumbnailWidth > 20_000
868
+ || thumbnail.thumbnailMediaType !== "image/jpeg"
869
+ ) throw new Error("Instagram thumbnail upload request input is outside its reviewed bound");
870
+ const entityName = `fb_uploader_${uploadId}`;
871
+ return Object.freeze({
872
+ url: new URL(`/rupload_igphoto/${entityName}`, INSTAGRAM_UPLOAD_ORIGIN).href,
873
+ method: "POST" as const,
874
+ headers: Object.freeze({
875
+ ...instagramVideoMutationHeaders(csrfToken, config, "image/jpeg"),
876
+ offset: "0",
877
+ "x-entity-length": String(thumbnail.thumbnailByteLength),
878
+ "x-entity-name": entityName,
879
+ "x-entity-type": "image/jpeg",
880
+ "x-instagram-rupload-params": JSON.stringify({
881
+ is_sidecar: "0",
882
+ media_type: 1,
883
+ upload_id: uploadId,
884
+ upload_media_height: thumbnail.thumbnailHeight,
885
+ upload_media_width: thumbnail.thumbnailWidth,
886
+ }),
887
+ }),
888
+ });
889
+ }
890
+
891
+ function exactInstagramConfigureInput(caption: string, uploadId: string): void {
892
+ if (
893
+ typeof caption !== "string"
894
+ || caption.length < 1
895
+ || caption.length > 1_000
896
+ || /[\0\r]/u.test(caption)
897
+ ) throw new Error("Instagram configure caption is outside its reviewed bound");
898
+ if (!/^[1-9][0-9]{12}$/u.test(uploadId)) {
899
+ throw new Error("Instagram configure upload ID is invalid");
900
+ }
901
+ }
902
+
903
+ export function instagramVideoConfigurePayload(
904
+ caption: string,
905
+ uploadId: string,
906
+ ): Readonly<Record<string, boolean | number | string>> {
907
+ exactInstagramConfigureInput(caption, uploadId);
908
+ return Object.freeze({
909
+ archive_only: false,
910
+ caption,
911
+ clips_share_preview_to_feed: "1",
912
+ disable_comments: "0",
913
+ disable_oa_reuse: false,
914
+ igtv_share_preview_to_feed: 1,
915
+ is_meta_only_post: "0",
916
+ is_unified_video: 1,
917
+ like_and_view_counts_disabled: "0",
918
+ media_share_flow: "creation_flow",
919
+ share_to_facebook: "",
920
+ share_to_fb_destination_type: "USER",
921
+ source_type: "library",
922
+ upload_id: uploadId,
923
+ video_subtitles_enabled: "0",
924
+ });
925
+ }
926
+
927
+ export function instagramVideoConfigureForm(
928
+ caption: string,
929
+ uploadId: string,
930
+ ): string {
931
+ const payload = instagramVideoConfigurePayload(caption, uploadId);
932
+ const form = new URLSearchParams();
933
+ for (const [name, value] of Object.entries(payload)) form.set(name, String(value));
934
+ form.set("signed_body", `SIGNATURE.${JSON.stringify(payload)}`);
935
+ return form.toString();
936
+ }
937
+
938
+ export type InstagramConfigureDispatchDecision =
939
+ | Readonly<{
940
+ kind: "inspect-terminal-envelope";
941
+ }>
942
+ | Readonly<{
943
+ kind: "retain-indeterminate-dispatch";
944
+ }>;
945
+
946
+ /**
947
+ * Classify the one allowed configure POST response. A 202 never authorizes a
948
+ * repeated mutation: retain the dispatch as indeterminate until separately
949
+ * obtained exact evidence can reconcile it. Both response bodies remain
950
+ * uninterpreted until a reviewed capture establishes their exact envelopes.
951
+ */
952
+ export function instagramConfigureDispatchDecision(
953
+ status: number,
954
+ ): InstagramConfigureDispatchDecision {
955
+ if (status === 200) {
956
+ return Object.freeze({ kind: "inspect-terminal-envelope" as const });
957
+ }
958
+ if (status === 202) {
959
+ return Object.freeze({ kind: "retain-indeterminate-dispatch" as const });
960
+ }
961
+ throw new Error("Instagram configure returned an unreviewed HTTP status");
962
+ }
963
+
964
+ export type InstagramVideoConfigureRequestShape = Readonly<{
965
+ body: string;
966
+ headers: Readonly<Record<string, string>>;
967
+ method: "POST";
968
+ url: string;
969
+ }>;
970
+
971
+ /** Construct the established configure request without performing it. */
972
+ export function instagramVideoConfigureRequestShape(
973
+ caption: string,
974
+ uploadId: string,
975
+ csrfToken: string,
976
+ config: InstagramVideoRequestConfig,
977
+ ): InstagramVideoConfigureRequestShape {
978
+ return Object.freeze({
979
+ body: instagramVideoConfigureForm(caption, uploadId),
980
+ headers: instagramVideoMutationHeaders(
981
+ csrfToken,
982
+ config,
983
+ "application/x-www-form-urlencoded;charset=UTF-8",
984
+ ),
985
+ method: "POST" as const,
986
+ url: new URL("/api/v1/media/configure_to_clips/", ORIGINS.instagram).href,
987
+ });
988
+ }
989
+
990
+ export type InstagramVideoDeleteRequestShape = Readonly<{
991
+ body: "";
992
+ headers: Readonly<Record<string, string>>;
993
+ method: "POST";
994
+ url: string;
995
+ }>;
996
+
997
+ /** Build the observed empty-body authored-media deletion request. */
998
+ export function instagramVideoDeleteRequestShape(
999
+ mediaId: string,
1000
+ csrfToken: string,
1001
+ config: InstagramVideoRequestConfig,
1002
+ ): InstagramVideoDeleteRequestShape {
1003
+ if (!/^[1-9][0-9]{0,31}_[1-9][0-9]{0,31}$/u.test(mediaId)) {
1004
+ throw new Error("Instagram deletion request media ID is invalid");
1005
+ }
1006
+ return Object.freeze({
1007
+ body: "" as const,
1008
+ headers: instagramVideoMutationHeaders(
1009
+ csrfToken,
1010
+ config,
1011
+ "application/x-www-form-urlencoded;charset=UTF-8",
1012
+ ),
1013
+ method: "POST" as const,
1014
+ url: new URL(
1015
+ `/api/v1/web/create/${mediaId}/delete/`,
1016
+ ORIGINS.instagram,
1017
+ ).href,
1018
+ });
1019
+ }
1020
+
1021
+ type ThreadsUploadedMedia = Readonly<{
1022
+ height: number;
1023
+ id: string;
1024
+ mediaType: 1 | 2;
1025
+ sourceMediaType: "image/png" | "video/mp4";
1026
+ width: number;
1027
+ }>;
1028
+
1029
+ function assertThreadsUploadAcknowledgement(
1030
+ value: unknown,
1031
+ uploadId: string,
1032
+ ): void {
1033
+ if (
1034
+ !isRecord(value)
1035
+ || Object.keys(value).sort().join(",") !== "status,upload_id"
1036
+ || value.status !== "ok"
1037
+ || value.upload_id !== uploadId
1038
+ ) throw new Error("Threads upload acknowledgement did not bind the exact upload ID");
1039
+ }
1040
+
1041
+ async function uploadThreadsImage(
1042
+ client: WebSessionClient,
1043
+ image: ThreadsImage,
1044
+ uploadId: string,
1045
+ ): Promise<ThreadsUploadedMedia> {
1046
+ const entityName = `fb_uploader_${uploadId}`;
1047
+ const acknowledgement = await client.requestJson({
1048
+ url: new URL(`/rupload_igphoto/${entityName}`, ORIGINS.threads),
1049
+ method: "POST",
1050
+ headers: {
1051
+ accept: "*/*",
1052
+ "content-type": image.mediaType,
1053
+ offset: "0",
1054
+ origin: ORIGINS.threads,
1055
+ referer: `${ORIGINS.threads}/`,
1056
+ "x-entity-length": String(image.bytes.byteLength),
1057
+ "x-entity-name": entityName,
1058
+ "x-entity-type": image.mediaType,
1059
+ "x-ig-app-id": THREADS_WEB_APP_ID,
1060
+ "x-instagram-rupload-params": JSON.stringify({
1061
+ is_sidecar: "0",
1062
+ is_threads: "1",
1063
+ media_type: 1,
1064
+ upload_id: uploadId,
1065
+ upload_media_height: image.height,
1066
+ upload_media_width: image.width,
1067
+ }),
1068
+ },
1069
+ body: image.bytes,
1070
+ expectedStatuses: [200],
1071
+ expectedContentTypes: ["application/json"],
1072
+ maxBytes: MAX_THREADS_UPLOAD_ACKNOWLEDGEMENT_BYTES,
1073
+ });
1074
+ assertThreadsUploadAcknowledgement(acknowledgement, uploadId);
1075
+ return Object.freeze({
1076
+ height: image.height,
1077
+ id: uploadId,
1078
+ mediaType: 1,
1079
+ sourceMediaType: image.mediaType,
1080
+ width: image.width,
1081
+ });
1082
+ }
1083
+
1084
+ async function uploadThreadsVideo(
1085
+ client: WebSessionClient,
1086
+ video: ThreadsVideo,
1087
+ uploadId: string,
1088
+ ): Promise<ThreadsUploadedMedia> {
1089
+ const entityName = `fb_uploader_${uploadId}`;
1090
+ const acknowledgement = await client.requestJson({
1091
+ url: new URL(`/rupload_igvideo/${entityName}`, ORIGINS.threads),
1092
+ method: "POST",
1093
+ headers: {
1094
+ accept: "*/*",
1095
+ "content-type": video.mediaType,
1096
+ offset: "0",
1097
+ origin: ORIGINS.threads,
1098
+ referer: `${ORIGINS.threads}/`,
1099
+ "x-entity-length": String(video.bytes.byteLength),
1100
+ "x-entity-name": entityName,
1101
+ "x-entity-type": video.mediaType,
1102
+ "x-ig-app-id": THREADS_WEB_APP_ID,
1103
+ "x-instagram-rupload-params": JSON.stringify({
1104
+ extract_cover_frame: "1",
1105
+ is_sidecar: "0",
1106
+ is_threads: "1",
1107
+ media_type: 2,
1108
+ upload_id: uploadId,
1109
+ upload_media_height: video.height,
1110
+ upload_media_width: video.width,
1111
+ }),
1112
+ },
1113
+ body: video.bytes,
1114
+ expectedStatuses: [200],
1115
+ expectedContentTypes: ["application/json"],
1116
+ maxBytes: MAX_THREADS_UPLOAD_ACKNOWLEDGEMENT_BYTES,
1117
+ });
1118
+ assertThreadsUploadAcknowledgement(acknowledgement, uploadId);
1119
+ return Object.freeze({
1120
+ height: video.height,
1121
+ id: uploadId,
1122
+ mediaType: 2,
1123
+ sourceMediaType: video.mediaType,
1124
+ width: video.width,
1125
+ });
1126
+ }
1127
+
1128
+ export type ThreadsPostLocator = Readonly<{
1129
+ readonly code: string;
1130
+ readonly id: string;
1131
+ readonly url: string;
1132
+ }>;
1133
+
1134
+ type ThreadsCreatedPost = Readonly<{
1135
+ readonly locator: ThreadsPostLocator;
1136
+ readonly media: Readonly<{
1137
+ readonly height: number;
1138
+ readonly mediaId: string;
1139
+ readonly mediaType: 1 | 2;
1140
+ readonly width: number;
1141
+ }> | null;
1142
+ }>;
1143
+
1144
+ type ThreadsCreateFailureCategory =
1145
+ | "transport"
1146
+ | "status"
1147
+ | "content-type"
1148
+ | "json"
1149
+ | "success-shape"
1150
+ | "identifiers"
1151
+ | "permalink"
1152
+ | "actor"
1153
+ | "unexpected";
1154
+
1155
+ class ThreadsCreateResponseError extends Error {
1156
+ readonly category: ThreadsCreateFailureCategory;
1157
+
1158
+ constructor(
1159
+ category: ThreadsCreateFailureCategory,
1160
+ message: string,
1161
+ options: { readonly cause?: unknown } = {},
1162
+ ) {
1163
+ super(message, options.cause === undefined ? undefined : { cause: options.cause });
1164
+ this.name = "ThreadsCreateResponseError";
1165
+ this.category = category;
1166
+ }
1167
+ }
1168
+
1169
+ function threadsCreateRequestFailureCategory(error: unknown): ThreadsCreateFailureCategory {
1170
+ if (!(error instanceof Error)) return "unexpected";
1171
+ if (
1172
+ error.message === "authenticated web API request failed before a reviewed response was received"
1173
+ || error.message.includes("authenticated web operation deadline")
1174
+ || error.message.includes("authenticated web operation timed out")
1175
+ ) return "transport";
1176
+ const statusAndContentType =
1177
+ /^authenticated web API returned unreviewed status\/content type ([0-9]{3})\//u.exec(
1178
+ error.message,
1179
+ );
1180
+ if (statusAndContentType !== null) {
1181
+ return statusAndContentType[1] === "200" ? "content-type" : "status";
1182
+ }
1183
+ if (
1184
+ error.message === "authenticated web API returned invalid UTF-8 JSON"
1185
+ || error.message === "authenticated web API returned malformed JSON"
1186
+ ) return "json";
1187
+ return "unexpected";
1188
+ }
1189
+
1190
+ function threadsCreatedPost(
1191
+ value: unknown,
1192
+ viewerId: string,
1193
+ expectedBody: string,
1194
+ uploaded: ThreadsUploadedMedia | null,
1195
+ ): ThreadsCreatedPost {
1196
+ // The reviewed synchronous composer response projects only the new post
1197
+ // locator. Keep the upload dimensions locally bound, then require the
1198
+ // independent permalink readback below to prove actor, text, and image.
1199
+ if (
1200
+ !isRecord(value)
1201
+ || Object.keys(value).sort().join(",") !== "media,status"
1202
+ || value.status !== "ok"
1203
+ || !isRecord(value.media)
1204
+ ) {
1205
+ throw new ThreadsCreateResponseError(
1206
+ "success-shape",
1207
+ "Threads create response did not match the reviewed success shape",
1208
+ );
1209
+ }
1210
+ const minimalLocator = Object.keys(value.media).sort().join(",") === "code,permalink,pk";
1211
+ if (!minimalLocator && uploaded?.mediaType !== 2) {
1212
+ throw new ThreadsCreateResponseError(
1213
+ "success-shape",
1214
+ "Threads create response did not match the reviewed success shape",
1215
+ );
1216
+ }
1217
+ if (!minimalLocator) {
1218
+ try {
1219
+ const projected = projectThreadsPublishVideo(
1220
+ value.media,
1221
+ "Threads video create response media",
1222
+ );
1223
+ const projectedUser = isRecord(projected.user) ? projected.user : null;
1224
+ if (
1225
+ projected.caption !== expectedBody
1226
+ || projected.user === null
1227
+ || projectedUser?.id !== viewerId
1228
+ || projected.video === null
1229
+ || projected.video.mediaId !== projected.id
1230
+ || uploaded === null
1231
+ || projected.video.mediaType !== uploaded.mediaType
1232
+ || projected.video.width !== uploaded.width
1233
+ || projected.video.height !== uploaded.height
1234
+ || (projected.canonical_url !== null
1235
+ && projected.canonical_url !== value.media.permalink)
1236
+ ) {
1237
+ throw new Error("unbound rich video response");
1238
+ }
1239
+ } catch {
1240
+ throw new ThreadsCreateResponseError(
1241
+ "success-shape",
1242
+ "Threads video create response did not bind the confirmed actor, text, and media",
1243
+ );
1244
+ }
1245
+ }
1246
+ const { code, permalink, pk } = value.media;
1247
+ if (
1248
+ typeof code !== "string"
1249
+ || !/^[A-Za-z0-9_-]{1,64}$/u.test(code)
1250
+ || typeof permalink !== "string"
1251
+ || permalink.length > 2_048
1252
+ || typeof pk !== "string"
1253
+ || !/^[0-9]{1,32}(?:_[0-9]{1,32})?$/u.test(pk)
1254
+ ) {
1255
+ throw new ThreadsCreateResponseError(
1256
+ "identifiers",
1257
+ "Threads create response returned invalid post identifiers",
1258
+ );
1259
+ }
1260
+ let url: URL;
1261
+ try {
1262
+ url = new URL(permalink);
1263
+ } catch {
1264
+ throw new ThreadsCreateResponseError(
1265
+ "permalink",
1266
+ "Threads create response returned an unreviewed permalink",
1267
+ );
1268
+ }
1269
+ const path = url.pathname.split("/");
1270
+ if (
1271
+ url.origin !== ORIGINS.threads
1272
+ || url.username !== ""
1273
+ || url.password !== ""
1274
+ || url.search !== ""
1275
+ || url.hash !== ""
1276
+ || path.length !== 4
1277
+ || !/^@[A-Za-z0-9._]{1,64}$/u.test(path[1] ?? "")
1278
+ || path[2] !== "post"
1279
+ || path[3] !== code
1280
+ ) {
1281
+ throw new ThreadsCreateResponseError(
1282
+ "permalink",
1283
+ "Threads create response returned an unreviewed permalink",
1284
+ );
1285
+ }
1286
+ if (pk.includes("_") && !pk.endsWith(`_${viewerId}`)) {
1287
+ throw new ThreadsCreateResponseError(
1288
+ "actor",
1289
+ "Threads create response changed the confirmed actor",
1290
+ );
1291
+ }
1292
+ return Object.freeze({
1293
+ locator: Object.freeze({ code, id: pk, url: url.href }),
1294
+ media: uploaded === null
1295
+ ? null
1296
+ : Object.freeze({
1297
+ height: uploaded.height,
1298
+ mediaId: pk,
1299
+ mediaType: uploaded.mediaType,
1300
+ width: uploaded.width,
1301
+ }),
1302
+ });
1303
+ }
1304
+
1305
+ function threadsTextPostAppInfo(body: string): string {
1306
+ return JSON.stringify({
1307
+ excluded_inline_media_ids: "[]",
1308
+ is_genai_invocation_post: false,
1309
+ is_reply_approval_enabled: false,
1310
+ is_spoiler_media: false,
1311
+ text_with_entities: {
1312
+ entities: [],
1313
+ text: body,
1314
+ },
1315
+ });
1316
+ }
1317
+
1318
+ async function createThreadsPost(
1319
+ client: WebSessionClient,
1320
+ viewer: BoundMetaViewer,
1321
+ prepared: Extract<PreparedMetaRead, { readonly kind: "threads-post" | "threads-video" }>,
1322
+ uploaded: ThreadsUploadedMedia | null,
1323
+ config: ThreadsRequestConfig,
1324
+ uploadId: string,
1325
+ ): Promise<ThreadsCreatedPost> {
1326
+ const csrfToken = webSessionCookie(client.cookies, "csrftoken");
1327
+ const webSessionId = threadsWebSessionId(uploadId);
1328
+ const form = new URLSearchParams();
1329
+ form.set("audience", prepared.audience);
1330
+ form.set("caption", prepared.body);
1331
+ form.set("creator_geo_gating_info", JSON.stringify({ whitelist_country_codes: [] }));
1332
+ form.set("is_threads", "true");
1333
+ form.set("should_include_permalink", "true");
1334
+ form.set("text_post_app_info", threadsTextPostAppInfo(prepared.body));
1335
+ form.set("upload_id", uploadId);
1336
+ form.set("web_session_id", webSessionId);
1337
+ form.set(
1338
+ config.sprinkleParameter,
1339
+ threadsSprinkleValue(csrfToken, config.sprinkleVersion),
1340
+ );
1341
+ let response: unknown;
1342
+ try {
1343
+ response = await client.requestJson({
1344
+ url: new URL("/api/v1/media/configure_text_post_app_feed/", ORIGINS.threads),
1345
+ method: "POST",
1346
+ headers: threadsApiHeaders(
1347
+ client,
1348
+ config,
1349
+ webSessionId,
1350
+ "application/x-www-form-urlencoded;charset=UTF-8",
1351
+ ),
1352
+ body: form.toString(),
1353
+ expectedStatuses: [200],
1354
+ expectedContentTypes: ["application/json", "text/plain"],
1355
+ maxBytes: 256 * 1024,
1356
+ });
1357
+ } catch (error) {
1358
+ throw new ThreadsCreateResponseError(
1359
+ threadsCreateRequestFailureCategory(error),
1360
+ "Threads create request did not return one reviewed response",
1361
+ { cause: error },
1362
+ );
1363
+ }
1364
+ return threadsCreatedPost(response, viewer.id, prepared.body, uploaded);
1365
+ }
1366
+
1367
+ function metaDispatchEvent(
1368
+ id: string,
1369
+ started: number,
1370
+ verified: number,
1371
+ ): WebSessionDispatchEvent {
1372
+ return { id, index: 1, progress: { planned: 1, started, verified } };
1373
+ }
1374
+
1375
+ async function executeThreadsPost(
1376
+ client: WebSessionClient,
1377
+ viewer: BoundMetaViewer,
1378
+ prepared: Extract<PreparedMetaRead, { readonly kind: "threads-post" }>,
1379
+ options: {
1380
+ readonly fileResolver?: BrowserFileResolver;
1381
+ readonly operationDeadline?: WebSessionOperationDeadline;
1382
+ readonly beforeDispatch?: (event: WebSessionDispatchEvent) => Promise<void>;
1383
+ readonly afterProviderAcceptedMutationTarget?: (
1384
+ event: WebSessionProviderAcceptedMutationTargetEvent,
1385
+ ) => Promise<void>;
1386
+ readonly afterDispatchVerified?: (event: WebSessionDispatchEvent) => Promise<void>;
1387
+ readonly now: () => number;
1388
+ },
1389
+ ): Promise<WebSessionExecution> {
1390
+ const image = prepared.attachment === undefined
1391
+ ? null
1392
+ : await materializeThreadsImage(
1393
+ prepared.attachment,
1394
+ options.fileResolver,
1395
+ options.operationDeadline,
1396
+ );
1397
+ const uploadId = threadsUploadId(options.now);
1398
+ let started = 0;
1399
+ let verified = 0;
1400
+ let created: ThreadsCreatedPost | null = null;
1401
+ let failureStage = image === null ? "post create admission" : "image upload confirmation";
1402
+ try {
1403
+ // Rupload can accept an orphaned provider blob, but it cannot publish a
1404
+ // Threads post. Keep the durable post-dispatch boundary immediately in
1405
+ // front of configure_text_post_app_feed so an upload transport failure is
1406
+ // safely retryable instead of being misclassified as an indeterminate
1407
+ // public post. Text-only posts skip rupload and use the same create form.
1408
+ const uploaded = image === null
1409
+ ? null
1410
+ : await uploadThreadsImage(client, image, uploadId);
1411
+ const dispatchViewer = await currentViewer("threads", client);
1412
+ if (dispatchViewer.subject !== viewer.subject) {
1413
+ throw new Error(
1414
+ image === null
1415
+ ? "Threads current viewer changed before the post dispatch"
1416
+ : "Threads current viewer changed after the image upload",
1417
+ );
1418
+ }
1419
+ const config = threadsRequestConfig(dispatchViewer.rootHtml);
1420
+ await options.beforeDispatch?.(metaDispatchEvent("posts.publish", started, verified));
1421
+ started = 1;
1422
+ failureStage = "post create response";
1423
+ created = await createThreadsPost(
1424
+ client,
1425
+ dispatchViewer,
1426
+ prepared,
1427
+ uploaded,
1428
+ config,
1429
+ uploadId,
1430
+ );
1431
+ const createdImage = created.media;
1432
+ failureStage = "accepted target retention";
1433
+ await options.afterProviderAcceptedMutationTarget?.({
1434
+ id: "posts.publish",
1435
+ index: 1,
1436
+ target: {
1437
+ schemaVersion: 1,
1438
+ identifier: createdImage === null
1439
+ ? canonicalJson({
1440
+ code: created.locator.code,
1441
+ id: created.locator.id,
1442
+ url: created.locator.url,
1443
+ })
1444
+ : canonicalJson({
1445
+ code: created.locator.code,
1446
+ height: createdImage.height,
1447
+ id: created.locator.id,
1448
+ mediaType: createdImage.mediaType,
1449
+ remoteMediaId: createdImage.mediaId,
1450
+ url: created.locator.url,
1451
+ width: createdImage.width,
1452
+ }),
1453
+ },
1454
+ });
1455
+ failureStage = "permalink readback";
1456
+ const readbackHtml = await client.requestText({
1457
+ url: new URL(created.locator.url),
1458
+ method: "GET",
1459
+ headers: htmlHeaders(ORIGINS.threads),
1460
+ expectedContentTypes: ["text/html"],
1461
+ maxBytes: MAX_BOOTSTRAP_BYTES,
1462
+ });
1463
+ const post = normalizeThreadsPostHtml(
1464
+ readbackHtml,
1465
+ dispatchViewer.id,
1466
+ created.locator.id,
1467
+ created.locator.code,
1468
+ created.locator.url,
1469
+ prepared.body,
1470
+ createdImage === null
1471
+ ? null
1472
+ : { height: createdImage.height, width: createdImage.width },
1473
+ );
1474
+ if (createdImage === null) {
1475
+ if (post.image !== null) {
1476
+ throw new Error("Threads permalink readback introduced an unconfirmed image");
1477
+ }
1478
+ } else {
1479
+ const remoteImage = post.image;
1480
+ if (
1481
+ remoteImage === null
1482
+ || remoteImage.mediaId !== createdImage.mediaId
1483
+ || remoteImage.mediaType !== createdImage.mediaType
1484
+ || remoteImage.width !== createdImage.width
1485
+ || remoteImage.height !== createdImage.height
1486
+ ) throw new Error("Threads permalink readback changed the response-bound image");
1487
+ }
1488
+ verified = 1;
1489
+ await options.afterDispatchVerified?.(metaDispatchEvent("posts.publish", started, verified));
1490
+ return {
1491
+ status: "succeeded",
1492
+ output: createdImage === null || post.image === null
1493
+ ? Object.freeze({ post })
1494
+ : Object.freeze({
1495
+ post,
1496
+ attachment: Object.freeze({
1497
+ height: post.image.height,
1498
+ mediaType: "image/png" as const,
1499
+ remoteMediaId: post.image.mediaId,
1500
+ verifiedBy: "permalink-readback",
1501
+ width: post.image.width,
1502
+ }),
1503
+ }),
1504
+ finalUrl: created.locator.url,
1505
+ dispatchStarted: true,
1506
+ dispatch: { planned: 1, started, verified },
1507
+ };
1508
+ } catch (error) {
1509
+ const publicFailureStage = failureStage === "post create response"
1510
+ ? `${failureStage} (${error instanceof ThreadsCreateResponseError ? error.category : "unexpected"})`
1511
+ : failureStage;
1512
+ const verifiedSurfaces = image === null
1513
+ ? "exact actor, ID, code, text, and permalink readback"
1514
+ : "exact actor, ID, code, text, image, and permalink readback";
1515
+ return {
1516
+ status: started > 0 ? "indeterminate" : "failed",
1517
+ output: null,
1518
+ finalUrl: created?.locator.url ?? `${ORIGINS.threads}/`,
1519
+ dispatchStarted: started > 0,
1520
+ dispatch: { planned: 1, started, verified },
1521
+ error: started > 0
1522
+ ? `Threads may have accepted the ${image === null ? "post" : "image upload or post"} but ${verifiedSurfaces} was not verified; failure stage: ${publicFailureStage}; reconcile before retrying`
1523
+ : image === null
1524
+ ? "Threads post create failed before submission; retry with a fresh confirmed plan"
1525
+ : "Threads image upload failed before post submission; retry with a fresh confirmed plan",
1526
+ };
1527
+ }
1528
+ }
1529
+
1530
+ async function executeThreadsVideo(
1531
+ client: WebSessionClient,
1532
+ viewer: BoundMetaViewer,
1533
+ prepared: Extract<PreparedMetaRead, { readonly kind: "threads-video" }>,
1534
+ options: {
1535
+ readonly fileResolver?: BrowserFileResolver;
1536
+ readonly operationDeadline?: WebSessionOperationDeadline;
1537
+ readonly beforeDispatch?: (event: WebSessionDispatchEvent) => Promise<void>;
1538
+ readonly afterProviderAcceptedMutationTarget?: (
1539
+ event: WebSessionProviderAcceptedMutationTargetEvent,
1540
+ ) => Promise<void>;
1541
+ readonly afterDispatchVerified?: (event: WebSessionDispatchEvent) => Promise<void>;
1542
+ readonly now: () => number;
1543
+ },
1544
+ ): Promise<WebSessionExecution> {
1545
+ const video = await materializeThreadsVideo(
1546
+ prepared.media,
1547
+ options.fileResolver,
1548
+ options.operationDeadline,
1549
+ );
1550
+ const uploadId = threadsUploadId(options.now);
1551
+ let started = 0;
1552
+ let verified = 0;
1553
+ let created: ThreadsCreatedPost | null = null;
1554
+ let failureStage = "video upload confirmation";
1555
+ try {
1556
+ // A completed rupload is only an orphanable provider blob. The durable
1557
+ // at-most-once boundary remains immediately before the post-creating
1558
+ // configure request.
1559
+ const uploaded = await uploadThreadsVideo(client, video, uploadId);
1560
+ const dispatchViewer = await currentViewer("threads", client);
1561
+ if (dispatchViewer.subject !== viewer.subject) {
1562
+ throw new Error("Threads current viewer changed after the video upload");
1563
+ }
1564
+ const config = threadsRequestConfig(dispatchViewer.rootHtml);
1565
+ await options.beforeDispatch?.(metaDispatchEvent("media.publish", started, verified));
1566
+ started = 1;
1567
+ failureStage = "post create response";
1568
+ created = await createThreadsPost(
1569
+ client,
1570
+ dispatchViewer,
1571
+ prepared,
1572
+ uploaded,
1573
+ config,
1574
+ uploadId,
1575
+ );
1576
+ const createdVideo = created.media;
1577
+ if (createdVideo === null || createdVideo.mediaType !== 2) {
1578
+ throw new Error("Threads create response changed the confirmed video media type");
1579
+ }
1580
+ failureStage = "accepted target retention";
1581
+ await options.afterProviderAcceptedMutationTarget?.({
1582
+ id: "media.publish",
1583
+ index: 1,
1584
+ target: {
1585
+ schemaVersion: 1,
1586
+ identifier: canonicalJson({
1587
+ code: created.locator.code,
1588
+ height: createdVideo.height,
1589
+ id: created.locator.id,
1590
+ mediaType: createdVideo.mediaType,
1591
+ remoteMediaId: createdVideo.mediaId,
1592
+ url: created.locator.url,
1593
+ width: createdVideo.width,
1594
+ }),
1595
+ },
1596
+ });
1597
+ failureStage = "permalink readback";
1598
+ const readbackHtml = await client.requestText({
1599
+ url: new URL(created.locator.url),
1600
+ method: "GET",
1601
+ headers: htmlHeaders(ORIGINS.threads),
1602
+ expectedContentTypes: ["text/html"],
1603
+ maxBytes: MAX_BOOTSTRAP_BYTES,
1604
+ });
1605
+ const post = normalizeThreadsVideoPostHtml(
1606
+ readbackHtml,
1607
+ dispatchViewer.id,
1608
+ created.locator.id,
1609
+ created.locator.code,
1610
+ created.locator.url,
1611
+ prepared.body,
1612
+ video,
1613
+ );
1614
+ const remoteVideo = post.video as ThreadsVideoProjection;
1615
+ if (
1616
+ remoteVideo.mediaId !== createdVideo.mediaId
1617
+ || remoteVideo.mediaType !== createdVideo.mediaType
1618
+ || remoteVideo.width !== createdVideo.width
1619
+ || remoteVideo.height !== createdVideo.height
1620
+ ) throw new Error("Threads permalink readback changed the response-bound video");
1621
+ verified = 1;
1622
+ await options.afterDispatchVerified?.(metaDispatchEvent("media.publish", started, verified));
1623
+ return {
1624
+ status: "succeeded",
1625
+ output: Object.freeze({
1626
+ post,
1627
+ media: Object.freeze({
1628
+ durationSeconds: remoteVideo.durationSeconds,
1629
+ hasAudio: remoteVideo.hasAudio,
1630
+ height: remoteVideo.height,
1631
+ mediaType: video.mediaType,
1632
+ remoteMediaId: remoteVideo.mediaId,
1633
+ verifiedBy: "permalink-readback",
1634
+ width: remoteVideo.width,
1635
+ }),
1636
+ }),
1637
+ finalUrl: created.locator.url,
1638
+ dispatchStarted: true,
1639
+ dispatch: { planned: 1, started, verified },
1640
+ };
1641
+ } catch (error) {
1642
+ const publicFailureStage = failureStage === "post create response"
1643
+ ? `${failureStage} (${error instanceof ThreadsCreateResponseError ? error.category : "unexpected"})`
1644
+ : failureStage;
1645
+ return {
1646
+ status: started > 0 ? "indeterminate" : "failed",
1647
+ output: null,
1648
+ finalUrl: created?.locator.url ?? `${ORIGINS.threads}/`,
1649
+ dispatchStarted: started > 0,
1650
+ dispatch: { planned: 1, started, verified },
1651
+ error: started > 0
1652
+ ? `Threads may have accepted the video or post but exact actor, ID, code, text, video, and permalink readback was not verified; failure stage: ${publicFailureStage}; reconcile before retrying`
1653
+ : "Threads video upload failed before post submission; retry with a fresh confirmed plan",
1654
+ };
1655
+ }
1656
+ }
1657
+
1658
+ type ThreadsPublishedMediaMutationTarget = Readonly<{
1659
+ code: string;
1660
+ height: number;
1661
+ id: string;
1662
+ mediaType: 1 | 2;
1663
+ remoteMediaId: string;
1664
+ url: string;
1665
+ width: number;
1666
+ }>;
1667
+
1668
+ type ThreadsPublishedTextMutationTarget = Readonly<{
1669
+ code: string;
1670
+ id: string;
1671
+ url: string;
1672
+ }>;
1673
+
1674
+ type ThreadsPublishedMutationTarget =
1675
+ | ThreadsPublishedMediaMutationTarget
1676
+ | ThreadsPublishedTextMutationTarget;
1677
+
1678
+ function isThreadsPublishedMediaTarget(
1679
+ target: ThreadsPublishedMutationTarget,
1680
+ ): target is ThreadsPublishedMediaMutationTarget {
1681
+ return "mediaType" in target;
1682
+ }
1683
+
1684
+ function threadsPublishedMutationTargetDimension(
1685
+ value: unknown,
1686
+ label: string,
1687
+ ): number {
1688
+ if (!Number.isSafeInteger(value) || (value as number) < 1 || (value as number) > 20_000) {
1689
+ throw new Error(`${label} must be an integer between 1 and 20000`);
1690
+ }
1691
+ return value as number;
1692
+ }
1693
+
1694
+ function parseThreadsPublishedPermalink(value: unknown, code: string): URL {
1695
+ if (typeof value !== "string" || value.length < 1 || value.length > 2_048) {
1696
+ throw new Error("Threads provider-accepted post target returned an invalid permalink");
1697
+ }
1698
+ let url: URL;
1699
+ try {
1700
+ url = new URL(value);
1701
+ } catch {
1702
+ throw new Error("Threads provider-accepted post target returned an invalid permalink");
1703
+ }
1704
+ const path = url.pathname.split("/");
1705
+ if (
1706
+ url.origin !== ORIGINS.threads
1707
+ || url.username !== ""
1708
+ || url.password !== ""
1709
+ || url.search !== ""
1710
+ || url.hash !== ""
1711
+ || path.length !== 4
1712
+ || !/^@[A-Za-z0-9._]{1,64}$/u.test(path[1] ?? "")
1713
+ || path[2] !== "post"
1714
+ || path[3] !== code
1715
+ ) throw new Error("Threads provider-accepted post target returned an invalid permalink");
1716
+ return url;
1717
+ }
1718
+
1719
+ function parseThreadsPublishedMutationTarget(
1720
+ identifier: string,
1721
+ ): ThreadsPublishedMutationTarget {
1722
+ let value: unknown;
1723
+ try {
1724
+ value = JSON.parse(identifier);
1725
+ } catch {
1726
+ throw new Error("Threads provider-accepted post target is not canonical JSON");
1727
+ }
1728
+ if (!isRecord(value)) {
1729
+ throw new Error("Threads provider-accepted post target contained unsupported fields");
1730
+ }
1731
+ if (typeof value.id !== "string" || !/^[0-9]{1,32}(?:_[0-9]{1,32})?$/u.test(value.id)) {
1732
+ throw new Error("Threads provider-accepted post target returned invalid media identifiers");
1733
+ }
1734
+ if (typeof value.code !== "string" || !/^[A-Za-z0-9_-]{1,64}$/u.test(value.code)) {
1735
+ throw new Error("Threads provider-accepted post target returned an invalid post code");
1736
+ }
1737
+ const keys = Object.keys(value).sort().join(",");
1738
+ const url = parseThreadsPublishedPermalink(value.url, value.code);
1739
+ if (keys === "code,id,url") {
1740
+ const parsed = Object.freeze({ code: value.code, id: value.id, url: url.href });
1741
+ if (canonicalJson(parsed) !== identifier) {
1742
+ throw new Error("Threads provider-accepted post target is not canonical");
1743
+ }
1744
+ return parsed;
1745
+ }
1746
+ if (keys !== "code,height,id,mediaType,remoteMediaId,url,width") {
1747
+ throw new Error("Threads provider-accepted post target contained unsupported fields");
1748
+ }
1749
+ if (typeof value.remoteMediaId !== "string" || value.remoteMediaId !== value.id) {
1750
+ throw new Error("Threads provider-accepted post target returned invalid media identifiers");
1751
+ }
1752
+ if (value.mediaType !== 1 && value.mediaType !== 2) {
1753
+ throw new Error("Threads provider-accepted post target did not identify one reviewed media item");
1754
+ }
1755
+ const parsed = Object.freeze({
1756
+ code: value.code,
1757
+ height: threadsPublishedMutationTargetDimension(
1758
+ value.height,
1759
+ "Threads provider-accepted post target height",
1760
+ ),
1761
+ id: value.id,
1762
+ mediaType: value.mediaType,
1763
+ remoteMediaId: value.remoteMediaId,
1764
+ url: url.href,
1765
+ width: threadsPublishedMutationTargetDimension(
1766
+ value.width,
1767
+ "Threads provider-accepted post target width",
1768
+ ),
1769
+ });
1770
+ if (canonicalJson(parsed) !== identifier) {
1771
+ throw new Error("Threads provider-accepted post target is not canonical");
1772
+ }
1773
+ return parsed;
1774
+ }
1775
+
1776
+ /**
1777
+ * Reconcile one exact response-bound Threads post with one read of its exact
1778
+ * permalink. This never resolves or uploads the confirmed media file.
1779
+ */
1780
+ export async function readThreadsWebPublishedMutationTarget(
1781
+ recipe: WebSessionRecipe,
1782
+ input: OperationInput,
1783
+ auth: GhostgetAuth,
1784
+ identifier: string,
1785
+ options: {
1786
+ readonly dependencies?: MetaWebRuntimeDependencies;
1787
+ } = {},
1788
+ ): Promise<{ readonly present: true; readonly postId: string }> {
1789
+ const isPostPublish = recipe.site === "threads"
1790
+ && recipe.action === "posts.publish"
1791
+ && (recipe.contractVersion === 4 || recipe.contractVersion === 5);
1792
+ const isVideoPublish = recipe.site === "threads"
1793
+ && recipe.action === "media.publish"
1794
+ && recipe.contractVersion === 1;
1795
+ if (!isPostPublish && !isVideoPublish) {
1796
+ throw new Error(
1797
+ "Threads publish recovery supports only posts.publish@4, posts.publish@5, or media.publish@1",
1798
+ );
1799
+ }
1800
+ const target = parseThreadsPublishedMutationTarget(identifier);
1801
+ const prepared = prepareMetaRead(recipe, input, auth, Object.freeze({}));
1802
+ if (
1803
+ (isPostPublish && prepared.kind !== "threads-post")
1804
+ || (isVideoPublish && prepared.kind !== "threads-video")
1805
+ ) {
1806
+ throw new Error("Threads publish recovery input did not match its exact media contract");
1807
+ }
1808
+ if (prepared.kind !== "threads-post" && prepared.kind !== "threads-video") {
1809
+ throw new Error("Threads publish recovery input did not match its exact media contract");
1810
+ }
1811
+ const mediaTarget = isThreadsPublishedMediaTarget(target) ? target : null;
1812
+ if (prepared.kind === "threads-post" && (
1813
+ (recipe.contractVersion === 4 && prepared.attachment === undefined)
1814
+ || (prepared.attachment !== undefined) !== (mediaTarget !== null)
1815
+ || (mediaTarget !== null && mediaTarget.mediaType !== 1)
1816
+ )) {
1817
+ throw new Error("Threads publish recovery target did not bind the confirmed media input");
1818
+ }
1819
+ if (
1820
+ prepared.kind === "threads-video"
1821
+ && (mediaTarget === null || mediaTarget.mediaType !== 2)
1822
+ ) {
1823
+ throw new Error("Threads publish recovery target did not bind the confirmed media input");
1824
+ }
1825
+ const expectedSubject = expectedMetaAuthSubject("threads", auth);
1826
+ const viewerId = expectedSubject.slice("threads:".length);
1827
+ const dependencies = metaWebSessionDependencies("threads", auth, options.dependencies);
1828
+ const client = await createWebSessionClient(ORIGINS.threads, auth, {
1829
+ timeoutMs: recipe.timeoutMs,
1830
+ ...(dependencies === undefined ? {} : { dependencies }),
1831
+ });
1832
+ if (webSessionCookie(client.cookies, "ds_user_id") !== viewerId) {
1833
+ throw new Error("Threads account cookie did not match the confirmed auth subject");
1834
+ }
1835
+ const html = await client.requestText({
1836
+ url: new URL(target.url),
1837
+ method: "GET",
1838
+ headers: htmlHeaders(ORIGINS.threads),
1839
+ expectedContentTypes: ["text/html"],
1840
+ maxBytes: Math.min(recipe.maxOutputBytes, MAX_BOOTSTRAP_BYTES),
1841
+ });
1842
+ if (prepared.kind === "threads-video") {
1843
+ if (mediaTarget === null || mediaTarget.mediaType !== 2) {
1844
+ throw new Error("Threads publish recovery target did not bind the confirmed media input");
1845
+ }
1846
+ const post = normalizeThreadsVideoPostHtml(
1847
+ html,
1848
+ viewerId,
1849
+ target.id,
1850
+ target.code,
1851
+ target.url,
1852
+ prepared.body,
1853
+ { height: mediaTarget.height, width: mediaTarget.width },
1854
+ );
1855
+ const media = post.video;
1856
+ if (
1857
+ media === null
1858
+ || media.mediaId !== mediaTarget.remoteMediaId
1859
+ || media.mediaType !== mediaTarget.mediaType
1860
+ || media.width !== mediaTarget.width
1861
+ || media.height !== mediaTarget.height
1862
+ ) throw new Error("Threads publish recovery readback changed the accepted media");
1863
+ } else {
1864
+ const post = normalizeThreadsPostHtml(
1865
+ html,
1866
+ viewerId,
1867
+ target.id,
1868
+ target.code,
1869
+ target.url,
1870
+ prepared.body,
1871
+ mediaTarget === null
1872
+ ? null
1873
+ : { height: mediaTarget.height, width: mediaTarget.width },
1874
+ );
1875
+ const media = post.image;
1876
+ if (mediaTarget !== null) {
1877
+ if (
1878
+ media === null
1879
+ || media.mediaId !== mediaTarget.remoteMediaId
1880
+ || media.mediaType !== mediaTarget.mediaType
1881
+ || media.width !== mediaTarget.width
1882
+ || media.height !== mediaTarget.height
1883
+ ) throw new Error("Threads publish recovery readback changed the accepted media");
1884
+ } else if (media !== null) {
1885
+ throw new Error("Threads publish recovery readback introduced unconfirmed media");
1886
+ }
1887
+ }
1888
+ return Object.freeze({ present: true, postId: target.id });
1889
+ }
1890
+
1891
+ function facebookMarketplaceAuthHash(auth: GhostgetAuth): string {
1892
+ return sha256(canonicalJson(auth));
1893
+ }
1894
+
1895
+ function expectedFacebookViewerId(auth: GhostgetAuth): string {
1896
+ const subject = webSessionAuthSubject(auth);
1897
+ const prefix = "facebook:user:";
1898
+ const id = subject?.startsWith(prefix) === true
1899
+ ? subject.slice(prefix.length)
1900
+ : null;
1901
+ if (!isCanonicalMetaNumericId(id)) {
1902
+ throw new Error(
1903
+ "Facebook Marketplace pagination requires an exact bound Facebook viewer subject",
1904
+ );
1905
+ }
1906
+ return id;
1907
+ }
1908
+
1909
+ function expectedMetaAuthSubject(site: MetaWebSite, auth: GhostgetAuth): string {
1910
+ const subject = webSessionAuthSubject(auth);
1911
+ const prefix = site === "instagram"
1912
+ ? "instagram:"
1913
+ : site === "threads"
1914
+ ? "threads:"
1915
+ : "facebook:user:";
1916
+ if (
1917
+ subject === null
1918
+ || !subject.startsWith(prefix)
1919
+ || !isCanonicalMetaNumericId(subject.slice(prefix.length))
1920
+ ) {
1921
+ throw new Error(`${site} authenticated operations require an exact bound viewer subject`);
1922
+ }
1923
+ return subject;
1924
+ }
1925
+
1926
+ function openFacebookMarketplaceCursor(
1927
+ token: string,
1928
+ auth: GhostgetAuth,
1929
+ environment: Readonly<Record<string, string | undefined>>,
1930
+ ): FacebookMarketplacePaginationCursor {
1931
+ const cursor = reconstructFacebookMarketplacePaginationCursor(
1932
+ expectedFacebookViewerId(auth),
1933
+ openCursorToken(
1934
+ MARKETPLACE_CURSOR_SCOPE,
1935
+ auth.id,
1936
+ facebookMarketplaceAuthHash(auth),
1937
+ token,
1938
+ environment,
1939
+ ),
1940
+ );
1941
+ if (facebookMarketplacePaginationCursorExhausted(cursor)) {
1942
+ throw new Error("Marketplace pagination cursor reached its reviewed chain bound");
1943
+ }
1944
+ return cursor;
1945
+ }
1946
+
1947
+ function sealFacebookMarketplaceFeed(
1948
+ feed: FacebookMarketplaceFeed,
1949
+ html: string,
1950
+ viewerId: string,
1951
+ auth: GhostgetAuth,
1952
+ previous: FacebookMarketplacePaginationCursor | null,
1953
+ environment: Readonly<Record<string, string | undefined>>,
1954
+ ): FacebookMarketplaceFeed {
1955
+ if (feed.next_cursor === null) return feed;
1956
+ const cursor = bindFacebookMarketplacePaginationCursor(
1957
+ viewerId,
1958
+ feed.next_cursor,
1959
+ facebookMarketplacePaginationInputHash(html, viewerId),
1960
+ previous,
1961
+ );
1962
+ if (facebookMarketplacePaginationCursorExhausted(cursor)) {
1963
+ return Object.freeze({
1964
+ ...feed,
1965
+ next_cursor: null,
1966
+ continuation_supported: false,
1967
+ complete: false,
1968
+ });
1969
+ }
1970
+ return Object.freeze({
1971
+ ...feed,
1972
+ next_cursor: sealCursorToken(
1973
+ MARKETPLACE_CURSOR_SCOPE,
1974
+ auth.id,
1975
+ facebookMarketplaceAuthHash(auth),
1976
+ cursor,
1977
+ environment,
1978
+ ),
1979
+ });
1980
+ }
1981
+
1982
+ type BoundMetaViewer = {
1983
+ readonly id: string;
1984
+ readonly subject: string;
1985
+ readonly rootHtml: string;
1986
+ };
1987
+
1988
+ async function currentViewer(
1989
+ site: MetaWebSite,
1990
+ client: WebSessionClient,
1991
+ ): Promise<BoundMetaViewer> {
1992
+ const html = await rootHtml(client, ORIGINS[site]);
1993
+ if (site === "instagram") {
1994
+ const id = parseInstagramViewerId(html);
1995
+ if (webSessionCookie(client.cookies, "ds_user_id") !== id) {
1996
+ throw new Error("Instagram Polaris viewer did not match the selected browser session");
1997
+ }
1998
+ return Object.freeze({ id, subject: `instagram:${id}`, rootHtml: html });
1999
+ }
2000
+ if (site === "threads") {
2001
+ const id = parseThreadsViewerId(html);
2002
+ if (webSessionCookie(client.cookies, "ds_user_id") !== id) {
2003
+ throw new Error("Threads Barcelona viewer did not match the selected browser session");
2004
+ }
2005
+ return Object.freeze({ id, subject: `threads:${id}`, rootHtml: html });
2006
+ }
2007
+ const id = parseFacebookViewerId(html);
2008
+ if (webSessionCookie(client.cookies, "c_user") !== id) {
2009
+ throw new Error("Facebook CurrentUserInitialData did not match the selected browser session");
2010
+ }
2011
+ return Object.freeze({ id, subject: `facebook:user:${id}`, rootHtml: html });
2012
+ }
2013
+
2014
+ export async function probeMetaWebSubject(
2015
+ site: MetaWebSite,
2016
+ auth: GhostgetAuth,
2017
+ options: {
2018
+ readonly timeoutMs?: number;
2019
+ readonly dependencies?: MetaWebRuntimeDependencies;
2020
+ readonly signal?: AbortSignal;
2021
+ } = {},
2022
+ ): Promise<string> {
2023
+ const dependencies = metaWebSessionDependencies(site, auth, options.dependencies);
2024
+ const client = await createWebSessionClient(ORIGINS[site], auth, {
2025
+ timeoutMs: options.timeoutMs ?? 60_000,
2026
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
2027
+ ...(dependencies === undefined ? {} : { dependencies }),
2028
+ });
2029
+ return (await currentViewer(site, client)).subject;
2030
+ }
2031
+
2032
+ async function requireBoundViewer(
2033
+ site: MetaWebSite,
2034
+ client: WebSessionClient,
2035
+ expectedSubject: string,
2036
+ ): Promise<BoundMetaViewer> {
2037
+ const subjectPrefix = site === "instagram"
2038
+ ? "instagram:"
2039
+ : site === "threads"
2040
+ ? "threads:"
2041
+ : "facebook:user:";
2042
+ const cookieName = site === "instagram" || site === "threads"
2043
+ ? "ds_user_id"
2044
+ : "c_user";
2045
+ const expectedCookieValue = expectedSubject.slice(subjectPrefix.length);
2046
+ if (webSessionCookie(client.cookies, cookieName) !== expectedCookieValue) {
2047
+ throw new Error(
2048
+ `${site} account cookie did not match the confirmed auth subject`,
2049
+ );
2050
+ }
2051
+ const viewer = await currentViewer(site, client);
2052
+ if (viewer.subject !== expectedSubject) {
2053
+ throw new Error(`${site} current viewer no longer matches the confirmed auth subject`);
2054
+ }
2055
+ return viewer;
2056
+ }
2057
+
2058
+ const INSTAGRAM_REMOVED_TITLE = "Page not found • Instagram";
2059
+ const INSTAGRAM_REMOVED_MAIN_TEXT = "Sorry, this page isn't available.";
2060
+ const INSTAGRAM_REMOVED_DETAIL_TEXT =
2061
+ "The link you followed may be broken, or the page may have been removed.";
2062
+ const INSTAGRAM_REMOVED_BACK_LINK_TEXT = "Go back to Instagram.";
2063
+
2064
+ function instagramHtmlTitle(source: string): string {
2065
+ const matches = [...source.matchAll(/<title(?:\s[^>]*)?>([^<]*)<\/title>/giu)];
2066
+ if (matches.length !== 1) {
2067
+ throw new Error("Instagram permalink response did not contain one exact HTML title");
2068
+ }
2069
+ return (matches[0]?.[1] ?? "")
2070
+ .replaceAll("&bull;", "•")
2071
+ .replaceAll("&#8226;", "•")
2072
+ .replaceAll("&#x2022;", "•")
2073
+ .replaceAll("&amp;", "&")
2074
+ .replaceAll("&lt;", "<")
2075
+ .replaceAll("&gt;", ">")
2076
+ .replaceAll("&quot;", '"')
2077
+ .replaceAll("&#39;", "'");
2078
+ }
2079
+
2080
+ /** Fixed semantic projection of the observed authenticated soft-200 tombstone. */
2081
+ export function instagramVideoPermalinkWasRemoved(html: unknown): boolean {
2082
+ if (
2083
+ typeof html !== "string"
2084
+ || html.length < 1
2085
+ || Buffer.byteLength(html, "utf8") > MAX_API_BYTES
2086
+ ) throw new Error("Instagram permalink response exceeded its reviewed HTML bound");
2087
+ const title = instagramHtmlTitle(html);
2088
+ if (title !== INSTAGRAM_REMOVED_TITLE) return false;
2089
+ if (
2090
+ !html.includes(INSTAGRAM_REMOVED_MAIN_TEXT)
2091
+ || !html.includes(INSTAGRAM_REMOVED_DETAIL_TEXT)
2092
+ || !html.includes(INSTAGRAM_REMOVED_BACK_LINK_TEXT)
2093
+ ) throw new Error("Instagram permalink removal marker changed shape");
2094
+ return true;
2095
+ }
2096
+
2097
+ /**
2098
+ * Read only one exact provider-derived Instagram video target. Publishing
2099
+ * remains capture-required; deletion uses this same target projection for
2100
+ * durable desired-state reconciliation without resolving either upload file.
2101
+ */
2102
+ export async function readInstagramVideoAcceptedMutationTargetPresence(
2103
+ recipe: WebSessionRecipe,
2104
+ input: OperationInput,
2105
+ auth: GhostgetAuth,
2106
+ identifier: string,
2107
+ options: {
2108
+ readonly dependencies?: MetaWebRuntimeDependencies;
2109
+ readonly operationDeadline?: WebSessionOperationDeadline;
2110
+ readonly signal?: AbortSignal;
2111
+ } = {},
2112
+ ): Promise<Readonly<{
2113
+ code: string;
2114
+ mediaId: string;
2115
+ present: boolean;
2116
+ }>> {
2117
+ const isPublish = recipe.site === "instagram"
2118
+ && recipe.action === "media.publish"
2119
+ && recipe.contractVersion === 3;
2120
+ const isDelete = recipe.site === "instagram"
2121
+ && recipe.action === "content.delete"
2122
+ && recipe.contractVersion === 2;
2123
+ if (!isPublish && !isDelete) {
2124
+ throw new Error(
2125
+ "Instagram video accepted-target readback supports only media.publish@3 or content.delete@2",
2126
+ );
2127
+ }
2128
+ const target = parseInstagramVideoAcceptedTargetIdentifier(identifier);
2129
+ const expectedCaption = isPublish
2130
+ ? prepareInstagramVideoPublishInput(input).caption
2131
+ : prepareInstagramAuthoredPostDeleteInput(input).expectedCaption;
2132
+ if (isDelete) {
2133
+ const deletion = prepareInstagramAuthoredPostDeleteInput(input);
2134
+ if (deletion.mediaId !== target.mediaId) {
2135
+ throw new Error("Instagram deletion accepted target did not bind the confirmed media ID");
2136
+ }
2137
+ }
2138
+
2139
+ const expectedSubject = expectedMetaAuthSubject("instagram", auth);
2140
+ const client = await createWebSessionClient(ORIGINS.instagram, auth, {
2141
+ timeoutMs: recipe.timeoutMs,
2142
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
2143
+ ...(options.operationDeadline === undefined
2144
+ ? {}
2145
+ : { operationDeadline: options.operationDeadline }),
2146
+ ...(options.dependencies === undefined
2147
+ ? {}
2148
+ : { dependencies: options.dependencies }),
2149
+ });
2150
+ const viewer = await requireBoundViewer(
2151
+ "instagram",
2152
+ client,
2153
+ expectedSubject,
2154
+ );
2155
+ const permalink = await client.requestText({
2156
+ url: new URL(target.url),
2157
+ method: "GET",
2158
+ headers: htmlHeaders(ORIGINS.instagram),
2159
+ expectedContentTypes: ["text/html"],
2160
+ maxBytes: Math.min(recipe.maxOutputBytes, MAX_API_BYTES),
2161
+ });
2162
+ if (instagramVideoPermalinkWasRemoved(permalink)) {
2163
+ return Object.freeze({
2164
+ code: target.code,
2165
+ mediaId: target.mediaId,
2166
+ present: false,
2167
+ });
2168
+ }
2169
+ const response = await client.requestJson({
2170
+ url: new URL(`/api/v1/media/${target.mediaId}/info/`, ORIGINS.instagram),
2171
+ method: "GET",
2172
+ headers: instagramHeaders(target.url),
2173
+ expectedStatuses: [200],
2174
+ expectedContentTypes: ["application/json"],
2175
+ maxBytes: Math.min(recipe.maxOutputBytes, MAX_API_BYTES),
2176
+ });
2177
+ const normalized = normalizeInstagramPost(response, target.mediaId);
2178
+ bindInstagramVideoMediaReadback(normalized, {
2179
+ expectedCaption,
2180
+ expectedCode: target.code,
2181
+ mediaId: target.mediaId,
2182
+ viewerId: viewer.id,
2183
+ });
2184
+ return Object.freeze({
2185
+ code: target.code,
2186
+ mediaId: target.mediaId,
2187
+ present: true,
2188
+ });
2189
+ }
2190
+
2191
+ function exactInstagramMediaId(input: OperationInput): string {
2192
+ return exactStringInput(
2193
+ input,
2194
+ "media_id",
2195
+ /^[0-9]{1,32}(?:_[0-9]{1,32})?$/u,
2196
+ "an exact Instagram media ID",
2197
+ );
2198
+ }
2199
+
2200
+ type PreparedMetaRead =
2201
+ | {
2202
+ readonly kind: "instagram-video";
2203
+ readonly input: OperationInput;
2204
+ }
2205
+ | {
2206
+ readonly kind: "instagram-video-delete";
2207
+ readonly expectedCaption: string;
2208
+ readonly mediaId: string;
2209
+ }
2210
+ | {
2211
+ readonly kind: "instagram-profile";
2212
+ readonly profile: string;
2213
+ }
2214
+ | {
2215
+ readonly kind: "instagram-feed";
2216
+ readonly limit: number;
2217
+ }
2218
+ | {
2219
+ readonly kind: "instagram-media";
2220
+ readonly mediaId: string;
2221
+ }
2222
+ | {
2223
+ readonly kind: "instagram-comments";
2224
+ readonly mediaId: string;
2225
+ readonly limit: number;
2226
+ }
2227
+ | {
2228
+ readonly kind: "instagram-inbox";
2229
+ readonly limit: number;
2230
+ }
2231
+ | {
2232
+ readonly kind: "instagram-contacts";
2233
+ readonly threadLimit: number;
2234
+ readonly contactLimit: number;
2235
+ }
2236
+ | {
2237
+ readonly kind: "threads-profile";
2238
+ readonly profile: string;
2239
+ }
2240
+ | {
2241
+ readonly kind: "threads-feed";
2242
+ readonly limit: number;
2243
+ }
2244
+ | {
2245
+ readonly kind: "threads-post";
2246
+ readonly attachment?: FileInputValue;
2247
+ readonly audience: "default";
2248
+ readonly body: string;
2249
+ }
2250
+ | {
2251
+ readonly kind: "threads-video";
2252
+ readonly audience: "default";
2253
+ readonly body: string;
2254
+ readonly media: FileInputValue;
2255
+ }
2256
+ | {
2257
+ readonly kind: "facebook-feed";
2258
+ readonly limit: number;
2259
+ }
2260
+ | {
2261
+ readonly kind: "facebook-group-feed";
2262
+ readonly groupId: string;
2263
+ readonly limit: number;
2264
+ }
2265
+ | {
2266
+ readonly kind: "facebook-marketplace-feed";
2267
+ readonly cursor?: FacebookMarketplacePaginationCursor;
2268
+ readonly limit: number;
2269
+ }
2270
+ | {
2271
+ readonly kind: "facebook-marketplace-listing";
2272
+ readonly listingId: string;
2273
+ };
2274
+
2275
+ function prepareMetaRead(
2276
+ recipe: WebSessionRecipe,
2277
+ input: OperationInput,
2278
+ auth: GhostgetAuth,
2279
+ environment: Readonly<Record<string, string | undefined>>,
2280
+ ): PreparedMetaRead {
2281
+ if (recipe.site === "instagram") {
2282
+ if (recipe.action === "media.publish") {
2283
+ prepareInstagramVideoPublishInput(input);
2284
+ return Object.freeze({ kind: "instagram-video", input });
2285
+ }
2286
+ if (recipe.action === "content.delete") {
2287
+ const deletion = prepareInstagramAuthoredPostDeleteInput(input);
2288
+ return Object.freeze({
2289
+ kind: "instagram-video-delete",
2290
+ expectedCaption: deletion.expectedCaption,
2291
+ mediaId: deletion.mediaId,
2292
+ });
2293
+ }
2294
+ if (recipe.action === "profiles.read") {
2295
+ requireExactInputKeys(input, ["profile"]);
2296
+ return Object.freeze({
2297
+ kind: "instagram-profile",
2298
+ profile: exactStringInput(
2299
+ input,
2300
+ "profile",
2301
+ /^[a-z0-9._]{1,30}$/u,
2302
+ "one canonical lowercase Instagram handle",
2303
+ ),
2304
+ });
2305
+ }
2306
+ if (recipe.action === "contacts.list") {
2307
+ requireExactInputKeys(input, ["contact_limit", "thread_limit"]);
2308
+ return Object.freeze({
2309
+ kind: "instagram-contacts",
2310
+ threadLimit: integerInput(input, "thread_limit", 20, 1, 50),
2311
+ contactLimit: integerInput(input, "contact_limit", 50, 1, 100),
2312
+ });
2313
+ }
2314
+ if (recipe.action === "feeds.read") {
2315
+ requireExactInputKeys(input, ["feed", "limit"]);
2316
+ exactEnumInput(input, "feed", ["home"]);
2317
+ return Object.freeze({
2318
+ kind: "instagram-feed",
2319
+ limit: integerInput(input, "limit", 20, 1, 30),
2320
+ });
2321
+ }
2322
+ if (recipe.action === "posts.read" || recipe.action === "media.read") {
2323
+ requireExactInputKeys(input, ["media_id"]);
2324
+ return Object.freeze({
2325
+ kind: "instagram-media",
2326
+ mediaId: exactInstagramMediaId(input),
2327
+ });
2328
+ }
2329
+ if (recipe.action === "comments.read") {
2330
+ requireExactInputKeys(input, ["limit", "media_id"]);
2331
+ const mediaId = exactInstagramMediaId(input);
2332
+ return Object.freeze({
2333
+ kind: "instagram-comments",
2334
+ mediaId,
2335
+ limit: integerInput(input, "limit", 20, 1, 50),
2336
+ });
2337
+ }
2338
+ if (recipe.action === "messaging.list") {
2339
+ requireExactInputKeys(input, ["folder", "limit"]);
2340
+ exactEnumInput(input, "folder", ["inbox"]);
2341
+ return Object.freeze({
2342
+ kind: "instagram-inbox",
2343
+ limit: integerInput(input, "limit", 20, 1, 50),
2344
+ });
2345
+ }
2346
+ }
2347
+ if (recipe.site === "threads" && recipe.action === "profiles.read") {
2348
+ requireExactInputKeys(input, ["profile"]);
2349
+ return Object.freeze({
2350
+ kind: "threads-profile",
2351
+ profile: exactStringInput(
2352
+ input,
2353
+ "profile",
2354
+ /^[a-z0-9._]{1,30}$/u,
2355
+ "one canonical lowercase Threads handle",
2356
+ ),
2357
+ });
2358
+ }
2359
+ if (recipe.site === "threads" && recipe.action === "feeds.read") {
2360
+ requireExactInputKeys(input, ["feed", "limit"]);
2361
+ exactEnumInput(input, "feed", ["for-you"]);
2362
+ return Object.freeze({
2363
+ kind: "threads-feed",
2364
+ limit: integerInput(input, "limit", 20, 1, 30),
2365
+ });
2366
+ }
2367
+ if (recipe.site === "threads" && recipe.action === "posts.publish") {
2368
+ requireExactInputKeys(input, ["attachment", "audience", "body"]);
2369
+ const body = input.body;
2370
+ if (
2371
+ typeof body !== "string"
2372
+ || body.length < 1
2373
+ || body.length > 450
2374
+ || /[\0\r]/u.test(body)
2375
+ ) throw new Error("input.body must be 1 to 450 bounded UTF-16 code units");
2376
+ const audience = input.audience === undefined
2377
+ ? "default"
2378
+ : exactEnumInput(input, "audience", ["default"]);
2379
+ return Object.freeze({
2380
+ kind: "threads-post",
2381
+ ...(input.attachment === undefined ? {} : { attachment: fileInput(input.attachment) }),
2382
+ audience: audience as "default",
2383
+ body,
2384
+ });
2385
+ }
2386
+ if (recipe.site === "threads" && recipe.action === "media.publish") {
2387
+ requireExactInputKeys(input, ["audience", "body", "media"]);
2388
+ const body = input.body;
2389
+ if (
2390
+ typeof body !== "string"
2391
+ || body.length < 1
2392
+ || body.length > 450
2393
+ || /[\0\r]/u.test(body)
2394
+ ) throw new Error("input.body must be 1 to 450 bounded UTF-16 code units");
2395
+ if (input.media === undefined) {
2396
+ throw new Error("reviewed Threads media.publish requires one MP4 video");
2397
+ }
2398
+ const audience = input.audience === undefined
2399
+ ? "default"
2400
+ : exactEnumInput(input, "audience", ["default"]);
2401
+ return Object.freeze({
2402
+ kind: "threads-video",
2403
+ audience: audience as "default",
2404
+ body,
2405
+ media: fileInput(input.media, "input.media"),
2406
+ });
2407
+ }
2408
+ if (recipe.site === "facebook" && recipe.action === "feeds.read") {
2409
+ requireExactInputKeys(input, ["feed", "limit"]);
2410
+ exactEnumInput(input, "feed", ["home"]);
2411
+ return Object.freeze({
2412
+ kind: "facebook-feed",
2413
+ limit: integerInput(input, "limit", 20, 1, 30),
2414
+ });
2415
+ }
2416
+ if (recipe.site === "facebook-group" && recipe.action === "feeds.read") {
2417
+ requireExactInputKeys(input, ["feed", "group_id", "limit"]);
2418
+ exactEnumInput(input, "feed", ["group"]);
2419
+ return Object.freeze({
2420
+ kind: "facebook-group-feed",
2421
+ groupId: exactStringInput(
2422
+ input,
2423
+ "group_id",
2424
+ /^[1-9][0-9]{0,31}$/u,
2425
+ "an exact nonzero numeric Facebook Group ID",
2426
+ ),
2427
+ limit: integerInput(input, "limit", 20, 1, 30),
2428
+ });
2429
+ }
2430
+ if (recipe.site === "facebook-marketplace" && recipe.action === "feeds.read") {
2431
+ requireExactInputKeys(input, ["cursor", "feed", "limit"]);
2432
+ exactEnumInput(input, "feed", ["marketplace"]);
2433
+ const token = optionalOpaqueCursor(input, "cursor");
2434
+ const cursor = token === undefined
2435
+ ? undefined
2436
+ : openFacebookMarketplaceCursor(token, auth, environment);
2437
+ return Object.freeze({
2438
+ kind: "facebook-marketplace-feed",
2439
+ ...(cursor === undefined ? {} : { cursor }),
2440
+ limit: integerInput(input, "limit", cursor === undefined ? 30 : 50, 1, 50),
2441
+ });
2442
+ }
2443
+ if (
2444
+ recipe.site === "facebook-marketplace"
2445
+ && recipe.action === "listings.read"
2446
+ ) {
2447
+ requireExactInputKeys(input, ["listing_id"]);
2448
+ return Object.freeze({
2449
+ kind: "facebook-marketplace-listing",
2450
+ listingId: exactStringInput(
2451
+ input,
2452
+ "listing_id",
2453
+ /^[1-9][0-9]{0,31}$/u,
2454
+ "an exact nonzero Marketplace listing ID",
2455
+ ),
2456
+ });
2457
+ }
2458
+ throw new Error(
2459
+ `${recipe.site} authenticated web operation ${recipe.action} has no executable reviewed contract`,
2460
+ );
2461
+ }
2462
+
2463
+ async function executeInstagramRead(
2464
+ prepared: Extract<
2465
+ PreparedMetaRead,
2466
+ {
2467
+ readonly kind:
2468
+ | "instagram-feed"
2469
+ | "instagram-media"
2470
+ | "instagram-comments"
2471
+ | "instagram-inbox"
2472
+ | "instagram-contacts";
2473
+ }
2474
+ >,
2475
+ client: WebSessionClient,
2476
+ viewerId: string,
2477
+ maximumBytes: number,
2478
+ ): Promise<unknown> {
2479
+ const maxBytes = Math.min(maximumBytes, MAX_API_BYTES);
2480
+ if (prepared.kind === "instagram-feed") {
2481
+ const url = new URL("/api/v1/feed/timeline/", ORIGINS.instagram);
2482
+ url.searchParams.set("count", String(prepared.limit));
2483
+ const response = await client.requestJson({
2484
+ url,
2485
+ method: "GET",
2486
+ headers: instagramHeaders(`${ORIGINS.instagram}/`),
2487
+ maxBytes,
2488
+ });
2489
+ return normalizeInstagramFeed(response, prepared.limit);
2490
+ }
2491
+ if (prepared.kind === "instagram-media") {
2492
+ const response = await client.requestJson({
2493
+ url: new URL(`/api/v1/media/${prepared.mediaId}/info/`, ORIGINS.instagram),
2494
+ method: "GET",
2495
+ headers: instagramHeaders(`${ORIGINS.instagram}/`),
2496
+ maxBytes,
2497
+ });
2498
+ return normalizeInstagramPost(response, prepared.mediaId);
2499
+ }
2500
+ if (prepared.kind === "instagram-comments") {
2501
+ const url = new URL(
2502
+ `/api/v1/media/${prepared.mediaId}/comments/`,
2503
+ ORIGINS.instagram,
2504
+ );
2505
+ url.searchParams.set("can_support_threading", "true");
2506
+ url.searchParams.set("permalink_enabled", "false");
2507
+ const response = await client.requestJson({
2508
+ url,
2509
+ method: "GET",
2510
+ headers: instagramHeaders(`${ORIGINS.instagram}/`),
2511
+ maxBytes,
2512
+ });
2513
+ return normalizeInstagramComments(response, prepared.mediaId, prepared.limit);
2514
+ }
2515
+ if (
2516
+ prepared.kind === "instagram-inbox"
2517
+ || prepared.kind === "instagram-contacts"
2518
+ ) {
2519
+ const url = new URL("/api/v1/direct_v2/inbox/", ORIGINS.instagram);
2520
+ const threadLimit = prepared.kind === "instagram-inbox"
2521
+ ? prepared.limit
2522
+ : prepared.threadLimit;
2523
+ url.searchParams.set("limit", String(threadLimit));
2524
+ url.searchParams.set("thread_message_limit", "1");
2525
+ url.searchParams.set("persistentBadging", "true");
2526
+ url.searchParams.set("visual_message_return_type", "unseen");
2527
+ const response = await client.requestJson({
2528
+ url,
2529
+ method: "GET",
2530
+ headers: instagramHeaders(`${ORIGINS.instagram}/direct/inbox/`),
2531
+ maxBytes,
2532
+ });
2533
+ return prepared.kind === "instagram-inbox"
2534
+ ? normalizeInstagramInbox(response, viewerId, prepared.limit)
2535
+ : normalizeInstagramContacts(
2536
+ response,
2537
+ viewerId,
2538
+ prepared.threadLimit,
2539
+ prepared.contactLimit,
2540
+ );
2541
+ }
2542
+ const exhaustive: never = prepared;
2543
+ throw new Error(`Instagram authenticated web operation ${(exhaustive as { kind: string }).kind} is not reviewed`);
2544
+ }
2545
+
2546
+ function instagramProfileBrowserFailure(error: unknown): unknown {
2547
+ if (error instanceof InstagramProfileBrowserResponseRejectedError) {
2548
+ return new ProviderReadResponseRejectedError(error.status);
2549
+ }
2550
+ if (
2551
+ error instanceof InstagramProfileBrowserFailure
2552
+ && (
2553
+ error.category === "startup"
2554
+ || error.category === "execution-context"
2555
+ || error.category === "provider-fetch"
2556
+ )
2557
+ ) return new ProviderReadTransportError(error);
2558
+ return error;
2559
+ }
2560
+
2561
+ async function executeInstagramProfileRead(
2562
+ recipe: WebSessionRecipe,
2563
+ prepared: Extract<PreparedMetaRead, { readonly kind: "instagram-profile" }>,
2564
+ auth: GhostgetAuth,
2565
+ expectedSubject: string,
2566
+ finalUrl: string,
2567
+ options: {
2568
+ readonly dependencies?: MetaWebRuntimeDependencies;
2569
+ readonly operationDeadline?: WebSessionOperationDeadline;
2570
+ readonly publishCleanupResource?: WebSessionCleanupResourcePublisher;
2571
+ },
2572
+ ): Promise<WebSessionExecution> {
2573
+ let failureStage: "identity" | "target" = "identity";
2574
+ let transport: InstagramProfileBrowserTransport | null = null;
2575
+ try {
2576
+ const createTransport = options.dependencies
2577
+ ?.createInstagramProfileBrowserTransport
2578
+ ?? createInstagramProfileBrowserTransport;
2579
+ transport = await createTransport(auth, {
2580
+ timeoutMs: recipe.timeoutMs,
2581
+ // The viewer bootstrap keeps its established 12 MiB bound while the
2582
+ // contained target JSON read remains independently capped at 8 MiB.
2583
+ maxOutputBytes: MAX_BOOTSTRAP_BYTES,
2584
+ ...(options.operationDeadline === undefined
2585
+ ? {}
2586
+ : { operationDeadline: options.operationDeadline }),
2587
+ ...(options.publishCleanupResource === undefined
2588
+ ? {}
2589
+ : { publishCleanupResource: options.publishCleanupResource }),
2590
+ });
2591
+ const viewerId = parseInstagramViewerId(
2592
+ await transport.readCurrentViewerHtml(),
2593
+ );
2594
+ if (`instagram:${viewerId}` !== expectedSubject) {
2595
+ throw new Error(
2596
+ "instagram current viewer no longer matches the confirmed auth subject",
2597
+ );
2598
+ }
2599
+ failureStage = "target";
2600
+ const response = await transport.readProfileJson(prepared.profile);
2601
+ const output = normalizeInstagramProfileStats(
2602
+ response,
2603
+ viewerId,
2604
+ prepared.profile,
2605
+ new Date((options.dependencies?.now ?? Date.now)()).toISOString(),
2606
+ );
2607
+ return {
2608
+ status: "succeeded",
2609
+ output,
2610
+ finalUrl,
2611
+ dispatchStarted: false,
2612
+ dispatch: { planned: 0, started: 0, verified: 0 },
2613
+ };
2614
+ } catch (error) {
2615
+ if (error instanceof PreservedBrowserArtifactsError) throw error;
2616
+ const projected = instagramProfileBrowserFailure(error);
2617
+ return failedProviderRead("Instagram profile", projected, finalUrl, {
2618
+ stage: failureStage,
2619
+ authenticated: true,
2620
+ accountMismatch: (candidate) => candidate.message.includes(
2621
+ "current viewer no longer matches the confirmed auth subject",
2622
+ ) || candidate.message.includes(
2623
+ "profile response did not bind the current viewer ID",
2624
+ ) || candidate.message.includes(
2625
+ "profile response did not bind the requested handle",
2626
+ ),
2627
+ authRepairRequired: (candidate) =>
2628
+ candidate instanceof InstagramProfileBrowserFailure
2629
+ && candidate.category === "authwall",
2630
+ });
2631
+ } finally {
2632
+ await transport?.close();
2633
+ }
2634
+ }
2635
+
2636
+ function instagramMutationDispatchEvent(
2637
+ id: "content.delete" | "media.publish",
2638
+ started: number,
2639
+ verified: number,
2640
+ ): WebSessionDispatchEvent {
2641
+ return Object.freeze({
2642
+ id,
2643
+ index: 1,
2644
+ progress: Object.freeze({ planned: 1, started, verified }),
2645
+ });
2646
+ }
2647
+
2648
+ async function readInstagramAuthoredVideoTarget(
2649
+ client: WebSessionClient,
2650
+ recipe: WebSessionRecipe,
2651
+ viewerId: string,
2652
+ mediaId: string,
2653
+ expectedCaption: string,
2654
+ ): Promise<ReturnType<typeof bindInstagramVideoMediaReadback>> {
2655
+ const response = await client.requestJson({
2656
+ url: new URL(`/api/v1/media/${mediaId}/info/`, ORIGINS.instagram),
2657
+ method: "GET",
2658
+ headers: instagramHeaders(`${ORIGINS.instagram}/`),
2659
+ expectedStatuses: [200],
2660
+ expectedContentTypes: ["application/json"],
2661
+ maxBytes: Math.min(recipe.maxOutputBytes, MAX_API_BYTES),
2662
+ });
2663
+ return bindInstagramVideoMediaReadback(
2664
+ normalizeInstagramPost(response, mediaId),
2665
+ { expectedCaption, mediaId, viewerId },
2666
+ );
2667
+ }
2668
+
2669
+ async function instagramBlobBytes(
2670
+ body: Blob,
2671
+ expectedLength: number,
2672
+ label: "video" | "thumbnail",
2673
+ ): Promise<Uint8Array> {
2674
+ const bytes = new Uint8Array(await body.arrayBuffer());
2675
+ if (bytes.byteLength !== expectedLength) {
2676
+ throw new Error(`Instagram ${label} dispatch body changed length`);
2677
+ }
2678
+ return bytes;
2679
+ }
2680
+
2681
+ async function executeInstagramVideoPublish(
2682
+ client: WebSessionClient,
2683
+ viewer: BoundMetaViewer,
2684
+ recipe: WebSessionRecipe,
2685
+ input: OperationInput,
2686
+ auth: GhostgetAuth,
2687
+ options: {
2688
+ readonly fileResolver?: BrowserFileResolver;
2689
+ readonly signal?: AbortSignal;
2690
+ readonly operationDeadline?: WebSessionOperationDeadline;
2691
+ readonly beforeDispatch?: (event: WebSessionDispatchEvent) => Promise<void>;
2692
+ readonly afterProviderAcceptedMutationTarget?: (
2693
+ event: WebSessionProviderAcceptedMutationTargetEvent,
2694
+ ) => Promise<void>;
2695
+ readonly afterDispatchVerified?: (event: WebSessionDispatchEvent) => Promise<void>;
2696
+ readonly dependencies?: MetaWebRuntimeDependencies;
2697
+ readonly now: () => number;
2698
+ },
2699
+ ): Promise<WebSessionExecution> {
2700
+ const bound = await materializeInstagramVideoPublishInput(
2701
+ input,
2702
+ options.fileResolver,
2703
+ options.operationDeadline,
2704
+ );
2705
+ const snapshot = revalidateInstagramVideoPublishBindingForDispatch(bound);
2706
+ const uploadId = instagramVideoUploadId(options.now);
2707
+ const config = instagramVideoRequestConfig(viewer.rootHtml);
2708
+ const csrfToken = webSessionCookie(client.cookies, "csrftoken");
2709
+ const uploadClient = await createWebSessionClient(INSTAGRAM_UPLOAD_ORIGIN, auth, {
2710
+ timeoutMs: recipe.timeoutMs,
2711
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
2712
+ ...(options.operationDeadline === undefined
2713
+ ? {}
2714
+ : { operationDeadline: options.operationDeadline }),
2715
+ ...(options.dependencies === undefined
2716
+ ? {}
2717
+ : { dependencies: options.dependencies }),
2718
+ });
2719
+ if (webSessionCookie(uploadClient.cookies, "ds_user_id") !== viewer.id) {
2720
+ throw new Error("Instagram upload session did not bind the confirmed viewer");
2721
+ }
2722
+
2723
+ let started = 0;
2724
+ let verified = 0;
2725
+ let failureStage = "dispatch-admission";
2726
+ let target: ReturnType<typeof parseInstagramVideoConfigureAccepted> | null = null;
2727
+ try {
2728
+ await options.beforeDispatch?.(
2729
+ instagramMutationDispatchEvent("media.publish", started, verified),
2730
+ );
2731
+ started = 1;
2732
+
2733
+ failureStage = "video-upload";
2734
+ const videoRequest = instagramVideoUploadRequestShape(
2735
+ snapshot,
2736
+ uploadId,
2737
+ csrfToken,
2738
+ config,
2739
+ );
2740
+ const videoBytes = await instagramBlobBytes(
2741
+ snapshot.body,
2742
+ snapshot.byteLength,
2743
+ "video",
2744
+ );
2745
+ try {
2746
+ const acknowledgement = await uploadClient.requestJson({
2747
+ url: new URL(videoRequest.url),
2748
+ method: videoRequest.method,
2749
+ headers: videoRequest.headers,
2750
+ body: videoBytes,
2751
+ expectedStatuses: [200],
2752
+ expectedContentTypes: ["application/json"],
2753
+ maxBytes: 16 * 1024,
2754
+ });
2755
+ assertInstagramVideoUploadAcknowledgement(acknowledgement);
2756
+ } finally {
2757
+ videoBytes.fill(0);
2758
+ }
2759
+
2760
+ failureStage = "thumbnail-upload";
2761
+ const thumbnailRequest = instagramVideoThumbnailUploadRequestShape(
2762
+ snapshot,
2763
+ uploadId,
2764
+ csrfToken,
2765
+ config,
2766
+ );
2767
+ const thumbnailBytes = await instagramBlobBytes(
2768
+ snapshot.thumbnailBody,
2769
+ snapshot.thumbnailByteLength,
2770
+ "thumbnail",
2771
+ );
2772
+ try {
2773
+ const acknowledgement = await uploadClient.requestJson({
2774
+ url: new URL(thumbnailRequest.url),
2775
+ method: thumbnailRequest.method,
2776
+ headers: thumbnailRequest.headers,
2777
+ body: thumbnailBytes,
2778
+ expectedStatuses: [200],
2779
+ expectedContentTypes: ["application/json"],
2780
+ maxBytes: 16 * 1024,
2781
+ });
2782
+ assertInstagramVideoUploadAcknowledgement(acknowledgement);
2783
+ } finally {
2784
+ thumbnailBytes.fill(0);
2785
+ }
2786
+
2787
+ failureStage = "account-revalidation";
2788
+ const reboundViewer = await requireBoundViewer(
2789
+ "instagram",
2790
+ client,
2791
+ viewer.subject,
2792
+ );
2793
+
2794
+ failureStage = "configure";
2795
+ const configureRequest = instagramVideoConfigureRequestShape(
2796
+ snapshot.caption,
2797
+ uploadId,
2798
+ csrfToken,
2799
+ config,
2800
+ );
2801
+ const configure = await client.requestJsonResponse({
2802
+ url: new URL(configureRequest.url),
2803
+ method: configureRequest.method,
2804
+ headers: configureRequest.headers,
2805
+ body: configureRequest.body,
2806
+ expectedStatuses: [200, 202],
2807
+ expectedContentTypes: ["application/json"],
2808
+ maxBytes: Math.min(recipe.maxOutputBytes, MAX_API_BYTES),
2809
+ });
2810
+ if (configure.status === 202) {
2811
+ assertInstagramVideoConfigureIndeterminate(configure.value);
2812
+ throw new Error(
2813
+ "Instagram configure remains indeterminate after its one allowed POST",
2814
+ );
2815
+ }
2816
+ target = parseInstagramVideoConfigureAccepted(configure.value, {
2817
+ caption: snapshot.caption,
2818
+ uploadId,
2819
+ viewerId: reboundViewer.id,
2820
+ });
2821
+
2822
+ failureStage = "accepted-target-recording";
2823
+ await options.afterProviderAcceptedMutationTarget?.({
2824
+ id: "media.publish",
2825
+ index: 1,
2826
+ target: Object.freeze({
2827
+ schemaVersion: 1,
2828
+ identifier: instagramVideoAcceptedTargetIdentifier(target),
2829
+ }),
2830
+ });
2831
+
2832
+ failureStage = "publication-readback";
2833
+ await readInstagramAuthoredVideoTarget(
2834
+ client,
2835
+ recipe,
2836
+ reboundViewer.id,
2837
+ target.mediaId,
2838
+ snapshot.caption,
2839
+ );
2840
+ verified = 1;
2841
+ failureStage = "verification-recording";
2842
+ await options.afterDispatchVerified?.(
2843
+ instagramMutationDispatchEvent("media.publish", started, verified),
2844
+ );
2845
+ return {
2846
+ status: "succeeded",
2847
+ output: Object.freeze({
2848
+ published: true,
2849
+ target,
2850
+ media: Object.freeze({
2851
+ byteLength: snapshot.byteLength,
2852
+ durationMilliseconds: snapshot.durationMilliseconds,
2853
+ height: snapshot.height,
2854
+ mediaType: snapshot.mediaType,
2855
+ sha256: snapshot.mediaSha256,
2856
+ thumbnailByteLength: snapshot.thumbnailByteLength,
2857
+ thumbnailMediaType: snapshot.thumbnailMediaType,
2858
+ thumbnailSha256: snapshot.thumbnailSha256,
2859
+ width: snapshot.width,
2860
+ }),
2861
+ }),
2862
+ finalUrl: target.url,
2863
+ dispatchStarted: true,
2864
+ dispatch: { planned: 1, started, verified },
2865
+ };
2866
+ } catch {
2867
+ return {
2868
+ status: started > 0 ? "indeterminate" : "failed",
2869
+ output: null,
2870
+ finalUrl: target?.url ?? ORIGINS.instagram,
2871
+ dispatchStarted: started > 0,
2872
+ dispatch: { planned: 1, started, verified },
2873
+ error: started > 0
2874
+ ? `Instagram may have accepted the exact video publish, but its response-derived target and independent readback were not both verified; reconcile before retrying (stage: ${failureStage})`
2875
+ : `Instagram video publication failed before remote submission (stage: ${failureStage})`,
2876
+ };
2877
+ }
2878
+ }
2879
+
2880
+ async function executeInstagramVideoDelete(
2881
+ client: WebSessionClient,
2882
+ viewer: BoundMetaViewer,
2883
+ recipe: WebSessionRecipe,
2884
+ prepared: Extract<PreparedMetaRead, { readonly kind: "instagram-video-delete" }>,
2885
+ options: {
2886
+ readonly beforeDispatch?: (event: WebSessionDispatchEvent) => Promise<void>;
2887
+ readonly afterProviderBoundMutationTarget?: (
2888
+ event: WebSessionProviderBoundMutationTargetEvent,
2889
+ ) => Promise<void>;
2890
+ readonly afterDispatchVerified?: (event: WebSessionDispatchEvent) => Promise<void>;
2891
+ },
2892
+ ): Promise<WebSessionExecution> {
2893
+ const initial = await readInstagramAuthoredVideoTarget(
2894
+ client,
2895
+ recipe,
2896
+ viewer.id,
2897
+ prepared.mediaId,
2898
+ prepared.expectedCaption,
2899
+ );
2900
+ const reboundViewer = await requireBoundViewer(
2901
+ "instagram",
2902
+ client,
2903
+ viewer.subject,
2904
+ );
2905
+ const fresh = await readInstagramAuthoredVideoTarget(
2906
+ client,
2907
+ recipe,
2908
+ reboundViewer.id,
2909
+ prepared.mediaId,
2910
+ prepared.expectedCaption,
2911
+ );
2912
+ if (instagramVideoAcceptedTargetIdentifier(initial)
2913
+ !== instagramVideoAcceptedTargetIdentifier(fresh)) {
2914
+ throw new Error("Instagram deletion target changed between its exact pre-reads");
2915
+ }
2916
+ const config = instagramVideoRequestConfig(reboundViewer.rootHtml);
2917
+ const csrfToken = webSessionCookie(client.cookies, "csrftoken");
2918
+ const request = instagramVideoDeleteRequestShape(
2919
+ fresh.mediaId,
2920
+ csrfToken,
2921
+ config,
2922
+ );
2923
+
2924
+ let started = 0;
2925
+ let verified = 0;
2926
+ let failureStage = "dispatch-admission";
2927
+ try {
2928
+ await options.beforeDispatch?.(
2929
+ instagramMutationDispatchEvent("content.delete", started, verified),
2930
+ );
2931
+ started = 1;
2932
+ failureStage = "bound-target-recording";
2933
+ await options.afterProviderBoundMutationTarget?.({
2934
+ id: "content.delete",
2935
+ index: 1,
2936
+ target: Object.freeze({
2937
+ schemaVersion: 1,
2938
+ identifier: instagramVideoAcceptedTargetIdentifier(fresh),
2939
+ }),
2940
+ });
2941
+
2942
+ failureStage = "delete-transport";
2943
+ const acknowledgement = await client.requestJson({
2944
+ url: new URL(request.url),
2945
+ method: request.method,
2946
+ headers: request.headers,
2947
+ body: request.body,
2948
+ expectedStatuses: [200],
2949
+ expectedContentTypes: ["application/json"],
2950
+ maxBytes: 16 * 1024,
2951
+ });
2952
+ assertInstagramVideoDeleteAcknowledgement(acknowledgement);
2953
+
2954
+ failureStage = "account-revalidation";
2955
+ await requireBoundViewer("instagram", client, reboundViewer.subject);
2956
+ failureStage = "deletion-readback";
2957
+ const permalink = await client.requestText({
2958
+ url: new URL(fresh.url),
2959
+ method: "GET",
2960
+ headers: htmlHeaders(ORIGINS.instagram),
2961
+ expectedContentTypes: ["text/html"],
2962
+ maxBytes: Math.min(recipe.maxOutputBytes, MAX_API_BYTES),
2963
+ });
2964
+ if (!instagramVideoPermalinkWasRemoved(permalink)) {
2965
+ throw new Error("Instagram deleted-video permalink did not return its removal marker");
2966
+ }
2967
+ verified = 1;
2968
+ failureStage = "verification-recording";
2969
+ await options.afterDispatchVerified?.(
2970
+ instagramMutationDispatchEvent("content.delete", started, verified),
2971
+ );
2972
+ return {
2973
+ status: "succeeded",
2974
+ output: Object.freeze({ deleted: true, target: fresh }),
2975
+ finalUrl: fresh.url,
2976
+ dispatchStarted: true,
2977
+ dispatch: { planned: 1, started, verified },
2978
+ };
2979
+ } catch {
2980
+ return {
2981
+ status: started > 0 ? "indeterminate" : "failed",
2982
+ output: null,
2983
+ finalUrl: fresh.url,
2984
+ dispatchStarted: started > 0,
2985
+ dispatch: { planned: 1, started, verified },
2986
+ error: started > 0
2987
+ ? `Instagram may have deleted the exact authored video, but its fixed soft-200 removal marker was not verified; reconcile before retrying (stage: ${failureStage})`
2988
+ : `Instagram video deletion failed before remote submission (stage: ${failureStage})`,
2989
+ };
2990
+ }
2991
+ }
2992
+
2993
+ type MetaWebExecutionOptions = {
2994
+ readonly fileResolver?: BrowserFileResolver;
2995
+ readonly signal?: AbortSignal;
2996
+ readonly operationDeadline?: WebSessionOperationDeadline;
2997
+ readonly registerCleanupBarrier?: WebSessionCleanupBarrierRegistrar;
2998
+ readonly publishCleanupResource?: WebSessionCleanupResourcePublisher;
2999
+ readonly beforeDispatch?: (event: WebSessionDispatchEvent) => Promise<void>;
3000
+ readonly afterProviderAcceptedMutationTarget?: (
3001
+ event: WebSessionProviderAcceptedMutationTargetEvent,
3002
+ ) => Promise<void>;
3003
+ readonly afterProviderBoundMutationTarget?: (
3004
+ event: WebSessionProviderBoundMutationTargetEvent,
3005
+ ) => Promise<void>;
3006
+ readonly afterDispatchVerified?: (event: WebSessionDispatchEvent) => Promise<void>;
3007
+ readonly dependencies?: MetaWebRuntimeDependencies;
3008
+ readonly environment?: Readonly<Record<string, string | undefined>>;
3009
+ };
3010
+
3011
+ async function executeMetaWebOperationInternal(
3012
+ recipe: WebSessionRecipe,
3013
+ input: OperationInput,
3014
+ auth: GhostgetAuth,
3015
+ options: MetaWebExecutionOptions = {},
3016
+ ): Promise<WebSessionExecution> {
3017
+ if (!isMetaSite(recipe.site)) {
3018
+ throw new Error("Meta authenticated web recipe is not installed");
3019
+ }
3020
+ const contract = operationContract(recipe.site, recipe.action);
3021
+ if (contract === null) throw new Error(`Meta authenticated web operation ${recipe.action} is not registered`);
3022
+ if (recipe.contractVersion !== contract.contractVersion) {
3023
+ throw new Error(
3024
+ `${recipe.site} authenticated web operation ${recipe.action} contract version ${recipe.contractVersion} is not installed`,
3025
+ );
3026
+ }
3027
+ if (contract.state !== "observed") {
3028
+ throw new Error(`${recipe.site} authenticated web operation ${recipe.action} is capture-required: ${contract.reason}`);
3029
+ }
3030
+ const isThreadsMutation = recipe.site === "threads"
3031
+ && (recipe.action === "posts.publish" || recipe.action === "media.publish");
3032
+ const isInstagramMutation = recipe.site === "instagram"
3033
+ && recipe.action === "content.delete";
3034
+ if (
3035
+ !isThreadsMutation
3036
+ && !isInstagramMutation
3037
+ && (contract.risk !== "R1" || contract.effect !== "read")
3038
+ ) {
3039
+ throw new Error(`${recipe.site} reviewed Meta runtime refuses non-read execution`);
3040
+ }
3041
+ if (!isThreadsMutation && !isInstagramMutation) {
3042
+ void options.beforeDispatch;
3043
+ void options.afterProviderAcceptedMutationTarget;
3044
+ void options.afterProviderBoundMutationTarget;
3045
+ void options.afterDispatchVerified;
3046
+ }
3047
+
3048
+ const environment = options.environment ?? process.env;
3049
+ const prepared = prepareMetaRead(recipe, input, auth, environment);
3050
+ const statisticsRead = prepared.kind === "instagram-profile"
3051
+ || prepared.kind === "threads-profile";
3052
+
3053
+ const origin = ORIGINS[recipe.site];
3054
+ const statisticsFinalUrl = prepared.kind === "instagram-profile"
3055
+ ? `${origin}/${prepared.profile}/`
3056
+ : prepared.kind === "threads-profile"
3057
+ ? new URL(`/@${encodeURIComponent(prepared.profile)}`, origin).href
3058
+ : null;
3059
+ let expectedSubject: string;
3060
+ try {
3061
+ expectedSubject = expectedMetaAuthSubject(recipe.site, auth);
3062
+ } catch (error) {
3063
+ if (!statisticsRead) throw error;
3064
+ return failedProviderRead(`${recipe.site} profile`, error, statisticsFinalUrl, {
3065
+ stage: "bootstrap",
3066
+ authenticated: true,
3067
+ authRepairRequired: (candidate) => candidate.message.includes("auth subject"),
3068
+ });
3069
+ }
3070
+ if (prepared.kind === "instagram-profile") {
3071
+ return executeInstagramProfileRead(
3072
+ recipe,
3073
+ prepared,
3074
+ auth,
3075
+ expectedSubject,
3076
+ `${origin}/${prepared.profile}/`,
3077
+ {
3078
+ ...(options.dependencies === undefined
3079
+ ? {}
3080
+ : { dependencies: options.dependencies }),
3081
+ ...(options.operationDeadline === undefined
3082
+ ? {}
3083
+ : { operationDeadline: options.operationDeadline }),
3084
+ ...(options.publishCleanupResource === undefined
3085
+ ? {}
3086
+ : { publishCleanupResource: options.publishCleanupResource }),
3087
+ },
3088
+ );
3089
+ }
3090
+ const dependencies = metaWebSessionDependencies(
3091
+ recipe.site,
3092
+ auth,
3093
+ options.dependencies,
3094
+ );
3095
+ let threadsAccountCookieState: WebSessionCookieRotationState = Object.freeze({
3096
+ cookies: Object.freeze([]),
3097
+ tombstones: Object.freeze([]),
3098
+ });
3099
+ let client: WebSessionClient;
3100
+ try {
3101
+ client = await createWebSessionClient(origin, auth, {
3102
+ timeoutMs: recipe.timeoutMs,
3103
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
3104
+ ...(options.operationDeadline === undefined
3105
+ ? {}
3106
+ : { operationDeadline: options.operationDeadline }),
3107
+ ...(dependencies === undefined ? {} : { dependencies }),
3108
+ ...(isThreadsMutation
3109
+ ? {
3110
+ cookieRotation: {
3111
+ // Account-cookie changes are retained only inside this invocation
3112
+ // so the mandatory post-upload viewer probe observes and rejects
3113
+ // an upload response that moved the browser session to a new user.
3114
+ allowedNames: Object.freeze(["ds_user_id"]),
3115
+ cachedState: threadsAccountCookieState,
3116
+ maxCachedCookieAgeSeconds: 60,
3117
+ tombstoneTtlSeconds: 60,
3118
+ save: (state: WebSessionCookieRotationState) => {
3119
+ threadsAccountCookieState = state;
3120
+ },
3121
+ },
3122
+ }
3123
+ : {}),
3124
+ });
3125
+ } catch (error) {
3126
+ if (!statisticsRead) throw error;
3127
+ return failedProviderRead(`${recipe.site} profile`, error, statisticsFinalUrl, {
3128
+ stage: "bootstrap",
3129
+ authenticated: true,
3130
+ });
3131
+ }
3132
+ let viewer: BoundMetaViewer;
3133
+ try {
3134
+ viewer = await requireBoundViewer(recipe.site, client, expectedSubject);
3135
+ } catch (error) {
3136
+ if (!statisticsRead) throw error;
3137
+ return failedProviderRead(`${recipe.site} profile`, error, statisticsFinalUrl, {
3138
+ stage: "identity",
3139
+ authenticated: true,
3140
+ accountMismatch: (candidate) => candidate.message.includes("no longer matches")
3141
+ || candidate.message.includes("did not match the confirmed auth subject")
3142
+ || candidate.message.includes("did not match the selected browser session"),
3143
+ authRepairRequired: (candidate) => candidate.message.includes("cookie")
3144
+ || candidate instanceof ThreadsAuthRepairRequiredError,
3145
+ });
3146
+ }
3147
+ if (prepared.kind === "threads-profile") {
3148
+ try {
3149
+ const profilePath = `/@${encodeURIComponent(prepared.profile)}`;
3150
+ const profileHtml = await metaHtmlPath(client, origin, profilePath);
3151
+ let recentViews = normalizeThreadsRecentViewsAvailability("<main></main>");
3152
+ try {
3153
+ const insightsHtml = await metaHtmlPath(client, origin, "/insights", profilePath);
3154
+ recentViews = normalizeThreadsRecentViewsAvailability(insightsHtml);
3155
+ } catch (error) {
3156
+ let cause: unknown = error;
3157
+ for (let depth = 0; depth < 8 && cause !== undefined; depth += 1) {
3158
+ if (cause instanceof OperationDeadlineError) throw cause;
3159
+ cause = cause instanceof Error ? cause.cause : undefined;
3160
+ }
3161
+ options.operationDeadline?.throwIfUnavailable(
3162
+ "Threads insights supplemental read",
3163
+ );
3164
+ // Insights are eligibility-gated supplemental data. The exact target
3165
+ // follower projection remains publishable when this page is absent.
3166
+ }
3167
+ const output = normalizeThreadsProfileStats(
3168
+ profileHtml,
3169
+ viewer.id,
3170
+ prepared.profile,
3171
+ recentViews,
3172
+ new Date((options.dependencies?.now ?? Date.now)()).toISOString(),
3173
+ );
3174
+ return {
3175
+ status: "succeeded",
3176
+ output,
3177
+ finalUrl: statisticsFinalUrl,
3178
+ dispatchStarted: false,
3179
+ dispatch: { planned: 0, started: 0, verified: 0 },
3180
+ };
3181
+ } catch (error) {
3182
+ return failedProviderRead("Threads profile", error, statisticsFinalUrl, {
3183
+ stage: "target",
3184
+ authenticated: true,
3185
+ accountMismatch: (candidate) => candidate.message.includes(
3186
+ "response changed its bound viewer",
3187
+ ) || candidate.message.includes(
3188
+ "did not bind the current viewer ID",
3189
+ ),
3190
+ });
3191
+ }
3192
+ }
3193
+ if (prepared.kind === "instagram-video") {
3194
+ return executeInstagramVideoPublish(
3195
+ client,
3196
+ viewer,
3197
+ recipe,
3198
+ prepared.input,
3199
+ auth,
3200
+ {
3201
+ ...(options.fileResolver === undefined ? {} : { fileResolver: options.fileResolver }),
3202
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
3203
+ ...(options.operationDeadline === undefined
3204
+ ? {}
3205
+ : { operationDeadline: options.operationDeadline }),
3206
+ ...(options.beforeDispatch === undefined
3207
+ ? {}
3208
+ : { beforeDispatch: options.beforeDispatch }),
3209
+ ...(options.afterProviderAcceptedMutationTarget === undefined
3210
+ ? {}
3211
+ : {
3212
+ afterProviderAcceptedMutationTarget:
3213
+ options.afterProviderAcceptedMutationTarget,
3214
+ }),
3215
+ ...(options.afterDispatchVerified === undefined
3216
+ ? {}
3217
+ : { afterDispatchVerified: options.afterDispatchVerified }),
3218
+ ...(dependencies === undefined ? {} : { dependencies }),
3219
+ now: options.dependencies?.now ?? Date.now,
3220
+ },
3221
+ );
3222
+ }
3223
+ if (prepared.kind === "instagram-video-delete") {
3224
+ return executeInstagramVideoDelete(client, viewer, recipe, prepared, {
3225
+ ...(options.beforeDispatch === undefined
3226
+ ? {}
3227
+ : { beforeDispatch: options.beforeDispatch }),
3228
+ ...(options.afterProviderBoundMutationTarget === undefined
3229
+ ? {}
3230
+ : {
3231
+ afterProviderBoundMutationTarget:
3232
+ options.afterProviderBoundMutationTarget,
3233
+ }),
3234
+ ...(options.afterDispatchVerified === undefined
3235
+ ? {}
3236
+ : { afterDispatchVerified: options.afterDispatchVerified }),
3237
+ });
3238
+ }
3239
+ if (prepared.kind === "threads-post") {
3240
+ return executeThreadsPost(client, viewer, prepared, {
3241
+ ...(options.fileResolver === undefined ? {} : { fileResolver: options.fileResolver }),
3242
+ ...(options.operationDeadline === undefined
3243
+ ? {}
3244
+ : { operationDeadline: options.operationDeadline }),
3245
+ ...(options.beforeDispatch === undefined
3246
+ ? {}
3247
+ : { beforeDispatch: options.beforeDispatch }),
3248
+ ...(options.afterProviderAcceptedMutationTarget === undefined
3249
+ ? {}
3250
+ : {
3251
+ afterProviderAcceptedMutationTarget:
3252
+ options.afterProviderAcceptedMutationTarget,
3253
+ }),
3254
+ ...(options.afterDispatchVerified === undefined
3255
+ ? {}
3256
+ : { afterDispatchVerified: options.afterDispatchVerified }),
3257
+ now: options.dependencies?.now ?? Date.now,
3258
+ });
3259
+ }
3260
+ if (prepared.kind === "threads-video") {
3261
+ return executeThreadsVideo(client, viewer, prepared, {
3262
+ ...(options.fileResolver === undefined ? {} : { fileResolver: options.fileResolver }),
3263
+ ...(options.operationDeadline === undefined
3264
+ ? {}
3265
+ : { operationDeadline: options.operationDeadline }),
3266
+ ...(options.beforeDispatch === undefined
3267
+ ? {}
3268
+ : { beforeDispatch: options.beforeDispatch }),
3269
+ ...(options.afterProviderAcceptedMutationTarget === undefined
3270
+ ? {}
3271
+ : {
3272
+ afterProviderAcceptedMutationTarget:
3273
+ options.afterProviderAcceptedMutationTarget,
3274
+ }),
3275
+ ...(options.afterDispatchVerified === undefined
3276
+ ? {}
3277
+ : { afterDispatchVerified: options.afterDispatchVerified }),
3278
+ now: options.dependencies?.now ?? Date.now,
3279
+ });
3280
+ }
3281
+ let output: unknown;
3282
+ let finalUrl = `${origin}/`;
3283
+ if (
3284
+ prepared.kind === "instagram-feed"
3285
+ || prepared.kind === "instagram-media"
3286
+ || prepared.kind === "instagram-comments"
3287
+ || prepared.kind === "instagram-inbox"
3288
+ || prepared.kind === "instagram-contacts"
3289
+ ) {
3290
+ const instagramResponse = await executeInstagramRead(
3291
+ prepared,
3292
+ client,
3293
+ viewer.id,
3294
+ recipe.maxOutputBytes,
3295
+ );
3296
+ output = instagramResponse;
3297
+ if (
3298
+ prepared.kind === "instagram-inbox"
3299
+ || prepared.kind === "instagram-contacts"
3300
+ ) {
3301
+ finalUrl = `${origin}/direct/inbox/`;
3302
+ }
3303
+ } else if (prepared.kind === "threads-feed") {
3304
+ output = normalizeThreadsFeedHtml(viewer.rootHtml, viewer.id, prepared.limit);
3305
+ } else if (prepared.kind === "facebook-feed") {
3306
+ output = normalizeFacebookFeedHtml(viewer.rootHtml, viewer.id, prepared.limit);
3307
+ } else if (prepared.kind === "facebook-group-feed") {
3308
+ const path = `/groups/${encodeURIComponent(prepared.groupId)}/`;
3309
+ const html = await metaHtmlPath(client, origin, path, "/groups/");
3310
+ output = normalizeFacebookGroupFeedHtml(
3311
+ html,
3312
+ viewer.id,
3313
+ prepared.groupId,
3314
+ prepared.limit,
3315
+ );
3316
+ finalUrl = new URL(path, origin).href;
3317
+ } else if (prepared.kind === "facebook-marketplace-feed") {
3318
+ const html = await metaHtmlPath(client, origin, "/marketplace/");
3319
+ const feed = prepared.cursor === undefined
3320
+ ? normalizeFacebookMarketplaceFeedHtml(html, viewer.id, prepared.limit)
3321
+ : await executeFacebookMarketplaceContinuation(
3322
+ client,
3323
+ html,
3324
+ viewer.id,
3325
+ prepared.cursor,
3326
+ prepared.limit,
3327
+ recipe,
3328
+ options.dependencies,
3329
+ options,
3330
+ );
3331
+ output = sealFacebookMarketplaceFeed(
3332
+ feed,
3333
+ html,
3334
+ viewer.id,
3335
+ auth,
3336
+ prepared.cursor ?? null,
3337
+ environment,
3338
+ );
3339
+ finalUrl = `${origin}/marketplace/`;
3340
+ } else if (prepared.kind === "facebook-marketplace-listing") {
3341
+ const path = `/marketplace/item/${encodeURIComponent(prepared.listingId)}/`;
3342
+ const html = await metaHtmlPath(client, origin, path, "/marketplace/");
3343
+ output = normalizeFacebookMarketplaceListingHtml(
3344
+ html,
3345
+ viewer.id,
3346
+ prepared.listingId,
3347
+ );
3348
+ finalUrl = new URL(path, origin).href;
3349
+ } else {
3350
+ const exhaustive: never = prepared;
3351
+ throw new Error(
3352
+ `Meta authenticated web operation ${(exhaustive as { kind: string }).kind} is not reviewed`,
3353
+ );
3354
+ }
3355
+ return {
3356
+ status: "succeeded",
3357
+ output,
3358
+ finalUrl,
3359
+ dispatchStarted: false,
3360
+ dispatch: { planned: 0, started: 0, verified: 0 },
3361
+ };
3362
+ }
3363
+
3364
+ export function executeMetaWebOperation(
3365
+ recipe: WebSessionRecipe,
3366
+ input: OperationInput,
3367
+ auth: GhostgetAuth,
3368
+ options: MetaWebExecutionOptions = {},
3369
+ ): Promise<WebSessionExecution> {
3370
+ if (recipe.site === "instagram" && recipe.action === "profiles.read") {
3371
+ return startWebSessionCleanupTrackedOperation(
3372
+ options.registerCleanupBarrier,
3373
+ (publishCleanupResource) => executeMetaWebOperationInternal(
3374
+ recipe,
3375
+ input,
3376
+ auth,
3377
+ {
3378
+ ...options,
3379
+ ...(publishCleanupResource === undefined
3380
+ ? {}
3381
+ : { publishCleanupResource }),
3382
+ },
3383
+ ),
3384
+ browserCleanupBarrier,
3385
+ );
3386
+ }
3387
+ return executeMetaWebOperationInternal(recipe, input, auth, options);
3388
+ }