Built this as one off script to post a bunch of records to bluesky based on a given csv to bootstrap https://bsky.app/profile/cdk.dev
sonnet 3.5 wrote all the code
Built this as one off script to post a bunch of records to bluesky based on a given csv to bootstrap https://bsky.app/profile/cdk.dev
sonnet 3.5 wrote all the code
| import { XRPC, CredentialManager } from '@atcute/client' | |
| import '@atcute/bluesky/lexicons' | |
| import RichtextBuilder from '@atcute/bluesky-richtext-builder' | |
| import { parse } from 'csv-parse/sync' | |
| import { readFileSync } from 'node:fs' | |
| import { resolve } from 'node:path' | |
| async function fetchEmbedUrlCard(url: string, manager: CredentialManager) { | |
| try { | |
| const response = await fetch(url) | |
| const html = await response.text() | |
| const card: any = { | |
| uri: url, | |
| title: '', | |
| description: '' | |
| } | |
| const titleMatch = html.match(/<meta property="og:title" content="([^"]+)"/) | |
| if (titleMatch) card.title = titleMatch[1] | |
| const descMatch = html.match(/<meta property="og:description" content="([^"]+)"/) | |
| if (descMatch) card.description = descMatch[1] | |
| const imgMatch = html.match(/<meta property="og:image" content="([^"]+)"/) | |
| if (imgMatch) { | |
| const imgUrl = imgMatch[1].startsWith('http') ? imgMatch[1] : `${url}${imgMatch[1]}` | |
| const imgResponse = await fetch(imgUrl) | |
| const contentType = imgResponse.headers.get('content-type') | |
| if (!contentType?.startsWith('image/')) { | |
| console.warn(`Skipping invalid image type: ${contentType} for URL: ${imgUrl}`) | |
| return { | |
| $type: 'app.bsky.embed.external', | |
| external: card | |
| } | |
| } | |
| const blob = await imgResponse.blob() | |
| const blobResponse = await fetch('https://bsky.social/xrpc/com.atproto.repo.uploadBlob', { | |
| method: 'POST', | |
| headers: { | |
| 'Authorization': `Bearer ${await manager.session?.accessJwt}` | |
| }, | |
| body: blob | |
| }) | |
| const blobData = await blobResponse.json() | |
| card.thumb = blobData.blob | |
| } | |
| return { | |
| $type: 'app.bsky.embed.external', | |
| external: card | |
| } | |
| } catch (error) { | |
| console.error('Error fetching embed:', error) | |
| return null | |
| } | |
| } | |
| async function main() { | |
| const manager = new CredentialManager({ service: 'https://bsky.social' }) | |
| const rpc = new XRPC({ handler: manager }) | |
| await manager.login({ | |
| identifier: `<user>`, | |
| password: `<app password>` | |
| }) | |
| const csvPath = resolve(__dirname, 'posts.csv') | |
| const csvContent = readFileSync(csvPath, 'utf-8') | |
| const records = parse(csvContent, { | |
| columns: true, | |
| skip_empty_lines: true | |
| }) | |
| let index = 0; | |
| for (const record of records) { | |
| try { | |
| // Calculate all lengths first | |
| const tags = JSON.parse(record.categories) | |
| const tagsLength = tags.reduce((acc: number, cat: any) => | |
| acc + cat.S.replace(/\s+/g, '').length + 2, 0) // +2 for # and space | |
| const urlLength = record.url.length + 1 // +1 for space | |
| const totalFixedLength = tagsLength + urlLength | |
| const maxContentLength = 280 - totalFixedLength | |
| // Determine content length and truncate if needed | |
| let content = record.content | |
| if (content.length > maxContentLength) { | |
| console.warn(`Content too long (${content.length + totalFixedLength} total chars), truncating: ${record.title}`) | |
| content = `${content.slice(0, maxContentLength - 3)}...` // -3 for ellipsis | |
| } | |
| // Now build the post with our pre-calculated content | |
| const builder = new RichtextBuilder() | |
| .addText(content) | |
| .addText(' ') | |
| // Add tags and URL | |
| for (const cat of tags) { | |
| builder.addTag(cat.S.replace(/\s+/g, '')) | |
| builder.addText(' ') | |
| } | |
| builder.addLink(record.url, record.url) | |
| const { text, facets } = builder.build() | |
| const embed = await fetchEmbedUrlCard(record.url, manager) | |
| // Use the createdAt from CSV and add minutes based on index | |
| const createdAtDate = new Date(record.createdAt) | |
| createdAtDate.setMinutes(createdAtDate.getMinutes() + index) | |
| const result = await rpc.call('com.atproto.repo.createRecord', { | |
| data: { | |
| repo: await manager.session?.did!, | |
| collection: 'app.bsky.feed.post', | |
| record: { | |
| text, | |
| createdAt: createdAtDate.toISOString(), | |
| facets, | |
| embed, | |
| $type: 'app.bsky.feed.post' | |
| } | |
| } | |
| }) | |
| index++ | |
| console.log(`Posted: ${record.title}`) | |
| console.log(result) | |
| await new Promise(resolve => setTimeout(resolve, 5 * 1000)) | |
| } catch (error) { | |
| console.error(`Error posting ${record.title}:`, error) | |
| } | |
| } | |
| } | |
| main().catch(console.error) |