suumo-search

Perform advanced searches on Suumo.jp
git clone https://git.neuralcrash.com/suumo-search.git
Log | Files | Refs | README

commit a287fa62af88f84cc56e85f5672da60d94c70a68
parent 23ea52dcb52d912290ece0a93a78c79d6bd7d4d3
Author: Kebigon <git@kebigon.xyz>
Date:   Sun,  5 Apr 2020 15:08:28 +0900

Add possibility to configure several searches with different conditions
Diffstat:
Msrc/main/java/xyz/kebigon/housesearch/HouseSearchApplication.java | 129+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Msrc/main/java/xyz/kebigon/housesearch/domain/SearchConditions.java | 135++++++++++++++++++++++++++++++++-----------------------------------------------
Msrc/main/java/xyz/kebigon/housesearch/mail/EmailSender.java | 255+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Asrc/main/packaged-resources/cfg/cheap-house-expression.cfg | 11+++++++++++
Dsrc/main/packaged-resources/cfg/search-conditions.cfg | 22----------------------
Asrc/main/packaged-resources/cfg/searches.json | 33+++++++++++++++++++++++++++++++++
Rsrc/main/packaged-resources/cfg/search-expression.cfg -> src/main/packaged-resources/cfg/tokyo-house-expression.cfg | 0
7 files changed, 297 insertions(+), 288 deletions(-)

