Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Use Puppeteer’s page.$eval() to select the span, read its text in the browser context, and convert the trimmed string with Number():

const value = await page.$eval('.price', element =>
  Number(element.textContent.trim())
);

if (!Number.isFinite(value)) {
  throw new Error('The span did not contain a finite number');
}

This is strict: the complete trimmed span text must represent one finite JavaScript number. The rest of this guide explains when to use textContent or innerText, how to handle missing or repeated spans, how to normalize formatted values, and how to diagnose common Puppeteer failures.

Basic pattern: select, read, convert, validate

Puppeteer runs the callback supplied to page.$eval(selector, pageFunction) inside the loaded page. The first matching element is passed to the callback, and the callback’s return value is sent back to Node.js. If the selector matches nothing, $eval throws.

import puppeteer from 'puppeteer';

const browser = await puppeteer.launch({ headless: true });
const page = await browser.newPage();

try {
  await page.goto('https://example.com/product', {
    waitUntil: 'networkidle0'
  });

  const value = await page.$eval('span.price', element => {
    const text = element.textContent.trim();
    const number = Number(text);

    if (!Number.isFinite(number)) {
      throw new Error(`Expected a finite number, received: ${text}`);
    }

    return number;
  });

  console.log(value);
} finally {
  await browser.close();
}

Keep the conversion inside the page callback. That way you return a primitive number rather than a DOM node, and all DOM access occurs where the element exists. The callback may be asynchronous; Puppeteer waits for a returned promise before resolving the $eval call.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Why Number() is the default

Number(text.trim()) requires the entire trimmed string to be numeric. A value such as 12.50 becomes 12.5, while 12.50 USD becomes NaN. That failure is useful when the page contract says the span should contain only a number: it prevents a unit, label, or unexpected markup from silently entering your data.

Always validate the result when invalid input would affect a calculation, database write, comparison, or alert. Number.isFinite(value) accepts only finite values of JavaScript type number; it rejects NaN, positive and negative infinity, and non-number values without coercing them.

textContent versus innerText

The property you choose defines what “the span’s value” means.

Property What it reads Use it when Important behavior
textContent Text in the node and its descendants The DOM text is the machine-readable input It does not consider whether text is visibly rendered
innerText Rendered, human-readable text The displayed value, including CSS visibility, is what matters It can trigger layout/reflow while the browser computes rendered text

Reading DOM text with textContent

const value = await page.$eval('.price', element => {
  const raw = element.textContent.trim();
  const result = Number(raw);
  if (!Number.isFinite(result)) throw new Error(`Not numeric: ${raw}`);
  return result;
});

This is normally the best choice for a span intentionally containing a data value. Be aware that hidden descendants still contribute. For example, a visually hidden label inside the span can make the complete string nonnumeric.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Reading the displayed value with innerText

const displayedValue = await page.$eval('.price', element => {
  const raw = element.innerText.trim();
  const result = Number(raw);
  if (!Number.isFinite(result)) throw new Error(`Displayed text is not numeric: ${raw}`);
  return result;
});

Choose this when CSS-hidden text should not count and the rendered representation is the source of truth. Because it accounts for layout, it can be more expensive than textContent when you process many elements.

Strict conversion and deliberate prefix parsing

Use Number for a whole-string contract

Strict conversion catches formatting that your parser has not explicitly handled:

Number('12.50');       // 12.5
Number(' 12.50 ');     // 12.5
Number('12.50 USD');   // NaN
Number('');            // 0

Since an empty string converts to zero, check the trimmed input before conversion if an empty span must be rejected:

const value = await page.$eval('.price', element => {
  const raw = element.textContent.trim();
  if (raw === '') throw new Error('The price span is empty');

  const number = Number(raw);
  if (!Number.isFinite(number)) throw new Error(`Invalid number: ${raw}`);
  return number;
});

Use parseFloat only for an intentional numeric prefix

parseFloat reads the longest valid numeric prefix. It can be appropriate when the input contract explicitly allows trailing text:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
parseFloat('12.50 USD'); // 12.5
parseFloat('12px');       // 12
parseFloat('USD 12');     // NaN

