···11+"Twitter To Bluesky" is published under the MIT license.
22+33+Copyright 2024 Marco Maroni
44+55+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:
66+77+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
88+99+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
···11+# Instagram To Bluesky
22+33+Import all post exported from Instagram to a Bluesky account.
44+55+⚠️ This project is a work-in-progress ⚠️
66+77+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.
88+99+⚠️ We recommend creating a specific account to test the import and not using your main Bluesky account ⚠️
1010+1111+## Which posts are NOT imported
1212+1313+- Stories and post with videos, because videos are not currently supported by Bluesky.
1414+1515+## Prerequisite
1616+1717+- Nodejs >= 20.12x
1818+- The archive of your post from the Instagram in your local disk.
1919+2020+## Getting started
2121+2222+1. Install Typescript: `npm i -g typescript`
2323+2. Install Node.js: `npm i -g ts-node`
2424+3. In the project folder run: `npm i`
2525+3. Create an .env file in the project folder by setting the following variables:
2626+- `BLUESKY_USERNAME` = username into which you want to import the tweets (e.g. "test.bsky.social")
2727+- `BLUESKY_PASSWORD` = account password created via App Password (eg. "pwd123")
2828+- `ARCHIVE_FOLDER` = full path to the folder containing the Instagram archive (e.g. "C:/Temp/instagram-archive")
2929+3030+3131+**I highly recommend trying to simulate the import first and import a small range of tweets, using the additional parameters documented below.**
3232+3333+## Running the script
3434+3535+You can run the script locally: `npm start` or `npm run start_log` to write an import.log file.
3636+3737+### Optional environment parameters
3838+3939+Additionally you can set these environment variables to customize behavior:
4040+4141+- `SIMULATE` = if set to "1" simulates the import by counting the tweets and indicating the estimated import time.
4242+- `MIN_DATE` = indicates the minimum date of tweets to import, ISO format (e.g. '2011-01-01' or '2011-02-09T10:30:49.000Z').
4343+- `MAX_DATE` = indicates the maximum date of tweets to import, ISO format (e.g. '2012-01-01' or '2014-04-09T12:36:49.328Z').
4444+4545+## License
4646+4747+"Instagram To Bluesky" is published under the MIT license.
4848+4949+Copyright 2024 Marco Maroni
5050+5151+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:
5252+5353+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
5454+5555+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
···11+import * as dotenv from 'dotenv';
22+import FS from 'fs';
33+import * as process from 'process';
44+55+import { BskyAgent, RichText } from '@atproto/api';
66+77+dotenv.config();
88+99+const agent = new BskyAgent({
1010+ service: 'https://bsky.social',
1111+})
1212+1313+const SIMULATE = process.env.SIMULATE === "1";
1414+1515+const API_DELAY = 2500; // https://docs.bsky.app/docs/advanced-guides/rate-limits
1616+1717+const TWITTER_HANDLE = process.env.TWITTER_HANDLE;
1818+1919+let MIN_DATE: Date | undefined = undefined;
2020+if (process.env.MIN_DATE != null && process.env.MIN_DATE.length > 0)
2121+ MIN_DATE = new Date(process.env.MIN_DATE as string);
2222+2323+let MAX_DATE: Date | undefined = undefined;
2424+if (process.env.MAX_DATE != null && process.env.MAX_DATE.length > 0)
2525+ MAX_DATE = new Date(process.env.MAX_DATE as string);
2626+2727+function decodeUTF8(data) {
2828+ if (typeof data === "string") {
2929+ const charCodes = Array.prototype.map.call(data, (c) => c.charCodeAt(0)) as number[];
3030+ const utf8 = new Uint8Array(charCodes);
3131+ return new TextDecoder("utf-8").decode(utf8);
3232+ }
3333+3434+ if (Array.isArray(data)) {
3535+ return data.map(decodeUTF8);
3636+ }
3737+3838+ if (typeof data === "object") {
3939+ const obj = {};
4040+ Object.entries(data).forEach(([key, value]) => {
4141+ obj[key] = decodeUTF8(value);
4242+ });
4343+ return obj;
4444+ }
4545+4646+ return data;
4747+}
4848+4949+5050+async function main() {
5151+ console.log(`Import started at ${new Date().toISOString()}`)
5252+5353+ const fInstaPosts = FS.readFileSync(process.env.ARCHIVE_FOLDER + "/your_instagram_activity/content/posts_1.json");
5454+ const instaPosts = decodeUTF8(JSON.parse(fInstaPosts.toString()));
5555+ let importedPosts = 0;
5656+ let importedMedia = 0;
5757+ if (instaPosts != null && instaPosts.length > 0) {
5858+5959+ const sortedPost = instaPosts.sort((a, b) => {
6060+ let ad = new Date(a.media[0].creation_timestamp * 1000).getTime();
6161+ let bd = new Date(b.media[0].creation_timestamp * 1000).getTime();
6262+ return ad - bd;
6363+ });
6464+6565+ await agent.login({ identifier: process.env.BLUESKY_USERNAME!, password: process.env.BLUESKY_PASSWORD! })
6666+6767+ for (let index = 0; index < sortedPost.length; index++) {
6868+ const post = sortedPost[index];
6969+7070+ let postDate: Date | undefined = undefined;
7171+ if (post.creation_timestamp != undefined)
7272+ postDate = new Date(post.creation_timestamp * 1000);
7373+7474+ let postText = "";
7575+ if (post.title && post.title.length > 0)
7676+ postText = post.title;
7777+7878+ // If the post is made up of a single image,
7979+ // the text of the post appears to be associated with the only image present
8080+ if ( post.media?.length == 1 ) {
8181+ if( postText.length == 0) {
8282+ postText = post.media[0].title;
8383+ }
8484+ if( postDate == undefined ) {
8585+ postDate = new Date(post.media[0].creation_timestamp * 1000);
8686+ }
8787+ }
8888+8989+ //this cheks assume that the array is sorted by date (first the oldest)
9090+ if (MIN_DATE != undefined && postDate! < MIN_DATE)
9191+ continue;
9292+ if (MAX_DATE != undefined && postDate! > MAX_DATE)
9393+ break;
9494+9595+ console.log(`Parse Instagram post'`);
9696+ console.log(` Created at ${postDate?.toISOString()}`);
9797+ console.log(` Text '${postText}'`);
9898+9999+ let embeddedImage = [] as any;
100100+ for (let j = 0; j < post.media.length; j++) {
101101+ const postMedia = post.media[j];
102102+ const mediaDate = new Date(postMedia.creation_timestamp * 1000);
103103+ const mediaText = postMedia.title;
104104+105105+ // if (postMedia.uri == "media/posts/202108/240742101_558570538822653_7921317535156034037_n_17968506598442521.jpg") {
106106+ // console.log("debug");
107107+ // }
108108+109109+ console.log(` Media ${j} - ${postMedia.uri}`);
110110+ console.log(` Created at ${mediaDate.toISOString()}`);
111111+ console.log(` Text '${mediaText}'`);
112112+113113+ const fileType = postMedia.uri.substring(postMedia.uri.lastIndexOf(".") + 1)
114114+ let mimeType = "";
115115+ switch (fileType) {
116116+ case "heic":
117117+ mimeType = "image/heic"
118118+ break;
119119+ case "webp":
120120+ mimeType = "image/webp"
121121+ break;
122122+ case "jpg":
123123+ mimeType = "image/jpeg"
124124+ break;
125125+ default:
126126+ console.error("Unsopported image file type" + fileType);
127127+ break;
128128+ }
129129+ if (mimeType.length <= 0)
130130+ continue;
131131+132132+ const mediaFilename = `${process.env.ARCHIVE_FOLDER}/${postMedia.uri}`;
133133+ const imageBuffer = FS.readFileSync(mediaFilename);
134134+135135+ if (!SIMULATE) {
136136+ const blobRecord = await agent.uploadBlob(imageBuffer, {
137137+ encoding: mimeType
138138+ });
139139+140140+ embeddedImage.push({
141141+ alt: mediaText,
142142+ image: {
143143+ $type: "blob",
144144+ ref: blobRecord.data.blob.ref,
145145+ mimeType: mimeType,
146146+ size: blobRecord.data.blob.size
147147+ }
148148+ })
149149+ }
150150+151151+ importedMedia++;
152152+ }
153153+154154+ const rt = new RichText({
155155+ text: postText
156156+ });
157157+ await rt.detectFacets(agent);
158158+ const postRecord = {
159159+ $type: 'app.bsky.feed.post',
160160+ text: rt.text,
161161+ facets: rt.facets,
162162+ createdAt: postDate?.toISOString(),
163163+ embed: embeddedImage.length > 0 ? { $type: "app.bsky.embed.images", images: embeddedImage } : undefined,
164164+ }
165165+166166+ if (!SIMULATE) {
167167+ //I wait 3 seconds so as not to exceed the api rate limits
168168+ await new Promise(resolve => setTimeout(resolve, API_DELAY));
169169+170170+ const recordData = await agent.post(postRecord);
171171+ const i = recordData.uri.lastIndexOf("/");
172172+ if (i > 0) {
173173+ const rkey = recordData.uri.substring(i + 1);
174174+ const postUri = `https://bsky.app/profile/${process.env.BLUESKY_USERNAME!}/post/${rkey}`;
175175+ console.log("Bluesky post create, URL: " + postUri);
176176+ importedPosts++;
177177+ } else {
178178+ console.warn(recordData);
179179+ }
180180+ } else {
181181+ importedPosts++;
182182+ }
183183+184184+ // if( importedPosts > 2)
185185+ // break;
186186+ }
187187+ }
188188+189189+ if (SIMULATE) {
190190+ // In addition to the delay in AT Proto API calls, we will also consider a 10% delta for image upload
191191+ const minutes = Math.round((importedMedia * API_DELAY / 1000) / 60) * 1.10
192192+ const hours = Math.floor(minutes / 60);
193193+ const min = minutes % 60;
194194+ console.log(`Estimated time for real import: ${hours} hours and ${min} minutes`);
195195+ }
196196+197197+ console.log(`Import finished at ${new Date().toISOString()}, imported ${importedPosts} posts with ${importedMedia} media`)
198198+}
199199+200200+main();