Spaces:
Sleeping
Sleeping
| import { createFileRoute } from "@tanstack/react-router"; | |
| import { zipSync, strToU8 } from "fflate"; | |
| import { z } from "zod"; | |
| const PostSchema = z.object({ | |
| id: z.string(), | |
| fileUrl: z.string().url(), | |
| ext: z.string().default("jpg"), | |
| previewUrl: z.string().optional(), | |
| sampleUrl: z.string().optional(), | |
| tags: z.array(z.string()).default([]), | |
| source: z.string().optional(), | |
| rating: z.string().optional(), | |
| score: z.number().optional(), | |
| fileSize: z.number().optional(), | |
| width: z.number().optional(), | |
| height: z.number().optional(), | |
| md5: z.string().optional(), | |
| uploader: z.union([z.string(), z.number()]).optional(), | |
| createdAt: z.string().optional(), | |
| rawJson: z.string().optional(), | |
| }); | |
| const BodySchema = z.object({ | |
| siteName: z.string().default("grabber"), | |
| posts: z.array(PostSchema).min(1).max(200), | |
| excludeTags: z.array(z.string()).default([]), | |
| zipName: z.string().optional(), | |
| }); | |
| function sanitize(s: string) { | |
| return s.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 60); | |
| } | |
| function humanSize(n?: number): string { | |
| if (n == null || !Number.isFinite(n)) return ""; | |
| if (n < 1024) return `${n} B`; | |
| if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; | |
| if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(2)} MB`; | |
| return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`; | |
| } | |
| function buildMarkdown(post: z.infer<typeof PostSchema>, excludeSet: Set<string>): string { | |
| const dims = post.width && post.height ? `${post.width} x ${post.height}` : ""; | |
| const rows: [string, string][] = [ | |
| ["Source", post.source ?? ""], | |
| ["Rating", post.rating ?? ""], | |
| ["Score", post.score != null ? String(post.score) : ""], | |
| ["Dimensions", dims], | |
| ["File size", humanSize(post.fileSize)], | |
| ["MD5", post.md5 ?? ""], | |
| ["Uploader", post.uploader != null ? String(post.uploader) : ""], | |
| ["Created at", post.createdAt ?? ""], | |
| ["Original file", post.fileUrl], | |
| ["Sample", post.sampleUrl ?? ""], | |
| ["Preview", post.previewUrl ?? ""], | |
| ["Ext", post.ext], | |
| ]; | |
| const meta = rows.map(([k, v]) => `- **${k}:** ${v}`).join("\n"); | |
| const filteredTags = post.tags.filter((t) => !excludeSet.has(t.toLowerCase())); | |
| const tags = filteredTags.length ? filteredTags.join(" ") : ""; | |
| let raw = "{}"; | |
| if (post.rawJson) { | |
| try { | |
| raw = JSON.stringify(JSON.parse(post.rawJson), null, 2); | |
| } catch { | |
| raw = post.rawJson; | |
| } | |
| } | |
| return `# Post ${post.id} | |
| ${meta} | |
| ## Tags | |
| ${tags} | |
| ## Raw metadata | |
| \`\`\`json | |
| ${raw} | |
| \`\`\` | |
| `; | |
| } | |
| export const Route = createFileRoute("/api/download-zip")({ | |
| server: { | |
| handlers: { | |
| POST: async ({ request }) => { | |
| let body: z.infer<typeof BodySchema>; | |
| try { | |
| body = BodySchema.parse(await request.json()); | |
| } catch (err) { | |
| return new Response( | |
| JSON.stringify({ error: err instanceof Error ? err.message : "Invalid body" }), | |
| { status: 400, headers: { "Content-Type": "application/json" } }, | |
| ); | |
| } | |
| const files: Record<string, Uint8Array> = {}; | |
| const errors: string[] = []; | |
| const siteName = sanitize(body.siteName); | |
| const excludeSet = new Set((body.excludeTags || []).map((t) => t.toLowerCase())); | |
| await Promise.all( | |
| body.posts.map(async (post) => { | |
| const idSafe = sanitize(post.id); | |
| const extSafe = sanitize(post.ext || "jpg"); | |
| const base = `${siteName}_${idSafe}`; | |
| // Filter out tags that user specified to be missing | |
| const filteredTags = post.tags.filter((t) => !excludeSet.has(t.toLowerCase())); | |
| // tags .txt — same basename as the image | |
| files[`${base}.txt`] = strToU8(filteredTags.join(" ")); | |
| // per-post markdown metadata | |
| files[`${idSafe}.md`] = strToU8(buildMarkdown(post, excludeSet)); | |
| // fetch the image itself | |
| try { | |
| const res = await fetch(post.fileUrl, { | |
| headers: { "User-Agent": "LovableGrabber/1.0" }, | |
| }); | |
| if (!res.ok) { | |
| errors.push(`${post.id}: HTTP ${res.status}`); | |
| return; | |
| } | |
| const buf = new Uint8Array(await res.arrayBuffer()); | |
| files[`${base}.${extSafe}`] = buf; | |
| } catch (err) { | |
| errors.push(`${post.id}: ${err instanceof Error ? err.message : "fetch failed"}`); | |
| } | |
| }), | |
| ); | |
| if (errors.length) { | |
| files["_errors.txt"] = strToU8(errors.join("\n")); | |
| } | |
| if (Object.keys(files).length === 0) { | |
| return new Response(JSON.stringify({ error: "No files downloaded" }), { | |
| status: 502, | |
| headers: { "Content-Type": "application/json" }, | |
| }); | |
| } | |
| const zipped = zipSync(files, { level: 0 }); | |
| let finalZipName = ""; | |
| if (body.zipName && body.zipName.trim()) { | |
| finalZipName = sanitize(body.zipName.trim()); | |
| if (!finalZipName.endsWith(".zip")) { | |
| finalZipName += ".zip"; | |
| } | |
| } else { | |
| const ts = new Date().toISOString().replace(/[:.]/g, "-"); | |
| finalZipName = `grabber-${ts}.zip`; | |
| } | |
| return new Response(zipped, { | |
| status: 200, | |
| headers: { | |
| "Content-Type": "application/zip", | |
| "Content-Disposition": `attachment; filename="${finalZipName}"`, | |
| "Cache-Control": "no-store", | |
| }, | |
| }); | |
| }, | |
| }, | |
| }, | |
| }); | |