teqo-backup/scripts/lib/flickrApi.mjs
Francisco Solla 446a873c84 feat(C231): ingerir o acervo de fotos do Flickr no storage privado
Collection privada archivePhoto (flickrId unique+index, metadados do Flickr +
EXIF, leitura pela comunicação, mutação admin, entrada no mapa S3) com migration
aditiva add_archive_photo; ingestão idempotente (transação + upload) e CLI
pnpm flickr:import (plano dry-run por padrão, --apply com intenção/TEQO_ENV/S3,
--verify inventário read-only, recibos JSON em data/flickr/reports). Guard de
alvo extraído de reel-ingest para scripts/lib/cli.mjs; downloadUrlToFile ganha
headers (User-Agent do Flickr).
2026-09-26 04:19:40 -03:00

202 lines
6.8 KiB
JavaScript

/**
* C231 — thin Flickr API client for the photo-archive ingestion: plain `fetch`
* (injectable), a proper `User-Agent`, sequential pacing and retry with
* backoff for transient failures only. There is no SDK dependency: the account
* is read-only and the endpoints used here answer JSON.
*
* `stat=fail` answers (auth, permission, not-found) are terminal and surface
* as `FlickrApiError` with the Flickr code — the receipt names them. The API
* key travels in the query string (Flickr's own model for public reads) and
* never enters logs or receipts.
*/
const FLICKR_API_URL = 'https://api.flickr.com/services/rest/'
export const FLICKR_USER_AGENT = 'teqo-flickr-archive/1.0 (+https://jorgesolla1313.com.br)'
const FLICKR_LISTING_PAGE_SIZE = 500
/** Flickr documents 3,600 calls/hour — one call per second stays under it. */
const FLICKR_PACING_MS = 1000
/** One listing call carries every metadata field the archive needs. */
export const FLICKR_PHOTO_EXTRAS =
'description,date_taken,date_upload,license,owner_name,tags,geo,media,url_o,path_alias'
const FLICKR_TIMEOUT_MS = 30_000
const FLICKR_RETRY_ATTEMPTS = 4
const FLICKR_RETRY_BASE_DELAY_MS = 1_000
class FlickrApiError extends Error {
/**
* @param {string} message
* @param {{ code?: string | number | null, status?: number | null }} [options]
*/
constructor(message, { code = null, status = null } = {}) {
super(message)
this.name = 'FlickrApiError'
this.code = code
this.status = status
}
}
/** HTTP 429/5xx and thrown network/timeout errors retry; `stat=fail` never does. */
const isRetryable = (error) => {
if (error instanceof FlickrApiError) {
return error.status === 429 || (error.status !== null && error.status >= 500)
}
return true
}
/**
* @param {{
* apiKey?: string,
* fetchImpl?: typeof fetch,
* pacingMs?: number,
* retryAttempts?: number,
* retryBaseDelayMs?: number,
* timeoutMs?: number,
* sleep?: (ms: number) => Promise<void>,
* now?: () => number,
* }} [options]
*/
export const createFlickrClient = ({
apiKey,
fetchImpl = fetch,
pacingMs = FLICKR_PACING_MS,
retryAttempts = FLICKR_RETRY_ATTEMPTS,
retryBaseDelayMs = FLICKR_RETRY_BASE_DELAY_MS,
timeoutMs = FLICKR_TIMEOUT_MS,
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
now = () => Date.now(),
} = {}) => {
const key = typeof apiKey === 'string' ? apiKey.trim() : ''
if (key === '') {
throw new Error('FLICKR_API_KEY ausente — a ingestão exige a chave da API do Flickr.')
}
let lastCallAt = null
const pace = async () => {
if (lastCallAt !== null && pacingMs > 0) {
const wait = lastCallAt + pacingMs - now()
if (wait > 0) await sleep(wait)
}
lastCallAt = now()
}
/**
* One REST call with pacing + transient retry. Returns the parsed body with
* `stat: 'ok'`; anything else is a named refusal.
*/
const call = async (method, params = {}) => {
for (let attempt = 1; ; attempt += 1) {
await pace()
try {
const url = new URL(FLICKR_API_URL)
url.searchParams.set('method', method)
url.searchParams.set('api_key', key)
url.searchParams.set('format', 'json')
url.searchParams.set('nojsoncallback', '1')
for (const [name, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== '') {
url.searchParams.set(name, String(value))
}
}
const response = await fetchImpl(url.toString(), {
headers: { 'User-Agent': FLICKR_USER_AGENT, Accept: 'application/json' },
signal: AbortSignal.timeout(timeoutMs),
})
if (!response.ok) {
throw new FlickrApiError(`HTTP ${response.status}`, { status: response.status })
}
const body = await response.json()
if (body?.stat !== 'ok') {
throw new FlickrApiError(
`Flickr ${body?.code ?? '?'}: ${body?.message ?? 'erro desconhecido'}`,
{ code: body?.code ?? null },
)
}
return body
} catch (error) {
if (attempt >= retryAttempts || !isRetryable(error)) throw error
await sleep(retryBaseDelayMs * 2 ** (attempt - 1))
}
}
}
return {
call,
/** One page of the account photostream (`flickr.people.getPhotos`). */
listPhotosPage: async ({
userId,
page = 1,
perPage = FLICKR_LISTING_PAGE_SIZE,
extras = FLICKR_PHOTO_EXTRAS,
}) => {
const body = await call('flickr.people.getPhotos', {
user_id: userId,
page,
per_page: perPage,
extras,
})
return body.photos ?? { page: 1, pages: 1, per_page: perPage, total: 0, photo: [] }
},
/** One page of the account albums (`flickr.photosets.getList`). */
listPhotoSets: async ({ userId, page = 1, perPage = FLICKR_LISTING_PAGE_SIZE }) => {
const body = await call('flickr.photosets.getList', {
user_id: userId,
page,
per_page: perPage,
})
return body.photosets ?? { page: 1, pages: 1, per_page: perPage, total: 0, photoset: [] }
},
/** One page of an album's photos (`flickr.photosets.getPhotos`). */
listPhotoSetPage: async ({ userId, albumId, page = 1, perPage = FLICKR_LISTING_PAGE_SIZE }) => {
const body = await call('flickr.photosets.getPhotos', {
user_id: userId,
photoset_id: albumId,
page,
per_page: perPage,
})
return body.photoset ?? { page: 1, pages: 1, per_page: perPage, photo: [] }
},
/**
* `flickr.photos.getExif` — tolerant by contract: a photo without readable
* EXIF is not a failure (the receipt records the gap). HTTP/network errors
* still propagate.
*/
getExif: async (photoId) => {
try {
const body = await call('flickr.photos.getExif', { photo_id: photoId })
return body.photo?.exif ?? []
} catch (error) {
if (error instanceof FlickrApiError && error.status === null) return []
throw error
}
},
/**
* Largest available image from `flickr.photos.getSizes` — the documented
* fallback when the listing has no `url_o`. Sizes without usable
* dimensions are ignored (never a NaN winner); null when nothing usable is
* returned.
*/
getLargestSize: async (photoId) => {
const body = await call('flickr.photos.getSizes', { photo_id: photoId })
const sizes = Array.isArray(body.sizes?.size) ? body.sizes.size : []
let largest = null
let largestArea = -1
for (const size of sizes) {
if (size?.media !== 'photo' || typeof size.source !== 'string') continue
const area = Number(size.width) * Number(size.height)
if (!Number.isFinite(area) || area <= largestArea) continue
largest = size
largestArea = area
}
return largest ? { url: largest.source } : null
},
}
}