@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,3305 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ArticleDraftDocument,
|
|
3
|
+
ArticleDraftDocumentV2,
|
|
4
|
+
ArticleDraftImageBlock,
|
|
5
|
+
ArticleDraftLinkRange,
|
|
6
|
+
ArticleDraftTextBlock,
|
|
7
|
+
} from "../article-draft-document";
|
|
8
|
+
|
|
9
|
+
const MAX_CSRF_TOKEN_CHARACTERS = 1_024;
|
|
10
|
+
const MAX_QUERY_CANDIDATES = 4_096;
|
|
11
|
+
const MAX_QUERY_CANDIDATE_CHARACTERS = 512;
|
|
12
|
+
const MAX_MESSAGING_ITEMS = 100;
|
|
13
|
+
|
|
14
|
+
export const LINKEDIN_MESSENGER_CONVERSATIONS_QUERY_PREFIX = "messengerConversations";
|
|
15
|
+
export const LINKEDIN_MESSENGER_CONVERSATIONS_OBSERVED_QUERY_ID =
|
|
16
|
+
"messengerConversations.0d5e6781bbee71c3e51c8843c6519f48";
|
|
17
|
+
export const LINKEDIN_MESSENGER_GRAPHQL_PATH = "/voyager/api/voyagerMessagingGraphQL/graphql";
|
|
18
|
+
export const LINKEDIN_POST_CREATE_MUTATION_ID =
|
|
19
|
+
"voyagerContentcreationDashShares.80089eb2e82a2dfa23cb621fb09eb7bf";
|
|
20
|
+
export const LINKEDIN_POST_READBACK_QUERY_ID =
|
|
21
|
+
"voyagerFeedDashUpdates.00f9ed72d35c2a949114759b829f9886";
|
|
22
|
+
export const LINKEDIN_GRAPHQL_PATH = "/voyager/api/graphql";
|
|
23
|
+
|
|
24
|
+
export const LINKEDIN_WEB_OPERATION_NAMES = Object.freeze([
|
|
25
|
+
"feeds.read",
|
|
26
|
+
"contacts.list",
|
|
27
|
+
"contacts.read",
|
|
28
|
+
"profiles.read",
|
|
29
|
+
"organizations.read",
|
|
30
|
+
"relationships.recommendations.read",
|
|
31
|
+
"messaging.list",
|
|
32
|
+
"messaging.read",
|
|
33
|
+
"messaging.send",
|
|
34
|
+
"media.publish",
|
|
35
|
+
"posts.read",
|
|
36
|
+
"posts.publish",
|
|
37
|
+
"posts.repost",
|
|
38
|
+
"posts.quote",
|
|
39
|
+
"comments.read",
|
|
40
|
+
"comments.create",
|
|
41
|
+
"replies.create",
|
|
42
|
+
"reactions.set",
|
|
43
|
+
"relationships.connect",
|
|
44
|
+
"articles.read",
|
|
45
|
+
"articles.draft.save",
|
|
46
|
+
"articles.publish",
|
|
47
|
+
] as const);
|
|
48
|
+
|
|
49
|
+
export type LinkedInWebOperationName = (typeof LINKEDIN_WEB_OPERATION_NAMES)[number];
|
|
50
|
+
export type LinkedInWebRisk = "R1" | "R2" | "R3";
|
|
51
|
+
export type LinkedInWebContractState = "observed" | "capture-required";
|
|
52
|
+
export type LinkedInWebEvidence =
|
|
53
|
+
| "live-har"
|
|
54
|
+
| "live-response"
|
|
55
|
+
| "first-party-bundle"
|
|
56
|
+
| "none";
|
|
57
|
+
|
|
58
|
+
type LinkedInWebReadRequestRule = {
|
|
59
|
+
readonly kind: "registered-query" | "restli-read" | "server-rendered-read";
|
|
60
|
+
readonly method: "GET";
|
|
61
|
+
readonly path: string;
|
|
62
|
+
readonly queryPrefix: string | null;
|
|
63
|
+
readonly allowedQueryParameters: readonly string[];
|
|
64
|
+
readonly requiredQueryParameters: readonly string[];
|
|
65
|
+
readonly fixedQueryParameters: readonly (readonly [string, string])[];
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
type LinkedInWebWriteRequestRule = {
|
|
69
|
+
readonly kind: "registered-mutation" | "restli-write" | "server-bound-upload";
|
|
70
|
+
readonly method: "POST" | "PUT";
|
|
71
|
+
readonly path: string;
|
|
72
|
+
readonly queryId: string | null;
|
|
73
|
+
readonly fixedQueryParameters: readonly (readonly [string, string])[];
|
|
74
|
+
readonly bodyContract: string;
|
|
75
|
+
readonly targetHostnameFamilies: readonly string[];
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
type LinkedInWebRequestRule = LinkedInWebReadRequestRule | LinkedInWebWriteRequestRule;
|
|
79
|
+
|
|
80
|
+
export type LinkedInWebOperationContract = {
|
|
81
|
+
readonly effect: "read" | "write";
|
|
82
|
+
readonly risk: LinkedInWebRisk;
|
|
83
|
+
readonly state: LinkedInWebContractState;
|
|
84
|
+
readonly evidence: LinkedInWebEvidence;
|
|
85
|
+
readonly requests: readonly LinkedInWebRequestRule[];
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Code-owned internal-web operation surface. A capture-required entry is a
|
|
90
|
+
* semantic reservation, not permission to guess or execute an endpoint.
|
|
91
|
+
*/
|
|
92
|
+
export const LINKEDIN_WEB_OPERATIONS = {
|
|
93
|
+
"contacts.list": {
|
|
94
|
+
effect: "read",
|
|
95
|
+
risk: "R1",
|
|
96
|
+
state: "capture-required",
|
|
97
|
+
evidence: "none",
|
|
98
|
+
requests: [],
|
|
99
|
+
},
|
|
100
|
+
"contacts.read": {
|
|
101
|
+
effect: "read",
|
|
102
|
+
risk: "R1",
|
|
103
|
+
state: "observed",
|
|
104
|
+
evidence: "live-response",
|
|
105
|
+
requests: [{
|
|
106
|
+
kind: "server-rendered-read",
|
|
107
|
+
method: "GET",
|
|
108
|
+
path: "/in/:publicIdentifier/ and /voyager/api/graphql voyagerIdentityDashProfileContactInfo",
|
|
109
|
+
queryPrefix: "voyagerIdentityDashProfileContactInfo",
|
|
110
|
+
allowedQueryParameters: ["includeWebMetadata", "queryId", "queryName", "variables"],
|
|
111
|
+
requiredQueryParameters: ["includeWebMetadata", "variables"],
|
|
112
|
+
fixedQueryParameters: [["includeWebMetadata", "true"]],
|
|
113
|
+
}],
|
|
114
|
+
},
|
|
115
|
+
"feeds.read": {
|
|
116
|
+
effect: "read",
|
|
117
|
+
risk: "R1",
|
|
118
|
+
state: "observed",
|
|
119
|
+
evidence: "live-response",
|
|
120
|
+
requests: [{
|
|
121
|
+
kind: "registered-query",
|
|
122
|
+
method: "GET",
|
|
123
|
+
path: "/voyager/api/graphql",
|
|
124
|
+
queryPrefix: "voyagerFeedDashProfileUpdates",
|
|
125
|
+
allowedQueryParameters: ["includeWebMetadata", "queryId", "variables"],
|
|
126
|
+
requiredQueryParameters: ["includeWebMetadata", "queryId", "variables"],
|
|
127
|
+
fixedQueryParameters: [["includeWebMetadata", "true"]],
|
|
128
|
+
}],
|
|
129
|
+
},
|
|
130
|
+
"profiles.read": {
|
|
131
|
+
effect: "read",
|
|
132
|
+
risk: "R1",
|
|
133
|
+
state: "observed",
|
|
134
|
+
evidence: "live-response",
|
|
135
|
+
requests: [{
|
|
136
|
+
kind: "server-rendered-read",
|
|
137
|
+
method: "GET",
|
|
138
|
+
path: "/in/:publicIdentifier/ and /mynetwork/invite-connect/connections/",
|
|
139
|
+
queryPrefix: null,
|
|
140
|
+
allowedQueryParameters: [],
|
|
141
|
+
requiredQueryParameters: [],
|
|
142
|
+
fixedQueryParameters: [],
|
|
143
|
+
}],
|
|
144
|
+
},
|
|
145
|
+
"organizations.read": {
|
|
146
|
+
effect: "read",
|
|
147
|
+
risk: "R1",
|
|
148
|
+
state: "observed",
|
|
149
|
+
evidence: "live-response",
|
|
150
|
+
requests: [{
|
|
151
|
+
kind: "server-rendered-read",
|
|
152
|
+
method: "GET",
|
|
153
|
+
path: "/company/:universalName/",
|
|
154
|
+
queryPrefix: null,
|
|
155
|
+
allowedQueryParameters: [],
|
|
156
|
+
requiredQueryParameters: [],
|
|
157
|
+
fixedQueryParameters: [],
|
|
158
|
+
}],
|
|
159
|
+
},
|
|
160
|
+
"relationships.recommendations.read": {
|
|
161
|
+
effect: "read",
|
|
162
|
+
risk: "R1",
|
|
163
|
+
state: "capture-required",
|
|
164
|
+
evidence: "none",
|
|
165
|
+
requests: [],
|
|
166
|
+
},
|
|
167
|
+
"messaging.list": {
|
|
168
|
+
effect: "read",
|
|
169
|
+
risk: "R1",
|
|
170
|
+
state: "capture-required",
|
|
171
|
+
evidence: "live-har",
|
|
172
|
+
requests: [],
|
|
173
|
+
},
|
|
174
|
+
"messaging.read": {
|
|
175
|
+
effect: "read",
|
|
176
|
+
risk: "R1",
|
|
177
|
+
state: "capture-required",
|
|
178
|
+
evidence: "live-har",
|
|
179
|
+
requests: [],
|
|
180
|
+
},
|
|
181
|
+
"messaging.send": {
|
|
182
|
+
effect: "write",
|
|
183
|
+
risk: "R3",
|
|
184
|
+
state: "capture-required",
|
|
185
|
+
evidence: "none",
|
|
186
|
+
requests: [],
|
|
187
|
+
},
|
|
188
|
+
"media.publish": {
|
|
189
|
+
effect: "write",
|
|
190
|
+
risk: "R3",
|
|
191
|
+
state: "capture-required",
|
|
192
|
+
evidence: "none",
|
|
193
|
+
requests: [],
|
|
194
|
+
},
|
|
195
|
+
"posts.read": {
|
|
196
|
+
effect: "read",
|
|
197
|
+
risk: "R1",
|
|
198
|
+
state: "capture-required",
|
|
199
|
+
evidence: "none",
|
|
200
|
+
requests: [],
|
|
201
|
+
},
|
|
202
|
+
"posts.publish": {
|
|
203
|
+
effect: "write",
|
|
204
|
+
risk: "R3",
|
|
205
|
+
state: "observed",
|
|
206
|
+
evidence: "first-party-bundle",
|
|
207
|
+
requests: [
|
|
208
|
+
{
|
|
209
|
+
kind: "restli-write",
|
|
210
|
+
method: "POST",
|
|
211
|
+
path: "/voyager/api/voyagerVideoDashMediaUploadMetadata",
|
|
212
|
+
queryId: null,
|
|
213
|
+
fixedQueryParameters: [["action", "upload"]],
|
|
214
|
+
bodyContract: "IMAGE_SHARING registration for the exact plan-bound PNG size and fixed filename",
|
|
215
|
+
targetHostnameFamilies: ["linkedin.com"],
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
kind: "server-bound-upload",
|
|
219
|
+
method: "PUT",
|
|
220
|
+
path: "server-returned exact upload URL",
|
|
221
|
+
queryId: null,
|
|
222
|
+
fixedQueryParameters: [],
|
|
223
|
+
bodyContract: "exact registered PNG bytes once, or exact contiguous registered parts once",
|
|
224
|
+
targetHostnameFamilies: ["linkedin.com", "licdn.com"],
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
kind: "restli-write",
|
|
228
|
+
method: "POST",
|
|
229
|
+
path: "/voyager/api/voyagerVideoDashMediaUploadMetadata",
|
|
230
|
+
queryId: null,
|
|
231
|
+
fixedQueryParameters: [["action", "completeMultipartUpload"]],
|
|
232
|
+
bodyContract: "registered artifact, multipart metadata, and exact per-part response evidence only",
|
|
233
|
+
targetHostnameFamilies: ["linkedin.com"],
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
kind: "registered-mutation",
|
|
237
|
+
method: "POST",
|
|
238
|
+
path: LINKEDIN_GRAPHQL_PATH,
|
|
239
|
+
queryId: LINKEDIN_POST_CREATE_MUTATION_ID,
|
|
240
|
+
fixedQueryParameters: [["action", "execute"]],
|
|
241
|
+
bodyContract: "one PUBLISHED FEED post with exact commentary, fixed commenter scope, confirmed visibility, and optional response-bound IMAGE media URN",
|
|
242
|
+
targetHostnameFamilies: ["linkedin.com"],
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
kind: "registered-query",
|
|
246
|
+
method: "GET",
|
|
247
|
+
path: LINKEDIN_GRAPHQL_PATH,
|
|
248
|
+
queryPrefix: "voyagerFeedDashUpdates",
|
|
249
|
+
allowedQueryParameters: ["includeWebMetadata", "queryId", "variables"],
|
|
250
|
+
requiredQueryParameters: ["includeWebMetadata", "queryId", "variables"],
|
|
251
|
+
fixedQueryParameters: [
|
|
252
|
+
["includeWebMetadata", "true"],
|
|
253
|
+
["queryId", LINKEDIN_POST_READBACK_QUERY_ID],
|
|
254
|
+
],
|
|
255
|
+
},
|
|
256
|
+
],
|
|
257
|
+
},
|
|
258
|
+
"posts.repost": {
|
|
259
|
+
effect: "write",
|
|
260
|
+
risk: "R3",
|
|
261
|
+
state: "capture-required",
|
|
262
|
+
evidence: "none",
|
|
263
|
+
requests: [],
|
|
264
|
+
},
|
|
265
|
+
"posts.quote": {
|
|
266
|
+
effect: "write",
|
|
267
|
+
risk: "R3",
|
|
268
|
+
state: "capture-required",
|
|
269
|
+
evidence: "none",
|
|
270
|
+
requests: [],
|
|
271
|
+
},
|
|
272
|
+
"comments.read": {
|
|
273
|
+
effect: "read",
|
|
274
|
+
risk: "R1",
|
|
275
|
+
state: "capture-required",
|
|
276
|
+
evidence: "first-party-bundle",
|
|
277
|
+
requests: [],
|
|
278
|
+
},
|
|
279
|
+
"comments.create": {
|
|
280
|
+
effect: "write",
|
|
281
|
+
risk: "R3",
|
|
282
|
+
state: "capture-required",
|
|
283
|
+
evidence: "first-party-bundle",
|
|
284
|
+
requests: [],
|
|
285
|
+
},
|
|
286
|
+
"replies.create": {
|
|
287
|
+
effect: "write",
|
|
288
|
+
risk: "R3",
|
|
289
|
+
state: "capture-required",
|
|
290
|
+
evidence: "first-party-bundle",
|
|
291
|
+
requests: [],
|
|
292
|
+
},
|
|
293
|
+
"reactions.set": {
|
|
294
|
+
effect: "write",
|
|
295
|
+
risk: "R2",
|
|
296
|
+
state: "capture-required",
|
|
297
|
+
evidence: "none",
|
|
298
|
+
requests: [],
|
|
299
|
+
},
|
|
300
|
+
"relationships.connect": {
|
|
301
|
+
effect: "write",
|
|
302
|
+
risk: "R3",
|
|
303
|
+
state: "capture-required",
|
|
304
|
+
evidence: "none",
|
|
305
|
+
requests: [],
|
|
306
|
+
},
|
|
307
|
+
"articles.read": {
|
|
308
|
+
effect: "read",
|
|
309
|
+
risk: "R1",
|
|
310
|
+
state: "capture-required",
|
|
311
|
+
evidence: "live-har",
|
|
312
|
+
requests: [],
|
|
313
|
+
},
|
|
314
|
+
"articles.draft.save": {
|
|
315
|
+
effect: "write",
|
|
316
|
+
risk: "R2",
|
|
317
|
+
state: "observed",
|
|
318
|
+
evidence: "live-har",
|
|
319
|
+
requests: [],
|
|
320
|
+
},
|
|
321
|
+
"articles.publish": {
|
|
322
|
+
effect: "write",
|
|
323
|
+
risk: "R3",
|
|
324
|
+
state: "capture-required",
|
|
325
|
+
evidence: "none",
|
|
326
|
+
requests: [],
|
|
327
|
+
},
|
|
328
|
+
} as const satisfies Readonly<Record<LinkedInWebOperationName, LinkedInWebOperationContract>>;
|
|
329
|
+
|
|
330
|
+
for (const contract of Object.values(LINKEDIN_WEB_OPERATIONS)) {
|
|
331
|
+
for (const request of contract.requests) Object.freeze(request);
|
|
332
|
+
Object.freeze(contract.requests);
|
|
333
|
+
Object.freeze(contract);
|
|
334
|
+
}
|
|
335
|
+
Object.freeze(LINKEDIN_WEB_OPERATIONS);
|
|
336
|
+
|
|
337
|
+
export const LINKEDIN_WEB_FOLDER_CATEGORIES = Object.freeze({
|
|
338
|
+
focused: "PRIMARY_INBOX",
|
|
339
|
+
other: "SECONDARY_INBOX",
|
|
340
|
+
requests: "MESSAGE_REQUEST_PENDING",
|
|
341
|
+
archive: "ARCHIVE",
|
|
342
|
+
spam: "SPAM",
|
|
343
|
+
all: "INBOX",
|
|
344
|
+
} as const);
|
|
345
|
+
|
|
346
|
+
export type LinkedInWebFolder = keyof typeof LINKEDIN_WEB_FOLDER_CATEGORIES;
|
|
347
|
+
export type LinkedInWebFolderCategory = (typeof LINKEDIN_WEB_FOLDER_CATEGORIES)[LinkedInWebFolder];
|
|
348
|
+
|
|
349
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
350
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function hasOwn(record: object, key: PropertyKey): boolean {
|
|
354
|
+
return Object.prototype.hasOwnProperty.call(record, key);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export function linkedInWebFolderCategory(value: unknown): LinkedInWebFolderCategory {
|
|
358
|
+
if (
|
|
359
|
+
typeof value !== "string"
|
|
360
|
+
|| !hasOwn(LINKEDIN_WEB_FOLDER_CATEGORIES, value)
|
|
361
|
+
) {
|
|
362
|
+
throw new Error("LinkedIn folder must be focused, other, requests, archive, spam, or all");
|
|
363
|
+
}
|
|
364
|
+
return LINKEDIN_WEB_FOLDER_CATEGORIES[value as LinkedInWebFolder];
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Derives LinkedIn's csrf-token header without logging or returning cookie metadata. */
|
|
368
|
+
export function linkedInCsrfTokenFromJSessionId(value: unknown): string {
|
|
369
|
+
if (typeof value !== "string" || value.length === 0 || value.length > MAX_CSRF_TOKEN_CHARACTERS) {
|
|
370
|
+
throw new Error("LinkedIn JSESSIONID must be a bounded cookie value");
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const startsQuoted = value.startsWith('"');
|
|
374
|
+
const endsQuoted = value.endsWith('"');
|
|
375
|
+
if (startsQuoted !== endsQuoted) {
|
|
376
|
+
throw new Error("LinkedIn JSESSIONID has mismatched wrapper quotes");
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const token = startsQuoted ? value.slice(1, -1) : value;
|
|
380
|
+
if (!token.startsWith("ajax:") || token.length === "ajax:".length) {
|
|
381
|
+
throw new Error("LinkedIn JSESSIONID is not an ajax session token");
|
|
382
|
+
}
|
|
383
|
+
for (let index = 0; index < token.length; index += 1) {
|
|
384
|
+
const code = token.charCodeAt(index);
|
|
385
|
+
if (
|
|
386
|
+
code < 0x21
|
|
387
|
+
|| code > 0x7e
|
|
388
|
+
|| code === 0x22
|
|
389
|
+
|| code === 0x2c
|
|
390
|
+
|| code === 0x3b
|
|
391
|
+
|| code === 0x5c
|
|
392
|
+
) {
|
|
393
|
+
throw new Error("LinkedIn JSESSIONID contains an invalid cookie character");
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return token;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function queryPrefix(value: unknown): string {
|
|
400
|
+
if (typeof value !== "string" || !/^[A-Za-z][A-Za-z0-9_]{0,127}$/u.test(value)) {
|
|
401
|
+
throw new Error("LinkedIn registered-query prefix is invalid");
|
|
402
|
+
}
|
|
403
|
+
return value;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function escapedRegularExpression(value: string): string {
|
|
407
|
+
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Resolves one unique current registered-query ID and fails closed on drift ambiguity. */
|
|
411
|
+
export function resolveLinkedInRegisteredQueryId(
|
|
412
|
+
prefixValue: unknown,
|
|
413
|
+
candidatesValue: unknown,
|
|
414
|
+
): string {
|
|
415
|
+
const prefix = queryPrefix(prefixValue);
|
|
416
|
+
if (!Array.isArray(candidatesValue) || candidatesValue.length > MAX_QUERY_CANDIDATES) {
|
|
417
|
+
throw new Error("LinkedIn registered-query candidates must be a bounded array");
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const matcher = new RegExp(`^${escapedRegularExpression(prefix)}\\.[0-9a-fA-F]{32}$`, "u");
|
|
421
|
+
const matches = new Set<string>();
|
|
422
|
+
for (const candidate of candidatesValue) {
|
|
423
|
+
if (typeof candidate !== "string" || candidate.length > MAX_QUERY_CANDIDATE_CHARACTERS) {
|
|
424
|
+
throw new Error("LinkedIn registered-query candidate is invalid");
|
|
425
|
+
}
|
|
426
|
+
if (matcher.test(candidate)) matches.add(candidate);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (matches.size === 0) {
|
|
430
|
+
throw new Error(`LinkedIn registered query ${prefix} was not found`);
|
|
431
|
+
}
|
|
432
|
+
if (matches.size !== 1) {
|
|
433
|
+
throw new Error(`LinkedIn registered query ${prefix} is ambiguous`);
|
|
434
|
+
}
|
|
435
|
+
const match = matches.values().next().value;
|
|
436
|
+
if (match === undefined) throw new Error("LinkedIn registered-query resolution failed");
|
|
437
|
+
return match;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export const RESTLI_V2_VALUE_LIMITS = Object.freeze({
|
|
441
|
+
maximumDepth: 12,
|
|
442
|
+
maximumNodes: 4_096,
|
|
443
|
+
maximumStringCharacters: 8_192,
|
|
444
|
+
maximumListItems: 512,
|
|
445
|
+
maximumObjectFields: 256,
|
|
446
|
+
maximumEncodedCharacters: 64 * 1_024,
|
|
447
|
+
} as const);
|
|
448
|
+
|
|
449
|
+
type RestliEncodingState = {
|
|
450
|
+
nodes: number;
|
|
451
|
+
readonly active: WeakSet<object>;
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
function encodedRestliString(value: string): string {
|
|
455
|
+
if (value.length > RESTLI_V2_VALUE_LIMITS.maximumStringCharacters) {
|
|
456
|
+
throw new Error("Rest.li string exceeds the value limit");
|
|
457
|
+
}
|
|
458
|
+
let encoded: string;
|
|
459
|
+
try {
|
|
460
|
+
encoded = encodeURIComponent(value);
|
|
461
|
+
} catch {
|
|
462
|
+
throw new Error("Rest.li string contains invalid Unicode");
|
|
463
|
+
}
|
|
464
|
+
return encoded.replace(/[!'()*]/gu, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function boundedEncodedValue(value: string): string {
|
|
468
|
+
if (value.length > RESTLI_V2_VALUE_LIMITS.maximumEncodedCharacters) {
|
|
469
|
+
throw new Error("Rest.li encoded value exceeds the output limit");
|
|
470
|
+
}
|
|
471
|
+
return value;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function encodeRestliValue(value: unknown, depth: number, state: RestliEncodingState): string {
|
|
475
|
+
if (depth > RESTLI_V2_VALUE_LIMITS.maximumDepth) {
|
|
476
|
+
throw new Error("Rest.li value exceeds the nesting-depth limit");
|
|
477
|
+
}
|
|
478
|
+
state.nodes += 1;
|
|
479
|
+
if (state.nodes > RESTLI_V2_VALUE_LIMITS.maximumNodes) {
|
|
480
|
+
throw new Error("Rest.li value exceeds the node limit");
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (value === null) return "null";
|
|
484
|
+
if (typeof value === "string") return boundedEncodedValue(encodedRestliString(value));
|
|
485
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
486
|
+
if (typeof value === "number") {
|
|
487
|
+
if (!Number.isSafeInteger(value)) throw new Error("Rest.li numbers must be safe integers");
|
|
488
|
+
return Object.is(value, -0) ? "0" : String(value);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (Array.isArray(value)) {
|
|
492
|
+
if (value.length > RESTLI_V2_VALUE_LIMITS.maximumListItems) {
|
|
493
|
+
throw new Error("Rest.li list exceeds the item limit");
|
|
494
|
+
}
|
|
495
|
+
if (state.active.has(value)) throw new Error("Rest.li value contains a cycle");
|
|
496
|
+
state.active.add(value);
|
|
497
|
+
try {
|
|
498
|
+
const items = value.map((item) => encodeRestliValue(item, depth + 1, state));
|
|
499
|
+
return boundedEncodedValue(`List(${items.join(",")})`);
|
|
500
|
+
} finally {
|
|
501
|
+
state.active.delete(value);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (typeof value !== "object" || value === null) {
|
|
506
|
+
throw new Error("Rest.li value contains an unsupported type");
|
|
507
|
+
}
|
|
508
|
+
const prototype: unknown = Object.getPrototypeOf(value);
|
|
509
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
510
|
+
throw new Error("Rest.li objects must be plain records");
|
|
511
|
+
}
|
|
512
|
+
if (state.active.has(value)) throw new Error("Rest.li value contains a cycle");
|
|
513
|
+
|
|
514
|
+
const keys = Reflect.ownKeys(value);
|
|
515
|
+
if (keys.length > RESTLI_V2_VALUE_LIMITS.maximumObjectFields) {
|
|
516
|
+
throw new Error("Rest.li object exceeds the field limit");
|
|
517
|
+
}
|
|
518
|
+
if (keys.some((key) => typeof key !== "string")) {
|
|
519
|
+
throw new Error("Rest.li object cannot contain symbol fields");
|
|
520
|
+
}
|
|
521
|
+
const stringKeys = keys as string[];
|
|
522
|
+
for (const key of stringKeys) {
|
|
523
|
+
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/u.test(key)) {
|
|
524
|
+
throw new Error("Rest.li object contains an invalid field name");
|
|
525
|
+
}
|
|
526
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
527
|
+
if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) {
|
|
528
|
+
throw new Error("Rest.li object fields must be enumerable data properties");
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
state.active.add(value);
|
|
533
|
+
try {
|
|
534
|
+
const fields = stringKeys.sort().map((key) => {
|
|
535
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
536
|
+
if (descriptor === undefined || !("value" in descriptor)) {
|
|
537
|
+
throw new Error("Rest.li object field changed during encoding");
|
|
538
|
+
}
|
|
539
|
+
return `${key}:${encodeRestliValue(descriptor.value, depth + 1, state)}`;
|
|
540
|
+
});
|
|
541
|
+
return boundedEncodedValue(`(${fields.join(",")})`);
|
|
542
|
+
} finally {
|
|
543
|
+
state.active.delete(value);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Encodes a bounded Rest.li protocol-2.0 value, not an entire query string. */
|
|
548
|
+
export function encodeRestliV2Value(value: unknown): string {
|
|
549
|
+
return encodeRestliValue(value, 0, { nodes: 0, active: new WeakSet<object>() });
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
export type LinkedInWebJsonRecord = Readonly<Record<string, unknown>>;
|
|
553
|
+
|
|
554
|
+
export type NormalizedLinkedInGraphql = {
|
|
555
|
+
readonly data: LinkedInWebJsonRecord;
|
|
556
|
+
readonly included: readonly LinkedInWebJsonRecord[];
|
|
557
|
+
readonly entitiesByUrn: ReadonlyMap<string, LinkedInWebJsonRecord>;
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
export type LinkedInMessagingParticipant = {
|
|
561
|
+
readonly urn: string;
|
|
562
|
+
readonly identityUrn: string | null;
|
|
563
|
+
readonly displayName: string | null;
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
export type LinkedInMessagingConversation = {
|
|
567
|
+
readonly id: string;
|
|
568
|
+
readonly urn: string;
|
|
569
|
+
readonly categories: readonly string[];
|
|
570
|
+
readonly groupChat: boolean;
|
|
571
|
+
readonly title: string | null;
|
|
572
|
+
readonly createdAt: number;
|
|
573
|
+
readonly lastActivityAt: number;
|
|
574
|
+
readonly lastReadAt: number | null;
|
|
575
|
+
readonly read: boolean;
|
|
576
|
+
readonly unreadCount: number;
|
|
577
|
+
readonly notificationStatus: string;
|
|
578
|
+
readonly participants: readonly LinkedInMessagingParticipant[];
|
|
579
|
+
readonly latestMessage: {
|
|
580
|
+
readonly id: string;
|
|
581
|
+
readonly urn: string;
|
|
582
|
+
readonly deliveredAt: number;
|
|
583
|
+
readonly body: string;
|
|
584
|
+
readonly subject: string | null;
|
|
585
|
+
readonly senderUrn: string | null;
|
|
586
|
+
} | null;
|
|
587
|
+
readonly url: string;
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
export type LinkedInMessagingList = {
|
|
591
|
+
readonly folder: LinkedInWebFolder;
|
|
592
|
+
readonly conversations: readonly LinkedInMessagingConversation[];
|
|
593
|
+
/** Compatibility name for LinkedIn's opaque provider sync token; not an executable page cursor. */
|
|
594
|
+
readonly nextCursor: string | null;
|
|
595
|
+
readonly continuationSupported: false;
|
|
596
|
+
readonly complete: boolean;
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
type JsonValidationState = {
|
|
600
|
+
nodes: number;
|
|
601
|
+
readonly active: WeakSet<object>;
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
function canonicalJson(value: unknown, depth: number, state: JsonValidationState): string {
|
|
605
|
+
if (depth > 64) throw new Error("LinkedIn GraphQL response exceeds the nesting-depth limit");
|
|
606
|
+
state.nodes += 1;
|
|
607
|
+
if (state.nodes > 100_000) throw new Error("LinkedIn GraphQL response exceeds the node limit");
|
|
608
|
+
|
|
609
|
+
if (value === null) return "null";
|
|
610
|
+
if (typeof value === "string" || typeof value === "boolean") return JSON.stringify(value);
|
|
611
|
+
if (typeof value === "number") {
|
|
612
|
+
if (!Number.isFinite(value)) throw new Error("LinkedIn GraphQL response contains a non-finite number");
|
|
613
|
+
return Object.is(value, -0) ? "0" : String(value);
|
|
614
|
+
}
|
|
615
|
+
if (typeof value !== "object" || value === null) {
|
|
616
|
+
throw new Error("LinkedIn GraphQL response contains a non-JSON value");
|
|
617
|
+
}
|
|
618
|
+
if (state.active.has(value)) throw new Error("LinkedIn GraphQL response contains a cycle");
|
|
619
|
+
state.active.add(value);
|
|
620
|
+
try {
|
|
621
|
+
if (Array.isArray(value)) {
|
|
622
|
+
return `[${value.map((item) => canonicalJson(item, depth + 1, state)).join(",")}]`;
|
|
623
|
+
}
|
|
624
|
+
const prototype: unknown = Object.getPrototypeOf(value);
|
|
625
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
626
|
+
throw new Error("LinkedIn GraphQL response contains a non-plain object");
|
|
627
|
+
}
|
|
628
|
+
const keys = Reflect.ownKeys(value);
|
|
629
|
+
if (keys.some((key) => typeof key !== "string")) {
|
|
630
|
+
throw new Error("LinkedIn GraphQL response contains symbol fields");
|
|
631
|
+
}
|
|
632
|
+
const stringKeys = keys as string[];
|
|
633
|
+
for (const key of stringKeys) {
|
|
634
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
635
|
+
if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) {
|
|
636
|
+
throw new Error("LinkedIn GraphQL response fields must be enumerable data properties");
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
return `{${stringKeys.sort().map((key) => {
|
|
640
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
641
|
+
if (descriptor === undefined || !("value" in descriptor)) {
|
|
642
|
+
throw new Error("LinkedIn GraphQL response changed during normalization");
|
|
643
|
+
}
|
|
644
|
+
return `${JSON.stringify(key)}:${canonicalJson(descriptor.value, depth + 1, state)}`;
|
|
645
|
+
}).join(",")}}`;
|
|
646
|
+
} finally {
|
|
647
|
+
state.active.delete(value);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function graphqlRecord(value: unknown, label: string): LinkedInWebJsonRecord {
|
|
652
|
+
if (!isRecord(value)) throw new Error(`LinkedIn GraphQL ${label} must be an object`);
|
|
653
|
+
return value;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function entityIdentityKeys(entity: LinkedInWebJsonRecord): readonly string[] {
|
|
657
|
+
const keys: string[] = [];
|
|
658
|
+
for (const field of ["entityUrn", "backendUrn", "urn"] as const) {
|
|
659
|
+
if (!hasOwn(entity, field)) continue;
|
|
660
|
+
const value = entity[field];
|
|
661
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 4_096) {
|
|
662
|
+
throw new Error(`LinkedIn GraphQL included entity has an invalid ${field}`);
|
|
663
|
+
}
|
|
664
|
+
keys.push(value);
|
|
665
|
+
}
|
|
666
|
+
if (keys.length === 0) throw new Error("LinkedIn GraphQL included entity has no canonical URN");
|
|
667
|
+
return [...new Set(keys)];
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** Normalizes LinkedIn's data + included envelope and indexes every entity URN alias. */
|
|
671
|
+
export function normalizeLinkedInGraphqlEnvelope(value: unknown): NormalizedLinkedInGraphql {
|
|
672
|
+
const envelope = graphqlRecord(value, "envelope");
|
|
673
|
+
if (hasOwn(envelope, "serviceErrorCode")) {
|
|
674
|
+
throw new Error("LinkedIn GraphQL response contains a service error");
|
|
675
|
+
}
|
|
676
|
+
if (hasOwn(envelope, "errors")) {
|
|
677
|
+
if (!Array.isArray(envelope.errors)) throw new Error("LinkedIn GraphQL errors must be an array");
|
|
678
|
+
if (envelope.errors.length > 0) throw new Error("LinkedIn GraphQL response contains provider errors");
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const data = graphqlRecord(envelope.data, "data");
|
|
682
|
+
canonicalJson(data, 0, { nodes: 0, active: new WeakSet<object>() });
|
|
683
|
+
|
|
684
|
+
const rawIncluded = envelope.included === undefined ? [] : envelope.included;
|
|
685
|
+
if (!Array.isArray(rawIncluded)) throw new Error("LinkedIn GraphQL included must be an array");
|
|
686
|
+
|
|
687
|
+
const included: LinkedInWebJsonRecord[] = [];
|
|
688
|
+
const entitiesByUrn = new Map<string, LinkedInWebJsonRecord>();
|
|
689
|
+
const canonicalByUrn = new Map<string, string>();
|
|
690
|
+
const primaryUrns = new Set<string>();
|
|
691
|
+
|
|
692
|
+
for (const rawEntity of rawIncluded) {
|
|
693
|
+
const entity = graphqlRecord(rawEntity, "included entity");
|
|
694
|
+
const canonical = canonicalJson(entity, 0, { nodes: 0, active: new WeakSet<object>() });
|
|
695
|
+
const urns = entityIdentityKeys(entity);
|
|
696
|
+
let duplicate = false;
|
|
697
|
+
for (const urn of urns) {
|
|
698
|
+
const prior = canonicalByUrn.get(urn);
|
|
699
|
+
if (prior !== undefined && prior !== canonical) {
|
|
700
|
+
throw new Error("LinkedIn GraphQL included entities conflict for one URN");
|
|
701
|
+
}
|
|
702
|
+
if (prior === canonical) duplicate = true;
|
|
703
|
+
}
|
|
704
|
+
for (const urn of urns) {
|
|
705
|
+
canonicalByUrn.set(urn, canonical);
|
|
706
|
+
if (!entitiesByUrn.has(urn)) entitiesByUrn.set(urn, entity);
|
|
707
|
+
}
|
|
708
|
+
const primaryUrn = urns[0];
|
|
709
|
+
if (primaryUrn === undefined) throw new Error("LinkedIn GraphQL included entity identity disappeared");
|
|
710
|
+
if (!duplicate && !primaryUrns.has(primaryUrn)) {
|
|
711
|
+
included.push(entity);
|
|
712
|
+
primaryUrns.add(primaryUrn);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
return {
|
|
717
|
+
data,
|
|
718
|
+
included: Object.freeze(included),
|
|
719
|
+
entitiesByUrn,
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function boundedText(value: unknown, label: string, maximum: number): string {
|
|
724
|
+
if (
|
|
725
|
+
typeof value !== "string"
|
|
726
|
+
|| value.length < 1
|
|
727
|
+
|| value.length > maximum
|
|
728
|
+
|| /[\0\r]/u.test(value)
|
|
729
|
+
) throw new Error(`${label} must be a bounded string`);
|
|
730
|
+
return value;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function optionalText(value: unknown, label: string, maximum: number): string | null {
|
|
734
|
+
if (value === null || value === undefined) return null;
|
|
735
|
+
return boundedText(value, label, maximum);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function nonnegativeInteger(value: unknown, label: string): number {
|
|
739
|
+
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
|
740
|
+
throw new Error(`${label} must be a non-negative safe integer`);
|
|
741
|
+
}
|
|
742
|
+
return value as number;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function optionalNonnegativeInteger(value: unknown, label: string): number | null {
|
|
746
|
+
if (value === null || value === undefined) return null;
|
|
747
|
+
return nonnegativeInteger(value, label);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function linkedInUrn(value: unknown, label: string, maximum = 4_096): string {
|
|
751
|
+
const urn = boundedText(value, label, maximum);
|
|
752
|
+
if (!/^urn:li:[A-Za-z][A-Za-z0-9_]*:.+$/u.test(urn)) {
|
|
753
|
+
throw new Error(`${label} must be an exact LinkedIn URN`);
|
|
754
|
+
}
|
|
755
|
+
return urn;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
type LinkedInExactCountMetric = {
|
|
759
|
+
readonly status: "available";
|
|
760
|
+
readonly value: number;
|
|
761
|
+
readonly precision: "exact";
|
|
762
|
+
readonly unit: "count";
|
|
763
|
+
};
|
|
764
|
+
|
|
765
|
+
type LinkedInUnavailableCountMetric = {
|
|
766
|
+
readonly status: "unavailable";
|
|
767
|
+
readonly reason: "not-authorized";
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
export type LinkedInPersonalProfileStats = {
|
|
771
|
+
readonly schemaVersion: 1;
|
|
772
|
+
readonly provider: "linkedin";
|
|
773
|
+
readonly target: {
|
|
774
|
+
readonly kind: "profile";
|
|
775
|
+
readonly id: string;
|
|
776
|
+
readonly url: string;
|
|
777
|
+
};
|
|
778
|
+
readonly observedAt: string;
|
|
779
|
+
readonly completeness: "complete" | "partial";
|
|
780
|
+
readonly metrics: {
|
|
781
|
+
readonly followers: LinkedInExactCountMetric;
|
|
782
|
+
readonly connections: LinkedInExactCountMetric | LinkedInUnavailableCountMetric;
|
|
783
|
+
};
|
|
784
|
+
readonly metadata: {
|
|
785
|
+
readonly profileSlug: string;
|
|
786
|
+
};
|
|
787
|
+
};
|
|
788
|
+
|
|
789
|
+
export type LinkedInOrganizationStats = {
|
|
790
|
+
readonly schemaVersion: 1;
|
|
791
|
+
readonly provider: "linkedin";
|
|
792
|
+
readonly target: {
|
|
793
|
+
readonly kind: "organization";
|
|
794
|
+
readonly id: string;
|
|
795
|
+
readonly url: string;
|
|
796
|
+
};
|
|
797
|
+
readonly observedAt: string;
|
|
798
|
+
readonly completeness: "complete";
|
|
799
|
+
readonly metrics: {
|
|
800
|
+
readonly followers: LinkedInExactCountMetric;
|
|
801
|
+
};
|
|
802
|
+
readonly metadata: {
|
|
803
|
+
readonly universalName: string;
|
|
804
|
+
readonly name: string;
|
|
805
|
+
readonly description: string | null;
|
|
806
|
+
readonly websiteUrl: string | null;
|
|
807
|
+
};
|
|
808
|
+
};
|
|
809
|
+
|
|
810
|
+
export type LinkedInProfileTarget = {
|
|
811
|
+
readonly slug: string;
|
|
812
|
+
readonly url: string;
|
|
813
|
+
};
|
|
814
|
+
|
|
815
|
+
export function linkedInPersonalProfilePublicIdentifier(
|
|
816
|
+
value: unknown,
|
|
817
|
+
): string {
|
|
818
|
+
const identifier = boundedText(
|
|
819
|
+
value,
|
|
820
|
+
"LinkedIn personal profile public identifier",
|
|
821
|
+
100,
|
|
822
|
+
);
|
|
823
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{1,99}$/u.test(identifier)) {
|
|
824
|
+
throw new Error(
|
|
825
|
+
"LinkedIn personal profile public identifier has an unsupported format",
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
return identifier.toLowerCase();
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function linkedInTarget(
|
|
832
|
+
value: unknown,
|
|
833
|
+
kind: "in" | "company",
|
|
834
|
+
): LinkedInProfileTarget {
|
|
835
|
+
const label = kind === "in" ? "personal profile" : "organization";
|
|
836
|
+
const raw = boundedText(value, `LinkedIn ${label} URL`, 512);
|
|
837
|
+
let url: URL;
|
|
838
|
+
try {
|
|
839
|
+
url = new URL(raw);
|
|
840
|
+
} catch {
|
|
841
|
+
throw new Error(`LinkedIn ${label} URL must be an absolute URL`);
|
|
842
|
+
}
|
|
843
|
+
if (
|
|
844
|
+
url.origin !== "https://www.linkedin.com"
|
|
845
|
+
|| url.username !== ""
|
|
846
|
+
|| url.password !== ""
|
|
847
|
+
|| url.search !== ""
|
|
848
|
+
|| url.hash !== ""
|
|
849
|
+
) throw new Error(`LinkedIn ${label} URL must use the exact public LinkedIn origin`);
|
|
850
|
+
const match = new RegExp(`^/${kind}/([A-Za-z0-9][A-Za-z0-9_-]{1,99})/?$`, "u")
|
|
851
|
+
.exec(url.pathname);
|
|
852
|
+
if (match?.[1] === undefined) {
|
|
853
|
+
throw new Error(`LinkedIn ${label} URL has an unsupported path`);
|
|
854
|
+
}
|
|
855
|
+
const slug = kind === "in"
|
|
856
|
+
? linkedInPersonalProfilePublicIdentifier(match[1])
|
|
857
|
+
: match[1].toLowerCase();
|
|
858
|
+
return Object.freeze({
|
|
859
|
+
slug,
|
|
860
|
+
url: `https://www.linkedin.com/${kind}/${slug}/`,
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
export function linkedInPersonalProfileTarget(value: unknown): LinkedInProfileTarget {
|
|
865
|
+
return linkedInTarget(value, "in");
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
export function linkedInOrganizationTarget(value: unknown): LinkedInProfileTarget {
|
|
869
|
+
return linkedInTarget(value, "company");
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function linkedInObservationTime(value: string): string {
|
|
873
|
+
if (
|
|
874
|
+
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u.test(value)
|
|
875
|
+
|| !Number.isFinite(Date.parse(value))
|
|
876
|
+
) throw new Error("LinkedIn profile observedAt must be an exact UTC timestamp");
|
|
877
|
+
return value;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function linkedInExactCountMetric(value: unknown, label: string): LinkedInExactCountMetric {
|
|
881
|
+
return Object.freeze({
|
|
882
|
+
status: "available",
|
|
883
|
+
value: nonnegativeInteger(value, label),
|
|
884
|
+
precision: "exact",
|
|
885
|
+
unit: "count",
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function decodeLinkedInProfileEntity(entity: string): string {
|
|
890
|
+
if (entity === " ") return " ";
|
|
891
|
+
return decodeLinkedInArticleEntity(entity);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function linkedInVisibleText(value: string): string {
|
|
895
|
+
const withoutScripts = value
|
|
896
|
+
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/giu, " ")
|
|
897
|
+
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/giu, " ");
|
|
898
|
+
return withoutScripts
|
|
899
|
+
.replace(/<[^>]+>/gu, " ")
|
|
900
|
+
.replace(
|
|
901
|
+
/&(?:nbsp|quot|amp|lt|gt|apos|#(?:[xX][0-9A-Fa-f]{1,6}|[0-9]{1,7}));/gu,
|
|
902
|
+
(entity) => decodeLinkedInProfileEntity(entity),
|
|
903
|
+
)
|
|
904
|
+
.replace(/\s+/gu, " ")
|
|
905
|
+
.trim();
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function exactLinkedInLabelCount(value: string, noun: "followers" | "connections"): number {
|
|
909
|
+
const match = new RegExp(`^(0|[1-9][0-9]{0,2}(?:,[0-9]{3})*) ${noun}$`, "u").exec(value);
|
|
910
|
+
if (match?.[1] === undefined) {
|
|
911
|
+
throw new Error(`LinkedIn ${noun} label was not an exact count`);
|
|
912
|
+
}
|
|
913
|
+
const count = Number(match[1].replaceAll(",", ""));
|
|
914
|
+
return nonnegativeInteger(count, `LinkedIn ${noun} count`);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function linkedInHtmlAttribute(value: string, name: string): string | null {
|
|
918
|
+
const matches = [...value.matchAll(
|
|
919
|
+
new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "giu"),
|
|
920
|
+
)];
|
|
921
|
+
if (matches.length === 0) return null;
|
|
922
|
+
if (matches.length !== 1) throw new Error(`LinkedIn HTML repeated ${name}`);
|
|
923
|
+
const raw = matches[0]?.[1] ?? matches[0]?.[2];
|
|
924
|
+
if (raw === undefined || raw.length > 4_096) {
|
|
925
|
+
throw new Error(`LinkedIn HTML ${name} exceeded its reviewed bound`);
|
|
926
|
+
}
|
|
927
|
+
return raw.replace(
|
|
928
|
+
/&(?:quot|amp|lt|gt|apos|#(?:[xX][0-9A-Fa-f]{1,6}|[0-9]{1,7}));/gu,
|
|
929
|
+
(entity) => decodeLinkedInProfileEntity(entity),
|
|
930
|
+
);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function linkedInSelfFollowerCount(html: unknown): number {
|
|
934
|
+
if (typeof html !== "string" || html.length < 1 || html.length > 8 * 1024 * 1024) {
|
|
935
|
+
throw new Error("LinkedIn personal profile exceeded its reviewed HTML bound");
|
|
936
|
+
}
|
|
937
|
+
const counts: number[] = [];
|
|
938
|
+
let anchors = 0;
|
|
939
|
+
for (const match of html.matchAll(/<a\b([^>]{0,4096})>([\s\S]{0,16384}?)<\/a>/giu)) {
|
|
940
|
+
anchors += 1;
|
|
941
|
+
if (anchors > 10_000) throw new Error("LinkedIn personal profile returned too many anchors");
|
|
942
|
+
const attributes = match[1];
|
|
943
|
+
const body = match[2];
|
|
944
|
+
if (attributes === undefined || body === undefined) continue;
|
|
945
|
+
const href = linkedInHtmlAttribute(attributes, "href");
|
|
946
|
+
if (href === null) continue;
|
|
947
|
+
let url: URL;
|
|
948
|
+
try {
|
|
949
|
+
url = new URL(href, "https://www.linkedin.com");
|
|
950
|
+
} catch {
|
|
951
|
+
continue;
|
|
952
|
+
}
|
|
953
|
+
if (
|
|
954
|
+
url.origin !== "https://www.linkedin.com"
|
|
955
|
+
|| url.pathname !== "/mynetwork/network-manager/people-follow/followers"
|
|
956
|
+
|| url.search !== ""
|
|
957
|
+
|| url.hash !== ""
|
|
958
|
+
) continue;
|
|
959
|
+
counts.push(exactLinkedInLabelCount(linkedInVisibleText(body), "followers"));
|
|
960
|
+
}
|
|
961
|
+
if (counts.length !== 1) {
|
|
962
|
+
throw new Error("LinkedIn personal profile did not bind one exact self follower count");
|
|
963
|
+
}
|
|
964
|
+
return counts[0]!;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function linkedInConnectionsCount(html: unknown): number {
|
|
968
|
+
if (typeof html !== "string" || html.length < 1 || html.length > 8 * 1024 * 1024) {
|
|
969
|
+
throw new Error("LinkedIn connections page exceeded its reviewed HTML bound");
|
|
970
|
+
}
|
|
971
|
+
const text = linkedInVisibleText(html);
|
|
972
|
+
if (!text.includes("Sort by:") || !text.includes("Search with filters")) {
|
|
973
|
+
throw new Error("LinkedIn connections page omitted its exact list controls");
|
|
974
|
+
}
|
|
975
|
+
const labels = [...text.matchAll(/\b(0|[1-9][0-9]{0,2}(?:,[0-9]{3})*) connections\b/gu)]
|
|
976
|
+
.map((match) => match[0]);
|
|
977
|
+
if (labels.length !== 1 || labels[0] === undefined) {
|
|
978
|
+
throw new Error("LinkedIn connections page did not expose one exact total");
|
|
979
|
+
}
|
|
980
|
+
return exactLinkedInLabelCount(labels[0], "connections");
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
export function projectLinkedInPersonalProfileStats(input: {
|
|
984
|
+
readonly profileHtml: unknown;
|
|
985
|
+
readonly connectionsHtml: unknown | null;
|
|
986
|
+
readonly profileUrl: unknown;
|
|
987
|
+
readonly expectedSubject: unknown;
|
|
988
|
+
readonly expectedPublicIdentifier: unknown;
|
|
989
|
+
readonly observedAt: string;
|
|
990
|
+
}): LinkedInPersonalProfileStats {
|
|
991
|
+
const target = linkedInPersonalProfileTarget(input.profileUrl);
|
|
992
|
+
const subject = boundedText(input.expectedSubject, "LinkedIn personal profile subject", 512);
|
|
993
|
+
if (!/^urn:li:fsd_profile:[0-9]{1,32}$/u.test(subject)) {
|
|
994
|
+
throw new Error("LinkedIn personal profile subject has an unsupported format");
|
|
995
|
+
}
|
|
996
|
+
const publicIdentifier = linkedInPersonalProfilePublicIdentifier(
|
|
997
|
+
input.expectedPublicIdentifier,
|
|
998
|
+
);
|
|
999
|
+
if (target.slug !== publicIdentifier) {
|
|
1000
|
+
throw new Error(
|
|
1001
|
+
"LinkedIn personal profile URL does not match the bound current member public identifier",
|
|
1002
|
+
);
|
|
1003
|
+
}
|
|
1004
|
+
const connections = input.connectionsHtml === null
|
|
1005
|
+
? Object.freeze({ status: "unavailable" as const, reason: "not-authorized" as const })
|
|
1006
|
+
: linkedInExactCountMetric(
|
|
1007
|
+
linkedInConnectionsCount(input.connectionsHtml),
|
|
1008
|
+
"LinkedIn connections",
|
|
1009
|
+
);
|
|
1010
|
+
return Object.freeze({
|
|
1011
|
+
schemaVersion: 1,
|
|
1012
|
+
provider: "linkedin",
|
|
1013
|
+
target: Object.freeze({ kind: "profile", id: subject, url: target.url }),
|
|
1014
|
+
observedAt: linkedInObservationTime(input.observedAt),
|
|
1015
|
+
completeness: input.connectionsHtml === null ? "partial" : "complete",
|
|
1016
|
+
metrics: Object.freeze({
|
|
1017
|
+
followers: linkedInExactCountMetric(
|
|
1018
|
+
linkedInSelfFollowerCount(input.profileHtml),
|
|
1019
|
+
"LinkedIn followers",
|
|
1020
|
+
),
|
|
1021
|
+
connections,
|
|
1022
|
+
}),
|
|
1023
|
+
metadata: Object.freeze({ profileSlug: target.slug }),
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
type LinkedInEmbeddedRecord = Readonly<Record<string, unknown>>;
|
|
1028
|
+
|
|
1029
|
+
function linkedInEmbeddedRecords(html: unknown): readonly LinkedInEmbeddedRecord[] {
|
|
1030
|
+
if (typeof html !== "string" || html.length < 1 || html.length > 8 * 1024 * 1024) {
|
|
1031
|
+
throw new Error("LinkedIn organization page exceeded its reviewed HTML bound");
|
|
1032
|
+
}
|
|
1033
|
+
const roots: unknown[] = [];
|
|
1034
|
+
let codeTags = 0;
|
|
1035
|
+
for (const match of html.matchAll(/<code\b([^>]{0,4096})>([\s\S]*?)<\/code>/giu)) {
|
|
1036
|
+
codeTags += 1;
|
|
1037
|
+
if (codeTags > 256) throw new Error("LinkedIn organization page returned too many code payloads");
|
|
1038
|
+
const attributes = match[1];
|
|
1039
|
+
const body = match[2];
|
|
1040
|
+
if (attributes === undefined || body === undefined) continue;
|
|
1041
|
+
const id = linkedInHtmlAttribute(attributes, "id");
|
|
1042
|
+
if (id === null || !/^bpr-guid-[0-9]{1,12}$/u.test(id)) continue;
|
|
1043
|
+
linkedInArticleCodeAttributes(attributes);
|
|
1044
|
+
if (body.length < 1 || body.length > 1024 * 1024) {
|
|
1045
|
+
throw new Error("LinkedIn organization bootstrap payload exceeded its reviewed bound");
|
|
1046
|
+
}
|
|
1047
|
+
const json = body.replace(
|
|
1048
|
+
LINKEDIN_ARTICLE_HTML_ENTITY,
|
|
1049
|
+
(entity) => decodeLinkedInArticleEntity(entity),
|
|
1050
|
+
).trim();
|
|
1051
|
+
try {
|
|
1052
|
+
roots.push(JSON.parse(json) as unknown);
|
|
1053
|
+
} catch {
|
|
1054
|
+
throw new Error("LinkedIn organization bootstrap payload contained malformed JSON");
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
if (roots.length < 1) throw new Error("LinkedIn organization page omitted its bootstrap payloads");
|
|
1058
|
+
const records: LinkedInEmbeddedRecord[] = [];
|
|
1059
|
+
const stack = roots.map((value) => ({ value, depth: 0 }));
|
|
1060
|
+
let nodes = 0;
|
|
1061
|
+
while (stack.length > 0) {
|
|
1062
|
+
const next = stack.pop()!;
|
|
1063
|
+
nodes += 1;
|
|
1064
|
+
if (nodes > 500_000 || next.depth > 32) {
|
|
1065
|
+
throw new Error("LinkedIn organization bootstrap exceeded its traversal bound");
|
|
1066
|
+
}
|
|
1067
|
+
if (Array.isArray(next.value)) {
|
|
1068
|
+
if (next.value.length > 20_000) {
|
|
1069
|
+
throw new Error("LinkedIn organization bootstrap array exceeded its reviewed bound");
|
|
1070
|
+
}
|
|
1071
|
+
for (const value of next.value) stack.push({ value, depth: next.depth + 1 });
|
|
1072
|
+
continue;
|
|
1073
|
+
}
|
|
1074
|
+
if (!isRecord(next.value)) continue;
|
|
1075
|
+
records.push(next.value);
|
|
1076
|
+
for (const value of Object.values(next.value)) {
|
|
1077
|
+
stack.push({ value, depth: next.depth + 1 });
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
return Object.freeze(records);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
function oneUniqueLinkedInText(
|
|
1084
|
+
values: readonly unknown[],
|
|
1085
|
+
label: string,
|
|
1086
|
+
maximum: number,
|
|
1087
|
+
required: boolean,
|
|
1088
|
+
): string | null {
|
|
1089
|
+
const normalized = values
|
|
1090
|
+
.filter((value) => value !== undefined && value !== null && value !== "")
|
|
1091
|
+
.map((value) => boundedText(value, label, maximum));
|
|
1092
|
+
const unique = [...new Set(normalized)];
|
|
1093
|
+
if (unique.length === 0 && !required) return null;
|
|
1094
|
+
if (unique.length !== 1) throw new Error(`${label} was missing or ambiguous`);
|
|
1095
|
+
return unique[0]!;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function safeLinkedInPublicUrl(value: string | null, label: string): string | null {
|
|
1099
|
+
if (value === null) return null;
|
|
1100
|
+
let url: URL;
|
|
1101
|
+
try {
|
|
1102
|
+
url = new URL(value);
|
|
1103
|
+
} catch {
|
|
1104
|
+
throw new Error(`${label} must be an absolute URL`);
|
|
1105
|
+
}
|
|
1106
|
+
if (
|
|
1107
|
+
(url.protocol !== "https:" && url.protocol !== "http:")
|
|
1108
|
+
|| url.username !== ""
|
|
1109
|
+
|| url.password !== ""
|
|
1110
|
+
) throw new Error(`${label} must be a safe public HTTP URL`);
|
|
1111
|
+
return url.href;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
export function projectLinkedInOrganizationStats(input: {
|
|
1115
|
+
readonly html: unknown;
|
|
1116
|
+
readonly organizationUrl: unknown;
|
|
1117
|
+
readonly observedAt: string;
|
|
1118
|
+
}): LinkedInOrganizationStats {
|
|
1119
|
+
const target = linkedInOrganizationTarget(input.organizationUrl);
|
|
1120
|
+
const records = linkedInEmbeddedRecords(input.html);
|
|
1121
|
+
const companies = records.filter((record) =>
|
|
1122
|
+
record.$type === "com.linkedin.voyager.dash.organization.Company"
|
|
1123
|
+
&& record.universalName === target.slug);
|
|
1124
|
+
if (companies.length < 1) {
|
|
1125
|
+
throw new Error("LinkedIn organization response did not bind the requested universal name");
|
|
1126
|
+
}
|
|
1127
|
+
const companyIds = new Set(companies.map((company) => {
|
|
1128
|
+
const id = boundedText(company.entityUrn, "LinkedIn organization entity URN", 512);
|
|
1129
|
+
if (!/^urn:li:fsd_company:[0-9]{1,32}$/u.test(id)) {
|
|
1130
|
+
throw new Error("LinkedIn organization entity URN changed format");
|
|
1131
|
+
}
|
|
1132
|
+
return id;
|
|
1133
|
+
}));
|
|
1134
|
+
const followingRefs = new Set(companies.map((company) => {
|
|
1135
|
+
const ref = boundedText(company["*followingState"], "LinkedIn organization following-state reference", 1_024);
|
|
1136
|
+
if (!ref.startsWith("urn:li:fsd_followingState:")) {
|
|
1137
|
+
throw new Error("LinkedIn organization following-state reference changed format");
|
|
1138
|
+
}
|
|
1139
|
+
return ref;
|
|
1140
|
+
}));
|
|
1141
|
+
if (companyIds.size !== 1 || followingRefs.size !== 1) {
|
|
1142
|
+
throw new Error("LinkedIn organization response exposed ambiguous target identities");
|
|
1143
|
+
}
|
|
1144
|
+
const followingRef = followingRefs.values().next().value!;
|
|
1145
|
+
const states = records.filter((record) =>
|
|
1146
|
+
record.$type === "com.linkedin.voyager.dash.feed.FollowingState"
|
|
1147
|
+
&& record.entityUrn === followingRef);
|
|
1148
|
+
const followerCounts = new Set(states.map((state) =>
|
|
1149
|
+
nonnegativeInteger(state.followerCount, "LinkedIn organization followerCount")));
|
|
1150
|
+
if (followerCounts.size !== 1) {
|
|
1151
|
+
throw new Error("LinkedIn organization response omitted or contradicted its exact follower count");
|
|
1152
|
+
}
|
|
1153
|
+
const name = oneUniqueLinkedInText(
|
|
1154
|
+
companies.map((company) => company.name),
|
|
1155
|
+
"LinkedIn organization name",
|
|
1156
|
+
1_000,
|
|
1157
|
+
true,
|
|
1158
|
+
);
|
|
1159
|
+
if (name === null) throw new Error("LinkedIn organization name is unavailable");
|
|
1160
|
+
const description = oneUniqueLinkedInText(
|
|
1161
|
+
companies.map((company) => company.description),
|
|
1162
|
+
"LinkedIn organization description",
|
|
1163
|
+
20_000,
|
|
1164
|
+
false,
|
|
1165
|
+
);
|
|
1166
|
+
const websiteUrl = safeLinkedInPublicUrl(oneUniqueLinkedInText(
|
|
1167
|
+
companies.map((company) => company.websiteUrl),
|
|
1168
|
+
"LinkedIn organization website URL",
|
|
1169
|
+
2_048,
|
|
1170
|
+
false,
|
|
1171
|
+
), "LinkedIn organization website URL");
|
|
1172
|
+
return Object.freeze({
|
|
1173
|
+
schemaVersion: 1,
|
|
1174
|
+
provider: "linkedin",
|
|
1175
|
+
target: Object.freeze({
|
|
1176
|
+
kind: "organization",
|
|
1177
|
+
id: companyIds.values().next().value!,
|
|
1178
|
+
url: target.url,
|
|
1179
|
+
}),
|
|
1180
|
+
observedAt: linkedInObservationTime(input.observedAt),
|
|
1181
|
+
completeness: "complete",
|
|
1182
|
+
metrics: Object.freeze({
|
|
1183
|
+
followers: linkedInExactCountMetric(
|
|
1184
|
+
followerCounts.values().next().value!,
|
|
1185
|
+
"LinkedIn organization followers",
|
|
1186
|
+
),
|
|
1187
|
+
}),
|
|
1188
|
+
metadata: Object.freeze({
|
|
1189
|
+
universalName: target.slug,
|
|
1190
|
+
name,
|
|
1191
|
+
description,
|
|
1192
|
+
websiteUrl,
|
|
1193
|
+
}),
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
export function linkedInMailboxUrnFromMiniProfile(value: unknown): string {
|
|
1198
|
+
const miniProfile = boundedText(value, "LinkedIn normalized mini-profile URN", 512);
|
|
1199
|
+
const suffix = /^urn:li:fs_miniProfile:([A-Za-z0-9_-]{1,256})$/u.exec(miniProfile)?.[1];
|
|
1200
|
+
if (suffix === undefined) throw new Error("LinkedIn normalized mini-profile URN is invalid");
|
|
1201
|
+
return `urn:li:fsd_profile:${suffix}`;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
export const LINKEDIN_FIRST_PARTY_ARTICLES_PATH =
|
|
1205
|
+
"/voyager/api/voyagerPublishingDashFirstPartyArticles";
|
|
1206
|
+
export const LINKEDIN_ARTICLE_PAGE_MAX_CHARACTERS = 2 * 1024 * 1024;
|
|
1207
|
+
|
|
1208
|
+
const LINKEDIN_ARTICLE_CODE_PAYLOAD_MAX_CHARACTERS = 1024 * 1024;
|
|
1209
|
+
const LINKEDIN_ARTICLE_CODE_TAG_MAX_COUNT = 5_000;
|
|
1210
|
+
const LINKEDIN_ARTICLE_MATCHING_PAYLOAD_MAX_COUNT = 20;
|
|
1211
|
+
|
|
1212
|
+
const LINKEDIN_ARTICLE_TYPE = "com.linkedin.voyager.dash.publishing.FirstPartyArticle";
|
|
1213
|
+
const LINKEDIN_ARTICLE_COLLECTION_TYPE = "com.linkedin.restli.common.CollectionResponse";
|
|
1214
|
+
const LINKEDIN_TEXT_BLOCK_TYPE = "com.linkedin.voyager.dash.publishing.TextBlock";
|
|
1215
|
+
const LINKEDIN_TEXT_VIEW_MODEL_TYPE = "com.linkedin.voyager.dash.common.text.TextViewModel";
|
|
1216
|
+
const LINKEDIN_TEXT_ATTRIBUTE_TYPE = "com.linkedin.voyager.dash.common.text.TextAttribute";
|
|
1217
|
+
const LINKEDIN_IMAGE_BLOCK_TYPE = "com.linkedin.voyager.dash.publishing.ImageBlock";
|
|
1218
|
+
const LINKEDIN_IMAGE_VIEW_MODEL_TYPE = "com.linkedin.voyager.dash.common.image.ImageViewModel";
|
|
1219
|
+
const LINKEDIN_IMAGE_ATTRIBUTE_TYPE = "com.linkedin.voyager.dash.common.image.ImageAttribute";
|
|
1220
|
+
const LINKEDIN_VECTOR_IMAGE_TYPE = "com.linkedin.common.VectorImage";
|
|
1221
|
+
const LINKEDIN_VECTOR_ARTIFACT_TYPE = "com.linkedin.common.VectorArtifact";
|
|
1222
|
+
const LINKEDIN_COVER_IMAGE_TYPE = "com.linkedin.voyager.dash.publishing.CoverImage";
|
|
1223
|
+
|
|
1224
|
+
export type LinkedInArticleDraftReadback = {
|
|
1225
|
+
readonly draftId: string;
|
|
1226
|
+
readonly title: string;
|
|
1227
|
+
readonly document: ArticleDraftDocument;
|
|
1228
|
+
readonly profileUrn: string;
|
|
1229
|
+
};
|
|
1230
|
+
|
|
1231
|
+
export type LinkedInArticleDraftSnapshot = Omit<LinkedInArticleDraftReadback, "document"> & {
|
|
1232
|
+
readonly document: ArticleDraftDocument | null;
|
|
1233
|
+
};
|
|
1234
|
+
|
|
1235
|
+
export type LinkedInArticleDraftV2Readback = Omit<LinkedInArticleDraftReadback, "document"> & {
|
|
1236
|
+
readonly document: ArticleDraftDocumentV2;
|
|
1237
|
+
readonly coverAssetUrn: string | null;
|
|
1238
|
+
readonly imageAssetUrns: readonly string[];
|
|
1239
|
+
};
|
|
1240
|
+
|
|
1241
|
+
export type LinkedInArticleDraftV2Snapshot = Omit<LinkedInArticleDraftV2Readback, "document"> & {
|
|
1242
|
+
readonly document: ArticleDraftDocumentV2 | null;
|
|
1243
|
+
};
|
|
1244
|
+
|
|
1245
|
+
function exactObjectKeys(
|
|
1246
|
+
value: Readonly<Record<string, unknown>>,
|
|
1247
|
+
expected: readonly string[],
|
|
1248
|
+
label: string,
|
|
1249
|
+
): void {
|
|
1250
|
+
if (Object.keys(value).sort().join(",") !== [...expected].sort().join(",")) {
|
|
1251
|
+
throw new Error(`${label} has unsupported fields`);
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
function linkedInArticleImageKnownFieldMask(
|
|
1256
|
+
value: unknown,
|
|
1257
|
+
knownFields: readonly string[],
|
|
1258
|
+
): string {
|
|
1259
|
+
if (!isRecord(value)) return "n";
|
|
1260
|
+
let mask = 0;
|
|
1261
|
+
let unknown = 0;
|
|
1262
|
+
for (const key of Object.keys(value)) {
|
|
1263
|
+
const index = knownFields.indexOf(key);
|
|
1264
|
+
if (index === -1) unknown += 1;
|
|
1265
|
+
else mask |= 1 << index;
|
|
1266
|
+
}
|
|
1267
|
+
return `${mask.toString(16)}u${Math.min(unknown, 99)}`;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
function linkedInArticleImagePollingUrlClass(
|
|
1271
|
+
registration: Readonly<Record<string, unknown>> | null,
|
|
1272
|
+
): string {
|
|
1273
|
+
if (registration === null || !Object.hasOwn(registration, "pollingUrl")) return "a";
|
|
1274
|
+
const value = registration.pollingUrl;
|
|
1275
|
+
if (value === null) return "n";
|
|
1276
|
+
if (typeof value !== "string") return "o";
|
|
1277
|
+
if (value.length > 64 * 1_024) return "b";
|
|
1278
|
+
let url: URL;
|
|
1279
|
+
try {
|
|
1280
|
+
url = new URL(value);
|
|
1281
|
+
} catch {
|
|
1282
|
+
return "m";
|
|
1283
|
+
}
|
|
1284
|
+
if (
|
|
1285
|
+
url.username !== ""
|
|
1286
|
+
|| url.password !== ""
|
|
1287
|
+
|| url.hash !== ""
|
|
1288
|
+
|| url.href !== value
|
|
1289
|
+
) return "m";
|
|
1290
|
+
if (
|
|
1291
|
+
url.origin === "https://www.linkedin.com"
|
|
1292
|
+
&& url.pathname.startsWith("/voyager/api/")
|
|
1293
|
+
) return "v";
|
|
1294
|
+
if (url.origin === "https://www.linkedin.com") return "l";
|
|
1295
|
+
if (url.protocol === "https:") return "h";
|
|
1296
|
+
return "x";
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
/**
|
|
1300
|
+
* Produce a value-free bounded structural discriminator for reviewed live
|
|
1301
|
+
* contract work. It exposes no response values, URLs, IDs, or unknown names.
|
|
1302
|
+
*/
|
|
1303
|
+
export function linkedInArticleImageRegistrationDriftCategory(
|
|
1304
|
+
value: unknown,
|
|
1305
|
+
error: unknown,
|
|
1306
|
+
): string {
|
|
1307
|
+
const envelope = isRecord(value) ? value : null;
|
|
1308
|
+
const data = envelope !== null && isRecord(envelope.data)
|
|
1309
|
+
? envelope.data
|
|
1310
|
+
: envelope;
|
|
1311
|
+
const registration = data !== null && isRecord(data.value)
|
|
1312
|
+
? data.value
|
|
1313
|
+
: null;
|
|
1314
|
+
const headers = registration !== null && isRecord(registration.singleUploadHeaders)
|
|
1315
|
+
? registration.singleUploadHeaders
|
|
1316
|
+
: null;
|
|
1317
|
+
const message = error instanceof Error ? error.message : "";
|
|
1318
|
+
const step = message.includes("response.data.value has unsupported fields")
|
|
1319
|
+
? "registration-fields"
|
|
1320
|
+
: message.includes("response.data has unsupported fields")
|
|
1321
|
+
? "data-fields"
|
|
1322
|
+
: message.includes("response has unsupported fields")
|
|
1323
|
+
? "envelope-fields"
|
|
1324
|
+
: message.includes("included entities")
|
|
1325
|
+
? "included"
|
|
1326
|
+
: message.includes("response type")
|
|
1327
|
+
? "response-type"
|
|
1328
|
+
: message.includes("media type")
|
|
1329
|
+
? "media-type"
|
|
1330
|
+
: message.includes("assetRealtimeTopic")
|
|
1331
|
+
? "realtime-topic"
|
|
1332
|
+
: message.includes("mediaArtifactUrn")
|
|
1333
|
+
? "artifact-urn"
|
|
1334
|
+
: message.includes(" registration urn")
|
|
1335
|
+
? "asset-urn"
|
|
1336
|
+
: message.includes("recipes")
|
|
1337
|
+
? "recipes"
|
|
1338
|
+
: message.includes("upload headers")
|
|
1339
|
+
? "upload-headers"
|
|
1340
|
+
: message.includes("polling URL")
|
|
1341
|
+
? "polling-url"
|
|
1342
|
+
: message.includes("upload URL")
|
|
1343
|
+
? "upload-url"
|
|
1344
|
+
: "other";
|
|
1345
|
+
const mediaType = registration?.type === "VECTOR"
|
|
1346
|
+
? "v"
|
|
1347
|
+
: registration?.type === "SINGLE"
|
|
1348
|
+
? "s"
|
|
1349
|
+
: registration?.type === "MULTIPART_FORMDATA"
|
|
1350
|
+
? "f"
|
|
1351
|
+
: registration?.type === "MULTIPART"
|
|
1352
|
+
? "m"
|
|
1353
|
+
: "o";
|
|
1354
|
+
return [
|
|
1355
|
+
step,
|
|
1356
|
+
`e${linkedInArticleImageKnownFieldMask(envelope, ["data", "included", "value"])}`,
|
|
1357
|
+
`d${linkedInArticleImageKnownFieldMask(data, ["$type", "value"])}`,
|
|
1358
|
+
`r${linkedInArticleImageKnownFieldMask(registration, [
|
|
1359
|
+
"$type",
|
|
1360
|
+
"assetRealtimeTopic",
|
|
1361
|
+
"mediaArtifactUrn",
|
|
1362
|
+
"multipartMetadata",
|
|
1363
|
+
"partUploadRequests",
|
|
1364
|
+
"pollingUrl",
|
|
1365
|
+
"recipes",
|
|
1366
|
+
"singleUploadHeaders",
|
|
1367
|
+
"singleUploadUrl",
|
|
1368
|
+
"type",
|
|
1369
|
+
"urn",
|
|
1370
|
+
])}`,
|
|
1371
|
+
`h${linkedInArticleImageKnownFieldMask(headers, ["media-type-family"])}`,
|
|
1372
|
+
`t${mediaType}`,
|
|
1373
|
+
`p${linkedInArticleImagePollingUrlClass(registration)}`,
|
|
1374
|
+
].join("-");
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
export function linkedInArticleDraftId(value: unknown, label = "LinkedIn Article draft ID"): string {
|
|
1378
|
+
if (typeof value !== "string" || !/^[0-9]{1,32}$/u.test(value)) {
|
|
1379
|
+
throw new Error(`${label} must be one exact 1-32 digit private LinkedIn Article ID`);
|
|
1380
|
+
}
|
|
1381
|
+
return value;
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
export function linkedInArticleDraftUrn(value: unknown): string {
|
|
1385
|
+
return `urn:li:fsd_firstPartyArticle:${linkedInArticleDraftId(value)}`;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
function linkedInArticleProfileUrn(value: unknown): string {
|
|
1389
|
+
if (
|
|
1390
|
+
typeof value !== "string"
|
|
1391
|
+
|| value.length > 512
|
|
1392
|
+
|| !/^urn:li:fsd_profile:[A-Za-z0-9_-]{1,256}$/u.test(value)
|
|
1393
|
+
) throw new Error("LinkedIn Article profile URN is invalid");
|
|
1394
|
+
return value;
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
export function linkedInArticleDraftReadUrl(value: unknown): URL {
|
|
1398
|
+
const urn = linkedInArticleDraftUrn(value);
|
|
1399
|
+
const url = new URL(LINKEDIN_FIRST_PARTY_ARTICLES_PATH, "https://www.linkedin.com");
|
|
1400
|
+
url.searchParams.set("articleUrn", urn);
|
|
1401
|
+
url.searchParams.set("q", "articleUrn");
|
|
1402
|
+
return url;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
export function linkedInArticleDraftEntityUrl(value: unknown): URL {
|
|
1406
|
+
const urn = linkedInArticleDraftUrn(value);
|
|
1407
|
+
return new URL(
|
|
1408
|
+
`${LINKEDIN_FIRST_PARTY_ARTICLES_PATH}/${urn}`,
|
|
1409
|
+
"https://www.linkedin.com",
|
|
1410
|
+
);
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
export function linkedInArticleDraftEditUrl(value: unknown): URL {
|
|
1414
|
+
const draftId = linkedInArticleDraftId(value);
|
|
1415
|
+
return new URL(`/article/edit/${draftId}/`, "https://www.linkedin.com");
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
export const LINKEDIN_ARTICLE_INLINE_IMAGE_UPLOAD_PATH =
|
|
1419
|
+
"/voyager/api/voyagerVideoDashMediaUploadMetadata?action=upload";
|
|
1420
|
+
|
|
1421
|
+
export type LinkedInArticleImageUploadBinding = {
|
|
1422
|
+
readonly assetUrn: string;
|
|
1423
|
+
readonly pollingUrl: string | null;
|
|
1424
|
+
readonly recipes: readonly string[];
|
|
1425
|
+
readonly uploadHeaders: Readonly<Record<string, string>>;
|
|
1426
|
+
readonly uploadUrl: string;
|
|
1427
|
+
};
|
|
1428
|
+
|
|
1429
|
+
function linkedInArticleBoundUrl(
|
|
1430
|
+
value: unknown,
|
|
1431
|
+
label: string,
|
|
1432
|
+
pathPrefix: string,
|
|
1433
|
+
): string {
|
|
1434
|
+
const href = boundedText(value, label, 64 * 1_024);
|
|
1435
|
+
let url: URL;
|
|
1436
|
+
try {
|
|
1437
|
+
url = new URL(href);
|
|
1438
|
+
} catch {
|
|
1439
|
+
throw new Error(`${label} escaped its reviewed LinkedIn origin`);
|
|
1440
|
+
}
|
|
1441
|
+
if (
|
|
1442
|
+
url.origin !== "https://www.linkedin.com"
|
|
1443
|
+
|| url.username !== ""
|
|
1444
|
+
|| url.password !== ""
|
|
1445
|
+
|| url.hash !== ""
|
|
1446
|
+
|| !url.pathname.startsWith(pathPrefix)
|
|
1447
|
+
|| url.href !== href
|
|
1448
|
+
) throw new Error(`${label} escaped its reviewed LinkedIn origin`);
|
|
1449
|
+
return href;
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
/** Strictly bind registration output before any signed image upload occurs. */
|
|
1453
|
+
export function normalizeLinkedInArticleImageUploadRegistration(
|
|
1454
|
+
value: unknown,
|
|
1455
|
+
): LinkedInArticleImageUploadBinding {
|
|
1456
|
+
const envelope = graphqlRecord(value, "LinkedIn Article image registration response");
|
|
1457
|
+
const envelopeFields = Object.keys(envelope).sort().join(",");
|
|
1458
|
+
let dataValue: unknown;
|
|
1459
|
+
if (envelopeFields === "data,included") {
|
|
1460
|
+
if (!Array.isArray(envelope.included) || envelope.included.length !== 0) {
|
|
1461
|
+
throw new Error("LinkedIn Article image registration returned unexpected included entities");
|
|
1462
|
+
}
|
|
1463
|
+
const data = graphqlRecord(envelope.data, "LinkedIn Article image registration response.data");
|
|
1464
|
+
const dataFields = Object.keys(data).sort().join(",");
|
|
1465
|
+
if (dataFields === "$type,value") {
|
|
1466
|
+
if (data.$type !== "com.linkedin.restli.common.ActionResponse") {
|
|
1467
|
+
throw new Error("LinkedIn Article image registration changed its response type");
|
|
1468
|
+
}
|
|
1469
|
+
} else if (dataFields !== "value") {
|
|
1470
|
+
throw new Error("LinkedIn Article image registration response.data has unsupported fields");
|
|
1471
|
+
}
|
|
1472
|
+
dataValue = data.value;
|
|
1473
|
+
} else if (envelopeFields === "value") {
|
|
1474
|
+
dataValue = envelope.value;
|
|
1475
|
+
} else {
|
|
1476
|
+
throw new Error("LinkedIn Article image registration response has unsupported fields");
|
|
1477
|
+
}
|
|
1478
|
+
const registration = graphqlRecord(
|
|
1479
|
+
dataValue,
|
|
1480
|
+
"LinkedIn Article image registration response.data.value",
|
|
1481
|
+
);
|
|
1482
|
+
const fullRegistrationFields = [
|
|
1483
|
+
"$type",
|
|
1484
|
+
"assetRealtimeTopic",
|
|
1485
|
+
"mediaArtifactUrn",
|
|
1486
|
+
"pollingUrl",
|
|
1487
|
+
"recipes",
|
|
1488
|
+
"singleUploadHeaders",
|
|
1489
|
+
"singleUploadUrl",
|
|
1490
|
+
"type",
|
|
1491
|
+
"urn",
|
|
1492
|
+
] as const;
|
|
1493
|
+
const legacySingleRegistrationFields = [
|
|
1494
|
+
"mediaArtifactUrn",
|
|
1495
|
+
"recipes",
|
|
1496
|
+
"singleUploadHeaders",
|
|
1497
|
+
"singleUploadUrl",
|
|
1498
|
+
"type",
|
|
1499
|
+
"urn",
|
|
1500
|
+
] as const;
|
|
1501
|
+
const currentSingleRegistrationFields = [
|
|
1502
|
+
"$type",
|
|
1503
|
+
"mediaArtifactUrn",
|
|
1504
|
+
"singleUploadHeaders",
|
|
1505
|
+
"singleUploadUrl",
|
|
1506
|
+
"type",
|
|
1507
|
+
"urn",
|
|
1508
|
+
] as const;
|
|
1509
|
+
const registrationFields = Object.keys(registration).sort().join(",");
|
|
1510
|
+
const fullFields = [...fullRegistrationFields].sort().join(",");
|
|
1511
|
+
const legacySingleFields = [...legacySingleRegistrationFields].sort().join(",");
|
|
1512
|
+
const currentSingleFields = [...currentSingleRegistrationFields].sort().join(",");
|
|
1513
|
+
if (
|
|
1514
|
+
registrationFields !== fullFields
|
|
1515
|
+
&& registrationFields !== legacySingleFields
|
|
1516
|
+
&& registrationFields !== currentSingleFields
|
|
1517
|
+
) {
|
|
1518
|
+
throw new Error("LinkedIn Article image registration response.data.value has unsupported fields");
|
|
1519
|
+
}
|
|
1520
|
+
const fullRegistration = registrationFields === fullFields;
|
|
1521
|
+
const currentSingleRegistration = registrationFields === currentSingleFields;
|
|
1522
|
+
if (
|
|
1523
|
+
registration.$type !== undefined
|
|
1524
|
+
&& registration.$type !== "com.linkedin.mediauploader.MediaUploadMetadata"
|
|
1525
|
+
) throw new Error("LinkedIn Article image registration changed its response type");
|
|
1526
|
+
if (registration.type !== "VECTOR" && registration.type !== "SINGLE") {
|
|
1527
|
+
throw new Error("LinkedIn Article image registration changed its media type");
|
|
1528
|
+
}
|
|
1529
|
+
if (registration.type === "VECTOR" && !fullRegistration) {
|
|
1530
|
+
throw new Error("LinkedIn Article image registration response.data.value has unsupported fields");
|
|
1531
|
+
}
|
|
1532
|
+
if (fullRegistration) {
|
|
1533
|
+
boundedText(
|
|
1534
|
+
registration.assetRealtimeTopic,
|
|
1535
|
+
"LinkedIn Article image registration assetRealtimeTopic",
|
|
1536
|
+
4_096,
|
|
1537
|
+
);
|
|
1538
|
+
}
|
|
1539
|
+
linkedInUrn(
|
|
1540
|
+
registration.mediaArtifactUrn,
|
|
1541
|
+
"LinkedIn Article image registration mediaArtifactUrn",
|
|
1542
|
+
);
|
|
1543
|
+
const assetUrn = linkedInArticleImageAssetUrn(
|
|
1544
|
+
registration.urn,
|
|
1545
|
+
"LinkedIn Article image registration urn",
|
|
1546
|
+
);
|
|
1547
|
+
const recipeValues = currentSingleRegistration ? [] : registration.recipes;
|
|
1548
|
+
if (
|
|
1549
|
+
!Array.isArray(recipeValues)
|
|
1550
|
+
|| (!currentSingleRegistration && recipeValues.length < 1)
|
|
1551
|
+
|| recipeValues.length > 20
|
|
1552
|
+
) {
|
|
1553
|
+
throw new Error("LinkedIn Article image registration recipes changed shape");
|
|
1554
|
+
}
|
|
1555
|
+
const recipes = recipeValues.map((recipe, index) =>
|
|
1556
|
+
linkedInUrn(recipe, `LinkedIn Article image registration recipes[${index}]`));
|
|
1557
|
+
if (new Set(recipes).size !== recipes.length) {
|
|
1558
|
+
throw new Error("LinkedIn Article image registration repeated a recipe");
|
|
1559
|
+
}
|
|
1560
|
+
const headers = graphqlRecord(
|
|
1561
|
+
registration.singleUploadHeaders,
|
|
1562
|
+
"LinkedIn Article image registration singleUploadHeaders",
|
|
1563
|
+
);
|
|
1564
|
+
exactObjectKeys(headers, ["media-type-family"], "LinkedIn Article image registration singleUploadHeaders");
|
|
1565
|
+
if (headers["media-type-family"] !== "STILLIMAGE") {
|
|
1566
|
+
throw new Error("LinkedIn Article image registration changed its upload headers");
|
|
1567
|
+
}
|
|
1568
|
+
let pollingUrl: string | null = null;
|
|
1569
|
+
if (fullRegistration && registration.type === "VECTOR") {
|
|
1570
|
+
pollingUrl = linkedInArticleBoundUrl(
|
|
1571
|
+
registration.pollingUrl,
|
|
1572
|
+
"LinkedIn Article image polling URL",
|
|
1573
|
+
"/voyager/api/",
|
|
1574
|
+
);
|
|
1575
|
+
} else if (
|
|
1576
|
+
fullRegistration
|
|
1577
|
+
&& registration.type === "SINGLE"
|
|
1578
|
+
&& registration.pollingUrl !== null
|
|
1579
|
+
) {
|
|
1580
|
+
linkedInArticleBoundUrl(
|
|
1581
|
+
registration.pollingUrl,
|
|
1582
|
+
"LinkedIn Article image polling URL",
|
|
1583
|
+
"/",
|
|
1584
|
+
);
|
|
1585
|
+
}
|
|
1586
|
+
return Object.freeze({
|
|
1587
|
+
assetUrn,
|
|
1588
|
+
pollingUrl,
|
|
1589
|
+
recipes: Object.freeze(recipes),
|
|
1590
|
+
uploadHeaders: Object.freeze({ "media-type-family": "STILLIMAGE" }),
|
|
1591
|
+
uploadUrl: linkedInArticleBoundUrl(
|
|
1592
|
+
registration.singleUploadUrl,
|
|
1593
|
+
"LinkedIn Article image upload URL",
|
|
1594
|
+
"/dms-uploads/",
|
|
1595
|
+
),
|
|
1596
|
+
});
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
/** Require every registered recipe to finish before an asset may enter a draft. */
|
|
1600
|
+
export function normalizeLinkedInArticleImageUploadStatus(
|
|
1601
|
+
value: unknown,
|
|
1602
|
+
binding: LinkedInArticleImageUploadBinding,
|
|
1603
|
+
): void {
|
|
1604
|
+
if (binding.pollingUrl === null) {
|
|
1605
|
+
throw new Error("LinkedIn Article image registration did not expose a polling contract");
|
|
1606
|
+
}
|
|
1607
|
+
const status = graphqlRecord(value, "LinkedIn Article image status response");
|
|
1608
|
+
exactObjectKeys(status, ["asset", "assetStatus", "status"], "LinkedIn Article image status response");
|
|
1609
|
+
if (status.asset !== binding.assetUrn || status.assetStatus !== "ALLOWED") {
|
|
1610
|
+
throw new Error("LinkedIn Article image status did not bind one allowed asset");
|
|
1611
|
+
}
|
|
1612
|
+
const recipes = graphqlRecord(status.status, "LinkedIn Article image status response.status");
|
|
1613
|
+
exactObjectKeys(recipes, binding.recipes, "LinkedIn Article image status response.status");
|
|
1614
|
+
if (binding.recipes.some((recipe) => recipes[recipe] !== "AVAILABLE")) {
|
|
1615
|
+
throw new Error("LinkedIn Article image processing did not finish every recipe");
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
type LinkedInArticleCodePayload = {
|
|
1620
|
+
readonly attributes: string;
|
|
1621
|
+
readonly body: string;
|
|
1622
|
+
};
|
|
1623
|
+
|
|
1624
|
+
function linkedInArticleCodeAttributes(value: unknown): void {
|
|
1625
|
+
if (typeof value !== "string" || value.length > 4_096) {
|
|
1626
|
+
throw new Error("LinkedIn Article bootstrap code attributes exceeded their reviewed bound");
|
|
1627
|
+
}
|
|
1628
|
+
const attributes = new Map<string, string>();
|
|
1629
|
+
let remaining = value.trim();
|
|
1630
|
+
while (remaining.length > 0) {
|
|
1631
|
+
const match = /^([A-Za-z][A-Za-z0-9:_-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/u.exec(remaining);
|
|
1632
|
+
if (match === null) {
|
|
1633
|
+
throw new Error("LinkedIn Article bootstrap code attributes changed shape");
|
|
1634
|
+
}
|
|
1635
|
+
const name = match[1]?.toLowerCase();
|
|
1636
|
+
const attributeValue = match[2] ?? match[3];
|
|
1637
|
+
if (name === undefined || attributeValue === undefined || attributes.has(name)) {
|
|
1638
|
+
throw new Error("LinkedIn Article bootstrap code attributes were ambiguous");
|
|
1639
|
+
}
|
|
1640
|
+
attributes.set(name, attributeValue);
|
|
1641
|
+
remaining = remaining.slice(match[0].length).trimStart();
|
|
1642
|
+
}
|
|
1643
|
+
if ([...attributes.keys()].sort().join(",") !== "id,style") {
|
|
1644
|
+
throw new Error("LinkedIn Article bootstrap code attributes changed shape");
|
|
1645
|
+
}
|
|
1646
|
+
if (!/^bpr-guid-[0-9]{1,12}$/u.test(attributes.get("id") ?? "")) {
|
|
1647
|
+
throw new Error("LinkedIn Article bootstrap code identifier changed shape");
|
|
1648
|
+
}
|
|
1649
|
+
const style = (attributes.get("style") ?? "").replace(/\s/gu, "");
|
|
1650
|
+
if (style !== "display:none" && style !== "display:none;") {
|
|
1651
|
+
throw new Error("LinkedIn Article bootstrap code payload is no longer hidden");
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
const LINKEDIN_ARTICLE_HTML_ENTITY =
|
|
1656
|
+
/&(?:quot|amp|lt|gt|apos|#(?:[xX][0-9A-Fa-f]{1,6}|[0-9]{1,7}));/gu;
|
|
1657
|
+
|
|
1658
|
+
function decodeLinkedInArticleEntity(entity: string): string {
|
|
1659
|
+
if (entity === """) return '"';
|
|
1660
|
+
if (entity === "&") return "&";
|
|
1661
|
+
if (entity === "<") return "<";
|
|
1662
|
+
if (entity === ">") return ">";
|
|
1663
|
+
if (entity === "'") return "'";
|
|
1664
|
+
const numeric = /^&#(?:[xX]([0-9A-Fa-f]{1,6})|([0-9]{1,7}));$/u.exec(entity);
|
|
1665
|
+
if (numeric === null) {
|
|
1666
|
+
throw new Error("LinkedIn Article bootstrap used an unsupported HTML entity");
|
|
1667
|
+
}
|
|
1668
|
+
const codePoint = Number.parseInt(numeric[1] ?? numeric[2] ?? "", numeric[1] === undefined ? 10 : 16);
|
|
1669
|
+
if (
|
|
1670
|
+
!Number.isSafeInteger(codePoint)
|
|
1671
|
+
|| codePoint < 0
|
|
1672
|
+
|| codePoint > 0x10_FFFF
|
|
1673
|
+
|| (codePoint >= 0xD800 && codePoint <= 0xDFFF)
|
|
1674
|
+
) throw new Error("LinkedIn Article bootstrap used an invalid numeric HTML entity");
|
|
1675
|
+
return String.fromCodePoint(codePoint);
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
function parseLinkedInArticleCodePayload(
|
|
1679
|
+
value: LinkedInArticleCodePayload,
|
|
1680
|
+
draftUrn: string,
|
|
1681
|
+
): unknown {
|
|
1682
|
+
linkedInArticleCodeAttributes(value.attributes);
|
|
1683
|
+
if (
|
|
1684
|
+
value.body.length < 1
|
|
1685
|
+
|| value.body.length > LINKEDIN_ARTICLE_CODE_PAYLOAD_MAX_CHARACTERS
|
|
1686
|
+
|| !value.body.includes(draftUrn)
|
|
1687
|
+
) throw new Error("LinkedIn Article bootstrap code payload did not bind the exact draft");
|
|
1688
|
+
const json = value.body.replace(
|
|
1689
|
+
LINKEDIN_ARTICLE_HTML_ENTITY,
|
|
1690
|
+
(entity) => decodeLinkedInArticleEntity(entity),
|
|
1691
|
+
).trim();
|
|
1692
|
+
if (!json.startsWith("{") || !json.endsWith("}")) {
|
|
1693
|
+
throw new Error("LinkedIn Article bootstrap code payload changed its JSON boundary");
|
|
1694
|
+
}
|
|
1695
|
+
try {
|
|
1696
|
+
return JSON.parse(json) as unknown;
|
|
1697
|
+
} catch {
|
|
1698
|
+
throw new Error("LinkedIn Article bootstrap code payload contained malformed JSON");
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
/**
|
|
1703
|
+
* Select the one exact hidden server-response payload for a private draft.
|
|
1704
|
+
* Payloads are produced by the contained browser's bounded HTML scan; callers
|
|
1705
|
+
* cannot provide HTML, selectors, or code through the semantic operation.
|
|
1706
|
+
*/
|
|
1707
|
+
export function linkedInArticleDraftEnvelopeFromCodePayloads(
|
|
1708
|
+
value: unknown,
|
|
1709
|
+
draftIdValue: unknown,
|
|
1710
|
+
): unknown {
|
|
1711
|
+
const draftUrn = linkedInArticleDraftUrn(draftIdValue);
|
|
1712
|
+
if (!Array.isArray(value) || value.length !== 1) {
|
|
1713
|
+
throw new Error("LinkedIn Article bootstrap did not isolate one exact hidden payload");
|
|
1714
|
+
}
|
|
1715
|
+
const payload = graphqlRecord(value[0], "Article bootstrap code payload");
|
|
1716
|
+
exactObjectKeys(
|
|
1717
|
+
payload,
|
|
1718
|
+
["attributes", "body"],
|
|
1719
|
+
"LinkedIn Article bootstrap code payload",
|
|
1720
|
+
);
|
|
1721
|
+
const attributes = boundedText(
|
|
1722
|
+
payload.attributes,
|
|
1723
|
+
"LinkedIn Article bootstrap code attributes",
|
|
1724
|
+
4_096,
|
|
1725
|
+
);
|
|
1726
|
+
const body = boundedText(
|
|
1727
|
+
payload.body,
|
|
1728
|
+
"LinkedIn Article bootstrap code body",
|
|
1729
|
+
LINKEDIN_ARTICLE_CODE_PAYLOAD_MAX_CHARACTERS,
|
|
1730
|
+
);
|
|
1731
|
+
return parseLinkedInArticleCodePayload({ attributes, body }, draftUrn);
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
/** Extract the same bounded hidden payload from an authenticated HTML response. */
|
|
1735
|
+
export function linkedInArticleDraftEnvelopeFromHtml(
|
|
1736
|
+
value: unknown,
|
|
1737
|
+
draftIdValue: unknown,
|
|
1738
|
+
): unknown {
|
|
1739
|
+
const draftUrn = linkedInArticleDraftUrn(draftIdValue);
|
|
1740
|
+
if (
|
|
1741
|
+
typeof value !== "string"
|
|
1742
|
+
|| value.length < 1
|
|
1743
|
+
|| value.length > LINKEDIN_ARTICLE_PAGE_MAX_CHARACTERS
|
|
1744
|
+
) throw new Error("LinkedIn Article page exceeded its reviewed HTML bound");
|
|
1745
|
+
const payloads: LinkedInArticleCodePayload[] = [];
|
|
1746
|
+
let codeTags = 0;
|
|
1747
|
+
for (const match of value.matchAll(/<code\b([^>]*)>([\s\S]*?)<\/code>/giu)) {
|
|
1748
|
+
codeTags += 1;
|
|
1749
|
+
if (codeTags > LINKEDIN_ARTICLE_CODE_TAG_MAX_COUNT) {
|
|
1750
|
+
throw new Error("LinkedIn Article page returned too many code payloads");
|
|
1751
|
+
}
|
|
1752
|
+
const attributes = match[1];
|
|
1753
|
+
const body = match[2];
|
|
1754
|
+
if (attributes === undefined || body === undefined || !body.includes(draftUrn)) continue;
|
|
1755
|
+
payloads.push(Object.freeze({ attributes, body }));
|
|
1756
|
+
if (payloads.length > LINKEDIN_ARTICLE_MATCHING_PAYLOAD_MAX_COUNT) {
|
|
1757
|
+
throw new Error("LinkedIn Article page returned too many matching payloads");
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
return linkedInArticleDraftEnvelopeFromCodePayloads(payloads, draftIdValue);
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
function linkedInArticleBlockType(type: ArticleDraftTextBlock["type"]): string {
|
|
1764
|
+
if (type === "paragraph") return "PARAGRAPH";
|
|
1765
|
+
if (type === "heading1") return "HEADING_1";
|
|
1766
|
+
if (type === "heading2") return "HEADING_2";
|
|
1767
|
+
if (type === "blockquote") return "QUOTE";
|
|
1768
|
+
throw new Error(
|
|
1769
|
+
"LinkedIn Article drafts currently support only paragraph, heading1, heading2, and blockquote blocks",
|
|
1770
|
+
);
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
function linkedInArticleDocumentBlockType(value: unknown, label: string): ArticleDraftTextBlock["type"] {
|
|
1774
|
+
if (value === "PARAGRAPH") return "paragraph";
|
|
1775
|
+
if (value === "HEADING_1") return "heading1";
|
|
1776
|
+
if (value === "HEADING_2") return "heading2";
|
|
1777
|
+
if (value === "QUOTE") return "blockquote";
|
|
1778
|
+
throw new Error(`${label} has an unsupported LinkedIn Article text-block type`);
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
function linkedInArticleAttribute(link: ArticleDraftLinkRange): Readonly<Record<string, unknown>> {
|
|
1782
|
+
return Object.freeze({
|
|
1783
|
+
$type: LINKEDIN_TEXT_ATTRIBUTE_TYPE,
|
|
1784
|
+
detailDataUnion: Object.freeze({ hyperlink: link.url }),
|
|
1785
|
+
length: link.length,
|
|
1786
|
+
start: link.offset,
|
|
1787
|
+
});
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
/** Project the reviewed text/headings/native-link subset into LinkedIn's Article model. */
|
|
1791
|
+
export function buildLinkedInArticleContent(
|
|
1792
|
+
document: ArticleDraftDocument,
|
|
1793
|
+
): readonly Readonly<Record<string, unknown>>[] {
|
|
1794
|
+
return Object.freeze(document.blocks.map((block) => {
|
|
1795
|
+
if (block.styles.length !== 0) {
|
|
1796
|
+
throw new Error("LinkedIn Article text styles remain capture-required");
|
|
1797
|
+
}
|
|
1798
|
+
return Object.freeze({
|
|
1799
|
+
textBlock: Object.freeze({
|
|
1800
|
+
$type: LINKEDIN_TEXT_BLOCK_TYPE,
|
|
1801
|
+
content: Object.freeze({
|
|
1802
|
+
$type: LINKEDIN_TEXT_VIEW_MODEL_TYPE,
|
|
1803
|
+
attributesV2: Object.freeze(block.links.map(linkedInArticleAttribute)),
|
|
1804
|
+
text: block.text,
|
|
1805
|
+
}),
|
|
1806
|
+
type: linkedInArticleBlockType(block.type),
|
|
1807
|
+
}),
|
|
1808
|
+
});
|
|
1809
|
+
}));
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
function linkedInArticleImageAssetUrn(value: unknown, label: string): string {
|
|
1813
|
+
if (
|
|
1814
|
+
typeof value !== "string"
|
|
1815
|
+
|| value.length > 512
|
|
1816
|
+
|| !/^urn:li:digitalmediaAsset:[A-Za-z0-9_-]{1,256}$/u.test(value)
|
|
1817
|
+
) throw new Error(`${label} must be one exact LinkedIn digital-media asset URN`);
|
|
1818
|
+
return value;
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
function linkedInArticleImageWriteBlock(
|
|
1822
|
+
block: ArticleDraftImageBlock,
|
|
1823
|
+
assetUrnValue: unknown,
|
|
1824
|
+
): Readonly<Record<string, unknown>> {
|
|
1825
|
+
const assetUrn = linkedInArticleImageAssetUrn(
|
|
1826
|
+
assetUrnValue,
|
|
1827
|
+
`LinkedIn Article image ${block.imageIndex}`,
|
|
1828
|
+
);
|
|
1829
|
+
if (block.altText === undefined) {
|
|
1830
|
+
throw new Error("LinkedIn Article inline images require descriptive altText");
|
|
1831
|
+
}
|
|
1832
|
+
return Object.freeze({
|
|
1833
|
+
imageBlock: Object.freeze({
|
|
1834
|
+
$type: LINKEDIN_IMAGE_BLOCK_TYPE,
|
|
1835
|
+
alignment: "FULL_WIDTH",
|
|
1836
|
+
caption: Object.freeze({
|
|
1837
|
+
$type: LINKEDIN_TEXT_VIEW_MODEL_TYPE,
|
|
1838
|
+
text: block.caption ?? "",
|
|
1839
|
+
}),
|
|
1840
|
+
content: Object.freeze({
|
|
1841
|
+
$type: LINKEDIN_IMAGE_VIEW_MODEL_TYPE,
|
|
1842
|
+
accessibilityText: block.altText,
|
|
1843
|
+
attributes: Object.freeze([Object.freeze({
|
|
1844
|
+
$type: LINKEDIN_IMAGE_ATTRIBUTE_TYPE,
|
|
1845
|
+
detailDataUnion: Object.freeze({
|
|
1846
|
+
vectorImage: Object.freeze({
|
|
1847
|
+
$type: LINKEDIN_VECTOR_IMAGE_TYPE,
|
|
1848
|
+
artifacts: Object.freeze([]),
|
|
1849
|
+
digitalmediaAsset: assetUrn,
|
|
1850
|
+
}),
|
|
1851
|
+
}),
|
|
1852
|
+
})]),
|
|
1853
|
+
}),
|
|
1854
|
+
}),
|
|
1855
|
+
});
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
/** Project mixed text/images into LinkedIn's captured Article write model. */
|
|
1859
|
+
export function buildLinkedInArticleContentV2(
|
|
1860
|
+
document: ArticleDraftDocumentV2,
|
|
1861
|
+
imageAssetUrns: readonly string[],
|
|
1862
|
+
): readonly Readonly<Record<string, unknown>>[] {
|
|
1863
|
+
const used = new Set<number>();
|
|
1864
|
+
const content = document.blocks.map((block) => {
|
|
1865
|
+
if (block.type === "image") {
|
|
1866
|
+
used.add(block.imageIndex);
|
|
1867
|
+
return linkedInArticleImageWriteBlock(block, imageAssetUrns[block.imageIndex]);
|
|
1868
|
+
}
|
|
1869
|
+
return buildLinkedInArticleContent(Object.freeze({
|
|
1870
|
+
schemaVersion: 1,
|
|
1871
|
+
blocks: Object.freeze([block]),
|
|
1872
|
+
}))[0]!;
|
|
1873
|
+
});
|
|
1874
|
+
if (
|
|
1875
|
+
imageAssetUrns.length !== used.size
|
|
1876
|
+
|| imageAssetUrns.some((_, index) => !used.has(index))
|
|
1877
|
+
) throw new Error("LinkedIn Article image assets did not bind every exact image block");
|
|
1878
|
+
return Object.freeze(content);
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
function escapeLinkedInArticleHtmlText(value: string): string {
|
|
1882
|
+
return value.replace(/[&<>]/gu, (character) => {
|
|
1883
|
+
if (character === "&") return "&";
|
|
1884
|
+
if (character === "<") return "<";
|
|
1885
|
+
return ">";
|
|
1886
|
+
});
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
function escapeLinkedInArticleHtmlAttribute(value: string): string {
|
|
1890
|
+
return value.replace(/[&<>"]/gu, (character) => {
|
|
1891
|
+
if (character === "&") return "&";
|
|
1892
|
+
if (character === "<") return "<";
|
|
1893
|
+
if (character === ">") return ">";
|
|
1894
|
+
return """;
|
|
1895
|
+
});
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
function linkedInArticleHtmlTag(
|
|
1899
|
+
type: ArticleDraftTextBlock["type"],
|
|
1900
|
+
): "p" | "h2" | "h3" | "blockquote" {
|
|
1901
|
+
if (type === "paragraph") return "p";
|
|
1902
|
+
if (type === "heading1") return "h2";
|
|
1903
|
+
if (type === "heading2") return "h3";
|
|
1904
|
+
if (type === "blockquote") return "blockquote";
|
|
1905
|
+
throw new Error(
|
|
1906
|
+
"LinkedIn Article drafts currently support only paragraph, heading1, heading2, and blockquote blocks",
|
|
1907
|
+
);
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
/** Project the reviewed Article subset into LinkedIn's required rendered HTML. */
|
|
1911
|
+
export function buildLinkedInArticleContentHtml(document: ArticleDraftDocument): string {
|
|
1912
|
+
return document.blocks.map((block) => {
|
|
1913
|
+
if (block.styles.length !== 0) {
|
|
1914
|
+
throw new Error("LinkedIn Article text styles remain capture-required");
|
|
1915
|
+
}
|
|
1916
|
+
let cursor = 0;
|
|
1917
|
+
let content = "";
|
|
1918
|
+
for (const link of block.links) {
|
|
1919
|
+
content += escapeLinkedInArticleHtmlText(block.text.slice(cursor, link.offset));
|
|
1920
|
+
content += `<a href="${escapeLinkedInArticleHtmlAttribute(link.url)}" target="_blank">`;
|
|
1921
|
+
content += escapeLinkedInArticleHtmlText(
|
|
1922
|
+
block.text.slice(link.offset, link.offset + link.length),
|
|
1923
|
+
);
|
|
1924
|
+
content += "</a>";
|
|
1925
|
+
cursor = link.offset + link.length;
|
|
1926
|
+
}
|
|
1927
|
+
content += escapeLinkedInArticleHtmlText(block.text.slice(cursor));
|
|
1928
|
+
const tag = linkedInArticleHtmlTag(block.type);
|
|
1929
|
+
return `<${tag}>${content}</${tag}>`;
|
|
1930
|
+
}).join("");
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
/**
|
|
1934
|
+
* LinkedIn accepts the stable asset URN as the image identity and restores the
|
|
1935
|
+
* transient CDN source itself. Keeping that source out of the confirmed input
|
|
1936
|
+
* also prevents signed or expiring provider URLs from entering durable state.
|
|
1937
|
+
*/
|
|
1938
|
+
export function buildLinkedInArticleContentHtmlV2(
|
|
1939
|
+
document: ArticleDraftDocumentV2,
|
|
1940
|
+
imageAssetUrns: readonly string[],
|
|
1941
|
+
): string {
|
|
1942
|
+
const used = new Set<number>();
|
|
1943
|
+
const html = document.blocks.map((block) => {
|
|
1944
|
+
if (block.type !== "image") {
|
|
1945
|
+
return buildLinkedInArticleContentHtml(Object.freeze({
|
|
1946
|
+
schemaVersion: 1,
|
|
1947
|
+
blocks: Object.freeze([block]),
|
|
1948
|
+
}));
|
|
1949
|
+
}
|
|
1950
|
+
const assetUrn = linkedInArticleImageAssetUrn(
|
|
1951
|
+
imageAssetUrns[block.imageIndex],
|
|
1952
|
+
`LinkedIn Article image ${block.imageIndex}`,
|
|
1953
|
+
);
|
|
1954
|
+
used.add(block.imageIndex);
|
|
1955
|
+
return `<figure><img data-media-urn="${escapeLinkedInArticleHtmlAttribute(assetUrn)}"><figcaption>${escapeLinkedInArticleHtmlText(block.caption ?? "")}</figcaption></figure>`;
|
|
1956
|
+
}).join("");
|
|
1957
|
+
if (
|
|
1958
|
+
imageAssetUrns.length !== used.size
|
|
1959
|
+
|| imageAssetUrns.some((_, index) => !used.has(index))
|
|
1960
|
+
) throw new Error("LinkedIn Article image assets did not bind every exact image block");
|
|
1961
|
+
return html;
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
export function buildLinkedInArticleCreateBody(
|
|
1965
|
+
profileUrnValue: unknown,
|
|
1966
|
+
titleValue: unknown,
|
|
1967
|
+
): Readonly<Record<string, unknown>> {
|
|
1968
|
+
const profileUrn = linkedInArticleProfileUrn(profileUrnValue);
|
|
1969
|
+
if (
|
|
1970
|
+
typeof titleValue !== "string"
|
|
1971
|
+
|| titleValue.length < 1
|
|
1972
|
+
|| titleValue.length > 150
|
|
1973
|
+
|| /[\0\r\n]/u.test(titleValue)
|
|
1974
|
+
) throw new Error("input.title must be one bounded plain-text line");
|
|
1975
|
+
return Object.freeze({
|
|
1976
|
+
authors: Object.freeze([Object.freeze({ profileUrn })]),
|
|
1977
|
+
contentHtml: "",
|
|
1978
|
+
state: "AUTOSAVED",
|
|
1979
|
+
title: titleValue,
|
|
1980
|
+
});
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
export function buildLinkedInArticleTitlePatch(titleValue: unknown): Readonly<Record<string, unknown>> {
|
|
1984
|
+
const title = buildLinkedInArticleCreateBody("urn:li:fsd_profile:fixture", titleValue).title;
|
|
1985
|
+
return Object.freeze({
|
|
1986
|
+
patch: Object.freeze({ $set: Object.freeze({ state: "AUTOSAVED", title }) }),
|
|
1987
|
+
});
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
export function buildLinkedInArticleCoverPatch(
|
|
1991
|
+
assetUrnValue: unknown,
|
|
1992
|
+
): Readonly<Record<string, unknown>> {
|
|
1993
|
+
const originalImageUrn = linkedInArticleImageAssetUrn(
|
|
1994
|
+
assetUrnValue,
|
|
1995
|
+
"LinkedIn Article cover image",
|
|
1996
|
+
);
|
|
1997
|
+
return Object.freeze({
|
|
1998
|
+
patch: Object.freeze({
|
|
1999
|
+
$set: Object.freeze({
|
|
2000
|
+
coverMediaV2Union: Object.freeze({
|
|
2001
|
+
coverImage: Object.freeze({
|
|
2002
|
+
$type: LINKEDIN_COVER_IMAGE_TYPE,
|
|
2003
|
+
caption: Object.freeze({ text: "" }),
|
|
2004
|
+
originalImageUrn,
|
|
2005
|
+
}),
|
|
2006
|
+
}),
|
|
2007
|
+
state: "AUTOSAVED",
|
|
2008
|
+
}),
|
|
2009
|
+
}),
|
|
2010
|
+
});
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
export function buildLinkedInArticleContentPatch(
|
|
2014
|
+
document: ArticleDraftDocument,
|
|
2015
|
+
): Readonly<Record<string, unknown>> {
|
|
2016
|
+
return Object.freeze({
|
|
2017
|
+
patch: Object.freeze({
|
|
2018
|
+
$set: Object.freeze({
|
|
2019
|
+
content: buildLinkedInArticleContent(document),
|
|
2020
|
+
contentHtml: buildLinkedInArticleContentHtml(document),
|
|
2021
|
+
state: "AUTOSAVED",
|
|
2022
|
+
}),
|
|
2023
|
+
}),
|
|
2024
|
+
});
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
export function buildLinkedInArticleContentPatchV2(
|
|
2028
|
+
document: ArticleDraftDocumentV2,
|
|
2029
|
+
imageAssetUrns: readonly string[],
|
|
2030
|
+
): Readonly<Record<string, unknown>> {
|
|
2031
|
+
return Object.freeze({
|
|
2032
|
+
patch: Object.freeze({
|
|
2033
|
+
$set: Object.freeze({
|
|
2034
|
+
content: buildLinkedInArticleContentV2(document, imageAssetUrns),
|
|
2035
|
+
contentHtml: buildLinkedInArticleContentHtmlV2(document, imageAssetUrns),
|
|
2036
|
+
state: "AUTOSAVED",
|
|
2037
|
+
}),
|
|
2038
|
+
}),
|
|
2039
|
+
});
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
function normalizeLinkedInArticleLink(
|
|
2043
|
+
value: unknown,
|
|
2044
|
+
text: string,
|
|
2045
|
+
label: string,
|
|
2046
|
+
): ArticleDraftLinkRange {
|
|
2047
|
+
const attribute = graphqlRecord(value, label);
|
|
2048
|
+
exactObjectKeys(attribute, ["$type", "detailDataUnion", "length", "start"], label);
|
|
2049
|
+
if (attribute.$type !== LINKEDIN_TEXT_ATTRIBUTE_TYPE) {
|
|
2050
|
+
throw new Error(`${label} changed its reviewed LinkedIn text-attribute type`);
|
|
2051
|
+
}
|
|
2052
|
+
const detail = graphqlRecord(attribute.detailDataUnion, `${label}.detailDataUnion`);
|
|
2053
|
+
exactObjectKeys(detail, ["hyperlink"], `${label}.detailDataUnion`);
|
|
2054
|
+
const start = attribute.start;
|
|
2055
|
+
const length = attribute.length;
|
|
2056
|
+
if (
|
|
2057
|
+
!Number.isSafeInteger(start)
|
|
2058
|
+
|| !Number.isSafeInteger(length)
|
|
2059
|
+
|| (start as number) < 0
|
|
2060
|
+
|| (length as number) < 1
|
|
2061
|
+
|| (start as number) + (length as number) > text.length
|
|
2062
|
+
) throw new Error(`${label} escaped its LinkedIn Article text`);
|
|
2063
|
+
const hyperlink = boundedText(detail.hyperlink, `${label}.hyperlink`, 8_192);
|
|
2064
|
+
let parsed: URL;
|
|
2065
|
+
try {
|
|
2066
|
+
parsed = new URL(hyperlink);
|
|
2067
|
+
} catch {
|
|
2068
|
+
throw new Error(`${label}.hyperlink is not an absolute HTTPS URL`);
|
|
2069
|
+
}
|
|
2070
|
+
if (
|
|
2071
|
+
parsed.protocol !== "https:"
|
|
2072
|
+
|| parsed.username !== ""
|
|
2073
|
+
|| parsed.password !== ""
|
|
2074
|
+
|| parsed.href !== hyperlink
|
|
2075
|
+
) throw new Error(`${label}.hyperlink is not one canonical absolute HTTPS URL`);
|
|
2076
|
+
return Object.freeze({ offset: start as number, length: length as number, url: hyperlink });
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
function normalizeLinkedInArticleBlock(value: unknown, index: number): ArticleDraftTextBlock {
|
|
2080
|
+
const label = `LinkedIn Article content[${index}]`;
|
|
2081
|
+
const wrapper = graphqlRecord(value, label);
|
|
2082
|
+
exactObjectKeys(wrapper, ["textBlock"], label);
|
|
2083
|
+
const textBlock = graphqlRecord(wrapper.textBlock, `${label}.textBlock`);
|
|
2084
|
+
exactObjectKeys(textBlock, ["$type", "content", "type"], `${label}.textBlock`);
|
|
2085
|
+
if (textBlock.$type !== LINKEDIN_TEXT_BLOCK_TYPE) {
|
|
2086
|
+
throw new Error(`${label}.textBlock changed its reviewed type`);
|
|
2087
|
+
}
|
|
2088
|
+
const content = graphqlRecord(textBlock.content, `${label}.textBlock.content`);
|
|
2089
|
+
exactObjectKeys(content, ["$type", "attributesV2", "text"], `${label}.textBlock.content`);
|
|
2090
|
+
if (content.$type !== LINKEDIN_TEXT_VIEW_MODEL_TYPE) {
|
|
2091
|
+
throw new Error(`${label}.textBlock.content changed its reviewed type`);
|
|
2092
|
+
}
|
|
2093
|
+
if (typeof content.text !== "string" || /[\0\r\n]/u.test(content.text)) {
|
|
2094
|
+
throw new Error(`${label}.textBlock.content.text must be one bounded line`);
|
|
2095
|
+
}
|
|
2096
|
+
if (!Array.isArray(content.attributesV2) || content.attributesV2.length > 500) {
|
|
2097
|
+
throw new Error(`${label}.textBlock.content.attributesV2 exceeded its reviewed bound`);
|
|
2098
|
+
}
|
|
2099
|
+
const links = content.attributesV2.map((attribute, linkIndex) =>
|
|
2100
|
+
normalizeLinkedInArticleLink(
|
|
2101
|
+
attribute,
|
|
2102
|
+
content.text as string,
|
|
2103
|
+
`${label}.textBlock.content.attributesV2[${linkIndex}]`,
|
|
2104
|
+
));
|
|
2105
|
+
let linkEnd = 0;
|
|
2106
|
+
for (const link of links) {
|
|
2107
|
+
if (link.offset < linkEnd) throw new Error(`${label} links must be ordered and non-overlapping`);
|
|
2108
|
+
linkEnd = link.offset + link.length;
|
|
2109
|
+
}
|
|
2110
|
+
return Object.freeze({
|
|
2111
|
+
type: linkedInArticleDocumentBlockType(textBlock.type, `${label}.textBlock.type`),
|
|
2112
|
+
text: content.text,
|
|
2113
|
+
links: Object.freeze(links),
|
|
2114
|
+
styles: Object.freeze([]),
|
|
2115
|
+
});
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
function linkedInCanonicalHttpsUrl(value: unknown, label: string): string {
|
|
2119
|
+
const url = boundedText(value, label, 8_192);
|
|
2120
|
+
let parsed: URL;
|
|
2121
|
+
try {
|
|
2122
|
+
parsed = new URL(url);
|
|
2123
|
+
} catch {
|
|
2124
|
+
throw new Error(`${label} is not one canonical absolute HTTPS URL`);
|
|
2125
|
+
}
|
|
2126
|
+
if (
|
|
2127
|
+
parsed.protocol !== "https:"
|
|
2128
|
+
|| parsed.username !== ""
|
|
2129
|
+
|| parsed.password !== ""
|
|
2130
|
+
|| parsed.href !== url
|
|
2131
|
+
) throw new Error(`${label} is not one canonical absolute HTTPS URL`);
|
|
2132
|
+
return url;
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
function normalizeLinkedInArticleImageBlock(
|
|
2136
|
+
value: unknown,
|
|
2137
|
+
index: number,
|
|
2138
|
+
imageIndex: number,
|
|
2139
|
+
): { readonly block: ArticleDraftImageBlock; readonly assetUrn: string } {
|
|
2140
|
+
const label = `LinkedIn Article content[${index}]`;
|
|
2141
|
+
const wrapper = graphqlRecord(value, label);
|
|
2142
|
+
exactObjectKeys(wrapper, ["imageBlock"], label);
|
|
2143
|
+
const imageBlock = graphqlRecord(wrapper.imageBlock, `${label}.imageBlock`);
|
|
2144
|
+
exactObjectKeys(
|
|
2145
|
+
imageBlock,
|
|
2146
|
+
["$type", "alignment", "caption", "content"],
|
|
2147
|
+
`${label}.imageBlock`,
|
|
2148
|
+
);
|
|
2149
|
+
if (
|
|
2150
|
+
imageBlock.$type !== LINKEDIN_IMAGE_BLOCK_TYPE
|
|
2151
|
+
|| imageBlock.alignment !== "FULL_WIDTH"
|
|
2152
|
+
) throw new Error(`${label}.imageBlock changed its reviewed type or alignment`);
|
|
2153
|
+
|
|
2154
|
+
const captionModel = graphqlRecord(imageBlock.caption, `${label}.imageBlock.caption`);
|
|
2155
|
+
exactObjectKeys(
|
|
2156
|
+
captionModel,
|
|
2157
|
+
["$type", "attributesV2", "text"],
|
|
2158
|
+
`${label}.imageBlock.caption`,
|
|
2159
|
+
);
|
|
2160
|
+
if (
|
|
2161
|
+
captionModel.$type !== LINKEDIN_TEXT_VIEW_MODEL_TYPE
|
|
2162
|
+
|| !Array.isArray(captionModel.attributesV2)
|
|
2163
|
+
|| captionModel.attributesV2.length !== 0
|
|
2164
|
+
|| typeof captionModel.text !== "string"
|
|
2165
|
+
|| captionModel.text.length > 1_000
|
|
2166
|
+
|| /[\0\r]/u.test(captionModel.text)
|
|
2167
|
+
) throw new Error(`${label}.imageBlock.caption changed its reviewed shape`);
|
|
2168
|
+
|
|
2169
|
+
const content = graphqlRecord(imageBlock.content, `${label}.imageBlock.content`);
|
|
2170
|
+
exactObjectKeys(
|
|
2171
|
+
content,
|
|
2172
|
+
["$type", "accessibilityText", "accessibilityTextAttributes", "attributes"],
|
|
2173
|
+
`${label}.imageBlock.content`,
|
|
2174
|
+
);
|
|
2175
|
+
if (
|
|
2176
|
+
content.$type !== LINKEDIN_IMAGE_VIEW_MODEL_TYPE
|
|
2177
|
+
|| typeof content.accessibilityText !== "string"
|
|
2178
|
+
|| content.accessibilityText.length < 1
|
|
2179
|
+
|| content.accessibilityText.length > 1_000
|
|
2180
|
+
|| /[\0\r]/u.test(content.accessibilityText)
|
|
2181
|
+
|| !Array.isArray(content.accessibilityTextAttributes)
|
|
2182
|
+
|| content.accessibilityTextAttributes.length !== 0
|
|
2183
|
+
|| !Array.isArray(content.attributes)
|
|
2184
|
+
|| content.attributes.length !== 1
|
|
2185
|
+
) throw new Error(`${label}.imageBlock.content changed its reviewed shape`);
|
|
2186
|
+
|
|
2187
|
+
const attribute = graphqlRecord(content.attributes[0], `${label}.imageBlock.content.attributes[0]`);
|
|
2188
|
+
exactObjectKeys(attribute, ["$type", "detailDataUnion"], `${label}.imageBlock.content.attributes[0]`);
|
|
2189
|
+
if (attribute.$type !== LINKEDIN_IMAGE_ATTRIBUTE_TYPE) {
|
|
2190
|
+
throw new Error(`${label}.imageBlock.content changed its reviewed image attribute`);
|
|
2191
|
+
}
|
|
2192
|
+
const detail = graphqlRecord(
|
|
2193
|
+
attribute.detailDataUnion,
|
|
2194
|
+
`${label}.imageBlock.content.attributes[0].detailDataUnion`,
|
|
2195
|
+
);
|
|
2196
|
+
exactObjectKeys(detail, ["vectorImage"], `${label}.imageBlock.content.attributes[0].detailDataUnion`);
|
|
2197
|
+
const vector = graphqlRecord(
|
|
2198
|
+
detail.vectorImage,
|
|
2199
|
+
`${label}.imageBlock.content.attributes[0].detailDataUnion.vectorImage`,
|
|
2200
|
+
);
|
|
2201
|
+
exactObjectKeys(
|
|
2202
|
+
vector,
|
|
2203
|
+
["$type", "artifacts", "digitalmediaAsset", "rootUrl"],
|
|
2204
|
+
`${label}.imageBlock.content.attributes[0].detailDataUnion.vectorImage`,
|
|
2205
|
+
);
|
|
2206
|
+
if (
|
|
2207
|
+
vector.$type !== LINKEDIN_VECTOR_IMAGE_TYPE
|
|
2208
|
+
|| !Array.isArray(vector.artifacts)
|
|
2209
|
+
|| vector.artifacts.length < 1
|
|
2210
|
+
|| vector.artifacts.length > 20
|
|
2211
|
+
) throw new Error(`${label}.imageBlock vector image changed its reviewed shape`);
|
|
2212
|
+
const assetUrn = linkedInArticleImageAssetUrn(
|
|
2213
|
+
vector.digitalmediaAsset,
|
|
2214
|
+
`${label}.imageBlock vector image`,
|
|
2215
|
+
);
|
|
2216
|
+
linkedInCanonicalHttpsUrl(vector.rootUrl, `${label}.imageBlock vector image rootUrl`);
|
|
2217
|
+
for (const [artifactIndex, rawArtifact] of vector.artifacts.entries()) {
|
|
2218
|
+
const artifactLabel = `${label}.imageBlock vector image artifacts[${artifactIndex}]`;
|
|
2219
|
+
const artifact = graphqlRecord(rawArtifact, artifactLabel);
|
|
2220
|
+
exactObjectKeys(
|
|
2221
|
+
artifact,
|
|
2222
|
+
["$type", "expiresAt", "fileIdentifyingUrlPathSegment", "height", "width"],
|
|
2223
|
+
artifactLabel,
|
|
2224
|
+
);
|
|
2225
|
+
if (
|
|
2226
|
+
artifact.$type !== LINKEDIN_VECTOR_ARTIFACT_TYPE
|
|
2227
|
+
|| !Number.isSafeInteger(artifact.expiresAt)
|
|
2228
|
+
|| (artifact.expiresAt as number) < 0
|
|
2229
|
+
|| !Number.isSafeInteger(artifact.height)
|
|
2230
|
+
|| (artifact.height as number) < 1
|
|
2231
|
+
|| !Number.isSafeInteger(artifact.width)
|
|
2232
|
+
|| (artifact.width as number) < 1
|
|
2233
|
+
|| typeof artifact.fileIdentifyingUrlPathSegment !== "string"
|
|
2234
|
+
|| artifact.fileIdentifyingUrlPathSegment.length < 1
|
|
2235
|
+
|| artifact.fileIdentifyingUrlPathSegment.length > 4_096
|
|
2236
|
+
|| /[\0\r\n]/u.test(artifact.fileIdentifyingUrlPathSegment)
|
|
2237
|
+
) throw new Error(`${artifactLabel} changed its reviewed shape`);
|
|
2238
|
+
}
|
|
2239
|
+
return Object.freeze({
|
|
2240
|
+
assetUrn,
|
|
2241
|
+
block: Object.freeze({
|
|
2242
|
+
type: "image",
|
|
2243
|
+
imageIndex,
|
|
2244
|
+
altText: content.accessibilityText,
|
|
2245
|
+
...(captionModel.text === "" ? {} : { caption: captionModel.text }),
|
|
2246
|
+
}),
|
|
2247
|
+
});
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
function normalizeLinkedInCoverImageViewModel(
|
|
2251
|
+
value: unknown,
|
|
2252
|
+
label: string,
|
|
2253
|
+
requireAssetUrn: boolean,
|
|
2254
|
+
): string | null {
|
|
2255
|
+
const image = graphqlRecord(value, label);
|
|
2256
|
+
exactObjectKeys(image, ["$type", "attributes"], label);
|
|
2257
|
+
if (
|
|
2258
|
+
image.$type !== LINKEDIN_IMAGE_VIEW_MODEL_TYPE
|
|
2259
|
+
|| !Array.isArray(image.attributes)
|
|
2260
|
+
|| image.attributes.length !== 1
|
|
2261
|
+
) throw new Error(`${label} changed its reviewed image shape`);
|
|
2262
|
+
const attribute = graphqlRecord(image.attributes[0], `${label}.attributes[0]`);
|
|
2263
|
+
exactObjectKeys(attribute, ["$type", "detailDataUnion"], `${label}.attributes[0]`);
|
|
2264
|
+
if (attribute.$type !== LINKEDIN_IMAGE_ATTRIBUTE_TYPE) {
|
|
2265
|
+
throw new Error(`${label} changed its reviewed image attribute`);
|
|
2266
|
+
}
|
|
2267
|
+
const detail = graphqlRecord(
|
|
2268
|
+
attribute.detailDataUnion,
|
|
2269
|
+
`${label}.attributes[0].detailDataUnion`,
|
|
2270
|
+
);
|
|
2271
|
+
exactObjectKeys(detail, ["vectorImage"], `${label}.attributes[0].detailDataUnion`);
|
|
2272
|
+
const vector = graphqlRecord(
|
|
2273
|
+
detail.vectorImage,
|
|
2274
|
+
`${label}.attributes[0].detailDataUnion.vectorImage`,
|
|
2275
|
+
);
|
|
2276
|
+
exactObjectKeys(
|
|
2277
|
+
vector,
|
|
2278
|
+
requireAssetUrn
|
|
2279
|
+
? ["$type", "artifacts", "digitalmediaAsset", "rootUrl"]
|
|
2280
|
+
: ["$type", "artifacts", "rootUrl"],
|
|
2281
|
+
`${label}.attributes[0].detailDataUnion.vectorImage`,
|
|
2282
|
+
);
|
|
2283
|
+
if (
|
|
2284
|
+
vector.$type !== LINKEDIN_VECTOR_IMAGE_TYPE
|
|
2285
|
+
|| !Array.isArray(vector.artifacts)
|
|
2286
|
+
|| vector.artifacts.length < 1
|
|
2287
|
+
|| vector.artifacts.length > 20
|
|
2288
|
+
) throw new Error(`${label} changed its reviewed vector image shape`);
|
|
2289
|
+
linkedInCanonicalHttpsUrl(vector.rootUrl, `${label}.vectorImage.rootUrl`);
|
|
2290
|
+
for (const [artifactIndex, rawArtifact] of vector.artifacts.entries()) {
|
|
2291
|
+
const artifactLabel = `${label}.vectorImage.artifacts[${artifactIndex}]`;
|
|
2292
|
+
const artifact = graphqlRecord(rawArtifact, artifactLabel);
|
|
2293
|
+
exactObjectKeys(
|
|
2294
|
+
artifact,
|
|
2295
|
+
["$type", "expiresAt", "fileIdentifyingUrlPathSegment", "height", "width"],
|
|
2296
|
+
artifactLabel,
|
|
2297
|
+
);
|
|
2298
|
+
if (
|
|
2299
|
+
artifact.$type !== LINKEDIN_VECTOR_ARTIFACT_TYPE
|
|
2300
|
+
|| !Number.isSafeInteger(artifact.expiresAt)
|
|
2301
|
+
|| (artifact.expiresAt as number) < 0
|
|
2302
|
+
|| !Number.isSafeInteger(artifact.height)
|
|
2303
|
+
|| (artifact.height as number) < 1
|
|
2304
|
+
|| !Number.isSafeInteger(artifact.width)
|
|
2305
|
+
|| (artifact.width as number) < 1
|
|
2306
|
+
|| typeof artifact.fileIdentifyingUrlPathSegment !== "string"
|
|
2307
|
+
|| artifact.fileIdentifyingUrlPathSegment.length < 1
|
|
2308
|
+
|| artifact.fileIdentifyingUrlPathSegment.length > 4_096
|
|
2309
|
+
|| /[\0\r\n]/u.test(artifact.fileIdentifyingUrlPathSegment)
|
|
2310
|
+
) throw new Error(`${artifactLabel} changed its reviewed shape`);
|
|
2311
|
+
}
|
|
2312
|
+
return requireAssetUrn
|
|
2313
|
+
? linkedInArticleImageAssetUrn(vector.digitalmediaAsset, `${label}.vectorImage`)
|
|
2314
|
+
: null;
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
function normalizeLinkedInArticleCover(
|
|
2318
|
+
coverMediaValue: unknown,
|
|
2319
|
+
coverMediaV2UnionValue: unknown,
|
|
2320
|
+
): string | null {
|
|
2321
|
+
if (coverMediaValue === null && coverMediaV2UnionValue === null) return null;
|
|
2322
|
+
if (coverMediaValue === null || coverMediaV2UnionValue === null) {
|
|
2323
|
+
throw new Error("LinkedIn Article cover readback omitted one reviewed cover projection");
|
|
2324
|
+
}
|
|
2325
|
+
const legacy = graphqlRecord(coverMediaValue, "LinkedIn Article coverMedia");
|
|
2326
|
+
exactObjectKeys(
|
|
2327
|
+
legacy,
|
|
2328
|
+
["$type", "caption", "originalImage", "originalImageUrn"],
|
|
2329
|
+
"LinkedIn Article coverMedia",
|
|
2330
|
+
);
|
|
2331
|
+
if (legacy.$type !== LINKEDIN_COVER_IMAGE_TYPE) {
|
|
2332
|
+
throw new Error("LinkedIn Article coverMedia changed its reviewed type");
|
|
2333
|
+
}
|
|
2334
|
+
const legacyAssetUrn = linkedInArticleImageAssetUrn(
|
|
2335
|
+
legacy.originalImageUrn,
|
|
2336
|
+
"LinkedIn Article coverMedia.originalImageUrn",
|
|
2337
|
+
);
|
|
2338
|
+
normalizeLinkedInCoverImageViewModel(
|
|
2339
|
+
legacy.originalImage,
|
|
2340
|
+
"LinkedIn Article coverMedia.originalImage",
|
|
2341
|
+
false,
|
|
2342
|
+
);
|
|
2343
|
+
const caption = graphqlRecord(legacy.caption, "LinkedIn Article coverMedia.caption");
|
|
2344
|
+
exactObjectKeys(
|
|
2345
|
+
caption,
|
|
2346
|
+
["$type", "attributesV2", "text", "textDirection"],
|
|
2347
|
+
"LinkedIn Article coverMedia.caption",
|
|
2348
|
+
);
|
|
2349
|
+
if (
|
|
2350
|
+
caption.$type !== LINKEDIN_TEXT_VIEW_MODEL_TYPE
|
|
2351
|
+
|| !Array.isArray(caption.attributesV2)
|
|
2352
|
+
|| caption.attributesV2.length !== 0
|
|
2353
|
+
|| caption.text !== ""
|
|
2354
|
+
|| caption.textDirection !== "USER_LOCALE"
|
|
2355
|
+
) throw new Error("LinkedIn Article cover caption changed its reviewed empty shape");
|
|
2356
|
+
|
|
2357
|
+
const union = graphqlRecord(
|
|
2358
|
+
coverMediaV2UnionValue,
|
|
2359
|
+
"LinkedIn Article coverMediaV2Union",
|
|
2360
|
+
);
|
|
2361
|
+
exactObjectKeys(union, ["coverImage"], "LinkedIn Article coverMediaV2Union");
|
|
2362
|
+
const cover = graphqlRecord(
|
|
2363
|
+
union.coverImage,
|
|
2364
|
+
"LinkedIn Article coverMediaV2Union.coverImage",
|
|
2365
|
+
);
|
|
2366
|
+
exactObjectKeys(
|
|
2367
|
+
cover,
|
|
2368
|
+
["$type", "originalImage", "originalImageUrn"],
|
|
2369
|
+
"LinkedIn Article coverMediaV2Union.coverImage",
|
|
2370
|
+
);
|
|
2371
|
+
if (cover.$type !== LINKEDIN_COVER_IMAGE_TYPE) {
|
|
2372
|
+
throw new Error("LinkedIn Article coverMediaV2Union changed its reviewed type");
|
|
2373
|
+
}
|
|
2374
|
+
const unionAssetUrn = linkedInArticleImageAssetUrn(
|
|
2375
|
+
cover.originalImageUrn,
|
|
2376
|
+
"LinkedIn Article coverMediaV2Union.coverImage.originalImageUrn",
|
|
2377
|
+
);
|
|
2378
|
+
const vectorAssetUrn = normalizeLinkedInCoverImageViewModel(
|
|
2379
|
+
cover.originalImage,
|
|
2380
|
+
"LinkedIn Article coverMediaV2Union.coverImage.originalImage",
|
|
2381
|
+
true,
|
|
2382
|
+
);
|
|
2383
|
+
if (legacyAssetUrn !== unionAssetUrn || unionAssetUrn !== vectorAssetUrn) {
|
|
2384
|
+
throw new Error("LinkedIn Article cover projections no longer bind one exact asset");
|
|
2385
|
+
}
|
|
2386
|
+
return unionAssetUrn;
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
type LinkedInArticleNormalizedDocument =
|
|
2390
|
+
| ArticleDraftDocument
|
|
2391
|
+
| ArticleDraftDocumentV2;
|
|
2392
|
+
|
|
2393
|
+
type LinkedInArticleNormalizedSnapshot = {
|
|
2394
|
+
readonly draftId: string;
|
|
2395
|
+
readonly title: string;
|
|
2396
|
+
readonly document: LinkedInArticleNormalizedDocument | null;
|
|
2397
|
+
readonly profileUrn: string;
|
|
2398
|
+
readonly coverAssetUrn: string | null;
|
|
2399
|
+
readonly imageAssetUrns: readonly string[];
|
|
2400
|
+
};
|
|
2401
|
+
|
|
2402
|
+
function normalizeLinkedInArticleDraftValue(
|
|
2403
|
+
value: unknown,
|
|
2404
|
+
draftIdValue: unknown,
|
|
2405
|
+
profileUrnValue: unknown,
|
|
2406
|
+
allowEmptyContent: boolean,
|
|
2407
|
+
schemaVersion: 1 | 2 = 1,
|
|
2408
|
+
metadataOnly = false,
|
|
2409
|
+
normalizeDocument = true,
|
|
2410
|
+
): LinkedInArticleNormalizedSnapshot {
|
|
2411
|
+
const draftId = linkedInArticleDraftId(draftIdValue);
|
|
2412
|
+
const profileUrn = linkedInArticleProfileUrn(profileUrnValue);
|
|
2413
|
+
const urn = linkedInArticleDraftUrn(draftId);
|
|
2414
|
+
const normalized = normalizeLinkedInGraphqlEnvelope(value);
|
|
2415
|
+
exactObjectKeys(
|
|
2416
|
+
normalized.data,
|
|
2417
|
+
["$type", "*elements", "entityUrn", "paging"],
|
|
2418
|
+
"LinkedIn Article response.data",
|
|
2419
|
+
);
|
|
2420
|
+
if (normalized.data.$type !== LINKEDIN_ARTICLE_COLLECTION_TYPE) {
|
|
2421
|
+
throw new Error("LinkedIn Article readback changed its collection response type");
|
|
2422
|
+
}
|
|
2423
|
+
linkedInUrn(
|
|
2424
|
+
normalized.data.entityUrn,
|
|
2425
|
+
"LinkedIn Article readback collection entityUrn",
|
|
2426
|
+
);
|
|
2427
|
+
const paging = graphqlRecord(
|
|
2428
|
+
normalized.data.paging,
|
|
2429
|
+
"LinkedIn Article response.data.paging",
|
|
2430
|
+
);
|
|
2431
|
+
exactObjectKeys(
|
|
2432
|
+
paging,
|
|
2433
|
+
["count", "links", "start"],
|
|
2434
|
+
"LinkedIn Article response.data.paging",
|
|
2435
|
+
);
|
|
2436
|
+
if (
|
|
2437
|
+
nonnegativeInteger(paging.count, "LinkedIn Article response.data.paging.count") !== 10
|
|
2438
|
+
|| nonnegativeInteger(paging.start, "LinkedIn Article response.data.paging.start") !== 0
|
|
2439
|
+
|| !Array.isArray(paging.links)
|
|
2440
|
+
|| paging.links.length !== 0
|
|
2441
|
+
) throw new Error("LinkedIn Article readback changed its exact draft paging boundary");
|
|
2442
|
+
if (
|
|
2443
|
+
!Array.isArray(normalized.data["*elements"])
|
|
2444
|
+
|| normalized.data["*elements"].length !== 1
|
|
2445
|
+
|| normalized.data["*elements"][0] !== urn
|
|
2446
|
+
) throw new Error("LinkedIn Article readback did not select the exact draft");
|
|
2447
|
+
const article = normalized.entitiesByUrn.get(urn);
|
|
2448
|
+
if (article === undefined) throw new Error("LinkedIn Article readback omitted the exact draft entity");
|
|
2449
|
+
exactObjectKeys(article, [
|
|
2450
|
+
"$type",
|
|
2451
|
+
"activityUrn",
|
|
2452
|
+
"annotation",
|
|
2453
|
+
"annotationActionType",
|
|
2454
|
+
"articleActionUnions",
|
|
2455
|
+
"articleAnnotation",
|
|
2456
|
+
"articlePublishedTimeDescription",
|
|
2457
|
+
"articleType",
|
|
2458
|
+
"authors",
|
|
2459
|
+
"availableLocales",
|
|
2460
|
+
"content",
|
|
2461
|
+
"contentDescription",
|
|
2462
|
+
"contentHtml",
|
|
2463
|
+
"contentSegments",
|
|
2464
|
+
"coverMedia",
|
|
2465
|
+
"coverMediaV2Union",
|
|
2466
|
+
"createdAt",
|
|
2467
|
+
"entityUrn",
|
|
2468
|
+
"featured",
|
|
2469
|
+
"followingStateUrn",
|
|
2470
|
+
"gatedArticleMetadata",
|
|
2471
|
+
"initialUpdateUrn",
|
|
2472
|
+
"issueNumber",
|
|
2473
|
+
"linkedInArticleUrn",
|
|
2474
|
+
"locale",
|
|
2475
|
+
"memberContributionInsight",
|
|
2476
|
+
"permalink",
|
|
2477
|
+
"publishedAt",
|
|
2478
|
+
"scheduledAt",
|
|
2479
|
+
"seoDescription",
|
|
2480
|
+
"seoTitle",
|
|
2481
|
+
"series",
|
|
2482
|
+
"servedLocale",
|
|
2483
|
+
"socialDetailUrn",
|
|
2484
|
+
"socialProofInsight",
|
|
2485
|
+
"sponsoredAccountUrn",
|
|
2486
|
+
"state",
|
|
2487
|
+
"surveyComponent",
|
|
2488
|
+
"title",
|
|
2489
|
+
"trackingId",
|
|
2490
|
+
"ugcPostUrn",
|
|
2491
|
+
"updatedAt",
|
|
2492
|
+
"version",
|
|
2493
|
+
"viewerAllowedToEdit",
|
|
2494
|
+
], "LinkedIn Article readback entity");
|
|
2495
|
+
const nullFields = [
|
|
2496
|
+
"activityUrn",
|
|
2497
|
+
"annotation",
|
|
2498
|
+
"annotationActionType",
|
|
2499
|
+
"articleAnnotation",
|
|
2500
|
+
"articlePublishedTimeDescription",
|
|
2501
|
+
"contentDescription",
|
|
2502
|
+
"contentSegments",
|
|
2503
|
+
"featured",
|
|
2504
|
+
"gatedArticleMetadata",
|
|
2505
|
+
"initialUpdateUrn",
|
|
2506
|
+
"issueNumber",
|
|
2507
|
+
"locale",
|
|
2508
|
+
"memberContributionInsight",
|
|
2509
|
+
"permalink",
|
|
2510
|
+
"publishedAt",
|
|
2511
|
+
"scheduledAt",
|
|
2512
|
+
"seoDescription",
|
|
2513
|
+
"seoTitle",
|
|
2514
|
+
"series",
|
|
2515
|
+
"servedLocale",
|
|
2516
|
+
"socialDetailUrn",
|
|
2517
|
+
"socialProofInsight",
|
|
2518
|
+
"sponsoredAccountUrn",
|
|
2519
|
+
"surveyComponent",
|
|
2520
|
+
"trackingId",
|
|
2521
|
+
"ugcPostUrn",
|
|
2522
|
+
"viewerAllowedToEdit",
|
|
2523
|
+
] as const;
|
|
2524
|
+
if (nullFields.some((field) => article[field] !== null)) {
|
|
2525
|
+
throw new Error("LinkedIn Article readback was not the exact private unpublished draft");
|
|
2526
|
+
}
|
|
2527
|
+
const coverAssetUrn = normalizeLinkedInArticleCover(
|
|
2528
|
+
article.coverMedia,
|
|
2529
|
+
article.coverMediaV2Union,
|
|
2530
|
+
);
|
|
2531
|
+
if (
|
|
2532
|
+
article.$type !== LINKEDIN_ARTICLE_TYPE
|
|
2533
|
+
|| article.entityUrn !== urn
|
|
2534
|
+
|| article.linkedInArticleUrn !== `urn:li:linkedInArticle:${draftId}`
|
|
2535
|
+
|| article.state !== "DRAFT"
|
|
2536
|
+
|| article.articleType !== "FIRST_PARTY_ARTICLE"
|
|
2537
|
+
) throw new Error("LinkedIn Article readback was not the exact private unpublished draft");
|
|
2538
|
+
if (
|
|
2539
|
+
!Array.isArray(article.articleActionUnions)
|
|
2540
|
+
|| article.articleActionUnions.length !== 0
|
|
2541
|
+
|| !Array.isArray(article.availableLocales)
|
|
2542
|
+
|| article.availableLocales.length !== 0
|
|
2543
|
+
) throw new Error("LinkedIn Article readback added unsupported actions or locales");
|
|
2544
|
+
linkedInUrn(
|
|
2545
|
+
article.followingStateUrn,
|
|
2546
|
+
"LinkedIn Article readback followingStateUrn",
|
|
2547
|
+
);
|
|
2548
|
+
if (!Array.isArray(article.authors) || article.authors.length !== 1) {
|
|
2549
|
+
throw new Error("LinkedIn Article readback did not bind one exact author");
|
|
2550
|
+
}
|
|
2551
|
+
const author = graphqlRecord(article.authors[0], "LinkedIn Article readback author");
|
|
2552
|
+
exactObjectKeys(author, ["profileUrn"], "LinkedIn Article readback author");
|
|
2553
|
+
if (author.profileUrn !== profileUrn) {
|
|
2554
|
+
throw new Error("LinkedIn Article readback author no longer matches the current member");
|
|
2555
|
+
}
|
|
2556
|
+
const title = boundedText(article.title, "LinkedIn Article readback title", 150);
|
|
2557
|
+
if (
|
|
2558
|
+
(article.contentHtml !== null && (typeof article.contentHtml !== "string" || article.contentHtml.length > 524_288))
|
|
2559
|
+
|| !Array.isArray(article.content)
|
|
2560
|
+
|| (!allowEmptyContent && article.content.length < 1)
|
|
2561
|
+
|| article.content.length > 5_000
|
|
2562
|
+
) throw new Error("LinkedIn Article readback content exceeded its reviewed bounds");
|
|
2563
|
+
nonnegativeInteger(article.createdAt, "LinkedIn Article readback createdAt");
|
|
2564
|
+
nonnegativeInteger(article.updatedAt, "LinkedIn Article readback updatedAt");
|
|
2565
|
+
nonnegativeInteger(article.version, "LinkedIn Article readback version");
|
|
2566
|
+
const imageAssetUrns: string[] = [];
|
|
2567
|
+
const blocks = metadataOnly || !normalizeDocument
|
|
2568
|
+
? []
|
|
2569
|
+
: article.content.map((block, index) => {
|
|
2570
|
+
if (schemaVersion === 1) return normalizeLinkedInArticleBlock(block, index);
|
|
2571
|
+
const wrapper = graphqlRecord(block, `LinkedIn Article content[${index}]`);
|
|
2572
|
+
if (Object.hasOwn(wrapper, "imageBlock")) {
|
|
2573
|
+
const normalized = normalizeLinkedInArticleImageBlock(
|
|
2574
|
+
block,
|
|
2575
|
+
index,
|
|
2576
|
+
imageAssetUrns.length,
|
|
2577
|
+
);
|
|
2578
|
+
imageAssetUrns.push(normalized.assetUrn);
|
|
2579
|
+
return normalized.block;
|
|
2580
|
+
}
|
|
2581
|
+
return normalizeLinkedInArticleBlock(block, index);
|
|
2582
|
+
});
|
|
2583
|
+
if (
|
|
2584
|
+
schemaVersion === 2
|
|
2585
|
+
&& blocks.length >= 2
|
|
2586
|
+
&& blocks.at(-2)?.type === "image"
|
|
2587
|
+
&& blocks.at(-1)?.type === "paragraph"
|
|
2588
|
+
&& (blocks.at(-1) as ArticleDraftTextBlock).text === ""
|
|
2589
|
+
&& (blocks.at(-1) as ArticleDraftTextBlock).links.length === 0
|
|
2590
|
+
&& (blocks.at(-1) as ArticleDraftTextBlock).styles.length === 0
|
|
2591
|
+
) blocks.pop();
|
|
2592
|
+
return Object.freeze({
|
|
2593
|
+
draftId,
|
|
2594
|
+
title,
|
|
2595
|
+
profileUrn,
|
|
2596
|
+
coverAssetUrn,
|
|
2597
|
+
document: metadataOnly || blocks.length === 0
|
|
2598
|
+
? null
|
|
2599
|
+
: Object.freeze({ schemaVersion, blocks: Object.freeze(blocks) }) as LinkedInArticleNormalizedDocument,
|
|
2600
|
+
imageAssetUrns: Object.freeze(imageAssetUrns),
|
|
2601
|
+
});
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
export type LinkedInArticleDraftV2Metadata = Readonly<{
|
|
2605
|
+
draftId: string;
|
|
2606
|
+
title: string;
|
|
2607
|
+
profileUrn: string;
|
|
2608
|
+
coverAssetUrn: string | null;
|
|
2609
|
+
}>;
|
|
2610
|
+
|
|
2611
|
+
/** Verify owner, private lifecycle, title, and cover without trusting stale body blocks. */
|
|
2612
|
+
export function normalizeLinkedInArticleDraftV2Metadata(
|
|
2613
|
+
value: unknown,
|
|
2614
|
+
draftIdValue: unknown,
|
|
2615
|
+
profileUrnValue: unknown,
|
|
2616
|
+
): LinkedInArticleDraftV2Metadata {
|
|
2617
|
+
const normalized = normalizeLinkedInArticleDraftValue(
|
|
2618
|
+
value,
|
|
2619
|
+
draftIdValue,
|
|
2620
|
+
profileUrnValue,
|
|
2621
|
+
true,
|
|
2622
|
+
2,
|
|
2623
|
+
true,
|
|
2624
|
+
false,
|
|
2625
|
+
);
|
|
2626
|
+
return Object.freeze({
|
|
2627
|
+
draftId: normalized.draftId,
|
|
2628
|
+
title: normalized.title,
|
|
2629
|
+
profileUrn: normalized.profileUrn,
|
|
2630
|
+
coverAssetUrn: normalized.coverAssetUrn,
|
|
2631
|
+
});
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
/** Normalize an owner-bound private draft before its first content autosave. */
|
|
2635
|
+
export function normalizeLinkedInArticleDraftSnapshot(
|
|
2636
|
+
value: unknown,
|
|
2637
|
+
draftIdValue: unknown,
|
|
2638
|
+
profileUrnValue: unknown,
|
|
2639
|
+
): LinkedInArticleDraftSnapshot {
|
|
2640
|
+
const normalized = normalizeLinkedInArticleDraftValue(
|
|
2641
|
+
value,
|
|
2642
|
+
draftIdValue,
|
|
2643
|
+
profileUrnValue,
|
|
2644
|
+
true,
|
|
2645
|
+
);
|
|
2646
|
+
if (normalized.document !== null && normalized.document.schemaVersion !== 1) {
|
|
2647
|
+
throw new Error("LinkedIn Article readback changed its text-only schema version");
|
|
2648
|
+
}
|
|
2649
|
+
return Object.freeze({
|
|
2650
|
+
draftId: normalized.draftId,
|
|
2651
|
+
title: normalized.title,
|
|
2652
|
+
profileUrn: normalized.profileUrn,
|
|
2653
|
+
document: normalized.document,
|
|
2654
|
+
});
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
/** Normalize only the exact owner/private/title binding before create verification or replacement. */
|
|
2658
|
+
export function normalizeLinkedInArticleDraftMetadata(
|
|
2659
|
+
value: unknown,
|
|
2660
|
+
draftIdValue: unknown,
|
|
2661
|
+
profileUrnValue: unknown,
|
|
2662
|
+
): Readonly<Omit<LinkedInArticleDraftReadback, "document">> {
|
|
2663
|
+
const normalized = normalizeLinkedInArticleDraftValue(
|
|
2664
|
+
value,
|
|
2665
|
+
draftIdValue,
|
|
2666
|
+
profileUrnValue,
|
|
2667
|
+
true,
|
|
2668
|
+
1,
|
|
2669
|
+
true,
|
|
2670
|
+
);
|
|
2671
|
+
return Object.freeze({
|
|
2672
|
+
draftId: normalized.draftId,
|
|
2673
|
+
profileUrn: normalized.profileUrn,
|
|
2674
|
+
title: normalized.title,
|
|
2675
|
+
});
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
/** Normalize one exact owner-bound, unpublished native Article readback. */
|
|
2679
|
+
export function normalizeLinkedInArticleDraft(
|
|
2680
|
+
value: unknown,
|
|
2681
|
+
draftIdValue: unknown,
|
|
2682
|
+
profileUrnValue: unknown,
|
|
2683
|
+
): LinkedInArticleDraftReadback {
|
|
2684
|
+
const normalized = normalizeLinkedInArticleDraftValue(
|
|
2685
|
+
value,
|
|
2686
|
+
draftIdValue,
|
|
2687
|
+
profileUrnValue,
|
|
2688
|
+
false,
|
|
2689
|
+
);
|
|
2690
|
+
if (
|
|
2691
|
+
normalized.document === null
|
|
2692
|
+
|| normalized.document.schemaVersion !== 1
|
|
2693
|
+
) {
|
|
2694
|
+
throw new Error("LinkedIn Article readback omitted its confirmed document");
|
|
2695
|
+
}
|
|
2696
|
+
return Object.freeze({
|
|
2697
|
+
draftId: normalized.draftId,
|
|
2698
|
+
profileUrn: normalized.profileUrn,
|
|
2699
|
+
title: normalized.title,
|
|
2700
|
+
document: normalized.document,
|
|
2701
|
+
});
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
/** Normalize one exact owner-bound image-capable private Article snapshot. */
|
|
2705
|
+
export function normalizeLinkedInArticleDraftV2Snapshot(
|
|
2706
|
+
value: unknown,
|
|
2707
|
+
draftIdValue: unknown,
|
|
2708
|
+
profileUrnValue: unknown,
|
|
2709
|
+
): LinkedInArticleDraftV2Snapshot {
|
|
2710
|
+
const normalized = normalizeLinkedInArticleDraftValue(
|
|
2711
|
+
value,
|
|
2712
|
+
draftIdValue,
|
|
2713
|
+
profileUrnValue,
|
|
2714
|
+
true,
|
|
2715
|
+
2,
|
|
2716
|
+
);
|
|
2717
|
+
if (normalized.document !== null && normalized.document.schemaVersion !== 2) {
|
|
2718
|
+
throw new Error("LinkedIn Article readback changed its image-capable schema version");
|
|
2719
|
+
}
|
|
2720
|
+
return Object.freeze({
|
|
2721
|
+
draftId: normalized.draftId,
|
|
2722
|
+
title: normalized.title,
|
|
2723
|
+
profileUrn: normalized.profileUrn,
|
|
2724
|
+
document: normalized.document,
|
|
2725
|
+
coverAssetUrn: normalized.coverAssetUrn,
|
|
2726
|
+
imageAssetUrns: normalized.imageAssetUrns,
|
|
2727
|
+
});
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
/** Normalize exact text/image order, alt text, captions, and provider assets. */
|
|
2731
|
+
export function normalizeLinkedInArticleDraftV2(
|
|
2732
|
+
value: unknown,
|
|
2733
|
+
draftIdValue: unknown,
|
|
2734
|
+
profileUrnValue: unknown,
|
|
2735
|
+
): LinkedInArticleDraftV2Readback {
|
|
2736
|
+
const normalized = normalizeLinkedInArticleDraftV2Snapshot(
|
|
2737
|
+
value,
|
|
2738
|
+
draftIdValue,
|
|
2739
|
+
profileUrnValue,
|
|
2740
|
+
);
|
|
2741
|
+
if (normalized.document === null) {
|
|
2742
|
+
throw new Error("LinkedIn Article readback omitted its confirmed document");
|
|
2743
|
+
}
|
|
2744
|
+
return Object.freeze({
|
|
2745
|
+
draftId: normalized.draftId,
|
|
2746
|
+
title: normalized.title,
|
|
2747
|
+
profileUrn: normalized.profileUrn,
|
|
2748
|
+
document: normalized.document,
|
|
2749
|
+
coverAssetUrn: normalized.coverAssetUrn,
|
|
2750
|
+
imageAssetUrns: normalized.imageAssetUrns,
|
|
2751
|
+
});
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
export function linkedInMessengerConversationsUrl(
|
|
2755
|
+
mailboxUrnValue: unknown,
|
|
2756
|
+
queryIdValue: unknown = LINKEDIN_MESSENGER_CONVERSATIONS_OBSERVED_QUERY_ID,
|
|
2757
|
+
): URL {
|
|
2758
|
+
const mailboxUrn = boundedText(mailboxUrnValue, "LinkedIn mailbox URN", 512);
|
|
2759
|
+
if (!/^urn:li:fsd_profile:[A-Za-z0-9_-]{1,256}$/u.test(mailboxUrn)) {
|
|
2760
|
+
throw new Error("LinkedIn mailbox URN is invalid");
|
|
2761
|
+
}
|
|
2762
|
+
const queryId = resolveLinkedInRegisteredQueryId(
|
|
2763
|
+
LINKEDIN_MESSENGER_CONVERSATIONS_QUERY_PREFIX,
|
|
2764
|
+
[queryIdValue],
|
|
2765
|
+
);
|
|
2766
|
+
const url = new URL(LINKEDIN_MESSENGER_GRAPHQL_PATH, "https://www.linkedin.com");
|
|
2767
|
+
url.searchParams.set("queryId", queryId);
|
|
2768
|
+
url.searchParams.set("variables", `(mailboxUrn:${mailboxUrn})`);
|
|
2769
|
+
return url;
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2772
|
+
export type LinkedInPostVisibility = "public" | "connections";
|
|
2773
|
+
|
|
2774
|
+
export type LinkedInPostCreateInput = {
|
|
2775
|
+
readonly body: string;
|
|
2776
|
+
readonly visibility: LinkedInPostVisibility;
|
|
2777
|
+
readonly mediaUrn: string | null;
|
|
2778
|
+
readonly altText: string | null;
|
|
2779
|
+
};
|
|
2780
|
+
|
|
2781
|
+
export type LinkedInPostProjection = {
|
|
2782
|
+
readonly entityUrn: string;
|
|
2783
|
+
readonly mediaUrn: string | null;
|
|
2784
|
+
readonly url: string;
|
|
2785
|
+
};
|
|
2786
|
+
|
|
2787
|
+
export function linkedInPostText(value: unknown): string {
|
|
2788
|
+
if (
|
|
2789
|
+
typeof value !== "string"
|
|
2790
|
+
|| value.length < 1
|
|
2791
|
+
|| value.length > 3_000
|
|
2792
|
+
|| /\0/u.test(value)
|
|
2793
|
+
) throw new Error("LinkedIn post body must be 1-3000 characters without NUL");
|
|
2794
|
+
return value;
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2797
|
+
export function linkedInPostVisibility(value: unknown): LinkedInPostVisibility {
|
|
2798
|
+
if (value !== "public" && value !== "connections") {
|
|
2799
|
+
throw new Error("LinkedIn post visibility must be public or connections");
|
|
2800
|
+
}
|
|
2801
|
+
return value;
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2804
|
+
export function linkedInPostAltText(value: unknown, mediaPresent: boolean): string | null {
|
|
2805
|
+
if (value === undefined) return null;
|
|
2806
|
+
if (!mediaPresent) throw new Error("LinkedIn alt_text requires one reviewed image");
|
|
2807
|
+
if (
|
|
2808
|
+
typeof value !== "string"
|
|
2809
|
+
|| value.length < 1
|
|
2810
|
+
|| value.length > 4_000
|
|
2811
|
+
|| /\0/u.test(value)
|
|
2812
|
+
) throw new Error("LinkedIn image alt_text must be 1-4000 characters without NUL");
|
|
2813
|
+
return value;
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2816
|
+
export function linkedInPostMediaUrn(value: unknown): string {
|
|
2817
|
+
if (
|
|
2818
|
+
typeof value !== "string"
|
|
2819
|
+
|| value.length > 512
|
|
2820
|
+
|| !/^urn:li:(?:digitalmediaAsset|fsd_image):[A-Za-z0-9_(),.:%=-]{1,448}$/u.test(value)
|
|
2821
|
+
) throw new Error("LinkedIn image upload returned an invalid media URN");
|
|
2822
|
+
return value;
|
|
2823
|
+
}
|
|
2824
|
+
|
|
2825
|
+
export function linkedInPostEntityUrn(value: unknown): string {
|
|
2826
|
+
if (
|
|
2827
|
+
typeof value !== "string"
|
|
2828
|
+
|| value.length > 512
|
|
2829
|
+
|| !/^urn:li:(?:fsd_share|share|ugcPost):[A-Za-z0-9_(),.:%=-]{1,448}$/u.test(value)
|
|
2830
|
+
) throw new Error("LinkedIn post response returned an invalid entity URN");
|
|
2831
|
+
return value;
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
/** Exact first-party post-create variable projection observed in the current ShareCreateRequest bundle. */
|
|
2835
|
+
export function buildLinkedInPostCreateVariables(
|
|
2836
|
+
input: LinkedInPostCreateInput,
|
|
2837
|
+
): Readonly<Record<string, unknown>> {
|
|
2838
|
+
const body = linkedInPostText(input.body);
|
|
2839
|
+
const visibility = linkedInPostVisibility(input.visibility);
|
|
2840
|
+
const mediaUrn = input.mediaUrn === null ? null : linkedInPostMediaUrn(input.mediaUrn);
|
|
2841
|
+
const altText = linkedInPostAltText(input.altText ?? undefined, mediaUrn !== null);
|
|
2842
|
+
const post: Record<string, unknown> = {
|
|
2843
|
+
allowedCommentersScope: "ALL",
|
|
2844
|
+
commentary: {
|
|
2845
|
+
$type: "com.linkedin.voyager.dash.deco.common.text.TextViewModelV2",
|
|
2846
|
+
attributesV2: [],
|
|
2847
|
+
text: body,
|
|
2848
|
+
},
|
|
2849
|
+
intendedShareLifeCycleState: "PUBLISHED",
|
|
2850
|
+
origin: "FEED",
|
|
2851
|
+
paidEndorsement: false,
|
|
2852
|
+
visibilityDataUnion: {
|
|
2853
|
+
visibilityType: visibility === "public" ? "ANYONE" : "CONNECTIONS_ONLY",
|
|
2854
|
+
},
|
|
2855
|
+
};
|
|
2856
|
+
if (mediaUrn !== null) {
|
|
2857
|
+
post.media = {
|
|
2858
|
+
category: "IMAGE",
|
|
2859
|
+
mediaUrn,
|
|
2860
|
+
tapTargets: [],
|
|
2861
|
+
...(altText === null ? {} : { altText }),
|
|
2862
|
+
};
|
|
2863
|
+
}
|
|
2864
|
+
return Object.freeze({ post: Object.freeze(post) });
|
|
2865
|
+
}
|
|
2866
|
+
|
|
2867
|
+
/** Exact current registered GraphQL readback for one response-bound backend share URN. */
|
|
2868
|
+
export function linkedInPostReadbackUrl(entityUrnValue: unknown): URL {
|
|
2869
|
+
const entityUrn = linkedInPostEntityUrn(entityUrnValue);
|
|
2870
|
+
const url = new URL(LINKEDIN_GRAPHQL_PATH, "https://www.linkedin.com");
|
|
2871
|
+
url.searchParams.set("includeWebMetadata", "true");
|
|
2872
|
+
url.searchParams.set("queryId", LINKEDIN_POST_READBACK_QUERY_ID);
|
|
2873
|
+
url.searchParams.set(
|
|
2874
|
+
"variables",
|
|
2875
|
+
`(moduleKey:feed-item:desktop,urnOrNss:${entityUrn})`,
|
|
2876
|
+
);
|
|
2877
|
+
return url;
|
|
2878
|
+
}
|
|
2879
|
+
|
|
2880
|
+
/** Parse the minimal code-owned browser projection after create plus independent readback. */
|
|
2881
|
+
export function normalizeLinkedInPostProjection(
|
|
2882
|
+
value: unknown,
|
|
2883
|
+
expected: {
|
|
2884
|
+
readonly body: string;
|
|
2885
|
+
readonly profileUrn: string;
|
|
2886
|
+
readonly mediaUrn: string | null;
|
|
2887
|
+
},
|
|
2888
|
+
): LinkedInPostProjection {
|
|
2889
|
+
const projection = isRecord(value) ? value : null;
|
|
2890
|
+
if (projection === null) throw new Error("LinkedIn post browser returned an invalid projection");
|
|
2891
|
+
exactObjectKeys(
|
|
2892
|
+
projection,
|
|
2893
|
+
[
|
|
2894
|
+
"actorMatched",
|
|
2895
|
+
"entityMatched",
|
|
2896
|
+
"entityUrn",
|
|
2897
|
+
"lifecycle",
|
|
2898
|
+
"mediaMatched",
|
|
2899
|
+
"mediaUrn",
|
|
2900
|
+
"textMatched",
|
|
2901
|
+
"url",
|
|
2902
|
+
],
|
|
2903
|
+
"LinkedIn post browser projection",
|
|
2904
|
+
);
|
|
2905
|
+
linkedInPostText(expected.body);
|
|
2906
|
+
if (!/^urn:li:fsd_profile:[A-Za-z0-9_-]{1,256}$/u.test(expected.profileUrn)) {
|
|
2907
|
+
throw new Error("LinkedIn post expected profile binding is invalid");
|
|
2908
|
+
}
|
|
2909
|
+
const entityUrn = linkedInPostEntityUrn(projection.entityUrn);
|
|
2910
|
+
const mediaUrn = projection.mediaUrn === null
|
|
2911
|
+
? null
|
|
2912
|
+
: linkedInPostMediaUrn(projection.mediaUrn);
|
|
2913
|
+
if (
|
|
2914
|
+
projection.lifecycle !== "PUBLISHED"
|
|
2915
|
+
|| projection.actorMatched !== true
|
|
2916
|
+
|| projection.entityMatched !== true
|
|
2917
|
+
|| projection.textMatched !== true
|
|
2918
|
+
|| projection.mediaMatched !== (expected.mediaUrn !== null)
|
|
2919
|
+
|| mediaUrn !== expected.mediaUrn
|
|
2920
|
+
) throw new Error("LinkedIn independent post readback did not bind the confirmed post");
|
|
2921
|
+
if (typeof projection.url !== "string" || projection.url.length > 2_048) {
|
|
2922
|
+
throw new Error("LinkedIn post browser returned an invalid permalink");
|
|
2923
|
+
}
|
|
2924
|
+
const url = new URL(projection.url);
|
|
2925
|
+
if (
|
|
2926
|
+
url.origin !== "https://www.linkedin.com"
|
|
2927
|
+
|| url.username !== ""
|
|
2928
|
+
|| url.password !== ""
|
|
2929
|
+
|| url.hash !== ""
|
|
2930
|
+
|| !url.pathname.startsWith("/feed/update/")
|
|
2931
|
+
) throw new Error("LinkedIn post browser returned an unreviewed permalink");
|
|
2932
|
+
return Object.freeze({ entityUrn, mediaUrn, url: url.href });
|
|
2933
|
+
}
|
|
2934
|
+
|
|
2935
|
+
export function assertLinkedInMessengerConversationsRequest(
|
|
2936
|
+
requestValue: {
|
|
2937
|
+
readonly url: string | URL;
|
|
2938
|
+
readonly method: string;
|
|
2939
|
+
},
|
|
2940
|
+
expectedMailboxUrnValue: unknown,
|
|
2941
|
+
): URL {
|
|
2942
|
+
const url = requestValue.url instanceof URL ? new URL(requestValue.url.href) : new URL(requestValue.url);
|
|
2943
|
+
if (
|
|
2944
|
+
requestValue.method !== "GET"
|
|
2945
|
+
|| url.origin !== "https://www.linkedin.com"
|
|
2946
|
+
|| url.pathname !== LINKEDIN_MESSENGER_GRAPHQL_PATH
|
|
2947
|
+
|| url.username !== ""
|
|
2948
|
+
|| url.password !== ""
|
|
2949
|
+
|| url.hash !== ""
|
|
2950
|
+
) throw new Error("LinkedIn messenger-conversations request escaped its exact reviewed route");
|
|
2951
|
+
const queryNames = [...url.searchParams.keys()];
|
|
2952
|
+
if (
|
|
2953
|
+
queryNames.length !== 2
|
|
2954
|
+
|| queryNames[0] !== "queryId"
|
|
2955
|
+
|| queryNames[1] !== "variables"
|
|
2956
|
+
|| url.searchParams.getAll("queryId").length !== 1
|
|
2957
|
+
|| url.searchParams.getAll("variables").length !== 1
|
|
2958
|
+
) throw new Error("LinkedIn messenger-conversations request query shape is invalid");
|
|
2959
|
+
const expected = linkedInMessengerConversationsUrl(
|
|
2960
|
+
expectedMailboxUrnValue,
|
|
2961
|
+
url.searchParams.get("queryId"),
|
|
2962
|
+
);
|
|
2963
|
+
if (url.searchParams.get("variables") !== expected.searchParams.get("variables")) {
|
|
2964
|
+
throw new Error("LinkedIn messenger-conversations request mailbox binding changed");
|
|
2965
|
+
}
|
|
2966
|
+
return url;
|
|
2967
|
+
}
|
|
2968
|
+
|
|
2969
|
+
function exactStringList(value: unknown, label: string, maximumItems: number): readonly string[] {
|
|
2970
|
+
if (!Array.isArray(value) || value.length > maximumItems) {
|
|
2971
|
+
throw new Error(`${label} must be a bounded string array`);
|
|
2972
|
+
}
|
|
2973
|
+
const result = value.map((item, index) => boundedText(item, `${label}[${index}]`, 4_096));
|
|
2974
|
+
return Object.freeze(result);
|
|
2975
|
+
}
|
|
2976
|
+
|
|
2977
|
+
function displayNameFromPreview(value: unknown): string | null {
|
|
2978
|
+
if (!isRecord(value)) return null;
|
|
2979
|
+
const candidates = new Set<string>();
|
|
2980
|
+
let nodes = 0;
|
|
2981
|
+
const visit = (item: unknown, depth: number): void => {
|
|
2982
|
+
if (depth > 5 || nodes >= 256 || !isRecord(item)) return;
|
|
2983
|
+
nodes += 1;
|
|
2984
|
+
const first = typeof item.firstName === "string" ? item.firstName.trim() : "";
|
|
2985
|
+
const last = typeof item.lastName === "string" ? item.lastName.trim() : "";
|
|
2986
|
+
const combined = `${first} ${last}`.trim();
|
|
2987
|
+
if (combined.length > 0 && combined.length <= 256 && !/[\0\r]/u.test(combined)) candidates.add(combined);
|
|
2988
|
+
for (const key of ["name", "displayName"] as const) {
|
|
2989
|
+
const candidate = item[key];
|
|
2990
|
+
if (
|
|
2991
|
+
typeof candidate === "string"
|
|
2992
|
+
&& candidate.trim().length > 0
|
|
2993
|
+
&& candidate.length <= 256
|
|
2994
|
+
&& !/[\0\r]/u.test(candidate)
|
|
2995
|
+
) candidates.add(candidate.trim());
|
|
2996
|
+
}
|
|
2997
|
+
for (const child of Object.values(item)) visit(child, depth + 1);
|
|
2998
|
+
};
|
|
2999
|
+
visit(value, 0);
|
|
3000
|
+
return candidates.size === 1 ? candidates.values().next().value ?? null : null;
|
|
3001
|
+
}
|
|
3002
|
+
|
|
3003
|
+
function normalizedParticipant(
|
|
3004
|
+
entity: LinkedInWebJsonRecord,
|
|
3005
|
+
): LinkedInMessagingParticipant {
|
|
3006
|
+
if (entity.$type !== "com.linkedin.messenger.MessagingParticipant") {
|
|
3007
|
+
throw new Error("LinkedIn messaging participant reference resolved to the wrong entity type");
|
|
3008
|
+
}
|
|
3009
|
+
return Object.freeze({
|
|
3010
|
+
urn: linkedInUrn(entity.entityUrn ?? entity.backendUrn, "LinkedIn messaging participant URN"),
|
|
3011
|
+
identityUrn: entity.hostIdentityUrn === null || entity.hostIdentityUrn === undefined
|
|
3012
|
+
? null
|
|
3013
|
+
: linkedInUrn(entity.hostIdentityUrn, "LinkedIn messaging participant identity URN"),
|
|
3014
|
+
displayName: displayNameFromPreview(entity.preview),
|
|
3015
|
+
});
|
|
3016
|
+
}
|
|
3017
|
+
|
|
3018
|
+
function messageBody(entity: LinkedInWebJsonRecord): string {
|
|
3019
|
+
if (typeof entity.body === "string") return boundedText(entity.body, "LinkedIn message body", 32_768);
|
|
3020
|
+
if (isRecord(entity.body) && typeof entity.body.text === "string") {
|
|
3021
|
+
return boundedText(entity.body.text, "LinkedIn message body.text", 32_768);
|
|
3022
|
+
}
|
|
3023
|
+
if (typeof entity.renderContentFallbackText === "string") {
|
|
3024
|
+
return boundedText(entity.renderContentFallbackText, "LinkedIn message fallback text", 32_768);
|
|
3025
|
+
}
|
|
3026
|
+
return "";
|
|
3027
|
+
}
|
|
3028
|
+
|
|
3029
|
+
function normalizedMessage(
|
|
3030
|
+
entity: LinkedInWebJsonRecord,
|
|
3031
|
+
): LinkedInMessagingConversation["latestMessage"] {
|
|
3032
|
+
if (entity.$type !== "com.linkedin.messenger.Message") {
|
|
3033
|
+
throw new Error("LinkedIn message reference resolved to the wrong entity type");
|
|
3034
|
+
}
|
|
3035
|
+
const urn = linkedInUrn(entity.entityUrn ?? entity.backendUrn, "LinkedIn message URN");
|
|
3036
|
+
return Object.freeze({
|
|
3037
|
+
id: linkedInUrn(entity.backendUrn ?? entity.entityUrn, "LinkedIn message backend URN"),
|
|
3038
|
+
urn,
|
|
3039
|
+
deliveredAt: nonnegativeInteger(entity.deliveredAt, "LinkedIn message deliveredAt"),
|
|
3040
|
+
body: messageBody(entity),
|
|
3041
|
+
subject: optionalText(entity.subject, "LinkedIn message subject", 8_192),
|
|
3042
|
+
senderUrn: entity["*sender"] === null || entity["*sender"] === undefined
|
|
3043
|
+
? null
|
|
3044
|
+
: linkedInUrn(entity["*sender"], "LinkedIn message sender reference"),
|
|
3045
|
+
});
|
|
3046
|
+
}
|
|
3047
|
+
|
|
3048
|
+
function exactConversationUrl(value: unknown): string {
|
|
3049
|
+
const raw = boundedText(value, "LinkedIn conversation URL", 4_096);
|
|
3050
|
+
const parsed = new URL(raw, "https://www.linkedin.com");
|
|
3051
|
+
if (
|
|
3052
|
+
parsed.origin !== "https://www.linkedin.com"
|
|
3053
|
+
|| !parsed.pathname.startsWith("/messaging/")
|
|
3054
|
+
|| parsed.username !== ""
|
|
3055
|
+
|| parsed.password !== ""
|
|
3056
|
+
|| parsed.hash !== ""
|
|
3057
|
+
) throw new Error("LinkedIn conversation URL escaped the reviewed messaging surface");
|
|
3058
|
+
return parsed.href;
|
|
3059
|
+
}
|
|
3060
|
+
|
|
3061
|
+
function normalizedConversation(
|
|
3062
|
+
entity: LinkedInWebJsonRecord,
|
|
3063
|
+
entitiesByUrn: ReadonlyMap<string, LinkedInWebJsonRecord>,
|
|
3064
|
+
): LinkedInMessagingConversation {
|
|
3065
|
+
if (entity.$type !== "com.linkedin.messenger.Conversation") {
|
|
3066
|
+
throw new Error("LinkedIn conversation reference resolved to the wrong entity type");
|
|
3067
|
+
}
|
|
3068
|
+
const participantReferences = exactStringList(
|
|
3069
|
+
entity["*conversationParticipants"],
|
|
3070
|
+
"LinkedIn conversation participant references",
|
|
3071
|
+
100,
|
|
3072
|
+
);
|
|
3073
|
+
const participants: LinkedInMessagingParticipant[] = [];
|
|
3074
|
+
for (const reference of participantReferences) {
|
|
3075
|
+
const participant = entitiesByUrn.get(reference);
|
|
3076
|
+
if (participant !== undefined) participants.push(normalizedParticipant(participant));
|
|
3077
|
+
}
|
|
3078
|
+
const messages = graphqlRecord(entity.messages, "conversation messages");
|
|
3079
|
+
const messageReferences = exactStringList(messages["*elements"], "LinkedIn conversation message references", 100);
|
|
3080
|
+
const latestReference = messageReferences[0];
|
|
3081
|
+
const latestEntity = latestReference === undefined ? undefined : entitiesByUrn.get(latestReference);
|
|
3082
|
+
const latestMessage = latestEntity === undefined ? null : normalizedMessage(latestEntity);
|
|
3083
|
+
const categories = exactStringList(entity.categories, "LinkedIn conversation categories", 32);
|
|
3084
|
+
for (const category of categories) {
|
|
3085
|
+
if (!/^[A-Z][A-Z0-9_]{0,63}$/u.test(category)) {
|
|
3086
|
+
throw new Error("LinkedIn conversation category is invalid");
|
|
3087
|
+
}
|
|
3088
|
+
}
|
|
3089
|
+
if (typeof entity.groupChat !== "boolean" || typeof entity.read !== "boolean") {
|
|
3090
|
+
throw new Error("LinkedIn conversation state flags are invalid");
|
|
3091
|
+
}
|
|
3092
|
+
return Object.freeze({
|
|
3093
|
+
id: linkedInUrn(entity.backendUrn ?? entity.entityUrn, "LinkedIn conversation backend URN"),
|
|
3094
|
+
urn: linkedInUrn(entity.entityUrn ?? entity.backendUrn, "LinkedIn conversation URN"),
|
|
3095
|
+
categories,
|
|
3096
|
+
groupChat: entity.groupChat,
|
|
3097
|
+
title: optionalText(entity.title, "LinkedIn conversation title", 8_192),
|
|
3098
|
+
createdAt: nonnegativeInteger(entity.createdAt, "LinkedIn conversation createdAt"),
|
|
3099
|
+
lastActivityAt: nonnegativeInteger(entity.lastActivityAt, "LinkedIn conversation lastActivityAt"),
|
|
3100
|
+
lastReadAt: optionalNonnegativeInteger(entity.lastReadAt, "LinkedIn conversation lastReadAt"),
|
|
3101
|
+
read: entity.read,
|
|
3102
|
+
unreadCount: nonnegativeInteger(entity.unreadCount, "LinkedIn conversation unreadCount"),
|
|
3103
|
+
notificationStatus: boundedText(
|
|
3104
|
+
entity.notificationStatus,
|
|
3105
|
+
"LinkedIn conversation notificationStatus",
|
|
3106
|
+
64,
|
|
3107
|
+
),
|
|
3108
|
+
participants: Object.freeze(participants),
|
|
3109
|
+
latestMessage,
|
|
3110
|
+
url: exactConversationUrl(entity.conversationUrl),
|
|
3111
|
+
});
|
|
3112
|
+
}
|
|
3113
|
+
|
|
3114
|
+
function folderIncludesConversation(folder: LinkedInWebFolder, categories: readonly string[]): boolean {
|
|
3115
|
+
if (folder === "all") return true;
|
|
3116
|
+
return categories.includes(LINKEDIN_WEB_FOLDER_CATEGORIES[folder]);
|
|
3117
|
+
}
|
|
3118
|
+
|
|
3119
|
+
export function normalizeLinkedInMessagingList(
|
|
3120
|
+
value: unknown,
|
|
3121
|
+
folderValue: unknown,
|
|
3122
|
+
limitValue: unknown,
|
|
3123
|
+
): LinkedInMessagingList {
|
|
3124
|
+
if (
|
|
3125
|
+
typeof folderValue !== "string"
|
|
3126
|
+
|| !hasOwn(LINKEDIN_WEB_FOLDER_CATEGORIES, folderValue)
|
|
3127
|
+
) throw new Error("LinkedIn folder must be focused, other, requests, archive, spam, or all");
|
|
3128
|
+
const folder = folderValue as LinkedInWebFolder;
|
|
3129
|
+
if (!Number.isSafeInteger(limitValue) || (limitValue as number) < 1 || (limitValue as number) > MAX_MESSAGING_ITEMS) {
|
|
3130
|
+
throw new Error("LinkedIn messaging limit must be an integer between 1 and 100");
|
|
3131
|
+
}
|
|
3132
|
+
const limit = limitValue as number;
|
|
3133
|
+
const normalized = normalizeLinkedInGraphqlEnvelope(value);
|
|
3134
|
+
const outerData = graphqlRecord(normalized.data.data, "messenger-conversations data");
|
|
3135
|
+
const collection = graphqlRecord(
|
|
3136
|
+
outerData.messengerConversationsBySyncToken,
|
|
3137
|
+
"messenger-conversations collection",
|
|
3138
|
+
);
|
|
3139
|
+
if (collection.$type !== "com.linkedin.restli.common.CollectionResponse") {
|
|
3140
|
+
throw new Error("LinkedIn messenger-conversations collection type changed");
|
|
3141
|
+
}
|
|
3142
|
+
const references = exactStringList(
|
|
3143
|
+
collection["*elements"],
|
|
3144
|
+
"LinkedIn messenger-conversations references",
|
|
3145
|
+
MAX_MESSAGING_ITEMS,
|
|
3146
|
+
);
|
|
3147
|
+
const conversations: LinkedInMessagingConversation[] = [];
|
|
3148
|
+
let matchingConversations = 0;
|
|
3149
|
+
for (const reference of references) {
|
|
3150
|
+
const entity = normalized.entitiesByUrn.get(reference);
|
|
3151
|
+
if (entity === undefined) {
|
|
3152
|
+
throw new Error("LinkedIn messenger-conversations response omitted a referenced conversation");
|
|
3153
|
+
}
|
|
3154
|
+
const conversation = normalizedConversation(entity, normalized.entitiesByUrn);
|
|
3155
|
+
if (folderIncludesConversation(folder, conversation.categories)) {
|
|
3156
|
+
matchingConversations += 1;
|
|
3157
|
+
if (conversations.length < limit) conversations.push(conversation);
|
|
3158
|
+
}
|
|
3159
|
+
}
|
|
3160
|
+
const metadata = graphqlRecord(collection.metadata, "messenger-conversations metadata");
|
|
3161
|
+
const nextCursor = optionalText(metadata.newSyncToken, "LinkedIn messenger-conversations sync token", 4_096);
|
|
3162
|
+
return Object.freeze({
|
|
3163
|
+
folder,
|
|
3164
|
+
conversations: Object.freeze(conversations),
|
|
3165
|
+
nextCursor,
|
|
3166
|
+
continuationSupported: false,
|
|
3167
|
+
complete: nextCursor === null && matchingConversations <= limit,
|
|
3168
|
+
});
|
|
3169
|
+
}
|
|
3170
|
+
|
|
3171
|
+
function operationContract(value: unknown): LinkedInWebOperationContract {
|
|
3172
|
+
if (typeof value !== "string" || !hasOwn(LINKEDIN_WEB_OPERATIONS, value)) {
|
|
3173
|
+
throw new Error("LinkedIn web operation is unknown");
|
|
3174
|
+
}
|
|
3175
|
+
return LINKEDIN_WEB_OPERATIONS[value as LinkedInWebOperationName];
|
|
3176
|
+
}
|
|
3177
|
+
|
|
3178
|
+
export function assertLinkedInWebR1RequestAllowed(
|
|
3179
|
+
operationValue: unknown,
|
|
3180
|
+
requestValue: unknown,
|
|
3181
|
+
): void {
|
|
3182
|
+
const contract = operationContract(operationValue);
|
|
3183
|
+
if (contract.risk !== "R1" || contract.effect !== "read") {
|
|
3184
|
+
throw new Error("LinkedIn web operation is not an R1 read");
|
|
3185
|
+
}
|
|
3186
|
+
if (contract.state !== "observed" || contract.requests.length === 0) {
|
|
3187
|
+
throw new Error("LinkedIn R1 operation has no captured request contract");
|
|
3188
|
+
}
|
|
3189
|
+
if (operationValue === "messaging.list") {
|
|
3190
|
+
if (!isRecord(requestValue)) throw new Error("LinkedIn messaging-list request must be an object");
|
|
3191
|
+
assertLinkedInMessengerConversationsRequest({
|
|
3192
|
+
url: requestValue.url instanceof URL || typeof requestValue.url === "string"
|
|
3193
|
+
? requestValue.url
|
|
3194
|
+
: (() => { throw new Error("LinkedIn messaging-list request URL is invalid"); })(),
|
|
3195
|
+
method: boundedText(requestValue.method, "LinkedIn messaging-list request method", 16),
|
|
3196
|
+
}, requestValue.mailboxUrn);
|
|
3197
|
+
return;
|
|
3198
|
+
}
|
|
3199
|
+
if (operationValue === "contacts.read") {
|
|
3200
|
+
if (!isRecord(requestValue)) throw new Error("LinkedIn contact-info request must be an object");
|
|
3201
|
+
const method = boundedText(requestValue.method, "LinkedIn contact-info request method", 16)
|
|
3202
|
+
.toUpperCase();
|
|
3203
|
+
if (method !== "GET") throw new Error("LinkedIn contact-info reads require GET");
|
|
3204
|
+
const rawUrl = requestValue.url;
|
|
3205
|
+
if (!(rawUrl instanceof URL) && typeof rawUrl !== "string") {
|
|
3206
|
+
throw new Error("LinkedIn contact-info request URL is invalid");
|
|
3207
|
+
}
|
|
3208
|
+
const url = rawUrl instanceof URL ? new URL(rawUrl.href) : new URL(rawUrl);
|
|
3209
|
+
if (
|
|
3210
|
+
url.origin === "https://www.linkedin.com"
|
|
3211
|
+
&& url.username === ""
|
|
3212
|
+
&& url.password === ""
|
|
3213
|
+
&& url.hash === ""
|
|
3214
|
+
) {
|
|
3215
|
+
if (url.search === "" && url.pathname === "/voyager/api/me") return;
|
|
3216
|
+
if (url.search === "" && /^\/in\/[A-Za-z0-9][A-Za-z0-9_-]{1,99}\/$/u.test(url.pathname)) {
|
|
3217
|
+
linkedInPersonalProfileTarget(url.href);
|
|
3218
|
+
return;
|
|
3219
|
+
}
|
|
3220
|
+
if (url.pathname === LINKEDIN_GRAPHQL_PATH) {
|
|
3221
|
+
const queryNames = [...url.searchParams.keys()];
|
|
3222
|
+
const queryKey = queryNames[1];
|
|
3223
|
+
if (
|
|
3224
|
+
queryNames.length === 3
|
|
3225
|
+
&& queryNames[0] === "includeWebMetadata"
|
|
3226
|
+
&& queryNames[2] === "variables"
|
|
3227
|
+
&& (queryKey === "queryId" || queryKey === "queryName")
|
|
3228
|
+
&& url.searchParams.get("includeWebMetadata") === "true"
|
|
3229
|
+
&& url.searchParams.getAll("includeWebMetadata").length === 1
|
|
3230
|
+
&& url.searchParams.getAll("variables").length === 1
|
|
3231
|
+
&& queryKey !== undefined
|
|
3232
|
+
&& url.searchParams.getAll(queryKey).length === 1
|
|
3233
|
+
&& (
|
|
3234
|
+
queryKey === "queryName"
|
|
3235
|
+
? url.searchParams.get("queryName") === "voyagerIdentityDashProfileContactInfo"
|
|
3236
|
+
: /^voyagerIdentityDashProfileContactInfo\.[0-9a-f]{32}$/u.test(
|
|
3237
|
+
url.searchParams.get("queryId") ?? "",
|
|
3238
|
+
)
|
|
3239
|
+
)
|
|
3240
|
+
&& /^\(profileUrn:urn:li:fsd_profile:[A-Za-z0-9_-]{1,256}\)$/u.test(
|
|
3241
|
+
url.searchParams.get("variables") ?? "",
|
|
3242
|
+
)
|
|
3243
|
+
) return;
|
|
3244
|
+
}
|
|
3245
|
+
}
|
|
3246
|
+
throw new Error("LinkedIn contact-info request escaped its exact reviewed route");
|
|
3247
|
+
}
|
|
3248
|
+
if (operationValue === "profiles.read" || operationValue === "organizations.read") {
|
|
3249
|
+
if (!isRecord(requestValue)) throw new Error("LinkedIn profile read request must be an object");
|
|
3250
|
+
const method = boundedText(requestValue.method, "LinkedIn profile read request method", 16)
|
|
3251
|
+
.toUpperCase();
|
|
3252
|
+
if (method !== "GET") throw new Error("LinkedIn profile reads require GET");
|
|
3253
|
+
const rawUrl = requestValue.url;
|
|
3254
|
+
if (!(rawUrl instanceof URL) && typeof rawUrl !== "string") {
|
|
3255
|
+
throw new Error("LinkedIn profile read request URL is invalid");
|
|
3256
|
+
}
|
|
3257
|
+
const url = rawUrl instanceof URL ? new URL(rawUrl.href) : new URL(rawUrl);
|
|
3258
|
+
if (
|
|
3259
|
+
operationValue === "profiles.read"
|
|
3260
|
+
&& url.href === "https://www.linkedin.com/mynetwork/invite-connect/connections/"
|
|
3261
|
+
) return;
|
|
3262
|
+
if (operationValue === "profiles.read") linkedInPersonalProfileTarget(url.href);
|
|
3263
|
+
else linkedInOrganizationTarget(url.href);
|
|
3264
|
+
return;
|
|
3265
|
+
}
|
|
3266
|
+
if (operationValue === "feeds.read") {
|
|
3267
|
+
if (!isRecord(requestValue)) {
|
|
3268
|
+
throw new Error("LinkedIn profile-activity request must be an object");
|
|
3269
|
+
}
|
|
3270
|
+
const method = boundedText(requestValue.method, "LinkedIn profile-activity request method", 16)
|
|
3271
|
+
.toUpperCase();
|
|
3272
|
+
if (method !== "GET") throw new Error("LinkedIn profile-activity reads require GET");
|
|
3273
|
+
const rawUrl = requestValue.url;
|
|
3274
|
+
if (!(rawUrl instanceof URL) && typeof rawUrl !== "string") {
|
|
3275
|
+
throw new Error("LinkedIn profile-activity request URL is invalid");
|
|
3276
|
+
}
|
|
3277
|
+
const url = rawUrl instanceof URL ? new URL(rawUrl.href) : new URL(rawUrl);
|
|
3278
|
+
if (
|
|
3279
|
+
url.origin !== "https://www.linkedin.com"
|
|
3280
|
+
|| url.pathname !== LINKEDIN_GRAPHQL_PATH
|
|
3281
|
+
|| url.username !== ""
|
|
3282
|
+
|| url.password !== ""
|
|
3283
|
+
|| url.hash !== ""
|
|
3284
|
+
) throw new Error("LinkedIn profile-activity request escaped its exact reviewed route");
|
|
3285
|
+
const queryNames = [...url.searchParams.keys()];
|
|
3286
|
+
if (
|
|
3287
|
+
queryNames.length !== 3
|
|
3288
|
+
|| queryNames[0] !== "includeWebMetadata"
|
|
3289
|
+
|| queryNames[1] !== "queryId"
|
|
3290
|
+
|| queryNames[2] !== "variables"
|
|
3291
|
+
|| url.searchParams.get("includeWebMetadata") !== "true"
|
|
3292
|
+
) throw new Error("LinkedIn profile-activity request query shape is invalid");
|
|
3293
|
+
resolveLinkedInRegisteredQueryId(
|
|
3294
|
+
"voyagerFeedDashProfileUpdates",
|
|
3295
|
+
[url.searchParams.get("queryId")],
|
|
3296
|
+
);
|
|
3297
|
+
const variables = url.searchParams.get("variables");
|
|
3298
|
+
if (
|
|
3299
|
+
typeof variables !== "string"
|
|
3300
|
+
|| !/\bprofileUrn:urn:li:fsd_profile:[A-Za-z0-9_-]{1,256}\b/u.test(variables)
|
|
3301
|
+
) throw new Error("LinkedIn profile-activity request omitted its profile URN");
|
|
3302
|
+
return;
|
|
3303
|
+
}
|
|
3304
|
+
throw new Error("LinkedIn R1 operation has no captured request contract");
|
|
3305
|
+
}
|