Search.query + Offer.price

This commit is contained in:
Patrick Haßel 2024-06-18 10:24:39 +02:00
parent 2b2bbf9182
commit ec8ebf3280
10 changed files with 248 additions and 6 deletions

View File

@ -1,7 +1,10 @@
package de.ph87.kleinanzeigen.kleinanzeigen; package de.ph87.kleinanzeigen.kleinanzeigen;
import de.ph87.kleinanzeigen.kleinanzeigen.offer.LocationNotFound;
import de.ph87.kleinanzeigen.kleinanzeigen.offer.OfferCreate; import de.ph87.kleinanzeigen.kleinanzeigen.offer.OfferCreate;
import de.ph87.kleinanzeigen.kleinanzeigen.offer.OfferService; import de.ph87.kleinanzeigen.kleinanzeigen.offer.OfferService;
import de.ph87.kleinanzeigen.kleinanzeigen.search.SearchDto;
import de.ph87.kleinanzeigen.kleinanzeigen.search.SearchService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.jsoup.Jsoup; import org.jsoup.Jsoup;
@ -13,6 +16,7 @@ import org.springframework.stereotype.Service;
import java.io.IOException; import java.io.IOException;
import java.net.URI; import java.net.URI;
import java.time.DateTimeException;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@Slf4j @Slf4j
@ -21,25 +25,44 @@ import java.util.concurrent.TimeUnit;
@RequiredArgsConstructor @RequiredArgsConstructor
public class KleinanzeigenApi { public class KleinanzeigenApi {
private static final String VERSCHENKEN_EPPELBORN_30KM = "https://www.kleinanzeigen.de/s-zu-verschenken/66571/seite:%d/c192l339r%d"; private static final String VERSCHENKEN_EPPELBORN_RADIUS = "https://www.kleinanzeigen.de/s-zu-verschenken/66571/c192l339r%d";
private static final String QUERY_EPPELBORN_RADIUS = "https://www.kleinanzeigen.de/s-66571/preis:%s:%s/%s/k0l339r%d";
private final OfferService offerService; private final OfferService offerService;
private final SearchService searchService;
private final KleinanzeigenConfig config; private final KleinanzeigenConfig config;
@Scheduled(initialDelay = 0, fixedRate = 1, timeUnit = TimeUnit.MINUTES) @Scheduled(initialDelay = 0, fixedRate = 1, timeUnit = TimeUnit.MINUTES)
public void fetch() { public void fetch() {
final URI uri = URI.create(VERSCHENKEN_EPPELBORN_30KM.formatted(1, config.getRadiusKm())); fetch(VERSCHENKEN_EPPELBORN_RADIUS.formatted(config.getRadiusKm()), config.getRadiusKm());
searchService.findAllEnabledDto().forEach(this::fetch);
}
private void fetch(final SearchDto search) {
final String url = QUERY_EPPELBORN_RADIUS.formatted(
search.getPriceMinEscaped(),
search.getPriceMaxEscaped(),
search.getQueryEscaped(),
search.getRadius()
);
fetch(url, search.getRadius());
}
public void fetch(final String url, final int radius) {
final URI uri = URI.create(url);
try { try {
log.debug("Fetching page: {}", uri); log.debug("Fetching page: {}", uri);
final Document document = Jsoup.parse(uri.toURL(), 3000); final Document document = Jsoup.parse(uri.toURL(), 3000);
document.select("li.ad-listitem:not(.is-topad) article.aditem").forEach(article -> tryParse(article, uri)); document.select("li.ad-listitem:not(.is-topad) article.aditem").forEach(article -> tryParse(article, uri, radius));
} catch (IOException e) { } catch (IOException e) {
log.error("Failed to fetch Kleinanzeigen: {}", e.toString()); log.error("Failed to fetch Kleinanzeigen: {}", e.toString());
} }
} }
private void tryParse(final Element article, final URI uri) { private void tryParse(final Element article, final URI uri, final int radius) {
try { try {
final OfferCreate create = new OfferCreate(article, uri); final OfferCreate create = new OfferCreate(article, uri);
if (create.getDistance() <= radius) { if (create.getDistance() <= radius) {

View File

@ -8,6 +8,7 @@ import lombok.NoArgsConstructor;
import lombok.NonNull; import lombok.NonNull;
import lombok.ToString; import lombok.ToString;
import java.math.BigDecimal;
import java.time.ZonedDateTime; import java.time.ZonedDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@ -68,6 +69,13 @@ public class Offer {
@Nullable @Nullable
private String imageURL = null; private String imageURL = null;
@Column
@Nullable
private BigDecimal price = null;
@Column(nullable = false)
private boolean negotiable;
@ToString.Exclude @ToString.Exclude
@OneToMany(mappedBy = "offer") @OneToMany(mappedBy = "offer")
private List<Message> messages = new ArrayList<>(); private List<Message> messages = new ArrayList<>();
@ -108,6 +116,14 @@ public class Offer {
this.imageURL = create.getImageURL(); this.imageURL = create.getImageURL();
changed = true; changed = true;
} }
if ((this.price == null) != (create.getPrice() == null) || (this.price != null && this.price.compareTo(create.getPrice()) != 0)) {
this.price = create.getPrice();
changed = true;
}
if (!Objects.equals(this.negotiable, create.isNegotiable())) {
this.negotiable = create.isNegotiable();
changed = true;
}
return changed; return changed;
} }

View File

@ -10,6 +10,7 @@ import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element; import org.jsoup.nodes.Element;
import java.io.IOException; import java.io.IOException;
import java.math.BigDecimal;
import java.net.URI; import java.net.URI;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalTime; import java.time.LocalTime;
@ -52,7 +53,12 @@ public class OfferCreate {
@Nullable @Nullable
private final String imageURL; private final String imageURL;
public OfferCreate(final Element article, final URI uri) { @Nullable
private final BigDecimal price;
private final boolean negotiable;
public OfferCreate(final Element article, final URI uri) throws LocationNotFound {
articleId = article.attr("data-adid"); articleId = article.attr("data-adid");
title = article.select(".text-module-begin").text(); title = article.select(".text-module-begin").text();
description = article.select(".aditem-main--middle--description").text(); description = article.select(".aditem-main--middle--description").text();
@ -67,6 +73,15 @@ public class OfferCreate {
location = locationMatcher.group("location"); location = locationMatcher.group("location");
distance = Integer.parseInt(locationMatcher.group("distance")); distance = Integer.parseInt(locationMatcher.group("distance"));
imageURL = getImageURL(articleURL); imageURL = getImageURL(articleURL);
final Matcher priceMatcher = Pattern.compile("(?<price>\\d+(?:[,.]\\d+)?) €(?<negotiable> VB)?").matcher(article.select(".aditem-main--middle--price-shipping--price").text());
if (priceMatcher.find()) {
price = new BigDecimal(priceMatcher.group("price"));
negotiable = priceMatcher.group("negotiable") != null;
} else {
price = null;
negotiable = false;
}
} }
private ZonedDateTime parseDate(final String text) { private ZonedDateTime parseDate(final String text) {

View File

@ -5,6 +5,7 @@ import lombok.Getter;
import lombok.NonNull; import lombok.NonNull;
import lombok.ToString; import lombok.ToString;
import java.math.BigDecimal;
import java.time.ZonedDateTime; import java.time.ZonedDateTime;
@Getter @Getter
@ -51,6 +52,11 @@ public class OfferDto {
@Nullable @Nullable
private final String imageURL; private final String imageURL;
@Nullable
private final BigDecimal price;
private final boolean negotiable;
public OfferDto(final @NonNull Offer offer) { public OfferDto(final @NonNull Offer offer) {
id = offer.getId(); id = offer.getId();
version = offer.getVersion(); version = offer.getVersion();
@ -66,8 +72,15 @@ public class OfferDto {
description = offer.getDescription(); description = offer.getDescription();
articleURL = offer.getArticleURL(); articleURL = offer.getArticleURL();
imageURL = offer.getImageURL(); imageURL = offer.getImageURL();
} price = offer.getPrice();
negotiable = offer.isNegotiable();
}
public String combinePrice() {
if (price == null) {
return "Zu Verschenken";
}
return "%s €%s".formatted(price.intValue(), negotiable ? " VB" : "");
} }
} }

View File

@ -0,0 +1,49 @@
package de.ph87.kleinanzeigen.kleinanzeigen.search;
import jakarta.annotation.Nullable;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import lombok.ToString;
@Entity
@Getter
@ToString
@NoArgsConstructor
public class Search {
@Id
@GeneratedValue
private long id;
@Column(nullable = false)
private boolean enabled;
@NonNull
@Column(nullable = false)
private String query;
@Column(nullable = false)
private int radius;
@Column
@Nullable
private Integer priceMin = null;
@Column
@Nullable
private Integer priceMax = null;
public Search(final SearchCreate create) {
enabled = create.isEnabled();
query = create.getQuery();
radius = create.getRadius();
priceMin = create.getPriceMin();
priceMax = create.getPriceMax();
}
}

View File

@ -0,0 +1,31 @@
package de.ph87.kleinanzeigen.kleinanzeigen.search;
import jakarta.annotation.Nullable;
import lombok.Data;
import lombok.NonNull;
@Data
public class SearchCreate {
private final boolean enabled;
@NonNull
private final String query;
private final int radius;
@Nullable
private final Integer priceMin;
@Nullable
private final Integer priceMax;
public SearchCreate(final boolean enabled, @NonNull final String query, final int radius, @Nullable final Integer priceMin, @Nullable final Integer priceMax) {
this.enabled = enabled;
this.query = query;
this.radius = radius;
this.priceMin = priceMin;
this.priceMax = priceMax;
}
}

View File

@ -0,0 +1,48 @@
package de.ph87.kleinanzeigen.kleinanzeigen.search;
import jakarta.annotation.Nullable;
import lombok.Data;
import lombok.NonNull;
import java.util.Locale;
@Data
public class SearchDto {
private final long id;
private final boolean enabled;
@NonNull
private final String query;
private final int radius;
@Nullable
private final Integer priceMin;
@Nullable
private final Integer priceMax;
public SearchDto(final Search search) {
this.id = search.getId();
this.enabled = search.isEnabled();
this.query = search.getQuery();
this.radius = search.getRadius();
this.priceMin = search.getPriceMin();
this.priceMax = search.getPriceMax();
}
public String getQueryEscaped() {
return query.toLowerCase(Locale.ROOT).replaceAll("^[^a-z0-9]+|[^a-z0-9]+$", "").replaceAll("[^a-z0-9]+", "-");
}
public String getPriceMinEscaped() {
return priceMin == null ? "" : priceMin + "";
}
public String getPriceMaxEscaped() {
return priceMax == null ? "" : priceMax + "";
}
}

View File

@ -0,0 +1,11 @@
package de.ph87.kleinanzeigen.kleinanzeigen.search;
import org.springframework.data.repository.ListCrudRepository;
import java.util.List;
public interface SearchRepository extends ListCrudRepository<Search, Long> {
List<Search> findAllByEnabledTrue();
}

View File

@ -0,0 +1,35 @@
package de.ph87.kleinanzeigen.kleinanzeigen.search;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class SearchService {
private final SearchRepository searchRepository;
@PostConstruct
public void init() {
if (searchRepository.count() == 0) {
searchRepository.save(new Search(new SearchCreate(true, "Garten Bank", 15, 0, 30)));
searchRepository.save(new Search(new SearchCreate(true, "Teich Pumpe", 15, 0, 30)));
}
}
public List<SearchDto> findAllEnabledDto() {
return searchRepository.findAllByEnabledTrue().stream().map(this::toDto).toList();
}
private SearchDto toDto(Search search) {
return new SearchDto(search);
}
}

View File

@ -186,8 +186,9 @@ public class TelegramService {
} }
private String createText(final OfferDto offer) { private String createText(final OfferDto offer) {
return "%s\n%s\n%s\n%s\n".formatted( return "%s\n%s\n%s %s (%d km)\n%s\n%s\n".formatted(
offer.getTitle().replaceAll("\\[", "(").replaceAll("]", ")"), offer.getTitle().replaceAll("\\[", "(").replaceAll("]", ")"),
offer.combinePrice(),
offer.getZipcode(), offer.getZipcode(),
offer.getLocation(), offer.getLocation(),
offer.getDistance(), offer.getDistance(),