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 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 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 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 extractFields(Document doc) { Map 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 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 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; } } }