Pokemon TCG Stock Tracker

Build a Java app that discovers new Pokemon TCG listings and stock changes, then alerts you on Discord.

Introduction

30 Second Summary

Pokemon TCG restocks sell out in minutes, and new products appear on store pages without warning. By the time you check manually, the booster packs you wanted are already gone.

In this project, you will build a self-hosted stock monitoring application in Java that automatically discovers new Pokemon TCG listings on NZ retailer pages, tracks stock changes, and sends you a Discord alert the moment something becomes available.

What You'll Build

A Maven-based Java application that discovers new Pokemon TCG products via JB Hi-Fi NZ's JSON API, scrapes individual product pages from Hobby Lords, detects stock changes and new listings, and fires rich Discord alerts, deployed on a Raspberry Pi 5 running 24/7.

By the end of this project, you'll have:

  • A product discovery engine that fetches JB Hi-Fi NZ's JSON API to find new Pokemon TCG listings automatically, plus a Jsoup scraper for Hobby Lords product pages.
  • A Discord webhook integration that sends rich embed alerts for restocks and new listings with product names, prices, and direct buy links.
  • A fully deployed systemd service on your Raspberry Pi 5 that auto-restarts on crashes and monitors products around the clock.
  • Secret Mission: Add price-drop tracking that alerts you when a product drops below a threshold you configure.

Are there any prerequisites?

You should have basic Java knowledge (classes, methods, variables) and a Mac with JDK 24.0.1 installed.

You also need a Discord server where you have permission to create webhooks. A Raspberry Pi 5 (4GB) with a microSD card is required for the deployment step.

Set Up the Maven Project

With Maven ready on your Mac, it is time to create the project that will become your Pokemon stock tracker.

In this step, you will set up a Maven project with the right dependency for web scraping, create the directory structure Java expects, and prepare a configuration file that the app will read at runtime.

In this step, get ready to:
  • Create the project directory and configure pom.xml with the Jsoup dependency.
  • Set up the source directory and a placeholder App.java to verify the build.
  • Create config.properties for runtime settings like the check interval and product URLs.
Create the project directory and pom.xml

Every Maven project starts with a pom.xml file that declares your project coordinates, dependencies, and build settings.

  • Press Cmd+Space to open Spotlight Search.
  • Type Terminal and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir pokemon-stock-tracker
cd pokemon-stock-tracker
  • Confirm the folder exists by running:
ls ~/Desktop | grep pokemon

You should see pokemon-stock-tracker in the output.

  • Create pom.xml in the project root by running:
touch pom.xml
  • Open pom.xml in your code editor and paste the following content:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>nz.stocktracker</groupId>
    <artifactId>pokemon-stock-tracker</artifactId>
    <version>1.0</version>
    <packaging>jar</packaging>

    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.jsoup</groupId>
            <artifactId>jsoup</artifactId>
            <version>1.22.2</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <configuration>
                    <archive>
                        <manifest>
                            <mainClass>stocktracker.App</mainClass>
                            <addClasspath>true</addClasspath>
                            <classpathPrefix>lib/</classpathPrefix>
                        </manifest>
                    </archive>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

What does this pom.xml configure?

  • The <groupId> and <artifactId> identify your project uniquely in the Maven ecosystem.
  • The compiler properties target Java 17 because the Raspberry Pi will run that version.
  • Jsoup (1.22.2) is your web scraping library. It fetches pages and parses HTML with CSS selectors, all in a single zero-dependency package.
  • The maven-jar-plugin sets the main class so you can run the JAR directly. The classpathPrefix tells Java to look in a lib/ folder for Jsoup at runtime.
  • Save pom.xml.

You will not see a visible change yet. The next substep creates the source code that lets Maven build successfully.

Editor not opening the file?

If you are using VS Code, you can open the entire project folder by running code . from inside the pokemon-stock-tracker directory. For IntelliJ, use File > Open and select the pokemon-stock-tracker folder.

Help me open my project folder in a code editor.

✔️ Awesome, I've got everything!

Great. Make sure pom.xml is saved before moving on.

ⓧ I'd like to double check the full code

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>nz.stocktracker</groupId>
    <artifactId>pokemon-stock-tracker</artifactId>
    <version>1.0</version>
    <packaging>jar</packaging>

    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.jsoup</groupId>
            <artifactId>jsoup</artifactId>
            <version>1.22.2</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <configuration>
                    <archive>
                        <manifest>
                            <mainClass>stocktracker.App</mainClass>
                            <addClasspath>true</addClasspath>
                            <classpathPrefix>lib/</classpathPrefix>
                        </manifest>
                    </archive>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>
Create the source directory and App.java

Maven expects source code in a specific directory structure. Your Java files live under src/main/java/ followed by your package path.

  • Create the full directory structure by running this command in your terminal (make sure you are inside the pokemon-stock-tracker folder):
mkdir -p src/main/java/stocktracker
  • In your code editor, create a new file at src/main/java/stocktracker/App.java and paste the following:
package stocktracker;

public class App {
    public static void main(String[] args) {
        System.out.println("Pokemon Stock Tracker starting...");
    }
}

What does this code do?

This is a minimal placeholder class with a main method. Its only job right now is to prove the project compiles and runs. You will replace this with the full tracker logic in a later step.

  • Save App.java.
  • Back in your terminal, build the project by running:
mvn package

Maven will download Jsoup, compile your code, and produce a JAR. You should see BUILD SUCCESS near the end of the output.

Seeing BUILD FAILURE?

Check that your pom.xml is saved and that the XML is well-formed (no unclosed tags or typos). Also confirm App.java is at exactly src/main/java/stocktracker/App.java inside the pokemon-stock-tracker folder. The package name in the file must match the directory path.

Help me fix my Maven build failure.

✔️ Awesome, I've got everything!

Great. Make sure App.java is saved and you saw BUILD SUCCESS in your terminal.

ⓧ I'd like to double check the full code

package stocktracker;

public class App {
    public static void main(String[] args) {
        System.out.println("Pokemon Stock Tracker starting...");
    }
}
Create the configuration file

The tracker needs to know which product URLs to monitor, how often to check, and where to send alerts. Storing these in an external config.properties file means you can change settings on the Pi without rebuilding the JAR.

  • In your code editor, create a new file called config.properties in the root of your pokemon-stock-tracker folder (next to pom.xml) and paste the following:
# Discord webhook URL - create one in your server's channel settings
discord.webhook.url=your-webhook-url-here

# Check interval in seconds (300 = 5 minutes)
check.interval.seconds=300

# Store URLs for automatic product discovery (JB Hi-Fi uses Shopify JSON API)
store.url.1=https://www.jbhifi.co.nz

# Individual product URLs to monitor (for stores without a discovery API)
product.url.1=https://www.hobbylords.co.nz/products/single/pokemon-tcg-scarlet-&-violet-stellar-crown-booster-pack

What do these properties control?

  • discord.webhook.url is where alert messages will be sent. You will set this up in a later step.
  • check.interval.seconds controls how often the app polls. 300 seconds (5 minutes) is respectful to the stores while still catching restocks and new listings quickly.
  • store.url.1 points at a store's base URL. JB Hi-Fi NZ runs on Shopify, which exposes a public /products.json API. Your app will call this API to discover every Pokemon TCG product automatically, including new listings.
  • product.url.1 is a direct product page URL for stores that do not offer a discovery API. The app scrapes these pages individually using Jsoup.
  • Save config.properties.

You will not see a visible change from this file yet. The app will read it once you build the main loop in a later step.

Two ways to track products

For JB Hi-Fi, you do not need to add individual product URLs. The app automatically discovers all Pokemon TCG products from the store's JSON API. New products appear in your Discord the moment JB Hi-Fi lists them.

For Hobby Lords (or any store without a discovery API), add individual product page URLs using the product.url. numbering pattern (product.url.2, product.url.3, etc.).

✔️ Awesome, I've got everything!

Great. Make sure config.properties is saved in the project root alongside pom.xml.

ⓧ I'd like to double check the full code

# Discord webhook URL - create one in your server's channel settings
discord.webhook.url=your-webhook-url-here

# Check interval in seconds (300 = 5 minutes)
check.interval.seconds=300

# Store URLs for automatic product discovery (JB Hi-Fi uses Shopify JSON API)
store.url.1=https://www.jbhifi.co.nz

# Individual product URLs to monitor (for stores without a discovery API)
product.url.1=https://www.hobbylords.co.nz/products/single/pokemon-tcg-scarlet-&-violet-stellar-crown-booster-pack

