I made a little script to download images from tweets and set exif data to info derived from the tweet. Especially helpful is the creation date and the GPS data (if the tweet includes location).

I'm using this to go back to pictures people have taken of me speaking at conferences and adding them to my personal photo library and make sure they appear at the right time in my photo library timeline.

Install twimage-download

// Menu: Twimage Download
// Description: Download twitter images and set their exif info based on the tweet metadata
// Shortcut: fn ctrl opt cmd t
// Author: Kent C. Dodds
// Twitter: @kentcdodds
import fs from 'fs'
import {fileURLToPath, URL} from 'url'
const exiftool = await npm('node-exiftool')
const exiftoolBin = await npm('dist-exiftool')
const fsExtra = await npm('fs-extra')
const baseOut = home('Pictures/twimages')
const token = await env('TWITTER_BEARER_TOKEN')
const twitterUrl = await arg('Twitter URL')
console.log(`Starting with ${twitterUrl}`)
const tweetId = new URL(twitterUrl).pathname.split('/').slice(-1)[0]
const params = new URLSearchParams()
params.set('ids', tweetId)
params.set('user.fields', 'username')
params.set('tweet.fields', 'author_id,created_at,geo')
params.set('media.fields', 'url')
params.set('expansions', 'author_id,attachments.media_keys,geo.place_id')
const response = await get(
`https://api.twitter.com/2/tweets?${params.toString()}`,
{
headers: {
authorization: `Bearer ${token}`,
},
},
)
const json = /** @type import('../types/twimage-download').JsonResponse */ (
response.data
)
const ep = new exiftool.ExiftoolProcess(exiftoolBin)
await ep.open()
for (const tweet of json.data) {
const {attachments, geo, id, text, created_at} = tweet
if (!attachments) throw new Error(`No attachements: ${tweet.id}`)
const author = json.includes.users.find(u => u.id === tweet.author_id)
if (!author) throw new Error(`wut? No author? ${tweet.id}`)
const link = `https://twitter.com/${author.username}/status/${id}`
const {latitude, longitude} = geo ? await getGeoCoords(geo.place_id) : {}
for (const mediaKey of attachments.media_keys) {
const media = json.includes.media.find(m => mediaKey === m.media_key)
if (!media) throw new Error(`Huh... no media found...`)
const formattedDate = formatDate(created_at)
const colonDate = formattedDate.replace(/-/g, ':')
const formattedTimestamp = formatTimestamp(created_at)
const filename = new URL(media.url).pathname.split('/').slice(-1)[0]
const filepath = path.join(
baseOut,
formattedDate.split('-').slice(0, 2).join('-'),
filename,
)
await download(media.url, filepath)
console.log(`Updating exif metadata for ${filepath}`)
await ep.writeMetadata(
filepath,
{
ImageDescription: `${text}${link}`,
Keywords: 'photos from tweets',
DateTimeOriginal: formattedTimestamp,
FileModifyDate: formattedTimestamp,
ModifyDate: formattedTimestamp,
CreateDate: formattedTimestamp,
...(geo
? {
GPSLatitudeRef: latitude > 0 ? 'North' : 'South',
GPSLongitudeRef: longitude > 0 ? 'East' : 'West',
GPSLatitude: latitude,
GPSLongitude: longitude,
GPSDateStamp: colonDate,
GPSDateTime: formattedTimestamp,
}
: null),
},
['overwrite_original'],
)
}
}
await ep.close()
console.log(`All done with ${twitterUrl}`)
function formatDate(t) {
const d = new Date(t)
return `${d.getFullYear()}-${padZero(d.getMonth() + 1)}-${padZero(
d.getDate(),
)}`
}
function formatTimestamp(t) {
const d = new Date(t)
const formattedDate = formatDate(t)
return `${formatDate(t)} ${d.getHours()}:${d.getMinutes()}:${d.getSeconds()}`
}
function padZero(n) {
return String(n).padStart(2, '0')
}
async function getGeoCoords(placeId) {
const response = await get(
`https://api.twitter.com/1.1/geo/id/${placeId}.json`,
{
headers: {
authorization: `Bearer ${token}`,
},
},
)
const [longitude, latitude] = response.data.centroid
return {latitude, longitude}
}
async function download(url, out) {
console.log(`downloading ${url} to ${out}`)
await fsExtra.ensureDir(path.dirname(out))
const writer = fs.createWriteStream(out)
const response = await get(url, {responseType: 'stream'})
response.data.pipe(writer)
return new Promise((resolve, reject) => {
writer.on('finish', () => resolve(out))
writer.on('error', reject)
})
}