first commit

This commit is contained in:
luoyangwei
2026-06-06 00:49:07 +08:00
commit 2b860d201e
134 changed files with 22773 additions and 0 deletions
+303
View File
@@ -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;
});