Your project foundation is solid. You have a working Maven build, the Jsoup dependency ready to go, and a configuration file prepared for when the tracker goes live. Next up, you will build the scrapers that actually fetch product pages and detect stock status.

Build the Scrapers

Your Maven project compiles, your dependencies are locked in, and your config file is set up with two different approaches: a store URL for automatic product discovery and a direct product URL for individual scraping.

Now you need the code that actually talks to these stores. In this step, you will build two scrapers using different strategies. The JB Hi-Fi scraper uses a public JSON API to discover every Pokemon TCG product automatically. The Hobby Lords scraper uses Jsoup to parse individual product pages.

In this step, get ready to:
  • Create the Product data class and the StoreScraper interface with both single-product and discovery methods.
  • Implement a JB Hi-Fi NZ scraper that discovers Pokemon TCG products via the Shopify JSON API.
  • Implement a Hobby Lords scraper for individual product pages and test both scrapers live.
Create the data model and interface

Before writing any scraping logic, you need two foundational pieces. A data class holds the information you extract from each product page. An interface defines the contract every store scraper must follow.

What is the strategy pattern?

The strategy pattern lets you define a family of algorithms (one per store) behind a shared interface. Your main application code calls the interface methods without caring which store it is talking to.

Later, adding a new store is as simple as writing a new class that implements the same interface. No changes to the existing code.

  • Create a new file called Product.java inside src/main/java/stocktracker/ and paste in this code:
package stocktracker;

public class Product {
    private final String url;
    private final String name;
    private final String price;
    private final boolean inStock;
    private final String store;

    public Product(String url, String name, String price, boolean inStock, String store) {
        this.url = url;
        this.name = name;
        this.price = price;
        this.inStock = inStock;
        this.store = store;
    }

    public String getUrl() { return url; }
    public String getName() { return name; }
    public String getPrice() { return price; }
    public boolean isInStock() { return inStock; }
    public String getStore() { return store; }

    @Override
    public String toString() {
        return String.format("[%s] %s - %s (%s)", store, name, price, inStock ? "IN STOCK" : "OUT OF STOCK");
    }
}

What does this code do?

Product is an immutable data class. Each field is final, meaning once a Product is created it cannot be changed.

The toString() method gives you a readable one-line summary when you print a Product to the console. This is useful for logging during development.

  • Save Product.java.
  • Create another new file called StoreScraper.java in the same src/main/java/stocktracker/ folder and paste this code:
package stocktracker;

import java.io.IOException;
import java.util.List;

public interface StoreScraper {
    // Returns the human-readable store name
    String getStoreName();

    // Scrapes a single product page and returns its details
    Product scrape(String url) throws IOException;

    // Discovers all Pokemon TCG products from a store's catalog
    List<Product> discoverProducts(String storeBaseUrl) throws IOException;
}

What does this interface define?

Every store scraper must implement three methods. getStoreName() returns a human-readable name. scrape() takes a single product URL and returns a Product object.

The new discoverProducts() method is the key addition. It takes a store's base URL and returns a list of every Pokemon TCG product it can find. For stores with an API (like JB Hi-Fi's Shopify JSON endpoint), this returns dozens of products in one call. For stores without an API, it can return an empty list.

  • Save StoreScraper.java.
  • Verify both files compile by running this command in your terminal from the pokemon-stock-tracker project folder:
mvn compile
  • You should see BUILD SUCCESS in the terminal output.

Seeing a compilation error?

Check that both files are in the src/main/java/stocktracker/ directory. The package stocktracker; line at the top of each file must match this folder structure exactly.

Also confirm that the class name in each file matches the filename. Product.java must contain public class Product, and StoreScraper.java must contain public interface StoreScraper.

Help me debug my Maven compilation error.

✔️ Awesome, I've got everything!

Great. Make sure both Product.java and StoreScraper.java are saved before moving on.

ⓧ I'd like to double check the full code

package stocktracker;

public class Product {
    private final String url;
    private final String name;
    private final String price;
    private final boolean inStock;
    private final String store;

    public Product(String url, String name, String price, boolean inStock, String store) {
        this.url = url;
        this.name = name;
        this.price = price;
        this.inStock = inStock;
        this.store = store;
    }

    public String getUrl() { return url; }
    public String getName() { return name; }
    public String getPrice() { return price; }
    public boolean isInStock() { return inStock; }
    public String getStore() { return store; }

    @Override
    public String toString() {
        return String.format("[%s] %s - %s (%s)", store, name, price, inStock ? "IN STOCK" : "OUT OF STOCK");
    }
}
package stocktracker;

import java.io.IOException;
import java.util.List;

public interface StoreScraper {
    String getStoreName();
    Product scrape(String url) throws IOException;
    List<Product> discoverProducts(String storeBaseUrl) throws IOException;
}
Implement the JB Hi-Fi scraper

JB Hi-Fi NZ runs on Shopify, which exposes a public /products.json API that returns structured product data. Instead of scraping HTML for individual products, your scraper will call this API, filter for Pokemon TCG products, and build Product objects directly from the JSON response.

  • Create a new file called JBHiFiScraper.java in src/main/java/stocktracker/ and paste in this code:
package stocktracker;

import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.ArrayList;
import java.util.List;

public class JBHiFiScraper implements StoreScraper {
    private static final String USER_AGENT = "Mozilla/5.0 (compatible; PokemonStockTracker/1.0)";
    private final HttpClient httpClient;

    public JBHiFiScraper() {
        this.httpClient = HttpClient.newHttpClient();
    }

    @Override
    public String getStoreName() {
        return "JB Hi-Fi";
    }

    @Override
    public Product scrape(String url) throws IOException {
        // Fetch the full HTML page for a single product
        Document doc = Jsoup.connect(url)
                .userAgent(USER_AGENT)
                .timeout(10000)
                .get();

        // Extract the product name from the h1 heading
        String name = "Unknown Product";
        Element titleElement = doc.selectFirst("h1");
        if (titleElement != null) {
            name = titleElement.text().trim();
        }

        // Check if the page contains "sold out" anywhere in the body text
        String pageText = doc.body().text().toLowerCase();
        boolean inStock = !pageText.contains("sold out");

        // Extract the price from any element with "price" in its class name
        String price = "N/A";
        Element priceElement = doc.selectFirst("[class*=price]");
        if (priceElement != null) {
            price = priceElement.text().trim();
        }

        return new Product(url, name, price, inStock, getStoreName());
    }

    @Override
    public List<Product> discoverProducts(String storeBaseUrl) throws IOException {
        List<Product> products = new ArrayList<>();
        int page = 1;

        // Paginate through the Shopify JSON API (max 250 products per page)
        while (true) {
            String apiUrl = storeBaseUrl + "/products.json?limit=250&page=" + page;
            HttpRequest request = HttpRequest.newBuilder()
                    .uri(URI.create(apiUrl))
                    .header("User-Agent", USER_AGENT)
                    .GET()
                    .build();

            HttpResponse<String> response;
            try {
                response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
            } catch (InterruptedException e) {
                throw new IOException("Interrupted during API call", e);
            }

            if (response.statusCode() != 200) {
                throw new IOException("API returned status " + response.statusCode());
            }

            String json = response.body();
            List<Product> pageProducts = parseProductsJson(json, storeBaseUrl);

            if (pageProducts.isEmpty()) {
                break;
            }

            products.addAll(pageProducts);
            page++;
        }

        System.out.println("  Discovered " + products.size() + " Pokemon TCG products from JB Hi-Fi");
        return products;
    }

