XPath Contains Text: Find Elements Reliably

Al Amin/ Author10 min read
XPath Contains Text: Find Elements Reliably

Your scraper worked yesterday. Today it throws NoSuchElementException on a button you were sure existed. The XPath was clean, the page loaded, and the text still looks right in the browser. Then you inspect the DOM and find the label now includes extra spaces, a nested <span>, or a dynamic count like “Show 15 properties”.

That's the normal lifecycle of text-based selectors on real estate sites. Listing portals change copy, UI teams wrap labels in extra markup, and JavaScript frameworks delay rendering just long enough to make a perfectly reasonable XPath return nothing. This is why developers reach for XPath contains text patterns instead of exact text matches.

Introduction Why Exact Text Matches Often Fail

Exact text matching looks precise until a front-end team touches the markup. A locator like //button[text()='Search'] fails if the site turns that into Search, inserts a line break, or changes the label to “Search homes”. On listing pages, this happens constantly with buttons like “Contact Agent”, “Save Listing”, and “Show 12 more photos”.

A frustrated programmer staring at a laptop screen showing a Selenium NoSuchElementException error while web scraping.

XPath has been around a long time. XPath 1.0 became a W3C Recommendation on November 16, 1999, and contains() became one of the most commonly used text-matching tools because it lets you match a partial substring instead of an exact string, which is much more forgiving when labels vary or include dynamic values, as noted in this XPath text selection reference from ScrapingBee.

For scraping real estate pages, partial matching is often the practical middle ground. You might not know whether the page says “3 Bedrooms”, “Bedrooms 3”, or “3 Bed”. But if one stable fragment exists, contains() gives you a better shot than text() equality.

Practical rule: Exact text matches are for stable UI copy. Text fragments are for production pages that change.

There's another reason exact matching breaks. The text you see on screen isn't always stored as one simple text node. A badge might be split across nested tags, or rendered after the DOM is initially loaded. When that happens, the issue isn't your syntax alone. It's your assumption about how the page is built.

Finding Elements with XPath Contains Text

The most common form of XPath contains text is:

//tag[contains(text(), 'substring')]

That reads as: find a given element type whose direct text node includes a substring. On a property details page, you might use it to find labels such as “Bedrooms”, “Baths”, or “Price history” without depending on the full visible string.

The basic pattern

Suppose the HTML looks like this:

<div class="facts">
  <span>3 Bedrooms</span>
  <span>2 Bathrooms</span>
  <span>1,850 sqft</span>
</div>

A brittle exact match would be:

//span[text()='3 Bedrooms']

A more resilient selector is:

//span[contains(text(), 'Bedrooms')]

That will still match if the site changes the value from 3 Bedrooms to 4 Bedrooms. This is usually what you want when the numeric part changes but the label fragment stays stable.

Here's a quick comparison you can keep nearby:

Function

Syntax

Use Case

Example

text()

//span[text()='Bedrooms']

Exact visible string is stable

//button[text()='Save']

contains()

//span[contains(text(), 'Bedrooms')]

Label varies but includes a stable fragment

//span[contains(text(), 'Bedrooms')]

normalize-space() with text

//button[normalize-space(text())='Search']

Extra spaces or line breaks break exact match

//button[normalize-space(text())='Search']

contains() with normalize-space()

//button[contains(normalize-space(text()), 'Search')]

Partial match plus whitespace cleanup

//button[contains(normalize-space(text()), 'Search')]

If you build scrapers in multiple languages, it helps to see how selector strategy carries across stacks. This guide on web scraping with Ruby is useful if you need the same text-selection logic outside Python or JavaScript.

When text really means direct text only

Junior developers typically get tripped up on this point: text() targets the element's direct text node, not necessarily all visible text inside the element.

Example:

<button><span>Save</span> Listing</button>

This may break:

//button[contains(text(), 'Save Listing')]

Why? Because the visible label is split. Part is inside <span>, part is outside it. In those cases, matching against . is often safer because it evaluates the element's string value, including descendant text.

Use this when text is split across nested tags:

//button[contains(., 'Save Listing')]

If the browser shows the text as one phrase but the DOM splits it into child nodes, contains(text(), ...) can miss it.

