From afa52cb11cb232aecbcb9552ef8805ce8ea5ac4b Mon Sep 17 00:00:00 2001 From: michael1986 Date: Thu, 27 Nov 2025 08:15:26 +0100 Subject: [PATCH] start --- .github/workflows/deploy.yml | 42 + .gitignore | 4 +- .idea/.name | 1 + .idea/dataSources.xml | 65 + .idea/material_theme_project_new.xml | 10 +- .../auction/scraper/TroostwijkScraper.java | 1587 +++++++++-------- .../TroostwijkScraper$DatabaseService.class | Bin 7932 -> 9104 bytes .../scraper/TroostwijkScraper$Lot.class | Bin 1128 -> 1128 bytes ...ostwijkScraper$NotificationService$1.class | Bin 1161 -> 1161 bytes ...roostwijkScraper$NotificationService.class | Bin 5720 -> 5720 bytes ...stwijkScraper$ObjectDetectionService.class | Bin 5821 -> 5821 bytes .../auction/scraper/TroostwijkScraper.class | Bin 15371 -> 17601 bytes 12 files changed, 968 insertions(+), 741 deletions(-) create mode 100644 .github/workflows/deploy.yml create mode 100644 .idea/.name create mode 100644 .idea/dataSources.xml 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 7069d866f22bb227b8bd796be11da7680122769e..55d52d544fd39626689fd6103e250dccb6af9288 100644 GIT binary patch delta 2093 zcmZvcc~q2D7{;IbG8-_63@$Sc(%2Cf9B@a3G!=0>EGDQKLL#Dx7#XM)Bx!U!lAiQ@ zhQ}30u@`Gh1viwJghA>G{6F!InQ}p6}lG``-J!zkBa{ZOF>e zAqQ{l+yh{cGHngXva6xm@DQiDY<5|3p?1#HG1W6_LT0+BxTLJqhH*CRQs|nMnwFZ9 z?Mca;n33Hhwx=h*)KgG0|Nb)ntF!0k&no20yA{mFv<+`+Irf2SZ^J5kf2BTb*cY~1 z+#$0H%Z3*hr!lcY7zbnZ?^5(U+T^w~)o-*eAMaB2r}xm7UjYYTVXRR*=Gd5}f8C7>LQDu33!D;$E)tDc>4rg^ z%cM@FJ}BLdtRAiuj$t^6L(E}A5f0;hBWr2PN=@3~$jrzi%xmKO2k_t{e5|k65Z`X9 zs?-;pg!Wu89ziky?PMT4k^rv^LZT#MqzuM*NkXn9qe$++LK%W88H!bMCu(IFd@>wO zG6KgX1*c_z5=l|v)L0p57#u)2?QkAPaf}8+)oq4B9ZxUapp(n%kH_%|-36&0I6*3c zCap_{9sh9sDL!MZjoR4`M{{)3{a~l*G?*lvlrl&ula$7zzubipG6CZx3sV?dKz)Mx zf@B+pCXkX7d4?e?!tk_V$ij>->V`D8(;fFYJL6;!?{l+^p6g`;oCG?R=+K`u@AxpIGe~f3++= zhE-ch;nXfHow6Ov(j42h5LZ`~r}cKl_VxL&N$@(xxGXNSW4#a0>!Unl^ioS~W-ph; zhpmFib#{vn;>y20P;itR?@%pOKgBhUooTVJA#?{K(!D-(RLF?y<->A8lF4QAVWlF<7?;WM zhL2Sv!5p_A!ErwHRPgeNpY&5`Z?5NbcmH%9qLWN<`z>g%k2PCVLS;}rF*wi%*`$9D zrm+++&rK34sH0J0=wcqdYL0`9-SOYoWo$ClaLAL~o#ot}73d>RvB|8!XlgcNCDaP) zYFQa*AZ}*S(VMttF40dp#H68EX0X@WYE29KAZP}09#qcqmNnCppKmK%1q5Ga>oD-P zf!V;{64uT~bxQU>d!hJ-^)gR=D|rvj`nxBI=hBZ(ydr?xu}5oi*W|V~@imB$wTO~u z&|TIcUYd%Xkxf?o~2fZwS$Qv8#@i@3&7CmOZm0u<{lyb%BZ delta 1248 zcmZ9KTWnNy5XOHqyX`LB-7ec3TNcZ1Wr3^&Vi%=Wihy?$f)OQ}P#y|ASd9^@_|PUi zSi+Nuc$7yp))y0FVoIQtBH(Qi1rfQ`J7^V9D3nXN)asnmbtyia^Zn0%&dm4CH}l$q z!;K|-M|wU2=5YPPk|klMFPxR0u1F*+vbSqN=^N?{j$i3NC8V92K-yUwtE~M?h_tM1 zUeVCH`q`&nT-6}aV$PN@z_|eD!;E9l*%CWl#E_eGpseQgvVbqZRcBvWV<=!$MQlgt zbt=n~ckQt6QhXp=@XJ<8r5jgH6>9G&JK1IEo^~_dvfyKiE!OKiSN`r2H+!lPB0Gu7 zZsM|sl=P92^A`+nd}L4?YYer7&m(N_%#Pi zolmCojk%)aoSv%gn^YgXaK(kiR(C0T0%xcWHrn}jqr$;@|4Ba z7_KvXN28vg$IPWT%RNB>BE0DdDmEFNh1qn{$UM)*PIK4Q<3lel4fpZ%eHXZC;34{jq}uU2Qr*soOtbpj!clhA6?I+0nL zpk62OfZoF6T0^r==6OxhrYSaPEgSV#-qtkl=~Uj=X>7OLV}|`27j$}|+j>@e{ua~5 zOn$U30;E~bPyGClO(($)Z+-lfbEYsI&6^SA8oyY#LWrUKaQ^FdGKOwu~0X_lEfo4f69so@I4 zHoe2^DPx6V-0w}$XVYx;M7uL;FHEpKzfn2oiPV!f?a^G*(KiBx@ zF-7k&@8@%;F5q6B%W}hK3}4ZOWB3;p_}dEn9R>c50{_9X!i|*NQ_iA+q}Cp)va0RU2l2O$6e diff --git a/target/classes/com/auction/scraper/TroostwijkScraper$NotificationService.class b/target/classes/com/auction/scraper/TroostwijkScraper$NotificationService.class index 526cd7538504728a06407b9ce1977b87ec2e5a96..f1950ab83b04d341b7ad7ba3eb6c5931d6139980 100644 GIT binary patch delta 289 zcmWN}%`3wJ9LMpu?`O@!P@86G4w{)|3Pa++5rqRzN)d;Zl+tc;;;1yPt}c>tQF4{1 zdC1e`Or$t?h!DR&;PuMHGO>+uZ`Up^+oQnt@!0_ZJ0xgFR9Kp*9n)YZwAv{hc1E|I z(`Oe9*d;@D#i(7AunZG+L(*)s{r9ocqIp*`TYM`wCU_4}TCUUAzS9(%`Y9~9ds zA^URvZ))v_xc$;%f3yoaC3@vzP;N$)&6skSQa;nlBPkE_D%e~on#mfLpIjAFqyXhA zA)-?1RYseFbSvceO2!ptQdN!*J3hj!s+m)iMb)sZ7%Qq}RduYXo-H-8tvEYsB(3ID H!vFFQc>6)J delta 289 zcmWO0&nv@m0LJmhJCQ_vx5kBo9Hi!ei#TvZ;eeA`HXD{1i`+IOE?Cxgi=vSqxiItN zpk{uABIdlmf`7s14|u(wr|2%avjMBmqf*9Ig;x%Is%1iT_|-r_%}lC|DRnZf9)jv; zMni-&!mP#!%g3C8%qv7hODxzKD``blPE6IT$i=EDt=Yql-+@h)v8773Wf4~mJ8C3h z`nFm~s-2WN*i|=s>SbSp9B7oZyd28Uks_RGkz=!Qq7BY&STYoflG85u8%W0HZMdM#yj&arUq{$Zg2k6a81` zvUnK`79r=|Ey(+gPd$lfBD&>xX6%v-yTWbP6x$7Dc1w>X8L&G>?4B`uVA39$wkPK7 znR$C*!CqOm6f5?|n!U4OA8gwvyY|JNeX}n(v>%S_mkawNDTiZV7#BwbIN_A+wC^}$ zXV$anOyrU+H+k|)3OYHeaomdTEEdKbe*-QxlCLIwYNlQ-^sALYwZ&__*THs79b~C9 UJ-YC#n~-`4r(LDKc%3iy51lbbl>h($ diff --git a/target/classes/com/auction/scraper/TroostwijkScraper.class b/target/classes/com/auction/scraper/TroostwijkScraper.class index c04fa8f80eb3f545064c12ad0310fc3fd02e880e..479f69160317ca8b2b92dcf583c5aab80023e67f 100644 GIT binary patch literal 17601 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 N@f_!=^vtH<{{r9>8zTS! delta 7527 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 MQ?IDk)Efx=57c4AW&i*H