    // Parses the Shopify JSON response and filters for Pokemon TCG products
    private List<Product> parseProductsJson(String json, String storeBaseUrl) {
        List<Product> products = new ArrayList<>();

        // Find the products array in the JSON
        int searchFrom = 0;
        while (true) {
            int titleIdx = json.indexOf("\"title\":\"", searchFrom);
            if (titleIdx == -1) break;

            // Extract the title value
            int titleStart = titleIdx + 9;
            int titleEnd = json.indexOf("\"", titleStart);
            String title = json.substring(titleStart, titleEnd);

            // Extract the handle (URL slug) for this product
            int handleIdx = json.indexOf("\"handle\":\"", titleEnd);
            int handleStart = handleIdx + 10;
            int handleEnd = json.indexOf("\"", handleStart);
            String handle = json.substring(handleStart, handleEnd);

            // Extract the product_type
            int typeIdx = json.indexOf("\"product_type\":\"", handleEnd);
            String productType = "";
            if (typeIdx != -1 && typeIdx < handleEnd + 500) {
                int typeStart = typeIdx + 16;
                int typeEnd = json.indexOf("\"", typeStart);
                productType = json.substring(typeStart, typeEnd);
            }

            // Extract tags to check for Pokemon content
            int tagsIdx = json.indexOf("\"tags\":", handleEnd);
            String tagsSection = "";
            if (tagsIdx != -1 && tagsIdx < handleEnd + 1000) {
                int tagsEnd = json.indexOf("]", tagsIdx);
                tagsSection = json.substring(tagsIdx, tagsEnd).toLowerCase();
            }

            // Filter: only keep Pokemon TCG products
            String titleLower = title.toLowerCase();
            String typeLower = productType.toLowerCase();
            boolean isPokemon = titleLower.contains("pokemon") || titleLower.contains("pokémon")
                    || tagsSection.contains("pokemon") || tagsSection.contains("pokémon");
            boolean isTCG = titleLower.contains("tcg") || titleLower.contains("trading card")
                    || titleLower.contains("booster") || titleLower.contains("trainer box")
                    || typeLower.contains("trading card") || typeLower.contains("collectible");

            if (isPokemon || (isTCG && isPokemon)) {
                // Extract price from first variant
                String price = "N/A";
                int priceIdx = json.indexOf("\"price\":\"", handleEnd);
                if (priceIdx != -1 && priceIdx < handleEnd + 2000) {
                    int priceStart = priceIdx + 9;
                    int priceEnd = json.indexOf("\"", priceStart);
                    price = "$" + json.substring(priceStart, priceEnd);
                }

                // Extract availability from first variant
                boolean available = false;
                int availIdx = json.indexOf("\"available\":", handleEnd);
                if (availIdx != -1 && availIdx < handleEnd + 2000) {
                    available = json.substring(availIdx + 12, availIdx + 16).startsWith("true");
                }

                String productUrl = storeBaseUrl + "/products/" + handle;
                products.add(new Product(productUrl, title, price, available, getStoreName()));
            }

            // Move past this product to find the next one
            searchFrom = titleEnd + 1;
        }

        return products;
    }
}

What does this scraper do?

  • scrape() still works for individual product pages, parsing HTML with Jsoup the same way as before.
  • discoverProducts() calls /products.json?limit=250&page=N to fetch the store's full catalog as structured JSON. It paginates until an empty page is returned.
  • parseProductsJson() filters the raw JSON for products with "pokemon" in the title or tags. It extracts the title, price, availability, and builds the product URL from the handle field.
  • This approach means the scraper automatically finds new products the moment JB Hi-Fi lists them, without you adding any URLs to the config.
  • Save JBHiFiScraper.java.
  • Run mvn compile again to confirm it compiles with no errors.

Cannot find symbol errors?

If you see errors about Product or StoreScraper not being found, check that all three files (Product.java, StoreScraper.java, JBHiFiScraper.java) are in the same src/main/java/stocktracker/ directory and each starts with package stocktracker; on line 1.

Help me fix my JBHiFiScraper compilation errors.

✔️ Awesome, I've got everything!

Great. Make sure JBHiFiScraper.java is saved before moving on.

ⓧ I'd like to double check the full code

package stocktracker;

import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.ArrayList;
import java.util.List;

public class JBHiFiScraper implements StoreScraper {
    private static final String USER_AGENT = "Mozilla/5.0 (compatible; PokemonStockTracker/1.0)";
    private final HttpClient httpClient;

    public JBHiFiScraper() {
        this.httpClient = HttpClient.newHttpClient();
    }

    @Override
    public String getStoreName() {
        return "JB Hi-Fi";
    }

    @Override
    public Product scrape(String url) throws IOException {
        Document doc = Jsoup.connect(url)
                .userAgent(USER_AGENT)
                .timeout(10000)
                .get();

        String name = "Unknown Product";
        Element titleElement = doc.selectFirst("h1");
        if (titleElement != null) {
            name = titleElement.text().trim();
        }

        String pageText = doc.body().text().toLowerCase();
        boolean inStock = !pageText.contains("sold out");

        String price = "N/A";
        Element priceElement = doc.selectFirst("[class*=price]");
        if (priceElement != null) {
            price = priceElement.text().trim();
        }

        return new Product(url, name, price, inStock, getStoreName());
    }

    @Override
    public List<Product> discoverProducts(String storeBaseUrl) throws IOException {
        List<Product> products = new ArrayList<>();
        int page = 1;

        while (true) {
            String apiUrl = storeBaseUrl + "/products.json?limit=250&page=" + page;
            HttpRequest request = HttpRequest.newBuilder()
                    .uri(URI.create(apiUrl))
                    .header("User-Agent", USER_AGENT)
                    .GET()
                    .build();

            HttpResponse<String> response;
            try {
                response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
            } catch (InterruptedException e) {
                throw new IOException("Interrupted during API call", e);
            }

            if (response.statusCode() != 200) {
                throw new IOException("API returned status " + response.statusCode());
            }

            String json = response.body();
            List<Product> pageProducts = parseProductsJson(json, storeBaseUrl);

            if (pageProducts.isEmpty()) {
                break;
            }

            products.addAll(pageProducts);
            page++;
        }

        System.out.println("  Discovered " + products.size() + " Pokemon TCG products from JB Hi-Fi");
        return products;
    }

    private List<Product> parseProductsJson(String json, String storeBaseUrl) {
        List<Product> products = new ArrayList<>();

        int searchFrom = 0;
        while (true) {
            int titleIdx = json.indexOf("\"title\":\"", searchFrom);
            if (titleIdx == -1) break;

            int titleStart = titleIdx + 9;
            int titleEnd = json.indexOf("\"", titleStart);
            String title = json.substring(titleStart, titleEnd);

            int handleIdx = json.indexOf("\"handle\":\"", titleEnd);
            int handleStart = handleIdx + 10;
            int handleEnd = json.indexOf("\"", handleStart);
            String handle = json.substring(handleStart, handleEnd);

            int typeIdx = json.indexOf("\"product_type\":\"", handleEnd);
            String productType = "";
            if (typeIdx != -1 && typeIdx < handleEnd + 500) {
                int typeStart = typeIdx + 16;
                int typeEnd = json.indexOf("\"", typeStart);
                productType = json.substring(typeStart, typeEnd);
            }

            int tagsIdx = json.indexOf("\"tags\":", handleEnd);
            String tagsSection = "";
            if (tagsIdx != -1 && tagsIdx < handleEnd + 1000) {
                int tagsEnd = json.indexOf("]", tagsIdx);
                tagsSection = json.substring(tagsIdx, tagsEnd).toLowerCase();
            }

            String titleLower = title.toLowerCase();
            String typeLower = productType.toLowerCase();
            boolean isPokemon = titleLower.contains("pokemon") || titleLower.contains("pok\u00e9mon")
                    || tagsSection.contains("pokemon") || tagsSection.contains("pok\u00e9mon");
            boolean isTCG = titleLower.contains("tcg") || titleLower.contains("trading card")
                    || titleLower.contains("booster") || titleLower.contains("trainer box")
                    || typeLower.contains("trading card") || typeLower.contains("collectible");

            if (isPokemon || (isTCG && isPokemon)) {
                String price = "N/A";
                int priceIdx = json.indexOf("\"price\":\"", handleEnd);
                if (priceIdx != -1 && priceIdx < handleEnd + 2000) {
                    int priceStart = priceIdx + 9;
                    int priceEnd = json.indexOf("\"", priceStart);
                    price = "$" + json.substring(priceStart, priceEnd);
                }

                boolean available = false;
                int availIdx = json.indexOf("\"available\":", handleEnd);
                if (availIdx != -1 && availIdx < handleEnd + 2000) {
                    available = json.substring(availIdx + 12, availIdx + 16).startsWith("true");
                }

                String productUrl = storeBaseUrl + "/products/" + handle;
                products.add(new Product(productUrl, title, price, available, getStoreName()));
            }

            searchFrom = titleEnd + 1;
        }

        return products;
    }
}
Implement the Hobby Lords scraper and test

Hobby Lords uses a different page structure. It shows "Sold Out" with capital letters when a product is unavailable, and displays the price in an h3 element prefixed with a dollar sign.

  • Create a new file called HobbyLordsScraper.java in src/main/java/stocktracker/ and paste in this code:
package stocktracker;

