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.
Windows Errors? Fix Them Before They Spread
Repair common Windows errors and clear accumulated junk for a smoother, more stable PC - no reinstall needed.Free scan · no reinstallOutdated Drivers Are Slowing You Down
One free scan finds every outdated or missing driver and matches the right update for your exact hardware.Free scan · exact hardware match#1 Best Overall
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.
Do these 3 things before closing this tab:
1Clear out junk files and repair common Windows errors2Scan for outdated or missing drivers - takes under a minute3Repair Windows errors before they cause bigger problemsReading 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:
Rank #2
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:
Recommended Free Tools
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:
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:
The Tool Desk
Outbyte Driver Updater FREEScan for outdated or missing drivers - takes under a minuteDriver Scan →Outbyte PC Repair FREEClear out junk files and repair common Windows errorsFree Scan →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:
Rank #4
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:
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
NumberandNumber.isFiniteagain.
The value is unexpectedly zero
- Cause:
Number('')returns0, 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
NaNerror. - Close browsers: Put
browser.close()in afinallyblock so timeouts do not leak Chrome processes. - Avoid unnecessary layout work: Prefer
textContentunless rendered visibility is part of the requirement. - Respect the site: Use appropriate navigation timeouts, concurrency, and access permissions for the pages you automate.
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.
For a direct request, see the ScreenshotNeo API documentation:
Best Value
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.
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.
Quick Recap
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.

