Import Instagram archive to a Bluesky account
9
fork

Configure Feed

Select the types of activity you want to include in your feed.

First commit

Marco Maroni fa899a25

+459
+11
.gitignore
··· 1 + # Dependency directories 2 + node_modules/ 3 + 4 + # environment variable files 5 + .env 6 + 7 + # log files 8 + .log 9 + 10 + # compiled js 11 + app.js
+9
LICENSE.txt
··· 1 + "Twitter To Bluesky" is published under the MIT license. 2 + 3 + Copyright 2024 Marco Maroni 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 + 7 + The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 + 9 + THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+55
README.md
··· 1 + # Instagram To Bluesky 2 + 3 + Import all post exported from Instagram to a Bluesky account. 4 + 5 + ⚠️ This project is a work-in-progress ⚠️ 6 + 7 + They use the official archive export file format from Instagram (https://www.instagram.com/download/request), this utility reads the archive from the local disk and using the official Bluesky Typescript SDK imports the posts into the configured Bluesky account. 8 + 9 + ⚠️ We recommend creating a specific account to test the import and not using your main Bluesky account ⚠️ 10 + 11 + ## Which posts are NOT imported 12 + 13 + - Stories and post with videos, because videos are not currently supported by Bluesky. 14 + 15 + ## Prerequisite 16 + 17 + - Nodejs >= 20.12x 18 + - The archive of your post from the Instagram in your local disk. 19 + 20 + ## Getting started 21 + 22 + 1. Install Typescript: `npm i -g typescript` 23 + 2. Install Node.js: `npm i -g ts-node` 24 + 3. In the project folder run: `npm i` 25 + 3. Create an .env file in the project folder by setting the following variables: 26 + - `BLUESKY_USERNAME` = username into which you want to import the tweets (e.g. "test.bsky.social") 27 + - `BLUESKY_PASSWORD` = account password created via App Password (eg. "pwd123") 28 + - `ARCHIVE_FOLDER` = full path to the folder containing the Instagram archive (e.g. "C:/Temp/instagram-archive") 29 + 30 + 31 + **I highly recommend trying to simulate the import first and import a small range of tweets, using the additional parameters documented below.** 32 + 33 + ## Running the script 34 + 35 + You can run the script locally: `npm start` or `npm run start_log` to write an import.log file. 36 + 37 + ### Optional environment parameters 38 + 39 + Additionally you can set these environment variables to customize behavior: 40 + 41 + - `SIMULATE` = if set to "1" simulates the import by counting the tweets and indicating the estimated import time. 42 + - `MIN_DATE` = indicates the minimum date of tweets to import, ISO format (e.g. '2011-01-01' or '2011-02-09T10:30:49.000Z'). 43 + - `MAX_DATE` = indicates the maximum date of tweets to import, ISO format (e.g. '2012-01-01' or '2014-04-09T12:36:49.328Z'). 44 + 45 + ## License 46 + 47 + "Instagram To Bluesky" is published under the MIT license. 48 + 49 + Copyright 2024 Marco Maroni 50 + 51 + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 52 + 53 + The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 54 + 55 + THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+200
app.ts
··· 1 + import * as dotenv from 'dotenv'; 2 + import FS from 'fs'; 3 + import * as process from 'process'; 4 + 5 + import { BskyAgent, RichText } from '@atproto/api'; 6 + 7 + dotenv.config(); 8 + 9 + const agent = new BskyAgent({ 10 + service: 'https://bsky.social', 11 + }) 12 + 13 + const SIMULATE = process.env.SIMULATE === "1"; 14 + 15 + const API_DELAY = 2500; // https://docs.bsky.app/docs/advanced-guides/rate-limits 16 + 17 + const TWITTER_HANDLE = process.env.TWITTER_HANDLE; 18 + 19 + let MIN_DATE: Date | undefined = undefined; 20 + if (process.env.MIN_DATE != null && process.env.MIN_DATE.length > 0) 21 + MIN_DATE = new Date(process.env.MIN_DATE as string); 22 + 23 + let MAX_DATE: Date | undefined = undefined; 24 + if (process.env.MAX_DATE != null && process.env.MAX_DATE.length > 0) 25 + MAX_DATE = new Date(process.env.MAX_DATE as string); 26 + 27 + function decodeUTF8(data) { 28 + if (typeof data === "string") { 29 + const charCodes = Array.prototype.map.call(data, (c) => c.charCodeAt(0)) as number[]; 30 + const utf8 = new Uint8Array(charCodes); 31 + return new TextDecoder("utf-8").decode(utf8); 32 + } 33 + 34 + if (Array.isArray(data)) { 35 + return data.map(decodeUTF8); 36 + } 37 + 38 + if (typeof data === "object") { 39 + const obj = {}; 40 + Object.entries(data).forEach(([key, value]) => { 41 + obj[key] = decodeUTF8(value); 42 + }); 43 + return obj; 44 + } 45 + 46 + return data; 47 + } 48 + 49 + 50 + async function main() { 51 + console.log(`Import started at ${new Date().toISOString()}`) 52 + 53 + const fInstaPosts = FS.readFileSync(process.env.ARCHIVE_FOLDER + "/your_instagram_activity/content/posts_1.json"); 54 + const instaPosts = decodeUTF8(JSON.parse(fInstaPosts.toString())); 55 + let importedPosts = 0; 56 + let importedMedia = 0; 57 + if (instaPosts != null && instaPosts.length > 0) { 58 + 59 + const sortedPost = instaPosts.sort((a, b) => { 60 + let ad = new Date(a.media[0].creation_timestamp * 1000).getTime(); 61 + let bd = new Date(b.media[0].creation_timestamp * 1000).getTime(); 62 + return ad - bd; 63 + }); 64 + 65 + await agent.login({ identifier: process.env.BLUESKY_USERNAME!, password: process.env.BLUESKY_PASSWORD! }) 66 + 67 + for (let index = 0; index < sortedPost.length; index++) { 68 + const post = sortedPost[index]; 69 + 70 + let postDate: Date | undefined = undefined; 71 + if (post.creation_timestamp != undefined) 72 + postDate = new Date(post.creation_timestamp * 1000); 73 + 74 + let postText = ""; 75 + if (post.title && post.title.length > 0) 76 + postText = post.title; 77 + 78 + // If the post is made up of a single image, 79 + // the text of the post appears to be associated with the only image present 80 + if ( post.media?.length == 1 ) { 81 + if( postText.length == 0) { 82 + postText = post.media[0].title; 83 + } 84 + if( postDate == undefined ) { 85 + postDate = new Date(post.media[0].creation_timestamp * 1000); 86 + } 87 + } 88 + 89 + //this cheks assume that the array is sorted by date (first the oldest) 90 + if (MIN_DATE != undefined && postDate! < MIN_DATE) 91 + continue; 92 + if (MAX_DATE != undefined && postDate! > MAX_DATE) 93 + break; 94 + 95 + console.log(`Parse Instagram post'`); 96 + console.log(` Created at ${postDate?.toISOString()}`); 97 + console.log(` Text '${postText}'`); 98 + 99 + let embeddedImage = [] as any; 100 + for (let j = 0; j < post.media.length; j++) { 101 + const postMedia = post.media[j]; 102 + const mediaDate = new Date(postMedia.creation_timestamp * 1000); 103 + const mediaText = postMedia.title; 104 + 105 + // if (postMedia.uri == "media/posts/202108/240742101_558570538822653_7921317535156034037_n_17968506598442521.jpg") { 106 + // console.log("debug"); 107 + // } 108 + 109 + console.log(` Media ${j} - ${postMedia.uri}`); 110 + console.log(` Created at ${mediaDate.toISOString()}`); 111 + console.log(` Text '${mediaText}'`); 112 + 113 + const fileType = postMedia.uri.substring(postMedia.uri.lastIndexOf(".") + 1) 114 + let mimeType = ""; 115 + switch (fileType) { 116 + case "heic": 117 + mimeType = "image/heic" 118 + break; 119 + case "webp": 120 + mimeType = "image/webp" 121 + break; 122 + case "jpg": 123 + mimeType = "image/jpeg" 124 + break; 125 + default: 126 + console.error("Unsopported image file type" + fileType); 127 + break; 128 + } 129 + if (mimeType.length <= 0) 130 + continue; 131 + 132 + const mediaFilename = `${process.env.ARCHIVE_FOLDER}/${postMedia.uri}`; 133 + const imageBuffer = FS.readFileSync(mediaFilename); 134 + 135 + if (!SIMULATE) { 136 + const blobRecord = await agent.uploadBlob(imageBuffer, { 137 + encoding: mimeType 138 + }); 139 + 140 + embeddedImage.push({ 141 + alt: mediaText, 142 + image: { 143 + $type: "blob", 144 + ref: blobRecord.data.blob.ref, 145 + mimeType: mimeType, 146 + size: blobRecord.data.blob.size 147 + } 148 + }) 149 + } 150 + 151 + importedMedia++; 152 + } 153 + 154 + const rt = new RichText({ 155 + text: postText 156 + }); 157 + await rt.detectFacets(agent); 158 + const postRecord = { 159 + $type: 'app.bsky.feed.post', 160 + text: rt.text, 161 + facets: rt.facets, 162 + createdAt: postDate?.toISOString(), 163 + embed: embeddedImage.length > 0 ? { $type: "app.bsky.embed.images", images: embeddedImage } : undefined, 164 + } 165 + 166 + if (!SIMULATE) { 167 + //I wait 3 seconds so as not to exceed the api rate limits 168 + await new Promise(resolve => setTimeout(resolve, API_DELAY)); 169 + 170 + const recordData = await agent.post(postRecord); 171 + const i = recordData.uri.lastIndexOf("/"); 172 + if (i > 0) { 173 + const rkey = recordData.uri.substring(i + 1); 174 + const postUri = `https://bsky.app/profile/${process.env.BLUESKY_USERNAME!}/post/${rkey}`; 175 + console.log("Bluesky post create, URL: " + postUri); 176 + importedPosts++; 177 + } else { 178 + console.warn(recordData); 179 + } 180 + } else { 181 + importedPosts++; 182 + } 183 + 184 + // if( importedPosts > 2) 185 + // break; 186 + } 187 + } 188 + 189 + if (SIMULATE) { 190 + // In addition to the delay in AT Proto API calls, we will also consider a 10% delta for image upload 191 + const minutes = Math.round((importedMedia * API_DELAY / 1000) / 60) * 1.10 192 + const hours = Math.floor(minutes / 60); 193 + const min = minutes % 60; 194 + console.log(`Estimated time for real import: ${hours} hours and ${min} minutes`); 195 + } 196 + 197 + console.log(`Import finished at ${new Date().toISOString()}, imported ${importedPosts} posts with ${importedMedia} media`) 198 + } 199 + 200 + main();
+146
package-lock.json
··· 1 + { 2 + "name": "instagramtobluesky", 3 + "version": "0.0.1", 4 + "lockfileVersion": 3, 5 + "requires": true, 6 + "packages": { 7 + "": { 8 + "name": "instagramtobluesky", 9 + "version": "0.0.1", 10 + "dependencies": { 11 + "@atproto/api": "^0.12.2", 12 + "dotenv": "^16.4.5", 13 + "process": "^0.11.10" 14 + }, 15 + "devDependencies": { 16 + "@types/node": "^20.12.7" 17 + }, 18 + "engines": { 19 + "node": ">=20.12.0" 20 + } 21 + }, 22 + "node_modules/@atproto/api": { 23 + "version": "0.12.2", 24 + "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.12.2.tgz", 25 + "integrity": "sha512-UVzCiDZH2j0wrr/O8nb1edD5cYLVqB5iujueXUCbHS3rAwIxgmyLtA3Hzm2QYsGPo/+xsIg1fNvpq9rNT6KWUA==", 26 + "dependencies": { 27 + "@atproto/common-web": "^0.3.0", 28 + "@atproto/lexicon": "^0.4.0", 29 + "@atproto/syntax": "^0.3.0", 30 + "@atproto/xrpc": "^0.5.0", 31 + "multiformats": "^9.9.0", 32 + "tlds": "^1.234.0" 33 + } 34 + }, 35 + "node_modules/@atproto/common-web": { 36 + "version": "0.3.0", 37 + "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.3.0.tgz", 38 + "integrity": "sha512-67VnV6JJyX+ZWyjV7xFQMypAgDmjVaR9ZCuU/QW+mqlqI7fex2uL4Fv+7/jHadgzhuJHVd6OHOvNn0wR5WZYtA==", 39 + "dependencies": { 40 + "graphemer": "^1.4.0", 41 + "multiformats": "^9.9.0", 42 + "uint8arrays": "3.0.0", 43 + "zod": "^3.21.4" 44 + } 45 + }, 46 + "node_modules/@atproto/lexicon": { 47 + "version": "0.4.0", 48 + "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.4.0.tgz", 49 + "integrity": "sha512-RvCBKdSI4M8qWm5uTNz1z3R2yIvIhmOsMuleOj8YR6BwRD+QbtUBy3l+xQ7iXf4M5fdfJFxaUNa6Ty0iRwdKqQ==", 50 + "dependencies": { 51 + "@atproto/common-web": "^0.3.0", 52 + "@atproto/syntax": "^0.3.0", 53 + "iso-datestring-validator": "^2.2.2", 54 + "multiformats": "^9.9.0", 55 + "zod": "^3.21.4" 56 + } 57 + }, 58 + "node_modules/@atproto/syntax": { 59 + "version": "0.3.0", 60 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.3.0.tgz", 61 + "integrity": "sha512-Weq0ZBxffGHDXHl9U7BQc2BFJi/e23AL+k+i5+D9hUq/bzT4yjGsrCejkjq0xt82xXDjmhhvQSZ0LqxyZ5woxA==" 62 + }, 63 + "node_modules/@atproto/xrpc": { 64 + "version": "0.5.0", 65 + "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.5.0.tgz", 66 + "integrity": "sha512-swu+wyOLvYW4l3n+VAuJbHcPcES+tin2Lsrp8Bw5aIXIICiuFn1YMFlwK9JwVUzTH21Py1s1nHEjr4CJeElJog==", 67 + "dependencies": { 68 + "@atproto/lexicon": "^0.4.0", 69 + "zod": "^3.21.4" 70 + } 71 + }, 72 + "node_modules/@types/node": { 73 + "version": "20.12.7", 74 + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz", 75 + "integrity": "sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==", 76 + "dev": true, 77 + "dependencies": { 78 + "undici-types": "~5.26.4" 79 + } 80 + }, 81 + "node_modules/dotenv": { 82 + "version": "16.4.5", 83 + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", 84 + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", 85 + "engines": { 86 + "node": ">=12" 87 + }, 88 + "funding": { 89 + "url": "https://dotenvx.com" 90 + } 91 + }, 92 + "node_modules/graphemer": { 93 + "version": "1.4.0", 94 + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", 95 + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==" 96 + }, 97 + "node_modules/iso-datestring-validator": { 98 + "version": "2.2.2", 99 + "resolved": "https://registry.npmjs.org/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz", 100 + "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==" 101 + }, 102 + "node_modules/multiformats": { 103 + "version": "9.9.0", 104 + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", 105 + "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==" 106 + }, 107 + "node_modules/process": { 108 + "version": "0.11.10", 109 + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", 110 + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", 111 + "engines": { 112 + "node": ">= 0.6.0" 113 + } 114 + }, 115 + "node_modules/tlds": { 116 + "version": "1.252.0", 117 + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.252.0.tgz", 118 + "integrity": "sha512-GA16+8HXvqtfEnw/DTcwB0UU354QE1n3+wh08oFjr6Znl7ZLAeUgYzCcK+/CCrOyE0vnHR8/pu3XXG3vDijXpQ==", 119 + "bin": { 120 + "tlds": "bin.js" 121 + } 122 + }, 123 + "node_modules/uint8arrays": { 124 + "version": "3.0.0", 125 + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz", 126 + "integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==", 127 + "dependencies": { 128 + "multiformats": "^9.4.2" 129 + } 130 + }, 131 + "node_modules/undici-types": { 132 + "version": "5.26.5", 133 + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", 134 + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", 135 + "dev": true 136 + }, 137 + "node_modules/zod": { 138 + "version": "3.22.4", 139 + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", 140 + "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", 141 + "funding": { 142 + "url": "https://github.com/sponsors/colinhacks" 143 + } 144 + } 145 + } 146 + }
+22
package.json
··· 1 + { 2 + "name": "instagramtobluesky", 3 + "version": "0.0.1", 4 + "description": "Import Instagram archive to a Bluesky account", 5 + "main": "app.js", 6 + "engines": { 7 + "node": ">=20.12.0" 8 + }, 9 + "scripts": { 10 + "start": "npx tsc && node app.js", 11 + "start_log": "npx tsc && node app.js > import.log", 12 + "compile": "npx tsc" 13 + }, 14 + "dependencies": { 15 + "@atproto/api": "^0.12.2", 16 + "dotenv": "^16.4.5", 17 + "process": "^0.11.10" 18 + }, 19 + "devDependencies": { 20 + "@types/node": "^20.12.7" 21 + } 22 + }
+16
tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "target": "esnext", 4 + "module": "NodeNext", 5 + "moduleResolution": "nodenext", 6 + "allowSyntheticDefaultImports": true, 7 + "esModuleInterop": true, 8 + "forceConsistentCasingInFileNames": true, 9 + "strict": true, 10 + "noImplicitAny": false, 11 + "skipLibCheck": true 12 + }, 13 + "ts-node": { 14 + "esm": true 15 + } 16 + }