A Zotero translator for Greater Greater Washington

February 2026

Greater Greater Washington is a blog covering urban issues in and around Washington, DC. Zotero by default sees entries as websites, not blog posts. This translator, coded by Claude, will instead import them into Zotero as blog posts, capturing the title, author or authors, publication, and date.

To install a translator, save the following as a text files, then place the file in your Zotero data>translators directory and restart Zotero.

{
	"translatorID": "e3c96b1e-4d1a-4b2c-9f2e-7a8d3f1c5e2b",
	"label": "Greater Greater Washington",
	"creator": "Custom Translator",
	"target": "^https?://(www\\.)?ggwash\\.org/",
	"minVersion": "5.0",
	"maxVersion": "",
	"priority": 100,
	"inRepository": false,
	"translatorType": 4,
	"browserSupport": "gcsibv",
	"lastUpdated": "2026-02-22 20:00:00"
}

/*
	Zotero Translator for Greater Greater Washington (https://ggwash.org)
	Item type: Blog Post

	GGWash byline structure (from observed page text):
	  "PlanningBy [AuthorLink] August 6, 2019 · 75"
	  "LinksBy [AuthorLink] February 20, 2026"
	  "HousingGGWash PolicyBy [AuthorLink], [AuthorLink] April 3, 2025"

	The <a href="/contributors/[slug]"> tag wraps ONLY the author name.
	The date is a sibling text node or element sitting outside the <a> tag.
	Therefore link.textContent contains only the name; the date is found by
	walking up to the parent element and reading its full textContent, which
	includes all child text nodes (the link text, the date, the comment count).
*/

// ---------------------------------------------------------------------------
// detectWeb
// ---------------------------------------------------------------------------
function detectWeb(doc, url) {
	if (/\/view\/\d+/.test(url)) return "blogPost";
	if (doc.querySelector('a[href*="/contributors/"]')) return "blogPost";
	return false;
}

// ---------------------------------------------------------------------------
// Shared date regex
// ---------------------------------------------------------------------------
var MONTH_PAT = "Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:tember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?";
var DATE_RE   = new RegExp("\\b(" + MONTH_PAT + ")\\s+(\\d{1,2}),?\\s+(\\d{4})\\b", "i");

// ---------------------------------------------------------------------------
// Name helpers
// ---------------------------------------------------------------------------
function cleanName(raw) {
	return raw.replace(/\s*[\(\[][^\)\]]*[\)\]]/g, "").replace(/\s+/g, " ").trim();
}

function looksLikeName(text) {
	if (!text || text.length < 2 || text.length > 80) return false;
	if (/\d/.test(text)) return false;
	if (/\bBy\b/i.test(text)) return false;
	if (!/[A-Z]/.test(text)) return false;
	if (/^(contributors?|authors?|staff|team|about|all\s+posts)$/i.test(text)) return false;
	if (text.trim().split(/\s+/).length > 5) return false;
	return true;
}

function extractAuthorSegment(raw) {
	var text = raw;
	var byIdx = text.lastIndexOf("By ");
	if (byIdx === -1) byIdx = text.lastIndexOf("by ");
	if (byIdx !== -1) text = text.slice(byIdx + 3);
	text = text.replace(new RegExp("\\s*\\b(?:" + MONTH_PAT + ")\\b.*$", "i"), "").trim();
	text = text.replace(/\s*[·•]\s*\d+.*$/, "").trim();
	text = text.replace(/\s+\d[\d\s]*$/, "").trim();
	return text;
}

