Docs/Skills and code/skill meme-fetcher

.claude/skills/meme-fetcher/scripts/library.cjs

Code203 lines9.9 KBSkill: meme-fetcher

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.