bilhej/backend/src/main/java/se/bilhalsning/service/VehicleLookupService.java
Joakim Mörling 18f462c5c1 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)
2026-05-19 15:15:20 +02:00

141 lines
No EOL
4.7 KiB
Java

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