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) |