diff --git a/docs/GraphQL.md b/docs/GraphQL.md new file mode 100644 index 0000000..c533853 --- /dev/null +++ b/docs/GraphQL.md @@ -0,0 +1,126 @@ +# GraphQL Auction Schema Explorer + +A Python script for exploring and testing GraphQL queries against the TBAuctions storefront API. This tool helps understand the auction schema by testing different query structures and viewing the responses. + +## Features + +- Three pre-configured GraphQL queries with varying levels of detail +- Asynchronous HTTP requests using aiohttp for efficient testing +- Error handling and formatted JSON output +- Configurable auction ID, locale, and platform parameters + +## Prerequisites + +- Python 3.7 or higher +- Required packages: `aiohttp` + +## Installation + +1. Clone or download this script +2. Install dependencies: + +```bash +pip install aiohttp +``` + +## Usage + +Run the script directly: + +```bash +python auction_explorer.py +``` + +Or make it executable and run: + +```bash +chmod +x auction_explorer.py +./auction_explorer.py +``` + +## Queries Included + +The script tests three different query structures: + +### 1. `viewingDays_simple` +Basic query that retrieves city and country code for viewing days. + +### 2. `viewingDays_with_times` +Extended query that includes date ranges (`from` and `to`) along with city information. + +### 3. `full_auction` +Comprehensive query that fetches: +- Auction ID and display ID +- Bidding status +- Buyer's premium +- Viewing days with location and timing +- Collection days with location and timing + +## Configuration + +Modify these variables in the script as needed: + +```python +GRAPHQL_ENDPOINT = "https://storefront.tbauctions.com/storefront/graphql" +auction_id = "9d5d9d6b-94de-4147-b523-dfa512d85dfa" # Replace with your auction ID +variables = { + "auctionId": auction_id, + "locale": "nl", # Change locale as needed + "platform": "TWK" # Change platform as needed +} +``` + +## Output Format + +The script outputs: +- Query name and separator +- Success status with formatted JSON response +- Or error messages if the query fails + +Example output: +``` +============================================================ +QUERY: viewingDays_simple +============================================================ +SUCCESS: +{ + "data": { + "auction": { + "viewingDays": [ + { + "city": "Amsterdam", + "countryCode": "NL" + } + ] + } + } +} +``` + +## Customization + +To add new queries, extend the `QUERIES` dictionary: + +```python +QUERIES = { + "your_query_name": """ + query YourQuery($auctionId: TbaUuid!, $locale: String!, $platform: Platform!) { + auction(id: $auctionId, locale: $locale, platform: $platform) { + # Your fields here + } + } + """, + # ... existing queries +} +``` + +## Notes + +- The script includes a 500ms delay between queries to avoid rate limiting +- Timeout is set to 30 seconds per request +- All queries use the same GraphQL endpoint and variables +- Error responses are displayed in a readable format + +## License + +This script is provided for educational and exploratory purposes. \ No newline at end of file diff --git a/src/main/java/auctiora/AuctionMonitorResource.java b/src/main/java/auctiora/AuctionMonitorResource.java index 04c222d..7c2fce3 100644 --- a/src/main/java/auctiora/AuctionMonitorResource.java +++ b/src/main/java/auctiora/AuctionMonitorResource.java @@ -41,7 +41,10 @@ public class AuctionMonitorResource { @Inject RateLimitedHttpClient httpClient; - + + @Inject + LotEnrichmentService enrichmentService; + /** * GET /api/monitor/status * Returns current monitoring status @@ -236,7 +239,37 @@ public class AuctionMonitorResource { .build(); } } - + + /** + * POST /api/monitor/trigger/graphql-enrichment + * Manually trigger GraphQL enrichment for all lots or lots closing soon + */ + @POST + @Path("/trigger/graphql-enrichment") + public Response triggerGraphQLEnrichment(@QueryParam("hoursUntilClose") @DefaultValue("24") int hours) { + try { + int enriched; + if (hours > 0) { + enriched = enrichmentService.enrichClosingSoonLots(hours); + return Response.ok(Map.of( + "message", "GraphQL enrichment triggered for lots closing within " + hours + " hours", + "enrichedCount", enriched + )).build(); + } else { + enriched = enrichmentService.enrichAllActiveLots(); + return Response.ok(Map.of( + "message", "GraphQL enrichment triggered for all lots", + "enrichedCount", enriched + )).build(); + } + } catch (Exception e) { + LOG.error("Failed to trigger GraphQL enrichment", e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", e.getMessage())) + .build(); + } + } + /** * GET /api/monitor/auctions * Returns list of all auctions diff --git a/src/main/java/auctiora/BidHistory.java b/src/main/java/auctiora/BidHistory.java new file mode 100644 index 0000000..f556886 --- /dev/null +++ b/src/main/java/auctiora/BidHistory.java @@ -0,0 +1,16 @@ +package auctiora; + +import java.time.LocalDateTime; + +/** + * Represents a bid in the bid history + */ +public record BidHistory( + int id, + String lotId, + double bidAmount, + LocalDateTime bidTime, + boolean isAutobid, + String bidderId, + Integer bidderNumber +) {} diff --git a/src/main/java/auctiora/DatabaseService.java b/src/main/java/auctiora/DatabaseService.java index bc17e2a..eb2eff6 100644 --- a/src/main/java/auctiora/DatabaseService.java +++ b/src/main/java/auctiora/DatabaseService.java @@ -250,6 +250,9 @@ public class DatabaseService { log.info("Migrating schema: Adding 'closing_notified' column to lots table"); stmt.execute("ALTER TABLE lots ADD COLUMN closing_notified INTEGER DEFAULT 0"); } + + // Migrate intelligence fields from GraphQL API + migrateIntelligenceFields(stmt); } catch (SQLException e) { // Table might not exist yet, which is fine log.debug("Could not check lots table schema: " + e.getMessage()); @@ -294,6 +297,118 @@ public class DatabaseService { } } + /** + * Migrates intelligence fields to lots table (GraphQL enrichment data) + */ + private void migrateIntelligenceFields(java.sql.Statement stmt) throws SQLException { + try (var rs = stmt.executeQuery("PRAGMA table_info(lots)")) { + var columns = new java.util.HashSet(); + while (rs.next()) { + columns.add(rs.getString("name")); + } + + // HIGH PRIORITY FIELDS + if (!columns.contains("followers_count")) { + log.info("Adding followers_count column"); + stmt.execute("ALTER TABLE lots ADD COLUMN followers_count INTEGER"); + } + if (!columns.contains("estimated_min")) { + log.info("Adding estimated_min column"); + stmt.execute("ALTER TABLE lots ADD COLUMN estimated_min REAL"); + } + if (!columns.contains("estimated_max")) { + log.info("Adding estimated_max column"); + stmt.execute("ALTER TABLE lots ADD COLUMN estimated_max REAL"); + } + if (!columns.contains("next_bid_step_cents")) { + log.info("Adding next_bid_step_cents column"); + stmt.execute("ALTER TABLE lots ADD COLUMN next_bid_step_cents INTEGER"); + } + if (!columns.contains("condition")) { + log.info("Adding condition column"); + stmt.execute("ALTER TABLE lots ADD COLUMN condition TEXT"); + } + if (!columns.contains("category_path")) { + log.info("Adding category_path column"); + stmt.execute("ALTER TABLE lots ADD COLUMN category_path TEXT"); + } + if (!columns.contains("city_location")) { + log.info("Adding city_location column"); + stmt.execute("ALTER TABLE lots ADD COLUMN city_location TEXT"); + } + if (!columns.contains("country_code")) { + log.info("Adding country_code column"); + stmt.execute("ALTER TABLE lots ADD COLUMN country_code TEXT"); + } + + // MEDIUM PRIORITY FIELDS + if (!columns.contains("bidding_status")) { + log.info("Adding bidding_status column"); + stmt.execute("ALTER TABLE lots ADD COLUMN bidding_status TEXT"); + } + if (!columns.contains("appearance")) { + log.info("Adding appearance column"); + stmt.execute("ALTER TABLE lots ADD COLUMN appearance TEXT"); + } + if (!columns.contains("packaging")) { + log.info("Adding packaging column"); + stmt.execute("ALTER TABLE lots ADD COLUMN packaging TEXT"); + } + if (!columns.contains("quantity")) { + log.info("Adding quantity column"); + stmt.execute("ALTER TABLE lots ADD COLUMN quantity INTEGER"); + } + if (!columns.contains("vat")) { + log.info("Adding vat column"); + stmt.execute("ALTER TABLE lots ADD COLUMN vat REAL"); + } + if (!columns.contains("buyer_premium_percentage")) { + log.info("Adding buyer_premium_percentage column"); + stmt.execute("ALTER TABLE lots ADD COLUMN buyer_premium_percentage REAL"); + } + if (!columns.contains("remarks")) { + log.info("Adding remarks column"); + stmt.execute("ALTER TABLE lots ADD COLUMN remarks TEXT"); + } + + // BID INTELLIGENCE FIELDS + if (!columns.contains("starting_bid")) { + log.info("Adding starting_bid column"); + stmt.execute("ALTER TABLE lots ADD COLUMN starting_bid REAL"); + } + if (!columns.contains("reserve_price")) { + log.info("Adding reserve_price column"); + stmt.execute("ALTER TABLE lots ADD COLUMN reserve_price REAL"); + } + if (!columns.contains("reserve_met")) { + log.info("Adding reserve_met column"); + stmt.execute("ALTER TABLE lots ADD COLUMN reserve_met INTEGER"); + } + if (!columns.contains("bid_increment")) { + log.info("Adding bid_increment column"); + stmt.execute("ALTER TABLE lots ADD COLUMN bid_increment REAL"); + } + if (!columns.contains("view_count")) { + log.info("Adding view_count column"); + stmt.execute("ALTER TABLE lots ADD COLUMN view_count INTEGER"); + } + if (!columns.contains("first_bid_time")) { + log.info("Adding first_bid_time column"); + stmt.execute("ALTER TABLE lots ADD COLUMN first_bid_time TEXT"); + } + if (!columns.contains("last_bid_time")) { + log.info("Adding last_bid_time column"); + stmt.execute("ALTER TABLE lots ADD COLUMN last_bid_time TEXT"); + } + if (!columns.contains("bid_velocity")) { + log.info("Adding bid_velocity column"); + stmt.execute("ALTER TABLE lots ADD COLUMN bid_velocity REAL"); + } + } catch (SQLException e) { + log.warn("Could not migrate intelligence fields: {}", e.getMessage()); + } + } + /** * Inserts or updates an auction record (typically called by external scraper) */ diff --git a/src/main/java/auctiora/Lot.java b/src/main/java/auctiora/Lot.java index d92966a..1fc07c6 100644 --- a/src/main/java/auctiora/Lot.java +++ b/src/main/java/auctiora/Lot.java @@ -4,19 +4,6 @@ import lombok.With; import java.time.Duration; import java.time.LocalDateTime; -/** - * Represents a bid in the bid history - */ -record BidHistory( - int id, - String lotId, - double bidAmount, - LocalDateTime bidTime, - boolean isAutobid, - String bidderId, - Integer bidderNumber -) {} - /// Represents a lot (kavel) in an auction. /// Data typically populated by the external scraper process. /// This project enriches the data with image analysis and monitoring. diff --git a/src/main/java/auctiora/LotEnrichmentScheduler.java b/src/main/java/auctiora/LotEnrichmentScheduler.java new file mode 100644 index 0000000..5285471 --- /dev/null +++ b/src/main/java/auctiora/LotEnrichmentScheduler.java @@ -0,0 +1,88 @@ +package auctiora; + +import io.quarkus.scheduler.Scheduled; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import lombok.extern.slf4j.Slf4j; + +/** + * Scheduled tasks for enriching lots with GraphQL intelligence data. + * Uses dynamic frequencies based on lot closing times: + * - Critical (< 1 hour): Every 5 minutes + * - Urgent (< 6 hours): Every 30 minutes + * - Normal (< 24 hours): Every 2 hours + * - All lots: Every 6 hours + */ +@Slf4j +@ApplicationScoped +public class LotEnrichmentScheduler { + + @Inject + LotEnrichmentService enrichmentService; + + /** + * Enriches lots closing within 1 hour - HIGH PRIORITY + * Runs every 5 minutes + */ + @Scheduled(cron = "0 */5 * * * ?") + public void enrichCriticalLots() { + try { + log.debug("Enriching critical lots (closing < 1 hour)"); + int enriched = enrichmentService.enrichClosingSoonLots(1); + if (enriched > 0) { + log.info("Enriched {} critical lots", enriched); + } + } catch (Exception e) { + log.error("Failed to enrich critical lots", e); + } + } + + /** + * Enriches lots closing within 6 hours - MEDIUM PRIORITY + * Runs every 30 minutes + */ + @Scheduled(cron = "0 */30 * * * ?") + public void enrichUrgentLots() { + try { + log.debug("Enriching urgent lots (closing < 6 hours)"); + int enriched = enrichmentService.enrichClosingSoonLots(6); + if (enriched > 0) { + log.info("Enriched {} urgent lots", enriched); + } + } catch (Exception e) { + log.error("Failed to enrich urgent lots", e); + } + } + + /** + * Enriches lots closing within 24 hours - NORMAL PRIORITY + * Runs every 2 hours + */ + @Scheduled(cron = "0 0 */2 * * ?") + public void enrichDailyLots() { + try { + log.debug("Enriching daily lots (closing < 24 hours)"); + int enriched = enrichmentService.enrichClosingSoonLots(24); + if (enriched > 0) { + log.info("Enriched {} daily lots", enriched); + } + } catch (Exception e) { + log.error("Failed to enrich daily lots", e); + } + } + + /** + * Enriches all active lots - LOW PRIORITY + * Runs every 6 hours to keep all data fresh + */ + @Scheduled(cron = "0 0 */6 * * ?") + public void enrichAllLots() { + try { + log.info("Starting full enrichment of all lots"); + int enriched = enrichmentService.enrichAllActiveLots(); + log.info("Full enrichment complete: {} lots updated", enriched); + } catch (Exception e) { + log.error("Failed to enrich all lots", e); + } + } +} diff --git a/src/main/java/auctiora/LotEnrichmentService.java b/src/main/java/auctiora/LotEnrichmentService.java new file mode 100644 index 0000000..51e8139 --- /dev/null +++ b/src/main/java/auctiora/LotEnrichmentService.java @@ -0,0 +1,213 @@ +package auctiora; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import lombok.extern.slf4j.Slf4j; + +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Service for enriching lots with intelligence data from GraphQL API. + * Updates existing lot records with followers, estimates, velocity, etc. + */ +@Slf4j +@ApplicationScoped +public class LotEnrichmentService { + + @Inject + TroostwijkGraphQLClient graphQLClient; + + @Inject + DatabaseService db; + + /** + * Enriches a single lot with GraphQL intelligence data + */ + public boolean enrichLot(Lot lot) { + try { + var intelligence = graphQLClient.fetchLotIntelligence(lot.lotId()); + if (intelligence == null) { + log.debug("No intelligence data for lot {}", lot.lotId()); + return false; + } + + // Merge intelligence with existing lot data + var enrichedLot = mergeLotWithIntelligence(lot, intelligence); + db.upsertLot(enrichedLot); + + log.debug("Enriched lot {} with GraphQL data", lot.lotId()); + return true; + + } catch (Exception e) { + log.warn("Failed to enrich lot {}: {}", lot.lotId(), e.getMessage()); + return false; + } + } + + /** + * Enriches multiple lots in batch (more efficient) + * @param lots List of lots to enrich + * @return Number of successfully enriched lots + */ + public int enrichLotsBatch(List lots) { + if (lots.isEmpty()) { + return 0; + } + + try { + List lotIds = lots.stream() + .map(Lot::lotId) + .collect(Collectors.toList()); + + log.info("Fetching intelligence for {} lots via GraphQL batch query", lotIds.size()); + var intelligenceList = graphQLClient.fetchBatchLotIntelligence(lotIds); + + if (intelligenceList.isEmpty()) { + log.warn("No intelligence data returned for batch of {} lots", lotIds.size()); + return 0; + } + + // Create map for fast lookup + var intelligenceMap = intelligenceList.stream() + .collect(Collectors.toMap( + LotIntelligence::lotId, + intel -> intel + )); + + int enrichedCount = 0; + for (var lot : lots) { + var intelligence = intelligenceMap.get(lot.lotId()); + if (intelligence != null) { + try { + var enrichedLot = mergeLotWithIntelligence(lot, intelligence); + db.upsertLot(enrichedLot); + enrichedCount++; + } catch (SQLException e) { + log.warn("Failed to update lot {}: {}", lot.lotId(), e.getMessage()); + } + } + } + + log.info("Successfully enriched {}/{} lots", enrichedCount, lots.size()); + return enrichedCount; + + } catch (Exception e) { + log.error("Failed to enrich lots batch: {}", e.getMessage()); + return 0; + } + } + + /** + * Enriches lots closing soon (within specified hours) with higher priority + */ + public int enrichClosingSoonLots(int hoursUntilClose) { + try { + var allLots = db.getAllLots(); + var closingSoon = allLots.stream() + .filter(lot -> lot.closingTime() != null) + .filter(lot -> { + long minutes = lot.minutesUntilClose(); + return minutes > 0 && minutes <= hoursUntilClose * 60; + }) + .toList(); + + if (closingSoon.isEmpty()) { + log.debug("No lots closing within {} hours", hoursUntilClose); + return 0; + } + + log.info("Enriching {} lots closing within {} hours", closingSoon.size(), hoursUntilClose); + return enrichLotsBatch(closingSoon); + + } catch (Exception e) { + log.error("Failed to enrich closing soon lots: {}", e.getMessage()); + return 0; + } + } + + /** + * Enriches all active lots (can be slow for large datasets) + */ + public int enrichAllActiveLots() { + try { + var allLots = db.getAllLots(); + log.info("Enriching all {} active lots", allLots.size()); + + // Process in batches to avoid overwhelming the API + int batchSize = 50; + int totalEnriched = 0; + + for (int i = 0; i < allLots.size(); i += batchSize) { + int end = Math.min(i + batchSize, allLots.size()); + List batch = allLots.subList(i, end); + + int enriched = enrichLotsBatch(batch); + totalEnriched += enriched; + + // Small delay between batches to respect rate limits + if (end < allLots.size()) { + Thread.sleep(1000); + } + } + + log.info("Finished enriching all lots. Total enriched: {}/{}", totalEnriched, allLots.size()); + return totalEnriched; + + } catch (Exception e) { + log.error("Failed to enrich all lots: {}", e.getMessage()); + return 0; + } + } + + /** + * Merges existing lot data with GraphQL intelligence + */ + private Lot mergeLotWithIntelligence(Lot lot, LotIntelligence intel) { + return new Lot( + lot.saleId(), + lot.lotId(), + lot.title(), + lot.description(), + lot.manufacturer(), + lot.type(), + lot.year(), + lot.category(), + lot.currentBid(), + lot.currency(), + lot.url(), + lot.closingTime(), + lot.closingNotified(), + // HIGH PRIORITY FIELDS from GraphQL + intel.followersCount(), + intel.estimatedMin(), + intel.estimatedMax(), + intel.nextBidStepInCents(), + intel.condition(), + intel.categoryPath(), + intel.cityLocation(), + intel.countryCode(), + // MEDIUM PRIORITY FIELDS + intel.biddingStatus(), + intel.appearance(), + intel.packaging(), + intel.quantity(), + intel.vat(), + intel.buyerPremiumPercentage(), + intel.remarks(), + // BID INTELLIGENCE FIELDS + intel.startingBid(), + intel.reservePrice(), + intel.reserveMet(), + intel.bidIncrement(), + intel.viewCount(), + intel.firstBidTime(), + intel.lastBidTime(), + intel.bidVelocity(), + null, // condition_score (computed separately) + null // provenance_docs (computed separately) + ); + } +} diff --git a/src/main/java/auctiora/LotIntelligence.java b/src/main/java/auctiora/LotIntelligence.java new file mode 100644 index 0000000..35ba48d --- /dev/null +++ b/src/main/java/auctiora/LotIntelligence.java @@ -0,0 +1,33 @@ +package auctiora; + +import java.time.LocalDateTime; + +/** + * Record holding enriched intelligence data fetched from GraphQL API + */ +public record LotIntelligence( + long lotId, + Integer followersCount, + Double estimatedMin, + Double estimatedMax, + Long nextBidStepInCents, + String condition, + String categoryPath, + String cityLocation, + String countryCode, + String biddingStatus, + String appearance, + String packaging, + Long quantity, + Double vat, + Double buyerPremiumPercentage, + String remarks, + Double startingBid, + Double reservePrice, + Boolean reserveMet, + Double bidIncrement, + Integer viewCount, + LocalDateTime firstBidTime, + LocalDateTime lastBidTime, + Double bidVelocity +) {} diff --git a/src/main/java/auctiora/TroostwijkGraphQLClient.java b/src/main/java/auctiora/TroostwijkGraphQLClient.java new file mode 100644 index 0000000..7ea9b0b --- /dev/null +++ b/src/main/java/auctiora/TroostwijkGraphQLClient.java @@ -0,0 +1,332 @@ +package auctiora; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import lombok.extern.slf4j.Slf4j; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; + +/** + * GraphQL client for fetching enriched lot data from Troostwijk API. + * Fetches intelligence fields: followers, estimates, bid velocity, condition, etc. + */ +@Slf4j +@ApplicationScoped +public class TroostwijkGraphQLClient { + + private static final String GRAPHQL_ENDPOINT = "https://www.troostwijk.com/graphql"; + private static final ObjectMapper objectMapper = new ObjectMapper(); + + @Inject + RateLimitedHttpClient rateLimitedClient; + + /** + * Fetches enriched lot data from GraphQL API + * @param lotId The lot ID to fetch + * @return LotIntelligence with enriched fields, or null if failed + */ + public LotIntelligence fetchLotIntelligence(long lotId) { + try { + String query = buildLotQuery(lotId); + String requestBody = String.format("{\"query\":\"%s\"}", + escapeJson(query)); + + var request = java.net.http.HttpRequest.newBuilder() + .uri(java.net.URI.create(GRAPHQL_ENDPOINT)) + .header("Content-Type", "application/json") + .POST(java.net.http.HttpRequest.BodyPublishers.ofString(requestBody)) + .build(); + + var response = rateLimitedClient.send( + request, + java.net.http.HttpResponse.BodyHandlers.ofString() + ); + + if (response == null || response.body() == null) { + log.debug("No response from GraphQL for lot {}", lotId); + return null; + } + + return parseLotIntelligence(response.body(), lotId); + + } catch (Exception e) { + log.warn("Failed to fetch lot intelligence for {}: {}", lotId, e.getMessage()); + return null; + } + } + + /** + * Batch fetch multiple lots in a single query (more efficient) + */ + public List fetchBatchLotIntelligence(List lotIds) { + List results = new ArrayList<>(); + + // Split into batches of 50 to avoid query size limits + int batchSize = 50; + for (int i = 0; i < lotIds.size(); i += batchSize) { + int end = Math.min(i + batchSize, lotIds.size()); + List batch = lotIds.subList(i, end); + + try { + String query = buildBatchLotQuery(batch); + String requestBody = String.format("{\"query\":\"%s\"}", + escapeJson(query)); + + var request = java.net.http.HttpRequest.newBuilder() + .uri(java.net.URI.create(GRAPHQL_ENDPOINT)) + .header("Content-Type", "application/json") + .POST(java.net.http.HttpRequest.BodyPublishers.ofString(requestBody)) + .build(); + + var response = rateLimitedClient.send( + request, + java.net.http.HttpResponse.BodyHandlers.ofString() + ); + + if (response != null && response.body() != null) { + results.addAll(parseBatchLotIntelligence(response.body(), batch)); + } + + } catch (Exception e) { + log.warn("Failed to fetch batch lot intelligence: {}", e.getMessage()); + } + } + + return results; + } + + private String buildLotQuery(long lotId) { + return """ + query { + lot(id: %d) { + id + followersCount + estimatedMin + estimatedMax + nextBidStepInCents + condition + categoryPath + city + countryCode + biddingStatus + appearance + packaging + quantity + vat + buyerPremiumPercentage + remarks + startingBid + reservePrice + reserveMet + bidIncrement + viewCount + firstBidTime + lastBidTime + bidsCount + } + } + """.formatted(lotId).replaceAll("\\s+", " "); + } + + private String buildBatchLotQuery(List lotIds) { + StringBuilder query = new StringBuilder("query {"); + + for (int i = 0; i < lotIds.size(); i++) { + query.append(String.format(""" + lot%d: lot(id: %d) { + id + followersCount + estimatedMin + estimatedMax + nextBidStepInCents + condition + categoryPath + city + countryCode + biddingStatus + vat + buyerPremiumPercentage + viewCount + bidsCount + } + """, i, lotIds.get(i))); + } + + query.append("}"); + return query.toString().replaceAll("\\s+", " "); + } + + private LotIntelligence parseLotIntelligence(String json, long lotId) { + try { + JsonNode root = objectMapper.readTree(json); + JsonNode lotNode = root.path("data").path("lot"); + + if (lotNode.isMissingNode()) { + return null; + } + + return new LotIntelligence( + lotId, + getIntOrNull(lotNode, "followersCount"), + getDoubleOrNull(lotNode, "estimatedMin"), + getDoubleOrNull(lotNode, "estimatedMax"), + getLongOrNull(lotNode, "nextBidStepInCents"), + getStringOrNull(lotNode, "condition"), + getStringOrNull(lotNode, "categoryPath"), + getStringOrNull(lotNode, "city"), + getStringOrNull(lotNode, "countryCode"), + getStringOrNull(lotNode, "biddingStatus"), + getStringOrNull(lotNode, "appearance"), + getStringOrNull(lotNode, "packaging"), + getLongOrNull(lotNode, "quantity"), + getDoubleOrNull(lotNode, "vat"), + getDoubleOrNull(lotNode, "buyerPremiumPercentage"), + getStringOrNull(lotNode, "remarks"), + getDoubleOrNull(lotNode, "startingBid"), + getDoubleOrNull(lotNode, "reservePrice"), + getBooleanOrNull(lotNode, "reserveMet"), + getDoubleOrNull(lotNode, "bidIncrement"), + getIntOrNull(lotNode, "viewCount"), + parseDateTime(getStringOrNull(lotNode, "firstBidTime")), + parseDateTime(getStringOrNull(lotNode, "lastBidTime")), + calculateBidVelocity(lotNode) + ); + + } catch (Exception e) { + log.warn("Failed to parse lot intelligence: {}", e.getMessage()); + return null; + } + } + + private List parseBatchLotIntelligence(String json, List lotIds) { + List results = new ArrayList<>(); + + try { + JsonNode root = objectMapper.readTree(json); + JsonNode data = root.path("data"); + + for (int i = 0; i < lotIds.size(); i++) { + JsonNode lotNode = data.path("lot" + i); + if (!lotNode.isMissingNode()) { + var intelligence = parseLotIntelligenceFromNode(lotNode, lotIds.get(i)); + if (intelligence != null) { + results.add(intelligence); + } + } + } + + } catch (Exception e) { + log.warn("Failed to parse batch lot intelligence: {}", e.getMessage()); + } + + return results; + } + + private LotIntelligence parseLotIntelligenceFromNode(JsonNode lotNode, long lotId) { + try { + return new LotIntelligence( + lotId, + getIntOrNull(lotNode, "followersCount"), + getDoubleOrNull(lotNode, "estimatedMin"), + getDoubleOrNull(lotNode, "estimatedMax"), + getLongOrNull(lotNode, "nextBidStepInCents"), + getStringOrNull(lotNode, "condition"), + getStringOrNull(lotNode, "categoryPath"), + getStringOrNull(lotNode, "city"), + getStringOrNull(lotNode, "countryCode"), + getStringOrNull(lotNode, "biddingStatus"), + null, // appearance not in batch query + null, // packaging not in batch query + null, // quantity not in batch query + getDoubleOrNull(lotNode, "vat"), + getDoubleOrNull(lotNode, "buyerPremiumPercentage"), + null, // remarks not in batch query + null, // startingBid not in batch query + null, // reservePrice not in batch query + null, // reserveMet not in batch query + null, // bidIncrement not in batch query + getIntOrNull(lotNode, "viewCount"), + null, // firstBidTime not in batch query + null, // lastBidTime not in batch query + calculateBidVelocity(lotNode) + ); + } catch (Exception e) { + log.warn("Failed to parse lot node: {}", e.getMessage()); + return null; + } + } + + private Double calculateBidVelocity(JsonNode lotNode) { + try { + Integer bidsCount = getIntOrNull(lotNode, "bidsCount"); + String firstBidStr = getStringOrNull(lotNode, "firstBidTime"); + + if (bidsCount == null || firstBidStr == null || bidsCount == 0) { + return null; + } + + LocalDateTime firstBid = parseDateTime(firstBidStr); + if (firstBid == null) return null; + + long hoursElapsed = java.time.Duration.between(firstBid, LocalDateTime.now()).toHours(); + if (hoursElapsed == 0) return (double) bidsCount; + + return (double) bidsCount / hoursElapsed; + + } catch (Exception e) { + return null; + } + } + + private LocalDateTime parseDateTime(String dateStr) { + if (dateStr == null || dateStr.isBlank()) return null; + + try { + return LocalDateTime.parse(dateStr, DateTimeFormatter.ISO_DATE_TIME); + } catch (Exception e) { + return null; + } + } + + private String escapeJson(String str) { + return str.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } + + private Integer getIntOrNull(JsonNode node, String field) { + JsonNode fieldNode = node.path(field); + return fieldNode.isNumber() ? fieldNode.asInt() : null; + } + + private Long getLongOrNull(JsonNode node, String field) { + JsonNode fieldNode = node.path(field); + return fieldNode.isNumber() ? fieldNode.asLong() : null; + } + + private Double getDoubleOrNull(JsonNode node, String field) { + JsonNode fieldNode = node.path(field); + return fieldNode.isNumber() ? fieldNode.asDouble() : null; + } + + private String getStringOrNull(JsonNode node, String field) { + JsonNode fieldNode = node.path(field); + return fieldNode.isTextual() ? fieldNode.asText() : null; + } + + private Boolean getBooleanOrNull(JsonNode node, String field) { + JsonNode fieldNode = node.path(field); + return fieldNode.isBoolean() ? fieldNode.asBoolean() : null; + } +}