Import Instagram archive to a Bluesky account
9
fork

Configure Feed

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

Refactor Instagram to Bluesky migration with improved media processing and upload strategy

- Update BlueskyClient to use a unified uploadMedia method for images and videos
- Modify instagram-to-bluesky.ts to use new InstagramMediaProcessor for post processing
- Implement more robust media upload and embedding logic
- Update tsconfig to exclude test files
- Improve error handling and logging for media uploads

+106 -74
+9 -29
src/bluesky/bluesky.ts
··· 43 43 } 44 44 45 45 /** 46 - * Upload video file and get blob reference 47 - */ 48 - async uploadVideo( 49 - buffer: Buffer, 50 - mimeType: string = "video/mp4" 51 - ): Promise<BlobRef> { 52 - try { 53 - logger.debug("Starting video upload process..."); 54 - const response = await this.agent.uploadBlob(buffer, { 55 - encoding: mimeType, 56 - }); 57 - 58 - if (!response?.data?.blob) { 59 - throw new Error("Failed to get video upload reference"); 60 - } 61 - 62 - return response.data.blob; 63 - } catch (error) { 64 - logger.error("Failed to upload video:", error); 65 - throw error; 66 - } 67 - } 68 - 69 - /** 70 - * Upload image file and get blob reference 46 + * Upload media file (image or video) and get blob reference 47 + * @param buffer The media file buffer 48 + * @param mimeType The MIME type of the media (defaults to image/jpeg) 71 49 */ 72 - async uploadImage( 50 + async uploadMedia( 73 51 buffer: Buffer | Blob, 74 52 mimeType: string = "image/jpeg" 75 53 ): Promise<BlobRef> { 76 54 try { 77 - logger.debug("Uploading image..."); 55 + const mediaType = mimeType.startsWith('video') ? 'video' : 'image'; 56 + logger.debug(`Uploading ${mediaType}...`); 57 + 78 58 const response = await this.agent.uploadBlob(buffer, { 79 59 encoding: mimeType, 80 60 }); 81 61 82 62 if (!response?.data?.blob) { 83 - throw new Error("Failed to get image upload reference"); 63 + throw new Error(`Failed to get ${mediaType} upload reference`); 84 64 } 85 65 86 66 return response.data.blob; 87 67 } catch (error) { 88 - logger.error("Failed to upload image:", error); 68 + logger.error(`Failed to upload media with mimeType: ${mimeType}:`, error); 89 69 throw error; 90 70 } 91 71 }
+96 -44
src/instagram-to-bluesky.ts
··· 1 - import * as dotenv from 'dotenv'; 2 - import FS from 'fs'; 3 - import path from 'path'; 4 - import * as process from 'process'; 1 + import * as dotenv from "dotenv"; 2 + import FS from "fs"; 3 + import path from "path"; 4 + import * as process from "process"; 5 5 6 - import { BlueskyClient } from './bluesky/bluesky.js'; 7 - import { logger } from './logger/logger.js'; 8 - import { decodeUTF8, processPost } from './media/media.js'; 9 - import { InstagramExportedPost } from './media/InstagramExportedPost.js'; 6 + import { BlueskyClient } from "./bluesky/bluesky"; 7 + import { logger } from "./logger/logger"; 8 + import { decodeUTF8, InstagramMediaProcessor } from "./media/media"; 9 + import { InstagramExportedPost } from "./media/InstagramExportedPost"; 10 + import { 11 + EmbeddedMedia, 12 + ImageEmbed, 13 + ImageEmbedImpl, 14 + ImagesEmbedImpl, 15 + VideoEmbedImpl, 16 + } from "./bluesky/index"; 17 + import { BlobRef } from "@atproto/api"; 10 18 11 19 dotenv.config(); 12 20 ··· 57 65 } 58 66 59 67 /** 60 - * 68 + * 61 69 */ 62 70 export async function main() { 63 71 // Set environment variables within function scope, allows mocked unit testing. ··· 120 128 const fInstaPosts: Buffer = FS.readFileSync(postsJsonPath); 121 129 122 130 // Decode raw JSON data into an object. 123 - const instaPosts: InstagramExportedPost[] = decodeUTF8(JSON.parse(fInstaPosts.toString())); 131 + const instaPosts: InstagramExportedPost[] = decodeUTF8( 132 + JSON.parse(fInstaPosts.toString()) 133 + ); 124 134 125 135 // Initialize counters for posts and media imports. 126 136 let importedPosts = 0; ··· 135 145 return ad - bd; 136 146 }); 137 147 138 - // Check each posts/media's creation timestamp 148 + // Preprocess posts before transforming into a normalized format. 139 149 for (const post of sortedPosts) { 140 150 let checkDate: Date | undefined; 141 151 if (post.creation_timestamp) { ··· 167 177 ); 168 178 break; 169 179 } 180 + } 170 181 182 + // Create media processor that can handle multiple data formats. 183 + const mediaProcessor = new InstagramMediaProcessor( 184 + instaPosts, 185 + archivalFolder 186 + ); 171 187 172 - // TODO use media processor instead. 173 - const { 174 - postDate, 175 - postText, 176 - mediaCount, 177 - embeddedMedia: initialMedia, 178 - } = await processPost(post, archivalFolder); 179 - let embeddedMedia: any = initialMedia; 180 - 188 + // Process posts with images and a video. 189 + const processedPosts = await mediaProcessor.process(); 190 + 191 + for (const { 192 + postDate, 193 + postText, 194 + embeddedMedia, 195 + mediaCount, 196 + } of processedPosts) { 181 197 // If the post does not have a creation date after processing skip. 182 198 if (!postDate) { 183 199 logger.warn("Skipping post - Invalid date"); ··· 190 206 setTimeout(resolve, API_RATE_LIMIT_DELAY) 191 207 ); 192 208 try { 209 + let uploadedMedia: EmbeddedMedia | undefined; 193 210 211 + if (embeddedMedia) { 212 + if (Array.isArray(embeddedMedia)) { 213 + const embeddedImages: ImageEmbed[] = []; 214 + for (const imageMedia of embeddedMedia) { 215 + const { mediaBuffer, mimeType } = imageMedia; 194 216 195 - // Create post with embedded pre-uploaded data. 196 - const postUrl = await bluesky.createPost( 197 - postDate, 198 - postText, 199 - embeddedMedia 200 - ); 217 + const blobRef: BlobRef = await bluesky.uploadMedia( 218 + mediaBuffer!, 219 + mimeType! 220 + ); 221 + embeddedImages.push( 222 + new ImageEmbedImpl(postText, blobRef, mimeType!, mediaBuffer!) 223 + ); 224 + } 201 225 202 - if (postUrl) { 203 - logger.info(`Bluesky post created with url: ${postUrl}`); 204 - importedPosts++; 226 + uploadedMedia = new ImagesEmbedImpl(embeddedImages); 227 + } else { 228 + const { mediaBuffer, mimeType } = embeddedMedia; 229 + const blobRef = await bluesky.uploadMedia( 230 + mediaBuffer!, 231 + mimeType! 232 + ); 233 + uploadedMedia = new VideoEmbedImpl( 234 + postText, 235 + mediaBuffer!, 236 + mimeType!, 237 + mediaBuffer?.length, 238 + blobRef, 239 + { width: 640, height: 640 } 240 + ); 241 + } 242 + } 243 + 244 + if (uploadedMedia) { 245 + // Create post with embedded pre-uploaded data. 246 + const postUrl = await bluesky.createPost( 247 + postDate, 248 + postText, 249 + uploadedMedia 250 + ); 251 + 252 + // Log successful post creation 253 + if (postUrl) { 254 + logger.info(`Bluesky post created with url: ${postUrl}`); 255 + importedPosts++; 256 + } 205 257 } 206 258 } catch (error) { 207 259 logger.error( ··· 230 282 // Add the total media posted to inform the user. 231 283 importedMedia += mediaCount; 232 284 } 233 - } 234 - 235 - // If we are simulating the migration we want to inform the user the estimated time it may take. 236 - if (SIMULATE) { 237 - const estimatedTime = calculateEstimatedTime(importedMedia); 238 - logger.info(`Estimated time for real import: ${estimatedTime}`); 239 - } 240 285 241 - // Log the results for the user, end time, and the number of posts and media migrated. 242 - const importEnd: Date = new Date(); 243 - logger.info( 244 - `Import finished at ${importEnd.toISOString()}, imported ${importedPosts} posts with ${importedMedia} media` 245 - ); 246 - // Inform the user the total time it took to migrate. 247 - const totalTime = importEnd.getTime() - importStart.getTime(); 248 - logger.info(`Total import time: ${formatDuration(totalTime)}`); 286 + // If we are simulating the migration we want to inform the user the estimated time it may take. 287 + if (SIMULATE) { 288 + const estimatedTime = calculateEstimatedTime(importedMedia); 289 + logger.info(`Estimated time for real import: ${estimatedTime}`); 290 + } 291 + 292 + // Log the results for the user, end time, and the number of posts and media migrated. 293 + const importEnd: Date = new Date(); 294 + logger.info( 295 + `Import finished at ${importEnd.toISOString()}, imported ${importedPosts} posts with ${importedMedia} media` 296 + ); 297 + // Inform the user the total time it took to migrate. 298 + const totalTime = importEnd.getTime() - importStart.getTime(); 299 + logger.info(`Total import time: ${formatDuration(totalTime)}`); 300 + } 249 301 }
+1 -1
tsconfig.json
··· 28 28 "require": ["tsconfig-paths/register"] 29 29 }, 30 30 "include": ["src/**/*"], 31 - "exclude": ["node_modules"] 31 + "exclude": ["node_modules", "**/*test.ts"] 32 32 }