Extract Organizations from the Horizons Foundation Directory

Site horizonsfoundation.orgTask extract-org-directory-csvVersion v4Updated Nov 6, 2026Category directory-extraction

Extract every organization in the Horizons Foundation organization directory as spreadsheet-ready CSV with contact details and source URLs, optionally filtering records by city. This skill was captured from a live agent session on horizonsfoundation.org and is published here as a reusable recipe for agents.

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.

Extract organizations from the Horizons Foundation directory as CSV with names, phone numbers, websites, addresses, and canonical profile URLs. The extractor can optionally restrict results to a city while preserving blank phone values.

Use Cases

  • Export the complete organization directory as CSV.
  • List organizations in a specified city with phone and website details.
  • Collect organization contact details and profile URLs.
  • Refresh a spreadsheet from the current directory contents.

Automation Flow

  1. Navigate directly to https://www.horizonsfoundation.org/org-directory/ with waitUntil: 'domcontentloaded', then wait for .profile-name.
  2. Run this extractor on the loaded page. Set cityFilter to a city name such as Oakland, or leave it empty for all records.
(() => {
  const cityFilter = "";
  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, a[href^="tel:"]');
    const contactItems = [...row.querySelectorAll(".profile-contact li")]
      .map(li => ({ li, text: clean(li.textContent) }));
    const addressNode = row.querySelector(".address, address");
    const address = clean(addressNode?.textContent) || clean(
      contactItems.find(({ li, text }) =>
        text &&
        !li.matches(".phone") && !li.querySelector(".phone") &&
        !/^tel:|^mailto:/i.test(text) &&
        !/^(phone|website|web|email)\s*:/i.test(text) &&
        !li.querySelector('a[href^="http"], a[href^="mailto:"], a[href^="tel:"]')
      )?.text
    );
    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);
      });
    return {
      organization_name: clean(row.querySelector(".profile-name")?.textContent),
      phone: clean(phoneNode?.textContent),
      website: websiteNode ? absolute(websiteNode.getAttribute("href")) || clean(websiteNode.textContent) : "",
      address,
      source_url: orgLink
    };
  }).filter(record => !cityFilter || new RegExp(`\\b${cityFilter.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i").test(record.address));
  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");
})()

Params

ParamWhat it doesExample value
directory-urlDirect collection URLhttps://www.horizonsfoundation.org/org-directory/
cityFilterOptional case-insensitive city filter applied to the extracted addressOakland

Possible Friction Points

  • Cloudflare challenge appears instead of the directory → continue the BQL session until the directory loads, or retry the navigation.
  • Directory markup changes .row, .profile-name, or contact selectors → update the extractor selectors and preserve canonical /org/{slug}/ links as source_url.
  • The directory has no pagination controls and currently exposes 112 canonical records on one page → extract the loaded page in one pass; do not infer additional pages.
  • An organization has no phone number → emit an empty phone value; do not infer or substitute one from another page.

Example Result

"organization_name,phone,website,address,source_url\n\"Example Organization\",\"\",\"https://example.com/\",\"Example Address\",\"https://example.com/org/example/\""

Call it

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