diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 0000000..861bf2a
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,42 @@
+name: Build and Deploy
+
+on:
+ push:
+ branches: ["main"]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Java
+ uses: actions/setup-java@v4
+ with:
+ distribution: 'temurin'
+ java-version: '21'
+
+ - name: Build
+ run: mvn -B clean package
+
+ - name: Upload to JFrog
+ run: |
+ curl -u "${{ secrets.JFROG_USER }}:${{ secrets.JFROG_PASS }}" \
+ -T target/*.jar \
+ "http://JFROG-SERVER/artifactory/myrepo/app-latest.jar"
+
+ deploy:
+ needs: build
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Trigger remote deploy script
+ uses: appleboy/ssh-action@v0.1.7
+ with:
+ host: ${{ secrets.SERVER_IP }}
+ username: ${{ secrets.SERVER_USER }}
+ key: ${{ secrets.SERVER_SSH_KEY }}
+ script: |
+ /opt/myapp/update.sh
diff --git a/.gitignore b/.gitignore
index 13275f1..27caa23 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,4 +27,6 @@ bin/
.vscode/
### Mac OS ###
-.DS_Store
\ No newline at end of file
+.DS_Store
+
+NUL
\ No newline at end of file
diff --git a/.idea/.name b/.idea/.name
new file mode 100644
index 0000000..d543c51
--- /dev/null
+++ b/.idea/.name
@@ -0,0 +1 @@
+scrappy
\ No newline at end of file
diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml
new file mode 100644
index 0000000..53c09db
--- /dev/null
+++ b/.idea/dataSources.xml
@@ -0,0 +1,65 @@
+
+
+
+
+ sqlite.xerial
+ true
+ org.sqlite.JDBC
+ jdbc:sqlite:$PROJECT_DIR$/troostwijk.db
+ $ProjectFileDir$
+
+
+ file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/license.txt
+
+
+ file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/org/slf4j/slf4j-api/1.7.36/slf4j-api-1.7.36.jar
+
+
+ file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/org/slf4j/slf4j-api/1.7.36/slf4j-api-1.7.36.jar.sha1
+
+
+ file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/org/slf4j/slf4j-api/1.7.36/slf4j-api-1.7.36.pom
+
+
+ file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/org/slf4j/slf4j-api/1.7.36/slf4j-api-1.7.36.pom.sha1
+
+
+ file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/org/slf4j/slf4j-api/1.7.36/_remote.repositories
+
+
+ file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/org/slf4j/slf4j-parent/1.7.36/slf4j-parent-1.7.36.pom
+
+
+ file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/org/slf4j/slf4j-parent/1.7.36/slf4j-parent-1.7.36.pom.sha1
+
+
+ file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/org/slf4j/slf4j-parent/1.7.36/_remote.repositories
+
+
+ file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/org/xerial/sqlite-jdbc/3.45.1.0/sqlite-jdbc-3.45.1.0.jar
+
+
+ file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/org/xerial/sqlite-jdbc/3.45.1.0/sqlite-jdbc-3.45.1.0.jar.sha1
+
+
+ file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/org/xerial/sqlite-jdbc/3.45.1.0/sqlite-jdbc-3.45.1.0.pom
+
+
+ file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/org/xerial/sqlite-jdbc/3.45.1.0/sqlite-jdbc-3.45.1.0.pom.sha1
+
+
+ file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/org/xerial/sqlite-jdbc/3.45.1.0/_remote.repositories
+
+
+ file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/org/xerial/sqlite-jdbc/3.45.1.0/sqlite-jdbc-3.45.1.0.jar
+
+
+ file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/org/slf4j/slf4j-api/1.7.36/slf4j-api-1.7.36.jar
+
+
+ file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.43.0/org/xerial/sqlite-jdbc/3.43.0.0/sqlite-jdbc-3.43.0.0.jar
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/material_theme_project_new.xml b/.idea/material_theme_project_new.xml
index 169f590..c3c2631 100644
--- a/.idea/material_theme_project_new.xml
+++ b/.idea/material_theme_project_new.xml
@@ -3,8 +3,16 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/src/main/java/com/auction/scraper/TroostwijkScraper.java b/src/main/java/com/auction/scraper/TroostwijkScraper.java
index 6d64fd8..c3d0098 100644
--- a/src/main/java/com/auction/scraper/TroostwijkScraper.java
+++ b/src/main/java/com/auction/scraper/TroostwijkScraper.java
@@ -85,760 +85,869 @@ import static org.opencv.dnn.Dnn.DNN_TARGET_CPU;
* persisting data, scheduling updates, and performing object detection.
*/
public class TroostwijkScraper {
-
- // Base URLs – adjust these if Troostwijk changes their site structure
- private static final String AUCTIONS_PAGE = "https://www.troostwijkauctions.com/nl/auctions";
- private static final String LOT_API = "https://api.troostwijkauctions.com/lot/7/list";
-
- // HTTP client used for API calls
- private final HttpClient httpClient;
- private final ObjectMapper objectMapper;
- private final DatabaseService db;
- private final NotificationService notifier;
- private final ObjectDetectionService detector;
-
- /**
- * Constructor. Creates supporting services and ensures the database
- * tables exist.
- *
- * @param databasePath Path to SQLite database file
- * @param notificationConfig "desktop" for desktop only, or "smtp:user:pass:toEmail" for email
- * @param unused Unused parameter (kept for compatibility)
- * @param yoloCfgPath Path to YOLO configuration file
- * @param yoloWeightsPath Path to YOLO weights file
- * @param classNamesPath Path to file containing class names
- */
- public TroostwijkScraper(String databasePath, String notificationConfig, String unused,
- String yoloCfgPath, String yoloWeightsPath, String classNamesPath) throws SQLException, IOException {
- this.httpClient = HttpClient.newHttpClient();
- this.objectMapper = new ObjectMapper();
- this.db = new DatabaseService(databasePath);
- this.notifier = new NotificationService(notificationConfig, unused);
- this.detector = new ObjectDetectionService(yoloCfgPath, yoloWeightsPath, classNamesPath);
- // initialize DB
- db.ensureSchema();
- }
-
- /**
- * Discovers all active Dutch auctions by crawling the auctions page.
- *
- * Troostwijk lists auctions for many countries on one page. We parse
- * the page with jsoup (an HTML parser that fetches and parses real‐world
- * HTML easily【438902460386021†L14-L24】) and filter auctions whose location
- * contains ", NL" (indicating the Netherlands). Each auction link
- * contains a unique sale ID which we extract from its URL.
- *
- * @return a list of sale identifiers for auctions located in NL
- */
- public List discoverDutchAuctions() {
- List saleIds = new ArrayList<>();
- try {
- // Fetch the auctions overview page
- Document doc = Jsoup.connect(AUCTIONS_PAGE).get();
- // Select all anchor elements that represent an auction listing.
- // The exact selector may change; inspect the page with your browser’s
- // developer tools and update accordingly.
- Elements auctionLinks = doc.select("a[href][data-id]");
- for (Element link : auctionLinks) {
- Element locationElement = link.selectFirst(".auction-location");
- String location = locationElement != null ? locationElement.text() : "";
- if (location.contains(", NL")) {
- // Extract saleID from the data-id attribute or href
- String saleIdStr = link.attr("data-id");
- if (saleIdStr.isEmpty()) {
- // Fallback: parse from URL path, e.g. /nl/sale/27213/machines
- String href = link.attr("href");
- String[] parts = href.split("/");
- for (String p : parts) {
- if (p.matches("\\d+")) {
- saleIdStr = p;
- break;
- }
- }
- }
- try {
- int saleId = Integer.parseInt(saleIdStr);
+
+ // Base URLs – adjust these if Troostwijk changes their site structure
+ private static final String AUCTIONS_PAGE = "https://www.troostwijkauctions.com/nl/auctions";
+ private static final String LOT_API = "https://api.troostwijkauctions.com/lot/7/list";
+
+ // HTTP client used for API calls
+ private final HttpClient httpClient;
+ private final ObjectMapper objectMapper;
+ private final DatabaseService db;
+ private final NotificationService notifier;
+ private final ObjectDetectionService detector;
+
+ /**
+ * Constructor. Creates supporting services and ensures the database
+ * tables exist.
+ *
+ * @param databasePath Path to SQLite database file
+ * @param notificationConfig "desktop" for desktop only, or "smtp:user:pass:toEmail" for email
+ * @param unused Unused parameter (kept for compatibility)
+ * @param yoloCfgPath Path to YOLO configuration file
+ * @param yoloWeightsPath Path to YOLO weights file
+ * @param classNamesPath Path to file containing class names
+ */
+ public TroostwijkScraper(String databasePath, String notificationConfig, String unused,
+ String yoloCfgPath, String yoloWeightsPath, String classNamesPath) throws SQLException, IOException {
+ this.httpClient = HttpClient.newHttpClient();
+ this.objectMapper = new ObjectMapper();
+ this.db = new DatabaseService(databasePath);
+ this.notifier = new NotificationService(notificationConfig, unused);
+ this.detector = new ObjectDetectionService(yoloCfgPath, yoloWeightsPath, classNamesPath);
+ // initialize DB
+ db.ensureSchema();
+ }
+
+ /**
+ * Discovers all active Dutch auctions by crawling the auctions page.
+ *
+ * Troostwijk lists auctions for many countries on one page. We parse
+ * the page with jsoup and filter auctions whose location contains ", NL"
+ * (indicating the Netherlands). Each auction link contains a unique sale ID
+ * in the format A1-xxxxx or A7-xxxxx which we extract from the URL.
+ *
+ * @return a list of sale identifiers for auctions located in NL
+ */
+ public List discoverDutchAuctions() {
+ List saleIds = new ArrayList<>();
+ try {
+ // Fetch the auctions overview page
+ Document doc = Jsoup.connect(AUCTIONS_PAGE).get();
+
+ // Select all anchor elements that link to auction pages
+ // The URL pattern is: /a/auction-title-A1-xxxxx or /a/auction-title-A7-xxxxx
+ Elements auctionLinks = doc.select("a[href^='/a/']");
+
+ System.out.println("Found " + auctionLinks.size() + " potential auction links");
+
+ for (Element link : auctionLinks) {
+ // Get the href to extract the auction ID
+ String href = link.attr("href");
+
+ // Check if this link contains location text with ", NL"
+ String linkText = link.text();
+
+ // Look for location in any div inside the link
+ Elements divs = link.select("div");
+ boolean isDutch = false;
+ for (Element div : divs) {
+ String text = div.text();
+ if (text.contains(", NL")) {
+ isDutch = true;
+ break;
+ }
+ }
+
+ if (isDutch) {
+ // Extract auction ID from URL
+ // Format: /a/title-A1-38375 or /a/title-A7-12345
+ // We want the number after A1- or A7-
+ java.util.regex.Pattern pattern = java.util.regex.Pattern.compile("A[17]-(\\d+)");
+ java.util.regex.Matcher matcher = pattern.matcher(href);
+
+ if (matcher.find()) {
+ try {
+ int saleId = Integer.parseInt(matcher.group(1));
+ if (!saleIds.contains(saleId)) {
saleIds.add(saleId);
- } catch (NumberFormatException ignored) {
- // not a sale ID
- }
- }
+ System.out.println(" Found Dutch auction: " + saleId + " - " + href);
+ }
+ } catch (NumberFormatException e) {
+ // Skip invalid IDs
+ }
+ }
}
- } catch (IOException e) {
- System.err.println("Failed to discover auctions: " + e.getMessage());
- }
- return saleIds;
- }
-
- /**
- * Retrieves all lots for a given sale ID using Troostwijk’s internal JSON
- * API. The API accepts parameters such as batchSize, offset, and saleID.
- * A large batchSize returns many lots at once【610752406306016†L124-L134】. We loop
- * until no further results are returned. Each JSON result is mapped to
- * our Lot domain object and persisted to the database.
- *
- * @param saleId the sale identifier
- */
- public void fetchLotsForSale(int saleId) {
- int batchSize = 200;
- int offset = 0;
- boolean more = true;
- while (more) {
- try {
- String url = LOT_API + "?batchSize=" + batchSize
- + "&listType=7&offset=" + offset
- + "&sortOption=0&saleID=" + saleId
- + "&parentID=0&relationID=0&buildversion=201807311";
- HttpRequest request = HttpRequest.newBuilder()
- .uri(URI.create(url))
- .header("Accept", "application/json")
- .GET()
- .build();
- HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
- if (response.statusCode() != 200) {
- System.err.println("API call failed for sale " + saleId + " with status " + response.statusCode());
- break;
- }
- JsonNode root = objectMapper.readTree(response.body());
- JsonNode results = root.path("results");
- if (!results.isArray() || results.isEmpty()) {
- more = false;
- break;
- }
- for (JsonNode node : results) {
- Lot lot = new Lot();
- lot.saleId = saleId;
- lot.lotId = node.path("lotID").asInt();
- lot.title = node.path("t").asText();
- lot.description = node.path("d").asText();
- lot.manufacturer = node.path("mf").asText();
- lot.type = node.path("typ").asText();
- lot.year = node.path("yb").asInt();
- lot.category = node.path("lc").asText();
- // Current bid; field names may differ (e.g. currentBid or cb)
- lot.currentBid = node.path("cb").asDouble();
- lot.currency = node.path("cu").asText();
- lot.url = "https://www.troostwijkauctions.com/nl" + node.path("url").asText();
- // Save basic lot info into DB
- db.upsertLot(lot);
- // Download images and perform object detection
- List imageUrls = new ArrayList<>();
- JsonNode imgs = node.path("imgs");
- if (imgs.isArray()) {
- for (JsonNode imgNode : imgs) {
- String imgUrl = imgNode.asText();
- imageUrls.add(imgUrl);
- }
- }
- for (String imgUrl : imageUrls) {
- String fileName = downloadImage(imgUrl, saleId, lot.lotId);
- if (fileName != null) {
- // run object detection once per image
- List labels = detector.detectObjects(fileName);
- db.insertImage(lot.lotId, imgUrl, fileName, labels);
- }
- }
- }
- offset += batchSize;
- } catch (IOException | InterruptedException e) {
- System.err.println("Error fetching lots for sale " + saleId + ": " + e.getMessage());
- more = false;
- } catch (SQLException e) {
- System.err.println("Database error: " + e.getMessage());
- }
- }
- }
-
- /**
- * Downloads an image from the given URL to a local directory. Images
- * are stored under "images///" to keep them organised.
- *
- * @param imageUrl remote image URL
- * @param saleId sale identifier
- * @param lotId lot identifier
- * @return absolute path to saved file or null on failure
- */
- private String downloadImage(String imageUrl, int saleId, int lotId) {
- try {
+ }
+ } catch (IOException e) {
+ System.err.println("Failed to discover auctions: " + e.getMessage());
+ e.printStackTrace();
+ }
+ return saleIds;
+ }
+
+ /**
+ * Retrieves all lots for a given sale ID using Troostwijk's internal JSON
+ * API. The API accepts parameters such as batchSize, offset, and saleID.
+ * A large batchSize returns many lots at once. We loop until no further
+ * results are returned. Each JSON result is mapped to our Lot domain
+ * object and persisted to the database.
+ *
+ * @param saleId the sale identifier
+ */
+ public void fetchLotsForSale(int saleId) {
+ int batchSize = 200;
+ int offset = 0;
+ boolean more = true;
+ int totalLots = 0;
+
+ while (more) {
+ try {
+ String url = LOT_API + "?batchSize=" + batchSize
+ + "&listType=7&offset=" + offset
+ + "&sortOption=0&saleID=" + saleId
+ + "&parentID=0&relationID=0&buildversion=201807311";
+
+ System.out.println(" Fetching lots from API (offset=" + offset + ")...");
+
HttpRequest request = HttpRequest.newBuilder()
- .uri(URI.create(imageUrl))
- .GET()
- .build();
- HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
- if (response.statusCode() == 200) {
- Path dir = Paths.get("images", String.valueOf(saleId), String.valueOf(lotId));
- Files.createDirectories(dir);
- String fileName = Paths.get(imageUrl).getFileName().toString();
- Path dest = dir.resolve(fileName);
- Files.copy(response.body(), dest);
- return dest.toAbsolutePath().toString();
- }
- } catch (IOException | InterruptedException e) {
- System.err.println("Failed to download image " + imageUrl + ": " + e.getMessage());
- }
- return null;
- }
-
- /**
- * Schedules periodic monitoring of all lots. The scheduler runs every
- * hour to refresh current bids and closing times. For lots that
- * are within 30 minutes of closing, it increases the polling frequency
- * automatically. When a new bid is detected or a lot is about to
- * expire, a Pushover notification is sent to the configured user.
- * Note: In production, ensure proper shutdown handling for the scheduler.
- */
- public ScheduledExecutorService scheduleMonitoring() {
- ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
- scheduler.scheduleAtFixedRate(() -> {
- try {
- List activeLots = db.getActiveLots();
- for (Lot lot : activeLots) {
- // refresh the lot's bidding information via API
- refreshLotBid(lot);
- // check closing time to adjust monitoring
- long minutesLeft = lot.minutesUntilClose();
- if (minutesLeft < 30) {
- // send warning when within 5 minutes
- if (minutesLeft <= 5 && !lot.closingNotified) {
- notifier.sendNotification("Kavel " + lot.lotId + " sluit binnen " + minutesLeft + " min.",
- "Lot nearing closure", 1);
- lot.closingNotified = true;
- db.updateLotNotificationFlags(lot);
- }
- // schedule additional quick check for this lot
- scheduler.schedule(() -> refreshLotBid(lot), 5, TimeUnit.MINUTES);
- }
- }
- } catch (SQLException e) {
- System.err.println("Error during scheduled monitoring: " + e.getMessage());
- }
- }, 0, 1, TimeUnit.HOURS);
- return scheduler;
- }
-
- /**
- * Refreshes the bid for a single lot and sends notification if it has
- * changed since the last check. The method calls the same API used for
- * initial scraping but only extracts the current bid for the given lot.
- *
- * @param lot the lot to refresh
- */
- private void refreshLotBid(Lot lot) {
- try {
- String url = LOT_API + "?batchSize=1&listType=7&offset=0&sortOption=0&saleID=" + lot.saleId
- + "&parentID=0&relationID=0&buildversion=201807311&lotID=" + lot.lotId;
- HttpRequest request = HttpRequest.newBuilder().uri(URI.create(url)).GET().build();
+ .uri(URI.create(url))
+ .header("Accept", "application/json")
+ .header("User-Agent", "Mozilla/5.0")
+ .GET()
+ .build();
+
HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
- if (response.statusCode() != 200) return;
- JsonNode root = objectMapper.readTree(response.body());
+
+ if (response.statusCode() != 200) {
+ System.err.println(" ⚠️ API call failed for sale " + saleId);
+ System.err.println(" Status: " + response.statusCode());
+ System.err.println(" Response: " + response.body().substring(0, Math.min(200, response.body().length())));
+ break;
+ }
+
+ JsonNode root = objectMapper.readTree(response.body());
JsonNode results = root.path("results");
- if (results.isArray() && !results.isEmpty()) {
- JsonNode node = results.get(0);
- double newBid = node.path("cb").asDouble();
- if (Double.compare(newBid, lot.currentBid) > 0) {
- double previous = lot.currentBid;
- lot.currentBid = newBid;
- db.updateLotCurrentBid(lot);
- String msg = String.format("Nieuw bod op kavel %d: €%.2f (was €%.2f)", lot.lotId, newBid, previous);
- notifier.sendNotification(msg, "Kavel bieding update", 0);
- }
+
+ if (!results.isArray() || results.isEmpty()) {
+ if (offset == 0) {
+ System.out.println(" ⚠️ No lots found for sale " + saleId);
+ System.out.println(" API Response: " + response.body().substring(0, Math.min(500, response.body().length())));
+ }
+ more = false;
+ break;
}
- } catch (IOException | InterruptedException | SQLException e) {
- System.err.println("Failed to refresh bid for lot " + lot.lotId + ": " + e.getMessage());
- }
- }
-
- /**
- * Entry point. Configure database location, notification settings, and
- * YOLO model paths here before running. Once started the scraper
- * discovers Dutch auctions, scrapes lots, and begins monitoring.
- */
- public static void main(String[] args) throws Exception {
- System.out.println("=== Troostwijk Auction Scraper ===\n");
-
- // Configuration parameters (replace with your own values)
- String databaseFile = "troostwijk.db";
-
- // Notification configuration - choose one:
- // Option 1: Desktop notifications only (free, no setup required)
- String notificationConfig = System.getenv().getOrDefault("NOTIFICATION_CONFIG", "desktop");
-
- // Option 2: Desktop + Email via Gmail (free, requires Gmail app password)
- // Format: "smtp:username:appPassword:toEmail"
- // Example: "smtp:your.email@gmail.com:abcd1234efgh5678:recipient@example.com"
- // Get app password: Google Account > Security > 2-Step Verification > App passwords
-
- // YOLO model paths (optional - scraper works without object detection)
- String yoloCfg = "models/yolov4.cfg";
- String yoloWeights = "models/yolov4.weights";
- String yoloClasses = "models/coco.names";
-
- // Load native OpenCV library
- System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
-
- System.out.println("Initializing scraper...");
- TroostwijkScraper scraper = new TroostwijkScraper(databaseFile, notificationConfig, "",
- yoloCfg, yoloWeights, yoloClasses);
-
- // Step 1: Discover auctions in NL
- System.out.println("\n[1/3] Discovering Dutch auctions...");
- List auctions = scraper.discoverDutchAuctions();
- System.out.println("✓ Found " + auctions.size() + " auctions: " + auctions);
-
- // Step 2: Fetch lots for each auction
- System.out.println("\n[2/3] Fetching lot details...");
- for (int saleId : auctions) {
- System.out.println(" Processing sale " + saleId + "...");
- scraper.fetchLotsForSale(saleId);
- }
-
- // Step 3: Start monitoring bids and closures
- System.out.println("\n[3/3] Starting monitoring service...");
- scraper.scheduleMonitoring();
- System.out.println("✓ Monitoring active. Press Ctrl+C to stop.\n");
- }
-
- // ----------------------------------------------------------------------
- // Domain classes and services
- // ----------------------------------------------------------------------
-
- /**
- * Simple POJO representing a lot (kavel) in an auction. It keeps track
- * of the sale it belongs to, current bid and closing time. The method
- * minutesUntilClose computes how many minutes remain until the lot closes.
- */
- static class Lot {
- int saleId;
- int lotId;
- String title;
- String description;
- String manufacturer;
- String type;
- int year;
- String category;
- double currentBid;
- String currency;
- String url;
- LocalDateTime closingTime; // null if unknown
- boolean closingNotified;
-
- long minutesUntilClose() {
- if (closingTime == null) return Long.MAX_VALUE;
- return java.time.Duration.between(LocalDateTime.now(), closingTime).toMinutes();
- }
- }
-
- /**
- * Service for persisting auctions, lots, images, and object labels into
- * a SQLite database. Uses the Xerial JDBC driver which connects to
- * SQLite via a URL of the form "jdbc:sqlite:path_to_file"【329850066306528†L40-L63】.
- */
- static class DatabaseService {
- private final String url;
- DatabaseService(String dbPath) {
- this.url = "jdbc:sqlite:" + dbPath;
- }
- /**
- * Creates tables if they do not already exist. The schema includes
- * tables for sales, lots, images, and object labels. This method is
- * idempotent; it can be called multiple times.
- */
- void ensureSchema() throws SQLException {
- try (Connection conn = DriverManager.getConnection(url); Statement stmt = conn.createStatement()) {
- // Sales table
- stmt.execute("CREATE TABLE IF NOT EXISTS sales ("
- + "sale_id INTEGER PRIMARY KEY,"
- + "title TEXT,"
- + "location TEXT,"
- + "closing_time TEXT"
- + ")");
- // Lots table
- stmt.execute("CREATE TABLE IF NOT EXISTS lots ("
- + "lot_id INTEGER PRIMARY KEY,"
- + "sale_id INTEGER,"
- + "title TEXT,"
- + "description TEXT,"
- + "manufacturer TEXT,"
- + "type TEXT,"
- + "year INTEGER,"
- + "category TEXT,"
- + "current_bid REAL,"
- + "currency TEXT,"
- + "url TEXT,"
- + "closing_time TEXT,"
- + "closing_notified INTEGER DEFAULT 0,"
- + "FOREIGN KEY (sale_id) REFERENCES sales(sale_id)"
- + ")");
- // Images table
- stmt.execute("CREATE TABLE IF NOT EXISTS images ("
- + "id INTEGER PRIMARY KEY AUTOINCREMENT,"
- + "lot_id INTEGER,"
- + "url TEXT,"
- + "file_path TEXT,"
- + "labels TEXT,"
- + "FOREIGN KEY (lot_id) REFERENCES lots(lot_id)"
- + ")");
+ int lotsInBatch = results.size();
+ System.out.println(" Found " + lotsInBatch + " lots in this batch");
+
+ for (JsonNode node : results) {
+ Lot lot = new Lot();
+ lot.saleId = saleId;
+ lot.lotId = node.path("lotID").asInt();
+ lot.title = node.path("t").asText();
+ lot.description = node.path("d").asText();
+ lot.manufacturer = node.path("mf").asText();
+ lot.type = node.path("typ").asText();
+ lot.year = node.path("yb").asInt();
+ lot.category = node.path("lc").asText();
+ // Current bid; field names may differ (e.g. currentBid or cb)
+ lot.currentBid = node.path("cb").asDouble();
+ lot.currency = node.path("cu").asText();
+ lot.url = "https://www.troostwijkauctions.com/nl" + node.path("url").asText();
+
+ // Save basic lot info into DB
+ db.upsertLot(lot);
+ totalLots++;
+
+ // Download images and perform object detection
+ List imageUrls = new ArrayList<>();
+ JsonNode imgs = node.path("imgs");
+ if (imgs.isArray()) {
+ for (JsonNode imgNode : imgs) {
+ String imgUrl = imgNode.asText();
+ imageUrls.add(imgUrl);
+ }
+ }
+
+ // Download and analyze images (optional, can be slow)
+ for (String imgUrl : imageUrls) {
+ String fileName = downloadImage(imgUrl, saleId, lot.lotId);
+ if (fileName != null) {
+ // run object detection once per image
+ List labels = detector.detectObjects(fileName);
+ db.insertImage(lot.lotId, imgUrl, fileName, labels);
+ }
+ }
}
- }
-
- /**
- * Inserts or updates a lot record. Uses INSERT OR REPLACE to
- * implement upsert semantics so that existing rows are replaced.
- */
- synchronized void upsertLot(Lot lot) throws SQLException {
- String sql = "INSERT INTO lots (lot_id, sale_id, title, description, manufacturer, type, year, category, current_bid, currency, url, closing_time, closing_notified)"
- + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
- + " ON CONFLICT(lot_id) DO UPDATE SET "
- + "sale_id = excluded.sale_id, title = excluded.title, description = excluded.description, "
- + "manufacturer = excluded.manufacturer, type = excluded.type, year = excluded.year, category = excluded.category, "
- + "current_bid = excluded.current_bid, currency = excluded.currency, url = excluded.url, closing_time = excluded.closing_time";
- try (Connection conn = DriverManager.getConnection(url); PreparedStatement ps = conn.prepareStatement(sql)) {
- ps.setInt(1, lot.lotId);
- ps.setInt(2, lot.saleId);
- ps.setString(3, lot.title);
- ps.setString(4, lot.description);
- ps.setString(5, lot.manufacturer);
- ps.setString(6, lot.type);
- ps.setInt(7, lot.year);
- ps.setString(8, lot.category);
- ps.setDouble(9, lot.currentBid);
- ps.setString(10, lot.currency);
- ps.setString(11, lot.url);
- ps.setString(12, lot.closingTime != null ? lot.closingTime.toString() : null);
- ps.setInt(13, lot.closingNotified ? 1 : 0);
- ps.executeUpdate();
+
+ System.out.println(" ✓ Processed " + totalLots + " lots so far");
+ offset += batchSize;
+ } catch (IOException | InterruptedException e) {
+ System.err.println("Error fetching lots for sale " + saleId + ": " + e.getMessage());
+ more = false;
+ } catch (SQLException e) {
+ System.err.println("Database error: " + e.getMessage());
+ }
+ }
+ }
+
+ /**
+ * Downloads an image from the given URL to a local directory. Images
+ * are stored under "images///" to keep them organised.
+ *
+ * @param imageUrl remote image URL
+ * @param saleId sale identifier
+ * @param lotId lot identifier
+ * @return absolute path to saved file or null on failure
+ */
+ private String downloadImage(String imageUrl, int saleId, int lotId) {
+ try {
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(imageUrl))
+ .GET()
+ .build();
+ HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
+ if (response.statusCode() == 200) {
+ Path dir = Paths.get("images", String.valueOf(saleId), String.valueOf(lotId));
+ Files.createDirectories(dir);
+ String fileName = Paths.get(imageUrl).getFileName().toString();
+ Path dest = dir.resolve(fileName);
+ Files.copy(response.body(), dest);
+ return dest.toAbsolutePath().toString();
+ }
+ } catch (IOException | InterruptedException e) {
+ System.err.println("Failed to download image " + imageUrl + ": " + e.getMessage());
+ }
+ return null;
+ }
+
+ /**
+ * Schedules periodic monitoring of all lots. The scheduler runs every
+ * hour to refresh current bids and closing times. For lots that
+ * are within 30 minutes of closing, it increases the polling frequency
+ * automatically. When a new bid is detected or a lot is about to
+ * expire, a Pushover notification is sent to the configured user.
+ * Note: In production, ensure proper shutdown handling for the scheduler.
+ */
+ public ScheduledExecutorService scheduleMonitoring() {
+ ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
+ scheduler.scheduleAtFixedRate(() -> {
+ try {
+ List activeLots = db.getActiveLots();
+ for (Lot lot : activeLots) {
+ // refresh the lot's bidding information via API
+ refreshLotBid(lot);
+ // check closing time to adjust monitoring
+ long minutesLeft = lot.minutesUntilClose();
+ if (minutesLeft < 30) {
+ // send warning when within 5 minutes
+ if (minutesLeft <= 5 && !lot.closingNotified) {
+ notifier.sendNotification("Kavel " + lot.lotId + " sluit binnen " + minutesLeft + " min.",
+ "Lot nearing closure", 1);
+ lot.closingNotified = true;
+ db.updateLotNotificationFlags(lot);
+ }
+ // schedule additional quick check for this lot
+ scheduler.schedule(() -> refreshLotBid(lot), 5, TimeUnit.MINUTES);
+ }
}
- }
-
- /**
- * Inserts a new image record. Each image is associated with a lot and
- * stores both the original URL and the local file path. Detected
- * labels are stored as a comma separated string.
- */
- synchronized void insertImage(int lotId, String url, String filePath, List labels) throws SQLException {
- String sql = "INSERT INTO images (lot_id, url, file_path, labels) VALUES (?, ?, ?, ?)";
- try (Connection conn = DriverManager.getConnection(this.url); PreparedStatement ps = conn.prepareStatement(sql)) {
- ps.setInt(1, lotId);
- ps.setString(2, url);
- ps.setString(3, filePath);
- ps.setString(4, String.join(",", labels));
- ps.executeUpdate();
+ } catch (SQLException e) {
+ System.err.println("Error during scheduled monitoring: " + e.getMessage());
+ }
+ }, 0, 1, TimeUnit.HOURS);
+ return scheduler;
+ }
+
+ /**
+ * Refreshes the bid for a single lot and sends notification if it has
+ * changed since the last check. The method calls the same API used for
+ * initial scraping but only extracts the current bid for the given lot.
+ *
+ * @param lot the lot to refresh
+ */
+ private void refreshLotBid(Lot lot) {
+ try {
+ String url = LOT_API + "?batchSize=1&listType=7&offset=0&sortOption=0&saleID=" + lot.saleId
+ + "&parentID=0&relationID=0&buildversion=201807311&lotID=" + lot.lotId;
+ HttpRequest request = HttpRequest.newBuilder().uri(URI.create(url)).GET().build();
+ HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
+ if (response.statusCode() != 200) return;
+ JsonNode root = objectMapper.readTree(response.body());
+ JsonNode results = root.path("results");
+ if (results.isArray() && !results.isEmpty()) {
+ JsonNode node = results.get(0);
+ double newBid = node.path("cb").asDouble();
+ if (Double.compare(newBid, lot.currentBid) > 0) {
+ double previous = lot.currentBid;
+ lot.currentBid = newBid;
+ db.updateLotCurrentBid(lot);
+ String msg = String.format("Nieuw bod op kavel %d: €%.2f (was €%.2f)", lot.lotId, newBid, previous);
+ notifier.sendNotification(msg, "Kavel bieding update", 0);
}
- }
-
- /**
- * Retrieves all lots that are still active (i.e., have a closing time
- * in the future or unknown). Only these lots need to be monitored.
- */
- synchronized List getActiveLots() throws SQLException {
- List list = new ArrayList<>();
- String sql = "SELECT lot_id, sale_id, current_bid, currency, closing_time, closing_notified FROM lots";
- try (Connection conn = DriverManager.getConnection(url); Statement stmt = conn.createStatement()) {
- ResultSet rs = stmt.executeQuery(sql);
- while (rs.next()) {
- Lot lot = new Lot();
- lot.lotId = rs.getInt("lot_id");
- lot.saleId = rs.getInt("sale_id");
- lot.currentBid = rs.getDouble("current_bid");
- lot.currency = rs.getString("currency");
- String closing = rs.getString("closing_time");
- lot.closingNotified = rs.getInt("closing_notified") != 0;
- if (closing != null) {
- lot.closingTime = LocalDateTime.parse(closing);
- }
- list.add(lot);
- }
+ }
+ } catch (IOException | InterruptedException | SQLException e) {
+ System.err.println("Failed to refresh bid for lot " + lot.lotId + ": " + e.getMessage());
+ }
+ }
+
+ /**
+ * Entry point. Configure database location, notification settings, and
+ * YOLO model paths here before running. Once started the scraper
+ * discovers Dutch auctions, scrapes lots, and begins monitoring.
+ */
+ public static void main(String[] args) throws Exception {
+ System.out.println("=== Troostwijk Auction Scraper ===\n");
+
+ // Configuration parameters (replace with your own values)
+ String databaseFile = "troostwijk.db";
+
+ // Notification configuration - choose one:
+ // Option 1: Desktop notifications only (free, no setup required)
+ String notificationConfig = System.getenv().getOrDefault("NOTIFICATION_CONFIG", "desktop");
+
+ // Option 2: Desktop + Email via Gmail (free, requires Gmail app password)
+ // Format: "smtp:username:appPassword:toEmail"
+ // Example: "smtp:your.email@gmail.com:abcd1234efgh5678:recipient@example.com"
+ // Get app password: Google Account > Security > 2-Step Verification > App passwords
+
+ // YOLO model paths (optional - scraper works without object detection)
+ String yoloCfg = "models/yolov4.cfg";
+ String yoloWeights = "models/yolov4.weights";
+ String yoloClasses = "models/coco.names";
+
+ // Load native OpenCV library
+ System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
+
+ System.out.println("Initializing scraper...");
+ TroostwijkScraper scraper = new TroostwijkScraper(databaseFile, notificationConfig, "",
+ yoloCfg, yoloWeights, yoloClasses);
+
+ // Step 1: Discover auctions in NL
+ System.out.println("\n[1/3] Discovering Dutch auctions...");
+ List auctions = scraper.discoverDutchAuctions();
+ System.out.println("✓ Found " + auctions.size() + " auctions: " + auctions);
+
+ // Step 2: Fetch lots for each auction
+ System.out.println("\n[2/3] Fetching lot details...");
+ int totalAuctions = auctions.size();
+ int currentAuction = 0;
+ for (int saleId : auctions) {
+ currentAuction++;
+ System.out.println(" [" + currentAuction + "/" + totalAuctions + "] Processing sale " + saleId + "...");
+ scraper.fetchLotsForSale(saleId);
+ }
+
+ // Show database summary
+ System.out.println("\n📊 Database Summary:");
+ scraper.printDatabaseStats();
+
+ // Step 3: Start monitoring bids and closures
+ System.out.println("\n[3/3] Starting monitoring service...");
+ scraper.scheduleMonitoring();
+ System.out.println("✓ Monitoring active. Press Ctrl+C to stop.\n");
+ }
+
+ /**
+ * Prints statistics about the data in the database.
+ */
+ private void printDatabaseStats() {
+ try {
+ List allLots = db.getAllLots();
+ int imageCount = db.getImageCount();
+
+ System.out.println(" Total lots in database: " + allLots.size());
+ System.out.println(" Total images downloaded: " + imageCount);
+
+ if (!allLots.isEmpty()) {
+ double totalBids = allLots.stream().mapToDouble(l -> l.currentBid).sum();
+ System.out.println(" Total current bids: €" + String.format("%.2f", totalBids));
+ }
+ } catch (SQLException e) {
+ System.err.println(" ⚠️ Could not retrieve database stats: " + e.getMessage());
+ }
+ }
+
+ // ----------------------------------------------------------------------
+ // Domain classes and services
+ // ----------------------------------------------------------------------
+
+ /**
+ * Simple POJO representing a lot (kavel) in an auction. It keeps track
+ * of the sale it belongs to, current bid and closing time. The method
+ * minutesUntilClose computes how many minutes remain until the lot closes.
+ */
+ static class Lot {
+
+ int saleId;
+ int lotId;
+ String title;
+ String description;
+ String manufacturer;
+ String type;
+ int year;
+ String category;
+ double currentBid;
+ String currency;
+ String url;
+ LocalDateTime closingTime; // null if unknown
+ boolean closingNotified;
+
+ long minutesUntilClose() {
+ if (closingTime == null) return Long.MAX_VALUE;
+ return java.time.Duration.between(LocalDateTime.now(), closingTime).toMinutes();
+ }
+ }
+
+ /**
+ * Service for persisting auctions, lots, images, and object labels into
+ * a SQLite database. Uses the Xerial JDBC driver which connects to
+ * SQLite via a URL of the form "jdbc:sqlite:path_to_file"【329850066306528†L40-L63】.
+ */
+ static class DatabaseService {
+
+ private final String url;
+ DatabaseService(String dbPath) {
+ this.url = "jdbc:sqlite:" + dbPath;
+ }
+ /**
+ * Creates tables if they do not already exist. The schema includes
+ * tables for sales, lots, images, and object labels. This method is
+ * idempotent; it can be called multiple times.
+ */
+ void ensureSchema() throws SQLException {
+ try (Connection conn = DriverManager.getConnection(url); Statement stmt = conn.createStatement()) {
+ // Sales table
+ stmt.execute("CREATE TABLE IF NOT EXISTS sales ("
+ + "sale_id INTEGER PRIMARY KEY,"
+ + "title TEXT,"
+ + "location TEXT,"
+ + "closing_time TEXT"
+ + ")");
+ // Lots table
+ stmt.execute("CREATE TABLE IF NOT EXISTS lots ("
+ + "lot_id INTEGER PRIMARY KEY,"
+ + "sale_id INTEGER,"
+ + "title TEXT,"
+ + "description TEXT,"
+ + "manufacturer TEXT,"
+ + "type TEXT,"
+ + "year INTEGER,"
+ + "category TEXT,"
+ + "current_bid REAL,"
+ + "currency TEXT,"
+ + "url TEXT,"
+ + "closing_time TEXT,"
+ + "closing_notified INTEGER DEFAULT 0,"
+ + "FOREIGN KEY (sale_id) REFERENCES sales(sale_id)"
+ + ")");
+ // Images table
+ stmt.execute("CREATE TABLE IF NOT EXISTS images ("
+ + "id INTEGER PRIMARY KEY AUTOINCREMENT,"
+ + "lot_id INTEGER,"
+ + "url TEXT,"
+ + "file_path TEXT,"
+ + "labels TEXT,"
+ + "FOREIGN KEY (lot_id) REFERENCES lots(lot_id)"
+ + ")");
+ }
+ }
+
+ /**
+ * Inserts or updates a lot record. Uses INSERT OR REPLACE to
+ * implement upsert semantics so that existing rows are replaced.
+ */
+ synchronized void upsertLot(Lot lot) throws SQLException {
+ String sql = "INSERT INTO lots (lot_id, sale_id, title, description, manufacturer, type, year, category, current_bid, currency, url, closing_time, closing_notified)"
+ + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
+ + " ON CONFLICT(lot_id) DO UPDATE SET "
+ + "sale_id = excluded.sale_id, title = excluded.title, description = excluded.description, "
+ + "manufacturer = excluded.manufacturer, type = excluded.type, year = excluded.year, category = excluded.category, "
+ + "current_bid = excluded.current_bid, currency = excluded.currency, url = excluded.url, closing_time = excluded.closing_time";
+ try (Connection conn = DriverManager.getConnection(url); PreparedStatement ps = conn.prepareStatement(sql)) {
+ ps.setInt(1, lot.lotId);
+ ps.setInt(2, lot.saleId);
+ ps.setString(3, lot.title);
+ ps.setString(4, lot.description);
+ ps.setString(5, lot.manufacturer);
+ ps.setString(6, lot.type);
+ ps.setInt(7, lot.year);
+ ps.setString(8, lot.category);
+ ps.setDouble(9, lot.currentBid);
+ ps.setString(10, lot.currency);
+ ps.setString(11, lot.url);
+ ps.setString(12, lot.closingTime != null ? lot.closingTime.toString() : null);
+ ps.setInt(13, lot.closingNotified ? 1 : 0);
+ ps.executeUpdate();
+ }
+ }
+
+ /**
+ * Inserts a new image record. Each image is associated with a lot and
+ * stores both the original URL and the local file path. Detected
+ * labels are stored as a comma separated string.
+ */
+ synchronized void insertImage(int lotId, String url, String filePath, List labels) throws SQLException {
+ String sql = "INSERT INTO images (lot_id, url, file_path, labels) VALUES (?, ?, ?, ?)";
+ try (Connection conn = DriverManager.getConnection(this.url); PreparedStatement ps = conn.prepareStatement(sql)) {
+ ps.setInt(1, lotId);
+ ps.setString(2, url);
+ ps.setString(3, filePath);
+ ps.setString(4, String.join(",", labels));
+ ps.executeUpdate();
+ }
+ }
+
+ /**
+ * Retrieves all lots that are still active (i.e., have a closing time
+ * in the future or unknown). Only these lots need to be monitored.
+ */
+ synchronized List getActiveLots() throws SQLException {
+ List list = new ArrayList<>();
+ String sql = "SELECT lot_id, sale_id, current_bid, currency, closing_time, closing_notified FROM lots";
+ try (Connection conn = DriverManager.getConnection(url); Statement stmt = conn.createStatement()) {
+ ResultSet rs = stmt.executeQuery(sql);
+ while (rs.next()) {
+ Lot lot = new Lot();
+ lot.lotId = rs.getInt("lot_id");
+ lot.saleId = rs.getInt("sale_id");
+ lot.currentBid = rs.getDouble("current_bid");
+ lot.currency = rs.getString("currency");
+ String closing = rs.getString("closing_time");
+ lot.closingNotified = rs.getInt("closing_notified") != 0;
+ if (closing != null) {
+ lot.closingTime = LocalDateTime.parse(closing);
+ }
+ list.add(lot);
}
- return list;
- }
-
- /**
- * Updates the current bid of a lot after a bid refresh.
- */
- synchronized void updateLotCurrentBid(Lot lot) throws SQLException {
- try (Connection conn = DriverManager.getConnection(url); PreparedStatement ps = conn.prepareStatement(
- "UPDATE lots SET current_bid = ? WHERE lot_id = ?")) {
- ps.setDouble(1, lot.currentBid);
- ps.setInt(2, lot.lotId);
- ps.executeUpdate();
+ }
+ return list;
+ }
+
+ /**
+ * Retrieves all lots from the database.
+ */
+ synchronized List getAllLots() throws SQLException {
+ List list = new ArrayList<>();
+ String sql = "SELECT lot_id, sale_id, title, current_bid, currency FROM lots";
+ try (Connection conn = DriverManager.getConnection(url); Statement stmt = conn.createStatement()) {
+ ResultSet rs = stmt.executeQuery(sql);
+ while (rs.next()) {
+ Lot lot = new Lot();
+ lot.lotId = rs.getInt("lot_id");
+ lot.saleId = rs.getInt("sale_id");
+ lot.title = rs.getString("title");
+ lot.currentBid = rs.getDouble("current_bid");
+ lot.currency = rs.getString("currency");
+ list.add(lot);
}
- }
-
- /**
- * Updates the closingNotified flag of a lot (set to 1 when we have
- * warned the user about its imminent closure).
- */
- synchronized void updateLotNotificationFlags(Lot lot) throws SQLException {
- try (Connection conn = DriverManager.getConnection(url); PreparedStatement ps = conn.prepareStatement(
- "UPDATE lots SET closing_notified = ? WHERE lot_id = ?")) {
- ps.setInt(1, lot.closingNotified ? 1 : 0);
- ps.setInt(2, lot.lotId);
- ps.executeUpdate();
+ }
+ return list;
+ }
+
+ /**
+ * Gets the total number of images in the database.
+ */
+ synchronized int getImageCount() throws SQLException {
+ String sql = "SELECT COUNT(*) as count FROM images";
+ try (Connection conn = DriverManager.getConnection(url); Statement stmt = conn.createStatement()) {
+ ResultSet rs = stmt.executeQuery(sql);
+ if (rs.next()) {
+ return rs.getInt("count");
}
- }
- }
-
- /**
- * Service for sending notifications via desktop notifications and/or email.
- * Supports free notification methods:
- * 1. Desktop notifications (Windows/Linux/macOS system tray)
- * 2. Email via Gmail SMTP (free, requires app password)
- *
- * Configuration:
- * - For email: Set notificationEmail to your Gmail address
- * - Enable 2FA in Gmail and create an App Password
- * - Use format "smtp:username:appPassword:toEmail" for credentials
- * - Or use "desktop" for desktop-only notifications
- */
- static class NotificationService {
- private final boolean useDesktop;
- private final boolean useEmail;
- private final String smtpUsername;
- private final String smtpPassword;
- private final String toEmail;
-
- /**
- * Creates a notification service.
- *
- * @param config "desktop" for desktop only, or "smtp:username:password:toEmail" for email
- * @param unusedParam Kept for compatibility (can pass empty string)
- */
- NotificationService(String config, String unusedParam) {
-
- if ("desktop".equalsIgnoreCase(config)) {
- this.useDesktop = true;
- this.useEmail = false;
- this.smtpUsername = null;
- this.smtpPassword = null;
- this.toEmail = null;
- } else if (config.startsWith("smtp:")) {
- String[] parts = config.split(":", 4);
- if (parts.length != 4) {
- throw new IllegalArgumentException("Email config must be 'smtp:username:password:toEmail'");
- }
- this.useDesktop = true; // Always include desktop
- this.useEmail = true;
- this.smtpUsername = parts[1];
- this.smtpPassword = parts[2];
- this.toEmail = parts[3];
+ }
+ return 0;
+ }
+
+ /**
+ * Updates the current bid of a lot after a bid refresh.
+ */
+ synchronized void updateLotCurrentBid(Lot lot) throws SQLException {
+ try (Connection conn = DriverManager.getConnection(url); PreparedStatement ps = conn.prepareStatement(
+ "UPDATE lots SET current_bid = ? WHERE lot_id = ?")) {
+ ps.setDouble(1, lot.currentBid);
+ ps.setInt(2, lot.lotId);
+ ps.executeUpdate();
+ }
+ }
+
+ /**
+ * Updates the closingNotified flag of a lot (set to 1 when we have
+ * warned the user about its imminent closure).
+ */
+ synchronized void updateLotNotificationFlags(Lot lot) throws SQLException {
+ try (Connection conn = DriverManager.getConnection(url); PreparedStatement ps = conn.prepareStatement(
+ "UPDATE lots SET closing_notified = ? WHERE lot_id = ?")) {
+ ps.setInt(1, lot.closingNotified ? 1 : 0);
+ ps.setInt(2, lot.lotId);
+ ps.executeUpdate();
+ }
+ }
+ }
+
+ /**
+ * Service for sending notifications via desktop notifications and/or email.
+ * Supports free notification methods:
+ * 1. Desktop notifications (Windows/Linux/macOS system tray)
+ * 2. Email via Gmail SMTP (free, requires app password)
+ *
+ * Configuration:
+ * - For email: Set notificationEmail to your Gmail address
+ * - Enable 2FA in Gmail and create an App Password
+ * - Use format "smtp:username:appPassword:toEmail" for credentials
+ * - Or use "desktop" for desktop-only notifications
+ */
+ static class NotificationService {
+
+ private final boolean useDesktop;
+ private final boolean useEmail;
+ private final String smtpUsername;
+ private final String smtpPassword;
+ private final String toEmail;
+
+ /**
+ * Creates a notification service.
+ *
+ * @param config "desktop" for desktop only, or "smtp:username:password:toEmail" for email
+ * @param unusedParam Kept for compatibility (can pass empty string)
+ */
+ NotificationService(String config, String unusedParam) {
+
+ if ("desktop".equalsIgnoreCase(config)) {
+ this.useDesktop = true;
+ this.useEmail = false;
+ this.smtpUsername = null;
+ this.smtpPassword = null;
+ this.toEmail = null;
+ } else if (config.startsWith("smtp:")) {
+ String[] parts = config.split(":", 4);
+ if (parts.length != 4) {
+ throw new IllegalArgumentException("Email config must be 'smtp:username:password:toEmail'");
+ }
+ this.useDesktop = true; // Always include desktop
+ this.useEmail = true;
+ this.smtpUsername = parts[1];
+ this.smtpPassword = parts[2];
+ this.toEmail = parts[3];
+ } else {
+ throw new IllegalArgumentException("Config must be 'desktop' or 'smtp:username:password:toEmail'");
+ }
+ }
+
+ /**
+ * Sends notification via configured channels.
+ *
+ * @param message The message body
+ * @param title Message title
+ * @param priority Priority level (0=normal, 1=high)
+ */
+ void sendNotification(String message, String title, int priority) {
+ if (useDesktop) {
+ sendDesktopNotification(title, message, priority);
+ }
+ if (useEmail) {
+ sendEmailNotification(title, message, priority);
+ }
+ }
+
+ /**
+ * Sends a desktop notification using system tray.
+ * Works on Windows, macOS, and Linux with desktop environments.
+ */
+ private void sendDesktopNotification(String title, String message, int priority) {
+ try {
+ if (java.awt.SystemTray.isSupported()) {
+ java.awt.SystemTray tray = java.awt.SystemTray.getSystemTray();
+ java.awt.Image image = java.awt.Toolkit.getDefaultToolkit()
+ .createImage(new byte[0]); // Empty image
+
+ java.awt.TrayIcon trayIcon = new java.awt.TrayIcon(image, "Troostwijk Scraper");
+ trayIcon.setImageAutoSize(true);
+
+ java.awt.TrayIcon.MessageType messageType = priority > 0
+ ? java.awt.TrayIcon.MessageType.WARNING
+ : java.awt.TrayIcon.MessageType.INFO;
+
+ tray.add(trayIcon);
+ trayIcon.displayMessage(title, message, messageType);
+
+ // Remove icon after 2 seconds to avoid clutter
+ Thread.sleep(2000);
+ tray.remove(trayIcon);
+
+ System.out.println("Desktop notification sent: " + title);
} else {
- throw new IllegalArgumentException("Config must be 'desktop' or 'smtp:username:password:toEmail'");
+ System.out.println("Desktop notifications not supported, logging: " + title + " - " + message);
}
- }
-
- /**
- * Sends notification via configured channels.
- *
- * @param message The message body
- * @param title Message title
- * @param priority Priority level (0=normal, 1=high)
- */
- void sendNotification(String message, String title, int priority) {
- if (useDesktop) {
- sendDesktopNotification(title, message, priority);
+ } catch (Exception e) {
+ System.err.println("Desktop notification failed: " + e.getMessage());
+ }
+ }
+
+ /**
+ * Sends email notification via Gmail SMTP (free).
+ * Uses Gmail's SMTP server with app password authentication.
+ */
+ private void sendEmailNotification(String title, String message, int priority) {
+ try {
+ java.util.Properties props = new java.util.Properties();
+ props.put("mail.smtp.auth", "true");
+ props.put("mail.smtp.starttls.enable", "true");
+ props.put("mail.smtp.host", "smtp.gmail.com");
+ props.put("mail.smtp.port", "587");
+ props.put("mail.smtp.ssl.trust", "smtp.gmail.com");
+
+ javax.mail.Session session = javax.mail.Session.getInstance(props,
+ new javax.mail.Authenticator() {
+
+ protected javax.mail.PasswordAuthentication getPasswordAuthentication() {
+ return new javax.mail.PasswordAuthentication(smtpUsername, smtpPassword);
+ }
+ });
+
+ javax.mail.Message msg = new javax.mail.internet.MimeMessage(session);
+ msg.setFrom(new javax.mail.internet.InternetAddress(smtpUsername));
+ msg.setRecipients(javax.mail.Message.RecipientType.TO,
+ javax.mail.internet.InternetAddress.parse(toEmail));
+ msg.setSubject("[Troostwijk] " + title);
+ msg.setText(message);
+ msg.setSentDate(new java.util.Date());
+
+ if (priority > 0) {
+ msg.setHeader("X-Priority", "1");
+ msg.setHeader("Importance", "High");
}
- if (useEmail) {
- sendEmailNotification(title, message, priority);
+
+ javax.mail.Transport.send(msg);
+ System.out.println("Email notification sent: " + title);
+
+ } catch (Exception e) {
+ System.err.println("Email notification failed: " + e.getMessage());
+ }
+ }
+ }
+
+ /**
+ * Service for performing object detection on images using OpenCV's DNN
+ * module. The DNN module can load pre‑trained models from several
+ * frameworks (Darknet, TensorFlow, ONNX, etc.)【784097309529506†L209-L233】. Here
+ * we load a YOLO model (Darknet) by specifying the configuration and
+ * weights files. For each image we run a forward pass and return a
+ * list of detected class labels.
+ *
+ * If model files are not found, the service operates in disabled mode
+ * and returns empty lists.
+ */
+ static class ObjectDetectionService {
+
+ private final Net net;
+ private final List classNames;
+ private final boolean enabled;
+
+ ObjectDetectionService(String cfgPath, String weightsPath, String classNamesPath) throws IOException {
+ // Check if model files exist
+ Path cfgFile = Paths.get(cfgPath);
+ Path weightsFile = Paths.get(weightsPath);
+ Path classNamesFile = Paths.get(classNamesPath);
+
+ if (!Files.exists(cfgFile) || !Files.exists(weightsFile) || !Files.exists(classNamesFile)) {
+ System.out.println("⚠️ Object detection disabled: YOLO model files not found");
+ System.out.println(" Expected files:");
+ System.out.println(" - " + cfgPath);
+ System.out.println(" - " + weightsPath);
+ System.out.println(" - " + classNamesPath);
+ System.out.println(" Scraper will continue without image analysis.");
+ this.enabled = false;
+ this.net = null;
+ this.classNames = new ArrayList<>();
+ return;
+ }
+
+ try {
+ // Load network
+ this.net = Dnn.readNetFromDarknet(cfgPath, weightsPath);
+ this.net.setPreferableBackend(DNN_BACKEND_OPENCV);
+ this.net.setPreferableTarget(DNN_TARGET_CPU);
+ // Load class names (one per line)
+ this.classNames = Files.readAllLines(classNamesFile);
+ this.enabled = true;
+ System.out.println("✓ Object detection enabled with YOLO");
+ } catch (Exception e) {
+ System.err.println("⚠️ Object detection disabled: " + e.getMessage());
+ throw new IOException("Failed to initialize object detection", e);
+ }
+ }
+ /**
+ * Detects objects in the given image file and returns a list of
+ * human‑readable labels. Only detections above a confidence
+ * threshold are returned. For brevity this method omits drawing
+ * bounding boxes. See the OpenCV DNN documentation for details on
+ * post‑processing【784097309529506†L324-L344】.
+ *
+ * @param imagePath absolute path to the image
+ * @return list of detected class names (empty if detection disabled)
+ */
+ List detectObjects(String imagePath) {
+ if (!enabled) {
+ return new ArrayList<>();
+ }
+
+ List labels = new ArrayList<>();
+ Mat image = Imgcodecs.imread(imagePath);
+ if (image.empty()) return labels;
+ // Create a 4D blob from the image
+ Mat blob = Dnn.blobFromImage(image, 1.0 / 255.0, new Size(416, 416), new Scalar(0, 0, 0), true, false);
+ net.setInput(blob);
+ List outs = new ArrayList<>();
+ List outNames = getOutputLayerNames(net);
+ net.forward(outs, outNames);
+ // Post‑process: for each detection compute score and choose class
+ float confThreshold = 0.5f;
+ for (Mat out : outs) {
+ for (int i = 0; i < out.rows(); i++) {
+ double[] data = out.get(i, 0);
+ if (data == null) continue;
+ // The first 5 numbers are bounding box, then class scores
+ double[] scores = new double[classNames.size()];
+ System.arraycopy(data, 5, scores, 0, scores.length);
+ int classId = argMax(scores);
+ double confidence = scores[classId];
+ if (confidence > confThreshold) {
+ String label = classNames.get(classId);
+ if (!labels.contains(label)) {
+ labels.add(label);
+ }
+ }
}
- }
-
- /**
- * Sends a desktop notification using system tray.
- * Works on Windows, macOS, and Linux with desktop environments.
- */
- private void sendDesktopNotification(String title, String message, int priority) {
- try {
- if (java.awt.SystemTray.isSupported()) {
- java.awt.SystemTray tray = java.awt.SystemTray.getSystemTray();
- java.awt.Image image = java.awt.Toolkit.getDefaultToolkit()
- .createImage(new byte[0]); // Empty image
-
- java.awt.TrayIcon trayIcon = new java.awt.TrayIcon(image, "Troostwijk Scraper");
- trayIcon.setImageAutoSize(true);
-
- java.awt.TrayIcon.MessageType messageType = priority > 0
- ? java.awt.TrayIcon.MessageType.WARNING
- : java.awt.TrayIcon.MessageType.INFO;
-
- tray.add(trayIcon);
- trayIcon.displayMessage(title, message, messageType);
-
- // Remove icon after 2 seconds to avoid clutter
- Thread.sleep(2000);
- tray.remove(trayIcon);
-
- System.out.println("Desktop notification sent: " + title);
- } else {
- System.out.println("Desktop notifications not supported, logging: " + title + " - " + message);
- }
- } catch (Exception e) {
- System.err.println("Desktop notification failed: " + e.getMessage());
+ }
+ return labels;
+ }
+ /**
+ * Returns the indexes of the output layers in the network. YOLO
+ * automatically discovers its output layers; other models may require
+ * manually specifying them【784097309529506†L356-L365】.
+ */
+ private List getOutputLayerNames(Net net) {
+ List names = new ArrayList<>();
+ List outLayers = net.getUnconnectedOutLayers().toList();
+ List layersNames = net.getLayerNames();
+ for (Integer i : outLayers) {
+ names.add(layersNames.get(i - 1));
+ }
+ return names;
+ }
+ /**
+ * Returns the index of the maximum value in the array.
+ */
+ private int argMax(double[] array) {
+ int best = 0;
+ double max = array[0];
+ for (int i = 1; i < array.length; i++) {
+ if (array[i] > max) {
+ max = array[i];
+ best = i;
}
- }
-
- /**
- * Sends email notification via Gmail SMTP (free).
- * Uses Gmail's SMTP server with app password authentication.
- */
- private void sendEmailNotification(String title, String message, int priority) {
- try {
- java.util.Properties props = new java.util.Properties();
- props.put("mail.smtp.auth", "true");
- props.put("mail.smtp.starttls.enable", "true");
- props.put("mail.smtp.host", "smtp.gmail.com");
- props.put("mail.smtp.port", "587");
- props.put("mail.smtp.ssl.trust", "smtp.gmail.com");
-
- javax.mail.Session session = javax.mail.Session.getInstance(props,
- new javax.mail.Authenticator() {
- protected javax.mail.PasswordAuthentication getPasswordAuthentication() {
- return new javax.mail.PasswordAuthentication(smtpUsername, smtpPassword);
- }
- });
-
- javax.mail.Message msg = new javax.mail.internet.MimeMessage(session);
- msg.setFrom(new javax.mail.internet.InternetAddress(smtpUsername));
- msg.setRecipients(javax.mail.Message.RecipientType.TO,
- javax.mail.internet.InternetAddress.parse(toEmail));
- msg.setSubject("[Troostwijk] " + title);
- msg.setText(message);
- msg.setSentDate(new java.util.Date());
-
- if (priority > 0) {
- msg.setHeader("X-Priority", "1");
- msg.setHeader("Importance", "High");
- }
-
- javax.mail.Transport.send(msg);
- System.out.println("Email notification sent: " + title);
-
- } catch (Exception e) {
- System.err.println("Email notification failed: " + e.getMessage());
- }
- }
- }
-
- /**
- * Service for performing object detection on images using OpenCV's DNN
- * module. The DNN module can load pre‑trained models from several
- * frameworks (Darknet, TensorFlow, ONNX, etc.)【784097309529506†L209-L233】. Here
- * we load a YOLO model (Darknet) by specifying the configuration and
- * weights files. For each image we run a forward pass and return a
- * list of detected class labels.
- *
- * If model files are not found, the service operates in disabled mode
- * and returns empty lists.
- */
- static class ObjectDetectionService {
- private final Net net;
- private final List classNames;
- private final boolean enabled;
-
- ObjectDetectionService(String cfgPath, String weightsPath, String classNamesPath) throws IOException {
- // Check if model files exist
- Path cfgFile = Paths.get(cfgPath);
- Path weightsFile = Paths.get(weightsPath);
- Path classNamesFile = Paths.get(classNamesPath);
-
- if (!Files.exists(cfgFile) || !Files.exists(weightsFile) || !Files.exists(classNamesFile)) {
- System.out.println("⚠️ Object detection disabled: YOLO model files not found");
- System.out.println(" Expected files:");
- System.out.println(" - " + cfgPath);
- System.out.println(" - " + weightsPath);
- System.out.println(" - " + classNamesPath);
- System.out.println(" Scraper will continue without image analysis.");
- this.enabled = false;
- this.net = null;
- this.classNames = new ArrayList<>();
- return;
- }
-
- try {
- // Load network
- this.net = Dnn.readNetFromDarknet(cfgPath, weightsPath);
- this.net.setPreferableBackend(DNN_BACKEND_OPENCV);
- this.net.setPreferableTarget(DNN_TARGET_CPU);
- // Load class names (one per line)
- this.classNames = Files.readAllLines(classNamesFile);
- this.enabled = true;
- System.out.println("✓ Object detection enabled with YOLO");
- } catch (Exception e) {
- System.err.println("⚠️ Object detection disabled: " + e.getMessage());
- throw new IOException("Failed to initialize object detection", e);
- }
- }
- /**
- * Detects objects in the given image file and returns a list of
- * human‑readable labels. Only detections above a confidence
- * threshold are returned. For brevity this method omits drawing
- * bounding boxes. See the OpenCV DNN documentation for details on
- * post‑processing【784097309529506†L324-L344】.
- *
- * @param imagePath absolute path to the image
- * @return list of detected class names (empty if detection disabled)
- */
- List detectObjects(String imagePath) {
- if (!enabled) {
- return new ArrayList<>();
- }
-
- List labels = new ArrayList<>();
- Mat image = Imgcodecs.imread(imagePath);
- if (image.empty()) return labels;
- // Create a 4D blob from the image
- Mat blob = Dnn.blobFromImage(image, 1.0 / 255.0, new Size(416, 416), new Scalar(0, 0, 0), true, false);
- net.setInput(blob);
- List outs = new ArrayList<>();
- List outNames = getOutputLayerNames(net);
- net.forward(outs, outNames);
- // Post‑process: for each detection compute score and choose class
- float confThreshold = 0.5f;
- for (Mat out : outs) {
- for (int i = 0; i < out.rows(); i++) {
- double[] data = out.get(i, 0);
- if (data == null) continue;
- // The first 5 numbers are bounding box, then class scores
- double[] scores = new double[classNames.size()];
- System.arraycopy(data, 5, scores, 0, scores.length);
- int classId = argMax(scores);
- double confidence = scores[classId];
- if (confidence > confThreshold) {
- String label = classNames.get(classId);
- if (!labels.contains(label)) {
- labels.add(label);
- }
- }
- }
- }
- return labels;
- }
- /**
- * Returns the indexes of the output layers in the network. YOLO
- * automatically discovers its output layers; other models may require
- * manually specifying them【784097309529506†L356-L365】.
- */
- private List getOutputLayerNames(Net net) {
- List names = new ArrayList<>();
- List outLayers = net.getUnconnectedOutLayers().toList();
- List layersNames = net.getLayerNames();
- for (Integer i : outLayers) {
- names.add(layersNames.get(i - 1));
- }
- return names;
- }
- /**
- * Returns the index of the maximum value in the array.
- */
- private int argMax(double[] array) {
- int best = 0;
- double max = array[0];
- for (int i = 1; i < array.length; i++) {
- if (array[i] > max) {
- max = array[i];
- best = i;
- }
- }
- return best;
- }
- }
+ }
+ return best;
+ }
+ }
}
\ No newline at end of file
diff --git a/target/classes/com/auction/scraper/TroostwijkScraper$DatabaseService.class b/target/classes/com/auction/scraper/TroostwijkScraper$DatabaseService.class
index 7069d86..55d52d5 100644
Binary files a/target/classes/com/auction/scraper/TroostwijkScraper$DatabaseService.class and b/target/classes/com/auction/scraper/TroostwijkScraper$DatabaseService.class differ
diff --git a/target/classes/com/auction/scraper/TroostwijkScraper$Lot.class b/target/classes/com/auction/scraper/TroostwijkScraper$Lot.class
index 9af6273..23d3787 100644
Binary files a/target/classes/com/auction/scraper/TroostwijkScraper$Lot.class and b/target/classes/com/auction/scraper/TroostwijkScraper$Lot.class differ
diff --git a/target/classes/com/auction/scraper/TroostwijkScraper$NotificationService$1.class b/target/classes/com/auction/scraper/TroostwijkScraper$NotificationService$1.class
index 3ebb139..7539e9d 100644
Binary files a/target/classes/com/auction/scraper/TroostwijkScraper$NotificationService$1.class and b/target/classes/com/auction/scraper/TroostwijkScraper$NotificationService$1.class differ
diff --git a/target/classes/com/auction/scraper/TroostwijkScraper$NotificationService.class b/target/classes/com/auction/scraper/TroostwijkScraper$NotificationService.class
index 526cd75..f1950ab 100644
Binary files a/target/classes/com/auction/scraper/TroostwijkScraper$NotificationService.class and b/target/classes/com/auction/scraper/TroostwijkScraper$NotificationService.class differ
diff --git a/target/classes/com/auction/scraper/TroostwijkScraper$ObjectDetectionService.class b/target/classes/com/auction/scraper/TroostwijkScraper$ObjectDetectionService.class
index f82c6a4..a123796 100644
Binary files a/target/classes/com/auction/scraper/TroostwijkScraper$ObjectDetectionService.class and b/target/classes/com/auction/scraper/TroostwijkScraper$ObjectDetectionService.class differ
diff --git a/target/classes/com/auction/scraper/TroostwijkScraper.class b/target/classes/com/auction/scraper/TroostwijkScraper.class
index c04fa8f..479f691 100644
Binary files a/target/classes/com/auction/scraper/TroostwijkScraper.class and b/target/classes/com/auction/scraper/TroostwijkScraper.class differ