diff --git a/src/main/java/xyz/kebigon/housesearch/HouseSearchApplication.java b/src/main/java/xyz/kebigon/housesearch/HouseSearchApplication.java @@ -19,66 +19,71 @@ import xyz.kebigon.housesearch.mail.EmailSender; @Slf4j public class HouseSearchApplication { - public static void main(String[] args) throws IOException, EmailException - { - final SearchConditions conditions = SearchConditions.load(); - final SentPostingsCache sentPostings = SentPostingsCache.load(); - - try - { - Collection<Posting> postings; - - try (final SuumoBrowser suumo = new SuumoBrowser()) - { - postings = suumo.search(conditions, sentPostings); - } - - if (postings.isEmpty()) - { - log.info("No postings found on Suumo, terminating"); - return; - } - - if (!StringUtils.isEmpty(conditions.getExpression())) - { - try (final YahooTransitBrowser yahooTransit = new YahooTransitBrowser()) - { - ApplicationContext.setYahooTransitBrowser(yahooTransit); - - postings = postings.stream() // Do not parallel here - .filter(property -> SearchConditionsValidator.validateExpression(property, conditions)).collect(Collectors.toList()); - } - - if (postings.isEmpty()) - { - log.info("No postings left after applying expression filter, terminating"); - return; - } - } - - log.info("=======[ RESULTS ]======="); - log.info("Found {} postings", postings.size()); - - for (final Posting posting : postings) - log.info("-> {}", posting); - - log.info("Sending email notification"); - - final EmailSender sender = new EmailSender(); - sender.send(postings); - - // Register sent postings - postings.forEach(sentPostings::add); - - log.info("Email notification sent, terminating"); - } - catch (final Throwable t) - { - log.error("Unrecoverable exception", t); - } - finally - { - sentPostings.save(); - } - } + public static void main(String[] args) throws IOException, EmailException + { + final SentPostingsCache sentPostings = SentPostingsCache.load(); + + try + { + for (final SearchConditions conditions : SearchConditions.load()) + processConditions(conditions, sentPostings); + } + catch (final Throwable t) + { + log.error("Unrecoverable exception", t); + } + finally + { + sentPostings.save(); + } + } + + private static void processConditions(SearchConditions conditions, SentPostingsCache sentPostings) throws IOException, EmailException + { + Collection<Posting> postings; + + try (final SuumoBrowser suumo = new SuumoBrowser()) + { + postings = suumo.search(conditions, sentPostings); + } + + if (postings.isEmpty()) + { + log.info("No postings found on Suumo, terminating"); + return; + } + + if (!StringUtils.isEmpty(conditions.getExpression())) + { + try (final YahooTransitBrowser yahooTransit = new YahooTransitBrowser()) + { + ApplicationContext.setYahooTransitBrowser(yahooTransit); + + postings = postings.stream() // Do not parallel here + .filter(property -> SearchConditionsValidator.validateExpression(property, conditions)).collect(Collectors.toList()); + } + + if (postings.isEmpty()) + { + log.info("No postings left after applying expression filter, terminating"); + return; + } + } + + log.info("=======[ RESULTS ]======="); + log.info("Found {} postings", postings.size()); + + for (final Posting posting : postings) + log.info("-> {}", posting); + + log.info("Sending email notification"); + + final EmailSender sender = new EmailSender(); + sender.send(postings, conditions); + + // Register sent postings + postings.forEach(sentPostings::add); + + log.info("Email notification sent, terminating"); + } } diff --git a/src/main/java/xyz/kebigon/housesearch/domain/SearchConditions.java b/src/main/java/xyz/kebigon/housesearch/domain/SearchConditions.java @@ -3,94 +3,69 @@ package xyz.kebigon.housesearch.domain; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; -import java.util.Properties; -import org.springframework.util.StringUtils; +import org.apache.commons.lang3.StringUtils; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.Data; @Data public class SearchConditions { - private Area area; - private AnounceType type; - private Long minPrice; - private Long maxPrice; - private Integer minAge; - private Integer maxAge; - private Integer minLandSurface; - private Integer maxLandSurface; - private Integer minHouseSurface; - private Integer maxHouseSurface; - private Integer maxWalkTimeToStation; - private String expression; - - public static SearchConditions load() throws IOException - { - final Properties properties = new Properties(); - properties.load(SearchConditions.class.getClassLoader().getResourceAsStream("search-conditions.cfg")); - - final SearchConditions conditions = new SearchConditions(); - conditions.setArea(areaProp(properties, "area")); - conditions.setType(anounceTypeProp(properties, "type")); - conditions.setMinPrice(longProp(properties, "price.min")); - conditions.setMaxPrice(longProp(properties, "price.max")); - conditions.setMinAge(intProp(properties, "age.min")); - conditions.setMaxAge(intProp(properties, "age.max")); - conditions.setMinLandSurface(intProp(properties, "surface.land.min")); - conditions.setMaxLandSurface(intProp(properties, "surface.land.max")); - conditions.setMinHouseSurface(intProp(properties, "surface.house.min")); - conditions.setMaxHouseSurface(intProp(properties, "surface.house.max")); - conditions.setMaxWalkTimeToStation(intProp(properties, "station.walktime.max")); - conditions.setExpression(expressionProp(properties, "expression.file")); - return conditions; - } - - private static Area areaProp(Properties properties, String key) - { - final String property = properties.getProperty(key); - return StringUtils.isEmpty(property) ? null : Area.valueOf(property); - } - - private static AnounceType anounceTypeProp(Properties properties, String key) - { - final String property = properties.getProperty(key); - return StringUtils.isEmpty(property) ? null : AnounceType.valueOf(property); - } - - private static Integer intProp(Properties properties, String key) - { - final String property = properties.getProperty(key); - return StringUtils.isEmpty(property) ? null : Integer.parseInt(property); - } - - private static Long longProp(Properties properties, String key) - { - final String property = properties.getProperty(key); - return StringUtils.isEmpty(property) ? null : Long.parseLong(property); - } + private String name; + private Area area; + private AnounceType type; + @JsonProperty("price.min") + private Long minPrice; + @JsonProperty("price.max") + private Long maxPrice; + @JsonProperty("age.min") + private Integer minAge; + @JsonProperty("age.max") + private Integer maxAge; + @JsonProperty("surface.land.min") + private Integer minLandSurface; + @JsonProperty("surface.land.max") + private Integer maxLandSurface; + @JsonProperty("surface.house.min") + private Integer minHouseSurface; + @JsonProperty("surface.house.max") + private Integer maxHouseSurface; + @JsonProperty("station.walktime.max") + private Integer maxWalkTimeToStation; + private String expression; - private static String expressionProp(Properties properties, String key) throws IOException - { - final String property = properties.getProperty(key); - if (StringUtils.isEmpty(property)) - return null; + public static SearchConditions[] load() throws IOException + { + final ObjectMapper mapper = new ObjectMapper(); + return mapper.readValue(SearchConditions.class.getClassLoader().getResourceAsStream("searches.json"), SearchConditions[].class); + } - String expression = ""; - try (final BufferedReader reader = new BufferedReader(new InputStreamReader(SearchConditions.class.getClassLoader().getResourceAsStream(property)))) - { - String line; - while ((line = reader.readLine()) != null) - { - line = line.trim(); - if (line.isEmpty()) - continue; - if (line.startsWith("#")) - continue; - expression += line; - } - } + @JsonProperty("expression.file") + private void expressionProp(String expressionFile) throws IOException + { + if (StringUtils.isEmpty(expressionFile)) + { + expression = null; + return; + } - return expression; - } + expression = ""; + try (final BufferedReader reader = new BufferedReader( + new InputStreamReader(SearchConditions.class.getClassLoader().getResourceAsStream(expressionFile)))) + { + String line; + while ((line = reader.readLine()) != null) + { + line = line.trim(); + if (line.isEmpty()) + continue; + if (line.startsWith("#")) + continue; + expression += line; + } + } + } } diff --git a/src/main/java/xyz/kebigon/housesearch/mail/EmailSender.java b/src/main/java/xyz/kebigon/housesearch/mail/EmailSender.java @@ -13,131 +13,138 @@ import org.apache.commons.mail.HtmlEmail; import xyz.kebigon.housesearch.HouseSearchApplication; import xyz.kebigon.housesearch.domain.Posting; import xyz.kebigon.housesearch.domain.Route; +import xyz.kebigon.housesearch.domain.SearchConditions; public class EmailSender { - private final Session mailSession; - - public EmailSender() throws IOException - { - final Properties mailProperties = new Properties(); - mailProperties.load(HouseSearchApplication.class.getClassLoader().getResourceAsStream("mail.cfg")); - mailSession = Session.getInstance(mailProperties); - } - - public void send(Collection<Posting> postings) throws EmailException - { - // Send email separately - if (postings.size() <= 3) - { - for (final Posting posting : postings) - { - final StringBuilder builder = new StringBuilder(); - appendPosting(posting, builder); - send(posting.getAge() + "yo house found near " + posting.getStation() + " station for " + (posting.getPrice() / 10000) + "万円", - builder.toString()); - } - } - // Send one email with all results - else - { - final StringBuilder builder = new StringBuilder(); - for (final Posting posting : postings) - appendPosting(posting, builder); - send(postings.size() + " houses found", builder.toString()); - } - } - - private void send(String title, String content) throws EmailException - { - final Email email = new HtmlEmail(); - email.setMailSession(mailSession); - email.setSubject(title); - email.setCharset("UTF-8"); - email.setMsg(content); - - String from = mailSession.getProperty("housesearch.mail.from"); - String to = mailSession.getProperty("housesearch.mail.to"); - String bcc = mailSession.getProperty("housesearch.mail.bcc"); - - if (from != null && !(from = from.trim()).isEmpty()) - email.setFrom(from); - - if (to != null && !(to = to.trim()).isEmpty()) - for (final String address : mailSession.getProperty(to).split(",")) - email.addTo(address.trim()); - - if (bcc != null && !(bcc = bcc.trim()).isEmpty()) - for (final String address : bcc.split(",")) - email.addBcc(address.trim()); - - email.send(); - } - - private static void appendPosting(Posting posting, StringBuilder builder) - { - builder.append("<p><a href=\"").append(posting.getUrl()).append("\">"); - builder.append(posting.getAge()).append("yo house near ").append(posting.getStation()).append(" station for ").append(posting.getPrice() / 10000) - .append("万円"); - builder.append("</a>"); - builder.append("<br>House surface: ").append(Math.round(posting.getHouseSurface())).append("m2 / ") - .append(Math.round(posting.getHouseSurface() * 0.3025)).append("坪"); - builder.append("<br>Land surface: ").append(Math.round(posting.getLandSurface())).append("m2 / ").append(Math.round(posting.getLandSurface() * 0.3025)) - .append("坪"); - - appendRoutes(posting, builder); - - builder.append("</p>"); - } - - private static void appendRoutes(Posting posting, StringBuilder builder) - { - final Route fastestRoute = posting.getFastestRoute(); - final Route cheapestRoute = posting.getCheapestRoute(); - final Route easiestRoute = posting.getEasiestRoute(); - - // No route have been analyzed - if (fastestRoute == null && cheapestRoute == null && easiestRoute == null) - return; - - builder.append("<br>Routes: "); - - if (fastestRoute == cheapestRoute) - if (fastestRoute == easiestRoute) - appendRoute("Best", posting, fastestRoute, builder); - else - { - appendRoute("Fastest & Cheapest", posting, fastestRoute, builder); - appendRoute("Easiest", posting, easiestRoute, builder); - } - else if (fastestRoute == easiestRoute) - { - appendRoute("Fastest & Easiest", posting, fastestRoute, builder); - appendRoute("Cheapest", posting, cheapestRoute, builder); - } - else - { - appendRoute("Fastest", posting, fastestRoute, builder); - - if (cheapestRoute == easiestRoute) - appendRoute("Cheapest & Easiest", posting, cheapestRoute, builder); - else - { - appendRoute("Cheapest", posting, cheapestRoute, builder); - appendRoute("Easiest", posting, easiestRoute, builder); - } - } - } - - private static void appendRoute(String label, Posting property, Route route, StringBuilder builder) - { - if (route == null) - return; - - builder.append("<br>- ").append(label).append(": "); - builder.append(route.getTime() + property.getWalkTimeToStation()).append("min to ").append(route.getTo()); - builder.append(" (walk: ").append(property.getWalkTimeToStation()).append("min, train: ").append(route.getTime()).append("min)"); - builder.append(" / ").append(route.getFare()).append("円"); - builder.append(" / ").append(route.getTransfer()).append(" transfer"); - } + private static final String ONE_POSTING_TITLE = "[%1$s] %2$syo house found near %3$s station for %4$s万円"; + private static final String SEVERAL_POSTINGS_TITLE = "[%1$s] %2$s houses found"; + + private final Session mailSession; + + public EmailSender() throws IOException + { + final Properties mailProperties = new Properties(); + mailProperties.load(HouseSearchApplication.class.getClassLoader().getResourceAsStream("mail.cfg")); + mailSession = Session.getInstance(mailProperties); + } + + public void send(Collection<Posting> postings, SearchConditions conditions) throws EmailException + { + // Send email separately + if (postings.size() <= 3) + { + for (final Posting posting : postings) + { + final String title = String.format(ONE_POSTING_TITLE, conditions.getName(), posting.getAge(), posting.getStation(), posting.getPrice() / 10000); + + final StringBuilder builder = new StringBuilder(); + appendPosting(posting, builder); + send(title, builder.toString()); + } + } + // Send one email with all results + else + { + final String title = String.format(SEVERAL_POSTINGS_TITLE, conditions.getName(), postings.size()); + + final StringBuilder builder = new StringBuilder(); + for (final Posting posting : postings) + appendPosting(posting, builder); + send(title, builder.toString()); + } + } + + private void send(String title, String content) throws EmailException + { + final Email email = new HtmlEmail(); + email.setMailSession(mailSession); + email.setSubject(title); + email.setCharset("UTF-8"); + email.setMsg(content); + + String from = mailSession.getProperty("housesearch.mail.from"); + String to = mailSession.getProperty("housesearch.mail.to"); + String bcc = mailSession.getProperty("housesearch.mail.bcc"); + + if (from != null && !(from = from.trim()).isEmpty()) + email.setFrom(from); + + if (to != null && !(to = to.trim()).isEmpty()) + for (final String address : mailSession.getProperty(to).split(",")) + email.addTo(address.trim()); + + if (bcc != null && !(bcc = bcc.trim()).isEmpty()) + for (final String address : bcc.split(",")) + email.addBcc(address.trim()); + + email.send(); + } + + private static void appendPosting(Posting posting, StringBuilder builder) + { + builder.append("<p><a href=\"").append(posting.getUrl()).append("\">"); + builder.append(posting.getAge()).append("yo house near ").append(posting.getStation()).append(" station for ").append(posting.getPrice() / 10000) + .append("万円"); + builder.append("</a>"); + builder.append("<br>House surface: ").append(Math.round(posting.getHouseSurface())).append("m2 / ") + .append(Math.round(posting.getHouseSurface() * 0.3025)).append("坪"); + builder.append("<br>Land surface: ").append(Math.round(posting.getLandSurface())).append("m2 / ").append(Math.round(posting.getLandSurface() * 0.3025)) + .append("坪"); + + appendRoutes(posting, builder); + + builder.append("</p>"); + } + + private static void appendRoutes(Posting posting, StringBuilder builder) + { + final Route fastestRoute = posting.getFastestRoute(); + final Route cheapestRoute = posting.getCheapestRoute(); + final Route easiestRoute = posting.getEasiestRoute(); + + // No route have been analyzed + if (fastestRoute == null && cheapestRoute == null && easiestRoute == null) + return; + + builder.append("<br>Routes: "); + + if (fastestRoute == cheapestRoute) + if (fastestRoute == easiestRoute) + appendRoute("Best", posting, fastestRoute, builder); + else + { + appendRoute("Fastest & Cheapest", posting, fastestRoute, builder); + appendRoute("Easiest", posting, easiestRoute, builder); + } + else if (fastestRoute == easiestRoute) + { + appendRoute("Fastest & Easiest", posting, fastestRoute, builder); + appendRoute("Cheapest", posting, cheapestRoute, builder); + } + else + { + appendRoute("Fastest", posting, fastestRoute, builder); + + if (cheapestRoute == easiestRoute) + appendRoute("Cheapest & Easiest", posting, cheapestRoute, builder); + else + { + appendRoute("Cheapest", posting, cheapestRoute, builder); + appendRoute("Easiest", posting, easiestRoute, builder); + } + } + } + + private static void appendRoute(String label, Posting property, Route route, StringBuilder builder) + { + if (route == null) + return; + + builder.append("<br>- ").append(label).append(": "); + builder.append(route.getTime() + property.getWalkTimeToStation()).append("min to ").append(route.getTo()); + builder.append(" (walk: ").append(property.getWalkTimeToStation()).append("min, train: ").append(route.getTime()).append("min)"); + builder.append(" / ").append(route.getFare()).append("円"); + builder.append(" / ").append(route.getTransfer()).append(" transfer"); + } } diff --git a/src/main/packaged-resources/cfg/cheap-house-expression.cfg b/src/main/packaged-resources/cfg/cheap-house-expression.cfg @@ -0,0 +1,10 @@ + +# At least one good route +( + (#timeToStation(#property, '東京駅') <= 85 && #fareToStation(#property, '東京駅') <= 650 && #transferToStation(#property, '東京駅') <= 1) + || (#timeToStation(#property, '三越前') <= 85 && #fareToStation(#property, '三越前') <= 650 && #transferToStation(#property, '三越前') <= 1) + || (#timeToStation(#property, '大手町(東京都)駅') <= 85 && #fareToStation(#property, '大手町(東京都)駅') <= 650 && #transferToStation(#property, '大手町(東京都)駅') <= 1) +) + +# Mistaken with Tokyo's Akasaka +&& !url.startsWith('https://suumo.jp/chukoikkodate/gumma/sc_maebashi') +\ No newline at end of file diff --git a/src/main/packaged-resources/cfg/search-conditions.cfg b/src/main/packaged-resources/cfg/search-conditions.cfg @@ -1,22 +0,0 @@ -area=KANTO -type=USED_HOUSE - -# Price of the house (in yens) -price.min= -price.max=20000000 - -# Age of the house (in years) -age.min= -age.max=20 - -# Surface of the house/land (in squared meters) -surface.land.min=100 -surface.land.max= -surface.house.min=80 -surface.house.max= - -# Time to go to the nearest station by foot (in minutes) -station.walktime.max= - -# More complex conditions in form of a spring expression -expression.file=search-expression.cfg diff --git a/src/main/packaged-resources/cfg/searches.json b/src/main/packaged-resources/cfg/searches.json @@ -0,0 +1,32 @@ +[ + { + "name": "Cheap house", + "area": "KANTO", + "type": "USED_HOUSE", + "price.min": null, + "price.max": 12000000, + "age.min": null, + "age.max": 20, + "surface.land.min": 100, + "surface.land.max": null, + "surface.house.min": 80, + "surface.house.max": null, + "station.walktime.max": null, + "expression.file": "cheap-house-expression.cfg" + }, + { + "name": "House close to Tokyo", + "area": "KANTO", + "type": "USED_HOUSE", + "price.min": null, + "price.max": 20000000, + "age.min": null, + "age.max": 20, + "surface.land.min": 100, + "surface.land.max": null, + "surface.house.min": 80, + "surface.house.max": null, + "station.walktime.max": null, + "expression.file": "tokyo-house-expression.cfg" + } +] +\ No newline at end of file diff --git a/src/main/packaged-resources/cfg/search-expression.cfg b/src/main/packaged-resources/cfg/tokyo-house-expression.cfg