Fix GraphQL enrichment to use displayId instead of numeric lotId

- Added displayId (String) field to Lot record for full lot ID (e.g., "A1-34732-49")
- Updated ScraperDataAdapter to extract both numeric ID and displayId from database
- Fixed TroostwijkGraphQLClient to query by displayId using lotDetails() instead of lot()
- Matched Python scraper's query structure with LOT_BIDDING_QUERY pattern
- Updated GraphQL response parsing to handle lotDetails.location and biddingStatistics
- Added upsertLotWithIntelligence() method to DatabaseService for full intelligence updates
- Updated LotEnrichmentService to pass displayId to GraphQL client

This fixes the "No intelligence data returned" error on production server.
GraphQL API requires string displayId parameter, not numeric lot ID.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Tour
2025-12-07 13:31:40 +01:00
parent 80b9841aee
commit 4d7da94315
5 changed files with 195 additions and 77 deletions

View File

@@ -634,6 +634,97 @@ public class DatabaseService {
} }
} }
/**
* Updates a lot with full intelligence data from GraphQL enrichment.
* This is a comprehensive update that includes all 24 intelligence fields.
*/
synchronized void upsertLotWithIntelligence(Lot lot) throws SQLException {
var sql = """
UPDATE lots SET
sale_id = ?,
title = ?,
description = ?,
manufacturer = ?,
type = ?,
year = ?,
category = ?,
current_bid = ?,
currency = ?,
url = ?,
closing_time = ?,
followers_count = ?,
estimated_min = ?,
estimated_max = ?,
next_bid_step_in_cents = ?,
condition = ?,
category_path = ?,
city_location = ?,
country_code = ?,
bidding_status = ?,
appearance = ?,
packaging = ?,
quantity = ?,
vat = ?,
buyer_premium_percentage = ?,
remarks = ?,
starting_bid = ?,
reserve_price = ?,
reserve_met = ?,
bid_increment = ?,
view_count = ?,
first_bid_time = ?,
last_bid_time = ?,
bid_velocity = ?
WHERE lot_id = ?
""";
try (var conn = DriverManager.getConnection(url); var ps = conn.prepareStatement(sql)) {
ps.setLong(1, lot.saleId());
ps.setString(2, lot.title());
ps.setString(3, lot.description());
ps.setString(4, lot.manufacturer());
ps.setString(5, lot.type());
ps.setInt(6, lot.year());
ps.setString(7, lot.category());
ps.setDouble(8, lot.currentBid());
ps.setString(9, lot.currency());
ps.setString(10, lot.url());
ps.setString(11, lot.closingTime() != null ? lot.closingTime().toString() : null);
// Intelligence fields
if (lot.followersCount() != null) ps.setInt(12, lot.followersCount()); else ps.setNull(12, java.sql.Types.INTEGER);
if (lot.estimatedMin() != null) ps.setDouble(13, lot.estimatedMin()); else ps.setNull(13, java.sql.Types.REAL);
if (lot.estimatedMax() != null) ps.setDouble(14, lot.estimatedMax()); else ps.setNull(14, java.sql.Types.REAL);
if (lot.nextBidStepInCents() != null) ps.setLong(15, lot.nextBidStepInCents()); else ps.setNull(15, java.sql.Types.BIGINT);
ps.setString(16, lot.condition());
ps.setString(17, lot.categoryPath());
ps.setString(18, lot.cityLocation());
ps.setString(19, lot.countryCode());
ps.setString(20, lot.biddingStatus());
ps.setString(21, lot.appearance());
ps.setString(22, lot.packaging());
if (lot.quantity() != null) ps.setLong(23, lot.quantity()); else ps.setNull(23, java.sql.Types.BIGINT);
if (lot.vat() != null) ps.setDouble(24, lot.vat()); else ps.setNull(24, java.sql.Types.REAL);
if (lot.buyerPremiumPercentage() != null) ps.setDouble(25, lot.buyerPremiumPercentage()); else ps.setNull(25, java.sql.Types.REAL);
ps.setString(26, lot.remarks());
if (lot.startingBid() != null) ps.setDouble(27, lot.startingBid()); else ps.setNull(27, java.sql.Types.REAL);
if (lot.reservePrice() != null) ps.setDouble(28, lot.reservePrice()); else ps.setNull(28, java.sql.Types.REAL);
if (lot.reserveMet() != null) ps.setInt(29, lot.reserveMet() ? 1 : 0); else ps.setNull(29, java.sql.Types.INTEGER);
if (lot.bidIncrement() != null) ps.setDouble(30, lot.bidIncrement()); else ps.setNull(30, java.sql.Types.REAL);
if (lot.viewCount() != null) ps.setInt(31, lot.viewCount()); else ps.setNull(31, java.sql.Types.INTEGER);
ps.setString(32, lot.firstBidTime() != null ? lot.firstBidTime().toString() : null);
ps.setString(33, lot.lastBidTime() != null ? lot.lastBidTime().toString() : null);
if (lot.bidVelocity() != null) ps.setDouble(34, lot.bidVelocity()); else ps.setNull(34, java.sql.Types.REAL);
ps.setLong(35, lot.lotId());
int updated = ps.executeUpdate();
if (updated == 0) {
log.warn("Failed to update lot {} - lot not found in database", lot.lotId());
}
}
}
/** /**
* Inserts a complete image record (for testing/legacy compatibility). * Inserts a complete image record (for testing/legacy compatibility).
* In production, scraper inserts with local_path, monitor updates labels via updateImageLabels. * In production, scraper inserts with local_path, monitor updates labels via updateImageLabels.

View File

@@ -11,6 +11,7 @@ import java.time.LocalDateTime;
record Lot( record Lot(
long saleId, long saleId,
long lotId, long lotId,
String displayId, // Full lot ID string (e.g., "A1-34732-49") for GraphQL queries
String title, String title,
String description, String description,
String manufacturer, String manufacturer,
@@ -140,7 +141,7 @@ record Lot(
double currentBid, String currency, String url, double currentBid, String currency, String url,
LocalDateTime closingTime, boolean closingNotified) { LocalDateTime closingTime, boolean closingNotified) {
return new Lot( return new Lot(
saleId, lotId, title, description, manufacturer, type, year, category, saleId, lotId, null, title, description, manufacturer, type, year, category,
currentBid, currency, url, closingTime, closingNotified, currentBid, currency, url, closingTime, closingNotified,
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null, null, null, null, null,

View File

@@ -27,16 +27,21 @@ public class LotEnrichmentService {
* Enriches a single lot with GraphQL intelligence data * Enriches a single lot with GraphQL intelligence data
*/ */
public boolean enrichLot(Lot lot) { public boolean enrichLot(Lot lot) {
if (lot.displayId() == null || lot.displayId().isBlank()) {
log.debug("Cannot enrich lot {} - missing displayId", lot.lotId());
return false;
}
try { try {
var intelligence = graphQLClient.fetchLotIntelligence(lot.lotId()); var intelligence = graphQLClient.fetchLotIntelligence(lot.displayId(), lot.lotId());
if (intelligence == null) { if (intelligence == null) {
log.debug("No intelligence data for lot {}", lot.lotId()); log.debug("No intelligence data for lot {}", lot.displayId());
return false; return false;
} }
// Merge intelligence with existing lot data // Merge intelligence with existing lot data
var enrichedLot = mergeLotWithIntelligence(lot, intelligence); var enrichedLot = mergeLotWithIntelligence(lot, intelligence);
db.upsertLot(enrichedLot); db.upsertLotWithIntelligence(enrichedLot);
log.debug("Enriched lot {} with GraphQL data", lot.lotId()); log.debug("Enriched lot {} with GraphQL data", lot.lotId());
return true; return true;
@@ -48,7 +53,7 @@ public class LotEnrichmentService {
} }
/** /**
* Enriches multiple lots in batch (more efficient) * Enriches multiple lots sequentially
* @param lots List of lots to enrich * @param lots List of lots to enrich
* @return Number of successfully enriched lots * @return Number of successfully enriched lots
*/ */
@@ -57,47 +62,33 @@ public class LotEnrichmentService {
return 0; return 0;
} }
try { log.info("Enriching {} lots via GraphQL", lots.size());
List<Long> lotIds = lots.stream() int enrichedCount = 0;
.map(Lot::lotId)
.collect(Collectors.toList());
log.info("Fetching intelligence for {} lots via GraphQL batch query", lotIds.size()); for (var lot : lots) {
var intelligenceList = graphQLClient.fetchBatchLotIntelligence(lotIds); if (lot.displayId() == null || lot.displayId().isBlank()) {
log.debug("Skipping lot {} - missing displayId", lot.lotId());
if (intelligenceList.isEmpty()) { continue;
log.warn("No intelligence data returned for batch of {} lots", lotIds.size());
return 0;
} }
// Create map for fast lookup try {
var intelligenceMap = intelligenceList.stream() var intelligence = graphQLClient.fetchLotIntelligence(lot.displayId(), lot.lotId());
.collect(Collectors.toMap(
LotIntelligence::lotId,
intel -> intel
));
int enrichedCount = 0;
for (var lot : lots) {
var intelligence = intelligenceMap.get(lot.lotId());
if (intelligence != null) { if (intelligence != null) {
try { var enrichedLot = mergeLotWithIntelligence(lot, intelligence);
var enrichedLot = mergeLotWithIntelligence(lot, intelligence); db.upsertLotWithIntelligence(enrichedLot);
db.upsertLot(enrichedLot); enrichedCount++;
enrichedCount++; } else {
} catch (SQLException e) { log.debug("No intelligence data for lot {}", lot.displayId());
log.warn("Failed to update lot {}: {}", lot.lotId(), e.getMessage());
}
} }
} catch (Exception e) {
log.warn("Failed to enrich lot {}: {}", lot.displayId(), e.getMessage());
} }
log.info("Successfully enriched {}/{} lots", enrichedCount, lots.size()); // Small delay to respect rate limits (handled by RateLimitedHttpClient)
return enrichedCount;
} catch (Exception e) {
log.error("Failed to enrich lots batch: {}", e.getMessage());
return 0;
} }
log.info("Successfully enriched {}/{} lots", enrichedCount, lots.size());
return enrichedCount;
} }
/** /**
@@ -169,6 +160,7 @@ public class LotEnrichmentService {
return new Lot( return new Lot(
lot.saleId(), lot.saleId(),
lot.lotId(), lot.lotId(),
lot.displayId(), // Preserve displayId
lot.title(), lot.title(),
lot.description(), lot.description(),
lot.manufacturer(), lot.manufacturer(),

View File

@@ -47,8 +47,9 @@ public class ScraperDataAdapter {
} }
public static Lot fromScraperLot(ResultSet rs) throws SQLException { public static Lot fromScraperLot(ResultSet rs) throws SQLException {
var lotId = extractNumericId(rs.getString("lot_id")); var lotIdStr = rs.getString("lot_id"); // Full display ID (e.g., "A1-34732-49")
var saleId = extractNumericId(rs.getString("auction_id")); var lotId = extractNumericId(lotIdStr);
var saleId = extractNumericId(rs.getString("auction_id"));
var bidStr = getStringOrNull(rs, "current_bid"); var bidStr = getStringOrNull(rs, "current_bid");
var bid = parseBidAmount(bidStr); var bid = parseBidAmount(bidStr);
@@ -59,6 +60,7 @@ public class ScraperDataAdapter {
return new Lot( return new Lot(
saleId, saleId,
lotId, lotId,
lotIdStr, // Store full displayId for GraphQL queries
rs.getString("title"), rs.getString("title"),
getStringOrDefault(rs, "description", ""), getStringOrDefault(rs, "description", ""),
"", "", 0, "", "", 0,

View File

@@ -33,13 +33,19 @@ public class TroostwijkGraphQLClient {
/** /**
* Fetches enriched lot data from GraphQL API * Fetches enriched lot data from GraphQL API
* @param lotId The lot ID to fetch * @param displayId The lot display ID (e.g., "A1-34732-49")
* @param lotId The numeric lot ID for mapping back to database
* @return LotIntelligence with enriched fields, or null if failed * @return LotIntelligence with enriched fields, or null if failed
*/ */
public LotIntelligence fetchLotIntelligence(long lotId) { public LotIntelligence fetchLotIntelligence(String displayId, long lotId) {
if (displayId == null || displayId.isBlank()) {
log.debug("Cannot fetch intelligence for null/blank displayId");
return null;
}
try { try {
String query = buildLotQuery(lotId); String query = buildLotQuery();
String variables = buildVariables(lotId); String variables = buildVariables(displayId);
// Proper GraphQL request format with query and variables // Proper GraphQL request format with query and variables
String requestBody = String.format( String requestBody = String.format(
@@ -61,11 +67,11 @@ public class TroostwijkGraphQLClient {
); );
if (response == null || response.body() == null) { if (response == null || response.body() == null) {
log.debug("No response from GraphQL for lot {}", lotId); log.debug("No response from GraphQL for lot {}", displayId);
return null; return null;
} }
log.debug("GraphQL response for lot {}: {}", lotId, response.body().substring(0, Math.min(200, response.body().length()))); log.debug("GraphQL response for lot {}: {}", displayId, response.body().substring(0, Math.min(200, response.body().length())));
return parseLotIntelligence(response.body(), lotId); return parseLotIntelligence(response.body(), lotId);
} catch (Exception e) { } catch (Exception e) {
@@ -114,12 +120,12 @@ public class TroostwijkGraphQLClient {
return results; return results;
} }
private String buildLotQuery(long lotId) { private String buildLotQuery() {
// TBAuctions API uses lot objects with specific fields // Match Python scraper's LOT_BIDDING_QUERY structure
// Note: This query structure needs to be adjusted based on actual TBAuctions schema // Uses lotDetails(displayId:...) instead of lot(id:...)
return """ return """
query GetLot($lotId: String!, $locale: String!, $platform: Platform!) { query LotBiddingData($lotDisplayId: String!, $locale: String!, $platform: Platform!) {
lot(id: $lotId, locale: $locale, platform: $platform) { lotDetails(displayId: $lotDisplayId, locale: $locale, platform: $platform) {
id id
displayId displayId
followersCount followersCount
@@ -128,21 +134,31 @@ public class TroostwijkGraphQLClient {
condition condition
description description
biddingStatus biddingStatus
buyerPremium buyersPremium
viewCount viewCount
estimatedValueInCentsMin
estimatedValueInCentsMax
categoryPath
location {
city
country
}
biddingStatistics {
numberOfBids
}
} }
} }
""".replaceAll("\\s+", " "); """.replaceAll("\\s+", " ");
} }
private String buildVariables(long lotId) { private String buildVariables(String displayId) {
return String.format(""" return String.format("""
{ {
"lotId": "%d", "lotDisplayId": "%s",
"locale": "%s", "locale": "%s",
"platform": "%s" "platform": "%s"
} }
""", lotId, LOCALE, PLATFORM).replaceAll("\\s+", " "); """, displayId, LOCALE, PLATFORM).replaceAll("\\s+", " ");
} }
private String buildBatchLotQuery(List<Long> lotIds) { private String buildBatchLotQuery(List<Long> lotIds) {
@@ -182,37 +198,53 @@ public class TroostwijkGraphQLClient {
} }
JsonNode root = objectMapper.readTree(json); JsonNode root = objectMapper.readTree(json);
JsonNode lotNode = root.path("data").path("lot"); JsonNode lotNode = root.path("data").path("lotDetails");
if (lotNode.isMissingNode()) { if (lotNode.isMissingNode()) {
log.debug("No lotDetails in GraphQL response");
return null; return null;
} }
// Extract location from nested object
JsonNode locationNode = lotNode.path("location");
String city = locationNode.isMissingNode() ? null : getStringOrNull(locationNode, "city");
String countryCode = locationNode.isMissingNode() ? null : getStringOrNull(locationNode, "country");
// Extract bids count from nested biddingStatistics
JsonNode statsNode = lotNode.path("biddingStatistics");
Integer bidsCount = statsNode.isMissingNode() ? null : getIntOrNull(statsNode, "numberOfBids");
// Convert cents to euros for estimates
Long estimatedMinCents = getLongOrNull(lotNode, "estimatedValueInCentsMin");
Long estimatedMaxCents = getLongOrNull(lotNode, "estimatedValueInCentsMax");
Double estimatedMin = estimatedMinCents != null ? estimatedMinCents.doubleValue() : null;
Double estimatedMax = estimatedMaxCents != null ? estimatedMaxCents.doubleValue() : null;
return new LotIntelligence( return new LotIntelligence(
lotId, lotId,
getIntOrNull(lotNode, "followersCount"), getIntOrNull(lotNode, "followersCount"),
getDoubleOrNull(lotNode, "estimatedMin"), estimatedMin,
getDoubleOrNull(lotNode, "estimatedMax"), estimatedMax,
getLongOrNull(lotNode, "nextBidStepInCents"), getLongOrNull(lotNode, "nextBidStepInCents"),
getStringOrNull(lotNode, "condition"), getStringOrNull(lotNode, "condition"),
getStringOrNull(lotNode, "categoryPath"), getStringOrNull(lotNode, "categoryPath"),
getStringOrNull(lotNode, "city"), city,
getStringOrNull(lotNode, "countryCode"), countryCode,
getStringOrNull(lotNode, "biddingStatus"), getStringOrNull(lotNode, "biddingStatus"),
getStringOrNull(lotNode, "appearance"), null, // appearance - not in API response
getStringOrNull(lotNode, "packaging"), null, // packaging - not in API response
getLongOrNull(lotNode, "quantity"), null, // quantity - not in API response
getDoubleOrNull(lotNode, "vat"), null, // vat - not in API response
getDoubleOrNull(lotNode, "buyerPremiumPercentage"), null, // buyerPremiumPercentage - could extract from buyersPremium
getStringOrNull(lotNode, "remarks"), null, // remarks - not in API response
getDoubleOrNull(lotNode, "startingBid"), null, // startingBid - not in API response
getDoubleOrNull(lotNode, "reservePrice"), null, // reservePrice - not in API response
getBooleanOrNull(lotNode, "reserveMet"), null, // reserveMet - not in API response
getDoubleOrNull(lotNode, "bidIncrement"), null, // bidIncrement - not in API response
getIntOrNull(lotNode, "viewCount"), getIntOrNull(lotNode, "viewCount"),
parseDateTime(getStringOrNull(lotNode, "firstBidTime")), null, // firstBidTime - not in API response
parseDateTime(getStringOrNull(lotNode, "lastBidTime")), null, // lastBidTime - not in API response
calculateBidVelocity(lotNode) null // bidVelocity - could calculate from bidsCount if we had timing data
); );
} catch (Exception e) { } catch (Exception e) {