import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import java.io.IOException;
import java.util.Collections;
import java.util.List;

public class HobbyLordsScraper implements StoreScraper {
    private static final String USER_AGENT = "Mozilla/5.0 (compatible; PokemonStockTracker/1.0)";

    @Override
    public String getStoreName() {
        return "Hobby Lords";
    }

    @Override
    public Product scrape(String url) throws IOException {
        // Fetch the full HTML page
        Document doc = Jsoup.connect(url)
                .userAgent(USER_AGENT)
                .timeout(10000)
                .get();

        // Extract product name from the h1 heading
        String name = "Unknown Product";
        Element titleElement = doc.selectFirst("h1");
        if (titleElement != null) {
            name = titleElement.text().trim();
        }

        // Hobby Lords shows "Sold Out" (with capitals) when unavailable
        String pageText = doc.body().text();
        boolean inStock = !pageText.contains("Sold Out");

        // Price lives in an h3 element and starts with $
        String price = "N/A";
        Element priceElement = doc.selectFirst("h3");
        if (priceElement != null) {
            String priceText = priceElement.text().trim();
            if (priceText.startsWith("$")) {
                price = priceText;
            }
        }

        return new Product(url, name, price, inStock, getStoreName());
    }

    @Override
    public List<Product> discoverProducts(String storeBaseUrl) throws IOException {
        // Hobby Lords uses JavaScript rendering for their listing pages,
        // so automated discovery is not possible with Jsoup alone.
        // Individual product URLs must be added to config.properties.
        return Collections.emptyList();
    }
}

How is this different from the JB Hi-Fi scraper?

  • Hobby Lords does not run on Shopify and their listing pages use JavaScript to render product grids. Jsoup can only parse static HTML, so discovery is not possible here.
  • Instead, Hobby Lords uses direct product page scraping via scrape(). You add individual product URLs to your config and the scraper reads each page's HTML.
  • discoverProducts() returns an empty list for Hobby Lords. The strategy pattern means the main application does not need to know which approach a store uses.
  • Save HobbyLordsScraper.java.
  • Run mvn compile to verify all four Java files compile together.

Seeing errors about Jsoup imports?

Make sure your pom.xml still has the Jsoup dependency with version 1.22.2. If Maven cannot resolve the dependency, try running mvn dependency:resolve to force a fresh download.

Help me fix my Jsoup import errors.

✔️ Awesome, I've got everything!

Great. Make sure HobbyLordsScraper.java is saved before moving on.

ⓧ I'd like to double check the full code

package stocktracker;

import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import java.io.IOException;
import java.util.Collections;
import java.util.List;

public class HobbyLordsScraper implements StoreScraper {
    private static final String USER_AGENT = "Mozilla/5.0 (compatible; PokemonStockTracker/1.0)";

    @Override
    public String getStoreName() {
        return "Hobby Lords";
    }

    @Override
    public Product scrape(String url) throws IOException {
        Document doc = Jsoup.connect(url)
                .userAgent(USER_AGENT)
                .timeout(10000)
                .get();

        String name = "Unknown Product";
        Element titleElement = doc.selectFirst("h1");
        if (titleElement != null) {
            name = titleElement.text().trim();
        }

        String pageText = doc.body().text();
        boolean inStock = !pageText.contains("Sold Out");

        String price = "N/A";
        Element priceElement = doc.selectFirst("h3");
        if (priceElement != null) {
            String priceText = priceElement.text().trim();
            if (priceText.startsWith("$")) {
                price = priceText;
            }
        }

        return new Product(url, name, price, inStock, getStoreName());
    }

    @Override
    public List<Product> discoverProducts(String storeBaseUrl) throws IOException {
        return Collections.emptyList();
    }
}

Now test both scrapers live. You will temporarily update App.java to call the JB Hi-Fi discovery method and the Hobby Lords single-product scraper, then print the results.

  • Open App.java in src/main/java/stocktracker/ and replace the entire file contents with this temporary test code:
package stocktracker;

import java.io.IOException;
import java.util.List;

public class App {
    public static void main(String[] args) {
        System.out.println("Pokemon Stock Tracker starting...");

        // Test JB Hi-Fi discovery via JSON API
        JBHiFiScraper jbScraper = new JBHiFiScraper();
        try {
            List<Product> discovered = jbScraper.discoverProducts("https://www.jbhifi.co.nz");
            System.out.println("\nDiscovered " + discovered.size() + " Pokemon TCG products:");
            for (Product p : discovered) {
                System.out.println("  " + p);
            }
        } catch (IOException e) {
            System.err.println("JB Hi-Fi discovery failed: " + e.getMessage());
        }

        // Test Hobby Lords individual product scrape
        HobbyLordsScraper hlScraper = new HobbyLordsScraper();
        try {
            Product hlProduct = hlScraper.scrape(
                "https://www.hobbylords.co.nz/products/single/pokemon-tcg-scarlet-&-violet-stellar-crown-booster-pack");
            System.out.println("\n" + hlProduct);
        } catch (IOException e) {
            System.err.println("Hobby Lords scrape failed: " + e.getMessage());
        }
    }
}

What does this test code do?

It calls discoverProducts() on the JB Hi-Fi scraper with the store's base URL. The scraper hits the /products.json API, filters for Pokemon TCG products, and prints every one it finds.

It also calls scrape() on the Hobby Lords scraper with a single product URL to verify HTML parsing still works. Both results print using the Product.toString() method you wrote earlier.

  • Save App.java.
  • Build the JAR and copy the Jsoup dependency into target/lib/ by running these commands:
mvn package
mvn dependency:copy-dependencies -DoutputDirectory=target/lib
  • Run the application by running this command:
java -jar target/pokemon-stock-tracker-1.0.jar
  • You should see output like this (product names and prices will vary depending on what is currently listed):
Pokemon Stock Tracker starting...
  Discovered 12 Pokemon TCG products from JB Hi-Fi

Discovered 12 Pokemon TCG products:
  [JB Hi-Fi] Pokemon TCG Mega Evolution... - $9.00 (IN STOCK)
  [JB Hi-Fi] Pokemon TCG Scarlet... - $7.00 (OUT OF STOCK)
  ...

[Hobby Lords] Pokemon TCG Scarlet & Violet Stellar Crown Booster Pack - $8.00 (OUT OF STOCK)

Seeing a connection error or timeout?

Make sure you have an internet connection. The scrapers are making real HTTP requests to live websites.

If you see a 403 or "HTTP error fetching URL" message, the site may be temporarily blocking automated requests. Wait a minute and try again. The 10-second timeout means slow connections may also fail on the first attempt.

Help me debug my scraper connection error.

  • Now that you have confirmed the scrapers work, revert App.java back to its original placeholder. Replace the entire file contents with:
package stocktracker;

public class App {
    public static void main(String[] args) {
        System.out.println("Pokemon Stock Tracker starting...");
    }
}
  • Save App.java. You will build the full application logic in the next step.

The JB Hi-Fi scraper is pulling product data directly from the store's JSON API, discovering new listings automatically. The Hobby Lords scraper handles individual product pages via HTML parsing. Next up, you will build the state tracking and Discord alert system so your tracker can detect when something changes and ping you instantly.

Add State Tracking and Discord Alerts

Your scrapers can discover new Pokemon TCG products from JB Hi-Fi's API and pull live product data from Hobby Lords. But scraping alone is not enough.

Without memory of previous checks, the tracker would alert you every single time it runs. You need a way to remember which products you have already seen, track their stock status, and only fire alerts when a product is newly discovered or transitions from out-of-stock to in-stock. That is where state tracking and Discord webhooks come in.

In this step, get ready to:
  • Build a file-based state tracker that detects stock transitions.
  • Create a Discord notifier that sends rich embed alerts via webhook.
  • Set up a Discord webhook and test the full alert flow.
Create the state tracker

The tracker needs to remember whether each product was in stock or out of stock the last time it checked. A simple text file stores one line per product with the URL and its last-known boolean status.

  • Create a new file called ProductStore.java inside src/main/java/stocktracker/ and paste the following code:
package stocktracker;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;

public class ProductStore {
    private final Path stateFile;
    private final Map<String, Boolean> previousStates;

    public ProductStore(Path stateFile) {
        this.stateFile = stateFile;
        this.previousStates = new HashMap<>();
        load();
    }

