This commit is contained in:
Tour
2025-12-07 11:31:55 +01:00
parent 43b5fc03fd
commit 65bb5cd80a
9 changed files with 958 additions and 15 deletions

126
docs/GraphQL.md Normal file
View File

@@ -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.

View File

@@ -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

View File

@@ -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
) {}

View File

@@ -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<String>();
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)
*/

View File

@@ -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.

View File

@@ -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);
}
}
}

View File

@@ -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<Lot> lots) {
if (lots.isEmpty()) {
return 0;
}
try {
List<Long> 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<Lot> 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)
);
}
}

View File

@@ -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
) {}

View File

@@ -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<LotIntelligence> fetchBatchLotIntelligence(List<Long> lotIds) {
List<LotIntelligence> 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<Long> 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<Long> 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<LotIntelligence> parseBatchLotIntelligence(String json, List<Long> lotIds) {
List<LotIntelligence> 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;
}
}