Scrape Brookstone ETF Product Pages

Site brookstoneam.comTask scrape-etf-product-pageVersion v2Updated Nov 6, 2026Category finance

Extract fund identity, ETF metadata, performance, holdings, and document links from any Brookstone ETF product page. This skill was captured from a live agent session on brookstoneam.com 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.

Scrape any Brookstone ETF product page and return its title, ticker, labeled ETF fields, performance tables, holdings tables, data blocks, and nearby fund-document links.

Use Cases

  • Extract ticker and ETF metadata from a Brookstone product page.
  • Retrieve quarter-end or other performance tables.
  • Collect All Holdings or Top Holdings rows.
  • Capture prospectus, reports, and other fund-document URLs.

Automation Flow

  1. Build https://www.brookstoneam.com/{product-slug} from the caller-supplied slug, or use the supplied full URL.
  2. Navigate with waitUntil: "domcontentloaded", wait for h2, then wait an additional 3000 ms for asynchronous metadata and tables.
  3. Run this evaluate() on the loaded page:
(() => {
  const clean = s => (s || '').replace(/\s+/g, ' ').trim();
  const text = el => clean(el ? (el.innerText || el.textContent) : '');
  const headings = [...document.querySelectorAll('h2,h3,h4')];
  const findHeading = name => headings.find(h => text(h).toLowerCase() === name.toLowerCase());
  const section = name => {
    const h = findHeading(name);
    if (!h) return null;
    let n = h;
    for (let i = 0; i < 5 && n; i++, n = n.parentElement) {
      if (text(n).length > text(h).length) return text(n);
    }
    return text(h.parentElement);
  };
  const precedingHeading = el => {
    let result = null;
    for (const h of headings) {
      if (h.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING) result = h;
    }
    return result ? text(result) : '';
  };
  const readRows = table => [...table.querySelectorAll('tr')]
    .map(tr => [...tr.querySelectorAll('th,td')].map(text))
    .filter(row => row.some(Boolean));
  const tables = [...document.querySelectorAll('table')].map((table, index) => {
    const rows = readRows(table);
    const context = clean([
      precedingHeading(table),
      table.caption && text(table.caption),
      table.parentElement && text(table.parentElement)
    ].filter(Boolean).join(' '));
    const searchable = (context + ' ' + rows.flat().join(' ')).toLowerCase();
    return {
      index,
      heading: precedingHeading(table) || null,
      className: String(table.className || ''),
      rows,
      context,
      kind: /quarter.?end|performance/.test(searchable) ? 'quarterEndPerformance' :
        /all holdings|top holdings|holdings/.test(searchable) ? 'holdings' :
        /etf data/.test(searchable) ? 'etfData' : 'other'
    };
  }).filter(table => table.rows.length);
  const fields = {};
  document.querySelectorAll('.text-style-tagline').forEach(label => {
    const key = text(label);
    if (!key || fields[key] != null || !label.parentElement) return;
    const clone = label.parentElement.cloneNode(true);
    clone.querySelectorAll('.text-style-tagline').forEach(node => node.remove());
    const value = text(clone);
    if (value) fields[key] = value;
  });
  const etfTables = tables.filter(table => table.kind === 'etfData');
  etfTables.flatMap(table => table.rows).forEach(row => {
    if (row.length >= 2 && row[0] && fields[row[0]] == null) fields[row[0]] = row.slice(1).join(' | ');
  });
  const tickerRow = etfTables.flatMap(table => table.rows).find(row => row.some(cell => /^ticker$/i.test(cell)));
  const ticker = fields.Ticker || (tickerRow && tickerRow.slice(1).find(Boolean)) || text(document.querySelector('[data-ticker]')) || null;
  const dataBlocks = [];
  const seenBlocks = new Set();
  document.querySelectorAll('.data-block-2col, .data-block.light, [class*="data-block"]').forEach(el => {
    const value = text(el);
    if (value && !seenBlocks.has(value)) {
      seenBlocks.add(value);
      dataBlocks.push({className: String(el.className || ''), text: value});
    }
  });
  const documentHeading = findHeading('Fund Documents') || findHeading('Documents');
  let documentRoot = documentHeading;
  for (let i = 0; i < 6 && documentRoot; i++, documentRoot = documentRoot.parentElement) {
    if (documentRoot.querySelectorAll && documentRoot.querySelectorAll('a[href]').length) break;
  }
  const links = root => root ? [...root.querySelectorAll('a[href]')]
    .map(a => ({text: text(a), href: a.href}))
    .filter(link => link.text || link.href) : [];
  const fundDocuments = links(documentRoot);
  const pdfLinks = [...document.querySelectorAll('a[href]')]
    .map(a => ({text: text(a), href: a.href}))
    .filter(link => /\.pdf(?:$|\?)/i.test(link.href));
  return {
    url: location.href,
    title: text(document.querySelector('h1')) || document.title,
    ticker,
    fields,
    sections: {
      objective: section('Objective'),
      etfData: section('ETF Data') || etfTables,
      listingInformation: section('Listing Information'),
      fundDocuments: section('Fund Documents') || section('Documents')
    },
    dataBlocks,
    quarterEndPerformance: tables.filter(table => table.kind === 'quarterEndPerformance'),
    topHoldings: tables.filter(table => table.kind === 'holdings'),
    tables,
    fundDocuments,
    pdfLinks
  };
})();

Params

ParamWhat it doesExample value
product-slugBrookstone ETF product-page pathbrookstone-yield-etf
product-urlFull product URL; use instead of constructing the slug URLhttps://www.brookstoneam.com/brookstone-yield-etf

Possible Friction Points

  • Metadata, performance, or holdings are empty after h2 appears → wait an additional 3000 ms before extraction.
  • Holdings section is labeled All Holdings → use topHoldings or matching tables entries; do not require a Top Holdings heading.
  • Documents section is labeled Documents → use the extractor's heading fallback and fundDocuments; use pdfLinks for every PDF on the page.
  • Objective or Listing Information headings are absent → treat those section values as null and use dataBlocks, fields, and tables.
  • Caller supplies only a product name without a slug or URL → resolve the product link from Brookstone navigation before constructing the direct URL; do not invent a slug.
  • Navigation exposes Arlington Asset Management or other-domain links → remain on the Brookstone product URL and do not substitute another domain.

Example Result

{
  "url": "https://example.com/fund",
  "title": "Example Title",
  "ticker": "EXMP",
  "fields": {
    "Ticker": "EXMP"
  },
  "sections": {
    "objective": "Example investment objective",
    "etfData": "ETF Data Ticker EXMP",
    "listingInformation": "Example listing information",
    "fundDocuments": "Fund Documents"
  },
  "dataBlocks": [
    {
      "className": "data-block-2col",
      "text": "Example data"
    }
  ],
  "quarterEndPerformance": [],
  "topHoldings": [],
  "tables": [
    {
      "index": 0,
      "heading": "ETF Data",
      "className": "example-table",
      "rows": [
        [
          "Ticker",
          "EXMP"
        ]
      ],
      "context": "ETF Data Ticker EXMP",
      "kind": "etfData"
    }
  ],
  "fundDocuments": [
    {
      "text": "Example prospectus",
      "href": "https://example.com/document.pdf"
    }
  ],
  "pdfLinks": [
    {
      "text": "Example prospectus",
      "href": "https://example.com/document.pdf"
    }
  ]
}

Call it

GET https://production-sfo.browserless.io/skills?token=TOKEN-HERE&domain=brookstoneam.com&task=scrape-etf-product-page