Extract Organizations from the Horizons Foundation Directory

Site horizonsfoundation.orgTask extract-org-directory-csvVersion v3Updated Jul 30, 2026Category directory-extraction

Extract every organization in the Horizons Foundation organization directory as spreadsheet-ready CSV with contact details and source URLs. This skill was captured from a live agent session on horizonsfoundation.org and publishes here verbatim, exactly as an agent receives it.

NoteSelectors and URL schemes drift as sites change. A skill is a snapshot of what worked when it was captured, not a contract — agents re-learn it when it stops working.

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

  1. Navigate directly to https://www.horizonsfoundation.org/org-directory/ with waitUntil: 'domcontentloaded', then wait for .profile-name.
  2. On the loaded directory page, run this single evaluate() extractor. It identifies records by requiring both .profile-name and 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 .row elements; only rows containing both .profile-name and a canonical /org/{slug}/ link are records.
  • Use the row’s /org/{slug}/ link as source_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 a tel: 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_url

The CSV is quoted and escaped for spreadsheet import; blank or unavailable fields are emitted as empty quoted values.

Call it

GET https://production-sfo.browserless.io/skills?token=TOKEN-HERE&domain=horizonsfoundation.org&task=extract-org-directory-csv