- 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)
141 lines
No EOL
4.7 KiB
Java
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;
|
|
}
|
|
}
|
|
} |