@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,1400 @@
|
|
|
1
|
+
import { Blob } from "node:buffer";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { open } from "node:fs/promises";
|
|
5
|
+
import { types as nodeTypes } from "node:util";
|
|
6
|
+
|
|
7
|
+
import type { GhostgetAuth } from "../auth";
|
|
8
|
+
import type { BrowserFileResolver } from "../browser";
|
|
9
|
+
import { canonicalJson } from "../canonical-json";
|
|
10
|
+
import type { FileInputValue, OperationInput, WebSessionRecipe } from "../model";
|
|
11
|
+
import {
|
|
12
|
+
createWebSessionClient,
|
|
13
|
+
webSessionAuthSubject,
|
|
14
|
+
webSessionCookie,
|
|
15
|
+
type WebSessionClient,
|
|
16
|
+
type WebSessionNetworkDependencies,
|
|
17
|
+
} from "../web-session-client";
|
|
18
|
+
import type {
|
|
19
|
+
WebSessionDispatchEvent,
|
|
20
|
+
WebSessionExecution,
|
|
21
|
+
WebSessionOperationDeadline,
|
|
22
|
+
} from "../web-session-execution";
|
|
23
|
+
import { failedProviderRead } from "./read-failure";
|
|
24
|
+
import {
|
|
25
|
+
assertYouTubeResponseSuccess,
|
|
26
|
+
assertYouTubeVideoBinding,
|
|
27
|
+
createYouTubeSapisidAuthorization,
|
|
28
|
+
findYouTubeCommentsContinuation,
|
|
29
|
+
parseYouTubeInitialDataHtml,
|
|
30
|
+
parseYouTubeBootstrapHtml,
|
|
31
|
+
projectYouTubeProfile,
|
|
32
|
+
projectYouTubeComments,
|
|
33
|
+
projectYouTubeItems,
|
|
34
|
+
projectYouTubeMedia,
|
|
35
|
+
projectYouTubePost,
|
|
36
|
+
youtubePostBrowseRequest,
|
|
37
|
+
youtubeProfileBrowseRequest,
|
|
38
|
+
youtubeProfileTarget,
|
|
39
|
+
youtubeCurrentSubject,
|
|
40
|
+
youtubeLikeMutationRequest,
|
|
41
|
+
youtubeLikeState,
|
|
42
|
+
youtubeSubscriptionMutationRequest,
|
|
43
|
+
youtubeSubscriptionState,
|
|
44
|
+
youtubeWatchLaterState,
|
|
45
|
+
type YouTubeBootstrapConfig,
|
|
46
|
+
} from "./youtube-web";
|
|
47
|
+
import { isoBmffMp4VideoMetadata } from "./iso-bmff";
|
|
48
|
+
|
|
49
|
+
const YOUTUBE_ORIGIN = "https://www.youtube.com";
|
|
50
|
+
const MAX_BOOTSTRAP_BYTES = 2 * 1024 * 1024;
|
|
51
|
+
const MAX_PROFILE_PAGE_BYTES = 4 * 1024 * 1024;
|
|
52
|
+
const MAX_YOUTUBE_VIDEO_BYTES = 128 * 1024 * 1024;
|
|
53
|
+
const DEFAULT_LIMIT = 20;
|
|
54
|
+
const YOUTUBE_MP4_COMPATIBILITY_POLICY = Object.freeze({
|
|
55
|
+
compatibleBrands: Object.freeze([
|
|
56
|
+
"M4V ",
|
|
57
|
+
"MSNV",
|
|
58
|
+
"avc1",
|
|
59
|
+
"iso2",
|
|
60
|
+
"isom",
|
|
61
|
+
"mp41",
|
|
62
|
+
"mp42",
|
|
63
|
+
]),
|
|
64
|
+
rejectedMajorBrands: Object.freeze(["qt "]),
|
|
65
|
+
});
|
|
66
|
+
const YOUTUBE_VIDEO_PUBLISH_BINDING_KEYS = Object.freeze([
|
|
67
|
+
"ageRestricted",
|
|
68
|
+
"byteLength",
|
|
69
|
+
"bytes",
|
|
70
|
+
"caption",
|
|
71
|
+
"categoryId",
|
|
72
|
+
"containsSyntheticMedia",
|
|
73
|
+
"durationSeconds",
|
|
74
|
+
"height",
|
|
75
|
+
"madeForKids",
|
|
76
|
+
"mediaSha256",
|
|
77
|
+
"mediaType",
|
|
78
|
+
"notifySubscribers",
|
|
79
|
+
"title",
|
|
80
|
+
"visibility",
|
|
81
|
+
"width",
|
|
82
|
+
] as const);
|
|
83
|
+
const TYPED_ARRAY_PROTOTYPE = Object.getPrototypeOf(
|
|
84
|
+
Uint8Array.prototype,
|
|
85
|
+
) as object;
|
|
86
|
+
const TYPED_ARRAY_BUFFER_GETTER = Object.getOwnPropertyDescriptor(
|
|
87
|
+
TYPED_ARRAY_PROTOTYPE,
|
|
88
|
+
"buffer",
|
|
89
|
+
)?.get;
|
|
90
|
+
const TYPED_ARRAY_BYTE_LENGTH_GETTER = Object.getOwnPropertyDescriptor(
|
|
91
|
+
TYPED_ARRAY_PROTOTYPE,
|
|
92
|
+
"byteLength",
|
|
93
|
+
)?.get;
|
|
94
|
+
|
|
95
|
+
type InnertubeEndpoint =
|
|
96
|
+
| "account/account_menu"
|
|
97
|
+
| "account/accounts_list"
|
|
98
|
+
| "browse"
|
|
99
|
+
| "like/like"
|
|
100
|
+
| "like/removelike"
|
|
101
|
+
| "navigation/resolve_url"
|
|
102
|
+
| "next"
|
|
103
|
+
| "player"
|
|
104
|
+
| "playlist/edit"
|
|
105
|
+
| "subscription/subscribe"
|
|
106
|
+
| "subscription/unsubscribe";
|
|
107
|
+
|
|
108
|
+
export type YouTubeWebRuntimeDependencies = Partial<WebSessionNetworkDependencies> & {
|
|
109
|
+
readonly now?: () => number;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export class YouTubeAuthRepairRequiredError extends Error {
|
|
113
|
+
constructor() {
|
|
114
|
+
super("YouTube selected session is not signed in");
|
|
115
|
+
this.name = "YouTubeAuthRepairRequiredError";
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export type YouTubeWebDesiredStateKind =
|
|
120
|
+
| "like"
|
|
121
|
+
| "subscription"
|
|
122
|
+
| "watch-later";
|
|
123
|
+
|
|
124
|
+
export type YouTubeWebDesiredStatePreparation = {
|
|
125
|
+
readonly kind: YouTubeWebDesiredStateKind;
|
|
126
|
+
readonly targetId: string;
|
|
127
|
+
readonly desiredState: boolean;
|
|
128
|
+
readonly actualState: boolean;
|
|
129
|
+
readonly alreadyDesired: boolean;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export type YouTubeWebDesiredStateReadback = {
|
|
133
|
+
readonly kind: YouTubeWebDesiredStateKind;
|
|
134
|
+
readonly targetId: string;
|
|
135
|
+
readonly enabled: boolean;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export type YouTubeBoundVideoPublish = Readonly<{
|
|
139
|
+
ageRestricted: boolean;
|
|
140
|
+
bytes: Uint8Array<ArrayBuffer>;
|
|
141
|
+
byteLength: number;
|
|
142
|
+
caption: string | null;
|
|
143
|
+
categoryId: string;
|
|
144
|
+
containsSyntheticMedia: boolean;
|
|
145
|
+
durationSeconds: number;
|
|
146
|
+
height: number;
|
|
147
|
+
madeForKids: boolean;
|
|
148
|
+
mediaType: "video/mp4";
|
|
149
|
+
mediaSha256: string;
|
|
150
|
+
notifySubscribers: boolean;
|
|
151
|
+
title: string;
|
|
152
|
+
visibility: "private" | "unlisted" | "public";
|
|
153
|
+
width: number;
|
|
154
|
+
}>;
|
|
155
|
+
|
|
156
|
+
export type YouTubeVideoPublishDispatchSnapshot = Readonly<{
|
|
157
|
+
ageRestricted: boolean;
|
|
158
|
+
body: Blob;
|
|
159
|
+
byteLength: number;
|
|
160
|
+
caption: string | null;
|
|
161
|
+
categoryId: string;
|
|
162
|
+
containsSyntheticMedia: boolean;
|
|
163
|
+
durationSeconds: number;
|
|
164
|
+
height: number;
|
|
165
|
+
madeForKids: boolean;
|
|
166
|
+
mediaType: "video/mp4";
|
|
167
|
+
mediaSha256: string;
|
|
168
|
+
notifySubscribers: boolean;
|
|
169
|
+
title: string;
|
|
170
|
+
visibility: "private" | "unlisted" | "public";
|
|
171
|
+
width: number;
|
|
172
|
+
}>;
|
|
173
|
+
|
|
174
|
+
export type YouTubeVideoDeleteInput = Readonly<{
|
|
175
|
+
expectedTitle: string;
|
|
176
|
+
videoId: string;
|
|
177
|
+
}>;
|
|
178
|
+
|
|
179
|
+
export type YouTubeVideoCanonicalTarget = Readonly<{
|
|
180
|
+
schemaVersion: 1;
|
|
181
|
+
url: string;
|
|
182
|
+
videoId: string;
|
|
183
|
+
}>;
|
|
184
|
+
|
|
185
|
+
function exactYouTubeVideoPublishBinding(
|
|
186
|
+
value: unknown,
|
|
187
|
+
): Readonly<Record<string, unknown>> {
|
|
188
|
+
if (
|
|
189
|
+
typeof value !== "object"
|
|
190
|
+
|| value === null
|
|
191
|
+
|| Array.isArray(value)
|
|
192
|
+
) throw new Error("YouTube video binding must be one exact object");
|
|
193
|
+
if (nodeTypes.isProxy(value)) {
|
|
194
|
+
throw new Error("YouTube video binding must not be a proxy");
|
|
195
|
+
}
|
|
196
|
+
const prototype: unknown = Object.getPrototypeOf(value);
|
|
197
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
198
|
+
throw new Error("YouTube video binding must use a plain prototype");
|
|
199
|
+
}
|
|
200
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
201
|
+
const ownKeys = Reflect.ownKeys(descriptors);
|
|
202
|
+
if (
|
|
203
|
+
ownKeys.length !== YOUTUBE_VIDEO_PUBLISH_BINDING_KEYS.length
|
|
204
|
+
|| ownKeys.some((key) => typeof key !== "string")
|
|
205
|
+
|| (ownKeys as string[]).sort().join(",")
|
|
206
|
+
!== [...YOUTUBE_VIDEO_PUBLISH_BINDING_KEYS].sort().join(",")
|
|
207
|
+
) throw new Error("YouTube video binding contained unsupported fields");
|
|
208
|
+
const snapshot = Object.create(null) as Record<string, unknown>;
|
|
209
|
+
for (const key of YOUTUBE_VIDEO_PUBLISH_BINDING_KEYS) {
|
|
210
|
+
const descriptor = descriptors[key];
|
|
211
|
+
if (
|
|
212
|
+
descriptor === undefined
|
|
213
|
+
|| !descriptor.enumerable
|
|
214
|
+
|| !("value" in descriptor)
|
|
215
|
+
) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
"YouTube video binding must contain only enumerable data properties",
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
snapshot[key] = descriptor.value;
|
|
221
|
+
}
|
|
222
|
+
return Object.freeze(snapshot);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function snapshotYouTubeVideoBytes(value: unknown): Uint8Array<ArrayBuffer> {
|
|
226
|
+
if (
|
|
227
|
+
typeof value !== "object"
|
|
228
|
+
|| value === null
|
|
229
|
+
|| nodeTypes.isProxy(value)
|
|
230
|
+
|| !(value instanceof Uint8Array)
|
|
231
|
+
|| Object.getPrototypeOf(value) !== Uint8Array.prototype
|
|
232
|
+
|| TYPED_ARRAY_BUFFER_GETTER === undefined
|
|
233
|
+
|| TYPED_ARRAY_BYTE_LENGTH_GETTER === undefined
|
|
234
|
+
) throw new Error("YouTube video binding must contain one bounded MP4");
|
|
235
|
+
let buffer: unknown;
|
|
236
|
+
let byteLength: unknown;
|
|
237
|
+
try {
|
|
238
|
+
buffer = TYPED_ARRAY_BUFFER_GETTER.call(value) as unknown;
|
|
239
|
+
byteLength = TYPED_ARRAY_BYTE_LENGTH_GETTER.call(value) as unknown;
|
|
240
|
+
} catch {
|
|
241
|
+
throw new Error("YouTube video binding must contain one bounded MP4");
|
|
242
|
+
}
|
|
243
|
+
if (
|
|
244
|
+
typeof byteLength !== "number"
|
|
245
|
+
|| !Number.isSafeInteger(byteLength)
|
|
246
|
+
|| byteLength < 24
|
|
247
|
+
|| byteLength > MAX_YOUTUBE_VIDEO_BYTES
|
|
248
|
+
|| nodeTypes.isSharedArrayBuffer(buffer)
|
|
249
|
+
) throw new Error("YouTube video binding must contain one bounded MP4");
|
|
250
|
+
|
|
251
|
+
// Copy through the intrinsic typed-array operation. Caller-defined
|
|
252
|
+
// byteLength, buffer, iterator, or indexed accessors are not consulted, and
|
|
253
|
+
// this unique ArrayBuffer is the only source used to validate and build the
|
|
254
|
+
// eventual Blob.
|
|
255
|
+
const bytes = new Uint8Array(byteLength);
|
|
256
|
+
try {
|
|
257
|
+
Uint8Array.prototype.set.call(bytes, value);
|
|
258
|
+
} catch {
|
|
259
|
+
throw new Error("YouTube video binding must contain one bounded MP4");
|
|
260
|
+
}
|
|
261
|
+
return bytes;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export const YOUTUBE_VIDEO_CAPTURE_REQUIRED_REASONS = Object.freeze({
|
|
265
|
+
"content.delete": "cleanup only discarded the stalled incomplete Studio draft; no uploaded-video authored pre-read, accepted video/delete response, or exact-target absence readback was observed",
|
|
266
|
+
"media.publish": "the signed-in Studio capture reached metadata JSON responses, but the selected MP4 remained at 0%; resumable initiation, byte-transfer acceptance, finalization, processing, and exact current-account readback remain unproved",
|
|
267
|
+
} as const);
|
|
268
|
+
|
|
269
|
+
type YouTubeBootstrap = {
|
|
270
|
+
readonly auth: GhostgetAuth;
|
|
271
|
+
readonly client: WebSessionClient;
|
|
272
|
+
readonly config: YouTubeBootstrapConfig;
|
|
273
|
+
readonly sapisid: string;
|
|
274
|
+
readonly timeoutMs: number;
|
|
275
|
+
readonly maxOutputBytes: number;
|
|
276
|
+
readonly now: () => number;
|
|
277
|
+
readonly subject: string;
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
function boundedString(value: unknown, label: string, maximum: number): string {
|
|
281
|
+
if (
|
|
282
|
+
typeof value !== "string"
|
|
283
|
+
|| value.length < 1
|
|
284
|
+
|| value.length > maximum
|
|
285
|
+
|| /[\0\r]/u.test(value)
|
|
286
|
+
) throw new Error(`${label} must be a bounded string`);
|
|
287
|
+
return value;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function stringInput(input: OperationInput, name: string, maximum: number): string {
|
|
291
|
+
return boundedString(input[name], `input.${name}`, maximum);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function booleanInput(input: OperationInput, name: string): boolean {
|
|
295
|
+
const value = input[name];
|
|
296
|
+
if (typeof value !== "boolean") throw new Error(`input.${name} must be boolean`);
|
|
297
|
+
return value;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function exactInputKeys(
|
|
301
|
+
input: OperationInput,
|
|
302
|
+
required: readonly string[],
|
|
303
|
+
optional: readonly string[],
|
|
304
|
+
label: string,
|
|
305
|
+
): void {
|
|
306
|
+
const allowed = new Set([...required, ...optional]);
|
|
307
|
+
const keys = Object.keys(input);
|
|
308
|
+
if (keys.some((key) => !allowed.has(key))) {
|
|
309
|
+
throw new Error(`${label} contained an unsupported input field`);
|
|
310
|
+
}
|
|
311
|
+
if (required.some((key) => !Object.hasOwn(input, key))) {
|
|
312
|
+
throw new Error(`${label} omitted a required input field`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function fileInput(value: unknown, label: string): FileInputValue {
|
|
317
|
+
if (
|
|
318
|
+
typeof value !== "object"
|
|
319
|
+
|| value === null
|
|
320
|
+
|| Array.isArray(value)
|
|
321
|
+
|| Object.keys(value).sort().join(",") !== "kind,reference"
|
|
322
|
+
|| (value as { readonly kind?: unknown }).kind !== "file"
|
|
323
|
+
|| typeof (value as { readonly reference?: unknown }).reference !== "string"
|
|
324
|
+
|| (value as { readonly reference: string }).reference.length < 1
|
|
325
|
+
|| (value as { readonly reference: string }).reference.length > 4_096
|
|
326
|
+
|| /[\0\r\n]/u.test((value as { readonly reference: string }).reference)
|
|
327
|
+
) throw new Error(`${label} must be one plan-bound file`);
|
|
328
|
+
return value as FileInputValue;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function youtubeTitleInput(input: OperationInput, name: string): string {
|
|
332
|
+
const value = stringInput(input, name, 90);
|
|
333
|
+
if (/\n/u.test(value)) throw new Error(`input.${name} must be one exact YouTube title`);
|
|
334
|
+
return value;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function youtubeVideoId(value: unknown, label: string): string {
|
|
338
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9_-]{11}$/u.test(value)) {
|
|
339
|
+
throw new Error(`${label} must be an exact YouTube video ID`);
|
|
340
|
+
}
|
|
341
|
+
return value;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function youtubeVideoWatchUrl(videoId: string): string {
|
|
345
|
+
return `${YOUTUBE_ORIGIN}/watch?v=${videoId}`;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function youtubeMp4Metadata(bytes: Uint8Array): Readonly<{
|
|
349
|
+
durationSeconds: number;
|
|
350
|
+
height: number;
|
|
351
|
+
width: number;
|
|
352
|
+
}> {
|
|
353
|
+
return isoBmffMp4VideoMetadata(
|
|
354
|
+
bytes,
|
|
355
|
+
"YouTube video",
|
|
356
|
+
YOUTUBE_MP4_COMPATIBILITY_POLICY,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function youtubeVideoSha256(bytes: Uint8Array): string {
|
|
361
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Serialize only one exact video ID and its derived canonical watch URL. This
|
|
366
|
+
* local target format does not imply provider acceptance or grant dispatch.
|
|
367
|
+
*/
|
|
368
|
+
export function youtubeVideoTargetIdentifier(videoIdValue: unknown): string {
|
|
369
|
+
const videoId = youtubeVideoId(
|
|
370
|
+
videoIdValue,
|
|
371
|
+
"YouTube canonical video target ID",
|
|
372
|
+
);
|
|
373
|
+
return canonicalJson({
|
|
374
|
+
schemaVersion: 1,
|
|
375
|
+
url: youtubeVideoWatchUrl(videoId),
|
|
376
|
+
videoId,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Parse only the canonical local target shape used by future reconciliation. */
|
|
381
|
+
export function parseYouTubeVideoTargetIdentifier(
|
|
382
|
+
identifier: unknown,
|
|
383
|
+
): YouTubeVideoCanonicalTarget {
|
|
384
|
+
if (
|
|
385
|
+
typeof identifier !== "string"
|
|
386
|
+
|| identifier.length < 1
|
|
387
|
+
|| identifier.length > 4_096
|
|
388
|
+
) throw new Error("YouTube canonical video target is not canonical JSON");
|
|
389
|
+
let value: unknown;
|
|
390
|
+
try {
|
|
391
|
+
value = JSON.parse(identifier);
|
|
392
|
+
} catch {
|
|
393
|
+
throw new Error("YouTube canonical video target is not canonical JSON");
|
|
394
|
+
}
|
|
395
|
+
if (
|
|
396
|
+
typeof value !== "object"
|
|
397
|
+
|| value === null
|
|
398
|
+
|| Array.isArray(value)
|
|
399
|
+
|| Object.keys(value).sort().join(",") !== "schemaVersion,url,videoId"
|
|
400
|
+
) throw new Error("YouTube canonical video target contained unsupported fields");
|
|
401
|
+
const target = value as Readonly<Record<string, unknown>>;
|
|
402
|
+
if (target.schemaVersion !== 1) {
|
|
403
|
+
throw new Error("YouTube canonical video target schema version is unsupported");
|
|
404
|
+
}
|
|
405
|
+
const videoId = youtubeVideoId(
|
|
406
|
+
target.videoId,
|
|
407
|
+
"YouTube canonical video target ID",
|
|
408
|
+
);
|
|
409
|
+
const parsed = Object.freeze({
|
|
410
|
+
schemaVersion: 1 as const,
|
|
411
|
+
url: youtubeVideoWatchUrl(videoId),
|
|
412
|
+
videoId,
|
|
413
|
+
});
|
|
414
|
+
if (target.url !== parsed.url || canonicalJson(parsed) !== identifier) {
|
|
415
|
+
throw new Error("YouTube canonical video target is not canonical");
|
|
416
|
+
}
|
|
417
|
+
return parsed;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Revalidate and snapshot a materialized video immediately before a future
|
|
422
|
+
* dispatch. The current capture-required operation does not call this helper.
|
|
423
|
+
*/
|
|
424
|
+
export function revalidateYouTubeVideoPublishBindingForDispatch(
|
|
425
|
+
value: unknown,
|
|
426
|
+
): YouTubeVideoPublishDispatchSnapshot {
|
|
427
|
+
const binding = exactYouTubeVideoPublishBinding(value);
|
|
428
|
+
const bytes = snapshotYouTubeVideoBytes(binding.bytes);
|
|
429
|
+
const metadata = youtubeMp4Metadata(bytes);
|
|
430
|
+
const mediaSha256 = youtubeVideoSha256(bytes);
|
|
431
|
+
const declaredByteLength = binding.byteLength;
|
|
432
|
+
const declaredDurationSeconds = binding.durationSeconds;
|
|
433
|
+
const declaredHeight = binding.height;
|
|
434
|
+
const declaredMediaSha256 = binding.mediaSha256;
|
|
435
|
+
const declaredWidth = binding.width;
|
|
436
|
+
if (
|
|
437
|
+
!Number.isSafeInteger(declaredByteLength)
|
|
438
|
+
|| declaredByteLength !== bytes.byteLength
|
|
439
|
+
|| typeof declaredMediaSha256 !== "string"
|
|
440
|
+
|| !/^[a-f0-9]{64}$/u.test(declaredMediaSha256)
|
|
441
|
+
|| declaredMediaSha256 !== mediaSha256
|
|
442
|
+
|| declaredDurationSeconds !== metadata.durationSeconds
|
|
443
|
+
|| declaredHeight !== metadata.height
|
|
444
|
+
|| declaredWidth !== metadata.width
|
|
445
|
+
) throw new Error("YouTube video binding changed from its exact bytes");
|
|
446
|
+
const ageRestricted = binding.ageRestricted;
|
|
447
|
+
const containsSyntheticMedia = binding.containsSyntheticMedia;
|
|
448
|
+
const madeForKids = binding.madeForKids;
|
|
449
|
+
const mediaType = binding.mediaType;
|
|
450
|
+
const notifySubscribers = binding.notifySubscribers;
|
|
451
|
+
const visibility = binding.visibility;
|
|
452
|
+
if (
|
|
453
|
+
typeof ageRestricted !== "boolean"
|
|
454
|
+
|| typeof containsSyntheticMedia !== "boolean"
|
|
455
|
+
|| typeof madeForKids !== "boolean"
|
|
456
|
+
|| typeof notifySubscribers !== "boolean"
|
|
457
|
+
|| mediaType !== "video/mp4"
|
|
458
|
+
|| (
|
|
459
|
+
visibility !== "private"
|
|
460
|
+
&& visibility !== "unlisted"
|
|
461
|
+
&& visibility !== "public"
|
|
462
|
+
)
|
|
463
|
+
) throw new Error("YouTube video binding declarations are invalid");
|
|
464
|
+
if (ageRestricted && madeForKids) {
|
|
465
|
+
throw new Error("YouTube video cannot be both made for kids and creator age-restricted");
|
|
466
|
+
}
|
|
467
|
+
const categoryId = boundedString(
|
|
468
|
+
binding.categoryId,
|
|
469
|
+
"YouTube video binding category ID",
|
|
470
|
+
3,
|
|
471
|
+
);
|
|
472
|
+
if (!/^[1-9][0-9]{0,2}$/u.test(categoryId)) {
|
|
473
|
+
throw new Error("YouTube video binding category ID must be exact");
|
|
474
|
+
}
|
|
475
|
+
const title = boundedString(binding.title, "YouTube video binding title", 90);
|
|
476
|
+
if (/\n/u.test(title)) {
|
|
477
|
+
throw new Error("YouTube video binding title must be one exact title");
|
|
478
|
+
}
|
|
479
|
+
const caption = binding.caption === null
|
|
480
|
+
? null
|
|
481
|
+
: boundedString(binding.caption, "YouTube video binding caption", 1_000);
|
|
482
|
+
const body = new Blob([bytes], { type: "video/mp4" });
|
|
483
|
+
if (body.size !== bytes.byteLength || body.type !== "video/mp4") {
|
|
484
|
+
throw new Error("YouTube video dispatch snapshot changed shape");
|
|
485
|
+
}
|
|
486
|
+
return Object.freeze({
|
|
487
|
+
ageRestricted,
|
|
488
|
+
body,
|
|
489
|
+
byteLength: bytes.byteLength,
|
|
490
|
+
caption,
|
|
491
|
+
categoryId,
|
|
492
|
+
containsSyntheticMedia,
|
|
493
|
+
durationSeconds: metadata.durationSeconds,
|
|
494
|
+
height: metadata.height,
|
|
495
|
+
madeForKids,
|
|
496
|
+
mediaSha256,
|
|
497
|
+
mediaType: "video/mp4" as const,
|
|
498
|
+
notifySubscribers,
|
|
499
|
+
title,
|
|
500
|
+
visibility,
|
|
501
|
+
width: metadata.width,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Materialize exactly one plan-bound MP4 and the complete conservative creator
|
|
507
|
+
* declarations. This remains a local preflight: no upload route calls it until
|
|
508
|
+
* an authorized Studio capture proves dispatch and independent readback.
|
|
509
|
+
*/
|
|
510
|
+
export async function materializeYouTubeVideoPublishInput(
|
|
511
|
+
input: OperationInput,
|
|
512
|
+
fileResolver: BrowserFileResolver | undefined,
|
|
513
|
+
operationDeadline?: WebSessionOperationDeadline,
|
|
514
|
+
): Promise<YouTubeBoundVideoPublish> {
|
|
515
|
+
const required = [
|
|
516
|
+
"age_restricted",
|
|
517
|
+
"category_id",
|
|
518
|
+
"contains_synthetic_media",
|
|
519
|
+
"made_for_kids",
|
|
520
|
+
"media",
|
|
521
|
+
"notify_subscribers",
|
|
522
|
+
"title",
|
|
523
|
+
"visibility",
|
|
524
|
+
] as const;
|
|
525
|
+
exactInputKeys(input, required, ["caption"], "YouTube video publishing");
|
|
526
|
+
const media = fileInput(input.media, "input.media");
|
|
527
|
+
const title = youtubeTitleInput(input, "title");
|
|
528
|
+
const caption = input.caption === undefined
|
|
529
|
+
? null
|
|
530
|
+
: stringInput(input, "caption", 1_000);
|
|
531
|
+
const visibility = input.visibility;
|
|
532
|
+
if (visibility !== "private" && visibility !== "unlisted" && visibility !== "public") {
|
|
533
|
+
throw new Error("input.visibility must be private, unlisted, or public");
|
|
534
|
+
}
|
|
535
|
+
const madeForKids = booleanInput(input, "made_for_kids");
|
|
536
|
+
const notifySubscribers = booleanInput(input, "notify_subscribers");
|
|
537
|
+
const containsSyntheticMedia = booleanInput(input, "contains_synthetic_media");
|
|
538
|
+
const ageRestricted = booleanInput(input, "age_restricted");
|
|
539
|
+
if (madeForKids && ageRestricted) {
|
|
540
|
+
throw new Error("YouTube video cannot be both made for kids and creator age-restricted");
|
|
541
|
+
}
|
|
542
|
+
const categoryId = stringInput(input, "category_id", 3);
|
|
543
|
+
if (!/^[1-9][0-9]{0,2}$/u.test(categoryId)) {
|
|
544
|
+
throw new Error("input.category_id must be an exact positive YouTube category ID");
|
|
545
|
+
}
|
|
546
|
+
if (fileResolver === undefined) {
|
|
547
|
+
throw new Error("YouTube video upload requires the plan-bound file resolver");
|
|
548
|
+
}
|
|
549
|
+
const resolve = () => fileResolver([media]);
|
|
550
|
+
const paths = operationDeadline === undefined
|
|
551
|
+
? await resolve()
|
|
552
|
+
: await operationDeadline.run(resolve, "authenticated web operation deadline");
|
|
553
|
+
operationDeadline?.throwIfUnavailable("authenticated web operation deadline");
|
|
554
|
+
if (paths.length !== 1 || typeof paths[0] !== "string") {
|
|
555
|
+
throw new Error("YouTube file resolver did not return one exact video path");
|
|
556
|
+
}
|
|
557
|
+
const noFollow = "O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0;
|
|
558
|
+
const handle = operationDeadline === undefined
|
|
559
|
+
? await open(paths[0], constants.O_RDONLY | noFollow)
|
|
560
|
+
: await operationDeadline.run(
|
|
561
|
+
() => open(paths[0]!, constants.O_RDONLY | noFollow),
|
|
562
|
+
"authenticated web operation deadline",
|
|
563
|
+
);
|
|
564
|
+
try {
|
|
565
|
+
const before = operationDeadline === undefined
|
|
566
|
+
? await handle.stat()
|
|
567
|
+
: await operationDeadline.run(
|
|
568
|
+
() => handle.stat(),
|
|
569
|
+
"authenticated web operation deadline",
|
|
570
|
+
);
|
|
571
|
+
if (
|
|
572
|
+
!before.isFile()
|
|
573
|
+
|| before.size < 24
|
|
574
|
+
|| before.size > MAX_YOUTUBE_VIDEO_BYTES
|
|
575
|
+
) {
|
|
576
|
+
throw new Error(
|
|
577
|
+
"YouTube video must be a regular MP4 no larger than the 128 MiB in-memory publish limit",
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
const fileBytes = operationDeadline === undefined
|
|
581
|
+
? await handle.readFile()
|
|
582
|
+
: await operationDeadline.run(
|
|
583
|
+
() => handle.readFile(),
|
|
584
|
+
"authenticated web operation deadline",
|
|
585
|
+
);
|
|
586
|
+
const after = operationDeadline === undefined
|
|
587
|
+
? await handle.stat()
|
|
588
|
+
: await operationDeadline.run(
|
|
589
|
+
() => handle.stat(),
|
|
590
|
+
"authenticated web operation deadline",
|
|
591
|
+
);
|
|
592
|
+
if (
|
|
593
|
+
before.dev !== after.dev
|
|
594
|
+
|| before.ino !== after.ino
|
|
595
|
+
|| before.size !== after.size
|
|
596
|
+
|| before.mtimeMs !== after.mtimeMs
|
|
597
|
+
|| before.ctimeMs !== after.ctimeMs
|
|
598
|
+
|| fileBytes.byteLength !== before.size
|
|
599
|
+
) throw new Error("YouTube video changed while it was materialized");
|
|
600
|
+
const bytes = new Uint8Array(fileBytes);
|
|
601
|
+
const metadata = youtubeMp4Metadata(bytes);
|
|
602
|
+
const mediaSha256 = youtubeVideoSha256(bytes);
|
|
603
|
+
return Object.freeze({
|
|
604
|
+
ageRestricted,
|
|
605
|
+
bytes,
|
|
606
|
+
byteLength: bytes.byteLength,
|
|
607
|
+
caption,
|
|
608
|
+
categoryId,
|
|
609
|
+
containsSyntheticMedia,
|
|
610
|
+
durationSeconds: metadata.durationSeconds,
|
|
611
|
+
height: metadata.height,
|
|
612
|
+
madeForKids,
|
|
613
|
+
mediaType: "video/mp4" as const,
|
|
614
|
+
mediaSha256,
|
|
615
|
+
notifySubscribers,
|
|
616
|
+
title,
|
|
617
|
+
visibility,
|
|
618
|
+
width: metadata.width,
|
|
619
|
+
});
|
|
620
|
+
} finally {
|
|
621
|
+
await handle.close();
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Validate the exact authored-video confirmation required by deletion. */
|
|
626
|
+
export function prepareYouTubeVideoDeleteInput(
|
|
627
|
+
input: OperationInput,
|
|
628
|
+
): YouTubeVideoDeleteInput {
|
|
629
|
+
exactInputKeys(
|
|
630
|
+
input,
|
|
631
|
+
["expected_title", "video_id"],
|
|
632
|
+
[],
|
|
633
|
+
"YouTube video deletion",
|
|
634
|
+
);
|
|
635
|
+
return Object.freeze({
|
|
636
|
+
expectedTitle: youtubeTitleInput(input, "expected_title"),
|
|
637
|
+
videoId: youtubeVideoId(input.video_id, "input.video_id"),
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function integerInput(
|
|
642
|
+
input: OperationInput,
|
|
643
|
+
name: string,
|
|
644
|
+
fallback: number,
|
|
645
|
+
minimum: number,
|
|
646
|
+
maximum: number,
|
|
647
|
+
): number {
|
|
648
|
+
const value = input[name] ?? fallback;
|
|
649
|
+
if (!Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum) {
|
|
650
|
+
throw new Error(`input.${name} must be an integer between ${minimum} and ${maximum}`);
|
|
651
|
+
}
|
|
652
|
+
return value as number;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function videoIdInput(input: OperationInput): string {
|
|
656
|
+
const value = stringInput(input, "video_id", 11);
|
|
657
|
+
if (!/^[A-Za-z0-9_-]{11}$/u.test(value)) throw new Error("input.video_id must be an exact YouTube video ID");
|
|
658
|
+
return value;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function postIdInput(input: OperationInput): string {
|
|
662
|
+
const value = stringInput(input, "post_id", 256);
|
|
663
|
+
if (!/^[A-Za-z0-9_-]{10,256}$/u.test(value)) {
|
|
664
|
+
throw new Error("input.post_id must be an exact YouTube Community post ID");
|
|
665
|
+
}
|
|
666
|
+
return value;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function channelIdInput(input: OperationInput): string {
|
|
670
|
+
const value = stringInput(input, "channel_id", 24);
|
|
671
|
+
if (!/^UC[A-Za-z0-9_-]{22}$/u.test(value)) {
|
|
672
|
+
throw new Error("input.channel_id must be an exact YouTube channel ID");
|
|
673
|
+
}
|
|
674
|
+
return value;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function sapisidCookie(client: WebSessionClient): string {
|
|
678
|
+
for (const name of ["SAPISID", "__Secure-3PAPISID", "__Secure-1PAPISID"] as const) {
|
|
679
|
+
if (client.cookies.some((cookie) => cookie.name === name)) return webSessionCookie(client.cookies, name);
|
|
680
|
+
}
|
|
681
|
+
throw new Error("YouTube signed-in session omitted its SAPISID cookie");
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function requestHeaders(bootstrap: Omit<YouTubeBootstrap, "subject">): Readonly<Record<string, string>> {
|
|
685
|
+
const authorization = createYouTubeSapisidAuthorization(
|
|
686
|
+
bootstrap.sapisid,
|
|
687
|
+
bootstrap.now(),
|
|
688
|
+
);
|
|
689
|
+
return {
|
|
690
|
+
accept: "application/json",
|
|
691
|
+
authorization,
|
|
692
|
+
"content-type": "application/json",
|
|
693
|
+
origin: YOUTUBE_ORIGIN,
|
|
694
|
+
referer: `${YOUTUBE_ORIGIN}/`,
|
|
695
|
+
"x-goog-authuser": bootstrap.config.sessionIndex,
|
|
696
|
+
...(bootstrap.config.delegatedSessionId === null
|
|
697
|
+
? {}
|
|
698
|
+
: { "x-goog-pageid": bootstrap.config.delegatedSessionId }),
|
|
699
|
+
...(bootstrap.config.visitorData === null
|
|
700
|
+
? {}
|
|
701
|
+
: { "x-goog-visitor-id": bootstrap.config.visitorData }),
|
|
702
|
+
"x-origin": YOUTUBE_ORIGIN,
|
|
703
|
+
"x-youtube-bootstrap-logged-in": String(bootstrap.config.bootstrapLoggedIn),
|
|
704
|
+
"x-youtube-client-name": bootstrap.config.clientNameHeader,
|
|
705
|
+
"x-youtube-client-version": bootstrap.config.clientVersion,
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function endpointUrl(endpoint: InnertubeEndpoint, apiKey: string): URL {
|
|
710
|
+
const url = new URL(`/youtubei/v1/${endpoint}`, YOUTUBE_ORIGIN);
|
|
711
|
+
url.searchParams.set("prettyPrint", "false");
|
|
712
|
+
url.searchParams.set("key", apiKey);
|
|
713
|
+
return url;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
async function innertube(
|
|
717
|
+
bootstrap: Omit<YouTubeBootstrap, "subject">,
|
|
718
|
+
endpoint: InnertubeEndpoint,
|
|
719
|
+
body: Readonly<Record<string, unknown>>,
|
|
720
|
+
label: string,
|
|
721
|
+
): Promise<unknown> {
|
|
722
|
+
const response = await bootstrap.client.requestJson({
|
|
723
|
+
url: endpointUrl(endpoint, bootstrap.config.apiKey),
|
|
724
|
+
method: "POST",
|
|
725
|
+
headers: requestHeaders(bootstrap),
|
|
726
|
+
body: JSON.stringify({ context: bootstrap.config.context, ...body }),
|
|
727
|
+
expectedStatuses: [200],
|
|
728
|
+
expectedContentTypes: ["application/json"],
|
|
729
|
+
maxBytes: bootstrap.maxOutputBytes,
|
|
730
|
+
});
|
|
731
|
+
assertYouTubeResponseSuccess(response, label);
|
|
732
|
+
return response;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
async function currentSubject(bootstrap: Omit<YouTubeBootstrap, "subject">): Promise<string> {
|
|
736
|
+
const accountMenu = await innertube(bootstrap, "account/account_menu", {}, "YouTube account menu");
|
|
737
|
+
const accountsList = await innertube(bootstrap, "account/accounts_list", {}, "YouTube accounts list");
|
|
738
|
+
return youtubeCurrentSubject(
|
|
739
|
+
accountMenu,
|
|
740
|
+
accountsList,
|
|
741
|
+
bootstrap.config.delegatedSessionId,
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
async function bootstrapYouTube(
|
|
746
|
+
auth: GhostgetAuth,
|
|
747
|
+
options: {
|
|
748
|
+
readonly timeoutMs: number;
|
|
749
|
+
readonly maxOutputBytes: number;
|
|
750
|
+
readonly signal?: AbortSignal;
|
|
751
|
+
readonly operationDeadline?: WebSessionOperationDeadline;
|
|
752
|
+
readonly dependencies?: YouTubeWebRuntimeDependencies;
|
|
753
|
+
},
|
|
754
|
+
): Promise<YouTubeBootstrap> {
|
|
755
|
+
const client = await createWebSessionClient(YOUTUBE_ORIGIN, auth, {
|
|
756
|
+
timeoutMs: options.timeoutMs,
|
|
757
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
758
|
+
...(options.operationDeadline === undefined
|
|
759
|
+
? {}
|
|
760
|
+
: { operationDeadline: options.operationDeadline }),
|
|
761
|
+
...(options.dependencies === undefined ? {} : { dependencies: options.dependencies }),
|
|
762
|
+
});
|
|
763
|
+
const html = await client.requestText({
|
|
764
|
+
url: new URL("/", YOUTUBE_ORIGIN),
|
|
765
|
+
headers: { accept: "text/html" },
|
|
766
|
+
expectedContentTypes: ["text/html"],
|
|
767
|
+
maxBytes: MAX_BOOTSTRAP_BYTES,
|
|
768
|
+
});
|
|
769
|
+
const config = parseYouTubeBootstrapHtml(html);
|
|
770
|
+
if (config.bootstrapLoggedIn === false) {
|
|
771
|
+
throw new YouTubeAuthRepairRequiredError();
|
|
772
|
+
}
|
|
773
|
+
const partial = {
|
|
774
|
+
auth,
|
|
775
|
+
client,
|
|
776
|
+
config,
|
|
777
|
+
sapisid: sapisidCookie(client),
|
|
778
|
+
timeoutMs: options.timeoutMs,
|
|
779
|
+
maxOutputBytes: options.maxOutputBytes,
|
|
780
|
+
now: options.dependencies?.now ?? Date.now,
|
|
781
|
+
};
|
|
782
|
+
const subject = await currentSubject(partial);
|
|
783
|
+
return Object.freeze({ ...partial, subject });
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function requireBoundSubject(bootstrap: YouTubeBootstrap): string {
|
|
787
|
+
const expected = webSessionAuthSubject(bootstrap.auth);
|
|
788
|
+
if (expected === null) {
|
|
789
|
+
throw new Error("YouTube authenticated operations require a bound auth subject");
|
|
790
|
+
}
|
|
791
|
+
if (expected !== bootstrap.subject) {
|
|
792
|
+
throw new Error("YouTube current account did not match the bound auth subject");
|
|
793
|
+
}
|
|
794
|
+
return expected;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
export async function probeYouTubeWebSubject(
|
|
798
|
+
auth: GhostgetAuth,
|
|
799
|
+
options: {
|
|
800
|
+
readonly timeoutMs?: number;
|
|
801
|
+
readonly dependencies?: YouTubeWebRuntimeDependencies;
|
|
802
|
+
readonly signal?: AbortSignal;
|
|
803
|
+
} = {},
|
|
804
|
+
): Promise<string> {
|
|
805
|
+
const bootstrap = await bootstrapYouTube(auth, {
|
|
806
|
+
timeoutMs: options.timeoutMs ?? 60_000,
|
|
807
|
+
maxOutputBytes: 2 * 1024 * 1024,
|
|
808
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
809
|
+
...(options.dependencies === undefined ? {} : { dependencies: options.dependencies }),
|
|
810
|
+
});
|
|
811
|
+
return bootstrap.subject;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const feedBrowseIds = Object.freeze({
|
|
815
|
+
home: "FEwhat_to_watch",
|
|
816
|
+
subscriptions: "FEsubscriptions",
|
|
817
|
+
library: "FElibrary",
|
|
818
|
+
history: "FEhistory",
|
|
819
|
+
playlists: "FEplaylist_aggregation",
|
|
820
|
+
"watch-later": "VLWL",
|
|
821
|
+
liked: "VLLL",
|
|
822
|
+
} as const);
|
|
823
|
+
|
|
824
|
+
type FeedName = keyof typeof feedBrowseIds;
|
|
825
|
+
|
|
826
|
+
function feedName(input: OperationInput): FeedName {
|
|
827
|
+
const value = stringInput(input, "feed", 32);
|
|
828
|
+
if (!Object.hasOwn(feedBrowseIds, value)) throw new Error("input.feed must name a reviewed YouTube feed");
|
|
829
|
+
return value as FeedName;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
async function executeFeed(
|
|
833
|
+
bootstrap: YouTubeBootstrap,
|
|
834
|
+
input: OperationInput,
|
|
835
|
+
): Promise<WebSessionExecution> {
|
|
836
|
+
requireBoundSubject(bootstrap);
|
|
837
|
+
const feed = feedName(input);
|
|
838
|
+
const limit = integerInput(input, "limit", DEFAULT_LIMIT, 1, 100);
|
|
839
|
+
const response = await innertube(
|
|
840
|
+
bootstrap,
|
|
841
|
+
"browse",
|
|
842
|
+
{ browseId: feedBrowseIds[feed] },
|
|
843
|
+
"YouTube feed",
|
|
844
|
+
);
|
|
845
|
+
return {
|
|
846
|
+
status: "succeeded",
|
|
847
|
+
output: { feed, ...projectYouTubeItems(response, limit) },
|
|
848
|
+
finalUrl: feed === "home"
|
|
849
|
+
? `${YOUTUBE_ORIGIN}/`
|
|
850
|
+
: feed === "subscriptions"
|
|
851
|
+
? `${YOUTUBE_ORIGIN}/feed/subscriptions`
|
|
852
|
+
: feed === "history"
|
|
853
|
+
? `${YOUTUBE_ORIGIN}/feed/history`
|
|
854
|
+
: feed === "watch-later"
|
|
855
|
+
? `${YOUTUBE_ORIGIN}/playlist?list=WL`
|
|
856
|
+
: feed === "liked"
|
|
857
|
+
? `${YOUTUBE_ORIGIN}/playlist?list=LL`
|
|
858
|
+
: `${YOUTUBE_ORIGIN}/feed/you`,
|
|
859
|
+
dispatchStarted: false,
|
|
860
|
+
dispatch: { planned: 0, started: 0, verified: 0 },
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
async function executeMediaRead(
|
|
865
|
+
bootstrap: YouTubeBootstrap,
|
|
866
|
+
input: OperationInput,
|
|
867
|
+
): Promise<WebSessionExecution> {
|
|
868
|
+
requireBoundSubject(bootstrap);
|
|
869
|
+
const videoId = videoIdInput(input);
|
|
870
|
+
const response = await innertube(
|
|
871
|
+
bootstrap,
|
|
872
|
+
"player",
|
|
873
|
+
{ videoId, contentCheckOk: true, racyCheckOk: true },
|
|
874
|
+
"YouTube player",
|
|
875
|
+
);
|
|
876
|
+
return {
|
|
877
|
+
status: "succeeded",
|
|
878
|
+
output: projectYouTubeMedia(response, videoId),
|
|
879
|
+
finalUrl: `${YOUTUBE_ORIGIN}/watch?v=${videoId}`,
|
|
880
|
+
dispatchStarted: false,
|
|
881
|
+
dispatch: { planned: 0, started: 0, verified: 0 },
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
async function executePostRead(
|
|
886
|
+
bootstrap: YouTubeBootstrap,
|
|
887
|
+
input: OperationInput,
|
|
888
|
+
): Promise<WebSessionExecution> {
|
|
889
|
+
requireBoundSubject(bootstrap);
|
|
890
|
+
const postId = postIdInput(input);
|
|
891
|
+
const resolved = await innertube(
|
|
892
|
+
bootstrap,
|
|
893
|
+
"navigation/resolve_url",
|
|
894
|
+
{ url: `${YOUTUBE_ORIGIN}/post/${postId}` },
|
|
895
|
+
"YouTube Community URL resolution",
|
|
896
|
+
);
|
|
897
|
+
const browse = youtubePostBrowseRequest(resolved, postId);
|
|
898
|
+
const response = await innertube(
|
|
899
|
+
bootstrap,
|
|
900
|
+
"browse",
|
|
901
|
+
browse,
|
|
902
|
+
"YouTube Community post",
|
|
903
|
+
);
|
|
904
|
+
return {
|
|
905
|
+
status: "succeeded",
|
|
906
|
+
output: projectYouTubePost(response, postId),
|
|
907
|
+
finalUrl: `${YOUTUBE_ORIGIN}/post/${postId}`,
|
|
908
|
+
dispatchStarted: false,
|
|
909
|
+
dispatch: { planned: 0, started: 0, verified: 0 },
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
async function executeCommentsRead(
|
|
914
|
+
bootstrap: YouTubeBootstrap,
|
|
915
|
+
input: OperationInput,
|
|
916
|
+
): Promise<WebSessionExecution> {
|
|
917
|
+
requireBoundSubject(bootstrap);
|
|
918
|
+
const videoId = videoIdInput(input);
|
|
919
|
+
const limit = integerInput(input, "limit", DEFAULT_LIMIT, 1, 100);
|
|
920
|
+
const initial = await innertube(
|
|
921
|
+
bootstrap,
|
|
922
|
+
"next",
|
|
923
|
+
{ videoId },
|
|
924
|
+
"YouTube video comments bootstrap",
|
|
925
|
+
);
|
|
926
|
+
assertYouTubeVideoBinding(initial, videoId, "YouTube video comments bootstrap");
|
|
927
|
+
const continuation = findYouTubeCommentsContinuation(initial);
|
|
928
|
+
const response = continuation === null
|
|
929
|
+
? initial
|
|
930
|
+
: await innertube(
|
|
931
|
+
bootstrap,
|
|
932
|
+
"next",
|
|
933
|
+
{ continuation },
|
|
934
|
+
"YouTube comments",
|
|
935
|
+
);
|
|
936
|
+
return {
|
|
937
|
+
status: "succeeded",
|
|
938
|
+
output: { videoId, ...projectYouTubeComments(response, limit) },
|
|
939
|
+
finalUrl: `${YOUTUBE_ORIGIN}/watch?v=${videoId}`,
|
|
940
|
+
dispatchStarted: false,
|
|
941
|
+
dispatch: { planned: 0, started: 0, verified: 0 },
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function exactProfileCount(value: number | null): Readonly<Record<string, unknown>> {
|
|
946
|
+
return value === null
|
|
947
|
+
? Object.freeze({ status: "unavailable", reason: "not-exposed" })
|
|
948
|
+
: Object.freeze({
|
|
949
|
+
status: "available",
|
|
950
|
+
value,
|
|
951
|
+
precision: "exact",
|
|
952
|
+
unit: "count",
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
async function executeProfileRead(
|
|
957
|
+
bootstrap: YouTubeBootstrap,
|
|
958
|
+
input: OperationInput,
|
|
959
|
+
): Promise<WebSessionExecution> {
|
|
960
|
+
requireBoundSubject(bootstrap);
|
|
961
|
+
const target = youtubeProfileTarget(input.profile);
|
|
962
|
+
const resolved = await innertube(
|
|
963
|
+
bootstrap,
|
|
964
|
+
"navigation/resolve_url",
|
|
965
|
+
{ url: target.url },
|
|
966
|
+
"YouTube profile URL resolution",
|
|
967
|
+
);
|
|
968
|
+
const browse = youtubeProfileBrowseRequest(resolved, target);
|
|
969
|
+
const html = await bootstrap.client.requestText({
|
|
970
|
+
url: new URL(`${target.url}/about`),
|
|
971
|
+
headers: { accept: "text/html" },
|
|
972
|
+
expectedContentTypes: ["text/html"],
|
|
973
|
+
maxBytes: Math.min(bootstrap.maxOutputBytes, MAX_PROFILE_PAGE_BYTES),
|
|
974
|
+
});
|
|
975
|
+
const response = parseYouTubeInitialDataHtml(html);
|
|
976
|
+
const profile = projectYouTubeProfile(response, browse.browseId, target.handle);
|
|
977
|
+
if (
|
|
978
|
+
target.handle !== null
|
|
979
|
+
&& profile.handle?.toLocaleLowerCase("en-US") !== target.handle.toLocaleLowerCase("en-US")
|
|
980
|
+
) throw new Error("YouTube profile response did not bind the requested handle");
|
|
981
|
+
const observationTime = bootstrap.now();
|
|
982
|
+
if (
|
|
983
|
+
!Number.isSafeInteger(observationTime)
|
|
984
|
+
|| observationTime < 0
|
|
985
|
+
|| observationTime > 8_640_000_000_000_000
|
|
986
|
+
) throw new Error("YouTube profile observation time is invalid");
|
|
987
|
+
const complete = profile.subscribers !== null
|
|
988
|
+
&& profile.videos !== null
|
|
989
|
+
&& profile.views !== null;
|
|
990
|
+
return {
|
|
991
|
+
status: "succeeded",
|
|
992
|
+
output: Object.freeze({
|
|
993
|
+
schemaVersion: 1,
|
|
994
|
+
provider: "youtube",
|
|
995
|
+
target: Object.freeze({
|
|
996
|
+
kind: "profile",
|
|
997
|
+
id: profile.channelId,
|
|
998
|
+
url: profile.canonicalUrl,
|
|
999
|
+
}),
|
|
1000
|
+
observedAt: new Date(observationTime).toISOString(),
|
|
1001
|
+
completeness: complete ? "complete" : "partial",
|
|
1002
|
+
metrics: Object.freeze({
|
|
1003
|
+
subscribers: exactProfileCount(profile.subscribers),
|
|
1004
|
+
videos: exactProfileCount(profile.videos),
|
|
1005
|
+
views: exactProfileCount(profile.views),
|
|
1006
|
+
}),
|
|
1007
|
+
metadata: Object.freeze({
|
|
1008
|
+
...(profile.handle === null ? {} : { handle: profile.handle }),
|
|
1009
|
+
displayName: profile.displayName,
|
|
1010
|
+
...(profile.bio === null ? {} : { bio: profile.bio }),
|
|
1011
|
+
}),
|
|
1012
|
+
}),
|
|
1013
|
+
finalUrl: profile.canonicalUrl,
|
|
1014
|
+
dispatchStarted: false,
|
|
1015
|
+
dispatch: { planned: 0, started: 0, verified: 0 },
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
function dispatchEvent(
|
|
1020
|
+
action: string,
|
|
1021
|
+
started: number,
|
|
1022
|
+
verified: number,
|
|
1023
|
+
): WebSessionDispatchEvent {
|
|
1024
|
+
return { id: action, index: 1, progress: { planned: 1, started, verified } };
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
async function likeReadback(bootstrap: YouTubeBootstrap, videoId: string): Promise<boolean> {
|
|
1028
|
+
const response = await innertube(
|
|
1029
|
+
bootstrap,
|
|
1030
|
+
"next",
|
|
1031
|
+
{ videoId },
|
|
1032
|
+
"YouTube like readback",
|
|
1033
|
+
);
|
|
1034
|
+
return youtubeLikeState(response, videoId);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
async function saveReadback(bootstrap: YouTubeBootstrap, videoId: string): Promise<boolean> {
|
|
1038
|
+
const response = await innertube(
|
|
1039
|
+
bootstrap,
|
|
1040
|
+
"next",
|
|
1041
|
+
{ videoId },
|
|
1042
|
+
"YouTube save readback",
|
|
1043
|
+
);
|
|
1044
|
+
return youtubeWatchLaterState(response, videoId);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
async function followReadback(bootstrap: YouTubeBootstrap, channelId: string): Promise<boolean> {
|
|
1048
|
+
const response = await innertube(
|
|
1049
|
+
bootstrap,
|
|
1050
|
+
"browse",
|
|
1051
|
+
{ browseId: channelId },
|
|
1052
|
+
"YouTube subscription readback",
|
|
1053
|
+
);
|
|
1054
|
+
return youtubeSubscriptionState(response, channelId);
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function isYouTubeDesiredStateRecipe(recipe: WebSessionRecipe): boolean {
|
|
1058
|
+
return recipe.site === "youtube"
|
|
1059
|
+
&& recipe.contractVersion === 1
|
|
1060
|
+
&& (
|
|
1061
|
+
recipe.action === "likes.set"
|
|
1062
|
+
|| recipe.action === "content.save"
|
|
1063
|
+
|| recipe.action === "relationships.follow.set"
|
|
1064
|
+
);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
async function prepareDesiredStateWithBootstrap(
|
|
1068
|
+
bootstrap: YouTubeBootstrap,
|
|
1069
|
+
recipe: WebSessionRecipe,
|
|
1070
|
+
input: OperationInput,
|
|
1071
|
+
): Promise<{
|
|
1072
|
+
readonly preparation: YouTubeWebDesiredStatePreparation;
|
|
1073
|
+
readonly commandSource: unknown;
|
|
1074
|
+
}> {
|
|
1075
|
+
if (!isYouTubeDesiredStateRecipe(recipe)) {
|
|
1076
|
+
throw new Error(
|
|
1077
|
+
"YouTube desired-state preparation supports only likes.set, content.save, and relationships.follow.set",
|
|
1078
|
+
);
|
|
1079
|
+
}
|
|
1080
|
+
requireBoundSubject(bootstrap);
|
|
1081
|
+
const kind: YouTubeWebDesiredStateKind = recipe.action === "likes.set"
|
|
1082
|
+
? "like"
|
|
1083
|
+
: recipe.action === "content.save"
|
|
1084
|
+
? "watch-later"
|
|
1085
|
+
: "subscription";
|
|
1086
|
+
const targetId = kind === "subscription" ? channelIdInput(input) : videoIdInput(input);
|
|
1087
|
+
const desiredState = kind === "like"
|
|
1088
|
+
? booleanInput(input, "liked")
|
|
1089
|
+
: kind === "watch-later"
|
|
1090
|
+
? booleanInput(input, "saved")
|
|
1091
|
+
: booleanInput(input, "followed");
|
|
1092
|
+
const commandSource = kind === "subscription"
|
|
1093
|
+
? await innertube(
|
|
1094
|
+
bootstrap,
|
|
1095
|
+
"browse",
|
|
1096
|
+
{ browseId: targetId },
|
|
1097
|
+
"YouTube subscription command discovery",
|
|
1098
|
+
)
|
|
1099
|
+
: await innertube(
|
|
1100
|
+
bootstrap,
|
|
1101
|
+
"next",
|
|
1102
|
+
{ videoId: targetId },
|
|
1103
|
+
kind === "like"
|
|
1104
|
+
? "YouTube like command discovery"
|
|
1105
|
+
: "YouTube save readback",
|
|
1106
|
+
);
|
|
1107
|
+
const actualState = kind === "like"
|
|
1108
|
+
? youtubeLikeState(commandSource, targetId)
|
|
1109
|
+
: kind === "watch-later"
|
|
1110
|
+
? youtubeWatchLaterState(commandSource, targetId)
|
|
1111
|
+
: youtubeSubscriptionState(commandSource, targetId);
|
|
1112
|
+
return Object.freeze({
|
|
1113
|
+
preparation: Object.freeze({
|
|
1114
|
+
kind,
|
|
1115
|
+
targetId,
|
|
1116
|
+
desiredState,
|
|
1117
|
+
actualState,
|
|
1118
|
+
alreadyDesired: actualState === desiredState,
|
|
1119
|
+
}),
|
|
1120
|
+
commandSource: kind === "watch-later" ? null : commandSource,
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* Perform only the account and exact-target reads that precede a YouTube
|
|
1126
|
+
* desired-state write. Capture-required execution remains network-inert; this
|
|
1127
|
+
* read-only seam exists for reconciliation and deterministic preparation tests.
|
|
1128
|
+
*/
|
|
1129
|
+
export async function prepareYouTubeWebDesiredState(
|
|
1130
|
+
recipe: WebSessionRecipe,
|
|
1131
|
+
input: OperationInput,
|
|
1132
|
+
auth: GhostgetAuth,
|
|
1133
|
+
options: {
|
|
1134
|
+
readonly signal?: AbortSignal;
|
|
1135
|
+
readonly operationDeadline?: WebSessionOperationDeadline;
|
|
1136
|
+
readonly dependencies?: YouTubeWebRuntimeDependencies;
|
|
1137
|
+
} = {},
|
|
1138
|
+
): Promise<YouTubeWebDesiredStatePreparation> {
|
|
1139
|
+
if (!isYouTubeDesiredStateRecipe(recipe)) {
|
|
1140
|
+
throw new Error(
|
|
1141
|
+
"YouTube desired-state preparation supports only likes.set, content.save, and relationships.follow.set",
|
|
1142
|
+
);
|
|
1143
|
+
}
|
|
1144
|
+
const bootstrap = await bootstrapYouTube(auth, {
|
|
1145
|
+
timeoutMs: recipe.timeoutMs,
|
|
1146
|
+
maxOutputBytes: recipe.maxOutputBytes,
|
|
1147
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
1148
|
+
...(options.operationDeadline === undefined
|
|
1149
|
+
? {}
|
|
1150
|
+
: { operationDeadline: options.operationDeadline }),
|
|
1151
|
+
...(options.dependencies === undefined ? {} : { dependencies: options.dependencies }),
|
|
1152
|
+
});
|
|
1153
|
+
return (await prepareDesiredStateWithBootstrap(
|
|
1154
|
+
bootstrap,
|
|
1155
|
+
recipe,
|
|
1156
|
+
input,
|
|
1157
|
+
)).preparation;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/** Independently observe one exact YouTube desired state for reconciliation. */
|
|
1161
|
+
export async function readYouTubeWebDesiredState(
|
|
1162
|
+
recipe: WebSessionRecipe,
|
|
1163
|
+
input: OperationInput,
|
|
1164
|
+
auth: GhostgetAuth,
|
|
1165
|
+
options: {
|
|
1166
|
+
readonly signal?: AbortSignal;
|
|
1167
|
+
readonly operationDeadline?: WebSessionOperationDeadline;
|
|
1168
|
+
readonly dependencies?: YouTubeWebRuntimeDependencies;
|
|
1169
|
+
} = {},
|
|
1170
|
+
): Promise<YouTubeWebDesiredStateReadback> {
|
|
1171
|
+
const preparation = await prepareYouTubeWebDesiredState(
|
|
1172
|
+
recipe,
|
|
1173
|
+
input,
|
|
1174
|
+
auth,
|
|
1175
|
+
options,
|
|
1176
|
+
);
|
|
1177
|
+
return Object.freeze({
|
|
1178
|
+
kind: preparation.kind,
|
|
1179
|
+
targetId: preparation.targetId,
|
|
1180
|
+
enabled: preparation.actualState,
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
function desiredStateNoOp(
|
|
1185
|
+
preparation: YouTubeWebDesiredStatePreparation,
|
|
1186
|
+
): WebSessionExecution {
|
|
1187
|
+
return {
|
|
1188
|
+
status: "succeeded",
|
|
1189
|
+
output: Object.freeze({
|
|
1190
|
+
kind: preparation.kind,
|
|
1191
|
+
targetId: preparation.targetId,
|
|
1192
|
+
enabled: preparation.desiredState,
|
|
1193
|
+
noOp: true,
|
|
1194
|
+
effect: "already-satisfied",
|
|
1195
|
+
}),
|
|
1196
|
+
finalUrl: preparation.kind === "subscription"
|
|
1197
|
+
? `${YOUTUBE_ORIGIN}/channel/${preparation.targetId}`
|
|
1198
|
+
: `${YOUTUBE_ORIGIN}/watch?v=${preparation.targetId}`,
|
|
1199
|
+
dispatchStarted: false,
|
|
1200
|
+
dispatch: { planned: 1, started: 0, verified: 0 },
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
async function executeDesiredState(
|
|
1205
|
+
bootstrap: YouTubeBootstrap,
|
|
1206
|
+
recipe: WebSessionRecipe,
|
|
1207
|
+
input: OperationInput,
|
|
1208
|
+
options: {
|
|
1209
|
+
readonly beforeDispatch?: (event: WebSessionDispatchEvent) => Promise<void>;
|
|
1210
|
+
readonly afterDispatchVerified?: (event: WebSessionDispatchEvent) => Promise<void>;
|
|
1211
|
+
},
|
|
1212
|
+
): Promise<WebSessionExecution> {
|
|
1213
|
+
const prepared = await prepareDesiredStateWithBootstrap(bootstrap, recipe, input);
|
|
1214
|
+
const { kind, targetId, desiredState: desired } = prepared.preparation;
|
|
1215
|
+
if (prepared.preparation.alreadyDesired) {
|
|
1216
|
+
return desiredStateNoOp(prepared.preparation);
|
|
1217
|
+
}
|
|
1218
|
+
let started = 0;
|
|
1219
|
+
let verified = 0;
|
|
1220
|
+
try {
|
|
1221
|
+
if (kind === "like") {
|
|
1222
|
+
const mutation = youtubeLikeMutationRequest(
|
|
1223
|
+
prepared.commandSource,
|
|
1224
|
+
targetId,
|
|
1225
|
+
desired,
|
|
1226
|
+
);
|
|
1227
|
+
await options.beforeDispatch?.(dispatchEvent(recipe.action, 0, 0));
|
|
1228
|
+
started = 1;
|
|
1229
|
+
await innertube(
|
|
1230
|
+
bootstrap,
|
|
1231
|
+
mutation.endpoint,
|
|
1232
|
+
mutation.body,
|
|
1233
|
+
"YouTube like mutation",
|
|
1234
|
+
);
|
|
1235
|
+
} else if (kind === "watch-later") {
|
|
1236
|
+
await options.beforeDispatch?.(dispatchEvent(recipe.action, 0, 0));
|
|
1237
|
+
started = 1;
|
|
1238
|
+
await innertube(
|
|
1239
|
+
bootstrap,
|
|
1240
|
+
"playlist/edit",
|
|
1241
|
+
{
|
|
1242
|
+
playlistId: "WL",
|
|
1243
|
+
actions: [{
|
|
1244
|
+
action: desired ? "ACTION_ADD_VIDEO" : "ACTION_REMOVE_VIDEO",
|
|
1245
|
+
...(desired ? { addedVideoId: targetId } : { removedVideoId: targetId }),
|
|
1246
|
+
}],
|
|
1247
|
+
},
|
|
1248
|
+
"YouTube Watch Later mutation",
|
|
1249
|
+
);
|
|
1250
|
+
} else {
|
|
1251
|
+
const mutation = youtubeSubscriptionMutationRequest(
|
|
1252
|
+
prepared.commandSource,
|
|
1253
|
+
targetId,
|
|
1254
|
+
desired,
|
|
1255
|
+
);
|
|
1256
|
+
await options.beforeDispatch?.(dispatchEvent(recipe.action, 0, 0));
|
|
1257
|
+
started = 1;
|
|
1258
|
+
await innertube(
|
|
1259
|
+
bootstrap,
|
|
1260
|
+
mutation.endpoint,
|
|
1261
|
+
mutation.body,
|
|
1262
|
+
"YouTube subscription mutation",
|
|
1263
|
+
);
|
|
1264
|
+
}
|
|
1265
|
+
const actual = kind === "like"
|
|
1266
|
+
? await likeReadback(bootstrap, targetId)
|
|
1267
|
+
: kind === "watch-later"
|
|
1268
|
+
? await saveReadback(bootstrap, targetId)
|
|
1269
|
+
: await followReadback(bootstrap, targetId);
|
|
1270
|
+
if (actual !== desired) throw new Error("YouTube desired-state readback did not match the confirmed state");
|
|
1271
|
+
verified = 1;
|
|
1272
|
+
await options.afterDispatchVerified?.(dispatchEvent(recipe.action, started, verified));
|
|
1273
|
+
return {
|
|
1274
|
+
status: "succeeded",
|
|
1275
|
+
output: { kind, targetId, enabled: desired },
|
|
1276
|
+
finalUrl: kind === "subscription"
|
|
1277
|
+
? `${YOUTUBE_ORIGIN}/channel/${targetId}`
|
|
1278
|
+
: `${YOUTUBE_ORIGIN}/watch?v=${targetId}`,
|
|
1279
|
+
dispatchStarted: true,
|
|
1280
|
+
dispatch: { planned: 1, started, verified },
|
|
1281
|
+
};
|
|
1282
|
+
} catch {
|
|
1283
|
+
return {
|
|
1284
|
+
status: started > 0 ? "indeterminate" : "failed",
|
|
1285
|
+
output: null,
|
|
1286
|
+
finalUrl: kind === "subscription"
|
|
1287
|
+
? `${YOUTUBE_ORIGIN}/channel/${targetId}`
|
|
1288
|
+
: `${YOUTUBE_ORIGIN}/watch?v=${targetId}`,
|
|
1289
|
+
dispatchStarted: started > 0,
|
|
1290
|
+
dispatch: { planned: 1, started, verified },
|
|
1291
|
+
error: started > 0
|
|
1292
|
+
? "YouTube may have changed the requested state but exact readback was not verified; reconcile before retrying"
|
|
1293
|
+
: "YouTube desired-state dispatch failed before submission",
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
export async function executeYouTubeWebOperation(
|
|
1299
|
+
recipe: WebSessionRecipe,
|
|
1300
|
+
input: OperationInput,
|
|
1301
|
+
auth: GhostgetAuth,
|
|
1302
|
+
options: {
|
|
1303
|
+
readonly signal?: AbortSignal;
|
|
1304
|
+
readonly operationDeadline?: WebSessionOperationDeadline;
|
|
1305
|
+
readonly fileResolver?: BrowserFileResolver;
|
|
1306
|
+
readonly beforeDispatch?: (event: WebSessionDispatchEvent) => Promise<void>;
|
|
1307
|
+
readonly afterDispatchVerified?: (event: WebSessionDispatchEvent) => Promise<void>;
|
|
1308
|
+
readonly dependencies?: YouTubeWebRuntimeDependencies;
|
|
1309
|
+
} = {},
|
|
1310
|
+
): Promise<WebSessionExecution> {
|
|
1311
|
+
if (
|
|
1312
|
+
recipe.site === "youtube"
|
|
1313
|
+
&& (
|
|
1314
|
+
(recipe.action === "media.publish" && recipe.contractVersion === 2)
|
|
1315
|
+
|| (recipe.action === "content.delete" && recipe.contractVersion === 1)
|
|
1316
|
+
)
|
|
1317
|
+
) {
|
|
1318
|
+
const reason = YOUTUBE_VIDEO_CAPTURE_REQUIRED_REASONS[recipe.action as keyof typeof YOUTUBE_VIDEO_CAPTURE_REQUIRED_REASONS];
|
|
1319
|
+
throw new Error(
|
|
1320
|
+
`YouTube authenticated web operation ${recipe.action} is capture-required: ${reason}`,
|
|
1321
|
+
);
|
|
1322
|
+
}
|
|
1323
|
+
if (
|
|
1324
|
+
recipe.site === "youtube"
|
|
1325
|
+
&& recipe.contractVersion === 1
|
|
1326
|
+
&& [
|
|
1327
|
+
"content.save",
|
|
1328
|
+
"likes.set",
|
|
1329
|
+
"relationships.follow.set",
|
|
1330
|
+
].includes(recipe.action)
|
|
1331
|
+
) {
|
|
1332
|
+
throw new Error(
|
|
1333
|
+
`YouTube authenticated web operation ${recipe.action} is capture-required until an authorized low-stakes live fixture passes`,
|
|
1334
|
+
);
|
|
1335
|
+
}
|
|
1336
|
+
if (
|
|
1337
|
+
recipe.site !== "youtube"
|
|
1338
|
+
|| recipe.contractVersion !== 1
|
|
1339
|
+
|| ![
|
|
1340
|
+
"comments.read",
|
|
1341
|
+
"content.save",
|
|
1342
|
+
"feeds.read",
|
|
1343
|
+
"likes.set",
|
|
1344
|
+
"media.read",
|
|
1345
|
+
"posts.read",
|
|
1346
|
+
"profiles.read",
|
|
1347
|
+
"relationships.follow.set",
|
|
1348
|
+
].includes(recipe.action)
|
|
1349
|
+
) {
|
|
1350
|
+
throw new Error(`YouTube authenticated web operation ${recipe.action} has no executable reviewed contract`);
|
|
1351
|
+
}
|
|
1352
|
+
const bootstrapOptions = {
|
|
1353
|
+
timeoutMs: recipe.timeoutMs,
|
|
1354
|
+
maxOutputBytes: recipe.maxOutputBytes,
|
|
1355
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
1356
|
+
...(options.operationDeadline === undefined
|
|
1357
|
+
? {}
|
|
1358
|
+
: { operationDeadline: options.operationDeadline }),
|
|
1359
|
+
...(options.dependencies === undefined ? {} : { dependencies: options.dependencies }),
|
|
1360
|
+
};
|
|
1361
|
+
if (recipe.action === "profiles.read") {
|
|
1362
|
+
const target = youtubeProfileTarget(input.profile);
|
|
1363
|
+
let bootstrap: YouTubeBootstrap;
|
|
1364
|
+
try {
|
|
1365
|
+
bootstrap = await bootstrapYouTube(auth, bootstrapOptions);
|
|
1366
|
+
} catch (error) {
|
|
1367
|
+
return failedProviderRead("YouTube profile", error, target.url, {
|
|
1368
|
+
stage: "bootstrap",
|
|
1369
|
+
authenticated: true,
|
|
1370
|
+
authRepairRequired: (candidate) => candidate.message.includes("cookie")
|
|
1371
|
+
|| candidate.message.includes("bound auth subject")
|
|
1372
|
+
|| candidate instanceof YouTubeAuthRepairRequiredError,
|
|
1373
|
+
accountMismatch: (candidate) => candidate.message.includes("current account did not match"),
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1376
|
+
try {
|
|
1377
|
+
return await executeProfileRead(bootstrap, input);
|
|
1378
|
+
} catch (error) {
|
|
1379
|
+
return failedProviderRead("YouTube profile", error, target.url, {
|
|
1380
|
+
stage: "target",
|
|
1381
|
+
authenticated: true,
|
|
1382
|
+
authRepairRequired: (candidate) => candidate.message.includes("cookie")
|
|
1383
|
+
|| candidate.message.includes("bound auth subject")
|
|
1384
|
+
|| candidate instanceof YouTubeAuthRepairRequiredError,
|
|
1385
|
+
accountMismatch: (candidate) => candidate.message.includes("current account did not match"),
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
const bootstrap = await bootstrapYouTube(auth, bootstrapOptions);
|
|
1390
|
+
if (recipe.action === "feeds.read") return executeFeed(bootstrap, input);
|
|
1391
|
+
if (recipe.action === "media.read") return executeMediaRead(bootstrap, input);
|
|
1392
|
+
if (recipe.action === "posts.read") return executePostRead(bootstrap, input);
|
|
1393
|
+
if (recipe.action === "comments.read") return executeCommentsRead(bootstrap, input);
|
|
1394
|
+
if (
|
|
1395
|
+
recipe.action === "likes.set"
|
|
1396
|
+
|| recipe.action === "content.save"
|
|
1397
|
+
|| recipe.action === "relationships.follow.set"
|
|
1398
|
+
) return executeDesiredState(bootstrap, recipe, input, options);
|
|
1399
|
+
throw new Error(`YouTube authenticated web operation ${recipe.action} has no executable reviewed contract`);
|
|
1400
|
+
}
|