Recommended Free Tools
Some links on this page are affiliate links: if you buy through them we may earn a commission, at no extra cost to you.
Use (//p)[1]/text()[last()] for the last direct text node in the first paragraph. For the final paragraph in the document, use (//p)[last()]/text()[last()]. If indentation or formatting creates whitespace-only nodes, add [normalize-space()] before [last()]. Because Selenium’s normal XPath locator returns elements, evaluate a text-node XPath with JavaScript and read the node’s nodeValue.
Use the XPath that matches your target
These expressions differ in two ways: which paragraph is selected, and whether the text node must be a direct child or any descendant.
| What you need | XPath |
|---|---|
| Last direct text child of the first paragraph | (//p)[1]/text()[last()] |
| Last direct text child of the last paragraph in the document | (//p)[last()]/text()[last()] |
| Last non-whitespace direct text child of the first paragraph | (//p)[1]/text()[normalize-space()][last()] |
| Last direct text child relative to a paragraph already selected | ./text()[last()] |
| Last descendant text node, including text inside nested elements | (.//text())[last()] |
The parentheses around //p are important when “last” means the final paragraph in the complete result set. Without them, //p[last()] applies the positional predicate in each applicable parent context and can select more than one paragraph when paragraphs are spread across containers.
What text()[last()] actually selects
text() means direct text-node children
In XPath, text() is the node test for text-node children of the current context. It does not include text nested inside an inline element. For example:
#1 Best Overall
<p>Start <em>emphasized</em> finish</p>
This paragraph has direct text nodes containing “Start ” and “ finish”, while “emphasized” belongs to the em element. Therefore ./text()[last()] selects the direct text node containing “ finish”, not the text inside em.
[last()] is evaluated in the current step
After the text() step produces the direct text-node sequence, [last()] selects the final node in that sequence. In (//p)[1]/text()[last()], the first paragraph is chosen first; the final direct text node is chosen second. W3C’s XPath 1.0 specification describes child::text() as selecting text-node children and uses positional predicates such as position()=last() for final-position selection (W3C XPath 1.0).
Direct children versus descendants
Use text() when you specifically need text nodes directly under the paragraph. Use .//text() when inline markup should be included. The grouped descendant form (.//text())[last()] finds the final descendant text node in document order. This is a different operation from finding the final direct text child, so choose it deliberately rather than using it as a workaround.
The Tool Desk
Outbyte PC Repair FREEClear out junk files and repair common Windows errorsFree Scan →Outbyte Driver Updater FREEFix the driver behind crashes, sound loss and screen glitchesFind Drivers →Why a normal Selenium XPath lookup can fail
Selenium documents By.XPATH as an element locator: “Select the element via XPATH” (Selenium Python API). A text node is not an element. Consequently, calling find_element(By.XPATH, '(//p)[1]/text()[last()]') may produce an invalid-selector error in browser WebDriver implementations instead of returning a text node.
Select the paragraph element with Selenium, then evaluate a relative XPath from that element with JavaScript. The browser’s DOM XPath evaluator can return the text node, and JavaScript can convert it to its stored string value.
Rank #2
Python and Selenium: reliable retrieval
Get the final direct text node from the first paragraph
The following example is runnable with Selenium 4 and a driver available on your system. Replace the URL with the page under test.
from selenium import webdriver
from selenium.webdriver.common.by import By
options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
driver = webdriver.Chrome(options=options)
try:
driver.get("https://example.com")
paragraph = driver.find_element(By.XPATH, "(//p)[1]")
last_text = driver.execute_script(
"""
const result = document.evaluate(
'./text()[last()]',
arguments[0],
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
null
).singleNodeValue;
return result ? result.nodeValue : null;
""",
paragraph,
)
print(last_text)
finally:
driver.quit()
arguments[0] is the paragraph WebElement passed to execute_script, so ./text()[last()] is evaluated relative to that paragraph rather than against the entire document. If there is no matching direct text node, the script returns JavaScript null, which Selenium exposes as Python None.
Ignore whitespace-only nodes
HTML formatting can create a direct text node containing only line breaks or spaces. Add the predicate [normalize-space()] before the positional predicate:
last_text = driver.execute_script(
"""
const result = document.evaluate(
'./text()[normalize-space()][last()]',
arguments[0],
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
null
).singleNodeValue;
return result ? result.nodeValue : null;
""",
paragraph,
)
Here, normalize-space() is used as a boolean filter: nodes whose normalized value is empty are excluded before last() chooses a node. MDN documents last() and normalize-space() among the XPath functions (MDN XPath functions).
Read direct child nodes without a second XPath
If your test needs explicit control over node types, inspect the paragraph’s childNodes collection:
Rank #3
last_text = driver.execute_script(
"""
const nodes = [...arguments[0].childNodes]
.filter(node => node.nodeType === Node.TEXT_NODE && node.nodeValue.trim());
return nodes.length ? nodes[nodes.length - 1].nodeValue : null;
""",
paragraph,
)
This version filters to text nodes and removes whitespace-only entries with JavaScript’s trim(). It returns the original nodeValue, including any leading or trailing whitespace that remains on the selected node.
Choosing the correct paragraph scope
First paragraph
(//p)[1] creates one document-wide paragraph sequence and selects its first member. Appending /text()[last()] then selects that paragraph’s final direct text child.
Last paragraph in the document
(//p)[last()]/text()[last()] first groups every paragraph, selects the final one, and then selects its final direct text child. This is the expression to use when “last paragraph” means the last p element in document order across the whole page.
Why //p[last()] may surprise you
//p[last()] does not necessarily mean “the last p on the page.” The p[last()] predicate is evaluated for each applicable parent context. If separate containers each contain paragraphs, the final paragraph under each context can match. Group the paragraph location path—(//p)[last()]—when you need one document-wide result.
A paragraph you already have
Once Selenium has located a paragraph element, use the relative expression ./text()[last()]. The leading dot prevents the evaluation from escaping to another context.
Quick wins for a faster PC:
Repair Windows errors before they cause bigger problemsFix Now →Scan for outdated or missing drivers - takes under a minuteDriver Scan →Clear out junk files and repair common Windows errorsFree Scan →Getting the text after the last inline element
A common variation is “get the text after the last span.” Suppose the markup is:
<p>Label <span>value</span> trailing text</p>
The text after the span is a direct text child of the paragraph, so the same relative expression works:
paragraph = driver.find_element(By.CSS_SELECTOR, "p")
trailing = driver.execute_script(
"""
const result = document.evaluate(
'./text()[last()]',
arguments[0], null,
XPathResult.FIRST_ORDERED_NODE_TYPE, null
).singleNodeValue;
return result ? result.nodeValue : null;
""",
paragraph,
)
If the apparent trailing words are wrapped in another element, they are not direct text children. In that case, use a descendant expression such as (.//text())[last()], understanding that it returns the final descendant text node regardless of which inline element contains it.
Common errors and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
InvalidSelectorException or an invalid-selector response |
The XPath result is a text node, but find_element expects an element. |
Locate the paragraph element, then evaluate the relative XPath with execute_script. |
None is returned |
The paragraph has no direct text nodes, or every direct node is whitespace when the normalized form is used. | Inspect the markup; use (.//text())[last()] for descendant text, or handle the missing value explicitly. |
The value inside em, span, or another inline element is missing |
text() selects only direct children. |
Use the descendant form (.//text())[last()] when nested text is intended. |
| Several paragraphs match the “last” expression | //p[last()] was evaluated per parent context. |
Group the paragraph set first: (//p)[last()]. |
| The result contains only indentation or line breaks | Whitespace-only text nodes are legitimate DOM nodes. | Filter with text()[normalize-space()][last()], or apply an equivalent trim() check in JavaScript. |
| The returned string has unexpected spaces | The selected node’s stored value includes those spaces. | Return the node value as-is when exact source text matters; otherwise trim it in your test code after retrieval. |
Testing several paragraphs
When a test must inspect every paragraph, first retrieve paragraph elements and apply the same relative script to each one. This preserves element-oriented Selenium lookup while keeping text-node handling in the browser:
What’s actually slowing this PC down?
Pick the symptom - the matching free tool is one click away.
paragraphs = driver.find_elements(By.XPATH, "//p")
script = """
const result = document.evaluate(
'./text()[normalize-space()][last()]',
arguments[0], null,
XPathResult.FIRST_ORDERED_NODE_TYPE, null
).singleNodeValue;
return result ? result.nodeValue : null;
"""
values = [driver.execute_script(script, p) for p in paragraphs]
for index, value in enumerate(values, start=1):
print(index, value)
This approach makes the scope explicit: Selenium supplies one paragraph element at a time, and the relative XPath cannot accidentally select a text node from another paragraph.
Best Value
Performance and reliability considerations
Keep the XPath narrow
If the page has a stable container, scope the initial element lookup to it—for example, (//main//p)[1]—instead of scanning unrelated paragraphs. Keep the grouping parentheses when selecting the final paragraph within that scoped result.
Wait for the content that creates the text node
Run the JavaScript after the paragraph’s content has been rendered. If a script later inserts a text node or replaces inline markup, an earlier result describes the earlier DOM state. Locate the paragraph again or evaluate after the page’s own readiness condition.
Decide what “text” means in the assertion
- Use direct text with
text()when nested labels or emphasis should be excluded. - Use descendant text with
.//text()when the final visible fragment may be inside inline markup. - Use the normalized predicate to reject whitespace-only nodes.
- Preserve
nodeValuewhen exact source spacing is significant; trim only in the assertion layer when presentation spacing is irrelevant.
XPath 3.0 likewise defines text() as matching text-node children and illustrates final-position selection with para[fn:last()] (W3C XPath 3.0). The browser implementation used by WebDriver determines the available XPath version, so the expressions above intentionally use XPath 1.0-compatible functions.
Or skip the browser setup
If your purpose is to keep a visual record of the page while debugging selectors, ScreenshotNeo can capture a URL through one HTTP request. It is separate from Selenium and does not return DOM text nodes, but it can provide a clean screenshot of the page state you are investigating. Cookie and consent banners are accepted and removed before capture, along with more than 60 known consent platforms, newsletter popups and chat widgets; each cleanup step can be turned off. Bot checks, CAPTCHAs, blank pages, timeouts, failed loads and cache hits are not billed, and the response identifies the page verdict and billing status in X-Page-Verdict and X-Billed headers.
See the ScreenshotNeo API documentation for parameters. A direct cURL call is:
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
The equivalent Python request is:
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
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}`);
ScreenshotNeo also provides an MCP server with take_screenshot, get_page_info and capture_pdf tools for Claude, Cursor and other MCP clients. The Free plan includes 1,000 screenshots per month without a card; paid plans start at $5 for 3,000 screenshots. Create a free ScreenshotNeo account to try the capture without entering payment details.
Frequently Asked Questions
Does normalize-space() rewrite the text node returned by JavaScript?
No. In text()[normalize-space()][last()] it acts as a predicate that decides which nodes remain eligible. The selected node’s original nodeValue is still returned; trim or otherwise transform that string separately if your assertion requires normalized spacing.
Do these 3 things before closing this tab:
1Fix the driver behind crashes, sound loss and screen glitches2Repair Windows errors before they cause bigger problems3Scan for outdated or missing drivers - takes under a minuteQuick 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.

