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:
Joakim Mörling 2026-05-19 15:15:20 +02:00
parent 6dc9b6de33
commit 18f462c5c1
6 changed files with 203 additions and 0 deletions

View file

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

View file

@ -0,0 +1,9 @@
package se.bilhalsning.dto;
public record VehicleInfoResponse(
String make,
String model,
int year,
String color,
String fuel
) {}

View file

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

View file

@ -0,0 +1,7 @@
package se.bilhalsning.exception;
public class VehicleLookupException extends RuntimeException {
public VehicleLookupException(String message, Throwable cause) {
super(message, cause);
}
}

View file

@ -0,0 +1,7 @@
package se.bilhalsning.exception;
public class VehicleNotFoundException extends RuntimeException {
public VehicleNotFoundException(String plate) {
super("Vehicle not found: " + plate);
}
}

View file

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