Purpose
Extract all organizations shown in the Horizons Foundation organization directory and return CSV columns organization_name, phone, website, address, and source_url.
When to Use
Use for a complete organization-directory export. The directory is available directly at /org-directory/; no homepage navigation or search interaction is required.
Workflow
- Navigate directly to
https://www.horizonsfoundation.org/org-directory/withwaitUntil: 'domcontentloaded', then wait for.profile-name. - On the loaded directory page, run this single
evaluate()extractor. It identifies records by requiring both.profile-nameand a canonical/org/{slug}/link, then returns spreadsheet-ready CSV:
(() => {
const clean = (value) => (value || "").replace(/\s+/g, " ").trim();
const csv = (value) => `"${String(value ?? "").replace(/"/g, '""')}"`;
const absolute = (href) => {
if (!href) return "";
try {
return new URL(href, location.href).href;
} catch {
return "";
}
};
const isOrgUrl = (href) => /\/org\/[^/]+\/$/.test(href);
const rows = [...document.querySelectorAll(".row")].filter(
(row) =>
row.querySelector(".profile-name") &&
[...row.querySelectorAll('a[href*="/org/"]')].some((a) =>
isOrgUrl(absolute(a.getAttribute("href"))),
),
);
const records = rows.map((row) => {
const orgLink = [...row.querySelectorAll('a[href*="/org/"]')]
.map((a) => absolute(a.getAttribute("href")))
.find(isOrgUrl);
const phoneNode =
row.querySelector(".phone") || row.querySelector('a[href^="tel:"]');
const phone = clean(phoneNode?.textContent);
const addressNode = row.querySelector(".address, address");
const contactItems = [...row.querySelectorAll(".profile-contact li")];
const address =
clean(addressNode?.textContent) ||
clean(
contactItems
.map((li) => clean(li.textContent))
.find(
(text) =>
text &&
!/^tel:|^mailto:/i.test(text) &&
!/^(phone|website|web|email)\s*:/i.test(text) &&
!li.querySelector(
'a[href^="http"], a[href^="mailto:"], a[href^="tel:"]',
),
),
);
const websiteNode =
row.querySelector(".website a[href], a.website[href]") ||
[...row.querySelectorAll(".profile-contact a[href]")].find((a) => {
const href = absolute(a.getAttribute("href"));
return (
/^https?:\/\//i.test(href) &&
!/horizonsfoundation\.org\/org(?:-directory)?\//i.test(href)
);
});
const website = websiteNode
? absolute(websiteNode.getAttribute("href")) || clean(websiteNode.textContent)
: "";
return {
organization_name: clean(row.querySelector(".profile-name")?.textContent),
phone,
website,
address,
source_url: orgLink || "",
};
});
const header = ["organization_name", "phone", "website", "address", "source_url"];
return [header, ...records.map((record) => header.map((key) => csv(record[key])))]
.map((line) => line.join(","))
.join("\n");
})();Site-Specific Gotchas
- Organization cards are mixed into generic
.rowelements; only rows containing both.profile-nameand a canonical/org/{slug}/link are records. - Use the row’s
/org/{slug}/link assource_url; do not use the directory URL for every record. - Contact data is presented through
.phone,.profile-contact, and sometimes.address/address; the extractor also supports atel:link for phone fallback. - Address extraction falls back to contact-list text while excluding phone, email, and website links.
- The directory page is the collection endpoint. No opaque organization ID resolution or per-organization navigation is needed for the directory export.
- The website selector relies on the directory’s current profile/contact classes and external-link pattern; validate if the site redesigns its card markup.
- The directory should be treated as a single collection page; if a redesign introduces lazy loading or pagination, load all directory content before running the extractor.
Expected Output
A CSV string with one header row and one row per organization:
organization_name,phone,website,address,source_urlThe CSV is quoted and escaped for spreadsheet import; blank or unavailable fields are emitted as empty quoted values.