@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.
- package/CHANGELOG.md +458 -0
- package/LICENSE +21 -0
- package/README.md +1359 -0
- package/bunfig.toml +2 -0
- package/dist/apple-photos-client.js +704 -0
- package/dist/beeper-client.js +4602 -0
- package/dist/client.js +1605 -0
- package/dist/index-26yq8q16.js +31 -0
- package/dist/index-9t13b4k7.js +508 -0
- package/dist/index-axwm9b3v.js +5 -0
- package/dist/index-gwk7rbyj.js +30 -0
- package/dist/index.js +701 -0
- package/dist/messaging.js +1130 -0
- package/dist/omni-client.js +1406 -0
- package/dist/whatsapp-client.js +355 -0
- package/docs/imessage-direct-provider.md +106 -0
- package/docs/rental-listings.md +94 -0
- package/package.json +600 -0
- package/skills/ghostget/SKILL.md +251 -0
- package/skills/ghostget/agents/openai.yaml +4 -0
- package/skills/ghostget/references/adapter-contract.md +181 -0
- package/skills/ghostget/references/apple-photos.md +115 -0
- package/skills/ghostget/references/article-drafts.md +235 -0
- package/skills/ghostget/references/code-owned-provider-scaffold.md +134 -0
- package/skills/ghostget/references/cross-posting-video.md +139 -0
- package/skills/ghostget/references/cross-posting.md +82 -0
- package/skills/ghostget/references/derivation.md +390 -0
- package/skills/ghostget/references/hraness-social-profile-stats.json +269 -0
- package/skills/ghostget/references/install.md +66 -0
- package/skills/ghostget/references/linkedin-adapter.md +442 -0
- package/skills/ghostget/references/messaging.md +168 -0
- package/skills/ghostget/references/meta-comet-contract.md +153 -0
- package/skills/ghostget/references/platform-patterns.md +122 -0
- package/skills/ghostget/references/provider-plugins.md +496 -0
- package/skills/ghostget/references/reddit-flair.md +78 -0
- package/skills/ghostget/references/safety-and-state.md +214 -0
- package/skills/ghostget/references/settlement-and-duplicate-cleanup.md +79 -0
- package/skills/ghostget/references/social-platform-routing.md +66 -0
- package/skills/ghostget/references/social-profile-stats.md +195 -0
- package/skills/ghostget/references/social-video-platform-routing.md +89 -0
- package/skills/ghostget/references/whatsapp-adapter.md +101 -0
- package/skills/ghostget/references/x-adapter.md +332 -0
- package/skills/ghostget/references/x-ai-disclosure.md +71 -0
- package/src/apple-photos-cli.ts +639 -0
- package/src/apple-photos-client-types.ts +168 -0
- package/src/apple-photos-client.ts +201 -0
- package/src/apple-photos-contact-evidence.ts +838 -0
- package/src/apple-photos-local-source.ts +1131 -0
- package/src/args.ts +2053 -0
- package/src/article-draft-document.ts +417 -0
- package/src/article-draft-embeds.ts +123 -0
- package/src/article-draft-images.ts +214 -0
- package/src/assets/adapter-template/wrench-adapter.json +40 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.json +1500 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.v1.0.0.json +129 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.v1.1.0.json +207 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.v2.0.0.json +1463 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.v2.1.0.json +1463 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.v2.2.0.json +1475 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.v2.3.0.json +1488 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.json +684 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.0.0.json +581 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.1.0.json +581 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.2.0.json +581 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.3.0.json +615 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.4.0.json +684 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.5.0.json +684 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.6.0.json +684 -0
- package/src/assets/adapters/clasificados/wrench-web-adapter.json +54 -0
- package/src/assets/adapters/facebook/wrench-web-adapter.json +750 -0
- package/src/assets/adapters/facebook/wrench-web-adapter.v1.0.0.json +706 -0
- package/src/assets/adapters/facebook/wrench-web-adapter.v1.1.0.json +703 -0
- package/src/assets/adapters/facebook/wrench-web-adapter.v1.2.0.json +750 -0
- package/src/assets/adapters/facebook-group/wrench-web-adapter.json +682 -0
- package/src/assets/adapters/facebook-group/wrench-web-adapter.v1.0.0.json +583 -0
- package/src/assets/adapters/facebook-group/wrench-web-adapter.v1.1.0.json +682 -0
- package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.json +437 -0
- package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.v1.0.0.json +383 -0
- package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.v1.1.0.json +437 -0
- package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.v1.1.1.json +437 -0
- package/src/assets/adapters/facebook-page/wrench-web-adapter.json +880 -0
- package/src/assets/adapters/facebook-page/wrench-web-adapter.v1.0.0.json +740 -0
- package/src/assets/adapters/facebook-page/wrench-web-adapter.v1.1.0.json +880 -0
- package/src/assets/adapters/github/wrench-web-adapter.json +69 -0
- package/src/assets/adapters/github/wrench-web-adapter.v1.0.0.json +42 -0
- package/src/assets/adapters/github/wrench-web-adapter.v1.1.0.json +69 -0
- package/src/assets/adapters/gmail/wrench-adapter.json +161 -0
- package/src/assets/adapters/gmail/wrench-adapter.v1.2.0.json +157 -0
- package/src/assets/adapters/hacker-news/wrench-web-adapter.json +315 -0
- package/src/assets/adapters/hacker-news/wrench-web-adapter.v1.0.0.json +315 -0
- package/src/assets/adapters/imessage/wrench-web-adapter.json +236 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.json +719 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.0.0.json +627 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.1.0.json +609 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.2.0.json +640 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.3.0.json +671 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.4.0.json +710 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.5.0.json +710 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.6.0.json +719 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.7.0.json +719 -0
- package/src/assets/adapters/linkedin/wrench-adapter.json +442 -0
- package/src/assets/adapters/linkedin/wrench-adapter.v0.4.0.json +124 -0
- package/src/assets/adapters/linkedin/wrench-adapter.v1.0.0.json +411 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.json +914 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.0.0.json +604 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.1.0.json +604 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.10.0.json +809 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.11.0.json +809 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.12.0.json +820 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.13.0.json +819 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.14.0.json +819 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.15.0.json +819 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.16.0.json +874 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.17.0.json +874 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.18.0.json +874 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.19.0.json +874 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.2.0.json +731 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.3.0.json +768 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.4.0.json +808 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.5.0.json +808 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.6.0.json +808 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.7.0.json +825 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.8.0.json +792 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.9.0.json +809 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.json +855 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.0.0.json +577 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.1.0.json +604 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.10.0.json +712 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.11.0.json +712 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.12.0.json +712 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.2.0.json +653 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.3.0.json +712 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.4.0.json +713 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.5.0.json +712 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.6.0.json +713 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.7.0.json +713 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.8.0.json +713 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.9.0.json +713 -0
- package/src/assets/adapters/substack/wrench-web-adapter.json +936 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.0.0.json +817 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.1.0.json +812 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.2.0.json +812 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.3.0.json +902 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.4.0.json +936 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.5.0.json +936 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.6.0.json +936 -0
- package/src/assets/adapters/threads/wrench-web-adapter.json +692 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.0.0.json +629 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.1.0.json +623 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.2.0.json +622 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.3.0.json +622 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.4.0.json +622 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.5.0.json +699 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.6.0.json +693 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.7.0.json +692 -0
- package/src/assets/adapters/tiktok/wrench-web-adapter.json +762 -0
- package/src/assets/adapters/tiktok/wrench-web-adapter.v1.0.0.json +681 -0
- package/src/assets/adapters/tiktok/wrench-web-adapter.v1.1.0.json +708 -0
- package/src/assets/adapters/tiktok/wrench-web-adapter.v1.2.0.json +762 -0
- package/src/assets/adapters/tiktok/wrench-web-adapter.v1.3.0.json +762 -0
- package/src/assets/adapters/twitch/wrench-web-adapter.json +42 -0
- package/src/assets/adapters/twitch/wrench-web-adapter.v1.0.0.json +42 -0
- package/src/assets/adapters/whatsapp/wrench-web-adapter.json +400 -0
- package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.0.0.json +355 -0
- package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.1.0.json +386 -0
- package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.2.0.json +386 -0
- package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.3.0.json +400 -0
- package/src/assets/adapters/x/wrench-adapter.json +749 -0
- package/src/assets/adapters/x/wrench-adapter.v1.0.0.json +700 -0
- package/src/assets/adapters/x/wrench-adapter.v1.1.0.json +707 -0
- package/src/assets/adapters/x/wrench-web-adapter.json +712 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.1.0.json +579 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.10.0.json +685 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.11.0.json +712 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.12.0.json +712 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.13.0.json +712 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.2.0.json +578 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.3.0.json +610 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.4.0.json +617 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.5.0.json +634 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.6.0.json +632 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.7.0.json +649 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.8.0.json +649 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.9.0.json +683 -0
- package/src/assets/adapters/youtube/wrench-web-adapter.json +561 -0
- package/src/assets/adapters/youtube/wrench-web-adapter.v1.0.0.json +465 -0
- package/src/assets/adapters/youtube/wrench-web-adapter.v1.1.0.json +519 -0
- package/src/assets/adapters/youtube/wrench-web-adapter.v1.2.0.json +561 -0
- package/src/assets/adapters/youtube/wrench-web-adapter.v1.3.0.json +561 -0
- package/src/assets/code-owned-provider-template/plugin.test.ts.template +54 -0
- package/src/assets/code-owned-provider-template/plugin.ts.template +102 -0
- package/src/assets/code-owned-provider-template/runtime.internal.test.ts.template +44 -0
- package/src/assets/code-owned-provider-template/runtime.ts.template +59 -0
- package/src/assets/code-owned-provider-template/wrench-adapter.json.template +33 -0
- package/src/assets/fonts/nebula-sans/LICENSE.txt +96 -0
- package/src/assets/fonts/nebula-sans/NebulaSans-Book.woff2 +0 -0
- package/src/assets/fonts/nebula-sans/PROVENANCE.md +14 -0
- package/src/assets.d.ts +4 -0
- package/src/auth.ts +1157 -0
- package/src/beeper-client-types.ts +169 -0
- package/src/beeper-client.ts +250 -0
- package/src/beeper-contact-interactions-cli.ts +180 -0
- package/src/beeper-contact-interactions.ts +1612 -0
- package/src/beeper-message-bundle-v1.ts +257 -0
- package/src/beeper-message-like-me-cli.ts +84 -0
- package/src/beeper-message-like-me-export.ts +2274 -0
- package/src/beeper-message-like-me-recovery.ts +931 -0
- package/src/beeper-message-like-me-source.ts +4034 -0
- package/src/browser-admission.ts +928 -0
- package/src/browser-snapshots.ts +260 -0
- package/src/browser.ts +3873 -0
- package/src/canonical-json.ts +60 -0
- package/src/catalog-cli.ts +638 -0
- package/src/cli.ts +208 -0
- package/src/client-types.ts +301 -0
- package/src/client.ts +2726 -0
- package/src/confirmed-write-failure.ts +44 -0
- package/src/confirmed-write-model.ts +72 -0
- package/src/confirmed-write-platform.ts +1055 -0
- package/src/confirmed-write-program.ts +224 -0
- package/src/confirmed-write-runtime.ts +16 -0
- package/src/cursor-token.ts +228 -0
- package/src/derivation-dnr-readiness.ts +367 -0
- package/src/derivation-file-chooser.ts +384 -0
- package/src/derivation-network-boundary.ts +835 -0
- package/src/derivation-network-guard.ts +782 -0
- package/src/derivation-network-proxy-helper.ts +440 -0
- package/src/derivation-network-proxy.ts +385 -0
- package/src/derive-command-helper.ts +693 -0
- package/src/derive-fixtures.ts +343 -0
- package/src/derive-review.ts +980 -0
- package/src/derive.ts +4754 -0
- package/src/fixtures/beeper-message-like-me-v1/accounts.ndjson +2 -0
- package/src/fixtures/beeper-message-like-me-v1/conversations.ndjson +1 -0
- package/src/fixtures/beeper-message-like-me-v1/manifest.json +1 -0
- package/src/fixtures/beeper-message-like-me-v1/messages.ndjson +2 -0
- package/src/fixtures/beeper-message-like-me-v1/participants.ndjson +3 -0
- package/src/fixtures/beeper-message-like-me-v1/reactions.ndjson +1 -0
- package/src/fixtures/beeper-message-like-me-v1/tombstones.ndjson +1 -0
- package/src/ghostget.ts +3672 -0
- package/src/gmail-capture.ts +1244 -0
- package/src/har-internal.ts +1242 -0
- package/src/har.ts +743 -0
- package/src/index.ts +218 -0
- package/src/invocation-read-platform.ts +55 -0
- package/src/invocation-read-program.ts +93 -0
- package/src/invocation-read-runtime.ts +11 -0
- package/src/linked-device-lifecycle-admission.ts +450 -0
- package/src/linked-device-lifecycle-journal.ts +1560 -0
- package/src/linked-device-lifecycle-runtime.ts +1602 -0
- package/src/local-cli-admission.ts +135 -0
- package/src/local-cli-contracts.ts +271 -0
- package/src/local-cli-execution.ts +98 -0
- package/src/local-cli-surface-contract.ts +1510 -0
- package/src/local-cli-tool-identity.ts +336 -0
- package/src/media/archive.ts +2325 -0
- package/src/media/args.ts +303 -0
- package/src/media/cli.ts +287 -0
- package/src/media/doctor.ts +475 -0
- package/src/media/ffmpeg.ts +1330 -0
- package/src/media/http-capture.ts +711 -0
- package/src/media/http-probe.ts +593 -0
- package/src/media/http.ts +387 -0
- package/src/media/index.ts +10 -0
- package/src/media/local-transcription.ts +613 -0
- package/src/media/lock.ts +321 -0
- package/src/media/manifest.ts +1712 -0
- package/src/media/metadata.ts +906 -0
- package/src/media/process.ts +723 -0
- package/src/media/revision.ts +166 -0
- package/src/media/runtime-closure.ts +1063 -0
- package/src/media/source-router.ts +187 -0
- package/src/media/transcriber-config.ts +1122 -0
- package/src/media/transcript-persistence-model.ts +15 -0
- package/src/media/transcript-persistence-platform.ts +89 -0
- package/src/media/transcript-persistence-program.ts +39 -0
- package/src/media/transcript-persistence-runtime.ts +24 -0
- package/src/media/transcript.ts +616 -0
- package/src/media/utf8-order.ts +30 -0
- package/src/media/whisper-cpp.ts +464 -0
- package/src/media/whisper-language.ts +58 -0
- package/src/media/yt-dlp.ts +439 -0
- package/src/message-like-me-agentic-messaging.ts +882 -0
- package/src/messaging-action-store.ts +793 -0
- package/src/messaging-runtime.ts +2136 -0
- package/src/messaging-store.ts +539 -0
- package/src/messaging-types.ts +1663 -0
- package/src/messaging.ts +334 -0
- package/src/model.ts +2671 -0
- package/src/oauth-google.ts +888 -0
- package/src/omni-client-types.ts +306 -0
- package/src/omni-client.ts +2225 -0
- package/src/omni-limits.ts +34 -0
- package/src/omni-model.ts +1614 -0
- package/src/omni-request.ts +379 -0
- package/src/omni-runtime.ts +1632 -0
- package/src/operation-deadline.ts +189 -0
- package/src/path-helper.ts +1656 -0
- package/src/pinned-https.ts +194 -0
- package/src/plan-assets.ts +832 -0
- package/src/platform-catalog.ts +1832 -0
- package/src/plugins/beeper-linked-device/plugin.ts +310 -0
- package/src/plugins/bluesky-web/plugin.ts +178 -0
- package/src/plugins/clasificados-web/plugin.ts +66 -0
- package/src/plugins/github-web/plugin.ts +80 -0
- package/src/plugins/gmail-official/plugin.ts +110 -0
- package/src/plugins/hacker-news-web/plugin.ts +52 -0
- package/src/plugins/imessage-direct/plugin.ts +195 -0
- package/src/plugins/imessage-direct/vendor/0001-fix-keep-AppleScript-send-payloads-out-of-child-argv.patch +1430 -0
- package/src/plugins/imessage-direct/vendor/0002-feat-rpc-add-exact-chat-lookup.patch +225 -0
- package/src/plugins/imessage-direct/vendor/provenance.json +80 -0
- package/src/plugins/linkedin-official/plugin.ts +84 -0
- package/src/plugins/linkedin-web/plugin.ts +412 -0
- package/src/plugins/meta-web/plugin.ts +317 -0
- package/src/plugins/reddit-web/flair.ts +492 -0
- package/src/plugins/reddit-web/plugin.ts +477 -0
- package/src/plugins/substack-web/plugin.ts +124 -0
- package/src/plugins/tiktok-web/plugin.ts +67 -0
- package/src/plugins/twitch-web/plugin.ts +54 -0
- package/src/plugins/whatsapp-linked-device/plugin.ts +105 -0
- package/src/plugins/x-official/plugin.ts +64 -0
- package/src/plugins/x-web/plugin.ts +488 -0
- package/src/plugins/youtube-web/plugin.ts +101 -0
- package/src/portable-run-recovery.ts +466 -0
- package/src/process-identity.ts +501 -0
- package/src/profile-clone-helper.ts +104 -0
- package/src/provider-catalog-views.ts +51 -0
- package/src/provider-context.ts +69 -0
- package/src/provider-contract-definitions-x.ts +45 -0
- package/src/provider-contract-definitions.ts +948 -0
- package/src/provider-contract-input-gmail.ts +176 -0
- package/src/provider-contract-input-linkedin.ts +77 -0
- package/src/provider-contract-input-x.ts +304 -0
- package/src/provider-contract-planning.ts +23 -0
- package/src/provider-contract-semantic-identity.ts +54 -0
- package/src/provider-contracts.ts +251 -0
- package/src/provider-http.ts +723 -0
- package/src/provider-plugin-auth.ts +36 -0
- package/src/provider-plugin-builtins.ts +229 -0
- package/src/provider-plugin-cleanup-barrier.ts +229 -0
- package/src/provider-plugin-cleanup-execution.ts +79 -0
- package/src/provider-plugin-cleanup-resource.ts +470 -0
- package/src/provider-plugin-contract-identity.ts +707 -0
- package/src/provider-plugin-host.ts +1972 -0
- package/src/provider-plugin-identifiers.ts +60 -0
- package/src/provider-plugin-invocation-lease.ts +889 -0
- package/src/provider-plugin-lifecycle-kernel.ts +566 -0
- package/src/provider-plugin-lifecycle.ts +1455 -0
- package/src/provider-plugin-module-analysis.ts +336 -0
- package/src/provider-plugin-package.ts +2530 -0
- package/src/provider-plugin-portable-authority.ts +106 -0
- package/src/provider-plugin-portable-catalog.ts +94 -0
- package/src/provider-plugin-portable-identity.ts +315 -0
- package/src/provider-plugin-portable-runtime.ts +2057 -0
- package/src/provider-plugin-protocol.ts +1625 -0
- package/src/provider-plugin-registry.ts +3342 -0
- package/src/provider-plugin-store.ts +2155 -0
- package/src/provider-plugin-test-fixture.ts +5 -0
- package/src/provider-plugin.ts +4907 -0
- package/src/provider-plugins.generated.ts +43 -0
- package/src/provider-plugins.ts +8 -0
- package/src/provider-subject.ts +41 -0
- package/src/provider.ts +315 -0
- package/src/providers/beeper-local-runtime.ts +5426 -0
- package/src/providers/beeper-local.ts +2739 -0
- package/src/providers/beeper-messaging.ts +462 -0
- package/src/providers/beeper-omni.ts +1159 -0
- package/src/providers/bluesky-web-runtime.ts +2895 -0
- package/src/providers/bluesky-web.ts +1614 -0
- package/src/providers/clasificados-web-runtime.ts +255 -0
- package/src/providers/clasificados-web.ts +503 -0
- package/src/providers/contact-projection.ts +301 -0
- package/src/providers/github-read-model.ts +94 -0
- package/src/providers/github-read-platform.ts +165 -0
- package/src/providers/github-read-program.ts +97 -0
- package/src/providers/github-web-runtime.ts +320 -0
- package/src/providers/github-web.ts +403 -0
- package/src/providers/gmail-api.ts +2834 -0
- package/src/providers/gmail-omni.ts +830 -0
- package/src/providers/gmail.ts +1130 -0
- package/src/providers/hacker-news-web-runtime.ts +220 -0
- package/src/providers/hacker-news-web.ts +809 -0
- package/src/providers/imessage-direct-install.ts +354 -0
- package/src/providers/imessage-direct-messaging.ts +532 -0
- package/src/providers/imessage-direct-omni.ts +400 -0
- package/src/providers/imessage-direct-runtime.ts +1498 -0
- package/src/providers/imessage-direct.ts +351 -0
- package/src/providers/instagram-video-foundations.ts +913 -0
- package/src/providers/instagram-web-profile-browser.ts +600 -0
- package/src/providers/iso-bmff.ts +338 -0
- package/src/providers/linkedin-company-platform.ts +42 -0
- package/src/providers/linkedin-company-program.ts +88 -0
- package/src/providers/linkedin-contact-failure.ts +84 -0
- package/src/providers/linkedin-contact-platform.ts +45 -0
- package/src/providers/linkedin-contact-program.ts +91 -0
- package/src/providers/linkedin-profile-activity-failure.ts +76 -0
- package/src/providers/linkedin-profile-activity-platform.ts +41 -0
- package/src/providers/linkedin-profile-activity-program.ts +67 -0
- package/src/providers/linkedin-read-failure.ts +52 -0
- package/src/providers/linkedin-self-platform.ts +53 -0
- package/src/providers/linkedin-self-program.ts +122 -0
- package/src/providers/linkedin-web-article-browser.ts +794 -0
- package/src/providers/linkedin-web-bootstrap.ts +146 -0
- package/src/providers/linkedin-web-contact.ts +830 -0
- package/src/providers/linkedin-web-feed-browser.ts +832 -0
- package/src/providers/linkedin-web-feed.ts +864 -0
- package/src/providers/linkedin-web-post-browser.ts +653 -0
- package/src/providers/linkedin-web-profile-browser.ts +683 -0
- package/src/providers/linkedin-web-runtime.ts +2388 -0
- package/src/providers/linkedin-web.ts +3305 -0
- package/src/providers/linkedin.ts +1885 -0
- package/src/providers/meta-bootstrap.ts +1043 -0
- package/src/providers/meta-facebook-group.ts +690 -0
- package/src/providers/meta-marketplace-relay.ts +747 -0
- package/src/providers/meta-omni.ts +373 -0
- package/src/providers/meta-relay-bundle.ts +1049 -0
- package/src/providers/meta-web-descriptors.ts +1740 -0
- package/src/providers/meta-web-runtime.ts +3388 -0
- package/src/providers/meta-web.ts +2931 -0
- package/src/providers/read-failure.ts +139 -0
- package/src/providers/reddit-omni.ts +462 -0
- package/src/providers/reddit-web-runtime.ts +1929 -0
- package/src/providers/reddit-web.ts +1611 -0
- package/src/providers/rental-listings.ts +460 -0
- package/src/providers/substack-omni.ts +406 -0
- package/src/providers/substack-video-mp4.ts +33 -0
- package/src/providers/substack-web-runtime.ts +2812 -0
- package/src/providers/substack-web.ts +1502 -0
- package/src/providers/tiktok-video-mp4.ts +33 -0
- package/src/providers/tiktok-web-runtime.ts +770 -0
- package/src/providers/tiktok-web.ts +2786 -0
- package/src/providers/twitch-web-runtime.ts +226 -0
- package/src/providers/twitch-web.ts +274 -0
- package/src/providers/whatsapp-account-identity.ts +55 -0
- package/src/providers/whatsapp-contact-projection-helper.ts +874 -0
- package/src/providers/whatsapp-contact-projection-protocol.ts +403 -0
- package/src/providers/whatsapp-interaction-projection-helper.ts +1452 -0
- package/src/providers/whatsapp-interaction-projection-protocol.ts +462 -0
- package/src/providers/whatsapp-message-export-projection-protocol.ts +532 -0
- package/src/providers/whatsapp-messaging.ts +132 -0
- package/src/providers/whatsapp-omni.ts +586 -0
- package/src/providers/whatsapp-web-runtime.ts +3108 -0
- package/src/providers/whatsapp-web.ts +1037 -0
- package/src/providers/x-image-provenance.ts +523 -0
- package/src/providers/x-made-with-ai.ts +106 -0
- package/src/providers/x-transaction-id.ts +413 -0
- package/src/providers/x-web-runtime.ts +3485 -0
- package/src/providers/x-web.ts +2332 -0
- package/src/providers/x.ts +1889 -0
- package/src/providers/youtube-web-runtime.ts +1400 -0
- package/src/providers/youtube-web.ts +1221 -0
- package/src/read-admission-runtime.ts +91 -0
- package/src/read-client.ts +521 -0
- package/src/read-effect-platform.ts +7 -0
- package/src/read-effect-runtime.ts +14 -0
- package/src/read-effect.ts +52 -0
- package/src/read-projection-admission.ts +866 -0
- package/src/read-projections.ts +3151 -0
- package/src/recovery.ts +1492 -0
- package/src/reviewed-template.ts +146 -0
- package/src/run-journal.ts +1272 -0
- package/src/runtime.ts +5116 -0
- package/src/scripts/generate-provider-plugin-catalog.ts +121 -0
- package/src/scripts/install-whatsapp-protocol.sh +232 -0
- package/src/scripts/recover-install-stages.ts +1491 -0
- package/src/scripts/resolve-state-home.ts +30 -0
- package/src/scripts/scaffold-web-provider.ts +973 -0
- package/src/scripts/sync-bundled-adapters.ts +695 -0
- package/src/session-secrets.ts +1183 -0
- package/src/state-helper.bunfig.toml +2 -0
- package/src/state-helper.ts +2914 -0
- package/src/storage.ts +3842 -0
- package/src/transport-policy.ts +2 -0
- package/src/usage.ts +182 -0
- package/src/version.ts +2 -0
- package/src/web-session-authentication-policy.ts +136 -0
- package/src/web-session-cleanup-admission.ts +2344 -0
- package/src/web-session-cleanup-join.ts +99 -0
- package/src/web-session-client.ts +996 -0
- package/src/web-session-contract-definitions.ts +820 -0
- package/src/web-session-contract-planning.ts +42 -0
- package/src/web-session-contracts.ts +228 -0
- package/src/web-session-cookies.ts +500 -0
- package/src/web-session-execution.ts +385 -0
- package/src/web-session-http.ts +373 -0
- package/src/web-session-read-errors.ts +19 -0
- package/src/web-session-read-runtime.ts +124 -0
- package/src/web-session-recovery.ts +818 -0
- package/src/web-session-sites.ts +58 -0
- package/src/web-session-template.ts +994 -0
- package/src/web-session.ts +93 -0
- package/src/whatsapp-client-binding.ts +21 -0
- package/src/whatsapp-client-types.ts +75 -0
- package/src/whatsapp-client.ts +395 -0
- package/src/whatsapp-export-coordinate.ts +17 -0
- package/src/whatsapp-message-bundle-v2.ts +195 -0
- package/src/whatsapp-message-like-me-cli.ts +195 -0
- package/src/whatsapp-message-like-me-export.ts +1072 -0
- package/src/whatsapp-message-like-me-source.ts +1012 -0
- package/tsconfig.json +67 -0
|
@@ -0,0 +1,2931 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared policy and bounded projections for Meta's signed-in consumer sites.
|
|
3
|
+
*
|
|
4
|
+
* This file never performs network I/O. It accepts only whole responses from
|
|
5
|
+
* exact requests owned by meta-web-runtime.ts and projects them to stable,
|
|
6
|
+
* bounded values. Relay bootloader JSON is treated as foreign input.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { assertMetaCometReadActor } from "./meta-bootstrap";
|
|
10
|
+
import { projectContactDirectionStats } from "./contact-projection";
|
|
11
|
+
import { extractMetaJsonScriptTexts } from "./meta-relay-bundle";
|
|
12
|
+
|
|
13
|
+
export const META_WEB_SITES = Object.freeze([
|
|
14
|
+
"instagram",
|
|
15
|
+
"threads",
|
|
16
|
+
"facebook",
|
|
17
|
+
"facebook-page",
|
|
18
|
+
"facebook-group",
|
|
19
|
+
"facebook-marketplace",
|
|
20
|
+
] as const);
|
|
21
|
+
|
|
22
|
+
export type MetaWebSite = (typeof META_WEB_SITES)[number];
|
|
23
|
+
export type MetaWebRisk = "R1" | "R2" | "R3";
|
|
24
|
+
export type MetaWebContractState = "observed" | "capture-required";
|
|
25
|
+
|
|
26
|
+
const unavailableInstagramContactStats = Object.freeze({
|
|
27
|
+
count: null,
|
|
28
|
+
complete: false,
|
|
29
|
+
lowerBound: false,
|
|
30
|
+
truncated: false,
|
|
31
|
+
lastAt: null,
|
|
32
|
+
lastAtComplete: false,
|
|
33
|
+
lastAtBasis: "unavailable",
|
|
34
|
+
incompleteReasons: Object.freeze(["message-history-capture-required"]),
|
|
35
|
+
} as const);
|
|
36
|
+
|
|
37
|
+
const unavailableInstagramContactStatsProjection = projectContactDirectionStats(
|
|
38
|
+
unavailableInstagramContactStats,
|
|
39
|
+
unavailableInstagramContactStats,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
export const META_WEB_OPERATION_NAMES = Object.freeze({
|
|
43
|
+
instagram: Object.freeze([
|
|
44
|
+
"comments.create", "comments.read", "contacts.list", "content.delete", "content.edit", "content.save", "content.share",
|
|
45
|
+
"feeds.read", "likes.set", "media.publish", "media.read", "messaging.list",
|
|
46
|
+
"messaging.read", "messaging.send", "posts.read", "posts.repost", "profiles.read", "reactions.set",
|
|
47
|
+
"relationships.follow.set", "replies.create",
|
|
48
|
+
] as const),
|
|
49
|
+
threads: Object.freeze([
|
|
50
|
+
"comments.read", "content.edit", "content.save", "content.share", "feeds.read",
|
|
51
|
+
"likes.set", "media.publish", "media.read", "messaging.list", "messaging.read", "messaging.send",
|
|
52
|
+
"posts.publish", "posts.quote", "posts.read", "posts.repost", "profiles.read",
|
|
53
|
+
"relationships.follow.set", "replies.create", "threads.publish",
|
|
54
|
+
] as const),
|
|
55
|
+
facebook: Object.freeze([
|
|
56
|
+
"comments.create", "comments.read", "contacts.list", "content.edit", "content.save", "content.share",
|
|
57
|
+
"feeds.read", "likes.set", "media.publish", "media.read", "messaging.list",
|
|
58
|
+
"messaging.read", "messaging.send", "posts.publish", "posts.quote", "posts.read",
|
|
59
|
+
"posts.repost", "reactions.set", "relationships.follow.set", "replies.create",
|
|
60
|
+
] as const),
|
|
61
|
+
"facebook-page": Object.freeze([
|
|
62
|
+
"comments.create", "comments.read", "content.edit", "content.save", "content.schedule",
|
|
63
|
+
"content.share", "feeds.read", "likes.set", "media.publish", "media.read",
|
|
64
|
+
"messaging.list", "messaging.read", "messaging.send", "posts.publish", "posts.quote",
|
|
65
|
+
"posts.read", "posts.repost", "reactions.set", "relationships.follow.set",
|
|
66
|
+
"replies.create",
|
|
67
|
+
] as const),
|
|
68
|
+
"facebook-group": Object.freeze([
|
|
69
|
+
"comments.create", "comments.read", "communities.membership.set", "content.edit",
|
|
70
|
+
"content.save", "content.share", "feeds.read", "likes.set", "media.publish",
|
|
71
|
+
"media.read", "posts.publish", "posts.quote", "posts.read", "posts.repost",
|
|
72
|
+
"reactions.set", "replies.create",
|
|
73
|
+
] as const),
|
|
74
|
+
"facebook-marketplace": Object.freeze([
|
|
75
|
+
"content.edit", "content.save", "content.share", "feeds.read", "listings.publish",
|
|
76
|
+
"listings.read", "media.read", "messaging.list", "messaging.read", "messaging.send",
|
|
77
|
+
] as const),
|
|
78
|
+
} satisfies Readonly<Record<MetaWebSite, readonly string[]>>);
|
|
79
|
+
|
|
80
|
+
export type MetaWebOperationName = (typeof META_WEB_OPERATION_NAMES)[MetaWebSite][number];
|
|
81
|
+
|
|
82
|
+
const META_NUMERIC_ID_PATTERN = /^[1-9][0-9]{0,31}$/u;
|
|
83
|
+
|
|
84
|
+
/** True only for Meta account IDs in their one canonical decimal representation. */
|
|
85
|
+
export function isCanonicalMetaNumericId(value: unknown): value is string {
|
|
86
|
+
return typeof value === "string" && META_NUMERIC_ID_PATTERN.test(value);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export type MetaWebOperationContract = {
|
|
90
|
+
readonly contractVersion: number;
|
|
91
|
+
readonly effect: "read" | "write";
|
|
92
|
+
readonly risk: MetaWebRisk;
|
|
93
|
+
readonly state: MetaWebContractState;
|
|
94
|
+
readonly evidence: "live-direct" | "first-party-bundle" | "none";
|
|
95
|
+
readonly reason: string;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const observed = (reason: string, contractVersion = 1): MetaWebOperationContract => Object.freeze({
|
|
99
|
+
contractVersion,
|
|
100
|
+
effect: "read",
|
|
101
|
+
risk: "R1",
|
|
102
|
+
state: "observed",
|
|
103
|
+
evidence: "live-direct",
|
|
104
|
+
reason,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const observedMutation = (
|
|
108
|
+
risk: "R2" | "R3",
|
|
109
|
+
reason: string,
|
|
110
|
+
contractVersion = 1,
|
|
111
|
+
): MetaWebOperationContract => Object.freeze({
|
|
112
|
+
contractVersion,
|
|
113
|
+
effect: "write",
|
|
114
|
+
risk,
|
|
115
|
+
state: "observed",
|
|
116
|
+
evidence: "first-party-bundle",
|
|
117
|
+
reason,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
function riskForOperation(operation: string): MetaWebRisk {
|
|
121
|
+
if (
|
|
122
|
+
operation.endsWith(".read")
|
|
123
|
+
|| operation.endsWith(".list")
|
|
124
|
+
|| operation === "feeds.read"
|
|
125
|
+
|| operation === "media.read"
|
|
126
|
+
|| operation === "listings.read"
|
|
127
|
+
) return "R1";
|
|
128
|
+
if (
|
|
129
|
+
operation === "likes.set"
|
|
130
|
+
|| operation === "reactions.set"
|
|
131
|
+
|| operation === "content.save"
|
|
132
|
+
|| operation === "relationships.follow.set"
|
|
133
|
+
|| operation === "communities.membership.set"
|
|
134
|
+
) return "R2";
|
|
135
|
+
return "R3";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const captureRequired = (
|
|
139
|
+
operation: string,
|
|
140
|
+
reason?: string,
|
|
141
|
+
contractVersion = 1,
|
|
142
|
+
): MetaWebOperationContract => {
|
|
143
|
+
const risk = riskForOperation(operation);
|
|
144
|
+
return Object.freeze({
|
|
145
|
+
contractVersion,
|
|
146
|
+
effect: risk === "R1" ? "read" : "write",
|
|
147
|
+
risk,
|
|
148
|
+
state: "capture-required",
|
|
149
|
+
evidence: "none",
|
|
150
|
+
reason: reason ?? (
|
|
151
|
+
risk === "R1"
|
|
152
|
+
? "the exact acknowledgement-free response and target binding still require a reviewed live capture"
|
|
153
|
+
: risk === "R2"
|
|
154
|
+
? "the exact actor, target, mutation response, and independent desired-state readback require an authorized fixture"
|
|
155
|
+
: "the exact actor, audience, attachment transport, dispatch response, and independent publication readback require an authorized fixture"
|
|
156
|
+
),
|
|
157
|
+
});
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
function contracts(
|
|
161
|
+
site: MetaWebSite,
|
|
162
|
+
overrides: Readonly<Record<string, MetaWebOperationContract>>,
|
|
163
|
+
): Readonly<Record<string, MetaWebOperationContract>> {
|
|
164
|
+
return Object.freeze(Object.fromEntries(
|
|
165
|
+
META_WEB_OPERATION_NAMES[site].map((operation) => [
|
|
166
|
+
operation,
|
|
167
|
+
overrides[operation] ?? captureRequired(operation),
|
|
168
|
+
]),
|
|
169
|
+
));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export const META_WEB_OPERATIONS = Object.freeze({
|
|
173
|
+
instagram: contracts("instagram", {
|
|
174
|
+
"contacts.list": observed(
|
|
175
|
+
"unique non-viewer participants from one bounded first page of the live direct_v2 inbox summary GET; the contact set and all message statistics retain explicit first-page or unavailable completeness",
|
|
176
|
+
1,
|
|
177
|
+
),
|
|
178
|
+
"feeds.read": observed(
|
|
179
|
+
"one bounded first page from live direct /api/v1/feed/timeline JSON with viewer binding and no continuation cursor accepted or exposed",
|
|
180
|
+
2,
|
|
181
|
+
),
|
|
182
|
+
"posts.read": observed("live direct /api/v1/media/{id}/info JSON with exact returned-media binding"),
|
|
183
|
+
"media.read": observed("the target-bound media-info response supplies bounded media metadata without copying credentials"),
|
|
184
|
+
"comments.read": observed(
|
|
185
|
+
"one bounded first page from live direct /api/v1/media/{id}/comments JSON with caption/root binding and no continuation cursor accepted or exposed",
|
|
186
|
+
2,
|
|
187
|
+
),
|
|
188
|
+
"messaging.list": observed(
|
|
189
|
+
"one bounded first page from the live direct_v2 inbox summary GET, with no continuation cursor accepted or exposed and no seen, ack, or presence endpoint issued",
|
|
190
|
+
2,
|
|
191
|
+
),
|
|
192
|
+
"messaging.read": captureRequired(
|
|
193
|
+
"messaging.read",
|
|
194
|
+
"Direct thread reads require a reviewed no-seen/no-presence capture; listing evidence does not authorize reading messages",
|
|
195
|
+
),
|
|
196
|
+
"messaging.send": captureRequired(
|
|
197
|
+
"messaging.send",
|
|
198
|
+
"Instagram messaging is split across Direct, LS/Msys, and E2EE transports; plaintext replay is prohibited",
|
|
199
|
+
),
|
|
200
|
+
"media.publish": captureRequired(
|
|
201
|
+
"media.publish",
|
|
202
|
+
"one exact plan-bound MP4 and JPEG cover can reach one configure POST, but the observed first response was 202 without an accepted target; no safe retry or independent upload-ID reconciliation contract is proven",
|
|
203
|
+
3,
|
|
204
|
+
),
|
|
205
|
+
"content.delete": observedMutation(
|
|
206
|
+
"R3",
|
|
207
|
+
"two exact authored-video pre-reads bind actor, caption, kind, full media ID, and shortcode before one delete POST; did_delete acknowledgement and the exact authenticated soft-200 removal marker verify deletion",
|
|
208
|
+
2,
|
|
209
|
+
),
|
|
210
|
+
"profiles.read": observed(
|
|
211
|
+
"live direct target-bound /api/v1/users/web_profile_info JSON with exact current-viewer ID binding and exact follower, following, and post counts",
|
|
212
|
+
),
|
|
213
|
+
}),
|
|
214
|
+
threads: contracts("threads", {
|
|
215
|
+
"feeds.read": observed(
|
|
216
|
+
"one bounded first page from live direct signed-in Threads Relay preload JSON with exact Barcelona viewer binding and no continuation cursor accepted or exposed",
|
|
217
|
+
2,
|
|
218
|
+
),
|
|
219
|
+
"posts.publish": observedMutation(
|
|
220
|
+
"R3",
|
|
221
|
+
"reviewed live configure_text_post_app_feed create with optional single-PNG upload, exact minimal created-locator binding, durable response-bound post identity plus completed-upload dimensions when an image is supplied, and independent exact permalink actor/text and optional image readback",
|
|
222
|
+
5,
|
|
223
|
+
),
|
|
224
|
+
"media.publish": observedMutation(
|
|
225
|
+
"R3",
|
|
226
|
+
"reviewed live single-MP4 rupload_igvideo transfer with synchronous 200 completion, exact configure_text_post_app_feed actor and created-locator binding, durable response-bound post identity plus completed-upload dimensions, and independent exact permalink actor/text/video readback",
|
|
227
|
+
1,
|
|
228
|
+
),
|
|
229
|
+
"profiles.read": observed(
|
|
230
|
+
"live direct target-bound signed-in Threads profile HTML preload with exact current-viewer ID binding; recent views remain explicitly unavailable while this account is below the provider's Insights eligibility threshold",
|
|
231
|
+
),
|
|
232
|
+
"messaging.list": captureRequired(
|
|
233
|
+
"messaging.list",
|
|
234
|
+
"Threads inbox uses Lightspeed/Msys state and may acknowledge or update presence; Relay setup metadata is not message-list authority",
|
|
235
|
+
),
|
|
236
|
+
"messaging.read": captureRequired(
|
|
237
|
+
"messaging.read",
|
|
238
|
+
"Threads conversation reads require protocol-correct Lightspeed/Msys acknowledgement analysis",
|
|
239
|
+
),
|
|
240
|
+
"messaging.send": captureRequired(
|
|
241
|
+
"messaging.send",
|
|
242
|
+
"Threads message send requires protocol-correct Lightspeed/Msys or E2EE implementation",
|
|
243
|
+
),
|
|
244
|
+
}),
|
|
245
|
+
facebook: contracts("facebook", {
|
|
246
|
+
"contacts.list": captureRequired(
|
|
247
|
+
"contacts.list",
|
|
248
|
+
"personal Facebook contacts require either an exact friends collection or a separately reviewed Messenger-participant transport with actor binding, paging, completeness, and acknowledgement analysis",
|
|
249
|
+
),
|
|
250
|
+
"feeds.read": observed(
|
|
251
|
+
"live direct signed-in Comet Relay news-feed preload JSON with exact current-user binding",
|
|
252
|
+
2,
|
|
253
|
+
),
|
|
254
|
+
"messaging.list": captureRequired(
|
|
255
|
+
"messaging.list",
|
|
256
|
+
"a homepage Comet preload does not establish the inbox route or folder, paging or completeness, or acknowledgement/presence behavior; Messenger Msys/E2EE needs its own reviewed transport",
|
|
257
|
+
2,
|
|
258
|
+
),
|
|
259
|
+
"messaging.read": captureRequired(
|
|
260
|
+
"messaging.read",
|
|
261
|
+
"Messenger conversation reads require protocol-correct Lightspeed/Msys acknowledgement analysis",
|
|
262
|
+
),
|
|
263
|
+
"messaging.send": captureRequired(
|
|
264
|
+
"messaging.send",
|
|
265
|
+
"Messenger send requires protocol-correct Lightspeed/Msys/E2EE and cannot be represented as a plaintext GraphQL replay",
|
|
266
|
+
),
|
|
267
|
+
}),
|
|
268
|
+
"facebook-page": contracts("facebook-page", {
|
|
269
|
+
"messaging.list": captureRequired(
|
|
270
|
+
"messaging.list",
|
|
271
|
+
"Page inbox actor switching and Page-vs-person recipient binding require a reviewed business-inbox capture",
|
|
272
|
+
),
|
|
273
|
+
"messaging.send": captureRequired(
|
|
274
|
+
"messaging.send",
|
|
275
|
+
"Page messaging requires exact Page actor proof plus protocol-correct inbox transport",
|
|
276
|
+
),
|
|
277
|
+
}),
|
|
278
|
+
"facebook-group": contracts("facebook-group", {
|
|
279
|
+
"feeds.read": observed(
|
|
280
|
+
"live direct signed-in Group HTML bootstrap with exact current-user and numeric Group target binding, complete streamed-fragment assembly, no client-script execution, and no pagination claim",
|
|
281
|
+
2,
|
|
282
|
+
),
|
|
283
|
+
}),
|
|
284
|
+
"facebook-marketplace": contracts("facebook-marketplace", {
|
|
285
|
+
"feeds.read": observed(
|
|
286
|
+
"live direct signed-in Marketplace HTML bootstrap plus current-bundle-resolved Relay continuation with exact current-user proof binding, complete streamed-page assembly, and no client-script execution",
|
|
287
|
+
2,
|
|
288
|
+
),
|
|
289
|
+
"listings.read": observed(
|
|
290
|
+
"live direct signed-in Marketplace product-details HTML bootstrap with exact current-user and requested-listing binding; the browser item-seen mutation is not executed",
|
|
291
|
+
2,
|
|
292
|
+
),
|
|
293
|
+
"messaging.list": captureRequired(
|
|
294
|
+
"messaging.list",
|
|
295
|
+
"Marketplace inbox scope must remain distinct from personal Messenger and requires listing-bound capture",
|
|
296
|
+
),
|
|
297
|
+
"messaging.send": captureRequired(
|
|
298
|
+
"messaging.send",
|
|
299
|
+
"Marketplace send requires exact listing, seller/buyer, thread, and protocol-correct Messenger binding",
|
|
300
|
+
),
|
|
301
|
+
}),
|
|
302
|
+
} satisfies Readonly<Record<MetaWebSite, Readonly<Record<string, MetaWebOperationContract>>>>);
|
|
303
|
+
|
|
304
|
+
type JsonRecord = Record<string, unknown>;
|
|
305
|
+
|
|
306
|
+
function isRecord(value: unknown): value is JsonRecord {
|
|
307
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function isUnknownArray(value: unknown): value is readonly unknown[] {
|
|
311
|
+
return Array.isArray(value);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function record(value: unknown, label: string): JsonRecord {
|
|
315
|
+
if (!isRecord(value)) throw new Error(`${label} must be an object`);
|
|
316
|
+
return value;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function boundedString(value: unknown, label: string, maximum: number, allowEmpty = false): string {
|
|
320
|
+
if (
|
|
321
|
+
typeof value !== "string"
|
|
322
|
+
|| (!allowEmpty && value.length < 1)
|
|
323
|
+
|| value.length > maximum
|
|
324
|
+
|| /[\0\r]/u.test(value)
|
|
325
|
+
) throw new Error(`${label} must be bounded text`);
|
|
326
|
+
return value;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function optionalString(value: unknown, label: string, maximum: number): string | null {
|
|
330
|
+
if (value === undefined || value === null || value === "") return null;
|
|
331
|
+
return boundedString(value, label, maximum);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function optionalInteger(value: unknown, label: string): number | null {
|
|
335
|
+
if (value === undefined || value === null) return null;
|
|
336
|
+
if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error(`${label} must be a non-negative integer`);
|
|
337
|
+
return value as number;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function optionalBoolean(value: unknown, label: string): boolean | null {
|
|
341
|
+
if (value === undefined || value === null) return null;
|
|
342
|
+
if (typeof value !== "boolean") throw new Error(`${label} must be boolean`);
|
|
343
|
+
return value;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const MAX_JSON_SCRIPTS = 512;
|
|
347
|
+
const MAX_TREE_NODES = 250_000;
|
|
348
|
+
const MAX_TREE_DEPTH = 40;
|
|
349
|
+
const FACEBOOK_HOME_STREAM_KEY_PATTERN =
|
|
350
|
+
/^adp_CometModernHomeFeedQueryRelayPreloader_[A-Za-z0-9_]{1,192}$/u;
|
|
351
|
+
const FACEBOOK_GROUP_STREAM_KEY_PATTERN =
|
|
352
|
+
/^adp_CometGroupDiscussionRootSuccessQueryRelayPreloader_[A-Za-z0-9_]{1,192}$/u;
|
|
353
|
+
const FACEBOOK_MARKETPLACE_FEED_STREAM_KEY_PATTERN =
|
|
354
|
+
/^adp_MarketplaceCometBrowseFeedLightContainerQueryRelayPreloader_[A-Za-z0-9_]{1,192}$/u;
|
|
355
|
+
const FACEBOOK_MARKETPLACE_LISTING_DETAIL_STREAM_KEY_PATTERN =
|
|
356
|
+
/^adp_MarketplacePDPContainerQueryRelayPreloader_[A-Za-z0-9_]{1,192}$/u;
|
|
357
|
+
const FACEBOOK_MARKETPLACE_LISTING_MEDIA_STREAM_KEY_PATTERN =
|
|
358
|
+
/^adp_MarketplacePDPC2CMediaViewerWithImagesQueryRelayPreloader_[A-Za-z0-9_]{1,192}$/u;
|
|
359
|
+
const FACEBOOK_CURRENT_USER_ASYNC_KEY_PATTERN =
|
|
360
|
+
/^adp_WebWorkerV2HasteResponsePreloader_[A-Za-z0-9_]{1,192}$/u;
|
|
361
|
+
|
|
362
|
+
export function parseMetaJsonScripts(html: unknown): readonly unknown[] {
|
|
363
|
+
const texts = extractMetaJsonScriptTexts(html);
|
|
364
|
+
if (texts.length > MAX_JSON_SCRIPTS) {
|
|
365
|
+
throw new Error("Meta HTML response contained too many JSON scripts");
|
|
366
|
+
}
|
|
367
|
+
const results = texts.map((text) => {
|
|
368
|
+
try {
|
|
369
|
+
return JSON.parse(text) as unknown;
|
|
370
|
+
} catch {
|
|
371
|
+
throw new Error("Meta bootloader JSON script was malformed");
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
return Object.freeze(results);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function parseMetaJsonDocuments(text: unknown): readonly unknown[] {
|
|
378
|
+
if (
|
|
379
|
+
typeof text !== "string"
|
|
380
|
+
|| text.length < 1
|
|
381
|
+
|| text.length > 16 * 1024 * 1024
|
|
382
|
+
|| text.includes("\0")
|
|
383
|
+
) throw new Error("Meta streamed JSON response must be bounded text");
|
|
384
|
+
let source = text;
|
|
385
|
+
if (source.startsWith("for (;;);")) source = source.slice("for (;;);".length);
|
|
386
|
+
const lines = source.split(/\r?\n/u).filter((line) => line.trim().length > 0);
|
|
387
|
+
if (lines.length < 1 || lines.length > 512) {
|
|
388
|
+
throw new Error("Meta streamed JSON response had an unsupported document count");
|
|
389
|
+
}
|
|
390
|
+
const documents = lines.map((line, index) => {
|
|
391
|
+
if (line.length > 4 * 1024 * 1024) {
|
|
392
|
+
throw new Error(`Meta streamed JSON document[${index}] exceeded its reviewed bound`);
|
|
393
|
+
}
|
|
394
|
+
let parsed: unknown;
|
|
395
|
+
try {
|
|
396
|
+
parsed = JSON.parse(line) as unknown;
|
|
397
|
+
} catch {
|
|
398
|
+
throw new Error(`Meta streamed JSON document[${index}] was malformed`);
|
|
399
|
+
}
|
|
400
|
+
if (!isRecord(parsed)) {
|
|
401
|
+
throw new Error(`Meta streamed JSON document[${index}] must be an object`);
|
|
402
|
+
}
|
|
403
|
+
return parsed;
|
|
404
|
+
});
|
|
405
|
+
return Object.freeze(documents);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function walk(
|
|
409
|
+
roots: readonly unknown[],
|
|
410
|
+
visit: (value: unknown, path: readonly string[]) => void,
|
|
411
|
+
): void {
|
|
412
|
+
let nodes = 0;
|
|
413
|
+
const stack: { readonly value: unknown; readonly path: readonly string[]; readonly depth: number }[] =
|
|
414
|
+
roots
|
|
415
|
+
.map((value) => ({ value, path: Object.freeze([]), depth: 0 }))
|
|
416
|
+
.reverse();
|
|
417
|
+
while (stack.length > 0) {
|
|
418
|
+
const item = stack.pop();
|
|
419
|
+
if (item === undefined) break;
|
|
420
|
+
nodes += 1;
|
|
421
|
+
if (nodes > MAX_TREE_NODES) throw new Error("Meta bootloader JSON exceeded its reviewed structural bound");
|
|
422
|
+
visit(item.value, item.path);
|
|
423
|
+
if (item.depth >= MAX_TREE_DEPTH) {
|
|
424
|
+
if (
|
|
425
|
+
(Array.isArray(item.value) && item.value.length > 0)
|
|
426
|
+
|| (isRecord(item.value) && Object.keys(item.value).length > 0)
|
|
427
|
+
) {
|
|
428
|
+
throw new Error("Meta bootloader JSON exceeded its reviewed depth bound");
|
|
429
|
+
}
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
if (Array.isArray(item.value)) {
|
|
433
|
+
if (item.value.length > 10_000) throw new Error("Meta bootloader JSON contained an oversized array");
|
|
434
|
+
for (let index = item.value.length - 1; index >= 0; index -= 1) {
|
|
435
|
+
stack.push({
|
|
436
|
+
value: item.value[index],
|
|
437
|
+
path: Object.freeze([...item.path, "[]"]),
|
|
438
|
+
depth: item.depth + 1,
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
} else if (isRecord(item.value)) {
|
|
442
|
+
const entries = Object.entries(item.value);
|
|
443
|
+
if (entries.length > 10_000) throw new Error("Meta bootloader JSON contained an oversized object");
|
|
444
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
445
|
+
const entry = entries[index];
|
|
446
|
+
if (entry === undefined) continue;
|
|
447
|
+
stack.push({
|
|
448
|
+
value: entry[1],
|
|
449
|
+
path: Object.freeze([...item.path, entry[0]]),
|
|
450
|
+
depth: item.depth + 1,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function pathEquals(
|
|
458
|
+
value: readonly string[],
|
|
459
|
+
expected: readonly string[],
|
|
460
|
+
): boolean {
|
|
461
|
+
return value.length === expected.length
|
|
462
|
+
&& value.every((segment, index) => segment === expected[index]);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function isReviewedRelayModuleTuple(
|
|
466
|
+
value: unknown,
|
|
467
|
+
name: string,
|
|
468
|
+
): value is readonly [string, readonly unknown[], JsonRecord, number] {
|
|
469
|
+
return (
|
|
470
|
+
Array.isArray(value)
|
|
471
|
+
&& value.length === 4
|
|
472
|
+
&& value[0] === name
|
|
473
|
+
&& Array.isArray(value[1])
|
|
474
|
+
&& value[1].length === 0
|
|
475
|
+
&& isRecord(value[2])
|
|
476
|
+
&& Number.isSafeInteger(value[3])
|
|
477
|
+
&& (value[3] as number) >= 0
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function directRelayResultMatches(
|
|
482
|
+
roots: readonly unknown[],
|
|
483
|
+
path: readonly string[],
|
|
484
|
+
value: unknown,
|
|
485
|
+
): boolean {
|
|
486
|
+
return (
|
|
487
|
+
pathEquals(path, ["result"])
|
|
488
|
+
&& roots.some((root) =>
|
|
489
|
+
isRecord(root)
|
|
490
|
+
&& root.result === value
|
|
491
|
+
)
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function directRelayDataMatches(
|
|
496
|
+
roots: readonly unknown[],
|
|
497
|
+
path: readonly string[],
|
|
498
|
+
value: unknown,
|
|
499
|
+
): boolean {
|
|
500
|
+
if (pathEquals(path, ["data"])) {
|
|
501
|
+
return roots.some((root) => isRecord(root) && root.data === value);
|
|
502
|
+
}
|
|
503
|
+
return (
|
|
504
|
+
pathEquals(path, ["result", "data"])
|
|
505
|
+
&& roots.some((root) =>
|
|
506
|
+
isRecord(root)
|
|
507
|
+
&& isRecord(root.result)
|
|
508
|
+
&& root.result.data === value
|
|
509
|
+
)
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function relayPrefetchResultMatches(
|
|
514
|
+
roots: readonly unknown[],
|
|
515
|
+
path: readonly string[],
|
|
516
|
+
value: unknown,
|
|
517
|
+
selectData: boolean,
|
|
518
|
+
): boolean {
|
|
519
|
+
const expectedPath = selectData
|
|
520
|
+
? ["require", "[]", "[]", "__bbox", "result", "data"]
|
|
521
|
+
: ["require", "[]", "[]", "__bbox", "result"];
|
|
522
|
+
if (!pathEquals(path, expectedPath)) return false;
|
|
523
|
+
for (const root of roots) {
|
|
524
|
+
if (!isRecord(root) || !Array.isArray(root.require)) continue;
|
|
525
|
+
for (const candidate of root.require) {
|
|
526
|
+
if (
|
|
527
|
+
!isReviewedRelayModuleTuple(candidate, "Relay")
|
|
528
|
+
&& !isReviewedRelayModuleTuple(candidate, "RelayPrefetch")
|
|
529
|
+
) continue;
|
|
530
|
+
const bbox = candidate[2].__bbox;
|
|
531
|
+
if (!isRecord(bbox) || !isRecord(bbox.result)) continue;
|
|
532
|
+
const selected = selectData ? bbox.result.data : bbox.result;
|
|
533
|
+
if (selected === value) return true;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return false;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function scheduledServerResultMatches(
|
|
540
|
+
roots: readonly unknown[],
|
|
541
|
+
path: readonly string[],
|
|
542
|
+
value: unknown,
|
|
543
|
+
selectData: boolean,
|
|
544
|
+
): boolean {
|
|
545
|
+
const expectedPath = selectData
|
|
546
|
+
? ["require", "[]", "[]", "[]", "__bbox", "result", "data"]
|
|
547
|
+
: ["require", "[]", "[]", "[]", "__bbox", "result"];
|
|
548
|
+
if (!pathEquals(path, expectedPath)) return false;
|
|
549
|
+
for (const root of roots) {
|
|
550
|
+
if (!isRecord(root) || !Array.isArray(root.require)) continue;
|
|
551
|
+
for (const scheduled of root.require) {
|
|
552
|
+
if (
|
|
553
|
+
!Array.isArray(scheduled)
|
|
554
|
+
|| scheduled.length !== 4
|
|
555
|
+
|| scheduled[0] !== "ScheduledServerJS"
|
|
556
|
+
|| scheduled[1] !== "handle"
|
|
557
|
+
|| scheduled[2] !== null
|
|
558
|
+
|| !Array.isArray(scheduled[3])
|
|
559
|
+
) continue;
|
|
560
|
+
for (const payload of scheduled[3]) {
|
|
561
|
+
if (
|
|
562
|
+
!isRecord(payload)
|
|
563
|
+
|| !isRecord(payload.__bbox)
|
|
564
|
+
|| !isRecord(payload.__bbox.result)
|
|
565
|
+
) continue;
|
|
566
|
+
const selected = selectData
|
|
567
|
+
? payload.__bbox.result.data
|
|
568
|
+
: payload.__bbox.result;
|
|
569
|
+
if (selected === value) return true;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return false;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function relayPrefetchedStreamResultMatches(
|
|
577
|
+
roots: readonly unknown[],
|
|
578
|
+
path: readonly string[],
|
|
579
|
+
value: unknown,
|
|
580
|
+
selectData: boolean,
|
|
581
|
+
preloaderKeyPattern: RegExp,
|
|
582
|
+
): boolean {
|
|
583
|
+
return relayPrefetchedStreamResultKey(
|
|
584
|
+
roots,
|
|
585
|
+
path,
|
|
586
|
+
value,
|
|
587
|
+
selectData,
|
|
588
|
+
preloaderKeyPattern,
|
|
589
|
+
) !== null;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function relayPrefetchedStreamResultKey(
|
|
593
|
+
roots: readonly unknown[],
|
|
594
|
+
path: readonly string[],
|
|
595
|
+
value: unknown,
|
|
596
|
+
selectData: boolean,
|
|
597
|
+
preloaderKeyPattern: RegExp,
|
|
598
|
+
): string | null {
|
|
599
|
+
const expectedPath = selectData
|
|
600
|
+
? [
|
|
601
|
+
"require",
|
|
602
|
+
"[]",
|
|
603
|
+
"[]",
|
|
604
|
+
"[]",
|
|
605
|
+
"__bbox",
|
|
606
|
+
"require",
|
|
607
|
+
"[]",
|
|
608
|
+
"[]",
|
|
609
|
+
"[]",
|
|
610
|
+
"__bbox",
|
|
611
|
+
"result",
|
|
612
|
+
"data",
|
|
613
|
+
]
|
|
614
|
+
: [
|
|
615
|
+
"require",
|
|
616
|
+
"[]",
|
|
617
|
+
"[]",
|
|
618
|
+
"[]",
|
|
619
|
+
"__bbox",
|
|
620
|
+
"require",
|
|
621
|
+
"[]",
|
|
622
|
+
"[]",
|
|
623
|
+
"[]",
|
|
624
|
+
"__bbox",
|
|
625
|
+
"result",
|
|
626
|
+
];
|
|
627
|
+
if (!pathEquals(path, expectedPath)) return null;
|
|
628
|
+
let matchedKey: string | null = null;
|
|
629
|
+
for (const root of roots) {
|
|
630
|
+
if (!isRecord(root) || !isUnknownArray(root.require)) continue;
|
|
631
|
+
for (const scheduled of root.require) {
|
|
632
|
+
if (
|
|
633
|
+
!isUnknownArray(scheduled)
|
|
634
|
+
|| scheduled.length !== 4
|
|
635
|
+
|| scheduled[0] !== "ScheduledServerJS"
|
|
636
|
+
|| scheduled[1] !== "handle"
|
|
637
|
+
|| scheduled[2] !== null
|
|
638
|
+
|| !isUnknownArray(scheduled[3])
|
|
639
|
+
) continue;
|
|
640
|
+
for (const scheduledPayload of scheduled[3]) {
|
|
641
|
+
if (
|
|
642
|
+
!isRecord(scheduledPayload)
|
|
643
|
+
|| !isRecord(scheduledPayload.__bbox)
|
|
644
|
+
|| !isUnknownArray(scheduledPayload.__bbox.require)
|
|
645
|
+
) continue;
|
|
646
|
+
for (const stream of scheduledPayload.__bbox.require) {
|
|
647
|
+
if (
|
|
648
|
+
!isUnknownArray(stream)
|
|
649
|
+
|| stream.length !== 4
|
|
650
|
+
|| stream[0] !== "RelayPrefetchedStreamCache"
|
|
651
|
+
|| stream[1] !== "next"
|
|
652
|
+
|| !isUnknownArray(stream[2])
|
|
653
|
+
|| stream[2].length !== 0
|
|
654
|
+
|| !isUnknownArray(stream[3])
|
|
655
|
+
|| stream[3].length !== 2
|
|
656
|
+
|| typeof stream[3][0] !== "string"
|
|
657
|
+
|| !preloaderKeyPattern.test(stream[3][0])
|
|
658
|
+
) continue;
|
|
659
|
+
const streamPayload = stream[3][1];
|
|
660
|
+
if (
|
|
661
|
+
!isRecord(streamPayload)
|
|
662
|
+
|| !isRecord(streamPayload.__bbox)
|
|
663
|
+
|| !isRecord(streamPayload.__bbox.result)
|
|
664
|
+
) continue;
|
|
665
|
+
const selected = selectData
|
|
666
|
+
? streamPayload.__bbox.result.data
|
|
667
|
+
: streamPayload.__bbox.result;
|
|
668
|
+
if (selected !== value) continue;
|
|
669
|
+
const candidateKey = stream[3][0];
|
|
670
|
+
if (matchedKey !== null && matchedKey !== candidateKey) {
|
|
671
|
+
throw new Error("Meta streamed result matched multiple preloader keys");
|
|
672
|
+
}
|
|
673
|
+
matchedKey = candidateKey;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
return matchedKey;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
export function isReviewedMetaRelayDataEnvelope(
|
|
682
|
+
roots: readonly unknown[],
|
|
683
|
+
path: readonly string[],
|
|
684
|
+
value: unknown,
|
|
685
|
+
): boolean {
|
|
686
|
+
return (
|
|
687
|
+
directRelayDataMatches(roots, path, value)
|
|
688
|
+
|| relayPrefetchResultMatches(roots, path, value, true)
|
|
689
|
+
|| scheduledServerResultMatches(roots, path, value, true)
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
export function isReviewedMetaRelayResultEnvelope(
|
|
694
|
+
roots: readonly unknown[],
|
|
695
|
+
path: readonly string[],
|
|
696
|
+
value: unknown,
|
|
697
|
+
): boolean {
|
|
698
|
+
return (
|
|
699
|
+
directRelayResultMatches(roots, path, value)
|
|
700
|
+
|| relayPrefetchResultMatches(roots, path, value, false)
|
|
701
|
+
|| scheduledServerResultMatches(roots, path, value, false)
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function reviewedFacebookHomeFeedDataEnvelopeKey(
|
|
706
|
+
roots: readonly unknown[],
|
|
707
|
+
path: readonly string[],
|
|
708
|
+
value: unknown,
|
|
709
|
+
): string | null {
|
|
710
|
+
return relayPrefetchedStreamResultKey(
|
|
711
|
+
roots,
|
|
712
|
+
path,
|
|
713
|
+
value,
|
|
714
|
+
true,
|
|
715
|
+
FACEBOOK_HOME_STREAM_KEY_PATTERN,
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function isReviewedFacebookHomeFeedResultEnvelope(
|
|
720
|
+
roots: readonly unknown[],
|
|
721
|
+
path: readonly string[],
|
|
722
|
+
value: unknown,
|
|
723
|
+
): boolean {
|
|
724
|
+
return relayPrefetchedStreamResultMatches(
|
|
725
|
+
roots,
|
|
726
|
+
path,
|
|
727
|
+
value,
|
|
728
|
+
false,
|
|
729
|
+
FACEBOOK_HOME_STREAM_KEY_PATTERN,
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function reviewedFacebookHomeFeedResultEnvelopeKey(
|
|
734
|
+
roots: readonly unknown[],
|
|
735
|
+
path: readonly string[],
|
|
736
|
+
value: unknown,
|
|
737
|
+
): string | null {
|
|
738
|
+
return relayPrefetchedStreamResultKey(
|
|
739
|
+
roots,
|
|
740
|
+
path,
|
|
741
|
+
value,
|
|
742
|
+
false,
|
|
743
|
+
FACEBOOK_HOME_STREAM_KEY_PATTERN,
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
export function isReviewedFacebookGroupRelayResultEnvelope(
|
|
748
|
+
roots: readonly unknown[],
|
|
749
|
+
path: readonly string[],
|
|
750
|
+
value: unknown,
|
|
751
|
+
): boolean {
|
|
752
|
+
return relayPrefetchedStreamResultMatches(
|
|
753
|
+
roots,
|
|
754
|
+
path,
|
|
755
|
+
value,
|
|
756
|
+
false,
|
|
757
|
+
FACEBOOK_GROUP_STREAM_KEY_PATTERN,
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function reviewedFacebookMarketplaceFeedDataEnvelopeKey(
|
|
762
|
+
roots: readonly unknown[],
|
|
763
|
+
path: readonly string[],
|
|
764
|
+
value: unknown,
|
|
765
|
+
): string | null {
|
|
766
|
+
return relayPrefetchedStreamResultKey(
|
|
767
|
+
roots,
|
|
768
|
+
path,
|
|
769
|
+
value,
|
|
770
|
+
true,
|
|
771
|
+
FACEBOOK_MARKETPLACE_FEED_STREAM_KEY_PATTERN,
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function reviewedFacebookMarketplaceFeedResultEnvelopeKey(
|
|
776
|
+
roots: readonly unknown[],
|
|
777
|
+
path: readonly string[],
|
|
778
|
+
value: unknown,
|
|
779
|
+
): string | null {
|
|
780
|
+
return relayPrefetchedStreamResultKey(
|
|
781
|
+
roots,
|
|
782
|
+
path,
|
|
783
|
+
value,
|
|
784
|
+
false,
|
|
785
|
+
FACEBOOK_MARKETPLACE_FEED_STREAM_KEY_PATTERN,
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function isReviewedFacebookMarketplaceListingDetailDataEnvelope(
|
|
790
|
+
roots: readonly unknown[],
|
|
791
|
+
path: readonly string[],
|
|
792
|
+
value: unknown,
|
|
793
|
+
): boolean {
|
|
794
|
+
return relayPrefetchedStreamResultMatches(
|
|
795
|
+
roots,
|
|
796
|
+
path,
|
|
797
|
+
value,
|
|
798
|
+
true,
|
|
799
|
+
FACEBOOK_MARKETPLACE_LISTING_DETAIL_STREAM_KEY_PATTERN,
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function isReviewedFacebookMarketplaceListingMediaDataEnvelope(
|
|
804
|
+
roots: readonly unknown[],
|
|
805
|
+
path: readonly string[],
|
|
806
|
+
value: unknown,
|
|
807
|
+
): boolean {
|
|
808
|
+
return relayPrefetchedStreamResultMatches(
|
|
809
|
+
roots,
|
|
810
|
+
path,
|
|
811
|
+
value,
|
|
812
|
+
true,
|
|
813
|
+
FACEBOOK_MARKETPLACE_LISTING_MEDIA_STREAM_KEY_PATTERN,
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function isReviewedFacebookMarketplaceListingResultEnvelope(
|
|
818
|
+
roots: readonly unknown[],
|
|
819
|
+
path: readonly string[],
|
|
820
|
+
value: unknown,
|
|
821
|
+
): boolean {
|
|
822
|
+
return (
|
|
823
|
+
relayPrefetchedStreamResultMatches(
|
|
824
|
+
roots,
|
|
825
|
+
path,
|
|
826
|
+
value,
|
|
827
|
+
false,
|
|
828
|
+
FACEBOOK_MARKETPLACE_LISTING_DETAIL_STREAM_KEY_PATTERN,
|
|
829
|
+
)
|
|
830
|
+
|| relayPrefetchedStreamResultMatches(
|
|
831
|
+
roots,
|
|
832
|
+
path,
|
|
833
|
+
value,
|
|
834
|
+
false,
|
|
835
|
+
FACEBOOK_MARKETPLACE_LISTING_MEDIA_STREAM_KEY_PATTERN,
|
|
836
|
+
)
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function assertEmptyProviderErrors(
|
|
841
|
+
value: JsonRecord,
|
|
842
|
+
label: string,
|
|
843
|
+
): void {
|
|
844
|
+
if (!Object.hasOwn(value, "errors")) return;
|
|
845
|
+
if (!Array.isArray(value.errors)) {
|
|
846
|
+
throw new Error(`${label}.errors must be an array`);
|
|
847
|
+
}
|
|
848
|
+
if (value.errors.length > 0) {
|
|
849
|
+
throw new Error(`${label} contained provider errors`);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function directModuleContainerContains(
|
|
854
|
+
roots: readonly unknown[],
|
|
855
|
+
path: readonly string[],
|
|
856
|
+
moduleValue: readonly unknown[],
|
|
857
|
+
): boolean {
|
|
858
|
+
if (
|
|
859
|
+
!pathEquals(path, ["require", "[]"])
|
|
860
|
+
&& !pathEquals(path, ["define", "[]"])
|
|
861
|
+
) return false;
|
|
862
|
+
const containerName = path[0];
|
|
863
|
+
if (containerName === undefined) return false;
|
|
864
|
+
return roots.some((root) =>
|
|
865
|
+
isRecord(root)
|
|
866
|
+
&& Array.isArray(root[containerName])
|
|
867
|
+
&& root[containerName].some((candidate) => candidate === moduleValue)
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function scheduledServerModuleContainerContains(
|
|
872
|
+
roots: readonly unknown[],
|
|
873
|
+
path: readonly string[],
|
|
874
|
+
moduleValue: readonly unknown[],
|
|
875
|
+
): boolean {
|
|
876
|
+
const innerContainerName = path[5];
|
|
877
|
+
if (
|
|
878
|
+
(
|
|
879
|
+
!pathEquals(path, [
|
|
880
|
+
"require",
|
|
881
|
+
"[]",
|
|
882
|
+
"[]",
|
|
883
|
+
"[]",
|
|
884
|
+
"__bbox",
|
|
885
|
+
"define",
|
|
886
|
+
"[]",
|
|
887
|
+
])
|
|
888
|
+
&& !pathEquals(path, [
|
|
889
|
+
"require",
|
|
890
|
+
"[]",
|
|
891
|
+
"[]",
|
|
892
|
+
"[]",
|
|
893
|
+
"__bbox",
|
|
894
|
+
"require",
|
|
895
|
+
"[]",
|
|
896
|
+
])
|
|
897
|
+
)
|
|
898
|
+
|| (innerContainerName !== "define" && innerContainerName !== "require")
|
|
899
|
+
) return false;
|
|
900
|
+
for (const root of roots) {
|
|
901
|
+
if (!isRecord(root) || !Array.isArray(root.require)) continue;
|
|
902
|
+
for (const scheduledValue of root.require) {
|
|
903
|
+
if (
|
|
904
|
+
!Array.isArray(scheduledValue)
|
|
905
|
+
|| scheduledValue.length !== 4
|
|
906
|
+
|| scheduledValue[0] !== "ScheduledServerJS"
|
|
907
|
+
|| scheduledValue[1] !== "handle"
|
|
908
|
+
|| scheduledValue[2] !== null
|
|
909
|
+
|| !Array.isArray(scheduledValue[3])
|
|
910
|
+
) continue;
|
|
911
|
+
for (const payload of scheduledValue[3]) {
|
|
912
|
+
if (!isRecord(payload) || !isRecord(payload.__bbox)) continue;
|
|
913
|
+
const container = payload.__bbox[innerContainerName];
|
|
914
|
+
if (
|
|
915
|
+
Array.isArray(container)
|
|
916
|
+
&& container.some((candidate) => candidate === moduleValue)
|
|
917
|
+
) return true;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
return false;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function hydratedAsyncDataModuleContainerContains(
|
|
925
|
+
roots: readonly unknown[],
|
|
926
|
+
path: readonly string[],
|
|
927
|
+
moduleValue: readonly unknown[],
|
|
928
|
+
): boolean {
|
|
929
|
+
if (!pathEquals(path, [
|
|
930
|
+
"require",
|
|
931
|
+
"[]",
|
|
932
|
+
"[]",
|
|
933
|
+
"[]",
|
|
934
|
+
"__bbox",
|
|
935
|
+
"require",
|
|
936
|
+
"[]",
|
|
937
|
+
"[]",
|
|
938
|
+
"[]",
|
|
939
|
+
"data",
|
|
940
|
+
"__bbox",
|
|
941
|
+
"hrp",
|
|
942
|
+
"jsmods",
|
|
943
|
+
"define",
|
|
944
|
+
"[]",
|
|
945
|
+
])) return false;
|
|
946
|
+
for (const root of roots) {
|
|
947
|
+
if (!isRecord(root) || !isUnknownArray(root.require)) continue;
|
|
948
|
+
for (const scheduledValue of root.require) {
|
|
949
|
+
if (
|
|
950
|
+
!isUnknownArray(scheduledValue)
|
|
951
|
+
|| scheduledValue.length !== 4
|
|
952
|
+
|| scheduledValue[0] !== "ScheduledServerJS"
|
|
953
|
+
|| scheduledValue[1] !== "handle"
|
|
954
|
+
|| scheduledValue[2] !== null
|
|
955
|
+
|| !isUnknownArray(scheduledValue[3])
|
|
956
|
+
) continue;
|
|
957
|
+
for (const scheduledPayload of scheduledValue[3]) {
|
|
958
|
+
if (
|
|
959
|
+
!isRecord(scheduledPayload)
|
|
960
|
+
|| !isRecord(scheduledPayload.__bbox)
|
|
961
|
+
|| !isUnknownArray(scheduledPayload.__bbox.require)
|
|
962
|
+
) continue;
|
|
963
|
+
for (const asyncValue of scheduledPayload.__bbox.require) {
|
|
964
|
+
if (
|
|
965
|
+
!isUnknownArray(asyncValue)
|
|
966
|
+
|| asyncValue.length !== 4
|
|
967
|
+
|| asyncValue[0] !== "AsyncData"
|
|
968
|
+
|| asyncValue[1] !== "resolve"
|
|
969
|
+
|| !isUnknownArray(asyncValue[2])
|
|
970
|
+
|| asyncValue[2].length !== 0
|
|
971
|
+
|| !isUnknownArray(asyncValue[3])
|
|
972
|
+
|| asyncValue[3].length !== 2
|
|
973
|
+
|| typeof asyncValue[3][0] !== "string"
|
|
974
|
+
|| !FACEBOOK_CURRENT_USER_ASYNC_KEY_PATTERN.test(asyncValue[3][0])
|
|
975
|
+
) continue;
|
|
976
|
+
const asyncPayload = asyncValue[3][1];
|
|
977
|
+
if (!isRecord(asyncPayload) || !isRecord(asyncPayload.data)) continue;
|
|
978
|
+
const dataBbox = asyncPayload.data.__bbox;
|
|
979
|
+
if (!isRecord(dataBbox) || !isRecord(dataBbox.hrp)) continue;
|
|
980
|
+
const jsmods = dataBbox.hrp.jsmods;
|
|
981
|
+
if (
|
|
982
|
+
isRecord(jsmods)
|
|
983
|
+
&& isUnknownArray(jsmods.define)
|
|
984
|
+
&& jsmods.define.some((candidate) => candidate === moduleValue)
|
|
985
|
+
) return true;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
return false;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function modulePathIsReviewed(
|
|
994
|
+
roots: readonly unknown[],
|
|
995
|
+
path: readonly string[],
|
|
996
|
+
moduleValue: readonly unknown[],
|
|
997
|
+
moduleName: string,
|
|
998
|
+
): boolean {
|
|
999
|
+
return (
|
|
1000
|
+
directModuleContainerContains(roots, path, moduleValue)
|
|
1001
|
+
|| scheduledServerModuleContainerContains(roots, path, moduleValue)
|
|
1002
|
+
|| (
|
|
1003
|
+
moduleName === "CurrentUserInitialData"
|
|
1004
|
+
&& hydratedAsyncDataModuleContainerContains(roots, path, moduleValue)
|
|
1005
|
+
)
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function modulePayloads(roots: readonly unknown[], moduleName: string): readonly JsonRecord[] {
|
|
1010
|
+
const results: JsonRecord[] = [];
|
|
1011
|
+
walk(roots, (value, path) => {
|
|
1012
|
+
if (!Array.isArray(value) || value[0] !== moduleName) return;
|
|
1013
|
+
if (!modulePathIsReviewed(roots, path, value, moduleName)) {
|
|
1014
|
+
throw new Error(`${moduleName} appeared outside a reviewed module path`);
|
|
1015
|
+
}
|
|
1016
|
+
if (
|
|
1017
|
+
value.length !== 4
|
|
1018
|
+
|| !Array.isArray(value[1])
|
|
1019
|
+
|| value[1].length !== 0
|
|
1020
|
+
|| !isRecord(value[2])
|
|
1021
|
+
|| !Number.isSafeInteger(value[3])
|
|
1022
|
+
|| (value[3] as number) < 0
|
|
1023
|
+
) {
|
|
1024
|
+
throw new Error(`${moduleName} module was malformed`);
|
|
1025
|
+
}
|
|
1026
|
+
results.push(value[2]);
|
|
1027
|
+
});
|
|
1028
|
+
return Object.freeze(results);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function oneStableId(values: readonly unknown[], label: string): string {
|
|
1032
|
+
const ids = new Set<string>();
|
|
1033
|
+
for (const value of values) {
|
|
1034
|
+
if (isCanonicalMetaNumericId(value)) ids.add(value);
|
|
1035
|
+
}
|
|
1036
|
+
if (ids.size !== 1) throw new Error(`${label} did not resolve to exactly one stable account ID`);
|
|
1037
|
+
return [...ids][0] as string;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
export function parseInstagramViewerId(html: unknown): string {
|
|
1041
|
+
const roots = parseMetaJsonScripts(html);
|
|
1042
|
+
const payloads = modulePayloads(roots, "PolarisViewer");
|
|
1043
|
+
return oneStableId(payloads.map((payload) => payload.id), "Instagram Polaris viewer");
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
export function parseFacebookViewerId(html: unknown): string {
|
|
1047
|
+
const roots = parseMetaJsonScripts(html);
|
|
1048
|
+
const payloads = modulePayloads(roots, "CurrentUserInitialData");
|
|
1049
|
+
if (payloads.length < 1) {
|
|
1050
|
+
throw new Error("Facebook current user did not resolve to exactly one stable account ID");
|
|
1051
|
+
}
|
|
1052
|
+
const candidates: string[] = [];
|
|
1053
|
+
for (const [index, payload] of payloads.entries()) {
|
|
1054
|
+
if (
|
|
1055
|
+
!isCanonicalMetaNumericId(payload.ACCOUNT_ID)
|
|
1056
|
+
|| payload.ACCOUNT_ID !== payload.USER_ID
|
|
1057
|
+
) {
|
|
1058
|
+
throw new Error(
|
|
1059
|
+
`Facebook CurrentUserInitialData[${index}] contained malformed or conflicting viewer identities`,
|
|
1060
|
+
);
|
|
1061
|
+
}
|
|
1062
|
+
candidates.push(payload.ACCOUNT_ID);
|
|
1063
|
+
}
|
|
1064
|
+
return oneStableId(candidates, "Facebook current user");
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
export class ThreadsAuthRepairRequiredError extends Error {
|
|
1068
|
+
constructor() {
|
|
1069
|
+
super("Threads selected session is signed out");
|
|
1070
|
+
this.name = "ThreadsAuthRepairRequiredError";
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
export function parseThreadsViewerId(html: unknown): string {
|
|
1075
|
+
const roots = parseMetaJsonScripts(html);
|
|
1076
|
+
const sessionPayloads = modulePayloads(roots, "BarcelonaSessionInfo");
|
|
1077
|
+
if (
|
|
1078
|
+
sessionPayloads.length > 0
|
|
1079
|
+
&& sessionPayloads.every((payload) =>
|
|
1080
|
+
payload.is_th_session === true && payload.is_logged_out === true
|
|
1081
|
+
)
|
|
1082
|
+
) throw new ThreadsAuthRepairRequiredError();
|
|
1083
|
+
if (
|
|
1084
|
+
sessionPayloads.length < 1
|
|
1085
|
+
|| sessionPayloads.some((payload) => payload.is_th_session !== true || payload.is_logged_out !== false)
|
|
1086
|
+
) throw new Error("Threads bootstrap did not prove one signed-in Barcelona session");
|
|
1087
|
+
const ids: unknown[] = [];
|
|
1088
|
+
walk(roots, (value) => {
|
|
1089
|
+
if (!isRecord(value)) return;
|
|
1090
|
+
const viewer = isRecord(value.viewer) ? value.viewer : null;
|
|
1091
|
+
const user = viewer !== null && isRecord(viewer.user) ? viewer.user : null;
|
|
1092
|
+
if (user !== null) ids.push(user.id);
|
|
1093
|
+
});
|
|
1094
|
+
return oneStableId(ids, "Threads Barcelona viewer");
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
type ProfileMetric = Readonly<
|
|
1098
|
+
| {
|
|
1099
|
+
readonly status: "available";
|
|
1100
|
+
readonly value: number;
|
|
1101
|
+
readonly precision: "exact";
|
|
1102
|
+
readonly unit: "count";
|
|
1103
|
+
}
|
|
1104
|
+
| {
|
|
1105
|
+
readonly status: "unavailable";
|
|
1106
|
+
readonly reason: "not-exposed" | "not-authorized" | "provider-drift";
|
|
1107
|
+
}
|
|
1108
|
+
>;
|
|
1109
|
+
|
|
1110
|
+
function unavailableProfileMetric(
|
|
1111
|
+
reason: "not-exposed" | "not-authorized" | "provider-drift",
|
|
1112
|
+
): ProfileMetric {
|
|
1113
|
+
return Object.freeze({ status: "unavailable", reason });
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
function exactProfileMetric(value: unknown): ProfileMetric {
|
|
1117
|
+
if (value === undefined || value === null) {
|
|
1118
|
+
return unavailableProfileMetric("not-exposed");
|
|
1119
|
+
}
|
|
1120
|
+
const candidate = typeof value === "string" && /^(?:0|[1-9][0-9]*)$/u.test(value)
|
|
1121
|
+
? Number(value)
|
|
1122
|
+
: value;
|
|
1123
|
+
if (!Number.isSafeInteger(candidate) || (candidate as number) < 0) {
|
|
1124
|
+
return unavailableProfileMetric("provider-drift");
|
|
1125
|
+
}
|
|
1126
|
+
return Object.freeze({
|
|
1127
|
+
status: "available",
|
|
1128
|
+
value: candidate as number,
|
|
1129
|
+
precision: "exact",
|
|
1130
|
+
unit: "count",
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
function exactProfileObservedAt(value: string): string {
|
|
1135
|
+
if (
|
|
1136
|
+
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u.test(value)
|
|
1137
|
+
|| !Number.isFinite(Date.parse(value))
|
|
1138
|
+
) throw new Error("Meta profile observation time must be one exact UTC instant");
|
|
1139
|
+
return value;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
function profileCountContainer(value: unknown): ProfileMetric {
|
|
1143
|
+
if (value === undefined || value === null) {
|
|
1144
|
+
return unavailableProfileMetric("not-exposed");
|
|
1145
|
+
}
|
|
1146
|
+
if (!isRecord(value)) return unavailableProfileMetric("provider-drift");
|
|
1147
|
+
return exactProfileMetric(value.count);
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
function canonicalMetaProfileHandle(
|
|
1151
|
+
value: unknown,
|
|
1152
|
+
label: string,
|
|
1153
|
+
maximum: number,
|
|
1154
|
+
): string {
|
|
1155
|
+
const handle = boundedString(value, label, maximum).toLowerCase();
|
|
1156
|
+
if (!/^[a-z0-9._]+$/u.test(handle)) {
|
|
1157
|
+
throw new Error(`${label} must be one canonical profile handle`);
|
|
1158
|
+
}
|
|
1159
|
+
return handle;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
function optionalPublicProfileUrl(value: unknown, label: string): string | null {
|
|
1163
|
+
const candidate = optionalString(value, label, 2_048);
|
|
1164
|
+
if (candidate === null) return null;
|
|
1165
|
+
let parsed: URL;
|
|
1166
|
+
try {
|
|
1167
|
+
parsed = new URL(candidate);
|
|
1168
|
+
} catch {
|
|
1169
|
+
throw new Error(`${label} must be an absolute public URL`);
|
|
1170
|
+
}
|
|
1171
|
+
if (
|
|
1172
|
+
(parsed.protocol !== "https:" && parsed.protocol !== "http:")
|
|
1173
|
+
|| parsed.username !== ""
|
|
1174
|
+
|| parsed.password !== ""
|
|
1175
|
+
) throw new Error(`${label} must be a credential-free public HTTP URL`);
|
|
1176
|
+
return parsed.href;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
function firstProfileBioLink(value: unknown, label: string): string | null {
|
|
1180
|
+
if (value === undefined || value === null) return null;
|
|
1181
|
+
if (!Array.isArray(value) || value.length > 5) {
|
|
1182
|
+
throw new Error(`${label} must be a bounded array`);
|
|
1183
|
+
}
|
|
1184
|
+
for (const [index, item] of value.entries()) {
|
|
1185
|
+
if (!isRecord(item)) throw new Error(`${label}[${index}] must be an object`);
|
|
1186
|
+
const url = optionalPublicProfileUrl(item.url, `${label}[${index}].url`);
|
|
1187
|
+
if (url !== null) return url;
|
|
1188
|
+
}
|
|
1189
|
+
return null;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/** Project the exact signed-in Instagram profile-info response. */
|
|
1193
|
+
export function normalizeInstagramProfileStats(
|
|
1194
|
+
value: unknown,
|
|
1195
|
+
expectedViewerId: string,
|
|
1196
|
+
expectedProfile: string,
|
|
1197
|
+
observedAt: string,
|
|
1198
|
+
): Readonly<Record<string, unknown>> {
|
|
1199
|
+
if (!isCanonicalMetaNumericId(expectedViewerId)) {
|
|
1200
|
+
throw new Error("Instagram profile expected viewer ID is invalid");
|
|
1201
|
+
}
|
|
1202
|
+
const profile = canonicalMetaProfileHandle(
|
|
1203
|
+
expectedProfile,
|
|
1204
|
+
"Instagram profile target",
|
|
1205
|
+
30,
|
|
1206
|
+
);
|
|
1207
|
+
const root = record(value, "Instagram profile response");
|
|
1208
|
+
if (root.status !== "ok") throw new Error("Instagram profile response status changed");
|
|
1209
|
+
const data = record(root.data, "Instagram profile response.data");
|
|
1210
|
+
const user = record(data.user, "Instagram profile response.data.user");
|
|
1211
|
+
const id = boundedString(user.id ?? user.pk, "Instagram profile response user ID", 32);
|
|
1212
|
+
if (!isCanonicalMetaNumericId(id) || id !== expectedViewerId) {
|
|
1213
|
+
throw new Error("Instagram profile response did not bind the current viewer ID");
|
|
1214
|
+
}
|
|
1215
|
+
const handle = canonicalMetaProfileHandle(
|
|
1216
|
+
user.username,
|
|
1217
|
+
"Instagram profile response username",
|
|
1218
|
+
30,
|
|
1219
|
+
);
|
|
1220
|
+
if (handle !== profile) {
|
|
1221
|
+
throw new Error("Instagram profile response did not bind the requested handle");
|
|
1222
|
+
}
|
|
1223
|
+
const followers = profileCountContainer(user.edge_followed_by);
|
|
1224
|
+
const following = profileCountContainer(user.edge_follow);
|
|
1225
|
+
const posts = profileCountContainer(user.edge_owner_to_timeline_media);
|
|
1226
|
+
const displayName = optionalString(user.full_name, "Instagram profile full_name", 256);
|
|
1227
|
+
const bio = optionalString(user.biography, "Instagram profile biography", 2_048);
|
|
1228
|
+
const websiteUrl = optionalPublicProfileUrl(
|
|
1229
|
+
user.external_url,
|
|
1230
|
+
"Instagram profile external_url",
|
|
1231
|
+
);
|
|
1232
|
+
const complete = [followers, following, posts]
|
|
1233
|
+
.every((metric) => metric.status === "available");
|
|
1234
|
+
return Object.freeze({
|
|
1235
|
+
schemaVersion: 1,
|
|
1236
|
+
provider: "instagram",
|
|
1237
|
+
target: Object.freeze({
|
|
1238
|
+
kind: "profile",
|
|
1239
|
+
id,
|
|
1240
|
+
url: `https://www.instagram.com/${handle}/`,
|
|
1241
|
+
}),
|
|
1242
|
+
observedAt: exactProfileObservedAt(observedAt),
|
|
1243
|
+
completeness: complete ? "complete" : "partial",
|
|
1244
|
+
metrics: Object.freeze({ followers, following, posts }),
|
|
1245
|
+
metadata: Object.freeze({
|
|
1246
|
+
handle,
|
|
1247
|
+
...(displayName === null ? {} : { displayName }),
|
|
1248
|
+
...(bio === null ? {} : { bio }),
|
|
1249
|
+
...(websiteUrl === null ? {} : { websiteUrl }),
|
|
1250
|
+
}),
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
type ThreadsProfileCandidate = Readonly<{
|
|
1255
|
+
id: string;
|
|
1256
|
+
handle: string;
|
|
1257
|
+
displayName: string | null;
|
|
1258
|
+
bio: string | null;
|
|
1259
|
+
websiteUrl: string | null;
|
|
1260
|
+
followers: ProfileMetric;
|
|
1261
|
+
}>;
|
|
1262
|
+
|
|
1263
|
+
function threadsProfileCandidate(
|
|
1264
|
+
value: JsonRecord,
|
|
1265
|
+
expectedProfile: string,
|
|
1266
|
+
): ThreadsProfileCandidate | null {
|
|
1267
|
+
if (value.follower_count === undefined || value.username === undefined) return null;
|
|
1268
|
+
const handle = canonicalMetaProfileHandle(
|
|
1269
|
+
value.username,
|
|
1270
|
+
"Threads profile username",
|
|
1271
|
+
30,
|
|
1272
|
+
);
|
|
1273
|
+
if (handle !== expectedProfile) return null;
|
|
1274
|
+
const id = boundedString(value.pk ?? value.id, "Threads profile ID", 32);
|
|
1275
|
+
if (!isCanonicalMetaNumericId(id)) throw new Error("Threads profile ID is invalid");
|
|
1276
|
+
return Object.freeze({
|
|
1277
|
+
id,
|
|
1278
|
+
handle,
|
|
1279
|
+
displayName: optionalString(value.full_name, "Threads profile full_name", 256),
|
|
1280
|
+
bio: optionalString(value.biography, "Threads profile biography", 2_048),
|
|
1281
|
+
websiteUrl: firstProfileBioLink(value.bio_links, "Threads profile bio_links"),
|
|
1282
|
+
followers: exactProfileMetric(value.follower_count),
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
function sameThreadsProfile(
|
|
1287
|
+
left: ThreadsProfileCandidate,
|
|
1288
|
+
right: ThreadsProfileCandidate,
|
|
1289
|
+
): boolean {
|
|
1290
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
/** Project one exact signed-in Threads profile preload. */
|
|
1294
|
+
export function normalizeThreadsProfileStats(
|
|
1295
|
+
html: unknown,
|
|
1296
|
+
expectedViewerId: string,
|
|
1297
|
+
expectedProfile: string,
|
|
1298
|
+
recentViews: ProfileMetric,
|
|
1299
|
+
observedAt: string,
|
|
1300
|
+
): Readonly<Record<string, unknown>> {
|
|
1301
|
+
if (!isCanonicalMetaNumericId(expectedViewerId)) {
|
|
1302
|
+
throw new Error("Threads profile expected viewer ID is invalid");
|
|
1303
|
+
}
|
|
1304
|
+
const profile = canonicalMetaProfileHandle(expectedProfile, "Threads profile target", 30);
|
|
1305
|
+
if (parseThreadsViewerId(html) !== expectedViewerId) {
|
|
1306
|
+
throw new Error("Threads profile response changed its bound viewer");
|
|
1307
|
+
}
|
|
1308
|
+
const candidates: ThreadsProfileCandidate[] = [];
|
|
1309
|
+
walk(parseMetaJsonScripts(html), (candidate) => {
|
|
1310
|
+
if (!isRecord(candidate)) return;
|
|
1311
|
+
const projected = threadsProfileCandidate(candidate, profile);
|
|
1312
|
+
if (projected !== null) candidates.push(projected);
|
|
1313
|
+
});
|
|
1314
|
+
if (candidates.length < 1) throw new Error("Threads profile preload omitted the requested profile");
|
|
1315
|
+
const first = candidates[0]!;
|
|
1316
|
+
if (candidates.some((candidate) => !sameThreadsProfile(first, candidate))) {
|
|
1317
|
+
throw new Error("Threads profile preload returned conflicting profile projections");
|
|
1318
|
+
}
|
|
1319
|
+
if (first.id !== expectedViewerId) {
|
|
1320
|
+
throw new Error("Threads profile response did not bind the current viewer ID");
|
|
1321
|
+
}
|
|
1322
|
+
// The signed-in Insights UI exposes the exact eligibility rule: metrics remain
|
|
1323
|
+
// unavailable until the profile reaches 100 followers. The direct HTML shell
|
|
1324
|
+
// can omit that client-rendered sentence, so the bound exact follower count
|
|
1325
|
+
// proves the same current-account authorization state below the threshold.
|
|
1326
|
+
const boundRecentViews = recentViews.status === "unavailable"
|
|
1327
|
+
&& recentViews.reason === "provider-drift"
|
|
1328
|
+
&& first.followers.status === "available"
|
|
1329
|
+
&& first.followers.value < 100
|
|
1330
|
+
? unavailableProfileMetric("not-authorized")
|
|
1331
|
+
: recentViews;
|
|
1332
|
+
const complete = first.followers.status === "available"
|
|
1333
|
+
&& boundRecentViews.status === "available";
|
|
1334
|
+
return Object.freeze({
|
|
1335
|
+
schemaVersion: 1,
|
|
1336
|
+
provider: "threads",
|
|
1337
|
+
target: Object.freeze({
|
|
1338
|
+
kind: "profile",
|
|
1339
|
+
id: first.id,
|
|
1340
|
+
url: `https://www.threads.com/@${first.handle}`,
|
|
1341
|
+
}),
|
|
1342
|
+
observedAt: exactProfileObservedAt(observedAt),
|
|
1343
|
+
completeness: complete ? "complete" : "partial",
|
|
1344
|
+
metrics: Object.freeze({ followers: first.followers, recentViews: boundRecentViews }),
|
|
1345
|
+
metadata: Object.freeze({
|
|
1346
|
+
handle: first.handle,
|
|
1347
|
+
...(first.displayName === null ? {} : { displayName: first.displayName }),
|
|
1348
|
+
...(first.bio === null ? {} : { bio: first.bio }),
|
|
1349
|
+
...(first.websiteUrl === null ? {} : { websiteUrl: first.websiteUrl }),
|
|
1350
|
+
}),
|
|
1351
|
+
});
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
/** Current account-specific Threads Insights availability without DOM parsing. */
|
|
1355
|
+
export function normalizeThreadsRecentViewsAvailability(html: unknown): ProfileMetric {
|
|
1356
|
+
const source = boundedString(html, "Threads Insights response", 12 * 1024 * 1024);
|
|
1357
|
+
const normalized = source
|
|
1358
|
+
.replaceAll("\\u0027", "'")
|
|
1359
|
+
.replaceAll("\\u2019", "'")
|
|
1360
|
+
.replaceAll("’", "'")
|
|
1361
|
+
.replaceAll("'", "'")
|
|
1362
|
+
.replaceAll("'", "'")
|
|
1363
|
+
.replaceAll("'", "'");
|
|
1364
|
+
if (normalized.includes(
|
|
1365
|
+
"Check back in once you've reached 100 followers to see your insights.",
|
|
1366
|
+
)) return unavailableProfileMetric("not-authorized");
|
|
1367
|
+
return unavailableProfileMetric("provider-drift");
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
function instagramUser(value: unknown, label: string): Readonly<Record<string, unknown>> | null {
|
|
1371
|
+
if (value === undefined || value === null) return null;
|
|
1372
|
+
const user = record(value, label);
|
|
1373
|
+
const id = optionalString(user.pk ?? user.id, `${label}.id`, 32);
|
|
1374
|
+
if (id !== null && !isCanonicalMetaNumericId(id)) {
|
|
1375
|
+
throw new Error(`${label}.id must be a canonical decimal account ID`);
|
|
1376
|
+
}
|
|
1377
|
+
return Object.freeze({
|
|
1378
|
+
id,
|
|
1379
|
+
username: optionalString(user.username, `${label}.username`, 64),
|
|
1380
|
+
full_name: optionalString(user.full_name, `${label}.full_name`, 256),
|
|
1381
|
+
});
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
function instagramMedia(value: unknown, label: string): Readonly<Record<string, unknown>> {
|
|
1385
|
+
const media = record(value, label);
|
|
1386
|
+
const id = boundedString(media.id, `${label}.id`, 80);
|
|
1387
|
+
if (!/^[0-9]{1,32}(?:_[0-9]{1,32})?$/u.test(id)) throw new Error(`${label}.id must be an exact Instagram media ID`);
|
|
1388
|
+
const caption = media.caption === undefined || media.caption === null
|
|
1389
|
+
? null
|
|
1390
|
+
: optionalString(record(media.caption, `${label}.caption`).text, `${label}.caption.text`, 10_000);
|
|
1391
|
+
return Object.freeze({
|
|
1392
|
+
id,
|
|
1393
|
+
pk: optionalString(media.pk, `${label}.pk`, 32),
|
|
1394
|
+
code: optionalString(media.code, `${label}.code`, 64),
|
|
1395
|
+
media_type: optionalInteger(media.media_type, `${label}.media_type`),
|
|
1396
|
+
taken_at: optionalInteger(media.taken_at, `${label}.taken_at`),
|
|
1397
|
+
caption,
|
|
1398
|
+
user: instagramUser(media.user ?? media.owner, `${label}.user`),
|
|
1399
|
+
has_liked: optionalBoolean(media.has_liked, `${label}.has_liked`),
|
|
1400
|
+
has_viewer_saved: optionalBoolean(
|
|
1401
|
+
media.has_viewer_saved ?? media.has_privately_liked,
|
|
1402
|
+
`${label}.has_viewer_saved`,
|
|
1403
|
+
),
|
|
1404
|
+
like_count: optionalInteger(media.like_count, `${label}.like_count`),
|
|
1405
|
+
comment_count: optionalInteger(media.comment_count, `${label}.comment_count`),
|
|
1406
|
+
});
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
function okInstagramEnvelope(value: unknown, label: string): JsonRecord {
|
|
1410
|
+
const envelope = record(value, label);
|
|
1411
|
+
if (envelope.status !== "ok") throw new Error(`${label}.status must be ok`);
|
|
1412
|
+
return envelope;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
export function normalizeInstagramFeed(value: unknown, limit: number): unknown {
|
|
1416
|
+
const envelope = okInstagramEnvelope(value, "Instagram timeline response");
|
|
1417
|
+
if (!Array.isArray(envelope.feed_items) || envelope.feed_items.length > 500) {
|
|
1418
|
+
throw new Error("Instagram timeline response.feed_items must be a bounded array");
|
|
1419
|
+
}
|
|
1420
|
+
const items: unknown[] = [];
|
|
1421
|
+
for (const [index, itemValue] of envelope.feed_items.entries()) {
|
|
1422
|
+
const item = record(itemValue, `Instagram timeline response.feed_items[${index}]`);
|
|
1423
|
+
if (item.media_or_ad === undefined) continue;
|
|
1424
|
+
items.push(instagramMedia(item.media_or_ad, `Instagram timeline media[${index}]`));
|
|
1425
|
+
if (items.length >= limit) break;
|
|
1426
|
+
}
|
|
1427
|
+
optionalString(envelope.next_max_id, "Instagram timeline response.next_max_id", 4096);
|
|
1428
|
+
optionalBoolean(envelope.more_available, "Instagram timeline response.more_available");
|
|
1429
|
+
return Object.freeze({
|
|
1430
|
+
feed: "home",
|
|
1431
|
+
items: Object.freeze(items),
|
|
1432
|
+
page_scope: "first-page-only",
|
|
1433
|
+
continuation_supported: false,
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
export function normalizeInstagramPost(value: unknown, mediaId: string): unknown {
|
|
1438
|
+
const envelope = okInstagramEnvelope(value, "Instagram media-info response");
|
|
1439
|
+
if (!Array.isArray(envelope.items) || envelope.items.length !== 1) {
|
|
1440
|
+
throw new Error("Instagram media-info response must contain exactly one item");
|
|
1441
|
+
}
|
|
1442
|
+
const item = instagramMedia(envelope.items[0], "Instagram media-info item");
|
|
1443
|
+
if (item.id !== mediaId) throw new Error("Instagram media-info response did not bind the requested media");
|
|
1444
|
+
return item;
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
export function normalizeInstagramComments(value: unknown, mediaId: string, limit: number): unknown {
|
|
1448
|
+
const envelope = okInstagramEnvelope(value, "Instagram comments response");
|
|
1449
|
+
const caption = record(envelope.caption, "Instagram comments response.caption");
|
|
1450
|
+
const captionMediaId = boundedString(caption.media_id, "Instagram comments response.caption.media_id", 80);
|
|
1451
|
+
const bare = mediaId.split("_", 1)[0] ?? mediaId;
|
|
1452
|
+
if (captionMediaId !== mediaId && captionMediaId !== bare) {
|
|
1453
|
+
throw new Error("Instagram comments response did not bind the requested media");
|
|
1454
|
+
}
|
|
1455
|
+
if (!Array.isArray(envelope.comments) || envelope.comments.length > 10_000) {
|
|
1456
|
+
throw new Error("Instagram comments response.comments must be a bounded array");
|
|
1457
|
+
}
|
|
1458
|
+
const comments = envelope.comments.slice(0, limit).map((value, index) => {
|
|
1459
|
+
const comment = record(value, `Instagram comments response.comments[${index}]`);
|
|
1460
|
+
const id = boundedString(comment.pk ?? comment.id, `Instagram comments response.comments[${index}].id`, 80);
|
|
1461
|
+
return Object.freeze({
|
|
1462
|
+
id,
|
|
1463
|
+
text: boundedString(comment.text, `Instagram comments response.comments[${index}].text`, 10_000, true),
|
|
1464
|
+
created_at: optionalInteger(comment.created_at, `Instagram comments response.comments[${index}].created_at`),
|
|
1465
|
+
parent_comment_id: optionalString(
|
|
1466
|
+
comment.parent_comment_id,
|
|
1467
|
+
`Instagram comments response.comments[${index}].parent_comment_id`,
|
|
1468
|
+
80,
|
|
1469
|
+
),
|
|
1470
|
+
user: instagramUser(comment.user, `Instagram comments response.comments[${index}].user`),
|
|
1471
|
+
has_liked_comment: optionalBoolean(
|
|
1472
|
+
comment.has_liked_comment,
|
|
1473
|
+
`Instagram comments response.comments[${index}].has_liked_comment`,
|
|
1474
|
+
),
|
|
1475
|
+
comment_like_count: optionalInteger(
|
|
1476
|
+
comment.comment_like_count,
|
|
1477
|
+
`Instagram comments response.comments[${index}].comment_like_count`,
|
|
1478
|
+
),
|
|
1479
|
+
});
|
|
1480
|
+
});
|
|
1481
|
+
optionalString(
|
|
1482
|
+
envelope.next_min_id ?? envelope.next_max_id,
|
|
1483
|
+
"Instagram comments response.next_cursor",
|
|
1484
|
+
4096,
|
|
1485
|
+
);
|
|
1486
|
+
optionalBoolean(
|
|
1487
|
+
envelope.has_more_comments ?? envelope.has_more_headload_comments,
|
|
1488
|
+
"Instagram comments response.has_more",
|
|
1489
|
+
);
|
|
1490
|
+
return Object.freeze({
|
|
1491
|
+
media_id: mediaId,
|
|
1492
|
+
comments: Object.freeze(comments),
|
|
1493
|
+
page_scope: "first-page-only",
|
|
1494
|
+
continuation_supported: false,
|
|
1495
|
+
});
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
export function normalizeInstagramInbox(value: unknown, viewerId: string, limit: number): unknown {
|
|
1499
|
+
if (!isCanonicalMetaNumericId(viewerId)) {
|
|
1500
|
+
throw new Error("Instagram inbox viewer ID must be a canonical decimal account ID");
|
|
1501
|
+
}
|
|
1502
|
+
const envelope = okInstagramEnvelope(value, "Instagram inbox response");
|
|
1503
|
+
const viewer = record(envelope.viewer, "Instagram inbox response.viewer");
|
|
1504
|
+
const responseViewerId = boundedString(viewer.pk ?? viewer.id, "Instagram inbox response.viewer.id", 32);
|
|
1505
|
+
if (!isCanonicalMetaNumericId(responseViewerId)) {
|
|
1506
|
+
throw new Error("Instagram inbox response.viewer.id must be a canonical decimal account ID");
|
|
1507
|
+
}
|
|
1508
|
+
if (responseViewerId !== viewerId) {
|
|
1509
|
+
throw new Error("Instagram inbox response changed its bound viewer");
|
|
1510
|
+
}
|
|
1511
|
+
const inbox = record(envelope.inbox, "Instagram inbox response.inbox");
|
|
1512
|
+
if (!Array.isArray(inbox.threads) || inbox.threads.length > 1_000) {
|
|
1513
|
+
throw new Error("Instagram inbox response.inbox.threads must be a bounded array");
|
|
1514
|
+
}
|
|
1515
|
+
const rawThreadCount = inbox.threads.length;
|
|
1516
|
+
const threads = inbox.threads.slice(0, limit).map((value, index) => {
|
|
1517
|
+
const thread = record(value, `Instagram inbox response.inbox.threads[${index}]`);
|
|
1518
|
+
if (thread.users !== undefined && !Array.isArray(thread.users)) {
|
|
1519
|
+
throw new Error(`Instagram inbox thread[${index}].users must be a bounded array`);
|
|
1520
|
+
}
|
|
1521
|
+
if (Array.isArray(thread.users) && thread.users.length > 100) {
|
|
1522
|
+
throw new Error(`Instagram inbox thread[${index}].users exceeded its reviewed bound`);
|
|
1523
|
+
}
|
|
1524
|
+
const users = Array.isArray(thread.users)
|
|
1525
|
+
? thread.users.map((user, userIndex) =>
|
|
1526
|
+
instagramUser(user, `Instagram inbox thread[${index}].users[${userIndex}]`))
|
|
1527
|
+
: [];
|
|
1528
|
+
return Object.freeze({
|
|
1529
|
+
thread_id: boundedString(thread.thread_id, `Instagram inbox thread[${index}].thread_id`, 512),
|
|
1530
|
+
thread_title: optionalString(thread.thread_title, `Instagram inbox thread[${index}].thread_title`, 512),
|
|
1531
|
+
users: Object.freeze(users),
|
|
1532
|
+
last_activity_at: optionalInteger(thread.last_activity_at, `Instagram inbox thread[${index}].last_activity_at`),
|
|
1533
|
+
read_state: optionalInteger(thread.read_state, `Instagram inbox thread[${index}].read_state`),
|
|
1534
|
+
pending: optionalBoolean(thread.pending, `Instagram inbox thread[${index}].pending`),
|
|
1535
|
+
});
|
|
1536
|
+
});
|
|
1537
|
+
const providerCursor = optionalString(
|
|
1538
|
+
inbox.oldest_cursor ?? inbox.next_cursor,
|
|
1539
|
+
"Instagram inbox next cursor",
|
|
1540
|
+
4096,
|
|
1541
|
+
);
|
|
1542
|
+
const providerHasOlder = optionalBoolean(inbox.has_older, "Instagram inbox has_older") ?? false;
|
|
1543
|
+
return Object.freeze({
|
|
1544
|
+
folder: "inbox",
|
|
1545
|
+
threads: Object.freeze(threads),
|
|
1546
|
+
page_scope: "first-page-only",
|
|
1547
|
+
continuation_supported: false,
|
|
1548
|
+
raw_thread_count: rawThreadCount,
|
|
1549
|
+
provider_has_older: providerHasOlder,
|
|
1550
|
+
provider_cursor_present: providerCursor !== null,
|
|
1551
|
+
pending_requests_total: optionalInteger(envelope.pending_requests_total, "Instagram inbox pending_requests_total"),
|
|
1552
|
+
});
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
export function normalizeInstagramContacts(
|
|
1556
|
+
value: unknown,
|
|
1557
|
+
viewerId: string,
|
|
1558
|
+
threadLimit: number,
|
|
1559
|
+
contactLimit: number,
|
|
1560
|
+
): unknown {
|
|
1561
|
+
const inbox = record(
|
|
1562
|
+
normalizeInstagramInbox(value, viewerId, threadLimit),
|
|
1563
|
+
"Instagram normalized inbox",
|
|
1564
|
+
);
|
|
1565
|
+
if (!Array.isArray(inbox.threads)) {
|
|
1566
|
+
throw new Error("Instagram normalized inbox omitted its bounded threads");
|
|
1567
|
+
}
|
|
1568
|
+
const rawThreadCount = optionalInteger(
|
|
1569
|
+
inbox.raw_thread_count,
|
|
1570
|
+
"Instagram normalized inbox.raw_thread_count",
|
|
1571
|
+
);
|
|
1572
|
+
const providerHasOlder = optionalBoolean(
|
|
1573
|
+
inbox.provider_has_older,
|
|
1574
|
+
"Instagram normalized inbox.provider_has_older",
|
|
1575
|
+
);
|
|
1576
|
+
const providerCursorPresent = optionalBoolean(
|
|
1577
|
+
inbox.provider_cursor_present,
|
|
1578
|
+
"Instagram normalized inbox.provider_cursor_present",
|
|
1579
|
+
);
|
|
1580
|
+
if (
|
|
1581
|
+
rawThreadCount === null
|
|
1582
|
+
|| providerHasOlder === null
|
|
1583
|
+
|| providerCursorPresent === null
|
|
1584
|
+
) {
|
|
1585
|
+
throw new Error("Instagram normalized inbox omitted its pagination evidence");
|
|
1586
|
+
}
|
|
1587
|
+
const byId = new Map<string, Readonly<Record<string, unknown>>>();
|
|
1588
|
+
for (const [threadIndex, threadValue] of inbox.threads.entries()) {
|
|
1589
|
+
const thread = record(
|
|
1590
|
+
threadValue,
|
|
1591
|
+
`Instagram normalized inbox.threads[${threadIndex}]`,
|
|
1592
|
+
);
|
|
1593
|
+
if (!Array.isArray(thread.users)) {
|
|
1594
|
+
throw new Error(
|
|
1595
|
+
`Instagram normalized inbox.threads[${threadIndex}].users must be a bounded array`,
|
|
1596
|
+
);
|
|
1597
|
+
}
|
|
1598
|
+
for (const [userIndex, userValue] of thread.users.entries()) {
|
|
1599
|
+
const user = record(
|
|
1600
|
+
userValue,
|
|
1601
|
+
`Instagram normalized inbox.threads[${threadIndex}].users[${userIndex}]`,
|
|
1602
|
+
);
|
|
1603
|
+
const id = boundedString(
|
|
1604
|
+
user.id,
|
|
1605
|
+
`Instagram normalized inbox.threads[${threadIndex}].users[${userIndex}].id`,
|
|
1606
|
+
32,
|
|
1607
|
+
);
|
|
1608
|
+
if (id === viewerId || byId.has(id)) continue;
|
|
1609
|
+
byId.set(id, Object.freeze({
|
|
1610
|
+
providerId: id,
|
|
1611
|
+
displayName: optionalString(
|
|
1612
|
+
user.full_name,
|
|
1613
|
+
`Instagram normalized inbox.threads[${threadIndex}].users[${userIndex}].full_name`,
|
|
1614
|
+
256,
|
|
1615
|
+
),
|
|
1616
|
+
handle: optionalString(
|
|
1617
|
+
user.username,
|
|
1618
|
+
`Instagram normalized inbox.threads[${threadIndex}].users[${userIndex}].username`,
|
|
1619
|
+
64,
|
|
1620
|
+
),
|
|
1621
|
+
...unavailableInstagramContactStatsProjection,
|
|
1622
|
+
}));
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
const allContacts = [...byId.values()];
|
|
1626
|
+
const threadLimitReached = rawThreadCount > threadLimit;
|
|
1627
|
+
const contactLimitReached = allContacts.length > contactLimit;
|
|
1628
|
+
const contactSetIncompleteReasons = [
|
|
1629
|
+
"first-inbox-page-only",
|
|
1630
|
+
...(providerHasOlder ? ["provider-has-older"] : []),
|
|
1631
|
+
...(providerCursorPresent ? ["provider-cursor-present"] : []),
|
|
1632
|
+
...(threadLimitReached ? ["thread-limit-reached"] : []),
|
|
1633
|
+
...(contactLimitReached ? ["contact-limit-reached"] : []),
|
|
1634
|
+
] as const;
|
|
1635
|
+
const contactTruncated = providerHasOlder
|
|
1636
|
+
|| providerCursorPresent
|
|
1637
|
+
|| threadLimitReached
|
|
1638
|
+
|| contactLimitReached;
|
|
1639
|
+
return Object.freeze({
|
|
1640
|
+
provider: "instagram",
|
|
1641
|
+
operation: "contacts.list",
|
|
1642
|
+
accountSubject: `instagram:${viewerId}`,
|
|
1643
|
+
contacts: Object.freeze(allContacts.slice(0, contactLimit)),
|
|
1644
|
+
metadataScope: "first-page-inbox-participant-summary",
|
|
1645
|
+
contactSetCompleteness: "first-page-only",
|
|
1646
|
+
contactSetIncompleteReasons: Object.freeze(contactSetIncompleteReasons),
|
|
1647
|
+
contactTruncated,
|
|
1648
|
+
statsScope: "unavailable-without-acknowledgement-free-message-history",
|
|
1649
|
+
});
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
export type ThreadsImageProjection = Readonly<{
|
|
1653
|
+
candidateCount: number;
|
|
1654
|
+
height: number;
|
|
1655
|
+
mediaId: string;
|
|
1656
|
+
mediaType: 1;
|
|
1657
|
+
width: number;
|
|
1658
|
+
}>;
|
|
1659
|
+
|
|
1660
|
+
export type ThreadsVideoProjection = Readonly<{
|
|
1661
|
+
candidateCount: number;
|
|
1662
|
+
durationSeconds: number | null;
|
|
1663
|
+
hasAudio: boolean | null;
|
|
1664
|
+
height: number;
|
|
1665
|
+
mediaId: string;
|
|
1666
|
+
mediaType: 2;
|
|
1667
|
+
width: number;
|
|
1668
|
+
}>;
|
|
1669
|
+
|
|
1670
|
+
export type ThreadsPostProjection = Readonly<{
|
|
1671
|
+
id: string;
|
|
1672
|
+
code: string | null;
|
|
1673
|
+
canonical_url: string | null;
|
|
1674
|
+
caption: string | null;
|
|
1675
|
+
user: Readonly<Record<string, unknown>> | null;
|
|
1676
|
+
taken_at: number | null;
|
|
1677
|
+
has_liked: boolean | null;
|
|
1678
|
+
has_viewer_saved: boolean | null;
|
|
1679
|
+
like_count: number | null;
|
|
1680
|
+
image: ThreadsImageProjection | null;
|
|
1681
|
+
}>;
|
|
1682
|
+
|
|
1683
|
+
export type ThreadsVideoPostProjection = Readonly<{
|
|
1684
|
+
id: string;
|
|
1685
|
+
code: string | null;
|
|
1686
|
+
canonical_url: string | null;
|
|
1687
|
+
caption: string | null;
|
|
1688
|
+
user: Readonly<Record<string, unknown>> | null;
|
|
1689
|
+
taken_at: number | null;
|
|
1690
|
+
has_liked: boolean | null;
|
|
1691
|
+
has_viewer_saved: boolean | null;
|
|
1692
|
+
like_count: number | null;
|
|
1693
|
+
video: ThreadsVideoProjection | null;
|
|
1694
|
+
}>;
|
|
1695
|
+
|
|
1696
|
+
function positiveThreadsImageDimension(value: unknown, label: string): number {
|
|
1697
|
+
if (!Number.isSafeInteger(value) || (value as number) < 1 || (value as number) > 20_000) {
|
|
1698
|
+
throw new Error(`${label} must be an integer between 1 and 20000`);
|
|
1699
|
+
}
|
|
1700
|
+
return value as number;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
function threadsImageCandidateUrl(value: unknown, label: string): string {
|
|
1704
|
+
const source = boundedString(value, label, 16_384);
|
|
1705
|
+
let url: URL;
|
|
1706
|
+
try {
|
|
1707
|
+
url = new URL(source);
|
|
1708
|
+
} catch {
|
|
1709
|
+
throw new Error(`${label} must be an exact reviewed HTTPS media URL`);
|
|
1710
|
+
}
|
|
1711
|
+
const hostname = url.hostname.toLowerCase();
|
|
1712
|
+
if (
|
|
1713
|
+
url.protocol !== "https:"
|
|
1714
|
+
|| url.username !== ""
|
|
1715
|
+
|| url.password !== ""
|
|
1716
|
+
|| url.hash !== ""
|
|
1717
|
+
|| ![
|
|
1718
|
+
"cdninstagram.com",
|
|
1719
|
+
"fbcdn.net",
|
|
1720
|
+
"instagram.com",
|
|
1721
|
+
"threads.com",
|
|
1722
|
+
].some((family) => hostname === family || hostname.endsWith(`.${family}`))
|
|
1723
|
+
) throw new Error(`${label} left the reviewed Threads media host families`);
|
|
1724
|
+
return url.href;
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
function threadsImage(
|
|
1728
|
+
post: JsonRecord,
|
|
1729
|
+
mediaId: string,
|
|
1730
|
+
label: string,
|
|
1731
|
+
): ThreadsImageProjection | null {
|
|
1732
|
+
const hasImageFields = post.media_type !== undefined
|
|
1733
|
+
|| post.original_width !== undefined
|
|
1734
|
+
|| post.original_height !== undefined
|
|
1735
|
+
|| post.image_versions2 !== undefined;
|
|
1736
|
+
if (!hasImageFields) return null;
|
|
1737
|
+
if (post.media_type !== 1) {
|
|
1738
|
+
throw new Error(`${label}.media_type must identify one reviewed image`);
|
|
1739
|
+
}
|
|
1740
|
+
if (post.carousel_media !== undefined && post.carousel_media !== null) {
|
|
1741
|
+
throw new Error(`${label} unexpectedly returned carousel media`);
|
|
1742
|
+
}
|
|
1743
|
+
const width = positiveThreadsImageDimension(
|
|
1744
|
+
post.original_width,
|
|
1745
|
+
`${label}.original_width`,
|
|
1746
|
+
);
|
|
1747
|
+
const height = positiveThreadsImageDimension(
|
|
1748
|
+
post.original_height,
|
|
1749
|
+
`${label}.original_height`,
|
|
1750
|
+
);
|
|
1751
|
+
const versions = record(post.image_versions2, `${label}.image_versions2`);
|
|
1752
|
+
if (
|
|
1753
|
+
!Array.isArray(versions.candidates)
|
|
1754
|
+
|| versions.candidates.length < 1
|
|
1755
|
+
|| versions.candidates.length > 20
|
|
1756
|
+
) throw new Error(`${label}.image_versions2.candidates must contain 1 to 20 images`);
|
|
1757
|
+
let originalCandidateFound = false;
|
|
1758
|
+
for (const [index, candidateValue] of versions.candidates.entries()) {
|
|
1759
|
+
const candidate = record(
|
|
1760
|
+
candidateValue,
|
|
1761
|
+
`${label}.image_versions2.candidates[${index}]`,
|
|
1762
|
+
);
|
|
1763
|
+
const candidateWidth = positiveThreadsImageDimension(
|
|
1764
|
+
candidate.width,
|
|
1765
|
+
`${label}.image_versions2.candidates[${index}].width`,
|
|
1766
|
+
);
|
|
1767
|
+
const candidateHeight = positiveThreadsImageDimension(
|
|
1768
|
+
candidate.height,
|
|
1769
|
+
`${label}.image_versions2.candidates[${index}].height`,
|
|
1770
|
+
);
|
|
1771
|
+
threadsImageCandidateUrl(
|
|
1772
|
+
candidate.url,
|
|
1773
|
+
`${label}.image_versions2.candidates[${index}].url`,
|
|
1774
|
+
);
|
|
1775
|
+
if (candidateWidth === width && candidateHeight === height) {
|
|
1776
|
+
originalCandidateFound = true;
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
if (!originalCandidateFound) {
|
|
1780
|
+
throw new Error(`${label} omitted an exact original-dimension image candidate`);
|
|
1781
|
+
}
|
|
1782
|
+
return Object.freeze({
|
|
1783
|
+
candidateCount: versions.candidates.length,
|
|
1784
|
+
height,
|
|
1785
|
+
mediaId,
|
|
1786
|
+
mediaType: 1,
|
|
1787
|
+
width,
|
|
1788
|
+
});
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
function optionalThreadsVideoDuration(value: unknown, label: string): number | null {
|
|
1792
|
+
if (value === undefined || value === null) return null;
|
|
1793
|
+
if (
|
|
1794
|
+
typeof value !== "number"
|
|
1795
|
+
|| !Number.isFinite(value)
|
|
1796
|
+
|| value <= 0
|
|
1797
|
+
|| value > 3_600
|
|
1798
|
+
) throw new Error(`${label} must be a finite duration no longer than 3600 seconds`);
|
|
1799
|
+
return value;
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
function optionalThreadsVideoHasAudio(value: unknown, label: string): boolean | null {
|
|
1803
|
+
if (value === undefined || value === null) return null;
|
|
1804
|
+
if (typeof value !== "boolean") throw new Error(`${label} must be boolean when present`);
|
|
1805
|
+
return value;
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
function threadsVideo(
|
|
1809
|
+
post: JsonRecord,
|
|
1810
|
+
mediaId: string,
|
|
1811
|
+
label: string,
|
|
1812
|
+
): ThreadsVideoProjection | null {
|
|
1813
|
+
const hasVideoFields = post.media_type !== undefined
|
|
1814
|
+
|| post.original_width !== undefined
|
|
1815
|
+
|| post.original_height !== undefined
|
|
1816
|
+
|| post.video_versions !== undefined;
|
|
1817
|
+
if (!hasVideoFields) return null;
|
|
1818
|
+
if (post.media_type !== 2) {
|
|
1819
|
+
throw new Error(`${label}.media_type must identify one reviewed video`);
|
|
1820
|
+
}
|
|
1821
|
+
if (post.carousel_media !== undefined && post.carousel_media !== null) {
|
|
1822
|
+
throw new Error(`${label} unexpectedly returned carousel media`);
|
|
1823
|
+
}
|
|
1824
|
+
const width = positiveThreadsImageDimension(
|
|
1825
|
+
post.original_width,
|
|
1826
|
+
`${label}.original_width`,
|
|
1827
|
+
);
|
|
1828
|
+
const height = positiveThreadsImageDimension(
|
|
1829
|
+
post.original_height,
|
|
1830
|
+
`${label}.original_height`,
|
|
1831
|
+
);
|
|
1832
|
+
if (
|
|
1833
|
+
!Array.isArray(post.video_versions)
|
|
1834
|
+
|| post.video_versions.length < 1
|
|
1835
|
+
|| post.video_versions.length > 20
|
|
1836
|
+
) throw new Error(`${label}.video_versions must contain 1 to 20 videos`);
|
|
1837
|
+
let originalCandidateFound = false;
|
|
1838
|
+
for (const [index, candidateValue] of post.video_versions.entries()) {
|
|
1839
|
+
const candidate = record(candidateValue, `${label}.video_versions[${index}]`);
|
|
1840
|
+
const candidateWidth = positiveThreadsImageDimension(
|
|
1841
|
+
candidate.width,
|
|
1842
|
+
`${label}.video_versions[${index}].width`,
|
|
1843
|
+
);
|
|
1844
|
+
const candidateHeight = positiveThreadsImageDimension(
|
|
1845
|
+
candidate.height,
|
|
1846
|
+
`${label}.video_versions[${index}].height`,
|
|
1847
|
+
);
|
|
1848
|
+
threadsImageCandidateUrl(
|
|
1849
|
+
candidate.url,
|
|
1850
|
+
`${label}.video_versions[${index}].url`,
|
|
1851
|
+
);
|
|
1852
|
+
if (candidateWidth === width && candidateHeight === height) {
|
|
1853
|
+
originalCandidateFound = true;
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
if (!originalCandidateFound) {
|
|
1857
|
+
throw new Error(`${label} omitted an exact original-dimension video candidate`);
|
|
1858
|
+
}
|
|
1859
|
+
return Object.freeze({
|
|
1860
|
+
candidateCount: post.video_versions.length,
|
|
1861
|
+
durationSeconds: optionalThreadsVideoDuration(
|
|
1862
|
+
post.video_duration,
|
|
1863
|
+
`${label}.video_duration`,
|
|
1864
|
+
),
|
|
1865
|
+
hasAudio: optionalThreadsVideoHasAudio(post.has_audio, `${label}.has_audio`),
|
|
1866
|
+
height,
|
|
1867
|
+
mediaId,
|
|
1868
|
+
mediaType: 2,
|
|
1869
|
+
width,
|
|
1870
|
+
});
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
function threadsPost(value: unknown, label: string): Omit<ThreadsPostProjection, "image"> {
|
|
1874
|
+
const post = record(value, label);
|
|
1875
|
+
const id = boundedString(post.id ?? post.pk, `${label}.id`, 80);
|
|
1876
|
+
if (!/^[0-9]{1,32}(?:_[0-9]{1,32})?$/u.test(id)) throw new Error(`${label}.id must be an exact Threads post ID`);
|
|
1877
|
+
const caption = post.caption === undefined || post.caption === null
|
|
1878
|
+
? null
|
|
1879
|
+
: optionalString(record(post.caption, `${label}.caption`).text, `${label}.caption.text`, 10_000);
|
|
1880
|
+
return Object.freeze({
|
|
1881
|
+
id,
|
|
1882
|
+
code: optionalString(post.code, `${label}.code`, 64),
|
|
1883
|
+
canonical_url: optionalString(post.canonical_url, `${label}.canonical_url`, 2_048),
|
|
1884
|
+
caption,
|
|
1885
|
+
user: instagramUser(post.user, `${label}.user`),
|
|
1886
|
+
taken_at: optionalInteger(post.taken_at, `${label}.taken_at`),
|
|
1887
|
+
has_liked: optionalBoolean(post.has_liked, `${label}.has_liked`),
|
|
1888
|
+
has_viewer_saved: optionalBoolean(post.has_viewer_saved, `${label}.has_viewer_saved`),
|
|
1889
|
+
like_count: optionalInteger(post.like_count, `${label}.like_count`),
|
|
1890
|
+
});
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
export function projectThreadsPublishPost(
|
|
1894
|
+
value: unknown,
|
|
1895
|
+
label: string,
|
|
1896
|
+
): ThreadsPostProjection {
|
|
1897
|
+
const post = record(value, label);
|
|
1898
|
+
const projected = threadsPost(post, label);
|
|
1899
|
+
return Object.freeze({
|
|
1900
|
+
...projected,
|
|
1901
|
+
image: threadsImage(post, projected.id, label),
|
|
1902
|
+
});
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
export function projectThreadsPublishVideo(
|
|
1906
|
+
value: unknown,
|
|
1907
|
+
label: string,
|
|
1908
|
+
): ThreadsVideoPostProjection {
|
|
1909
|
+
const post = record(value, label);
|
|
1910
|
+
const projected = threadsPost(post, label);
|
|
1911
|
+
return Object.freeze({
|
|
1912
|
+
...projected,
|
|
1913
|
+
video: threadsVideo(post, projected.id, label),
|
|
1914
|
+
});
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
export function normalizeThreadsFeedHtml(html: unknown, viewerId: string, limit: number): unknown {
|
|
1918
|
+
const roots = parseMetaJsonScripts(html);
|
|
1919
|
+
if (parseThreadsViewerId(html) !== viewerId) throw new Error("Threads feed response changed its bound viewer");
|
|
1920
|
+
const posts: Readonly<Record<string, unknown>>[] = [];
|
|
1921
|
+
const seen = new Set<string>();
|
|
1922
|
+
walk(roots, (value) => {
|
|
1923
|
+
if (!isRecord(value) || !Array.isArray(value.edges) || posts.length >= limit) return;
|
|
1924
|
+
for (const edgeValue of value.edges) {
|
|
1925
|
+
if (posts.length >= limit || !isRecord(edgeValue)) break;
|
|
1926
|
+
const node = isRecord(edgeValue.node) ? edgeValue.node : null;
|
|
1927
|
+
const thread = node !== null && isRecord(node.text_post_app_thread)
|
|
1928
|
+
? node.text_post_app_thread
|
|
1929
|
+
: null;
|
|
1930
|
+
if (thread === null || !Array.isArray(thread.thread_items)) continue;
|
|
1931
|
+
for (const itemValue of thread.thread_items) {
|
|
1932
|
+
if (posts.length >= limit || !isRecord(itemValue) || !isRecord(itemValue.post)) break;
|
|
1933
|
+
const projected = threadsPost(itemValue.post, "Threads feed post");
|
|
1934
|
+
const id = projected.id;
|
|
1935
|
+
if (typeof id !== "string" || seen.has(id)) continue;
|
|
1936
|
+
seen.add(id);
|
|
1937
|
+
posts.push(projected);
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
});
|
|
1941
|
+
if (posts.length === 0) throw new Error("Threads Relay preload omitted a bounded feed");
|
|
1942
|
+
return Object.freeze({
|
|
1943
|
+
feed: "for-you",
|
|
1944
|
+
posts: Object.freeze(posts),
|
|
1945
|
+
page_scope: "first-page-only",
|
|
1946
|
+
continuation_supported: false,
|
|
1947
|
+
});
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
export function normalizeThreadsPostHtml(
|
|
1951
|
+
html: unknown,
|
|
1952
|
+
viewerId: string,
|
|
1953
|
+
expectedPostId: string,
|
|
1954
|
+
expectedCode: string,
|
|
1955
|
+
expectedUrl: string,
|
|
1956
|
+
expectedCaption: string,
|
|
1957
|
+
expectedImage: Readonly<{ readonly height: number; readonly width: number }> | null,
|
|
1958
|
+
): ThreadsPostProjection {
|
|
1959
|
+
if (!/^[0-9]{1,32}(?:_[0-9]{1,32})?$/u.test(expectedPostId)) {
|
|
1960
|
+
throw new Error("Threads readback expected post ID is invalid");
|
|
1961
|
+
}
|
|
1962
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/u.test(expectedCode)) {
|
|
1963
|
+
throw new Error("Threads readback expected post code is invalid");
|
|
1964
|
+
}
|
|
1965
|
+
let locator: URL;
|
|
1966
|
+
try {
|
|
1967
|
+
locator = new URL(expectedUrl);
|
|
1968
|
+
} catch {
|
|
1969
|
+
throw new Error("Threads readback expected permalink is invalid");
|
|
1970
|
+
}
|
|
1971
|
+
const locatorPath = locator.pathname.split("/");
|
|
1972
|
+
if (
|
|
1973
|
+
locator.origin !== "https://www.threads.com"
|
|
1974
|
+
|| locator.username !== ""
|
|
1975
|
+
|| locator.password !== ""
|
|
1976
|
+
|| locator.search !== ""
|
|
1977
|
+
|| locator.hash !== ""
|
|
1978
|
+
|| locatorPath.length !== 4
|
|
1979
|
+
|| !/^@[A-Za-z0-9._]{1,64}$/u.test(locatorPath[1] ?? "")
|
|
1980
|
+
|| locatorPath[2] !== "post"
|
|
1981
|
+
|| locatorPath[3] !== expectedCode
|
|
1982
|
+
) throw new Error("Threads readback expected permalink is invalid");
|
|
1983
|
+
if (parseThreadsViewerId(html) !== viewerId) {
|
|
1984
|
+
throw new Error("Threads post readback changed its bound viewer");
|
|
1985
|
+
}
|
|
1986
|
+
const matches: Readonly<Record<string, unknown>>[] = [];
|
|
1987
|
+
walk(parseMetaJsonScripts(html), (value) => {
|
|
1988
|
+
if (
|
|
1989
|
+
!isRecord(value)
|
|
1990
|
+
|| (value.id !== expectedPostId && value.pk !== expectedPostId)
|
|
1991
|
+
|| value.caption === undefined
|
|
1992
|
+
|| value.user === undefined
|
|
1993
|
+
) return;
|
|
1994
|
+
const projected = projectThreadsPublishPost(value, "Threads post readback");
|
|
1995
|
+
const user = isRecord(projected.user) ? projected.user : null;
|
|
1996
|
+
const imageMatches = expectedImage === null
|
|
1997
|
+
? projected.image === null
|
|
1998
|
+
: projected.image !== null
|
|
1999
|
+
&& projected.image.mediaId === expectedPostId
|
|
2000
|
+
&& projected.image.width === expectedImage.width
|
|
2001
|
+
&& projected.image.height === expectedImage.height;
|
|
2002
|
+
if (
|
|
2003
|
+
projected.caption === expectedCaption
|
|
2004
|
+
&& projected.code === expectedCode
|
|
2005
|
+
&& projected.canonical_url === locator.href
|
|
2006
|
+
&& user?.id === viewerId
|
|
2007
|
+
&& imageMatches
|
|
2008
|
+
) {
|
|
2009
|
+
matches.push(projected);
|
|
2010
|
+
}
|
|
2011
|
+
});
|
|
2012
|
+
if (matches.length < 1) {
|
|
2013
|
+
throw new Error(
|
|
2014
|
+
expectedImage === null
|
|
2015
|
+
? "Threads post readback did not bind the confirmed actor, ID, code, permalink, and text"
|
|
2016
|
+
: "Threads post readback did not bind the confirmed actor, ID, code, permalink, text, and image",
|
|
2017
|
+
);
|
|
2018
|
+
}
|
|
2019
|
+
if (matches.length !== 1) {
|
|
2020
|
+
throw new Error("Threads post readback returned an ambiguous exact post");
|
|
2021
|
+
}
|
|
2022
|
+
return matches[0] as ThreadsPostProjection;
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
export function normalizeThreadsVideoPostHtml(
|
|
2026
|
+
html: unknown,
|
|
2027
|
+
viewerId: string,
|
|
2028
|
+
expectedPostId: string,
|
|
2029
|
+
expectedCode: string,
|
|
2030
|
+
expectedUrl: string,
|
|
2031
|
+
expectedCaption: string,
|
|
2032
|
+
expectedVideo: Readonly<{ readonly height: number; readonly width: number }>,
|
|
2033
|
+
): ThreadsVideoPostProjection {
|
|
2034
|
+
if (!/^[0-9]{1,32}(?:_[0-9]{1,32})?$/u.test(expectedPostId)) {
|
|
2035
|
+
throw new Error("Threads video readback expected post ID is invalid");
|
|
2036
|
+
}
|
|
2037
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/u.test(expectedCode)) {
|
|
2038
|
+
throw new Error("Threads video readback expected post code is invalid");
|
|
2039
|
+
}
|
|
2040
|
+
let locator: URL;
|
|
2041
|
+
try {
|
|
2042
|
+
locator = new URL(expectedUrl);
|
|
2043
|
+
} catch {
|
|
2044
|
+
throw new Error("Threads video readback expected permalink is invalid");
|
|
2045
|
+
}
|
|
2046
|
+
const locatorPath = locator.pathname.split("/");
|
|
2047
|
+
if (
|
|
2048
|
+
locator.origin !== "https://www.threads.com"
|
|
2049
|
+
|| locator.username !== ""
|
|
2050
|
+
|| locator.password !== ""
|
|
2051
|
+
|| locator.search !== ""
|
|
2052
|
+
|| locator.hash !== ""
|
|
2053
|
+
|| locatorPath.length !== 4
|
|
2054
|
+
|| !/^@[A-Za-z0-9._]{1,64}$/u.test(locatorPath[1] ?? "")
|
|
2055
|
+
|| locatorPath[2] !== "post"
|
|
2056
|
+
|| locatorPath[3] !== expectedCode
|
|
2057
|
+
) throw new Error("Threads video readback expected permalink is invalid");
|
|
2058
|
+
if (parseThreadsViewerId(html) !== viewerId) {
|
|
2059
|
+
throw new Error("Threads video post readback changed its bound viewer");
|
|
2060
|
+
}
|
|
2061
|
+
const matches: ThreadsVideoPostProjection[] = [];
|
|
2062
|
+
walk(parseMetaJsonScripts(html), (value) => {
|
|
2063
|
+
if (
|
|
2064
|
+
!isRecord(value)
|
|
2065
|
+
|| (value.id !== expectedPostId && value.pk !== expectedPostId)
|
|
2066
|
+
|| value.caption === undefined
|
|
2067
|
+
|| value.user === undefined
|
|
2068
|
+
) return;
|
|
2069
|
+
const projected = projectThreadsPublishVideo(value, "Threads video post readback");
|
|
2070
|
+
const user = isRecord(projected.user) ? projected.user : null;
|
|
2071
|
+
if (
|
|
2072
|
+
projected.caption === expectedCaption
|
|
2073
|
+
&& projected.code === expectedCode
|
|
2074
|
+
&& projected.canonical_url === locator.href
|
|
2075
|
+
&& user?.id === viewerId
|
|
2076
|
+
&& projected.video !== null
|
|
2077
|
+
&& projected.video.mediaId === expectedPostId
|
|
2078
|
+
&& projected.video.width === expectedVideo.width
|
|
2079
|
+
&& projected.video.height === expectedVideo.height
|
|
2080
|
+
) matches.push(projected);
|
|
2081
|
+
});
|
|
2082
|
+
if (matches.length < 1) {
|
|
2083
|
+
throw new Error(
|
|
2084
|
+
"Threads video post readback did not bind the confirmed actor, ID, code, permalink, text, and video",
|
|
2085
|
+
);
|
|
2086
|
+
}
|
|
2087
|
+
if (matches.length !== 1) {
|
|
2088
|
+
throw new Error("Threads video post readback returned an ambiguous exact post");
|
|
2089
|
+
}
|
|
2090
|
+
return matches[0]!;
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
function facebookPost(value: unknown, label: string): Readonly<Record<string, unknown>> {
|
|
2094
|
+
const node = record(value, label);
|
|
2095
|
+
const id = boundedString(node.post_id ?? node.id, `${label}.id`, 256);
|
|
2096
|
+
const actors = Array.isArray(node.actors)
|
|
2097
|
+
? node.actors.slice(0, 20).map((actorValue, index) => {
|
|
2098
|
+
const actor = record(actorValue, `${label}.actors[${index}]`);
|
|
2099
|
+
return Object.freeze({
|
|
2100
|
+
id: optionalString(actor.id, `${label}.actors[${index}].id`, 64),
|
|
2101
|
+
name: optionalString(actor.name, `${label}.actors[${index}].name`, 512),
|
|
2102
|
+
});
|
|
2103
|
+
})
|
|
2104
|
+
: [];
|
|
2105
|
+
let message: string | null = null;
|
|
2106
|
+
const sections = isRecord(node.comet_sections) ? node.comet_sections : null;
|
|
2107
|
+
const content = sections !== null && isRecord(sections.content) ? sections.content : null;
|
|
2108
|
+
const story = content !== null && isRecord(content.story) ? content.story : null;
|
|
2109
|
+
const storyMessage = story !== null && isRecord(story.message) ? story.message : null;
|
|
2110
|
+
if (storyMessage !== null) message = optionalString(storyMessage.text, `${label}.message`, 20_000);
|
|
2111
|
+
return Object.freeze({
|
|
2112
|
+
id,
|
|
2113
|
+
permalink_url: optionalString(node.permalink_url, `${label}.permalink_url`, 4_096),
|
|
2114
|
+
creation_time: optionalInteger(node.creation_time, `${label}.creation_time`),
|
|
2115
|
+
message,
|
|
2116
|
+
actors: Object.freeze(actors),
|
|
2117
|
+
});
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
export function normalizeFacebookFeedHtml(html: unknown, viewerId: string, limit: number): unknown {
|
|
2121
|
+
const roots = parseMetaJsonScripts(html);
|
|
2122
|
+
if (parseFacebookViewerId(html) !== viewerId) throw new Error("Facebook feed response changed its bound viewer");
|
|
2123
|
+
assertMetaCometReadActor(roots, viewerId);
|
|
2124
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 30) {
|
|
2125
|
+
throw new Error("Facebook feed limit must be an integer between 1 and 30");
|
|
2126
|
+
}
|
|
2127
|
+
const posts: Readonly<Record<string, unknown>>[] = [];
|
|
2128
|
+
const seen = new Set<string>();
|
|
2129
|
+
const indexedEdges = new Map<number, {
|
|
2130
|
+
readonly edge: JsonRecord;
|
|
2131
|
+
readonly label: string;
|
|
2132
|
+
}>();
|
|
2133
|
+
let rootCount = 0;
|
|
2134
|
+
let streamedEdgeCount = 0;
|
|
2135
|
+
let finalPageInfoCount = 0;
|
|
2136
|
+
let streamPhase: "before-root" | "edges" | "final" = "before-root";
|
|
2137
|
+
let streamPreloaderKey: string | null = null;
|
|
2138
|
+
const addNode = (node: unknown, label: string): void => {
|
|
2139
|
+
if (!isRecord(node)) return;
|
|
2140
|
+
const isFeedStory = node.__isFeedUnit === "Story";
|
|
2141
|
+
if (
|
|
2142
|
+
(node.__typename !== undefined && node.__typename !== "Story" && isFeedStory)
|
|
2143
|
+
|| (node.__typename === "Story" && !isFeedStory)
|
|
2144
|
+
) {
|
|
2145
|
+
throw new Error(`${label} changed its Story type markers`);
|
|
2146
|
+
}
|
|
2147
|
+
if (!isFeedStory) return;
|
|
2148
|
+
const post = facebookPost(node, label);
|
|
2149
|
+
const id = post.id;
|
|
2150
|
+
if (typeof id !== "string") return;
|
|
2151
|
+
if (seen.has(id)) throw new Error("Facebook news feed contained a duplicate Story ID");
|
|
2152
|
+
seen.add(id);
|
|
2153
|
+
if (posts.length < limit) posts.push(post);
|
|
2154
|
+
};
|
|
2155
|
+
walk(roots, (value, path) => {
|
|
2156
|
+
if (!isRecord(value)) return;
|
|
2157
|
+
if (
|
|
2158
|
+
path.length === 0
|
|
2159
|
+
|| isReviewedFacebookHomeFeedResultEnvelope(roots, path, value)
|
|
2160
|
+
) {
|
|
2161
|
+
assertEmptyProviderErrors(value, "Facebook news-feed envelope");
|
|
2162
|
+
}
|
|
2163
|
+
const viewer = isRecord(value.viewer) ? value.viewer : null;
|
|
2164
|
+
const feed = viewer !== null && isRecord(viewer.news_feed) ? viewer.news_feed : null;
|
|
2165
|
+
if (feed !== null) {
|
|
2166
|
+
const feedPreloaderKey = reviewedFacebookHomeFeedDataEnvelopeKey(
|
|
2167
|
+
roots,
|
|
2168
|
+
path,
|
|
2169
|
+
value,
|
|
2170
|
+
);
|
|
2171
|
+
if (feedPreloaderKey === null) {
|
|
2172
|
+
throw new Error("Facebook news feed appeared outside its reviewed Relay data root");
|
|
2173
|
+
}
|
|
2174
|
+
if (streamPhase !== "before-root") {
|
|
2175
|
+
throw new Error("Facebook news feed emitted its initial root out of order");
|
|
2176
|
+
}
|
|
2177
|
+
streamPhase = "edges";
|
|
2178
|
+
streamPreloaderKey = feedPreloaderKey;
|
|
2179
|
+
if (!Array.isArray(feed.edges) || feed.edges.length > 500) {
|
|
2180
|
+
throw new Error("Facebook news-feed root edges must be a bounded array");
|
|
2181
|
+
}
|
|
2182
|
+
rootCount += 1;
|
|
2183
|
+
for (const [index, edgeValue] of feed.edges.entries()) {
|
|
2184
|
+
const edge = record(edgeValue, `Facebook news-feed edge[${index}]`);
|
|
2185
|
+
boundedString(edge.cursor, `Facebook news-feed edge[${index}].cursor`, 4_096);
|
|
2186
|
+
if (indexedEdges.has(index)) {
|
|
2187
|
+
throw new Error("Facebook news feed contained a duplicate edge coordinate");
|
|
2188
|
+
}
|
|
2189
|
+
indexedEdges.set(index, {
|
|
2190
|
+
edge,
|
|
2191
|
+
label: `Facebook news-feed edge[${index}]`,
|
|
2192
|
+
});
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
if (Array.isArray(value.path)) {
|
|
2196
|
+
const patch = value.path;
|
|
2197
|
+
const patchPreloaderKey = reviewedFacebookHomeFeedResultEnvelopeKey(
|
|
2198
|
+
roots,
|
|
2199
|
+
path,
|
|
2200
|
+
value,
|
|
2201
|
+
);
|
|
2202
|
+
const reviewedPatchEnvelope = patchPreloaderKey !== null;
|
|
2203
|
+
if (
|
|
2204
|
+
reviewedPatchEnvelope
|
|
2205
|
+
&& streamPreloaderKey !== null
|
|
2206
|
+
&& patchPreloaderKey !== streamPreloaderKey
|
|
2207
|
+
) {
|
|
2208
|
+
throw new Error("Facebook news-feed stream changed its bound preloader key");
|
|
2209
|
+
}
|
|
2210
|
+
if (
|
|
2211
|
+
patch.length === 4
|
|
2212
|
+
&& patch[0] === "viewer"
|
|
2213
|
+
&& patch[1] === "news_feed"
|
|
2214
|
+
&& patch[2] === "edges"
|
|
2215
|
+
&& typeof patch[3] === "number"
|
|
2216
|
+
) {
|
|
2217
|
+
if (!reviewedPatchEnvelope || patch[3] < 0 || patch[3] > 499) {
|
|
2218
|
+
throw new Error("Facebook news-feed patch used an unreviewed path");
|
|
2219
|
+
}
|
|
2220
|
+
if (streamPhase !== "edges") {
|
|
2221
|
+
throw new Error("Facebook news-feed edge patch appeared outside its reviewed stream order");
|
|
2222
|
+
}
|
|
2223
|
+
const extensions = record(
|
|
2224
|
+
value.extensions,
|
|
2225
|
+
`Facebook news-feed patch[${patch[3]}].extensions`,
|
|
2226
|
+
);
|
|
2227
|
+
if (extensions.is_final !== false) {
|
|
2228
|
+
throw new Error("Facebook news-feed edge patch was not explicitly nonfinal");
|
|
2229
|
+
}
|
|
2230
|
+
const edge = record(value.data, `Facebook news-feed patch[${patch[3]}]`);
|
|
2231
|
+
boundedString(edge.cursor, `Facebook news-feed patch[${patch[3]}].cursor`, 4_096);
|
|
2232
|
+
if (indexedEdges.has(patch[3])) {
|
|
2233
|
+
throw new Error("Facebook news feed contained a duplicate edge coordinate");
|
|
2234
|
+
}
|
|
2235
|
+
indexedEdges.set(patch[3], {
|
|
2236
|
+
edge,
|
|
2237
|
+
label: `Facebook news-feed patch[${patch[3]}]`,
|
|
2238
|
+
});
|
|
2239
|
+
streamedEdgeCount += 1;
|
|
2240
|
+
} else if (
|
|
2241
|
+
patch.length === 2
|
|
2242
|
+
&& patch[0] === "viewer"
|
|
2243
|
+
&& patch[1] === "news_feed"
|
|
2244
|
+
) {
|
|
2245
|
+
if (!reviewedPatchEnvelope) {
|
|
2246
|
+
throw new Error("Facebook news-feed patch used an unreviewed path");
|
|
2247
|
+
}
|
|
2248
|
+
if (streamPhase !== "edges") {
|
|
2249
|
+
throw new Error("Facebook news-feed final patch appeared outside its reviewed stream order");
|
|
2250
|
+
}
|
|
2251
|
+
streamPhase = "final";
|
|
2252
|
+
const extensions = record(
|
|
2253
|
+
value.extensions,
|
|
2254
|
+
"Facebook news-feed final patch extensions",
|
|
2255
|
+
);
|
|
2256
|
+
if (extensions.is_final !== true) {
|
|
2257
|
+
throw new Error("Facebook news-feed final patch was not explicitly final");
|
|
2258
|
+
}
|
|
2259
|
+
const data = record(value.data, "Facebook news-feed final patch data");
|
|
2260
|
+
const pageInfo = record(
|
|
2261
|
+
data.page_info,
|
|
2262
|
+
"Facebook news-feed final patch page_info",
|
|
2263
|
+
);
|
|
2264
|
+
if (typeof pageInfo.has_next_page !== "boolean") {
|
|
2265
|
+
throw new Error("Facebook news-feed final page_info.has_next_page must be boolean");
|
|
2266
|
+
}
|
|
2267
|
+
if (pageInfo.end_cursor !== null) {
|
|
2268
|
+
boundedString(
|
|
2269
|
+
pageInfo.end_cursor,
|
|
2270
|
+
"Facebook news-feed final page_info.end_cursor",
|
|
2271
|
+
4_096,
|
|
2272
|
+
);
|
|
2273
|
+
}
|
|
2274
|
+
finalPageInfoCount += 1;
|
|
2275
|
+
} else if (patch[0] === "viewer" && patch[1] === "news_feed") {
|
|
2276
|
+
throw new Error("Facebook news-feed patch used an unreviewed path");
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
});
|
|
2280
|
+
if (rootCount !== 1) {
|
|
2281
|
+
throw new Error("Facebook Relay preload did not contain exactly one news-feed root");
|
|
2282
|
+
}
|
|
2283
|
+
if (
|
|
2284
|
+
finalPageInfoCount > 1
|
|
2285
|
+
|| (streamedEdgeCount > 0 && finalPageInfoCount !== 1)
|
|
2286
|
+
) {
|
|
2287
|
+
throw new Error("Facebook news feed did not contain exactly one final page_info fragment");
|
|
2288
|
+
}
|
|
2289
|
+
const orderedEdges = [...indexedEdges.entries()].sort(
|
|
2290
|
+
([left], [right]) => left - right,
|
|
2291
|
+
);
|
|
2292
|
+
for (const [expectedIndex, [providerIndex, indexed]] of orderedEdges.entries()) {
|
|
2293
|
+
if (providerIndex !== expectedIndex) {
|
|
2294
|
+
throw new Error("Facebook news feed used a noncontiguous edge coordinate");
|
|
2295
|
+
}
|
|
2296
|
+
addNode(indexed.edge.node, `${indexed.label}.node`);
|
|
2297
|
+
}
|
|
2298
|
+
if (posts.length === 0) throw new Error("Facebook Relay preload omitted a bounded news feed");
|
|
2299
|
+
return Object.freeze({
|
|
2300
|
+
feed: "home",
|
|
2301
|
+
posts: Object.freeze(posts),
|
|
2302
|
+
nextCursor: null,
|
|
2303
|
+
continuationSupported: false,
|
|
2304
|
+
complete: false,
|
|
2305
|
+
});
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
function exactDecimalId(value: unknown, label: string): string {
|
|
2309
|
+
const id = boundedString(value, label, 32);
|
|
2310
|
+
if (!/^[0-9]{1,32}$/u.test(id) || id === "0") throw new Error(`${label} must be a stable decimal ID`);
|
|
2311
|
+
return id;
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
function optionalHttpsUrl(value: unknown, label: string, maximum = 4_096): string | null {
|
|
2315
|
+
const raw = optionalString(value, label, maximum);
|
|
2316
|
+
if (raw === null) return null;
|
|
2317
|
+
let parsed: URL;
|
|
2318
|
+
try {
|
|
2319
|
+
parsed = new URL(raw);
|
|
2320
|
+
} catch {
|
|
2321
|
+
throw new Error(`${label} must be an absolute HTTPS URL`);
|
|
2322
|
+
}
|
|
2323
|
+
if (parsed.protocol !== "https:" || parsed.username !== "" || parsed.password !== "") {
|
|
2324
|
+
throw new Error(`${label} must be an absolute HTTPS URL`);
|
|
2325
|
+
}
|
|
2326
|
+
return parsed.href;
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
function marketplaceLocation(value: unknown, label: string): Readonly<Record<string, string | null>> {
|
|
2330
|
+
if (value === undefined || value === null) {
|
|
2331
|
+
return Object.freeze({ city: null, state: null });
|
|
2332
|
+
}
|
|
2333
|
+
const location = record(value, label);
|
|
2334
|
+
const reverseGeocode = record(location.reverse_geocode, `${label}.reverse_geocode`);
|
|
2335
|
+
return Object.freeze({
|
|
2336
|
+
city: optionalString(reverseGeocode.city, `${label}.reverse_geocode.city`, 256),
|
|
2337
|
+
state: optionalString(reverseGeocode.state, `${label}.reverse_geocode.state`, 256),
|
|
2338
|
+
});
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
function marketplaceFeedListing(
|
|
2342
|
+
value: unknown,
|
|
2343
|
+
label: string,
|
|
2344
|
+
): Readonly<Record<string, unknown>> {
|
|
2345
|
+
const node = record(value, label);
|
|
2346
|
+
if (node.__typename === "MarketplaceFeedGeneralListingObject") {
|
|
2347
|
+
if (node.__isMarketplaceFeedGeneralListingData !== "MarketplaceFeedGeneralListingObject") {
|
|
2348
|
+
throw new Error(`${label} changed its reviewed listing marker`);
|
|
2349
|
+
}
|
|
2350
|
+
const entity = record(node.entity, `${label}.entity`);
|
|
2351
|
+
const listing = record(node.listing, `${label}.listing`);
|
|
2352
|
+
const entityId = exactDecimalId(entity.id, `${label}.entity.id`);
|
|
2353
|
+
const listingId = exactDecimalId(listing.id, `${label}.listing.id`);
|
|
2354
|
+
if (entityId !== listingId) throw new Error(`${label} did not bind one exact Marketplace listing`);
|
|
2355
|
+
const data = record(node.data, `${label}.data`);
|
|
2356
|
+
const price = record(data.price, `${label}.data.price`);
|
|
2357
|
+
const photo = record(node.photo, `${label}.photo`);
|
|
2358
|
+
const image = record(photo.default_image, `${label}.photo.default_image`);
|
|
2359
|
+
return Object.freeze({
|
|
2360
|
+
id: listingId,
|
|
2361
|
+
title: boundedString(data.title, `${label}.data.title`, 512),
|
|
2362
|
+
amount: boundedString(price.amount_with_offset, `${label}.data.price.amount_with_offset`, 64),
|
|
2363
|
+
currency: boundedString(price.currency, `${label}.data.price.currency`, 16),
|
|
2364
|
+
formatted_price: null,
|
|
2365
|
+
location: marketplaceLocation(entity.location, `${label}.entity.location`),
|
|
2366
|
+
image_url: optionalHttpsUrl(image.uri, `${label}.photo.default_image.uri`),
|
|
2367
|
+
creation_time: optionalInteger(listing.creation_time, `${label}.listing.creation_time`),
|
|
2368
|
+
});
|
|
2369
|
+
}
|
|
2370
|
+
if (node.__typename === "GroupCommerceProductItem") {
|
|
2371
|
+
const id = exactDecimalId(node.id, `${label}.id`);
|
|
2372
|
+
const price = record(node.listing_price, `${label}.listing_price`);
|
|
2373
|
+
const formattedPrice = record(node.formatted_price, `${label}.formatted_price`);
|
|
2374
|
+
const photo = record(node.primary_listing_photo, `${label}.primary_listing_photo`);
|
|
2375
|
+
const image = record(photo.image, `${label}.primary_listing_photo.image`);
|
|
2376
|
+
return Object.freeze({
|
|
2377
|
+
id,
|
|
2378
|
+
title: boundedString(node.marketplace_listing_title, `${label}.marketplace_listing_title`, 512),
|
|
2379
|
+
amount: boundedString(price.amount, `${label}.listing_price.amount`, 64),
|
|
2380
|
+
currency: null,
|
|
2381
|
+
formatted_price: boundedString(formattedPrice.text, `${label}.formatted_price.text`, 128),
|
|
2382
|
+
location: marketplaceLocation(node.location, `${label}.location`),
|
|
2383
|
+
image_url: optionalHttpsUrl(image.uri, `${label}.primary_listing_photo.image.uri`),
|
|
2384
|
+
creation_time: optionalInteger(node.creation_time, `${label}.creation_time`),
|
|
2385
|
+
});
|
|
2386
|
+
}
|
|
2387
|
+
throw new Error(`${label} used an unreviewed Marketplace listing variant`);
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
type MarketplaceIndexedEdge = {
|
|
2391
|
+
readonly index: number;
|
|
2392
|
+
readonly edge: JsonRecord;
|
|
2393
|
+
};
|
|
2394
|
+
|
|
2395
|
+
export type FacebookMarketplaceFeed = {
|
|
2396
|
+
readonly feed: "marketplace";
|
|
2397
|
+
readonly listings: readonly Readonly<Record<string, unknown>>[];
|
|
2398
|
+
readonly sponsored_units: number;
|
|
2399
|
+
readonly provider_has_next_page: boolean;
|
|
2400
|
+
readonly next_cursor: string | null;
|
|
2401
|
+
readonly continuation_supported: boolean;
|
|
2402
|
+
readonly truncated: boolean;
|
|
2403
|
+
readonly complete: boolean;
|
|
2404
|
+
};
|
|
2405
|
+
|
|
2406
|
+
function marketplaceFeedStream(
|
|
2407
|
+
roots: readonly unknown[],
|
|
2408
|
+
source: "html" | "documents",
|
|
2409
|
+
): {
|
|
2410
|
+
readonly edges: readonly MarketplaceIndexedEdge[];
|
|
2411
|
+
readonly endCursor: string;
|
|
2412
|
+
readonly hasNextPage: boolean;
|
|
2413
|
+
readonly root: JsonRecord;
|
|
2414
|
+
} {
|
|
2415
|
+
const indexed = new Map<number, JsonRecord>();
|
|
2416
|
+
let rootCount = 0;
|
|
2417
|
+
let selectedRoot: JsonRecord | null = null;
|
|
2418
|
+
const pageInfoCandidates: JsonRecord[] = [];
|
|
2419
|
+
let finalPageInfoCount = 0;
|
|
2420
|
+
let streamPhase: "before-root" | "edges" | "final" = "before-root";
|
|
2421
|
+
let streamPreloaderKey: string | null = null;
|
|
2422
|
+
|
|
2423
|
+
const addEdge = (index: number, value: unknown, label: string): void => {
|
|
2424
|
+
if (!Number.isSafeInteger(index) || index < 0 || index > 499) {
|
|
2425
|
+
throw new Error(`${label} used an out-of-bounds edge index`);
|
|
2426
|
+
}
|
|
2427
|
+
if (value === null) return;
|
|
2428
|
+
const edge = record(value, label);
|
|
2429
|
+
if (indexed.has(index)) throw new Error("Marketplace feed stream contained a duplicate edge index");
|
|
2430
|
+
indexed.set(index, edge);
|
|
2431
|
+
};
|
|
2432
|
+
|
|
2433
|
+
walk(roots, (value, pathToValue) => {
|
|
2434
|
+
if (!isRecord(value)) return;
|
|
2435
|
+
const resultPreloaderKey = source === "html"
|
|
2436
|
+
? reviewedFacebookMarketplaceFeedResultEnvelopeKey(
|
|
2437
|
+
roots,
|
|
2438
|
+
pathToValue,
|
|
2439
|
+
value,
|
|
2440
|
+
)
|
|
2441
|
+
: null;
|
|
2442
|
+
const reviewedResultEnvelope = source === "html"
|
|
2443
|
+
? resultPreloaderKey !== null
|
|
2444
|
+
: (
|
|
2445
|
+
pathToValue.length === 0
|
|
2446
|
+
|| directRelayResultMatches(roots, pathToValue, value)
|
|
2447
|
+
);
|
|
2448
|
+
if (
|
|
2449
|
+
pathToValue.length === 0
|
|
2450
|
+
|| reviewedResultEnvelope
|
|
2451
|
+
) {
|
|
2452
|
+
assertEmptyProviderErrors(value, "Marketplace feed envelope");
|
|
2453
|
+
}
|
|
2454
|
+
if (Object.hasOwn(value, "marketplace_home_feed")) {
|
|
2455
|
+
const dataPreloaderKey = source === "html"
|
|
2456
|
+
? reviewedFacebookMarketplaceFeedDataEnvelopeKey(
|
|
2457
|
+
roots,
|
|
2458
|
+
pathToValue,
|
|
2459
|
+
value,
|
|
2460
|
+
)
|
|
2461
|
+
: null;
|
|
2462
|
+
const reviewedDataEnvelope = source === "html"
|
|
2463
|
+
? dataPreloaderKey !== null
|
|
2464
|
+
: directRelayDataMatches(roots, pathToValue, value);
|
|
2465
|
+
if (!reviewedDataEnvelope) {
|
|
2466
|
+
throw new Error("Marketplace feed root appeared outside its reviewed Relay data root");
|
|
2467
|
+
}
|
|
2468
|
+
if (streamPhase !== "before-root") {
|
|
2469
|
+
throw new Error("Marketplace feed emitted its initial root out of order");
|
|
2470
|
+
}
|
|
2471
|
+
streamPhase = "edges";
|
|
2472
|
+
if (source === "html") {
|
|
2473
|
+
streamPreloaderKey = dataPreloaderKey;
|
|
2474
|
+
}
|
|
2475
|
+
const feed = record(value.marketplace_home_feed, "Marketplace feed root");
|
|
2476
|
+
if (!Array.isArray(feed.edges) || feed.edges.length > 500) {
|
|
2477
|
+
throw new Error("Marketplace feed root edges must be a bounded array");
|
|
2478
|
+
}
|
|
2479
|
+
rootCount += 1;
|
|
2480
|
+
selectedRoot = feed;
|
|
2481
|
+
for (const [index, edge] of feed.edges.entries()) {
|
|
2482
|
+
addEdge(index, edge, `Marketplace feed root edge[${index}]`);
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
if (!Array.isArray(value.path)) return;
|
|
2486
|
+
const path = value.path;
|
|
2487
|
+
const concernsMarketplace =
|
|
2488
|
+
path[0] === "marketplace_home_feed";
|
|
2489
|
+
const patchPreloaderKey = source === "html"
|
|
2490
|
+
? reviewedFacebookMarketplaceFeedResultEnvelopeKey(
|
|
2491
|
+
roots,
|
|
2492
|
+
pathToValue,
|
|
2493
|
+
value,
|
|
2494
|
+
)
|
|
2495
|
+
: null;
|
|
2496
|
+
if (
|
|
2497
|
+
concernsMarketplace
|
|
2498
|
+
&& (
|
|
2499
|
+
source === "html"
|
|
2500
|
+
? patchPreloaderKey === null
|
|
2501
|
+
: pathToValue.length !== 0
|
|
2502
|
+
)
|
|
2503
|
+
) {
|
|
2504
|
+
throw new Error("Marketplace feed patch appeared outside its reviewed streamed envelope");
|
|
2505
|
+
}
|
|
2506
|
+
if (
|
|
2507
|
+
concernsMarketplace
|
|
2508
|
+
&& source === "html"
|
|
2509
|
+
&& streamPreloaderKey !== null
|
|
2510
|
+
&& patchPreloaderKey !== streamPreloaderKey
|
|
2511
|
+
) {
|
|
2512
|
+
throw new Error("Marketplace feed stream changed its bound preloader key");
|
|
2513
|
+
}
|
|
2514
|
+
if (concernsMarketplace && streamPhase !== "edges") {
|
|
2515
|
+
throw new Error("Marketplace feed patch appeared outside its reviewed stream order");
|
|
2516
|
+
}
|
|
2517
|
+
if (
|
|
2518
|
+
path.length === 3
|
|
2519
|
+
&& path[0] === "marketplace_home_feed"
|
|
2520
|
+
&& path[1] === "edges"
|
|
2521
|
+
&& typeof path[2] === "number"
|
|
2522
|
+
) {
|
|
2523
|
+
const extensions = record(
|
|
2524
|
+
value.extensions,
|
|
2525
|
+
"Marketplace feed streamed edge extensions",
|
|
2526
|
+
);
|
|
2527
|
+
if (extensions.is_final !== false) {
|
|
2528
|
+
throw new Error("Marketplace feed streamed edge must be explicitly nonfinal");
|
|
2529
|
+
}
|
|
2530
|
+
addEdge(path[2], value.data, `Marketplace feed streamed edge[${path[2]}]`);
|
|
2531
|
+
return;
|
|
2532
|
+
}
|
|
2533
|
+
if (path.length === 1 && path[0] === "marketplace_home_feed") {
|
|
2534
|
+
const extensions = record(
|
|
2535
|
+
value.extensions,
|
|
2536
|
+
"Marketplace feed streamed page-info extensions",
|
|
2537
|
+
);
|
|
2538
|
+
if (extensions.is_final !== true) {
|
|
2539
|
+
throw new Error("Marketplace feed streamed page-info fragment was not final");
|
|
2540
|
+
}
|
|
2541
|
+
const data = record(value.data, "Marketplace feed streamed page info");
|
|
2542
|
+
pageInfoCandidates.push(record(data.page_info, "Marketplace feed streamed page_info"));
|
|
2543
|
+
finalPageInfoCount += 1;
|
|
2544
|
+
streamPhase = "final";
|
|
2545
|
+
return;
|
|
2546
|
+
}
|
|
2547
|
+
if (concernsMarketplace) {
|
|
2548
|
+
throw new Error("Marketplace feed patch used an unreviewed path");
|
|
2549
|
+
}
|
|
2550
|
+
});
|
|
2551
|
+
|
|
2552
|
+
if (rootCount !== 1) throw new Error("Marketplace response did not contain exactly one feed root");
|
|
2553
|
+
if (selectedRoot === null) {
|
|
2554
|
+
throw new Error("Marketplace response did not contain exactly one feed root");
|
|
2555
|
+
}
|
|
2556
|
+
if (pageInfoCandidates.length !== 1 || finalPageInfoCount !== 1) {
|
|
2557
|
+
throw new Error("Marketplace response did not contain one final page-info fragment");
|
|
2558
|
+
}
|
|
2559
|
+
const ordered = [...indexed.entries()]
|
|
2560
|
+
.sort(([left], [right]) => left - right)
|
|
2561
|
+
.map(([index, edge]) => Object.freeze({ index, edge }));
|
|
2562
|
+
if (ordered.length === 0 || ordered.some((entry, index) => entry.index !== index)) {
|
|
2563
|
+
throw new Error("Marketplace feed stream did not contain one contiguous provider page");
|
|
2564
|
+
}
|
|
2565
|
+
const pageInfo = pageInfoCandidates[0];
|
|
2566
|
+
if (pageInfo === undefined || typeof pageInfo.has_next_page !== "boolean") {
|
|
2567
|
+
throw new Error("Marketplace feed page_info.has_next_page must be boolean");
|
|
2568
|
+
}
|
|
2569
|
+
return {
|
|
2570
|
+
edges: Object.freeze(ordered),
|
|
2571
|
+
endCursor: boundedString(pageInfo.end_cursor, "Marketplace feed page_info.end_cursor", 4_096),
|
|
2572
|
+
hasNextPage: pageInfo.has_next_page,
|
|
2573
|
+
root: selectedRoot,
|
|
2574
|
+
};
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2577
|
+
function projectFacebookMarketplaceFeed(
|
|
2578
|
+
stream: ReturnType<typeof marketplaceFeedStream>,
|
|
2579
|
+
limit: number,
|
|
2580
|
+
): FacebookMarketplaceFeed {
|
|
2581
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 50) {
|
|
2582
|
+
throw new Error("Marketplace feed limit must be an integer between 1 and 50");
|
|
2583
|
+
}
|
|
2584
|
+
const allListings: Readonly<Record<string, unknown>>[] = [];
|
|
2585
|
+
const seen = new Set<string>();
|
|
2586
|
+
let sponsoredUnits = 0;
|
|
2587
|
+
for (const { index, edge } of stream.edges) {
|
|
2588
|
+
boundedString(edge.cursor, `Marketplace feed edge[${index}].cursor`, 4_096);
|
|
2589
|
+
const node = record(edge.node, `Marketplace feed edge[${index}].node`);
|
|
2590
|
+
let candidates: readonly unknown[];
|
|
2591
|
+
if (node.__typename === "MarketplaceFeedTopPicksUnit") {
|
|
2592
|
+
if (!Array.isArray(node.marketplace_listings) || node.marketplace_listings.length > 100) {
|
|
2593
|
+
throw new Error(`Marketplace feed edge[${index}] top picks must be a bounded array`);
|
|
2594
|
+
}
|
|
2595
|
+
candidates = node.marketplace_listings;
|
|
2596
|
+
} else if (node.__typename === "MarketplaceFeedAdStory") {
|
|
2597
|
+
sponsoredUnits += 1;
|
|
2598
|
+
continue;
|
|
2599
|
+
} else {
|
|
2600
|
+
candidates = [node];
|
|
2601
|
+
}
|
|
2602
|
+
for (const [candidateIndex, candidate] of candidates.entries()) {
|
|
2603
|
+
const listing = marketplaceFeedListing(
|
|
2604
|
+
candidate,
|
|
2605
|
+
`Marketplace feed edge[${index}] listing[${candidateIndex}]`,
|
|
2606
|
+
);
|
|
2607
|
+
const id = listing.id;
|
|
2608
|
+
if (typeof id !== "string" || seen.has(id)) continue;
|
|
2609
|
+
seen.add(id);
|
|
2610
|
+
allListings.push(listing);
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
if (allListings.length === 0) throw new Error("Marketplace feed contained no reviewed listings");
|
|
2614
|
+
const truncated = allListings.length > limit;
|
|
2615
|
+
const continuationSupported = !truncated;
|
|
2616
|
+
return Object.freeze({
|
|
2617
|
+
feed: "marketplace",
|
|
2618
|
+
listings: Object.freeze(allListings.slice(0, limit)),
|
|
2619
|
+
sponsored_units: sponsoredUnits,
|
|
2620
|
+
provider_has_next_page: stream.hasNextPage,
|
|
2621
|
+
next_cursor: stream.hasNextPage && continuationSupported ? stream.endCursor : null,
|
|
2622
|
+
continuation_supported: continuationSupported,
|
|
2623
|
+
truncated,
|
|
2624
|
+
complete: !stream.hasNextPage && !truncated,
|
|
2625
|
+
});
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2628
|
+
export function normalizeFacebookMarketplaceFeedHtml(
|
|
2629
|
+
html: unknown,
|
|
2630
|
+
viewerId: string,
|
|
2631
|
+
limit: number,
|
|
2632
|
+
): FacebookMarketplaceFeed {
|
|
2633
|
+
const roots = parseMetaJsonScripts(html);
|
|
2634
|
+
if (parseFacebookViewerId(html) !== viewerId) {
|
|
2635
|
+
throw new Error("Marketplace feed response changed its bound viewer");
|
|
2636
|
+
}
|
|
2637
|
+
assertMetaCometReadActor(roots, viewerId);
|
|
2638
|
+
return projectFacebookMarketplaceFeed(marketplaceFeedStream(roots, "html"), limit);
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
export function normalizeFacebookMarketplaceFeedDocuments(
|
|
2642
|
+
text: unknown,
|
|
2643
|
+
previousCursor: string,
|
|
2644
|
+
limit: number,
|
|
2645
|
+
): FacebookMarketplaceFeed {
|
|
2646
|
+
return normalizeFacebookMarketplaceFeedJsonDocuments(
|
|
2647
|
+
parseMetaJsonDocuments(text),
|
|
2648
|
+
previousCursor,
|
|
2649
|
+
limit,
|
|
2650
|
+
);
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
export function normalizeFacebookMarketplaceFeedJsonDocuments(
|
|
2654
|
+
documents: readonly unknown[],
|
|
2655
|
+
previousCursor: string,
|
|
2656
|
+
limit: number,
|
|
2657
|
+
expectedRoot?: unknown,
|
|
2658
|
+
): FacebookMarketplaceFeed {
|
|
2659
|
+
for (const [index, value] of documents.entries()) {
|
|
2660
|
+
const document = record(value, `Marketplace streamed document[${index}]`);
|
|
2661
|
+
if (!Object.hasOwn(document, "errors")) continue;
|
|
2662
|
+
if (!Array.isArray(document.errors)) {
|
|
2663
|
+
throw new Error(`Marketplace streamed document[${index}].errors must be an array`);
|
|
2664
|
+
}
|
|
2665
|
+
if (document.errors.length > 0) {
|
|
2666
|
+
throw new Error("Marketplace streamed response contained provider errors");
|
|
2667
|
+
}
|
|
2668
|
+
}
|
|
2669
|
+
const stream = marketplaceFeedStream(documents, "documents");
|
|
2670
|
+
if (expectedRoot !== undefined && stream.root !== expectedRoot) {
|
|
2671
|
+
throw new Error("Marketplace streamed response changed its descriptor-bound root");
|
|
2672
|
+
}
|
|
2673
|
+
const prior = boundedString(previousCursor, "Marketplace previous cursor", 4_096);
|
|
2674
|
+
if (stream.endCursor === prior) {
|
|
2675
|
+
throw new Error("Marketplace continuation repeated its prior cursor");
|
|
2676
|
+
}
|
|
2677
|
+
return projectFacebookMarketplaceFeed(stream, limit);
|
|
2678
|
+
}
|
|
2679
|
+
|
|
2680
|
+
function marketplaceListingPhoto(
|
|
2681
|
+
value: unknown,
|
|
2682
|
+
label: string,
|
|
2683
|
+
): Readonly<Record<string, unknown>> {
|
|
2684
|
+
const photo = record(value, label);
|
|
2685
|
+
const image = record(photo.image, `${label}.image`);
|
|
2686
|
+
return Object.freeze({
|
|
2687
|
+
id: exactDecimalId(photo.id, `${label}.id`),
|
|
2688
|
+
url: optionalHttpsUrl(image.uri, `${label}.image.uri`),
|
|
2689
|
+
width: optionalInteger(image.width, `${label}.image.width`),
|
|
2690
|
+
height: optionalInteger(image.height, `${label}.image.height`),
|
|
2691
|
+
accessibility_caption: optionalString(
|
|
2692
|
+
photo.accessibility_caption,
|
|
2693
|
+
`${label}.accessibility_caption`,
|
|
2694
|
+
1_024,
|
|
2695
|
+
),
|
|
2696
|
+
});
|
|
2697
|
+
}
|
|
2698
|
+
|
|
2699
|
+
export function normalizeFacebookMarketplaceListingHtml(
|
|
2700
|
+
html: unknown,
|
|
2701
|
+
viewerId: string,
|
|
2702
|
+
listingId: string,
|
|
2703
|
+
): unknown {
|
|
2704
|
+
const expectedListingId = exactDecimalId(listingId, "Marketplace requested listing ID");
|
|
2705
|
+
const roots = parseMetaJsonScripts(html);
|
|
2706
|
+
if (parseFacebookViewerId(html) !== viewerId) {
|
|
2707
|
+
throw new Error("Marketplace listing response changed its bound viewer");
|
|
2708
|
+
}
|
|
2709
|
+
assertMetaCometReadActor(roots, viewerId);
|
|
2710
|
+
const detailCandidates: JsonRecord[] = [];
|
|
2711
|
+
const mediaCandidates: JsonRecord[] = [];
|
|
2712
|
+
walk(roots, (value, path) => {
|
|
2713
|
+
if (!isRecord(value)) return;
|
|
2714
|
+
if (
|
|
2715
|
+
path.length === 0
|
|
2716
|
+
|| isReviewedFacebookMarketplaceListingResultEnvelope(
|
|
2717
|
+
roots,
|
|
2718
|
+
path,
|
|
2719
|
+
value,
|
|
2720
|
+
)
|
|
2721
|
+
) {
|
|
2722
|
+
assertEmptyProviderErrors(value, "Marketplace listing envelope");
|
|
2723
|
+
}
|
|
2724
|
+
const viewer = isRecord(value.viewer) ? value.viewer : null;
|
|
2725
|
+
const detailsPage = viewer !== null
|
|
2726
|
+
&& isRecord(viewer.marketplace_product_details_page)
|
|
2727
|
+
? viewer.marketplace_product_details_page
|
|
2728
|
+
: null;
|
|
2729
|
+
const reviewedDetailDataRoot =
|
|
2730
|
+
isReviewedFacebookMarketplaceListingDetailDataEnvelope(
|
|
2731
|
+
roots,
|
|
2732
|
+
path,
|
|
2733
|
+
value,
|
|
2734
|
+
);
|
|
2735
|
+
const reviewedMediaDataRoot =
|
|
2736
|
+
isReviewedFacebookMarketplaceListingMediaDataEnvelope(
|
|
2737
|
+
roots,
|
|
2738
|
+
path,
|
|
2739
|
+
value,
|
|
2740
|
+
);
|
|
2741
|
+
const reviewedDataRoot = reviewedDetailDataRoot || reviewedMediaDataRoot;
|
|
2742
|
+
const directTarget = reviewedDataRoot && isRecord(value.target)
|
|
2743
|
+
? value.target
|
|
2744
|
+
: null;
|
|
2745
|
+
if (detailsPage === null && directTarget === null) return;
|
|
2746
|
+
if (!reviewedDataRoot) {
|
|
2747
|
+
throw new Error(
|
|
2748
|
+
"Marketplace product-details root appeared outside its reviewed Relay data root",
|
|
2749
|
+
);
|
|
2750
|
+
}
|
|
2751
|
+
if (detailsPage !== null && directTarget !== null) {
|
|
2752
|
+
throw new Error("Marketplace product-details response matched multiple reviewed root variants");
|
|
2753
|
+
}
|
|
2754
|
+
const target = directTarget ?? record(
|
|
2755
|
+
detailsPage?.target,
|
|
2756
|
+
"Marketplace product-details target",
|
|
2757
|
+
);
|
|
2758
|
+
if (
|
|
2759
|
+
target.__typename !== "GroupCommerceProductItem"
|
|
2760
|
+
|| target.id !== expectedListingId
|
|
2761
|
+
) {
|
|
2762
|
+
throw new Error("Marketplace product-details root changed its requested listing target");
|
|
2763
|
+
}
|
|
2764
|
+
const hasDetailProjection = (
|
|
2765
|
+
typeof target.marketplace_listing_title === "string"
|
|
2766
|
+
&& isRecord(target.listing_price)
|
|
2767
|
+
&& isRecord(target.redacted_description)
|
|
2768
|
+
);
|
|
2769
|
+
const hasMediaProjection = Array.isArray(target.listing_photos);
|
|
2770
|
+
if (
|
|
2771
|
+
hasDetailProjection
|
|
2772
|
+
&& !reviewedDetailDataRoot
|
|
2773
|
+
) {
|
|
2774
|
+
throw new Error(
|
|
2775
|
+
"Marketplace listing detail came from an unreviewed preloader",
|
|
2776
|
+
);
|
|
2777
|
+
}
|
|
2778
|
+
if (
|
|
2779
|
+
hasMediaProjection
|
|
2780
|
+
&& !reviewedMediaDataRoot
|
|
2781
|
+
) {
|
|
2782
|
+
throw new Error(
|
|
2783
|
+
"Marketplace listing media came from an unreviewed preloader",
|
|
2784
|
+
);
|
|
2785
|
+
}
|
|
2786
|
+
if (hasDetailProjection) detailCandidates.push(target);
|
|
2787
|
+
if (hasMediaProjection) mediaCandidates.push(target);
|
|
2788
|
+
});
|
|
2789
|
+
if (detailCandidates.length !== 1 || mediaCandidates.length !== 1) {
|
|
2790
|
+
throw new Error("Marketplace listing response did not bind one exact detailed listing");
|
|
2791
|
+
}
|
|
2792
|
+
const detail = detailCandidates[0];
|
|
2793
|
+
const media = mediaCandidates[0];
|
|
2794
|
+
if (detail === undefined || media === undefined) {
|
|
2795
|
+
throw new Error("Marketplace listing response omitted its reviewed target");
|
|
2796
|
+
}
|
|
2797
|
+
const primary = record(detail.primary_mp_ent, "Marketplace listing primary entity");
|
|
2798
|
+
if (exactDecimalId(primary.id, "Marketplace listing primary entity.id") !== expectedListingId) {
|
|
2799
|
+
throw new Error("Marketplace listing primary entity changed its requested target");
|
|
2800
|
+
}
|
|
2801
|
+
const price = record(detail.listing_price, "Marketplace listing price");
|
|
2802
|
+
const description = record(detail.redacted_description, "Marketplace listing description");
|
|
2803
|
+
const locationText = record(detail.location_text, "Marketplace listing location text");
|
|
2804
|
+
const seller = record(detail.marketplace_listing_seller, "Marketplace listing seller");
|
|
2805
|
+
const sellerId = exactDecimalId(seller.id, "Marketplace listing seller.id");
|
|
2806
|
+
if (seller.user_id !== undefined && seller.user_id !== sellerId) {
|
|
2807
|
+
throw new Error("Marketplace listing seller identity was ambiguous");
|
|
2808
|
+
}
|
|
2809
|
+
if (!Array.isArray(media.listing_photos) || media.listing_photos.length > 100) {
|
|
2810
|
+
throw new Error("Marketplace listing photos must be a bounded array");
|
|
2811
|
+
}
|
|
2812
|
+
if (
|
|
2813
|
+
media.pre_recorded_videos !== undefined
|
|
2814
|
+
&& (
|
|
2815
|
+
!Array.isArray(media.pre_recorded_videos)
|
|
2816
|
+
|| media.pre_recorded_videos.length > 100
|
|
2817
|
+
)
|
|
2818
|
+
) {
|
|
2819
|
+
throw new Error("Marketplace listing videos must be a bounded array");
|
|
2820
|
+
}
|
|
2821
|
+
const photos = media.listing_photos.map((photo, index) =>
|
|
2822
|
+
marketplaceListingPhoto(photo, `Marketplace listing photo[${index}]`));
|
|
2823
|
+
return Object.freeze({
|
|
2824
|
+
id: expectedListingId,
|
|
2825
|
+
title: boundedString(detail.marketplace_listing_title, "Marketplace listing title", 512),
|
|
2826
|
+
description: boundedString(description.text, "Marketplace listing description.text", 100_000, true),
|
|
2827
|
+
price: Object.freeze({
|
|
2828
|
+
amount: boundedString(price.amount, "Marketplace listing price.amount", 64),
|
|
2829
|
+
currency: optionalString(price.currency, "Marketplace listing price.currency", 16),
|
|
2830
|
+
formatted: optionalString(
|
|
2831
|
+
price.formatted_amount_zeros_stripped,
|
|
2832
|
+
"Marketplace listing price.formatted_amount_zeros_stripped",
|
|
2833
|
+
128,
|
|
2834
|
+
),
|
|
2835
|
+
}),
|
|
2836
|
+
location: optionalString(locationText.text, "Marketplace listing location_text.text", 512),
|
|
2837
|
+
creation_time: optionalInteger(detail.creation_time, "Marketplace listing creation_time"),
|
|
2838
|
+
is_live: optionalBoolean(detail.is_live, "Marketplace listing is_live"),
|
|
2839
|
+
is_pending: optionalBoolean(detail.is_pending, "Marketplace listing is_pending"),
|
|
2840
|
+
is_sold: optionalBoolean(detail.is_sold, "Marketplace listing is_sold"),
|
|
2841
|
+
is_viewer_seller: optionalBoolean(detail.is_viewer_seller, "Marketplace listing is_viewer_seller"),
|
|
2842
|
+
seller: Object.freeze({
|
|
2843
|
+
id: sellerId,
|
|
2844
|
+
name: optionalString(seller.name, "Marketplace listing seller.name", 512),
|
|
2845
|
+
}),
|
|
2846
|
+
photos: Object.freeze(photos),
|
|
2847
|
+
video_count: Array.isArray(media.pre_recorded_videos)
|
|
2848
|
+
? media.pre_recorded_videos.length
|
|
2849
|
+
: 0,
|
|
2850
|
+
});
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2853
|
+
export function normalizeFacebookInboxHtml(html: unknown, viewerId: string, limit: number): unknown {
|
|
2854
|
+
const roots = parseMetaJsonScripts(html);
|
|
2855
|
+
if (parseFacebookViewerId(html) !== viewerId) throw new Error("Facebook inbox response changed its bound viewer");
|
|
2856
|
+
const candidates: JsonRecord[] = [];
|
|
2857
|
+
walk(roots, (value) => {
|
|
2858
|
+
if (!isRecord(value)) return;
|
|
2859
|
+
const viewer = isRecord(value.viewer) ? value.viewer : null;
|
|
2860
|
+
const threads = viewer !== null && isRecord(viewer.message_threads) ? viewer.message_threads : null;
|
|
2861
|
+
if (threads !== null) candidates.push(threads);
|
|
2862
|
+
});
|
|
2863
|
+
if (candidates.length !== 1 || !Array.isArray(candidates[0]?.edges) || (candidates[0]?.edges as unknown[]).length > 1_000) {
|
|
2864
|
+
throw new Error("Facebook Relay preload did not contain exactly one bounded message-thread summary list");
|
|
2865
|
+
}
|
|
2866
|
+
const edges = candidates[0]?.edges as unknown[];
|
|
2867
|
+
const threads = edges.slice(0, limit).map((edgeValue, index) => {
|
|
2868
|
+
const edge = record(edgeValue, `Facebook message thread edge[${index}]`);
|
|
2869
|
+
const node = record(edge.node, `Facebook message thread node[${index}]`);
|
|
2870
|
+
const threadKey = record(node.thread_key, `Facebook message thread[${index}].thread_key`);
|
|
2871
|
+
const participantsContainer = record(node.all_participants, `Facebook message thread[${index}].all_participants`);
|
|
2872
|
+
if (!Array.isArray(participantsContainer.edges) || participantsContainer.edges.length > 100) {
|
|
2873
|
+
throw new Error(`Facebook message thread[${index}] participants must be bounded`);
|
|
2874
|
+
}
|
|
2875
|
+
const participants = participantsContainer.edges.map((participantEdge, participantIndex) => {
|
|
2876
|
+
const participantNode = record(
|
|
2877
|
+
record(participantEdge, `Facebook participant edge[${participantIndex}]`).node,
|
|
2878
|
+
`Facebook participant node[${participantIndex}]`,
|
|
2879
|
+
);
|
|
2880
|
+
const actor = record(participantNode.messaging_actor, `Facebook participant actor[${participantIndex}]`);
|
|
2881
|
+
return Object.freeze({
|
|
2882
|
+
id: boundedString(actor.id, `Facebook participant actor[${participantIndex}].id`, 64),
|
|
2883
|
+
name: optionalString(actor.name, `Facebook participant actor[${participantIndex}].name`, 512),
|
|
2884
|
+
});
|
|
2885
|
+
});
|
|
2886
|
+
return Object.freeze({
|
|
2887
|
+
thread_id: boundedString(
|
|
2888
|
+
threadKey.thread_fbid ?? threadKey.other_user_id ?? node.id,
|
|
2889
|
+
`Facebook message thread[${index}].thread_id`,
|
|
2890
|
+
256,
|
|
2891
|
+
),
|
|
2892
|
+
name: optionalString(node.name, `Facebook message thread[${index}].name`, 512),
|
|
2893
|
+
updated_time: optionalInteger(node.updated_time, `Facebook message thread[${index}].updated_time`),
|
|
2894
|
+
participants: Object.freeze(participants),
|
|
2895
|
+
});
|
|
2896
|
+
});
|
|
2897
|
+
return Object.freeze({ folder: "inbox", threads: Object.freeze(threads) });
|
|
2898
|
+
}
|
|
2899
|
+
|
|
2900
|
+
export const metaWebEvidenceSnapshot = Object.freeze({
|
|
2901
|
+
schemaVersion: 1,
|
|
2902
|
+
role: "revision-evidence-only" as const,
|
|
2903
|
+
observedOn: "2026-08-22",
|
|
2904
|
+
authentication: "browser-cookie-session" as const,
|
|
2905
|
+
operations: Object.freeze({
|
|
2906
|
+
instagram: Object.freeze({
|
|
2907
|
+
viewer: "GET / HTML PolarisViewer, corroborated by ds_user_id",
|
|
2908
|
+
feed: "GET /api/v1/feed/timeline/ first page without cursor continuation",
|
|
2909
|
+
post: "GET /api/v1/media/{id}/info/",
|
|
2910
|
+
comments: "GET /api/v1/media/{id}/comments/ first page without cursor continuation",
|
|
2911
|
+
inbox: "GET /api/v1/direct_v2/inbox/ first page without cursor continuation or seen/ack dispatch",
|
|
2912
|
+
}),
|
|
2913
|
+
threads: Object.freeze({
|
|
2914
|
+
viewer: "GET / HTML BarcelonaSessionInfo plus Relay viewer.user.id",
|
|
2915
|
+
feed: "GET / signed-in first-page Relay feedData preload without cursor continuation",
|
|
2916
|
+
publishImage: "POST configure_text_post_app_feed with exact created-locator response binding after optional PNG rupload; when an image is supplied, require synchronous 200 upload completion and completed-upload dimensions; then GET the exact returned permalink for independent actor/text and optional image readback",
|
|
2917
|
+
publishVideo: "POST one MP4 to the exact rupload_igvideo entity with synchronous 200 completion and exact dimensions, POST configure_text_post_app_feed with exact actor/text/video locator binding, then GET the exact returned permalink for independent video readback",
|
|
2918
|
+
}),
|
|
2919
|
+
facebook: Object.freeze({
|
|
2920
|
+
viewer: "GET / HTML CurrentUserInitialData, corroborated by c_user",
|
|
2921
|
+
feed: "GET / signed-in Comet Relay viewer.news_feed preload",
|
|
2922
|
+
}),
|
|
2923
|
+
"facebook-marketplace": Object.freeze({
|
|
2924
|
+
feed: "GET /marketplace/ signed-in Relay bootstrap without script execution",
|
|
2925
|
+
listing: "GET /marketplace/item/{id}/ signed-in product-details bootstrap without item-seen dispatch",
|
|
2926
|
+
}),
|
|
2927
|
+
"facebook-group": Object.freeze({
|
|
2928
|
+
feed: "GET /groups/{id}/ signed-in first-page Relay bootstrap without script execution or cursor continuation",
|
|
2929
|
+
}),
|
|
2930
|
+
}),
|
|
2931
|
+
});
|