first commit
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
import { z } from "zod";
|
||||
|
||||
const SOURCE = "unsplash-wallpapers";
|
||||
const DEFAULT_PAGE = 1;
|
||||
const DEFAULT_PER_PAGE = 20;
|
||||
const UNSPLASH_WALLPAPERS_URL =
|
||||
"https://api.unsplash.com/topics/wallpapers/photos";
|
||||
|
||||
const env = loadEnvLocal();
|
||||
|
||||
const photoSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
slug: z.string().nullish(),
|
||||
description: z.string().nullish(),
|
||||
alt_description: z.string().nullish(),
|
||||
width: z.number().int().nullish(),
|
||||
height: z.number().int().nullish(),
|
||||
color: z.string().nullish(),
|
||||
blur_hash: z.string().nullish(),
|
||||
likes: z.number().int().nullish(),
|
||||
created_at: z.string().nullish(),
|
||||
updated_at: z.string().nullish(),
|
||||
urls: z.record(z.string(), z.unknown()).nullish(),
|
||||
links: z.record(z.string(), z.unknown()).nullish(),
|
||||
user: z.record(z.string(), z.unknown()).nullish(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
function loadEnvLocal() {
|
||||
const envMap = { ...process.env };
|
||||
|
||||
try {
|
||||
const file = readFileSync(".env.local", "utf8");
|
||||
|
||||
for (const line of file.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (!trimmed || trimmed.startsWith("#") || !trimmed.includes("=")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [key, ...valueParts] = trimmed.split("=");
|
||||
const value = valueParts.join("=").trim();
|
||||
envMap[key.trim()] = value.replace(/^["']|["']$/g, "");
|
||||
}
|
||||
} catch {
|
||||
// Environment variables can also be provided by the process manager.
|
||||
}
|
||||
|
||||
return envMap;
|
||||
}
|
||||
|
||||
function requireEnv(key) {
|
||||
const value = env[key];
|
||||
|
||||
if (!value) {
|
||||
throw new Error(`${key} is not configured.`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeDate(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||
}
|
||||
|
||||
function getColorFamily(color) {
|
||||
if (!color) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = color.trim().replace(/^#/, "");
|
||||
|
||||
if (!/^[0-9a-f]{6}$/i.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const r = parseInt(normalized.slice(0, 2), 16);
|
||||
const g = parseInt(normalized.slice(2, 4), 16);
|
||||
const b = parseInt(normalized.slice(4, 6), 16);
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const lightness = (max + min) / 510;
|
||||
|
||||
if (lightness > 0.78) {
|
||||
return "light";
|
||||
}
|
||||
if (lightness < 0.2) {
|
||||
return "black";
|
||||
}
|
||||
|
||||
const delta = max - min;
|
||||
|
||||
if (delta < 24) {
|
||||
return lightness > 0.52 ? "light" : "black";
|
||||
}
|
||||
|
||||
let hue = 0;
|
||||
|
||||
if (max === r) {
|
||||
hue = ((g - b) / delta) % 6;
|
||||
} else if (max === g) {
|
||||
hue = (b - r) / delta + 2;
|
||||
} else {
|
||||
hue = (r - g) / delta + 4;
|
||||
}
|
||||
|
||||
hue = Math.round(hue * 60);
|
||||
|
||||
if (hue < 0) {
|
||||
hue += 360;
|
||||
}
|
||||
|
||||
if (hue < 18 || hue >= 345) {
|
||||
return "red";
|
||||
}
|
||||
if (hue < 45) {
|
||||
return "orange";
|
||||
}
|
||||
if (hue < 70) {
|
||||
return "yellow";
|
||||
}
|
||||
if (hue < 170) {
|
||||
return "green";
|
||||
}
|
||||
if (hue < 255) {
|
||||
return "blue";
|
||||
}
|
||||
if (hue < 345) {
|
||||
return "purple";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function mapPhotoToRow(photo) {
|
||||
return {
|
||||
unsplash_id: photo.id,
|
||||
slug: photo.slug ?? null,
|
||||
description: photo.description ?? null,
|
||||
alt_description: photo.alt_description ?? null,
|
||||
width: photo.width ?? null,
|
||||
height: photo.height ?? null,
|
||||
color: photo.color ?? null,
|
||||
color_family: getColorFamily(photo.color),
|
||||
blur_hash: photo.blur_hash ?? null,
|
||||
likes: photo.likes ?? null,
|
||||
photo_created_at: normalizeDate(photo.created_at),
|
||||
photo_updated_at: normalizeDate(photo.updated_at),
|
||||
urls: photo.urls ?? {},
|
||||
links: photo.links ?? {},
|
||||
user_info: photo.user ?? {},
|
||||
raw: photo,
|
||||
source: SOURCE,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchPhotos({ page, perPage }) {
|
||||
const accessKey = requireEnv("UNSPLASH_ACCESS_KEY");
|
||||
const url = new URL(UNSPLASH_WALLPAPERS_URL);
|
||||
url.searchParams.set("page", String(page));
|
||||
url.searchParams.set("per_page", String(perPage));
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
authorization: `Client-ID ${accessKey}`,
|
||||
"user-agent": "WalloraGallerySync/1.0",
|
||||
},
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unsplash API request failed with ${response.status}.`);
|
||||
}
|
||||
|
||||
if (!contentType.includes("application/json")) {
|
||||
throw new Error(
|
||||
`Unsplash returned non-JSON content: ${contentType || "unknown"}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = z.array(photoSchema).safeParse(await response.json());
|
||||
|
||||
if (!parsed.success) {
|
||||
throw new Error("Unsplash response did not match the expected array.");
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const supabase = createClient(
|
||||
requireEnv("NEXT_PUBLIC_SUPABASE_URL"),
|
||||
requireEnv("SUPABASE_SERVICE_ROLE_KEY"),
|
||||
{
|
||||
auth: {
|
||||
persistSession: false,
|
||||
autoRefreshToken: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const { data: state, error: stateError } = await supabase
|
||||
.from("wg_gallery_sync_state")
|
||||
.select("source,next_page,per_page")
|
||||
.eq("source", SOURCE)
|
||||
.maybeSingle();
|
||||
|
||||
if (stateError) {
|
||||
throw stateError;
|
||||
}
|
||||
|
||||
const page = state?.next_page ?? DEFAULT_PAGE;
|
||||
const perPage = state?.per_page ?? DEFAULT_PER_PAGE;
|
||||
|
||||
try {
|
||||
const photos = await fetchPhotos({ page, perPage });
|
||||
const rows = photos.map(mapPhotoToRow);
|
||||
let insertedCount = 0;
|
||||
|
||||
if (rows.length > 0) {
|
||||
const { data, error } = await supabase
|
||||
.from("wg_gallery_photos")
|
||||
.upsert(rows, {
|
||||
onConflict: "unsplash_id",
|
||||
ignoreDuplicates: true,
|
||||
})
|
||||
.select("unsplash_id");
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
insertedCount = data?.length ?? 0;
|
||||
}
|
||||
|
||||
const nextPage = rows.length === 0 ? DEFAULT_PAGE : page + 1;
|
||||
const { error: updateError } = await supabase
|
||||
.from("wg_gallery_sync_state")
|
||||
.upsert(
|
||||
{
|
||||
source: SOURCE,
|
||||
next_page: nextPage,
|
||||
per_page: perPage,
|
||||
last_synced_at: new Date().toISOString(),
|
||||
last_inserted_count: insertedCount,
|
||||
last_seen_count: photos.length,
|
||||
last_error: null,
|
||||
},
|
||||
{ onConflict: "source" },
|
||||
);
|
||||
|
||||
if (updateError) {
|
||||
throw updateError;
|
||||
}
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
source: SOURCE,
|
||||
page,
|
||||
nextPage,
|
||||
seenCount: photos.length,
|
||||
insertedCount,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Unknown sync error.";
|
||||
|
||||
await supabase.from("wg_gallery_sync_state").upsert(
|
||||
{
|
||||
source: SOURCE,
|
||||
next_page: page,
|
||||
per_page: perPage,
|
||||
last_synced_at: new Date().toISOString(),
|
||||
last_inserted_count: 0,
|
||||
last_seen_count: 0,
|
||||
last_error: message,
|
||||
},
|
||||
{ onConflict: "source" },
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in New Issue
Block a user