That permissiveness can conceal a markup or formatting error. Do not use it merely to make a failing selector “work.” If a currency symbol, unit, or label is expected, remove or validate that part explicitly before conversion.

Currency, grouping, and locale formats

JavaScript’s built-in numeric conversion is not a locale-aware parser. Strings such as $1,234.56, 1.234,56 €, and 12,50 require a known format and normalization policy. Do not globally delete punctuation unless you know whether commas are grouping marks or decimal separators.

Example: a known US-style currency format

const dollars = await page.$eval('.price', element => {
  const raw = element.textContent.trim();
  const normalized = raw.replace(/[$,]/g, '');
  const number = Number(normalized);
  if (!Number.isFinite(number)) throw new Error(`Invalid US amount: ${raw}`);
  return number;
});

This example is correct only when the source contract is a dollar amount using commas for groups and a period for decimals. For other locales, define a separate normalization rule or use a locale-aware parsing library in your own application, then validate the result.

Waiting for the span before reading it

Modern pages often insert values after the initial navigation. Navigate first, then wait for the selector:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
await page.goto('https://example.com/product', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('span.price', { visible: true });

const value = await page.$eval('span.price', element => {
  const number = Number(element.textContent.trim());
  if (!Number.isFinite(number)) throw new Error('Price is not finite');
  return number;
});

Use visible: true when a hidden template element could match first. If the text itself changes after the element appears, wait for the expected state rather than only the element:

await page.waitForFunction(() => {
  const element = document.querySelector('span.price');
  return element && element.textContent.trim() !== '';
});

For pages that replace the node during rendering, perform the final $eval after the wait; do not retain a stale element handle from an earlier render.

Missing spans and optional values

When the span is required, let the failure identify a broken page contract and add context:

try {
  const value = await page.$eval('.price', element => Number(element.textContent.trim()));
  console.log(value);
} catch (error) {
  throw new Error(`Could not read required .price span: ${error.message}`);
}

When the span is optional, query for it first and return null when absent:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
const value = await page.evaluate(() => {
  const element = document.querySelector('.discount');
  if (!element) return null;

  const raw = element.textContent.trim();
  const number = Number(raw);
  return Number.isFinite(number) ? number : null;
});

This form uses page.evaluate so the no-match branch can be handled in the page context without an exception.

Reading several matching spans with $$eval

page.$$eval(selector, pageFunction) passes all matching elements to the callback. Map each element to a number and decide whether invalid entries should fail the whole operation:

const values = await page.$$eval('.price', elements => {
  return elements.map((element, index) => {
    const raw = element.textContent.trim();
    const number = Number(raw);
    if (!Number.isFinite(number)) {
      throw new Error(`Invalid price at index ${index}: ${raw}`);
    }
    return number;
  });
});

console.log(values);

If only the first match is meaningful, use $eval rather than collecting every match. If order matters, remember that the returned array follows document order.

page.evaluate as an alternative

page.evaluate is useful when the selector, fallback logic, and conversion all belong in one document query:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
const value = await page.evaluate(() => {
  const element = document.querySelector('span.price');
  if (!element) throw new Error('span.price was not found');

  const raw = element.textContent.trim();
  const number = Number(raw);
  if (!Number.isFinite(number)) throw new Error(`Invalid value: ${raw}`);
  return number;
});

Both approaches execute JavaScript in the page context and return the result to Node.js. Prefer $eval for a concise, selector-focused operation; prefer evaluate when you need multiple queries or custom branching.

Common failures and fixes

“failed to find element” or a $eval exception

  • Cause: The selector is wrong, the page has not rendered the span, or the span is inside a different frame.
  • Fix: Verify the selector in browser developer tools, call waitForSelector, and inspect frames if the content is embedded.

The result is NaN

  • Cause: The span includes a currency symbol, unit, label, nonbreaking space, or locale-specific punctuation.
  • Fix: Log the exact trimmed text, define a format-specific normalization rule, then run Number and Number.isFinite again.

The value is unexpectedly zero

  • Cause: Number('') returns 0, often because the value has not rendered yet.
  • Fix: Reject an empty string before conversion and wait for non-empty text.

The value is stale or changes between runs

  • Cause: Client-side rendering, personalization, location, or time-dependent data.
  • Fix: Wait for the application’s settled state, set the required viewport or locale, and capture the raw text alongside the parsed value for diagnostics.

The span is inside an iframe

Selectors run against the current page document, not every frame. Find the frame, wait in that frame, and evaluate there:

const frame = page.frames().find(f => f.url().includes('/checkout'));
if (!frame) throw new Error('Checkout frame not found');

await frame.waitForSelector('.total');
const total = await frame.$eval('.total', element => {
  const number = Number(element.textContent.trim());
  if (!Number.isFinite(number)) throw new Error('Invalid total');
  return number;
});

The callback cannot access Node.js variables

The function passed to $eval or evaluate runs in the browser, not in Node.js. Pass values as arguments instead of referencing local Node variables directly:

const selector = 'span.price';
const value = await page.evaluate((css) => {
  const element = document.querySelector(css);
  return element ? Number(element.textContent.trim()) : null;
}, selector);

Reliability, performance, and data quality

  • Wait for the right condition: Navigation completion alone does not guarantee that a client-rendered span has its final text.
  • Keep extraction small: Return numbers or arrays of numbers, not element handles or large page objects.
  • Validate at the boundary: Reject empty, nonnumeric, and nonfinite values before storing or calculating.
  • Record raw input on failures: The original text reveals locale and markup changes faster than a generic NaN error.
  • Close browsers: Put browser.close() in a finally block so timeouts do not leak Chrome processes.
  • Avoid unnecessary layout work: Prefer textContent unless rendered visibility is part of the requirement.
  • Respect the site: Use appropriate navigation timeouts, concurrency, and access permissions for the pages you automate.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Or skip the browser setup

If your goal is a clean image or PDF of a page rather than DOM extraction, ScreenshotNeo provides a single-request website screenshot API and an MCP server for AI agents. It removes cookie/consent banners, newsletter popups, and chat widgets before capture; bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers identify the page verdict and billing status. Its MCP tools include take_screenshot, get_page_info, and capture_pdf.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

For a direct request, see the ScreenshotNeo API documentation:

curl -G "https://api.screenshotneo.com/v1/shot" 
  -d access_key=YOUR_API_KEY 
  --data-urlencode url=https://stripe.com 
  -o shot.webp

The same endpoint works from Python:

import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
    timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)

