Single-container app with Postgres catalog, Typesense search, Authelia Remote-User admin, portfolio views, resume/PDF, media uploads, and ArtStation import tooling.
299 lines
8.8 KiB
TypeScript
299 lines
8.8 KiB
TypeScript
/**
|
|
* ArtStation project importer for jmartgraphix portfolio.
|
|
*
|
|
* Usage:
|
|
* npm run import:artstation -- --user jmartgraphix
|
|
* npm run import:artstation -- --user jmartgraphix --dry-run
|
|
* npm run import:artstation -- --url https://www.artstation.com/artwork/XXXX
|
|
*
|
|
* Fetches public ArtStation JSON endpoints, downloads images, and creates
|
|
* draft projects so you can edit/publish from the admin panel afterward.
|
|
*/
|
|
import "dotenv/config";
|
|
import { PrismaClient, Visibility } from "@prisma/client";
|
|
import slugify from "slugify";
|
|
import { downloadRemoteImage } from "../src/lib/media.js";
|
|
|
|
const prisma = new PrismaClient();
|
|
const USER_AGENT =
|
|
"Mozilla/5.0 (compatible; jmartgraphix-importer/1.0; +https://jmartgraphix.com)";
|
|
|
|
function arg(name: string, fallback?: string): string | undefined {
|
|
const idx = process.argv.indexOf(`--${name}`);
|
|
if (idx >= 0 && process.argv[idx + 1]) return process.argv[idx + 1];
|
|
return fallback;
|
|
}
|
|
|
|
function hasFlag(name: string): boolean {
|
|
return process.argv.includes(`--${name}`);
|
|
}
|
|
|
|
async function fetchJson<T>(url: string): Promise<T> {
|
|
const res = await fetch(url, {
|
|
headers: { "User-Agent": USER_AGENT, Accept: "application/json" },
|
|
signal: AbortSignal.timeout(30_000),
|
|
});
|
|
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
|
|
return res.json() as Promise<T>;
|
|
}
|
|
|
|
interface AsProjectListItem {
|
|
id: number;
|
|
title: string;
|
|
hash_id?: string;
|
|
permalink?: string;
|
|
cover?: { url?: string; small_square_url?: string };
|
|
mediums?: { name: string }[];
|
|
categories?: { name: string }[];
|
|
published_at?: string;
|
|
}
|
|
|
|
interface AsProjectDetail {
|
|
id: number;
|
|
title: string;
|
|
description?: string;
|
|
hash_id?: string;
|
|
permalink?: string;
|
|
published_at?: string;
|
|
tags?: string[];
|
|
mediums?: { name: string }[];
|
|
categories?: { name: string }[];
|
|
software_items?: { name: string }[];
|
|
assets?: {
|
|
id: number;
|
|
title?: string;
|
|
asset_type?: string;
|
|
image_url?: string;
|
|
has_image?: boolean;
|
|
width?: number;
|
|
height?: number;
|
|
}[];
|
|
cover?: { url?: string };
|
|
user?: { full_name?: string; username?: string };
|
|
}
|
|
|
|
// Map ArtStation medium/category names to our category slugs
|
|
const CATEGORY_MAP: Record<string, string> = {
|
|
"3d": "3d-modeling",
|
|
"3d modeling": "3d-modeling",
|
|
modeling: "3d-modeling",
|
|
characters: "3d-modeling",
|
|
environments: "3d-modeling",
|
|
animation: "3d-animation",
|
|
"3d animation": "3d-animation",
|
|
cad: "cad",
|
|
product: "cad",
|
|
industrial: "cad",
|
|
sculpting: "3d-sculpting",
|
|
"digital sculpting": "3d-sculpting",
|
|
ai: "ai-generated-content",
|
|
"ai art": "ai-generated-content",
|
|
video: "video",
|
|
cinematography: "video",
|
|
photography: "photography",
|
|
"graphic design": "graphic-design",
|
|
design: "graphic-design",
|
|
illustration: "illustration",
|
|
concept: "illustration",
|
|
};
|
|
|
|
async function resolveCategoryIds(names: string[]): Promise<string[]> {
|
|
const all = await prisma.category.findMany();
|
|
const bySlug = Object.fromEntries(all.map((c) => [c.slug, c.id]));
|
|
const ids = new Set<string>();
|
|
for (const n of names) {
|
|
const key = n.toLowerCase().trim();
|
|
const mapped = CATEGORY_MAP[key];
|
|
if (mapped && bySlug[mapped]) ids.add(bySlug[mapped]);
|
|
const direct = all.find((c) => c.name.toLowerCase() === key || c.slug === slugify(key, { lower: true, strict: true }));
|
|
if (direct) ids.add(direct.id);
|
|
}
|
|
return [...ids];
|
|
}
|
|
|
|
async function uniqueSlug(title: string): Promise<string> {
|
|
const base = slugify(title, { lower: true, strict: true }) || "artwork";
|
|
let slug = base;
|
|
let n = 2;
|
|
while (await prisma.project.findUnique({ where: { slug } })) {
|
|
slug = `${base}-${n++}`;
|
|
}
|
|
return slug;
|
|
}
|
|
|
|
async function importProject(hashOrUrl: string, dryRun: boolean): Promise<void> {
|
|
let hash = hashOrUrl;
|
|
const m = hashOrUrl.match(/artstation\.com\/artwork\/([A-Za-z0-9]+)/);
|
|
if (m) hash = m[1];
|
|
|
|
const detail = await fetchJson<AsProjectDetail>(
|
|
`https://www.artstation.com/projects/${hash}.json`
|
|
);
|
|
|
|
const sourceUrl =
|
|
detail.permalink || `https://www.artstation.com/artwork/${detail.hash_id || hash}`;
|
|
const existing = await prisma.project.findFirst({
|
|
where: { sourceUrl },
|
|
});
|
|
if (existing) {
|
|
console.log(` skip (exists): ${detail.title}`);
|
|
return;
|
|
}
|
|
|
|
const mediums = (detail.mediums || []).map((m) => m.name);
|
|
const cats = (detail.categories || []).map((c) => c.name);
|
|
const software = (detail.software_items || []).map((s) => s.name);
|
|
const tags = detail.tags || [];
|
|
const categoryIds = await resolveCategoryIds([...mediums, ...cats]);
|
|
|
|
console.log(` import: ${detail.title} (${(detail.assets || []).length} assets)`);
|
|
if (dryRun) return;
|
|
|
|
const slug = await uniqueSlug(detail.title);
|
|
const project = await prisma.project.create({
|
|
data: {
|
|
title: detail.title,
|
|
slug,
|
|
description: stripHtml(detail.description || ""),
|
|
shortDescription: stripHtml(detail.description || "").slice(0, 280) || null,
|
|
date: detail.published_at ? new Date(detail.published_at) : null,
|
|
software,
|
|
externalLinks: [{ label: "ArtStation", url: sourceUrl }],
|
|
featured: false,
|
|
displayPriority: 100,
|
|
visibility: Visibility.draft,
|
|
sourceUrl,
|
|
sourcePlatform: "artstation",
|
|
},
|
|
});
|
|
|
|
for (const cid of categoryIds) {
|
|
await prisma.projectCategory.create({
|
|
data: { projectId: project.id, categoryId: cid },
|
|
});
|
|
}
|
|
|
|
for (const t of tags) {
|
|
const tslug = slugify(t, { lower: true, strict: true });
|
|
if (!tslug) continue;
|
|
const tag = await prisma.tag.upsert({
|
|
where: { slug: tslug },
|
|
create: { name: t, slug: tslug },
|
|
update: {},
|
|
});
|
|
await prisma.projectTag.upsert({
|
|
where: { projectId_tagId: { projectId: project.id, tagId: tag.id } },
|
|
create: { projectId: project.id, tagId: tag.id },
|
|
update: {},
|
|
});
|
|
}
|
|
|
|
let sortOrder = 0;
|
|
let thumbnailId: string | null = null;
|
|
const assets = detail.assets || [];
|
|
for (const asset of assets) {
|
|
if (asset.asset_type && asset.asset_type !== "image" && !asset.has_image) continue;
|
|
const imageUrl = asset.image_url || detail.cover?.url;
|
|
if (!imageUrl) continue;
|
|
const saved = await downloadRemoteImage(imageUrl);
|
|
if (!saved) {
|
|
console.warn(` failed download: ${imageUrl}`);
|
|
continue;
|
|
}
|
|
const media = await prisma.media.create({
|
|
data: {
|
|
projectId: project.id,
|
|
type: "image",
|
|
url: saved.url,
|
|
thumbnailUrl: saved.thumbnailUrl,
|
|
filename: saved.filename,
|
|
mimeType: saved.mimeType,
|
|
width: saved.width || asset.width,
|
|
height: saved.height || asset.height,
|
|
sizeBytes: saved.sizeBytes,
|
|
alt: asset.title || detail.title,
|
|
sortOrder: sortOrder++,
|
|
},
|
|
});
|
|
if (!thumbnailId) thumbnailId = media.id;
|
|
}
|
|
|
|
if (thumbnailId) {
|
|
await prisma.project.update({
|
|
where: { id: project.id },
|
|
data: { thumbnailId },
|
|
});
|
|
}
|
|
|
|
console.log(` created draft project ${project.slug}`);
|
|
}
|
|
|
|
function stripHtml(html: string): string {
|
|
return html
|
|
.replace(/<br\s*\/?>/gi, "\n")
|
|
.replace(/<\/p>/gi, "\n\n")
|
|
.replace(/<[^>]+>/g, "")
|
|
.replace(/ /g, " ")
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, '"')
|
|
.replace(/\n{3,}/g, "\n\n")
|
|
.trim();
|
|
}
|
|
|
|
async function listUserProjects(username: string): Promise<AsProjectListItem[]> {
|
|
const all: AsProjectListItem[] = [];
|
|
let page = 1;
|
|
for (;;) {
|
|
// Public profile projects endpoint
|
|
const url = `https://www.artstation.com/users/${username}/projects.json?page=${page}`;
|
|
try {
|
|
const data = await fetchJson<{ data?: AsProjectListItem[] } | AsProjectListItem[]>(url);
|
|
const batch = Array.isArray(data) ? data : data.data || [];
|
|
if (batch.length === 0) break;
|
|
all.push(...batch);
|
|
if (batch.length < 50) break;
|
|
page++;
|
|
if (page > 20) break;
|
|
} catch (err) {
|
|
console.error(`Failed to list page ${page}:`, err);
|
|
break;
|
|
}
|
|
}
|
|
return all;
|
|
}
|
|
|
|
async function main() {
|
|
const dryRun = hasFlag("dry-run");
|
|
const user = arg("user", "jmartgraphix")!;
|
|
const singleUrl = arg("url");
|
|
|
|
console.log(`ArtStation import (user=${user}, dryRun=${dryRun})`);
|
|
|
|
if (singleUrl) {
|
|
await importProject(singleUrl, dryRun);
|
|
} else {
|
|
const list = await listUserProjects(user);
|
|
console.log(`Found ${list.length} projects`);
|
|
for (const item of list) {
|
|
const hash = item.hash_id || String(item.id);
|
|
try {
|
|
await importProject(hash, dryRun);
|
|
await new Promise((r) => setTimeout(r, 500)); // be polite
|
|
} catch (err) {
|
|
console.error(` error importing ${item.title}:`, err);
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log("Done. Review drafts in the admin panel and publish when ready.");
|
|
}
|
|
|
|
main()
|
|
.catch((e) => {
|
|
console.error(e);
|
|
process.exit(1);
|
|
})
|
|
.finally(() => prisma.$disconnect());
|