- Add branded NotFound catch-all (no more blank unknown routes) - Portfolio cards: shimmer skeleton, eager first-row thumbs, real alts - Hide empty categories; rename CAD view slug to avoid collision - Resume summary preserves paragraphs/bullets - Home/About/footer hire contact mailto CTA - Sticky header scroll-padding; chip aria-labels - Security headers; Flickr import strip View On Black + junk tags - Content cleanup script for grammar, residue, tags, featured
168 lines
5.9 KiB
TypeScript
168 lines
5.9 KiB
TypeScript
/**
|
||
* One-shot content hygiene from dogfood QA (grammar, Flickr residue, junk tags, etc.)
|
||
*
|
||
* npx tsx scripts/content-cleanup.ts
|
||
*/
|
||
import { prisma } from "../src/lib/prisma.js";
|
||
import { reindexAllProjects } from "../src/lib/typesense.js";
|
||
|
||
function cleanDescription(text: string): string {
|
||
let t = text || "";
|
||
t = t.replace(/\bcompanies sensors\b/gi, "company's sensors");
|
||
t = t.replace(/\b3d model\b/g, "3D model");
|
||
t = t.replace(/\b3d models\b/g, "3D models");
|
||
t = t.replace(/\bgaurded\b/gi, "guarded");
|
||
t = t.replace(/\bView\s+On\s+Black\b/gi, "");
|
||
t = t.replace(/[ \t]+\n/g, "\n");
|
||
t = t.replace(/\n{3,}/g, "\n\n");
|
||
return t.trim();
|
||
}
|
||
|
||
function makeShort(description: string, existing: string | null): string | null {
|
||
const cleaned = cleanDescription(existing || description || "");
|
||
if (!cleaned) return null;
|
||
// Prefer first sentence if reasonably short
|
||
const sentence = cleaned.match(/^[^.!?]+[.!?]/)?.[0];
|
||
if (sentence && sentence.length >= 40 && sentence.length <= 180) return sentence.trim();
|
||
if (cleaned.length <= 180) return cleaned;
|
||
return cleaned.slice(0, 177).replace(/\s+\S*$/, "") + "…";
|
||
}
|
||
|
||
function isJunkTag(name: string): boolean {
|
||
const t = name.trim();
|
||
if (!t) return true;
|
||
if (/^\d+$/.test(t)) return true;
|
||
if (/^\d+mm$/i.test(t)) return true;
|
||
if (/nikon|canon|nikkor|sony|sigma|tamron|exif|\bd90\b|\bd700|\bd800|e5000/i.test(t))
|
||
return true;
|
||
if (/^after$/i.test(t) || /^effects$/i.test(t)) return true;
|
||
if (/viewonblack|view.?on.?black/i.test(t)) return true;
|
||
if (t.length >= 14 && !/\s/.test(t) && /[0-9]/.test(t)) return true;
|
||
if (/369rollinghill|frenchcreekstatepark/i.test(t)) return true;
|
||
return false;
|
||
}
|
||
|
||
const DESCRIPTION_OVERRIDES: Record<string, string> = {
|
||
ankle:
|
||
"3D animated anatomical study of a human ankle, modeled and rendered for motion work and produced with Modo and After Effects.",
|
||
"agi-engineering-v-process-diagram":
|
||
"Motion graphics process diagram explaining AGI’s Engineering V methodology for industrial automation projects—used in technical and marketing communications.",
|
||
"wired-for-sound-animated-tv-spot":
|
||
"Animated television spot for Wired for Sound, combining motion graphics and 2D animation for broadcast.",
|
||
"aikido-kokikai-federation-akf-logo":
|
||
"Video identity sequence for the Aikido Kokikai Federation (AKF) logo—brand motion for federation communications.",
|
||
};
|
||
|
||
async function main() {
|
||
console.log("Content cleanup starting…");
|
||
|
||
const projects = await prisma.project.findMany({
|
||
select: {
|
||
id: true,
|
||
slug: true,
|
||
title: true,
|
||
description: true,
|
||
shortDescription: true,
|
||
},
|
||
});
|
||
|
||
let textFixed = 0;
|
||
for (const p of projects) {
|
||
let description = cleanDescription(p.description || "");
|
||
let shortDescription = p.shortDescription
|
||
? cleanDescription(p.shortDescription)
|
||
: null;
|
||
|
||
if (DESCRIPTION_OVERRIDES[p.slug]) {
|
||
description = DESCRIPTION_OVERRIDES[p.slug];
|
||
}
|
||
|
||
// Eagle: shortDescription was truncated mid-word
|
||
if (p.slug === "eagle-in-flight" && shortDescription && /tru$/.test(shortDescription)) {
|
||
shortDescription = makeShort(description, null);
|
||
}
|
||
|
||
// When short is empty, a full dump, or identical to full desc — derive a blurb
|
||
if (
|
||
!shortDescription ||
|
||
shortDescription === description ||
|
||
(description && shortDescription.length > 200 && shortDescription.startsWith(description.slice(0, 40)))
|
||
) {
|
||
shortDescription = makeShort(description, null);
|
||
}
|
||
|
||
if (
|
||
description !== (p.description || "") ||
|
||
shortDescription !== (p.shortDescription || null)
|
||
) {
|
||
await prisma.project.update({
|
||
where: { id: p.id },
|
||
data: { description, shortDescription },
|
||
});
|
||
textFixed++;
|
||
console.log(` text: ${p.slug}`);
|
||
}
|
||
}
|
||
console.log(`Updated text on ${textFixed} projects`);
|
||
|
||
// Rename CAD view to avoid /portfolio/cad collision with empty CAD category
|
||
const cadView = await prisma.portfolioView.findFirst({ where: { slug: "cad" } });
|
||
if (cadView) {
|
||
await prisma.portfolioView.update({
|
||
where: { id: cadView.id },
|
||
data: { slug: "technical-cad", name: "CAD & Technical" },
|
||
});
|
||
console.log("Renamed portfolio view cad → technical-cad");
|
||
}
|
||
|
||
// Feature a few strong recent published projects if none featured
|
||
const featuredCount = await prisma.project.count({
|
||
where: { featured: true, visibility: "published" },
|
||
});
|
||
if (featuredCount === 0) {
|
||
const candidates = await prisma.project.findMany({
|
||
where: { visibility: "published" },
|
||
orderBy: [{ date: { sort: "desc", nulls: "last" } }, { displayPriority: "asc" }],
|
||
take: 6,
|
||
select: { id: true, title: true, slug: true },
|
||
});
|
||
for (const c of candidates) {
|
||
await prisma.project.update({ where: { id: c.id }, data: { featured: true } });
|
||
console.log(` featured: ${c.slug}`);
|
||
}
|
||
console.log(`Featured ${candidates.length} projects`);
|
||
} else {
|
||
console.log(`Leaving ${featuredCount} existing featured projects`);
|
||
}
|
||
|
||
// Junk tags
|
||
const tags = await prisma.tag.findMany({ select: { id: true, name: true, slug: true } });
|
||
let tagsRemoved = 0;
|
||
for (const tag of tags) {
|
||
if (!isJunkTag(tag.name) && !isJunkTag(tag.slug)) continue;
|
||
await prisma.projectTag.deleteMany({ where: { tagId: tag.id } });
|
||
await prisma.tag.delete({ where: { id: tag.id } });
|
||
tagsRemoved++;
|
||
console.log(` removed tag: ${tag.name}`);
|
||
}
|
||
console.log(`Removed ${tagsRemoved} junk tags`);
|
||
|
||
// Merge After Effects if both tags somehow remain (handled by delete)
|
||
|
||
try {
|
||
const n = await reindexAllProjects();
|
||
console.log(`Typesense reindexed (${n} projects)`);
|
||
} catch (err) {
|
||
console.warn("Typesense reindex failed:", err);
|
||
}
|
||
|
||
console.log("Content cleanup done.");
|
||
await prisma.$disconnect();
|
||
}
|
||
|
||
main().catch(async (err) => {
|
||
console.error(err);
|
||
await prisma.$disconnect();
|
||
process.exit(1);
|
||
});
|