Features
This commit is contained in:
126
docs/GraphQL.md
Normal file
126
docs/GraphQL.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
16
src/main/java/auctiora/BidHistory.java
Normal file
16
src/main/java/auctiora/BidHistory.java
Normal 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
|
||||
) {}
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
|
||||
88
src/main/java/auctiora/LotEnrichmentScheduler.java
Normal file
88
src/main/java/auctiora/LotEnrichmentScheduler.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
213
src/main/java/auctiora/LotEnrichmentService.java
Normal file
213
src/main/java/auctiora/LotEnrichmentService.java
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
33
src/main/java/auctiora/LotIntelligence.java
Normal file
33
src/main/java/auctiora/LotIntelligence.java
Normal 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
|
||||
) {}
|
||||
332
src/main/java/auctiora/TroostwijkGraphQLClient.java
Normal file
332
src/main/java/auctiora/TroostwijkGraphQLClient.java
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user