@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,809 @@
1
+ /**
2
+ * Hacker News first-party HTML policy and bounded projection.
3
+ *
4
+ * Authenticated reads use fixed first-party pages. Request-bound `auth`,
5
+ * `hmac`, `fnid`, and `fnop` values may exist only in ephemeral values parsed
6
+ * from the immediately preceding page; normalized output never contains them.
7
+ */
8
+
9
+ import { renderCookieHeader } from "@hraness/kb/clip/cookies";
10
+
11
+ import { pinnedHttpsFetch } from "../pinned-https";
12
+ import type {
13
+ WebSessionClient,
14
+ WebSessionFetch,
15
+ } from "../web-session-client";
16
+
17
+ export const HACKER_NEWS_WEB_OPERATION_NAMES = Object.freeze([
18
+ "comments.create",
19
+ "comments.read",
20
+ "content.edit",
21
+ "content.save",
22
+ "feeds.read",
23
+ "posts.publish",
24
+ "posts.read",
25
+ "reactions.set",
26
+ "replies.create",
27
+ ] as const);
28
+
29
+ export type HackerNewsWebOperationName = (typeof HACKER_NEWS_WEB_OPERATION_NAMES)[number];
30
+ export type HackerNewsWebContractState = "observed" | "capture-required";
31
+ export type HackerNewsWebRisk = "R1" | "R2" | "R3";
32
+
33
+ export type HackerNewsWebOperationContract = {
34
+ readonly effect: "read" | "write";
35
+ readonly risk: HackerNewsWebRisk;
36
+ readonly state: HackerNewsWebContractState;
37
+ readonly reason: string;
38
+ };
39
+
40
+ const observed = (reason: string): HackerNewsWebOperationContract => Object.freeze({
41
+ effect: "read",
42
+ risk: "R1",
43
+ state: "observed",
44
+ reason,
45
+ });
46
+
47
+ const captureRequired = (
48
+ risk: "R2" | "R3",
49
+ reason: string,
50
+ ): HackerNewsWebOperationContract => Object.freeze({
51
+ effect: "write",
52
+ risk,
53
+ state: "capture-required",
54
+ reason,
55
+ });
56
+
57
+ export const HACKER_NEWS_WEB_OPERATIONS = Object.freeze({
58
+ "feeds.read": observed("signed-in /news HTML with exact athing/subtext projection"),
59
+ "posts.read": observed("exact /item?id target with submission-row binding"),
60
+ "comments.read": observed("exact /item?id target with bounded ordered comment projection"),
61
+ "content.save": captureRequired(
62
+ "R2",
63
+ "favorite and un-favorite links are request-bound; both real state fixtures and independent favorites-list readback are still required",
64
+ ),
65
+ "reactions.set": captureRequired(
66
+ "R2",
67
+ "upvote and unvote are request-bound human actions; exact undo fixture and readback are still required",
68
+ ),
69
+ "comments.create": captureRequired(
70
+ "R3",
71
+ "comment hmac form and externally visible response need an authorized fixture",
72
+ ),
73
+ "replies.create": captureRequired(
74
+ "R3",
75
+ "reply hmac form and exact parent/actor response binding need an authorized fixture",
76
+ ),
77
+ "posts.publish": captureRequired(
78
+ "R3",
79
+ "submission fnid/fnop form and returned item binding need an authorized fixture",
80
+ ),
81
+ "content.edit": captureRequired(
82
+ "R3",
83
+ "edit form is absent without an owned editable item and remains unobserved",
84
+ ),
85
+ } as const satisfies Readonly<Record<HackerNewsWebOperationName, HackerNewsWebOperationContract>>);
86
+
87
+ const HN_ORIGIN = "https://news.ycombinator.com";
88
+ const MAX_HTML_BYTES = 4 * 1024 * 1024;
89
+
90
+ function boundedHtml(value: unknown, label: string): string {
91
+ if (
92
+ typeof value !== "string"
93
+ || value.length < 1
94
+ || value.length > MAX_HTML_BYTES
95
+ || value.includes("\0")
96
+ ) throw new Error(`${label} exceeded its reviewed HTML bound`);
97
+ return value;
98
+ }
99
+
100
+ function boundedString(
101
+ value: unknown,
102
+ label: string,
103
+ maximum: number,
104
+ allowEmpty = false,
105
+ ): string {
106
+ if (
107
+ typeof value !== "string"
108
+ || (!allowEmpty && value.length < 1)
109
+ || value.length > maximum
110
+ || /[\0\r]/u.test(value)
111
+ ) throw new Error(`${label} must be bounded text`);
112
+ return value;
113
+ }
114
+
115
+ function itemId(value: unknown, label: string): string {
116
+ const result = boundedString(value, label, 20);
117
+ if (!/^[1-9][0-9]{0,19}$/u.test(result)) throw new Error(`${label} must be a decimal Hacker News item ID`);
118
+ return result;
119
+ }
120
+
121
+ function exactUrl(value: string | URL, label: string): URL {
122
+ let url: URL;
123
+ try {
124
+ url = value instanceof URL ? new URL(value.href) : new URL(value);
125
+ } catch {
126
+ throw new Error(`${label} must be an absolute URL`);
127
+ }
128
+ if (
129
+ url.origin !== HN_ORIGIN
130
+ || url.username !== ""
131
+ || url.password !== ""
132
+ || url.hash !== ""
133
+ ) throw new Error(`${label} must use the exact ${HN_ORIGIN} origin`);
134
+ return url;
135
+ }
136
+
137
+ function exactParameters(value: URLSearchParams, label: string): ReadonlyMap<string, string> {
138
+ const result = new Map<string, string>();
139
+ for (const [name, item] of value) {
140
+ if (result.has(name)) throw new Error(`${label} repeated ${name}`);
141
+ if (name.length < 1 || name.length > 64 || /[\0\r\n]/u.test(name + item)) {
142
+ throw new Error(`${label} contained an invalid parameter`);
143
+ }
144
+ result.set(name, item);
145
+ }
146
+ return result;
147
+ }
148
+
149
+ function exactNames(
150
+ values: ReadonlyMap<string, string>,
151
+ required: readonly string[],
152
+ label: string,
153
+ ): void {
154
+ const requiredSet = new Set(required);
155
+ const missing = required.filter((name) => !values.has(name));
156
+ const extra = [...values.keys()].filter((name) => !requiredSet.has(name));
157
+ if (missing.length > 0) throw new Error(`${label} omitted ${missing.join(", ")}`);
158
+ if (extra.length > 0) throw new Error(`${label} contained unsupported ${extra.join(", ")}`);
159
+ }
160
+
161
+ export type HackerNewsReadOperation = "viewer.current" | "feeds.read" | "posts.read" | "comments.read";
162
+
163
+ export function authorizeHackerNewsReadRequest(input: {
164
+ readonly operation: HackerNewsReadOperation;
165
+ readonly url: string | URL;
166
+ readonly method: string;
167
+ readonly body?: unknown;
168
+ readonly targetId?: string;
169
+ }): Readonly<{
170
+ operation: HackerNewsReadOperation;
171
+ method: "GET";
172
+ path: string;
173
+ queryNames: readonly string[];
174
+ }> {
175
+ if (input.method.toUpperCase() !== "GET" || input.body !== undefined) {
176
+ throw new Error("Hacker News authenticated reads require body-free GET");
177
+ }
178
+ const url = exactUrl(input.url, "Hacker News read URL");
179
+ const query = exactParameters(url.searchParams, "Hacker News read query");
180
+ if (input.operation === "viewer.current" || input.operation === "feeds.read") {
181
+ if (url.pathname !== "/news" || query.size !== 0) {
182
+ throw new Error("Hacker News news request changed its reviewed exchange");
183
+ }
184
+ } else {
185
+ if (url.pathname !== "/item") throw new Error("Hacker News item request path is not reviewed");
186
+ exactNames(query, ["id"], "Hacker News item query");
187
+ const expected = itemId(input.targetId, "Hacker News requested item");
188
+ if (query.get("id") !== expected) {
189
+ throw new Error("Hacker News item query did not bind the requested item");
190
+ }
191
+ }
192
+ return Object.freeze({
193
+ operation: input.operation,
194
+ method: "GET",
195
+ path: url.pathname,
196
+ queryNames: Object.freeze([...query.keys()].sort()),
197
+ });
198
+ }
199
+
200
+ function attribute(attributes: string, name: string): string | null {
201
+ const expression = new RegExp(
202
+ `(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`,
203
+ "iu",
204
+ );
205
+ const match = expression.exec(attributes);
206
+ return match?.[1] ?? match?.[2] ?? null;
207
+ }
208
+
209
+ function decodeHtml(value: string): string {
210
+ return value.replace(
211
+ /&(?:#([0-9]{1,7})|#x([0-9a-f]{1,6})|([a-z]+));/giu,
212
+ (entity, decimal: string | undefined, hexadecimal: string | undefined, named: string | undefined) => {
213
+ if (decimal !== undefined || hexadecimal !== undefined) {
214
+ const codePoint = Number.parseInt(decimal ?? hexadecimal!, decimal === undefined ? 16 : 10);
215
+ if (!Number.isSafeInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) return "\uFFFD";
216
+ try {
217
+ return String.fromCodePoint(codePoint);
218
+ } catch {
219
+ return "\uFFFD";
220
+ }
221
+ }
222
+ switch (named?.toLowerCase()) {
223
+ case undefined: return entity;
224
+ case "amp": return "&";
225
+ case "apos": return "'";
226
+ case "gt": return ">";
227
+ case "lt": return "<";
228
+ case "nbsp": return " ";
229
+ case "quot": return "\"";
230
+ default: return entity;
231
+ }
232
+ },
233
+ );
234
+ }
235
+
236
+ function plainText(value: string, label: string, maximum: number): string {
237
+ const withBreaks = value
238
+ .replace(/<(?:br)\b[^>]*>/giu, "\n")
239
+ .replace(/<(?:p)\b[^>]*>/giu, "\n\n")
240
+ .replace(/<[^>]*>/gu, "");
241
+ const text = decodeHtml(withBreaks)
242
+ .replace(/\r/gu, "")
243
+ .replace(/[ \t]+\n/gu, "\n")
244
+ .replace(/\n[ \t]+/gu, "\n")
245
+ .replace(/[ \t]{2,}/gu, " ")
246
+ .replace(/\n{3,}/gu, "\n\n")
247
+ .trim();
248
+ return boundedString(text, label, maximum, true);
249
+ }
250
+
251
+ type AthingSegment = {
252
+ readonly id: string;
253
+ readonly classes: readonly string[];
254
+ readonly html: string;
255
+ };
256
+
257
+ function athingSegments(value: string): readonly AthingSegment[] {
258
+ const html = boundedHtml(value, "Hacker News page");
259
+ const starts: {
260
+ readonly index: number;
261
+ readonly id: string;
262
+ readonly classes: readonly string[];
263
+ }[] = [];
264
+ for (const match of html.matchAll(/<tr\b([^>]*)>/giu)) {
265
+ const attributes = match[1] ?? "";
266
+ const classValue = attribute(attributes, "class");
267
+ if (classValue === null) continue;
268
+ const classes = classValue.trim().split(/\s+/u).filter(Boolean);
269
+ if (!classes.includes("athing")) continue;
270
+ const id = itemId(attribute(attributes, "id"), "Hacker News athing ID");
271
+ starts.push({
272
+ index: match.index,
273
+ id,
274
+ classes: Object.freeze(classes),
275
+ });
276
+ }
277
+ if (starts.length > 2_000) throw new Error("Hacker News page exceeded its reviewed athing bound");
278
+ return Object.freeze(starts.map((start, index) => Object.freeze({
279
+ id: start.id,
280
+ classes: start.classes,
281
+ html: html.slice(start.index, starts[index + 1]?.index ?? html.length),
282
+ })));
283
+ }
284
+
285
+ function anchors(html: string): readonly {
286
+ readonly attributes: string;
287
+ readonly href: string;
288
+ readonly text: string;
289
+ }[] {
290
+ const result: {
291
+ readonly attributes: string;
292
+ readonly href: string;
293
+ readonly text: string;
294
+ }[] = [];
295
+ for (const match of html.matchAll(/<a\b([^>]*)>([\s\S]*?)<\/a>/giu)) {
296
+ const attributes = match[1] ?? "";
297
+ const rawHref = attribute(attributes, "href");
298
+ if (rawHref === null) continue;
299
+ result.push(Object.freeze({
300
+ attributes,
301
+ href: decodeHtml(rawHref),
302
+ text: plainText(match[2] ?? "", "Hacker News anchor text", 10_000),
303
+ }));
304
+ }
305
+ return Object.freeze(result);
306
+ }
307
+
308
+ function classFragment(html: string, className: string): string | null {
309
+ const escaped = className.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
310
+ const expression = new RegExp(
311
+ `<(?:span|div)\\b[^>]*class\\s*=\\s*(?:"[^"]*\\b${escaped}\\b[^"]*"|'[^']*\\b${escaped}\\b[^']*')[^>]*>([\\s\\S]*?)</(?:span|div)>`,
312
+ "iu",
313
+ );
314
+ return expression.exec(html)?.[1] ?? null;
315
+ }
316
+
317
+ function classAnchor(html: string, className: string): {
318
+ readonly href: string;
319
+ readonly text: string;
320
+ } | null {
321
+ const escaped = className.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
322
+ const expression = new RegExp(
323
+ `<a\\b([^>]*class\\s*=\\s*(?:"[^"]*\\b${escaped}\\b[^"]*"|'[^']*\\b${escaped}\\b[^']*')[^>]*)>([\\s\\S]*?)</a>`,
324
+ "iu",
325
+ );
326
+ const match = expression.exec(html);
327
+ if (match === null) return null;
328
+ const href = attribute(match[1] ?? "", "href");
329
+ if (href === null) return null;
330
+ return Object.freeze({
331
+ href: decodeHtml(href),
332
+ text: plainText(match[2] ?? "", `Hacker News ${className} text`, 10_000),
333
+ });
334
+ }
335
+
336
+ function titleAnchor(segment: AthingSegment): {
337
+ readonly href: string;
338
+ readonly text: string;
339
+ } {
340
+ const fragment = classFragment(segment.html, "titleline");
341
+ if (fragment === null) throw new Error("Hacker News submission omitted its titleline");
342
+ const first = anchors(fragment)[0];
343
+ if (first === undefined) throw new Error("Hacker News submission omitted its title link");
344
+ return first;
345
+ }
346
+
347
+ function projectedHref(value: string, label: string): string {
348
+ const href = boundedString(value, label, 4096);
349
+ let url: URL;
350
+ try {
351
+ url = new URL(href, HN_ORIGIN);
352
+ } catch {
353
+ throw new Error(`${label} was not a valid URL`);
354
+ }
355
+ if (
356
+ (url.protocol !== "https:" && url.protocol !== "http:")
357
+ || url.username !== ""
358
+ || url.password !== ""
359
+ ) throw new Error(`${label} must be an HTTP URL without credentials`);
360
+ return url.href;
361
+ }
362
+
363
+ function numericText(
364
+ html: string,
365
+ className: string,
366
+ suffix: string,
367
+ ): number | null {
368
+ const fragment = classFragment(html, className);
369
+ if (fragment === null) return null;
370
+ const text = plainText(fragment, `Hacker News ${className}`, 128);
371
+ const match = new RegExp(`^([0-9]+)(?:\\s+${suffix})?$`, "iu").exec(text);
372
+ if (match === null) throw new Error(`Hacker News ${className} did not contain a reviewed number`);
373
+ const number = Number(match[1]);
374
+ if (!Number.isSafeInteger(number)) throw new Error(`Hacker News ${className} exceeded its numeric bound`);
375
+ return number;
376
+ }
377
+
378
+ function createdAt(html: string): string | null {
379
+ const expression = /<(?:span)\b([^>]*class\s*=\s*(?:"[^"]*\bage\b[^"]*"|'[^']*\bage\b[^']*')[^>]*)>/iu;
380
+ const match = expression.exec(html);
381
+ if (match === null) return null;
382
+ const title = attribute(match[1] ?? "", "title");
383
+ if (title === null) return null;
384
+ const iso = title.split(/\s+/u, 1)[0] ?? "";
385
+ if (!/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}$/u.test(iso)) {
386
+ throw new Error("Hacker News age title did not contain a reviewed timestamp");
387
+ }
388
+ return `${iso}Z`;
389
+ }
390
+
391
+ function commentCount(segment: AthingSegment): number {
392
+ for (const anchor of anchors(segment.html)) {
393
+ let url: URL;
394
+ try {
395
+ url = new URL(anchor.href, HN_ORIGIN);
396
+ } catch {
397
+ continue;
398
+ }
399
+ if (url.origin !== HN_ORIGIN || url.pathname !== "/item" || url.searchParams.get("id") !== segment.id) continue;
400
+ if (anchor.text === "discuss") return 0;
401
+ const match = /^([0-9]+)\s+comments?$/u.exec(anchor.text.replace(/\u00a0/gu, " "));
402
+ if (match !== null) return Number(match[1]);
403
+ }
404
+ return 0;
405
+ }
406
+
407
+ function submissionBody(segment: AthingSegment): string {
408
+ const fragment = classFragment(segment.html, "toptext");
409
+ return fragment === null ? "" : plainText(fragment, "Hacker News submission body", 100_000);
410
+ }
411
+
412
+ export type HackerNewsProjectedPost = {
413
+ readonly id: string;
414
+ readonly title: string;
415
+ readonly url: string;
416
+ readonly author: string | null;
417
+ readonly body: string;
418
+ readonly createdAt: string | null;
419
+ readonly score: number | null;
420
+ readonly commentCount: number;
421
+ };
422
+
423
+ function projectedPost(segment: AthingSegment): HackerNewsProjectedPost {
424
+ if (segment.classes.includes("comtr")) throw new Error("Hacker News post projection received a comment row");
425
+ const title = titleAnchor(segment);
426
+ const author = classAnchor(segment.html, "hnuser");
427
+ return Object.freeze({
428
+ id: segment.id,
429
+ title: boundedString(title.text, "Hacker News post title", 1_000, true),
430
+ url: projectedHref(title.href, "Hacker News post URL"),
431
+ author: author === null ? null : boundedString(author.text, "Hacker News post author", 64),
432
+ body: submissionBody(segment),
433
+ createdAt: createdAt(segment.html),
434
+ score: numericText(segment.html, "score", "points?"),
435
+ commentCount: commentCount(segment),
436
+ });
437
+ }
438
+
439
+ export function parseHackerNewsViewerHtml(value: string): string {
440
+ const html = boundedHtml(value, "Hacker News account page");
441
+ const matches: { readonly href: string; readonly text: string }[] = [];
442
+ for (const anchor of anchors(html)) {
443
+ if (attribute(anchor.attributes, "id") !== "me") continue;
444
+ matches.push(anchor);
445
+ }
446
+ if (matches.length !== 1) throw new Error("Hacker News page must contain exactly one current-account link");
447
+ const current = matches[0]!;
448
+ const url = exactUrl(new URL(current.href, HN_ORIGIN), "Hacker News current-account URL");
449
+ if (url.pathname !== "/user") throw new Error("Hacker News current-account link changed its path");
450
+ const query = exactParameters(url.searchParams, "Hacker News current-account query");
451
+ exactNames(query, ["id"], "Hacker News current-account query");
452
+ const username = boundedString(current.text, "Hacker News current username", 64);
453
+ if (query.get("id") !== username) throw new Error("Hacker News current-account link did not bind its username");
454
+ if (!/^[A-Za-z0-9_-]{1,64}$/u.test(username)) throw new Error("Hacker News current username is invalid");
455
+ return username;
456
+ }
457
+
458
+ export function normalizeHackerNewsFeedHtml(
459
+ value: string,
460
+ limit: number,
461
+ ): Readonly<{
462
+ posts: readonly HackerNewsProjectedPost[];
463
+ hasMore: boolean;
464
+ }> {
465
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 30) {
466
+ throw new Error("Hacker News feed limit must be between 1 and 30");
467
+ }
468
+ const html = boundedHtml(value, "Hacker News feed");
469
+ const submissions = athingSegments(html).filter((segment) => !segment.classes.includes("comtr"));
470
+ if (submissions.length < 1 || submissions.length > 30) {
471
+ throw new Error("Hacker News feed contained an unreviewed submission count");
472
+ }
473
+ return Object.freeze({
474
+ posts: Object.freeze(submissions.slice(0, limit).map(projectedPost)),
475
+ hasMore: /<a\b[^>]*class\s*=\s*(?:"[^"]*\bmorelink\b[^"]*"|'[^']*\bmorelink\b[^']*')/iu.test(html),
476
+ });
477
+ }
478
+
479
+ function exactSubmission(value: string, expectedId: string): AthingSegment {
480
+ const target = itemId(expectedId, "Hacker News requested post");
481
+ const submissions = athingSegments(value).filter((segment) => !segment.classes.includes("comtr"));
482
+ if (submissions.length !== 1 || submissions[0]?.id !== target) {
483
+ throw new Error("Hacker News item page did not bind the requested post");
484
+ }
485
+ return submissions[0];
486
+ }
487
+
488
+ export function normalizeHackerNewsPostHtml(
489
+ value: string,
490
+ expectedId: string,
491
+ ): Readonly<{ post: HackerNewsProjectedPost }> {
492
+ return Object.freeze({ post: projectedPost(exactSubmission(value, expectedId)) });
493
+ }
494
+
495
+ export type HackerNewsProjectedComment = {
496
+ readonly id: string;
497
+ readonly postId: string;
498
+ readonly parentId: string;
499
+ readonly author: string | null;
500
+ readonly body: string;
501
+ readonly createdAt: string | null;
502
+ readonly depth: number;
503
+ };
504
+
505
+ function commentDepth(segment: AthingSegment): number {
506
+ const match = /<td\b([^>]*class\s*=\s*(?:"[^"]*\bind\b[^"]*"|'[^']*\bind\b[^']*')[^>]*)>/iu.exec(segment.html);
507
+ if (match === null) throw new Error("Hacker News comment omitted its indentation");
508
+ const raw = attribute(match[1] ?? "", "indent");
509
+ if (raw === null || !/^(?:0|[1-9][0-9]?)$/u.test(raw)) {
510
+ throw new Error("Hacker News comment indentation is invalid");
511
+ }
512
+ const depth = Number(raw);
513
+ if (depth > 40) throw new Error("Hacker News comment indentation exceeded its reviewed bound");
514
+ return depth;
515
+ }
516
+
517
+ function commentBody(segment: AthingSegment): string {
518
+ const fragment = classFragment(segment.html, "commtext");
519
+ if (fragment === null) return "";
520
+ return plainText(fragment, "Hacker News comment body", 100_000);
521
+ }
522
+
523
+ export function normalizeHackerNewsCommentsHtml(
524
+ value: string,
525
+ expectedPostId: string,
526
+ limit: number,
527
+ ): Readonly<{
528
+ post: HackerNewsProjectedPost;
529
+ comments: readonly HackerNewsProjectedComment[];
530
+ truncated: boolean;
531
+ }> {
532
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) {
533
+ throw new Error("Hacker News comment limit must be between 1 and 100");
534
+ }
535
+ const html = boundedHtml(value, "Hacker News item page");
536
+ const post = projectedPost(exactSubmission(html, expectedPostId));
537
+ const segments = athingSegments(html).filter((segment) => segment.classes.includes("comtr"));
538
+ if (segments.length > 1_000) throw new Error("Hacker News comment page exceeded its reviewed row bound");
539
+ const ancestry: string[] = [];
540
+ const comments: HackerNewsProjectedComment[] = [];
541
+ for (const segment of segments) {
542
+ const depth = commentDepth(segment);
543
+ const parentId = depth === 0 ? post.id : ancestry[depth - 1];
544
+ if (parentId === undefined) {
545
+ throw new Error("Hacker News comment indentation skipped its parent depth");
546
+ }
547
+ const explicitParent = /<a\b[^>]*href\s*=\s*(?:"#([0-9]+)"|'#([0-9]+)')[^>]*>\s*parent\s*<\/a>/iu.exec(segment.html);
548
+ const explicitParentId = explicitParent?.[1] ?? explicitParent?.[2] ?? null;
549
+ if (explicitParentId !== null && explicitParentId !== parentId) {
550
+ throw new Error("Hacker News comment parent link disagreed with indentation order");
551
+ }
552
+ ancestry[depth] = segment.id;
553
+ ancestry.length = depth + 1;
554
+ if (comments.length >= limit) continue;
555
+ const author = classAnchor(segment.html, "hnuser");
556
+ comments.push(Object.freeze({
557
+ id: segment.id,
558
+ postId: post.id,
559
+ parentId,
560
+ author: author === null ? null : boundedString(author.text, "Hacker News comment author", 64),
561
+ body: commentBody(segment),
562
+ createdAt: createdAt(segment.html),
563
+ depth,
564
+ }));
565
+ }
566
+ return Object.freeze({
567
+ post,
568
+ comments: Object.freeze(comments),
569
+ truncated: segments.length > limit,
570
+ });
571
+ }
572
+
573
+ function targetSegment(value: string, targetId: string): AthingSegment {
574
+ const target = itemId(targetId, "Hacker News action target");
575
+ const matches = athingSegments(value).filter((segment) => segment.id === target);
576
+ if (matches.length !== 1) throw new Error("Hacker News page did not contain one exact action target");
577
+ return matches[0]!;
578
+ }
579
+
580
+ function safeGoto(value: unknown, label: string): string {
581
+ const goto = boundedString(value, label, 2048);
582
+ let url: URL;
583
+ try {
584
+ url = new URL(goto, HN_ORIGIN);
585
+ } catch {
586
+ throw new Error(`${label} is invalid`);
587
+ }
588
+ if (
589
+ url.origin !== HN_ORIGIN
590
+ || url.username !== ""
591
+ || url.password !== ""
592
+ || url.hash !== ""
593
+ ) throw new Error(`${label} escaped the Hacker News origin`);
594
+ return `${url.pathname.slice(1)}${url.search}`;
595
+ }
596
+
597
+ export type HackerNewsFavoriteAction = {
598
+ readonly path: "/fave" | "/unfave";
599
+ readonly targetId: string;
600
+ readonly auth: string;
601
+ readonly goto: string;
602
+ readonly nextSavedState: boolean;
603
+ };
604
+
605
+ const unconsumedFavoriteActions = new WeakSet<HackerNewsFavoriteAction>();
606
+
607
+ /**
608
+ * Parse the one request-bound favorite action visible for an exact item.
609
+ * Synthetic parser tests do not promote content.save; promotion still needs
610
+ * inert evidence for both real provider states plus independent readback.
611
+ */
612
+ export function parseHackerNewsFavoriteAction(
613
+ value: string,
614
+ expectedTargetId: string,
615
+ ): HackerNewsFavoriteAction {
616
+ const target = itemId(expectedTargetId, "Hacker News favorite target");
617
+ const candidates: HackerNewsFavoriteAction[] = [];
618
+ for (const anchor of anchors(targetSegment(value, target).html)) {
619
+ let url: URL;
620
+ try {
621
+ url = new URL(anchor.href, HN_ORIGIN);
622
+ } catch {
623
+ continue;
624
+ }
625
+ if (url.origin !== HN_ORIGIN || (url.pathname !== "/fave" && url.pathname !== "/unfave")) continue;
626
+ const query = exactParameters(url.searchParams, "Hacker News favorite query");
627
+ exactNames(query, ["auth", "goto", "id"], "Hacker News favorite query");
628
+ if (query.get("id") !== target) throw new Error("Hacker News favorite action did not bind its target");
629
+ candidates.push(Object.freeze({
630
+ path: url.pathname,
631
+ targetId: target,
632
+ auth: boundedString(query.get("auth"), "Hacker News request-bound favorite auth", 256),
633
+ goto: safeGoto(query.get("goto"), "Hacker News favorite goto"),
634
+ nextSavedState: url.pathname === "/fave",
635
+ }));
636
+ }
637
+ if (candidates.length !== 1) {
638
+ throw new Error("Hacker News page must contain exactly one request-bound favorite action");
639
+ }
640
+ const action = candidates[0]!;
641
+ unconsumedFavoriteActions.add(action);
642
+ return action;
643
+ }
644
+
645
+ function safeRedirectLocation(value: string | null): string {
646
+ if (value === null) throw new Error("Hacker News action response omitted its redirect location");
647
+ let url: URL;
648
+ try {
649
+ url = exactUrl(new URL(value, HN_ORIGIN), "Hacker News action redirect");
650
+ } catch {
651
+ throw new Error("Hacker News action response attempted an unreviewed redirect");
652
+ }
653
+ for (const name of ["auth", "hmac", "fnid", "fnop"]) {
654
+ if (url.searchParams.has(name)) {
655
+ throw new Error("Hacker News action redirect attempted to expose request-bound proof");
656
+ }
657
+ }
658
+ if (url.pathname !== "/news" && url.pathname !== "/item" && url.pathname !== "/favorites") {
659
+ throw new Error("Hacker News action redirect path is not reviewed");
660
+ }
661
+ if (url.pathname === "/item") {
662
+ const query = exactParameters(url.searchParams, "Hacker News item redirect query");
663
+ if (![...query.keys()].every((name) => name === "id")) {
664
+ throw new Error("Hacker News item redirect query is not reviewed");
665
+ }
666
+ if (query.has("id")) itemId(query.get("id"), "Hacker News redirect item ID");
667
+ } else if (url.search !== "") {
668
+ throw new Error("Hacker News action redirect contained an unreviewed query");
669
+ }
670
+ return `${url.pathname}${url.search}`;
671
+ }
672
+
673
+ /**
674
+ * Execute only an action parsed from the immediately preceding provider page.
675
+ * The request-bound `auth` value is sent once and is absent from the result.
676
+ */
677
+ export async function dispatchHackerNewsFavoriteAction(
678
+ client: WebSessionClient,
679
+ action: HackerNewsFavoriteAction,
680
+ desiredSavedState: boolean,
681
+ beforeRequest: () => Promise<void>,
682
+ options: {
683
+ readonly timeoutMs?: number;
684
+ readonly fetch?: WebSessionFetch;
685
+ } = {},
686
+ ): Promise<Readonly<{ status: 302; location: string }>> {
687
+ if (!unconsumedFavoriteActions.has(action)) {
688
+ throw new Error("Hacker News favorite action must come from an immediate parsed provider page");
689
+ }
690
+ if (action.nextSavedState !== desiredSavedState) {
691
+ throw new Error("Hacker News request-bound favorite action does not match the desired state");
692
+ }
693
+ const target = itemId(action.targetId, "Hacker News favorite target");
694
+ const auth = boundedString(action.auth, "Hacker News request-bound favorite auth", 256);
695
+ const goto = safeGoto(action.goto, "Hacker News favorite goto");
696
+ const url = new URL(action.path, HN_ORIGIN);
697
+ url.searchParams.set("id", target);
698
+ url.searchParams.set("auth", auth);
699
+ url.searchParams.set("goto", goto);
700
+ unconsumedFavoriteActions.delete(action);
701
+ await beforeRequest();
702
+ const timeoutMs = options.timeoutMs ?? 60_000;
703
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1_000 || timeoutMs > 10 * 60_000) {
704
+ throw new Error("Hacker News manual redirect timeout is invalid");
705
+ }
706
+ const fetch = options.fetch ?? ((input: string | URL | Request, init: RequestInit = {}) => {
707
+ const inputUrl = input instanceof Request ? new URL(input.url) : new URL(input);
708
+ // pinnedHttpsFetch is a raw Node HTTPS transport: `redirect: "error"`
709
+ // means no automatic follow, while still returning the 302 for the
710
+ // provider-specific same-origin Location validation below.
711
+ return pinnedHttpsFetch(inputUrl, { ...init, redirect: "error" }, timeoutMs);
712
+ });
713
+ const headers = new Headers({
714
+ accept: "text/html",
715
+ cookie: renderCookieHeader(client.cookies),
716
+ referer: new URL(goto, `${HN_ORIGIN}/`).href,
717
+ });
718
+ const controller = new AbortController();
719
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
720
+ let response: Response | undefined;
721
+ try {
722
+ try {
723
+ response = await fetch(url, {
724
+ method: "GET",
725
+ headers,
726
+ redirect: "manual",
727
+ signal: controller.signal,
728
+ });
729
+ } catch (error) {
730
+ throw new Error("Hacker News action failed before a reviewed redirect was received", { cause: error });
731
+ }
732
+ if (response.status !== 302) {
733
+ response.body?.cancel().catch(() => undefined);
734
+ throw new Error(`Hacker News action returned unreviewed status ${response.status}`);
735
+ }
736
+ const location = safeRedirectLocation(response.headers.get("location"));
737
+ await response.body?.cancel().catch(() => undefined);
738
+ return Object.freeze({ status: 302, location });
739
+ } finally {
740
+ clearTimeout(timeout);
741
+ if (controller.signal.aborted) await response?.body?.cancel().catch(() => undefined);
742
+ }
743
+ }
744
+
745
+ function hiddenInputs(formHtml: string, label: string): ReadonlyMap<string, string> {
746
+ const values = new Map<string, string>();
747
+ for (const match of formHtml.matchAll(/<input\b([^>]*)>/giu)) {
748
+ const attributes = match[1] ?? "";
749
+ if ((attribute(attributes, "type") ?? "").toLowerCase() !== "hidden") continue;
750
+ const name = attribute(attributes, "name");
751
+ const value = attribute(attributes, "value");
752
+ if (name === null || value === null) throw new Error(`${label} hidden input omitted name or value`);
753
+ if (values.has(name)) throw new Error(`${label} repeated hidden input ${name}`);
754
+ values.set(name, decodeHtml(value));
755
+ }
756
+ return values;
757
+ }
758
+
759
+ function exactForm(value: string, actionPath: "/comment" | "/r", label: string): string {
760
+ const html = boundedHtml(value, label);
761
+ const candidates: string[] = [];
762
+ for (const match of html.matchAll(/<form\b([^>]*)>([\s\S]*?)<\/form>/giu)) {
763
+ const attributes = match[1] ?? "";
764
+ const action = attribute(attributes, "action");
765
+ const method = (attribute(attributes, "method") ?? "get").toLowerCase();
766
+ if (action === null) continue;
767
+ const url = new URL(decodeHtml(action), HN_ORIGIN);
768
+ if (url.origin === HN_ORIGIN && url.pathname === actionPath && method === "post") {
769
+ candidates.push(match[0]);
770
+ }
771
+ }
772
+ if (candidates.length !== 1) throw new Error(`${label} must contain exactly one ${actionPath} form`);
773
+ return candidates[0]!;
774
+ }
775
+
776
+ export type HackerNewsCommentFormProof = {
777
+ readonly parentId: string;
778
+ readonly goto: string;
779
+ readonly hmac: string;
780
+ };
781
+
782
+ export function parseHackerNewsCommentForm(
783
+ value: string,
784
+ expectedParentId: string,
785
+ ): HackerNewsCommentFormProof {
786
+ const parent = itemId(expectedParentId, "Hacker News comment parent");
787
+ const hidden = hiddenInputs(exactForm(value, "/comment", "Hacker News comment page"), "Hacker News comment form");
788
+ exactNames(hidden, ["goto", "hmac", "parent"], "Hacker News comment form");
789
+ if (hidden.get("parent") !== parent) throw new Error("Hacker News comment form did not bind its parent");
790
+ return Object.freeze({
791
+ parentId: parent,
792
+ goto: safeGoto(hidden.get("goto"), "Hacker News comment goto"),
793
+ hmac: boundedString(hidden.get("hmac"), "Hacker News request-bound comment hmac", 512),
794
+ });
795
+ }
796
+
797
+ export type HackerNewsSubmissionFormProof = {
798
+ readonly fnid: string;
799
+ readonly fnop: string;
800
+ };
801
+
802
+ export function parseHackerNewsSubmissionForm(value: string): HackerNewsSubmissionFormProof {
803
+ const hidden = hiddenInputs(exactForm(value, "/r", "Hacker News submit page"), "Hacker News submit form");
804
+ exactNames(hidden, ["fnid", "fnop"], "Hacker News submit form");
805
+ return Object.freeze({
806
+ fnid: boundedString(hidden.get("fnid"), "Hacker News request-bound fnid", 512),
807
+ fnop: boundedString(hidden.get("fnop"), "Hacker News request-bound fnop", 128),
808
+ });
809
+ }