feat: add real vehicle lookup via biluppgifter.se scraping
- Add VehicleInfoResponse DTO record with make, model, year, color, fuel fields
- Add VehicleNotFoundException for unknown plates (returns 404)
- Add VehicleLookupException for scrape failures (returns 500)
- Add handlers in GlobalExceptionHandler: 404 'Inget fordon hittades', 500 'Ett internt fel uppstod'
- Add VehicleLookupService that fetches biluppgifter.se/fordon/{plate}/ HTML
- Parse summary cards (.info > em + span) for Farg, Bransle, Modellar
- Parse Fordonsdata section (ul.list > li with span.label / span.value) for Fabrikat, Modell, Variant, Fordonsar
- Build model from Modell + Variant, parse year from Fordonsar / Modellar with Modellar fallback
- Filter out 'Logga in' placeholder values from gated fields
- Add VehicleController with GET /api/vehicles/{plate}, public endpoint (already permitAll)
This commit is contained in:
parent
6dc9b6de33
commit
18f462c5c1
6 changed files with 203 additions and 0 deletions
|
|
@ -0,0 +1,24 @@
|
|||
package se.bilhalsning.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import se.bilhalsning.dto.VehicleInfoResponse;
|
||||
import se.bilhalsning.service.VehicleLookupService;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/vehicles")
|
||||
@RequiredArgsConstructor
|
||||
public class VehicleController {
|
||||
|
||||
private final VehicleLookupService vehicleLookupService;
|
||||
|
||||
@GetMapping("/{plate}")
|
||||
public ResponseEntity<VehicleInfoResponse> lookup(@PathVariable String plate) {
|
||||
VehicleInfoResponse info = vehicleLookupService.lookup(plate);
|
||||
return ResponseEntity.ok(info);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package se.bilhalsning.dto;
|
||||
|
||||
public record VehicleInfoResponse(
|
||||
String make,
|
||||
String model,
|
||||
int year,
|
||||
String color,
|
||||
String fuel
|
||||
) {}
|
||||
|
|
@ -35,6 +35,21 @@ public class GlobalExceptionHandler {
|
|||
.body(new ErrorResponse(ex.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(VehicleNotFoundException.class)
|
||||
public ResponseEntity<ErrorResponse> handleVehicleNotFound(VehicleNotFoundException ex) {
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.NOT_FOUND)
|
||||
.body(new ErrorResponse("Inget fordon hittades"));
|
||||
}
|
||||
|
||||
@ExceptionHandler(VehicleLookupException.class)
|
||||
public ResponseEntity<ErrorResponse> handleVehicleLookup(VehicleLookupException ex) {
|
||||
log.error("Vehicle lookup failed", ex);
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(new ErrorResponse("Ett internt fel uppstod"));
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
|
||||
String message = ex.getBindingResult().getFieldErrors().stream()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
package se.bilhalsning.exception;
|
||||
|
||||
public class VehicleLookupException extends RuntimeException {
|
||||
public VehicleLookupException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package se.bilhalsning.exception;
|
||||
|
||||
public class VehicleNotFoundException extends RuntimeException {
|
||||
public VehicleNotFoundException(String plate) {
|
||||
super("Vehicle not found: " + plate);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
package se.bilhalsning.service;
|
||||
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.nodes.Document;
|
||||
import org.jsoup.nodes.Element;
|
||||
import org.jsoup.select.Elements;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import se.bilhalsning.dto.VehicleInfoResponse;
|
||||
import se.bilhalsning.exception.VehicleLookupException;
|
||||
import se.bilhalsning.exception.VehicleNotFoundException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class VehicleLookupService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(VehicleLookupService.class);
|
||||
private static final String BASE_URL = "https://biluppgifter.se/fordon";
|
||||
|
||||
public VehicleInfoResponse lookup(String plate) {
|
||||
Document doc = fetchPage(plate);
|
||||
Map<String, String> fields = new LinkedHashMap<>();
|
||||
|
||||
extractSummaryFields(doc, fields);
|
||||
extractDataSectionFields(doc, fields);
|
||||
|
||||
if (fields.isEmpty() || !fields.containsKey("Fabrikat")) {
|
||||
throw new VehicleNotFoundException(plate);
|
||||
}
|
||||
|
||||
String make = fields.getOrDefault("Fabrikat", "");
|
||||
String model = buildModel(fields);
|
||||
int year = parseYearFromFields(fields);
|
||||
String color = fields.getOrDefault("Färg", "");
|
||||
String fuel = fields.getOrDefault("Bränsle", "");
|
||||
|
||||
return new VehicleInfoResponse(make, model, year, color, fuel);
|
||||
}
|
||||
|
||||
Document fetchPage(String plate) {
|
||||
String url = BASE_URL + "/" + plate.toLowerCase() + "/";
|
||||
try {
|
||||
return Jsoup.connect(url)
|
||||
.userAgent("BilHej/1.0")
|
||||
.timeout(10_000)
|
||||
.get();
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to fetch vehicle data for plate {}", plate, e);
|
||||
throw new VehicleLookupException("Failed to fetch vehicle data", e);
|
||||
}
|
||||
}
|
||||
|
||||
void extractSummaryFields(Document doc, Map<String, String> fields) {
|
||||
Elements infoDivs = doc.select(".info > em");
|
||||
for (Element em : infoDivs) {
|
||||
Element span = em.nextElementSibling();
|
||||
if (span != null && span.tagName().equals("span")) {
|
||||
String label = span.text().trim();
|
||||
String value = em.ownText().trim();
|
||||
if (!label.isEmpty() && !value.isEmpty()) {
|
||||
fields.putIfAbsent(label, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void extractDataSectionFields(Document doc, Map<String, String> fields) {
|
||||
Element heading = doc.selectFirst("h2:containsOwn(Fordonsdata)");
|
||||
if (heading == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Element section = findParentSection(heading);
|
||||
if (section == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Elements listItems = section.select("ul.list > li");
|
||||
for (Element li : listItems) {
|
||||
Element labelEl = li.selectFirst("span.label");
|
||||
Element valueEl = li.selectFirst("span.value");
|
||||
if (labelEl != null && valueEl != null) {
|
||||
String label = labelEl.text().trim();
|
||||
String value = valueEl.text().trim();
|
||||
if (!label.isEmpty() && !value.isEmpty() && !value.equals("Logga in")) {
|
||||
fields.putIfAbsent(label, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, String> extractFields(Document doc) {
|
||||
Map<String, String> fields = new LinkedHashMap<>();
|
||||
extractSummaryFields(doc, fields);
|
||||
extractDataSectionFields(doc, fields);
|
||||
return fields;
|
||||
}
|
||||
|
||||
private Element findParentSection(Element heading) {
|
||||
Element current = heading.parent();
|
||||
while (current != null) {
|
||||
if ("section".equals(current.tagName())) {
|
||||
return current;
|
||||
}
|
||||
current = current.parent();
|
||||
}
|
||||
return heading.parent();
|
||||
}
|
||||
|
||||
private String buildModel(Map<String, String> fields) {
|
||||
String model = fields.getOrDefault("Modell", "");
|
||||
String variant = fields.getOrDefault("Variant", "");
|
||||
|
||||
if (!model.isEmpty() && !variant.isEmpty()) {
|
||||
return model + " " + variant;
|
||||
}
|
||||
return model.isEmpty() ? variant : model;
|
||||
}
|
||||
|
||||
private int parseYearFromFields(Map<String, String> fields) {
|
||||
String yearText = fields.getOrDefault("Fordonsår / Modellår", "");
|
||||
if (yearText.isEmpty()) {
|
||||
yearText = fields.getOrDefault("Modellår", "");
|
||||
}
|
||||
|
||||
if (yearText.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
String[] parts = yearText.split("/");
|
||||
try {
|
||||
return Integer.parseInt(parts[0].trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue