Skip to content

Instantly share code, notes, and snippets.

@mikeymckay
Last active August 24, 2025 21:32
Show Gist options
  • Save mikeymckay/9bd6a28c0b5bc21898a95bd2efaa4587 to your computer and use it in GitHub Desktop.
Save mikeymckay/9bd6a28c0b5bc21898a95bd2efaa4587 to your computer and use it in GitHub Desktop.
// Define a rectangle that fully contains Kenya (with a small buffer).
const KENYA_BBOX = {
minLat: -5.0, // south
maxLat: 5.5, // north
minLon: 33.5, // west
maxLon: 42.5 // east
};
const TARGET_ALBUM_NAME = 'Found Kenya Pics';
// Throttling and pagination controls
const MAX_REQUESTS_PER_SECOND = 100; // throttle for getItemInfoExt calls
const CHUNK_SLEEP_MS = 500; // delay between chunks within a page
const SAFETY_MAX_RESULTS = 500000; // safety bound on total fetched items
// LocalStorage keys
const LS_KEYS = {
resumeMode: 'kenya_resume_mode', // 'processed' | 'added'
flushSize: 'kenya_flush_size', // number as string
lastProcessedTs: 'kenya_resume_ts_processed', // number as string
lastAddedTs: 'kenya_resume_ts_added', // number as string
albumMediaKey: 'kenya_album_media_key' // album id for the target album
};
// Helpers: localStorage get/set
function lsGetNumber(key, fallback = null) {
const v = localStorage.getItem(key);
if (v == null) return fallback;
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
}
function lsGetString(key, fallback = null) {
const v = localStorage.getItem(key);
return v == null ? fallback : v;
}
function lsSet(key, val) {
if (val == null) return;
localStorage.setItem(key, String(val));
}
// Settings that can be persisted
function getFlushSize() {
const persisted = lsGetNumber(LS_KEYS.flushSize, null);
return Number.isFinite(persisted) && persisted > 0 ? persisted : 100; // default 100
}
function getResumeMode() {
const mode = lsGetString(LS_KEYS.resumeMode, 'processed'); // default 'processed'
return mode === 'added' ? 'added' : 'processed';
}
function getResumeTimestamp() {
const mode = getResumeMode();
if (mode === 'added') {
const t = lsGetNumber(LS_KEYS.lastAddedTs, null);
if (t != null) return t;
}
// fallback or default
return lsGetNumber(LS_KEYS.lastProcessedTs, null);
}
function setLastProcessedTs(ts) {
lsSet(LS_KEYS.lastProcessedTs, ts);
}
function setLastAddedTs(ts) {
lsSet(LS_KEYS.lastAddedTs, ts);
}
// Coordinate helpers
// Updated extractLatLon: handle E7/E6/E5 scaling and bbox-aware swap
function extractLatLon(coords) {
if (!coords) return null;
const scaleToDegrees = (v) => {
if (!Number.isFinite(v)) return null;
if (Math.abs(v) <= 180) return v; // already degrees
const tryScales = [1e7, 1e6, 1e5];
for (const s of tryScales) {
const candidate = v / s;
if (Math.abs(candidate) <= 180) return candidate;
}
return v;
};
const inLatLonRange = (lat, lon) =>
Number.isFinite(lat) && Number.isFinite(lon) &&
Math.abs(lat) <= 90 && Math.abs(lon) <= 180;
const chooseWithHeuristic = (lat, lon) => {
const candidate = inLatLonRange(lat, lon) ? { lat, lon } : null;
const swapped = inLatLonRange(lon, lat) ? { lat: lon, lon: lat } : null;
// Prefer the one inside the Kenya bbox, if any
if (candidate && isInBBox(candidate, KENYA_BBOX)) return candidate;
if (swapped && isInBBox(swapped, KENYA_BBOX)) return swapped;
// Otherwise prefer any in-range candidate; keep original ordering if both valid
if (candidate) return candidate;
if (swapped) return swapped;
return null;
};
// 1) Array form: [lon, lat]
if (Array.isArray(coords) && coords.length >= 2) {
const lon = scaleToDegrees(coords[0]);
const lat = scaleToDegrees(coords[1]);
return chooseWithHeuristic(lat, lon);
}
// 2) Object form: { latitude, longitude } or { lat, lon } or { lat, lng }
const latRaw = coords.latitude ?? coords.lat;
const lonRaw = coords.longitude ?? coords.lon ?? coords.lng;
const lat = scaleToDegrees(latRaw);
const lon = scaleToDegrees(lonRaw);
return chooseWithHeuristic(lat, lon);
}
function isInBBox({ lat, lon }, bbox) {
return (
lat >= bbox.minLat &&
lat <= bbox.maxLat &&
lon >= bbox.minLon &&
lon <= bbox.maxLon
);
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// Album helpers using gptkApiUtils
async function findAlbumByMediaKey(mediaKey) {
const albums = (await gptkApi.getAlbums()).items;
return albums.find(a => a.mediaKey === mediaKey) || null;
}
async function findAlbumByTitle(title) {
const albums = (await gptkApi.getAlbums()).items;
// If multiple have the same title, just pick the first. You can refine this if needed.
return albums.find(a => a.title === title) || null;
}
async function ensureTargetAlbumAndExistingSet() {
// Try by persisted mediaKey first
let album = null;
const persistedKey = lsGetString(LS_KEYS.albumMediaKey, null);
if (persistedKey) {
album = await findAlbumByMediaKey(persistedKey);
}
// If not found, try by title
if (!album) {
album = await findAlbumByTitle(TARGET_ALBUM_NAME);
}
// If still not found, create it (with an empty add)
if (!album) {
console.log(`Album "${TARGET_ALBUM_NAME}" not found. Creating...`);
await gptkApiUtils.addToNewAlbum([], TARGET_ALBUM_NAME);
// Re-fetch by title
album = await findAlbumByTitle(TARGET_ALBUM_NAME);
if (!album) {
throw new Error(`Failed to create or locate album "${TARGET_ALBUM_NAME}"`);
}
}
// Persist album mediaKey for future runs
lsSet(LS_KEYS.albumMediaKey, album.mediaKey);
// Load existing items to dedupe adds across runs
const existingItems = await gptkApiUtils.getAllMediaInAlbum(album.mediaKey);
const existingMediaKeySet = new Set((existingItems || []).map(it => it.mediaKey));
console.log(`Using album "${album.title}" (mediaKey=${album.mediaKey}) with ${existingMediaKeySet.size} existing items`);
return { album, existingMediaKeySet };
}
(async () => {
const FLUSH_BATCH_SIZE = getFlushSize();
const RESUME_MODE = getResumeMode();
const filteredBuffer = []; // pending Kenya items not yet flushed
let { album, existingMediaKeySet } = await ensureTargetAlbumAndExistingSet();
// Dedupe within this run
const seenThisRun = new Set();
let nextPageId = null;
let totalSeen = 0;
// Determine resume timestamp
let resumeTs = getResumeTimestamp();
console.log(
resumeTs != null
? `Resuming from ${RESUME_MODE} timestamp: ${resumeTs} (${new Date(resumeTs).toISOString()})`
: 'Starting from the beginning (no resume timestamp found)'
);
async function flushToAlbum(force = false) {
// Build a batch to add
if (filteredBuffer.length >= FLUSH_BATCH_SIZE || (force && filteredBuffer.length > 0)) {
const countToAdd = force ? filteredBuffer.length : FLUSH_BATCH_SIZE;
const candidate = filteredBuffer.splice(0, countToAdd);
// Dedupe against existing album and this run
const finalBatch = [];
for (const item of candidate) {
if (!item || !item.mediaKey) continue;
if (existingMediaKeySet.has(item.mediaKey)) continue;
if (seenThisRun.has(item.mediaKey)) continue;
seenThisRun.add(item.mediaKey);
finalBatch.push(item);
}
if (finalBatch.length === 0) {
console.log(`No new unique items to add in this batch (requested ${countToAdd}).`);
return;
}
// Add to existing album to avoid creating duplicates
await gptkApiUtils.addToExistingAlbum(finalBatch, album, /* preserveOrder */ false);
// Update existing set for future dedupe
for (const it of finalBatch) {
existingMediaKeySet.add(it.mediaKey);
}
// Persist and print the last added timestamp
const lastAddedTs = finalBatch[finalBatch.length - 1].timestamp;
setLastAddedTs(lastAddedTs);
console.log(`Added ${finalBatch.length} items to album "${album.title}". Last added timestamp: ${lastAddedTs} (${new Date(lastAddedTs).toISOString()})`);
}
}
gptkCore.isProcessRunning = true;
try {
do {
const page = await gptkApi.getItemsByTakenDate(
/* timestamp = */ resumeTs ?? null,
/* source = */ null,
/* pageId = */ nextPageId
);
const items = page?.items || [];
totalSeen += items.length;
if (items.length > 0) {
const lastOnPageTs = items[items.length - 1].timestamp;
console.log(`Fetched ${items.length} items; total seen: ${totalSeen}; last photo ts on page: ${lastOnPageTs} (${new Date(lastOnPageTs).toISOString()})`);
} else {
console.log('Fetched page with 0 items.');
}
// Process items in throttled batches
for (let i = 0; i < items.length; i += MAX_REQUESTS_PER_SECOND) {
if (!gptkCore.isProcessRunning) break;
const chunk = items.slice(i, i + MAX_REQUESTS_PER_SECOND);
const promises = chunk.map(async (item) => {
try {
const ext = await gptkApi.getItemInfoExt(item.mediaKey);
const coords = extractLatLon(ext?.geoLocation?.coordinates);
if (!coords) return null;
if (!isInBBox(coords, KENYA_BBOX)) return null;
return item;
} catch (err) {
console.error(`Error processing item ${item.mediaKey}:`, err);
return null;
}
});
const resolved = await Promise.all(promises);
for (const it of resolved) {
if (it) {
filteredBuffer.push(it);
await flushToAlbum(false); // flush as soon as we reach the configured size
}
}
if (i + MAX_REQUESTS_PER_SECOND < items.length) {
await sleep(CHUNK_SLEEP_MS);
}
}
// After processing this page, persist last processed timestamp
if (items.length > 0) {
const lastProcessedTs = items[items.length - 1].timestamp;
setLastProcessedTs(lastProcessedTs);
// For next page calls, keep using the same resumeTs but advance via nextPageId.
// If we restart mid-run, we will resume from lastProcessedTs.
}
nextPageId = page?.nextPageId;
if (totalSeen >= SAFETY_MAX_RESULTS) {
console.warn(`Stopping due to SAFETY_MAX_RESULTS=${SAFETY_MAX_RESULTS}`);
break;
}
} while (nextPageId);
// Final flush of any remaining items
await flushToAlbum(true);
// Summary logs
const persistedProcessed = lsGetNumber(LS_KEYS.lastProcessedTs, null);
const persistedAdded = lsGetNumber(LS_KEYS.lastAddedTs, null);
console.log(
persistedProcessed != null
? `Saved last processed timestamp: ${persistedProcessed} (${new Date(persistedProcessed).toISOString()})`
: 'No processed timestamp saved.'
);
console.log(
persistedAdded != null
? `Saved last added timestamp: ${persistedAdded} (${new Date(persistedAdded).toISOString()})`
: 'No added timestamp saved (no items were added).'
);
} catch (e) {
console.error('Fatal error:', e);
} finally {
gptkCore.isProcessRunning = false;
}
console.log('DONE');
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment