Docs/Skills and code/skill meme-fetcher
.claude/skills/meme-fetcher/scripts/library.cjs
What the file says about itself
Meme library helper: search, duplicate check, add (with image download), list, validate. No dependencies. Node 18+ (uses global fetch).
#!/usr/bin/env node
// Meme library helper: search, duplicate check, add (with image download), list, validate.
// No dependencies. Node 18+ (uses global fetch).
//
// node library.js search "<situation words>" rank existing entries for a situation
// node library.js exists --name "X" [--alias "Y"] [--url U] [--image-url U]
// node library.js add entry.json validate, dedupe, download, append
// node library.js list
// node library.js validate
//
// Exit codes: 0 ok, 1 error or invalid input, 2 duplicate (nothing written).
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const LIB = path.resolve(__dirname, '..', '..', '..', '..', 'research', 'meme-library');
const CAT = path.join(LIB, 'catalogue.json');
const MEDIA = path.join(LIB, 'media');
const MAX_BYTES = 5 * 1024 * 1024;
const TIERS = ['A', 'B', 'C'];
const REQUIRED = ['name', 'tier', 'format', 'origin', 'owner', 'feeling', 'situations', 'use_when', 'dont_use_when', 'sources'];
const STOP = new Set(['a', 'an', 'the', 'of', 'to', 'in', 'on', 'for', 'and', 'or', 'is', 'are', 'when', 'with', 'by', 'that', 'it', 'its', 'as', 'at', 'be', 'people', 'who']);
function die(msg, code) { console.error(msg); process.exit(code || 1); }
function load() { return JSON.parse(fs.readFileSync(CAT, 'utf8')); }
function save(cat) { fs.writeFileSync(CAT, JSON.stringify(cat, null, 2) + '\n'); }
function norm(s) { return String(s || '').toLowerCase().replace(/^the\s+/, '').replace(/[^a-z0-9]+/g, ''); }
function normUrl(u) {
try { const x = new URL(u); return (x.hostname.replace(/^www\./, '') + x.pathname).replace(/\/+$/, '').toLowerCase(); }
catch (e) { return ''; }
}
function slug(s) { return String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); }
function tokens(s) {
return String(s || '').toLowerCase().split(/[^a-z0-9]+/).filter(t => t && !STOP.has(t)).map(t => t.replace(/s$/, ''));
}
function sha(buf) { return crypto.createHash('sha256').update(buf).digest('hex'); }
function names(m) { return [m.id, m.name].concat(m.aliases || []).map(norm).filter(Boolean); }
// Returns {why, entry} for the first duplicate found, else null.
function findDuplicate(cat, cand, imageSha) {
const candNames = [cand.id, cand.name].concat(cand.aliases || []).map(norm).filter(Boolean);
const candUrls = (cand.sources || []).concat(cand.image_url ? [cand.image_url] : []).map(normUrl).filter(Boolean);
for (const m of cat.memes) {
const have = names(m);
const hit = candNames.find(n => have.includes(n));
if (hit) return { why: 'same name or alias', entry: m };
const haveUrls = (m.sources || []).concat(m.image_url ? [m.image_url] : []).map(normUrl).filter(Boolean);
const u = candUrls.find(x => haveUrls.includes(x));
if (u) return { why: 'same source URL (' + u + ')', entry: m };
if (imageSha && m.file) {
const p = path.join(LIB, m.file);
if (fs.existsSync(p) && sha(fs.readFileSync(p)) === imageSha) return { why: 'identical image file', entry: m };
}
}
return null;
}
function args(argv) {
const out = { _: [], alias: [] };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a.startsWith('--')) {
const k = a.slice(2); const v = argv[++i];
if (k === 'alias') out.alias.push(v); else out[k] = v;
} else out._.push(a);
}
return out;
}
function cmdSearch(q) {
const cat = load();
const qt = tokens(q);
if (!qt.length) die('give some situation words');
const rows = cat.memes.map(m => {
const tag = tokens((m.situations || []).join(' '));
const nm = tokens(m.name + ' ' + (m.aliases || []).join(' '));
const txt = tokens([m.use_when, m.feeling].join(' '));
let score = 0;
for (const t of qt) { if (tag.includes(t)) score += 3; if (nm.includes(t)) score += 2; if (txt.includes(t)) score += 1; }
return { m, score };
}).filter(r => r.score > 0).sort((a, b) => b.score - a.score);
if (!rows.length) { console.log('NO MATCH in library for: ' + q); return; }
for (const r of rows) {
const m = r.m;
console.log(r.score + '\t' + m.id + '\t[' + m.tier + ' ' + m.format + (m.fetched ? ' fetched' : ' no-file') + ' ' + m.clearance + ']\t' + m.use_when.slice(0, 110));
}
}
function cmdExists(a) {
const cat = load();
const cand = { name: a.name || '', aliases: a.alias, sources: a.url ? [a.url] : [], image_url: a['image-url'] };
const d = findDuplicate(cat, cand, null);
if (d) { console.log('EXISTS as ' + d.entry.id + ' (' + d.why + ')'); process.exit(2); }
console.log('NEW');
}
function validateEntry(e) {
const errs = [];
for (const k of REQUIRED) if (e[k] === undefined || e[k] === null || e[k] === '' || (Array.isArray(e[k]) && !e[k].length && k !== 'sources')) errs.push('missing ' + k);
if (e.tier === 'D') errs.push('tier D (private person) is never added');
else if (e.tier && !TIERS.includes(e.tier)) errs.push('tier must be A, B or C');
if (e.format && !['image', 'video'].includes(e.format)) errs.push('format must be image or video');
if (e.situations && !Array.isArray(e.situations)) errs.push('situations must be an array');
if (e.sources && !Array.isArray(e.sources)) errs.push('sources must be an array');
if (e.sources && Array.isArray(e.sources) && !e.sources.length && !String(e.notes || '').match(/UNVERIFIED/i)) errs.push('no sources: put UNVERIFIED in notes if the claim is unsupported');
return errs;
}
async function download(url, id) {
const r = await fetch(url, { headers: { 'user-agent': 'Mozilla/5.0' }, redirect: 'follow' });
if (!r.ok) throw new Error('download failed: HTTP ' + r.status);
const ct = (r.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
const ext = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif', 'image/webp': 'webp' }[ct];
if (!ext) throw new Error('refusing download: content-type is ' + (ct || 'unknown') + ', only jpeg, png, gif and webp stills are fetched');
const buf = Buffer.from(await r.arrayBuffer());
if (buf.length > MAX_BYTES) throw new Error('refusing download: ' + buf.length + ' bytes is over the 5 MB cap');
if (buf.length < 500) throw new Error('refusing download: only ' + buf.length + ' bytes, probably not an image');
return { buf, ext };
}
// A file may hold one entry or an array. Each is validated, deduped and added on its own.
async function cmdAdd(file) {
const raw = JSON.parse(fs.readFileSync(path.resolve(file), 'utf8'));
const list = Array.isArray(raw) ? raw : [raw];
let dup = 0, bad = 0;
for (const e of list) {
const r = await addOne(e);
if (r === 'dup') dup++; else if (r === 'bad') bad++;
}
if (list.length > 1) console.log('batch: ' + list.length + ' entries, ' + dup + ' duplicate, ' + bad + ' invalid or failed');
process.exit(bad ? 1 : dup ? 2 : 0);
}
async function addOne(e) {
const cat = load();
const errs = validateEntry(e);
if (errs.length) { console.log('INVALID ' + (e.name || '?') + ': ' + errs.join('; ')); return 'bad'; }
e.id = e.id ? slug(e.id) : slug(e.name);
// Duplicate check before any download.
let d = findDuplicate(cat, e, null);
if (d) { console.log('DUPLICATE ' + e.name + ' of ' + d.entry.id + ' (' + d.why + '). Nothing written.'); return 'dup'; }
let written = null;
if (e.image_url) {
if (e.format !== 'image') { console.log('INVALID ' + e.name + ': image_url is only for format "image"; clips are catalogue-only'); return 'bad'; }
let got;
try { got = await download(e.image_url, e.id); }
catch (err) { console.log('FAILED ' + e.name + ': ' + err.message); return 'bad'; }
const { buf, ext } = got;
d = findDuplicate(cat, e, sha(buf));
if (d) { console.log('DUPLICATE ' + e.name + ' of ' + d.entry.id + ' (' + d.why + '). Nothing written.'); return 'dup'; }
fs.mkdirSync(MEDIA, { recursive: true });
written = 'media/' + e.id + '.' + ext;
fs.writeFileSync(path.join(LIB, written), buf);
e.sha256 = sha(buf);
e.bytes = buf.length;
}
const entry = {
id: e.id, name: e.name, aliases: e.aliases || [], tier: e.tier, format: e.format,
file: written, fetched: !!written, image_url: e.image_url || null,
origin: e.origin, owner: e.owner, feeling: e.feeling, situations: e.situations,
use_when: e.use_when, dont_use_when: e.dont_use_when, sources: e.sources,
clearance: 'unchecked', notes: e.notes || '', added: new Date().toISOString().slice(0, 10),
found_for: e.found_for || ''
};
if (e.sha256) entry.sha256 = e.sha256;
cat.memes.push(entry);
save(cat);
console.log('ADDED ' + entry.id + (written ? ' with ' + written + ' (' + e.bytes + ' bytes). LOOK AT THE IMAGE before trusting it.' : ' (no file).'));
return 'ok';
}
function cmdList() {
for (const m of load().memes) console.log(m.id + '\t[' + m.tier + ' ' + m.format + (m.fetched ? ' fetched' : ' no-file') + ' ' + m.clearance + ']\t' + m.name);
}
function cmdValidate() {
const cat = load(); let bad = 0; const seen = new Map();
for (const m of cat.memes) {
const errs = validateEntry(m);
if (m.fetched && (!m.file || !fs.existsSync(path.join(LIB, m.file)))) errs.push('fetched but file missing');
if (!m.fetched && m.file) errs.push('file set but fetched is false');
for (const n of names(m)) { if (seen.has(n) && seen.get(n) !== m.id) errs.push('name clashes with ' + seen.get(n)); else seen.set(n, m.id); }
if (errs.length) { bad++; console.log(m.id + ': ' + errs.join('; ')); }
}
console.log(bad ? bad + ' entries with problems' : 'OK: ' + cat.memes.length + ' entries');
process.exit(bad ? 1 : 0);
}
(async () => {
const [cmd, ...rest] = process.argv.slice(2);
const a = args(rest);
try {
if (cmd === 'search') cmdSearch(a._.join(' '));
else if (cmd === 'exists') cmdExists(a);
else if (cmd === 'add') await cmdAdd(a._[0] || die('usage: add entry.json'));
else if (cmd === 'list') cmdList();
else if (cmd === 'validate') cmdValidate();
else die('commands: search, exists, add, list, validate');
} catch (err) { die('ERROR: ' + err.message); }
})();
Shown as it is in the repository at build time (2026-11-03); the home directory appears as <home>. If this page and the file differ, the file is right. Back to the catalogue.