function nameFromSlug(href) {
	var m = href.match(/\/contributors\/([^\/\?#]+)/);
	if (!m || !m[1]) return null;
	var slug = m[1];
	if (slug.indexOf("-") === -1) return slug;
	return slug.split("-").map(function(w) {
		return w.charAt(0).toUpperCase() + w.slice(1);
	}).join(" ");
}

function makeCreator(fullName) {
	var name = (fullName || "").trim();
	if (!name) return null;
	var idx = name.lastIndexOf(" ");
	if (idx === -1) return { lastName: name, firstName: "", creatorType: "author" };
	return { firstName: name.substring(0, idx).trim(), lastName: name.substring(idx + 1).trim(), creatorType: "author" };
}

// ---------------------------------------------------------------------------
// Authors — primary: /contributors/ links
// ---------------------------------------------------------------------------
function getAuthorsFromContributorLinks(doc) {
	var links = doc.querySelectorAll('a[href*="/contributors/"]');
	if (!links.length) return [];
	var creators = [];
	var seenSlugs = {};
	for (var i = 0; i < links.length; i++) {
		var link = links[i];
		var href = link.getAttribute("href") || "";
		var slugMatch = href.match(/\/contributors\/([^\/\?#]+)/);
		if (!slugMatch) continue;
		var slug = slugMatch[1].toLowerCase();
		if (seenSlugs[slug]) continue;
		var rawText = link.textContent.trim();
		if (!rawText) continue;
		var name = null;
		if (looksLikeName(cleanName(rawText))) {
			name = cleanName(rawText);
		} else {
			var seg = extractAuthorSegment(rawText);
			var slugName = nameFromSlug(href);
			if (slugName && seg.toLowerCase().indexOf(slug.replace(/-/g, " ")) !== -1) {
				name = cleanName(slugName);
			} else if (looksLikeName(cleanName(seg))) {
				name = cleanName(seg);
			} else if (slugName && looksLikeName(slugName)) {
				name = slugName;
			} else {
				continue;
			}
		}
		if (!name) continue;
		seenSlugs[slug] = true;
		var creator = makeCreator(name);
		if (creator) creators.push(creator);
	}
	return creators;
}

// ---------------------------------------------------------------------------
// Authors — fallbacks
// ---------------------------------------------------------------------------
function splitByline(text) {
	return text
		.replace(/,\s*and\s+/gi, "\x00").replace(/\s+and\s+/gi, "\x00")
		.replace(/\s*&\s*/g, "\x00").replace(/\s*,\s*/g, "\x00")
		.split("\x00").map(function(n) { return n.trim(); }).filter(function(n) { return n.length > 1; });
}

function getAuthorsFromBylineText(doc) {
	var selectors = [".byline", ".author", ".post-author", ".article-author",
		'[class*="byline"]', '[class*="author"]', '[itemprop="author"]', "address"];
	for (var s = 0; s < selectors.length; s++) {
		var nodes = doc.querySelectorAll(selectors[s]);
		for (var n = 0; n < nodes.length; n++) {
			var text = nodes[n].textContent.trim();
			var byMatch = text.match(/^[Bb]y\s+([\s\S]+)/);
			if (!byMatch) continue;
			var bylinePart = byMatch[1]
				.replace(new RegExp("\\s*\\b(?:" + MONTH_PAT + ")\\b.*$", "i"), "").trim();
			var parts = splitByline(bylinePart);
			var creators = [];
			for (var p = 0; p < parts.length; p++) {
				var creator = makeCreator(cleanName(parts[p]));
				if (creator) creators.push(creator);
			}
			if (creators.length) return creators;
		}
	}
	return [];
}

function getAuthorsFromMeta(doc) {
	var creators = [];
	var metas = doc.querySelectorAll('meta[name="author"]');
	for (var i = 0; i < metas.length; i++) {
		var content = (metas[i].getAttribute("content") || "").trim();
		if (!content) continue;
		var parts = splitByline(content);
		for (var p = 0; p < parts.length; p++) {
			var creator = makeCreator(cleanName(parts[p]));
			if (creator) creators.push(creator);
		}
	}
	return creators;
}

function getAuthorsFromJSONLD(doc) {
	var creators = [];
	var scripts = doc.querySelectorAll('script[type="application/ld+json"]');
	for (var i = 0; i < scripts.length; i++) {
		try {
			var data = JSON.parse(scripts[i].textContent);
			var candidates = Array.isArray(data) ? data : (data["@graph"] ? data["@graph"] : [data]);
			for (var j = 0; j < candidates.length; j++) {
				var obj = candidates[j];
				if (!obj.author) continue;
				var af = Array.isArray(obj.author) ? obj.author : [obj.author];
				for (var k = 0; k < af.length; k++) {
					var a = af[k];
					var ns = (typeof a === "string") ? a : (a.name || "");
					if (ns) { var c = makeCreator(cleanName(ns)); if (c) creators.push(c); }
				}
			}
			if (creators.length) return creators;
		} catch (e) {}
	}
	return creators;
}

function getAuthors(doc) {
	var r;
	r = getAuthorsFromContributorLinks(doc); if (r.length) return r;
	r = getAuthorsFromBylineText(doc);       if (r.length) return r;
	r = getAuthorsFromMeta(doc);             if (r.length) return r;
	return getAuthorsFromJSONLD(doc);
}

// ---------------------------------------------------------------------------
// Date
// ---------------------------------------------------------------------------

/**
 * Walk up from each contributor link and check the textContent of each
 * ancestor (up to 4 levels).  A parent's textContent includes sibling text
 * nodes around the <a> tag, which is where GGWash puts the date.
 */
function getDateFromContributorLinks(doc) {
	var links = doc.querySelectorAll('a[href*="/contributors/"]');
	for (var i = 0; i < links.length; i++) {
		var el = links[i].parentElement;
		for (var depth = 0; depth < 4; depth++) {
			if (!el) break;
			var m = DATE_RE.exec(el.textContent || "");
			if (m) return m[1] + " " + m[2] + ", " + m[3];
			el = el.parentElement;
		}
	}
	return "";
}

function getDate(doc) {
	// 1. Open Graph publication time
	var og = doc.querySelector('meta[property="article:published_time"]');
	if (og) { var v = og.getAttribute("content"); if (v) return v; }

	// 2. JSON-LD datePublished
	var scripts = doc.querySelectorAll('script[type="application/ld+json"]');
	for (var i = 0; i < scripts.length; i++) {
		try {
			var data = JSON.parse(scripts[i].textContent);
			var candidates = Array.isArray(data) ? data : (data["@graph"] ? data["@graph"] : [data]);
			for (var j = 0; j < candidates.length; j++) {
				if (candidates[j].datePublished) return candidates[j].datePublished;
			}
		} catch (e) {}
	}

	// 3. <time datetime="…">
	var timeEl = doc.querySelector("time[datetime]");
	if (timeEl) { var v = timeEl.getAttribute("datetime"); if (v) return v; }

	// 4. <time> visible text
	var timeEl2 = doc.querySelector("time");
	if (timeEl2) { var v = timeEl2.textContent.trim(); if (v) return v; }

	// 5. Class / itemprop selectors
	var dateSelectors = [
		'[itemprop="datePublished"]', 'meta[name="date"]', 'meta[name="pubdate"]',
		".published", ".pub-date", ".post-date", ".date"
	];
	for (var s = 0; s < dateSelectors.length; s++) {
		var el = doc.querySelector(dateSelectors[s]);
		if (!el) continue;
		var val = el.getAttribute
			? (el.getAttribute("content") || el.getAttribute("datetime") || el.textContent.trim())
			: "";
		if (val) return val;
	}

	// 6. GGWash-specific: date sits as a sibling of the contributor <a> tag;
	//    read it from the parent container's textContent.
	return getDateFromContributorLinks(doc);
}

// ---------------------------------------------------------------------------
// Title
// ---------------------------------------------------------------------------
function getTitle(doc) {
	var og = doc.querySelector('meta[property="og:title"]');
	if (og) { var t = (og.getAttribute("content") || "").trim(); if (t) return t; }
	var h1 = doc.querySelector("h1");
	if (h1) { var t = h1.textContent.trim(); if (t) return t; }
	var titleEl = doc.querySelector("title");
	if (titleEl) {
		return titleEl.textContent.replace(/\s*[–—-]+\s*Greater Greater Washington\s*$/i, "").trim();
	}
	return "";
}

// ---------------------------------------------------------------------------
// Canonical URL
// ---------------------------------------------------------------------------
function getCanonicalURL(doc, currentURL) {
	var link = doc.querySelector('link[rel="canonical"]');
	if (link) { var h = link.getAttribute("href"); if (h) return h; }
	var og = doc.querySelector('meta[property="og:url"]');
	if (og) { var c = og.getAttribute("content"); if (c) return c; }
	return currentURL;
}

// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
function doWeb(doc, url) {
	var item = new Zotero.Item("blogPost");
	item.title     = getTitle(doc);
	item.blogTitle = "Greater Greater Washington";
	item.date      = getDate(doc);
	item.url       = getCanonicalURL(doc, url);
	var creators   = getAuthors(doc);
	for (var i = 0; i < creators.length; i++) item.creators.push(creators[i]);
	item.complete();
}