location
public static void main(String[] args) throws IOException {
var searchQuery = "iphone 13";
var searchUrl = "https://newyork.craigslist.org/search/moa?query=%s".formatted(URLEncoder.encode(searchQuery, StandardCharsets.UTF_8));
System.out.println("searchUrl = " + searchUrl);
try (var client = new WebClient()) {
client.getOptions().setCssEnabled(false);
client.getOptions().setJavaScriptEnabled(false);
client.getOptions().setThrowExceptionOnFailingStatusCode(false);
client.getOptions().setThrowExceptionOnScriptError(false);
HtmlPage page = client.getPage(searchUrl);
for (var htmlItem : page.<HtmlElement>getByXPath("//li[contains(@class,'cl-static-search-result')]")) {
HtmlAnchor itemAnchor = htmlItem.getFirstByXPath(".//a");
HtmlElement itemTitle = htmlItem.getFirstByXPath(".//div[@class='title']");
HtmlElement itemPrice = htmlItem.getFirstByXPath(".//div[@class='price']");
HtmlElement itemLocation = htmlItem.getFirstByXPath(".//div[@class='location']");
if (itemAnchor != null && itemTitle != null) {
System.out.printf("Name: %s, Price: %s, Location: %s, URL: %s%n", itemTitle.asNormalizedText(), itemPrice.asNormalizedText(), (itemLocation == null) ? "N/A" : itemLocation.asNormalizedText(), itemAnchor.getHrefAttribute());
}
}
}
}
VoilĂ , we have parsed the whole page and managed to extract the individual product items!
đź’ˇ We released a new feature that makes this whole process way simpler. You can now extract data from HTML with one simple API call. Please check out the documentation
here
for more information.
Converting to JSON
While the previous example provided an excellent overview on how to quickly scrape a website, we could take this a step further and convert the data into a structured and machine-readable format, such as JSON.
For that, we just need to make small changes to our code and introduce a special object to hold our results.
POJO
We add an additional POJO (Plain Old Java Object) class, which will represent the JSON object and hold our data. In modern Java, we can use the record
keyword to define a simple data holder class:
record Item(String title, BigDecimal price, String location, String url) {
}
Mapping
Now, we need to instantiate our mapper:
private final static ObjectMapper OBJECT_MAPPER = new ObjectMapper();
and use it to convert our data into JSON:
if (itemAnchor != null && itemTitle != null) {
var itemName = itemTitle.asNormalizedText();
var itemUrl = itemAnchor.getHrefAttribute();
var itemPriceText = itemPrice.asNormalizedText();
var itemLocationText = (itemLocation == null) ? "N/A" : itemLocation.asNormalizedText();
var item = new Item(itemName, new BigDecimal(itemPriceText.replace("$", "").replace(",", ".")), itemLocationText, itemUrl);
System.out.println("item = " + OBJECT_MAPPER.writeValueAsString(item));
}
Let’s take it a step further
Our project provided us so far with a quick overview on what web scraping is, its fundamental concepts, and how to set up our own crawler, using Java and XPath.
For now, it’s a relatively simple example, taking a defined search term and returning as JSON all the products sold in the area of New York City. What if we wanted to get data from more than one city? Let’s check it out.
Multi-city support
If you closely look at the URL we previously used for the search, you’ll notice, Craigslist catalogues its ads by city and keeps that information as part of the hostname of the URL.
For example, our ads for New York City are all behind the following URL:
https://newyork.craigslist.org
If we wanted to fetch the ads relevant to Boston, we’d be using https://boston.craigslist.org
instead.
Now, let’s say, we’d like to retrieve all iPhone 13 ads for the East Coast and, specifically, for New York, Boston, and Washington D.C. In that case, we’d simply revisit our code from
Fetching the page
and extend it a bit, to support the other cities as well:
public static void main(String[] args) throws IOException {
var searchQuery = "iphone 13";
var cities = List.of("newyork", "boston", "washingtondc");
try (var client = new WebClient()) {
client.getOptions().setCssEnabled(false);
client.getOptions().setJavaScriptEnabled(false);
client.getOptions().setThrowExceptionOnFailingStatusCode(false);
client.getOptions().setThrowExceptionOnScriptError(false);
for (String city : cities) {
var searchUrl = "https://%s.craigslist.org/search/moa?query=%s".formatted(city, URLEncoder.encode(searchQuery, StandardCharsets.UTF_8));
System.out.println("searchUrl = " + searchUrl);
HtmlPage page = client.getPage(searchUrl);
for (var htmlItem : page.<HtmlElement>getByXPath("//li[contains(@class,'cl-static-search-result')]")) {
HtmlAnchor itemAnchor = htmlItem.getFirstByXPath(".//a");
HtmlElement itemTitle = htmlItem.getFirstByXPath(".//div[@class='title']");
HtmlElement itemPrice = htmlItem.getFirstByXPath(".//div[@class='price']");
HtmlElement itemLocation = htmlItem.getFirstByXPath(".//div[@class='location']");
if (itemAnchor != null && itemTitle != null) {
var itemName = itemTitle.asNormalizedText();
var itemUrl = itemAnchor.getHrefAttribute();
var itemPriceText = itemPrice.asNormalizedText();
var itemLocationText = (itemLocation == null) ? "N/A" : itemLocation.asNormalizedText();
var item = new Item(itemName, new BigDecimal(itemPriceText.replace("$", "").replace(",", ".")), itemLocationText, itemUrl);
System.out.println("item = " + OBJECT_MAPPER.writeValueAsString(item));
}
}
}
}
}
What we now did was to add a list for the cities and iterate over it to fetch the ads for each city individually.
VoilĂ , we now run the request for each city individually!
Output customisation
You could encounter the situation where your crawler may have to support different output formats.
For example, you might have to support JSON and CSV. In that case you could simply add a switch to your code, which changes the output format depending on its value:
public static void main(String[] args) {
var outputType = args.length == 1 ? args[0].toLowerCase() : "";
var searchQuery = "iphone 13";
var cities = List.of("newyork", "boston", "washingtondc");
var results = fetchCities(cities, searchQuery);
switch (outputType) {
case "json" -> asJson(results);
case "csv" -> asCsv(results);
default -> System.out.println("unknown output type");
}
}
private static void asCsv(Map<String, List<Item>> results) {
System.out.println("city,title,price,location,url");
for (Map.Entry<String, List<Item>> entry : results.entrySet()) {
for (Item item : entry.getValue()) {
System.out.printf("%s,%s,%s,%s,%s%n", entry.getKey(), item.title, item.price, item.location, item.url);
}
}
}
The fetchCities
method would then return a list of items for each city, and the asJson()
and asCsv()
methods would convert the data into the respective format.
If you now pass json
as first argument to your crawler call, it will return a JSON object for each entry (just as we originally showed under
Mapping
). If you passed csv
, it would print a comma-separated line for each entry instead.
Increasing scale with parallelisation
If you are planning to scrape a large number of sites, you might face the issue of slow performance. In that case, you could consider parallelising your requests to speed up the process.
Let’s start by expanding our previous example by adding additional cities to our list:
var cities = List.of("newyork", "boston", "washingtondc", "losangeles", "chicago", "sanfrancisco", "seattle", "miami", "dallas", "denver");
Now, let’s measure the time it takes to fetch all the cities sequentially by wrapping the original code into a timed
method:
public static void main(String[] args) {
timed(() -> {
var outputType = args.length == 1 ? args[0].toLowerCase() : "";
var searchQuery = "iphone 13";
var cities = List.of("newyork", "boston", "washingtondc", "losangeles", "chicago", "sanfrancisco", "seattle", "miami", "dallas", "denver");
var results = fetchCities(cities, searchQuery);
switch (outputType) {
case "json" -> asJson(results);
case "csv" -> asCsv(results);
default -> System.out.println("unknown output type");
}
});
}
private static void timed(Runnable action) {
var start = System.currentTimeMillis();
action.run();
var end = System.currentTimeMillis();
System.out.printf("time = %dms%n", end - start);
}
It turns out it runs in around 15 seconds:
time = 15861ms
Not great, not terrible, but would not be acceptable for a large number of cities.
Let’s parallelise the requests by using Java’s virtual threads, which are great for I/O-bound tasks.
To do this, we need to change the fetchCities
method to scrape each city on a separate virtual thread. This is done by wrapping the code in a CompletableFuture
and using a VirtualThreadExecutor
:
private static Map<String, List<Item>> fetchCities(List<String> cities, String searchQuery) {
try (var client = new WebClient()) {
client.getOptions().setCssEnabled(false);
client.getOptions().setJavaScriptEnabled(false);
client.getOptions().setThrowExceptionOnFailingStatusCode(false);
client.getOptions().setThrowExceptionOnScriptError(false);
return cities.stream().map(city -> Map.entry(city, CompletableFuture.supplyAsync(() -> {
var searchUrl = "https://%s.craigslist.org/search/moa?query=%s".formatted(city, URLEncoder.encode(searchQuery, StandardCharsets.UTF_8));
System.out.println("fetching: " + searchUrl);
try {
var results = new ArrayList<Item>();
HtmlPage page = client.getPage(searchUrl);
for (var htmlItem : page.<HtmlElement>getByXPath("//li[contains(@class,'cl-static-search-result')]")) {
HtmlAnchor itemAnchor = htmlItem.getFirstByXPath(".//a");
HtmlElement itemTitle = htmlItem.getFirstByXPath(".//div[@class='title']");
HtmlElement itemPrice = htmlItem.getFirstByXPath(".//div[@class='price']");
HtmlElement itemLocation = htmlItem.getFirstByXPath(".//div[@class='location']");
if (itemAnchor != null && itemTitle != null) {
var itemName = itemTitle.asNormalizedText();
var itemUrl = itemAnchor.getHrefAttribute();
var itemPriceText = itemPrice.asNormalizedText();
var itemLocationText = (itemLocation == null) ? "N/A" : itemLocation.asNormalizedText();
var item = new Item(itemName, new BigDecimal(itemPriceText.replace("$", "").replace(",", ".")), itemLocationText, itemUrl);
results.add(item);
}
}
return results;
} catch (IOException e) {
throw new RuntimeException(e);
}
}, Executors.newVirtualThreadPerTaskExecutor()))).toList()
.stream()
.collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().join()));
}
}
Now, if you run it again, you’ll see that the time has been reduced significantly:
time: 3473ms
Next steps
The examples mentioned so far provided a bit of insight on how to scrape Craigslist, but there are certainly still a few areas which could be improved.
- Pagination handling
- Support for more than one criterion
- and more
Of course, there’s a lot more to scraping than just fetching a single HTML page and running a few XPath expressions. Especially when it comes to distributed scraping, fully handling JavaScript, and CAPTCHAs, the topic can quickly become very complex. If you like it and would like to have these things handled automatically, then please simply check out our
web scraping API
. The first 1,000 API calls are on us!
Even more
We are almost at the end of this post, so thanks for staying with us until now, but we’d still have a couple of recommended articles for you.
Don’t get blocked
Also check out our recent blog post on
Web Scraping without getting blocked
, which goes into details on how to optimise your scraping approach in order to avoid being blocked by anti-scraping measures.
Scraping with Chrome and full JavaScript support
While HtmlUnit is a wonderful headless browser, you may still want to check out our other article on the
Introduction to Headless Chrome
, as this will provide you with additional insight on how to use Chrome’s headless mode, which features full JavaScript support, just as you’d expect it from your daily driver browser.
One CSS selector, please
CSS selectors are used for much more these days, than just applying colours and spacing. Very often they are used in the very same context as XPath expressions and if you happen to prefer CSS selectors, you should definitely also check out our
tutorial on HTML parsing with Java using jsoup
.
Python maybe?
Python has been one of the most popular languages for years at this point and is, in fact, commonly used for web scraping as well. If Python is your choice of language, you might just like our other
guide on using Python for scraping web pages
.
Or Groovy?
I’ve you like Java you’re going to LOVE Groovy. Check out our guide to
web scraping with Groovy
You may also like our guide about
web scraping with Kotlin
What about Scala?
Of course, we didn’t forget about
web scraping with Scala
, you should check it out!
Code sample
You can find the full source code of this example in our
Github repository
.