For simple markup, contains(text(), ...) is fine. For production pages with wrapped labels, icon spans, and badge counts, test contains(., ...) before assuming the page is wrong.

Advanced Text Matching Beyond Contains

contains() does a lot of work, but it isn't always the best fit. In scraping, the right function depends on how predictable the text is, how noisy the DOM is, and whether you're in an XPath 1.0 or 2.0 environment.

A visual guide explaining various XPath functions for advanced text matching beyond the basic contains method.

When starts-with is cleaner

Use starts-with() when the prefix is stable and the rest changes.

A real estate example is a listing label like:

  • Listing #4821

  • Listing #9917

  • Listing #12044

This selector is tighter than contains():

//div[starts-with(normalize-space(.), 'Listing #')]

It avoids matching unrelated strings where the same fragment appears in the middle.

Good candidates for starts-with():

  • Property IDs: Listing #, MLS , Ref

  • Status labels: For Sale, if the rest of the text adds location or date

  • Price change notices: Price reduced

Case-insensitive matching in XPath 1.0

XPath 1.0 doesn't give you a native lowercase function, so case-insensitive matching usually means translate().

If a site alternates between Apartment, APARTMENT, and apartment, this works:

//span[contains(
  translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'),
  'apartment'
)]

This is ugly, but reliable. I still use it when scraping sites with inconsistent capitalization in amenity tags or listing status chips.

A practical example:

//li[contains(
  translate(normalize-space(.), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'),
  'pet friendly'
)]

What XPath 2.0 adds

If your tool supports XPath 2.0, you get better string functions. matches() is the big one because it supports regular expressions.

That helps when the text follows a pattern instead of a single stable substring. For example, if you need prices that look like currency strings:

//span[matches(., '\$[0-9,]+')]

And if you need suffix checks, ends-with() is useful:

//a[ends-with(normalize-space(.), 'details')]

Most browser automation and scraping stacks still operate in ways that make XPath 1.0 knowledge more useful day to day, so don't overbuild around 2.0-only functions unless your parser clearly supports them. If your Node workflows involve browser automation and scraping pipelines, this piece on web scraping with Node.js is a good companion.

A better heuristic: Use contains() for fuzzy labels, starts-with() for stable prefixes, and translate() when case inconsistency is the real problem.

Real World Examples in Python and JavaScript

Theory matters less than selectors you can paste into working code. Real estate scraping usually has three modes: parse saved HTML quickly, automate a live browser when the site is interactive, and debug selectors directly in DevTools before you write any script.

A hand-drawn illustration comparing Python and JavaScript code side-by-side for web scraping using XPath techniques.

Python with lxml for static listing HTML

If you've already saved the page HTML and the content is server-rendered, lxml is fast and simple.

from lxml import html

doc = html.fromstring("""
<div class="amenities">
  <span>Pet Friendly</span>
  <span>Pool</span>
  <span>In-unit Laundry</span>
</div>
""")

amenities = doc.xpath("//div[@class='amenities']//span[contains(text(), '')]/text()")
print(amenities)

That example grabs all amenity text under a known container. In a real scraper, I'd usually scope tightly to the listing facts block first, then use text matching only inside that block.

Python with Selenium for interactive pages

For browser automation, don't let contains(text(), ...) stand alone if a stronger anchor exists. Practitioner guidance is clear that for critical automation, text matching should be combined with structural predicates such as tag, class, or ancestor conditions, and Selenium needs element nodes, not /text() nodes, as discussed in this Katalon community thread on XPath contains text.

from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

driver = webdriver.Chrome()
driver.get("https://example.com/listings")

next_button = WebDriverWait(driver, 10).until(
    EC.element_to_be_clickable((
        By.XPATH,
        "//nav[contains(@class, 'pagination')]//button[contains(., 'Next')]"
    ))
)
next_button.click()

That locator is better than //button[contains(text(),'Next')] because it scopes the match to the pagination nav. On listing search results, generic words like “Next”, “Save”, and “Contact” often appear more than once.

If you work in Selenium regularly, this walkthrough on web scraping with Selenium and Python fits well with the selector patterns here.

A quick visual walkthrough helps when you want to see how these selector ideas behave in a browser automation workflow:

Browser console debugging with JavaScript

