Through my previous questions, I developed a web-scraper that gets tennis ranking data.
- Webscraping tennis data: major refactor of code style and program modularity
- Webscraping tennis data 1.1: improving exception handling
In this version, I'm back with some modifications for my scraper based on my goals for the overarching project I have in mind. I understand this is a longer post, so I've divided my question into thematic sections - hopefully this makes it easier for readers to follow and provide feedback where they can!
What's changed in the code?
Rather than scrape ALL the weeks, and return a list of
WeeklyResult
s, the scraper now returns a result for a given week. This enables the scraper to scrape a week, pass it on to another function that utilizes the scraped result. Note that it is not truly "asynchronous" yet - more on that later.- To facilitate this,
Scraper
andMyProject
have been modified accordingly.
- To facilitate this,
Scraper
bug fix #1: certain weeks did not have actual ranking data for the No.1 on the website. Previously, each weekly result was loaded as anOptional
in case the player-cell element was empty. However, I had overlooked a case where the first available player-cell was non-empty, but didn't actually belong to the No.1 player.selectNumberOneRankCell
inscrapeWeekly
resolves this.
Scraper
bug fix #2: Further inspection showed that the emptyWeeklyResults
would be between stretches of the reign of a given player. With that trend in mind, plus the general likelihood that the current week's No.1 has a good chance to remain No.1 for the next week (generally), I changed the code to retain the No.1 player from the past week, in the case of an empty scraped result.- Added a new field
latestResult
and modifiedscrape
.
- Added a new field
WeeklyResult
&ScraperException
remain unchanged.
Code:
scraper
Package:
WeeklyResult.java
package scraper;
// A POJO that encapsulates a ranking week and the name of the corresponding No.1 player
public class WeeklyResult {
private final String week;
private final String playerName;
public WeeklyResult(final String week, final String playerName) {
this.week = week;
this.playerName = playerName;
}
public String getWeek() {
return week;
}
public String getPlayerName() {
return playerName;
}
}
ScraperException.java
package scraper;
public class ScraperException extends Exception {
final String message;
public ScraperException (String message) {
this.message = message;
}
public ScraperException (String message, Throwable cause) {
super(cause);
this.message = message;
}
@Override
public String toString() {
return this.message;
}
}
Scraper.java
package scraper;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import java.io.IOException;
import java.time.Duration;
import java.time.LocalDate;
import java.util.*;
import java.util.stream.Collectors;
public class Scraper {
private static final Logger logger = LogManager.getLogger(Scraper.class);
private final String urlPrefix;
private final String urlSuffix;
private final Duration timeout;
private final int totalTries;
private WeeklyResult latestResult;
public Scraper(final String urlPrefix, final String urlSuffix, final Duration timeout, final int totalTries) {
this.urlPrefix = urlPrefix;
this.urlSuffix = urlSuffix;
this.timeout = timeout;
this.totalTries = totalTries;
this.latestResult = new WeeklyResult("1973-08-16","N/A");
}
public WeeklyResult scrape(final String week) throws ScraperException {
// in the case the latest scraped data returns an "empty" weekly result, simply retain the latest No.1
// since it is likely he wouldn't have changed. A weekly result is deemed empty if no player or week info
// can be found on the ATP page.
this.latestResult = scrapeWeekly(week)
.orElse(new WeeklyResult(updateLatestWeekByOne(), this.latestResult.getPlayerName()));
return this.latestResult;
}
private Optional<WeeklyResult> scrapeWeekly(final String week) throws ScraperException {
final Document document = loadDocument(weeklyResultUrl(week));
final boolean numberOneDataExists = selectNumberOneRankCell(document).isPresent();
final Element playerCell = numberOneDataExists ? selectPlayerCellElement(document) : null;
return Optional.ofNullable(playerCell)
.map(element -> new WeeklyResult(week, element.text()));
}
public List<String> loadWeeks() throws ScraperException {
final Document document = loadDocument(urlPrefix);
final Elements elements = selectRankingWeeksElements(document);
final List<String> weeks = extractWeeks(elements);
return noEmptyElseThrow(weeks);
}
private Document loadDocument(final String url) throws ScraperException {
Document document = null;
for (int tries = 0; tries < this.totalTries; tries++) {
try {
document = Jsoup.connect(url).timeout((int) timeout.toMillis()).get();
break;
} catch (IOException e) {
if (tries == this.totalTries) {
throw new ScraperException("Error loading ATP website: ", e);
}
}
}
return document;
}
private static Elements selectRankingWeeksElements(final Document document) {
// extract ranking weeks from the dropdown menu
final Elements result = document.getElementsByAttributeValue("data-value", "rankDate")
.select("ul li");
Collections.reverse(result);
return result;
}
private static List<String> extractWeeks(final Collection<Element> elements) {
// refer to https://winterbe.com/posts/2014/07/31/java8-stream-tutorial-examples/
// and https://www.baeldung.com/java-maps-streams.
return elements.stream()
.map(Scraper::extractWeek)
.filter(week -> Optional.ofNullable(week).isPresent())
.collect(Collectors.toList());
}
private static List<String> noEmptyElseThrow(final List<String> weeks) throws ScraperException {
if (weeks.isEmpty()) {
throw new ScraperException("Cannot process empty data from the weeks calendar!");
} else {
return weeks;
}
}
private String weeklyResultUrl(final String week) {
return urlPrefix + "rankDate=" + week + urlSuffix;
}
private static Optional<Element> selectNumberOneRankCell(final Document document) {
final Element rankCell = selectPlayerRankCell(document);
return Optional.ofNullable(rankCell).filter(element -> numberOneRankCellExists(element));
}
private static Element selectPlayerCellElement(final Document document) {
return document.getElementsByClass("player-cell").first();
}
private static boolean numberOneRankCellExists(final Element rankCell) {
return rankCell.text().equals("1");
}
private static Element selectPlayerRankCell(final Document document) {
return document.getElementsByClass("rank-cell").first();
}
private static String extractWeek(final Element li) {
return li.text().replaceAll("\\.", "-");
}
private String updateLatestWeekByOne() {
return LocalDate.parse(this.latestResult.getWeek()).plusWeeks(1).toString();
}
}
myproject
Package:
MyProject.java
package myproject;
import org.apache.logging.log4j.Level;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.core.config.Configurator;
import scraper.Scraper;
import scraper.ScraperException;
import scraper.WeeklyResult;
import java.time.Duration;
import java.util.List;
// Main class to manage the visualization of player's legacy rankings
public class MyProject {
private static final Logger logger = LogManager.getRootLogger();
private static void utilizeScrapedResult(WeeklyResult weeklyResult) {
// pass the scraped result to the next stage of the visualization logic.
logger.info("Week: " + weeklyResult.getWeek() + " No.1: " + weeklyResult.getPlayerName());
}
public static void main(String[] args) {
Configurator.setRootLevel(Level.DEBUG);
final Scraper scraper =
new Scraper("https://www.atptour.com/en/rankings/singles?",
"&rankRange=0-100", Duration.ofSeconds(90), 3);
// The flow is as follows: scrape the latest weekly results (starting from 1973),
// then pass it to the ranking logic (IPR). Rinse and repeat
try {
final List<String> weeks = scraper.loadWeeks();
for (String week : weeks) {
WeeklyResult weeklyResult = scraper.scrape(week);
utilizeScrapedResult(weeklyResult);
}
} catch (ScraperException e) {
System.out.println(e.toString());
}
}
}
Scraper
Code: Optionals, Streams and Style Checks
I want to make sure I'm not abusing the concept of an
Optional
. I believe I'm not, since both the player-cell and rank-cell generally hold values relevant for us, but occasionally don't. One thing that was a bit sticky, though, was the fact that I didn't really have a neat way to relate rank-cell elements to player-cells. Logically, I wanted to say: "Therank-cell
element is empty if the first available one on the given page is not that of the actual No.1's. Select theplayer-cell
element if therankCell
is actually present." This is the best I could come up with:final boolean numberOneDataExists = selectNumberOneRankCell(document).isPresent(); final Element playerCell = numberOneDataExists ? selectPlayerCellElement(document) : null;
It'd be neat to know if there is a better way of achieving this.
Have I used Streams properly, specifically in the
selectNumberOneRankCell
&extractWeeks
functions?Any other style concerns would be appreciated. I think the addition of
latestResult
should be good, please let me know if I'm overlooking something!
MyProject
Code - Optimizing the Scraper design, Asynchronicity & Callbacks.
NOTE: Since this involves looking at my design, which could be off-topic, I will keep it short. If it is off-topic, please let me know and I'll remove it and repost to a more appropriate site.
In general, the code in MyProject
involves chaining separate pieces of logic. Eg. scrape a WeeklyResult
, pass it on to utilizeScrapedResult
, which does its work and constructs something, say a WeeklyRanking
, that is passed to the next logical section and so on. Would my current code structure be efficient to handle this as the number of separate pieces of logic increases, or should I switch to using callbacks as suggested?
- In this context, a given piece of logic would only be dependent on its output in the preceding timestamp. Eg. the
WeeklyRanking
for week B would have to be preceded by theWeeklyRanking
for week A, but theWeeklyResult
for week B could be scraped (and stored somewhere) before theWeeklyRanking
of week A is computed. On the flip side, aWeeklyResult
for week A cannot be constructed after theWeeklyResult
of week B. (I forget the mathematical term used to describe this relation...)
Feedback on any other aspects of the code that need to be addressed are welcome. If you made it this far, thank you for your time!