From 4212150c61b8f1b5004f3f5544d812d1eddbb39b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 22:48:43 +0000 Subject: [PATCH 1/9] chore(deps): update aws-sdk-js-v3 monorepo to ^3.1111.0 (#2386) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 222 +++++++++++++++++++++++----------------------- package.json | 4 +- 2 files changed, 113 insertions(+), 113 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0e60290d..c63f0bc7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,8 +22,8 @@ "zod": "^4.4.3" }, "devDependencies": { - "@aws-sdk/client-s3": "^3.1110.0", - "@aws-sdk/s3-request-presigner": "^3.1110.0", + "@aws-sdk/client-s3": "^3.1111.0", + "@aws-sdk/s3-request-presigner": "^3.1111.0", "@cloudflare/vitest-pool-workers": "^0.21.3", "@cloudflare/workers-types": "^4.20260702.1", "@eslint/js": "^10.0.1", @@ -60,14 +60,14 @@ } }, "node_modules/@aws-sdk/checksums": { - "version": "3.1000.27", - "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.27.tgz", - "integrity": "sha512-insWOqKKNUrbN/dohEG7BJ0U5GkyqhjbMb/NHNaLUtq+7my2M8C4EnZZZoxMmXRqCC+P9dEr+KyJA2JGGzoKLg==", + "version": "3.1000.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.28.tgz", + "integrity": "sha512-VCpnmyHQ1IH49ni3LXnQj7DPr7rmcJmzYeiCkYdCcfgNtkvOj38cdcL9lapBWoItZWFACJPFJlymqC7/gem3Gw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -77,18 +77,18 @@ } }, "node_modules/@aws-sdk/client-s3": { - "version": "3.1110.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1110.0.tgz", - "integrity": "sha512-40xbEcWjdaYKlZ4/NvndIJ3LotAEQAvHVQ7Z4NVy4Z4xGRN7xXJlHI9bMh/4aMJQ++6h5W5sv+wqjfk0rEKOBg==", + "version": "3.1111.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1111.0.tgz", + "integrity": "sha512-VnLT6aSTN8tWl/NsXUysXNZor7wQBp9CRwufo7kt8cwGXvHLZ0S/cV1K9WFcREGboVYSo3NGQ3ZvU7LRidh2aQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/checksums": "^3.1000.27", - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/credential-provider-node": "^3.972.79", - "@aws-sdk/middleware-sdk-s3": "^3.972.73", - "@aws-sdk/signature-v4-multi-region": "^3.996.44", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/checksums": "^3.1000.28", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/credential-provider-node": "^3.972.80", + "@aws-sdk/middleware-sdk-s3": "^3.972.74", + "@aws-sdk/signature-v4-multi-region": "^3.996.45", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", @@ -100,14 +100,14 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.977.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.7.tgz", - "integrity": "sha512-I88Iov89NVmjSmJLKSv7Cn9M2J+a2942OkA8nZCbz+sl4ZeY4zEOcoLOrbt1GRfQ8zEQKnjAJdXixA3J/p1fDQ==", + "version": "3.977.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.8.tgz", + "integrity": "sha512-7+Kcrkvrk9lM/m7jRhHpT4jCdvzGHsuaSRbF8TdzzkY1mRzp/Ogwf9c7H29k4gGhey0BBWhCWr16+t0J61gwmg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.974.3", - "@aws-sdk/xml-builder": "^3.972.38", + "@aws-sdk/types": "^3.974.4", + "@aws-sdk/xml-builder": "^3.972.39", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.31.1", "@smithy/signature-v4": "^5.6.12", @@ -120,14 +120,14 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.68", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.68.tgz", - "integrity": "sha512-2a20A/IdNOwUvaDq91iqqS7BA0XlNMfW3iLGZGZLJv0EbUqhSxB0PIx4rQQqssvWj1uXImb3/UCCdHz/+1dOiA==", + "version": "3.972.69", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.69.tgz", + "integrity": "sha512-AreCFzcB4kH2HF9031Ot0jSJr3KXvRg6e8uDeub20JEVdZU3Bv0sTq1plc7VsT3KiqutlzH7l0j50UcCWHUioA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -137,14 +137,14 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.70", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.70.tgz", - "integrity": "sha512-0yRem2Fs52r/Nn6UAqIlpjexfaYj8ziEozOe9tamtAVT/5bzFLKx8O2r7MaRqgS3hGKHIa1Jij9nKHSsNnb04A==", + "version": "3.972.71", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.71.tgz", + "integrity": "sha512-A8ObcqVmDMnk4F9NozZ7JwmUu9Q4xyBJkmyq1C5U+wNM9ht9J7+EuuyabsLWXZnOoTqFaJuYBYTKf5CTipkEjA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", @@ -156,21 +156,21 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.973.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.13.tgz", - "integrity": "sha512-2M39DE02XpYYaSWYk/4AsImXYUU/1L2xmTMLUpMMWq7DfLv191/vCRy3baKtdr45AkJQyVgSjmuVOLm15SwrRQ==", + "version": "3.973.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.14.tgz", + "integrity": "sha512-7c+Wti2LsERNWMfm7ySz3/6RPopFW3Nmn7s63Xpcq6R/tRuY5hpvkHA2xVgi5ukJbvok9l0IDtVEvqTtg+X7dw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/credential-provider-env": "^3.972.68", - "@aws-sdk/credential-provider-http": "^3.972.70", - "@aws-sdk/credential-provider-login": "^3.972.75", - "@aws-sdk/credential-provider-process": "^3.972.68", - "@aws-sdk/credential-provider-sso": "^3.973.12", - "@aws-sdk/credential-provider-web-identity": "^3.972.74", - "@aws-sdk/nested-clients": "^3.997.42", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/credential-provider-env": "^3.972.69", + "@aws-sdk/credential-provider-http": "^3.972.71", + "@aws-sdk/credential-provider-login": "^3.972.76", + "@aws-sdk/credential-provider-process": "^3.972.69", + "@aws-sdk/credential-provider-sso": "^3.973.13", + "@aws-sdk/credential-provider-web-identity": "^3.972.75", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.16.1", @@ -181,15 +181,15 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.75", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.75.tgz", - "integrity": "sha512-jaTESuJlQsoUZ44f/i2puyPt8VlF/dMMJ9HM3cStYtk7eKX4N9UWi83OLixUkoOJH3BwWlPLCq9YIK9nfWhVBg==", + "version": "3.972.76", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.76.tgz", + "integrity": "sha512-LVixwOnEJfrrfKHeZjBA8pIMTZjNDq8ak8VpcoWUuCJDrSnBNU8POJksULMgvN089P0MXtQYH2Zs627/MK1K0g==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/nested-clients": "^3.997.42", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -199,19 +199,19 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.79", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.79.tgz", - "integrity": "sha512-RIw5dof1EHkWubrZzPC941CDtnFG1iAXsxbFgLkhdYZXHc4icU13c/uxSMI0J5eUx9bxa7LjfpdjfClBB1QsDA==", + "version": "3.972.80", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.80.tgz", + "integrity": "sha512-bE2qh8ww4iClO1jHsBXdOE8FUgzDbdxbyorNjSCoPSkQd51k3jODItuPZfuwcLHZqDXsH+bI4AMHhqtuyR7mSg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.68", - "@aws-sdk/credential-provider-http": "^3.972.70", - "@aws-sdk/credential-provider-ini": "^3.973.13", - "@aws-sdk/credential-provider-process": "^3.972.68", - "@aws-sdk/credential-provider-sso": "^3.973.12", - "@aws-sdk/credential-provider-web-identity": "^3.972.74", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/credential-provider-env": "^3.972.69", + "@aws-sdk/credential-provider-http": "^3.972.71", + "@aws-sdk/credential-provider-ini": "^3.973.14", + "@aws-sdk/credential-provider-process": "^3.972.69", + "@aws-sdk/credential-provider-sso": "^3.973.13", + "@aws-sdk/credential-provider-web-identity": "^3.972.75", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.16.1", @@ -222,14 +222,14 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.68", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.68.tgz", - "integrity": "sha512-nLP3Pda2MQTFJ25hKBMmUuB9Uv+bTZQNlufbeCwklP549Vwnkd8bRLJoCKp5k6xjmdyptrPrOfGOhN0mKuca8A==", + "version": "3.972.69", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.69.tgz", + "integrity": "sha512-9kpTNdZTrcqXTfhxM7fgl9Z68ek3Fu5oe3Yf+A/pJGibEqpgZxz2tSY7SinmyCIU2PJ+ygY4FPoBBnLpocMtrQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -239,16 +239,16 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.973.12", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.12.tgz", - "integrity": "sha512-EmgyyHn+f9WCcelp3L/vci+LGbX8GigWaVphRArjVo5Pktkr9YnLy/mQ6VDkDyBD72dtfRNTgHmD2ts4rTDXKQ==", + "version": "3.973.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.13.tgz", + "integrity": "sha512-Oc81qauMPzUoTnAS2YKpNwY6sY/LUyQTEeaf6yP197WMxkEBQfcKLR1MFpD7+pNTubXnfkH6gwpji+Gc7iyD2Q==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/nested-clients": "^3.997.42", - "@aws-sdk/token-providers": "3.1108.0", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/token-providers": "3.1111.0", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -258,15 +258,15 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.74", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.74.tgz", - "integrity": "sha512-0YfczxGXF3RjGj8z7QG/Ho2HnLGKDHfPSHiTs47UU1U/+mmwISDN+rvGKt2zh+3FX8NdT4xd95LGBGyhQw2dgQ==", + "version": "3.972.75", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.75.tgz", + "integrity": "sha512-YPN6uoGDgjjjeVFZrcOeCJqmB6zpXoeeNgIjqe+DexJaWqdjVfCCe+VAZwli9Z2h8KhFW8oxkO39emQ1tyz/Mw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/nested-clients": "^3.997.42", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -276,15 +276,15 @@ } }, "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.972.73", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.73.tgz", - "integrity": "sha512-oy7sRA5HvHcAvkcKX6F8RI240jcOf3c8y/Gqjs9qemIibdKQqGBIi0uwa+47ZRYqGLpdEO28TQU4G73yUzo06Q==", + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.74.tgz", + "integrity": "sha512-2lzoV2z2QO5KJZYGOCnIZ1WVQgzMECvwuzr1xb034a++8QW4U4eGrmC2u4yg1xvNv4TLL/Uv5DLyuAiw0b9z7Q==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/signature-v4-multi-region": "^3.996.44", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/signature-v4-multi-region": "^3.996.45", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -294,15 +294,15 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.42", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.42.tgz", - "integrity": "sha512-XWRyon2MTHXD/zMoo0Mbge6Vwf+iE0qQaM/RyGO6NfZ9WukCFiQL27nQVZjYy2JwSIg+iXZxKOX95OBXqlSM4w==", + "version": "3.997.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.43.tgz", + "integrity": "sha512-bit+VpqWNyi3wHxFoTsTliNXimCSL2r2OeDTm7ZrG+YsTZ2D7ofDJ6r/t9PVBn80i6/v0X2h9Tgw6QP2MAKfPw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/signature-v4-multi-region": "^3.996.44", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/signature-v4-multi-region": "^3.996.45", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", @@ -314,15 +314,15 @@ } }, "node_modules/@aws-sdk/s3-request-presigner": { - "version": "3.1110.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1110.0.tgz", - "integrity": "sha512-xr7vfMj3Mea1r1+GzdBT/9LpKN2l+xBMSzbGoj3Uia6ERPMjx55BeqVtVGBHWw5+IKSGCx5SVC7Jn+3FxntMLg==", + "version": "3.1111.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1111.0.tgz", + "integrity": "sha512-ACp/VtDTw6AjFW5Q3M59uAMrBbAHeQS0UBIOIgUROO98Z3uhpiy37k9RmvxbXYVugmTcdNTy4DPVQtLG8sDy6g==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/signature-v4-multi-region": "^3.996.44", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/signature-v4-multi-region": "^3.996.45", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -332,13 +332,13 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.44", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.44.tgz", - "integrity": "sha512-ZSfQ35Qn4MhSY+A0Whyr+KBx+wJKZUyBsOrjB2pSHOafRzbFe47T8XcXM8hZqUAC69qnqIy0C9ArxTuud0CC2w==", + "version": "3.996.45", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.45.tgz", + "integrity": "sha512-bBuyztukzXq6plzFGHAWiQt0QXo+HL8b8lX5cFTzkez/74PtS1c0qPFCIVuHkyoT+miH2qOjAcm1/yoro2ESPA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/types": "^3.974.4", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -348,15 +348,15 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1108.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1108.0.tgz", - "integrity": "sha512-rI80zxDxGJ6904eC/YbjkdjY6JdaZvQ01kOmrMvw7cFQGIHo27fhnIVbMSVDS4T6foQImjxYSRoOu/uSJscXDw==", + "version": "3.1111.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1111.0.tgz", + "integrity": "sha512-JfljgoVtl+s3Qy21n9a7Z48uCQaOXcN74KJ3TEQfPoB293GrXFSt6HSQJF1sTZ8c/5QedEvd3NjJQMO4u9qa5A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/nested-clients": "^3.997.42", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -366,9 +366,9 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.974.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.3.tgz", - "integrity": "sha512-ECAqfpNsef+7MO8qtR0h9KcFIBAygaE7Cm6UOiQl+ft+uVap+1G7bNEjs4mdJE2OnA4m6k7i8peH8uGIAsOMGw==", + "version": "3.974.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.4.tgz", + "integrity": "sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -380,9 +380,9 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.38", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.38.tgz", - "integrity": "sha512-grf7mzfVxBS5AlsuTvBN7uDpzqohFww9fRPCO+EBSUdvtsYMcPSKdz54h/7XiscqNcUM1Ae1MF7JLHmiYYuzbQ==", + "version": "3.972.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.39.tgz", + "integrity": "sha512-FTti8DS5MMWXNUWiRwXAJeYS+0GHHiMy0+7XOhcwk63ILHmfS2UFy2z/HNpZCSOJJ3P3dnWY6hfYNW3DF0nXUA==", "dev": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 899bb959..a5bb1c33 100644 --- a/package.json +++ b/package.json @@ -33,8 +33,8 @@ "author": "dangered wolf", "license": "MIT", "devDependencies": { - "@aws-sdk/client-s3": "^3.1110.0", - "@aws-sdk/s3-request-presigner": "^3.1110.0", + "@aws-sdk/client-s3": "^3.1111.0", + "@aws-sdk/s3-request-presigner": "^3.1111.0", "@cloudflare/vitest-pool-workers": "^0.21.3", "@cloudflare/workers-types": "^4.20260702.1", "@eslint/js": "^10.0.1", From 85ba21c6e46424ac3e841ab4b2d71d0c5d5f7ee7 Mon Sep 17 00:00:00 2001 From: dangered wolf Date: Sat, 22 Aug 2026 13:46:20 -0400 Subject: [PATCH 2/9] fix(api): cap FxTwitter /2/search query at 512 characters (#2387) Reject oversized search queries with HTTP 400 before calling X, matching SearchTimeline's rawQuery limit, and map the upstream length error if it still appears. Co-authored-by: Cursor Agent --- .../src/providers/twitter/proxy/errors.ts | 5 ++ .../src/providers/twitter/search.ts | 8 +++- .../src/providers/twitter/searchErrors.ts | 26 +++++++++- src/realms/api/routes.ts | 29 +++++++++-- test/api.search.test.ts | 48 +++++++++++++++++++ .../SearchTimeline/query_too_long_error.json | 21 ++++++++ test/searchTimelineErrors.test.ts | 40 +++++++++++++++- 7 files changed, 169 insertions(+), 8 deletions(-) create mode 100644 test/mocks/SearchTimeline/query_too_long_error.json diff --git a/packages/atmosphere/src/providers/twitter/proxy/errors.ts b/packages/atmosphere/src/providers/twitter/proxy/errors.ts index 57dec96d..fa14ed47 100644 --- a/packages/atmosphere/src/providers/twitter/proxy/errors.ts +++ b/packages/atmosphere/src/providers/twitter/proxy/errors.ts @@ -205,6 +205,11 @@ const ERROR_RULES: ErrorRule[] = [ match: ({ json }) => parseSearchTimelineClientError(json) === 'blocklisted', disposition: 'ignore', log: 'SearchTimeline blocklisted query (expected client error)' + }, + { + match: ({ json }) => parseSearchTimelineClientError(json) === 'query_too_long', + disposition: 'ignore', + log: 'SearchTimeline query exceeds max length (expected client error)' } ]; diff --git a/packages/atmosphere/src/providers/twitter/search.ts b/packages/atmosphere/src/providers/twitter/search.ts index 695c0db0..1f3febfb 100644 --- a/packages/atmosphere/src/providers/twitter/search.ts +++ b/packages/atmosphere/src/providers/twitter/search.ts @@ -6,7 +6,9 @@ import { isTombstone } from '../../helpers/tombstone.js'; import { isSearchTimelineClientErrorResponse, parseSearchTimelineClientError, - searchTimelineClientErrorToApiQueryError + searchQueryTooLongError, + searchTimelineClientErrorToApiQueryError, + TWITTER_SEARCH_RAW_QUERY_MAX_LENGTH } from './searchErrors.js'; import type { APISearchResults, APITwitterStatus, ApiQueryError } from '../../types/api-schemas.js'; import type { FetchResults } from '../../types/fetch-results.js'; @@ -436,6 +438,10 @@ export const searchAPI = async ( host: TwitterBuildHost, language?: string ): Promise => { + if (query.length > TWITTER_SEARCH_RAW_QUERY_MAX_LENGTH) { + return searchQueryTooLongError(query.length); + } + const product = feedToProduct(feed); let response: TwitterSearchTimelineResponse | null; diff --git a/packages/atmosphere/src/providers/twitter/searchErrors.ts b/packages/atmosphere/src/providers/twitter/searchErrors.ts index 8c294075..b99326dc 100644 --- a/packages/atmosphere/src/providers/twitter/searchErrors.ts +++ b/packages/atmosphere/src/providers/twitter/searchErrors.ts @@ -1,6 +1,22 @@ import type { ApiQueryError } from '../../types/api-schemas.js'; -export type SearchTimelineClientErrorKind = 'empty_query' | 'blocklisted'; +/** X SearchTimeline rejects `rawQuery` longer than this; match that cap on FxTwitter `/2/search`. */ +export const TWITTER_SEARCH_RAW_QUERY_MAX_LENGTH = 512; + +export type SearchTimelineClientErrorKind = 'empty_query' | 'blocklisted' | 'query_too_long'; + +const QUERY_TOO_LONG_RE = /Raw query length \d+ exceeds max allowed \d+/i; + +export function formatSearchQueryTooLongMessage(length: number): string { + return `Raw query length ${length} exceeds max allowed ${TWITTER_SEARCH_RAW_QUERY_MAX_LENGTH}`; +} + +export function searchQueryTooLongError(length: number): ApiQueryError { + return { + code: 400, + message: formatSearchQueryTooLongMessage(length) + }; +} function asRecord(value: unknown): Record | undefined { if (value !== null && typeof value === 'object') { @@ -49,6 +65,9 @@ export function parseSearchTimelineClientError( if (message.includes('denylisted in Search Content Control tool')) { return 'blocklisted'; } + if (QUERY_TOO_LONG_RE.test(message)) { + return 'query_too_long'; + } return null; } @@ -70,5 +89,10 @@ export function searchTimelineClientErrorToApiQueryError( code: 400, message: 'Search query is blocked by X content controls' }; + case 'query_too_long': + return { + code: 400, + message: `Raw query length exceeds max allowed ${TWITTER_SEARCH_RAW_QUERY_MAX_LENGTH}` + }; } } diff --git a/src/realms/api/routes.ts b/src/realms/api/routes.ts index 02493254..59d7c757 100644 --- a/src/realms/api/routes.ts +++ b/src/realms/api/routes.ts @@ -4,6 +4,10 @@ import { PUBLIC_EXPLORE_TIMELINE_KINDS, type PublicExploreTimelineKind } from '@fxembed/atmosphere/providers/twitter/trends'; +import { + formatSearchQueryTooLongMessage, + TWITTER_SEARCH_RAW_QUERY_MAX_LENGTH +} from '@fxembed/atmosphere/providers/twitter/searchErrors'; import { APIProfileRelationshipListSchema, APIUserListResultsSchema, @@ -22,13 +26,27 @@ import { const twitterSearchQueryHasEffectiveContent = (raw: string): boolean => raw.replace(/_/g, '').trim().length > 0; -const twitterSearchQueryString = (openapiMeta: { description: string; example: string }) => - z +const twitterSearchQueryString = (openapiMeta: { + description: string; + example: string; + maxLength?: number; +}) => { + const maxLength = openapiMeta.maxLength; + return z .string() .refine(twitterSearchQueryHasEffectiveContent, { message: 'Search query must not be empty' }) + .superRefine((value, ctx) => { + if (maxLength !== undefined && value.length > maxLength) { + ctx.addIssue({ + code: 'custom', + message: formatSearchQueryTooLongMessage(value.length) + }); + } + }) .openapi(openapiMeta); +}; const aboutAccountQuery = z.object({ about_account: z.string().optional().openapi({ @@ -560,8 +578,9 @@ export const searchV2Route = createRoute({ request: { query: z.object({ q: twitterSearchQueryString({ - description: 'Search query (non-empty)', - example: 'puppies' + description: `Search query (non-empty, max ${TWITTER_SEARCH_RAW_QUERY_MAX_LENGTH} characters)`, + example: 'puppies', + maxLength: TWITTER_SEARCH_RAW_QUERY_MAX_LENGTH }), feed: z.enum(['latest', 'top', 'media']).optional().openapi({ description: 'Search tab (default latest)', @@ -581,7 +600,7 @@ export const searchV2Route = createRoute({ content: { 'application/json': { schema: APISearchResultsSchema } } }, 400: { - description: 'Invalid `q` parameter', + description: `Invalid \`q\` parameter (empty or longer than ${TWITTER_SEARCH_RAW_QUERY_MAX_LENGTH} characters)`, content: { 'application/json': { schema: ApiQueryErrorSchema } } }, 404: { diff --git a/test/api.search.test.ts b/test/api.search.test.ts index fee08540..4267b3f6 100644 --- a/test/api.search.test.ts +++ b/test/api.search.test.ts @@ -1,5 +1,6 @@ import { test, expect } from 'vitest'; import type { APITwitterStatus } from '../src/realms/api/schemas'; +import { TWITTER_SEARCH_RAW_QUERY_MAX_LENGTH } from '@fxembed/atmosphere/providers/twitter/searchErrors'; import { app } from '../src/worker'; import { botHeaders, twitterBaseUrl } from './helpers/data'; import harness from './helpers/harness'; @@ -103,6 +104,38 @@ test('API search accepts count parameter', async () => { expect(response.code).toEqual(200); }); +test('API search rejects q longer than 512 characters with 400', async () => { + const tooLong = 'a'.repeat(TWITTER_SEARCH_RAW_QUERY_MAX_LENGTH + 1); + const result = await app.request( + new Request(`https://api.fxtwitter.com/2/search?q=${tooLong}`, { + method: 'GET', + headers: botHeaders + }), + undefined, + harness + ); + expect(result.status).toEqual(400); + const body = (await result.json()) as { code?: number; message?: string; success?: boolean }; + expect(body.code).toEqual(400); + expect(body.message).toContain( + `Raw query length ${tooLong.length} exceeds max allowed ${TWITTER_SEARCH_RAW_QUERY_MAX_LENGTH}` + ); + expect(body.success).toBeUndefined(); +}); + +test('API search accepts q of exactly 512 characters', async () => { + const atLimit = 'a'.repeat(TWITTER_SEARCH_RAW_QUERY_MAX_LENGTH); + const result = await app.request( + new Request(`https://api.fxtwitter.com/2/search?q=${atLimit}`, { + method: 'GET', + headers: botHeaders + }), + undefined, + harness + ); + expect(result.status).not.toEqual(400); +}); + test('API search returns 400 for upstream empty query error', async () => { const result = await app.request( new Request('https://api.fxtwitter.com/2/search?q=empty_query_error', { @@ -132,3 +165,18 @@ test('API search returns 400 for upstream blocklisted query error', async () => expect(body.code).toEqual(400); expect(body.message).toContain('blocked by X content controls'); }); + +test('API search returns 400 for upstream query too long error', async () => { + const result = await app.request( + new Request('https://api.fxtwitter.com/2/search?q=query_too_long_error', { + method: 'GET', + headers: botHeaders + }), + undefined, + harness + ); + expect(result.status).toEqual(400); + const body = (await result.json()) as { code?: number; message?: string }; + expect(body.code).toEqual(400); + expect(body.message).toContain(`exceeds max allowed ${TWITTER_SEARCH_RAW_QUERY_MAX_LENGTH}`); +}); diff --git a/test/mocks/SearchTimeline/query_too_long_error.json b/test/mocks/SearchTimeline/query_too_long_error.json new file mode 100644 index 00000000..4e435b1a --- /dev/null +++ b/test/mocks/SearchTimeline/query_too_long_error.json @@ -0,0 +1,21 @@ +{ + "errors": [ + { + "message": "BadRequest: Raw query length 2268 exceeds max allowed 512", + "locations": [{ "line": 4, "column": 7 }], + "path": ["search_by_raw_query", "search_timeline", "timeline"], + "extensions": { + "name": "BadRequestError", + "source": "Client", + "code": 214, + "kind": "Validation", + "tracing": { "trace_id": "00000000000000000000000000000000" } + }, + "code": 214, + "kind": "Validation", + "name": "BadRequestError", + "source": "Client" + } + ], + "data": {} +} diff --git a/test/searchTimelineErrors.test.ts b/test/searchTimelineErrors.test.ts index 6d8a0cf8..0b081fd0 100644 --- a/test/searchTimelineErrors.test.ts +++ b/test/searchTimelineErrors.test.ts @@ -1,8 +1,11 @@ import { test, expect } from 'vitest'; import { + formatSearchQueryTooLongMessage, isSearchTimelineClientErrorResponse, parseSearchTimelineClientError, - searchTimelineClientErrorToApiQueryError + searchQueryTooLongError, + searchTimelineClientErrorToApiQueryError, + TWITTER_SEARCH_RAW_QUERY_MAX_LENGTH } from '@fxembed/atmosphere/providers/twitter/searchErrors'; const emptyQueryError = { @@ -27,6 +30,17 @@ const blocklistedError = { data: {} }; +const queryTooLongError = { + errors: [ + { + message: 'BadRequest: Raw query length 2268 exceeds max allowed 512', + path: ['search_by_raw_query', 'search_timeline', 'timeline'], + code: 214 + } + ], + data: {} +}; + test('parseSearchTimelineClientError detects empty query', () => { expect(parseSearchTimelineClientError(emptyQueryError)).toBe('empty_query'); expect(isSearchTimelineClientErrorResponse(emptyQueryError)).toBe(true); @@ -36,6 +50,11 @@ test('parseSearchTimelineClientError detects blocklisted query', () => { expect(parseSearchTimelineClientError(blocklistedError)).toBe('blocklisted'); }); +test('parseSearchTimelineClientError detects query too long', () => { + expect(parseSearchTimelineClientError(queryTooLongError)).toBe('query_too_long'); + expect(isSearchTimelineClientErrorResponse(queryTooLongError)).toBe(true); +}); + test('parseSearchTimelineClientError ignores unrelated GraphQL errors', () => { expect( parseSearchTimelineClientError({ @@ -58,6 +77,11 @@ test('parseSearchTimelineClientError accepts known messages without path', () => errors: [{ message: 'BadRequest: Query is denylisted in Search Content Control tool.' }] }) ).toBe('blocklisted'); + expect( + parseSearchTimelineClientError({ + errors: [{ message: 'BadRequest: Raw query length 2268 exceeds max allowed 512' }] + }) + ).toBe('query_too_long'); }); test('searchTimelineClientErrorToApiQueryError maps to API 400 messages', () => { @@ -69,4 +93,18 @@ test('searchTimelineClientErrorToApiQueryError maps to API 400 messages', () => code: 400, message: 'Search query is blocked by X content controls' }); + expect(searchTimelineClientErrorToApiQueryError('query_too_long')).toEqual({ + code: 400, + message: `Raw query length exceeds max allowed ${TWITTER_SEARCH_RAW_QUERY_MAX_LENGTH}` + }); +}); + +test('searchQueryTooLongError includes the actual query length', () => { + expect(searchQueryTooLongError(2268)).toEqual({ + code: 400, + message: formatSearchQueryTooLongMessage(2268) + }); + expect(formatSearchQueryTooLongMessage(2268)).toBe( + 'Raw query length 2268 exceeds max allowed 512' + ); }); From 7f2d5490dac5a911c0e6c6bdc00134317c26aea7 Mon Sep 17 00:00:00 2001 From: dangered wolf Date: Thu, 27 Aug 2026 17:49:55 -0400 Subject: [PATCH 3/9] feat(api): bluesky typeahead and user search --- .../src/providers/bluesky/client.ts | 61 ++++++++++++ .../src/providers/bluesky/search.ts | 94 ++++++++++++++++++- .../src/providers/mastodon/client.ts | 40 ++++++-- .../src/providers/mastodon/search.ts | 59 +++++++++++- .../src/providers/twitter/search.ts | 71 +++++++++++++- .../atmosphere/src/raw/vendor/bluesky.d.ts | 11 +++ src/providers/mastodon/atmosphere-handlers.ts | 25 ++++- src/providers/mastodon/atmosphere-register.ts | 3 + src/providers/mastodon/atmosphere-routes.ts | 37 ++++++++ src/realms/api/router.ts | 3 + src/realms/api/routes.ts | 41 ++++++++ src/realms/api/routes/twitter.ts | 26 ++++- src/realms/bluesky-api/handlers.ts | 52 +++++++++- src/realms/bluesky-api/router.ts | 6 ++ src/realms/bluesky-api/routes.ts | 81 ++++++++++++++++ 15 files changed, 595 insertions(+), 15 deletions(-) diff --git a/packages/atmosphere/src/providers/bluesky/client.ts b/packages/atmosphere/src/providers/bluesky/client.ts index 7143f5c5..b74f5d96 100644 --- a/packages/atmosphere/src/providers/bluesky/client.ts +++ b/packages/atmosphere/src/providers/bluesky/client.ts @@ -398,6 +398,67 @@ export const fetchSearchPosts = async ( return result; }; +/** + * `app.bsky.actor.searchActors` against the public AppView. Unlike `searchPosts`, actor search is + * served unauthenticated, so this works on a plain AppView with no proxy credentials. + */ +export const fetchSearchActors = async ( + params: { + q: string; + limit: number; + cursor?: string; + }, + opts?: BlueskyFetchOpts +): Promise< + { ok: true; data: BlueskySearchActorsResponse } | { ok: false; status: number; body: string } +> => { + const result = await executeBlueskyXrpc( + 'app.bsky.actor.searchActors', + { + q: params.q, + limit: params.limit, + cursor: params.cursor + }, + opts + ); + if (!result.ok) { + console.log('Bluesky searchActors failed', result.status, result.body?.slice?.(0, 200)); + } + return result; +}; + +/** + * `app.bsky.actor.searchActorsTypeahead` — the prefix-matching sibling of `searchActors`, meant + * for autocomplete while the user is still typing. No cursor: it answers one short page by design. + */ +export const fetchSearchActorsTypeahead = async ( + params: { + q: string; + limit: number; + }, + opts?: BlueskyFetchOpts +): Promise< + | { ok: true; data: BlueskySearchActorsTypeaheadResponse } + | { ok: false; status: number; body: string } +> => { + const result = await executeBlueskyXrpc( + 'app.bsky.actor.searchActorsTypeahead', + { + q: params.q, + limit: params.limit + }, + opts + ); + if (!result.ok) { + console.log( + 'Bluesky searchActorsTypeahead failed', + result.status, + result.body?.slice?.(0, 200) + ); + } + return result; +}; + const GET_PROFILES_MAX_ACTORS = 25; /** Batch `app.bsky.actor.getProfiles` (max 25 actors per request per lexicon). */ diff --git a/packages/atmosphere/src/providers/bluesky/search.ts b/packages/atmosphere/src/providers/bluesky/search.ts index 514c2013..56ab3300 100644 --- a/packages/atmosphere/src/providers/bluesky/search.ts +++ b/packages/atmosphere/src/providers/bluesky/search.ts @@ -1,6 +1,12 @@ -import type { APIBlueskyStatus, APISearchResultsBluesky } from '../../types/api-schemas.js'; +import type { + APIBlueskyStatus, + APISearchResultsBluesky, + APITypeaheadResponse, + APIUserListResults +} from '../../types/api-schemas.js'; import { buildAPIBlueskyPost } from './processor.js'; -import { fetchSearchPosts } from './client.js'; +import { fetchSearchActors, fetchSearchActorsTypeahead, fetchSearchPosts } from './client.js'; +import { blueskyProfileViewToApiUser } from './profileFollowers.js'; import { isBlueskyGalleryEmbed } from './gallery.js'; import type { BlueskyBuildHost } from './build-host.js'; @@ -101,3 +107,87 @@ export const blueskySearchAPI = async ( cursor: { top: null, bottom: nextCursor } }; }; + +/** + * People search via `app.bsky.actor.searchActors`. Returns the same `APIUserListResults` envelope + * as the follower and repost lists, so a client renders one profile list whatever produced it. + * + * `#profileView` carries no counts, so `followers`, `following`, and `statuses` come back 0 — a + * client that needs them fetches the full profile. `getFollowers` and `getLikes` behave the same. + */ +export const blueskySearchUsersAPI = async ( + options: { + q: string; + count: number; + cursor: string | null; + }, + opts?: { credentialKey?: string } +): Promise => { + const result = await fetchSearchActors( + { + q: options.q, + limit: options.count, + cursor: options.cursor ?? undefined + }, + { credentialKey: opts?.credentialKey } + ); + + if (!result.ok) { + if (result.status === 400 || result.status === 404) { + return { code: 404, results: [], cursor: { top: null, bottom: null } }; + } + return { code: 500, results: [], cursor: { top: null, bottom: null } }; + } + + const actors = result.data.actors ?? []; + + return { + code: 200, + results: actors.map(blueskyProfileViewToApiUser), + cursor: { top: null, bottom: result.data.cursor ?? null } + }; +}; + +/** + * Autocomplete while the user types, via `app.bsky.actor.searchActorsTypeahead`. + * + * Same envelope as FxTwitter `/2/typeahead` so one client call site serves both networks, but + * Bluesky indexes only accounts — there is no hashtag or event autocomplete behind it, so + * `topics` and `events` are always empty rather than absent. + */ +export const blueskyTypeaheadAPI = async ( + options: { + q: string; + count: number; + }, + opts?: { credentialKey?: string } +): Promise => { + const empty = (code: number): APITypeaheadResponse => ({ + code, + query: options.q, + num_results: 0, + users: [], + topics: [], + events: [] + }); + + const result = await fetchSearchActorsTypeahead( + { q: options.q, limit: options.count }, + { credentialKey: opts?.credentialKey } + ); + + if (!result.ok) { + return empty(result.status === 400 || result.status === 404 ? 404 : 500); + } + + const users = (result.data.actors ?? []).map(blueskyProfileViewToApiUser); + + return { + code: 200, + query: options.q, + num_results: users.length, + users, + topics: [], + events: [] + }; +}; diff --git a/packages/atmosphere/src/providers/mastodon/client.ts b/packages/atmosphere/src/providers/mastodon/client.ts index 40c484e3..12992339 100644 --- a/packages/atmosphere/src/providers/mastodon/client.ts +++ b/packages/atmosphere/src/providers/mastodon/client.ts @@ -36,15 +36,24 @@ const mastodonFetchInit = (signal: AbortSignal): RequestInit => ({ const isRedirectStatus = (s: number): boolean => s === 301 || s === 302 || s === 303 || s === 307 || s === 308; +/** + * Result of the redirect hop. Tagged explicitly rather than by `instanceof Response`: the two + * arms are told apart by a field we set ourselves, so this does not depend on the runtime's + * `Response` global being the same class the local `fetch` constructs. It is not, on React + * Native — and an `instanceof` test there silently returned the raw `Response` as if it were a + * result object, giving callers `ok: true` with `data: undefined`. + */ +type MastodonRedirectResult = { redirected: true; res: Response } | MastodonFetchErr; + /** Single same-host hop (e.g. trailing slash / canonical URL) without following cross-origin redirects. */ async function resolveMastodonRedirectIfNeeded( initialUrl: string, res: Response, expectedHost: string, signal: AbortSignal -): Promise { +): Promise { if (!isRedirectStatus(res.status)) { - return res; + return { redirected: true, res }; } const loc = res.headers.get('Location'); if (!loc) { @@ -63,11 +72,11 @@ async function resolveMastodonRedirectIfNeeded( body: `Mastodon redirect to different host rejected (${resolved.hostname})` }; } - return fetch(resolved.href, mastodonFetchInit(signal)); + return { redirected: true, res: await fetch(resolved.href, mastodonFetchInit(signal)) }; } -function isMastodonFetchErr(x: Response | MastodonFetchErr): x is MastodonFetchErr { - return !(x instanceof Response); +function isMastodonFetchErr(x: MastodonRedirectResult): x is MastodonFetchErr { + return !('redirected' in x); } async function mastodonFetch( @@ -92,7 +101,7 @@ async function mastodonFetch( clearTimeout(t); return afterRedirect; } - res = afterRedirect; + res = afterRedirect.res; } catch (e) { clearTimeout(t); const msg = e instanceof Error ? e.message : String(e); @@ -221,6 +230,25 @@ export const fetchAccountFollowing = async ( { limit: params.limit, max_id: params.max_id } ); +/** + * `GET /api/v2/search?type=accounts`. Unlike the statuses half of the same endpoint, account + * search is served to unauthenticated callers, and unlike `/api/v1/accounts/search` it does not + * require a token at all. `resolve` stays off so a query never makes the instance go fetch a + * remote account on our behalf. + */ +export const searchAccounts = async ( + domain: string, + q: string, + params: { limit: number; offset?: number } +): Promise> => + mastodonFetch(domain, '/api/v2/search', { + q, + type: 'accounts', + resolve: false, + limit: params.limit, + offset: params.offset + }); + export const searchStatuses = async ( domain: string, q: string, diff --git a/packages/atmosphere/src/providers/mastodon/search.ts b/packages/atmosphere/src/providers/mastodon/search.ts index 10ae21b0..9cc66a5c 100644 --- a/packages/atmosphere/src/providers/mastodon/search.ts +++ b/packages/atmosphere/src/providers/mastodon/search.ts @@ -1,6 +1,15 @@ -import type { APIMastodonStatus, APISearchResultsMastodon } from '../../types/api-schemas.js'; -import { assertSafeMastodonDomain, nextMaxIdFromLinkHeader, searchStatuses } from './client.js'; -import { buildAPIMastodonPost } from './processor.js'; +import type { + APIMastodonStatus, + APISearchResultsMastodon, + APIUserListResults +} from '../../types/api-schemas.js'; +import { + assertSafeMastodonDomain, + nextMaxIdFromLinkHeader, + searchAccounts, + searchStatuses +} from './client.js'; +import { buildAPIMastodonPost, mastodonAccountToApiUser } from './processor.js'; import type { MastodonBuildHost } from './build-host.js'; const decodeCursor = (cursor: string | null): { max_id?: string; offset?: number } | undefined => { @@ -114,3 +123,47 @@ export const mastodonSearchAPI = async ( cursor: { top: null, bottom } }; }; + +/** + * People search via `GET /api/v2/search?type=accounts`. + * + * Mastodon has no cross-instance account index, so results are whatever the queried instance has + * already federated. A large, well-connected instance therefore finds far more accounts than a + * small one — the domain the caller picks is the search corpus, not just the transport. + * + * Single page by design: Mastodon serves the first page of search to anonymous callers but + * answers any `offset` with 401 ("Search queries pagination is not supported without + * authentication"), and Atmosphere talks to instances unauthenticated. Emitting a cursor here + * would only hand the caller a "load more" that always fails, so `cursor.bottom` stays null. + */ +export const mastodonSearchUsersAPI = async ( + domain: string, + options: { + q: string; + count: number; + } +): Promise => { + try { + assertSafeMastodonDomain(domain); + } catch { + return { code: 400, results: [], cursor: { top: null, bottom: null } }; + } + + const result = await searchAccounts(domain, options.q, { limit: options.count }); + + if (!result.ok) { + if (result.status === 401) { + return { code: 401, results: [], cursor: { top: null, bottom: null } }; + } + if (result.status === 404 || result.status === 400) { + return { code: 404, results: [], cursor: { top: null, bottom: null } }; + } + return { code: 500, results: [], cursor: { top: null, bottom: null } }; + } + + return { + code: 200, + results: (result.data.accounts ?? []).map(a => mastodonAccountToApiUser(a, domain)), + cursor: { top: null, bottom: null } + }; +}; diff --git a/packages/atmosphere/src/providers/twitter/search.ts b/packages/atmosphere/src/providers/twitter/search.ts index 1f3febfb..7a2c0924 100644 --- a/packages/atmosphere/src/providers/twitter/search.ts +++ b/packages/atmosphere/src/providers/twitter/search.ts @@ -10,7 +10,13 @@ import { searchTimelineClientErrorToApiQueryError, TWITTER_SEARCH_RAW_QUERY_MAX_LENGTH } from './searchErrors.js'; -import type { APISearchResults, APITwitterStatus, ApiQueryError } from '../../types/api-schemas.js'; +import type { + APISearchResults, + APITwitterStatus, + APIUserListResults, + ApiQueryError +} from '../../types/api-schemas.js'; +import { convertToApiUser } from './profile.js'; import type { FetchResults } from '../../types/fetch-results.js'; import type { TwitterBuildHost } from './build-host.js'; @@ -506,3 +512,66 @@ export const searchAPI = async ( } }; }; + +/** + * People search — the same `SearchTimeline` query with `product: 'People'`, which returns + * `TimelineUser` rows instead of tweets. Reuses the follower/reposter row extractor, so the + * envelope matches `/2/profile/{handle}/followers` and a client renders one profile list. + */ +export const searchUsersAPI = async ( + query: string, + count: number, + cursor: string | null, + host: TwitterBuildHost, + language?: string +): Promise => { + if (query.length > TWITTER_SEARCH_RAW_QUERY_MAX_LENGTH) { + return searchQueryTooLongError(query.length); + } + + let response: TwitterSearchTimelineResponse | null; + + try { + response = (await graphqlRequest(host, { + query: SearchTimelineQuery, + variables: { + rawQuery: query, + count, + product: 'People', + cursor: cursor ?? null + }, + headers: buildLanguageHeaders(language), + validator: (_response: unknown) => { + if (isSearchTimelineClientErrorResponse(_response)) { + return true; + } + const r = _response as TwitterSearchTimelineResponse; + return Array.isArray(r?.data?.search_by_raw_query?.search_timeline?.timeline?.instructions); + } + })) as TwitterSearchTimelineResponse; + } catch (e) { + console.error('User search request failed', e); + return { code: 500, results: [], cursor: { top: null, bottom: null } }; + } + + const clientError = parseSearchTimelineClientError(response); + if (clientError) { + return searchTimelineClientErrorToApiQueryError(clientError); + } + + const instructions = response?.data?.search_by_raw_query?.search_timeline?.timeline?.instructions; + if (!instructions) { + return { code: 404, results: [], cursor: { top: null, bottom: null } }; + } + + const { users, cursors } = processUserRelationshipTimelineInstructionsImpl(instructions); + + return { + code: 200, + results: users.map(user => convertToApiUser(user)), + cursor: { + top: cursors.find(cur => cur.cursorType === 'Top')?.value ?? null, + bottom: cursors.find(cur => cur.cursorType === 'Bottom')?.value ?? null + } + }; +}; diff --git a/packages/atmosphere/src/raw/vendor/bluesky.d.ts b/packages/atmosphere/src/raw/vendor/bluesky.d.ts index f372a7bd..b07206e9 100644 --- a/packages/atmosphere/src/raw/vendor/bluesky.d.ts +++ b/packages/atmosphere/src/raw/vendor/bluesky.d.ts @@ -325,6 +325,17 @@ declare type BlueskySearchPostsResponse = { hitsTotal?: number; }; +/** `app.bsky.actor.searchActors` output (subset; `actors` are `#profileView`). */ +declare type BlueskySearchActorsResponse = { + actors?: BlueskyProfileView[]; + cursor?: string; +}; + +/** `app.bsky.actor.searchActorsTypeahead` output (subset; no cursor — prefix autocomplete only). */ +declare type BlueskySearchActorsTypeaheadResponse = { + actors?: BlueskyProfileView[]; +}; + /** `app.bsky.unspecced.getTrendingTopics` (subset; public AppView). */ declare type BlueskyTrendingTopicRow = { topic: string; diff --git a/src/providers/mastodon/atmosphere-handlers.ts b/src/providers/mastodon/atmosphere-handlers.ts index 2571f812..bb1d2ab0 100644 --- a/src/providers/mastodon/atmosphere-handlers.ts +++ b/src/providers/mastodon/atmosphere-handlers.ts @@ -14,6 +14,7 @@ import { mastodonProfileMediaV2Route, mastodonProfileStatusesV2Route, mastodonProfileV2Route, + mastodonSearchUsersV2Route, mastodonSearchV2Route, mastodonStatusLikesV2Route, mastodonStatusRepostsV2Route, @@ -33,7 +34,10 @@ import { mastodonProfileMediaAPI, mastodonProfileStatusesAPI } from '@fxembed/atmosphere/providers/mastodon/profileStatuses'; -import { mastodonSearchAPI } from '@fxembed/atmosphere/providers/mastodon/search'; +import { + mastodonSearchAPI, + mastodonSearchUsersAPI +} from '@fxembed/atmosphere/providers/mastodon/search'; import { mastodonStatusLikesAPI } from '@fxembed/atmosphere/providers/mastodon/statusLikes'; import { mastodonStatusRepostsAPI } from '@fxembed/atmosphere/providers/mastodon/statusReposts'; import { mastodonBuildHostFromContext } from './build-host-adapter'; @@ -163,6 +167,25 @@ export const mastodonSearchAPIRequest: RouteHandler(c, payload, httpStatus); }; +export const mastodonSearchUsersAPIRequest: RouteHandler< + typeof mastodonSearchUsersV2Route +> = async c => { + const { domain } = c.req.valid('param'); + const query = c.req.valid('query'); + const searchResponse = await mastodonSearchUsersAPI(domain, { + q: query.q, + count: query.count ?? 30 + }); + const { httpStatus, payload } = normalizeApiJsonResponse( + searchResponse, + [200, 400, 401, 404, 500] as const, + 'mastodonSearchUsersAPIRequest' + ); + c.status(httpStatus); + setApiHeaders(c); + return jsonAfterNormalize(c, payload, httpStatus); +}; + export const mastodonProfileAPIRequest: RouteHandler = async c => { const { domain, handle } = c.req.valid('param'); const processedResponse = await mastodonUserProfileAPI(handle, domain); diff --git a/src/providers/mastodon/atmosphere-register.ts b/src/providers/mastodon/atmosphere-register.ts index 13f79f60..715b1faa 100644 --- a/src/providers/mastodon/atmosphere-register.ts +++ b/src/providers/mastodon/atmosphere-register.ts @@ -7,6 +7,7 @@ import { mastodonProfileMediaAPIRequest, mastodonProfileStatusesAPIRequest, mastodonSearchAPIRequest, + mastodonSearchUsersAPIRequest, mastodonStatusAPIRequest, mastodonStatusLikesAPIRequest, mastodonStatusRepostsAPIRequest, @@ -19,6 +20,7 @@ import { mastodonProfileMediaV2Route, mastodonProfileStatusesV2Route, mastodonProfileV2Route, + mastodonSearchUsersV2Route, mastodonSearchV2Route, mastodonStatusLikesV2Route, mastodonStatusRepostsV2Route, @@ -33,6 +35,7 @@ export const registerMastodonAtmosphereRoutes = (atmosphere: OpenAPIHono) => { atmosphere.openapi(mastodonThreadV2Route, mastodonThreadAPIRequest); atmosphere.openapi(mastodonConversationV2Route, mastodonConversationAPIRequest); atmosphere.openapi(mastodonSearchV2Route, mastodonSearchAPIRequest); + atmosphere.openapi(mastodonSearchUsersV2Route, mastodonSearchUsersAPIRequest); atmosphere.openapi(mastodonProfileV2Route, mastodonProfileAPIRequest); atmosphere.openapi(mastodonProfileFollowersV2Route, mastodonProfileFollowersAPIRequest); atmosphere.openapi(mastodonProfileFollowingV2Route, mastodonProfileFollowingAPIRequest); diff --git a/src/providers/mastodon/atmosphere-routes.ts b/src/providers/mastodon/atmosphere-routes.ts index dd264df2..e4af25fa 100644 --- a/src/providers/mastodon/atmosphere-routes.ts +++ b/src/providers/mastodon/atmosphere-routes.ts @@ -255,6 +255,43 @@ export const mastodonSearchV2Route = createRoute({ } }); +export const mastodonSearchUsersV2Route = createRoute({ + method: 'get', + path: '/2/mastodon/{domain}/search/users', + summary: 'Search accounts on an instance', + description: + 'Uses Mastodon `GET /api/v2/search?type=accounts`, which (unlike the statuses half of the same endpoint) is served to unauthenticated callers. Same envelope as `/2/mastodon/{domain}/profile/{handle}/followers` (`code`, `results`, `cursor`). There is no cross-instance account index, so results are whatever `{domain}` has already federated — a large instance is a much larger corpus than a small one. Single page: Mastodon rejects any paginated search from an anonymous caller, so `cursor.bottom` is always null.', + request: { + params: domainParam, + query: z.object({ + q: mastodonSearchQueryString({ description: 'Search query', example: 'gargron' }), + count: z.coerce.number().int().min(1).max(100).optional().openapi({ default: 30 }) + }) + }, + responses: { + 200: { + description: 'Matching accounts', + content: { 'application/json': { schema: APIUserListResultsSchema } } + }, + 400: { + description: 'Invalid query', + content: { 'application/json': { schema: ApiQueryErrorSchema } } + }, + 401: { + description: 'Upstream requires authentication', + content: { 'application/json': { schema: APIUserListResultsSchema } } + }, + 404: { + description: 'No results', + content: { 'application/json': { schema: APIUserListResultsSchema } } + }, + 500: { + description: 'Upstream error', + content: { 'application/json': { schema: APIUserListResultsSchema } } + } + } +}); + export const mastodonProfileV2Route = createRoute({ method: 'get', path: '/2/mastodon/{domain}/profile/{handle}', diff --git a/src/realms/api/router.ts b/src/realms/api/router.ts index 6ae083bf..289b8ab1 100644 --- a/src/realms/api/router.ts +++ b/src/realms/api/router.ts @@ -15,6 +15,7 @@ import { profileFollowingAPIRequest, profileStatusesAPIRequest, searchAPIRequest, + searchUsersAPIRequest, statusAPIRequest, statusRepostsAPIRequest, statusQuotesAPIRequest, @@ -34,6 +35,7 @@ import { profileFollowingV2Route, profileStatusesV2Route, profileV2Route, + searchUsersV2Route, searchV2Route, statusV2Route, statusRepostsV2Route, @@ -77,6 +79,7 @@ api.openapi(profileFollowersV2Route, profileFollowersAPIRequest); api.openapi(profileFollowingV2Route, profileFollowingAPIRequest); api.openapi(profileV2Route, profileAPIRequest); api.openapi(searchV2Route, searchAPIRequest); +api.openapi(searchUsersV2Route, searchUsersAPIRequest); api.openapi(typeaheadV2Route, typeaheadAPIRequest); api.openapi(trendsV2Route, trendsAPIRequest); diff --git a/src/realms/api/routes.ts b/src/realms/api/routes.ts index 59d7c757..eb9e46bf 100644 --- a/src/realms/api/routes.ts +++ b/src/realms/api/routes.ts @@ -614,6 +614,47 @@ export const searchV2Route = createRoute({ } }); +export const searchUsersV2Route = createRoute({ + method: 'get', + path: '/2/search/users', + summary: 'Search people', + description: + 'Search accounts using the People tab of X search. Same envelope as `/2/profile/{handle}/followers` (`code`, `results`, `cursor`). Pass `cursor.bottom` back as `cursor` for the next page. `/2/typeahead` is the lighter, unpaginated autocomplete over the same corpus.', + request: { + query: z.object({ + q: twitterSearchQueryString({ + description: `Search query (non-empty, max ${TWITTER_SEARCH_RAW_QUERY_MAX_LENGTH} characters)`, + example: 'jack', + maxLength: TWITTER_SEARCH_RAW_QUERY_MAX_LENGTH + }), + count: z.coerce.number().int().min(1).max(100).optional().openapi({ + description: 'Page size (default 30)', + default: 30 + }), + cursor: z.string().optional(), + ...langQuery.shape + }) + }, + responses: { + 200: { + description: 'Matching accounts', + content: { 'application/json': { schema: APIUserListResultsSchema } } + }, + 400: { + description: `Invalid \`q\` parameter (empty or longer than ${TWITTER_SEARCH_RAW_QUERY_MAX_LENGTH} characters)`, + content: { 'application/json': { schema: ApiQueryErrorSchema } } + }, + 404: { + description: 'No results or timeline unavailable', + content: { 'application/json': { schema: APIUserListResultsSchema } } + }, + 500: { + description: 'Upstream or processing error', + content: { 'application/json': { schema: APIUserListResultsSchema } } + } + } +}); + const trendsTypeDescription = `Explore timeline kind. Supported: ${PUBLIC_EXPLORE_TIMELINE_KINDS.join(', ')}`; const typeaheadResultTypeDescription = diff --git a/src/realms/api/routes/twitter.ts b/src/realms/api/routes/twitter.ts index 6df3c00c..8d51a807 100644 --- a/src/realms/api/routes/twitter.ts +++ b/src/realms/api/routes/twitter.ts @@ -12,7 +12,7 @@ import { profileAboutAPI } from '@fxembed/atmosphere/providers/twitter/profile'; import { attachAboutAccountData } from '@fxembed/atmosphere/providers/twitter/aboutAccount'; -import { searchAPI } from '@fxembed/atmosphere/providers/twitter/search'; +import { searchAPI, searchUsersAPI } from '@fxembed/atmosphere/providers/twitter/search'; import { profileArticlesAPI, profileFollowersAPI, @@ -38,6 +38,7 @@ import { profileAboutV2Route, profileStatusesV2Route, profileV2Route, + searchUsersV2Route, searchV2Route, statusV2Route, statusRepostsV2Route, @@ -392,6 +393,29 @@ export const searchAPIRequest: RouteHandler = async c => { return jsonAfterNormalize(c, payload, httpStatus); }; +export const searchUsersAPIRequest: RouteHandler = async c => { + const host = twitterBuildHostFromContext(c); + const query = c.req.valid('query'); + + const searchResponse = await searchUsersAPI( + query.q, + query.count ?? 30, + query.cursor ?? null, + host, + query.lang + ); + const { httpStatus, payload } = normalizeApiJsonResponse( + searchResponse, + [200, 400, 404, 500] as const, + 'searchUsersAPIRequest' + ); + c.status(httpStatus); + for (const [header, value] of Object.entries(Constants.API_RESPONSE_HEADERS)) { + c.header(header, value); + } + return jsonAfterNormalize(c, payload, httpStatus); +}; + export const trendsAPIRequest: RouteHandler = async c => { const host = twitterBuildHostFromContext(c); const query = c.req.valid('query'); diff --git a/src/realms/bluesky-api/handlers.ts b/src/realms/bluesky-api/handlers.ts index 97ad6f77..d638453c 100644 --- a/src/realms/bluesky-api/handlers.ts +++ b/src/realms/bluesky-api/handlers.ts @@ -18,7 +18,11 @@ import { blueskyProfileFollowersAPI, blueskyProfileFollowingAPI } from '@fxembed/atmosphere/providers/bluesky/profileFollowers'; -import { blueskySearchAPI } from '@fxembed/atmosphere/providers/bluesky/search'; +import { + blueskySearchAPI, + blueskySearchUsersAPI, + blueskyTypeaheadAPI +} from '@fxembed/atmosphere/providers/bluesky/search'; import { blueskyTrendsAPI } from '@fxembed/atmosphere/providers/bluesky/trends'; import { blueskyStatusLikesAPI } from '@fxembed/atmosphere/providers/bluesky/statusLikes'; import { blueskyStatusRepostsAPI } from '@fxembed/atmosphere/providers/bluesky/statusReposts'; @@ -30,8 +34,10 @@ import { blueskyProfileMediaV2Route, blueskyProfileStatusesV2Route, blueskyProfileV2Route, + blueskySearchUsersV2Route, blueskySearchV2Route, blueskyTrendsV2Route, + blueskyTypeaheadV2Route, blueskyStatusLikesV2Route, blueskyStatusRepostsV2Route, blueskyStatusV2Route, @@ -213,6 +219,50 @@ export const blueskySearchAPIRequest: RouteHandler return jsonAfterNormalize(c, payload, httpStatus); }; +export const blueskySearchUsersAPIRequest: RouteHandler< + typeof blueskySearchUsersV2Route +> = async c => { + const query = c.req.valid('query'); + const searchResponse = await blueskySearchUsersAPI( + { + q: query.q, + count: query.count ?? 30, + cursor: query.cursor ?? null + }, + { credentialKey: c.env?.CREDENTIAL_KEY } + ); + + const { httpStatus, payload } = normalizeApiJsonResponse( + searchResponse, + [200, 400, 404, 500] as const, + 'blueskySearchUsersAPIRequest' + ); + c.status(httpStatus); + for (const [header, value] of Object.entries(Constants.API_RESPONSE_HEADERS)) { + c.header(header, value); + } + return jsonAfterNormalize(c, payload, httpStatus); +}; + +export const blueskyTypeaheadAPIRequest: RouteHandler = async c => { + const query = c.req.valid('query'); + const response = await blueskyTypeaheadAPI( + { q: query.q, count: query.count ?? 8 }, + { credentialKey: c.env?.CREDENTIAL_KEY } + ); + + const { httpStatus, payload } = normalizeApiJsonResponse( + response, + [200, 400, 404, 500] as const, + 'blueskyTypeaheadAPIRequest' + ); + c.status(httpStatus); + for (const [header, value] of Object.entries(Constants.API_RESPONSE_HEADERS)) { + c.header(header, value); + } + return jsonAfterNormalize(c, payload, httpStatus); +}; + export const blueskyTrendsAPIRequest: RouteHandler = async c => { const query = c.req.valid('query'); const type = query.type ?? 'trending'; diff --git a/src/realms/bluesky-api/router.ts b/src/realms/bluesky-api/router.ts index ca8c702e..a9958e6d 100644 --- a/src/realms/bluesky-api/router.ts +++ b/src/realms/bluesky-api/router.ts @@ -12,6 +12,8 @@ import { blueskyProfileMediaV2Route, blueskyProfileStatusesV2Route, blueskyProfileV2Route, + blueskySearchUsersV2Route, + blueskyTypeaheadV2Route, blueskySearchV2Route, blueskyTrendsV2Route, blueskyStatusLikesV2Route, @@ -28,6 +30,8 @@ import { blueskyProfileMediaAPIRequest, blueskyProfileStatusesAPIRequest, blueskySearchAPIRequest, + blueskySearchUsersAPIRequest, + blueskyTypeaheadAPIRequest, blueskyTrendsAPIRequest, blueskyStatusAPIRequest, blueskyStatusLikesAPIRequest, @@ -58,6 +62,8 @@ blueskyApi.openapi(blueskyStatusLikesV2Route, blueskyStatusLikesAPIRequest); blueskyApi.openapi(blueskyThreadV2Route, blueskyThreadAPIRequest); blueskyApi.openapi(blueskyConversationV2Route, blueskyConversationAPIRequest); blueskyApi.openapi(blueskySearchV2Route, blueskySearchAPIRequest); +blueskyApi.openapi(blueskySearchUsersV2Route, blueskySearchUsersAPIRequest); +blueskyApi.openapi(blueskyTypeaheadV2Route, blueskyTypeaheadAPIRequest); blueskyApi.openapi(blueskyTrendsV2Route, blueskyTrendsAPIRequest); blueskyApi.openapi(blueskyProfileV2Route, blueskyProfileAPIRequest); blueskyApi.openapi(blueskyProfileFollowersV2Route, blueskyProfileFollowersAPIRequest); diff --git a/src/realms/bluesky-api/routes.ts b/src/realms/bluesky-api/routes.ts index 3dc75fc2..251d1bc8 100644 --- a/src/realms/bluesky-api/routes.ts +++ b/src/realms/bluesky-api/routes.ts @@ -1,6 +1,7 @@ import { createRoute, z } from '@hono/zod-openapi'; import { APIProfileRelationshipListSchema, + APITypeaheadResponseSchema, APIUserListResultsSchema, ApiQueryErrorSchema, APISearchResultsBlueskySchema, @@ -314,6 +315,86 @@ export const blueskySearchV2Route = createRoute({ } }); +export const blueskySearchUsersV2Route = createRoute({ + method: 'get', + path: '/2/search/users', + summary: 'Search people', + description: + 'Search accounts via Bluesky `app.bsky.actor.searchActors`. Same envelope as `/2/profile/{handle}/followers` (`code`, `results`, `cursor`); pass `cursor.bottom` back as `cursor` for the next page. Rows come from `#profileView`, which carries no counts, so `followers`, `following`, and `statuses` are 0 — fetch `/2/profile/{handle}` for a full profile.', + request: { + query: z.object({ + q: blueskySearchQueryString({ + description: 'Search query (non-empty)', + example: 'jay' + }), + count: z.coerce.number().int().min(1).max(100).optional().openapi({ + description: 'Page size (default 30)', + default: 30 + }), + cursor: z + .string() + .optional() + .openapi({ description: 'Pagination cursor from prior response (`cursor.bottom`)' }) + }) + }, + responses: { + 200: { + description: 'Matching accounts', + content: { 'application/json': { schema: APIUserListResultsSchema } } + }, + 400: { + description: 'Invalid `q` parameter', + content: { 'application/json': { schema: ApiQueryErrorSchema } } + }, + 404: { + description: 'No results or search unavailable', + content: { 'application/json': { schema: APIUserListResultsSchema } } + }, + 500: { + description: 'Upstream or processing error', + content: { 'application/json': { schema: APIUserListResultsSchema } } + } + } +}); + +export const blueskyTypeaheadV2Route = createRoute({ + method: 'get', + path: '/2/typeahead', + summary: 'Search typeahead suggestions', + description: + 'Account autocomplete via Bluesky `app.bsky.actor.searchActorsTypeahead`, in the same envelope as FxTwitter `GET /2/typeahead`. Bluesky indexes only accounts for autocomplete, so `topics` and `events` are always empty. Unpaginated by design — use `/2/search/users` for a full, cursored people search.', + request: { + query: z.object({ + q: blueskySearchQueryString({ + description: 'Prefix or query string', + example: 'jac' + }), + count: z.coerce.number().int().min(1).max(50).optional().openapi({ + description: 'Maximum suggestions (default 8)', + default: 8 + }) + }) + }, + responses: { + 200: { + description: 'Typeahead payload', + content: { 'application/json': { schema: APITypeaheadResponseSchema } } + }, + 400: { + description: 'Invalid `q` parameter', + content: { 'application/json': { schema: ApiQueryErrorSchema } } + }, + 404: { + description: 'No suggestions or upstream returned an error payload', + content: { 'application/json': { schema: APITypeaheadResponseSchema } } + }, + 500: { + description: 'Upstream or processing error', + content: { 'application/json': { schema: APITypeaheadResponseSchema } } + } + } +}); + const blueskyTrendsTypeDescription = `Trend list. \`trending\` returns Bluesky live topics first, then suggested topic feeds to fill \`count\`. \`suggested\` returns only suggested feeds. Upstream: \`app.bsky.unspecced.getTrendingTopics\` (max 25 rows per request).`; export const blueskyTrendsV2Route = createRoute({ From 3f5d7b6c5fb782aa5df35fc290e576fc63717fef Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:49:47 +0000 Subject: [PATCH 4/9] chore(deps): update dependency vitest to ^4.1.11 (#2388) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 92 ++++++++++++++++---------------- package.json | 2 +- packages/atmosphere/package.json | 2 +- 3 files changed, 48 insertions(+), 48 deletions(-) diff --git a/package-lock.json b/package-lock.json index c63f0bc7..67d6ce1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,7 +43,7 @@ "tsx": "^4.23.12", "typescript": "^6.0.3", "typescript-eslint": "^8.67.0", - "vitest": "^4.1.10", + "vitest": "^4.1.11", "wrangler": "^4.107.1" } }, @@ -4403,16 +4403,16 @@ "license": "ISC" }, "node_modules/@vitest/expect": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -4421,13 +4421,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.10", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -4448,9 +4448,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", - "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { @@ -4461,13 +4461,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.10", + "@vitest/utils": "4.1.11", "pathe": "^2.0.3" }, "funding": { @@ -4475,14 +4475,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", - "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -4491,9 +4491,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", - "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { @@ -4501,13 +4501,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", - "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", + "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -8018,19 +8018,19 @@ } }, "node_modules/vitest": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", - "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.10", - "@vitest/mocker": "4.1.10", - "@vitest/pretty-format": "4.1.10", - "@vitest/runner": "4.1.10", - "@vitest/snapshot": "4.1.10", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -8058,12 +8058,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.10", - "@vitest/browser-preview": "4.1.10", - "@vitest/browser-webdriverio": "4.1.10", - "@vitest/coverage-istanbul": "4.1.10", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -8458,7 +8458,7 @@ "devDependencies": { "@types/node": "^24.13.3", "typescript": "^6.0.3", - "vitest": "^4.1.10" + "vitest": "^4.1.11" } }, "packages/atmosphere/node_modules/@types/node": { diff --git a/package.json b/package.json index a5bb1c33..fd8df518 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "tsx": "^4.23.12", "typescript": "^6.0.3", "typescript-eslint": "^8.67.0", - "vitest": "^4.1.10", + "vitest": "^4.1.11", "wrangler": "^4.107.1" }, "dependencies": { diff --git a/packages/atmosphere/package.json b/packages/atmosphere/package.json index 6e4ec7c4..f86d52bb 100644 --- a/packages/atmosphere/package.json +++ b/packages/atmosphere/package.json @@ -108,7 +108,7 @@ "devDependencies": { "@types/node": "^24.13.3", "typescript": "^6.0.3", - "vitest": "^4.1.10" + "vitest": "^4.1.11" }, "dependencies": { "@hono/zod-openapi": "^1.6.0", From 99afd818eef3e13399ba75d5ca49746e388d3b62 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 05:08:56 +0000 Subject: [PATCH 5/9] fix(deps): update dependency @hono/zod-openapi to ^1.6.1 (#2389) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 10 +++++----- package.json | 2 +- packages/atmosphere/package.json | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 67d6ce1b..3d658c0a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "dependencies": { "@fxembed/atmosphere": "*", "@hono/sentry": "^1.2.2", - "@hono/zod-openapi": "^1.6.0", + "@hono/zod-openapi": "^1.6.1", "cheerio": "^1.2.0", "hono": "^4.13.2", "i18next": "^26.3.6", @@ -2304,9 +2304,9 @@ } }, "node_modules/@hono/zod-openapi": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@hono/zod-openapi/-/zod-openapi-1.6.0.tgz", - "integrity": "sha512-42HXUBIaGmQh+hZGLw3Hy5piOreIXgnBnUSs55mtn2gnW/xWtU9nQwEUtRRCk5to8Vm2XbfO0J83eUCwCf5eTg==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@hono/zod-openapi/-/zod-openapi-1.6.1.tgz", + "integrity": "sha512-z1xS3FZxl4bBkU3kMIsQsLtsqxPTha5XZCsDEPMSZ6+3RRBQI7KED1GeoDpq2WueXCacsNIjcB0MtCnEHY7ahQ==", "license": "MIT", "dependencies": { "@asteasolutions/zod-to-openapi": "^9.1.0", @@ -8452,7 +8452,7 @@ "version": "0.0.1", "license": "MIT", "dependencies": { - "@hono/zod-openapi": "^1.6.0", + "@hono/zod-openapi": "^1.6.1", "zod": "^4.4.3" }, "devDependencies": { diff --git a/package.json b/package.json index fd8df518..a8260b49 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "dependencies": { "@fxembed/atmosphere": "*", "@hono/sentry": "^1.2.2", - "@hono/zod-openapi": "^1.6.0", + "@hono/zod-openapi": "^1.6.1", "cheerio": "^1.2.0", "hono": "^4.13.2", "i18next": "^26.3.6", diff --git a/packages/atmosphere/package.json b/packages/atmosphere/package.json index f86d52bb..cb55719c 100644 --- a/packages/atmosphere/package.json +++ b/packages/atmosphere/package.json @@ -111,7 +111,7 @@ "vitest": "^4.1.11" }, "dependencies": { - "@hono/zod-openapi": "^1.6.0", + "@hono/zod-openapi": "^1.6.1", "zod": "^4.4.3" } } From a70413162a92a526ad970d9d3036fca695570466 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 05:13:49 +0000 Subject: [PATCH 6/9] fix(deps): update dependency astro to ^7.2.4 (#2392) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docs/package-lock.json | 370 +++++++++++++++++++++++++++++------------ docs/package.json | 2 +- 2 files changed, 264 insertions(+), 108 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index dc2e6fee..5a0b97d2 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "dependencies": { "@astrojs/starlight": "^0.41.7", - "astro": "^7.2.2", + "astro": "^7.2.4", "starlight-openapi": "^0.26.1" } }, @@ -173,61 +173,6 @@ "node": ">=14.0.0" } }, - "node_modules/@astrojs/compiler-binding-wasm32-wasi/node_modules/@emnapi/core": { - "version": "2.0.0-alpha.3", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", - "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "2.0.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@astrojs/compiler-binding-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "2.0.0-alpha.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", - "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@astrojs/compiler-binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", - "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@astrojs/compiler-binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.0.tgz", - "integrity": "sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA==", - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.3" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=23.5.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^2.0.0-alpha.3", - "@emnapi/runtime": "^2.0.0-alpha.3" - } - }, "node_modules/@astrojs/compiler-binding-win32-arm64-msvc": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-win32-arm64-msvc/-/compiler-binding-win32-arm64-msvc-0.3.2.tgz", @@ -288,42 +233,200 @@ "unified": "^11.0.5" } }, - "node_modules/@astrojs/markdown-remark": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-7.2.2.tgz", - "integrity": "sha512-FGfmK84zSNcrsBd0dl1gXE9JvZYElp8EXQa2jpHVAxG4deGKAp43wspxFupjADJX7MSsMRHwYCnfT6EyVmgeFQ==", + "node_modules/@astrojs/markdown-satteri": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-satteri/-/markdown-satteri-0.3.7.tgz", + "integrity": "sha512-NHcHbrKW/opbZnTZQ5nH293BdcK2VV0tjuzI88CLnvy2njiEJVwUQ5KFnYd4NoWgHJCY/I1CyjGWCR4Vv3khXQ==", "license": "MIT", "dependencies": { - "@astrojs/internal-helpers": "0.10.2", + "@astrojs/internal-helpers": "0.10.4", "@astrojs/prism": "4.0.2", "github-slugger": "^2.0.0", - "hast-util-from-html": "^2.0.3", - "hast-util-to-text": "^4.0.2", - "mdast-util-definitions": "^6.0.0", - "rehype-raw": "^7.0.0", - "rehype-stringify": "^10.0.1", - "remark-gfm": "^4.0.1", - "remark-parse": "^11.0.0", - "remark-rehype": "^11.1.2", - "remark-smartypants": "^3.0.2", - "unified": "^11.0.5", - "unist-util-remove-position": "^5.0.0", - "unist-util-visit": "^5.1.0", - "unist-util-visit-parents": "^6.0.2", - "vfile": "^6.0.3" + "satteri": "^0.10.3" } }, - "node_modules/@astrojs/markdown-satteri": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@astrojs/markdown-satteri/-/markdown-satteri-0.3.5.tgz", - "integrity": "sha512-CvWVEFAbay7YO+i9SaqDJubipA5ckiVB89QWoMJ5XC0m5CtFg8JwZ7Kau6X9sYY7FZURH0w2l03ISH2jOS/RDQ==", + "node_modules/@astrojs/markdown-satteri/node_modules/@astrojs/internal-helpers": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.10.4.tgz", + "integrity": "sha512-nozZSy/mKYLqe4YrqbKtdOszedAfXYCtw3wZ0d+CAjz4GqQ4L9rl1ltIL5BlgwmYVinJg/RZ0MgGuWOdlyRZlA==", "license": "MIT", "dependencies": { - "@astrojs/internal-helpers": "0.10.2", - "@astrojs/prism": "4.0.2", - "github-slugger": "^2.0.0", - "hast-util-from-html": "^2.0.3", - "satteri": "^0.9.1" + "@types/hast": "^3.0.4", + "@types/mdast": "^4.0.4", + "js-yaml": "^4.3.0", + "picomatch": "^4.0.4", + "retext-smartypants": "^6.2.0", + "shiki": "^4.0.2", + "smol-toml": "^1.6.0", + "unified": "^11.0.5" + } + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@bruits/satteri-darwin-arm64": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-arm64/-/satteri-darwin-arm64-0.10.5.tgz", + "integrity": "sha512-27KTVl4TJkVahMy/ohyA7qd4938G5UNneFUz/PsScYfpIhj0IVAS23mpcJXdPF44sa6nva198lmV/cKIb2YPyA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@bruits/satteri-darwin-x64": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-x64/-/satteri-darwin-x64-0.10.5.tgz", + "integrity": "sha512-IjnLe3nKspq6qaeqGgjT7MT8VrTV74yWRlaag7ZdNsI8TDAYZ0iPxMCo+9KQZHUk5EyVB+reBI/PFWL5KuFw9Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@bruits/satteri-linux-arm64-gnu": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-gnu/-/satteri-linux-arm64-gnu-0.10.5.tgz", + "integrity": "sha512-glkYXZCJywjP13v67eAyAMSJdF+ncvEbYvgi/wOtffL9tQ27lr/zsyzUfgs+ovjJ9d8JNQKiXeiArJcX8PJL9w==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@bruits/satteri-linux-arm64-musl": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-musl/-/satteri-linux-arm64-musl-0.10.5.tgz", + "integrity": "sha512-yWdgG1g17Nh2QyGVlFUxGRa3FEFwiMcpZEyMNWkbM3deC94cmVc+/i9OuyFpdKuWo3GkgoCtYVOoxk1uCnCZIA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@bruits/satteri-linux-x64-gnu": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-x64-gnu/-/satteri-linux-x64-gnu-0.10.5.tgz", + "integrity": "sha512-FVaLoPT1fBgGl0J+AYebyyXJYBachGl8Oyyrf1lye4RTqCB4S0Gwkj1uM9RJyThUOvx5VUmAT1CnNh1SFHA+kw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@bruits/satteri-linux-x64-musl": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-x64-musl/-/satteri-linux-x64-musl-0.10.5.tgz", + "integrity": "sha512-EHpVAx2bqW3GINHTKkljtxVfQmVDGWIuwOYOP5YghTj+0PkBa2o8oKPRtQ9Kbsr1Fye8jtUcDjhwj2jMNugZKg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@bruits/satteri-wasm32-wasi": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-wasm32-wasi/-/satteri-wasm32-wasi-0.10.5.tgz", + "integrity": "sha512-ypz8c/Zmipxp4IoeDa228Gstv6TLzVmNs3yC6wKCoNSOjx1iwpgzu87Y3hTkXFdwChVGU85qeUDuOIarGUZQLw==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.2.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@bruits/satteri-win32-arm64-msvc": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-arm64-msvc/-/satteri-win32-arm64-msvc-0.10.5.tgz", + "integrity": "sha512-siTV88nb0LRqNpkL2gXboqCwVdq95sLtzMHS1/3eONV2gLbB3NAK46wmSMvCO/yquBvI2lvaFIfd8P12ecsxBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@bruits/satteri-win32-x64-msvc": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-x64-msvc/-/satteri-win32-x64-msvc-0.10.5.tgz", + "integrity": "sha512-C3IfPvfvMXmlzBxaMPKFS1XiuV9pu2mC7YqkPk7PSvTgPZ8gbdASIpHpztDLvTTQjqZ0z1Ol8tK5X+V6XXC0wQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@astrojs/markdown-satteri/node_modules/satteri": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/satteri/-/satteri-0.10.5.tgz", + "integrity": "sha512-Ao1LKpAEa9Wdg0otgbVKViZHEq9ebdXe4DMrp3s9vQAU0HNIuHnFEuMuOcm0ZIXyV0Yzxj91NvhLpvXZJO/5ZQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.5", + "@types/hast": "^3.0.5", + "@types/mdast": "^4.0.4", + "@types/unist": "^3.0.3" + }, + "optionalDependencies": { + "@bruits/satteri-darwin-arm64": "0.10.5", + "@bruits/satteri-darwin-x64": "0.10.5", + "@bruits/satteri-linux-arm64-gnu": "0.10.5", + "@bruits/satteri-linux-arm64-musl": "0.10.5", + "@bruits/satteri-linux-x64-gnu": "0.10.5", + "@bruits/satteri-linux-x64-musl": "0.10.5", + "@bruits/satteri-wasm32-wasi": "0.10.5", + "@bruits/satteri-win32-arm64-msvc": "0.10.5", + "@bruits/satteri-win32-x64-msvc": "0.10.5" } }, "node_modules/@astrojs/mdx": { @@ -360,6 +463,31 @@ } } }, + "node_modules/@astrojs/mdx/node_modules/@astrojs/markdown-remark": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-7.2.2.tgz", + "integrity": "sha512-FGfmK84zSNcrsBd0dl1gXE9JvZYElp8EXQa2jpHVAxG4deGKAp43wspxFupjADJX7MSsMRHwYCnfT6EyVmgeFQ==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.10.2", + "@astrojs/prism": "4.0.2", + "github-slugger": "^2.0.0", + "hast-util-from-html": "^2.0.3", + "hast-util-to-text": "^4.0.2", + "mdast-util-definitions": "^6.0.0", + "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "remark-smartypants": "^3.0.2", + "unified": "^11.0.5", + "unist-util-remove-position": "^5.0.0", + "unist-util-visit": "^5.1.0", + "unist-util-visit-parents": "^6.0.2", + "vfile": "^6.0.3" + } + }, "node_modules/@astrojs/prism": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-4.0.2.tgz", @@ -1728,21 +1856,24 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", - "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.3.tgz", + "integrity": "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==", "license": "MIT", "optional": true, "dependencies": { "@tybys/wasm-util": "^0.10.3" }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" }, "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "node_modules/@oslojs/encoding": { @@ -2318,9 +2449,9 @@ } }, "node_modules/@types/hast": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", "license": "MIT", "dependencies": { "@types/unist": "*" @@ -2551,14 +2682,14 @@ } }, "node_modules/astro": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/astro/-/astro-7.2.2.tgz", - "integrity": "sha512-OvLWCpXpb43lVYcWzSBJ0sk44qcEYYRZCjadtalLfAwb2RaC3P/zT+blZykBw/o6suw6sQQkn00ugN36akD8Mw==", + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/astro/-/astro-7.2.4.tgz", + "integrity": "sha512-+cuLsBns2wwUHI9a10xZMbjrF91m7+QNwqTVeljTx0B8Lf+8h0LgVGjdVIL2FALDKD2I565lczeeS3BFC+KdZg==", "license": "MIT", "dependencies": { "@astrojs/compiler-rs": "^0.3.2", - "@astrojs/internal-helpers": "0.10.2", - "@astrojs/markdown-satteri": "0.3.5", + "@astrojs/internal-helpers": "0.10.4", + "@astrojs/markdown-satteri": "0.3.7", "@astrojs/telemetry": "3.3.3", "@capsizecss/unpack": "^4.0.0", "@clack/prompts": "^1.1.0", @@ -2602,7 +2733,7 @@ "tinyexec": "^1.0.4", "tinyglobby": "^0.2.15", "ultrahtml": "^1.6.0", - "unifont": "~0.7.4", + "unifont": "~0.7.5", "unstorage": "^1.17.5", "vite": "^8.0.13", "vitefu": "^1.1.2", @@ -2626,7 +2757,7 @@ "sharp": "^0.34.0 || ^0.35.0" }, "peerDependencies": { - "@astrojs/markdown-remark": "7.2.2" + "@astrojs/markdown-remark": "7.2.4" }, "peerDependenciesMeta": { "@astrojs/markdown-remark": { @@ -2647,10 +2778,26 @@ "astro": "^4.0.0-beta || ^5.0.0-beta || ^3.3.0 || ^6.0.0-beta || ^7.0.0" } }, + "node_modules/astro/node_modules/@astrojs/internal-helpers": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.10.4.tgz", + "integrity": "sha512-nozZSy/mKYLqe4YrqbKtdOszedAfXYCtw3wZ0d+CAjz4GqQ4L9rl1ltIL5BlgwmYVinJg/RZ0MgGuWOdlyRZlA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.4", + "@types/mdast": "^4.0.4", + "js-yaml": "^4.3.0", + "picomatch": "^4.0.4", + "retext-smartypants": "^6.2.0", + "shiki": "^4.0.2", + "smol-toml": "^1.6.0", + "unified": "^11.0.5" + } + }, "node_modules/astro/node_modules/magic-string": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.0.tgz", - "integrity": "sha512-ptco+HFxTLgjafSLim2LojBSwfg5feBjd+SqyiwdGkzC38UPdZy3zgrHMI2CoTf5fJL38tbHMYWVzIH8BxGqJw==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.2.tgz", + "integrity": "sha512-veT/+7iXrXzT39XnEN4lOxtNl72dMgJ8Lp+5Bd6YcMSWpb0n0MjBM8Uuooi6jgJr8dhUW2swQgBmoZVMni5SVg==", "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -7157,6 +7304,15 @@ "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", "license": "MIT" }, + "node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -7183,14 +7339,14 @@ } }, "node_modules/unifont": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/unifont/-/unifont-0.7.4.tgz", - "integrity": "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg==", + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/unifont/-/unifont-0.7.5.tgz", + "integrity": "sha512-ULe/Cs+ZIsq+dcFofNkhqielCrUJnb5mr+Yc4EBM2VlL+6OZR6+cjtI2mT1bJvRBrVncqHAbLURxmPLcCXzWMg==", "license": "MIT", "dependencies": { "css-tree": "^3.1.0", - "ofetch": "^1.5.1", - "ohash": "^2.0.11" + "ohash": "^2.0.11", + "undici": "^8.0.0" } }, "node_modules/unist-util-find-after": { diff --git a/docs/package.json b/docs/package.json index c8551048..5df50534 100644 --- a/docs/package.json +++ b/docs/package.json @@ -15,7 +15,7 @@ }, "dependencies": { "@astrojs/starlight": "^0.41.7", - "astro": "^7.2.2", + "astro": "^7.2.4", "starlight-openapi": "^0.26.1" } } From 078aa69875b0bbd1640337057132edac6b217f06 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 05:16:12 +0000 Subject: [PATCH 7/9] fix(deps): update dependency hono to ^4.13.3 (#2393) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3d658c0a..85fca243 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@hono/sentry": "^1.2.2", "@hono/zod-openapi": "^1.6.1", "cheerio": "^1.2.0", - "hono": "^4.13.2", + "hono": "^4.13.3", "i18next": "^26.3.6", "i18next-icu": "^2.4.4", "zod": "^4.4.3" @@ -5952,9 +5952,9 @@ } }, "node_modules/hono": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.2.tgz", - "integrity": "sha512-JydRilDRkYBQMt9qR9U92mXxmbGqsqSn/IKOrh4e7/gEbn+0zSr8igTu0obwJoNGN4sez28DIql7FBHWydoJpA==", + "version": "4.13.3", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.3.tgz", + "integrity": "sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw==", "license": "MIT", "engines": { "node": ">=16.9.0" diff --git a/package.json b/package.json index a8260b49..522dbc4a 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "@hono/sentry": "^1.2.2", "@hono/zod-openapi": "^1.6.1", "cheerio": "^1.2.0", - "hono": "^4.13.2", + "hono": "^4.13.3", "i18next": "^26.3.6", "i18next-icu": "^2.4.4", "zod": "^4.4.3" From 0545509671a2e1f26566068f95736592dd182d4e Mon Sep 17 00:00:00 2001 From: dangered wolf Date: Fri, 28 Aug 2026 05:32:27 -0400 Subject: [PATCH 8/9] fix(threads): reject redirects on cookie-authenticated private API fetches (#2397) * feat: improve APi coverage of instagram / threads * Update packages/atmosphere/src/providers/threads/private-processor.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * fix(instagram): rotate accounts on private API status=fail (#2391) * fix(instagram): treat private API status=fail as rotation failure Parseable 2xx bodies with status: fail (checkpoint, spam block) were returned as ok: true, which hid empty results and skipped the next configured account. Match the Threads proxy: mark them failed and rotate. Co-authored-by: dangered wolf * style: prettier Instagram status=fail test fixture Co-authored-by: dangered wolf --------- Co-authored-by: Cursor Agent * fix(instagram,threads): constrain private-API pages to requested count (#2394) Pass count through Instagram comments and Threads replies/profile-feed fetches so next-page cursors match the truncated page instead of skipping items. Co-authored-by: Cursor Agent * fix(threads): decode search cursors as UTF-8 before JSON.parse (#2395) b64urlDecode returns an atob binary string. Search queries can contain non-ASCII text, so convert those bytes with TextDecoder before parsing. Co-authored-by: Cursor Agent * fix(threads): apply request timeout to private API body read (#2396) * fix(threads): apply request timeout to private API body read Fetch can resolve once headers arrive, leaving res.text() and JSON.parse outside withTimeout. Keep both inside the timed operation so a stalled body aborts, retries, and rotates accounts. Co-authored-by: dangered wolf * fix(threads): drop unused initializers in private API timeout path Co-authored-by: dangered wolf --------- Co-authored-by: Cursor Agent * fix(threads): reject redirects on cookie-authenticated private API fetches Do not follow 3xx responses when sending account session cookies, so a redirect cannot leak credentials to another origin. Co-authored-by: dangered wolf --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Cursor Agent --- .env.example | 1 + .github/workflows/deploy.yml | 1 + AGENTS.md | 2 + credentials.example.json | 13 + .../content/docs/deployment/credentials.mdx | 70 ++- esbuild.config.mjs | 3 +- packages/atmosphere/package.json | 4 + .../src/providers/instagram-runtime.ts | 59 ++ .../src/providers/instagram/account-proxy.ts | 217 ++++++++ .../src/providers/instagram/constants.ts | 31 ++ .../src/providers/instagram/conversation.ts | 105 +++- .../src/providers/instagram/cursors.ts | 58 +- .../instagram/fetch-shortcode-page.ts | 61 +- .../src/providers/instagram/likes.ts | 45 ++ .../src/providers/instagram/post.ts | 7 +- .../src/providers/instagram/private-api.ts | 208 +++++++ .../providers/instagram/private-processor.ts | 134 +++++ .../src/providers/instagram/profile.ts | 133 ++++- .../src/providers/instagram/relationships.ts | 60 ++ .../src/providers/instagram/resolve-user.ts | 47 ++ .../src/providers/instagram/search.ts | 131 +++++ .../src/providers/instagram/stories.ts | 78 +++ .../src/providers/instagram/tagged.ts | 70 +++ .../src/providers/threads/account-proxy.ts | 234 ++++++++ .../src/providers/threads/constants.ts | 35 ++ .../src/providers/threads/conversation.ts | 145 ++++- .../src/providers/threads/cursors.ts | 108 +++- .../atmosphere/src/providers/threads/likes.ts | 46 ++ .../atmosphere/src/providers/threads/post.ts | 125 +++-- .../src/providers/threads/private-api.ts | 262 +++++++++ .../providers/threads/private-processor.ts | 230 ++++++++ .../src/providers/threads/profile-tabs.ts | 79 +++ .../src/providers/threads/profile.ts | 58 +- .../src/providers/threads/relationships.ts | 69 +++ .../src/providers/threads/resolve-user.ts | 57 ++ .../src/providers/threads/search.ts | 228 ++++++++ .../src/providers/threads/trends.ts | 91 +++ .../atmosphere/src/types/proxy-credentials.ts | 30 +- src/constants.ts | 1 + .../instagram/atmosphere-handlers.ts | 238 +++++++- .../instagram/atmosphere-register.ts | 25 +- src/providers/instagram/atmosphere-routes.ts | 233 ++++++++ src/providers/threads/atmosphere-handlers.ts | 336 ++++++++++- src/providers/threads/atmosphere-register.ts | 34 +- src/providers/threads/atmosphere-routes.ts | 294 +++++++++- src/providers/twitter/proxy/credentials.ts | 20 +- src/realms/atmosphere/router.ts | 2 +- src/types/env.d.ts | 2 + src/worker.ts | 17 + test/helpers/env.ts | 1 + test/instagram.accountProxy.test.ts | 219 ++++++++ test/instagram.atmosphereRoutes.test.ts | 82 +++ test/instagram.cursors.test.ts | 59 +- test/instagram.privateProcessor.test.ts | 100 ++++ test/instagram.proxiedPost.test.ts | 263 +++++++++ test/instagram.proxiedSurfaces.test.ts | 281 ++++++++++ test/threads.accountProxy.test.ts | 275 +++++++++ test/threads.atmosphereRoutes.test.ts | 85 +++ test/threads.cursors.test.ts | 43 +- test/threads.proxiedSurfaces.test.ts | 525 ++++++++++++++++++ tools/stripcredentials.mjs | 46 +- 61 files changed, 6393 insertions(+), 123 deletions(-) create mode 100644 packages/atmosphere/src/providers/instagram-runtime.ts create mode 100644 packages/atmosphere/src/providers/instagram/account-proxy.ts create mode 100644 packages/atmosphere/src/providers/instagram/likes.ts create mode 100644 packages/atmosphere/src/providers/instagram/private-api.ts create mode 100644 packages/atmosphere/src/providers/instagram/private-processor.ts create mode 100644 packages/atmosphere/src/providers/instagram/relationships.ts create mode 100644 packages/atmosphere/src/providers/instagram/resolve-user.ts create mode 100644 packages/atmosphere/src/providers/instagram/search.ts create mode 100644 packages/atmosphere/src/providers/instagram/stories.ts create mode 100644 packages/atmosphere/src/providers/instagram/tagged.ts create mode 100644 packages/atmosphere/src/providers/threads/account-proxy.ts create mode 100644 packages/atmosphere/src/providers/threads/likes.ts create mode 100644 packages/atmosphere/src/providers/threads/private-api.ts create mode 100644 packages/atmosphere/src/providers/threads/private-processor.ts create mode 100644 packages/atmosphere/src/providers/threads/profile-tabs.ts create mode 100644 packages/atmosphere/src/providers/threads/relationships.ts create mode 100644 packages/atmosphere/src/providers/threads/resolve-user.ts create mode 100644 packages/atmosphere/src/providers/threads/search.ts create mode 100644 packages/atmosphere/src/providers/threads/trends.ts create mode 100644 test/instagram.accountProxy.test.ts create mode 100644 test/instagram.atmosphereRoutes.test.ts create mode 100644 test/instagram.privateProcessor.test.ts create mode 100644 test/instagram.proxiedPost.test.ts create mode 100644 test/instagram.proxiedSurfaces.test.ts create mode 100644 test/threads.accountProxy.test.ts create mode 100644 test/threads.atmosphereRoutes.test.ts create mode 100644 test/threads.proxiedSurfaces.test.ts diff --git a/.env.example b/.env.example index cdd52358..186682aa 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,7 @@ ATMOSPHERE_API_HOST_LIST = "api.atmosphere.tools,api-canary.atmosphere.tools" API_HOST_LIST = "api.fxtwitter.com,api-canary.fxtwitter.com" TWITTER_ROOT = "https://x.com" INSTAGRAM_ROOT = "https://www.instagram.com" +INSTAGRAM_API_ROOT = "https://i.instagram.com" SENTRY_DSN = "" SENTRY_AUTH_TOKEN = "" SENTRY_ORG = "" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5a371589..e3fa5b83 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -89,6 +89,7 @@ jobs: ATMOSPHERE_API_HOST_LIST: ${{ vars.ATMOSPHERE_API_HOST_LIST }} TWITTER_ROOT: ${{ vars.TWITTER_ROOT }} INSTAGRAM_ROOT: ${{ vars.INSTAGRAM_ROOT }} + INSTAGRAM_API_ROOT: ${{ vars.INSTAGRAM_API_ROOT }} SENTRY_DSN: ${{ secrets.SENTRY_DSN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }} diff --git a/AGENTS.md b/AGENTS.md index 2dd63a81..9c130939 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,8 @@ This is the repository for FxEmbed, the home of FxTwitter, FixupX, and FxBluesky - **Build:** `npm run build:atmosphere` (runs before the worker build). - **Transports:** `public` | `anonymous-proxy` (credentials) | `proxy-relay` (to another host’s OpenAPI) | `authenticated` (interface stub, not implemented) — see `packages/atmosphere/src/transports/`. - **Bluesky:** runtime wiring (API roots, proxy) — `setBlueskyProviderEnv` + `setBlueskyProxyRuntime` from `worker.ts` and `@fxembed/atmosphere/providers/bluesky-runtime`. +- **Instagram:** same pattern — `setInstagramProviderEnv` + `setInstagramProxyRuntime` from `worker.ts`. The account proxy (`providers/instagram/account-proxy.ts`) calls `i.instagram.com/api/v1/…` with a `sessionid` cookie from `credentials.json`’s `instagram.accounts`, rotating accounts on 401/403/429. Logged-out paths still work without it; the proxy-only routes (likers, follow lists, tagged, stories, user search, typeahead) answer `501`. Android app id / capabilities / UA in `providers/instagram/constants.ts` come from a decompiled `com.instagram.android` build — see the comments there before changing them. +- **Threads:** `packages/atmosphere/src/providers/threads/`. Logged-out `threads.com` Relay GraphQL (`client.ts`) plus an account proxy (`account-proxy.ts` + `private-api.ts`) that reuses the _Instagram_ credential pool — `resolveThreadsAccounts` is `resolveInstagramAccounts` — and only swaps in the Threads app fingerprint. Endpoint paths, parameter names and the app id / capabilities / UA in `providers/threads/constants.ts` come from a decompiled `com.instagram.barcelona` build; the smali (not the JADX output) is where the `text_feed/…` and `fbsearch/text_app/…` route templates survive. Proxy-only routes (search, typeahead, trends, likes, follow lists, Replies/Reposts/Media tabs) answer `501` without credentials; post / profile / timeline / conversation prefer the proxy and fall back to logged-out. - **Proxy-relay OpenAPI (optional):** `npm run openapi:atmosphere` fetches public specs and writes `packages/atmosphere/src/relay/generated/`. Use `createRelayFetch` from `@fxembed/atmosphere` for `User-Agent` + API key injection. - **Self-hosting:** docs site `/deployment/atmosphere-transports/` — public-only, own proxy pool, relay to `https://api.fxtwitter.com` / `https://api.fxbsky.app`, or mixed. Mastodon / Twitter / TikTok providers remain under `src/providers/*`; follow the Bluesky → `@fxembed/atmosphere` migration pattern when moving more code. diff --git a/credentials.example.json b/credentials.example.json index c377919b..7012fd2a 100644 --- a/credentials.example.json +++ b/credentials.example.json @@ -16,5 +16,18 @@ "service": "https://bsky.social" } ] + }, + "instagram": { + "accounts": [ + { + "sessionId": "your_sessionid_cookie", + "userId": "your_ds_user_id_cookie", + "csrfToken": "your_csrftoken_cookie", + "mid": "your_mid_cookie", + "deviceId": "your_ig_did_cookie", + "username": "account_handle", + "platform": "web" + } + ] } } diff --git a/docs/src/content/docs/deployment/credentials.mdx b/docs/src/content/docs/deployment/credentials.mdx index 84d94174..7096f445 100644 --- a/docs/src/content/docs/deployment/credentials.mdx +++ b/docs/src/content/docs/deployment/credentials.mdx @@ -1,6 +1,6 @@ --- title: Credentials -description: Setting up X/Twitter credentials for your FxEmbed deployment. +description: Setting up X/Twitter, Bluesky, and Instagram credentials for your FxEmbed deployment. --- FxEmbed works best with X/Twitter account credentials to fetch post data from the Twitter API. Without them, you will have lower rate limits and not be able to fetch NSFW posts. @@ -96,7 +96,7 @@ wrangler secret put CREDENTIAL_KEY FxEmbed by default will use the public Bluesky AppView API. We support specifying fallback accounts on different PDSes in the event of downtime of the public API. We will always prefer the public API unless it is down. -The proxy uses standard Bluesky [app passwords](https://bsky.app/settings/app-passwords) — no OAuth flow, no DPoP, nothing exotic. +The proxy uses standard Bluesky [app passwords](https://bsky.app/settings/app-passwords) ### Credential Format @@ -134,3 +134,69 @@ App passwords can be revoked at any time from the same screen if a key is ever c ### Encryption and Deployment Bluesky credentials are encrypted, pushed, and pulled using the exact same [credential management scripts](#credential-management-scripts) and [`CREDENTIAL_KEY`](#setting-the-credential-key) as Twitter — there's nothing Bluesky-specific to configure on the worker side. + +## Instagram + +Instagram's logged-out surfaces are heavily restricted: follower lists, likers, search, tagged posts and stories return nothing at all, and post and profile lookups are rate limited and gated on anything age-restricted. Adding an Instagram account proxy unlocks those surfaces and makes the existing ones far more reliable. + +Without it, FxEmbed still serves Instagram posts, profiles, profile grids and comments over the logged-out web path. The proxy-only routes report HTTP `501` rather than an empty result, so you can tell "not configured" apart from "this account really has no followers". + +### Credential Format + +Instagram accounts live alongside Twitter and Bluesky accounts in the same `credentials.json`: + +```json +{ + "instagram": { + "accounts": [ + { + "sessionId": "your_sessionid_cookie", + "userId": "your_ds_user_id_cookie", + "csrfToken": "your_csrftoken_cookie", + "mid": "your_mid_cookie", + "deviceId": "your_ig_did_cookie", + "username": "account_handle", + "platform": "web" + } + ] + } +} +``` + +Fields: + +- **`sessionId`** (required): The `sessionid` cookie of a logged-in Instagram session. +- **`userId`**: The `ds_user_id` cookie — the account's numeric pk. +- **`csrfToken`**: The `csrftoken` cookie. +- **`mid`** / **`deviceId`**: The `mid` and `ig_did` cookies. Optional, but Instagram is happier when the cookie jar looks complete. +- **`username`**: Used only for logging, so you can tell which session got rate limited. +- **`platform`**: `web` (default) or `android`. This picks the client fingerprint the proxy presents — see below. + +Only `sessionId` is strictly required; everything else improves how ordinary the session looks. + +### Picking a Platform + +`platform` must match where the `sessionid` came from: + +- **`web`** — a cookie harvested from `www.instagram.com` in a desktop browser. FxEmbed sends the matching desktop Chrome `User-Agent`, web app id, and browser `Sec-Fetch-*`/`Origin` headers. +- **`android`** — a cookie harvested from the Instagram Android app. FxEmbed sends the app's own `User-Agent`, app id, and `X-IG-Device-ID`, and omits the browser-only headers. + +Mixing the two is the usual cause of an unexpected checkpoint, so keep this consistent with where you got the cookie. + +### Obtaining a Session + +1. Sign in to the account you want to use as a proxy in a browser. +2. Open DevTools → **Application → Cookies → `https://www.instagram.com`**. +3. Copy the `sessionid`, `ds_user_id`, `csrftoken`, `mid` and `ig_did` values into the fields above, leaving `platform` as `web`. + +Sessions are long-lived but not permanent: logging the account out, changing its password, or an Instagram-side checkpoint invalidates the cookie. FxEmbed rotates to the next configured account on `401`, `403` and `429`, on an HTML login page, and on a 200 `{ status: 'fail' }` body (checkpoint / spam block), so a stale entry degrades one account rather than the whole deployment. Use accounts you're willing to lose, not a personal one. + +### Encryption and Deployment + +Instagram credentials are encrypted, pushed, and pulled using the exact same [credential management scripts](#credential-management-scripts) and [`CREDENTIAL_KEY`](#setting-the-credential-key) as Twitter and Bluesky. `npm run credentials:strip` keeps only the cookie-jar fields, so a `credentials.complete.json` that also holds a login password will not leak it into the encrypted bundle. + +### Threads + +Threads accounts _are_ Instagram accounts, so there is no separate credential block: the same `instagram.accounts` pool powers Threads. FxEmbed only swaps the client fingerprint — the Threads (`Barcelona`) app id and `User-Agent` — when it calls a Threads endpoint. + +Logged-out `threads.com` is even more restricted than Instagram's: search, typeahead, trending topics, likers, follow lists, and the Replies / Reposts / Media profile tabs are all behind a login. With a proxy configured those become available at `/2/threads/…`; without one they report `501`, exactly like their Instagram counterparts. Single posts, profiles, profile timelines and conversations keep working either way — the proxy just gives fuller and more reliable results. diff --git a/esbuild.config.mjs b/esbuild.config.mjs index f89e50fe..fd2bc997 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -61,7 +61,8 @@ let envVariables = [ 'PBS_PROXY_DOMAIN_LIST', 'OLD_EMBED_DOMAINS', 'TWITTER_ROOT', - 'INSTAGRAM_ROOT' + 'INSTAGRAM_ROOT', + 'INSTAGRAM_API_ROOT' ]; // Inline process.env.* so Workers bundles stay static; Bun/Node read real process.env at runtime. diff --git a/packages/atmosphere/package.json b/packages/atmosphere/package.json index cb55719c..b074bf75 100644 --- a/packages/atmosphere/package.json +++ b/packages/atmosphere/package.json @@ -70,6 +70,10 @@ "types": "./dist/providers/mastodon/*.d.ts", "import": "./dist/providers/mastodon/*.js" }, + "./providers/instagram-runtime": { + "types": "./dist/providers/instagram-runtime.d.ts", + "import": "./dist/providers/instagram-runtime.js" + }, "./providers/instagram/*": { "types": "./dist/providers/instagram/*.d.ts", "import": "./dist/providers/instagram/*.js" diff --git a/packages/atmosphere/src/providers/instagram-runtime.ts b/packages/atmosphere/src/providers/instagram-runtime.ts new file mode 100644 index 00000000..000f73b8 --- /dev/null +++ b/packages/atmosphere/src/providers/instagram-runtime.ts @@ -0,0 +1,59 @@ +import type { InstagramCredentials } from '../types/proxy-credentials.js'; + +/** + * Configurable Instagram web/private-API roots. + * The FxEmbed worker calls {@link setInstagramProviderEnv} at startup (see `worker.ts`). + */ +export type InstagramProviderEnv = { + /** Logged-out web origin (`www.instagram.com`). */ + webRoot: string; + /** Private API origin used by the account proxy (`i.instagram.com`). */ + apiRoot: string; + /** Sent as `User-Agent` on logged-out web requests when the caller supplies none. */ + friendlyUserAgent: string; +}; + +const defaultEnv: InstagramProviderEnv = { + webRoot: 'https://www.instagram.com', + apiRoot: 'https://i.instagram.com', + friendlyUserAgent: 'FxEmbed' +}; + +let env: InstagramProviderEnv = { ...defaultEnv }; + +export function setInstagramProviderEnv(partial: Partial): void { + env = { ...env, ...partial }; +} + +export function getInstagramProviderEnv(): InstagramProviderEnv { + return env; +} + +/** + * Encrypted bundle decrypt + account selection lives in the worker; the package only sees this + * interface (registered from `worker.ts`, same as `setBlueskyProxyRuntime` / `setTwitterProxyRuntime`). + */ +export type InstagramProxyRuntime = { + initCredentials: (key: string | undefined) => Promise; + hasBundledEncryptedCredentials: () => boolean; + hasInstagramProxyAccounts: () => boolean; + getShuffledInstagramAccounts: () => InstagramCredentials[]; +}; + +/** No-op fallback so logged-out Instagram paths work without worker proxy wiring (and in tests). */ +const noopProxy: InstagramProxyRuntime = { + initCredentials: async () => {}, + hasBundledEncryptedCredentials: () => false, + hasInstagramProxyAccounts: () => false, + getShuffledInstagramAccounts: () => [] +}; + +let proxy: InstagramProxyRuntime | null = null; + +export function setInstagramProxyRuntime(r: InstagramProxyRuntime): void { + proxy = r; +} + +export function getInstagramProxyRuntime(): InstagramProxyRuntime { + return proxy ?? noopProxy; +} diff --git a/packages/atmosphere/src/providers/instagram/account-proxy.ts b/packages/atmosphere/src/providers/instagram/account-proxy.ts new file mode 100644 index 00000000..648e86ef --- /dev/null +++ b/packages/atmosphere/src/providers/instagram/account-proxy.ts @@ -0,0 +1,217 @@ +import { withTimeout } from '../../helpers/with-timeout.js'; +import { getInstagramProviderEnv, getInstagramProxyRuntime } from '../instagram-runtime.js'; +import type { InstagramCredentials } from '../../types/proxy-credentials.js'; +import { + INSTAGRAM_ANDROID_APP_ID, + INSTAGRAM_ANDROID_CAPABILITIES, + INSTAGRAM_ANDROID_USER_AGENT, + INSTAGRAM_API_V1, + INSTAGRAM_ASBD_ID, + INSTAGRAM_ORIGIN, + INSTAGRAM_WEB_APP_ID +} from './constants.js'; + +const WEB_USER_AGENT = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; + +/** + * Per-request Instagram context. `credentialKey` is the worker's `CREDENTIAL_KEY` binding; without + * it (or without a bundled credential blob) every Instagram path stays logged-out. + */ +export type InstagramRequestContext = { + userAgent?: string; + credentialKey?: string; +}; + +/** + * True when this deployment *can* proxy Instagram through a logged-in account: a credential key is + * configured and the worker bundle carries an encrypted credential blob. Whether that blob actually + * contains Instagram accounts is only known after decryption — see {@link resolveInstagramAccounts}. + */ +export function hasInstagramAccountProxy(ctx: InstagramRequestContext | undefined): boolean { + return Boolean( + ctx?.credentialKey?.trim() && getInstagramProxyRuntime().hasBundledEncryptedCredentials() + ); +} + +/** Decrypts (once) and returns the proxy accounts in shuffled order; empty when unavailable. */ +export async function resolveInstagramAccounts( + ctx: InstagramRequestContext | undefined +): Promise { + if (!hasInstagramAccountProxy(ctx)) return []; + const rt = getInstagramProxyRuntime(); + try { + await rt.initCredentials(ctx?.credentialKey); + } catch (err) { + console.error('[instagram] credential init failed', { + message: err instanceof Error ? err.message : String(err) + }); + return []; + } + if (!rt.hasInstagramProxyAccounts()) { + return []; + } + return rt.getShuffledInstagramAccounts().filter(a => Boolean(a?.sessionId)); +} + +function cookieHeaderFor(account: InstagramCredentials): string { + const parts = [`sessionid=${account.sessionId}`]; + if (account.userId) parts.push(`ds_user_id=${account.userId}`); + if (account.csrfToken) parts.push(`csrftoken=${account.csrfToken}`); + if (account.mid) parts.push(`mid=${account.mid}`); + if (account.deviceId) parts.push(`ig_did=${account.deviceId}`); + return parts.join('; '); +} + +/** + * Headers for one proxied request. `android` accounts get the app fingerprint read out of the + * decompiled APK; `web` accounts get the desktop-browser fingerprint that matches a `sessionid` + * harvested from www.instagram.com. + */ +export function instagramProxyHeaders( + account: InstagramCredentials, + options: { referer?: string } = {} +): Record { + const android = account.platform === 'android'; + const headers: Record = { + 'User-Agent': android ? INSTAGRAM_ANDROID_USER_AGENT : WEB_USER_AGENT, + 'Accept': '*/*', + 'Accept-Language': 'en-US,en;q=0.9', + 'X-IG-App-ID': android ? INSTAGRAM_ANDROID_APP_ID : INSTAGRAM_WEB_APP_ID, + 'X-IG-Capabilities': INSTAGRAM_ANDROID_CAPABILITIES, + 'X-IG-WWW-Claim': '0', + 'Cookie': cookieHeaderFor(account) + }; + if (android) { + headers['X-IG-Connection-Type'] = 'WIFI'; + if (account.androidDeviceId) { + headers['X-IG-Device-ID'] = account.androidDeviceId; + } + } else { + headers['X-ASBD-ID'] = INSTAGRAM_ASBD_ID; + headers['Origin'] = INSTAGRAM_ORIGIN; + headers['Referer'] = options.referer ?? `${INSTAGRAM_ORIGIN}/`; + headers['Sec-Fetch-Dest'] = 'empty'; + headers['Sec-Fetch-Mode'] = 'cors'; + headers['Sec-Fetch-Site'] = 'same-origin'; + } + if (account.csrfToken) { + headers['X-CSRFToken'] = account.csrfToken; + } + return headers; +} + +/** HTTP statuses where another account is worth trying: auth/checkpoint/rate limit. */ +const ROTATE_STATUSES = new Set([401, 403, 429]); + +export type InstagramPrivateApiResult = { + ok: boolean; + /** 0 when no account was available at all (proxy not configured). */ + status: number; + json: unknown | null; + /** Set when a request actually went out, for logging. */ + accountUsed?: string; +}; + +/** + * Calls an `i.instagram.com/api/v1/…` endpoint through a proxy account, rotating accounts on + * auth/rate-limit failures and on a 200 `{ status: 'fail' }` body (checkpoint / spam block). + * Returns `{ ok: false, status: 0 }` when no proxy account is configured so callers can fall + * back to their logged-out path. + */ +export async function instagramPrivateApiRequest( + path: string, + ctx: InstagramRequestContext | undefined, + options: { + query?: Record; + method?: 'GET' | 'POST'; + body?: string; + referer?: string; + accounts?: InstagramCredentials[]; + } = {} +): Promise { + const accounts = options.accounts ?? (await resolveInstagramAccounts(ctx)); + if (!accounts.length) { + return { ok: false, status: 0, json: null }; + } + + const { apiRoot } = getInstagramProviderEnv(); + const url = new URL(`${apiRoot}${INSTAGRAM_API_V1}${path.startsWith('/') ? path : `/${path}`}`); + for (const [key, value] of Object.entries(options.query ?? {})) { + if (value === undefined || value === '') continue; + url.searchParams.set(key, String(value)); + } + + let last: InstagramPrivateApiResult = { ok: false, status: 500, json: null }; + for (const account of accounts) { + const headers = instagramProxyHeaders(account, { referer: options.referer }); + if (options.method === 'POST') { + headers['Content-Type'] = 'application/x-www-form-urlencoded'; + } + let res: Response; + try { + res = await withTimeout(signal => + fetch(url.toString(), { + method: options.method ?? 'GET', + headers, + body: options.method === 'POST' ? (options.body ?? '') : undefined, + signal + }) + ); + } catch (err) { + console.error('[instagram] private API request threw', { + path, + account: account.username, + message: err instanceof Error ? err.message : String(err) + }); + last = { ok: false, status: 500, json: null, accountUsed: account.username }; + continue; + } + + if (!res.ok) { + console.error('[instagram] private API request failed', { + path, + account: account.username, + status: res.status + }); + last = { ok: false, status: res.status, json: null, accountUsed: account.username }; + if (ROTATE_STATUSES.has(res.status)) continue; + return last; + } + + const text = await res.text(); + const trimmed = text.trim(); + // A logged-out or checkpointed session gets an HTML login page rather than JSON. + if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) { + console.error('[instagram] private API returned non-JSON (session likely invalid)', { + path, + account: account.username + }); + last = { ok: false, status: res.status, json: null, accountUsed: account.username }; + continue; + } + let parsed: unknown; + try { + parsed = JSON.parse(text) as unknown; + } catch { + last = { ok: false, status: res.status, json: null, accountUsed: account.username }; + continue; + } + // The private API answers 200 with `{ status: 'fail' }` for soft failures (checkpoint, + // spam block, feedback_required). Rotate rather than surfacing an empty page as success. + if ( + parsed && + typeof parsed === 'object' && + (parsed as { status?: unknown }).status === 'fail' + ) { + console.error('[instagram] private API returned status=fail', { + path, + account: account.username + }); + last = { ok: false, status: 502, json: parsed, accountUsed: account.username }; + continue; + } + return { ok: true, status: res.status, json: parsed, accountUsed: account.username }; + } + return last; +} diff --git a/packages/atmosphere/src/providers/instagram/constants.ts b/packages/atmosphere/src/providers/instagram/constants.ts index d81d9916..d4be226c 100644 --- a/packages/atmosphere/src/providers/instagram/constants.ts +++ b/packages/atmosphere/src/providers/instagram/constants.ts @@ -23,3 +23,34 @@ export const INSTAGRAM_POST_ROOT_FRIENDLY_NAME = 'PolarisLoggedOutDesktopWWWPostRootContentQuery' as const; export const INSTAGRAM_ORIGIN = 'https://www.instagram.com'; + +/* + * Android app constants, read out of a decompiled `com.instagram.android` build + * (444.0.0.46.85, versionCode 385104942). `InstagramSpecificHeaderServiceLayer` stamps + * `X-IG-Capabilities` and the default `X-IG-App-ID` onto every first-party request. + */ + +/** Instagram Android app id. Distinct from {@link INSTAGRAM_WEB_APP_ID}. */ +export const INSTAGRAM_ANDROID_APP_ID = '567067343352427'; + +/** `X-IG-Capabilities` value the app sends on every first-party request. */ +export const INSTAGRAM_ANDROID_CAPABILITIES = '3brTv10='; + +export const INSTAGRAM_ANDROID_VERSION_NAME = '444.0.0.46.85'; +export const INSTAGRAM_ANDROID_VERSION_CODE = '385104942'; + +/** + * Android `User-Agent`, in the app's own + * `Instagram Android (/; dpi; x; ; ; ; ; ; )` + * shape (the app builds the middle section with the `"%sdpi; %sx%s"` format string). + * Kept as one fixed, plausible device so a proxied session presents a stable fingerprint. + */ +export const INSTAGRAM_ANDROID_USER_AGENT = + `Instagram ${INSTAGRAM_ANDROID_VERSION_NAME} Android (34/14; 420dpi; 1080x2340; ` + + `samsung; SM-S911B; dm1q; qcom; en_US; ${INSTAGRAM_ANDROID_VERSION_CODE})`; + +/** Private API origin the Android app talks to. */ +export const INSTAGRAM_API_ORIGIN = 'https://i.instagram.com'; + +/** Private API prefix (`i.instagram.com/api/v1/…`). */ +export const INSTAGRAM_API_V1 = '/api/v1'; diff --git a/packages/atmosphere/src/providers/instagram/conversation.ts b/packages/atmosphere/src/providers/instagram/conversation.ts index 32eab1cc..a8d6fceb 100644 --- a/packages/atmosphere/src/providers/instagram/conversation.ts +++ b/packages/atmosphere/src/providers/instagram/conversation.ts @@ -1,14 +1,42 @@ -import type { SocialConversationInstagram } from '../../types/api-schemas.js'; +import type { APISubstatus, SocialConversationInstagram } from '../../types/api-schemas.js'; +import { resolveInstagramAccounts, type InstagramRequestContext } from './account-proxy.js'; import { fetchCommentPageGraphql, fetchInstagramCsrfToken } from './client.js'; import { decodeCommentCursor, encodeCommentCursor } from './cursors.js'; import { extractCommentsConnection } from './extractors.js'; import { fetchInstagramPageWithWebInfo } from './fetch-shortcode-page.js'; +import { fetchPrivateMediaComments } from './private-api.js'; +import { nextMaxIdFromPrivateResponse } from './private-processor.js'; import { + commentRecordToSubstatus, extractCommentsFromGraphqlJson, instagramNodeToStatus, mapCommentEdges } from './processor.js'; +/** `media/{pk}/comments/` returns a flat `comments` array rather than GraphQL edges. */ +function substatusesFromPrivateComments( + json: unknown, + shortcode: string, + parentAuthor: string, + limit: number +): APISubstatus[] { + if (!json || typeof json !== 'object') return []; + const comments = (json as { comments?: unknown }).comments; + if (!Array.isArray(comments)) return []; + const out: APISubstatus[] = []; + for (const comment of comments) { + if (out.length >= limit) break; + if (!comment || typeof comment !== 'object') continue; + const mapped = commentRecordToSubstatus( + comment as Record, + shortcode, + parentAuthor + ); + if (mapped) out.push(mapped); + } + return out; +} + export type InstagramConversationResult = | { ok: true; data: SocialConversationInstagram } | { ok: false; message: string; data?: SocialConversationInstagram }; @@ -20,10 +48,16 @@ export async function constructInstagramConversation( count: number; sortOrder: 'popular' | 'recent'; userAgent?: string; + credentialKey?: string; } ): Promise { const count = Math.min(100, Math.max(1, Math.floor(options.count))); - const page = await fetchInstagramPageWithWebInfo(shortcode, options.userAgent); + const ctx: InstagramRequestContext = { + userAgent: options.userAgent, + credentialKey: options.credentialKey + }; + const accounts = await resolveInstagramAccounts(ctx); + const page = await fetchInstagramPageWithWebInfo(shortcode, options.userAgent, ctx); if (!page.ok) { return { ok: true, @@ -64,6 +98,60 @@ export async function constructInstagramConversation( (typeof item.pk === 'string' || typeof item.pk === 'number' ? String(item.pk).split('_')[0] : ''); + /* + * With an account proxy, comments come from `media/{pk}/comments/`: it paginates past the ~24 + * comments the embedded page carries and works on posts whose logged-out page has no comment + * connection at all. Falls through to the logged-out GraphQL path if the call fails. + */ + if (accounts.length && mediaPk) { + let maxId: string | null = null; + if (options.cursor) { + const decoded = decodeCommentCursor(options.cursor); + if ( + !decoded || + decoded.shortcode !== shortcode || + decoded.mediaId !== mediaPk || + decoded.src !== 'proxy' + ) { + return { ok: false, message: 'Invalid cursor' }; + } + maxId = decoded.after; + } + const res = await fetchPrivateMediaComments(mediaPk, ctx, { + accounts, + maxId, + count, + sortOrder: options.sortOrder, + shortcode + }); + if (res.ok) { + const replies = substatusesFromPrivateComments(res.json, shortcode, fb.username, count); + const nextMaxId = nextMaxIdFromPrivateResponse(res.json); + const bottom = nextMaxId + ? encodeCommentCursor({ + v: 1, + mediaId: mediaPk, + shortcode, + sort: options.sortOrder, + after: nextMaxId, + count, + src: 'proxy' + }) + : null; + return { + ok: true, + data: { + code: 200, + status, + thread: [status], + replies, + author: status.author, + cursor: { bottom } + } + }; + } + } + const conn = page.comments ?? extractCommentsConnection(htmlBody); const pageInfo = conn?.page_info ?? {}; const hasNext = @@ -90,7 +178,8 @@ export async function constructInstagramConversation( shortcode, sort: options.sortOrder, after: endCursor, - count + count, + src: 'gql' }) : null; return { @@ -107,7 +196,12 @@ export async function constructInstagramConversation( } const decoded = decodeCommentCursor(options.cursor); - if (!decoded || decoded.shortcode !== shortcode || decoded.mediaId !== mediaPk) { + if ( + !decoded || + decoded.shortcode !== shortcode || + decoded.mediaId !== mediaPk || + decoded.src === 'proxy' + ) { return { ok: false, message: 'Invalid cursor' }; } @@ -189,7 +283,8 @@ export async function constructInstagramConversation( shortcode, sort: decoded.sort, after: pi.end_cursor, - count: decoded.count + count: decoded.count, + src: 'gql' }) : null; diff --git a/packages/atmosphere/src/providers/instagram/cursors.ts b/packages/atmosphere/src/providers/instagram/cursors.ts index 1dae139d..a4b07033 100644 --- a/packages/atmosphere/src/providers/instagram/cursors.ts +++ b/packages/atmosphere/src/providers/instagram/cursors.ts @@ -13,8 +13,11 @@ export type InstagramCommentCursorV1 = { mediaId: string; shortcode: string; sort: 'popular' | 'recent'; + /** GraphQL `end_cursor`, or the private API's `next_max_id` when `src` is `proxy`. */ after: string | null; count: number; + /** Which comment source minted this cursor; the two use incompatible cursor values. */ + src?: 'gql' | 'proxy'; }; const b64urlEncode = (json: string): string => { @@ -86,9 +89,62 @@ export function decodeCommentCursor(raw: string): InstagramCommentCursorV1 | nul shortcode: o.shortcode, sort: o.sort, after: typeof o.after === 'string' || o.after === null ? o.after : null, - count: Math.floor(o.count) + count: Math.floor(o.count), + src: o.src === 'proxy' ? 'proxy' : 'gql' }; } catch { return null; } } + +/** + * Cursor for `max_id`-paginated private API surfaces (follow lists, tagged feed, proxied profile + * feed). `k` keeps a cursor minted for one endpoint from being replayed against another. + */ +export type InstagramMaxIdCursorV1 = { + v: 1; + k: 'followers' | 'following' | 'tagged' | 'feed'; + /** User pk the list belongs to. */ + id: string; + /** + * Username, kept so paged requests can send the same `Referer` as page one. Stored lowercased: + * Instagram handles are case-insensitive, so `/Cristiano` must be able to resume `/cristiano`. + */ + u: string; + /** Instagram `next_max_id`. */ + m: string; + c: number; +}; + +const MAX_ID_CURSOR_KINDS = new Set([ + 'followers', + 'following', + 'tagged', + 'feed' +]); + +export function encodeMaxIdCursor(p: InstagramMaxIdCursorV1): string { + return b64urlEncode(JSON.stringify({ ...p, u: p.u.toLowerCase() })); +} + +/** Handle comparison for cursor validation; Instagram treats handles case-insensitively. */ +export function sameInstagramHandle(a: string, b: string): boolean { + return a.toLowerCase() === b.toLowerCase(); +} + +export function decodeMaxIdCursor(raw: string): InstagramMaxIdCursorV1 | null { + const json = b64urlDecode(raw); + if (!json) return null; + try { + const o = JSON.parse(json) as Partial; + if (o.v !== 1) return null; + if (!o.k || !MAX_ID_CURSOR_KINDS.has(o.k)) return null; + if (typeof o.id !== 'string' || !o.id) return null; + if (typeof o.u !== 'string') return null; + if (typeof o.m !== 'string' || !o.m) return null; + if (typeof o.c !== 'number' || !Number.isFinite(o.c) || o.c < 1 || o.c > 100) return null; + return { v: 1, k: o.k, id: o.id, u: o.u.toLowerCase(), m: o.m, c: Math.floor(o.c) }; + } catch { + return null; + } +} diff --git a/packages/atmosphere/src/providers/instagram/fetch-shortcode-page.ts b/packages/atmosphere/src/providers/instagram/fetch-shortcode-page.ts index 74599cd0..47fa0106 100644 --- a/packages/atmosphere/src/providers/instagram/fetch-shortcode-page.ts +++ b/packages/atmosphere/src/providers/instagram/fetch-shortcode-page.ts @@ -1,9 +1,11 @@ +import { resolveInstagramAccounts, type InstagramRequestContext } from './account-proxy.js'; import { fetchInstagramHtml, fetchInstagramSession, fetchPolarisPostGraphql, fetchRulingForContent } from './client.js'; +import { fetchPrivateMediaInfo } from './private-api.js'; import { extractLsdFromHtml, extractPolarisProductFromGraphqlJson, @@ -20,7 +22,7 @@ export type InstagramWebInfoPage = item: Record; pathUsed: string; comments: PolarisMediaBundle['comments']; - source: 'polaris-graphql' | 'polaris-html' | 'web-info-html'; + source: 'account-proxy' | 'polaris-graphql' | 'polaris-html' | 'web-info-html'; /** Session/doc LSD for GraphQL comment pagination (Polaris GraphQL has no HTML to parse). */ lsd: string | null; } @@ -35,6 +37,15 @@ export type InstagramWebInfoPage = lsd: null; }; +/** `media/{pk}/info/` answers with a one-element `items` array. */ +function firstMediaItem(json: unknown): Record | null { + if (!json || typeof json !== 'object') return null; + const items = (json as { items?: unknown }).items; + if (!Array.isArray(items) || items.length === 0) return null; + const first = items[0]; + return first && typeof first === 'object' ? (first as Record) : null; +} + function isLoginRedirect(finalUrl: string | undefined): boolean { if (!finalUrl) return false; try { @@ -46,7 +57,11 @@ function isLoginRedirect(finalUrl: string | undefined): boolean { } /** - * Fetches logged-out Instagram post media using the yt-dlp Polaris path: + * Fetches Instagram post media. + * + * When an account proxy is configured, `media/{pk}/info/` is tried first: it is one request instead + * of three and returns media that the logged-out surfaces gate (age-restricted posts, and the + * higher-quality video renditions). Everything after that is the logged-out yt-dlp Polaris path: * * 1. Homepage session (cookies + LSD from `__eqmc`) * 2. Optional `get_ruling_for_content` warm-up @@ -57,18 +72,46 @@ function isLoginRedirect(finalUrl: string | undefined): boolean { */ export async function fetchInstagramPageWithWebInfo( shortcode: string, - userAgent: string | undefined + userAgent: string | undefined, + ctx?: InstagramRequestContext ): Promise { + let mediaIdForProxy: string | null; + try { + mediaIdForProxy = String(instagramShortcodeToPk(shortcode)); + } catch { + mediaIdForProxy = null; + } + + if (mediaIdForProxy) { + const accounts = await resolveInstagramAccounts(ctx); + if (accounts.length) { + const info = await fetchPrivateMediaInfo(mediaIdForProxy, ctx, { accounts, shortcode }); + const item = info.ok ? firstMediaItem(info.json) : null; + if (item) { + return { + ok: true, + status: info.status, + html: '', + item, + pathUsed: `/p/${encodeURIComponent(shortcode)}/`, + comments: null, + source: 'account-proxy', + lsd: null + }; + } + /* + * Deliberately no short-circuit on 404 here: a proxy account that the poster has blocked + * gets a 404 for a post that is perfectly visible logged-out. Falling through costs one + * wasted request on genuinely deleted posts, which the logged-out path reports as 404 anyway. + */ + } + } + const session = await fetchInstagramSession(userAgent); const cookies = session?.cookieHeader ?? ''; const htmlOpts = cookies ? { cookies } : undefined; - let mediaId: string | null; - try { - mediaId = String(instagramShortcodeToPk(shortcode)); - } catch { - mediaId = null; - } + const mediaId = mediaIdForProxy; // Prefer GraphQL when we have a full session; ignore failures (common without TLS impersonation). if (session && mediaId && session.lsd) { diff --git a/packages/atmosphere/src/providers/instagram/likes.ts b/packages/atmosphere/src/providers/instagram/likes.ts new file mode 100644 index 00000000..2f9d1c5d --- /dev/null +++ b/packages/atmosphere/src/providers/instagram/likes.ts @@ -0,0 +1,45 @@ +import type { APIUserListResults } from '../../types/api-schemas.js'; +import { resolveInstagramAccounts, type InstagramRequestContext } from './account-proxy.js'; +import { fetchPrivateMediaLikers } from './private-api.js'; +import { usersFromPrivateList } from './private-processor.js'; +import { instagramShortcodeToPk } from './shortcode.js'; + +const empty = (code: number): APIUserListResults => ({ + code, + results: [], + cursor: { top: null, bottom: null } +}); + +/** + * Accounts that liked a post — Instagram's closest analogue to X's repost/quote lists. + * + * Requires the account proxy (`media/{pk}/likers/` is logged-in only) and returns a single + * un-paginated page, which is all Instagram serves for this surface. + */ +export async function constructInstagramStatusLikes( + shortcode: string, + options: { count: number; ctx?: InstagramRequestContext } +): Promise { + const count = Math.min(100, Math.max(1, Math.floor(options.count))); + const accounts = await resolveInstagramAccounts(options.ctx); + if (!accounts.length) { + return empty(501); + } + + let mediaId: string; + try { + mediaId = String(instagramShortcodeToPk(shortcode)); + } catch { + return empty(400); + } + + const res = await fetchPrivateMediaLikers(mediaId, options.ctx, { accounts, shortcode }); + if (!res.ok) { + return empty(res.status === 404 ? 404 : 500); + } + return { + code: 200, + results: usersFromPrivateList(res.json).slice(0, count), + cursor: { top: null, bottom: null } + }; +} diff --git a/packages/atmosphere/src/providers/instagram/post.ts b/packages/atmosphere/src/providers/instagram/post.ts index 38125cab..c488629d 100644 --- a/packages/atmosphere/src/providers/instagram/post.ts +++ b/packages/atmosphere/src/providers/instagram/post.ts @@ -1,12 +1,15 @@ import type { SocialThreadInstagram } from '../../types/api-schemas.js'; +import type { InstagramRequestContext } from './account-proxy.js'; import { fetchInstagramPageWithWebInfo } from './fetch-shortcode-page.js'; import { instagramNodeToStatus } from './processor.js'; export async function constructInstagramPost( shortcode: string, - userAgent: string | undefined + userAgent: string | undefined, + options: { credentialKey?: string } = {} ): Promise { - const page = await fetchInstagramPageWithWebInfo(shortcode, userAgent); + const ctx: InstagramRequestContext = { userAgent, credentialKey: options.credentialKey }; + const page = await fetchInstagramPageWithWebInfo(shortcode, userAgent, ctx); if (!page.ok) { return { code: page.status === 404 ? 404 : 500, status: null, thread: null, author: null }; } diff --git a/packages/atmosphere/src/providers/instagram/private-api.ts b/packages/atmosphere/src/providers/instagram/private-api.ts new file mode 100644 index 00000000..a00922b1 --- /dev/null +++ b/packages/atmosphere/src/providers/instagram/private-api.ts @@ -0,0 +1,208 @@ +import { + instagramPrivateApiRequest, + type InstagramPrivateApiResult, + type InstagramRequestContext +} from './account-proxy.js'; +import type { InstagramCredentials } from '../../types/proxy-credentials.js'; +import { INSTAGRAM_ORIGIN } from './constants.js'; + +/* + * Endpoint paths below are the ones the Instagram Android app itself calls + * (444.0.0.46.85). Two exceptions are marked `@legacy`: they are v1 REST endpoints the current + * app no longer references (it fetches those surfaces over GraphQL/Bloks instead) but which + * i.instagram.com still serves. Treat a sudden 404 from those as "Instagram retired it" rather + * than a bug here. + */ + +export type InstagramApiOptions = { + /** Pre-resolved accounts, so a multi-call flow reuses one shuffle. */ + accounts?: InstagramCredentials[]; +}; + +const profileReferer = (username: string) => `${INSTAGRAM_ORIGIN}/${encodeURIComponent(username)}/`; + +/** `users/{username}/usernameinfo/` — profile by handle. */ +export function fetchPrivateUserByUsername( + username: string, + ctx: InstagramRequestContext | undefined, + options: InstagramApiOptions = {} +): Promise { + return instagramPrivateApiRequest(`/users/${encodeURIComponent(username)}/usernameinfo/`, ctx, { + referer: profileReferer(username), + accounts: options.accounts + }); +} + +/** `users/{pk}/info/` — profile by numeric id. */ +export function fetchPrivateUserById( + userId: string, + ctx: InstagramRequestContext | undefined, + options: InstagramApiOptions = {} +): Promise { + return instagramPrivateApiRequest(`/users/${encodeURIComponent(userId)}/info/`, ctx, { + accounts: options.accounts + }); +} + +/** `media/{pk}/info/` — full media object, including video versions Instagram hides logged-out. */ +export function fetchPrivateMediaInfo( + mediaId: string, + ctx: InstagramRequestContext | undefined, + options: InstagramApiOptions & { shortcode?: string } = {} +): Promise { + return instagramPrivateApiRequest(`/media/${encodeURIComponent(mediaId)}/info/`, ctx, { + referer: options.shortcode + ? `${INSTAGRAM_ORIGIN}/p/${encodeURIComponent(options.shortcode)}/` + : undefined, + accounts: options.accounts + }); +} + +/** `media/{pk}/comments/` — comment page. `minId`/`maxId` are the app's cursor params. */ +export function fetchPrivateMediaComments( + mediaId: string, + ctx: InstagramRequestContext | undefined, + options: InstagramApiOptions & { + maxId?: string | null; + minId?: string | null; + count?: number; + sortOrder?: 'popular' | 'recent'; + shortcode?: string; + } = {} +): Promise { + return instagramPrivateApiRequest(`/media/${encodeURIComponent(mediaId)}/comments/`, ctx, { + query: { + can_support_threading: 'true', + permalink_enabled: 'false', + sort_order: options.sortOrder ?? 'popular', + count: options.count, + max_id: options.maxId ?? undefined, + min_id: options.minId ?? undefined + }, + referer: options.shortcode + ? `${INSTAGRAM_ORIGIN}/p/${encodeURIComponent(options.shortcode)}/` + : undefined, + accounts: options.accounts + }); +} + +/** + * `media/{pk}/likers/` — accounts that liked a post. + * @legacy Not referenced by the 444.x Android build (likers moved to GraphQL), still served by v1. + */ +export function fetchPrivateMediaLikers( + mediaId: string, + ctx: InstagramRequestContext | undefined, + options: InstagramApiOptions & { shortcode?: string } = {} +): Promise { + return instagramPrivateApiRequest(`/media/${encodeURIComponent(mediaId)}/likers/`, ctx, { + referer: options.shortcode + ? `${INSTAGRAM_ORIGIN}/p/${encodeURIComponent(options.shortcode)}/` + : undefined, + accounts: options.accounts + }); +} + +/** + * `feed/user/{pk}/` — a profile's own posts. + * @legacy Not referenced by the 444.x Android build (the profile grid moved to GraphQL), still + * served by v1 and materially better than the logged-out grid for private/age-gated accounts. + */ +export function fetchPrivateUserFeed( + userId: string, + ctx: InstagramRequestContext | undefined, + options: InstagramApiOptions & { maxId?: string | null; count?: number; username?: string } = {} +): Promise { + return instagramPrivateApiRequest(`/feed/user/${encodeURIComponent(userId)}/`, ctx, { + query: { count: options.count, max_id: options.maxId ?? undefined }, + referer: options.username ? profileReferer(options.username) : undefined, + accounts: options.accounts + }); +} + +/** `usertags/{pk}/feed/` — posts the account is tagged in. */ +export function fetchPrivateUserTaggedFeed( + userId: string, + ctx: InstagramRequestContext | undefined, + options: InstagramApiOptions & { maxId?: string | null; count?: number; username?: string } = {} +): Promise { + return instagramPrivateApiRequest(`/usertags/${encodeURIComponent(userId)}/feed/`, ctx, { + query: { count: options.count, max_id: options.maxId ?? undefined }, + referer: options.username ? `${profileReferer(options.username)}tagged/` : undefined, + accounts: options.accounts + }); +} + +/** `friendships/{pk}/followers/` — follower list page. */ +export function fetchPrivateFollowers( + userId: string, + ctx: InstagramRequestContext | undefined, + options: InstagramApiOptions & { maxId?: string | null; count?: number; username?: string } = {} +): Promise { + return instagramPrivateApiRequest(`/friendships/${encodeURIComponent(userId)}/followers/`, ctx, { + query: { + count: options.count, + max_id: options.maxId ?? undefined, + search_surface: 'follow_list_page' + }, + referer: options.username ? `${profileReferer(options.username)}followers/` : undefined, + accounts: options.accounts + }); +} + +/** `friendships/{pk}/following/` — following list page. */ +export function fetchPrivateFollowing( + userId: string, + ctx: InstagramRequestContext | undefined, + options: InstagramApiOptions & { maxId?: string | null; count?: number; username?: string } = {} +): Promise { + return instagramPrivateApiRequest(`/friendships/${encodeURIComponent(userId)}/following/`, ctx, { + query: { + count: options.count, + max_id: options.maxId ?? undefined, + search_surface: 'follow_list_page' + }, + referer: options.username ? `${profileReferer(options.username)}following/` : undefined, + accounts: options.accounts + }); +} + +/** `users/search/` — user search. */ +export function fetchPrivateUserSearch( + query: string, + ctx: InstagramRequestContext | undefined, + options: InstagramApiOptions & { count?: number } = {} +): Promise { + return instagramPrivateApiRequest('/users/search/', ctx, { + query: { q: query, count: options.count, search_surface: 'user_search_page' }, + accounts: options.accounts + }); +} + +/** `fbsearch/ig_typeahead/` — blended typeahead (users + hashtags + places). */ +export function fetchPrivateTypeahead( + query: string, + ctx: InstagramRequestContext | undefined, + options: InstagramApiOptions & { count?: number } = {} +): Promise { + return instagramPrivateApiRequest('/fbsearch/ig_typeahead/', ctx, { + query: { query, count: options.count, search_surface: 'top_search_page' }, + accounts: options.accounts + }); +} + +/** `feed/reels_media/` — active stories for one or more accounts. */ +export function fetchPrivateReelsMedia( + userIds: string[], + ctx: InstagramRequestContext | undefined, + options: InstagramApiOptions = {} +): Promise { + return instagramPrivateApiRequest('/feed/reels_media/', ctx, { + method: 'POST', + body: new URLSearchParams({ + user_ids: JSON.stringify(userIds), + source: 'profile' + }).toString(), + accounts: options.accounts + }); +} diff --git a/packages/atmosphere/src/providers/instagram/private-processor.ts b/packages/atmosphere/src/providers/instagram/private-processor.ts new file mode 100644 index 00000000..5f4e39bb --- /dev/null +++ b/packages/atmosphere/src/providers/instagram/private-processor.ts @@ -0,0 +1,134 @@ +import type { APIUser } from '../../types/api-schemas.js'; + +/** + * Normalizers for `i.instagram.com/api/v1` payloads. Media items from the private API already match + * the shape `instagramNodeToStatus` handles (`code`, `user`, `media_type`, `video_versions`, + * `carousel_media`, `caption.text`, `taken_at`), so only users and pagination need their own mapping. + */ + +function num(...vals: unknown[]): number { + for (const v of vals) { + if (typeof v === 'number' && Number.isFinite(v)) return Math.trunc(v); + if (typeof v === 'string' && v.trim() !== '') { + const n = Number(v); + if (Number.isFinite(n)) return Math.trunc(n); + } + } + return 0; +} + +function str(...vals: unknown[]): string { + for (const v of vals) { + if (typeof v === 'string' && v.length > 0) return v; + } + return ''; +} + +/** + * Map one private-API user record to {@link APIUser}. Handles both the full record from + * `usernameinfo` / `users/{pk}/info` and the trimmed record in follower / search lists (which omits + * counts — those come back as 0 rather than being invented). + */ +export function userFromPrivateRecord(rec: Record): APIUser | null { + const id = str(rec.pk_id, typeof rec.pk === 'number' ? String(rec.pk) : rec.pk, rec.id); + const username = str(rec.username); + if (!id || !username) return null; + const bio = typeof rec.biography === 'string' ? rec.biography : ''; + const isVerified = Boolean(rec.is_verified); + const isPrivate = Boolean(rec.is_private); + const externalUrl = str(rec.external_url); + const hdProfilePic = (rec.hd_profile_pic_url_info as { url?: string } | undefined)?.url; + return { + type: 'profile', + id, + name: str(rec.full_name) || username, + screen_name: username, + avatar_url: str(hdProfilePic, rec.profile_pic_url, rec.profile_pic_url_hd) || null, + banner_url: null, + description: bio, + raw_description: { text: bio, facets: [] }, + location: str( + (rec.address_street as string | undefined) ?? undefined, + (rec.city_name as string | undefined) ?? undefined + ), + url: `https://www.instagram.com/${encodeURIComponent(username)}/`, + protected: isPrivate, + followers: num(rec.follower_count), + following: num(rec.following_count), + statuses: num(rec.media_count), + media_count: num(rec.media_count), + likes: 0, + joined: '1970-01-01T00:00:00.000Z', + website: externalUrl + ? { url: externalUrl, display_url: externalUrl.replace(/^https?:\/\//, '') } + : null, + verification: { + verified: isVerified, + type: isVerified ? 'individual' : null + } + }; +} + +/** Pull `{ user: … }` out of `usernameinfo` / `users/{pk}/info` and map it. */ +export function userFromPrivateUserResponse(json: unknown): APIUser | null { + if (!json || typeof json !== 'object') return null; + const user = (json as { user?: unknown }).user; + if (!user || typeof user !== 'object') return null; + return userFromPrivateRecord(user as Record); +} + +/** Map a private-API `users` array (follower lists, `users/search/`) to {@link APIUser}s. */ +export function usersFromPrivateList(json: unknown): APIUser[] { + if (!json || typeof json !== 'object') return []; + const users = (json as { users?: unknown }).users; + if (!Array.isArray(users)) return []; + const out: APIUser[] = []; + for (const u of users) { + if (!u || typeof u !== 'object') continue; + const mapped = userFromPrivateRecord(u as Record); + if (mapped) out.push(mapped); + } + return out; +} + +/** + * The private API paginates with `next_max_id`, which is a string on feeds and (on some list + * endpoints) a number or a `{ next_max_id }`-shaped object. `big_list: false` means "no more pages" + * on friendship lists even when a cursor is echoed back. + */ +export function nextMaxIdFromPrivateResponse(json: unknown): string | null { + if (!json || typeof json !== 'object') return null; + const root = json as Record; + if (root.more_available === false) return null; + if (root.big_list === false) return null; + if (root.has_more_comments === false) return null; + const raw = root.next_max_id; + if (typeof raw === 'string' && raw.length > 0) return raw; + if (typeof raw === 'number' && Number.isFinite(raw)) return String(raw); + if (raw && typeof raw === 'object') { + const nested = (raw as { next_max_id?: unknown }).next_max_id; + if (typeof nested === 'string' && nested.length > 0) return nested; + if (typeof nested === 'number' && Number.isFinite(nested)) return String(nested); + } + return null; +} + +/** Media items from a private-API feed response (`items`), tolerating `{ media: … }` wrappers. */ +export function mediaItemsFromPrivateFeed(json: unknown): Record[] { + if (!json || typeof json !== 'object') return []; + const items = (json as { items?: unknown }).items; + if (!Array.isArray(items)) return []; + const out: Record[] = []; + for (const item of items) { + if (!item || typeof item !== 'object') continue; + const rec = item as Record; + // `usertags/{pk}/feed/` wraps each entry as `{ media: … }`. + const media = rec.media; + if (media && typeof media === 'object' && !Array.isArray(media)) { + out.push(media as Record); + continue; + } + out.push(rec); + } + return out; +} diff --git a/packages/atmosphere/src/providers/instagram/profile.ts b/packages/atmosphere/src/providers/instagram/profile.ts index 332cbac1..f857f123 100644 --- a/packages/atmosphere/src/providers/instagram/profile.ts +++ b/packages/atmosphere/src/providers/instagram/profile.ts @@ -3,17 +3,24 @@ import type { APISearchResultsInstagram, UserAPIResponse } from '../../types/api-schemas.js'; +import { resolveInstagramAccounts, type InstagramRequestContext } from './account-proxy.js'; import { fetchInstagramCsrfToken, fetchTimelineGraphqlPage, fetchWebProfileInfo } from './client.js'; import { + decodeMaxIdCursor, decodeProfileCursor, + encodeMaxIdCursor, encodeProfileCursor, + sameInstagramHandle, type InstagramProfileCursorV1 } from './cursors.js'; -import { edgeNodeToStatus, fullUserFromWebProfile } from './processor.js'; +import { fetchPrivateUserFeed } from './private-api.js'; +import { mediaItemsFromPrivateFeed, nextMaxIdFromPrivateResponse } from './private-processor.js'; +import { edgeNodeToStatus, instagramNodeToStatus } from './processor.js'; +import { resolveInstagramUser } from './resolve-user.js'; function getWebProfileUser(json: unknown): Record | null { const root = json as { data?: { user?: unknown } }; @@ -156,18 +163,69 @@ function parseFelixGraphql(json: unknown): { export async function constructInstagramProfile( username: string, - userAgent: string | undefined + userAgent: string | undefined, + options: { credentialKey?: string } = {} ): Promise { - const res = await fetchWebProfileInfo(username, userAgent); - if (!res.ok) { - if (res.status === 404) return { code: 404, message: 'User not found' }; + const ctx: InstagramRequestContext = { userAgent, credentialKey: options.credentialKey }; + const resolved = await resolveInstagramUser(username, ctx); + if (resolved.code === 404) return { code: 404, message: 'User not found' }; + if (resolved.code !== 200 || !resolved.user) { return { code: 500, message: 'Instagram profile request failed' }; } - const user = fullUserFromWebProfile(res.json as Record); - if (!user) { - return { code: 404, message: 'User not found' }; + return { code: 200, message: 'OK', user: resolved.user }; +} + +/** + * One page of a profile's own posts through the account proxy (`feed/user/{pk}/`). + * `videosOnly` filters the page after the fact — Instagram has no logged-in reels-tab REST + * endpoint, so a page can come back with fewer than `count` results while still paginating. + */ +async function privateFeedPage(params: { + userId: string; + username: string; + count: number; + maxId: string | null; + videosOnly: boolean; + ctx: InstagramRequestContext; + accounts: Awaited>; +}): Promise { + const res = await fetchPrivateUserFeed(params.userId, params.ctx, { + accounts: params.accounts, + count: params.count, + maxId: params.maxId, + username: params.username + }); + if (!res.ok) { + return { + code: res.status === 404 ? 404 : 500, + results: [], + cursor: { top: null, bottom: null } + }; } - return { code: 200, message: 'OK', user }; + + const ownerFallback = { id: params.userId, username: params.username }; + const results: APIInstagramStatus[] = []; + for (const item of mediaItemsFromPrivateFeed(res.json)) { + if (results.length >= params.count) break; + if (params.videosOnly && !nodeShowsVideoInGrid(item)) continue; + const status = instagramNodeToStatus(item, ownerFallback, { + userAgent: params.ctx.userAgent + }); + if (status) results.push(status); + } + + const nextMaxId = nextMaxIdFromPrivateResponse(res.json); + const bottom = nextMaxId + ? encodeMaxIdCursor({ + v: 1, + k: 'feed', + id: params.userId, + u: params.username, + m: nextMaxId, + c: params.count + }) + : null; + return { code: 200, results, cursor: { top: null, bottom } }; } async function timelinePageFromGraphql( @@ -225,11 +283,62 @@ async function timelinePageFromGraphql( return { code: 200, results, cursor: { top: null, bottom } }; } +/** + * Shared account-proxy entry for the profile grid / reels tab. Returns `null` when the request + * should fall through to the logged-out web path (no proxy, or the cursor belongs to it). + */ +async function tryPrivateProfileFeed( + username: string, + videosOnly: boolean, + options: { count: number; cursor: string | null; userAgent?: string; credentialKey?: string } +): Promise { + const ctx: InstagramRequestContext = { + userAgent: options.userAgent, + credentialKey: options.credentialKey + }; + const accounts = await resolveInstagramAccounts(ctx); + if (!accounts.length) return null; + + if (options.cursor) { + const decoded = decodeMaxIdCursor(options.cursor); + // A profile cursor from the logged-out path is still valid; let the caller handle it. + if (!decoded || decoded.k !== 'feed' || !sameInstagramHandle(decoded.u, username)) return null; + return privateFeedPage({ + userId: decoded.id, + username, + count: decoded.c, + maxId: decoded.m, + videosOnly, + ctx, + accounts + }); + } + + const resolved = await resolveInstagramUser(username, ctx, { accounts }); + if (resolved.code === 404) { + return { code: 404, results: [], cursor: { top: null, bottom: null } }; + } + if (resolved.code !== 200 || !resolved.user) return null; + + const page = await privateFeedPage({ + userId: resolved.user.id, + username, + count: Math.min(100, Math.max(1, Math.floor(options.count))), + maxId: null, + videosOnly, + ctx, + accounts + }); + return page.code === 200 ? page : null; +} + export async function constructInstagramProfileStatuses( username: string, - options: { count: number; cursor: string | null; userAgent?: string } + options: { count: number; cursor: string | null; userAgent?: string; credentialKey?: string } ): Promise { const count = Math.min(100, Math.max(1, Math.floor(options.count))); + const proxied = await tryPrivateProfileFeed(username, false, options); + if (proxied) return proxied; if (options.cursor) { const decoded = decodeProfileCursor(options.cursor); if (!decoded || decoded.k !== 't') { @@ -270,9 +379,11 @@ export async function constructInstagramProfileStatuses( export async function constructInstagramProfileVideos( username: string, - options: { count: number; cursor: string | null; userAgent?: string } + options: { count: number; cursor: string | null; userAgent?: string; credentialKey?: string } ): Promise { const count = Math.min(100, Math.max(1, Math.floor(options.count))); + const proxied = await tryPrivateProfileFeed(username, true, options); + if (proxied) return proxied; if (options.cursor) { const decoded = decodeProfileCursor(options.cursor); if (!decoded || decoded.k !== 'r') { diff --git a/packages/atmosphere/src/providers/instagram/relationships.ts b/packages/atmosphere/src/providers/instagram/relationships.ts new file mode 100644 index 00000000..3d4da780 --- /dev/null +++ b/packages/atmosphere/src/providers/instagram/relationships.ts @@ -0,0 +1,60 @@ +import type { APIProfileRelationshipList } from '../../types/api-schemas.js'; +import { resolveInstagramAccounts, type InstagramRequestContext } from './account-proxy.js'; +import { decodeMaxIdCursor, encodeMaxIdCursor, sameInstagramHandle } from './cursors.js'; +import { fetchPrivateFollowers, fetchPrivateFollowing } from './private-api.js'; +import { nextMaxIdFromPrivateResponse, usersFromPrivateList } from './private-processor.js'; +import { resolveInstagramUser } from './resolve-user.js'; + +export type InstagramRelationshipKind = 'followers' | 'following'; + +const empty = (code: number): APIProfileRelationshipList => ({ + code, + results: [], + cursor: { top: null, bottom: null } +}); + +/** + * Follower / following lists. Instagram only exposes these to a logged-in session, so this needs an + * account proxy; without one it reports 501 rather than pretending the account has no followers. + */ +export async function constructInstagramRelationshipList( + username: string, + kind: InstagramRelationshipKind, + options: { count: number; cursor: string | null; ctx?: InstagramRequestContext } +): Promise { + const count = Math.min(100, Math.max(1, Math.floor(options.count))); + const accounts = await resolveInstagramAccounts(options.ctx); + if (!accounts.length) { + return empty(501); + } + + let userId: string; + let maxId: string | null = null; + if (options.cursor) { + const decoded = decodeMaxIdCursor(options.cursor); + if (!decoded || decoded.k !== kind || !sameInstagramHandle(decoded.u, username)) { + return empty(400); + } + userId = decoded.id; + maxId = decoded.m; + } else { + const resolved = await resolveInstagramUser(username, options.ctx, { accounts }); + if (resolved.code !== 200 || !resolved.user) { + return empty(resolved.code); + } + userId = resolved.user.id; + } + + const fetcher = kind === 'followers' ? fetchPrivateFollowers : fetchPrivateFollowing; + const res = await fetcher(userId, options.ctx, { accounts, count, maxId, username }); + if (!res.ok) { + return empty(res.status === 404 ? 404 : 500); + } + + const results = usersFromPrivateList(res.json).slice(0, count); + const nextMaxId = nextMaxIdFromPrivateResponse(res.json); + const bottom = nextMaxId + ? encodeMaxIdCursor({ v: 1, k: kind, id: userId, u: username, m: nextMaxId, c: count }) + : null; + return { code: 200, results, cursor: { top: null, bottom } }; +} diff --git a/packages/atmosphere/src/providers/instagram/resolve-user.ts b/packages/atmosphere/src/providers/instagram/resolve-user.ts new file mode 100644 index 00000000..b591cffa --- /dev/null +++ b/packages/atmosphere/src/providers/instagram/resolve-user.ts @@ -0,0 +1,47 @@ +import type { APIUser } from '../../types/api-schemas.js'; +import type { InstagramCredentials } from '../../types/proxy-credentials.js'; +import { resolveInstagramAccounts, type InstagramRequestContext } from './account-proxy.js'; +import { fetchWebProfileInfo } from './client.js'; +import { fetchPrivateUserByUsername } from './private-api.js'; +import { fullUserFromWebProfile } from './processor.js'; +import { userFromPrivateUserResponse } from './private-processor.js'; + +export type ResolvedInstagramUser = { + code: 200 | 404 | 500; + user: APIUser | null; + /** Accounts resolved along the way, so callers can reuse one shuffle across follow-up calls. */ + accounts: InstagramCredentials[]; +}; + +/** + * Resolve a handle to a profile. Prefers the account proxy (`users/{username}/usernameinfo/`), + * which also works for age-gated accounts, and falls back to logged-out `web_profile_info`. + */ +export async function resolveInstagramUser( + username: string, + ctx: InstagramRequestContext | undefined, + options: { accounts?: InstagramCredentials[] } = {} +): Promise { + const accounts = options.accounts ?? (await resolveInstagramAccounts(ctx)); + + if (accounts.length) { + const res = await fetchPrivateUserByUsername(username, ctx, { accounts }); + if (res.ok) { + const user = userFromPrivateUserResponse(res.json); + if (user) return { code: 200, user, accounts }; + } + if (res.status === 404) { + return { code: 404, user: null, accounts }; + } + } + + const web = await fetchWebProfileInfo(username, ctx?.userAgent); + if (!web.ok) { + return { code: web.status === 404 ? 404 : 500, user: null, accounts }; + } + const user = fullUserFromWebProfile(web.json as Record); + if (!user) { + return { code: 404, user: null, accounts }; + } + return { code: 200, user, accounts }; +} diff --git a/packages/atmosphere/src/providers/instagram/search.ts b/packages/atmosphere/src/providers/instagram/search.ts new file mode 100644 index 00000000..c0d42c58 --- /dev/null +++ b/packages/atmosphere/src/providers/instagram/search.ts @@ -0,0 +1,131 @@ +import type { + APITypeaheadResponse, + APITypeaheadTopic, + APIUser, + APIUserListResults +} from '../../types/api-schemas.js'; +import { resolveInstagramAccounts, type InstagramRequestContext } from './account-proxy.js'; +import { fetchPrivateTypeahead, fetchPrivateUserSearch } from './private-api.js'; +import { userFromPrivateRecord, usersFromPrivateList } from './private-processor.js'; + +const emptyUserList = (code: number): APIUserListResults => ({ + code, + results: [], + cursor: { top: null, bottom: null } +}); + +const emptyTypeahead = (code: number, query: string): APITypeaheadResponse => ({ + code, + query, + num_results: 0, + users: [], + topics: [], + events: [] +}); + +/** + * User search (`users/search/`). Logged-out Instagram has no usable search surface, so this needs + * the account proxy; without one it reports 501. + * + * The endpoint returns one ranked page and no cursor, so `cursor.bottom` is always null. + */ +export async function constructInstagramUserSearch( + query: string, + options: { count: number; ctx?: InstagramRequestContext } +): Promise { + const count = Math.min(50, Math.max(1, Math.floor(options.count))); + const accounts = await resolveInstagramAccounts(options.ctx); + if (!accounts.length) { + return emptyUserList(501); + } + const res = await fetchPrivateUserSearch(query, options.ctx, { accounts, count }); + if (!res.ok) { + return emptyUserList(500); + } + return { + code: 200, + results: usersFromPrivateList(res.json).slice(0, count), + cursor: { top: null, bottom: null } + }; +} + +type TypeaheadEntry = { + user?: Record; + hashtag?: { name?: string; media_count?: number; formatted_media_count?: string }; + place?: { + location?: { name?: string; city?: string; short_name?: string }; + title?: string; + subtitle?: string; + }; +}; + +function typeaheadEntries(json: unknown): TypeaheadEntry[] { + if (!json || typeof json !== 'object') return []; + const list = (json as { list?: unknown }).list; + if (!Array.isArray(list)) return []; + return list.filter((e): e is TypeaheadEntry => Boolean(e) && typeof e === 'object'); +} + +/** + * Blended typeahead (`fbsearch/ig_typeahead/`). Instagram's mix is users / hashtags / places; + * hashtags and places both land in `topics` since the API v2 shape has no separate place bucket. + * `events` stays empty — Instagram has no equivalent. + */ +export async function constructInstagramTypeahead( + query: string, + options: { ctx?: InstagramRequestContext; count?: number } = {} +): Promise { + const accounts = await resolveInstagramAccounts(options.ctx); + if (!accounts.length) { + return emptyTypeahead(501, query); + } + const res = await fetchPrivateTypeahead(query, options.ctx, { accounts, count: options.count }); + if (!res.ok) { + return emptyTypeahead(500, query); + } + + const users: APIUser[] = []; + const topics: APITypeaheadTopic[] = []; + for (const entry of typeaheadEntries(res.json)) { + if (entry.user) { + const mapped = userFromPrivateRecord(entry.user); + if (mapped) users.push(mapped); + continue; + } + const tag = entry.hashtag; + const tagName = tag?.name; + if (tagName) { + topics.push({ + topic: `#${tagName}`, + result_context: { + display_string: tag?.formatted_media_count + ? `${tag.formatted_media_count} posts` + : undefined, + redirect_url: `https://www.instagram.com/explore/tags/${encodeURIComponent(tagName)}/`, + types: [{ type: 'hashtag' }] + } + }); + continue; + } + const place = entry.place; + const placeName = place?.location?.name ?? place?.title; + if (placeName) { + topics.push({ + topic: placeName, + result_context: { + display_string: place?.subtitle ?? place?.location?.city, + types: [{ type: 'place' }] + } + }); + } + } + + return { + code: 200, + query, + num_results: users.length + topics.length, + users, + topics, + events: [] + }; +} diff --git a/packages/atmosphere/src/providers/instagram/stories.ts b/packages/atmosphere/src/providers/instagram/stories.ts new file mode 100644 index 00000000..694991a6 --- /dev/null +++ b/packages/atmosphere/src/providers/instagram/stories.ts @@ -0,0 +1,78 @@ +import type { APIInstagramStatus, APISearchResultsInstagram } from '../../types/api-schemas.js'; +import { resolveInstagramAccounts, type InstagramRequestContext } from './account-proxy.js'; +import { fetchPrivateReelsMedia } from './private-api.js'; +import { instagramNodeToStatus } from './processor.js'; +import { resolveInstagramUser } from './resolve-user.js'; + +const empty = (code: number): APISearchResultsInstagram => ({ + code, + results: [], + cursor: { top: null, bottom: null } +}); + +/** `feed/reels_media/` answers with `reels` keyed by pk and/or a `reels_media` array. */ +function storyItemsFromReelsResponse(json: unknown, userId: string): Record[] { + if (!json || typeof json !== 'object') return []; + const root = json as { reels?: Record; reels_media?: unknown[] }; + const trays: unknown[] = []; + const keyed = root.reels?.[userId]; + if (keyed) trays.push(keyed); + if (Array.isArray(root.reels_media)) trays.push(...root.reels_media); + + const out: Record[] = []; + const seen = new Set(); + for (const tray of trays) { + if (!tray || typeof tray !== 'object') continue; + const items = (tray as { items?: unknown }).items; + if (!Array.isArray(items)) continue; + for (const item of items) { + if (!item || typeof item !== 'object') continue; + const rec = item as Record; + const key = String(rec.pk ?? rec.id ?? ''); + if (key && seen.has(key)) continue; + if (key) seen.add(key); + out.push(rec); + } + } + return out; +} + +/** + * An account's currently-active stories. Stories expire after 24 hours and are logged-in only, so + * this needs the account proxy; there is no pagination to expose. + */ +export async function constructInstagramProfileStories( + username: string, + options: { ctx?: InstagramRequestContext } = {} +): Promise { + const accounts = await resolveInstagramAccounts(options.ctx); + if (!accounts.length) { + return empty(501); + } + + const resolved = await resolveInstagramUser(username, options.ctx, { accounts }); + if (resolved.code !== 200 || !resolved.user) { + return empty(resolved.code); + } + const userId = resolved.user.id; + + const res = await fetchPrivateReelsMedia([userId], options.ctx, { accounts }); + if (!res.ok) { + return empty(res.status === 404 ? 404 : 500); + } + + const ownerFallback = { + id: userId, + username, + fullName: resolved.user.name, + pic: resolved.user.avatar_url + }; + const results: APIInstagramStatus[] = []; + for (const item of storyItemsFromReelsResponse(res.json, userId)) { + const status = instagramNodeToStatus(item, ownerFallback, { + userAgent: options.ctx?.userAgent + }); + if (status) results.push(status); + } + return { code: 200, results, cursor: { top: null, bottom: null } }; +} diff --git a/packages/atmosphere/src/providers/instagram/tagged.ts b/packages/atmosphere/src/providers/instagram/tagged.ts new file mode 100644 index 00000000..86157d6b --- /dev/null +++ b/packages/atmosphere/src/providers/instagram/tagged.ts @@ -0,0 +1,70 @@ +import type { APIInstagramStatus, APISearchResultsInstagram } from '../../types/api-schemas.js'; +import { resolveInstagramAccounts, type InstagramRequestContext } from './account-proxy.js'; +import { decodeMaxIdCursor, encodeMaxIdCursor, sameInstagramHandle } from './cursors.js'; +import { fetchPrivateUserTaggedFeed } from './private-api.js'; +import { mediaItemsFromPrivateFeed, nextMaxIdFromPrivateResponse } from './private-processor.js'; +import { instagramNodeToStatus } from './processor.js'; +import { resolveInstagramUser } from './resolve-user.js'; + +const empty = (code: number): APISearchResultsInstagram => ({ + code, + results: [], + cursor: { top: null, bottom: null } +}); + +/** + * Posts an account is tagged in (`usertags/{pk}/feed/`) — the closest analogue to X's + * `/profile/{handle}/media` for a third-party grid. Logged-in only, so this needs the account proxy. + */ +export async function constructInstagramProfileTagged( + username: string, + options: { count: number; cursor: string | null; ctx?: InstagramRequestContext } +): Promise { + const count = Math.min(100, Math.max(1, Math.floor(options.count))); + const accounts = await resolveInstagramAccounts(options.ctx); + if (!accounts.length) { + return empty(501); + } + + let userId: string; + let maxId: string | null = null; + if (options.cursor) { + const decoded = decodeMaxIdCursor(options.cursor); + if (!decoded || decoded.k !== 'tagged' || !sameInstagramHandle(decoded.u, username)) { + return empty(400); + } + userId = decoded.id; + maxId = decoded.m; + } else { + const resolved = await resolveInstagramUser(username, options.ctx, { accounts }); + if (resolved.code !== 200 || !resolved.user) { + return empty(resolved.code); + } + userId = resolved.user.id; + } + + const res = await fetchPrivateUserTaggedFeed(userId, options.ctx, { + accounts, + count, + maxId, + username + }); + if (!res.ok) { + return empty(res.status === 404 ? 404 : 500); + } + + const ownerFallback = { id: userId, username }; + const results: APIInstagramStatus[] = []; + for (const item of mediaItemsFromPrivateFeed(res.json).slice(0, count)) { + const status = instagramNodeToStatus(item, ownerFallback, { + userAgent: options.ctx?.userAgent + }); + if (status) results.push(status); + } + + const nextMaxId = nextMaxIdFromPrivateResponse(res.json); + const bottom = nextMaxId + ? encodeMaxIdCursor({ v: 1, k: 'tagged', id: userId, u: username, m: nextMaxId, c: count }) + : null; + return { code: 200, results, cursor: { top: null, bottom } }; +} diff --git a/packages/atmosphere/src/providers/threads/account-proxy.ts b/packages/atmosphere/src/providers/threads/account-proxy.ts new file mode 100644 index 00000000..37b8e6b2 --- /dev/null +++ b/packages/atmosphere/src/providers/threads/account-proxy.ts @@ -0,0 +1,234 @@ +import { withTimeout } from '../../helpers/with-timeout.js'; +import { getInstagramProviderEnv } from '../instagram-runtime.js'; +import { + hasInstagramAccountProxy, + resolveInstagramAccounts, + type InstagramRequestContext +} from '../instagram/account-proxy.js'; +import { INSTAGRAM_ASBD_ID } from '../instagram/constants.js'; +import type { InstagramCredentials } from '../../types/proxy-credentials.js'; +import { + THREADS_ANDROID_APP_ID, + THREADS_ANDROID_CAPABILITIES, + THREADS_ANDROID_USER_AGENT, + THREADS_API_V1, + THREADS_ORIGIN +} from './constants.js'; + +const WEB_USER_AGENT = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; + +/** + * Per-request Threads context. Threads accounts *are* Instagram accounts, so the proxy pool is the + * Instagram one — this is the same `{ credentialKey }` shape, aliased so callers in the Threads + * provider don't have to reach into the Instagram module. + */ +export type ThreadsRequestContext = InstagramRequestContext; + +/** True when this deployment can proxy Threads through a logged-in Instagram account. */ +export const hasThreadsAccountProxy = hasInstagramAccountProxy; + +/** + * Threads reuses the Instagram credential pool wholesale: one `sessionid` authenticates both + * surfaces, and only the client fingerprint differs (see {@link threadsProxyHeaders}). + */ +export const resolveThreadsAccounts = resolveInstagramAccounts; + +function cookieHeaderFor(account: InstagramCredentials): string { + const parts = [`sessionid=${account.sessionId}`]; + if (account.userId) parts.push(`ds_user_id=${account.userId}`); + if (account.csrfToken) parts.push(`csrftoken=${account.csrfToken}`); + if (account.mid) parts.push(`mid=${account.mid}`); + if (account.deviceId) parts.push(`ig_did=${account.deviceId}`); + return parts.join('; '); +} + +/** + * Headers for one proxied Threads request. Same session cookies as the Instagram proxy, but with + * the Barcelona app id — `text_feed/…` and `fbsearch/text_app/…` are only served to it. `android` + * accounts get the decompiled app's fingerprint; `web` accounts keep a browser fingerprint with + * `threads.com` as the origin, matching where such a `sessionid` was harvested. + */ +export function threadsProxyHeaders( + account: InstagramCredentials, + options: { referer?: string; acceptHint?: string } = {} +): Record { + const android = account.platform === 'android'; + const headers: Record = { + 'User-Agent': android ? THREADS_ANDROID_USER_AGENT : WEB_USER_AGENT, + 'Accept': '*/*', + 'Accept-Language': 'en-US,en;q=0.9', + 'X-IG-App-ID': THREADS_ANDROID_APP_ID, + 'X-IG-Capabilities': THREADS_ANDROID_CAPABILITIES, + 'X-IG-WWW-Claim': '0', + // `BarcelonaProfileNetworkSource` stamps this on every feed read. + 'X-IG-Accept-Hint': options.acceptHint ?? 'feed', + 'Cookie': cookieHeaderFor(account) + }; + if (android) { + headers['X-IG-Connection-Type'] = 'WIFI'; + if (account.androidDeviceId) { + headers['X-IG-Device-ID'] = account.androidDeviceId; + } + } else { + headers['X-ASBD-ID'] = INSTAGRAM_ASBD_ID; + headers['Origin'] = THREADS_ORIGIN; + headers['Referer'] = options.referer ?? `${THREADS_ORIGIN}/`; + headers['Sec-Fetch-Dest'] = 'empty'; + headers['Sec-Fetch-Mode'] = 'cors'; + headers['Sec-Fetch-Site'] = 'same-origin'; + } + if (account.csrfToken) { + headers['X-CSRFToken'] = account.csrfToken; + } + return headers; +} + +/** HTTP statuses where another account is worth trying: auth/checkpoint/rate limit. */ +const ROTATE_STATUSES = new Set([401, 403, 429]); + +export type ThreadsPrivateApiResult = { + ok: boolean; + /** 0 when no account was available at all (proxy not configured). */ + status: number; + json: unknown | null; + /** Set when a request actually went out, for logging. */ + accountUsed?: string; +}; + +/** + * Calls an `i.instagram.com/api/v1/…` endpoint as the Threads app, rotating accounts on + * auth/rate-limit failures. Returns `{ ok: false, status: 0 }` when no proxy account is configured + * so callers can fall back to their logged-out path (or report 501). + * + * `pathParams` fills the `{user_id}` / `{post_id}` placeholders the app's own route templates use; + * anything left over is sent as a query parameter, which is how the app's request builder behaves. + */ +export async function threadsPrivateApiRequest( + path: string, + ctx: ThreadsRequestContext | undefined, + options: { + pathParams?: Record; + query?: Record; + method?: 'GET' | 'POST'; + body?: string; + referer?: string; + acceptHint?: string; + accounts?: InstagramCredentials[]; + } = {} +): Promise { + const accounts = options.accounts ?? (await resolveThreadsAccounts(ctx)); + if (!accounts.length) { + return { ok: false, status: 0, json: null }; + } + + let resolvedPath = path; + for (const [key, value] of Object.entries(options.pathParams ?? {})) { + resolvedPath = resolvedPath.replace(`{${key}}`, encodeURIComponent(value)); + } + + const { apiRoot } = getInstagramProviderEnv(); + const url = new URL( + `${apiRoot}${THREADS_API_V1}${resolvedPath.startsWith('/') ? resolvedPath : `/${resolvedPath}`}` + ); + for (const [key, value] of Object.entries(options.query ?? {})) { + if (value === undefined || value === null || value === '') continue; + url.searchParams.set( + key, + typeof value === 'boolean' ? (value ? 'true' : 'false') : String(value) + ); + } + + let last: ThreadsPrivateApiResult = { ok: false, status: 500, json: null }; + for (const account of accounts) { + const headers = threadsProxyHeaders(account, { + referer: options.referer, + acceptHint: options.acceptHint + }); + if (options.method === 'POST') { + headers['Content-Type'] = 'application/x-www-form-urlencoded'; + } + let res: Response; + let text: string; + let parsed: unknown; + let parseFailed: boolean; + try { + // Fetch can resolve on headers; keep body read + JSON.parse inside the timeout so a + // stalled body aborts and rotates instead of hanging the request. + const timed = await withTimeout(async signal => { + const response = await fetch(url.toString(), { + method: options.method ?? 'GET', + headers, + body: options.method === 'POST' ? (options.body ?? '') : undefined, + // Never follow redirects with account cookies — a 3xx to another origin would + // leak sessionid. Fetch throws TypeError on redirect, which rotates accounts. + redirect: 'error', + signal + }); + if (!response.ok) { + return { response, text: '', parsed: null, parseFailed: false }; + } + const body = await response.text(); + try { + return { response, text: body, parsed: JSON.parse(body) as unknown, parseFailed: false }; + } catch { + return { response, text: body, parsed: null, parseFailed: true }; + } + }); + res = timed.response; + text = timed.text; + parsed = timed.parsed; + parseFailed = timed.parseFailed; + } catch (err) { + console.error('[threads] private API request threw', { + path: resolvedPath, + account: account.username, + message: err instanceof Error ? err.message : String(err) + }); + last = { ok: false, status: 500, json: null, accountUsed: account.username }; + continue; + } + + if (!res.ok) { + console.error('[threads] private API request failed', { + path: resolvedPath, + account: account.username, + status: res.status + }); + last = { ok: false, status: res.status, json: null, accountUsed: account.username }; + if (ROTATE_STATUSES.has(res.status)) continue; + return last; + } + + const trimmed = text.trim(); + // A logged-out or checkpointed session gets an HTML login page rather than JSON. + if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) { + console.error('[threads] private API returned non-JSON (session likely invalid)', { + path: resolvedPath, + account: account.username + }); + last = { ok: false, status: res.status, json: null, accountUsed: account.username }; + continue; + } + if (parseFailed) { + last = { ok: false, status: res.status, json: null, accountUsed: account.username }; + continue; + } + // The private API answers 200 with `{ status: 'fail' }` for soft failures (spam block, + // feedback_required). Rotate rather than surfacing an empty page as success. + if ( + parsed && + typeof parsed === 'object' && + (parsed as { status?: unknown }).status === 'fail' + ) { + console.error('[threads] private API returned status=fail', { + path: resolvedPath, + account: account.username + }); + last = { ok: false, status: 502, json: parsed, accountUsed: account.username }; + continue; + } + return { ok: true, status: res.status, json: parsed, accountUsed: account.username }; + } + return last; +} diff --git a/packages/atmosphere/src/providers/threads/constants.ts b/packages/atmosphere/src/providers/threads/constants.ts index c8ff2e9c..d6474fd7 100644 --- a/packages/atmosphere/src/providers/threads/constants.ts +++ b/packages/atmosphere/src/providers/threads/constants.ts @@ -67,3 +67,38 @@ export const THREADS_RELAY_DEFAULTS: Record = { __relay_internal__pv__BarcelonaShouldShowFediverseM075Featuresrelayprovider: false, __relay_internal__pv__BarcelonaIsInternalUserrelayprovider: false }; + +/* + * Threads Android app constants, read out of a decompiled `com.instagram.barcelona` build + * (445.0.0.2.83, versionCode 511505005). The app ships the same `InstagramSpecificHeaderServiceLayer` + * as Instagram but stamps its own `X-IG-App-ID`, so a proxied Threads request is an Instagram + * session presenting the Barcelona fingerprint. + */ + +/** Threads (Barcelona) app id. Distinct from Instagram's — `X-IG-App-ID` on every app request. */ +export const THREADS_ANDROID_APP_ID = '3419628305025917'; + +/** `X-IG-Capabilities` the app sends; identical to the Instagram build's. */ +export const THREADS_ANDROID_CAPABILITIES = '3brTv10='; + +export const THREADS_ANDROID_VERSION_NAME = '445.0.0.2.83'; +export const THREADS_ANDROID_VERSION_CODE = '511505005'; + +/** + * Android `User-Agent`, in the app's own + * `Barcelona Android (/; dpi; x; ; ; ; ; ; )` + * shape (built by `AbstractC870503bB.A00` from the `"%s %s Android %s"` / + * `"(%s/%s; %s; %s; %s; %s; %s; %s; %s)"` format strings — the maker slot collapses to one value + * when `Build.MANUFACTURER` equals `Build.BRAND`, which it does on the device modelled here). + * Kept as one fixed, plausible device so a proxied session presents a stable fingerprint. + */ +export const THREADS_ANDROID_USER_AGENT = + `Barcelona ${THREADS_ANDROID_VERSION_NAME} Android (34/14; 420dpi; 1080x2340; ` + + `samsung; SM-S911B; dm1q; qcom; en_US; ${THREADS_ANDROID_VERSION_CODE})`; + +/** Private API prefix shared with Instagram (`i.instagram.com/api/v1/…`). */ +export const THREADS_API_V1 = '/api/v1'; + +/** `search_surface` values the app sends to `fbsearch/text_app/serp/` (`X.03cj`). */ +export const THREADS_SEARCH_SURFACE_TOP = 'ig_text_search_serp_top'; +export const THREADS_SEARCH_SURFACE_RECENT = 'ig_text_search_serp_recent'; diff --git a/packages/atmosphere/src/providers/threads/conversation.ts b/packages/atmosphere/src/providers/threads/conversation.ts index 4d8776db..68862f45 100644 --- a/packages/atmosphere/src/providers/threads/conversation.ts +++ b/packages/atmosphere/src/providers/threads/conversation.ts @@ -1,6 +1,13 @@ import type { SocialConversation } from '../../types/api-status.js'; +import { resolveThreadsAccounts, type ThreadsRequestContext } from './account-proxy.js'; import { fetchThreadsPostPage, fetchThreadsSession, type ThreadsSession } from './client.js'; import { decodeThreadsConversationCursor, encodeThreadsConversationCursor } from './cursors.js'; +import { fetchThreadsPostReplies } from './private-api.js'; +import { + containingThreadChain, + nextTokenFromThreadsFeed, + replyRowsFromThreadsReplies +} from './private-processor.js'; import { buildThreadsTombstone, threadsPostToStatus, @@ -33,6 +40,96 @@ export type ThreadsConversationResult = | { ok: true; data: SocialConversation } | { ok: false; message: string; data?: SocialConversation }; +const conversationError = (code: number): SocialConversation => ({ + code, + status: null, + thread: null, + replies: null, + author: null, + cursor: null +}); + +/** + * Replies through the account proxy (`text_feed/{post_id}/replies/`), which is what the Threads app + * itself calls. Logged-out `threads.com` truncates reply threads hard, so this is the better source + * whenever credentials exist. Returns `null` when the proxy isn't configured or the call failed, so + * the caller can fall back to the logged-out Relay connection. + */ +async function proxiedConversation(params: { + mediaId: string; + shortcode: string; + count: number; + sortOrder: 'top' | 'recent'; + pagingToken: string | null; + ctx: ThreadsRequestContext; +}): Promise { + const accounts = await resolveThreadsAccounts(params.ctx); + if (!accounts.length) return null; + + const sortOrder = params.sortOrder === 'recent' ? 'all' : 'top'; + const res = await fetchThreadsPostReplies(params.mediaId, params.ctx, { + accounts, + sortOrder, + count: params.count, + pagingToken: params.pagingToken, + shortcode: params.shortcode + }); + if (!res.ok) { + return res.status === 404 ? conversationError(404) : null; + } + + const chain = containingThreadChain(res.json); + if (!chain.length) return null; + + const owner = chain[0]?.user as Record | undefined; + const ownerFb = { + id: String(owner?.pk ?? owner?.id ?? ''), + username: String(owner?.username ?? ''), + fullName: typeof owner?.full_name === 'string' ? owner.full_name : undefined, + pic: typeof owner?.profile_pic_url === 'string' ? owner.profile_pic_url : null + }; + + const chainStatuses = chain + .map(post => threadsPostToStatus(post, ownerFb)) + .filter((s): s is NonNullable => Boolean(s)); + if (!chainStatuses.length) { + return { + ...conversationError(404), + status: buildThreadsTombstone('unavailable', { id: params.shortcode }) + }; + } + + const status = chainStatuses[chainStatuses.length - 1]!; + const threadPrefix = chainStatuses.length > 1 ? chainStatuses.slice(0, -1) : []; + + const replies = replyRowsFromThreadsReplies(res.json) + .slice(0, params.count) + .map(row => xdtThreadEdgeToSubstatus({ node: row }, params.shortcode, ownerFb.username)) + .filter((r): r is NonNullable => Boolean(r)); + + const nextToken = nextTokenFromThreadsFeed(res.json); + const bottom = nextToken + ? encodeThreadsConversationCursor({ + v: 1, + postId: params.mediaId, + shortcode: params.shortcode, + sort: params.sortOrder === 'recent' ? 'RECENT' : 'TOP', + after: nextToken, + count: params.count, + src: 'proxy' + }) + : null; + + return { + code: 200, + status, + thread: threadPrefix.length ? threadPrefix : [status], + replies, + author: status.author, + cursor: { bottom } + }; +} + export async function constructThreadsConversation( rawId: string, options: { @@ -40,6 +137,7 @@ export async function constructThreadsConversation( count: number; sortOrder: 'top' | 'recent'; userAgent?: string; + ctx?: ThreadsRequestContext; } ): Promise { const shortcode = normalizeThreadsPostId(rawId); @@ -53,6 +151,30 @@ export async function constructThreadsConversation( const count = Math.min(100, Math.max(1, Math.floor(options.count))); const sortGraphql: 'TOP' | 'RECENT' = options.sortOrder === 'recent' ? 'RECENT' : 'TOP'; + const decodedCursor = options.cursor ? decodeThreadsConversationCursor(options.cursor) : null; + if (options.cursor && (!decodedCursor || decodedCursor.shortcode !== shortcode)) { + return { ok: false, message: 'Invalid cursor', data: conversationError(400) }; + } + + // A proxy cursor can only be replayed against the proxy, and vice versa. + if (decodedCursor?.src !== 'gql') { + const proxied = await proxiedConversation({ + mediaId, + shortcode, + count, + sortOrder: options.sortOrder, + pagingToken: decodedCursor?.after ?? null, + ctx: { ...options.ctx, userAgent: options.ctx?.userAgent ?? options.userAgent } + }); + if (proxied) { + return { ok: true, data: proxied }; + } + if (decodedCursor?.src === 'proxy') { + // The cursor belongs to a source this request can no longer reach. + return { ok: false, message: 'Invalid cursor', data: conversationError(400) }; + } + } + const session: ThreadsSession | null = await fetchThreadsSession(options.userAgent); if (!session) { return { @@ -68,25 +190,7 @@ export async function constructThreadsConversation( }; } - let after: string | null = null; - if (options.cursor) { - const decoded = decodeThreadsConversationCursor(options.cursor); - if (!decoded || decoded.shortcode !== shortcode) { - return { - ok: false, - message: 'Invalid cursor', - data: { - code: 400, - status: null, - thread: null, - replies: null, - author: null, - cursor: null - } - }; - } - after = decoded.after; - } + const after: string | null = decodedCursor?.after ?? null; const res = await fetchThreadsPostPage({ mediaId, @@ -192,7 +296,8 @@ export async function constructThreadsConversation( shortcode, sort: sortGraphql, after: afterForBottom, - count + count, + src: 'gql' }) : null; diff --git a/packages/atmosphere/src/providers/threads/cursors.ts b/packages/atmosphere/src/providers/threads/cursors.ts index 63ee6e1d..5ce097da 100644 --- a/packages/atmosphere/src/providers/threads/cursors.ts +++ b/packages/atmosphere/src/providers/threads/cursors.ts @@ -8,6 +8,12 @@ export type ThreadsConversationCursorV1 = { /** Upstream Relay `end_cursor` for the replies connection (opaque). */ after: string | null; count: number; + /** + * Which reply source minted this cursor. The logged-out Relay connection and the proxied + * `text_feed/{post_id}/replies/` route hand back incompatible tokens, so a cursor can only be + * replayed against the source it came from. + */ + src?: 'gql' | 'proxy'; }; export type ThreadsProfileTimelineCursorV1 = { @@ -62,7 +68,8 @@ export function decodeThreadsConversationCursor(raw: string): ThreadsConversatio shortcode: o.shortcode, sort: o.sort, after: typeof o.after === 'string' || o.after === null ? o.after : null, - count: Math.floor(o.count) + count: Math.floor(o.count), + src: o.src === 'proxy' ? 'proxy' : 'gql' }; } catch { return null; @@ -95,3 +102,102 @@ export function decodeThreadsProfileTimelineCursor( return null; } } + +/** + * Token cursor for the proxy-backed list surfaces (profile tabs, likes, follow lists). They all + * paginate the same way — an opaque upstream token plus the resolved user/media id — so one cursor + * shape covers them, with `k` keeping a cursor from being replayed against a different surface. + */ +export type ThreadsTokenCursorV1 = { + v: 1; + /** Which surface minted this cursor. */ + k: 'threads' | 'replies' | 'reposts' | 'media' | 'followers' | 'following' | 'likes'; + /** Numeric user pk (profile tabs, follow lists) or media pk (likes). */ + id: string; + /** Handle the caller asked for, so a cursor can't be swapped onto another profile. */ + u: string; + /** Upstream `paging_tokens.downwards` / `next_max_id`. */ + t: string | null; + c: number; +}; + +export type ThreadsSearchCursorV1 = { + v: 1; + q: string; + /** `recent` tab vs `top` tab; the two rank differently and their tokens aren't interchangeable. */ + r: boolean; + /** Upstream `page_token`. */ + t: string | null; + /** Upstream `rank_token`, replayed on every page of one search session. */ + rt: string | null; + /** Page ordinal the app sends as `page_num`. */ + p: number; + c: number; +}; + +const validCount = (c: unknown): c is number => + typeof c === 'number' && Number.isFinite(c) && c >= 1 && c <= 100; + +export function encodeThreadsTokenCursor(p: ThreadsTokenCursorV1): string { + return b64urlEncode(JSON.stringify(p)); +} + +export function decodeThreadsTokenCursor( + raw: string, + kind: ThreadsTokenCursorV1['k'] +): ThreadsTokenCursorV1 | null { + const json = b64urlDecode(raw); + if (!json) return null; + try { + const o = JSON.parse(json) as Partial; + if (o.v !== 1 || o.k !== kind) return null; + if (typeof o.id !== 'string' || typeof o.u !== 'string') return null; + if (!validCount(o.c)) return null; + return { + v: 1, + k: kind, + id: o.id, + u: o.u, + t: typeof o.t === 'string' || o.t === null ? o.t : null, + c: Math.floor(o.c) + }; + } catch { + return null; + } +} + +export function encodeThreadsSearchCursor(p: ThreadsSearchCursorV1): string { + return b64urlEncode(JSON.stringify(p)); +} + +export function decodeThreadsSearchCursor(raw: string): ThreadsSearchCursorV1 | null { + const json = b64urlDecode(raw); + if (!json) return null; + try { + const bytes = new Uint8Array(json.length); + for (let i = 0; i < json.length; i++) { + bytes[i] = json.charCodeAt(i); + } + const text = new TextDecoder('utf-8').decode(bytes); + const o = JSON.parse(text) as Partial; + if (o.v !== 1 || typeof o.q !== 'string' || typeof o.r !== 'boolean') return null; + if (!validCount(o.c)) return null; + if (typeof o.p !== 'number' || !Number.isFinite(o.p) || o.p < 0) return null; + return { + v: 1, + q: o.q, + r: o.r, + t: typeof o.t === 'string' || o.t === null ? o.t : null, + rt: typeof o.rt === 'string' || o.rt === null ? o.rt : null, + p: Math.floor(o.p), + c: Math.floor(o.c) + }; + } catch { + return null; + } +} + +/** Handles differ only by case / a leading `@`; a cursor should survive both. */ +export function sameThreadsHandle(a: string, b: string): boolean { + return a.replace(/^@/, '').toLowerCase() === b.replace(/^@/, '').toLowerCase(); +} diff --git a/packages/atmosphere/src/providers/threads/likes.ts b/packages/atmosphere/src/providers/threads/likes.ts new file mode 100644 index 00000000..1d33c373 --- /dev/null +++ b/packages/atmosphere/src/providers/threads/likes.ts @@ -0,0 +1,46 @@ +import type { APIUserListResults } from '../../types/api-schemas.js'; +import { resolveThreadsAccounts, type ThreadsRequestContext } from './account-proxy.js'; +import { fetchThreadsMediaLikers } from './private-api.js'; +import { usersFromThreadsList } from './private-processor.js'; +import { normalizeThreadsPostId, threadsShortcodeToMediaId } from './shortcode.js'; + +const empty = (code: number): APIUserListResults => ({ + code, + results: [], + cursor: { top: null, bottom: null } +}); + +/** + * Accounts that liked a post — Threads' analogue of X's like list. + * + * `media/{pk}/likers/` is logged-in only, so this needs the account proxy and returns a single + * un-paginated page, which is all the endpoint serves. + */ +export async function constructThreadsStatusLikes( + rawId: string, + options: { count: number; ctx?: ThreadsRequestContext } +): Promise { + const count = Math.min(100, Math.max(1, Math.floor(options.count))); + const accounts = await resolveThreadsAccounts(options.ctx); + if (!accounts.length) { + return empty(501); + } + + const shortcode = normalizeThreadsPostId(rawId); + let mediaId: string; + try { + mediaId = threadsShortcodeToMediaId(shortcode); + } catch { + return empty(400); + } + + const res = await fetchThreadsMediaLikers(mediaId, options.ctx, { accounts, shortcode }); + if (!res.ok) { + return empty(res.status === 404 ? 404 : 500); + } + return { + code: 200, + results: usersFromThreadsList(res.json).slice(0, count), + cursor: { top: null, bottom: null } + }; +} diff --git a/packages/atmosphere/src/providers/threads/post.ts b/packages/atmosphere/src/providers/threads/post.ts index 720d883e..a30495ee 100644 --- a/packages/atmosphere/src/providers/threads/post.ts +++ b/packages/atmosphere/src/providers/threads/post.ts @@ -1,5 +1,8 @@ import type { SocialThread } from '../../types/api-status.js'; +import { resolveThreadsAccounts, type ThreadsRequestContext } from './account-proxy.js'; import { fetchThreadsPostPage, fetchThreadsSession } from './client.js'; +import { fetchThreadsSingleThread } from './private-api.js'; +import { containingThreadChain } from './private-processor.js'; import { buildThreadsTombstone, threadsPostToStatus } from './processor.js'; import { normalizeThreadsPostId, threadsShortcodeToMediaId } from './shortcode.js'; @@ -12,9 +15,65 @@ function extractPostPageEdges(json: unknown): { return { edges: edges as { node?: Record; cursor?: string }[] }; } +const notFound = (): SocialThread => ({ code: 404, status: null, thread: null, author: null }); + +/** Owner details to fall back on for posts whose `user` block is trimmed down. */ +function ownerFallbackFrom(chain: Record[]): { + id: string; + username: string; + fullName?: string; + pic: string | null; +} { + const owner = chain[0]?.user as Record | undefined; + return { + id: String(owner?.pk ?? owner?.id ?? ''), + username: String(owner?.username ?? ''), + fullName: typeof owner?.full_name === 'string' ? owner.full_name : undefined, + pic: typeof owner?.profile_pic_url === 'string' ? owner.profile_pic_url : null + }; +} + +/** + * A post's own self-reply chain becomes `thread`, with the last entry as the focal `status` — + * the same convention the logged-out path has always used. + */ +function threadFromChain(chain: Record[], shortcode: string): SocialThread { + const ownerFb = ownerFallbackFrom(chain); + const statuses = chain + .map(post => threadsPostToStatus(post, ownerFb)) + .filter((s): s is NonNullable => Boolean(s)); + + if (!statuses.length) { + return { + code: 404, + status: buildThreadsTombstone('unavailable', { id: shortcode }), + thread: null, + author: null + }; + } + + const status = statuses[statuses.length - 1]!; + const prefix = statuses.length > 1 ? statuses.slice(0, -1) : []; + return { + code: 200, + status, + thread: prefix.length ? prefix : [status], + author: status.author + }; +} + +/** + * Resolve a single Threads post. + * + * With an account proxy configured this reads `text_feed/{post_id}/single_thread/`, which the + * Threads app itself uses and which serves posts logged-out `threads.com` withholds (age-gated + * accounts, limited-audience posts). Otherwise — and whenever that call fails — it falls back to + * the logged-out Relay query, so a deployment without credentials behaves exactly as before. + */ export async function constructThreadsPost( rawId: string, - userAgent: string | undefined + userAgent: string | undefined, + ctx?: ThreadsRequestContext ): Promise { const shortcode = normalizeThreadsPostId(rawId); let mediaId: string; @@ -24,6 +83,21 @@ export async function constructThreadsPost( return { code: 400, status: null, thread: null, author: null }; } + const requestCtx: ThreadsRequestContext = { ...ctx, userAgent: ctx?.userAgent ?? userAgent }; + const accounts = await resolveThreadsAccounts(requestCtx); + if (accounts.length) { + const proxied = await fetchThreadsSingleThread(mediaId, requestCtx, { accounts }); + if (proxied.ok) { + const chain = containingThreadChain(proxied.json); + if (chain.length) { + return threadFromChain(chain, shortcode); + } + } + if (proxied.status === 404) { + return notFound(); + } + } + const session = await fetchThreadsSession(userAgent); if (!session) { return { code: 500, status: null, thread: null, author: null }; @@ -42,53 +116,22 @@ export async function constructThreadsPost( } const { edges } = extractPostPageEdges(res.json); - if (!edges.length) { - return { code: 404, status: null, thread: null, author: null }; - } - const focalNode = edges[0]?.node; if (!focalNode) { - return { code: 404, status: null, thread: null, author: null }; + return notFound(); } - const items = (focalNode.thread_items as unknown[]) ?? []; + const items = focalNode.thread_items; if (!Array.isArray(items) || items.length === 0) { - return { code: 404, status: null, thread: null, author: null }; + return notFound(); } - const firstPost = (items[0] as { post?: Record })?.post; - const owner = firstPost?.user as Record | undefined; - const ownerFb = { - id: String(owner?.pk ?? owner?.id ?? ''), - username: String(owner?.username ?? ''), - fullName: typeof owner?.full_name === 'string' ? owner.full_name : undefined, - pic: typeof owner?.profile_pic_url === 'string' ? owner.profile_pic_url : null - }; - - const chainStatuses = items - .map(it => { - const p = (it as { post?: Record }).post; - return p ? threadsPostToStatus(p, ownerFb) : null; - }) - .filter((s): s is NonNullable => Boolean(s)); - - if (!chainStatuses.length) { - return { - code: 404, - status: buildThreadsTombstone('unavailable', { id: shortcode }), - thread: null, - author: null - }; + const chain = items + .map(it => (it as { post?: Record })?.post) + .filter((p): p is Record => Boolean(p)); + if (!chain.length) { + return notFound(); } - const status = chainStatuses[chainStatuses.length - 1]!; - const threadPrefix = - chainStatuses.length > 1 ? chainStatuses.slice(0, -1) : ([] as typeof chainStatuses); - - return { - code: 200, - status, - thread: threadPrefix.length ? threadPrefix : [status], - author: status.author - }; + return threadFromChain(chain, shortcode); } diff --git a/packages/atmosphere/src/providers/threads/private-api.ts b/packages/atmosphere/src/providers/threads/private-api.ts new file mode 100644 index 00000000..af212104 --- /dev/null +++ b/packages/atmosphere/src/providers/threads/private-api.ts @@ -0,0 +1,262 @@ +import { + threadsPrivateApiRequest, + type ThreadsPrivateApiResult, + type ThreadsRequestContext +} from './account-proxy.js'; +import type { InstagramCredentials } from '../../types/proxy-credentials.js'; +import { + THREADS_ORIGIN, + THREADS_SEARCH_SURFACE_RECENT, + THREADS_SEARCH_SURFACE_TOP +} from './constants.js'; + +/* + * Endpoint paths and parameter names below are the ones the Threads Android app itself calls + * (`com.instagram.barcelona` 445.0.0.2.83). The app keeps the `{user_id}` / `{post_id}` templates + * literally — `ProfileFeedDataSource`, `SerpFeedPagingSource`, `SearchTopicsRepository` and + * `LikesListRemoteDataSource` are the classes these were read from — so they are reproduced + * verbatim here and filled in by `threadsPrivateApiRequest`'s `pathParams`. + * + * Everything runs against `i.instagram.com/api/v1/…` with a logged-in Instagram session; only the + * `X-IG-App-ID` distinguishes a Threads request from an Instagram one. + */ + +export type ThreadsApiOptions = { + /** Pre-resolved accounts, so a multi-call flow reuses one shuffle. */ + accounts?: InstagramCredentials[]; +}; + +const profileReferer = (username: string) => `${THREADS_ORIGIN}/@${encodeURIComponent(username)}`; + +/** Which profile tab to read. The app models these as four sibling routes, not one parameter. */ +export type ThreadsProfileTab = 'threads' | 'replies' | 'reposts' | 'media'; + +const PROFILE_TAB_PATHS: Record = { + threads: 'text_feed/{user_id}/profile/', + replies: 'text_feed/{user_id}/profile/replies/', + reposts: 'text_feed/{user_id}/profile/reposts/', + media: 'text_feed/{user_id}/profile/media/' +}; + +/** + * `text_feed/{user_id}/profile/…` — one page of a profile tab. + * + * The app also sends `exclude_reposts` on the main tab so its dedicated Reposts tab doesn't + * duplicate rows; FxEmbed leaves it off so `/statuses` matches what the logged-out timeline serves. + */ +export function fetchThreadsProfileFeed( + userId: string, + tab: ThreadsProfileTab, + ctx: ThreadsRequestContext | undefined, + options: ThreadsApiOptions & { + maxId?: string | null; + count?: number; + username?: string; + } = {} +): Promise { + return threadsPrivateApiRequest(PROFILE_TAB_PATHS[tab], ctx, { + pathParams: { user_id: userId }, + query: { + user_id: userId, + count: options.count, + max_id: options.maxId ?? undefined, + is_app_start: false + }, + referer: options.username ? profileReferer(options.username) : undefined, + accounts: options.accounts + }); +} + +/** `text_feed/{post_id}/replies/` — replies to a post. `sortOrder` is the app's `top` / `all`. */ +export function fetchThreadsPostReplies( + postId: string, + ctx: ThreadsRequestContext | undefined, + options: ThreadsApiOptions & { + pagingToken?: string | null; + count?: number; + sortOrder?: 'top' | 'all'; + shortcode?: string; + username?: string; + } = {} +): Promise { + return threadsPrivateApiRequest('text_feed/{post_id}/replies/', ctx, { + pathParams: { post_id: postId }, + query: { + post_id: postId, + sort_order: options.sortOrder ?? 'top', + count: options.count, + paging_token: options.pagingToken ?? undefined, + check_for_unavailable_replies: true + }, + referer: + options.username && options.shortcode + ? `${profileReferer(options.username)}/post/${encodeURIComponent(options.shortcode)}` + : undefined, + accounts: options.accounts + }); +} + +/** `text_feed/{post_id}/single_thread/` — the focal post and its own thread chain, no replies. */ +export function fetchThreadsSingleThread( + postId: string, + ctx: ThreadsRequestContext | undefined, + options: ThreadsApiOptions = {} +): Promise { + return threadsPrivateApiRequest('text_feed/{post_id}/single_thread/', ctx, { + pathParams: { post_id: postId }, + query: { post_id: postId }, + accounts: options.accounts + }); +} + +/** + * `fbsearch/text_app/serp/` — post search. + * + * `recent` is the app's `0` / `1` toggle and has to agree with `search_surface`; passing one + * without the other returns the other tab's ranking. + */ +export function fetchThreadsSearchSerp( + query: string, + ctx: ThreadsRequestContext | undefined, + options: ThreadsApiOptions & { + recent?: boolean; + pageToken?: string | null; + pageNum?: number | null; + rankToken?: string | null; + tagId?: string | null; + } = {} +): Promise { + const recent = options.recent === true; + return threadsPrivateApiRequest('fbsearch/text_app/serp/', ctx, { + query: { + query, + search_surface: recent ? THREADS_SEARCH_SURFACE_RECENT : THREADS_SEARCH_SURFACE_TOP, + recent: recent ? '1' : '0', + is_from_pull_to_refresh: '0', + tag_id: options.tagId ?? undefined, + page_token: options.pageToken ?? undefined, + page_num: options.pageNum ?? undefined, + rank_token: options.rankToken ?? undefined + }, + referer: `${THREADS_ORIGIN}/search?q=${encodeURIComponent(query)}`, + accounts: options.accounts + }); +} + +/** `fbsearch/text_app/trends/` — the Threads trending topic list. */ +export function fetchThreadsTrends( + ctx: ThreadsRequestContext | undefined, + options: ThreadsApiOptions & { first?: number } = {} +): Promise { + return threadsPrivateApiRequest('fbsearch/text_app/trends/', ctx, { + query: { + first: options.first ?? undefined, + serp_prefetch: false, + should_fetch_related_communities: false + }, + accounts: options.accounts + }); +} + +/** `text_feed/{user_id}/liked_posts/` — posts an account liked (only its own, session-scoped). */ +export function fetchThreadsLikedPosts( + userId: string, + ctx: ThreadsRequestContext | undefined, + options: ThreadsApiOptions & { maxId?: string | null } = {} +): Promise { + return threadsPrivateApiRequest('text_feed/{user_id}/liked_posts/', ctx, { + pathParams: { user_id: userId }, + query: { user_id: userId, max_id: options.maxId ?? undefined }, + accounts: options.accounts + }); +} + +/** `media/{pk}/likers/` — accounts that liked a post; the Threads app shares Instagram's route. */ +export function fetchThreadsMediaLikers( + mediaId: string, + ctx: ThreadsRequestContext | undefined, + options: ThreadsApiOptions & { shortcode?: string; username?: string } = {} +): Promise { + return threadsPrivateApiRequest(`media/${encodeURIComponent(mediaId)}/likers/`, ctx, { + referer: + options.username && options.shortcode + ? `${profileReferer(options.username)}/post/${encodeURIComponent(options.shortcode)}` + : undefined, + acceptHint: 'user_list', + accounts: options.accounts + }); +} + +/** `friendships/{pk}/followers/` — follower list page (shared Instagram graph). */ +export function fetchThreadsFollowers( + userId: string, + ctx: ThreadsRequestContext | undefined, + options: ThreadsApiOptions & { maxId?: string | null; count?: number; username?: string } = {} +): Promise { + return threadsPrivateApiRequest(`friendships/${encodeURIComponent(userId)}/followers/`, ctx, { + query: { + count: options.count, + max_id: options.maxId ?? undefined, + search_surface: 'follow_list_page' + }, + referer: options.username ? `${profileReferer(options.username)}/followers` : undefined, + acceptHint: 'user_list', + accounts: options.accounts + }); +} + +/** `friendships/{pk}/following/` — following list page (shared Instagram graph). */ +export function fetchThreadsFollowing( + userId: string, + ctx: ThreadsRequestContext | undefined, + options: ThreadsApiOptions & { maxId?: string | null; count?: number; username?: string } = {} +): Promise { + return threadsPrivateApiRequest(`friendships/${encodeURIComponent(userId)}/following/`, ctx, { + query: { + count: options.count, + max_id: options.maxId ?? undefined, + search_surface: 'follow_list_page' + }, + referer: options.username ? `${profileReferer(options.username)}/following` : undefined, + acceptHint: 'user_list', + accounts: options.accounts + }); +} + +/** `users/search/` — user search, filtered to Threads-active accounts by the caller. */ +export function fetchThreadsUserSearch( + query: string, + ctx: ThreadsRequestContext | undefined, + options: ThreadsApiOptions & { count?: number } = {} +): Promise { + return threadsPrivateApiRequest('users/search/', ctx, { + query: { q: query, count: options.count, search_surface: 'user_search_page' }, + acceptHint: 'user_list', + accounts: options.accounts + }); +} + +/** `users/{username}/usernameinfo/` — handle → numeric pk, shared with Instagram. */ +export function fetchThreadsUserByUsername( + username: string, + ctx: ThreadsRequestContext | undefined, + options: ThreadsApiOptions = {} +): Promise { + return threadsPrivateApiRequest(`users/${encodeURIComponent(username)}/usernameinfo/`, ctx, { + referer: profileReferer(username), + accounts: options.accounts + }); +} + +/** `fbsearch/text_app/keyword/search/` — keyword suggestions behind the search box. */ +export function fetchThreadsKeywordSearch( + query: string, + ctx: ThreadsRequestContext | undefined, + options: ThreadsApiOptions = {} +): Promise { + return threadsPrivateApiRequest('fbsearch/text_app/keyword/search/', ctx, { + query: { query }, + referer: `${THREADS_ORIGIN}/search?q=${encodeURIComponent(query)}`, + accounts: options.accounts + }); +} diff --git a/packages/atmosphere/src/providers/threads/private-processor.ts b/packages/atmosphere/src/providers/threads/private-processor.ts new file mode 100644 index 00000000..881efde4 --- /dev/null +++ b/packages/atmosphere/src/providers/threads/private-processor.ts @@ -0,0 +1,230 @@ +import type { APIThreadsStatus, APIUser } from '../../types/api-schemas.js'; +import { threadsPostToStatus } from './processor.js'; + +/** + * Normalizers for the Threads slice of `i.instagram.com/api/v1`. + * + * The `post` records these endpoints return are the same objects the logged-out `threads.com` + * GraphQL wraps (`xdt_api__v1__text_feed__…` is a thin proxy over exactly these routes), so + * {@link threadsPostToStatus} already understands them. Only the envelope — how rows are nested and + * how pages are chained — differs, and that is what lives here. + */ + +const isRecord = (v: unknown): v is Record => + Boolean(v) && typeof v === 'object' && !Array.isArray(v); + +/** + * Rows arrive under a handful of keys depending on the surface: `items` on profile tabs, + * `reply_threads` on a post's replies, `media` on search. Each row is either a thread + * (`{ thread_items: [{ post }] }`) or a bare post. + */ +function feedRows(json: unknown): Record[] { + if (!isRecord(json)) return []; + for (const key of ['items', 'reply_threads', 'media', 'results', 'threads']) { + const value = json[key]; + if (Array.isArray(value)) { + return value.filter(isRecord); + } + } + return []; +} + +/** + * The post a row should be represented by. A thread row carries the whole chain in `thread_items`; + * the last item is the one the app renders as the row (earlier items are the "show more" context), + * matching how the logged-out timeline path already picks its status. + */ +function postFromRow(row: Record): Record | null { + const items = row.thread_items; + if (Array.isArray(items) && items.length > 0) { + for (let i = items.length - 1; i >= 0; i--) { + const item = items[i]; + if (isRecord(item) && isRecord(item.post)) return item.post; + } + return null; + } + if (isRecord(row.post)) return row.post; + if (isRecord(row.media)) return row.media; + // Search rows can be bare media objects. + return typeof row.code === 'string' || typeof row.pk === 'string' || typeof row.pk === 'number' + ? row + : null; +} + +/** Every `post` in a thread row, oldest first — the self-reply chain above a focal post. */ +export function threadChainFromRow(row: Record): Record[] { + const items = row.thread_items; + if (!Array.isArray(items)) { + const single = postFromRow(row); + return single ? [single] : []; + } + const out: Record[] = []; + for (const item of items) { + if (isRecord(item) && isRecord(item.post)) out.push(item.post); + } + return out; +} + +/** Map a private-API Threads feed page to statuses, dropping rows that can't be rendered. */ +export function statusesFromThreadsFeed( + json: unknown, + ownerFallback: { id: string; username: string; fullName?: string; pic?: string | null } +): APIThreadsStatus[] { + const out: APIThreadsStatus[] = []; + for (const row of feedRows(json)) { + const post = postFromRow(row); + if (!post) continue; + const status = threadsPostToStatus(post, ownerFallback); + if (status) out.push(status); + } + return out; +} + +/** Thread rows with their chains intact, for surfaces that render context above the row. */ +export function threadRowsFromThreadsFeed(json: unknown): Record[][] { + return feedRows(json) + .map(threadChainFromRow) + .filter(chain => chain.length > 0); +} + +/** + * Next-page token for a Threads feed. + * + * Profile tabs and reply lists paginate with `paging_tokens.downwards`; the shared Instagram feed + * routes still answer with `next_max_id`; search uses `page_token`. `has_more: false` (or + * `more_available: false`) ends the walk even when a token is echoed back. + */ +export function nextTokenFromThreadsFeed(json: unknown): string | null { + if (!isRecord(json)) return null; + if (json.has_more === false) return null; + if (json.more_available === false) return null; + const pagingTokens = json.paging_tokens; + if (isRecord(pagingTokens)) { + const down = pagingTokens.downwards; + if (typeof down === 'string' && down.length > 0) return down; + } + for (const key of ['next_max_id', 'page_token', 'next_page_token', 'paging_token']) { + const raw = json[key]; + if (typeof raw === 'string' && raw.length > 0) return raw; + if (typeof raw === 'number' && Number.isFinite(raw)) return String(raw); + } + return null; +} + +/** `rank_token` echoed by search, which the app replays on every subsequent page. */ +export function rankTokenFromThreadsSearch(json: unknown): string | null { + if (!isRecord(json)) return null; + const raw = json.rank_token; + return typeof raw === 'string' && raw.length > 0 ? raw : null; +} + +/** + * Users out of a Threads-flavoured list. These records are Instagram user records with the extra + * `is_active_on_text_post_app` / `has_onboarded_to_text_post_app` flags, so profile URLs point at + * `threads.com` rather than `instagram.com`. + */ +export function usersFromThreadsList( + json: unknown, + options: { threadsOnly?: boolean } = {} +): APIUser[] { + if (!isRecord(json)) return []; + const users = json.users; + if (!Array.isArray(users)) return []; + const out: APIUser[] = []; + for (const raw of users) { + if (!isRecord(raw)) continue; + if (options.threadsOnly && !isThreadsUser(raw)) continue; + const mapped = threadsUserFromPrivateRecord(raw); + if (mapped) out.push(mapped); + } + return out; +} + +/** Whether an Instagram user record belongs to someone who actually uses Threads. */ +export function isThreadsUser(rec: Record): boolean { + return Boolean(rec.is_active_on_text_post_app) || Boolean(rec.has_onboarded_to_text_post_app); +} + +const num = (...vals: unknown[]): number => { + for (const v of vals) { + if (typeof v === 'number' && Number.isFinite(v)) return Math.trunc(v); + if (typeof v === 'string' && v.trim() !== '') { + const n = Number(v); + if (Number.isFinite(n)) return Math.trunc(n); + } + } + return 0; +}; + +const str = (...vals: unknown[]): string => { + for (const v of vals) { + if (typeof v === 'string' && v.length > 0) return v; + } + return ''; +}; + +/** Map one private-API user record to an {@link APIUser} pointing at Threads. */ +export function threadsUserFromPrivateRecord(rec: Record): APIUser | null { + const id = str(rec.pk_id, typeof rec.pk === 'number' ? String(rec.pk) : rec.pk, rec.id); + const username = str(rec.username); + if (!id || !username) return null; + const bio = typeof rec.biography === 'string' ? rec.biography : ''; + const isVerified = Boolean(rec.is_verified); + const externalUrl = str(rec.external_url); + const hdProfilePic = (rec.hd_profile_pic_url_info as { url?: string } | undefined)?.url; + return { + type: 'profile', + id, + name: str(rec.full_name) || username, + screen_name: username, + avatar_url: str(hdProfilePic, rec.profile_pic_url, rec.profile_pic_url_hd) || null, + banner_url: null, + description: bio, + raw_description: { text: bio, facets: [] }, + location: '', + url: `https://www.threads.com/@${encodeURIComponent(username)}/`, + // Threads privacy is its own flag; `is_private` is the Instagram account's. + protected: Boolean(rec.text_post_app_is_private ?? rec.is_private), + followers: num(rec.follower_count), + following: num(rec.following_count), + statuses: 0, + media_count: 0, + likes: 0, + joined: '1970-01-01T00:00:00.000Z', + website: externalUrl + ? { url: externalUrl, display_url: externalUrl.replace(/^https?:\/\//, '') } + : null, + profile_embed: true, + verification: { + verified: isVerified, + type: isVerified ? 'individual' : null + } + }; +} + +/** + * The focal post's own chain out of a `single_thread` / `replies` response. + * + * Both routes wrap the post being viewed in `containing_thread`; older payloads put it first in + * `items` instead, so that is the fallback. + */ +export function containingThreadChain(json: unknown): Record[] { + if (!isRecord(json)) return []; + const containing = json.containing_thread; + if (isRecord(containing)) { + const chain = threadChainFromRow(containing); + if (chain.length) return chain; + } + const rows = feedRows(json); + return rows.length ? threadChainFromRow(rows[0]!) : []; +} + +/** Reply thread rows out of a `text_feed/{post_id}/replies/` response, newest page first. */ +export function replyRowsFromThreadsReplies(json: unknown): Record[] { + if (!isRecord(json)) return []; + const replies = json.reply_threads; + if (Array.isArray(replies)) return replies.filter(isRecord); + // Some payloads fold the focal post into `items` and list replies after it. + const rows = feedRows(json); + return rows.length > 1 ? rows.slice(1) : []; +} diff --git a/packages/atmosphere/src/providers/threads/profile-tabs.ts b/packages/atmosphere/src/providers/threads/profile-tabs.ts new file mode 100644 index 00000000..d5563b0a --- /dev/null +++ b/packages/atmosphere/src/providers/threads/profile-tabs.ts @@ -0,0 +1,79 @@ +import type { APISearchResultsThreads } from '../../types/api-schemas.js'; +import { resolveThreadsAccounts, type ThreadsRequestContext } from './account-proxy.js'; +import { + decodeThreadsTokenCursor, + encodeThreadsTokenCursor, + sameThreadsHandle +} from './cursors.js'; +import { fetchThreadsProfileFeed, type ThreadsProfileTab } from './private-api.js'; +import { nextTokenFromThreadsFeed, statusesFromThreadsFeed } from './private-processor.js'; +import { resolveThreadsUser } from './resolve-user.js'; + +export type { ThreadsProfileTab }; + +const empty = (code: number): APISearchResultsThreads => ({ + code, + results: [], + cursor: { top: null, bottom: null } +}); + +/** + * A profile's Replies / Reposts / Media tab. + * + * Logged-out `threads.com` only serves the main Threads tab, so these need the account proxy; + * without one they report 501 rather than pretending the tab is empty. The main tab keeps its + * logged-out path — see `constructThreadsProfileStatuses` in `profile.ts`. + */ +export async function constructThreadsProfileTab( + username: string, + tab: ThreadsProfileTab, + options: { count: number; cursor: string | null; ctx?: ThreadsRequestContext } +): Promise { + const count = Math.min(100, Math.max(1, Math.floor(options.count))); + const accounts = await resolveThreadsAccounts(options.ctx); + if (!accounts.length) { + return empty(501); + } + + const handle = username.replace(/^@/, ''); + let userId: string; + let maxId: string | null = null; + + if (options.cursor) { + const decoded = decodeThreadsTokenCursor(options.cursor, tab); + if (!decoded || !sameThreadsHandle(decoded.u, handle)) { + return empty(400); + } + userId = decoded.id; + maxId = decoded.t; + } else { + const resolved = await resolveThreadsUser(handle, options.ctx, { accounts }); + if (resolved.code !== 200 || !resolved.user) { + return empty(resolved.code); + } + userId = resolved.user.id; + } + + const res = await fetchThreadsProfileFeed(userId, tab, options.ctx, { + accounts, + maxId, + count, + username: handle + }); + if (!res.ok) { + return empty(res.status === 404 ? 404 : 500); + } + + const results = statusesFromThreadsFeed(res.json, { + id: userId, + username: handle, + pic: null + }).slice(0, count); + + const nextToken = nextTokenFromThreadsFeed(res.json); + const bottom = nextToken + ? encodeThreadsTokenCursor({ v: 1, k: tab, id: userId, u: handle, t: nextToken, c: count }) + : null; + + return { code: 200, results, cursor: { top: null, bottom } }; +} diff --git a/packages/atmosphere/src/providers/threads/profile.ts b/packages/atmosphere/src/providers/threads/profile.ts index 2cd1f2b4..97fa31a9 100644 --- a/packages/atmosphere/src/providers/threads/profile.ts +++ b/packages/atmosphere/src/providers/threads/profile.ts @@ -1,4 +1,5 @@ import type { APISearchResultsThreads, UserAPIResponse } from '../../types/api-schemas.js'; +import { resolveThreadsAccounts, type ThreadsRequestContext } from './account-proxy.js'; import { fetchThreadsProfilePage, fetchThreadsProfileTimeline, @@ -7,9 +8,12 @@ import { } from './client.js'; import { decodeThreadsProfileTimelineCursor, + decodeThreadsTokenCursor, encodeThreadsProfileTimelineCursor } from './cursors.js'; +import { constructThreadsProfileTab } from './profile-tabs.js'; import { threadsPostToStatus, userFromThreadsProfilePayload } from './processor.js'; +import { resolveThreadsUser } from './resolve-user.js'; function userIdFromHovercard(json: unknown): string | null { const u = (json as { data?: { user?: Record } })?.data?.user; @@ -94,10 +98,28 @@ function profileTimelinePage( return { results, nextAfter }; } +/** + * Resolve a profile. With an account proxy configured this reads `users/{username}/usernameinfo/`, + * which also covers accounts logged-out `threads.com` will not show; otherwise it falls back to the + * logged-out hovercard + profile-page pair. + */ export async function constructThreadsProfile( username: string, - userAgent: string | undefined + userAgent: string | undefined, + ctx?: ThreadsRequestContext ): Promise { + const requestCtx: ThreadsRequestContext = { ...ctx, userAgent: ctx?.userAgent ?? userAgent }; + const accounts = await resolveThreadsAccounts(requestCtx); + if (accounts.length) { + const resolved = await resolveThreadsUser(username, requestCtx, { accounts }); + if (resolved.code === 200 && resolved.user) { + return { code: 200, message: 'OK', user: resolved.user }; + } + if (resolved.code === 404) { + return { code: 404, message: 'User not found' }; + } + } + const session = await fetchThreadsSession(userAgent); if (!session) { return { code: 500, message: 'Threads session failed' }; @@ -136,11 +158,43 @@ export async function constructThreadsProfile( return { code: 200, message: 'OK', user }; } +/** + * A profile's main Threads tab. + * + * Prefers the account proxy, which serves the same rows the app sees; falls back to the logged-out + * Relay connection. The two mint different cursors, so a page walk stays on whichever source + * started it — a proxy cursor decodes only as a token cursor, and vice versa. + */ export async function constructThreadsProfileStatuses( username: string, - options: { count: number; cursor: string | null; userAgent?: string } + options: { count: number; cursor: string | null; userAgent?: string; ctx?: ThreadsRequestContext } ): Promise { const count = Math.min(100, Math.max(1, Math.floor(options.count))); + const requestCtx: ThreadsRequestContext = { + ...options.ctx, + userAgent: options.ctx?.userAgent ?? options.userAgent + }; + const isProxyCursor = + options.cursor != null && decodeThreadsTokenCursor(options.cursor, 'threads') != null; + if (options.cursor == null || isProxyCursor) { + const proxied = await constructThreadsProfileTab(username, 'threads', { + count, + cursor: options.cursor, + ctx: requestCtx + }); + if (isProxyCursor) { + // A proxy cursor means nothing to the logged-out connection, so this page walk is over + // either way — 501 (proxy since removed) becomes a plain bad cursor. + return proxied.code === 501 + ? { code: 400, results: [], cursor: { top: null, bottom: null } } + : proxied; + } + // Fresh request: fall through to the logged-out path only when the proxy couldn't answer. + if (proxied.code !== 501 && proxied.code !== 500) { + return proxied; + } + } + const session = await fetchThreadsSession(options.userAgent); if (!session) { return { code: 500, results: [], cursor: { top: null, bottom: null } }; diff --git a/packages/atmosphere/src/providers/threads/relationships.ts b/packages/atmosphere/src/providers/threads/relationships.ts new file mode 100644 index 00000000..ab02bdd9 --- /dev/null +++ b/packages/atmosphere/src/providers/threads/relationships.ts @@ -0,0 +1,69 @@ +import type { APIProfileRelationshipList } from '../../types/api-schemas.js'; +import { resolveThreadsAccounts, type ThreadsRequestContext } from './account-proxy.js'; +import { + decodeThreadsTokenCursor, + encodeThreadsTokenCursor, + sameThreadsHandle +} from './cursors.js'; +import { fetchThreadsFollowers, fetchThreadsFollowing } from './private-api.js'; +import { nextTokenFromThreadsFeed, usersFromThreadsList } from './private-processor.js'; +import { resolveThreadsUser } from './resolve-user.js'; + +export type ThreadsRelationshipKind = 'followers' | 'following'; + +const empty = (code: number): APIProfileRelationshipList => ({ + code, + results: [], + cursor: { top: null, bottom: null } +}); + +/** + * Follower / following lists. Threads shares Instagram's social graph, so these come from + * `friendships/{pk}/…` — a logged-in surface, hence the account proxy and a 501 without one. + * + * Results are *not* filtered to Threads-active accounts: the counts a Threads profile shows are + * the Instagram follower counts, so filtering would make the list disagree with the profile. + */ +export async function constructThreadsRelationshipList( + username: string, + kind: ThreadsRelationshipKind, + options: { count: number; cursor: string | null; ctx?: ThreadsRequestContext } +): Promise { + const count = Math.min(100, Math.max(1, Math.floor(options.count))); + const accounts = await resolveThreadsAccounts(options.ctx); + if (!accounts.length) { + return empty(501); + } + + const handle = username.replace(/^@/, ''); + let userId: string; + let maxId: string | null = null; + + if (options.cursor) { + const decoded = decodeThreadsTokenCursor(options.cursor, kind); + if (!decoded || !sameThreadsHandle(decoded.u, handle)) { + return empty(400); + } + userId = decoded.id; + maxId = decoded.t; + } else { + const resolved = await resolveThreadsUser(handle, options.ctx, { accounts }); + if (resolved.code !== 200 || !resolved.user) { + return empty(resolved.code); + } + userId = resolved.user.id; + } + + const fetcher = kind === 'followers' ? fetchThreadsFollowers : fetchThreadsFollowing; + const res = await fetcher(userId, options.ctx, { accounts, count, maxId, username: handle }); + if (!res.ok) { + return empty(res.status === 404 ? 404 : 500); + } + + const results = usersFromThreadsList(res.json).slice(0, count); + const nextToken = nextTokenFromThreadsFeed(res.json); + const bottom = nextToken + ? encodeThreadsTokenCursor({ v: 1, k: kind, id: userId, u: handle, t: nextToken, c: count }) + : null; + return { code: 200, results, cursor: { top: null, bottom } }; +} diff --git a/packages/atmosphere/src/providers/threads/resolve-user.ts b/packages/atmosphere/src/providers/threads/resolve-user.ts new file mode 100644 index 00000000..96b705ea --- /dev/null +++ b/packages/atmosphere/src/providers/threads/resolve-user.ts @@ -0,0 +1,57 @@ +import type { APIUser } from '../../types/api-schemas.js'; +import type { InstagramCredentials } from '../../types/proxy-credentials.js'; +import { resolveThreadsAccounts, type ThreadsRequestContext } from './account-proxy.js'; +import { fetchThreadsSession, fetchThreadsUserByUsername as fetchWebUser } from './client.js'; +import { fetchThreadsUserByUsername } from './private-api.js'; +import { threadsUserFromPrivateRecord } from './private-processor.js'; +import { userFromThreadsProfilePayload } from './processor.js'; + +export type ResolvedThreadsUser = { + code: 200 | 404 | 500; + user: APIUser | null; + /** Accounts resolved along the way, so callers can reuse one shuffle across follow-up calls. */ + accounts: InstagramCredentials[]; +}; + +/** + * Resolve a handle to a Threads profile. Prefers the account proxy + * (`users/{username}/usernameinfo/`, which carries the Threads-specific privacy flags), and falls + * back to the logged-out `threads.com` hovercard query. + */ +export async function resolveThreadsUser( + username: string, + ctx: ThreadsRequestContext | undefined, + options: { accounts?: InstagramCredentials[] } = {} +): Promise { + const handle = username.replace(/^@/, ''); + const accounts = options.accounts ?? (await resolveThreadsAccounts(ctx)); + + if (accounts.length) { + const res = await fetchThreadsUserByUsername(handle, ctx, { accounts }); + if (res.ok && res.json && typeof res.json === 'object') { + const rec = (res.json as { user?: unknown }).user; + if (rec && typeof rec === 'object') { + const user = threadsUserFromPrivateRecord(rec as Record); + if (user) return { code: 200, user, accounts }; + } + } + if (res.status === 404) { + return { code: 404, user: null, accounts }; + } + } + + const session = await fetchThreadsSession(ctx?.userAgent); + if (!session) { + return { code: 500, user: null, accounts }; + } + const hover = await fetchWebUser({ username: handle, session, userAgent: ctx?.userAgent }); + if (!hover.ok || hover.json == null) { + return { code: hover.status === 404 ? 404 : 500, user: null, accounts }; + } + const rec = (hover.json as { data?: { user?: unknown } })?.data?.user; + if (!rec || typeof rec !== 'object') { + return { code: 404, user: null, accounts }; + } + const user = userFromThreadsProfilePayload(rec as Record); + return user ? { code: 200, user, accounts } : { code: 404, user: null, accounts }; +} diff --git a/packages/atmosphere/src/providers/threads/search.ts b/packages/atmosphere/src/providers/threads/search.ts new file mode 100644 index 00000000..eaca2f6a --- /dev/null +++ b/packages/atmosphere/src/providers/threads/search.ts @@ -0,0 +1,228 @@ +import type { + APISearchResultsThreads, + APITypeaheadResponse, + APITypeaheadTopic, + APIUserListResults +} from '../../types/api-schemas.js'; +import { resolveThreadsAccounts, type ThreadsRequestContext } from './account-proxy.js'; +import { decodeThreadsSearchCursor, encodeThreadsSearchCursor } from './cursors.js'; +import { + fetchThreadsKeywordSearch, + fetchThreadsSearchSerp, + fetchThreadsUserSearch +} from './private-api.js'; +import { + nextTokenFromThreadsFeed, + rankTokenFromThreadsSearch, + statusesFromThreadsFeed, + usersFromThreadsList +} from './private-processor.js'; + +const emptySearch = (code: number): APISearchResultsThreads => ({ + code, + results: [], + cursor: { top: null, bottom: null } +}); + +const emptyUserList = (code: number): APIUserListResults => ({ + code, + results: [], + cursor: { top: null, bottom: null } +}); + +/** + * Post search (`fbsearch/text_app/serp/`) — the closest Threads analogue to X's `/2/search`. + * + * Logged-out `threads.com` puts search behind a login wall, so this needs the account proxy; + * without one it reports 501. `sortOrder` maps onto the app's two SERP tabs, which rank + * differently and mint incompatible page tokens, so it is pinned into the cursor. + */ +export async function constructThreadsSearch( + query: string, + options: { + count: number; + cursor: string | null; + sortOrder?: 'top' | 'recent'; + ctx?: ThreadsRequestContext; + } +): Promise { + const count = Math.min(100, Math.max(1, Math.floor(options.count))); + const q = query.trim(); + if (!q) { + return emptySearch(400); + } + const accounts = await resolveThreadsAccounts(options.ctx); + if (!accounts.length) { + return emptySearch(501); + } + + let recent = options.sortOrder === 'recent'; + let pageToken: string | null = null; + let rankToken: string | null = null; + let pageNum = 0; + let searchQuery = q; + + if (options.cursor) { + const decoded = decodeThreadsSearchCursor(options.cursor); + if (!decoded || decoded.q !== q) { + return emptySearch(400); + } + recent = decoded.r; + pageToken = decoded.t; + rankToken = decoded.rt; + pageNum = decoded.p; + searchQuery = decoded.q; + } + + const res = await fetchThreadsSearchSerp(searchQuery, options.ctx, { + accounts, + recent, + pageToken, + rankToken, + pageNum: pageNum > 0 ? pageNum : undefined + }); + if (!res.ok) { + return emptySearch(res.status === 404 ? 404 : 500); + } + + const results = statusesFromThreadsFeed(res.json, { id: '', username: '', pic: null }).slice( + 0, + count + ); + + const nextToken = nextTokenFromThreadsFeed(res.json); + const bottom = nextToken + ? encodeThreadsSearchCursor({ + v: 1, + q: searchQuery, + r: recent, + t: nextToken, + rt: rankTokenFromThreadsSearch(res.json) ?? rankToken, + p: pageNum + 1, + c: count + }) + : null; + + return { code: 200, results, cursor: { top: null, bottom } }; +} + +/** + * User search. `users/search/` is the Instagram-wide index, so results are filtered to accounts + * that are actually on Threads — an Instagram-only account has no Threads profile to link to. + * + * The endpoint returns one ranked page and no cursor, so `cursor.bottom` is always null. + */ +export async function constructThreadsUserSearch( + query: string, + options: { count: number; ctx?: ThreadsRequestContext } +): Promise { + const count = Math.min(50, Math.max(1, Math.floor(options.count))); + const q = query.trim(); + if (!q) { + return emptyUserList(400); + } + const accounts = await resolveThreadsAccounts(options.ctx); + if (!accounts.length) { + return emptyUserList(501); + } + // Ask for extra rows because the Threads filter drops Instagram-only accounts. + const res = await fetchThreadsUserSearch(q, options.ctx, { accounts, count: count * 2 }); + if (!res.ok) { + return emptyUserList(500); + } + const results = usersFromThreadsList(res.json, { threadsOnly: true }).slice(0, count); + return { code: 200, results, cursor: { top: null, bottom: null } }; +} + +const emptyTypeahead = (code: number, query: string): APITypeaheadResponse => ({ + code, + query, + num_results: 0, + users: [], + topics: [], + events: [] +}); + +const isRecord = (v: unknown): v is Record => + Boolean(v) && typeof v === 'object' && !Array.isArray(v); + +/** + * Keyword suggestions out of `fbsearch/text_app/keyword/search/`. + * + * The app renders these from `keywords[]`, where each row is either a bare keyword record or one + * wrapped as `{ keyword: … }`. Anything that doesn't yield a name is skipped, so a shape change + * upstream costs the topics half of typeahead rather than the whole response. + */ +function topicsFromKeywordSearch(json: unknown): APITypeaheadTopic[] { + if (!isRecord(json)) return []; + const rows = json.keywords ?? json.results ?? json.items; + if (!Array.isArray(rows)) return []; + const topics: APITypeaheadTopic[] = []; + for (const raw of rows) { + if (!isRecord(raw)) continue; + const rec = isRecord(raw.keyword) ? raw.keyword : raw; + const name = + typeof rec.keyword_text === 'string' + ? rec.keyword_text + : typeof rec.name === 'string' + ? rec.name + : typeof rec.keyword === 'string' + ? rec.keyword + : ''; + if (!name) continue; + const context = + typeof rec.keyword_context === 'string' + ? rec.keyword_context + : typeof rec.search_result_subtitle === 'string' + ? rec.search_result_subtitle + : undefined; + topics.push({ + topic: name, + result_context: { + ...(context ? { display_string: context } : {}), + redirect_url: `https://www.threads.com/search?q=${encodeURIComponent(name)}`, + types: [{ type: 'keyword' }] + } + }); + } + return topics; +} + +/** + * Blended typeahead: accounts from `users/search/` (filtered to Threads) plus keyword suggestions, + * matching the shape of `/2/instagram/typeahead` and X's `/2/typeahead`. `events` stays empty — + * Threads has no equivalent. + */ +export async function constructThreadsTypeahead( + query: string, + options: { count?: number; ctx?: ThreadsRequestContext } = {} +): Promise { + const q = query.trim(); + if (!q) { + return emptyTypeahead(400, query); + } + const accounts = await resolveThreadsAccounts(options.ctx); + if (!accounts.length) { + return emptyTypeahead(501, q); + } + + const [userRes, keywordRes] = await Promise.all([ + fetchThreadsUserSearch(q, options.ctx, { accounts, count: options.count }), + fetchThreadsKeywordSearch(q, options.ctx, { accounts }) + ]); + // Users are the half people actually navigate with, so only a failure there is fatal. + if (!userRes.ok) { + return emptyTypeahead(500, q); + } + + const users = usersFromThreadsList(userRes.json, { threadsOnly: true }); + const topics = keywordRes.ok ? topicsFromKeywordSearch(keywordRes.json) : []; + return { + code: 200, + query: q, + num_results: users.length + topics.length, + users, + topics, + events: [] + }; +} diff --git a/packages/atmosphere/src/providers/threads/trends.ts b/packages/atmosphere/src/providers/threads/trends.ts new file mode 100644 index 00000000..0e93b950 --- /dev/null +++ b/packages/atmosphere/src/providers/threads/trends.ts @@ -0,0 +1,91 @@ +import type { APITrend, APITrendsResponse } from '../../types/api-schemas.js'; +import { resolveThreadsAccounts, type ThreadsRequestContext } from './account-proxy.js'; +import { fetchThreadsTrends } from './private-api.js'; + +const empty = (code: number, message?: string): APITrendsResponse => ({ + code, + ...(message ? { message } : {}), + timeline_type: 'threads', + trends: [], + cursor: { top: null, bottom: null } +}); + +const isRecord = (v: unknown): v is Record => + Boolean(v) && typeof v === 'object' && !Array.isArray(v); + +const str = (...vals: unknown[]): string => { + for (const v of vals) { + if (typeof v === 'string' && v.length > 0) return v; + } + return ''; +}; + +/** Rows land under `trending_topics` or `topics` depending on which tab the surface serves. */ +function trendRows(json: unknown): Record[] { + if (!isRecord(json)) return []; + for (const key of ['trending_topics', 'topics', 'trends', 'items']) { + const value = json[key]; + if (Array.isArray(value)) return value.filter(isRecord); + } + return []; +} + +function trendFromRow(row: Record): APITrend | null { + const name = str(row.trend_title, row.topic_name, row.trend_name, row.trend_keyword, row.name); + if (!name) return null; + const rank = row.trend_rank; + const postCount = row.post_count; + const context = + str(row.trend_description) || + (typeof postCount === 'number' && Number.isFinite(postCount) + ? `${postCount} posts` + : str(row.subtitle)); + const related = row.related_communities; + const groupedTopics = Array.isArray(related) + ? related + .filter(isRecord) + .map(c => ({ name: str(c.name, c.topic_name, c.title) })) + .filter(c => c.name.length > 0) + : []; + return { + name, + rank: typeof rank === 'number' && Number.isFinite(rank) ? String(rank) : null, + context: context || null, + ...(groupedTopics.length ? { grouped_topics: groupedTopics } : {}) + }; +} + +/** + * Threads trending topics (`fbsearch/text_app/trends/`) — the analogue of X's `/2/trends`. + * + * Trends are a logged-in surface on Threads, so this needs the account proxy; without one it + * reports 501. The endpoint serves a single ranked page and no cursor. + */ +export async function constructThreadsTrends( + options: { count?: number; ctx?: ThreadsRequestContext } = {} +): Promise { + const accounts = await resolveThreadsAccounts(options.ctx); + if (!accounts.length) { + return empty(501, 'Threads trends require a proxied account'); + } + const count = + options.count === undefined ? undefined : Math.min(100, Math.max(1, Math.floor(options.count))); + const res = await fetchThreadsTrends(options.ctx, { accounts, first: count }); + if (!res.ok) { + return empty(res.status === 404 ? 404 : 500); + } + + const trends: APITrend[] = []; + for (const row of trendRows(res.json)) { + const trend = trendFromRow(row); + if (trend) trends.push(trend); + if (count !== undefined && trends.length >= count) break; + } + + return { + code: 200, + timeline_type: 'threads', + trends, + cursor: { top: null, bottom: null } + }; +} diff --git a/packages/atmosphere/src/types/proxy-credentials.ts b/packages/atmosphere/src/types/proxy-credentials.ts index dde8e96c..92fa1afa 100644 --- a/packages/atmosphere/src/types/proxy-credentials.ts +++ b/packages/atmosphere/src/types/proxy-credentials.ts @@ -12,10 +12,38 @@ export type TwitterCredentials = { username: string; }; -/** Per-provider credential buckets; extend with instagram, etc. */ +/** + * Instagram session for in-process account proxy. + * + * `sessionId` is the `sessionid` cookie of a logged-in account. `platform` picks which client + * fingerprint the proxy presents: `web` (default) matches a `sessionid` harvested from + * www.instagram.com in a desktop browser, `android` matches one harvested from the Android app + * (see `INSTAGRAM_ANDROID_*` in `providers/instagram/constants.ts`). Mixing them is what usually + * trips Instagram's checkpoint, so keep it consistent with where the cookie came from. + */ +export type InstagramCredentials = { + sessionId: string; + /** `ds_user_id` cookie — numeric account pk. Sent alongside `sessionid` when present. */ + userId?: string; + /** `csrftoken` cookie. Required for POST endpoints; harmless to omit for GETs. */ + csrfToken?: string; + /** `mid` cookie. Optional, but Instagram is happier when the cookie jar looks complete. */ + mid?: string; + /** `ig_did` cookie (device UUID). Optional, same rationale as `mid`. */ + deviceId?: string; + /** Android device id in `android-<16 hex>` form; only meaningful with `platform: 'android'`. */ + androidDeviceId?: string; + /** Screen name, for logging only. */ + username?: string; + /** Which client fingerprint to present. Defaults to `web`. */ + platform?: 'web' | 'android'; +}; + +/** Per-provider credential buckets. */ export type CredentialStore = { twitter?: { accounts: TwitterCredentials[] }; bluesky?: { accounts: BlueskyProxyCredentials[] }; + instagram?: { accounts: InstagramCredentials[] }; }; export type ErrorResponse = { diff --git a/src/constants.ts b/src/constants.ts index 22c10671..8406e5e6 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -54,6 +54,7 @@ export const Constants = { TIKTOK_ROOT: 'https://www.tiktok.com', TIKTOK_API_HOST: 'https://api16-normal-c-useast1a.tiktokv.com', INSTAGRAM_ROOT: process.env.INSTAGRAM_ROOT || 'https://www.instagram.com', + INSTAGRAM_API_ROOT: process.env.INSTAGRAM_API_ROOT || 'https://i.instagram.com', NATIVE_MULTI_IMAGE_UA_REGEX: /discordbot\/|matrixpreviewbot/gi, BOT_UA_REGEX: /bot|facebook|embed|got|firefox\/92|firefox\/38|chrome\/96\.0\.4664\.110|curl|wget|go-http|yahoo|generator|whatsapp|revoltchat|preview|link|proxy|vkshare|images|analyzer|index|crawl|spider|python|node|deno|mastodon|http\.rb|ruby|bun\/|fiddler|iframely|steamchaturllookup|bluesky|matrix-media-repo|cardyb|resolver|util|feedly|rss|reader|atom|thunderbird|axios/gi, diff --git a/src/providers/instagram/atmosphere-handlers.ts b/src/providers/instagram/atmosphere-handlers.ts index 736814b4..21d3a694 100644 --- a/src/providers/instagram/atmosphere-handlers.ts +++ b/src/providers/instagram/atmosphere-handlers.ts @@ -6,7 +6,10 @@ import { normalizeApiJsonResponse } from '../../realms/api/normalizeApiJsonResponse'; import type { + APIProfileRelationshipList, APISearchResultsInstagram, + APITypeaheadResponse, + APIUserListResults, SocialThreadInstagram, UserAPIResponse } from '../../realms/api/schemas'; @@ -20,13 +23,28 @@ import { constructInstagramProfileStatuses, constructInstagramProfileVideos } from '@fxembed/atmosphere/providers/instagram/profile'; +import { constructInstagramStatusLikes } from '@fxembed/atmosphere/providers/instagram/likes'; +import { constructInstagramRelationshipList } from '@fxembed/atmosphere/providers/instagram/relationships'; +import { + constructInstagramTypeahead, + constructInstagramUserSearch +} from '@fxembed/atmosphere/providers/instagram/search'; +import { constructInstagramProfileStories } from '@fxembed/atmosphere/providers/instagram/stories'; +import { constructInstagramProfileTagged } from '@fxembed/atmosphere/providers/instagram/tagged'; import { normalizeInstagramPostId } from '@fxembed/atmosphere/providers/instagram/shortcode'; import { instagramConversationV2Route, + instagramProfileFollowersV2Route, + instagramProfileFollowingV2Route, instagramProfileStatusesV2Route, + instagramProfileStoriesV2Route, + instagramProfileTaggedV2Route, instagramProfileVideosV2Route, instagramProfileV2Route, - instagramStatusV2Route + instagramSearchUsersV2Route, + instagramStatusLikesV2Route, + instagramStatusV2Route, + instagramTypeaheadV2Route } from './atmosphere-routes'; /** Logs uncaught throws from Instagram upstream logic (network, timeouts, parse bugs). */ @@ -87,7 +105,7 @@ export const instagramStatusAPIRequest: RouteHandler constructInstagramPost(shortcode, ua), + () => constructInstagramPost(shortcode, ua, { credentialKey: c.env?.CREDENTIAL_KEY }), instagramStatus500 ); const { httpStatus, payload } = normalizeApiJsonResponse( @@ -111,7 +129,7 @@ export const instagramProfileAPIRequest: RouteHandler constructInstagramProfile(username, ua), + () => constructInstagramProfile(username, ua, { credentialKey: c.env?.CREDENTIAL_KEY }), instagramProfile500 ); const { httpStatus, payload } = normalizeApiJsonResponse( @@ -137,7 +155,8 @@ export const instagramProfileStatusesAPIRequest: RouteHandler< constructInstagramProfileStatuses(username, { count: q.count, cursor: q.cursor ?? null, - userAgent: ua + userAgent: ua, + credentialKey: c.env?.CREDENTIAL_KEY }), instagramSearch500 ); @@ -164,7 +183,8 @@ export const instagramProfileVideosAPIRequest: RouteHandler< constructInstagramProfileVideos(username, { count: q.count, cursor: q.cursor ?? null, - userAgent: ua + userAgent: ua, + credentialKey: c.env?.CREDENTIAL_KEY }), instagramSearch500 ); @@ -193,7 +213,8 @@ export const instagramConversationAPIRequest: RouteHandler< cursor: q.cursor ?? null, count: q.count, sortOrder: q.sort_order, - userAgent: ua + userAgent: ua, + credentialKey: c.env?.CREDENTIAL_KEY }), instagramConversationError ); @@ -220,3 +241,208 @@ export const instagramConversationAPIRequest: RouteHandler< setApiHeaders(c); return jsonAfterNormalize(c, payload, httpStatus); }; + +/** Statuses the proxy-gated Instagram routes can answer with, including 501 for "no proxy here". */ +const PROXY_ROUTE_STATUSES = [200, 400, 404, 500, 501] as const; + +const instagramUserList500: APIUserListResults = { + code: 500, + results: [], + cursor: { top: null, bottom: null } +}; + +const instagramRelationship500: APIProfileRelationshipList = { + code: 500, + results: [], + cursor: { top: null, bottom: null } +}; + +export const instagramStatusLikesAPIRequest: RouteHandler< + typeof instagramStatusLikesV2Route +> = async c => { + const { id } = c.req.valid('param'); + const q = c.req.valid('query'); + const ua = c.req.header('user-agent') ?? undefined; + const shortcode = normalizeInstagramPostId(id); + const body = await withInstagramErrorLog( + 'constructInstagramStatusLikes', + { shortcode }, + () => + constructInstagramStatusLikes(shortcode, { + count: q.count, + ctx: { userAgent: ua, credentialKey: c.env?.CREDENTIAL_KEY } + }), + instagramUserList500 + ); + const { httpStatus, payload } = normalizeApiJsonResponse( + body, + PROXY_ROUTE_STATUSES, + 'instagramStatusLikesAPIRequest' + ); + c.status(httpStatus); + setApiHeaders(c); + return jsonAfterNormalize(c, payload, httpStatus); +}; + +export const instagramProfileFollowersAPIRequest: RouteHandler< + typeof instagramProfileFollowersV2Route +> = async c => { + const { username } = c.req.valid('param'); + const q = c.req.valid('query'); + const ua = c.req.header('user-agent') ?? undefined; + const body = await withInstagramErrorLog( + 'constructInstagramRelationshipList', + { username, kind: 'followers' }, + () => + constructInstagramRelationshipList(username, 'followers', { + count: q.count, + cursor: q.cursor ?? null, + ctx: { userAgent: ua, credentialKey: c.env?.CREDENTIAL_KEY } + }), + instagramRelationship500 + ); + const { httpStatus, payload } = normalizeApiJsonResponse( + body, + PROXY_ROUTE_STATUSES, + 'instagramProfileFollowersAPIRequest' + ); + c.status(httpStatus); + setApiHeaders(c); + return jsonAfterNormalize(c, payload, httpStatus); +}; + +export const instagramProfileFollowingAPIRequest: RouteHandler< + typeof instagramProfileFollowingV2Route +> = async c => { + const { username } = c.req.valid('param'); + const q = c.req.valid('query'); + const ua = c.req.header('user-agent') ?? undefined; + const body = await withInstagramErrorLog( + 'constructInstagramRelationshipList', + { username, kind: 'following' }, + () => + constructInstagramRelationshipList(username, 'following', { + count: q.count, + cursor: q.cursor ?? null, + ctx: { userAgent: ua, credentialKey: c.env?.CREDENTIAL_KEY } + }), + instagramRelationship500 + ); + const { httpStatus, payload } = normalizeApiJsonResponse( + body, + PROXY_ROUTE_STATUSES, + 'instagramProfileFollowingAPIRequest' + ); + c.status(httpStatus); + setApiHeaders(c); + return jsonAfterNormalize(c, payload, httpStatus); +}; + +export const instagramProfileTaggedAPIRequest: RouteHandler< + typeof instagramProfileTaggedV2Route +> = async c => { + const { username } = c.req.valid('param'); + const q = c.req.valid('query'); + const ua = c.req.header('user-agent') ?? undefined; + const body = await withInstagramErrorLog( + 'constructInstagramProfileTagged', + { username }, + () => + constructInstagramProfileTagged(username, { + count: q.count, + cursor: q.cursor ?? null, + ctx: { userAgent: ua, credentialKey: c.env?.CREDENTIAL_KEY } + }), + instagramSearch500 + ); + const { httpStatus, payload } = normalizeApiJsonResponse( + body, + PROXY_ROUTE_STATUSES, + 'instagramProfileTaggedAPIRequest' + ); + c.status(httpStatus); + setApiHeaders(c); + return jsonAfterNormalize(c, payload, httpStatus); +}; + +export const instagramProfileStoriesAPIRequest: RouteHandler< + typeof instagramProfileStoriesV2Route +> = async c => { + const { username } = c.req.valid('param'); + const ua = c.req.header('user-agent') ?? undefined; + const body = await withInstagramErrorLog( + 'constructInstagramProfileStories', + { username }, + () => + constructInstagramProfileStories(username, { + ctx: { userAgent: ua, credentialKey: c.env?.CREDENTIAL_KEY } + }), + instagramSearch500 + ); + const { httpStatus, payload } = normalizeApiJsonResponse( + body, + [200, 404, 500, 501] as const, + 'instagramProfileStoriesAPIRequest' + ); + c.status(httpStatus); + setApiHeaders(c); + return jsonAfterNormalize(c, payload, httpStatus); +}; + +export const instagramSearchUsersAPIRequest: RouteHandler< + typeof instagramSearchUsersV2Route +> = async c => { + const q = c.req.valid('query'); + const ua = c.req.header('user-agent') ?? undefined; + const body = await withInstagramErrorLog( + 'constructInstagramUserSearch', + { query: q.query }, + () => + constructInstagramUserSearch(q.query, { + count: q.count, + ctx: { userAgent: ua, credentialKey: c.env?.CREDENTIAL_KEY } + }), + instagramUserList500 + ); + const { httpStatus, payload } = normalizeApiJsonResponse( + body, + PROXY_ROUTE_STATUSES, + 'instagramSearchUsersAPIRequest' + ); + c.status(httpStatus); + setApiHeaders(c); + return jsonAfterNormalize(c, payload, httpStatus); +}; + +export const instagramTypeaheadAPIRequest: RouteHandler< + typeof instagramTypeaheadV2Route +> = async c => { + const q = c.req.valid('query'); + const ua = c.req.header('user-agent') ?? undefined; + const typeahead500: APITypeaheadResponse = { + code: 500, + query: q.query, + num_results: 0, + users: [], + topics: [], + events: [] + }; + const body = await withInstagramErrorLog( + 'constructInstagramTypeahead', + { query: q.query }, + () => + constructInstagramTypeahead(q.query, { + count: q.count, + ctx: { userAgent: ua, credentialKey: c.env?.CREDENTIAL_KEY } + }), + typeahead500 + ); + const { httpStatus, payload } = normalizeApiJsonResponse( + body, + PROXY_ROUTE_STATUSES, + 'instagramTypeaheadAPIRequest' + ); + c.status(httpStatus); + setApiHeaders(c); + return jsonAfterNormalize(c, payload, httpStatus); +}; diff --git a/src/providers/instagram/atmosphere-register.ts b/src/providers/instagram/atmosphere-register.ts index 23eb617e..b278cac4 100644 --- a/src/providers/instagram/atmosphere-register.ts +++ b/src/providers/instagram/atmosphere-register.ts @@ -2,16 +2,30 @@ import type { OpenAPIHono } from '@hono/zod-openapi'; import { instagramConversationAPIRequest, instagramProfileAPIRequest, + instagramProfileFollowersAPIRequest, + instagramProfileFollowingAPIRequest, instagramProfileStatusesAPIRequest, + instagramProfileStoriesAPIRequest, + instagramProfileTaggedAPIRequest, instagramProfileVideosAPIRequest, - instagramStatusAPIRequest + instagramSearchUsersAPIRequest, + instagramStatusAPIRequest, + instagramStatusLikesAPIRequest, + instagramTypeaheadAPIRequest } from './atmosphere-handlers'; import { instagramConversationV2Route, + instagramProfileFollowersV2Route, + instagramProfileFollowingV2Route, instagramProfileStatusesV2Route, + instagramProfileStoriesV2Route, + instagramProfileTaggedV2Route, instagramProfileVideosV2Route, instagramProfileV2Route, - instagramStatusV2Route + instagramSearchUsersV2Route, + instagramStatusLikesV2Route, + instagramStatusV2Route, + instagramTypeaheadV2Route } from './atmosphere-routes'; export const registerInstagramAtmosphereRoutes = (atmosphere: OpenAPIHono) => { @@ -20,4 +34,11 @@ export const registerInstagramAtmosphereRoutes = (atmosphere: OpenAPIHono) => { atmosphere.openapi(instagramProfileStatusesV2Route, instagramProfileStatusesAPIRequest); atmosphere.openapi(instagramProfileVideosV2Route, instagramProfileVideosAPIRequest); atmosphere.openapi(instagramConversationV2Route, instagramConversationAPIRequest); + atmosphere.openapi(instagramStatusLikesV2Route, instagramStatusLikesAPIRequest); + atmosphere.openapi(instagramProfileFollowersV2Route, instagramProfileFollowersAPIRequest); + atmosphere.openapi(instagramProfileFollowingV2Route, instagramProfileFollowingAPIRequest); + atmosphere.openapi(instagramProfileTaggedV2Route, instagramProfileTaggedAPIRequest); + atmosphere.openapi(instagramProfileStoriesV2Route, instagramProfileStoriesAPIRequest); + atmosphere.openapi(instagramSearchUsersV2Route, instagramSearchUsersAPIRequest); + atmosphere.openapi(instagramTypeaheadV2Route, instagramTypeaheadAPIRequest); }; diff --git a/src/providers/instagram/atmosphere-routes.ts b/src/providers/instagram/atmosphere-routes.ts index e169429e..bf590218 100644 --- a/src/providers/instagram/atmosphere-routes.ts +++ b/src/providers/instagram/atmosphere-routes.ts @@ -1,12 +1,23 @@ import { createRoute, z } from '@hono/zod-openapi'; import { ApiQueryErrorSchema, + APIProfileRelationshipListSchema, APISearchResultsInstagramSchema, + APITypeaheadResponseSchema, + APIUserListResultsSchema, SocialConversationInstagramSchema, SocialThreadInstagramSchema, UserAPIResponseSchema } from '../../realms/api/schemas'; +/** + * Instagram gates follower lists, likers, search, tagged posts and stories behind a login. Those + * routes answer 501 when the deployment has no Instagram account proxy configured, rather than + * returning an empty list that reads as "this account has none". + */ +const PROXY_ONLY_NOTE = + 'Requires an Instagram account proxy (`CREDENTIAL_KEY` + bundled `instagram.accounts`); returns 501 without one.'; + export const instagramStatusV2Route = createRoute({ method: 'get', path: '/2/instagram/status/{id}', @@ -177,3 +188,225 @@ export const instagramConversationV2Route = createRoute({ } } }); + +const NO_PROXY_DESCRIPTION = 'No Instagram account proxy configured on this deployment'; + +export const instagramStatusLikesV2Route = createRoute({ + method: 'get', + path: '/2/instagram/status/{id}/likes', + summary: 'List accounts that liked an Instagram post', + description: `Instagram's closest analogue to X's repost/quote lists. Instagram serves one un-paginated page, so \`cursor.bottom\` is always null. ${PROXY_ONLY_NOTE}`, + request: { + params: z.object({ + id: z + .string() + .openapi({ description: 'Post shortcode or permalink fragment', example: 'DXeh-kYiIge' }) + }), + query: z.object({ + count: z.coerce.number().int().min(1).max(100).default(20).openapi({ default: 20 }) + }) + }, + responses: { + 200: { + description: 'Likers', + content: { 'application/json': { schema: APIUserListResultsSchema } } + }, + 400: { + description: 'Invalid shortcode', + content: { 'application/json': { schema: APIUserListResultsSchema } } + }, + 404: { + description: 'Not found', + content: { 'application/json': { schema: APIUserListResultsSchema } } + }, + 500: { + description: 'Upstream error', + content: { 'application/json': { schema: APIUserListResultsSchema } } + }, + 501: { + description: NO_PROXY_DESCRIPTION, + content: { 'application/json': { schema: APIUserListResultsSchema } } + } + } +}); + +const relationshipRequest = { + params: z.object({ username: z.string().openapi({ example: 'cristiano' }) }), + query: z.object({ + count: z.coerce.number().int().min(1).max(100).default(20).openapi({ default: 20 }), + cursor: z + .string() + .optional() + .openapi({ description: 'Opaque pagination cursor (`cursor.bottom`)' }) + }) +}; + +const relationshipResponses = { + 200: { + description: 'Relationship page', + content: { 'application/json': { schema: APIProfileRelationshipListSchema } } + }, + 400: { + description: 'Invalid cursor', + content: { 'application/json': { schema: ApiQueryErrorSchema } } + }, + 404: { + description: 'Not found', + content: { 'application/json': { schema: APIProfileRelationshipListSchema } } + }, + 500: { + description: 'Upstream error', + content: { 'application/json': { schema: APIProfileRelationshipListSchema } } + }, + 501: { + description: NO_PROXY_DESCRIPTION, + content: { 'application/json': { schema: APIProfileRelationshipListSchema } } + } +}; + +export const instagramProfileFollowersV2Route = createRoute({ + method: 'get', + path: '/2/instagram/profile/{username}/followers', + summary: 'List an Instagram account’s followers', + description: PROXY_ONLY_NOTE, + request: relationshipRequest, + responses: relationshipResponses +}); + +export const instagramProfileFollowingV2Route = createRoute({ + method: 'get', + path: '/2/instagram/profile/{username}/following', + summary: 'List the accounts an Instagram account follows', + description: PROXY_ONLY_NOTE, + request: relationshipRequest, + responses: relationshipResponses +}); + +export const instagramProfileTaggedV2Route = createRoute({ + method: 'get', + path: '/2/instagram/profile/{username}/tagged', + summary: 'List posts an Instagram account is tagged in', + description: `The tagged grid, Instagram's nearest equivalent to X's \`/profile/{handle}/media\`. ${PROXY_ONLY_NOTE}`, + request: { + params: z.object({ username: z.string().openapi({ example: 'cristiano' }) }), + query: z.object({ + count: z.coerce.number().int().min(1).max(100).default(20).openapi({ default: 20 }), + cursor: z + .string() + .optional() + .openapi({ description: 'Opaque pagination cursor (`cursor.bottom`)' }) + }) + }, + responses: { + 200: { + description: 'Tagged page', + content: { 'application/json': { schema: APISearchResultsInstagramSchema } } + }, + 400: { + description: 'Invalid cursor', + content: { 'application/json': { schema: ApiQueryErrorSchema } } + }, + 404: { + description: 'Not found', + content: { 'application/json': { schema: APISearchResultsInstagramSchema } } + }, + 500: { + description: 'Upstream error', + content: { 'application/json': { schema: APISearchResultsInstagramSchema } } + }, + 501: { + description: NO_PROXY_DESCRIPTION, + content: { 'application/json': { schema: APISearchResultsInstagramSchema } } + } + } +}); + +export const instagramProfileStoriesV2Route = createRoute({ + method: 'get', + path: '/2/instagram/profile/{username}/stories', + summary: 'List an Instagram account’s active stories', + description: `Stories expire after 24 hours and are not paginated. ${PROXY_ONLY_NOTE}`, + request: { + params: z.object({ username: z.string().openapi({ example: 'cristiano' }) }) + }, + responses: { + 200: { + description: 'Active stories', + content: { 'application/json': { schema: APISearchResultsInstagramSchema } } + }, + 404: { + description: 'Not found', + content: { 'application/json': { schema: APISearchResultsInstagramSchema } } + }, + 500: { + description: 'Upstream error', + content: { 'application/json': { schema: APISearchResultsInstagramSchema } } + }, + 501: { + description: NO_PROXY_DESCRIPTION, + content: { 'application/json': { schema: APISearchResultsInstagramSchema } } + } + } +}); + +export const instagramSearchUsersV2Route = createRoute({ + method: 'get', + path: '/2/instagram/search/users', + summary: 'Search Instagram accounts', + description: `Instagram returns one ranked page with no cursor, so \`cursor.bottom\` is always null. ${PROXY_ONLY_NOTE}`, + request: { + query: z.object({ + query: z.string().min(1).max(50).openapi({ example: 'cristiano' }), + count: z.coerce.number().int().min(1).max(50).default(20).openapi({ default: 20 }) + }) + }, + responses: { + 200: { + description: 'Matching accounts', + content: { 'application/json': { schema: APIUserListResultsSchema } } + }, + 400: { + description: 'Invalid parameters', + content: { 'application/json': { schema: ApiQueryErrorSchema } } + }, + 500: { + description: 'Upstream error', + content: { 'application/json': { schema: APIUserListResultsSchema } } + }, + 501: { + description: NO_PROXY_DESCRIPTION, + content: { 'application/json': { schema: APIUserListResultsSchema } } + } + } +}); + +export const instagramTypeaheadV2Route = createRoute({ + method: 'get', + path: '/2/instagram/typeahead', + summary: 'Instagram blended typeahead', + description: `Accounts, hashtags and places. Hashtags and places both land in \`topics\` (tagged via \`result_context.types\`); \`events\` is always empty, as Instagram has no equivalent. ${PROXY_ONLY_NOTE}`, + request: { + query: z.object({ + query: z.string().min(1).max(50).openapi({ example: 'cristiano' }), + count: z.coerce.number().int().min(1).max(50).optional() + }) + }, + responses: { + 200: { + description: 'Typeahead results', + content: { 'application/json': { schema: APITypeaheadResponseSchema } } + }, + 400: { + description: 'Invalid parameters', + content: { 'application/json': { schema: ApiQueryErrorSchema } } + }, + 500: { + description: 'Upstream error', + content: { 'application/json': { schema: APITypeaheadResponseSchema } } + }, + 501: { + description: NO_PROXY_DESCRIPTION, + content: { 'application/json': { schema: APITypeaheadResponseSchema } } + } + } +}); diff --git a/src/providers/threads/atmosphere-handlers.ts b/src/providers/threads/atmosphere-handlers.ts index 8db25305..c019fe34 100644 --- a/src/providers/threads/atmosphere-handlers.ts +++ b/src/providers/threads/atmosphere-handlers.ts @@ -5,7 +5,14 @@ import { jsonAfterNormalize, normalizeApiJsonResponse } from '../../realms/api/normalizeApiJsonResponse'; -import type { APISearchResultsThreads, UserAPIResponse } from '../../realms/api/schemas'; +import type { + APIProfileRelationshipList, + APISearchResultsThreads, + APITrendsResponse, + APITypeaheadResponse, + APIUserListResults, + UserAPIResponse +} from '../../realms/api/schemas'; import type { SocialConversation, SocialThread } from '../../types/apiStatus'; import { constructThreadsConversation, @@ -16,11 +23,36 @@ import { constructThreadsProfile, constructThreadsProfileStatuses } from '@fxembed/atmosphere/providers/threads/profile'; +import { constructThreadsStatusLikes } from '@fxembed/atmosphere/providers/threads/likes'; +import { + constructThreadsProfileTab, + type ThreadsProfileTab +} from '@fxembed/atmosphere/providers/threads/profile-tabs'; +import { + constructThreadsRelationshipList, + type ThreadsRelationshipKind +} from '@fxembed/atmosphere/providers/threads/relationships'; +import { + constructThreadsSearch, + constructThreadsTypeahead, + constructThreadsUserSearch +} from '@fxembed/atmosphere/providers/threads/search'; +import { constructThreadsTrends } from '@fxembed/atmosphere/providers/threads/trends'; import { threadsConversationV2Route, + threadsProfileFollowersV2Route, + threadsProfileFollowingV2Route, + threadsProfileMediaV2Route, + threadsProfileRepliesV2Route, + threadsProfileRepostsV2Route, threadsProfileStatusesV2Route, threadsProfileV2Route, - threadsStatusV2Route + threadsSearchUsersV2Route, + threadsSearchV2Route, + threadsStatusLikesV2Route, + threadsStatusV2Route, + threadsTrendsV2Route, + threadsTypeaheadV2Route } from './atmosphere-routes'; async function withThreadsErrorLog( @@ -79,7 +111,7 @@ export const threadsStatusAPIRequest: RouteHandler const body = await withThreadsErrorLog( 'constructThreadsPost', { id }, - () => constructThreadsPost(id, ua), + () => constructThreadsPost(id, ua, { credentialKey: c.env?.CREDENTIAL_KEY }), threadsStatus500 ); const { httpStatus, payload } = normalizeApiJsonResponse( @@ -103,7 +135,7 @@ export const threadsProfileAPIRequest: RouteHandler constructThreadsProfile(username, ua), + () => constructThreadsProfile(username, ua, { credentialKey: c.env?.CREDENTIAL_KEY }), threadsProfile500 ); const { httpStatus, payload } = normalizeApiJsonResponse( @@ -129,7 +161,8 @@ export const threadsProfileStatusesAPIRequest: RouteHandler< constructThreadsProfileStatuses(username, { count: q.count, cursor: q.cursor ?? null, - userAgent: ua + userAgent: ua, + ctx: { userAgent: ua, credentialKey: c.env?.CREDENTIAL_KEY } }), threadsSearch500 ); @@ -157,7 +190,8 @@ export const threadsConversationAPIRequest: RouteHandler< cursor: q.cursor ?? null, count: q.count, sortOrder: q.sort_order, - userAgent: ua + userAgent: ua, + ctx: { userAgent: ua, credentialKey: c.env?.CREDENTIAL_KEY } }), threadsConversationError ); @@ -184,3 +218,293 @@ export const threadsConversationAPIRequest: RouteHandler< setApiHeaders(c); return jsonAfterNormalize(c, payload, httpStatus); }; + +/** Statuses the proxy-gated Threads routes can answer with, including 501 for "no proxy here". */ +const PROXY_ROUTE_STATUSES = [200, 400, 404, 500, 501] as const; + +const threadsUserList500: APIUserListResults = { + code: 500, + results: [], + cursor: { top: null, bottom: null } +}; + +const threadsRelationship500: APIProfileRelationshipList = { + code: 500, + results: [], + cursor: { top: null, bottom: null } +}; + +const threadsTrends500: APITrendsResponse = { + code: 500, + timeline_type: 'threads', + trends: [], + cursor: { top: null, bottom: null } +}; + +/** + * The proxy-only profile tabs differ only by which upstream tab they read, so the work lives in one + * helper and each route keeps its own thin, correctly-typed handler. + */ +async function profileTabBody( + username: string, + tab: ThreadsProfileTab, + q: { count: number; cursor?: string }, + ctx: { userAgent?: string; credentialKey?: string } +): Promise { + return withThreadsErrorLog( + 'constructThreadsProfileTab', + { username, tab }, + () => + constructThreadsProfileTab(username, tab, { + count: q.count, + cursor: q.cursor ?? null, + ctx + }), + threadsSearch500 + ); +} + +export const threadsProfileRepliesAPIRequest: RouteHandler< + typeof threadsProfileRepliesV2Route +> = async c => { + const { username } = c.req.valid('param'); + const q = c.req.valid('query'); + const body = await profileTabBody(username, 'replies', q, { + userAgent: c.req.header('user-agent') ?? undefined, + credentialKey: c.env?.CREDENTIAL_KEY + }); + const { httpStatus, payload } = normalizeApiJsonResponse( + body, + PROXY_ROUTE_STATUSES, + 'threadsProfileRepliesAPIRequest' + ); + c.status(httpStatus); + setApiHeaders(c); + return jsonAfterNormalize(c, payload, httpStatus); +}; + +export const threadsProfileRepostsAPIRequest: RouteHandler< + typeof threadsProfileRepostsV2Route +> = async c => { + const { username } = c.req.valid('param'); + const q = c.req.valid('query'); + const body = await profileTabBody(username, 'reposts', q, { + userAgent: c.req.header('user-agent') ?? undefined, + credentialKey: c.env?.CREDENTIAL_KEY + }); + const { httpStatus, payload } = normalizeApiJsonResponse( + body, + PROXY_ROUTE_STATUSES, + 'threadsProfileRepostsAPIRequest' + ); + c.status(httpStatus); + setApiHeaders(c); + return jsonAfterNormalize(c, payload, httpStatus); +}; + +export const threadsProfileMediaAPIRequest: RouteHandler< + typeof threadsProfileMediaV2Route +> = async c => { + const { username } = c.req.valid('param'); + const q = c.req.valid('query'); + const body = await profileTabBody(username, 'media', q, { + userAgent: c.req.header('user-agent') ?? undefined, + credentialKey: c.env?.CREDENTIAL_KEY + }); + const { httpStatus, payload } = normalizeApiJsonResponse( + body, + PROXY_ROUTE_STATUSES, + 'threadsProfileMediaAPIRequest' + ); + c.status(httpStatus); + setApiHeaders(c); + return jsonAfterNormalize(c, payload, httpStatus); +}; + +/** Followers and following differ only by which upstream list they read. */ +async function relationshipBody( + username: string, + kind: ThreadsRelationshipKind, + q: { count: number; cursor?: string }, + ctx: { userAgent?: string; credentialKey?: string } +): Promise { + return withThreadsErrorLog( + 'constructThreadsRelationshipList', + { username, kind }, + () => + constructThreadsRelationshipList(username, kind, { + count: q.count, + cursor: q.cursor ?? null, + ctx + }), + threadsRelationship500 + ); +} + +export const threadsProfileFollowersAPIRequest: RouteHandler< + typeof threadsProfileFollowersV2Route +> = async c => { + const { username } = c.req.valid('param'); + const q = c.req.valid('query'); + const body = await relationshipBody(username, 'followers', q, { + userAgent: c.req.header('user-agent') ?? undefined, + credentialKey: c.env?.CREDENTIAL_KEY + }); + const { httpStatus, payload } = normalizeApiJsonResponse( + body, + PROXY_ROUTE_STATUSES, + 'threadsProfileFollowersAPIRequest' + ); + c.status(httpStatus); + setApiHeaders(c); + return jsonAfterNormalize(c, payload, httpStatus); +}; + +export const threadsProfileFollowingAPIRequest: RouteHandler< + typeof threadsProfileFollowingV2Route +> = async c => { + const { username } = c.req.valid('param'); + const q = c.req.valid('query'); + const body = await relationshipBody(username, 'following', q, { + userAgent: c.req.header('user-agent') ?? undefined, + credentialKey: c.env?.CREDENTIAL_KEY + }); + const { httpStatus, payload } = normalizeApiJsonResponse( + body, + PROXY_ROUTE_STATUSES, + 'threadsProfileFollowingAPIRequest' + ); + c.status(httpStatus); + setApiHeaders(c); + return jsonAfterNormalize(c, payload, httpStatus); +}; + +export const threadsStatusLikesAPIRequest: RouteHandler< + typeof threadsStatusLikesV2Route +> = async c => { + const { id } = c.req.valid('param'); + const q = c.req.valid('query'); + const ua = c.req.header('user-agent') ?? undefined; + const body = await withThreadsErrorLog( + 'constructThreadsStatusLikes', + { id }, + () => + constructThreadsStatusLikes(id, { + count: q.count, + ctx: { userAgent: ua, credentialKey: c.env?.CREDENTIAL_KEY } + }), + threadsUserList500 + ); + const { httpStatus, payload } = normalizeApiJsonResponse( + body, + PROXY_ROUTE_STATUSES, + 'threadsStatusLikesAPIRequest' + ); + c.status(httpStatus); + setApiHeaders(c); + return jsonAfterNormalize(c, payload, httpStatus); +}; + +export const threadsSearchAPIRequest: RouteHandler = async c => { + const q = c.req.valid('query'); + const ua = c.req.header('user-agent') ?? undefined; + const body = await withThreadsErrorLog( + 'constructThreadsSearch', + { q: q.q }, + () => + constructThreadsSearch(q.q, { + count: q.count, + cursor: q.cursor ?? null, + sortOrder: q.sort_order, + ctx: { userAgent: ua, credentialKey: c.env?.CREDENTIAL_KEY } + }), + threadsSearch500 + ); + const { httpStatus, payload } = normalizeApiJsonResponse( + body, + PROXY_ROUTE_STATUSES, + 'threadsSearchAPIRequest' + ); + c.status(httpStatus); + setApiHeaders(c); + return jsonAfterNormalize(c, payload, httpStatus); +}; + +export const threadsSearchUsersAPIRequest: RouteHandler< + typeof threadsSearchUsersV2Route +> = async c => { + const q = c.req.valid('query'); + const ua = c.req.header('user-agent') ?? undefined; + const body = await withThreadsErrorLog( + 'constructThreadsUserSearch', + { q: q.q }, + () => + constructThreadsUserSearch(q.q, { + count: q.count, + ctx: { userAgent: ua, credentialKey: c.env?.CREDENTIAL_KEY } + }), + threadsUserList500 + ); + const { httpStatus, payload } = normalizeApiJsonResponse( + body, + [200, 400, 500, 501] as const, + 'threadsSearchUsersAPIRequest' + ); + c.status(httpStatus); + setApiHeaders(c); + return jsonAfterNormalize(c, payload, httpStatus); +}; + +export const threadsTrendsAPIRequest: RouteHandler = async c => { + const q = c.req.valid('query'); + const ua = c.req.header('user-agent') ?? undefined; + const body = await withThreadsErrorLog( + 'constructThreadsTrends', + {}, + () => + constructThreadsTrends({ + count: q.count, + ctx: { userAgent: ua, credentialKey: c.env?.CREDENTIAL_KEY } + }), + threadsTrends500 + ); + const { httpStatus, payload } = normalizeApiJsonResponse( + body, + [200, 404, 500, 501] as const, + 'threadsTrendsAPIRequest' + ); + c.status(httpStatus); + setApiHeaders(c); + return jsonAfterNormalize(c, payload, httpStatus); +}; + +export const threadsTypeaheadAPIRequest: RouteHandler = async c => { + const q = c.req.valid('query'); + const ua = c.req.header('user-agent') ?? undefined; + const typeahead500: APITypeaheadResponse = { + code: 500, + query: q.query, + num_results: 0, + users: [], + topics: [], + events: [] + }; + const body = await withThreadsErrorLog( + 'constructThreadsTypeahead', + { query: q.query }, + () => + constructThreadsTypeahead(q.query, { + count: q.count, + ctx: { userAgent: ua, credentialKey: c.env?.CREDENTIAL_KEY } + }), + typeahead500 + ); + const { httpStatus, payload } = normalizeApiJsonResponse( + body, + [200, 400, 500, 501] as const, + 'threadsTypeaheadAPIRequest' + ); + c.status(httpStatus); + setApiHeaders(c); + return jsonAfterNormalize(c, payload, httpStatus); +}; diff --git a/src/providers/threads/atmosphere-register.ts b/src/providers/threads/atmosphere-register.ts index 1743f7a3..ffbac217 100644 --- a/src/providers/threads/atmosphere-register.ts +++ b/src/providers/threads/atmosphere-register.ts @@ -2,19 +2,49 @@ import type { OpenAPIHono } from '@hono/zod-openapi'; import { threadsConversationAPIRequest, threadsProfileAPIRequest, + threadsProfileFollowersAPIRequest, + threadsProfileFollowingAPIRequest, + threadsProfileMediaAPIRequest, + threadsProfileRepliesAPIRequest, + threadsProfileRepostsAPIRequest, threadsProfileStatusesAPIRequest, - threadsStatusAPIRequest + threadsSearchAPIRequest, + threadsSearchUsersAPIRequest, + threadsStatusAPIRequest, + threadsStatusLikesAPIRequest, + threadsTrendsAPIRequest, + threadsTypeaheadAPIRequest } from './atmosphere-handlers'; import { threadsConversationV2Route, + threadsProfileFollowersV2Route, + threadsProfileFollowingV2Route, + threadsProfileMediaV2Route, + threadsProfileRepliesV2Route, + threadsProfileRepostsV2Route, threadsProfileStatusesV2Route, threadsProfileV2Route, - threadsStatusV2Route + threadsSearchUsersV2Route, + threadsSearchV2Route, + threadsStatusLikesV2Route, + threadsStatusV2Route, + threadsTrendsV2Route, + threadsTypeaheadV2Route } from './atmosphere-routes'; export const registerThreadsAtmosphereRoutes = (atmosphere: OpenAPIHono) => { atmosphere.openapi(threadsStatusV2Route, threadsStatusAPIRequest); + atmosphere.openapi(threadsStatusLikesV2Route, threadsStatusLikesAPIRequest); atmosphere.openapi(threadsProfileV2Route, threadsProfileAPIRequest); atmosphere.openapi(threadsProfileStatusesV2Route, threadsProfileStatusesAPIRequest); + atmosphere.openapi(threadsProfileRepliesV2Route, threadsProfileRepliesAPIRequest); + atmosphere.openapi(threadsProfileRepostsV2Route, threadsProfileRepostsAPIRequest); + atmosphere.openapi(threadsProfileMediaV2Route, threadsProfileMediaAPIRequest); + atmosphere.openapi(threadsProfileFollowersV2Route, threadsProfileFollowersAPIRequest); + atmosphere.openapi(threadsProfileFollowingV2Route, threadsProfileFollowingAPIRequest); atmosphere.openapi(threadsConversationV2Route, threadsConversationAPIRequest); + atmosphere.openapi(threadsSearchV2Route, threadsSearchAPIRequest); + atmosphere.openapi(threadsSearchUsersV2Route, threadsSearchUsersAPIRequest); + atmosphere.openapi(threadsTrendsV2Route, threadsTrendsAPIRequest); + atmosphere.openapi(threadsTypeaheadV2Route, threadsTypeaheadAPIRequest); }; diff --git a/src/providers/threads/atmosphere-routes.ts b/src/providers/threads/atmosphere-routes.ts index b9ae56db..486e3e38 100644 --- a/src/providers/threads/atmosphere-routes.ts +++ b/src/providers/threads/atmosphere-routes.ts @@ -1,7 +1,11 @@ import { createRoute, z } from '@hono/zod-openapi'; import { ApiQueryErrorSchema, + APIProfileRelationshipListSchema, APISearchResultsThreadsSchema, + APITrendsResponseSchema, + APITypeaheadResponseSchema, + APIUserListResultsSchema, SocialConversationSchema, SocialThreadSchema, UserAPIResponseSchema @@ -12,7 +16,7 @@ export const threadsStatusV2Route = createRoute({ path: '/2/threads/status/{id}', summary: 'Get a single Threads post', description: - 'Resolves a post by shortcode or Threads permalink. Data is sourced from logged-out `threads.com` GraphQL.', + 'Resolves a post by shortcode or Threads permalink. Reads the Threads app API when an account proxy is configured, and falls back to logged-out `threads.com` GraphQL otherwise.', request: { params: z.object({ id: z @@ -110,7 +114,7 @@ export const threadsConversationV2Route = createRoute({ path: '/2/threads/conversation/{id}', summary: 'Threads post with replies', description: - 'Returns the focal post plus direct replies as `substatus` rows (`type: substatus`, `provider: threads`).', + 'Returns the focal post plus direct replies as `substatus` rows (`type: substatus`, `provider: threads`). Replies come from the Threads app API when an account proxy is configured — logged-out `threads.com` truncates them hard — and fall back to the logged-out connection otherwise. A cursor is only valid against the source that minted it.', request: { params: z.object({ id: z @@ -145,3 +149,289 @@ export const threadsConversationV2Route = createRoute({ } } }); + +/** + * Threads gates search, trends, likers, follow lists and the Replies / Reposts / Media profile tabs + * behind a login. Those routes answer 501 when the deployment has no account proxy configured, + * rather than returning an empty list that reads as "this account has none". + * + * Threads accounts are Instagram accounts, so the pool is the Instagram one — the proxy only swaps + * in the Threads app fingerprint. + */ +const PROXY_ONLY_NOTE = + 'Requires an Instagram account proxy (`CREDENTIAL_KEY` + bundled `instagram.accounts`); returns 501 without one.'; + +const NO_PROXY_DESCRIPTION = 'No Instagram account proxy configured on this deployment'; + +const profileTabRequest = { + params: z.object({ username: z.string().openapi({ example: 'zuck' }) }), + query: z.object({ + count: z.coerce.number().int().min(1).max(100).default(20).openapi({ default: 20 }), + cursor: z + .string() + .optional() + .openapi({ description: 'Opaque pagination cursor (`cursor.bottom`)' }) + }) +}; + +const profileTabResponses = { + 200: { + description: 'Timeline page', + content: { 'application/json': { schema: APISearchResultsThreadsSchema } } + }, + 400: { + description: 'Invalid cursor', + content: { 'application/json': { schema: ApiQueryErrorSchema } } + }, + 404: { + description: 'Not found', + content: { 'application/json': { schema: APISearchResultsThreadsSchema } } + }, + 500: { + description: 'Upstream error', + content: { 'application/json': { schema: APISearchResultsThreadsSchema } } + }, + 501: { + description: NO_PROXY_DESCRIPTION, + content: { 'application/json': { schema: APISearchResultsThreadsSchema } } + } +}; + +export const threadsProfileRepliesV2Route = createRoute({ + method: 'get', + path: '/2/threads/profile/{username}/replies', + summary: 'List a Threads profile’s replies', + description: `The Replies tab, which logged-out \`threads.com\` does not serve. ${PROXY_ONLY_NOTE}`, + request: profileTabRequest, + responses: profileTabResponses +}); + +export const threadsProfileRepostsV2Route = createRoute({ + method: 'get', + path: '/2/threads/profile/{username}/reposts', + summary: 'List a Threads profile’s reposts', + description: `The Reposts tab — Threads' equivalent of an X profile's retweets. ${PROXY_ONLY_NOTE}`, + request: profileTabRequest, + responses: profileTabResponses +}); + +export const threadsProfileMediaV2Route = createRoute({ + method: 'get', + path: '/2/threads/profile/{username}/media', + summary: 'List a Threads profile’s posts with media', + description: `The Media tab, matching X's \`/2/profile/{handle}/media\`. ${PROXY_ONLY_NOTE}`, + request: profileTabRequest, + responses: profileTabResponses +}); + +const relationshipResponses = { + 200: { + description: 'Relationship page', + content: { 'application/json': { schema: APIProfileRelationshipListSchema } } + }, + 400: { + description: 'Invalid cursor', + content: { 'application/json': { schema: ApiQueryErrorSchema } } + }, + 404: { + description: 'Not found', + content: { 'application/json': { schema: APIProfileRelationshipListSchema } } + }, + 500: { + description: 'Upstream error', + content: { 'application/json': { schema: APIProfileRelationshipListSchema } } + }, + 501: { + description: NO_PROXY_DESCRIPTION, + content: { 'application/json': { schema: APIProfileRelationshipListSchema } } + } +}; + +export const threadsProfileFollowersV2Route = createRoute({ + method: 'get', + path: '/2/threads/profile/{username}/followers', + summary: 'List a Threads account’s followers', + description: `Threads shares Instagram's social graph, so this is the Instagram follower list — unfiltered, to match the counts the Threads profile shows. ${PROXY_ONLY_NOTE}`, + request: profileTabRequest, + responses: relationshipResponses +}); + +export const threadsProfileFollowingV2Route = createRoute({ + method: 'get', + path: '/2/threads/profile/{username}/following', + summary: 'List the accounts a Threads account follows', + description: `Threads shares Instagram's social graph, so this is the Instagram following list — unfiltered, to match the counts the Threads profile shows. ${PROXY_ONLY_NOTE}`, + request: profileTabRequest, + responses: relationshipResponses +}); + +export const threadsStatusLikesV2Route = createRoute({ + method: 'get', + path: '/2/threads/status/{id}/likes', + summary: 'List accounts that liked a Threads post', + description: `Threads serves one un-paginated page, so \`cursor.bottom\` is always null. ${PROXY_ONLY_NOTE}`, + request: { + params: z.object({ + id: z + .string() + .openapi({ description: 'Post shortcode or permalink fragment', example: 'DXhZAMkljvS' }) + }), + query: z.object({ + count: z.coerce.number().int().min(1).max(100).default(20).openapi({ default: 20 }) + }) + }, + responses: { + 200: { + description: 'Likers', + content: { 'application/json': { schema: APIUserListResultsSchema } } + }, + 400: { + description: 'Invalid shortcode', + content: { 'application/json': { schema: APIUserListResultsSchema } } + }, + 404: { + description: 'Not found', + content: { 'application/json': { schema: APIUserListResultsSchema } } + }, + 500: { + description: 'Upstream error', + content: { 'application/json': { schema: APIUserListResultsSchema } } + }, + 501: { + description: NO_PROXY_DESCRIPTION, + content: { 'application/json': { schema: APIUserListResultsSchema } } + } + } +}); + +export const threadsSearchV2Route = createRoute({ + method: 'get', + path: '/2/threads/search', + summary: 'Search Threads posts', + description: `Threads' search results page, matching X's \`/2/search\`. \`sort_order\` picks between the Top and Recent tabs; the two rank differently and their cursors are not interchangeable. ${PROXY_ONLY_NOTE}`, + request: { + query: z.object({ + q: z.string().min(1).max(512).openapi({ example: 'meta' }), + sort_order: z.enum(['top', 'recent']).default('top').openapi({ default: 'top' }), + count: z.coerce.number().int().min(1).max(100).default(20).openapi({ default: 20 }), + cursor: z + .string() + .optional() + .openapi({ description: 'Opaque pagination cursor (`cursor.bottom`)' }) + }) + }, + responses: { + 200: { + description: 'Search results page', + content: { 'application/json': { schema: APISearchResultsThreadsSchema } } + }, + 400: { + description: 'Invalid query or cursor', + content: { 'application/json': { schema: ApiQueryErrorSchema } } + }, + 404: { + description: 'Not found', + content: { 'application/json': { schema: APISearchResultsThreadsSchema } } + }, + 500: { + description: 'Upstream error', + content: { 'application/json': { schema: APISearchResultsThreadsSchema } } + }, + 501: { + description: NO_PROXY_DESCRIPTION, + content: { 'application/json': { schema: APISearchResultsThreadsSchema } } + } + } +}); + +export const threadsSearchUsersV2Route = createRoute({ + method: 'get', + path: '/2/threads/search/users', + summary: 'Search Threads accounts', + description: `Ranked account search, filtered to accounts that are actually on Threads. One page, no cursor. ${PROXY_ONLY_NOTE}`, + request: { + query: z.object({ + q: z.string().min(1).max(512).openapi({ example: 'meta' }), + count: z.coerce.number().int().min(1).max(50).default(20).openapi({ default: 20 }) + }) + }, + responses: { + 200: { + description: 'Matching accounts', + content: { 'application/json': { schema: APIUserListResultsSchema } } + }, + 400: { + description: 'Invalid query', + content: { 'application/json': { schema: APIUserListResultsSchema } } + }, + 500: { + description: 'Upstream error', + content: { 'application/json': { schema: APIUserListResultsSchema } } + }, + 501: { + description: NO_PROXY_DESCRIPTION, + content: { 'application/json': { schema: APIUserListResultsSchema } } + } + } +}); + +export const threadsTrendsV2Route = createRoute({ + method: 'get', + path: '/2/threads/trends', + summary: 'Get Threads trending topics', + description: `Threads' trending topics, matching X's \`/2/trends\`. One ranked page, no cursor. ${PROXY_ONLY_NOTE}`, + request: { + query: z.object({ + count: z.coerce.number().int().min(1).max(100).default(20).openapi({ default: 20 }) + }) + }, + responses: { + 200: { + description: 'Trends', + content: { 'application/json': { schema: APITrendsResponseSchema } } + }, + 404: { + description: 'Not found', + content: { 'application/json': { schema: APITrendsResponseSchema } } + }, + 500: { + description: 'Upstream error', + content: { 'application/json': { schema: APITrendsResponseSchema } } + }, + 501: { + description: NO_PROXY_DESCRIPTION, + content: { 'application/json': { schema: APITrendsResponseSchema } } + } + } +}); + +export const threadsTypeaheadV2Route = createRoute({ + method: 'get', + path: '/2/threads/typeahead', + summary: 'Threads search typeahead', + description: `Blended account + keyword suggestions, matching \`/2/instagram/typeahead\`. \`events\` is always empty — Threads has no equivalent. ${PROXY_ONLY_NOTE}`, + request: { + query: z.object({ + query: z.string().min(1).max(512).openapi({ example: 'meta' }), + count: z.coerce.number().int().min(1).max(50).default(20).openapi({ default: 20 }) + }) + }, + responses: { + 200: { + description: 'Suggestions', + content: { 'application/json': { schema: APITypeaheadResponseSchema } } + }, + 400: { + description: 'Invalid query', + content: { 'application/json': { schema: APITypeaheadResponseSchema } } + }, + 500: { + description: 'Upstream error', + content: { 'application/json': { schema: APITypeaheadResponseSchema } } + }, + 501: { + description: NO_PROXY_DESCRIPTION, + content: { 'application/json': { schema: APITypeaheadResponseSchema } } + } + } +}); diff --git a/src/providers/twitter/proxy/credentials.ts b/src/providers/twitter/proxy/credentials.ts index a60650b1..c7fa0e57 100644 --- a/src/providers/twitter/proxy/credentials.ts +++ b/src/providers/twitter/proxy/credentials.ts @@ -1,6 +1,7 @@ import type { BlueskyProxyCredentials, CredentialStore, + InstagramCredentials, TwitterCredentials } from '@fxembed/atmosphere/types/proxy-credentials'; @@ -95,7 +96,8 @@ export function hasBlueskyProxyAccounts(): boolean { return (credentialStore?.bluesky?.accounts?.length ?? 0) > 0; } -function shuffleBlueskyAccountsCopy(acc: BlueskyProxyCredentials[]): BlueskyProxyCredentials[] { +/** Fisher-Yates shuffle for spreading load across proxy accounts. */ +function shuffledCopy(acc: T[]): T[] { const copy = [...acc]; for (let i = copy.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); @@ -106,6 +108,21 @@ function shuffleBlueskyAccountsCopy(acc: BlueskyProxyCredentials[]): BlueskyProx return copy; } +function shuffleBlueskyAccountsCopy(acc: BlueskyProxyCredentials[]): BlueskyProxyCredentials[] { + return shuffledCopy(acc); +} + +export function hasInstagramProxyAccounts(): boolean { + return (credentialStore?.instagram?.accounts?.length ?? 0) > 0; +} + +/** Instagram proxy accounts in random order, so no single session absorbs every request. */ +export function getShuffledInstagramAccounts(): InstagramCredentials[] { + const acc = credentialStore?.instagram?.accounts ?? []; + if (!acc.length) return []; + return shuffledCopy(acc); +} + /** Hostname of a Bluesky proxy `service` URL (PDS), lowercased; empty if unparseable. */ export function blueskyProxyServiceHostname(service: string): string { try { @@ -118,7 +135,6 @@ export function blueskyProxyServiceHostname(service: string): string { } /** - * Fisher–Yates shuffle for spreading load across PDS proxy accounts. * When `preferredHostname` matches one or more accounts' service host, those are shuffled first, * then the rest (Discord activity hint path). */ diff --git a/src/realms/atmosphere/router.ts b/src/realms/atmosphere/router.ts index f60f5d5f..c79138b3 100644 --- a/src/realms/atmosphere/router.ts +++ b/src/realms/atmosphere/router.ts @@ -149,7 +149,7 @@ registerOpenApiJsonRoute(atmosphere, '/2/openapi.json', { title: 'FxEmbed Atmosphere API', version: '2.0.0', description: - 'Multi-provider JSON API (X/Twitter, Bluesky, Mastodon/ActivityPub, TikTok, Instagram, Threads). Mastodon routes are under `/2/mastodon/{instance}/…`, TikTok under `/2/tiktok/…`, Instagram under `/2/instagram/…`, Threads under `/2/threads/…`. Twitter (`/2/twitter/…`) and Bluesky (`/2/bluesky/…`) are served by forwarding to the same logic as `api.fxtwitter.com` and `api.fxbsky.app`; use their `/2/openapi.json` for full path and schema documentation.' + 'Multi-provider JSON API (X/Twitter, Bluesky, Mastodon/ActivityPub, TikTok, Instagram, Threads). Mastodon routes are under `/2/mastodon/{instance}/…`, TikTok under `/2/tiktok/…`, Instagram under `/2/instagram/…`, Threads under `/2/threads/…`. Twitter (`/2/twitter/…`) and Bluesky (`/2/bluesky/…`) are served by forwarding to the same logic as `api.fxtwitter.com` and `api.fxbsky.app`; use their `/2/openapi.json` for full path and schema documentation. Instagram routes for likers, follow lists, tagged posts, stories, user search and typeahead need an Instagram account proxy on the deployment and answer `501` without one.' }, servers: Constants.ATMOSPHERE_API_HOST_ROOT ? [ diff --git a/src/types/env.d.ts b/src/types/env.d.ts index c5bf95a1..178cf155 100644 --- a/src/types/env.d.ts +++ b/src/types/env.d.ts @@ -24,6 +24,8 @@ declare namespace NodeJS { PBS_PROXY_DOMAIN_LIST?: string; TWITTER_ROOT?: string; INSTAGRAM_ROOT?: string; + /** Instagram private API origin used by the account proxy. */ + INSTAGRAM_API_ROOT?: string; SENTRY_DSN?: string; RELEASE_NAME?: string; /** Inlined from credentials.enc.json at build (see esbuild.config.mjs). */ diff --git a/src/worker.ts b/src/worker.ts index b45c715c..97d7d55c 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -11,6 +11,10 @@ import { setBlueskyProviderEnv, setBlueskyProxyRuntime } from '@fxembed/atmosphere/providers/bluesky-runtime'; +import { + setInstagramProviderEnv, + setInstagramProxyRuntime +} from '@fxembed/atmosphere/providers/instagram-runtime'; import { setMastodonProviderEnv } from '@fxembed/atmosphere/providers/mastodon-runtime'; import { setTwitterProviderEnv, @@ -34,6 +38,19 @@ setBlueskyProxyRuntime({ blueskyProxyServiceHostname: proxyCreds.blueskyProxyServiceHostname }); +setInstagramProviderEnv({ + webRoot: Constants.INSTAGRAM_ROOT, + apiRoot: Constants.INSTAGRAM_API_ROOT, + friendlyUserAgent: Constants.FRIENDLY_USER_AGENT +}); + +setInstagramProxyRuntime({ + initCredentials: proxyCreds.initCredentials, + hasBundledEncryptedCredentials: proxyCreds.hasBundledEncryptedCredentials, + hasInstagramProxyAccounts: proxyCreds.hasInstagramProxyAccounts, + getShuffledInstagramAccounts: proxyCreds.getShuffledInstagramAccounts +}); + setMastodonProviderEnv({ userAgent: Constants.FRIENDLY_USER_AGENT, mosaicDomainList: Constants.MOSAIC_DOMAIN_LIST, diff --git a/test/helpers/env.ts b/test/helpers/env.ts index 2d043fd5..da7047d4 100644 --- a/test/helpers/env.ts +++ b/test/helpers/env.ts @@ -28,6 +28,7 @@ export const WORKER_TEST_PROCESS_ENV = { SENTRY_DSN: '', TWITTER_ROOT: 'https://x.com', INSTAGRAM_ROOT: 'https://www.instagram.com', + INSTAGRAM_API_ROOT: 'https://i.instagram.com', ENCRYPTED_CREDENTIALS: '', CREDENTIALS_IV: '' } as const satisfies Record; diff --git a/test/instagram.accountProxy.test.ts b/test/instagram.accountProxy.test.ts new file mode 100644 index 00000000..a4f9d0b7 --- /dev/null +++ b/test/instagram.accountProxy.test.ts @@ -0,0 +1,219 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + hasInstagramAccountProxy, + instagramPrivateApiRequest, + instagramProxyHeaders, + resolveInstagramAccounts +} from '@fxembed/atmosphere/providers/instagram/account-proxy'; +import { + setInstagramProviderEnv, + setInstagramProxyRuntime +} from '@fxembed/atmosphere/providers/instagram-runtime'; +import { + INSTAGRAM_ANDROID_APP_ID, + INSTAGRAM_ANDROID_CAPABILITIES, + INSTAGRAM_WEB_APP_ID +} from '@fxembed/atmosphere/providers/instagram/constants'; +import type { InstagramCredentials } from '@fxembed/atmosphere/types/proxy-credentials'; + +const webAccount: InstagramCredentials = { + sessionId: 'web-session', + userId: '1234', + csrfToken: 'csrf-web', + mid: 'MID', + username: 'web_account' +}; + +const androidAccount: InstagramCredentials = { + sessionId: 'android-session', + userId: '5678', + androidDeviceId: 'android-0123456789abcdef', + username: 'android_account', + platform: 'android' +}; + +/** Registers a proxy runtime that hands back exactly `accounts`, in the given order. */ +function installProxyRuntime(accounts: InstagramCredentials[], hasBundle = true) { + setInstagramProxyRuntime({ + initCredentials: async () => {}, + hasBundledEncryptedCredentials: () => hasBundle, + hasInstagramProxyAccounts: () => accounts.length > 0, + getShuffledInstagramAccounts: () => accounts + }); +} + +/** Restores the package default (no proxy), so other test files see a logged-out world. */ +function clearProxyRuntime() { + setInstagramProxyRuntime({ + initCredentials: async () => {}, + hasBundledEncryptedCredentials: () => false, + hasInstagramProxyAccounts: () => false, + getShuffledInstagramAccounts: () => [] + }); +} + +describe('instagram account proxy', () => { + beforeEach(() => { + setInstagramProviderEnv({ apiRoot: 'https://i.instagram.com' }); + }); + + afterEach(() => { + clearProxyRuntime(); + vi.unstubAllGlobals(); + }); + + it('reports no proxy without a credential key or without a bundled blob', () => { + installProxyRuntime([webAccount]); + expect(hasInstagramAccountProxy(undefined)).toBe(false); + expect(hasInstagramAccountProxy({ credentialKey: ' ' })).toBe(false); + expect(hasInstagramAccountProxy({ credentialKey: 'key' })).toBe(true); + + installProxyRuntime([webAccount], false); + expect(hasInstagramAccountProxy({ credentialKey: 'key' })).toBe(false); + }); + + it('drops accounts with no sessionid', async () => { + installProxyRuntime([{ sessionId: '' }, webAccount]); + const accounts = await resolveInstagramAccounts({ credentialKey: 'key' }); + expect(accounts).toEqual([webAccount]); + }); + + it('sends the web fingerprint for web accounts and the Android one for android accounts', () => { + const web = instagramProxyHeaders(webAccount); + expect(web['X-IG-App-ID']).toBe(INSTAGRAM_WEB_APP_ID); + expect(web['X-IG-Capabilities']).toBe(INSTAGRAM_ANDROID_CAPABILITIES); + expect(web['User-Agent']).toContain('Mozilla/5.0'); + expect(web['X-CSRFToken']).toBe('csrf-web'); + expect(web['Cookie']).toBe( + 'sessionid=web-session; ds_user_id=1234; csrftoken=csrf-web; mid=MID' + ); + expect(web['X-IG-Device-ID']).toBeUndefined(); + + const android = instagramProxyHeaders(androidAccount); + expect(android['X-IG-App-ID']).toBe(INSTAGRAM_ANDROID_APP_ID); + expect(android['User-Agent']).toMatch(/^Instagram \d+\.[\d.]+ Android \(/); + expect(android['X-IG-Device-ID']).toBe('android-0123456789abcdef'); + // Browser-only headers must not leak onto an app-fingerprinted request. + expect(android['Sec-Fetch-Mode']).toBeUndefined(); + expect(android['Origin']).toBeUndefined(); + }); + + it('returns status 0 when no proxy is configured so callers fall back to logged-out paths', async () => { + clearProxyRuntime(); + const fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + const res = await instagramPrivateApiRequest('/users/x/usernameinfo/', { + credentialKey: 'key' + }); + expect(res).toEqual({ ok: false, status: 0, json: null }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('builds the v1 URL with query params and returns parsed JSON', async () => { + installProxyRuntime([webAccount]); + const seen: string[] = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string) => { + seen.push(url); + return new Response(JSON.stringify({ status: 'ok' }), { status: 200 }); + }) + ); + const res = await instagramPrivateApiRequest( + '/friendships/173560420/followers/', + { credentialKey: 'key' }, + { query: { count: 20, max_id: undefined, search_surface: 'follow_list_page' } } + ); + expect(res.ok).toBe(true); + expect(res.json).toEqual({ status: 'ok' }); + expect(seen[0]).toBe( + 'https://i.instagram.com/api/v1/friendships/173560420/followers/?count=20&search_surface=follow_list_page' + ); + }); + + it('rotates to the next account on 401 and reports the account that answered', async () => { + installProxyRuntime([webAccount, androidAccount]); + const cookies: (string | null)[] = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (_url: string, init: RequestInit) => { + const cookie = (init.headers as Record)['Cookie']; + cookies.push(cookie); + if (cookie.includes('web-session')) { + return new Response('nope', { status: 401 }); + } + return new Response(JSON.stringify({ user: { pk: 1 } }), { status: 200 }); + }) + ); + const res = await instagramPrivateApiRequest('/users/x/usernameinfo/', { + credentialKey: 'key' + }); + expect(res.ok).toBe(true); + expect(res.accountUsed).toBe('android_account'); + expect(cookies).toHaveLength(2); + }); + + it('does not rotate on a 404 — the resource is missing, not the session', async () => { + installProxyRuntime([webAccount, androidAccount]); + const fetchSpy = vi.fn(async () => new Response('{}', { status: 404 })); + vi.stubGlobal('fetch', fetchSpy); + const res = await instagramPrivateApiRequest('/media/1/info/', { credentialKey: 'key' }); + expect(res.ok).toBe(false); + expect(res.status).toBe(404); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('treats an HTML login page as a dead session and tries the next account', async () => { + installProxyRuntime([webAccount, androidAccount]); + const fetchSpy = vi.fn(async (_url: string, init: RequestInit) => { + const cookie = (init.headers as Record)['Cookie']; + if (cookie.includes('web-session')) { + return new Response('login', { status: 200 }); + } + return new Response(JSON.stringify({ items: [] }), { status: 200 }); + }); + vi.stubGlobal('fetch', fetchSpy); + const res = await instagramPrivateApiRequest('/media/1/info/', { credentialKey: 'key' }); + expect(res.ok).toBe(true); + expect(res.accountUsed).toBe('android_account'); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('treats a 200 `status: fail` body as a failure worth rotating past', async () => { + installProxyRuntime([webAccount, androidAccount]); + const fetchSpy = vi.fn(async (_url: string, init: RequestInit) => { + const cookie = (init.headers as Record)['Cookie']; + if (cookie.includes('web-session')) { + return new Response(JSON.stringify({ status: 'fail', message: 'checkpoint_required' }), { + status: 200 + }); + } + return new Response(JSON.stringify({ status: 'ok', items: [] }), { status: 200 }); + }); + vi.stubGlobal('fetch', fetchSpy); + const res = await instagramPrivateApiRequest('/media/1/info/', { credentialKey: 'key' }); + expect(res.ok).toBe(true); + expect(res.json).toEqual({ status: 'ok', items: [] }); + expect(res.accountUsed).toBe('android_account'); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('reports 502 when every account answers `status: fail`', async () => { + installProxyRuntime([webAccount]); + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response(JSON.stringify({ status: 'fail', message: 'feedback_required' }), { + status: 200 + }) + ) + ); + const res = await instagramPrivateApiRequest('/friendships/1/followers/', { + credentialKey: 'key' + }); + expect(res.ok).toBe(false); + expect(res.status).toBe(502); + expect(res.json).toEqual({ status: 'fail', message: 'feedback_required' }); + }); +}); diff --git a/test/instagram.atmosphereRoutes.test.ts b/test/instagram.atmosphereRoutes.test.ts new file mode 100644 index 00000000..4da16e94 --- /dev/null +++ b/test/instagram.atmosphereRoutes.test.ts @@ -0,0 +1,82 @@ +import { expect, test } from 'vitest'; +import { app } from '../src/worker'; +import { botHeaders } from './helpers/data'; +import harness from './helpers/harness'; + +const ATMOSPHERE = 'https://api.atmosphere.tools'; + +const get = (path: string) => + app.request(new Request(`${ATMOSPHERE}${path}`, { headers: botHeaders }), undefined, harness); + +/** + * The proxy-gated Instagram routes need a logged-in session. Tests run with no `CREDENTIAL_KEY`, so + * each must answer 501 — an empty 200 would read as "this account has no followers/likers". + */ +const PROXY_ONLY_PATHS = [ + '/2/instagram/status/DXeh-kYiIge/likes', + '/2/instagram/profile/cristiano/followers', + '/2/instagram/profile/cristiano/following', + '/2/instagram/profile/cristiano/tagged', + '/2/instagram/profile/cristiano/stories', + '/2/instagram/search/users?query=cristiano', + '/2/instagram/typeahead?query=cristiano' +]; + +test.each(PROXY_ONLY_PATHS)('%s reports 501 with no Instagram account proxy', async path => { + const res = await get(path); + expect(res.status).toBe(501); + const body = (await res.json()) as { code: number }; + expect(body.code).toBe(501); +}); + +test('proxy-gated list routes still return a well-formed envelope', async () => { + const res = await get('/2/instagram/profile/cristiano/followers'); + const body = (await res.json()) as { + code: number; + results: unknown[]; + cursor: { top: string | null; bottom: string | null }; + }; + expect(body.results).toEqual([]); + expect(body.cursor).toEqual({ top: null, bottom: null }); +}); + +test('typeahead echoes the query back even when unavailable', async () => { + const res = await get('/2/instagram/typeahead?query=cristiano'); + const body = (await res.json()) as { query: string; users: unknown[]; events: unknown[] }; + expect(body.query).toBe('cristiano'); + expect(body.users).toEqual([]); + expect(body.events).toEqual([]); +}); + +test('search/users rejects a missing query before touching Instagram', async () => { + const res = await get('/2/instagram/search/users'); + expect(res.status).toBe(400); +}); + +test('Atmosphere OpenAPI documents the new Instagram routes', async () => { + const res = await get('/2/openapi.json'); + expect(res.status).toBe(200); + const doc = (await res.json()) as { paths: Record> }; + for (const path of [ + '/2/instagram/status/{id}', + '/2/instagram/status/{id}/likes', + '/2/instagram/conversation/{id}', + '/2/instagram/profile/{username}', + '/2/instagram/profile/{username}/statuses', + '/2/instagram/profile/{username}/videos', + '/2/instagram/profile/{username}/followers', + '/2/instagram/profile/{username}/following', + '/2/instagram/profile/{username}/tagged', + '/2/instagram/profile/{username}/stories', + '/2/instagram/search/users', + '/2/instagram/typeahead' + ]) { + expect(doc.paths[path], `missing OpenAPI path ${path}`).toBeDefined(); + } + + // The 501 is part of the contract, not an undocumented surprise. + const followers = doc.paths['/2/instagram/profile/{username}/followers'] as { + get: { responses: Record }; + }; + expect(followers.get.responses['501']).toBeDefined(); +}); diff --git a/test/instagram.cursors.test.ts b/test/instagram.cursors.test.ts index b44c9c0b..3cec50a7 100644 --- a/test/instagram.cursors.test.ts +++ b/test/instagram.cursors.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from 'vitest'; import { decodeCommentCursor, + decodeMaxIdCursor, decodeProfileCursor, encodeCommentCursor, + encodeMaxIdCursor, encodeProfileCursor } from '@fxembed/atmosphere/providers/instagram/cursors'; @@ -27,12 +29,67 @@ describe('instagram cursors', () => { shortcode: 'DXeh-kYiIge', sort: 'popular' as const, after: 'AFTER', - count: 10 + count: 10, + src: 'gql' as const }; const enc = encodeCommentCursor(cur); expect(decodeCommentCursor(enc)).toEqual(cur); }); + it('roundtrips a proxy-minted comment cursor and keeps the two sources apart', () => { + const proxyCursor = { + v: 1 as const, + mediaId: '3881689364048676894', + shortcode: 'DXeh-kYiIge', + sort: 'recent' as const, + after: '17900000000000000_0', + count: 20, + src: 'proxy' as const + }; + expect(decodeCommentCursor(encodeCommentCursor(proxyCursor))).toEqual(proxyCursor); + + // Cursors minted before `src` existed came from the logged-out GraphQL path. + const legacy = btoa( + JSON.stringify({ + v: 1, + mediaId: '3881689364048676894', + shortcode: 'DXeh-kYiIge', + sort: 'popular', + after: 'AFTER', + count: 10 + }) + ) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + expect(decodeCommentCursor(legacy)?.src).toBe('gql'); + }); + + it('roundtrips max_id cursor and rejects mismatched kinds', () => { + const cur = { + v: 1 as const, + k: 'followers' as const, + id: '173560420', + u: 'cristiano', + m: '100|abcdef', + c: 20 + }; + expect(decodeMaxIdCursor(encodeMaxIdCursor(cur))).toEqual(cur); + + const badKind = btoa(JSON.stringify({ v: 1, k: 'likers', id: '1', u: 'a', m: 'x', c: 20 })) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + expect(decodeMaxIdCursor(badKind)).toBeNull(); + expect(decodeMaxIdCursor('')).toBeNull(); + // An empty `m` would page from the top forever rather than advancing. + const emptyMax = btoa(JSON.stringify({ v: 1, k: 'feed', id: '1', u: 'a', m: '', c: 20 })) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + expect(decodeMaxIdCursor(emptyMax)).toBeNull(); + }); + it('decodeProfileCursor returns null for bad input', () => { expect(decodeProfileCursor('')).toBeNull(); expect(decodeProfileCursor('not-valid-base64!!!')).toBeNull(); diff --git a/test/instagram.privateProcessor.test.ts b/test/instagram.privateProcessor.test.ts new file mode 100644 index 00000000..983fda73 --- /dev/null +++ b/test/instagram.privateProcessor.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; +import { + mediaItemsFromPrivateFeed, + nextMaxIdFromPrivateResponse, + userFromPrivateRecord, + userFromPrivateUserResponse, + usersFromPrivateList +} from '@fxembed/atmosphere/providers/instagram/private-processor'; + +describe('instagram private API normalizers', () => { + it('maps a full usernameinfo record', () => { + const user = userFromPrivateUserResponse({ + user: { + pk: 173560420, + username: 'cristiano', + full_name: 'Cristiano Ronaldo', + biography: 'SIUUU', + is_verified: true, + is_private: false, + follower_count: 650000000, + following_count: 590, + media_count: 3800, + external_url: 'https://example.com/cr7', + profile_pic_url: 'https://cdn.example/small.jpg', + hd_profile_pic_url_info: { url: 'https://cdn.example/hd.jpg' } + } + }); + expect(user).toMatchObject({ + id: '173560420', + screen_name: 'cristiano', + name: 'Cristiano Ronaldo', + description: 'SIUUU', + followers: 650000000, + following: 590, + statuses: 3800, + media_count: 3800, + protected: false, + url: 'https://www.instagram.com/cristiano/' + }); + // The HD variant is preferred over the small one when Instagram offers both. + expect(user?.avatar_url).toBe('https://cdn.example/hd.jpg'); + expect(user?.verification).toEqual({ verified: true, type: 'individual' }); + expect(user?.website).toEqual({ + url: 'https://example.com/cr7', + display_url: 'example.com/cr7' + }); + }); + + it('maps trimmed follow-list records without inventing counts', () => { + const users = usersFromPrivateList({ + users: [ + { pk: 1, username: 'alpha', full_name: 'Alpha', is_private: true }, + { pk: 2, username: 'beta', is_verified: true }, + { username: 'no_pk' }, + 'garbage' + ] + }); + expect(users).toHaveLength(2); + expect(users[0]).toMatchObject({ + id: '1', + screen_name: 'alpha', + protected: true, + followers: 0 + }); + // Falls back to the handle when Instagram omits full_name. + expect(users[1]).toMatchObject({ id: '2', screen_name: 'beta', name: 'beta' }); + }); + + it('prefers pk_id when Instagram sends a lossy numeric pk', () => { + const user = userFromPrivateRecord({ + pk: 9007199254740993, + pk_id: '9007199254740993', + username: 'bigid' + }); + expect(user?.id).toBe('9007199254740993'); + }); + + it('reads next_max_id across the shapes Instagram uses', () => { + expect(nextMaxIdFromPrivateResponse({ next_max_id: 'abc' })).toBe('abc'); + expect(nextMaxIdFromPrivateResponse({ next_max_id: 12345 })).toBe('12345'); + expect(nextMaxIdFromPrivateResponse({ next_max_id: { next_max_id: 'nested' } })).toBe('nested'); + expect(nextMaxIdFromPrivateResponse({})).toBeNull(); + }); + + it('stops paginating when Instagram says the list is exhausted', () => { + // Instagram echoes a cursor back on the last page; the flags are what actually end it. + expect(nextMaxIdFromPrivateResponse({ next_max_id: 'abc', more_available: false })).toBeNull(); + expect(nextMaxIdFromPrivateResponse({ next_max_id: 'abc', big_list: false })).toBeNull(); + expect( + nextMaxIdFromPrivateResponse({ next_max_id: 'abc', has_more_comments: false }) + ).toBeNull(); + }); + + it('unwraps the { media } entries the tagged feed returns', () => { + const items = mediaItemsFromPrivateFeed({ + items: [{ media: { code: 'AAA' } }, { code: 'BBB' }, null, { media: [1, 2] }] + }); + expect(items.map(i => i.code)).toEqual(['AAA', 'BBB', undefined]); + }); +}); diff --git a/test/instagram.proxiedPost.test.ts b/test/instagram.proxiedPost.test.ts new file mode 100644 index 00000000..020ba052 --- /dev/null +++ b/test/instagram.proxiedPost.test.ts @@ -0,0 +1,263 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { setInstagramProxyRuntime } from '@fxembed/atmosphere/providers/instagram-runtime'; +import { constructInstagramPost } from '@fxembed/atmosphere/providers/instagram/post'; +import { constructInstagramConversation } from '@fxembed/atmosphere/providers/instagram/conversation'; +import { decodeCommentCursor } from '@fxembed/atmosphere/providers/instagram/cursors'; +import { instagramShortcodeToPk } from '@fxembed/atmosphere/providers/instagram/shortcode'; + +const SHORTCODE = 'DXeh-kYiIge'; +const MEDIA_PK = String(instagramShortcodeToPk(SHORTCODE)); +const credentialKey = 'test-key'; + +const mediaItem = { + code: SHORTCODE, + pk: `${MEDIA_PK}_173560420`, + media_type: 2, + taken_at: 1770000000, + like_count: 1000, + comment_count: 25, + caption: { text: 'proxied caption' }, + original_width: 1080, + original_height: 1920, + video_duration: 12.5, + video_versions: [{ url: 'https://cdn.example/hi.mp4', width: 1080, height: 1920, type: 101 }], + image_versions2: { + candidates: [{ url: 'https://cdn.example/thumb.jpg', width: 1080, height: 1920 }] + }, + user: { pk: 173560420, username: 'cristiano', full_name: 'Cristiano Ronaldo', is_verified: true } +}; + +function installProxy() { + setInstagramProxyRuntime({ + initCredentials: async () => {}, + hasBundledEncryptedCredentials: () => true, + hasInstagramProxyAccounts: () => true, + getShuffledInstagramAccounts: () => [{ sessionId: 'session', username: 'proxy_account' }] + }); +} + +function clearProxy() { + setInstagramProxyRuntime({ + initCredentials: async () => {}, + hasBundledEncryptedCredentials: () => false, + hasInstagramProxyAccounts: () => false, + getShuffledInstagramAccounts: () => [] + }); +} + +function stubApi(routes: Record) { + const requested: string[] = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: string) => { + const url = new URL(input); + requested.push(url.pathname + url.search); + for (const [prefix, body] of Object.entries(routes)) { + if (url.pathname.startsWith(prefix)) { + return new Response(JSON.stringify(body), { status: 200 }); + } + } + return new Response('{}', { status: 404 }); + }) + ); + return requested; +} + +describe('Instagram post and conversation through the account proxy', () => { + afterEach(() => { + clearProxy(); + vi.unstubAllGlobals(); + }); + + it('fetches a post from media/{pk}/info/ instead of scraping the logged-out page', async () => { + installProxy(); + const requested = stubApi({ + [`/api/v1/media/${MEDIA_PK}/info/`]: { items: [mediaItem] } + }); + + const thread = await constructInstagramPost(SHORTCODE, 'FxEmbedTest/1.0', { credentialKey }); + expect(thread.code).toBe(200); + expect(thread.status).toMatchObject({ + id: SHORTCODE, + text: 'proxied caption', + likes: 1000, + replies: 25 + }); + expect(thread.status?.media?.videos?.[0]).toMatchObject({ + url: 'https://cdn.example/hi.mp4', + width: 1080, + height: 1920, + duration: 12.5 + }); + expect(thread.author?.screen_name).toBe('cristiano'); + // One request, and no www.instagram.com HTML scrape behind it. + expect(requested).toEqual([`/api/v1/media/${MEDIA_PK}/info/`]); + }); + + it('falls through to the logged-out path when the proxy account gets a 404', async () => { + installProxy(); + // A poster who has blocked the proxy account 404s `media/{pk}/info/` for a post that is + // perfectly visible logged-out, so a proxy 404 must not end the lookup. + const requested = stubApi({}); + const thread = await constructInstagramPost(SHORTCODE, 'FxEmbedTest/1.0', { credentialKey }); + expect(thread.code).not.toBe(200); + expect(requested[0]).toBe(`/api/v1/media/${MEDIA_PK}/info/`); + expect(requested.some(u => !u.startsWith('/api/v1/'))).toBe(true); + }); + + it('pages comments through media/{pk}/comments/ and mints a proxy cursor', async () => { + installProxy(); + const requested = stubApi({ + [`/api/v1/media/${MEDIA_PK}/info/`]: { items: [mediaItem] }, + [`/api/v1/media/${MEDIA_PK}/comments/`]: { + comments: [ + { + pk: '18000000000000001', + text: 'first', + created_at: 1770000100, + comment_like_count: 3, + user: { pk: 99, username: 'fan', full_name: 'A Fan' } + } + ], + next_max_id: 'COMMENTS2', + has_more_comments: true + } + }); + + const result = await constructInstagramConversation(SHORTCODE, { + cursor: null, + count: 20, + sortOrder: 'popular', + userAgent: 'FxEmbedTest/1.0', + credentialKey + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.code).toBe(200); + expect(result.data.replies).toHaveLength(1); + expect(result.data.replies?.[0]).toMatchObject({ + id: '18000000000000001', + text: 'first', + likes: 3, + parent_id: SHORTCODE + }); + + const cursor = decodeCommentCursor(result.data.cursor?.bottom ?? ''); + expect(cursor).toMatchObject({ src: 'proxy', mediaId: MEDIA_PK, after: 'COMMENTS2' }); + expect(requested.some(u => u.includes(`/api/v1/media/${MEDIA_PK}/comments/`))).toBe(true); + + const page2 = await constructInstagramConversation(SHORTCODE, { + cursor: result.data.cursor?.bottom ?? null, + count: 20, + sortOrder: 'popular', + userAgent: 'FxEmbedTest/1.0', + credentialKey + }); + expect(page2.ok).toBe(true); + expect(requested.some(u => u.includes('max_id=COMMENTS2'))).toBe(true); + }); + + it('asks the comments API for count and surfaces the next comment on the following page', async () => { + installProxy(); + const comments = [ + { + pk: '18000000000000001', + text: 'first', + created_at: 1770000100, + user: { pk: 99, username: 'fan', full_name: 'A Fan' } + }, + { + pk: '18000000000000002', + text: 'second', + created_at: 1770000200, + user: { pk: 98, username: 'other', full_name: 'Someone Else' } + } + ]; + const requested: string[] = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: string) => { + const url = new URL(input); + requested.push(url.pathname + url.search); + if (url.pathname.startsWith(`/api/v1/media/${MEDIA_PK}/info/`)) { + return new Response(JSON.stringify({ items: [mediaItem] }), { status: 200 }); + } + if (url.pathname.startsWith(`/api/v1/media/${MEDIA_PK}/comments/`)) { + const count = Number(url.searchParams.get('count') ?? 20); + const maxId = url.searchParams.get('max_id'); + const start = maxId === 'C2' ? 1 : 0; + const page = comments.slice(start, start + count); + const hasMore = start + count < comments.length; + return new Response( + JSON.stringify({ + comments: page, + next_max_id: hasMore ? 'C2' : undefined, + has_more_comments: hasMore + }), + { status: 200 } + ); + } + return new Response('{}', { status: 404 }); + }) + ); + + const page1 = await constructInstagramConversation(SHORTCODE, { + cursor: null, + count: 1, + sortOrder: 'popular', + userAgent: 'FxEmbedTest/1.0', + credentialKey + }); + expect(page1.ok).toBe(true); + if (!page1.ok) return; + expect(page1.data.replies?.map(r => r.text)).toEqual(['first']); + expect(requested.some(u => u.includes('/comments/') && u.includes('count=1'))).toBe(true); + + const page2 = await constructInstagramConversation(SHORTCODE, { + cursor: page1.data.cursor?.bottom ?? null, + count: 1, + sortOrder: 'popular', + userAgent: 'FxEmbedTest/1.0', + credentialKey + }); + expect(page2.ok).toBe(true); + if (!page2.ok) return; + expect(page2.data.replies?.map(r => r.text)).toEqual(['second']); + expect(requested.some(u => u.includes('max_id=C2'))).toBe(true); + }); + + it('refuses a GraphQL cursor on the proxy path rather than serving the wrong page', async () => { + installProxy(); + stubApi({ + [`/api/v1/media/${MEDIA_PK}/info/`]: { items: [mediaItem] }, + [`/api/v1/media/${MEDIA_PK}/comments/`]: { comments: [] } + }); + // A cursor minted by the logged-out GraphQL path carries `src: 'gql'`; its `after` value is + // meaningless to the private API. + const gqlCursor = btoa( + JSON.stringify({ + v: 1, + mediaId: MEDIA_PK, + shortcode: SHORTCODE, + sort: 'popular', + after: 'QVFB...', + count: 20, + src: 'gql' + }) + ) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + + const result = await constructInstagramConversation(SHORTCODE, { + cursor: gqlCursor, + count: 20, + sortOrder: 'popular', + userAgent: 'FxEmbedTest/1.0', + credentialKey + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.message).toBe('Invalid cursor'); + }); +}); diff --git a/test/instagram.proxiedSurfaces.test.ts b/test/instagram.proxiedSurfaces.test.ts new file mode 100644 index 00000000..3e718371 --- /dev/null +++ b/test/instagram.proxiedSurfaces.test.ts @@ -0,0 +1,281 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { setInstagramProxyRuntime } from '@fxembed/atmosphere/providers/instagram-runtime'; +import { constructInstagramRelationshipList } from '@fxembed/atmosphere/providers/instagram/relationships'; +import { constructInstagramProfileTagged } from '@fxembed/atmosphere/providers/instagram/tagged'; +import { constructInstagramProfileStories } from '@fxembed/atmosphere/providers/instagram/stories'; +import { + constructInstagramTypeahead, + constructInstagramUserSearch +} from '@fxembed/atmosphere/providers/instagram/search'; +import { decodeMaxIdCursor } from '@fxembed/atmosphere/providers/instagram/cursors'; + +const ctx = { credentialKey: 'test-key', userAgent: 'FxEmbedTest/1.0' }; + +function installProxy() { + setInstagramProxyRuntime({ + initCredentials: async () => {}, + hasBundledEncryptedCredentials: () => true, + hasInstagramProxyAccounts: () => true, + getShuffledInstagramAccounts: () => [{ sessionId: 'session', username: 'proxy_account' }] + }); +} + +function clearProxy() { + setInstagramProxyRuntime({ + initCredentials: async () => {}, + hasBundledEncryptedCredentials: () => false, + hasInstagramProxyAccounts: () => false, + getShuffledInstagramAccounts: () => [] + }); +} + +/** Routes each requested v1 path to a canned JSON body, and records the URLs that were hit. */ +function stubApi(routes: Record) { + const requested: string[] = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: string) => { + const url = new URL(input); + requested.push(url.pathname + url.search); + for (const [prefix, body] of Object.entries(routes)) { + if (url.pathname.startsWith(prefix)) { + return new Response(JSON.stringify(body), { status: 200 }); + } + } + return new Response('{}', { status: 404 }); + }) + ); + return requested; +} + +const cristiano = { + user: { pk: 173560420, username: 'cristiano', full_name: 'Cristiano Ronaldo' } +}; + +describe('proxied Instagram surfaces', () => { + afterEach(() => { + clearProxy(); + vi.unstubAllGlobals(); + }); + + it('resolves a handle, lists followers and mints a resumable cursor', async () => { + installProxy(); + const requested = stubApi({ + '/api/v1/users/cristiano/usernameinfo/': cristiano, + '/api/v1/friendships/173560420/followers/': { + users: [{ pk: 1, username: 'alpha' }], + next_max_id: 'PAGE2', + big_list: true + } + }); + + const page = await constructInstagramRelationshipList('cristiano', 'followers', { + count: 20, + cursor: null, + ctx + }); + expect(page.code).toBe(200); + expect(page.results.map(u => u.screen_name)).toEqual(['alpha']); + expect(requested[0]).toContain('/api/v1/users/cristiano/usernameinfo/'); + expect(requested[1]).toContain('/api/v1/friendships/173560420/followers/'); + + const cursor = decodeMaxIdCursor(page.cursor.bottom ?? ''); + expect(cursor).toMatchObject({ k: 'followers', id: '173560420', u: 'cristiano', m: 'PAGE2' }); + + // Page two carries the user id in the cursor, so it skips the profile lookup entirely. + const page2 = await constructInstagramRelationshipList('cristiano', 'followers', { + count: 20, + cursor: page.cursor.bottom, + ctx + }); + expect(page2.code).toBe(200); + expect(requested[2]).toContain('max_id=PAGE2'); + expect(requested.filter(u => u.includes('usernameinfo'))).toHaveLength(1); + }); + + it('stops paginating followers when Instagram closes the list', async () => { + installProxy(); + stubApi({ + '/api/v1/users/cristiano/usernameinfo/': cristiano, + '/api/v1/friendships/173560420/followers/': { + users: [{ pk: 1, username: 'alpha' }], + next_max_id: 'IGNORED', + big_list: false + } + }); + const page = await constructInstagramRelationshipList('cristiano', 'followers', { + count: 20, + cursor: null, + ctx + }); + expect(page.cursor.bottom).toBeNull(); + }); + + it('rejects a cursor minted for a different list or account', async () => { + installProxy(); + stubApi({ + '/api/v1/users/cristiano/usernameinfo/': cristiano, + '/api/v1/friendships/173560420/followers/': { users: [], next_max_id: 'P2', big_list: true } + }); + const page = await constructInstagramRelationshipList('cristiano', 'followers', { + count: 20, + cursor: null, + ctx + }); + const followersCursor = page.cursor.bottom; + + const wrongList = await constructInstagramRelationshipList('cristiano', 'following', { + count: 20, + cursor: followersCursor, + ctx + }); + expect(wrongList.code).toBe(400); + + const wrongUser = await constructInstagramRelationshipList('leomessi', 'followers', { + count: 20, + cursor: followersCursor, + ctx + }); + expect(wrongUser.code).toBe(400); + + // Instagram handles are case-insensitive, so differing case must still resume the same list. + const sameUserOtherCase = await constructInstagramRelationshipList('Cristiano', 'followers', { + count: 20, + cursor: followersCursor, + ctx + }); + expect(sameUserOtherCase.code).toBe(200); + }); + + it('maps the tagged feed, unwrapping its { media } entries', async () => { + installProxy(); + stubApi({ + '/api/v1/users/cristiano/usernameinfo/': cristiano, + '/api/v1/usertags/173560420/feed/': { + items: [ + { + media: { + code: 'DXeh-kYiIge', + media_type: 1, + taken_at: 1770000000, + like_count: 42, + comment_count: 7, + caption: { text: 'tagged post' }, + image_versions2: { + candidates: [{ url: 'https://cdn.example/a.jpg', width: 1080, height: 1080 }] + }, + user: { pk: 1, username: 'someone_else' } + } + } + ], + next_max_id: 'TAG2', + more_available: true + } + }); + const page = await constructInstagramProfileTagged('cristiano', { + count: 20, + cursor: null, + ctx + }); + expect(page.code).toBe(200); + expect(page.results).toHaveLength(1); + expect(page.results[0]).toMatchObject({ + id: 'DXeh-kYiIge', + text: 'tagged post', + likes: 42, + replies: 7, + provider: 'instagram' + }); + // The post's own author wins over the profile whose tagged grid we asked for. + expect(page.results[0].author.screen_name).toBe('someone_else'); + expect(decodeMaxIdCursor(page.cursor.bottom ?? '')).toMatchObject({ k: 'tagged', m: 'TAG2' }); + }); + + it('flattens the story tray and de-duplicates repeated items', async () => { + installProxy(); + stubApi({ + '/api/v1/users/cristiano/usernameinfo/': cristiano, + '/api/v1/feed/reels_media/': { + reels: { + '173560420': { + items: [ + { pk: '1', code: 'STORYONE', media_type: 1, taken_at: 1770000000 }, + { pk: '2', code: 'STORYTWO', media_type: 1, taken_at: 1770000100 } + ] + } + }, + reels_media: [ + { items: [{ pk: '2', code: 'STORYTWO', media_type: 1, taken_at: 1770000100 }] } + ] + } + }); + const page = await constructInstagramProfileStories('cristiano', { ctx }); + expect(page.code).toBe(200); + expect(page.results.map(s => s.id)).toEqual(['STORYONE', 'STORYTWO']); + expect(page.cursor).toEqual({ top: null, bottom: null }); + }); + + it('maps user search results and caps them at count', async () => { + installProxy(); + stubApi({ + '/api/v1/users/search/': { + users: [ + { pk: 1, username: 'alpha' }, + { pk: 2, username: 'beta' }, + { pk: 3, username: 'gamma' } + ] + } + }); + const res = await constructInstagramUserSearch('al', { count: 2, ctx }); + expect(res.code).toBe(200); + expect(res.results.map(u => u.screen_name)).toEqual(['alpha', 'beta']); + expect(res.cursor.bottom).toBeNull(); + }); + + it('splits typeahead into users and topics, tagging hashtags and places', async () => { + installProxy(); + stubApi({ + '/api/v1/fbsearch/ig_typeahead/': { + list: [ + { user: { pk: 1, username: 'alpha', full_name: 'Alpha' } }, + { hashtag: { name: 'football', formatted_media_count: '12M' } }, + { place: { location: { name: 'Old Trafford', city: 'Manchester' } } }, + { unknown_entry: true } + ] + } + }); + const res = await constructInstagramTypeahead('al', { ctx }); + expect(res.code).toBe(200); + expect(res.query).toBe('al'); + expect(res.users.map(u => u.screen_name)).toEqual(['alpha']); + expect(res.topics).toEqual([ + { + topic: '#football', + result_context: { + display_string: '12M posts', + redirect_url: 'https://www.instagram.com/explore/tags/football/', + types: [{ type: 'hashtag' }] + } + }, + { + topic: 'Old Trafford', + result_context: { display_string: 'Manchester', types: [{ type: 'place' }] } + } + ]); + expect(res.num_results).toBe(3); + expect(res.events).toEqual([]); + }); + + it('reports 501 rather than an empty list when no proxy is configured', async () => { + clearProxy(); + const fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + const followers = await constructInstagramRelationshipList('cristiano', 'followers', { + count: 20, + cursor: null, + ctx + }); + expect(followers.code).toBe(501); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/test/threads.accountProxy.test.ts b/test/threads.accountProxy.test.ts new file mode 100644 index 00000000..576b3575 --- /dev/null +++ b/test/threads.accountProxy.test.ts @@ -0,0 +1,275 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + hasThreadsAccountProxy, + threadsPrivateApiRequest, + threadsProxyHeaders +} from '@fxembed/atmosphere/providers/threads/account-proxy'; +import { + setInstagramProviderEnv, + setInstagramProxyRuntime +} from '@fxembed/atmosphere/providers/instagram-runtime'; +import { + THREADS_ANDROID_APP_ID, + THREADS_ANDROID_CAPABILITIES, + THREADS_ANDROID_USER_AGENT, + THREADS_ORIGIN +} from '@fxembed/atmosphere/providers/threads/constants'; +import { INSTAGRAM_ANDROID_APP_ID } from '@fxembed/atmosphere/providers/instagram/constants'; +import type { InstagramCredentials } from '@fxembed/atmosphere/types/proxy-credentials'; + +const webAccount: InstagramCredentials = { + sessionId: 'web-session', + userId: '1234', + csrfToken: 'csrf-web', + mid: 'MID', + username: 'web_account' +}; + +const androidAccount: InstagramCredentials = { + sessionId: 'android-session', + userId: '5678', + androidDeviceId: 'android-0123456789abcdef', + username: 'android_account', + platform: 'android' +}; + +function installProxyRuntime(accounts: InstagramCredentials[], hasBundle = true) { + setInstagramProxyRuntime({ + initCredentials: async () => {}, + hasBundledEncryptedCredentials: () => hasBundle, + hasInstagramProxyAccounts: () => accounts.length > 0, + getShuffledInstagramAccounts: () => accounts + }); +} + +function clearProxyRuntime() { + setInstagramProxyRuntime({ + initCredentials: async () => {}, + hasBundledEncryptedCredentials: () => false, + hasInstagramProxyAccounts: () => false, + getShuffledInstagramAccounts: () => [] + }); +} + +describe('threads account proxy', () => { + beforeEach(() => { + setInstagramProviderEnv({ apiRoot: 'https://i.instagram.com' }); + }); + + afterEach(() => { + clearProxyRuntime(); + vi.unstubAllGlobals(); + }); + + it('reuses the Instagram credential pool', () => { + expect(hasThreadsAccountProxy({ credentialKey: 'key' })).toBe(false); + installProxyRuntime([webAccount]); + expect(hasThreadsAccountProxy({ credentialKey: 'key' })).toBe(true); + expect(hasThreadsAccountProxy({ credentialKey: ' ' })).toBe(false); + }); + + it('presents the Barcelona app id rather than the Instagram one', () => { + const web = threadsProxyHeaders(webAccount); + expect(web['X-IG-App-ID']).toBe(THREADS_ANDROID_APP_ID); + expect(web['X-IG-App-ID']).not.toBe(INSTAGRAM_ANDROID_APP_ID); + expect(web['X-IG-Capabilities']).toBe(THREADS_ANDROID_CAPABILITIES); + expect(web['Origin']).toBe(THREADS_ORIGIN); + expect(web['X-CSRFToken']).toBe('csrf-web'); + expect(web['Cookie']).toContain('sessionid=web-session'); + expect(web['Cookie']).toContain('ds_user_id=1234'); + + const android = threadsProxyHeaders(androidAccount); + expect(android['User-Agent']).toBe(THREADS_ANDROID_USER_AGENT); + expect(android['User-Agent']).toMatch(/^Barcelona /); + expect(android['X-IG-Device-ID']).toBe('android-0123456789abcdef'); + expect(android['Origin']).toBeUndefined(); + }); + + it('fills path templates and sends the rest as query parameters', async () => { + installProxyRuntime([webAccount]); + const seen: string[] = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: string) => { + seen.push(input); + return new Response(JSON.stringify({ status: 'ok' }), { status: 200 }); + }) + ); + + const res = await threadsPrivateApiRequest( + 'text_feed/{user_id}/profile/replies/', + { credentialKey: 'key' }, + { pathParams: { user_id: '99' }, query: { max_id: 'TOKEN', is_app_start: false } } + ); + expect(res.ok).toBe(true); + expect(seen[0]).toBe( + 'https://i.instagram.com/api/v1/text_feed/99/profile/replies/?max_id=TOKEN&is_app_start=false' + ); + }); + + it('reports status 0 with no accounts, so callers can answer 501', async () => { + clearProxyRuntime(); + const res = await threadsPrivateApiRequest('fbsearch/text_app/trends/', { + credentialKey: 'key' + }); + expect(res).toEqual({ ok: false, status: 0, json: null }); + }); + + it('rotates accounts on 429 and on a soft `status: fail` body', async () => { + installProxyRuntime([webAccount, androidAccount]); + const used: string[] = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (_input: string, init: RequestInit) => { + const cookie = String((init.headers as Record)['Cookie']); + used.push(cookie); + if (cookie.includes('web-session')) { + return new Response('rate limited', { status: 429 }); + } + return new Response(JSON.stringify({ status: 'ok', items: [] }), { status: 200 }); + }) + ); + + const res = await threadsPrivateApiRequest('fbsearch/text_app/trends/', { + credentialKey: 'key' + }); + expect(res.ok).toBe(true); + expect(res.accountUsed).toBe('android_account'); + expect(used).toHaveLength(2); + }); + + it('treats a 200 `status: fail` body as a failure worth rotating past', async () => { + installProxyRuntime([webAccount]); + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response(JSON.stringify({ status: 'fail', message: 'feedback_required' }), { + status: 200 + }) + ) + ); + const res = await threadsPrivateApiRequest('fbsearch/text_app/trends/', { + credentialKey: 'key' + }); + expect(res.ok).toBe(false); + expect(res.status).toBe(502); + }); + + it('treats an HTML login page as a dead session and tries the next account', async () => { + installProxyRuntime([webAccount, androidAccount]); + const fetchSpy = vi.fn(async (_url: string, init: RequestInit) => { + const cookie = String((init.headers as Record)['Cookie']); + if (cookie.includes('web-session')) { + return new Response('login', { status: 200 }); + } + return new Response(JSON.stringify({ items: [] }), { status: 200 }); + }); + vi.stubGlobal('fetch', fetchSpy); + const res = await threadsPrivateApiRequest('fbsearch/text_app/trends/', { + credentialKey: 'key' + }); + expect(res.ok).toBe(true); + expect(res.accountUsed).toBe('android_account'); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('keeps the HTTP status when JSON.parse fails and rotates', async () => { + installProxyRuntime([webAccount, androidAccount]); + const fetchSpy = vi.fn(async (_url: string, init: RequestInit) => { + const cookie = String((init.headers as Record)['Cookie']); + if (cookie.includes('web-session')) { + return new Response('{not json', { status: 200 }); + } + return new Response(JSON.stringify({ status: 'ok' }), { status: 200 }); + }); + vi.stubGlobal('fetch', fetchSpy); + const res = await threadsPrivateApiRequest('fbsearch/text_app/trends/', { + credentialKey: 'key' + }); + expect(res.ok).toBe(true); + expect(res.accountUsed).toBe('android_account'); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('reports the HTTP status as last result when malformed JSON is the only response', async () => { + installProxyRuntime([webAccount]); + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('{not json', { status: 200 })) + ); + const res = await threadsPrivateApiRequest('fbsearch/text_app/trends/', { + credentialKey: 'key' + }); + expect(res.ok).toBe(false); + expect(res.status).toBe(200); + expect(res.json).toBeNull(); + expect(res.accountUsed).toBe('web_account'); + }); + + it('rotates when the response body aborts inside the request timeout', async () => { + installProxyRuntime([webAccount, androidAccount]); + const fetchSpy = vi.fn(async (_url: string, init: RequestInit) => { + const cookie = String((init.headers as Record)['Cookie']); + if (cookie.includes('web-session')) { + return { + ok: true, + status: 200, + async text() { + const err = new Error('The operation was aborted'); + err.name = 'AbortError'; + throw err; + } + } as Response; + } + return new Response(JSON.stringify({ status: 'ok', items: [] }), { status: 200 }); + }); + vi.stubGlobal('fetch', fetchSpy); + const res = await threadsPrivateApiRequest('fbsearch/text_app/trends/', { + credentialKey: 'key' + }); + expect(res.ok).toBe(true); + expect(res.accountUsed).toBe('android_account'); + // withTimeout retries the whole fetch+body read 4 times (initial + 3) before rotating. + expect(fetchSpy).toHaveBeenCalledTimes(5); + }); + + it('refuses to follow redirects when sending account cookies', async () => { + installProxyRuntime([webAccount]); + const fetchSpy = vi.fn(async (_url: string, init: RequestInit) => { + expect(init.redirect).toBe('error'); + return new Response(JSON.stringify({ status: 'ok' }), { status: 200 }); + }); + vi.stubGlobal('fetch', fetchSpy); + + const res = await threadsPrivateApiRequest('fbsearch/text_app/trends/', { + credentialKey: 'key' + }); + expect(res.ok).toBe(true); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy.mock.calls[0][1]).toMatchObject({ + method: 'GET', + redirect: 'error' + }); + }); + + it('treats a redirect TypeError as a failed request and rotates', async () => { + installProxyRuntime([webAccount, androidAccount]); + const fetchSpy = vi.fn(async (_url: string, init: RequestInit) => { + expect(init.redirect).toBe('error'); + const cookie = String((init.headers as Record)['Cookie']); + if (cookie.includes('web-session')) { + throw new TypeError('Failed to fetch'); + } + return new Response(JSON.stringify({ status: 'ok', items: [] }), { status: 200 }); + }); + vi.stubGlobal('fetch', fetchSpy); + + const res = await threadsPrivateApiRequest('fbsearch/text_app/trends/', { + credentialKey: 'key' + }); + expect(res.ok).toBe(true); + expect(res.accountUsed).toBe('android_account'); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/test/threads.atmosphereRoutes.test.ts b/test/threads.atmosphereRoutes.test.ts new file mode 100644 index 00000000..09e0d7be --- /dev/null +++ b/test/threads.atmosphereRoutes.test.ts @@ -0,0 +1,85 @@ +import { expect, test } from 'vitest'; +import { app } from '../src/worker'; +import { botHeaders } from './helpers/data'; +import harness from './helpers/harness'; + +const ATMOSPHERE = 'https://api.atmosphere.tools'; + +const get = (path: string) => + app.request(new Request(`${ATMOSPHERE}${path}`, { headers: botHeaders }), undefined, harness); + +/** + * Threads gates these behind a login, and the proxy borrows the Instagram credential pool. Tests + * run with no `CREDENTIAL_KEY`, so each must answer 501 — an empty 200 would read as "this account + * has no replies/followers/likers". + */ +const PROXY_ONLY_PATHS = [ + '/2/threads/status/DXhZAMkljvS/likes', + '/2/threads/profile/zuck/replies', + '/2/threads/profile/zuck/reposts', + '/2/threads/profile/zuck/media', + '/2/threads/profile/zuck/followers', + '/2/threads/profile/zuck/following', + '/2/threads/search?q=meta', + '/2/threads/search/users?q=meta', + '/2/threads/trends', + '/2/threads/typeahead?query=meta' +]; + +test.each(PROXY_ONLY_PATHS)('%s reports 501 with no account proxy', async path => { + const res = await get(path); + expect(res.status).toBe(501); + const body = (await res.json()) as { code: number }; + expect(body.code).toBe(501); +}); + +test('proxy-gated list routes still return a well-formed envelope', async () => { + const res = await get('/2/threads/profile/zuck/replies'); + const body = (await res.json()) as { + results: unknown[]; + cursor: { top: string | null; bottom: string | null }; + }; + expect(body.results).toEqual([]); + expect(body.cursor).toEqual({ top: null, bottom: null }); +}); + +test('trends keeps its timeline_type even when unavailable', async () => { + const res = await get('/2/threads/trends'); + const body = (await res.json()) as { timeline_type: string; trends: unknown[] }; + expect(body.timeline_type).toBe('threads'); + expect(body.trends).toEqual([]); +}); + +test('search rejects a missing query before touching Threads', async () => { + const res = await get('/2/threads/search'); + expect(res.status).toBe(400); +}); + +test('typeahead echoes the query back even when unavailable', async () => { + const res = await get('/2/threads/typeahead?query=meta'); + const body = (await res.json()) as { query: string; users: unknown[]; events: unknown[] }; + expect(body.query).toBe('meta'); + expect(body.users).toEqual([]); + expect(body.events).toEqual([]); +}); + +test('Atmosphere OpenAPI documents the new Threads routes', async () => { + const res = await get('/2/openapi.json'); + expect(res.status).toBe(200); + const doc = (await res.json()) as { paths: Record> }; + for (const path of [ + '/2/threads/status/{id}/likes', + '/2/threads/profile/{username}/replies', + '/2/threads/profile/{username}/reposts', + '/2/threads/profile/{username}/media', + '/2/threads/profile/{username}/followers', + '/2/threads/profile/{username}/following', + '/2/threads/search', + '/2/threads/search/users', + '/2/threads/trends', + '/2/threads/typeahead' + ]) { + expect(doc.paths[path], `${path} missing from OpenAPI`).toBeDefined(); + expect(doc.paths[path]?.get).toBeDefined(); + } +}); diff --git a/test/threads.cursors.test.ts b/test/threads.cursors.test.ts index ea69db8f..78784d86 100644 --- a/test/threads.cursors.test.ts +++ b/test/threads.cursors.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it } from 'vitest'; import { decodeThreadsConversationCursor, decodeThreadsProfileTimelineCursor, + decodeThreadsSearchCursor, encodeThreadsConversationCursor, - encodeThreadsProfileTimelineCursor + encodeThreadsProfileTimelineCursor, + encodeThreadsSearchCursor } from '@fxembed/atmosphere/providers/threads/cursors'; describe('threads conversation cursor', () => { @@ -22,10 +24,27 @@ describe('threads conversation cursor', () => { shortcode: 'DXhZAMkljvS', sort: 'TOP', after: 'opaque-cursor', - count: 20 + count: 20, + // A cursor minted before the proxy existed came from the logged-out connection. + src: 'gql' }); }); + it('keeps proxy and logged-out cursors distinguishable', () => { + const proxy = decodeThreadsConversationCursor( + encodeThreadsConversationCursor({ + v: 1, + postId: '1', + shortcode: 'a', + sort: 'TOP', + after: 'paging-token', + count: 20, + src: 'proxy' + }) + ); + expect(proxy?.src).toBe('proxy'); + }); + it('returns encoded shortcode unchanged (caller must validate mismatch)', () => { const cur = decodeThreadsConversationCursor( encodeThreadsConversationCursor({ @@ -59,3 +78,23 @@ describe('threads profile timeline cursor', () => { }); }); }); + +describe('threads search cursor', () => { + it('round-trips UTF-8 query text', () => { + const payload = { + v: 1 as const, + q: 'café 日本語 🧵', + r: false, + t: 'PAGE2', + rt: 'RANK', + p: 1, + c: 20 + }; + expect(decodeThreadsSearchCursor(encodeThreadsSearchCursor(payload))).toEqual(payload); + }); + + it('returns null for invalid input', () => { + expect(decodeThreadsSearchCursor('not-a-cursor')).toBeNull(); + expect(decodeThreadsSearchCursor('')).toBeNull(); + }); +}); diff --git a/test/threads.proxiedSurfaces.test.ts b/test/threads.proxiedSurfaces.test.ts new file mode 100644 index 00000000..584806cd --- /dev/null +++ b/test/threads.proxiedSurfaces.test.ts @@ -0,0 +1,525 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { setInstagramProxyRuntime } from '@fxembed/atmosphere/providers/instagram-runtime'; +import { constructThreadsProfileTab } from '@fxembed/atmosphere/providers/threads/profile-tabs'; +import { constructThreadsRelationshipList } from '@fxembed/atmosphere/providers/threads/relationships'; +import { constructThreadsStatusLikes } from '@fxembed/atmosphere/providers/threads/likes'; +import { + constructThreadsSearch, + constructThreadsUserSearch +} from '@fxembed/atmosphere/providers/threads/search'; +import { constructThreadsTrends } from '@fxembed/atmosphere/providers/threads/trends'; +import { constructThreadsPost } from '@fxembed/atmosphere/providers/threads/post'; +import { constructThreadsConversation } from '@fxembed/atmosphere/providers/threads/conversation'; +import { constructThreadsProfile } from '@fxembed/atmosphere/providers/threads/profile'; +import { + decodeThreadsSearchCursor, + decodeThreadsTokenCursor +} from '@fxembed/atmosphere/providers/threads/cursors'; + +const ctx = { credentialKey: 'test-key', userAgent: 'FxEmbedTest/1.0' }; + +function installProxy() { + setInstagramProxyRuntime({ + initCredentials: async () => {}, + hasBundledEncryptedCredentials: () => true, + hasInstagramProxyAccounts: () => true, + getShuffledInstagramAccounts: () => [{ sessionId: 'session', username: 'proxy_account' }] + }); +} + +function clearProxy() { + setInstagramProxyRuntime({ + initCredentials: async () => {}, + hasBundledEncryptedCredentials: () => false, + hasInstagramProxyAccounts: () => false, + getShuffledInstagramAccounts: () => [] + }); +} + +/** Routes each requested v1 path to a canned JSON body, and records the URLs that were hit. */ +function stubApi(routes: Record) { + const requested: string[] = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: string) => { + const url = new URL(input); + requested.push(url.pathname + url.search); + for (const [prefix, body] of Object.entries(routes)) { + if (url.pathname.startsWith(prefix)) { + return new Response(JSON.stringify(body), { status: 200 }); + } + } + return new Response('{}', { status: 404 }); + }) + ); + return requested; +} + +const zuck = { user: { pk: 314216, username: 'zuck', full_name: 'Mark Zuckerberg' } }; + +/** One row in the shape `text_feed/…` returns: a thread wrapping its posts. */ +const threadRow = (code: string, text: string) => ({ + id: `${code}_314216`, + thread_items: [ + { + post: { + pk: '1', + code, + taken_at: 1700000000, + like_count: 5, + caption: { text }, + user: { pk: 314216, username: 'zuck', full_name: 'Mark Zuckerberg' } + } + } + ] +}); + +describe('proxied Threads surfaces', () => { + afterEach(() => { + clearProxy(); + vi.unstubAllGlobals(); + }); + + it('answers 501 on every proxy-only surface when no account is configured', async () => { + clearProxy(); + const tab = await constructThreadsProfileTab('zuck', 'replies', { + count: 20, + cursor: null, + ctx + }); + const followers = await constructThreadsRelationshipList('zuck', 'followers', { + count: 20, + cursor: null, + ctx + }); + const likes = await constructThreadsStatusLikes('DXhZAMkljvS', { count: 20, ctx }); + const search = await constructThreadsSearch('meta', { count: 20, cursor: null, ctx }); + const users = await constructThreadsUserSearch('meta', { count: 20, ctx }); + const trends = await constructThreadsTrends({ ctx }); + expect([tab.code, followers.code, likes.code, search.code, users.code, trends.code]).toEqual([ + 501, 501, 501, 501, 501, 501 + ]); + }); + + it('reads the replies tab and mints a resumable cursor', async () => { + installProxy(); + const requested = stubApi({ + '/api/v1/users/zuck/usernameinfo/': zuck, + '/api/v1/text_feed/314216/profile/replies/': { + items: [threadRow('AAA', 'a reply')], + paging_tokens: { downwards: 'PAGE2' }, + has_more: true + } + }); + + const page = await constructThreadsProfileTab('zuck', 'replies', { + count: 20, + cursor: null, + ctx + }); + expect(page.code).toBe(200); + expect(page.results.map(s => s.id)).toEqual(['AAA']); + expect(page.results[0]?.author.screen_name).toBe('zuck'); + expect(requested[0]).toContain('/api/v1/users/zuck/usernameinfo/'); + expect(requested[1]).toContain('/api/v1/text_feed/314216/profile/replies/'); + + const decoded = decodeThreadsTokenCursor(page.cursor.bottom ?? '', 'replies'); + expect(decoded).toMatchObject({ id: '314216', u: 'zuck', t: 'PAGE2' }); + }); + + it('rejects a cursor minted for a different tab or handle', async () => { + installProxy(); + stubApi({ + '/api/v1/users/zuck/usernameinfo/': zuck, + '/api/v1/text_feed/314216/profile/replies/': { + items: [threadRow('AAA', 'a reply')], + paging_tokens: { downwards: 'PAGE2' }, + has_more: true + } + }); + const page = await constructThreadsProfileTab('zuck', 'replies', { + count: 20, + cursor: null, + ctx + }); + const cursor = page.cursor.bottom ?? ''; + + const wrongTab = await constructThreadsProfileTab('zuck', 'reposts', { + count: 20, + cursor, + ctx + }); + expect(wrongTab.code).toBe(400); + + const wrongHandle = await constructThreadsProfileTab('mosseri', 'replies', { + count: 20, + cursor, + ctx + }); + expect(wrongHandle.code).toBe(400); + }); + + it('resumes a profile tab from a cursor without re-resolving the handle', async () => { + installProxy(); + stubApi({ + '/api/v1/users/zuck/usernameinfo/': zuck, + '/api/v1/text_feed/314216/profile/media/': { + items: [threadRow('AAA', 'first')], + paging_tokens: { downwards: 'PAGE2' }, + has_more: true + } + }); + const first = await constructThreadsProfileTab('zuck', 'media', { + count: 20, + cursor: null, + ctx + }); + + const requested = stubApi({ + '/api/v1/text_feed/314216/profile/media/': { + items: [threadRow('BBB', 'second')], + has_more: false + } + }); + const second = await constructThreadsProfileTab('zuck', 'media', { + count: 20, + cursor: first.cursor.bottom, + ctx + }); + expect(second.code).toBe(200); + expect(second.results.map(s => s.id)).toEqual(['BBB']); + expect(second.cursor.bottom).toBeNull(); + expect(requested).toHaveLength(1); + expect(requested[0]).toContain('max_id=PAGE2'); + }); + + it('searches posts on the top tab and pins the tab into the cursor', async () => { + installProxy(); + const requested = stubApi({ + '/api/v1/fbsearch/text_app/serp/': { + media: [threadRow('AAA', 'about meta')], + page_token: 'PAGE2', + rank_token: 'RANK', + has_more: true + } + }); + + const page = await constructThreadsSearch('meta', { count: 20, cursor: null, ctx }); + expect(page.code).toBe(200); + expect(page.results.map(s => s.id)).toEqual(['AAA']); + expect(requested[0]).toContain('search_surface=ig_text_search_serp_top'); + expect(requested[0]).toContain('recent=0'); + + const decoded = decodeThreadsSearchCursor(page.cursor.bottom ?? ''); + expect(decoded).toMatchObject({ q: 'meta', r: false, t: 'PAGE2', rt: 'RANK', p: 1 }); + }); + + it('sends the recent surface for the recent tab and rejects a cursor from another query', async () => { + installProxy(); + const requested = stubApi({ + '/api/v1/fbsearch/text_app/serp/': { + media: [threadRow('AAA', 'about meta')], + has_more: false + } + }); + const page = await constructThreadsSearch('meta', { + count: 20, + cursor: null, + sortOrder: 'recent', + ctx + }); + expect(page.code).toBe(200); + expect(requested[0]).toContain('search_surface=ig_text_search_serp_recent'); + expect(requested[0]).toContain('recent=1'); + expect(page.cursor.bottom).toBeNull(); + + const withOtherQuery = await constructThreadsSearch('threads', { + count: 20, + cursor: 'not-a-cursor-for-this-query', + ctx + }); + expect(withOtherQuery.code).toBe(400); + }); + + it('filters user search down to accounts that are on Threads', async () => { + installProxy(); + stubApi({ + '/api/v1/users/search/': { + users: [ + { pk: 1, username: 'on_threads', is_active_on_text_post_app: true }, + { pk: 2, username: 'instagram_only' }, + { pk: 3, username: 'onboarded', has_onboarded_to_text_post_app: true } + ] + } + }); + const page = await constructThreadsUserSearch('meta', { count: 20, ctx }); + expect(page.code).toBe(200); + expect(page.results.map(u => u.screen_name)).toEqual(['on_threads', 'onboarded']); + expect(page.results[0]?.url).toBe('https://www.threads.com/@on_threads/'); + }); + + it('lists likers of a post by decoding its shortcode', async () => { + installProxy(); + const requested = stubApi({ + '/api/v1/media/': { users: [{ pk: 1, username: 'liker' }] } + }); + const page = await constructThreadsStatusLikes( + 'https://www.threads.com/@zuck/post/DXhZAMkljvS', + { count: 20, ctx } + ); + expect(page.code).toBe(200); + expect(page.results.map(u => u.screen_name)).toEqual(['liker']); + expect(requested[0]).toMatch(/^\/api\/v1\/media\/\d+\/likers\/$/); + }); + + it('lists followers through the shared Instagram graph', async () => { + installProxy(); + const requested = stubApi({ + '/api/v1/users/zuck/usernameinfo/': zuck, + '/api/v1/friendships/314216/followers/': { + users: [{ pk: 1, username: 'alpha' }], + next_max_id: 'PAGE2' + } + }); + const page = await constructThreadsRelationshipList('zuck', 'followers', { + count: 20, + cursor: null, + ctx + }); + expect(page.code).toBe(200); + expect(page.results.map(u => u.screen_name)).toEqual(['alpha']); + expect(requested[1]).toContain('/api/v1/friendships/314216/followers/'); + expect(decodeThreadsTokenCursor(page.cursor.bottom ?? '', 'followers')).toMatchObject({ + t: 'PAGE2' + }); + }); + + it('reads a post through single_thread when a proxy is available', async () => { + installProxy(); + const requested = stubApi({ + '/api/v1/text_feed/': { + containing_thread: { + thread_items: [ + { post: { ...threadRow('AAA', 'first in chain').thread_items[0]!.post } }, + { + post: { + pk: '2', + code: 'DXhZAMkljvS', + taken_at: 1700000001, + caption: { text: 'the focal post' }, + user: { pk: 314216, username: 'zuck' } + } + } + ] + } + } + }); + + const res = await constructThreadsPost('DXhZAMkljvS', 'FxEmbedTest/1.0', ctx); + expect(res.code).toBe(200); + expect(res.status?.type).toBe('status'); + expect(res.status && 'id' in res.status ? res.status.id : null).toBe('DXhZAMkljvS'); + expect(res.thread?.map(s => ('id' in s ? s.id : null))).toEqual(['AAA']); + expect(requested[0]).toMatch(/^\/api\/v1\/text_feed\/\d+\/single_thread\//); + }); + + it('reads replies through the proxy and mints a proxy-tagged cursor', async () => { + installProxy(); + const requested = stubApi({ + '/api/v1/text_feed/': { + containing_thread: { + thread_items: [ + { + post: { + pk: '1', + code: 'DXhZAMkljvS', + taken_at: 1700000000, + caption: { text: 'focal' }, + user: { pk: 314216, username: 'zuck' } + } + } + ] + }, + reply_threads: [threadRow('RRR', 'a reply')], + paging_tokens: { downwards: 'REPLIES2' }, + has_more: true + } + }); + + const res = await constructThreadsConversation('DXhZAMkljvS', { + cursor: null, + count: 20, + sortOrder: 'top', + ctx + }); + expect(res.ok).toBe(true); + if (!res.ok) return; + expect(res.data.code).toBe(200); + expect(res.data.replies?.map(r => r.id)).toEqual(['RRR']); + expect(requested[0]).toContain('/replies/'); + expect(requested[0]).toContain('sort_order=top'); + }); + + it('asks replies for count and surfaces the next reply on the following page', async () => { + installProxy(); + const focal = { + pk: '1', + code: 'DXhZAMkljvS', + taken_at: 1700000000, + caption: { text: 'focal' }, + user: { pk: 314216, username: 'zuck' } + }; + const replies = [threadRow('AAA', 'first reply'), threadRow('BBB', 'second reply')]; + const requested: string[] = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: string) => { + const url = new URL(input); + requested.push(url.pathname + url.search); + if (!url.pathname.includes('/replies/')) { + return new Response('{}', { status: 404 }); + } + const count = Number(url.searchParams.get('count') ?? 20); + const token = url.searchParams.get('paging_token'); + const start = token === 'R2' ? 1 : 0; + const page = replies.slice(start, start + count); + const hasMore = start + count < replies.length; + return new Response( + JSON.stringify({ + containing_thread: { thread_items: [{ post: focal }] }, + reply_threads: page, + paging_tokens: hasMore ? { downwards: 'R2' } : undefined, + has_more: hasMore + }), + { status: 200 } + ); + }) + ); + + const page1 = await constructThreadsConversation('DXhZAMkljvS', { + cursor: null, + count: 1, + sortOrder: 'top', + ctx + }); + expect(page1.ok).toBe(true); + if (!page1.ok) return; + expect(page1.data.replies?.map(r => r.id)).toEqual(['AAA']); + expect(requested.some(u => u.includes('/replies/') && u.includes('count=1'))).toBe(true); + + const page2 = await constructThreadsConversation('DXhZAMkljvS', { + cursor: page1.data.cursor?.bottom ?? null, + count: 1, + sortOrder: 'top', + ctx + }); + expect(page2.ok).toBe(true); + if (!page2.ok) return; + expect(page2.data.replies?.map(r => r.id)).toEqual(['BBB']); + expect(requested.some(u => u.includes('paging_token=R2'))).toBe(true); + }); + + it('asks the profile feed for count and surfaces the next post on the following page', async () => { + installProxy(); + const items = [threadRow('AAA', 'first'), threadRow('BBB', 'second')]; + const requested: string[] = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: string) => { + const url = new URL(input); + requested.push(url.pathname + url.search); + if (url.pathname.startsWith('/api/v1/users/zuck/usernameinfo/')) { + return new Response(JSON.stringify(zuck), { status: 200 }); + } + if (url.pathname.startsWith('/api/v1/text_feed/314216/profile/replies/')) { + const count = Number(url.searchParams.get('count') ?? 20); + const maxId = url.searchParams.get('max_id'); + const start = maxId === 'PAGE2' ? 1 : 0; + const page = items.slice(start, start + count); + const hasMore = start + count < items.length; + return new Response( + JSON.stringify({ + items: page, + paging_tokens: hasMore ? { downwards: 'PAGE2' } : undefined, + has_more: hasMore + }), + { status: 200 } + ); + } + return new Response('{}', { status: 404 }); + }) + ); + + const page1 = await constructThreadsProfileTab('zuck', 'replies', { + count: 1, + cursor: null, + ctx + }); + expect(page1.code).toBe(200); + expect(page1.results.map(s => s.id)).toEqual(['AAA']); + expect(requested.some(u => u.includes('/profile/replies/') && u.includes('count=1'))).toBe( + true + ); + + const page2 = await constructThreadsProfileTab('zuck', 'replies', { + count: 1, + cursor: page1.cursor.bottom, + ctx + }); + expect(page2.code).toBe(200); + expect(page2.results.map(s => s.id)).toEqual(['BBB']); + expect(requested.some(u => u.includes('max_id=PAGE2'))).toBe(true); + }); + + it('resolves a profile through usernameinfo when a proxy is available', async () => { + installProxy(); + const requested = stubApi({ + '/api/v1/users/zuck/usernameinfo/': { + user: { + pk: 314216, + username: 'zuck', + full_name: 'Mark Zuckerberg', + biography: 'bio', + follower_count: 42, + is_verified: true + } + } + }); + const res = await constructThreadsProfile('zuck', 'FxEmbedTest/1.0', ctx); + expect(res.code).toBe(200); + expect(res.user?.screen_name).toBe('zuck'); + expect(res.user?.followers).toBe(42); + expect(res.user?.url).toBe('https://www.threads.com/@zuck/'); + expect(requested).toHaveLength(1); + }); + + it('maps trending topics onto the shared trends shape', async () => { + installProxy(); + stubApi({ + '/api/v1/fbsearch/text_app/trends/': { + trending_topics: [ + { + trend_title: 'Something happened', + trend_description: 'A big deal', + trend_rank: 1, + related_communities: [{ name: 'News' }] + }, + { topic_name: 'Another topic', post_count: 4200 }, + { trend_description: 'nameless rows are dropped' } + ] + } + }); + const page = await constructThreadsTrends({ ctx }); + expect(page.code).toBe(200); + expect(page.timeline_type).toBe('threads'); + expect(page.trends).toEqual([ + { + name: 'Something happened', + rank: '1', + context: 'A big deal', + grouped_topics: [{ name: 'News' }] + }, + { name: 'Another topic', rank: null, context: '4200 posts' } + ]); + }); +}); diff --git a/tools/stripcredentials.mjs b/tools/stripcredentials.mjs index aa59f400..75cd01ec 100644 --- a/tools/stripcredentials.mjs +++ b/tools/stripcredentials.mjs @@ -1,6 +1,7 @@ /** * Strip sensitive fields from credentials.complete.json → credentials.json - * Output shape: { "twitter": { "accounts": [...] }, "bluesky": { "accounts": [...] } } + * Output shape: { "twitter": { "accounts": [...] }, "bluesky": { "accounts": [...] }, + * "instagram": { "accounts": [...] } } * * Accepts complete file as either: * - { "twitter": { "accounts": [...] } } (preferred) @@ -22,13 +23,15 @@ const twitterAccounts = Array.isArray(raw.twitter?.accounts) : null; const blueskyAccounts = Array.isArray(raw.bluesky?.accounts) ? raw.bluesky.accounts : null; +const instagramAccounts = Array.isArray(raw.instagram?.accounts) ? raw.instagram.accounts : null; if ( (!Array.isArray(twitterAccounts) || twitterAccounts.length === 0) && - (!Array.isArray(blueskyAccounts) || blueskyAccounts.length === 0) + (!Array.isArray(blueskyAccounts) || blueskyAccounts.length === 0) && + (!Array.isArray(instagramAccounts) || instagramAccounts.length === 0) ) { console.error( - 'credentials.complete.json must have twitter.accounts (or legacy accounts), and/or bluesky.accounts, as non-empty arrays' + 'credentials.complete.json must have twitter.accounts (or legacy accounts), bluesky.accounts, and/or instagram.accounts, as non-empty arrays' ); process.exit(1); } @@ -106,8 +109,45 @@ if (Array.isArray(blueskyAccounts) && blueskyAccounts.length > 0) { }; } +if (Array.isArray(instagramAccounts) && instagramAccounts.length > 0) { + for (let i = 0; i < instagramAccounts.length; i++) { + const cred = instagramAccounts[i]; + const id = `instagram.accounts[${i}]`; + if (cred === null || typeof cred !== 'object' || Array.isArray(cred)) { + console.error(`${id}: each account must be a plain object`); + process.exit(1); + } + if (typeof cred.sessionId !== 'string' || cred.sessionId.length === 0) { + console.error(`${id}: "sessionId" must be a non-empty string (the \`sessionid\` cookie)`); + process.exit(1); + } + if (cred.platform !== undefined && cred.platform !== 'web' && cred.platform !== 'android') { + console.error(`${id}: "platform" must be "web" or "android" when present`); + process.exit(1); + } + } + + /* Only cookie-jar fields survive; the login password (if the complete file carries one) does not. */ + const optionalFields = ['userId', 'csrfToken', 'mid', 'deviceId', 'androidDeviceId', 'username']; + out.instagram = { + accounts: instagramAccounts.map(cred => { + const stripped = { sessionId: cred.sessionId }; + for (const field of optionalFields) { + if (typeof cred[field] === 'string' && cred[field].length > 0) { + stripped[field] = cred[field]; + } + } + if (cred.platform === 'web' || cred.platform === 'android') { + stripped.platform = cred.platform; + } + return stripped; + }) + }; +} + fs.writeFileSync(outPath, JSON.stringify(out, null, 2) + '\n', 'utf8'); const parts = []; if (out.twitter) parts.push(`${out.twitter.accounts.length} twitter account(s)`); if (out.bluesky) parts.push(`${out.bluesky.accounts.length} bluesky account(s)`); +if (out.instagram) parts.push(`${out.instagram.accounts.length} instagram account(s)`); console.log(`Wrote ${outPath} (${parts.join(', ')})`); From ebc09b782c6390a6e967885765c9271c8d006fc5 Mon Sep 17 00:00:00 2001 From: dangered wolf Date: Fri, 28 Aug 2026 05:34:15 -0400 Subject: [PATCH 9/9] Revert "fix(threads): reject redirects on cookie-authenticated private API fetches (#2397)" This reverts commit 0545509671a2e1f26566068f95736592dd182d4e. --- .env.example | 1 - .github/workflows/deploy.yml | 1 - AGENTS.md | 2 - credentials.example.json | 13 - .../content/docs/deployment/credentials.mdx | 70 +-- esbuild.config.mjs | 3 +- packages/atmosphere/package.json | 4 - .../src/providers/instagram-runtime.ts | 59 -- .../src/providers/instagram/account-proxy.ts | 217 -------- .../src/providers/instagram/constants.ts | 31 -- .../src/providers/instagram/conversation.ts | 105 +--- .../src/providers/instagram/cursors.ts | 58 +- .../instagram/fetch-shortcode-page.ts | 61 +- .../src/providers/instagram/likes.ts | 45 -- .../src/providers/instagram/post.ts | 7 +- .../src/providers/instagram/private-api.ts | 208 ------- .../providers/instagram/private-processor.ts | 134 ----- .../src/providers/instagram/profile.ts | 133 +---- .../src/providers/instagram/relationships.ts | 60 -- .../src/providers/instagram/resolve-user.ts | 47 -- .../src/providers/instagram/search.ts | 131 ----- .../src/providers/instagram/stories.ts | 78 --- .../src/providers/instagram/tagged.ts | 70 --- .../src/providers/threads/account-proxy.ts | 234 -------- .../src/providers/threads/constants.ts | 35 -- .../src/providers/threads/conversation.ts | 145 +---- .../src/providers/threads/cursors.ts | 108 +--- .../atmosphere/src/providers/threads/likes.ts | 46 -- .../atmosphere/src/providers/threads/post.ts | 125 ++--- .../src/providers/threads/private-api.ts | 262 --------- .../providers/threads/private-processor.ts | 230 -------- .../src/providers/threads/profile-tabs.ts | 79 --- .../src/providers/threads/profile.ts | 58 +- .../src/providers/threads/relationships.ts | 69 --- .../src/providers/threads/resolve-user.ts | 57 -- .../src/providers/threads/search.ts | 228 -------- .../src/providers/threads/trends.ts | 91 --- .../atmosphere/src/types/proxy-credentials.ts | 30 +- src/constants.ts | 1 - .../instagram/atmosphere-handlers.ts | 238 +------- .../instagram/atmosphere-register.ts | 25 +- src/providers/instagram/atmosphere-routes.ts | 233 -------- src/providers/threads/atmosphere-handlers.ts | 336 +---------- src/providers/threads/atmosphere-register.ts | 34 +- src/providers/threads/atmosphere-routes.ts | 294 +--------- src/providers/twitter/proxy/credentials.ts | 20 +- src/realms/atmosphere/router.ts | 2 +- src/types/env.d.ts | 2 - src/worker.ts | 17 - test/helpers/env.ts | 1 - test/instagram.accountProxy.test.ts | 219 -------- test/instagram.atmosphereRoutes.test.ts | 82 --- test/instagram.cursors.test.ts | 59 +- test/instagram.privateProcessor.test.ts | 100 ---- test/instagram.proxiedPost.test.ts | 263 --------- test/instagram.proxiedSurfaces.test.ts | 281 ---------- test/threads.accountProxy.test.ts | 275 --------- test/threads.atmosphereRoutes.test.ts | 85 --- test/threads.cursors.test.ts | 43 +- test/threads.proxiedSurfaces.test.ts | 525 ------------------ tools/stripcredentials.mjs | 46 +- 61 files changed, 123 insertions(+), 6393 deletions(-) delete mode 100644 packages/atmosphere/src/providers/instagram-runtime.ts delete mode 100644 packages/atmosphere/src/providers/instagram/account-proxy.ts delete mode 100644 packages/atmosphere/src/providers/instagram/likes.ts delete mode 100644 packages/atmosphere/src/providers/instagram/private-api.ts delete mode 100644 packages/atmosphere/src/providers/instagram/private-processor.ts delete mode 100644 packages/atmosphere/src/providers/instagram/relationships.ts delete mode 100644 packages/atmosphere/src/providers/instagram/resolve-user.ts delete mode 100644 packages/atmosphere/src/providers/instagram/search.ts delete mode 100644 packages/atmosphere/src/providers/instagram/stories.ts delete mode 100644 packages/atmosphere/src/providers/instagram/tagged.ts delete mode 100644 packages/atmosphere/src/providers/threads/account-proxy.ts delete mode 100644 packages/atmosphere/src/providers/threads/likes.ts delete mode 100644 packages/atmosphere/src/providers/threads/private-api.ts delete mode 100644 packages/atmosphere/src/providers/threads/private-processor.ts delete mode 100644 packages/atmosphere/src/providers/threads/profile-tabs.ts delete mode 100644 packages/atmosphere/src/providers/threads/relationships.ts delete mode 100644 packages/atmosphere/src/providers/threads/resolve-user.ts delete mode 100644 packages/atmosphere/src/providers/threads/search.ts delete mode 100644 packages/atmosphere/src/providers/threads/trends.ts delete mode 100644 test/instagram.accountProxy.test.ts delete mode 100644 test/instagram.atmosphereRoutes.test.ts delete mode 100644 test/instagram.privateProcessor.test.ts delete mode 100644 test/instagram.proxiedPost.test.ts delete mode 100644 test/instagram.proxiedSurfaces.test.ts delete mode 100644 test/threads.accountProxy.test.ts delete mode 100644 test/threads.atmosphereRoutes.test.ts delete mode 100644 test/threads.proxiedSurfaces.test.ts diff --git a/.env.example b/.env.example index 186682aa..cdd52358 100644 --- a/.env.example +++ b/.env.example @@ -21,7 +21,6 @@ ATMOSPHERE_API_HOST_LIST = "api.atmosphere.tools,api-canary.atmosphere.tools" API_HOST_LIST = "api.fxtwitter.com,api-canary.fxtwitter.com" TWITTER_ROOT = "https://x.com" INSTAGRAM_ROOT = "https://www.instagram.com" -INSTAGRAM_API_ROOT = "https://i.instagram.com" SENTRY_DSN = "" SENTRY_AUTH_TOKEN = "" SENTRY_ORG = "" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e3fa5b83..5a371589 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -89,7 +89,6 @@ jobs: ATMOSPHERE_API_HOST_LIST: ${{ vars.ATMOSPHERE_API_HOST_LIST }} TWITTER_ROOT: ${{ vars.TWITTER_ROOT }} INSTAGRAM_ROOT: ${{ vars.INSTAGRAM_ROOT }} - INSTAGRAM_API_ROOT: ${{ vars.INSTAGRAM_API_ROOT }} SENTRY_DSN: ${{ secrets.SENTRY_DSN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }} diff --git a/AGENTS.md b/AGENTS.md index 9c130939..2dd63a81 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,8 +8,6 @@ This is the repository for FxEmbed, the home of FxTwitter, FixupX, and FxBluesky - **Build:** `npm run build:atmosphere` (runs before the worker build). - **Transports:** `public` | `anonymous-proxy` (credentials) | `proxy-relay` (to another host’s OpenAPI) | `authenticated` (interface stub, not implemented) — see `packages/atmosphere/src/transports/`. - **Bluesky:** runtime wiring (API roots, proxy) — `setBlueskyProviderEnv` + `setBlueskyProxyRuntime` from `worker.ts` and `@fxembed/atmosphere/providers/bluesky-runtime`. -- **Instagram:** same pattern — `setInstagramProviderEnv` + `setInstagramProxyRuntime` from `worker.ts`. The account proxy (`providers/instagram/account-proxy.ts`) calls `i.instagram.com/api/v1/…` with a `sessionid` cookie from `credentials.json`’s `instagram.accounts`, rotating accounts on 401/403/429. Logged-out paths still work without it; the proxy-only routes (likers, follow lists, tagged, stories, user search, typeahead) answer `501`. Android app id / capabilities / UA in `providers/instagram/constants.ts` come from a decompiled `com.instagram.android` build — see the comments there before changing them. -- **Threads:** `packages/atmosphere/src/providers/threads/`. Logged-out `threads.com` Relay GraphQL (`client.ts`) plus an account proxy (`account-proxy.ts` + `private-api.ts`) that reuses the _Instagram_ credential pool — `resolveThreadsAccounts` is `resolveInstagramAccounts` — and only swaps in the Threads app fingerprint. Endpoint paths, parameter names and the app id / capabilities / UA in `providers/threads/constants.ts` come from a decompiled `com.instagram.barcelona` build; the smali (not the JADX output) is where the `text_feed/…` and `fbsearch/text_app/…` route templates survive. Proxy-only routes (search, typeahead, trends, likes, follow lists, Replies/Reposts/Media tabs) answer `501` without credentials; post / profile / timeline / conversation prefer the proxy and fall back to logged-out. - **Proxy-relay OpenAPI (optional):** `npm run openapi:atmosphere` fetches public specs and writes `packages/atmosphere/src/relay/generated/`. Use `createRelayFetch` from `@fxembed/atmosphere` for `User-Agent` + API key injection. - **Self-hosting:** docs site `/deployment/atmosphere-transports/` — public-only, own proxy pool, relay to `https://api.fxtwitter.com` / `https://api.fxbsky.app`, or mixed. Mastodon / Twitter / TikTok providers remain under `src/providers/*`; follow the Bluesky → `@fxembed/atmosphere` migration pattern when moving more code. diff --git a/credentials.example.json b/credentials.example.json index 7012fd2a..c377919b 100644 --- a/credentials.example.json +++ b/credentials.example.json @@ -16,18 +16,5 @@ "service": "https://bsky.social" } ] - }, - "instagram": { - "accounts": [ - { - "sessionId": "your_sessionid_cookie", - "userId": "your_ds_user_id_cookie", - "csrfToken": "your_csrftoken_cookie", - "mid": "your_mid_cookie", - "deviceId": "your_ig_did_cookie", - "username": "account_handle", - "platform": "web" - } - ] } } diff --git a/docs/src/content/docs/deployment/credentials.mdx b/docs/src/content/docs/deployment/credentials.mdx index 7096f445..84d94174 100644 --- a/docs/src/content/docs/deployment/credentials.mdx +++ b/docs/src/content/docs/deployment/credentials.mdx @@ -1,6 +1,6 @@ --- title: Credentials -description: Setting up X/Twitter, Bluesky, and Instagram credentials for your FxEmbed deployment. +description: Setting up X/Twitter credentials for your FxEmbed deployment. --- FxEmbed works best with X/Twitter account credentials to fetch post data from the Twitter API. Without them, you will have lower rate limits and not be able to fetch NSFW posts. @@ -96,7 +96,7 @@ wrangler secret put CREDENTIAL_KEY FxEmbed by default will use the public Bluesky AppView API. We support specifying fallback accounts on different PDSes in the event of downtime of the public API. We will always prefer the public API unless it is down. -The proxy uses standard Bluesky [app passwords](https://bsky.app/settings/app-passwords) +The proxy uses standard Bluesky [app passwords](https://bsky.app/settings/app-passwords) — no OAuth flow, no DPoP, nothing exotic. ### Credential Format @@ -134,69 +134,3 @@ App passwords can be revoked at any time from the same screen if a key is ever c ### Encryption and Deployment Bluesky credentials are encrypted, pushed, and pulled using the exact same [credential management scripts](#credential-management-scripts) and [`CREDENTIAL_KEY`](#setting-the-credential-key) as Twitter — there's nothing Bluesky-specific to configure on the worker side. - -## Instagram - -Instagram's logged-out surfaces are heavily restricted: follower lists, likers, search, tagged posts and stories return nothing at all, and post and profile lookups are rate limited and gated on anything age-restricted. Adding an Instagram account proxy unlocks those surfaces and makes the existing ones far more reliable. - -Without it, FxEmbed still serves Instagram posts, profiles, profile grids and comments over the logged-out web path. The proxy-only routes report HTTP `501` rather than an empty result, so you can tell "not configured" apart from "this account really has no followers". - -### Credential Format - -Instagram accounts live alongside Twitter and Bluesky accounts in the same `credentials.json`: - -```json -{ - "instagram": { - "accounts": [ - { - "sessionId": "your_sessionid_cookie", - "userId": "your_ds_user_id_cookie", - "csrfToken": "your_csrftoken_cookie", - "mid": "your_mid_cookie", - "deviceId": "your_ig_did_cookie", - "username": "account_handle", - "platform": "web" - } - ] - } -} -``` - -Fields: - -- **`sessionId`** (required): The `sessionid` cookie of a logged-in Instagram session. -- **`userId`**: The `ds_user_id` cookie — the account's numeric pk. -- **`csrfToken`**: The `csrftoken` cookie. -- **`mid`** / **`deviceId`**: The `mid` and `ig_did` cookies. Optional, but Instagram is happier when the cookie jar looks complete. -- **`username`**: Used only for logging, so you can tell which session got rate limited. -- **`platform`**: `web` (default) or `android`. This picks the client fingerprint the proxy presents — see below. - -Only `sessionId` is strictly required; everything else improves how ordinary the session looks. - -### Picking a Platform - -`platform` must match where the `sessionid` came from: - -- **`web`** — a cookie harvested from `www.instagram.com` in a desktop browser. FxEmbed sends the matching desktop Chrome `User-Agent`, web app id, and browser `Sec-Fetch-*`/`Origin` headers. -- **`android`** — a cookie harvested from the Instagram Android app. FxEmbed sends the app's own `User-Agent`, app id, and `X-IG-Device-ID`, and omits the browser-only headers. - -Mixing the two is the usual cause of an unexpected checkpoint, so keep this consistent with where you got the cookie. - -### Obtaining a Session - -1. Sign in to the account you want to use as a proxy in a browser. -2. Open DevTools → **Application → Cookies → `https://www.instagram.com`**. -3. Copy the `sessionid`, `ds_user_id`, `csrftoken`, `mid` and `ig_did` values into the fields above, leaving `platform` as `web`. - -Sessions are long-lived but not permanent: logging the account out, changing its password, or an Instagram-side checkpoint invalidates the cookie. FxEmbed rotates to the next configured account on `401`, `403` and `429`, on an HTML login page, and on a 200 `{ status: 'fail' }` body (checkpoint / spam block), so a stale entry degrades one account rather than the whole deployment. Use accounts you're willing to lose, not a personal one. - -### Encryption and Deployment - -Instagram credentials are encrypted, pushed, and pulled using the exact same [credential management scripts](#credential-management-scripts) and [`CREDENTIAL_KEY`](#setting-the-credential-key) as Twitter and Bluesky. `npm run credentials:strip` keeps only the cookie-jar fields, so a `credentials.complete.json` that also holds a login password will not leak it into the encrypted bundle. - -### Threads - -Threads accounts _are_ Instagram accounts, so there is no separate credential block: the same `instagram.accounts` pool powers Threads. FxEmbed only swaps the client fingerprint — the Threads (`Barcelona`) app id and `User-Agent` — when it calls a Threads endpoint. - -Logged-out `threads.com` is even more restricted than Instagram's: search, typeahead, trending topics, likers, follow lists, and the Replies / Reposts / Media profile tabs are all behind a login. With a proxy configured those become available at `/2/threads/…`; without one they report `501`, exactly like their Instagram counterparts. Single posts, profiles, profile timelines and conversations keep working either way — the proxy just gives fuller and more reliable results. diff --git a/esbuild.config.mjs b/esbuild.config.mjs index fd2bc997..f89e50fe 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -61,8 +61,7 @@ let envVariables = [ 'PBS_PROXY_DOMAIN_LIST', 'OLD_EMBED_DOMAINS', 'TWITTER_ROOT', - 'INSTAGRAM_ROOT', - 'INSTAGRAM_API_ROOT' + 'INSTAGRAM_ROOT' ]; // Inline process.env.* so Workers bundles stay static; Bun/Node read real process.env at runtime. diff --git a/packages/atmosphere/package.json b/packages/atmosphere/package.json index b074bf75..cb55719c 100644 --- a/packages/atmosphere/package.json +++ b/packages/atmosphere/package.json @@ -70,10 +70,6 @@ "types": "./dist/providers/mastodon/*.d.ts", "import": "./dist/providers/mastodon/*.js" }, - "./providers/instagram-runtime": { - "types": "./dist/providers/instagram-runtime.d.ts", - "import": "./dist/providers/instagram-runtime.js" - }, "./providers/instagram/*": { "types": "./dist/providers/instagram/*.d.ts", "import": "./dist/providers/instagram/*.js" diff --git a/packages/atmosphere/src/providers/instagram-runtime.ts b/packages/atmosphere/src/providers/instagram-runtime.ts deleted file mode 100644 index 000f73b8..00000000 --- a/packages/atmosphere/src/providers/instagram-runtime.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { InstagramCredentials } from '../types/proxy-credentials.js'; - -/** - * Configurable Instagram web/private-API roots. - * The FxEmbed worker calls {@link setInstagramProviderEnv} at startup (see `worker.ts`). - */ -export type InstagramProviderEnv = { - /** Logged-out web origin (`www.instagram.com`). */ - webRoot: string; - /** Private API origin used by the account proxy (`i.instagram.com`). */ - apiRoot: string; - /** Sent as `User-Agent` on logged-out web requests when the caller supplies none. */ - friendlyUserAgent: string; -}; - -const defaultEnv: InstagramProviderEnv = { - webRoot: 'https://www.instagram.com', - apiRoot: 'https://i.instagram.com', - friendlyUserAgent: 'FxEmbed' -}; - -let env: InstagramProviderEnv = { ...defaultEnv }; - -export function setInstagramProviderEnv(partial: Partial): void { - env = { ...env, ...partial }; -} - -export function getInstagramProviderEnv(): InstagramProviderEnv { - return env; -} - -/** - * Encrypted bundle decrypt + account selection lives in the worker; the package only sees this - * interface (registered from `worker.ts`, same as `setBlueskyProxyRuntime` / `setTwitterProxyRuntime`). - */ -export type InstagramProxyRuntime = { - initCredentials: (key: string | undefined) => Promise; - hasBundledEncryptedCredentials: () => boolean; - hasInstagramProxyAccounts: () => boolean; - getShuffledInstagramAccounts: () => InstagramCredentials[]; -}; - -/** No-op fallback so logged-out Instagram paths work without worker proxy wiring (and in tests). */ -const noopProxy: InstagramProxyRuntime = { - initCredentials: async () => {}, - hasBundledEncryptedCredentials: () => false, - hasInstagramProxyAccounts: () => false, - getShuffledInstagramAccounts: () => [] -}; - -let proxy: InstagramProxyRuntime | null = null; - -export function setInstagramProxyRuntime(r: InstagramProxyRuntime): void { - proxy = r; -} - -export function getInstagramProxyRuntime(): InstagramProxyRuntime { - return proxy ?? noopProxy; -} diff --git a/packages/atmosphere/src/providers/instagram/account-proxy.ts b/packages/atmosphere/src/providers/instagram/account-proxy.ts deleted file mode 100644 index 648e86ef..00000000 --- a/packages/atmosphere/src/providers/instagram/account-proxy.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { withTimeout } from '../../helpers/with-timeout.js'; -import { getInstagramProviderEnv, getInstagramProxyRuntime } from '../instagram-runtime.js'; -import type { InstagramCredentials } from '../../types/proxy-credentials.js'; -import { - INSTAGRAM_ANDROID_APP_ID, - INSTAGRAM_ANDROID_CAPABILITIES, - INSTAGRAM_ANDROID_USER_AGENT, - INSTAGRAM_API_V1, - INSTAGRAM_ASBD_ID, - INSTAGRAM_ORIGIN, - INSTAGRAM_WEB_APP_ID -} from './constants.js'; - -const WEB_USER_AGENT = - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; - -/** - * Per-request Instagram context. `credentialKey` is the worker's `CREDENTIAL_KEY` binding; without - * it (or without a bundled credential blob) every Instagram path stays logged-out. - */ -export type InstagramRequestContext = { - userAgent?: string; - credentialKey?: string; -}; - -/** - * True when this deployment *can* proxy Instagram through a logged-in account: a credential key is - * configured and the worker bundle carries an encrypted credential blob. Whether that blob actually - * contains Instagram accounts is only known after decryption — see {@link resolveInstagramAccounts}. - */ -export function hasInstagramAccountProxy(ctx: InstagramRequestContext | undefined): boolean { - return Boolean( - ctx?.credentialKey?.trim() && getInstagramProxyRuntime().hasBundledEncryptedCredentials() - ); -} - -/** Decrypts (once) and returns the proxy accounts in shuffled order; empty when unavailable. */ -export async function resolveInstagramAccounts( - ctx: InstagramRequestContext | undefined -): Promise { - if (!hasInstagramAccountProxy(ctx)) return []; - const rt = getInstagramProxyRuntime(); - try { - await rt.initCredentials(ctx?.credentialKey); - } catch (err) { - console.error('[instagram] credential init failed', { - message: err instanceof Error ? err.message : String(err) - }); - return []; - } - if (!rt.hasInstagramProxyAccounts()) { - return []; - } - return rt.getShuffledInstagramAccounts().filter(a => Boolean(a?.sessionId)); -} - -function cookieHeaderFor(account: InstagramCredentials): string { - const parts = [`sessionid=${account.sessionId}`]; - if (account.userId) parts.push(`ds_user_id=${account.userId}`); - if (account.csrfToken) parts.push(`csrftoken=${account.csrfToken}`); - if (account.mid) parts.push(`mid=${account.mid}`); - if (account.deviceId) parts.push(`ig_did=${account.deviceId}`); - return parts.join('; '); -} - -/** - * Headers for one proxied request. `android` accounts get the app fingerprint read out of the - * decompiled APK; `web` accounts get the desktop-browser fingerprint that matches a `sessionid` - * harvested from www.instagram.com. - */ -export function instagramProxyHeaders( - account: InstagramCredentials, - options: { referer?: string } = {} -): Record { - const android = account.platform === 'android'; - const headers: Record = { - 'User-Agent': android ? INSTAGRAM_ANDROID_USER_AGENT : WEB_USER_AGENT, - 'Accept': '*/*', - 'Accept-Language': 'en-US,en;q=0.9', - 'X-IG-App-ID': android ? INSTAGRAM_ANDROID_APP_ID : INSTAGRAM_WEB_APP_ID, - 'X-IG-Capabilities': INSTAGRAM_ANDROID_CAPABILITIES, - 'X-IG-WWW-Claim': '0', - 'Cookie': cookieHeaderFor(account) - }; - if (android) { - headers['X-IG-Connection-Type'] = 'WIFI'; - if (account.androidDeviceId) { - headers['X-IG-Device-ID'] = account.androidDeviceId; - } - } else { - headers['X-ASBD-ID'] = INSTAGRAM_ASBD_ID; - headers['Origin'] = INSTAGRAM_ORIGIN; - headers['Referer'] = options.referer ?? `${INSTAGRAM_ORIGIN}/`; - headers['Sec-Fetch-Dest'] = 'empty'; - headers['Sec-Fetch-Mode'] = 'cors'; - headers['Sec-Fetch-Site'] = 'same-origin'; - } - if (account.csrfToken) { - headers['X-CSRFToken'] = account.csrfToken; - } - return headers; -} - -/** HTTP statuses where another account is worth trying: auth/checkpoint/rate limit. */ -const ROTATE_STATUSES = new Set([401, 403, 429]); - -export type InstagramPrivateApiResult = { - ok: boolean; - /** 0 when no account was available at all (proxy not configured). */ - status: number; - json: unknown | null; - /** Set when a request actually went out, for logging. */ - accountUsed?: string; -}; - -/** - * Calls an `i.instagram.com/api/v1/…` endpoint through a proxy account, rotating accounts on - * auth/rate-limit failures and on a 200 `{ status: 'fail' }` body (checkpoint / spam block). - * Returns `{ ok: false, status: 0 }` when no proxy account is configured so callers can fall - * back to their logged-out path. - */ -export async function instagramPrivateApiRequest( - path: string, - ctx: InstagramRequestContext | undefined, - options: { - query?: Record; - method?: 'GET' | 'POST'; - body?: string; - referer?: string; - accounts?: InstagramCredentials[]; - } = {} -): Promise { - const accounts = options.accounts ?? (await resolveInstagramAccounts(ctx)); - if (!accounts.length) { - return { ok: false, status: 0, json: null }; - } - - const { apiRoot } = getInstagramProviderEnv(); - const url = new URL(`${apiRoot}${INSTAGRAM_API_V1}${path.startsWith('/') ? path : `/${path}`}`); - for (const [key, value] of Object.entries(options.query ?? {})) { - if (value === undefined || value === '') continue; - url.searchParams.set(key, String(value)); - } - - let last: InstagramPrivateApiResult = { ok: false, status: 500, json: null }; - for (const account of accounts) { - const headers = instagramProxyHeaders(account, { referer: options.referer }); - if (options.method === 'POST') { - headers['Content-Type'] = 'application/x-www-form-urlencoded'; - } - let res: Response; - try { - res = await withTimeout(signal => - fetch(url.toString(), { - method: options.method ?? 'GET', - headers, - body: options.method === 'POST' ? (options.body ?? '') : undefined, - signal - }) - ); - } catch (err) { - console.error('[instagram] private API request threw', { - path, - account: account.username, - message: err instanceof Error ? err.message : String(err) - }); - last = { ok: false, status: 500, json: null, accountUsed: account.username }; - continue; - } - - if (!res.ok) { - console.error('[instagram] private API request failed', { - path, - account: account.username, - status: res.status - }); - last = { ok: false, status: res.status, json: null, accountUsed: account.username }; - if (ROTATE_STATUSES.has(res.status)) continue; - return last; - } - - const text = await res.text(); - const trimmed = text.trim(); - // A logged-out or checkpointed session gets an HTML login page rather than JSON. - if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) { - console.error('[instagram] private API returned non-JSON (session likely invalid)', { - path, - account: account.username - }); - last = { ok: false, status: res.status, json: null, accountUsed: account.username }; - continue; - } - let parsed: unknown; - try { - parsed = JSON.parse(text) as unknown; - } catch { - last = { ok: false, status: res.status, json: null, accountUsed: account.username }; - continue; - } - // The private API answers 200 with `{ status: 'fail' }` for soft failures (checkpoint, - // spam block, feedback_required). Rotate rather than surfacing an empty page as success. - if ( - parsed && - typeof parsed === 'object' && - (parsed as { status?: unknown }).status === 'fail' - ) { - console.error('[instagram] private API returned status=fail', { - path, - account: account.username - }); - last = { ok: false, status: 502, json: parsed, accountUsed: account.username }; - continue; - } - return { ok: true, status: res.status, json: parsed, accountUsed: account.username }; - } - return last; -} diff --git a/packages/atmosphere/src/providers/instagram/constants.ts b/packages/atmosphere/src/providers/instagram/constants.ts index d4be226c..d81d9916 100644 --- a/packages/atmosphere/src/providers/instagram/constants.ts +++ b/packages/atmosphere/src/providers/instagram/constants.ts @@ -23,34 +23,3 @@ export const INSTAGRAM_POST_ROOT_FRIENDLY_NAME = 'PolarisLoggedOutDesktopWWWPostRootContentQuery' as const; export const INSTAGRAM_ORIGIN = 'https://www.instagram.com'; - -/* - * Android app constants, read out of a decompiled `com.instagram.android` build - * (444.0.0.46.85, versionCode 385104942). `InstagramSpecificHeaderServiceLayer` stamps - * `X-IG-Capabilities` and the default `X-IG-App-ID` onto every first-party request. - */ - -/** Instagram Android app id. Distinct from {@link INSTAGRAM_WEB_APP_ID}. */ -export const INSTAGRAM_ANDROID_APP_ID = '567067343352427'; - -/** `X-IG-Capabilities` value the app sends on every first-party request. */ -export const INSTAGRAM_ANDROID_CAPABILITIES = '3brTv10='; - -export const INSTAGRAM_ANDROID_VERSION_NAME = '444.0.0.46.85'; -export const INSTAGRAM_ANDROID_VERSION_CODE = '385104942'; - -/** - * Android `User-Agent`, in the app's own - * `Instagram Android (/; dpi; x; ; ; ; ; ; )` - * shape (the app builds the middle section with the `"%sdpi; %sx%s"` format string). - * Kept as one fixed, plausible device so a proxied session presents a stable fingerprint. - */ -export const INSTAGRAM_ANDROID_USER_AGENT = - `Instagram ${INSTAGRAM_ANDROID_VERSION_NAME} Android (34/14; 420dpi; 1080x2340; ` + - `samsung; SM-S911B; dm1q; qcom; en_US; ${INSTAGRAM_ANDROID_VERSION_CODE})`; - -/** Private API origin the Android app talks to. */ -export const INSTAGRAM_API_ORIGIN = 'https://i.instagram.com'; - -/** Private API prefix (`i.instagram.com/api/v1/…`). */ -export const INSTAGRAM_API_V1 = '/api/v1'; diff --git a/packages/atmosphere/src/providers/instagram/conversation.ts b/packages/atmosphere/src/providers/instagram/conversation.ts index a8d6fceb..32eab1cc 100644 --- a/packages/atmosphere/src/providers/instagram/conversation.ts +++ b/packages/atmosphere/src/providers/instagram/conversation.ts @@ -1,42 +1,14 @@ -import type { APISubstatus, SocialConversationInstagram } from '../../types/api-schemas.js'; -import { resolveInstagramAccounts, type InstagramRequestContext } from './account-proxy.js'; +import type { SocialConversationInstagram } from '../../types/api-schemas.js'; import { fetchCommentPageGraphql, fetchInstagramCsrfToken } from './client.js'; import { decodeCommentCursor, encodeCommentCursor } from './cursors.js'; import { extractCommentsConnection } from './extractors.js'; import { fetchInstagramPageWithWebInfo } from './fetch-shortcode-page.js'; -import { fetchPrivateMediaComments } from './private-api.js'; -import { nextMaxIdFromPrivateResponse } from './private-processor.js'; import { - commentRecordToSubstatus, extractCommentsFromGraphqlJson, instagramNodeToStatus, mapCommentEdges } from './processor.js'; -/** `media/{pk}/comments/` returns a flat `comments` array rather than GraphQL edges. */ -function substatusesFromPrivateComments( - json: unknown, - shortcode: string, - parentAuthor: string, - limit: number -): APISubstatus[] { - if (!json || typeof json !== 'object') return []; - const comments = (json as { comments?: unknown }).comments; - if (!Array.isArray(comments)) return []; - const out: APISubstatus[] = []; - for (const comment of comments) { - if (out.length >= limit) break; - if (!comment || typeof comment !== 'object') continue; - const mapped = commentRecordToSubstatus( - comment as Record, - shortcode, - parentAuthor - ); - if (mapped) out.push(mapped); - } - return out; -} - export type InstagramConversationResult = | { ok: true; data: SocialConversationInstagram } | { ok: false; message: string; data?: SocialConversationInstagram }; @@ -48,16 +20,10 @@ export async function constructInstagramConversation( count: number; sortOrder: 'popular' | 'recent'; userAgent?: string; - credentialKey?: string; } ): Promise { const count = Math.min(100, Math.max(1, Math.floor(options.count))); - const ctx: InstagramRequestContext = { - userAgent: options.userAgent, - credentialKey: options.credentialKey - }; - const accounts = await resolveInstagramAccounts(ctx); - const page = await fetchInstagramPageWithWebInfo(shortcode, options.userAgent, ctx); + const page = await fetchInstagramPageWithWebInfo(shortcode, options.userAgent); if (!page.ok) { return { ok: true, @@ -98,60 +64,6 @@ export async function constructInstagramConversation( (typeof item.pk === 'string' || typeof item.pk === 'number' ? String(item.pk).split('_')[0] : ''); - /* - * With an account proxy, comments come from `media/{pk}/comments/`: it paginates past the ~24 - * comments the embedded page carries and works on posts whose logged-out page has no comment - * connection at all. Falls through to the logged-out GraphQL path if the call fails. - */ - if (accounts.length && mediaPk) { - let maxId: string | null = null; - if (options.cursor) { - const decoded = decodeCommentCursor(options.cursor); - if ( - !decoded || - decoded.shortcode !== shortcode || - decoded.mediaId !== mediaPk || - decoded.src !== 'proxy' - ) { - return { ok: false, message: 'Invalid cursor' }; - } - maxId = decoded.after; - } - const res = await fetchPrivateMediaComments(mediaPk, ctx, { - accounts, - maxId, - count, - sortOrder: options.sortOrder, - shortcode - }); - if (res.ok) { - const replies = substatusesFromPrivateComments(res.json, shortcode, fb.username, count); - const nextMaxId = nextMaxIdFromPrivateResponse(res.json); - const bottom = nextMaxId - ? encodeCommentCursor({ - v: 1, - mediaId: mediaPk, - shortcode, - sort: options.sortOrder, - after: nextMaxId, - count, - src: 'proxy' - }) - : null; - return { - ok: true, - data: { - code: 200, - status, - thread: [status], - replies, - author: status.author, - cursor: { bottom } - } - }; - } - } - const conn = page.comments ?? extractCommentsConnection(htmlBody); const pageInfo = conn?.page_info ?? {}; const hasNext = @@ -178,8 +90,7 @@ export async function constructInstagramConversation( shortcode, sort: options.sortOrder, after: endCursor, - count, - src: 'gql' + count }) : null; return { @@ -196,12 +107,7 @@ export async function constructInstagramConversation( } const decoded = decodeCommentCursor(options.cursor); - if ( - !decoded || - decoded.shortcode !== shortcode || - decoded.mediaId !== mediaPk || - decoded.src === 'proxy' - ) { + if (!decoded || decoded.shortcode !== shortcode || decoded.mediaId !== mediaPk) { return { ok: false, message: 'Invalid cursor' }; } @@ -283,8 +189,7 @@ export async function constructInstagramConversation( shortcode, sort: decoded.sort, after: pi.end_cursor, - count: decoded.count, - src: 'gql' + count: decoded.count }) : null; diff --git a/packages/atmosphere/src/providers/instagram/cursors.ts b/packages/atmosphere/src/providers/instagram/cursors.ts index a4b07033..1dae139d 100644 --- a/packages/atmosphere/src/providers/instagram/cursors.ts +++ b/packages/atmosphere/src/providers/instagram/cursors.ts @@ -13,11 +13,8 @@ export type InstagramCommentCursorV1 = { mediaId: string; shortcode: string; sort: 'popular' | 'recent'; - /** GraphQL `end_cursor`, or the private API's `next_max_id` when `src` is `proxy`. */ after: string | null; count: number; - /** Which comment source minted this cursor; the two use incompatible cursor values. */ - src?: 'gql' | 'proxy'; }; const b64urlEncode = (json: string): string => { @@ -89,62 +86,9 @@ export function decodeCommentCursor(raw: string): InstagramCommentCursorV1 | nul shortcode: o.shortcode, sort: o.sort, after: typeof o.after === 'string' || o.after === null ? o.after : null, - count: Math.floor(o.count), - src: o.src === 'proxy' ? 'proxy' : 'gql' + count: Math.floor(o.count) }; } catch { return null; } } - -/** - * Cursor for `max_id`-paginated private API surfaces (follow lists, tagged feed, proxied profile - * feed). `k` keeps a cursor minted for one endpoint from being replayed against another. - */ -export type InstagramMaxIdCursorV1 = { - v: 1; - k: 'followers' | 'following' | 'tagged' | 'feed'; - /** User pk the list belongs to. */ - id: string; - /** - * Username, kept so paged requests can send the same `Referer` as page one. Stored lowercased: - * Instagram handles are case-insensitive, so `/Cristiano` must be able to resume `/cristiano`. - */ - u: string; - /** Instagram `next_max_id`. */ - m: string; - c: number; -}; - -const MAX_ID_CURSOR_KINDS = new Set([ - 'followers', - 'following', - 'tagged', - 'feed' -]); - -export function encodeMaxIdCursor(p: InstagramMaxIdCursorV1): string { - return b64urlEncode(JSON.stringify({ ...p, u: p.u.toLowerCase() })); -} - -/** Handle comparison for cursor validation; Instagram treats handles case-insensitively. */ -export function sameInstagramHandle(a: string, b: string): boolean { - return a.toLowerCase() === b.toLowerCase(); -} - -export function decodeMaxIdCursor(raw: string): InstagramMaxIdCursorV1 | null { - const json = b64urlDecode(raw); - if (!json) return null; - try { - const o = JSON.parse(json) as Partial; - if (o.v !== 1) return null; - if (!o.k || !MAX_ID_CURSOR_KINDS.has(o.k)) return null; - if (typeof o.id !== 'string' || !o.id) return null; - if (typeof o.u !== 'string') return null; - if (typeof o.m !== 'string' || !o.m) return null; - if (typeof o.c !== 'number' || !Number.isFinite(o.c) || o.c < 1 || o.c > 100) return null; - return { v: 1, k: o.k, id: o.id, u: o.u.toLowerCase(), m: o.m, c: Math.floor(o.c) }; - } catch { - return null; - } -} diff --git a/packages/atmosphere/src/providers/instagram/fetch-shortcode-page.ts b/packages/atmosphere/src/providers/instagram/fetch-shortcode-page.ts index 47fa0106..74599cd0 100644 --- a/packages/atmosphere/src/providers/instagram/fetch-shortcode-page.ts +++ b/packages/atmosphere/src/providers/instagram/fetch-shortcode-page.ts @@ -1,11 +1,9 @@ -import { resolveInstagramAccounts, type InstagramRequestContext } from './account-proxy.js'; import { fetchInstagramHtml, fetchInstagramSession, fetchPolarisPostGraphql, fetchRulingForContent } from './client.js'; -import { fetchPrivateMediaInfo } from './private-api.js'; import { extractLsdFromHtml, extractPolarisProductFromGraphqlJson, @@ -22,7 +20,7 @@ export type InstagramWebInfoPage = item: Record; pathUsed: string; comments: PolarisMediaBundle['comments']; - source: 'account-proxy' | 'polaris-graphql' | 'polaris-html' | 'web-info-html'; + source: 'polaris-graphql' | 'polaris-html' | 'web-info-html'; /** Session/doc LSD for GraphQL comment pagination (Polaris GraphQL has no HTML to parse). */ lsd: string | null; } @@ -37,15 +35,6 @@ export type InstagramWebInfoPage = lsd: null; }; -/** `media/{pk}/info/` answers with a one-element `items` array. */ -function firstMediaItem(json: unknown): Record | null { - if (!json || typeof json !== 'object') return null; - const items = (json as { items?: unknown }).items; - if (!Array.isArray(items) || items.length === 0) return null; - const first = items[0]; - return first && typeof first === 'object' ? (first as Record) : null; -} - function isLoginRedirect(finalUrl: string | undefined): boolean { if (!finalUrl) return false; try { @@ -57,11 +46,7 @@ function isLoginRedirect(finalUrl: string | undefined): boolean { } /** - * Fetches Instagram post media. - * - * When an account proxy is configured, `media/{pk}/info/` is tried first: it is one request instead - * of three and returns media that the logged-out surfaces gate (age-restricted posts, and the - * higher-quality video renditions). Everything after that is the logged-out yt-dlp Polaris path: + * Fetches logged-out Instagram post media using the yt-dlp Polaris path: * * 1. Homepage session (cookies + LSD from `__eqmc`) * 2. Optional `get_ruling_for_content` warm-up @@ -72,46 +57,18 @@ function isLoginRedirect(finalUrl: string | undefined): boolean { */ export async function fetchInstagramPageWithWebInfo( shortcode: string, - userAgent: string | undefined, - ctx?: InstagramRequestContext + userAgent: string | undefined ): Promise { - let mediaIdForProxy: string | null; - try { - mediaIdForProxy = String(instagramShortcodeToPk(shortcode)); - } catch { - mediaIdForProxy = null; - } - - if (mediaIdForProxy) { - const accounts = await resolveInstagramAccounts(ctx); - if (accounts.length) { - const info = await fetchPrivateMediaInfo(mediaIdForProxy, ctx, { accounts, shortcode }); - const item = info.ok ? firstMediaItem(info.json) : null; - if (item) { - return { - ok: true, - status: info.status, - html: '', - item, - pathUsed: `/p/${encodeURIComponent(shortcode)}/`, - comments: null, - source: 'account-proxy', - lsd: null - }; - } - /* - * Deliberately no short-circuit on 404 here: a proxy account that the poster has blocked - * gets a 404 for a post that is perfectly visible logged-out. Falling through costs one - * wasted request on genuinely deleted posts, which the logged-out path reports as 404 anyway. - */ - } - } - const session = await fetchInstagramSession(userAgent); const cookies = session?.cookieHeader ?? ''; const htmlOpts = cookies ? { cookies } : undefined; - const mediaId = mediaIdForProxy; + let mediaId: string | null; + try { + mediaId = String(instagramShortcodeToPk(shortcode)); + } catch { + mediaId = null; + } // Prefer GraphQL when we have a full session; ignore failures (common without TLS impersonation). if (session && mediaId && session.lsd) { diff --git a/packages/atmosphere/src/providers/instagram/likes.ts b/packages/atmosphere/src/providers/instagram/likes.ts deleted file mode 100644 index 2f9d1c5d..00000000 --- a/packages/atmosphere/src/providers/instagram/likes.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { APIUserListResults } from '../../types/api-schemas.js'; -import { resolveInstagramAccounts, type InstagramRequestContext } from './account-proxy.js'; -import { fetchPrivateMediaLikers } from './private-api.js'; -import { usersFromPrivateList } from './private-processor.js'; -import { instagramShortcodeToPk } from './shortcode.js'; - -const empty = (code: number): APIUserListResults => ({ - code, - results: [], - cursor: { top: null, bottom: null } -}); - -/** - * Accounts that liked a post — Instagram's closest analogue to X's repost/quote lists. - * - * Requires the account proxy (`media/{pk}/likers/` is logged-in only) and returns a single - * un-paginated page, which is all Instagram serves for this surface. - */ -export async function constructInstagramStatusLikes( - shortcode: string, - options: { count: number; ctx?: InstagramRequestContext } -): Promise { - const count = Math.min(100, Math.max(1, Math.floor(options.count))); - const accounts = await resolveInstagramAccounts(options.ctx); - if (!accounts.length) { - return empty(501); - } - - let mediaId: string; - try { - mediaId = String(instagramShortcodeToPk(shortcode)); - } catch { - return empty(400); - } - - const res = await fetchPrivateMediaLikers(mediaId, options.ctx, { accounts, shortcode }); - if (!res.ok) { - return empty(res.status === 404 ? 404 : 500); - } - return { - code: 200, - results: usersFromPrivateList(res.json).slice(0, count), - cursor: { top: null, bottom: null } - }; -} diff --git a/packages/atmosphere/src/providers/instagram/post.ts b/packages/atmosphere/src/providers/instagram/post.ts index c488629d..38125cab 100644 --- a/packages/atmosphere/src/providers/instagram/post.ts +++ b/packages/atmosphere/src/providers/instagram/post.ts @@ -1,15 +1,12 @@ import type { SocialThreadInstagram } from '../../types/api-schemas.js'; -import type { InstagramRequestContext } from './account-proxy.js'; import { fetchInstagramPageWithWebInfo } from './fetch-shortcode-page.js'; import { instagramNodeToStatus } from './processor.js'; export async function constructInstagramPost( shortcode: string, - userAgent: string | undefined, - options: { credentialKey?: string } = {} + userAgent: string | undefined ): Promise { - const ctx: InstagramRequestContext = { userAgent, credentialKey: options.credentialKey }; - const page = await fetchInstagramPageWithWebInfo(shortcode, userAgent, ctx); + const page = await fetchInstagramPageWithWebInfo(shortcode, userAgent); if (!page.ok) { return { code: page.status === 404 ? 404 : 500, status: null, thread: null, author: null }; } diff --git a/packages/atmosphere/src/providers/instagram/private-api.ts b/packages/atmosphere/src/providers/instagram/private-api.ts deleted file mode 100644 index a00922b1..00000000 --- a/packages/atmosphere/src/providers/instagram/private-api.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { - instagramPrivateApiRequest, - type InstagramPrivateApiResult, - type InstagramRequestContext -} from './account-proxy.js'; -import type { InstagramCredentials } from '../../types/proxy-credentials.js'; -import { INSTAGRAM_ORIGIN } from './constants.js'; - -/* - * Endpoint paths below are the ones the Instagram Android app itself calls - * (444.0.0.46.85). Two exceptions are marked `@legacy`: they are v1 REST endpoints the current - * app no longer references (it fetches those surfaces over GraphQL/Bloks instead) but which - * i.instagram.com still serves. Treat a sudden 404 from those as "Instagram retired it" rather - * than a bug here. - */ - -export type InstagramApiOptions = { - /** Pre-resolved accounts, so a multi-call flow reuses one shuffle. */ - accounts?: InstagramCredentials[]; -}; - -const profileReferer = (username: string) => `${INSTAGRAM_ORIGIN}/${encodeURIComponent(username)}/`; - -/** `users/{username}/usernameinfo/` — profile by handle. */ -export function fetchPrivateUserByUsername( - username: string, - ctx: InstagramRequestContext | undefined, - options: InstagramApiOptions = {} -): Promise { - return instagramPrivateApiRequest(`/users/${encodeURIComponent(username)}/usernameinfo/`, ctx, { - referer: profileReferer(username), - accounts: options.accounts - }); -} - -/** `users/{pk}/info/` — profile by numeric id. */ -export function fetchPrivateUserById( - userId: string, - ctx: InstagramRequestContext | undefined, - options: InstagramApiOptions = {} -): Promise { - return instagramPrivateApiRequest(`/users/${encodeURIComponent(userId)}/info/`, ctx, { - accounts: options.accounts - }); -} - -/** `media/{pk}/info/` — full media object, including video versions Instagram hides logged-out. */ -export function fetchPrivateMediaInfo( - mediaId: string, - ctx: InstagramRequestContext | undefined, - options: InstagramApiOptions & { shortcode?: string } = {} -): Promise { - return instagramPrivateApiRequest(`/media/${encodeURIComponent(mediaId)}/info/`, ctx, { - referer: options.shortcode - ? `${INSTAGRAM_ORIGIN}/p/${encodeURIComponent(options.shortcode)}/` - : undefined, - accounts: options.accounts - }); -} - -/** `media/{pk}/comments/` — comment page. `minId`/`maxId` are the app's cursor params. */ -export function fetchPrivateMediaComments( - mediaId: string, - ctx: InstagramRequestContext | undefined, - options: InstagramApiOptions & { - maxId?: string | null; - minId?: string | null; - count?: number; - sortOrder?: 'popular' | 'recent'; - shortcode?: string; - } = {} -): Promise { - return instagramPrivateApiRequest(`/media/${encodeURIComponent(mediaId)}/comments/`, ctx, { - query: { - can_support_threading: 'true', - permalink_enabled: 'false', - sort_order: options.sortOrder ?? 'popular', - count: options.count, - max_id: options.maxId ?? undefined, - min_id: options.minId ?? undefined - }, - referer: options.shortcode - ? `${INSTAGRAM_ORIGIN}/p/${encodeURIComponent(options.shortcode)}/` - : undefined, - accounts: options.accounts - }); -} - -/** - * `media/{pk}/likers/` — accounts that liked a post. - * @legacy Not referenced by the 444.x Android build (likers moved to GraphQL), still served by v1. - */ -export function fetchPrivateMediaLikers( - mediaId: string, - ctx: InstagramRequestContext | undefined, - options: InstagramApiOptions & { shortcode?: string } = {} -): Promise { - return instagramPrivateApiRequest(`/media/${encodeURIComponent(mediaId)}/likers/`, ctx, { - referer: options.shortcode - ? `${INSTAGRAM_ORIGIN}/p/${encodeURIComponent(options.shortcode)}/` - : undefined, - accounts: options.accounts - }); -} - -/** - * `feed/user/{pk}/` — a profile's own posts. - * @legacy Not referenced by the 444.x Android build (the profile grid moved to GraphQL), still - * served by v1 and materially better than the logged-out grid for private/age-gated accounts. - */ -export function fetchPrivateUserFeed( - userId: string, - ctx: InstagramRequestContext | undefined, - options: InstagramApiOptions & { maxId?: string | null; count?: number; username?: string } = {} -): Promise { - return instagramPrivateApiRequest(`/feed/user/${encodeURIComponent(userId)}/`, ctx, { - query: { count: options.count, max_id: options.maxId ?? undefined }, - referer: options.username ? profileReferer(options.username) : undefined, - accounts: options.accounts - }); -} - -/** `usertags/{pk}/feed/` — posts the account is tagged in. */ -export function fetchPrivateUserTaggedFeed( - userId: string, - ctx: InstagramRequestContext | undefined, - options: InstagramApiOptions & { maxId?: string | null; count?: number; username?: string } = {} -): Promise { - return instagramPrivateApiRequest(`/usertags/${encodeURIComponent(userId)}/feed/`, ctx, { - query: { count: options.count, max_id: options.maxId ?? undefined }, - referer: options.username ? `${profileReferer(options.username)}tagged/` : undefined, - accounts: options.accounts - }); -} - -/** `friendships/{pk}/followers/` — follower list page. */ -export function fetchPrivateFollowers( - userId: string, - ctx: InstagramRequestContext | undefined, - options: InstagramApiOptions & { maxId?: string | null; count?: number; username?: string } = {} -): Promise { - return instagramPrivateApiRequest(`/friendships/${encodeURIComponent(userId)}/followers/`, ctx, { - query: { - count: options.count, - max_id: options.maxId ?? undefined, - search_surface: 'follow_list_page' - }, - referer: options.username ? `${profileReferer(options.username)}followers/` : undefined, - accounts: options.accounts - }); -} - -/** `friendships/{pk}/following/` — following list page. */ -export function fetchPrivateFollowing( - userId: string, - ctx: InstagramRequestContext | undefined, - options: InstagramApiOptions & { maxId?: string | null; count?: number; username?: string } = {} -): Promise { - return instagramPrivateApiRequest(`/friendships/${encodeURIComponent(userId)}/following/`, ctx, { - query: { - count: options.count, - max_id: options.maxId ?? undefined, - search_surface: 'follow_list_page' - }, - referer: options.username ? `${profileReferer(options.username)}following/` : undefined, - accounts: options.accounts - }); -} - -/** `users/search/` — user search. */ -export function fetchPrivateUserSearch( - query: string, - ctx: InstagramRequestContext | undefined, - options: InstagramApiOptions & { count?: number } = {} -): Promise { - return instagramPrivateApiRequest('/users/search/', ctx, { - query: { q: query, count: options.count, search_surface: 'user_search_page' }, - accounts: options.accounts - }); -} - -/** `fbsearch/ig_typeahead/` — blended typeahead (users + hashtags + places). */ -export function fetchPrivateTypeahead( - query: string, - ctx: InstagramRequestContext | undefined, - options: InstagramApiOptions & { count?: number } = {} -): Promise { - return instagramPrivateApiRequest('/fbsearch/ig_typeahead/', ctx, { - query: { query, count: options.count, search_surface: 'top_search_page' }, - accounts: options.accounts - }); -} - -/** `feed/reels_media/` — active stories for one or more accounts. */ -export function fetchPrivateReelsMedia( - userIds: string[], - ctx: InstagramRequestContext | undefined, - options: InstagramApiOptions = {} -): Promise { - return instagramPrivateApiRequest('/feed/reels_media/', ctx, { - method: 'POST', - body: new URLSearchParams({ - user_ids: JSON.stringify(userIds), - source: 'profile' - }).toString(), - accounts: options.accounts - }); -} diff --git a/packages/atmosphere/src/providers/instagram/private-processor.ts b/packages/atmosphere/src/providers/instagram/private-processor.ts deleted file mode 100644 index 5f4e39bb..00000000 --- a/packages/atmosphere/src/providers/instagram/private-processor.ts +++ /dev/null @@ -1,134 +0,0 @@ -import type { APIUser } from '../../types/api-schemas.js'; - -/** - * Normalizers for `i.instagram.com/api/v1` payloads. Media items from the private API already match - * the shape `instagramNodeToStatus` handles (`code`, `user`, `media_type`, `video_versions`, - * `carousel_media`, `caption.text`, `taken_at`), so only users and pagination need their own mapping. - */ - -function num(...vals: unknown[]): number { - for (const v of vals) { - if (typeof v === 'number' && Number.isFinite(v)) return Math.trunc(v); - if (typeof v === 'string' && v.trim() !== '') { - const n = Number(v); - if (Number.isFinite(n)) return Math.trunc(n); - } - } - return 0; -} - -function str(...vals: unknown[]): string { - for (const v of vals) { - if (typeof v === 'string' && v.length > 0) return v; - } - return ''; -} - -/** - * Map one private-API user record to {@link APIUser}. Handles both the full record from - * `usernameinfo` / `users/{pk}/info` and the trimmed record in follower / search lists (which omits - * counts — those come back as 0 rather than being invented). - */ -export function userFromPrivateRecord(rec: Record): APIUser | null { - const id = str(rec.pk_id, typeof rec.pk === 'number' ? String(rec.pk) : rec.pk, rec.id); - const username = str(rec.username); - if (!id || !username) return null; - const bio = typeof rec.biography === 'string' ? rec.biography : ''; - const isVerified = Boolean(rec.is_verified); - const isPrivate = Boolean(rec.is_private); - const externalUrl = str(rec.external_url); - const hdProfilePic = (rec.hd_profile_pic_url_info as { url?: string } | undefined)?.url; - return { - type: 'profile', - id, - name: str(rec.full_name) || username, - screen_name: username, - avatar_url: str(hdProfilePic, rec.profile_pic_url, rec.profile_pic_url_hd) || null, - banner_url: null, - description: bio, - raw_description: { text: bio, facets: [] }, - location: str( - (rec.address_street as string | undefined) ?? undefined, - (rec.city_name as string | undefined) ?? undefined - ), - url: `https://www.instagram.com/${encodeURIComponent(username)}/`, - protected: isPrivate, - followers: num(rec.follower_count), - following: num(rec.following_count), - statuses: num(rec.media_count), - media_count: num(rec.media_count), - likes: 0, - joined: '1970-01-01T00:00:00.000Z', - website: externalUrl - ? { url: externalUrl, display_url: externalUrl.replace(/^https?:\/\//, '') } - : null, - verification: { - verified: isVerified, - type: isVerified ? 'individual' : null - } - }; -} - -/** Pull `{ user: … }` out of `usernameinfo` / `users/{pk}/info` and map it. */ -export function userFromPrivateUserResponse(json: unknown): APIUser | null { - if (!json || typeof json !== 'object') return null; - const user = (json as { user?: unknown }).user; - if (!user || typeof user !== 'object') return null; - return userFromPrivateRecord(user as Record); -} - -/** Map a private-API `users` array (follower lists, `users/search/`) to {@link APIUser}s. */ -export function usersFromPrivateList(json: unknown): APIUser[] { - if (!json || typeof json !== 'object') return []; - const users = (json as { users?: unknown }).users; - if (!Array.isArray(users)) return []; - const out: APIUser[] = []; - for (const u of users) { - if (!u || typeof u !== 'object') continue; - const mapped = userFromPrivateRecord(u as Record); - if (mapped) out.push(mapped); - } - return out; -} - -/** - * The private API paginates with `next_max_id`, which is a string on feeds and (on some list - * endpoints) a number or a `{ next_max_id }`-shaped object. `big_list: false` means "no more pages" - * on friendship lists even when a cursor is echoed back. - */ -export function nextMaxIdFromPrivateResponse(json: unknown): string | null { - if (!json || typeof json !== 'object') return null; - const root = json as Record; - if (root.more_available === false) return null; - if (root.big_list === false) return null; - if (root.has_more_comments === false) return null; - const raw = root.next_max_id; - if (typeof raw === 'string' && raw.length > 0) return raw; - if (typeof raw === 'number' && Number.isFinite(raw)) return String(raw); - if (raw && typeof raw === 'object') { - const nested = (raw as { next_max_id?: unknown }).next_max_id; - if (typeof nested === 'string' && nested.length > 0) return nested; - if (typeof nested === 'number' && Number.isFinite(nested)) return String(nested); - } - return null; -} - -/** Media items from a private-API feed response (`items`), tolerating `{ media: … }` wrappers. */ -export function mediaItemsFromPrivateFeed(json: unknown): Record[] { - if (!json || typeof json !== 'object') return []; - const items = (json as { items?: unknown }).items; - if (!Array.isArray(items)) return []; - const out: Record[] = []; - for (const item of items) { - if (!item || typeof item !== 'object') continue; - const rec = item as Record; - // `usertags/{pk}/feed/` wraps each entry as `{ media: … }`. - const media = rec.media; - if (media && typeof media === 'object' && !Array.isArray(media)) { - out.push(media as Record); - continue; - } - out.push(rec); - } - return out; -} diff --git a/packages/atmosphere/src/providers/instagram/profile.ts b/packages/atmosphere/src/providers/instagram/profile.ts index f857f123..332cbac1 100644 --- a/packages/atmosphere/src/providers/instagram/profile.ts +++ b/packages/atmosphere/src/providers/instagram/profile.ts @@ -3,24 +3,17 @@ import type { APISearchResultsInstagram, UserAPIResponse } from '../../types/api-schemas.js'; -import { resolveInstagramAccounts, type InstagramRequestContext } from './account-proxy.js'; import { fetchInstagramCsrfToken, fetchTimelineGraphqlPage, fetchWebProfileInfo } from './client.js'; import { - decodeMaxIdCursor, decodeProfileCursor, - encodeMaxIdCursor, encodeProfileCursor, - sameInstagramHandle, type InstagramProfileCursorV1 } from './cursors.js'; -import { fetchPrivateUserFeed } from './private-api.js'; -import { mediaItemsFromPrivateFeed, nextMaxIdFromPrivateResponse } from './private-processor.js'; -import { edgeNodeToStatus, instagramNodeToStatus } from './processor.js'; -import { resolveInstagramUser } from './resolve-user.js'; +import { edgeNodeToStatus, fullUserFromWebProfile } from './processor.js'; function getWebProfileUser(json: unknown): Record | null { const root = json as { data?: { user?: unknown } }; @@ -163,69 +156,18 @@ function parseFelixGraphql(json: unknown): { export async function constructInstagramProfile( username: string, - userAgent: string | undefined, - options: { credentialKey?: string } = {} + userAgent: string | undefined ): Promise { - const ctx: InstagramRequestContext = { userAgent, credentialKey: options.credentialKey }; - const resolved = await resolveInstagramUser(username, ctx); - if (resolved.code === 404) return { code: 404, message: 'User not found' }; - if (resolved.code !== 200 || !resolved.user) { + const res = await fetchWebProfileInfo(username, userAgent); + if (!res.ok) { + if (res.status === 404) return { code: 404, message: 'User not found' }; return { code: 500, message: 'Instagram profile request failed' }; } - return { code: 200, message: 'OK', user: resolved.user }; -} - -/** - * One page of a profile's own posts through the account proxy (`feed/user/{pk}/`). - * `videosOnly` filters the page after the fact — Instagram has no logged-in reels-tab REST - * endpoint, so a page can come back with fewer than `count` results while still paginating. - */ -async function privateFeedPage(params: { - userId: string; - username: string; - count: number; - maxId: string | null; - videosOnly: boolean; - ctx: InstagramRequestContext; - accounts: Awaited>; -}): Promise { - const res = await fetchPrivateUserFeed(params.userId, params.ctx, { - accounts: params.accounts, - count: params.count, - maxId: params.maxId, - username: params.username - }); - if (!res.ok) { - return { - code: res.status === 404 ? 404 : 500, - results: [], - cursor: { top: null, bottom: null } - }; + const user = fullUserFromWebProfile(res.json as Record); + if (!user) { + return { code: 404, message: 'User not found' }; } - - const ownerFallback = { id: params.userId, username: params.username }; - const results: APIInstagramStatus[] = []; - for (const item of mediaItemsFromPrivateFeed(res.json)) { - if (results.length >= params.count) break; - if (params.videosOnly && !nodeShowsVideoInGrid(item)) continue; - const status = instagramNodeToStatus(item, ownerFallback, { - userAgent: params.ctx.userAgent - }); - if (status) results.push(status); - } - - const nextMaxId = nextMaxIdFromPrivateResponse(res.json); - const bottom = nextMaxId - ? encodeMaxIdCursor({ - v: 1, - k: 'feed', - id: params.userId, - u: params.username, - m: nextMaxId, - c: params.count - }) - : null; - return { code: 200, results, cursor: { top: null, bottom } }; + return { code: 200, message: 'OK', user }; } async function timelinePageFromGraphql( @@ -283,62 +225,11 @@ async function timelinePageFromGraphql( return { code: 200, results, cursor: { top: null, bottom } }; } -/** - * Shared account-proxy entry for the profile grid / reels tab. Returns `null` when the request - * should fall through to the logged-out web path (no proxy, or the cursor belongs to it). - */ -async function tryPrivateProfileFeed( - username: string, - videosOnly: boolean, - options: { count: number; cursor: string | null; userAgent?: string; credentialKey?: string } -): Promise { - const ctx: InstagramRequestContext = { - userAgent: options.userAgent, - credentialKey: options.credentialKey - }; - const accounts = await resolveInstagramAccounts(ctx); - if (!accounts.length) return null; - - if (options.cursor) { - const decoded = decodeMaxIdCursor(options.cursor); - // A profile cursor from the logged-out path is still valid; let the caller handle it. - if (!decoded || decoded.k !== 'feed' || !sameInstagramHandle(decoded.u, username)) return null; - return privateFeedPage({ - userId: decoded.id, - username, - count: decoded.c, - maxId: decoded.m, - videosOnly, - ctx, - accounts - }); - } - - const resolved = await resolveInstagramUser(username, ctx, { accounts }); - if (resolved.code === 404) { - return { code: 404, results: [], cursor: { top: null, bottom: null } }; - } - if (resolved.code !== 200 || !resolved.user) return null; - - const page = await privateFeedPage({ - userId: resolved.user.id, - username, - count: Math.min(100, Math.max(1, Math.floor(options.count))), - maxId: null, - videosOnly, - ctx, - accounts - }); - return page.code === 200 ? page : null; -} - export async function constructInstagramProfileStatuses( username: string, - options: { count: number; cursor: string | null; userAgent?: string; credentialKey?: string } + options: { count: number; cursor: string | null; userAgent?: string } ): Promise { const count = Math.min(100, Math.max(1, Math.floor(options.count))); - const proxied = await tryPrivateProfileFeed(username, false, options); - if (proxied) return proxied; if (options.cursor) { const decoded = decodeProfileCursor(options.cursor); if (!decoded || decoded.k !== 't') { @@ -379,11 +270,9 @@ export async function constructInstagramProfileStatuses( export async function constructInstagramProfileVideos( username: string, - options: { count: number; cursor: string | null; userAgent?: string; credentialKey?: string } + options: { count: number; cursor: string | null; userAgent?: string } ): Promise { const count = Math.min(100, Math.max(1, Math.floor(options.count))); - const proxied = await tryPrivateProfileFeed(username, true, options); - if (proxied) return proxied; if (options.cursor) { const decoded = decodeProfileCursor(options.cursor); if (!decoded || decoded.k !== 'r') { diff --git a/packages/atmosphere/src/providers/instagram/relationships.ts b/packages/atmosphere/src/providers/instagram/relationships.ts deleted file mode 100644 index 3d4da780..00000000 --- a/packages/atmosphere/src/providers/instagram/relationships.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { APIProfileRelationshipList } from '../../types/api-schemas.js'; -import { resolveInstagramAccounts, type InstagramRequestContext } from './account-proxy.js'; -import { decodeMaxIdCursor, encodeMaxIdCursor, sameInstagramHandle } from './cursors.js'; -import { fetchPrivateFollowers, fetchPrivateFollowing } from './private-api.js'; -import { nextMaxIdFromPrivateResponse, usersFromPrivateList } from './private-processor.js'; -import { resolveInstagramUser } from './resolve-user.js'; - -export type InstagramRelationshipKind = 'followers' | 'following'; - -const empty = (code: number): APIProfileRelationshipList => ({ - code, - results: [], - cursor: { top: null, bottom: null } -}); - -/** - * Follower / following lists. Instagram only exposes these to a logged-in session, so this needs an - * account proxy; without one it reports 501 rather than pretending the account has no followers. - */ -export async function constructInstagramRelationshipList( - username: string, - kind: InstagramRelationshipKind, - options: { count: number; cursor: string | null; ctx?: InstagramRequestContext } -): Promise { - const count = Math.min(100, Math.max(1, Math.floor(options.count))); - const accounts = await resolveInstagramAccounts(options.ctx); - if (!accounts.length) { - return empty(501); - } - - let userId: string; - let maxId: string | null = null; - if (options.cursor) { - const decoded = decodeMaxIdCursor(options.cursor); - if (!decoded || decoded.k !== kind || !sameInstagramHandle(decoded.u, username)) { - return empty(400); - } - userId = decoded.id; - maxId = decoded.m; - } else { - const resolved = await resolveInstagramUser(username, options.ctx, { accounts }); - if (resolved.code !== 200 || !resolved.user) { - return empty(resolved.code); - } - userId = resolved.user.id; - } - - const fetcher = kind === 'followers' ? fetchPrivateFollowers : fetchPrivateFollowing; - const res = await fetcher(userId, options.ctx, { accounts, count, maxId, username }); - if (!res.ok) { - return empty(res.status === 404 ? 404 : 500); - } - - const results = usersFromPrivateList(res.json).slice(0, count); - const nextMaxId = nextMaxIdFromPrivateResponse(res.json); - const bottom = nextMaxId - ? encodeMaxIdCursor({ v: 1, k: kind, id: userId, u: username, m: nextMaxId, c: count }) - : null; - return { code: 200, results, cursor: { top: null, bottom } }; -} diff --git a/packages/atmosphere/src/providers/instagram/resolve-user.ts b/packages/atmosphere/src/providers/instagram/resolve-user.ts deleted file mode 100644 index b591cffa..00000000 --- a/packages/atmosphere/src/providers/instagram/resolve-user.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { APIUser } from '../../types/api-schemas.js'; -import type { InstagramCredentials } from '../../types/proxy-credentials.js'; -import { resolveInstagramAccounts, type InstagramRequestContext } from './account-proxy.js'; -import { fetchWebProfileInfo } from './client.js'; -import { fetchPrivateUserByUsername } from './private-api.js'; -import { fullUserFromWebProfile } from './processor.js'; -import { userFromPrivateUserResponse } from './private-processor.js'; - -export type ResolvedInstagramUser = { - code: 200 | 404 | 500; - user: APIUser | null; - /** Accounts resolved along the way, so callers can reuse one shuffle across follow-up calls. */ - accounts: InstagramCredentials[]; -}; - -/** - * Resolve a handle to a profile. Prefers the account proxy (`users/{username}/usernameinfo/`), - * which also works for age-gated accounts, and falls back to logged-out `web_profile_info`. - */ -export async function resolveInstagramUser( - username: string, - ctx: InstagramRequestContext | undefined, - options: { accounts?: InstagramCredentials[] } = {} -): Promise { - const accounts = options.accounts ?? (await resolveInstagramAccounts(ctx)); - - if (accounts.length) { - const res = await fetchPrivateUserByUsername(username, ctx, { accounts }); - if (res.ok) { - const user = userFromPrivateUserResponse(res.json); - if (user) return { code: 200, user, accounts }; - } - if (res.status === 404) { - return { code: 404, user: null, accounts }; - } - } - - const web = await fetchWebProfileInfo(username, ctx?.userAgent); - if (!web.ok) { - return { code: web.status === 404 ? 404 : 500, user: null, accounts }; - } - const user = fullUserFromWebProfile(web.json as Record); - if (!user) { - return { code: 404, user: null, accounts }; - } - return { code: 200, user, accounts }; -} diff --git a/packages/atmosphere/src/providers/instagram/search.ts b/packages/atmosphere/src/providers/instagram/search.ts deleted file mode 100644 index c0d42c58..00000000 --- a/packages/atmosphere/src/providers/instagram/search.ts +++ /dev/null @@ -1,131 +0,0 @@ -import type { - APITypeaheadResponse, - APITypeaheadTopic, - APIUser, - APIUserListResults -} from '../../types/api-schemas.js'; -import { resolveInstagramAccounts, type InstagramRequestContext } from './account-proxy.js'; -import { fetchPrivateTypeahead, fetchPrivateUserSearch } from './private-api.js'; -import { userFromPrivateRecord, usersFromPrivateList } from './private-processor.js'; - -const emptyUserList = (code: number): APIUserListResults => ({ - code, - results: [], - cursor: { top: null, bottom: null } -}); - -const emptyTypeahead = (code: number, query: string): APITypeaheadResponse => ({ - code, - query, - num_results: 0, - users: [], - topics: [], - events: [] -}); - -/** - * User search (`users/search/`). Logged-out Instagram has no usable search surface, so this needs - * the account proxy; without one it reports 501. - * - * The endpoint returns one ranked page and no cursor, so `cursor.bottom` is always null. - */ -export async function constructInstagramUserSearch( - query: string, - options: { count: number; ctx?: InstagramRequestContext } -): Promise { - const count = Math.min(50, Math.max(1, Math.floor(options.count))); - const accounts = await resolveInstagramAccounts(options.ctx); - if (!accounts.length) { - return emptyUserList(501); - } - const res = await fetchPrivateUserSearch(query, options.ctx, { accounts, count }); - if (!res.ok) { - return emptyUserList(500); - } - return { - code: 200, - results: usersFromPrivateList(res.json).slice(0, count), - cursor: { top: null, bottom: null } - }; -} - -type TypeaheadEntry = { - user?: Record; - hashtag?: { name?: string; media_count?: number; formatted_media_count?: string }; - place?: { - location?: { name?: string; city?: string; short_name?: string }; - title?: string; - subtitle?: string; - }; -}; - -function typeaheadEntries(json: unknown): TypeaheadEntry[] { - if (!json || typeof json !== 'object') return []; - const list = (json as { list?: unknown }).list; - if (!Array.isArray(list)) return []; - return list.filter((e): e is TypeaheadEntry => Boolean(e) && typeof e === 'object'); -} - -/** - * Blended typeahead (`fbsearch/ig_typeahead/`). Instagram's mix is users / hashtags / places; - * hashtags and places both land in `topics` since the API v2 shape has no separate place bucket. - * `events` stays empty — Instagram has no equivalent. - */ -export async function constructInstagramTypeahead( - query: string, - options: { ctx?: InstagramRequestContext; count?: number } = {} -): Promise { - const accounts = await resolveInstagramAccounts(options.ctx); - if (!accounts.length) { - return emptyTypeahead(501, query); - } - const res = await fetchPrivateTypeahead(query, options.ctx, { accounts, count: options.count }); - if (!res.ok) { - return emptyTypeahead(500, query); - } - - const users: APIUser[] = []; - const topics: APITypeaheadTopic[] = []; - for (const entry of typeaheadEntries(res.json)) { - if (entry.user) { - const mapped = userFromPrivateRecord(entry.user); - if (mapped) users.push(mapped); - continue; - } - const tag = entry.hashtag; - const tagName = tag?.name; - if (tagName) { - topics.push({ - topic: `#${tagName}`, - result_context: { - display_string: tag?.formatted_media_count - ? `${tag.formatted_media_count} posts` - : undefined, - redirect_url: `https://www.instagram.com/explore/tags/${encodeURIComponent(tagName)}/`, - types: [{ type: 'hashtag' }] - } - }); - continue; - } - const place = entry.place; - const placeName = place?.location?.name ?? place?.title; - if (placeName) { - topics.push({ - topic: placeName, - result_context: { - display_string: place?.subtitle ?? place?.location?.city, - types: [{ type: 'place' }] - } - }); - } - } - - return { - code: 200, - query, - num_results: users.length + topics.length, - users, - topics, - events: [] - }; -} diff --git a/packages/atmosphere/src/providers/instagram/stories.ts b/packages/atmosphere/src/providers/instagram/stories.ts deleted file mode 100644 index 694991a6..00000000 --- a/packages/atmosphere/src/providers/instagram/stories.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { APIInstagramStatus, APISearchResultsInstagram } from '../../types/api-schemas.js'; -import { resolveInstagramAccounts, type InstagramRequestContext } from './account-proxy.js'; -import { fetchPrivateReelsMedia } from './private-api.js'; -import { instagramNodeToStatus } from './processor.js'; -import { resolveInstagramUser } from './resolve-user.js'; - -const empty = (code: number): APISearchResultsInstagram => ({ - code, - results: [], - cursor: { top: null, bottom: null } -}); - -/** `feed/reels_media/` answers with `reels` keyed by pk and/or a `reels_media` array. */ -function storyItemsFromReelsResponse(json: unknown, userId: string): Record[] { - if (!json || typeof json !== 'object') return []; - const root = json as { reels?: Record; reels_media?: unknown[] }; - const trays: unknown[] = []; - const keyed = root.reels?.[userId]; - if (keyed) trays.push(keyed); - if (Array.isArray(root.reels_media)) trays.push(...root.reels_media); - - const out: Record[] = []; - const seen = new Set(); - for (const tray of trays) { - if (!tray || typeof tray !== 'object') continue; - const items = (tray as { items?: unknown }).items; - if (!Array.isArray(items)) continue; - for (const item of items) { - if (!item || typeof item !== 'object') continue; - const rec = item as Record; - const key = String(rec.pk ?? rec.id ?? ''); - if (key && seen.has(key)) continue; - if (key) seen.add(key); - out.push(rec); - } - } - return out; -} - -/** - * An account's currently-active stories. Stories expire after 24 hours and are logged-in only, so - * this needs the account proxy; there is no pagination to expose. - */ -export async function constructInstagramProfileStories( - username: string, - options: { ctx?: InstagramRequestContext } = {} -): Promise { - const accounts = await resolveInstagramAccounts(options.ctx); - if (!accounts.length) { - return empty(501); - } - - const resolved = await resolveInstagramUser(username, options.ctx, { accounts }); - if (resolved.code !== 200 || !resolved.user) { - return empty(resolved.code); - } - const userId = resolved.user.id; - - const res = await fetchPrivateReelsMedia([userId], options.ctx, { accounts }); - if (!res.ok) { - return empty(res.status === 404 ? 404 : 500); - } - - const ownerFallback = { - id: userId, - username, - fullName: resolved.user.name, - pic: resolved.user.avatar_url - }; - const results: APIInstagramStatus[] = []; - for (const item of storyItemsFromReelsResponse(res.json, userId)) { - const status = instagramNodeToStatus(item, ownerFallback, { - userAgent: options.ctx?.userAgent - }); - if (status) results.push(status); - } - return { code: 200, results, cursor: { top: null, bottom: null } }; -} diff --git a/packages/atmosphere/src/providers/instagram/tagged.ts b/packages/atmosphere/src/providers/instagram/tagged.ts deleted file mode 100644 index 86157d6b..00000000 --- a/packages/atmosphere/src/providers/instagram/tagged.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { APIInstagramStatus, APISearchResultsInstagram } from '../../types/api-schemas.js'; -import { resolveInstagramAccounts, type InstagramRequestContext } from './account-proxy.js'; -import { decodeMaxIdCursor, encodeMaxIdCursor, sameInstagramHandle } from './cursors.js'; -import { fetchPrivateUserTaggedFeed } from './private-api.js'; -import { mediaItemsFromPrivateFeed, nextMaxIdFromPrivateResponse } from './private-processor.js'; -import { instagramNodeToStatus } from './processor.js'; -import { resolveInstagramUser } from './resolve-user.js'; - -const empty = (code: number): APISearchResultsInstagram => ({ - code, - results: [], - cursor: { top: null, bottom: null } -}); - -/** - * Posts an account is tagged in (`usertags/{pk}/feed/`) — the closest analogue to X's - * `/profile/{handle}/media` for a third-party grid. Logged-in only, so this needs the account proxy. - */ -export async function constructInstagramProfileTagged( - username: string, - options: { count: number; cursor: string | null; ctx?: InstagramRequestContext } -): Promise { - const count = Math.min(100, Math.max(1, Math.floor(options.count))); - const accounts = await resolveInstagramAccounts(options.ctx); - if (!accounts.length) { - return empty(501); - } - - let userId: string; - let maxId: string | null = null; - if (options.cursor) { - const decoded = decodeMaxIdCursor(options.cursor); - if (!decoded || decoded.k !== 'tagged' || !sameInstagramHandle(decoded.u, username)) { - return empty(400); - } - userId = decoded.id; - maxId = decoded.m; - } else { - const resolved = await resolveInstagramUser(username, options.ctx, { accounts }); - if (resolved.code !== 200 || !resolved.user) { - return empty(resolved.code); - } - userId = resolved.user.id; - } - - const res = await fetchPrivateUserTaggedFeed(userId, options.ctx, { - accounts, - count, - maxId, - username - }); - if (!res.ok) { - return empty(res.status === 404 ? 404 : 500); - } - - const ownerFallback = { id: userId, username }; - const results: APIInstagramStatus[] = []; - for (const item of mediaItemsFromPrivateFeed(res.json).slice(0, count)) { - const status = instagramNodeToStatus(item, ownerFallback, { - userAgent: options.ctx?.userAgent - }); - if (status) results.push(status); - } - - const nextMaxId = nextMaxIdFromPrivateResponse(res.json); - const bottom = nextMaxId - ? encodeMaxIdCursor({ v: 1, k: 'tagged', id: userId, u: username, m: nextMaxId, c: count }) - : null; - return { code: 200, results, cursor: { top: null, bottom } }; -} diff --git a/packages/atmosphere/src/providers/threads/account-proxy.ts b/packages/atmosphere/src/providers/threads/account-proxy.ts deleted file mode 100644 index 37b8e6b2..00000000 --- a/packages/atmosphere/src/providers/threads/account-proxy.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { withTimeout } from '../../helpers/with-timeout.js'; -import { getInstagramProviderEnv } from '../instagram-runtime.js'; -import { - hasInstagramAccountProxy, - resolveInstagramAccounts, - type InstagramRequestContext -} from '../instagram/account-proxy.js'; -import { INSTAGRAM_ASBD_ID } from '../instagram/constants.js'; -import type { InstagramCredentials } from '../../types/proxy-credentials.js'; -import { - THREADS_ANDROID_APP_ID, - THREADS_ANDROID_CAPABILITIES, - THREADS_ANDROID_USER_AGENT, - THREADS_API_V1, - THREADS_ORIGIN -} from './constants.js'; - -const WEB_USER_AGENT = - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; - -/** - * Per-request Threads context. Threads accounts *are* Instagram accounts, so the proxy pool is the - * Instagram one — this is the same `{ credentialKey }` shape, aliased so callers in the Threads - * provider don't have to reach into the Instagram module. - */ -export type ThreadsRequestContext = InstagramRequestContext; - -/** True when this deployment can proxy Threads through a logged-in Instagram account. */ -export const hasThreadsAccountProxy = hasInstagramAccountProxy; - -/** - * Threads reuses the Instagram credential pool wholesale: one `sessionid` authenticates both - * surfaces, and only the client fingerprint differs (see {@link threadsProxyHeaders}). - */ -export const resolveThreadsAccounts = resolveInstagramAccounts; - -function cookieHeaderFor(account: InstagramCredentials): string { - const parts = [`sessionid=${account.sessionId}`]; - if (account.userId) parts.push(`ds_user_id=${account.userId}`); - if (account.csrfToken) parts.push(`csrftoken=${account.csrfToken}`); - if (account.mid) parts.push(`mid=${account.mid}`); - if (account.deviceId) parts.push(`ig_did=${account.deviceId}`); - return parts.join('; '); -} - -/** - * Headers for one proxied Threads request. Same session cookies as the Instagram proxy, but with - * the Barcelona app id — `text_feed/…` and `fbsearch/text_app/…` are only served to it. `android` - * accounts get the decompiled app's fingerprint; `web` accounts keep a browser fingerprint with - * `threads.com` as the origin, matching where such a `sessionid` was harvested. - */ -export function threadsProxyHeaders( - account: InstagramCredentials, - options: { referer?: string; acceptHint?: string } = {} -): Record { - const android = account.platform === 'android'; - const headers: Record = { - 'User-Agent': android ? THREADS_ANDROID_USER_AGENT : WEB_USER_AGENT, - 'Accept': '*/*', - 'Accept-Language': 'en-US,en;q=0.9', - 'X-IG-App-ID': THREADS_ANDROID_APP_ID, - 'X-IG-Capabilities': THREADS_ANDROID_CAPABILITIES, - 'X-IG-WWW-Claim': '0', - // `BarcelonaProfileNetworkSource` stamps this on every feed read. - 'X-IG-Accept-Hint': options.acceptHint ?? 'feed', - 'Cookie': cookieHeaderFor(account) - }; - if (android) { - headers['X-IG-Connection-Type'] = 'WIFI'; - if (account.androidDeviceId) { - headers['X-IG-Device-ID'] = account.androidDeviceId; - } - } else { - headers['X-ASBD-ID'] = INSTAGRAM_ASBD_ID; - headers['Origin'] = THREADS_ORIGIN; - headers['Referer'] = options.referer ?? `${THREADS_ORIGIN}/`; - headers['Sec-Fetch-Dest'] = 'empty'; - headers['Sec-Fetch-Mode'] = 'cors'; - headers['Sec-Fetch-Site'] = 'same-origin'; - } - if (account.csrfToken) { - headers['X-CSRFToken'] = account.csrfToken; - } - return headers; -} - -/** HTTP statuses where another account is worth trying: auth/checkpoint/rate limit. */ -const ROTATE_STATUSES = new Set([401, 403, 429]); - -export type ThreadsPrivateApiResult = { - ok: boolean; - /** 0 when no account was available at all (proxy not configured). */ - status: number; - json: unknown | null; - /** Set when a request actually went out, for logging. */ - accountUsed?: string; -}; - -/** - * Calls an `i.instagram.com/api/v1/…` endpoint as the Threads app, rotating accounts on - * auth/rate-limit failures. Returns `{ ok: false, status: 0 }` when no proxy account is configured - * so callers can fall back to their logged-out path (or report 501). - * - * `pathParams` fills the `{user_id}` / `{post_id}` placeholders the app's own route templates use; - * anything left over is sent as a query parameter, which is how the app's request builder behaves. - */ -export async function threadsPrivateApiRequest( - path: string, - ctx: ThreadsRequestContext | undefined, - options: { - pathParams?: Record; - query?: Record; - method?: 'GET' | 'POST'; - body?: string; - referer?: string; - acceptHint?: string; - accounts?: InstagramCredentials[]; - } = {} -): Promise { - const accounts = options.accounts ?? (await resolveThreadsAccounts(ctx)); - if (!accounts.length) { - return { ok: false, status: 0, json: null }; - } - - let resolvedPath = path; - for (const [key, value] of Object.entries(options.pathParams ?? {})) { - resolvedPath = resolvedPath.replace(`{${key}}`, encodeURIComponent(value)); - } - - const { apiRoot } = getInstagramProviderEnv(); - const url = new URL( - `${apiRoot}${THREADS_API_V1}${resolvedPath.startsWith('/') ? resolvedPath : `/${resolvedPath}`}` - ); - for (const [key, value] of Object.entries(options.query ?? {})) { - if (value === undefined || value === null || value === '') continue; - url.searchParams.set( - key, - typeof value === 'boolean' ? (value ? 'true' : 'false') : String(value) - ); - } - - let last: ThreadsPrivateApiResult = { ok: false, status: 500, json: null }; - for (const account of accounts) { - const headers = threadsProxyHeaders(account, { - referer: options.referer, - acceptHint: options.acceptHint - }); - if (options.method === 'POST') { - headers['Content-Type'] = 'application/x-www-form-urlencoded'; - } - let res: Response; - let text: string; - let parsed: unknown; - let parseFailed: boolean; - try { - // Fetch can resolve on headers; keep body read + JSON.parse inside the timeout so a - // stalled body aborts and rotates instead of hanging the request. - const timed = await withTimeout(async signal => { - const response = await fetch(url.toString(), { - method: options.method ?? 'GET', - headers, - body: options.method === 'POST' ? (options.body ?? '') : undefined, - // Never follow redirects with account cookies — a 3xx to another origin would - // leak sessionid. Fetch throws TypeError on redirect, which rotates accounts. - redirect: 'error', - signal - }); - if (!response.ok) { - return { response, text: '', parsed: null, parseFailed: false }; - } - const body = await response.text(); - try { - return { response, text: body, parsed: JSON.parse(body) as unknown, parseFailed: false }; - } catch { - return { response, text: body, parsed: null, parseFailed: true }; - } - }); - res = timed.response; - text = timed.text; - parsed = timed.parsed; - parseFailed = timed.parseFailed; - } catch (err) { - console.error('[threads] private API request threw', { - path: resolvedPath, - account: account.username, - message: err instanceof Error ? err.message : String(err) - }); - last = { ok: false, status: 500, json: null, accountUsed: account.username }; - continue; - } - - if (!res.ok) { - console.error('[threads] private API request failed', { - path: resolvedPath, - account: account.username, - status: res.status - }); - last = { ok: false, status: res.status, json: null, accountUsed: account.username }; - if (ROTATE_STATUSES.has(res.status)) continue; - return last; - } - - const trimmed = text.trim(); - // A logged-out or checkpointed session gets an HTML login page rather than JSON. - if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) { - console.error('[threads] private API returned non-JSON (session likely invalid)', { - path: resolvedPath, - account: account.username - }); - last = { ok: false, status: res.status, json: null, accountUsed: account.username }; - continue; - } - if (parseFailed) { - last = { ok: false, status: res.status, json: null, accountUsed: account.username }; - continue; - } - // The private API answers 200 with `{ status: 'fail' }` for soft failures (spam block, - // feedback_required). Rotate rather than surfacing an empty page as success. - if ( - parsed && - typeof parsed === 'object' && - (parsed as { status?: unknown }).status === 'fail' - ) { - console.error('[threads] private API returned status=fail', { - path: resolvedPath, - account: account.username - }); - last = { ok: false, status: 502, json: parsed, accountUsed: account.username }; - continue; - } - return { ok: true, status: res.status, json: parsed, accountUsed: account.username }; - } - return last; -} diff --git a/packages/atmosphere/src/providers/threads/constants.ts b/packages/atmosphere/src/providers/threads/constants.ts index d6474fd7..c8ff2e9c 100644 --- a/packages/atmosphere/src/providers/threads/constants.ts +++ b/packages/atmosphere/src/providers/threads/constants.ts @@ -67,38 +67,3 @@ export const THREADS_RELAY_DEFAULTS: Record = { __relay_internal__pv__BarcelonaShouldShowFediverseM075Featuresrelayprovider: false, __relay_internal__pv__BarcelonaIsInternalUserrelayprovider: false }; - -/* - * Threads Android app constants, read out of a decompiled `com.instagram.barcelona` build - * (445.0.0.2.83, versionCode 511505005). The app ships the same `InstagramSpecificHeaderServiceLayer` - * as Instagram but stamps its own `X-IG-App-ID`, so a proxied Threads request is an Instagram - * session presenting the Barcelona fingerprint. - */ - -/** Threads (Barcelona) app id. Distinct from Instagram's — `X-IG-App-ID` on every app request. */ -export const THREADS_ANDROID_APP_ID = '3419628305025917'; - -/** `X-IG-Capabilities` the app sends; identical to the Instagram build's. */ -export const THREADS_ANDROID_CAPABILITIES = '3brTv10='; - -export const THREADS_ANDROID_VERSION_NAME = '445.0.0.2.83'; -export const THREADS_ANDROID_VERSION_CODE = '511505005'; - -/** - * Android `User-Agent`, in the app's own - * `Barcelona Android (/; dpi; x; ; ; ; ; ; )` - * shape (built by `AbstractC870503bB.A00` from the `"%s %s Android %s"` / - * `"(%s/%s; %s; %s; %s; %s; %s; %s; %s)"` format strings — the maker slot collapses to one value - * when `Build.MANUFACTURER` equals `Build.BRAND`, which it does on the device modelled here). - * Kept as one fixed, plausible device so a proxied session presents a stable fingerprint. - */ -export const THREADS_ANDROID_USER_AGENT = - `Barcelona ${THREADS_ANDROID_VERSION_NAME} Android (34/14; 420dpi; 1080x2340; ` + - `samsung; SM-S911B; dm1q; qcom; en_US; ${THREADS_ANDROID_VERSION_CODE})`; - -/** Private API prefix shared with Instagram (`i.instagram.com/api/v1/…`). */ -export const THREADS_API_V1 = '/api/v1'; - -/** `search_surface` values the app sends to `fbsearch/text_app/serp/` (`X.03cj`). */ -export const THREADS_SEARCH_SURFACE_TOP = 'ig_text_search_serp_top'; -export const THREADS_SEARCH_SURFACE_RECENT = 'ig_text_search_serp_recent'; diff --git a/packages/atmosphere/src/providers/threads/conversation.ts b/packages/atmosphere/src/providers/threads/conversation.ts index 68862f45..4d8776db 100644 --- a/packages/atmosphere/src/providers/threads/conversation.ts +++ b/packages/atmosphere/src/providers/threads/conversation.ts @@ -1,13 +1,6 @@ import type { SocialConversation } from '../../types/api-status.js'; -import { resolveThreadsAccounts, type ThreadsRequestContext } from './account-proxy.js'; import { fetchThreadsPostPage, fetchThreadsSession, type ThreadsSession } from './client.js'; import { decodeThreadsConversationCursor, encodeThreadsConversationCursor } from './cursors.js'; -import { fetchThreadsPostReplies } from './private-api.js'; -import { - containingThreadChain, - nextTokenFromThreadsFeed, - replyRowsFromThreadsReplies -} from './private-processor.js'; import { buildThreadsTombstone, threadsPostToStatus, @@ -40,96 +33,6 @@ export type ThreadsConversationResult = | { ok: true; data: SocialConversation } | { ok: false; message: string; data?: SocialConversation }; -const conversationError = (code: number): SocialConversation => ({ - code, - status: null, - thread: null, - replies: null, - author: null, - cursor: null -}); - -/** - * Replies through the account proxy (`text_feed/{post_id}/replies/`), which is what the Threads app - * itself calls. Logged-out `threads.com` truncates reply threads hard, so this is the better source - * whenever credentials exist. Returns `null` when the proxy isn't configured or the call failed, so - * the caller can fall back to the logged-out Relay connection. - */ -async function proxiedConversation(params: { - mediaId: string; - shortcode: string; - count: number; - sortOrder: 'top' | 'recent'; - pagingToken: string | null; - ctx: ThreadsRequestContext; -}): Promise { - const accounts = await resolveThreadsAccounts(params.ctx); - if (!accounts.length) return null; - - const sortOrder = params.sortOrder === 'recent' ? 'all' : 'top'; - const res = await fetchThreadsPostReplies(params.mediaId, params.ctx, { - accounts, - sortOrder, - count: params.count, - pagingToken: params.pagingToken, - shortcode: params.shortcode - }); - if (!res.ok) { - return res.status === 404 ? conversationError(404) : null; - } - - const chain = containingThreadChain(res.json); - if (!chain.length) return null; - - const owner = chain[0]?.user as Record | undefined; - const ownerFb = { - id: String(owner?.pk ?? owner?.id ?? ''), - username: String(owner?.username ?? ''), - fullName: typeof owner?.full_name === 'string' ? owner.full_name : undefined, - pic: typeof owner?.profile_pic_url === 'string' ? owner.profile_pic_url : null - }; - - const chainStatuses = chain - .map(post => threadsPostToStatus(post, ownerFb)) - .filter((s): s is NonNullable => Boolean(s)); - if (!chainStatuses.length) { - return { - ...conversationError(404), - status: buildThreadsTombstone('unavailable', { id: params.shortcode }) - }; - } - - const status = chainStatuses[chainStatuses.length - 1]!; - const threadPrefix = chainStatuses.length > 1 ? chainStatuses.slice(0, -1) : []; - - const replies = replyRowsFromThreadsReplies(res.json) - .slice(0, params.count) - .map(row => xdtThreadEdgeToSubstatus({ node: row }, params.shortcode, ownerFb.username)) - .filter((r): r is NonNullable => Boolean(r)); - - const nextToken = nextTokenFromThreadsFeed(res.json); - const bottom = nextToken - ? encodeThreadsConversationCursor({ - v: 1, - postId: params.mediaId, - shortcode: params.shortcode, - sort: params.sortOrder === 'recent' ? 'RECENT' : 'TOP', - after: nextToken, - count: params.count, - src: 'proxy' - }) - : null; - - return { - code: 200, - status, - thread: threadPrefix.length ? threadPrefix : [status], - replies, - author: status.author, - cursor: { bottom } - }; -} - export async function constructThreadsConversation( rawId: string, options: { @@ -137,7 +40,6 @@ export async function constructThreadsConversation( count: number; sortOrder: 'top' | 'recent'; userAgent?: string; - ctx?: ThreadsRequestContext; } ): Promise { const shortcode = normalizeThreadsPostId(rawId); @@ -151,30 +53,6 @@ export async function constructThreadsConversation( const count = Math.min(100, Math.max(1, Math.floor(options.count))); const sortGraphql: 'TOP' | 'RECENT' = options.sortOrder === 'recent' ? 'RECENT' : 'TOP'; - const decodedCursor = options.cursor ? decodeThreadsConversationCursor(options.cursor) : null; - if (options.cursor && (!decodedCursor || decodedCursor.shortcode !== shortcode)) { - return { ok: false, message: 'Invalid cursor', data: conversationError(400) }; - } - - // A proxy cursor can only be replayed against the proxy, and vice versa. - if (decodedCursor?.src !== 'gql') { - const proxied = await proxiedConversation({ - mediaId, - shortcode, - count, - sortOrder: options.sortOrder, - pagingToken: decodedCursor?.after ?? null, - ctx: { ...options.ctx, userAgent: options.ctx?.userAgent ?? options.userAgent } - }); - if (proxied) { - return { ok: true, data: proxied }; - } - if (decodedCursor?.src === 'proxy') { - // The cursor belongs to a source this request can no longer reach. - return { ok: false, message: 'Invalid cursor', data: conversationError(400) }; - } - } - const session: ThreadsSession | null = await fetchThreadsSession(options.userAgent); if (!session) { return { @@ -190,7 +68,25 @@ export async function constructThreadsConversation( }; } - const after: string | null = decodedCursor?.after ?? null; + let after: string | null = null; + if (options.cursor) { + const decoded = decodeThreadsConversationCursor(options.cursor); + if (!decoded || decoded.shortcode !== shortcode) { + return { + ok: false, + message: 'Invalid cursor', + data: { + code: 400, + status: null, + thread: null, + replies: null, + author: null, + cursor: null + } + }; + } + after = decoded.after; + } const res = await fetchThreadsPostPage({ mediaId, @@ -296,8 +192,7 @@ export async function constructThreadsConversation( shortcode, sort: sortGraphql, after: afterForBottom, - count, - src: 'gql' + count }) : null; diff --git a/packages/atmosphere/src/providers/threads/cursors.ts b/packages/atmosphere/src/providers/threads/cursors.ts index 5ce097da..63ee6e1d 100644 --- a/packages/atmosphere/src/providers/threads/cursors.ts +++ b/packages/atmosphere/src/providers/threads/cursors.ts @@ -8,12 +8,6 @@ export type ThreadsConversationCursorV1 = { /** Upstream Relay `end_cursor` for the replies connection (opaque). */ after: string | null; count: number; - /** - * Which reply source minted this cursor. The logged-out Relay connection and the proxied - * `text_feed/{post_id}/replies/` route hand back incompatible tokens, so a cursor can only be - * replayed against the source it came from. - */ - src?: 'gql' | 'proxy'; }; export type ThreadsProfileTimelineCursorV1 = { @@ -68,8 +62,7 @@ export function decodeThreadsConversationCursor(raw: string): ThreadsConversatio shortcode: o.shortcode, sort: o.sort, after: typeof o.after === 'string' || o.after === null ? o.after : null, - count: Math.floor(o.count), - src: o.src === 'proxy' ? 'proxy' : 'gql' + count: Math.floor(o.count) }; } catch { return null; @@ -102,102 +95,3 @@ export function decodeThreadsProfileTimelineCursor( return null; } } - -/** - * Token cursor for the proxy-backed list surfaces (profile tabs, likes, follow lists). They all - * paginate the same way — an opaque upstream token plus the resolved user/media id — so one cursor - * shape covers them, with `k` keeping a cursor from being replayed against a different surface. - */ -export type ThreadsTokenCursorV1 = { - v: 1; - /** Which surface minted this cursor. */ - k: 'threads' | 'replies' | 'reposts' | 'media' | 'followers' | 'following' | 'likes'; - /** Numeric user pk (profile tabs, follow lists) or media pk (likes). */ - id: string; - /** Handle the caller asked for, so a cursor can't be swapped onto another profile. */ - u: string; - /** Upstream `paging_tokens.downwards` / `next_max_id`. */ - t: string | null; - c: number; -}; - -export type ThreadsSearchCursorV1 = { - v: 1; - q: string; - /** `recent` tab vs `top` tab; the two rank differently and their tokens aren't interchangeable. */ - r: boolean; - /** Upstream `page_token`. */ - t: string | null; - /** Upstream `rank_token`, replayed on every page of one search session. */ - rt: string | null; - /** Page ordinal the app sends as `page_num`. */ - p: number; - c: number; -}; - -const validCount = (c: unknown): c is number => - typeof c === 'number' && Number.isFinite(c) && c >= 1 && c <= 100; - -export function encodeThreadsTokenCursor(p: ThreadsTokenCursorV1): string { - return b64urlEncode(JSON.stringify(p)); -} - -export function decodeThreadsTokenCursor( - raw: string, - kind: ThreadsTokenCursorV1['k'] -): ThreadsTokenCursorV1 | null { - const json = b64urlDecode(raw); - if (!json) return null; - try { - const o = JSON.parse(json) as Partial; - if (o.v !== 1 || o.k !== kind) return null; - if (typeof o.id !== 'string' || typeof o.u !== 'string') return null; - if (!validCount(o.c)) return null; - return { - v: 1, - k: kind, - id: o.id, - u: o.u, - t: typeof o.t === 'string' || o.t === null ? o.t : null, - c: Math.floor(o.c) - }; - } catch { - return null; - } -} - -export function encodeThreadsSearchCursor(p: ThreadsSearchCursorV1): string { - return b64urlEncode(JSON.stringify(p)); -} - -export function decodeThreadsSearchCursor(raw: string): ThreadsSearchCursorV1 | null { - const json = b64urlDecode(raw); - if (!json) return null; - try { - const bytes = new Uint8Array(json.length); - for (let i = 0; i < json.length; i++) { - bytes[i] = json.charCodeAt(i); - } - const text = new TextDecoder('utf-8').decode(bytes); - const o = JSON.parse(text) as Partial; - if (o.v !== 1 || typeof o.q !== 'string' || typeof o.r !== 'boolean') return null; - if (!validCount(o.c)) return null; - if (typeof o.p !== 'number' || !Number.isFinite(o.p) || o.p < 0) return null; - return { - v: 1, - q: o.q, - r: o.r, - t: typeof o.t === 'string' || o.t === null ? o.t : null, - rt: typeof o.rt === 'string' || o.rt === null ? o.rt : null, - p: Math.floor(o.p), - c: Math.floor(o.c) - }; - } catch { - return null; - } -} - -/** Handles differ only by case / a leading `@`; a cursor should survive both. */ -export function sameThreadsHandle(a: string, b: string): boolean { - return a.replace(/^@/, '').toLowerCase() === b.replace(/^@/, '').toLowerCase(); -} diff --git a/packages/atmosphere/src/providers/threads/likes.ts b/packages/atmosphere/src/providers/threads/likes.ts deleted file mode 100644 index 1d33c373..00000000 --- a/packages/atmosphere/src/providers/threads/likes.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { APIUserListResults } from '../../types/api-schemas.js'; -import { resolveThreadsAccounts, type ThreadsRequestContext } from './account-proxy.js'; -import { fetchThreadsMediaLikers } from './private-api.js'; -import { usersFromThreadsList } from './private-processor.js'; -import { normalizeThreadsPostId, threadsShortcodeToMediaId } from './shortcode.js'; - -const empty = (code: number): APIUserListResults => ({ - code, - results: [], - cursor: { top: null, bottom: null } -}); - -/** - * Accounts that liked a post — Threads' analogue of X's like list. - * - * `media/{pk}/likers/` is logged-in only, so this needs the account proxy and returns a single - * un-paginated page, which is all the endpoint serves. - */ -export async function constructThreadsStatusLikes( - rawId: string, - options: { count: number; ctx?: ThreadsRequestContext } -): Promise { - const count = Math.min(100, Math.max(1, Math.floor(options.count))); - const accounts = await resolveThreadsAccounts(options.ctx); - if (!accounts.length) { - return empty(501); - } - - const shortcode = normalizeThreadsPostId(rawId); - let mediaId: string; - try { - mediaId = threadsShortcodeToMediaId(shortcode); - } catch { - return empty(400); - } - - const res = await fetchThreadsMediaLikers(mediaId, options.ctx, { accounts, shortcode }); - if (!res.ok) { - return empty(res.status === 404 ? 404 : 500); - } - return { - code: 200, - results: usersFromThreadsList(res.json).slice(0, count), - cursor: { top: null, bottom: null } - }; -} diff --git a/packages/atmosphere/src/providers/threads/post.ts b/packages/atmosphere/src/providers/threads/post.ts index a30495ee..720d883e 100644 --- a/packages/atmosphere/src/providers/threads/post.ts +++ b/packages/atmosphere/src/providers/threads/post.ts @@ -1,8 +1,5 @@ import type { SocialThread } from '../../types/api-status.js'; -import { resolveThreadsAccounts, type ThreadsRequestContext } from './account-proxy.js'; import { fetchThreadsPostPage, fetchThreadsSession } from './client.js'; -import { fetchThreadsSingleThread } from './private-api.js'; -import { containingThreadChain } from './private-processor.js'; import { buildThreadsTombstone, threadsPostToStatus } from './processor.js'; import { normalizeThreadsPostId, threadsShortcodeToMediaId } from './shortcode.js'; @@ -15,65 +12,9 @@ function extractPostPageEdges(json: unknown): { return { edges: edges as { node?: Record; cursor?: string }[] }; } -const notFound = (): SocialThread => ({ code: 404, status: null, thread: null, author: null }); - -/** Owner details to fall back on for posts whose `user` block is trimmed down. */ -function ownerFallbackFrom(chain: Record[]): { - id: string; - username: string; - fullName?: string; - pic: string | null; -} { - const owner = chain[0]?.user as Record | undefined; - return { - id: String(owner?.pk ?? owner?.id ?? ''), - username: String(owner?.username ?? ''), - fullName: typeof owner?.full_name === 'string' ? owner.full_name : undefined, - pic: typeof owner?.profile_pic_url === 'string' ? owner.profile_pic_url : null - }; -} - -/** - * A post's own self-reply chain becomes `thread`, with the last entry as the focal `status` — - * the same convention the logged-out path has always used. - */ -function threadFromChain(chain: Record[], shortcode: string): SocialThread { - const ownerFb = ownerFallbackFrom(chain); - const statuses = chain - .map(post => threadsPostToStatus(post, ownerFb)) - .filter((s): s is NonNullable => Boolean(s)); - - if (!statuses.length) { - return { - code: 404, - status: buildThreadsTombstone('unavailable', { id: shortcode }), - thread: null, - author: null - }; - } - - const status = statuses[statuses.length - 1]!; - const prefix = statuses.length > 1 ? statuses.slice(0, -1) : []; - return { - code: 200, - status, - thread: prefix.length ? prefix : [status], - author: status.author - }; -} - -/** - * Resolve a single Threads post. - * - * With an account proxy configured this reads `text_feed/{post_id}/single_thread/`, which the - * Threads app itself uses and which serves posts logged-out `threads.com` withholds (age-gated - * accounts, limited-audience posts). Otherwise — and whenever that call fails — it falls back to - * the logged-out Relay query, so a deployment without credentials behaves exactly as before. - */ export async function constructThreadsPost( rawId: string, - userAgent: string | undefined, - ctx?: ThreadsRequestContext + userAgent: string | undefined ): Promise { const shortcode = normalizeThreadsPostId(rawId); let mediaId: string; @@ -83,21 +24,6 @@ export async function constructThreadsPost( return { code: 400, status: null, thread: null, author: null }; } - const requestCtx: ThreadsRequestContext = { ...ctx, userAgent: ctx?.userAgent ?? userAgent }; - const accounts = await resolveThreadsAccounts(requestCtx); - if (accounts.length) { - const proxied = await fetchThreadsSingleThread(mediaId, requestCtx, { accounts }); - if (proxied.ok) { - const chain = containingThreadChain(proxied.json); - if (chain.length) { - return threadFromChain(chain, shortcode); - } - } - if (proxied.status === 404) { - return notFound(); - } - } - const session = await fetchThreadsSession(userAgent); if (!session) { return { code: 500, status: null, thread: null, author: null }; @@ -116,22 +42,53 @@ export async function constructThreadsPost( } const { edges } = extractPostPageEdges(res.json); + if (!edges.length) { + return { code: 404, status: null, thread: null, author: null }; + } + const focalNode = edges[0]?.node; if (!focalNode) { - return notFound(); + return { code: 404, status: null, thread: null, author: null }; } - const items = focalNode.thread_items; + const items = (focalNode.thread_items as unknown[]) ?? []; if (!Array.isArray(items) || items.length === 0) { - return notFound(); + return { code: 404, status: null, thread: null, author: null }; } - const chain = items - .map(it => (it as { post?: Record })?.post) - .filter((p): p is Record => Boolean(p)); - if (!chain.length) { - return notFound(); + const firstPost = (items[0] as { post?: Record })?.post; + const owner = firstPost?.user as Record | undefined; + const ownerFb = { + id: String(owner?.pk ?? owner?.id ?? ''), + username: String(owner?.username ?? ''), + fullName: typeof owner?.full_name === 'string' ? owner.full_name : undefined, + pic: typeof owner?.profile_pic_url === 'string' ? owner.profile_pic_url : null + }; + + const chainStatuses = items + .map(it => { + const p = (it as { post?: Record }).post; + return p ? threadsPostToStatus(p, ownerFb) : null; + }) + .filter((s): s is NonNullable => Boolean(s)); + + if (!chainStatuses.length) { + return { + code: 404, + status: buildThreadsTombstone('unavailable', { id: shortcode }), + thread: null, + author: null + }; } - return threadFromChain(chain, shortcode); + const status = chainStatuses[chainStatuses.length - 1]!; + const threadPrefix = + chainStatuses.length > 1 ? chainStatuses.slice(0, -1) : ([] as typeof chainStatuses); + + return { + code: 200, + status, + thread: threadPrefix.length ? threadPrefix : [status], + author: status.author + }; } diff --git a/packages/atmosphere/src/providers/threads/private-api.ts b/packages/atmosphere/src/providers/threads/private-api.ts deleted file mode 100644 index af212104..00000000 --- a/packages/atmosphere/src/providers/threads/private-api.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { - threadsPrivateApiRequest, - type ThreadsPrivateApiResult, - type ThreadsRequestContext -} from './account-proxy.js'; -import type { InstagramCredentials } from '../../types/proxy-credentials.js'; -import { - THREADS_ORIGIN, - THREADS_SEARCH_SURFACE_RECENT, - THREADS_SEARCH_SURFACE_TOP -} from './constants.js'; - -/* - * Endpoint paths and parameter names below are the ones the Threads Android app itself calls - * (`com.instagram.barcelona` 445.0.0.2.83). The app keeps the `{user_id}` / `{post_id}` templates - * literally — `ProfileFeedDataSource`, `SerpFeedPagingSource`, `SearchTopicsRepository` and - * `LikesListRemoteDataSource` are the classes these were read from — so they are reproduced - * verbatim here and filled in by `threadsPrivateApiRequest`'s `pathParams`. - * - * Everything runs against `i.instagram.com/api/v1/…` with a logged-in Instagram session; only the - * `X-IG-App-ID` distinguishes a Threads request from an Instagram one. - */ - -export type ThreadsApiOptions = { - /** Pre-resolved accounts, so a multi-call flow reuses one shuffle. */ - accounts?: InstagramCredentials[]; -}; - -const profileReferer = (username: string) => `${THREADS_ORIGIN}/@${encodeURIComponent(username)}`; - -/** Which profile tab to read. The app models these as four sibling routes, not one parameter. */ -export type ThreadsProfileTab = 'threads' | 'replies' | 'reposts' | 'media'; - -const PROFILE_TAB_PATHS: Record = { - threads: 'text_feed/{user_id}/profile/', - replies: 'text_feed/{user_id}/profile/replies/', - reposts: 'text_feed/{user_id}/profile/reposts/', - media: 'text_feed/{user_id}/profile/media/' -}; - -/** - * `text_feed/{user_id}/profile/…` — one page of a profile tab. - * - * The app also sends `exclude_reposts` on the main tab so its dedicated Reposts tab doesn't - * duplicate rows; FxEmbed leaves it off so `/statuses` matches what the logged-out timeline serves. - */ -export function fetchThreadsProfileFeed( - userId: string, - tab: ThreadsProfileTab, - ctx: ThreadsRequestContext | undefined, - options: ThreadsApiOptions & { - maxId?: string | null; - count?: number; - username?: string; - } = {} -): Promise { - return threadsPrivateApiRequest(PROFILE_TAB_PATHS[tab], ctx, { - pathParams: { user_id: userId }, - query: { - user_id: userId, - count: options.count, - max_id: options.maxId ?? undefined, - is_app_start: false - }, - referer: options.username ? profileReferer(options.username) : undefined, - accounts: options.accounts - }); -} - -/** `text_feed/{post_id}/replies/` — replies to a post. `sortOrder` is the app's `top` / `all`. */ -export function fetchThreadsPostReplies( - postId: string, - ctx: ThreadsRequestContext | undefined, - options: ThreadsApiOptions & { - pagingToken?: string | null; - count?: number; - sortOrder?: 'top' | 'all'; - shortcode?: string; - username?: string; - } = {} -): Promise { - return threadsPrivateApiRequest('text_feed/{post_id}/replies/', ctx, { - pathParams: { post_id: postId }, - query: { - post_id: postId, - sort_order: options.sortOrder ?? 'top', - count: options.count, - paging_token: options.pagingToken ?? undefined, - check_for_unavailable_replies: true - }, - referer: - options.username && options.shortcode - ? `${profileReferer(options.username)}/post/${encodeURIComponent(options.shortcode)}` - : undefined, - accounts: options.accounts - }); -} - -/** `text_feed/{post_id}/single_thread/` — the focal post and its own thread chain, no replies. */ -export function fetchThreadsSingleThread( - postId: string, - ctx: ThreadsRequestContext | undefined, - options: ThreadsApiOptions = {} -): Promise { - return threadsPrivateApiRequest('text_feed/{post_id}/single_thread/', ctx, { - pathParams: { post_id: postId }, - query: { post_id: postId }, - accounts: options.accounts - }); -} - -/** - * `fbsearch/text_app/serp/` — post search. - * - * `recent` is the app's `0` / `1` toggle and has to agree with `search_surface`; passing one - * without the other returns the other tab's ranking. - */ -export function fetchThreadsSearchSerp( - query: string, - ctx: ThreadsRequestContext | undefined, - options: ThreadsApiOptions & { - recent?: boolean; - pageToken?: string | null; - pageNum?: number | null; - rankToken?: string | null; - tagId?: string | null; - } = {} -): Promise { - const recent = options.recent === true; - return threadsPrivateApiRequest('fbsearch/text_app/serp/', ctx, { - query: { - query, - search_surface: recent ? THREADS_SEARCH_SURFACE_RECENT : THREADS_SEARCH_SURFACE_TOP, - recent: recent ? '1' : '0', - is_from_pull_to_refresh: '0', - tag_id: options.tagId ?? undefined, - page_token: options.pageToken ?? undefined, - page_num: options.pageNum ?? undefined, - rank_token: options.rankToken ?? undefined - }, - referer: `${THREADS_ORIGIN}/search?q=${encodeURIComponent(query)}`, - accounts: options.accounts - }); -} - -/** `fbsearch/text_app/trends/` — the Threads trending topic list. */ -export function fetchThreadsTrends( - ctx: ThreadsRequestContext | undefined, - options: ThreadsApiOptions & { first?: number } = {} -): Promise { - return threadsPrivateApiRequest('fbsearch/text_app/trends/', ctx, { - query: { - first: options.first ?? undefined, - serp_prefetch: false, - should_fetch_related_communities: false - }, - accounts: options.accounts - }); -} - -/** `text_feed/{user_id}/liked_posts/` — posts an account liked (only its own, session-scoped). */ -export function fetchThreadsLikedPosts( - userId: string, - ctx: ThreadsRequestContext | undefined, - options: ThreadsApiOptions & { maxId?: string | null } = {} -): Promise { - return threadsPrivateApiRequest('text_feed/{user_id}/liked_posts/', ctx, { - pathParams: { user_id: userId }, - query: { user_id: userId, max_id: options.maxId ?? undefined }, - accounts: options.accounts - }); -} - -/** `media/{pk}/likers/` — accounts that liked a post; the Threads app shares Instagram's route. */ -export function fetchThreadsMediaLikers( - mediaId: string, - ctx: ThreadsRequestContext | undefined, - options: ThreadsApiOptions & { shortcode?: string; username?: string } = {} -): Promise { - return threadsPrivateApiRequest(`media/${encodeURIComponent(mediaId)}/likers/`, ctx, { - referer: - options.username && options.shortcode - ? `${profileReferer(options.username)}/post/${encodeURIComponent(options.shortcode)}` - : undefined, - acceptHint: 'user_list', - accounts: options.accounts - }); -} - -/** `friendships/{pk}/followers/` — follower list page (shared Instagram graph). */ -export function fetchThreadsFollowers( - userId: string, - ctx: ThreadsRequestContext | undefined, - options: ThreadsApiOptions & { maxId?: string | null; count?: number; username?: string } = {} -): Promise { - return threadsPrivateApiRequest(`friendships/${encodeURIComponent(userId)}/followers/`, ctx, { - query: { - count: options.count, - max_id: options.maxId ?? undefined, - search_surface: 'follow_list_page' - }, - referer: options.username ? `${profileReferer(options.username)}/followers` : undefined, - acceptHint: 'user_list', - accounts: options.accounts - }); -} - -/** `friendships/{pk}/following/` — following list page (shared Instagram graph). */ -export function fetchThreadsFollowing( - userId: string, - ctx: ThreadsRequestContext | undefined, - options: ThreadsApiOptions & { maxId?: string | null; count?: number; username?: string } = {} -): Promise { - return threadsPrivateApiRequest(`friendships/${encodeURIComponent(userId)}/following/`, ctx, { - query: { - count: options.count, - max_id: options.maxId ?? undefined, - search_surface: 'follow_list_page' - }, - referer: options.username ? `${profileReferer(options.username)}/following` : undefined, - acceptHint: 'user_list', - accounts: options.accounts - }); -} - -/** `users/search/` — user search, filtered to Threads-active accounts by the caller. */ -export function fetchThreadsUserSearch( - query: string, - ctx: ThreadsRequestContext | undefined, - options: ThreadsApiOptions & { count?: number } = {} -): Promise { - return threadsPrivateApiRequest('users/search/', ctx, { - query: { q: query, count: options.count, search_surface: 'user_search_page' }, - acceptHint: 'user_list', - accounts: options.accounts - }); -} - -/** `users/{username}/usernameinfo/` — handle → numeric pk, shared with Instagram. */ -export function fetchThreadsUserByUsername( - username: string, - ctx: ThreadsRequestContext | undefined, - options: ThreadsApiOptions = {} -): Promise { - return threadsPrivateApiRequest(`users/${encodeURIComponent(username)}/usernameinfo/`, ctx, { - referer: profileReferer(username), - accounts: options.accounts - }); -} - -/** `fbsearch/text_app/keyword/search/` — keyword suggestions behind the search box. */ -export function fetchThreadsKeywordSearch( - query: string, - ctx: ThreadsRequestContext | undefined, - options: ThreadsApiOptions = {} -): Promise { - return threadsPrivateApiRequest('fbsearch/text_app/keyword/search/', ctx, { - query: { query }, - referer: `${THREADS_ORIGIN}/search?q=${encodeURIComponent(query)}`, - accounts: options.accounts - }); -} diff --git a/packages/atmosphere/src/providers/threads/private-processor.ts b/packages/atmosphere/src/providers/threads/private-processor.ts deleted file mode 100644 index 881efde4..00000000 --- a/packages/atmosphere/src/providers/threads/private-processor.ts +++ /dev/null @@ -1,230 +0,0 @@ -import type { APIThreadsStatus, APIUser } from '../../types/api-schemas.js'; -import { threadsPostToStatus } from './processor.js'; - -/** - * Normalizers for the Threads slice of `i.instagram.com/api/v1`. - * - * The `post` records these endpoints return are the same objects the logged-out `threads.com` - * GraphQL wraps (`xdt_api__v1__text_feed__…` is a thin proxy over exactly these routes), so - * {@link threadsPostToStatus} already understands them. Only the envelope — how rows are nested and - * how pages are chained — differs, and that is what lives here. - */ - -const isRecord = (v: unknown): v is Record => - Boolean(v) && typeof v === 'object' && !Array.isArray(v); - -/** - * Rows arrive under a handful of keys depending on the surface: `items` on profile tabs, - * `reply_threads` on a post's replies, `media` on search. Each row is either a thread - * (`{ thread_items: [{ post }] }`) or a bare post. - */ -function feedRows(json: unknown): Record[] { - if (!isRecord(json)) return []; - for (const key of ['items', 'reply_threads', 'media', 'results', 'threads']) { - const value = json[key]; - if (Array.isArray(value)) { - return value.filter(isRecord); - } - } - return []; -} - -/** - * The post a row should be represented by. A thread row carries the whole chain in `thread_items`; - * the last item is the one the app renders as the row (earlier items are the "show more" context), - * matching how the logged-out timeline path already picks its status. - */ -function postFromRow(row: Record): Record | null { - const items = row.thread_items; - if (Array.isArray(items) && items.length > 0) { - for (let i = items.length - 1; i >= 0; i--) { - const item = items[i]; - if (isRecord(item) && isRecord(item.post)) return item.post; - } - return null; - } - if (isRecord(row.post)) return row.post; - if (isRecord(row.media)) return row.media; - // Search rows can be bare media objects. - return typeof row.code === 'string' || typeof row.pk === 'string' || typeof row.pk === 'number' - ? row - : null; -} - -/** Every `post` in a thread row, oldest first — the self-reply chain above a focal post. */ -export function threadChainFromRow(row: Record): Record[] { - const items = row.thread_items; - if (!Array.isArray(items)) { - const single = postFromRow(row); - return single ? [single] : []; - } - const out: Record[] = []; - for (const item of items) { - if (isRecord(item) && isRecord(item.post)) out.push(item.post); - } - return out; -} - -/** Map a private-API Threads feed page to statuses, dropping rows that can't be rendered. */ -export function statusesFromThreadsFeed( - json: unknown, - ownerFallback: { id: string; username: string; fullName?: string; pic?: string | null } -): APIThreadsStatus[] { - const out: APIThreadsStatus[] = []; - for (const row of feedRows(json)) { - const post = postFromRow(row); - if (!post) continue; - const status = threadsPostToStatus(post, ownerFallback); - if (status) out.push(status); - } - return out; -} - -/** Thread rows with their chains intact, for surfaces that render context above the row. */ -export function threadRowsFromThreadsFeed(json: unknown): Record[][] { - return feedRows(json) - .map(threadChainFromRow) - .filter(chain => chain.length > 0); -} - -/** - * Next-page token for a Threads feed. - * - * Profile tabs and reply lists paginate with `paging_tokens.downwards`; the shared Instagram feed - * routes still answer with `next_max_id`; search uses `page_token`. `has_more: false` (or - * `more_available: false`) ends the walk even when a token is echoed back. - */ -export function nextTokenFromThreadsFeed(json: unknown): string | null { - if (!isRecord(json)) return null; - if (json.has_more === false) return null; - if (json.more_available === false) return null; - const pagingTokens = json.paging_tokens; - if (isRecord(pagingTokens)) { - const down = pagingTokens.downwards; - if (typeof down === 'string' && down.length > 0) return down; - } - for (const key of ['next_max_id', 'page_token', 'next_page_token', 'paging_token']) { - const raw = json[key]; - if (typeof raw === 'string' && raw.length > 0) return raw; - if (typeof raw === 'number' && Number.isFinite(raw)) return String(raw); - } - return null; -} - -/** `rank_token` echoed by search, which the app replays on every subsequent page. */ -export function rankTokenFromThreadsSearch(json: unknown): string | null { - if (!isRecord(json)) return null; - const raw = json.rank_token; - return typeof raw === 'string' && raw.length > 0 ? raw : null; -} - -/** - * Users out of a Threads-flavoured list. These records are Instagram user records with the extra - * `is_active_on_text_post_app` / `has_onboarded_to_text_post_app` flags, so profile URLs point at - * `threads.com` rather than `instagram.com`. - */ -export function usersFromThreadsList( - json: unknown, - options: { threadsOnly?: boolean } = {} -): APIUser[] { - if (!isRecord(json)) return []; - const users = json.users; - if (!Array.isArray(users)) return []; - const out: APIUser[] = []; - for (const raw of users) { - if (!isRecord(raw)) continue; - if (options.threadsOnly && !isThreadsUser(raw)) continue; - const mapped = threadsUserFromPrivateRecord(raw); - if (mapped) out.push(mapped); - } - return out; -} - -/** Whether an Instagram user record belongs to someone who actually uses Threads. */ -export function isThreadsUser(rec: Record): boolean { - return Boolean(rec.is_active_on_text_post_app) || Boolean(rec.has_onboarded_to_text_post_app); -} - -const num = (...vals: unknown[]): number => { - for (const v of vals) { - if (typeof v === 'number' && Number.isFinite(v)) return Math.trunc(v); - if (typeof v === 'string' && v.trim() !== '') { - const n = Number(v); - if (Number.isFinite(n)) return Math.trunc(n); - } - } - return 0; -}; - -const str = (...vals: unknown[]): string => { - for (const v of vals) { - if (typeof v === 'string' && v.length > 0) return v; - } - return ''; -}; - -/** Map one private-API user record to an {@link APIUser} pointing at Threads. */ -export function threadsUserFromPrivateRecord(rec: Record): APIUser | null { - const id = str(rec.pk_id, typeof rec.pk === 'number' ? String(rec.pk) : rec.pk, rec.id); - const username = str(rec.username); - if (!id || !username) return null; - const bio = typeof rec.biography === 'string' ? rec.biography : ''; - const isVerified = Boolean(rec.is_verified); - const externalUrl = str(rec.external_url); - const hdProfilePic = (rec.hd_profile_pic_url_info as { url?: string } | undefined)?.url; - return { - type: 'profile', - id, - name: str(rec.full_name) || username, - screen_name: username, - avatar_url: str(hdProfilePic, rec.profile_pic_url, rec.profile_pic_url_hd) || null, - banner_url: null, - description: bio, - raw_description: { text: bio, facets: [] }, - location: '', - url: `https://www.threads.com/@${encodeURIComponent(username)}/`, - // Threads privacy is its own flag; `is_private` is the Instagram account's. - protected: Boolean(rec.text_post_app_is_private ?? rec.is_private), - followers: num(rec.follower_count), - following: num(rec.following_count), - statuses: 0, - media_count: 0, - likes: 0, - joined: '1970-01-01T00:00:00.000Z', - website: externalUrl - ? { url: externalUrl, display_url: externalUrl.replace(/^https?:\/\//, '') } - : null, - profile_embed: true, - verification: { - verified: isVerified, - type: isVerified ? 'individual' : null - } - }; -} - -/** - * The focal post's own chain out of a `single_thread` / `replies` response. - * - * Both routes wrap the post being viewed in `containing_thread`; older payloads put it first in - * `items` instead, so that is the fallback. - */ -export function containingThreadChain(json: unknown): Record[] { - if (!isRecord(json)) return []; - const containing = json.containing_thread; - if (isRecord(containing)) { - const chain = threadChainFromRow(containing); - if (chain.length) return chain; - } - const rows = feedRows(json); - return rows.length ? threadChainFromRow(rows[0]!) : []; -} - -/** Reply thread rows out of a `text_feed/{post_id}/replies/` response, newest page first. */ -export function replyRowsFromThreadsReplies(json: unknown): Record[] { - if (!isRecord(json)) return []; - const replies = json.reply_threads; - if (Array.isArray(replies)) return replies.filter(isRecord); - // Some payloads fold the focal post into `items` and list replies after it. - const rows = feedRows(json); - return rows.length > 1 ? rows.slice(1) : []; -} diff --git a/packages/atmosphere/src/providers/threads/profile-tabs.ts b/packages/atmosphere/src/providers/threads/profile-tabs.ts deleted file mode 100644 index d5563b0a..00000000 --- a/packages/atmosphere/src/providers/threads/profile-tabs.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { APISearchResultsThreads } from '../../types/api-schemas.js'; -import { resolveThreadsAccounts, type ThreadsRequestContext } from './account-proxy.js'; -import { - decodeThreadsTokenCursor, - encodeThreadsTokenCursor, - sameThreadsHandle -} from './cursors.js'; -import { fetchThreadsProfileFeed, type ThreadsProfileTab } from './private-api.js'; -import { nextTokenFromThreadsFeed, statusesFromThreadsFeed } from './private-processor.js'; -import { resolveThreadsUser } from './resolve-user.js'; - -export type { ThreadsProfileTab }; - -const empty = (code: number): APISearchResultsThreads => ({ - code, - results: [], - cursor: { top: null, bottom: null } -}); - -/** - * A profile's Replies / Reposts / Media tab. - * - * Logged-out `threads.com` only serves the main Threads tab, so these need the account proxy; - * without one they report 501 rather than pretending the tab is empty. The main tab keeps its - * logged-out path — see `constructThreadsProfileStatuses` in `profile.ts`. - */ -export async function constructThreadsProfileTab( - username: string, - tab: ThreadsProfileTab, - options: { count: number; cursor: string | null; ctx?: ThreadsRequestContext } -): Promise { - const count = Math.min(100, Math.max(1, Math.floor(options.count))); - const accounts = await resolveThreadsAccounts(options.ctx); - if (!accounts.length) { - return empty(501); - } - - const handle = username.replace(/^@/, ''); - let userId: string; - let maxId: string | null = null; - - if (options.cursor) { - const decoded = decodeThreadsTokenCursor(options.cursor, tab); - if (!decoded || !sameThreadsHandle(decoded.u, handle)) { - return empty(400); - } - userId = decoded.id; - maxId = decoded.t; - } else { - const resolved = await resolveThreadsUser(handle, options.ctx, { accounts }); - if (resolved.code !== 200 || !resolved.user) { - return empty(resolved.code); - } - userId = resolved.user.id; - } - - const res = await fetchThreadsProfileFeed(userId, tab, options.ctx, { - accounts, - maxId, - count, - username: handle - }); - if (!res.ok) { - return empty(res.status === 404 ? 404 : 500); - } - - const results = statusesFromThreadsFeed(res.json, { - id: userId, - username: handle, - pic: null - }).slice(0, count); - - const nextToken = nextTokenFromThreadsFeed(res.json); - const bottom = nextToken - ? encodeThreadsTokenCursor({ v: 1, k: tab, id: userId, u: handle, t: nextToken, c: count }) - : null; - - return { code: 200, results, cursor: { top: null, bottom } }; -} diff --git a/packages/atmosphere/src/providers/threads/profile.ts b/packages/atmosphere/src/providers/threads/profile.ts index 97fa31a9..2cd1f2b4 100644 --- a/packages/atmosphere/src/providers/threads/profile.ts +++ b/packages/atmosphere/src/providers/threads/profile.ts @@ -1,5 +1,4 @@ import type { APISearchResultsThreads, UserAPIResponse } from '../../types/api-schemas.js'; -import { resolveThreadsAccounts, type ThreadsRequestContext } from './account-proxy.js'; import { fetchThreadsProfilePage, fetchThreadsProfileTimeline, @@ -8,12 +7,9 @@ import { } from './client.js'; import { decodeThreadsProfileTimelineCursor, - decodeThreadsTokenCursor, encodeThreadsProfileTimelineCursor } from './cursors.js'; -import { constructThreadsProfileTab } from './profile-tabs.js'; import { threadsPostToStatus, userFromThreadsProfilePayload } from './processor.js'; -import { resolveThreadsUser } from './resolve-user.js'; function userIdFromHovercard(json: unknown): string | null { const u = (json as { data?: { user?: Record } })?.data?.user; @@ -98,28 +94,10 @@ function profileTimelinePage( return { results, nextAfter }; } -/** - * Resolve a profile. With an account proxy configured this reads `users/{username}/usernameinfo/`, - * which also covers accounts logged-out `threads.com` will not show; otherwise it falls back to the - * logged-out hovercard + profile-page pair. - */ export async function constructThreadsProfile( username: string, - userAgent: string | undefined, - ctx?: ThreadsRequestContext + userAgent: string | undefined ): Promise { - const requestCtx: ThreadsRequestContext = { ...ctx, userAgent: ctx?.userAgent ?? userAgent }; - const accounts = await resolveThreadsAccounts(requestCtx); - if (accounts.length) { - const resolved = await resolveThreadsUser(username, requestCtx, { accounts }); - if (resolved.code === 200 && resolved.user) { - return { code: 200, message: 'OK', user: resolved.user }; - } - if (resolved.code === 404) { - return { code: 404, message: 'User not found' }; - } - } - const session = await fetchThreadsSession(userAgent); if (!session) { return { code: 500, message: 'Threads session failed' }; @@ -158,43 +136,11 @@ export async function constructThreadsProfile( return { code: 200, message: 'OK', user }; } -/** - * A profile's main Threads tab. - * - * Prefers the account proxy, which serves the same rows the app sees; falls back to the logged-out - * Relay connection. The two mint different cursors, so a page walk stays on whichever source - * started it — a proxy cursor decodes only as a token cursor, and vice versa. - */ export async function constructThreadsProfileStatuses( username: string, - options: { count: number; cursor: string | null; userAgent?: string; ctx?: ThreadsRequestContext } + options: { count: number; cursor: string | null; userAgent?: string } ): Promise { const count = Math.min(100, Math.max(1, Math.floor(options.count))); - const requestCtx: ThreadsRequestContext = { - ...options.ctx, - userAgent: options.ctx?.userAgent ?? options.userAgent - }; - const isProxyCursor = - options.cursor != null && decodeThreadsTokenCursor(options.cursor, 'threads') != null; - if (options.cursor == null || isProxyCursor) { - const proxied = await constructThreadsProfileTab(username, 'threads', { - count, - cursor: options.cursor, - ctx: requestCtx - }); - if (isProxyCursor) { - // A proxy cursor means nothing to the logged-out connection, so this page walk is over - // either way — 501 (proxy since removed) becomes a plain bad cursor. - return proxied.code === 501 - ? { code: 400, results: [], cursor: { top: null, bottom: null } } - : proxied; - } - // Fresh request: fall through to the logged-out path only when the proxy couldn't answer. - if (proxied.code !== 501 && proxied.code !== 500) { - return proxied; - } - } - const session = await fetchThreadsSession(options.userAgent); if (!session) { return { code: 500, results: [], cursor: { top: null, bottom: null } }; diff --git a/packages/atmosphere/src/providers/threads/relationships.ts b/packages/atmosphere/src/providers/threads/relationships.ts deleted file mode 100644 index ab02bdd9..00000000 --- a/packages/atmosphere/src/providers/threads/relationships.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { APIProfileRelationshipList } from '../../types/api-schemas.js'; -import { resolveThreadsAccounts, type ThreadsRequestContext } from './account-proxy.js'; -import { - decodeThreadsTokenCursor, - encodeThreadsTokenCursor, - sameThreadsHandle -} from './cursors.js'; -import { fetchThreadsFollowers, fetchThreadsFollowing } from './private-api.js'; -import { nextTokenFromThreadsFeed, usersFromThreadsList } from './private-processor.js'; -import { resolveThreadsUser } from './resolve-user.js'; - -export type ThreadsRelationshipKind = 'followers' | 'following'; - -const empty = (code: number): APIProfileRelationshipList => ({ - code, - results: [], - cursor: { top: null, bottom: null } -}); - -/** - * Follower / following lists. Threads shares Instagram's social graph, so these come from - * `friendships/{pk}/…` — a logged-in surface, hence the account proxy and a 501 without one. - * - * Results are *not* filtered to Threads-active accounts: the counts a Threads profile shows are - * the Instagram follower counts, so filtering would make the list disagree with the profile. - */ -export async function constructThreadsRelationshipList( - username: string, - kind: ThreadsRelationshipKind, - options: { count: number; cursor: string | null; ctx?: ThreadsRequestContext } -): Promise { - const count = Math.min(100, Math.max(1, Math.floor(options.count))); - const accounts = await resolveThreadsAccounts(options.ctx); - if (!accounts.length) { - return empty(501); - } - - const handle = username.replace(/^@/, ''); - let userId: string; - let maxId: string | null = null; - - if (options.cursor) { - const decoded = decodeThreadsTokenCursor(options.cursor, kind); - if (!decoded || !sameThreadsHandle(decoded.u, handle)) { - return empty(400); - } - userId = decoded.id; - maxId = decoded.t; - } else { - const resolved = await resolveThreadsUser(handle, options.ctx, { accounts }); - if (resolved.code !== 200 || !resolved.user) { - return empty(resolved.code); - } - userId = resolved.user.id; - } - - const fetcher = kind === 'followers' ? fetchThreadsFollowers : fetchThreadsFollowing; - const res = await fetcher(userId, options.ctx, { accounts, count, maxId, username: handle }); - if (!res.ok) { - return empty(res.status === 404 ? 404 : 500); - } - - const results = usersFromThreadsList(res.json).slice(0, count); - const nextToken = nextTokenFromThreadsFeed(res.json); - const bottom = nextToken - ? encodeThreadsTokenCursor({ v: 1, k: kind, id: userId, u: handle, t: nextToken, c: count }) - : null; - return { code: 200, results, cursor: { top: null, bottom } }; -} diff --git a/packages/atmosphere/src/providers/threads/resolve-user.ts b/packages/atmosphere/src/providers/threads/resolve-user.ts deleted file mode 100644 index 96b705ea..00000000 --- a/packages/atmosphere/src/providers/threads/resolve-user.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { APIUser } from '../../types/api-schemas.js'; -import type { InstagramCredentials } from '../../types/proxy-credentials.js'; -import { resolveThreadsAccounts, type ThreadsRequestContext } from './account-proxy.js'; -import { fetchThreadsSession, fetchThreadsUserByUsername as fetchWebUser } from './client.js'; -import { fetchThreadsUserByUsername } from './private-api.js'; -import { threadsUserFromPrivateRecord } from './private-processor.js'; -import { userFromThreadsProfilePayload } from './processor.js'; - -export type ResolvedThreadsUser = { - code: 200 | 404 | 500; - user: APIUser | null; - /** Accounts resolved along the way, so callers can reuse one shuffle across follow-up calls. */ - accounts: InstagramCredentials[]; -}; - -/** - * Resolve a handle to a Threads profile. Prefers the account proxy - * (`users/{username}/usernameinfo/`, which carries the Threads-specific privacy flags), and falls - * back to the logged-out `threads.com` hovercard query. - */ -export async function resolveThreadsUser( - username: string, - ctx: ThreadsRequestContext | undefined, - options: { accounts?: InstagramCredentials[] } = {} -): Promise { - const handle = username.replace(/^@/, ''); - const accounts = options.accounts ?? (await resolveThreadsAccounts(ctx)); - - if (accounts.length) { - const res = await fetchThreadsUserByUsername(handle, ctx, { accounts }); - if (res.ok && res.json && typeof res.json === 'object') { - const rec = (res.json as { user?: unknown }).user; - if (rec && typeof rec === 'object') { - const user = threadsUserFromPrivateRecord(rec as Record); - if (user) return { code: 200, user, accounts }; - } - } - if (res.status === 404) { - return { code: 404, user: null, accounts }; - } - } - - const session = await fetchThreadsSession(ctx?.userAgent); - if (!session) { - return { code: 500, user: null, accounts }; - } - const hover = await fetchWebUser({ username: handle, session, userAgent: ctx?.userAgent }); - if (!hover.ok || hover.json == null) { - return { code: hover.status === 404 ? 404 : 500, user: null, accounts }; - } - const rec = (hover.json as { data?: { user?: unknown } })?.data?.user; - if (!rec || typeof rec !== 'object') { - return { code: 404, user: null, accounts }; - } - const user = userFromThreadsProfilePayload(rec as Record); - return user ? { code: 200, user, accounts } : { code: 404, user: null, accounts }; -} diff --git a/packages/atmosphere/src/providers/threads/search.ts b/packages/atmosphere/src/providers/threads/search.ts deleted file mode 100644 index eaca2f6a..00000000 --- a/packages/atmosphere/src/providers/threads/search.ts +++ /dev/null @@ -1,228 +0,0 @@ -import type { - APISearchResultsThreads, - APITypeaheadResponse, - APITypeaheadTopic, - APIUserListResults -} from '../../types/api-schemas.js'; -import { resolveThreadsAccounts, type ThreadsRequestContext } from './account-proxy.js'; -import { decodeThreadsSearchCursor, encodeThreadsSearchCursor } from './cursors.js'; -import { - fetchThreadsKeywordSearch, - fetchThreadsSearchSerp, - fetchThreadsUserSearch -} from './private-api.js'; -import { - nextTokenFromThreadsFeed, - rankTokenFromThreadsSearch, - statusesFromThreadsFeed, - usersFromThreadsList -} from './private-processor.js'; - -const emptySearch = (code: number): APISearchResultsThreads => ({ - code, - results: [], - cursor: { top: null, bottom: null } -}); - -const emptyUserList = (code: number): APIUserListResults => ({ - code, - results: [], - cursor: { top: null, bottom: null } -}); - -/** - * Post search (`fbsearch/text_app/serp/`) — the closest Threads analogue to X's `/2/search`. - * - * Logged-out `threads.com` puts search behind a login wall, so this needs the account proxy; - * without one it reports 501. `sortOrder` maps onto the app's two SERP tabs, which rank - * differently and mint incompatible page tokens, so it is pinned into the cursor. - */ -export async function constructThreadsSearch( - query: string, - options: { - count: number; - cursor: string | null; - sortOrder?: 'top' | 'recent'; - ctx?: ThreadsRequestContext; - } -): Promise { - const count = Math.min(100, Math.max(1, Math.floor(options.count))); - const q = query.trim(); - if (!q) { - return emptySearch(400); - } - const accounts = await resolveThreadsAccounts(options.ctx); - if (!accounts.length) { - return emptySearch(501); - } - - let recent = options.sortOrder === 'recent'; - let pageToken: string | null = null; - let rankToken: string | null = null; - let pageNum = 0; - let searchQuery = q; - - if (options.cursor) { - const decoded = decodeThreadsSearchCursor(options.cursor); - if (!decoded || decoded.q !== q) { - return emptySearch(400); - } - recent = decoded.r; - pageToken = decoded.t; - rankToken = decoded.rt; - pageNum = decoded.p; - searchQuery = decoded.q; - } - - const res = await fetchThreadsSearchSerp(searchQuery, options.ctx, { - accounts, - recent, - pageToken, - rankToken, - pageNum: pageNum > 0 ? pageNum : undefined - }); - if (!res.ok) { - return emptySearch(res.status === 404 ? 404 : 500); - } - - const results = statusesFromThreadsFeed(res.json, { id: '', username: '', pic: null }).slice( - 0, - count - ); - - const nextToken = nextTokenFromThreadsFeed(res.json); - const bottom = nextToken - ? encodeThreadsSearchCursor({ - v: 1, - q: searchQuery, - r: recent, - t: nextToken, - rt: rankTokenFromThreadsSearch(res.json) ?? rankToken, - p: pageNum + 1, - c: count - }) - : null; - - return { code: 200, results, cursor: { top: null, bottom } }; -} - -/** - * User search. `users/search/` is the Instagram-wide index, so results are filtered to accounts - * that are actually on Threads — an Instagram-only account has no Threads profile to link to. - * - * The endpoint returns one ranked page and no cursor, so `cursor.bottom` is always null. - */ -export async function constructThreadsUserSearch( - query: string, - options: { count: number; ctx?: ThreadsRequestContext } -): Promise { - const count = Math.min(50, Math.max(1, Math.floor(options.count))); - const q = query.trim(); - if (!q) { - return emptyUserList(400); - } - const accounts = await resolveThreadsAccounts(options.ctx); - if (!accounts.length) { - return emptyUserList(501); - } - // Ask for extra rows because the Threads filter drops Instagram-only accounts. - const res = await fetchThreadsUserSearch(q, options.ctx, { accounts, count: count * 2 }); - if (!res.ok) { - return emptyUserList(500); - } - const results = usersFromThreadsList(res.json, { threadsOnly: true }).slice(0, count); - return { code: 200, results, cursor: { top: null, bottom: null } }; -} - -const emptyTypeahead = (code: number, query: string): APITypeaheadResponse => ({ - code, - query, - num_results: 0, - users: [], - topics: [], - events: [] -}); - -const isRecord = (v: unknown): v is Record => - Boolean(v) && typeof v === 'object' && !Array.isArray(v); - -/** - * Keyword suggestions out of `fbsearch/text_app/keyword/search/`. - * - * The app renders these from `keywords[]`, where each row is either a bare keyword record or one - * wrapped as `{ keyword: … }`. Anything that doesn't yield a name is skipped, so a shape change - * upstream costs the topics half of typeahead rather than the whole response. - */ -function topicsFromKeywordSearch(json: unknown): APITypeaheadTopic[] { - if (!isRecord(json)) return []; - const rows = json.keywords ?? json.results ?? json.items; - if (!Array.isArray(rows)) return []; - const topics: APITypeaheadTopic[] = []; - for (const raw of rows) { - if (!isRecord(raw)) continue; - const rec = isRecord(raw.keyword) ? raw.keyword : raw; - const name = - typeof rec.keyword_text === 'string' - ? rec.keyword_text - : typeof rec.name === 'string' - ? rec.name - : typeof rec.keyword === 'string' - ? rec.keyword - : ''; - if (!name) continue; - const context = - typeof rec.keyword_context === 'string' - ? rec.keyword_context - : typeof rec.search_result_subtitle === 'string' - ? rec.search_result_subtitle - : undefined; - topics.push({ - topic: name, - result_context: { - ...(context ? { display_string: context } : {}), - redirect_url: `https://www.threads.com/search?q=${encodeURIComponent(name)}`, - types: [{ type: 'keyword' }] - } - }); - } - return topics; -} - -/** - * Blended typeahead: accounts from `users/search/` (filtered to Threads) plus keyword suggestions, - * matching the shape of `/2/instagram/typeahead` and X's `/2/typeahead`. `events` stays empty — - * Threads has no equivalent. - */ -export async function constructThreadsTypeahead( - query: string, - options: { count?: number; ctx?: ThreadsRequestContext } = {} -): Promise { - const q = query.trim(); - if (!q) { - return emptyTypeahead(400, query); - } - const accounts = await resolveThreadsAccounts(options.ctx); - if (!accounts.length) { - return emptyTypeahead(501, q); - } - - const [userRes, keywordRes] = await Promise.all([ - fetchThreadsUserSearch(q, options.ctx, { accounts, count: options.count }), - fetchThreadsKeywordSearch(q, options.ctx, { accounts }) - ]); - // Users are the half people actually navigate with, so only a failure there is fatal. - if (!userRes.ok) { - return emptyTypeahead(500, q); - } - - const users = usersFromThreadsList(userRes.json, { threadsOnly: true }); - const topics = keywordRes.ok ? topicsFromKeywordSearch(keywordRes.json) : []; - return { - code: 200, - query: q, - num_results: users.length + topics.length, - users, - topics, - events: [] - }; -} diff --git a/packages/atmosphere/src/providers/threads/trends.ts b/packages/atmosphere/src/providers/threads/trends.ts deleted file mode 100644 index 0e93b950..00000000 --- a/packages/atmosphere/src/providers/threads/trends.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { APITrend, APITrendsResponse } from '../../types/api-schemas.js'; -import { resolveThreadsAccounts, type ThreadsRequestContext } from './account-proxy.js'; -import { fetchThreadsTrends } from './private-api.js'; - -const empty = (code: number, message?: string): APITrendsResponse => ({ - code, - ...(message ? { message } : {}), - timeline_type: 'threads', - trends: [], - cursor: { top: null, bottom: null } -}); - -const isRecord = (v: unknown): v is Record => - Boolean(v) && typeof v === 'object' && !Array.isArray(v); - -const str = (...vals: unknown[]): string => { - for (const v of vals) { - if (typeof v === 'string' && v.length > 0) return v; - } - return ''; -}; - -/** Rows land under `trending_topics` or `topics` depending on which tab the surface serves. */ -function trendRows(json: unknown): Record[] { - if (!isRecord(json)) return []; - for (const key of ['trending_topics', 'topics', 'trends', 'items']) { - const value = json[key]; - if (Array.isArray(value)) return value.filter(isRecord); - } - return []; -} - -function trendFromRow(row: Record): APITrend | null { - const name = str(row.trend_title, row.topic_name, row.trend_name, row.trend_keyword, row.name); - if (!name) return null; - const rank = row.trend_rank; - const postCount = row.post_count; - const context = - str(row.trend_description) || - (typeof postCount === 'number' && Number.isFinite(postCount) - ? `${postCount} posts` - : str(row.subtitle)); - const related = row.related_communities; - const groupedTopics = Array.isArray(related) - ? related - .filter(isRecord) - .map(c => ({ name: str(c.name, c.topic_name, c.title) })) - .filter(c => c.name.length > 0) - : []; - return { - name, - rank: typeof rank === 'number' && Number.isFinite(rank) ? String(rank) : null, - context: context || null, - ...(groupedTopics.length ? { grouped_topics: groupedTopics } : {}) - }; -} - -/** - * Threads trending topics (`fbsearch/text_app/trends/`) — the analogue of X's `/2/trends`. - * - * Trends are a logged-in surface on Threads, so this needs the account proxy; without one it - * reports 501. The endpoint serves a single ranked page and no cursor. - */ -export async function constructThreadsTrends( - options: { count?: number; ctx?: ThreadsRequestContext } = {} -): Promise { - const accounts = await resolveThreadsAccounts(options.ctx); - if (!accounts.length) { - return empty(501, 'Threads trends require a proxied account'); - } - const count = - options.count === undefined ? undefined : Math.min(100, Math.max(1, Math.floor(options.count))); - const res = await fetchThreadsTrends(options.ctx, { accounts, first: count }); - if (!res.ok) { - return empty(res.status === 404 ? 404 : 500); - } - - const trends: APITrend[] = []; - for (const row of trendRows(res.json)) { - const trend = trendFromRow(row); - if (trend) trends.push(trend); - if (count !== undefined && trends.length >= count) break; - } - - return { - code: 200, - timeline_type: 'threads', - trends, - cursor: { top: null, bottom: null } - }; -} diff --git a/packages/atmosphere/src/types/proxy-credentials.ts b/packages/atmosphere/src/types/proxy-credentials.ts index 92fa1afa..dde8e96c 100644 --- a/packages/atmosphere/src/types/proxy-credentials.ts +++ b/packages/atmosphere/src/types/proxy-credentials.ts @@ -12,38 +12,10 @@ export type TwitterCredentials = { username: string; }; -/** - * Instagram session for in-process account proxy. - * - * `sessionId` is the `sessionid` cookie of a logged-in account. `platform` picks which client - * fingerprint the proxy presents: `web` (default) matches a `sessionid` harvested from - * www.instagram.com in a desktop browser, `android` matches one harvested from the Android app - * (see `INSTAGRAM_ANDROID_*` in `providers/instagram/constants.ts`). Mixing them is what usually - * trips Instagram's checkpoint, so keep it consistent with where the cookie came from. - */ -export type InstagramCredentials = { - sessionId: string; - /** `ds_user_id` cookie — numeric account pk. Sent alongside `sessionid` when present. */ - userId?: string; - /** `csrftoken` cookie. Required for POST endpoints; harmless to omit for GETs. */ - csrfToken?: string; - /** `mid` cookie. Optional, but Instagram is happier when the cookie jar looks complete. */ - mid?: string; - /** `ig_did` cookie (device UUID). Optional, same rationale as `mid`. */ - deviceId?: string; - /** Android device id in `android-<16 hex>` form; only meaningful with `platform: 'android'`. */ - androidDeviceId?: string; - /** Screen name, for logging only. */ - username?: string; - /** Which client fingerprint to present. Defaults to `web`. */ - platform?: 'web' | 'android'; -}; - -/** Per-provider credential buckets. */ +/** Per-provider credential buckets; extend with instagram, etc. */ export type CredentialStore = { twitter?: { accounts: TwitterCredentials[] }; bluesky?: { accounts: BlueskyProxyCredentials[] }; - instagram?: { accounts: InstagramCredentials[] }; }; export type ErrorResponse = { diff --git a/src/constants.ts b/src/constants.ts index 8406e5e6..22c10671 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -54,7 +54,6 @@ export const Constants = { TIKTOK_ROOT: 'https://www.tiktok.com', TIKTOK_API_HOST: 'https://api16-normal-c-useast1a.tiktokv.com', INSTAGRAM_ROOT: process.env.INSTAGRAM_ROOT || 'https://www.instagram.com', - INSTAGRAM_API_ROOT: process.env.INSTAGRAM_API_ROOT || 'https://i.instagram.com', NATIVE_MULTI_IMAGE_UA_REGEX: /discordbot\/|matrixpreviewbot/gi, BOT_UA_REGEX: /bot|facebook|embed|got|firefox\/92|firefox\/38|chrome\/96\.0\.4664\.110|curl|wget|go-http|yahoo|generator|whatsapp|revoltchat|preview|link|proxy|vkshare|images|analyzer|index|crawl|spider|python|node|deno|mastodon|http\.rb|ruby|bun\/|fiddler|iframely|steamchaturllookup|bluesky|matrix-media-repo|cardyb|resolver|util|feedly|rss|reader|atom|thunderbird|axios/gi, diff --git a/src/providers/instagram/atmosphere-handlers.ts b/src/providers/instagram/atmosphere-handlers.ts index 21d3a694..736814b4 100644 --- a/src/providers/instagram/atmosphere-handlers.ts +++ b/src/providers/instagram/atmosphere-handlers.ts @@ -6,10 +6,7 @@ import { normalizeApiJsonResponse } from '../../realms/api/normalizeApiJsonResponse'; import type { - APIProfileRelationshipList, APISearchResultsInstagram, - APITypeaheadResponse, - APIUserListResults, SocialThreadInstagram, UserAPIResponse } from '../../realms/api/schemas'; @@ -23,28 +20,13 @@ import { constructInstagramProfileStatuses, constructInstagramProfileVideos } from '@fxembed/atmosphere/providers/instagram/profile'; -import { constructInstagramStatusLikes } from '@fxembed/atmosphere/providers/instagram/likes'; -import { constructInstagramRelationshipList } from '@fxembed/atmosphere/providers/instagram/relationships'; -import { - constructInstagramTypeahead, - constructInstagramUserSearch -} from '@fxembed/atmosphere/providers/instagram/search'; -import { constructInstagramProfileStories } from '@fxembed/atmosphere/providers/instagram/stories'; -import { constructInstagramProfileTagged } from '@fxembed/atmosphere/providers/instagram/tagged'; import { normalizeInstagramPostId } from '@fxembed/atmosphere/providers/instagram/shortcode'; import { instagramConversationV2Route, - instagramProfileFollowersV2Route, - instagramProfileFollowingV2Route, instagramProfileStatusesV2Route, - instagramProfileStoriesV2Route, - instagramProfileTaggedV2Route, instagramProfileVideosV2Route, instagramProfileV2Route, - instagramSearchUsersV2Route, - instagramStatusLikesV2Route, - instagramStatusV2Route, - instagramTypeaheadV2Route + instagramStatusV2Route } from './atmosphere-routes'; /** Logs uncaught throws from Instagram upstream logic (network, timeouts, parse bugs). */ @@ -105,7 +87,7 @@ export const instagramStatusAPIRequest: RouteHandler constructInstagramPost(shortcode, ua, { credentialKey: c.env?.CREDENTIAL_KEY }), + () => constructInstagramPost(shortcode, ua), instagramStatus500 ); const { httpStatus, payload } = normalizeApiJsonResponse( @@ -129,7 +111,7 @@ export const instagramProfileAPIRequest: RouteHandler constructInstagramProfile(username, ua, { credentialKey: c.env?.CREDENTIAL_KEY }), + () => constructInstagramProfile(username, ua), instagramProfile500 ); const { httpStatus, payload } = normalizeApiJsonResponse( @@ -155,8 +137,7 @@ export const instagramProfileStatusesAPIRequest: RouteHandler< constructInstagramProfileStatuses(username, { count: q.count, cursor: q.cursor ?? null, - userAgent: ua, - credentialKey: c.env?.CREDENTIAL_KEY + userAgent: ua }), instagramSearch500 ); @@ -183,8 +164,7 @@ export const instagramProfileVideosAPIRequest: RouteHandler< constructInstagramProfileVideos(username, { count: q.count, cursor: q.cursor ?? null, - userAgent: ua, - credentialKey: c.env?.CREDENTIAL_KEY + userAgent: ua }), instagramSearch500 ); @@ -213,8 +193,7 @@ export const instagramConversationAPIRequest: RouteHandler< cursor: q.cursor ?? null, count: q.count, sortOrder: q.sort_order, - userAgent: ua, - credentialKey: c.env?.CREDENTIAL_KEY + userAgent: ua }), instagramConversationError ); @@ -241,208 +220,3 @@ export const instagramConversationAPIRequest: RouteHandler< setApiHeaders(c); return jsonAfterNormalize(c, payload, httpStatus); }; - -/** Statuses the proxy-gated Instagram routes can answer with, including 501 for "no proxy here". */ -const PROXY_ROUTE_STATUSES = [200, 400, 404, 500, 501] as const; - -const instagramUserList500: APIUserListResults = { - code: 500, - results: [], - cursor: { top: null, bottom: null } -}; - -const instagramRelationship500: APIProfileRelationshipList = { - code: 500, - results: [], - cursor: { top: null, bottom: null } -}; - -export const instagramStatusLikesAPIRequest: RouteHandler< - typeof instagramStatusLikesV2Route -> = async c => { - const { id } = c.req.valid('param'); - const q = c.req.valid('query'); - const ua = c.req.header('user-agent') ?? undefined; - const shortcode = normalizeInstagramPostId(id); - const body = await withInstagramErrorLog( - 'constructInstagramStatusLikes', - { shortcode }, - () => - constructInstagramStatusLikes(shortcode, { - count: q.count, - ctx: { userAgent: ua, credentialKey: c.env?.CREDENTIAL_KEY } - }), - instagramUserList500 - ); - const { httpStatus, payload } = normalizeApiJsonResponse( - body, - PROXY_ROUTE_STATUSES, - 'instagramStatusLikesAPIRequest' - ); - c.status(httpStatus); - setApiHeaders(c); - return jsonAfterNormalize(c, payload, httpStatus); -}; - -export const instagramProfileFollowersAPIRequest: RouteHandler< - typeof instagramProfileFollowersV2Route -> = async c => { - const { username } = c.req.valid('param'); - const q = c.req.valid('query'); - const ua = c.req.header('user-agent') ?? undefined; - const body = await withInstagramErrorLog( - 'constructInstagramRelationshipList', - { username, kind: 'followers' }, - () => - constructInstagramRelationshipList(username, 'followers', { - count: q.count, - cursor: q.cursor ?? null, - ctx: { userAgent: ua, credentialKey: c.env?.CREDENTIAL_KEY } - }), - instagramRelationship500 - ); - const { httpStatus, payload } = normalizeApiJsonResponse( - body, - PROXY_ROUTE_STATUSES, - 'instagramProfileFollowersAPIRequest' - ); - c.status(httpStatus); - setApiHeaders(c); - return jsonAfterNormalize(c, payload, httpStatus); -}; - -export const instagramProfileFollowingAPIRequest: RouteHandler< - typeof instagramProfileFollowingV2Route -> = async c => { - const { username } = c.req.valid('param'); - const q = c.req.valid('query'); - const ua = c.req.header('user-agent') ?? undefined; - const body = await withInstagramErrorLog( - 'constructInstagramRelationshipList', - { username, kind: 'following' }, - () => - constructInstagramRelationshipList(username, 'following', { - count: q.count, - cursor: q.cursor ?? null, - ctx: { userAgent: ua, credentialKey: c.env?.CREDENTIAL_KEY } - }), - instagramRelationship500 - ); - const { httpStatus, payload } = normalizeApiJsonResponse( - body, - PROXY_ROUTE_STATUSES, - 'instagramProfileFollowingAPIRequest' - ); - c.status(httpStatus); - setApiHeaders(c); - return jsonAfterNormalize(c, payload, httpStatus); -}; - -export const instagramProfileTaggedAPIRequest: RouteHandler< - typeof instagramProfileTaggedV2Route -> = async c => { - const { username } = c.req.valid('param'); - const q = c.req.valid('query'); - const ua = c.req.header('user-agent') ?? undefined; - const body = await withInstagramErrorLog( - 'constructInstagramProfileTagged', - { username }, - () => - constructInstagramProfileTagged(username, { - count: q.count, - cursor: q.cursor ?? null, - ctx: { userAgent: ua, credentialKey: c.env?.CREDENTIAL_KEY } - }), - instagramSearch500 - ); - const { httpStatus, payload } = normalizeApiJsonResponse( - body, - PROXY_ROUTE_STATUSES, - 'instagramProfileTaggedAPIRequest' - ); - c.status(httpStatus); - setApiHeaders(c); - return jsonAfterNormalize(c, payload, httpStatus); -}; - -export const instagramProfileStoriesAPIRequest: RouteHandler< - typeof instagramProfileStoriesV2Route -> = async c => { - const { username } = c.req.valid('param'); - const ua = c.req.header('user-agent') ?? undefined; - const body = await withInstagramErrorLog( - 'constructInstagramProfileStories', - { username }, - () => - constructInstagramProfileStories(username, { - ctx: { userAgent: ua, credentialKey: c.env?.CREDENTIAL_KEY } - }), - instagramSearch500 - ); - const { httpStatus, payload } = normalizeApiJsonResponse( - body, - [200, 404, 500, 501] as const, - 'instagramProfileStoriesAPIRequest' - ); - c.status(httpStatus); - setApiHeaders(c); - return jsonAfterNormalize(c, payload, httpStatus); -}; - -export const instagramSearchUsersAPIRequest: RouteHandler< - typeof instagramSearchUsersV2Route -> = async c => { - const q = c.req.valid('query'); - const ua = c.req.header('user-agent') ?? undefined; - const body = await withInstagramErrorLog( - 'constructInstagramUserSearch', - { query: q.query }, - () => - constructInstagramUserSearch(q.query, { - count: q.count, - ctx: { userAgent: ua, credentialKey: c.env?.CREDENTIAL_KEY } - }), - instagramUserList500 - ); - const { httpStatus, payload } = normalizeApiJsonResponse( - body, - PROXY_ROUTE_STATUSES, - 'instagramSearchUsersAPIRequest' - ); - c.status(httpStatus); - setApiHeaders(c); - return jsonAfterNormalize(c, payload, httpStatus); -}; - -export const instagramTypeaheadAPIRequest: RouteHandler< - typeof instagramTypeaheadV2Route -> = async c => { - const q = c.req.valid('query'); - const ua = c.req.header('user-agent') ?? undefined; - const typeahead500: APITypeaheadResponse = { - code: 500, - query: q.query, - num_results: 0, - users: [], - topics: [], - events: [] - }; - const body = await withInstagramErrorLog( - 'constructInstagramTypeahead', - { query: q.query }, - () => - constructInstagramTypeahead(q.query, { - count: q.count, - ctx: { userAgent: ua, credentialKey: c.env?.CREDENTIAL_KEY } - }), - typeahead500 - ); - const { httpStatus, payload } = normalizeApiJsonResponse( - body, - PROXY_ROUTE_STATUSES, - 'instagramTypeaheadAPIRequest' - ); - c.status(httpStatus); - setApiHeaders(c); - return jsonAfterNormalize(c, payload, httpStatus); -}; diff --git a/src/providers/instagram/atmosphere-register.ts b/src/providers/instagram/atmosphere-register.ts index b278cac4..23eb617e 100644 --- a/src/providers/instagram/atmosphere-register.ts +++ b/src/providers/instagram/atmosphere-register.ts @@ -2,30 +2,16 @@ import type { OpenAPIHono } from '@hono/zod-openapi'; import { instagramConversationAPIRequest, instagramProfileAPIRequest, - instagramProfileFollowersAPIRequest, - instagramProfileFollowingAPIRequest, instagramProfileStatusesAPIRequest, - instagramProfileStoriesAPIRequest, - instagramProfileTaggedAPIRequest, instagramProfileVideosAPIRequest, - instagramSearchUsersAPIRequest, - instagramStatusAPIRequest, - instagramStatusLikesAPIRequest, - instagramTypeaheadAPIRequest + instagramStatusAPIRequest } from './atmosphere-handlers'; import { instagramConversationV2Route, - instagramProfileFollowersV2Route, - instagramProfileFollowingV2Route, instagramProfileStatusesV2Route, - instagramProfileStoriesV2Route, - instagramProfileTaggedV2Route, instagramProfileVideosV2Route, instagramProfileV2Route, - instagramSearchUsersV2Route, - instagramStatusLikesV2Route, - instagramStatusV2Route, - instagramTypeaheadV2Route + instagramStatusV2Route } from './atmosphere-routes'; export const registerInstagramAtmosphereRoutes = (atmosphere: OpenAPIHono) => { @@ -34,11 +20,4 @@ export const registerInstagramAtmosphereRoutes = (atmosphere: OpenAPIHono) => { atmosphere.openapi(instagramProfileStatusesV2Route, instagramProfileStatusesAPIRequest); atmosphere.openapi(instagramProfileVideosV2Route, instagramProfileVideosAPIRequest); atmosphere.openapi(instagramConversationV2Route, instagramConversationAPIRequest); - atmosphere.openapi(instagramStatusLikesV2Route, instagramStatusLikesAPIRequest); - atmosphere.openapi(instagramProfileFollowersV2Route, instagramProfileFollowersAPIRequest); - atmosphere.openapi(instagramProfileFollowingV2Route, instagramProfileFollowingAPIRequest); - atmosphere.openapi(instagramProfileTaggedV2Route, instagramProfileTaggedAPIRequest); - atmosphere.openapi(instagramProfileStoriesV2Route, instagramProfileStoriesAPIRequest); - atmosphere.openapi(instagramSearchUsersV2Route, instagramSearchUsersAPIRequest); - atmosphere.openapi(instagramTypeaheadV2Route, instagramTypeaheadAPIRequest); }; diff --git a/src/providers/instagram/atmosphere-routes.ts b/src/providers/instagram/atmosphere-routes.ts index bf590218..e169429e 100644 --- a/src/providers/instagram/atmosphere-routes.ts +++ b/src/providers/instagram/atmosphere-routes.ts @@ -1,23 +1,12 @@ import { createRoute, z } from '@hono/zod-openapi'; import { ApiQueryErrorSchema, - APIProfileRelationshipListSchema, APISearchResultsInstagramSchema, - APITypeaheadResponseSchema, - APIUserListResultsSchema, SocialConversationInstagramSchema, SocialThreadInstagramSchema, UserAPIResponseSchema } from '../../realms/api/schemas'; -/** - * Instagram gates follower lists, likers, search, tagged posts and stories behind a login. Those - * routes answer 501 when the deployment has no Instagram account proxy configured, rather than - * returning an empty list that reads as "this account has none". - */ -const PROXY_ONLY_NOTE = - 'Requires an Instagram account proxy (`CREDENTIAL_KEY` + bundled `instagram.accounts`); returns 501 without one.'; - export const instagramStatusV2Route = createRoute({ method: 'get', path: '/2/instagram/status/{id}', @@ -188,225 +177,3 @@ export const instagramConversationV2Route = createRoute({ } } }); - -const NO_PROXY_DESCRIPTION = 'No Instagram account proxy configured on this deployment'; - -export const instagramStatusLikesV2Route = createRoute({ - method: 'get', - path: '/2/instagram/status/{id}/likes', - summary: 'List accounts that liked an Instagram post', - description: `Instagram's closest analogue to X's repost/quote lists. Instagram serves one un-paginated page, so \`cursor.bottom\` is always null. ${PROXY_ONLY_NOTE}`, - request: { - params: z.object({ - id: z - .string() - .openapi({ description: 'Post shortcode or permalink fragment', example: 'DXeh-kYiIge' }) - }), - query: z.object({ - count: z.coerce.number().int().min(1).max(100).default(20).openapi({ default: 20 }) - }) - }, - responses: { - 200: { - description: 'Likers', - content: { 'application/json': { schema: APIUserListResultsSchema } } - }, - 400: { - description: 'Invalid shortcode', - content: { 'application/json': { schema: APIUserListResultsSchema } } - }, - 404: { - description: 'Not found', - content: { 'application/json': { schema: APIUserListResultsSchema } } - }, - 500: { - description: 'Upstream error', - content: { 'application/json': { schema: APIUserListResultsSchema } } - }, - 501: { - description: NO_PROXY_DESCRIPTION, - content: { 'application/json': { schema: APIUserListResultsSchema } } - } - } -}); - -const relationshipRequest = { - params: z.object({ username: z.string().openapi({ example: 'cristiano' }) }), - query: z.object({ - count: z.coerce.number().int().min(1).max(100).default(20).openapi({ default: 20 }), - cursor: z - .string() - .optional() - .openapi({ description: 'Opaque pagination cursor (`cursor.bottom`)' }) - }) -}; - -const relationshipResponses = { - 200: { - description: 'Relationship page', - content: { 'application/json': { schema: APIProfileRelationshipListSchema } } - }, - 400: { - description: 'Invalid cursor', - content: { 'application/json': { schema: ApiQueryErrorSchema } } - }, - 404: { - description: 'Not found', - content: { 'application/json': { schema: APIProfileRelationshipListSchema } } - }, - 500: { - description: 'Upstream error', - content: { 'application/json': { schema: APIProfileRelationshipListSchema } } - }, - 501: { - description: NO_PROXY_DESCRIPTION, - content: { 'application/json': { schema: APIProfileRelationshipListSchema } } - } -}; - -export const instagramProfileFollowersV2Route = createRoute({ - method: 'get', - path: '/2/instagram/profile/{username}/followers', - summary: 'List an Instagram account’s followers', - description: PROXY_ONLY_NOTE, - request: relationshipRequest, - responses: relationshipResponses -}); - -export const instagramProfileFollowingV2Route = createRoute({ - method: 'get', - path: '/2/instagram/profile/{username}/following', - summary: 'List the accounts an Instagram account follows', - description: PROXY_ONLY_NOTE, - request: relationshipRequest, - responses: relationshipResponses -}); - -export const instagramProfileTaggedV2Route = createRoute({ - method: 'get', - path: '/2/instagram/profile/{username}/tagged', - summary: 'List posts an Instagram account is tagged in', - description: `The tagged grid, Instagram's nearest equivalent to X's \`/profile/{handle}/media\`. ${PROXY_ONLY_NOTE}`, - request: { - params: z.object({ username: z.string().openapi({ example: 'cristiano' }) }), - query: z.object({ - count: z.coerce.number().int().min(1).max(100).default(20).openapi({ default: 20 }), - cursor: z - .string() - .optional() - .openapi({ description: 'Opaque pagination cursor (`cursor.bottom`)' }) - }) - }, - responses: { - 200: { - description: 'Tagged page', - content: { 'application/json': { schema: APISearchResultsInstagramSchema } } - }, - 400: { - description: 'Invalid cursor', - content: { 'application/json': { schema: ApiQueryErrorSchema } } - }, - 404: { - description: 'Not found', - content: { 'application/json': { schema: APISearchResultsInstagramSchema } } - }, - 500: { - description: 'Upstream error', - content: { 'application/json': { schema: APISearchResultsInstagramSchema } } - }, - 501: { - description: NO_PROXY_DESCRIPTION, - content: { 'application/json': { schema: APISearchResultsInstagramSchema } } - } - } -}); - -export const instagramProfileStoriesV2Route = createRoute({ - method: 'get', - path: '/2/instagram/profile/{username}/stories', - summary: 'List an Instagram account’s active stories', - description: `Stories expire after 24 hours and are not paginated. ${PROXY_ONLY_NOTE}`, - request: { - params: z.object({ username: z.string().openapi({ example: 'cristiano' }) }) - }, - responses: { - 200: { - description: 'Active stories', - content: { 'application/json': { schema: APISearchResultsInstagramSchema } } - }, - 404: { - description: 'Not found', - content: { 'application/json': { schema: APISearchResultsInstagramSchema } } - }, - 500: { - description: 'Upstream error', - content: { 'application/json': { schema: APISearchResultsInstagramSchema } } - }, - 501: { - description: NO_PROXY_DESCRIPTION, - content: { 'application/json': { schema: APISearchResultsInstagramSchema } } - } - } -}); - -export const instagramSearchUsersV2Route = createRoute({ - method: 'get', - path: '/2/instagram/search/users', - summary: 'Search Instagram accounts', - description: `Instagram returns one ranked page with no cursor, so \`cursor.bottom\` is always null. ${PROXY_ONLY_NOTE}`, - request: { - query: z.object({ - query: z.string().min(1).max(50).openapi({ example: 'cristiano' }), - count: z.coerce.number().int().min(1).max(50).default(20).openapi({ default: 20 }) - }) - }, - responses: { - 200: { - description: 'Matching accounts', - content: { 'application/json': { schema: APIUserListResultsSchema } } - }, - 400: { - description: 'Invalid parameters', - content: { 'application/json': { schema: ApiQueryErrorSchema } } - }, - 500: { - description: 'Upstream error', - content: { 'application/json': { schema: APIUserListResultsSchema } } - }, - 501: { - description: NO_PROXY_DESCRIPTION, - content: { 'application/json': { schema: APIUserListResultsSchema } } - } - } -}); - -export const instagramTypeaheadV2Route = createRoute({ - method: 'get', - path: '/2/instagram/typeahead', - summary: 'Instagram blended typeahead', - description: `Accounts, hashtags and places. Hashtags and places both land in \`topics\` (tagged via \`result_context.types\`); \`events\` is always empty, as Instagram has no equivalent. ${PROXY_ONLY_NOTE}`, - request: { - query: z.object({ - query: z.string().min(1).max(50).openapi({ example: 'cristiano' }), - count: z.coerce.number().int().min(1).max(50).optional() - }) - }, - responses: { - 200: { - description: 'Typeahead results', - content: { 'application/json': { schema: APITypeaheadResponseSchema } } - }, - 400: { - description: 'Invalid parameters', - content: { 'application/json': { schema: ApiQueryErrorSchema } } - }, - 500: { - description: 'Upstream error', - content: { 'application/json': { schema: APITypeaheadResponseSchema } } - }, - 501: { - description: NO_PROXY_DESCRIPTION, - content: { 'application/json': { schema: APITypeaheadResponseSchema } } - } - } -}); diff --git a/src/providers/threads/atmosphere-handlers.ts b/src/providers/threads/atmosphere-handlers.ts index c019fe34..8db25305 100644 --- a/src/providers/threads/atmosphere-handlers.ts +++ b/src/providers/threads/atmosphere-handlers.ts @@ -5,14 +5,7 @@ import { jsonAfterNormalize, normalizeApiJsonResponse } from '../../realms/api/normalizeApiJsonResponse'; -import type { - APIProfileRelationshipList, - APISearchResultsThreads, - APITrendsResponse, - APITypeaheadResponse, - APIUserListResults, - UserAPIResponse -} from '../../realms/api/schemas'; +import type { APISearchResultsThreads, UserAPIResponse } from '../../realms/api/schemas'; import type { SocialConversation, SocialThread } from '../../types/apiStatus'; import { constructThreadsConversation, @@ -23,36 +16,11 @@ import { constructThreadsProfile, constructThreadsProfileStatuses } from '@fxembed/atmosphere/providers/threads/profile'; -import { constructThreadsStatusLikes } from '@fxembed/atmosphere/providers/threads/likes'; -import { - constructThreadsProfileTab, - type ThreadsProfileTab -} from '@fxembed/atmosphere/providers/threads/profile-tabs'; -import { - constructThreadsRelationshipList, - type ThreadsRelationshipKind -} from '@fxembed/atmosphere/providers/threads/relationships'; -import { - constructThreadsSearch, - constructThreadsTypeahead, - constructThreadsUserSearch -} from '@fxembed/atmosphere/providers/threads/search'; -import { constructThreadsTrends } from '@fxembed/atmosphere/providers/threads/trends'; import { threadsConversationV2Route, - threadsProfileFollowersV2Route, - threadsProfileFollowingV2Route, - threadsProfileMediaV2Route, - threadsProfileRepliesV2Route, - threadsProfileRepostsV2Route, threadsProfileStatusesV2Route, threadsProfileV2Route, - threadsSearchUsersV2Route, - threadsSearchV2Route, - threadsStatusLikesV2Route, - threadsStatusV2Route, - threadsTrendsV2Route, - threadsTypeaheadV2Route + threadsStatusV2Route } from './atmosphere-routes'; async function withThreadsErrorLog( @@ -111,7 +79,7 @@ export const threadsStatusAPIRequest: RouteHandler const body = await withThreadsErrorLog( 'constructThreadsPost', { id }, - () => constructThreadsPost(id, ua, { credentialKey: c.env?.CREDENTIAL_KEY }), + () => constructThreadsPost(id, ua), threadsStatus500 ); const { httpStatus, payload } = normalizeApiJsonResponse( @@ -135,7 +103,7 @@ export const threadsProfileAPIRequest: RouteHandler constructThreadsProfile(username, ua, { credentialKey: c.env?.CREDENTIAL_KEY }), + () => constructThreadsProfile(username, ua), threadsProfile500 ); const { httpStatus, payload } = normalizeApiJsonResponse( @@ -161,8 +129,7 @@ export const threadsProfileStatusesAPIRequest: RouteHandler< constructThreadsProfileStatuses(username, { count: q.count, cursor: q.cursor ?? null, - userAgent: ua, - ctx: { userAgent: ua, credentialKey: c.env?.CREDENTIAL_KEY } + userAgent: ua }), threadsSearch500 ); @@ -190,8 +157,7 @@ export const threadsConversationAPIRequest: RouteHandler< cursor: q.cursor ?? null, count: q.count, sortOrder: q.sort_order, - userAgent: ua, - ctx: { userAgent: ua, credentialKey: c.env?.CREDENTIAL_KEY } + userAgent: ua }), threadsConversationError ); @@ -218,293 +184,3 @@ export const threadsConversationAPIRequest: RouteHandler< setApiHeaders(c); return jsonAfterNormalize(c, payload, httpStatus); }; - -/** Statuses the proxy-gated Threads routes can answer with, including 501 for "no proxy here". */ -const PROXY_ROUTE_STATUSES = [200, 400, 404, 500, 501] as const; - -const threadsUserList500: APIUserListResults = { - code: 500, - results: [], - cursor: { top: null, bottom: null } -}; - -const threadsRelationship500: APIProfileRelationshipList = { - code: 500, - results: [], - cursor: { top: null, bottom: null } -}; - -const threadsTrends500: APITrendsResponse = { - code: 500, - timeline_type: 'threads', - trends: [], - cursor: { top: null, bottom: null } -}; - -/** - * The proxy-only profile tabs differ only by which upstream tab they read, so the work lives in one - * helper and each route keeps its own thin, correctly-typed handler. - */ -async function profileTabBody( - username: string, - tab: ThreadsProfileTab, - q: { count: number; cursor?: string }, - ctx: { userAgent?: string; credentialKey?: string } -): Promise { - return withThreadsErrorLog( - 'constructThreadsProfileTab', - { username, tab }, - () => - constructThreadsProfileTab(username, tab, { - count: q.count, - cursor: q.cursor ?? null, - ctx - }), - threadsSearch500 - ); -} - -export const threadsProfileRepliesAPIRequest: RouteHandler< - typeof threadsProfileRepliesV2Route -> = async c => { - const { username } = c.req.valid('param'); - const q = c.req.valid('query'); - const body = await profileTabBody(username, 'replies', q, { - userAgent: c.req.header('user-agent') ?? undefined, - credentialKey: c.env?.CREDENTIAL_KEY - }); - const { httpStatus, payload } = normalizeApiJsonResponse( - body, - PROXY_ROUTE_STATUSES, - 'threadsProfileRepliesAPIRequest' - ); - c.status(httpStatus); - setApiHeaders(c); - return jsonAfterNormalize(c, payload, httpStatus); -}; - -export const threadsProfileRepostsAPIRequest: RouteHandler< - typeof threadsProfileRepostsV2Route -> = async c => { - const { username } = c.req.valid('param'); - const q = c.req.valid('query'); - const body = await profileTabBody(username, 'reposts', q, { - userAgent: c.req.header('user-agent') ?? undefined, - credentialKey: c.env?.CREDENTIAL_KEY - }); - const { httpStatus, payload } = normalizeApiJsonResponse( - body, - PROXY_ROUTE_STATUSES, - 'threadsProfileRepostsAPIRequest' - ); - c.status(httpStatus); - setApiHeaders(c); - return jsonAfterNormalize(c, payload, httpStatus); -}; - -export const threadsProfileMediaAPIRequest: RouteHandler< - typeof threadsProfileMediaV2Route -> = async c => { - const { username } = c.req.valid('param'); - const q = c.req.valid('query'); - const body = await profileTabBody(username, 'media', q, { - userAgent: c.req.header('user-agent') ?? undefined, - credentialKey: c.env?.CREDENTIAL_KEY - }); - const { httpStatus, payload } = normalizeApiJsonResponse( - body, - PROXY_ROUTE_STATUSES, - 'threadsProfileMediaAPIRequest' - ); - c.status(httpStatus); - setApiHeaders(c); - return jsonAfterNormalize(c, payload, httpStatus); -}; - -/** Followers and following differ only by which upstream list they read. */ -async function relationshipBody( - username: string, - kind: ThreadsRelationshipKind, - q: { count: number; cursor?: string }, - ctx: { userAgent?: string; credentialKey?: string } -): Promise { - return withThreadsErrorLog( - 'constructThreadsRelationshipList', - { username, kind }, - () => - constructThreadsRelationshipList(username, kind, { - count: q.count, - cursor: q.cursor ?? null, - ctx - }), - threadsRelationship500 - ); -} - -export const threadsProfileFollowersAPIRequest: RouteHandler< - typeof threadsProfileFollowersV2Route -> = async c => { - const { username } = c.req.valid('param'); - const q = c.req.valid('query'); - const body = await relationshipBody(username, 'followers', q, { - userAgent: c.req.header('user-agent') ?? undefined, - credentialKey: c.env?.CREDENTIAL_KEY - }); - const { httpStatus, payload } = normalizeApiJsonResponse( - body, - PROXY_ROUTE_STATUSES, - 'threadsProfileFollowersAPIRequest' - ); - c.status(httpStatus); - setApiHeaders(c); - return jsonAfterNormalize(c, payload, httpStatus); -}; - -export const threadsProfileFollowingAPIRequest: RouteHandler< - typeof threadsProfileFollowingV2Route -> = async c => { - const { username } = c.req.valid('param'); - const q = c.req.valid('query'); - const body = await relationshipBody(username, 'following', q, { - userAgent: c.req.header('user-agent') ?? undefined, - credentialKey: c.env?.CREDENTIAL_KEY - }); - const { httpStatus, payload } = normalizeApiJsonResponse( - body, - PROXY_ROUTE_STATUSES, - 'threadsProfileFollowingAPIRequest' - ); - c.status(httpStatus); - setApiHeaders(c); - return jsonAfterNormalize(c, payload, httpStatus); -}; - -export const threadsStatusLikesAPIRequest: RouteHandler< - typeof threadsStatusLikesV2Route -> = async c => { - const { id } = c.req.valid('param'); - const q = c.req.valid('query'); - const ua = c.req.header('user-agent') ?? undefined; - const body = await withThreadsErrorLog( - 'constructThreadsStatusLikes', - { id }, - () => - constructThreadsStatusLikes(id, { - count: q.count, - ctx: { userAgent: ua, credentialKey: c.env?.CREDENTIAL_KEY } - }), - threadsUserList500 - ); - const { httpStatus, payload } = normalizeApiJsonResponse( - body, - PROXY_ROUTE_STATUSES, - 'threadsStatusLikesAPIRequest' - ); - c.status(httpStatus); - setApiHeaders(c); - return jsonAfterNormalize(c, payload, httpStatus); -}; - -export const threadsSearchAPIRequest: RouteHandler = async c => { - const q = c.req.valid('query'); - const ua = c.req.header('user-agent') ?? undefined; - const body = await withThreadsErrorLog( - 'constructThreadsSearch', - { q: q.q }, - () => - constructThreadsSearch(q.q, { - count: q.count, - cursor: q.cursor ?? null, - sortOrder: q.sort_order, - ctx: { userAgent: ua, credentialKey: c.env?.CREDENTIAL_KEY } - }), - threadsSearch500 - ); - const { httpStatus, payload } = normalizeApiJsonResponse( - body, - PROXY_ROUTE_STATUSES, - 'threadsSearchAPIRequest' - ); - c.status(httpStatus); - setApiHeaders(c); - return jsonAfterNormalize(c, payload, httpStatus); -}; - -export const threadsSearchUsersAPIRequest: RouteHandler< - typeof threadsSearchUsersV2Route -> = async c => { - const q = c.req.valid('query'); - const ua = c.req.header('user-agent') ?? undefined; - const body = await withThreadsErrorLog( - 'constructThreadsUserSearch', - { q: q.q }, - () => - constructThreadsUserSearch(q.q, { - count: q.count, - ctx: { userAgent: ua, credentialKey: c.env?.CREDENTIAL_KEY } - }), - threadsUserList500 - ); - const { httpStatus, payload } = normalizeApiJsonResponse( - body, - [200, 400, 500, 501] as const, - 'threadsSearchUsersAPIRequest' - ); - c.status(httpStatus); - setApiHeaders(c); - return jsonAfterNormalize(c, payload, httpStatus); -}; - -export const threadsTrendsAPIRequest: RouteHandler = async c => { - const q = c.req.valid('query'); - const ua = c.req.header('user-agent') ?? undefined; - const body = await withThreadsErrorLog( - 'constructThreadsTrends', - {}, - () => - constructThreadsTrends({ - count: q.count, - ctx: { userAgent: ua, credentialKey: c.env?.CREDENTIAL_KEY } - }), - threadsTrends500 - ); - const { httpStatus, payload } = normalizeApiJsonResponse( - body, - [200, 404, 500, 501] as const, - 'threadsTrendsAPIRequest' - ); - c.status(httpStatus); - setApiHeaders(c); - return jsonAfterNormalize(c, payload, httpStatus); -}; - -export const threadsTypeaheadAPIRequest: RouteHandler = async c => { - const q = c.req.valid('query'); - const ua = c.req.header('user-agent') ?? undefined; - const typeahead500: APITypeaheadResponse = { - code: 500, - query: q.query, - num_results: 0, - users: [], - topics: [], - events: [] - }; - const body = await withThreadsErrorLog( - 'constructThreadsTypeahead', - { query: q.query }, - () => - constructThreadsTypeahead(q.query, { - count: q.count, - ctx: { userAgent: ua, credentialKey: c.env?.CREDENTIAL_KEY } - }), - typeahead500 - ); - const { httpStatus, payload } = normalizeApiJsonResponse( - body, - [200, 400, 500, 501] as const, - 'threadsTypeaheadAPIRequest' - ); - c.status(httpStatus); - setApiHeaders(c); - return jsonAfterNormalize(c, payload, httpStatus); -}; diff --git a/src/providers/threads/atmosphere-register.ts b/src/providers/threads/atmosphere-register.ts index ffbac217..1743f7a3 100644 --- a/src/providers/threads/atmosphere-register.ts +++ b/src/providers/threads/atmosphere-register.ts @@ -2,49 +2,19 @@ import type { OpenAPIHono } from '@hono/zod-openapi'; import { threadsConversationAPIRequest, threadsProfileAPIRequest, - threadsProfileFollowersAPIRequest, - threadsProfileFollowingAPIRequest, - threadsProfileMediaAPIRequest, - threadsProfileRepliesAPIRequest, - threadsProfileRepostsAPIRequest, threadsProfileStatusesAPIRequest, - threadsSearchAPIRequest, - threadsSearchUsersAPIRequest, - threadsStatusAPIRequest, - threadsStatusLikesAPIRequest, - threadsTrendsAPIRequest, - threadsTypeaheadAPIRequest + threadsStatusAPIRequest } from './atmosphere-handlers'; import { threadsConversationV2Route, - threadsProfileFollowersV2Route, - threadsProfileFollowingV2Route, - threadsProfileMediaV2Route, - threadsProfileRepliesV2Route, - threadsProfileRepostsV2Route, threadsProfileStatusesV2Route, threadsProfileV2Route, - threadsSearchUsersV2Route, - threadsSearchV2Route, - threadsStatusLikesV2Route, - threadsStatusV2Route, - threadsTrendsV2Route, - threadsTypeaheadV2Route + threadsStatusV2Route } from './atmosphere-routes'; export const registerThreadsAtmosphereRoutes = (atmosphere: OpenAPIHono) => { atmosphere.openapi(threadsStatusV2Route, threadsStatusAPIRequest); - atmosphere.openapi(threadsStatusLikesV2Route, threadsStatusLikesAPIRequest); atmosphere.openapi(threadsProfileV2Route, threadsProfileAPIRequest); atmosphere.openapi(threadsProfileStatusesV2Route, threadsProfileStatusesAPIRequest); - atmosphere.openapi(threadsProfileRepliesV2Route, threadsProfileRepliesAPIRequest); - atmosphere.openapi(threadsProfileRepostsV2Route, threadsProfileRepostsAPIRequest); - atmosphere.openapi(threadsProfileMediaV2Route, threadsProfileMediaAPIRequest); - atmosphere.openapi(threadsProfileFollowersV2Route, threadsProfileFollowersAPIRequest); - atmosphere.openapi(threadsProfileFollowingV2Route, threadsProfileFollowingAPIRequest); atmosphere.openapi(threadsConversationV2Route, threadsConversationAPIRequest); - atmosphere.openapi(threadsSearchV2Route, threadsSearchAPIRequest); - atmosphere.openapi(threadsSearchUsersV2Route, threadsSearchUsersAPIRequest); - atmosphere.openapi(threadsTrendsV2Route, threadsTrendsAPIRequest); - atmosphere.openapi(threadsTypeaheadV2Route, threadsTypeaheadAPIRequest); }; diff --git a/src/providers/threads/atmosphere-routes.ts b/src/providers/threads/atmosphere-routes.ts index 486e3e38..b9ae56db 100644 --- a/src/providers/threads/atmosphere-routes.ts +++ b/src/providers/threads/atmosphere-routes.ts @@ -1,11 +1,7 @@ import { createRoute, z } from '@hono/zod-openapi'; import { ApiQueryErrorSchema, - APIProfileRelationshipListSchema, APISearchResultsThreadsSchema, - APITrendsResponseSchema, - APITypeaheadResponseSchema, - APIUserListResultsSchema, SocialConversationSchema, SocialThreadSchema, UserAPIResponseSchema @@ -16,7 +12,7 @@ export const threadsStatusV2Route = createRoute({ path: '/2/threads/status/{id}', summary: 'Get a single Threads post', description: - 'Resolves a post by shortcode or Threads permalink. Reads the Threads app API when an account proxy is configured, and falls back to logged-out `threads.com` GraphQL otherwise.', + 'Resolves a post by shortcode or Threads permalink. Data is sourced from logged-out `threads.com` GraphQL.', request: { params: z.object({ id: z @@ -114,7 +110,7 @@ export const threadsConversationV2Route = createRoute({ path: '/2/threads/conversation/{id}', summary: 'Threads post with replies', description: - 'Returns the focal post plus direct replies as `substatus` rows (`type: substatus`, `provider: threads`). Replies come from the Threads app API when an account proxy is configured — logged-out `threads.com` truncates them hard — and fall back to the logged-out connection otherwise. A cursor is only valid against the source that minted it.', + 'Returns the focal post plus direct replies as `substatus` rows (`type: substatus`, `provider: threads`).', request: { params: z.object({ id: z @@ -149,289 +145,3 @@ export const threadsConversationV2Route = createRoute({ } } }); - -/** - * Threads gates search, trends, likers, follow lists and the Replies / Reposts / Media profile tabs - * behind a login. Those routes answer 501 when the deployment has no account proxy configured, - * rather than returning an empty list that reads as "this account has none". - * - * Threads accounts are Instagram accounts, so the pool is the Instagram one — the proxy only swaps - * in the Threads app fingerprint. - */ -const PROXY_ONLY_NOTE = - 'Requires an Instagram account proxy (`CREDENTIAL_KEY` + bundled `instagram.accounts`); returns 501 without one.'; - -const NO_PROXY_DESCRIPTION = 'No Instagram account proxy configured on this deployment'; - -const profileTabRequest = { - params: z.object({ username: z.string().openapi({ example: 'zuck' }) }), - query: z.object({ - count: z.coerce.number().int().min(1).max(100).default(20).openapi({ default: 20 }), - cursor: z - .string() - .optional() - .openapi({ description: 'Opaque pagination cursor (`cursor.bottom`)' }) - }) -}; - -const profileTabResponses = { - 200: { - description: 'Timeline page', - content: { 'application/json': { schema: APISearchResultsThreadsSchema } } - }, - 400: { - description: 'Invalid cursor', - content: { 'application/json': { schema: ApiQueryErrorSchema } } - }, - 404: { - description: 'Not found', - content: { 'application/json': { schema: APISearchResultsThreadsSchema } } - }, - 500: { - description: 'Upstream error', - content: { 'application/json': { schema: APISearchResultsThreadsSchema } } - }, - 501: { - description: NO_PROXY_DESCRIPTION, - content: { 'application/json': { schema: APISearchResultsThreadsSchema } } - } -}; - -export const threadsProfileRepliesV2Route = createRoute({ - method: 'get', - path: '/2/threads/profile/{username}/replies', - summary: 'List a Threads profile’s replies', - description: `The Replies tab, which logged-out \`threads.com\` does not serve. ${PROXY_ONLY_NOTE}`, - request: profileTabRequest, - responses: profileTabResponses -}); - -export const threadsProfileRepostsV2Route = createRoute({ - method: 'get', - path: '/2/threads/profile/{username}/reposts', - summary: 'List a Threads profile’s reposts', - description: `The Reposts tab — Threads' equivalent of an X profile's retweets. ${PROXY_ONLY_NOTE}`, - request: profileTabRequest, - responses: profileTabResponses -}); - -export const threadsProfileMediaV2Route = createRoute({ - method: 'get', - path: '/2/threads/profile/{username}/media', - summary: 'List a Threads profile’s posts with media', - description: `The Media tab, matching X's \`/2/profile/{handle}/media\`. ${PROXY_ONLY_NOTE}`, - request: profileTabRequest, - responses: profileTabResponses -}); - -const relationshipResponses = { - 200: { - description: 'Relationship page', - content: { 'application/json': { schema: APIProfileRelationshipListSchema } } - }, - 400: { - description: 'Invalid cursor', - content: { 'application/json': { schema: ApiQueryErrorSchema } } - }, - 404: { - description: 'Not found', - content: { 'application/json': { schema: APIProfileRelationshipListSchema } } - }, - 500: { - description: 'Upstream error', - content: { 'application/json': { schema: APIProfileRelationshipListSchema } } - }, - 501: { - description: NO_PROXY_DESCRIPTION, - content: { 'application/json': { schema: APIProfileRelationshipListSchema } } - } -}; - -export const threadsProfileFollowersV2Route = createRoute({ - method: 'get', - path: '/2/threads/profile/{username}/followers', - summary: 'List a Threads account’s followers', - description: `Threads shares Instagram's social graph, so this is the Instagram follower list — unfiltered, to match the counts the Threads profile shows. ${PROXY_ONLY_NOTE}`, - request: profileTabRequest, - responses: relationshipResponses -}); - -export const threadsProfileFollowingV2Route = createRoute({ - method: 'get', - path: '/2/threads/profile/{username}/following', - summary: 'List the accounts a Threads account follows', - description: `Threads shares Instagram's social graph, so this is the Instagram following list — unfiltered, to match the counts the Threads profile shows. ${PROXY_ONLY_NOTE}`, - request: profileTabRequest, - responses: relationshipResponses -}); - -export const threadsStatusLikesV2Route = createRoute({ - method: 'get', - path: '/2/threads/status/{id}/likes', - summary: 'List accounts that liked a Threads post', - description: `Threads serves one un-paginated page, so \`cursor.bottom\` is always null. ${PROXY_ONLY_NOTE}`, - request: { - params: z.object({ - id: z - .string() - .openapi({ description: 'Post shortcode or permalink fragment', example: 'DXhZAMkljvS' }) - }), - query: z.object({ - count: z.coerce.number().int().min(1).max(100).default(20).openapi({ default: 20 }) - }) - }, - responses: { - 200: { - description: 'Likers', - content: { 'application/json': { schema: APIUserListResultsSchema } } - }, - 400: { - description: 'Invalid shortcode', - content: { 'application/json': { schema: APIUserListResultsSchema } } - }, - 404: { - description: 'Not found', - content: { 'application/json': { schema: APIUserListResultsSchema } } - }, - 500: { - description: 'Upstream error', - content: { 'application/json': { schema: APIUserListResultsSchema } } - }, - 501: { - description: NO_PROXY_DESCRIPTION, - content: { 'application/json': { schema: APIUserListResultsSchema } } - } - } -}); - -export const threadsSearchV2Route = createRoute({ - method: 'get', - path: '/2/threads/search', - summary: 'Search Threads posts', - description: `Threads' search results page, matching X's \`/2/search\`. \`sort_order\` picks between the Top and Recent tabs; the two rank differently and their cursors are not interchangeable. ${PROXY_ONLY_NOTE}`, - request: { - query: z.object({ - q: z.string().min(1).max(512).openapi({ example: 'meta' }), - sort_order: z.enum(['top', 'recent']).default('top').openapi({ default: 'top' }), - count: z.coerce.number().int().min(1).max(100).default(20).openapi({ default: 20 }), - cursor: z - .string() - .optional() - .openapi({ description: 'Opaque pagination cursor (`cursor.bottom`)' }) - }) - }, - responses: { - 200: { - description: 'Search results page', - content: { 'application/json': { schema: APISearchResultsThreadsSchema } } - }, - 400: { - description: 'Invalid query or cursor', - content: { 'application/json': { schema: ApiQueryErrorSchema } } - }, - 404: { - description: 'Not found', - content: { 'application/json': { schema: APISearchResultsThreadsSchema } } - }, - 500: { - description: 'Upstream error', - content: { 'application/json': { schema: APISearchResultsThreadsSchema } } - }, - 501: { - description: NO_PROXY_DESCRIPTION, - content: { 'application/json': { schema: APISearchResultsThreadsSchema } } - } - } -}); - -export const threadsSearchUsersV2Route = createRoute({ - method: 'get', - path: '/2/threads/search/users', - summary: 'Search Threads accounts', - description: `Ranked account search, filtered to accounts that are actually on Threads. One page, no cursor. ${PROXY_ONLY_NOTE}`, - request: { - query: z.object({ - q: z.string().min(1).max(512).openapi({ example: 'meta' }), - count: z.coerce.number().int().min(1).max(50).default(20).openapi({ default: 20 }) - }) - }, - responses: { - 200: { - description: 'Matching accounts', - content: { 'application/json': { schema: APIUserListResultsSchema } } - }, - 400: { - description: 'Invalid query', - content: { 'application/json': { schema: APIUserListResultsSchema } } - }, - 500: { - description: 'Upstream error', - content: { 'application/json': { schema: APIUserListResultsSchema } } - }, - 501: { - description: NO_PROXY_DESCRIPTION, - content: { 'application/json': { schema: APIUserListResultsSchema } } - } - } -}); - -export const threadsTrendsV2Route = createRoute({ - method: 'get', - path: '/2/threads/trends', - summary: 'Get Threads trending topics', - description: `Threads' trending topics, matching X's \`/2/trends\`. One ranked page, no cursor. ${PROXY_ONLY_NOTE}`, - request: { - query: z.object({ - count: z.coerce.number().int().min(1).max(100).default(20).openapi({ default: 20 }) - }) - }, - responses: { - 200: { - description: 'Trends', - content: { 'application/json': { schema: APITrendsResponseSchema } } - }, - 404: { - description: 'Not found', - content: { 'application/json': { schema: APITrendsResponseSchema } } - }, - 500: { - description: 'Upstream error', - content: { 'application/json': { schema: APITrendsResponseSchema } } - }, - 501: { - description: NO_PROXY_DESCRIPTION, - content: { 'application/json': { schema: APITrendsResponseSchema } } - } - } -}); - -export const threadsTypeaheadV2Route = createRoute({ - method: 'get', - path: '/2/threads/typeahead', - summary: 'Threads search typeahead', - description: `Blended account + keyword suggestions, matching \`/2/instagram/typeahead\`. \`events\` is always empty — Threads has no equivalent. ${PROXY_ONLY_NOTE}`, - request: { - query: z.object({ - query: z.string().min(1).max(512).openapi({ example: 'meta' }), - count: z.coerce.number().int().min(1).max(50).default(20).openapi({ default: 20 }) - }) - }, - responses: { - 200: { - description: 'Suggestions', - content: { 'application/json': { schema: APITypeaheadResponseSchema } } - }, - 400: { - description: 'Invalid query', - content: { 'application/json': { schema: APITypeaheadResponseSchema } } - }, - 500: { - description: 'Upstream error', - content: { 'application/json': { schema: APITypeaheadResponseSchema } } - }, - 501: { - description: NO_PROXY_DESCRIPTION, - content: { 'application/json': { schema: APITypeaheadResponseSchema } } - } - } -}); diff --git a/src/providers/twitter/proxy/credentials.ts b/src/providers/twitter/proxy/credentials.ts index c7fa0e57..a60650b1 100644 --- a/src/providers/twitter/proxy/credentials.ts +++ b/src/providers/twitter/proxy/credentials.ts @@ -1,7 +1,6 @@ import type { BlueskyProxyCredentials, CredentialStore, - InstagramCredentials, TwitterCredentials } from '@fxembed/atmosphere/types/proxy-credentials'; @@ -96,8 +95,7 @@ export function hasBlueskyProxyAccounts(): boolean { return (credentialStore?.bluesky?.accounts?.length ?? 0) > 0; } -/** Fisher-Yates shuffle for spreading load across proxy accounts. */ -function shuffledCopy(acc: T[]): T[] { +function shuffleBlueskyAccountsCopy(acc: BlueskyProxyCredentials[]): BlueskyProxyCredentials[] { const copy = [...acc]; for (let i = copy.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); @@ -108,21 +106,6 @@ function shuffledCopy(acc: T[]): T[] { return copy; } -function shuffleBlueskyAccountsCopy(acc: BlueskyProxyCredentials[]): BlueskyProxyCredentials[] { - return shuffledCopy(acc); -} - -export function hasInstagramProxyAccounts(): boolean { - return (credentialStore?.instagram?.accounts?.length ?? 0) > 0; -} - -/** Instagram proxy accounts in random order, so no single session absorbs every request. */ -export function getShuffledInstagramAccounts(): InstagramCredentials[] { - const acc = credentialStore?.instagram?.accounts ?? []; - if (!acc.length) return []; - return shuffledCopy(acc); -} - /** Hostname of a Bluesky proxy `service` URL (PDS), lowercased; empty if unparseable. */ export function blueskyProxyServiceHostname(service: string): string { try { @@ -135,6 +118,7 @@ export function blueskyProxyServiceHostname(service: string): string { } /** + * Fisher–Yates shuffle for spreading load across PDS proxy accounts. * When `preferredHostname` matches one or more accounts' service host, those are shuffled first, * then the rest (Discord activity hint path). */ diff --git a/src/realms/atmosphere/router.ts b/src/realms/atmosphere/router.ts index c79138b3..f60f5d5f 100644 --- a/src/realms/atmosphere/router.ts +++ b/src/realms/atmosphere/router.ts @@ -149,7 +149,7 @@ registerOpenApiJsonRoute(atmosphere, '/2/openapi.json', { title: 'FxEmbed Atmosphere API', version: '2.0.0', description: - 'Multi-provider JSON API (X/Twitter, Bluesky, Mastodon/ActivityPub, TikTok, Instagram, Threads). Mastodon routes are under `/2/mastodon/{instance}/…`, TikTok under `/2/tiktok/…`, Instagram under `/2/instagram/…`, Threads under `/2/threads/…`. Twitter (`/2/twitter/…`) and Bluesky (`/2/bluesky/…`) are served by forwarding to the same logic as `api.fxtwitter.com` and `api.fxbsky.app`; use their `/2/openapi.json` for full path and schema documentation. Instagram routes for likers, follow lists, tagged posts, stories, user search and typeahead need an Instagram account proxy on the deployment and answer `501` without one.' + 'Multi-provider JSON API (X/Twitter, Bluesky, Mastodon/ActivityPub, TikTok, Instagram, Threads). Mastodon routes are under `/2/mastodon/{instance}/…`, TikTok under `/2/tiktok/…`, Instagram under `/2/instagram/…`, Threads under `/2/threads/…`. Twitter (`/2/twitter/…`) and Bluesky (`/2/bluesky/…`) are served by forwarding to the same logic as `api.fxtwitter.com` and `api.fxbsky.app`; use their `/2/openapi.json` for full path and schema documentation.' }, servers: Constants.ATMOSPHERE_API_HOST_ROOT ? [ diff --git a/src/types/env.d.ts b/src/types/env.d.ts index 178cf155..c5bf95a1 100644 --- a/src/types/env.d.ts +++ b/src/types/env.d.ts @@ -24,8 +24,6 @@ declare namespace NodeJS { PBS_PROXY_DOMAIN_LIST?: string; TWITTER_ROOT?: string; INSTAGRAM_ROOT?: string; - /** Instagram private API origin used by the account proxy. */ - INSTAGRAM_API_ROOT?: string; SENTRY_DSN?: string; RELEASE_NAME?: string; /** Inlined from credentials.enc.json at build (see esbuild.config.mjs). */ diff --git a/src/worker.ts b/src/worker.ts index 97d7d55c..b45c715c 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -11,10 +11,6 @@ import { setBlueskyProviderEnv, setBlueskyProxyRuntime } from '@fxembed/atmosphere/providers/bluesky-runtime'; -import { - setInstagramProviderEnv, - setInstagramProxyRuntime -} from '@fxembed/atmosphere/providers/instagram-runtime'; import { setMastodonProviderEnv } from '@fxembed/atmosphere/providers/mastodon-runtime'; import { setTwitterProviderEnv, @@ -38,19 +34,6 @@ setBlueskyProxyRuntime({ blueskyProxyServiceHostname: proxyCreds.blueskyProxyServiceHostname }); -setInstagramProviderEnv({ - webRoot: Constants.INSTAGRAM_ROOT, - apiRoot: Constants.INSTAGRAM_API_ROOT, - friendlyUserAgent: Constants.FRIENDLY_USER_AGENT -}); - -setInstagramProxyRuntime({ - initCredentials: proxyCreds.initCredentials, - hasBundledEncryptedCredentials: proxyCreds.hasBundledEncryptedCredentials, - hasInstagramProxyAccounts: proxyCreds.hasInstagramProxyAccounts, - getShuffledInstagramAccounts: proxyCreds.getShuffledInstagramAccounts -}); - setMastodonProviderEnv({ userAgent: Constants.FRIENDLY_USER_AGENT, mosaicDomainList: Constants.MOSAIC_DOMAIN_LIST, diff --git a/test/helpers/env.ts b/test/helpers/env.ts index da7047d4..2d043fd5 100644 --- a/test/helpers/env.ts +++ b/test/helpers/env.ts @@ -28,7 +28,6 @@ export const WORKER_TEST_PROCESS_ENV = { SENTRY_DSN: '', TWITTER_ROOT: 'https://x.com', INSTAGRAM_ROOT: 'https://www.instagram.com', - INSTAGRAM_API_ROOT: 'https://i.instagram.com', ENCRYPTED_CREDENTIALS: '', CREDENTIALS_IV: '' } as const satisfies Record; diff --git a/test/instagram.accountProxy.test.ts b/test/instagram.accountProxy.test.ts deleted file mode 100644 index a4f9d0b7..00000000 --- a/test/instagram.accountProxy.test.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - hasInstagramAccountProxy, - instagramPrivateApiRequest, - instagramProxyHeaders, - resolveInstagramAccounts -} from '@fxembed/atmosphere/providers/instagram/account-proxy'; -import { - setInstagramProviderEnv, - setInstagramProxyRuntime -} from '@fxembed/atmosphere/providers/instagram-runtime'; -import { - INSTAGRAM_ANDROID_APP_ID, - INSTAGRAM_ANDROID_CAPABILITIES, - INSTAGRAM_WEB_APP_ID -} from '@fxembed/atmosphere/providers/instagram/constants'; -import type { InstagramCredentials } from '@fxembed/atmosphere/types/proxy-credentials'; - -const webAccount: InstagramCredentials = { - sessionId: 'web-session', - userId: '1234', - csrfToken: 'csrf-web', - mid: 'MID', - username: 'web_account' -}; - -const androidAccount: InstagramCredentials = { - sessionId: 'android-session', - userId: '5678', - androidDeviceId: 'android-0123456789abcdef', - username: 'android_account', - platform: 'android' -}; - -/** Registers a proxy runtime that hands back exactly `accounts`, in the given order. */ -function installProxyRuntime(accounts: InstagramCredentials[], hasBundle = true) { - setInstagramProxyRuntime({ - initCredentials: async () => {}, - hasBundledEncryptedCredentials: () => hasBundle, - hasInstagramProxyAccounts: () => accounts.length > 0, - getShuffledInstagramAccounts: () => accounts - }); -} - -/** Restores the package default (no proxy), so other test files see a logged-out world. */ -function clearProxyRuntime() { - setInstagramProxyRuntime({ - initCredentials: async () => {}, - hasBundledEncryptedCredentials: () => false, - hasInstagramProxyAccounts: () => false, - getShuffledInstagramAccounts: () => [] - }); -} - -describe('instagram account proxy', () => { - beforeEach(() => { - setInstagramProviderEnv({ apiRoot: 'https://i.instagram.com' }); - }); - - afterEach(() => { - clearProxyRuntime(); - vi.unstubAllGlobals(); - }); - - it('reports no proxy without a credential key or without a bundled blob', () => { - installProxyRuntime([webAccount]); - expect(hasInstagramAccountProxy(undefined)).toBe(false); - expect(hasInstagramAccountProxy({ credentialKey: ' ' })).toBe(false); - expect(hasInstagramAccountProxy({ credentialKey: 'key' })).toBe(true); - - installProxyRuntime([webAccount], false); - expect(hasInstagramAccountProxy({ credentialKey: 'key' })).toBe(false); - }); - - it('drops accounts with no sessionid', async () => { - installProxyRuntime([{ sessionId: '' }, webAccount]); - const accounts = await resolveInstagramAccounts({ credentialKey: 'key' }); - expect(accounts).toEqual([webAccount]); - }); - - it('sends the web fingerprint for web accounts and the Android one for android accounts', () => { - const web = instagramProxyHeaders(webAccount); - expect(web['X-IG-App-ID']).toBe(INSTAGRAM_WEB_APP_ID); - expect(web['X-IG-Capabilities']).toBe(INSTAGRAM_ANDROID_CAPABILITIES); - expect(web['User-Agent']).toContain('Mozilla/5.0'); - expect(web['X-CSRFToken']).toBe('csrf-web'); - expect(web['Cookie']).toBe( - 'sessionid=web-session; ds_user_id=1234; csrftoken=csrf-web; mid=MID' - ); - expect(web['X-IG-Device-ID']).toBeUndefined(); - - const android = instagramProxyHeaders(androidAccount); - expect(android['X-IG-App-ID']).toBe(INSTAGRAM_ANDROID_APP_ID); - expect(android['User-Agent']).toMatch(/^Instagram \d+\.[\d.]+ Android \(/); - expect(android['X-IG-Device-ID']).toBe('android-0123456789abcdef'); - // Browser-only headers must not leak onto an app-fingerprinted request. - expect(android['Sec-Fetch-Mode']).toBeUndefined(); - expect(android['Origin']).toBeUndefined(); - }); - - it('returns status 0 when no proxy is configured so callers fall back to logged-out paths', async () => { - clearProxyRuntime(); - const fetchSpy = vi.fn(); - vi.stubGlobal('fetch', fetchSpy); - const res = await instagramPrivateApiRequest('/users/x/usernameinfo/', { - credentialKey: 'key' - }); - expect(res).toEqual({ ok: false, status: 0, json: null }); - expect(fetchSpy).not.toHaveBeenCalled(); - }); - - it('builds the v1 URL with query params and returns parsed JSON', async () => { - installProxyRuntime([webAccount]); - const seen: string[] = []; - vi.stubGlobal( - 'fetch', - vi.fn(async (url: string) => { - seen.push(url); - return new Response(JSON.stringify({ status: 'ok' }), { status: 200 }); - }) - ); - const res = await instagramPrivateApiRequest( - '/friendships/173560420/followers/', - { credentialKey: 'key' }, - { query: { count: 20, max_id: undefined, search_surface: 'follow_list_page' } } - ); - expect(res.ok).toBe(true); - expect(res.json).toEqual({ status: 'ok' }); - expect(seen[0]).toBe( - 'https://i.instagram.com/api/v1/friendships/173560420/followers/?count=20&search_surface=follow_list_page' - ); - }); - - it('rotates to the next account on 401 and reports the account that answered', async () => { - installProxyRuntime([webAccount, androidAccount]); - const cookies: (string | null)[] = []; - vi.stubGlobal( - 'fetch', - vi.fn(async (_url: string, init: RequestInit) => { - const cookie = (init.headers as Record)['Cookie']; - cookies.push(cookie); - if (cookie.includes('web-session')) { - return new Response('nope', { status: 401 }); - } - return new Response(JSON.stringify({ user: { pk: 1 } }), { status: 200 }); - }) - ); - const res = await instagramPrivateApiRequest('/users/x/usernameinfo/', { - credentialKey: 'key' - }); - expect(res.ok).toBe(true); - expect(res.accountUsed).toBe('android_account'); - expect(cookies).toHaveLength(2); - }); - - it('does not rotate on a 404 — the resource is missing, not the session', async () => { - installProxyRuntime([webAccount, androidAccount]); - const fetchSpy = vi.fn(async () => new Response('{}', { status: 404 })); - vi.stubGlobal('fetch', fetchSpy); - const res = await instagramPrivateApiRequest('/media/1/info/', { credentialKey: 'key' }); - expect(res.ok).toBe(false); - expect(res.status).toBe(404); - expect(fetchSpy).toHaveBeenCalledTimes(1); - }); - - it('treats an HTML login page as a dead session and tries the next account', async () => { - installProxyRuntime([webAccount, androidAccount]); - const fetchSpy = vi.fn(async (_url: string, init: RequestInit) => { - const cookie = (init.headers as Record)['Cookie']; - if (cookie.includes('web-session')) { - return new Response('login', { status: 200 }); - } - return new Response(JSON.stringify({ items: [] }), { status: 200 }); - }); - vi.stubGlobal('fetch', fetchSpy); - const res = await instagramPrivateApiRequest('/media/1/info/', { credentialKey: 'key' }); - expect(res.ok).toBe(true); - expect(res.accountUsed).toBe('android_account'); - expect(fetchSpy).toHaveBeenCalledTimes(2); - }); - - it('treats a 200 `status: fail` body as a failure worth rotating past', async () => { - installProxyRuntime([webAccount, androidAccount]); - const fetchSpy = vi.fn(async (_url: string, init: RequestInit) => { - const cookie = (init.headers as Record)['Cookie']; - if (cookie.includes('web-session')) { - return new Response(JSON.stringify({ status: 'fail', message: 'checkpoint_required' }), { - status: 200 - }); - } - return new Response(JSON.stringify({ status: 'ok', items: [] }), { status: 200 }); - }); - vi.stubGlobal('fetch', fetchSpy); - const res = await instagramPrivateApiRequest('/media/1/info/', { credentialKey: 'key' }); - expect(res.ok).toBe(true); - expect(res.json).toEqual({ status: 'ok', items: [] }); - expect(res.accountUsed).toBe('android_account'); - expect(fetchSpy).toHaveBeenCalledTimes(2); - }); - - it('reports 502 when every account answers `status: fail`', async () => { - installProxyRuntime([webAccount]); - vi.stubGlobal( - 'fetch', - vi.fn( - async () => - new Response(JSON.stringify({ status: 'fail', message: 'feedback_required' }), { - status: 200 - }) - ) - ); - const res = await instagramPrivateApiRequest('/friendships/1/followers/', { - credentialKey: 'key' - }); - expect(res.ok).toBe(false); - expect(res.status).toBe(502); - expect(res.json).toEqual({ status: 'fail', message: 'feedback_required' }); - }); -}); diff --git a/test/instagram.atmosphereRoutes.test.ts b/test/instagram.atmosphereRoutes.test.ts deleted file mode 100644 index 4da16e94..00000000 --- a/test/instagram.atmosphereRoutes.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { expect, test } from 'vitest'; -import { app } from '../src/worker'; -import { botHeaders } from './helpers/data'; -import harness from './helpers/harness'; - -const ATMOSPHERE = 'https://api.atmosphere.tools'; - -const get = (path: string) => - app.request(new Request(`${ATMOSPHERE}${path}`, { headers: botHeaders }), undefined, harness); - -/** - * The proxy-gated Instagram routes need a logged-in session. Tests run with no `CREDENTIAL_KEY`, so - * each must answer 501 — an empty 200 would read as "this account has no followers/likers". - */ -const PROXY_ONLY_PATHS = [ - '/2/instagram/status/DXeh-kYiIge/likes', - '/2/instagram/profile/cristiano/followers', - '/2/instagram/profile/cristiano/following', - '/2/instagram/profile/cristiano/tagged', - '/2/instagram/profile/cristiano/stories', - '/2/instagram/search/users?query=cristiano', - '/2/instagram/typeahead?query=cristiano' -]; - -test.each(PROXY_ONLY_PATHS)('%s reports 501 with no Instagram account proxy', async path => { - const res = await get(path); - expect(res.status).toBe(501); - const body = (await res.json()) as { code: number }; - expect(body.code).toBe(501); -}); - -test('proxy-gated list routes still return a well-formed envelope', async () => { - const res = await get('/2/instagram/profile/cristiano/followers'); - const body = (await res.json()) as { - code: number; - results: unknown[]; - cursor: { top: string | null; bottom: string | null }; - }; - expect(body.results).toEqual([]); - expect(body.cursor).toEqual({ top: null, bottom: null }); -}); - -test('typeahead echoes the query back even when unavailable', async () => { - const res = await get('/2/instagram/typeahead?query=cristiano'); - const body = (await res.json()) as { query: string; users: unknown[]; events: unknown[] }; - expect(body.query).toBe('cristiano'); - expect(body.users).toEqual([]); - expect(body.events).toEqual([]); -}); - -test('search/users rejects a missing query before touching Instagram', async () => { - const res = await get('/2/instagram/search/users'); - expect(res.status).toBe(400); -}); - -test('Atmosphere OpenAPI documents the new Instagram routes', async () => { - const res = await get('/2/openapi.json'); - expect(res.status).toBe(200); - const doc = (await res.json()) as { paths: Record> }; - for (const path of [ - '/2/instagram/status/{id}', - '/2/instagram/status/{id}/likes', - '/2/instagram/conversation/{id}', - '/2/instagram/profile/{username}', - '/2/instagram/profile/{username}/statuses', - '/2/instagram/profile/{username}/videos', - '/2/instagram/profile/{username}/followers', - '/2/instagram/profile/{username}/following', - '/2/instagram/profile/{username}/tagged', - '/2/instagram/profile/{username}/stories', - '/2/instagram/search/users', - '/2/instagram/typeahead' - ]) { - expect(doc.paths[path], `missing OpenAPI path ${path}`).toBeDefined(); - } - - // The 501 is part of the contract, not an undocumented surprise. - const followers = doc.paths['/2/instagram/profile/{username}/followers'] as { - get: { responses: Record }; - }; - expect(followers.get.responses['501']).toBeDefined(); -}); diff --git a/test/instagram.cursors.test.ts b/test/instagram.cursors.test.ts index 3cec50a7..b44c9c0b 100644 --- a/test/instagram.cursors.test.ts +++ b/test/instagram.cursors.test.ts @@ -1,10 +1,8 @@ import { describe, expect, it } from 'vitest'; import { decodeCommentCursor, - decodeMaxIdCursor, decodeProfileCursor, encodeCommentCursor, - encodeMaxIdCursor, encodeProfileCursor } from '@fxembed/atmosphere/providers/instagram/cursors'; @@ -29,67 +27,12 @@ describe('instagram cursors', () => { shortcode: 'DXeh-kYiIge', sort: 'popular' as const, after: 'AFTER', - count: 10, - src: 'gql' as const + count: 10 }; const enc = encodeCommentCursor(cur); expect(decodeCommentCursor(enc)).toEqual(cur); }); - it('roundtrips a proxy-minted comment cursor and keeps the two sources apart', () => { - const proxyCursor = { - v: 1 as const, - mediaId: '3881689364048676894', - shortcode: 'DXeh-kYiIge', - sort: 'recent' as const, - after: '17900000000000000_0', - count: 20, - src: 'proxy' as const - }; - expect(decodeCommentCursor(encodeCommentCursor(proxyCursor))).toEqual(proxyCursor); - - // Cursors minted before `src` existed came from the logged-out GraphQL path. - const legacy = btoa( - JSON.stringify({ - v: 1, - mediaId: '3881689364048676894', - shortcode: 'DXeh-kYiIge', - sort: 'popular', - after: 'AFTER', - count: 10 - }) - ) - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); - expect(decodeCommentCursor(legacy)?.src).toBe('gql'); - }); - - it('roundtrips max_id cursor and rejects mismatched kinds', () => { - const cur = { - v: 1 as const, - k: 'followers' as const, - id: '173560420', - u: 'cristiano', - m: '100|abcdef', - c: 20 - }; - expect(decodeMaxIdCursor(encodeMaxIdCursor(cur))).toEqual(cur); - - const badKind = btoa(JSON.stringify({ v: 1, k: 'likers', id: '1', u: 'a', m: 'x', c: 20 })) - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); - expect(decodeMaxIdCursor(badKind)).toBeNull(); - expect(decodeMaxIdCursor('')).toBeNull(); - // An empty `m` would page from the top forever rather than advancing. - const emptyMax = btoa(JSON.stringify({ v: 1, k: 'feed', id: '1', u: 'a', m: '', c: 20 })) - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); - expect(decodeMaxIdCursor(emptyMax)).toBeNull(); - }); - it('decodeProfileCursor returns null for bad input', () => { expect(decodeProfileCursor('')).toBeNull(); expect(decodeProfileCursor('not-valid-base64!!!')).toBeNull(); diff --git a/test/instagram.privateProcessor.test.ts b/test/instagram.privateProcessor.test.ts deleted file mode 100644 index 983fda73..00000000 --- a/test/instagram.privateProcessor.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - mediaItemsFromPrivateFeed, - nextMaxIdFromPrivateResponse, - userFromPrivateRecord, - userFromPrivateUserResponse, - usersFromPrivateList -} from '@fxembed/atmosphere/providers/instagram/private-processor'; - -describe('instagram private API normalizers', () => { - it('maps a full usernameinfo record', () => { - const user = userFromPrivateUserResponse({ - user: { - pk: 173560420, - username: 'cristiano', - full_name: 'Cristiano Ronaldo', - biography: 'SIUUU', - is_verified: true, - is_private: false, - follower_count: 650000000, - following_count: 590, - media_count: 3800, - external_url: 'https://example.com/cr7', - profile_pic_url: 'https://cdn.example/small.jpg', - hd_profile_pic_url_info: { url: 'https://cdn.example/hd.jpg' } - } - }); - expect(user).toMatchObject({ - id: '173560420', - screen_name: 'cristiano', - name: 'Cristiano Ronaldo', - description: 'SIUUU', - followers: 650000000, - following: 590, - statuses: 3800, - media_count: 3800, - protected: false, - url: 'https://www.instagram.com/cristiano/' - }); - // The HD variant is preferred over the small one when Instagram offers both. - expect(user?.avatar_url).toBe('https://cdn.example/hd.jpg'); - expect(user?.verification).toEqual({ verified: true, type: 'individual' }); - expect(user?.website).toEqual({ - url: 'https://example.com/cr7', - display_url: 'example.com/cr7' - }); - }); - - it('maps trimmed follow-list records without inventing counts', () => { - const users = usersFromPrivateList({ - users: [ - { pk: 1, username: 'alpha', full_name: 'Alpha', is_private: true }, - { pk: 2, username: 'beta', is_verified: true }, - { username: 'no_pk' }, - 'garbage' - ] - }); - expect(users).toHaveLength(2); - expect(users[0]).toMatchObject({ - id: '1', - screen_name: 'alpha', - protected: true, - followers: 0 - }); - // Falls back to the handle when Instagram omits full_name. - expect(users[1]).toMatchObject({ id: '2', screen_name: 'beta', name: 'beta' }); - }); - - it('prefers pk_id when Instagram sends a lossy numeric pk', () => { - const user = userFromPrivateRecord({ - pk: 9007199254740993, - pk_id: '9007199254740993', - username: 'bigid' - }); - expect(user?.id).toBe('9007199254740993'); - }); - - it('reads next_max_id across the shapes Instagram uses', () => { - expect(nextMaxIdFromPrivateResponse({ next_max_id: 'abc' })).toBe('abc'); - expect(nextMaxIdFromPrivateResponse({ next_max_id: 12345 })).toBe('12345'); - expect(nextMaxIdFromPrivateResponse({ next_max_id: { next_max_id: 'nested' } })).toBe('nested'); - expect(nextMaxIdFromPrivateResponse({})).toBeNull(); - }); - - it('stops paginating when Instagram says the list is exhausted', () => { - // Instagram echoes a cursor back on the last page; the flags are what actually end it. - expect(nextMaxIdFromPrivateResponse({ next_max_id: 'abc', more_available: false })).toBeNull(); - expect(nextMaxIdFromPrivateResponse({ next_max_id: 'abc', big_list: false })).toBeNull(); - expect( - nextMaxIdFromPrivateResponse({ next_max_id: 'abc', has_more_comments: false }) - ).toBeNull(); - }); - - it('unwraps the { media } entries the tagged feed returns', () => { - const items = mediaItemsFromPrivateFeed({ - items: [{ media: { code: 'AAA' } }, { code: 'BBB' }, null, { media: [1, 2] }] - }); - expect(items.map(i => i.code)).toEqual(['AAA', 'BBB', undefined]); - }); -}); diff --git a/test/instagram.proxiedPost.test.ts b/test/instagram.proxiedPost.test.ts deleted file mode 100644 index 020ba052..00000000 --- a/test/instagram.proxiedPost.test.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { setInstagramProxyRuntime } from '@fxembed/atmosphere/providers/instagram-runtime'; -import { constructInstagramPost } from '@fxembed/atmosphere/providers/instagram/post'; -import { constructInstagramConversation } from '@fxembed/atmosphere/providers/instagram/conversation'; -import { decodeCommentCursor } from '@fxembed/atmosphere/providers/instagram/cursors'; -import { instagramShortcodeToPk } from '@fxembed/atmosphere/providers/instagram/shortcode'; - -const SHORTCODE = 'DXeh-kYiIge'; -const MEDIA_PK = String(instagramShortcodeToPk(SHORTCODE)); -const credentialKey = 'test-key'; - -const mediaItem = { - code: SHORTCODE, - pk: `${MEDIA_PK}_173560420`, - media_type: 2, - taken_at: 1770000000, - like_count: 1000, - comment_count: 25, - caption: { text: 'proxied caption' }, - original_width: 1080, - original_height: 1920, - video_duration: 12.5, - video_versions: [{ url: 'https://cdn.example/hi.mp4', width: 1080, height: 1920, type: 101 }], - image_versions2: { - candidates: [{ url: 'https://cdn.example/thumb.jpg', width: 1080, height: 1920 }] - }, - user: { pk: 173560420, username: 'cristiano', full_name: 'Cristiano Ronaldo', is_verified: true } -}; - -function installProxy() { - setInstagramProxyRuntime({ - initCredentials: async () => {}, - hasBundledEncryptedCredentials: () => true, - hasInstagramProxyAccounts: () => true, - getShuffledInstagramAccounts: () => [{ sessionId: 'session', username: 'proxy_account' }] - }); -} - -function clearProxy() { - setInstagramProxyRuntime({ - initCredentials: async () => {}, - hasBundledEncryptedCredentials: () => false, - hasInstagramProxyAccounts: () => false, - getShuffledInstagramAccounts: () => [] - }); -} - -function stubApi(routes: Record) { - const requested: string[] = []; - vi.stubGlobal( - 'fetch', - vi.fn(async (input: string) => { - const url = new URL(input); - requested.push(url.pathname + url.search); - for (const [prefix, body] of Object.entries(routes)) { - if (url.pathname.startsWith(prefix)) { - return new Response(JSON.stringify(body), { status: 200 }); - } - } - return new Response('{}', { status: 404 }); - }) - ); - return requested; -} - -describe('Instagram post and conversation through the account proxy', () => { - afterEach(() => { - clearProxy(); - vi.unstubAllGlobals(); - }); - - it('fetches a post from media/{pk}/info/ instead of scraping the logged-out page', async () => { - installProxy(); - const requested = stubApi({ - [`/api/v1/media/${MEDIA_PK}/info/`]: { items: [mediaItem] } - }); - - const thread = await constructInstagramPost(SHORTCODE, 'FxEmbedTest/1.0', { credentialKey }); - expect(thread.code).toBe(200); - expect(thread.status).toMatchObject({ - id: SHORTCODE, - text: 'proxied caption', - likes: 1000, - replies: 25 - }); - expect(thread.status?.media?.videos?.[0]).toMatchObject({ - url: 'https://cdn.example/hi.mp4', - width: 1080, - height: 1920, - duration: 12.5 - }); - expect(thread.author?.screen_name).toBe('cristiano'); - // One request, and no www.instagram.com HTML scrape behind it. - expect(requested).toEqual([`/api/v1/media/${MEDIA_PK}/info/`]); - }); - - it('falls through to the logged-out path when the proxy account gets a 404', async () => { - installProxy(); - // A poster who has blocked the proxy account 404s `media/{pk}/info/` for a post that is - // perfectly visible logged-out, so a proxy 404 must not end the lookup. - const requested = stubApi({}); - const thread = await constructInstagramPost(SHORTCODE, 'FxEmbedTest/1.0', { credentialKey }); - expect(thread.code).not.toBe(200); - expect(requested[0]).toBe(`/api/v1/media/${MEDIA_PK}/info/`); - expect(requested.some(u => !u.startsWith('/api/v1/'))).toBe(true); - }); - - it('pages comments through media/{pk}/comments/ and mints a proxy cursor', async () => { - installProxy(); - const requested = stubApi({ - [`/api/v1/media/${MEDIA_PK}/info/`]: { items: [mediaItem] }, - [`/api/v1/media/${MEDIA_PK}/comments/`]: { - comments: [ - { - pk: '18000000000000001', - text: 'first', - created_at: 1770000100, - comment_like_count: 3, - user: { pk: 99, username: 'fan', full_name: 'A Fan' } - } - ], - next_max_id: 'COMMENTS2', - has_more_comments: true - } - }); - - const result = await constructInstagramConversation(SHORTCODE, { - cursor: null, - count: 20, - sortOrder: 'popular', - userAgent: 'FxEmbedTest/1.0', - credentialKey - }); - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.data.code).toBe(200); - expect(result.data.replies).toHaveLength(1); - expect(result.data.replies?.[0]).toMatchObject({ - id: '18000000000000001', - text: 'first', - likes: 3, - parent_id: SHORTCODE - }); - - const cursor = decodeCommentCursor(result.data.cursor?.bottom ?? ''); - expect(cursor).toMatchObject({ src: 'proxy', mediaId: MEDIA_PK, after: 'COMMENTS2' }); - expect(requested.some(u => u.includes(`/api/v1/media/${MEDIA_PK}/comments/`))).toBe(true); - - const page2 = await constructInstagramConversation(SHORTCODE, { - cursor: result.data.cursor?.bottom ?? null, - count: 20, - sortOrder: 'popular', - userAgent: 'FxEmbedTest/1.0', - credentialKey - }); - expect(page2.ok).toBe(true); - expect(requested.some(u => u.includes('max_id=COMMENTS2'))).toBe(true); - }); - - it('asks the comments API for count and surfaces the next comment on the following page', async () => { - installProxy(); - const comments = [ - { - pk: '18000000000000001', - text: 'first', - created_at: 1770000100, - user: { pk: 99, username: 'fan', full_name: 'A Fan' } - }, - { - pk: '18000000000000002', - text: 'second', - created_at: 1770000200, - user: { pk: 98, username: 'other', full_name: 'Someone Else' } - } - ]; - const requested: string[] = []; - vi.stubGlobal( - 'fetch', - vi.fn(async (input: string) => { - const url = new URL(input); - requested.push(url.pathname + url.search); - if (url.pathname.startsWith(`/api/v1/media/${MEDIA_PK}/info/`)) { - return new Response(JSON.stringify({ items: [mediaItem] }), { status: 200 }); - } - if (url.pathname.startsWith(`/api/v1/media/${MEDIA_PK}/comments/`)) { - const count = Number(url.searchParams.get('count') ?? 20); - const maxId = url.searchParams.get('max_id'); - const start = maxId === 'C2' ? 1 : 0; - const page = comments.slice(start, start + count); - const hasMore = start + count < comments.length; - return new Response( - JSON.stringify({ - comments: page, - next_max_id: hasMore ? 'C2' : undefined, - has_more_comments: hasMore - }), - { status: 200 } - ); - } - return new Response('{}', { status: 404 }); - }) - ); - - const page1 = await constructInstagramConversation(SHORTCODE, { - cursor: null, - count: 1, - sortOrder: 'popular', - userAgent: 'FxEmbedTest/1.0', - credentialKey - }); - expect(page1.ok).toBe(true); - if (!page1.ok) return; - expect(page1.data.replies?.map(r => r.text)).toEqual(['first']); - expect(requested.some(u => u.includes('/comments/') && u.includes('count=1'))).toBe(true); - - const page2 = await constructInstagramConversation(SHORTCODE, { - cursor: page1.data.cursor?.bottom ?? null, - count: 1, - sortOrder: 'popular', - userAgent: 'FxEmbedTest/1.0', - credentialKey - }); - expect(page2.ok).toBe(true); - if (!page2.ok) return; - expect(page2.data.replies?.map(r => r.text)).toEqual(['second']); - expect(requested.some(u => u.includes('max_id=C2'))).toBe(true); - }); - - it('refuses a GraphQL cursor on the proxy path rather than serving the wrong page', async () => { - installProxy(); - stubApi({ - [`/api/v1/media/${MEDIA_PK}/info/`]: { items: [mediaItem] }, - [`/api/v1/media/${MEDIA_PK}/comments/`]: { comments: [] } - }); - // A cursor minted by the logged-out GraphQL path carries `src: 'gql'`; its `after` value is - // meaningless to the private API. - const gqlCursor = btoa( - JSON.stringify({ - v: 1, - mediaId: MEDIA_PK, - shortcode: SHORTCODE, - sort: 'popular', - after: 'QVFB...', - count: 20, - src: 'gql' - }) - ) - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); - - const result = await constructInstagramConversation(SHORTCODE, { - cursor: gqlCursor, - count: 20, - sortOrder: 'popular', - userAgent: 'FxEmbedTest/1.0', - credentialKey - }); - expect(result.ok).toBe(false); - if (result.ok) return; - expect(result.message).toBe('Invalid cursor'); - }); -}); diff --git a/test/instagram.proxiedSurfaces.test.ts b/test/instagram.proxiedSurfaces.test.ts deleted file mode 100644 index 3e718371..00000000 --- a/test/instagram.proxiedSurfaces.test.ts +++ /dev/null @@ -1,281 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { setInstagramProxyRuntime } from '@fxembed/atmosphere/providers/instagram-runtime'; -import { constructInstagramRelationshipList } from '@fxembed/atmosphere/providers/instagram/relationships'; -import { constructInstagramProfileTagged } from '@fxembed/atmosphere/providers/instagram/tagged'; -import { constructInstagramProfileStories } from '@fxembed/atmosphere/providers/instagram/stories'; -import { - constructInstagramTypeahead, - constructInstagramUserSearch -} from '@fxembed/atmosphere/providers/instagram/search'; -import { decodeMaxIdCursor } from '@fxembed/atmosphere/providers/instagram/cursors'; - -const ctx = { credentialKey: 'test-key', userAgent: 'FxEmbedTest/1.0' }; - -function installProxy() { - setInstagramProxyRuntime({ - initCredentials: async () => {}, - hasBundledEncryptedCredentials: () => true, - hasInstagramProxyAccounts: () => true, - getShuffledInstagramAccounts: () => [{ sessionId: 'session', username: 'proxy_account' }] - }); -} - -function clearProxy() { - setInstagramProxyRuntime({ - initCredentials: async () => {}, - hasBundledEncryptedCredentials: () => false, - hasInstagramProxyAccounts: () => false, - getShuffledInstagramAccounts: () => [] - }); -} - -/** Routes each requested v1 path to a canned JSON body, and records the URLs that were hit. */ -function stubApi(routes: Record) { - const requested: string[] = []; - vi.stubGlobal( - 'fetch', - vi.fn(async (input: string) => { - const url = new URL(input); - requested.push(url.pathname + url.search); - for (const [prefix, body] of Object.entries(routes)) { - if (url.pathname.startsWith(prefix)) { - return new Response(JSON.stringify(body), { status: 200 }); - } - } - return new Response('{}', { status: 404 }); - }) - ); - return requested; -} - -const cristiano = { - user: { pk: 173560420, username: 'cristiano', full_name: 'Cristiano Ronaldo' } -}; - -describe('proxied Instagram surfaces', () => { - afterEach(() => { - clearProxy(); - vi.unstubAllGlobals(); - }); - - it('resolves a handle, lists followers and mints a resumable cursor', async () => { - installProxy(); - const requested = stubApi({ - '/api/v1/users/cristiano/usernameinfo/': cristiano, - '/api/v1/friendships/173560420/followers/': { - users: [{ pk: 1, username: 'alpha' }], - next_max_id: 'PAGE2', - big_list: true - } - }); - - const page = await constructInstagramRelationshipList('cristiano', 'followers', { - count: 20, - cursor: null, - ctx - }); - expect(page.code).toBe(200); - expect(page.results.map(u => u.screen_name)).toEqual(['alpha']); - expect(requested[0]).toContain('/api/v1/users/cristiano/usernameinfo/'); - expect(requested[1]).toContain('/api/v1/friendships/173560420/followers/'); - - const cursor = decodeMaxIdCursor(page.cursor.bottom ?? ''); - expect(cursor).toMatchObject({ k: 'followers', id: '173560420', u: 'cristiano', m: 'PAGE2' }); - - // Page two carries the user id in the cursor, so it skips the profile lookup entirely. - const page2 = await constructInstagramRelationshipList('cristiano', 'followers', { - count: 20, - cursor: page.cursor.bottom, - ctx - }); - expect(page2.code).toBe(200); - expect(requested[2]).toContain('max_id=PAGE2'); - expect(requested.filter(u => u.includes('usernameinfo'))).toHaveLength(1); - }); - - it('stops paginating followers when Instagram closes the list', async () => { - installProxy(); - stubApi({ - '/api/v1/users/cristiano/usernameinfo/': cristiano, - '/api/v1/friendships/173560420/followers/': { - users: [{ pk: 1, username: 'alpha' }], - next_max_id: 'IGNORED', - big_list: false - } - }); - const page = await constructInstagramRelationshipList('cristiano', 'followers', { - count: 20, - cursor: null, - ctx - }); - expect(page.cursor.bottom).toBeNull(); - }); - - it('rejects a cursor minted for a different list or account', async () => { - installProxy(); - stubApi({ - '/api/v1/users/cristiano/usernameinfo/': cristiano, - '/api/v1/friendships/173560420/followers/': { users: [], next_max_id: 'P2', big_list: true } - }); - const page = await constructInstagramRelationshipList('cristiano', 'followers', { - count: 20, - cursor: null, - ctx - }); - const followersCursor = page.cursor.bottom; - - const wrongList = await constructInstagramRelationshipList('cristiano', 'following', { - count: 20, - cursor: followersCursor, - ctx - }); - expect(wrongList.code).toBe(400); - - const wrongUser = await constructInstagramRelationshipList('leomessi', 'followers', { - count: 20, - cursor: followersCursor, - ctx - }); - expect(wrongUser.code).toBe(400); - - // Instagram handles are case-insensitive, so differing case must still resume the same list. - const sameUserOtherCase = await constructInstagramRelationshipList('Cristiano', 'followers', { - count: 20, - cursor: followersCursor, - ctx - }); - expect(sameUserOtherCase.code).toBe(200); - }); - - it('maps the tagged feed, unwrapping its { media } entries', async () => { - installProxy(); - stubApi({ - '/api/v1/users/cristiano/usernameinfo/': cristiano, - '/api/v1/usertags/173560420/feed/': { - items: [ - { - media: { - code: 'DXeh-kYiIge', - media_type: 1, - taken_at: 1770000000, - like_count: 42, - comment_count: 7, - caption: { text: 'tagged post' }, - image_versions2: { - candidates: [{ url: 'https://cdn.example/a.jpg', width: 1080, height: 1080 }] - }, - user: { pk: 1, username: 'someone_else' } - } - } - ], - next_max_id: 'TAG2', - more_available: true - } - }); - const page = await constructInstagramProfileTagged('cristiano', { - count: 20, - cursor: null, - ctx - }); - expect(page.code).toBe(200); - expect(page.results).toHaveLength(1); - expect(page.results[0]).toMatchObject({ - id: 'DXeh-kYiIge', - text: 'tagged post', - likes: 42, - replies: 7, - provider: 'instagram' - }); - // The post's own author wins over the profile whose tagged grid we asked for. - expect(page.results[0].author.screen_name).toBe('someone_else'); - expect(decodeMaxIdCursor(page.cursor.bottom ?? '')).toMatchObject({ k: 'tagged', m: 'TAG2' }); - }); - - it('flattens the story tray and de-duplicates repeated items', async () => { - installProxy(); - stubApi({ - '/api/v1/users/cristiano/usernameinfo/': cristiano, - '/api/v1/feed/reels_media/': { - reels: { - '173560420': { - items: [ - { pk: '1', code: 'STORYONE', media_type: 1, taken_at: 1770000000 }, - { pk: '2', code: 'STORYTWO', media_type: 1, taken_at: 1770000100 } - ] - } - }, - reels_media: [ - { items: [{ pk: '2', code: 'STORYTWO', media_type: 1, taken_at: 1770000100 }] } - ] - } - }); - const page = await constructInstagramProfileStories('cristiano', { ctx }); - expect(page.code).toBe(200); - expect(page.results.map(s => s.id)).toEqual(['STORYONE', 'STORYTWO']); - expect(page.cursor).toEqual({ top: null, bottom: null }); - }); - - it('maps user search results and caps them at count', async () => { - installProxy(); - stubApi({ - '/api/v1/users/search/': { - users: [ - { pk: 1, username: 'alpha' }, - { pk: 2, username: 'beta' }, - { pk: 3, username: 'gamma' } - ] - } - }); - const res = await constructInstagramUserSearch('al', { count: 2, ctx }); - expect(res.code).toBe(200); - expect(res.results.map(u => u.screen_name)).toEqual(['alpha', 'beta']); - expect(res.cursor.bottom).toBeNull(); - }); - - it('splits typeahead into users and topics, tagging hashtags and places', async () => { - installProxy(); - stubApi({ - '/api/v1/fbsearch/ig_typeahead/': { - list: [ - { user: { pk: 1, username: 'alpha', full_name: 'Alpha' } }, - { hashtag: { name: 'football', formatted_media_count: '12M' } }, - { place: { location: { name: 'Old Trafford', city: 'Manchester' } } }, - { unknown_entry: true } - ] - } - }); - const res = await constructInstagramTypeahead('al', { ctx }); - expect(res.code).toBe(200); - expect(res.query).toBe('al'); - expect(res.users.map(u => u.screen_name)).toEqual(['alpha']); - expect(res.topics).toEqual([ - { - topic: '#football', - result_context: { - display_string: '12M posts', - redirect_url: 'https://www.instagram.com/explore/tags/football/', - types: [{ type: 'hashtag' }] - } - }, - { - topic: 'Old Trafford', - result_context: { display_string: 'Manchester', types: [{ type: 'place' }] } - } - ]); - expect(res.num_results).toBe(3); - expect(res.events).toEqual([]); - }); - - it('reports 501 rather than an empty list when no proxy is configured', async () => { - clearProxy(); - const fetchSpy = vi.fn(); - vi.stubGlobal('fetch', fetchSpy); - const followers = await constructInstagramRelationshipList('cristiano', 'followers', { - count: 20, - cursor: null, - ctx - }); - expect(followers.code).toBe(501); - expect(fetchSpy).not.toHaveBeenCalled(); - }); -}); diff --git a/test/threads.accountProxy.test.ts b/test/threads.accountProxy.test.ts deleted file mode 100644 index 576b3575..00000000 --- a/test/threads.accountProxy.test.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - hasThreadsAccountProxy, - threadsPrivateApiRequest, - threadsProxyHeaders -} from '@fxembed/atmosphere/providers/threads/account-proxy'; -import { - setInstagramProviderEnv, - setInstagramProxyRuntime -} from '@fxembed/atmosphere/providers/instagram-runtime'; -import { - THREADS_ANDROID_APP_ID, - THREADS_ANDROID_CAPABILITIES, - THREADS_ANDROID_USER_AGENT, - THREADS_ORIGIN -} from '@fxembed/atmosphere/providers/threads/constants'; -import { INSTAGRAM_ANDROID_APP_ID } from '@fxembed/atmosphere/providers/instagram/constants'; -import type { InstagramCredentials } from '@fxembed/atmosphere/types/proxy-credentials'; - -const webAccount: InstagramCredentials = { - sessionId: 'web-session', - userId: '1234', - csrfToken: 'csrf-web', - mid: 'MID', - username: 'web_account' -}; - -const androidAccount: InstagramCredentials = { - sessionId: 'android-session', - userId: '5678', - androidDeviceId: 'android-0123456789abcdef', - username: 'android_account', - platform: 'android' -}; - -function installProxyRuntime(accounts: InstagramCredentials[], hasBundle = true) { - setInstagramProxyRuntime({ - initCredentials: async () => {}, - hasBundledEncryptedCredentials: () => hasBundle, - hasInstagramProxyAccounts: () => accounts.length > 0, - getShuffledInstagramAccounts: () => accounts - }); -} - -function clearProxyRuntime() { - setInstagramProxyRuntime({ - initCredentials: async () => {}, - hasBundledEncryptedCredentials: () => false, - hasInstagramProxyAccounts: () => false, - getShuffledInstagramAccounts: () => [] - }); -} - -describe('threads account proxy', () => { - beforeEach(() => { - setInstagramProviderEnv({ apiRoot: 'https://i.instagram.com' }); - }); - - afterEach(() => { - clearProxyRuntime(); - vi.unstubAllGlobals(); - }); - - it('reuses the Instagram credential pool', () => { - expect(hasThreadsAccountProxy({ credentialKey: 'key' })).toBe(false); - installProxyRuntime([webAccount]); - expect(hasThreadsAccountProxy({ credentialKey: 'key' })).toBe(true); - expect(hasThreadsAccountProxy({ credentialKey: ' ' })).toBe(false); - }); - - it('presents the Barcelona app id rather than the Instagram one', () => { - const web = threadsProxyHeaders(webAccount); - expect(web['X-IG-App-ID']).toBe(THREADS_ANDROID_APP_ID); - expect(web['X-IG-App-ID']).not.toBe(INSTAGRAM_ANDROID_APP_ID); - expect(web['X-IG-Capabilities']).toBe(THREADS_ANDROID_CAPABILITIES); - expect(web['Origin']).toBe(THREADS_ORIGIN); - expect(web['X-CSRFToken']).toBe('csrf-web'); - expect(web['Cookie']).toContain('sessionid=web-session'); - expect(web['Cookie']).toContain('ds_user_id=1234'); - - const android = threadsProxyHeaders(androidAccount); - expect(android['User-Agent']).toBe(THREADS_ANDROID_USER_AGENT); - expect(android['User-Agent']).toMatch(/^Barcelona /); - expect(android['X-IG-Device-ID']).toBe('android-0123456789abcdef'); - expect(android['Origin']).toBeUndefined(); - }); - - it('fills path templates and sends the rest as query parameters', async () => { - installProxyRuntime([webAccount]); - const seen: string[] = []; - vi.stubGlobal( - 'fetch', - vi.fn(async (input: string) => { - seen.push(input); - return new Response(JSON.stringify({ status: 'ok' }), { status: 200 }); - }) - ); - - const res = await threadsPrivateApiRequest( - 'text_feed/{user_id}/profile/replies/', - { credentialKey: 'key' }, - { pathParams: { user_id: '99' }, query: { max_id: 'TOKEN', is_app_start: false } } - ); - expect(res.ok).toBe(true); - expect(seen[0]).toBe( - 'https://i.instagram.com/api/v1/text_feed/99/profile/replies/?max_id=TOKEN&is_app_start=false' - ); - }); - - it('reports status 0 with no accounts, so callers can answer 501', async () => { - clearProxyRuntime(); - const res = await threadsPrivateApiRequest('fbsearch/text_app/trends/', { - credentialKey: 'key' - }); - expect(res).toEqual({ ok: false, status: 0, json: null }); - }); - - it('rotates accounts on 429 and on a soft `status: fail` body', async () => { - installProxyRuntime([webAccount, androidAccount]); - const used: string[] = []; - vi.stubGlobal( - 'fetch', - vi.fn(async (_input: string, init: RequestInit) => { - const cookie = String((init.headers as Record)['Cookie']); - used.push(cookie); - if (cookie.includes('web-session')) { - return new Response('rate limited', { status: 429 }); - } - return new Response(JSON.stringify({ status: 'ok', items: [] }), { status: 200 }); - }) - ); - - const res = await threadsPrivateApiRequest('fbsearch/text_app/trends/', { - credentialKey: 'key' - }); - expect(res.ok).toBe(true); - expect(res.accountUsed).toBe('android_account'); - expect(used).toHaveLength(2); - }); - - it('treats a 200 `status: fail` body as a failure worth rotating past', async () => { - installProxyRuntime([webAccount]); - vi.stubGlobal( - 'fetch', - vi.fn( - async () => - new Response(JSON.stringify({ status: 'fail', message: 'feedback_required' }), { - status: 200 - }) - ) - ); - const res = await threadsPrivateApiRequest('fbsearch/text_app/trends/', { - credentialKey: 'key' - }); - expect(res.ok).toBe(false); - expect(res.status).toBe(502); - }); - - it('treats an HTML login page as a dead session and tries the next account', async () => { - installProxyRuntime([webAccount, androidAccount]); - const fetchSpy = vi.fn(async (_url: string, init: RequestInit) => { - const cookie = String((init.headers as Record)['Cookie']); - if (cookie.includes('web-session')) { - return new Response('login', { status: 200 }); - } - return new Response(JSON.stringify({ items: [] }), { status: 200 }); - }); - vi.stubGlobal('fetch', fetchSpy); - const res = await threadsPrivateApiRequest('fbsearch/text_app/trends/', { - credentialKey: 'key' - }); - expect(res.ok).toBe(true); - expect(res.accountUsed).toBe('android_account'); - expect(fetchSpy).toHaveBeenCalledTimes(2); - }); - - it('keeps the HTTP status when JSON.parse fails and rotates', async () => { - installProxyRuntime([webAccount, androidAccount]); - const fetchSpy = vi.fn(async (_url: string, init: RequestInit) => { - const cookie = String((init.headers as Record)['Cookie']); - if (cookie.includes('web-session')) { - return new Response('{not json', { status: 200 }); - } - return new Response(JSON.stringify({ status: 'ok' }), { status: 200 }); - }); - vi.stubGlobal('fetch', fetchSpy); - const res = await threadsPrivateApiRequest('fbsearch/text_app/trends/', { - credentialKey: 'key' - }); - expect(res.ok).toBe(true); - expect(res.accountUsed).toBe('android_account'); - expect(fetchSpy).toHaveBeenCalledTimes(2); - }); - - it('reports the HTTP status as last result when malformed JSON is the only response', async () => { - installProxyRuntime([webAccount]); - vi.stubGlobal( - 'fetch', - vi.fn(async () => new Response('{not json', { status: 200 })) - ); - const res = await threadsPrivateApiRequest('fbsearch/text_app/trends/', { - credentialKey: 'key' - }); - expect(res.ok).toBe(false); - expect(res.status).toBe(200); - expect(res.json).toBeNull(); - expect(res.accountUsed).toBe('web_account'); - }); - - it('rotates when the response body aborts inside the request timeout', async () => { - installProxyRuntime([webAccount, androidAccount]); - const fetchSpy = vi.fn(async (_url: string, init: RequestInit) => { - const cookie = String((init.headers as Record)['Cookie']); - if (cookie.includes('web-session')) { - return { - ok: true, - status: 200, - async text() { - const err = new Error('The operation was aborted'); - err.name = 'AbortError'; - throw err; - } - } as Response; - } - return new Response(JSON.stringify({ status: 'ok', items: [] }), { status: 200 }); - }); - vi.stubGlobal('fetch', fetchSpy); - const res = await threadsPrivateApiRequest('fbsearch/text_app/trends/', { - credentialKey: 'key' - }); - expect(res.ok).toBe(true); - expect(res.accountUsed).toBe('android_account'); - // withTimeout retries the whole fetch+body read 4 times (initial + 3) before rotating. - expect(fetchSpy).toHaveBeenCalledTimes(5); - }); - - it('refuses to follow redirects when sending account cookies', async () => { - installProxyRuntime([webAccount]); - const fetchSpy = vi.fn(async (_url: string, init: RequestInit) => { - expect(init.redirect).toBe('error'); - return new Response(JSON.stringify({ status: 'ok' }), { status: 200 }); - }); - vi.stubGlobal('fetch', fetchSpy); - - const res = await threadsPrivateApiRequest('fbsearch/text_app/trends/', { - credentialKey: 'key' - }); - expect(res.ok).toBe(true); - expect(fetchSpy).toHaveBeenCalledTimes(1); - expect(fetchSpy.mock.calls[0][1]).toMatchObject({ - method: 'GET', - redirect: 'error' - }); - }); - - it('treats a redirect TypeError as a failed request and rotates', async () => { - installProxyRuntime([webAccount, androidAccount]); - const fetchSpy = vi.fn(async (_url: string, init: RequestInit) => { - expect(init.redirect).toBe('error'); - const cookie = String((init.headers as Record)['Cookie']); - if (cookie.includes('web-session')) { - throw new TypeError('Failed to fetch'); - } - return new Response(JSON.stringify({ status: 'ok', items: [] }), { status: 200 }); - }); - vi.stubGlobal('fetch', fetchSpy); - - const res = await threadsPrivateApiRequest('fbsearch/text_app/trends/', { - credentialKey: 'key' - }); - expect(res.ok).toBe(true); - expect(res.accountUsed).toBe('android_account'); - expect(fetchSpy).toHaveBeenCalledTimes(2); - }); -}); diff --git a/test/threads.atmosphereRoutes.test.ts b/test/threads.atmosphereRoutes.test.ts deleted file mode 100644 index 09e0d7be..00000000 --- a/test/threads.atmosphereRoutes.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { expect, test } from 'vitest'; -import { app } from '../src/worker'; -import { botHeaders } from './helpers/data'; -import harness from './helpers/harness'; - -const ATMOSPHERE = 'https://api.atmosphere.tools'; - -const get = (path: string) => - app.request(new Request(`${ATMOSPHERE}${path}`, { headers: botHeaders }), undefined, harness); - -/** - * Threads gates these behind a login, and the proxy borrows the Instagram credential pool. Tests - * run with no `CREDENTIAL_KEY`, so each must answer 501 — an empty 200 would read as "this account - * has no replies/followers/likers". - */ -const PROXY_ONLY_PATHS = [ - '/2/threads/status/DXhZAMkljvS/likes', - '/2/threads/profile/zuck/replies', - '/2/threads/profile/zuck/reposts', - '/2/threads/profile/zuck/media', - '/2/threads/profile/zuck/followers', - '/2/threads/profile/zuck/following', - '/2/threads/search?q=meta', - '/2/threads/search/users?q=meta', - '/2/threads/trends', - '/2/threads/typeahead?query=meta' -]; - -test.each(PROXY_ONLY_PATHS)('%s reports 501 with no account proxy', async path => { - const res = await get(path); - expect(res.status).toBe(501); - const body = (await res.json()) as { code: number }; - expect(body.code).toBe(501); -}); - -test('proxy-gated list routes still return a well-formed envelope', async () => { - const res = await get('/2/threads/profile/zuck/replies'); - const body = (await res.json()) as { - results: unknown[]; - cursor: { top: string | null; bottom: string | null }; - }; - expect(body.results).toEqual([]); - expect(body.cursor).toEqual({ top: null, bottom: null }); -}); - -test('trends keeps its timeline_type even when unavailable', async () => { - const res = await get('/2/threads/trends'); - const body = (await res.json()) as { timeline_type: string; trends: unknown[] }; - expect(body.timeline_type).toBe('threads'); - expect(body.trends).toEqual([]); -}); - -test('search rejects a missing query before touching Threads', async () => { - const res = await get('/2/threads/search'); - expect(res.status).toBe(400); -}); - -test('typeahead echoes the query back even when unavailable', async () => { - const res = await get('/2/threads/typeahead?query=meta'); - const body = (await res.json()) as { query: string; users: unknown[]; events: unknown[] }; - expect(body.query).toBe('meta'); - expect(body.users).toEqual([]); - expect(body.events).toEqual([]); -}); - -test('Atmosphere OpenAPI documents the new Threads routes', async () => { - const res = await get('/2/openapi.json'); - expect(res.status).toBe(200); - const doc = (await res.json()) as { paths: Record> }; - for (const path of [ - '/2/threads/status/{id}/likes', - '/2/threads/profile/{username}/replies', - '/2/threads/profile/{username}/reposts', - '/2/threads/profile/{username}/media', - '/2/threads/profile/{username}/followers', - '/2/threads/profile/{username}/following', - '/2/threads/search', - '/2/threads/search/users', - '/2/threads/trends', - '/2/threads/typeahead' - ]) { - expect(doc.paths[path], `${path} missing from OpenAPI`).toBeDefined(); - expect(doc.paths[path]?.get).toBeDefined(); - } -}); diff --git a/test/threads.cursors.test.ts b/test/threads.cursors.test.ts index 78784d86..ea69db8f 100644 --- a/test/threads.cursors.test.ts +++ b/test/threads.cursors.test.ts @@ -2,10 +2,8 @@ import { describe, expect, it } from 'vitest'; import { decodeThreadsConversationCursor, decodeThreadsProfileTimelineCursor, - decodeThreadsSearchCursor, encodeThreadsConversationCursor, - encodeThreadsProfileTimelineCursor, - encodeThreadsSearchCursor + encodeThreadsProfileTimelineCursor } from '@fxembed/atmosphere/providers/threads/cursors'; describe('threads conversation cursor', () => { @@ -24,27 +22,10 @@ describe('threads conversation cursor', () => { shortcode: 'DXhZAMkljvS', sort: 'TOP', after: 'opaque-cursor', - count: 20, - // A cursor minted before the proxy existed came from the logged-out connection. - src: 'gql' + count: 20 }); }); - it('keeps proxy and logged-out cursors distinguishable', () => { - const proxy = decodeThreadsConversationCursor( - encodeThreadsConversationCursor({ - v: 1, - postId: '1', - shortcode: 'a', - sort: 'TOP', - after: 'paging-token', - count: 20, - src: 'proxy' - }) - ); - expect(proxy?.src).toBe('proxy'); - }); - it('returns encoded shortcode unchanged (caller must validate mismatch)', () => { const cur = decodeThreadsConversationCursor( encodeThreadsConversationCursor({ @@ -78,23 +59,3 @@ describe('threads profile timeline cursor', () => { }); }); }); - -describe('threads search cursor', () => { - it('round-trips UTF-8 query text', () => { - const payload = { - v: 1 as const, - q: 'café 日本語 🧵', - r: false, - t: 'PAGE2', - rt: 'RANK', - p: 1, - c: 20 - }; - expect(decodeThreadsSearchCursor(encodeThreadsSearchCursor(payload))).toEqual(payload); - }); - - it('returns null for invalid input', () => { - expect(decodeThreadsSearchCursor('not-a-cursor')).toBeNull(); - expect(decodeThreadsSearchCursor('')).toBeNull(); - }); -}); diff --git a/test/threads.proxiedSurfaces.test.ts b/test/threads.proxiedSurfaces.test.ts deleted file mode 100644 index 584806cd..00000000 --- a/test/threads.proxiedSurfaces.test.ts +++ /dev/null @@ -1,525 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { setInstagramProxyRuntime } from '@fxembed/atmosphere/providers/instagram-runtime'; -import { constructThreadsProfileTab } from '@fxembed/atmosphere/providers/threads/profile-tabs'; -import { constructThreadsRelationshipList } from '@fxembed/atmosphere/providers/threads/relationships'; -import { constructThreadsStatusLikes } from '@fxembed/atmosphere/providers/threads/likes'; -import { - constructThreadsSearch, - constructThreadsUserSearch -} from '@fxembed/atmosphere/providers/threads/search'; -import { constructThreadsTrends } from '@fxembed/atmosphere/providers/threads/trends'; -import { constructThreadsPost } from '@fxembed/atmosphere/providers/threads/post'; -import { constructThreadsConversation } from '@fxembed/atmosphere/providers/threads/conversation'; -import { constructThreadsProfile } from '@fxembed/atmosphere/providers/threads/profile'; -import { - decodeThreadsSearchCursor, - decodeThreadsTokenCursor -} from '@fxembed/atmosphere/providers/threads/cursors'; - -const ctx = { credentialKey: 'test-key', userAgent: 'FxEmbedTest/1.0' }; - -function installProxy() { - setInstagramProxyRuntime({ - initCredentials: async () => {}, - hasBundledEncryptedCredentials: () => true, - hasInstagramProxyAccounts: () => true, - getShuffledInstagramAccounts: () => [{ sessionId: 'session', username: 'proxy_account' }] - }); -} - -function clearProxy() { - setInstagramProxyRuntime({ - initCredentials: async () => {}, - hasBundledEncryptedCredentials: () => false, - hasInstagramProxyAccounts: () => false, - getShuffledInstagramAccounts: () => [] - }); -} - -/** Routes each requested v1 path to a canned JSON body, and records the URLs that were hit. */ -function stubApi(routes: Record) { - const requested: string[] = []; - vi.stubGlobal( - 'fetch', - vi.fn(async (input: string) => { - const url = new URL(input); - requested.push(url.pathname + url.search); - for (const [prefix, body] of Object.entries(routes)) { - if (url.pathname.startsWith(prefix)) { - return new Response(JSON.stringify(body), { status: 200 }); - } - } - return new Response('{}', { status: 404 }); - }) - ); - return requested; -} - -const zuck = { user: { pk: 314216, username: 'zuck', full_name: 'Mark Zuckerberg' } }; - -/** One row in the shape `text_feed/…` returns: a thread wrapping its posts. */ -const threadRow = (code: string, text: string) => ({ - id: `${code}_314216`, - thread_items: [ - { - post: { - pk: '1', - code, - taken_at: 1700000000, - like_count: 5, - caption: { text }, - user: { pk: 314216, username: 'zuck', full_name: 'Mark Zuckerberg' } - } - } - ] -}); - -describe('proxied Threads surfaces', () => { - afterEach(() => { - clearProxy(); - vi.unstubAllGlobals(); - }); - - it('answers 501 on every proxy-only surface when no account is configured', async () => { - clearProxy(); - const tab = await constructThreadsProfileTab('zuck', 'replies', { - count: 20, - cursor: null, - ctx - }); - const followers = await constructThreadsRelationshipList('zuck', 'followers', { - count: 20, - cursor: null, - ctx - }); - const likes = await constructThreadsStatusLikes('DXhZAMkljvS', { count: 20, ctx }); - const search = await constructThreadsSearch('meta', { count: 20, cursor: null, ctx }); - const users = await constructThreadsUserSearch('meta', { count: 20, ctx }); - const trends = await constructThreadsTrends({ ctx }); - expect([tab.code, followers.code, likes.code, search.code, users.code, trends.code]).toEqual([ - 501, 501, 501, 501, 501, 501 - ]); - }); - - it('reads the replies tab and mints a resumable cursor', async () => { - installProxy(); - const requested = stubApi({ - '/api/v1/users/zuck/usernameinfo/': zuck, - '/api/v1/text_feed/314216/profile/replies/': { - items: [threadRow('AAA', 'a reply')], - paging_tokens: { downwards: 'PAGE2' }, - has_more: true - } - }); - - const page = await constructThreadsProfileTab('zuck', 'replies', { - count: 20, - cursor: null, - ctx - }); - expect(page.code).toBe(200); - expect(page.results.map(s => s.id)).toEqual(['AAA']); - expect(page.results[0]?.author.screen_name).toBe('zuck'); - expect(requested[0]).toContain('/api/v1/users/zuck/usernameinfo/'); - expect(requested[1]).toContain('/api/v1/text_feed/314216/profile/replies/'); - - const decoded = decodeThreadsTokenCursor(page.cursor.bottom ?? '', 'replies'); - expect(decoded).toMatchObject({ id: '314216', u: 'zuck', t: 'PAGE2' }); - }); - - it('rejects a cursor minted for a different tab or handle', async () => { - installProxy(); - stubApi({ - '/api/v1/users/zuck/usernameinfo/': zuck, - '/api/v1/text_feed/314216/profile/replies/': { - items: [threadRow('AAA', 'a reply')], - paging_tokens: { downwards: 'PAGE2' }, - has_more: true - } - }); - const page = await constructThreadsProfileTab('zuck', 'replies', { - count: 20, - cursor: null, - ctx - }); - const cursor = page.cursor.bottom ?? ''; - - const wrongTab = await constructThreadsProfileTab('zuck', 'reposts', { - count: 20, - cursor, - ctx - }); - expect(wrongTab.code).toBe(400); - - const wrongHandle = await constructThreadsProfileTab('mosseri', 'replies', { - count: 20, - cursor, - ctx - }); - expect(wrongHandle.code).toBe(400); - }); - - it('resumes a profile tab from a cursor without re-resolving the handle', async () => { - installProxy(); - stubApi({ - '/api/v1/users/zuck/usernameinfo/': zuck, - '/api/v1/text_feed/314216/profile/media/': { - items: [threadRow('AAA', 'first')], - paging_tokens: { downwards: 'PAGE2' }, - has_more: true - } - }); - const first = await constructThreadsProfileTab('zuck', 'media', { - count: 20, - cursor: null, - ctx - }); - - const requested = stubApi({ - '/api/v1/text_feed/314216/profile/media/': { - items: [threadRow('BBB', 'second')], - has_more: false - } - }); - const second = await constructThreadsProfileTab('zuck', 'media', { - count: 20, - cursor: first.cursor.bottom, - ctx - }); - expect(second.code).toBe(200); - expect(second.results.map(s => s.id)).toEqual(['BBB']); - expect(second.cursor.bottom).toBeNull(); - expect(requested).toHaveLength(1); - expect(requested[0]).toContain('max_id=PAGE2'); - }); - - it('searches posts on the top tab and pins the tab into the cursor', async () => { - installProxy(); - const requested = stubApi({ - '/api/v1/fbsearch/text_app/serp/': { - media: [threadRow('AAA', 'about meta')], - page_token: 'PAGE2', - rank_token: 'RANK', - has_more: true - } - }); - - const page = await constructThreadsSearch('meta', { count: 20, cursor: null, ctx }); - expect(page.code).toBe(200); - expect(page.results.map(s => s.id)).toEqual(['AAA']); - expect(requested[0]).toContain('search_surface=ig_text_search_serp_top'); - expect(requested[0]).toContain('recent=0'); - - const decoded = decodeThreadsSearchCursor(page.cursor.bottom ?? ''); - expect(decoded).toMatchObject({ q: 'meta', r: false, t: 'PAGE2', rt: 'RANK', p: 1 }); - }); - - it('sends the recent surface for the recent tab and rejects a cursor from another query', async () => { - installProxy(); - const requested = stubApi({ - '/api/v1/fbsearch/text_app/serp/': { - media: [threadRow('AAA', 'about meta')], - has_more: false - } - }); - const page = await constructThreadsSearch('meta', { - count: 20, - cursor: null, - sortOrder: 'recent', - ctx - }); - expect(page.code).toBe(200); - expect(requested[0]).toContain('search_surface=ig_text_search_serp_recent'); - expect(requested[0]).toContain('recent=1'); - expect(page.cursor.bottom).toBeNull(); - - const withOtherQuery = await constructThreadsSearch('threads', { - count: 20, - cursor: 'not-a-cursor-for-this-query', - ctx - }); - expect(withOtherQuery.code).toBe(400); - }); - - it('filters user search down to accounts that are on Threads', async () => { - installProxy(); - stubApi({ - '/api/v1/users/search/': { - users: [ - { pk: 1, username: 'on_threads', is_active_on_text_post_app: true }, - { pk: 2, username: 'instagram_only' }, - { pk: 3, username: 'onboarded', has_onboarded_to_text_post_app: true } - ] - } - }); - const page = await constructThreadsUserSearch('meta', { count: 20, ctx }); - expect(page.code).toBe(200); - expect(page.results.map(u => u.screen_name)).toEqual(['on_threads', 'onboarded']); - expect(page.results[0]?.url).toBe('https://www.threads.com/@on_threads/'); - }); - - it('lists likers of a post by decoding its shortcode', async () => { - installProxy(); - const requested = stubApi({ - '/api/v1/media/': { users: [{ pk: 1, username: 'liker' }] } - }); - const page = await constructThreadsStatusLikes( - 'https://www.threads.com/@zuck/post/DXhZAMkljvS', - { count: 20, ctx } - ); - expect(page.code).toBe(200); - expect(page.results.map(u => u.screen_name)).toEqual(['liker']); - expect(requested[0]).toMatch(/^\/api\/v1\/media\/\d+\/likers\/$/); - }); - - it('lists followers through the shared Instagram graph', async () => { - installProxy(); - const requested = stubApi({ - '/api/v1/users/zuck/usernameinfo/': zuck, - '/api/v1/friendships/314216/followers/': { - users: [{ pk: 1, username: 'alpha' }], - next_max_id: 'PAGE2' - } - }); - const page = await constructThreadsRelationshipList('zuck', 'followers', { - count: 20, - cursor: null, - ctx - }); - expect(page.code).toBe(200); - expect(page.results.map(u => u.screen_name)).toEqual(['alpha']); - expect(requested[1]).toContain('/api/v1/friendships/314216/followers/'); - expect(decodeThreadsTokenCursor(page.cursor.bottom ?? '', 'followers')).toMatchObject({ - t: 'PAGE2' - }); - }); - - it('reads a post through single_thread when a proxy is available', async () => { - installProxy(); - const requested = stubApi({ - '/api/v1/text_feed/': { - containing_thread: { - thread_items: [ - { post: { ...threadRow('AAA', 'first in chain').thread_items[0]!.post } }, - { - post: { - pk: '2', - code: 'DXhZAMkljvS', - taken_at: 1700000001, - caption: { text: 'the focal post' }, - user: { pk: 314216, username: 'zuck' } - } - } - ] - } - } - }); - - const res = await constructThreadsPost('DXhZAMkljvS', 'FxEmbedTest/1.0', ctx); - expect(res.code).toBe(200); - expect(res.status?.type).toBe('status'); - expect(res.status && 'id' in res.status ? res.status.id : null).toBe('DXhZAMkljvS'); - expect(res.thread?.map(s => ('id' in s ? s.id : null))).toEqual(['AAA']); - expect(requested[0]).toMatch(/^\/api\/v1\/text_feed\/\d+\/single_thread\//); - }); - - it('reads replies through the proxy and mints a proxy-tagged cursor', async () => { - installProxy(); - const requested = stubApi({ - '/api/v1/text_feed/': { - containing_thread: { - thread_items: [ - { - post: { - pk: '1', - code: 'DXhZAMkljvS', - taken_at: 1700000000, - caption: { text: 'focal' }, - user: { pk: 314216, username: 'zuck' } - } - } - ] - }, - reply_threads: [threadRow('RRR', 'a reply')], - paging_tokens: { downwards: 'REPLIES2' }, - has_more: true - } - }); - - const res = await constructThreadsConversation('DXhZAMkljvS', { - cursor: null, - count: 20, - sortOrder: 'top', - ctx - }); - expect(res.ok).toBe(true); - if (!res.ok) return; - expect(res.data.code).toBe(200); - expect(res.data.replies?.map(r => r.id)).toEqual(['RRR']); - expect(requested[0]).toContain('/replies/'); - expect(requested[0]).toContain('sort_order=top'); - }); - - it('asks replies for count and surfaces the next reply on the following page', async () => { - installProxy(); - const focal = { - pk: '1', - code: 'DXhZAMkljvS', - taken_at: 1700000000, - caption: { text: 'focal' }, - user: { pk: 314216, username: 'zuck' } - }; - const replies = [threadRow('AAA', 'first reply'), threadRow('BBB', 'second reply')]; - const requested: string[] = []; - vi.stubGlobal( - 'fetch', - vi.fn(async (input: string) => { - const url = new URL(input); - requested.push(url.pathname + url.search); - if (!url.pathname.includes('/replies/')) { - return new Response('{}', { status: 404 }); - } - const count = Number(url.searchParams.get('count') ?? 20); - const token = url.searchParams.get('paging_token'); - const start = token === 'R2' ? 1 : 0; - const page = replies.slice(start, start + count); - const hasMore = start + count < replies.length; - return new Response( - JSON.stringify({ - containing_thread: { thread_items: [{ post: focal }] }, - reply_threads: page, - paging_tokens: hasMore ? { downwards: 'R2' } : undefined, - has_more: hasMore - }), - { status: 200 } - ); - }) - ); - - const page1 = await constructThreadsConversation('DXhZAMkljvS', { - cursor: null, - count: 1, - sortOrder: 'top', - ctx - }); - expect(page1.ok).toBe(true); - if (!page1.ok) return; - expect(page1.data.replies?.map(r => r.id)).toEqual(['AAA']); - expect(requested.some(u => u.includes('/replies/') && u.includes('count=1'))).toBe(true); - - const page2 = await constructThreadsConversation('DXhZAMkljvS', { - cursor: page1.data.cursor?.bottom ?? null, - count: 1, - sortOrder: 'top', - ctx - }); - expect(page2.ok).toBe(true); - if (!page2.ok) return; - expect(page2.data.replies?.map(r => r.id)).toEqual(['BBB']); - expect(requested.some(u => u.includes('paging_token=R2'))).toBe(true); - }); - - it('asks the profile feed for count and surfaces the next post on the following page', async () => { - installProxy(); - const items = [threadRow('AAA', 'first'), threadRow('BBB', 'second')]; - const requested: string[] = []; - vi.stubGlobal( - 'fetch', - vi.fn(async (input: string) => { - const url = new URL(input); - requested.push(url.pathname + url.search); - if (url.pathname.startsWith('/api/v1/users/zuck/usernameinfo/')) { - return new Response(JSON.stringify(zuck), { status: 200 }); - } - if (url.pathname.startsWith('/api/v1/text_feed/314216/profile/replies/')) { - const count = Number(url.searchParams.get('count') ?? 20); - const maxId = url.searchParams.get('max_id'); - const start = maxId === 'PAGE2' ? 1 : 0; - const page = items.slice(start, start + count); - const hasMore = start + count < items.length; - return new Response( - JSON.stringify({ - items: page, - paging_tokens: hasMore ? { downwards: 'PAGE2' } : undefined, - has_more: hasMore - }), - { status: 200 } - ); - } - return new Response('{}', { status: 404 }); - }) - ); - - const page1 = await constructThreadsProfileTab('zuck', 'replies', { - count: 1, - cursor: null, - ctx - }); - expect(page1.code).toBe(200); - expect(page1.results.map(s => s.id)).toEqual(['AAA']); - expect(requested.some(u => u.includes('/profile/replies/') && u.includes('count=1'))).toBe( - true - ); - - const page2 = await constructThreadsProfileTab('zuck', 'replies', { - count: 1, - cursor: page1.cursor.bottom, - ctx - }); - expect(page2.code).toBe(200); - expect(page2.results.map(s => s.id)).toEqual(['BBB']); - expect(requested.some(u => u.includes('max_id=PAGE2'))).toBe(true); - }); - - it('resolves a profile through usernameinfo when a proxy is available', async () => { - installProxy(); - const requested = stubApi({ - '/api/v1/users/zuck/usernameinfo/': { - user: { - pk: 314216, - username: 'zuck', - full_name: 'Mark Zuckerberg', - biography: 'bio', - follower_count: 42, - is_verified: true - } - } - }); - const res = await constructThreadsProfile('zuck', 'FxEmbedTest/1.0', ctx); - expect(res.code).toBe(200); - expect(res.user?.screen_name).toBe('zuck'); - expect(res.user?.followers).toBe(42); - expect(res.user?.url).toBe('https://www.threads.com/@zuck/'); - expect(requested).toHaveLength(1); - }); - - it('maps trending topics onto the shared trends shape', async () => { - installProxy(); - stubApi({ - '/api/v1/fbsearch/text_app/trends/': { - trending_topics: [ - { - trend_title: 'Something happened', - trend_description: 'A big deal', - trend_rank: 1, - related_communities: [{ name: 'News' }] - }, - { topic_name: 'Another topic', post_count: 4200 }, - { trend_description: 'nameless rows are dropped' } - ] - } - }); - const page = await constructThreadsTrends({ ctx }); - expect(page.code).toBe(200); - expect(page.timeline_type).toBe('threads'); - expect(page.trends).toEqual([ - { - name: 'Something happened', - rank: '1', - context: 'A big deal', - grouped_topics: [{ name: 'News' }] - }, - { name: 'Another topic', rank: null, context: '4200 posts' } - ]); - }); -}); diff --git a/tools/stripcredentials.mjs b/tools/stripcredentials.mjs index 75cd01ec..aa59f400 100644 --- a/tools/stripcredentials.mjs +++ b/tools/stripcredentials.mjs @@ -1,7 +1,6 @@ /** * Strip sensitive fields from credentials.complete.json → credentials.json - * Output shape: { "twitter": { "accounts": [...] }, "bluesky": { "accounts": [...] }, - * "instagram": { "accounts": [...] } } + * Output shape: { "twitter": { "accounts": [...] }, "bluesky": { "accounts": [...] } } * * Accepts complete file as either: * - { "twitter": { "accounts": [...] } } (preferred) @@ -23,15 +22,13 @@ const twitterAccounts = Array.isArray(raw.twitter?.accounts) : null; const blueskyAccounts = Array.isArray(raw.bluesky?.accounts) ? raw.bluesky.accounts : null; -const instagramAccounts = Array.isArray(raw.instagram?.accounts) ? raw.instagram.accounts : null; if ( (!Array.isArray(twitterAccounts) || twitterAccounts.length === 0) && - (!Array.isArray(blueskyAccounts) || blueskyAccounts.length === 0) && - (!Array.isArray(instagramAccounts) || instagramAccounts.length === 0) + (!Array.isArray(blueskyAccounts) || blueskyAccounts.length === 0) ) { console.error( - 'credentials.complete.json must have twitter.accounts (or legacy accounts), bluesky.accounts, and/or instagram.accounts, as non-empty arrays' + 'credentials.complete.json must have twitter.accounts (or legacy accounts), and/or bluesky.accounts, as non-empty arrays' ); process.exit(1); } @@ -109,45 +106,8 @@ if (Array.isArray(blueskyAccounts) && blueskyAccounts.length > 0) { }; } -if (Array.isArray(instagramAccounts) && instagramAccounts.length > 0) { - for (let i = 0; i < instagramAccounts.length; i++) { - const cred = instagramAccounts[i]; - const id = `instagram.accounts[${i}]`; - if (cred === null || typeof cred !== 'object' || Array.isArray(cred)) { - console.error(`${id}: each account must be a plain object`); - process.exit(1); - } - if (typeof cred.sessionId !== 'string' || cred.sessionId.length === 0) { - console.error(`${id}: "sessionId" must be a non-empty string (the \`sessionid\` cookie)`); - process.exit(1); - } - if (cred.platform !== undefined && cred.platform !== 'web' && cred.platform !== 'android') { - console.error(`${id}: "platform" must be "web" or "android" when present`); - process.exit(1); - } - } - - /* Only cookie-jar fields survive; the login password (if the complete file carries one) does not. */ - const optionalFields = ['userId', 'csrfToken', 'mid', 'deviceId', 'androidDeviceId', 'username']; - out.instagram = { - accounts: instagramAccounts.map(cred => { - const stripped = { sessionId: cred.sessionId }; - for (const field of optionalFields) { - if (typeof cred[field] === 'string' && cred[field].length > 0) { - stripped[field] = cred[field]; - } - } - if (cred.platform === 'web' || cred.platform === 'android') { - stripped.platform = cred.platform; - } - return stripped; - }) - }; -} - fs.writeFileSync(outPath, JSON.stringify(out, null, 2) + '\n', 'utf8'); const parts = []; if (out.twitter) parts.push(`${out.twitter.accounts.length} twitter account(s)`); if (out.bluesky) parts.push(`${out.bluesky.accounts.length} bluesky account(s)`); -if (out.instagram) parts.push(`${out.instagram.accounts.length} instagram account(s)`); console.log(`Wrote ${outPath} (${parts.join(', ')})`);