@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,1221 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const YOUTUBE_ORIGIN = "https://www.youtube.com";
|
|
4
|
+
const MAX_CONFIG_BYTES = 2 * 1024 * 1024;
|
|
5
|
+
const MAX_PROFILE_PAGE_BYTES = 4 * 1024 * 1024;
|
|
6
|
+
const MAX_WALK_NODES = 250_000;
|
|
7
|
+
const MAX_WALK_DEPTH = 80;
|
|
8
|
+
|
|
9
|
+
type JsonRecord = Record<string, unknown>;
|
|
10
|
+
|
|
11
|
+
export type YouTubeInnertubeContext = {
|
|
12
|
+
readonly client: Readonly<Record<string, string | number | boolean>>;
|
|
13
|
+
readonly user?: Readonly<Record<string, string | boolean>>;
|
|
14
|
+
readonly request?: Readonly<Record<string, boolean>>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type YouTubeBootstrapConfig = {
|
|
18
|
+
readonly apiKey: string;
|
|
19
|
+
readonly bootstrapLoggedIn: boolean;
|
|
20
|
+
readonly clientName: string;
|
|
21
|
+
readonly clientNameHeader: string;
|
|
22
|
+
readonly clientVersion: string;
|
|
23
|
+
readonly context: YouTubeInnertubeContext;
|
|
24
|
+
readonly sessionIndex: string;
|
|
25
|
+
readonly delegatedSessionId: string | null;
|
|
26
|
+
readonly visitorData: string | null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type YouTubeVideoDeleteRequest = Readonly<{
|
|
30
|
+
body: Readonly<{
|
|
31
|
+
context: YouTubeInnertubeContext;
|
|
32
|
+
videoId: string;
|
|
33
|
+
}>;
|
|
34
|
+
endpoint: "video/delete";
|
|
35
|
+
method: "POST";
|
|
36
|
+
}>;
|
|
37
|
+
|
|
38
|
+
export type YouTubeProjectedItem = {
|
|
39
|
+
readonly kind: "video" | "playlist" | "channel" | "post";
|
|
40
|
+
readonly id: string;
|
|
41
|
+
readonly title: string | null;
|
|
42
|
+
readonly url: string | null;
|
|
43
|
+
readonly channelId: string | null;
|
|
44
|
+
readonly channelName: string | null;
|
|
45
|
+
readonly description: string | null;
|
|
46
|
+
readonly published: string | null;
|
|
47
|
+
readonly duration: string | null;
|
|
48
|
+
readonly views: string | null;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type YouTubeProjectedComment = {
|
|
52
|
+
readonly id: string;
|
|
53
|
+
readonly parentId: string | null;
|
|
54
|
+
readonly authorChannelId: string | null;
|
|
55
|
+
readonly author: string | null;
|
|
56
|
+
readonly body: string;
|
|
57
|
+
readonly published: string | null;
|
|
58
|
+
readonly votes: string | null;
|
|
59
|
+
readonly heartedByCreator: boolean;
|
|
60
|
+
readonly pinned: boolean;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type YouTubeProjectedPost = {
|
|
64
|
+
readonly id: string;
|
|
65
|
+
readonly authorChannelId: string | null;
|
|
66
|
+
readonly author: string | null;
|
|
67
|
+
readonly body: string;
|
|
68
|
+
readonly published: string | null;
|
|
69
|
+
readonly likes: string | null;
|
|
70
|
+
readonly attachmentKinds: readonly string[];
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export type YouTubeProfileTarget = {
|
|
74
|
+
readonly url: string;
|
|
75
|
+
readonly handle: string | null;
|
|
76
|
+
readonly channelId: string | null;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export type YouTubeProjectedProfile = {
|
|
80
|
+
readonly channelId: string;
|
|
81
|
+
readonly canonicalUrl: string;
|
|
82
|
+
readonly handle: string | null;
|
|
83
|
+
readonly displayName: string;
|
|
84
|
+
readonly bio: string | null;
|
|
85
|
+
readonly subscribers: number | null;
|
|
86
|
+
readonly videos: number | null;
|
|
87
|
+
readonly views: number | null;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
function isRecord(value: unknown): value is JsonRecord {
|
|
91
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function record(value: unknown, label: string): JsonRecord {
|
|
95
|
+
if (!isRecord(value)) throw new Error(`${label} must be an object`);
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function boundedString(
|
|
100
|
+
value: unknown,
|
|
101
|
+
label: string,
|
|
102
|
+
maximum = 32_768,
|
|
103
|
+
): string {
|
|
104
|
+
if (
|
|
105
|
+
typeof value !== "string"
|
|
106
|
+
|| value.length < 1
|
|
107
|
+
|| value.length > maximum
|
|
108
|
+
|| /[\0\r]/u.test(value)
|
|
109
|
+
) throw new Error(`${label} must be a bounded string`);
|
|
110
|
+
return value;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function optionalBoundedString(value: unknown, maximum = 32_768): string | null {
|
|
114
|
+
return typeof value === "string" && value.length > 0 && value.length <= maximum && !/[\0\r]/u.test(value)
|
|
115
|
+
? value
|
|
116
|
+
: null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function boundedIntegerString(value: unknown, label: string): string {
|
|
120
|
+
if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= 99) {
|
|
121
|
+
return String(value);
|
|
122
|
+
}
|
|
123
|
+
if (typeof value === "string" && /^(?:0|[1-9][0-9]?)$/u.test(value)) return value;
|
|
124
|
+
throw new Error(`${label} must be an account index between 0 and 99`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function balancedJsonObject(
|
|
128
|
+
text: string,
|
|
129
|
+
start: number,
|
|
130
|
+
maximumBytes = MAX_CONFIG_BYTES,
|
|
131
|
+
): { readonly value: unknown; readonly end: number } {
|
|
132
|
+
if (text[start] !== "{") throw new Error("YouTube configuration did not begin with an object");
|
|
133
|
+
let depth = 0;
|
|
134
|
+
let inString = false;
|
|
135
|
+
let escaped = false;
|
|
136
|
+
for (let index = start; index < text.length; index += 1) {
|
|
137
|
+
const character = text[index]!;
|
|
138
|
+
if (inString) {
|
|
139
|
+
if (escaped) {
|
|
140
|
+
escaped = false;
|
|
141
|
+
} else if (character === "\\") {
|
|
142
|
+
escaped = true;
|
|
143
|
+
} else if (character === "\"") {
|
|
144
|
+
inString = false;
|
|
145
|
+
}
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (character === "\"") {
|
|
149
|
+
inString = true;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (character === "{") depth += 1;
|
|
153
|
+
if (character !== "}") continue;
|
|
154
|
+
depth -= 1;
|
|
155
|
+
if (depth !== 0) continue;
|
|
156
|
+
if (index - start + 1 > maximumBytes) {
|
|
157
|
+
throw new Error("YouTube configuration exceeded its reviewed byte limit");
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
return { value: JSON.parse(text.slice(start, index + 1)) as unknown, end: index + 1 };
|
|
161
|
+
} catch {
|
|
162
|
+
// Some first-party scripts use JavaScript object syntax rather than JSON.
|
|
163
|
+
// Never evaluate it; reviewed fields must also appear in a strict JSON
|
|
164
|
+
// ytcfg object or the bootstrap fails closed below.
|
|
165
|
+
return { value: undefined, end: index + 1 };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
throw new Error("YouTube configuration contained an unterminated object");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Parse one strict ytInitialData object without evaluating page JavaScript. */
|
|
172
|
+
export function parseYouTubeInitialDataHtml(html: string): unknown {
|
|
173
|
+
if (html.length > MAX_PROFILE_PAGE_BYTES) {
|
|
174
|
+
throw new Error("YouTube profile page exceeded its reviewed byte limit");
|
|
175
|
+
}
|
|
176
|
+
const prefixes = [
|
|
177
|
+
"var ytInitialData =",
|
|
178
|
+
"window[\"ytInitialData\"] =",
|
|
179
|
+
"window['ytInitialData'] =",
|
|
180
|
+
] as const;
|
|
181
|
+
const candidates = new Map<string, unknown>();
|
|
182
|
+
for (const prefix of prefixes) {
|
|
183
|
+
let offset = 0;
|
|
184
|
+
while (offset < html.length) {
|
|
185
|
+
const found = html.indexOf(prefix, offset);
|
|
186
|
+
if (found < 0) break;
|
|
187
|
+
const afterPrefix = found + prefix.length;
|
|
188
|
+
let start = afterPrefix;
|
|
189
|
+
while (start < html.length && /\s/u.test(html[start]!)) start += 1;
|
|
190
|
+
if (html[start] !== "{") {
|
|
191
|
+
offset = afterPrefix;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
const parsed = balancedJsonObject(html, start, MAX_PROFILE_PAGE_BYTES);
|
|
195
|
+
if (isRecord(parsed.value)) {
|
|
196
|
+
candidates.set(JSON.stringify(parsed.value), parsed.value);
|
|
197
|
+
}
|
|
198
|
+
offset = parsed.end;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (candidates.size !== 1) {
|
|
202
|
+
throw new Error("YouTube profile page did not contain one strict initial-data object");
|
|
203
|
+
}
|
|
204
|
+
return candidates.values().next().value!;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function ytcfgObjects(html: string): readonly JsonRecord[] {
|
|
208
|
+
if (html.length > MAX_CONFIG_BYTES) throw new Error("YouTube bootstrap exceeded its reviewed byte limit");
|
|
209
|
+
const result: JsonRecord[] = [];
|
|
210
|
+
const prefix = "ytcfg.set(";
|
|
211
|
+
let offset = 0;
|
|
212
|
+
while (offset < html.length) {
|
|
213
|
+
const found = html.indexOf(prefix, offset);
|
|
214
|
+
if (found < 0) break;
|
|
215
|
+
const candidate = found + prefix.length;
|
|
216
|
+
let objectStart = candidate;
|
|
217
|
+
while (objectStart < html.length && /\s/u.test(html[objectStart]!)) objectStart += 1;
|
|
218
|
+
if (html[objectStart] === "{") {
|
|
219
|
+
const parsed = balancedJsonObject(html, objectStart);
|
|
220
|
+
if (isRecord(parsed.value)) result.push(parsed.value);
|
|
221
|
+
offset = parsed.end;
|
|
222
|
+
} else {
|
|
223
|
+
offset = candidate;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (result.length < 1) throw new Error("YouTube bootstrap omitted its ytcfg objects");
|
|
227
|
+
return Object.freeze(result);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function uniqueConfigValue(configs: readonly JsonRecord[], key: string): unknown {
|
|
231
|
+
const values = configs.filter((config) => Object.hasOwn(config, key)).map((config) => config[key]);
|
|
232
|
+
if (values.length < 1) return undefined;
|
|
233
|
+
const encoded = new Set(values.map((value) => JSON.stringify(value)));
|
|
234
|
+
if (encoded.size !== 1) throw new Error(`YouTube bootstrap contained conflicting ${key} values`);
|
|
235
|
+
return values[0];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function optionalContextScalar(
|
|
239
|
+
value: JsonRecord,
|
|
240
|
+
key: string,
|
|
241
|
+
maximum: number,
|
|
242
|
+
): string | number | boolean | undefined {
|
|
243
|
+
const candidate = value[key];
|
|
244
|
+
if (typeof candidate === "boolean") return candidate;
|
|
245
|
+
if (typeof candidate === "number" && Number.isSafeInteger(candidate)) return candidate;
|
|
246
|
+
if (
|
|
247
|
+
typeof candidate === "string"
|
|
248
|
+
&& candidate.length > 0
|
|
249
|
+
&& candidate.length <= maximum
|
|
250
|
+
&& !/[\0\r]/u.test(candidate)
|
|
251
|
+
) return candidate;
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function reviewedContext(value: unknown, clientName: string, clientVersion: string): YouTubeInnertubeContext {
|
|
256
|
+
const source = record(value, "YouTube INNERTUBE_CONTEXT");
|
|
257
|
+
const sourceClient = record(source.client, "YouTube INNERTUBE_CONTEXT.client");
|
|
258
|
+
const client: Record<string, string | number | boolean> = { clientName, clientVersion };
|
|
259
|
+
for (const [key, maximum] of [
|
|
260
|
+
["hl", 32],
|
|
261
|
+
["gl", 8],
|
|
262
|
+
["visitorData", 4096],
|
|
263
|
+
["platform", 64],
|
|
264
|
+
["clientFormFactor", 64],
|
|
265
|
+
["originalUrl", 2048],
|
|
266
|
+
["utcOffsetMinutes", 16],
|
|
267
|
+
["timeZone", 128],
|
|
268
|
+
["userAgent", 1024],
|
|
269
|
+
] as const) {
|
|
270
|
+
const candidate = optionalContextScalar(sourceClient, key, maximum);
|
|
271
|
+
if (candidate !== undefined) client[key] = candidate;
|
|
272
|
+
}
|
|
273
|
+
const sourceUser = isRecord(source.user) ? source.user : null;
|
|
274
|
+
const user: Record<string, string | boolean> = {};
|
|
275
|
+
if (sourceUser !== null) {
|
|
276
|
+
const lockedSafetyMode = sourceUser.lockedSafetyMode;
|
|
277
|
+
if (typeof lockedSafetyMode === "boolean") user.lockedSafetyMode = lockedSafetyMode;
|
|
278
|
+
const onBehalfOfUser = optionalBoundedString(sourceUser.onBehalfOfUser, 512);
|
|
279
|
+
if (onBehalfOfUser !== null) user.onBehalfOfUser = onBehalfOfUser;
|
|
280
|
+
}
|
|
281
|
+
const sourceRequest = isRecord(source.request) ? source.request : null;
|
|
282
|
+
const request = sourceRequest !== null && typeof sourceRequest.useSsl === "boolean"
|
|
283
|
+
? { useSsl: sourceRequest.useSsl }
|
|
284
|
+
: undefined;
|
|
285
|
+
return Object.freeze({
|
|
286
|
+
client: Object.freeze(client),
|
|
287
|
+
...(Object.keys(user).length === 0 ? {} : { user: Object.freeze(user) }),
|
|
288
|
+
...(request === undefined ? {} : { request: Object.freeze(request) }),
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function parseYouTubeBootstrapHtml(html: string): YouTubeBootstrapConfig {
|
|
293
|
+
const configs = ytcfgObjects(html);
|
|
294
|
+
const apiKey = boundedString(uniqueConfigValue(configs, "INNERTUBE_API_KEY"), "YouTube INNERTUBE_API_KEY", 256);
|
|
295
|
+
if (!/^[A-Za-z0-9_-]{20,256}$/u.test(apiKey)) {
|
|
296
|
+
throw new Error("YouTube INNERTUBE_API_KEY had an invalid public-key shape");
|
|
297
|
+
}
|
|
298
|
+
const contextValue = uniqueConfigValue(configs, "INNERTUBE_CONTEXT");
|
|
299
|
+
const contextRecord = record(contextValue, "YouTube INNERTUBE_CONTEXT");
|
|
300
|
+
const contextClient = record(contextRecord.client, "YouTube INNERTUBE_CONTEXT.client");
|
|
301
|
+
const clientName = boundedString(
|
|
302
|
+
contextClient.clientName ?? uniqueConfigValue(configs, "INNERTUBE_CONTEXT_CLIENT_NAME"),
|
|
303
|
+
"YouTube client name",
|
|
304
|
+
64,
|
|
305
|
+
);
|
|
306
|
+
if (!/^[A-Z][A-Z0-9_]{1,63}$/u.test(clientName)) throw new Error("YouTube client name is invalid");
|
|
307
|
+
const clientVersion = boundedString(
|
|
308
|
+
contextClient.clientVersion ?? uniqueConfigValue(configs, "INNERTUBE_CONTEXT_CLIENT_VERSION"),
|
|
309
|
+
"YouTube client version",
|
|
310
|
+
128,
|
|
311
|
+
);
|
|
312
|
+
if (!/^[A-Za-z0-9._-]{3,128}$/u.test(clientVersion)) throw new Error("YouTube client version is invalid");
|
|
313
|
+
const clientNameHeader = boundedIntegerString(
|
|
314
|
+
uniqueConfigValue(configs, "INNERTUBE_CONTEXT_CLIENT_NAME"),
|
|
315
|
+
"YouTube numeric client name",
|
|
316
|
+
);
|
|
317
|
+
const loggedIn = uniqueConfigValue(configs, "LOGGED_IN");
|
|
318
|
+
if (typeof loggedIn !== "boolean") throw new Error("YouTube bootstrap omitted its login-state flag");
|
|
319
|
+
const sessionIndex = boundedIntegerString(
|
|
320
|
+
uniqueConfigValue(configs, "SESSION_INDEX") ?? 0,
|
|
321
|
+
"YouTube session index",
|
|
322
|
+
);
|
|
323
|
+
const delegatedSessionId = optionalBoundedString(
|
|
324
|
+
uniqueConfigValue(configs, "DELEGATED_SESSION_ID"),
|
|
325
|
+
512,
|
|
326
|
+
);
|
|
327
|
+
const context = reviewedContext(contextRecord, clientName, clientVersion);
|
|
328
|
+
const visitorData = optionalBoundedString(context.client.visitorData, 4096);
|
|
329
|
+
return Object.freeze({
|
|
330
|
+
apiKey,
|
|
331
|
+
bootstrapLoggedIn: loggedIn,
|
|
332
|
+
clientName,
|
|
333
|
+
clientNameHeader,
|
|
334
|
+
clientVersion,
|
|
335
|
+
context,
|
|
336
|
+
sessionIndex,
|
|
337
|
+
delegatedSessionId,
|
|
338
|
+
visitorData,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function createYouTubeSapisidAuthorization(
|
|
343
|
+
sapisid: string,
|
|
344
|
+
nowMs: number,
|
|
345
|
+
origin = YOUTUBE_ORIGIN,
|
|
346
|
+
): string {
|
|
347
|
+
if (origin !== YOUTUBE_ORIGIN) throw new Error("YouTube SAPISIDHASH origin is not reviewed");
|
|
348
|
+
if (
|
|
349
|
+
typeof sapisid !== "string"
|
|
350
|
+
|| sapisid.length < 8
|
|
351
|
+
|| sapisid.length > 4096
|
|
352
|
+
|| /[\0\r\n\s]/u.test(sapisid)
|
|
353
|
+
) throw new Error("YouTube SAPISID cookie is invalid");
|
|
354
|
+
if (!Number.isSafeInteger(nowMs) || nowMs < 0) throw new Error("YouTube authorization time is invalid");
|
|
355
|
+
const timestamp = Math.floor(nowMs / 1000);
|
|
356
|
+
const digest = createHash("sha1")
|
|
357
|
+
.update(`${timestamp} ${sapisid} ${origin}`, "utf8")
|
|
358
|
+
.digest("hex");
|
|
359
|
+
return `SAPISIDHASH ${timestamp}_${digest}`;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function walkRecords(value: unknown, label: string): readonly JsonRecord[] {
|
|
363
|
+
const result: JsonRecord[] = [];
|
|
364
|
+
const stack: { readonly value: unknown; readonly depth: number }[] = [{ value, depth: 0 }];
|
|
365
|
+
let visited = 0;
|
|
366
|
+
while (stack.length > 0) {
|
|
367
|
+
const current = stack.pop()!;
|
|
368
|
+
visited += 1;
|
|
369
|
+
if (visited > MAX_WALK_NODES) throw new Error(`${label} exceeded its reviewed node limit`);
|
|
370
|
+
if (current.depth > MAX_WALK_DEPTH) throw new Error(`${label} exceeded its reviewed nesting limit`);
|
|
371
|
+
if (Array.isArray(current.value)) {
|
|
372
|
+
if (current.value.length > 10_000) throw new Error(`${label} contained an oversized array`);
|
|
373
|
+
for (let index = current.value.length - 1; index >= 0; index -= 1) {
|
|
374
|
+
stack.push({ value: current.value[index], depth: current.depth + 1 });
|
|
375
|
+
}
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
if (!isRecord(current.value)) continue;
|
|
379
|
+
result.push(current.value);
|
|
380
|
+
const values = Object.values(current.value);
|
|
381
|
+
for (let index = values.length - 1; index >= 0; index -= 1) {
|
|
382
|
+
stack.push({ value: values[index], depth: current.depth + 1 });
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return Object.freeze(result);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function simpleText(value: unknown, maximum = 32_768): string | null {
|
|
389
|
+
if (typeof value === "string") return optionalBoundedString(value, maximum);
|
|
390
|
+
if (!isRecord(value)) return null;
|
|
391
|
+
const direct = optionalBoundedString(value.simpleText, maximum);
|
|
392
|
+
if (direct !== null) return direct;
|
|
393
|
+
if (!Array.isArray(value.runs) || value.runs.length > 1_000) return null;
|
|
394
|
+
let result = "";
|
|
395
|
+
for (const run of value.runs) {
|
|
396
|
+
if (!isRecord(run) || typeof run.text !== "string" || run.text.length > maximum) return null;
|
|
397
|
+
result += run.text;
|
|
398
|
+
if (result.length > maximum) return null;
|
|
399
|
+
}
|
|
400
|
+
return result.length > 0 ? result : null;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function channelIdFromEndpoint(value: unknown): string | null {
|
|
404
|
+
if (!isRecord(value)) return null;
|
|
405
|
+
const browse = isRecord(value.browseEndpoint) ? value.browseEndpoint : null;
|
|
406
|
+
const candidate = browse?.browseId;
|
|
407
|
+
return typeof candidate === "string" && /^UC[A-Za-z0-9_-]{22}$/u.test(candidate)
|
|
408
|
+
? candidate
|
|
409
|
+
: null;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function canonicalYouTubePath(value: unknown): string | null {
|
|
413
|
+
if (!isRecord(value)) return null;
|
|
414
|
+
const command = isRecord(value.commandMetadata) ? value.commandMetadata : null;
|
|
415
|
+
const web = command !== null && isRecord(command.webCommandMetadata) ? command.webCommandMetadata : null;
|
|
416
|
+
const url = optionalBoundedString(web?.url, 2048);
|
|
417
|
+
return url !== null && url.startsWith("/") && !url.startsWith("//") ? url : null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function uniqueStrings(values: readonly (string | null)[], label: string): readonly string[] {
|
|
421
|
+
const unique = [...new Set(values.filter((value): value is string => value !== null))];
|
|
422
|
+
if (unique.length > 1000) throw new Error(`${label} exceeded its reviewed identity limit`);
|
|
423
|
+
return Object.freeze(unique);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function identityCandidates(value: unknown): readonly string[] {
|
|
427
|
+
const records = walkRecords(value, "YouTube account response");
|
|
428
|
+
return uniqueStrings(records.flatMap((item) => {
|
|
429
|
+
const candidates: (string | null)[] = [];
|
|
430
|
+
for (const key of ["channelId", "externalChannelId", "browseId"] as const) {
|
|
431
|
+
const candidate = item[key];
|
|
432
|
+
candidates.push(
|
|
433
|
+
typeof candidate === "string" && /^UC[A-Za-z0-9_-]{22}$/u.test(candidate)
|
|
434
|
+
? candidate
|
|
435
|
+
: null,
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
return candidates;
|
|
439
|
+
}), "YouTube account response");
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function selectedIdentityCandidates(value: unknown): readonly string[] {
|
|
443
|
+
const selected: string[] = [];
|
|
444
|
+
for (const item of walkRecords(value, "YouTube account list response")) {
|
|
445
|
+
if (item.isSelected !== true && item.selected !== true && item.isCurrentAccount !== true) continue;
|
|
446
|
+
selected.push(...identityCandidates(item));
|
|
447
|
+
}
|
|
448
|
+
return uniqueStrings(selected, "YouTube selected account response");
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function selectedGaiaCandidates(value: unknown): readonly string[] {
|
|
452
|
+
const selected: string[] = [];
|
|
453
|
+
for (const item of walkRecords(value, "YouTube account list response")) {
|
|
454
|
+
if (item.isSelected !== true && item.selected !== true && item.isCurrentAccount !== true) continue;
|
|
455
|
+
for (const nested of walkRecords(item, "YouTube selected account")) {
|
|
456
|
+
const candidate = nested.obfuscatedGaiaId ?? nested.gaiaId;
|
|
457
|
+
if (typeof candidate === "string" && /^[0-9]{1,32}$/u.test(candidate)) {
|
|
458
|
+
selected.push(candidate);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return uniqueStrings(selected, "YouTube selected Gaia account");
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export function youtubeCurrentSubject(
|
|
466
|
+
accountMenu: unknown,
|
|
467
|
+
accountsList: unknown,
|
|
468
|
+
delegatedSessionId: string | null = null,
|
|
469
|
+
): string {
|
|
470
|
+
assertYouTubeResponseSuccess(accountMenu, "YouTube account menu");
|
|
471
|
+
assertYouTubeResponseSuccess(accountsList, "YouTube accounts list");
|
|
472
|
+
const menuCandidates = identityCandidates(accountMenu);
|
|
473
|
+
const selectedCandidates = selectedIdentityCandidates(accountsList);
|
|
474
|
+
const listCandidates = selectedCandidates.length > 0 ? selectedCandidates : identityCandidates(accountsList);
|
|
475
|
+
const intersection = menuCandidates.filter((candidate) => listCandidates.includes(candidate));
|
|
476
|
+
const candidates = intersection.length > 0
|
|
477
|
+
? uniqueStrings(intersection, "YouTube current account")
|
|
478
|
+
: menuCandidates.length === 1 && listCandidates.length === 0
|
|
479
|
+
? menuCandidates
|
|
480
|
+
: listCandidates.length === 1 && menuCandidates.length === 0
|
|
481
|
+
? listCandidates
|
|
482
|
+
: [];
|
|
483
|
+
if (candidates.length !== 1) {
|
|
484
|
+
throw new Error("YouTube account endpoints did not bind one unique current channel");
|
|
485
|
+
}
|
|
486
|
+
const gaiaCandidates = selectedGaiaCandidates(accountsList);
|
|
487
|
+
if (gaiaCandidates.length > 1) {
|
|
488
|
+
throw new Error("YouTube accounts list did not bind one unique selected Gaia account");
|
|
489
|
+
}
|
|
490
|
+
if (
|
|
491
|
+
delegatedSessionId !== null
|
|
492
|
+
&& !/^[A-Za-z0-9_-]{1,128}$/u.test(delegatedSessionId)
|
|
493
|
+
) throw new Error("YouTube bootstrap exposed an invalid delegated-session identity");
|
|
494
|
+
return [
|
|
495
|
+
`youtube:channel:${candidates[0]!}`,
|
|
496
|
+
...(gaiaCandidates.length === 0 ? [] : [`gaia:${gaiaCandidates[0]!}`]),
|
|
497
|
+
...(delegatedSessionId === null ? [] : [`delegate:${delegatedSessionId}`]),
|
|
498
|
+
].join("/");
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export function assertYouTubeResponseSuccess(value: unknown, label: string): JsonRecord {
|
|
502
|
+
const envelope = record(value, `${label} response`);
|
|
503
|
+
if (envelope.error !== undefined) throw new Error(`${label} response contained an API error`);
|
|
504
|
+
for (const item of walkRecords(envelope.alerts, `${label} alerts`)) {
|
|
505
|
+
if (item.type === "ERROR") throw new Error(`${label} response contained an error alert`);
|
|
506
|
+
}
|
|
507
|
+
return envelope;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Build the bounded public-bundle-proven authored-video deletion request.
|
|
512
|
+
* Dispatch remains intentionally absent until an authorized accepted response,
|
|
513
|
+
* current-account pre-read, and exact-target absence readback are captured.
|
|
514
|
+
*/
|
|
515
|
+
export function youtubeVideoDeleteRequest(
|
|
516
|
+
config: YouTubeBootstrapConfig,
|
|
517
|
+
videoIdValue: unknown,
|
|
518
|
+
): YouTubeVideoDeleteRequest {
|
|
519
|
+
const videoId = boundedString(videoIdValue, "YouTube delete video ID", 11);
|
|
520
|
+
if (!/^[A-Za-z0-9_-]{11}$/u.test(videoId)) {
|
|
521
|
+
throw new Error("YouTube delete video ID must be exact");
|
|
522
|
+
}
|
|
523
|
+
if (
|
|
524
|
+
config.context.client.clientName !== config.clientName
|
|
525
|
+
|| config.context.client.clientVersion !== config.clientVersion
|
|
526
|
+
) throw new Error("YouTube delete context did not match its reviewed bootstrap");
|
|
527
|
+
return Object.freeze({
|
|
528
|
+
body: Object.freeze({ context: config.context, videoId }),
|
|
529
|
+
endpoint: "video/delete" as const,
|
|
530
|
+
method: "POST" as const,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function exactYouTubePublicUrl(value: unknown, _label: string): string | null {
|
|
535
|
+
if (typeof value !== "string" || value.length < 1 || value.length > 2048 || /[\0\r\n]/u.test(value)) {
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
const candidate = value.startsWith("https://")
|
|
539
|
+
? value
|
|
540
|
+
: value.startsWith("www.youtube.com/") || value.startsWith("youtube.com/")
|
|
541
|
+
? `https://${value}`
|
|
542
|
+
: value.startsWith("/") && !value.startsWith("//")
|
|
543
|
+
? `${YOUTUBE_ORIGIN}${value}`
|
|
544
|
+
: null;
|
|
545
|
+
if (candidate === null) return null;
|
|
546
|
+
let url: URL;
|
|
547
|
+
try {
|
|
548
|
+
url = new URL(candidate);
|
|
549
|
+
} catch {
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
if (
|
|
553
|
+
url.origin !== YOUTUBE_ORIGIN
|
|
554
|
+
|| url.username !== ""
|
|
555
|
+
|| url.password !== ""
|
|
556
|
+
|| url.search !== ""
|
|
557
|
+
|| url.hash !== ""
|
|
558
|
+
) return null;
|
|
559
|
+
const path = url.pathname.replace(/\/$/u, "");
|
|
560
|
+
if (
|
|
561
|
+
!/^\/@[A-Za-z0-9._-]{2,64}$/u.test(path)
|
|
562
|
+
&& !/^\/channel\/UC[A-Za-z0-9_-]{22}$/u.test(path)
|
|
563
|
+
) return null;
|
|
564
|
+
return `${YOUTUBE_ORIGIN}${path}`;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
export function youtubeProfileTarget(value: unknown): YouTubeProfileTarget {
|
|
568
|
+
const input = boundedString(value, "input.profile", 2048);
|
|
569
|
+
const candidate = input.startsWith("@") ? `${YOUTUBE_ORIGIN}/${input}` : input;
|
|
570
|
+
const url = exactYouTubePublicUrl(candidate, "input.profile");
|
|
571
|
+
if (url === null) {
|
|
572
|
+
throw new Error("input.profile must be an exact @handle or canonical YouTube channel URL");
|
|
573
|
+
}
|
|
574
|
+
const pathname = new URL(url).pathname;
|
|
575
|
+
const handle = pathname.startsWith("/@") ? pathname.slice(2) : null;
|
|
576
|
+
const channelId = pathname.startsWith("/channel/") ? pathname.slice("/channel/".length) : null;
|
|
577
|
+
return Object.freeze({ url, handle, channelId });
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function youtubeHandleFromUrl(value: string): string | null {
|
|
581
|
+
const path = new URL(value).pathname;
|
|
582
|
+
return path.startsWith("/@") ? path.slice(2) : null;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
export function youtubeProfileBrowseRequest(
|
|
586
|
+
value: unknown,
|
|
587
|
+
target: YouTubeProfileTarget,
|
|
588
|
+
): Readonly<{ browseId: string }> {
|
|
589
|
+
assertYouTubeResponseSuccess(value, "YouTube profile URL resolution");
|
|
590
|
+
const candidates = new Map<string, Readonly<{ browseId: string; params: string }>>();
|
|
591
|
+
for (const item of walkRecords(value, "YouTube profile URL resolution")) {
|
|
592
|
+
const browse = isRecord(item.browseEndpoint) ? item.browseEndpoint : null;
|
|
593
|
+
const browseId = browse?.browseId;
|
|
594
|
+
if (typeof browseId !== "string" || !/^UC[A-Za-z0-9_-]{22}$/u.test(browseId)) continue;
|
|
595
|
+
const params = browse?.params;
|
|
596
|
+
if (
|
|
597
|
+
typeof params !== "string"
|
|
598
|
+
|| params.length < 1
|
|
599
|
+
|| params.length > 4096
|
|
600
|
+
|| /[\0\r\n]/u.test(params)
|
|
601
|
+
) continue;
|
|
602
|
+
if (target.channelId !== null) {
|
|
603
|
+
if (browseId === target.channelId) {
|
|
604
|
+
candidates.set(`${browseId}\0${params}`, Object.freeze({ browseId, params }));
|
|
605
|
+
}
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
// The reviewed resolve_url request body already carries the exact
|
|
609
|
+
// canonical handle URL. Its response does not echo that public URL; bind
|
|
610
|
+
// the result by requiring one and only one valid channel browse target.
|
|
611
|
+
candidates.set(`${browseId}\0${params}`, Object.freeze({ browseId, params }));
|
|
612
|
+
}
|
|
613
|
+
if (candidates.size !== 1) {
|
|
614
|
+
throw new Error("YouTube profile URL resolution did not bind one exact channel");
|
|
615
|
+
}
|
|
616
|
+
const candidate = candidates.values().next().value!;
|
|
617
|
+
return Object.freeze({ browseId: candidate.browseId });
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function exactEnglishCount(
|
|
621
|
+
value: unknown,
|
|
622
|
+
singular: string,
|
|
623
|
+
plural: string,
|
|
624
|
+
): number | null {
|
|
625
|
+
const text = simpleText(value, 128);
|
|
626
|
+
if (text === null) return null;
|
|
627
|
+
if (text === `No ${plural}`) return 0;
|
|
628
|
+
const match = new RegExp(
|
|
629
|
+
`^((?:0|[1-9][0-9]{0,2}(?:,[0-9]{3})*)) (?:${singular}|${plural})$`,
|
|
630
|
+
"u",
|
|
631
|
+
).exec(text);
|
|
632
|
+
if (match === null) return null;
|
|
633
|
+
const number = Number(match[1]!.replaceAll(",", ""));
|
|
634
|
+
return Number.isSafeInteger(number) && number >= 0 ? number : null;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function uniqueMetadataRenderer(
|
|
638
|
+
value: unknown,
|
|
639
|
+
expectedChannelId: string,
|
|
640
|
+
): JsonRecord {
|
|
641
|
+
const candidates = walkRecords(value, "YouTube profile response").filter((item) =>
|
|
642
|
+
item.externalId === expectedChannelId
|
|
643
|
+
&& typeof item.title === "string"
|
|
644
|
+
&& (Object.hasOwn(item, "vanityChannelUrl") || Object.hasOwn(item, "description")));
|
|
645
|
+
const signatures = new Map(candidates.map((item) => [JSON.stringify(item), item]));
|
|
646
|
+
if (signatures.size !== 1) {
|
|
647
|
+
throw new Error("YouTube profile response did not bind one exact channel metadata renderer");
|
|
648
|
+
}
|
|
649
|
+
return signatures.values().next().value!;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function exactYouTubeResponsePublicUrl(value: unknown): string | null {
|
|
653
|
+
const normalized = typeof value === "string" && value.startsWith("http://www.youtube.com/")
|
|
654
|
+
? `https://${value.slice("http://".length)}`
|
|
655
|
+
: value;
|
|
656
|
+
return exactYouTubePublicUrl(normalized, "YouTube response canonical URL");
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function uniqueAboutViewModel(
|
|
660
|
+
value: unknown,
|
|
661
|
+
expectedChannelId: string,
|
|
662
|
+
expectedHandle: string | null,
|
|
663
|
+
): JsonRecord | null {
|
|
664
|
+
const candidates = walkRecords(value, "YouTube profile response").filter((item) => {
|
|
665
|
+
if (!Object.hasOwn(item, "videoCountText") || !Object.hasOwn(item, "viewCountText")) return false;
|
|
666
|
+
if (item.channelId !== expectedChannelId) return false;
|
|
667
|
+
const modelUrl = exactYouTubeResponsePublicUrl(item.canonicalChannelUrl);
|
|
668
|
+
if (modelUrl === null) return false;
|
|
669
|
+
const modelHandle = youtubeHandleFromUrl(modelUrl);
|
|
670
|
+
return expectedHandle === null
|
|
671
|
+
|| modelHandle?.toLocaleLowerCase("en-US") === expectedHandle.toLocaleLowerCase("en-US");
|
|
672
|
+
});
|
|
673
|
+
const signatures = new Map(candidates.map((item) => [JSON.stringify({
|
|
674
|
+
channelId: item.channelId,
|
|
675
|
+
canonicalChannelUrl: exactYouTubeResponsePublicUrl(item.canonicalChannelUrl),
|
|
676
|
+
subscriberCountText: simpleText(item.subscriberCountText, 128),
|
|
677
|
+
videoCountText: simpleText(item.videoCountText, 128),
|
|
678
|
+
viewCountText: simpleText(item.viewCountText, 128),
|
|
679
|
+
}), item]));
|
|
680
|
+
if (signatures.size > 1) {
|
|
681
|
+
throw new Error("YouTube profile response contained conflicting exact about statistics");
|
|
682
|
+
}
|
|
683
|
+
return signatures.size === 0 ? null : signatures.values().next().value!;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** Project exact values only; rounded or omitted text remains unavailable. */
|
|
687
|
+
export function projectYouTubeProfile(
|
|
688
|
+
value: unknown,
|
|
689
|
+
expectedChannelId: string,
|
|
690
|
+
expectedHandle: string | null = null,
|
|
691
|
+
): YouTubeProjectedProfile {
|
|
692
|
+
assertYouTubeResponseSuccess(value, "YouTube profile");
|
|
693
|
+
if (!/^UC[A-Za-z0-9_-]{22}$/u.test(expectedChannelId)) {
|
|
694
|
+
throw new Error("Expected YouTube channel ID is invalid");
|
|
695
|
+
}
|
|
696
|
+
if (expectedHandle !== null && !/^[A-Za-z0-9._-]{2,64}$/u.test(expectedHandle)) {
|
|
697
|
+
throw new Error("Expected YouTube handle is invalid");
|
|
698
|
+
}
|
|
699
|
+
const metadata = uniqueMetadataRenderer(value, expectedChannelId);
|
|
700
|
+
const displayName = boundedString(metadata.title, "YouTube profile title", 256);
|
|
701
|
+
const metadataUrl = exactYouTubePublicUrl(
|
|
702
|
+
metadata.vanityChannelUrl,
|
|
703
|
+
"YouTube profile canonical URL",
|
|
704
|
+
);
|
|
705
|
+
const metadataHandle = metadataUrl === null ? null : youtubeHandleFromUrl(metadataUrl);
|
|
706
|
+
if (
|
|
707
|
+
expectedHandle !== null
|
|
708
|
+
&& metadataHandle !== null
|
|
709
|
+
&& metadataHandle.toLocaleLowerCase("en-US") !== expectedHandle.toLocaleLowerCase("en-US")
|
|
710
|
+
) throw new Error("YouTube profile metadata did not bind the requested handle");
|
|
711
|
+
const about = uniqueAboutViewModel(value, expectedChannelId, expectedHandle);
|
|
712
|
+
const aboutUrl = about === null
|
|
713
|
+
? null
|
|
714
|
+
: exactYouTubeResponsePublicUrl(about.canonicalChannelUrl);
|
|
715
|
+
const canonicalUrl = expectedHandle === null
|
|
716
|
+
? metadataUrl ?? aboutUrl ?? `${YOUTUBE_ORIGIN}/channel/${expectedChannelId}`
|
|
717
|
+
: `${YOUTUBE_ORIGIN}/@${expectedHandle}`;
|
|
718
|
+
return Object.freeze({
|
|
719
|
+
channelId: expectedChannelId,
|
|
720
|
+
canonicalUrl,
|
|
721
|
+
handle: expectedHandle ?? youtubeHandleFromUrl(canonicalUrl),
|
|
722
|
+
displayName,
|
|
723
|
+
bio: optionalBoundedString(metadata.description, 4096),
|
|
724
|
+
subscribers: exactEnglishCount(about?.subscriberCountText, "subscriber", "subscribers"),
|
|
725
|
+
videos: exactEnglishCount(about?.videoCountText, "video", "videos"),
|
|
726
|
+
views: exactEnglishCount(about?.viewCountText, "view", "views"),
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function projectionFromRenderer(renderer: JsonRecord): YouTubeProjectedItem | null {
|
|
731
|
+
const videoId = optionalBoundedString(renderer.videoId, 64);
|
|
732
|
+
const playlistId = optionalBoundedString(renderer.playlistId, 256);
|
|
733
|
+
const postId = optionalBoundedString(renderer.postId, 256);
|
|
734
|
+
const directChannelId = optionalBoundedString(renderer.channelId, 64);
|
|
735
|
+
const kind = videoId !== null && /^[A-Za-z0-9_-]{11}$/u.test(videoId)
|
|
736
|
+
? "video"
|
|
737
|
+
: playlistId !== null && /^[A-Za-z0-9_-]{2,256}$/u.test(playlistId)
|
|
738
|
+
? "playlist"
|
|
739
|
+
: postId !== null && /^[A-Za-z0-9_-]{10,256}$/u.test(postId)
|
|
740
|
+
? "post"
|
|
741
|
+
: directChannelId !== null && /^UC[A-Za-z0-9_-]{22}$/u.test(directChannelId)
|
|
742
|
+
? "channel"
|
|
743
|
+
: null;
|
|
744
|
+
if (kind === null) return null;
|
|
745
|
+
const id = kind === "video"
|
|
746
|
+
? videoId!
|
|
747
|
+
: kind === "playlist"
|
|
748
|
+
? playlistId!
|
|
749
|
+
: kind === "post"
|
|
750
|
+
? postId!
|
|
751
|
+
: directChannelId!;
|
|
752
|
+
const ownerEndpoint = isRecord(renderer.ownerText) && Array.isArray(renderer.ownerText.runs)
|
|
753
|
+
? (renderer.ownerText.runs.find(isRecord)?.navigationEndpoint ?? null)
|
|
754
|
+
: null;
|
|
755
|
+
const authorEndpoint = isRecord(renderer.authorText) && Array.isArray(renderer.authorText.runs)
|
|
756
|
+
? (renderer.authorText.runs.find(isRecord)?.navigationEndpoint ?? null)
|
|
757
|
+
: null;
|
|
758
|
+
const channelId = kind === "channel"
|
|
759
|
+
? id
|
|
760
|
+
: channelIdFromEndpoint(ownerEndpoint) ?? channelIdFromEndpoint(authorEndpoint)
|
|
761
|
+
?? optionalBoundedString(renderer.channelId, 64);
|
|
762
|
+
const title = simpleText(renderer.title) ?? simpleText(renderer.headline)
|
|
763
|
+
?? simpleText(renderer.contentText) ?? simpleText(renderer.name);
|
|
764
|
+
const endpoint = renderer.navigationEndpoint ?? renderer.endpoint;
|
|
765
|
+
const url = canonicalYouTubePath(endpoint)
|
|
766
|
+
?? (kind === "video" ? `/watch?v=${id}` : kind === "channel" ? `/channel/${id}` : null);
|
|
767
|
+
return Object.freeze({
|
|
768
|
+
kind,
|
|
769
|
+
id,
|
|
770
|
+
title,
|
|
771
|
+
url,
|
|
772
|
+
channelId: channelId !== null && /^UC[A-Za-z0-9_-]{22}$/u.test(channelId) ? channelId : null,
|
|
773
|
+
channelName: simpleText(renderer.ownerText) ?? simpleText(renderer.shortBylineText)
|
|
774
|
+
?? simpleText(renderer.longBylineText) ?? simpleText(renderer.authorText),
|
|
775
|
+
description: simpleText(renderer.descriptionSnippet, 4096)
|
|
776
|
+
?? simpleText(renderer.descriptionText, 4096)
|
|
777
|
+
?? simpleText(renderer.contentText, 4096),
|
|
778
|
+
published: simpleText(renderer.publishedTimeText, 256),
|
|
779
|
+
duration: simpleText(renderer.lengthText, 256),
|
|
780
|
+
views: simpleText(renderer.viewCountText, 256)
|
|
781
|
+
?? simpleText(renderer.shortViewCountText, 256),
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
export function projectYouTubeItems(
|
|
786
|
+
value: unknown,
|
|
787
|
+
limit: number,
|
|
788
|
+
): { readonly items: readonly YouTubeProjectedItem[]; readonly continuation: string | null; readonly truncated: boolean } {
|
|
789
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) {
|
|
790
|
+
throw new Error("YouTube projection limit must be between 1 and 100");
|
|
791
|
+
}
|
|
792
|
+
const records = walkRecords(value, "YouTube browse response");
|
|
793
|
+
const items: YouTubeProjectedItem[] = [];
|
|
794
|
+
const seen = new Set<string>();
|
|
795
|
+
for (const item of records) {
|
|
796
|
+
const renderer = Object.entries(item)
|
|
797
|
+
.filter(([key, candidate]) => key.endsWith("Renderer") && isRecord(candidate))
|
|
798
|
+
.map(([, candidate]) => candidate as JsonRecord)
|
|
799
|
+
.find((candidate) => projectionFromRenderer(candidate) !== null);
|
|
800
|
+
const projection = renderer === undefined ? projectionFromRenderer(item) : projectionFromRenderer(renderer);
|
|
801
|
+
if (projection === null) continue;
|
|
802
|
+
const key = `${projection.kind}:${projection.id}`;
|
|
803
|
+
if (seen.has(key)) continue;
|
|
804
|
+
seen.add(key);
|
|
805
|
+
items.push(projection);
|
|
806
|
+
}
|
|
807
|
+
const continuations = uniqueStrings(records.map((item) => {
|
|
808
|
+
const command = isRecord(item.continuationCommand) ? item.continuationCommand : null;
|
|
809
|
+
const token = optionalBoundedString(command?.token, 8192);
|
|
810
|
+
return token;
|
|
811
|
+
}), "YouTube continuation");
|
|
812
|
+
return Object.freeze({
|
|
813
|
+
items: Object.freeze(items.slice(0, limit)),
|
|
814
|
+
continuation: continuations[0] ?? null,
|
|
815
|
+
truncated: items.length > limit || continuations.length > 0,
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
export function projectYouTubeMedia(value: unknown, expectedVideoId: string): Readonly<Record<string, unknown>> {
|
|
820
|
+
const envelope = assertYouTubeResponseSuccess(value, "YouTube player");
|
|
821
|
+
const details = record(envelope.videoDetails, "YouTube player.videoDetails");
|
|
822
|
+
if (details.videoId !== expectedVideoId) throw new Error("YouTube player response did not bind the requested video");
|
|
823
|
+
const microformat = isRecord(envelope.microformat) && isRecord(envelope.microformat.playerMicroformatRenderer)
|
|
824
|
+
? envelope.microformat.playerMicroformatRenderer
|
|
825
|
+
: {};
|
|
826
|
+
const keywords = Array.isArray(details.keywords)
|
|
827
|
+
? details.keywords.filter((item): item is string =>
|
|
828
|
+
typeof item === "string" && item.length > 0 && item.length <= 256).slice(0, 100)
|
|
829
|
+
: [];
|
|
830
|
+
const countries = Array.isArray(microformat.availableCountries)
|
|
831
|
+
? microformat.availableCountries.filter((item): item is string =>
|
|
832
|
+
typeof item === "string" && /^[A-Z]{2}$/u.test(item)).slice(0, 300)
|
|
833
|
+
: [];
|
|
834
|
+
const playability = isRecord(envelope.playabilityStatus) ? envelope.playabilityStatus : {};
|
|
835
|
+
return Object.freeze({
|
|
836
|
+
videoId: expectedVideoId,
|
|
837
|
+
title: optionalBoundedString(details.title, 1024),
|
|
838
|
+
channelId: optionalBoundedString(details.channelId, 64),
|
|
839
|
+
author: optionalBoundedString(details.author, 512),
|
|
840
|
+
lengthSeconds: optionalBoundedString(details.lengthSeconds, 32),
|
|
841
|
+
viewCount: optionalBoundedString(details.viewCount, 32),
|
|
842
|
+
shortDescription: optionalBoundedString(details.shortDescription, 16_384),
|
|
843
|
+
keywords: Object.freeze(keywords),
|
|
844
|
+
isLiveContent: details.isLiveContent === true,
|
|
845
|
+
playability: optionalBoundedString(playability.status, 64),
|
|
846
|
+
publishDate: optionalBoundedString(microformat.publishDate, 64),
|
|
847
|
+
uploadDate: optionalBoundedString(microformat.uploadDate, 64),
|
|
848
|
+
category: optionalBoundedString(microformat.category, 256),
|
|
849
|
+
familySafe: microformat.isFamilySafe === true,
|
|
850
|
+
availableCountries: Object.freeze(countries),
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function attachmentKinds(renderer: JsonRecord): readonly string[] {
|
|
855
|
+
const keys = new Set<string>();
|
|
856
|
+
for (const item of walkRecords(renderer, "YouTube Community post")) {
|
|
857
|
+
for (const key of Object.keys(item)) {
|
|
858
|
+
if (key === "backstageImageRenderer" || key === "imageRenderer") keys.add("image");
|
|
859
|
+
if (key === "videoRenderer") keys.add("video");
|
|
860
|
+
if (key === "pollRenderer" || key === "backstagePollRenderer") keys.add("poll");
|
|
861
|
+
if (key === "playlistRenderer") keys.add("playlist");
|
|
862
|
+
if (key === "quizRenderer") keys.add("quiz");
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
return Object.freeze([...keys].sort());
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
export function projectYouTubePost(value: unknown, expectedPostId: string): YouTubeProjectedPost {
|
|
869
|
+
assertYouTubeResponseSuccess(value, "YouTube Community post");
|
|
870
|
+
const renderers: JsonRecord[] = [];
|
|
871
|
+
for (const item of walkRecords(value, "YouTube Community post response")) {
|
|
872
|
+
for (const key of ["backstagePostRenderer", "postRenderer"] as const) {
|
|
873
|
+
if (isRecord(item[key]) && item[key].postId === expectedPostId) renderers.push(item[key]);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
if (renderers.length !== 1) throw new Error("YouTube Community response did not bind one exact requested post");
|
|
877
|
+
const renderer = renderers[0]!;
|
|
878
|
+
const body = simpleText(renderer.contentText, 16_384) ?? simpleText(renderer.content, 16_384);
|
|
879
|
+
if (body === null) throw new Error("YouTube Community post omitted its bounded body");
|
|
880
|
+
const authorRun = isRecord(renderer.authorText) && Array.isArray(renderer.authorText.runs)
|
|
881
|
+
? renderer.authorText.runs.find(isRecord)
|
|
882
|
+
: undefined;
|
|
883
|
+
return Object.freeze({
|
|
884
|
+
id: expectedPostId,
|
|
885
|
+
authorChannelId: channelIdFromEndpoint(authorRun?.navigationEndpoint),
|
|
886
|
+
author: simpleText(renderer.authorText, 512),
|
|
887
|
+
body,
|
|
888
|
+
published: simpleText(renderer.publishedTimeText, 256),
|
|
889
|
+
likes: simpleText(renderer.voteCount, 256)
|
|
890
|
+
?? simpleText(renderer.likeCount, 256),
|
|
891
|
+
attachmentKinds: attachmentKinds(renderer),
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
export function youtubePostBrowseRequest(
|
|
896
|
+
value: unknown,
|
|
897
|
+
expectedPostId: string,
|
|
898
|
+
): { readonly browseId: string; readonly params: string } {
|
|
899
|
+
assertYouTubeResponseSuccess(value, "YouTube Community URL resolution");
|
|
900
|
+
const expectedPath = `/post/${expectedPostId}`;
|
|
901
|
+
const candidates: { readonly browseId: string; readonly params: string }[] = [];
|
|
902
|
+
for (const item of walkRecords(value, "YouTube Community URL resolution")) {
|
|
903
|
+
const endpoint = isRecord(item.endpoint) ? item.endpoint : item;
|
|
904
|
+
const browse = isRecord(endpoint.browseEndpoint) ? endpoint.browseEndpoint : null;
|
|
905
|
+
if (browse === null) continue;
|
|
906
|
+
const command = isRecord(endpoint.commandMetadata) ? endpoint.commandMetadata : null;
|
|
907
|
+
const web = command !== null && isRecord(command.webCommandMetadata)
|
|
908
|
+
? command.webCommandMetadata
|
|
909
|
+
: null;
|
|
910
|
+
const returnedPath = optionalBoundedString(web?.url, 2048);
|
|
911
|
+
const browseId = optionalBoundedString(browse.browseId, 256);
|
|
912
|
+
const params = optionalBoundedString(browse.params, 8192);
|
|
913
|
+
if (returnedPath !== expectedPath || browseId === null || params === null) continue;
|
|
914
|
+
if (!/^[A-Za-z0-9_-]{2,256}$/u.test(browseId) || !/^[A-Za-z0-9_=-]{8,8192}$/u.test(params)) {
|
|
915
|
+
throw new Error("YouTube Community URL resolution returned an invalid browse binding");
|
|
916
|
+
}
|
|
917
|
+
candidates.push({ browseId, params });
|
|
918
|
+
}
|
|
919
|
+
const unique = new Map(candidates.map((candidate) => [
|
|
920
|
+
`${candidate.browseId}\0${candidate.params}`,
|
|
921
|
+
candidate,
|
|
922
|
+
]));
|
|
923
|
+
if (unique.size !== 1) {
|
|
924
|
+
throw new Error("YouTube Community URL resolution did not bind one exact post browse request");
|
|
925
|
+
}
|
|
926
|
+
return Object.freeze(unique.values().next().value!);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function commentProjection(renderer: JsonRecord): YouTubeProjectedComment | null {
|
|
930
|
+
const id = optionalBoundedString(renderer.commentId, 256);
|
|
931
|
+
if (id === null || !/^[A-Za-z0-9_.-]{8,256}$/u.test(id)) return null;
|
|
932
|
+
const body = simpleText(renderer.contentText, 16_384)
|
|
933
|
+
?? simpleText(renderer.commentText, 16_384)
|
|
934
|
+
?? simpleText(renderer.content, 16_384);
|
|
935
|
+
if (body === null) return null;
|
|
936
|
+
const authorRun = isRecord(renderer.authorText) && Array.isArray(renderer.authorText.runs)
|
|
937
|
+
? renderer.authorText.runs.find(isRecord)
|
|
938
|
+
: undefined;
|
|
939
|
+
const parentId = optionalBoundedString(renderer.parentCommentId, 256);
|
|
940
|
+
return Object.freeze({
|
|
941
|
+
id,
|
|
942
|
+
parentId,
|
|
943
|
+
authorChannelId: channelIdFromEndpoint(renderer.authorEndpoint)
|
|
944
|
+
?? channelIdFromEndpoint(authorRun?.navigationEndpoint),
|
|
945
|
+
author: simpleText(renderer.authorText, 512),
|
|
946
|
+
body,
|
|
947
|
+
published: simpleText(renderer.publishedTimeText, 256),
|
|
948
|
+
votes: simpleText(renderer.voteCount, 256),
|
|
949
|
+
heartedByCreator: renderer.isHearted === true
|
|
950
|
+
|| isRecord(renderer.creatorHeart) || isRecord(renderer.creatorHeartButton),
|
|
951
|
+
pinned: renderer.pinnedCommentBadge !== undefined || renderer.isPinned === true,
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function entityCommentProjection(payload: JsonRecord): YouTubeProjectedComment | null {
|
|
956
|
+
const properties = isRecord(payload.properties) ? payload.properties : null;
|
|
957
|
+
const author = isRecord(payload.author) ? payload.author : null;
|
|
958
|
+
const toolbar = isRecord(payload.toolbar) ? payload.toolbar : null;
|
|
959
|
+
if (properties === null) return null;
|
|
960
|
+
const id = optionalBoundedString(properties.commentId, 256);
|
|
961
|
+
if (id === null || !/^[A-Za-z0-9_.-]{8,256}$/u.test(id)) return null;
|
|
962
|
+
const content = isRecord(properties.content) ? properties.content : null;
|
|
963
|
+
const body = simpleText(properties.content, 16_384)
|
|
964
|
+
?? optionalBoundedString(content?.content, 16_384);
|
|
965
|
+
if (body === null) return null;
|
|
966
|
+
const channelId = optionalBoundedString(author?.channelId, 64);
|
|
967
|
+
const parentId = optionalBoundedString(properties.parentCommentId, 256);
|
|
968
|
+
return Object.freeze({
|
|
969
|
+
id,
|
|
970
|
+
parentId: parentId !== null && /^[A-Za-z0-9_.-]{8,256}$/u.test(parentId) ? parentId : null,
|
|
971
|
+
authorChannelId: channelId !== null && /^UC[A-Za-z0-9_-]{22}$/u.test(channelId)
|
|
972
|
+
? channelId
|
|
973
|
+
: null,
|
|
974
|
+
author: optionalBoundedString(author?.displayName, 512),
|
|
975
|
+
body,
|
|
976
|
+
published: optionalBoundedString(properties.publishedTime, 256),
|
|
977
|
+
votes: optionalBoundedString(toolbar?.likeCountNotliked, 256)
|
|
978
|
+
?? optionalBoundedString(toolbar?.likeCountLiked, 256),
|
|
979
|
+
heartedByCreator: toolbar?.heartState === "TOOLBAR_HEART_STATE_HEARTED",
|
|
980
|
+
pinned: properties.isPinned === true,
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
export function projectYouTubeComments(
|
|
985
|
+
value: unknown,
|
|
986
|
+
limit: number,
|
|
987
|
+
): { readonly comments: readonly YouTubeProjectedComment[]; readonly continuation: string | null; readonly truncated: boolean } {
|
|
988
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) {
|
|
989
|
+
throw new Error("YouTube comment limit must be between 1 and 100");
|
|
990
|
+
}
|
|
991
|
+
assertYouTubeResponseSuccess(value, "YouTube comments");
|
|
992
|
+
const records = walkRecords(value, "YouTube comments response");
|
|
993
|
+
const comments: YouTubeProjectedComment[] = [];
|
|
994
|
+
const seen = new Set<string>();
|
|
995
|
+
for (const item of records) {
|
|
996
|
+
const entity = isRecord(item.commentEntityPayload)
|
|
997
|
+
? entityCommentProjection(item.commentEntityPayload)
|
|
998
|
+
: null;
|
|
999
|
+
const projected = entity ?? [
|
|
1000
|
+
isRecord(item.commentRenderer) ? item.commentRenderer : null,
|
|
1001
|
+
isRecord(item.commentViewModel) ? item.commentViewModel : null,
|
|
1002
|
+
item,
|
|
1003
|
+
].map((candidate) => candidate === null ? null : commentProjection(candidate))
|
|
1004
|
+
.find((candidate): candidate is YouTubeProjectedComment => candidate !== null);
|
|
1005
|
+
if (projected === undefined || seen.has(projected.id)) continue;
|
|
1006
|
+
seen.add(projected.id);
|
|
1007
|
+
comments.push(projected);
|
|
1008
|
+
}
|
|
1009
|
+
const continuations = uniqueStrings(records.map((item) => {
|
|
1010
|
+
const command = isRecord(item.continuationCommand) ? item.continuationCommand : null;
|
|
1011
|
+
return optionalBoundedString(command?.token, 8192);
|
|
1012
|
+
}), "YouTube comments continuation");
|
|
1013
|
+
return Object.freeze({
|
|
1014
|
+
comments: Object.freeze(comments.slice(0, limit)),
|
|
1015
|
+
continuation: continuations[0] ?? null,
|
|
1016
|
+
truncated: comments.length > limit || continuations.length > 0,
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
export function findYouTubeCommentsContinuation(value: unknown): string | null {
|
|
1021
|
+
for (const item of walkRecords(value, "YouTube next response")) {
|
|
1022
|
+
const section = isRecord(item.itemSectionRenderer) ? item.itemSectionRenderer : null;
|
|
1023
|
+
const targetId = optionalBoundedString(section?.targetId, 256);
|
|
1024
|
+
if (section === null || targetId === null || !targetId.includes("comment")) continue;
|
|
1025
|
+
const tokens = uniqueStrings(walkRecords(section, "YouTube comments section").map((recordValue) => {
|
|
1026
|
+
const command = isRecord(recordValue.continuationCommand) ? recordValue.continuationCommand : null;
|
|
1027
|
+
return optionalBoundedString(command?.token, 8192);
|
|
1028
|
+
}), "YouTube comments section continuation");
|
|
1029
|
+
if (tokens.length > 1) throw new Error("YouTube comments section exposed ambiguous continuations");
|
|
1030
|
+
if (tokens.length === 1) return tokens[0]!;
|
|
1031
|
+
}
|
|
1032
|
+
return null;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
export function assertYouTubeVideoBinding(value: unknown, expectedVideoId: string, label: string): void {
|
|
1036
|
+
const matches = new Set<string>();
|
|
1037
|
+
for (const item of walkRecords(value, label)) {
|
|
1038
|
+
for (const key of ["videoId", "currentVideoId"] as const) {
|
|
1039
|
+
const candidate = item[key];
|
|
1040
|
+
if (typeof candidate === "string" && /^[A-Za-z0-9_-]{11}$/u.test(candidate)) matches.add(candidate);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
if (!matches.has(expectedVideoId)) throw new Error(`${label} did not bind the requested video`);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
export function youtubeLikeState(value: unknown, expectedVideoId: string): boolean {
|
|
1047
|
+
assertYouTubeResponseSuccess(value, "YouTube like readback");
|
|
1048
|
+
assertYouTubeVideoBinding(value, expectedVideoId, "YouTube like readback");
|
|
1049
|
+
const states = new Set<boolean>();
|
|
1050
|
+
for (const item of walkRecords(value, "YouTube like readback")) {
|
|
1051
|
+
const icon = isRecord(item.defaultIcon) ? item.defaultIcon.iconType
|
|
1052
|
+
: isRecord(item.icon) ? item.icon.iconType
|
|
1053
|
+
: null;
|
|
1054
|
+
if (icon === "LIKE" && typeof item.isToggled === "boolean") states.add(item.isToggled);
|
|
1055
|
+
if (item.likeStatus === "LIKE") states.add(true);
|
|
1056
|
+
if (item.likeStatus === "INDIFFERENT") states.add(false);
|
|
1057
|
+
}
|
|
1058
|
+
if (states.size !== 1) throw new Error("YouTube like readback did not expose one exact state");
|
|
1059
|
+
return states.values().next().value!;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
function exactOpaqueParams(value: unknown, label: string): string {
|
|
1063
|
+
const params = boundedString(value, label, 8192);
|
|
1064
|
+
if (!/^[A-Za-z0-9_=%-]{8,8192}$/u.test(params)) {
|
|
1065
|
+
throw new Error(`${label} had an invalid first-party parameter shape`);
|
|
1066
|
+
}
|
|
1067
|
+
return params;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
export function youtubeLikeMutationRequest(
|
|
1071
|
+
value: unknown,
|
|
1072
|
+
expectedVideoId: string,
|
|
1073
|
+
desired: boolean,
|
|
1074
|
+
): {
|
|
1075
|
+
readonly endpoint: "like/like" | "like/removelike";
|
|
1076
|
+
readonly body: Readonly<Record<string, unknown>>;
|
|
1077
|
+
} {
|
|
1078
|
+
assertYouTubeResponseSuccess(value, "YouTube like command discovery");
|
|
1079
|
+
assertYouTubeVideoBinding(value, expectedVideoId, "YouTube like command discovery");
|
|
1080
|
+
const apiUrl = desired ? "/youtubei/v1/like/like" : "/youtubei/v1/like/removelike";
|
|
1081
|
+
const expectedStatus = desired ? "LIKE" : "INDIFFERENT";
|
|
1082
|
+
const paramsKey = desired ? "likeParams" : "removeLikeParams";
|
|
1083
|
+
const candidates: Readonly<Record<string, unknown>>[] = [];
|
|
1084
|
+
const likeControls: unknown[] = [];
|
|
1085
|
+
for (const item of walkRecords(value, "YouTube like-control discovery")) {
|
|
1086
|
+
const primary = isRecord(item.videoPrimaryInfoRenderer)
|
|
1087
|
+
? item.videoPrimaryInfoRenderer
|
|
1088
|
+
: null;
|
|
1089
|
+
const actions = primary !== null && isRecord(primary.videoActions)
|
|
1090
|
+
? primary.videoActions
|
|
1091
|
+
: null;
|
|
1092
|
+
const menu = actions !== null && isRecord(actions.menuRenderer) ? actions.menuRenderer : null;
|
|
1093
|
+
if (!Array.isArray(menu?.topLevelButtons) || menu.topLevelButtons.length > 100) continue;
|
|
1094
|
+
for (const button of menu.topLevelButtons) {
|
|
1095
|
+
if (!isRecord(button)) continue;
|
|
1096
|
+
const modern = isRecord(button.segmentedLikeDislikeButtonViewModel)
|
|
1097
|
+
? button.segmentedLikeDislikeButtonViewModel
|
|
1098
|
+
: null;
|
|
1099
|
+
const legacy = isRecord(button.segmentedLikeDislikeButtonRenderer)
|
|
1100
|
+
? button.segmentedLikeDislikeButtonRenderer
|
|
1101
|
+
: null;
|
|
1102
|
+
if (modern !== null && modern.likeButtonViewModel !== undefined) {
|
|
1103
|
+
likeControls.push(modern.likeButtonViewModel);
|
|
1104
|
+
}
|
|
1105
|
+
if (legacy !== null && legacy.likeButton !== undefined) likeControls.push(legacy.likeButton);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
for (const root of likeControls) {
|
|
1109
|
+
for (const item of walkRecords(root, "YouTube like control")) {
|
|
1110
|
+
const command = isRecord(item.commandMetadata) ? item.commandMetadata : null;
|
|
1111
|
+
const web = command !== null && isRecord(command.webCommandMetadata)
|
|
1112
|
+
? command.webCommandMetadata
|
|
1113
|
+
: null;
|
|
1114
|
+
const payload = isRecord(item.likeEndpoint) ? item.likeEndpoint : null;
|
|
1115
|
+
if (web?.apiUrl !== apiUrl || payload === null) continue;
|
|
1116
|
+
const target = record(payload.target, "YouTube like command target");
|
|
1117
|
+
if (target.videoId !== expectedVideoId || payload.status !== expectedStatus) continue;
|
|
1118
|
+
candidates.push(Object.freeze({
|
|
1119
|
+
status: expectedStatus,
|
|
1120
|
+
target: Object.freeze({ videoId: expectedVideoId }),
|
|
1121
|
+
[paramsKey]: exactOpaqueParams(payload[paramsKey], `YouTube ${paramsKey}`),
|
|
1122
|
+
}));
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
const unique = new Map(candidates.map((candidate) => [JSON.stringify(candidate), candidate]));
|
|
1126
|
+
if (unique.size !== 1) throw new Error("YouTube did not expose one exact current like command");
|
|
1127
|
+
return Object.freeze({
|
|
1128
|
+
endpoint: desired ? "like/like" : "like/removelike",
|
|
1129
|
+
body: unique.values().next().value!,
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
export function youtubeWatchLaterState(value: unknown, expectedVideoId: string): boolean {
|
|
1134
|
+
assertYouTubeResponseSuccess(value, "YouTube save readback");
|
|
1135
|
+
assertYouTubeVideoBinding(value, expectedVideoId, "YouTube save readback");
|
|
1136
|
+
const states = new Set<boolean>();
|
|
1137
|
+
for (const item of walkRecords(value, "YouTube save readback")) {
|
|
1138
|
+
if (item.playlistId !== "WL") continue;
|
|
1139
|
+
if (!Array.isArray(item.actions) || item.actions.length > 100) continue;
|
|
1140
|
+
for (const action of item.actions) {
|
|
1141
|
+
if (!isRecord(action)) continue;
|
|
1142
|
+
if (action.action === "ACTION_ADD_VIDEO" && action.addedVideoId === expectedVideoId) {
|
|
1143
|
+
states.add(false);
|
|
1144
|
+
}
|
|
1145
|
+
if (action.action === "ACTION_REMOVE_VIDEO" && action.removedVideoId === expectedVideoId) {
|
|
1146
|
+
states.add(true);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
if (states.size !== 1) throw new Error("YouTube save readback did not expose one exact Watch Later state");
|
|
1151
|
+
return states.values().next().value!;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
export function youtubeSubscriptionMutationRequest(
|
|
1155
|
+
value: unknown,
|
|
1156
|
+
expectedChannelId: string,
|
|
1157
|
+
desired: boolean,
|
|
1158
|
+
): {
|
|
1159
|
+
readonly endpoint: "subscription/subscribe" | "subscription/unsubscribe";
|
|
1160
|
+
readonly body: Readonly<Record<string, unknown>>;
|
|
1161
|
+
} {
|
|
1162
|
+
assertYouTubeResponseSuccess(value, "YouTube subscription command discovery");
|
|
1163
|
+
const apiUrl = desired
|
|
1164
|
+
? "/youtubei/v1/subscription/subscribe"
|
|
1165
|
+
: "/youtubei/v1/subscription/unsubscribe";
|
|
1166
|
+
const endpointKey = desired ? "subscribeEndpoint" : "unsubscribeEndpoint";
|
|
1167
|
+
const candidates: Readonly<Record<string, unknown>>[] = [];
|
|
1168
|
+
for (const item of walkRecords(value, "YouTube subscription command discovery")) {
|
|
1169
|
+
const command = isRecord(item.commandMetadata) ? item.commandMetadata : null;
|
|
1170
|
+
const web = command !== null && isRecord(command.webCommandMetadata)
|
|
1171
|
+
? command.webCommandMetadata
|
|
1172
|
+
: null;
|
|
1173
|
+
const payload = isRecord(item[endpointKey]) ? item[endpointKey] : null;
|
|
1174
|
+
if (web?.apiUrl !== apiUrl || payload === null) continue;
|
|
1175
|
+
if (
|
|
1176
|
+
!Array.isArray(payload.channelIds)
|
|
1177
|
+
|| payload.channelIds.length !== 1
|
|
1178
|
+
|| payload.channelIds[0] !== expectedChannelId
|
|
1179
|
+
) continue;
|
|
1180
|
+
candidates.push(Object.freeze({
|
|
1181
|
+
channelIds: Object.freeze([expectedChannelId]),
|
|
1182
|
+
params: exactOpaqueParams(payload.params, "YouTube subscription params"),
|
|
1183
|
+
}));
|
|
1184
|
+
}
|
|
1185
|
+
const unique = new Map(candidates.map((candidate) => [JSON.stringify(candidate), candidate]));
|
|
1186
|
+
if (unique.size !== 1) {
|
|
1187
|
+
throw new Error("YouTube did not expose one exact current subscription command");
|
|
1188
|
+
}
|
|
1189
|
+
return Object.freeze({
|
|
1190
|
+
endpoint: desired ? "subscription/subscribe" : "subscription/unsubscribe",
|
|
1191
|
+
body: unique.values().next().value!,
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
export function youtubeSubscriptionState(value: unknown, expectedChannelId: string): boolean {
|
|
1196
|
+
assertYouTubeResponseSuccess(value, "YouTube subscription readback");
|
|
1197
|
+
const channelIds = new Set<string>();
|
|
1198
|
+
const entityStates = new Set<boolean>();
|
|
1199
|
+
const legacyStates = new Set<boolean>();
|
|
1200
|
+
for (const item of walkRecords(value, "YouTube subscription readback")) {
|
|
1201
|
+
for (const key of ["channelId", "externalId", "browseId"] as const) {
|
|
1202
|
+
const candidate = item[key];
|
|
1203
|
+
if (typeof candidate === "string" && /^UC[A-Za-z0-9_-]{22}$/u.test(candidate)) {
|
|
1204
|
+
channelIds.add(candidate);
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
const stateEntity = isRecord(item.subscriptionStateEntity)
|
|
1208
|
+
? item.subscriptionStateEntity
|
|
1209
|
+
: null;
|
|
1210
|
+
if (typeof stateEntity?.subscribed === "boolean") entityStates.add(stateEntity.subscribed);
|
|
1211
|
+
if (typeof item.subscribed === "boolean") legacyStates.add(item.subscribed);
|
|
1212
|
+
if (item.subscriptionState === "SUBSCRIBED") legacyStates.add(true);
|
|
1213
|
+
if (item.subscriptionState === "NOT_SUBSCRIBED") legacyStates.add(false);
|
|
1214
|
+
}
|
|
1215
|
+
if (!channelIds.has(expectedChannelId)) {
|
|
1216
|
+
throw new Error("YouTube subscription readback did not bind the requested channel");
|
|
1217
|
+
}
|
|
1218
|
+
const states = entityStates.size > 0 ? entityStates : legacyStates;
|
|
1219
|
+
if (states.size !== 1) throw new Error("YouTube subscription readback did not expose one exact state");
|
|
1220
|
+
return states.values().next().value!;
|
|
1221
|
+
}
|