And from Node.js:

const q = new URLSearchParams({
  access_key: 'YOUR_API_KEY',
  url: 'https://stripe.com'
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const bytes = new Uint8Array(await res.arrayBuffer());
// Write bytes with your preferred Node.js filesystem code.

ScreenshotNeo includes full-page and element capture, device presets, custom viewports, retina scale, dark mode, PDF controls, custom CSS and JavaScript, clicks, waits, request blocking, headers, cookies, user agents, authorization, timezone, geolocation, transparent backgrounds, resizing, configurable caching, signed links, asynchronous jobs, bulk capture, usage data, and an OpenAPI specification. It accepts the parameter names used by other screenshot APIs to ease migration.

The Free plan includes 1,000 screenshots per month with no card. Paid plans start at $5 for 3,000 shots; Growth is $15 for 15,000, Pro $39 for 60,000, Scale $99 for 250,000, and Business $249 for 1,000,000. Yearly billing gives two months free, and every feature is available on every plan. Create a free ScreenshotNeo account to start.

Frequently Asked Questions

Does $eval return a string or a number?

It returns whatever the page callback returns. Return the result of Number(...) to send a JavaScript number back to Node.js.

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Can Puppeteer read a span that is created after navigation?

Yes. Wait for the selector or for a non-empty text condition, then run the extraction against the current DOM.

Why does innerText sometimes differ from textContent?

They answer different questions: innerText reflects rendered text and visibility, while textContent reads descendant text regardless of styling.

How can I preserve decimal precision for very large values?

JavaScript numbers use IEEE-754 double precision. If the source can exceed safe integer limits or requires exact decimal arithmetic, keep the validated string and use a decimal or big-integer strategy appropriate for your application.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.