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
- Navigate directly to
https://www.horizonsfoundation.org/org-directory/withwaitUntil: 'domcontentloaded', then wait for.profile-name. - Run this extractor on the loaded page. Set
cityFilterto a city name such asOakland, 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
| Param | What it does | Example value |
|---|---|---|
| directory-url | Direct collection URL | https://www.horizonsfoundation.org/org-directory/ |
| cityFilter | Optional case-insensitive city filter applied to the extracted address | Oakland |
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 assource_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
phonevalue; 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/\""