    private void load() {
        if (!Files.exists(stateFile)) {
            return;
        }
        try {
            for (String line : Files.readAllLines(stateFile)) {
                String[] parts = line.split("\\|");
                if (parts.length >= 2) {
                    previousStates.put(parts[0], Boolean.parseBoolean(parts[1]));
                }
            }
        } catch (IOException e) {
            System.err.println("Warning: Could not load state file: " + e.getMessage());
        }
    }

    public void save() {
        try {
            var lines = previousStates.entrySet().stream()
                    .map(e -> e.getKey() + "|" + e.getValue())
                    .toList();
            Files.write(stateFile, lines);
        } catch (IOException e) {
            System.err.println("Warning: Could not save state file: " + e.getMessage());
        }
    }

    public boolean hasChanged(Product product) {
        Boolean previousState = previousStates.get(product.getUrl());
        if (previousState == null) {
            previousStates.put(product.getUrl(), product.isInStock());
            return product.isInStock();
        }
        boolean changed = previousState != product.isInStock();
        previousStates.put(product.getUrl(), product.isInStock());
        return changed;
    }
}

What does this code do?

  • load() reads product-state.txt on startup and populates a HashMap with each product URL mapped to its last-known stock boolean.
  • save() writes the current state map back to the file so it persists across restarts.
  • hasChanged() is the core logic. If a product has never been seen before, it records its state and returns true only if the product is in stock. For known products, it returns true only when the stock status flipped since the last check.
  • Save ProductStore.java.

You will not see a change yet. The next class (DiscordNotifier) uses this state tracker to decide when to fire alerts.

Create the Discord notifier

When the state tracker detects a transition, the app needs to push a notification. Java's built-in HttpClient can POST a JSON payload directly to a Discord webhook URL with no extra libraries.

  • Create a new file called DiscordNotifier.java inside src/main/java/stocktracker/ and paste the following code:
package stocktracker;

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class DiscordNotifier {
    private final String webhookUrl;
    private final HttpClient httpClient;

    public DiscordNotifier(String webhookUrl) {
        this.webhookUrl = webhookUrl;
        this.httpClient = HttpClient.newHttpClient();
    }

    public void sendAlert(Product product) {
        String color = product.isInStock() ? "3066993" : "15158332";
        String status = product.isInStock() ? "IN STOCK" : "Out of Stock";

        String json = """
                {
                  "embeds": [{
                    "title": "%s",
                    "url": "%s",
                    "description": "Status changed to **%s**",
                    "color": %s,
                    "fields": [
                      {"name": "Price", "value": "%s", "inline": true},
                      {"name": "Store", "value": "%s", "inline": true}
                    ]
                  }]
                }
                """.formatted(
                escapeJson(product.getName()),
                product.getUrl(),
                status,
                color,
                escapeJson(product.getPrice()),
                escapeJson(product.getStore())
        );

        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(webhookUrl))
                .header("Content-Type", "application/json")
                .POST(HttpRequest.BodyPublishers.ofString(json))
                .build();

        try {
            HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
            if (response.statusCode() == 204 || response.statusCode() == 200) {
                System.out.println("Alert sent for: " + product.getName());
            } else {
                System.err.println("Discord webhook failed: " + response.statusCode() + " - " + response.body());
            }
        } catch (IOException | InterruptedException e) {
            System.err.println("Failed to send Discord alert: " + e.getMessage());
        }
    }

    private String escapeJson(String text) {
        return text.replace("\\", "\\\\")
                   .replace("\"", "\\\"")
                   .replace("\n", "\\n");
    }
}

What does this code do?

  • sendAlert() builds a rich embed JSON payload with the product name as the title, a clickable URL, status description, color coding (green for in-stock, red for out-of-stock), and price/store as inline fields.
  • The HttpClient POSTs the JSON to the Discord webhook URL. Discord returns status 204 on success.
  • escapeJson() sanitizes product names and prices so special characters do not break the JSON payload.
  • Save DiscordNotifier.java.

You will not see a change yet. Next you will create the Discord webhook and test that the alert arrives.

Seeing a compilation error?

