mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
Adiciona a CLI pnpm archive:integrity: varredura read-only de todas as archivePhoto (download + decode pelo mesmo caminho da rota pública e do índice facial; sai 1 enquanto houver mídia quebrada approved) e --apply que re-ingere do Flickr pelo flickrId, substitui o objeto na mesma chave via payload.update filePath (sem tocar curadoria/status) e rebaixa a draft as irrecuperáveis (removed intocado). Dono da leitura ganha inspectPrivateMediaObject e isPrivateMediaMissingError (S3 NoSuchKey/404 + ENOENT local; checksum nunca é missing); archivePhotoIngest ganha repairArchivePhotoObject e withdrawArchivePhotoFromPublic; decisão do reparo em scripts/lib/archiveIntegrityRepair.mjs com deps injetadas e puro de CLI/recibo em scripts/lib/archiveIntegrityPlan.mjs. Sem migration, sem UI. Guardas: ARCHIVE_INTEGRITY_CONFIRM + TEQO_ENV exato + 4 S3_* (em qualquer modo no alvo não-local) + FLICKR_API_KEY. Runbook C246, changelog e unit/int/e2e afetado.
217 lines
7.5 KiB
JavaScript
217 lines
7.5 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
|
|
|
|
export 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
|
|
}
|
|
|
|
/**
|
|
* C246 — a terminal refusal that says the SOURCE is gone: the Flickr
|
|
* "photo not found" `stat=fail` codes (1/2) or an HTTP 404. Auth/service
|
|
* failures (`stat=fail` 100/105, HTTP 401/403) and transient errors (429/5xx,
|
|
* network) are NOT a source verdict — the integrity repair keeps them as
|
|
* retryable instead of withdrawing photos because of a bad API key or an
|
|
* outage.
|
|
*/
|
|
const FLICKR_SOURCE_GONE_CODES = new Set([1, 2])
|
|
|
|
export const isFlickrSourceGone = (error) =>
|
|
error instanceof FlickrApiError &&
|
|
(error.status === 404 ||
|
|
(error.status === null && FLICKR_SOURCE_GONE_CODES.has(Number(error.code))))
|
|
|
|
/**
|
|
* @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
|
|
},
|
|
}
|
|
}
|