The fastest way to test XPath on a live site is the browser console with $x().

$x("//div[contains(@class, 'listing-card')]//span[contains(., 'Bedrooms')]")

Or to find a CTA on a property page:

$x("//button[contains(., 'Contact Agent')]")

I use this debugging sequence:

  1. Start narrow: target a known container like a listing card or facts panel.

  2. Test text assumptions: switch between text() and . if nested tags are involved.

  3. Inspect match count: if $x() returns multiple nodes, tighten with class, role, or ancestor constraints.

That last step matters. A selector that “works” but matches the wrong element is worse than one that fails loudly.

Common Pitfalls When Using XPath Contains Text

Most XPath failures come from one of three causes: bad assumptions about whitespace, bad assumptions about rendering timing, or bad assumptions about uniqueness. The syntax isn't usually the main problem.

An infographic titled common pitfalls when using XPath contains for text, listing five common challenges.

Whitespace breaks exact assumptions

HTML formatting introduces leading spaces, trailing spaces, and line breaks you won't notice in the rendered UI. That's why a button that looks like “Search” can still fail an exact text match.

Use normalize-space() when the problem smells like formatting:

//button[normalize-space(text())='Search']

Or, if the text may vary slightly:

//button[contains(normalize-space(.), 'Search')]

Hidden whitespace is often the first thing to suspect when a selector looks right but returns nothing.

Client-rendered apps return zero matches

This is the failure mode that generic tutorials skip. In modern React, Vue, and Angular pages, the initial DOM snapshot may not contain the text you're trying to match. A 2025 to 2026 projection cited by DataHen says 68% of modern real estate and marketplace listings use component-based rendering where initial DOM snapshots lack the target text, which explains why scrapers often see zero matches for strings like “for sale” or “Apartment” until hydration completes, as described in their write-up on fixing XPath contains text on rendered pages.

Symptoms usually look like this:

  • HTTP fetch works, XPath returns nothing: the server sent shell HTML, not rendered listing content.

  • Selenium finds the page but not the label: the browser loaded, but the app hasn't injected the text yet.

  • DevTools Elements tab shows the text later: hydration happened after your query ran.

Fix the strategy, not just the XPath:

  • Wait for rendering: use explicit waits for a stable container or visible marker.

  • Prefer browser automation when needed: Playwright or Selenium handles client-rendered pages better than plain HTTP requests.

  • Inspect the initial response: if the text isn't in raw HTML, no XPath tweak will magically find it.

Teams that don't want to build and maintain those extraction flows themselves often use data extraction services when modern rendering turns simple scrapers into browser orchestration projects.

Over-matching creates the wrong click

contains() is forgiving, but that also makes it sloppy if your substring is generic.

Bad:

//button[contains(., 'Save')]

On a property page, that might match “Save Search”, “Save Listing”, and “Save Seller Contact”.

Better:

//section[contains(@class,'listing-actions')]//button[contains(., 'Save Listing')]

The standard practical workflow is to use text() when the visible string is stable and exact, use contains(text(), "...") when copy varies or is partially dynamic, and add normalize-space() when formatting differences are causing failures, as summarized in this guide to XPath text selection workflow.

Best Practices for Reliable Text Selection

Use this as the short version.

  • Prefer attributes first: If a stable id, data-*, aria-label, or predictable class exists, use that before text.

  • Use exact text sparingly: text() is great when the string is stable. It's brittle when product teams edit labels.

  • Reach for contains when labels drift: contains(text(), "...") works well when the copy changes slightly or includes dynamic values.

  • Clean whitespace early: normalize-space() fixes a lot of “should work” failures caused by formatting.

  • Anchor text selectors to structure: Scope by tag, container, class, or ancestor so you don't match the wrong button.

  • Assume rendering may be delayed: On modern listing apps, a missing match can mean missing hydration, not a bad XPath.

The best XPath strategy isn't “always use contains.” It's using the weakest locator that's still reliable, and no weaker.


If you're building a real estate product and need listing, pricing, availability, or market data without babysitting brittle scrapers, RealtyAPI.io gives you a developer-first API layer for public real estate data across major platforms. You can prototype quickly, skip a lot of selector maintenance, and focus your engineering time on the product instead of DOM drift.