If your IDE shows an error on the text block (the """ syntax), make sure your pom.xml has <maven.compiler.source>17</maven.compiler.source>. Text blocks require Java 15 or higher.

help me fix a compilation error with Java text blocks in my DiscordNotifier

Set up a Discord webhook and test the alert

Discord webhooks are HTTP endpoints tied to a specific channel. When your app POSTs a JSON payload to the webhook URL, the message appears in that channel with no bot or persistent connection required.

  • Open Discord and navigate to the server where you want stock alerts to appear.
  • Click the server name at the top left to open the dropdown menu.
  • Click Server Settings.
  • Click Integrations in the left sidebar.
  • Click Create Webhook.
  • Choose which text channel the webhook should post to (e.g. a #stock-alerts channel).
  • Click Copy Webhook URL.

Why webhooks instead of a bot?

A Discord bot requires a persistent WebSocket connection, OAuth token management, and an additional library. A webhook is a simple HTTP POST endpoint that returns 204 on success. For one-way notifications like stock alerts, a webhook is the lightest-weight option.

  • Open config.properties in your project root.
  • Find the line that says discord.webhook.url=your-webhook-url-here and replace your-webhook-url-here with the webhook URL you just copied.
  • Save config.properties.

Now test that the alert reaches Discord by temporarily calling sendAlert() with a dummy Product from your main method.

  • Open App.java and temporarily replace the contents of the main method with this test code:
public static void main(String[] args) {
    // Temporary test - remove after verifying
    String webhookUrl = "your-webhook-url-here";
    DiscordNotifier notifier = new DiscordNotifier(webhookUrl);
    Product testProduct = new Product(
            "https://example.com/test",
            "Test Pokemon Booster Pack",
            "$12.99",
            true,
            "Test Store"
    );
    notifier.sendAlert(testProduct);
    System.out.println("Test alert sent!");
}

What does this test do?

This creates a fake Product object and sends it directly to your Discord webhook. If the webhook URL is correct, you will see a green embed message appear in your Discord channel within a few seconds.

Remember to paste your actual webhook URL in place of your-webhook-url-here in the test code.

  • Build and run the test by running these commands from your project root:
mvn package -q
java -cp target/classes:target/dependency/* stocktracker.App
  • Check your Discord channel. You should see a green embed with the title "Test Pokemon Booster Pack", a price of $12.99, and a store of "Test Store".
  • After confirming the alert works, revert App.java back to the original placeholder main method:
public static void main(String[] args) {
    System.out.println("Pokemon Stock Tracker starting...");
}
  • Save App.java.

No message appearing in Discord?

  • Make sure you replaced your-webhook-url-here with your actual webhook URL in the test code. The URL should start with https://discord.com/api/webhooks/.
  • Check your terminal output. If it prints a status code other than 200 or 204, the URL may be incorrect or the webhook may have been deleted.
  • Verify that your webhook is configured to post in the channel you are checking.

help me debug my Discord webhook alert not appearing

✔️ Awesome, I've got everything!

Great. Double check you have saved both ProductStore.java and DiscordNotifier.java, and that App.java is reverted to its original placeholder.

ⓧ I'd like to double check the full code

Here is the full code for the two new files. Make sure yours matches exactly.

package stocktracker;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;

public class ProductStore {
    private final Path stateFile;
    private final Map<String, Boolean> previousStates;

    public ProductStore(Path stateFile) {
        this.stateFile = stateFile;
        this.previousStates = new HashMap<>();
        load();
    }

    private void load() {
        if (!Files.exists(stateFile)) {
            return;
        }
        try {
            for (String line : Files.readAllLines(stateFile)) {
                String[] parts = line.split("\\|");
                if (parts.length >= 2) {
                    previousStates.put(parts[0], Boolean.parseBoolean(parts[1]));
                }
            }
        } catch (IOException e) {
            System.err.println("Warning: Could not load state file: " + e.getMessage());
        }
    }

    public void save() {
        try {
            var lines = previousStates.entrySet().stream()
                    .map(e -> e.getKey() + "|" + e.getValue())
                    .toList();
            Files.write(stateFile, lines);
        } catch (IOException e) {
            System.err.println("Warning: Could not save state file: " + e.getMessage());
        }
    }

    public boolean hasChanged(Product product) {
        Boolean previousState = previousStates.get(product.getUrl());
        if (previousState == null) {
            previousStates.put(product.getUrl(), product.isInStock());
            return product.isInStock();
        }
        boolean changed = previousState != product.isInStock();
        previousStates.put(product.getUrl(), product.isInStock());
        return changed;
    }
}
package stocktracker;

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class DiscordNotifier {
    private final String webhookUrl;
    private final HttpClient httpClient;

    public DiscordNotifier(String webhookUrl) {
        this.webhookUrl = webhookUrl;
        this.httpClient = HttpClient.newHttpClient();
    }

    public void sendAlert(Product product) {
        String color = product.isInStock() ? "3066993" : "15158332";
        String status = product.isInStock() ? "IN STOCK" : "Out of Stock";

        String json = """
                {
                  "embeds": [{
                    "title": "%s",
                    "url": "%s",
                    "description": "Status changed to **%s**",
                    "color": %s,
                    "fields": [
                      {"name": "Price", "value": "%s", "inline": true},
                      {"name": "Store", "value": "%s", "inline": true}
                    ]
                  }]
                }
                """.formatted(
                escapeJson(product.getName()),
                product.getUrl(),
                status,
                color,
                escapeJson(product.getPrice()),
                escapeJson(product.getStore())
        );

        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(webhookUrl))
                .header("Content-Type", "application/json")
                .POST(HttpRequest.BodyPublishers.ofString(json))
                .build();

        try {
            HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
            if (response.statusCode() == 204 || response.statusCode() == 200) {
                System.out.println("Alert sent for: " + product.getName());
            } else {
                System.err.println("Discord webhook failed: " + response.statusCode() + " - " + response.body());
            }
        } catch (IOException | InterruptedException e) {
            System.err.println("Failed to send Discord alert: " + e.getMessage());
        }
    }

    private String escapeJson(String text) {
        return text.replace("\\", "\\\\")
                   .replace("\"", "\\\"")
                   .replace("\n", "\\n");
    }
}
package stocktracker;

public class App {
    public static void main(String[] args) {
        System.out.println("Pokemon Stock Tracker starting...");
    }
}

You now have a state tracker that detects stock transitions and a notifier that delivers rich alerts to Discord. Next up, you will wire these components together into a polling loop that monitors your products on repeat.

Wire Up the Main Loop and Test Locally

You have scrapers that can discover new products from JB Hi-Fi's JSON API and read individual product pages from Hobby Lords. You also have a notifier that can ping Discord. The missing piece is the engine that ties everything together: a loop that runs product discovery, checks individual URLs, detects stock transitions and new listings, and fires alerts.

In this step, you will rebuild App.java from its placeholder into a fully functional application with configuration loading, store-level discovery, individual product scraping, and an infinite polling loop.

In this step, get ready to:
  • Build the App class with configuration loading for both store URLs and product URLs.
  • Implement the polling loop that runs discovery, scrapes individual products, and sends alerts.
  • Package the project and run it locally to verify end-to-end functionality.
Build out App.java with configuration loading

The current App.java is just a placeholder that prints a message. You need to replace it with the real application class that reads config.properties, loads both store URLs (for discovery) and product URLs (for direct scraping), initializes all components, and knows which scraper handles which store.

  • In App.java, select all the existing code and replace it with the following:
package stocktracker;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Properties;

public class App {
    private final List<StoreScraper> scrapers;
    private final List<String> storeUrls;
    private final List<String> productUrls;
    private final ProductStore store;
    private final DiscordNotifier notifier;
    private final int checkIntervalSeconds;

    // Constructor loads all configuration and initializes components
    public App(Properties config) {
        this.scrapers = List.of(new JBHiFiScraper(), new HobbyLordsScraper());
        this.storeUrls = loadUrlsByPrefix(config, "store.url.");
        this.productUrls = loadUrlsByPrefix(config, "product.url.");
        this.store = new ProductStore(Path.of("product-state.txt"));
        this.notifier = new DiscordNotifier(config.getProperty("discord.webhook.url"));
        this.checkIntervalSeconds = Integer.parseInt(
                config.getProperty("check.interval.seconds", "300"));
    }

    // Reads all keys with a given prefix and collects their values into a list
    private List<String> loadUrlsByPrefix(Properties config, String prefix) {
        List<String> urls = new ArrayList<>();
        for (String key : config.stringPropertyNames()) {
            if (key.startsWith(prefix)) {
                urls.add(config.getProperty(key));
            }
        }
        return urls;
    }

    // Routes a product URL to the correct scraper based on domain
    private StoreScraper getScraperForUrl(String url) {
        if (url.contains("jbhifi.co.nz")) {
            return scrapers.get(0);
        } else if (url.contains("hobbylords.co.nz")) {
            return scrapers.get(1);
        }
        return null;
    }

What does this code do?

This is the first half of your App class. It handles all the setup work.

  • The constructor reads both store.url.* keys (for store-level discovery) and product.url.* keys (for individual product scraping) from the config.
  • loadUrlsByPrefix() is a reusable helper that scans config keys by prefix. It handles both URL types with the same logic.
  • getScraperForUrl() checks the domain in the URL to pick the right scraper. This is the strategy pattern in action: the caller does not need to know which scraper implementation handles which store.

Don't save yet. You will add the remaining methods in the next substep before the class compiles.

Implement the polling loop

The polling loop is what makes this a continuously running service instead of a one-shot script. It scrapes every configured product, checks for state transitions, fires alerts, and then sleeps until the next cycle.

  • In App.java, add the following methods directly below getScraperForUrl(), and close the class with a final }:
    // Starts the infinite polling loop
    public void run() {
        System.out.println("Pokemon Stock Tracker started!");
        System.out.println("Store URLs for discovery: " + storeUrls.size());
        System.out.println("Individual product URLs: " + productUrls.size());
        System.out.println("Check interval: " + checkIntervalSeconds + " seconds");

        while (true) {
            checkAllProducts();
            store.save();
            try {
                Thread.sleep(checkIntervalSeconds * 1000L);
            } catch (InterruptedException e) {
                System.out.println("Tracker stopped.");
                break;
            }
        }
    }

    // Runs discovery on store URLs, then scrapes individual product URLs
    private void checkAllProducts() {
        System.out.println("--- Checking products at " + LocalDateTime.now() + " ---");

        // Phase 1: Discover products from store APIs
        for (String storeUrl : storeUrls) {
            StoreScraper scraper = getScraperForUrl(storeUrl);
            if (scraper == null) {
                System.err.println("No scraper found for store: " + storeUrl);
                continue;
            }
            try {
                List<Product> discovered = scraper.discoverProducts(storeUrl);
                for (Product product : discovered) {
                    System.out.println("  " + product);
                    if (store.hasChanged(product) && product.isInStock()) {
                        notifier.sendAlert(product);
                    }
                }
            } catch (IOException e) {
                System.err.println("  Error discovering from " + storeUrl + ": " + e.getMessage());
            }
        }

        // Phase 2: Scrape individual product URLs
        for (String url : productUrls) {
            StoreScraper scraper = getScraperForUrl(url);
            if (scraper == null) {
                System.err.println("No scraper found for: " + url);
                continue;
            }
            try {
                Product product = scraper.scrape(url);
                System.out.println("  " + product);
                if (store.hasChanged(product) && product.isInStock()) {
                    notifier.sendAlert(product);
                }
            } catch (IOException e) {
                System.err.println("  Error scraping " + url + ": " + e.getMessage());
            }
        }
    }

    // Entry point: loads config file and starts the app
    public static void main(String[] args) {
        Properties config = new Properties();
        Path configPath = Path.of("config.properties");
        try (var reader = Files.newBufferedReader(configPath)) {
            config.load(reader);
        } catch (IOException e) {
            System.err.println("Failed to load config.properties: " + e.getMessage());
            System.exit(1);
        }
        new App(config).run();
    }
}

What does this code do?

  • run() is the main loop. It calls checkAllProducts(), saves state to disk, then sleeps for the configured interval before repeating.
  • checkAllProducts() works in two phases. Phase 1 calls discoverProducts() on each store URL (JB Hi-Fi's JSON API), finding new listings automatically. Phase 2 calls scrape() on each individual product URL (Hobby Lords pages).
  • Both phases use the same alert logic: store.hasChanged(product) && product.isInStock(). This fires when a product is newly discovered and in stock, or when a known product transitions back to in-stock.
  • main() loads config.properties from the current working directory, passes it to the App constructor, and kicks off the loop.
  • Save App.java.

Seeing compile errors?

  • Make sure you have the closing } brace at the very end of the file. The class declaration that opens with public class App { needs a matching close after main().
  • Check that all the imports at the top of the file match exactly. Missing import java.time.LocalDateTime; will cause an error in checkAllProducts().
  • Verify that Product.java, StoreScraper.java, ProductStore.java, and DiscordNotifier.java are all in the same stocktracker package folder.

Help me fix compile errors in my App.java stock tracker class.

✔️ Awesome, I've got everything!

Great. Double check you have saved App.java before moving on.

ⓧ I'd like to double check the full code

package stocktracker;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Properties;

public class App {
    private final List<StoreScraper> scrapers;
    private final List<String> storeUrls;
    private final List<String> productUrls;
    private final ProductStore store;
    private final DiscordNotifier notifier;
    private final int checkIntervalSeconds;

    public App(Properties config) {
        this.scrapers = List.of(new JBHiFiScraper(), new HobbyLordsScraper());
        this.storeUrls = loadUrlsByPrefix(config, "store.url.");
        this.productUrls = loadUrlsByPrefix(config, "product.url.");
        this.store = new ProductStore(Path.of("product-state.txt"));
        this.notifier = new DiscordNotifier(config.getProperty("discord.webhook.url"));
        this.checkIntervalSeconds = Integer.parseInt(
                config.getProperty("check.interval.seconds", "300"));
    }

    private List<String> loadUrlsByPrefix(Properties config, String prefix) {
        List<String> urls = new ArrayList<>();
        for (String key : config.stringPropertyNames()) {
            if (key.startsWith(prefix)) {
                urls.add(config.getProperty(key));
            }
        }
        return urls;
    }

    private StoreScraper getScraperForUrl(String url) {
        if (url.contains("jbhifi.co.nz")) {
            return scrapers.get(0);
        } else if (url.contains("hobbylords.co.nz")) {
            return scrapers.get(1);
        }
        return null;
    }

    public void run() {
        System.out.println("Pokemon Stock Tracker started!");
        System.out.println("Store URLs for discovery: " + storeUrls.size());
        System.out.println("Individual product URLs: " + productUrls.size());
        System.out.println("Check interval: " + checkIntervalSeconds + " seconds");

        while (true) {
            checkAllProducts();
            store.save();
            try {
                Thread.sleep(checkIntervalSeconds * 1000L);
            } catch (InterruptedException e) {
                System.out.println("Tracker stopped.");
                break;
            }
        }
    }

    private void checkAllProducts() {
        System.out.println("--- Checking products at " + LocalDateTime.now() + " ---");

        for (String storeUrl : storeUrls) {
            StoreScraper scraper = getScraperForUrl(storeUrl);
            if (scraper == null) {
                System.err.println("No scraper found for store: " + storeUrl);
                continue;
            }
            try {
                List<Product> discovered = scraper.discoverProducts(storeUrl);
                for (Product product : discovered) {
                    System.out.println("  " + product);
                    if (store.hasChanged(product) && product.isInStock()) {
                        notifier.sendAlert(product);
                    }
                }
            } catch (IOException e) {
                System.err.println("  Error discovering from " + storeUrl + ": " + e.getMessage());
            }
        }

        for (String url : productUrls) {
            StoreScraper scraper = getScraperForUrl(url);
            if (scraper == null) {
                System.err.println("No scraper found for: " + url);
                continue;
            }
            try {
                Product product = scraper.scrape(url);
                System.out.println("  " + product);
                if (store.hasChanged(product) && product.isInStock()) {
                    notifier.sendAlert(product);
                }
            } catch (IOException e) {
                System.err.println("  Error scraping " + url + ": " + e.getMessage());
            }
        }
    }

    public static void main(String[] args) {
        Properties config = new Properties();
        Path configPath = Path.of("config.properties");
        try (var reader = Files.newBufferedReader(configPath)) {
            config.load(reader);
        } catch (IOException e) {
            System.err.println("Failed to load config.properties: " + e.getMessage());
            System.exit(1);
        }
        new App(config).run();
    }
}
Package and run locally

Time to see all of your components working together. You will compile the project into a JAR and run it from the project root so it can find config.properties.

  • Make sure your config.properties has your real Discord webhook URL (replacing your-webhook-url-here).
  • Build the project by running this command from your pokemon-stock-tracker folder:
mvn package
  • You should see BUILD SUCCESS in the terminal output. This creates target/pokemon-stock-tracker-1.0.jar.

Build failed?

  • Read the error message carefully. A "cannot find symbol" error means a class or method name is misspelled. Check that your file names match the class names exactly (case-sensitive).
  • If you see "package does not exist" for org.jsoup, Maven did not download the dependency. Run mvn clean package to force a fresh build.

Help me fix my Maven build error for the Pokemon Stock Tracker.

  • Run the application by executing this command:
java -jar target/pokemon-stock-tracker-1.0.jar
  • You should see output like:
Pokemon Stock Tracker started!
Store URLs for discovery: 1
Individual product URLs: 1
Check interval: 300 seconds
--- Checking products at 2026-06-24T14:32:05.123 ---
  Discovered 12 Pokemon TCG products from JB Hi-Fi
  [JB Hi-Fi] Pokemon TCG Mega Evolution... - $9.00 (IN STOCK)
  [JB Hi-Fi] Pokemon TCG Scarlet... - $7.00 (OUT OF STOCK)
  ...
  [Hobby Lords] Pokemon TCG Stellar Crown... - $8.50 (IN STOCK)
Alert sent for: Pokemon TCG Mega Evolution...
Alert sent for: Pokemon TCG Stellar Crown...

What should I see?

On the first run, all product states are new. The tracker discovers Pokemon TCG products from JB Hi-Fi's JSON API and scrapes the Hobby Lords product page. It sends a Discord alert for any product that is currently in stock, because the transition from "never seen" to "in stock" counts as a change.

After printing the first batch of results, the app sleeps for 300 seconds (5 minutes) before checking again. Press Ctrl+C to stop the application once you have confirmed it works.

  • Check your Discord channel. You should see a rich embed alert for any in-stock products with the product name, price, store, and a link to buy.
  • Press Ctrl+C in the terminal to stop the tracker.

No Discord alert received?

  • Check the terminal output for "Discord webhook failed" messages. A 401 status means your webhook URL is incorrect or expired. Regenerate the webhook in Discord and update config.properties.
  • If you see "Error scraping" for a URL, the store might be temporarily blocking requests. Wait a minute and try again.
  • If the output shows all products as OUT OF STOCK, no alert fires because the tracker only alerts on in-stock transitions. This is correct behaviour.

Help me troubleshoot why my Discord alerts are not sending.

Your stock tracker is fully functional on your Mac. Next up, you will deploy it to a Raspberry Pi so it can run 24/7 without keeping your laptop open.

Deploy to Raspberry Pi

Your stock tracker works perfectly on your Mac. But closing your laptop or losing Wi-Fi means missed restocks.

A Raspberry Pi solves this by running your tracker 24/7 on dedicated hardware that draws barely any power. In this step, you will flash the Pi, transfer your application, and set it up as a self-healing background service.

In this step, get ready to:
  • Set up your Raspberry Pi 5 with SSH access and Java installed.
  • Transfer your packaged application and dependencies to the Pi.
  • Create a systemd service that keeps your tracker running with automatic crash recovery.
Set up the Raspberry Pi 5

Before you can deploy anything, your Pi needs an operating system, network access, and a Java runtime.

  • Download and install Raspberry Pi Imager from raspberrypi.com/software on your Mac.
  • Insert your microSD card into your Mac.
  • Open Raspberry Pi Imager.
  • Click Choose Device and select Raspberry Pi 5.
  • Click Choose OS and select Raspberry Pi OS (64-bit).
  • Click Choose Storage and select your microSD card.
  • Click Next. When prompted to customise settings, click Edit Settings.
  • In the General tab, set a hostname (e.g. raspberrypi), username (pi), and password.
  • Switch to the Services tab and check Enable SSH. Select Use password authentication.
  • Click Save, then Yes to apply the settings and begin writing.

Why enable SSH?

SSH lets you control your Pi from your Mac's terminal without plugging in a monitor or keyboard. You will use it to transfer files and manage the tracker service remotely.

  • Once the write completes, eject the microSD card and insert it into your Raspberry Pi 5.
  • Connect the Pi to power and to the same network as your Mac (via Ethernet or Wi-Fi configured during setup).
  • Wait about 60 seconds for the Pi to boot, then connect from your Mac by running this command:
ssh pi@raspberrypi.local
  • When prompted, type yes to accept the host key, then enter the password you set in Raspberry Pi Imager.

Can't connect to the Pi?

  • Make sure the Pi and your Mac are on the same network.
  • If raspberrypi.local does not resolve, try finding the Pi's IP address from your router's admin page and use that instead.
  • Wait a full 60 seconds after powering on. The first boot takes longer than usual.

Help me troubleshoot SSH connection to my Raspberry Pi.

Now that you are connected to the Pi via SSH, you need to install Java so the Pi can run your application.

  • Install Java on the Pi by running this command:
sudo apt update && sudo apt install default-jdk -y

What does this install?

On Raspberry Pi OS Bookworm (64-bit), default-jdk installs OpenJDK 17. Your project compiles to Java 17 bytecode (set in pom.xml), so this is the exact runtime your JAR needs.

  • Verify the installation by running:
java -version
  • You should see output starting with openjdk version "17 followed by build details.

Seeing 'command not found'?

  • Make sure the sudo apt install command finished without errors. Scroll up and check for any failure messages.
  • If the install failed, check your internet connection on the Pi and try sudo apt update again.

Help me install Java on my Raspberry Pi.

Transfer your application to the Pi

Your application runs as a JAR file with one external dependency (Jsoup). You need to package everything, copy it to the Pi, and test it there.

  • Open a new terminal tab on your Mac (keep the SSH session open in the other tab).
  • Navigate to your project folder (pokemon-stock-tracker on your Desktop).
  • Package your application and copy its dependencies into a lib/ folder by running these commands:
mvn package
mvn dependency:copy-dependencies -DoutputDirectory=target/lib

What does this do?

The first command builds your JAR at target/pokemon-stock-tracker-1.0.jar. The second command copies Jsoup (your only dependency) into target/lib/.

Your JAR's manifest already references lib/ as the classpath prefix (from the maven-jar-plugin config in pom.xml). As long as the lib/ folder sits next to the JAR, Java finds the dependency automatically.

  • Create a directory on the Pi for your application by running this in your SSH tab:
mkdir ~/pokemon-tracker
  • Switch back to your Mac terminal tab and transfer the JAR, lib/ folder, and config file to the Pi by running these commands:
scp target/pokemon-stock-tracker-1.0.jar pi@raspberrypi.local:~/pokemon-tracker/
scp -r target/lib pi@raspberrypi.local:~/pokemon-tracker/
scp config.properties pi@raspberrypi.local:~/pokemon-tracker/
  • Enter your Pi's password when prompted for each transfer.

What is scp?

The scp command (secure copy) transfers files over SSH. The -r flag copies directories recursively, which is needed for the lib/ folder containing Jsoup.

  • Switch to your SSH tab and verify the files arrived by running:
ls ~/pokemon-tracker
  • You should see config.properties, lib, and pokemon-stock-tracker-1.0.jar in the output.
  • Test the application on the Pi by running:
cd ~/pokemon-tracker
java -jar pokemon-stock-tracker-1.0.jar
  • You should see the "Pokemon Stock Tracker started!" message followed by product status output, just like when you ran it on your Mac.
  • Press Ctrl+C to stop the tracker. You will set it up as a background service next.

Seeing 'ClassNotFoundException' or 'NoClassDefFoundError'?

  • This means Java cannot find the Jsoup library. Check that the lib/ folder exists inside ~/pokemon-tracker/ and contains a .jar file.
  • Run ls ~/pokemon-tracker/lib to confirm. You should see jsoup-1.22.2.jar.
  • If the folder is empty or missing, re-run the scp -r command from your Mac.

Help me fix a ClassNotFoundException on my Raspberry Pi.

Create a systemd service for auto-start and crash recovery

Running the tracker manually means it stops when you close the SSH session. A systemd service turns your application into a proper background process that starts on boot and restarts automatically if it crashes.

  • Create a service file on the Pi by running:
sudo nano /etc/systemd/system/pokemon-tracker.service
  • Paste the following service configuration into the file:
[Unit]
Description=Pokemon Stock Tracker
After=network.target

[Service]
ExecStart=/usr/bin/java -jar /home/pi/pokemon-tracker/pokemon-stock-tracker-1.0.jar
WorkingDirectory=/home/pi/pokemon-tracker
User=pi
Restart=always
RestartSec=30

[Install]
WantedBy=multi-user.target

What do these settings mean?

  • After=network.target waits for the network to be ready before starting, since the tracker makes HTTP requests.
  • WorkingDirectory tells Java where to find config.properties and where to write product-state.txt.
  • Restart=always means systemd restarts the tracker if it crashes for any reason.
  • RestartSec=30 waits 30 seconds before restarting, giving the network time to recover if that caused the crash.
  • Save the file by pressing Ctrl+O, then Enter, then Ctrl+X to exit nano.
  • Reload systemd, enable the service to start on boot, and start it now by running:
sudo systemctl daemon-reload
sudo systemctl enable pokemon-tracker
sudo systemctl start pokemon-tracker

What does enable vs start do?

The enable command registers the service to start automatically on every boot. The start command starts it right now without waiting for a reboot.

  • Verify the service is running by checking its status:
sudo systemctl status pokemon-tracker
  • You should see active (running) in green text, along with the tracker's console output showing product checks.

Seeing 'failed' or 'inactive'?

  • Check the service logs by running sudo journalctl -u pokemon-tracker -n 20 to see the error.
  • Make sure the paths in the service file use /home/pi/pokemon-tracker (full absolute paths). Systemd does not expand ~ in service files.
  • After editing the service file, always run sudo systemctl daemon-reload before restarting.

Help me fix my systemd service on the Raspberry Pi.

💡 View live logs

To watch the tracker in real-time, run sudo journalctl -u pokemon-tracker -f. Press Ctrl+C to stop watching (the tracker keeps running in the background).

Secret mission

Price-Drop Alerts

Your tracker already catches restocks. But what about price drops? In this mission, you'll extend the state tracker to monitor prices and fire a distinct blue Discord embed when a product falls below a threshold you configure.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs on hardware you own (Raspberry Pi) with no cloud subscriptions, so there are no ongoing costs beyond negligible power draw.

Resources you used:

  • Raspberry Pi 5 systemd service (pokemon-tracker.service) running 24/7.
  • Project files on the Pi (~/pokemon-tracker).
  • Discord webhook in your server (free, no cost).
  • Local Maven project on your Mac (pokemon-stock-tracker).

Keep everything running

No action needed. Choose this if you want the tracker to keep monitoring product pages indefinitely.

  • Your Pi draws approximately 5W of power and will run indefinitely.
  • The systemd service auto-restarts on crashes, so the tracker recovers without intervention.
  • Discord webhook alerts continue to fire whenever stock changes or price drops are detected.

Pause - I'll come back to this later

Stop the tracker service on your Pi while keeping everything in place for later. You can resume at any time.

  • SSH into your Pi by running:
  • Stop the tracker service by running:
ssh pi@raspberrypi.local
sudo systemctl stop pokemon-tracker
  • When you want to resume, SSH into the Pi and start the service again by running:
sudo systemctl start pokemon-tracker

Delete - I don't want to use this again

Remove all project resources from your Pi, Discord server, and local machine. This cannot be undone.

  • SSH into your Pi by running:
ssh pi@raspberrypi.local
  • Stop and disable the systemd service, remove the service file, and delete the project directory by running:
sudo systemctl stop pokemon-tracker
sudo systemctl disable pokemon-tracker
sudo rm /etc/systemd/system/pokemon-tracker.service
sudo systemctl daemon-reload
rm -rf ~/pokemon-tracker
  • Delete the Discord webhook: open your Discord server, go to Server Settings, select Integrations, click View Webhooks, find the webhook you created for this project, and delete it.
  • Delete the local project on your Mac. Open Terminal and run:
rm -rf ~/Desktop/pokemon-stock-tracker

Nice Work!

Nice Work!

Nice work! You just built and deployed a fully self-hosted stock monitoring service that automatically discovers new Pokemon TCG products via JB Hi-Fi's JSON API, tracks stock changes across NZ retailers, and pings your Discord the moment something new or restocked appears.

You've learned how to:

  • Built a product discovery engine that uses Shopify's public /products.json API to find new Pokemon TCG listings automatically, alongside Jsoup HTML scraping for stores without an API.
  • Created a Discord webhook integration that sends rich embed alerts for new listings and restocks with product names, prices, and direct buy links.
  • Deployed a self-hosted service on a Raspberry Pi 5 running 24/7 as a systemd service with automatic crash recovery.
  • Secret Mission: Added price-drop tracking with configurable thresholds and distinct blue alert styling.

Ready to quiz yourself?