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()));
|
.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)
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||||
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
|
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
|
||||||
String message = ex.getBindingResult().getFieldErrors().stream()
|
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